From ca2b3d5cecfd8b4c01ff04b8207c7e1f14f79c60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Thu, 23 Jul 2026 17:00:17 +0200 Subject: [PATCH] Implement EVPN Fabric VTEP NVE reconciliation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a reconcileVTEPNVE phase that creates one NetworkVirtualizationEdge resource per VTEP device. NVE is configured with BGP host reachability, ARP suppression, lo1 as source interface, lo2 as anycast source, and the shared anycast gateway virtual MAC from the fabric spec. Signed-off-by: Felix Kästner --- internal/controller/evpn/fabric_controller.go | 73 +++++++++++++++++++ .../controller/evpn/fabric_controller_test.go | 21 ++++++ 2 files changed, 94 insertions(+) diff --git a/internal/controller/evpn/fabric_controller.go b/internal/controller/evpn/fabric_controller.go index b12ab280e..43a3bbd22 100644 --- a/internal/controller/evpn/fabric_controller.go +++ b/internal/controller/evpn/fabric_controller.go @@ -60,6 +60,7 @@ type FabricReconciler struct { // +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=bgp,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=bgppeers,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=pim,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=networkvirtualizationedges,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=pool.networking.metal.ironcore.dev,resources=claims,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=pool.networking.metal.ironcore.dev,resources=ipaddresspools,verbs=get;list;watch // +kubebuilder:rbac:groups=pool.networking.metal.ironcore.dev,resources=ipprefixpools,verbs=get;list;watch @@ -175,6 +176,7 @@ func (r *FabricReconciler) SetupWithManager(mgr ctrl.Manager) error { Owns(&v1alpha1.BGP{}). Owns(&v1alpha1.BGPPeer{}). Owns(&v1alpha1.PIM{}). + Owns(&v1alpha1.NetworkVirtualizationEdge{}). // Re-reconcile when a Device's labels change so that devices newly // matching a deviceSelector are enrolled into the fabric. Watches( @@ -218,6 +220,7 @@ func (r *FabricReconciler) reconcile(ctx context.Context, fabric *evpnv1alpha1.F r.reconcileUnderlayIGP, r.reconcileOverlayBGP, r.reconcileMulticastPIM, + r.reconcileVTEPNVE, } for _, phase := range phases { res, err := phase(ctx, fabric, state) @@ -1139,6 +1142,76 @@ func (r *FabricReconciler) reconcilePIM(ctx context.Context, deviceName string, return nil } +// reconcileVTEPNVE creates one NetworkVirtualizationEdge resource per VTEP device. +// The NVE references lo1 (primary VTEP) as source and lo2 (anycast VTEP) as anycast source. +func (r *FabricReconciler) reconcileVTEPNVE(ctx context.Context, fabric *evpnv1alpha1.Fabric, state *ReconcileState) (ctrl.Result, error) { + selector, err := metav1.LabelSelectorAsSelector(&fabric.Spec.VTEP.DeviceSelector) + if err != nil { + return ctrl.Result{}, reconcile.TerminalError(fmt.Errorf("invalid vtep deviceSelector: %w", err)) + } + + devices := &v1alpha1.DeviceList{} + 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) + } + + for i := range devices.Items { + device := &devices.Items[i] + + lo1Name := fmt.Sprintf("%s-%s-lo%d", fabric.Name, device.Name, LoopbackVTEP) + lo2Name := fmt.Sprintf("%s-%s-lo%d", fabric.Name, device.Name, LoopbackVTEPAnycast) + + // Skip if lo1 is not yet allocated. + loopbacks := state.loopbacks[device.Name] + if !slices.ContainsFunc(loopbacks, func(intf *v1alpha1.Interface) bool { return intf.Name == lo1Name }) { + ctrl.LoggerFrom(ctx).V(1).Info("Skipping NVE reconciliation: lo1 not yet allocated", "device", device.Name) + continue + } + + if err := r.reconcileNVE(ctx, device, fabric, lo1Name, lo2Name); err != nil { + return ctrl.Result{}, err + } + } + return ctrl.Result{}, nil +} + +// reconcileNVE creates or updates the NetworkVirtualizationEdge resource for a VTEP device. +func (r *FabricReconciler) reconcileNVE(ctx context.Context, device *v1alpha1.Device, fabric *evpnv1alpha1.Fabric, lo1Name, lo2Name string) error { + name := fmt.Sprintf("%s-%s-nve", fabric.Name, device.Name) + + nve := &v1alpha1.NetworkVirtualizationEdge{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: fabric.Namespace, + }, + } + res, err := controllerutil.CreateOrPatch(ctx, r.Client, nve, func() error { + if nve.Labels == nil { + nve.Labels = make(map[string]string) + } + nve.Labels[evpnv1alpha1.FabricLabel] = fabric.Name + nve.Spec.DeviceRef = v1alpha1.LocalObjectReference{Name: device.Name} + nve.Spec.AdminState = v1alpha1.AdminStateUp + nve.Spec.HostReachability = v1alpha1.HostReachabilityTypeBGP + nve.Spec.SuppressARP = true + nve.Spec.SourceInterfaceRef = v1alpha1.LocalObjectReference{Name: lo1Name} + nve.Spec.AnycastSourceInterfaceRef = &v1alpha1.LocalObjectReference{Name: lo2Name} + if fabric.Spec.VTEP.AnycastGateway != nil { + nve.Spec.AnycastGateway = &v1alpha1.AnycastGateway{ + VirtualMAC: fabric.Spec.VTEP.AnycastGateway.VirtualMAC, + } + } + return controllerutil.SetControllerReference(fabric, nve, r.Scheme) + }) + if err != nil { + return fmt.Errorf("reconciling NVE %s: %w", name, err) + } + if res == controllerutil.OperationResultCreated { + r.Recorder.Eventf(fabric, nil, "Normal", "NVECreated", "Reconcile", "Created NVE %s", name) + } + return nil +} + // devicesToFabrics is a [handler.MapFunc] that enqueues all Fabrics whose // spec.deviceSelector matches the labels of the changed Device. func (r *FabricReconciler) devicesToFabrics(ctx context.Context, obj client.Object) []ctrl.Request { diff --git a/internal/controller/evpn/fabric_controller_test.go b/internal/controller/evpn/fabric_controller_test.go index e4bbdd7cf..eef5026a1 100644 --- a/internal/controller/evpn/fabric_controller_test.go +++ b/internal/controller/evpn/fabric_controller_test.go @@ -497,6 +497,27 @@ var _ = Describe("Fabric Controller", func() { }).Should(Succeed()) } + By("Verifying NVE resources are created for VTEP devices (leaves)") + for _, leaf := range []*corev1alpha1.Device{leaf1, leaf2} { + Eventually(func(g Gomega) { + nve := &corev1alpha1.NetworkVirtualizationEdge{} + g.Expect(k8sClient.Get(ctx, client.ObjectKey{Name: fabric.Name + "-" + leaf.Name + "-nve", Namespace: metav1.NamespaceDefault}, nve)).To(Succeed()) + g.Expect(nve.Spec.DeviceRef.Name).To(Equal(leaf.Name)) + g.Expect(nve.Spec.AdminState).To(Equal(corev1alpha1.AdminStateUp)) + g.Expect(nve.Spec.HostReachability).To(Equal(corev1alpha1.HostReachabilityTypeBGP)) + g.Expect(nve.Spec.SuppressARP).To(BeTrue()) + g.Expect(nve.Spec.SourceInterfaceRef.Name).To(Equal(fabric.Name + "-" + leaf.Name + "-lo1")) + g.Expect(nve.Spec.AnycastSourceInterfaceRef).NotTo(BeNil()) + g.Expect(nve.Spec.AnycastSourceInterfaceRef.Name).To(Equal(fabric.Name + "-" + leaf.Name + "-lo2")) + + g.Expect(nve.OwnerReferences).To(ContainElement(SatisfyAll( + HaveField("Kind", "Fabric"), + HaveField("Name", fabric.Name), + HaveField("Controller", HaveValue(BeTrue())), + ))) + }).Should(Succeed()) + } + By("Verifying the Fabric Ready condition is True once all phases are complete") Eventually(func(g Gomega) { f := &evpnv1alpha1.Fabric{}