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/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/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/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/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/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/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..40b8b68c --- /dev/null +++ b/config/samples/v1alpha1_dhcprelay.yaml @@ -0,0 +1,16 @@ +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.3 + - 192.168.1.4 + interfaceRefs: + - 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/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.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"). 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 +} 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, 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 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 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.