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": {