From 8bdbff4292d1517974f176ce4d625381e07c9ee5 Mon Sep 17 00:00:00 2001 From: Pujol Date: Fri, 27 Feb 2026 16:37:10 +0100 Subject: [PATCH 1/4] Add brick `LLDP` * Introduces the LLDP type and controller to configure Link Layer Discovery Protocol on network devices. --- PROJECT | 8 + Tiltfile | 3 + api/core/v1alpha1/groupversion_info.go | 3 + api/core/v1alpha1/lldp_types.go | 108 +++++ api/core/v1alpha1/zz_generated.deepcopy.go | 102 ++++ .../lldps.networking.metal.ironcore.dev.yaml | 211 +++++++++ .../templates/rbac/lldp-admin-role.yaml | 24 + .../templates/rbac/lldp-editor-role.yaml | 30 ++ .../templates/rbac/lldp-viewer-role.yaml | 26 ++ .../templates/rbac/manager-role.yaml | 3 + cmd/main.go | 13 + .../networking.metal.ironcore.dev_lldps.yaml | 207 +++++++++ config/crd/kustomization.yaml | 1 + config/rbac/kustomization.yaml | 3 + config/rbac/lldp_admin_role.yaml | 27 ++ config/rbac/lldp_editor_role.yaml | 33 ++ config/rbac/lldp_viewer_role.yaml | 29 ++ config/rbac/role.yaml | 3 + config/samples/kustomization.yaml | 1 + config/samples/v1alpha1_lldp.yaml | 18 + docs/api-reference/index.md | 57 +++ internal/controller/core/lldp_controller.go | 438 ++++++++++++++++++ .../controller/core/lldp_controller_test.go | 277 +++++++++++ internal/controller/core/suite_test.go | 30 ++ internal/provider/provider.go | 24 + 25 files changed, 1679 insertions(+) create mode 100644 api/core/v1alpha1/lldp_types.go create mode 100644 charts/network-operator/templates/crd/lldps.networking.metal.ironcore.dev.yaml create mode 100644 charts/network-operator/templates/rbac/lldp-admin-role.yaml create mode 100644 charts/network-operator/templates/rbac/lldp-editor-role.yaml create mode 100644 charts/network-operator/templates/rbac/lldp-viewer-role.yaml create mode 100644 config/crd/bases/networking.metal.ironcore.dev_lldps.yaml create mode 100644 config/rbac/lldp_admin_role.yaml create mode 100644 config/rbac/lldp_editor_role.yaml create mode 100644 config/rbac/lldp_viewer_role.yaml create mode 100644 config/samples/v1alpha1_lldp.yaml create mode 100644 internal/controller/core/lldp_controller.go create mode 100644 internal/controller/core/lldp_controller_test.go diff --git a/PROJECT b/PROJECT index 77765743..4e0dd1f5 100644 --- a/PROJECT +++ b/PROJECT @@ -262,4 +262,12 @@ resources: kind: InterfaceConfig 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: LLDP + path: github.com/ironcore-dev/network-operator/api/core/v1alpha1 + version: v1alpha1 version: "3" diff --git a/Tiltfile b/Tiltfile index da365942..7ced06c3 100644 --- a/Tiltfile +++ b/Tiltfile @@ -117,6 +117,9 @@ k8s_resource(new_name='nve1', objects=['nve1:networkvirtualizationedge'], trigge # k8s_yaml('./config/samples/cisco/nx/v1alpha1_nveconfig.yaml') # k8s_resource(new_name='nve1-cfg', objects=['nve1-cfg:networkvirtualizationedgeconfig'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False) +k8s_yaml('./config/samples/v1alpha1_lldp.yaml') +k8s_resource(new_name='lldp', objects=['leaf1-lldp:lldp'], 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/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go index 80370e38..9a86d27b 100644 --- a/api/core/v1alpha1/groupversion_info.go +++ b/api/core/v1alpha1/groupversion_info.go @@ -158,6 +158,9 @@ const ( // IncompatibleProviderConfigRef indicates that the referenced provider configuration is not compatible with the target platform. IncompatibleProviderConfigRef = "IncompatibleProviderConfigRef" + + // DuplicateResourceOnDevice indicates that a resource of the same type as the one being created already exists on the target device. + DuplicateResourceOnDevice = "DuplicateResourceOnDevice" ) // Reasons that are specific to [Interface] objects. diff --git a/api/core/v1alpha1/lldp_types.go b/api/core/v1alpha1/lldp_types.go new file mode 100644 index 00000000..a723cfa4 --- /dev/null +++ b/api/core/v1alpha1/lldp_types.go @@ -0,0 +1,108 @@ +// 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" +) + +// LLDPSpec defines the desired state of LLDP +type LLDPSpec struct { + // DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace. + // Immutable. + // +required + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="DeviceRef is immutable" + DeviceRef LocalObjectReference `json:"deviceRef"` + + // ProviderConfigRef is a reference to a resource holding the provider-specific configuration for this LLDP. + // If not specified the provider applies the target platform's default settings. + // +optional + ProviderConfigRef *TypedLocalObjectReference `json:"providerConfigRef,omitempty"` + + // AdminState indicates whether LLDP is system-wide administratively up or down. + // +required + AdminState AdminState `json:"adminState"` +} + +// LLDPStatus defines the observed state of LLDP. +type LLDPStatus 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 LLDP 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"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:path=lldps +// +kubebuilder:resource:singular=lldp +// +kubebuilder:printcolumn:name="Device",type=string,JSONPath=`.spec.deviceRef.name` +// +kubebuilder:printcolumn:name="Admin State",type=string,JSONPath=`.spec.adminState` +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Configured",type=string,JSONPath=`.status.conditions[?(@.type=="Configured")].status`,priority=1 +// +kubebuilder:printcolumn:name="Operational",type=string,JSONPath=`.status.conditions[?(@.type=="Operational")].status`,priority=1 +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// LLDP is the Schema for the lldps API +type LLDP struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitzero"` + + // +required + Spec LLDPSpec `json:"spec"` + + // +optional + Status LLDPStatus `json:"status,omitzero"` +} + +// GetConditions implements conditions.Getter. +func (l *LLDP) GetConditions() []metav1.Condition { + return l.Status.Conditions +} + +// SetConditions implements conditions.Setter. +func (l *LLDP) SetConditions(conditions []metav1.Condition) { + l.Status.Conditions = conditions +} + +// +kubebuilder:object:root=true + +// LLDPList contains a list of LLDP +type LLDPList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []LLDP `json:"items"` +} + +var ( + LLDPDependencies []schema.GroupVersionKind + lldpDependenciesMu sync.Mutex +) + +// RegisterLLDPDependency registers a provider-specific GVK as a dependency of LLDP. +// ProviderConfigs should call this in their init() function to ensure the dependency is registered. +func RegisterLLDPDependency(gvk schema.GroupVersionKind) { + lldpDependenciesMu.Lock() + defer lldpDependenciesMu.Unlock() + LLDPDependencies = append(LLDPDependencies, gvk) +} + +func init() { + SchemeBuilder.Register(&LLDP{}, &LLDPList{}) +} diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go index b9a0a1d3..10e4d76e 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -1713,6 +1713,108 @@ func (in *InterfaceStatus) DeepCopy() *InterfaceStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LLDP) DeepCopyInto(out *LLDP) { + *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 LLDP. +func (in *LLDP) DeepCopy() *LLDP { + if in == nil { + return nil + } + out := new(LLDP) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LLDP) 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 *LLDPList) DeepCopyInto(out *LLDPList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]LLDP, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLDPList. +func (in *LLDPList) DeepCopy() *LLDPList { + if in == nil { + return nil + } + out := new(LLDPList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LLDPList) 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 *LLDPSpec) DeepCopyInto(out *LLDPSpec) { + *out = *in + out.DeviceRef = in.DeviceRef + if in.ProviderConfigRef != nil { + in, out := &in.ProviderConfigRef, &out.ProviderConfigRef + *out = new(TypedLocalObjectReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLDPSpec. +func (in *LLDPSpec) DeepCopy() *LLDPSpec { + if in == nil { + return nil + } + out := new(LLDPSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LLDPStatus) DeepCopyInto(out *LLDPStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLDPStatus. +func (in *LLDPStatus) DeepCopy() *LLDPStatus { + if in == nil { + return nil + } + out := new(LLDPStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *LocalObjectReference) DeepCopyInto(out *LocalObjectReference) { *out = *in diff --git a/charts/network-operator/templates/crd/lldps.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/lldps.networking.metal.ironcore.dev.yaml new file mode 100644 index 00000000..8112aa77 --- /dev/null +++ b/charts/network-operator/templates/crd/lldps.networking.metal.ironcore.dev.yaml @@ -0,0 +1,211 @@ +{{- 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: lldps.networking.metal.ironcore.dev +spec: + group: networking.metal.ironcore.dev + names: + kind: LLDP + listKind: LLDPList + plural: lldps + singular: lldp + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.deviceRef.name + name: Device + type: string + - jsonPath: .spec.adminState + name: Admin State + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Configured")].status + name: Configured + priority: 1 + type: string + - jsonPath: .status.conditions[?(@.type=="Operational")].status + name: Operational + priority: 1 + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: LLDP is the Schema for the lldps 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: LLDPSpec defines the desired state of LLDP + properties: + adminState: + description: AdminState indicates whether LLDP is system-wide administratively + up or down. + enum: + - Up + - Down + type: string + deviceRef: + description: |- + DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace. + Immutable. + properties: + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: DeviceRef is immutable + rule: self == oldSelf + providerConfigRef: + description: |- + ProviderConfigRef is a reference to a resource holding the provider-specific configuration for this LLDP. + 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 + required: + - adminState + - deviceRef + type: object + status: + description: LLDPStatus defines the observed state of LLDP. + properties: + conditions: + description: |- + conditions represent the current state of the LLDP 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 + type: object + required: + - metadata + - spec + type: object + served: true + storage: true + subresources: + status: {} +{{- end }} diff --git a/charts/network-operator/templates/rbac/lldp-admin-role.yaml b/charts/network-operator/templates/rbac/lldp-admin-role.yaml new file mode 100644 index 00000000..2775a5ff --- /dev/null +++ b/charts/network-operator/templates/rbac/lldp-admin-role.yaml @@ -0,0 +1,24 @@ +{{- if .Values.rbacHelpers.enable }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/name: {{ include "network-operator.name" . }} + helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + app.kubernetes.io/instance: {{ .Release.Name }} + name: {{ include "network-operator.resourceName" (dict "suffix" "lldp-admin-role" "context" $) }} +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - lldp + verbs: + - '*' +- apiGroups: + - networking.metal.ironcore.dev + resources: + - lldp/status + verbs: + - get +{{- end }} diff --git a/charts/network-operator/templates/rbac/lldp-editor-role.yaml b/charts/network-operator/templates/rbac/lldp-editor-role.yaml new file mode 100644 index 00000000..6b228291 --- /dev/null +++ b/charts/network-operator/templates/rbac/lldp-editor-role.yaml @@ -0,0 +1,30 @@ +{{- if .Values.rbacHelpers.enable }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/name: {{ include "network-operator.name" . }} + helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + app.kubernetes.io/instance: {{ .Release.Name }} + name: {{ include "network-operator.resourceName" (dict "suffix" "lldp-editor-role" "context" $) }} +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - lldp + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - lldp/status + verbs: + - get +{{- end }} diff --git a/charts/network-operator/templates/rbac/lldp-viewer-role.yaml b/charts/network-operator/templates/rbac/lldp-viewer-role.yaml new file mode 100644 index 00000000..8db4af74 --- /dev/null +++ b/charts/network-operator/templates/rbac/lldp-viewer-role.yaml @@ -0,0 +1,26 @@ +{{- if .Values.rbacHelpers.enable }} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/name: {{ include "network-operator.name" . }} + helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + app.kubernetes.io/instance: {{ .Release.Name }} + name: {{ include "network-operator.resourceName" (dict "suffix" "lldp-viewer-role" "context" $) }} +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - lldp + verbs: + - get + - list + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - lldp/status + verbs: + - get +{{- end }} diff --git a/charts/network-operator/templates/rbac/manager-role.yaml b/charts/network-operator/templates/rbac/manager-role.yaml index 2b198167..793df82b 100644 --- a/charts/network-operator/templates/rbac/manager-role.yaml +++ b/charts/network-operator/templates/rbac/manager-role.yaml @@ -51,6 +51,7 @@ rules: - evpninstances - interfaces - isis + - lldps - managementaccesses - networkvirtualizationedges - ntp @@ -84,6 +85,7 @@ rules: - evpninstances/finalizers - interfaces/finalizers - isis/finalizers + - lldps/finalizers - managementaccesses/finalizers - networkvirtualizationedges/finalizers - ntp/finalizers @@ -111,6 +113,7 @@ rules: - evpninstances/status - interfaces/status - isis/status + - lldps/status - managementaccesses/status - networkvirtualizationedges/status - ntp/status diff --git a/cmd/main.go b/cmd/main.go index cf9cd405..2b375426 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -457,6 +457,19 @@ func main() { os.Exit(1) } + if err := (&corecontroller.LLDPReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("lldp-controller"), + WatchFilterValue: watchFilterValue, + Provider: prov, + Locker: locker, + RequeueInterval: requeueInterval, + }).SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "LLDP") + os.Exit(1) + } + if err := (&corecontroller.OSPFReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), diff --git a/config/crd/bases/networking.metal.ironcore.dev_lldps.yaml b/config/crd/bases/networking.metal.ironcore.dev_lldps.yaml new file mode 100644 index 00000000..9dbb6c0d --- /dev/null +++ b/config/crd/bases/networking.metal.ironcore.dev_lldps.yaml @@ -0,0 +1,207 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.1 + name: lldps.networking.metal.ironcore.dev +spec: + group: networking.metal.ironcore.dev + names: + kind: LLDP + listKind: LLDPList + plural: lldps + singular: lldp + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.deviceRef.name + name: Device + type: string + - jsonPath: .spec.adminState + name: Admin State + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Configured")].status + name: Configured + priority: 1 + type: string + - jsonPath: .status.conditions[?(@.type=="Operational")].status + name: Operational + priority: 1 + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: LLDP is the Schema for the lldps 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: LLDPSpec defines the desired state of LLDP + properties: + adminState: + description: AdminState indicates whether LLDP is system-wide administratively + up or down. + enum: + - Up + - Down + type: string + deviceRef: + description: |- + DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace. + Immutable. + properties: + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: DeviceRef is immutable + rule: self == oldSelf + providerConfigRef: + description: |- + ProviderConfigRef is a reference to a resource holding the provider-specific configuration for this LLDP. + 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 + required: + - adminState + - deviceRef + type: object + status: + description: LLDPStatus defines the observed state of LLDP. + properties: + conditions: + description: |- + conditions represent the current state of the LLDP 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 + 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 e51962a6..a785dd11 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -24,6 +24,7 @@ resources: - bases/networking.metal.ironcore.dev_users.yaml - bases/networking.metal.ironcore.dev_vlans.yaml - bases/networking.metal.ironcore.dev_vrfs.yaml +- bases/networking.metal.ironcore.dev_lldps.yaml - bases/nx.cisco.networking.metal.ironcore.dev_bordergateways.yaml - bases/nx.cisco.networking.metal.ironcore.dev_managementaccessconfigs.yaml - bases/nx.cisco.networking.metal.ironcore.dev_networkvirtualizationedgeconfigs.yaml diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 6f973e8e..61a11ce6 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -52,6 +52,9 @@ resources: - isis_admin_role.yaml - isis_editor_role.yaml - isis_viewer_role.yaml +- lldp_admin_role.yaml +- lldp_editor_role.yaml +- lldp_viewer_role.yaml - managementaccess_admin_role.yaml - managementaccess_editor_role.yaml - managementaccess_viewer_role.yaml diff --git a/config/rbac/lldp_admin_role.yaml b/config/rbac/lldp_admin_role.yaml new file mode 100644 index 00000000..78a46e86 --- /dev/null +++ b/config/rbac/lldp_admin_role.yaml @@ -0,0 +1,27 @@ +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over networking.metal.ironcore.dev. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: lldp-admin-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - lldp + verbs: + - '*' +- apiGroups: + - networking.metal.ironcore.dev + resources: + - lldp/status + verbs: + - get diff --git a/config/rbac/lldp_editor_role.yaml b/config/rbac/lldp_editor_role.yaml new file mode 100644 index 00000000..96dfd7d5 --- /dev/null +++ b/config/rbac/lldp_editor_role.yaml @@ -0,0 +1,33 @@ +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the networking.metal.ironcore.dev. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: lldp-editor-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - lldp + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - lldp/status + verbs: + - get diff --git a/config/rbac/lldp_viewer_role.yaml b/config/rbac/lldp_viewer_role.yaml new file mode 100644 index 00000000..d71ba718 --- /dev/null +++ b/config/rbac/lldp_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to networking.metal.ironcore.dev resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: lldp-viewer-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - lldp + verbs: + - get + - list + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - lldp/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 65b7217d..cb16788f 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -52,6 +52,7 @@ rules: - evpninstances - interfaces - isis + - lldps - managementaccesses - networkvirtualizationedges - ntp @@ -85,6 +86,7 @@ rules: - evpninstances/finalizers - interfaces/finalizers - isis/finalizers + - lldps/finalizers - managementaccesses/finalizers - networkvirtualizationedges/finalizers - ntp/finalizers @@ -112,6 +114,7 @@ rules: - evpninstances/status - interfaces/status - isis/status + - lldps/status - managementaccesses/status - networkvirtualizationedges/status - ntp/status diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 0451ffc9..968902b1 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -2,6 +2,7 @@ resources: - v1alpha1_device.yaml - v1alpha1_interface.yaml +- v1alpha1_lldp.yaml - v1alpha1_banner.yaml - v1alpha1_user.yaml - v1alpha1_dns.yaml diff --git a/config/samples/v1alpha1_lldp.yaml b/config/samples/v1alpha1_lldp.yaml new file mode 100644 index 00000000..fc9691d8 --- /dev/null +++ b/config/samples/v1alpha1_lldp.yaml @@ -0,0 +1,18 @@ +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: LLDP +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + networking.metal.ironcore.dev/device-name: leaf1 + name: leaf1-lldp +spec: + deviceRef: + name: leaf1 + adminState: Up + # Uncomment to add NXOS provider-specific config + # See: ./cisco/nx/v1alpha1_lldpconfig.yaml + # providerConfigRef: + # apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 + # kind: LLDPConfig + # name: leaf1-lldpconfig diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index 2982390e..b55c757a 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -24,6 +24,7 @@ SPDX-License-Identifier: Apache-2.0 - [EVPNInstance](#evpninstance) - [ISIS](#isis) - [Interface](#interface) +- [LLDP](#lldp) - [ManagementAccess](#managementaccess) - [NTP](#ntp) - [NetworkVirtualizationEdge](#networkvirtualizationedge) @@ -136,6 +137,7 @@ _Appears in:_ - [DNSSpec](#dnsspec) - [ISISSpec](#isisspec) - [InterfaceSpec](#interfacespec) +- [LLDPSpec](#lldpspec) - [NTPSpec](#ntpspec) - [NetworkVirtualizationEdgeSpec](#networkvirtualizationedgespec) - [OSPFSpec](#ospfspec) @@ -1404,6 +1406,59 @@ _Appears in:_ | `Passive` | LACPModePassive indicates that LACP is in passive mode.
| +#### LLDP + + + +LLDP is the Schema for the lldps API + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `networking.metal.ironcore.dev/v1alpha1` | | | +| `kind` _string_ | `LLDP` | | | +| `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` _[LLDPSpec](#lldpspec)_ | | | Required: \{\}
| +| `status` _[LLDPStatus](#lldpstatus)_ | | | Optional: \{\}
| + + +#### LLDPSpec + + + +LLDPSpec defines the desired state of LLDP + + + +_Appears in:_ +- [LLDP](#lldp) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `deviceRef` _[LocalObjectReference](#localobjectreference)_ | DeviceName is the name of 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 LLDP.
If not specified the provider applies the target platform's default settings. | | Optional: \{\}
| +| `adminState` _[AdminState](#adminstate)_ | AdminState indicates whether LLDP is system-wide administratively up or down. | | Enum: [Up Down]
Required: \{\}
| + + +#### LLDPStatus + + + +LLDPStatus defines the observed state of LLDP. + + + +_Appears in:_ +- [LLDP](#lldp) + +| 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 LLDP 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: \{\}
| + + #### LocalObjectReference @@ -1432,6 +1487,7 @@ _Appears in:_ - [InterfaceSpec](#interfacespec) - [InterfaceStatus](#interfacestatus) - [KeepAlive](#keepalive) +- [LLDPSpec](#lldpspec) - [ManagementAccessSpec](#managementaccessspec) - [NTPSpec](#ntpspec) - [NetworkVirtualizationEdgeSpec](#networkvirtualizationedgespec) @@ -2754,6 +2810,7 @@ _Appears in:_ - [EVPNInstanceSpec](#evpninstancespec) - [ISISSpec](#isisspec) - [InterfaceSpec](#interfacespec) +- [LLDPSpec](#lldpspec) - [ManagementAccessSpec](#managementaccessspec) - [NTPSpec](#ntpspec) - [NetworkVirtualizationEdgeSpec](#networkvirtualizationedgespec) diff --git a/internal/controller/core/lldp_controller.go b/internal/controller/core/lldp_controller.go new file mode 100644 index 00000000..3b5a4ba3 --- /dev/null +++ b/internal/controller/core/lldp_controller.go @@ -0,0 +1,438 @@ +// 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" + "time" + + "slices" + + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + kerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/client-go/tools/record" + "k8s.io/klog/v2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/conditions" + "github.com/ironcore-dev/network-operator/internal/deviceutil" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/resourcelock" +) + +// LLDPReconciler reconciles a LLDP object +type LLDPReconciler struct { + client.Client + Scheme *runtime.Scheme + // APIReader is an uncached client.Reader used for provider-specific validation. + // It avoids dependency on informers for provider-specific CRDs. + APIReader client.Reader + + // WatchFilterValue is the label value used to filter events prior to reconciliation. + WatchFilterValue string + + // Recorder is used to record events for the controller. + // More info: https://book.kubebuilder.io/reference/raising-events + Recorder record.EventRecorder + + // Provider is the driver that will be used to create & delete the LLDP. + 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=lldps,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=lldps/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=lldps/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.4/pkg/reconcile +func (r *LLDPReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, reterr error) { + log := ctrl.LoggerFrom(ctx) + log.Info("Reconciling resource") + + obj := new(v1alpha1.LLDP) + if err := r.Get(ctx, req.NamespacedName, obj); err != nil { + if apierrors.IsNotFound(err) { + log.Info("Resource not found. Ignoring reconciliation since object must be deleted") + return ctrl.Result{}, nil + } + // Error reading the object - requeue the request. + log.Error(err, "Failed to get resource") + return ctrl.Result{}, err + } + + prov, ok := r.Provider().(provider.LLDPProvider) + 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 LLDPProvider", + }) { + 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 + } + + // Prevent concurrent reconciliations of resources targeting the same device + if err := r.Locker.AcquireLock(ctx, device.Name, "lldp-controller"); err != nil { + if errors.Is(err, resourcelock.ErrLockAlreadyHeld) { + log.Info("Device is already locked, requeuing reconciliation") + return ctrl.Result{RequeueAfter: time.Second * 5}, nil + } + log.Error(err, "Failed to acquire device lock") + return ctrl.Result{}, err + } + defer func() { + if err := r.Locker.ReleaseLock(ctx, device.Name, "lldp-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 + } + + s := &lldpScope{ + Device: device, + LLDP: obj, + Connection: conn, + 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}) + } + return + } + + if !equality.Semantic.DeepEqual(orig.Status, obj.Status) { + if err := r.Status().Patch(ctx, obj, client.MergeFrom(orig)); err != nil { + log.Error(err, "Failed to update status") + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + } + }() + + res, err := r.reconcile(ctx, s) + if err != nil { + log.Error(err, "Failed to reconcile resource") + return ctrl.Result{}, err + } + return res, nil +} + +type lldpScope struct { + Device *v1alpha1.Device + LLDP *v1alpha1.LLDP + Connection *deviceutil.Connection + Provider provider.LLDPProvider + // ProviderConfig is the resource referenced by LLDP.Spec.ProviderConfigRef, if any. + ProviderConfig *provider.ProviderConfig +} + +func (r *LLDPReconciler) reconcile(ctx context.Context, s *lldpScope) (_ ctrl.Result, reterr error) { + if s.LLDP.Labels == nil { + s.LLDP.Labels = make(map[string]string) + } + s.LLDP.Labels[v1alpha1.DeviceLabel] = s.Device.Name + + // Ensure LLDP resource is owned by the Device. + if !controllerutil.HasControllerReference(s.LLDP) { + if err := controllerutil.SetOwnerReference(s.Device, s.LLDP, r.Scheme, controllerutil.WithBlockOwnerDeletion(true)); err != nil { + return ctrl.Result{}, err + } + } + + defer func() { + conditions.RecomputeReady(s.LLDP) + }() + + cfg, err := r.validateProviderConfigRef(ctx, s) + if err != nil { + return ctrl.Result{}, err + } + s.ProviderConfig = cfg + + if err := r.validateUniqueLLDPPerDevice(ctx, s); err != nil { + return ctrl.Result{}, err + } + + 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 LLDP is realized on the remote device. + err = s.Provider.EnsureLLDP(ctx, &provider.LLDPRequest{ + LLDP: s.LLDP, + ProviderConfig: s.ProviderConfig, + }) + + cond := conditions.FromError(err) + conditions.Set(s.LLDP, cond) + + if err != nil { + return ctrl.Result{}, err + } + + status, err := s.Provider.GetLLDPStatus(ctx, &provider.LLDPRequest{ + LLDP: s.LLDP, + ProviderConfig: s.ProviderConfig, + }) + if err != nil { + return ctrl.Result{}, fmt.Errorf("failed to get LLDP status: %w", err) + } + + cond = metav1.Condition{ + Type: v1alpha1.OperationalCondition, + Status: metav1.ConditionTrue, + Reason: v1alpha1.OperationalReason, + Message: "LLDP is operationally up", + } + if !status.OperStatus { + cond.Status = metav1.ConditionFalse + cond.Reason = v1alpha1.DegradedReason + cond.Message = "LLDP is operationally down" + } + conditions.Set(s.LLDP, cond) + + return ctrl.Result{}, nil +} + +// validateProviderConfigRef checks if the referenced provider configuration exists and is compatible with the target platform. +func (r *LLDPReconciler) validateProviderConfigRef(ctx context.Context, s *lldpScope) (*provider.ProviderConfig, error) { + if s.LLDP.Spec.ProviderConfigRef == nil { + return nil, nil + } + + cfg, err := provider.GetProviderConfig(ctx, r, s.LLDP.Namespace, s.LLDP.Spec.ProviderConfigRef) + if err != nil { + conditions.Set(s.LLDP, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.IncompatibleProviderConfigRef, + Message: fmt.Sprintf("Failed to get ProviderConfigRef: %v", err), + }) + return nil, err + } + + gv, err := schema.ParseGroupVersion(s.LLDP.Spec.ProviderConfigRef.APIVersion) + if err != nil { + conditions.Set(s.LLDP, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.IncompatibleProviderConfigRef, + Message: fmt.Sprintf("ProviderConfigRef is not compatible with Device: %v", err), + }) + return nil, reconcile.TerminalError(fmt.Errorf("invalid API version %q: %w", s.LLDP.Spec.ProviderConfigRef.APIVersion, err)) + } + + gvk := schema.GroupVersionKind{ + Group: gv.Group, + Version: gv.Version, + Kind: s.LLDP.Spec.ProviderConfigRef.Kind, + } + + // verify the GVK is a registered dependency for LLDP + if ok := slices.Contains(v1alpha1.LLDPDependencies, gvk); !ok { + conditions.Set(s.LLDP, 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 device type", s.LLDP.Spec.ProviderConfigRef.Kind, s.LLDP.Spec.ProviderConfigRef.APIVersion), + }) + return nil, reconcile.TerminalError(fmt.Errorf("unsupported ProviderConfigRef Kind %q on this provider", gv)) + } + + return cfg, nil +} + +func (r *LLDPReconciler) validateUniqueLLDPPerDevice(ctx context.Context, s *lldpScope) error { + 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}, + ); err != nil { + return err + } + for _, lldp := range list.Items { + if lldp.Name != s.LLDP.Name { + conditions.Set(s.LLDP, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.DuplicateResourceOnDevice, + Message: fmt.Sprintf("Another LLDP (%s) already exists for device %s", lldp.Name, s.LLDP.Spec.DeviceRef.Name), + }) + return reconcile.TerminalError(fmt.Errorf("only one LLDP resource allowed per device (%s)", s.LLDP.Spec.DeviceRef.Name)) + } + } + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *LLDPReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { + if r.RequeueInterval == 0 { + return errors.New("requeue interval must not be 0") + } + + // Use the manager's APIReader for provider-specific validation so that + // validation does not depend on informers for provider-specific CRDs. + r.APIReader = mgr.GetAPIReader() + + 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) + } + + 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"). + WithEventFilter(filter) + + for _, gvk := range v1alpha1.LLDPDependencies { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(gvk) + c = c.Watches( + obj, + handler.EnqueueRequestsFromMapFunc(r.mapProviderConfigToLLDP), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ) + if _, err := mgr.GetCache().GetInformer(ctx, obj); err != nil { + return fmt.Errorf("failed to create informer for LLDP dependency %s: %w", gvk.String(), err) + } + } + return c.Complete(r) +} + +func (r *LLDPReconciler) mapProviderConfigToLLDP(ctx context.Context, obj client.Object) []reconcile.Request { + log := ctrl.LoggerFrom(ctx, "Object", klog.KObj(obj)) + + list := &v1alpha1.LLDPList{} + if err := r.List(ctx, list, client.InNamespace(obj.GetNamespace())); err != nil { + log.Error(err, "failed to list LLDPs") + 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 LLDP for provider config change, enqueuing for reconciliation", "LLDP", klog.KObj(&m)) + requests = append(requests, reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: m.Name, + Namespace: m.Namespace, + }, + }) + } + } + return requests +} + +func (r *LLDPReconciler) finalize(ctx context.Context, s *lldpScope) (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.DeleteLLDP(ctx, &provider.LLDPRequest{ + LLDP: s.LLDP, + }) +} diff --git a/internal/controller/core/lldp_controller_test.go b/internal/controller/core/lldp_controller_test.go new file mode 100644 index 00000000..57b59fba --- /dev/null +++ b/internal/controller/core/lldp_controller_test.go @@ -0,0 +1,277 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + . "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("LLDP Controller", func() { + Context("When reconciling a resource", func() { + const ( + deviceName = "testlldp-device" + resourceName = "testlldp-lldp" + ) + + resourceKey := client.ObjectKey{Name: resourceName, Namespace: metav1.NamespaceDefault} + deviceKey := client.ObjectKey{Name: deviceName, Namespace: metav1.NamespaceDefault} + + var ( + device *v1alpha1.Device + lldp *v1alpha1.LLDP + ) + + BeforeEach(func() { + By("Creating the custom resource for the Kind Device") + device = &v1alpha1.Device{} + if err := k8sClient.Get(ctx, deviceKey, device); errors.IsNotFound(err) { + device = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: deviceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.2:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + } + }) + + AfterEach(func() { + By("Cleaning up the LLDP resource") + lldp = &v1alpha1.LLDP{} + err := k8sClient.Get(ctx, resourceKey, lldp) + if err == nil { + Expect(k8sClient.Delete(ctx, lldp)).To(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.LLDP).To(BeNil(), "Provider should have no LLDP configured") + }).Should(Succeed()) + }) + + It("Should successfully reconcile the resource", func() { + By("Creating the custom resource for the Kind LLDP") + lldp = &v1alpha1.LLDP{} + if err := k8sClient.Get(ctx, resourceKey, lldp); errors.IsNotFound(err) { + lldp = &v1alpha1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.LLDPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + AdminState: "Up", + }, + } + Expect(k8sClient.Create(ctx, lldp)).To(Succeed()) + } + + By("Verifying the controller adds a finalizer") + Eventually(func(g Gomega) { + lldp = &v1alpha1.LLDP{} + g.Expect(k8sClient.Get(ctx, resourceKey, lldp)).To(Succeed()) + g.Expect(controllerutil.ContainsFinalizer(lldp, v1alpha1.FinalizerName)).To(BeTrue()) + }).Should(Succeed()) + + By("Verifying the controller adds the device label") + Eventually(func(g Gomega) { + lldp = &v1alpha1.LLDP{} + g.Expect(k8sClient.Get(ctx, resourceKey, lldp)).To(Succeed()) + g.Expect(lldp.Labels).To(HaveKeyWithValue(v1alpha1.DeviceLabel, deviceName)) + }).Should(Succeed()) + + By("Verifying the controller sets the owner reference") + Eventually(func(g Gomega) { + lldp = &v1alpha1.LLDP{} + g.Expect(k8sClient.Get(ctx, resourceKey, lldp)).To(Succeed()) + g.Expect(lldp.OwnerReferences).To(HaveLen(1)) + g.Expect(lldp.OwnerReferences[0].Kind).To(Equal("Device")) + g.Expect(lldp.OwnerReferences[0].Name).To(Equal(deviceName)) + }).Should(Succeed()) + + By("Verifying the controller updates the status conditions") + Eventually(func(g Gomega) { + lldp = &v1alpha1.LLDP{} + g.Expect(k8sClient.Get(ctx, resourceKey, lldp)).To(Succeed()) + g.Expect(lldp.Status.Conditions).To(HaveLen(3)) + + cond := meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ReadyCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + + cond = meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + + cond = meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.OperationalCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Ensuring the LLDP is created in the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.LLDP).ToNot(BeNil(), "Provider LLDP should not be nil") + if testProvider.LLDP != nil { + g.Expect(testProvider.LLDP.GetName()).To(Equal(resourceName), "Provider should have LLDP configured") + } + }).Should(Succeed()) + }) + + It("Should successfully reconcile the resource with AdminState Down", func() { + By("Creating the custom resource for the Kind LLDP with AdminState Down") + lldp = &v1alpha1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.LLDPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + AdminState: v1alpha1.AdminStateDown, + }, + } + Expect(k8sClient.Create(ctx, lldp)).To(Succeed()) + + By("Verifying the controller adds a finalizer") + Eventually(func(g Gomega) { + lldp = &v1alpha1.LLDP{} + g.Expect(k8sClient.Get(ctx, resourceKey, lldp)).To(Succeed()) + g.Expect(controllerutil.ContainsFinalizer(lldp, v1alpha1.FinalizerName)).To(BeTrue()) + }).Should(Succeed()) + + By("Verifying the controller updates the status conditions") + Eventually(func(g Gomega) { + lldp = &v1alpha1.LLDP{} + g.Expect(k8sClient.Get(ctx, resourceKey, lldp)).To(Succeed()) + g.Expect(lldp.Status.Conditions).To(HaveLen(3)) + + cond := meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ReadyCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Ensuring the LLDP is created in the provider with AdminState Down") + Eventually(func(g Gomega) { + g.Expect(testProvider.LLDP).ToNot(BeNil()) + if testProvider.LLDP != nil { + g.Expect(testProvider.LLDP.Spec.AdminState).To(Equal(v1alpha1.AdminStateDown)) + } + }).Should(Succeed()) + }) + + It("Should reject duplicate LLDP resources on the same device", func() { + By("Creating the first LLDP resource") + lldp = &v1alpha1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.LLDPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, lldp)).To(Succeed()) + + By("Waiting for the first LLDP to be ready") + Eventually(func(g Gomega) { + lldp = &v1alpha1.LLDP{} + g.Expect(k8sClient.Get(ctx, resourceKey, lldp)).To(Succeed()) + cond := meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ReadyCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Creating a second LLDP resource for the same device") + duplicateName := resourceName + "-duplicate" + duplicateKey := client.ObjectKey{Name: duplicateName, Namespace: metav1.NamespaceDefault} + duplicateLLDP := &v1alpha1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: duplicateName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.LLDPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, duplicateLLDP)).To(Succeed()) + + By("Verifying the second LLDP has a ConfiguredCondition=False with DuplicateResourceOnDevice reason") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, duplicateKey, duplicateLLDP) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(duplicateLLDP.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 LLDP resource") + Expect(k8sClient.Delete(ctx, duplicateLLDP)).To(Succeed()) + }) + + It("Should properly handle deletion and cleanup", func() { + By("Creating the custom resource for the Kind LLDP") + lldp = &v1alpha1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.LLDPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, lldp)).To(Succeed()) + + By("Waiting for the LLDP to be ready") + Eventually(func(g Gomega) { + lldp = &v1alpha1.LLDP{} + g.Expect(k8sClient.Get(ctx, resourceKey, lldp)).To(Succeed()) + cond := meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ReadyCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Verifying LLDP is created in the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.LLDP).ToNot(BeNil()) + }).Should(Succeed()) + + By("Deleting the LLDP resource") + Expect(k8sClient.Delete(ctx, lldp)).To(Succeed()) + + By("Verifying the LLDP is removed from the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.LLDP).To(BeNil(), "Provider should have no LLDP configured after deletion") + }).Should(Succeed()) + + By("Verifying the resource is fully deleted") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, &v1alpha1.LLDP{}) + g.Expect(errors.IsNotFound(err)).To(BeTrue()) + }).Should(Succeed()) + }) + }) +}) diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go index 9e9606c5..ae664b61 100644 --- a/internal/controller/core/suite_test.go +++ b/internal/controller/core/suite_test.go @@ -320,6 +320,16 @@ var _ = BeforeSuite(func() { }).SetupWithManager(ctx, k8sManager) Expect(err).NotTo(HaveOccurred()) + err = (&LLDPReconciler{ + 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) @@ -387,6 +397,7 @@ var ( _ provider.PrefixSetProvider = (*Provider)(nil) _ provider.RoutingPolicyProvider = (*Provider)(nil) _ provider.NVEProvider = (*Provider)(nil) + _ provider.LLDPProvider = (*Provider)(nil) ) // Provider is a simple in-memory provider for testing purposes only. @@ -415,6 +426,7 @@ type Provider struct { PrefixSets sets.Set[string] RoutingPolicies sets.Set[string] NVE *v1alpha1.NetworkVirtualizationEdge + LLDP *v1alpha1.LLDP } func NewProvider() *Provider { @@ -832,3 +844,21 @@ func (p *Provider) GetNVEStatus(_ context.Context, _ *provider.NVERequest) (prov } return status, nil } + +func (p *Provider) EnsureLLDP(_ context.Context, req *provider.LLDPRequest) error { + p.Lock() + defer p.Unlock() + p.LLDP = req.LLDP + return nil +} + +func (p *Provider) DeleteLLDP(_ context.Context, req *provider.LLDPRequest) error { + p.Lock() + defer p.Unlock() + p.LLDP = nil + return nil +} + +func (p *Provider) GetLLDPStatus(_ context.Context, _ *provider.LLDPRequest) (provider.LLDPStatus, error) { + return provider.LLDPStatus{OperStatus: true}, nil +} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index a1ac2a02..f932d532 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -586,6 +586,30 @@ type NVEStatus struct { HostReachabilityType string } +// LLDPProvider is an interface to configure LLDP on a device. +type LLDPProvider interface { + Provider + + // EnsureLLDP realizes LLDP configuration. + EnsureLLDP(context.Context, *LLDPRequest) error + // DeleteLLDP deletes the LLDP configuration. + DeleteLLDP(context.Context, *LLDPRequest) error + // GetLLDPStatus call retrieves the current status of the LLDP configuration. + GetLLDPStatus(context.Context, *LLDPRequest) (LLDPStatus, error) +} + +type LLDPRequest struct { + LLDP *v1alpha1.LLDP + ProviderConfig *ProviderConfig +} + +// LLDPStatus represents the operational status of LLDP on the device. +// It does not include neighbor information; this is handled in a different resource. +type LLDPStatus struct { + // OperStatus indicates whether LLDP is operationally up (true) or down (false). + OperStatus bool +} + var mu sync.RWMutex // ProviderFunc returns a new [Provider] instance. From 29235e68a5341723666f411d5f368191063cbf32 Mon Sep 17 00:00:00 2001 From: Pujol Date: Fri, 27 Feb 2026 16:41:27 +0100 Subject: [PATCH 2/4] [NXOS] Add provider-specific configuration for `LLDP` * Add type `LLDPConfig` for NXOS. Allows per-interface control of LLDP PDU tx/rx and global timing parameters (holdTime, initDelay). --- Tiltfile | 3 + api/cisco/nx/v1alpha1/lldpconfig_types.go | 78 +++++++++++++++ .../nx/v1alpha1/zz_generated.deepcopy.go | 94 ++++++++++++++++++ ...x.cisco.networking.metal.ironcore.dev.yaml | 99 +++++++++++++++++++ .../templates/rbac/manager-role.yaml | 1 + ...orking.metal.ironcore.dev_lldpconfigs.yaml | 95 ++++++++++++++++++ config/crd/kustomization.yaml | 1 + config/rbac/role.yaml | 1 + .../samples/cisco/nx/v1alpha1_lldpconfig.yaml | 16 +++ config/samples/kustomization.yaml | 1 + docs/api-reference/index.md | 57 +++++++++++ 11 files changed, 446 insertions(+) create mode 100644 api/cisco/nx/v1alpha1/lldpconfig_types.go create mode 100644 charts/network-operator/templates/crd/lldpconfigs.nx.cisco.networking.metal.ironcore.dev.yaml create mode 100644 config/crd/bases/nx.cisco.networking.metal.ironcore.dev_lldpconfigs.yaml create mode 100644 config/samples/cisco/nx/v1alpha1_lldpconfig.yaml diff --git a/Tiltfile b/Tiltfile index 7ced06c3..2a1be775 100644 --- a/Tiltfile +++ b/Tiltfile @@ -119,6 +119,9 @@ k8s_resource(new_name='nve1', objects=['nve1:networkvirtualizationedge'], trigge k8s_yaml('./config/samples/v1alpha1_lldp.yaml') k8s_resource(new_name='lldp', objects=['leaf1-lldp:lldp'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False) +# Uncomment the following lines for NXOS specific LLDP config +# 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) print('🚀 network-operator development environment') print('👉 Edit the code inside the api/, cmd/, or internal/ directories') diff --git a/api/cisco/nx/v1alpha1/lldpconfig_types.go b/api/cisco/nx/v1alpha1/lldpconfig_types.go new file mode 100644 index 00000000..5ed93354 --- /dev/null +++ b/api/cisco/nx/v1alpha1/lldpconfig_types.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + v1alpha1 "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +// +kubebuilder:rbac:groups=nx.cisco.networking.metal.ironcore.dev,resources=lldpconfigs,verbs=get;list;watch + +// LLDPConfig defines the Cisco-specific configuration of an LLDP object. +type LLDPConfigSpec struct { + // InitDelay defines the delay in seconds before LLDP starts sending packets after interface comes up. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=10 + // +kubebuilder:default=2 + InitDelay int16 `json:"initDelay,omitempty"` + + // HoldTime defines the time in seconds that the receiving device should hold the LLDP information before discarding it. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=255 + // +kubebuilder:default=120 + HoldTime int16 `json:"holdTime,omitempty"` + + // InterfaceRefs is a list of interfaces and their LLDP configuration. + // +optional + // +listType=atomic + InterfaceRefs []LLDPInterface `json:"interfaceRefs,omitempty"` +} + +type LLDPInterface struct { + v1alpha1.LocalObjectReference `json:",inline"` + + // AdminRxState defines the administrative state for receiving LLDP PDUs on the interface. + // +optional + // +kubebuilder:default=Up + AdminRxState v1alpha1.AdminState `json:"adminRxState,omitempty"` + + // AdminTxState defines the administrative state for transmitting LLDP PDUs on the interface. + // +optional + // +kubebuilder:default=Up + AdminTxState v1alpha1.AdminState `json:"adminTxState,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:resource:path=lldpconfigs +// +kubebuilder:resource:singular=lldpconfig + +// LLDPConfig is the Schema for the LLDPConfig API +type LLDPConfig struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty,omitzero"` + + // spec defines the desired state of LLDP + // +required + Spec LLDPConfigSpec `json:"spec"` +} + +// +kubebuilder:object:root=true + +// LLDPConfigList contains a list of LLDPConfigs +type LLDPConfigList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []LLDPConfig `json:"items"` +} + +// init registers the LLDPConfig type with the scheme and sets +// itself as a dependency for the LLDP core type. +func init() { + v1alpha1.RegisterLLDPDependency(GroupVersion.WithKind("LLDPConfig")) + SchemeBuilder.Register(&LLDPConfig{}, &LLDPConfigList{}) +} diff --git a/api/cisco/nx/v1alpha1/zz_generated.deepcopy.go b/api/cisco/nx/v1alpha1/zz_generated.deepcopy.go index e293098e..f5a013ca 100644 --- a/api/cisco/nx/v1alpha1/zz_generated.deepcopy.go +++ b/api/cisco/nx/v1alpha1/zz_generated.deepcopy.go @@ -323,6 +323,100 @@ func (in *KeepAlive) DeepCopy() *KeepAlive { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LLDPConfig) DeepCopyInto(out *LLDPConfig) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLDPConfig. +func (in *LLDPConfig) DeepCopy() *LLDPConfig { + if in == nil { + return nil + } + out := new(LLDPConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LLDPConfig) 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 *LLDPConfigList) DeepCopyInto(out *LLDPConfigList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]LLDPConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLDPConfigList. +func (in *LLDPConfigList) DeepCopy() *LLDPConfigList { + if in == nil { + return nil + } + out := new(LLDPConfigList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *LLDPConfigList) 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 *LLDPConfigSpec) DeepCopyInto(out *LLDPConfigSpec) { + *out = *in + if in.InterfaceRefs != nil { + in, out := &in.InterfaceRefs, &out.InterfaceRefs + *out = make([]LLDPInterface, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLDPConfigSpec. +func (in *LLDPConfigSpec) DeepCopy() *LLDPConfigSpec { + if in == nil { + return nil + } + out := new(LLDPConfigSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LLDPInterface) DeepCopyInto(out *LLDPInterface) { + *out = *in + out.LocalObjectReference = in.LocalObjectReference +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLDPInterface. +func (in *LLDPInterface) DeepCopy() *LLDPInterface { + if in == nil { + return nil + } + out := new(LLDPInterface) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ManagementAccessConfig) DeepCopyInto(out *ManagementAccessConfig) { *out = *in diff --git a/charts/network-operator/templates/crd/lldpconfigs.nx.cisco.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/lldpconfigs.nx.cisco.networking.metal.ironcore.dev.yaml new file mode 100644 index 00000000..6bbc0f3c --- /dev/null +++ b/charts/network-operator/templates/crd/lldpconfigs.nx.cisco.networking.metal.ironcore.dev.yaml @@ -0,0 +1,99 @@ +{{- 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: lldpconfigs.nx.cisco.networking.metal.ironcore.dev +spec: + group: nx.cisco.networking.metal.ironcore.dev + names: + kind: LLDPConfig + listKind: LLDPConfigList + plural: lldpconfigs + singular: lldpconfig + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: LLDPConfig is the Schema for the LLDPConfig API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the desired state of LLDP + properties: + holdTime: + default: 120 + description: HoldTime defines the time in seconds that the receiving + device should hold the LLDP information before discarding it. + maximum: 255 + minimum: 1 + type: integer + initDelay: + default: 2 + description: InitDelay defines the delay in seconds before LLDP starts + sending packets after interface comes up. + maximum: 10 + minimum: 1 + type: integer + interfaceRefs: + description: InterfaceRefs is a list of interfaces and their LLDP + configuration. + items: + properties: + adminRxState: + default: Up + description: AdminRxState defines the administrative state for + receiving LLDP PDUs on the interface. + enum: + - Up + - Down + type: string + adminTxState: + default: Up + description: AdminTxState defines the administrative state for + transmitting LLDP PDUs on the interface. + enum: + - Up + - Down + type: string + 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 + type: array + x-kubernetes-list-type: atomic + type: object + required: + - spec + type: object + served: true + storage: true +{{- end }} diff --git a/charts/network-operator/templates/rbac/manager-role.yaml b/charts/network-operator/templates/rbac/manager-role.yaml index 793df82b..220eb38f 100644 --- a/charts/network-operator/templates/rbac/manager-role.yaml +++ b/charts/network-operator/templates/rbac/manager-role.yaml @@ -166,6 +166,7 @@ rules: - nx.cisco.networking.metal.ironcore.dev resources: - interfaceconfigs + - lldpconfigs - managementaccessconfigs - networkvirtualizationedgeconfigs verbs: diff --git a/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_lldpconfigs.yaml b/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_lldpconfigs.yaml new file mode 100644 index 00000000..af5bf632 --- /dev/null +++ b/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_lldpconfigs.yaml @@ -0,0 +1,95 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.1 + name: lldpconfigs.nx.cisco.networking.metal.ironcore.dev +spec: + group: nx.cisco.networking.metal.ironcore.dev + names: + kind: LLDPConfig + listKind: LLDPConfigList + plural: lldpconfigs + singular: lldpconfig + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: LLDPConfig is the Schema for the LLDPConfig API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the desired state of LLDP + properties: + holdTime: + default: 120 + description: HoldTime defines the time in seconds that the receiving + device should hold the LLDP information before discarding it. + maximum: 255 + minimum: 1 + type: integer + initDelay: + default: 2 + description: InitDelay defines the delay in seconds before LLDP starts + sending packets after interface comes up. + maximum: 10 + minimum: 1 + type: integer + interfaceRefs: + description: InterfaceRefs is a list of interfaces and their LLDP + configuration. + items: + properties: + adminRxState: + default: Up + description: AdminRxState defines the administrative state for + receiving LLDP PDUs on the interface. + enum: + - Up + - Down + type: string + adminTxState: + default: Up + description: AdminTxState defines the administrative state for + transmitting LLDP PDUs on the interface. + enum: + - Up + - Down + type: string + 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 + type: array + x-kubernetes-list-type: atomic + type: object + required: + - spec + type: object + served: true + storage: true diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index a785dd11..808e4b4d 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -31,6 +31,7 @@ resources: - bases/nx.cisco.networking.metal.ironcore.dev_systems.yaml - bases/nx.cisco.networking.metal.ironcore.dev_vpcdomains.yaml - bases/nx.cisco.networking.metal.ironcore.dev_interfaceconfigs.yaml +- bases/nx.cisco.networking.metal.ironcore.dev_lldpconfigs.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: [] diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index cb16788f..5bf736dd 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -167,6 +167,7 @@ rules: - nx.cisco.networking.metal.ironcore.dev resources: - interfaceconfigs + - lldpconfigs - managementaccessconfigs - networkvirtualizationedgeconfigs verbs: diff --git a/config/samples/cisco/nx/v1alpha1_lldpconfig.yaml b/config/samples/cisco/nx/v1alpha1_lldpconfig.yaml new file mode 100644 index 00000000..528147fe --- /dev/null +++ b/config/samples/cisco/nx/v1alpha1_lldpconfig.yaml @@ -0,0 +1,16 @@ +apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 +kind: LLDPConfig +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: leaf1-lldpconfig +spec: + initDelay: 10 + holdTime: 120 + interfaceRefs: + - name: eth1-1 + adminRxState: Down + adminTxState: Down + - name: eth1-2 + adminTxState: Down diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 968902b1..ea53c049 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -29,4 +29,5 @@ resources: - cisco/nx/v1alpha1_system.yaml - cisco/nx/v1alpha1_vpcdomain.yaml - cisco/nx/v1alpha1_interfaceconfig.yaml +- cisco/nx/v1alpha1_lldpconfig.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index b55c757a..d13601a4 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -137,6 +137,7 @@ _Appears in:_ - [DNSSpec](#dnsspec) - [ISISSpec](#isisspec) - [InterfaceSpec](#interfacespec) +- [LLDPInterface](#lldpinterface) - [LLDPSpec](#lldpspec) - [NTPSpec](#ntpspec) - [NetworkVirtualizationEdgeSpec](#networkvirtualizationedgespec) @@ -1487,6 +1488,7 @@ _Appears in:_ - [InterfaceSpec](#interfacespec) - [InterfaceStatus](#interfacestatus) - [KeepAlive](#keepalive) +- [LLDPInterface](#lldpinterface) - [LLDPSpec](#lldpspec) - [ManagementAccessSpec](#managementaccessspec) - [NTPSpec](#ntpspec) @@ -3025,6 +3027,7 @@ Package v1alpha1 contains API Schema definitions for the nx.cisco.networking.met ### Resource Types - [BorderGateway](#bordergateway) - [InterfaceConfig](#interfaceconfig) +- [LLDPConfig](#lldpconfig) - [ManagementAccessConfig](#managementaccessconfig) - [NetworkVirtualizationEdgeConfig](#networkvirtualizationedgeconfig) - [System](#system) @@ -3284,6 +3287,60 @@ _Appears in:_ | `vrfRef` _[LocalObjectReference](#localobjectreference)_ | The reference to a VRF resource used to send keepalive packets to the peer.
Mutually exclusive with VrfName. | | Optional: \{\}
| +#### LLDPConfig + + + +LLDPConfig is the Schema for the LLDPConfig API + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `nx.cisco.networking.metal.ironcore.dev/v1alpha1` | | | +| `kind` _string_ | `LLDPConfig` | | | +| `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` _[LLDPConfigSpec](#lldpconfigspec)_ | spec defines the desired state of LLDP | | Required: \{\}
| + + +#### LLDPConfigSpec + + + +LLDPConfig defines the Cisco-specific configuration of an LLDP object. + + + +_Appears in:_ +- [LLDPConfig](#lldpconfig) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `initDelay` _integer_ | InitDelay defines the delay in seconds before LLDP starts sending packets after interface comes up. | 2 | Maximum: 10
Minimum: 1
Optional: \{\}
| +| `holdTime` _integer_ | HoldTime defines the time in seconds that the receiving device should hold the LLDP information before discarding it. | 120 | Maximum: 255
Minimum: 1
Optional: \{\}
| +| `interfaceRefs` _[LLDPInterface](#lldpinterface) array_ | InterfaceRefs is a list of interfaces and their LLDP configuration. | | Optional: \{\}
| + + +#### LLDPInterface + + + + + + + +_Appears in:_ +- [LLDPConfigSpec](#lldpconfigspec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `name` _string_ | Name of the referent.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names | | MaxLength: 63
MinLength: 1
Required: \{\}
| +| `adminRxState` _[AdminState](#adminstate)_ | AdminRxState defines the administrative state for receiving LLDP PDUs on the interface. | Up | Enum: [Up Down]
Optional: \{\}
| +| `adminTxState` _[AdminState](#adminstate)_ | AdminTxState defines the administrative state for transmitting LLDP PDUs on the interface. | Up | Enum: [Up Down]
Optional: \{\}
| + + #### ManagementAccessConfig From d4d2e124fe71d1d891f1988af6c42c50754af85d Mon Sep 17 00:00:00 2001 From: Pujol Date: Fri, 27 Feb 2026 16:41:35 +0100 Subject: [PATCH 3/4] Add `providerconfig` package * This package introduces a registry pattern for functions scoped to provider-specific objects. For example, controllers can use these functions to validate provider-specific configs without them requiring details of the implementation. * We introduce this package as a stand-alone package to avoid adding a dependency to the k8s client in the provider implementation(s). --- .../providerconfig/cisco/nx/lldpconfig.go | 94 ++++++++ .../cisco/nx/lldpconfig_test.go | 223 ++++++++++++++++++ .../providerconfig/validation_registry.go | 63 +++++ .../validation_registry_test.go | 43 ++++ 4 files changed, 423 insertions(+) create mode 100644 internal/providerconfig/cisco/nx/lldpconfig.go create mode 100644 internal/providerconfig/cisco/nx/lldpconfig_test.go create mode 100644 internal/providerconfig/validation_registry.go create mode 100644 internal/providerconfig/validation_registry_test.go diff --git a/internal/providerconfig/cisco/nx/lldpconfig.go b/internal/providerconfig/cisco/nx/lldpconfig.go new file mode 100644 index 00000000..74f77eeb --- /dev/null +++ b/internal/providerconfig/cisco/nx/lldpconfig.go @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package nx + +import ( + "context" + "errors" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + nxv1alpha "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/conditions" + "github.com/ironcore-dev/network-operator/internal/providerconfig" +) + +func init() { + providerconfig.RegisterValidator(nxv1alpha.GroupVersion.WithKind("LLDPConfig"), LLDPConfigValidationFunc) +} + +// LLDPConfigScope contains pre-fetched and validated data for LLDP configuration. +// The provider decodes this from unstructured data. +type LLDPConfigScope struct { + // Interfaces is a map with keys being the interface name (K8s resource name) + // and values being the corresponding Interface resources. + Interfaces map[string]*v1alpha1.Interface `json:"interfaces"` +} + +func LLDPConfigValidationFunc(ctx context.Context, r client.Reader, parent client.Object, ref *v1alpha1.TypedLocalObjectReference) (*providerconfig.Scope, error) { + lldp, ok := parent.(*v1alpha1.LLDP) + if !ok { + return nil, errors.New("parent is not LLDP") + } + + cfg := new(nxv1alpha.LLDPConfig) + if err := r.Get(ctx, client.ObjectKey{Namespace: lldp.Namespace, Name: ref.Name}, cfg); err != nil { + return nil, fmt.Errorf("failed to get LLDPConfig %q: %w", ref.Name, err) + } + + s := &LLDPConfigScope{ + Interfaces: make(map[string]*v1alpha1.Interface), + } + // Ensure all referenced interfaces exist and belong to the same device as the LLDP. + for _, ifCfg := range cfg.Spec.InterfaceRefs { + intf := new(v1alpha1.Interface) + if err := r.Get(ctx, client.ObjectKey{Name: ifCfg.Name, Namespace: lldp.Namespace}, intf); err != nil { + if apierrors.IsNotFound(err) { + msg := fmt.Sprintf("Referenced interface %q not found", ifCfg.Name) + conditions.Set(lldp, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.InterfaceNotFoundReason, + Message: msg, + }) + return nil, fmt.Errorf("%s: %w", msg, err) + } + return nil, fmt.Errorf("failed to get referenced interface %q: %w", ifCfg.Name, err) + } + if intf.Spec.DeviceRef.Name != lldp.Spec.DeviceRef.Name { + msg := fmt.Sprintf("Referenced interface %q belongs to device %q, expected %q", ifCfg.Name, intf.Spec.DeviceRef.Name, lldp.Spec.DeviceRef.Name) + conditions.Set(lldp, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.CrossDeviceReferenceReason, + Message: msg, + }) + return nil, errors.New(msg) + } + if intf.Spec.Type != v1alpha1.InterfaceTypePhysical && intf.Spec.Type != v1alpha1.InterfaceTypeAggregate { + msg := fmt.Sprintf("Referenced interface %q is of type %q, expected %q or %q", ifCfg.Name, intf.Spec.Type, v1alpha1.InterfaceTypePhysical, v1alpha1.InterfaceTypeAggregate) + conditions.Set(lldp, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.InvalidInterfaceTypeReason, + Message: msg, + }) + return nil, errors.New(msg) + } + s.Interfaces[ifCfg.Name] = intf + } + + m, err := runtime.DefaultUnstructuredConverter.ToUnstructured(s) + if err != nil { + return nil, fmt.Errorf("failed to convert provider scope to unstructured: %w", err) + } + u := &unstructured.Unstructured{Object: m} + return providerconfig.NewScope(u), nil +} diff --git a/internal/providerconfig/cisco/nx/lldpconfig_test.go b/internal/providerconfig/cisco/nx/lldpconfig_test.go new file mode 100644 index 00000000..e621895e --- /dev/null +++ b/internal/providerconfig/cisco/nx/lldpconfig_test.go @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package nx + +import ( + "testing" + + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + nxv1alpha "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" + corev1 "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +func newTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + + scheme := runtime.NewScheme() + utilruntime.Must(corev1.AddToScheme(scheme)) + utilruntime.Must(nxv1alpha.AddToScheme(scheme)) + return scheme +} + +func TestLLDPConfigValidationFunc_Success(t *testing.T) { + g := NewWithT(t) + + scheme := newTestScheme(t) + + lldp := &corev1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-lldp", + Namespace: metav1.NamespaceDefault, + }, + Spec: corev1.LLDPSpec{ + DeviceRef: corev1.LocalObjectReference{Name: "test-device"}, + AdminState: corev1.AdminStateUp, + }, + } + + cfg := &nxv1alpha.LLDPConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: metav1.NamespaceDefault, + }, + Spec: nxv1alpha.LLDPConfigSpec{ + InterfaceRefs: []nxv1alpha.LLDPInterface{{ + LocalObjectReference: corev1.LocalObjectReference{Name: "if1"}, + }}, + }, + } + + intf := &corev1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + Name: "if1", + Namespace: metav1.NamespaceDefault, + }, + Spec: corev1.InterfaceSpec{ + DeviceRef: corev1.LocalObjectReference{Name: "test-device"}, + Name: "Ethernet1/1", + Type: corev1.InterfaceTypePhysical, + }, + } + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cfg, intf). + Build() + + ref := &corev1.TypedLocalObjectReference{ + APIVersion: nxv1alpha.GroupVersion.String(), + Kind: "LLDPConfig", + Name: cfg.Name, + } + + scope, err := LLDPConfigValidationFunc(t.Context(), c, lldp, ref) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(scope).NotTo(BeNil()) + + // Decode back into LLDPConfigScope + var decoded LLDPConfigScope + err = scope.Into(&decoded) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(decoded.Interfaces).To(HaveKey("if1")) + g.Expect(decoded.Interfaces["if1"].Spec.DeviceRef.Name).To(Equal("test-device")) +} + +func TestLLDPConfigValidationFunc_ParentNotLLDP(t *testing.T) { + g := NewWithT(t) + + scheme := newTestScheme(t) + + c := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + parent := &corev1.Interface{} + ref := &corev1.TypedLocalObjectReference{ + APIVersion: nxv1alpha.GroupVersion.String(), + Kind: "LLDPConfig", + Name: "does-not-matter", + } + + scope, err := LLDPConfigValidationFunc(t.Context(), c, parent, ref) + g.Expect(err).To(HaveOccurred()) + g.Expect(scope).To(BeNil()) +} + +func TestLLDPConfigValidationFunc_InterfaceNotFoundSetsCondition(t *testing.T) { + g := NewWithT(t) + + scheme := newTestScheme(t) + + lldp := &corev1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-lldp", + Namespace: metav1.NamespaceDefault, + }, + Spec: corev1.LLDPSpec{ + DeviceRef: corev1.LocalObjectReference{Name: "test-device"}, + AdminState: corev1.AdminStateUp, + }, + } + + cfg := &nxv1alpha.LLDPConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: metav1.NamespaceDefault, + }, + Spec: nxv1alpha.LLDPConfigSpec{ + InterfaceRefs: []nxv1alpha.LLDPInterface{{ + LocalObjectReference: corev1.LocalObjectReference{Name: "missing-if"}, + }}, + }, + } + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cfg). + Build() + + ref := &corev1.TypedLocalObjectReference{ + APIVersion: nxv1alpha.GroupVersion.String(), + Kind: "LLDPConfig", + Name: cfg.Name, + } + + scope, err := LLDPConfigValidationFunc(t.Context(), c, lldp, ref) + g.Expect(err).To(HaveOccurred()) + g.Expect(scope).To(BeNil()) + + g.Expect(lldp.Status.Conditions).ToNot(BeEmpty()) + cond := lldp.Status.Conditions[len(lldp.Status.Conditions)-1] + g.Expect(cond.Type).To(Equal(corev1.ConfiguredCondition)) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(corev1.InterfaceNotFoundReason)) +} + +func TestLLDPConfigValidationFunc_CrossDeviceReferenceSetsCondition(t *testing.T) { + g := NewWithT(t) + + scheme := newTestScheme(t) + + lldp := &corev1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-lldp", + Namespace: metav1.NamespaceDefault, + }, + Spec: corev1.LLDPSpec{ + DeviceRef: corev1.LocalObjectReference{Name: "device-a"}, + AdminState: corev1.AdminStateUp, + }, + } + + cfg := &nxv1alpha.LLDPConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-config", + Namespace: metav1.NamespaceDefault, + }, + Spec: nxv1alpha.LLDPConfigSpec{ + InterfaceRefs: []nxv1alpha.LLDPInterface{{ + LocalObjectReference: corev1.LocalObjectReference{Name: "if1"}, + }}, + }, + } + + // Interface belongs to a different device + intf := &corev1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + Name: "if1", + Namespace: metav1.NamespaceDefault, + }, + Spec: corev1.InterfaceSpec{ + DeviceRef: corev1.LocalObjectReference{Name: "device-b"}, + Name: "Ethernet1/1", + Type: corev1.InterfaceTypePhysical, + }, + } + + c := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(cfg, intf). + Build() + + ref := &corev1.TypedLocalObjectReference{ + APIVersion: nxv1alpha.GroupVersion.String(), + Kind: "LLDPConfig", + Name: cfg.Name, + } + + scope, err := LLDPConfigValidationFunc(t.Context(), c, lldp, ref) + g.Expect(err).To(HaveOccurred()) + g.Expect(scope).To(BeNil()) + + g.Expect(lldp.Status.Conditions).ToNot(BeEmpty()) + cond := lldp.Status.Conditions[len(lldp.Status.Conditions)-1] + g.Expect(cond.Type).To(Equal(corev1.ConfiguredCondition)) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(corev1.CrossDeviceReferenceReason)) +} diff --git a/internal/providerconfig/validation_registry.go b/internal/providerconfig/validation_registry.go new file mode 100644 index 00000000..eb51b329 --- /dev/null +++ b/internal/providerconfig/validation_registry.go @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package providerconfig + +import ( + "context" + "sync" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + + corev1 "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +// Scope wraps objects fetched from k8s and validated by registered provider config validators. +// This allows the provider to remain independent of Kubernetes client libraries. +type Scope struct { + obj *unstructured.Unstructured +} + +// NewScope creates a new Scope from an unstructured object. +func NewScope(obj *unstructured.Unstructured) *Scope { + return &Scope{obj: obj} +} + +// Into converts the underlying unstructured object into the specified type. +func (s *Scope) Into(v any) error { + return runtime.DefaultUnstructuredConverter.FromUnstructured(s.obj.Object, v) +} + +// Raw returns the underlying unstructured object. +func (s *Scope) Raw() *unstructured.Unstructured { + return s.obj +} + +// Validator validates a provider-specific configuration object referenced by a core resource. +// It receives a client.Reader so that callers can choose whether to use the cached client or +// the uncached APIReader. +type Validator func(ctx context.Context, r client.Reader, parent client.Object, ref *corev1.TypedLocalObjectReference) (*Scope, error) + +var ( + mu sync.RWMutex + lookup = map[schema.GroupVersionKind]Validator{} +) + +// RegisterValidator registers a provider config validator for the given GVK. +// Registration should be called from init() in provider config packages. +func RegisterValidator(gvk schema.GroupVersionKind, fn Validator) { + mu.Lock() + defer mu.Unlock() + lookup[gvk] = fn +} + +// GetValidator returns the registered validator for the GVK, if any. +func GetValidator(gvk schema.GroupVersionKind) (Validator, bool) { + mu.RLock() + defer mu.RUnlock() + fn, ok := lookup[gvk] + return fn, ok +} diff --git a/internal/providerconfig/validation_registry_test.go b/internal/providerconfig/validation_registry_test.go new file mode 100644 index 00000000..5e317294 --- /dev/null +++ b/internal/providerconfig/validation_registry_test.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package providerconfig + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + + corev1 "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +func TestRegisterAndGetValidator(t *testing.T) { + gvk := schema.GroupVersionKind{Group: "test.example.com", Version: "v1", Kind: "TestConfig"} + + called := false + testValidator := func(ctx context.Context, c client.Reader, parent client.Object, ref *corev1.TypedLocalObjectReference) (*Scope, error) { + called = true + return nil, nil + } + + RegisterValidator(gvk, testValidator) + + fn, ok := GetValidator(gvk) + assert.True(t, ok, "validator should be found") + assert.NotNil(t, fn) + + _, err := fn(context.Background(), nil, nil, nil) + assert.NoError(t, err) + assert.True(t, called, "validator function should have been called") +} + +func TestGetValidator_NotFound(t *testing.T) { + gvk := schema.GroupVersionKind{Group: "unknown.example.com", Version: "v1", Kind: "Unknown"} + + fn, ok := GetValidator(gvk) + assert.False(t, ok) + assert.Nil(t, fn) +} From 199cada28653b5d92fec595e2cef99f6f0a710c0 Mon Sep 17 00:00:00 2001 From: Pujol Date: Fri, 27 Feb 2026 16:41:44 +0100 Subject: [PATCH 4/4] [NXOS] `LLDP` provider implementation * Enables/disables LLDP feature and configures global and per-interface settings on Cisco NX-OS switches. --- cmd/main.go | 3 + internal/controller/core/lldp_controller.go | 33 ++- .../controller/core/lldp_controller_test.go | 248 ++++++++++++++++++ internal/controller/core/suite_test.go | 7 + internal/provider/cisco/nxos/lldp.go | 45 ++++ internal/provider/cisco/nxos/lldp_test.go | 24 ++ internal/provider/cisco/nxos/provider.go | 93 +++++++ .../provider/cisco/nxos/testdata/lldp.json | 22 ++ .../cisco/nxos/testdata/lldp.json.txt | 7 + internal/provider/provider.go | 18 +- 10 files changed, 496 insertions(+), 4 deletions(-) create mode 100644 internal/provider/cisco/nxos/lldp.go create mode 100644 internal/provider/cisco/nxos/lldp_test.go create mode 100644 internal/provider/cisco/nxos/testdata/lldp.json create mode 100644 internal/provider/cisco/nxos/testdata/lldp.json.txt diff --git a/cmd/main.go b/cmd/main.go index 2b375426..00f8c0f9 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -40,6 +40,9 @@ import ( _ "github.com/ironcore-dev/network-operator/internal/provider/cisco/nxos" _ "github.com/ironcore-dev/network-operator/internal/provider/openconfig" + // Import provider-specific config validators. + _ "github.com/ironcore-dev/network-operator/internal/providerconfig/cisco/nx" + nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" "github.com/ironcore-dev/network-operator/api/core/v1alpha1" nxcontroller "github.com/ironcore-dev/network-operator/internal/controller/cisco/nx" diff --git a/internal/controller/core/lldp_controller.go b/internal/controller/core/lldp_controller.go index 3b5a4ba3..b98ffae4 100644 --- a/internal/controller/core/lldp_controller.go +++ b/internal/controller/core/lldp_controller.go @@ -34,6 +34,7 @@ import ( "github.com/ironcore-dev/network-operator/internal/conditions" "github.com/ironcore-dev/network-operator/internal/deviceutil" "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/providerconfig" "github.com/ironcore-dev/network-operator/internal/resourcelock" ) @@ -201,6 +202,8 @@ type lldpScope struct { Provider provider.LLDPProvider // ProviderConfig is the resource referenced by LLDP.Spec.ProviderConfigRef, if any. ProviderConfig *provider.ProviderConfig + // ProviderConfigScope is the scope with the resources referenced in ProviderConfig, if applicable. + ProviderConfigScope *providerconfig.Scope } func (r *LLDPReconciler) reconcile(ctx context.Context, s *lldpScope) (_ ctrl.Result, reterr error) { @@ -240,9 +243,17 @@ func (r *LLDPReconciler) reconcile(ctx context.Context, s *lldpScope) (_ ctrl.Re }() // Ensure the LLDP is realized on the remote device. + // Convert providerconfig.Scope to provider.ProviderConfigScope. + // These are separate types to decouple the validation layer (which uses k8s client) + // from the provider layer (which should not depend on k8s client). + var providerScope *provider.ProviderConfigScope + if s.ProviderConfigScope != nil { + providerScope = provider.NewProviderConfigScope(s.ProviderConfigScope.Raw()) + } err = s.Provider.EnsureLLDP(ctx, &provider.LLDPRequest{ - LLDP: s.LLDP, - ProviderConfig: s.ProviderConfig, + LLDP: s.LLDP, + ProviderConfig: s.ProviderConfig, + ProviderConfigScope: providerScope, }) cond := conditions.FromError(err) @@ -321,6 +332,24 @@ func (r *LLDPReconciler) validateProviderConfigRef(ctx context.Context, s *lldpS return nil, reconcile.TerminalError(fmt.Errorf("unsupported ProviderConfigRef Kind %q on this provider", gv)) } + // if a provider-specific validator is registered, use it + if validator, ok := providerconfig.GetValidator(gvk); ok { + reader := r.APIReader + if reader == nil { + // fall back to the cached client if APIReader is not set + reader = r.Client + } + s.ProviderConfigScope, err = validator(ctx, reader, s.LLDP, s.LLDP.Spec.ProviderConfigRef) + if err != nil { + conditions.Set(s.LLDP, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.IncompatibleProviderConfigRef, + Message: fmt.Sprintf("ProviderConfigRef validation failed: %v", err), + }) + return nil, reconcile.TerminalError(fmt.Errorf("configuration error in provider config ref %w", err)) + } + } return cfg, nil } diff --git a/internal/controller/core/lldp_controller_test.go b/internal/controller/core/lldp_controller_test.go index 57b59fba..471f148e 100644 --- a/internal/controller/core/lldp_controller_test.go +++ b/internal/controller/core/lldp_controller_test.go @@ -13,6 +13,7 @@ import ( "k8s.io/apimachinery/pkg/api/meta" + nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" "github.com/ironcore-dev/network-operator/api/core/v1alpha1" ) @@ -56,6 +57,12 @@ var _ = Describe("LLDP Controller", func() { err := k8sClient.Get(ctx, resourceKey, lldp) if err == nil { Expect(k8sClient.Delete(ctx, lldp)).To(Succeed()) + + By("Waiting for LLDP resource to be fully deleted") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, &v1alpha1.LLDP{}) + g.Expect(errors.IsNotFound(err)).To(BeTrue()) + }).Should(Succeed()) } By("Cleaning up the Device resource") @@ -274,4 +281,245 @@ var _ = Describe("LLDP Controller", func() { }).Should(Succeed()) }) }) + + Context("When reconciling with ProviderConfigRef", func() { + const ( + deviceName = "testlldp-provider-device" + resourceName = "testlldp-provider-lldp" + ) + + resourceKey := client.ObjectKey{Name: resourceName, Namespace: metav1.NamespaceDefault} + deviceKey := client.ObjectKey{Name: deviceName, Namespace: metav1.NamespaceDefault} + + var ( + device *v1alpha1.Device + lldp *v1alpha1.LLDP + ) + + BeforeEach(func() { + By("Creating the custom resource for the Kind Device") + device = &v1alpha1.Device{} + if err := k8sClient.Get(ctx, deviceKey, device); errors.IsNotFound(err) { + device = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: deviceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.2:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + } + }) + + AfterEach(func() { + By("Cleaning up the LLDP resource") + lldp = &v1alpha1.LLDP{} + err := k8sClient.Get(ctx, resourceKey, lldp) + if err == nil { + Expect(k8sClient.Delete(ctx, lldp)).To(Succeed()) + + By("Waiting for LLDP resource to be fully deleted") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, &v1alpha1.LLDP{}) + 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 resource has been deleted") + Eventually(func(g Gomega) { + g.Expect(testProvider.LLDP).To(BeNil(), "Provider should have no LLDP configured") + }).Should(Succeed()) + }) + + It("Should handle missing ProviderConfigRef", func() { + By("Creating LLDP with a non-existent ProviderConfigRef") + lldp = &v1alpha1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.LLDPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + AdminState: v1alpha1.AdminStateUp, + ProviderConfigRef: &v1alpha1.TypedLocalObjectReference{ + APIVersion: "nx.cisco.networking.metal.ironcore.dev/v1alpha1", + Kind: "LLDPConfig", + Name: "non-existent-config", + }, + }, + } + Expect(k8sClient.Create(ctx, lldp)).To(Succeed()) + + By("Verifying the controller sets ConfiguredCondition to False") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, lldp) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(v1alpha1.IncompatibleProviderConfigRef)) + }).Should(Succeed()) + }) + + It("Should handle invalid ProviderConfigRef API version", func() { + By("Creating LLDP with invalid API version in ProviderConfigRef") + lldp = &v1alpha1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.LLDPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + AdminState: v1alpha1.AdminStateUp, + ProviderConfigRef: &v1alpha1.TypedLocalObjectReference{ + APIVersion: "invalid-api-version", + Kind: "LLDPConfig", + Name: "some-config", + }, + }, + } + Expect(k8sClient.Create(ctx, lldp)).To(Succeed()) + + By("Verifying the controller sets ConfiguredCondition to False with IncompatibleProviderConfigRef") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, lldp) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(v1alpha1.IncompatibleProviderConfigRef)) + }).Should(Succeed()) + }) + + It("Should handle unsupported ProviderConfigRef Kind", func() { + By("Creating LLDP with unsupported Kind in ProviderConfigRef") + lldp = &v1alpha1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.LLDPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + AdminState: v1alpha1.AdminStateUp, + ProviderConfigRef: &v1alpha1.TypedLocalObjectReference{ + APIVersion: "v1", + Kind: "ConfigMap", + Name: "some-config", + }, + }, + } + Expect(k8sClient.Create(ctx, lldp)).To(Succeed()) + + By("Verifying the controller sets ConfiguredCondition to False with IncompatibleProviderConfigRef") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, lldp) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(v1alpha1.IncompatibleProviderConfigRef)) + }).Should(Succeed()) + }) + + It("Should successfully reconcile with valid ProviderConfigRef", func() { + const ( + interfaceName = "testlldp-provider-interface" + lldpConfigName = "testlldp-provider-config" + ) + + By("Creating the Interface resource") + intf := &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + Name: interfaceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Name: "Ethernet1/1", + Type: v1alpha1.InterfaceTypePhysical, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, intf)).To(Succeed()) + + By("Creating the LLDPConfig resource") + lldpConfig := &nxv1alpha1.LLDPConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: lldpConfigName, + Namespace: metav1.NamespaceDefault, + }, + Spec: nxv1alpha1.LLDPConfigSpec{ + InitDelay: 5, + HoldTime: 180, + InterfaceRefs: []nxv1alpha1.LLDPInterface{{ + LocalObjectReference: v1alpha1.LocalObjectReference{Name: interfaceName}, + AdminRxState: v1alpha1.AdminStateUp, + AdminTxState: v1alpha1.AdminStateDown, + }}, + }, + } + Expect(k8sClient.Create(ctx, lldpConfig)).To(Succeed()) + + By("Creating LLDP with valid ProviderConfigRef") + lldp = &v1alpha1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.LLDPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + AdminState: v1alpha1.AdminStateUp, + ProviderConfigRef: &v1alpha1.TypedLocalObjectReference{ + APIVersion: nxv1alpha1.GroupVersion.String(), + Kind: "LLDPConfig", + Name: lldpConfigName, + }, + }, + } + Expect(k8sClient.Create(ctx, lldp)).To(Succeed()) + + By("Verifying the controller sets all conditions to True") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, lldp) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ReadyCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + + cond = meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + + cond = meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.OperationalCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Verifying the LLDP is created in the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.LLDP).ToNot(BeNil()) + g.Expect(testProvider.LLDP.GetName()).To(Equal(resourceName)) + }).Should(Succeed()) + + By("Cleaning up the LLDPConfig resource") + Expect(k8sClient.Delete(ctx, lldpConfig)).To(Succeed()) + + By("Cleaning up the Interface resource") + Expect(k8sClient.Delete(ctx, intf)).To(Succeed()) + }) + }) }) diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go index ae664b61..254d8306 100644 --- a/internal/controller/core/suite_test.go +++ b/internal/controller/core/suite_test.go @@ -28,10 +28,14 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" + nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" "github.com/ironcore-dev/network-operator/api/core/v1alpha1" "github.com/ironcore-dev/network-operator/internal/deviceutil" "github.com/ironcore-dev/network-operator/internal/provider" "github.com/ironcore-dev/network-operator/internal/resourcelock" + + // Import provider-specific config validators to register them. + _ "github.com/ironcore-dev/network-operator/internal/providerconfig/cisco/nx" // +kubebuilder:scaffold:imports ) @@ -67,6 +71,9 @@ var _ = BeforeSuite(func() { err = v1alpha1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) + err = nxv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + // +kubebuilder:scaffold:scheme By("bootstrapping test environment") diff --git a/internal/provider/cisco/nxos/lldp.go b/internal/provider/cisco/nxos/lldp.go new file mode 100644 index 00000000..e2b73e47 --- /dev/null +++ b/internal/provider/cisco/nxos/lldp.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package nxos + +import "github.com/ironcore-dev/network-operator/internal/provider/cisco/gnmiext/v2" + +var ( + _ gnmiext.Configurable = (*LLDP)(nil) +) + +type LLDP struct { + // HoldTime is the number of seconds that a receiving device should hold the information sent by another device before discarding it. + HoldTime Option[uint16] `json:"holdTime"` + // InitDelay is the number of seconds for LLDP to initialize on any interface. + InitDelay Option[uint16] `json:"initDelayTime"` + // IfItems contains the per-interface LLDP configuration. + IfItems struct { + IfList gnmiext.List[string, *LLDPIfItem] `json:"If-list,omitzero"` + } `json:"if-items,omitzero"` +} + +func (*LLDP) XPath() string { + return "System/lldp-items/inst-items" +} + +func (*LLDP) IsListItem() {} + +type LLDPIfItem struct { + InterfaceName string `json:"id"` + AdminRxSt Option[AdminSt] `json:"adminRxSt"` + AdminTxSt Option[AdminSt] `json:"adminTxSt"` +} + +func (i *LLDPIfItem) Key() string { return i.InterfaceName } + +type LLDPOper struct { + OperSt OperSt `json:"operSt"` +} + +func (*LLDPOper) IsListItem() {} + +func (*LLDPOper) XPath() string { + return "System/fm-items/lldp-items" +} diff --git a/internal/provider/cisco/nxos/lldp_test.go b/internal/provider/cisco/nxos/lldp_test.go new file mode 100644 index 00000000..9c4bae3c --- /dev/null +++ b/internal/provider/cisco/nxos/lldp_test.go @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package nxos + +func init() { + lldp := &LLDP{ + HoldTime: NewOption(uint16(200)), + InitDelay: NewOption(uint16(5)), + } + + lldp.IfItems.IfList.Set(&LLDPIfItem{ + InterfaceName: "eth7/1", + AdminRxSt: NewOption(AdminStDisabled), + AdminTxSt: NewOption(AdminStDisabled), + }) + + lldp.IfItems.IfList.Set(&LLDPIfItem{ + InterfaceName: "eth8/1", + AdminTxSt: NewOption(AdminStDisabled), + }) + + Register("lldp", lldp) +} diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index 5c5df3ee..58a9ab4c 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -11,6 +11,7 @@ import ( "crypto/rsa" "errors" "fmt" + "maps" "math" "net/netip" "reflect" @@ -29,6 +30,7 @@ import ( "github.com/ironcore-dev/network-operator/internal/deviceutil" "github.com/ironcore-dev/network-operator/internal/provider" "github.com/ironcore-dev/network-operator/internal/provider/cisco/gnmiext/v2" + nxcfg "github.com/ironcore-dev/network-operator/internal/providerconfig/cisco/nx" ) var ( @@ -56,6 +58,7 @@ var ( _ provider.VLANProvider = (*Provider)(nil) _ provider.VRFProvider = (*Provider)(nil) _ provider.NVEProvider = (*Provider)(nil) + _ provider.LLDPProvider = (*Provider)(nil) ) type Provider struct { @@ -2657,6 +2660,96 @@ func (p *Provider) GetNVEStatus(ctx context.Context, req *provider.NVERequest) ( return s, nil } +func (p *Provider) EnsureLLDP(ctx context.Context, req *provider.LLDPRequest) error { + f1 := new(Feature) + f1.Name = "lldp" + f1.AdminSt = AdminStEnabled + if req.LLDP.Spec.AdminState == v1alpha1.AdminStateDown { + f1.AdminSt = AdminStDisabled + } + + if err := p.Patch(ctx, f1); err != nil { + return err + } + + l := new(LLDP) + c := new(nxv1alpha1.LLDPConfig) + if req.ProviderConfig == nil { + return nil + } + + if err := req.ProviderConfig.Into(c); err != nil { + return fmt.Errorf("failed to decode provider config: %w", err) + } + + l.InitDelay = NewOption(uint16(c.Spec.InitDelay)) //nolint:gosec + l.HoldTime = NewOption(uint16(c.Spec.HoldTime)) //nolint:gosec + + // patch if the provider config only includes global LLDP settings + if c.Spec.InterfaceRefs == nil { + return p.Patch(ctx, l) + } + + // return error the config references interfaces but the request does not include a scope with them + if req.ProviderConfigScope == nil { + return errors.New("lldp: provider config scope is required when interface refs are specified") + } + + s := new(nxcfg.LLDPConfigScope) + if err := req.ProviderConfigScope.Into(s); err != nil { + return fmt.Errorf("failed to decode provider config into scope: %w", err) + } + + for _, ifCfg := range c.Spec.InterfaceRefs { + intf, ok := s.Interfaces[ifCfg.Name] + if !ok { + available := slices.Sorted(maps.Keys(s.Interfaces)) + return fmt.Errorf("lldp: interface %q not found in request (available interfaces: %v)", ifCfg.Name, available) + } + + item := new(LLDPIfItem) + name, err := ShortName(intf.Spec.Name) + if err != nil { + return fmt.Errorf("lldp: failed to get short name for interface %q: %w", intf.Spec.Name, err) + } + item.InterfaceName = name + + item.AdminRxSt = NewOption(AdminStEnabled) + if ifCfg.AdminRxState == v1alpha1.AdminStateDown { + item.AdminRxSt = NewOption(AdminStDisabled) + } + item.AdminTxSt = NewOption(AdminStEnabled) + if ifCfg.AdminTxState == v1alpha1.AdminStateDown { + item.AdminTxSt = NewOption(AdminStDisabled) + } + l.IfItems.IfList.Set(item) + } + return p.Patch(ctx, l) +} + +func (p *Provider) DeleteLLDP(ctx context.Context, req *provider.LLDPRequest) error { + f1 := new(Feature) + f1.Name = "lldp" + f1.AdminSt = AdminStDisabled + + if err := p.Patch(ctx, f1); err != nil { + return err + } + return nil +} + +func (p *Provider) GetLLDPStatus(ctx context.Context, req *provider.LLDPRequest) (provider.LLDPStatus, error) { + s := provider.LLDPStatus{} + + op := new(LLDPOper) + if err := p.client.GetState(ctx, op); err != nil && !errors.Is(err, gnmiext.ErrNil) { + return provider.LLDPStatus{}, err + } + s.OperStatus = op.OperSt == OperSt(AdminStEnabled) + + return s, nil +} + func (p *Provider) Patch(ctx context.Context, conf ...gnmiext.Configurable) error { if NXVersion(p.client.Capabilities()) > VersionNX10_6_2 { return p.client.Patch(ctx, conf...) diff --git a/internal/provider/cisco/nxos/testdata/lldp.json b/internal/provider/cisco/nxos/testdata/lldp.json new file mode 100644 index 00000000..9e659f0a --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/lldp.json @@ -0,0 +1,22 @@ +{ + "lldp-items": { + "inst-items": { + "holdTime": 200, + "initDelayTime": 5, + "if-items": { + "If-list": [ + { + "id": "eth7/1", + "adminRxSt": "disabled", + "adminTxSt": "disabled" + }, + { + "id": "eth8/1", + "adminRxSt": "DME_UNSET_PROPERTY_MARKER", + "adminTxSt": "disabled" + } + ] + } + } + } +} diff --git a/internal/provider/cisco/nxos/testdata/lldp.json.txt b/internal/provider/cisco/nxos/testdata/lldp.json.txt new file mode 100644 index 00000000..d9d0a743 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/lldp.json.txt @@ -0,0 +1,7 @@ +lldp holdtime 200 +lldp reinit 5 +interface ethernet 7/1 + no lldp receive + no lldp transmit +interface ethernet 8/1 + no lldp transmit diff --git a/internal/provider/provider.go b/internal/provider/provider.go index f932d532..52251f85 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -599,8 +599,9 @@ type LLDPProvider interface { } type LLDPRequest struct { - LLDP *v1alpha1.LLDP - ProviderConfig *ProviderConfig + LLDP *v1alpha1.LLDP + ProviderConfig *ProviderConfig + ProviderConfigScope *ProviderConfigScope } // LLDPStatus represents the operational status of LLDP on the device. @@ -674,3 +675,16 @@ type ProviderConfig struct { func (p ProviderConfig) Into(v any) error { return runtime.DefaultUnstructuredConverter.FromUnstructured(p.obj.Object, v) } + +// ProviderConfigScope is a wrapper around an [unstructured.Unstructured] object that represents a provider-specific scope. +type ProviderConfigScope struct { + obj *unstructured.Unstructured +} + +func (p ProviderConfigScope) Into(v any) error { + return runtime.DefaultUnstructuredConverter.FromUnstructured(p.obj.Object, v) +} + +func NewProviderConfigScope(obj *unstructured.Unstructured) *ProviderConfigScope { + return &ProviderConfigScope{obj: obj} +}