From 2aa88784b48b5edd5bdb49636c83ed01c8ab846b Mon Sep 17 00:00:00 2001 From: Pujol Date: Tue, 24 Mar 2026 18:34:29 +0100 Subject: [PATCH 1/6] [core] Add `DHCPRelay` API type Add core types to configure DHCP relay on L3 interfaces. The DHCPRelay resource is device-scoped (one per device) and references a list of Layer 3 interfaces where DHCP relay should be enabled, along with the DHCP server addresses. Supported interface types are Physical, Aggregate, and RoutedVLAN (Layer 3 SVIs). The controller will validate that all referenced interfaces belong to the same device and are configured with IPv4 addressing. The optional VrfRef field allows specifying which VRF context should be used when forwarding DHCP messages to the servers. --- PROJECT | 8 + Tiltfile | 3 + api/core/v1alpha1/dhcprelay_types.go | 125 +++++++++ api/core/v1alpha1/groupversion_info.go | 6 + api/core/v1alpha1/zz_generated.deepcopy.go | 122 +++++++++ ...prelays.networking.metal.ironcore.dev.yaml | 249 ++++++++++++++++++ ...x.cisco.networking.metal.ironcore.dev.yaml | 1 + ...olicies.networking.metal.ironcore.dev.yaml | 18 +- .../validating-webhook-configuration.yaml | 20 ++ ...working.metal.ironcore.dev_dhcprelays.yaml | 245 +++++++++++++++++ config/crd/kustomization.yaml | 1 + config/samples/kustomization.yaml | 1 + config/samples/v1alpha1_dhcprelay.yaml | 17 ++ docs/api-reference/index.md | 75 +++++- internal/provider/provider.go | 23 ++ 15 files changed, 896 insertions(+), 18 deletions(-) create mode 100644 api/core/v1alpha1/dhcprelay_types.go create mode 100644 charts/network-operator/templates/crd/dhcprelays.networking.metal.ironcore.dev.yaml create mode 100644 config/crd/bases/networking.metal.ironcore.dev_dhcprelays.yaml create mode 100644 config/samples/v1alpha1_dhcprelay.yaml diff --git a/PROJECT b/PROJECT index 920f8beb..24face92 100644 --- a/PROJECT +++ b/PROJECT @@ -281,4 +281,12 @@ resources: kind: BGPConfig 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: DHCPRelay + path: github.com/ironcore-dev/network-operator/api/core/v1alpha1 + version: v1alpha1 version: "3" diff --git a/Tiltfile b/Tiltfile index 1fc8e2ab..1957c2ff 100644 --- a/Tiltfile +++ b/Tiltfile @@ -125,6 +125,9 @@ k8s_resource(new_name='lldp', objects=['leaf1-lldp:lldp'], trigger_mode=TRIGGER_ # k8s_yaml('./config/samples/cisco/nx/v1alpha1_lldpconfig.yaml') # k8s_resource(new_name='lldpconfig', objects=['leaf1-lldpconfig:lldpconfig'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False) +k8s_yaml('./config/samples/v1alpha1_dhcprelay.yaml') +k8s_resource(new_name='dhcprelay', objects=['dhcprelay:dhcprelay'], resource_deps=['eth1-1'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False) + print('🚀 network-operator development environment') print('👉 Edit the code inside the api/, cmd/, or internal/ directories') print('👉 Tilt will automatically rebuild and redeploy when changes are detected') diff --git a/api/core/v1alpha1/dhcprelay_types.go b/api/core/v1alpha1/dhcprelay_types.go new file mode 100644 index 00000000..ab5f41e7 --- /dev/null +++ b/api/core/v1alpha1/dhcprelay_types.go @@ -0,0 +1,125 @@ +// 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" +) + +// DHCPRelaySpec defines the desired state of DHCPRelay. +// Only a single DHCPRelay resource should be created per Device, the controller will reject additional resources of this type with the same DeviceRef. +type DHCPRelaySpec struct { + // DeviceRef is a reference to 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 DHCPRelay. + // If not specified the provider applies the target platform's default settings. + // +optional + ProviderConfigRef *TypedLocalObjectReference `json:"providerConfigRef,omitempty"` + + // VrfRef is an optional reference to the VRF to use when relaying DHCP messages in all referenced interfaces. + // +optional + VrfRef *LocalObjectReference `json:"vrfRef,omitempty"` + + // Servers is a list of DHCP server addresses to which DHCP messages will be relayed. + // Only IPv4 addresses are currently supported. + // +required + // +listType=atomic + // +kubebuilder:validation:items:Format=ipv4 + // +kubebuilder:validation:MinItems=1 + Servers []string `json:"servers"` + + // InterfaceRefs is a list of interfaces + // +required + // +listType=atomic + // +kubebuilder:validation:MinItems=1 + InterfaceRefs []LocalObjectReference `json:"interfaceRefs,omitempty"` +} + +// DHCPRelayStatus defines the observed state of DHCPRelay. +type DHCPRelayStatus 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 DHCPRelay 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 status of each condition is one of True, False, or Unknown. + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + // ConfiguredInterfaces contains the names of Interface resources that have DHCP relay configured as known by the device. + // +optional + // +listType=atomic + ConfiguredInterfaces []string `json:"configuredInterfaces,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:path=dhcprelays +// +kubebuilder:resource:singular=dhcprelay +// +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="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// DHCPRelay is the Schema for the DHCPRelays API +type DHCPRelay struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitzero"` + + // +required + Spec DHCPRelaySpec `json:"spec"` + + // +optional + Status DHCPRelayStatus `json:"status,omitzero"` +} + +// GetConditions implements conditions.Getter. +func (l *DHCPRelay) GetConditions() []metav1.Condition { + return l.Status.Conditions +} + +// SetConditions implements conditions.Setter. +func (l *DHCPRelay) SetConditions(conditions []metav1.Condition) { + l.Status.Conditions = conditions +} + +// +kubebuilder:object:root=true + +// DHCPRelayList contains a list of DHCPRelay +type DHCPRelayList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []DHCPRelay `json:"items"` +} + +var ( + DHCPRelayDependencies []schema.GroupVersionKind + DHCPRelayDependenciesMu sync.Mutex +) + +// RegisterDHCPRelayDependency registers a provider-specific GVK as a dependency of DHCPRelay. +// ProviderConfigs should call this in their init() function to ensure the dependency is registered. +func RegisterDHCPRelayDependency(gvk schema.GroupVersionKind) { + DHCPRelayDependenciesMu.Lock() + defer DHCPRelayDependenciesMu.Unlock() + DHCPRelayDependencies = append(DHCPRelayDependencies, gvk) +} + +func init() { + SchemeBuilder.Register(&DHCPRelay{}, &DHCPRelayList{}) +} diff --git a/api/core/v1alpha1/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go index f5100391..b44e132b 100644 --- a/api/core/v1alpha1/groupversion_info.go +++ b/api/core/v1alpha1/groupversion_info.go @@ -221,3 +221,9 @@ const ( // NVEAlreadyExistsReason indicates that another NetworkVirtualizationEdge already exists on the same device. NVEAlreadyExistsReason = "NetworkVirtualizationEdgeAlreadyExists" ) + +// Reasons that are specific to [DHCPRelay] objects. +const ( + // IPAddressingNotFoundReason indicates that a referenced interface has no IPv4 addresses configured. + IPAddressingNotFoundReason = "IPAddressingNotFound" +) diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go index bff93d77..8cf17fdf 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -955,6 +955,128 @@ func (in *ControlProtocol) DeepCopy() *ControlProtocol { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DHCPRelay) DeepCopyInto(out *DHCPRelay) { + *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 DHCPRelay. +func (in *DHCPRelay) DeepCopy() *DHCPRelay { + if in == nil { + return nil + } + out := new(DHCPRelay) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DHCPRelay) 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 *DHCPRelayList) DeepCopyInto(out *DHCPRelayList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]DHCPRelay, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DHCPRelayList. +func (in *DHCPRelayList) DeepCopy() *DHCPRelayList { + if in == nil { + return nil + } + out := new(DHCPRelayList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *DHCPRelayList) 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 *DHCPRelaySpec) DeepCopyInto(out *DHCPRelaySpec) { + *out = *in + out.DeviceRef = in.DeviceRef + if in.ProviderConfigRef != nil { + in, out := &in.ProviderConfigRef, &out.ProviderConfigRef + *out = new(TypedLocalObjectReference) + **out = **in + } + if in.VrfRef != nil { + in, out := &in.VrfRef, &out.VrfRef + *out = new(LocalObjectReference) + **out = **in + } + if in.Servers != nil { + in, out := &in.Servers, &out.Servers + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.InterfaceRefs != nil { + in, out := &in.InterfaceRefs, &out.InterfaceRefs + *out = make([]LocalObjectReference, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DHCPRelaySpec. +func (in *DHCPRelaySpec) DeepCopy() *DHCPRelaySpec { + if in == nil { + return nil + } + out := new(DHCPRelaySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *DHCPRelayStatus) DeepCopyInto(out *DHCPRelayStatus) { + *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]) + } + } + if in.ConfiguredInterfaces != nil { + in, out := &in.ConfiguredInterfaces, &out.ConfiguredInterfaces + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DHCPRelayStatus. +func (in *DHCPRelayStatus) DeepCopy() *DHCPRelayStatus { + if in == nil { + return nil + } + out := new(DHCPRelayStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DNS) DeepCopyInto(out *DNS) { *out = *in diff --git a/charts/network-operator/templates/crd/dhcprelays.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/dhcprelays.networking.metal.ironcore.dev.yaml new file mode 100644 index 00000000..ca16de7a --- /dev/null +++ b/charts/network-operator/templates/crd/dhcprelays.networking.metal.ironcore.dev.yaml @@ -0,0 +1,249 @@ +{{- if .Values.crd.enable }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + {{- if .Values.crd.keep }} + "helm.sh/resource-policy": keep + {{- end }} + controller-gen.kubebuilder.io/version: v0.20.1 + name: dhcprelays.networking.metal.ironcore.dev +spec: + group: networking.metal.ironcore.dev + names: + kind: DHCPRelay + listKind: DHCPRelayList + plural: dhcprelays + singular: dhcprelay + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.deviceRef.name + name: Device + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: DHCPRelay is the Schema for the DHCPRelays 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: |- + DHCPRelaySpec defines the desired state of DHCPRelay. + Only a single DHCPRelay resource should be created per Device, the controller will reject additional resources of this type with the same DeviceRef. + properties: + deviceRef: + description: |- + DeviceRef is a reference to 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 + interfaceRefs: + description: InterfaceRefs is a list of interfaces + items: + description: |- + LocalObjectReference contains enough information to locate a + referenced object inside the same namespace. + 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 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + providerConfigRef: + description: |- + ProviderConfigRef is a reference to a resource holding the provider-specific configuration for this DHCPRelay. + 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 + servers: + description: |- + Servers is a list of DHCP server addresses to which DHCP messages will be relayed. + Only IPv4 addresses are currently supported. + items: + format: ipv4 + type: string + minItems: 1 + type: array + x-kubernetes-list-type: atomic + vrfRef: + description: VrfRef is an optional reference to the VRF to use when + relaying DHCP messages in all referenced interfaces. + 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 + required: + - deviceRef + - interfaceRefs + - servers + type: object + status: + description: DHCPRelayStatus defines the observed state of DHCPRelay. + properties: + conditions: + description: |- + conditions represent the current state of the DHCPRelay 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 status of each condition is one of True, False, or Unknown. + 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 + configuredInterfaces: + description: ConfiguredInterfaces contains the names of Interface + resources that have DHCP relay configured as known by the device. + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + required: + - metadata + - spec + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/network-operator/templates/crd/interfaceconfigs.nx.cisco.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/interfaceconfigs.nx.cisco.networking.metal.ironcore.dev.yaml index d7ecff1f..f95900b2 100644 --- a/charts/network-operator/templates/crd/interfaceconfigs.nx.cisco.networking.metal.ironcore.dev.yaml +++ b/charts/network-operator/templates/crd/interfaceconfigs.nx.cisco.networking.metal.ironcore.dev.yaml @@ -79,6 +79,7 @@ spec: - Normal - Edge - Network + - Trunk type: string required: - portType diff --git a/charts/network-operator/templates/crd/routingpolicies.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/routingpolicies.networking.metal.ironcore.dev.yaml index f051fc63..6c274011 100644 --- a/charts/network-operator/templates/crd/routingpolicies.networking.metal.ironcore.dev.yaml +++ b/charts/network-operator/templates/crd/routingpolicies.networking.metal.ironcore.dev.yaml @@ -143,9 +143,8 @@ spec: Only applicable when RouteDisposition is AcceptRoute. properties: setASPath: - description: |- - SetASPath configures modifications to the BGP AS path attribute. - Not all providers may support this action. + description: SetASPath configures modifications to the + BGP AS path attribute. properties: asNumber: anyOf: @@ -166,12 +165,11 @@ spec: description: |- ASNumber is the autonomous system number to prepend to the AS path. Supports both plain format (1-4294967295) and dotted notation (1-65535.0-65535) as per RFC 5396. - Mutually exclusive with useLastAS. x-kubernetes-int-or-string: true useLastAS: - description: |- - UseLastAS prepends the last AS number in the existing AS path the specified number of times. - Mutually exclusive with asNumber. + description: UseLastAS prepends the last AS + number in the existing AS path the specified + number of times. format: int32 maximum: 10 minimum: 1 @@ -192,12 +190,10 @@ spec: description: |- ASNumber targets a specific AS number in the path for replacement. Supports both plain format (1-4294967295) and dotted notation (1-65535.0-65535) as per RFC 5396. - Mutually exclusive with privateAS. x-kubernetes-int-or-string: true privateAS: - description: |- - PrivateAS, when set to true, targets all private AS numbers in the path for replacement. - Mutually exclusive with asNumber. + description: PrivateAS, when set to true, targets + all private AS numbers in the path for replacement. type: boolean replacement: anyOf: diff --git a/charts/network-operator/templates/webhook/validating-webhook-configuration.yaml b/charts/network-operator/templates/webhook/validating-webhook-configuration.yaml index f8dc76a8..7f35422f 100644 --- a/charts/network-operator/templates/webhook/validating-webhook-configuration.yaml +++ b/charts/network-operator/templates/webhook/validating-webhook-configuration.yaml @@ -108,6 +108,26 @@ webhooks: resources: - prefixsets sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: {{ include "network-operator.resourceName" (dict "suffix" "webhook-service" "context" $) }} + namespace: {{ .Release.Namespace }} + path: /validate-networking-metal-ironcore-dev-v1alpha1-routingpolicy + failurePolicy: Fail + name: routingpolicy-v1alpha1.kb.io + rules: + - apiGroups: + - networking.metal.ironcore.dev + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - routingpolicies + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/config/crd/bases/networking.metal.ironcore.dev_dhcprelays.yaml b/config/crd/bases/networking.metal.ironcore.dev_dhcprelays.yaml new file mode 100644 index 00000000..3d858a99 --- /dev/null +++ b/config/crd/bases/networking.metal.ironcore.dev_dhcprelays.yaml @@ -0,0 +1,245 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.1 + name: dhcprelays.networking.metal.ironcore.dev +spec: + group: networking.metal.ironcore.dev + names: + kind: DHCPRelay + listKind: DHCPRelayList + plural: dhcprelays + singular: dhcprelay + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.deviceRef.name + name: Device + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: DHCPRelay is the Schema for the DHCPRelays 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: |- + DHCPRelaySpec defines the desired state of DHCPRelay. + Only a single DHCPRelay resource should be created per Device, the controller will reject additional resources of this type with the same DeviceRef. + properties: + deviceRef: + description: |- + DeviceRef is a reference to 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 + interfaceRefs: + description: InterfaceRefs is a list of interfaces + items: + description: |- + LocalObjectReference contains enough information to locate a + referenced object inside the same namespace. + 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 + minItems: 1 + type: array + x-kubernetes-list-type: atomic + providerConfigRef: + description: |- + ProviderConfigRef is a reference to a resource holding the provider-specific configuration for this DHCPRelay. + 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 + servers: + description: |- + Servers is a list of DHCP server addresses to which DHCP messages will be relayed. + Only IPv4 addresses are currently supported. + items: + format: ipv4 + type: string + minItems: 1 + type: array + x-kubernetes-list-type: atomic + vrfRef: + description: VrfRef is an optional reference to the VRF to use when + relaying DHCP messages in all referenced interfaces. + 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 + required: + - deviceRef + - interfaceRefs + - servers + type: object + status: + description: DHCPRelayStatus defines the observed state of DHCPRelay. + properties: + conditions: + description: |- + conditions represent the current state of the DHCPRelay 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 status of each condition is one of True, False, or Unknown. + 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 + configuredInterfaces: + description: ConfiguredInterfaces contains the names of Interface + resources that have DHCP relay configured as known by the device. + items: + type: string + type: array + x-kubernetes-list-type: atomic + type: object + required: + - metadata + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 6069d716..182f057f 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -8,6 +8,7 @@ resources: - bases/networking.metal.ironcore.dev_bgppeers.yaml - bases/networking.metal.ironcore.dev_certificates.yaml - bases/networking.metal.ironcore.dev_devices.yaml +- bases/networking.metal.ironcore.dev_dhcprelays.yaml - bases/networking.metal.ironcore.dev_dns.yaml - bases/networking.metal.ironcore.dev_evpninstances.yaml - bases/networking.metal.ironcore.dev_interfaces.yaml diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 9a5dbaf3..c2ba378a 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -1,6 +1,7 @@ ## Append samples of your project ## resources: - v1alpha1_device.yaml +- v1alpha1-dhcprelay.yaml - v1alpha1_interface.yaml - v1alpha1_lldp.yaml - v1alpha1_banner.yaml diff --git a/config/samples/v1alpha1_dhcprelay.yaml b/config/samples/v1alpha1_dhcprelay.yaml new file mode 100644 index 00000000..510aae55 --- /dev/null +++ b/config/samples/v1alpha1_dhcprelay.yaml @@ -0,0 +1,17 @@ +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: DHCPRelay +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + networking.metal.ironcore.dev/device-name: leaf1 + name: dhcprelay +spec: + deviceRef: + name: leaf1 + servers: + - 192.168.1.1 + - 192.168.1.2 + interfaceRefs: + - name: svi-10 + - name: eth1-1 diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index 96e42f93..17e85b87 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -19,6 +19,7 @@ SPDX-License-Identifier: Apache-2.0 - [BGPPeer](#bgppeer) - [Banner](#banner) - [Certificate](#certificate) +- [DHCPRelay](#dhcprelay) - [DNS](#dns) - [Device](#device) - [EVPNInstance](#evpninstance) @@ -649,7 +650,7 @@ _Appears in:_ | --- | --- | --- | --- | | `setCommunity` _[SetCommunityAction](#setcommunityaction)_ | SetCommunity configures BGP standard community attributes. | | Optional: \{\}
| | `setExtCommunity` _[SetExtCommunityAction](#setextcommunityaction)_ | SetExtCommunity configures BGP extended community attributes. | | Optional: \{\}
| -| `setASPath` _[SetASPathAction](#setaspathaction)_ | SetASPath configures modifications to the BGP AS path attribute.
Not all providers may support this action. | | Optional: \{\}
| +| `setASPath` _[SetASPathAction](#setaspathaction)_ | SetASPath configures modifications to the BGP AS path attribute. | | Optional: \{\}
| #### Certificate @@ -792,6 +793,63 @@ _Appears in:_ | `mode` _[LACPMode](#lacpmode)_ | Mode defines the LACP mode for the aggregate interface. | | Enum: [Active Passive]
Required: \{\}
| +#### DHCPRelay + + + +DHCPRelay is the Schema for the DHCPRelays API + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `networking.metal.ironcore.dev/v1alpha1` | | | +| `kind` _string_ | `DHCPRelay` | | | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | | +| `spec` _[DHCPRelaySpec](#dhcprelayspec)_ | | | Required: \{\}
| +| `status` _[DHCPRelayStatus](#dhcprelaystatus)_ | | | Optional: \{\}
| + + +#### DHCPRelaySpec + + + +DHCPRelaySpec defines the desired state of DHCPRelay. +Only a single DHCPRelay resource should be created per Device, the controller will reject additional resources of this type with the same DeviceRef. + + + +_Appears in:_ +- [DHCPRelay](#dhcprelay) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `deviceRef` _[LocalObjectReference](#localobjectreference)_ | DeviceRef is a reference to the Device this object belongs to. The Device object must exist in the same namespace.
Immutable. | | Required: \{\}
| +| `providerConfigRef` _[TypedLocalObjectReference](#typedlocalobjectreference)_ | ProviderConfigRef is a reference to a resource holding the provider-specific configuration for this DHCPRelay.
If not specified the provider applies the target platform's default settings. | | Optional: \{\}
| +| `vrfRef` _[LocalObjectReference](#localobjectreference)_ | VrfRef is an optional reference to the VRF to use when relaying DHCP messages in all referenced interfaces. | | Optional: \{\}
| +| `servers` _string array_ | Servers is a list of DHCP server addresses to which DHCP messages will be relayed.
Only IPv4 addresses are currently supported. | | MinItems: 1
items:Format: ipv4
Required: \{\}
| +| `interfaceRefs` _[LocalObjectReference](#localobjectreference) array_ | InterfaceRefs is a list of interfaces | | MinItems: 1
Required: \{\}
| + + +#### DHCPRelayStatus + + + +DHCPRelayStatus defines the observed state of DHCPRelay. + + + +_Appears in:_ +- [DHCPRelay](#dhcprelay) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array_ | conditions represent the current state of the DHCPRelay 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 status of each condition is one of True, False, or Unknown. | | Optional: \{\}
| +| `configuredInterfaces` _string array_ | ConfiguredInterfaces contains the names of Interface resources that have DHCP relay configured as known by the device. | | Optional: \{\}
| + + #### DNS @@ -1499,6 +1557,7 @@ _Appears in:_ - [BannerSpec](#bannerspec) - [BorderGatewaySpec](#bordergatewayspec) - [CertificateSpec](#certificatespec) +- [DHCPRelaySpec](#dhcprelayspec) - [DNSSpec](#dnsspec) - [DevicePort](#deviceport) - [EVPNInstanceSpec](#evpninstancespec) @@ -2635,8 +2694,8 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `asNumber` _[IntOrString](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#intorstring-intstr-util)_ | ASNumber is the autonomous system number to prepend to the AS path.
Supports both plain format (1-4294967295) and dotted notation (1-65535.0-65535) as per RFC 5396.
Mutually exclusive with useLastAS. | | Optional: \{\}
| -| `useLastAS` _integer_ | UseLastAS prepends the last AS number in the existing AS path the specified number of times.
Mutually exclusive with asNumber. | | Maximum: 10
Minimum: 1
Optional: \{\}
| +| `asNumber` _[IntOrString](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#intorstring-intstr-util)_ | ASNumber is the autonomous system number to prepend to the AS path.
Supports both plain format (1-4294967295) and dotted notation (1-65535.0-65535) as per RFC 5396. | | Optional: \{\}
| +| `useLastAS` _integer_ | UseLastAS prepends the last AS number in the existing AS path the specified number of times. | | Maximum: 10
Minimum: 1
Optional: \{\}
| #### SetASPathReplace @@ -2653,8 +2712,8 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `privateAS` _boolean_ | PrivateAS, when set to true, targets all private AS numbers in the path for replacement.
Mutually exclusive with asNumber. | | Optional: \{\}
| -| `asNumber` _[IntOrString](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#intorstring-intstr-util)_ | ASNumber targets a specific AS number in the path for replacement.
Supports both plain format (1-4294967295) and dotted notation (1-65535.0-65535) as per RFC 5396.
Mutually exclusive with privateAS. | | Optional: \{\}
| +| `privateAS` _boolean_ | PrivateAS, when set to true, targets all private AS numbers in the path for replacement. | | Optional: \{\}
| +| `asNumber` _[IntOrString](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#intorstring-intstr-util)_ | ASNumber targets a specific AS number in the path for replacement.
Supports both plain format (1-4294967295) and dotted notation (1-65535.0-65535) as per RFC 5396. | | Optional: \{\}
| | `replacement` _[IntOrString](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#intorstring-intstr-util)_ | Replacement is the AS number to substitute in place of matched AS numbers.
Supports both plain format (1-4294967295) and dotted notation (1-65535.0-65535) as per RFC 5396. | | Required: \{\}
| @@ -2859,6 +2918,7 @@ _Appears in:_ - [BGPSpec](#bgpspec) - [BannerSpec](#bannerspec) - [CertificateSpec](#certificatespec) +- [DHCPRelaySpec](#dhcprelayspec) - [DNSSpec](#dnsspec) - [EVPNInstanceSpec](#evpninstancespec) - [ISISSpec](#isisspec) @@ -3562,7 +3622,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | -| `portType` _[SpanningTreePortType](#spanningtreeporttype)_ | PortType defines the spanning tree port type. | | Enum: [Normal Edge Network]
Required: \{\}
| +| `portType` _[SpanningTreePortType](#spanningtreeporttype)_ | PortType defines the spanning tree port type. | | Enum: [Normal Edge Network Trunk]
Required: \{\}
| | `bpduGuard` _boolean_ | BPDUGuard enables BPDU guard on the interface.
When enabled, the port is shut down if a BPDU is received. | | Optional: \{\}
| | `bpduFilter` _boolean_ | BPDUFilter enables BPDU filter on the interface.
When enabled, BPDUs are not sent or received on the port. | | Optional: \{\}
| @@ -3574,7 +3634,7 @@ _Underlying type:_ _string_ SpanningTreePortType represents the spanning tree port type. _Validation:_ -- Enum: [Normal Edge Network] +- Enum: [Normal Edge Network Trunk] _Appears in:_ - [SpanningTree](#spanningtree) @@ -3583,6 +3643,7 @@ _Appears in:_ | --- | --- | | `Normal` | SpanningTreePortTypeNormal indicates a normal spanning tree port.
| | `Edge` | SpanningTreePortTypeEdge indicates an edge port (connects to end devices).
| +| `Trunk` | SpanningTreePortTypeTrunk indicates a trunk port performing spanning tree calculations for multiple VLANs (connects to end devices and carries multiple VLANs).
| | `Network` | SpanningTreePortTypeNetwork indicates a network port (connects to other switches).
| diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 6fad4007..9c416346 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -616,6 +616,29 @@ type LLDPStatus struct { OperStatus bool } +type DHCPRelayProvider interface { + Provider + + // EnsureDHCPRelay realizes DHCP Relay configuration. + EnsureDHCPRelay(context.Context, *DHCPRelayRequest) error + // DeleteDHCPRelay deletes the DHCP Relay configuration. + DeleteDHCPRelay(context.Context, *DHCPRelayRequest) error + // GetDHCPRelayStatus call retrieves the current status of the DHCP Relay configuration. + GetDHCPRelayStatus(context.Context, *DHCPRelayRequest) (DHCPRelayStatus, error) +} + +type DHCPRelayRequest struct { + DHCPRelay *v1alpha1.DHCPRelay + ProviderConfig *ProviderConfig + Interfaces []*v1alpha1.Interface + VRF *v1alpha1.VRF +} + +type DHCPRelayStatus struct { + // ConfiguredInterfaces contains the names of the interfaces on the device for which DHCP Relay is configured, e.g., eth1/1. + ConfiguredInterfaces []string +} + var mu sync.RWMutex // ProviderFunc returns a new [Provider] instance. From 94eb0ebfd2fc346665872926ab192823c278249e Mon Sep 17 00:00:00 2001 From: Pujol Date: Tue, 24 Mar 2026 18:35:35 +0100 Subject: [PATCH 2/6] [core] Add `DHCPRelay` controller The controller validates that the referenced Device exists and is not paused, that all InterfaceRefs belong to the same device and are Layer 3 interfaces (Physical, Aggregate, or RoutedVLAN types) with IPv4 configuration, and that the optional VrfRef belongs to the same device. It enforces that only one DHCPRelay resource exists per device. The controller retrieves the list of interfaces that have DHCP relay configured on the device and updates the ConfiguredInterfaceRefs status field. The controller watches for Device Paused field changes, Interface Ready condition changes, VRF Ready condition changes, and ProviderConfig updates to trigger re-reconciliation when dependencies change. The controller considers only the Configuration condition of the referenced resources as DHCPRelay is a configuration resource only. --- .../templates/rbac/manager-role.yaml | 3 + cmd/main.go | 13 + config/rbac/role.yaml | 3 + config/samples/v1alpha1_dhcprelay.yaml | 5 +- .../controller/core/dhcprelay_controller.go | 720 +++++++++++ .../core/dhcprelay_controller_test.go | 1126 +++++++++++++++++ .../controller/core/lldp_controller_test.go | 4 +- internal/controller/core/suite_test.go | 39 + 8 files changed, 1907 insertions(+), 6 deletions(-) create mode 100644 internal/controller/core/dhcprelay_controller.go create mode 100644 internal/controller/core/dhcprelay_controller_test.go diff --git a/charts/network-operator/templates/rbac/manager-role.yaml b/charts/network-operator/templates/rbac/manager-role.yaml index b4d3140d..282df18c 100644 --- a/charts/network-operator/templates/rbac/manager-role.yaml +++ b/charts/network-operator/templates/rbac/manager-role.yaml @@ -47,6 +47,7 @@ rules: - bgppeers - certificates - devices + - dhcprelays - dns - evpninstances - interfaces @@ -81,6 +82,7 @@ rules: - bgppeers/finalizers - certificates/finalizers - devices/finalizers + - dhcprelays/finalizers - dns/finalizers - evpninstances/finalizers - interfaces/finalizers @@ -109,6 +111,7 @@ rules: - bgppeers/status - certificates/status - devices/status + - dhcprelays/status - dns/status - evpninstances/status - interfaces/status diff --git a/cmd/main.go b/cmd/main.go index 418fed16..8109fc48 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -596,6 +596,19 @@ func main() { os.Exit(1) } + if err := (&corecontroller.DHCPRelayReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorder("dhcprelay-controller"), + WatchFilterValue: watchFilterValue, + Provider: prov, + Locker: locker, + RequeueInterval: requeueInterval, + }).SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "DHCPRelay") + os.Exit(1) + } + if os.Getenv("ENABLE_WEBHOOKS") != "false" { if err := webhookv1alpha1.SetupVRFWebhookWithManager(mgr); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "VRF") diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 7a3e8663..a80add84 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -48,6 +48,7 @@ rules: - bgppeers - certificates - devices + - dhcprelays - dns - evpninstances - interfaces @@ -82,6 +83,7 @@ rules: - bgppeers/finalizers - certificates/finalizers - devices/finalizers + - dhcprelays/finalizers - dns/finalizers - evpninstances/finalizers - interfaces/finalizers @@ -110,6 +112,7 @@ rules: - bgppeers/status - certificates/status - devices/status + - dhcprelays/status - dns/status - evpninstances/status - interfaces/status diff --git a/config/samples/v1alpha1_dhcprelay.yaml b/config/samples/v1alpha1_dhcprelay.yaml index 510aae55..40b8b68c 100644 --- a/config/samples/v1alpha1_dhcprelay.yaml +++ b/config/samples/v1alpha1_dhcprelay.yaml @@ -10,8 +10,7 @@ spec: deviceRef: name: leaf1 servers: - - 192.168.1.1 - - 192.168.1.2 + - 192.168.1.3 + - 192.168.1.4 interfaceRefs: - - name: svi-10 - name: eth1-1 diff --git a/internal/controller/core/dhcprelay_controller.go b/internal/controller/core/dhcprelay_controller.go new file mode 100644 index 00000000..2b3b9d8c --- /dev/null +++ b/internal/controller/core/dhcprelay_controller.go @@ -0,0 +1,720 @@ +// 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/events" + "k8s.io/klog/v2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/conditions" + "github.com/ironcore-dev/network-operator/internal/deviceutil" + "github.com/ironcore-dev/network-operator/internal/paused" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/resourcelock" +) + +// DHCPRelayReconciler reconciles a DHCPRelay object +type DHCPRelayReconciler struct { + client.Client + Scheme *runtime.Scheme + + // WatchFilterValue is the label value used to filter events prior to reconciliation. + WatchFilterValue string + + // Recorder is used to record events for the controller. + // More info: https://book.kubebuilder.io/reference/raising-events + Recorder events.EventRecorder + + // Provider is the driver that will be used to create & delete the dhcp relay configuration. + Provider provider.ProviderFunc + + // Locker is used to synchronize operations on resources targeting the same device. + Locker *resourcelock.ResourceLocker + + // RequeueInterval is the duration after which the controller should requeue the reconciliation, + // regardless of changes. + RequeueInterval time.Duration +} + +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=dhcprelays,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=dhcprelays/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=dhcprelays/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 +// +// For more details about the method shape, read up here: +// - https://ahmet.im/blog/controller-pitfalls/#reconcile-method-shape +func (r *DHCPRelayReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, reterr error) { + log := ctrl.LoggerFrom(ctx) + log.Info("Reconciling resource") + + obj := new(v1alpha1.DHCPRelay) + if err := r.Get(ctx, req.NamespacedName, obj); err != nil { + if apierrors.IsNotFound(err) { + // If the custom resource is not found then it usually means that it was deleted or not created + // In this way, we will stop the reconciliation + log.Info("Resource not found. Ignoring since object must be deleted") + return ctrl.Result{}, nil + } + // Error reading the object - requeue the request. + log.Error(err, "Failed to get resource") + return ctrl.Result{}, err + } + + prov, ok := r.Provider().(provider.DHCPRelayProvider) + 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.DHCPRelayProvider", + }) { + return ctrl.Result{}, r.Status().Update(ctx, obj) + } + return ctrl.Result{}, nil + } + + device, err := deviceutil.GetDeviceByName(ctx, r, obj.Namespace, obj.Spec.DeviceRef.Name) + if err != nil { + return ctrl.Result{}, err + } + + if isPaused, requeue, err := paused.EnsureCondition(ctx, r.Client, device, obj); isPaused || requeue || err != nil { + return ctrl.Result{Requeue: requeue}, err + } + + if err := r.Locker.AcquireLock(ctx, device.Name, "dhcprelay-controller"); err != nil { + if errors.Is(err, resourcelock.ErrLockAlreadyHeld) { + log.Info("Device is already locked, requeuing reconciliation") + return ctrl.Result{RequeueAfter: time.Second}, nil + } + log.Error(err, "Failed to acquire device lock") + return ctrl.Result{}, err + } + defer func() { + if err := r.Locker.ReleaseLock(ctx, device.Name, "dhcprelay-controller"); err != nil { + log.Error(err, "Failed to release device lock") + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + }() + + conn, err := deviceutil.GetDeviceConnection(ctx, r, device) + if err != nil { + return ctrl.Result{}, err + } + + var cfg *provider.ProviderConfig + if obj.Spec.ProviderConfigRef != nil { + cfg, err = provider.GetProviderConfig(ctx, r, obj.Namespace, obj.Spec.ProviderConfigRef) + if err != nil { + return ctrl.Result{}, err + } + } + + s := &dhcprelayScope{ + Device: device, + DHCPRelay: 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) { + // Pass obj.DeepCopy() to avoid Patch() modifying obj and interfering with status update below + if err := r.Patch(ctx, obj.DeepCopy(), client.MergeFrom(orig)); err != nil { + log.Error(err, "Failed to update resource metadata") + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + } + if !equality.Semantic.DeepEqual(orig.Status, obj.Status) { + if err := r.Status().Patch(ctx, obj, client.MergeFrom(orig)); err != nil { + log.Error(err, "Failed to update status") + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + } + }() + + res, err := r.reconcile(ctx, s) + if err != nil { + log.Error(err, "Failed to reconcile resource") + return ctrl.Result{}, err + } + + return res, nil +} + +// scope holds the different objects that are read and used during the reconcile. +type dhcprelayScope struct { + Device *v1alpha1.Device + DHCPRelay *v1alpha1.DHCPRelay + Connection *deviceutil.Connection + ProviderConfig *provider.ProviderConfig + Provider provider.DHCPRelayProvider + interfaces []*v1alpha1.Interface + vrf *v1alpha1.VRF +} + +func (r *DHCPRelayReconciler) reconcile(ctx context.Context, s *dhcprelayScope) (_ ctrl.Result, reterr error) { + if s.DHCPRelay.Labels == nil { + s.DHCPRelay.Labels = make(map[string]string) + } + s.DHCPRelay.Labels[v1alpha1.DeviceLabel] = s.Device.Name + + // Ensure the DHCPRelay is owned by the Device. + if !controllerutil.HasControllerReference(s.DHCPRelay) { + if err := controllerutil.SetOwnerReference(s.Device, s.DHCPRelay, r.Scheme, controllerutil.WithBlockOwnerDeletion(true)); err != nil { + return ctrl.Result{}, err + } + } + + if err := r.validateUniqueResourcePerDevice(ctx, s); err != nil { + return ctrl.Result{}, err + } + + if err := r.validateProviderConfigRef(ctx, s); err != nil { + return ctrl.Result{}, err + } + + interfaces, err := r.reconcileInterfaceRefs(ctx, s) + if err != nil { + return ctrl.Result{}, err + } + s.interfaces = interfaces + + vrf, err := r.reconcileVRFRef(ctx, s) + if err != nil { + return ctrl.Result{}, err + } + s.vrf = vrf + + // Connect to remote device using the provider. + if err := s.Provider.Connect(ctx, s.Connection); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to connect to provider: %w", err) + } + defer func() { + if err := s.Provider.Disconnect(ctx, s.Connection); err != nil { + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + }() + + // Ensure the DHCPRelay is realized on the remote device. + err = s.Provider.EnsureDHCPRelay(ctx, &provider.DHCPRelayRequest{ + DHCPRelay: s.DHCPRelay, + ProviderConfig: s.ProviderConfig, + Interfaces: s.interfaces, + VRF: s.vrf, + }) + + cond := conditions.FromError(err) + // As this resource is configuration only, we use the Configured condition as top-level Ready condition. + cond.Type = v1alpha1.ReadyCondition + conditions.Set(s.DHCPRelay, cond) + + if err != nil { + return ctrl.Result{}, err + } + + // Retrieve and update the status from the device; this include the list of interfaces that are actually configured on the device. + status, err := s.Provider.GetDHCPRelayStatus(ctx, &provider.DHCPRelayRequest{ + DHCPRelay: s.DHCPRelay, + ProviderConfig: s.ProviderConfig, + Interfaces: s.interfaces, + }) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get DHCP relay status: %w", err) + } + + s.DHCPRelay.Status.ConfiguredInterfaces = status.ConfiguredInterfaces + + return ctrl.Result{RequeueAfter: Jitter(r.RequeueInterval)}, nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *DHCPRelayReconciler) 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) + } + + bldr := ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.DHCPRelay{}). + Named("dhcprelay"). + WithEventFilter(filter) + + for _, gvk := range v1alpha1.DHCPRelayDependencies { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(gvk) + + bldr = bldr.Watches( + obj, + handler.EnqueueRequestsFromMapFunc(r.mapProviderConfigToDHCPRelay), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ) + } + + return bldr. + // Watches enqueues DHCPRelays for updates in referenced Device resources. + // Triggers on create, delete, and update events when the Paused spec field changes. + Watches( + &v1alpha1.Device{}, + handler.EnqueueRequestsFromMapFunc(r.deviceToDHCPRelays), + builder.WithPredicates(predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + oldDevice := e.ObjectOld.(*v1alpha1.Device) + newDevice := e.ObjectNew.(*v1alpha1.Device) + // Only trigger when Paused spec field changes. + return oldDevice.Spec.Paused != newDevice.Spec.Paused + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + }), + ). + // Watches enqueues DHCPRelays when referenced Interface resources are configured. + Watches( + &v1alpha1.Interface{}, + handler.EnqueueRequestsFromMapFunc(r.interfaceToDHCPRelays), + builder.WithPredicates(predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + return false + }, + UpdateFunc: func(e event.UpdateEvent) bool { + oldIntf := e.ObjectOld.(*v1alpha1.Interface) + newIntf := e.ObjectNew.(*v1alpha1.Interface) + // Only trigger when Configured condition changes (not operational status). + return conditions.IsConfigured(oldIntf) != conditions.IsConfigured(newIntf) + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + }), + ). + // Watches enqueues DHCPRelays when referenced VRF resources are configured. + Watches( + &v1alpha1.VRF{}, + handler.EnqueueRequestsFromMapFunc(r.vrfToDHCPRelays), + builder.WithPredicates(predicate.Funcs{ + CreateFunc: func(e event.CreateEvent) bool { + return false + }, + UpdateFunc: func(e event.UpdateEvent) bool { + oldVRF := e.ObjectOld.(*v1alpha1.VRF) + newVRF := e.ObjectNew.(*v1alpha1.VRF) + // Only trigger when Configured condition changes (not operational status). + return conditions.IsConfigured(oldVRF) != conditions.IsConfigured(newVRF) + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + }), + ). + Complete(r) +} + +// validateProviderConfigRef checks if the referenced provider configuration is compatible with the target platform. +func (r *DHCPRelayReconciler) validateProviderConfigRef(_ context.Context, s *dhcprelayScope) error { + if s.DHCPRelay.Spec.ProviderConfigRef == nil { + return nil + } + + gv, err := schema.ParseGroupVersion(s.DHCPRelay.Spec.ProviderConfigRef.APIVersion) + if err != nil { + conditions.Set(s.DHCPRelay, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.IncompatibleProviderConfigRef, + Message: fmt.Sprintf("Invalid API version in ProviderConfigRef: %v", err), + }) + return reconcile.TerminalError(fmt.Errorf("invalid API version %q: %w", s.DHCPRelay.Spec.ProviderConfigRef.APIVersion, err)) + } + + gvk := gv.WithKind(s.DHCPRelay.Spec.ProviderConfigRef.Kind) + + if ok := slices.Contains(v1alpha1.DHCPRelayDependencies, gvk); !ok { + conditions.Set(s.DHCPRelay, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.IncompatibleProviderConfigRef, + Message: fmt.Sprintf("ProviderConfigRef kind '%s' with API version '%s' is not compatible with this type", s.DHCPRelay.Spec.ProviderConfigRef.Kind, s.DHCPRelay.Spec.ProviderConfigRef.APIVersion), + }) + return reconcile.TerminalError(fmt.Errorf("unsupported ProviderConfigRef Kind %q on this provider", gv)) + } + + return nil +} + +// reconcileInterfaceRefs fetches all referenced interfaces and validates them +func (r *DHCPRelayReconciler) reconcileInterfaceRefs(ctx context.Context, s *dhcprelayScope) ([]*v1alpha1.Interface, error) { + if len(s.DHCPRelay.Spec.InterfaceRefs) == 0 { + return nil, nil + } + + interfaces := make([]*v1alpha1.Interface, 0, len(s.DHCPRelay.Spec.InterfaceRefs)) + for _, ifRef := range s.DHCPRelay.Spec.InterfaceRefs { + iface, err := r.reconcileInterfaceRef(ctx, ifRef, s) + if err != nil { + return nil, err + } + interfaces = append(interfaces, iface) + } + + return interfaces, nil +} + +// reconcileInterfaceRef checks that the referenced interface exists and belongs to the same device as the DHCPRelay. +func (r *DHCPRelayReconciler) reconcileInterfaceRef(ctx context.Context, interfaceRef v1alpha1.LocalObjectReference, s *dhcprelayScope) (*v1alpha1.Interface, error) { + intf := new(v1alpha1.Interface) + if err := r.Get(ctx, types.NamespacedName{ + Name: interfaceRef.Name, + Namespace: s.DHCPRelay.Namespace, + }, intf); err != nil { + if apierrors.IsNotFound(err) { + conditions.Set(s.DHCPRelay, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.WaitingForDependenciesReason, + Message: fmt.Sprintf("Interface %s not found", interfaceRef.Name), + }) + return nil, reconcile.TerminalError(fmt.Errorf("interface %s not found", interfaceRef.Name)) + } + return nil, fmt.Errorf("failed to get interface %s: %w", interfaceRef.Name, err) + } + + // Verify the interface belongs to the same device + if intf.Spec.DeviceRef.Name != s.Device.Name { + conditions.Set(s.DHCPRelay, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.CrossDeviceReferenceReason, + Message: fmt.Sprintf("Interface %s belongs to device %s, not %s", interfaceRef.Name, intf.Spec.DeviceRef.Name, s.Device.Name), + }) + return nil, reconcile.TerminalError(fmt.Errorf("interface %s belongs to different device", interfaceRef.Name)) + } + + switch intf.Spec.Type { + case v1alpha1.InterfaceTypePhysical, v1alpha1.InterfaceTypeAggregate, v1alpha1.InterfaceTypeRoutedVLAN: + // Supported types, do nothing + default: + conditions.Set(s.DHCPRelay, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.InvalidInterfaceTypeReason, + Message: fmt.Sprintf("Interface %s has invalid type %s (only Physical, Aggregate, and RoutedVLAN types are supported)", interfaceRef.Name, intf.Spec.Type), + }) + return nil, reconcile.TerminalError(fmt.Errorf("interface %s has an invalid type: %s", interfaceRef.Name, intf.Spec.Type)) + } + + // Verify the interface configuration is applied to the device (not operational status) + if !conditions.IsConfigured(intf) { + conditions.Set(s.DHCPRelay, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.WaitingForDependenciesReason, + Message: fmt.Sprintf("Interface %s is not configured on the device", interfaceRef.Name), + }) + return nil, reconcile.TerminalError(fmt.Errorf("interface %s is not configured", interfaceRef.Name)) + } + + // Verify the interface has required IP addressing configured based on server address types + if intf.Spec.IPv4 == nil { + conditions.Set(s.DHCPRelay, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.IPAddressingNotFoundReason, + Message: fmt.Sprintf("Interface %s has no IPv4 configuration (address or unnumbered required for DHCP relay)", interfaceRef.Name), + }) + return nil, reconcile.TerminalError(fmt.Errorf("interface %s has no IPv4 configuration", interfaceRef.Name)) + } + + return intf, nil +} + +func (r *DHCPRelayReconciler) reconcileVRFRef(ctx context.Context, s *dhcprelayScope) (*v1alpha1.VRF, error) { + if s.DHCPRelay.Spec.VrfRef == nil { + return nil, nil + } + vrf := new(v1alpha1.VRF) + if err := r.Get(ctx, types.NamespacedName{ + Name: s.DHCPRelay.Spec.VrfRef.Name, + Namespace: s.DHCPRelay.Namespace, + }, vrf); err != nil { + if apierrors.IsNotFound(err) { + conditions.Set(s.DHCPRelay, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.WaitingForDependenciesReason, + Message: fmt.Sprintf("VRF %s not found", s.DHCPRelay.Spec.VrfRef.Name), + }) + return nil, reconcile.TerminalError(fmt.Errorf("vrf %s not found", s.DHCPRelay.Spec.VrfRef.Name)) + } + return nil, fmt.Errorf("failed to get VRF %s: %w", s.DHCPRelay.Spec.VrfRef.Name, err) + } + + // Verify the VRF belongs to the same device + if vrf.Spec.DeviceRef.Name != s.Device.Name { + conditions.Set(s.DHCPRelay, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.CrossDeviceReferenceReason, + Message: fmt.Sprintf("VRF %s belongs to device %s, not %s", s.DHCPRelay.Spec.VrfRef.Name, vrf.Spec.DeviceRef.Name, s.Device.Name), + }) + return nil, reconcile.TerminalError(fmt.Errorf("vrf %s belongs to different device", s.DHCPRelay.Spec.VrfRef.Name)) + } + + // Verify the VRF configuration is applied to the device (not operational status) + if !conditions.IsConfigured(vrf) { + conditions.Set(s.DHCPRelay, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.WaitingForDependenciesReason, + Message: fmt.Sprintf("VRF %s is not configured on the device", s.DHCPRelay.Spec.VrfRef.Name), + }) + return nil, reconcile.TerminalError(fmt.Errorf("vrf %s is not configured", s.DHCPRelay.Spec.VrfRef.Name)) + } + + return vrf, nil +} + +func (r *DHCPRelayReconciler) validateUniqueResourcePerDevice(ctx context.Context, s *dhcprelayScope) error { + var list v1alpha1.DHCPRelayList + if err := r.List(ctx, &list, + client.InNamespace(s.DHCPRelay.Namespace), + client.MatchingLabels{v1alpha1.DeviceLabel: s.Device.Name}, + ); err != nil { + return err + } + for _, dhcprelay := range list.Items { + if dhcprelay.Name != s.DHCPRelay.Name { + conditions.Set(s.DHCPRelay, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.DuplicateResourceOnDevice, + Message: fmt.Sprintf("Another DHCPRelay (%s) already exists for device %s", dhcprelay.Name, s.DHCPRelay.Spec.DeviceRef.Name), + }) + return reconcile.TerminalError(fmt.Errorf("only one DHCPRelay resource allowed per device (%s)", s.DHCPRelay.Spec.DeviceRef.Name)) + } + } + return nil +} + +func (r *DHCPRelayReconciler) mapProviderConfigToDHCPRelay(ctx context.Context, obj client.Object) []reconcile.Request { + log := ctrl.LoggerFrom(ctx, "Object", klog.KObj(obj)) + + list := &v1alpha1.DHCPRelayList{} + if err := r.List(ctx, list, client.InNamespace(obj.GetNamespace())); err != nil { + log.Error(err, "failed to list DHCPRelays") + 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("Found matching DHCPRelay for provider config change, enqueuing for reconciliation", "DHCPRelay", klog.KObj(&m)) + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: m.Name, + Namespace: m.Namespace, + }, + }) + } + } + return requests +} + +func (r *DHCPRelayReconciler) finalize(ctx context.Context, s *dhcprelayScope) (reterr error) { + if err := s.Provider.Connect(ctx, s.Connection); err != nil { + return fmt.Errorf("failed to connect to provider: %w", err) + } + defer func() { + if err := s.Provider.Disconnect(ctx, s.Connection); err != nil { + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + }() + + return s.Provider.DeleteDHCPRelay(ctx, &provider.DHCPRelayRequest{ + DHCPRelay: s.DHCPRelay, + ProviderConfig: s.ProviderConfig, + }) +} + +// deviceToDHCPRelays is a [handler.MapFunc] to be used to enqueue requests for reconciliation +func (r *DHCPRelayReconciler) deviceToDHCPRelays(ctx context.Context, obj client.Object) []ctrl.Request { + device, ok := obj.(*v1alpha1.Device) + if !ok { + panic(fmt.Sprintf("Expected a Device but got a %T", obj)) + } + + log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) + + list := new(v1alpha1.DHCPRelayList) + if err := r.List(ctx, list, + client.InNamespace(device.Namespace), + client.MatchingLabels{v1alpha1.DeviceLabel: device.Name}, + ); err != nil { + log.Error(err, "Failed to list DHCPRelays") + return nil + } + + requests := make([]ctrl.Request, 0, len(list.Items)) + for _, i := range list.Items { + log.Info("Enqueuing DHCPRelay for reconciliation", "DHCPRelay", klog.KObj(&i)) + requests = append(requests, ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: i.Name, + Namespace: i.Namespace, + }, + }) + } + + return requests +} + +// interfaceToDHCPRelays is a [handler.MapFunc] that enqueues DHCPRelays referencing the given Interface. +func (r *DHCPRelayReconciler) interfaceToDHCPRelays(ctx context.Context, obj client.Object) []ctrl.Request { + intf, ok := obj.(*v1alpha1.Interface) + if !ok { + panic(fmt.Sprintf("Expected an Interface but got a %T", obj)) + } + + log := ctrl.LoggerFrom(ctx, "Interface", klog.KObj(intf)) + + list := new(v1alpha1.DHCPRelayList) + if err := r.List(ctx, list, + client.InNamespace(intf.Namespace), + client.MatchingLabels{v1alpha1.DeviceLabel: intf.Spec.DeviceRef.Name}, + ); err != nil { + log.Error(err, "Failed to list DHCPRelays") + return nil + } + + var requests []ctrl.Request + for _, dhcpRelay := range list.Items { + for _, ifRef := range dhcpRelay.Spec.InterfaceRefs { + if ifRef.Name == intf.Name { + log.Info("Enqueuing DHCPRelay for reconciliation", "DHCPRelay", klog.KObj(&dhcpRelay)) + requests = append(requests, ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: dhcpRelay.Name, + Namespace: dhcpRelay.Namespace, + }, + }) + break + } + } + } + + return requests +} + +// vrfToDHCPRelays is a [handler.MapFunc] that enqueues DHCPRelays referencing the given VRF. +func (r *DHCPRelayReconciler) vrfToDHCPRelays(ctx context.Context, obj client.Object) []ctrl.Request { + vrf, ok := obj.(*v1alpha1.VRF) + if !ok { + panic(fmt.Sprintf("Expected a VRF but got a %T", obj)) + } + + log := ctrl.LoggerFrom(ctx, "VRF", klog.KObj(vrf)) + + list := new(v1alpha1.DHCPRelayList) + if err := r.List(ctx, list, + client.InNamespace(vrf.Namespace), + client.MatchingLabels{v1alpha1.DeviceLabel: vrf.Spec.DeviceRef.Name}, + ); err != nil { + log.Error(err, "Failed to list DHCPRelays") + return nil + } + + var requests []ctrl.Request + for _, dhcpRelay := range list.Items { + if dhcpRelay.Spec.VrfRef != nil && dhcpRelay.Spec.VrfRef.Name == vrf.Name { + log.Info("Enqueuing DHCPRelay for reconciliation", "DHCPRelay", klog.KObj(&dhcpRelay)) + requests = append(requests, ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: dhcpRelay.Name, + Namespace: dhcpRelay.Namespace, + }, + }) + } + } + + return requests +} diff --git a/internal/controller/core/dhcprelay_controller_test.go b/internal/controller/core/dhcprelay_controller_test.go new file mode 100644 index 00000000..6554eb8b --- /dev/null +++ b/internal/controller/core/dhcprelay_controller_test.go @@ -0,0 +1,1126 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "net/netip" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "k8s.io/apimachinery/pkg/api/meta" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +var _ = Describe("DHCPRelay Controller", func() { + Context("When reconciling a resource", func() { + var ( + deviceName string + resourceName string + interfaceName string + vlanName string + resourceKey client.ObjectKey + deviceKey client.ObjectKey + interfaceKey client.ObjectKey + vlanKey client.ObjectKey + device *v1alpha1.Device + vlan *v1alpha1.VLAN + intf *v1alpha1.Interface + dhcprelay *v1alpha1.DHCPRelay + ) + + BeforeEach(func() { + By("Creating the custom resource for the Kind Device") + device = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.50:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + deviceName = device.Name + deviceKey = client.ObjectKey{Name: deviceName, Namespace: metav1.NamespaceDefault} + + By("Creating the custom resource for the Kind VLAN") + vlan = &v1alpha1.VLAN{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-vlan-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.VLANSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + ID: 10, + Name: "vlan10", + }, + } + Expect(k8sClient.Create(ctx, vlan)).To(Succeed()) + vlanName = vlan.Name + vlanKey = client.ObjectKey{Name: vlanName, Namespace: metav1.NamespaceDefault} + + By("Creating the custom resource for the Kind Interface") + intf = &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-intf-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Name: "vlan10", + Type: v1alpha1.InterfaceTypeRoutedVLAN, + AdminState: v1alpha1.AdminStateUp, + VlanRef: &v1alpha1.LocalObjectReference{Name: vlanName}, + IPv4: &v1alpha1.InterfaceIPv4{ + Addresses: []v1alpha1.IPPrefix{{Prefix: netip.MustParsePrefix("10.0.0.1/24")}}, + }, + }, + } + Expect(k8sClient.Create(ctx, intf)).To(Succeed()) + interfaceName = intf.Name + interfaceKey = client.ObjectKey{Name: interfaceName, Namespace: metav1.NamespaceDefault} + + By("Waiting for Interface to be configured") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, interfaceKey, intf) + g.Expect(err).NotTo(HaveOccurred()) + cond := meta.FindStatusCondition(intf.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + }) + + AfterEach(func() { + By("Cleaning up the DHCPRelay resource") + dhcprelay = &v1alpha1.DHCPRelay{} + err := k8sClient.Get(ctx, resourceKey, dhcprelay) + if err == nil { + Expect(k8sClient.Delete(ctx, dhcprelay)).To(Succeed()) + + By("Waiting for DHCPRelay resource to be fully deleted") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, &v1alpha1.DHCPRelay{}) + g.Expect(errors.IsNotFound(err)).To(BeTrue()) + }).Should(Succeed()) + } + + By("Cleaning up the Interface resource") + intf = &v1alpha1.Interface{} + err = k8sClient.Get(ctx, interfaceKey, intf) + if err == nil { + Expect(k8sClient.Delete(ctx, intf)).To(Succeed()) + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, interfaceKey, &v1alpha1.Interface{}) + g.Expect(errors.IsNotFound(err)).To(BeTrue()) + }).Should(Succeed()) + } + + By("Cleaning up the VLAN resource") + vlan = &v1alpha1.VLAN{} + err = k8sClient.Get(ctx, vlanKey, vlan) + if err == nil { + Expect(k8sClient.Delete(ctx, vlan)).To(Succeed()) + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, vlanKey, &v1alpha1.VLAN{}) + g.Expect(errors.IsNotFound(err)).To(BeTrue()) + }).Should(Succeed()) + } + + By("Cleaning up the Device resource") + err = k8sClient.Get(ctx, deviceKey, device) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient.Delete(ctx, device, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) + + By("Verifying the resource has been deleted") + Eventually(func(g Gomega) { + g.Expect(testProvider.DHCPRelay).To(BeNil(), "Provider should have no DHCPRelay configured") + }).Should(Succeed()) + }) + + It("Should successfully reconcile the resource", func() { + By("Creating the custom resource for the Kind DHCPRelay") + dhcprelay = &v1alpha1.DHCPRelay{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DHCPRelaySpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Servers: []string{"192.168.1.1", "192.168.1.2"}, + InterfaceRefs: []v1alpha1.LocalObjectReference{ + {Name: interfaceName}, + }, + }, + } + Expect(k8sClient.Create(ctx, dhcprelay)).To(Succeed()) + resourceName = dhcprelay.Name + resourceKey = client.ObjectKey{Name: resourceName, Namespace: metav1.NamespaceDefault} + + By("Verifying the controller adds a finalizer") + Eventually(func(g Gomega) { + dhcprelay = &v1alpha1.DHCPRelay{} + g.Expect(k8sClient.Get(ctx, resourceKey, dhcprelay)).To(Succeed()) + g.Expect(controllerutil.ContainsFinalizer(dhcprelay, v1alpha1.FinalizerName)).To(BeTrue()) + }).Should(Succeed()) + + By("Verifying the controller adds the device label") + Eventually(func(g Gomega) { + dhcprelay = &v1alpha1.DHCPRelay{} + g.Expect(k8sClient.Get(ctx, resourceKey, dhcprelay)).To(Succeed()) + g.Expect(dhcprelay.Labels).To(HaveKeyWithValue(v1alpha1.DeviceLabel, deviceName)) + }).Should(Succeed()) + + By("Verifying the controller sets the owner reference") + Eventually(func(g Gomega) { + dhcprelay = &v1alpha1.DHCPRelay{} + g.Expect(k8sClient.Get(ctx, resourceKey, dhcprelay)).To(Succeed()) + g.Expect(dhcprelay.OwnerReferences).To(HaveLen(1)) + g.Expect(dhcprelay.OwnerReferences[0].Kind).To(Equal("Device")) + g.Expect(dhcprelay.OwnerReferences[0].Name).To(Equal(deviceName)) + }).Should(Succeed()) + + By("Verifying the controller updates the status conditions") + Eventually(func(g Gomega) { + dhcprelay = &v1alpha1.DHCPRelay{} + g.Expect(k8sClient.Get(ctx, resourceKey, dhcprelay)).To(Succeed()) + + cond := meta.FindStatusCondition(dhcprelay.Status.Conditions, v1alpha1.ReadyCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Verifying the status contains configured interface refs") + Eventually(func(g Gomega) { + dhcprelay = &v1alpha1.DHCPRelay{} + g.Expect(k8sClient.Get(ctx, resourceKey, dhcprelay)).To(Succeed()) + g.Expect(dhcprelay.Status.ConfiguredInterfaces).To(ContainElement(intf.Spec.Name)) + }).Should(Succeed()) + + By("Ensuring the DHCPRelay is created in the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.DHCPRelay).ToNot(BeNil(), "Provider DHCPRelay should not be nil") + if testProvider.DHCPRelay != nil { + g.Expect(testProvider.DHCPRelay.GetName()).To(Equal(resourceName), "Provider should have DHCPRelay configured") + } + }).Should(Succeed()) + }) + + It("Should reject duplicate DHCPRelay resources on the same device", func() { + By("Creating the first DHCPRelay resource") + dhcprelay = &v1alpha1.DHCPRelay{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DHCPRelaySpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Servers: []string{"192.168.1.1"}, + InterfaceRefs: []v1alpha1.LocalObjectReference{ + {Name: interfaceName}, + }, + }, + } + Expect(k8sClient.Create(ctx, dhcprelay)).To(Succeed()) + resourceName = dhcprelay.Name + resourceKey = client.ObjectKey{Name: resourceName, Namespace: metav1.NamespaceDefault} + + By("Waiting for the first DHCPRelay to be ready") + Eventually(func(g Gomega) { + dhcprelay = &v1alpha1.DHCPRelay{} + g.Expect(k8sClient.Get(ctx, resourceKey, dhcprelay)).To(Succeed()) + cond := meta.FindStatusCondition(dhcprelay.Status.Conditions, v1alpha1.ReadyCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Creating a second DHCPRelay resource for the same device") + duplicateDHCPRelay := &v1alpha1.DHCPRelay{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-dup-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DHCPRelaySpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Servers: []string{"192.168.1.1"}, + InterfaceRefs: []v1alpha1.LocalObjectReference{ + {Name: interfaceName}, + }, + }, + } + Expect(k8sClient.Create(ctx, duplicateDHCPRelay)).To(Succeed()) + duplicateKey := client.ObjectKey{Name: duplicateDHCPRelay.Name, Namespace: metav1.NamespaceDefault} + + By("Verifying the second DHCPRelay has a ConfiguredCondition=False with DuplicateResourceOnDevice reason") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, duplicateKey, duplicateDHCPRelay) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(duplicateDHCPRelay.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(v1alpha1.DuplicateResourceOnDevice)) + }).Should(Succeed()) + + By("Cleaning up the duplicate DHCPRelay resource") + Expect(k8sClient.Delete(ctx, duplicateDHCPRelay)).To(Succeed()) + }) + + It("Should properly handle deletion and cleanup", func() { + By("Creating the custom resource for the Kind DHCPRelay") + dhcprelay = &v1alpha1.DHCPRelay{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DHCPRelaySpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Servers: []string{"192.168.1.1"}, + InterfaceRefs: []v1alpha1.LocalObjectReference{ + {Name: interfaceName}, + }, + }, + } + Expect(k8sClient.Create(ctx, dhcprelay)).To(Succeed()) + resourceName = dhcprelay.Name + resourceKey = client.ObjectKey{Name: resourceName, Namespace: metav1.NamespaceDefault} + + By("Waiting for the DHCPRelay to be ready") + Eventually(func(g Gomega) { + dhcprelay = &v1alpha1.DHCPRelay{} + g.Expect(k8sClient.Get(ctx, resourceKey, dhcprelay)).To(Succeed()) + cond := meta.FindStatusCondition(dhcprelay.Status.Conditions, v1alpha1.ReadyCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Verifying DHCPRelay is created in the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.DHCPRelay).ToNot(BeNil()) + }).Should(Succeed()) + + By("Deleting the DHCPRelay resource") + Expect(k8sClient.Delete(ctx, dhcprelay)).To(Succeed()) + + By("Verifying the DHCPRelay is removed from the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.DHCPRelay).To(BeNil(), "Provider should have no DHCPRelay configured after deletion") + }).Should(Succeed()) + + By("Verifying the resource is fully deleted") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, &v1alpha1.DHCPRelay{}) + g.Expect(errors.IsNotFound(err)).To(BeTrue()) + }).Should(Succeed()) + }) + }) + + Context("When DeviceRef references non-existent Device", func() { + var ( + resourceName string + resourceKey client.ObjectKey + ) + + AfterEach(func() { + By("Cleaning up the DHCPRelay resource") + dhcprelay := &v1alpha1.DHCPRelay{} + err := k8sClient.Get(ctx, resourceKey, dhcprelay) + if err == nil { + // Remove finalizer if present to allow deletion + if controllerutil.ContainsFinalizer(dhcprelay, v1alpha1.FinalizerName) { + controllerutil.RemoveFinalizer(dhcprelay, v1alpha1.FinalizerName) + Expect(k8sClient.Update(ctx, dhcprelay)).To(Succeed()) + } + Expect(k8sClient.Delete(ctx, dhcprelay)).To(Succeed()) + } + }) + + It("Should not add finalizer when Device does not exist", func() { + By("Creating DHCPRelay referencing a non-existent Device") + dhcprelay := &v1alpha1.DHCPRelay{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-nodev-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DHCPRelaySpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: "non-existent-device"}, + Servers: []string{"192.168.1.1"}, + InterfaceRefs: []v1alpha1.LocalObjectReference{ + {Name: "test-interface"}, + }, + }, + } + Expect(k8sClient.Create(ctx, dhcprelay)).To(Succeed()) + resourceName = dhcprelay.Name + resourceKey = client.ObjectKey{Name: resourceName, Namespace: metav1.NamespaceDefault} + + By("Verifying the controller does not add a finalizer") + Consistently(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, dhcprelay) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(controllerutil.ContainsFinalizer(dhcprelay, v1alpha1.FinalizerName)).To(BeFalse()) + }).Should(Succeed()) + }) + }) + + Context("When InterfaceRef references non-existent Interface", func() { + var ( + deviceName string + resourceName string + resourceKey client.ObjectKey + deviceKey client.ObjectKey + device *v1alpha1.Device + ) + + BeforeEach(func() { + By("Creating the Device resource") + device = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-noint-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.51:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + deviceName = device.Name + deviceKey = client.ObjectKey{Name: deviceName, Namespace: metav1.NamespaceDefault} + }) + + AfterEach(func() { + By("Cleaning up the DHCPRelay resource") + dhcprelay := &v1alpha1.DHCPRelay{} + err := k8sClient.Get(ctx, resourceKey, dhcprelay) + if err == nil { + Expect(k8sClient.Delete(ctx, dhcprelay)).To(Succeed()) + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, &v1alpha1.DHCPRelay{}) + g.Expect(errors.IsNotFound(err)).To(BeTrue()) + }).Should(Succeed()) + } + + By("Cleaning up the Device resource") + err = k8sClient.Get(ctx, deviceKey, device) + if err == nil { + Expect(k8sClient.Delete(ctx, device, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) + } + }) + + It("Should set ConfiguredCondition to False when Interface does not exist", func() { + By("Creating DHCPRelay referencing a non-existent Interface") + dhcprelay := &v1alpha1.DHCPRelay{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-noint-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DHCPRelaySpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Servers: []string{"192.168.1.1"}, + InterfaceRefs: []v1alpha1.LocalObjectReference{ + {Name: "non-existent-interface"}, + }, + }, + } + Expect(k8sClient.Create(ctx, dhcprelay)).To(Succeed()) + resourceName = dhcprelay.Name + resourceKey = client.ObjectKey{Name: resourceName, Namespace: metav1.NamespaceDefault} + + By("Verifying the controller sets ConfiguredCondition to False with WaitingForDependenciesReason") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, dhcprelay) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(dhcprelay.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(v1alpha1.WaitingForDependenciesReason)) + }).Should(Succeed()) + }) + }) + + Context("When InterfaceRef belongs to a different device", func() { + var ( + deviceName string + otherDeviceName string + resourceName string + otherIntfName string + otherVlanName string + resourceKey client.ObjectKey + deviceKey client.ObjectKey + otherDeviceKey client.ObjectKey + otherIntfKey client.ObjectKey + otherVlanKey client.ObjectKey + device *v1alpha1.Device + otherDevice *v1alpha1.Device + otherVlan *v1alpha1.VLAN + otherIntf *v1alpha1.Interface + ) + + BeforeEach(func() { + By("Creating the Device resource") + device = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-crossdev-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.52:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + deviceName = device.Name + deviceKey = client.ObjectKey{Name: deviceName, Namespace: metav1.NamespaceDefault} + + By("Creating another Device resource") + otherDevice = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-crossdev-other-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.53:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, otherDevice)).To(Succeed()) + otherDeviceName = otherDevice.Name + otherDeviceKey = client.ObjectKey{Name: otherDeviceName, Namespace: metav1.NamespaceDefault} + + By("Creating a VLAN on the other Device") + otherVlan = &v1alpha1.VLAN{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-crossdev-vlan-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.VLANSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: otherDeviceName}, + ID: 20, + Name: "vlan20", + }, + } + Expect(k8sClient.Create(ctx, otherVlan)).To(Succeed()) + otherVlanName = otherVlan.Name + otherVlanKey = client.ObjectKey{Name: otherVlanName, Namespace: metav1.NamespaceDefault} + + By("Creating an Interface on the other Device") + otherIntf = &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-crossdev-intf-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: otherDeviceName}, + Name: "vlan20", + Type: v1alpha1.InterfaceTypeRoutedVLAN, + VlanRef: &v1alpha1.LocalObjectReference{Name: otherVlanName}, + AdminState: v1alpha1.AdminStateUp, + IPv4: &v1alpha1.InterfaceIPv4{ + Addresses: []v1alpha1.IPPrefix{{Prefix: netip.MustParsePrefix("10.0.1.1/24")}}, + }, + }, + } + Expect(k8sClient.Create(ctx, otherIntf)).To(Succeed()) + otherIntfName = otherIntf.Name + otherIntfKey = client.ObjectKey{Name: otherIntfName, Namespace: metav1.NamespaceDefault} + }) + + AfterEach(func() { + By("Cleaning up the DHCPRelay resource") + dhcprelay := &v1alpha1.DHCPRelay{} + err := k8sClient.Get(ctx, resourceKey, dhcprelay) + if err == nil { + Expect(k8sClient.Delete(ctx, dhcprelay)).To(Succeed()) + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, &v1alpha1.DHCPRelay{}) + g.Expect(errors.IsNotFound(err)).To(BeTrue()) + }).Should(Succeed()) + } + + By("Cleaning up the Interface resource") + err = k8sClient.Get(ctx, otherIntfKey, otherIntf) + if err == nil { + Expect(k8sClient.Delete(ctx, otherIntf)).To(Succeed()) + } + + By("Cleaning up the VLAN resource") + err = k8sClient.Get(ctx, otherVlanKey, otherVlan) + if err == nil { + Expect(k8sClient.Delete(ctx, otherVlan)).To(Succeed()) + } + + By("Cleaning up the Device resources") + err = k8sClient.Get(ctx, deviceKey, device) + if err == nil { + Expect(k8sClient.Delete(ctx, device, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) + } + err = k8sClient.Get(ctx, otherDeviceKey, otherDevice) + if err == nil { + Expect(k8sClient.Delete(ctx, otherDevice, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) + } + }) + + It("Should set ConfiguredCondition to False with CrossDeviceReferenceReason", func() { + By("Creating DHCPRelay referencing an Interface from a different device") + dhcprelay := &v1alpha1.DHCPRelay{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-crossdev-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DHCPRelaySpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Servers: []string{"192.168.1.1"}, + InterfaceRefs: []v1alpha1.LocalObjectReference{ + {Name: otherIntfName}, + }, + }, + } + Expect(k8sClient.Create(ctx, dhcprelay)).To(Succeed()) + resourceName = dhcprelay.Name + resourceKey = client.ObjectKey{Name: resourceName, Namespace: metav1.NamespaceDefault} + + By("Verifying the controller sets ConfiguredCondition to False with CrossDeviceReferenceReason") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, dhcprelay) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(dhcprelay.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(v1alpha1.CrossDeviceReferenceReason)) + }).Should(Succeed()) + }) + }) + + Context("When VrfRef belongs to a different device", func() { + var ( + deviceName string + otherDeviceName string + resourceName string + interfaceName string + vlanName string + otherVrfName string + resourceKey client.ObjectKey + deviceKey client.ObjectKey + otherDeviceKey client.ObjectKey + interfaceKey client.ObjectKey + vlanKey client.ObjectKey + otherVrfKey client.ObjectKey + device *v1alpha1.Device + otherDevice *v1alpha1.Device + vlan *v1alpha1.VLAN + intf *v1alpha1.Interface + otherVrf *v1alpha1.VRF + ) + + BeforeEach(func() { + By("Creating the Device resource") + device = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-vrfcross-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.57:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + deviceName = device.Name + deviceKey = client.ObjectKey{Name: deviceName, Namespace: metav1.NamespaceDefault} + + By("Creating another Device resource") + otherDevice = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-vrfcross-other-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.58:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, otherDevice)).To(Succeed()) + otherDeviceName = otherDevice.Name + otherDeviceKey = client.ObjectKey{Name: otherDeviceName, Namespace: metav1.NamespaceDefault} + + By("Creating a VLAN on the main Device") + vlan = &v1alpha1.VLAN{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-vrfcross-vlan-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.VLANSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + ID: 60, + Name: "vlan60", + }, + } + Expect(k8sClient.Create(ctx, vlan)).To(Succeed()) + vlanName = vlan.Name + vlanKey = client.ObjectKey{Name: vlanName, Namespace: metav1.NamespaceDefault} + + By("Creating an Interface on the main Device") + intf = &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-vrfcross-intf-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Name: "vlan60", + Type: v1alpha1.InterfaceTypeRoutedVLAN, + VlanRef: &v1alpha1.LocalObjectReference{Name: vlanName}, + AdminState: v1alpha1.AdminStateUp, + IPv4: &v1alpha1.InterfaceIPv4{ + Addresses: []v1alpha1.IPPrefix{{Prefix: netip.MustParsePrefix("10.0.6.1/24")}}, + }, + }, + } + Expect(k8sClient.Create(ctx, intf)).To(Succeed()) + interfaceName = intf.Name + interfaceKey = client.ObjectKey{Name: interfaceName, Namespace: metav1.NamespaceDefault} + + By("Waiting for Interface to be configured") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, interfaceKey, intf) + g.Expect(err).NotTo(HaveOccurred()) + cond := meta.FindStatusCondition(intf.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Creating a VRF on the other Device") + otherVrf = &v1alpha1.VRF{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-vrfcross-vrf-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.VRFSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: otherDeviceName}, + Name: "VRF-OTHER", + }, + } + Expect(k8sClient.Create(ctx, otherVrf)).To(Succeed()) + otherVrfName = otherVrf.Name + otherVrfKey = client.ObjectKey{Name: otherVrfName, Namespace: metav1.NamespaceDefault} + }) + + AfterEach(func() { + By("Cleaning up the DHCPRelay resource") + dhcprelay := &v1alpha1.DHCPRelay{} + err := k8sClient.Get(ctx, resourceKey, dhcprelay) + if err == nil { + Expect(k8sClient.Delete(ctx, dhcprelay)).To(Succeed()) + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, &v1alpha1.DHCPRelay{}) + g.Expect(errors.IsNotFound(err)).To(BeTrue()) + }).Should(Succeed()) + } + + By("Cleaning up the VRF resource") + err = k8sClient.Get(ctx, otherVrfKey, otherVrf) + if err == nil { + Expect(k8sClient.Delete(ctx, otherVrf)).To(Succeed()) + } + + By("Cleaning up the Interface resource") + err = k8sClient.Get(ctx, interfaceKey, intf) + if err == nil { + Expect(k8sClient.Delete(ctx, intf)).To(Succeed()) + } + + By("Cleaning up the VLAN resource") + err = k8sClient.Get(ctx, vlanKey, vlan) + if err == nil { + Expect(k8sClient.Delete(ctx, vlan)).To(Succeed()) + } + + By("Cleaning up the Device resources") + err = k8sClient.Get(ctx, deviceKey, device) + if err == nil { + Expect(k8sClient.Delete(ctx, device, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) + } + err = k8sClient.Get(ctx, otherDeviceKey, otherDevice) + if err == nil { + Expect(k8sClient.Delete(ctx, otherDevice, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) + } + }) + + It("Should set ConfiguredCondition to False with CrossDeviceReferenceReason", func() { + By("Creating DHCPRelay referencing a VRF from a different device") + dhcprelay := &v1alpha1.DHCPRelay{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-vrfcross-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DHCPRelaySpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Servers: []string{"192.168.1.1"}, + InterfaceRefs: []v1alpha1.LocalObjectReference{ + {Name: interfaceName}, + }, + VrfRef: &v1alpha1.LocalObjectReference{Name: otherVrfName}, + }, + } + Expect(k8sClient.Create(ctx, dhcprelay)).To(Succeed()) + resourceName = dhcprelay.Name + resourceKey = client.ObjectKey{Name: resourceName, Namespace: metav1.NamespaceDefault} + + By("Verifying the controller sets ConfiguredCondition to False with CrossDeviceReferenceReason") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, dhcprelay) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(dhcprelay.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(v1alpha1.CrossDeviceReferenceReason)) + g.Expect(cond.Message).To(ContainSubstring("VRF")) + }).Should(Succeed()) + }) + }) + + Context("When Interface has unnumbered IPv4 configuration", func() { + var ( + deviceName string + resourceName string + loopbackIntfName string + unnumberedIntfName string + resourceKey client.ObjectKey + deviceKey client.ObjectKey + loopbackIntfKey client.ObjectKey + unnumberedIntfKey client.ObjectKey + device *v1alpha1.Device + loopbackIntf *v1alpha1.Interface + unnumberedIntf *v1alpha1.Interface + ) + + BeforeEach(func() { + By("Creating the Device resource") + device = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-unnum-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.54:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + deviceName = device.Name + deviceKey = client.ObjectKey{Name: deviceName, Namespace: metav1.NamespaceDefault} + + By("Creating a loopback Interface with an IP address") + loopbackIntf = &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-unnum-lo-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Name: "loopback0", + Type: v1alpha1.InterfaceTypeLoopback, + AdminState: v1alpha1.AdminStateUp, + IPv4: &v1alpha1.InterfaceIPv4{ + Addresses: []v1alpha1.IPPrefix{{Prefix: netip.MustParsePrefix("10.255.255.1/32")}}, + }, + }, + } + Expect(k8sClient.Create(ctx, loopbackIntf)).To(Succeed()) + loopbackIntfName = loopbackIntf.Name + loopbackIntfKey = client.ObjectKey{Name: loopbackIntfName, Namespace: metav1.NamespaceDefault} + + By("Waiting for loopback Interface to be ready") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, loopbackIntfKey, loopbackIntf) + g.Expect(err).NotTo(HaveOccurred()) + cond := meta.FindStatusCondition(loopbackIntf.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Creating an unnumbered Interface referencing the loopback") + unnumberedIntf = &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-unnum-intf-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Name: "ethernet1/1", + Type: v1alpha1.InterfaceTypePhysical, + AdminState: v1alpha1.AdminStateUp, + IPv4: &v1alpha1.InterfaceIPv4{ + Unnumbered: &v1alpha1.InterfaceIPv4Unnumbered{ + InterfaceRef: v1alpha1.LocalObjectReference{Name: loopbackIntfName}, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, unnumberedIntf)).To(Succeed()) + unnumberedIntfName = unnumberedIntf.Name + unnumberedIntfKey = client.ObjectKey{Name: unnumberedIntfName, Namespace: metav1.NamespaceDefault} + + By("Waiting for unnumbered Interface to be configured") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, unnumberedIntfKey, unnumberedIntf) + g.Expect(err).NotTo(HaveOccurred()) + cond := meta.FindStatusCondition(unnumberedIntf.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + }) + + AfterEach(func() { + By("Cleaning up the DHCPRelay resource") + dhcprelay := &v1alpha1.DHCPRelay{} + err := k8sClient.Get(ctx, resourceKey, dhcprelay) + if err == nil { + Expect(k8sClient.Delete(ctx, dhcprelay)).To(Succeed()) + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, &v1alpha1.DHCPRelay{}) + g.Expect(errors.IsNotFound(err)).To(BeTrue()) + }).Should(Succeed()) + } + + By("Cleaning up the unnumbered Interface resource") + err = k8sClient.Get(ctx, unnumberedIntfKey, unnumberedIntf) + if err == nil { + Expect(k8sClient.Delete(ctx, unnumberedIntf)).To(Succeed()) + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, unnumberedIntfKey, &v1alpha1.Interface{}) + g.Expect(errors.IsNotFound(err)).To(BeTrue()) + }).Should(Succeed()) + } + + By("Cleaning up the loopback Interface resource") + err = k8sClient.Get(ctx, loopbackIntfKey, loopbackIntf) + if err == nil { + Expect(k8sClient.Delete(ctx, loopbackIntf)).To(Succeed()) + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, loopbackIntfKey, &v1alpha1.Interface{}) + g.Expect(errors.IsNotFound(err)).To(BeTrue()) + }).Should(Succeed()) + } + + By("Cleaning up the Device resource") + err = k8sClient.Get(ctx, deviceKey, device) + if err == nil { + Expect(k8sClient.Delete(ctx, device, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) + } + + By("Verifying the provider has been cleaned up") + Eventually(func(g Gomega) { + g.Expect(testProvider.DHCPRelay).To(BeNil(), "Provider should have no DHCPRelay configured") + }).Should(Succeed()) + }) + + It("Should successfully reconcile with an unnumbered Interface", func() { + By("Creating DHCPRelay with an unnumbered Interface") + dhcprelay := &v1alpha1.DHCPRelay{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-unnum-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DHCPRelaySpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Servers: []string{"192.168.1.1"}, + InterfaceRefs: []v1alpha1.LocalObjectReference{ + {Name: unnumberedIntfName}, + }, + }, + } + Expect(k8sClient.Create(ctx, dhcprelay)).To(Succeed()) + resourceName = dhcprelay.Name + resourceKey = client.ObjectKey{Name: resourceName, Namespace: metav1.NamespaceDefault} + + By("Verifying the controller sets ReadyCondition to True") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, dhcprelay) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(dhcprelay.Status.Conditions, v1alpha1.ReadyCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Verifying the status contains configured interface refs") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, dhcprelay) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(dhcprelay.Status.ConfiguredInterfaces).To(ContainElement(unnumberedIntf.Spec.Name)) + }).Should(Succeed()) + + By("Ensuring the DHCPRelay is created in the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.DHCPRelay).ToNot(BeNil(), "Provider DHCPRelay should not be nil") + }).Should(Succeed()) + }) + }) + + Context("When Interface is not Ready", func() { + var ( + deviceName string + resourceName string + interfaceName string + vlanName string + resourceKey client.ObjectKey + deviceKey client.ObjectKey + interfaceKey client.ObjectKey + vlanKey client.ObjectKey + device *v1alpha1.Device + vlan *v1alpha1.VLAN + intf *v1alpha1.Interface + ) + + const nonExistentVrfName = "testdhcprelay-intfnotready-nonexistent-vrf" + + BeforeEach(func() { + By("Creating the Device resource") + device = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-intfnr-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.55:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + deviceName = device.Name + deviceKey = client.ObjectKey{Name: deviceName, Namespace: metav1.NamespaceDefault} + + By("Creating the VLAN resource") + vlan = &v1alpha1.VLAN{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-intfnr-vlan-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.VLANSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + ID: 40, + Name: "vlan40", + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, vlan)).To(Succeed()) + vlanName = vlan.Name + vlanKey = client.ObjectKey{Name: vlanName, Namespace: metav1.NamespaceDefault} + + By("Creating the Interface resource with a VRF reference to a non-existent VRF (will not become Ready)") + intf = &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-intfnr-intf-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Name: "vlan40", + AdminState: v1alpha1.AdminStateUp, + Type: v1alpha1.InterfaceTypeRoutedVLAN, + VlanRef: &v1alpha1.LocalObjectReference{Name: vlanName}, + VrfRef: &v1alpha1.LocalObjectReference{Name: nonExistentVrfName}, + IPv4: &v1alpha1.InterfaceIPv4{ + Addresses: []v1alpha1.IPPrefix{{Prefix: netip.MustParsePrefix("10.0.4.1/24")}}, + }, + }, + } + Expect(k8sClient.Create(ctx, intf)).To(Succeed()) + interfaceName = intf.Name + interfaceKey = client.ObjectKey{Name: interfaceName, Namespace: metav1.NamespaceDefault} + + By("Verifying the Interface is NOT Ready (because VRF doesn't exist)") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, interfaceKey, intf) + g.Expect(err).NotTo(HaveOccurred()) + cond := meta.FindStatusCondition(intf.Status.Conditions, v1alpha1.ReadyCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + }).Should(Succeed()) + }) + + AfterEach(func() { + By("Cleaning up the DHCPRelay resource") + dhcprelay := &v1alpha1.DHCPRelay{} + err := k8sClient.Get(ctx, resourceKey, dhcprelay) + if err == nil { + Expect(k8sClient.Delete(ctx, dhcprelay)).To(Succeed()) + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, &v1alpha1.DHCPRelay{}) + g.Expect(errors.IsNotFound(err)).To(BeTrue()) + }).Should(Succeed()) + } + + By("Cleaning up the Interface resource") + err = k8sClient.Get(ctx, interfaceKey, intf) + if err == nil { + Expect(k8sClient.Delete(ctx, intf)).To(Succeed()) + } + + By("Cleaning up the VLAN resource") + err = k8sClient.Get(ctx, vlanKey, vlan) + if err == nil { + Expect(k8sClient.Delete(ctx, vlan)).To(Succeed()) + } + + By("Cleaning up the Device resource") + device = &v1alpha1.Device{} + err = k8sClient.Get(ctx, deviceKey, device) + if err == nil { + Expect(k8sClient.Delete(ctx, device, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) + } + }) + + It("Should set ConfiguredCondition to False with WaitingForDependenciesReason when Interface is not configured", func() { + By("Creating DHCPRelay referencing a non-configured Interface") + dhcprelay := &v1alpha1.DHCPRelay{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-intfnr-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DHCPRelaySpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Servers: []string{"192.168.1.1"}, + InterfaceRefs: []v1alpha1.LocalObjectReference{ + {Name: interfaceName}, + }, + }, + } + Expect(k8sClient.Create(ctx, dhcprelay)).To(Succeed()) + resourceName = dhcprelay.Name + resourceKey = client.ObjectKey{Name: resourceName, Namespace: metav1.NamespaceDefault} + + By("Verifying the controller sets ConfiguredCondition to False with WaitingForDependenciesReason") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, dhcprelay) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(dhcprelay.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(v1alpha1.WaitingForDependenciesReason)) + g.Expect(cond.Message).To(ContainSubstring("not configured")) + }).Should(Succeed()) + }) + }) +}) diff --git a/internal/controller/core/lldp_controller_test.go b/internal/controller/core/lldp_controller_test.go index cf630b1d..ea58dabd 100644 --- a/internal/controller/core/lldp_controller_test.go +++ b/internal/controller/core/lldp_controller_test.go @@ -274,9 +274,7 @@ var _ = Describe("LLDP Controller", func() { }) Context("When DeviceRef references non-existent Device", func() { - var ( - resourceKey client.ObjectKey - ) + var resourceKey client.ObjectKey AfterEach(func() { By("Cleaning up the LLDP resource") diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go index bee5e525..ccfec943 100644 --- a/internal/controller/core/suite_test.go +++ b/internal/controller/core/suite_test.go @@ -330,6 +330,16 @@ var _ = BeforeSuite(func() { }).SetupWithManager(ctx, k8sManager) Expect(err).NotTo(HaveOccurred()) + err = (&DHCPRelayReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + Recorder: recorder, + Provider: prov, + Locker: testLocker, + RequeueInterval: time.Second, + }).SetupWithManager(ctx, k8sManager) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = k8sManager.Start(ctx) @@ -398,6 +408,7 @@ var ( _ provider.RoutingPolicyProvider = (*Provider)(nil) _ provider.NVEProvider = (*Provider)(nil) _ provider.LLDPProvider = (*Provider)(nil) + _ provider.DHCPRelayProvider = (*Provider)(nil) ) // Provider is a simple in-memory provider for testing purposes only. @@ -427,6 +438,7 @@ type Provider struct { RoutingPolicies sets.Set[string] NVE *v1alpha1.NetworkVirtualizationEdge LLDP *v1alpha1.LLDP + DHCPRelay *v1alpha1.DHCPRelay } func NewProvider() *Provider { @@ -862,3 +874,30 @@ func (p *Provider) DeleteLLDP(_ context.Context, req *provider.LLDPRequest) erro func (p *Provider) GetLLDPStatus(_ context.Context, _ *provider.LLDPRequest) (provider.LLDPStatus, error) { return provider.LLDPStatus{OperStatus: true}, nil } + +func (p *Provider) EnsureDHCPRelay(_ context.Context, req *provider.DHCPRelayRequest) error { + p.Lock() + defer p.Unlock() + p.DHCPRelay = req.DHCPRelay + return nil +} + +func (p *Provider) DeleteDHCPRelay(_ context.Context, req *provider.DHCPRelayRequest) error { + p.Lock() + defer p.Unlock() + p.DHCPRelay = nil + return nil +} + +func (p *Provider) GetDHCPRelayStatus(_ context.Context, req *provider.DHCPRelayRequest) (provider.DHCPRelayStatus, error) { + p.Lock() + defer p.Unlock() + status := provider.DHCPRelayStatus{} + if p.DHCPRelay != nil { + // Return the interface names from the request (simulating what the device would return) + for _, intf := range req.Interfaces { + status.ConfiguredInterfaces = append(status.ConfiguredInterfaces, intf.Spec.Name) + } + } + return status, nil +} From 5863bf49ed0cac9907989d1ecb7ee60eecf1f651 Mon Sep 17 00:00:00 2001 From: Pujol Date: Wed, 25 Mar 2026 13:50:21 +0100 Subject: [PATCH 3/6] [NXOS] Add `DHCPRelay` provider implementation Enable or disable the DHCP feature based on AdminState. When enabled, configure DHCP relay on each referenced interface with the specified server addresses. The provider uses the VRF context from VrfRef (or the NXOS default "!unspecified" if no VRF is specified) when configuring server addresses. The implementation uses the Update operation to ensure stale DHCP relay entries are removed when the configuration changes. This also affects entries referencing interfaces not managed by the operator. The entire tree is removed on deletion, affecting non-managed interfaces., It leaves the DHCP feature in its current state. GetDHCPRelayStatus queries the device for all interfaces with DHCP relay configured and returns their names. --- internal/provider/cisco/nxos/dhcprelay.go | 44 ++++++++++++++ internal/provider/cisco/nxos/provider.go | 60 +++++++++++++++++++ .../cisco/nxos/testdata/dhcprelay.json | 23 +++++++ .../cisco/nxos/testdata/dhcprelay.json.txt | 3 + 4 files changed, 130 insertions(+) create mode 100644 internal/provider/cisco/nxos/dhcprelay.go create mode 100644 internal/provider/cisco/nxos/testdata/dhcprelay.json create mode 100644 internal/provider/cisco/nxos/testdata/dhcprelay.json.txt diff --git a/internal/provider/cisco/nxos/dhcprelay.go b/internal/provider/cisco/nxos/dhcprelay.go new file mode 100644 index 00000000..30e67ca7 --- /dev/null +++ b/internal/provider/cisco/nxos/dhcprelay.go @@ -0,0 +1,44 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package nxos + +import ( + "net/netip" + + "github.com/ironcore-dev/network-operator/internal/provider/cisco/gnmiext/v2" +) + +var _ gnmiext.Configurable = (*DHCPRelayConfig)(nil) + +// DHCPRelayConfig represents the complete DHCP relay configuration tree. +type DHCPRelayConfig struct { + RelayIfList gnmiext.List[string, *DHCPRelay] `json:"RelayIf-list,omitzero"` +} + +func (*DHCPRelayConfig) XPath() string { + return "System/dhcp-items/inst-items/relayif-items" +} + +// DHCPRelay represents the DHCP Relay configuration for a single interface. +type DHCPRelay struct { + ID string `json:"id"` + AddrItems struct { + AddrList gnmiext.List[netip.Addr, *DHCPRelayServer] `json:"RelayAddr-list,omitzero"` + } `json:"addr-items"` +} + +func (d *DHCPRelay) Key() string { + return d.ID +} + +func (*DHCPRelay) IsListItem() {} + +type DHCPRelayServer struct { + Address netip.Addr `json:"address"` + Vrf string `json:"vrf,omitempty"` +} + +func (d *DHCPRelayServer) Key() netip.Addr { + return d.Address +} diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index 8258dc87..dca62843 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -58,6 +58,7 @@ var ( _ provider.VRFProvider = (*Provider)(nil) _ provider.NVEProvider = (*Provider)(nil) _ provider.LLDPProvider = (*Provider)(nil) + _ provider.DHCPRelayProvider = (*Provider)(nil) ) type Provider struct { @@ -2867,6 +2868,65 @@ func separateFeatureActivation(conf []gnmiext.Configurable) (features, others [] return fa, conf[:n:n] } +// EnsureDHCPRelay configures DHCP relay on the specified interfaces. +// Replaces the entire DHCP relay configuration on the device with the provided configuration in the request. +func (p *Provider) EnsureDHCPRelay(ctx context.Context, req *provider.DHCPRelayRequest) error { + f := new(Feature) + f.Name = "dhcp" + f.AdminSt = AdminStEnabled + + // undocumented default value for the VRF property in DME (can be verified via NX-API) + vrfName := "!unspecified" + if req.VRF != nil { + vrfName = req.VRF.Spec.Name + } + + conf := new(DHCPRelayConfig) + for _, intf := range req.Interfaces { + ifName, err := ShortName(intf.Spec.Name) + if err != nil { + return fmt.Errorf("dhcp relay: failed to get short name for interface %q: %w", intf.Spec.Name, err) + } + + relay := &DHCPRelay{ID: ifName} + for _, addr := range req.DHCPRelay.Spec.Servers { + a, err := netip.ParseAddr(addr) + if err != nil { + return fmt.Errorf("dhcp relay: invalid server address %q: %w", addr, err) + } + relay.AddrItems.AddrList.Set(&DHCPRelayServer{Address: a, Vrf: vrfName}) + } + conf.RelayIfList.Set(relay) + } + + return p.Update(ctx, f, conf) +} + +// DeleteDHCPRelay removes all DHCP relay configurations from the device. +func (p *Provider) DeleteDHCPRelay(ctx context.Context, req *provider.DHCPRelayRequest) error { + config := new(DHCPRelayConfig) + return p.client.Delete(ctx, config) +} + +// GetDHCPRelayStatus retrieves the current DHCP relay status. +func (p *Provider) GetDHCPRelayStatus(ctx context.Context, req *provider.DHCPRelayRequest) (provider.DHCPRelayStatus, error) { + s := provider.DHCPRelayStatus{} + config := new(DHCPRelayConfig) + if err := p.client.GetConfig(ctx, config); err != nil { + if errors.Is(err, gnmiext.ErrNil) { + return s, nil + } + return s, fmt.Errorf("dhcp relay: failed to get status: %w", err) + } + + s.ConfiguredInterfaces = make([]string, 0, config.RelayIfList.Len()) + for _, relay := range config.RelayIfList { + s.ConfiguredInterfaces = append(s.ConfiguredInterfaces, relay.ID) + } + + return s, nil +} + func init() { provider.Register("cisco-nxos-gnmi", NewProvider) } diff --git a/internal/provider/cisco/nxos/testdata/dhcprelay.json b/internal/provider/cisco/nxos/testdata/dhcprelay.json new file mode 100644 index 00000000..be749f06 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/dhcprelay.json @@ -0,0 +1,23 @@ +{ + "dhcp-items": { + "inst-items": { + "relayif-items": { + "RelayIf-list": [ + { + "addr-items": { + "RelayAddr-list": [ + { + "address": "1.1.1.1" + }, + { + "address": "2.2.2.2", + "vrf": "mgmt0" + } + ] + } + } + ] + } + } + } +} diff --git a/internal/provider/cisco/nxos/testdata/dhcprelay.json.txt b/internal/provider/cisco/nxos/testdata/dhcprelay.json.txt new file mode 100644 index 00000000..4b1d0b0c --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/dhcprelay.json.txt @@ -0,0 +1,3 @@ +int vlan2 + ip dhcp relay address 1.1.1.1 + ip dhcp relay address 2.2.2.2 vrf mgmt0 From 2fbc0031f21faed866663cfe411aeea60ca2057f Mon Sep 17 00:00:00 2001 From: Pujol Date: Wed, 25 Mar 2026 13:50:24 +0100 Subject: [PATCH 4/6] fix: testdata filename typo --- .../testdata/{syslog_srcif.json.txtt => syslog_srcif.json.txt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename internal/provider/cisco/nxos/testdata/{syslog_srcif.json.txtt => syslog_srcif.json.txt} (100%) diff --git a/internal/provider/cisco/nxos/testdata/syslog_srcif.json.txtt b/internal/provider/cisco/nxos/testdata/syslog_srcif.json.txt similarity index 100% rename from internal/provider/cisco/nxos/testdata/syslog_srcif.json.txtt rename to internal/provider/cisco/nxos/testdata/syslog_srcif.json.txt From f3a91a94df0a0fe58592699365a458d810a84e76 Mon Sep 17 00:00:00 2001 From: Pujol Date: Wed, 25 Mar 2026 13:50:28 +0100 Subject: [PATCH 5/6] fix: update nolint directive SA1019: LoadCertificate is deprecated but we still it on NXOS --- internal/provider/cisco/nxos/cert.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/provider/cisco/nxos/cert.go b/internal/provider/cisco/nxos/cert.go index 7691bf9d..033c2fa1 100644 --- a/internal/provider/cisco/nxos/cert.go +++ b/internal/provider/cisco/nxos/cert.go @@ -41,7 +41,7 @@ func (c *Certificate) Load(ctx context.Context, conn *grpc.ClientConn, trustpoin // Only the `LoadCertificate` method is currently supported on the Nexus 9000 series. Even though it's already deprecated. // See: https://www.cisco.com/c/en/us/td/docs/dcn/nx-os/nexus9000/104x/programmability/cisco-nexus-9000-series-nx-os-programmability-guide-104x/gnoi---operation-interface.html - _, err = cert.NewCertificateManagementClient(conn).LoadCertificate(ctx, &cert.LoadCertificateRequest{ //nolint:staticcheck + _, err = cert.NewCertificateManagementClient(conn).LoadCertificate(ctx, &cert.LoadCertificateRequest{ //nolint:staticcheck // SA1019: LoadCertificate is deprecated but we still need to use it for NX-OS devices. Certificate: &cert.Certificate{Type: cert.CertificateType_CT_X509, Certificate: b}, KeyPair: &cert.KeyPair{PrivateKey: priv, PublicKey: pub}, CertificateId: trustpoint, From 2fdd75a6dc4c839a231c86c39f0ed8b553442782 Mon Sep 17 00:00:00 2001 From: Pujol Date: Wed, 25 Mar 2026 16:48:40 +0100 Subject: [PATCH 6/6] [core] Use device label instead of field indexer in LLDP controller --- internal/controller/core/lldp_controller.go | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/internal/controller/core/lldp_controller.go b/internal/controller/core/lldp_controller.go index 88b01d99..bc402686 100644 --- a/internal/controller/core/lldp_controller.go +++ b/internal/controller/core/lldp_controller.go @@ -388,7 +388,7 @@ func (r *LLDPReconciler) validateUniqueLLDPPerDevice(ctx context.Context, s *lld var list v1alpha1.LLDPList if err := r.List(ctx, &list, client.InNamespace(s.LLDP.Namespace), - client.MatchingFields{".spec.deviceRef.name": s.LLDP.Spec.DeviceRef.Name}, + client.MatchingLabels{v1alpha1.DeviceLabel: s.Device.Name}, ); err != nil { return err } @@ -422,13 +422,6 @@ func (r *LLDPReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) return fmt.Errorf("failed to create label selector predicate: %w", err) } - if err := mgr.GetFieldIndexer().IndexField(ctx, &v1alpha1.LLDP{}, ".spec.deviceRef.name", func(obj client.Object) []string { - lldp := obj.(*v1alpha1.LLDP) - return []string{lldp.Spec.DeviceRef.Name} - }); err != nil { - return err - } - c := ctrl.NewControllerManagedBy(mgr). For(&v1alpha1.LLDP{}). Named("lldp").