From d837cb11153d4ccca0fae02f5601336c1b883097 Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Tue, 16 Dec 2025 17:22:51 +0100 Subject: [PATCH 1/4] (fix): Cisco IOS XR Yang Empty Data Type In Cisco IOS XR the yang empty type is not implemented correctly for bundle-interfaces. Instead of returning "[null]" as defined in the RFC, "[\n null \n]" is. We simply work around of this. --- internal/provider/cisco/gnmiext/v2/empty.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/provider/cisco/gnmiext/v2/empty.go b/internal/provider/cisco/gnmiext/v2/empty.go index 1bcca084..0b64c584 100644 --- a/internal/provider/cisco/gnmiext/v2/empty.go +++ b/internal/provider/cisco/gnmiext/v2/empty.go @@ -6,6 +6,7 @@ package gnmiext import ( "encoding/json" "fmt" + "regexp" ) // NOTE: Use json.Marshaler and json.Unmarshaler interfaces instead of the @@ -39,7 +40,10 @@ func (e *Empty) UnmarshalJSON(b []byte) error { *e = false return nil } - if string(b) != "[null]" { + + //Due to some Cisco IOSX ouptut we also match [ \n null \n] + nullTypeRe := regexp.MustCompile(`^\[\s*null\s*]$`) + if !nullTypeRe.MatchString(string(b)) { return fmt.Errorf("gnmiext: invalid empty value: %s", string(b)) } *e = true From abb90552de6ce4d047762c5eddf5837d8c54f661 Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Thu, 18 Dec 2025 09:13:46 +0100 Subject: [PATCH 2/4] (feat): API side support QandQ Vlan configuration Introduce new API field InnerVlan storing the inner vlan of a QandQ Tag. --- api/core/v1alpha1/interface_types.go | 6 ++++++ .../bases/networking.metal.ironcore.dev_interfaces.yaml | 7 +++++++ 2 files changed, 13 insertions(+) diff --git a/api/core/v1alpha1/interface_types.go b/api/core/v1alpha1/interface_types.go index 5e75eb1b..0328001c 100644 --- a/api/core/v1alpha1/interface_types.go +++ b/api/core/v1alpha1/interface_types.go @@ -138,6 +138,12 @@ type Switchport struct { // +kubebuilder:validation:Maximum=4094 AccessVlan int32 `json:"accessVlan,omitempty"` + // InnerVlan specifies the VLAN id for QinQ access mode switchports. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=4094 + InnerVlan int32 `json:"innerVlan,omitempty"` + // NativeVlan specifies the native VLAN ID for trunk mode switchports. // Only applicable when Mode is set to "Trunk". // +optional diff --git a/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml b/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml index 9284fee0..cf4946e3 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml @@ -334,6 +334,13 @@ spec: type: integer minItems: 1 type: array + innerVlan: + description: InnerVlan specifies the VLAN id for QinQ access mode + switchports. + format: int32 + maximum: 4094 + minimum: 1 + type: integer mode: description: Mode defines the switchport mode, such as access or trunk. From a138c9dbf7df87b9901facc802cbaee3906817fe Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Thu, 18 Dec 2025 13:35:10 +0100 Subject: [PATCH 3/4] (feat): Cisco IOS XR implement bundle interface configuration --- internal/provider/cisco/iosxr/intf.go | 164 +++++++++++++++-- internal/provider/cisco/iosxr/provider.go | 211 ++++++++++++++++------ 2 files changed, 302 insertions(+), 73 deletions(-) diff --git a/internal/provider/cisco/iosxr/intf.go b/internal/provider/cisco/iosxr/intf.go index 76045b68..dc146523 100644 --- a/internal/provider/cisco/iosxr/intf.go +++ b/internal/provider/cisco/iosxr/intf.go @@ -4,23 +4,32 @@ package iosxr import ( + "errors" "fmt" "regexp" + "strconv" "github.com/ironcore-dev/network-operator/internal/provider/cisco/gnmiext/v2" ) type PhysIf struct { Name string `json:"-"` - Description string `json:"description"` - Active string `json:"active"` - Vrf string `json:"Cisco-IOS-XR-infra-rsi-cfg:vrf,omitempty"` - Statistics Statistics `json:"Cisco-IOS-XR-infra-statsd-cfg:statistics,omitempty"` - IPv4Network IPv4Network `json:"Cisco-IOS-XR-ipv4-io-cfg:ipv4-network,omitempty"` - IPv6Network IPv6Network `json:"Cisco-IOS-XR-ipv6-ma-cfg:ipv6-network,omitempty"` - IPv6Neighbor IPv6Neighbor `json:"Cisco-IOS-XR-ipv6-nd-cfg:ipv6-neighbor,omitempty"` - MTUs MTUs `json:"mtus,omitempty"` - Shutdown gnmiext.Empty `json:"shutdown,omitempty"` + Description string `json:"description,omitzero"` + Statistics Statistics `json:"Cisco-IOS-XR-infra-statsd-cfg:statistics,omitzero"` + MTUs MTUs `json:"mtus,omitzero"` + Active string `json:"active,omitzero"` + Vrf string `json:"Cisco-IOS-XR-infra-rsi-cfg:vrf,omitzero"` + IPv4Network IPv4Network `json:"Cisco-IOS-XR-ipv4-io-cfg:ipv4-network,omitzero"` + IPv6Network IPv6Network `json:"Cisco-IOS-XR-ipv6-ma-cfg:ipv6-network,omitzero"` + IPv6Neighbor IPv6Neighbor `json:"Cisco-IOS-XR-ipv6-nd-cfg:ipv6-neighbor,omitzero"` + Shutdown gnmiext.Empty `json:"shutdown,omitzero"` + + //BundleMember configuration for Physical interface as member of a Bundle-Ether + BundleMember BundleMember `json:"Cisco-IOS-XR-bundlemgr-cfg:bundle-member,omitzero"` +} + +type BundleMember struct { + ID BundleID `json:"id"` } type Statistics struct { @@ -73,30 +82,91 @@ type MTU struct { Owner string `json:"owner"` } +type BunldePortActivity string + +const ( + PortActivityOn BunldePortActivity = "on" + PortActivityActive BunldePortActivity = "active" + PortActivityPassive BunldePortActivity = "passive" + PortActivityInherit BunldePortActivity = "inherit" +) + +// BundleInterface represents a port-channel (LAG) interface on IOS-XR devices +type BundleInterface struct { + Name string `json:"-"` + Description string `json:"description,omitzero"` + Statistics Statistics `json:"Cisco-IOS-XR-infra-statsd-cfg:statistics,omitzero"` + MTUs MTUs `json:"mtus,omitzero"` + //mode in which an interface is running (e.g., virtual for subinterfaces) + Mode gnmiext.Empty `json:"interface-virtual,omitzero"` + + //existence of this object causes the creation of the software subinterface + ModeNoPhysical string `json:"interface-mode-non-physical,omitzero"` + Bundle Bundle `json:"Cisco-IOS-XR-bundlemgr-cfg:bundle,omitzero"` + SubInterface VlanSubInterface `json:"Cisco-IOS-XR-l2-eth-infra-cfg:vlan-sub-configuration,omitzero"` +} + +type BundleID struct { + BundleID int32 `json:"bundle-id"` + PortAcivity string `json:"port-activity"` +} + +type Bundle struct { + MinAct MinimumActive `json:"minimum-active"` +} + +type MinimumActive struct { + Links int32 `json:"links"` +} + +type VlanSubInterface struct { + VlanIdentifier VlanIdentifier `json:"vlan-identifier"` +} + +type VlanIdentifier struct { + FirstTag int32 `json:"first-tag"` + SecondTag int32 `json:"second-tag"` + VlanType string `json:"vlan-type"` +} + func (i *PhysIf) XPath() string { return fmt.Sprintf("Cisco-IOS-XR-ifmgr-cfg:interface-configurations/interface-configuration[active=act][interface-name=%s]", i.Name) } func (i *PhysIf) String() string { - return fmt.Sprintf("Name: %s, Description=%s, ShutDown=%t", i.Name, i.Description, i.Shutdown) + return fmt.Sprintf("Name: %s, Description=%s", i.Name, i.Description) +} + +func (i *BundleInterface) XPath() string { + return fmt.Sprintf("Cisco-IOS-XR-ifmgr-cfg:interface-configurations/interface-configuration[active=act][interface-name=%s]", i.Name) +} + +func (i *BundleInterface) String() string { + return fmt.Sprintf("Name: %s, Description=%s", i.Name, i.Description) } type IFaceSpeed string const ( - Speed10G IFaceSpeed = "TenGigE" - Speed25G IFaceSpeed = "TwentyFiveGigE" - Speed40G IFaceSpeed = "FortyGigE" - Speed100G IFaceSpeed = "HundredGigE" + Speed10G IFaceSpeed = "TenGigE" + Speed25G IFaceSpeed = "TwentyFiveGigE" + Speed40G IFaceSpeed = "FortyGigE" + Speed100G IFaceSpeed = "HundredGigE" + EtherBundle IFaceSpeed = "etherbundle" ) -func ExtractMTUOwnerFromIfaceName(ifaceName string) (IFaceSpeed, error) { +func ExractMTUOwnerFromIfaceName(ifaceName string) (IFaceSpeed, error) { + //MTU owner of bundle interfaces is 'etherbundle' + bundleEtherRE := regexp.MustCompile(`^Bundle-Ether*`) + if bundleEtherRE.MatchString(ifaceName) { + // For Bundle-Ether interfaces + return EtherBundle, nil + } + // Match the port_type in an interface name /// // E.g. match TwentyFiveGigE of interface with name TwentyFiveGigE0/0/0/1 re := regexp.MustCompile(`^\D*`) - mtuOwner := string(re.Find([]byte(ifaceName))) - if mtuOwner == "" { return "", fmt.Errorf("failed to extract MTU owner from interface name %s", ifaceName) } @@ -115,6 +185,66 @@ func ExtractMTUOwnerFromIfaceName(ifaceName string) (IFaceSpeed, error) { } } +func CheckInterfaceNameTypeAggregate(name string) error { + if name == "" { + return errors.New("interface name must not be empty") + } + //Matches Bundle-Ether[.] or BE[.] + re := regexp.MustCompile(`^(Bundle-Ether|BE)(\d+)(\.(\d+))?$`) + matches := re.FindStringSubmatch(name) + + if matches == nil { + return fmt.Errorf("unsupported interface format %q, expected one of: %q", name, re.String()) + } + + //Vlan is part of the name + if matches[2] == "" { + return fmt.Errorf("unsupported interface format %q, expected one of: %q", name, re.String()) + } + //Check outer vlan + //fixme: check range up to 65000 + //err := CheckVlanRange(matches[2]) + + //Check inner vlan if we have a subinterface + if matches[4] != "" { + return CheckVlanRange(matches[4]) + } + return nil +} + +func ExtractBundleIdAndVlanTagsFromName(name string) (int32, int32) { + //Matches BE1.1 or Bundle-Ether1.1 + re := regexp.MustCompile(`^(Bundle-Ether|BE)(\d+)(?:\.(\d+))?$`) + matches := re.FindStringSubmatch(name) + + bundleID := int32(0) + outerVlan := int32(0) + switch len(matches) { + case 4: + o, _ := strconv.Atoi(matches[2]) + bundleID = int32(o) + case 5: + o, _ := strconv.Atoi(matches[2]) + i, _ := strconv.Atoi(matches[3]) + bundleID = int32(o) + outerVlan = int32(i) + } + return bundleID, outerVlan +} + +func CheckVlanRange(vlan string) error { + v, err := strconv.Atoi(vlan) + + if err != nil { + return fmt.Errorf("failed to parse VLAN %q: %w", vlan, err) + } + + if v < 1 || v > 4095 { + return fmt.Errorf("VLAN %s is out of range, valid range is 1-4095", vlan) + } + return nil +} + type PhysIfStateType string const ( diff --git a/internal/provider/cisco/iosxr/provider.go b/internal/provider/cisco/iosxr/provider.go index 1d58a522..1d4f1602 100644 --- a/internal/provider/cisco/iosxr/provider.go +++ b/internal/provider/cisco/iosxr/provider.go @@ -9,6 +9,8 @@ import ( "fmt" "strconv" + cp "github.com/felix-kaestner/copy" + "github.com/ironcore-dev/network-operator/internal/deviceutil" "github.com/ironcore-dev/network-operator/internal/provider" "github.com/ironcore-dev/network-operator/internal/provider/cisco/gnmiext/v2" @@ -53,78 +55,175 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte return errors.New("client is not connected") } - if req.Interface.Spec.Type != v1alpha1.InterfaceTypePhysical { - message := "unsupported interface type for interface " + req.Interface.Spec.Name - return errors.New(message) - } - name := req.Interface.Spec.Name - physif := &PhysIf{} + switch req.Interface.Spec.Type { + case v1alpha1.InterfaceTypePhysical: + conf := make([]gnmiext.Configurable, 0, 2) + + iface := &PhysIf{} + iface.Name = name + iface.Description = req.Interface.Spec.Description + + //Check if interface is part of a bundle + //Bundle configuration needs to happen in a sperate gnmi call + bundle_name := req.Interface.GetAnnotations()[v1alpha1.AggregateLabel] + if bundle_name == "" { + iface.Statistics.LoadInterval = uint8(30) + + if req.Interface.Spec.MTU != 0 { + mtu, err := NewMTU(name, req.Interface.Spec.MTU) + if err != nil { + return err + } + iface.MTUs = mtu + } + + if !(req.Interface.Spec.IPv4 == nil) { + //if len(req.Interface.Spec.IPv4.Addresses) == 0 { + // message := "no IPv4 address configured for interface " + name + // return errors.New(message) + //} + if len(req.Interface.Spec.IPv4.Addresses) > 1 { + message := "multiple IPv4 addresses configured for interface " + name + return errors.New(message) + } + + // (fixme): support IPv6 addresses, IPv6 neighbor config + ip := req.Interface.Spec.IPv4.Addresses[0].Addr().String() + ipNet := req.Interface.Spec.IPv4.Addresses[0].Bits() + + iface.IPv4Network = IPv4Network{ + Addresses: AddressesIPv4{ + Primary: Primary{ + Address: ip, + Netmask: strconv.Itoa(ipNet), + }, + }, + } + } + } - physif.Name = req.Interface.Spec.Name - physif.Description = req.Interface.Spec.Description + //Configure bundle member + ifaceBundeConf := &PhysIf{} + ifaceBundeConf.Name = name + if bundle_name != "" { + bundle_id, _ := ExtractBundleIdAndVlanTagsFromName(bundle_name) + ifaceBundeConf.BundleMember = BundleMember{ + ID: BundleID{ + BundleID: bundle_id, + PortAcivity: string(PortActivityOn), + }, + } + conf = append(conf, ifaceBundeConf) + } - physif.Statistics.LoadInterval = 30 - owner, err := ExtractMTUOwnerFromIfaceName(name) - if err != nil { - message := "failed to extract MTU owner from interface name" + name - return errors.New(message) - } - physif.MTUs = MTUs{MTU: []MTU{{MTU: req.Interface.Spec.MTU, Owner: string(owner)}}} - - // (fixme): for the moment it is enough to keep this static - // option1: extend existing interface spec - // option2: create a custom iosxr config - physif.Shutdown = gnmiext.Empty(false) - if req.Interface.Spec.AdminState == v1alpha1.AdminStateDown { - physif.Shutdown = gnmiext.Empty(true) - } - physif.Statistics.LoadInterval = uint8(30) + // (fixme): for the moment it is enought to keep this static + // option1: extend existing interface spec + // option2: create a custom iosxr config + iface.Shutdown = gnmiext.Empty(false) + if req.Interface.Spec.AdminState == v1alpha1.AdminStateDown { + iface.Shutdown = gnmiext.Empty(true) + } + conf = append(conf, iface) - if len(req.Interface.Spec.IPv4.Addresses) == 0 { - message := "no IPv4 address configured for interface " + name - return errors.New(message) - } + return updateInteface(ctx, p.client, conf...) - if len(req.Interface.Spec.IPv4.Addresses) > 1 { - message := "multiple IPv4 addresses configured for interface " + name - return errors.New(message) - } + case v1alpha1.InterfaceTypeAggregate: + if err := CheckInterfaceNameTypeAggregate(name); err != nil { + return err + } - // (fixme): support IPv6 addresses, IPv6 neighbor config - ip := req.Interface.Spec.IPv4.Addresses[0].Addr().String() - ipNet := req.Interface.Spec.IPv4.Addresses[0].Bits() - - physif.IPv4Network = IPv4Network{ - Addresses: AddressesIPv4{ - Primary: Primary{ - Address: ip, - Netmask: strconv.Itoa(ipNet), - }, - }, - } + //Presence of an outerVlan Tag indicates a subinterface + //BE. + _, outerVlan := ExtractBundleIdAndVlanTagsFromName(name) + + if outerVlan != req.Interface.Spec.Switchport.AccessVlan { + message := fmt.Sprintf("AccesVlan must match bundle-ether name pattern BE.. %d != %d", + outerVlan, req.Interface.Spec.Switchport.AccessVlan) + return errors.New(message) + } - // Check if interface exists otherwise patch will fail - tmpPhysif := &PhysIf{} - tmpPhysif.Name = name + iface := &BundleInterface{} + iface.Name = name + iface.Description = req.Interface.Spec.Description + + if outerVlan != 0 { + iface.ModeNoPhysical = "default" + + iface.SubInterface = VlanSubInterface{ + VlanIdentifier: VlanIdentifier{ + FirstTag: outerVlan, + SecondTag: req.Interface.Spec.Switchport.AccessVlan, + VlanType: "vlan-type-dot1q", + }, + } + + //Subinterface configures QAndQ vlan + if req.Interface.Spec.Switchport.AccessVlan != 0 { + iface.SubInterface.VlanIdentifier.SecondTag = req.Interface.Spec.Switchport.AccessVlan + iface.SubInterface.VlanIdentifier.VlanType = "vlan-type-dot1ad" + } + + } else { + //Set Interface mode to virtual for bundle interfaces + iface.Mode = gnmiext.Empty(true) + + iface.Statistics.LoadInterval = uint8(30) + + mtu, err := NewMTU(name, req.Interface.Spec.MTU) + if err != nil { + return err + } + iface.MTUs = mtu + + iface.Bundle = Bundle{ + MinAct: MinimumActive{ + Links: 1, + }, + } - err = p.client.GetConfig(ctx, tmpPhysif) - if err != nil { - // Interface does not exist, create it - err = p.client.Update(ctx, physif) - if err != nil { - return fmt.Errorf("failed to create interface %s: %w", req.Interface.Spec.Name, err) } - return nil + return updateInteface(ctx, p.client, iface) } + return nil +} - err = p.client.Update(ctx, physif) +func NewMTU(intName string, mtu int32) (MTUs, error) { + owner, err := ExractMTUOwnerFromIfaceName(intName) if err != nil { - return err + message := "failed to extract MTU owner from interface name" + intName + return MTUs{}, errors.New(message) } + return MTUs{MTU: []MTU{{ + MTU: mtu, + Owner: string(owner), + }}}, nil + +} +func updateInteface(ctx context.Context, client gnmiext.Client, conf ...gnmiext.Configurable) error { + for _, cf := range conf { + // Check if an interface exists otherwise patch will fail + got := cp.Deep(cf) + err := client.GetConfig(ctx, got) + if err != nil { + // Interface does not exist, create it + err = client.Create(ctx, cf) + if err == nil { + continue + } + return err + + } + err = client.Patch(ctx, cf) + if err != nil { + return err + } + + } return nil + } func (p *Provider) DeleteInterface(ctx context.Context, req *provider.InterfaceRequest) error { From 3708c300c99e53e6d5b7d98839943e2375487742 Mon Sep 17 00:00:00 2001 From: Sven Rosenzweig Date: Thu, 18 Dec 2025 13:36:47 +0100 Subject: [PATCH 4/4] (fix): Handle none existing config in case of creation For Bundle- and Bundlesubinterfaces creation fails, as the gnmi Update/Patch call, checks whether the object exists or not. In our case object, we try to create, does not exists in the conf DB, so the call will fail. --- internal/provider/cisco/gnmiext/v2/client.go | 33 ++++++++++++------- .../provider/cisco/iosxr/provider_test.go | 4 +++ 2 files changed, 25 insertions(+), 12 deletions(-) diff --git a/internal/provider/cisco/gnmiext/v2/client.go b/internal/provider/cisco/gnmiext/v2/client.go index bb20d5df..4d25c6f2 100644 --- a/internal/provider/cisco/gnmiext/v2/client.go +++ b/internal/provider/cisco/gnmiext/v2/client.go @@ -63,6 +63,7 @@ type Client interface { Patch(ctx context.Context, conf ...Configurable) error Update(ctx context.Context, conf ...Configurable) error Delete(ctx context.Context, conf ...Configurable) error + Create(ctx context.Context, conf ...Configurable) error } // Client is a gNMI client offering convenience methods for device configuration @@ -148,14 +149,18 @@ func (c *client) GetState(ctx context.Context, conf ...Configurable) error { // If the current configuration equals the desired configuration, the operation is skipped. // For partial updates that merge changes, use [Client.Patch] instead. func (c *client) Update(ctx context.Context, conf ...Configurable) error { - return c.set(ctx, false, conf...) + return c.set(ctx, false, true, conf...) } // Patch merges the configuration for the given set of items. // If the current configuration equals the desired configuration, the operation is skipped. // For full replacement of configuration, use [Client.Update] instead. func (c *client) Patch(ctx context.Context, conf ...Configurable) error { - return c.set(ctx, true, conf...) + return c.set(ctx, true, true, conf...) +} + +func (c *client) Create(ctx context.Context, conf ...Configurable) error { + return c.set(ctx, false, false, conf...) } // Delete resets the configuration for the given set of items. @@ -261,7 +266,7 @@ func (c *client) get(ctx context.Context, dt gpb.GetRequest_DataType, conf ...Co // configuration. Otherwise, a full replacement is done. // If the current configuration equals the desired configuration, the operation // is skipped. -func (c *client) set(ctx context.Context, patch bool, conf ...Configurable) error { +func (c *client) set(ctx context.Context, patch bool, retrieve bool, conf ...Configurable) error { if len(conf) == 0 { return nil } @@ -272,16 +277,20 @@ func (c *client) set(ctx context.Context, patch bool, conf ...Configurable) erro return err } got := cp.Deep(cf) - err = c.GetConfig(ctx, got) - if err != nil && !errors.Is(err, ErrNil) { - return fmt.Errorf("gnmiext: failed to retrieve current config for %s: %w", cf.XPath(), err) - } - // If the current configuration is equal to the desired configuration, skip the update. - // This avoids unnecessary updates and potential disruptions. - if err == nil && reflect.DeepEqual(cf, got) { - c.logger.V(1).Info("Configuration is already up-to-date", "path", cf.XPath()) - continue + + if retrieve { + err = c.GetConfig(ctx, got) + if err != nil && !errors.Is(err, ErrNil) { + return fmt.Errorf("gnmiext: failed to retrieve current config for %s: %w", cf.XPath(), err) + } + // If the current configuration is equal to the desired configuration, skip the update. + // This avoids unnecessary updates and potential disruptions. + if err == nil && reflect.DeepEqual(cf, got) { + c.logger.V(1).Info("Configuration is already up-to-date", "path", cf.XPath()) + continue + } } + b, err := c.Marshal(cf) if err != nil { return err diff --git a/internal/provider/cisco/iosxr/provider_test.go b/internal/provider/cisco/iosxr/provider_test.go index 55e90be3..74214876 100644 --- a/internal/provider/cisco/iosxr/provider_test.go +++ b/internal/provider/cisco/iosxr/provider_test.go @@ -116,6 +116,10 @@ func (m *MockClient) GetConfig(ctx context.Context, conf ...gnmiext.Configurable return nil } +func (m *MockClient) Create(ctx context.Context, conf ...gnmiext.Configurable) error { + return nil +} + func (m *MockClient) GetState(ctx context.Context, conf ...gnmiext.Configurable) error { if m.GetStateFunc != nil { return m.GetStateFunc(ctx, conf...)