From 964f82dbd802959ac1d3b25a88c6f7d259247407 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Thu, 27 Aug 2026 11:35:05 +0200 Subject: [PATCH 1/8] Add neighbor labels to EVPN-VXLAN example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felix Kästner --- .../kubernetes/06-interconnects/leaf1-eth1-1.yaml | 1 + .../kubernetes/06-interconnects/leaf1-eth1-2.yaml | 1 + .../kubernetes/06-interconnects/leaf2-eth1-1.yaml | 1 + .../kubernetes/06-interconnects/leaf2-eth1-2.yaml | 1 + .../kubernetes/06-interconnects/leaf3-eth1-1.yaml | 1 + .../kubernetes/06-interconnects/leaf3-eth1-2.yaml | 1 + .../kubernetes/06-interconnects/spine1-eth1-1.yaml | 1 + .../kubernetes/06-interconnects/spine1-eth1-2.yaml | 1 + .../kubernetes/06-interconnects/spine1-eth1-3.yaml | 1 + .../kubernetes/06-interconnects/spine2-eth1-1.yaml | 1 + .../kubernetes/06-interconnects/spine2-eth1-2.yaml | 1 + .../kubernetes/06-interconnects/spine2-eth1-3.yaml | 1 + 12 files changed, 12 insertions(+) diff --git a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf1-eth1-1.yaml b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf1-eth1-1.yaml index 2279a52d7..bd0b1b97c 100644 --- a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf1-eth1-1.yaml +++ b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf1-eth1-1.yaml @@ -3,6 +3,7 @@ kind: Interface metadata: labels: networking.metal.ironcore.dev/role: fabric + networking.metal.ironcore.dev/interface-neighbor: spine1-eth1-1 name: leaf1-eth1-1 spec: deviceRef: diff --git a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf1-eth1-2.yaml b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf1-eth1-2.yaml index f663e4155..e9d3538c5 100644 --- a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf1-eth1-2.yaml +++ b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf1-eth1-2.yaml @@ -3,6 +3,7 @@ kind: Interface metadata: labels: networking.metal.ironcore.dev/role: fabric + networking.metal.ironcore.dev/interface-neighbor: spine2-eth1-1 name: leaf1-eth1-2 spec: deviceRef: diff --git a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf2-eth1-1.yaml b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf2-eth1-1.yaml index 390695146..c4ca79d50 100644 --- a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf2-eth1-1.yaml +++ b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf2-eth1-1.yaml @@ -3,6 +3,7 @@ kind: Interface metadata: labels: networking.metal.ironcore.dev/role: fabric + networking.metal.ironcore.dev/interface-neighbor: spine1-eth1-2 name: leaf2-eth1-1 spec: deviceRef: diff --git a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf2-eth1-2.yaml b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf2-eth1-2.yaml index ebb21d790..1cfda3844 100644 --- a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf2-eth1-2.yaml +++ b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf2-eth1-2.yaml @@ -3,6 +3,7 @@ kind: Interface metadata: labels: networking.metal.ironcore.dev/role: fabric + networking.metal.ironcore.dev/interface-neighbor: spine2-eth1-2 name: leaf2-eth1-2 spec: deviceRef: diff --git a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf3-eth1-1.yaml b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf3-eth1-1.yaml index ffa16d09c..f81e1d2c8 100644 --- a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf3-eth1-1.yaml +++ b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf3-eth1-1.yaml @@ -3,6 +3,7 @@ kind: Interface metadata: labels: networking.metal.ironcore.dev/role: fabric + networking.metal.ironcore.dev/interface-neighbor: spine1-eth1-3 name: leaf3-eth1-1 spec: deviceRef: diff --git a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf3-eth1-2.yaml b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf3-eth1-2.yaml index 897ff27a0..1e01b19f8 100644 --- a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf3-eth1-2.yaml +++ b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/leaf3-eth1-2.yaml @@ -3,6 +3,7 @@ kind: Interface metadata: labels: networking.metal.ironcore.dev/role: fabric + networking.metal.ironcore.dev/interface-neighbor: spine2-eth1-3 name: leaf3-eth1-2 spec: deviceRef: diff --git a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine1-eth1-1.yaml b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine1-eth1-1.yaml index a02e1229f..86577babe 100644 --- a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine1-eth1-1.yaml +++ b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine1-eth1-1.yaml @@ -3,6 +3,7 @@ kind: Interface metadata: labels: networking.metal.ironcore.dev/role: fabric + networking.metal.ironcore.dev/interface-neighbor: leaf1-eth1-1 name: spine1-eth1-1 spec: deviceRef: diff --git a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine1-eth1-2.yaml b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine1-eth1-2.yaml index fe50dcebe..fd49b8911 100644 --- a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine1-eth1-2.yaml +++ b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine1-eth1-2.yaml @@ -3,6 +3,7 @@ kind: Interface metadata: labels: networking.metal.ironcore.dev/role: fabric + networking.metal.ironcore.dev/interface-neighbor: leaf2-eth1-1 name: spine1-eth1-2 spec: deviceRef: diff --git a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine1-eth1-3.yaml b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine1-eth1-3.yaml index 296771e0f..bfccbae80 100644 --- a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine1-eth1-3.yaml +++ b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine1-eth1-3.yaml @@ -3,6 +3,7 @@ kind: Interface metadata: labels: networking.metal.ironcore.dev/role: fabric + networking.metal.ironcore.dev/interface-neighbor: leaf3-eth1-1 name: spine1-eth1-3 spec: deviceRef: diff --git a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine2-eth1-1.yaml b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine2-eth1-1.yaml index 19dce69dc..f62581633 100644 --- a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine2-eth1-1.yaml +++ b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine2-eth1-1.yaml @@ -3,6 +3,7 @@ kind: Interface metadata: labels: networking.metal.ironcore.dev/role: fabric + networking.metal.ironcore.dev/interface-neighbor: leaf1-eth1-2 name: spine2-eth1-1 spec: deviceRef: diff --git a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine2-eth1-2.yaml b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine2-eth1-2.yaml index 89bf816d9..3d5daec51 100644 --- a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine2-eth1-2.yaml +++ b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine2-eth1-2.yaml @@ -3,6 +3,7 @@ kind: Interface metadata: labels: networking.metal.ironcore.dev/role: fabric + networking.metal.ironcore.dev/interface-neighbor: leaf2-eth1-2 name: spine2-eth1-2 spec: deviceRef: diff --git a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine2-eth1-3.yaml b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine2-eth1-3.yaml index 6b90576c5..83fbc28fa 100644 --- a/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine2-eth1-3.yaml +++ b/examples/cisco-n9k-evpn-vxlan/kubernetes/06-interconnects/spine2-eth1-3.yaml @@ -3,6 +3,7 @@ kind: Interface metadata: labels: networking.metal.ironcore.dev/role: fabric + networking.metal.ironcore.dev/interface-neighbor: leaf3-eth1-2 name: spine2-eth1-3 spec: deviceRef: From 483709a2c771a5759872f7f54cd9df0f075e9b64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Thu, 27 Aug 2026 11:36:02 +0200 Subject: [PATCH 2/8] Normalize NVE source interface names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felix Kästner --- internal/provider/cisco/nxos/provider.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index 8db1ce2f4..20a3fb267 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -3381,15 +3381,24 @@ func (p *Provider) EnsureNVE(ctx context.Context, req *provider.NVERequest) erro return errors.New("nve: anycast source interface cannot be the same as source interface") } + sourceInterface, err := ShortName(req.SourceInterface.Spec.Name) + if err != nil { + return fmt.Errorf("nve: invalid source interface name %q: %w", req.SourceInterface.Spec.Name, err) + } + n := new(NVE) n.AdminSt = AdminStDisabled if req.NVE.Spec.AdminState == v1alpha1.AdminStateUp { n.AdminSt = AdminStEnabled } - n.SourceInterface = req.SourceInterface.Spec.Name + n.SourceInterface = sourceInterface if req.AnycastSourceInterface != nil { - n.AnycastInterface = NewOption(req.AnycastSourceInterface.Spec.Name) + anycastInterface, err := ShortName(req.AnycastSourceInterface.Spec.Name) + if err != nil { + return fmt.Errorf("nve: invalid anycast source interface name %q: %w", req.AnycastSourceInterface.Spec.Name, err) + } + n.AnycastInterface = NewOption(anycastInterface) } if req.NVE.Spec.MulticastGroups != nil && req.NVE.Spec.MulticastGroups.L2 != nil { n.McastGroupL2 = NewOption(req.NVE.Spec.MulticastGroups.L2.Addr().String()) From efb0f59cd4bde0986ee0e2a37b6203b6cbc9bf44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Thu, 27 Aug 2026 11:38:13 +0200 Subject: [PATCH 3/8] Use canonical names for Fabric loopbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felix Kästner --- internal/controller/evpn/fabric_controller.go | 2 +- internal/controller/evpn/fabric_controller_test.go | 6 +++--- internal/controller/evpn/suite_test.go | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/controller/evpn/fabric_controller.go b/internal/controller/evpn/fabric_controller.go index c8be13a29..ee4e577a4 100644 --- a/internal/controller/evpn/fabric_controller.go +++ b/internal/controller/evpn/fabric_controller.go @@ -409,7 +409,7 @@ func (r *FabricReconciler) reconcileLoopbackInterface(ctx context.Context, fabri return nil, reconcile.TerminalError(fmt.Errorf("resolving loopback interface name for id %d: %w", loopbackID, err)) } - name := fmt.Sprintf("%s-%s-%s", fabric.Name, device.Name, handle) + name := fmt.Sprintf("%s-%s-lo%d", fabric.Name, device.Name, loopbackID) intf := &v1alpha1.Interface{ ObjectMeta: metav1.ObjectMeta{ Name: name, diff --git a/internal/controller/evpn/fabric_controller_test.go b/internal/controller/evpn/fabric_controller_test.go index eef5026a1..9a02c315f 100644 --- a/internal/controller/evpn/fabric_controller_test.go +++ b/internal/controller/evpn/fabric_controller_test.go @@ -265,7 +265,7 @@ var _ = Describe("Fabric Controller", func() { g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: fabric.Name + "-" + d.Name + "-lo0", Namespace: metav1.NamespaceDefault}, intf)).To(Succeed()) g.Expect(intf.Spec.Type).To(Equal(corev1alpha1.InterfaceTypeLoopback)) g.Expect(intf.Spec.DeviceRef.Name).To(Equal(d.Name)) - g.Expect(intf.Spec.Name).To(Equal("lo0")) + g.Expect(intf.Spec.Name).To(Equal("Loopback0")) g.Expect(intf.Spec.AdminState).To(Equal(corev1alpha1.AdminStateUp)) g.Expect(intf.Spec.Description).To(Equal("Router-ID, BGP Source")) g.Expect(intf.Spec.IPv4).NotTo(BeNil()) @@ -282,7 +282,7 @@ var _ = Describe("Fabric Controller", func() { g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: fabric.Name + "-" + d.Name + "-" + id, Namespace: metav1.NamespaceDefault}, intf)).To(Succeed()) g.Expect(intf.Spec.Type).To(Equal(corev1alpha1.InterfaceTypeLoopback)) g.Expect(intf.Spec.DeviceRef.Name).To(Equal(d.Name)) - g.Expect(intf.Spec.Name).To(Equal(id)) + g.Expect(intf.Spec.Name).To(Equal("Loopback" + id[2:])) g.Expect(intf.Spec.AdminState).To(Equal(corev1alpha1.AdminStateUp)) g.Expect(intf.Spec.Description).To(Equal(descriptions[loIdx])) g.Expect(intf.Spec.IPv4).NotTo(BeNil()) @@ -298,7 +298,7 @@ var _ = Describe("Fabric Controller", func() { g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: fabric.Name + "-" + d.Name + "-lo100", Namespace: metav1.NamespaceDefault}, intf)).To(Succeed()) g.Expect(intf.Spec.Type).To(Equal(corev1alpha1.InterfaceTypeLoopback)) g.Expect(intf.Spec.DeviceRef.Name).To(Equal(d.Name)) - g.Expect(intf.Spec.Name).To(Equal("lo100")) + g.Expect(intf.Spec.Name).To(Equal("Loopback100")) g.Expect(intf.Spec.AdminState).To(Equal(corev1alpha1.AdminStateUp)) g.Expect(intf.Spec.Description).To(Equal("Rendezvous Point")) g.Expect(intf.Spec.IPv4).NotTo(BeNil()) diff --git a/internal/controller/evpn/suite_test.go b/internal/controller/evpn/suite_test.go index 030c39a34..7dc5c8197 100644 --- a/internal/controller/evpn/suite_test.go +++ b/internal/controller/evpn/suite_test.go @@ -203,5 +203,5 @@ func (p *Provider) InterfaceNameEqual(_ context.Context, a, b string) (bool, err } func (p *Provider) LoopbackInterfaceName(id int) (string, error) { - return fmt.Sprintf("lo%d", id), nil + return fmt.Sprintf("Loopback%d", id), nil } From 90b0acb4c1e3713e85ea03edad1f01b098ed5425 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Thu, 27 Aug 2026 11:39:16 +0200 Subject: [PATCH 4/8] Sort Fabric devices before loopback allocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felix Kästner --- internal/controller/evpn/fabric_controller.go | 2 ++ internal/controller/evpn/fabric_controller_test.go | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/internal/controller/evpn/fabric_controller.go b/internal/controller/evpn/fabric_controller.go index ee4e577a4..2df200d22 100644 --- a/internal/controller/evpn/fabric_controller.go +++ b/internal/controller/evpn/fabric_controller.go @@ -276,6 +276,7 @@ func (r *FabricReconciler) reconcileSystemLoopbacks(ctx context.Context, fabric if err := r.List(ctx, devices, client.InNamespace(fabric.Namespace), client.MatchingLabelsSelector{Selector: selector}); err != nil { return ctrl.Result{}, fmt.Errorf("listing all fabric devices for system loopbacks: %w", err) } + slices.SortFunc(devices.Items, func(a, b v1alpha1.Device) int { return cmp.Compare(a.Name, b.Name) }) // Range by index only — list.Items are value types; using the second loop // variable would copy the entire struct on each iteration. for i := range devices.Items { @@ -305,6 +306,7 @@ func (r *FabricReconciler) reconcileVTEPLoopbacks(ctx context.Context, fabric *e if err := r.List(ctx, devices, client.InNamespace(fabric.Namespace), client.MatchingLabelsSelector{Selector: selector}); err != nil { return ctrl.Result{}, fmt.Errorf("listing VTEP devices: %w", err) } + slices.SortFunc(devices.Items, func(a, b v1alpha1.Device) int { return cmp.Compare(a.Name, b.Name) }) for i := range devices.Items { for _, id := range []int{LoopbackVTEP, LoopbackVTEPAnycast} { claimName := fmt.Sprintf("%s-%s-lo%d", fabric.Name, devices.Items[i].Name, id) diff --git a/internal/controller/evpn/fabric_controller_test.go b/internal/controller/evpn/fabric_controller_test.go index 9a02c315f..d5ec8df0a 100644 --- a/internal/controller/evpn/fabric_controller_test.go +++ b/internal/controller/evpn/fabric_controller_test.go @@ -4,6 +4,8 @@ package evpn import ( + "fmt" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -242,6 +244,15 @@ var _ = Describe("Fabric Controller", func() { }).Should(Succeed()) } + By("Verifying lo0 addresses are allocated in device name order") + Eventually(func(g Gomega) { + for i, d := range []*corev1alpha1.Device{leaf1, leaf2, spine1, spine2} { + claim := &poolv1alpha1.Claim{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: fabric.Name + "-" + d.Name + "-lo0", Namespace: metav1.NamespaceDefault}, claim)).To(Succeed()) + g.Expect(claim.Status.Value).To(Equal(fmt.Sprintf("10.0.0.%d", i))) + } + }).Should(Succeed()) + By("Verifying lo1 and lo2 Claims are created only for leaf (VTEP) devices") for _, d := range []*corev1alpha1.Device{leaf1, leaf2} { for _, id := range []string{"lo1", "lo2"} { From 54eae017c2a755400ea097838833dbfa7e8b34f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Thu, 27 Aug 2026 11:50:46 +0200 Subject: [PATCH 5/8] Validate OSPF interface IPv4 configuration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felix Kästner --- api/core/v1alpha1/interface_types.go | 5 ++ internal/controller/core/ospf_controller.go | 24 ++++++- .../controller/core/ospf_controller_test.go | 65 +++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/api/core/v1alpha1/interface_types.go b/api/core/v1alpha1/interface_types.go index 74058e994..551a1e42b 100644 --- a/api/core/v1alpha1/interface_types.go +++ b/api/core/v1alpha1/interface_types.go @@ -595,6 +595,11 @@ type Interface struct { Status InterfaceStatus `json:"status,omitempty,omitzero"` } +// HasIPv4 reports whether the Interface is configured as a routed IPv4 interface. +func (in *Interface) HasIPv4() bool { + return in.Spec.Switchport == nil && in.Spec.IPv4 != nil +} + // GetConditions implements conditions.Getter. func (in *Interface) GetConditions() []metav1.Condition { return in.Status.Conditions diff --git a/internal/controller/core/ospf_controller.go b/internal/controller/core/ospf_controller.go index 2e69c38fe..5bfbf1ef1 100644 --- a/internal/controller/core/ospf_controller.go +++ b/internal/controller/core/ospf_controller.go @@ -274,7 +274,9 @@ func (r *OSPFReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) newInterface := e.ObjectNew.(*v1alpha1.Interface) oldConfigured := conditions.Get(oldInterface, v1alpha1.ConfiguredCondition) newConfigured := conditions.Get(newInterface, v1alpha1.ConfiguredCondition) - return ((oldConfigured == nil) != (newConfigured == nil)) || (newConfigured != nil && oldConfigured.Status != newConfigured.Status) + return oldInterface.HasIPv4() != newInterface.HasIPv4() || + ((oldConfigured == nil) != (newConfigured == nil)) || + (newConfigured != nil && oldConfigured.Status != newConfigured.Status) }, GenericFunc: func(e event.GenericEvent) bool { return false @@ -327,6 +329,26 @@ func (r *OSPFReconciler) reconcile(ctx context.Context, s *ospfScope) (reterr er return err } + if intf.Spec.DeviceRef.Name != s.Device.Name { + conditions.Set(s.OSPF, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.CrossDeviceReferenceReason, + Message: fmt.Sprintf("interface %q belongs to device %q, not %q", ref.Name, intf.Spec.DeviceRef.Name, s.Device.Name), + }) + return reconcile.TerminalError(fmt.Errorf("interface %q belongs to a different device", ref.Name)) + } + + if !intf.HasIPv4() { + conditions.Set(s.OSPF, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.IPAddressingNotFoundReason, + Message: fmt.Sprintf("interface %q has no IPv4 configuration", ref.Name), + }) + return reconcile.TerminalError(fmt.Errorf("interface %q has no IPv4 configuration", ref.Name)) + } + if !conditions.IsConfigured(intf) { conditions.Set(s.OSPF, metav1.Condition{ Type: v1alpha1.ReadyCondition, diff --git a/internal/controller/core/ospf_controller_test.go b/internal/controller/core/ospf_controller_test.go index e7c7d0a9c..fe87b7410 100644 --- a/internal/controller/core/ospf_controller_test.go +++ b/internal/controller/core/ospf_controller_test.go @@ -12,6 +12,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/conditions" ) var _ = Describe("OSPF Controller", func() { @@ -118,6 +119,70 @@ var _ = Describe("OSPF Controller", func() { }) }) + Context("When a referenced interface has no IPv4 configuration", func() { + var ( + name string + key client.ObjectKey + ) + + BeforeEach(func() { + device := &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{GenerateName: "test-ospf-l2-intf-", Namespace: metav1.NamespaceDefault}, + Spec: v1alpha1.DeviceSpec{Endpoint: v1alpha1.Endpoint{Address: "192.168.10.4:9339"}}, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + name = device.Name + key = client.ObjectKey{Name: name, Namespace: metav1.NamespaceDefault} + + intf := &v1alpha1.Interface{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: metav1.NamespaceDefault}, + Spec: v1alpha1.InterfaceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Name: "eth1", + AdminState: v1alpha1.AdminStateUp, + Type: v1alpha1.InterfaceTypePhysical, + }, + } + Expect(k8sClient.Create(ctx, intf)).To(Succeed()) + Eventually(func(g Gomega) { + current := &v1alpha1.Interface{} + g.Expect(k8sClient.Get(ctx, key, current)).To(Succeed()) + g.Expect(conditions.IsConfigured(current)).To(BeTrue()) + }).Should(Succeed()) + + ospf := &v1alpha1.OSPF{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: metav1.NamespaceDefault}, + Spec: v1alpha1.OSPFSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Instance: "UNDERLAY", + RouterID: "10.0.0.10", + InterfaceRefs: []v1alpha1.OSPFInterface{{ + LocalObjectReference: v1alpha1.LocalObjectReference{Name: name}, + Area: "0.0.0.0", + }}, + }, + } + Expect(k8sClient.Create(ctx, ospf)).To(Succeed()) + }) + + AfterEach(func() { + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, &v1alpha1.OSPF{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: metav1.NamespaceDefault}}))).To(Succeed()) + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, &v1alpha1.Interface{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: metav1.NamespaceDefault}}))).To(Succeed()) + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, &v1alpha1.Device{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: metav1.NamespaceDefault}}))).To(Succeed()) + }) + + It("Should set ConfiguredCondition to false", func() { + Eventually(func(g Gomega) { + resource := &v1alpha1.OSPF{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + configured := meta.FindStatusCondition(resource.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(configured).NotTo(BeNil()) + g.Expect(configured.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(configured.Reason).To(Equal(v1alpha1.IPAddressingNotFoundReason)) + }).Should(Succeed()) + }) + }) + Context("When an interfaceRef does not exist", func() { var ( name string From f818f5ca10ae3b73d8215c01ce6c4e80962ea7ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Thu, 27 Aug 2026 11:53:08 +0200 Subject: [PATCH 6/8] Wire provider into Fabric controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felix Kästner --- cmd/main.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/main.go b/cmd/main.go index efeff4163..da6bdca71 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -835,6 +835,7 @@ func main() { //nolint:gocyclo Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Recorder: mgr.GetEventRecorder("fabric-controller"), + Provider: prov, }).SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to create controller", "controller", "Fabric") os.Exit(1) From b1f9ad03fcbaf612bc6e4f6657c143f1ec0c178c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Thu, 27 Aug 2026 11:56:24 +0200 Subject: [PATCH 7/8] Report Fabric convergence conditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felix Kästner --- api/evpn/v1alpha1/groupversion_info.go | 21 +++ internal/controller/evpn/fabric_controller.go | 138 +++++++++++++++++- .../controller/evpn/fabric_controller_test.go | 28 +++- 3 files changed, 173 insertions(+), 14 deletions(-) diff --git a/api/evpn/v1alpha1/groupversion_info.go b/api/evpn/v1alpha1/groupversion_info.go index 620f26472..43afeeba2 100644 --- a/api/evpn/v1alpha1/groupversion_info.go +++ b/api/evpn/v1alpha1/groupversion_info.go @@ -31,3 +31,24 @@ const ( // with the value set to the owning Fabric's name. FabricLabel = "evpn.networking.metal.ironcore.dev/fabric" ) + +// Fabric condition types. +const ( + // UnderlayConvergedCondition reports whether all underlay IGP adjacencies are formed. + UnderlayConvergedCondition = "UnderlayConverged" + + // OverlayConvergedCondition reports whether all overlay BGP sessions are established. + OverlayConvergedCondition = "OverlayConverged" +) + +// Fabric condition reasons. +const ( + // ConvergedReason indicates all child resources report Operational=True. + ConvergedReason = "Converged" + + // NotConvergedReason indicates one or more child resources are not yet operational. + NotConvergedReason = "NotConverged" + + // NoResourcesReason indicates no child resources exist yet for this condition. + NoResourcesReason = "NoResources" +) diff --git a/internal/controller/evpn/fabric_controller.go b/internal/controller/evpn/fabric_controller.go index 2df200d22..c528c472c 100644 --- a/internal/controller/evpn/fabric_controller.go +++ b/internal/controller/evpn/fabric_controller.go @@ -125,7 +125,7 @@ func (r *FabricReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ c } orig := fabric.DeepCopy() - if conditions.InitializeConditions(fabric, v1alpha1.ReadyCondition) { + if conditions.InitializeConditions(fabric, v1alpha1.ReadyCondition, evpnv1alpha1.UnderlayConvergedCondition, evpnv1alpha1.OverlayConvergedCondition) { log.V(1).Info("Initializing status conditions") return ctrl.Result{}, r.Status().Update(ctx, fabric) } @@ -208,6 +208,12 @@ type ReconcileState struct { } func (r *FabricReconciler) reconcile(ctx context.Context, fabric *evpnv1alpha1.Fabric) (ctrl.Result, error) { + defer func() { + r.computeUnderlayConverged(ctx, fabric) + r.computeOverlayConverged(ctx, fabric) + conditions.RecomputeReady(fabric) + }() + state := &ReconcileState{ loopbacks: make(map[string][]*v1alpha1.Interface), uplinks: make(map[string][]*v1alpha1.Interface), @@ -244,15 +250,133 @@ func (r *FabricReconciler) reconcile(ctx context.Context, fabric *evpnv1alpha1.F return res, err } } - conditions.Set(fabric, metav1.Condition{ - Type: v1alpha1.ReadyCondition, - Status: metav1.ConditionTrue, - Reason: v1alpha1.ReadyReason, - Message: "Resource is ready", - }) return ctrl.Result{}, nil } +// computeUnderlayConverged checks that all OSPF neighbors are in Full state (or ISIS is Operational). +// For OSPF, convergence means every expected uplink interface has a neighbor in Full adjacency state. +func (r *FabricReconciler) computeUnderlayConverged(ctx context.Context, fabric *evpnv1alpha1.Fabric) { + labelSelector := client.MatchingLabels{evpnv1alpha1.FabricLabel: fabric.Name} + + ospfList := &v1alpha1.OSPFList{} + if err := r.List(ctx, ospfList, client.InNamespace(fabric.Namespace), labelSelector); err != nil { + ctrl.LoggerFrom(ctx).Error(err, "Failed to list OSPF resources for convergence check") + return + } + + isisList := &v1alpha1.ISISList{} + if err := r.List(ctx, isisList, client.InNamespace(fabric.Namespace), labelSelector); err != nil { + ctrl.LoggerFrom(ctx).Error(err, "Failed to list ISIS resources for convergence check") + return + } + + totalInstances := len(ospfList.Items) + len(isisList.Items) + if totalInstances == 0 { + conditions.Set(fabric, metav1.Condition{ + Type: evpnv1alpha1.UnderlayConvergedCondition, + Status: metav1.ConditionFalse, + Reason: evpnv1alpha1.NoResourcesReason, + Message: "No underlay IGP resources created yet", + }) + return + } + + // For OSPF: count expected non-passive interfaces (uplinks) and check each has a Full neighbor. + var totalAdjacencies, fullAdjacencies int + for i := range ospfList.Items { + ospf := &ospfList.Items[i] + // Count expected adjacencies: non-passive interface refs. + for _, ref := range ospf.Spec.InterfaceRefs { + if ref.Passive != nil && *ref.Passive { + continue + } + totalAdjacencies++ + } + // Count actual Full adjacencies from status. + for _, neighbor := range ospf.Status.Neighbors { + if neighbor.AdjacencyState == v1alpha1.OSPFNeighborStateFull { + fullAdjacencies++ + } + } + } + + // For ISIS: no per-neighbor status yet; count instances and fall back to Operational condition. + for i := range isisList.Items { + isis := &isisList.Items[i] + totalAdjacencies++ + if cond := conditions.Get(isis, v1alpha1.OperationalCondition); cond != nil && cond.Status == metav1.ConditionTrue { + fullAdjacencies++ + } + } + + switch { + case totalAdjacencies == 0: + conditions.Set(fabric, metav1.Condition{ + Type: evpnv1alpha1.UnderlayConvergedCondition, + Status: metav1.ConditionFalse, + Reason: evpnv1alpha1.NoResourcesReason, + Message: "No underlay adjacencies expected", + }) + case fullAdjacencies == totalAdjacencies: + conditions.Set(fabric, metav1.Condition{ + Type: evpnv1alpha1.UnderlayConvergedCondition, + Status: metav1.ConditionTrue, + Reason: evpnv1alpha1.ConvergedReason, + Message: fmt.Sprintf("All %d underlay adjacencies are Full", totalAdjacencies), + }) + default: + conditions.Set(fabric, metav1.Condition{ + Type: evpnv1alpha1.UnderlayConvergedCondition, + Status: metav1.ConditionFalse, + Reason: evpnv1alpha1.NotConvergedReason, + Message: fmt.Sprintf("%d/%d underlay adjacencies are Full", fullAdjacencies, totalAdjacencies), + }) + } +} + +// computeOverlayConverged checks that all BGPPeer sessions are in Established state. +func (r *FabricReconciler) computeOverlayConverged(ctx context.Context, fabric *evpnv1alpha1.Fabric) { + labelSelector := client.MatchingLabels{evpnv1alpha1.FabricLabel: fabric.Name} + + peerList := &v1alpha1.BGPPeerList{} + if err := r.List(ctx, peerList, client.InNamespace(fabric.Namespace), labelSelector); err != nil { + ctrl.LoggerFrom(ctx).Error(err, "Failed to list BGPPeer resources for convergence check") + return + } + + total := len(peerList.Items) + var established int + for i := range peerList.Items { + if peerList.Items[i].Status.SessionState == v1alpha1.BGPPeerSessionStateEstablished { + established++ + } + } + + switch { + case total == 0: + conditions.Set(fabric, metav1.Condition{ + Type: evpnv1alpha1.OverlayConvergedCondition, + Status: metav1.ConditionFalse, + Reason: evpnv1alpha1.NoResourcesReason, + Message: "No overlay BGP peers created yet", + }) + case established == total: + conditions.Set(fabric, metav1.Condition{ + Type: evpnv1alpha1.OverlayConvergedCondition, + Status: metav1.ConditionTrue, + Reason: evpnv1alpha1.ConvergedReason, + Message: fmt.Sprintf("All %d overlay BGP sessions are Established", total), + }) + default: + conditions.Set(fabric, metav1.Condition{ + Type: evpnv1alpha1.OverlayConvergedCondition, + Status: metav1.ConditionFalse, + Reason: evpnv1alpha1.NotConvergedReason, + Message: fmt.Sprintf("%d/%d overlay BGP sessions are Established", established, total), + }) + } +} + func (r *FabricReconciler) finalize(ctx context.Context, fabric *evpnv1alpha1.Fabric) error { _ = ctx _ = fabric diff --git a/internal/controller/evpn/fabric_controller_test.go b/internal/controller/evpn/fabric_controller_test.go index d5ec8df0a..9e4435f53 100644 --- a/internal/controller/evpn/fabric_controller_test.go +++ b/internal/controller/evpn/fabric_controller_test.go @@ -529,16 +529,30 @@ var _ = Describe("Fabric Controller", func() { }).Should(Succeed()) } - By("Verifying the Fabric Ready condition is True once all phases are complete") + By("Verifying convergence conditions report correct state") Eventually(func(g Gomega) { f := &evpnv1alpha1.Fabric{} g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(fabric), f)).To(Succeed()) - g.Expect(f.Status.Conditions).To(ContainElement( - SatisfyAll( - HaveField("Type", corev1alpha1.ReadyCondition), - HaveField("Status", metav1.ConditionTrue), - ), - )) + + // Underlay: OSPF resources exist but no device controller sets Operational in envtest. + g.Expect(f.Status.Conditions).To(ContainElement(SatisfyAll( + HaveField("Type", evpnv1alpha1.UnderlayConvergedCondition), + HaveField("Status", metav1.ConditionFalse), + HaveField("Reason", evpnv1alpha1.NotConvergedReason), + ))) + + // Overlay: BGPPeer resources exist but no device controller sets Operational in envtest. + g.Expect(f.Status.Conditions).To(ContainElement(SatisfyAll( + HaveField("Type", evpnv1alpha1.OverlayConvergedCondition), + HaveField("Status", metav1.ConditionFalse), + HaveField("Reason", evpnv1alpha1.NotConvergedReason), + ))) + + // Ready is False because convergence conditions are not met. + g.Expect(f.Status.Conditions).To(ContainElement(SatisfyAll( + HaveField("Type", corev1alpha1.ReadyCondition), + HaveField("Status", metav1.ConditionFalse), + ))) }).Should(Succeed()) }) }) From 8e0d195e4ed25ca959e9923de4bdda0873b2feaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Fri, 28 Aug 2026 11:49:22 +0200 Subject: [PATCH 8/8] Use regular ownership for Fabric resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Felix Kästner --- internal/controller/evpn/fabric_controller.go | 26 +++++++++---------- .../controller/evpn/fabric_controller_test.go | 10 +++---- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/internal/controller/evpn/fabric_controller.go b/internal/controller/evpn/fabric_controller.go index c528c472c..eaef6807a 100644 --- a/internal/controller/evpn/fabric_controller.go +++ b/internal/controller/evpn/fabric_controller.go @@ -170,13 +170,13 @@ func (r *FabricReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&evpnv1alpha1.Fabric{}). Owns(&poolv1alpha1.Claim{}). - Owns(&v1alpha1.Interface{}). - Owns(&v1alpha1.OSPF{}). - Owns(&v1alpha1.ISIS{}). - Owns(&v1alpha1.BGP{}). - Owns(&v1alpha1.BGPPeer{}). - Owns(&v1alpha1.PIM{}). - Owns(&v1alpha1.NetworkVirtualizationEdge{}). + Owns(&v1alpha1.Interface{}, builder.MatchEveryOwner). + Owns(&v1alpha1.OSPF{}, builder.MatchEveryOwner). + Owns(&v1alpha1.ISIS{}, builder.MatchEveryOwner). + Owns(&v1alpha1.BGP{}, builder.MatchEveryOwner). + Owns(&v1alpha1.BGPPeer{}, builder.MatchEveryOwner). + Owns(&v1alpha1.PIM{}, builder.MatchEveryOwner). + Owns(&v1alpha1.NetworkVirtualizationEdge{}, builder.MatchEveryOwner). // Re-reconcile when a Device's labels change so that devices newly // matching a deviceSelector are enrolled into the fabric. Watches( @@ -819,7 +819,7 @@ func (r *FabricReconciler) reconcileOSPF(ctx context.Context, device *v1alpha1.D Area: "0.0.0.0", }) } - return controllerutil.SetControllerReference(fabric, ospf, r.Scheme) + return controllerutil.SetOwnerReference(fabric, ospf, r.Scheme) }) if err != nil { return fmt.Errorf("reconciling OSPF %s: %w", name, err) @@ -868,7 +868,7 @@ func (r *FabricReconciler) reconcileISIS(ctx context.Context, device *v1alpha1.D refs = append(refs, v1alpha1.LocalObjectReference{Name: up.Name}) } isis.Spec.InterfaceRefs = refs - return controllerutil.SetControllerReference(fabric, isis, r.Scheme) + return controllerutil.SetOwnerReference(fabric, isis, r.Scheme) }) if err != nil { return fmt.Errorf("reconciling ISIS %s: %w", name, err) @@ -1025,7 +1025,7 @@ func (r *FabricReconciler) reconcileBGP(ctx context.Context, device *v1alpha1.De }, }, } - return controllerutil.SetControllerReference(fabric, bgp, r.Scheme) + return controllerutil.SetOwnerReference(fabric, bgp, r.Scheme) }) if err != nil { return fmt.Errorf("reconciling BGP %s: %w", name, err) @@ -1084,7 +1084,7 @@ func (r *FabricReconciler) reconcileBGPPeer(ctx context.Context, local, remote * RouteReflectorClient: rrClient, }, } - return controllerutil.SetControllerReference(fabric, peer, r.Scheme) + return controllerutil.SetOwnerReference(fabric, peer, r.Scheme) }) if err != nil { return fmt.Errorf("reconciling BGPPeer %s: %w", name, err) @@ -1277,7 +1277,7 @@ func (r *FabricReconciler) reconcilePIM(ctx context.Context, deviceName string, pim.Spec.AdminState = v1alpha1.AdminStateUp pim.Spec.RendezvousPoints = rps pim.Spec.InterfaceRefs = intfRefs - return controllerutil.SetControllerReference(fabric, pim, r.Scheme) + return controllerutil.SetOwnerReference(fabric, pim, r.Scheme) }) if err != nil { return fmt.Errorf("reconciling PIM %s: %w", name, err) @@ -1347,7 +1347,7 @@ func (r *FabricReconciler) reconcileNVE(ctx context.Context, device *v1alpha1.De VirtualMAC: fabric.Spec.VTEP.AnycastGateway.VirtualMAC, } } - return controllerutil.SetControllerReference(fabric, nve, r.Scheme) + return controllerutil.SetOwnerReference(fabric, nve, r.Scheme) }) if err != nil { return fmt.Errorf("reconciling NVE %s: %w", name, err) diff --git a/internal/controller/evpn/fabric_controller_test.go b/internal/controller/evpn/fabric_controller_test.go index 9e4435f53..519893cd2 100644 --- a/internal/controller/evpn/fabric_controller_test.go +++ b/internal/controller/evpn/fabric_controller_test.go @@ -369,7 +369,7 @@ var _ = Describe("Fabric Controller", func() { g.Expect(ospf.OwnerReferences).To(ContainElement(SatisfyAll( HaveField("Kind", "Fabric"), HaveField("Name", fabric.Name), - HaveField("Controller", HaveValue(BeTrue())), + HaveField("Controller", BeNil()), ))) }).Should(Succeed()) } @@ -399,7 +399,7 @@ var _ = Describe("Fabric Controller", func() { g.Expect(bgp.OwnerReferences).To(ContainElement(SatisfyAll( HaveField("Kind", "Fabric"), HaveField("Name", fabric.Name), - HaveField("Controller", HaveValue(BeTrue())), + HaveField("Controller", BeNil()), ))) }).Should(Succeed()) } @@ -480,7 +480,7 @@ var _ = Describe("Fabric Controller", func() { g.Expect(pim.OwnerReferences).To(ContainElement(SatisfyAll( HaveField("Kind", "Fabric"), HaveField("Name", fabric.Name), - HaveField("Controller", HaveValue(BeTrue())), + HaveField("Controller", BeNil()), ))) }).Should(Succeed()) } @@ -524,7 +524,7 @@ var _ = Describe("Fabric Controller", func() { g.Expect(nve.OwnerReferences).To(ContainElement(SatisfyAll( HaveField("Kind", "Fabric"), HaveField("Name", fabric.Name), - HaveField("Controller", HaveValue(BeTrue())), + HaveField("Controller", BeNil()), ))) }).Should(Succeed()) } @@ -972,7 +972,7 @@ var _ = Describe("Fabric Controller", func() { g.Expect(isis.OwnerReferences).To(ContainElement(SatisfyAll( HaveField("Kind", "Fabric"), HaveField("Name", fabric.Name), - HaveField("Controller", HaveValue(BeTrue())), + HaveField("Controller", BeNil()), ))) }).Should(Succeed()) }