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.