From 3ef08d14794a00b772fed5d7713ce87cacf1cda6 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 5 Mar 2026 16:05:35 +0100 Subject: [PATCH 1/4] [core] Add `LLDP` API type Add core types to configure Link-Layer Discovery Protocol on devices. The type includes a system-wide setting to enable and disable LLDP. Following OpenConfig design, this type allows per-interface configuration (enable/disable). Notice that the interaction of global and per-interface settings may differ on each platform and provider implementation. Also, some platforms like NXOS allow configuring more settings than those defined in OpenConfig, e.g., disable or enable receive and transmit. This is currently not required by our use case. If this is ever needed this API will need to change. --- PROJECT | 8 + api/core/v1alpha1/groupversion_info.go | 3 + api/core/v1alpha1/lldp_types.go | 123 +++++++++ api/core/v1alpha1/zz_generated.deepcopy.go | 123 +++++++++ .../lldps.networking.metal.ironcore.dev.yaml | 238 ++++++++++++++++++ .../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 + .../networking.metal.ironcore.dev_lldps.yaml | 234 +++++++++++++++++ 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 | 16 ++ docs/api-reference/index.md | 78 ++++++ internal/provider/provider.go | 26 ++ 20 files changed, 1029 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 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/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..f7da56b8 --- /dev/null +++ b/api/core/v1alpha1/lldp_types.go @@ -0,0 +1,123 @@ +// 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"` + + // InterfaceRefs is a list of interfaces and their LLDP configuration. + // +optional + // +listType=atomic + InterfaceRefs []LLDPInterface `json:"interfaceRefs,omitempty"` +} + +type LLDPInterface struct { + LocalObjectReference `json:",inline"` + + // AdminState indicates whether LLDP is administratively up or down on this interface. + // This will be ignored if LLDP is configured to be administratively down system-wide. + // +optional + // +kubebuilder:default=Up + 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..32988e04 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -1713,6 +1713,129 @@ 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 *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 *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 + } + 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 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..044b2cd5 --- /dev/null +++ b/charts/network-operator/templates/crd/lldps.networking.metal.ironcore.dev.yaml @@ -0,0 +1,238 @@ +{{- 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 + interfaceRefs: + description: InterfaceRefs is a list of interfaces and their LLDP + configuration. + items: + properties: + adminState: + default: Up + description: |- + AdminState indicates whether LLDP is administratively up or down on this interface. + This will be ignored if LLDP is configured to be administratively down system-wide. + 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 + 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/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..53368a01 --- /dev/null +++ b/config/crd/bases/networking.metal.ironcore.dev_lldps.yaml @@ -0,0 +1,234 @@ +--- +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 + interfaceRefs: + description: InterfaceRefs is a list of interfaces and their LLDP + configuration. + items: + properties: + adminState: + default: Up + description: |- + AdminState indicates whether LLDP is administratively up or down on this interface. + This will be ignored if LLDP is configured to be administratively down system-wide. + 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 + 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..7246675e --- /dev/null +++ b/config/samples/v1alpha1_lldp.yaml @@ -0,0 +1,16 @@ +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 + interfaceRefs: + - name: eth1-1 + - name: eth1-2 + adminState: Down diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index 2982390e..e6f3af55 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,8 @@ _Appears in:_ - [DNSSpec](#dnsspec) - [ISISSpec](#isisspec) - [InterfaceSpec](#interfacespec) +- [LLDPInterface](#lldpinterface) +- [LLDPSpec](#lldpspec) - [NTPSpec](#ntpspec) - [NetworkVirtualizationEdgeSpec](#networkvirtualizationedgespec) - [OSPFSpec](#ospfspec) @@ -917,6 +920,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | +| `paused` _boolean_ | Paused can be used to prevent controllers from processing the Device and its associated objects. | | Optional: \{\}
| | `endpoint` _[Endpoint](#endpoint)_ | Endpoint contains the connection information for the device. | | Required: \{\}
| | `provisioning` _[Provisioning](#provisioning)_ | Provisioning is an optional configuration for the device provisioning process.
It can be used to provide initial configuration templates or scripts that are applied during the device provisioning. | | Optional: \{\}
| @@ -1404,6 +1408,77 @@ _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: \{\}
| + + +#### LLDPInterface + + + + + + + +_Appears in:_ +- [LLDPSpec](#lldpspec) + +| 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: \{\}
| +| `adminState` _[AdminState](#adminstate)_ | AdminState indicates whether LLDP is administratively up or down on this interface.
This will be ignored if LLDP is configured to be administratively down system-wide. | Up | Enum: [Up Down]
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: \{\}
| +| `interfaceRefs` _[LLDPInterface](#lldpinterface) array_ | InterfaceRefs is a list of interfaces and their LLDP configuration. | | Optional: \{\}
| + + +#### 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 +1507,8 @@ _Appears in:_ - [InterfaceSpec](#interfacespec) - [InterfaceStatus](#interfacestatus) - [KeepAlive](#keepalive) +- [LLDPInterface](#lldpinterface) +- [LLDPSpec](#lldpspec) - [ManagementAccessSpec](#managementaccessspec) - [NTPSpec](#ntpspec) - [NetworkVirtualizationEdgeSpec](#networkvirtualizationedgespec) @@ -2754,6 +2831,7 @@ _Appears in:_ - [EVPNInstanceSpec](#evpninstancespec) - [ISISSpec](#isisspec) - [InterfaceSpec](#interfacespec) +- [LLDPSpec](#lldpspec) - [ManagementAccessSpec](#managementaccessspec) - [NTPSpec](#ntpspec) - [NetworkVirtualizationEdgeSpec](#networkvirtualizationedgespec) diff --git a/internal/provider/provider.go b/internal/provider/provider.go index a1ac2a02..eb0a5191 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -586,6 +586,32 @@ 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 + // Interfaces are the Interface resources referenced by LLDP.Spec.InterfaceRefs. + Interfaces []*v1alpha1.Interface +} + +// 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 88427962adefaff1fe824a94905d15256f126f27 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 5 Mar 2026 16:12:48 +0100 Subject: [PATCH 2/4] [core] Add `LLDP` controller The controller validates that the referenced Device exists and is not paused, that ProviderConfigRef (if specified) points to a supported provider-specific configuration, and that all InterfaceRefs belong to the same device. It also ensures only one LLDP resource exists per device. During reconciliation, the controller sets the device label and owner reference on the LLDP resource, then calls the provider to apply the LLDP configuration and retrieve operational status. The controller watches for Device Paused field changes and ProviderConfig updates to trigger re-reconciliation when dependencies change. Periodic polling via RequeueInterval ensures operational status stays updated. --- Tiltfile | 4 + cmd/main.go | 13 + ...networking.metal.ironcore.dev_devices.yaml | 5 - internal/controller/core/lldp_controller.go | 546 +++++++++ .../controller/core/lldp_controller_test.go | 1087 +++++++++++++++++ internal/controller/core/suite_test.go | 30 + 6 files changed, 1680 insertions(+), 5 deletions(-) create mode 100644 internal/controller/core/lldp_controller.go create mode 100644 internal/controller/core/lldp_controller_test.go diff --git a/Tiltfile b/Tiltfile index fb7ad19c..8a7b8e88 100644 --- a/Tiltfile +++ b/Tiltfile @@ -117,6 +117,10 @@ 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) +# Uncomment the following lines for NXOS specific LLDP config + 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/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_devices.yaml b/config/crd/bases/networking.metal.ironcore.dev_devices.yaml index 0da74495..fad0aa5d 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_devices.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_devices.yaml @@ -435,10 +435,6 @@ spec: type: string error: type: string - phase: - description: ProvisioningPhase represents the reason for the - current provisioning status. - type: string reboot: format: date-time type: string @@ -448,7 +444,6 @@ spec: token: type: string required: - - phase - startTime - token type: object diff --git a/internal/controller/core/lldp_controller.go b/internal/controller/core/lldp_controller.go new file mode 100644 index 00000000..748e20a7 --- /dev/null +++ b/internal/controller/core/lldp_controller.go @@ -0,0 +1,546 @@ +// 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/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/annotations" + "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 + + // 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 + } + + if annotations.IsPaused(device, obj) { + log.Info("Reconciliation is paused for this object") + return ctrl.Result{}, nil + } + + // 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 + // Interfaces are the Interface resources referenced by LLDP.Spec.InterfaceRefs. + Interfaces []*v1alpha1.Interface +} + +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 + } + } + + if err := r.validateUniqueLLDPPerDevice(ctx, s); err != nil { + return ctrl.Result{}, err + } + + if err := r.validateProviderConfigRef(ctx, s); err != nil { + return ctrl.Result{}, err + } + + interfaces, err := r.reconcileInterfaceRefs(ctx, s) + if err != nil { + return ctrl.Result{}, err + } + s.Interfaces = interfaces + + 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}) + } + }() + + defer func() { + conditions.RecomputeReady(s.LLDP) + }() + + // Ensure the LLDP is realized on the remote device. + err = s.Provider.EnsureLLDP(ctx, &provider.LLDPRequest{ + LLDP: s.LLDP, + ProviderConfig: s.ProviderConfig, + Interfaces: s.Interfaces, + }) + + 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{RequeueAfter: Jitter(r.RequeueInterval)}, 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) error { + if s.LLDP.Spec.ProviderConfigRef == nil { + return 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 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 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, + } + + 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 reconcile.TerminalError(fmt.Errorf("unsupported ProviderConfigRef Kind %q on this provider", gv)) + } + + s.ProviderConfig = cfg + return nil +} + +// reconcileInterfaceRefs fetches all referenced interfaces and validates them +func (r *LLDPReconciler) reconcileInterfaceRefs(ctx context.Context, s *lldpScope) ([]*v1alpha1.Interface, error) { + if len(s.LLDP.Spec.InterfaceRefs) == 0 { + return nil, nil + } + + var interfaces []*v1alpha1.Interface + for _, ifRef := range s.LLDP.Spec.InterfaceRefs { + iface, err := r.reconcileInterfaceRef(ctx, ifRef, s) + if err != nil { + return nil, err + } + interfaces = append(interfaces, iface) + } + + return interfaces, nil +} + +// reconcileInterfaceRef checks that the referenced interface exists and belongs to the same device as the LLDP. +func (r *LLDPReconciler) reconcileInterfaceRef(ctx context.Context, interfaceRef v1alpha1.LLDPInterface, s *lldpScope) (*v1alpha1.Interface, error) { + intf := new(v1alpha1.Interface) + if err := r.Get(ctx, types.NamespacedName{ + Name: interfaceRef.Name, + Namespace: s.LLDP.Namespace, + }, intf); err != nil { + if apierrors.IsNotFound(err) { + conditions.Set(s.LLDP, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.WaitingForDependenciesReason, + Message: fmt.Sprintf("Interface %s not found", interfaceRef.Name), + }) + return nil, reconcile.TerminalError(fmt.Errorf("interface %s not found", interfaceRef.Name)) + } + + return nil, fmt.Errorf("failed to get interface %s: %w", interfaceRef.Name, err) + } + + // Verify the interface belongs to the same device + if intf.Spec.DeviceRef.Name != s.Device.Name { + conditions.Set(s.LLDP, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.CrossDeviceReferenceReason, + Message: fmt.Sprintf("Interface %s belongs to device %s, not %s", interfaceRef.Name, intf.Spec.DeviceRef.Name, s.Device.Name), + }) + return nil, reconcile.TerminalError(fmt.Errorf("interface %s belongs to different device", interfaceRef.Name)) + } + + return intf, 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") + } + + 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{}), + ) + } + + // Watches enqueues LLDPs for updates in referenced Device resources. + // Triggers on update events when the Paused spec field changes. + c = c.Watches( + &v1alpha1.Device{}, + handler.EnqueueRequestsFromMapFunc(r.deviceToLLDPs), + builder.WithPredicates(predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + oldDevice := e.ObjectOld.(*v1alpha1.Device) + newDevice := e.ObjectNew.(*v1alpha1.Device) + // Only trigger when Paused spec field changes. + return !equality.Semantic.DeepEqual(oldDevice.Spec.Paused, newDevice.Spec.Paused) + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + }), + ) + + 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, + }) +} + +// deviceToLLDPs is a [handler.MapFunc] to be used to enqueue requests for reconciliation +// for LLDPs when their referenced Device's Paused spec field changes. +func (r *LLDPReconciler) deviceToLLDPs(ctx context.Context, obj client.Object) []ctrl.Request { + device, ok := obj.(*v1alpha1.Device) + if !ok { + panic(fmt.Sprintf("Expected a Device but got a %T", obj)) + } + + log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) + + lldps := new(v1alpha1.LLDPList) + if err := r.List(ctx, lldps, + client.InNamespace(device.Namespace), + client.MatchingLabels{v1alpha1.DeviceLabel: device.Name}, + ); err != nil { + log.Error(err, "Failed to list LLDPs") + return nil + } + + requests := make([]ctrl.Request, 0, len(lldps.Items)) + for _, l := range lldps.Items { + log.Info("Enqueuing LLDP for reconciliation", "LLDP", klog.KObj(&l)) + requests = append(requests, ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: l.Name, + Namespace: l.Namespace, + }, + }) + } + + return requests +} diff --git a/internal/controller/core/lldp_controller_test.go b/internal/controller/core/lldp_controller_test.go new file mode 100644 index 00000000..947b3fa2 --- /dev/null +++ b/internal/controller/core/lldp_controller_test.go @@ -0,0 +1,1087 @@ +// 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("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) + 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()) + }) + }) + + Context("When DeviceRef references non-existent Device", func() { + const resourceName = "testlldp-nodevice-lldp" + + resourceKey := client.ObjectKey{Name: resourceName, Namespace: metav1.NamespaceDefault} + + AfterEach(func() { + By("Cleaning up the LLDP resource") + lldp := &v1alpha1.LLDP{} + err := k8sClient.Get(ctx, resourceKey, lldp) + if err == nil { + // Remove finalizer if present to allow deletion + if controllerutil.ContainsFinalizer(lldp, v1alpha1.FinalizerName) { + controllerutil.RemoveFinalizer(lldp, v1alpha1.FinalizerName) + Expect(k8sClient.Update(ctx, lldp)).To(Succeed()) + } + Expect(k8sClient.Delete(ctx, lldp)).To(Succeed()) + } + }) + + It("Should not add finalizer when Device does not exist", func() { + By("Creating LLDP referencing a non-existent Device") + lldp := &v1alpha1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.LLDPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: "non-existent-device"}, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, lldp)).To(Succeed()) + + By("Verifying the controller does not add a finalizer") + Consistently(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, lldp) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(controllerutil.ContainsFinalizer(lldp, v1alpha1.FinalizerName)).To(BeFalse()) + }).Should(Succeed()) + }) + }) + + Context("When Device is paused", func() { + const ( + deviceName = "testlldp-paused-device" + resourceName = "testlldp-paused-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 Device resource") + 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.6: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") + device = &v1alpha1.Device{} + err = k8sClient.Get(ctx, deviceKey, device) + if err == nil { + // Ensure device is not paused before deletion + if device.Spec.Paused != nil && *device.Spec.Paused { + device.Spec.Paused = nil + Expect(k8sClient.Update(ctx, device)).To(Succeed()) + } + Expect(k8sClient.Delete(ctx, device, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) + } + + By("Verifying the provider has been cleaned up") + Eventually(func(g Gomega) { + g.Expect(testProvider.LLDP).To(BeNil(), "Provider should have no LLDP configured") + }).Should(Succeed()) + }) + + It("Should skip reconciliation when Device is paused", func() { + By("Creating 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 LLDP to be ready") + 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)) + }).Should(Succeed()) + + By("Pausing the Device") + paused := true + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, deviceKey, device) + g.Expect(err).NotTo(HaveOccurred()) + device.Spec.Paused = &paused + g.Expect(k8sClient.Update(ctx, device)).To(Succeed()) + }).Should(Succeed()) + + By("Updating LLDP AdminState to Down") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, lldp) + g.Expect(err).NotTo(HaveOccurred()) + lldp.Spec.AdminState = v1alpha1.AdminStateDown + g.Expect(k8sClient.Update(ctx, lldp)).To(Succeed()) + }).Should(Succeed()) + + By("Verifying the provider still has AdminState Up (reconciliation was skipped)") + Consistently(func(g Gomega) { + g.Expect(testProvider.LLDP).ToNot(BeNil()) + g.Expect(testProvider.LLDP.Spec.AdminState).To(Equal(v1alpha1.AdminStateUp)) + }).Should(Succeed()) + + By("Unpausing the Device") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, deviceKey, device) + g.Expect(err).NotTo(HaveOccurred()) + device.Spec.Paused = nil + g.Expect(k8sClient.Update(ctx, device)).To(Succeed()) + }).Should(Succeed()) + + By("Verifying the provider now has AdminState Down (reconciliation resumed)") + Eventually(func(g Gomega) { + g.Expect(testProvider.LLDP).ToNot(BeNil()) + g.Expect(testProvider.LLDP.Spec.AdminState).To(Equal(v1alpha1.AdminStateDown)) + }).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()) + }) + + }) + + Context("When reconciling with InterfaceRefs", func() { + const ( + deviceName = "testlldp-intfref-device" + resourceName = "testlldp-intfref-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.3: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 InterfaceRef", func() { + By("Creating LLDP with a non-existent InterfaceRef") + lldp = &v1alpha1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.LLDPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + AdminState: v1alpha1.AdminStateUp, + InterfaceRefs: []v1alpha1.LLDPInterface{{ + LocalObjectReference: v1alpha1.LocalObjectReference{Name: "non-existent-interface"}, + AdminState: v1alpha1.AdminStateUp, + }}, + }, + } + Expect(k8sClient.Create(ctx, lldp)).To(Succeed()) + + By("Verifying the controller sets ConfiguredCondition to False with WaitingForDependenciesReason") + 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.WaitingForDependenciesReason)) + }).Should(Succeed()) + }) + + It("Should handle InterfaceRef belonging to a different device", func() { + const ( + otherDeviceName = "testlldp-other-device" + otherInterfaceName = "testlldp-other-interface" + ) + + By("Creating another Device") + otherDevice := &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: otherDeviceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.99:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, otherDevice)).To(Succeed()) + + By("Creating an Interface on the other Device") + otherInterface := &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + Name: otherInterfaceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: otherDeviceName}, + Name: "Ethernet1/1", + Type: v1alpha1.InterfaceTypePhysical, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, otherInterface)).To(Succeed()) + + By("Creating LLDP referencing an Interface from a different device") + lldp = &v1alpha1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.LLDPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + AdminState: v1alpha1.AdminStateUp, + InterfaceRefs: []v1alpha1.LLDPInterface{{ + LocalObjectReference: v1alpha1.LocalObjectReference{Name: otherInterfaceName}, + AdminState: v1alpha1.AdminStateUp, + }}, + }, + } + Expect(k8sClient.Create(ctx, lldp)).To(Succeed()) + + By("Verifying the controller sets ConfiguredCondition to False with CrossDeviceReferenceReason") + 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.CrossDeviceReferenceReason)) + }).Should(Succeed()) + + By("Cleaning up the other Interface and Device") + Expect(k8sClient.Delete(ctx, otherInterface)).To(Succeed()) + Expect(k8sClient.Delete(ctx, otherDevice, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) + }) + + It("Should successfully reconcile with multiple InterfaceRefs", func() { + const ( + interface1Name = "testlldp-intfref-intf1" + interface2Name = "testlldp-intfref-intf2" + ) + + By("Creating the first Interface") + intf1 := &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + Name: interface1Name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Name: "Ethernet1/1", + Type: v1alpha1.InterfaceTypePhysical, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, intf1)).To(Succeed()) + + By("Creating the second Interface") + intf2 := &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + Name: interface2Name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Name: "Ethernet1/2", + Type: v1alpha1.InterfaceTypePhysical, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, intf2)).To(Succeed()) + + By("Creating LLDP with multiple InterfaceRefs") + lldp = &v1alpha1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.LLDPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + AdminState: v1alpha1.AdminStateUp, + InterfaceRefs: []v1alpha1.LLDPInterface{ + { + LocalObjectReference: v1alpha1.LocalObjectReference{Name: interface1Name}, + AdminState: v1alpha1.AdminStateUp, + }, + { + LocalObjectReference: v1alpha1.LocalObjectReference{Name: interface2Name}, + AdminState: v1alpha1.AdminStateDown, + }, + }, + }, + } + 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)) + }).Should(Succeed()) + + By("Cleaning up the Interface resources") + Expect(k8sClient.Delete(ctx, intf1)).To(Succeed()) + Expect(k8sClient.Delete(ctx, intf2)).To(Succeed()) + }) + }) + + Context("When updating LLDP spec", func() { + const ( + deviceName = "testlldp-update-device" + resourceName = "testlldp-update-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.4: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 AdminState update from Up to Down", func() { + By("Creating LLDP with AdminState Up") + 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 LLDP to be ready") + 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)) + }).Should(Succeed()) + + By("Verifying provider has AdminState Up") + Eventually(func(g Gomega) { + g.Expect(testProvider.LLDP).ToNot(BeNil()) + g.Expect(testProvider.LLDP.Spec.AdminState).To(Equal(v1alpha1.AdminStateUp)) + }).Should(Succeed()) + + By("Updating AdminState to Down") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, lldp) + g.Expect(err).NotTo(HaveOccurred()) + lldp.Spec.AdminState = v1alpha1.AdminStateDown + g.Expect(k8sClient.Update(ctx, lldp)).To(Succeed()) + }).Should(Succeed()) + + By("Verifying provider has AdminState Down") + Eventually(func(g Gomega) { + g.Expect(testProvider.LLDP).ToNot(BeNil()) + g.Expect(testProvider.LLDP.Spec.AdminState).To(Equal(v1alpha1.AdminStateDown)) + }).Should(Succeed()) + }) + + It("Should handle adding InterfaceRefs after creation", func() { + const interfaceName = "testlldp-update-interface" + + By("Creating an Interface") + 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 LLDP without InterfaceRefs") + 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 LLDP to be ready") + 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)) + }).Should(Succeed()) + + By("Adding InterfaceRefs to LLDP") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, lldp) + g.Expect(err).NotTo(HaveOccurred()) + lldp.Spec.InterfaceRefs = []v1alpha1.LLDPInterface{{ + LocalObjectReference: v1alpha1.LocalObjectReference{Name: interfaceName}, + AdminState: v1alpha1.AdminStateUp, + }} + g.Expect(k8sClient.Update(ctx, lldp)).To(Succeed()) + }).Should(Succeed()) + + By("Verifying LLDP remains Ready after adding InterfaceRefs") + 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)) + + g.Expect(lldp.Spec.InterfaceRefs).To(HaveLen(1)) + }).Should(Succeed()) + + By("Cleaning up the Interface resource") + Expect(k8sClient.Delete(ctx, intf)).To(Succeed()) + }) + + It("Should handle removing InterfaceRefs after creation", func() { + const interfaceName = "testlldp-remove-interface" + + By("Creating an Interface") + 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 LLDP with InterfaceRefs") + lldp = &v1alpha1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.LLDPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + AdminState: v1alpha1.AdminStateUp, + InterfaceRefs: []v1alpha1.LLDPInterface{{ + LocalObjectReference: v1alpha1.LocalObjectReference{Name: interfaceName}, + AdminState: v1alpha1.AdminStateUp, + }}, + }, + } + Expect(k8sClient.Create(ctx, lldp)).To(Succeed()) + + By("Waiting for LLDP to be ready") + 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)) + + g.Expect(lldp.Spec.InterfaceRefs).To(HaveLen(1)) + }).Should(Succeed()) + + By("Removing InterfaceRefs from LLDP") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, lldp) + g.Expect(err).NotTo(HaveOccurred()) + lldp.Spec.InterfaceRefs = nil + g.Expect(k8sClient.Update(ctx, lldp)).To(Succeed()) + }).Should(Succeed()) + + By("Verifying LLDP remains Ready after removing InterfaceRefs") + 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)) + + g.Expect(lldp.Spec.InterfaceRefs).To(BeEmpty()) + }).Should(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 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 +} From 67c715f4b62a19806014db091decadd14c7cdec1 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 5 Mar 2026 16:13:43 +0100 Subject: [PATCH 3/4] [NXOS] Add `LLDPConfig` API type Add LLDPConfig CRD for Cisco NX-OS specific LLDP settings that extend the core LLDP configuration. This resource can be referenced via the ProviderConfigRef field in the LLDP spec. The LLDPConfig allows setting InitDelay, the number of seconds to wait before LLDP starts sending packets after an interface comes up, and HoldTime, the duration in seconds that receiving devices should retain LLDP information before discarding it. --- Tiltfile | 2 + api/cisco/nx/v1alpha1/lldpconfig_types.go | 59 +++++++++++++++ .../nx/v1alpha1/zz_generated.deepcopy.go | 73 +++++++++++++++++++ ...x.cisco.networking.metal.ironcore.dev.yaml | 65 +++++++++++++++++ .../templates/rbac/manager-role.yaml | 1 + .../rbac/nx.cisco-lldpconfig-admin-role.yaml | 24 ++++++ .../rbac/nx.cisco-lldpconfig-editor-role.yaml | 30 ++++++++ .../rbac/nx.cisco-lldpconfig-viewer-role.yaml | 26 +++++++ ...orking.metal.ironcore.dev_lldpconfigs.yaml | 61 ++++++++++++++++ config/crd/kustomization.yaml | 1 + .../rbac/cisco/nx/lldpconfig_admin_role.yaml | 27 +++++++ .../rbac/cisco/nx/lldpconfig_editor_role.yaml | 33 +++++++++ .../rbac/cisco/nx/lldpconfig_viewer_role.yaml | 29 ++++++++ config/rbac/kustomization.yaml | 3 + config/rbac/role.yaml | 1 + .../samples/cisco/nx/v1alpha1_lldpconfig.yaml | 10 +++ config/samples/kustomization.yaml | 1 + config/samples/v1alpha1_lldp.yaml | 6 ++ docs/api-reference/index.md | 36 +++++++++ 19 files changed, 488 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 charts/network-operator/templates/rbac/nx.cisco-lldpconfig-admin-role.yaml create mode 100644 charts/network-operator/templates/rbac/nx.cisco-lldpconfig-editor-role.yaml create mode 100644 charts/network-operator/templates/rbac/nx.cisco-lldpconfig-viewer-role.yaml create mode 100644 config/crd/bases/nx.cisco.networking.metal.ironcore.dev_lldpconfigs.yaml create mode 100644 config/rbac/cisco/nx/lldpconfig_admin_role.yaml create mode 100644 config/rbac/cisco/nx/lldpconfig_editor_role.yaml create mode 100644 config/rbac/cisco/nx/lldpconfig_viewer_role.yaml create mode 100644 config/samples/cisco/nx/v1alpha1_lldpconfig.yaml diff --git a/Tiltfile b/Tiltfile index 8a7b8e88..2b88b139 100644 --- a/Tiltfile +++ b/Tiltfile @@ -120,6 +120,8 @@ 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..f2e5173b --- /dev/null +++ b/api/cisco/nx/v1alpha1/lldpconfig_types.go @@ -0,0 +1,59 @@ +// 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"` +} + +// +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..ea2a78b7 100644 --- a/api/cisco/nx/v1alpha1/zz_generated.deepcopy.go +++ b/api/cisco/nx/v1alpha1/zz_generated.deepcopy.go @@ -323,6 +323,79 @@ 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) + out.Spec = in.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 +} + +// 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 *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..46a0e555 --- /dev/null +++ b/charts/network-operator/templates/crd/lldpconfigs.nx.cisco.networking.metal.ironcore.dev.yaml @@ -0,0 +1,65 @@ +{{- 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 + 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/charts/network-operator/templates/rbac/nx.cisco-lldpconfig-admin-role.yaml b/charts/network-operator/templates/rbac/nx.cisco-lldpconfig-admin-role.yaml new file mode 100644 index 00000000..4db33e94 --- /dev/null +++ b/charts/network-operator/templates/rbac/nx.cisco-lldpconfig-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" "nx" "context" $) }}.cisco-lldpconfig-admin-role +rules: +- apiGroups: + - nx.cisco.networking.metal.ironcore.dev + resources: + - lldpconfigs + verbs: + - '*' +- apiGroups: + - nx.cisco.networking.metal.ironcore.dev + resources: + - lldpconfigs/status + verbs: + - get +{{- end }} diff --git a/charts/network-operator/templates/rbac/nx.cisco-lldpconfig-editor-role.yaml b/charts/network-operator/templates/rbac/nx.cisco-lldpconfig-editor-role.yaml new file mode 100644 index 00000000..79f36e0c --- /dev/null +++ b/charts/network-operator/templates/rbac/nx.cisco-lldpconfig-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" "nx" "context" $) }}.cisco-lldpconfig-editor-role +rules: +- apiGroups: + - nx.cisco.networking.metal.ironcore.dev + resources: + - lldpconfigs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - nx.cisco.networking.metal.ironcore.dev + resources: + - lldpconfigs/status + verbs: + - get +{{- end }} diff --git a/charts/network-operator/templates/rbac/nx.cisco-lldpconfig-viewer-role.yaml b/charts/network-operator/templates/rbac/nx.cisco-lldpconfig-viewer-role.yaml new file mode 100644 index 00000000..6bf28047 --- /dev/null +++ b/charts/network-operator/templates/rbac/nx.cisco-lldpconfig-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" "nx" "context" $) }}.cisco-lldpconfig-viewer-role +rules: +- apiGroups: + - nx.cisco.networking.metal.ironcore.dev + resources: + - lldpconfigs + verbs: + - get + - list + - watch +- apiGroups: + - nx.cisco.networking.metal.ironcore.dev + resources: + - lldpconfigs/status + verbs: + - get +{{- end }} 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..8f5523fb --- /dev/null +++ b/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_lldpconfigs.yaml @@ -0,0 +1,61 @@ +--- +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 + 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/cisco/nx/lldpconfig_admin_role.yaml b/config/rbac/cisco/nx/lldpconfig_admin_role.yaml new file mode 100644 index 00000000..ff46cb79 --- /dev/null +++ b/config/rbac/cisco/nx/lldpconfig_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 nx.cisco.networking.metal.ironcore.dev. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: nx.cisco-lldpconfig-admin-role +rules: +- apiGroups: + - nx.cisco.networking.metal.ironcore.dev + resources: + - lldpconfigs + verbs: + - '*' +- apiGroups: + - nx.cisco.networking.metal.ironcore.dev + resources: + - lldpconfigs/status + verbs: + - get diff --git a/config/rbac/cisco/nx/lldpconfig_editor_role.yaml b/config/rbac/cisco/nx/lldpconfig_editor_role.yaml new file mode 100644 index 00000000..8c84e1f0 --- /dev/null +++ b/config/rbac/cisco/nx/lldpconfig_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 nx.cisco.networking.metal.ironcore.dev. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: nx.cisco-lldpconfig-editor-role +rules: +- apiGroups: + - nx.cisco.networking.metal.ironcore.dev + resources: + - lldpconfigs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - nx.cisco.networking.metal.ironcore.dev + resources: + - lldpconfigs/status + verbs: + - get diff --git a/config/rbac/cisco/nx/lldpconfig_viewer_role.yaml b/config/rbac/cisco/nx/lldpconfig_viewer_role.yaml new file mode 100644 index 00000000..cb32b2b7 --- /dev/null +++ b/config/rbac/cisco/nx/lldpconfig_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 nx.cisco.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: nx.cisco-lldpconfig-viewer-role +rules: +- apiGroups: + - nx.cisco.networking.metal.ironcore.dev + resources: + - lldpconfigs + verbs: + - get + - list + - watch +- apiGroups: + - nx.cisco.networking.metal.ironcore.dev + resources: + - lldpconfigs/status + verbs: + - get diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 61a11ce6..ef25b96c 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -110,3 +110,6 @@ resources: - cisco/nx/interfaceconfig_admin_role.yaml - cisco/nx/interfaceconfig_editor_role.yaml - cisco/nx/interfaceconfig_viewer_role.yaml +- cisco/nx/lldpconfig_admin_role.yaml +- cisco/nx/lldpconfig_editor_role.yaml +- cisco/nx/lldpconfig_viewer_role.yaml 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..010a6272 --- /dev/null +++ b/config/samples/cisco/nx/v1alpha1_lldpconfig.yaml @@ -0,0 +1,10 @@ +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 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/config/samples/v1alpha1_lldp.yaml b/config/samples/v1alpha1_lldp.yaml index 7246675e..797feab6 100644 --- a/config/samples/v1alpha1_lldp.yaml +++ b/config/samples/v1alpha1_lldp.yaml @@ -14,3 +14,9 @@ spec: - name: eth1-1 - name: eth1-2 adminState: Down + # 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 e6f3af55..29bc2bb8 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -3046,6 +3046,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) @@ -3305,6 +3306,41 @@ _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: \{\}
| + + #### ManagementAccessConfig From 695c51f5e8adb28c86e39786b06189c2990ca525 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 5 Mar 2026 16:13:54 +0100 Subject: [PATCH 4/4] [NXOS] Add `LLDP` provider implementation Enable or disable the LLDP feature based on the AdminState in the spec. When enabled, configure per-interface Tx/Rx settings based on the InterfaceRefs. The core API does not distinguish between Tx and Rx, so both are set to the same value. If a LLDPConfig is referenced via ProviderConfigRef, apply the InitDelay and HoldTime settings. On deletion, disable the LLDP feature on the device. --- internal/provider/cisco/nxos/lldp.go | 45 +++++++++ internal/provider/cisco/nxos/lldp_test.go | 24 +++++ internal/provider/cisco/nxos/provider.go | 95 +++++++++++++++++++ .../provider/cisco/nxos/testdata/lldp.json | 22 +++++ .../cisco/nxos/testdata/lldp.json.txt | 7 ++ 5 files changed, 193 insertions(+) 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/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 bd691120..a51266ea 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" @@ -56,6 +57,7 @@ var ( _ provider.VLANProvider = (*Provider)(nil) _ provider.VRFProvider = (*Provider)(nil) _ provider.NVEProvider = (*Provider)(nil) + _ provider.LLDPProvider = (*Provider)(nil) ) type Provider struct { @@ -2657,6 +2659,99 @@ 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 + } + + // if LLDP is disabled, skip the rest of the configuration since device will reject further LLDP-related configuration + if f1.AdminSt == AdminStDisabled { + return nil + } + + // return error if interfaces are referenced but not provided in the request + if len(req.Interfaces) != len(req.LLDP.Spec.InterfaceRefs) { + return errors.New("lldp: number of interfaces in the request does not match the number of interface references in LLDP .spec") + } + + l := new(LLDP) + + interfaceMap := make(map[string]*v1alpha1.Interface, len(req.Interfaces)) + for _, intf := range req.Interfaces { + interfaceMap[intf.Name] = intf + } + + for _, ifRef := range req.LLDP.Spec.InterfaceRefs { + intf, ok := interfaceMap[ifRef.Name] + if !ok { + available := slices.Sorted(maps.Keys(interfaceMap)) + return fmt.Errorf("lldp: interface %q not found in request (available interfaces: %v)", ifRef.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) + item.AdminTxSt = NewOption(AdminStEnabled) + + // Set admin state based on the interface-level admin state from LLDP spec + if ifRef.AdminState == v1alpha1.AdminStateDown { + item.AdminRxSt = NewOption(AdminStDisabled) + item.AdminTxSt = NewOption(AdminStDisabled) + } + + l.IfItems.IfList.Set(item) + } + + if req.ProviderConfig == nil { + return p.Patch(ctx, l) + } + + c := new(nxv1alpha1.LLDPConfig) + 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 + + 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