From 5ce49434e657c63d3307f2184a29a9c594148d72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Wed, 27 May 2026 18:11:36 +0200 Subject: [PATCH] Add EthernetSegment resource and NX-OS provider implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce the EthernetSegment API type for EVPN multi-homing (RFC 7432 Section 5) with controller, CRD, and NX-OS provider. The controller validates that the referenced interface is an aggregate with switchport configuration and watches for switchport state transitions. The NX-OS provider enables EVPN multihoming globally, configures the ESI per port-channel via a subtree patch, and rejects SingleActive mode and vPC coexistence with typed terminal errors. Signed-off-by: Felix Kรคstner --- PROJECT | 8 + Tiltfile | 3 + api/core/v1alpha1/ethernetsegment_types.go | 139 +++++ api/core/v1alpha1/groupversion_info.go | 3 + api/core/v1alpha1/zz_generated.deepcopy.go | 103 ++++ ...egments.networking.metal.ironcore.dev.yaml | 242 +++++++++ .../rbac/ethernetsegment-admin-role.yaml | 31 ++ .../rbac/ethernetsegment-editor-role.yaml | 37 ++ .../rbac/ethernetsegment-viewer-role.yaml | 33 ++ .../templates/rbac/manager-role.yaml | 3 + cmd/main.go | 12 + ...g.metal.ironcore.dev_ethernetsegments.yaml | 238 +++++++++ config/crd/kustomization.yaml | 1 + config/rbac/ethernetsegment_admin_role.yaml | 27 + config/rbac/ethernetsegment_editor_role.yaml | 33 ++ config/rbac/ethernetsegment_viewer_role.yaml | 29 + config/rbac/kustomization.yaml | 3 + config/rbac/role.yaml | 3 + config/samples/kustomization.yaml | 1 + config/samples/v1alpha1_ethernetsegment.yaml | 14 + docs/api-reference/index.md | 81 +++ .../core/ethernetsegment_controller.go | 502 ++++++++++++++++++ .../core/ethernetsegment_controller_test.go | 185 +++++++ internal/provider/cisco/nxos/esi.go | 40 ++ internal/provider/cisco/nxos/esi_test.go | 18 + internal/provider/cisco/nxos/provider.go | 57 ++ .../cisco/nxos/testdata/esi_interface.json | 18 + .../nxos/testdata/esi_interface.json.txt | 5 + .../cisco/nxos/testdata/esi_multihoming.json | 8 + .../nxos/testdata/esi_multihoming.json.txt | 2 + internal/provider/provider.go | 20 + 31 files changed, 1899 insertions(+) create mode 100644 api/core/v1alpha1/ethernetsegment_types.go create mode 100644 charts/network-operator/templates/crd/ethernetsegments.networking.metal.ironcore.dev.yaml create mode 100644 charts/network-operator/templates/rbac/ethernetsegment-admin-role.yaml create mode 100644 charts/network-operator/templates/rbac/ethernetsegment-editor-role.yaml create mode 100644 charts/network-operator/templates/rbac/ethernetsegment-viewer-role.yaml create mode 100644 config/crd/bases/networking.metal.ironcore.dev_ethernetsegments.yaml create mode 100644 config/rbac/ethernetsegment_admin_role.yaml create mode 100644 config/rbac/ethernetsegment_editor_role.yaml create mode 100644 config/rbac/ethernetsegment_viewer_role.yaml create mode 100644 config/samples/v1alpha1_ethernetsegment.yaml create mode 100644 internal/controller/core/ethernetsegment_controller.go create mode 100644 internal/controller/core/ethernetsegment_controller_test.go create mode 100644 internal/provider/cisco/nxos/esi.go create mode 100644 internal/provider/cisco/nxos/esi_test.go create mode 100644 internal/provider/cisco/nxos/testdata/esi_interface.json create mode 100644 internal/provider/cisco/nxos/testdata/esi_interface.json.txt create mode 100644 internal/provider/cisco/nxos/testdata/esi_multihoming.json create mode 100644 internal/provider/cisco/nxos/testdata/esi_multihoming.json.txt diff --git a/PROJECT b/PROJECT index 24face925..d773e94bc 100644 --- a/PROJECT +++ b/PROJECT @@ -289,4 +289,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..2859381ef 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=['lag-to-server1'], 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..7a401b7bc --- /dev/null +++ b/api/core/v1alpha1/ethernetsegment_types.go @@ -0,0 +1,139 @@ +// 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/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 +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"` + + // 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). + // Immutable. + // +required + // +kubebuilder:validation:Pattern=`^([0-9a-fA-F]{2}:){9}[0-9a-fA-F]{2}$` + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="ESI is immutable" + ESI string `json:"esi"` + + // 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"` +} + +// 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" +) + +// 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"` +} + +// +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=`.spec.esi` +// +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="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(&EthernetSegment{}, &EthernetSegmentList{}) +} diff --git a/api/core/v1alpha1/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go index 3fdaf4800..eeb807e94 100644 --- a/api/core/v1alpha1/groupversion_info.go +++ b/api/core/v1alpha1/groupversion_info.go @@ -198,6 +198,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 3571a97c0..29540036d 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -1561,6 +1561,109 @@ 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 +} + +// 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..65320d369 --- /dev/null +++ b/charts/network-operator/templates/crd/ethernetsegments.networking.metal.ironcore.dev.yaml @@ -0,0 +1,242 @@ +{{- if .Values.crd.enable }} +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: .spec.esi + name: ESI + 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=="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: + 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). + Immutable. + pattern: ^([0-9a-fA-F]{2}:){9}[0-9a-fA-F]{2}$ + type: string + x-kubernetes-validations: + - message: ESI 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 + - esi + - interfaceRef + type: object + 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 + 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..468cd5be6 --- /dev/null +++ b/charts/network-operator/templates/rbac/ethernetsegment-admin-role.yaml @@ -0,0 +1,31 @@ +{{- if .Values.rbac.helpers.enable }} +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..cab541f6b --- /dev/null +++ b/charts/network-operator/templates/rbac/ethernetsegment-editor-role.yaml @@ -0,0 +1,37 @@ +{{- if .Values.rbac.helpers.enable }} +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..b4f95e7e2 --- /dev/null +++ b/charts/network-operator/templates/rbac/ethernetsegment-viewer-role.yaml @@ -0,0 +1,33 @@ +{{- if .Values.rbac.helpers.enable }} +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..1ad417327 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -625,6 +625,18 @@ 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, + }).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..40d21c2cf --- /dev/null +++ b/config/crd/bases/networking.metal.ironcore.dev_ethernetsegments.yaml @@ -0,0 +1,238 @@ +--- +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: .spec.esi + name: ESI + 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=="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: + 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). + Immutable. + pattern: ^([0-9a-fA-F]{2}:){9}[0-9a-fA-F]{2}$ + type: string + x-kubernetes-validations: + - message: ESI 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 + - esi + - interfaceRef + type: object + 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 + 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..f8d1974bf --- /dev/null +++ b/config/samples/v1alpha1_ethernetsegment.yaml @@ -0,0 +1,14 @@ +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: leaf-1 + interfaceRef: + name: lag-to-server1 + 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 b333d37e9..bfcf8f2fc 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) @@ -1240,6 +1241,66 @@ _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: \{\}
| +| `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).
Immutable. | | Pattern: `^([0-9a-fA-F]\{2\}:)\{9\}[0-9a-fA-F]\{2\}$`
Required: \{\}
| +| `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: \{\}
| + + +#### 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: \{\}
| + + #### FECMode _Underlying type:_ _string_ @@ -1669,6 +1730,7 @@ _Appears in:_ - [DNSSpec](#dnsspec) - [DevicePort](#deviceport) - [EVPNInstanceSpec](#evpninstancespec) +- [EthernetSegmentSpec](#ethernetsegmentspec) - [ISISSpec](#isisspec) - [InterconnectInterfaceReference](#interconnectinterfacereference) - [InterfaceIPv4Unnumbered](#interfaceipv4unnumbered) @@ -2517,6 +2579,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 @@ -3097,6 +3177,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..0157d7c6b --- /dev/null +++ b/internal/controller/core/ethernetsegment_controller.go @@ -0,0 +1,502 @@ +// 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 +} + +// +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) { + 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{}, 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 + } + } + + 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) + // As this resource is configuration only, we use the Configured condition as top-level Ready condition. + cond.Type = v1alpha1.ReadyCondition + conditions.Set(s.EthernetSegment, cond) + + return err +} + +// 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.ReadyCondition, + 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.ReadyCondition, + 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.ReadyCondition, + 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.ReadyCondition, + 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) { + 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, + 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..3bfdfacbd --- /dev/null +++ b/internal/controller/core/ethernetsegment_controller_test.go @@ -0,0 +1,185 @@ +// 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("Banner Controller", func() { + Context("When reconciling a resource", func() { + 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-banner-", + 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() { + var resource client.Object = &v1alpha1.Banner{} + err := k8sClient.Get(ctx, key, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance Banner") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + + resource = &v1alpha1.Device{} + err = k8sClient.Get(ctx, key, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance Device") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + + By("Ensuring the resource is deleted from the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.PreLoginBanner).To(BeNil(), "Provider PreLogin Banner should be nil") + g.Expect(testProvider.PostLoginBanner).To(BeNil(), "Provider PostLogin Banner should be nil") + }).Should(Succeed()) + }) + + It("Should successfully reconcile a PreLogin Banner", func() { + By("Creating a PreLogin Banner") + banner := &v1alpha1.Banner{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.BannerSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Type: v1alpha1.BannerTypePreLogin, + Message: v1alpha1.TemplateSource{ + Inline: new("Test Banner"), + }, + }, + } + Expect(k8sClient.Create(ctx, banner)).To(Succeed()) + + By("Adding a finalizer to the resource") + Eventually(func(g Gomega) { + resource := &v1alpha1.Banner{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(controllerutil.ContainsFinalizer(resource, v1alpha1.FinalizerName)).To(BeTrue()) + }).Should(Succeed()) + + By("Adding the device label to the resource") + Eventually(func(g Gomega) { + resource := &v1alpha1.Banner{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Labels).To(HaveKeyWithValue(v1alpha1.DeviceLabel, name)) + }).Should(Succeed()) + + By("Adding the device as a owner reference") + Eventually(func(g Gomega) { + resource := &v1alpha1.Banner{} + 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("Updating the resource status") + Eventually(func(g Gomega) { + resource := &v1alpha1.Banner{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Status.Conditions).To(HaveLen(2)) + 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.PausedCondition)) + g.Expect(resource.Status.Conditions[1].Status).To(Equal(metav1.ConditionFalse)) + }).Should(Succeed()) + + By("Ensuring the resource is created in the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.PreLoginBanner).ToNot(BeNil(), "Provider Banner should not be nil") + g.Expect(testProvider.PostLoginBanner).To(BeNil(), "Provider PostLogin Banner should be nil") + if testProvider.PreLoginBanner != nil { + g.Expect(*testProvider.PreLoginBanner).To(Equal("Test Banner")) + } + }).Should(Succeed()) + }) + + It("Should successfully reconcile a PostLogin Banner", func() { + By("Creating a PostLogin Banner") + banner := &v1alpha1.Banner{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.BannerSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Type: v1alpha1.BannerTypePostLogin, + Message: v1alpha1.TemplateSource{ + Inline: new("Test Banner"), + }, + }, + } + Expect(k8sClient.Create(ctx, banner)).To(Succeed()) + + By("Adding a finalizer to the resource") + Eventually(func(g Gomega) { + resource := &v1alpha1.Banner{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(controllerutil.ContainsFinalizer(resource, v1alpha1.FinalizerName)).To(BeTrue()) + }).Should(Succeed()) + + By("Adding the device label to the resource") + Eventually(func(g Gomega) { + resource := &v1alpha1.Banner{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Labels).To(HaveKeyWithValue(v1alpha1.DeviceLabel, name)) + }).Should(Succeed()) + + By("Adding the device as a owner reference") + Eventually(func(g Gomega) { + resource := &v1alpha1.Banner{} + 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("Updating the resource status") + Eventually(func(g Gomega) { + resource := &v1alpha1.Banner{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Status.Conditions).To(HaveLen(2)) + 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.PausedCondition)) + g.Expect(resource.Status.Conditions[1].Status).To(Equal(metav1.ConditionFalse)) + }).Should(Succeed()) + + By("Ensuring the resource is created in the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.PreLoginBanner).To(BeNil(), "Provider PreLogin Banner should be nil") + g.Expect(testProvider.PostLoginBanner).ToNot(BeNil(), "Provider PostLogin Banner should not be nil") + if testProvider.PostLoginBanner != nil { + g.Expect(*testProvider.PostLoginBanner).To(Equal("Test Banner")) + } + }).Should(Succeed()) + }) + }) +}) diff --git a/internal/provider/cisco/nxos/esi.go b/internal/provider/cisco/nxos/esi.go new file mode 100644 index 000000000..13987a233 --- /dev/null +++ b/internal/provider/cisco/nxos/esi.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package nxos + +import "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" + +var ( + _ gnmiext.DataElement = (*EthernetSegmentItems)(nil) + _ gnmiext.DataElement = (*MultihomingItems)(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 string `json:"esi"` + Tag string `json:"tag"` +} + +type EthernetSegmentType string + +const ( + EthernetSegmentTypeNative EthernetSegmentType = "native" +) + +func (e *EthernetSegmentItems) XPath() string { + return "System/intf-items/aggr-items/AggrIf-list[id=" + e.ID + "]/ethernetsegment-items" +} + +// MultihomingItems represents the global EVPN multihoming configuration. +type MultihomingItems struct { + EadEviRoute bool `json:"eadEviRoute"` + State AdminSt `json:"state"` +} + +func (*MultihomingItems) XPath() string { + return "System/eps-items/multihoming-items" +} diff --git a/internal/provider/cisco/nxos/esi_test.go b/internal/provider/cisco/nxos/esi_test.go new file mode 100644 index 000000000..0d0fd8fa8 --- /dev/null +++ b/internal/provider/cisco/nxos/esi_test.go @@ -0,0 +1,18 @@ +// 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: "0011.2233.4455.6677.8801", + Tag: "0", + }) + + Register("esi_multihoming", &MultihomingItems{ + EadEviRoute: true, + State: AdminStEnabled, + }) +} diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index b85d102d9..81de34863 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -62,6 +62,7 @@ var ( _ provider.NVEProvider = (*Provider)(nil) _ provider.LLDPProvider = (*Provider)(nil) _ provider.DHCPRelayProvider = (*Provider)(nil) + _ provider.EthernetSegmentProvider = (*Provider)(nil) ) type Provider struct { @@ -3271,6 +3272,62 @@ 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", + }) + } + + 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 + } + + parts := strings.Split(req.EthernetSegment.Spec.ESI, ":") + if len(parts) != 10 { + return apistatus.NewInvalidArgumentError(apistatus.FieldViolation{ + Field: "spec.esi", + Description: fmt.Sprintf("invalid ESI %q: expected 10 colon-separated octets", req.EthernetSegment.Spec.ESI), + }) + } + + esi := strings.Join(parts, "") + esi = esi[0:4] + "." + esi[4:8] + "." + esi[8:12] + "." + esi[12:16] + "." + esi[16:20] + + f := new(Feature) + f.Name = "evpn" + f.AdminSt = AdminStEnabled + + mh := new(MultihomingItems) + mh.State = AdminStEnabled + mh.EadEviRoute = true + + es := new(EthernetSegmentItems) + es.ID = name + es.Type = EthernetSegmentTypeNative + es.ESI = esi + es.Tag = "0" + + return p.Patch(ctx, f, mh, es) +} + +func (p *Provider) DeleteEthernetSegment(ctx context.Context, req *provider.DeleteEthernetSegmentRequest) error { + name, err := ShortName(req.EthernetSegment.Spec.InterfaceRef.Name) + if err != nil { + return err + } + + es := &EthernetSegmentItems{ID: name} + return p.client.Delete(ctx, es) +} + func init() { provider.Register("cisco-nxos-gnmi", NewProvider) } 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..970fa6382 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/esi_interface.json @@ -0,0 +1,18 @@ +{ + "intf-items": { + "aggr-items": { + "AggrIf-list": [ + { + "id": "po10", + "layer": "Layer2", + "mode": "trunk", + "ethernetsegment-items": { + "esType": "native", + "esi": "0011.2233.4455.6677.8801", + "tag": "0" + } + } + ] + } + } +} 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..4c07c6d6d --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/esi_interface.json.txt @@ -0,0 +1,5 @@ +interface port-channel10 + switchport + switchport mode trunk + ethernet-segment + esi 0011.2233.4455.6677.8801 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..2201e2aee --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/esi_multihoming.json @@ -0,0 +1,8 @@ +{ + "eps-items": { + "multihoming-items": { + "eadEviRoute": true, + "state": "enabled" + } + } +} 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..684775926 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/esi_multihoming.json.txt @@ -0,0 +1,2 @@ +evpn multihoming + ead-evi route diff --git a/internal/provider/provider.go b/internal/provider/provider.go index a9d7f9883..8b6a60054 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -696,6 +696,26 @@ 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 +} + +type EnsureEthernetSegmentRequest struct { + EthernetSegment *v1alpha1.EthernetSegment + Interface *v1alpha1.Interface + ProviderConfig *ProviderConfig +} + +type DeleteEthernetSegmentRequest struct { + EthernetSegment *v1alpha1.EthernetSegment + ProviderConfig *ProviderConfig +} + var mu sync.RWMutex // ProviderFunc returns a new [Provider] instance.