From 2b9fa8ca45c788533cfc442da1df4e86c297a0c1 Mon Sep 17 00:00:00 2001 From: Pujol Date: Mon, 13 Apr 2026 17:29:58 +0200 Subject: [PATCH 1/7] Add hostname to Device status Fetch the configured hostname from the device and populate it in the Device status. The hostname is a configuration item (not state), so it is fetched via GetConfig rather than GetState. This implies one additional "unpacked" gNMI call to the switch. As per [1], it is not possible to issue a `GetRequest` message with different types in the set of requested paths. Note that it is possible to leave the `type` empty and thereby get all data (`CONFIG`, `STATE`, and `OPERATIONAL`). We could implement a `client.GetAny()` method for this case and retrieve all data at once since the models referenced `DeviceInfo` do not have excessive data. However, as this is the only case for such an optimization for now, and the `Device` is infrequently reconciled, this does not seem necessary for now. [1] https://github.com/openconfig/reference/blob/master/rpc/gnmi/gnmi-specification.md#331-the-getrequest-message Signed-off-by: Pujol --- api/core/v1alpha1/device_types.go | 4 ++++ .../crd/devices.networking.metal.ironcore.dev.yaml | 3 +++ .../crd/bases/networking.metal.ironcore.dev_devices.yaml | 3 +++ docs/api-reference/index.md | 1 + internal/controller/core/device_controller.go | 1 + internal/provider/cisco/nxos/provider.go | 7 +++++++ internal/provider/cisco/nxos/system.go | 7 +++++++ internal/provider/provider.go | 2 ++ 8 files changed, 28 insertions(+) diff --git a/api/core/v1alpha1/device_types.go b/api/core/v1alpha1/device_types.go index 218a891fc..b9b362029 100644 --- a/api/core/v1alpha1/device_types.go +++ b/api/core/v1alpha1/device_types.go @@ -128,6 +128,10 @@ type DeviceStatus struct { // +required Phase DevicePhase `json:"phase,omitempty"` + // Hostname is the hostname of the Device. + // +optional + Hostname string `json:"hostname,omitempty"` + // Manufacturer is the manufacturer of the Device. // +optional Manufacturer string `json:"manufacturer,omitempty"` diff --git a/charts/network-operator/templates/crd/devices.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/devices.networking.metal.ironcore.dev.yaml index 2ebac4ace..a623817eb 100644 --- a/charts/network-operator/templates/crd/devices.networking.metal.ironcore.dev.yaml +++ b/charts/network-operator/templates/crd/devices.networking.metal.ironcore.dev.yaml @@ -372,6 +372,9 @@ spec: description: FirmwareVersion is the firmware version running on the Device. type: string + hostname: + description: Hostname is the hostname of the Device. + type: string lastRebootTime: description: LastRebootTime is the timestamp of the last reboot of the Device, if known. diff --git a/config/crd/bases/networking.metal.ironcore.dev_devices.yaml b/config/crd/bases/networking.metal.ironcore.dev_devices.yaml index 2e4ea5d0b..9d07a1234 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_devices.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_devices.yaml @@ -369,6 +369,9 @@ spec: description: FirmwareVersion is the firmware version running on the Device. type: string + hostname: + description: Hostname is the hostname of the Device. + type: string lastRebootTime: description: LastRebootTime is the timestamp of the last reboot of the Device, if known. diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index bdd381f63..d4443ed81 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -1038,6 +1038,7 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `phase` _[DevicePhase](#devicephase)_ | Phase represents the current phase of the Device. | Pending | Enum: [Pending Provisioning Running Failed Provisioned]
Required: \{\}
| +| `hostname` _string_ | Hostname is the hostname of the Device. | | Optional: \{\}
| | `manufacturer` _string_ | Manufacturer is the manufacturer of the Device. | | Optional: \{\}
| | `model` _string_ | Model is the model identifier of the Device. | | Optional: \{\}
| | `serialNumber` _string_ | SerialNumber is the serial number of the Device. | | Optional: \{\}
| diff --git a/internal/controller/core/device_controller.go b/internal/controller/core/device_controller.go index 111e98e5b..c05ef0e96 100644 --- a/internal/controller/core/device_controller.go +++ b/internal/controller/core/device_controller.go @@ -297,6 +297,7 @@ func (r *DeviceReconciler) reconcile(ctx context.Context, device *v1alpha1.Devic if err != nil { return fmt.Errorf("failed to get device info: %w", err) } + device.Status.Hostname = info.Hostname device.Status.Manufacturer = info.Manufacturer device.Status.Model = info.Model device.Status.SerialNumber = info.SerialNumber diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index 502d77478..badb81a97 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -193,15 +193,22 @@ func (p *Provider) ListPorts(ctx context.Context) ([]provider.DevicePort, error) } func (p *Provider) GetDeviceInfo(ctx context.Context) (*provider.DeviceInfo, error) { + h := new(Hostname) m := new(Model) s := new(SerialNumber) fw := new(FirmwareVersion) + + // Hostname is a config item, not state + if err := p.client.GetConfig(ctx, h); err != nil { + return nil, err + } if err := p.client.GetState(ctx, m, s, fw); err != nil { return nil, err } return &provider.DeviceInfo{ Manufacturer: Manufacturer, + Hostname: string(*h), Model: string(*m), SerialNumber: string(*s), FirmwareVersion: string(*fw), diff --git a/internal/provider/cisco/nxos/system.go b/internal/provider/cisco/nxos/system.go index 5b4acb950..8d8fe2b57 100644 --- a/internal/provider/cisco/nxos/system.go +++ b/internal/provider/cisco/nxos/system.go @@ -39,6 +39,13 @@ func (s *SystemJumboMTU) Default() { *s = 9216 } +// Hostname is the configured hostname of the device. +type Hostname string + +func (*Hostname) XPath() string { + return "System/name" +} + // Model is the chassis model of the device, e.g. "N9K-C9336C-FX2". type Model string diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 50005701b..2325ac50f 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -65,6 +65,8 @@ type DevicePort struct { } type DeviceInfo struct { + // Hostname is the hostname of the device. + Hostname string // Manufacturer is the manufacturer of the device, e.g. "Cisco". Manufacturer string // Model is the model of the device, e.g. "N9K-C9332D-GX2B". From 74e1e2c2292a2635799ed29f85195e71321dd860 Mon Sep 17 00:00:00 2001 From: Pujol Date: Mon, 13 Apr 2026 16:34:44 +0200 Subject: [PATCH 2/7] Fix requeue interval in `DHCPRelay` controller The `dhcprelay_controller` was not aligned with the design of the other controllers. When the device is locked it should requeue using jitter and also with priority `LockWaitPriorityDefault`. Adds a test to verify that reconciliation is triggered when an interface gets configured (after it reconciles once a pending `vrf` resource is created). Signed-off-by: Pujol --- .../controller/core/dhcprelay_controller.go | 2 +- .../core/dhcprelay_controller_test.go | 66 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/internal/controller/core/dhcprelay_controller.go b/internal/controller/core/dhcprelay_controller.go index 18128c201..67dd967b0 100644 --- a/internal/controller/core/dhcprelay_controller.go +++ b/internal/controller/core/dhcprelay_controller.go @@ -116,7 +116,7 @@ func (r *DHCPRelayReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( if err := r.Locker.AcquireLock(ctx, device.Name, "dhcprelay-controller"); err != nil { if errors.Is(err, resourcelock.ErrLockAlreadyHeld) { log.V(3).Info("Device is already locked, requeuing reconciliation") - return ctrl.Result{RequeueAfter: time.Second}, nil + return ctrl.Result{RequeueAfter: Jitter(time.Second), Priority: new(LockWaitPriorityDefault)}, nil } log.Error(err, "Failed to acquire device lock") return ctrl.Result{}, err diff --git a/internal/controller/core/dhcprelay_controller_test.go b/internal/controller/core/dhcprelay_controller_test.go index 6554eb8b1..e440f1b03 100644 --- a/internal/controller/core/dhcprelay_controller_test.go +++ b/internal/controller/core/dhcprelay_controller_test.go @@ -1122,5 +1122,71 @@ var _ = Describe("DHCPRelay Controller", func() { g.Expect(cond.Message).To(ContainSubstring("not configured")) }).Should(Succeed()) }) + + It("Should re-reconcile DHCPRelay when Interface becomes configured (watch trigger)", func() { + By("Creating DHCPRelay referencing a non-configured Interface") + dhcprelay := &v1alpha1.DHCPRelay{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-dhcprelay-intfnr-watch-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DHCPRelaySpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Servers: []string{"192.168.1.1"}, + InterfaceRefs: []v1alpha1.LocalObjectReference{ + {Name: interfaceName}, + }, + }, + } + Expect(k8sClient.Create(ctx, dhcprelay)).To(Succeed()) + resourceName = dhcprelay.Name + resourceKey = client.ObjectKey{Name: resourceName, Namespace: metav1.NamespaceDefault} + + By("Verifying DHCPRelay is not ready due to non-configured Interface") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, dhcprelay) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(dhcprelay.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(v1alpha1.WaitingForDependenciesReason)) + }).Should(Succeed()) + + By("Creating the VRF to make the Interface configured") + vrf := &v1alpha1.VRF{ + ObjectMeta: metav1.ObjectMeta{ + Name: nonExistentVrfName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.VRFSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Name: "VRF-TEST", + }, + } + Expect(k8sClient.Create(ctx, vrf)).To(Succeed()) + + By("Waiting for Interface to become configured") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, interfaceKey, intf) + g.Expect(err).NotTo(HaveOccurred()) + cond := meta.FindStatusCondition(intf.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Verifying DHCPRelay becomes ready after Interface is configured (watch triggered re-reconciliation)") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, dhcprelay) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(dhcprelay.Status.Conditions, v1alpha1.ReadyCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Cleaning up the VRF resource") + Expect(k8sClient.Delete(ctx, vrf)).To(Succeed()) + }) }) }) From a25a45006ad8435481ada4d2f12cadca94574ffb Mon Sep 17 00:00:00 2001 From: Pujol Date: Mon, 4 May 2026 11:19:33 +0200 Subject: [PATCH 3/7] Extend Interface API with LLDP info Add a `neighbors` field to the Interface status containing LLDP neighbor information derived from TLVs: chassis ID, port ID, system name, and expiration time based on TTL. As per 52eae24, users can annotate or label an interface resource with expected neighbor information. This can be cross-validated against the LLDP data that is now stored in the status. We change the annotation format to accept either the chassis ID field or the system name. According to the standard [1], the chassis ID itself can represent different types, like the MAC address, the interface name or the chassis component among others (see Sec. "8.5.2.2 Chassis ID subtype" for details). As information like MAC is not always immediately available, we opt for also allowing the user to use the sysName instead. The rationale is that the hostname is typically configured by the operator, the user, or a known provisioning process. As a result, controller will check the value in the annotation for both cases. The exact mechanism is detailed in the next commit. The status includes a validation field summarizing whether the neighbor could be validated and its result. While TTL is a mandatory field in LLDPDUs, we exclude it from the status because its value decreases continuously on the device. Instead, we compute an ExpirationTime (current time + TTL) which is more meaningful for users. Note that this value is recomputed on each reconcile - a predicate is added later to prevent this from causing infinite reconciliation loops. This design slightly deviates from OpenConfig [2]. In there all LLDP information is contained in the `lldp` subtree. We have decided that configuration is provided by the `lldp` resource. However, the adjacency data is put into the `interface` status and retrieved by its controller. This simplifies the design by removing a dependency towards the `lldp` resource. Notice that if a user enables LLDP by means other than the operator, the data will appear in the interface even if the `lldp` resource does not exist. [1] https://ieeexplore.ieee.org/document/7433915 [2] https://openconfig.net/projects/models/schemadocs/yangdoc/openconfig-lldp.html#lldp-interfaces-interface-neighbors-neighbor-id Signed-off-by: Pujol --- api/core/v1alpha1/groupversion_info.go | 5 +- api/core/v1alpha1/interface_types.go | 154 ++++++++++++++++++ api/core/v1alpha1/zz_generated.deepcopy.go | 23 +++ ...erfaces.networking.metal.ironcore.dev.yaml | 83 ++++++++++ ...working.metal.ironcore.dev_interfaces.yaml | 83 ++++++++++ docs/api-reference/index.md | 92 +++++++++++ 6 files changed, 438 insertions(+), 2 deletions(-) diff --git a/api/core/v1alpha1/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go index b8a066d42..3fdaf4800 100644 --- a/api/core/v1alpha1/groupversion_info.go +++ b/api/core/v1alpha1/groupversion_info.go @@ -80,8 +80,9 @@ const PhysicalInterfaceNeighborLabel = "networking.metal.ironcore.dev/interface- // PhysicalInterfaceNeighborRawAnnotation stores raw neighbor identification for interfaces // connected to unmanaged devices (devices without an Interface resource). -// The value format is "chassisID::portID" where: -// - chassisID: The LLDP chassis identifier (MAC address or system name) +// The value format is "(chassisID|sysName)::portID" where: +// - chassisID: The LLDP Chassis ID (e.g. MAC address) +// - sysName: The LLDP System Name (optional TLV) // - portID: The LLDP port identifier (interface name, alias, or MAC address) // // Example: "00:1a:2b:3c:4d:5e::Ethernet1/1" or "spine-switch-01::Ethernet48" diff --git a/api/core/v1alpha1/interface_types.go b/api/core/v1alpha1/interface_types.go index 18ddcd4f6..410593ee2 100644 --- a/api/core/v1alpha1/interface_types.go +++ b/api/core/v1alpha1/interface_types.go @@ -381,8 +381,162 @@ type InterfaceStatus struct { // This field only applies to physical interfaces that are part of an aggregate interface. // +optional MemberOf *LocalObjectReference `json:"memberOf,omitempty"` + + // Neighbors contains a list of neighbor interfaces connected to this interface and discovered with LLDP. + // If a single interface has multiple neighbor adjacencies, we validate each adjacency against the same one label/annotation. + // +optional + Neighbors []Neighbor `json:"neighbors,omitempty"` } +// Neighbor represents an LLDP neighbor discovered on an interface. +// It includes the results of the LLDP adjacency validation against the expected neighbor information from the interface's labels or annotations. +type Neighbor struct { + // ChassisID contains an octet string indicating the specific chassis ID of the neighbor. + // Its semantics are defined by the ChassisIDType field. + // +required + ChassisID string `json:"chassisId"` + + // ChassisIDType represents the chassis ID subtype. + // Full list of types can be found in IEEE 802.1AB-2016 Table 8-2. + // +required + // +kubebuilder:validation:Enum=ChassisComponent;InterfaceAlias;PortComponent;MACAddress;NetworkAddress;InterfaceName;Local + ChassisIDType ChassisIDType `json:"chassisIdType"` + + // PortID contains an octet string indicating the specific port ID of the neighbor. + // Its semantics are defined by the PortIDType field. + // +required + PortID string `json:"portId"` + + // PortIDType represents the port ID subtype. + // Full list of types can be found in IEEE 802.1AB-2016 Table 8-3. + // +required + // +kubebuilder:validation:Enum=InterfaceAlias;PortComponent;MACAddress;NetworkAddress;InterfaceName;AgentCircuitID;Local + PortIDType PortIDType `json:"portIdType"` + + // SystemName is an alpha-numeric string that indicates the system’s administratively assigned name. + // +optional + SystemName string `json:"systemName,omitempty"` + + // SystemDescription is a textual description of the neighbor, should include hardware and software information + // If the device supports IETF RFC 3418, this is the `sysDescr` + // +optional + SystemDescription string `json:"systemDescription,omitempty"` + + // PortDescription contains the port description of the neighbor port. + // If the device supports IETF RFC 2863, this is the `ifDescr` + // +optional + PortDescription string `json:"portDescription,omitempty"` + + // ExpirationTime is the time when the LLDP neighbor information expires. + // It is calculated based on the TTL. + // +required + ExpirationTime metav1.Time `json:"expirationTime"` + + // Validation indicates whether the LLDP neighbor information matches the information in the label or annotations of the interface. + // Empty when no validation source (label or annotation) is configured on the interface. + // +optional + Validation NeighborValidation `json:"validation,omitempty"` +} + +// ChassisIDType represents the chassis ID subtype for LLDP neighbor information. +// See IEEE 802.1AB-2016 section 8.5.2.2 for details. +type ChassisIDType string + +const ( + // ChassisIDTypeChassisComponent is `EntPhysicalAlias` when entPhysClass has a value of ‘chassis(3)’ (IETF RFC 6933) + ChassisIDTypeChassisComponent ChassisIDType = "ChassisComponent" + // ChassisIDTypeInterfaceAlias is `ifAlias` (IETF RFC 2863) + ChassisIDTypeInterfaceAlias ChassisIDType = "InterfaceAlias" + // ChassisIDTypePortComponent is `entPhysicalAlias` when `entPhysicalClass` has a value ‘port(10)’ or ‘backplane(4)’ (IETF RFC 6933) + ChassisIDTypePortComponent ChassisIDType = "PortComponent" + // ChassisIDTypeMACAddress is the MAC address (IEEE Std 802) + ChassisIDTypeMACAddress ChassisIDType = "MACAddress" + // ChassisIDTypeNetworkAddress is an octet string representation of a particular network family and address. + ChassisIDTypeNetworkAddress ChassisIDType = "NetworkAddress" + // ChassisIDTypeInterfaceName is `ifName` (IETF RFC 2863) + ChassisIDTypeInterfaceName ChassisIDType = "InterfaceName" + // ChassisIDTypeLocal is an alphanumeric string that and is locally assigned + ChassisIDTypeLocal ChassisIDType = "Local" +) + +func ChassisIDTypeFromValue(value uint8) (ChassisIDType, bool) { + switch value { + case 1: + return ChassisIDTypeChassisComponent, true + case 2: + return ChassisIDTypeInterfaceAlias, true + case 3: + return ChassisIDTypePortComponent, true + case 4: + return ChassisIDTypeMACAddress, true + case 5: + return ChassisIDTypeNetworkAddress, true + case 6: + return ChassisIDTypeInterfaceName, true + case 7: + return ChassisIDTypeLocal, true + default: + return "", false + } +} + +// PortIDType represents the port ID subtype for LLDP neighbor information. +// See IEEE 802.1AB-2016 section 8.5.3.2 for details. +type PortIDType string + +const ( + // PortIDTypeInterfaceAlias is `ifAlias` (IETF RFC 2863) + PortIDTypeInterfaceAlias PortIDType = "InterfaceAlias" + // PortIDTypePortComponent is `entPhysicalAlias` when `entPhysicalClass` has a value ‘port(10)’ or ‘backplane(4)’ (IETF RFC 6933) + PortIDTypePortComponent PortIDType = "PortComponent" + // PortIDTypeMACAddress is the MAC address (IEEE Std 802) + PortIDTypeMACAddress PortIDType = "MACAddress" + // PortIDTypeNetworkAddress is an octet string representation of a particular network family and address. + PortIDTypeNetworkAddress PortIDType = "NetworkAddress" + // PortIDTypeInterfaceName is `ifName` (IETF RFC 2863) + PortIDTypeInterfaceName PortIDType = "InterfaceName" + // PortIDTypeAgentCircuitID is the agent circuit ID (IETF RFC 3046) + PortIDTypeAgentCircuitID PortIDType = "AgentCircuitID" + // PortIDTypeLocal is an alphanumeric string that and is locally assigned + PortIDTypeLocal PortIDType = "Local" +) + +func PortIDTypeFromValue(value uint8) (PortIDType, bool) { + switch value { + case 1: + return PortIDTypeInterfaceAlias, true + case 2: + return PortIDTypePortComponent, true + case 3: + return PortIDTypeMACAddress, true + case 4: + return PortIDTypeNetworkAddress, true + case 5: + return PortIDTypeInterfaceName, true + case 6: + return PortIDTypeAgentCircuitID, true + case 7: + return PortIDTypeLocal, true + default: + return "", false + } +} + +// NeighborValidation represents the result of the validation of the LLDP neighbor information against the expected values from the interface's labels or annotations. +// +kubebuilder:validation:Enum=NotFound;Verified;DeviceMismatch;PortMismatch +type NeighborValidation string + +const ( + // NeighborNotFound indicates that the resource referenced in the PhysicalInterfaceNeighborLabel label could not be found. + NeighborNotFound NeighborValidation = "NotFound" + // NeighborVerified indicates that the LLDP neighbor information has been verified and matches the expected values. + NeighborVerified NeighborValidation = "Verified" + // NeighborDeviceMismatch indicates that the LLDP neighbor information does not match the expected values, indicating a potential misconfiguration or unexpected neighbor. + NeighborDeviceMismatch NeighborValidation = "DeviceMismatch" + // NeighborPortMismatch indicates that the LLDP neighbor information does not match the expected port information, indicating a potential misconfiguration or unexpected neighbor. + NeighborPortMismatch NeighborValidation = "PortMismatch" +) + // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:path=interfaces diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go index f3ffed091..3571a97c0 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -1903,6 +1903,13 @@ func (in *InterfaceStatus) DeepCopyInto(out *InterfaceStatus) { *out = new(LocalObjectReference) **out = **in } + if in.Neighbors != nil { + in, out := &in.Neighbors, &out.Neighbors + *out = make([]Neighbor, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new InterfaceStatus. @@ -2377,6 +2384,22 @@ func (in *NameServer) DeepCopy() *NameServer { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Neighbor) DeepCopyInto(out *Neighbor) { + *out = *in + in.ExpirationTime.DeepCopyInto(&out.ExpirationTime) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Neighbor. +func (in *Neighbor) DeepCopy() *Neighbor { + if in == nil { + return nil + } + out := new(Neighbor) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NetworkVirtualizationEdge) DeepCopyInto(out *NetworkVirtualizationEdge) { *out = *in diff --git a/charts/network-operator/templates/crd/interfaces.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/interfaces.networking.metal.ironcore.dev.yaml index 918df2e8d..fca4f0650 100644 --- a/charts/network-operator/templates/crd/interfaces.networking.metal.ironcore.dev.yaml +++ b/charts/network-operator/templates/crd/interfaces.networking.metal.ironcore.dev.yaml @@ -625,6 +625,89 @@ spec: - name type: object x-kubernetes-map-type: atomic + neighbors: + description: |- + Neighbors contains a list of neighbor interfaces connected to this interface and discovered with LLDP. + If a single interface has multiple neighbor adjacencies, we validate each adjacency against the same one label/annotation. + items: + description: |- + Neighbor represents an LLDP neighbor discovered on an interface. + It includes the results of the LLDP adjacency validation against the expected neighbor information from the interface's labels or annotations. + properties: + chassisId: + description: |- + ChassisID contains an octet string indicating the specific chassis ID of the neighbor. + Its semantics are defined by the ChassisIDType field. + type: string + chassisIdType: + description: |- + ChassisIDType represents the chassis ID subtype. + Full list of types can be found in IEEE 802.1AB-2016 Table 8-2. + enum: + - ChassisComponent + - InterfaceAlias + - PortComponent + - MACAddress + - NetworkAddress + - InterfaceName + - Local + type: string + expirationTime: + description: |- + ExpirationTime is the time when the LLDP neighbor information expires. + It is calculated based on the TTL. + format: date-time + type: string + portDescription: + description: |- + PortDescription contains the port description of the neighbor port. + If the device supports IETF RFC 2863, this is the `ifDescr` + type: string + portId: + description: |- + PortID contains an octet string indicating the specific port ID of the neighbor. + Its semantics are defined by the PortIDType field. + type: string + portIdType: + description: |- + PortIDType represents the port ID subtype. + Full list of types can be found in IEEE 802.1AB-2016 Table 8-3. + enum: + - InterfaceAlias + - PortComponent + - MACAddress + - NetworkAddress + - InterfaceName + - AgentCircuitID + - Local + type: string + systemDescription: + description: |- + SystemDescription is a textual description of the neighbor, should include hardware and software information + If the device supports IETF RFC 3418, this is the `sysDescr` + type: string + systemName: + description: SystemName is an alpha-numeric string that indicates + the system’s administratively assigned name. + type: string + validation: + description: |- + Validation indicates whether the LLDP neighbor information matches the information in the label or annotations of the interface. + Empty when no validation source (label or annotation) is configured on the interface. + enum: + - NotFound + - Verified + - DeviceMismatch + - PortMismatch + type: string + required: + - chassisId + - chassisIdType + - expirationTime + - portId + - portIdType + type: object + type: array type: object required: - spec diff --git a/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml b/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml index ab77c10b5..4903efa77 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_interfaces.yaml @@ -622,6 +622,89 @@ spec: - name type: object x-kubernetes-map-type: atomic + neighbors: + description: |- + Neighbors contains a list of neighbor interfaces connected to this interface and discovered with LLDP. + If a single interface has multiple neighbor adjacencies, we validate each adjacency against the same one label/annotation. + items: + description: |- + Neighbor represents an LLDP neighbor discovered on an interface. + It includes the results of the LLDP adjacency validation against the expected neighbor information from the interface's labels or annotations. + properties: + chassisId: + description: |- + ChassisID contains an octet string indicating the specific chassis ID of the neighbor. + Its semantics are defined by the ChassisIDType field. + type: string + chassisIdType: + description: |- + ChassisIDType represents the chassis ID subtype. + Full list of types can be found in IEEE 802.1AB-2016 Table 8-2. + enum: + - ChassisComponent + - InterfaceAlias + - PortComponent + - MACAddress + - NetworkAddress + - InterfaceName + - Local + type: string + expirationTime: + description: |- + ExpirationTime is the time when the LLDP neighbor information expires. + It is calculated based on the TTL. + format: date-time + type: string + portDescription: + description: |- + PortDescription contains the port description of the neighbor port. + If the device supports IETF RFC 2863, this is the `ifDescr` + type: string + portId: + description: |- + PortID contains an octet string indicating the specific port ID of the neighbor. + Its semantics are defined by the PortIDType field. + type: string + portIdType: + description: |- + PortIDType represents the port ID subtype. + Full list of types can be found in IEEE 802.1AB-2016 Table 8-3. + enum: + - InterfaceAlias + - PortComponent + - MACAddress + - NetworkAddress + - InterfaceName + - AgentCircuitID + - Local + type: string + systemDescription: + description: |- + SystemDescription is a textual description of the neighbor, should include hardware and software information + If the device supports IETF RFC 3418, this is the `sysDescr` + type: string + systemName: + description: SystemName is an alpha-numeric string that indicates + the system’s administratively assigned name. + type: string + validation: + description: |- + Validation indicates whether the LLDP neighbor information matches the information in the label or annotations of the interface. + Empty when no validation source (label or annotation) is configured on the interface. + enum: + - NotFound + - Verified + - DeviceMismatch + - PortMismatch + type: string + required: + - chassisId + - chassisIdType + - expirationTime + - portId + - portIdType + type: object + type: array type: object required: - spec diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index d4443ed81..b333d37e9 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -763,6 +763,29 @@ _Appears in:_ | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array_ | The conditions are a list of status objects that describe the state of the Certificate. | | Optional: \{\}
| +#### ChassisIDType + +_Underlying type:_ _string_ + +ChassisIDType represents the chassis ID subtype for LLDP neighbor information. +See IEEE 802.1AB-2016 section 8.5.2.2 for details. + + + +_Appears in:_ +- [Neighbor](#neighbor) + +| Field | Description | +| --- | --- | +| `ChassisComponent` | ChassisIDTypeChassisComponent is `EntPhysicalAlias` when entPhysClass has a value of ‘chassis(3)’ (IETF RFC 6933)
| +| `InterfaceAlias` | ChassisIDTypeInterfaceAlias is `ifAlias` (IETF RFC 2863)
| +| `PortComponent` | ChassisIDTypePortComponent is `entPhysicalAlias` when `entPhysicalClass` has a value ‘port(10)’ or ‘backplane(4)’ (IETF RFC 6933)
| +| `MACAddress` | ChassisIDTypeMACAddress is the MAC address (IEEE Std 802)
| +| `NetworkAddress` | ChassisIDTypeNetworkAddress is an octet string representation of a particular network family and address.
| +| `InterfaceName` | ChassisIDTypeInterfaceName is `ifName` (IETF RFC 2863)
| +| `Local` | ChassisIDTypeLocal is an alphanumeric string that and is locally assigned
| + + #### ChecksumType _Underlying type:_ _string_ @@ -1508,6 +1531,7 @@ _Appears in:_ | --- | --- | --- | --- | | `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array_ | The conditions are a list of status objects that describe the state of the Interface. | | Optional: \{\}
| | `memberOf` _[LocalObjectReference](#localobjectreference)_ | MemberOf references the aggregate interface this interface is a member of, if any.
This field only applies to physical interfaces that are part of an aggregate interface. | | Optional: \{\}
| +| `neighbors` _[Neighbor](#neighbor) array_ | Neighbors contains a list of neighbor interfaces connected to this interface and discovered with LLDP.
If a single interface has multiple neighbor adjacencies, we validate each adjacency against the same one label/annotation. | | Optional: \{\}
| #### InterfaceType @@ -1911,6 +1935,51 @@ _Appears in:_ | `vrfName` _string_ | The name of the vrf used to communicate with the DNS server. | | MaxLength: 63
MinLength: 1
Optional: \{\}
| +#### Neighbor + + + +Neighbor represents an LLDP neighbor discovered on an interface. +It includes the results of the LLDP adjacency validation against the expected neighbor information from the interface's labels or annotations. + + + +_Appears in:_ +- [InterfaceStatus](#interfacestatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `chassisId` _string_ | ChassisID contains an octet string indicating the specific chassis ID of the neighbor.
Its semantics are defined by the ChassisIDType field. | | Required: \{\}
| +| `chassisIdType` _[ChassisIDType](#chassisidtype)_ | ChassisIDType represents the chassis ID subtype.
Full list of types can be found in IEEE 802.1AB-2016 Table 8-2. | | Enum: [ChassisComponent InterfaceAlias PortComponent MACAddress NetworkAddress InterfaceName Local]
Required: \{\}
| +| `portId` _string_ | PortID contains an octet string indicating the specific port ID of the neighbor.
Its semantics are defined by the PortIDType field. | | Required: \{\}
| +| `portIdType` _[PortIDType](#portidtype)_ | PortIDType represents the port ID subtype.
Full list of types can be found in IEEE 802.1AB-2016 Table 8-3. | | Enum: [InterfaceAlias PortComponent MACAddress NetworkAddress InterfaceName AgentCircuitID Local]
Required: \{\}
| +| `systemName` _string_ | SystemName is an alpha-numeric string that indicates the system’s administratively assigned name. | | Optional: \{\}
| +| `systemDescription` _string_ | SystemDescription is a textual description of the neighbor, should include hardware and software information
If the device supports IETF RFC 3418, this is the `sysDescr` | | Optional: \{\}
| +| `portDescription` _string_ | PortDescription contains the port description of the neighbor port.
If the device supports IETF RFC 2863, this is the `ifDescr` | | Optional: \{\}
| +| `expirationTime` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#time-v1-meta)_ | ExpirationTime is the time when the LLDP neighbor information expires.
It is calculated based on the TTL. | | Required: \{\}
| +| `validation` _[NeighborValidation](#neighborvalidation)_ | Validation indicates whether the LLDP neighbor information matches the information in the label or annotations of the interface.
Empty when no validation source (label or annotation) is configured on the interface. | | Enum: [NotFound Verified DeviceMismatch PortMismatch]
Optional: \{\}
| + + +#### NeighborValidation + +_Underlying type:_ _string_ + +NeighborValidation represents the result of the validation of the LLDP neighbor information against the expected values from the interface's labels or annotations. + +_Validation:_ +- Enum: [NotFound Verified DeviceMismatch PortMismatch] + +_Appears in:_ +- [Neighbor](#neighbor) + +| Field | Description | +| --- | --- | +| `NotFound` | NeighborNotFound indicates that the resource referenced in the PhysicalInterfaceNeighborLabel label could not be found.
| +| `Verified` | NeighborVerified indicates that the LLDP neighbor information has been verified and matches the expected values.
| +| `DeviceMismatch` | NeighborDeviceMismatch indicates that the LLDP neighbor information does not match the expected values, indicating a potential misconfiguration or unexpected neighbor.
| +| `PortMismatch` | NeighborPortMismatch indicates that the LLDP neighbor information does not match the expected port information, indicating a potential misconfiguration or unexpected neighbor.
| + + #### NetworkVirtualizationEdge @@ -2275,6 +2344,29 @@ _Appears in:_ | `actions` _[PolicyActions](#policyactions)_ | Actions define what to do when conditions match. | | Required: \{\}
| +#### PortIDType + +_Underlying type:_ _string_ + +PortIDType represents the port ID subtype for LLDP neighbor information. +See IEEE 802.1AB-2016 section 8.5.3.2 for details. + + + +_Appears in:_ +- [Neighbor](#neighbor) + +| Field | Description | +| --- | --- | +| `InterfaceAlias` | PortIDTypeInterfaceAlias is `ifAlias` (IETF RFC 2863)
| +| `PortComponent` | PortIDTypePortComponent is `entPhysicalAlias` when `entPhysicalClass` has a value ‘port(10)’ or ‘backplane(4)’ (IETF RFC 6933)
| +| `MACAddress` | PortIDTypeMACAddress is the MAC address (IEEE Std 802)
| +| `NetworkAddress` | PortIDTypeNetworkAddress is an octet string representation of a particular network family and address.
| +| `InterfaceName` | PortIDTypeInterfaceName is `ifName` (IETF RFC 2863)
| +| `AgentCircuitID` | PortIDTypeAgentCircuitID is the agent circuit ID (IETF RFC 3046)
| +| `Local` | PortIDTypeLocal is an alphanumeric string that and is locally assigned
| + + #### PrefixEntry From b4625edfec026eaee23f4a09387536020b4aef25 Mon Sep 17 00:00:00 2001 From: Pujol Date: Mon, 4 May 2026 11:29:35 +0200 Subject: [PATCH 4/7] Implement neighbor validation through LLDP data Instruct the interface controller to retrieve the LLDP adjacency information via the provider and populate the status accordingly. The neighbor validation is implemented as a non-blocking operation. Errors are logged but they don't prevent reconciliation of the interface. The validation checks first if the resource has a label. The label is used to perform a validation against an interface resource. If this fails, then the controller falls back to check the annotation. The annotation is used to validate neighbors that are not a kubernetes resource (see 52eae24). The operator does not actively track TTL expiration. Instead, it relies on the device to remove expired neighbors and the periodic requeue interval to sync the status. The ExpirationTime field is informational for users to know when a neighbor will disappear. Note that neighbor entries may become stale if the interface is not requeued in time - the status reflects the last fetched state, not real-time data. This commit also adds a missing watch for the LLDP controller. Now, if the LLDP resource references an interface resource that has not been created, the LLDP resource will be reconciled once the missing dependency is created. This was problematic during bootstraps, as the lldp feature was not installed if a single interface was missing. We also add tests for LLDP operational status degradation, verifying that the controller correctly sets OperationalCondition to False when the device reports LLDP is down, and recovers when it comes back up. Signed-off-by: Pujol --- .../controller/core/interface_controller.go | 147 +++++++- internal/controller/core/lldp_controller.go | 83 ++++- .../controller/core/lldp_controller_test.go | 352 ++++++++++++++++++ internal/controller/core/suite_test.go | 39 +- 4 files changed, 600 insertions(+), 21 deletions(-) diff --git a/internal/controller/core/interface_controller.go b/internal/controller/core/interface_controller.go index 9cda93ac8..ca049d739 100644 --- a/internal/controller/core/interface_controller.go +++ b/internal/controller/core/interface_controller.go @@ -9,6 +9,7 @@ import ( "fmt" "net/netip" "slices" + "strings" "time" "k8s.io/apimachinery/pkg/api/equality" @@ -541,7 +542,22 @@ func (r *InterfaceReconciler) reconcile(ctx context.Context, s *scope) (reterr e return fmt.Errorf("failed to get interface status: %w", err) } - cond = metav1.Condition{ + r.reconcileInterfaceStatus(ctx, s, &status) + + return nil +} + +func (r *InterfaceReconciler) reconcileInterfaceStatus(ctx context.Context, s *scope, status *provider.InterfaceStatus) { + // Neighbor adjacencies is only metadata and should not prevent reconciliation + if s.Interface.Spec.Type == v1alpha1.InterfaceTypePhysical && len(status.LLDPAdjacencies) > 0 { + if err := r.updateNeighborAdjacenciesStatus(ctx, s, status); err != nil { + ctrl.LoggerFrom(ctx).Error(err, "failed to update neighbor adjacency status", "interface", klog.KObj(s.Interface)) + } + } else { + s.Interface.Status.Neighbors = nil + } + + cond := metav1.Condition{ Type: v1alpha1.OperationalCondition, Status: metav1.ConditionTrue, Reason: v1alpha1.OperationalReason, @@ -556,8 +572,135 @@ func (r *InterfaceReconciler) reconcile(ctx context.Context, s *scope) (reterr e cond.Message = fmt.Sprintf("Device returned %q", status.OperMessage) } conditions.Set(s.Interface, cond) +} - return nil +// updateNeighborAdjacenciesStatus updates the Interface status with the LLDP neighbor adjacencies returned by the provider. +// It validates the adjacencies by looking at the corresponding label/annotation. +// It first attempts to validate through label (neighbor is managed by the operator and exists as a kubernetes resource). +// If that fails, it attempts to validate through annotation (neighbor is not managed by the operator). +// Only returns an error if there is an issue during the validation process, but does not return an error if the validation fails (i.e. the adjacency is marked as invalid). +func (r *InterfaceReconciler) updateNeighborAdjacenciesStatus(ctx context.Context, s *scope, status *provider.InterfaceStatus) error { + type neighborKey struct{ ChassisID, PortID string } + + existingNeighbors := make(map[neighborKey]v1alpha1.Neighbor) + for _, n := range s.Interface.Status.Neighbors { + existingNeighbors[neighborKey{n.ChassisID, n.PortID}] = n + } + + log := ctrl.LoggerFrom(ctx) + if len(status.LLDPAdjacencies) > 1 { + log.V(1).Info("Multiple LLDP adjacencies found for a single interface, will validate each adjacency against one single label/annotation", "interface", klog.KObj(s.Interface), "adjacencyCount", len(status.LLDPAdjacencies)) + } + + var errs []error + neighbors := make([]v1alpha1.Neighbor, 0, len(status.LLDPAdjacencies)) + for _, adj := range status.LLDPAdjacencies { + chassisIDType, ok := v1alpha1.ChassisIDTypeFromValue(adj.ChassisIDType) + if !ok { + log.V(1).Info("Skipping LLDP adjacency with unknown chassis ID type", "chassisID", adj.ChassisID, "chassisIDType", adj.ChassisIDType) + continue + } + portIDType, ok := v1alpha1.PortIDTypeFromValue(adj.PortIDType) + if !ok { + log.V(1).Info("Skipping LLDP adjacency with unknown port ID type", "chassisID", adj.ChassisID, "portIDType", adj.PortIDType) + continue + } + + adjacency := v1alpha1.Neighbor{ + SystemName: adj.SysName, + SystemDescription: adj.SysDescription, + ChassisID: adj.ChassisID, + ChassisIDType: chassisIDType, + PortID: adj.PortID, + PortIDType: portIDType, + PortDescription: adj.PortDescription, + ExpirationTime: metav1.NewTime(time.Now().Add(adj.TTL).Truncate(time.Second)), + } + + // NOTE: the operator runs a single provider currently, so s.Provider is used for the + // remote device as well. If multi-provider support is added, the remote device's + // provider must be resolved here instead. + if neighborLabelValue, ok := s.Interface.Labels[v1alpha1.PhysicalInterfaceNeighborLabel]; ok { + var err error + if adjacency.Validation, err = r.validateLLDPAdjacencyThroughLabel(ctx, s.Provider, s.Interface, &adjacency, neighborLabelValue); err != nil { + errs = append(errs, fmt.Errorf("failed to validate LLDP adjacency %q/%q through label %q: %w", adj.ChassisID, adj.PortID, neighborLabelValue, err)) + } + } + + if neighborAnnotationValue, ok := s.Interface.Annotations[v1alpha1.PhysicalInterfaceNeighborRawAnnotation]; ok && adjacency.Validation == "" { + var err error + if adjacency.Validation, err = r.validateLLDPAdjacencyThroughAnnotation(ctx, s.Interface, &adjacency, neighborAnnotationValue); err != nil { + errs = append(errs, fmt.Errorf("failed to validate LLDP adjacency %q/%q through annotation %q: %w", adj.ChassisID, adj.PortID, neighborAnnotationValue, err)) + } + } + neighbors = append(neighbors, adjacency) + } + + s.Interface.Status.Neighbors = neighbors + + return kerrors.NewAggregate(errs) +} + +func (r *InterfaceReconciler) validateLLDPAdjacencyThroughLabel(ctx context.Context, remoteProvider provider.InterfaceProvider, intf *v1alpha1.Interface, n *v1alpha1.Neighbor, label string) (v1alpha1.NeighborValidation, error) { + key := client.ObjectKey{ + Name: label, + Namespace: intf.Namespace, + } + + remoteIntf := new(v1alpha1.Interface) + if err := r.Get(ctx, key, remoteIntf); err != nil { + if !apierrors.IsNotFound(err) { + return "", fmt.Errorf("failed to get neighbor interface %w", err) + } + return v1alpha1.NeighborNotFound, nil + } + + log := ctrl.LoggerFrom(ctx, "LLDP validation", klog.KObj(intf)) + + remoteDevice, err := deviceutil.GetOwnerDevice(ctx, r, remoteIntf) + if err != nil { + return "", fmt.Errorf("could not find the device owning interface %q: %w", remoteIntf.Name, err) + } + + if remoteDevice.Status.Hostname == "" { + return "", fmt.Errorf("the neighbor device does not have a hostname yet, cannot validate adjacency: neighborInterface=%q", remoteIntf.Name) + } + + if remoteDevice.Status.Hostname != n.SystemName { + log.V(1).Info("the neighbor device hostname does not match", "expected", n.SystemName, "actual", remoteDevice.Status.Hostname) + return v1alpha1.NeighborDeviceMismatch, nil + } + + equal, err := remoteProvider.InterfaceNameEqual(ctx, remoteIntf.Spec.Name, n.PortID) + if err != nil { + return "", fmt.Errorf("failed to compare interface names %q and %q: %w", remoteIntf.Spec.Name, n.PortID, err) + } + if !equal { + log.V(1).Info("the neighbor interface name does not match", "expected", n.PortID, "actual", remoteIntf.Spec.Name) + return v1alpha1.NeighborPortMismatch, nil + } + + return v1alpha1.NeighborVerified, nil +} + +func (r *InterfaceReconciler) validateLLDPAdjacencyThroughAnnotation(ctx context.Context, intf *v1alpha1.Interface, n *v1alpha1.Neighbor, annotation string) (v1alpha1.NeighborValidation, error) { + remoteDeviceID, remotePortID, ok := strings.Cut(annotation, "::") + if !ok || remoteDeviceID == "" || remotePortID == "" { + return "", errors.New("invalid neighbor annotation value, expected format is ::") + } + + log := ctrl.LoggerFrom(ctx, "LLDP validation", klog.KObj(intf)) + if remoteDeviceID != n.ChassisID && remoteDeviceID != n.SystemName { + log.V(1).Info("the neighbor device identifier does not match", "annotationValue", remoteDeviceID, "chassisID", n.ChassisID, "systemName", n.SystemName) + return v1alpha1.NeighborDeviceMismatch, nil + } + + if remotePortID != n.PortID { + log.V(1).Info("the neighbor port identifier does not match", "annotationValue", remotePortID, "portID", n.PortID) + return v1alpha1.NeighborPortMismatch, nil + } + + return v1alpha1.NeighborVerified, nil } func (r *InterfaceReconciler) reconcileIPv4(ctx context.Context, s *scope) (provider.IPv4, error) { diff --git a/internal/controller/core/lldp_controller.go b/internal/controller/core/lldp_controller.go index 0e6a91593..b5c01f6c3 100644 --- a/internal/controller/core/lldp_controller.go +++ b/internal/controller/core/lldp_controller.go @@ -433,6 +433,7 @@ func (r *LLDPReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) for _, gvk := range v1alpha1.LLDPDependencies { obj := &unstructured.Unstructured{} obj.SetGroupVersionKind(gvk) + c = c.Watches( obj, handler.EnqueueRequestsFromMapFunc(r.mapProviderConfigToLLDP), @@ -440,21 +441,35 @@ func (r *LLDPReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) ) } - // Watches enqueues LLDPs for updates in referenced Device resources. - // Triggers on create, delete, and update events when the device's effective pause state changes. - c = c.Watches( - &v1alpha1.Device{}, - handler.EnqueueRequestsFromMapFunc(r.deviceToLLDPs), - builder.WithPredicates(predicate.Funcs{ - UpdateFunc: func(e event.UpdateEvent) bool { - return paused.DevicePausedChanged(e.ObjectOld, e.ObjectNew) - }, - GenericFunc: func(e event.GenericEvent) bool { - return false - }, - }), - ) - + c = c. + // Triggers on create, delete, and update events when the device's effective pause state changes. + // Watches enqueues LLDPs for updates in referenced Device resources. + Watches( + &v1alpha1.Device{}, + handler.EnqueueRequestsFromMapFunc(r.deviceToLLDPs), + builder.WithPredicates(predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + return paused.DevicePausedChanged(e.ObjectOld, e.ObjectNew) + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + }), + ). + // Watches enqueues LLDPs for updates in referenced Interface resources. + // This ensures LLDP reconciles when a referenced Interface is created or updated. + Watches( + &v1alpha1.Interface{}, + handler.EnqueueRequestsFromMapFunc(r.interfaceToLLDPs), + builder.WithPredicates(predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + return false + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + }), + ) return c.Complete(r) } @@ -534,3 +549,41 @@ func (r *LLDPReconciler) deviceToLLDPs(ctx context.Context, obj client.Object) [ return requests } + +// interfaceToLLDPs is a [handler.MapFunc] to be used to enqueue requests for reconciliation +// for LLDPs when a referenced Interface is created or updated. +func (r *LLDPReconciler) interfaceToLLDPs(ctx context.Context, obj client.Object) []ctrl.Request { + intf, ok := obj.(*v1alpha1.Interface) + if !ok { + panic(fmt.Sprintf("Expected an Interface but got a %T", obj)) + } + + log := ctrl.LoggerFrom(ctx, "Interface", klog.KObj(intf)) + + list := new(v1alpha1.LLDPList) + if err := r.List(ctx, list, + client.InNamespace(intf.Namespace), + client.MatchingFields{v1alpha1.DeviceRefIndexKey: intf.Spec.DeviceRef.Name}, + ); err != nil { + log.Error(err, "Failed to list LLDPs") + return nil + } + + var requests []ctrl.Request + for _, lldp := range list.Items { + for _, ifRef := range lldp.Spec.InterfaceRefs { + if ifRef.Name == intf.Name { + log.V(2).Info("Enqueuing LLDP for reconciliation", "LLDP", klog.KObj(&lldp)) + requests = append(requests, ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: lldp.Name, + Namespace: lldp.Namespace, + }, + }) + break + } + } + } + + return requests +} diff --git a/internal/controller/core/lldp_controller_test.go b/internal/controller/core/lldp_controller_test.go index ea58dabde..f47e833da 100644 --- a/internal/controller/core/lldp_controller_test.go +++ b/internal/controller/core/lldp_controller_test.go @@ -1057,4 +1057,356 @@ var _ = Describe("LLDP Controller", func() { Expect(k8sClient.Delete(ctx, intf)).To(Succeed()) }) }) + + Context("When Interface is created after LLDP", func() { + var ( + deviceName string + interfaceName string + resourceKey client.ObjectKey + deviceKey client.ObjectKey + interfaceKey client.ObjectKey + device *v1alpha1.Device + lldp *v1alpha1.LLDP + intf *v1alpha1.Interface + ) + + BeforeEach(func() { + By("Creating the Device resource") + device = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "testlldp-watch-device-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.7:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + deviceName = device.Name + deviceKey = client.ObjectKey{Name: deviceName, Namespace: metav1.NamespaceDefault} + resourceKey = client.ObjectKey{Name: deviceName + "-lldp", Namespace: metav1.NamespaceDefault} + interfaceName = deviceName + "-intf" + interfaceKey = client.ObjectKey{Name: interfaceName, Namespace: metav1.NamespaceDefault} + }) + + 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 Interface resource") + intf = &v1alpha1.Interface{} + err = k8sClient.Get(ctx, interfaceKey, intf) + if err == nil { + Expect(k8sClient.Delete(ctx, intf)).To(Succeed()) + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, interfaceKey, &v1alpha1.Interface{}) + g.Expect(errors.IsNotFound(err)).To(BeTrue()) + }).Should(Succeed()) + } + + By("Cleaning up the Device resource") + err = k8sClient.Get(ctx, deviceKey, device) + if err == nil { + Expect(k8sClient.Delete(ctx, device, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) + } + + By("Verifying the provider has been cleaned up") + Eventually(func(g Gomega) { + g.Expect(testProvider.LLDP).To(BeNil(), "Provider should have no LLDP configured") + }).Should(Succeed()) + }) + + It("Should re-reconcile LLDP when referenced Interface is created", func() { + By("Creating LLDP with InterfaceRef to non-existent Interface") + lldp = &v1alpha1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: deviceName + "-lldp", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.LLDPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + AdminState: v1alpha1.AdminStateUp, + InterfaceRefs: []v1alpha1.LLDPInterface{{ + LocalObjectReference: v1alpha1.LocalObjectReference{Name: interfaceName}, + AdminState: v1alpha1.AdminStateUp, + }}, + }, + } + Expect(k8sClient.Create(ctx, lldp)).To(Succeed()) + + By("Verifying LLDP is not ready due to missing Interface") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, lldp) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(v1alpha1.WaitingForDependenciesReason)) + }).Should(Succeed()) + + By("Creating the referenced Interface") + intf = &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + Name: interfaceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Name: "Ethernet1/1", + Type: v1alpha1.InterfaceTypePhysical, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, intf)).To(Succeed()) + + By("Verifying LLDP becomes ready after Interface is created (watch triggered re-reconciliation)") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, lldp) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ReadyCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + + cond = meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + }) + + It("Should re-reconcile LLDP when referenced Interface is deleted", func() { + By("Creating the Interface first") + intf = &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{ + Name: interfaceName, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + Name: "Ethernet1/1", + Type: v1alpha1.InterfaceTypePhysical, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, intf)).To(Succeed()) + + By("Creating LLDP with InterfaceRef") + lldp = &v1alpha1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: deviceName + "-lldp", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.LLDPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + AdminState: v1alpha1.AdminStateUp, + InterfaceRefs: []v1alpha1.LLDPInterface{{ + LocalObjectReference: v1alpha1.LocalObjectReference{Name: interfaceName}, + AdminState: v1alpha1.AdminStateUp, + }}, + }, + } + Expect(k8sClient.Create(ctx, lldp)).To(Succeed()) + + By("Verifying LLDP is ready") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, lldp) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ReadyCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Deleting the referenced Interface") + Expect(k8sClient.Delete(ctx, intf)).To(Succeed()) + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, interfaceKey, &v1alpha1.Interface{}) + g.Expect(errors.IsNotFound(err)).To(BeTrue()) + }).Should(Succeed()) + // Clear intf so AfterEach doesn't try to delete it again + intf = nil + + By("Verifying LLDP becomes not-ready after Interface is deleted (watch triggered re-reconciliation)") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, lldp) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(v1alpha1.WaitingForDependenciesReason)) + }).Should(Succeed()) + }) + }) + + Context("When LLDP operational status is degraded", func() { + var ( + deviceName string + resourceKey client.ObjectKey + deviceKey client.ObjectKey + device *v1alpha1.Device + lldp *v1alpha1.LLDP + ) + + BeforeEach(func() { + By("Creating the Device resource") + device = &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "testlldp-oper-device-", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.8:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + deviceName = device.Name + deviceKey = client.ObjectKey{Name: deviceName, Namespace: metav1.NamespaceDefault} + resourceKey = client.ObjectKey{Name: deviceName + "-lldp", Namespace: metav1.NamespaceDefault} + }) + + AfterEach(func() { + By("Resetting provider LLDP operational status to true") + testProvider.Lock() + testProvider.LLDPOperStatus = true + testProvider.Unlock() + + 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 provider has been cleaned up") + Eventually(func(g Gomega) { + g.Expect(testProvider.LLDP).To(BeNil(), "Provider should have no LLDP configured") + }).Should(Succeed()) + }) + + It("Should set OperationalCondition to False when LLDP is operationally down", func() { + By("Setting provider to return operational status down") + testProvider.Lock() + testProvider.LLDPOperStatus = false + testProvider.Unlock() + + By("Creating LLDP resource") + lldp = &v1alpha1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: deviceName + "-lldp", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.LLDPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, lldp)).To(Succeed()) + + By("Verifying OperationalCondition is False with DegradedReason") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, lldp) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.OperationalCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(v1alpha1.DegradedReason)) + g.Expect(cond.Message).To(ContainSubstring("operationally down")) + }).Should(Succeed()) + + By("Verifying ReadyCondition is also False due to degraded operational status") + 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.ConditionFalse)) + }).Should(Succeed()) + }) + + It("Should recover when LLDP becomes operationally up", func() { + By("Setting provider to return operational status down") + testProvider.Lock() + testProvider.LLDPOperStatus = false + testProvider.Unlock() + + By("Creating LLDP resource") + lldp = &v1alpha1.LLDP{ + ObjectMeta: metav1.ObjectMeta{ + Name: deviceName + "-lldp", + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.LLDPSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: deviceName}, + AdminState: v1alpha1.AdminStateUp, + }, + } + Expect(k8sClient.Create(ctx, lldp)).To(Succeed()) + + By("Verifying OperationalCondition is False") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, lldp) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.OperationalCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + }).Should(Succeed()) + + By("Setting provider to return operational status up") + testProvider.Lock() + testProvider.LLDPOperStatus = true + testProvider.Unlock() + + By("Verifying OperationalCondition becomes True after requeue") + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, resourceKey, lldp) + g.Expect(err).NotTo(HaveOccurred()) + + cond := meta.FindStatusCondition(lldp.Status.Conditions, v1alpha1.OperationalCondition) + g.Expect(cond).ToNot(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionTrue)) + g.Expect(cond.Reason).To(Equal(v1alpha1.OperationalReason)) + }).Should(Succeed()) + + By("Verifying ReadyCondition is also 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)) + }).Should(Succeed()) + }) + }) }) diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go index d505254d8..81065d523 100644 --- a/internal/controller/core/suite_test.go +++ b/internal/controller/core/suite_test.go @@ -441,6 +441,8 @@ type Provider struct { RoutingPolicies sets.Set[string] NVE *v1alpha1.NetworkVirtualizationEdge LLDP *v1alpha1.LLDP + LLDPOperStatus bool + LLDPNeighbors map[string]*provider.LLDPAdjacency DHCPRelay *v1alpha1.DHCPRelay } @@ -459,6 +461,8 @@ func NewProvider() *Provider { EVIs: sets.New[int32](), PrefixSets: sets.New[string](), RoutingPolicies: sets.New[string](), + LLDPOperStatus: true, + LLDPNeighbors: make(map[string]*provider.LLDPAdjacency), } } @@ -545,10 +549,23 @@ func (p *Provider) DeleteInterface(_ context.Context, req *provider.InterfaceReq return nil } -func (p *Provider) GetInterfaceStatus(context.Context, *provider.InterfaceRequest) (provider.InterfaceStatus, error) { - return provider.InterfaceStatus{ +func (p *Provider) GetInterfaceStatus(_ context.Context, req *provider.InterfaceRequest) (provider.InterfaceStatus, error) { + p.Lock() + defer p.Unlock() + + status := provider.InterfaceStatus{ OperStatus: true, - }, nil + } + + if neighbor, ok := p.LLDPNeighbors[req.Interface.Spec.Name]; ok { + status.LLDPAdjacencies = []provider.LLDPAdjacency{*neighbor} + } + + return status, nil +} + +func (p *Provider) InterfaceNameEqual(_ context.Context, a, b string) (bool, error) { + return a == b, nil } func (p *Provider) EnsureBanner(_ context.Context, req *provider.EnsureBannerRequest) error { @@ -903,7 +920,9 @@ func (p *Provider) DeleteLLDP(_ context.Context, req *provider.LLDPRequest) erro } func (p *Provider) GetLLDPStatus(_ context.Context, _ *provider.LLDPRequest) (provider.LLDPStatus, error) { - return provider.LLDPStatus{OperStatus: true}, nil + p.Lock() + defer p.Unlock() + return provider.LLDPStatus{OperStatus: p.LLDPOperStatus}, nil } func (p *Provider) EnsureDHCPRelay(_ context.Context, req *provider.DHCPRelayRequest) error { @@ -932,3 +951,15 @@ func (p *Provider) GetDHCPRelayStatus(_ context.Context, req *provider.DHCPRelay } return status, nil } + +// SetLLDPNeighbor is a test helper to configure LLDP neighbor information for an interface. +func (p *Provider) SetLLDPNeighbor(interfaceName, sysName, chassisID, portID string, ttl uint32) { + p.Lock() + defer p.Unlock() + p.LLDPNeighbors[interfaceName] = &provider.LLDPAdjacency{ + SysName: sysName, + ChassisID: chassisID, + PortID: portID, + TTL: time.Duration(ttl) * time.Second, + } +} From 0e936564b48692f4868b0b46a471f158f944b170 Mon Sep 17 00:00:00 2001 From: Pujol Date: Mon, 4 May 2026 11:29:43 +0200 Subject: [PATCH 5/7] Include LLDP adjacencies in Interface GetStatus Retrieve LLDP neighbor information while fetching interface status. As of now, this is only performed on physical interfaces with the use case of cabling validation in mind. The GetStatus response now includes a slice of adjacencies with a subset of the Cisco model for NXOS [1]. Also adds `InterfaceNameEqual` to the InterfaceProvider interface, allowing provider-specific logic to compare interface names. This is needed because of interface naming conventions differ across vendors (e.g., NX-OS uses forms like "eth1/1", "Ethernet1/1"). [1] https://pubhub.devnetcloud.com/media/dme-docs-10-4-3/docs/Discovery%20Protocols/lldp%3AAdjEp/ Signed-off-by: Pujol --- internal/provider/cisco/iosxr/provider.go | 5 +++ internal/provider/cisco/nxos/lldp.go | 24 ++++++++++++ internal/provider/cisco/nxos/provider.go | 47 +++++++++++++++++++---- internal/provider/openconfig/provider.go | 5 +++ internal/provider/provider.go | 16 ++++++++ 5 files changed, 90 insertions(+), 7 deletions(-) diff --git a/internal/provider/cisco/iosxr/provider.go b/internal/provider/cisco/iosxr/provider.go index 09d785c59..a8988e5ce 100644 --- a/internal/provider/cisco/iosxr/provider.go +++ b/internal/provider/cisco/iosxr/provider.go @@ -297,6 +297,11 @@ func (p *Provider) GetInterfaceStatus(ctx context.Context, req *provider.Interfa }, nil } +func (p *Provider) InterfaceNameEqual(_ context.Context, a, b string) (bool, error) { + // TODO: implement provider specific logic to compare interface names + return a == b, nil +} + func init() { provider.Register("cisco-iosxr-gnmi", NewProvider) } diff --git a/internal/provider/cisco/nxos/lldp.go b/internal/provider/cisco/nxos/lldp.go index a42875729..efbe17d51 100644 --- a/internal/provider/cisco/nxos/lldp.go +++ b/internal/provider/cisco/nxos/lldp.go @@ -41,3 +41,27 @@ func (*LLDPOper) IsListItem() {} func (*LLDPOper) XPath() string { return "System/fm-items/lldp-items" } + +// LLDPAdjacencyItems represents the LLDP neighbor information for a single interface. +type LLDPAdjacencyItems struct { + // ID is the identifier of the interface for which the LLDP neighbor information is being retrieved, e.g., "eth1/1". + ID string `json:"-"` + AdjItems struct { + AdjEpList []struct { + ChassisIDT uint8 `json:"chassisIdT"` + ChassisIDV string `json:"chassisIdV"` + PortIDT uint8 `json:"portIdT"` + PortIDV string `json:"portIdV"` + PortDesc string `json:"portDesc,omitempty"` + SysName string `json:"sysName,omitempty"` + SysDesc string `json:"sysDesc,omitempty"` + TTL int32 `json:"ttl"` + } `json:"AdjEp-list,omitzero"` + } `json:"adj-items,omitzero"` +} + +func (p *LLDPAdjacencyItems) XPath() string { + return "System/lldp-items/inst-items/if-items/If-list[id=" + p.ID + "]" +} + +func (*LLDPAdjacencyItems) IsListItem() {} diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index badb81a97..d270637cb 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -1332,19 +1332,37 @@ func (p *Provider) GetInterfaceStatus(ctx context.Context, req *provider.Interfa } var ( - operSt OperSt - operMsg string + operSt OperSt + operMsg string + lldpAdjacencies []provider.LLDPAdjacency ) switch req.Interface.Spec.Type { case v1alpha1.InterfaceTypePhysical: phys := new(PhysIfOperItems) phys.ID = name - if err := p.client.GetState(ctx, phys); err != nil && !errors.Is(err, gnmiext.ErrNil) { + lldpAdj := new(LLDPAdjacencyItems) + lldpAdj.ID = name + if err := p.client.GetState(ctx, phys, lldpAdj); err != nil && !errors.Is(err, gnmiext.ErrNil) { return provider.InterfaceStatus{}, err } operSt = phys.OperSt operMsg = phys.OperStQual + lldpAdjacencies = make([]provider.LLDPAdjacency, 0, len(lldpAdj.AdjItems.AdjEpList)) + for _, adj := range lldpAdj.AdjItems.AdjEpList { + neighbor := provider.LLDPAdjacency{ + ChassisID: adj.ChassisIDV, + ChassisIDType: adj.ChassisIDT, + PortID: adj.PortIDV, + PortIDType: adj.PortIDT, + PortDescription: adj.PortDesc, + SysName: adj.SysName, + SysDescription: adj.SysDesc, + TTL: time.Duration(adj.TTL) * time.Second, + } + lldpAdjacencies = append(lldpAdjacencies, neighbor) + } + case v1alpha1.InterfaceTypeLoopback: lb := new(LoopbackOperItems) lb.ID = name @@ -1392,10 +1410,25 @@ func (p *Provider) GetInterfaceStatus(ctx context.Context, req *provider.Interfa operMsg = "" } - return provider.InterfaceStatus{ - OperStatus: operSt == OperStUp, - OperMessage: operMsg, - }, nil + status := provider.InterfaceStatus{ + OperStatus: operSt == OperStUp, + OperMessage: operMsg, + LLDPAdjacencies: lldpAdjacencies, + } + + return status, nil +} + +func (p *Provider) InterfaceNameEqual(_ context.Context, a, b string) (bool, error) { + shortA, err := ShortName(a) + if err != nil { + return false, fmt.Errorf("invalid interface name: %w", err) + } + shortB, err := ShortName(b) + if err != nil { + return false, fmt.Errorf("invalid interface name: %w", err) + } + return shortA == shortB, nil } var ErrInterfaceNotFound = errors.New("one or more interfaces do not exist") diff --git a/internal/provider/openconfig/provider.go b/internal/provider/openconfig/provider.go index 96437a9b6..424d2e690 100644 --- a/internal/provider/openconfig/provider.go +++ b/internal/provider/openconfig/provider.go @@ -200,6 +200,11 @@ func (p *Provider) GetInterfaceStatus(context.Context, *provider.InterfaceReques return provider.InterfaceStatus{}, nil } +func (p *Provider) InterfaceNameEqual(_ context.Context, a, b string) (bool, error) { + // TODO: implement provider specific logic to compare interface names + return a == b, nil +} + func init() { provider.Register("openconfig", NewProvider) } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 2325ac50f..f1845132e 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -87,6 +87,8 @@ type InterfaceProvider interface { DeleteInterface(context.Context, *InterfaceRequest) error // GetInterfaceStatus call is responsible for retrieving the current status of the Interface from the provider. GetInterfaceStatus(context.Context, *InterfaceRequest) (InterfaceStatus, error) + // InterfaceNameEqual reports whether two interface names refer to the same interface on the provider. + InterfaceNameEqual(context.Context, string, string) (bool, error) } type EnsureInterfaceRequest struct { @@ -137,6 +139,20 @@ type InterfaceStatus struct { // OperMessage provides additional information about the operational status of the interface. // Leave empty if the provider does not return any additional information. OperMessage string + // LLDPAdjacencies provides information about the directly connected neighbors on this interface, if available. + LLDPAdjacencies []LLDPAdjacency +} + +// LLDPAdjacency represents information about a directly connected neighbor on an interface, as discovered through LLDP. +type LLDPAdjacency struct { + SysName string + SysDescription string + ChassisID string + ChassisIDType uint8 + PortID string + PortIDType uint8 + PortDescription string + TTL time.Duration } // BannerProvider is the interface for the realization of the Banner objects over different providers. From 182a4caedcb75f4327af6be7a30b995b19300c9f Mon Sep 17 00:00:00 2001 From: Pujol Date: Mon, 4 May 2026 11:29:54 +0200 Subject: [PATCH 6/7] Add update predicate to skip ExpirationTime-only changes Each reconcile computes ExpirationTime from current time plus TTL. The status patch triggers another reconcile, causing an infinite loop. This predicate filters Update events on the Interface watch, skipping reconciliation when the only change is ExpirationTime. Includes a conditions-count check to ensure initial condition setup completes before filtering kicks in. Signed-off-by: Pujol --- .../controller/core/interface_controller.go | 44 ++++++- .../core/interface_controller_test.go | 120 ++++++++++++++++++ 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/internal/controller/core/interface_controller.go b/internal/controller/core/interface_controller.go index ca049d739..5f40d4436 100644 --- a/internal/controller/core/interface_controller.go +++ b/internal/controller/core/interface_controller.go @@ -290,7 +290,12 @@ func (r *InterfaceReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Man } bldr := ctrl.NewControllerManagedBy(mgr). - For(&v1alpha1.Interface{}). + For(&v1alpha1.Interface{}, builder.WithPredicates(predicate.Or( + predicate.GenerationChangedPredicate{}, + predicate.LabelChangedPredicate{}, + predicate.AnnotationChangedPredicate{}, + interfaceUpdatePredicate{}, + ))). Named("interface"). WithEventFilter(filter) @@ -418,6 +423,43 @@ func (r *InterfaceReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Man Complete(r) } +// interfaceUpdatePredicate passes status-only updates through unless the +// neighbor ExpirationTime is the only change. Without this filter the status +// patch would immediately trigger a redundant second reconcile because the +// controller updates the ExpirationTime in the status during every reconcile loop. +// This would cause repeated reconciles every time the controller tries to update +// the status, even if there are no changes to the spec. +type interfaceUpdatePredicate struct { + predicate.Funcs +} + +// Update implements predicate.Predicate. +func (interfaceUpdatePredicate) Update(e event.UpdateEvent) bool { + oldIntf, ok := e.ObjectOld.(*v1alpha1.Interface) + if !ok { + return true + } + newIntf, ok := e.ObjectNew.(*v1alpha1.Interface) + if !ok { + return true + } + // Always reconcile if conditions haven't been fully initialized. + // InitializeConditions adds Ready/Configured/Operational, and paused.EnsureCondition + // adds Paused. Until all 4 are present, we must allow reconciles to complete setup. + if len(newIntf.Status.Conditions) < 4 { + return true + } + oldStatus := oldIntf.Status.DeepCopy() + newStatus := newIntf.Status.DeepCopy() + for i := range oldStatus.Neighbors { + oldStatus.Neighbors[i].ExpirationTime = metav1.Time{} + } + for i := range newStatus.Neighbors { + newStatus.Neighbors[i].ExpirationTime = metav1.Time{} + } + return !equality.Semantic.DeepEqual(oldStatus, newStatus) +} + // scope holds the different objects that are read and used during the reconcile. type scope struct { Device *v1alpha1.Device diff --git a/internal/controller/core/interface_controller_test.go b/internal/controller/core/interface_controller_test.go index 9b9c3af26..0107f8690 100644 --- a/internal/controller/core/interface_controller_test.go +++ b/internal/controller/core/interface_controller_test.go @@ -5,12 +5,14 @@ package core import ( "net/netip" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" "github.com/ironcore-dev/network-operator/api/core/v1alpha1" ) @@ -1253,4 +1255,122 @@ var _ = Describe("Interface Controller", func() { }).Should(Succeed()) }) }) + + Context("Interface Update Predicate", func() { + var p interfaceUpdatePredicate + + BeforeEach(func() { + p = interfaceUpdatePredicate{} + }) + + It("Should allow update when Neighbors field is added", func() { + oldIntf := &v1alpha1.Interface{ + Status: v1alpha1.InterfaceStatus{ + Neighbors: nil, + }, + } + newIntf := &v1alpha1.Interface{ + Status: v1alpha1.InterfaceStatus{ + Neighbors: []v1alpha1.Neighbor{{ + SystemName: "switch-1", + ChassisID: "00:11:22:33:44:55", + PortID: "Eth1/1", + }}, + }, + } + e := event.UpdateEvent{ObjectOld: oldIntf, ObjectNew: newIntf} + Expect(p.Update(e)).To(BeTrue()) + }) + + It("Should allow update when neighbor SystemName changes", func() { + oldIntf := &v1alpha1.Interface{ + Status: v1alpha1.InterfaceStatus{ + Neighbors: []v1alpha1.Neighbor{{ + SystemName: "switch-1", + ChassisID: "00:11:22:33:44:55", + PortID: "Eth1/1", + ExpirationTime: metav1.NewTime(time.Now()), + }}, + }, + } + newIntf := oldIntf.DeepCopy() + newIntf.Status.Neighbors[0].SystemName = "switch-2" + + e := event.UpdateEvent{ObjectOld: oldIntf, ObjectNew: newIntf} + Expect(p.Update(e)).To(BeTrue()) + }) + + It("Should block update when only ExpirationTime changes", func() { + now := time.Now() + conditions := []metav1.Condition{ + {Type: v1alpha1.ReadyCondition, Status: metav1.ConditionTrue}, + {Type: v1alpha1.ConfiguredCondition, Status: metav1.ConditionTrue}, + {Type: v1alpha1.OperationalCondition, Status: metav1.ConditionTrue}, + {Type: v1alpha1.PausedCondition, Status: metav1.ConditionFalse}, + } + oldIntf := &v1alpha1.Interface{ + Status: v1alpha1.InterfaceStatus{ + Conditions: conditions, + Neighbors: []v1alpha1.Neighbor{{ + SystemName: "switch-1", + ChassisID: "00:11:22:33:44:55", + PortID: "Eth1/1", + ExpirationTime: metav1.NewTime(now), + }}, + }, + } + newIntf := oldIntf.DeepCopy() + newIntf.Status.Neighbors[0].ExpirationTime = metav1.NewTime(now.Add(120 * time.Second)) + + e := event.UpdateEvent{ObjectOld: oldIntf, ObjectNew: newIntf} + Expect(p.Update(e)).To(BeFalse()) + }) + + It("Should allow update when neighbor is removed", func() { + oldIntf := &v1alpha1.Interface{ + Status: v1alpha1.InterfaceStatus{ + Neighbors: []v1alpha1.Neighbor{{SystemName: "switch-1"}}, + }, + } + newIntf := &v1alpha1.Interface{ + Status: v1alpha1.InterfaceStatus{ + Neighbors: nil, + }, + } + e := event.UpdateEvent{ObjectOld: oldIntf, ObjectNew: newIntf} + Expect(p.Update(e)).To(BeTrue()) + }) + + It("Should allow update when generation changes", func() { + oldIntf := &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{Generation: 1}, + Status: v1alpha1.InterfaceStatus{ + Neighbors: []v1alpha1.Neighbor{{ + ExpirationTime: metav1.NewTime(time.Now()), + }}, + }, + } + newIntf := oldIntf.DeepCopy() + newIntf.Generation = 2 + newIntf.Status.Neighbors[0].ExpirationTime = metav1.NewTime(time.Now().Add(120 * time.Second)) + + e := event.UpdateEvent{ObjectOld: oldIntf, ObjectNew: newIntf} + Expect(p.Update(e)).To(BeTrue()) + }) + + It("Should allow update when conditions are not fully initialized", func() { + oldIntf := &v1alpha1.Interface{ + Status: v1alpha1.InterfaceStatus{ + Conditions: []metav1.Condition{ + {Type: v1alpha1.ReadyCondition, Status: metav1.ConditionUnknown}, + {Type: v1alpha1.PausedCondition, Status: metav1.ConditionTrue}, + }, + }, + } + newIntf := oldIntf.DeepCopy() + + e := event.UpdateEvent{ObjectOld: oldIntf, ObjectNew: newIntf} + Expect(p.Update(e)).To(BeTrue()) + }) + }) }) From c3f333cc770cc9a010fd65358ea4bd51506860a2 Mon Sep 17 00:00:00 2001 From: Pujol Date: Tue, 5 May 2026 11:11:01 +0200 Subject: [PATCH 7/7] Apply gofumpt v0.10.0 formatting Signed-off-by: Pujol --- .../cisco/nx/bordergateway_controller.go | 3 ++- .../controller/cisco/nx/system_controller.go | 3 ++- .../cisco/nx/vpcdomain_controller.go | 6 +++-- internal/controller/core/acl_controller.go | 3 ++- internal/controller/core/banner_controller.go | 3 ++- internal/controller/core/bgp_controller.go | 9 ++++--- .../controller/core/bgp_peer_controller.go | 15 ++++++++---- .../controller/core/certificate_controller.go | 3 ++- .../controller/core/dhcprelay_controller.go | 12 ++++++---- internal/controller/core/dns_controller.go | 3 ++- .../core/evpninstance_controller.go | 3 ++- .../controller/core/interface_controller.go | 3 ++- internal/controller/core/isis_controller.go | 6 +++-- internal/controller/core/lldp_controller.go | 9 ++++--- .../core/managementaccess_controller.go | 3 ++- internal/controller/core/ntp_controller.go | 3 ++- internal/controller/core/nve_controller.go | 6 +++-- internal/controller/core/ospf_controller.go | 6 +++-- internal/controller/core/pim_controller.go | 6 +++-- .../controller/core/prefixset_controller.go | 3 ++- .../core/routingpolicy_controller.go | 3 ++- internal/controller/core/snmp_controller.go | 3 ++- internal/controller/core/syslog_controller.go | 3 ++- internal/controller/core/user_controller.go | 3 ++- internal/controller/core/vlan_controller.go | 3 ++- internal/controller/core/vrf_controller.go | 3 ++- test/e2e/e2e_test.go | 24 ++++++++++++------- test/e2e/util_test.go | 3 ++- test/lab/main_test.go | 6 +++-- 29 files changed, 106 insertions(+), 53 deletions(-) diff --git a/internal/controller/cisco/nx/bordergateway_controller.go b/internal/controller/cisco/nx/bordergateway_controller.go index 6a7f9a380..3470a1a61 100644 --- a/internal/controller/cisco/nx/bordergateway_controller.go +++ b/internal/controller/cisco/nx/bordergateway_controller.go @@ -626,7 +626,8 @@ func (r *BorderGatewayReconciler) deviceToBorderGateways(ctx context.Context, ob log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(nxv1alpha1.BorderGatewayList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/internal/controller/cisco/nx/system_controller.go b/internal/controller/cisco/nx/system_controller.go index ca42c1fd8..71831b30d 100644 --- a/internal/controller/cisco/nx/system_controller.go +++ b/internal/controller/cisco/nx/system_controller.go @@ -297,7 +297,8 @@ func (r *SystemReconciler) deviceToSystems(ctx context.Context, obj client.Objec log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(nxv1alpha1.SystemList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/internal/controller/cisco/nx/vpcdomain_controller.go b/internal/controller/cisco/nx/vpcdomain_controller.go index 6eb7b85e1..8f888da1c 100644 --- a/internal/controller/cisco/nx/vpcdomain_controller.go +++ b/internal/controller/cisco/nx/vpcdomain_controller.go @@ -463,7 +463,8 @@ func (r *VPCDomainReconciler) mapInterfaceToVPCDomain(ctx context.Context, obj c log := ctrl.LoggerFrom(ctx, "Interface", klog.KObj(iface)) list := new(nxv1alpha1.VPCDomainList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(iface.Namespace), client.MatchingFields{vpcDomainPeerLinkRefKey: iface.Name}, ); err != nil { @@ -507,7 +508,8 @@ func (r *VPCDomainReconciler) deviceToVPCDomains(ctx context.Context, obj client log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(nxv1alpha1.VPCDomainList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/internal/controller/core/acl_controller.go b/internal/controller/core/acl_controller.go index 97d8eb0d1..20c5de8a5 100644 --- a/internal/controller/core/acl_controller.go +++ b/internal/controller/core/acl_controller.go @@ -333,7 +333,8 @@ func (r *AccessControlListReconciler) deviceToAccessControlLists(ctx context.Con log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.AccessControlListList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/internal/controller/core/banner_controller.go b/internal/controller/core/banner_controller.go index 83db2f8c3..6641e4131 100644 --- a/internal/controller/core/banner_controller.go +++ b/internal/controller/core/banner_controller.go @@ -348,7 +348,8 @@ func (r *BannerReconciler) deviceToBanners(ctx context.Context, obj client.Objec log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.BannerList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/internal/controller/core/bgp_controller.go b/internal/controller/core/bgp_controller.go index 1ee25669d..a273b252c 100644 --- a/internal/controller/core/bgp_controller.go +++ b/internal/controller/core/bgp_controller.go @@ -518,7 +518,8 @@ func (r *BGPReconciler) deviceToBGPs(ctx context.Context, obj client.Object) []c log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.BGPList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { @@ -583,7 +584,8 @@ func (r *BGPReconciler) vrfToBGPs(ctx context.Context, obj client.Object) []ctrl log := ctrl.LoggerFrom(ctx, "VRF", klog.KObj(vrf)) list := new(v1alpha1.BGPList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(vrf.Namespace), client.MatchingFields{bgpVrfRefIndexKey: vrf.Name}, ); err != nil { @@ -615,7 +617,8 @@ func (r *BGPReconciler) routingPolicyToBGPs(ctx context.Context, obj client.Obje log := ctrl.LoggerFrom(ctx, "RoutingPolicy", klog.KObj(rp)) list := new(v1alpha1.BGPList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(rp.Namespace), client.MatchingFields{bgpRedistributeDirectRoutePolicyIndexKey: rp.Name}, ); err != nil { diff --git a/internal/controller/core/bgp_peer_controller.go b/internal/controller/core/bgp_peer_controller.go index 34e1f423a..71de26336 100644 --- a/internal/controller/core/bgp_peer_controller.go +++ b/internal/controller/core/bgp_peer_controller.go @@ -693,7 +693,8 @@ func (r *BGPPeerReconciler) deviceToBGPPeers(ctx context.Context, obj client.Obj log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.BGPPeerList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { @@ -758,7 +759,8 @@ func (r *BGPPeerReconciler) bgpToBGPPeers(ctx context.Context, obj client.Object log := ctrl.LoggerFrom(ctx, "BGP", klog.KObj(bgp)) list := new(v1alpha1.BGPPeerList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(bgp.Namespace), client.MatchingFields{bgpPeerBGPRefIndexKey: bgp.Name}, ); err != nil { @@ -791,7 +793,8 @@ func (r *BGPPeerReconciler) vrfToBGPPeers(ctx context.Context, obj client.Object log := ctrl.LoggerFrom(ctx, "VRF", klog.KObj(vrf)) bgpList := new(v1alpha1.BGPList) - if err := r.List(ctx, bgpList, + if err := r.List( + ctx, bgpList, client.InNamespace(vrf.Namespace), client.MatchingFields{bgpVrfRefIndexKey: vrf.Name}, ); err != nil { @@ -802,7 +805,8 @@ func (r *BGPPeerReconciler) vrfToBGPPeers(ctx context.Context, obj client.Object var requests []ctrl.Request for _, bgp := range bgpList.Items { peerList := new(v1alpha1.BGPPeerList) - if err := r.List(ctx, peerList, + if err := r.List( + ctx, peerList, client.InNamespace(vrf.Namespace), client.MatchingFields{bgpPeerBGPRefIndexKey: bgp.Name}, ); err != nil { @@ -835,7 +839,8 @@ func (r *BGPPeerReconciler) routingPolicyToBGPPeers(ctx context.Context, obj cli log := ctrl.LoggerFrom(ctx, "RoutingPolicy", klog.KObj(rp)) list := new(v1alpha1.BGPPeerList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(rp.Namespace), client.MatchingFields{bgpPeerRoutingPolicyRefIndexKey: rp.Name}, ); err != nil { diff --git a/internal/controller/core/certificate_controller.go b/internal/controller/core/certificate_controller.go index 4c1a000c6..8aede6f75 100644 --- a/internal/controller/core/certificate_controller.go +++ b/internal/controller/core/certificate_controller.go @@ -342,7 +342,8 @@ func (r *CertificateReconciler) deviceToCertificates(ctx context.Context, obj cl log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.CertificateList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/internal/controller/core/dhcprelay_controller.go b/internal/controller/core/dhcprelay_controller.go index 67dd967b0..28d787911 100644 --- a/internal/controller/core/dhcprelay_controller.go +++ b/internal/controller/core/dhcprelay_controller.go @@ -550,7 +550,8 @@ func (r *DHCPRelayReconciler) reconcileVRFRef(ctx context.Context, s *dhcprelayS func (r *DHCPRelayReconciler) validateUniqueResourcePerDevice(ctx context.Context, s *dhcprelayScope) error { var list v1alpha1.DHCPRelayList - if err := r.List(ctx, &list, + if err := r.List( + ctx, &list, client.InNamespace(s.DHCPRelay.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: s.Device.Name}, ); err != nil { @@ -625,7 +626,8 @@ func (r *DHCPRelayReconciler) deviceToDHCPRelays(ctx context.Context, obj client log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.DHCPRelayList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { @@ -657,7 +659,8 @@ func (r *DHCPRelayReconciler) interfaceToDHCPRelays(ctx context.Context, obj cli log := ctrl.LoggerFrom(ctx, "Interface", klog.KObj(intf)) list := new(v1alpha1.DHCPRelayList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(intf.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: intf.Spec.DeviceRef.Name}, ); err != nil { @@ -694,7 +697,8 @@ func (r *DHCPRelayReconciler) vrfToDHCPRelays(ctx context.Context, obj client.Ob log := ctrl.LoggerFrom(ctx, "VRF", klog.KObj(vrf)) list := new(v1alpha1.DHCPRelayList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(vrf.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: vrf.Spec.DeviceRef.Name}, ); err != nil { diff --git a/internal/controller/core/dns_controller.go b/internal/controller/core/dns_controller.go index 7e9d15850..36c9e1758 100644 --- a/internal/controller/core/dns_controller.go +++ b/internal/controller/core/dns_controller.go @@ -324,7 +324,8 @@ func (r *DNSReconciler) deviceToDNSs(ctx context.Context, obj client.Object) []c log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.DNSList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/internal/controller/core/evpninstance_controller.go b/internal/controller/core/evpninstance_controller.go index 98d73dc49..48bf64195 100644 --- a/internal/controller/core/evpninstance_controller.go +++ b/internal/controller/core/evpninstance_controller.go @@ -463,7 +463,8 @@ func (r *EVPNInstanceReconciler) deviceToEVPNInstances(ctx context.Context, obj log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.EVPNInstanceList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/internal/controller/core/interface_controller.go b/internal/controller/core/interface_controller.go index 5f40d4436..be04340be 100644 --- a/internal/controller/core/interface_controller.go +++ b/internal/controller/core/interface_controller.go @@ -1379,7 +1379,8 @@ func (r *InterfaceReconciler) deviceToInterfaces(ctx context.Context, obj client log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) interfaces := new(v1alpha1.InterfaceList) - if err := r.List(ctx, interfaces, + if err := r.List( + ctx, interfaces, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/internal/controller/core/isis_controller.go b/internal/controller/core/isis_controller.go index f08ec05ea..5c361a473 100644 --- a/internal/controller/core/isis_controller.go +++ b/internal/controller/core/isis_controller.go @@ -380,7 +380,8 @@ func (r *ISISReconciler) interfaceToISIS(ctx context.Context, obj client.Object) log := ctrl.LoggerFrom(ctx, "Interface", klog.KObj(iface)) list := new(v1alpha1.ISISList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(iface.Namespace), ); err != nil { log.Error(err, "Failed to list ISISs") @@ -416,7 +417,8 @@ func (r *ISISReconciler) deviceToISISs(ctx context.Context, obj client.Object) [ log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.ISISList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/internal/controller/core/lldp_controller.go b/internal/controller/core/lldp_controller.go index b5c01f6c3..8ddf306a5 100644 --- a/internal/controller/core/lldp_controller.go +++ b/internal/controller/core/lldp_controller.go @@ -382,7 +382,8 @@ func (r *LLDPReconciler) reconcileInterfaceRef(ctx context.Context, interfaceRef func (r *LLDPReconciler) validateUniqueLLDPPerDevice(ctx context.Context, s *lldpScope) error { var list v1alpha1.LLDPList - if err := r.List(ctx, &list, + if err := r.List( + ctx, &list, client.InNamespace(s.LLDP.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: s.Device.Name}, ); err != nil { @@ -528,7 +529,8 @@ func (r *LLDPReconciler) deviceToLLDPs(ctx context.Context, obj client.Object) [ log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) lldps := new(v1alpha1.LLDPList) - if err := r.List(ctx, lldps, + if err := r.List( + ctx, lldps, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { @@ -561,7 +563,8 @@ func (r *LLDPReconciler) interfaceToLLDPs(ctx context.Context, obj client.Object log := ctrl.LoggerFrom(ctx, "Interface", klog.KObj(intf)) list := new(v1alpha1.LLDPList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(intf.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: intf.Spec.DeviceRef.Name}, ); err != nil { diff --git a/internal/controller/core/managementaccess_controller.go b/internal/controller/core/managementaccess_controller.go index 7e3bd6d09..a715739ec 100644 --- a/internal/controller/core/managementaccess_controller.go +++ b/internal/controller/core/managementaccess_controller.go @@ -324,7 +324,8 @@ func (r *ManagementAccessReconciler) deviceToManagementAccesses(ctx context.Cont log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.ManagementAccessList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/internal/controller/core/ntp_controller.go b/internal/controller/core/ntp_controller.go index c99f2148d..c5adb2706 100644 --- a/internal/controller/core/ntp_controller.go +++ b/internal/controller/core/ntp_controller.go @@ -324,7 +324,8 @@ func (r *NTPReconciler) deviceToNTPs(ctx context.Context, obj client.Object) []c log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.NTPList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/internal/controller/core/nve_controller.go b/internal/controller/core/nve_controller.go index 396877c96..09b273522 100644 --- a/internal/controller/core/nve_controller.go +++ b/internal/controller/core/nve_controller.go @@ -300,7 +300,8 @@ func (r *NetworkVirtualizationEdgeReconciler) reconcile(ctx context.Context, s * func (r *NetworkVirtualizationEdgeReconciler) validateUniqueNVEPerDevice(ctx context.Context, s *nveScope) error { var list v1alpha1.NetworkVirtualizationEdgeList - if err := r.List(ctx, &list, + if err := r.List( + ctx, &list, client.InNamespace(s.NVE.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: s.NVE.Spec.DeviceRef.Name}, ); err != nil { @@ -520,7 +521,8 @@ func (r *NetworkVirtualizationEdgeReconciler) deviceToNVEs(ctx context.Context, log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.NetworkVirtualizationEdgeList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/internal/controller/core/ospf_controller.go b/internal/controller/core/ospf_controller.go index 0f8a8124b..7fbe57786 100644 --- a/internal/controller/core/ospf_controller.go +++ b/internal/controller/core/ospf_controller.go @@ -464,7 +464,8 @@ func (r *OSPFReconciler) deviceToOSPFs(ctx context.Context, obj client.Object) [ log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.OSPFList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { @@ -497,7 +498,8 @@ func (r *OSPFReconciler) interfaceToOSPF(ctx context.Context, obj client.Object) log := ctrl.LoggerFrom(ctx, "Interface", klog.KObj(iface)) list := new(v1alpha1.OSPFList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(iface.Namespace), ); err != nil { log.Error(err, "Failed to list OSPFs") diff --git a/internal/controller/core/pim_controller.go b/internal/controller/core/pim_controller.go index ba407c65c..8fef7afe4 100644 --- a/internal/controller/core/pim_controller.go +++ b/internal/controller/core/pim_controller.go @@ -382,7 +382,8 @@ func (r *PIMReconciler) deviceToPIMs(ctx context.Context, obj client.Object) []c log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.PIMList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { @@ -415,7 +416,8 @@ func (r *PIMReconciler) interfaceToPIM(ctx context.Context, obj client.Object) [ log := ctrl.LoggerFrom(ctx, "Interface", klog.KObj(iface)) list := new(v1alpha1.PIMList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(iface.Namespace), ); err != nil { log.Error(err, "Failed to list PIMs") diff --git a/internal/controller/core/prefixset_controller.go b/internal/controller/core/prefixset_controller.go index a90d8b5fe..c144af7b8 100644 --- a/internal/controller/core/prefixset_controller.go +++ b/internal/controller/core/prefixset_controller.go @@ -333,7 +333,8 @@ func (r *PrefixSetReconciler) deviceToPrefixSets(ctx context.Context, obj client log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.PrefixSetList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/internal/controller/core/routingpolicy_controller.go b/internal/controller/core/routingpolicy_controller.go index 94678ca23..ad0c02d50 100644 --- a/internal/controller/core/routingpolicy_controller.go +++ b/internal/controller/core/routingpolicy_controller.go @@ -461,7 +461,8 @@ func (r *RoutingPolicyReconciler) deviceToRoutingPolicies(ctx context.Context, o log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.RoutingPolicyList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/internal/controller/core/snmp_controller.go b/internal/controller/core/snmp_controller.go index 94dbc3496..af54b90e2 100644 --- a/internal/controller/core/snmp_controller.go +++ b/internal/controller/core/snmp_controller.go @@ -326,7 +326,8 @@ func (r *SNMPReconciler) deviceToSNMPs(ctx context.Context, obj client.Object) [ log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.SNMPList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/internal/controller/core/syslog_controller.go b/internal/controller/core/syslog_controller.go index 1e6e8a13e..00c754b96 100644 --- a/internal/controller/core/syslog_controller.go +++ b/internal/controller/core/syslog_controller.go @@ -330,7 +330,8 @@ func (r *SyslogReconciler) deviceToSyslogs(ctx context.Context, obj client.Objec log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.SyslogList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/internal/controller/core/user_controller.go b/internal/controller/core/user_controller.go index 9690d2ee1..edb776b34 100644 --- a/internal/controller/core/user_controller.go +++ b/internal/controller/core/user_controller.go @@ -402,7 +402,8 @@ func (r *UserReconciler) deviceToUsers(ctx context.Context, obj client.Object) [ log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.UserList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/internal/controller/core/vlan_controller.go b/internal/controller/core/vlan_controller.go index e6e6ad7c7..06d4aab85 100644 --- a/internal/controller/core/vlan_controller.go +++ b/internal/controller/core/vlan_controller.go @@ -358,7 +358,8 @@ func (r *VLANReconciler) deviceToVLANs(ctx context.Context, obj client.Object) [ log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.VLANList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/internal/controller/core/vrf_controller.go b/internal/controller/core/vrf_controller.go index 16bdd5e24..dd51c522d 100644 --- a/internal/controller/core/vrf_controller.go +++ b/internal/controller/core/vrf_controller.go @@ -329,7 +329,8 @@ func (r *VRFReconciler) deviceToVRFs(ctx context.Context, obj client.Object) []c log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) list := new(v1alpha1.VRFList) - if err := r.List(ctx, list, + if err := r.List( + ctx, list, client.InNamespace(device.Namespace), client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, ); err != nil { diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 788531eba..032db0663 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -39,7 +39,8 @@ var _ = Describe("Manager", Ordered, func() { // and deploying the controller. BeforeAll(func(ctx SpecContext) { By("deploying the gnmi-test-server") - cmd := exec.CommandContext(ctx, "kubectl", "run", "gnmi-test-server", + cmd := exec.CommandContext( + ctx, "kubectl", "run", "gnmi-test-server", "--image", serverImage, "--image-pull-policy", "Never", "--namespace", "default", @@ -50,7 +51,8 @@ var _ = Describe("Manager", Ordered, func() { _, err := Run(cmd) Expect(err).NotTo(HaveOccurred(), "Failed to deploy the gnmi-test-server") - cmd = exec.CommandContext(ctx, "kubectl", "wait", "pods/gnmi-test-server", + cmd = exec.CommandContext( + ctx, "kubectl", "wait", "pods/gnmi-test-server", "--for", "condition=Ready", "--namespace", "default", "--timeout", "1m", @@ -58,7 +60,8 @@ var _ = Describe("Manager", Ordered, func() { _, err = Run(cmd) Expect(err).NotTo(HaveOccurred()) - cmd = exec.CommandContext(ctx, "kubectl", "get", "pod", "gnmi-test-server", + cmd = exec.CommandContext( + ctx, "kubectl", "get", "pod", "gnmi-test-server", "--output", "jsonpath='{.status.podIP}'", "--namespace", "default", ) @@ -171,7 +174,8 @@ var _ = Describe("Manager", Ordered, func() { By("validating that the controller-manager pod is running as expected") verifyControllerUp := func(g Gomega) { // Get the name of the controller-manager pod - cmd := exec.CommandContext(ctx, "kubectl", "get", + cmd := exec.CommandContext( + ctx, "kubectl", "get", "pods", "-l", "control-plane=controller-manager", "-o", "go-template={{ range .items }}"+ "{{ if not .metadata.deletionTimestamp }}"+ @@ -316,7 +320,8 @@ var _ = Describe("Manager", Ordered, func() { // strings.ToLower(), // )) - DescribeTable("Should reconcile the api objects", + DescribeTable( + "Should reconcile the api objects", func(ctx SpecContext, file string) { device := ` apiVersion: networking.metal.ironcore.dev/v1alpha1 @@ -341,7 +346,8 @@ spec: Expect(err).NotTo(HaveOccurred(), "Failed to apply Interface") // #nosec G204 - cmd := exec.CommandContext(ctx, "kubectl", "wait", a.Files[0].Name, + cmd := exec.CommandContext( + ctx, "kubectl", "wait", a.Files[0].Name, "--for", "condition=Configured", "--namespace", "default", "--timeout", "5m", @@ -349,7 +355,8 @@ spec: _, err = Run(cmd) Expect(err).NotTo(HaveOccurred()) - cmd = exec.CommandContext(ctx, "kubectl", "exec", "gnmi-test-server", + cmd = exec.CommandContext( + ctx, "kubectl", "exec", "gnmi-test-server", "--namespace", "default", "--", "wget", "-qO-", "http://localhost:8000/v1/state", @@ -369,7 +376,8 @@ spec: _, err = Run(cmd) Expect(err).NotTo(HaveOccurred(), "Failed to delete object") - cmd = exec.CommandContext(ctx, "kubectl", "exec", "gnmi-test-server", + cmd = exec.CommandContext( + ctx, "kubectl", "exec", "gnmi-test-server", "--namespace", "default", "--", "wget", "-qO-", "--header='X-HTTP-Method-Override: DELETE'", "http://localhost:8000/v1/state", diff --git a/test/e2e/util_test.go b/test/e2e/util_test.go index f4ac6fb3e..1281d18db 100644 --- a/test/e2e/util_test.go +++ b/test/e2e/util_test.go @@ -140,7 +140,8 @@ func InstallCertManager(ctx context.Context) error { } // Wait for cert-manager-webhook to be ready, which can take time if cert-manager // was re-installed after uninstalling on a cluster. - cmd = exec.CommandContext(ctx, "kubectl", "wait", "deployment.apps/cert-manager-webhook", + cmd = exec.CommandContext( + ctx, "kubectl", "wait", "deployment.apps/cert-manager-webhook", "--for", "condition=Available", "--namespace", "cert-manager", "--timeout", "5m", diff --git a/test/lab/main_test.go b/test/lab/main_test.go index 2e1d6a638..6ebb7f03d 100644 --- a/test/lab/main_test.go +++ b/test/lab/main_test.go @@ -97,7 +97,8 @@ func Vty() script.Cmd { stderr = stderrBuf.String() return }, nil - }) + }, + ) } // Apply returns a script command that applies a Kubernetes manifest to the cluster @@ -150,7 +151,8 @@ func Apply() script.Cmd { return "", "", fmt.Errorf("resource %s is not ready", res.GetName()) } return WaitTimeout(wait, timeout, interval), nil - }) + }, + ) } // TODO(felix-kaestner): Load endpoint configuration from a config file.