diff --git a/PROJECT b/PROJECT index a9fdbac0..b0c6bf28 100644 --- a/PROJECT +++ b/PROJECT @@ -230,4 +230,27 @@ resources: kind: BorderGateway path: github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: networking.metal.ironcore.dev + kind: NetworkVirtualizationEdge + 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: NetworkVirtualizationEdgeConfig + 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 cd21eb70..46a72133 100644 --- a/Tiltfile +++ b/Tiltfile @@ -111,6 +111,12 @@ k8s_resource(new_name='bgp-import-policy', objects=['bgp-import-policy:routingpo k8s_yaml('./config/samples/cisco/nx/v1alpha1_vpcdomain.yaml') k8s_resource(new_name='vpcdomain', objects=['leaf1-vpcdomain:vpcdomain', 'leaf1-vrfvpckeepalive:vrf', 'eth1-30:interface', 'eth1-31:interface','eth1-32:interface', 'po1:interface'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False) +k8s_yaml('./config/samples/v1alpha1_nve.yaml') +k8s_resource(new_name='nve1', objects=['nve1:networkvirtualizationedge'], trigger_mode=TRIGGER_MODE_MANUAL, resource_deps=['lo0', 'lo1'], auto_init=False) +# Uncomment the following lines and edit the above spec to add NXOS provider-specific config to the NetworkVirtualizationEdge resource +# k8s_yaml('./config/samples/cisco/nx/v1alpha1_nveconfig.yaml') +# k8s_resource(new_name='nve1-cfg', objects=['nve1-cfg:networkvirtualizationedgeconfig'], 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/nveconfig_types.go b/api/cisco/nx/v1alpha1/nveconfig_types.go new file mode 100644 index 00000000..5fbfbdb0 --- /dev/null +++ b/api/cisco/nx/v1alpha1/nveconfig_types.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 ( + 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=networkvirtualizationedgeconfigs,verbs=get;list;watch + +// NetworkVirtualizationEdgeConfig defines the Cisco-specific configuration of a Network Virtualization Edge (NVE) object. +type NetworkVirtualizationEdgeConfigSpec 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 int16 `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 int16 `json:"id,omitempty"` + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=3967 + RangeMin int16 `json:"rangeMin,omitempty"` + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=3967 + RangeMax int16 `json:"rangeMax,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:path=networkvirtualizationedgeconfigs +// +kubebuilder:resource:singular=networkvirtualizationedgeconfig +// +kubebuilder:resource:shortName=nveconfig + +// NetworkVirtualizationEdgeConfig is the Schema for the NetworkVirtualizationEdgeConfig API +type NetworkVirtualizationEdgeConfig struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty,omitzero"` + + // spec defines the desired state of NVE + // +required + Spec NetworkVirtualizationEdgeConfigSpec `json:"spec"` +} + +// +kubebuilder:object:root=true + +// NetworkVirtualizationEdgeConfigList contains a list of NetworkVirtualizationEdgeConfigs +type NetworkVirtualizationEdgeConfigList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []NetworkVirtualizationEdgeConfig `json:"items"` +} + +// init registers the NetworkVirtualizationEdgeConfig type with the core v1alpha1 scheme and sets +// itself as a dependency for the NetworkVirtualizationEdge core type. +func init() { + v1alpha1.RegisterNetworkVirtualizationEdgeDependency(GroupVersion.WithKind("NetworkVirtualizationEdgeConfig")) + SchemeBuilder.Register(&NetworkVirtualizationEdgeConfig{}, &NetworkVirtualizationEdgeConfigList{}) +} diff --git a/api/cisco/nx/v1alpha1/zz_generated.deepcopy.go b/api/cisco/nx/v1alpha1/zz_generated.deepcopy.go index 17a861d4..3202026f 100644 --- a/api/cisco/nx/v1alpha1/zz_generated.deepcopy.go +++ b/api/cisco/nx/v1alpha1/zz_generated.deepcopy.go @@ -300,6 +300,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 *NetworkVirtualizationEdgeConfig) DeepCopyInto(out *NetworkVirtualizationEdgeConfig) { + *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 NetworkVirtualizationEdgeConfig. +func (in *NetworkVirtualizationEdgeConfig) DeepCopy() *NetworkVirtualizationEdgeConfig { + if in == nil { + return nil + } + out := new(NetworkVirtualizationEdgeConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NetworkVirtualizationEdgeConfig) 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 *NetworkVirtualizationEdgeConfigList) DeepCopyInto(out *NetworkVirtualizationEdgeConfigList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]NetworkVirtualizationEdgeConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkVirtualizationEdgeConfigList. +func (in *NetworkVirtualizationEdgeConfigList) DeepCopy() *NetworkVirtualizationEdgeConfigList { + if in == nil { + return nil + } + out := new(NetworkVirtualizationEdgeConfigList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NetworkVirtualizationEdgeConfigList) 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 *NetworkVirtualizationEdgeConfigSpec) DeepCopyInto(out *NetworkVirtualizationEdgeConfigSpec) { + *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 NetworkVirtualizationEdgeConfigSpec. +func (in *NetworkVirtualizationEdgeConfigSpec) DeepCopy() *NetworkVirtualizationEdgeConfigSpec { + if in == nil { + return nil + } + out := new(NetworkVirtualizationEdgeConfigSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Peer) DeepCopyInto(out *Peer) { *out = *in @@ -452,6 +530,21 @@ func (in *SystemStatus) DeepCopy() *SystemStatus { 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 +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *VPCDomain) DeepCopyInto(out *VPCDomain) { *out = *in diff --git a/api/core/v1alpha1/device_types_test.go b/api/core/v1alpha1/device_types_test.go index 8274dee7..281cc975 100644 --- a/api/core/v1alpha1/device_types_test.go +++ b/api/core/v1alpha1/device_types_test.go @@ -17,12 +17,12 @@ func TestDevice_GetActiveProvisioning(t *testing.T) { tests := []struct { name string - device *Device + device Device want *ProvisioningInfo }{ { name: "no provisioning entries", - device: &Device{ + device: Device{ Status: DeviceStatus{ Provisioning: []ProvisioningInfo{}, }, @@ -31,7 +31,7 @@ func TestDevice_GetActiveProvisioning(t *testing.T) { }, { name: "single active provisioning entry", - device: &Device{ + device: Device{ Status: DeviceStatus{ Provisioning: []ProvisioningInfo{ { @@ -48,7 +48,7 @@ func TestDevice_GetActiveProvisioning(t *testing.T) { }, { name: "single completed provisioning entry", - device: &Device{ + device: Device{ Status: DeviceStatus{ Provisioning: []ProvisioningInfo{ { @@ -63,7 +63,7 @@ func TestDevice_GetActiveProvisioning(t *testing.T) { }, { name: "multiple completed provisioning entries", - device: &Device{ + device: Device{ Status: DeviceStatus{ Provisioning: []ProvisioningInfo{ { @@ -83,7 +83,7 @@ func TestDevice_GetActiveProvisioning(t *testing.T) { }, { name: "active provisioning", - device: &Device{ + device: Device{ Status: DeviceStatus{ Provisioning: []ProvisioningInfo{ { @@ -116,6 +116,7 @@ func TestDevice_GetActiveProvisioning(t *testing.T) { if got == nil { t.Fatalf("GetActiveProvisioning() = nil, want non-nil") + return } if got.Token != tt.want.Token { @@ -138,13 +139,13 @@ func TestDevice_CreateProvisioningEntry(t *testing.T) { tests := []struct { name string - device *Device + device Device wantErr bool expectedEntries int }{ { name: "successful creation in provisioning phase with no existing entries", - device: &Device{ + device: Device{ Status: DeviceStatus{ Phase: DevicePhaseProvisioning, Provisioning: []ProvisioningInfo{}, @@ -155,7 +156,7 @@ func TestDevice_CreateProvisioningEntry(t *testing.T) { }, { name: "successful creation with completed provisioning entries", - device: &Device{ + device: Device{ Status: DeviceStatus{ Phase: DevicePhaseProvisioning, Provisioning: []ProvisioningInfo{ @@ -172,7 +173,7 @@ func TestDevice_CreateProvisioningEntry(t *testing.T) { }, { name: "error when device is in pending phase", - device: &Device{ + device: Device{ Status: DeviceStatus{ Phase: DevicePhasePending, Provisioning: []ProvisioningInfo{}, @@ -183,7 +184,7 @@ func TestDevice_CreateProvisioningEntry(t *testing.T) { }, { name: "error when active provisioning already exists", - device: &Device{ + device: Device{ Status: DeviceStatus{ Phase: DevicePhaseProvisioning, Provisioning: []ProvisioningInfo{ @@ -202,12 +203,14 @@ func TestDevice_CreateProvisioningEntry(t *testing.T) { entry, err := tt.device.CreateProvisioningEntry() if (err != nil) != tt.wantErr { t.Fatalf("CreateProvisioningEntry() error = %v, wantErr %v", err, tt.wantErr) + return } if tt.wantErr { return } if entry == nil { t.Fatal("expected non-nil entry") + return } if len(tt.device.Status.Provisioning) != tt.expectedEntries { t.Errorf("expected %d provisioning entries, got %d", tt.expectedEntries, len(tt.device.Status.Provisioning)) diff --git a/api/core/v1alpha1/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go index ce6f3563..bbc11480 100644 --- a/api/core/v1alpha1/groupversion_info.go +++ b/api/core/v1alpha1/groupversion_info.go @@ -117,6 +117,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. @@ -154,3 +157,9 @@ const ( // BGPPeerNotFoundReason indicates that a referenced BGPPeer was not found. BGPPeerNotFoundReason = "BGPPeerNotFound" ) + +// Reasons that are specific to [NetworkVirtualizationEdge] objects. +const ( + // NVEAlreadyExistsReason indicates that another NetworkVirtualizationEdge already exists on the same device. + NVEAlreadyExistsReason = "NetworkVirtualizationEdgeAlreadyExists" +) diff --git a/api/core/v1alpha1/nve_types.go b/api/core/v1alpha1/nve_types.go new file mode 100644 index 00000000..5af5406b --- /dev/null +++ b/api/core/v1alpha1/nve_types.go @@ -0,0 +1,190 @@ +// 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" +) + +// NetworkVirtualizationEdgeSpec defines the desired state of a Network Virtualization Edge (NVE). +// +kubebuilder:validation:XValidation:rule="!has(self.anycastSourceInterfaceRef) || self.anycastSourceInterfaceRef.name != self.sourceInterfaceRef.name",message="anycastSourceInterfaceRef.name must differ from sourceInterfaceRef.name" +type NetworkVirtualizationEdgeSpec 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"` +} + +// NetworkVirtualizationEdgeStatus defines the observed state of the NVE. +type NetworkVirtualizationEdgeStatus 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=networkvirtualizationedges +// +kubebuilder:resource:singular=networkvirtualizationedge +// +kubebuilder:resource:shortName=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" + +// NetworkVirtualizationEdge is the Schema for the networkvirtualizationedges API +// The NVE resource is the equivalent to an Endpoint for a Network Virtualization Overlay Object in OpenConfig (`nvo:Ep`). +type NetworkVirtualizationEdge struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty,omitzero"` + + // +required + Spec NetworkVirtualizationEdgeSpec `json:"spec"` + + // +optional + Status NetworkVirtualizationEdgeStatus `json:"status,omitempty,omitzero"` +} + +// GetConditions implements conditions.Getter. +func (in *NetworkVirtualizationEdge) GetConditions() []metav1.Condition { + return in.Status.Conditions +} + +// SetConditions implements conditions.Setter. +func (in *NetworkVirtualizationEdge) SetConditions(conditions []metav1.Condition) { + in.Status.Conditions = conditions +} + +// +kubebuilder:object:root=true + +// NetworkVirtualizationEdgeList contains a list of NetworkVirtualizationEdges +type NetworkVirtualizationEdgeList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []NetworkVirtualizationEdge `json:"items"` +} + +var ( + NetworkVirtualizationEdgeDependencies []schema.GroupVersionKind + networkVirtualizationEdgeDependenciesMu sync.Mutex +) + +// RegisterNetworkVirtualizationEdgeDependency 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 RegisterNetworkVirtualizationEdgeDependency(gvk schema.GroupVersionKind) { + networkVirtualizationEdgeDependenciesMu.Lock() + defer networkVirtualizationEdgeDependenciesMu.Unlock() + NetworkVirtualizationEdgeDependencies = append(NetworkVirtualizationEdgeDependencies, gvk) +} + +func init() { + SchemeBuilder.Register(&NetworkVirtualizationEdge{}, &NetworkVirtualizationEdgeList{}) +} diff --git a/api/core/v1alpha1/ref_types.go b/api/core/v1alpha1/ref_types.go index 9a1aefdc..bff3830d 100644 --- a/api/core/v1alpha1/ref_types.go +++ b/api/core/v1alpha1/ref_types.go @@ -39,7 +39,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 f4fb2326..025027bd 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 *BFD) DeepCopyInto(out *BFD) { *out = *in @@ -1852,6 +1867,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 @@ -1989,6 +2019,124 @@ func (in *NameServer) DeepCopy() *NameServer { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkVirtualizationEdge) DeepCopyInto(out *NetworkVirtualizationEdge) { + *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 NetworkVirtualizationEdge. +func (in *NetworkVirtualizationEdge) DeepCopy() *NetworkVirtualizationEdge { + if in == nil { + return nil + } + out := new(NetworkVirtualizationEdge) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NetworkVirtualizationEdge) 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 *NetworkVirtualizationEdgeList) DeepCopyInto(out *NetworkVirtualizationEdgeList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]NetworkVirtualizationEdge, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NetworkVirtualizationEdgeList. +func (in *NetworkVirtualizationEdgeList) DeepCopy() *NetworkVirtualizationEdgeList { + if in == nil { + return nil + } + out := new(NetworkVirtualizationEdgeList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *NetworkVirtualizationEdgeList) 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 *NetworkVirtualizationEdgeSpec) DeepCopyInto(out *NetworkVirtualizationEdgeSpec) { + *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 NetworkVirtualizationEdgeSpec. +func (in *NetworkVirtualizationEdgeSpec) DeepCopy() *NetworkVirtualizationEdgeSpec { + if in == nil { + return nil + } + out := new(NetworkVirtualizationEdgeSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NetworkVirtualizationEdgeStatus) DeepCopyInto(out *NetworkVirtualizationEdgeStatus) { + *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 NetworkVirtualizationEdgeStatus. +func (in *NetworkVirtualizationEdgeStatus) DeepCopy() *NetworkVirtualizationEdgeStatus { + if in == nil { + return nil + } + out := new(NetworkVirtualizationEdgeStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OSPF) DeepCopyInto(out *OSPF) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index 35ea8589..c23324c6 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 ) @@ -439,6 +440,18 @@ func main() { os.Exit(1) } + if err := (&corecontroller.NetworkVirtualizationEdgeReconciler{ + 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", "NetworkVirtualizationEdge") + os.Exit(1) + } + if err := (&nxcontroller.SystemReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), @@ -519,6 +532,16 @@ func main() { setupLog.Error(err, "unable to create webhook", "webhook", "BGPPeer") os.Exit(1) } + + if err := webhookv1alpha1.SetupNetworkVirtualizationEdgeWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "NetworkVirtualizationEdge") + os.Exit(1) + } + + if err := webhooknxv1alpha1.SetupNetworkVirtualizationEdgeConfigWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "NetworkVirtualizationEdgeConfig") + os.Exit(1) + } } // +kubebuilder:scaffold:builder diff --git a/config/crd/bases/networking.metal.ironcore.dev_accesscontrollists.yaml b/config/crd/bases/networking.metal.ironcore.dev_accesscontrollists.yaml index e9e02e7f..27b62b94 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_accesscontrollists.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_accesscontrollists.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: accesscontrollists.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_banners.yaml b/config/crd/bases/networking.metal.ironcore.dev_banners.yaml index 6a519b63..67b28d08 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_banners.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_banners.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: banners.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_bgp.yaml b/config/crd/bases/networking.metal.ironcore.dev_bgp.yaml index 8a826d4b..6554c686 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_bgp.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_bgp.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: bgp.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_bgppeers.yaml b/config/crd/bases/networking.metal.ironcore.dev_bgppeers.yaml index cbd6ed6e..ca06be8c 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_bgppeers.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_bgppeers.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: bgppeers.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_certificates.yaml b/config/crd/bases/networking.metal.ironcore.dev_certificates.yaml index fe733a01..281d8d11 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_certificates.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_certificates.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: certificates.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_devices.yaml b/config/crd/bases/networking.metal.ironcore.dev_devices.yaml index 7003cc2c..7bbf292b 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_devices.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_devices.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: devices.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_dns.yaml b/config/crd/bases/networking.metal.ironcore.dev_dns.yaml index ca176ed7..5f1c0f37 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_dns.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_dns.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: dns.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_evpninstances.yaml b/config/crd/bases/networking.metal.ironcore.dev_evpninstances.yaml index b56e292e..3e58e108 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_evpninstances.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_evpninstances.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: evpninstances.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml b/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml index 9284fee0..d5482194 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: interfaces.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_isis.yaml b/config/crd/bases/networking.metal.ironcore.dev_isis.yaml index 543295b9..db0bcceb 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_isis.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_isis.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: isis.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_managementaccesses.yaml b/config/crd/bases/networking.metal.ironcore.dev_managementaccesses.yaml index 3df1c466..14ee4fe7 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_managementaccesses.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_managementaccesses.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: managementaccesses.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_networkvirtualizationedges.yaml b/config/crd/bases/networking.metal.ironcore.dev_networkvirtualizationedges.yaml new file mode 100644 index 00000000..59622833 --- /dev/null +++ b/config/crd/bases/networking.metal.ironcore.dev_networkvirtualizationedges.yaml @@ -0,0 +1,309 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: networkvirtualizationedges.networking.metal.ironcore.dev +spec: + group: networking.metal.ironcore.dev + names: + kind: NetworkVirtualizationEdge + listKind: NetworkVirtualizationEdgeList + plural: networkvirtualizationedges + shortNames: + - nve + singular: networkvirtualizationedge + 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: |- + NetworkVirtualizationEdge is the Schema for the networkvirtualizationedges API + The NVE resource is the equivalent to an Endpoint for a Network Virtualization Overlay Object in OpenConfig (`nvo:Ep`). + 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: NetworkVirtualizationEdgeSpec defines the desired state of + a Network Virtualization Edge (NVE). + 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: NetworkVirtualizationEdgeStatus defines the observed state + of the 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/networking.metal.ironcore.dev_ntp.yaml b/config/crd/bases/networking.metal.ironcore.dev_ntp.yaml index 0e83abf5..f7e515c3 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_ntp.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_ntp.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: ntp.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_ospf.yaml b/config/crd/bases/networking.metal.ironcore.dev_ospf.yaml index 6c4958d8..f2e00807 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_ospf.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_ospf.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: ospf.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_pim.yaml b/config/crd/bases/networking.metal.ironcore.dev_pim.yaml index b71f6e2c..d3209418 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_pim.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_pim.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: pim.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_prefixsets.yaml b/config/crd/bases/networking.metal.ironcore.dev_prefixsets.yaml index 9639705b..56889994 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_prefixsets.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_prefixsets.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: prefixsets.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_routingpolicies.yaml b/config/crd/bases/networking.metal.ironcore.dev_routingpolicies.yaml index f89d2372..7d1e27c5 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_routingpolicies.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_routingpolicies.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: routingpolicies.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_snmp.yaml b/config/crd/bases/networking.metal.ironcore.dev_snmp.yaml index 0e93b1c6..32d6bf1c 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_snmp.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_snmp.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: snmp.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_syslogs.yaml b/config/crd/bases/networking.metal.ironcore.dev_syslogs.yaml index d70fe42a..a6779a87 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_syslogs.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_syslogs.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: syslogs.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_users.yaml b/config/crd/bases/networking.metal.ironcore.dev_users.yaml index 93b26c65..3c1c450c 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_users.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_users.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: users.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_vlans.yaml b/config/crd/bases/networking.metal.ironcore.dev_vlans.yaml index 04178f8a..a1999a0f 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_vlans.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_vlans.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: vlans.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/networking.metal.ironcore.dev_vrfs.yaml b/config/crd/bases/networking.metal.ironcore.dev_vrfs.yaml index de2e693a..540a3597 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_vrfs.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_vrfs.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: vrfs.networking.metal.ironcore.dev spec: group: networking.metal.ironcore.dev diff --git a/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_bordergateways.yaml b/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_bordergateways.yaml index 5a68eb76..30817039 100644 --- a/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_bordergateways.yaml +++ b/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_bordergateways.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: bordergateways.nx.cisco.networking.metal.ironcore.dev spec: group: nx.cisco.networking.metal.ironcore.dev diff --git a/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_managementaccessconfigs.yaml b/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_managementaccessconfigs.yaml index 8baf5feb..8bebfe2c 100644 --- a/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_managementaccessconfigs.yaml +++ b/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_managementaccessconfigs.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: managementaccessconfigs.nx.cisco.networking.metal.ironcore.dev spec: group: nx.cisco.networking.metal.ironcore.dev diff --git a/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_networkvirtualizationedgeconfigs.yaml b/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_networkvirtualizationedgeconfigs.yaml new file mode 100644 index 00000000..1db7bf76 --- /dev/null +++ b/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_networkvirtualizationedgeconfigs.yaml @@ -0,0 +1,94 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: networkvirtualizationedgeconfigs.nx.cisco.networking.metal.ironcore.dev +spec: + group: nx.cisco.networking.metal.ironcore.dev + names: + kind: NetworkVirtualizationEdgeConfig + listKind: NetworkVirtualizationEdgeConfigList + plural: networkvirtualizationedgeconfigs + shortNames: + - nveconfig + singular: networkvirtualizationedgeconfig + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: NetworkVirtualizationEdgeConfig is the Schema for the NetworkVirtualizationEdgeConfig + 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/bases/nx.cisco.networking.metal.ironcore.dev_systems.yaml b/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_systems.yaml index 76eddf09..3ccfcbf8 100644 --- a/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_systems.yaml +++ b/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_systems.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: systems.nx.cisco.networking.metal.ironcore.dev spec: group: nx.cisco.networking.metal.ironcore.dev diff --git a/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_vpcdomains.yaml b/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_vpcdomains.yaml index 5f5319b1..b470ff3b 100644 --- a/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_vpcdomains.yaml +++ b/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_vpcdomains.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.0 + controller-gen.kubebuilder.io/version: v0.19.0 name: vpcdomains.nx.cisco.networking.metal.ironcore.dev spec: group: nx.cisco.networking.metal.ironcore.dev diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index dd00131c..876b5825 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -2,31 +2,33 @@ # since it depends on service name and namespace that are out of this kustomize package. # It should be run by config/default resources: -- bases/networking.metal.ironcore.dev_devices.yaml -- bases/networking.metal.ironcore.dev_interfaces.yaml -- bases/networking.metal.ironcore.dev_banners.yaml -- bases/networking.metal.ironcore.dev_users.yaml -- bases/networking.metal.ironcore.dev_dns.yaml -- bases/networking.metal.ironcore.dev_ntp.yaml - bases/networking.metal.ironcore.dev_accesscontrollists.yaml -- bases/networking.metal.ironcore.dev_certificates.yaml -- bases/networking.metal.ironcore.dev_snmp.yaml -- bases/networking.metal.ironcore.dev_syslogs.yaml -- bases/networking.metal.ironcore.dev_managementaccesses.yaml -- bases/networking.metal.ironcore.dev_isis.yaml -- bases/networking.metal.ironcore.dev_vrfs.yaml -- bases/networking.metal.ironcore.dev_pim.yaml +- bases/networking.metal.ironcore.dev_banners.yaml - bases/networking.metal.ironcore.dev_bgp.yaml - bases/networking.metal.ironcore.dev_bgppeers.yaml -- bases/networking.metal.ironcore.dev_ospf.yaml -- bases/networking.metal.ironcore.dev_vlans.yaml +- bases/networking.metal.ironcore.dev_certificates.yaml +- bases/networking.metal.ironcore.dev_devices.yaml +- bases/networking.metal.ironcore.dev_dns.yaml - bases/networking.metal.ironcore.dev_evpninstances.yaml +- bases/networking.metal.ironcore.dev_interfaces.yaml +- bases/networking.metal.ironcore.dev_isis.yaml +- bases/networking.metal.ironcore.dev_managementaccesses.yaml +- bases/networking.metal.ironcore.dev_networkvirtualizationedges.yaml +- bases/networking.metal.ironcore.dev_ntp.yaml +- bases/networking.metal.ironcore.dev_ospf.yaml +- bases/networking.metal.ironcore.dev_pim.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/networking.metal.ironcore.dev_snmp.yaml +- bases/networking.metal.ironcore.dev_syslogs.yaml +- bases/networking.metal.ironcore.dev_users.yaml +- bases/networking.metal.ironcore.dev_vlans.yaml +- bases/networking.metal.ironcore.dev_vrfs.yaml +- bases/nx.cisco.networking.metal.ironcore.dev_bordergateways.yaml - bases/nx.cisco.networking.metal.ironcore.dev_managementaccessconfigs.yaml +- bases/nx.cisco.networking.metal.ironcore.dev_networkvirtualizationedgeconfigs.yaml +- bases/nx.cisco.networking.metal.ironcore.dev_systems.yaml - bases/nx.cisco.networking.metal.ironcore.dev_vpcdomains.yaml -- bases/nx.cisco.networking.metal.ironcore.dev_bordergateways.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 bf596db5..f1650b69 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -22,78 +22,85 @@ 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. -- device_admin_role.yaml -- device_editor_role.yaml -- device_viewer_role.yaml -- interface_admin_role.yaml -- interface_editor_role.yaml -- interface_viewer_role.yaml +- accesscontrollist_admin_role.yaml +- accesscontrollist_editor_role.yaml +- accesscontrollist_viewer_role.yaml - banner_admin_role.yaml - banner_editor_role.yaml - banner_viewer_role.yaml -- user_admin_role.yaml -- user_editor_role.yaml -- user_viewer_role.yaml +- bgp_admin_role.yaml +- bgp_editor_role.yaml +- bgp_viewer_role.yaml +- bgppeer_admin_role.yaml +- bgppeer_editor_role.yaml +- bgppeer_viewer_role.yaml +- certificate_admin_role.yaml +- certificate_editor_role.yaml +- certificate_viewer_role.yaml +- device_admin_role.yaml +- device_editor_role.yaml +- device_viewer_role.yaml - dns_admin_role.yaml - dns_editor_role.yaml - dns_viewer_role.yaml +- evpninstance_admin_role.yaml +- evpninstance_editor_role.yaml +- evpninstance_viewer_role.yaml +- interface_admin_role.yaml +- interface_editor_role.yaml +- interface_viewer_role.yaml +- isis_admin_role.yaml +- isis_editor_role.yaml +- isis_viewer_role.yaml +- managementaccess_admin_role.yaml +- managementaccess_editor_role.yaml +- managementaccess_viewer_role.yaml +- nve_admin_role.yaml +- nve_editor_role.yaml +- nve_viewer_role.yaml - ntp_admin_role.yaml - ntp_editor_role.yaml - ntp_viewer_role.yaml -- accesscontrollist_admin_role.yaml -- accesscontrollist_editor_role.yaml -- accesscontrollist_viewer_role.yaml -- certificate_admin_role.yaml -- certificate_editor_role.yaml -- certificate_viewer_role.yaml +- ospf_admin_role.yaml +- ospf_editor_role.yaml +- ospf_viewer_role.yaml +- pim_admin_role.yaml +- pim_editor_role.yaml +- pim_viewer_role.yaml +- prefixset_admin_role.yaml +- prefixset_editor_role.yaml +- prefixset_viewer_role.yaml +- routingpolicy_admin_role.yaml +- routingpolicy_editor_role.yaml +- routingpolicy_viewer_role.yaml - snmp_admin_role.yaml - snmp_editor_role.yaml - snmp_viewer_role.yaml - syslog_admin_role.yaml - syslog_editor_role.yaml - syslog_viewer_role.yaml -- managementaccess_admin_role.yaml -- managementaccess_editor_role.yaml -- managementaccess_viewer_role.yaml -- isis_admin_role.yaml -- isis_editor_role.yaml -- isis_viewer_role.yaml -- vrf_admin_role.yaml -- vrf_editor_role.yaml -- vrf_viewer_role.yaml -- pim_admin_role.yaml -- pim_editor_role.yaml -- pim_viewer_role.yaml -- bgp_admin_role.yaml -- bgp_editor_role.yaml -- bgp_viewer_role.yaml -- bgppeer_admin_role.yaml -- bgppeer_editor_role.yaml -- bgppeer_viewer_role.yaml -- ospf_admin_role.yaml -- ospf_editor_role.yaml -- ospf_viewer_role.yaml +- user_admin_role.yaml +- user_editor_role.yaml +- user_viewer_role.yaml - vlan_admin_role.yaml - vlan_editor_role.yaml - vlan_viewer_role.yaml -- cisco/nx/system_admin_role.yaml -- cisco/nx/system_editor_role.yaml -- cisco/nx/system_viewer_role.yaml +- vrf_admin_role.yaml +- vrf_editor_role.yaml +- vrf_viewer_role.yaml +# The following RBAC configurations apply to Cisco NX specific CRDs +- cisco/nx/bordergateway_admin_role.yaml +- cisco/nx/bordergateway_editor_role.yaml +- cisco/nx/bordergateway_viewer_role.yaml - cisco/nx/managementaccessconfig_admin_role.yaml - cisco/nx/managementaccessconfig_editor_role.yaml - cisco/nx/managementaccessconfig_viewer_role.yaml +- cisco/nx/nveconfig_admin_role.yaml +- cisco/nx/nveconfig_editor_role.yaml +- cisco/nx/nveconfig_viewer_role.yaml +- cisco/nx/system_admin_role.yaml +- cisco/nx/system_editor_role.yaml +- cisco/nx/system_viewer_role.yaml - cisco/nx/vpcdomain_admin_role.yaml - cisco/nx/vpcdomain_editor_role.yaml - cisco/nx/vpcdomain_viewer_role.yaml -- evpninstance_admin_role.yaml -- evpninstance_editor_role.yaml -- evpninstance_viewer_role.yaml -- prefixset_admin_role.yaml -- prefixset_editor_role.yaml -- prefixset_viewer_role.yaml -- routingpolicy_admin_role.yaml -- routingpolicy_editor_role.yaml -- routingpolicy_viewer_role.yaml -- cisco/nx/bordergateway_admin_role.yaml -- cisco/nx/bordergateway_editor_role.yaml -- cisco/nx/bordergateway_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 9aaa36ff..3ab55f80 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -42,6 +42,7 @@ rules: - interfaces - isis - managementaccesses + - networkvirtualizationedges - ntp - ospf - pim @@ -74,6 +75,7 @@ rules: - interfaces/finalizers - isis/finalizers - managementaccesses/finalizers + - networkvirtualizationedges/finalizers - ntp/finalizers - ospf/finalizers - pim/finalizers @@ -100,6 +102,7 @@ rules: - interfaces/status - isis/status - managementaccesses/status + - networkvirtualizationedges/status - ntp/status - ospf/status - pim/status @@ -150,6 +153,7 @@ rules: - nx.cisco.networking.metal.ironcore.dev resources: - managementaccessconfigs + - networkvirtualizationedgeconfigs 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..625500b0 --- /dev/null +++ b/config/samples/cisco/nx/v1alpha1_nveconfig.yaml @@ -0,0 +1,14 @@ +apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 +kind: NetworkVirtualizationEdgeConfig +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 6a236e4e..60d0063f 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -19,10 +19,12 @@ resources: - v1alpha1_ospf.yaml - v1alpha1_vlan.yaml - v1alpha1_evi.yaml +- v1alpha1_nve.yaml - v1alpha1_prefixset.yaml - v1alpha1_routingpolicy.yaml -- cisco/nx/v1alpha1_system.yaml +- cisco/nx/v1alpha1_bordergateway.yaml - cisco/nx/v1alpha1_managementaccessconfig.yaml +- cisco/nx/v1alpha1_nveconfig.yaml +- cisco/nx/v1alpha1_system.yaml - cisco/nx/v1alpha1_vpcdomain.yaml -- cisco/nx/v1alpha1_bordergateway.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/v1alpha1_interface.yaml b/config/samples/v1alpha1_interface.yaml index 4852ea7d..1418c0f2 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..dd2dc4c1 --- /dev/null +++ b/config/samples/v1alpha1_nve.yaml @@ -0,0 +1,26 @@ +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: NetworkVirtualizationEdge +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: NetworkVirtualizationEdgeConfig + # 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 5453a7c7..a51ca1d7 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-networkvirtualizationedgeconfig + failurePolicy: Fail + name: networkvirtualizationedgeconfig-cisco-nx-v1alpha1.kb.io + rules: + - apiGroups: + - nx.cisco.networking.metal.ironcore.dev + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - networkvirtualizationedgeconfigs + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/internal/controller/cisco/nx/suite_test.go b/internal/controller/cisco/nx/suite_test.go index b03a813e..3e2cc4bc 100644 --- a/internal/controller/cisco/nx/suite_test.go +++ b/internal/controller/cisco/nx/suite_test.go @@ -26,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" "github.com/ironcore-dev/network-operator/internal/provider/cisco/nxos" @@ -46,7 +47,7 @@ var ( func TestControllers(t *testing.T) { RegisterFailHandler(Fail) - RunSpecs(t, "Cisco NXOS Controller Suite") + RunSpecs(t, "Cisco NX Controller Suite") } var _ = BeforeSuite(func() { @@ -123,6 +124,15 @@ var _ = BeforeSuite(func() { }).SetupWithManager(ctx, k8sManager) Expect(err).NotTo(HaveOccurred()) + err = (&corecontroller.NetworkVirtualizationEdgeReconciler{ + 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) @@ -151,7 +161,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) @@ -168,9 +178,10 @@ func detectTestBinaryDir() string { type MockProvider struct { sync.Mutex + BorderGateway *nxv1alpha1.BorderGateway + NVE *v1alpha1.NetworkVirtualizationEdge Settings *nxv1alpha1.System VPCDomain *nxv1alpha1.VPCDomain - BorderGateway *nxv1alpha1.BorderGateway } var _ Provider = (*MockProvider)(nil) @@ -234,3 +245,32 @@ func (p *MockProvider) ResetBorderGatewaySettings(ctx context.Context) error { p.BorderGateway = nil return nil } + +func (p *MockProvider) EnsureNVE(_ context.Context, req *provider.NVERequest) error { + p.Lock() + defer p.Unlock() + p.NVE = req.NVE + 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/nve_controller.go b/internal/controller/core/nve_controller.go new file mode 100644 index 00000000..643b48cf --- /dev/null +++ b/internal/controller/core/nve_controller.go @@ -0,0 +1,501 @@ +// 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" + + "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" +) + +// NetworkVirtualizationEdgeReconciler reconciles a NVE object +type NetworkVirtualizationEdgeReconciler 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=networkvirtualizationedges,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=networkvirtualizationedges/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=networkvirtualizationedges/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 *NetworkVirtualizationEdgeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, reterr error) { + log := ctrl.LoggerFrom(ctx) + log.Info("Reconciling resource") + + obj := new(v1alpha1.NetworkVirtualizationEdge) + 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.NetworkVirtualizationEdge + Connection *deviceutil.Connection + ProviderConfig *provider.ProviderConfig + Provider provider.NVEProvider +} + +func (r *NetworkVirtualizationEdgeReconciler) 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.validateUniqueNVEPerDevice(ctx, s); 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 +} + +func (r *NetworkVirtualizationEdgeReconciler) validateUniqueNVEPerDevice(ctx context.Context, s *nveScope) error { + var list v1alpha1.NetworkVirtualizationEdgeList + if err := r.List(ctx, &list, + client.InNamespace(s.NVE.Namespace), + client.MatchingFields{".spec.deviceRef.name": s.NVE.Spec.DeviceRef.Name}, + ); err != nil { + return err + } + for _, nve := range list.Items { + if nve.Name != s.NVE.Name { + conditions.Set(s.NVE, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.NVEAlreadyExistsReason, + Message: fmt.Sprintf("Another NVE (%s) already exists for device %s", nve.Name, s.NVE.Spec.DeviceRef.Name), + }) + return reconcile.TerminalError(fmt.Errorf("only one NVE is allowed per device (%s)", s.NVE.Spec.DeviceRef.Name)) + } + } + return nil +} + +// validateInterfaceRef checks that the referenced interface exists, is of type Loopback, and belongs to the same device as the NVE. +func (r *NetworkVirtualizationEdgeReconciler) 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("referenced interface %q not found", s.NVE.Spec.SourceInterfaceRef.Name)) + } + return nil, fmt.Errorf("failed to get 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 *NetworkVirtualizationEdgeReconciler) validateProviderConfigRef(_ 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 API version %q: %w", s.NVE.Spec.ProviderConfigRef.APIVersion, err)) + } + + if found := slices.Contains(v1alpha1.NetworkVirtualizationEdgeDependencies, 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)) + } + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *NetworkVirtualizationEdgeReconciler) 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) + } + + // Index NVEs by their DeviceRef.name for uniqueness checks. + if err := mgr.GetFieldIndexer().IndexField(ctx, &v1alpha1.NetworkVirtualizationEdge{}, ".spec.deviceRef.name", func(obj client.Object) []string { + vpc := obj.(*v1alpha1.NetworkVirtualizationEdge) + return []string{vpc.Spec.DeviceRef.Name} + }); err != nil { + return err + } + + c := ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.NetworkVirtualizationEdge{}). + 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.NetworkVirtualizationEdgeDependencies { + 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 *NetworkVirtualizationEdgeReconciler) mapProviderConfigToNVEs(ctx context.Context, obj client.Object) []reconcile.Request { + log := ctrl.LoggerFrom(ctx, "Object", klog.KObj(obj)) + + list := &v1alpha1.NetworkVirtualizationEdgeList{} + 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 *NetworkVirtualizationEdgeReconciler) 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.NetworkVirtualizationEdgeList{} + 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 *NetworkVirtualizationEdgeReconciler) 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..6eddde8e --- /dev/null +++ b/internal/controller/core/nve_controller_test.go @@ -0,0 +1,667 @@ +// 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" +) + +const ( + defaultTimeout = 5 * time.Second + defaultPoll = 150 * time.Millisecond + testEndpointAddr = "192.168.10.2:9339" +) + +// Helpers +func ensureDevice(deviceKey client.ObjectKey, spec v1alpha1.DeviceSpec) { + d := &v1alpha1.Device{} + if err := k8sClient.Get(ctx, deviceKey, d); errors.IsNotFound(err) { + d = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{Name: deviceKey.Name, Namespace: deviceKey.Namespace}, + Spec: spec, + } + Expect(k8sClient.Create(ctx, d)).To(Succeed()) + } else { + Expect(err).NotTo(HaveOccurred()) + } +} + +func ensureInterface(ns, deviceName, ifName string, ifType v1alpha1.InterfaceType) *v1alpha1.Interface { + key := client.ObjectKey{Name: ifName, Namespace: ns} + ifObj := &v1alpha1.Interface{} + if err := k8sClient.Get(ctx, key, ifObj); errors.IsNotFound(err) { + ifObj = &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{Name: ifName, Namespace: ns}, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Name: ifName, + Type: ifType, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, ifObj)).To(Succeed()) + } else { + Expect(err).NotTo(HaveOccurred()) + } + return ifObj +} + +func ensureInterfaces(deviceName string, names []string, ifType v1alpha1.InterfaceType) { + for _, name := range names { + ensureInterface(metav1.NamespaceDefault, deviceName, name, ifType) + } +} + +func ensureNVE(nveKey client.ObjectKey, spec v1alpha1.NetworkVirtualizationEdgeSpec) *v1alpha1.NetworkVirtualizationEdge { + n := &v1alpha1.NetworkVirtualizationEdge{} + if err := k8sClient.Get(ctx, nveKey, n); errors.IsNotFound(err) { + n = &v1alpha1.NetworkVirtualizationEdge{ + ObjectMeta: metav1.ObjectMeta{Name: nveKey.Name, Namespace: nveKey.Namespace}, + Spec: spec, + } + Expect(k8sClient.Create(ctx, n)).To(Succeed()) + } else { + Expect(err).NotTo(HaveOccurred()) + } + return n +} + +func cleanupNVEResources(nveKeys, interfaceKeys, deviceKeys []client.ObjectKey) { + By("Cleaning up created resources") + for _, nveKey := range nveKeys { + nve := &v1alpha1.NetworkVirtualizationEdge{} + Expect(k8sClient.Get(ctx, nveKey, nve)).NotTo(HaveOccurred()) + Expect(k8sClient.Delete(ctx, nve)).To(Succeed()) + Eventually(func() bool { + return errors.IsNotFound(k8sClient.Get(ctx, nveKey, &v1alpha1.NetworkVirtualizationEdge{})) + }, defaultTimeout, defaultPoll).Should(BeTrue(), "NVE should be fully deleted") + } + + for _, ifKey := range interfaceKeys { + ifObj := &v1alpha1.Interface{} + Expect(k8sClient.Get(ctx, ifKey, ifObj)).NotTo(HaveOccurred()) + Expect(k8sClient.Delete(ctx, ifObj)).To(Succeed()) + Eventually(func() bool { + return errors.IsNotFound(k8sClient.Get(ctx, ifKey, &v1alpha1.Interface{})) + }, defaultTimeout, defaultPoll).Should(BeTrue(), "Interface should be fully deleted") + } + + for _, deviceKey := range deviceKeys { + device := &v1alpha1.Device{} + Expect(k8sClient.Get(ctx, deviceKey, device)).NotTo(HaveOccurred()) + Expect(k8sClient.Delete(ctx, device)).To(Succeed()) + Eventually(func() bool { + return errors.IsNotFound(k8sClient.Get(ctx, deviceKey, &v1alpha1.Device{})) + }, defaultTimeout, defaultPoll).Should(BeTrue(), "Device should be fully deleted") + } + By("Ensuring the resource is deleted from the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.NVE).To(BeNil(), "Provider NVE should be empty") + }, defaultTimeout, defaultPoll).Should(Succeed()) +} + +var _ = Describe("NVE Controller", func() { + Context("When reconciling a resource", func() { + const ( + deviceName = "test-nve-device" + nveName = "test-nve-nve" + nsName = metav1.NamespaceDefault + ) + var ( + nve *v1alpha1.NetworkVirtualizationEdge + ) + interfaceNames := []string{"lo0", "lo1"} + + nveKey := client.ObjectKey{Name: nveName, Namespace: nsName} + deviceKey := client.ObjectKey{Name: deviceName, Namespace: nsName} + interfaceKeys := make([]client.ObjectKey, len(interfaceNames)) + for i, ifName := range interfaceNames { + interfaceKeys[i] = client.ObjectKey{Name: ifName, Namespace: nsName} + } + + BeforeEach(func() { + By("Creating the custom resource for the Kind Device") + ensureDevice(deviceKey, v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{Address: testEndpointAddr}, + }) + + By("Ensuring loopback interfaces exist") + ensureInterfaces(deviceName, interfaceNames, v1alpha1.InterfaceTypeLoopback) + + By("Creating the custom resource for the Kind NVE") + nve = ensureNVE(nveKey, v1alpha1.NetworkVirtualizationEdgeSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + SuppressARP: true, + HostReachability: "BGP", + SourceInterfaceRef: v1alpha1.LocalObjectReference{Name: interfaceNames[0]}, + AnycastSourceInterfaceRef: &v1alpha1.LocalObjectReference{Name: interfaceNames[1]}, + MulticastGroups: &v1alpha1.MulticastGroups{L2: "234.0.0.1"}, + AdminState: v1alpha1.AdminStateUp, + }) + }) + + AfterEach(func() { + cleanupNVEResources([]client.ObjectKey{nveKey}, interfaceKeys, []client.ObjectKey{deviceKey}) + }) + + It("Should successfully reconcile the resource", func() { + By("Adding a finalizer to the resource") + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, nveKey, 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, nveKey, nve)).To(Succeed()) + g.Expect(nve.Labels).To(HaveKeyWithValue(v1alpha1.DeviceLabel, deviceName)) + }).Should(Succeed()) + + By("Adding the device as a owner reference") + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, nveKey, 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(deviceName)) + }).Should(Succeed()) + + By("Updating the resource status") + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, nveKey, 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: nsName}, primary)).To(Succeed()) + g.Expect(primary.Spec.Type).To(Equal(v1alpha1.InterfaceTypeLoopback)) + g.Expect(primary.Spec.DeviceRef.Name).To(Equal(deviceName)) + + anycast := &v1alpha1.Interface{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: nve.Spec.AnycastSourceInterfaceRef.Name, Namespace: nsName}, anycast)).To(Succeed()) + g.Expect(anycast.Spec.Type).To(Equal(v1alpha1.InterfaceTypeLoopback)) + g.Expect(anycast.Spec.DeviceRef.Name).To(Equal(deviceName)) + 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.NetworkVirtualizationEdge{} + g.Expect(k8sClient.Get(ctx, nveKey, 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 ( + deviceName = "test-nvewithrefupdates-device" + nveName = "test-nvewithrefupdates-nve" + nsName = metav1.NamespaceDefault + ) + var ( + nve *v1alpha1.NetworkVirtualizationEdge + ) + + interfaceNames := []string{"lo10", "lo11", "lo12"} + deviceKey := client.ObjectKey{Name: deviceName, Namespace: nsName} + nveKey := client.ObjectKey{Name: nveName, Namespace: nsName} + interfaceKeys := make([]client.ObjectKey, len(interfaceNames)) + for i, ifName := range interfaceNames { + interfaceKeys[i] = client.ObjectKey{Name: ifName, Namespace: nsName} + } + + BeforeEach(func() { + By("Creating the custom resource for the Kind Device") + ensureDevice(deviceKey, v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{Address: testEndpointAddr}, + }) + + By("Ensuring loopback interfaces exist") + ensureInterfaces(deviceName, interfaceNames, v1alpha1.InterfaceTypeLoopback) + + By("Creating the custom resource for the Kind NVE") + nve = &v1alpha1.NetworkVirtualizationEdge{} + if err := k8sClient.Get(ctx, nveKey, nve); errors.IsNotFound(err) { + nve = &v1alpha1.NetworkVirtualizationEdge{ + ObjectMeta: metav1.ObjectMeta{ + Name: nveName, + Namespace: nsName, + }, + Spec: v1alpha1.NetworkVirtualizationEdgeSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + SuppressARP: true, + HostReachability: "BGP", + SourceInterfaceRef: v1alpha1.LocalObjectReference{Name: interfaceNames[0]}, + MulticastGroups: &v1alpha1.MulticastGroups{ + L2: "234.0.0.1", + }, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, nve)).To(Succeed()) + } + }) + + AfterEach(func() { + cleanupNVEResources([]client.ObjectKey{nveKey}, interfaceKeys, []client.ObjectKey{deviceKey}) + }) + + It("Should reconcile when SourceInterfaceRef is changed", func() { + By("Patching NVE: SourceInterfaceRef") + patch := client.MergeFrom(nve.DeepCopy()) + nve.Spec.SourceInterfaceRef = v1alpha1.LocalObjectReference{Name: interfaceNames[1]} + Expect(k8sClient.Patch(ctx, nve, patch)).To(Succeed()) + + By("Verifying reconciliation modifies provider and status") + Eventually(func(g Gomega) { + g.Expect(testProvider.NVE).ToNot(BeNil()) + g.Expect(testProvider.NVE.Spec.SourceInterfaceRef.Name).To(Equal(interfaceNames[1])) + g.Expect(testProvider.NVE.Status.SourceInterfaceName).To(Equal(interfaceNames[1])) + }, defaultTimeout, defaultPoll).Should(Succeed()) + }) + + It("Should reconcile when AnycastSourceInterfaceRef is added", func() { + By("Patching NVE: AnycastSourceInterfaceRef") + patch := client.MergeFrom(nve.DeepCopy()) + nve.Spec.AnycastSourceInterfaceRef = &v1alpha1.LocalObjectReference{Name: interfaceNames[2]} + Expect(k8sClient.Patch(ctx, nve, patch)).To(Succeed()) + + By("Verifying reconciliation modifies 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(interfaceNames[2])) + g.Expect(testProvider.NVE.Status.AnycastSourceInterfaceName).To(Equal(interfaceNames[2])) + } + }, 5*time.Second, 100*time.Millisecond).Should(Succeed()) + }) + }) + + Context("When source interface is missing", func() { + const ( + deviceName = "test-nvemissingif-device" + nveName = "test-nvemissingif-nve" + nsName = metav1.NamespaceDefault + ) + deviceKey := client.ObjectKey{Name: deviceName, Namespace: nsName} + nveKey := client.ObjectKey{Name: nveName, Namespace: nsName} + + BeforeEach(func() { + By("Creating device only (no interfaces)") + ensureDevice(deviceKey, v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{Address: testEndpointAddr}, + }) + + By("Creating an NVE object with a reference to a non-existent interface") + _ = ensureNVE(nveKey, v1alpha1.NetworkVirtualizationEdgeSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + SuppressARP: true, + HostReachability: "BGP", + SourceInterfaceRef: v1alpha1.LocalObjectReference{Name: "lo-missing"}, + AdminState: v1alpha1.AdminStateUp, + }) + }) + + AfterEach(func() { + cleanupNVEResources([]client.ObjectKey{nveKey}, nil, []client.ObjectKey{deviceKey}) + }) + + It("Should set Configured=False with WaitingForDependenciesReason", func() { + Eventually(func(g Gomega) { + cur := &v1alpha1.NetworkVirtualizationEdge{} + g.Expect(k8sClient.Get(ctx, nveKey, cur)).To(Succeed()) + cond := meta.FindStatusCondition(cur.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(v1alpha1.WaitingForDependenciesReason)) + }, defaultTimeout, defaultPoll).Should(Succeed()) + }) + }) + + Context("When AnycastSourceInterfaceRef is omitted", func() { + const ( + deviceName = "test-nve-anycast-omit-device" + nveName = "test-nve-anycast-omit" + nsName = metav1.NamespaceDefault + ) + interfaceNames := []string{"lo30"} + deviceKey := client.ObjectKey{Name: deviceName, Namespace: nsName} + nveKey := client.ObjectKey{Name: nveName, Namespace: nsName} + interfaceKeys := []client.ObjectKey{{Name: interfaceNames[0], Namespace: nsName}} + + BeforeEach(func() { + ensureDevice(deviceKey, v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{Address: testEndpointAddr}, + }) + ensureInterfaces(deviceName, interfaceNames, v1alpha1.InterfaceTypeLoopback) + + _ = ensureNVE(nveKey, v1alpha1.NetworkVirtualizationEdgeSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + SuppressARP: true, + HostReachability: "BGP", + SourceInterfaceRef: v1alpha1.LocalObjectReference{Name: interfaceNames[0]}, + AdminState: v1alpha1.AdminStateUp, + // AnycastSourceInterfaceRef: nil, + }) + }) + + AfterEach(func() { + cleanupNVEResources([]client.ObjectKey{nveKey}, interfaceKeys, []client.ObjectKey{deviceKey}) + }) + + It("Should reconcile with nil anycast and empty status AnycastSourceInterfaceName", func() { + Eventually(func(g Gomega) { + g.Expect(testProvider.NVE).NotTo(BeNil()) + g.Expect(testProvider.NVE.Spec.AnycastSourceInterfaceRef).To(BeNil()) + }, defaultTimeout, defaultPoll).Should(Succeed()) + + Eventually(func(g Gomega) { + cur := &v1alpha1.NetworkVirtualizationEdge{} + g.Expect(k8sClient.Get(ctx, nveKey, cur)).To(Succeed()) + g.Expect(cur.Status.AnycastSourceInterfaceName).To(BeEmpty()) + cfg := meta.FindStatusCondition(cur.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cfg).NotTo(BeNil()) + g.Expect(cfg.Status).To(Equal(metav1.ConditionTrue)) + }, defaultTimeout, defaultPoll).Should(Succeed()) + }) + }) + + Context("When creating more than one NVE per device", func() { + const ( + deviceName = "test-nve-uniqueness-device" + nve1Name = "test-nve-uniqueness-1" + nve2Name = "test-nve-uniqueness-2" + nsName = metav1.NamespaceDefault + ) + interfaceNames := []string{"lo40", "lo41"} + deviceKey := client.ObjectKey{Name: deviceName, Namespace: nsName} + nve1Key := client.ObjectKey{Name: nve1Name, Namespace: nsName} + nve2Key := client.ObjectKey{Name: nve2Name, Namespace: nsName} + interfaceKeys := []client.ObjectKey{ + {Name: interfaceNames[0], Namespace: nsName}, + {Name: interfaceNames[1], Namespace: nsName}, + } + + BeforeEach(func() { + ensureDevice(deviceKey, v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{Address: testEndpointAddr}, + }) + ensureInterfaces(deviceName, interfaceNames, v1alpha1.InterfaceTypeLoopback) + + _ = ensureNVE(nve1Key, v1alpha1.NetworkVirtualizationEdgeSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + SuppressARP: true, + HostReachability: "BGP", + SourceInterfaceRef: v1alpha1.LocalObjectReference{Name: interfaceNames[0]}, + AdminState: v1alpha1.AdminStateUp, + }) + _ = ensureNVE(nve2Key, v1alpha1.NetworkVirtualizationEdgeSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + SuppressARP: true, + HostReachability: "BGP", + SourceInterfaceRef: v1alpha1.LocalObjectReference{Name: interfaceNames[1]}, + AdminState: v1alpha1.AdminStateUp, + }) + }) + + AfterEach(func() { + cleanupNVEResources([]client.ObjectKey{nve1Key, nve2Key}, interfaceKeys, []client.ObjectKey{deviceKey}) + }) + + It("Should set Configured=False with NVEAlreadyExistsReason on the second NVE", func() { + Eventually(func(g Gomega) { + cur := &v1alpha1.NetworkVirtualizationEdge{} + g.Expect(k8sClient.Get(ctx, nve2Key, cur)).To(Succeed()) + cond := meta.FindStatusCondition(cur.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(v1alpha1.NVEAlreadyExistsReason)) + }, defaultTimeout, defaultPoll).Should(Succeed()) + }) + }) + + Context("When using erroneous interface references (non loopback type)", func() { + const ( + deviceName = "test-nvemisconfigurediftype-device" + nveName = "test-nvemisconfigurediftype-nve" + nsName = metav1.NamespaceDefault + ) + var ( + nve *v1alpha1.NetworkVirtualizationEdge + ) + + interfaceNames := []string{"eth1", "eth2"} + + deviceKey := client.ObjectKey{Name: deviceName, Namespace: nsName} + nveKey := client.ObjectKey{Name: nveName, Namespace: nsName} + interfaceKeys := make([]client.ObjectKey, len(interfaceNames)) + for i, ifName := range interfaceNames { + interfaceKeys[i] = client.ObjectKey{Name: ifName, Namespace: nsName} + } + + BeforeEach(func() { + By("Creating the custom resource for the Kind Device") + ensureDevice(deviceKey, v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{Address: testEndpointAddr}, + }) + + By("Ensuring loopback interfaces with wrong type exist") + ensureInterfaces(deviceName, interfaceNames, v1alpha1.InterfaceTypePhysical) + + By("Creating the custom resource for the Kind NetworkVirtualizationEdge") + nve = &v1alpha1.NetworkVirtualizationEdge{} + if err := k8sClient.Get(ctx, nveKey, nve); errors.IsNotFound(err) { + nve = &v1alpha1.NetworkVirtualizationEdge{ + ObjectMeta: metav1.ObjectMeta{ + Name: nveName, + Namespace: nsName, + }, + Spec: v1alpha1.NetworkVirtualizationEdgeSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + SuppressARP: true, + HostReachability: "BGP", + SourceInterfaceRef: v1alpha1.LocalObjectReference{Name: interfaceNames[0]}, + AnycastSourceInterfaceRef: &v1alpha1.LocalObjectReference{Name: interfaceNames[1]}, + MulticastGroups: &v1alpha1.MulticastGroups{ + L2: "234.0.0.1", + }, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, nve)).To(Succeed()) + } + }) + + AfterEach(func() { + cleanupNVEResources([]client.ObjectKey{nveKey}, interfaceKeys, []client.ObjectKey{deviceKey}) + }) + + It("Should set Configured=False with InvalidInterfaceTypeReason", func() { + Eventually(func(g Gomega) { + current := &v1alpha1.NetworkVirtualizationEdge{} + g.Expect(k8sClient.Get(ctx, nveKey, 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 ( + deviceName = "test-nvemisconfiguredcrossdevice-device" + device2Name = "test-nvemisconfiguredcrossdevice-device2" // device for interface reference + nveName = "test-nvemisconfiguredcrossdevice-nve" + nsName = metav1.NamespaceDefault + ) + + var ( + nve *v1alpha1.NetworkVirtualizationEdge + deviceKey, nveKey client.ObjectKey + interfaceKeys []client.ObjectKey + ) + + interfaceNames := []string{"lo2", "lo3"} + deviceKey = client.ObjectKey{Name: deviceName, Namespace: nsName} + nveKey = client.ObjectKey{Name: nveName, Namespace: nsName} + interfaceKeys = make([]client.ObjectKey, len(interfaceNames)) + for i, ifName := range interfaceNames { + interfaceKeys[i] = client.ObjectKey{Name: ifName, Namespace: nsName} + } + + BeforeEach(func() { + By("Creating the custom resource for the Kind Device") + ensureDevice(deviceKey, v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{Address: testEndpointAddr}, + }) + + By("Ensuring loopback interfaces with created on a different device") + By("Ensuring loopback interfaces exist") + ensureInterfaces(device2Name, interfaceNames, v1alpha1.InterfaceTypeLoopback) + + By("Creating the custom resource for the Kind NetworkVirtualizationEdge") + nve = &v1alpha1.NetworkVirtualizationEdge{} + if err := k8sClient.Get(ctx, nveKey, nve); errors.IsNotFound(err) { + nve = &v1alpha1.NetworkVirtualizationEdge{ + ObjectMeta: metav1.ObjectMeta{ + Name: nveName, + Namespace: nsName, + }, + Spec: v1alpha1.NetworkVirtualizationEdgeSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + SuppressARP: true, + HostReachability: "BGP", + SourceInterfaceRef: v1alpha1.LocalObjectReference{Name: interfaceNames[0]}, + AnycastSourceInterfaceRef: &v1alpha1.LocalObjectReference{Name: interfaceNames[1]}, + MulticastGroups: &v1alpha1.MulticastGroups{ + L2: "234.0.0.1", + }, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, nve)).To(Succeed()) + } + }) + + AfterEach(func() { + cleanupNVEResources([]client.ObjectKey{nveKey}, interfaceKeys, []client.ObjectKey{deviceKey}) + }) + + It("Should set Configured=False with CrossDeviceReferenceReason", func() { + Eventually(func(g Gomega) { + nve := &v1alpha1.NetworkVirtualizationEdge{} + g.Expect(k8sClient.Get(ctx, nveKey, nve)).To(Succeed()) + cond := meta.FindStatusCondition(nve.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 ( + deviceName = "test-nvemisconfigured-providerconfigref-device" + nveName = "test-nvemisconfigured-providerconfigref-nve" + nsName = metav1.NamespaceDefault + ) + var ( + nve *v1alpha1.NetworkVirtualizationEdge + deviceKey, nveKey client.ObjectKey + ) + + interfaceNames := []string{"lo6", "lo7", "lo8"} + deviceKey = client.ObjectKey{Name: deviceName, Namespace: nsName} + nveKey = client.ObjectKey{Name: nveName, Namespace: nsName} + interfaceKeys := make([]client.ObjectKey, len(interfaceNames)) + for i, ifName := range interfaceNames { + interfaceKeys[i] = client.ObjectKey{Name: ifName, Namespace: nsName} + } + + BeforeEach(func() { + By("Creating the custom resource for the Kind Device") + ensureDevice(deviceKey, v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{Address: testEndpointAddr}, + }) + + By("Ensuring loopback interfaces exist") + ensureInterfaces(deviceName, interfaceNames, v1alpha1.InterfaceTypeLoopback) + + By("Ensuring an NVE with an invalid providerConfigRef") + nve = &v1alpha1.NetworkVirtualizationEdge{ + ObjectMeta: metav1.ObjectMeta{ + Name: nveName, + Namespace: nsName, + }, + Spec: v1alpha1.NetworkVirtualizationEdgeSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + SuppressARP: true, + HostReachability: "BGP", + SourceInterfaceRef: v1alpha1.LocalObjectReference{Name: interfaceNames[0]}, + AnycastSourceInterfaceRef: &v1alpha1.LocalObjectReference{Name: interfaceNames[1]}, + AdminState: v1alpha1.AdminStateUp, + ProviderConfigRef: &v1alpha1.TypedLocalObjectReference{ + Name: interfaceNames[2], + Kind: "Interface", + APIVersion: "networking.metal.ironcore.dev/v1alpha1", + }, // invalid provider config ref + }, + } + Expect(k8sClient.Create(ctx, nve)).To(Succeed()) + }) + + AfterEach(func() { + cleanupNVEResources([]client.ObjectKey{nveKey}, interfaceKeys, []client.ObjectKey{deviceKey}) + }) + + It("Should set Configured=False and an `IncompatibleProviderConfigRef` reason", func() { + Eventually(func(g Gomega) { + nve := &v1alpha1.NetworkVirtualizationEdge{} + g.Expect(k8sClient.Get(ctx, nveKey, nve)).To(Succeed()) + cond := meta.FindStatusCondition(nve.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)) + }, defaultTimeout, defaultPoll).Should(Succeed()) + }) + }) +}) diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go index b1091814..5003e65c 100644 --- a/internal/controller/core/suite_test.go +++ b/internal/controller/core/suite_test.go @@ -258,6 +258,15 @@ var _ = BeforeSuite(func() { }).SetupWithManager(ctx, k8sManager) Expect(err).NotTo(HaveOccurred()) + err = (&NetworkVirtualizationEdgeReconciler{ + 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(), @@ -340,6 +349,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. @@ -367,6 +377,7 @@ type Provider struct { EVIs sets.Set[int32] PrefixSets sets.Set[string] RoutingPolicies sets.Set[string] + NVE *v1alpha1.NetworkVirtualizationEdge } func NewProvider() *Provider { @@ -743,3 +754,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..c7ba4d00 100644 --- a/internal/provider/cisco/nxos/nve.go +++ b/internal/provider/cisco/nxos/nve.go @@ -4,31 +4,76 @@ package nxos import ( + "encoding/json" "strconv" "github.com/ironcore-dev/network-operator/internal/provider/cisco/gnmiext/v2" ) var _ gnmiext.Configurable = (*NVE)(nil) +var _ gnmiext.Configurable = (*NVEInfraVLANs)(nil) +var _ gnmiext.Configurable = (*FabricFwd)(nil) // NVE represents the Network Virtualization Edge interface (nve1). +// Note: NXOS only supports a single NVE interface with epId=1. type NVE struct { 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=1]" +} + +var ( + _ json.Marshaler = (*NVE)(nil) + _ json.Unmarshaler = (*NVE)(nil) +) + +// MarshalJSON marshals the NVE struct to JSON, adding the fixed epId field with value 1. +func (n NVE) MarshalJSON() ([]byte, error) { + type Copy NVE + cpy := Copy(n) + return json.Marshal(struct { + EpId int32 `json:"epId"` + Copy + }{ + Copy: cpy, + EpId: 1, + }) +} + +// UnmarshalJSON unmarshals JSON data into the NVE struct, ignoring the epId field, which is always 1. +func (n *NVE) UnmarshalJSON(b []byte) error { + type Copy NVE + var aux struct { + EpId *int32 `json:"epId,omitempty"` + Copy + } + if err := json.Unmarshal(b, &aux); err != nil { + return err + } + *n = NVE(aux.Copy) + return nil } type VNI struct { @@ -52,16 +97,51 @@ 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 { + OperSt OperSt `json:"operState"` +} + +func (n *NVEOper) XPath() string { + return "System/eps-items/epId-items/Ep-list[epId=1]" +} + +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..3a545f76 100644 --- a/internal/provider/cisco/nxos/nve_test.go +++ b/internal/provider/cisco/nxos/nve_test.go @@ -5,7 +5,6 @@ package nxos func init() { nve := &NVE{ - ID: 1, AdminSt: AdminStEnabled, HostReach: HostReachBGP, AdvertiseVmac: true, @@ -13,6 +12,7 @@ func init() { AnycastInterface: NewOption("lo1"), SuppressARP: true, McastGroupL2: NewOption("237.0.0.1"), + McastGroupL3: NewOption(""), HoldDownTime: 300, } Register("nve", nve) @@ -22,4 +22,17 @@ func init() { McastGroup: NewOption("239.1.1.100"), } Register("vni", vni) + nveInfraVLANs := &NVEInfraVLANs{ + 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 4010c530..028509e9 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -60,6 +60,7 @@ var ( _ provider.UserProvider = (*Provider)(nil) _ provider.VLANProvider = (*Provider)(nil) _ provider.VRFProvider = (*Provider)(nil) + _ provider.NVEProvider = (*Provider)(nil) ) type Provider struct { @@ -485,32 +486,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) @@ -1339,91 +1314,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 @@ -2570,6 +2460,128 @@ func (p *Provider) ResetBorderGatewaySettings(ctx context.Context) error { return p.client.Delete(ctx, conf...) } +// 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 { + f1 := new(Feature) + f1.Name = "evpn" + f1.AdminSt = AdminStEnabled + + f2 := new(Feature) + f2.Name = "nvo" + f2.AdminSt = AdminStEnabled + + if err := p.client.Patch(ctx, f1, f2); err != nil { + return err + } + + 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.AdminSt = AdminStDisabled + if req.NVE.Spec.AdminState == v1alpha1.AdminStateUp { + n.AdminSt = AdminStEnabled + } + n.SourceInterface = req.SourceInterface.Spec.Name + + if req.AnycastSourceInterface != nil { + n.AnycastInterface = NewOption(req.AnycastSourceInterface.Spec.Name) + } + if req.NVE.Spec.MulticastGroups != nil && req.NVE.Spec.MulticastGroups.L2 != "" { + n.McastGroupL2 = NewOption(req.NVE.Spec.MulticastGroups.L2) + } + 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.NetworkVirtualizationEdgeConfig) + if req.ProviderConfig != nil { + if err := req.ProviderConfig.Into(vc); err != nil { + return fmt.Errorf("failed to decode provider config: %w", err) + } + n.HoldDownTime = uint16(vc.Spec.HoldDownTime) // #nosec G115 -- kubebuilder validation + 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)}) // #nosec G115 -- kubebuilder validation + continue + } + for i := ivList.RangeMin; i <= ivList.RangeMax; i++ { + iv.InfraVLANList = append(iv.InfraVLANList, &NVEInfraVLAN{ID: uint32(i)}) // #nosec G115 -- kubebuilder validation + } + } + + 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) + 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) + 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) + 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 ad7765ae..7170b7c8 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -547,6 +547,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.NetworkVirtualizationEdge + 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..80bc24fe --- /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("networkvirtualizationedgeconfig-resource") + +// SetupNetworkVirtualizationEdgeConfigWebhookWithManager registers the webhook for NetworkVirtualizationEdge in the manager. +func SetupNetworkVirtualizationEdgeConfigWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(&v1alpha1.NetworkVirtualizationEdgeConfig{}). + WithValidator(&NetworkVirtualizationEdgeConfigCustomValidator{Client: mgr.GetClient()}). + Complete() +} + +// +kubebuilder:webhook:path=/validate-nx-cisco-networking-metal-ironcore-dev-v1alpha1-networkvirtualizationedgeconfig,mutating=false,failurePolicy=Fail,sideEffects=None,groups=nx.cisco.networking.metal.ironcore.dev,resources=networkvirtualizationedgeconfigs,verbs=create;update,versions=v1alpha1,name=networkvirtualizationedgeconfig-cisco-nx-v1alpha1.kb.io,admissionReviewVersions=v1 + +// NetworkVirtualizationEdgeConfigCustomValidator struct is responsible for validating the NetworkVirtualizationEdgeConfig resource +// when it is created, updated, or deleted. +type NetworkVirtualizationEdgeConfigCustomValidator struct { + Client client.Client +} + +var _ webhook.CustomValidator = &NetworkVirtualizationEdgeConfigCustomValidator{} + +// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type NetworkVirtualizationEdgeConfig. +func (v *NetworkVirtualizationEdgeConfigCustomValidator) ValidateCreate(_ context.Context, obj runtime.Object) (admission.Warnings, error) { + vc, ok := obj.(*v1alpha1.NetworkVirtualizationEdgeConfig) + if !ok { + return nil, fmt.Errorf("expected a NetworkVirtualizationEdgeConfig object but got %T", obj) + } + vclog.Info("Validation for NetworkVirtualizationEdgeConfig upon creation", "name", vc.GetName()) + + return nil, validateNetworkVirtualizationEdgeConfigSpec(vc) +} + +// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type NetworkVirtualizationEdgeConfig. +func (v *NetworkVirtualizationEdgeConfigCustomValidator) ValidateUpdate(_ context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + vc, ok := newObj.(*v1alpha1.NetworkVirtualizationEdgeConfig) + + if !ok { + return nil, fmt.Errorf("expected a NetworkVirtualizationEdgeConfig object for the newObj but got %T", newObj) + } + vclog.Info("Validation for NetworkVirtualizationEdgeConfig upon update", "name", vc.GetName()) + + return nil, validateNetworkVirtualizationEdgeConfigSpec(vc) +} + +// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type NetworkVirtualizationEdgeConfig. +func (v *NetworkVirtualizationEdgeConfigCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + _, ok := obj.(*v1alpha1.NetworkVirtualizationEdgeConfig) + if !ok { + return nil, fmt.Errorf("expected a NetworkVirtualizationEdgeConfig object but got %T", obj) + } + return nil, nil +} + +const maxTotalVLANs = 512 + +type rng struct { + start int16 + end int16 +} + +// validateNetworkVirtualizationEdgeConfigSpec 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 validateNetworkVirtualizationEdgeConfigSpec(vc *v1alpha1.NetworkVirtualizationEdgeConfig) error { + if vc.Spec.InfraVLANs == nil { + return nil + } + + var vlanRanges []rng + for _, item := range vc.Spec.InfraVLANs { + start, end := item.ID, item.ID + if item.ID == 0 { + start = item.RangeMin + end = 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..47f658ed --- /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("NetworkVirtualizationEdgeConfig Webhook", func() { + var ( + obj *nxv1alpha1.NetworkVirtualizationEdgeConfig + oldObj *nxv1alpha1.NetworkVirtualizationEdgeConfig + validator NetworkVirtualizationEdgeConfigCustomValidator + ) + + BeforeEach(func() { + obj = &nxv1alpha1.NetworkVirtualizationEdgeConfig{ + Spec: nxv1alpha1.NetworkVirtualizationEdgeConfigSpec{ + InfraVLANs: []nxv1alpha1.VLANListItem{ + {ID: 10}, + {RangeMin: 20, RangeMax: 25}, + {RangeMin: 100, RangeMax: 110}, + }, + }, + } + oldObj = obj.DeepCopy() + validator = NetworkVirtualizationEdgeConfigCustomValidator{} + }) + + 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 NetworkVirtualizationEdgeConfig 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.NetworkVirtualizationEdgeConfigList{}) + 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..7d2e0f98 --- /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 = SetupNetworkVirtualizationEdgeConfigWebhookWithManager(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..4c3f788a --- /dev/null +++ b/internal/webhook/core/v1alpha1/nve_webhook.go @@ -0,0 +1,97 @@ +// 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("networkvirtualizationedge-resource") + +// SetupNetworkVirtualizationEdgeWebhookWithManager registers the webhook for NetworkVirtualizationEdge in the manager. +func SetupNetworkVirtualizationEdgeWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(&v1alpha1.NetworkVirtualizationEdge{}). + WithValidator(&NetworkVirtualizationEdgeCustomValidator{mgr.GetClient()}). + Complete() +} + +// +kubebuilder:webhook:path=/validate-networking-metal-ironcore-dev-v1alpha1-networkvirtualizationedge,mutating=false,failurePolicy=Fail,sideEffects=None,groups=networking.metal.ironcore.dev,resources=networkvirtualizationedges,verbs=create;update,versions=v1alpha1,name=networkvirtualizationedge-v1alpha1.kb.io,admissionReviewVersions=v1 +// NetworkVirtualizationEdgeCustomValidator struct is responsible for validating the NetworkVirtualizationEdge resource +// when it is created, updated, or deleted. +type NetworkVirtualizationEdgeCustomValidator struct { + Client client.Client +} + +var _ webhook.CustomValidator = &NetworkVirtualizationEdgeCustomValidator{} + +// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type NetworkVirtualizationEdge. +func (v *NetworkVirtualizationEdgeCustomValidator) ValidateCreate(_ context.Context, obj runtime.Object) (admission.Warnings, error) { + nve, ok := obj.(*v1alpha1.NetworkVirtualizationEdge) + if !ok { + return nil, fmt.Errorf("expected a NetworkVirtualizationEdge object but got %T", obj) + } + nvelog.Info("Validation for NetworkVirtualizationEdge upon creation", "name", nve.GetName()) + + return nil, v.validateNetworkVirtualizationEdgeSpec(nve) +} + +// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type NetworkVirtualizationEdge . +func (v *NetworkVirtualizationEdgeCustomValidator) ValidateUpdate(_ context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + nve, ok := newObj.(*v1alpha1.NetworkVirtualizationEdge) + if !ok { + return nil, fmt.Errorf("expected a NetworkVirtualizationEdge object for the newObj but got %T", newObj) + } + nvelog.Info("Validation for NetworkVirtualizationEdge upon update", "name", nve.GetName()) + + return nil, v.validateNetworkVirtualizationEdgeSpec(nve) +} + +// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type NetworkVirtualizationEdge. +func (v *NetworkVirtualizationEdgeCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + _, ok := obj.(*v1alpha1.NetworkVirtualizationEdge) + if !ok { + return nil, fmt.Errorf("expected a NetworkVirtualizationEdge object but got %T", obj) + } + + return nil, nil +} + +// validateNetworkVirtualizationEdgeSpec performs validation of the NetworkVirtualizationEdge spec, namely on the MulticastGroups field. +func (v *NetworkVirtualizationEdgeCustomValidator) validateNetworkVirtualizationEdgeSpec(nve *v1alpha1.NetworkVirtualizationEdge) 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 (*NetworkVirtualizationEdgeCustomValidator) 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..f5fd1c78 --- /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("NetworkVirtualizationEdge Webhook", func() { + var ( + obj *corev1alpha1.NetworkVirtualizationEdge + oldObj *corev1alpha1.NetworkVirtualizationEdge + validator NetworkVirtualizationEdgeCustomValidator + ) + + BeforeEach(func() { + obj = &corev1alpha1.NetworkVirtualizationEdge{ + Spec: corev1alpha1.NetworkVirtualizationEdgeSpec{ + 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.NetworkVirtualizationEdge{} + validator = NetworkVirtualizationEdgeCustomValidator{} + 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.NetworkVirtualizationEdgeList{}) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/internal/webhook/core/v1alpha1/prefixset_webhook_test.go b/internal/webhook/core/v1alpha1/prefixset_webhook_test.go index 2a8b299e..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" @@ -21,7 +19,6 @@ var _ = Describe("PrefixSet Webhook", func() { ) BeforeEach(func() { - ctx = context.Background() obj = &v1alpha1.PrefixSet{ ObjectMeta: metav1.ObjectMeta{ Name: "test-prefix-set", diff --git a/internal/webhook/core/v1alpha1/webhook_suite_test.go b/internal/webhook/core/v1alpha1/webhook_suite_test.go index daed7d9e..81860031 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 = SetupNetworkVirtualizationEdgeWebhookWithManager(mgr) + Expect(err).NotTo(HaveOccurred()) + // +kubebuilder:scaffold:webhook go func() {