diff --git a/Tiltfile b/Tiltfile index ad9857573..f3662dbc2 100644 --- a/Tiltfile +++ b/Tiltfile @@ -83,6 +83,8 @@ k8s_resource(new_name='po10', objects=['po-10:interface'], trigger_mode=TRIGGER_ k8s_resource(new_name='eth1-3', objects=['eth1-3:interface'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) k8s_resource(new_name='po20', objects=['po-20:interface'], resource_deps=['eth1-3'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) k8s_resource(new_name='svi-10', objects=['svi-10:interface'], resource_deps=['vlan-10'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) +k8s_resource(new_name='lo2', objects=['lo2:interface'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) +k8s_resource(new_name='eth1-4', objects=['eth1-4:interface'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) k8s_yaml('./config/samples/v1alpha1_banner.yaml') k8s_resource(new_name='banner', objects=['banner:banner'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) diff --git a/api/core/v1alpha1/interface_types.go b/api/core/v1alpha1/interface_types.go index 4560c50a5..8b48a6332 100644 --- a/api/core/v1alpha1/interface_types.go +++ b/api/core/v1alpha1/interface_types.go @@ -13,6 +13,7 @@ import ( // InterfaceSpec defines the desired state of Interface. // +kubebuilder:validation:XValidation:rule="!has(self.switchport) || !has(self.ipv4)", message="switchport and ipv4 are mutually exclusive" +// +kubebuilder:validation:XValidation:rule="!has(self.switchport) || !has(self.ipv6)", message="switchport and ipv6 are mutually exclusive" // +kubebuilder:validation:XValidation:rule="self.type != 'Loopback' || !has(self.switchport)", message="switchport must not be specified for interfaces of type Loopback" // +kubebuilder:validation:XValidation:rule="self.type == 'Physical' || !has(self.ipv4) || !has(self.ipv4.unnumbered)", message="unnumbered ipv4 configuration can only be used for interfaces of type Physical" // +kubebuilder:validation:XValidation:rule="self.type != 'Aggregate' || has(self.aggregation)", message="aggregation must be specified for interfaces of type Aggregate" @@ -80,6 +81,10 @@ type InterfaceSpec struct { // +optional IPv4 *InterfaceIPv4 `json:"ipv4,omitempty"` + // IPv6 defines the IPv6 configuration for the interface. + // +optional + IPv6 *InterfaceIPv6 `json:"ipv6,omitempty"` + // Aggregation defines the aggregation (bundle) configuration for the interface. // This is only applicable for interfaces of type Aggregate. // +optional @@ -289,6 +294,28 @@ type InterfaceIPv4Unnumbered struct { InterfaceRef LocalObjectReference `json:"interfaceRef"` } +// InterfaceIPv6 defines the IPv6 configuration for an interface. +// +kubebuilder:validation:XValidation:rule="has(self.addresses) || (has(self.useLinkLocalOnly) && self.useLinkLocalOnly)", message="either addresses or useLinkLocalOnly must be specified" +// +kubebuilder:validation:XValidation:rule="!has(self.addresses) || !has(self.useLinkLocalOnly) || !self.useLinkLocalOnly", message="addresses and useLinkLocalOnly are mutually exclusive" +type InterfaceIPv6 struct { + // Addresses defines the list of global unicast IPv6 addresses assigned to + // the interface. Unlike IPv4, all addresses are equal, there is no primary + // or secondary distinction. + // Link-local addresses cannot be assigned here, they are configured through + // UseLinkLocalOnly. + // +optional + // +listType=atomic + // +kubebuilder:validation:MinItems=1 + Addresses []IPPrefix `json:"addresses,omitempty"` + + // UseLinkLocalOnly configures the interface to operate with only its + // automatically generated IPv6 link-local address, without assigning a + // global address. This is what unnumbered, interface-based BGP peering + // requires in order to discover neighbours over their link-local address. + // +optional + UseLinkLocalOnly bool `json:"useLinkLocalOnly,omitempty"` +} + // BFD defines the Bidirectional Forwarding Detection configuration for an interface. type BFD struct { // Enabled indicates whether BFD is enabled on the interface. @@ -600,6 +627,11 @@ func (in *Interface) HasIPv4() bool { return in.Spec.Switchport == nil && in.Spec.IPv4 != nil } +// HasIPv6 reports whether the Interface is configured as a routed IPv6 interface. +func (in *Interface) HasIPv6() bool { + return in.Spec.Switchport == nil && in.Spec.IPv6 != nil +} + // GetConditions implements conditions.Getter. func (in *Interface) GetConditions() []metav1.Condition { return in.Status.Conditions diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go index 5e0a83e3a..7ceabafe5 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -2490,6 +2490,28 @@ func (in *InterfaceIPv4Unnumbered) DeepCopy() *InterfaceIPv4Unnumbered { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *InterfaceIPv6) DeepCopyInto(out *InterfaceIPv6) { + *out = *in + if in.Addresses != nil { + in, out := &in.Addresses, &out.Addresses + *out = make([]IPPrefix, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InterfaceIPv6. +func (in *InterfaceIPv6) DeepCopy() *InterfaceIPv6 { + if in == nil { + return nil + } + out := new(InterfaceIPv6) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *InterfaceList) DeepCopyInto(out *InterfaceList) { *out = *in @@ -2561,6 +2583,11 @@ func (in *InterfaceSpec) DeepCopyInto(out *InterfaceSpec) { *out = new(InterfaceIPv4) (*in).DeepCopyInto(*out) } + if in.IPv6 != nil { + in, out := &in.IPv6, &out.IPv6 + *out = new(InterfaceIPv6) + (*in).DeepCopyInto(*out) + } if in.Aggregation != nil { in, out := &in.Aggregation, &out.Aggregation *out = new(Aggregation) diff --git a/charts/network-operator/templates/crd/interfaces.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/interfaces.networking.metal.ironcore.dev.yaml index 3469fc02a..8170a14a8 100644 --- a/charts/network-operator/templates/crd/interfaces.networking.metal.ironcore.dev.yaml +++ b/charts/network-operator/templates/crd/interfaces.networking.metal.ironcore.dev.yaml @@ -332,6 +332,35 @@ spec: rule: '!has(self.addresses) || !has(self.unnumbered)' - message: anycastGateway and unnumbered are mutually exclusive rule: '!has(self.unnumbered) || !self.anycastGateway' + ipv6: + description: IPv6 defines the IPv6 configuration for the interface. + properties: + addresses: + description: |- + Addresses defines the list of global unicast IPv6 addresses assigned to + the interface. Unlike IPv4, all addresses are equal, there is no primary + or secondary distinction. + Link-local addresses cannot be assigned here, they are configured through + UseLinkLocalOnly. + items: + format: cidr + type: string + minItems: 1 + type: array + x-kubernetes-list-type: atomic + useLinkLocalOnly: + description: |- + UseLinkLocalOnly configures the interface to operate with only its + automatically generated IPv6 link-local address, without assigning a + global address. This is what unnumbered, interface-based BGP peering + requires in order to discover neighbours over their link-local address. + type: boolean + type: object + x-kubernetes-validations: + - message: either addresses or useLinkLocalOnly must be specified + rule: has(self.addresses) || (has(self.useLinkLocalOnly) && self.useLinkLocalOnly) + - message: addresses and useLinkLocalOnly are mutually exclusive + rule: '!has(self.addresses) || !has(self.useLinkLocalOnly) || !self.useLinkLocalOnly' mtu: description: MTU (Maximum Transmission Unit) specifies the size of the largest packet that can be sent over the interface. @@ -515,6 +544,8 @@ spec: x-kubernetes-validations: - message: switchport and ipv4 are mutually exclusive rule: '!has(self.switchport) || !has(self.ipv4)' + - message: switchport and ipv6 are mutually exclusive + rule: '!has(self.switchport) || !has(self.ipv6)' - message: switchport must not be specified for interfaces of type Loopback rule: self.type != 'Loopback' || !has(self.switchport) - message: unnumbered ipv4 configuration can only be used for interfaces diff --git a/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml b/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml index 8fc99eb8d..d760f983e 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml @@ -329,6 +329,35 @@ spec: rule: '!has(self.addresses) || !has(self.unnumbered)' - message: anycastGateway and unnumbered are mutually exclusive rule: '!has(self.unnumbered) || !self.anycastGateway' + ipv6: + description: IPv6 defines the IPv6 configuration for the interface. + properties: + addresses: + description: |- + Addresses defines the list of global unicast IPv6 addresses assigned to + the interface. Unlike IPv4, all addresses are equal, there is no primary + or secondary distinction. + Link-local addresses cannot be assigned here, they are configured through + UseLinkLocalOnly. + items: + format: cidr + type: string + minItems: 1 + type: array + x-kubernetes-list-type: atomic + useLinkLocalOnly: + description: |- + UseLinkLocalOnly configures the interface to operate with only its + automatically generated IPv6 link-local address, without assigning a + global address. This is what unnumbered, interface-based BGP peering + requires in order to discover neighbours over their link-local address. + type: boolean + type: object + x-kubernetes-validations: + - message: either addresses or useLinkLocalOnly must be specified + rule: has(self.addresses) || (has(self.useLinkLocalOnly) && self.useLinkLocalOnly) + - message: addresses and useLinkLocalOnly are mutually exclusive + rule: '!has(self.addresses) || !has(self.useLinkLocalOnly) || !self.useLinkLocalOnly' mtu: description: MTU (Maximum Transmission Unit) specifies the size of the largest packet that can be sent over the interface. @@ -512,6 +541,8 @@ spec: x-kubernetes-validations: - message: switchport and ipv4 are mutually exclusive rule: '!has(self.switchport) || !has(self.ipv4)' + - message: switchport and ipv6 are mutually exclusive + rule: '!has(self.switchport) || !has(self.ipv6)' - message: switchport must not be specified for interfaces of type Loopback rule: self.type != 'Loopback' || !has(self.switchport) - message: unnumbered ipv4 configuration can only be used for interfaces diff --git a/config/samples/v1alpha1_interface.yaml b/config/samples/v1alpha1_interface.yaml index 51fc040b9..e6fb8bf26 100644 --- a/config/samples/v1alpha1_interface.yaml +++ b/config/samples/v1alpha1_interface.yaml @@ -239,3 +239,44 @@ spec: addresses: - 192.168.10.254/24 anycastGateway: true +--- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + networking.metal.ironcore.dev/device-name: leaf1 + name: lo2 +spec: + deviceRef: + name: leaf1 + name: lo2 + description: IPv6 Router-ID Leaf1 + adminState: Up + type: Loopback + mtu: 1500 + ipv6: + addresses: + - 2001:db8::10/128 +--- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + networking.metal.ironcore.dev/device-name: leaf1 + name: eth1-4 +spec: + deviceRef: + name: leaf1 + name: eth1/4 + description: Unnumbered uplink for interface-based BGP peering + adminState: Up + type: Physical + mtu: 9216 + # Use only the automatically generated IPv6 link-local address, so the link + # needs no addressing of its own. Mutually exclusive with ipv6.addresses. + ipv6: + useLinkLocalOnly: true diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index f55f0b4e8..f89990a8f 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -2259,6 +2259,7 @@ _Appears in:_ - [IPPrefixPoolSpec](#ipprefixpoolspec) - [IPPrefixSpec](#ipprefixspec) - [InterfaceIPv4](#interfaceipv4) +- [InterfaceIPv6](#interfaceipv6) - [MulticastGroups](#multicastgroups) - [PrefixEntry](#prefixentry) - [RendezvousPoint](#rendezvouspoint) @@ -2434,6 +2435,23 @@ _Appears in:_ | `interfaceRef` _[LocalObjectReference](#localobjectreference)_ | InterfaceRef is a reference to the interface from which to borrow the IP address.
The referenced interface must exist and have at least one IPv4 address configured. | | Required: \{\}
| +#### InterfaceIPv6 + + + +InterfaceIPv6 defines the IPv6 configuration for an interface. + + + +_Appears in:_ +- [InterfaceSpec](#interfacespec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `addresses` _[IPPrefix](#ipprefix) array_ | Addresses defines the list of global unicast IPv6 addresses assigned to
the interface. Unlike IPv4, all addresses are equal, there is no primary
or secondary distinction.
Link-local addresses cannot be assigned here, they are configured through
UseLinkLocalOnly. | | Format: cidr
MinItems: 1
Type: string
Optional: \{\}
| +| `useLinkLocalOnly` _boolean_ | UseLinkLocalOnly configures the interface to operate with only its
automatically generated IPv6 link-local address, without assigning a
global address. This is what unnumbered, interface-based BGP peering
requires in order to discover neighbours over their link-local address. | | Optional: \{\}
| + + #### InterfaceSource @@ -2474,6 +2492,7 @@ _Appears in:_ | `mtu` _integer_ | MTU (Maximum Transmission Unit) specifies the size of the largest packet that can be sent over the interface. | | Maximum: 9216
Minimum: 576
Optional: \{\}
| | `switchport` _[Switchport](#switchport)_ | Switchport defines the switchport configuration for the interface.
This is only applicable for Ethernet and Aggregate interfaces. | | Optional: \{\}
| | `ipv4` _[InterfaceIPv4](#interfaceipv4)_ | IPv4 defines the IPv4 configuration for the interface. | | Optional: \{\}
| +| `ipv6` _[InterfaceIPv6](#interfaceipv6)_ | IPv6 defines the IPv6 configuration for the interface. | | Optional: \{\}
| | `aggregation` _[Aggregation](#aggregation)_ | Aggregation defines the aggregation (bundle) configuration for the interface.
This is only applicable for interfaces of type Aggregate. | | Optional: \{\}
| | `vlanRef` _[LocalObjectReference](#localobjectreference)_ | VlanRef is a reference to the VLAN resource that this interface provides routing for.
This is only applicable for interfaces of type RoutedVLAN.
The referenced VLAN must exist in the same namespace. | | Optional: \{\}
| | `vrfRef` _[LocalObjectReference](#localobjectreference)_ | VrfRef is a reference to the VRF resource that this interface belongs to.
If not specified, the interface will be part of the default VRF.
This is only applicable for Layer 3 interfaces.
The referenced VRF must exist in the same namespace. | | Optional: \{\}
| diff --git a/hack/provider/main.go b/hack/provider/main.go index 401d6111f..097e48c4e 100644 --- a/hack/provider/main.go +++ b/hack/provider/main.go @@ -662,6 +662,20 @@ func performCreate(ctx context.Context, prov provider.Provider, obj client.Objec } } + var ipv6 provider.IPv6 + if res.Spec.IPv6 != nil { + switch { + case res.Spec.IPv6.UseLinkLocalOnly: + ipv6 = provider.IPv6LinkLocalOnly{} + case len(res.Spec.IPv6.Addresses) > 0: + addrs := make([]netip.Prefix, len(res.Spec.IPv6.Addresses)) + for i, addr := range res.Spec.IPv6.Addresses { + addrs[i] = addr.Prefix + } + ipv6 = provider.IPv6AddressList(addrs) + } + } + var members []*v1alpha1.Interface if res.Spec.Type == v1alpha1.InterfaceTypeAggregate && len(res.Spec.Aggregation.MemberInterfaceRefs) > 0 { if len(refStore) == 0 { @@ -697,6 +711,7 @@ func performCreate(ctx context.Context, prov provider.Provider, obj client.Objec return ip.EnsureInterface(ctx, &provider.EnsureInterfaceRequest{ Interface: res, IPv4: ipv4, + IPv6: ipv6, Members: members, MultiChassisID: multiChassisID, VLAN: vlan, diff --git a/internal/controller/core/interface_controller.go b/internal/controller/core/interface_controller.go index 8123f5f9b..306c12db5 100644 --- a/internal/controller/core/interface_controller.go +++ b/internal/controller/core/interface_controller.go @@ -369,6 +369,7 @@ func (r *InterfaceReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Man // Only trigger when fields that affect member Physical interface // reconciliation change (e.g. layer, VRF membership, MTU). return !equality.Semantic.DeepEqual(oldIntf.Spec.IPv4, newIntf.Spec.IPv4) || + !equality.Semantic.DeepEqual(oldIntf.Spec.IPv6, newIntf.Spec.IPv6) || !equality.Semantic.DeepEqual(oldIntf.Spec.Switchport, newIntf.Spec.Switchport) || !equality.Semantic.DeepEqual(oldIntf.Spec.VrfRef, newIntf.Spec.VrfRef) || oldIntf.Spec.MTU != newIntf.Spec.MTU @@ -569,6 +570,8 @@ func (r *InterfaceReconciler) reconcile(ctx context.Context, s *scope) (reterr e } } + ipv6 := interfaceIPv6(s.Interface.Spec.IPv6) + if err := s.Provider.Connect(ctx, s.Connection); err != nil { return fmt.Errorf("failed to connect to provider: %w", err) } @@ -583,6 +586,7 @@ func (r *InterfaceReconciler) reconcile(ctx context.Context, s *scope) (reterr e Interface: s.Interface, ProviderConfig: s.ProviderConfig, IPv4: ip, + IPv6: ipv6, Members: members, MultiChassisID: multiChassisID, AggregateParent: aggregateParent, @@ -777,6 +781,24 @@ func (r *InterfaceReconciler) validateLLDPAdjacencyThroughAnnotation(ctx context return v1alpha1.NeighborVerified, nil } +// interfaceIPv6 maps the IPv6 spec of an Interface to the provider representation. +// It returns nil if the Interface has no IPv6 configuration. +func interfaceIPv6(spec *v1alpha1.InterfaceIPv6) provider.IPv6 { + switch { + case spec == nil: + return nil + case spec.UseLinkLocalOnly: + return provider.IPv6LinkLocalOnly{} + case len(spec.Addresses) > 0: + addrs := make([]netip.Prefix, len(spec.Addresses)) + for i, addr := range spec.Addresses { + addrs[i] = addr.Prefix + } + return provider.IPv6AddressList(addrs) + } + return nil +} + func (r *InterfaceReconciler) reconcileIPv4(ctx context.Context, s *scope) (provider.IPv4, error) { switch { case len(s.Interface.Spec.IPv4.Addresses) > 0: diff --git a/internal/provider/cisco/nxos/intf.go b/internal/provider/cisco/nxos/intf.go index ba223f47d..e152e9519 100644 --- a/internal/provider/cisco/nxos/intf.go +++ b/internal/provider/cisco/nxos/intf.go @@ -406,7 +406,14 @@ func (d *AddrDom) Key() string { return d.Name } type AddrItem struct { ID string `json:"id"` Unnumbered string `json:"unnumbered,omitempty"` - AddrItems struct { + + // UseLinkLocalAddr configures the interface to use only its automatically + // generated IPv6 link-local address ("ipv6 address use-link-local-only"). + // Only set for IPv6; the IPv4 object has no such property, so it must stay + // empty there to keep the payload free of it. + UseLinkLocalAddr AdminSt `json:"useLinkLocalAddr,omitempty"` + + AddrItems struct { AddrList gnmiext.List[string, *IntfAddr] `json:"Addr-list,omitzero"` } `json:"addr-items,omitzero"` diff --git a/internal/provider/cisco/nxos/intf_test.go b/internal/provider/cisco/nxos/intf_test.go index 5bb4bc806..dcfdf0506 100644 --- a/internal/provider/cisco/nxos/intf_test.go +++ b/internal/provider/cisco/nxos/intf_test.go @@ -137,6 +137,14 @@ func init() { }) Register("intf_addr4", intfAddr4) + intfAddr6 := &AddrItem{ID: "lo0", Vrf: DefaultVRFName, Is6: true, UseLinkLocalAddr: AdminStDisabled} + intfAddr6.AddrItems.AddrList.Set(&IntfAddr{Addr: "2001:db8:1::1/64", Type: IntfAddrTypePrimary}) + intfAddr6.AddrItems.AddrList.Set(&IntfAddr{Addr: "2001:db8:2::1/64", Type: IntfAddrTypePrimary}) + Register("intf_addr6", intfAddr6) + + // "ipv6 address use-link-local-only", as required for unnumbered peering. + Register("intf_lladdr6", &AddrItem{ID: "eth1/1", Vrf: DefaultVRFName, Is6: true, UseLinkLocalAddr: AdminStEnabled}) + pc := &PortChannel{ AccessVlan: DefaultVLAN, AdminSt: AdminStUp, diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index 8e1a49a42..451898e88 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -1276,6 +1276,28 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte } } } + var ipv6Addr *AddrItem + if req.IPv6 != nil { + ipv6Addr = new(AddrItem) + ipv6Addr.ID = name + ipv6Addr.Vrf = vrf + ipv6Addr.Is6 = true + // Always sent so that turning link-local-only off is reconciled, and so + // the payload matches what the device reports back. + ipv6Addr.UseLinkLocalAddr = AdminStDisabled + + switch v := req.IPv6.(type) { + case provider.IPv6AddressList: + // Unlike IPv4, IPv6 has no primary/secondary distinction: an + // interface simply carries multiple equal addresses. + for _, p := range v { + ipv6Addr.AddrItems.AddrList.Set(&IntfAddr{Addr: p.String(), Type: IntfAddrTypePrimary}) + } + + case provider.IPv6LinkLocalOnly: + ipv6Addr.UseLinkLocalAddr = AdminStEnabled + } + } addrs := new(AddrList) if err := p.client.GetConfig(ctx, addrs); err != nil && !errors.Is(err, gnmiext.ErrNil) { @@ -1286,6 +1308,15 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte sb.Delete(a) } } + ipv6Addrs := &AddrList{Is6: true} + if err := p.client.GetConfig(ctx, ipv6Addrs); err != nil && !errors.Is(err, gnmiext.ErrNil) { + return err + } + for _, a := range ipv6Addrs.GetAddrItemsByInterface(name) { + if ipv6Addr == nil || a.Vrf != vrf { + sb.Delete(a) + } + } switch req.Interface.Spec.Type { case v1alpha1.InterfaceTypePhysical: @@ -1323,7 +1354,7 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte // If this Physical interface is a member of an L3 Aggregate (port-channel), // it must be Layer3 on NX-OS even though it has no IP address of its own. - if req.IPv4 != nil || (req.AggregateParent != nil && req.AggregateParent.Spec.IPv4 != nil) { + if req.IPv4 != nil || req.IPv6 != nil || (req.AggregateParent != nil && (req.AggregateParent.Spec.IPv4 != nil || req.AggregateParent.Spec.IPv6 != nil)) { p.Layer = Layer3 p.RtvrfMbrItems = NewVrfMember(name, vrf) p.AccessVlan = string(AdjOperStUnknown) @@ -1420,7 +1451,7 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte pc.UserCfgdFlags |= UserFlagAdminMTU } - if req.IPv4 != nil { + if req.IPv4 != nil || req.IPv6 != nil { pc.Layer = Layer3 pc.RtvrfMbrItems = NewVrfMember(name, vrf) pc.AccessVlan = "unknown" @@ -1577,7 +1608,7 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte } s.Encap = encap - if req.IPv4 != nil { + if req.IPv4 != nil || req.IPv6 != nil { s.RtvrfMbrItems = NewVrfMember(name, vrf) } @@ -1595,7 +1626,7 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte }) } - if (req.Interface.Spec.Type == v1alpha1.InterfaceTypePhysical || req.Interface.Spec.Type == v1alpha1.InterfaceTypeAggregate) && req.IPv4 == nil && (req.AggregateParent == nil || req.AggregateParent.Spec.IPv4 == nil) { + if (req.Interface.Spec.Type == v1alpha1.InterfaceTypePhysical || req.Interface.Spec.Type == v1alpha1.InterfaceTypeAggregate) && req.IPv4 == nil && req.IPv6 == nil && (req.AggregateParent == nil || (req.AggregateParent.Spec.IPv4 == nil && req.AggregateParent.Spec.IPv6 == nil)) { stp := new(SpanningTree) stp.IfName = name stp.Mode = SpanningTreeModeDefault @@ -1632,6 +1663,9 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte if addr != nil { sb.Patch(addr) } + if ipv6Addr != nil { + sb.Patch(ipv6Addr) + } switch { case req.Interface.Spec.BFD != nil && req.Interface.Spec.BFD.Enabled: @@ -1721,6 +1755,14 @@ func (p *Provider) DeleteInterface(ctx context.Context, req *provider.InterfaceR sb.Delete(addr) } + ipv6Addrs := &AddrList{Is6: true} + if err := p.client.GetConfig(ctx, ipv6Addrs); err != nil && !errors.Is(err, gnmiext.ErrNil) { + return err + } + for _, addr := range ipv6Addrs.GetAddrItemsByInterface(name) { + sb.Delete(addr) + } + bfd := new(BFD) bfd.ID = name sb.Delete(bfd) diff --git a/internal/provider/cisco/nxos/testdata/intf_addr6.json b/internal/provider/cisco/nxos/testdata/intf_addr6.json new file mode 100644 index 000000000..7d099255b --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/intf_addr6.json @@ -0,0 +1,37 @@ +{ + "ipv6-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "id": "lo0", + "useLinkLocalAddr": "disabled", + "addr-items": { + "Addr-list": [ + { + "addr": "2001:db8:1::1/64", + "pref": 0, + "tag": 0, + "type": "primary" + }, + { + "addr": "2001:db8:2::1/64", + "pref": 0, + "tag": 0, + "type": "primary" + } + ] + } + } + ] + } + } + ] + } + } + } +} diff --git a/internal/provider/cisco/nxos/testdata/intf_addr6.json.txt b/internal/provider/cisco/nxos/testdata/intf_addr6.json.txt new file mode 100644 index 000000000..96ae0b5e4 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/intf_addr6.json.txt @@ -0,0 +1,3 @@ +interface Loopback0 + ipv6 address 2001:db8:1::1/64 + ipv6 address 2001:db8:2::1/64 diff --git a/internal/provider/cisco/nxos/testdata/intf_lladdr6.json b/internal/provider/cisco/nxos/testdata/intf_lladdr6.json new file mode 100644 index 000000000..1cbd30c16 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/intf_lladdr6.json @@ -0,0 +1,21 @@ +{ + "ipv6-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "id": "eth1/1", + "useLinkLocalAddr": "enabled" + } + ] + } + } + ] + } + } + } +} diff --git a/internal/provider/cisco/nxos/testdata/intf_lladdr6.json.txt b/internal/provider/cisco/nxos/testdata/intf_lladdr6.json.txt new file mode 100644 index 000000000..990d45124 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/intf_lladdr6.json.txt @@ -0,0 +1,3 @@ +interface Ethernet1/1 + no switchport + ipv6 address use-link-local-only diff --git a/internal/provider/openconfig/interface.go b/internal/provider/openconfig/interface.go index f57c42bcf..f4192e88a 100644 --- a/internal/provider/openconfig/interface.go +++ b/internal/provider/openconfig/interface.go @@ -19,6 +19,13 @@ var _ provider.InterfaceProvider = (*Provider)(nil) func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInterfaceRequest) error { spec := req.Interface.Spec + if _, ok := req.IPv6.(provider.IPv6LinkLocalOnly); ok { + return apistatus.NewUnsupportedFieldError(apistatus.FieldViolation{ + Field: "spec.ipv6.useLinkLocalOnly", + Description: "openconfig provider does not support link-local-only IPv6 interfaces", + }) + } + i := &Interface{ Name: spec.Name, Config: &InterfaceConfig{ @@ -113,7 +120,7 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte } } - if req.IPv4 != nil { + if req.IPv4 != nil || req.IPv6 != nil { sub := &Subinterface{ Index: 0, Config: &SubinterfaceConfig{Index: 0, Enabled: true}, @@ -155,6 +162,8 @@ func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInte } } + sub.IPv6 = newInterfaceIPv6(req.IPv6) + subs := &Subinterfaces{} subs.Subinterface.Set(sub) i.Subinterfaces = subs @@ -313,9 +322,38 @@ func (p *Provider) EnsureSubinterface(ctx context.Context, req *provider.EnsureI } } + sub.IPv6 = newInterfaceIPv6(req.IPv6) + return p.client.Update(ctx, sub) } +// newInterfaceIPv6 builds the IPv6 container of a subinterface, or nil if no +// IPv6 addresses were requested. Link-local-only is rejected by EnsureInterface +// before this is reached. +func newInterfaceIPv6(ipv6 provider.IPv6) *InterfaceIPv6 { + v, ok := ipv6.(provider.IPv6AddressList) + if !ok { + return nil + } + + addrs := &IPv6Addresses{} + for _, prefix := range v { + ip := prefix.Addr().String() + addrs.Address.Set(&IPv6Address{ + IP: ip, + Config: &IPv6AddressConfig{ + IP: ip, + PrefixLength: uint8(prefix.Bits()), //nolint:gosec + Type: IPv6AddressTypeGlobalUnicast, + }, + }) + } + return &InterfaceIPv6{ + Config: &InterfaceIPv6Config{Enabled: true}, + Addresses: addrs, + } +} + // InterfaceType represents the YANG identity for the interface type. type InterfaceType string @@ -476,6 +514,7 @@ type Subinterface struct { Index uint32 `json:"index"` Config *SubinterfaceConfig `json:"config,omitempty"` IPv4 *InterfaceIPv4 `json:"openconfig-if-ip:ipv4,omitempty"` + IPv6 *InterfaceIPv6 `json:"openconfig-if-ip:ipv6,omitempty"` } func (s *Subinterface) Key() uint32 { @@ -500,6 +539,49 @@ type InterfaceIPv4Config struct { Enabled bool `json:"enabled"` } +// InterfaceIPv6 holds the IPv6 container of a subinterface. +type InterfaceIPv6 struct { + Addresses *IPv6Addresses `json:"addresses"` + Config *InterfaceIPv6Config `json:"config"` +} + +// InterfaceIPv6Config holds the config container for IPv6. +type InterfaceIPv6Config struct { + Enabled bool `json:"enabled"` +} + +// IPv6Addresses holds the IPv6 address list container. +type IPv6Addresses struct { + Address gnmiext.List[string, *IPv6Address] `json:"address,omitempty"` +} + +// IPv6Address represents a single IPv6 address entry. +type IPv6Address struct { + IP string `json:"ip"` + Config *IPv6AddressConfig `json:"config,omitempty"` +} + +func (a *IPv6Address) Key() string { + return a.IP +} + +// IPv6AddressType represents the type of an IPv6 address. Unlike IPv4, IPv6 has +// no primary/secondary addresses. Only global unicast is ever configured here: +// link-local addresses are rejected by the webhook in favour of +// spec.ipv6.useLinkLocalOnly, which this provider does not support. +type IPv6AddressType string + +const ( + IPv6AddressTypeGlobalUnicast IPv6AddressType = "GLOBAL_UNICAST" +) + +// IPv6AddressConfig holds the config for a single IPv6 address. +type IPv6AddressConfig struct { + IP string `json:"ip"` + PrefixLength uint8 `json:"prefix-length"` + Type IPv6AddressType `json:"type,omitempty"` +} + // IPv4Addresses holds the IPv4 address list container. type IPv4Addresses struct { Address gnmiext.List[string, *IPv4Address] `json:"address,omitempty"` @@ -626,6 +708,7 @@ type SubinterfaceEntry struct { Index uint32 `json:"index"` Config *SubinterfaceConfig `json:"config,omitempty"` IPv4 *InterfaceIPv4 `json:"openconfig-if-ip:ipv4,omitempty"` + IPv6 *InterfaceIPv6 `json:"openconfig-if-ip:ipv6,omitempty"` Vlan *SubinterfaceVlan `json:"openconfig-vlan:vlan,omitempty"` } diff --git a/internal/provider/openconfig/interface_test.go b/internal/provider/openconfig/interface_test.go index 6711bd486..2dd4f365a 100644 --- a/internal/provider/openconfig/interface_test.go +++ b/internal/provider/openconfig/interface_test.go @@ -35,6 +35,33 @@ func TestOpenConfigTrunkVlansJSON(t *testing.T) { } } +func TestOpenConfigIPv6AddressJSON(t *testing.T) { + intf := &Subinterface{ + Index: 0, + IPv6: &InterfaceIPv6{ + Config: &InterfaceIPv6Config{Enabled: true}, + Addresses: &IPv6Addresses{}, + }, + } + intf.IPv6.Addresses.Address.Set(&IPv6Address{ + IP: "2001:db8::1", + Config: &IPv6AddressConfig{ + IP: "2001:db8::1", + PrefixLength: 64, + Type: IPv6AddressTypeGlobalUnicast, + }, + }) + + got, err := json.Marshal(intf) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + want := `{"index":0,"openconfig-if-ip:ipv6":{"addresses":{"address":[{"ip":"2001:db8::1","config":{"ip":"2001:db8::1","prefix-length":64,"type":"GLOBAL_UNICAST"}}]},"config":{"enabled":true}}}` + if string(got) != want { + t.Fatalf("json.Marshal() = %s, want %s", got, want) + } +} + func TestOpenConfigTrunkVlansXPath(t *testing.T) { tests := []struct { name string diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 36e789f5a..64a11b3cd 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -184,6 +184,7 @@ type EnsureInterfaceRequest struct { Interface *v1alpha1.Interface ProviderConfig *ProviderConfig IPv4 IPv4 + IPv6 IPv6 // Members is the list of member interfaces for aggregated interfaces. // This field is only applicable if the interface type is Aggregate. @@ -222,6 +223,20 @@ type IPv4Unnumbered struct { func (IPv4Unnumbered) isIPv4() {} +type IPv6 interface { + isIPv6() +} + +type IPv6AddressList []netip.Prefix + +func (IPv6AddressList) isIPv6() {} + +// IPv6LinkLocalOnly configures the interface to use only its automatically +// generated IPv6 link-local address, without a global address. +type IPv6LinkLocalOnly struct{} + +func (IPv6LinkLocalOnly) isIPv6() {} + type InterfaceStatus struct { // OperStatus indicates whether the interface is operationally up (true) or down (false). OperStatus bool diff --git a/internal/webhook/core/v1alpha1/interface_webhook.go b/internal/webhook/core/v1alpha1/interface_webhook.go index b6bd989c1..769fba9c2 100644 --- a/internal/webhook/core/v1alpha1/interface_webhook.go +++ b/internal/webhook/core/v1alpha1/interface_webhook.go @@ -74,6 +74,12 @@ func validateInterfaceSpec(intf *v1alpha1.Interface) error { } } + if intf.Spec.IPv6 != nil { + if err := validateInterfaceIPv6(intf.Spec.IPv6); err != nil { + errAgg = append(errAgg, err) + } + } + if intf.Spec.Switchport != nil { if err := validateSwitchport(intf.Spec.Switchport); err != nil { errAgg = append(errAgg, err) @@ -153,3 +159,24 @@ func validateInterfaceIPv4(ip *v1alpha1.InterfaceIPv4) error { } return errors.Join(errAgg...) } + +// validateInterfaceIPv6 performs validation on the InterfaceIPv6 spec. +func validateInterfaceIPv6(ip *v1alpha1.InterfaceIPv6) error { + var errAgg []error + for i, cidr := range ip.Addresses { + if !cidr.Prefix.Addr().Is6() { + errAgg = append(errAgg, fmt.Errorf("invalid IPv6 address %q: address is IPv4", cidr.String())) + continue + } + if cidr.Prefix.Addr().IsLinkLocalUnicast() { + errAgg = append(errAgg, fmt.Errorf("invalid IPv6 address %q: link-local addresses cannot be assigned, use useLinkLocalOnly instead", cidr.String())) + continue + } + for j := i + 1; j < len(ip.Addresses); j++ { + if p := ip.Addresses[j].Prefix; cidr.Overlaps(p) { + errAgg = append(errAgg, fmt.Errorf("invalid IPv6 address %q: overlaps with %q", cidr.String(), p.String())) + } + } + } + return errors.Join(errAgg...) +} diff --git a/internal/webhook/core/v1alpha1/interface_webhook_test.go b/internal/webhook/core/v1alpha1/interface_webhook_test.go index 1c5dfc31b..7c153f147 100644 --- a/internal/webhook/core/v1alpha1/interface_webhook_test.go +++ b/internal/webhook/core/v1alpha1/interface_webhook_test.go @@ -66,6 +66,36 @@ var _ = Describe("Interface Webhook", func() { Expect(err.Error()).To(ContainSubstring("address is IPv6")) }) + It("Should allow valid IPv6 addresses", func() { + obj.Spec.IPv6 = &v1alpha1.InterfaceIPv6{ + Addresses: []v1alpha1.IPPrefix{v1alpha1.MustParsePrefix("2001:db8::1/64")}, + } + + Expect(validateInterfaceSpec(obj)).To(Succeed()) + }) + + It("Should reject link-local addresses in the IPv6 address list", func() { + obj.Spec.IPv6 = &v1alpha1.InterfaceIPv6{ + Addresses: []v1alpha1.IPPrefix{v1alpha1.MustParsePrefix("fe80::1/64")}, + } + + Expect(validateInterfaceSpec(obj)).To(MatchError(ContainSubstring("use useLinkLocalOnly instead"))) + }) + + It("Should allow useLinkLocalOnly without addresses", func() { + obj.Spec.IPv6 = &v1alpha1.InterfaceIPv6{UseLinkLocalOnly: true} + + Expect(validateInterfaceSpec(obj)).To(Succeed()) + }) + + It("Should reject IPv4 addresses in IPv6 field", func() { + obj.Spec.IPv6 = &v1alpha1.InterfaceIPv6{ + Addresses: []v1alpha1.IPPrefix{v1alpha1.MustParsePrefix("192.0.2.1/31")}, + } + + Expect(validateInterfaceSpec(obj)).To(MatchError(ContainSubstring("invalid IPv6 address"))) + }) + It("Should reject overlapping IPv4 addresses", func() { obj.Spec.IPv4 = &v1alpha1.InterfaceIPv4{ Addresses: []v1alpha1.IPPrefix{ diff --git a/test/gnmi/testdata/nx.cisco.networking.metal.ironcore.dev/interface_loopback_multi_addr.txtar b/test/gnmi/testdata/nx.cisco.networking.metal.ironcore.dev/interface_loopback_multi_addr.txtar index 996a79086..7de9b6c86 100644 --- a/test/gnmi/testdata/nx.cisco.networking.metal.ironcore.dev/interface_loopback_multi_addr.txtar +++ b/test/gnmi/testdata/nx.cisco.networking.metal.ironcore.dev/interface_loopback_multi_addr.txtar @@ -15,6 +15,10 @@ spec: addresses: - 10.0.0.20/32 - 10.0.0.21/32 + ipv6: + addresses: + - 2001:db8:1::20/128 + - 2001:db8:2::21/128 -- state/preload -- { @@ -80,6 +84,41 @@ spec: } } }, + "ipv6-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "id": "lo1", + "useLinkLocalAddr": "disabled", + "addr-items": { + "Addr-list": [ + { + "addr": "2001:db8:1::20/128", + "pref": 0, + "tag": 0, + "type": "primary" + }, + { + "addr": "2001:db8:2::21/128", + "pref": 0, + "tag": 0, + "type": "primary" + } + ] + } + } + ] + } + } + ] + } + } + }, "icmpv4-items": { "inst-items": { "dom-items": { @@ -128,6 +167,20 @@ spec: } } }, + "ipv6-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [] + } + } + ] + } + } + }, "icmpv4-items": { "inst-items": { "dom-items": {