diff --git a/PROJECT b/PROJECT index f2549331..9ca8813c 100644 --- a/PROJECT +++ b/PROJECT @@ -179,4 +179,12 @@ resources: kind: VLAN path: github.com/ironcore-dev/network-operator/api/core/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: networking.metal.ironcore.dev + kind: EVPNInstance + path: github.com/ironcore-dev/network-operator/api/core/v1alpha1 + version: v1alpha1 version: "3" diff --git a/Tiltfile b/Tiltfile index 14a41999..185b4c80 100644 --- a/Tiltfile +++ b/Tiltfile @@ -102,6 +102,9 @@ k8s_resource(new_name='ospf-underlay', objects=['underlay:ospf'], resource_deps= k8s_yaml('./config/samples/v1alpha1_vlan.yaml') k8s_resource(new_name='vlan-10', objects=['vlan-10:vlan'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False) +k8s_yaml('./config/samples/v1alpha1_evi.yaml') +k8s_resource(new_name='vxlan-100010', objects=['vxlan-100010:evpninstance'], resource_deps=['vlan-10'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False) + print('🚀 network-operator development environment') print('👉 Edit the code inside the api/, cmd/, or internal/ directories') print('👉 Tilt will automatically rebuild and redeploy when changes are detected') diff --git a/api/core/v1alpha1/evpninstance_types.go b/api/core/v1alpha1/evpninstance_types.go new file mode 100644 index 00000000..19657109 --- /dev/null +++ b/api/core/v1alpha1/evpninstance_types.go @@ -0,0 +1,157 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EVPNInstanceSpec defines the desired state of EVPNInstance +// +// It models an EVPN instance (EVI) context on a single network device based on VXLAN encapsulation and the VLAN-based service type defined in [RFC 8365]. +// [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" +type EVPNInstanceSpec struct { + // DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace. + // Immutable. + // +required + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="DeviceRef is immutable" + DeviceRef LocalObjectReference `json:"deviceRef"` + + // ProviderConfigRef is a reference to a resource holding the provider-specific configuration of this interface. + // This reference is used to link the BGP to its provider-specific configuration. + // +optional + ProviderConfigRef *TypedLocalObjectReference `json:"providerConfigRef,omitempty"` + + // VNI is the VXLAN Network Identifier. + // Immutable. + // +required + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=16777214 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="VNI is immutable" + VNI int32 `json:"vni"` + + // Type specifies the EVPN instance type. + // Immutable. + // +required + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Type is immutable" + Type EVPNInstanceType `json:"type"` + + // 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). + // +optional + // +kubebuilder:validation:Format=ipv4 + MulticastGroupAddress string `json:"multicastGroupAddress,omitempty"` + + // RouteDistinguisher is the route distinguisher for the EVI. + // Formats supported: + // - Type 0: : + // - Type 1: : + // - Type 2: : + // +optional + RouteDistinguisher string `json:"routeDistinguisher,omitempty"` + + // RouteTargets is the list of route targets for the EVI. + // +optional + // +listType=map + // +listMapKey=value + // +kubebuilder:validation:MinItems=1 + RouteTargets []EVPNRouteTarget `json:"routeTargets,omitempty"` + + // 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 + // +kubebuilder:validation:XValidation:rule="self.name == oldSelf.name",message="VLANRef is immutable" + VLANRef *LocalObjectReference `json:"vlanRef,omitempty"` +} + +// EVPNInstanceType defines the type of EVPN instance. +// +kubebuilder:validation:Enum=Bridged;Routed +type EVPNInstanceType string + +const ( + // EVPNInstanceTypeBridged represents an L2VNI (MAC-VRF) EVPN instance. + // Corresponds to OpenConfig network-instance type L2VSI. + EVPNInstanceTypeBridged EVPNInstanceType = "Bridged" + + // EVPNInstanceTypeRouted represents an L3VNI (IP-VRF) EVPN instance. + // Corresponds to OpenConfig network-instance type L3VRF. + EVPNInstanceTypeRouted EVPNInstanceType = "Routed" +) + +type EVPNRouteTarget struct { + // Value is the route target value, must have the format as RouteDistinguisher. + // +required + // +kubebuilder:validation:MinLength=1 + Value string `json:"value"` + + // Action defines whether the route target is imported, exported, or both. + // +required + Action RouteTargetAction `json:"action"` +} + +// EVPNInstanceStatus defines the observed state of EVPNInstance. +type EVPNInstanceStatus struct { + // The conditions are a list of status objects that describe the state of the EVPNInstance. + // +listType=map + // +listMapKey=type + // +patchStrategy=merge + // +patchMergeKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:path=evpninstances +// +kubebuilder:resource:singular=evpninstance +// +kubebuilder:resource:shortName=evi;vni +// +kubebuilder:printcolumn:name="Device",type=string,JSONPath=`.spec.deviceRef.name` +// +kubebuilder:printcolumn:name="Type",type=string,JSONPath=`.spec.type` +// +kubebuilder:printcolumn:name="VNI",type=integer,JSONPath=`.spec.vni` +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// EVPNInstance is the Schema for the evpninstances API +type EVPNInstance struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Specification of the desired state of the resource. + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + // +required + Spec EVPNInstanceSpec `json:"spec"` + + // Status of the resource. This is set and updated automatically. + // Read-only. + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + // +optional + Status EVPNInstanceStatus `json:"status,omitempty,omitzero"` +} + +// GetConditions implements conditions.Getter. +func (i *EVPNInstance) GetConditions() []metav1.Condition { + return i.Status.Conditions +} + +// SetConditions implements conditions.Setter. +func (i *EVPNInstance) SetConditions(conditions []metav1.Condition) { + i.Status.Conditions = conditions +} + +// +kubebuilder:object:root=true + +// EVPNInstanceList contains a list of EVPNInstance +type EVPNInstanceList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []EVPNInstance `json:"items"` +} + +func init() { + SchemeBuilder.Register(&EVPNInstance{}, &EVPNInstanceList{}) +} diff --git a/api/core/v1alpha1/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go index 476c7499..d136848f 100644 --- a/api/core/v1alpha1/groupversion_info.go +++ b/api/core/v1alpha1/groupversion_info.go @@ -50,6 +50,10 @@ const AggregateLabel = "networking.metal.ironcore.dev/aggregate-name" // the name of the RoutedVLAN interface that provides Layer 3 routing for the VLAN. const RoutedVLANLabel = "networking.metal.ironcore.dev/routed-vlan-name" +// L2VNILabel is a label applied to VLANs to indicate +// the name of the EVPNInstance that maps the VLAN to a L2VNI in the VXLAN fabric. +const L2VNILabel = "networking.metal.ironcore.dev/evi-name" + // VRFLabel is a label applied to interfaces to indicate // the name of the VRF they belong to. const VRFLabel = "networking.metal.ironcore.dev/vrf-name" diff --git a/api/core/v1alpha1/vlan_types.go b/api/core/v1alpha1/vlan_types.go index 90262a68..4db8b267 100644 --- a/api/core/v1alpha1/vlan_types.go +++ b/api/core/v1alpha1/vlan_types.go @@ -66,6 +66,11 @@ type VLANStatus struct { // This field is set when an Interface of type RoutedVLAN references this VLAN. // +optional RoutedBy *LocalObjectReference `json:"routedBy,omitempty"` + + // BridgedBy references the EVPNInstance that provides a L2VNI for this VLAN, if any. + // This field is set when an EVPNInstance of type Bridged references this VLAN. + // +optional + BridgedBy *LocalObjectReference `json:"bridgedBy,omitempty"` } // +kubebuilder:object:root=true diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go index e721597f..d2660a1e 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -1136,6 +1136,133 @@ func (in *DeviceStatus) DeepCopy() *DeviceStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EVPNInstance) DeepCopyInto(out *EVPNInstance) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EVPNInstance. +func (in *EVPNInstance) DeepCopy() *EVPNInstance { + if in == nil { + return nil + } + out := new(EVPNInstance) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EVPNInstance) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EVPNInstanceList) DeepCopyInto(out *EVPNInstanceList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]EVPNInstance, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EVPNInstanceList. +func (in *EVPNInstanceList) DeepCopy() *EVPNInstanceList { + if in == nil { + return nil + } + out := new(EVPNInstanceList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EVPNInstanceList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EVPNInstanceSpec) DeepCopyInto(out *EVPNInstanceSpec) { + *out = *in + out.DeviceRef = in.DeviceRef + if in.ProviderConfigRef != nil { + in, out := &in.ProviderConfigRef, &out.ProviderConfigRef + *out = new(TypedLocalObjectReference) + **out = **in + } + if in.RouteTargets != nil { + in, out := &in.RouteTargets, &out.RouteTargets + *out = make([]EVPNRouteTarget, len(*in)) + copy(*out, *in) + } + if in.VLANRef != nil { + in, out := &in.VLANRef, &out.VLANRef + *out = new(LocalObjectReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EVPNInstanceSpec. +func (in *EVPNInstanceSpec) DeepCopy() *EVPNInstanceSpec { + if in == nil { + return nil + } + out := new(EVPNInstanceSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EVPNInstanceStatus) DeepCopyInto(out *EVPNInstanceStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EVPNInstanceStatus. +func (in *EVPNInstanceStatus) DeepCopy() *EVPNInstanceStatus { + if in == nil { + return nil + } + out := new(EVPNInstanceStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EVPNRouteTarget) DeepCopyInto(out *EVPNRouteTarget) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EVPNRouteTarget. +func (in *EVPNRouteTarget) DeepCopy() *EVPNRouteTarget { + if in == nil { + return nil + } + out := new(EVPNRouteTarget) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Endpoint) DeepCopyInto(out *Endpoint) { *out = *in @@ -2804,6 +2931,11 @@ func (in *VLANStatus) DeepCopyInto(out *VLANStatus) { *out = new(LocalObjectReference) **out = **in } + if in.BridgedBy != nil { + in, out := &in.BridgedBy, &out.BridgedBy + *out = new(LocalObjectReference) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VLANStatus. diff --git a/charts/network-operator/templates/crd/networking.metal.ironcore.dev_evpninstances.yaml b/charts/network-operator/templates/crd/networking.metal.ironcore.dev_evpninstances.yaml new file mode 100644 index 00000000..9b66923d --- /dev/null +++ b/charts/network-operator/templates/crd/networking.metal.ironcore.dev_evpninstances.yaml @@ -0,0 +1,288 @@ +{{- if .Values.crd.enable }} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + labels: + {{- include "chart.labels" . | nindent 4 }} + annotations: + {{- if .Values.crd.keep }} + "helm.sh/resource-policy": keep + {{- end }} + controller-gen.kubebuilder.io/version: v0.19.0 + name: evpninstances.networking.metal.ironcore.dev +spec: + group: networking.metal.ironcore.dev + names: + kind: EVPNInstance + listKind: EVPNInstanceList + plural: evpninstances + shortNames: + - evi + - vni + singular: evpninstance + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.deviceRef.name + name: Device + type: string + - jsonPath: .spec.type + name: Type + type: string + - jsonPath: .spec.vni + name: VNI + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: EVPNInstance is the Schema for the evpninstances API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + Specification of the desired state of the resource. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + properties: + deviceRef: + description: |- + DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace. + Immutable. + properties: + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: DeviceRef is immutable + rule: self == oldSelf + multicastGroupAddress: + description: |- + 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 + type: string + providerConfigRef: + description: |- + ProviderConfigRef is a reference to a resource holding the provider-specific configuration of this interface. + This reference is used to link the BGP to its provider-specific configuration. + properties: + apiVersion: + description: APIVersion is the api group version of the resource + being referenced. + maxLength: 253 + minLength: 1 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/)?([a-z0-9]([-a-z0-9]*[a-z0-9])?)$ + type: string + kind: + description: |- + Kind of the resource being referenced. + Kind must consist of alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: |- + Name of the resource being referenced. + Name must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - apiVersion + - kind + - name + type: object + x-kubernetes-map-type: atomic + routeDistinguisher: + description: |- + RouteDistinguisher is the route distinguisher for the EVI. + Formats supported: + - Type 0: : + - Type 1: : + - Type 2: : + type: string + routeTargets: + description: RouteTargets is the list of route targets for the EVI. + items: + properties: + action: + description: Action defines whether the route target is imported, + exported, or both. + enum: + - Import + - Export + - Both + type: string + value: + description: Value is the route target value, must have the + format as RouteDistinguisher. + minLength: 1 + type: string + required: + - action + - value + type: object + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - value + x-kubernetes-list-type: map + type: + description: |- + Type specifies the EVPN instance type. + Immutable. + enum: + - Bridged + - Routed + type: string + x-kubernetes-validations: + - message: Type is immutable + rule: self == oldSelf + vlanRef: + description: |- + 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. + 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: VLANRef is immutable + rule: self.name == oldSelf.name + vni: + description: |- + VNI is the VXLAN Network Identifier. + Immutable. + format: int32 + maximum: 16777214 + minimum: 1 + type: integer + x-kubernetes-validations: + - message: VNI is immutable + rule: self == oldSelf + required: + - deviceRef + - type + - vni + type: object + x-kubernetes-validations: + - message: VLANRef must be specified when Type is Bridged + rule: self.type != 'Bridged' || has(self.vlanRef) + status: + description: |- + Status of the resource. This is set and updated automatically. + Read-only. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + properties: + conditions: + description: The conditions are a list of status objects that describe + the state of the EVPNInstance. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +{{- end -}} diff --git a/charts/network-operator/templates/crd/networking.metal.ironcore.dev_vlans.yaml b/charts/network-operator/templates/crd/networking.metal.ironcore.dev_vlans.yaml index 93de1670..d551886f 100644 --- a/charts/network-operator/templates/crd/networking.metal.ironcore.dev_vlans.yaml +++ b/charts/network-operator/templates/crd/networking.metal.ironcore.dev_vlans.yaml @@ -158,6 +158,22 @@ spec: Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status properties: + bridgedBy: + description: |- + BridgedBy references the EVPNInstance that provides a L2VNI for this VLAN, if any. + This field is set when an EVPNInstance of type Bridged references this VLAN. + 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 conditions: description: The conditions are a list of status objects that describe the state of the VLAN. diff --git a/charts/network-operator/templates/rbac/evpninstance_admin_role.yaml b/charts/network-operator/templates/rbac/evpninstance_admin_role.yaml new file mode 100644 index 00000000..d6e92792 --- /dev/null +++ b/charts/network-operator/templates/rbac/evpninstance_admin_role.yaml @@ -0,0 +1,28 @@ +{{- if .Values.rbac.enable }} +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over networking.metal.ironcore.dev. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + {{- include "chart.labels" . | nindent 4 }} + name: evpninstance-admin-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - evpninstances + verbs: + - '*' +- apiGroups: + - networking.metal.ironcore.dev + resources: + - evpninstances/status + verbs: + - get +{{- end -}} diff --git a/charts/network-operator/templates/rbac/evpninstance_editor_role.yaml b/charts/network-operator/templates/rbac/evpninstance_editor_role.yaml new file mode 100644 index 00000000..1276b4d9 --- /dev/null +++ b/charts/network-operator/templates/rbac/evpninstance_editor_role.yaml @@ -0,0 +1,34 @@ +{{- if .Values.rbac.enable }} +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the networking.metal.ironcore.dev. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + {{- include "chart.labels" . | nindent 4 }} + name: evpninstance-editor-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - evpninstances + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - evpninstances/status + verbs: + - get +{{- end -}} diff --git a/charts/network-operator/templates/rbac/evpninstance_viewer_role.yaml b/charts/network-operator/templates/rbac/evpninstance_viewer_role.yaml new file mode 100644 index 00000000..a73df989 --- /dev/null +++ b/charts/network-operator/templates/rbac/evpninstance_viewer_role.yaml @@ -0,0 +1,30 @@ +{{- if .Values.rbac.enable }} +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to networking.metal.ironcore.dev resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + {{- include "chart.labels" . | nindent 4 }} + name: evpninstance-viewer-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - evpninstances + verbs: + - get + - list + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - evpninstances/status + verbs: + - get +{{- end -}} diff --git a/charts/network-operator/templates/rbac/role.yaml b/charts/network-operator/templates/rbac/role.yaml index 81ef7153..587ca11e 100644 --- a/charts/network-operator/templates/rbac/role.yaml +++ b/charts/network-operator/templates/rbac/role.yaml @@ -41,6 +41,7 @@ rules: - certificates - devices - dns + - evpninstances - interfaces - isis - managementaccesses @@ -70,6 +71,7 @@ rules: - certificates/finalizers - devices/finalizers - dns/finalizers + - evpninstances/finalizers - interfaces/finalizers - isis/finalizers - managementaccesses/finalizers @@ -93,6 +95,7 @@ rules: - certificates/status - devices/status - dns/status + - evpninstances/status - interfaces/status - isis/status - managementaccesses/status diff --git a/cmd/main.go b/cmd/main.go index 42653fb7..6691b281 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -451,6 +451,16 @@ func main() { os.Exit(1) } } + if err := (&corecontroller.EVPNInstanceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("evpn-instance-controller"), + WatchFilterValue: watchFilterValue, + Provider: prov, + }).SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "EVPNInstance") + os.Exit(1) + } // +kubebuilder:scaffold:builder if metricsCertWatcher != nil { diff --git a/config/crd/bases/networking.metal.ironcore.dev_evpninstances.yaml b/config/crd/bases/networking.metal.ironcore.dev_evpninstances.yaml new file mode 100644 index 00000000..3e58e108 --- /dev/null +++ b/config/crd/bases/networking.metal.ironcore.dev_evpninstances.yaml @@ -0,0 +1,281 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: evpninstances.networking.metal.ironcore.dev +spec: + group: networking.metal.ironcore.dev + names: + kind: EVPNInstance + listKind: EVPNInstanceList + plural: evpninstances + shortNames: + - evi + - vni + singular: evpninstance + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.deviceRef.name + name: Device + type: string + - jsonPath: .spec.type + name: Type + type: string + - jsonPath: .spec.vni + name: VNI + type: integer + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: EVPNInstance is the Schema for the evpninstances API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + Specification of the desired state of the resource. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + properties: + deviceRef: + description: |- + DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace. + Immutable. + properties: + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: DeviceRef is immutable + rule: self == oldSelf + multicastGroupAddress: + description: |- + 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 + type: string + providerConfigRef: + description: |- + ProviderConfigRef is a reference to a resource holding the provider-specific configuration of this interface. + This reference is used to link the BGP to its provider-specific configuration. + properties: + apiVersion: + description: APIVersion is the api group version of the resource + being referenced. + maxLength: 253 + minLength: 1 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*\/)?([a-z0-9]([-a-z0-9]*[a-z0-9])?)$ + type: string + kind: + description: |- + Kind of the resource being referenced. + Kind must consist of alphanumeric characters or '-', start with an alphabetic character, and end with an alphanumeric character. + maxLength: 63 + minLength: 1 + pattern: ^[a-zA-Z]([-a-zA-Z0-9]*[a-zA-Z0-9])?$ + type: string + name: + description: |- + Name of the resource being referenced. + Name must consist of lower case alphanumeric characters, '-' or '.', and must start and end with an alphanumeric character. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - apiVersion + - kind + - name + type: object + x-kubernetes-map-type: atomic + routeDistinguisher: + description: |- + RouteDistinguisher is the route distinguisher for the EVI. + Formats supported: + - Type 0: : + - Type 1: : + - Type 2: : + type: string + routeTargets: + description: RouteTargets is the list of route targets for the EVI. + items: + properties: + action: + description: Action defines whether the route target is imported, + exported, or both. + enum: + - Import + - Export + - Both + type: string + value: + description: Value is the route target value, must have the + format as RouteDistinguisher. + minLength: 1 + type: string + required: + - action + - value + type: object + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - value + x-kubernetes-list-type: map + type: + description: |- + Type specifies the EVPN instance type. + Immutable. + enum: + - Bridged + - Routed + type: string + x-kubernetes-validations: + - message: Type is immutable + rule: self == oldSelf + vlanRef: + description: |- + 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. + 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: VLANRef is immutable + rule: self.name == oldSelf.name + vni: + description: |- + VNI is the VXLAN Network Identifier. + Immutable. + format: int32 + maximum: 16777214 + minimum: 1 + type: integer + x-kubernetes-validations: + - message: VNI is immutable + rule: self == oldSelf + required: + - deviceRef + - type + - vni + type: object + x-kubernetes-validations: + - message: VLANRef must be specified when Type is Bridged + rule: self.type != 'Bridged' || has(self.vlanRef) + status: + description: |- + Status of the resource. This is set and updated automatically. + Read-only. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + properties: + conditions: + description: The conditions are a list of status objects that describe + the state of the EVPNInstance. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/networking.metal.ironcore.dev_vlans.yaml b/config/crd/bases/networking.metal.ironcore.dev_vlans.yaml index da898ef0..08224f21 100644 --- a/config/crd/bases/networking.metal.ironcore.dev_vlans.yaml +++ b/config/crd/bases/networking.metal.ironcore.dev_vlans.yaml @@ -152,6 +152,22 @@ spec: Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status properties: + bridgedBy: + description: |- + BridgedBy references the EVPNInstance that provides a L2VNI for this VLAN, if any. + This field is set when an EVPNInstance of type Bridged references this VLAN. + 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 conditions: description: The conditions are a list of status objects that describe the state of the VLAN. diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 9ce1c5b5..782ca184 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -22,6 +22,7 @@ resources: - bases/nx.cisco.networking.metal.ironcore.dev_systems.yaml - bases/nx.cisco.networking.metal.ironcore.dev_managementaccessconfigs.yaml - bases/networking.metal.ironcore.dev_vlans.yaml +- bases/networking.metal.ironcore.dev_evpninstances.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/rbac/evpninstance_admin_role.yaml b/config/rbac/evpninstance_admin_role.yaml new file mode 100644 index 00000000..8382f5ff --- /dev/null +++ b/config/rbac/evpninstance_admin_role.yaml @@ -0,0 +1,27 @@ +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over networking.metal.ironcore.dev. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: evpninstance-admin-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - evpninstances + verbs: + - '*' +- apiGroups: + - networking.metal.ironcore.dev + resources: + - evpninstances/status + verbs: + - get diff --git a/config/rbac/evpninstance_editor_role.yaml b/config/rbac/evpninstance_editor_role.yaml new file mode 100644 index 00000000..6bd53f08 --- /dev/null +++ b/config/rbac/evpninstance_editor_role.yaml @@ -0,0 +1,33 @@ +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the networking.metal.ironcore.dev. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: evpninstance-editor-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - evpninstances + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - evpninstances/status + verbs: + - get diff --git a/config/rbac/evpninstance_viewer_role.yaml b/config/rbac/evpninstance_viewer_role.yaml new file mode 100644 index 00000000..675c5f16 --- /dev/null +++ b/config/rbac/evpninstance_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to networking.metal.ironcore.dev resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: evpninstance-viewer-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - evpninstances + verbs: + - get + - list + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - evpninstances/status + verbs: + - get diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 6be54e8b..db30d741 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -82,3 +82,6 @@ resources: - cisco/nx/managementaccessconfig_admin_role.yaml - cisco/nx/managementaccessconfig_editor_role.yaml - cisco/nx/managementaccessconfig_viewer_role.yaml +- evpninstance_admin_role.yaml +- evpninstance_editor_role.yaml +- evpninstance_viewer_role.yaml diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 37182199..d12eafe7 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -38,6 +38,7 @@ rules: - certificates - devices - dns + - evpninstances - interfaces - isis - managementaccesses @@ -67,6 +68,7 @@ rules: - certificates/finalizers - devices/finalizers - dns/finalizers + - evpninstances/finalizers - interfaces/finalizers - isis/finalizers - managementaccesses/finalizers @@ -90,6 +92,7 @@ rules: - certificates/status - devices/status - dns/status + - evpninstances/status - interfaces/status - isis/status - managementaccesses/status diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index e41d6605..6cbe9356 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -20,4 +20,5 @@ resources: - v1alpha1_vlan.yaml - cisco/nx/v1alpha1_system.yaml - cisco/nx/v1alpha1_managementaccessconfig.yaml +- v1alpha1_evi.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/v1alpha1_evi.yaml b/config/samples/v1alpha1_evi.yaml new file mode 100644 index 00000000..093ee73a --- /dev/null +++ b/config/samples/v1alpha1_evi.yaml @@ -0,0 +1,19 @@ +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: EVPNInstance +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: vxlan-100010 +spec: + deviceRef: + name: leaf1 + vni: 100010 + type: Bridged + multicastGroupAddress: 239.1.1.100 + routeDistinguisher: 10.0.0.10:65000 + routeTargets: + - value: 65000:100010 + action: Both + vlanRef: + name: vlan-10 diff --git a/internal/controller/core/evpninstance_controller.go b/internal/controller/core/evpninstance_controller.go new file mode 100644 index 00000000..1de337a5 --- /dev/null +++ b/internal/controller/core/evpninstance_controller.go @@ -0,0 +1,425 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + kerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/client-go/tools/record" + "k8s.io/klog/v2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/conditions" + "github.com/ironcore-dev/network-operator/internal/deviceutil" + "github.com/ironcore-dev/network-operator/internal/provider" +) + +// EVPNInstanceReconciler reconciles a EVPNInstance object +type EVPNInstanceReconciler struct { + client.Client + Scheme *runtime.Scheme + + // WatchFilterValue is the label value used to filter events prior to reconciliation. + WatchFilterValue string + + // Recorder is used to record events for the controller. + // More info: https://book.kubebuilder.io/reference/raising-events + Recorder record.EventRecorder + + // Provider is the driver that will be used to create & delete the evpninstance. + Provider provider.ProviderFunc +} + +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=evpninstances,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=evpninstances/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=evpninstances/finalizers,verbs=update +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=vlans,verbs=get;list;watch +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=vlans/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.20.2/pkg/reconcile +// +// For more details about the method shape, read up here: +// - https://ahmet.im/blog/controller-pitfalls/#reconcile-method-shape +func (r *EVPNInstanceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, reterr error) { + log := ctrl.LoggerFrom(ctx) + log.Info("Reconciling resource") + + obj := new(v1alpha1.EVPNInstance) + if err := r.Get(ctx, req.NamespacedName, obj); err != nil { + if apierrors.IsNotFound(err) { + // If the custom resource is not found then it usually means that it was deleted or not created + // In this way, we will stop the reconciliation + log.Info("Resource not found. Ignoring since object must be deleted") + return ctrl.Result{}, nil + } + // Error reading the object - requeue the request. + log.Error(err, "Failed to get resource") + return ctrl.Result{}, err + } + + prov, ok := r.Provider().(provider.EVPNInstanceProvider) + if !ok { + if meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{ + Type: v1alpha1.ReadyCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.NotImplementedReason, + Message: "Provider does not implement provider.EVPNInstanceProvider", + }) { + return ctrl.Result{}, r.Status().Update(ctx, obj) + } + return ctrl.Result{}, nil + } + + device, err := deviceutil.GetDeviceByName(ctx, r, obj.Namespace, obj.Spec.DeviceRef.Name) + if err != nil { + return ctrl.Result{}, err + } + + conn, err := deviceutil.GetDeviceConnection(ctx, r, device) + if err != nil { + return ctrl.Result{}, err + } + + var cfg *provider.ProviderConfig + if obj.Spec.ProviderConfigRef != nil { + cfg, err = provider.GetProviderConfig(ctx, r, obj.Namespace, obj.Spec.ProviderConfigRef) + if err != nil { + return ctrl.Result{}, err + } + } + + s := &eviScope{ + Device: device, + EVPNInstance: obj, + Connection: conn, + ProviderConfig: cfg, + Provider: prov, + } + + if !obj.DeletionTimestamp.IsZero() { + if controllerutil.ContainsFinalizer(obj, v1alpha1.FinalizerName) { + if err := r.finalize(ctx, s); err != nil { + log.Error(err, "Failed to finalize resource") + return ctrl.Result{}, err + } + controllerutil.RemoveFinalizer(obj, v1alpha1.FinalizerName) + if err := r.Update(ctx, obj); err != nil { + log.Error(err, "Failed to remove finalizer from resource") + return ctrl.Result{}, err + } + } + log.Info("Resource is being deleted, skipping reconciliation") + return ctrl.Result{}, nil + } + + // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/finalizers + if !controllerutil.ContainsFinalizer(obj, v1alpha1.FinalizerName) { + controllerutil.AddFinalizer(obj, v1alpha1.FinalizerName) + if err := r.Update(ctx, obj); err != nil { + log.Error(err, "Failed to add finalizer to resource") + return ctrl.Result{}, err + } + log.Info("Added finalizer to resource") + return ctrl.Result{}, nil + } + + orig := obj.DeepCopy() + if conditions.InitializeConditions(obj, v1alpha1.ReadyCondition) { + log.Info("Initializing status conditions") + return ctrl.Result{}, r.Status().Update(ctx, obj) + } + + // Always attempt to update the metadata/status after reconciliation + defer func() { + if !equality.Semantic.DeepEqual(orig.ObjectMeta, obj.ObjectMeta) { + if err := r.Patch(ctx, obj, client.MergeFrom(orig)); err != nil { + log.Error(err, "Failed to update resource metadata") + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + return + } + + if !equality.Semantic.DeepEqual(orig.Status, obj.Status) { + if err := r.Status().Patch(ctx, obj, client.MergeFrom(orig)); err != nil { + log.Error(err, "Failed to update status") + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + } + }() + + if err := r.reconcile(ctx, s); err != nil { + log.Error(err, "Failed to reconcile resource") + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +var eviVlanRefKey = ".spec.vlanRef.name" + +// SetupWithManager sets up the controller with the Manager. +func (r *EVPNInstanceReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { + labelSelector := metav1.LabelSelector{} + if r.WatchFilterValue != "" { + labelSelector.MatchLabels = map[string]string{v1alpha1.WatchLabel: r.WatchFilterValue} + } + + filter, err := predicate.LabelSelectorPredicate(labelSelector) + if err != nil { + return fmt.Errorf("failed to create label selector predicate: %w", err) + } + + if err := mgr.GetFieldIndexer().IndexField(ctx, &v1alpha1.EVPNInstance{}, eviVlanRefKey, func(obj client.Object) []string { + evi := obj.(*v1alpha1.EVPNInstance) + if evi.Spec.VLANRef == nil { + return nil + } + return []string{evi.Spec.VLANRef.Name} + }); err != nil { + return err + } + + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.EVPNInstance{}). + Named("evpninstance"). + WithEventFilter(filter). + // Watches enqueues EVPNInstances for updates in referenced VLAN resources. + // Only triggers on create and delete events since VLAN IDs are immutable. + Watches( + &v1alpha1.VLAN{}, + handler.EnqueueRequestsFromMapFunc(r.vlanToEVPNInstance), + builder.WithPredicates(predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + return false + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + }), + ). + Complete(r) +} + +// eviScope holds the different objects that are read and used during the reconcile. +type eviScope struct { + Device *v1alpha1.Device + EVPNInstance *v1alpha1.EVPNInstance + Connection *deviceutil.Connection + ProviderConfig *provider.ProviderConfig + Provider provider.EVPNInstanceProvider +} + +func (r *EVPNInstanceReconciler) reconcile(ctx context.Context, s *eviScope) (reterr error) { + if s.EVPNInstance.Labels == nil { + s.EVPNInstance.Labels = make(map[string]string) + } + + s.EVPNInstance.Labels[v1alpha1.DeviceLabel] = s.Device.Name + + // Ensure the EVPNInstance is owned by the Device. + if !controllerutil.HasControllerReference(s.EVPNInstance) { + if err := controllerutil.SetOwnerReference(s.Device, s.EVPNInstance, r.Scheme, controllerutil.WithBlockOwnerDeletion(true)); err != nil { + return err + } + } + + var vlan *v1alpha1.VLAN + if s.EVPNInstance.Spec.Type == v1alpha1.EVPNInstanceTypeBridged && s.EVPNInstance.Spec.VLANRef != nil { + var err error + vlan, err = r.reconcileVLAN(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) + } + defer func() { + if err := s.Provider.Disconnect(ctx, s.Connection); err != nil { + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + }() + + // Ensure the EVPNInstance is realized on the provider. + err := s.Provider.EnsureEVPNInstance(ctx, &provider.EVPNInstanceRequest{ + EVPNInstance: s.EVPNInstance, + ProviderConfig: s.ProviderConfig, + VLAN: vlan, + }) + + cond := conditions.FromError(err) + // As this resource is configuration only, we use the Configured condition as top-level Ready condition. + cond.Type = v1alpha1.ReadyCondition + conditions.Set(s.EVPNInstance, cond) + + return err +} + +// reconcileVLAN ensures that the referenced VLAN exists, belongs to the same device as the EVPNInstance. +// It also updates the VLAN to reference the EVPNInstance by setting its BridgedBy status field. +func (r *EVPNInstanceReconciler) reconcileVLAN(ctx context.Context, s *eviScope) (*v1alpha1.VLAN, error) { + key := client.ObjectKey{ + Name: s.EVPNInstance.Spec.VLANRef.Name, + Namespace: s.EVPNInstance.Namespace, + } + + vlan := new(v1alpha1.VLAN) + if err := r.Get(ctx, key, vlan); err != nil { + if apierrors.IsNotFound(err) { + conditions.Set(s.EVPNInstance, metav1.Condition{ + Type: v1alpha1.ReadyCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.VLANNotFoundReason, + Message: fmt.Sprintf("referenced VLAN %q not found", key), + }) + return nil, reconcile.TerminalError(fmt.Errorf("referenced VLAN %q not found", key)) + } + return nil, fmt.Errorf("failed to get referenced VLAN %q: %w", key, err) + } + + if vlan.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 VLAN %q does not belong to device %q", vlan.Name, s.Device.Name), + }) + return nil, reconcile.TerminalError(fmt.Errorf("referenced VLAN %q does not belong to device %q", vlan.Name, s.Device.Name)) + } + + if vlan.Status.BridgedBy != nil && vlan.Status.BridgedBy.Name != s.EVPNInstance.Name { + conditions.Set(s.EVPNInstance, metav1.Condition{ + Type: v1alpha1.ReadyCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.VLANAlreadyInUseReason, + Message: fmt.Sprintf("VLAN %q is already in use by EVPNInstance %q", vlan.Name, vlan.Status.BridgedBy.Name), + }) + return nil, reconcile.TerminalError(fmt.Errorf("VLAN %q is already in use by EVPNInstance %q", vlan.Name, vlan.Status.BridgedBy.Name)) + } + + if vlan.Status.BridgedBy == nil { + vlan.Status.BridgedBy = &v1alpha1.LocalObjectReference{Name: s.EVPNInstance.Name} + if err := r.Status().Update(ctx, vlan); err != nil { + return nil, fmt.Errorf("failed to update VLAN %q status: %w", vlan.Name, err) + } + } + + if vlan.Labels == nil { + vlan.Labels = make(map[string]string) + } + + if vlan.Labels[v1alpha1.L2VNILabel] != s.EVPNInstance.Name { + vlan.Labels[v1alpha1.L2VNILabel] = s.EVPNInstance.Name + if err := r.Update(ctx, vlan); err != nil { + return nil, fmt.Errorf("failed to update VLAN %q labels: %w", vlan.Name, err) + } + } + + return vlan, nil +} + +func (r *EVPNInstanceReconciler) finalize(ctx context.Context, s *eviScope) (reterr error) { + if err := r.finalizeVLAN(ctx, s); err != nil { + return err + } + + if err := s.Provider.Connect(ctx, s.Connection); err != nil { + return fmt.Errorf("failed to connect to provider: %w", err) + } + defer func() { + if err := s.Provider.Disconnect(ctx, s.Connection); err != nil { + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + }() + + return s.Provider.DeleteEVPNInstance(ctx, &provider.EVPNInstanceRequest{ + EVPNInstance: s.EVPNInstance, + ProviderConfig: s.ProviderConfig, + }) +} + +// finalizeVLAN removes the EVPNInstance reference from the VLAN. +func (r *EVPNInstanceReconciler) finalizeVLAN(ctx context.Context, s *eviScope) error { + if s.EVPNInstance.Spec.VLANRef == nil { + return nil + } + + vlan := new(v1alpha1.VLAN) + if err := r.Get(ctx, client.ObjectKey{Name: s.EVPNInstance.Spec.VLANRef.Name, Namespace: s.EVPNInstance.Namespace}, vlan); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return err + } + + if vlan.Status.BridgedBy != nil && vlan.Status.BridgedBy.Name == s.EVPNInstance.Name { + vlan.Status.BridgedBy = nil + if err := r.Status().Update(ctx, vlan); err != nil { + return fmt.Errorf("failed to update VLAN %q status: %w", vlan.Name, err) + } + } + + if vlan.Labels != nil && vlan.Labels[v1alpha1.L2VNILabel] == s.EVPNInstance.Name { + delete(vlan.Labels, v1alpha1.L2VNILabel) + if err := r.Update(ctx, vlan); err != nil { + return fmt.Errorf("failed to update VLAN %q labels: %w", vlan.Name, err) + } + } + + return nil +} + +// vlanToEVPNInstance is a [handler.MapFunc] to be used to enqueue requests for reconciliation +// for an EVPNInstance when its referenced VLAN changes. +func (r *EVPNInstanceReconciler) vlanToEVPNInstance(ctx context.Context, obj client.Object) []ctrl.Request { + vlan, ok := obj.(*v1alpha1.VLAN) + if !ok { + panic(fmt.Sprintf("Expected a VLAN but got a %T", obj)) + } + + log := ctrl.LoggerFrom(ctx, "VLAN", klog.KObj(vlan)) + + evpnInstances := new(v1alpha1.EVPNInstanceList) + if err := r.List(ctx, evpnInstances, client.InNamespace(vlan.Namespace), client.MatchingFields{eviVlanRefKey: vlan.Name}); err != nil { + log.Error(err, "Failed to list EVPNInstances") + return nil + } + + requests := []ctrl.Request{} + for _, evi := range evpnInstances.Items { + if evi.Spec.VLANRef != nil && evi.Spec.VLANRef.Name == vlan.Name { + log.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 +} diff --git a/internal/controller/core/evpninstance_controller_test.go b/internal/controller/core/evpninstance_controller_test.go new file mode 100644 index 00000000..92628f36 --- /dev/null +++ b/internal/controller/core/evpninstance_controller_test.go @@ -0,0 +1,242 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +var _ = Describe("EVPNInstance Controller", func() { + Context("When reconciling a resource", func() { + const name = "test-evi" + const vni = 100010 + key := client.ObjectKey{Name: name, Namespace: metav1.NamespaceDefault} + + BeforeEach(func() { + By("Creating a Device resource for testing") + device := &v1alpha1.Device{} + if err := k8sClient.Get(ctx, key, device); errors.IsNotFound(err) { + resource := &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.2:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + By("Cleaning up all EVPNInstance resources") + Expect(k8sClient.DeleteAllOf(ctx, &v1alpha1.EVPNInstance{}, client.InNamespace(metav1.NamespaceDefault))).To(Succeed()) + + By("Cleaning up test VLAN resource") + vlan := &v1alpha1.VLAN{} + if err := k8sClient.Get(ctx, key, vlan); err == nil { + Expect(k8sClient.Delete(ctx, vlan)).To(Succeed()) + } + + device := &v1alpha1.Device{} + err := k8sClient.Get(ctx, key, device) + Expect(err).NotTo(HaveOccurred()) + + By("Cleaning up the test Device resource") + Expect(k8sClient.Delete(ctx, device, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) + + By("Verifying the EVPNInstance is removed from the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.EVIs.Has(vni)).To(BeFalse(), "Provider shouldn't have VNI configured anymore") + }).Should(Succeed()) + }) + + It("Should successfully reconcile EVPNInstance with VLAN reference", func() { + By("Creating a VLAN resource") + vlan := &v1alpha1.VLAN{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.VLANSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + ID: 10, + Name: "vlan-10", + AdminState: v1alpha1.VLANStateActive, + }, + } + Expect(k8sClient.Create(ctx, vlan)).To(Succeed()) + + By("Creating an EVPNInstance with complete configuration from sample") + evi := &v1alpha1.EVPNInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.EVPNInstanceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + VNI: vni, + Type: v1alpha1.EVPNInstanceTypeBridged, + MulticastGroupAddress: "239.1.1.100", + RouteDistinguisher: "10.0.0.10:65000", + RouteTargets: []v1alpha1.EVPNRouteTarget{ + { + Value: "65000:100010", + Action: v1alpha1.RouteTargetActionBoth, + }, + }, + VLANRef: &v1alpha1.LocalObjectReference{Name: name}, + }, + } + Expect(k8sClient.Create(ctx, evi)).To(Succeed()) + + By("Verifying the controller adds a finalizer") + Eventually(func(g Gomega) { + resource := &v1alpha1.EVPNInstance{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(controllerutil.ContainsFinalizer(resource, v1alpha1.FinalizerName)).To(BeTrue()) + }).Should(Succeed()) + + By("Verifying the controller adds the device label") + Eventually(func(g Gomega) { + resource := &v1alpha1.EVPNInstance{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Labels).To(HaveKeyWithValue(v1alpha1.DeviceLabel, name)) + }).Should(Succeed()) + + By("Verifying the controller sets the device as owner reference") + Eventually(func(g Gomega) { + resource := &v1alpha1.EVPNInstance{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.OwnerReferences).To(HaveLen(1)) + g.Expect(resource.OwnerReferences[0].Kind).To(Equal("Device")) + g.Expect(resource.OwnerReferences[0].Name).To(Equal(name)) + }).Should(Succeed()) + + By("Verifying the controller updates the status conditions") + Eventually(func(g Gomega) { + resource := &v1alpha1.EVPNInstance{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Status.Conditions).To(HaveLen(1)) + g.Expect(resource.Status.Conditions[0].Type).To(Equal(v1alpha1.ReadyCondition)) + g.Expect(resource.Status.Conditions[0].Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Verifying the VLAN is labeled with L2VNI label") + Eventually(func(g Gomega) { + vlanResource := &v1alpha1.VLAN{} + g.Expect(k8sClient.Get(ctx, key, vlanResource)).To(Succeed()) + g.Expect(vlanResource.Labels).To(HaveKeyWithValue(v1alpha1.L2VNILabel, name)) + }).Should(Succeed()) + + By("Verifying the VLAN status is updated with BridgedBy reference") + Eventually(func(g Gomega) { + vlanResource := &v1alpha1.VLAN{} + g.Expect(k8sClient.Get(ctx, key, vlanResource)).To(Succeed()) + g.Expect(vlanResource.Status.BridgedBy).ToNot(BeNil()) + g.Expect(vlanResource.Status.BridgedBy.Name).To(Equal(name)) + }).Should(Succeed()) + + By("Verifying the EVPNInstance is configured in the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.EVIs.Has(vni)).To(BeTrue(), "Provider should have VNI configured") + }).Should(Succeed()) + }) + + It("Should handle EVPNInstance referencing non-existent VLAN", func() { + By("Creating an EVPNInstance referencing a non-existent VLAN") + evi := &v1alpha1.EVPNInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.EVPNInstanceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + VNI: vni, + Type: v1alpha1.EVPNInstanceTypeBridged, + MulticastGroupAddress: "239.1.1.100", + RouteDistinguisher: "10.0.0.10:65000", + RouteTargets: []v1alpha1.EVPNRouteTarget{ + { + Value: "65000:100010", + Action: v1alpha1.RouteTargetActionBoth, + }, + }, + VLANRef: &v1alpha1.LocalObjectReference{Name: "non-existent-vlan"}, + }, + } + Expect(k8sClient.Create(ctx, evi)).To(Succeed()) + + By("Verifying the controller sets VLAN not found status") + Eventually(func(g Gomega) { + resource := &v1alpha1.EVPNInstance{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Status.Conditions).To(HaveLen(1)) + g.Expect(resource.Status.Conditions[0].Type).To(Equal(v1alpha1.ReadyCondition)) + g.Expect(resource.Status.Conditions[0].Status).To(Equal(metav1.ConditionFalse)) + g.Expect(resource.Status.Conditions[0].Reason).To(Equal(v1alpha1.VLANNotFoundReason)) + }).Should(Succeed()) + }) + + It("Should handle EVPNInstance referencing VLAN on different device", func() { + By("Creating a VLAN on a different device") + vlan := &v1alpha1.VLAN{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.VLANSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: "different-device"}, + ID: 10, + Name: "vlan-10", + AdminState: v1alpha1.VLANStateActive, + }, + } + Expect(k8sClient.Create(ctx, vlan)).To(Succeed()) + + By("Creating an EVPNInstance referencing the cross-device VLAN") + evi := &v1alpha1.EVPNInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.EVPNInstanceSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + VNI: vni, + Type: v1alpha1.EVPNInstanceTypeBridged, + MulticastGroupAddress: "239.1.1.100", + RouteDistinguisher: "10.0.0.10:65000", + RouteTargets: []v1alpha1.EVPNRouteTarget{ + { + Value: "65000:100010", + Action: v1alpha1.RouteTargetActionBoth, + }, + }, + VLANRef: &v1alpha1.LocalObjectReference{Name: name}, + }, + } + Expect(k8sClient.Create(ctx, evi)).To(Succeed()) + + By("Verifying the controller sets cross-device reference status") + Eventually(func(g Gomega) { + resource := &v1alpha1.EVPNInstance{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Status.Conditions).To(HaveLen(1)) + g.Expect(resource.Status.Conditions[0].Type).To(Equal(v1alpha1.ReadyCondition)) + g.Expect(resource.Status.Conditions[0].Status).To(Equal(metav1.ConditionFalse)) + g.Expect(resource.Status.Conditions[0].Reason).To(Equal(v1alpha1.CrossDeviceReferenceReason)) + }).Should(Succeed()) + }) + }) +}) diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go index 108f8d68..9835d185 100644 --- a/internal/controller/core/suite_test.go +++ b/internal/controller/core/suite_test.go @@ -249,6 +249,14 @@ var _ = BeforeSuite(func() { }).SetupWithManager(k8sManager) Expect(err).NotTo(HaveOccurred()) + err = (&EVPNInstanceReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + Recorder: recorder, + Provider: prov, + }).SetupWithManager(ctx, k8sManager) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = k8sManager.Start(ctx) @@ -311,6 +319,7 @@ var ( _ provider.BGPPeerProvider = (*Provider)(nil) _ provider.OSPFProvider = (*Provider)(nil) _ provider.VLANProvider = (*Provider)(nil) + _ provider.EVPNInstanceProvider = (*Provider)(nil) ) // Provider is a simple in-memory provider for testing purposes only. @@ -334,6 +343,7 @@ type Provider struct { BGPPeers sets.Set[string] OSPF sets.Set[string] VLANs sets.Set[int16] + EVIs sets.Set[int32] } func NewProvider() *Provider { @@ -347,6 +357,7 @@ func NewProvider() *Provider { BGPPeers: sets.New[string](), OSPF: sets.New[string](), VLANs: sets.New[int16](), + EVIs: sets.New[int32](), } } @@ -642,3 +653,17 @@ func (p *Provider) GetVLANStatus(context.Context, *provider.VLANRequest) (provid OperStatus: true, }, nil } + +func (p *Provider) EnsureEVPNInstance(_ context.Context, req *provider.EVPNInstanceRequest) error { + p.Lock() + defer p.Unlock() + p.EVIs.Insert(req.EVPNInstance.Spec.VNI) + return nil +} + +func (p *Provider) DeleteEVPNInstance(_ context.Context, req *provider.EVPNInstanceRequest) error { + p.Lock() + defer p.Unlock() + p.EVIs.Delete(req.EVPNInstance.Spec.VNI) + return nil +} diff --git a/internal/provider/cisco/nxos/evi.go b/internal/provider/cisco/nxos/evi.go new file mode 100644 index 00000000..f5fdad81 --- /dev/null +++ b/internal/provider/cisco/nxos/evi.go @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package nxos + +import ( + "errors" + "fmt" + "math" + "net/netip" + "strconv" + "strings" + + "github.com/ironcore-dev/network-operator/internal/provider/cisco/gnmiext/v2" +) + +var _ gnmiext.Configurable = (*BDEVI)(nil) + +// BDEVI represents a Bridge Domain Ethernet VPN Instance (MAC-VRF). +type BDEVI struct { + Encap string `json:"encap"` + Rd string `json:"rd"` + RttpItems struct { + RttPList gnmiext.List[RttEntryType, *RttEntry] `json:"RttP-list,omitzero"` + } `json:"rttp-items,omitzero"` +} + +func (*BDEVI) IsListItem() {} + +func (b *BDEVI) XPath() string { + return "System/evpn-items/bdevi-items/BDEvi-list[encap=" + b.Encap + "]" +} + +func RouteDistinguisher(rd string) (string, error) { + s, err := extcommunity(rd) + if err != nil { + return "", err + } + return "rd:" + s, nil +} + +func RouteTarget(rt string) (string, error) { + s, err := extcommunity(rt) + if err != nil { + return "", err + } + return "route-target:" + s, nil +} + +// extcommunity converts a value to an extended community string. +func extcommunity(s string) (string, error) { + if s == "" { + return "unknown:0:0", nil + } + parts := strings.SplitN(s, ":", 2) + if len(parts) != 2 { + return "", errors.New("invalid route distinguisher format") + } + asn, err := strconv.ParseUint(parts[1], 10, 32) + if err != nil { + return "", fmt.Errorf("invalid route distinguisher format: %w", err) + } + // Type-0 + if asn > math.MaxUint16 { + return "as2-nn4:" + s, nil + } + // Type-1 + if _, err := netip.ParseAddr(parts[0]); err == nil { + return "ipv4-nn2:" + s, nil + } + // Type-2 + return "as4-nn2:" + s, nil +} diff --git a/internal/provider/cisco/nxos/evi_test.go b/internal/provider/cisco/nxos/evi_test.go new file mode 100644 index 00000000..9b484196 --- /dev/null +++ b/internal/provider/cisco/nxos/evi_test.go @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package nxos + +func init() { + rtt := &Rtt{} + rtt.Rtt, _ = RouteTarget("65000:100010") //nolint:errcheck + + rt := &RttEntry{Type: RttEntryTypeExport} + rt.EntItems.RttEntryList.Set(rtt) + + evi := &BDEVI{Encap: "vxlan-100010"} + evi.Rd, _ = RouteDistinguisher("10.0.0.10:65000") //nolint:errcheck + evi.RttpItems.RttPList.Set(rt) + Register("evi", evi) +} diff --git a/internal/provider/cisco/nxos/intf.go b/internal/provider/cisco/nxos/intf.go index 2407036b..b73781ad 100644 --- a/internal/provider/cisco/nxos/intf.go +++ b/internal/provider/cisco/nxos/intf.go @@ -28,6 +28,7 @@ var ( _ gnmiext.Configurable = (*SwitchVirtualInterface)(nil) _ gnmiext.Configurable = (*SwitchVirtualInterfaceOperItems)(nil) _ gnmiext.Configurable = (*AddrItem)(nil) + _ gnmiext.Configurable = (*FabricFwdIf)(nil) ) // Loopback represents a loopback interface on a NX-OS device. @@ -307,6 +308,27 @@ const ( IntfAddrTypeSecondary IntfAddrType = "secondary" ) +// FabricFwdIf that represents an Interface configured as part of the HMM Fabric Forwarding Instance. +type FabricFwdIf struct { + AdminSt AdminSt `json:"adminSt"` + ID string `json:"id"` + Mode FwdMode `json:"mode"` +} + +func (*FabricFwdIf) IsListItem() {} + +func (f *FabricFwdIf) XPath() string { + return "System/hmm-items/fwdinst-items/if-items/FwdIf-list[id=" + f.ID + "]" +} + +type FwdMode string + +const ( + FwdModeStandard FwdMode = "standard" + FwdModeAnycastGateway FwdMode = "anycastGW" + FwdModeProxyGateway FwdMode = "proxyGW" +) + // Range provides a string representation of identifiers (typically VLAN IDs) that formats the range in a human-readable way. // Consecutive IDs are represented as a range (e.g., "10-12"), while single IDs are shown individually (e.g., "15"). // All values are joined in a comma-separated list of ranges and individual IDs, e.g. "10-12,15,20-22". diff --git a/internal/provider/cisco/nxos/nve.go b/internal/provider/cisco/nxos/nve.go index 1dd1592f..44fa34af 100644 --- a/internal/provider/cisco/nxos/nve.go +++ b/internal/provider/cisco/nxos/nve.go @@ -13,16 +13,16 @@ var _ gnmiext.Configurable = (*NVE)(nil) // NVE represents the Network Virtualization Edge interface (nve1). type NVE struct { - AdminSt AdminSt `json:"adminSt"` - AdvertiseVmac bool `json:"advertiseVmac"` - AnycastInterface string `json:"anycastIntf,omitempty"` - ID int `json:"epId"` - HoldDownTime int16 `json:"holdDownTime"` - HostReach HostReachType `json:"hostReach"` - McastGroupL2 string `json:"mcastGroupL2,omitempty"` - McastGroupL3 string `json:"mcastGroupL3,omitempty"` - SourceInterface string `json:"sourceInterface"` - SuppressARP bool `json:"suppressARP"` + AdminSt AdminSt `json:"adminSt"` + AdvertiseVmac bool `json:"advertiseVmac"` + AnycastInterface Option[string] `json:"anycastIntf"` + ID int `json:"epId"` + HoldDownTime int16 `json:"holdDownTime"` + HostReach HostReachType `json:"hostReach"` + McastGroupL2 Option[string] `json:"mcastGroupL2"` + McastGroupL3 Option[string] `json:"mcastGroupL3"` + SourceInterface string `json:"sourceInterface"` + SuppressARP bool `json:"suppressARP"` } func (*NVE) IsListItem() {} @@ -31,9 +31,37 @@ func (n *NVE) XPath() string { return "System/eps-items/epId-items/Ep-list[epId=" + strconv.Itoa(n.ID) + "]" } +type VNI struct { + AssociateVrfFlag bool `json:"associateVrfFlag"` + McastGroup Option[string] `json:"mcastGroup"` + Vni int32 `json:"vni"` +} + +func (*VNI) IsListItem() {} + +func (v *VNI) XPath() string { + return "System/eps-items/epId-items/Ep-list[epId=1]/nws-items/vni-items/Nw-list[vni=" + strconv.FormatInt(int64(v.Vni), 10) + "]" +} + +type VNIOperItems struct { + Vni int32 `json:"vni"` + State OperSt `json:"state"` +} + +func (v *VNIOperItems) XPath() string { + return "System/eps-items/epId-items/Ep-list[epId=1]/nws-items/opervni-items/OperNw-list[vni=" + strconv.FormatInt(int64(v.Vni), 10) + "]" +} + type HostReachType string const ( HostReachFloodAndLearn HostReachType = "Flood_and_learn" HostReachBGP HostReachType = "bgp" ) + +type VNIState string + +const ( + VNIStateUp VNIState = "Up" + VNIStateDown VNIState = "Down" +) diff --git a/internal/provider/cisco/nxos/nve_test.go b/internal/provider/cisco/nxos/nve_test.go index c8ecac72..b983e9c6 100644 --- a/internal/provider/cisco/nxos/nve_test.go +++ b/internal/provider/cisco/nxos/nve_test.go @@ -10,10 +10,16 @@ func init() { HostReach: HostReachBGP, AdvertiseVmac: true, SourceInterface: "lo0", - AnycastInterface: "lo1", + AnycastInterface: NewOption("lo1"), SuppressARP: true, - McastGroupL2: "237.0.0.1", + McastGroupL2: NewOption("237.0.0.1"), HoldDownTime: 300, } Register("nve", nve) + + vni := &VNI{ + Vni: 100010, + McastGroup: NewOption("239.1.1.100"), + } + Register("vni", vni) } diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index 0ebbfce0..d2822575 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -37,6 +37,7 @@ var ( _ provider.BGPPeerProvider = (*Provider)(nil) _ provider.CertificateProvider = (*Provider)(nil) _ provider.DNSProvider = (*Provider)(nil) + _ provider.EVPNInstanceProvider = (*Provider)(nil) _ provider.InterfaceProvider = (*Provider)(nil) _ provider.ISISProvider = (*Provider)(nil) _ provider.ManagementAccessProvider = (*Provider)(nil) @@ -415,6 +416,138 @@ func (p *Provider) DeleteDNS(ctx context.Context) error { return p.client.Delete(ctx, d) } +func (p *Provider) EnsureEVPNInstance(ctx context.Context, req *provider.EVPNInstanceRequest) (err error) { + f := new(Feature) + f.Name = "nvo" + f.AdminSt = AdminStEnabled + + f2 := new(Feature) + f2.Name = "vnsegment" + f2.AdminSt = AdminStEnabled + + if err := p.client.Update(ctx, f, f2); err != nil { + return err + } + + // TODO: Remove hardcoded "evpn"/"bgp" feature and NVE instance when NVE is fully supported as a dedicated resource. + nve := new(NVE) + nve.ID = 1 + if err := p.client.GetConfig(ctx, nve); err != nil { + if !errors.Is(err, gnmiext.ErrNil) { + return err + } + + nve.AdminSt = AdminStEnabled + nve.HoldDownTime = 180 + nve.HostReach = HostReachBGP + nve.SourceInterface = "lo1" + + fe := new(Feature) + fe.Name = "evpn" + fe.AdminSt = AdminStEnabled + + fb := new(Feature) + fb.Name = "bgp" + fb.AdminSt = AdminStEnabled + + if err := p.client.Update(ctx, nve, fe, fb); err != nil { + return err + } + } + + conf := make([]gnmiext.Configurable, 0, 3) + if req.EVPNInstance.Spec.Type == v1alpha1.EVPNInstanceTypeBridged { + v := new(VLAN) + v.FabEncap = "vlan-" + strconv.FormatInt(int64(req.VLAN.Spec.ID), 10) + if err := p.client.GetConfig(ctx, v); err != nil { + return fmt.Errorf("evpn instance: failed to get vlan %d: %w", req.VLAN.Spec.ID, err) + } + + vxlan := new(VXLAN) + vxlan.AccEncap = "vxlan-" + strconv.FormatInt(int64(req.EVPNInstance.Spec.VNI), 10) + vxlan.FabEncap = v.FabEncap + conf = append(conf, vxlan) + } + + vni := new(VNI) + vni.Vni = req.EVPNInstance.Spec.VNI + if req.EVPNInstance.Spec.MulticastGroupAddress != "" { + vni.McastGroup = NewOption(req.EVPNInstance.Spec.MulticastGroupAddress) + } + conf = append(conf, vni) + + switch req.EVPNInstance.Spec.Type { + 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) + } + imports := &RttEntry{Type: RttEntryTypeImport} + exports := &RttEntry{Type: RttEntryTypeExport} + targets := req.EVPNInstance.Spec.RouteTargets + if len(targets) == 0 { + // If no route targets are specified, use 'route-target:unknown:0:0' for both import and export. + // This is equivalent to 'route-target both auto' on the command line. + targets = append(targets, v1alpha1.EVPNRouteTarget{Action: v1alpha1.RouteTargetActionBoth}) + } + for _, rt := range targets { + s, err := RouteTarget(rt.Value) + if err != nil { + return fmt.Errorf("evpn instance: invalid import route target: %w", err) + } + r := &Rtt{Rtt: s} + switch rt.Action { + case v1alpha1.RouteTargetActionImport: + imports.EntItems.RttEntryList.Set(r) + case v1alpha1.RouteTargetActionExport: + exports.EntItems.RttEntryList.Set(r) + case v1alpha1.RouteTargetActionBoth: + imports.EntItems.RttEntryList.Set(r) + exports.EntItems.RttEntryList.Set(r) + } + } + if imports.EntItems.RttEntryList.Len() > 0 { + evi.RttpItems.RttPList.Set(imports) + } + if exports.EntItems.RttEntryList.Len() > 0 { + evi.RttpItems.RttPList.Set(exports) + } + conf = append(conf, evi) + + case v1alpha1.EVPNInstanceTypeRouted: + vni.AssociateVrfFlag = true + } + + return p.client.Update(ctx, conf...) +} + +func (p *Provider) DeleteEVPNInstance(ctx context.Context, req *provider.EVPNInstanceRequest) error { + conf := make([]gnmiext.Configurable, 0, 3) + + evi := new(BDEVI) + evi.Encap = "vxlan-" + strconv.FormatInt(int64(req.EVPNInstance.Spec.VNI), 10) + conf = append(conf, evi) + + vni := new(VNI) + vni.Vni = req.EVPNInstance.Spec.VNI + conf = append(conf, vni) + + if req.EVPNInstance.Spec.Type == v1alpha1.EVPNInstanceTypeBridged { + bd := new(BDItems) + if err := p.client.GetConfig(ctx, bd); err != nil && !errors.Is(err, gnmiext.ErrNil) { + return err + } + + if v := bd.GetByVXLAN(evi.Encap); v != nil { + conf = append(conf, v) + } + } + + return p.client.Delete(ctx, conf...) +} + func (p *Provider) EnsureInterface(ctx context.Context, req *provider.EnsureInterfaceRequest) error { name, err := ShortName(req.Interface.Spec.Name) if err != nil { @@ -1061,7 +1194,7 @@ func (p *Provider) EnsureNVE(ctx context.Context, req *NVERequest) error { return errors.New("nve: source and anycast interfaces must be different") } nve.SourceInterface = srcIf - nve.AnycastInterface = anyIf + nve.AnycastInterface = NewOption(anyIf) if req.HostReach != HostReachBGP && req.HostReach != HostReachFloodAndLearn { return fmt.Errorf("nve: invalid host reach type %q", req.HostReach) @@ -1080,14 +1213,14 @@ func (p *Provider) EnsureNVE(ctx context.Context, req *NVERequest) error { if !ip.Is4() || !ip.IsMulticast() { return fmt.Errorf("nve: invalid multicast IPv4 address: %s", ip) } - nve.McastGroupL2 = ip.String() + nve.McastGroupL2 = NewOption(ip.String()) } if ip := req.McastL3; ip != nil { if !ip.Is4() || !ip.IsMulticast() { return fmt.Errorf("nve: invalid multicast IPv4 address: %s", ip) } - nve.McastGroupL3 = ip.String() + nve.McastGroupL3 = NewOption(ip.String()) } if req.HoldDownTime != 0 { @@ -1675,7 +1808,7 @@ func (p *Provider) EnsureVRF(ctx context.Context, req *provider.VRFRequest) erro return fmt.Errorf("invalid ASN in route distinguisher: %w", err) } dom.Rd = "rd:asn2-nn4:" + req.VRF.Spec.RouteDistinguisher - if asn > math.MaxUint16 { + if asn < math.MaxUint16 { dom.Rd = "rd:asn4-nn2:" + req.VRF.Spec.RouteDistinguisher } } diff --git a/internal/provider/cisco/nxos/testdata/evi.json b/internal/provider/cisco/nxos/testdata/evi.json new file mode 100644 index 00000000..f4060d52 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/evi.json @@ -0,0 +1,27 @@ +{ + "evpn-items": { + "adminSt": "enabled", + "bdevi-items": { + "BDEvi-list": [ + { + "encap": "vxlan-100010", + "rd": "rd:ipv4-nn2:10.0.0.10:65000", + "rttp-items": { + "RttP-list": [ + { + "type": "export", + "ent-items": { + "RttEntry-list": [ + { + "rtt": "route-target:as2-nn4:65000:100010" + } + ] + } + } + ] + } + } + ] + } + } +} diff --git a/internal/provider/cisco/nxos/testdata/evi.json.txt b/internal/provider/cisco/nxos/testdata/evi.json.txt new file mode 100644 index 00000000..f4193af1 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/evi.json.txt @@ -0,0 +1,4 @@ +evpn + vni 100010 l2 + rd 10.0.0.10:65000 + route-target export 65000:100010 diff --git a/internal/provider/cisco/nxos/testdata/nve.json b/internal/provider/cisco/nxos/testdata/nve.json index 4e3b26a8..a9ff60f2 100644 --- a/internal/provider/cisco/nxos/testdata/nve.json +++ b/internal/provider/cisco/nxos/testdata/nve.json @@ -10,6 +10,7 @@ "holdDownTime": 300, "hostReach": "bgp", "mcastGroupL2": "237.0.0.1", + "mcastGroupL3": "DME_UNSET_PROPERTY_MARKER", "sourceInterface": "lo0", "suppressARP": true } diff --git a/internal/provider/cisco/nxos/testdata/vlan.json b/internal/provider/cisco/nxos/testdata/vlan.json index ae7bff14..1dbdcbf8 100644 --- a/internal/provider/cisco/nxos/testdata/vlan.json +++ b/internal/provider/cisco/nxos/testdata/vlan.json @@ -3,7 +3,6 @@ "bd-items": { "BD-list": [ { - "accEncap": "vxlan-100010", "adminSt": "active", "BdState": "active", "fabEncap": "vlan-10", diff --git a/internal/provider/cisco/nxos/testdata/vni.json b/internal/provider/cisco/nxos/testdata/vni.json new file mode 100644 index 00000000..8f4e8cfe --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/vni.json @@ -0,0 +1,22 @@ +{ + "eps-items": { + "epId-items": { + "Ep-list": [ + { + "epId": "1", + "nws-items": { + "vni-items": { + "Nw-list": [ + { + "associateVrfFlag": false, + "mcastGroup": "239.1.1.100", + "vni": 100010 + } + ] + } + } + } + ] + } + } +} diff --git a/internal/provider/cisco/nxos/testdata/vni.json.txt b/internal/provider/cisco/nxos/testdata/vni.json.txt new file mode 100644 index 00000000..deba51a5 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/vni.json.txt @@ -0,0 +1,3 @@ +interface nve1 + member vni 100010 + mcast-group 239.1.1.100 diff --git a/internal/provider/cisco/nxos/vlan.go b/internal/provider/cisco/nxos/vlan.go index 369b7600..8c619872 100644 --- a/internal/provider/cisco/nxos/vlan.go +++ b/internal/provider/cisco/nxos/vlan.go @@ -4,6 +4,8 @@ package nxos import ( + "encoding/json" + "github.com/ironcore-dev/network-operator/internal/provider/cisco/gnmiext/v2" ) @@ -14,6 +16,7 @@ var ( _ gnmiext.Defaultable = (*VLANReservation)(nil) _ gnmiext.Configurable = (*VLAN)(nil) _ gnmiext.Configurable = (*VLANOperItems)(nil) + _ gnmiext.Configurable = (*VXLAN)(nil) ) // VLANSystem represents the settings shared among all VLANs @@ -46,7 +49,6 @@ func (v *VLANReservation) Default() { // VLAN represents a VLAN configuration on the device type VLAN struct { - AccEncap string `json:"accEncap,omitempty"` AdminSt BdState `json:"adminSt"` BdState BdState `json:"BdState"` // Note the capitalization of this fields JSON tag FabEncap string `json:"fabEncap"` @@ -78,3 +80,55 @@ const ( // BdStateInactive indicates that the bridge domain is inactive/suspended BdStateInactive BdState = "suspend" ) + +type BDItems struct { + BdList []struct { + AccEncap string `json:"accEncap"` + FabEncap string `json:"fabEncap"` + } `json:"BD-list"` +} + +func (*BDItems) XPath() string { + return "System/bd-items/bd-items" +} + +func (b *BDItems) GetByVXLAN(v string) *VXLAN { + for _, bd := range b.BdList { + if bd.AccEncap == v { + return &VXLAN{ + AccEncap: bd.AccEncap, + FabEncap: bd.FabEncap, + } + } + } + return nil +} + +var ( + _ json.Marshaler = VXLAN{} + _ json.Unmarshaler = (*VXLAN)(nil) +) + +// VXLAN represents VXLAN encapsulation settings for a VLAN. +// It is part of the Bridge Domain configuration of a VLAN. +type VXLAN struct { + AccEncap string `json:"-"` + FabEncap string `json:"-"` +} + +func (v *VXLAN) XPath() string { + return "System/bd-items/bd-items/BD-list[fabEncap=" + v.FabEncap + "]/accEncap" +} + +func (v VXLAN) MarshalJSON() ([]byte, error) { + return json.Marshal(v.AccEncap) +} + +func (v *VXLAN) UnmarshalJSON(b []byte) error { + var encap string + if err := json.Unmarshal(b, &encap); err != nil { + return err + } + v.AccEncap = encap + return nil +} diff --git a/internal/provider/cisco/nxos/vlan_test.go b/internal/provider/cisco/nxos/vlan_test.go index 3e39fcf6..cd7213fc 100644 --- a/internal/provider/cisco/nxos/vlan_test.go +++ b/internal/provider/cisco/nxos/vlan_test.go @@ -4,7 +4,7 @@ package nxos func init() { - Register("vlan", &VLAN{AccEncap: "vxlan-100010", AdminSt: BdStateActive, BdState: BdStateActive, FabEncap: "vlan-10", Name: NewOption("Test")}) + Register("vlan", &VLAN{AdminSt: BdStateActive, BdState: BdStateActive, FabEncap: "vlan-10", Name: NewOption("Test")}) Register("vlan_reservation", &VLANReservation{SysVlan: 3850}) Register("vlan_system", &VLANSystem{LongName: true}) } diff --git a/internal/provider/provider.go b/internal/provider/provider.go index fc58f4a6..d0c59822 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -472,6 +472,22 @@ type VLANRequest struct { ProviderConfig *ProviderConfig } +// EVPNInstanceProvider is the interface for the realization of the EVPNInstance objects over different providers. +type EVPNInstanceProvider interface { + Provider + + // EnsureEVPNInstance call is responsible for EVPNInstance realization on the provider. + EnsureEVPNInstance(context.Context, *EVPNInstanceRequest) error + // DeleteEVPNInstance call is responsible for EVPNInstance deletion on the provider. + DeleteEVPNInstance(context.Context, *EVPNInstanceRequest) error +} + +type EVPNInstanceRequest struct { + EVPNInstance *v1alpha1.EVPNInstance + ProviderConfig *ProviderConfig + VLAN *v1alpha1.VLAN +} + var mu sync.RWMutex // ProviderFunc returns a new [Provider] instance.