From 02584bb80c3a461803642db357031a382bd68a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Tue, 7 Jul 2026 10:50:13 +0200 Subject: [PATCH 1/7] Deprecate VNI field on VRF in favor of EVPNInstance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The L3 VNI is better suited on the EVPNInstance resource where it is already present. Mark the VRF spec.vni field as deprecated and emit an admission warning when it is set, guiding users to migrate to the EVPNInstance VNI field instead. Signed-off-by: Felix Kästner --- api/core/v1alpha1/vrf_types.go | 2 ++ .../vrfs.networking.metal.ironcore.dev.yaml | 6 +++-- .../networking.metal.ironcore.dev_vrfs.yaml | 6 +++-- docs/api-reference/index.md | 2 +- internal/provider/cisco/nxos/provider.go | 5 ++-- internal/webhook/core/v1alpha1/vrf_webhook.go | 14 +++++++++-- .../webhook/core/v1alpha1/vrf_webhook_test.go | 23 +++++++++++++++++++ 7 files changed, 49 insertions(+), 9 deletions(-) diff --git a/api/core/v1alpha1/vrf_types.go b/api/core/v1alpha1/vrf_types.go index d4b2452f3..7eba43fea 100644 --- a/api/core/v1alpha1/vrf_types.go +++ b/api/core/v1alpha1/vrf_types.go @@ -39,6 +39,8 @@ type VRFSpec struct { Description string `json:"description,omitempty"` // VNI is the VXLAN Network Identifier for the VRF (always an L3). + // + // Deprecated: Use the VNI field on the EVPNInstance resource instead. This field will be removed in a future release. // +optional // +kubebuilder:validation:Minimum=1 // +kubebuilder:validation:Maximum=16777215 diff --git a/charts/network-operator/templates/crd/vrfs.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/vrfs.networking.metal.ironcore.dev.yaml index 68b6c6b49..94dc15a62 100644 --- a/charts/network-operator/templates/crd/vrfs.networking.metal.ironcore.dev.yaml +++ b/charts/network-operator/templates/crd/vrfs.networking.metal.ironcore.dev.yaml @@ -186,8 +186,10 @@ spec: - value x-kubernetes-list-type: map vni: - description: VNI is the VXLAN Network Identifier for the VRF (always - an L3). + description: |- + VNI is the VXLAN Network Identifier for the VRF (always an L3). + + Deprecated: Use the VNI field on the EVPNInstance resource instead. This field will be removed in a future release. format: int32 maximum: 16777215 minimum: 1 diff --git a/config/crd/bases/networking.metal.ironcore.dev_vrfs.yaml b/config/crd/bases/networking.metal.ironcore.dev_vrfs.yaml index 6e8df1183..dee1199b3 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_vrfs.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_vrfs.yaml @@ -183,8 +183,10 @@ spec: - value x-kubernetes-list-type: map vni: - description: VNI is the VXLAN Network Identifier for the VRF (always - an L3). + description: |- + VNI is the VXLAN Network Identifier for the VRF (always an L3). + + Deprecated: Use the VNI field on the EVPNInstance resource instead. This field will be removed in a future release. format: int32 maximum: 16777215 minimum: 1 diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index c66cf4eba..d6badd762 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -3690,7 +3690,7 @@ _Appears in:_ | `providerConfigRef` _[TypedLocalObjectReference](#typedlocalobjectreference)_ | ProviderConfigRef is a reference to a resource holding the provider-specific configuration of this interface.
This reference is used to link the VRF to its provider-specific configuration. | | Optional: \{\}
| | `name` _string_ | Name is the name of the VRF.
Immutable. | | MaxLength: 32
MinLength: 1
Required: \{\}
| | `description` _string_ | Description provides a human-readable description of the VRF. | | MaxLength: 255
MinLength: 1
Optional: \{\}
| -| `vni` _integer_ | VNI is the VXLAN Network Identifier for the VRF (always an L3). | | Maximum: 1.6777215e+07
Minimum: 1
Optional: \{\}
| +| `vni` _integer_ | VNI is the VXLAN Network Identifier for the VRF (always an L3).
Deprecated: Use the VNI field on the EVPNInstance resource instead. This field will be removed in a future release. | | Maximum: 1.6777215e+07
Minimum: 1
Optional: \{\}
| | `routeDistinguisher` _string_ | RouteDistinguisher is the route distinguisher for the VRF.
Formats supported:
- Type 0: ASN(0-65535):Number(0-4294967295)
- Type 1: IPv4:Number(0-65535)
- Type 2: ASN(65536-4294967295):Number(0-65535)
Validation via admission webhook for the VRF type. | | Optional: \{\}
| | `routeTargets` _[RouteTarget](#routetarget) array_ | RouteTargets is the list of route targets for the VRF. | | Optional: \{\}
| diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index 72ac8e3dd..8cc8da4db 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -2521,9 +2521,10 @@ func (p *Provider) EnsureVRF(ctx context.Context, req *provider.VRFRequest) erro if req.VRF.Spec.Description != "" { v.Descr = NewOption(req.VRF.Spec.Description) } - if req.VRF.Spec.VNI > 0 { + // TODO: remove use of deprecated VNI field in a future release. + if req.VRF.Spec.VNI > 0 { //nolint:staticcheck // handling deprecated field for backward compatibility v.L3Vni = true - v.Encap = NewOption("vxlan-" + strconv.FormatUint(uint64(req.VRF.Spec.VNI), 10)) + v.Encap = NewOption("vxlan-" + strconv.FormatUint(uint64(req.VRF.Spec.VNI), 10)) //nolint:staticcheck } dom := new(VRFDom) diff --git a/internal/webhook/core/v1alpha1/vrf_webhook.go b/internal/webhook/core/v1alpha1/vrf_webhook.go index d66c9f1aa..2e6eebcc0 100644 --- a/internal/webhook/core/v1alpha1/vrf_webhook.go +++ b/internal/webhook/core/v1alpha1/vrf_webhook.go @@ -41,14 +41,24 @@ var _ admission.Validator[*v1alpha1.VRF] = &VRFCustomValidator{} func (v *VRFCustomValidator) ValidateCreate(_ context.Context, vrf *v1alpha1.VRF) (admission.Warnings, error) { vrflog.Info("Validation for VRF upon creation", "name", vrf.GetName()) - return nil, validateVRFSpec(vrf) + var warnings admission.Warnings + if vrf.Spec.VNI > 0 { //nolint:staticcheck // handling deprecated field for backward compatibility + warnings = append(warnings, "spec.vni is deprecated; use the vni field on the EVPNInstance resource instead") + } + + return warnings, validateVRFSpec(vrf) } // ValidateUpdate implements admission.Validator so a webhook will be registered for the type VRF. func (v *VRFCustomValidator) ValidateUpdate(_ context.Context, _, vrf *v1alpha1.VRF) (admission.Warnings, error) { vrflog.Info("Validation for VRF upon update", "name", vrf.GetName()) - return nil, validateVRFSpec(vrf) + var warnings admission.Warnings + if vrf.Spec.VNI > 0 { //nolint:staticcheck // handling deprecated field for backward compatibility + warnings = append(warnings, "spec.vni is deprecated; use the vni field on the EVPNInstance resource instead") + } + + return warnings, validateVRFSpec(vrf) } // ValidateDelete implements admission.Validator so a webhook will be registered for the type VRF. diff --git a/internal/webhook/core/v1alpha1/vrf_webhook_test.go b/internal/webhook/core/v1alpha1/vrf_webhook_test.go index f7ebd1be8..6cfede00f 100644 --- a/internal/webhook/core/v1alpha1/vrf_webhook_test.go +++ b/internal/webhook/core/v1alpha1/vrf_webhook_test.go @@ -280,4 +280,27 @@ var _ = Describe("VRF Webhook", func() { Expect(err).To(HaveOccurred()) }) }) + + Context("Deprecated VNI field", func() { + It("returns deprecation warning on create when VNI is set", func() { + obj.Spec.VNI = 100010 //nolint:staticcheck + warnings, err := validator.ValidateCreate(ctx, obj) + Expect(err).NotTo(HaveOccurred()) + Expect(warnings).To(ContainElement(ContainSubstring("spec.vni is deprecated"))) + }) + + It("returns no warning on create when VNI is not set", func() { + warnings, err := validator.ValidateCreate(ctx, obj) + Expect(err).NotTo(HaveOccurred()) + Expect(warnings).To(BeEmpty()) + }) + + It("returns deprecation warning on update when VNI is set", func() { + newObj := obj.DeepCopy() + newObj.Spec.VNI = 100010 //nolint:staticcheck + warnings, err := validator.ValidateUpdate(ctx, obj, newObj) + Expect(err).NotTo(HaveOccurred()) + Expect(warnings).To(ContainElement(ContainSubstring("spec.vni is deprecated"))) + }) + }) }) From a17b31c5348903df271aaeb978b0589bfc93960a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Tue, 7 Jul 2026 13:22:48 +0200 Subject: [PATCH 2/7] Add VRFRef to EVPNInstance and move L3VNI to EVI provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a vrfRef field to EVPNInstance for Routed type (L3VNI/IP-VRF), mirroring the existing vlanRef for Bridged type. The controller resolves the VRF, validates same-device ownership, and passes it to the provider. Refactor the NX-OS provider to split VRF YANG writes between two controllers: - EnsureVRF patches leaf fields (name, description) and replaces the domain items (RD, route targets) via Update. - EnsureEVPNInstance patches L3VNI/Encap on the VRF separately for Routed type instances. - DeleteEVPNInstance clears L3VNI/Encap when the VRF still exists. This avoids the two controllers overwriting each other's fields via gNMI Replace on the same YANG subtree. Signed-off-by: Felix Kästner --- api/core/v1alpha1/evpninstance_types.go | 9 ++ api/core/v1alpha1/zz_generated.deepcopy.go | 5 + ...stances.networking.metal.ironcore.dev.yaml | 23 ++++ ...king.metal.ironcore.dev_evpninstances.yaml | 23 ++++ docs/api-reference/index.md | 1 + .../core/evpninstance_controller.go | 122 +++++++++++++++++- internal/provider/cisco/nxos/provider.go | 46 +++++-- .../provider/cisco/nxos/testdata/vrf.json | 89 +------------ .../provider/cisco/nxos/testdata/vrf_dom.json | 94 ++++++++++++++ .../cisco/nxos/testdata/vrf_encap.json | 11 ++ internal/provider/cisco/nxos/vrf.go | 37 +++++- internal/provider/cisco/nxos/vrf_test.go | 13 +- internal/provider/provider.go | 1 + 13 files changed, 368 insertions(+), 106 deletions(-) create mode 100644 internal/provider/cisco/nxos/testdata/vrf_dom.json create mode 100644 internal/provider/cisco/nxos/testdata/vrf_encap.json diff --git a/api/core/v1alpha1/evpninstance_types.go b/api/core/v1alpha1/evpninstance_types.go index 6b58d6296..cdf8a9f23 100644 --- a/api/core/v1alpha1/evpninstance_types.go +++ b/api/core/v1alpha1/evpninstance_types.go @@ -17,6 +17,7 @@ import ( // [RFC 8365]: https://datatracker.ietf.org/doc/html/rfc8365 // // +kubebuilder:validation:XValidation:rule="self.type != 'Bridged' || has(self.vlanRef)",message="VLANRef must be specified when Type is Bridged" +// +kubebuilder:validation:XValidation:rule="self.type != 'Routed' || has(self.vrfRef)",message="VRFRef must be specified when Type is Routed" type EVPNInstanceSpec struct { // DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace. // Immutable. @@ -71,6 +72,14 @@ type EVPNInstanceSpec struct { // +optional // +kubebuilder:validation:XValidation:rule="self.name == oldSelf.name",message="VLANRef is immutable" VLANRef *LocalObjectReference `json:"vlanRef,omitempty"` + + // VRFRef is a reference to a VRF resource for which this EVPNInstance provides the L3VNI. + // This field is only applicable when Type is Routed (L3VNI). + // The VRF resource must exist in the same namespace. + // Immutable. + // +optional + // +kubebuilder:validation:XValidation:rule="self.name == oldSelf.name",message="VRFRef is immutable" + VRFRef *LocalObjectReference `json:"vrfRef,omitempty"` } // EVPNInstanceType defines the type of EVPN instance. diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go index 1fd3266f2..11dfde574 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -1770,6 +1770,11 @@ func (in *EVPNInstanceSpec) DeepCopyInto(out *EVPNInstanceSpec) { *out = new(LocalObjectReference) **out = **in } + if in.VRFRef != nil { + in, out := &in.VRFRef, &out.VRFRef + *out = new(LocalObjectReference) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EVPNInstanceSpec. diff --git a/charts/network-operator/templates/crd/evpninstances.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/evpninstances.networking.metal.ironcore.dev.yaml index 5d9de7d1d..a65b87886 100644 --- a/charts/network-operator/templates/crd/evpninstances.networking.metal.ironcore.dev.yaml +++ b/charts/network-operator/templates/crd/evpninstances.networking.metal.ironcore.dev.yaml @@ -207,6 +207,27 @@ spec: x-kubernetes-validations: - message: VNI is immutable rule: self == oldSelf + vrfRef: + description: |- + VRFRef is a reference to a VRF resource for which this EVPNInstance provides the L3VNI. + This field is only applicable when Type is Routed (L3VNI). + The VRF resource must exist in the same namespace. + Immutable. + properties: + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: VRFRef is immutable + rule: self.name == oldSelf.name required: - deviceRef - type @@ -215,6 +236,8 @@ spec: x-kubernetes-validations: - message: VLANRef must be specified when Type is Bridged rule: self.type != 'Bridged' || has(self.vlanRef) + - message: VRFRef must be specified when Type is Routed + rule: self.type != 'Routed' || has(self.vrfRef) status: description: |- Status of the resource. This is set and updated automatically. diff --git a/config/crd/bases/networking.metal.ironcore.dev_evpninstances.yaml b/config/crd/bases/networking.metal.ironcore.dev_evpninstances.yaml index 4007ecd93..f71a3f6ac 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_evpninstances.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_evpninstances.yaml @@ -204,6 +204,27 @@ spec: x-kubernetes-validations: - message: VNI is immutable rule: self == oldSelf + vrfRef: + description: |- + VRFRef is a reference to a VRF resource for which this EVPNInstance provides the L3VNI. + This field is only applicable when Type is Routed (L3VNI). + The VRF resource must exist in the same namespace. + Immutable. + properties: + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: VRFRef is immutable + rule: self.name == oldSelf.name required: - deviceRef - type @@ -212,6 +233,8 @@ spec: x-kubernetes-validations: - message: VLANRef must be specified when Type is Bridged rule: self.type != 'Bridged' || has(self.vlanRef) + - message: VRFRef must be specified when Type is Routed + rule: self.type != 'Routed' || has(self.vrfRef) status: description: |- Status of the resource. This is set and updated automatically. diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index d6badd762..3359a9c2e 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -1415,6 +1415,7 @@ _Appears in:_ | `routeDistinguisher` _string_ | RouteDistinguisher is the route distinguisher for the EVI.
Formats supported:
- Type 0: ASN(0-65535):Number(0-4294967295)
- Type 1: IPv4:Number(0-65535)
- Type 2: ASN(65536-4294967295):Number(0-65535) | | Optional: \{\}
| | `routeTargets` _[EVPNRouteTarget](#evpnroutetarget) array_ | RouteTargets is the list of route targets for the EVI. | | MinItems: 1
Optional: \{\}
| | `vlanRef` _[LocalObjectReference](#localobjectreference)_ | VLANRef is a reference to a VLAN resource for which this EVPNInstance builds the MAC-VRF.
This field is only applicable when Type is Bridged (L2VNI).
The VLAN resource must exist in the same namespace.
Immutable. | | Optional: \{\}
| +| `vrfRef` _[LocalObjectReference](#localobjectreference)_ | VRFRef is a reference to a VRF resource for which this EVPNInstance provides the L3VNI.
This field is only applicable when Type is Routed (L3VNI).
The VRF resource must exist in the same namespace.
Immutable. | | Optional: \{\}
| #### EVPNInstanceStatus diff --git a/internal/controller/core/evpninstance_controller.go b/internal/controller/core/evpninstance_controller.go index fa6be4b1a..0d69ef604 100644 --- a/internal/controller/core/evpninstance_controller.go +++ b/internal/controller/core/evpninstance_controller.go @@ -204,7 +204,10 @@ func (r *EVPNInstanceReconciler) Reconcile(ctx context.Context, req ctrl.Request return ctrl.Result{}, nil } -var eviVlanRefKey = ".spec.vlanRef.name" +var ( + eviVlanRefKey = ".spec.vlanRef.name" + eviVrfRefKey = ".spec.vrfRef.name" +) // SetupWithManager sets up the controller with the Manager. func (r *EVPNInstanceReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { @@ -228,6 +231,16 @@ func (r *EVPNInstanceReconciler) SetupWithManager(ctx context.Context, mgr ctrl. return err } + if err := mgr.GetFieldIndexer().IndexField(ctx, &v1alpha1.EVPNInstance{}, eviVrfRefKey, func(obj client.Object) []string { + evi := obj.(*v1alpha1.EVPNInstance) + if evi.Spec.VRFRef == nil { + return nil + } + return []string{evi.Spec.VRFRef.Name} + }); err != nil { + return err + } + if err := mgr.GetFieldIndexer().IndexField(ctx, &v1alpha1.EVPNInstance{}, v1alpha1.DeviceRefIndexKey, func(obj client.Object) []string { o := obj.(*v1alpha1.EVPNInstance) return []string{o.Spec.DeviceRef.Name} @@ -266,6 +279,20 @@ func (r *EVPNInstanceReconciler) SetupWithManager(ctx context.Context, mgr ctrl. }, }), ). + // Watches enqueues EVPNInstances for updates in referenced VRF resources. + // Only triggers on create and delete events since VRF names are immutable. + Watches( + &v1alpha1.VRF{}, + handler.EnqueueRequestsFromMapFunc(r.vrfToEVPNInstance), + builder.WithPredicates(predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + return false + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + }), + ). // Watches enqueues EVPNInstances for updates in referenced Device resources. // Triggers on create, delete, and update events when the device's effective pause state changes. Watches( @@ -315,6 +342,15 @@ func (r *EVPNInstanceReconciler) reconcile(ctx context.Context, s *eviScope) (re } } + var vrf *v1alpha1.VRF + if s.EVPNInstance.Spec.Type == v1alpha1.EVPNInstanceTypeRouted && s.EVPNInstance.Spec.VRFRef != nil { + var err error + vrf, err = r.reconcileVRF(ctx, s) + if err != nil { + return err + } + } + if err := s.Provider.Connect(ctx, s.Connection); err != nil { return fmt.Errorf("failed to connect to provider: %w", err) } @@ -329,6 +365,7 @@ func (r *EVPNInstanceReconciler) reconcile(ctx context.Context, s *eviScope) (re EVPNInstance: s.EVPNInstance, ProviderConfig: s.ProviderConfig, VLAN: vlan, + VRF: vrf, }) cond := conditions.FromError(err) @@ -402,11 +439,60 @@ func (r *EVPNInstanceReconciler) reconcileVLAN(ctx context.Context, s *eviScope) return vlan, nil } +// reconcileVRF ensures that the referenced VRF exists and belongs to the same device as the EVPNInstance. +func (r *EVPNInstanceReconciler) reconcileVRF(ctx context.Context, s *eviScope) (*v1alpha1.VRF, error) { + key := client.ObjectKey{ + Name: s.EVPNInstance.Spec.VRFRef.Name, + Namespace: s.EVPNInstance.Namespace, + } + + vrf := new(v1alpha1.VRF) + if err := r.Get(ctx, key, vrf); err != nil { + if apierrors.IsNotFound(err) { + conditions.Set(s.EVPNInstance, metav1.Condition{ + Type: v1alpha1.ReadyCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.VRFNotFoundReason, + Message: fmt.Sprintf("referenced VRF %q not found", key), + }) + return nil, reconcile.TerminalError(fmt.Errorf("referenced VRF %q not found", key)) + } + return nil, fmt.Errorf("failed to get referenced VRF %q: %w", key, err) + } + + if vrf.Spec.DeviceRef.Name != s.Device.Name { + conditions.Set(s.EVPNInstance, metav1.Condition{ + Type: v1alpha1.ReadyCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.CrossDeviceReferenceReason, + Message: fmt.Sprintf("referenced VRF %q does not belong to device %q", vrf.Name, s.Device.Name), + }) + return nil, reconcile.TerminalError(fmt.Errorf("referenced VRF %q does not belong to device %q", vrf.Name, s.Device.Name)) + } + + return vrf, nil +} + func (r *EVPNInstanceReconciler) finalize(ctx context.Context, s *eviScope) (reterr error) { if err := r.finalizeVLAN(ctx, s); err != nil { return err } + var vrf *v1alpha1.VRF + if s.EVPNInstance.Spec.VRFRef != nil { + vrf = new(v1alpha1.VRF) + err := r.Get(ctx, client.ObjectKey{ + Name: s.EVPNInstance.Spec.VRFRef.Name, + Namespace: s.EVPNInstance.Namespace, + }, vrf) + if client.IgnoreNotFound(err) != nil { + return fmt.Errorf("failed to get referenced VRF: %w", err) + } + if err != nil { + vrf = nil + } + } + if err := s.Provider.Connect(ctx, s.Connection); err != nil { return fmt.Errorf("failed to connect to provider: %w", err) } @@ -419,6 +505,7 @@ func (r *EVPNInstanceReconciler) finalize(ctx context.Context, s *eviScope) (ret return s.Provider.DeleteEVPNInstance(ctx, &provider.EVPNInstanceRequest{ EVPNInstance: s.EVPNInstance, ProviderConfig: s.ProviderConfig, + VRF: vrf, }) } @@ -520,6 +607,39 @@ func (r *EVPNInstanceReconciler) vlanToEVPNInstance(ctx context.Context, obj cli return requests } +// vrfToEVPNInstance is a [handler.MapFunc] to be used to enqueue requests for reconciliation +// for an EVPNInstance when its referenced VRF changes. +func (r *EVPNInstanceReconciler) vrfToEVPNInstance(ctx context.Context, obj client.Object) []ctrl.Request { + vrf, ok := obj.(*v1alpha1.VRF) + if !ok { + panic(fmt.Sprintf("Expected a VRF but got a %T", obj)) + } + + log := ctrl.LoggerFrom(ctx, "VRF", klog.KObj(vrf)) + + evpnInstances := new(v1alpha1.EVPNInstanceList) + if err := r.List(ctx, evpnInstances, client.InNamespace(vrf.Namespace), client.MatchingFields{eviVrfRefKey: vrf.Name}); err != nil { + log.Error(err, "Failed to list EVPNInstances") + return nil + } + + requests := []ctrl.Request{} + for _, evi := range evpnInstances.Items { + if evi.Spec.VRFRef != nil && evi.Spec.VRFRef.Name == vrf.Name { + log.V(2).Info("Enqueuing EVPNInstance for reconciliation", "EVPNInstance", klog.KObj(&evi)) + + requests = append(requests, ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: evi.Name, + Namespace: evi.Namespace, + }, + }) + } + } + + return requests +} + // evpnInstancesForProviderConfig is a [handler.MapFunc] to be used to enqueue requests for reconciliation // for a EVPNInstance to update when one of its referenced provider configurations gets updated. func (r *EVPNInstanceReconciler) evpnInstancesForProviderConfig(ctx context.Context, obj client.Object) []reconcile.Request { diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index 8cc8da4db..a5f418715 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -810,10 +810,37 @@ func (p *Provider) EnsureEVPNInstance(ctx context.Context, req *provider.EVPNIns vni.AssociateVrfFlag = true } - return p.Update(ctx, updates...) + if err := p.Update(ctx, updates...); err != nil { + return err + } + + // Patch L3VNI/Encap on the VRF separately. This merges into the existing + // VRF tree without replacing fields managed by EnsureVRF. + if req.EVPNInstance.Spec.Type == v1alpha1.EVPNInstanceTypeRouted && req.VRF != nil { + vrf := new(VRFEncap) + vrf.Name = req.VRF.Spec.Name + vrf.L3Vni = true + vrf.Encap = NewOption("vxlan-" + strconv.FormatInt(int64(req.EVPNInstance.Spec.VNI), 10)) + if err := p.Patch(ctx, vrf); err != nil { + return err + } + } + + return nil } func (p *Provider) DeleteEVPNInstance(ctx context.Context, req *provider.EVPNInstanceRequest) error { + // Clear L3VNI/Encap on the VRF if this is a Routed EVI and the VRF still exists. + // If no VRF is passed in the request, assume it has already been deleted and skip this step. + if req.EVPNInstance.Spec.Type == v1alpha1.EVPNInstanceTypeRouted && req.VRF != nil { + vrf := new(VRFEncap) + vrf.Name = req.VRF.Spec.Name + vrf.L3Vni = false + if err := p.Patch(ctx, vrf); err != nil { + return err + } + } + deletes := make([]gnmiext.DataElement, 0, 3) evi := new(BDEVI) @@ -2521,15 +2548,11 @@ func (p *Provider) EnsureVRF(ctx context.Context, req *provider.VRFRequest) erro if req.VRF.Spec.Description != "" { v.Descr = NewOption(req.VRF.Spec.Description) } - // TODO: remove use of deprecated VNI field in a future release. - if req.VRF.Spec.VNI > 0 { //nolint:staticcheck // handling deprecated field for backward compatibility - v.L3Vni = true - v.Encap = NewOption("vxlan-" + strconv.FormatUint(uint64(req.VRF.Spec.VNI), 10)) //nolint:staticcheck - } dom := new(VRFDom) dom.Name = req.VRF.Spec.Name - v.DomItems.DomList.Set(dom) + domItems := &VRFDomItems{Name: req.VRF.Spec.Name} + domItems.DomList.Set(dom) // pre: RD format has been already been validated by VRFCustomValidator if req.VRF.Spec.RouteDistinguisher != "" { @@ -2651,7 +2674,14 @@ func (p *Provider) EnsureVRF(ctx context.Context, req *provider.VRFRequest) erro dom.AfItems.DomAfList.Set(afIPv6) } - return p.Update(ctx, v) + // Patch the VRF fields (name, description), merges into existing tree + // to preserve L3Vni/Encap set by EnsureEVPNInstance. + if err := p.Patch(ctx, v); err != nil { + return err + } + + // Replace the VRF domain items (RD, route targets), fully owned by this controller. + return p.Update(ctx, domItems) } func (p *Provider) DeleteVRF(ctx context.Context, req *provider.VRFRequest) error { diff --git a/internal/provider/cisco/nxos/testdata/vrf.json b/internal/provider/cisco/nxos/testdata/vrf.json index 33438bfcd..98413e24b 100644 --- a/internal/provider/cisco/nxos/testdata/vrf.json +++ b/internal/provider/cisco/nxos/testdata/vrf.json @@ -2,95 +2,8 @@ "inst-items": { "Inst-list": [ { - "encap": "vxlan-101", - "l3vni": true, "name": "CC-CLOUD01", - "descr": "CC-CLOUD01 VRF", - "dom-items": { - "Dom-list": [ - { - "name": "CC-CLOUD01", - "rd": "rd:as4-nn2:4269539332:101", - "af-items": { - "DomAf-list": [ - { - "type": "ipv4-ucast", - "ctrl-items": { - "AfCtrl-list": [ - { - "type": "l2vpn-evpn", - "rttp-items": { - "RttP-list": [ - { - "type": "export", - "ent-items": { - "RttEntry-list": [ - { - "rtt": "route-target:as2-nn2:65148:4101" - }, - { - "rtt": "route-target:as2-nn2:65148:1101" - } - ] - } - }, - { - "type": "import", - "ent-items": { - "RttEntry-list": [ - { - "rtt": "route-target:as2-nn2:65148:4101" - }, - { - "rtt": "route-target:as2-nn2:65148:1101" - } - ] - } - } - ] - } - }, - { - "type": "ipv4-ucast", - "rttp-items": { - "RttP-list": [ - { - "type": "export", - "ent-items": { - "RttEntry-list": [ - { - "rtt": "route-target:as2-nn2:65148:4101" - }, - { - "rtt": "route-target:as2-nn2:65148:1101" - } - ] - } - }, - { - "type": "import", - "ent-items": { - "RttEntry-list": [ - { - "rtt": "route-target:as2-nn2:65148:4101" - }, - { - "rtt": "route-target:as2-nn2:65148:1101" - } - ] - } - } - ] - } - } - ] - } - } - ] - } - } - ] - } + "descr": "CC-CLOUD01 VRF" } ] } diff --git a/internal/provider/cisco/nxos/testdata/vrf_dom.json b/internal/provider/cisco/nxos/testdata/vrf_dom.json new file mode 100644 index 000000000..9e46f0fd4 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/vrf_dom.json @@ -0,0 +1,94 @@ +{ + "inst-items": { + "Inst-list": [ + { + "name": "CC-CLOUD01", + "dom-items": { + "Dom-list": [ + { + "name": "CC-CLOUD01", + "rd": "rd:as4-nn2:4269539332:101", + "af-items": { + "DomAf-list": [ + { + "type": "ipv4-ucast", + "ctrl-items": { + "AfCtrl-list": [ + { + "type": "l2vpn-evpn", + "rttp-items": { + "RttP-list": [ + { + "type": "export", + "ent-items": { + "RttEntry-list": [ + { + "rtt": "route-target:as2-nn2:65148:4101" + }, + { + "rtt": "route-target:as2-nn2:65148:1101" + } + ] + } + }, + { + "type": "import", + "ent-items": { + "RttEntry-list": [ + { + "rtt": "route-target:as2-nn2:65148:4101" + }, + { + "rtt": "route-target:as2-nn2:65148:1101" + } + ] + } + } + ] + } + }, + { + "type": "ipv4-ucast", + "rttp-items": { + "RttP-list": [ + { + "type": "export", + "ent-items": { + "RttEntry-list": [ + { + "rtt": "route-target:as2-nn2:65148:4101" + }, + { + "rtt": "route-target:as2-nn2:65148:1101" + } + ] + } + }, + { + "type": "import", + "ent-items": { + "RttEntry-list": [ + { + "rtt": "route-target:as2-nn2:65148:4101" + }, + { + "rtt": "route-target:as2-nn2:65148:1101" + } + ] + } + } + ] + } + } + ] + } + } + ] + } + } + ] + } + } + ] + } +} diff --git a/internal/provider/cisco/nxos/testdata/vrf_encap.json b/internal/provider/cisco/nxos/testdata/vrf_encap.json new file mode 100644 index 000000000..bea632de0 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/vrf_encap.json @@ -0,0 +1,11 @@ +{ + "inst-items": { + "Inst-list": [ + { + "encap": "vxlan-101", + "l3vni": true, + "name": "CC-CLOUD01" + } + ] + } +} diff --git a/internal/provider/cisco/nxos/vrf.go b/internal/provider/cisco/nxos/vrf.go index f34f5cab2..6de5bc8ef 100644 --- a/internal/provider/cisco/nxos/vrf.go +++ b/internal/provider/cisco/nxos/vrf.go @@ -11,14 +11,17 @@ const ( ManagementVRFName = "management" ) -var _ gnmiext.DataElement = (*VRF)(nil) +var ( + _ gnmiext.DataElement = (*VRF)(nil) + _ gnmiext.DataElement = (*VRFEncap)(nil) + _ gnmiext.DataElement = (*VRFDomItems)(nil) +) +// VRF represents the VRF YANG container with name and description patched by [Provider.EnsureVRF]. +// It excludes L3Vni/Encap (patched by [Provider.EnsureEVPNInstance]) and DomItems (sent separately via Update). type VRF struct { - Encap Option[string] `json:"encap"` - L3Vni bool `json:"l3vni"` - Name string `json:"name"` - Descr Option[string] `json:"descr"` - DomItems VRFDomItems `json:"dom-items,omitzero"` + Name string `json:"name"` + Descr Option[string] `json:"descr"` } func (*VRF) IsListItem() {} @@ -27,10 +30,32 @@ func (v *VRF) XPath() string { return "System/inst-items/Inst-list[name=" + v.Name + "]" } +// VRFEncap represents the VRF YANG container with L3VNI and encapsulation fields, +// used by [Provider.EnsureEVPNInstance] to patch L3VNI configuration on the VRF. +type VRFEncap struct { + Encap Option[string] `json:"encap"` + L3Vni bool `json:"l3vni"` + Name string `json:"name"` +} + +func (*VRFEncap) IsListItem() {} + +func (v *VRFEncap) XPath() string { + return "System/inst-items/Inst-list[name=" + v.Name + "]" +} + type VRFDomItems struct { + Name string `json:"-"` + DomList gnmiext.List[string, *VRFDom] `json:"Dom-list,omitzero"` } +func (*VRFDomItems) IsListItem() {} + +func (d *VRFDomItems) XPath() string { + return "System/inst-items/Inst-list[name=" + d.Name + "]/dom-items" +} + type VRFDom struct { Name string `json:"name"` Rd string `json:"rd,omitempty"` diff --git a/internal/provider/cisco/nxos/vrf_test.go b/internal/provider/cisco/nxos/vrf_test.go index 7f5a6bf74..70cb2bb37 100644 --- a/internal/provider/cisco/nxos/vrf_test.go +++ b/internal/provider/cisco/nxos/vrf_test.go @@ -47,9 +47,16 @@ func init() { vrf := new(VRF) vrf.Name = "CC-CLOUD01" - vrf.L3Vni = true - vrf.Encap = NewOption("vxlan-101") vrf.Descr = NewOption("CC-CLOUD01 VRF") - vrf.DomItems.DomList.Set(dom) Register("vrf", vrf) + + vrfEncap := new(VRFEncap) + vrfEncap.Name = "CC-CLOUD01" + vrfEncap.L3Vni = true + vrfEncap.Encap = NewOption("vxlan-101") + Register("vrf_encap", vrfEncap) + + domItems := &VRFDomItems{Name: "CC-CLOUD01"} + domItems.DomList.Set(dom) + Register("vrf_dom", domItems) } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index df9a60c74..4ba8ff86f 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -563,6 +563,7 @@ type EVPNInstanceRequest struct { EVPNInstance *v1alpha1.EVPNInstance ProviderConfig *ProviderConfig VLAN *v1alpha1.VLAN + VRF *v1alpha1.VRF } // PrefixSetProvider is the interface for the realization of the PrefixSet objects over different providers. From ae18db6ce87b1c7df609c4a37c44b85e31a9ce3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Tue, 7 Jul 2026 14:41:59 +0200 Subject: [PATCH 3/7] Restrict RouteDistinguisher to Bridged EVPNInstances MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add XValidation rule rejecting routeDistinguisher when type is Routed, since the RD for an IP-VRF is configured on the VRF resource itself. Update field documentation to clarify this constraint. Signed-off-by: Felix Kästner --- api/core/v1alpha1/evpninstance_types.go | 3 +++ .../crd/evpninstances.networking.metal.ironcore.dev.yaml | 4 ++++ .../bases/networking.metal.ironcore.dev_evpninstances.yaml | 4 ++++ docs/api-reference/index.md | 2 +- 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/api/core/v1alpha1/evpninstance_types.go b/api/core/v1alpha1/evpninstance_types.go index cdf8a9f23..7075dbeeb 100644 --- a/api/core/v1alpha1/evpninstance_types.go +++ b/api/core/v1alpha1/evpninstance_types.go @@ -18,6 +18,7 @@ import ( // // +kubebuilder:validation:XValidation:rule="self.type != 'Bridged' || has(self.vlanRef)",message="VLANRef must be specified when Type is Bridged" // +kubebuilder:validation:XValidation:rule="self.type != 'Routed' || has(self.vrfRef)",message="VRFRef must be specified when Type is Routed" +// +kubebuilder:validation:XValidation:rule="self.type != 'Routed' || !has(self.routeDistinguisher)",message="RouteDistinguisher must not be set when Type is Routed" type EVPNInstanceSpec struct { // DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace. // Immutable. @@ -51,6 +52,8 @@ type EVPNInstanceSpec struct { MulticastGroupAddress string `json:"multicastGroupAddress,omitempty"` // RouteDistinguisher is the route distinguisher for the EVI. + // This field is only applicable when Type is Bridged (MAC-VRF). + // For Routed type, the route distinguisher is configured on the referenced VRF instead. // Formats supported: // - Type 0: ASN(0-65535):Number(0-4294967295) // - Type 1: IPv4:Number(0-65535) diff --git a/charts/network-operator/templates/crd/evpninstances.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/evpninstances.networking.metal.ironcore.dev.yaml index a65b87886..da6dd1950 100644 --- a/charts/network-operator/templates/crd/evpninstances.networking.metal.ironcore.dev.yaml +++ b/charts/network-operator/templates/crd/evpninstances.networking.metal.ironcore.dev.yaml @@ -133,6 +133,8 @@ spec: routeDistinguisher: description: |- RouteDistinguisher is the route distinguisher for the EVI. + This field is only applicable when Type is Bridged (MAC-VRF). + For Routed type, the route distinguisher is configured on the referenced VRF instead. Formats supported: - Type 0: ASN(0-65535):Number(0-4294967295) - Type 1: IPv4:Number(0-65535) @@ -238,6 +240,8 @@ spec: rule: self.type != 'Bridged' || has(self.vlanRef) - message: VRFRef must be specified when Type is Routed rule: self.type != 'Routed' || has(self.vrfRef) + - message: RouteDistinguisher must not be set when Type is Routed + rule: self.type != 'Routed' || !has(self.routeDistinguisher) status: description: |- Status of the resource. This is set and updated automatically. diff --git a/config/crd/bases/networking.metal.ironcore.dev_evpninstances.yaml b/config/crd/bases/networking.metal.ironcore.dev_evpninstances.yaml index f71a3f6ac..ddca98df9 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_evpninstances.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_evpninstances.yaml @@ -130,6 +130,8 @@ spec: routeDistinguisher: description: |- RouteDistinguisher is the route distinguisher for the EVI. + This field is only applicable when Type is Bridged (MAC-VRF). + For Routed type, the route distinguisher is configured on the referenced VRF instead. Formats supported: - Type 0: ASN(0-65535):Number(0-4294967295) - Type 1: IPv4:Number(0-65535) @@ -235,6 +237,8 @@ spec: rule: self.type != 'Bridged' || has(self.vlanRef) - message: VRFRef must be specified when Type is Routed rule: self.type != 'Routed' || has(self.vrfRef) + - message: RouteDistinguisher must not be set when Type is Routed + rule: self.type != 'Routed' || !has(self.routeDistinguisher) status: description: |- Status of the resource. This is set and updated automatically. diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index 3359a9c2e..5c62f9607 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -1412,7 +1412,7 @@ _Appears in:_ | `vni` _integer_ | VNI is the VXLAN Network Identifier.
Immutable. | | Maximum: 1.6777214e+07
Minimum: 1
Required: \{\}
| | `type` _[EVPNInstanceType](#evpninstancetype)_ | Type specifies the EVPN instance type.
Immutable. | | Enum: [Bridged Routed]
Required: \{\}
| | `multicastGroupAddress` _string_ | MulticastGroupAddress specifies the IPv4 multicast group address used for BUM (Broadcast, Unknown unicast, Multicast) traffic.
The address must be in the valid multicast range (224.0.0.0 - 239.255.255.255). | | Format: ipv4
Optional: \{\}
| -| `routeDistinguisher` _string_ | RouteDistinguisher is the route distinguisher for the EVI.
Formats supported:
- Type 0: ASN(0-65535):Number(0-4294967295)
- Type 1: IPv4:Number(0-65535)
- Type 2: ASN(65536-4294967295):Number(0-65535) | | Optional: \{\}
| +| `routeDistinguisher` _string_ | RouteDistinguisher is the route distinguisher for the EVI.
This field is only applicable when Type is Bridged (MAC-VRF).
For Routed type, the route distinguisher is configured on the referenced VRF instead.
Formats supported:
- Type 0: ASN(0-65535):Number(0-4294967295)
- Type 1: IPv4:Number(0-65535)
- Type 2: ASN(65536-4294967295):Number(0-65535) | | Optional: \{\}
| | `routeTargets` _[EVPNRouteTarget](#evpnroutetarget) array_ | RouteTargets is the list of route targets for the EVI. | | MinItems: 1
Optional: \{\}
| | `vlanRef` _[LocalObjectReference](#localobjectreference)_ | VLANRef is a reference to a VLAN resource for which this EVPNInstance builds the MAC-VRF.
This field is only applicable when Type is Bridged (L2VNI).
The VLAN resource must exist in the same namespace.
Immutable. | | Optional: \{\}
| | `vrfRef` _[LocalObjectReference](#localobjectreference)_ | VRFRef is a reference to a VRF resource for which this EVPNInstance provides the L3VNI.
This field is only applicable when Type is Routed (L3VNI).
The VRF resource must exist in the same namespace.
Immutable. | | Optional: \{\}
| From 010ec0008a40cb181362069a6c35e44f4485f2c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Tue, 7 Jul 2026 16:52:29 +0200 Subject: [PATCH 4/7] Reuse RouteDistinguisher/RouteTarget helpers in EnsureVRF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace inline RD and RT formatting logic with the existing RouteDistinguisher() and RouteTarget() helpers from evi.go. Signed-off-by: Felix Kästner --- internal/provider/cisco/nxos/provider.go | 48 +++++------------------- internal/provider/cisco/nxos/vrf.go | 6 +-- internal/provider/cisco/nxos/vrf_test.go | 2 +- 3 files changed, 14 insertions(+), 42 deletions(-) diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index a5f418715..526b7335b 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -2554,21 +2554,13 @@ func (p *Provider) EnsureVRF(ctx context.Context, req *provider.VRFRequest) erro domItems := &VRFDomItems{Name: req.VRF.Spec.Name} domItems.DomList.Set(dom) - // pre: RD format has been already been validated by VRFCustomValidator + // RouteDistinguisher is already validated by VRFCustomValidator if req.VRF.Spec.RouteDistinguisher != "" { - tokens := strings.Split(req.VRF.Spec.RouteDistinguisher, ":") - if strings.Contains(tokens[0], ".") { - dom.Rd = "rd:ipv4-nn2:" + req.VRF.Spec.RouteDistinguisher - } else { - asn, err := strconv.ParseUint(tokens[0], 10, 32) - if err != nil { - return fmt.Errorf("invalid ASN in route distinguisher: %w", err) - } - dom.Rd = "rd:asn2-nn4:" + req.VRF.Spec.RouteDistinguisher - if asn < math.MaxUint16 { - dom.Rd = "rd:asn4-nn2:" + req.VRF.Spec.RouteDistinguisher - } + rd, err := RouteDistinguisher(req.VRF.Spec.RouteDistinguisher) + if err != nil { + return fmt.Errorf("vrf: invalid route distinguisher: %w", err) } + dom.Rd = NewOption(rd) } // configure route targets @@ -2582,32 +2574,14 @@ func (p *Provider) EnsureVRF(ctx context.Context, req *provider.VRFRequest) erro importEntryIPv6EVPN := &RttEntry{Type: RttEntryTypeImport} exportEntryIPv6EVPN := &RttEntry{Type: RttEntryTypeExport} - // route targets are already validated by VRFCustomValidator + // RouteTargets are already validated by VRFCustomValidator for _, rt := range req.VRF.Spec.RouteTargets { - rttValue := "route-target:" - tokens := strings.Split(rt.Value, ":") - if strings.Contains(tokens[0], ".") { - rttValue += "ipv4-nn2:" + rt.Value - } else { - asn, err := strconv.ParseUint(tokens[0], 10, 32) - if err != nil { - return fmt.Errorf("invalid ASN in route target: %w", err) - } - if asn > math.MaxUint16 { - rttValue += "as4-nn2:" + rt.Value - } else { - nn, err := strconv.ParseUint(tokens[1], 10, 32) - if err != nil { - return fmt.Errorf("invalid number in route target: %w", err) - } - rttValue += "as2-nn2:" + rt.Value - if nn > math.MaxUint16 { - rttValue += "as2-nn4:" + rt.Value - } - } + rttValue, err := RouteTarget(rt.Value) + if err != nil { + return fmt.Errorf("invalid route target: %w", err) } - rtt := Rtt{Rtt: rttValue} + rtt := Rtt{Rtt: rttValue} for _, af := range rt.AddressFamilies { switch af { case v1alpha1.IPv4: @@ -2645,7 +2619,6 @@ func (p *Provider) EnsureVRF(ctx context.Context, req *provider.VRFRequest) erro } if len(req.VRF.Spec.RouteTargets) > 0 { - // Initialize address families afIPv4 := &VRFDomAf{Type: AddressFamilyIPv4Unicast} afIPv6 := &VRFDomAf{Type: AddressFamilyIPv6Unicast} @@ -2653,7 +2626,6 @@ func (p *Provider) EnsureVRF(ctx context.Context, req *provider.VRFRequest) erro if importE.EntItems.RttEntryList.Len() == 0 && exportE.EntItems.RttEntryList.Len() == 0 { return } - ctrl := &VRFDomAfCtrl{Type: afType} if importE.EntItems.RttEntryList.Len() > 0 { ctrl.RttpItems.RttPList.Set(importE) diff --git a/internal/provider/cisco/nxos/vrf.go b/internal/provider/cisco/nxos/vrf.go index 6de5bc8ef..d51390460 100644 --- a/internal/provider/cisco/nxos/vrf.go +++ b/internal/provider/cisco/nxos/vrf.go @@ -57,9 +57,9 @@ func (d *VRFDomItems) XPath() string { } type VRFDom struct { - Name string `json:"name"` - Rd string `json:"rd,omitempty"` - AfItems VRFDomAfItems `json:"af-items,omitzero"` + Name string `json:"name"` + Rd Option[string] `json:"rd"` + AfItems VRFDomAfItems `json:"af-items,omitzero"` } func (d *VRFDom) Key() string { return d.Name } diff --git a/internal/provider/cisco/nxos/vrf_test.go b/internal/provider/cisco/nxos/vrf_test.go index 70cb2bb37..ebdeea625 100644 --- a/internal/provider/cisco/nxos/vrf_test.go +++ b/internal/provider/cisco/nxos/vrf_test.go @@ -42,7 +42,7 @@ func init() { dom := new(VRFDom) dom.Name = "CC-CLOUD01" - dom.Rd = "rd:as4-nn2:4269539332:101" + dom.Rd = NewOption("rd:as4-nn2:4269539332:101") dom.AfItems.DomAfList.Set(af) vrf := new(VRF) From 3d7cd197617a690028fa45fb1eb83fed305e7c0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Tue, 7 Jul 2026 17:01:11 +0200 Subject: [PATCH 5/7] Check BGP feature is enabled before configuring VRF route distinguisher 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 | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index 526b7335b..1fe80d360 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -2556,6 +2556,15 @@ func (p *Provider) EnsureVRF(ctx context.Context, req *provider.VRFRequest) erro // RouteDistinguisher is already validated by VRFCustomValidator if req.VRF.Spec.RouteDistinguisher != "" { + f := new(Feature) + f.Name = "bgp" + if err := p.client.GetConfig(ctx, f); err != nil && !errors.Is(err, gnmiext.ErrNil) { + return err + } + if f.AdminSt != AdminStEnabled { + return apistatus.NewFailedPreconditionError("bgp feature must be enabled to configure route distinguisher") + } + rd, err := RouteDistinguisher(req.VRF.Spec.RouteDistinguisher) if err != nil { return fmt.Errorf("vrf: invalid route distinguisher: %w", err) From 2e1bc87e7a5aa17bfd0bad855f87e5d0b03d1c22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Tue, 7 Jul 2026 17:09:16 +0200 Subject: [PATCH 6/7] Make BDEVI route distinguisher optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change BDEVI.Rd from plain string to Option[string] so it is only sent to the device when explicitly configured. Skip the RD call in EnsureEVPNInstance when RouteDistinguisher is empty. Signed-off-by: Felix Kästner --- internal/provider/cisco/nxos/evi.go | 4 ++-- internal/provider/cisco/nxos/evi_test.go | 4 +++- internal/provider/cisco/nxos/provider.go | 9 ++++++--- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/internal/provider/cisco/nxos/evi.go b/internal/provider/cisco/nxos/evi.go index 5a693183c..279c1c480 100644 --- a/internal/provider/cisco/nxos/evi.go +++ b/internal/provider/cisco/nxos/evi.go @@ -18,8 +18,8 @@ var _ gnmiext.DataElement = (*BDEVI)(nil) // BDEVI represents a Bridge Domain Ethernet VPN Instance (MAC-VRF). type BDEVI struct { - Encap string `json:"encap"` - Rd string `json:"rd"` + Encap string `json:"encap"` + Rd Option[string] `json:"rd"` RttpItems struct { RttPList gnmiext.List[RttEntryType, *RttEntry] `json:"RttP-list,omitzero"` } `json:"rttp-items,omitzero"` diff --git a/internal/provider/cisco/nxos/evi_test.go b/internal/provider/cisco/nxos/evi_test.go index 9b4841964..975e2dd12 100644 --- a/internal/provider/cisco/nxos/evi_test.go +++ b/internal/provider/cisco/nxos/evi_test.go @@ -10,8 +10,10 @@ func init() { rt := &RttEntry{Type: RttEntryTypeExport} rt.EntItems.RttEntryList.Set(rtt) + rd, _ := RouteDistinguisher("10.0.0.10:65000") //nolint:errcheck + evi := &BDEVI{Encap: "vxlan-100010"} - evi.Rd, _ = RouteDistinguisher("10.0.0.10:65000") //nolint:errcheck + evi.Rd = NewOption(rd) evi.RttpItems.RttPList.Set(rt) Register("evi", evi) } diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index 1fe80d360..4032a9358 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -770,9 +770,12 @@ func (p *Provider) EnsureEVPNInstance(ctx context.Context, req *provider.EVPNIns case v1alpha1.EVPNInstanceTypeBridged: evi := new(BDEVI) evi.Encap = "vxlan-" + strconv.FormatInt(int64(req.EVPNInstance.Spec.VNI), 10) - evi.Rd, err = RouteDistinguisher(req.EVPNInstance.Spec.RouteDistinguisher) - if err != nil { - return fmt.Errorf("evpn instance: invalid route distinguisher: %w", err) + if req.EVPNInstance.Spec.RouteDistinguisher != "" { + rd, err := RouteDistinguisher(req.EVPNInstance.Spec.RouteDistinguisher) + if err != nil { + return fmt.Errorf("evpn instance: invalid route distinguisher: %w", err) + } + evi.Rd = NewOption(rd) } imports := &RttEntry{Type: RttEntryTypeImport} exports := &RttEntry{Type: RttEntryTypeExport} From 632e5ebda2ec3a8a0a8c09626b7ed1eeef23482f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20K=C3=A4stner?= Date: Tue, 7 Jul 2026 17:22:29 +0200 Subject: [PATCH 7/7] Support Auto value for RouteDistinguisher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add RouteDistinguisherAuto constant and accept "Auto" as a special value for the routeDistinguisher field on VRF and EVPNInstance. This maps to "rd auto" on the device CLI (NX-OS: rd:unknown:0:0). The extcommunity helper handles both empty and "Auto" as the auto- derivation case, keeping RouteDistinguisher/RouteTarget as simple wrappers. Signed-off-by: Felix Kästner --- api/core/v1alpha1/evpninstance_types.go | 2 ++ api/core/v1alpha1/vrf_types.go | 5 +++++ .../crd/evpninstances.networking.metal.ironcore.dev.yaml | 2 ++ .../templates/crd/vrfs.networking.metal.ironcore.dev.yaml | 2 ++ .../bases/networking.metal.ironcore.dev_evpninstances.yaml | 2 ++ config/crd/bases/networking.metal.ironcore.dev_vrfs.yaml | 2 ++ docs/api-reference/index.md | 4 ++-- internal/provider/cisco/nxos/evi.go | 3 ++- internal/webhook/core/v1alpha1/vrf_webhook.go | 2 +- internal/webhook/core/v1alpha1/vrf_webhook_test.go | 6 ++++++ 10 files changed, 26 insertions(+), 4 deletions(-) diff --git a/api/core/v1alpha1/evpninstance_types.go b/api/core/v1alpha1/evpninstance_types.go index 7075dbeeb..d38fb01b7 100644 --- a/api/core/v1alpha1/evpninstance_types.go +++ b/api/core/v1alpha1/evpninstance_types.go @@ -54,7 +54,9 @@ type EVPNInstanceSpec struct { // RouteDistinguisher is the route distinguisher for the EVI. // This field is only applicable when Type is Bridged (MAC-VRF). // For Routed type, the route distinguisher is configured on the referenced VRF instead. + // Set to "Auto" for automatic derivation (equivalent to "rd auto"). // Formats supported: + // - "Auto" (automatic derivation) // - Type 0: ASN(0-65535):Number(0-4294967295) // - Type 1: IPv4:Number(0-65535) // - Type 2: ASN(65536-4294967295):Number(0-65535) diff --git a/api/core/v1alpha1/vrf_types.go b/api/core/v1alpha1/vrf_types.go index 7eba43fea..af9d125f3 100644 --- a/api/core/v1alpha1/vrf_types.go +++ b/api/core/v1alpha1/vrf_types.go @@ -11,6 +11,9 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" ) +// RouteDistinguisherAuto is the special value for automatic RD derivation (equivalent to "rd auto"). +const RouteDistinguisherAuto = "Auto" + // VRFSpec defines the desired state of VRF type VRFSpec struct { // DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace. @@ -47,7 +50,9 @@ type VRFSpec struct { VNI uint32 `json:"vni,omitempty"` // RouteDistinguisher is the route distinguisher for the VRF. + // Set to "Auto" for automatic derivation (equivalent to "rd auto"). // Formats supported: + // - "Auto" (automatic derivation) // - Type 0: ASN(0-65535):Number(0-4294967295) // - Type 1: IPv4:Number(0-65535) // - Type 2: ASN(65536-4294967295):Number(0-65535) diff --git a/charts/network-operator/templates/crd/evpninstances.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/evpninstances.networking.metal.ironcore.dev.yaml index da6dd1950..519d3d2d4 100644 --- a/charts/network-operator/templates/crd/evpninstances.networking.metal.ironcore.dev.yaml +++ b/charts/network-operator/templates/crd/evpninstances.networking.metal.ironcore.dev.yaml @@ -135,7 +135,9 @@ spec: RouteDistinguisher is the route distinguisher for the EVI. This field is only applicable when Type is Bridged (MAC-VRF). For Routed type, the route distinguisher is configured on the referenced VRF instead. + Set to "Auto" for automatic derivation (equivalent to "rd auto"). Formats supported: + - "Auto" (automatic derivation) - Type 0: ASN(0-65535):Number(0-4294967295) - Type 1: IPv4:Number(0-65535) - Type 2: ASN(65536-4294967295):Number(0-65535) diff --git a/charts/network-operator/templates/crd/vrfs.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/vrfs.networking.metal.ironcore.dev.yaml index 94dc15a62..c377a8eb3 100644 --- a/charts/network-operator/templates/crd/vrfs.networking.metal.ironcore.dev.yaml +++ b/charts/network-operator/templates/crd/vrfs.networking.metal.ironcore.dev.yaml @@ -137,7 +137,9 @@ spec: routeDistinguisher: description: |- RouteDistinguisher is the route distinguisher for the VRF. + Set to "Auto" for automatic derivation (equivalent to "rd auto"). Formats supported: + - "Auto" (automatic derivation) - Type 0: ASN(0-65535):Number(0-4294967295) - Type 1: IPv4:Number(0-65535) - Type 2: ASN(65536-4294967295):Number(0-65535) diff --git a/config/crd/bases/networking.metal.ironcore.dev_evpninstances.yaml b/config/crd/bases/networking.metal.ironcore.dev_evpninstances.yaml index ddca98df9..8ee981b5f 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_evpninstances.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_evpninstances.yaml @@ -132,7 +132,9 @@ spec: RouteDistinguisher is the route distinguisher for the EVI. This field is only applicable when Type is Bridged (MAC-VRF). For Routed type, the route distinguisher is configured on the referenced VRF instead. + Set to "Auto" for automatic derivation (equivalent to "rd auto"). Formats supported: + - "Auto" (automatic derivation) - Type 0: ASN(0-65535):Number(0-4294967295) - Type 1: IPv4:Number(0-65535) - Type 2: ASN(65536-4294967295):Number(0-65535) diff --git a/config/crd/bases/networking.metal.ironcore.dev_vrfs.yaml b/config/crd/bases/networking.metal.ironcore.dev_vrfs.yaml index dee1199b3..535cd7b04 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_vrfs.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_vrfs.yaml @@ -134,7 +134,9 @@ spec: routeDistinguisher: description: |- RouteDistinguisher is the route distinguisher for the VRF. + Set to "Auto" for automatic derivation (equivalent to "rd auto"). Formats supported: + - "Auto" (automatic derivation) - Type 0: ASN(0-65535):Number(0-4294967295) - Type 1: IPv4:Number(0-65535) - Type 2: ASN(65536-4294967295):Number(0-65535) diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index 5c62f9607..8aa6954ad 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -1412,7 +1412,7 @@ _Appears in:_ | `vni` _integer_ | VNI is the VXLAN Network Identifier.
Immutable. | | Maximum: 1.6777214e+07
Minimum: 1
Required: \{\}
| | `type` _[EVPNInstanceType](#evpninstancetype)_ | Type specifies the EVPN instance type.
Immutable. | | Enum: [Bridged Routed]
Required: \{\}
| | `multicastGroupAddress` _string_ | MulticastGroupAddress specifies the IPv4 multicast group address used for BUM (Broadcast, Unknown unicast, Multicast) traffic.
The address must be in the valid multicast range (224.0.0.0 - 239.255.255.255). | | Format: ipv4
Optional: \{\}
| -| `routeDistinguisher` _string_ | RouteDistinguisher is the route distinguisher for the EVI.
This field is only applicable when Type is Bridged (MAC-VRF).
For Routed type, the route distinguisher is configured on the referenced VRF instead.
Formats supported:
- Type 0: ASN(0-65535):Number(0-4294967295)
- Type 1: IPv4:Number(0-65535)
- Type 2: ASN(65536-4294967295):Number(0-65535) | | Optional: \{\}
| +| `routeDistinguisher` _string_ | RouteDistinguisher is the route distinguisher for the EVI.
This field is only applicable when Type is Bridged (MAC-VRF).
For Routed type, the route distinguisher is configured on the referenced VRF instead.
Set to "Auto" for automatic derivation (equivalent to "rd auto").
Formats supported:
- "Auto" (automatic derivation)
- Type 0: ASN(0-65535):Number(0-4294967295)
- Type 1: IPv4:Number(0-65535)
- Type 2: ASN(65536-4294967295):Number(0-65535) | | Optional: \{\}
| | `routeTargets` _[EVPNRouteTarget](#evpnroutetarget) array_ | RouteTargets is the list of route targets for the EVI. | | MinItems: 1
Optional: \{\}
| | `vlanRef` _[LocalObjectReference](#localobjectreference)_ | VLANRef is a reference to a VLAN resource for which this EVPNInstance builds the MAC-VRF.
This field is only applicable when Type is Bridged (L2VNI).
The VLAN resource must exist in the same namespace.
Immutable. | | Optional: \{\}
| | `vrfRef` _[LocalObjectReference](#localobjectreference)_ | VRFRef is a reference to a VRF resource for which this EVPNInstance provides the L3VNI.
This field is only applicable when Type is Routed (L3VNI).
The VRF resource must exist in the same namespace.
Immutable. | | Optional: \{\}
| @@ -3692,7 +3692,7 @@ _Appears in:_ | `name` _string_ | Name is the name of the VRF.
Immutable. | | MaxLength: 32
MinLength: 1
Required: \{\}
| | `description` _string_ | Description provides a human-readable description of the VRF. | | MaxLength: 255
MinLength: 1
Optional: \{\}
| | `vni` _integer_ | VNI is the VXLAN Network Identifier for the VRF (always an L3).
Deprecated: Use the VNI field on the EVPNInstance resource instead. This field will be removed in a future release. | | Maximum: 1.6777215e+07
Minimum: 1
Optional: \{\}
| -| `routeDistinguisher` _string_ | RouteDistinguisher is the route distinguisher for the VRF.
Formats supported:
- Type 0: ASN(0-65535):Number(0-4294967295)
- Type 1: IPv4:Number(0-65535)
- Type 2: ASN(65536-4294967295):Number(0-65535)
Validation via admission webhook for the VRF type. | | Optional: \{\}
| +| `routeDistinguisher` _string_ | RouteDistinguisher is the route distinguisher for the VRF.
Set to "Auto" for automatic derivation (equivalent to "rd auto").
Formats supported:
- "Auto" (automatic derivation)
- Type 0: ASN(0-65535):Number(0-4294967295)
- Type 1: IPv4:Number(0-65535)
- Type 2: ASN(65536-4294967295):Number(0-65535)
Validation via admission webhook for the VRF type. | | Optional: \{\}
| | `routeTargets` _[RouteTarget](#routetarget) array_ | RouteTargets is the list of route targets for the VRF. | | Optional: \{\}
| diff --git a/internal/provider/cisco/nxos/evi.go b/internal/provider/cisco/nxos/evi.go index 279c1c480..95b03b3c7 100644 --- a/internal/provider/cisco/nxos/evi.go +++ b/internal/provider/cisco/nxos/evi.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" ) @@ -80,7 +81,7 @@ func stdcommunity(s string) (string, error) { // extcommunity converts a value to an extended community string. func extcommunity(s string) (string, error) { - if s == "" { + if s == "" || s == v1alpha1.RouteDistinguisherAuto { return "unknown:0:0", nil } parts := strings.SplitN(s, ":", 2) diff --git a/internal/webhook/core/v1alpha1/vrf_webhook.go b/internal/webhook/core/v1alpha1/vrf_webhook.go index 2e6eebcc0..d6cd5f2ab 100644 --- a/internal/webhook/core/v1alpha1/vrf_webhook.go +++ b/internal/webhook/core/v1alpha1/vrf_webhook.go @@ -70,7 +70,7 @@ func validateVRFSpec(vrf *v1alpha1.VRF) error { var errAgg []error rd := strings.TrimSpace(vrf.Spec.RouteDistinguisher) - if rd != "" { + if rd != "" && rd != v1alpha1.RouteDistinguisherAuto { if err := validateRouteDistinguisher(rd); err != nil { errAgg = append(errAgg, fmt.Errorf("invalid route distinguisher value %q: %w", rd, err)) } diff --git a/internal/webhook/core/v1alpha1/vrf_webhook_test.go b/internal/webhook/core/v1alpha1/vrf_webhook_test.go index 6cfede00f..d370be074 100644 --- a/internal/webhook/core/v1alpha1/vrf_webhook_test.go +++ b/internal/webhook/core/v1alpha1/vrf_webhook_test.go @@ -42,6 +42,12 @@ var _ = Describe("VRF Webhook", func() { Expect(err).ToNot(HaveOccurred()) }) + It("accepts Auto RD", func() { + obj.Spec.RouteDistinguisher = "Auto" + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).ToNot(HaveOccurred()) + }) + It("rejects bad format (missing colon)", func() { obj.Spec.RouteDistinguisher = "badformat" _, err := validator.ValidateCreate(ctx, obj)