From 261e143ff44baf3c5ea12c2ded32af34c1a7b7fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Tue, 11 Aug 2026 11:28:23 +0200 Subject: [PATCH] Implement OpenConfig provider for core resource types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add provider implementations for ACL, AAA, BGP, BGPPeer, ISIS, LLDP, NTP, PrefixSet, RoutingPolicy, Syslog, and VRF resources targeting Nokia SRLinux via gNMI. Use SetBuilder for multi-element operations and direct client calls for single-element operations. Guard unsupported fields with UnsupportedFieldError. Signed-off-by: Felix Kästner --- api/core/v1alpha1/prefix_types.go | 5 + api/core/v1alpha1/prefixset_types.go | 6 + internal/provider/openconfig/aaa.go | 223 +++++++++++ internal/provider/openconfig/acl.go | 213 +++++++++++ internal/provider/openconfig/bgp.go | 347 ++++++++++++++++++ internal/provider/openconfig/bgp_test.go | 34 ++ internal/provider/openconfig/bgppeer.go | 305 +++++++++++++++ internal/provider/openconfig/interface.go | 26 +- internal/provider/openconfig/isis.go | 241 ++++++++++++ internal/provider/openconfig/lldp.go | 84 +++++ internal/provider/openconfig/prefixset.go | 87 +++++ internal/provider/openconfig/routingpolicy.go | 183 +++++++++ internal/provider/openconfig/syslog.go | 156 ++++++++ internal/provider/openconfig/vrf.go | 87 +++++ test/gnmi/testdata/openconfig/bgp.txt | 69 ++++ test/gnmi/testdata/openconfig/dns.txt | 8 +- test/gnmi/testdata/openconfig/lldp.txt | 32 ++ test/gnmi/testdata/openconfig/prefixset.txt | 59 +++ .../testdata/openconfig/routingpolicy.txt | 123 +++++++ test/gnmi/testdata/openconfig/syslog.txt | 48 +++ test/gnmi/testdata/openconfig/vrf.txt | 31 ++ 21 files changed, 2363 insertions(+), 4 deletions(-) create mode 100644 internal/provider/openconfig/aaa.go create mode 100644 internal/provider/openconfig/acl.go create mode 100644 internal/provider/openconfig/bgp.go create mode 100644 internal/provider/openconfig/bgp_test.go create mode 100644 internal/provider/openconfig/bgppeer.go create mode 100644 internal/provider/openconfig/isis.go create mode 100644 internal/provider/openconfig/lldp.go create mode 100644 internal/provider/openconfig/prefixset.go create mode 100644 internal/provider/openconfig/routingpolicy.go create mode 100644 internal/provider/openconfig/syslog.go create mode 100644 internal/provider/openconfig/vrf.go create mode 100644 test/gnmi/testdata/openconfig/bgp.txt create mode 100644 test/gnmi/testdata/openconfig/lldp.txt create mode 100644 test/gnmi/testdata/openconfig/prefixset.txt create mode 100644 test/gnmi/testdata/openconfig/routingpolicy.txt create mode 100644 test/gnmi/testdata/openconfig/syslog.txt create mode 100644 test/gnmi/testdata/openconfig/vrf.txt diff --git a/api/core/v1alpha1/prefix_types.go b/api/core/v1alpha1/prefix_types.go index 4a1671d06..b6060395e 100644 --- a/api/core/v1alpha1/prefix_types.go +++ b/api/core/v1alpha1/prefix_types.go @@ -71,6 +71,11 @@ func (p *IPPrefix) UnmarshalJSON(data []byte) error { return nil } +// Is6 reports whether the prefix contains an IPv6 address. +func (p IPPrefix) Is6() bool { + return p.Addr().Is6() +} + // IsPointToPoint reports whether the prefix indicates a point-to-point link. // For IPv4, this means a /31 subnet mask as defined in [RFC 3021]. // For IPv6, this means a /127 subnet mask as defined in [RFC 6164]. diff --git a/api/core/v1alpha1/prefixset_types.go b/api/core/v1alpha1/prefixset_types.go index 97624d842..f80d433ce 100644 --- a/api/core/v1alpha1/prefixset_types.go +++ b/api/core/v1alpha1/prefixset_types.go @@ -4,6 +4,7 @@ package v1alpha1 import ( + "fmt" "sync" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -73,6 +74,11 @@ type MaskLengthRange struct { Max int8 `json:"max"` } +// String returns the mask length range in OpenConfig dot-dot notation, e.g. "16..24". +func (m MaskLengthRange) String() string { + return fmt.Sprintf("%d..%d", m.Min, m.Max) +} + // PrefixSetStatus defines the observed state of PrefixSet. type PrefixSetStatus struct { // EntriesSummary provides a human-readable summary of the number of prefix entries. diff --git a/internal/provider/openconfig/aaa.go b/internal/provider/openconfig/aaa.go new file mode 100644 index 000000000..d159ec511 --- /dev/null +++ b/internal/provider/openconfig/aaa.go @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package openconfig + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/apistatus" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" +) + +var _ provider.AAAProvider = (*Provider)(nil) + +func (p *Provider) EnsureAAA(ctx context.Context, req *provider.EnsureAAARequest) error { + spec := req.AAA.Spec + + sb := new(gnmiext.SetBuilder) + + for _, sg := range spec.ServerGroups { + if sg.Type == v1alpha1.AAAServerGroupTypeRADIUS { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.serverGroups[].type", + Description: "openconfig provider does not support RADIUS server groups on SRLinux", + }) + } + + group := &AAAServerGroup{ + Name: sg.Name, + Config: &AAAServerGroupConfig{ + Name: sg.Name, + Type: AAAServerGroupTypeTACACS, + }, + Servers: &AAAServers{}, + } + + for _, srv := range sg.Servers { + s := &AAAServer{ + Address: srv.Address, + Config: &AAAServerConfig{ + Address: srv.Address, + }, + } + if srv.Timeout != nil { + s.Config.Timeout = uint16(srv.Timeout.Seconds()) + } + if srv.TACACS != nil { + key := req.TACACSServerKeys[srv.Address] + s.TACACS = &AAAServerTACACS{ + Config: &AAAServerTACACSConfig{ + Port: uint16(srv.TACACS.Port), //nolint:gosec + SecretKey: key, + }, + } + } + group.Servers.Server.Set(s) + } + sb.Update(group) + } + + if spec.Authentication != nil { + methods := make([]AAAMethodType, 0, len(spec.Authentication.Methods)) + for _, m := range spec.Authentication.Methods { + methods = append(methods, toAAAMethod(m)) + } + sb.Update(&AAAAuthenticationConfig{Methods: methods}) + } + + if spec.Accounting != nil { + methods := make([]AAAMethodType, 0, len(spec.Accounting.Methods)) + for _, m := range spec.Accounting.Methods { + methods = append(methods, toAAAMethod(m)) + } + sb.Update(&AAAAccountingConfig{Methods: methods}) + } + + // Authorization — not supported on SRLinux OC (empty config options). + if spec.Authorization != nil { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.authorization", + Description: "openconfig provider does not support AAA authorization on SRLinux", + }) + } + + return p.client.Do(ctx, sb) +} + +func (p *Provider) DeleteAAA(ctx context.Context, req *provider.DeleteAAARequest) error { + sb := new(gnmiext.SetBuilder) + for _, sg := range req.AAA.Spec.ServerGroups { + sb.Delete(&AAAServerGroup{Name: sg.Name}) + } + if len(req.AAA.Spec.ServerGroups) == 0 { + sb.Delete(&AAAContainer{}) + } + return p.client.Do(ctx, sb) +} + +func toAAAMethod(m v1alpha1.AAAMethod) AAAMethodType { + switch m.Type { + case v1alpha1.AAAMethodTypeLocal: + return AAAMethodTypeLocal + case v1alpha1.AAAMethodTypeNone: + return AAAMethodTypeNone + case v1alpha1.AAAMethodTypeGroup: + return AAAMethodType(m.GroupName) + default: + return AAAMethodTypeLocal + } +} + +// AAAServerGroupType represents the OpenConfig AAA server group type identity. +type AAAServerGroupType string + +const ( + AAAServerGroupTypeTACACS AAAServerGroupType = "openconfig-aaa:TACACS" +) + +// AAAMethodType represents the AAA authentication/accounting method string. +type AAAMethodType string + +const ( + AAAMethodTypeLocal AAAMethodType = "local" + AAAMethodTypeNone AAAMethodType = "none" +) + +// Compile-time assertions. +var ( + _ gnmiext.DataElement = (*AAAServerGroup)(nil) + _ gnmiext.DataElement = (*AAAAuthenticationConfig)(nil) + _ gnmiext.DataElement = (*AAAAccountingConfig)(nil) + _ gnmiext.DataElement = (*AAAContainer)(nil) +) + +// AAAContainer targets the full AAA container for deletion. +type AAAContainer struct{} + +func (*AAAContainer) XPath() string { return "openconfig-system:system/aaa" } + +// AAAServerGroup targets a server-group entry. +type AAAServerGroup struct { + Name string `json:"-"` + Config *AAAServerGroupConfig `json:"config,omitempty"` + Servers *AAAServers `json:"servers,omitempty"` +} + +func (g *AAAServerGroup) XPath() string { + return fmt.Sprintf("openconfig-system:system/aaa/server-groups/server-group[name=%s]", g.Name) +} + +// AAAServerGroupConfig holds the server-group config. +type AAAServerGroupConfig struct { + Name string `json:"name"` + Type AAAServerGroupType `json:"type"` +} + +// AAAServers holds the server list. +type AAAServers struct { + Server gnmiext.List[string, *AAAServer] `json:"server,omitempty"` +} + +// AAAServer represents a single server entry. +type AAAServer struct { + Address string `json:"address"` + Config *AAAServerConfig `json:"config,omitempty"` + TACACS *AAAServerTACACS `json:"tacacs,omitempty"` +} + +func (s *AAAServer) Key() string { return s.Address } + +// AAAServerConfig holds the server config. +type AAAServerConfig struct { + Address string `json:"address"` + Timeout uint16 `json:"timeout,omitempty"` +} + +// AAAServerTACACS holds the tacacs container. +type AAAServerTACACS struct { + Config *AAAServerTACACSConfig `json:"config,omitempty"` +} + +// AAAServerTACACSConfig holds tacacs config. +// SecretKey is write-only — the device returns an encrypted form that +// would never match the plaintext, so we exclude it from unmarshal to +// avoid perpetual diffs. +type AAAServerTACACSConfig struct { + Port uint16 `json:"port,omitempty"` + SecretKey string `json:"secret-key,omitempty"` +} + +func (c *AAAServerTACACSConfig) UnmarshalJSON(data []byte) error { + type alias struct { + Port uint16 `json:"port,omitempty"` + } + var a alias + if err := json.Unmarshal(data, &a); err != nil { + return err + } + c.Port = a.Port + return nil +} + +// AAAAuthenticationConfig targets aaa/authentication/config. +type AAAAuthenticationConfig struct { + Methods []AAAMethodType `json:"authentication-method"` +} + +func (*AAAAuthenticationConfig) XPath() string { + return "openconfig-system:system/aaa/authentication/config" +} + +// AAAAccountingConfig targets aaa/accounting/config. +type AAAAccountingConfig struct { + Methods []AAAMethodType `json:"accounting-method"` +} + +func (*AAAAccountingConfig) XPath() string { + return "openconfig-system:system/aaa/accounting/config" +} diff --git a/internal/provider/openconfig/acl.go b/internal/provider/openconfig/acl.go new file mode 100644 index 000000000..ca1adb464 --- /dev/null +++ b/internal/provider/openconfig/acl.go @@ -0,0 +1,213 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package openconfig + +import ( + "context" + "fmt" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" +) + +var _ provider.ACLProvider = (*Provider)(nil) + +func (p *Provider) EnsureACL(ctx context.Context, req *provider.EnsureACLRequest) error { + spec := req.ACL.Spec + + // Determine ACL type based on first entry's source address. + aclType := ACLTypeIPv4 + if len(spec.Entries) > 0 { + if spec.Entries[0].SourceAddress.Is6() { + aclType = ACLTypeIPv6 + } + } + + acl := &ACLSet{ + Name: spec.Name, + ACLType: aclType, + Config: &ACLSetConfig{ + Name: spec.Name, + Type: aclType, + }, + Entries: &ACLEntries{}, + } + + for _, entry := range spec.Entries { + e := &ACLEntry{ + SequenceID: uint32(entry.Sequence), //nolint:gosec + Config: &ACLEntryConfig{ + SequenceID: uint32(entry.Sequence), //nolint:gosec + Description: entry.Description, + }, + Actions: &ACLEntryActions{ + Config: &ACLEntryActionsConfig{ + ForwardingAction: toACLForwardingAction(entry.Action), + }, + }, + } + + if aclType == ACLTypeIPv4 { + e.IPv4 = &ACLIPv4{ + Config: &ACLIPv4Config{ + SourceAddress: entry.SourceAddress.String(), + DestinationAddress: entry.DestinationAddress.String(), + Protocol: toACLProtocol(entry.Protocol), + }, + } + } else { + e.IPv6 = &ACLIPv6{ + Config: &ACLIPv6Config{ + SourceAddress: entry.SourceAddress.String(), + DestinationAddress: entry.DestinationAddress.String(), + Protocol: toACLProtocol(entry.Protocol), + }, + } + } + + acl.Entries.Entry.Set(e) + } + + return p.client.Update(ctx, acl) +} + +func (p *Provider) DeleteACL(ctx context.Context, req *provider.DeleteACLRequest) error { + // Delete both possible types — only one will exist. + aclV4 := &ACLSet{Name: req.Name, ACLType: ACLTypeIPv4} + aclV6 := &ACLSet{Name: req.Name, ACLType: ACLTypeIPv6} + return p.client.Delete(ctx, aclV4, aclV6) +} + +func toACLForwardingAction(a v1alpha1.ACLAction) ACLForwardingAction { + switch a { + case v1alpha1.ActionPermit: + return ACLForwardingActionAccept + case v1alpha1.ActionDeny: + return ACLForwardingActionDrop + default: + return ACLForwardingActionDrop + } +} + +func toACLProtocol(proto v1alpha1.Protocol) ACLProtocol { + switch proto { + case v1alpha1.ProtocolICMP: + return ACLProtocolICMP + case v1alpha1.ProtocolTCP: + return ACLProtocolTCP + case v1alpha1.ProtocolUDP: + return ACLProtocolUDP + case v1alpha1.ProtocolOSPF: + return ACLProtocolOSPF + case v1alpha1.ProtocolPIM: + return ACLProtocolPIM + default: + return "" // IP (any) + } +} + +// ACLType represents the OpenConfig ACL set type identity. +type ACLType string + +const ( + ACLTypeIPv4 ACLType = "openconfig-acl:ACL_IPV4" + ACLTypeIPv6 ACLType = "openconfig-acl:ACL_IPV6" +) + +// ACLForwardingAction represents the OpenConfig ACL forwarding action. +type ACLForwardingAction string + +const ( + ACLForwardingActionAccept ACLForwardingAction = "openconfig-acl:ACCEPT" + ACLForwardingActionDrop ACLForwardingAction = "openconfig-acl:DROP" +) + +// ACLProtocol represents an IP protocol number used in ACL entries. +type ACLProtocol string + +const ( + ACLProtocolICMP ACLProtocol = "1" + ACLProtocolTCP ACLProtocol = "6" + ACLProtocolUDP ACLProtocol = "17" + ACLProtocolOSPF ACLProtocol = "89" + ACLProtocolPIM ACLProtocol = "103" +) + +// Compile-time assertion. +var _ gnmiext.DataElement = (*ACLSet)(nil) + +// ACLSet represents an OC acl-set list entry. +type ACLSet struct { + Name string `json:"-"` + ACLType ACLType `json:"-"` + Config *ACLSetConfig `json:"config,omitempty"` + Entries *ACLEntries `json:"acl-entries,omitempty"` +} + +func (a *ACLSet) XPath() string { + return fmt.Sprintf("openconfig-acl:acl/acl-sets/acl-set[name=%s][type=%s]", a.Name, a.ACLType) +} + +// ACLSetConfig holds the acl-set config. +type ACLSetConfig struct { + Name string `json:"name"` + Type ACLType `json:"type"` +} + +// ACLEntries holds the acl-entry list. +type ACLEntries struct { + Entry gnmiext.List[uint32, *ACLEntry] `json:"acl-entry,omitempty"` +} + +// ACLEntry represents a single ACL entry. +type ACLEntry struct { + SequenceID uint32 `json:"sequence-id"` + Config *ACLEntryConfig `json:"config,omitempty"` + IPv4 *ACLIPv4 `json:"ipv4,omitempty"` + IPv6 *ACLIPv6 `json:"ipv6,omitempty"` + Actions *ACLEntryActions `json:"actions,omitempty"` +} + +func (e *ACLEntry) Key() uint32 { return e.SequenceID } + +// ACLEntryConfig holds the entry config. +type ACLEntryConfig struct { + SequenceID uint32 `json:"sequence-id"` + Description string `json:"description,omitempty"` +} + +// ACLIPv4 holds the IPv4 match criteria. +type ACLIPv4 struct { + Config *ACLIPv4Config `json:"config,omitempty"` +} + +// ACLIPv4Config holds IPv4 match config. +type ACLIPv4Config struct { + SourceAddress string `json:"source-address,omitempty"` + DestinationAddress string `json:"destination-address,omitempty"` + Protocol ACLProtocol `json:"protocol,omitempty"` +} + +// ACLIPv6 holds the IPv6 match criteria. +type ACLIPv6 struct { + Config *ACLIPv6Config `json:"config,omitempty"` +} + +// ACLIPv6Config holds IPv6 match config. +type ACLIPv6Config struct { + SourceAddress string `json:"source-address,omitempty"` + DestinationAddress string `json:"destination-address,omitempty"` + Protocol ACLProtocol `json:"protocol,omitempty"` +} + +// ACLEntryActions holds the actions for an ACL entry. +type ACLEntryActions struct { + Config *ACLEntryActionsConfig `json:"config,omitempty"` +} + +// ACLEntryActionsConfig holds the actions config. +type ACLEntryActionsConfig struct { + ForwardingAction ACLForwardingAction `json:"forwarding-action"` +} diff --git a/internal/provider/openconfig/bgp.go b/internal/provider/openconfig/bgp.go new file mode 100644 index 000000000..f745586ac --- /dev/null +++ b/internal/provider/openconfig/bgp.go @@ -0,0 +1,347 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package openconfig + +import ( + "context" + "fmt" + "strconv" + "strings" + + "k8s.io/apimachinery/pkg/util/intstr" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/apistatus" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" +) + +var _ provider.BGPProvider = (*Provider)(nil) + +// DefaultNetworkInstance is the OpenConfig network-instance name for the default VRF. +const DefaultNetworkInstance = "default" + +func (p *Provider) EnsureBGP(ctx context.Context, req *provider.EnsureBGPRequest) error { + if req.BGP.Spec.AdminState == v1alpha1.AdminStateDown { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.adminState", + Description: "openconfig provider does not support disabling BGP (adminState Down)", + }) + } + + spec := req.BGP.Spec + + asn, err := asnToUint32(spec.ASNumber) + if err != nil { + return err + } + + ni := DefaultNetworkInstance + if req.VRF != nil { + ni = req.VRF.Spec.Name + } + + var afiSafis *BGPAfiSafis + if af := spec.AddressFamilies; af != nil { + if af.L2vpnEvpn != nil { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.addressFamilies.l2vpnEvpn", + Description: "openconfig provider does not support L2VPN EVPN address family", + }) + } + afiSafis = &BGPAfiSafis{} + if af.Ipv4Unicast != nil { + safi := &BGPAfiSafi{ + AfiSafiName: BGPAfiSafiTypeIPv4Unicast, + Config: &BGPAfiSafiConfig{ + AfiSafiName: BGPAfiSafiTypeIPv4Unicast, + Enabled: af.Ipv4Unicast.Enabled, + }, + } + if mp := af.Ipv4Unicast.Multipath; mp != nil { + ump := &BGPUseMultiplePaths{} + if mp.Ebgp != nil { + ump.Ebgp = &BGPEbgpMultipath{Config: &BGPEbgpMultipathConfig{ + AllowMultipleAS: mp.Ebgp.AllowMultipleAs, + MaximumPaths: uint32(mp.Ebgp.MaximumPaths), //nolint:gosec + }} + } + if mp.Ibgp != nil { + ump.Ibgp = &BGPIbgpMultipath{Config: &BGPIbgpMultipathConfig{ + MaximumPaths: uint32(mp.Ibgp.MaximumPaths), //nolint:gosec + }} + } + safi.UseMultiplePaths = ump + } + afiSafis.AfiSafi.Set(safi) + } + if af.Ipv6Unicast != nil { + safi := &BGPAfiSafi{ + AfiSafiName: BGPAfiSafiTypeIPv6Unicast, + Config: &BGPAfiSafiConfig{ + AfiSafiName: BGPAfiSafiTypeIPv6Unicast, + Enabled: af.Ipv6Unicast.Enabled, + }, + } + if mp := af.Ipv6Unicast.Multipath; mp != nil { + ump := &BGPUseMultiplePaths{} + if mp.Ebgp != nil { + ump.Ebgp = &BGPEbgpMultipath{Config: &BGPEbgpMultipathConfig{ + AllowMultipleAS: mp.Ebgp.AllowMultipleAs, + MaximumPaths: uint32(mp.Ebgp.MaximumPaths), //nolint:gosec + }} + } + if mp.Ibgp != nil { + ump.Ibgp = &BGPIbgpMultipath{Config: &BGPIbgpMultipathConfig{ + MaximumPaths: uint32(mp.Ibgp.MaximumPaths), //nolint:gosec + }} + } + safi.UseMultiplePaths = ump + } + afiSafis.AfiSafi.Set(safi) + } + } + + proto := &BGPProtocol{ + NetworkInstance: ni, + Config: &BGPProtocolConfig{ + Identifier: PolicyTypeBGP, + Name: "BGP", + }, + BGP: &BGP{ + Global: &BGPGlobal{ + Config: &BGPGlobalConfig{ + AS: asn, + RouterID: spec.RouterID, + }, + AfiSafis: afiSafis, + }, + }, + } + + sb := new(gnmiext.SetBuilder) + sb.Update(proto) + + // Handle redistribute direct routes via table-connections. + for afType, policy := range req.RedistributeDirectRoutePolicies { + af, err := toAddressFamily(afType) + if err != nil { + return err + } + tc := &TableConnection{ + NetworkInstance: ni, + SrcProtocol: PolicyTypeDirectlyConnected, + DstProtocol: PolicyTypeBGP, + AddressFamily: af, + Config: &TableConnectionConfig{ + SrcProtocol: PolicyTypeDirectlyConnected, + DstProtocol: PolicyTypeBGP, + AddressFamily: af, + ImportPolicy: []string{policy.Spec.Name}, + }, + } + sb.Update(tc) + } + + return p.client.Do(ctx, sb) +} + +func (p *Provider) DeleteBGP(ctx context.Context, req *provider.DeleteBGPRequest) error { + ni := DefaultNetworkInstance + if req.VRF != nil { + ni = req.VRF.Spec.Name + } + + proto := &BGPProtocol{NetworkInstance: ni} + + // Also clean up any table-connections for redistribution. + tcIPv4 := &TableConnection{ + NetworkInstance: ni, + SrcProtocol: PolicyTypeDirectlyConnected, + DstProtocol: PolicyTypeBGP, + AddressFamily: AddressFamilyIPv4, + } + tcIPv6 := &TableConnection{ + NetworkInstance: ni, + SrcProtocol: PolicyTypeDirectlyConnected, + DstProtocol: PolicyTypeBGP, + AddressFamily: AddressFamilyIPv6, + } + + return p.client.Delete(ctx, proto, tcIPv4, tcIPv6) +} + +// toOCAddressFamily converts a BGPAddressFamilyType to the OpenConfig address-family identity. +func toAddressFamily(af v1alpha1.BGPAddressFamilyType) (AddressFamily, error) { + switch af { + case v1alpha1.BGPAddressFamilyIpv4Unicast: + return AddressFamilyIPv4, nil + case v1alpha1.BGPAddressFamilyIpv6Unicast: + return AddressFamilyIPv6, nil + default: + return "", apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.addressFamilies", + Description: fmt.Sprintf("unsupported address family %q for table-connection", af), + }) + } +} + +// asnToUint32 converts an IntOrString ASN (plain or dotted notation) to a uint32. +func asnToUint32(asn intstr.IntOrString) (uint32, error) { + if asn.Type == intstr.Int { + return uint32(asn.IntVal), nil //nolint:gosec + } + s := asn.StrVal + if !strings.Contains(s, ".") { + v, err := strconv.ParseUint(s, 10, 32) + if err != nil { + return 0, fmt.Errorf("invalid AS number %q: %w", s, err) + } + return uint32(v), nil + } + // Dotted notation: high.low → (high * 65536) + low + parts := strings.SplitN(s, ".", 2) + high, err := strconv.ParseUint(parts[0], 10, 16) + if err != nil { + return 0, fmt.Errorf("invalid AS number %q: %w", s, err) + } + low, err := strconv.ParseUint(parts[1], 10, 16) + if err != nil { + return 0, fmt.Errorf("invalid AS number %q: %w", s, err) + } + return uint32(high*65536) + uint32(low), nil +} + +// PolicyType represents the OpenConfig policy-types protocol identity. +type PolicyType string + +const ( + PolicyTypeBGP PolicyType = "openconfig-policy-types:BGP" + PolicyTypeISIS PolicyType = "openconfig-policy-types:ISIS" + PolicyTypeDirectlyConnected PolicyType = "openconfig-policy-types:DIRECTLY_CONNECTED" +) + +// AddressFamily represents the OpenConfig address-family identity for table connections. +type AddressFamily string + +const ( + AddressFamilyIPv4 AddressFamily = "openconfig-types:IPV4" + AddressFamilyIPv6 AddressFamily = "openconfig-types:IPV6" +) + +// BGPAfiSafiType represents the OpenConfig BGP AFI-SAFI type identity. +type BGPAfiSafiType string + +const ( + BGPAfiSafiTypeIPv4Unicast BGPAfiSafiType = "openconfig-bgp-types:IPV4_UNICAST" + BGPAfiSafiTypeIPv6Unicast BGPAfiSafiType = "openconfig-bgp-types:IPV6_UNICAST" +) + +// Compile-time assertions. +var ( + _ gnmiext.DataElement = (*BGPProtocol)(nil) + _ gnmiext.DataElement = (*TableConnection)(nil) +) + +// BGPProtocol represents the OC protocol[identifier=BGP] list entry. +type BGPProtocol struct { + NetworkInstance string `json:"-"` + Config *BGPProtocolConfig `json:"config,omitempty"` + BGP *BGP `json:"bgp,omitempty"` +} + +func (b *BGPProtocol) XPath() string { + return fmt.Sprintf("openconfig-network-instance:network-instances/network-instance[name=%s]/protocols/protocol[identifier=openconfig-policy-types:BGP][name=BGP]", b.NetworkInstance) +} + +// BGPProtocolConfig holds the config for the protocol list entry. +type BGPProtocolConfig struct { + Identifier PolicyType `json:"identifier"` + Name string `json:"name"` +} + +// BGP holds the bgp container. +type BGP struct { + Global *BGPGlobal `json:"global,omitempty"` +} + +// BGPGlobal holds the bgp/global container. +type BGPGlobal struct { + Config *BGPGlobalConfig `json:"config,omitempty"` + AfiSafis *BGPAfiSafis `json:"afi-safis,omitempty"` +} + +// BGPGlobalConfig holds bgp/global/config. +type BGPGlobalConfig struct { + AS uint32 `json:"as"` + RouterID string `json:"router-id"` +} + +// BGPAfiSafis holds the afi-safis container. +type BGPAfiSafis struct { + AfiSafi gnmiext.List[string, *BGPAfiSafi] `json:"afi-safi,omitempty"` +} + +// BGPAfiSafi represents a single afi-safi list entry. +type BGPAfiSafi struct { + AfiSafiName BGPAfiSafiType `json:"afi-safi-name"` + Config *BGPAfiSafiConfig `json:"config,omitempty"` + UseMultiplePaths *BGPUseMultiplePaths `json:"use-multiple-paths,omitempty"` +} + +func (a *BGPAfiSafi) Key() string { return string(a.AfiSafiName) } + +// BGPAfiSafiConfig holds afi-safi/config. +type BGPAfiSafiConfig struct { + AfiSafiName BGPAfiSafiType `json:"afi-safi-name"` + Enabled bool `json:"enabled"` +} + +// BGPUseMultiplePaths holds the use-multiple-paths container. +type BGPUseMultiplePaths struct { + Ebgp *BGPEbgpMultipath `json:"ebgp,omitempty"` + Ibgp *BGPIbgpMultipath `json:"ibgp,omitempty"` +} + +// BGPEbgpMultipath holds use-multiple-paths/ebgp. +type BGPEbgpMultipath struct { + Config *BGPEbgpMultipathConfig `json:"config,omitempty"` +} + +// BGPEbgpMultipathConfig holds use-multiple-paths/ebgp/config. +type BGPEbgpMultipathConfig struct { + AllowMultipleAS bool `json:"allow-multiple-as,omitempty"` + MaximumPaths uint32 `json:"maximum-paths,omitempty"` +} + +// BGPIbgpMultipath holds use-multiple-paths/ibgp. +type BGPIbgpMultipath struct { + Config *BGPIbgpMultipathConfig `json:"config,omitempty"` +} + +// BGPIbgpMultipathConfig holds use-multiple-paths/ibgp/config. +type BGPIbgpMultipathConfig struct { + MaximumPaths uint32 `json:"maximum-paths,omitempty"` +} + +// TableConnection represents a table-connection list entry for route redistribution. +type TableConnection struct { + NetworkInstance string `json:"-"` + SrcProtocol PolicyType `json:"-"` + DstProtocol PolicyType `json:"-"` + AddressFamily AddressFamily `json:"-"` + Config *TableConnectionConfig `json:"config,omitempty"` +} + +func (tc *TableConnection) XPath() string { + return fmt.Sprintf("openconfig-network-instance:network-instances/network-instance[name=%s]/table-connections/table-connection[src-protocol=%s][dst-protocol=%s][address-family=%s]", tc.NetworkInstance, tc.SrcProtocol, tc.DstProtocol, tc.AddressFamily) +} + +// TableConnectionConfig holds table-connection/config. +type TableConnectionConfig struct { + SrcProtocol PolicyType `json:"src-protocol"` + DstProtocol PolicyType `json:"dst-protocol"` + AddressFamily AddressFamily `json:"address-family"` + ImportPolicy []string `json:"import-policy,omitempty"` +} diff --git a/internal/provider/openconfig/bgp_test.go b/internal/provider/openconfig/bgp_test.go new file mode 100644 index 000000000..16d89e365 --- /dev/null +++ b/internal/provider/openconfig/bgp_test.go @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package openconfig + +import ( + "testing" + + "k8s.io/apimachinery/pkg/util/intstr" +) + +func TestAsnToUint32(t *testing.T) { + tests := []struct { + name string + asn intstr.IntOrString + want uint32 + }{ + {"int value", intstr.FromInt32(65000), 65000}, + {"string plain", intstr.FromString("4294967295"), 4294967295}, + {"string dotted", intstr.FromString("1.1"), 65537}, + {"string dotted large", intstr.FromString("65535.65535"), 4294967295}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := asnToUint32(test.asn) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != test.want { + t.Errorf("asnToUint32(%v) = %d, want %d", test.asn, got, test.want) + } + }) + } +} diff --git a/internal/provider/openconfig/bgppeer.go b/internal/provider/openconfig/bgppeer.go new file mode 100644 index 000000000..edb6f814d --- /dev/null +++ b/internal/provider/openconfig/bgppeer.go @@ -0,0 +1,305 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package openconfig + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/apistatus" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" +) + +var _ provider.BGPPeerProvider = (*Provider)(nil) + +// bgpPeerGroupName is the default peer-group name used for all neighbors. +// SRLinux requires a peer-group on every neighbor via OpenConfig. +const bgpPeerGroupName = "NETOP-DEFAULT" + +func (p *Provider) EnsureBGPPeer(ctx context.Context, req *provider.EnsureBGPPeerRequest) error { + spec := req.BGPPeer.Spec + + peerAS, err := asnToUint32(spec.ASNumber) + if err != nil { + return err + } + + ni := DefaultNetworkInstance + if req.VRF != nil { + ni = req.VRF.Spec.Name + } + + pg := &BGPPeerGroup{ + NetworkInstance: ni, + PeerGroupName: bgpPeerGroupName, + Config: &BGPPeerGroupConfig{ + PeerGroupName: bgpPeerGroupName, + }, + } + + neighbor := &BGPNeighbor{ + NetworkInstance: ni, + NeighborAddress: spec.Address, + Config: &BGPNeighborConfig{ + NeighborAddress: spec.Address, + PeerAS: peerAS, + PeerGroup: bgpPeerGroupName, + Enabled: spec.AdminState != v1alpha1.AdminStateDown, + Description: spec.Description, + }, + } + + if spec.LocalAS != nil { + localAS, err := asnToUint32(spec.LocalAS.ASNumber) + if err != nil { + return err + } + neighbor.Config.LocalAS = localAS + // Note: PrependLocalAS / PrependGlobalAS not supported via OC local-as leaf. + if spec.LocalAS.PrependLocalAS != nil && !*spec.LocalAS.PrependLocalAS { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.localAS.prependLocalAS", + Description: "openconfig provider does not support disabling local-AS prepend on SRLinux", + }) + } + if spec.LocalAS.PrependGlobalAS != nil && !*spec.LocalAS.PrependGlobalAS { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.localAS.prependGlobalAS", + Description: "openconfig provider does not support disabling global-AS prepend on SRLinux", + }) + } + } + + if req.SourceInterface != "" { + neighbor.Transport = &BGPNeighborTransport{ + Config: &BGPNeighborTransportConfig{ + LocalAddress: req.SourceInterface, + }, + } + } + + if spec.AddressFamilies != nil { + neighbor.AfiSafis = &BGPNeighborAfiSafis{} + if af := spec.AddressFamilies.Ipv4Unicast; af != nil { + safi := &BGPNeighborAfiSafi{ + AfiSafiName: BGPAfiSafiTypeIPv4Unicast, + Config: &BGPNeighborAfiSafiConfig{ + AfiSafiName: BGPAfiSafiTypeIPv4Unicast, + Enabled: af.Enabled, + }, + } + if policy, ok := req.InboundRoutingPolicies[v1alpha1.BGPAddressFamilyIpv4Unicast]; ok { + safi.ApplyPolicy = &BGPNeighborApplyPolicy{ + Config: &BGPNeighborApplyPolicyConfig{ + ImportPolicy: []string{policy}, + }, + } + } + if policy, ok := req.OutboundRoutingPolicies[v1alpha1.BGPAddressFamilyIpv4Unicast]; ok { + if safi.ApplyPolicy == nil { + safi.ApplyPolicy = &BGPNeighborApplyPolicy{Config: &BGPNeighborApplyPolicyConfig{}} + } + safi.ApplyPolicy.Config.ExportPolicy = []string{policy} + } + neighbor.AfiSafis.AfiSafi.Set(safi) + } + if af := spec.AddressFamilies.Ipv6Unicast; af != nil { + safi := &BGPNeighborAfiSafi{ + AfiSafiName: BGPAfiSafiTypeIPv6Unicast, + Config: &BGPNeighborAfiSafiConfig{ + AfiSafiName: BGPAfiSafiTypeIPv6Unicast, + Enabled: af.Enabled, + }, + } + if policy, ok := req.InboundRoutingPolicies[v1alpha1.BGPAddressFamilyIpv6Unicast]; ok { + safi.ApplyPolicy = &BGPNeighborApplyPolicy{ + Config: &BGPNeighborApplyPolicyConfig{ + ImportPolicy: []string{policy}, + }, + } + } + if policy, ok := req.OutboundRoutingPolicies[v1alpha1.BGPAddressFamilyIpv6Unicast]; ok { + if safi.ApplyPolicy == nil { + safi.ApplyPolicy = &BGPNeighborApplyPolicy{Config: &BGPNeighborApplyPolicyConfig{}} + } + safi.ApplyPolicy.Config.ExportPolicy = []string{policy} + } + neighbor.AfiSafis.AfiSafi.Set(safi) + } + if af := spec.AddressFamilies.L2vpnEvpn; af != nil && af.Enabled { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.addressFamilies.l2vpnEvpn", + Description: "openconfig provider does not support L2VPN EVPN on SRLinux", + }) + } + } + + return p.client.Update(ctx, pg, neighbor) +} + +func (p *Provider) DeleteBGPPeer(ctx context.Context, req *provider.DeleteBGPPeerRequest) error { + ni := DefaultNetworkInstance + if req.VRF != nil { + ni = req.VRF.Spec.Name + } + neighbor := &BGPNeighbor{ + NetworkInstance: ni, + NeighborAddress: req.BGPPeer.Spec.Address, + } + return p.client.Delete(ctx, neighbor) +} + +func (p *Provider) GetPeerStatus(ctx context.Context, req *provider.BGPPeerStatusRequest) (provider.BGPPeerStatus, error) { + ni := DefaultNetworkInstance + if req.VRF != nil { + ni = req.VRF.Spec.Name + } + + state := &BGPNeighborState{ + NetworkInstance: ni, + NeighborAddress: req.BGPPeer.Spec.Address, + } + if err := p.client.GetState(ctx, state); err != nil { + return provider.BGPPeerStatus{}, err + } + + return provider.BGPPeerStatus{ + SessionState: toBGPSessionState(state.SessionState), + LastEstablishedTime: state.LastEstablished, + }, nil +} + +func toBGPSessionState(s string) v1alpha1.BGPPeerSessionState { + switch BGPSessionState(strings.ToUpper(s)) { + case BGPSessionStateIdle: + return v1alpha1.BGPPeerSessionStateIdle + case BGPSessionStateConnect: + return v1alpha1.BGPPeerSessionStateConnect + case BGPSessionStateActive: + return v1alpha1.BGPPeerSessionStateActive + case BGPSessionStateOpenSent: + return v1alpha1.BGPPeerSessionStateOpenSent + case BGPSessionStateOpenConfirm: + return v1alpha1.BGPPeerSessionStateOpenConfirm + case BGPSessionStateEstablished: + return v1alpha1.BGPPeerSessionStateEstablished + default: + return v1alpha1.BGPPeerSessionStateUnknown + } +} + +// BGPSessionState represents the OpenConfig BGP session state. +type BGPSessionState string + +const ( + BGPSessionStateIdle BGPSessionState = "IDLE" + BGPSessionStateConnect BGPSessionState = "CONNECT" + BGPSessionStateActive BGPSessionState = "ACTIVE" + BGPSessionStateOpenSent BGPSessionState = "OPENSENT" + BGPSessionStateOpenConfirm BGPSessionState = "OPENCONFIRM" + BGPSessionStateEstablished BGPSessionState = "ESTABLISHED" +) + +// Compile-time assertions. +var ( + _ gnmiext.DataElement = (*BGPPeerGroup)(nil) + _ gnmiext.DataElement = (*BGPNeighbor)(nil) + _ gnmiext.DataElement = (*BGPNeighborState)(nil) +) + +// BGPPeerGroup targets a peer-group entry. +type BGPPeerGroup struct { + NetworkInstance string `json:"-"` + PeerGroupName string `json:"-"` + Config *BGPPeerGroupConfig `json:"config,omitempty"` +} + +func (pg *BGPPeerGroup) XPath() string { + return fmt.Sprintf("openconfig-network-instance:network-instances/network-instance[name=%s]/protocols/protocol[identifier=openconfig-policy-types:BGP][name=BGP]/bgp/peer-groups/peer-group[peer-group-name=%s]", pg.NetworkInstance, pg.PeerGroupName) +} + +// BGPPeerGroupConfig holds peer-group config. +type BGPPeerGroupConfig struct { + PeerGroupName string `json:"peer-group-name"` +} + +// BGPNeighbor targets a neighbor entry. +type BGPNeighbor struct { + NetworkInstance string `json:"-"` + NeighborAddress string `json:"-"` + Config *BGPNeighborConfig `json:"config,omitempty"` + Transport *BGPNeighborTransport `json:"transport,omitempty"` + AfiSafis *BGPNeighborAfiSafis `json:"afi-safis,omitempty"` +} + +func (n *BGPNeighbor) XPath() string { + return fmt.Sprintf("openconfig-network-instance:network-instances/network-instance[name=%s]/protocols/protocol[identifier=openconfig-policy-types:BGP][name=BGP]/bgp/neighbors/neighbor[neighbor-address=%s]", n.NetworkInstance, n.NeighborAddress) +} + +// BGPNeighborConfig holds neighbor config. +type BGPNeighborConfig struct { + NeighborAddress string `json:"neighbor-address"` + PeerAS uint32 `json:"peer-as"` + PeerGroup string `json:"peer-group"` + Enabled bool `json:"enabled"` + Description string `json:"description,omitempty"` + LocalAS uint32 `json:"local-as,omitempty"` +} + +// BGPNeighborTransport holds transport config. +type BGPNeighborTransport struct { + Config *BGPNeighborTransportConfig `json:"config,omitempty"` +} + +// BGPNeighborTransportConfig holds transport/config. +type BGPNeighborTransportConfig struct { + LocalAddress string `json:"local-address,omitempty"` +} + +// BGPNeighborAfiSafis holds the per-neighbor afi-safis. +type BGPNeighborAfiSafis struct { + AfiSafi gnmiext.List[string, *BGPNeighborAfiSafi] `json:"afi-safi,omitempty"` +} + +// BGPNeighborAfiSafi represents a per-neighbor afi-safi. +type BGPNeighborAfiSafi struct { + AfiSafiName BGPAfiSafiType `json:"afi-safi-name"` + Config *BGPNeighborAfiSafiConfig `json:"config,omitempty"` + ApplyPolicy *BGPNeighborApplyPolicy `json:"apply-policy,omitempty"` +} + +func (a *BGPNeighborAfiSafi) Key() string { return string(a.AfiSafiName) } + +// BGPNeighborAfiSafiConfig holds per-neighbor afi-safi config. +type BGPNeighborAfiSafiConfig struct { + AfiSafiName BGPAfiSafiType `json:"afi-safi-name"` + Enabled bool `json:"enabled"` +} + +// BGPNeighborApplyPolicy holds apply-policy for a neighbor afi-safi. +type BGPNeighborApplyPolicy struct { + Config *BGPNeighborApplyPolicyConfig `json:"config,omitempty"` +} + +// BGPNeighborApplyPolicyConfig holds apply-policy config. +type BGPNeighborApplyPolicyConfig struct { + ImportPolicy []string `json:"import-policy,omitempty"` + ExportPolicy []string `json:"export-policy,omitempty"` +} + +// BGPNeighborState reads neighbor state for session-state. +type BGPNeighborState struct { + NetworkInstance string `json:"-"` + NeighborAddress string `json:"-"` + SessionState string `json:"session-state,omitempty"` + LastEstablished time.Time `json:"last-established,omitzero"` +} + +func (s *BGPNeighborState) XPath() string { + return fmt.Sprintf("openconfig-network-instance:network-instances/network-instance[name=%s]/protocols/protocol[identifier=openconfig-policy-types:BGP][name=BGP]/bgp/neighbors/neighbor[neighbor-address=%s]/state", s.NetworkInstance, s.NeighborAddress) +} diff --git a/internal/provider/openconfig/interface.go b/internal/provider/openconfig/interface.go index 76c9788d2..4e4a31c2f 100644 --- a/internal/provider/openconfig/interface.go +++ b/internal/provider/openconfig/interface.go @@ -57,9 +57,15 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte i.Config.Type = InterfaceTypeIEEE8023adLag i.Aggregation = &InterfaceAggregation{ Config: &InterfaceAggregationConfig{ - LagType: "LACP", + LagType: LagTypeLACP, }, } + if req.MultiChassisID != nil { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.aggregation.multichassis", + Description: "openconfig provider does not support multi-chassis LAG on SRLinux", + }) + } case v1alpha1.InterfaceTypeRoutedVLAN: i.Config.Type = InterfaceTypeL3IPVlan @@ -135,6 +141,13 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte i.Subinterfaces = subs } + if spec.BFD != nil { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.bfd", + Description: "openconfig provider does not support BFD on SRLinux", + }) + } + return p.client.Update(ctx, i) } @@ -189,7 +202,7 @@ func (p *Provider) GetInterfaceStatus(ctx context.Context, req *provider.Interfa }, nil } -func (p *Provider) InterfaceNameEqual(_ context.Context, a, b string) (bool, error) { +func (*Provider) InterfaceNameEqual(_ context.Context, a, b string) (bool, error) { return a == b, nil } @@ -332,6 +345,13 @@ const ( InterfaceTPIDDot1Q InterfaceTPID = "TPID_0X8100" ) +// LagType represents the OpenConfig LAG type identity. +type LagType string + +const ( + LagTypeLACP LagType = "LACP" +) + // Compile-time assertions. var ( _ gnmiext.DataElement = (*Interface)(nil) @@ -539,7 +559,7 @@ type InterfaceAggregation struct { // InterfaceAggregationConfig holds the config container for aggregation. type InterfaceAggregationConfig struct { - LagType string `json:"lag-type,omitempty"` + LagType LagType `json:"lag-type,omitempty"` MinLinks *uint16 `json:"min-links,omitempty"` } diff --git a/internal/provider/openconfig/isis.go b/internal/provider/openconfig/isis.go new file mode 100644 index 000000000..4f28bd724 --- /dev/null +++ b/internal/provider/openconfig/isis.go @@ -0,0 +1,241 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package openconfig + +import ( + "context" + "fmt" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/apistatus" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" +) + +var _ provider.ISISProvider = (*Provider)(nil) + +func (p *Provider) EnsureISIS(ctx context.Context, req *provider.EnsureISISRequest) error { + spec := req.ISIS.Spec + + ni := DefaultNetworkInstance + + levelCap, err := toISISLevelCapability(spec.Type) + if err != nil { + return err + } + + proto := &ISISProtocol{ + NetworkInstance: ni, + Name: spec.Instance, + Config: &ISISProtocolConfig{ + Identifier: PolicyTypeISIS, + Name: spec.Instance, + Enabled: spec.AdminState == v1alpha1.AdminStateUp, + }, + ISIS: &ISIS{ + Global: &ISISGlobal{ + Config: &ISISGlobalConfig{ + LevelCapability: levelCap, + Net: []string{spec.NetworkEntityTitle}, + }, + }, + }, + } + + if len(spec.AddressFamilies) > 0 { + proto.ISIS.Global.AfiSafi = &ISISAfiSafi{} + for _, af := range spec.AddressFamilies { + afi, safi := toISISAfiSafi(af) + proto.ISIS.Global.AfiSafi.AF.Set(&ISISAf{ + AfiName: afi, + SafiName: safi, + Config: &ISISAfConfig{ + AfiName: afi, + SafiName: safi, + Enabled: true, + }, + }) + } + } + + if spec.OverloadBit == v1alpha1.OverloadBitAlways { + proto.ISIS.Global.LspBit = &ISISLspBit{ + OverloadBit: &ISISOverloadBit{ + Config: &ISISOverloadBitConfig{SetBit: true}, + }, + } + } + + sb := new(gnmiext.SetBuilder) + sb.Update(proto) + + for _, iface := range req.Interfaces { + sb.Update(&ISISInterface{ + NetworkInstance: ni, + ProtocolName: spec.Instance, + InterfaceID: iface.Spec.Name, + Config: &ISISInterfaceConfig{ + InterfaceID: iface.Spec.Name, + Enabled: true, + }, + }) + } + + return p.client.Do(ctx, sb) +} + +func (p *Provider) DeleteISIS(ctx context.Context, req *provider.DeleteISISRequest) error { + ni := DefaultNetworkInstance + proto := &ISISProtocol{ + NetworkInstance: ni, + Name: req.ISIS.Spec.Instance, + } + return p.client.Delete(ctx, proto) +} + +func toISISLevelCapability(level v1alpha1.ISISLevel) (ISISLevelCapability, error) { + switch level { + case v1alpha1.ISISLevel1: + return ISISLevelCapabilityLevel1, nil + case v1alpha1.ISISLevel2: + return ISISLevelCapabilityLevel2, nil + case v1alpha1.ISISLevel12: + return ISISLevelCapabilityLevel1_2, nil + default: + return "", apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.type", + Description: fmt.Sprintf("unsupported ISIS level %q", level), + }) + } +} + +func toISISAfiSafi(af v1alpha1.AddressFamily) (ISISAfiName, ISISSafiName) { //nolint:unparam + switch af { + case v1alpha1.AddressFamilyIPv6Unicast: + return ISISAfiNameIPv6, ISISSafiNameUnicast + default: // IPv4Unicast + return ISISAfiNameIPv4, ISISSafiNameUnicast + } +} + +// ISISLevelCapability represents the ISIS level capability identity. +type ISISLevelCapability string + +const ( + ISISLevelCapabilityLevel1 ISISLevelCapability = "LEVEL_1" + ISISLevelCapabilityLevel2 ISISLevelCapability = "LEVEL_2" + ISISLevelCapabilityLevel1_2 ISISLevelCapability = "LEVEL_1_2" +) + +// ISISAfiName represents the ISIS address family identifier. +type ISISAfiName string + +const ( + ISISAfiNameIPv4 ISISAfiName = "openconfig-isis-types:IPV4" + ISISAfiNameIPv6 ISISAfiName = "openconfig-isis-types:IPV6" +) + +// ISISSafiName represents the ISIS sub-address family identifier. +type ISISSafiName string + +const ( + ISISSafiNameUnicast ISISSafiName = "openconfig-isis-types:UNICAST" +) + +// Compile-time assertions. +var ( + _ gnmiext.DataElement = (*ISISProtocol)(nil) + _ gnmiext.DataElement = (*ISISInterface)(nil) +) + +// ISISProtocol represents the OC protocol[identifier=ISIS] entry. +type ISISProtocol struct { + NetworkInstance string `json:"-"` + Name string `json:"-"` + Config *ISISProtocolConfig `json:"config,omitempty"` + ISIS *ISIS `json:"isis,omitempty"` +} + +func (i *ISISProtocol) XPath() string { + return fmt.Sprintf("openconfig-network-instance:network-instances/network-instance[name=%s]/protocols/protocol[identifier=openconfig-policy-types:ISIS][name=%s]", i.NetworkInstance, i.Name) +} + +// ISISProtocolConfig holds the protocol config. +type ISISProtocolConfig struct { + Identifier PolicyType `json:"identifier"` + Name string `json:"name"` + Enabled bool `json:"enabled"` +} + +// ISIS holds the isis container. +type ISIS struct { + Global *ISISGlobal `json:"global,omitempty"` +} + +// ISISGlobal holds isis/global. +type ISISGlobal struct { + Config *ISISGlobalConfig `json:"config,omitempty"` + AfiSafi *ISISAfiSafi `json:"afi-safi,omitempty"` + LspBit *ISISLspBit `json:"lsp-bit,omitempty"` +} + +// ISISGlobalConfig holds isis/global/config. +type ISISGlobalConfig struct { + LevelCapability ISISLevelCapability `json:"level-capability"` + Net []string `json:"net"` +} + +// ISISAfiSafi holds isis/global/afi-safi. +type ISISAfiSafi struct { + AF gnmiext.List[string, *ISISAf] `json:"af,omitempty"` +} + +// ISISAf represents an ISIS afi-safi entry. +type ISISAf struct { + AfiName ISISAfiName `json:"afi-name"` + SafiName ISISSafiName `json:"safi-name"` + Config *ISISAfConfig `json:"config,omitempty"` +} + +func (a *ISISAf) Key() string { return string(a.AfiName) + "/" + string(a.SafiName) } + +// ISISAfConfig holds isis af config. +type ISISAfConfig struct { + AfiName ISISAfiName `json:"afi-name"` + SafiName ISISSafiName `json:"safi-name"` + Enabled bool `json:"enabled"` +} + +// ISISLspBit holds isis/global/lsp-bit. +type ISISLspBit struct { + OverloadBit *ISISOverloadBit `json:"overload-bit,omitempty"` +} + +// ISISOverloadBit holds overload-bit container. +type ISISOverloadBit struct { + Config *ISISOverloadBitConfig `json:"config,omitempty"` +} + +// ISISOverloadBitConfig holds overload-bit/config. +type ISISOverloadBitConfig struct { + SetBit bool `json:"set-bit"` +} + +// ISISInterface targets an ISIS interface config. +type ISISInterface struct { + NetworkInstance string `json:"-"` + ProtocolName string `json:"-"` + InterfaceID string `json:"-"` + Config *ISISInterfaceConfig `json:"config,omitempty"` +} + +func (i *ISISInterface) XPath() string { + return fmt.Sprintf("openconfig-network-instance:network-instances/network-instance[name=%s]/protocols/protocol[identifier=openconfig-policy-types:ISIS][name=%s]/isis/interfaces/interface[interface-id=%s]", i.NetworkInstance, i.ProtocolName, i.InterfaceID) +} + +// ISISInterfaceConfig holds the per-interface config. +type ISISInterfaceConfig struct { + InterfaceID string `json:"interface-id"` + Enabled bool `json:"enabled"` +} diff --git a/internal/provider/openconfig/lldp.go b/internal/provider/openconfig/lldp.go new file mode 100644 index 000000000..350ea804f --- /dev/null +++ b/internal/provider/openconfig/lldp.go @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package openconfig + +import ( + "context" + "fmt" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" +) + +var _ provider.LLDPProvider = (*Provider)(nil) + +func (p *Provider) EnsureLLDP(ctx context.Context, req *provider.LLDPRequest) error { + spec := req.LLDP.Spec + + sb := new(gnmiext.SetBuilder) + sb.Update(&LLDPConfig{ + Enabled: spec.AdminState == v1alpha1.AdminStateUp, + }) + + for i, ref := range spec.InterfaceRefs { + if i >= len(req.Interfaces) { + break + } + sb.Update(&LLDPInterfaceConfig{ + Name: req.Interfaces[i].Spec.Name, + Enabled: ref.AdminState == v1alpha1.AdminStateUp, + }) + } + + return p.client.Do(ctx, sb) +} + +func (p *Provider) DeleteLLDP(ctx context.Context, req *provider.LLDPRequest) error { + // LLDP cannot be fully deleted on SRLinux — disable it. + return p.client.Update(ctx, &LLDPConfig{Enabled: false}) +} + +func (p *Provider) GetLLDPStatus(ctx context.Context, _ *provider.LLDPRequest) (provider.LLDPStatus, error) { + state := &LLDPState{} + if err := p.client.GetState(ctx, state); err != nil { + return provider.LLDPStatus{}, err + } + return provider.LLDPStatus{OperStatus: state.Enabled}, nil +} + +// Compile-time assertions. +var ( + _ gnmiext.DataElement = (*LLDPConfig)(nil) + _ gnmiext.DataElement = (*LLDPInterfaceConfig)(nil) + _ gnmiext.DataElement = (*LLDPState)(nil) +) + +// LLDPConfig targets openconfig-lldp:lldp/config. +type LLDPConfig struct { + Enabled bool `json:"enabled"` +} + +func (*LLDPConfig) XPath() string { + return "openconfig-lldp:lldp/config" +} + +// LLDPInterfaceConfig targets a per-interface LLDP config. +type LLDPInterfaceConfig struct { + Name string `json:"-"` + Enabled bool `json:"enabled"` +} + +func (l *LLDPInterfaceConfig) XPath() string { + return fmt.Sprintf("openconfig-lldp:lldp/interfaces/interface[name=%s]/config", l.Name) +} + +// LLDPState reads lldp/state for oper status. +type LLDPState struct { + Enabled bool `json:"enabled"` +} + +func (*LLDPState) XPath() string { + return "openconfig-lldp:lldp/state" +} diff --git a/internal/provider/openconfig/prefixset.go b/internal/provider/openconfig/prefixset.go new file mode 100644 index 000000000..504a22c17 --- /dev/null +++ b/internal/provider/openconfig/prefixset.go @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package openconfig + +import ( + "context" + "fmt" + + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" +) + +var _ provider.PrefixSetProvider = (*Provider)(nil) + +func (p *Provider) EnsurePrefixSet(ctx context.Context, req *provider.PrefixSetRequest) error { + spec := req.PrefixSet.Spec + + ps := &PrefixSetElement{ + Name: spec.Name, + Config: &PrefixSetConfig{ + Name: spec.Name, + }, + Prefixes: &PrefixSetPrefixes{}, + } + + for _, entry := range spec.Entries { + mlr := MaskLengthRangeExact + if entry.MaskLengthRange != nil { + mlr = entry.MaskLengthRange.String() + } + ps.Prefixes.Prefix.Set(&PrefixSetPrefix{ + IPPrefix: entry.Prefix.String(), + MaskLenRange: mlr, + Config: &PrefixSetPrefixConfig{ + IPPrefix: entry.Prefix.String(), + MaskLenRange: mlr, + }, + }) + } + + return p.client.Update(ctx, ps) +} + +func (p *Provider) DeletePrefixSet(ctx context.Context, req *provider.PrefixSetRequest) error { + ps := &PrefixSetElement{Name: req.PrefixSet.Spec.Name} + return p.client.Delete(ctx, ps) +} + +// Compile-time assertion. +var _ gnmiext.DataElement = (*PrefixSetElement)(nil) + +// PrefixSetElement targets a prefix-set entry. +type PrefixSetElement struct { + Name string `json:"-"` + Config *PrefixSetConfig `json:"config,omitempty"` + Prefixes *PrefixSetPrefixes `json:"prefixes,omitempty"` +} + +func (ps *PrefixSetElement) XPath() string { + return fmt.Sprintf("openconfig-routing-policy:routing-policy/defined-sets/prefix-sets/prefix-set[name=%s]", ps.Name) +} + +// PrefixSetConfig holds the prefix-set config. +type PrefixSetConfig struct { + Name string `json:"name"` +} + +// PrefixSetPrefixes holds the prefix list. +type PrefixSetPrefixes struct { + Prefix gnmiext.List[string, *PrefixSetPrefix] `json:"prefix,omitempty"` +} + +// PrefixSetPrefix represents a single prefix entry. +type PrefixSetPrefix struct { + IPPrefix string `json:"ip-prefix"` + MaskLenRange string `json:"masklength-range"` + Config *PrefixSetPrefixConfig `json:"config,omitempty"` +} + +func (p *PrefixSetPrefix) Key() string { return p.IPPrefix + "/" + p.MaskLenRange } + +// PrefixSetPrefixConfig holds prefix config. +type PrefixSetPrefixConfig struct { + IPPrefix string `json:"ip-prefix"` + MaskLenRange string `json:"masklength-range"` +} diff --git a/internal/provider/openconfig/routingpolicy.go b/internal/provider/openconfig/routingpolicy.go new file mode 100644 index 000000000..0d4d0d72f --- /dev/null +++ b/internal/provider/openconfig/routingpolicy.go @@ -0,0 +1,183 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package openconfig + +import ( + "context" + "fmt" + "strconv" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/apistatus" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" +) + +var _ provider.RoutingPolicyProvider = (*Provider)(nil) + +func (p *Provider) EnsureRoutingPolicy(ctx context.Context, req *provider.EnsureRoutingPolicyRequest) error { + pd := &PolicyDefinition{ + Name: req.Name, + Config: &PolicyDefinitionConfig{ + Name: req.Name, + }, + Statements: &PolicyStatements{}, + } + + for _, stmt := range req.Statements { + // OC uses string names for statements; convert sequence number. + name := strconv.Itoa(int(stmt.Sequence)) + + s := &PolicyStatementElement{ + Name: name, + Config: &PolicyStatementConfig{ + Name: name, + }, + Actions: &PolicyStatementActions{ + Config: &PolicyStatementActionsConfig{ + PolicyResult: toPolicyResult(stmt.Actions.RouteDisposition), + }, + }, + } + + for _, cond := range stmt.Conditions { + switch c := cond.(type) { + case provider.MatchPrefixSetCondition: + s.Conditions = &PolicyStatementConditions{ + MatchPrefixSet: &PolicyMatchPrefixSet{ + Config: &PolicyMatchPrefixSetConfig{ + PrefixSet: c.PrefixSet.Spec.Name, + MatchSetOptions: MatchSetOptionAny, + }, + }, + } + } + } + + // BGP actions (set-local-pref only — community actions not supported on SRLinux OC). + if stmt.Actions.BgpActions != nil { + if stmt.Actions.BgpActions.SetCommunity != nil { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.statements[].actions.bgpActions.setCommunity", + Description: "openconfig provider does not support inline community-set on SRLinux", + }) + } + if stmt.Actions.BgpActions.SetExtCommunity != nil { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.statements[].actions.bgpActions.setExtCommunity", + Description: "openconfig provider does not support inline ext-community-set on SRLinux", + }) + } + if stmt.Actions.BgpActions.SetASPath != nil { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.statements[].actions.bgpActions.setASPath", + Description: "openconfig provider does not support AS-path manipulation on SRLinux", + }) + } + } + + pd.Statements.Statement.Set(s) + } + + return p.client.Update(ctx, pd) +} + +func (p *Provider) DeleteRoutingPolicy(ctx context.Context, req *provider.DeleteRoutingPolicyRequest) error { + pd := &PolicyDefinition{Name: req.Name} + return p.client.Delete(ctx, pd) +} + +func toPolicyResult(rd v1alpha1.RouteDisposition) PolicyResult { + switch rd { + case v1alpha1.AcceptRoute: + return PolicyResultAcceptRoute + case v1alpha1.RejectRoute: + return PolicyResultRejectRoute + default: + return PolicyResultRejectRoute + } +} + +// PolicyResult represents the OpenConfig routing policy result action. +type PolicyResult string + +const ( + PolicyResultAcceptRoute PolicyResult = "ACCEPT_ROUTE" + PolicyResultRejectRoute PolicyResult = "REJECT_ROUTE" +) + +// MatchSetOption represents the match-set-options identity. +type MatchSetOption string + +const ( + MatchSetOptionAny MatchSetOption = "ANY" +) + +// MaskLengthRangeExact is the mask-length-range value for exact prefix matches. +const MaskLengthRangeExact = "exact" + +// Compile-time assertion. +var _ gnmiext.DataElement = (*PolicyDefinition)(nil) + +// PolicyDefinition targets a policy-definition entry. +type PolicyDefinition struct { + Name string `json:"-"` + Config *PolicyDefinitionConfig `json:"config,omitempty"` + Statements *PolicyStatements `json:"statements,omitempty"` +} + +func (pd *PolicyDefinition) XPath() string { + return fmt.Sprintf("openconfig-routing-policy:routing-policy/policy-definitions/policy-definition[name=%s]", pd.Name) +} + +// PolicyDefinitionConfig holds policy-definition/config. +type PolicyDefinitionConfig struct { + Name string `json:"name"` +} + +// PolicyStatements holds the statement list. +type PolicyStatements struct { + Statement gnmiext.List[string, *PolicyStatementElement] `json:"statement,omitempty"` +} + +// PolicyStatementElement represents a single statement. +type PolicyStatementElement struct { + Name string `json:"name"` + Config *PolicyStatementConfig `json:"config,omitempty"` + Conditions *PolicyStatementConditions `json:"conditions,omitempty"` + Actions *PolicyStatementActions `json:"actions,omitempty"` +} + +func (s *PolicyStatementElement) Key() string { return s.Name } + +// PolicyStatementConfig holds statement config. +type PolicyStatementConfig struct { + Name string `json:"name"` +} + +// PolicyStatementConditions holds statement conditions. +type PolicyStatementConditions struct { + MatchPrefixSet *PolicyMatchPrefixSet `json:"match-prefix-set,omitempty"` +} + +// PolicyMatchPrefixSet holds match-prefix-set. +type PolicyMatchPrefixSet struct { + Config *PolicyMatchPrefixSetConfig `json:"config,omitempty"` +} + +// PolicyMatchPrefixSetConfig holds match-prefix-set config. +type PolicyMatchPrefixSetConfig struct { + PrefixSet string `json:"prefix-set"` + MatchSetOptions MatchSetOption `json:"match-set-options"` +} + +// PolicyStatementActions holds statement actions. +type PolicyStatementActions struct { + Config *PolicyStatementActionsConfig `json:"config,omitempty"` +} + +// PolicyStatementActionsConfig holds actions config. +type PolicyStatementActionsConfig struct { + PolicyResult PolicyResult `json:"policy-result"` +} diff --git a/internal/provider/openconfig/syslog.go b/internal/provider/openconfig/syslog.go new file mode 100644 index 000000000..a27e74160 --- /dev/null +++ b/internal/provider/openconfig/syslog.go @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package openconfig + +import ( + "context" + "fmt" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/apistatus" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" +) + +var _ provider.SyslogProvider = (*Provider)(nil) + +func (p *Provider) EnsureSyslog(ctx context.Context, req *provider.EnsureSyslogRequest) error { + spec := req.Syslog.Spec + + if len(spec.Facilities) > 0 { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.facilities", + Description: "openconfig provider does not support syslog facilities on SRLinux", + }) + } + + sb := new(gnmiext.SetBuilder) + + for _, srv := range spec.Servers { + if srv.VrfName != "" { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.servers[].vrfName", + Description: "openconfig provider does not support syslog VRF on SRLinux", + }) + } + + rs := &SyslogRemoteServer{ + Host: srv.Address, + Config: &SyslogRemoteServerConfig{ + Host: srv.Address, + RemotePort: uint16(srv.Port), //nolint:gosec + }, + Selectors: &SyslogSelectors{}, + } + rs.Selectors.Selector.Set(&SyslogSelector{ + Facility: SyslogFacilityAll, + Severity: toSyslogSeverity(srv.Severity), + Config: &SyslogSelectorConfig{ + Facility: SyslogFacilityAll, + Severity: toSyslogSeverity(srv.Severity), + }, + }) + sb.Update(rs) + } + + return p.client.Do(ctx, sb) +} + +func (p *Provider) DeleteSyslog(ctx context.Context) error { + return p.client.Delete(ctx, &SyslogContainer{}) +} + +// SyslogSeverity represents the OpenConfig syslog severity identity. +type SyslogSeverity string + +const ( + SyslogSeverityDebug SyslogSeverity = "DEBUG" + SyslogSeverityInformational SyslogSeverity = "INFORMATIONAL" + SyslogSeverityNotice SyslogSeverity = "NOTICE" + SyslogSeverityWarning SyslogSeverity = "WARNING" + SyslogSeverityError SyslogSeverity = "ERROR" + SyslogSeverityCritical SyslogSeverity = "CRITICAL" + SyslogSeverityAlert SyslogSeverity = "ALERT" + SyslogSeverityEmergency SyslogSeverity = "EMERGENCY" +) + +func toSyslogSeverity(s v1alpha1.Severity) SyslogSeverity { + switch s { + case v1alpha1.SeverityDebug: + return SyslogSeverityDebug + case v1alpha1.SeverityInfo: + return SyslogSeverityInformational + case v1alpha1.SeverityNotice: + return SyslogSeverityNotice + case v1alpha1.SeverityWarning: + return SyslogSeverityWarning + case v1alpha1.SeverityError: + return SyslogSeverityError + case v1alpha1.SeverityCritical: + return SyslogSeverityCritical + case v1alpha1.SeverityAlert: + return SyslogSeverityAlert + case v1alpha1.SeverityEmergency: + return SyslogSeverityEmergency + default: + return SyslogSeverityInformational + } +} + +// SyslogFacility represents the OpenConfig syslog facility identity. +type SyslogFacility string + +const ( + SyslogFacilityAll SyslogFacility = "ALL" +) + +// Compile-time assertions. +var ( + _ gnmiext.DataElement = (*SyslogRemoteServer)(nil) + _ gnmiext.DataElement = (*SyslogContainer)(nil) +) + +// SyslogContainer targets the full logging container for deletion. +type SyslogContainer struct{} + +func (*SyslogContainer) XPath() string { + return "openconfig-system:system/logging/remote-servers" +} + +// SyslogRemoteServer targets a specific remote-server entry. +type SyslogRemoteServer struct { + Host string `json:"-"` + Config *SyslogRemoteServerConfig `json:"config,omitempty"` + Selectors *SyslogSelectors `json:"selectors,omitempty"` +} + +func (s *SyslogRemoteServer) XPath() string { + return fmt.Sprintf("openconfig-system:system/logging/remote-servers/remote-server[host=%s]", s.Host) +} + +// SyslogRemoteServerConfig holds the remote server config. +type SyslogRemoteServerConfig struct { + Host string `json:"host"` + RemotePort uint16 `json:"remote-port,omitempty"` +} + +// SyslogSelectors holds the selector list. +type SyslogSelectors struct { + Selector gnmiext.List[string, *SyslogSelector] `json:"selector,omitempty"` +} + +// SyslogSelector represents a facility+severity selector. +type SyslogSelector struct { + Facility SyslogFacility `json:"facility"` + Severity SyslogSeverity `json:"severity"` + Config *SyslogSelectorConfig `json:"config,omitempty"` +} + +func (s *SyslogSelector) Key() string { return string(s.Facility) + "/" + string(s.Severity) } + +// SyslogSelectorConfig holds selector config. +type SyslogSelectorConfig struct { + Facility SyslogFacility `json:"facility"` + Severity SyslogSeverity `json:"severity"` +} diff --git a/internal/provider/openconfig/vrf.go b/internal/provider/openconfig/vrf.go new file mode 100644 index 000000000..797ecac0c --- /dev/null +++ b/internal/provider/openconfig/vrf.go @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package openconfig + +import ( + "context" + "fmt" + + "github.com/ironcore-dev/network-operator/internal/apistatus" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" +) + +var _ provider.VRFProvider = (*Provider)(nil) + +func (p *Provider) EnsureVRF(ctx context.Context, req *provider.VRFRequest) error { + spec := req.VRF.Spec + + if spec.RouteDistinguisher != "" { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.routeDistinguisher", + Description: "openconfig provider does not support route-distinguisher on SRLinux", + }) + } + if len(spec.RouteTargets) > 0 { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.routeTargets", + Description: "openconfig provider does not support route-targets on SRLinux", + }) + } + if spec.VNI > 0 { //nolint:staticcheck + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.vni", + Description: "openconfig provider does not support VNI on SRLinux", + }) + } + + niType := NetworkInstanceTypeL3VRF + if spec.Name == DefaultNetworkInstance { + niType = NetworkInstanceTypeDefaultInstance + } + + ni := &NetworkInstance{ + Name: spec.Name, + Config: &NetworkInstanceConfig{ + Name: spec.Name, + Type: niType, + Description: spec.Description, + }, + } + + return p.client.Update(ctx, ni) +} + +func (p *Provider) DeleteVRF(ctx context.Context, req *provider.VRFRequest) error { + ni := &NetworkInstance{Name: req.VRF.Spec.Name} + return p.client.Delete(ctx, ni) +} + +// NetworkInstanceType represents the OpenConfig network-instance type identity. +type NetworkInstanceType string + +const ( + NetworkInstanceTypeL3VRF NetworkInstanceType = "openconfig-network-instance-types:L3VRF" + NetworkInstanceTypeDefaultInstance NetworkInstanceType = "openconfig-network-instance-types:DEFAULT_INSTANCE" +) + +// Compile-time assertion. +var _ gnmiext.DataElement = (*NetworkInstance)(nil) + +// NetworkInstance represents an OC network-instance list entry. +type NetworkInstance struct { + Name string `json:"-"` + Config *NetworkInstanceConfig `json:"config,omitempty"` +} + +func (ni *NetworkInstance) XPath() string { + return fmt.Sprintf("openconfig-network-instance:network-instances/network-instance[name=%s]", ni.Name) +} + +// NetworkInstanceConfig holds config for a network-instance. +type NetworkInstanceConfig struct { + Name string `json:"name"` + Type NetworkInstanceType `json:"type"` + Description string `json:"description,omitempty"` +} diff --git a/test/gnmi/testdata/openconfig/bgp.txt b/test/gnmi/testdata/openconfig/bgp.txt new file mode 100644 index 000000000..882a85eab --- /dev/null +++ b/test/gnmi/testdata/openconfig/bgp.txt @@ -0,0 +1,69 @@ +# BGP global instance +-- bgps/bgp -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: BGP +metadata: + name: bgp + namespace: default +spec: + deviceRef: + name: device + asNumber: 65000 + routerId: 10.0.0.1 + addressFamilies: + ipv4Unicast: + enabled: true + ipv6Unicast: + enabled: true + +-- state/preload -- +{} + +-- state/expect -- +{ + "openconfig-network-instance:network-instances": { + "network-instance": [ + { + "protocols": { + "protocol": [ + { + "config": { + "identifier": "openconfig-policy-types:BGP", + "name": "BGP" + }, + "bgp": { + "global": { + "config": { + "as": 65000, + "router-id": "10.0.0.1" + }, + "afi-safis": { + "afi-safi": [ + { + "afi-safi-name": "openconfig-bgp-types:IPV4_UNICAST", + "config": { + "afi-safi-name": "openconfig-bgp-types:IPV4_UNICAST", + "enabled": true + } + }, + { + "afi-safi-name": "openconfig-bgp-types:IPV6_UNICAST", + "config": { + "afi-safi-name": "openconfig-bgp-types:IPV6_UNICAST", + "enabled": true + } + } + ] + } + } + } + } + ] + } + } + ] + } +} + +-- state/delete -- +{} diff --git a/test/gnmi/testdata/openconfig/dns.txt b/test/gnmi/testdata/openconfig/dns.txt index 31f77c9c7..717fb728d 100644 --- a/test/gnmi/testdata/openconfig/dns.txt +++ b/test/gnmi/testdata/openconfig/dns.txt @@ -12,7 +12,10 @@ spec: servers: - address: 8.8.8.8 - address: 1.1.1.1 --- state -- +-- state/preload -- +{} + +-- state/expect -- { "openconfig-system:system": { "dns": { @@ -40,3 +43,6 @@ spec: } } } + +-- state/delete -- +{} diff --git a/test/gnmi/testdata/openconfig/lldp.txt b/test/gnmi/testdata/openconfig/lldp.txt new file mode 100644 index 000000000..dada2386a --- /dev/null +++ b/test/gnmi/testdata/openconfig/lldp.txt @@ -0,0 +1,32 @@ +# LLDP enabled globally +-- lldps/lldp -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: LLDP +metadata: + name: lldp + namespace: default +spec: + deviceRef: + name: device + adminState: Up + +-- state/preload -- +{} + +-- state/expect -- +{ + "openconfig-lldp:lldp": { + "config": { + "enabled": true + } + } +} + +-- state/delete -- +{ + "openconfig-lldp:lldp": { + "config": { + "enabled": false + } + } +} diff --git a/test/gnmi/testdata/openconfig/prefixset.txt b/test/gnmi/testdata/openconfig/prefixset.txt new file mode 100644 index 000000000..e7b1a60bb --- /dev/null +++ b/test/gnmi/testdata/openconfig/prefixset.txt @@ -0,0 +1,59 @@ +# PrefixSet with exact match entries +-- prefixsets/pfx-loopbacks -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: PrefixSet +metadata: + name: pfx-loopbacks + namespace: default +spec: + deviceRef: + name: device + name: LOOPBACKS + entries: + - sequence: 10 + prefix: 10.0.0.0/24 + - sequence: 20 + prefix: 10.1.0.0/16 + +-- state/preload -- +{} + +-- state/expect -- +{ + "openconfig-routing-policy:routing-policy": { + "defined-sets": { + "prefix-sets": { + "prefix-set": [ + { + "config": { + "name": "LOOPBACKS" + }, + "prefixes": { + "prefix": [ + { + "ip-prefix": "10.0.0.0/24", + "masklength-range": "exact", + "config": { + "ip-prefix": "10.0.0.0/24", + "masklength-range": "exact" + } + }, + { + "ip-prefix": "10.1.0.0/16", + "masklength-range": "exact", + "config": { + "ip-prefix": "10.1.0.0/16", + "masklength-range": "exact" + } + } + ] + } + } + ] + } + } + } +} + +-- state/delete -- +{} diff --git a/test/gnmi/testdata/openconfig/routingpolicy.txt b/test/gnmi/testdata/openconfig/routingpolicy.txt new file mode 100644 index 000000000..cc88053be --- /dev/null +++ b/test/gnmi/testdata/openconfig/routingpolicy.txt @@ -0,0 +1,123 @@ +# RoutingPolicy accept-loopbacks with referenced PrefixSet +-- prefixsets/pfx-loopbacks -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: PrefixSet +metadata: + name: pfx-loopbacks + namespace: default +spec: + deviceRef: + name: device + name: LOOPBACKS + entries: + - sequence: 10 + prefix: 10.0.0.0/24 + - sequence: 20 + prefix: 10.1.0.0/16 +-- routingpolicies/accept-loopbacks -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: RoutingPolicy +metadata: + name: accept-loopbacks + namespace: default +spec: + deviceRef: + name: device + name: accept-loopbacks + statements: + - sequence: 10 + conditions: + matchPrefixSet: + prefixSetRef: + name: pfx-loopbacks + actions: + routeDisposition: AcceptRoute + - sequence: 65535 + actions: + routeDisposition: RejectRoute + +-- state/preload -- +{} + +-- state/expect -- +{ + "openconfig-routing-policy:routing-policy": { + "defined-sets": { + "prefix-sets": { + "prefix-set": [ + { + "config": { + "name": "LOOPBACKS" + }, + "prefixes": { + "prefix": [ + { + "ip-prefix": "10.0.0.0/24", + "masklength-range": "exact", + "config": { + "ip-prefix": "10.0.0.0/24", + "masklength-range": "exact" + } + }, + { + "ip-prefix": "10.1.0.0/16", + "masklength-range": "exact", + "config": { + "ip-prefix": "10.1.0.0/16", + "masklength-range": "exact" + } + } + ] + } + } + ] + } + }, + "policy-definitions": { + "policy-definition": [ + { + "config": { + "name": "accept-loopbacks" + }, + "statements": { + "statement": [ + { + "name": "10", + "config": { + "name": "10" + }, + "conditions": { + "match-prefix-set": { + "config": { + "prefix-set": "LOOPBACKS", + "match-set-options": "ANY" + } + } + }, + "actions": { + "config": { + "policy-result": "ACCEPT_ROUTE" + } + } + }, + { + "name": "65535", + "config": { + "name": "65535" + }, + "actions": { + "config": { + "policy-result": "REJECT_ROUTE" + } + } + } + ] + } + } + ] + } + } +} + +-- state/delete -- +{} diff --git a/test/gnmi/testdata/openconfig/syslog.txt b/test/gnmi/testdata/openconfig/syslog.txt new file mode 100644 index 000000000..9ab78b1ca --- /dev/null +++ b/test/gnmi/testdata/openconfig/syslog.txt @@ -0,0 +1,48 @@ +# Syslog remote server +-- syslogs/syslog -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Syslog +metadata: + name: syslog + namespace: default +spec: + deviceRef: + name: device + servers: + - address: 192.0.2.100 + severity: Info + +-- state/preload -- +{} + +-- state/expect -- +{ + "openconfig-system:system": { + "logging": { + "remote-servers": { + "remote-server": [ + { + "config": { + "host": "192.0.2.100" + }, + "selectors": { + "selector": [ + { + "facility": "ALL", + "severity": "INFORMATIONAL", + "config": { + "facility": "ALL", + "severity": "INFORMATIONAL" + } + } + ] + } + } + ] + } + } + } +} + +-- state/delete -- +{} diff --git a/test/gnmi/testdata/openconfig/vrf.txt b/test/gnmi/testdata/openconfig/vrf.txt new file mode 100644 index 000000000..23b1fcfe5 --- /dev/null +++ b/test/gnmi/testdata/openconfig/vrf.txt @@ -0,0 +1,31 @@ +# VRF L3VRF +-- vrfs/vrf-blue -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: VRF +metadata: + name: vrf-blue + namespace: default +spec: + deviceRef: + name: device + name: BLUE + +-- state/preload -- +{} + +-- state/expect -- +{ + "openconfig-network-instance:network-instances": { + "network-instance": [ + { + "config": { + "name": "BLUE", + "type": "openconfig-network-instance-types:L3VRF" + } + } + ] + } +} + +-- state/delete -- +{}