Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Tiltfile
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ k8s_resource(new_name='eth1-1', objects=['eth1-1:interface'], trigger_mode=TRIGG
k8s_resource(new_name='eth1-2', objects=['eth1-2:interface'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False)
k8s_resource(new_name='eth1-10', objects=['eth1-10:interface'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False)
k8s_resource(new_name='po10', objects=['po-10:interface'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False)
k8s_resource(new_name='eth1-3', objects=['eth1-3:interface'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False)
k8s_resource(new_name='po20', objects=['po-20:interface'], resource_deps=['eth1-3'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False)
k8s_resource(new_name='svi-10', objects=['svi-10:interface'], resource_deps=['vlan-10'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False)

k8s_yaml('./config/samples/v1alpha1_banner.yaml')
Expand Down
8 changes: 4 additions & 4 deletions api/core/v1alpha1/interface_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ import (
// +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"
// +kubebuilder:validation:XValidation:rule="self.type == 'Aggregate' || !has(self.aggregation)", message="aggregation must only be specified on interfaces of type Aggregate"
// +kubebuilder:validation:XValidation:rule="self.type != 'Aggregate' || !has(self.ipv4)", message="ipv4 must not be specified for interfaces of type Aggregate"
// +kubebuilder:validation:XValidation:rule="self.type != 'Aggregate' || !has(self.switchport) || !has(self.ipv4)", message="ipv4 must not be specified for Aggregate interfaces with switchport configuration"
// +kubebuilder:validation:XValidation:rule="self.type != 'RoutedVLAN' || has(self.vlanRef)", message="vlanRef must be specified for interfaces of type RoutedVLAN"
// +kubebuilder:validation:XValidation:rule="self.type == 'RoutedVLAN' || !has(self.vlanRef)", message="vlanRef must only be specified on interfaces of type RoutedVLAN"
// +kubebuilder:validation:XValidation:rule="self.type != 'RoutedVLAN' || !has(self.switchport)", message="switchport must not be specified for interfaces of type RoutedVLAN"
// +kubebuilder:validation:XValidation:rule="self.type != 'RoutedVLAN' || !has(self.aggregation)", message="aggregation must not be specified for interfaces of type RoutedVLAN"
// +kubebuilder:validation:XValidation:rule="self.type == 'RoutedVLAN' || !has(self.ipv4) || !self.ipv4.anycastGateway", message="anycastGateway can only be enabled for interfaces of type RoutedVLAN"
// +kubebuilder:validation:XValidation:rule="self.type != 'Aggregate' || !has(self.vrfRef)", message="vrfRef must not be specified for interfaces of type Aggregate"
// +kubebuilder:validation:XValidation:rule="self.type != 'Aggregate' || !has(self.switchport) || !has(self.vrfRef)", message="vrfRef must not be specified for Aggregate interfaces with switchport configuration"
// +kubebuilder:validation:XValidation:rule="self.type != 'Physical' || !has(self.switchport) || !has(self.vrfRef)", message="vrfRef must not be specified for Physical interfaces with switchport configuration"
// +kubebuilder:validation:XValidation:rule="self.type != 'Aggregate' || !has(self.bfd)", message="bfd must not be specified for interfaces of type Aggregate"
// +kubebuilder:validation:XValidation:rule="self.type != 'Aggregate' || !has(self.switchport) || !has(self.bfd)", message="bfd must not be specified for Aggregate interfaces with switchport configuration"
// +kubebuilder:validation:XValidation:rule="!has(self.bfd) || !has(self.switchport)", message="bfd must not be specified for interfaces with switchport configuration"
// +kubebuilder:validation:XValidation:rule="self.type == 'Physical' || !has(self.ethernet)", message="ethernet configuration must only be specified on interfaces of type Physical"
type InterfaceSpec struct {
Expand Down Expand Up @@ -95,7 +95,7 @@ type InterfaceSpec struct {
VrfRef *LocalObjectReference `json:"vrfRef,omitempty"`

// BFD defines the Bidirectional Forwarding Detection configuration for the interface.
// BFD is only applicable for Layer 3 interfaces (Physical, Loopback, RoutedVLAN).
// BFD is only applicable for Layer 3 interfaces.
// +optional
BFD *BFD `json:"bfd,omitempty"`

Expand Down
33 changes: 33 additions & 0 deletions api/core/v1alpha1/prefix_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ package v1alpha1
import (
"encoding/json"
"net/netip"

"k8s.io/apimachinery/pkg/api/equality"
)

// IPPrefix represents an IP prefix in CIDR notation.
Expand Down Expand Up @@ -37,6 +39,12 @@ func (p IPPrefix) IsZero() bool {
return !p.IsValid()
}

// Equal reports whether p and q are the same prefix.
// This method exists as a convenience for callers that need a direct comparison.
func (p IPPrefix) Equal(q IPPrefix) bool {
return p.Prefix == q.Prefix
}

// MarshalJSON implements [json.Marshaler].
func (p IPPrefix) MarshalJSON() ([]byte, error) {
if !p.IsValid() {
Expand All @@ -63,6 +71,19 @@ func (p *IPPrefix) UnmarshalJSON(data []byte) error {
return nil
}

// IsPointToPoint reports whether the prefix indicates a point-to-point link.
// For IPv4, this means a /31 subnet mask as defined in [RFC 3021].
// For IPv6, this means a /127 subnet mask as defined in [RFC 6164].
//
// [RFC 3021]: https://datatracker.ietf.org/doc/html/rfc3021
// [RFC 6164]: https://datatracker.ietf.org/doc/html/rfc6164
func (p IPPrefix) IsPointToPoint() bool {
if p.Addr().Is4() {
return p.Bits() == 31
}
return p.Bits() == 127
}

// DeepCopyInto copies all properties of this object into another object of the same type
func (in *IPPrefix) DeepCopyInto(out *IPPrefix) {
*out = *in
Expand All @@ -77,3 +98,15 @@ func (in *IPPrefix) DeepCopy() *IPPrefix {
in.DeepCopyInto(out)
return out
}

func init() {
// IPPrefix embeds [netip.Prefix] which contains unexported fields.
// [equality.Semantic.DeepEqual] panics on unexported fields, so an
// explicit equality function is registered in this package's init to
// make any type containing IPPrefix safe to compare.
if err := equality.Semantic.AddFunc(func(a, b IPPrefix) bool {
return a.Equal(b)
}); err != nil {
panic(err)
}
}
31 changes: 31 additions & 0 deletions api/core/v1alpha1/prefix_types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
// SPDX-License-Identifier: Apache-2.0

package v1alpha1

import "testing"

func TestIPPrefix_IsPointToPoint(t *testing.T) {
tests := []struct {
name string
prefix string
want bool
}{
{name: "IPv4 /31 is p2p", prefix: "10.0.0.0/31", want: true},
{name: "IPv4 /32 is not p2p", prefix: "10.0.0.1/32", want: false},
{name: "IPv4 /30 is not p2p", prefix: "10.0.0.0/30", want: false},
{name: "IPv4 /24 is not p2p", prefix: "192.168.1.0/24", want: false},
{name: "IPv6 /127 is p2p", prefix: "2001:db8::/127", want: true},
{name: "IPv6 /128 is not p2p", prefix: "2001:db8::1/128", want: false},
{name: "IPv6 /126 is not p2p", prefix: "2001:db8::/126", want: false},
{name: "IPv6 /64 is not p2p", prefix: "2001:db8::/64", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p := MustParsePrefix(tt.prefix)
if got := p.IsPointToPoint(); got != tt.want {
t.Errorf("IPPrefix(%q).IsPointToPoint() = %v, want %v", tt.prefix, got, tt.want)
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ spec:
bfd:
description: |-
BFD defines the Bidirectional Forwarding Detection configuration for the interface.
BFD is only applicable for Layer 3 interfaces (Physical, Loopback, RoutedVLAN).
BFD is only applicable for Layer 3 interfaces.
properties:
desiredMinimumTxInterval:
description: |-
Expand Down Expand Up @@ -440,8 +440,9 @@ spec:
rule: self.type != 'Aggregate' || has(self.aggregation)
- message: aggregation must only be specified on interfaces of type Aggregate
rule: self.type == 'Aggregate' || !has(self.aggregation)
- message: ipv4 must not be specified for interfaces of type Aggregate
rule: self.type != 'Aggregate' || !has(self.ipv4)
- message: ipv4 must not be specified for Aggregate interfaces with switchport
configuration
rule: self.type != 'Aggregate' || !has(self.switchport) || !has(self.ipv4)
- message: vlanRef must be specified for interfaces of type RoutedVLAN
rule: self.type != 'RoutedVLAN' || has(self.vlanRef)
- message: vlanRef must only be specified on interfaces of type RoutedVLAN
Expand All @@ -452,13 +453,15 @@ spec:
rule: self.type != 'RoutedVLAN' || !has(self.aggregation)
- message: anycastGateway can only be enabled for interfaces of type RoutedVLAN
rule: self.type == 'RoutedVLAN' || !has(self.ipv4) || !self.ipv4.anycastGateway
- message: vrfRef must not be specified for interfaces of type Aggregate
rule: self.type != 'Aggregate' || !has(self.vrfRef)
- message: vrfRef must not be specified for Aggregate interfaces with
switchport configuration
rule: self.type != 'Aggregate' || !has(self.switchport) || !has(self.vrfRef)
- message: vrfRef must not be specified for Physical interfaces with switchport
configuration
rule: self.type != 'Physical' || !has(self.switchport) || !has(self.vrfRef)
- message: bfd must not be specified for interfaces of type Aggregate
rule: self.type != 'Aggregate' || !has(self.bfd)
- message: bfd must not be specified for Aggregate interfaces with switchport
configuration
rule: self.type != 'Aggregate' || !has(self.switchport) || !has(self.bfd)
- message: bfd must not be specified for interfaces with switchport configuration
rule: '!has(self.bfd) || !has(self.switchport)'
- message: ethernet configuration must only be specified on interfaces
Expand Down
17 changes: 10 additions & 7 deletions config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ spec:
bfd:
description: |-
BFD defines the Bidirectional Forwarding Detection configuration for the interface.
BFD is only applicable for Layer 3 interfaces (Physical, Loopback, RoutedVLAN).
BFD is only applicable for Layer 3 interfaces.
properties:
desiredMinimumTxInterval:
description: |-
Expand Down Expand Up @@ -437,8 +437,9 @@ spec:
rule: self.type != 'Aggregate' || has(self.aggregation)
- message: aggregation must only be specified on interfaces of type Aggregate
rule: self.type == 'Aggregate' || !has(self.aggregation)
- message: ipv4 must not be specified for interfaces of type Aggregate
rule: self.type != 'Aggregate' || !has(self.ipv4)
- message: ipv4 must not be specified for Aggregate interfaces with switchport
configuration
rule: self.type != 'Aggregate' || !has(self.switchport) || !has(self.ipv4)
- message: vlanRef must be specified for interfaces of type RoutedVLAN
rule: self.type != 'RoutedVLAN' || has(self.vlanRef)
- message: vlanRef must only be specified on interfaces of type RoutedVLAN
Expand All @@ -449,13 +450,15 @@ spec:
rule: self.type != 'RoutedVLAN' || !has(self.aggregation)
- message: anycastGateway can only be enabled for interfaces of type RoutedVLAN
rule: self.type == 'RoutedVLAN' || !has(self.ipv4) || !self.ipv4.anycastGateway
- message: vrfRef must not be specified for interfaces of type Aggregate
rule: self.type != 'Aggregate' || !has(self.vrfRef)
- message: vrfRef must not be specified for Aggregate interfaces with
switchport configuration
rule: self.type != 'Aggregate' || !has(self.switchport) || !has(self.vrfRef)
- message: vrfRef must not be specified for Physical interfaces with switchport
configuration
rule: self.type != 'Physical' || !has(self.switchport) || !has(self.vrfRef)
- message: bfd must not be specified for interfaces of type Aggregate
rule: self.type != 'Aggregate' || !has(self.bfd)
- message: bfd must not be specified for Aggregate interfaces with switchport
configuration
rule: self.type != 'Aggregate' || !has(self.switchport) || !has(self.bfd)
- message: bfd must not be specified for interfaces with switchport configuration
rule: '!has(self.bfd) || !has(self.switchport)'
- message: ethernet configuration must only be specified on interfaces
Expand Down
47 changes: 47 additions & 0 deletions config/samples/v1alpha1_interface.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,53 @@ spec:
---
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-3
spec:
deviceRef:
name: leaf1
name: eth1/3
description: L3 Port-Channel Member
adminState: Up
type: Physical
mtu: 9216
---
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: po-20
spec:
deviceRef:
name: leaf1
name: po20
description: L3 Port-Channel
adminState: Up
type: Aggregate
mtu: 9216
ipv4:
addresses:
- 10.0.100.0/31
bfd:
enabled: true
desiredMinimumTxInterval: 300ms
requiredMinimumReceive: 300ms
detectionMultiplier: 3
aggregation:
controlProtocol:
mode: Active
memberInterfaceRefs:
- name: eth1-3
---
apiVersion: networking.metal.ironcore.dev/v1alpha1
kind: Interface
metadata:
labels:
app.kubernetes.io/name: network-operator
Expand Down
2 changes: 1 addition & 1 deletion docs/api-reference/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -1349,7 +1349,7 @@ _Appears in:_
| `aggregation` _[Aggregation](#aggregation)_ | Aggregation defines the aggregation (bundle) configuration for the interface.<br />This is only applicable for interfaces of type Aggregate. | | Optional: \{\} <br /> |
| `vlanRef` _[LocalObjectReference](#localobjectreference)_ | VlanRef is a reference to the VLAN resource that this interface provides routing for.<br />This is only applicable for interfaces of type RoutedVLAN.<br />The referenced VLAN must exist in the same namespace. | | Optional: \{\} <br /> |
| `vrfRef` _[LocalObjectReference](#localobjectreference)_ | VrfRef is a reference to the VRF resource that this interface belongs to.<br />If not specified, the interface will be part of the default VRF.<br />This is only applicable for Layer 3 interfaces.<br />The referenced VRF must exist in the same namespace. | | Optional: \{\} <br /> |
| `bfd` _[BFD](#bfd)_ | BFD defines the Bidirectional Forwarding Detection configuration for the interface.<br />BFD is only applicable for Layer 3 interfaces (Physical, Loopback, RoutedVLAN). | | Optional: \{\} <br /> |
| `bfd` _[BFD](#bfd)_ | BFD defines the Bidirectional Forwarding Detection configuration for the interface.<br />BFD is only applicable for Layer 3 interfaces. | | Optional: \{\} <br /> |
| `ethernet` _[Ethernet](#ethernet)_ | Ethernet defines the ethernet-specific configuration for physical interfaces.<br />This configuration is only applicable to Physical interfaces.<br />When omitted, ethernet parameters use their default values (e.g., FEC mode defaults to auto). | | Optional: \{\} <br /> |


Expand Down
82 changes: 75 additions & 7 deletions internal/controller/core/interface_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,33 @@ func (r *InterfaceReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Man
},
}),
).
// Watches enqueues member Physical Interfaces when their parent Aggregate changes.
// Only triggers when Aggregate Spec fields change that affect member reconciliation.
Watches(
&v1alpha1.Interface{},
handler.EnqueueRequestsFromMapFunc(r.aggregateToMembers),
builder.WithPredicates(predicate.Funcs{
CreateFunc: func(e event.CreateEvent) bool {
return false
},
UpdateFunc: func(e event.UpdateEvent) bool {
oldIntf := e.ObjectOld.(*v1alpha1.Interface)
newIntf := e.ObjectNew.(*v1alpha1.Interface)
// 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.Switchport, newIntf.Spec.Switchport) ||
!equality.Semantic.DeepEqual(oldIntf.Spec.VrfRef, newIntf.Spec.VrfRef) ||
oldIntf.Spec.MTU != newIntf.Spec.MTU
},
DeleteFunc: func(e event.DeleteEvent) bool {
return false
},
GenericFunc: func(e event.GenericEvent) bool {
return false
},
}),
).
// Watches enqueues RoutedVLAN Interfaces for updates in referenced VLAN resources.
// Only triggers on create and delete events since VLAN IDs are immutable.
Watches(
Expand Down Expand Up @@ -400,6 +427,18 @@ func (r *InterfaceReconciler) reconcile(ctx context.Context, s *scope) (_ ctrl.R
}
}

var aggregateParent *v1alpha1.Interface
if s.Interface.Spec.Type == v1alpha1.InterfaceTypePhysical && s.Interface.Status.MemberOf != nil {
aggregateParent = new(v1alpha1.Interface)
key := client.ObjectKey{Name: s.Interface.Status.MemberOf.Name, Namespace: s.Interface.Namespace}
if err := r.Get(ctx, key, aggregateParent); err != nil {
if !apierrors.IsNotFound(err) {
return ctrl.Result{}, fmt.Errorf("failed to get aggregate parent %q: %w", s.Interface.Status.MemberOf.Name, err)
}
aggregateParent = nil
}
}

var multiChassisID *int16
if s.Interface.Spec.Aggregation != nil && s.Interface.Spec.Aggregation.MultiChassis != nil {
multiChassisID = &s.Interface.Spec.Aggregation.MultiChassis.ID
Expand Down Expand Up @@ -443,13 +482,14 @@ func (r *InterfaceReconciler) reconcile(ctx context.Context, s *scope) (_ ctrl.R

// Ensure the Interface is realized on the provider.
err := s.Provider.EnsureInterface(ctx, &provider.EnsureInterfaceRequest{
Interface: s.Interface,
ProviderConfig: s.ProviderConfig,
IPv4: ip,
Members: members,
MultiChassisID: multiChassisID,
VLAN: vlan,
VRF: vrf,
Interface: s.Interface,
ProviderConfig: s.ProviderConfig,
IPv4: ip,
Members: members,
MultiChassisID: multiChassisID,
AggregateParent: aggregateParent,
VLAN: vlan,
VRF: vrf,
})

cond := conditions.FromError(err)
Expand Down Expand Up @@ -874,6 +914,34 @@ func (r *InterfaceReconciler) interfaceToAggregate(ctx context.Context, obj clie
return requests
}

// aggregateToMembers is a [handler.MapFunc] to be used to enqueue requests for reconciliation
// for member Physical Interfaces when their parent Aggregate Interface gets updated.
func (r *InterfaceReconciler) aggregateToMembers(ctx context.Context, obj client.Object) []ctrl.Request {
intf, ok := obj.(*v1alpha1.Interface)
if !ok {
panic(fmt.Sprintf("Expected a Interface but got a %T", obj))
}

if intf.Spec.Type != v1alpha1.InterfaceTypeAggregate {
return nil
}

log := ctrl.LoggerFrom(ctx, "Aggregate", klog.KObj(intf))

requests := make([]ctrl.Request, 0, len(intf.Spec.Aggregation.MemberInterfaceRefs))
for _, ref := range intf.Spec.Aggregation.MemberInterfaceRefs {
log.Info("Enqueuing member Interface for reconciliation", "Member", ref.Name)
requests = append(requests, ctrl.Request{
NamespacedName: client.ObjectKey{
Name: ref.Name,
Namespace: intf.Namespace,
},
})
}

return requests
}

// vlanToRoutedVLAN is a [handler.MapFunc] to be used to enqueue requests for reconciliation
// for a RoutedVLAN Interface when its referenced VLAN changes.
func (r *InterfaceReconciler) vlanToRoutedVLAN(ctx context.Context, obj client.Object) []ctrl.Request {
Expand Down
Loading
Loading