From 205e2e2d02392bc7149b5491ddc4a6960734ad2c Mon Sep 17 00:00:00 2001 From: Pujol Date: Wed, 5 Nov 2025 15:19:53 +0100 Subject: [PATCH 1/2] feat: Network Virtualization Endpoints (NVEs) * Adds support for NVEs. The core type `NVE` requires at least two loopback interfaces on the same device, and an optional field for Multicastgroup. * Includes a Cisco NXOS specfic settings in `cisco.nx.v1alpha1.NVEConfig` * The `NVEConfig` also modifies system and fabric forwarding settings. --- PROJECT | 23 + Tiltfile | 5 + api/cisco/nx/v1alpha1/groupversion_info.go | 5 + api/cisco/nx/v1alpha1/nveconfig_types.go | 84 +++ .../nx/v1alpha1/zz_generated.deepcopy.go | 93 +++ api/core/v1alpha1/groupversion_info.go | 3 + api/core/v1alpha1/nve_types.go | 188 +++++ api/core/v1alpha1/ref_types.go | 2 +- api/core/v1alpha1/zz_generated.deepcopy.go | 148 ++++ cmd/main.go | 55 +- .../networking.metal.ironcore.dev_nves.yaml | 305 +++++++++ ...working.metal.ironcore.dev_nveconfigs.yaml | 91 +++ config/crd/kustomization.yaml | 6 +- .../rbac/cisco/nx/nveconfig_admin_role.yaml | 21 + .../rbac/cisco/nx/nveconfig_editor_role.yaml | 27 + .../rbac/cisco/nx/nveconfig_viewer_role.yaml | 23 + config/rbac/kustomization.yaml | 9 + config/rbac/nve_admin_role.yaml | 27 + config/rbac/nve_editor_role.yaml | 33 + config/rbac/nve_viewer_role.yaml | 29 + config/rbac/role.yaml | 4 + .../samples/cisco/nx/v1alpha1_nveconfig.yaml | 14 + config/samples/kustomization.yaml | 2 + config/samples/v1alpha1_interface.yaml | 2 +- config/samples/v1alpha1_nve.yaml | 26 + config/webhook/manifests.yaml | 40 ++ .../cisco/nx/nve_controller_test.go | 236 +++++++ internal/controller/cisco/nx/suite_test.go | 49 +- .../controller/core/interface_controller.go | 6 +- internal/controller/core/nve_controller.go | 485 +++++++++++++ .../controller/core/nve_controller_test.go | 641 ++++++++++++++++++ internal/controller/core/suite_test.go | 42 +- internal/provider/cisco/nxos/nve.go | 71 +- internal/provider/cisco/nxos/nve_test.go | 15 + internal/provider/cisco/nxos/provider.go | 233 ++++--- .../cisco/nxos/testdata/fabric_forward.json | 8 + .../nxos/testdata/fabric_forward.json.txt | 1 + .../cisco/nxos/testdata/infra_vlans.json | 21 + .../cisco/nxos/testdata/infra_vlans.json.txt | 1 + .../provider/cisco/nxos/testdata/nve.json | 4 +- internal/provider/provider.go | 33 + .../cisco/nx/v1alpha1/nveconfig_webhook.go | 119 ++++ .../nx/v1alpha1/nveconfig_webhook_test.go | 120 ++++ .../cisco/nx/v1alpha1/webhook_suite_test.go | 151 +++++ internal/webhook/core/v1alpha1/nve_webhook.go | 98 +++ .../webhook/core/v1alpha1/nve_webhook_test.go | 85 +++ .../core/v1alpha1/webhook_suite_test.go | 3 + 47 files changed, 3537 insertions(+), 150 deletions(-) create mode 100644 api/cisco/nx/v1alpha1/nveconfig_types.go create mode 100644 api/core/v1alpha1/nve_types.go create mode 100644 config/crd/bases/networking.metal.ironcore.dev_nves.yaml create mode 100644 config/crd/bases/nx.cisco.networking.metal.ironcore.dev_nveconfigs.yaml create mode 100644 config/rbac/cisco/nx/nveconfig_admin_role.yaml create mode 100644 config/rbac/cisco/nx/nveconfig_editor_role.yaml create mode 100644 config/rbac/cisco/nx/nveconfig_viewer_role.yaml create mode 100644 config/rbac/nve_admin_role.yaml create mode 100644 config/rbac/nve_editor_role.yaml create mode 100644 config/rbac/nve_viewer_role.yaml create mode 100644 config/samples/cisco/nx/v1alpha1_nveconfig.yaml create mode 100644 config/samples/v1alpha1_nve.yaml create mode 100644 internal/controller/cisco/nx/nve_controller_test.go create mode 100644 internal/controller/core/nve_controller.go create mode 100644 internal/controller/core/nve_controller_test.go create mode 100644 internal/provider/cisco/nxos/testdata/fabric_forward.json create mode 100644 internal/provider/cisco/nxos/testdata/fabric_forward.json.txt create mode 100644 internal/provider/cisco/nxos/testdata/infra_vlans.json create mode 100644 internal/provider/cisco/nxos/testdata/infra_vlans.json.txt create mode 100644 internal/webhook/cisco/nx/v1alpha1/nveconfig_webhook.go create mode 100644 internal/webhook/cisco/nx/v1alpha1/nveconfig_webhook_test.go create mode 100644 internal/webhook/cisco/nx/v1alpha1/webhook_suite_test.go create mode 100644 internal/webhook/core/v1alpha1/nve_webhook.go create mode 100644 internal/webhook/core/v1alpha1/nve_webhook_test.go diff --git a/PROJECT b/PROJECT index 4f18e48e..7696f2fc 100644 --- a/PROJECT +++ b/PROJECT @@ -206,4 +206,27 @@ resources: kind: RoutingPolicy 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: NVE + path: github.com/ironcore-dev/network-operator/api/core/v1alpha1 + version: v1alpha1 + webhooks: + validation: true + webhookVersion: v1 +- api: + crdVersion: v1 + namespaced: true + domain: cisco.networking.metal.ironcore.dev + group: nx + kind: NVEConfig + controller: false + path: github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1 + version: v1alpha1 + webhooks: + validation: true + webhookVersion: v1 version: "3" diff --git a/Tiltfile b/Tiltfile index 249ce1cd..a4cb74c5 100644 --- a/Tiltfile +++ b/Tiltfile @@ -111,6 +111,11 @@ k8s_resource(new_name='ccloud-prefixset', objects=['ccloud-prefixset:prefixset'] k8s_yaml('./config/samples/v1alpha1_routingpolicy.yaml') k8s_resource(new_name='bgp-import-policy', objects=['bgp-import-policy:routingpolicy', 'internal-networks:prefixset', 'partner-networks:prefixset', 'blocked-networks:prefixset'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False) +k8s_yaml('./config/samples/v1alpha1_nve.yaml') +k8s_yaml('./config/samples/cisco/nx/v1alpha1_nveconfig.yaml') +k8s_resource(new_name='nve1', objects=['nve1:nve'], trigger_mode=TRIGGER_MODE_MANUAL, resource_deps=['lo0', 'lo1'], auto_init=False) +k8s_resource(new_name='nve1-cfg', objects=['nve1-cfg:nveconfig'], 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/cisco/nx/v1alpha1/groupversion_info.go b/api/cisco/nx/v1alpha1/groupversion_info.go index 8190e2c5..32e1f1a5 100644 --- a/api/cisco/nx/v1alpha1/groupversion_info.go +++ b/api/cisco/nx/v1alpha1/groupversion_info.go @@ -22,3 +22,8 @@ var ( // AddToScheme adds the types in this group-version to the given scheme. AddToScheme = SchemeBuilder.AddToScheme ) + +// Reasons that are specific to [NVEConfig] objects. +const ( + NVEConfigAlreadyExistsReason = "NVEConfigAlreadyExists" +) diff --git a/api/cisco/nx/v1alpha1/nveconfig_types.go b/api/cisco/nx/v1alpha1/nveconfig_types.go new file mode 100644 index 00000000..6ec4de05 --- /dev/null +++ b/api/cisco/nx/v1alpha1/nveconfig_types.go @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + v1alpha1 "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +// +kubebuilder:rbac:groups=nx.cisco.networking.metal.ironcore.dev,resources=nveconfigs,verbs=get;list;watch + +// NVEConfig defines the Cisco-specific configuration of a Network Virtualization Object +type NVEConfigSpec struct { + // AdvertiseVirtualMAC controls if the NVE should advertise a virtual MAC address + // +optional + // +kubebuilder:default=false + AdvertiseVirtualMAC bool `json:"advertiseVirtualMAC,omitempty"` + + // HoldDownTime defines the duration for which the switch suppresses the advertisement of the NVE loopback address. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=1500 + // +kubebuilder:default=180 + HoldDownTime uint16 `json:"holdDownTime,omitempty"` + + // InfraVLANs specifies VLANs used by all SVI interfaces for uplink and vPC peer-links in VXLAN as infra-VLANs. + // The total number of VLANs configured must not exceed 512. + // Elements in the list must not overlap with each other. + // +optional + // +kubebuilder:validation:MaxItems=10 + InfraVLANs []VLANListItem `json:"infraVLANs,omitempty"` +} + +// VLANListItem represents a single VLAN ID or a range start-end. If ID is set, rangeMin and rangeMax must be absent. If ID is absent, both rangeMin +// and rangeMax must be set. +// +kubebuilder:validation:XValidation:rule="!has(self.rangeMax) || self.rangeMax > self.rangeMin",message="rangeMax must be greater than rangeMin" +// +kubebuilder:validation:XValidation:rule="has(self.id) || (has(self.rangeMin) && has(self.rangeMax))",message="either ID or both rangeMin and rangeMax must be set" +// +kubebuilder:validation:XValidation:rule="!has(self.id) || (!has(self.rangeMin) && !has(self.rangeMax))",message="rangeMin and rangeMax must be omitted when ID is set" +type VLANListItem struct { + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=3967 + ID uint16 `json:"id,omitempty"` + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=3967 + RangeMin uint16 `json:"rangeMin,omitempty"` + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=3967 + RangeMax uint16 `json:"rangeMax,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:path=nveconfigs +// +kubebuilder:resource:singular=nveconfig + +// NVEConfig is the Schema for the NVE API +type NVEConfig struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty,omitzero"` + + // spec defines the desired state of NVE + // +required + Spec NVEConfigSpec `json:"spec"` +} + +// +kubebuilder:object:root=true + +// NVEList contains a list of NVE +type NVEConfigList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []NVEConfig `json:"items"` +} + +// init registers the NVEConfig type with the core v1alpha1 scheme and sets +// itself as a dependency for the NVE core type. +func init() { + v1alpha1.RegisterNVEDependency(GroupVersion.WithKind("NVEConfig")) + SchemeBuilder.Register(&NVEConfig{}, &NVEConfigList{}) +} diff --git a/api/cisco/nx/v1alpha1/zz_generated.deepcopy.go b/api/cisco/nx/v1alpha1/zz_generated.deepcopy.go index 4feadef3..de00e926 100644 --- a/api/cisco/nx/v1alpha1/zz_generated.deepcopy.go +++ b/api/cisco/nx/v1alpha1/zz_generated.deepcopy.go @@ -103,6 +103,84 @@ func (in *ManagementAccessConfigSpec) DeepCopy() *ManagementAccessConfigSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NVEConfig) DeepCopyInto(out *NVEConfig) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NVEConfig. +func (in *NVEConfig) DeepCopy() *NVEConfig { + if in == nil { + return nil + } + out := new(NVEConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NVEConfig) 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 *NVEConfigList) DeepCopyInto(out *NVEConfigList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]NVEConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NVEConfigList. +func (in *NVEConfigList) DeepCopy() *NVEConfigList { + if in == nil { + return nil + } + out := new(NVEConfigList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NVEConfigList) 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 *NVEConfigSpec) DeepCopyInto(out *NVEConfigSpec) { + *out = *in + if in.InfraVLANs != nil { + in, out := &in.InfraVLANs, &out.InfraVLANs + *out = make([]VLANListItem, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NVEConfigSpec. +func (in *NVEConfigSpec) DeepCopy() *NVEConfigSpec { + if in == nil { + return nil + } + out := new(NVEConfigSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SSH) DeepCopyInto(out *SSH) { *out = *in @@ -214,3 +292,18 @@ func (in *SystemStatus) DeepCopy() *SystemStatus { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VLANListItem) DeepCopyInto(out *VLANListItem) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VLANListItem. +func (in *VLANListItem) DeepCopy() *VLANListItem { + if in == nil { + return nil + } + out := new(VLANListItem) + in.DeepCopyInto(out) + return out +} diff --git a/api/core/v1alpha1/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go index 7b5ea1ec..70c0d4a0 100644 --- a/api/core/v1alpha1/groupversion_info.go +++ b/api/core/v1alpha1/groupversion_info.go @@ -109,6 +109,9 @@ const ( // WaitingForDependenciesReason indicates that the resource is waiting for its dependencies to be ready. WaitingForDependenciesReason = "WaitingForDependencies" + + // IncompatibleProviderConfigRef indicates that the referenced provider configuration is not compatible with the target platform. + IncompatibleProviderConfigRef = "IncompatibleProviderConfigRef" ) // Reasons that are specific to [Interface] objects. diff --git a/api/core/v1alpha1/nve_types.go b/api/core/v1alpha1/nve_types.go new file mode 100644 index 00000000..82335f1e --- /dev/null +++ b/api/core/v1alpha1/nve_types.go @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: 2025 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" +) + +// NVESpec defines the desired state of a VXLAN Tunnel Endpoint (NVE); in OpenConfig: an Endpoint for a Network Virtualization Overlay Object. +// +kubebuilder:validation:XValidation:rule="!has(self.anycastSourceInterfaceRef) || self.anycastSourceInterfaceRef.name != self.sourceInterfaceRef.name",message="anycastSourceInterfaceRef.name must differ from sourceInterfaceRef.name" +type NVESpec struct { + // DeviceName 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 for this NVE. + // If not specified the provider applies the target platform's default settings. + // +optional + ProviderConfigRef *TypedLocalObjectReference `json:"providerConfigRef,omitempty"` + + // AdminState indicates whether the interface is administratively up or down. + // +required + AdminState AdminState `json:"adminState"` + + // SourceInterface is the reference to the loopback interface used for the primary NVE IP address. + // +required + SourceInterfaceRef LocalObjectReference `json:"sourceInterfaceRef"` + + // AnycastSourceInterfaceRef is the reference to the loopback interface used for anycast NVE IP address. + // +optional + AnycastSourceInterfaceRef *LocalObjectReference `json:"anycastSourceInterfaceRef,omitempty"` + + // SuppressARP indicates whether ARP suppression is enabled for this NVE. + // +optional + // +kubebuilder:default=false + SuppressARP bool `json:"suppressARP"` + + // HostReachability specifies the method used for host reachability. + // +required + HostReachability HostReachabilityType `json:"hostReachability"` + + // MulticastGroups defines multicast group addresses for BUM traffic. + // +optional + MulticastGroups *MulticastGroups `json:"multicastGroups,omitzero"` + + // AnycastGateway defines the distributed anycast gateway configuration. + // This enables multiple NVEs to share the same gateway IP and MAC + // for active-active first-hop redundancy. + // +optional + AnycastGateway *AnycastGateway `json:"anycastGateway,omitzero"` +} + +// HostReachabilityType defines the method used for host reachability. +// +kubebuilder:validation:Enum=FloodAndLearn;BGP +type HostReachabilityType string + +const ( + // HostReachabilityTypeBGP uses BGP EVPN control-plane for MAC/IP advertisement. + HostReachabilityTypeBGP HostReachabilityType = "BGP" + // HostReachabilityTypeFloodAndLearn uses data-plane learning for MAC addresses. + HostReachabilityTypeFloodAndLearn HostReachabilityType = "FloodAndLearn" +) + +// MulticastGroups defines multicast group addresses for overlay BUM traffic. +// Only supports IPv4 multicast addresses. +type MulticastGroups struct { + // L2 is the multicast group for Layer 2 VNIs (BUM traffic in bridged VLANs). + // +optional + // +kubebuilder:validation:Format=ipv4 + L2 string `json:"l2,omitempty"` + + // L3 is the multicast group for Layer 3 VNIs (BUM traffic in routed VRFs). + // +optional + // +kubebuilder:validation:Format=ipv4 + L3 string `json:"l3,omitempty"` +} + +// AnycastGateway defines distributed anycast gateway configuration. +// Multiple NVEs in the fabric share the same virtual MAC address, +// enabling active-active default gateway redundancy for hosts. +type AnycastGateway struct { + // VirtualMAC is the shared MAC address used by all NVEs in the fabric + // for anycast gateway functionality on RoutedVLAN (SVI) interfaces. + // All switches in the fabric must use the same MAC address. + // Format: IEEE 802 MAC-48 address (e.g., "00:00:5E:00:01:01") + // +required + // +kubebuilder:validation:Pattern=`^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$` + VirtualMAC string `json:"virtualMAC"` +} + +// NVEStatus defines the observed state of NVE. +type NVEStatus struct { + // For Kubernetes API conventions, see: + // https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties + + // conditions represent the current state of the NVE resource. + // Each condition has a unique type and reflects the status of a specific aspect of the resource. + // + // Standard condition types include: + // - "Available": the resource is fully functional + // - "Progressing": the resource is being created or updated + // - "Degraded": the resource failed to reach or maintain its desired state + // + // The conditions are a list of status objects that describe the state of the NVE. + //+listType=map + //+listMapKey=type + //+patchStrategy=merge + //+patchMergeKey=type + //+optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // SourceInterfaceName is the resolved source interface IP address used for NVE encapsulation. + SourceInterfaceName string `json:"sourceInterfaceName,omitempty"` + + // AnycastSourceInterfaceName is the resolved anycast source interface IP address used for NVE encapsulation. + AnycastSourceInterfaceName string `json:"anycastSourceInterfaceName,omitempty"` + + // HostReachability indicates the actual method used for host reachability. + HostReachability string `json:"hostReachability,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:path=nves +// +kubebuilder:resource:singular=nve +// +kubebuilder:printcolumn:name="Device",type=string,JSONPath=`.spec.deviceRef.name` +// +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="SrcIf",type=string,JSONPath=`.status.sourceInterfaceName` +// +kubebuilder:printcolumn:name="AnycastSrcIf",type=string,JSONPath=`.status.anycastSourceInterfaceName` +// +kubebuilder:printcolumn:name="HostReachability",type=string,JSONPath=`.status.hostReachability` +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// NVE is the Schema for the nves API +type NVE struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty,omitzero"` + + // +required + Spec NVESpec `json:"spec"` + + // +optional + Status NVEStatus `json:"status,omitempty,omitzero"` +} + +// GetConditions implements conditions.Getter. +func (in *NVE) GetConditions() []metav1.Condition { + return in.Status.Conditions +} + +// SetConditions implements conditions.Setter. +func (in *NVE) SetConditions(conditions []metav1.Condition) { + in.Status.Conditions = conditions +} + +// +kubebuilder:object:root=true + +// NVEList contains a list of NVE +type NVEList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []NVE `json:"items"` +} + +var ( + NVEDependencies []schema.GroupVersionKind + nveDependenciesMu sync.Mutex +) + +// RegisterNVEDependency adds GVKs to the NVE dependency registry.This function is typically +// called during package initialization by provider implementations (e.g., NVOConfig from cisco/nx/v1alpha1) +// to declare themselves as valid ProviderConfigRef targets. +func RegisterNVEDependency(gvk schema.GroupVersionKind) { + nveDependenciesMu.Lock() + defer nveDependenciesMu.Unlock() + NVEDependencies = append(NVEDependencies, gvk) +} + +func init() { + SchemeBuilder.Register(&NVE{}, &NVEList{}) +} diff --git a/api/core/v1alpha1/ref_types.go b/api/core/v1alpha1/ref_types.go index ff7c576a..226f46f7 100644 --- a/api/core/v1alpha1/ref_types.go +++ b/api/core/v1alpha1/ref_types.go @@ -38,7 +38,7 @@ type TypedLocalObjectReference struct { // +required // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=253 - //+kubebuilder:validation: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])?)$` + // +kubebuilder:validation: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])?)$` APIVersion string `json:"apiVersion"` } diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go index e6806f5c..b1f9a65f 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -179,6 +179,21 @@ func (in *Aggregation) DeepCopy() *Aggregation { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *AnycastGateway) DeepCopyInto(out *AnycastGateway) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new AnycastGateway. +func (in *AnycastGateway) DeepCopy() *AnycastGateway { + if in == nil { + return nil + } + out := new(AnycastGateway) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BGP) DeepCopyInto(out *BGP) { *out = *in @@ -1843,6 +1858,21 @@ func (in *MultiChassis) DeepCopy() *MultiChassis { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MulticastGroups) DeepCopyInto(out *MulticastGroups) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MulticastGroups. +func (in *MulticastGroups) DeepCopy() *MulticastGroups { + if in == nil { + return nil + } + out := new(MulticastGroups) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NTP) DeepCopyInto(out *NTP) { *out = *in @@ -1965,6 +1995,124 @@ func (in *NTPStatus) DeepCopy() *NTPStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NVE) DeepCopyInto(out *NVE) { + *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 NVE. +func (in *NVE) DeepCopy() *NVE { + if in == nil { + return nil + } + out := new(NVE) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NVE) 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 *NVEList) DeepCopyInto(out *NVEList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]NVE, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NVEList. +func (in *NVEList) DeepCopy() *NVEList { + if in == nil { + return nil + } + out := new(NVEList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NVEList) 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 *NVESpec) DeepCopyInto(out *NVESpec) { + *out = *in + out.DeviceRef = in.DeviceRef + if in.ProviderConfigRef != nil { + in, out := &in.ProviderConfigRef, &out.ProviderConfigRef + *out = new(TypedLocalObjectReference) + **out = **in + } + out.SourceInterfaceRef = in.SourceInterfaceRef + if in.AnycastSourceInterfaceRef != nil { + in, out := &in.AnycastSourceInterfaceRef, &out.AnycastSourceInterfaceRef + *out = new(LocalObjectReference) + **out = **in + } + if in.MulticastGroups != nil { + in, out := &in.MulticastGroups, &out.MulticastGroups + *out = new(MulticastGroups) + **out = **in + } + if in.AnycastGateway != nil { + in, out := &in.AnycastGateway, &out.AnycastGateway + *out = new(AnycastGateway) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NVESpec. +func (in *NVESpec) DeepCopy() *NVESpec { + if in == nil { + return nil + } + out := new(NVESpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NVEStatus) DeepCopyInto(out *NVEStatus) { + *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 NVEStatus. +func (in *NVEStatus) DeepCopy() *NVEStatus { + if in == nil { + return nil + } + out := new(NVEStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NameServer) DeepCopyInto(out *NameServer) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index 089a2dcf..9ca9bdc8 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -44,6 +44,7 @@ import ( nxcontroller "github.com/ironcore-dev/network-operator/internal/controller/cisco/nx" corecontroller "github.com/ironcore-dev/network-operator/internal/controller/core" "github.com/ironcore-dev/network-operator/internal/provider" + webhooknxv1alpha1 "github.com/ironcore-dev/network-operator/internal/webhook/cisco/nx/v1alpha1" webhookv1alpha1 "github.com/ironcore-dev/network-operator/internal/webhook/core/v1alpha1" // +kubebuilder:scaffold:imports ) @@ -427,6 +428,18 @@ func main() { os.Exit(1) } + if err := (&corecontroller.NVEReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("nve-controller"), + WatchFilterValue: watchFilterValue, + Provider: prov, + RequeueInterval: requeueInterval, + }).SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "NVE") + os.Exit(1) + } + if err := (&nxcontroller.SystemReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), @@ -460,11 +473,15 @@ func main() { 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") - os.Exit(1) - } + if err := (&corecontroller.RoutingPolicyReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("routingpolicy-controller"), + WatchFilterValue: watchFilterValue, + Provider: prov, + }).SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "RoutingPolicy") + os.Exit(1) } if os.Getenv("ENABLE_WEBHOOKS") != "false" { @@ -472,24 +489,28 @@ func main() { setupLog.Error(err, "unable to create webhook", "webhook", "Interface") 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") + os.Exit(1) + } + if err := webhookv1alpha1.SetupPrefixSetWebhookWithManager(mgr); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "PrefixSet") os.Exit(1) } + + if err := webhookv1alpha1.SetupNVEWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "NVE") + os.Exit(1) + } + + if err := webhooknxv1alpha1.SetupNVEConfigWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "NVEConfig") + os.Exit(1) + } } - if err := (&corecontroller.RoutingPolicyReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: mgr.GetEventRecorderFor("routingpolicy-controller"), - WatchFilterValue: watchFilterValue, - Provider: prov, - }).SetupWithManager(ctx, mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "RoutingPolicy") - os.Exit(1) - } + // +kubebuilder:scaffold:builder if metricsCertWatcher != nil { diff --git a/config/crd/bases/networking.metal.ironcore.dev_nves.yaml b/config/crd/bases/networking.metal.ironcore.dev_nves.yaml new file mode 100644 index 00000000..a912a317 --- /dev/null +++ b/config/crd/bases/networking.metal.ironcore.dev_nves.yaml @@ -0,0 +1,305 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: nves.networking.metal.ironcore.dev +spec: + group: networking.metal.ironcore.dev + names: + kind: NVE + listKind: NVEList + plural: nves + singular: nve + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.deviceRef.name + name: Device + 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.sourceInterfaceName + name: SrcIf + type: string + - jsonPath: .status.anycastSourceInterfaceName + name: AnycastSrcIf + type: string + - jsonPath: .status.hostReachability + name: HostReachability + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: NVE is the Schema for the nves 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: 'NVESpec defines the desired state of a VXLAN Tunnel Endpoint + (NVE); in OpenConfig: an Endpoint for a Network Virtualization Overlay + Object.' + properties: + adminState: + description: AdminState indicates whether the interface is administratively + up or down. + enum: + - Up + - Down + type: string + anycastGateway: + description: |- + AnycastGateway defines the distributed anycast gateway configuration. + This enables multiple NVEs to share the same gateway IP and MAC + for active-active first-hop redundancy. + properties: + virtualMAC: + description: |- + VirtualMAC is the shared MAC address used by all NVEs in the fabric + for anycast gateway functionality on RoutedVLAN (SVI) interfaces. + All switches in the fabric must use the same MAC address. + Format: IEEE 802 MAC-48 address (e.g., "00:00:5E:00:01:01") + pattern: ^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$ + type: string + required: + - virtualMAC + type: object + anycastSourceInterfaceRef: + description: AnycastSourceInterfaceRef is the reference to the loopback + interface used for anycast NVE IP address. + 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 + deviceRef: + description: |- + DeviceName 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 + hostReachability: + description: HostReachability specifies the method used for host reachability. + enum: + - FloodAndLearn + - BGP + type: string + multicastGroups: + description: MulticastGroups defines multicast group addresses for + BUM traffic. + properties: + l2: + description: L2 is the multicast group for Layer 2 VNIs (BUM traffic + in bridged VLANs). + format: ipv4 + type: string + l3: + description: L3 is the multicast group for Layer 3 VNIs (BUM traffic + in routed VRFs). + format: ipv4 + type: string + type: object + providerConfigRef: + description: |- + ProviderConfigRef is a reference to a resource holding the provider-specific configuration for this NVE. + If not specified the provider applies the target platform's default settings. + 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 + sourceInterfaceRef: + description: SourceInterface is the reference to the loopback interface + used for the primary NVE IP address. + 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 + suppressARP: + default: false + description: SuppressARP indicates whether ARP suppression is enabled + for this NVE. + type: boolean + required: + - adminState + - deviceRef + - hostReachability + - sourceInterfaceRef + type: object + x-kubernetes-validations: + - message: anycastSourceInterfaceRef.name must differ from sourceInterfaceRef.name + rule: '!has(self.anycastSourceInterfaceRef) || self.anycastSourceInterfaceRef.name + != self.sourceInterfaceRef.name' + status: + description: NVEStatus defines the observed state of NVE. + properties: + anycastSourceInterfaceName: + description: AnycastSourceInterfaceName is the resolved anycast source + interface IP address used for NVE encapsulation. + type: string + conditions: + description: |- + conditions represent the current state of the NVE resource. + Each condition has a unique type and reflects the status of a specific aspect of the resource. + + Standard condition types include: + - "Available": the resource is fully functional + - "Progressing": the resource is being created or updated + - "Degraded": the resource failed to reach or maintain its desired state + + The conditions are a list of status objects that describe the state of the NVE. + 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 + hostReachability: + description: HostReachability indicates the actual method used for + host reachability. + type: string + sourceInterfaceName: + description: SourceInterfaceName is the resolved source interface + IP address used for NVE encapsulation. + type: string + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_nveconfigs.yaml b/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_nveconfigs.yaml new file mode 100644 index 00000000..3625ae63 --- /dev/null +++ b/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_nveconfigs.yaml @@ -0,0 +1,91 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: nveconfigs.nx.cisco.networking.metal.ironcore.dev +spec: + group: nx.cisco.networking.metal.ironcore.dev + names: + kind: NVEConfig + listKind: NVEConfigList + plural: nveconfigs + singular: nveconfig + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: NVEConfig is the Schema for the NVE 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: spec defines the desired state of NVE + properties: + advertiseVirtualMAC: + default: false + description: AdvertiseVirtualMAC controls if the NVE should advertise + a virtual MAC address + type: boolean + holdDownTime: + default: 180 + description: HoldDownTime defines the duration for which the switch + suppresses the advertisement of the NVE loopback address. + maximum: 1500 + minimum: 1 + type: integer + infraVLANs: + description: |- + InfraVLANs specifies VLANs used by all SVI interfaces for uplink and vPC peer-links in VXLAN as infra-VLANs. + The total number of VLANs configured must not exceed 512. + Elements in the list must not overlap with each other. + items: + description: |- + VLANListItem represents a single VLAN ID or a range start-end. If ID is set, rangeMin and rangeMax must be absent. If ID is absent, both rangeMin + and rangeMax must be set. + properties: + id: + maximum: 3967 + minimum: 1 + type: integer + rangeMax: + maximum: 3967 + minimum: 1 + type: integer + rangeMin: + maximum: 3967 + minimum: 1 + type: integer + type: object + x-kubernetes-validations: + - message: rangeMax must be greater than rangeMin + rule: '!has(self.rangeMax) || self.rangeMax > self.rangeMin' + - message: either ID or both rangeMin and rangeMax must be set + rule: has(self.id) || (has(self.rangeMin) && has(self.rangeMax)) + - message: rangeMin and rangeMax must be omitted when ID is set + rule: '!has(self.id) || (!has(self.rangeMin) && !has(self.rangeMax))' + maxItems: 10 + type: array + type: object + required: + - spec + type: object + served: true + storage: true diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 6eee553a..e412c4f2 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -19,12 +19,14 @@ resources: - bases/networking.metal.ironcore.dev_bgp.yaml - bases/networking.metal.ironcore.dev_bgppeers.yaml - bases/networking.metal.ironcore.dev_ospf.yaml -- bases/nx.cisco.networking.metal.ironcore.dev_systems.yaml -- bases/nx.cisco.networking.metal.ironcore.dev_managementaccessconfigs.yaml +- bases/networking.metal.ironcore.dev_nves.yaml - bases/networking.metal.ironcore.dev_vlans.yaml - bases/networking.metal.ironcore.dev_evpninstances.yaml - bases/networking.metal.ironcore.dev_prefixsets.yaml - bases/networking.metal.ironcore.dev_routingpolicies.yaml +- bases/nx.cisco.networking.metal.ironcore.dev_systems.yaml +- bases/nx.cisco.networking.metal.ironcore.dev_managementaccessconfigs.yaml +- bases/nx.cisco.networking.metal.ironcore.dev_nveconfigs.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/rbac/cisco/nx/nveconfig_admin_role.yaml b/config/rbac/cisco/nx/nveconfig_admin_role.yaml new file mode 100644 index 00000000..04a7b5d1 --- /dev/null +++ b/config/rbac/cisco/nx/nveconfig_admin_role.yaml @@ -0,0 +1,21 @@ +# 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 nx.cisco.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: nx.cisco-nveconfig-admin-role +rules: +- apiGroups: + - nx.cisco.networking.metal.ironcore.dev + resources: + - nveconfigs + verbs: + - '*' diff --git a/config/rbac/cisco/nx/nveconfig_editor_role.yaml b/config/rbac/cisco/nx/nveconfig_editor_role.yaml new file mode 100644 index 00000000..58c4b75e --- /dev/null +++ b/config/rbac/cisco/nx/nveconfig_editor_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 nx.cisco.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: nx.cisco-nveconfig-editor-role +rules: +- apiGroups: + - nx.cisco.networking.metal.ironcore.dev + resources: + - nveconfigs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch diff --git a/config/rbac/cisco/nx/nveconfig_viewer_role.yaml b/config/rbac/cisco/nx/nveconfig_viewer_role.yaml new file mode 100644 index 00000000..b745d24a --- /dev/null +++ b/config/rbac/cisco/nx/nveconfig_viewer_role.yaml @@ -0,0 +1,23 @@ +# 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 nx.cisco.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: nx.cisco-nveconfig-viewer-role +rules: +- apiGroups: + - nx.cisco.networking.metal.ironcore.dev + resources: + - nveconfigs + verbs: + - get + - list + - watch diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 6fc661ba..de056d3e 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -22,6 +22,9 @@ resources: # default, aiding admins in cluster management. Those roles are # not used by the network-operator itself. You can comment the following lines # if you do not want those helpers be installed with your Project. +- nve_admin_role.yaml +- nve_editor_role.yaml +- nve_viewer_role.yaml - device_admin_role.yaml - device_editor_role.yaml - device_viewer_role.yaml @@ -76,6 +79,9 @@ resources: - vlan_admin_role.yaml - vlan_editor_role.yaml - vlan_viewer_role.yaml +- evpninstance_admin_role.yaml +- evpninstance_editor_role.yaml +- evpninstance_viewer_role.yaml - cisco/nx/system_admin_role.yaml - cisco/nx/system_editor_role.yaml - cisco/nx/system_viewer_role.yaml @@ -91,3 +97,6 @@ resources: - routingpolicy_admin_role.yaml - routingpolicy_editor_role.yaml - routingpolicy_viewer_role.yaml +- cisco/nx/nveconfig_admin_role.yaml +- cisco/nx/nveconfig_editor_role.yaml +- cisco/nx/nveconfig_viewer_role.yaml diff --git a/config/rbac/nve_admin_role.yaml b/config/rbac/nve_admin_role.yaml new file mode 100644 index 00000000..592762c5 --- /dev/null +++ b/config/rbac/nve_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: nve-admin-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - nves + verbs: + - '*' +- apiGroups: + - networking.metal.ironcore.dev + resources: + - nves/status + verbs: + - get diff --git a/config/rbac/nve_editor_role.yaml b/config/rbac/nve_editor_role.yaml new file mode 100644 index 00000000..55a85412 --- /dev/null +++ b/config/rbac/nve_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: nve-editor-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - nves + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - nves/status + verbs: + - get diff --git a/config/rbac/nve_viewer_role.yaml b/config/rbac/nve_viewer_role.yaml new file mode 100644 index 00000000..b96ad564 --- /dev/null +++ b/config/rbac/nve_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: nve-viewer-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - nves + verbs: + - get + - list + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - nves/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 3ff55ef7..59447b51 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -43,6 +43,7 @@ rules: - isis - managementaccesses - ntp + - nves - ospf - pim - prefixsets @@ -75,6 +76,7 @@ rules: - isis/finalizers - managementaccesses/finalizers - ntp/finalizers + - nves/finalizers - ospf/finalizers - pim/finalizers - prefixsets/finalizers @@ -101,6 +103,7 @@ rules: - isis/status - managementaccesses/status - ntp/status + - nves/status - ospf/status - pim/status - prefixsets/status @@ -118,6 +121,7 @@ rules: - nx.cisco.networking.metal.ironcore.dev resources: - managementaccessconfigs + - nveconfigs verbs: - get - list diff --git a/config/samples/cisco/nx/v1alpha1_nveconfig.yaml b/config/samples/cisco/nx/v1alpha1_nveconfig.yaml new file mode 100644 index 00000000..2259e523 --- /dev/null +++ b/config/samples/cisco/nx/v1alpha1_nveconfig.yaml @@ -0,0 +1,14 @@ +apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 +kind: NVEConfig +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: nve1-cfg +spec: + holdDownTime: 180 + advertiseVirtualMAC: false + infraVLANs: + - rangeMin: 100 + rangeMax: 105 + - id: 200 diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 23a31c8d..37bfeaa9 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -18,9 +18,11 @@ resources: - v1alpha1_bgppeer.yaml - v1alpha1_ospf.yaml - v1alpha1_vlan.yaml +- v1alpha1_nve.yaml - cisco/nx/v1alpha1_system.yaml - cisco/nx/v1alpha1_managementaccessconfig.yaml - v1alpha1_evi.yaml - v1alpha1_prefixset.yaml - v1alpha1_routingpolicy.yaml +- cisco/nx/v1alpha1_nveconfig.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/v1alpha1_interface.yaml b/config/samples/v1alpha1_interface.yaml index 6c34b528..419fe783 100644 --- a/config/samples/v1alpha1_interface.yaml +++ b/config/samples/v1alpha1_interface.yaml @@ -34,7 +34,7 @@ spec: deviceRef: name: leaf1 name: lo1 - description: VTEP Leaf1 + description: NVE/VTEP Leaf1 adminState: Up type: Loopback mtu: 1500 diff --git a/config/samples/v1alpha1_nve.yaml b/config/samples/v1alpha1_nve.yaml new file mode 100644 index 00000000..4f251651 --- /dev/null +++ b/config/samples/v1alpha1_nve.yaml @@ -0,0 +1,26 @@ +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: NVE +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + networking.metal.ironcore.dev/device-name: leaf1 + name: nve1 +spec: + providerConfigRef: + apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 + kind: NVEConfig + name: nve1-cfg + deviceRef: + name: leaf1 + adminState: Up + hostReachability: BGP + suppressARP: true + sourceInterfaceRef: + name: lo0 + anycastSourceInterfaceRef: + name: lo1 + multicastGroups: + l2: 224.0.0.2 + anycastGateway: + virtualMAC: 00:00:11:11:22:22 diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 72b88cd0..e5f6b13b 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -4,6 +4,26 @@ kind: ValidatingWebhookConfiguration metadata: name: validating-webhook-configuration webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-nx-cisco-networking-metal-ironcore-dev-v1alpha1-nveconfig + failurePolicy: Fail + name: nveconfig-cisco-nx-v1alpha1.kb.io + rules: + - apiGroups: + - nx.cisco.networking.metal.ironcore.dev + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - nveconfigs + sideEffects: None - admissionReviewVersions: - v1 clientConfig: @@ -24,6 +44,26 @@ webhooks: resources: - interfaces sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-networking-metal-ironcore-dev-v1alpha1-nve + failurePolicy: Fail + name: nve-v1alpha1.kb.io + rules: + - apiGroups: + - networking.metal.ironcore.dev + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - nves + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/internal/controller/cisco/nx/nve_controller_test.go b/internal/controller/cisco/nx/nve_controller_test.go new file mode 100644 index 00000000..4899f9ce --- /dev/null +++ b/internal/controller/cisco/nx/nve_controller_test.go @@ -0,0 +1,236 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package nx + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" + v1alpha1 "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("NVE Controller", func() { + Context("When reconciling a resource with cisco nx provider ref", func() { + const name = "test-nve-with-prov-ref" + const provConfigRefName = "test-nveconfig-prov-ref-refname" + + key := client.ObjectKey{Name: name, Namespace: metav1.NamespaceDefault} + pcKey := client.ObjectKey{Name: provConfigRefName, Namespace: metav1.NamespaceDefault} + + var ( + device *v1alpha1.Device + nve *v1alpha1.NVE + NVEConfig *nxv1alpha1.NVEConfig + ) + + BeforeEach(func() { + By("Creating the custom resource for the Kind Device") + device = &v1alpha1.Device{} + if err := k8sClient.Get(ctx, key, device); errors.IsNotFound(err) { + device = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.2:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + } + + By("Ensuring loopback interfaces exist") + for _, ifName := range []string{"lo4", "lo5"} { + Eventually(func(g Gomega) { + ifObj := &v1alpha1.Interface{} + if err := k8sClient.Get(ctx, client.ObjectKey{Name: ifName, Namespace: metav1.NamespaceDefault}, ifObj); errors.IsNotFound(err) { + ifObj = &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + Name: ifName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Name: ifName, + Type: v1alpha1.InterfaceTypeLoopback, + AdminState: "Up", + }, + } + Expect(k8sClient.Create(ctx, ifObj)).To(Succeed()) + } + }, 5*time.Second, 150*time.Millisecond).Should(Succeed()) + } + + By("Ensuring Cisco NXOS config (Kind NVEConfig)") + NVEConfig = &nxv1alpha1.NVEConfig{} + if err := k8sClient.Get(ctx, pcKey, NVEConfig); errors.IsNotFound(err) { + NVEConfig = &nxv1alpha1.NVEConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: provConfigRefName, + Namespace: metav1.NamespaceDefault, + }, + Spec: nxv1alpha1.NVEConfigSpec{ + HoldDownTime: 300, + AdvertiseVirtualMAC: true, + InfraVLANs: []nxv1alpha1.VLANListItem{ + {ID: 100}, + {RangeMin: 300, RangeMax: 400}, + }, + }, + } + Expect(k8sClient.Create(ctx, NVEConfig)).To(Succeed()) + } + + By("Creating the custom resource for the Kind NVE") + nve = &v1alpha1.NVE{} + if err := k8sClient.Get(ctx, key, nve); errors.IsNotFound(err) { + nve = &v1alpha1.NVE{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.NVESpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + SuppressARP: true, + HostReachability: "BGP", + SourceInterfaceRef: v1alpha1.LocalObjectReference{Name: "lo4"}, + AnycastSourceInterfaceRef: &v1alpha1.LocalObjectReference{Name: "lo5"}, + MulticastGroups: &v1alpha1.MulticastGroups{ + L2: "234.1.1.1", + }, + ProviderConfigRef: &v1alpha1.TypedLocalObjectReference{ + APIVersion: nxv1alpha1.GroupVersion.String(), + Kind: "NVEConfig", + Name: provConfigRefName, + }, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, nve)).To(Succeed()) + } + }) + + It("Updating the contents of a referenced provider config ref should trigger a reconciliation", func() { + testProvider.EnsureNVECalls = 0 + newHoldDownTime := uint16(400) + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, pcKey, NVEConfig)).To(Succeed()) + NVEConfig.Spec.HoldDownTime = newHoldDownTime + g.Expect(k8sClient.Update(ctx, NVEConfig)).To(Succeed()) + }).Should(Succeed()) + + Eventually(func() int { + return int(testProvider.EnsureNVECalls) + }).Should(BeNumerically(">", 0)) + }) + + It("Should not allow an additional NVEConfig for the same device", func() { + secondNVEConfig := &nxv1alpha1.NVEConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "another-nveconfig", + Namespace: metav1.NamespaceDefault, + }, + Spec: nxv1alpha1.NVEConfigSpec{ + HoldDownTime: 200, + AdvertiseVirtualMAC: false, + }, + } + Eventually(func(g Gomega) { + Expect(k8sClient.Create(ctx, secondNVEConfig)).To(Succeed()) + }).Should(Succeed()) + + secondNVE := &v1alpha1.NVE{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-nve-duplicate", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.NVESpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + SuppressARP: false, + HostReachability: "BGP", + SourceInterfaceRef: v1alpha1.LocalObjectReference{Name: "lo4"}, + AnycastSourceInterfaceRef: &v1alpha1.LocalObjectReference{Name: "lo5"}, + ProviderConfigRef: &v1alpha1.TypedLocalObjectReference{ + APIVersion: nxv1alpha1.GroupVersion.String(), + Kind: "NVEConfig", + Name: "another-nveconfig", + }, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, secondNVE)).To(Succeed()) + + Eventually(func(g Gomega) { + current := &v1alpha1.NVE{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: "test-nve-duplicate", Namespace: metav1.NamespaceDefault}, current)).To(Succeed()) + cond := meta.FindStatusCondition(current.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(current.Status.Conditions).To(HaveLen(2)) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(ContainSubstring(nxv1alpha1.NVEConfigAlreadyExistsReason)) + }).Should(Succeed()) + + Eventually(func(g Gomega) { + err := k8sClient.Delete(ctx, secondNVE) + if err != nil && !errors.IsNotFound(err) { + Expect(err).NotTo(HaveOccurred()) + } + }).Should(Succeed()) + }) + + It("Should successfully reconcile the resource", func() { + By("Adding a finalizer to the resource") + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, key, nve)).To(Succeed()) + g.Expect(controllerutil.ContainsFinalizer(nve, v1alpha1.FinalizerName)).To(BeTrue()) + }).Should(Succeed()) + + By("Adding the device label to the resource") + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, key, nve)).To(Succeed()) + g.Expect(nve.Labels).To(HaveKeyWithValue(v1alpha1.DeviceLabel, name)) + }).Should(Succeed()) + + By("Adding the device as a owner reference") + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, key, nve)).To(Succeed()) + g.Expect(nve.OwnerReferences).To(HaveLen(1)) + g.Expect(nve.OwnerReferences[0].Kind).To(Equal("Device")) + g.Expect(nve.OwnerReferences[0].Name).To(Equal(name)) + }).Should(Succeed()) + + By("Updating the resource status") + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, key, nve)).To(Succeed()) + g.Expect(nve.Status.Conditions).To(HaveLen(3)) + g.Expect(nve.Status.Conditions[0].Type).To(Equal(v1alpha1.ReadyCondition)) + g.Expect(nve.Status.Conditions[0].Status).To(Equal(metav1.ConditionTrue)) + g.Expect(nve.Status.Conditions[1].Type).To(Equal(v1alpha1.ConfiguredCondition)) + g.Expect(nve.Status.Conditions[1].Status).To(Equal(metav1.ConditionTrue)) + g.Expect(nve.Status.Conditions[2].Type).To(Equal(v1alpha1.OperationalCondition)) + g.Expect(nve.Status.Conditions[2].Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Ensuring the NVE is created in the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.NVE).ToNot(BeNil(), "Provider NVE should not be nil") + g.Expect(testProvider.NVE.Spec.ProviderConfigRef).ToNot(BeNil(), "Provider NVE ProviderConfigRef should not be nil") + g.Expect(testProvider.NVE.Spec.ProviderConfigRef.APIVersion).To(Equal(nxv1alpha1.GroupVersion.String()), "Provider NVE ProviderConfigRef APIVersion should be set") + g.Expect(testProvider.NVE.Spec.ProviderConfigRef.Kind).To(Equal("NVEConfig"), "Provider NVE ProviderConfigRef Kind should be set") + g.Expect(testProvider.NVE.Spec.ProviderConfigRef.Name).To(Equal(provConfigRefName), "Provider NVE ProviderConfigRef Name should be set") + }).Should(Succeed()) + }) + }) +}) diff --git a/internal/controller/cisco/nx/suite_test.go b/internal/controller/cisco/nx/suite_test.go index 11eb9c5c..947a08f3 100644 --- a/internal/controller/cisco/nx/suite_test.go +++ b/internal/controller/cisco/nx/suite_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "sync" "testing" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -25,6 +26,7 @@ import ( nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + corecontroller "github.com/ironcore-dev/network-operator/internal/controller/core" "github.com/ironcore-dev/network-operator/internal/deviceutil" "github.com/ironcore-dev/network-operator/internal/provider" // +kubebuilder:scaffold:imports @@ -44,7 +46,7 @@ var ( func TestControllers(t *testing.T) { RegisterFailHandler(Fail) - RunSpecs(t, "Controller Suite") + RunSpecs(t, "Cisco NX Controller Suite") } var _ = BeforeSuite(func() { @@ -105,6 +107,15 @@ var _ = BeforeSuite(func() { }).SetupWithManager(k8sManager) Expect(err).NotTo(HaveOccurred()) + err = (&corecontroller.NVEReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + Recorder: recorder, + Provider: prov, + RequeueInterval: time.Second, + }).SetupWithManager(ctx, k8sManager) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = k8sManager.Start(ctx) @@ -133,7 +144,7 @@ var _ = AfterSuite(func() { // setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are // properly set up, run 'make setup-envtest' beforehand. func detectTestBinaryDir() string { - basePath := filepath.Join("..", "..", "bin", "k8s") + basePath := filepath.Join("..", "..", "..", "..", "bin", "k8s") entries, err := os.ReadDir(basePath) if err != nil { logf.Log.Error(err, "Failed to read directory", "path", basePath) @@ -150,7 +161,9 @@ func detectTestBinaryDir() string { type MockProvider struct { sync.Mutex - Settings *nxv1alpha1.System + Settings *nxv1alpha1.System + NVE *v1alpha1.NVE + EnsureNVECalls uint16 } var _ Provider = (*MockProvider)(nil) @@ -175,3 +188,33 @@ func (p *MockProvider) ResetSystemSettings(ctx context.Context) error { p.Settings = nil return nil } + +func (p *MockProvider) EnsureNVE(_ context.Context, req *provider.NVERequest) error { + p.Lock() + defer p.Unlock() + p.NVE = req.NVE + p.EnsureNVECalls++ + return nil +} + +func (p *MockProvider) DeleteNVE(_ context.Context, req *provider.NVERequest) error { + p.Lock() + defer p.Unlock() + p.NVE = nil + return nil +} + +func (p *MockProvider) GetNVEStatus(_ context.Context, req *provider.NVERequest) (provider.NVEStatus, error) { + status := provider.NVEStatus{ + OperStatus: true, + } + if p.NVE != nil { + if p.NVE.Spec.SourceInterfaceRef.Name != "" { + status.SourceInterfaceName = p.NVE.Spec.SourceInterfaceRef.Name + } + if p.NVE.Spec.AnycastSourceInterfaceRef != nil { + status.AnycastSourceInterfaceName = p.NVE.Spec.AnycastSourceInterfaceRef.Name + } + } + return status, nil +} diff --git a/internal/controller/core/interface_controller.go b/internal/controller/core/interface_controller.go index d4852384..60140d45 100644 --- a/internal/controller/core/interface_controller.go +++ b/internal/controller/core/interface_controller.go @@ -128,7 +128,11 @@ func (r *InterfaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( if !obj.DeletionTimestamp.IsZero() { if controllerutil.ContainsFinalizer(obj, v1alpha1.FinalizerName) { - if err := r.finalize(ctx, s); err != nil { + // if err := r.finalize(ctx, s); err != nil { + // log.Error(err, "Failed to finalize resource") + // return ctrl.Result{}, err + // } + if err := r.finalize(ctx, s); err != nil && !apierrors.IsNotFound(err) { log.Error(err, "Failed to finalize resource") return ctrl.Result{}, err } diff --git a/internal/controller/core/nve_controller.go b/internal/controller/core/nve_controller.go new file mode 100644 index 00000000..8e24494c --- /dev/null +++ b/internal/controller/core/nve_controller.go @@ -0,0 +1,485 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "context" + "errors" + "fmt" + "slices" + "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/runtime/schema" + "k8s.io/apimachinery/pkg/types" + kerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/client-go/tools/record" + "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" + + nxv1alphav1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/conditions" + "github.com/ironcore-dev/network-operator/internal/deviceutil" + "github.com/ironcore-dev/network-operator/internal/provider" +) + +// NVEReconciler reconciles a NVE object +type NVEReconciler 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 record.EventRecorder + + // Provider is the driver that will be used to create & delete the dns. + Provider provider.ProviderFunc + + // RequeueInterval is the duration after which the controller should requeue the reconciliation, + // regardless of changes. + RequeueInterval time.Duration +} + +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=nves,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=nves/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=nves/finalizers,verbs=update +// +kubebuilder:rbac:groups=core,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.22.1/pkg/reconcile +func (r *NVEReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, reterr error) { + log := ctrl.LoggerFrom(ctx) + log.Info("Reconciling resource") + + obj := new(v1alpha1.NVE) + if err := r.Get(ctx, req.NamespacedName, obj); err != nil { + if apierrors.IsNotFound(err) { + log.Info("Resource not found. Ignoring reconciliation 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.NVEProvider) + 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 NVEProvider", + }) { + 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 + } + + 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 := &nveScope{ + Device: device, + NVE: 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.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.Info("Added finalizer to resource") + return ctrl.Result{}, nil + } + + orig := obj.DeepCopy() + if conditions.InitializeConditions(obj, v1alpha1.ReadyCondition) { + log.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) { + if err := r.Patch(ctx, obj, client.MergeFrom(orig)); err != nil { + log.Error(err, "Failed to update resource metadata") + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + return + } + + 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{}, err + } + + // force a periodic requeue to enforce state is in sync + return ctrl.Result{RequeueAfter: Jitter(r.RequeueInterval)}, nil +} + +// nveScope holds k8s objects used during a reconciliation. +type nveScope struct { + Device *v1alpha1.Device + NVE *v1alpha1.NVE + Connection *deviceutil.Connection + ProviderConfig *provider.ProviderConfig + Provider provider.NVEProvider +} + +func (r *NVEReconciler) reconcile(ctx context.Context, s *nveScope) (reterr error) { + if s.NVE.Labels == nil { + s.NVE.Labels = make(map[string]string) + } + s.NVE.Labels[v1alpha1.DeviceLabel] = s.Device.Name + + if !controllerutil.HasControllerReference(s.NVE) { + if err := controllerutil.SetOwnerReference(s.Device, s.NVE, r.Scheme, controllerutil.WithBlockOwnerDeletion(true)); err != nil { + return err + } + } + + if err := r.validateProviderConfigRef(ctx, s); err != nil { + return err + } + + sourceIf, err := r.validateInterfaceRef(ctx, &s.NVE.Spec.SourceInterfaceRef, s) + if err != nil { + return err + } + + anycastIf, err := r.validateInterfaceRef(ctx, s.NVE.Spec.AnycastSourceInterfaceRef, 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}) + } + }() + + defer func() { + conditions.RecomputeReady(s.NVE) + }() + + err = s.Provider.EnsureNVE(ctx, &provider.NVERequest{ + NVE: s.NVE, + ProviderConfig: s.ProviderConfig, + SourceInterface: sourceIf, + AnycastSourceInterface: anycastIf, + }) + + cond := conditions.FromError(err) + conditions.Set(s.NVE, cond) + if err != nil { + return err + } + + status, err := s.Provider.GetNVEStatus(ctx, &provider.NVERequest{ + NVE: s.NVE, + ProviderConfig: s.ProviderConfig, + }) + if err != nil { + return fmt.Errorf("failed to get NVE status: %w", err) + } + + s.NVE.Status.SourceInterfaceName = status.SourceInterfaceName + s.NVE.Status.AnycastSourceInterfaceName = status.AnycastSourceInterfaceName + s.NVE.Status.HostReachability = status.HostReachabilityType + + cond = metav1.Condition{ + Type: v1alpha1.OperationalCondition, + Status: metav1.ConditionTrue, + Reason: v1alpha1.OperationalReason, + Message: "NVE is operationally up", + } + if !status.OperStatus { + cond.Status = metav1.ConditionFalse + cond.Reason = v1alpha1.DegradedReason + cond.Message = "NVE is operationally down" + } + conditions.Set(s.NVE, cond) + + return nil +} + +// validateInterfaceRef checks that the referenced interface exists, is of type Loopback, and belongs to the same device as the NVE. +func (r *NVEReconciler) validateInterfaceRef(ctx context.Context, interfaceRef *v1alpha1.LocalObjectReference, s *nveScope) (*v1alpha1.Interface, error) { + if interfaceRef == nil { + return nil, nil + } + intf := new(v1alpha1.Interface) + intf.Name = interfaceRef.Name + intf.Namespace = s.NVE.Namespace + + if err := r.Get(ctx, client.ObjectKey{Name: intf.Name, Namespace: intf.Namespace}, intf); err != nil { + if apierrors.IsNotFound(err) { + conditions.Set(s.NVE, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.WaitingForDependenciesReason, + Message: fmt.Sprintf("interface resource '%s' not found in namespace '%s'", intf.Name, intf.Namespace), + }) + return nil, reconcile.TerminalError(fmt.Errorf("member interface %q not found", s.NVE.Spec.SourceInterfaceRef.Name)) + } + return nil, reconcile.TerminalError(fmt.Errorf("failed to get member interface %q: %w", s.NVE.Spec.SourceInterfaceRef.Name, err)) + } + + if intf.Spec.Type != v1alpha1.InterfaceTypeLoopback { + conditions.Set(s.NVE, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.InvalidInterfaceTypeReason, + Message: fmt.Sprintf("interface referenced by '%s' must be of type 'Loopback'", interfaceRef.Name), + }) + return nil, reconcile.TerminalError(fmt.Errorf("interface referenced by '%s' must be of type 'Loopback'", interfaceRef.Name)) + } + + if s.NVE.Spec.DeviceRef.Name != intf.Spec.DeviceRef.Name { + conditions.Set(s.NVE, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.CrossDeviceReferenceReason, + Message: fmt.Sprintf("interface '%s' deviceRef '%s' does not match NVE deviceRef '%s'", intf.Name, intf.Spec.DeviceRef.Name, s.NVE.Spec.DeviceRef.Name), + }) + return nil, reconcile.TerminalError(fmt.Errorf("interface '%s' deviceRef '%s' does not match NVE deviceRef '%s'", intf.Name, intf.Spec.DeviceRef.Name, s.NVE.Spec.DeviceRef.Name)) + } + return intf, nil +} + +// validateProviderConfigRef checks if the referenced provider configuration is compatible with the target platform. +func (r *NVEReconciler) validateProviderConfigRef(ctx context.Context, s *nveScope) error { + if s.NVE.Spec.ProviderConfigRef == nil { + return nil + } + gv, err := schema.ParseGroupVersion(s.NVE.Spec.ProviderConfigRef.APIVersion) + if err != nil { + conditions.Set(s.NVE, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.IncompatibleProviderConfigRef, + Message: fmt.Sprintf("ProviderConfigRef is not compatible with Device: %v", err), + }) + return reconcile.TerminalError(fmt.Errorf("invalid apiVersion %q: %w", s.NVE.Spec.ProviderConfigRef.APIVersion, err)) + } + + if found := slices.Contains(v1alpha1.NVEDependencies, schema.GroupVersionKind{ + Group: gv.Group, + Version: gv.Version, + Kind: s.NVE.Spec.ProviderConfigRef.Kind, + }); !found { + conditions.Set(s.NVE, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.IncompatibleProviderConfigRef, + Message: fmt.Sprintf("ProviderConfigRef is not compatible with Device: %v", err), + }) + return reconcile.TerminalError(fmt.Errorf("unsupported provider config ref kind %q for NVE on the provider", gv)) + } + + nveList := &v1alpha1.NVEList{} + if err := r.List(ctx, nveList, client.InNamespace(s.NVE.Namespace)); err != nil { + return fmt.Errorf("failed to list NVEs: %w", err) + } + for _, v := range nveList.Items { + if v.Name != s.NVE.Name && v.Spec.DeviceRef.Name == s.NVE.Spec.DeviceRef.Name { + conditions.Set(s.NVE, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: nxv1alphav1.NVEConfigAlreadyExistsReason, + Message: fmt.Sprintf("Another NVE (%s) already exists for device %s", v.Name, v.Spec.DeviceRef.Name), + }) + return reconcile.TerminalError(fmt.Errorf("only one NVE is allowed per device (%s)", s.NVE.Spec.DeviceRef.Name)) + } + } + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *NVEReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { + if r.RequeueInterval == 0 { + return errors.New("requeue interval must not be 0") + } + + 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) + } + + c := ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.NVE{}). + Named("nve"). + WithEventFilter(filter). + Watches( + &v1alpha1.Interface{}, + handler.EnqueueRequestsFromMapFunc(r.mapInterfaceToNVEs), + builder.WithPredicates(predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + return false + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + }), + ) + + for _, gvk := range v1alpha1.NVEDependencies { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(gvk) + c = c.Watches( + obj, + handler.EnqueueRequestsFromMapFunc(r.mapProviderConfigToNVEs), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ) + } + + return c.Complete(r) +} + +// mapProviderConfigToNVEs is a [handler.MapFunc] to re-enqueue NVEs that require reconciliation, i.e., +// whose referenced provider configuration has changed. +func (r *NVEReconciler) mapProviderConfigToNVEs(ctx context.Context, obj client.Object) []reconcile.Request { + log := ctrl.LoggerFrom(ctx, "Object", klog.KObj(obj)) + + list := &v1alpha1.NVEList{} + if err := r.List(ctx, list, client.InNamespace(obj.GetNamespace())); err != nil { + log.Error(err, "Failed to list NVEs") + 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.Info("Enqueuing NVE for reconciliation", "NVE", klog.KObj(&m)) + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: m.Name, + Namespace: m.Namespace, + }, + }) + } + } + return requests +} + +// mapInterfaceToNVEs is a [handler.MapFunc] to re-enqueue NVEs that reference the given Interface. +func (r *NVEReconciler) mapInterfaceToNVEs(ctx context.Context, obj client.Object) []reconcile.Request { + intf, ok := obj.(*v1alpha1.Interface) + if !ok { + panic(fmt.Sprintf("Expected an Interface but got a %T", obj)) + } + log := ctrl.LoggerFrom(ctx) + nves := &v1alpha1.NVEList{} + if err := r.List(ctx, nves, client.InNamespace(obj.GetNamespace())); err != nil { + log.Error(err, "Failed to list NVEs") + return nil + } + + requests := []ctrl.Request{} + for _, i := range nves.Items { + if i.Spec.SourceInterfaceRef.Name == intf.Spec.Name || + (i.Spec.AnycastSourceInterfaceRef != nil && i.Spec.AnycastSourceInterfaceRef.Name == intf.Spec.Name) { + log.Info("Enqueuing NVE for reconciliation", "NVE", klog.KObj(&i)) + requests = append(requests, ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: i.Name, + Namespace: i.Namespace, + }, + }) + } + } + return requests +} + +func (r *NVEReconciler) finalize(ctx context.Context, s *nveScope) (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}) + } + }() + + // TDO: do we need the other or just works with refs and finalizers? + return s.Provider.DeleteNVE(ctx, &provider.NVERequest{ + NVE: s.NVE, + }) +} diff --git a/internal/controller/core/nve_controller_test.go b/internal/controller/core/nve_controller_test.go new file mode 100644 index 00000000..fd7cb9a0 --- /dev/null +++ b/internal/controller/core/nve_controller_test.go @@ -0,0 +1,641 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var _ = Describe("NVE Controller", func() { + Context("When reconciling a resource", func() { + const name = "test-nve" + key := client.ObjectKey{Name: name, Namespace: metav1.NamespaceDefault} + + var ( + device *v1alpha1.Device + nve *v1alpha1.NVE + ) + BeforeEach(func() { + By("Creating the custom resource for the Kind Device") + device = &v1alpha1.Device{} + if err := k8sClient.Get(ctx, key, device); errors.IsNotFound(err) { + device = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.2:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + } + + By("Ensuring loopback interfaces exist") + for _, ifName := range []string{"lo0", "lo1"} { + ifObj := &v1alpha1.Interface{} + if err := k8sClient.Get(ctx, client.ObjectKey{Name: ifName, Namespace: metav1.NamespaceDefault}, ifObj); errors.IsNotFound(err) { + ifObj = &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + Name: ifName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Name: ifName, + Type: v1alpha1.InterfaceTypeLoopback, + AdminState: "Up", + }, + } + Expect(k8sClient.Create(ctx, ifObj)).To(Succeed()) + } + } + + By("Creating the custom resource for the Kind NVE") + nve = &v1alpha1.NVE{} + if err := k8sClient.Get(ctx, key, nve); errors.IsNotFound(err) { + nve = &v1alpha1.NVE{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.NVESpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + SuppressARP: true, + HostReachability: "BGP", + SourceInterfaceRef: v1alpha1.LocalObjectReference{Name: "lo0"}, + AnycastSourceInterfaceRef: &v1alpha1.LocalObjectReference{Name: "lo1"}, + MulticastGroups: &v1alpha1.MulticastGroups{ + L2: "234.0.0.1", + }, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, nve)).To(Succeed()) + } + }) + + AfterEach(func() { + err := k8sClient.Get(ctx, key, nve) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance NVE") + Expect(k8sClient.Delete(ctx, nve)).To(Succeed()) + + err = k8sClient.Get(ctx, key, device) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance Device") + Expect(k8sClient.Delete(ctx, device)).To(Succeed()) + + By("Ensuring the resource is deleted from the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.NVE).To(BeNil(), "Provider NVE should be empty") + }).Should(Succeed()) + }) + + It("Should successfully reconcile the resource", func() { + By("Adding a finalizer to the resource") + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, key, nve)).To(Succeed()) + g.Expect(controllerutil.ContainsFinalizer(nve, v1alpha1.FinalizerName)).To(BeTrue()) + }).Should(Succeed()) + + By("Adding the device label to the resource") + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, key, nve)).To(Succeed()) + g.Expect(nve.Labels).To(HaveKeyWithValue(v1alpha1.DeviceLabel, name)) + }).Should(Succeed()) + + By("Adding the device as a owner reference") + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, key, nve)).To(Succeed()) + g.Expect(nve.OwnerReferences).To(HaveLen(1)) + g.Expect(nve.OwnerReferences[0].Kind).To(Equal("Device")) + g.Expect(nve.OwnerReferences[0].Name).To(Equal(name)) + }).Should(Succeed()) + + By("Updating the resource status") + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, key, nve)).To(Succeed()) + g.Expect(nve.Status.Conditions).To(HaveLen(3)) + g.Expect(nve.Status.Conditions[0].Type).To(Equal(v1alpha1.ReadyCondition)) + g.Expect(nve.Status.Conditions[0].Status).To(Equal(metav1.ConditionTrue)) + g.Expect(nve.Status.Conditions[1].Type).To(Equal(v1alpha1.ConfiguredCondition)) + g.Expect(nve.Status.Conditions[1].Status).To(Equal(metav1.ConditionTrue)) + g.Expect(nve.Status.Conditions[2].Type).To(Equal(v1alpha1.OperationalCondition)) + g.Expect(nve.Status.Conditions[2].Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Ensuring the NVE is created in the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.NVE).ToNot(BeNil(), "Provider NVE should not be nil") + g.Expect(testProvider.NVE.Spec.AdminState).To(BeEquivalentTo(v1alpha1.AdminStateUp), "Provider NVE Enabled should be true") + g.Expect(testProvider.NVE.Spec.SuppressARP).To(BeTrue(), "Provider NVE SuppressARP should be true") + g.Expect(testProvider.NVE.Spec.HostReachability).To(BeEquivalentTo("BGP"), "Provider NVE hostreachability should be BGP") + g.Expect(testProvider.NVE.Spec.SourceInterfaceRef.Name).To(Equal("lo0"), "Provider NVE primary interface should be lo0") + g.Expect(testProvider.NVE.Spec.MulticastGroups).ToNot(BeNil(), "Provider NVE multicast group should not be nil") + g.Expect(testProvider.NVE.Spec.MulticastGroups.L2).To(Equal("234.0.0.1"), "Provider NVE multicast group prefix should be seet") + }).Should(Succeed()) + + By("Verifying referenced interfaces exist and are loopbacks") + Eventually(func(g Gomega) { + primary := &v1alpha1.Interface{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: nve.Spec.SourceInterfaceRef.Name, Namespace: nve.Namespace}, primary)).To(Succeed()) + g.Expect(primary.Spec.Type).To(Equal(v1alpha1.InterfaceTypeLoopback)) + g.Expect(primary.Spec.DeviceRef.Name).To(Equal(name)) + + anycast := &v1alpha1.Interface{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: nve.Spec.AnycastSourceInterfaceRef.Name, Namespace: nve.Namespace}, anycast)).To(Succeed()) + g.Expect(anycast.Spec.Type).To(Equal(v1alpha1.InterfaceTypeLoopback)) + g.Expect(anycast.Spec.DeviceRef.Name).To(Equal(name)) + g.Expect(anycast.Name).NotTo(Equal(primary.Name)) // ensure different interfaces + }).Should(Succeed()) + + By("Verifying the controller sets valid reference status") + Eventually(func(g Gomega) { + resource := &v1alpha1.NVE{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Status.Conditions).To(HaveLen(3)) + 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)) + }).Should(Succeed()) + }) + + }) + + Context("When updating referenced resources", func() { + const name = "test-nve-with-ref-updates" + key := client.ObjectKey{Name: name, Namespace: metav1.NamespaceDefault} + + var ( + device *v1alpha1.Device + nve *v1alpha1.NVE + ) + BeforeEach(func() { + By("Creating the custom resource for the Kind Device") + device = &v1alpha1.Device{} + if err := k8sClient.Get(ctx, key, device); errors.IsNotFound(err) { + device = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.2:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + } + + By("Ensuring loopback interfaces exist") + for _, ifName := range []string{"lo10", "lo11"} { + ifObj := &v1alpha1.Interface{} + if err := k8sClient.Get(ctx, client.ObjectKey{Name: ifName, Namespace: metav1.NamespaceDefault}, ifObj); errors.IsNotFound(err) { + ifObj = &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + Name: ifName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Name: ifName, + Type: v1alpha1.InterfaceTypeLoopback, + AdminState: "Up", + }, + } + Expect(k8sClient.Create(ctx, ifObj)).To(Succeed()) + } + } + + By("Creating the custom resource for the Kind NVE") + nve = &v1alpha1.NVE{} + if err := k8sClient.Get(ctx, key, nve); errors.IsNotFound(err) { + nve = &v1alpha1.NVE{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.NVESpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + SuppressARP: true, + HostReachability: "BGP", + SourceInterfaceRef: v1alpha1.LocalObjectReference{Name: "lo10"}, + // AnycastSourceInterfaceRef: &v1alpha1.LocalObjectReference{Name: "lo11"}, + MulticastGroups: &v1alpha1.MulticastGroups{ + L2: "234.0.0.1", + }, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, nve)).To(Succeed()) + } + }) + + AfterEach(func() { + By("Cleanup the specific resource instance NVE") + Expect(k8sClient.Delete(ctx, nve)).To(Succeed()) + + By("Deleting loopback interfaces") + for _, ifName := range []string{"lo10", "lo11", "lo12"} { + ifObj := &v1alpha1.Interface{} + err := k8sClient.Get(ctx, client.ObjectKey{Name: ifName, Namespace: metav1.NamespaceDefault}, ifObj) + if err == nil { + Expect(k8sClient.Delete(ctx, ifObj)).To(Succeed()) + Eventually(func() bool { + err := k8sClient.Get(ctx, client.ObjectKey{Name: ifName, Namespace: metav1.NamespaceDefault}, ifObj) + return errors.IsNotFound(err) + }).Should(BeTrue()) + } + } + + By("Deleting all Kind Device") + Expect(k8sClient.Delete(ctx, device)).To(Succeed()) + + By("Ensuring the resource is deleted from the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.NVE).To(BeNil(), "Provider NVE should be empty") + }).Should(Succeed()) + + Eventually(func() bool { + err := k8sClient.Get(ctx, key, device) + return errors.IsNotFound(err) + }).Should(BeTrue()) + }) + + It("Should reconcile when SourceInterfaceRef is changed", func() { + By("Patching NVE to update SourceInterfaceRef") + patch := client.MergeFrom(nve.DeepCopy()) + nve.Spec.SourceInterfaceRef = v1alpha1.LocalObjectReference{Name: "lo11"} + Expect(k8sClient.Patch(ctx, nve, patch)).To(Succeed()) + + By("Verifying reconciliation updates provider and status") + Eventually(func(g Gomega) { + g.Expect(testProvider.NVE).ToNot(BeNil()) + g.Expect(testProvider.NVE.Spec.SourceInterfaceRef.Name).To(Equal("lo11")) + g.Expect(testProvider.NVE.Status.SourceInterfaceName).To(Equal("lo11")) + }, 5*time.Second, 100*time.Millisecond).Should(Succeed()) + }) + + It("Should reconcile when AnycastSourceInterfaceRef is added", func() { + By("Patching NVE to add AnycastSourceInterfaceRef") + patch := client.MergeFrom(nve.DeepCopy()) + nve.Spec.AnycastSourceInterfaceRef = &v1alpha1.LocalObjectReference{Name: "lo12"} + Expect(k8sClient.Patch(ctx, nve, patch)).To(Succeed()) + + By("Creating the anycast interface") + ifObj := &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + Name: "lo12", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Name: "lo12", + Type: v1alpha1.InterfaceTypeLoopback, + AdminState: "Up", + }, + } + Expect(k8sClient.Create(ctx, ifObj)).To(Succeed()) + + By("Verifying reconciliation updates provider and status") + Eventually(func(g Gomega) { + if testProvider.NVE != nil { + g.Expect(testProvider.NVE).ToNot(BeNil()) + g.Expect(testProvider.NVE.Spec.AnycastSourceInterfaceRef.Name).To(Equal("lo12")) + g.Expect(testProvider.NVE.Status.AnycastSourceInterfaceName).To(Equal("lo12")) + } + }, 5*time.Second, 100*time.Millisecond).Should(Succeed()) + }) + }) + + Context("When using erroneous interface references (non loopback type)", func() { + const name = "test-nve-misconfigured-iftype" + key := client.ObjectKey{Name: name, Namespace: metav1.NamespaceDefault} + + var ( + device *v1alpha1.Device + nve *v1alpha1.NVE + ) + + BeforeEach(func() { + device = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{Address: "192.168.10.2:9339"}, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + + By("Ensuring loopback interfaces with wrong type exist") + for _, ifName := range []string{"eth1", "eth2"} { + ifObj := &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + Name: ifName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Name: ifName, + Type: v1alpha1.InterfaceTypePhysical, // invalid for NVE + AdminState: "Up", + }, + } + Expect(k8sClient.Create(ctx, ifObj)).To(Succeed()) + } + + By("Creating the custom resource for the Kind NVE") + nve = &v1alpha1.NVE{} + if err := k8sClient.Get(ctx, key, nve); errors.IsNotFound(err) { + nve = &v1alpha1.NVE{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.NVESpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + SuppressARP: true, + HostReachability: "BGP", + SourceInterfaceRef: v1alpha1.LocalObjectReference{Name: "eth1"}, + AnycastSourceInterfaceRef: &v1alpha1.LocalObjectReference{Name: "eth2"}, + MulticastGroups: &v1alpha1.MulticastGroups{ + L2: "234.0.0.1", + }, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, nve)).To(Succeed()) + } + }) + + AfterEach(func() { + err := k8sClient.Get(ctx, key, nve) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance NVE") + Expect(k8sClient.Delete(ctx, nve)).To(Succeed()) + + By("Cleanup the specific resource instance Interfaces") + for _, ifName := range []string{"eth1", "eth2"} { + ifObj := &v1alpha1.Interface{} + err := k8sClient.Get(ctx, client.ObjectKey{Name: ifName, Namespace: metav1.NamespaceDefault}, ifObj) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient.Delete(ctx, ifObj)).To(Succeed()) + } + + err = k8sClient.Get(ctx, key, device) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance Device") + Expect(k8sClient.Delete(ctx, device)).To(Succeed()) + + By("Ensuring the resource is deleted from the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.NVE).To(BeNil(), "Provider NVE should be empty") + }).Should(Succeed()) + }) + + It("Should set Configured=False with InvalidInterfaceTypeReason", func() { + Eventually(func(g Gomega) { + current := &v1alpha1.NVE{} + g.Expect(k8sClient.Get(ctx, key, current)).To(Succeed()) + cond := meta.FindStatusCondition(current.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(v1alpha1.InvalidInterfaceTypeReason)) + }).Should(Succeed()) + }) + }) + + Context("When using erroneous interface references (cross-device reference)", func() { + const name = "test-nve-misconfigured-crossdevice" + const nameAlt = "test-nve-misconfigured-crossdevice-alt" // device for interface reference + key := client.ObjectKey{Name: name, Namespace: metav1.NamespaceDefault} + + var ( + device *v1alpha1.Device + deviceAlt *v1alpha1.Device + nve *v1alpha1.NVE + ) + + BeforeEach(func() { + device = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{Address: "192.168.10.2:9339"}, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + + deviceAlt = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: nameAlt, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{Address: "192.168.10.2:9339"}, + }, + } + Expect(k8sClient.Create(ctx, deviceAlt)).To(Succeed()) + + By("Ensuring loopback interfaces with created on a different device") + for _, ifName := range []string{"lo2", "lo3"} { + ifObj := &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + Name: ifName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: nameAlt}, + Name: ifName, + Type: v1alpha1.InterfaceTypeLoopback, + AdminState: "Up", + }, + } + Expect(k8sClient.Create(ctx, ifObj)).To(Succeed()) + } + + By("Creating the custom resource for the Kind NVE") + nve = &v1alpha1.NVE{} + if err := k8sClient.Get(ctx, key, nve); errors.IsNotFound(err) { + nve = &v1alpha1.NVE{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.NVESpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + SuppressARP: true, + HostReachability: "BGP", + SourceInterfaceRef: v1alpha1.LocalObjectReference{Name: "lo2"}, + AnycastSourceInterfaceRef: &v1alpha1.LocalObjectReference{Name: "lo3"}, + MulticastGroups: &v1alpha1.MulticastGroups{ + L2: "234.0.0.1", + }, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, nve)).To(Succeed()) + } + }) + + AfterEach(func() { + err := k8sClient.Get(ctx, key, nve) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance NVE") + Expect(k8sClient.Delete(ctx, nve)).To(Succeed()) + + By("Cleanup the specific resource instance Interfaces") + for _, ifName := range []string{"lo2", "lo3"} { + ifObj := &v1alpha1.Interface{} + err := k8sClient.Get(ctx, client.ObjectKey{Name: ifName, Namespace: metav1.NamespaceDefault}, ifObj) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient.Delete(ctx, ifObj)).To(Succeed()) + } + + err = k8sClient.Get(ctx, key, device) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance Device") + Expect(k8sClient.Delete(ctx, device)).To(Succeed()) + + By("Cleanup the specific resource second instance Device") + Expect(k8sClient.Delete(ctx, deviceAlt)).To(Succeed()) + + By("Ensuring the resource is deleted from the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.NVE).To(BeNil(), "Provider NVE should be empty") + }).Should(Succeed()) + }) + + It("Should set Configured=False with CrossDeviceReferenceReason", func() { + Eventually(func(g Gomega) { + current := &v1alpha1.NVE{} + g.Expect(k8sClient.Get(ctx, key, current)).To(Succeed()) + cond := meta.FindStatusCondition(current.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(v1alpha1.CrossDeviceReferenceReason)) + }).Should(Succeed()) + }) + }) + + Context("When using a non registered dependency for providerConfigRef", func() { + const name = "test-nve-misconfigured-providerconfigref" + key := client.ObjectKey{Name: name, Namespace: metav1.NamespaceDefault} + + var ( + device *v1alpha1.Device + nve *v1alpha1.NVE + ) + + BeforeEach(func() { + device = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{Address: "192.168.10.2:9339"}, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + + By("Ensuring loopback interfaces with created on a different device") + for _, ifName := range []string{"lo6", "lo7", "lo8"} { + ifObj := &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + Name: ifName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Name: ifName, + Type: v1alpha1.InterfaceTypeLoopback, + AdminState: "Up", + }, + } + Expect(k8sClient.Create(ctx, ifObj)).To(Succeed()) + } + + By("Ensuring Cisco NXOS config (Kind NVOConfig)") + nve = &v1alpha1.NVE{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.NVESpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + SuppressARP: true, + HostReachability: "BGP", + SourceInterfaceRef: v1alpha1.LocalObjectReference{Name: "lo6"}, + AnycastSourceInterfaceRef: &v1alpha1.LocalObjectReference{Name: "lo7"}, + AdminState: v1alpha1.AdminStateUp, + ProviderConfigRef: &v1alpha1.TypedLocalObjectReference{ + Name: "lo8", + Kind: "Interface", + APIVersion: "networking.metal.ironcore.dev/v1alpha1", + }, // invalid provider config ref + }, + } + Expect(k8sClient.Create(ctx, nve)).To(Succeed()) + }) + + AfterEach(func() { + err := k8sClient.Get(ctx, key, nve) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance NVE") + Expect(k8sClient.Delete(ctx, nve)).To(Succeed()) + + err = k8sClient.Get(ctx, key, device) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance Device") + Expect(k8sClient.Delete(ctx, device)).To(Succeed()) + + By("Ensuring the resource is deleted from the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.NVE).To(BeNil(), "Provider NVE should be empty") + }).Should(Succeed()) + }) + + It("Should set Configured=False with IncompatibleProviderConfigRef", func() { + Eventually(func(g Gomega) { + current := &v1alpha1.NVE{} + g.Expect(k8sClient.Get(ctx, key, current)).To(Succeed()) + cond := meta.FindStatusCondition(current.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(v1alpha1.IncompatibleProviderConfigRef)) + }).Should(Succeed()) + }) + }) +}) diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go index 83310b0c..8bc30aa0 100644 --- a/internal/controller/core/suite_test.go +++ b/internal/controller/core/suite_test.go @@ -47,7 +47,7 @@ var ( func TestControllers(t *testing.T) { RegisterFailHandler(Fail) - RunSpecs(t, "Controller Suite") + RunSpecs(t, "Core Controller Suite") } var _ = BeforeSuite(func() { @@ -258,6 +258,15 @@ var _ = BeforeSuite(func() { }).SetupWithManager(ctx, k8sManager) Expect(err).NotTo(HaveOccurred()) + err = (&NVEReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + Recorder: recorder, + Provider: prov, + RequeueInterval: time.Second, + }).SetupWithManager(ctx, k8sManager) + Expect(err).NotTo(HaveOccurred()) + err = (&PrefixSetReconciler{ Client: k8sManager.GetClient(), Scheme: k8sManager.GetScheme(), @@ -339,6 +348,7 @@ var ( _ provider.EVPNInstanceProvider = (*Provider)(nil) _ provider.PrefixSetProvider = (*Provider)(nil) _ provider.RoutingPolicyProvider = (*Provider)(nil) + _ provider.NVEProvider = (*Provider)(nil) ) // Provider is a simple in-memory provider for testing purposes only. @@ -366,6 +376,7 @@ type Provider struct { EVIs sets.Set[int32] PrefixSets sets.Set[string] RoutingPolicies sets.Set[string] + NVE *v1alpha1.NVE } func NewProvider() *Provider { @@ -734,3 +745,32 @@ func (p *Provider) DeleteRoutingPolicy(_ context.Context, req *provider.DeleteRo p.RoutingPolicies.Delete(req.Name) return nil } + +func (p *Provider) EnsureNVE(_ context.Context, req *provider.NVERequest) error { + p.Lock() + defer p.Unlock() + p.NVE = req.NVE + return nil +} + +func (p *Provider) DeleteNVE(_ context.Context, req *provider.NVERequest) error { + p.Lock() + defer p.Unlock() + p.NVE = nil + return nil +} + +func (p *Provider) GetNVEStatus(context.Context, *provider.NVERequest) (provider.NVEStatus, error) { + status := provider.NVEStatus{ + OperStatus: true, + } + if p.NVE != nil { + if p.NVE.Spec.SourceInterfaceRef.Name != "" { + status.SourceInterfaceName = p.NVE.Spec.SourceInterfaceRef.Name + } + if p.NVE.Spec.AnycastSourceInterfaceRef != nil { + status.AnycastSourceInterfaceName = p.NVE.Spec.AnycastSourceInterfaceRef.Name + } + } + return status, nil +} diff --git a/internal/provider/cisco/nxos/nve.go b/internal/provider/cisco/nxos/nve.go index 44fa34af..302f4687 100644 --- a/internal/provider/cisco/nxos/nve.go +++ b/internal/provider/cisco/nxos/nve.go @@ -10,25 +10,38 @@ import ( ) var _ gnmiext.Configurable = (*NVE)(nil) +var _ gnmiext.Configurable = (*NVEInfraVLANs)(nil) +var _ gnmiext.Configurable = (*FabricFwd)(nil) // NVE represents the Network Virtualization Edge interface (nve1). +// ID must be always 1 on NX-OS devices. type NVE struct { + ID uint8 `json:"epId"` AdminSt AdminSt `json:"adminSt"` AdvertiseVmac bool `json:"advertiseVmac"` + SourceInterface string `json:"sourceInterface,omitempty"` AnycastInterface Option[string] `json:"anycastIntf"` - ID int `json:"epId"` - HoldDownTime int16 `json:"holdDownTime"` + HoldDownTime uint16 `json:"holdDownTime"` HostReach HostReachType `json:"hostReach"` McastGroupL2 Option[string] `json:"mcastGroupL2"` McastGroupL3 Option[string] `json:"mcastGroupL3"` - SourceInterface string `json:"sourceInterface"` SuppressARP bool `json:"suppressARP"` } +type HostReachType string + +const ( + HostReachFloodAndLearn HostReachType = "Flood_and_learn" + HostReachBGP HostReachType = "bgp" + HostReachController HostReachType = "controller" + HostReachOpenFlow HostReachType = "openflow" + HostReachOpenFlowIR HostReachType = "openflowIR" +) + func (*NVE) IsListItem() {} func (n *NVE) XPath() string { - return "System/eps-items/epId-items/Ep-list[epId=" + strconv.Itoa(n.ID) + "]" + return "System/eps-items/epId-items/Ep-list[epId=" + strconv.FormatInt(int64(n.ID), 10) + "]" } type VNI struct { @@ -52,16 +65,52 @@ func (v *VNIOperItems) XPath() string { return "System/eps-items/epId-items/Ep-list[epId=1]/nws-items/opervni-items/OperNw-list[vni=" + strconv.FormatInt(int64(v.Vni), 10) + "]" } -type HostReachType string - -const ( - HostReachFloodAndLearn HostReachType = "Flood_and_learn" - HostReachBGP HostReachType = "bgp" -) - type VNIState string const ( VNIStateUp VNIState = "Up" VNIStateDown VNIState = "Down" ) + +type NVEInfraVLANs struct { + InfraVLANList []*NVEInfraVLAN `json:"InfraVlan-list,omitempty"` +} + +func (n *NVEInfraVLANs) XPath() string { + return "System/pltfm-items/nve-items/NVE-list[id=1]/infravlan-items" +} + +type NVEInfraVLAN struct { + ID uint32 `json:"id"` +} + +func (*NVEInfraVLAN) IsListItem() {} + +// NVEOper represents the operational state of the NVE interface. +// Note: NXOS also returns the Operational status of the associated interfaces, +// but those are not included here. +type NVEOper struct { + ID uint8 `json:"-"` + OperSt OperSt `json:"operState"` +} + +func (n *NVEOper) XPath() string { + return "System/eps-items/epId-items/Ep-list[epId=" + strconv.Itoa(int(n.ID)) + "]" +} + +func (*NVEOper) IsListItem() {} + +// FabricFwd represents the fabric forwarding settings required for NVE operation. +// Should use only PATCH operations: `FabricFwdIf` also modifies this model. +type FabricFwd struct { + // AdminSt defines the administrative state of fabric forwarding + AdminSt string `json:"adminSt"` + // Address defines the anycast gateway MAC address + Address string `json:"amac"` +} + +func (*FabricFwd) XPath() string { + return "System/hmm-items/fwdinst-items" +} + +func (*FabricFwd) IsListItem() {} diff --git a/internal/provider/cisco/nxos/nve_test.go b/internal/provider/cisco/nxos/nve_test.go index b983e9c6..4934756b 100644 --- a/internal/provider/cisco/nxos/nve_test.go +++ b/internal/provider/cisco/nxos/nve_test.go @@ -13,6 +13,7 @@ func init() { AnycastInterface: NewOption("lo1"), SuppressARP: true, McastGroupL2: NewOption("237.0.0.1"), + McastGroupL3: NewOption(""), HoldDownTime: 300, } Register("nve", nve) @@ -22,4 +23,18 @@ func init() { McastGroup: NewOption("239.1.1.100"), } Register("vni", vni) + nveInfraVLANs := &NVEInfraVLANs{ + ID: 1, + InfraVLANList: []*NVEInfraVLAN{ + {ID: 4052}, + {ID: 4092}, + }, + } + Register("infra_vlans", nveInfraVLANs) + + ffw := &FabricFwd{ + AdminSt: "enabled", + Address: "00:00:11:11:22:22", + } + Register("fabric_forward", ffw) } diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index 756d8a52..c85c1f1e 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -57,6 +57,7 @@ var ( _ provider.UserProvider = (*Provider)(nil) _ provider.VLANProvider = (*Provider)(nil) _ provider.VRFProvider = (*Provider)(nil) + _ provider.NVEProvider = (*Provider)(nil) ) type Provider struct { @@ -451,32 +452,6 @@ func (p *Provider) EnsureEVPNInstance(ctx context.Context, req *provider.EVPNIns return err } - // TODO: Remove hardcoded "evpn"/"bgp" feature and NVE instance when NVE is fully supported as a dedicated resource. - nve := new(NVE) - nve.ID = 1 - if err := p.client.GetConfig(ctx, nve); err != nil { - if !errors.Is(err, gnmiext.ErrNil) { - return err - } - - nve.AdminSt = AdminStEnabled - nve.HoldDownTime = 180 - nve.HostReach = HostReachBGP - nve.SourceInterface = "lo1" - - fe := new(Feature) - fe.Name = "evpn" - fe.AdminSt = AdminStEnabled - - fb := new(Feature) - fb.Name = "bgp" - fb.AdminSt = AdminStEnabled - - if err := p.client.Update(ctx, nve, fe, fb); err != nil { - return err - } - } - conf := make([]gnmiext.Configurable, 0, 3) if req.EVPNInstance.Spec.Type == v1alpha1.EVPNInstanceTypeBridged { v := new(VLAN) @@ -1261,91 +1236,6 @@ func (p *Provider) DeleteNTP(ctx context.Context) error { return p.client.Delete(ctx, n, f) } -type NVERequest struct { - AdminSt bool - HostReach HostReachType - AdvertiseVirtualRmac *bool - // the name of the loopback to use as source - SourceInterface string - // the name of the loopback to use for anycast - AnycastInterface string - SuppressARP *bool - // multicast group for L2 VTEP discovery - McastL2 *netip.Addr - // multicast group for L3 VTEP discovery - McastL3 *netip.Addr - HoldDownTime int16 // in seconds -} - -func (p *Provider) EnsureNVE(ctx context.Context, req *NVERequest) error { - f := new(Feature) - f.Name = "nvo" - f.AdminSt = AdminStEnabled - - f2 := new(Feature) - f2.Name = "ngmvpn" - f2.AdminSt = AdminStEnabled - - srcIf, err := ShortNameLoopback(req.SourceInterface) - if err != nil { - return err - } - - anyIf, err := ShortNameLoopback(req.AnycastInterface) - if err != nil { - return err - } - - nve := new(NVE) - nve.ID = 1 - nve.AdminSt = AdminStDisabled - if req.AdminSt { - nve.AdminSt = AdminStEnabled - } - - if srcIf == anyIf { - return errors.New("nve: source and anycast interfaces must be different") - } - nve.SourceInterface = srcIf - nve.AnycastInterface = NewOption(anyIf) - - if req.HostReach != HostReachBGP && req.HostReach != HostReachFloodAndLearn { - return fmt.Errorf("nve: invalid host reach type %q", req.HostReach) - } - nve.HostReach = req.HostReach - - if req.AdvertiseVirtualRmac != nil { - nve.AdvertiseVmac = *req.AdvertiseVirtualRmac - } - - if req.SuppressARP != nil { - nve.SuppressARP = *req.SuppressARP - } - - if ip := req.McastL2; ip != nil { - if !ip.Is4() || !ip.IsMulticast() { - return fmt.Errorf("nve: invalid multicast IPv4 address: %s", ip) - } - nve.McastGroupL2 = NewOption(ip.String()) - } - - if ip := req.McastL3; ip != nil { - if !ip.Is4() || !ip.IsMulticast() { - return fmt.Errorf("nve: invalid multicast IPv4 address: %s", ip) - } - nve.McastGroupL3 = NewOption(ip.String()) - } - - if req.HoldDownTime != 0 { - if req.HoldDownTime < 1 || req.HoldDownTime > 1500 { - return fmt.Errorf("nve: hold down time %d is out of range (1-1500 seconds)", req.HoldDownTime) - } - nve.HoldDownTime = req.HoldDownTime - } - - return p.client.Update(ctx, f, f2, nve) -} - type NXOSPF struct { // PropagateDefaultRoute is equivalent to the CLI command `default-information originate` PropagateDefaultRoute *bool @@ -2143,6 +2033,127 @@ func (p *Provider) ResetSystemSettings(ctx context.Context) error { ) } +func ToPtr[T any](v T) *T { + return &v +} + +// EnsureNVE ensures that the NVE configuration on the device matches the desired state specified in the NVE custom resource. +// If no provider config is provided then the provider will use default settings. +func (p *Provider) EnsureNVE(ctx context.Context, req *provider.NVERequest) error { + if req.AnycastSourceInterface != nil && req.AnycastSourceInterface.Spec.Name == req.SourceInterface.Spec.Name { + return errors.New("nve: anycast source interface cannot be the same as source interface") + } + + n := new(NVE) + n.ID = 1 + n.AdminSt = AdminStDisabled + if req.NVE.Spec.AdminState == v1alpha1.AdminStateUp { + n.AdminSt = AdminStEnabled + } + n.SourceInterface = req.SourceInterface.Spec.Name + + n.AnycastInterface = NewOption("") + if req.AnycastSourceInterface != nil { + n.AnycastInterface = NewOption(req.AnycastSourceInterface.Spec.Name) + } + n.McastGroupL2 = NewOption("") + if req.NVE.Spec.MulticastGroups != nil && req.NVE.Spec.MulticastGroups.L2 != "" { + n.McastGroupL2 = NewOption(req.NVE.Spec.MulticastGroups.L2) + } + n.McastGroupL3 = NewOption("") + if req.NVE.Spec.MulticastGroups != nil && req.NVE.Spec.MulticastGroups.L3 != "" { + n.McastGroupL3 = NewOption(req.NVE.Spec.MulticastGroups.L3) + } + + n.SuppressARP = req.NVE.Spec.SuppressARP + + switch req.NVE.Spec.HostReachability { + case v1alpha1.HostReachabilityTypeBGP: + n.HostReach = HostReachBGP + case v1alpha1.HostReachabilityTypeFloodAndLearn: + n.HostReach = HostReachFloodAndLearn + default: + return fmt.Errorf("invalid evpn host reachability type %q", req.NVE.Spec.HostReachability) + } + + // defaults in this provider + n.AdvertiseVmac = false + n.HoldDownTime = 180 + + vc := new(nxv1alpha1.NVEConfig) + if req.ProviderConfig != nil { + if err := req.ProviderConfig.Into(vc); err != nil { + return fmt.Errorf("failed to decode provider config: %w", err) + } + n.HoldDownTime = vc.Spec.HoldDownTime + n.AdvertiseVmac = vc.Spec.AdvertiseVirtualMAC + } + + iv := new(NVEInfraVLANs) + for _, ivList := range vc.Spec.InfraVLANs { + if ivList.ID != 0 { + iv.InfraVLANList = append(iv.InfraVLANList, &NVEInfraVLAN{ID: uint32(ivList.ID)}) + continue + } + for i := ivList.RangeMin; i <= ivList.RangeMax; i++ { + iv.InfraVLANList = append(iv.InfraVLANList, &NVEInfraVLAN{ID: uint32(i)}) + } + } + + ag := new(FabricFwd) + if req.NVE.Spec.AnycastGateway != nil { + ag.AdminSt = string(AdminStEnabled) + ag.Address = req.NVE.Spec.AnycastGateway.VirtualMAC + } + + return p.client.Patch(ctx, n, iv, ag) +} + +func (p *Provider) DeleteNVE(ctx context.Context, req *provider.NVERequest) error { + v := new(NVE) + v.ID = 1 + iv := new(NVEInfraVLANs) + av := new(FabricFwd) + return p.client.Delete(ctx, v, iv, av) +} + +// GetNVEStatus retrieves the operational status of the NVE configuration on the device. +func (p *Provider) GetNVEStatus(ctx context.Context, req *provider.NVERequest) (provider.NVEStatus, error) { + s := provider.NVEStatus{} + + op := new(NVEOper) + op.ID = 1 + if err := p.client.GetState(ctx, op); err != nil && !errors.Is(err, gnmiext.ErrNil) { + return provider.NVEStatus{}, err + } + s.OperStatus = op.OperSt == OperStUp + + n := new(NVE) + n.ID = 1 + if err := p.client.GetConfig(ctx, n); err != nil && !errors.Is(err, gnmiext.ErrNil) { + return provider.NVEStatus{}, err + } + s.SourceInterfaceName = n.SourceInterface + if n.AnycastInterface.Value != nil { + s.AnycastSourceInterfaceName = *n.AnycastInterface.Value + } + switch n.HostReach { + case HostReachBGP: + s.HostReachabilityType = "BGP" + case HostReachFloodAndLearn: + s.HostReachabilityType = "FloodAndLearn" + case HostReachController: + s.HostReachabilityType = "Controller" + case HostReachOpenFlow: + s.HostReachabilityType = "OpenFlow" + case HostReachOpenFlowIR: + s.HostReachabilityType = "OpenFlowIR" + default: + // unknown type, return as empty + } + return s, nil +} + func init() { provider.Register("cisco-nxos-gnmi", NewProvider) } diff --git a/internal/provider/cisco/nxos/testdata/fabric_forward.json b/internal/provider/cisco/nxos/testdata/fabric_forward.json new file mode 100644 index 00000000..abf5a011 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/fabric_forward.json @@ -0,0 +1,8 @@ +{ + "hmm-items": { + "fwdinst-items": { + "adminSt": "enabled", + "amac": "00:00:11:11:22:22" + } + } + } diff --git a/internal/provider/cisco/nxos/testdata/fabric_forward.json.txt b/internal/provider/cisco/nxos/testdata/fabric_forward.json.txt new file mode 100644 index 00000000..ee32e2e5 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/fabric_forward.json.txt @@ -0,0 +1 @@ +fabric forwarding anycast-gateway-mac 0000.1111.2222 diff --git a/internal/provider/cisco/nxos/testdata/infra_vlans.json b/internal/provider/cisco/nxos/testdata/infra_vlans.json new file mode 100644 index 00000000..c257daba --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/infra_vlans.json @@ -0,0 +1,21 @@ +{ + "pltfm-items": { + "nve-items": { + "NVE-list": [ + { + "id": "1", + "infravlan-items": { + "InfraVlan-list": [ + { + "id": 4052 + }, + { + "id": 4092 + } + ] + } + } + ] + } + } + } diff --git a/internal/provider/cisco/nxos/testdata/infra_vlans.json.txt b/internal/provider/cisco/nxos/testdata/infra_vlans.json.txt new file mode 100644 index 00000000..e2dbb7bc --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/infra_vlans.json.txt @@ -0,0 +1 @@ +system nve infra-vlans 4052,4092 diff --git a/internal/provider/cisco/nxos/testdata/nve.json b/internal/provider/cisco/nxos/testdata/nve.json index a9ff60f2..934e1332 100644 --- a/internal/provider/cisco/nxos/testdata/nve.json +++ b/internal/provider/cisco/nxos/testdata/nve.json @@ -3,15 +3,15 @@ "epId-items": { "Ep-list": [ { + "epId": 1, "adminSt": "enabled", "advertiseVmac": true, + "sourceInterface": "lo0", "anycastIntf": "lo1", - "epId": 1, "holdDownTime": 300, "hostReach": "bgp", "mcastGroupL2": "237.0.0.1", "mcastGroupL3": "DME_UNSET_PROPERTY_MARKER", - "sourceInterface": "lo0", "suppressARP": true } ] diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 03e5e3f3..aab65da0 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -542,6 +542,39 @@ type DeleteRoutingPolicyRequest struct { Name string } +type NVEProvider interface { + Provider + + // EnsureVRF call is responsible for VRF realization on the provider. + EnsureNVE(context.Context, *NVERequest) error + // DeleteVRF call is responsible for VRF deletion on the provider. + DeleteNVE(context.Context, *NVERequest) error + // GetInterfaceStatus call is responsible for retrieving the current status of the Interface from the provider. + GetNVEStatus(context.Context, *NVERequest) (NVEStatus, error) +} + +type NVERequest struct { + NVE *v1alpha1.NVE + SourceInterface *v1alpha1.Interface + AnycastSourceInterface *v1alpha1.Interface + ProviderConfig *ProviderConfig +} + +type NVEStatus struct { + // OperStatus indicates whether the NVE is operationally up (true) or down (false). + OperStatus bool + // OperStatusSourceInterface indicates if the primary source interface is operationally up (true) or down (false). + OperStatusSourceInterface bool + // OperStatusAnycastSourceInterface indicates if the primary and anycast interfaces are operationally up (true) or down (false). + OperStatusAnycastSourceInterface bool + // SourceInterfaceName is the name of the interface configured as source interface in the remote device. + SourceInterfaceName string + // AnycastSourceInterfaceName is the name of the interface configured as anycast source interface in the remote device. + AnycastSourceInterfaceName string + // HostReachabilityType is the type of host reachability configured on the remote device. + HostReachabilityType string +} + var mu sync.RWMutex // ProviderFunc returns a new [Provider] instance. diff --git a/internal/webhook/cisco/nx/v1alpha1/nveconfig_webhook.go b/internal/webhook/cisco/nx/v1alpha1/nveconfig_webhook.go new file mode 100644 index 00000000..c62c57f9 --- /dev/null +++ b/internal/webhook/cisco/nx/v1alpha1/nveconfig_webhook.go @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + "cmp" + "context" + "fmt" + "slices" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" +) + +// vclog is for logging in this package. +var vclog = logf.Log.WithName("nveconfig-resource") + +// SetupNVEConfigWebhookWithManager registers the webhook for NVE in the manager. +func SetupNVEConfigWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(&v1alpha1.NVEConfig{}). + WithValidator(&NVEConfigCustomValidator{Client: mgr.GetClient()}). + Complete() +} + +// +kubebuilder:webhook:path=/validate-nx-cisco-networking-metal-ironcore-dev-v1alpha1-nveconfig,mutating=false,failurePolicy=Fail,sideEffects=None,groups=nx.cisco.networking.metal.ironcore.dev,resources=nveconfigs,verbs=create;update,versions=v1alpha1,name=nveconfig-cisco-nx-v1alpha1.kb.io,admissionReviewVersions=v1 + +// NVEConfigCustomValidator struct is responsible for validating the NVEConfig resource +// when it is created, updated, or deleted. +type NVEConfigCustomValidator struct { + Client client.Client +} + +var _ webhook.CustomValidator = &NVEConfigCustomValidator{} + +// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type NVEConfig. +func (v *NVEConfigCustomValidator) ValidateCreate(_ context.Context, obj runtime.Object) (admission.Warnings, error) { + vc, ok := obj.(*v1alpha1.NVEConfig) + if !ok { + return nil, fmt.Errorf("expected a NVEConfig object but got %T", obj) + } + vclog.Info("Validation for NVEConfig upon creation", "name", vc.GetName()) + + return nil, validateNVEConfigSpec(vc) +} + +// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type NVEConfig. +func (v *NVEConfigCustomValidator) ValidateUpdate(_ context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + vc, ok := newObj.(*v1alpha1.NVEConfig) + + if !ok { + return nil, fmt.Errorf("expected a NVEConfig object for the newObj but got %T", newObj) + } + vclog.Info("Validation for NVEConfig upon update", "name", vc.GetName()) + + return nil, validateNVEConfigSpec(vc) +} + +// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type NVEConfig. +func (v *NVEConfigCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + _, ok := obj.(*v1alpha1.NVEConfig) + if !ok { + return nil, fmt.Errorf("expected a NVEConfig object but got %T", obj) + } + return nil, nil +} + +const maxTotalVLANs = 512 + +type rng struct { + start uint + end uint +} + +// validateNVEConfigSpec performs validation to enforce that the VLAN ranges +// - are strictly non overlapping +// - the number of vlans configured does not exceed 512 +// - the IDs must be in the range 1-3967 +func validateNVEConfigSpec(vc *v1alpha1.NVEConfig) error { + if vc.Spec.InfraVLANs == nil { + return nil + } + + var vlanRanges []rng + for _, item := range vc.Spec.InfraVLANs { + start, end := uint(item.ID), uint(item.ID) + if item.ID == 0 { + start = uint(item.RangeMin) + end = uint(item.RangeMax) + } + if end < start { + return fmt.Errorf("range end < start in (%d-%d)", start, end) + } + + vlanRanges = append(vlanRanges, rng{start: start, end: end}) + } + + slices.SortFunc(vlanRanges, func(i, j rng) int { return cmp.Compare(i.start, j.start) }) + currVLANs := (vlanRanges[0].end - vlanRanges[0].start + 1) + for i := 1; i < len(vlanRanges); i++ { + prev := vlanRanges[i-1] + cur := vlanRanges[i] + if cur.start <= prev.end { + return fmt.Errorf("overlapping vlan ranges (%d-%d) and (%d-%d)", prev.start, prev.end, cur.start, cur.end) + } + currVLANs += (cur.end - cur.start + 1) + if currVLANs > maxTotalVLANs { + return fmt.Errorf("total number of vlans exceeds maximum of %d", maxTotalVLANs) + } + } + return nil +} diff --git a/internal/webhook/cisco/nx/v1alpha1/nveconfig_webhook_test.go b/internal/webhook/cisco/nx/v1alpha1/nveconfig_webhook_test.go new file mode 100644 index 00000000..312469a0 --- /dev/null +++ b/internal/webhook/cisco/nx/v1alpha1/nveconfig_webhook_test.go @@ -0,0 +1,120 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 +package v1alpha1 + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" +) + +var _ = Describe("NVEConfig Webhook", func() { + var ( + obj *nxv1alpha1.NVEConfig + oldObj *nxv1alpha1.NVEConfig + validator NVEConfigCustomValidator + ) + + BeforeEach(func() { + obj = &nxv1alpha1.NVEConfig{ + Spec: nxv1alpha1.NVEConfigSpec{ + InfraVLANs: []nxv1alpha1.VLANListItem{ + {ID: 10}, + {RangeMin: 20, RangeMax: 25}, + {RangeMin: 100, RangeMax: 110}, + }, + }, + } + oldObj = obj.DeepCopy() + validator = NVEConfigCustomValidator{} + }) + + Context("ValidateCreate InfraVLANs", func() { + It("accepts single VLAN via ID", func() { + obj.Spec.InfraVLANs = []nxv1alpha1.VLANListItem{{ID: 100}} + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).NotTo(HaveOccurred()) + }) + + It("accepts multiple non-overlapping ranges and single IDs", func() { + obj.Spec.InfraVLANs = []nxv1alpha1.VLANListItem{ + {RangeMin: 1, RangeMax: 10}, + {RangeMin: 20, RangeMax: 30}, + {ID: 40}, + {RangeMin: 50, RangeMax: 60}, + } + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).NotTo(HaveOccurred()) + }) + + It("rejects overlapping ranges (shared boundary)", func() { + obj.Spec.InfraVLANs = []nxv1alpha1.VLANListItem{ + {RangeMin: 10, RangeMax: 20}, + {RangeMin: 20, RangeMax: 30}, + } + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).To(HaveOccurred()) + }) + + It("rejects overlapping ID inside a range", func() { + obj.Spec.InfraVLANs = []nxv1alpha1.VLANListItem{ + {RangeMin: 10, RangeMax: 20}, + {ID: 15}, + } + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).To(HaveOccurred()) + }) + + It("rejects total VLAN count > 512", func() { + // 1-400 plus 401-600 totals 600 + obj.Spec.InfraVLANs = []nxv1alpha1.VLANListItem{ + {RangeMin: 1, RangeMax: 400}, + {RangeMin: 401, RangeMax: 600}, + } + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("ValidateUpdate InfraVLANs", func() { + It("allows unchanged valid config", func() { + newObj := oldObj.DeepCopy() + _, err := validator.ValidateUpdate(ctx, oldObj, newObj) + Expect(err).NotTo(HaveOccurred()) + }) + + It("rejects newly introduced overlap", func() { + newObj := oldObj.DeepCopy() + newObj.Spec.InfraVLANs = []nxv1alpha1.VLANListItem{ + {RangeMin: 1, RangeMax: 10}, + {RangeMin: 11, RangeMax: 20}, + {RangeMin: 15, RangeMax: 25}, + } + _, err := validator.ValidateUpdate(ctx, oldObj, newObj) + Expect(err).To(HaveOccurred()) + }) + + It("rejects update causing total VLAN count overflow", func() { + newObj := oldObj.DeepCopy() + newObj.Spec.InfraVLANs = []nxv1alpha1.VLANListItem{ + {RangeMin: 1, RangeMax: 300}, + {RangeMin: 301, RangeMax: 650}, + } + _, err := validator.ValidateUpdate(ctx, oldObj, newObj) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("ValidateDelete", func() { + It("allows delete on NVEConfig object", func() { + _, err := validator.ValidateDelete(ctx, obj) + Expect(err).NotTo(HaveOccurred()) + }) + + It("rejects delete when object type is wrong", func() { + _, err := validator.ValidateDelete(ctx, &nxv1alpha1.NVEConfigList{}) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/internal/webhook/cisco/nx/v1alpha1/webhook_suite_test.go b/internal/webhook/cisco/nx/v1alpha1/webhook_suite_test.go new file mode 100644 index 00000000..b7551e96 --- /dev/null +++ b/internal/webhook/cisco/nx/v1alpha1/webhook_suite_test.go @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "os" + "path/filepath" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" + // +kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var ( + ctx context.Context + cancel context.CancelFunc + k8sClient client.Client + cfg *rest.Config + testEnv *envtest.Environment +) + +func TestAPIs(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Webhook Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + var err error + err = v1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:scheme + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: false, + WebhookInstallOptions: envtest.WebhookInstallOptions{ + Paths: []string{filepath.Join("..", "..", "..", "..", "..", "config", "webhook")}, + }, + } + + // Retrieve the first found binary directory to allow running tests from IDEs + if getFirstFoundEnvTestBinaryDir() != "" { + testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() + } + + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + + // start webhook server using Manager. + webhookInstallOptions := &testEnv.WebhookInstallOptions + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + WebhookServer: webhook.NewServer(webhook.Options{ + Host: webhookInstallOptions.LocalServingHost, + Port: webhookInstallOptions.LocalServingPort, + CertDir: webhookInstallOptions.LocalServingCertDir, + }), + LeaderElection: false, + Metrics: metricsserver.Options{BindAddress: "0"}, + }) + Expect(err).NotTo(HaveOccurred()) + + err = SetupNVEConfigWebhookWithManager(mgr) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:webhook + + go func() { + defer GinkgoRecover() + err = mgr.Start(ctx) + Expect(err).NotTo(HaveOccurred()) + }() + + // wait for the webhook server to get ready. + dialer := &net.Dialer{Timeout: time.Second} + addrPort := fmt.Sprintf("%s:%d", webhookInstallOptions.LocalServingHost, webhookInstallOptions.LocalServingPort) + Eventually(func() error { + // TODO: fix the InsecureSkipVerify + conn, err := tls.DialWithDialer(dialer, "tcp", addrPort, &tls.Config{InsecureSkipVerify: true}) //nolint:gosec + if err != nil { + return err + } + + return conn.Close() + }).Should(Succeed()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + cancel() + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. +// ENVTEST-based tests depend on specific binaries, usually located in paths set by +// controller-runtime. When running tests directly (e.g., via an IDE) without using +// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. +// +// This function streamlines the process by finding the required binaries, similar to +// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are +// properly set up, run 'make setup-envtest' beforehand. +func getFirstFoundEnvTestBinaryDir() string { + basePath := filepath.Join("..", "..", "..", "..", "bin", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + logf.Log.Error(err, "Failed to read directory", "path", basePath) + return "" + } + for _, entry := range entries { + if entry.IsDir() { + return filepath.Join(basePath, entry.Name()) + } + } + return "" +} diff --git a/internal/webhook/core/v1alpha1/nve_webhook.go b/internal/webhook/core/v1alpha1/nve_webhook.go new file mode 100644 index 00000000..0505d0a5 --- /dev/null +++ b/internal/webhook/core/v1alpha1/nve_webhook.go @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + "context" + "fmt" + "net/netip" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +// log is for logging in this package. +var nvelog = logf.Log.WithName("nve-resource") + +// SetupNVEWebhookWithManager registers the webhook for NVE in the manager. +func SetupNVEWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(&v1alpha1.NVE{}). + WithValidator(&NVECustomValidator{mgr.GetClient()}). + Complete() +} + +// +kubebuilder:webhook:path=/validate-networking-metal-ironcore-dev-v1alpha1-nve,mutating=false,failurePolicy=Fail,sideEffects=None,groups=networking.metal.ironcore.dev,resources=nves,verbs=create;update,versions=v1alpha1,name=nve-v1alpha1.kb.io,admissionReviewVersions=v1 + +// NVECustomValidator struct is responsible for validating the NVE resource +// when it is created, updated, or deleted. +type NVECustomValidator struct { + Client client.Client +} + +var _ webhook.CustomValidator = &NVECustomValidator{} + +// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type NVE. +func (v *NVECustomValidator) ValidateCreate(_ context.Context, obj runtime.Object) (admission.Warnings, error) { + nve, ok := obj.(*v1alpha1.NVE) + if !ok { + return nil, fmt.Errorf("expected a NVE object but got %T", obj) + } + nvelog.Info("Validation for NVE upon creation", "name", nve.GetName()) + + return nil, v.validateNVESpec(nve) +} + +// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type NVE. +func (v *NVECustomValidator) ValidateUpdate(_ context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + nve, ok := newObj.(*v1alpha1.NVE) + if !ok { + return nil, fmt.Errorf("expected a NVE object for the newObj but got %T", newObj) + } + nvelog.Info("Validation for NVE upon update", "name", nve.GetName()) + + return nil, v.validateNVESpec(nve) +} + +// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type NVE. +func (v *NVECustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + _, ok := obj.(*v1alpha1.NVE) + if !ok { + return nil, fmt.Errorf("expected a NVE object but got %T", obj) + } + + return nil, nil +} + +// validateNVESpec performs validation of the NVE spec, namely on the MulticastGroups field. +func (v *NVECustomValidator) validateNVESpec(nve *v1alpha1.NVE) error { + if nve.Spec.MulticastGroups == nil { + return nil + } + if nve.Spec.MulticastGroups.L2 != "" { + if ok, err := v.isMulticast(nve.Spec.MulticastGroups.L2); err != nil || !ok { + return fmt.Errorf("%q is not a multicast address", nve.Spec.MulticastGroups.L2) + } + } + if nve.Spec.MulticastGroups.L3 != "" { + if ok, err := v.isMulticast(nve.Spec.MulticastGroups.L3); err != nil || !ok { + return fmt.Errorf("%q is not a multicast address", nve.Spec.MulticastGroups.L3) + } + } + return nil +} + +func (*NVECustomValidator) isMulticast(s string) (bool, error) { + addr, err := netip.ParseAddr(s) + if err != nil || !addr.IsValid() { + return false, fmt.Errorf("%q is not a valid IP addr: %w", s, err) + } + return addr.IsMulticast(), nil +} diff --git a/internal/webhook/core/v1alpha1/nve_webhook_test.go b/internal/webhook/core/v1alpha1/nve_webhook_test.go new file mode 100644 index 00000000..29f52642 --- /dev/null +++ b/internal/webhook/core/v1alpha1/nve_webhook_test.go @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1alpha1 "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +var _ = Describe("NVE Webhook", func() { + var ( + obj *corev1alpha1.NVE + oldObj *corev1alpha1.NVE + validator NVECustomValidator + ) + + BeforeEach(func() { + obj = &corev1alpha1.NVE{ + Spec: corev1alpha1.NVESpec{ + DeviceRef: corev1alpha1.LocalObjectReference{Name: "leaf1"}, + AdminState: corev1alpha1.AdminStateUp, + SourceInterfaceRef: corev1alpha1.LocalObjectReference{Name: "lo0"}, + AnycastSourceInterfaceRef: &corev1alpha1.LocalObjectReference{Name: "lo1"}, + SuppressARP: true, + HostReachability: corev1alpha1.HostReachabilityTypeFloodAndLearn, + }, + } + oldObj = &corev1alpha1.NVE{} + validator = NVECustomValidator{} + Expect(validator).NotTo(BeNil(), "Expected validator to be initialized") + Expect(oldObj).NotTo(BeNil(), "Expected oldObj to be initialized") + Expect(obj).NotTo(BeNil(), "Expected obj to be initialized") + }) + + Context("ValidateCreate MulticastGroup", func() { + It("accepts nil multicastGroup", func() { + obj.Spec.MulticastGroups = nil + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).ToNot(HaveOccurred()) + }) + + It("accepts valid IPv4 multicast address", func() { + obj.Spec.MulticastGroups = &corev1alpha1.MulticastGroups{ + L2: "239.1.1.1", + } + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).ToNot(HaveOccurred()) + }) + + It("rejects non-multicast IPv4 address", func() { + obj.Spec.MulticastGroups = &corev1alpha1.MulticastGroups{ + L3: "10.0.0.1", + } + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).To(HaveOccurred()) + }) + }) + + Context("Validate Update MulticastGroup IPv4 prefix", func() { + It("allows unchanged valid multicastGroup", func() { + oldObj := obj.DeepCopy() + oldObj.Spec.MulticastGroups = &corev1alpha1.MulticastGroups{ + L2: "239.10.10.1", + } + newObj := oldObj.DeepCopy() + _, err := validator.ValidateUpdate(ctx, oldObj, newObj) + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Context("ValidateDelete", func() { + It("allows delete on NVE object", func() { + _, err := validator.ValidateDelete(ctx, obj) + Expect(err).ToNot(HaveOccurred()) + }) + + It("rejects delete when object type is wrong", func() { + _, err := validator.ValidateDelete(ctx, &corev1alpha1.NVEList{}) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/internal/webhook/core/v1alpha1/webhook_suite_test.go b/internal/webhook/core/v1alpha1/webhook_suite_test.go index daed7d9e..e0aa6971 100644 --- a/internal/webhook/core/v1alpha1/webhook_suite_test.go +++ b/internal/webhook/core/v1alpha1/webhook_suite_test.go @@ -104,6 +104,9 @@ var _ = BeforeSuite(func() { err = SetupPrefixSetWebhookWithManager(mgr) Expect(err).NotTo(HaveOccurred()) + err = SetupNVEWebhookWithManager(mgr) + Expect(err).NotTo(HaveOccurred()) + // +kubebuilder:scaffold:webhook go func() { From 6aca84c72e40bea91ef8ae81bad3a97c8147ce4a Mon Sep 17 00:00:00 2001 From: Pujol Date: Wed, 31 Dec 2025 14:15:45 +0100 Subject: [PATCH 2/2] Fix govet lint error: shadowing declaration --- internal/webhook/core/v1alpha1/prefixset_webhook_test.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/webhook/core/v1alpha1/prefixset_webhook_test.go b/internal/webhook/core/v1alpha1/prefixset_webhook_test.go index 9d79f614..2ac7962d 100644 --- a/internal/webhook/core/v1alpha1/prefixset_webhook_test.go +++ b/internal/webhook/core/v1alpha1/prefixset_webhook_test.go @@ -4,8 +4,6 @@ package v1alpha1 import ( - "context" - . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -15,14 +13,12 @@ import ( var _ = Describe("PrefixSet Webhook", func() { var ( - ctx context.Context obj *v1alpha1.PrefixSet oldObj *v1alpha1.PrefixSet validator PrefixSetCustomValidator ) BeforeEach(func() { - ctx = context.Background() obj = &v1alpha1.PrefixSet{ ObjectMeta: metav1.ObjectMeta{ Name: "test-prefix-set",