diff --git a/PROJECT b/PROJECT
index 77765743..4e0dd1f5 100644
--- a/PROJECT
+++ b/PROJECT
@@ -262,4 +262,12 @@ resources:
kind: InterfaceConfig
path: github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1
version: v1alpha1
+- api:
+ crdVersion: v1
+ namespaced: true
+ controller: true
+ domain: networking.metal.ironcore.dev
+ kind: LLDP
+ path: github.com/ironcore-dev/network-operator/api/core/v1alpha1
+ version: v1alpha1
version: "3"
diff --git a/Tiltfile b/Tiltfile
index da365942..2a1be775 100644
--- a/Tiltfile
+++ b/Tiltfile
@@ -117,6 +117,12 @@ 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
+# 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')
print('👉 Tilt will automatically rebuild and redeploy when changes are detected')
diff --git a/api/cisco/nx/v1alpha1/lldpconfig_types.go b/api/cisco/nx/v1alpha1/lldpconfig_types.go
new file mode 100644
index 00000000..5ed93354
--- /dev/null
+++ b/api/cisco/nx/v1alpha1/lldpconfig_types.go
@@ -0,0 +1,78 @@
+// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package v1alpha1
+
+import (
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+
+ v1alpha1 "github.com/ironcore-dev/network-operator/api/core/v1alpha1"
+)
+
+// +kubebuilder:rbac:groups=nx.cisco.networking.metal.ironcore.dev,resources=lldpconfigs,verbs=get;list;watch
+
+// LLDPConfig defines the Cisco-specific configuration of an LLDP object.
+type LLDPConfigSpec struct {
+ // InitDelay defines the delay in seconds before LLDP starts sending packets after interface comes up.
+ // +optional
+ // +kubebuilder:validation:Minimum=1
+ // +kubebuilder:validation:Maximum=10
+ // +kubebuilder:default=2
+ InitDelay int16 `json:"initDelay,omitempty"`
+
+ // HoldTime defines the time in seconds that the receiving device should hold the LLDP information before discarding it.
+ // +optional
+ // +kubebuilder:validation:Minimum=1
+ // +kubebuilder:validation:Maximum=255
+ // +kubebuilder:default=120
+ HoldTime int16 `json:"holdTime,omitempty"`
+
+ // InterfaceRefs is a list of interfaces and their LLDP configuration.
+ // +optional
+ // +listType=atomic
+ InterfaceRefs []LLDPInterface `json:"interfaceRefs,omitempty"`
+}
+
+type LLDPInterface struct {
+ v1alpha1.LocalObjectReference `json:",inline"`
+
+ // AdminRxState defines the administrative state for receiving LLDP PDUs on the interface.
+ // +optional
+ // +kubebuilder:default=Up
+ AdminRxState v1alpha1.AdminState `json:"adminRxState,omitempty"`
+
+ // AdminTxState defines the administrative state for transmitting LLDP PDUs on the interface.
+ // +optional
+ // +kubebuilder:default=Up
+ AdminTxState v1alpha1.AdminState `json:"adminTxState,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:resource:path=lldpconfigs
+// +kubebuilder:resource:singular=lldpconfig
+
+// LLDPConfig is the Schema for the LLDPConfig API
+type LLDPConfig struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty,omitzero"`
+
+ // spec defines the desired state of LLDP
+ // +required
+ Spec LLDPConfigSpec `json:"spec"`
+}
+
+// +kubebuilder:object:root=true
+
+// LLDPConfigList contains a list of LLDPConfigs
+type LLDPConfigList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []LLDPConfig `json:"items"`
+}
+
+// init registers the LLDPConfig type with the scheme and sets
+// itself as a dependency for the LLDP core type.
+func init() {
+ v1alpha1.RegisterLLDPDependency(GroupVersion.WithKind("LLDPConfig"))
+ SchemeBuilder.Register(&LLDPConfig{}, &LLDPConfigList{})
+}
diff --git a/api/cisco/nx/v1alpha1/zz_generated.deepcopy.go b/api/cisco/nx/v1alpha1/zz_generated.deepcopy.go
index e293098e..f5a013ca 100644
--- a/api/cisco/nx/v1alpha1/zz_generated.deepcopy.go
+++ b/api/cisco/nx/v1alpha1/zz_generated.deepcopy.go
@@ -323,6 +323,100 @@ func (in *KeepAlive) DeepCopy() *KeepAlive {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *LLDPConfig) DeepCopyInto(out *LLDPConfig) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ in.Spec.DeepCopyInto(&out.Spec)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLDPConfig.
+func (in *LLDPConfig) DeepCopy() *LLDPConfig {
+ if in == nil {
+ return nil
+ }
+ out := new(LLDPConfig)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *LLDPConfig) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *LLDPConfigList) DeepCopyInto(out *LLDPConfigList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]LLDPConfig, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLDPConfigList.
+func (in *LLDPConfigList) DeepCopy() *LLDPConfigList {
+ if in == nil {
+ return nil
+ }
+ out := new(LLDPConfigList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *LLDPConfigList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *LLDPConfigSpec) DeepCopyInto(out *LLDPConfigSpec) {
+ *out = *in
+ if in.InterfaceRefs != nil {
+ in, out := &in.InterfaceRefs, &out.InterfaceRefs
+ *out = make([]LLDPInterface, len(*in))
+ copy(*out, *in)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLDPConfigSpec.
+func (in *LLDPConfigSpec) DeepCopy() *LLDPConfigSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(LLDPConfigSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *LLDPInterface) DeepCopyInto(out *LLDPInterface) {
+ *out = *in
+ out.LocalObjectReference = in.LocalObjectReference
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLDPInterface.
+func (in *LLDPInterface) DeepCopy() *LLDPInterface {
+ if in == nil {
+ return nil
+ }
+ out := new(LLDPInterface)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ManagementAccessConfig) DeepCopyInto(out *ManagementAccessConfig) {
*out = *in
diff --git a/api/core/v1alpha1/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go
index 80370e38..9a86d27b 100644
--- a/api/core/v1alpha1/groupversion_info.go
+++ b/api/core/v1alpha1/groupversion_info.go
@@ -158,6 +158,9 @@ const (
// IncompatibleProviderConfigRef indicates that the referenced provider configuration is not compatible with the target platform.
IncompatibleProviderConfigRef = "IncompatibleProviderConfigRef"
+
+ // DuplicateResourceOnDevice indicates that a resource of the same type as the one being created already exists on the target device.
+ DuplicateResourceOnDevice = "DuplicateResourceOnDevice"
)
// Reasons that are specific to [Interface] objects.
diff --git a/api/core/v1alpha1/lldp_types.go b/api/core/v1alpha1/lldp_types.go
new file mode 100644
index 00000000..a723cfa4
--- /dev/null
+++ b/api/core/v1alpha1/lldp_types.go
@@ -0,0 +1,108 @@
+// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package v1alpha1
+
+import (
+ "sync"
+
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+// LLDPSpec defines the desired state of LLDP
+type LLDPSpec struct {
+ // DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace.
+ // Immutable.
+ // +required
+ // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="DeviceRef is immutable"
+ DeviceRef LocalObjectReference `json:"deviceRef"`
+
+ // ProviderConfigRef is a reference to a resource holding the provider-specific configuration for this LLDP.
+ // If not specified the provider applies the target platform's default settings.
+ // +optional
+ ProviderConfigRef *TypedLocalObjectReference `json:"providerConfigRef,omitempty"`
+
+ // AdminState indicates whether LLDP is system-wide administratively up or down.
+ // +required
+ AdminState AdminState `json:"adminState"`
+}
+
+// LLDPStatus defines the observed state of LLDP.
+type LLDPStatus struct {
+ // For Kubernetes API conventions, see:
+ // https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties
+
+ // conditions represent the current state of the LLDP resource.
+ // Each condition has a unique type and reflects the status of a specific aspect of the resource.
+ //
+ // Standard condition types include:
+ // - "Available": the resource is fully functional
+ // - "Progressing": the resource is being created or updated
+ // - "Degraded": the resource failed to reach or maintain its desired state
+ //
+ // The status of each condition is one of True, False, or Unknown.
+ // +listType=map
+ // +listMapKey=type
+ // +optional
+ Conditions []metav1.Condition `json:"conditions,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:subresource:status
+// +kubebuilder:resource:path=lldps
+// +kubebuilder:resource:singular=lldp
+// +kubebuilder:printcolumn:name="Device",type=string,JSONPath=`.spec.deviceRef.name`
+// +kubebuilder:printcolumn:name="Admin State",type=string,JSONPath=`.spec.adminState`
+// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
+// +kubebuilder:printcolumn:name="Configured",type=string,JSONPath=`.status.conditions[?(@.type=="Configured")].status`,priority=1
+// +kubebuilder:printcolumn:name="Operational",type=string,JSONPath=`.status.conditions[?(@.type=="Operational")].status`,priority=1
+// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
+
+// LLDP is the Schema for the lldps API
+type LLDP struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitzero"`
+
+ // +required
+ Spec LLDPSpec `json:"spec"`
+
+ // +optional
+ Status LLDPStatus `json:"status,omitzero"`
+}
+
+// GetConditions implements conditions.Getter.
+func (l *LLDP) GetConditions() []metav1.Condition {
+ return l.Status.Conditions
+}
+
+// SetConditions implements conditions.Setter.
+func (l *LLDP) SetConditions(conditions []metav1.Condition) {
+ l.Status.Conditions = conditions
+}
+
+// +kubebuilder:object:root=true
+
+// LLDPList contains a list of LLDP
+type LLDPList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitzero"`
+ Items []LLDP `json:"items"`
+}
+
+var (
+ LLDPDependencies []schema.GroupVersionKind
+ lldpDependenciesMu sync.Mutex
+)
+
+// RegisterLLDPDependency registers a provider-specific GVK as a dependency of LLDP.
+// ProviderConfigs should call this in their init() function to ensure the dependency is registered.
+func RegisterLLDPDependency(gvk schema.GroupVersionKind) {
+ lldpDependenciesMu.Lock()
+ defer lldpDependenciesMu.Unlock()
+ LLDPDependencies = append(LLDPDependencies, gvk)
+}
+
+func init() {
+ SchemeBuilder.Register(&LLDP{}, &LLDPList{})
+}
diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go
index b9a0a1d3..10e4d76e 100644
--- a/api/core/v1alpha1/zz_generated.deepcopy.go
+++ b/api/core/v1alpha1/zz_generated.deepcopy.go
@@ -1713,6 +1713,108 @@ func (in *InterfaceStatus) DeepCopy() *InterfaceStatus {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *LLDP) DeepCopyInto(out *LLDP) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ in.Spec.DeepCopyInto(&out.Spec)
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLDP.
+func (in *LLDP) DeepCopy() *LLDP {
+ if in == nil {
+ return nil
+ }
+ out := new(LLDP)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *LLDP) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *LLDPList) DeepCopyInto(out *LLDPList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]LLDP, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLDPList.
+func (in *LLDPList) DeepCopy() *LLDPList {
+ if in == nil {
+ return nil
+ }
+ out := new(LLDPList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *LLDPList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *LLDPSpec) DeepCopyInto(out *LLDPSpec) {
+ *out = *in
+ out.DeviceRef = in.DeviceRef
+ if in.ProviderConfigRef != nil {
+ in, out := &in.ProviderConfigRef, &out.ProviderConfigRef
+ *out = new(TypedLocalObjectReference)
+ **out = **in
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLDPSpec.
+func (in *LLDPSpec) DeepCopy() *LLDPSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(LLDPSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *LLDPStatus) DeepCopyInto(out *LLDPStatus) {
+ *out = *in
+ if in.Conditions != nil {
+ in, out := &in.Conditions, &out.Conditions
+ *out = make([]v1.Condition, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LLDPStatus.
+func (in *LLDPStatus) DeepCopy() *LLDPStatus {
+ if in == nil {
+ return nil
+ }
+ out := new(LLDPStatus)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *LocalObjectReference) DeepCopyInto(out *LocalObjectReference) {
*out = *in
diff --git a/charts/network-operator/templates/crd/lldpconfigs.nx.cisco.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/lldpconfigs.nx.cisco.networking.metal.ironcore.dev.yaml
new file mode 100644
index 00000000..6bbc0f3c
--- /dev/null
+++ b/charts/network-operator/templates/crd/lldpconfigs.nx.cisco.networking.metal.ironcore.dev.yaml
@@ -0,0 +1,99 @@
+{{- if .Values.crd.enable }}
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ {{- if .Values.crd.keep }}
+ "helm.sh/resource-policy": keep
+ {{- end }}
+ controller-gen.kubebuilder.io/version: v0.20.1
+ name: lldpconfigs.nx.cisco.networking.metal.ironcore.dev
+spec:
+ group: nx.cisco.networking.metal.ironcore.dev
+ names:
+ kind: LLDPConfig
+ listKind: LLDPConfigList
+ plural: lldpconfigs
+ singular: lldpconfig
+ scope: Namespaced
+ versions:
+ - name: v1alpha1
+ schema:
+ openAPIV3Schema:
+ description: LLDPConfig is the Schema for the LLDPConfig API
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: spec defines the desired state of LLDP
+ properties:
+ holdTime:
+ default: 120
+ description: HoldTime defines the time in seconds that the receiving
+ device should hold the LLDP information before discarding it.
+ maximum: 255
+ minimum: 1
+ type: integer
+ initDelay:
+ default: 2
+ description: InitDelay defines the delay in seconds before LLDP starts
+ sending packets after interface comes up.
+ maximum: 10
+ minimum: 1
+ type: integer
+ interfaceRefs:
+ description: InterfaceRefs is a list of interfaces and their LLDP
+ configuration.
+ items:
+ properties:
+ adminRxState:
+ default: Up
+ description: AdminRxState defines the administrative state for
+ receiving LLDP PDUs on the interface.
+ enum:
+ - Up
+ - Down
+ type: string
+ adminTxState:
+ default: Up
+ description: AdminTxState defines the administrative state for
+ transmitting LLDP PDUs on the interface.
+ enum:
+ - Up
+ - Down
+ type: string
+ name:
+ description: |-
+ Name of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ type: array
+ x-kubernetes-list-type: atomic
+ type: object
+ required:
+ - spec
+ type: object
+ served: true
+ storage: true
+{{- end }}
diff --git a/charts/network-operator/templates/crd/lldps.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/lldps.networking.metal.ironcore.dev.yaml
new file mode 100644
index 00000000..8112aa77
--- /dev/null
+++ b/charts/network-operator/templates/crd/lldps.networking.metal.ironcore.dev.yaml
@@ -0,0 +1,211 @@
+{{- if .Values.crd.enable }}
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ {{- if .Values.crd.keep }}
+ "helm.sh/resource-policy": keep
+ {{- end }}
+ controller-gen.kubebuilder.io/version: v0.20.1
+ name: lldps.networking.metal.ironcore.dev
+spec:
+ group: networking.metal.ironcore.dev
+ names:
+ kind: LLDP
+ listKind: LLDPList
+ plural: lldps
+ singular: lldp
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - jsonPath: .spec.deviceRef.name
+ name: Device
+ type: string
+ - jsonPath: .spec.adminState
+ name: Admin State
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Ready")].status
+ name: Ready
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Configured")].status
+ name: Configured
+ priority: 1
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Operational")].status
+ name: Operational
+ priority: 1
+ type: string
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1alpha1
+ schema:
+ openAPIV3Schema:
+ description: LLDP is the Schema for the lldps API
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: LLDPSpec defines the desired state of LLDP
+ properties:
+ adminState:
+ description: AdminState indicates whether LLDP is system-wide administratively
+ up or down.
+ enum:
+ - Up
+ - Down
+ type: string
+ deviceRef:
+ description: |-
+ DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace.
+ Immutable.
+ properties:
+ name:
+ description: |-
+ Name of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ x-kubernetes-validations:
+ - message: DeviceRef is immutable
+ rule: self == oldSelf
+ providerConfigRef:
+ description: |-
+ ProviderConfigRef is a reference to a resource holding the provider-specific configuration for this LLDP.
+ If not specified the provider applies the target platform's default settings.
+ properties:
+ apiVersion:
+ description: APIVersion is the api group version of the resource
+ being referenced.
+ maxLength: 253
+ minLength: 1
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/)?([a-z0-9]([-a-z0-9]*[a-z0-9])?)$
+ type: string
+ kind:
+ description: |-
+ Kind of the resource being referenced.
+ Kind must consist of alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$
+ type: string
+ name:
+ description: |-
+ Name of the resource being referenced.
+ Name must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character.
+ maxLength: 253
+ minLength: 1
+ pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+ type: string
+ required:
+ - apiVersion
+ - kind
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ required:
+ - adminState
+ - deviceRef
+ type: object
+ status:
+ description: LLDPStatus defines the observed state of LLDP.
+ properties:
+ conditions:
+ description: |-
+ conditions represent the current state of the LLDP resource.
+ Each condition has a unique type and reflects the status of a specific aspect of the resource.
+
+ Standard condition types include:
+ - "Available": the resource is fully functional
+ - "Progressing": the resource is being created or updated
+ - "Degraded": the resource failed to reach or maintain its desired state
+
+ The status of each condition is one of True, False, or Unknown.
+ items:
+ description: Condition contains details for one aspect of the current
+ state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ type: object
+ required:
+ - metadata
+ - spec
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
+{{- end }}
diff --git a/charts/network-operator/templates/rbac/lldp-admin-role.yaml b/charts/network-operator/templates/rbac/lldp-admin-role.yaml
new file mode 100644
index 00000000..2775a5ff
--- /dev/null
+++ b/charts/network-operator/templates/rbac/lldp-admin-role.yaml
@@ -0,0 +1,24 @@
+{{- if .Values.rbacHelpers.enable }}
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
+ app.kubernetes.io/name: {{ include "network-operator.name" . }}
+ helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
+ name: {{ include "network-operator.resourceName" (dict "suffix" "lldp-admin-role" "context" $) }}
+rules:
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - lldp
+ verbs:
+ - '*'
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - lldp/status
+ verbs:
+ - get
+{{- end }}
diff --git a/charts/network-operator/templates/rbac/lldp-editor-role.yaml b/charts/network-operator/templates/rbac/lldp-editor-role.yaml
new file mode 100644
index 00000000..6b228291
--- /dev/null
+++ b/charts/network-operator/templates/rbac/lldp-editor-role.yaml
@@ -0,0 +1,30 @@
+{{- if .Values.rbacHelpers.enable }}
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
+ app.kubernetes.io/name: {{ include "network-operator.name" . }}
+ helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
+ name: {{ include "network-operator.resourceName" (dict "suffix" "lldp-editor-role" "context" $) }}
+rules:
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - lldp
+ verbs:
+ - create
+ - delete
+ - get
+ - list
+ - patch
+ - update
+ - watch
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - lldp/status
+ verbs:
+ - get
+{{- end }}
diff --git a/charts/network-operator/templates/rbac/lldp-viewer-role.yaml b/charts/network-operator/templates/rbac/lldp-viewer-role.yaml
new file mode 100644
index 00000000..8db4af74
--- /dev/null
+++ b/charts/network-operator/templates/rbac/lldp-viewer-role.yaml
@@ -0,0 +1,26 @@
+{{- if .Values.rbacHelpers.enable }}
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ labels:
+ app.kubernetes.io/managed-by: {{ .Release.Service }}
+ app.kubernetes.io/name: {{ include "network-operator.name" . }}
+ helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
+ app.kubernetes.io/instance: {{ .Release.Name }}
+ name: {{ include "network-operator.resourceName" (dict "suffix" "lldp-viewer-role" "context" $) }}
+rules:
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - lldp
+ verbs:
+ - get
+ - list
+ - watch
+- apiGroups:
+ - networking.metal.ironcore.dev
+ resources:
+ - lldp/status
+ verbs:
+ - get
+{{- end }}
diff --git a/charts/network-operator/templates/rbac/manager-role.yaml b/charts/network-operator/templates/rbac/manager-role.yaml
index 2b198167..220eb38f 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
@@ -163,6 +166,7 @@ rules:
- nx.cisco.networking.metal.ironcore.dev
resources:
- interfaceconfigs
+ - lldpconfigs
- managementaccessconfigs
- networkvirtualizationedgeconfigs
verbs:
diff --git a/cmd/main.go b/cmd/main.go
index cf9cd405..00f8c0f9 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -40,6 +40,9 @@ import (
_ "github.com/ironcore-dev/network-operator/internal/provider/cisco/nxos"
_ "github.com/ironcore-dev/network-operator/internal/provider/openconfig"
+ // Import provider-specific config validators.
+ _ "github.com/ironcore-dev/network-operator/internal/providerconfig/cisco/nx"
+
nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1"
"github.com/ironcore-dev/network-operator/api/core/v1alpha1"
nxcontroller "github.com/ironcore-dev/network-operator/internal/controller/cisco/nx"
@@ -457,6 +460,19 @@ func main() {
os.Exit(1)
}
+ if err := (&corecontroller.LLDPReconciler{
+ Client: mgr.GetClient(),
+ Scheme: mgr.GetScheme(),
+ Recorder: mgr.GetEventRecorderFor("lldp-controller"),
+ WatchFilterValue: watchFilterValue,
+ Provider: prov,
+ Locker: locker,
+ RequeueInterval: requeueInterval,
+ }).SetupWithManager(ctx, mgr); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "LLDP")
+ os.Exit(1)
+ }
+
if err := (&corecontroller.OSPFReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
diff --git a/config/crd/bases/networking.metal.ironcore.dev_lldps.yaml b/config/crd/bases/networking.metal.ironcore.dev_lldps.yaml
new file mode 100644
index 00000000..9dbb6c0d
--- /dev/null
+++ b/config/crd/bases/networking.metal.ironcore.dev_lldps.yaml
@@ -0,0 +1,207 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.20.1
+ name: lldps.networking.metal.ironcore.dev
+spec:
+ group: networking.metal.ironcore.dev
+ names:
+ kind: LLDP
+ listKind: LLDPList
+ plural: lldps
+ singular: lldp
+ scope: Namespaced
+ versions:
+ - additionalPrinterColumns:
+ - jsonPath: .spec.deviceRef.name
+ name: Device
+ type: string
+ - jsonPath: .spec.adminState
+ name: Admin State
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Ready")].status
+ name: Ready
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Configured")].status
+ name: Configured
+ priority: 1
+ type: string
+ - jsonPath: .status.conditions[?(@.type=="Operational")].status
+ name: Operational
+ priority: 1
+ type: string
+ - jsonPath: .metadata.creationTimestamp
+ name: Age
+ type: date
+ name: v1alpha1
+ schema:
+ openAPIV3Schema:
+ description: LLDP is the Schema for the lldps API
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: LLDPSpec defines the desired state of LLDP
+ properties:
+ adminState:
+ description: AdminState indicates whether LLDP is system-wide administratively
+ up or down.
+ enum:
+ - Up
+ - Down
+ type: string
+ deviceRef:
+ description: |-
+ DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace.
+ Immutable.
+ properties:
+ name:
+ description: |-
+ Name of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ x-kubernetes-validations:
+ - message: DeviceRef is immutable
+ rule: self == oldSelf
+ providerConfigRef:
+ description: |-
+ ProviderConfigRef is a reference to a resource holding the provider-specific configuration for this LLDP.
+ If not specified the provider applies the target platform's default settings.
+ properties:
+ apiVersion:
+ description: APIVersion is the api group version of the resource
+ being referenced.
+ maxLength: 253
+ minLength: 1
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/)?([a-z0-9]([-a-z0-9]*[a-z0-9])?)$
+ type: string
+ kind:
+ description: |-
+ Kind of the resource being referenced.
+ Kind must consist of alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character.
+ maxLength: 63
+ minLength: 1
+ pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$
+ type: string
+ name:
+ description: |-
+ Name of the resource being referenced.
+ Name must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character.
+ maxLength: 253
+ minLength: 1
+ pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$
+ type: string
+ required:
+ - apiVersion
+ - kind
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ required:
+ - adminState
+ - deviceRef
+ type: object
+ status:
+ description: LLDPStatus defines the observed state of LLDP.
+ properties:
+ conditions:
+ description: |-
+ conditions represent the current state of the LLDP resource.
+ Each condition has a unique type and reflects the status of a specific aspect of the resource.
+
+ Standard condition types include:
+ - "Available": the resource is fully functional
+ - "Progressing": the resource is being created or updated
+ - "Degraded": the resource failed to reach or maintain its desired state
+
+ The status of each condition is one of True, False, or Unknown.
+ items:
+ description: Condition contains details for one aspect of the current
+ state of this API Resource.
+ properties:
+ lastTransitionTime:
+ description: |-
+ lastTransitionTime is the last time the condition transitioned from one status to another.
+ This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
+ format: date-time
+ type: string
+ message:
+ description: |-
+ message is a human readable message indicating details about the transition.
+ This may be an empty string.
+ maxLength: 32768
+ type: string
+ observedGeneration:
+ description: |-
+ observedGeneration represents the .metadata.generation that the condition was set based upon.
+ For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
+ with respect to the current state of the instance.
+ format: int64
+ minimum: 0
+ type: integer
+ reason:
+ description: |-
+ reason contains a programmatic identifier indicating the reason for the condition's last transition.
+ Producers of specific condition types may define expected values and meanings for this field,
+ and whether the values are considered a guaranteed API.
+ The value should be a CamelCase string.
+ This field may not be empty.
+ maxLength: 1024
+ minLength: 1
+ pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+ type: string
+ status:
+ description: status of the condition, one of True, False, Unknown.
+ enum:
+ - "True"
+ - "False"
+ - Unknown
+ type: string
+ type:
+ description: type of condition in CamelCase or in foo.example.com/CamelCase.
+ maxLength: 316
+ pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+ type: string
+ required:
+ - lastTransitionTime
+ - message
+ - reason
+ - status
+ - type
+ type: object
+ type: array
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ type: object
+ required:
+ - metadata
+ - spec
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_lldpconfigs.yaml b/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_lldpconfigs.yaml
new file mode 100644
index 00000000..af5bf632
--- /dev/null
+++ b/config/crd/bases/nx.cisco.networking.metal.ironcore.dev_lldpconfigs.yaml
@@ -0,0 +1,95 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.20.1
+ name: lldpconfigs.nx.cisco.networking.metal.ironcore.dev
+spec:
+ group: nx.cisco.networking.metal.ironcore.dev
+ names:
+ kind: LLDPConfig
+ listKind: LLDPConfigList
+ plural: lldpconfigs
+ singular: lldpconfig
+ scope: Namespaced
+ versions:
+ - name: v1alpha1
+ schema:
+ openAPIV3Schema:
+ description: LLDPConfig is the Schema for the LLDPConfig API
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: spec defines the desired state of LLDP
+ properties:
+ holdTime:
+ default: 120
+ description: HoldTime defines the time in seconds that the receiving
+ device should hold the LLDP information before discarding it.
+ maximum: 255
+ minimum: 1
+ type: integer
+ initDelay:
+ default: 2
+ description: InitDelay defines the delay in seconds before LLDP starts
+ sending packets after interface comes up.
+ maximum: 10
+ minimum: 1
+ type: integer
+ interfaceRefs:
+ description: InterfaceRefs is a list of interfaces and their LLDP
+ configuration.
+ items:
+ properties:
+ adminRxState:
+ default: Up
+ description: AdminRxState defines the administrative state for
+ receiving LLDP PDUs on the interface.
+ enum:
+ - Up
+ - Down
+ type: string
+ adminTxState:
+ default: Up
+ description: AdminTxState defines the administrative state for
+ transmitting LLDP PDUs on the interface.
+ enum:
+ - Up
+ - Down
+ type: string
+ name:
+ description: |-
+ Name of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ maxLength: 63
+ minLength: 1
+ type: string
+ required:
+ - name
+ type: object
+ x-kubernetes-map-type: atomic
+ type: array
+ x-kubernetes-list-type: atomic
+ type: object
+ required:
+ - spec
+ type: object
+ served: true
+ storage: true
diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml
index e51962a6..808e4b4d 100644
--- a/config/crd/kustomization.yaml
+++ b/config/crd/kustomization.yaml
@@ -24,12 +24,14 @@ 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
- 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/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..5bf736dd 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
@@ -164,6 +167,7 @@ rules:
- nx.cisco.networking.metal.ironcore.dev
resources:
- interfaceconfigs
+ - lldpconfigs
- managementaccessconfigs
- networkvirtualizationedgeconfigs
verbs:
diff --git a/config/samples/cisco/nx/v1alpha1_lldpconfig.yaml b/config/samples/cisco/nx/v1alpha1_lldpconfig.yaml
new file mode 100644
index 00000000..528147fe
--- /dev/null
+++ b/config/samples/cisco/nx/v1alpha1_lldpconfig.yaml
@@ -0,0 +1,16 @@
+apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1
+kind: LLDPConfig
+metadata:
+ labels:
+ app.kubernetes.io/name: network-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: leaf1-lldpconfig
+spec:
+ initDelay: 10
+ holdTime: 120
+ interfaceRefs:
+ - name: eth1-1
+ adminRxState: Down
+ adminTxState: Down
+ - name: eth1-2
+ adminTxState: Down
diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml
index 0451ffc9..ea53c049 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
@@ -28,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
new file mode 100644
index 00000000..fc9691d8
--- /dev/null
+++ b/config/samples/v1alpha1_lldp.yaml
@@ -0,0 +1,18 @@
+apiVersion: networking.metal.ironcore.dev/v1alpha1
+kind: LLDP
+metadata:
+ labels:
+ app.kubernetes.io/name: network-operator
+ app.kubernetes.io/managed-by: kustomize
+ networking.metal.ironcore.dev/device-name: leaf1
+ name: leaf1-lldp
+spec:
+ deviceRef:
+ name: leaf1
+ adminState: Up
+ # Uncomment to add NXOS provider-specific config
+ # See: ./cisco/nx/v1alpha1_lldpconfig.yaml
+ # providerConfigRef:
+ # apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1
+ # kind: LLDPConfig
+ # name: leaf1-lldpconfig
diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md
index 2982390e..d13601a4 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)
@@ -1404,6 +1407,59 @@ _Appears in:_
| `Passive` | LACPModePassive indicates that LACP is in passive mode.
|
+#### LLDP
+
+
+
+LLDP is the Schema for the lldps API
+
+
+
+
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `apiVersion` _string_ | `networking.metal.ironcore.dev/v1alpha1` | | |
+| `kind` _string_ | `LLDP` | | |
+| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | |
+| `spec` _[LLDPSpec](#lldpspec)_ | | | Required: \{\}
|
+| `status` _[LLDPStatus](#lldpstatus)_ | | | Optional: \{\}
|
+
+
+#### LLDPSpec
+
+
+
+LLDPSpec defines the desired state of LLDP
+
+
+
+_Appears in:_
+- [LLDP](#lldp)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `deviceRef` _[LocalObjectReference](#localobjectreference)_ | DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace.
Immutable. | | Required: \{\}
|
+| `providerConfigRef` _[TypedLocalObjectReference](#typedlocalobjectreference)_ | ProviderConfigRef is a reference to a resource holding the provider-specific configuration for this LLDP.
If not specified the provider applies the target platform's default settings. | | Optional: \{\}
|
+| `adminState` _[AdminState](#adminstate)_ | AdminState indicates whether LLDP is system-wide administratively up or down. | | Enum: [Up Down]
Required: \{\}
|
+
+
+#### LLDPStatus
+
+
+
+LLDPStatus defines the observed state of LLDP.
+
+
+
+_Appears in:_
+- [LLDP](#lldp)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array_ | conditions represent the current state of the LLDP resource.
Each condition has a unique type and reflects the status of a specific aspect of the resource.
Standard condition types include:
- "Available": the resource is fully functional
- "Progressing": the resource is being created or updated
- "Degraded": the resource failed to reach or maintain its desired state
The status of each condition is one of True, False, or Unknown. | | Optional: \{\}
|
+
+
#### LocalObjectReference
@@ -1432,6 +1488,8 @@ _Appears in:_
- [InterfaceSpec](#interfacespec)
- [InterfaceStatus](#interfacestatus)
- [KeepAlive](#keepalive)
+- [LLDPInterface](#lldpinterface)
+- [LLDPSpec](#lldpspec)
- [ManagementAccessSpec](#managementaccessspec)
- [NTPSpec](#ntpspec)
- [NetworkVirtualizationEdgeSpec](#networkvirtualizationedgespec)
@@ -2754,6 +2812,7 @@ _Appears in:_
- [EVPNInstanceSpec](#evpninstancespec)
- [ISISSpec](#isisspec)
- [InterfaceSpec](#interfacespec)
+- [LLDPSpec](#lldpspec)
- [ManagementAccessSpec](#managementaccessspec)
- [NTPSpec](#ntpspec)
- [NetworkVirtualizationEdgeSpec](#networkvirtualizationedgespec)
@@ -2968,6 +3027,7 @@ Package v1alpha1 contains API Schema definitions for the nx.cisco.networking.met
### Resource Types
- [BorderGateway](#bordergateway)
- [InterfaceConfig](#interfaceconfig)
+- [LLDPConfig](#lldpconfig)
- [ManagementAccessConfig](#managementaccessconfig)
- [NetworkVirtualizationEdgeConfig](#networkvirtualizationedgeconfig)
- [System](#system)
@@ -3227,6 +3287,60 @@ _Appears in:_
| `vrfRef` _[LocalObjectReference](#localobjectreference)_ | The reference to a VRF resource used to send keepalive packets to the peer.
Mutually exclusive with VrfName. | | Optional: \{\}
|
+#### LLDPConfig
+
+
+
+LLDPConfig is the Schema for the LLDPConfig API
+
+
+
+
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `apiVersion` _string_ | `nx.cisco.networking.metal.ironcore.dev/v1alpha1` | | |
+| `kind` _string_ | `LLDPConfig` | | |
+| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | |
+| `spec` _[LLDPConfigSpec](#lldpconfigspec)_ | spec defines the desired state of LLDP | | Required: \{\}
|
+
+
+#### LLDPConfigSpec
+
+
+
+LLDPConfig defines the Cisco-specific configuration of an LLDP object.
+
+
+
+_Appears in:_
+- [LLDPConfig](#lldpconfig)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `initDelay` _integer_ | InitDelay defines the delay in seconds before LLDP starts sending packets after interface comes up. | 2 | Maximum: 10
Minimum: 1
Optional: \{\}
|
+| `holdTime` _integer_ | HoldTime defines the time in seconds that the receiving device should hold the LLDP information before discarding it. | 120 | Maximum: 255
Minimum: 1
Optional: \{\}
|
+| `interfaceRefs` _[LLDPInterface](#lldpinterface) array_ | InterfaceRefs is a list of interfaces and their LLDP configuration. | | Optional: \{\}
|
+
+
+#### LLDPInterface
+
+
+
+
+
+
+
+_Appears in:_
+- [LLDPConfigSpec](#lldpconfigspec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `name` _string_ | Name of the referent.
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names | | MaxLength: 63
MinLength: 1
Required: \{\}
|
+| `adminRxState` _[AdminState](#adminstate)_ | AdminRxState defines the administrative state for receiving LLDP PDUs on the interface. | Up | Enum: [Up Down]
Optional: \{\}
|
+| `adminTxState` _[AdminState](#adminstate)_ | AdminTxState defines the administrative state for transmitting LLDP PDUs on the interface. | Up | Enum: [Up Down]
Optional: \{\}
|
+
+
#### ManagementAccessConfig
diff --git a/internal/controller/core/lldp_controller.go b/internal/controller/core/lldp_controller.go
new file mode 100644
index 00000000..b98ffae4
--- /dev/null
+++ b/internal/controller/core/lldp_controller.go
@@ -0,0 +1,467 @@
+// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package core
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "slices"
+
+ "k8s.io/apimachinery/pkg/api/equality"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "k8s.io/apimachinery/pkg/types"
+ kerrors "k8s.io/apimachinery/pkg/util/errors"
+ "k8s.io/client-go/tools/record"
+ "k8s.io/klog/v2"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/builder"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+ "sigs.k8s.io/controller-runtime/pkg/handler"
+ "sigs.k8s.io/controller-runtime/pkg/predicate"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+
+ "github.com/ironcore-dev/network-operator/api/core/v1alpha1"
+ "github.com/ironcore-dev/network-operator/internal/conditions"
+ "github.com/ironcore-dev/network-operator/internal/deviceutil"
+ "github.com/ironcore-dev/network-operator/internal/provider"
+ "github.com/ironcore-dev/network-operator/internal/providerconfig"
+ "github.com/ironcore-dev/network-operator/internal/resourcelock"
+)
+
+// LLDPReconciler reconciles a LLDP object
+type LLDPReconciler struct {
+ client.Client
+ Scheme *runtime.Scheme
+ // APIReader is an uncached client.Reader used for provider-specific validation.
+ // It avoids dependency on informers for provider-specific CRDs.
+ APIReader client.Reader
+
+ // WatchFilterValue is the label value used to filter events prior to reconciliation.
+ WatchFilterValue string
+
+ // Recorder is used to record events for the controller.
+ // More info: https://book.kubebuilder.io/reference/raising-events
+ Recorder record.EventRecorder
+
+ // Provider is the driver that will be used to create & delete the LLDP.
+ Provider provider.ProviderFunc
+
+ // Locker is used to synchronize operations on resources targeting the same device.
+ Locker *resourcelock.ResourceLocker
+
+ // RequeueInterval is the duration after which the controller should requeue the reconciliation,
+ // regardless of changes.
+ RequeueInterval time.Duration
+}
+
+// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=lldps,verbs=get;list;watch;create;update;patch;delete
+// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=lldps/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=lldps/finalizers,verbs=update
+// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
+
+// Reconcile is part of the main kubernetes reconciliation loop which aims to
+// move the current state of the cluster closer to the desired state.
+//
+// For more details, check Reconcile and its Result here:
+// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.22.4/pkg/reconcile
+func (r *LLDPReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, reterr error) {
+ log := ctrl.LoggerFrom(ctx)
+ log.Info("Reconciling resource")
+
+ obj := new(v1alpha1.LLDP)
+ if err := r.Get(ctx, req.NamespacedName, obj); err != nil {
+ if apierrors.IsNotFound(err) {
+ log.Info("Resource not found. Ignoring reconciliation since object must be deleted")
+ return ctrl.Result{}, nil
+ }
+ // Error reading the object - requeue the request.
+ log.Error(err, "Failed to get resource")
+ return ctrl.Result{}, err
+ }
+
+ prov, ok := r.Provider().(provider.LLDPProvider)
+ if !ok {
+ if meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{
+ Type: v1alpha1.ReadyCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.NotImplementedReason,
+ Message: "Provider does not implement provider LLDPProvider",
+ }) {
+ return ctrl.Result{}, r.Status().Update(ctx, obj)
+ }
+ return ctrl.Result{}, nil
+ }
+
+ device, err := deviceutil.GetDeviceByName(ctx, r, obj.Namespace, obj.Spec.DeviceRef.Name)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ // Prevent concurrent reconciliations of resources targeting the same device
+ if err := r.Locker.AcquireLock(ctx, device.Name, "lldp-controller"); err != nil {
+ if errors.Is(err, resourcelock.ErrLockAlreadyHeld) {
+ log.Info("Device is already locked, requeuing reconciliation")
+ return ctrl.Result{RequeueAfter: time.Second * 5}, nil
+ }
+ log.Error(err, "Failed to acquire device lock")
+ return ctrl.Result{}, err
+ }
+ defer func() {
+ if err := r.Locker.ReleaseLock(ctx, device.Name, "lldp-controller"); err != nil {
+ log.Error(err, "Failed to release device lock")
+ reterr = kerrors.NewAggregate([]error{reterr, err})
+ }
+ }()
+
+ conn, err := deviceutil.GetDeviceConnection(ctx, r, device)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ s := &lldpScope{
+ Device: device,
+ LLDP: obj,
+ Connection: conn,
+ Provider: prov,
+ }
+
+ if !obj.DeletionTimestamp.IsZero() {
+ if controllerutil.ContainsFinalizer(obj, v1alpha1.FinalizerName) {
+ if err := r.finalize(ctx, s); err != nil {
+ log.Error(err, "Failed to finalize resource")
+ return ctrl.Result{}, err
+ }
+ controllerutil.RemoveFinalizer(obj, v1alpha1.FinalizerName)
+ if err := r.Update(ctx, obj); err != nil {
+ log.Error(err, "Failed to remove finalizer from resource")
+ return ctrl.Result{}, err
+ }
+ }
+ log.Info("Resource is being deleted, skipping reconciliation")
+ return ctrl.Result{}, nil
+ }
+
+ // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/finalizers
+ if !controllerutil.ContainsFinalizer(obj, v1alpha1.FinalizerName) {
+ controllerutil.AddFinalizer(obj, v1alpha1.FinalizerName)
+ if err := r.Update(ctx, obj); err != nil {
+ log.Error(err, "Failed to add finalizer to resource")
+ return ctrl.Result{}, err
+ }
+ log.Info("Added finalizer to resource")
+ return ctrl.Result{}, nil
+ }
+
+ orig := obj.DeepCopy()
+ if conditions.InitializeConditions(obj, v1alpha1.ReadyCondition) {
+ log.Info("Initializing status conditions")
+ return ctrl.Result{}, r.Status().Update(ctx, obj)
+ }
+
+ // Always attempt to update the metadata/status after reconciliation
+ defer func() {
+ if !equality.Semantic.DeepEqual(orig.ObjectMeta, obj.ObjectMeta) {
+ // Pass obj.DeepCopy() to avoid Patch() modifying obj and interfering with status update below
+ if err := r.Patch(ctx, obj.DeepCopy(), client.MergeFrom(orig)); err != nil {
+ log.Error(err, "Failed to update resource metadata")
+ reterr = kerrors.NewAggregate([]error{reterr, err})
+ }
+ return
+ }
+
+ if !equality.Semantic.DeepEqual(orig.Status, obj.Status) {
+ if err := r.Status().Patch(ctx, obj, client.MergeFrom(orig)); err != nil {
+ log.Error(err, "Failed to update status")
+ reterr = kerrors.NewAggregate([]error{reterr, err})
+ }
+ }
+ }()
+
+ res, err := r.reconcile(ctx, s)
+ if err != nil {
+ log.Error(err, "Failed to reconcile resource")
+ return ctrl.Result{}, err
+ }
+ return res, nil
+}
+
+type lldpScope struct {
+ Device *v1alpha1.Device
+ LLDP *v1alpha1.LLDP
+ Connection *deviceutil.Connection
+ Provider provider.LLDPProvider
+ // ProviderConfig is the resource referenced by LLDP.Spec.ProviderConfigRef, if any.
+ ProviderConfig *provider.ProviderConfig
+ // ProviderConfigScope is the scope with the resources referenced in ProviderConfig, if applicable.
+ ProviderConfigScope *providerconfig.Scope
+}
+
+func (r *LLDPReconciler) reconcile(ctx context.Context, s *lldpScope) (_ ctrl.Result, reterr error) {
+ if s.LLDP.Labels == nil {
+ s.LLDP.Labels = make(map[string]string)
+ }
+ s.LLDP.Labels[v1alpha1.DeviceLabel] = s.Device.Name
+
+ // Ensure LLDP resource is owned by the Device.
+ if !controllerutil.HasControllerReference(s.LLDP) {
+ if err := controllerutil.SetOwnerReference(s.Device, s.LLDP, r.Scheme, controllerutil.WithBlockOwnerDeletion(true)); err != nil {
+ return ctrl.Result{}, err
+ }
+ }
+
+ defer func() {
+ conditions.RecomputeReady(s.LLDP)
+ }()
+
+ cfg, err := r.validateProviderConfigRef(ctx, s)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+ s.ProviderConfig = cfg
+
+ if err := r.validateUniqueLLDPPerDevice(ctx, s); err != nil {
+ return ctrl.Result{}, err
+ }
+
+ if err := s.Provider.Connect(ctx, s.Connection); err != nil {
+ return ctrl.Result{}, fmt.Errorf("failed to connect to provider: %w", err)
+ }
+ defer func() {
+ if err := s.Provider.Disconnect(ctx, s.Connection); err != nil {
+ reterr = kerrors.NewAggregate([]error{reterr, err})
+ }
+ }()
+
+ // Ensure the LLDP is realized on the remote device.
+ // Convert providerconfig.Scope to provider.ProviderConfigScope.
+ // These are separate types to decouple the validation layer (which uses k8s client)
+ // from the provider layer (which should not depend on k8s client).
+ var providerScope *provider.ProviderConfigScope
+ if s.ProviderConfigScope != nil {
+ providerScope = provider.NewProviderConfigScope(s.ProviderConfigScope.Raw())
+ }
+ err = s.Provider.EnsureLLDP(ctx, &provider.LLDPRequest{
+ LLDP: s.LLDP,
+ ProviderConfig: s.ProviderConfig,
+ ProviderConfigScope: providerScope,
+ })
+
+ cond := conditions.FromError(err)
+ conditions.Set(s.LLDP, cond)
+
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ status, err := s.Provider.GetLLDPStatus(ctx, &provider.LLDPRequest{
+ LLDP: s.LLDP,
+ ProviderConfig: s.ProviderConfig,
+ })
+ if err != nil {
+ return ctrl.Result{}, fmt.Errorf("failed to get LLDP status: %w", err)
+ }
+
+ cond = metav1.Condition{
+ Type: v1alpha1.OperationalCondition,
+ Status: metav1.ConditionTrue,
+ Reason: v1alpha1.OperationalReason,
+ Message: "LLDP is operationally up",
+ }
+ if !status.OperStatus {
+ cond.Status = metav1.ConditionFalse
+ cond.Reason = v1alpha1.DegradedReason
+ cond.Message = "LLDP is operationally down"
+ }
+ conditions.Set(s.LLDP, cond)
+
+ return ctrl.Result{}, nil
+}
+
+// validateProviderConfigRef checks if the referenced provider configuration exists and is compatible with the target platform.
+func (r *LLDPReconciler) validateProviderConfigRef(ctx context.Context, s *lldpScope) (*provider.ProviderConfig, error) {
+ if s.LLDP.Spec.ProviderConfigRef == nil {
+ return nil, nil
+ }
+
+ cfg, err := provider.GetProviderConfig(ctx, r, s.LLDP.Namespace, s.LLDP.Spec.ProviderConfigRef)
+ if err != nil {
+ conditions.Set(s.LLDP, metav1.Condition{
+ Type: v1alpha1.ConfiguredCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.IncompatibleProviderConfigRef,
+ Message: fmt.Sprintf("Failed to get ProviderConfigRef: %v", err),
+ })
+ return nil, err
+ }
+
+ gv, err := schema.ParseGroupVersion(s.LLDP.Spec.ProviderConfigRef.APIVersion)
+ if err != nil {
+ conditions.Set(s.LLDP, metav1.Condition{
+ Type: v1alpha1.ConfiguredCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.IncompatibleProviderConfigRef,
+ Message: fmt.Sprintf("ProviderConfigRef is not compatible with Device: %v", err),
+ })
+ return nil, reconcile.TerminalError(fmt.Errorf("invalid API version %q: %w", s.LLDP.Spec.ProviderConfigRef.APIVersion, err))
+ }
+
+ gvk := schema.GroupVersionKind{
+ Group: gv.Group,
+ Version: gv.Version,
+ Kind: s.LLDP.Spec.ProviderConfigRef.Kind,
+ }
+
+ // verify the GVK is a registered dependency for LLDP
+ if ok := slices.Contains(v1alpha1.LLDPDependencies, gvk); !ok {
+ conditions.Set(s.LLDP, metav1.Condition{
+ Type: v1alpha1.ConfiguredCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.IncompatibleProviderConfigRef,
+ Message: fmt.Sprintf("ProviderConfigRef kind '%s' with API version '%s' is not compatible with this device type", s.LLDP.Spec.ProviderConfigRef.Kind, s.LLDP.Spec.ProviderConfigRef.APIVersion),
+ })
+ return nil, reconcile.TerminalError(fmt.Errorf("unsupported ProviderConfigRef Kind %q on this provider", gv))
+ }
+
+ // if a provider-specific validator is registered, use it
+ if validator, ok := providerconfig.GetValidator(gvk); ok {
+ reader := r.APIReader
+ if reader == nil {
+ // fall back to the cached client if APIReader is not set
+ reader = r.Client
+ }
+ s.ProviderConfigScope, err = validator(ctx, reader, s.LLDP, s.LLDP.Spec.ProviderConfigRef)
+ if err != nil {
+ conditions.Set(s.LLDP, metav1.Condition{
+ Type: v1alpha1.ConfiguredCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.IncompatibleProviderConfigRef,
+ Message: fmt.Sprintf("ProviderConfigRef validation failed: %v", err),
+ })
+ return nil, reconcile.TerminalError(fmt.Errorf("configuration error in provider config ref %w", err))
+ }
+ }
+ return cfg, nil
+}
+
+func (r *LLDPReconciler) validateUniqueLLDPPerDevice(ctx context.Context, s *lldpScope) error {
+ var list v1alpha1.LLDPList
+ if err := r.List(ctx, &list,
+ client.InNamespace(s.LLDP.Namespace),
+ client.MatchingFields{".spec.deviceRef.name": s.LLDP.Spec.DeviceRef.Name},
+ ); err != nil {
+ return err
+ }
+ for _, lldp := range list.Items {
+ if lldp.Name != s.LLDP.Name {
+ conditions.Set(s.LLDP, metav1.Condition{
+ Type: v1alpha1.ConfiguredCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.DuplicateResourceOnDevice,
+ Message: fmt.Sprintf("Another LLDP (%s) already exists for device %s", lldp.Name, s.LLDP.Spec.DeviceRef.Name),
+ })
+ return reconcile.TerminalError(fmt.Errorf("only one LLDP resource allowed per device (%s)", s.LLDP.Spec.DeviceRef.Name))
+ }
+ }
+ return nil
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *LLDPReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error {
+ if r.RequeueInterval == 0 {
+ return errors.New("requeue interval must not be 0")
+ }
+
+ // Use the manager's APIReader for provider-specific validation so that
+ // validation does not depend on informers for provider-specific CRDs.
+ r.APIReader = mgr.GetAPIReader()
+
+ labelSelector := metav1.LabelSelector{}
+ if r.WatchFilterValue != "" {
+ labelSelector.MatchLabels = map[string]string{v1alpha1.WatchLabel: r.WatchFilterValue}
+ }
+
+ filter, err := predicate.LabelSelectorPredicate(labelSelector)
+ if err != nil {
+ return fmt.Errorf("failed to create label selector predicate: %w", err)
+ }
+
+ if err := mgr.GetFieldIndexer().IndexField(ctx, &v1alpha1.LLDP{}, ".spec.deviceRef.name", func(obj client.Object) []string {
+ lldp := obj.(*v1alpha1.LLDP)
+ return []string{lldp.Spec.DeviceRef.Name}
+ }); err != nil {
+ return err
+ }
+
+ c := ctrl.NewControllerManagedBy(mgr).
+ For(&v1alpha1.LLDP{}).
+ Named("lldp").
+ WithEventFilter(filter)
+
+ for _, gvk := range v1alpha1.LLDPDependencies {
+ obj := &unstructured.Unstructured{}
+ obj.SetGroupVersionKind(gvk)
+ c = c.Watches(
+ obj,
+ handler.EnqueueRequestsFromMapFunc(r.mapProviderConfigToLLDP),
+ builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}),
+ )
+ if _, err := mgr.GetCache().GetInformer(ctx, obj); err != nil {
+ return fmt.Errorf("failed to create informer for LLDP dependency %s: %w", gvk.String(), err)
+ }
+ }
+ return c.Complete(r)
+}
+
+func (r *LLDPReconciler) mapProviderConfigToLLDP(ctx context.Context, obj client.Object) []reconcile.Request {
+ log := ctrl.LoggerFrom(ctx, "Object", klog.KObj(obj))
+
+ list := &v1alpha1.LLDPList{}
+ if err := r.List(ctx, list, client.InNamespace(obj.GetNamespace())); err != nil {
+ log.Error(err, "failed to list LLDPs")
+ return nil
+ }
+
+ gkv := obj.GetObjectKind().GroupVersionKind()
+
+ var requests []reconcile.Request
+ for _, m := range list.Items {
+ if m.Spec.ProviderConfigRef != nil &&
+ m.Spec.ProviderConfigRef.Name == obj.GetName() &&
+ m.Spec.ProviderConfigRef.Kind == gkv.Kind &&
+ m.Spec.ProviderConfigRef.APIVersion == gkv.GroupVersion().Identifier() {
+ log.Info("Found matching LLDP for provider config change, enqueuing for reconciliation", "LLDP", klog.KObj(&m))
+ requests = append(requests, reconcile.Request{
+ NamespacedName: types.NamespacedName{
+ Name: m.Name,
+ Namespace: m.Namespace,
+ },
+ })
+ }
+ }
+ return requests
+}
+
+func (r *LLDPReconciler) finalize(ctx context.Context, s *lldpScope) (reterr error) {
+ if err := s.Provider.Connect(ctx, s.Connection); err != nil {
+ return fmt.Errorf("failed to connect to provider: %w", err)
+ }
+ defer func() {
+ if err := s.Provider.Disconnect(ctx, s.Connection); err != nil {
+ reterr = kerrors.NewAggregate([]error{reterr, err})
+ }
+ }()
+
+ return s.Provider.DeleteLLDP(ctx, &provider.LLDPRequest{
+ LLDP: s.LLDP,
+ })
+}
diff --git a/internal/controller/core/lldp_controller_test.go b/internal/controller/core/lldp_controller_test.go
new file mode 100644
index 00000000..471f148e
--- /dev/null
+++ b/internal/controller/core/lldp_controller_test.go
@@ -0,0 +1,525 @@
+// 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"
+
+ nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1"
+ "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 reconciling with ProviderConfigRef", func() {
+ const (
+ deviceName = "testlldp-provider-device"
+ resourceName = "testlldp-provider-lldp"
+ )
+
+ resourceKey := client.ObjectKey{Name: resourceName, Namespace: metav1.NamespaceDefault}
+ deviceKey := client.ObjectKey{Name: deviceName, Namespace: metav1.NamespaceDefault}
+
+ var (
+ device *v1alpha1.Device
+ lldp *v1alpha1.LLDP
+ )
+
+ BeforeEach(func() {
+ By("Creating the custom resource for the Kind Device")
+ device = &v1alpha1.Device{}
+ if err := k8sClient.Get(ctx, deviceKey, device); errors.IsNotFound(err) {
+ device = &v1alpha1.Device{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: deviceName,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.DeviceSpec{
+ Endpoint: v1alpha1.Endpoint{
+ Address: "192.168.10.2:9339",
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, device)).To(Succeed())
+ }
+ })
+
+ AfterEach(func() {
+ By("Cleaning up the LLDP resource")
+ lldp = &v1alpha1.LLDP{}
+ err := k8sClient.Get(ctx, resourceKey, lldp)
+ if err == nil {
+ Expect(k8sClient.Delete(ctx, lldp)).To(Succeed())
+
+ By("Waiting for LLDP resource to be fully deleted")
+ Eventually(func(g Gomega) {
+ err := k8sClient.Get(ctx, resourceKey, &v1alpha1.LLDP{})
+ g.Expect(errors.IsNotFound(err)).To(BeTrue())
+ }).Should(Succeed())
+ }
+
+ By("Cleaning up the Device resource")
+ err = k8sClient.Get(ctx, deviceKey, device)
+ if err == nil {
+ Expect(k8sClient.Delete(ctx, device, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed())
+ }
+
+ By("Verifying the resource has been deleted")
+ Eventually(func(g Gomega) {
+ g.Expect(testProvider.LLDP).To(BeNil(), "Provider should have no LLDP configured")
+ }).Should(Succeed())
+ })
+
+ It("Should handle missing ProviderConfigRef", func() {
+ By("Creating LLDP with a non-existent ProviderConfigRef")
+ lldp = &v1alpha1.LLDP{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: resourceName,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.LLDPSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName},
+ AdminState: v1alpha1.AdminStateUp,
+ ProviderConfigRef: &v1alpha1.TypedLocalObjectReference{
+ APIVersion: "nx.cisco.networking.metal.ironcore.dev/v1alpha1",
+ Kind: "LLDPConfig",
+ Name: "non-existent-config",
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, lldp)).To(Succeed())
+
+ By("Verifying the controller sets ConfiguredCondition to False")
+ Eventually(func(g Gomega) {
+ err := k8sClient.Get(ctx, resourceKey, lldp)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ cond := meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ConfiguredCondition)
+ g.Expect(cond).ToNot(BeNil())
+ g.Expect(cond.Status).To(Equal(metav1.ConditionFalse))
+ g.Expect(cond.Reason).To(Equal(v1alpha1.IncompatibleProviderConfigRef))
+ }).Should(Succeed())
+ })
+
+ It("Should handle invalid ProviderConfigRef API version", func() {
+ By("Creating LLDP with invalid API version in ProviderConfigRef")
+ lldp = &v1alpha1.LLDP{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: resourceName,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.LLDPSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName},
+ AdminState: v1alpha1.AdminStateUp,
+ ProviderConfigRef: &v1alpha1.TypedLocalObjectReference{
+ APIVersion: "invalid-api-version",
+ Kind: "LLDPConfig",
+ Name: "some-config",
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, lldp)).To(Succeed())
+
+ By("Verifying the controller sets ConfiguredCondition to False with IncompatibleProviderConfigRef")
+ Eventually(func(g Gomega) {
+ err := k8sClient.Get(ctx, resourceKey, lldp)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ cond := meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ConfiguredCondition)
+ g.Expect(cond).ToNot(BeNil())
+ g.Expect(cond.Status).To(Equal(metav1.ConditionFalse))
+ g.Expect(cond.Reason).To(Equal(v1alpha1.IncompatibleProviderConfigRef))
+ }).Should(Succeed())
+ })
+
+ It("Should handle unsupported ProviderConfigRef Kind", func() {
+ By("Creating LLDP with unsupported Kind in ProviderConfigRef")
+ lldp = &v1alpha1.LLDP{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: resourceName,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.LLDPSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName},
+ AdminState: v1alpha1.AdminStateUp,
+ ProviderConfigRef: &v1alpha1.TypedLocalObjectReference{
+ APIVersion: "v1",
+ Kind: "ConfigMap",
+ Name: "some-config",
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, lldp)).To(Succeed())
+
+ By("Verifying the controller sets ConfiguredCondition to False with IncompatibleProviderConfigRef")
+ Eventually(func(g Gomega) {
+ err := k8sClient.Get(ctx, resourceKey, lldp)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ cond := meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ConfiguredCondition)
+ g.Expect(cond).ToNot(BeNil())
+ g.Expect(cond.Status).To(Equal(metav1.ConditionFalse))
+ g.Expect(cond.Reason).To(Equal(v1alpha1.IncompatibleProviderConfigRef))
+ }).Should(Succeed())
+ })
+
+ It("Should successfully reconcile with valid ProviderConfigRef", func() {
+ const (
+ interfaceName = "testlldp-provider-interface"
+ lldpConfigName = "testlldp-provider-config"
+ )
+
+ By("Creating the Interface resource")
+ intf := &v1alpha1.Interface{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: interfaceName,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.InterfaceSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName},
+ Name: "Ethernet1/1",
+ Type: v1alpha1.InterfaceTypePhysical,
+ AdminState: v1alpha1.AdminStateUp,
+ },
+ }
+ Expect(k8sClient.Create(ctx, intf)).To(Succeed())
+
+ By("Creating the LLDPConfig resource")
+ lldpConfig := &nxv1alpha1.LLDPConfig{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: lldpConfigName,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: nxv1alpha1.LLDPConfigSpec{
+ InitDelay: 5,
+ HoldTime: 180,
+ InterfaceRefs: []nxv1alpha1.LLDPInterface{{
+ LocalObjectReference: v1alpha1.LocalObjectReference{Name: interfaceName},
+ AdminRxState: v1alpha1.AdminStateUp,
+ AdminTxState: v1alpha1.AdminStateDown,
+ }},
+ },
+ }
+ Expect(k8sClient.Create(ctx, lldpConfig)).To(Succeed())
+
+ By("Creating LLDP with valid ProviderConfigRef")
+ lldp = &v1alpha1.LLDP{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: resourceName,
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.LLDPSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName},
+ AdminState: v1alpha1.AdminStateUp,
+ ProviderConfigRef: &v1alpha1.TypedLocalObjectReference{
+ APIVersion: nxv1alpha1.GroupVersion.String(),
+ Kind: "LLDPConfig",
+ Name: lldpConfigName,
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, lldp)).To(Succeed())
+
+ By("Verifying the controller sets all conditions to True")
+ Eventually(func(g Gomega) {
+ err := k8sClient.Get(ctx, resourceKey, lldp)
+ g.Expect(err).NotTo(HaveOccurred())
+
+ cond := meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ReadyCondition)
+ g.Expect(cond).ToNot(BeNil())
+ g.Expect(cond.Status).To(Equal(metav1.ConditionTrue))
+
+ cond = meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ConfiguredCondition)
+ g.Expect(cond).ToNot(BeNil())
+ g.Expect(cond.Status).To(Equal(metav1.ConditionTrue))
+
+ cond = meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.OperationalCondition)
+ g.Expect(cond).ToNot(BeNil())
+ g.Expect(cond.Status).To(Equal(metav1.ConditionTrue))
+ }).Should(Succeed())
+
+ By("Verifying the LLDP is created in the provider")
+ Eventually(func(g Gomega) {
+ g.Expect(testProvider.LLDP).ToNot(BeNil())
+ g.Expect(testProvider.LLDP.GetName()).To(Equal(resourceName))
+ }).Should(Succeed())
+
+ By("Cleaning up the LLDPConfig resource")
+ Expect(k8sClient.Delete(ctx, lldpConfig)).To(Succeed())
+
+ By("Cleaning up the Interface resource")
+ Expect(k8sClient.Delete(ctx, intf)).To(Succeed())
+ })
+ })
+})
diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go
index 9e9606c5..254d8306 100644
--- a/internal/controller/core/suite_test.go
+++ b/internal/controller/core/suite_test.go
@@ -28,10 +28,14 @@ import (
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
+ nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1"
"github.com/ironcore-dev/network-operator/api/core/v1alpha1"
"github.com/ironcore-dev/network-operator/internal/deviceutil"
"github.com/ironcore-dev/network-operator/internal/provider"
"github.com/ironcore-dev/network-operator/internal/resourcelock"
+
+ // Import provider-specific config validators to register them.
+ _ "github.com/ironcore-dev/network-operator/internal/providerconfig/cisco/nx"
// +kubebuilder:scaffold:imports
)
@@ -67,6 +71,9 @@ var _ = BeforeSuite(func() {
err = v1alpha1.AddToScheme(scheme.Scheme)
Expect(err).NotTo(HaveOccurred())
+ err = nxv1alpha1.AddToScheme(scheme.Scheme)
+ Expect(err).NotTo(HaveOccurred())
+
// +kubebuilder:scaffold:scheme
By("bootstrapping test environment")
@@ -320,6 +327,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 +404,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 +433,7 @@ type Provider struct {
PrefixSets sets.Set[string]
RoutingPolicies sets.Set[string]
NVE *v1alpha1.NetworkVirtualizationEdge
+ LLDP *v1alpha1.LLDP
}
func NewProvider() *Provider {
@@ -832,3 +851,21 @@ func (p *Provider) GetNVEStatus(_ context.Context, _ *provider.NVERequest) (prov
}
return status, nil
}
+
+func (p *Provider) EnsureLLDP(_ context.Context, req *provider.LLDPRequest) error {
+ p.Lock()
+ defer p.Unlock()
+ p.LLDP = req.LLDP
+ return nil
+}
+
+func (p *Provider) DeleteLLDP(_ context.Context, req *provider.LLDPRequest) error {
+ p.Lock()
+ defer p.Unlock()
+ p.LLDP = nil
+ return nil
+}
+
+func (p *Provider) GetLLDPStatus(_ context.Context, _ *provider.LLDPRequest) (provider.LLDPStatus, error) {
+ return provider.LLDPStatus{OperStatus: true}, nil
+}
diff --git a/internal/provider/cisco/nxos/lldp.go b/internal/provider/cisco/nxos/lldp.go
new file mode 100644
index 00000000..e2b73e47
--- /dev/null
+++ b/internal/provider/cisco/nxos/lldp.go
@@ -0,0 +1,45 @@
+// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package nxos
+
+import "github.com/ironcore-dev/network-operator/internal/provider/cisco/gnmiext/v2"
+
+var (
+ _ gnmiext.Configurable = (*LLDP)(nil)
+)
+
+type LLDP struct {
+ // HoldTime is the number of seconds that a receiving device should hold the information sent by another device before discarding it.
+ HoldTime Option[uint16] `json:"holdTime"`
+ // InitDelay is the number of seconds for LLDP to initialize on any interface.
+ InitDelay Option[uint16] `json:"initDelayTime"`
+ // IfItems contains the per-interface LLDP configuration.
+ IfItems struct {
+ IfList gnmiext.List[string, *LLDPIfItem] `json:"If-list,omitzero"`
+ } `json:"if-items,omitzero"`
+}
+
+func (*LLDP) XPath() string {
+ return "System/lldp-items/inst-items"
+}
+
+func (*LLDP) IsListItem() {}
+
+type LLDPIfItem struct {
+ InterfaceName string `json:"id"`
+ AdminRxSt Option[AdminSt] `json:"adminRxSt"`
+ AdminTxSt Option[AdminSt] `json:"adminTxSt"`
+}
+
+func (i *LLDPIfItem) Key() string { return i.InterfaceName }
+
+type LLDPOper struct {
+ OperSt OperSt `json:"operSt"`
+}
+
+func (*LLDPOper) IsListItem() {}
+
+func (*LLDPOper) XPath() string {
+ return "System/fm-items/lldp-items"
+}
diff --git a/internal/provider/cisco/nxos/lldp_test.go b/internal/provider/cisco/nxos/lldp_test.go
new file mode 100644
index 00000000..9c4bae3c
--- /dev/null
+++ b/internal/provider/cisco/nxos/lldp_test.go
@@ -0,0 +1,24 @@
+// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package nxos
+
+func init() {
+ lldp := &LLDP{
+ HoldTime: NewOption(uint16(200)),
+ InitDelay: NewOption(uint16(5)),
+ }
+
+ lldp.IfItems.IfList.Set(&LLDPIfItem{
+ InterfaceName: "eth7/1",
+ AdminRxSt: NewOption(AdminStDisabled),
+ AdminTxSt: NewOption(AdminStDisabled),
+ })
+
+ lldp.IfItems.IfList.Set(&LLDPIfItem{
+ InterfaceName: "eth8/1",
+ AdminTxSt: NewOption(AdminStDisabled),
+ })
+
+ Register("lldp", lldp)
+}
diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go
index 5c5df3ee..58a9ab4c 100644
--- a/internal/provider/cisco/nxos/provider.go
+++ b/internal/provider/cisco/nxos/provider.go
@@ -11,6 +11,7 @@ import (
"crypto/rsa"
"errors"
"fmt"
+ "maps"
"math"
"net/netip"
"reflect"
@@ -29,6 +30,7 @@ import (
"github.com/ironcore-dev/network-operator/internal/deviceutil"
"github.com/ironcore-dev/network-operator/internal/provider"
"github.com/ironcore-dev/network-operator/internal/provider/cisco/gnmiext/v2"
+ nxcfg "github.com/ironcore-dev/network-operator/internal/providerconfig/cisco/nx"
)
var (
@@ -56,6 +58,7 @@ var (
_ provider.VLANProvider = (*Provider)(nil)
_ provider.VRFProvider = (*Provider)(nil)
_ provider.NVEProvider = (*Provider)(nil)
+ _ provider.LLDPProvider = (*Provider)(nil)
)
type Provider struct {
@@ -2657,6 +2660,96 @@ func (p *Provider) GetNVEStatus(ctx context.Context, req *provider.NVERequest) (
return s, nil
}
+func (p *Provider) EnsureLLDP(ctx context.Context, req *provider.LLDPRequest) error {
+ f1 := new(Feature)
+ f1.Name = "lldp"
+ f1.AdminSt = AdminStEnabled
+ if req.LLDP.Spec.AdminState == v1alpha1.AdminStateDown {
+ f1.AdminSt = AdminStDisabled
+ }
+
+ if err := p.Patch(ctx, f1); err != nil {
+ return err
+ }
+
+ l := new(LLDP)
+ c := new(nxv1alpha1.LLDPConfig)
+ if req.ProviderConfig == nil {
+ return nil
+ }
+
+ if err := req.ProviderConfig.Into(c); err != nil {
+ return fmt.Errorf("failed to decode provider config: %w", err)
+ }
+
+ l.InitDelay = NewOption(uint16(c.Spec.InitDelay)) //nolint:gosec
+ l.HoldTime = NewOption(uint16(c.Spec.HoldTime)) //nolint:gosec
+
+ // patch if the provider config only includes global LLDP settings
+ if c.Spec.InterfaceRefs == nil {
+ return p.Patch(ctx, l)
+ }
+
+ // return error the config references interfaces but the request does not include a scope with them
+ if req.ProviderConfigScope == nil {
+ return errors.New("lldp: provider config scope is required when interface refs are specified")
+ }
+
+ s := new(nxcfg.LLDPConfigScope)
+ if err := req.ProviderConfigScope.Into(s); err != nil {
+ return fmt.Errorf("failed to decode provider config into scope: %w", err)
+ }
+
+ for _, ifCfg := range c.Spec.InterfaceRefs {
+ intf, ok := s.Interfaces[ifCfg.Name]
+ if !ok {
+ available := slices.Sorted(maps.Keys(s.Interfaces))
+ return fmt.Errorf("lldp: interface %q not found in request (available interfaces: %v)", ifCfg.Name, available)
+ }
+
+ item := new(LLDPIfItem)
+ name, err := ShortName(intf.Spec.Name)
+ if err != nil {
+ return fmt.Errorf("lldp: failed to get short name for interface %q: %w", intf.Spec.Name, err)
+ }
+ item.InterfaceName = name
+
+ item.AdminRxSt = NewOption(AdminStEnabled)
+ if ifCfg.AdminRxState == v1alpha1.AdminStateDown {
+ item.AdminRxSt = NewOption(AdminStDisabled)
+ }
+ item.AdminTxSt = NewOption(AdminStEnabled)
+ if ifCfg.AdminTxState == v1alpha1.AdminStateDown {
+ item.AdminTxSt = NewOption(AdminStDisabled)
+ }
+ l.IfItems.IfList.Set(item)
+ }
+ return p.Patch(ctx, l)
+}
+
+func (p *Provider) DeleteLLDP(ctx context.Context, req *provider.LLDPRequest) error {
+ f1 := new(Feature)
+ f1.Name = "lldp"
+ f1.AdminSt = AdminStDisabled
+
+ if err := p.Patch(ctx, f1); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (p *Provider) GetLLDPStatus(ctx context.Context, req *provider.LLDPRequest) (provider.LLDPStatus, error) {
+ s := provider.LLDPStatus{}
+
+ op := new(LLDPOper)
+ if err := p.client.GetState(ctx, op); err != nil && !errors.Is(err, gnmiext.ErrNil) {
+ return provider.LLDPStatus{}, err
+ }
+ s.OperStatus = op.OperSt == OperSt(AdminStEnabled)
+
+ return s, nil
+}
+
func (p *Provider) Patch(ctx context.Context, conf ...gnmiext.Configurable) error {
if NXVersion(p.client.Capabilities()) > VersionNX10_6_2 {
return p.client.Patch(ctx, conf...)
diff --git a/internal/provider/cisco/nxos/testdata/lldp.json b/internal/provider/cisco/nxos/testdata/lldp.json
new file mode 100644
index 00000000..9e659f0a
--- /dev/null
+++ b/internal/provider/cisco/nxos/testdata/lldp.json
@@ -0,0 +1,22 @@
+{
+ "lldp-items": {
+ "inst-items": {
+ "holdTime": 200,
+ "initDelayTime": 5,
+ "if-items": {
+ "If-list": [
+ {
+ "id": "eth7/1",
+ "adminRxSt": "disabled",
+ "adminTxSt": "disabled"
+ },
+ {
+ "id": "eth8/1",
+ "adminRxSt": "DME_UNSET_PROPERTY_MARKER",
+ "adminTxSt": "disabled"
+ }
+ ]
+ }
+ }
+ }
+}
diff --git a/internal/provider/cisco/nxos/testdata/lldp.json.txt b/internal/provider/cisco/nxos/testdata/lldp.json.txt
new file mode 100644
index 00000000..d9d0a743
--- /dev/null
+++ b/internal/provider/cisco/nxos/testdata/lldp.json.txt
@@ -0,0 +1,7 @@
+lldp holdtime 200
+lldp reinit 5
+interface ethernet 7/1
+ no lldp receive
+ no lldp transmit
+interface ethernet 8/1
+ no lldp transmit
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index a1ac2a02..52251f85 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -586,6 +586,31 @@ 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
+ ProviderConfigScope *ProviderConfigScope
+}
+
+// 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.
@@ -650,3 +675,16 @@ type ProviderConfig struct {
func (p ProviderConfig) Into(v any) error {
return runtime.DefaultUnstructuredConverter.FromUnstructured(p.obj.Object, v)
}
+
+// ProviderConfigScope is a wrapper around an [unstructured.Unstructured] object that represents a provider-specific scope.
+type ProviderConfigScope struct {
+ obj *unstructured.Unstructured
+}
+
+func (p ProviderConfigScope) Into(v any) error {
+ return runtime.DefaultUnstructuredConverter.FromUnstructured(p.obj.Object, v)
+}
+
+func NewProviderConfigScope(obj *unstructured.Unstructured) *ProviderConfigScope {
+ return &ProviderConfigScope{obj: obj}
+}
diff --git a/internal/providerconfig/cisco/nx/lldpconfig.go b/internal/providerconfig/cisco/nx/lldpconfig.go
new file mode 100644
index 00000000..74f77eeb
--- /dev/null
+++ b/internal/providerconfig/cisco/nx/lldpconfig.go
@@ -0,0 +1,94 @@
+// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package nx
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ nxv1alpha "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1"
+ "github.com/ironcore-dev/network-operator/api/core/v1alpha1"
+ "github.com/ironcore-dev/network-operator/internal/conditions"
+ "github.com/ironcore-dev/network-operator/internal/providerconfig"
+)
+
+func init() {
+ providerconfig.RegisterValidator(nxv1alpha.GroupVersion.WithKind("LLDPConfig"), LLDPConfigValidationFunc)
+}
+
+// LLDPConfigScope contains pre-fetched and validated data for LLDP configuration.
+// The provider decodes this from unstructured data.
+type LLDPConfigScope struct {
+ // Interfaces is a map with keys being the interface name (K8s resource name)
+ // and values being the corresponding Interface resources.
+ Interfaces map[string]*v1alpha1.Interface `json:"interfaces"`
+}
+
+func LLDPConfigValidationFunc(ctx context.Context, r client.Reader, parent client.Object, ref *v1alpha1.TypedLocalObjectReference) (*providerconfig.Scope, error) {
+ lldp, ok := parent.(*v1alpha1.LLDP)
+ if !ok {
+ return nil, errors.New("parent is not LLDP")
+ }
+
+ cfg := new(nxv1alpha.LLDPConfig)
+ if err := r.Get(ctx, client.ObjectKey{Namespace: lldp.Namespace, Name: ref.Name}, cfg); err != nil {
+ return nil, fmt.Errorf("failed to get LLDPConfig %q: %w", ref.Name, err)
+ }
+
+ s := &LLDPConfigScope{
+ Interfaces: make(map[string]*v1alpha1.Interface),
+ }
+ // Ensure all referenced interfaces exist and belong to the same device as the LLDP.
+ for _, ifCfg := range cfg.Spec.InterfaceRefs {
+ intf := new(v1alpha1.Interface)
+ if err := r.Get(ctx, client.ObjectKey{Name: ifCfg.Name, Namespace: lldp.Namespace}, intf); err != nil {
+ if apierrors.IsNotFound(err) {
+ msg := fmt.Sprintf("Referenced interface %q not found", ifCfg.Name)
+ conditions.Set(lldp, metav1.Condition{
+ Type: v1alpha1.ConfiguredCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.InterfaceNotFoundReason,
+ Message: msg,
+ })
+ return nil, fmt.Errorf("%s: %w", msg, err)
+ }
+ return nil, fmt.Errorf("failed to get referenced interface %q: %w", ifCfg.Name, err)
+ }
+ if intf.Spec.DeviceRef.Name != lldp.Spec.DeviceRef.Name {
+ msg := fmt.Sprintf("Referenced interface %q belongs to device %q, expected %q", ifCfg.Name, intf.Spec.DeviceRef.Name, lldp.Spec.DeviceRef.Name)
+ conditions.Set(lldp, metav1.Condition{
+ Type: v1alpha1.ConfiguredCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.CrossDeviceReferenceReason,
+ Message: msg,
+ })
+ return nil, errors.New(msg)
+ }
+ if intf.Spec.Type != v1alpha1.InterfaceTypePhysical && intf.Spec.Type != v1alpha1.InterfaceTypeAggregate {
+ msg := fmt.Sprintf("Referenced interface %q is of type %q, expected %q or %q", ifCfg.Name, intf.Spec.Type, v1alpha1.InterfaceTypePhysical, v1alpha1.InterfaceTypeAggregate)
+ conditions.Set(lldp, metav1.Condition{
+ Type: v1alpha1.ConfiguredCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.InvalidInterfaceTypeReason,
+ Message: msg,
+ })
+ return nil, errors.New(msg)
+ }
+ s.Interfaces[ifCfg.Name] = intf
+ }
+
+ m, err := runtime.DefaultUnstructuredConverter.ToUnstructured(s)
+ if err != nil {
+ return nil, fmt.Errorf("failed to convert provider scope to unstructured: %w", err)
+ }
+ u := &unstructured.Unstructured{Object: m}
+ return providerconfig.NewScope(u), nil
+}
diff --git a/internal/providerconfig/cisco/nx/lldpconfig_test.go b/internal/providerconfig/cisco/nx/lldpconfig_test.go
new file mode 100644
index 00000000..e621895e
--- /dev/null
+++ b/internal/providerconfig/cisco/nx/lldpconfig_test.go
@@ -0,0 +1,223 @@
+// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package nx
+
+import (
+ "testing"
+
+ . "github.com/onsi/gomega"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ utilruntime "k8s.io/apimachinery/pkg/util/runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ nxv1alpha "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1"
+ corev1 "github.com/ironcore-dev/network-operator/api/core/v1alpha1"
+)
+
+func newTestScheme(t *testing.T) *runtime.Scheme {
+ t.Helper()
+
+ scheme := runtime.NewScheme()
+ utilruntime.Must(corev1.AddToScheme(scheme))
+ utilruntime.Must(nxv1alpha.AddToScheme(scheme))
+ return scheme
+}
+
+func TestLLDPConfigValidationFunc_Success(t *testing.T) {
+ g := NewWithT(t)
+
+ scheme := newTestScheme(t)
+
+ lldp := &corev1.LLDP{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-lldp",
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: corev1.LLDPSpec{
+ DeviceRef: corev1.LocalObjectReference{Name: "test-device"},
+ AdminState: corev1.AdminStateUp,
+ },
+ }
+
+ cfg := &nxv1alpha.LLDPConfig{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-config",
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: nxv1alpha.LLDPConfigSpec{
+ InterfaceRefs: []nxv1alpha.LLDPInterface{{
+ LocalObjectReference: corev1.LocalObjectReference{Name: "if1"},
+ }},
+ },
+ }
+
+ intf := &corev1.Interface{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "if1",
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: corev1.InterfaceSpec{
+ DeviceRef: corev1.LocalObjectReference{Name: "test-device"},
+ Name: "Ethernet1/1",
+ Type: corev1.InterfaceTypePhysical,
+ },
+ }
+
+ c := fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithObjects(cfg, intf).
+ Build()
+
+ ref := &corev1.TypedLocalObjectReference{
+ APIVersion: nxv1alpha.GroupVersion.String(),
+ Kind: "LLDPConfig",
+ Name: cfg.Name,
+ }
+
+ scope, err := LLDPConfigValidationFunc(t.Context(), c, lldp, ref)
+ g.Expect(err).ToNot(HaveOccurred())
+ g.Expect(scope).NotTo(BeNil())
+
+ // Decode back into LLDPConfigScope
+ var decoded LLDPConfigScope
+ err = scope.Into(&decoded)
+ g.Expect(err).ToNot(HaveOccurred())
+ g.Expect(decoded.Interfaces).To(HaveKey("if1"))
+ g.Expect(decoded.Interfaces["if1"].Spec.DeviceRef.Name).To(Equal("test-device"))
+}
+
+func TestLLDPConfigValidationFunc_ParentNotLLDP(t *testing.T) {
+ g := NewWithT(t)
+
+ scheme := newTestScheme(t)
+
+ c := fake.NewClientBuilder().
+ WithScheme(scheme).
+ Build()
+
+ parent := &corev1.Interface{}
+ ref := &corev1.TypedLocalObjectReference{
+ APIVersion: nxv1alpha.GroupVersion.String(),
+ Kind: "LLDPConfig",
+ Name: "does-not-matter",
+ }
+
+ scope, err := LLDPConfigValidationFunc(t.Context(), c, parent, ref)
+ g.Expect(err).To(HaveOccurred())
+ g.Expect(scope).To(BeNil())
+}
+
+func TestLLDPConfigValidationFunc_InterfaceNotFoundSetsCondition(t *testing.T) {
+ g := NewWithT(t)
+
+ scheme := newTestScheme(t)
+
+ lldp := &corev1.LLDP{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-lldp",
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: corev1.LLDPSpec{
+ DeviceRef: corev1.LocalObjectReference{Name: "test-device"},
+ AdminState: corev1.AdminStateUp,
+ },
+ }
+
+ cfg := &nxv1alpha.LLDPConfig{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-config",
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: nxv1alpha.LLDPConfigSpec{
+ InterfaceRefs: []nxv1alpha.LLDPInterface{{
+ LocalObjectReference: corev1.LocalObjectReference{Name: "missing-if"},
+ }},
+ },
+ }
+
+ c := fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithObjects(cfg).
+ Build()
+
+ ref := &corev1.TypedLocalObjectReference{
+ APIVersion: nxv1alpha.GroupVersion.String(),
+ Kind: "LLDPConfig",
+ Name: cfg.Name,
+ }
+
+ scope, err := LLDPConfigValidationFunc(t.Context(), c, lldp, ref)
+ g.Expect(err).To(HaveOccurred())
+ g.Expect(scope).To(BeNil())
+
+ g.Expect(lldp.Status.Conditions).ToNot(BeEmpty())
+ cond := lldp.Status.Conditions[len(lldp.Status.Conditions)-1]
+ g.Expect(cond.Type).To(Equal(corev1.ConfiguredCondition))
+ g.Expect(cond.Status).To(Equal(metav1.ConditionFalse))
+ g.Expect(cond.Reason).To(Equal(corev1.InterfaceNotFoundReason))
+}
+
+func TestLLDPConfigValidationFunc_CrossDeviceReferenceSetsCondition(t *testing.T) {
+ g := NewWithT(t)
+
+ scheme := newTestScheme(t)
+
+ lldp := &corev1.LLDP{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-lldp",
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: corev1.LLDPSpec{
+ DeviceRef: corev1.LocalObjectReference{Name: "device-a"},
+ AdminState: corev1.AdminStateUp,
+ },
+ }
+
+ cfg := &nxv1alpha.LLDPConfig{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "test-config",
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: nxv1alpha.LLDPConfigSpec{
+ InterfaceRefs: []nxv1alpha.LLDPInterface{{
+ LocalObjectReference: corev1.LocalObjectReference{Name: "if1"},
+ }},
+ },
+ }
+
+ // Interface belongs to a different device
+ intf := &corev1.Interface{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "if1",
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: corev1.InterfaceSpec{
+ DeviceRef: corev1.LocalObjectReference{Name: "device-b"},
+ Name: "Ethernet1/1",
+ Type: corev1.InterfaceTypePhysical,
+ },
+ }
+
+ c := fake.NewClientBuilder().
+ WithScheme(scheme).
+ WithObjects(cfg, intf).
+ Build()
+
+ ref := &corev1.TypedLocalObjectReference{
+ APIVersion: nxv1alpha.GroupVersion.String(),
+ Kind: "LLDPConfig",
+ Name: cfg.Name,
+ }
+
+ scope, err := LLDPConfigValidationFunc(t.Context(), c, lldp, ref)
+ g.Expect(err).To(HaveOccurred())
+ g.Expect(scope).To(BeNil())
+
+ g.Expect(lldp.Status.Conditions).ToNot(BeEmpty())
+ cond := lldp.Status.Conditions[len(lldp.Status.Conditions)-1]
+ g.Expect(cond.Type).To(Equal(corev1.ConfiguredCondition))
+ g.Expect(cond.Status).To(Equal(metav1.ConditionFalse))
+ g.Expect(cond.Reason).To(Equal(corev1.CrossDeviceReferenceReason))
+}
diff --git a/internal/providerconfig/validation_registry.go b/internal/providerconfig/validation_registry.go
new file mode 100644
index 00000000..eb51b329
--- /dev/null
+++ b/internal/providerconfig/validation_registry.go
@@ -0,0 +1,63 @@
+// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package providerconfig
+
+import (
+ "context"
+ "sync"
+
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ corev1 "github.com/ironcore-dev/network-operator/api/core/v1alpha1"
+)
+
+// Scope wraps objects fetched from k8s and validated by registered provider config validators.
+// This allows the provider to remain independent of Kubernetes client libraries.
+type Scope struct {
+ obj *unstructured.Unstructured
+}
+
+// NewScope creates a new Scope from an unstructured object.
+func NewScope(obj *unstructured.Unstructured) *Scope {
+ return &Scope{obj: obj}
+}
+
+// Into converts the underlying unstructured object into the specified type.
+func (s *Scope) Into(v any) error {
+ return runtime.DefaultUnstructuredConverter.FromUnstructured(s.obj.Object, v)
+}
+
+// Raw returns the underlying unstructured object.
+func (s *Scope) Raw() *unstructured.Unstructured {
+ return s.obj
+}
+
+// Validator validates a provider-specific configuration object referenced by a core resource.
+// It receives a client.Reader so that callers can choose whether to use the cached client or
+// the uncached APIReader.
+type Validator func(ctx context.Context, r client.Reader, parent client.Object, ref *corev1.TypedLocalObjectReference) (*Scope, error)
+
+var (
+ mu sync.RWMutex
+ lookup = map[schema.GroupVersionKind]Validator{}
+)
+
+// RegisterValidator registers a provider config validator for the given GVK.
+// Registration should be called from init() in provider config packages.
+func RegisterValidator(gvk schema.GroupVersionKind, fn Validator) {
+ mu.Lock()
+ defer mu.Unlock()
+ lookup[gvk] = fn
+}
+
+// GetValidator returns the registered validator for the GVK, if any.
+func GetValidator(gvk schema.GroupVersionKind) (Validator, bool) {
+ mu.RLock()
+ defer mu.RUnlock()
+ fn, ok := lookup[gvk]
+ return fn, ok
+}
diff --git a/internal/providerconfig/validation_registry_test.go b/internal/providerconfig/validation_registry_test.go
new file mode 100644
index 00000000..5e317294
--- /dev/null
+++ b/internal/providerconfig/validation_registry_test.go
@@ -0,0 +1,43 @@
+// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package providerconfig
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+
+ corev1 "github.com/ironcore-dev/network-operator/api/core/v1alpha1"
+)
+
+func TestRegisterAndGetValidator(t *testing.T) {
+ gvk := schema.GroupVersionKind{Group: "test.example.com", Version: "v1", Kind: "TestConfig"}
+
+ called := false
+ testValidator := func(ctx context.Context, c client.Reader, parent client.Object, ref *corev1.TypedLocalObjectReference) (*Scope, error) {
+ called = true
+ return nil, nil
+ }
+
+ RegisterValidator(gvk, testValidator)
+
+ fn, ok := GetValidator(gvk)
+ assert.True(t, ok, "validator should be found")
+ assert.NotNil(t, fn)
+
+ _, err := fn(context.Background(), nil, nil, nil)
+ assert.NoError(t, err)
+ assert.True(t, called, "validator function should have been called")
+}
+
+func TestGetValidator_NotFound(t *testing.T) {
+ gvk := schema.GroupVersionKind{Group: "unknown.example.com", Version: "v1", Kind: "Unknown"}
+
+ fn, ok := GetValidator(gvk)
+ assert.False(t, ok)
+ assert.Nil(t, fn)
+}