diff --git a/PROJECT b/PROJECT index be2d8eb2d..d71de4292 100644 --- a/PROJECT +++ b/PROJECT @@ -293,6 +293,14 @@ resources: kind: DHCPRelay 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: StaticRoute + path: github.com/ironcore-dev/network-operator/api/core/v1alpha1 + version: v1alpha1 - api: crdVersion: v1 namespaced: true diff --git a/Tiltfile b/Tiltfile index f3662dbc2..b0fd3a497 100644 --- a/Tiltfile +++ b/Tiltfile @@ -211,6 +211,9 @@ k8s_resource(new_name='mac-entry', objects=['mac-entry:probe'], trigger_mode=TRI k8s_resource(new_name='route-prefix', objects=['route-prefix:probe'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) k8s_resource(new_name='vtep-peers', objects=['vtep-peers:probe'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) +k8s_yaml('./config/samples/v1alpha1_staticroute.yaml') +k8s_resource(new_name='staticroute', objects=['str-cc-admin:staticroute'], resource_deps=['vrf-admin'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False, labels=['samples']) + 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/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go index c2c6e192c..38bcfda54 100644 --- a/api/core/v1alpha1/groupversion_info.go +++ b/api/core/v1alpha1/groupversion_info.go @@ -262,6 +262,15 @@ const ( // VRFNotFoundReason indicates that a referenced VRF was not found. VRFNotFoundReason = "VRFNotFound" + // VRFNotConfiguredReason indicates that a referenced VRF is not configured. + VRFNotConfiguredReason = "VRFNotConfigured" + + // VRFAlreadyInUseReason indicates that a referenced VRF is already in use by another interface or static route. + VRFAlreadyInUseReason = "VRFAlreadyInUse" + + // InterfaceNotConfiguredReason indicates that a referenced interface is not configured. + InterfaceNotConfiguredReason = "InterfaceNotConfigured" + // ParentInterfaceNotFoundReason indicates that a referenced parent interface for a subinterface was not found. ParentInterfaceNotFoundReason = "ParentInterfaceNotFound" diff --git a/api/core/v1alpha1/staticroute_types.go b/api/core/v1alpha1/staticroute_types.go new file mode 100644 index 000000000..f162a29ae --- /dev/null +++ b/api/core/v1alpha1/staticroute_types.go @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + "sync" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// StaticRouteSpec defines the desired state of StaticRoute +// Static routes are used to define explicit paths for network traffic. They can be categorized into different types based on their characteristics and use cases: +// Directly Connected Routes: Only output interfaces are specified, and the next hop is directly reachable through those interfaces. +// Recursive Static Routes: In a recursive static route, only the next hop is specified. The output interface is derived from the next hop. +// Fully Specified Static Routes: Specifies both the output interfaces and the next hop, providing a complete path for the traffic. +// Floating Static Routes: These routes have a higher administrative distance than dynamic routes, allowing them to serve as backup routes that are only used when the primary route is unavailable. +type StaticRouteSpec 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 Interface to its provider-specific configuration. + // +optional + ProviderConfigRef *TypedLocalObjectReference `json:"providerConfigRef,omitempty"` + + // Name is the name of the static route. + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=255 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Name is immutable" + Name string `json:"name"` + + // Description is an optional human-readable description for this static route. + // +optional + // +kubebuilder:validation:MaxLength=255 + Description string `json:"description,omitempty"` + + // VrfRef is a reference to the VRF resource that this static route belongs to. + // If not specified, the static route will be part of the default VRF. + // The referenced VRF must exist in the same namespace. + // +optional + VrfRef *LocalObjectReference `json:"vrfRef,omitempty"` + + // IPPrefix is the destination IP prefix for the static route. + // +required + Prefix IPPrefix `json:"prefix"` + + // +required + // +kubebuilder:validation:MinItems=1 + NextHops []*NextHop `json:"nextHops,omitempty"` +} + +type NextHop struct { + // TODO(sven-rosenzweig): It is possible to point an a static route in a VRF to an Interface. For now this is not needed. + // InterfaceRef is a reference to the Interface resource that this static route is associated with. + // The referenced Interface must exist in the same namespace. + // +optional + InterfaceRef *LocalObjectReference `json:"interfaceRef,omitempty"` + + // Address is the IP address of the next hop for the static route. + // +required + // +kubebuilder:validation:Format=ipv4 + Address string `json:"address,omitempty"` + + // Metric assigns a priority to the static route. Lower values indicate higher priority. + // +optional + Metric *int32 `json:"metric,omitempty"` +} + +// StaticRouteStatus defines the observed state of StaticRoute. +type StaticRouteStatus struct { + // The conditions are a list of status objects that describe the state of the StaticRoute. + // +listType=map + // +listMapKey=type + // +patchStrategy=mergegit + // +patchMergeKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Device",type=string,JSONPath=`.spec.deviceRef.name` +// +kubebuilder:printcolumn:name="VRF",type=string,JSONPath=`.spec.vrfRef.name` +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Paused",type=string,JSONPath=`.status.conditions[?(@.type=="Paused")].status`,priority=1 +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// StaticRoute is the Schema for the staticroutes API +type StaticRoute struct { + metav1.TypeMeta `json:",inline"` + + // metadata is a standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` + + // spec defines the desired state of StaticRoute + // +required + Spec StaticRouteSpec `json:"spec"` + + // status defines the observed state of StaticRoute + // +optional + Status StaticRouteStatus `json:"status,omitzero"` +} + +// GetConditions implements conditions.Getter. +func (sr *StaticRoute) GetConditions() []metav1.Condition { + return sr.Status.Conditions +} + +// SetConditions implements conditions.Setter. +func (sr *StaticRoute) SetConditions(conditions []metav1.Condition) { + sr.Status.Conditions = conditions +} + +// +kubebuilder:object:root=true + +// StaticRouteList contains a list of StaticRoute +type StaticRouteList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []StaticRoute `json:"items"` +} + +var ( + StaticRouteDependencies []schema.GroupVersionKind + staticRouteDependenciesMu sync.Mutex +) + +func RegisterStaticRouteDependency(gvk schema.GroupVersionKind) { + staticRouteDependenciesMu.Lock() + defer staticRouteDependenciesMu.Unlock() + StaticRouteDependencies = append(StaticRouteDependencies, gvk) +} + +func init() { + SchemeBuilder.Register(func(s *runtime.Scheme) error { + s.AddKnownTypes(GroupVersion, &StaticRoute{}, &StaticRouteList{}) + return nil + }) +} diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go index 7ceabafe5..4912ae8a2 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -3311,6 +3311,31 @@ func (in *NetworkVirtualizationEdgeStatus) DeepCopy() *NetworkVirtualizationEdge return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NextHop) DeepCopyInto(out *NextHop) { + *out = *in + if in.InterfaceRef != nil { + in, out := &in.InterfaceRef, &out.InterfaceRef + *out = new(LocalObjectReference) + **out = **in + } + if in.Metric != nil { + in, out := &in.Metric, &out.Metric + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NextHop. +func (in *NextHop) DeepCopy() *NextHop { + if in == nil { + return nil + } + out := new(NextHop) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OSPF) DeepCopyInto(out *OSPF) { *out = *in @@ -4575,6 +4600,125 @@ func (in *SetExtCommunityAction) DeepCopy() *SetExtCommunityAction { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StaticRoute) DeepCopyInto(out *StaticRoute) { + *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 StaticRoute. +func (in *StaticRoute) DeepCopy() *StaticRoute { + if in == nil { + return nil + } + out := new(StaticRoute) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *StaticRoute) 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 *StaticRouteList) DeepCopyInto(out *StaticRouteList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]StaticRoute, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StaticRouteList. +func (in *StaticRouteList) DeepCopy() *StaticRouteList { + if in == nil { + return nil + } + out := new(StaticRouteList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *StaticRouteList) 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 *StaticRouteSpec) DeepCopyInto(out *StaticRouteSpec) { + *out = *in + out.DeviceRef = in.DeviceRef + if in.ProviderConfigRef != nil { + in, out := &in.ProviderConfigRef, &out.ProviderConfigRef + *out = new(TypedLocalObjectReference) + **out = **in + } + if in.VrfRef != nil { + in, out := &in.VrfRef, &out.VrfRef + *out = new(LocalObjectReference) + **out = **in + } + in.Prefix.DeepCopyInto(&out.Prefix) + if in.NextHops != nil { + in, out := &in.NextHops, &out.NextHops + *out = make([]*NextHop, len(*in)) + for i := range *in { + if (*in)[i] != nil { + in, out := &(*in)[i], &(*out)[i] + *out = new(NextHop) + (*in).DeepCopyInto(*out) + } + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StaticRouteSpec. +func (in *StaticRouteSpec) DeepCopy() *StaticRouteSpec { + if in == nil { + return nil + } + out := new(StaticRouteSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *StaticRouteStatus) DeepCopyInto(out *StaticRouteStatus) { + *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 StaticRouteStatus. +func (in *StaticRouteStatus) DeepCopy() *StaticRouteStatus { + if in == nil { + return nil + } + out := new(StaticRouteStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Switchport) DeepCopyInto(out *Switchport) { *out = *in diff --git a/charts/network-operator/templates/crd/staticroutes.networking.metal.ironcore.dev.yaml b/charts/network-operator/templates/crd/staticroutes.networking.metal.ironcore.dev.yaml new file mode 100644 index 000000000..e319e57c9 --- /dev/null +++ b/charts/network-operator/templates/crd/staticroutes.networking.metal.ironcore.dev.yaml @@ -0,0 +1,262 @@ +{{- if .Values.crd.enabled }} +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + {{- if .Values.crd.keep }} + "helm.sh/resource-policy": keep + {{- end }} + controller-gen.kubebuilder.io/version: v0.22.0 + name: staticroutes.networking.metal.ironcore.dev +spec: + group: networking.metal.ironcore.dev + names: + kind: StaticRoute + listKind: StaticRouteList + plural: staticroutes + singular: staticroute + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.deviceRef.name + name: Device + type: string + - jsonPath: .spec.vrfRef.name + name: VRF + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Paused")].status + name: Paused + priority: 1 + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: StaticRoute is the Schema for the staticroutes 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: spec defines the desired state of StaticRoute + properties: + description: + description: Description is an optional human-readable description + for this static route. + maxLength: 255 + type: string + 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 + name: + description: Name is the name of the static route. + maxLength: 255 + minLength: 1 + type: string + x-kubernetes-validations: + - message: Name is immutable + rule: self == oldSelf + nextHops: + items: + properties: + address: + description: Address is the IP address of the next hop for the + static route. + format: ipv4 + type: string + interfaceRef: + description: |- + InterfaceRef is a reference to the Interface resource that this static route is associated with. + The referenced Interface must exist in the same namespace. + 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 + metric: + description: Metric assigns a priority to the static route. + Lower values indicate higher priority. + format: int32 + type: integer + required: + - address + type: object + minItems: 1 + type: array + prefix: + description: IPPrefix is the destination IP prefix for the static + route. + format: cidr + 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 Interface 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 + vrfRef: + description: |- + VrfRef is a reference to the VRF resource that this static route belongs to. + If not specified, the static route will be part of the default VRF. + The referenced VRF must exist in the same namespace. + 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 + required: + - deviceRef + - name + - nextHops + - prefix + type: object + status: + description: status defines the observed state of StaticRoute + properties: + conditions: + description: The conditions are a list of status objects that describe + the state of the StaticRoute. + 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/rbac/manager-role.yaml b/charts/network-operator/templates/rbac/manager-role.yaml index 79729cf5f..57bd6d2d8 100644 --- a/charts/network-operator/templates/rbac/manager-role.yaml +++ b/charts/network-operator/templates/rbac/manager-role.yaml @@ -98,6 +98,7 @@ rules: - probes - routingpolicies - snmp + - staticroutes - syslogs - users - vlans @@ -135,6 +136,7 @@ rules: - prefixsets/finalizers - routingpolicies/finalizers - snmp/finalizers + - staticroutes/finalizers - syslogs/finalizers - users/finalizers - vlans/finalizers @@ -168,6 +170,7 @@ rules: - probes/status - routingpolicies/status - snmp/status + - staticroutes/status - syslogs/status - users/status - vlans/status diff --git a/charts/network-operator/templates/rbac/networking-staticroute-admin-role.yaml b/charts/network-operator/templates/rbac/networking-staticroute-admin-role.yaml new file mode 100644 index 000000000..a941c887a --- /dev/null +++ b/charts/network-operator/templates/rbac/networking-staticroute-admin-role.yaml @@ -0,0 +1,31 @@ +{{- if .Values.rbac.helpers.enabled }} +apiVersion: rbac.authorization.k8s.io/v1 +{{- if .Values.rbac.namespaced }} +kind: Role +{{- else }} +kind: ClusterRole +{{- end }} +metadata: +{{- if .Values.rbac.namespaced }} + namespace: {{ .Release.Namespace }} +{{- end }} + labels: + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/name: {{ include "network-operator.name" . }} + helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + app.kubernetes.io/instance: {{ .Release.Name }} + name: {{ include "network-operator.resourceName" (dict "suffix" "networking-staticroute-admin-role" "context" $) }} +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - staticroutes + verbs: + - '*' +- apiGroups: + - networking.metal.ironcore.dev + resources: + - staticroutes/status + verbs: + - get +{{- end }} diff --git a/charts/network-operator/templates/rbac/networking-staticroute-editor-role.yaml b/charts/network-operator/templates/rbac/networking-staticroute-editor-role.yaml new file mode 100644 index 000000000..3917fd2e9 --- /dev/null +++ b/charts/network-operator/templates/rbac/networking-staticroute-editor-role.yaml @@ -0,0 +1,37 @@ +{{- if .Values.rbac.helpers.enabled }} +apiVersion: rbac.authorization.k8s.io/v1 +{{- if .Values.rbac.namespaced }} +kind: Role +{{- else }} +kind: ClusterRole +{{- end }} +metadata: +{{- if .Values.rbac.namespaced }} + namespace: {{ .Release.Namespace }} +{{- end }} + labels: + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/name: {{ include "network-operator.name" . }} + helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + app.kubernetes.io/instance: {{ .Release.Name }} + name: {{ include "network-operator.resourceName" (dict "suffix" "networking-staticroute-editor-role" "context" $) }} +rules: +- apiGroups: + - networking.networking.metal.ironcore.dev + resources: + - staticroutes + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.networking.metal.ironcore.dev + resources: + - staticroutes/status + verbs: + - get +{{- end }} diff --git a/charts/network-operator/templates/rbac/networking-staticroute-viewer-role.yaml b/charts/network-operator/templates/rbac/networking-staticroute-viewer-role.yaml new file mode 100644 index 000000000..f6feb41c7 --- /dev/null +++ b/charts/network-operator/templates/rbac/networking-staticroute-viewer-role.yaml @@ -0,0 +1,33 @@ +{{- if .Values.rbac.helpers.enabled }} +apiVersion: rbac.authorization.k8s.io/v1 +{{- if .Values.rbac.namespaced }} +kind: Role +{{- else }} +kind: ClusterRole +{{- end }} +metadata: +{{- if .Values.rbac.namespaced }} + namespace: {{ .Release.Namespace }} +{{- end }} + labels: + app.kubernetes.io/managed-by: {{ .Release.Service }} + app.kubernetes.io/name: {{ include "network-operator.name" . }} + helm.sh/chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + app.kubernetes.io/instance: {{ .Release.Name }} + name: {{ include "network-operator.resourceName" (dict "suffix" "networking-staticroute-viewer-role" "context" $) }} +rules: +- apiGroups: + - networking.networking.metal.ironcore.dev + resources: + - staticroutes + verbs: + - get + - list + - watch +- apiGroups: + - networking.networking.metal.ironcore.dev + resources: + - staticroutes/status + verbs: + - get +{{- end }} diff --git a/cmd/main.go b/cmd/main.go index d121a5633..3e97623b4 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -693,6 +693,19 @@ func main() { //nolint:gocyclo setupLog.Error(err, "Failed to create controller", "controller", "pool-ipprefix") os.Exit(1) } + + if err := (&corecontroller.StaticRouteReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorder("staticroute-controller"), + WatchFilterValue: watchFilterValue, + Locker: locker, + RequeueInterval: requeueInterval, + }).SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "StaticRoute") + os.Exit(1) + } + if os.Getenv("ENABLE_WEBHOOKS") != "false" { if err := webhookv1alpha1.SetupVRFWebhookWithManager(mgr); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "VRF") diff --git a/config/crd/bases/networking.metal.ironcore.dev_staticroutes.yaml b/config/crd/bases/networking.metal.ironcore.dev_staticroutes.yaml new file mode 100644 index 000000000..1e7fb31d1 --- /dev/null +++ b/config/crd/bases/networking.metal.ironcore.dev_staticroutes.yaml @@ -0,0 +1,258 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.22.0 + name: staticroutes.networking.metal.ironcore.dev +spec: + group: networking.metal.ironcore.dev + names: + kind: StaticRoute + listKind: StaticRouteList + plural: staticroutes + singular: staticroute + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.deviceRef.name + name: Device + type: string + - jsonPath: .spec.vrfRef.name + name: VRF + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Paused")].status + name: Paused + priority: 1 + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: StaticRoute is the Schema for the staticroutes 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: spec defines the desired state of StaticRoute + properties: + description: + description: Description is an optional human-readable description + for this static route. + maxLength: 255 + type: string + 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 + name: + description: Name is the name of the static route. + maxLength: 255 + minLength: 1 + type: string + x-kubernetes-validations: + - message: Name is immutable + rule: self == oldSelf + nextHops: + items: + properties: + address: + description: Address is the IP address of the next hop for the + static route. + format: ipv4 + type: string + interfaceRef: + description: |- + InterfaceRef is a reference to the Interface resource that this static route is associated with. + The referenced Interface must exist in the same namespace. + 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 + metric: + description: Metric assigns a priority to the static route. + Lower values indicate higher priority. + format: int32 + type: integer + required: + - address + type: object + minItems: 1 + type: array + prefix: + description: IPPrefix is the destination IP prefix for the static + route. + format: cidr + 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 Interface 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 + vrfRef: + description: |- + VrfRef is a reference to the VRF resource that this static route belongs to. + If not specified, the static route will be part of the default VRF. + The referenced VRF must exist in the same namespace. + 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 + required: + - deviceRef + - name + - nextHops + - prefix + type: object + status: + description: status defines the observed state of StaticRoute + properties: + conditions: + description: The conditions are a list of status objects that describe + the state of the StaticRoute. + 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/kustomization.yaml b/config/crd/kustomization.yaml index 0281b3f4e..f6490684a 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -28,6 +28,7 @@ resources: - bases/networking.metal.ironcore.dev_lldps.yaml - bases/networking.metal.ironcore.dev_configbackups.yaml - bases/networking.metal.ironcore.dev_ethernetsegments.yaml +- bases/networking.metal.ironcore.dev_staticroutes.yaml - bases/networking.metal.ironcore.dev_aaa.yaml - bases/networking.metal.ironcore.dev_probes.yaml - bases/pool.networking.metal.ironcore.dev_indexpools.yaml diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index c8e18ad6b..49d2f0fd9 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -88,6 +88,9 @@ resources: - snmp_admin_role.yaml - snmp_editor_role.yaml - snmp_viewer_role.yaml +- staticroute_admin_role.yaml +- staticroute_editor_role.yaml +- staticroute_viewer_role.yaml - syslog_admin_role.yaml - syslog_editor_role.yaml - syslog_viewer_role.yaml diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 66cdebb60..683e7ffdd 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -92,6 +92,7 @@ rules: - probes - routingpolicies - snmp + - staticroutes - syslogs - users - vlans @@ -129,6 +130,7 @@ rules: - prefixsets/finalizers - routingpolicies/finalizers - snmp/finalizers + - staticroutes/finalizers - syslogs/finalizers - users/finalizers - vlans/finalizers @@ -162,6 +164,7 @@ rules: - probes/status - routingpolicies/status - snmp/status + - staticroutes/status - syslogs/status - users/status - vlans/status diff --git a/config/rbac/staticroute_admin_role.yaml b/config/rbac/staticroute_admin_role.yaml new file mode 100644 index 000000000..0ed244599 --- /dev/null +++ b/config/rbac/staticroute_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.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: networking-staticroute-admin-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - staticroutes + verbs: + - '*' +- apiGroups: + - networking.metal.ironcore.dev + resources: + - staticroutes/status + verbs: + - get diff --git a/config/rbac/staticroute_editor_role.yaml b/config/rbac/staticroute_editor_role.yaml new file mode 100644 index 000000000..9c0d44e62 --- /dev/null +++ b/config/rbac/staticroute_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.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: networking-staticroute-editor-role +rules: +- apiGroups: + - networking.networking.metal.ironcore.dev + resources: + - staticroutes + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.networking.metal.ironcore.dev + resources: + - staticroutes/status + verbs: + - get diff --git a/config/rbac/staticroute_viewer_role.yaml b/config/rbac/staticroute_viewer_role.yaml new file mode 100644 index 000000000..877e02c59 --- /dev/null +++ b/config/rbac/staticroute_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.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: networking-staticroute-viewer-role +rules: +- apiGroups: + - networking.networking.metal.ironcore.dev + resources: + - staticroutes + verbs: + - get + - list + - watch +- apiGroups: + - networking.networking.metal.ironcore.dev + resources: + - staticroutes/status + verbs: + - get diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 4fdb74fa5..2b46876e5 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -26,6 +26,7 @@ resources: - v1alpha1_prefixset.yaml - v1alpha1_routingpolicy.yaml - v1alpha1_ethernetsegment.yaml +- v1alpha1_staticroute.yaml - v1alpha1_aaa.yaml - v1alpha1_indexpool.yaml - v1alpha1_ipaddresspool.yaml diff --git a/config/samples/v1alpha1_staticroute.yaml b/config/samples/v1alpha1_staticroute.yaml new file mode 100644 index 000000000..6999fcb44 --- /dev/null +++ b/config/samples/v1alpha1_staticroute.yaml @@ -0,0 +1,18 @@ +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: StaticRoute +metadata: + labels: + name: str-cc-admin +spec: + name: sr-cc-admin + description: StaticRoute for CC-ADMIN VRF + deviceRef: + name: leaf1 + vrfRef: + name: vrf-cc-admin + prefix: "192.168.1.0/24" + nextHops: + - address: "10.8.0.1/24" + metric: 10 + - address: "10.8.0.2/24" + metric: 20 diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md index f89990a8f..e2b7bb16c 100644 --- a/docs/api-reference/index.md +++ b/docs/api-reference/index.md @@ -349,6 +349,7 @@ Package v1alpha1 contains API Schema definitions for the networking.metal.ironco - [Probe](#probe) - [RoutingPolicy](#routingpolicy) - [SNMP](#snmp) +- [StaticRoute](#staticroute) - [Syslog](#syslog) - [User](#user) - [VLAN](#vlan) @@ -2264,6 +2265,7 @@ _Appears in:_ - [PrefixEntry](#prefixentry) - [RendezvousPoint](#rendezvouspoint) - [RoutePresenceProbe](#routepresenceprobe) +- [StaticRouteSpec](#staticroutespec) @@ -2690,6 +2692,7 @@ _Appears in:_ - [ManagementAccessSpec](#managementaccessspec) - [NTPSpec](#ntpspec) - [NetworkVirtualizationEdgeSpec](#networkvirtualizationedgespec) +- [NextHop](#nexthop) - [OSPFInterface](#ospfinterface) - [OSPFNeighbor](#ospfneighbor) - [OSPFSpec](#ospfspec) @@ -2701,6 +2704,7 @@ _Appears in:_ - [ProbeSpec](#probespec) - [RoutingPolicySpec](#routingpolicyspec) - [SNMPSpec](#snmpspec) +- [StaticRouteSpec](#staticroutespec) - [SyslogSpec](#syslogspec) - [SystemSpec](#systemspec) - [UserSpec](#userspec) @@ -3073,6 +3077,24 @@ _Appears in:_ | `hostReachability` _string_ | HostReachability indicates the actual method used for host reachability. | | | +#### NextHop + + + + + + + +_Appears in:_ +- [StaticRouteSpec](#staticroutespec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `interfaceRef` _[LocalObjectReference](#localobjectreference)_ | InterfaceRef is a reference to the Interface resource that this static route is associated with.
The referenced Interface must exist in the same namespace. | | Optional: \{\}
| +| `address` _string_ | Address is the IP address of the next hop for the static route. | | Format: ipv4
Required: \{\}
| +| `metric` _integer_ | Metric assigns a priority to the static route. Lower values indicate higher priority. | | Optional: \{\}
| + + #### OSPF @@ -4142,6 +4164,68 @@ _Appears in:_ | `Emergency` | | +#### StaticRoute + + + +StaticRoute is the Schema for the staticroutes API + + + + + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `apiVersion` _string_ | `networking.metal.ironcore.dev/v1alpha1` | | | +| `kind` _string_ | `StaticRoute` | | | +| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | Optional: \{\}
| +| `spec` _[StaticRouteSpec](#staticroutespec)_ | spec defines the desired state of StaticRoute | | Required: \{\}
| +| `status` _[StaticRouteStatus](#staticroutestatus)_ | status defines the observed state of StaticRoute | | Optional: \{\}
| + + +#### StaticRouteSpec + + + +StaticRouteSpec defines the desired state of StaticRoute +Static routes are used to define explicit paths for network traffic. They can be categorized into different types based on their characteristics and use cases: +Directly Connected Routes: Only output interfaces are specified, and the next hop is directly reachable through those interfaces. +Recursive Static Routes: In a recursive static route, only the next hop is specified. The output interface is derived from the next hop. +Fully Specified Static Routes: Specifies both the output interfaces and the next hop, providing a complete path for the traffic. +Floating Static Routes: These routes have a higher administrative distance than dynamic routes, allowing them to serve as backup routes that are only used when the primary route is unavailable. + + + +_Appears in:_ +- [StaticRoute](#staticroute) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `deviceRef` _[LocalObjectReference](#localobjectreference)_ | DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace.
Immutable. | | Required: \{\}
| +| `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 Interface to its provider-specific configuration. | | Optional: \{\}
| +| `name` _string_ | Name is the name of the static route. | | MaxLength: 255
MinLength: 1
Required: \{\}
| +| `description` _string_ | Description is an optional human-readable description for this static route. | | MaxLength: 255
Optional: \{\}
| +| `vrfRef` _[LocalObjectReference](#localobjectreference)_ | VrfRef is a reference to the VRF resource that this static route belongs to.
If not specified, the static route will be part of the default VRF.
The referenced VRF must exist in the same namespace. | | Optional: \{\}
| +| `prefix` _[IPPrefix](#ipprefix)_ | IPPrefix is the destination IP prefix for the static route. | | Format: cidr
Type: string
Required: \{\}
| +| `nextHops` _[NextHop](#nexthop) array_ | | | MinItems: 1
Required: \{\}
| + + +#### StaticRouteStatus + + + +StaticRouteStatus defines the observed state of StaticRoute. + + + +_Appears in:_ +- [StaticRoute](#staticroute) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.35/#condition-v1-meta) array_ | The conditions are a list of status objects that describe the state of the StaticRoute. | | Optional: \{\}
| + + #### Switchport @@ -4310,6 +4394,7 @@ _Appears in:_ - [ProbeSpec](#probespec) - [RoutingPolicySpec](#routingpolicyspec) - [SNMPSpec](#snmpspec) +- [StaticRouteSpec](#staticroutespec) - [SyslogSpec](#syslogspec) - [UserSpec](#userspec) - [VLANSpec](#vlanspec) diff --git a/internal/controller/core/staticroute_controller.go b/internal/controller/core/staticroute_controller.go new file mode 100644 index 000000000..c643f1890 --- /dev/null +++ b/internal/controller/core/staticroute_controller.go @@ -0,0 +1,534 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "context" + "errors" + "fmt" + "time" + + "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/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + kerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/client-go/tools/events" + "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/apistatus" + "github.com/ironcore-dev/network-operator/internal/conditions" + "github.com/ironcore-dev/network-operator/internal/deviceutil" + "github.com/ironcore-dev/network-operator/internal/paused" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/resourcelock" +) + +// StaticRouteReconciler reconciles a StaticRoute object +type StaticRouteReconciler 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. + Recorder events.EventRecorder + + // Locker is used to synchronize operations on resources targeting the same device. + Locker *resourcelock.ResourceLocker + + // RequeueInterval is the duration after which the controller should requeue the reconciliation, + // regardless of changes. + RequeueInterval time.Duration +} + +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=staticroutes,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=staticroutes/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=staticroutes/finalizers,verbs=update +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=vrfs,verbs=get;list;watch +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=interfaces,verbs=get;list;watch +// +kubebuilder:rbac:groups=events.k8s.io,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. +func (r *StaticRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, reterr error) { + log := ctrl.LoggerFrom(ctx) + log.V(3).Info("Reconciling resource") + + obj := new(v1alpha1.StaticRoute) + if err := r.Get(ctx, req.NamespacedName, obj); err != nil { + if apierrors.IsNotFound(err) { + log.V(3).Info("Resource not found. Ignoring since object must be deleted") + return ctrl.Result{}, nil + } + log.Error(err, "Failed to get resource") + return ctrl.Result{}, err + } + + device, err := deviceutil.GetDeviceByName(ctx, r, obj.Namespace, obj.Spec.DeviceRef.Name) + if err != nil { + return ctrl.Result{}, err + } + + prov, err := provider.LoadProvider[provider.StaticRouteProvider](device.Spec.Provider) + if err != nil { + reason := v1alpha1.NotImplementedReason + if _, ok := errors.AsType[provider.NotFoundError](err); ok { + reason = v1alpha1.ProviderNotFoundReason + } + if meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{ + Type: v1alpha1.ReadyCondition, + Status: metav1.ConditionFalse, + Reason: reason, + Message: err.Error(), + }) { + return ctrl.Result{}, r.Status().Update(ctx, obj) + } + return ctrl.Result{}, nil + } + + orig := obj.DeepCopy() + if isPaused, err := paused.EnsureCondition(ctx, r.Client, device, obj); isPaused || err != nil { + return ctrl.Result{}, err + } + + if err := r.Locker.AcquireLock(ctx, device.Name, "staticroute-controller"); err != nil { + if errors.Is(err, resourcelock.ErrLockAlreadyHeld) { + log.V(3).Info("Device is already locked, requeuing reconciliation") + return ctrl.Result{RequeueAfter: Jitter(time.Second), Priority: new(LockWaitPriorityHigh)}, nil + } + log.Error(err, "Failed to acquire device lock") + return ctrl.Result{}, err + } + defer func() { + if err := r.Locker.ReleaseLock(ctx, device.Name, "staticroute-controller"); err != nil { + log.Error(err, "Failed to release device lock") + reterr = kerrors.NewAggregate([]error{reterr, 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 := &staticRouteScope{ + Device: device, + StaticRoute: 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.V(3).Info("Resource is being deleted, skipping reconciliation") + return ctrl.Result{}, nil + } + + 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.V(1).Info("Added finalizer to resource") + return ctrl.Result{}, nil + } + + if conditions.InitializeConditions(obj, v1alpha1.ReadyCondition, v1alpha1.ConfiguredCondition) { + log.V(1).Info("Initializing status conditions") + return ctrl.Result{}, r.Status().Update(ctx, obj) + } + + defer func() { + if !equality.Semantic.DeepEqual(orig.ObjectMeta, obj.ObjectMeta) { + if err := r.Patch(ctx, obj.DeepCopy(), client.MergeFrom(orig)); err != nil { + log.Error(err, "Failed to update resource metadata") + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + } + 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{}, apistatus.WrapTerminalError(err) + } + + return ctrl.Result{RequeueAfter: r.RequeueInterval}, nil +} + +const ( + staticRouteVrfRefKey = ".spec.vrfRef.name" + staticRouteInterfaceRefKey = ".spec.interfaceRef.name" +) + +// SetupWithManager sets up the controller with the Manager. +func (r *StaticRouteReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { + if r.RequeueInterval == 0 { + return errors.New("requeue interval must not be 0") + } + + 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.StaticRoute{}, staticRouteVrfRefKey, func(obj client.Object) []string { + sr := obj.(*v1alpha1.StaticRoute) + if sr.Spec.VrfRef == nil { + return nil + } + return []string{sr.Spec.VrfRef.Name} + }); err != nil { + return err + } + + if err := mgr.GetFieldIndexer().IndexField(ctx, &v1alpha1.StaticRoute{}, v1alpha1.DeviceRefIndexKey, func(obj client.Object) []string { + o := obj.(*v1alpha1.StaticRoute) + return []string{o.Spec.DeviceRef.Name} + }); err != nil { + return err + } + + bldr := ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.StaticRoute{}). + Named("staticroute"). + WithEventFilter(filter) + + for _, gvk := range v1alpha1.StaticRouteDependencies { + obj := &unstructured.Unstructured{} + obj.SetGroupVersionKind(gvk) + + bldr = bldr.Watches( + obj, + handler.EnqueueRequestsFromMapFunc(r.staticRoutesForProviderConfig), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ) + } + + return bldr. + Watches( + &v1alpha1.VRF{}, + handler.EnqueueRequestsFromMapFunc(r.vrfToStaticRoute), + builder.WithPredicates(predicate.Funcs{ + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + UpdateFunc: func(e event.UpdateEvent) bool { + return false + }, + }), + ). + Watches( + &v1alpha1.Device{}, + handler.EnqueueRequestsFromMapFunc(r.deviceToStaticRoutes), + builder.WithPredicates(predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + return paused.DevicePausedChanged(e.ObjectOld, e.ObjectNew) + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + }), + ). + Complete(r) +} + +// staticRouteScope holds the different objects that are read and used during the reconcile. +type staticRouteScope struct { + Device *v1alpha1.Device + StaticRoute *v1alpha1.StaticRoute + Connection *deviceutil.Connection + ProviderConfig *provider.ProviderConfig + Provider provider.StaticRouteProvider + IntfMap map[string]*v1alpha1.Interface +} + +func (r *StaticRouteReconciler) reconcile(ctx context.Context, s *staticRouteScope) (reterr error) { + if s.StaticRoute.Labels == nil { + s.StaticRoute.Labels = make(map[string]string) + } + + s.StaticRoute.Labels[v1alpha1.DeviceLabel] = s.Device.Name + + if !controllerutil.HasControllerReference(s.StaticRoute) { + if err := controllerutil.SetOwnerReference(s.Device, s.StaticRoute, r.Scheme, controllerutil.WithBlockOwnerDeletion(true)); err != nil { + return err + } + } + + defer func() { + conditions.RecomputeReady(s.StaticRoute) + }() + + var vrf *v1alpha1.VRF + if s.StaticRoute.Spec.VrfRef != nil { + var err error + vrf, err = r.reconcileVRF(ctx, s) + if err != nil { + return err + } + } + + intfMap, err := r.reconcileIntf(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}) + } + }() + + err = s.Provider.EnsureStaticRoute(ctx, &provider.StaticRouteRequest{ + StaticRoute: s.StaticRoute, + ProviderConfig: s.ProviderConfig, + VRF: vrf, + InterfaceMap: intfMap, + }) + + cond := conditions.FromError(err) + conditions.Set(s.StaticRoute, cond) + + return err +} + +// reconcileVRF ensures that the referenced VRF exists and belongs to the same device as the StaticRoute. +func (r *StaticRouteReconciler) reconcileVRF(ctx context.Context, s *staticRouteScope) (*v1alpha1.VRF, error) { + key := client.ObjectKey{ + Name: s.StaticRoute.Spec.VrfRef.Name, + Namespace: s.StaticRoute.Namespace, + } + + vrf := new(v1alpha1.VRF) + if err := r.Get(ctx, key, vrf); err != nil { + if apierrors.IsNotFound(err) { + conditions.Set(s.StaticRoute, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + 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.StaticRoute, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + 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 +} + +// reconcileIntf ensures that the referenced Interface exists and belongs to the same device as the StaticRoute. +func (r *StaticRouteReconciler) reconcileIntf(ctx context.Context, s *staticRouteScope) (map[string]*v1alpha1.Interface, error) { + intfMap := make(map[string]*v1alpha1.Interface) + for _, nexthop := range s.StaticRoute.Spec.NextHops { + if nexthop.InterfaceRef == nil { + continue + } + intf := new(v1alpha1.Interface) + key := client.ObjectKey{ + Name: nexthop.InterfaceRef.Name, + Namespace: s.StaticRoute.Namespace, + } + + if err := r.Get(ctx, key, intf); err != nil { + if apierrors.IsNotFound(err) { + conditions.Set(s.StaticRoute, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.InterfaceNotFoundReason, + Message: fmt.Sprintf("referenced Interface %q not found", client.ObjectKeyFromObject(intf)), + }) + return nil, err + } + conditions.Set(s.StaticRoute, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.InterfaceNotFoundReason, + Message: fmt.Sprintf("failed to get referenced Interface %q: %v", client.ObjectKeyFromObject(intf), err), + }) + return nil, err + } + + if intf.Spec.DeviceRef.Name != s.Device.Name { + conditions.Set(s.StaticRoute, metav1.Condition{ + Type: v1alpha1.ConfiguredCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.CrossDeviceReferenceReason, + Message: fmt.Sprintf("referenced Interface %q does not belong to device %q", intf.Name, s.Device.Name), + }) + return nil, fmt.Errorf("referenced Interface %q does not belong to device %q", intf.Name, s.Device.Name) + } + intfMap[intf.Name] = intf + } + return intfMap, nil +} + +func (r *StaticRouteReconciler) finalize(ctx context.Context, s *staticRouteScope) (reterr error) { + 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}) + } + }() + + vrf := new(v1alpha1.VRF) + vrf.Spec.Name = s.StaticRoute.Spec.VrfRef.Name + + return s.Provider.DeleteStaticRoute(ctx, &provider.StaticRouteRequest{ + StaticRoute: s.StaticRoute, + ProviderConfig: s.ProviderConfig, + VRF: vrf, + }) +} + +// vrfToStaticRoute is a [handler.MapFunc] to be used to enqueue requests for reconciliation +// for StaticRoutes when their referenced VRF changes. +func (r *StaticRouteReconciler) vrfToStaticRoute(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)) + + staticRoutes := new(v1alpha1.StaticRouteList) + if err := r.List(ctx, staticRoutes, client.InNamespace(vrf.Namespace), client.MatchingFields{staticRouteVrfRefKey: vrf.Name}); err != nil { + log.Error(err, "Failed to list StaticRoutes") + return nil + } + + requests := []ctrl.Request{} + for _, sr := range staticRoutes.Items { + if sr.Spec.VrfRef != nil && sr.Spec.VrfRef.Name == vrf.Name { + log.V(2).Info("Enqueuing StaticRoute for reconciliation", "StaticRoute", klog.KObj(&sr)) + + requests = append(requests, ctrl.Request{ + Name: sr.Name, + Namespace: sr.Namespace, + }) + } + } + + return requests +} + +// staticRoutesForProviderConfig is a [handler.MapFunc] to be used to enqueue requests for reconciliation +// for a StaticRoute to update when one of its referenced provider configurations gets updated. +func (r *StaticRouteReconciler) staticRoutesForProviderConfig(ctx context.Context, obj client.Object) []reconcile.Request { + log := ctrl.LoggerFrom(ctx, "Object", klog.KObj(obj)) + + list := &v1alpha1.StaticRouteList{} + if err := r.List(ctx, list, client.InNamespace(obj.GetNamespace())); err != nil { + log.Error(err, "Failed to list StaticRoutes") + return nil + } + + gkv := obj.GetObjectKind().GroupVersionKind() + + var requests []reconcile.Request + for _, m := range list.Items { + if m.Spec.ProviderConfigRef != nil && + m.Spec.ProviderConfigRef.Name == obj.GetName() && + m.Spec.ProviderConfigRef.Kind == gkv.Kind && + m.Spec.ProviderConfigRef.APIVersion == gkv.GroupVersion().Identifier() { + log.V(2).Info("Enqueuing StaticRoute for reconciliation", "StaticRoute", klog.KObj(&m)) + requests = append(requests, reconcile.Request{ + Name: m.Name, + Namespace: m.Namespace, + }) + } + } + + return requests +} + +// deviceToStaticRoutes is a [handler.MapFunc] to be used to enqueue requests for reconciliation +// for StaticRoutes when their referenced Device's effective pause state changes. +func (r *StaticRouteReconciler) deviceToStaticRoutes(ctx context.Context, obj client.Object) []ctrl.Request { + device, ok := obj.(*v1alpha1.Device) + if !ok { + panic(fmt.Sprintf("Expected a Device but got a %T", obj)) + } + + log := ctrl.LoggerFrom(ctx, "Device", klog.KObj(device)) + + staticRoutes := new(v1alpha1.StaticRouteList) + if err := r.List( + ctx, staticRoutes, + client.InNamespace(device.Namespace), + client.MatchingFields{v1alpha1.DeviceRefIndexKey: device.Name}, + ); err != nil { + log.Error(err, "Failed to list StaticRoutes") + return nil + } + + requests := make([]ctrl.Request, 0, len(staticRoutes.Items)) + for _, sr := range staticRoutes.Items { + log.V(2).Info("Enqueuing StaticRoute for reconciliation", "StaticRoute", klog.KObj(&sr)) + requests = append(requests, ctrl.Request{ + Name: sr.Name, + Namespace: sr.Namespace, + }) + } + + return requests +} diff --git a/internal/controller/core/staticroute_controller_test.go b/internal/controller/core/staticroute_controller_test.go new file mode 100644 index 000000000..34c10b479 --- /dev/null +++ b/internal/controller/core/staticroute_controller_test.go @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "net/netip" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + v1alpha1 "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +var _ = Describe("StaticRoute Controller", func() { + Context("When reconciling a resource", func() { + var ( + name string + key client.ObjectKey + vrfName string + ) + + BeforeEach(func() { + By("Creating a test Device resource") + device := &v1alpha1.Device{ + GenerateName: "test-staticroute-", + Namespace: metav1.NamespaceDefault, + Spec: v1alpha1.DeviceSpec{ + Provider: "test-provider", + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.2:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + name = device.Name + key = client.ObjectKey{Name: name, Namespace: metav1.NamespaceDefault} + + By("Creating a test VRF resource") + vrf := &v1alpha1.VRF{ + GenerateName: "test-vrf-", + Namespace: metav1.NamespaceDefault, + Labels: make(map[string]string), + Spec: v1alpha1.VRFSpec{ + DeviceRef: v1alpha1.LocalObjectReference{ + Name: device.Name, + }, + Name: "test-vrf", + }, + } + Expect(k8sClient.Create(ctx, vrf)).To(Succeed()) + vrfName = vrf.Name + }) + + AfterEach(func() { + By("Cleaning up all StaticRoute resources") + Expect(k8sClient.DeleteAllOf(ctx, &v1alpha1.StaticRoute{}, client.InNamespace(metav1.NamespaceDefault))).To(Succeed()) + + By("Cleaning up test VRF resource") + vrf := &v1alpha1.VRF{} + vrfKey := client.ObjectKey{Name: vrfName, Namespace: metav1.NamespaceDefault} + if err := k8sClient.Get(ctx, vrfKey, vrf); err == nil { + Expect(k8sClient.Delete(ctx, vrf)).To(Succeed()) + } + + By("Cleaning up test Device resource") + device := &v1alpha1.Device{} + if err := k8sClient.Get(ctx, key, device); err == nil { + Expect(k8sClient.Delete(ctx, device, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) + } + + By("Verifying all StaticRoutes are deleted") + Eventually(func(g Gomega) { + srList := &v1alpha1.StaticRouteList{} + g.Expect(k8sClient.List(ctx, srList, client.InNamespace(metav1.NamespaceDefault))).To(Succeed()) + g.Expect(srList.Items).To(BeEmpty()) + }).Should(Succeed()) + }) + + It("Should successfully reconcile a StaticRoute resource", func() { + By("Creating a StaticRoute with IPv4 routes") + distance := int32(1) + staticRoute := &v1alpha1.StaticRoute{ + GenerateName: "test-staticroute-", + Namespace: metav1.NamespaceDefault, + + Spec: v1alpha1.StaticRouteSpec{ + DeviceRef: v1alpha1.LocalObjectReference{ + Name: name, + }, + Name: "test-static-route", + VrfRef: &v1alpha1.LocalObjectReference{ + Name: vrfName, + }, + Prefix: v1alpha1.IPPrefix{ + Prefix: netip.MustParsePrefix("10.0.0.0/24"), + }, + NextHops: []*v1alpha1.NextHop{ + { + Address: "192.168.1.1", + Metric: &distance, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, staticRoute)).To(Succeed()) + staticRouteKey := client.ObjectKeyFromObject(staticRoute) + + By("Verifying the controller adds a finalizer") + Eventually(func(g Gomega) { + resource := &v1alpha1.StaticRoute{} + g.Expect(k8sClient.Get(ctx, staticRouteKey, 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.StaticRoute{} + g.Expect(k8sClient.Get(ctx, staticRouteKey, 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.StaticRoute{} + g.Expect(k8sClient.Get(ctx, staticRouteKey, 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.StaticRoute{} + g.Expect(k8sClient.Get(ctx, staticRouteKey, resource)).To(Succeed()) + g.Expect(resource.Status.Conditions).To(HaveLen(3)) + g.Expect(resource.Status.Conditions[0].Type).To(Equal(v1alpha1.ReadyCondition)) + g.Expect(resource.Status.Conditions[0].Status).To(Equal(metav1.ConditionTrue)) + g.Expect(resource.Status.Conditions[1].Type).To(Equal(v1alpha1.ConfiguredCondition)) + g.Expect(resource.Status.Conditions[1].Status).To(Equal(metav1.ConditionTrue)) + g.Expect(resource.Status.Conditions[1].Reason).To(Equal(v1alpha1.ConfiguredReason)) + }).Should(Succeed()) + }) + + It("Should handle StaticRoute with missing VRF reference", func() { + By("Creating a StaticRoute referencing non-existent VRF") + distance := int32(1) + staticRoute := &v1alpha1.StaticRoute{ + GenerateName: "test-staticroute-missing-vrf-", + Namespace: metav1.NamespaceDefault, + Spec: v1alpha1.StaticRouteSpec{ + DeviceRef: v1alpha1.LocalObjectReference{ + Name: name, + }, + Name: "test-static-route-missing-vrf", + VrfRef: &v1alpha1.LocalObjectReference{ + Name: "non-existent-vrf", + }, + Prefix: v1alpha1.IPPrefix{ + Prefix: netip.MustParsePrefix("172.16.0.0/12"), + }, + NextHops: []*v1alpha1.NextHop{ + { + Address: "10.0.0.254", + Metric: &distance, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, staticRoute)).To(Succeed()) + staticRouteKey := client.ObjectKeyFromObject(staticRoute) + + By("Verifying the controller adds a finalizer") + Eventually(func(g Gomega) { + resource := &v1alpha1.StaticRoute{} + g.Expect(k8sClient.Get(ctx, staticRouteKey, resource)).To(Succeed()) + g.Expect(controllerutil.ContainsFinalizer(resource, v1alpha1.FinalizerName)).To(BeTrue()) + }).Should(Succeed()) + + By("Verifying the controller sets ConfiguredCondition to False") + Eventually(func(g Gomega) { + resource := &v1alpha1.StaticRoute{} + g.Expect(k8sClient.Get(ctx, staticRouteKey, resource)).To(Succeed()) + g.Expect(resource.Status.Conditions).NotTo(BeEmpty()) + + cond := meta.FindStatusCondition(resource.Status.Conditions, v1alpha1.ConfiguredCondition) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + g.Expect(cond.Reason).To(Equal(v1alpha1.VRFNotFoundReason)) + g.Expect(cond.Message).To(ContainSubstring("non-existent-vrf")) + }).Should(Succeed()) + + By("Verifying ReadyCondition is False") + Eventually(func(g Gomega) { + resource := &v1alpha1.StaticRoute{} + g.Expect(k8sClient.Get(ctx, staticRouteKey, resource)).To(Succeed()) + cond := meta.FindStatusCondition(resource.Status.Conditions, v1alpha1.ReadyCondition) + g.Expect(cond).NotTo(BeNil()) + g.Expect(cond.Status).To(Equal(metav1.ConditionFalse)) + }).Should(Succeed()) + }) + }) +}) diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go index e4ebcf5d8..e5f814ccb 100644 --- a/internal/controller/core/suite_test.go +++ b/internal/controller/core/suite_test.go @@ -228,6 +228,15 @@ var _ = BeforeSuite(func() { }).SetupWithManager(ctx, k8sManager) Expect(err).NotTo(HaveOccurred()) + err = (&StaticRouteReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + Recorder: recorder, + Locker: testLocker, + RequeueInterval: time.Second, + }).SetupWithManager(ctx, k8sManager) + Expect(err).NotTo(HaveOccurred()) + err = (&PIMReconciler{ Client: k8sManager.GetClient(), Scheme: k8sManager.GetScheme(), @@ -422,6 +431,7 @@ var ( _ provider.EthernetSegmentProvider = (*Provider)(nil) _ provider.ConfigBackupProvider = (*Provider)(nil) _ provider.ProbeProvider = (*Provider)(nil) + _ provider.StaticRouteProvider = (*Provider)(nil) ) // Provider is a simple in-memory provider for testing purposes only. @@ -463,6 +473,7 @@ type Provider struct { StartupConfig *v1alpha1.ConfigBackup ConfigBackups []*provider.ConfigBackupFile StorageTotal int64 + StaticRoutes *v1alpha1.StaticRoute } func NewProvider() *Provider { @@ -1102,6 +1113,20 @@ func (p *Provider) GetVTEPPeers(context.Context, *provider.VTEPPeersRequest) ([] return []provider.VTEPPeer{{PeerIP: "192.0.2.10", OperStatus: true}}, nil } +func (p *Provider) EnsureStaticRoute(_ context.Context, req *provider.StaticRouteRequest) error { + p.Lock() + defer p.Unlock() + p.StaticRoutes = nil + return nil +} + +func (p *Provider) DeleteStaticRoute(_ context.Context, req *provider.StaticRouteRequest) error { + p.Lock() + defer p.Unlock() + p.StaticRoutes = nil + return nil +} + // SetLLDPNeighbor is a test helper to configure LLDP neighbor information for an interface. func (p *Provider) SetLLDPNeighbor(interfaceName, sysName, chassisID, portID string, ttl uint32) { p.Lock() diff --git a/internal/provider/cisco/iosxr/provider.go b/internal/provider/cisco/iosxr/provider.go index 7a7b733d5..146feddde 100644 --- a/internal/provider/cisco/iosxr/provider.go +++ b/internal/provider/cisco/iosxr/provider.go @@ -20,13 +20,14 @@ import ( ) var ( - _ provider.Provider = &Provider{} - _ provider.DeviceProvider = &Provider{} - _ provider.InterfaceProvider = &Provider{} - _ provider.VRFProvider = &Provider{} - _ provider.BGPProvider = &Provider{} - _ provider.BGPPeerProvider = &Provider{} - _ provider.PrefixSetProvider = &Provider{} + _ provider.Provider = &Provider{} + _ provider.DeviceProvider = &Provider{} + _ provider.InterfaceProvider = &Provider{} + _ provider.VRFProvider = &Provider{} + _ provider.BGPProvider = &Provider{} + _ provider.BGPPeerProvider = &Provider{} + _ provider.PrefixSetProvider = &Provider{} + _ provider.StaticRouteProvider = &Provider{} ) type Provider struct { @@ -615,6 +616,66 @@ func (p *Provider) LoopbackInterfaceName(id int) (string, error) { return fmt.Sprintf("Loopback%d", id), nil } +func (p *Provider) EnsureStaticRoute(ctx context.Context, req *provider.StaticRouteRequest) error { + var nexthopAddress NexthopAddresses + var nexthopInterface NexthopInterfaces + + prefixIP := req.StaticRoute.Spec.Prefix + for _, nextHop := range req.StaticRoute.Spec.NextHops { + if nextHop.InterfaceRef != nil { + intfName := req.InterfaceMap[nextHop.InterfaceRef.Name].Spec.Name + nexthopInterface.NexthopInterface = append(nexthopInterface.NexthopInterface, + NewNexthopInterface(intfName, nextHop.Address, nextHop.Metric)) + continue + } + nexthopAddress.NexthopAddress = append(nexthopAddress.NexthopAddress, + NewNexthopAddress(nextHop.Address, nextHop.Metric)) + } + + prefix := Prefix{ + PrefixAddress: prefixIP.Addr().String(), + PrefixLength: prefixIP.Bits(), + IsIpv4: prefixIP.Addr().Is4(), + } + if len(nexthopAddress.NexthopAddress) > 0 { + prefix.NextHopAddress = &nexthopAddress + } + if len(nexthopInterface.NexthopInterface) > 0 { + prefix.NextHopInterface = &nexthopInterface + } + + if req.VRF != nil && req.VRF.Spec.Name != "" { + prefix.VRFName = req.VRF.Spec.Name + } + + // A single client.Update (gNMI replace) drops nexthop-addresses when both + // nexthop-addresses and nexthop-interface-addresses are present. Delete the + // prefix and patch the desired state back in a single atomic SetRequest so + // no traffic-blackhole window opens between the two operations. gNMI applies + // the delete before the update within one Set. + b := new(gnmiext.SetBuilder).Delete(&prefix).Patch(&prefix) + + return p.client.Do(ctx, b) +} + +func (p *Provider) DeleteStaticRoute(ctx context.Context, req *provider.StaticRouteRequest) error { + staticRoute := &Prefix{ + PrefixAddress: req.StaticRoute.Spec.Prefix.Addr().String(), + PrefixLength: req.StaticRoute.Spec.Prefix.Bits(), + } + + staticRoute.IsIpv4 = true + if !req.StaticRoute.Spec.Prefix.Addr().Is4() { + staticRoute.IsIpv4 = false + } + + if req.VRF != nil && req.VRF.Spec.Name != "" { + staticRoute.VRFName = req.VRF.Spec.Name + } + + return p.client.Delete(ctx, staticRoute) +} + func init() { provider.Register("iosxr.cisco.networking.metal.ironcore.dev", NewProvider) } diff --git a/internal/provider/cisco/iosxr/static_route.go b/internal/provider/cisco/iosxr/static_route.go new file mode 100644 index 000000000..861e06c24 --- /dev/null +++ b/internal/provider/cisco/iosxr/static_route.go @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package iosxr + +import "strconv" + +func (s *Prefix) XPath() string { + basePath := "Cisco-IOS-XR-um-router-static-cfg:router/static/address-family/" + if s.VRFName != "" { + basePath = "Cisco-IOS-XR-um-router-static-cfg:router/static/vrfs/vrf[vrf-name=" + s.VRFName + "]/address-family/" + } + + if s.IsIpv4 { + return basePath + "ipv4/unicast/prefixes/prefix[prefix-address=" + s.PrefixAddress + "][prefix-length=" + strconv.Itoa(s.PrefixLength) + "]" + } + return basePath + "ipv6/unicast/prefixes/prefix[prefix-address=" + s.PrefixAddress + "][prefix-length=" + strconv.Itoa(s.PrefixLength) + "]" +} + +type Prefix struct { + PrefixAddress string `json:"prefix-address"` + PrefixLength int `json:"prefix-length"` + NextHopAddress *NexthopAddresses `json:"nexthop-addresses,omitempty"` + NextHopInterface *NexthopInterfaces `json:"nexthop-interface-addresses,omitzero"` + VRFName string `json:"-"` + IsIpv4 bool `json:"-"` +} + +type NexthopAddresses struct { + NexthopAddress []NexthopAddress `json:"nexthop-address"` +} + +type NexthopAddress struct { + Address string `json:"address"` + Distance uint32 `json:"distance-metric,omitempty"` +} + +type NexthopInterfaces struct { + NexthopInterface []NexthopInterface `json:"nexthop-interface-address,omitempty"` +} + +type NexthopInterface struct { + Address string `json:"address,omitempty"` + InterfaceName string `json:"interface-name,omitempty"` + Distance uint32 `json:"distance-metric,omitempty"` +} + +func NewNexthopAddress(address string, distance *int32) NexthopAddress { + nexthop := NexthopAddress{ + Address: address, + } + if distance != nil && *distance >= 0 { + nexthop.Distance = uint32(*distance) + } + return nexthop +} + +func NewNexthopInterface(name, address string, distance *int32) NexthopInterface { + nexthop := NexthopInterface{ + Address: address, + InterfaceName: name, + } + if distance != nil && *distance >= 0 { + nexthop.Distance = uint32(*distance) + } + return nexthop +} diff --git a/internal/provider/cisco/iosxr/static_route_test.go b/internal/provider/cisco/iosxr/static_route_test.go new file mode 100644 index 000000000..8d2ae62e9 --- /dev/null +++ b/internal/provider/cisco/iosxr/static_route_test.go @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package iosxr + +func init() { + metric12 := int32(12) + metric10 := int32(10) + metric9 := int32(9) + + route := &Prefix{ + PrefixAddress: "192.168.1.0", + PrefixLength: 24, + IsIpv4: true, + NextHopAddress: &NexthopAddresses{ + NexthopAddress: []NexthopAddress{ + NewNexthopAddress("10.10.0.1", &metric12), + }, + }, + NextHopInterface: &NexthopInterfaces{ + NexthopInterface: []NexthopInterface{ + NewNexthopInterface("TwentyFiveGigE0/0/0/34", "10.9.2.1", &metric10), + NewNexthopInterface("TwentyFiveGigE0/0/0/35", "10.8.1.1", &metric9), + }, + }, + } + + Register("static_route", route) +} diff --git a/internal/provider/cisco/iosxr/testdata/static_route.json b/internal/provider/cisco/iosxr/testdata/static_route.json new file mode 100644 index 000000000..d4c3b6e87 --- /dev/null +++ b/internal/provider/cisco/iosxr/testdata/static_route.json @@ -0,0 +1,40 @@ +{ + "router": { + "static": { + "address-family": { + "ipv4": { + "unicast": { + "prefixes": { + "prefix": { + "nexthop-addresses": { + "nexthop-address": [ + { + "address": "10.10.0.1", + "distance-metric": 12 + } + ] + }, + "nexthop-interface-addresses": { + "nexthop-interface-address": [ + { + "address": "10.9.2.1", + "distance-metric": 10, + "interface-name": "TwentyFiveGigE0/0/0/34" + }, + { + "address": "10.8.1.1", + "distance-metric": 9, + "interface-name": "TwentyFiveGigE0/0/0/35" + } + ] + }, + "prefix-address": "192.168.1.0", + "prefix-length": 24 + } + } + } + } + } + } + } +} diff --git a/internal/provider/cisco/iosxr/testdata/static_route.json.txt b/internal/provider/cisco/iosxr/testdata/static_route.json.txt new file mode 100644 index 000000000..9edb12308 --- /dev/null +++ b/internal/provider/cisco/iosxr/testdata/static_route.json.txt @@ -0,0 +1,5 @@ +router static + address-family ipv4 unicast + 192.168.1.0/24 10.10.0.1 12 + 192.168.1.0/24 TwentyFiveGigE0/0/0/34 10.9.2.1 10 + 192.168.1.0/24 TwentyFiveGigE0/0/0/35 10.8.1.1 9 diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index ea05e7776..99d0c5b05 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -73,6 +73,7 @@ var ( _ provider.AAAProvider = (*Provider)(nil) _ provider.ConfigBackupProvider = (*Provider)(nil) _ provider.ProbeProvider = (*Provider)(nil) + _ provider.StaticRouteProvider = (*Provider)(nil) ) // maxSetOperations is the maximum number of operations per gNMI Set RPC. @@ -4375,6 +4376,40 @@ func NormalizeMACAddress(mac string) string { return fmt.Sprintf("%s:%s:%s:%s:%s:%s", h[0:2], h[2:4], h[4:6], h[6:8], h[8:10], h[10:12]) } +func (p *Provider) EnsureStaticRoute(ctx context.Context, req *provider.StaticRouteRequest) error { + vrfName := DefaultVRFName + if req.VRF != nil { + vrfName = req.VRF.Spec.Name + } + + route := &StaticRoute{ + VRF: vrfName, + Prefix: req.StaticRoute.Spec.Prefix.String(), + } + for _, nextHop := range req.StaticRoute.Spec.NextHops { + var intf string + if nextHop.InterfaceRef != nil { + intf = req.InterfaceMap[nextHop.InterfaceRef.Name].Spec.Name + } + route.NhItems.NexthopList.Set(NewStaticRouteNexthop(nextHop.Address, vrfName, intf, nextHop.Metric)) + } + + return p.client.Update(ctx, route) +} + +func (p *Provider) DeleteStaticRoute(ctx context.Context, req *provider.StaticRouteRequest) error { + vrfName := DefaultVRFName + if req.VRF != nil { + vrfName = req.VRF.Spec.Name + } + + route := &StaticRoute{ + VRF: vrfName, + Prefix: req.StaticRoute.Spec.Prefix.String(), + } + return p.client.Delete(ctx, route) +} + func init() { provider.Register("nx.cisco.networking.metal.ironcore.dev", NewProvider) } diff --git a/internal/provider/cisco/nxos/static_route.go b/internal/provider/cisco/nxos/static_route.go new file mode 100644 index 000000000..736d7ec59 --- /dev/null +++ b/internal/provider/cisco/nxos/static_route.go @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package nxos + +import ( + "github.com/ironcore-dev/network-operator/internal/transport/gnmiext" +) + +// defaultNexthopPref is the NX-OS platform default administrative distance for a +// static route next hop. It is applied when the API spec does not set a metric, +// so a repeated reconcile does not diff against the device's default. +const ( + defaultNexthopPref int32 = 1 + defaultInterface string = "unspecified" +) + +var _ gnmiext.DataElement = (*StaticRoute)(nil) + +// StaticRoute represents a Route-list entry under a VRF's IPv4 routing domain. +// VRF is the routing domain name (e.g. "default") and is not serialized to JSON; +// it is only used to build the XPath. +type StaticRoute struct { + VRF string `json:"-"` + Prefix string `json:"prefix"` + NhItems StaticRouteNhItems `json:"nh-items"` +} + +func (*StaticRoute) IsListItem() {} + +func (r *StaticRoute) XPath() string { + return "System/ipv4-items/inst-items/dom-items/Dom-list[name=" + r.VRF + + "]/rt-items/Route-list[prefix=" + r.Prefix + "]" +} + +type StaticRouteNhItems struct { + NexthopList gnmiext.List[StaticRouteNexthopKey, *StaticRouteNexthop] `json:"Nexthop-list"` +} + +type StaticRouteNexthopKey struct { + NhVrf string + NhAddr string + NhIf string + Pref int32 +} + +type StaticRouteNexthop struct { + NhAddr string `json:"nhAddr"` + NhIf string `json:"nhIf"` + NhVrf string `json:"nhVrf"` + Pref int32 `json:"pref"` +} + +func (n *StaticRouteNexthop) Key() StaticRouteNexthopKey { + return StaticRouteNexthopKey{NhVrf: n.NhVrf, NhAddr: n.NhAddr, NhIf: n.NhIf, Pref: n.Pref} +} + +func NewStaticRouteNexthop(address, vrf, intf string, metric *int32) *StaticRouteNexthop { + pref := defaultNexthopPref + if metric != nil { + pref = *metric + } + + if intf == "" { + intf = defaultInterface + } + return &StaticRouteNexthop{ + NhVrf: vrf, + Pref: pref, + NhAddr: address, + NhIf: intf, + } +} diff --git a/internal/provider/cisco/nxos/static_route_test.go b/internal/provider/cisco/nxos/static_route_test.go new file mode 100644 index 000000000..8ef2a4dfe --- /dev/null +++ b/internal/provider/cisco/nxos/static_route_test.go @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package nxos + +func init() { + metric30 := int32(30) + metric15 := int32(15) + + route := &StaticRoute{ + VRF: "test-vrf", + Prefix: "172.16.0.0/16", + } + route.NhItems.NexthopList.Set(NewStaticRouteNexthop("12.0.100.1", "test-vrf", "eth1/1", &metric30)) + route.NhItems.NexthopList.Set(NewStaticRouteNexthop("11.0.100.1", "test-vrf", "", &metric15)) + Register("staticroute", route) +} diff --git a/internal/provider/cisco/nxos/testdata/static_route.json b/internal/provider/cisco/nxos/testdata/static_route.json new file mode 100644 index 000000000..7da2d67d3 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/static_route.json @@ -0,0 +1,36 @@ +{ + "ipv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "test-vrf", + "rt-items": { + "Route-list": [ + { + "prefix": "172.16.0.0/16", + "nh-items": { + "Nexthop-list": [ + { + "nhAddr": "12.0.100.1", + "nhIf": "eth1/1", + "nhVrf": "test-vrf", + "pref": 30 + }, + { + "nhAddr": "11.0.100.1", + "nhIf": "unspecified", + "nhVrf": "test-vrf", + "pref": 15 + } + ] + } + } + ] + } + } + ] + } + } + } +} diff --git a/internal/provider/cisco/nxos/testdata/static_route.json.txt b/internal/provider/cisco/nxos/testdata/static_route.json.txt new file mode 100644 index 000000000..219fad0eb --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/static_route.json.txt @@ -0,0 +1,3 @@ +vrf context test-vrf + ip route 172.16.0.0/16 eth1/1 12.0.100.1 30 + ip route 172.16.0.0/16 11.0.100.1 15 diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 72da8f428..8614395b1 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -955,6 +955,23 @@ type VTEPPeer struct { OperStatus bool } +// StaticRouteProvider is the interface for the realization of the StaticRoute objects over different providers. +type StaticRouteProvider interface { + Provider + + // EnsureStaticRoute call is responsible for StaticRoute realization on the provider. + EnsureStaticRoute(context.Context, *StaticRouteRequest) error + // DeleteStaticRoute call is responsible for StaticRoute deletion on the provider. + DeleteStaticRoute(context.Context, *StaticRouteRequest) error +} + +type StaticRouteRequest struct { + StaticRoute *v1alpha1.StaticRoute + ProviderConfig *ProviderConfig + VRF *v1alpha1.VRF + InterfaceMap map[string]*v1alpha1.Interface +} + var mu sync.RWMutex // ProviderFunc returns a new [Provider] instance. diff --git a/test/gnmi/gnmi_suite_test.go b/test/gnmi/gnmi_suite_test.go index 03df6bef4..b9cc991ed 100644 --- a/test/gnmi/gnmi_suite_test.go +++ b/test/gnmi/gnmi_suite_test.go @@ -195,6 +195,15 @@ func registerControllers(ctx context.Context, mgr ctrl.Manager, recorder *events }).SetupWithManager(ctx, mgr) Expect(err).NotTo(HaveOccurred()) + err = (&core.StaticRouteReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + err = (&core.NTPReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), diff --git a/test/gnmi/testdata/nx.cisco.networking.metal.ironcore.dev/staticroute.txtar b/test/gnmi/testdata/nx.cisco.networking.metal.ironcore.dev/staticroute.txtar new file mode 100644 index 000000000..f7548c3fb --- /dev/null +++ b/test/gnmi/testdata/nx.cisco.networking.metal.ironcore.dev/staticroute.txtar @@ -0,0 +1,218 @@ +-- vrfs/test-vrf -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: VRF +metadata: + name: test-vrf + namespace: default +spec: + deviceRef: + name: device + name: test-vrf + description: "Test VRF" + +-- interfaces/eth1-1 -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + name: eth1-1 + namespace: default +spec: + deviceRef: + name: device + name: eth1/1 + description: Leaf1 to Spine1 + adminState: Up + type: Physical + mtu: 9216 + ipv4: + addresses: + - 10.0.1.1/30 + +-- staticroutes/test-vrf-route -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: StaticRoute +metadata: + name: test-vrf-route + namespace: default +spec: + deviceRef: + name: device + name: test-vrf-route + description: Default route to internet gateway + prefix: "172.16.0.0/16" + nextHops: + - address: "11.0.100.1" + metric: 15 + - address: "12.0.100.1" + metric: 30 + interfaceRef: + name: "eth1-1" + vrfRef: + name: "test-vrf" + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} + +-- state/expect -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + }, + "inst-items": { + "Inst-list": [ + { + "name": "test-vrf", + "descr": "Test VRF", + "dom-items": { + "Dom-list": [ + { + "name": "test-vrf", + "rd": "DME_UNSET_PROPERTY_MARKER" + } + ] + } + } + ] + }, + "intf-items": { + "phys-items": { + "PhysIf-list": [ + { + "accessVlan": "unknown", + "adminSt": "up", + "descr": "Leaf1 to Spine1", + "FECMode": "auto", + "id": "eth1/1", + "layer": "Layer3", + "mtu": 9216, + "medium": "broadcast", + "mode": "access", + "nativeVlan": "unknown", + "userCfgdFlags": "admin_layer,admin_mtu,admin_state", + "rtvrfMbr-items": { + "tDn": "/System/inst-items/Inst-list[name='default']" + }, + "physExtd-items": { + "bufferBoost": "enable" + } + } + ] + } + }, + "ipv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "id": "eth1/1", + "addr-items": { + "Addr-list": [ + { + "addr": "10.0.1.1/30", + "pref": 0, + "tag": 0, + "type": "primary" + } + ] + } + } + ] + } + }, + { + "name": "test-vrf", + "rt-items": { + "Route-list": [ + { + "prefix": "172.16.0.0/16", + "nh-items": { + "Nexthop-list": [ + { + "nhAddr": "11.0.100.1", + "nhIf": "unspecified", + "nhVrf": "test-vrf", + "pref": 15 + }, + { + "nhAddr": "12.0.100.1", + "nhIf": "eth1/1", + "nhVrf": "test-vrf", + "pref": 30 + } + ] + } + } + ] + } + } + ] + } + } + } + } + } + +-- state/delete -- + { + "System": { + "procsys-items": { + "bootTime": "1700000000" + }, + "inst-items": { + "Inst-list": [] + }, + "intf-items": { + "phys-items": { + "PhysIf-list": [ + { + "accessVlan": "vlan-1", + "descr": "DME_UNSET_PROPERTY_MARKER", + "FECMode": "auto", + "id": "eth1/1", + "layer": "Layer2", + "mtu": 1500, + "medium": "broadcast", + "mode": "access", + "nativeVlan": "vlan-1", + "userCfgdFlags": "", + "physExtd-items": { + "bufferBoost": "enable" + }, + "trunkVlans": "1-4094" + } + ] + } + }, + "ipv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [] + } + }, + { + "name": "test-vrf", + "rt-items": { + "Route-list": [] + } + } + ] + } + } + } + } + }