diff --git a/PROJECT b/PROJECT
index 052e3a0fe..499f66842 100644
--- a/PROJECT
+++ b/PROJECT
@@ -290,4 +290,12 @@ resources:
kind: DHCPRelay
path: github.com/ironcore-dev/network-operator/api/core/v1alpha1
version: v1alpha1
+- api:
+ crdVersion: v1
+ namespaced: true
+ controller: true
+ domain: networking.metal.ironcore.dev
+ kind: EthernetSegment
+ path: github.com/ironcore-dev/network-operator/api/core/v1alpha1
+ version: v1alpha1
version: "3"
diff --git a/Tiltfile b/Tiltfile
index b03e5885b..24f891fb8 100644
--- a/Tiltfile
+++ b/Tiltfile
@@ -139,6 +139,9 @@ k8s_resource(new_name='lldp', objects=['leaf1-lldp:lldp'], trigger_mode=TRIGGER_
k8s_yaml('./config/samples/v1alpha1_dhcprelay.yaml')
k8s_resource(new_name='dhcprelay', objects=['dhcprelay:dhcprelay'], resource_deps=['eth1-1'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False)
+k8s_yaml('./config/samples/v1alpha1_ethernetsegment.yaml')
+k8s_resource(new_name='ethernetsegment-sample', objects=['ethernetsegment-sample:ethernetsegment'], resource_deps=['po10'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False)
+
print('🚀 network-operator development environment')
print('👉 Edit the code inside the api/, cmd/, or internal/ directories')
print('👉 Tilt will automatically rebuild and redeploy when changes are detected')
diff --git a/api/core/v1alpha1/ethernetsegment_types.go b/api/core/v1alpha1/ethernetsegment_types.go
new file mode 100644
index 000000000..21a1fc87e
--- /dev/null
+++ b/api/core/v1alpha1/ethernetsegment_types.go
@@ -0,0 +1,232 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package v1alpha1
+
+import (
+ "sync"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+// EthernetSegmentSpec defines the desired state of EthernetSegment.
+//
+// It models an EVPN Ethernet Segment for multihoming as defined in [RFC 7432] Section 5.
+// An Ethernet Segment associates an Aggregate interface with a 10-byte Ethernet Segment
+// Identifier (ESI), enabling multi-homed CE connectivity.
+// [RFC 7432]: https://datatracker.ietf.org/doc/html/rfc7432
+//
+// +kubebuilder:validation:XValidation:rule="self.esiType != 'Arbitrary' || has(self.esi)",message="ESI is required when ESIType is Arbitrary"
+// +kubebuilder:validation:XValidation:rule="!(self.esiType == 'LACP' || self.esiType == 'MST') || !has(self.esi)",message="ESI must be omitted when ESIType is LACP or MST"
+// +kubebuilder:validation:XValidation:rule="!has(self.esi) || (self.esiType == 'Arbitrary' && self.esi.startsWith('00')) || (self.esiType == 'MAC' && self.esi.startsWith('03')) || (self.esiType == 'RouterID' && self.esi.startsWith('04')) || (self.esiType == 'AS' && self.esi.startsWith('05'))",message="ESI first byte must match ESIType"
+type EthernetSegmentSpec struct {
+ // DeviceRef is the name of the Device this object belongs to. The Device object must exist in the same namespace.
+ // Immutable.
+ // +required
+ // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="DeviceRef is immutable"
+ DeviceRef LocalObjectReference `json:"deviceRef"`
+
+ // ProviderConfigRef is a reference to a resource holding the provider-specific configuration of this Ethernet Segment.
+ // +optional
+ ProviderConfigRef *TypedLocalObjectReference `json:"providerConfigRef,omitempty"`
+
+ // InterfaceRef is the name of the Interface this Ethernet Segment is associated with.
+ // The Interface must be of type Aggregate and belong to the same Device.
+ // Immutable.
+ // +required
+ // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="InterfaceRef is immutable"
+ InterfaceRef LocalObjectReference `json:"interfaceRef"`
+
+ // ESIType selects the ESI derivation method (RFC 7432 Section 5).
+ // When Arbitrary (Type 0), ESI must be provided explicitly.
+ // When LACP or MST (Types 1, 2), ESI is always auto-derived (ESI field must be omitted).
+ // When MAC, RouterID, or AS (Types 3-5), ESI may be explicit or auto-derived.
+ // Immutable.
+ // +required
+ // +kubebuilder:default=Arbitrary
+ // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="ESIType is immutable"
+ ESIType ESIType `json:"esiType"`
+
+ // ESI is the 10-byte Ethernet Segment Identifier in colon-separated hex notation
+ // (e.g., "00:11:22:33:44:55:66:77:88:01"). Must not be all-zeros or all-ones (reserved per RFC 7432).
+ // Required when ESIType is Arbitrary. Must be omitted when ESIType is LACP or MST.
+ // Optional for MAC, RouterID, and AS types (omit to auto-derive on the device).
+ // Immutable once set.
+ // +optional
+ // +kubebuilder:validation:Pattern=`^([0-9a-fA-F]{2}:){9}[0-9a-fA-F]{2}$`
+ // +kubebuilder:validation:XValidation:rule="size(oldSelf) == 0 || self == oldSelf",message="ESI is immutable once set"
+ ESI string `json:"esi,omitempty"`
+
+ // RedundancyMode defines the multi-homing forwarding model for this Ethernet Segment
+ // as defined in RFC 7432 Section 14.1.
+ // +kubebuilder:validation:Enum=AllActive;SingleActive
+ // +kubebuilder:default=AllActive
+ // +optional
+ RedundancyMode RedundancyMode `json:"redundancyMode,omitempty"`
+
+ // DesignatedForwarder configures the Designated Forwarder election for this
+ // Ethernet Segment (RFC 7432 Section 8.5, RFC 8584).
+ // +optional
+ DesignatedForwarder *DesignatedForwarder `json:"designatedForwarder,omitempty"`
+}
+
+// ESIType defines the ESI derivation method per RFC 7432 Section 5.
+// +kubebuilder:validation:Enum=Arbitrary;LACP;MST;MAC;RouterID;AS
+type ESIType string
+
+const (
+ // ESITypeArbitrary indicates an operator-configured ESI value (Type 0).
+ ESITypeArbitrary ESIType = "Arbitrary"
+
+ // ESITypeLACP indicates a LACP-based ESI derived from CE system MAC and port key (Type 1).
+ ESITypeLACP ESIType = "LACP"
+
+ // ESITypeMST indicates a bridge-protocol-based ESI derived from root bridge parameters (Type 2).
+ ESITypeMST ESIType = "MST"
+
+ // ESITypeMAC indicates a MAC-based ESI derived from system MAC and local discriminator (Type 3).
+ ESITypeMAC ESIType = "MAC"
+
+ // ESITypeRouterID indicates a router-ID-based ESI (Type 4).
+ ESITypeRouterID ESIType = "RouterID"
+
+ // ESITypeAS indicates an AS-number-based ESI (Type 5).
+ ESITypeAS ESIType = "AS"
+)
+
+// RedundancyMode defines the forwarding model for a multi-homed Ethernet Segment.
+// +kubebuilder:validation:Enum=AllActive;SingleActive
+type RedundancyMode string
+
+const (
+ // RedundancyModeAllActive enables all PE nodes in the segment to forward unicast
+ // traffic simultaneously (RFC 7432 Section 14.1.2).
+ RedundancyModeAllActive RedundancyMode = "AllActive"
+
+ // RedundancyModeSingleActive restricts forwarding to the elected Designated Forwarder
+ // only (RFC 7432 Section 14.1.1).
+ RedundancyModeSingleActive RedundancyMode = "SingleActive"
+)
+
+// DFElectionMode defines the Designated Forwarder election algorithm.
+// +kubebuilder:validation:Enum=Default;HighestRandomWeight;Preference
+type DFElectionMode string
+
+const (
+ // DFElectionModeDefault uses the modulo-based DF election per RFC 7432 Section 8.5.
+ DFElectionModeDefault DFElectionMode = "Default"
+
+ // DFElectionModeHighestRandomWeight uses the HRW algorithm per RFC 8584.
+ DFElectionModeHighestRandomWeight DFElectionMode = "HighestRandomWeight"
+
+ // DFElectionModePreference uses preference-based DF election per RFC 8584.
+ DFElectionModePreference DFElectionMode = "Preference"
+)
+
+// DesignatedForwarder configures the DF election parameters for an Ethernet Segment.
+//
+// +kubebuilder:validation:XValidation:rule="self.electionMode == 'Preference' || !has(self.electionWaitTime)",message="electionWaitTime is only valid when electionMode is Preference"
+type DesignatedForwarder struct {
+ // ElectionMode selects the DF election algorithm.
+ // +kubebuilder:default=Default
+ // +optional
+ ElectionMode DFElectionMode `json:"electionMode,omitempty"`
+
+ // ElectionWaitTime is the DF election hold timer. The PE waits this
+ // duration before selecting the DF based on highest preference.
+ // Only applicable when ElectionMode is Preference.
+ // +optional
+ ElectionWaitTime *metav1.Duration `json:"electionWaitTime,omitempty"`
+}
+
+// EthernetSegmentStatus defines the observed state of EthernetSegment.
+type EthernetSegmentStatus struct {
+ // +listType=map
+ // +listMapKey=type
+ // +patchStrategy=merge
+ // +patchMergeKey=type
+ // +optional
+ Conditions []metav1.Condition `json:"conditions,omitempty"`
+
+ // ESI is the realized 10-byte Ethernet Segment Identifier on the device,
+ // in colon-separated hex notation. Populated from spec or read back from
+ // device when auto-generated.
+ // +optional
+ ESI string `json:"esi,omitempty"`
+
+ // ESIType is the ESI derivation type parsed from the first byte of ESI.
+ // +optional
+ ESIType ESIType `json:"esiType,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+// +kubebuilder:resource:path=ethernetsegments
+// +kubebuilder:resource:singular=ethernetsegment
+// +kubebuilder:resource:shortName=es
+// +kubebuilder:printcolumn:name="ESI",type=string,JSONPath=`.status.esi`
+// +kubebuilder:printcolumn:name="ESI Type",type=string,JSONPath=`.status.esiType`,priority=1
+// +kubebuilder:printcolumn:name="Device",type=string,JSONPath=`.spec.deviceRef.name`
+// +kubebuilder:printcolumn:name="Interface",type=string,JSONPath=`.spec.interfaceRef.name`
+// +kubebuilder:printcolumn:name="Redundancy Mode",type=string,JSONPath=`.spec.redundancyMode`,priority=1
+// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
+// +kubebuilder:printcolumn:name="Configured",type=string,JSONPath=`.status.conditions[?(@.type=="Configured")].status`,priority=1
+// +kubebuilder:printcolumn:name="Operational",type=string,JSONPath=`.status.conditions[?(@.type=="Operational")].status`,priority=1
+// +kubebuilder:printcolumn:name="Paused",type=string,JSONPath=`.status.conditions[?(@.type=="Paused")].status`,priority=1
+// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
+
+// EthernetSegment is the Schema for the ethernetsegments API.
+type EthernetSegment struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ // Specification of the desired state of the resource.
+ // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
+ // +required
+ Spec EthernetSegmentSpec `json:"spec,omitempty"`
+
+ // Status of the resource. This is set and updated automatically.
+ // Read-only.
+ // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
+ // +optional
+ Status EthernetSegmentStatus `json:"status,omitempty,omitzero"`
+}
+
+// GetConditions implements conditions.Getter.
+func (e *EthernetSegment) GetConditions() []metav1.Condition {
+ return e.Status.Conditions
+}
+
+// SetConditions implements conditions.Setter.
+func (e *EthernetSegment) SetConditions(conditions []metav1.Condition) {
+ e.Status.Conditions = conditions
+}
+
+// +kubebuilder:object:root=true
+
+// EthernetSegmentList contains a list of EthernetSegment.
+type EthernetSegmentList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitzero"`
+ Items []EthernetSegment `json:"items"`
+}
+
+var (
+ EthernetSegmentDependencies []schema.GroupVersionKind
+ ethernetSegmentDependenciesMu sync.Mutex
+)
+
+func RegisterEthernetSegmentDependency(gvk schema.GroupVersionKind) {
+ ethernetSegmentDependenciesMu.Lock()
+ defer ethernetSegmentDependenciesMu.Unlock()
+ EthernetSegmentDependencies = append(EthernetSegmentDependencies, gvk)
+}
+
+func init() {
+ SchemeBuilder.Register(func(s *runtime.Scheme) error {
+ s.AddKnownTypes(GroupVersion, &EthernetSegment{}, &EthernetSegmentList{})
+ return nil
+ })
+}
diff --git a/api/core/v1alpha1/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go
index 1500d38a7..b1d017686 100644
--- a/api/core/v1alpha1/groupversion_info.go
+++ b/api/core/v1alpha1/groupversion_info.go
@@ -202,6 +202,9 @@ const (
// CrossDeviceReferenceReason indicates that a referenced interface belongs to a different device.
CrossDeviceReferenceReason = "CrossDeviceReference"
+ // InterfaceNotSwitchportReason indicates that a referenced interface does not have switchport configuration.
+ InterfaceNotSwitchportReason = "InterfaceNotSwitchport"
+
// MemberInterfaceAlreadyInUseReason indicates that a member interface is already part of another aggregate.
MemberInterfaceAlreadyInUseReason = "MemberInterfaceAlreadyInUse"
diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go
index 1d6104fe2..6762be937 100644
--- a/api/core/v1alpha1/zz_generated.deepcopy.go
+++ b/api/core/v1alpha1/zz_generated.deepcopy.go
@@ -1247,6 +1247,26 @@ func (in *DNSStatus) DeepCopy() *DNSStatus {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *DesignatedForwarder) DeepCopyInto(out *DesignatedForwarder) {
+ *out = *in
+ if in.ElectionWaitTime != nil {
+ in, out := &in.ElectionWaitTime, &out.ElectionWaitTime
+ *out = new(v1.Duration)
+ **out = **in
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DesignatedForwarder.
+func (in *DesignatedForwarder) DeepCopy() *DesignatedForwarder {
+ if in == nil {
+ return nil
+ }
+ out := new(DesignatedForwarder)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *Device) DeepCopyInto(out *Device) {
*out = *in
@@ -1571,6 +1591,114 @@ func (in *Ethernet) DeepCopy() *Ethernet {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *EthernetSegment) DeepCopyInto(out *EthernetSegment) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ in.Spec.DeepCopyInto(&out.Spec)
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EthernetSegment.
+func (in *EthernetSegment) DeepCopy() *EthernetSegment {
+ if in == nil {
+ return nil
+ }
+ out := new(EthernetSegment)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *EthernetSegment) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *EthernetSegmentList) DeepCopyInto(out *EthernetSegmentList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]EthernetSegment, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EthernetSegmentList.
+func (in *EthernetSegmentList) DeepCopy() *EthernetSegmentList {
+ if in == nil {
+ return nil
+ }
+ out := new(EthernetSegmentList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *EthernetSegmentList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *EthernetSegmentSpec) DeepCopyInto(out *EthernetSegmentSpec) {
+ *out = *in
+ out.DeviceRef = in.DeviceRef
+ if in.ProviderConfigRef != nil {
+ in, out := &in.ProviderConfigRef, &out.ProviderConfigRef
+ *out = new(TypedLocalObjectReference)
+ **out = **in
+ }
+ out.InterfaceRef = in.InterfaceRef
+ if in.DesignatedForwarder != nil {
+ in, out := &in.DesignatedForwarder, &out.DesignatedForwarder
+ *out = new(DesignatedForwarder)
+ (*in).DeepCopyInto(*out)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EthernetSegmentSpec.
+func (in *EthernetSegmentSpec) DeepCopy() *EthernetSegmentSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(EthernetSegmentSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *EthernetSegmentStatus) DeepCopyInto(out *EthernetSegmentStatus) {
+ *out = *in
+ if in.Conditions != nil {
+ in, out := &in.Conditions, &out.Conditions
+ *out = make([]v1.Condition, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EthernetSegmentStatus.
+func (in *EthernetSegmentStatus) DeepCopy() *EthernetSegmentStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(EthernetSegmentStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *GNMI) DeepCopyInto(out *GNMI) {
*out = *in
diff --git a/charts/network-operator/templates/crd/ethernetsegments.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/ethernetsegments.networking.metal.ironcore.dev.yaml
new file mode 100644
index 000000000..ccf59107b
--- /dev/null
+++ b/charts/network-operator/templates/crd/ethernetsegments.networking.metal.ironcore.dev.yaml
@@ -0,0 +1,325 @@
+{{- if .Values.crd.enabled }}
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ {{- if .Values.crd.keep }}
+ "helm.sh/resource-policy": keep
+ {{- end }}
+ controller-gen.kubebuilder.io/version: v0.21.0
+ name: ethernetsegments.networking.metal.ironcore.dev
+spec:
+ group: networking.metal.ironcore.dev
+ names:
+ kind: EthernetSegment
+ listKind: EthernetSegmentList
+ plural: ethernetsegments
+ shortNames:
+ - es
+ singular: ethernetsegment
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - jsonPath: .status.esi
+ name: ESI
+ type: string
+ - jsonPath: .status.esiType
+ name: ESI Type
+ priority: 1
+ type: string
+ - jsonPath: .spec.deviceRef.name
+ name: Device
+ type: string
+ - jsonPath: .spec.interfaceRef.name
+ name: Interface
+ type: string
+ - jsonPath: .spec.redundancyMode
+ name: Redundancy Mode
+ priority: 1
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Ready")].status
+ name: Ready
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Configured")].status
+ name: Configured
+ priority: 1
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Operational")].status
+ name: Operational
+ priority: 1
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Paused")].status
+ name: Paused
+ priority: 1
+ type: string
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1alpha1
+ schema:
+ openAPIV3Schema:
+ description: EthernetSegment is the Schema for the ethernetsegments API.
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: |-
+ Specification of the desired state of the resource.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
+ properties:
+ designatedForwarder:
+ description: |-
+ DesignatedForwarder configures the Designated Forwarder election for this
+ Ethernet Segment (RFC 7432 Section 8.5, RFC 8584).
+ properties:
+ electionMode:
+ default: Default
+ description: ElectionMode selects the DF election algorithm.
+ enum:
+ - Default
+ - HighestRandomWeight
+ - Preference
+ type: string
+ electionWaitTime:
+ description: |-
+ ElectionWaitTime is the DF election hold timer. The PE waits this
+ duration before selecting the DF based on highest preference.
+ Only applicable when ElectionMode is Preference.
+ type: string
+ type: object
+ x-kubernetes-validations:
+ - message: electionWaitTime is only valid when electionMode is Preference
+ rule: self.electionMode == 'Preference' || !has(self.electionWaitTime)
+ deviceRef:
+ description: |-
+ DeviceRef is the name of the Device this object belongs to. The Device object must exist in the same namespace.
+ Immutable.
+ properties:
+ name:
+ description: |-
+ Name of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ x-kubernetes-validations:
+ - message: DeviceRef is immutable
+ rule: self == oldSelf
+ esi:
+ description: |-
+ ESI is the 10-byte Ethernet Segment Identifier in colon-separated hex notation
+ (e.g., "00:11:22:33:44:55:66:77:88:01"). Must not be all-zeros or all-ones (reserved per RFC 7432).
+ Required when ESIType is Arbitrary. Must be omitted when ESIType is LACP or MST.
+ Optional for MAC, RouterID, and AS types (omit to auto-derive on the device).
+ Immutable once set.
+ pattern: ^([0-9a-fA-F]{2}:){9}[0-9a-fA-F]{2}$
+ type: string
+ x-kubernetes-validations:
+ - message: ESI is immutable once set
+ rule: size(oldSelf) == 0 || self == oldSelf
+ esiType:
+ default: Arbitrary
+ description: |-
+ ESIType selects the ESI derivation method (RFC 7432 Section 5).
+ When Arbitrary (Type 0), ESI must be provided explicitly.
+ When LACP or MST (Types 1, 2), ESI is always auto-derived (ESI field must be omitted).
+ When MAC, RouterID, or AS (Types 3-5), ESI may be explicit or auto-derived.
+ Immutable.
+ enum:
+ - Arbitrary
+ - LACP
+ - MST
+ - MAC
+ - RouterID
+ - AS
+ type: string
+ x-kubernetes-validations:
+ - message: ESIType is immutable
+ rule: self == oldSelf
+ interfaceRef:
+ description: |-
+ InterfaceRef is the name of the Interface this Ethernet Segment is associated with.
+ The Interface must be of type Aggregate and belong to the same Device.
+ Immutable.
+ properties:
+ name:
+ description: |-
+ Name of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ x-kubernetes-validations:
+ - message: InterfaceRef is immutable
+ rule: self == oldSelf
+ providerConfigRef:
+ description: ProviderConfigRef is a reference to a resource holding
+ the provider-specific configuration of this Ethernet Segment.
+ properties:
+ apiVersion:
+ description: APIVersion is the api group version of the resource
+ being referenced.
+ maxLength: 253
+ minLength: 1
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/)?([a-z0-9]([-a-z0-9]*[a-z0-9])?)$
+ type: string
+ kind:
+ description: |-
+ Kind of the resource being referenced.
+ Kind must consist of alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$
+ type: string
+ name:
+ description: |-
+ Name of the resource being referenced.
+ Name must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character.
+ maxLength: 253
+ minLength: 1
+ pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+ type: string
+ required:
+ - apiVersion
+ - kind
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ redundancyMode:
+ default: AllActive
+ description: |-
+ RedundancyMode defines the multi-homing forwarding model for this Ethernet Segment
+ as defined in RFC 7432 Section 14.1.
+ enum:
+ - AllActive
+ - SingleActive
+ type: string
+ required:
+ - deviceRef
+ - esiType
+ - interfaceRef
+ type: object
+ x-kubernetes-validations:
+ - message: ESI is required when ESIType is Arbitrary
+ rule: self.esiType != 'Arbitrary' || has(self.esi)
+ - message: ESI must be omitted when ESIType is LACP or MST
+ rule: '!(self.esiType == ''LACP'' || self.esiType == ''MST'') || !has(self.esi)'
+ - message: ESI first byte must match ESIType
+ rule: '!has(self.esi) || (self.esiType == ''Arbitrary'' && self.esi.startsWith(''00''))
+ || (self.esiType == ''MAC'' && self.esi.startsWith(''03'')) || (self.esiType
+ == ''RouterID'' && self.esi.startsWith(''04'')) || (self.esiType ==
+ ''AS'' && self.esi.startsWith(''05''))'
+ status:
+ description: |-
+ Status of the resource. This is set and updated automatically.
+ Read-only.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
+ properties:
+ conditions:
+ items:
+ description: Condition contains details for one aspect of the current
+ state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ esi:
+ description: |-
+ ESI is the realized 10-byte Ethernet Segment Identifier on the device,
+ in colon-separated hex notation. Populated from spec or read back from
+ device when auto-generated.
+ type: string
+ esiType:
+ description: ESIType is the ESI derivation type parsed from the first
+ byte of ESI.
+ enum:
+ - Arbitrary
+ - LACP
+ - MST
+ - MAC
+ - RouterID
+ - AS
+ type: string
+ type: object
+ required:
+ - spec
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
+{{- end }}
diff --git a/charts/network-operator/templates/rbac/ethernetsegment-admin-role.yaml b/charts/network-operator/templates/rbac/ethernetsegment-admin-role.yaml
new file mode 100644
index 000000000..a2e40a91a
--- /dev/null
+++ b/charts/network-operator/templates/rbac/ethernetsegment-admin-role.yaml
@@ -0,0 +1,31 @@
+{{- if .Values.rbac.helpers.enabled }}
+apiVersion: rbac.authorization.k8s.io/v1
+{{- if .Values.rbac.namespaced }}
+kind: Role
+{{- else }}
+kind: ClusterRole
+{{- end }}
+metadata:
+{{- if .Values.rbac.namespaced }}
+ namespace: {{ .Release.Namespace }}
+{{- end }}
+ labels:
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
+ app.kubernetes.io/name: {{ include "network-operator.name" . }}
+ helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
+ name: {{ include "network-operator.resourceName" (dict "suffix" "ethernetsegment-admin-role" "context" $) }}
+rules:
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - ethernetsegments
+ verbs:
+ - '*'
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - ethernetsegments/status
+ verbs:
+ - get
+{{- end }}
diff --git a/charts/network-operator/templates/rbac/ethernetsegment-editor-role.yaml b/charts/network-operator/templates/rbac/ethernetsegment-editor-role.yaml
new file mode 100644
index 000000000..a8b383682
--- /dev/null
+++ b/charts/network-operator/templates/rbac/ethernetsegment-editor-role.yaml
@@ -0,0 +1,37 @@
+{{- if .Values.rbac.helpers.enabled }}
+apiVersion: rbac.authorization.k8s.io/v1
+{{- if .Values.rbac.namespaced }}
+kind: Role
+{{- else }}
+kind: ClusterRole
+{{- end }}
+metadata:
+{{- if .Values.rbac.namespaced }}
+ namespace: {{ .Release.Namespace }}
+{{- end }}
+ labels:
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
+ app.kubernetes.io/name: {{ include "network-operator.name" . }}
+ helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
+ name: {{ include "network-operator.resourceName" (dict "suffix" "ethernetsegment-editor-role" "context" $) }}
+rules:
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - ethernetsegments
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - ethernetsegments/status
+ verbs:
+ - get
+{{- end }}
diff --git a/charts/network-operator/templates/rbac/ethernetsegment-viewer-role.yaml b/charts/network-operator/templates/rbac/ethernetsegment-viewer-role.yaml
new file mode 100644
index 000000000..ccbfdf414
--- /dev/null
+++ b/charts/network-operator/templates/rbac/ethernetsegment-viewer-role.yaml
@@ -0,0 +1,33 @@
+{{- if .Values.rbac.helpers.enabled }}
+apiVersion: rbac.authorization.k8s.io/v1
+{{- if .Values.rbac.namespaced }}
+kind: Role
+{{- else }}
+kind: ClusterRole
+{{- end }}
+metadata:
+{{- if .Values.rbac.namespaced }}
+ namespace: {{ .Release.Namespace }}
+{{- end }}
+ labels:
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
+ app.kubernetes.io/name: {{ include "network-operator.name" . }}
+ helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
+ name: {{ include "network-operator.resourceName" (dict "suffix" "ethernetsegment-viewer-role" "context" $) }}
+rules:
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - ethernetsegments
+ verbs:
+ - get
+ - list
+ - watch
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - ethernetsegments/status
+ verbs:
+ - get
+{{- end }}
diff --git a/charts/network-operator/templates/rbac/manager-role.yaml b/charts/network-operator/templates/rbac/manager-role.yaml
index 580e6b8bd..958c534c9 100644
--- a/charts/network-operator/templates/rbac/manager-role.yaml
+++ b/charts/network-operator/templates/rbac/manager-role.yaml
@@ -56,6 +56,7 @@ rules:
- devices
- dhcprelays
- dns
+ - ethernetsegments
- evpninstances
- interfaces
- isis
@@ -91,6 +92,7 @@ rules:
- devices/finalizers
- dhcprelays/finalizers
- dns/finalizers
+ - ethernetsegments/finalizers
- evpninstances/finalizers
- interfaces/finalizers
- isis/finalizers
@@ -120,6 +122,7 @@ rules:
- devices/status
- dhcprelays/status
- dns/status
+ - ethernetsegments/status
- evpninstances/status
- interfaces/status
- isis/status
diff --git a/cmd/main.go b/cmd/main.go
index 82de236ce..a818ae288 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -625,6 +625,19 @@ func main() { //nolint:gocyclo
os.Exit(1)
}
+ if err := (&corecontroller.EthernetSegmentReconciler{
+ Client: mgr.GetClient(),
+ Scheme: mgr.GetScheme(),
+ Recorder: mgr.GetEventRecorder("ethernetsegment-controller"),
+ WatchFilterValue: watchFilterValue,
+ Provider: prov,
+ Locker: locker,
+ RequeueInterval: requeueInterval,
+ }).SetupWithManager(ctx, mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "EthernetSegment")
+ os.Exit(1)
+ }
+
if os.Getenv("ENABLE_WEBHOOKS") != "false" {
if err := webhookv1alpha1.SetupVRFWebhookWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create webhook", "webhook", "VRF")
diff --git a/config/crd/bases/networking.metal.ironcore.dev_ethernetsegments.yaml b/config/crd/bases/networking.metal.ironcore.dev_ethernetsegments.yaml
new file mode 100644
index 000000000..67beeb0a0
--- /dev/null
+++ b/config/crd/bases/networking.metal.ironcore.dev_ethernetsegments.yaml
@@ -0,0 +1,321 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.21.0
+ name: ethernetsegments.networking.metal.ironcore.dev
+spec:
+ group: networking.metal.ironcore.dev
+ names:
+ kind: EthernetSegment
+ listKind: EthernetSegmentList
+ plural: ethernetsegments
+ shortNames:
+ - es
+ singular: ethernetsegment
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - jsonPath: .status.esi
+ name: ESI
+ type: string
+ - jsonPath: .status.esiType
+ name: ESI Type
+ priority: 1
+ type: string
+ - jsonPath: .spec.deviceRef.name
+ name: Device
+ type: string
+ - jsonPath: .spec.interfaceRef.name
+ name: Interface
+ type: string
+ - jsonPath: .spec.redundancyMode
+ name: Redundancy Mode
+ priority: 1
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Ready")].status
+ name: Ready
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Configured")].status
+ name: Configured
+ priority: 1
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Operational")].status
+ name: Operational
+ priority: 1
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Paused")].status
+ name: Paused
+ priority: 1
+ type: string
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1alpha1
+ schema:
+ openAPIV3Schema:
+ description: EthernetSegment is the Schema for the ethernetsegments API.
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: |-
+ Specification of the desired state of the resource.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
+ properties:
+ designatedForwarder:
+ description: |-
+ DesignatedForwarder configures the Designated Forwarder election for this
+ Ethernet Segment (RFC 7432 Section 8.5, RFC 8584).
+ properties:
+ electionMode:
+ default: Default
+ description: ElectionMode selects the DF election algorithm.
+ enum:
+ - Default
+ - HighestRandomWeight
+ - Preference
+ type: string
+ electionWaitTime:
+ description: |-
+ ElectionWaitTime is the DF election hold timer. The PE waits this
+ duration before selecting the DF based on highest preference.
+ Only applicable when ElectionMode is Preference.
+ type: string
+ type: object
+ x-kubernetes-validations:
+ - message: electionWaitTime is only valid when electionMode is Preference
+ rule: self.electionMode == 'Preference' || !has(self.electionWaitTime)
+ deviceRef:
+ description: |-
+ DeviceRef is the name of the Device this object belongs to. The Device object must exist in the same namespace.
+ Immutable.
+ properties:
+ name:
+ description: |-
+ Name of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ x-kubernetes-validations:
+ - message: DeviceRef is immutable
+ rule: self == oldSelf
+ esi:
+ description: |-
+ ESI is the 10-byte Ethernet Segment Identifier in colon-separated hex notation
+ (e.g., "00:11:22:33:44:55:66:77:88:01"). Must not be all-zeros or all-ones (reserved per RFC 7432).
+ Required when ESIType is Arbitrary. Must be omitted when ESIType is LACP or MST.
+ Optional for MAC, RouterID, and AS types (omit to auto-derive on the device).
+ Immutable once set.
+ pattern: ^([0-9a-fA-F]{2}:){9}[0-9a-fA-F]{2}$
+ type: string
+ x-kubernetes-validations:
+ - message: ESI is immutable once set
+ rule: size(oldSelf) == 0 || self == oldSelf
+ esiType:
+ default: Arbitrary
+ description: |-
+ ESIType selects the ESI derivation method (RFC 7432 Section 5).
+ When Arbitrary (Type 0), ESI must be provided explicitly.
+ When LACP or MST (Types 1, 2), ESI is always auto-derived (ESI field must be omitted).
+ When MAC, RouterID, or AS (Types 3-5), ESI may be explicit or auto-derived.
+ Immutable.
+ enum:
+ - Arbitrary
+ - LACP
+ - MST
+ - MAC
+ - RouterID
+ - AS
+ type: string
+ x-kubernetes-validations:
+ - message: ESIType is immutable
+ rule: self == oldSelf
+ interfaceRef:
+ description: |-
+ InterfaceRef is the name of the Interface this Ethernet Segment is associated with.
+ The Interface must be of type Aggregate and belong to the same Device.
+ Immutable.
+ properties:
+ name:
+ description: |-
+ Name of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ x-kubernetes-validations:
+ - message: InterfaceRef is immutable
+ rule: self == oldSelf
+ providerConfigRef:
+ description: ProviderConfigRef is a reference to a resource holding
+ the provider-specific configuration of this Ethernet Segment.
+ properties:
+ apiVersion:
+ description: APIVersion is the api group version of the resource
+ being referenced.
+ maxLength: 253
+ minLength: 1
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/)?([a-z0-9]([-a-z0-9]*[a-z0-9])?)$
+ type: string
+ kind:
+ description: |-
+ Kind of the resource being referenced.
+ Kind must consist of alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$
+ type: string
+ name:
+ description: |-
+ Name of the resource being referenced.
+ Name must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character.
+ maxLength: 253
+ minLength: 1
+ pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+ type: string
+ required:
+ - apiVersion
+ - kind
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ redundancyMode:
+ default: AllActive
+ description: |-
+ RedundancyMode defines the multi-homing forwarding model for this Ethernet Segment
+ as defined in RFC 7432 Section 14.1.
+ enum:
+ - AllActive
+ - SingleActive
+ type: string
+ required:
+ - deviceRef
+ - esiType
+ - interfaceRef
+ type: object
+ x-kubernetes-validations:
+ - message: ESI is required when ESIType is Arbitrary
+ rule: self.esiType != 'Arbitrary' || has(self.esi)
+ - message: ESI must be omitted when ESIType is LACP or MST
+ rule: '!(self.esiType == ''LACP'' || self.esiType == ''MST'') || !has(self.esi)'
+ - message: ESI first byte must match ESIType
+ rule: '!has(self.esi) || (self.esiType == ''Arbitrary'' && self.esi.startsWith(''00''))
+ || (self.esiType == ''MAC'' && self.esi.startsWith(''03'')) || (self.esiType
+ == ''RouterID'' && self.esi.startsWith(''04'')) || (self.esiType ==
+ ''AS'' && self.esi.startsWith(''05''))'
+ status:
+ description: |-
+ Status of the resource. This is set and updated automatically.
+ Read-only.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status
+ properties:
+ conditions:
+ items:
+ description: Condition contains details for one aspect of the current
+ state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ esi:
+ description: |-
+ ESI is the realized 10-byte Ethernet Segment Identifier on the device,
+ in colon-separated hex notation. Populated from spec or read back from
+ device when auto-generated.
+ type: string
+ esiType:
+ description: ESIType is the ESI derivation type parsed from the first
+ byte of ESI.
+ enum:
+ - Arbitrary
+ - LACP
+ - MST
+ - MAC
+ - RouterID
+ - AS
+ type: string
+ type: object
+ required:
+ - spec
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml
index 182f057f4..fb8088452 100644
--- a/config/crd/kustomization.yaml
+++ b/config/crd/kustomization.yaml
@@ -26,6 +26,7 @@ resources:
- bases/networking.metal.ironcore.dev_vlans.yaml
- bases/networking.metal.ironcore.dev_vrfs.yaml
- bases/networking.metal.ironcore.dev_lldps.yaml
+- bases/networking.metal.ironcore.dev_ethernetsegments.yaml
- bases/nx.cisco.networking.metal.ironcore.dev_bordergateways.yaml
- bases/nx.cisco.networking.metal.ironcore.dev_managementaccessconfigs.yaml
- bases/nx.cisco.networking.metal.ironcore.dev_networkvirtualizationedgeconfigs.yaml
diff --git a/config/rbac/ethernetsegment_admin_role.yaml b/config/rbac/ethernetsegment_admin_role.yaml
new file mode 100644
index 000000000..ffd76ba02
--- /dev/null
+++ b/config/rbac/ethernetsegment_admin_role.yaml
@@ -0,0 +1,27 @@
+# This rule is not used by the project network-operator itself.
+# It is provided to allow the cluster admin to help manage permissions for users.
+#
+# Grants full permissions ('*') over networking.metal.ironcore.dev.
+# This role is intended for users authorized to modify roles and bindings within the cluster,
+# enabling them to delegate specific permissions to other users or groups as needed.
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/name: network-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: ethernetsegment-admin-role
+rules:
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - ethernetsegments
+ verbs:
+ - '*'
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - ethernetsegments/status
+ verbs:
+ - get
diff --git a/config/rbac/ethernetsegment_editor_role.yaml b/config/rbac/ethernetsegment_editor_role.yaml
new file mode 100644
index 000000000..564fdfc08
--- /dev/null
+++ b/config/rbac/ethernetsegment_editor_role.yaml
@@ -0,0 +1,33 @@
+# This rule is not used by the project network-operator itself.
+# It is provided to allow the cluster admin to help manage permissions for users.
+#
+# Grants permissions to create, update, and delete resources within the networking.metal.ironcore.dev.
+# This role is intended for users who need to manage these resources
+# but should not control RBAC or manage permissions for others.
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/name: network-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: ethernetsegment-editor-role
+rules:
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - ethernetsegments
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - ethernetsegments/status
+ verbs:
+ - get
diff --git a/config/rbac/ethernetsegment_viewer_role.yaml b/config/rbac/ethernetsegment_viewer_role.yaml
new file mode 100644
index 000000000..0214c5fe7
--- /dev/null
+++ b/config/rbac/ethernetsegment_viewer_role.yaml
@@ -0,0 +1,29 @@
+# This rule is not used by the project network-operator itself.
+# It is provided to allow the cluster admin to help manage permissions for users.
+#
+# Grants read-only access to networking.metal.ironcore.dev resources.
+# This role is intended for users who need visibility into these resources
+# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing.
+
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/name: network-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: ethernetsegment-viewer-role
+rules:
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - ethernetsegments
+ verbs:
+ - get
+ - list
+ - watch
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - ethernetsegments/status
+ verbs:
+ - get
diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml
index f6aab1b99..64594d3a2 100644
--- a/config/rbac/kustomization.yaml
+++ b/config/rbac/kustomization.yaml
@@ -43,6 +43,9 @@ resources:
- dns_admin_role.yaml
- dns_editor_role.yaml
- dns_viewer_role.yaml
+- ethernetsegment_admin_role.yaml
+- ethernetsegment_editor_role.yaml
+- ethernetsegment_viewer_role.yaml
- evpninstance_admin_role.yaml
- evpninstance_editor_role.yaml
- evpninstance_viewer_role.yaml
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index e9757ff33..963922a8f 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -50,6 +50,7 @@ rules:
- devices
- dhcprelays
- dns
+ - ethernetsegments
- evpninstances
- interfaces
- isis
@@ -85,6 +86,7 @@ rules:
- devices/finalizers
- dhcprelays/finalizers
- dns/finalizers
+ - ethernetsegments/finalizers
- evpninstances/finalizers
- interfaces/finalizers
- isis/finalizers
@@ -114,6 +116,7 @@ rules:
- devices/status
- dhcprelays/status
- dns/status
+ - ethernetsegments/status
- evpninstances/status
- interfaces/status
- isis/status
diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml
index ac22ba582..81d66ee3f 100644
--- a/config/samples/kustomization.yaml
+++ b/config/samples/kustomization.yaml
@@ -24,6 +24,7 @@ resources:
- v1alpha1_nve.yaml
- v1alpha1_prefixset.yaml
- v1alpha1_routingpolicy.yaml
+- v1alpha1_ethernetsegment.yaml
- cisco/nx/v1alpha1_bordergateway.yaml
- cisco/nx/v1alpha1_managementaccessconfig.yaml
- cisco/nx/v1alpha1_nveconfig.yaml
diff --git a/config/samples/v1alpha1_ethernetsegment.yaml b/config/samples/v1alpha1_ethernetsegment.yaml
new file mode 100644
index 000000000..83ae65e0c
--- /dev/null
+++ b/config/samples/v1alpha1_ethernetsegment.yaml
@@ -0,0 +1,15 @@
+apiVersion: networking.metal.ironcore.dev/v1alpha1
+kind: EthernetSegment
+metadata:
+ labels:
+ app.kubernetes.io/name: network-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: ethernetsegment-sample
+spec:
+ deviceRef:
+ name: leaf1
+ interfaceRef:
+ name: po-10
+ esiType: Arbitrary
+ esi: "00:11:22:33:44:55:66:77:88:01"
+ redundancyMode: AllActive
diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md
index cae61154b..2e334a233 100644
--- a/docs/api-reference/index.md
+++ b/docs/api-reference/index.md
@@ -23,6 +23,7 @@ SPDX-License-Identifier: Apache-2.0
- [DNS](#dns)
- [Device](#device)
- [EVPNInstance](#evpninstance)
+- [EthernetSegment](#ethernetsegment)
- [ISIS](#isis)
- [Interface](#interface)
- [LLDP](#lldp)
@@ -858,6 +859,25 @@ _Appears in:_
| `mode` _[LACPMode](#lacpmode)_ | Mode defines the LACP mode for the aggregate interface. | | Enum: [Active Passive]
Required: \{\}
|
+#### DFElectionMode
+
+_Underlying type:_ _string_
+
+DFElectionMode defines the Designated Forwarder election algorithm.
+
+_Validation:_
+- Enum: [Default HighestRandomWeight Preference]
+
+_Appears in:_
+- [DesignatedForwarder](#designatedforwarder)
+
+| Field | Description |
+| --- | --- |
+| `Default` | DFElectionModeDefault uses the modulo-based DF election per RFC 7432 Section 8.5.
|
+| `HighestRandomWeight` | DFElectionModeHighestRandomWeight uses the HRW algorithm per RFC 8584.
|
+| `Preference` | DFElectionModePreference uses preference-based DF election per RFC 8584.
|
+
+
#### DHCPRelay
@@ -971,6 +991,23 @@ _Appears in:_
| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array_ | The conditions are a list of status objects that describe the state of the DNS. | | Optional: \{\}
|
+#### DesignatedForwarder
+
+
+
+DesignatedForwarder configures the DF election parameters for an Ethernet Segment.
+
+
+
+_Appears in:_
+- [EthernetSegmentSpec](#ethernetsegmentspec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `electionMode` _[DFElectionMode](#dfelectionmode)_ | ElectionMode selects the DF election algorithm. | Default | Enum: [Default HighestRandomWeight Preference]
Optional: \{\}
|
+| `electionWaitTime` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#duration-v1-meta)_ | ElectionWaitTime is the DF election hold timer. The PE waits this
duration before selecting the DF based on highest preference.
Only applicable when ElectionMode is Preference. | | Optional: \{\}
|
+
+
#### Device
@@ -1075,6 +1112,29 @@ _Appears in:_
| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array_ | The conditions are a list of status objects that describe the state of the Device. | | Optional: \{\}
|
+#### ESIType
+
+_Underlying type:_ _string_
+
+ESIType defines the ESI derivation method per RFC 7432 Section 5.
+
+_Validation:_
+- Enum: [Arbitrary LACP MST MAC RouterID AS]
+
+_Appears in:_
+- [EthernetSegmentSpec](#ethernetsegmentspec)
+- [EthernetSegmentStatus](#ethernetsegmentstatus)
+
+| Field | Description |
+| --- | --- |
+| `Arbitrary` | ESITypeArbitrary indicates an operator-configured ESI value (Type 0).
|
+| `LACP` | ESITypeLACP indicates a LACP-based ESI derived from CE system MAC and port key (Type 1).
|
+| `MST` | ESITypeMST indicates a bridge-protocol-based ESI derived from root bridge parameters (Type 2).
|
+| `MAC` | ESITypeMAC indicates a MAC-based ESI derived from system MAC and local discriminator (Type 3).
|
+| `RouterID` | ESITypeRouterID indicates a router-ID-based ESI (Type 4).
|
+| `AS` | ESITypeAS indicates an AS-number-based ESI (Type 5).
|
+
+
#### EVPNInstance
@@ -1242,6 +1302,70 @@ _Appears in:_
| `fecMode` _[FECMode](#fecmode)_ | FECMode specifies the Forward Error Correction mode for the interface.
FEC provides error detection and correction at the physical layer, improving link reliability.
When not specified, the FEC mode defaults to "auto" where the device negotiates the appropriate mode. | | Enum: [FC RS528 Disabled]
Optional: \{\}
|
+#### EthernetSegment
+
+
+
+EthernetSegment is the Schema for the ethernetsegments API.
+
+
+
+
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `apiVersion` _string_ | `networking.metal.ironcore.dev/v1alpha1` | | |
+| `kind` _string_ | `EthernetSegment` | | |
+| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | |
+| `spec` _[EthernetSegmentSpec](#ethernetsegmentspec)_ | Specification of the desired state of the resource.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status | | Required: \{\}
|
+| `status` _[EthernetSegmentStatus](#ethernetsegmentstatus)_ | Status of the resource. This is set and updated automatically.
Read-only.
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status | | Optional: \{\}
|
+
+
+#### EthernetSegmentSpec
+
+
+
+EthernetSegmentSpec defines the desired state of EthernetSegment.
+
+It models an EVPN Ethernet Segment for multihoming as defined in [RFC 7432] Section 5.
+An Ethernet Segment associates an Aggregate interface with a 10-byte Ethernet Segment
+Identifier (ESI), enabling multi-homed CE connectivity.
+[RFC 7432]: https://datatracker.ietf.org/doc/html/rfc7432
+
+
+
+_Appears in:_
+- [EthernetSegment](#ethernetsegment)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `deviceRef` _[LocalObjectReference](#localobjectreference)_ | DeviceRef is the name of the Device this object belongs to. The Device object must exist in the same namespace.
Immutable. | | Required: \{\}
|
+| `providerConfigRef` _[TypedLocalObjectReference](#typedlocalobjectreference)_ | ProviderConfigRef is a reference to a resource holding the provider-specific configuration of this Ethernet Segment. | | Optional: \{\}
|
+| `interfaceRef` _[LocalObjectReference](#localobjectreference)_ | InterfaceRef is the name of the Interface this Ethernet Segment is associated with.
The Interface must be of type Aggregate and belong to the same Device.
Immutable. | | Required: \{\}
|
+| `esiType` _[ESIType](#esitype)_ | ESIType selects the ESI derivation method (RFC 7432 Section 5).
When Arbitrary (Type 0), ESI must be provided explicitly.
When LACP or MST (Types 1, 2), ESI is always auto-derived (ESI field must be omitted).
When MAC, RouterID, or AS (Types 3-5), ESI may be explicit or auto-derived.
Immutable. | Arbitrary | Enum: [Arbitrary LACP MST MAC RouterID AS]
Required: \{\}
|
+| `esi` _string_ | ESI is the 10-byte Ethernet Segment Identifier in colon-separated hex notation
(e.g., "00:11:22:33:44:55:66:77:88:01"). Must not be all-zeros or all-ones (reserved per RFC 7432).
Required when ESIType is Arbitrary. Must be omitted when ESIType is LACP or MST.
Optional for MAC, RouterID, and AS types (omit to auto-derive on the device).
Immutable once set. | | Pattern: `^([0-9a-fA-F]\{2\}:)\{9\}[0-9a-fA-F]\{2\}$`
Optional: \{\}
|
+| `redundancyMode` _[RedundancyMode](#redundancymode)_ | RedundancyMode defines the multi-homing forwarding model for this Ethernet Segment
as defined in RFC 7432 Section 14.1. | AllActive | Enum: [AllActive SingleActive]
Optional: \{\}
|
+| `designatedForwarder` _[DesignatedForwarder](#designatedforwarder)_ | DesignatedForwarder configures the Designated Forwarder election for this
Ethernet Segment (RFC 7432 Section 8.5, RFC 8584). | | Optional: \{\}
|
+
+
+#### EthernetSegmentStatus
+
+
+
+EthernetSegmentStatus defines the observed state of EthernetSegment.
+
+
+
+_Appears in:_
+- [EthernetSegment](#ethernetsegment)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array_ | | | Optional: \{\}
|
+| `esi` _string_ | ESI is the realized 10-byte Ethernet Segment Identifier on the device,
in colon-separated hex notation. Populated from spec or read back from
device when auto-generated. | | Optional: \{\}
|
+| `esiType` _[ESIType](#esitype)_ | ESIType is the ESI derivation type parsed from the first byte of ESI. | | Enum: [Arbitrary LACP MST MAC RouterID AS]
Optional: \{\}
|
+
+
#### FECMode
_Underlying type:_ _string_
@@ -1689,6 +1813,7 @@ _Appears in:_
- [DNSSpec](#dnsspec)
- [DevicePort](#deviceport)
- [EVPNInstanceSpec](#evpninstancespec)
+- [EthernetSegmentSpec](#ethernetsegmentspec)
- [ISISSpec](#isisspec)
- [InterconnectInterfaceReference](#interconnectinterfacereference)
- [InterfaceIPv4Unnumbered](#interfaceipv4unnumbered)
@@ -2537,6 +2662,24 @@ _Appears in:_
+#### RedundancyMode
+
+_Underlying type:_ _string_
+
+RedundancyMode defines the forwarding model for a multi-homed Ethernet Segment.
+
+_Validation:_
+- Enum: [AllActive SingleActive]
+
+_Appears in:_
+- [EthernetSegmentSpec](#ethernetsegmentspec)
+
+| Field | Description |
+| --- | --- |
+| `AllActive` | RedundancyModeAllActive enables all PE nodes in the segment to forward unicast
traffic simultaneously (RFC 7432 Section 14.1.2).
|
+| `SingleActive` | RedundancyModeSingleActive restricts forwarding to the elected Designated Forwarder
only (RFC 7432 Section 14.1.1).
|
+
+
#### RendezvousPoint
@@ -3117,6 +3260,7 @@ _Appears in:_
- [DHCPRelaySpec](#dhcprelayspec)
- [DNSSpec](#dnsspec)
- [EVPNInstanceSpec](#evpninstancespec)
+- [EthernetSegmentSpec](#ethernetsegmentspec)
- [ISISSpec](#isisspec)
- [InterfaceSpec](#interfacespec)
- [LLDPSpec](#lldpspec)
diff --git a/internal/controller/core/ethernetsegment_controller.go b/internal/controller/core/ethernetsegment_controller.go
new file mode 100644
index 000000000..8f0a3ee21
--- /dev/null
+++ b/internal/controller/core/ethernetsegment_controller.go
@@ -0,0 +1,573 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package core
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "k8s.io/apimachinery/pkg/api/equality"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/types"
+ kerrors "k8s.io/apimachinery/pkg/util/errors"
+ "k8s.io/client-go/tools/events"
+ "k8s.io/klog/v2"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/builder"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+ "sigs.k8s.io/controller-runtime/pkg/event"
+ "sigs.k8s.io/controller-runtime/pkg/handler"
+ "sigs.k8s.io/controller-runtime/pkg/predicate"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ "github.com/ironcore-dev/network-operator/api/core/v1alpha1"
+ "github.com/ironcore-dev/network-operator/internal/apistatus"
+ "github.com/ironcore-dev/network-operator/internal/conditions"
+ "github.com/ironcore-dev/network-operator/internal/deviceutil"
+ "github.com/ironcore-dev/network-operator/internal/paused"
+ "github.com/ironcore-dev/network-operator/internal/provider"
+ "github.com/ironcore-dev/network-operator/internal/resourcelock"
+)
+
+// EthernetSegmentReconciler reconciles a EthernetSegment object
+type EthernetSegmentReconciler struct {
+ client.Client
+ Scheme *runtime.Scheme
+
+ // WatchFilterValue is the label value used to filter events prior to reconciliation.
+ WatchFilterValue string
+
+ // Recorder is used to record events for the controller.
+ // More info: https://book.kubebuilder.io/reference/raising-events
+ Recorder events.EventRecorder
+
+ // Provider is the driver that will be used to create & delete the ethernetsegment.
+ Provider provider.ProviderFunc
+
+ // Locker is used to synchronize operations on resources targeting the same device.
+ Locker *resourcelock.ResourceLocker
+
+ // RequeueInterval is the duration after which the controller should requeue the reconciliation,
+ // in order to periodically reconcile the resource.
+ RequeueInterval time.Duration
+}
+
+// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=ethernetsegments,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=ethernetsegments/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=ethernetsegments/finalizers,verbs=update
+// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
+
+// Reconcile is part of the main kubernetes reconciliation loop which aims to
+// move the current state of the cluster closer to the desired state.
+//
+// For more details, check Reconcile and its Result here:
+// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.20.2/pkg/reconcile
+//
+// For more details about the method shape, read up here:
+// - https://ahmet.im/blog/controller-pitfalls/#reconcile-method-shape
+func (r *EthernetSegmentReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, reterr error) {
+ log := ctrl.LoggerFrom(ctx)
+ log.V(3).Info("Reconciling resource")
+
+ obj := new(v1alpha1.EthernetSegment)
+ if err := r.Get(ctx, req.NamespacedName, obj); err != nil {
+ if apierrors.IsNotFound(err) {
+ // If the custom resource is not found then it usually means that it was deleted or not created
+ // In this way, we will stop the reconciliation
+ log.V(3).Info("Resource not found. Ignoring since object must be deleted")
+ return ctrl.Result{}, nil
+ }
+ // Error reading the object - requeue the request.
+ log.Error(err, "Failed to get resource")
+ return ctrl.Result{}, err
+ }
+
+ prov, ok := r.Provider().(provider.EthernetSegmentProvider)
+ if !ok {
+ if meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{
+ Type: v1alpha1.ReadyCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.NotImplementedReason,
+ Message: "Provider does not implement provider.EthernetSegmentProvider",
+ }) {
+ return ctrl.Result{}, r.Status().Update(ctx, obj)
+ }
+ return ctrl.Result{}, nil
+ }
+
+ device, err := deviceutil.GetDeviceByName(ctx, r, obj.Namespace, obj.Spec.DeviceRef.Name)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ if isPaused, requeue, err := paused.EnsureCondition(ctx, r.Client, device, obj); isPaused || requeue || err != nil {
+ return ctrl.Result{Requeue: requeue}, err
+ }
+
+ if err := r.Locker.AcquireLock(ctx, device.Name, "ethernetsegment-controller"); err != nil {
+ if errors.Is(err, resourcelock.ErrLockAlreadyHeld) {
+ log.V(3).Info("Device is already locked, requeuing reconciliation")
+ return ctrl.Result{RequeueAfter: Jitter(time.Second), Priority: new(LockWaitPriorityDefault)}, nil
+ }
+ log.Error(err, "Failed to acquire device lock")
+ return ctrl.Result{}, err
+ }
+ defer func() {
+ if err := r.Locker.ReleaseLock(ctx, device.Name, "ethernetsegment-controller"); err != nil {
+ log.Error(err, "Failed to release device lock")
+ reterr = kerrors.NewAggregate([]error{reterr, err})
+ }
+ }()
+
+ conn, err := deviceutil.GetDeviceConnection(ctx, r, device)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ var cfg *provider.ProviderConfig
+ if obj.Spec.ProviderConfigRef != nil {
+ cfg, err = provider.GetProviderConfig(ctx, r, obj.Namespace, obj.Spec.ProviderConfigRef)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+ }
+
+ s := ðernetSegmentScope{
+ Device: device,
+ EthernetSegment: obj,
+ Connection: conn,
+ ProviderConfig: cfg,
+ Provider: prov,
+ }
+
+ if !obj.DeletionTimestamp.IsZero() {
+ if controllerutil.ContainsFinalizer(obj, v1alpha1.FinalizerName) {
+ if err := r.finalize(ctx, s); err != nil {
+ log.Error(err, "Failed to finalize resource")
+ return ctrl.Result{}, err
+ }
+ controllerutil.RemoveFinalizer(obj, v1alpha1.FinalizerName)
+ if err := r.Update(ctx, obj); err != nil {
+ log.Error(err, "Failed to remove finalizer from resource")
+ return ctrl.Result{}, err
+ }
+ }
+ log.V(3).Info("Resource is being deleted, skipping reconciliation")
+ return ctrl.Result{}, nil
+ }
+
+ // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/finalizers
+ if !controllerutil.ContainsFinalizer(obj, v1alpha1.FinalizerName) {
+ controllerutil.AddFinalizer(obj, v1alpha1.FinalizerName)
+ if err := r.Update(ctx, obj); err != nil {
+ log.Error(err, "Failed to add finalizer to resource")
+ return ctrl.Result{}, err
+ }
+ log.V(1).Info("Added finalizer to resource")
+ return ctrl.Result{}, nil
+ }
+
+ orig := obj.DeepCopy()
+ if conditions.InitializeConditions(obj, v1alpha1.ReadyCondition, v1alpha1.ConfiguredCondition, v1alpha1.OperationalCondition) {
+ log.V(1).Info("Initializing status conditions")
+ return ctrl.Result{}, r.Status().Update(ctx, obj)
+ }
+
+ // Always attempt to update the metadata/status after reconciliation
+ defer func() {
+ if !equality.Semantic.DeepEqual(orig.ObjectMeta, obj.ObjectMeta) {
+ // Pass obj.DeepCopy() to avoid Patch() modifying obj and interfering with status update below
+ if err := r.Patch(ctx, obj.DeepCopy(), client.MergeFrom(orig)); err != nil {
+ log.Error(err, "Failed to update resource metadata")
+ reterr = kerrors.NewAggregate([]error{reterr, err})
+ }
+ }
+ if !equality.Semantic.DeepEqual(orig.Status, obj.Status) {
+ if err := r.Status().Patch(ctx, obj, client.MergeFrom(orig)); err != nil {
+ log.Error(err, "Failed to update status")
+ reterr = kerrors.NewAggregate([]error{reterr, err})
+ }
+ }
+ }()
+
+ if err := r.reconcile(ctx, s); err != nil {
+ log.Error(err, "Failed to reconcile resource")
+ return ctrl.Result{}, apistatus.WrapTerminalError(err)
+ }
+
+ return ctrl.Result{RequeueAfter: Jitter(r.RequeueInterval)}, nil
+}
+
+const (
+ ethernetSegmentInterfaceRefKey = ".spec.interfaceRef.name"
+)
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *EthernetSegmentReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error {
+ labelSelector := metav1.LabelSelector{}
+ if r.WatchFilterValue != "" {
+ labelSelector.MatchLabels = map[string]string{v1alpha1.WatchLabel: r.WatchFilterValue}
+ }
+
+ filter, err := predicate.LabelSelectorPredicate(labelSelector)
+ if err != nil {
+ return fmt.Errorf("failed to create label selector predicate: %w", err)
+ }
+
+ if err := mgr.GetFieldIndexer().IndexField(ctx, &v1alpha1.EthernetSegment{}, v1alpha1.DeviceRefIndexKey, func(obj client.Object) []string {
+ o := obj.(*v1alpha1.EthernetSegment)
+ return []string{o.Spec.DeviceRef.Name}
+ }); err != nil {
+ return err
+ }
+
+ if err := mgr.GetFieldIndexer().IndexField(ctx, &v1alpha1.EthernetSegment{}, ethernetSegmentInterfaceRefKey, func(obj client.Object) []string {
+ o := obj.(*v1alpha1.EthernetSegment)
+ return []string{o.Spec.InterfaceRef.Name}
+ }); err != nil {
+ return err
+ }
+
+ bldr := ctrl.NewControllerManagedBy(mgr).
+ For(&v1alpha1.EthernetSegment{}).
+ Named("ethernetsegment").
+ WithEventFilter(filter)
+
+ for _, gvk := range v1alpha1.EthernetSegmentDependencies {
+ obj := &unstructured.Unstructured{}
+ obj.SetGroupVersionKind(gvk)
+
+ bldr = bldr.Watches(
+ obj,
+ handler.EnqueueRequestsFromMapFunc(r.ethernetsegmentsForProviderConfig),
+ builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}),
+ )
+ }
+
+ return bldr.
+ // Watches enqueues EthernetSegments for updates in referenced Interface resources.
+ // Triggers on create, delete, and update events when the interface's switchport configuration changes.
+ Watches(
+ &v1alpha1.Interface{},
+ handler.EnqueueRequestsFromMapFunc(r.interfaceToEthernetSegments),
+ builder.WithPredicates(predicate.Funcs{
+ UpdateFunc: func(e event.UpdateEvent) bool {
+ oldIntf, _ := e.ObjectOld.(*v1alpha1.Interface)
+ newIntf, _ := e.ObjectNew.(*v1alpha1.Interface)
+ return oldIntf != nil && newIntf != nil && (oldIntf.Spec.Switchport == nil) != (newIntf.Spec.Switchport == nil)
+ },
+ GenericFunc: func(e event.GenericEvent) bool {
+ return false
+ },
+ }),
+ ).
+ // Watches enqueues EthernetSegments for updates in referenced Device resources.
+ // Triggers on create, delete, and update events when the device's effective pause state changes.
+ Watches(
+ &v1alpha1.Device{},
+ handler.EnqueueRequestsFromMapFunc(r.deviceToEthernetSegments),
+ builder.WithPredicates(predicate.Funcs{
+ UpdateFunc: func(e event.UpdateEvent) bool {
+ return paused.DevicePausedChanged(e.ObjectOld, e.ObjectNew)
+ },
+ GenericFunc: func(e event.GenericEvent) bool {
+ return false
+ },
+ }),
+ ).
+ Complete(r)
+}
+
+// scope holds the different objects that are read and used during the reconcile.
+type ethernetSegmentScope struct {
+ Device *v1alpha1.Device
+ EthernetSegment *v1alpha1.EthernetSegment
+ Connection *deviceutil.Connection
+ ProviderConfig *provider.ProviderConfig
+ Provider provider.EthernetSegmentProvider
+}
+
+func (r *EthernetSegmentReconciler) reconcile(ctx context.Context, s *ethernetSegmentScope) (reterr error) {
+ if s.EthernetSegment.Labels == nil {
+ s.EthernetSegment.Labels = make(map[string]string)
+ }
+
+ s.EthernetSegment.Labels[v1alpha1.DeviceLabel] = s.Device.Name
+
+ // Ensure the EthernetSegment is owned by the Device.
+ if !controllerutil.HasControllerReference(s.EthernetSegment) {
+ if err := controllerutil.SetOwnerReference(s.Device, s.EthernetSegment, r.Scheme, controllerutil.WithBlockOwnerDeletion(true)); err != nil {
+ return err
+ }
+ }
+
+ defer func() {
+ conditions.RecomputeReady(s.EthernetSegment)
+ }()
+
+ intf, err := r.reconcileInterface(ctx, s)
+ if err != nil {
+ return err
+ }
+
+ if err := s.Provider.Connect(ctx, s.Connection); err != nil {
+ return fmt.Errorf("failed to connect to provider: %w", err)
+ }
+ defer func() {
+ if err := s.Provider.Disconnect(ctx, s.Connection); err != nil {
+ reterr = kerrors.NewAggregate([]error{reterr, err})
+ }
+ }()
+
+ // Ensure the EthernetSegment is realized on the provider.
+ err = s.Provider.EnsureEthernetSegment(ctx, &provider.EnsureEthernetSegmentRequest{
+ EthernetSegment: s.EthernetSegment,
+ Interface: intf,
+ ProviderConfig: s.ProviderConfig,
+ })
+
+ cond := conditions.FromError(err)
+ conditions.Set(s.EthernetSegment, cond)
+
+ if err != nil {
+ return err
+ }
+
+ status, err := s.Provider.GetEthernetSegmentStatus(ctx, &provider.EthernetSegmentStatusRequest{
+ EthernetSegment: s.EthernetSegment,
+ Interface: intf,
+ ProviderConfig: s.ProviderConfig,
+ })
+ if err != nil {
+ return fmt.Errorf("failed to get ethernet segment status: %w", err)
+ }
+
+ s.EthernetSegment.Status.ESI = status.ESI
+ s.EthernetSegment.Status.ESIType = esiTypeFromValue(status.ESI)
+
+ cond = metav1.Condition{
+ Type: v1alpha1.OperationalCondition,
+ Status: metav1.ConditionTrue,
+ Reason: v1alpha1.OperationalReason,
+ Message: "Ethernet Segment is operationally up",
+ }
+ if !status.OperStatus {
+ cond.Status = metav1.ConditionFalse
+ cond.Reason = v1alpha1.DegradedReason
+ cond.Message = "Ethernet Segment is operationally down"
+ }
+ conditions.Set(s.EthernetSegment, cond)
+
+ return nil
+}
+
+func esiTypeFromValue(esi string) v1alpha1.ESIType {
+ if len(esi) < 2 {
+ return ""
+ }
+ switch esi[:2] {
+ case "00":
+ return v1alpha1.ESITypeArbitrary
+ case "01":
+ return v1alpha1.ESITypeLACP
+ case "02":
+ return v1alpha1.ESITypeMST
+ case "03":
+ return v1alpha1.ESITypeMAC
+ case "04":
+ return v1alpha1.ESITypeRouterID
+ case "05":
+ return v1alpha1.ESITypeAS
+ default:
+ return ""
+ }
+}
+
+// reconcileInterface resolves the referenced Interface and validates that it is of type
+// Aggregate, belongs to the same Device, and has switchport configuration.
+func (r *EthernetSegmentReconciler) reconcileInterface(ctx context.Context, s *ethernetSegmentScope) (*v1alpha1.Interface, error) {
+ key := client.ObjectKey{
+ Name: s.EthernetSegment.Spec.InterfaceRef.Name,
+ Namespace: s.EthernetSegment.Namespace,
+ }
+
+ intf := new(v1alpha1.Interface)
+ if err := r.Get(ctx, key, intf); err != nil {
+ if apierrors.IsNotFound(err) {
+ conditions.Set(s.EthernetSegment, metav1.Condition{
+ Type: v1alpha1.ConfiguredCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.InterfaceNotFoundReason,
+ Message: fmt.Sprintf("referenced interface %q not found", key),
+ })
+ return nil, reconcile.TerminalError(fmt.Errorf("referenced interface %q not found", key))
+ }
+ return nil, fmt.Errorf("failed to get referenced interface %q: %w", key, err)
+ }
+
+ if intf.Spec.DeviceRef.Name != s.Device.Name {
+ conditions.Set(s.EthernetSegment, metav1.Condition{
+ Type: v1alpha1.ConfiguredCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.CrossDeviceReferenceReason,
+ Message: fmt.Sprintf("referenced interface %q does not belong to device %q", intf.Name, s.Device.Name),
+ })
+ return nil, reconcile.TerminalError(fmt.Errorf("referenced interface %q does not belong to device %q", intf.Name, s.Device.Name))
+ }
+
+ if intf.Spec.Type != v1alpha1.InterfaceTypeAggregate {
+ conditions.Set(s.EthernetSegment, metav1.Condition{
+ Type: v1alpha1.ConfiguredCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.InvalidInterfaceTypeReason,
+ Message: fmt.Sprintf("referenced interface %q is not of type Aggregate, got %q", intf.Name, intf.Spec.Type),
+ })
+ return nil, reconcile.TerminalError(fmt.Errorf("referenced interface %q is not of type Aggregate, got %q", intf.Name, intf.Spec.Type))
+ }
+
+ if intf.Spec.Switchport == nil {
+ conditions.Set(s.EthernetSegment, metav1.Condition{
+ Type: v1alpha1.ConfiguredCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.InterfaceNotSwitchportReason,
+ Message: fmt.Sprintf("referenced interface %q must have switchport configuration", intf.Name),
+ })
+ return nil, reconcile.TerminalError(fmt.Errorf("referenced interface %q must have switchport configuration", intf.Name))
+ }
+
+ return intf, nil
+}
+
+func (r *EthernetSegmentReconciler) finalize(ctx context.Context, s *ethernetSegmentScope) (reterr error) {
+ intf := new(v1alpha1.Interface)
+ if err := r.Get(ctx, client.ObjectKey{
+ Name: s.EthernetSegment.Spec.InterfaceRef.Name,
+ Namespace: s.EthernetSegment.Namespace,
+ }, intf); err != nil {
+ if apierrors.IsNotFound(err) {
+ // If the interface no longer exists, there is no device config to clean up
+ // since the resource could not have been configured without a valid interface.
+ return nil
+ }
+ return fmt.Errorf("failed to get referenced interface: %w", err)
+ }
+
+ if err := s.Provider.Connect(ctx, s.Connection); err != nil {
+ return fmt.Errorf("failed to connect to provider: %w", err)
+ }
+ defer func() {
+ if err := s.Provider.Disconnect(ctx, s.Connection); err != nil {
+ reterr = kerrors.NewAggregate([]error{reterr, err})
+ }
+ }()
+
+ return s.Provider.DeleteEthernetSegment(ctx, &provider.DeleteEthernetSegmentRequest{
+ EthernetSegment: s.EthernetSegment,
+ Interface: intf,
+ ProviderConfig: s.ProviderConfig,
+ })
+}
+
+// deviceToEthernetSegments is a [handler.MapFunc] to be used to enqueue requests for reconciliation
+// for EthernetSegments when their referenced Device's effective pause state changes.
+func (r *EthernetSegmentReconciler) deviceToEthernetSegments(ctx context.Context, obj client.Object) []ctrl.Request {
+ device, ok := obj.(*v1alpha1.Device)
+ if !ok {
+ panic(fmt.Sprintf("Expected a Device but got a %T", obj))
+ }
+
+ log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device))
+
+ list := new(v1alpha1.EthernetSegmentList)
+ if err := r.List(
+ ctx, list,
+ client.InNamespace(device.Namespace),
+ client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name},
+ ); err != nil {
+ log.Error(err, "Failed to list EthernetSegments")
+ return nil
+ }
+
+ requests := make([]ctrl.Request, 0, len(list.Items))
+ for _, i := range list.Items {
+ log.V(2).Info("Enqueuing EthernetSegment for reconciliation", "EthernetSegment", klog.KObj(&i))
+ requests = append(requests, ctrl.Request{
+ NamespacedName: client.ObjectKey{
+ Name: i.Name,
+ Namespace: i.Namespace,
+ },
+ })
+ }
+
+ return requests
+}
+
+// ethernetsegmentsForProviderConfig is a [handler.MapFunc] to be used to enqueue requests for reconciliation
+// for a EthernetSegment to update when one of its referenced provider configurations gets updated.
+func (r *EthernetSegmentReconciler) ethernetsegmentsForProviderConfig(ctx context.Context, obj client.Object) []reconcile.Request {
+ log := ctrl.LoggerFrom(ctx, "Object", klog.KObj(obj))
+
+ list := &v1alpha1.EthernetSegmentList{}
+ if err := r.List(ctx, list, client.InNamespace(obj.GetNamespace())); err != nil {
+ log.Error(err, "Failed to list EthernetSegments")
+ return nil
+ }
+
+ gkv := obj.GetObjectKind().GroupVersionKind()
+
+ var requests []reconcile.Request
+ for _, m := range list.Items {
+ if m.Spec.ProviderConfigRef != nil &&
+ m.Spec.ProviderConfigRef.Name == obj.GetName() &&
+ m.Spec.ProviderConfigRef.Kind == gkv.Kind &&
+ m.Spec.ProviderConfigRef.APIVersion == gkv.GroupVersion().Identifier() {
+ log.V(2).Info("Enqueuing EthernetSegment for reconciliation", "EthernetSegment", klog.KObj(&m))
+ requests = append(requests, reconcile.Request{
+ NamespacedName: types.NamespacedName{
+ Name: m.Name,
+ Namespace: m.Namespace,
+ },
+ })
+ }
+ }
+
+ return requests
+}
+
+// interfaceToEthernetSegments is a [handler.MapFunc] to be used to enqueue requests for reconciliation
+// for EthernetSegments when their referenced Interface changes.
+func (r *EthernetSegmentReconciler) interfaceToEthernetSegments(ctx context.Context, obj client.Object) []ctrl.Request {
+ intf, ok := obj.(*v1alpha1.Interface)
+ if !ok {
+ panic(fmt.Sprintf("Expected an Interface but got a %T", obj))
+ }
+
+ log := ctrl.LoggerFrom(ctx, "Interface", klog.KObj(intf))
+
+ list := new(v1alpha1.EthernetSegmentList)
+ if err := r.List(ctx, list, client.InNamespace(intf.Namespace), client.MatchingFields{ethernetSegmentInterfaceRefKey: intf.Name}); err != nil {
+ log.Error(err, "Failed to list EthernetSegments")
+ return nil
+ }
+
+ requests := make([]ctrl.Request, 0, len(list.Items))
+ for _, i := range list.Items {
+ log.V(2).Info("Enqueuing EthernetSegment for reconciliation", "EthernetSegment", klog.KObj(&i))
+ requests = append(requests, ctrl.Request{
+ NamespacedName: client.ObjectKey{
+ Name: i.Name,
+ Namespace: i.Namespace,
+ },
+ })
+ }
+
+ return requests
+}
diff --git a/internal/controller/core/ethernetsegment_controller_test.go b/internal/controller/core/ethernetsegment_controller_test.go
new file mode 100644
index 000000000..a6b0ad6f6
--- /dev/null
+++ b/internal/controller/core/ethernetsegment_controller_test.go
@@ -0,0 +1,396 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package core
+
+import (
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+
+ "github.com/ironcore-dev/network-operator/api/core/v1alpha1"
+)
+
+var _ = Describe("EthernetSegment Controller", func() {
+ Context("When reconciling a resource", func() {
+ const esi = "00:11:22:33:44:55:66:77:88:01"
+ var (
+ name string
+ key client.ObjectKey
+ )
+
+ BeforeEach(func() {
+ By("Creating the custom resource for the Kind Device")
+ device := &v1alpha1.Device{
+ ObjectMeta: metav1.ObjectMeta{
+ GenerateName: "test-es-",
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.DeviceSpec{
+ Endpoint: v1alpha1.Endpoint{
+ Address: "192.168.10.2:9339",
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, device)).To(Succeed())
+ name = device.Name
+ key = client.ObjectKey{Name: name, Namespace: metav1.NamespaceDefault}
+ })
+
+ AfterEach(func() {
+ By("Cleaning up all EthernetSegment resources")
+ Expect(k8sClient.DeleteAllOf(ctx, &v1alpha1.EthernetSegment{}, client.InNamespace(metav1.NamespaceDefault))).To(Succeed())
+
+ By("Cleaning up test Interface resource")
+ intf := &v1alpha1.Interface{}
+ if err := k8sClient.Get(ctx, key, intf); err == nil {
+ Expect(k8sClient.Delete(ctx, intf)).To(Succeed())
+ }
+
+ device := &v1alpha1.Device{}
+ err := k8sClient.Get(ctx, key, device)
+ Expect(err).NotTo(HaveOccurred())
+
+ By("Cleaning up the test Device resource")
+ Expect(k8sClient.Delete(ctx, device, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed())
+
+ By("Verifying the EthernetSegment is removed from the provider")
+ Eventually(func(g Gomega) {
+ _, exists := testProvider.GetEthernetSegment(name)
+ g.Expect(exists).To(BeFalse(), "Provider shouldn't have ESI configured anymore")
+ }).Should(Succeed())
+ })
+
+ It("Should successfully reconcile an EthernetSegment", func() {
+ By("Creating an Aggregate Interface with switchport config")
+ intf := &v1alpha1.Interface{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.InterfaceSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: name},
+ Name: "port-channel10",
+ Type: v1alpha1.InterfaceTypeAggregate,
+ AdminState: v1alpha1.AdminStateUp,
+ Switchport: &v1alpha1.Switchport{
+ Mode: v1alpha1.SwitchportModeTrunk,
+ },
+ Aggregation: &v1alpha1.Aggregation{
+ MemberInterfaceRefs: []v1alpha1.LocalObjectReference{{Name: "eth1"}},
+ ControlProtocol: v1alpha1.ControlProtocol{Mode: v1alpha1.LACPModeActive},
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, intf)).To(Succeed())
+
+ By("Creating an EthernetSegment")
+ es := &v1alpha1.EthernetSegment{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.EthernetSegmentSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: name},
+ InterfaceRef: v1alpha1.LocalObjectReference{Name: name},
+ ESIType: v1alpha1.ESITypeArbitrary,
+ ESI: esi,
+ RedundancyMode: v1alpha1.RedundancyModeAllActive,
+ },
+ }
+ Expect(k8sClient.Create(ctx, es)).To(Succeed())
+
+ By("Verifying the controller adds a finalizer")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.EthernetSegment{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(controllerutil.ContainsFinalizer(resource, v1alpha1.FinalizerName)).To(BeTrue())
+ }).Should(Succeed())
+
+ By("Verifying the controller adds the device label")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.EthernetSegment{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(resource.Labels).To(HaveKeyWithValue(v1alpha1.DeviceLabel, name))
+ }).Should(Succeed())
+
+ By("Verifying the controller sets the device as owner reference")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.EthernetSegment{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(resource.OwnerReferences).To(HaveLen(1))
+ g.Expect(resource.OwnerReferences[0].Kind).To(Equal("Device"))
+ g.Expect(resource.OwnerReferences[0].Name).To(Equal(name))
+ }).Should(Succeed())
+
+ By("Verifying the controller updates the status conditions")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.EthernetSegment{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(resource.Status.Conditions).To(HaveLen(4))
+ g.Expect(resource.Status.Conditions[0].Type).To(Equal(v1alpha1.ReadyCondition))
+ g.Expect(resource.Status.Conditions[0].Status).To(Equal(metav1.ConditionTrue))
+ g.Expect(resource.Status.Conditions[1].Type).To(Equal(v1alpha1.ConfiguredCondition))
+ g.Expect(resource.Status.Conditions[1].Status).To(Equal(metav1.ConditionTrue))
+ g.Expect(resource.Status.Conditions[2].Type).To(Equal(v1alpha1.OperationalCondition))
+ g.Expect(resource.Status.Conditions[2].Status).To(Equal(metav1.ConditionTrue))
+ g.Expect(resource.Status.Conditions[3].Type).To(Equal(v1alpha1.PausedCondition))
+ g.Expect(resource.Status.Conditions[3].Status).To(Equal(metav1.ConditionFalse))
+ }).Should(Succeed())
+
+ By("Verifying the EthernetSegment is configured in the provider")
+ Eventually(func(g Gomega) {
+ storedESI, exists := testProvider.GetEthernetSegment(name)
+ g.Expect(exists).To(BeTrue(), "Provider should have ESI configured")
+ g.Expect(storedESI).To(Equal(esi))
+ }).Should(Succeed())
+
+ By("Verifying status ESI and ESIType are populated")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.EthernetSegment{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(resource.Status.ESI).To(Equal(esi))
+ g.Expect(resource.Status.ESIType).To(Equal(v1alpha1.ESITypeArbitrary))
+ }).Should(Succeed())
+ })
+
+ It("Should handle EthernetSegment referencing non-existent Interface", func() {
+ By("Creating an EthernetSegment referencing a non-existent Interface")
+ es := &v1alpha1.EthernetSegment{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.EthernetSegmentSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: name},
+ InterfaceRef: v1alpha1.LocalObjectReference{Name: "non-existent-intf"},
+ ESIType: v1alpha1.ESITypeArbitrary,
+ ESI: esi,
+ RedundancyMode: v1alpha1.RedundancyModeAllActive,
+ },
+ }
+ Expect(k8sClient.Create(ctx, es)).To(Succeed())
+
+ By("Verifying the controller sets InterfaceNotFound status")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.EthernetSegment{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(resource.Status.Conditions).To(HaveLen(4))
+ g.Expect(resource.Status.Conditions[0].Type).To(Equal(v1alpha1.ReadyCondition))
+ g.Expect(resource.Status.Conditions[0].Status).To(Equal(metav1.ConditionFalse))
+ g.Expect(resource.Status.Conditions[1].Type).To(Equal(v1alpha1.ConfiguredCondition))
+ g.Expect(resource.Status.Conditions[1].Status).To(Equal(metav1.ConditionFalse))
+ g.Expect(resource.Status.Conditions[1].Reason).To(Equal(v1alpha1.InterfaceNotFoundReason))
+ g.Expect(resource.Status.Conditions[2].Type).To(Equal(v1alpha1.OperationalCondition))
+ g.Expect(resource.Status.Conditions[3].Type).To(Equal(v1alpha1.PausedCondition))
+ g.Expect(resource.Status.Conditions[3].Status).To(Equal(metav1.ConditionFalse))
+ }).Should(Succeed())
+ })
+
+ It("Should handle EthernetSegment referencing Interface on different device", func() {
+ By("Creating an Interface on a different device")
+ intf := &v1alpha1.Interface{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.InterfaceSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: "different-device"},
+ Name: "port-channel10",
+ Type: v1alpha1.InterfaceTypeAggregate,
+ AdminState: v1alpha1.AdminStateUp,
+ Switchport: &v1alpha1.Switchport{
+ Mode: v1alpha1.SwitchportModeTrunk,
+ },
+ Aggregation: &v1alpha1.Aggregation{
+ MemberInterfaceRefs: []v1alpha1.LocalObjectReference{{Name: "eth1"}},
+ ControlProtocol: v1alpha1.ControlProtocol{Mode: v1alpha1.LACPModeActive},
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, intf)).To(Succeed())
+
+ By("Creating an EthernetSegment referencing the cross-device Interface")
+ es := &v1alpha1.EthernetSegment{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.EthernetSegmentSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: name},
+ InterfaceRef: v1alpha1.LocalObjectReference{Name: name},
+ ESIType: v1alpha1.ESITypeArbitrary,
+ ESI: esi,
+ RedundancyMode: v1alpha1.RedundancyModeAllActive,
+ },
+ }
+ Expect(k8sClient.Create(ctx, es)).To(Succeed())
+
+ By("Verifying the controller sets CrossDeviceReference status")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.EthernetSegment{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(resource.Status.Conditions).To(HaveLen(4))
+ g.Expect(resource.Status.Conditions[0].Type).To(Equal(v1alpha1.ReadyCondition))
+ g.Expect(resource.Status.Conditions[0].Status).To(Equal(metav1.ConditionFalse))
+ g.Expect(resource.Status.Conditions[1].Type).To(Equal(v1alpha1.ConfiguredCondition))
+ g.Expect(resource.Status.Conditions[1].Status).To(Equal(metav1.ConditionFalse))
+ g.Expect(resource.Status.Conditions[1].Reason).To(Equal(v1alpha1.CrossDeviceReferenceReason))
+ g.Expect(resource.Status.Conditions[2].Type).To(Equal(v1alpha1.OperationalCondition))
+ g.Expect(resource.Status.Conditions[3].Type).To(Equal(v1alpha1.PausedCondition))
+ g.Expect(resource.Status.Conditions[3].Status).To(Equal(metav1.ConditionFalse))
+ }).Should(Succeed())
+ })
+
+ It("Should handle EthernetSegment referencing non-Aggregate Interface", func() {
+ By("Creating an Ethernet Interface (not Aggregate)")
+ intf := &v1alpha1.Interface{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.InterfaceSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: name},
+ Name: "eth1/1",
+ Type: v1alpha1.InterfaceTypePhysical,
+ AdminState: v1alpha1.AdminStateUp,
+ Switchport: &v1alpha1.Switchport{
+ Mode: v1alpha1.SwitchportModeTrunk,
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, intf)).To(Succeed())
+
+ By("Creating an EthernetSegment referencing the non-Aggregate Interface")
+ es := &v1alpha1.EthernetSegment{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.EthernetSegmentSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: name},
+ InterfaceRef: v1alpha1.LocalObjectReference{Name: name},
+ ESIType: v1alpha1.ESITypeArbitrary,
+ ESI: esi,
+ RedundancyMode: v1alpha1.RedundancyModeAllActive,
+ },
+ }
+ Expect(k8sClient.Create(ctx, es)).To(Succeed())
+
+ By("Verifying the controller sets InvalidInterfaceType status")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.EthernetSegment{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(resource.Status.Conditions).To(HaveLen(4))
+ g.Expect(resource.Status.Conditions[0].Type).To(Equal(v1alpha1.ReadyCondition))
+ g.Expect(resource.Status.Conditions[0].Status).To(Equal(metav1.ConditionFalse))
+ g.Expect(resource.Status.Conditions[1].Type).To(Equal(v1alpha1.ConfiguredCondition))
+ g.Expect(resource.Status.Conditions[1].Status).To(Equal(metav1.ConditionFalse))
+ g.Expect(resource.Status.Conditions[1].Reason).To(Equal(v1alpha1.InvalidInterfaceTypeReason))
+ g.Expect(resource.Status.Conditions[2].Type).To(Equal(v1alpha1.OperationalCondition))
+ g.Expect(resource.Status.Conditions[3].Type).To(Equal(v1alpha1.PausedCondition))
+ g.Expect(resource.Status.Conditions[3].Status).To(Equal(metav1.ConditionFalse))
+ }).Should(Succeed())
+ })
+
+ It("Should handle EthernetSegment referencing Interface without switchport", func() {
+ By("Creating an Aggregate Interface without switchport config")
+ intf := &v1alpha1.Interface{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.InterfaceSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: name},
+ Name: "port-channel10",
+ Type: v1alpha1.InterfaceTypeAggregate,
+ AdminState: v1alpha1.AdminStateUp,
+ Aggregation: &v1alpha1.Aggregation{
+ MemberInterfaceRefs: []v1alpha1.LocalObjectReference{{Name: "eth1"}},
+ ControlProtocol: v1alpha1.ControlProtocol{Mode: v1alpha1.LACPModeActive},
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, intf)).To(Succeed())
+
+ By("Creating an EthernetSegment referencing the Interface")
+ es := &v1alpha1.EthernetSegment{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.EthernetSegmentSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: name},
+ InterfaceRef: v1alpha1.LocalObjectReference{Name: name},
+ ESIType: v1alpha1.ESITypeArbitrary,
+ ESI: esi,
+ RedundancyMode: v1alpha1.RedundancyModeAllActive,
+ },
+ }
+ Expect(k8sClient.Create(ctx, es)).To(Succeed())
+
+ By("Verifying the controller sets InterfaceNotSwitchport status")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.EthernetSegment{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(resource.Status.Conditions).To(HaveLen(4))
+ g.Expect(resource.Status.Conditions[0].Type).To(Equal(v1alpha1.ReadyCondition))
+ g.Expect(resource.Status.Conditions[0].Status).To(Equal(metav1.ConditionFalse))
+ g.Expect(resource.Status.Conditions[1].Type).To(Equal(v1alpha1.ConfiguredCondition))
+ g.Expect(resource.Status.Conditions[1].Status).To(Equal(metav1.ConditionFalse))
+ g.Expect(resource.Status.Conditions[1].Reason).To(Equal(v1alpha1.InterfaceNotSwitchportReason))
+ g.Expect(resource.Status.Conditions[2].Type).To(Equal(v1alpha1.OperationalCondition))
+ g.Expect(resource.Status.Conditions[3].Type).To(Equal(v1alpha1.PausedCondition))
+ g.Expect(resource.Status.Conditions[3].Status).To(Equal(metav1.ConditionFalse))
+ }).Should(Succeed())
+ })
+ It("Should auto-derive ESI when ESIType is MAC and ESI is omitted", func() {
+ By("Creating an Aggregate Interface with switchport config")
+ intf := &v1alpha1.Interface{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.InterfaceSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: name},
+ Name: "port-channel20",
+ Type: v1alpha1.InterfaceTypeAggregate,
+ AdminState: v1alpha1.AdminStateUp,
+ Switchport: &v1alpha1.Switchport{
+ Mode: v1alpha1.SwitchportModeTrunk,
+ },
+ Aggregation: &v1alpha1.Aggregation{
+ MemberInterfaceRefs: []v1alpha1.LocalObjectReference{{Name: "eth1"}},
+ ControlProtocol: v1alpha1.ControlProtocol{Mode: v1alpha1.LACPModeActive},
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, intf)).To(Succeed())
+
+ By("Creating an EthernetSegment with ESIType MAC and no explicit ESI")
+ es := &v1alpha1.EthernetSegment{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: name,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.EthernetSegmentSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: name},
+ InterfaceRef: v1alpha1.LocalObjectReference{Name: name},
+ ESIType: v1alpha1.ESITypeMAC,
+ RedundancyMode: v1alpha1.RedundancyModeAllActive,
+ },
+ }
+ Expect(k8sClient.Create(ctx, es)).To(Succeed())
+
+ By("Verifying the status ESI is auto-populated from the provider")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.EthernetSegment{}
+ g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed())
+ g.Expect(resource.Status.ESI).To(Equal("03:aa:bb:cc:dd:ee:ff:00:00:01"))
+ g.Expect(resource.Status.ESIType).To(Equal(v1alpha1.ESITypeMAC))
+ }).Should(Succeed())
+ })
+ })
+})
diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go
index d7cf389fd..ce3431c5a 100644
--- a/internal/controller/core/suite_test.go
+++ b/internal/controller/core/suite_test.go
@@ -339,6 +339,15 @@ var _ = BeforeSuite(func() {
}).SetupWithManager(ctx, k8sManager)
Expect(err).NotTo(HaveOccurred())
+ err = (&EthernetSegmentReconciler{
+ Client: k8sManager.GetClient(),
+ Scheme: k8sManager.GetScheme(),
+ Recorder: recorder,
+ Provider: prov,
+ Locker: testLocker,
+ }).SetupWithManager(ctx, k8sManager)
+ Expect(err).NotTo(HaveOccurred())
+
go func() {
defer GinkgoRecover()
err = k8sManager.Start(ctx)
@@ -409,6 +418,7 @@ var (
_ provider.NVEProvider = (*Provider)(nil)
_ provider.LLDPProvider = (*Provider)(nil)
_ provider.DHCPRelayProvider = (*Provider)(nil)
+ _ provider.EthernetSegmentProvider = (*Provider)(nil)
)
// Provider is a simple in-memory provider for testing purposes only.
@@ -418,52 +428,54 @@ type Provider struct {
ConnectError error // if non-nil, Connect returns this error
LastRebootTime time.Time
- Ports sets.Set[string]
- User sets.Set[string]
- PreLoginBanner *string
- PostLoginBanner *string
- DNS *v1alpha1.DNS
- NTP *v1alpha1.NTP
- ACLs sets.Set[string]
- Certs sets.Set[string]
- SNMP *v1alpha1.SNMP
- Syslog *v1alpha1.Syslog
- Access *v1alpha1.ManagementAccess
- ISIS sets.Set[string]
- VRF sets.Set[string]
- PIM *v1alpha1.PIM
- BGP *v1alpha1.BGP
- BGPVRF *v1alpha1.VRF
- BGPPeers sets.Set[string]
- OSPF sets.Set[string]
- VLANs sets.Set[int16]
- EVIs sets.Set[int32]
- PrefixSets sets.Set[string]
- RoutingPolicies sets.Set[string]
- NVE *v1alpha1.NetworkVirtualizationEdge
- LLDP *v1alpha1.LLDP
- LLDPOperStatus bool
- LLDPNeighbors map[string]*provider.LLDPAdjacency
- DHCPRelay *v1alpha1.DHCPRelay
+ Ports sets.Set[string]
+ User sets.Set[string]
+ PreLoginBanner *string
+ PostLoginBanner *string
+ DNS *v1alpha1.DNS
+ NTP *v1alpha1.NTP
+ ACLs sets.Set[string]
+ Certs sets.Set[string]
+ SNMP *v1alpha1.SNMP
+ Syslog *v1alpha1.Syslog
+ Access *v1alpha1.ManagementAccess
+ ISIS sets.Set[string]
+ VRF sets.Set[string]
+ PIM *v1alpha1.PIM
+ BGP *v1alpha1.BGP
+ BGPVRF *v1alpha1.VRF
+ BGPPeers sets.Set[string]
+ OSPF sets.Set[string]
+ VLANs sets.Set[int16]
+ EVIs sets.Set[int32]
+ PrefixSets sets.Set[string]
+ RoutingPolicies sets.Set[string]
+ NVE *v1alpha1.NetworkVirtualizationEdge
+ LLDP *v1alpha1.LLDP
+ LLDPOperStatus bool
+ LLDPNeighbors map[string]*provider.LLDPAdjacency
+ DHCPRelay *v1alpha1.DHCPRelay
+ EthernetSegments map[string]string
}
func NewProvider() *Provider {
return &Provider{
- LastRebootTime: lastRebootTime,
- Ports: sets.New[string](),
- User: sets.New[string](),
- ACLs: sets.New[string](),
- Certs: sets.New[string](),
- ISIS: sets.New[string](),
- VRF: sets.New[string](),
- BGPPeers: sets.New[string](),
- OSPF: sets.New[string](),
- VLANs: sets.New[int16](),
- EVIs: sets.New[int32](),
- PrefixSets: sets.New[string](),
- RoutingPolicies: sets.New[string](),
- LLDPOperStatus: true,
- LLDPNeighbors: make(map[string]*provider.LLDPAdjacency),
+ LastRebootTime: lastRebootTime,
+ Ports: sets.New[string](),
+ User: sets.New[string](),
+ ACLs: sets.New[string](),
+ Certs: sets.New[string](),
+ ISIS: sets.New[string](),
+ VRF: sets.New[string](),
+ BGPPeers: sets.New[string](),
+ OSPF: sets.New[string](),
+ VLANs: sets.New[int16](),
+ EVIs: sets.New[int32](),
+ PrefixSets: sets.New[string](),
+ RoutingPolicies: sets.New[string](),
+ LLDPOperStatus: true,
+ LLDPNeighbors: make(map[string]*provider.LLDPAdjacency),
+ EthernetSegments: make(map[string]string),
}
}
@@ -953,6 +965,39 @@ func (p *Provider) GetDHCPRelayStatus(_ context.Context, req *provider.DHCPRelay
return status, nil
}
+func (p *Provider) EnsureEthernetSegment(_ context.Context, req *provider.EnsureEthernetSegmentRequest) error {
+ p.Lock()
+ defer p.Unlock()
+ esi := req.EthernetSegment.Spec.ESI
+ if esi == "" {
+ // Simulate auto-generated ESI (Type 3 MAC-based)
+ esi = "03:aa:bb:cc:dd:ee:ff:00:00:01"
+ }
+ p.EthernetSegments[req.EthernetSegment.Name] = esi
+ return nil
+}
+
+func (p *Provider) DeleteEthernetSegment(_ context.Context, req *provider.DeleteEthernetSegmentRequest) error {
+ p.Lock()
+ defer p.Unlock()
+ delete(p.EthernetSegments, req.EthernetSegment.Name)
+ return nil
+}
+
+func (p *Provider) GetEthernetSegmentStatus(_ context.Context, req *provider.EthernetSegmentStatusRequest) (provider.EthernetSegmentStatus, error) {
+ p.Lock()
+ defer p.Unlock()
+ esi := p.EthernetSegments[req.EthernetSegment.Name]
+ return provider.EthernetSegmentStatus{ESI: esi, OperStatus: esi != ""}, nil
+}
+
+func (p *Provider) GetEthernetSegment(name string) (string, bool) {
+ p.Lock()
+ defer p.Unlock()
+ esi, ok := p.EthernetSegments[name]
+ return esi, ok
+}
+
// SetLLDPNeighbor is a test helper to configure LLDP neighbor information for an interface.
func (p *Provider) SetLLDPNeighbor(interfaceName, sysName, chassisID, portID string, ttl uint32) {
p.Lock()
diff --git a/internal/provider/cisco/nxos/esi.go b/internal/provider/cisco/nxos/esi.go
new file mode 100644
index 000000000..73f8e5d28
--- /dev/null
+++ b/internal/provider/cisco/nxos/esi.go
@@ -0,0 +1,91 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package nxos
+
+import (
+ "encoding/json"
+
+ "github.com/ironcore-dev/network-operator/internal/transport/gnmiext"
+)
+
+var (
+ _ gnmiext.DataElement = (*EthernetSegmentItems)(nil)
+ _ gnmiext.DataElement = (*MultihomingItems)(nil)
+ _ gnmiext.DataElement = (*EvpnMulticastItems)(nil)
+)
+
+// EthernetSegmentItems represents the per-interface Ethernet Segment configuration
+// nested under a port-channel aggregate interface.
+type EthernetSegmentItems struct {
+ ID string `json:"-"`
+ Type EthernetSegmentType `json:"esType"`
+ ESI Option[string] `json:"esi"`
+ SysMac Option[string] `json:"sysMac"`
+ SysMacInherit bool `json:"sysMacInherit"`
+ LocalIdentifier uint32 `json:"localIdentifier"`
+ LocalIdentifierInherit bool `json:"localIdentifierInherit"`
+}
+
+func (e *EthernetSegmentItems) XPath() string {
+ return "System/intf-items/aggr-items/AggrIf-list[id=" + e.ID + "]/ethernetsegment-items"
+}
+
+type EthernetSegmentType string
+
+const EthernetSegmentTypeNative EthernetSegmentType = "native"
+
+// MultihomingItems represents the global EVPN multihoming configuration.
+type MultihomingItems struct {
+ EadEviRoute bool `json:"eadEviRoute"`
+ State AdminSt `json:"state"`
+ DfElectionMode DfElectMode `json:"dfElectionMode,omitempty"`
+ DfElectionTime string `json:"dfElectionTime,omitempty"`
+}
+
+func (*MultihomingItems) XPath() string {
+ return "System/eps-items/multihoming-items"
+}
+
+type DfElectMode string
+
+const DfElectModeModulo DfElectMode = "modulo"
+
+// EvpnMulticastItems represents the global EVPN multicast configuration.
+type EvpnMulticastItems struct {
+ State AdminSt `json:"state"`
+}
+
+func (*EvpnMulticastItems) XPath() string {
+ return "System/eps-items/evpnmulticast-items"
+}
+
+// EthernetSegmentResponse is the NX-API JSON response for
+// "show nve ethernet-segment summary".
+type EthernetSegmentResponse struct {
+ Table struct {
+ Row ethernetSegmentRows `json:"ROW_es"`
+ } `json:"TABLE_es"`
+}
+
+type ethernetSegmentRows []EthernetSegmentRow
+
+func (r *ethernetSegmentRows) UnmarshalJSON(data []byte) error {
+ if len(data) > 0 && data[0] == '{' {
+ var single EthernetSegmentRow
+ if err := json.Unmarshal(data, &single); err != nil {
+ return err
+ }
+ *r = []EthernetSegmentRow{single}
+ return nil
+ }
+ return json.Unmarshal(data, (*[]EthernetSegmentRow)(r))
+}
+
+// EthernetSegmentRow is a single row from the NX-API
+// "show nve ethernet-segment summary" response.
+type EthernetSegmentRow struct {
+ ESI string `json:"esi"`
+ ESState string `json:"es-state"`
+ Interface string `json:"if-name"`
+}
diff --git a/internal/provider/cisco/nxos/esi_test.go b/internal/provider/cisco/nxos/esi_test.go
new file mode 100644
index 000000000..8e0ec9382
--- /dev/null
+++ b/internal/provider/cisco/nxos/esi_test.go
@@ -0,0 +1,30 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package nxos
+
+func init() {
+ Register("esi_interface", &EthernetSegmentItems{
+ ID: "po10",
+ Type: EthernetSegmentTypeNative,
+ ESI: NewOption("0011.2233.4455.6677.8801"),
+ })
+
+ Register("esi_interface_sysmac", &EthernetSegmentItems{
+ ID: "po10",
+ Type: EthernetSegmentTypeNative,
+ SysMac: NewOption("00:11:22:33:44:55"),
+ LocalIdentifier: 1,
+ })
+
+ Register("esi_multihoming", &MultihomingItems{
+ EadEviRoute: true,
+ State: AdminStEnabled,
+ DfElectionMode: DfElectModeModulo,
+ DfElectionTime: "2.2",
+ })
+
+ Register("esi_evpn_multicast", &EvpnMulticastItems{
+ State: AdminStEnabled,
+ })
+}
diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go
index 2f92f4c35..c4e5471a3 100644
--- a/internal/provider/cisco/nxos/provider.go
+++ b/internal/provider/cisco/nxos/provider.go
@@ -9,6 +9,7 @@ import (
"context"
"crypto/rand"
"crypto/rsa"
+ "encoding/json"
"errors"
"fmt"
"maps"
@@ -62,6 +63,7 @@ var (
_ provider.NVEProvider = (*Provider)(nil)
_ provider.LLDPProvider = (*Provider)(nil)
_ provider.DHCPRelayProvider = (*Provider)(nil)
+ _ provider.EthernetSegmentProvider = (*Provider)(nil)
)
type Provider struct {
@@ -91,7 +93,7 @@ func (p *Provider) Connect(ctx context.Context, conn *deviceutil.Connection) (er
}
// NXAPI only uses the address for URI construction.
c := *conn
- c.Address = netip.MustParseAddrPort(conn.Address).String()
+ c.Address = netip.MustParseAddrPort(conn.Address).Addr().String()
p.nxapi, err = nxapi.NewClient(&c, timeout)
if err != nil {
return fmt.Errorf("failed to create nxapi client: %w", err)
@@ -3328,6 +3330,151 @@ func (p *Provider) GetDHCPRelayStatus(ctx context.Context, req *provider.DHCPRel
return s, nil
}
+func (p *Provider) EnsureEthernetSegment(ctx context.Context, req *provider.EnsureEthernetSegmentRequest) error {
+ if req.EthernetSegment.Spec.RedundancyMode == v1alpha1.RedundancyModeSingleActive {
+ return apistatus.NewInvalidArgumentError(apistatus.FieldViolation{
+ Field: "spec.redundancyMode",
+ Description: "NX-OS only supports AllActive redundancy mode for Ethernet Segments",
+ })
+ }
+
+ switch req.EthernetSegment.Spec.ESIType {
+ case v1alpha1.ESITypeArbitrary, v1alpha1.ESITypeMAC:
+ // supported
+ default:
+ return apistatus.NewInvalidArgumentError(apistatus.FieldViolation{
+ Field: "spec.esiType",
+ Description: fmt.Sprintf("NX-OS only supports Arbitrary (Type 0) and MAC (Type 3) ESI types, got %q", req.EthernetSegment.Spec.ESIType),
+ })
+ }
+
+ if df := req.EthernetSegment.Spec.DesignatedForwarder; df != nil {
+ switch df.ElectionMode {
+ case v1alpha1.DFElectionModeDefault:
+ // supported
+ default:
+ return apistatus.NewInvalidArgumentError(apistatus.FieldViolation{
+ Field: "spec.designatedForwarder.electionMode",
+ Description: fmt.Sprintf("NX-OS only supports Default (modulo) DF election mode, got %q", df.ElectionMode),
+ })
+ }
+ }
+
+ vpc := &Feature{Name: "vpc"}
+ if err := p.client.GetConfig(ctx, vpc); err == nil && vpc.AdminSt == AdminStEnabled {
+ return apistatus.NewFailedPreconditionError("ethernet segment: EVPN multihoming cannot be used together with vPC on the same device")
+ }
+
+ name, err := ShortName(req.Interface.Spec.Name)
+ if err != nil {
+ return err
+ }
+
+ f := new(Feature)
+ f.Name = "evpn"
+ f.AdminSt = AdminStEnabled
+
+ mh := new(MultihomingItems)
+ mh.State = AdminStEnabled
+ mh.EadEviRoute = true
+ mh.DfElectionMode = DfElectModeModulo
+ mh.DfElectionTime = "3.0"
+ if df := req.EthernetSegment.Spec.DesignatedForwarder; df != nil && df.ElectionWaitTime != nil {
+ mh.DfElectionTime = fmt.Sprintf("%g", df.ElectionWaitTime.Seconds())
+ }
+
+ mm := new(EvpnMulticastItems)
+ mm.State = AdminStEnabled
+
+ es := new(EthernetSegmentItems)
+ es.ID = name
+ es.Type = EthernetSegmentTypeNative
+
+ hex := strings.ReplaceAll(req.EthernetSegment.Spec.ESI, ":", "")
+ switch req.EthernetSegment.Spec.ESIType {
+ case v1alpha1.ESITypeArbitrary:
+ es.ESI = NewOption(hex[0:4] + "." + hex[4:8] + "." + hex[8:12] + "." + hex[12:16] + "." + hex[16:20])
+ case v1alpha1.ESITypeMAC:
+ if req.EthernetSegment.Spec.ESI != "" {
+ mac := hex[2:14]
+ es.SysMac = NewOption(mac[0:2] + ":" + mac[2:4] + ":" + mac[4:6] + ":" + mac[6:8] + ":" + mac[8:10] + ":" + mac[10:12])
+ d, err := strconv.ParseUint(hex[14:20], 16, 24)
+ if err != nil {
+ return fmt.Errorf("failed to parse ESI local discriminator: %w", err)
+ }
+ es.LocalIdentifier = uint32(d)
+ } else {
+ es.SysMacInherit = true
+ es.LocalIdentifierInherit = true
+ }
+ case v1alpha1.ESITypeLACP, v1alpha1.ESITypeMST, v1alpha1.ESITypeRouterID, v1alpha1.ESITypeAS:
+ return fmt.Errorf("ESI type %s is not supported by this provider", req.EthernetSegment.Spec.ESIType)
+ }
+
+ return p.Patch(ctx, f, mh, mm, es)
+}
+
+func (p *Provider) DeleteEthernetSegment(ctx context.Context, req *provider.DeleteEthernetSegmentRequest) error {
+ name, err := ShortName(req.Interface.Spec.Name)
+ if err != nil {
+ return err
+ }
+
+ es := &EthernetSegmentItems{ID: name}
+ return p.client.Delete(ctx, es)
+}
+
+func (p *Provider) GetEthernetSegmentStatus(ctx context.Context, req *provider.EthernetSegmentStatusRequest) (provider.EthernetSegmentStatus, error) {
+ name, err := ShortName(req.Interface.Spec.Name)
+ if err != nil {
+ return provider.EthernetSegmentStatus{}, err
+ }
+
+ res, err := p.nxapi.Do(ctx, nxapi.NewRequest("show nve ethernet-segment summary"))
+ if err != nil {
+ return provider.EthernetSegmentStatus{}, err
+ }
+ if len(res) == 0 {
+ return provider.EthernetSegmentStatus{}, nil
+ }
+
+ var resp EthernetSegmentResponse
+ if err := json.Unmarshal(res[0], &resp); err != nil {
+ return provider.EthernetSegmentStatus{}, err
+ }
+
+ var row *EthernetSegmentRow
+ for i := range resp.Table.Row {
+ short, err := ShortName(resp.Table.Row[i].Interface)
+ if err != nil {
+ continue
+ }
+ if short == name {
+ row = &resp.Table.Row[i]
+ break
+ }
+ }
+ if row == nil || row.ESI == "" {
+ return provider.EthernetSegmentStatus{}, nil
+ }
+
+ // Convert dotted-quad ESI (e.g. "0300.0034.5634.5600.0001") to colon-hex.
+ hex := strings.ReplaceAll(row.ESI, ".", "")
+ if len(hex) != 20 {
+ return provider.EthernetSegmentStatus{}, fmt.Errorf("invalid ESI format: %q", row.ESI)
+ }
+
+ parts := make([]string, 10)
+ for i := range 10 {
+ parts[i] = hex[i*2 : i*2+2]
+ }
+
+ return provider.EthernetSegmentStatus{
+ ESI: strings.Join(parts, ":"),
+ OperStatus: strings.EqualFold(row.ESState, string(OperStUp)),
+ }, nil
+}
+
func init() {
provider.Register("cisco-nxos-gnmi", NewProvider)
}
diff --git a/internal/provider/cisco/nxos/testdata/esi_evpn_multicast.json b/internal/provider/cisco/nxos/testdata/esi_evpn_multicast.json
new file mode 100644
index 000000000..f93510541
--- /dev/null
+++ b/internal/provider/cisco/nxos/testdata/esi_evpn_multicast.json
@@ -0,0 +1,7 @@
+{
+ "eps-items": {
+ "evpnmulticast-items": {
+ "state": "enabled"
+ }
+ }
+}
diff --git a/internal/provider/cisco/nxos/testdata/esi_evpn_multicast.json.txt b/internal/provider/cisco/nxos/testdata/esi_evpn_multicast.json.txt
new file mode 100644
index 000000000..c78a91051
--- /dev/null
+++ b/internal/provider/cisco/nxos/testdata/esi_evpn_multicast.json.txt
@@ -0,0 +1 @@
+advertise evpn multicast
diff --git a/internal/provider/cisco/nxos/testdata/esi_interface.json b/internal/provider/cisco/nxos/testdata/esi_interface.json
new file mode 100644
index 000000000..61d41291e
--- /dev/null
+++ b/internal/provider/cisco/nxos/testdata/esi_interface.json
@@ -0,0 +1,19 @@
+{
+ "intf-items": {
+ "aggr-items": {
+ "AggrIf-list": [
+ {
+ "id": "po10",
+ "ethernetsegment-items": {
+ "esType": "native",
+ "esi": "0011.2233.4455.6677.8801",
+ "sysMac": "DME_UNSET_PROPERTY_MARKER",
+ "sysMacInherit": false,
+ "localIdentifier": 0,
+ "localIdentifierInherit": false
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/internal/provider/cisco/nxos/testdata/esi_interface.json.txt b/internal/provider/cisco/nxos/testdata/esi_interface.json.txt
new file mode 100644
index 000000000..8d01f4928
--- /dev/null
+++ b/internal/provider/cisco/nxos/testdata/esi_interface.json.txt
@@ -0,0 +1,3 @@
+interface port-channel10
+ ethernet-segment
+ esi 0011.2233.4455.6677.8801
diff --git a/internal/provider/cisco/nxos/testdata/esi_interface_sysmac.json b/internal/provider/cisco/nxos/testdata/esi_interface_sysmac.json
new file mode 100644
index 000000000..34561f3d3
--- /dev/null
+++ b/internal/provider/cisco/nxos/testdata/esi_interface_sysmac.json
@@ -0,0 +1,19 @@
+{
+ "intf-items": {
+ "aggr-items": {
+ "AggrIf-list": [
+ {
+ "id": "po10",
+ "ethernetsegment-items": {
+ "esType": "native",
+ "esi": "DME_UNSET_PROPERTY_MARKER",
+ "sysMac": "00:11:22:33:44:55",
+ "sysMacInherit": false,
+ "localIdentifier": 1,
+ "localIdentifierInherit": false
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/internal/provider/cisco/nxos/testdata/esi_interface_sysmac.json.txt b/internal/provider/cisco/nxos/testdata/esi_interface_sysmac.json.txt
new file mode 100644
index 000000000..cda06b2c9
--- /dev/null
+++ b/internal/provider/cisco/nxos/testdata/esi_interface_sysmac.json.txt
@@ -0,0 +1,3 @@
+interface port-channel10
+ ethernet-segment
+ esi system-mac 0011.2233.4455 1
diff --git a/internal/provider/cisco/nxos/testdata/esi_multihoming.json b/internal/provider/cisco/nxos/testdata/esi_multihoming.json
new file mode 100644
index 000000000..67c99d010
--- /dev/null
+++ b/internal/provider/cisco/nxos/testdata/esi_multihoming.json
@@ -0,0 +1,10 @@
+{
+ "eps-items": {
+ "multihoming-items": {
+ "eadEviRoute": true,
+ "state": "enabled",
+ "dfElectionMode": "modulo",
+ "dfElectionTime": "2.2"
+ }
+ }
+}
diff --git a/internal/provider/cisco/nxos/testdata/esi_multihoming.json.txt b/internal/provider/cisco/nxos/testdata/esi_multihoming.json.txt
new file mode 100644
index 000000000..2997d8ba0
--- /dev/null
+++ b/internal/provider/cisco/nxos/testdata/esi_multihoming.json.txt
@@ -0,0 +1,4 @@
+evpn multihoming
+ ead-evi route
+ df-election mode modulo
+ df-election time 2.2
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index a9d7f9883..6c9b45615 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -696,6 +696,43 @@ type DHCPRelayStatus struct {
ConfiguredInterfaces []string
}
+type EthernetSegmentProvider interface {
+ Provider
+
+ // EnsureEthernetSegment is responsible for EthernetSegment realization on the provider.
+ EnsureEthernetSegment(context.Context, *EnsureEthernetSegmentRequest) error
+ // DeleteEthernetSegment is responsible for EthernetSegment deletion on the provider.
+ DeleteEthernetSegment(context.Context, *DeleteEthernetSegmentRequest) error
+ // GetEthernetSegmentStatus reads back the realized ESI from the device.
+ GetEthernetSegmentStatus(context.Context, *EthernetSegmentStatusRequest) (EthernetSegmentStatus, error)
+}
+
+type EnsureEthernetSegmentRequest struct {
+ EthernetSegment *v1alpha1.EthernetSegment
+ Interface *v1alpha1.Interface
+ ProviderConfig *ProviderConfig
+}
+
+type DeleteEthernetSegmentRequest struct {
+ EthernetSegment *v1alpha1.EthernetSegment
+ Interface *v1alpha1.Interface
+ ProviderConfig *ProviderConfig
+}
+
+type EthernetSegmentStatusRequest struct {
+ EthernetSegment *v1alpha1.EthernetSegment
+ Interface *v1alpha1.Interface
+ ProviderConfig *ProviderConfig
+}
+
+type EthernetSegmentStatus struct {
+ // ESI is the realized 10-byte ESI in colon-separated hex, read from device operational state.
+ // For auto-derived ESI types this is the value generated by the device.
+ ESI string
+ // OperStatus indicates whether the Ethernet Segment is operationally up (true) or down (false).
+ OperStatus bool
+}
+
var mu sync.RWMutex
// ProviderFunc returns a new [Provider] instance.