diff --git a/PROJECT b/PROJECT
index 432be3aa5..b93b13e77 100644
--- a/PROJECT
+++ b/PROJECT
@@ -290,6 +290,14 @@ resources:
kind: DHCPRelay
path: github.com/ironcore-dev/network-operator/api/core/v1alpha1
version: v1alpha1
+- api:
+ crdVersion: v1
+ namespaced: true
+ domain: networking.metal.ironcore.dev
+ group: core
+ kind: StaticRoute
+ path: github.com/ironcore-dev/network-operator/api/core/v1alpha1
+ version: v1alpha1
- api:
crdVersion: v1
namespaced: true
diff --git a/api/core/v1alpha1/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go
index b1d017686..22fa589cf 100644
--- a/api/core/v1alpha1/groupversion_info.go
+++ b/api/core/v1alpha1/groupversion_info.go
@@ -72,6 +72,10 @@ const L2VNILabel = "networking.metal.ironcore.dev/evi-name"
// the name of the VRF they belong to.
const VRFLabel = "networking.metal.ironcore.dev/vrf-name"
+// StaticRouteLabel is a label applied to VRFs to indicate
+// the name of the StaticRoute that references them.
+const StaticRouteLabel = "networking.metal.ironcore.dev/static-route-name"
+
// DeviceMaintenanceAnnotation is an annotation that can be applied to Device objects
// to trigger certain disruptive operations, such as reboots or firmware upgrades.
const DeviceMaintenanceAnnotation = "networking.metal.ironcore.dev/maintenance"
@@ -217,6 +221,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..aba6ca374
--- /dev/null
+++ b/api/core/v1alpha1/staticroute_types.go
@@ -0,0 +1,145 @@
+// SPDX-FileCopyrightText: 2025 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"`
+
+ // +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"`
+
+ // +required
+ // +kubebuilder:validation:MinItems=1
+ Routes []StaticRoutes `json:"routes,omitempty"`
+}
+
+type StaticRoutes 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"`
+
+ // IPPrefix is the destination IP prefix for the static route.
+ // +required
+ IPPrefix IPPrefix `json:"ipPrefix"`
+
+ // NextHop is the IP address of the next hop for the static route.
+ // +optional
+ NextHop string `json:"nextHop"`
+
+ // AdministrativeDistance assigns a priority to the static route. Lower values indicate higher priority.
+ // +optional
+ AdministrativeDistance *int32 `json:"administrativeDistance,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 Interface.
+ // +listType=map
+ // +listMapKey=type
+ // +patchStrategy=merge
+ // +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 1fd3266f2..2cc3da31a 100644
--- a/api/core/v1alpha1/zz_generated.deepcopy.go
+++ b/api/core/v1alpha1/zz_generated.deepcopy.go
@@ -4002,6 +4002,141 @@ 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
+ }
+ if in.Routes != nil {
+ in, out := &in.Routes, &out.Routes
+ *out = make([]StaticRoutes, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// 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 *StaticRoutes) DeepCopyInto(out *StaticRoutes) {
+ *out = *in
+ in.IPPrefix.DeepCopyInto(&out.IPPrefix)
+ if in.AdministrativeDistance != nil {
+ in, out := &in.AdministrativeDistance, &out.AdministrativeDistance
+ *out = new(int32)
+ **out = **in
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new StaticRoutes.
+func (in *StaticRoutes) DeepCopy() *StaticRoutes {
+ if in == nil {
+ return nil
+ }
+ out := new(StaticRoutes)
+ 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/rbac/manager-role.yaml b/charts/network-operator/templates/rbac/manager-role.yaml
index 66960deef..3c81a7c64 100644
--- a/charts/network-operator/templates/rbac/manager-role.yaml
+++ b/charts/network-operator/templates/rbac/manager-role.yaml
@@ -70,6 +70,7 @@ rules:
- prefixsets
- routingpolicies
- snmp
+ - staticroutes
- syslogs
- users
- vlans
@@ -107,6 +108,7 @@ rules:
- prefixsets/finalizers
- routingpolicies/finalizers
- snmp/finalizers
+ - staticroutes/finalizers
- syslogs/finalizers
- users/finalizers
- vlans/finalizers
@@ -138,6 +140,7 @@ rules:
- prefixsets/status
- routingpolicies/status
- snmp/status
+ - staticroutes/status
- syslogs/status
- users/status
- vlans/status
diff --git a/cmd/main.go b/cmd/main.go
index 6ec416967..92de3b8b1 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -650,6 +650,19 @@ func main() { //nolint:gocyclo
os.Exit(1)
}
+ if err := (&corecontroller.StaticRouteReconciler{
+ Client: mgr.GetClient(),
+ Scheme: mgr.GetScheme(),
+ Recorder: mgr.GetEventRecorder("staticroute-controller"),
+ WatchFilterValue: watchFilterValue,
+ Provider: prov,
+ 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..5882b7139
--- /dev/null
+++ b/config/crd/bases/networking.metal.ironcore.dev_staticroutes.yaml
@@ -0,0 +1,236 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.21.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:
+ 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
+ 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
+ routes:
+ items:
+ properties:
+ administrativeDistance:
+ description: AdministrativeDistance assigns a priority to the
+ static route. Lower values indicate higher priority.
+ format: int32
+ type: integer
+ ipPrefix:
+ description: IPPrefix is the destination IP prefix for the static
+ route.
+ format: cidr
+ type: string
+ nextHop:
+ description: NextHop is the IP address of the next hop for the
+ static route.
+ type: string
+ type: object
+ minItems: 1
+ type: array
+ 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
+ - routes
+ 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 Interface.
+ 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 fa2e19d84..4778be542 100644
--- a/config/crd/kustomization.yaml
+++ b/config/crd/kustomization.yaml
@@ -27,6 +27,7 @@ resources:
- bases/networking.metal.ironcore.dev_vrfs.yaml
- bases/networking.metal.ironcore.dev_lldps.yaml
- bases/networking.metal.ironcore.dev_ethernetsegments.yaml
+- bases/networking.metal.ironcore.dev_staticroutes.yaml
- bases/nx.cisco.networking.metal.ironcore.dev_bordergateways.yaml
- bases/nx.cisco.networking.metal.ironcore.dev_managementaccessconfigs.yaml
- bases/nx.cisco.networking.metal.ironcore.dev_networkvirtualizationedgeconfigs.yaml
diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml
index b3c034253..846470b6b 100644
--- a/config/rbac/kustomization.yaml
+++ b/config/rbac/kustomization.yaml
@@ -85,6 +85,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
@@ -125,3 +128,5 @@ resources:
- cisco/nx/bgpconfig_admin_role.yaml
- cisco/nx/bgpconfig_editor_role.yaml
- cisco/nx/bgpconfig_viewer_role.yaml
+
+
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index 8155c5914..9c4d5df77 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -64,6 +64,7 @@ rules:
- prefixsets
- routingpolicies
- snmp
+ - staticroutes
- syslogs
- users
- vlans
@@ -101,6 +102,7 @@ rules:
- prefixsets/finalizers
- routingpolicies/finalizers
- snmp/finalizers
+ - staticroutes/finalizers
- syslogs/finalizers
- users/finalizers
- vlans/finalizers
@@ -132,6 +134,7 @@ rules:
- prefixsets/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..7b347e3ea
--- /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.networking.metal.ironcore.dev
+ resources:
+ - staticroutes
+ verbs:
+ - '*'
+- apiGroups:
+ - networking.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 5367e21c5..bd8b94bcb 100644
--- a/config/samples/kustomization.yaml
+++ b/config/samples/kustomization.yaml
@@ -25,6 +25,7 @@ resources:
- v1alpha1_prefixset.yaml
- v1alpha1_routingpolicy.yaml
- v1alpha1_ethernetsegment.yaml
+- v1alpha1_staticroute.yaml
- cisco/nx/v1alpha1_bordergateway.yaml
- cisco/nx/v1alpha1_managementaccessconfig.yaml
- cisco/nx/v1alpha1_nveconfig.yaml
diff --git a/config/samples/v1alpha1_staticroute.yaml b/config/samples/v1alpha1_staticroute.yaml
new file mode 100644
index 000000000..fd11ba199
--- /dev/null
+++ b/config/samples/v1alpha1_staticroute.yaml
@@ -0,0 +1,18 @@
+apiVersion: core.networking.metal.ironcore.dev/v1alpha1
+kind: StaticRoute
+metadata:
+ labels:
+ app.kubernetes.io/name: network-operator
+ app.kubernetes.io/managed-by: kustomize
+ name: staticroute-sample
+spec:
+ Name: sample-static-route
+ Description: This is a sample static route.
+ DeviceName: sample-device
+ IPPrefix:
+ Prefix: "192.168.1.0/24"
+ VrfRef:
+ Name: sample-vrf
+ NextHop:
+ Prefix: "192.168.1.1/24"
+ AdministrativeDistance: 10
diff --git a/docs/api-reference/index.md b/docs/api-reference/index.md
index c66cf4eba..640dcc516 100644
--- a/docs/api-reference/index.md
+++ b/docs/api-reference/index.md
@@ -36,6 +36,7 @@ SPDX-License-Identifier: Apache-2.0
- [PrefixSet](#prefixset)
- [RoutingPolicy](#routingpolicy)
- [SNMP](#snmp)
+- [StaticRoute](#staticroute)
- [Syslog](#syslog)
- [User](#user)
- [VLAN](#vlan)
@@ -1694,6 +1695,7 @@ _Appears in:_
- [MulticastGroups](#multicastgroups)
- [PrefixEntry](#prefixentry)
- [RendezvousPoint](#rendezvouspoint)
+- [StaticRoutes](#staticroutes)
@@ -2073,6 +2075,7 @@ _Appears in:_
- [PrefixSetSpec](#prefixsetspec)
- [RoutingPolicySpec](#routingpolicyspec)
- [SNMPSpec](#snmpspec)
+- [StaticRouteSpec](#staticroutespec)
- [SyslogSpec](#syslogspec)
- [SystemSpec](#systemspec)
- [UserSpec](#userspec)
@@ -3353,6 +3356,85 @@ _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_ | | | 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: \{\}
|
+| `routes` _[StaticRoutes](#staticroutes) 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 Interface. | | Optional: \{\}
|
+
+
+#### StaticRoutes
+
+
+
+
+
+
+
+_Appears in:_
+- [StaticRouteSpec](#staticroutespec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `ipPrefix` _[IPPrefix](#ipprefix)_ | IPPrefix is the destination IP prefix for the static route. | | Format: cidr
Type: string
Optional: \{\}
Required: \{\}
|
+| `nextHop` _string_ | NextHop is the IP address of the next hop for the static route. | | Optional: \{\}
|
+| `administrativeDistance` _integer_ | AdministrativeDistance assigns a priority to the static route. Lower values indicate higher priority. | | Optional: \{\}
|
+
+
#### Switchport
@@ -3513,6 +3595,7 @@ _Appears in:_
- [PrefixSetSpec](#prefixsetspec)
- [RoutingPolicySpec](#routingpolicyspec)
- [SNMPSpec](#snmpspec)
+- [StaticRouteSpec](#staticroutespec)
- [SyslogSpec](#syslogspec)
- [UserSpec](#userspec)
- [VLANSpec](#vlanspec)
diff --git a/internal/controller/core/interface_controller.go b/internal/controller/core/interface_controller.go
index 8e5f7a46d..344b77994 100644
--- a/internal/controller/core/interface_controller.go
+++ b/internal/controller/core/interface_controller.go
@@ -90,7 +90,7 @@ func (r *InterfaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
if apierrors.IsNotFound(err) {
// If the custom resource is not found then it usually means that it was deleted or not created
// In this way, we will stop the reconciliation
- log.V(3).Info("Resource not found. Ignoring since object must be deleted")
+ log.V(3).Info("Resource not foufinnd. Ignoring since object must be deleted")
return ctrl.Result{}, nil
}
// Error reading the object - requeue the request.
diff --git a/internal/controller/core/staticroute_controller.go b/internal/controller/core/staticroute_controller.go
new file mode 100644
index 000000000..ac56d2104
--- /dev/null
+++ b/internal/controller/core/staticroute_controller.go
@@ -0,0 +1,534 @@
+// SPDX-FileCopyrightText: 2025 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"
+ "k8s.io/apimachinery/pkg/types"
+ 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
+
+ // Provider is the driver that will be used to create & delete the static route.
+ Provider provider.ProviderFunc
+
+ // 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
+ }
+
+ prov, ok := r.Provider().(provider.StaticRouteProvider)
+ if !ok {
+ if meta.SetStatusCondition(&obj.Status.Conditions, metav1.Condition{
+ Type: v1alpha1.ReadyCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.NotImplementedReason,
+ Message: "Provider does not implement provider.StaticRouteProvider",
+ }) {
+ return ctrl.Result{}, r.Status().Update(ctx, obj)
+ }
+ return ctrl.Result{}, nil
+ }
+
+ device, err := deviceutil.GetDeviceByName(ctx, r, obj.Namespace, obj.Spec.DeviceRef.Name)
+ if err != nil {
+ return ctrl.Result{}, err
+ }
+
+ if isPaused, requeue, err := paused.EnsureCondition(ctx, r.Client, device, obj); isPaused || requeue || err != nil {
+ return ctrl.Result{Requeue: requeue}, 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
+ }
+
+ orig := obj.DeepCopy()
+ 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
+ },
+ }),
+ ).
+ 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
+}
+
+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
+ }
+ }
+
+ 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.EnsureStaticRouteRequest{
+ StaticRoute: s.StaticRoute,
+ ProviderConfig: s.ProviderConfig,
+ VRF: vrf,
+ })
+
+ 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.
+// add a label to the vrf to indicate that it is being used by the static route
+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))
+ }
+
+ // if an label already exists do not overwrite, but check if it is the same static route, otherwise return a terminal error
+ if existingSR, found := vrf.Labels[v1alpha1.StaticRouteLabel]; found {
+ fmt.Println("sven: found label on vrf", vrf.Name, " static route: ", existingSR, " current static route: ", s.StaticRoute.Name, " vrf", vrf.Labels)
+ if existingSR != s.StaticRoute.Name {
+ conditions.Set(s.StaticRoute, metav1.Condition{
+ Type: v1alpha1.ConfiguredCondition,
+ Status: metav1.ConditionFalse,
+ Reason: v1alpha1.VRFAlreadyInUseReason,
+ Message: fmt.Sprintf("referenced VRF %q is already used by static route %q", vrf.Name, existingSR),
+ })
+ return nil, reconcile.TerminalError(fmt.Errorf("referenced VRF %q is already used by static route %q", vrf.Name, existingSR))
+ }
+ } else {
+ if vrf.Labels == nil {
+ vrf.Labels = make(map[string]string)
+ }
+ vrf.Labels[v1alpha1.StaticRouteLabel] = s.StaticRoute.Name
+ if err := r.Update(ctx, vrf); err != nil {
+ return nil, fmt.Errorf("failed to label referenced VRF %q: %w", vrf.Name, err)
+ }
+ }
+
+ return vrf, 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, err := r.finalizeVRF(ctx, s)
+ if err != nil {
+ return err
+ }
+
+ return s.Provider.DeleteStaticRoute(ctx, &provider.StaticRouteRequest{
+ StaticRoute: s.StaticRoute,
+ ProviderConfig: s.ProviderConfig,
+ VRF: vrf,
+ })
+}
+
+func (r *StaticRouteReconciler) finalizeVRF(ctx context.Context, s *staticRouteScope) (vrf *v1alpha1.VRF, reterr 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) {
+ // VRF not found - nothing to clean up
+ return nil, client.IgnoreNotFound(err)
+ }
+ return nil, fmt.Errorf("failed to get referenced VRF %q: %w", key, err)
+ }
+
+ // Only remove the label if it belongs to this StaticRoute
+ if existingSR, found := vrf.Labels[v1alpha1.StaticRouteLabel]; found && existingSR == s.StaticRoute.Name {
+ delete(vrf.Labels, v1alpha1.StaticRouteLabel)
+ if err := r.Update(ctx, vrf); err != nil {
+ return nil, fmt.Errorf("failed to remove label from referenced VRF %q: %w", vrf.Name, err)
+ }
+ }
+ return vrf, nil
+}
+
+// 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{
+ NamespacedName: client.ObjectKey{
+ 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{
+ NamespacedName: types.NamespacedName{
+ 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{
+ NamespacedName: client.ObjectKey{
+ 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..36b6b5e30
--- /dev/null
+++ b/internal/controller/core/staticroute_controller_test.go
@@ -0,0 +1,368 @@
+// SPDX-FileCopyrightText: 2025 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"
+ 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{
+ ObjectMeta: metav1.ObjectMeta{
+ GenerateName: "test-staticroute-",
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.DeviceSpec{
+ 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{
+ ObjectMeta: metav1.ObjectMeta{
+ 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{
+ ObjectMeta: metav1.ObjectMeta{
+ GenerateName: "test-staticroute-",
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.StaticRouteSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{
+ Name: name,
+ },
+ Name: "test-static-route",
+ VrfRef: &v1alpha1.LocalObjectReference{
+ Name: vrfName,
+ },
+ Routes: []v1alpha1.StaticRoutes{
+ {
+ IPPrefix: v1alpha1.IPPrefix{
+ Prefix: netip.MustParsePrefix("10.0.0.0/24"),
+ },
+ NextHop: "192.168.1.1",
+ AdministrativeDistance: &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{
+ ObjectMeta: metav1.ObjectMeta{
+ 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",
+ },
+ Routes: []v1alpha1.StaticRoutes{
+ {
+ IPPrefix: v1alpha1.IPPrefix{
+ Prefix: netip.MustParsePrefix("172.16.0.0/12"),
+ },
+ NextHop: "10.0.0.254",
+ AdministrativeDistance: &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())
+ configured := false
+ for _, cond := range resource.Status.Conditions {
+ if cond.Type == v1alpha1.ConfiguredCondition {
+ configured = true
+ 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"))
+ }
+ }
+ g.Expect(configured).To(BeTrue(), "ConfiguredCondition should be present")
+ }).Should(Succeed())
+
+ By("Verifying ReadyCondition is False")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.StaticRoute{}
+ g.Expect(k8sClient.Get(ctx, staticRouteKey, resource)).To(Succeed())
+ ready := false
+ for _, cond := range resource.Status.Conditions {
+ if cond.Type == v1alpha1.ReadyCondition {
+ ready = true
+ g.Expect(cond.Status).To(Equal(metav1.ConditionFalse))
+ }
+ }
+ g.Expect(ready).To(BeTrue(), "ReadyCondition should be present")
+ }).Should(Succeed())
+ })
+
+ It("Should reject a second StaticRoute trying to use the same VRF", func() {
+ By("Creating the first StaticRoute with VRF reference")
+ distance := int32(1)
+ staticRoute1 := &v1alpha1.StaticRoute{
+ ObjectMeta: metav1.ObjectMeta{
+ GenerateName: "test-staticroute-first-",
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.StaticRouteSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{
+ Name: name,
+ },
+ Name: "test-static-route-first",
+ VrfRef: &v1alpha1.LocalObjectReference{
+ Name: vrfName,
+ },
+ Routes: []v1alpha1.StaticRoutes{
+ {
+ IPPrefix: v1alpha1.IPPrefix{
+ Prefix: netip.MustParsePrefix("10.0.0.0/24"),
+ },
+ NextHop: "192.168.1.1",
+ AdministrativeDistance: &distance,
+ },
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, staticRoute1)).To(Succeed())
+ staticRouteKey1 := client.ObjectKeyFromObject(staticRoute1)
+
+ By("Waiting for first StaticRoute to be reconciled successfully")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.StaticRoute{}
+ g.Expect(k8sClient.Get(ctx, staticRouteKey1, resource)).To(Succeed())
+ g.Expect(controllerutil.ContainsFinalizer(resource, v1alpha1.FinalizerName)).To(BeTrue())
+
+ // Verify VRF has been labeled by the first StaticRoute
+ vrf := &v1alpha1.VRF{}
+ vrfKey := client.ObjectKey{Name: vrfName, Namespace: metav1.NamespaceDefault}
+ g.Expect(k8sClient.Get(ctx, vrfKey, vrf)).To(Succeed())
+ g.Expect(vrf.Labels).To(HaveKeyWithValue(v1alpha1.StaticRouteLabel, staticRoute1.Name))
+ }).Should(Succeed())
+
+ By("Creating a second StaticRoute trying to use the same VRF")
+ staticRoute2 := &v1alpha1.StaticRoute{
+ ObjectMeta: metav1.ObjectMeta{
+ GenerateName: "test-staticroute-second-",
+ Namespace: metav1.NamespaceDefault,
+ },
+ Spec: v1alpha1.StaticRouteSpec{
+ DeviceRef: v1alpha1.LocalObjectReference{
+ Name: name,
+ },
+ Name: "test-static-route-second",
+ VrfRef: &v1alpha1.LocalObjectReference{
+ Name: vrfName, // Same VRF!
+ },
+ Routes: []v1alpha1.StaticRoutes{
+ {
+ IPPrefix: v1alpha1.IPPrefix{
+ Prefix: netip.MustParsePrefix("10.1.0.0/24"),
+ },
+ NextHop: "192.168.1.2",
+ AdministrativeDistance: &distance,
+ },
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, staticRoute2)).To(Succeed())
+ staticRouteKey2 := client.ObjectKeyFromObject(staticRoute2)
+
+ By("Verifying the second StaticRoute gets a finalizer (initial reconcile)")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.StaticRoute{}
+ g.Expect(k8sClient.Get(ctx, staticRouteKey2, resource)).To(Succeed())
+ g.Expect(controllerutil.ContainsFinalizer(resource, v1alpha1.FinalizerName)).To(BeTrue())
+ }).Should(Succeed())
+
+ By("Verifying the second StaticRoute has ConfiguredCondition=False with VRFAlreadyInUse reason")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.StaticRoute{}
+ g.Expect(k8sClient.Get(ctx, staticRouteKey2, resource)).To(Succeed())
+
+ configured := false
+ for _, cond := range resource.Status.Conditions {
+ if cond.Type == v1alpha1.ConfiguredCondition {
+ configured = true
+ g.Expect(cond.Status).To(Equal(metav1.ConditionFalse))
+ g.Expect(cond.Reason).To(Equal(v1alpha1.VRFAlreadyInUseReason))
+ g.Expect(cond.Message).To(ContainSubstring("already used by static route"))
+ g.Expect(cond.Message).To(ContainSubstring(staticRoute1.Name))
+ }
+ }
+ g.Expect(configured).To(BeTrue(), "ConfiguredCondition should be present")
+ }).Should(Succeed())
+
+ By("Verifying the second StaticRoute has ReadyCondition=False")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.StaticRoute{}
+ g.Expect(k8sClient.Get(ctx, staticRouteKey2, resource)).To(Succeed())
+
+ ready := false
+ for _, cond := range resource.Status.Conditions {
+ if cond.Type == v1alpha1.ReadyCondition {
+ ready = true
+ g.Expect(cond.Status).To(Equal(metav1.ConditionFalse))
+ }
+ }
+ g.Expect(ready).To(BeTrue(), "ReadyCondition should be present")
+ }).Should(Succeed())
+
+ By("Verifying VRF label still points to the first StaticRoute")
+ vrf := &v1alpha1.VRF{}
+ vrfKey := client.ObjectKey{Name: vrfName, Namespace: metav1.NamespaceDefault}
+ Expect(k8sClient.Get(ctx, vrfKey, vrf)).To(Succeed())
+ Expect(vrf.Labels).To(HaveKeyWithValue(v1alpha1.StaticRouteLabel, staticRoute1.Name))
+
+ By("Deleting the first StaticRoute")
+ Expect(k8sClient.Delete(ctx, staticRoute1)).To(Succeed())
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.StaticRoute{}
+ err := k8sClient.Get(ctx, staticRouteKey1, resource)
+ g.Expect(err).To(HaveOccurred())
+ }).Should(Succeed())
+
+ By("Verifying the second StaticRoute can now reconcile successfully")
+ Eventually(func(g Gomega) {
+ resource := &v1alpha1.StaticRoute{}
+ g.Expect(k8sClient.Get(ctx, staticRouteKey2, resource)).To(Succeed())
+
+ // Check VRF is now labeled with the second StaticRoute
+ vrf := &v1alpha1.VRF{}
+ g.Expect(k8sClient.Get(ctx, vrfKey, vrf)).To(Succeed())
+ g.Expect(vrf.Labels).To(HaveKeyWithValue(v1alpha1.StaticRouteLabel, staticRoute2.Name))
+
+ // // Check conditions are now successful
+ for _, cond := range resource.Status.Conditions {
+ if cond.Type == v1alpha1.ReadyCondition {
+ g.Expect(cond.Status).To(Equal(metav1.ConditionTrue))
+ }
+ if cond.Type == v1alpha1.ConfiguredCondition {
+ g.Expect(cond.Status).To(Equal(metav1.ConditionTrue))
+ }
+ }
+ }).Should(Succeed())
+ })
+ })
+})
diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go
index ce3431c5a..9cf8b6103 100644
--- a/internal/controller/core/suite_test.go
+++ b/internal/controller/core/suite_test.go
@@ -233,6 +233,16 @@ var _ = BeforeSuite(func() {
}).SetupWithManager(ctx, k8sManager)
Expect(err).NotTo(HaveOccurred())
+ err = (&StaticRouteReconciler{
+ Client: k8sManager.GetClient(),
+ Scheme: k8sManager.GetScheme(),
+ Recorder: recorder,
+ Provider: prov,
+ Locker: testLocker,
+ RequeueInterval: time.Second,
+ }).SetupWithManager(ctx, k8sManager)
+ Expect(err).NotTo(HaveOccurred())
+
err = (&PIMReconciler{
Client: k8sManager.GetClient(),
Scheme: k8sManager.GetScheme(),
@@ -419,6 +429,7 @@ var (
_ provider.LLDPProvider = (*Provider)(nil)
_ provider.DHCPRelayProvider = (*Provider)(nil)
_ provider.EthernetSegmentProvider = (*Provider)(nil)
+ _ provider.StaticRouteProvider = (*Provider)(nil)
)
// Provider is a simple in-memory provider for testing purposes only.
@@ -456,6 +467,7 @@ type Provider struct {
LLDPNeighbors map[string]*provider.LLDPAdjacency
DHCPRelay *v1alpha1.DHCPRelay
EthernetSegments map[string]string
+ StaticRoutes *v1alpha1.StaticRoute
}
func NewProvider() *Provider {
@@ -998,6 +1010,21 @@ func (p *Provider) GetEthernetSegment(name string) (string, bool) {
return esi, ok
}
+func (p *Provider) EnsureStaticRoute(_ context.Context, req *provider.EnsureStaticRouteRequest) 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/controller/core/vrf_controller.go b/internal/controller/core/vrf_controller.go
index 05a1cb30a..ccf19ecdd 100644
--- a/internal/controller/core/vrf_controller.go
+++ b/internal/controller/core/vrf_controller.go
@@ -313,6 +313,11 @@ func (r *VRFReconciler) finalize(ctx context.Context, s *vrfScope) (reterr error
}
}()
+ // Check if the VRF is still referenced by any StaticRoute.
+ if _, found := s.VRF.Labels[v1alpha1.StaticRouteLabel]; found {
+ return fmt.Errorf("cannot delete VRF %q because it is still referenced by StaticRoute %q", s.VRF.Name, s.VRF.Labels[v1alpha1.StaticRouteLabel])
+ }
+
return s.Provider.DeleteVRF(ctx, &provider.VRFRequest{
VRF: s.VRF,
ProviderConfig: s.ProviderConfig,
diff --git a/internal/provider/cisco/iosxr/provider.go b/internal/provider/cisco/iosxr/provider.go
index c05255a09..1f57c7d90 100644
--- a/internal/provider/cisco/iosxr/provider.go
+++ b/internal/provider/cisco/iosxr/provider.go
@@ -20,12 +20,11 @@ import (
)
var (
- _ provider.Provider = &Provider{}
- _ provider.DeviceProvider = &Provider{}
- _ provider.InterfaceProvider = &Provider{}
- _ provider.VRFProvider = &Provider{}
- _ provider.BGPProvider = &Provider{}
- _ provider.BGPPeerProvider = &Provider{}
+ _ provider.Provider = &Provider{}
+ _ provider.DeviceProvider = &Provider{}
+ _ provider.InterfaceProvider = &Provider{}
+ _ provider.VRFProvider = &Provider{}
+ _ provider.StaticRouteProvider = &Provider{}
)
type Provider struct {
@@ -574,6 +573,90 @@ func (p *Provider) GetPeerStatus(ctx context.Context, req *provider.BGPPeerStatu
return state, nil
}
+func (p *Provider) EnsureStaticRoute(ctx context.Context, req *provider.EnsureStaticRouteRequest) error {
+ af := StaticRouteAddressFamily{}
+
+ ipv4Prefixes := []Prefix{}
+ ipv6Prefixes := []Prefix{}
+
+ for _, route := range req.StaticRoute.Spec.Routes {
+ ip := route.IPPrefix
+ adminDistance := route.AdministrativeDistance
+
+ var nexthopAddress *NexthopAddresses
+ if route.NextHop != "" {
+ nexthopAddress = &NexthopAddresses{
+ NexthopAddress: []NexthopAddress{
+ NewNexthopAddress(route.NextHop, adminDistance),
+ },
+ }
+ }
+
+ var nexthopInterface *NexthopInterfaces
+
+ if ip.Addr().Is4() {
+ ipv4Prefixes = append(ipv4Prefixes, Prefix{
+ PrefixAddress: ip.Addr().String(),
+ PrefixLength: ip.Bits(),
+ NextHopAddress: nexthopAddress,
+ NextHopInterface: nexthopInterface,
+ })
+ } else if ip.Addr().Is6() {
+ ipv6Prefixes = append(ipv6Prefixes, Prefix{
+ PrefixAddress: ip.Addr().String(),
+ PrefixLength: ip.Bits(),
+ // NextHopAddress: &NexthopAddresses{
+ // NexthopAddress: []NexthopAddress{nexthopAddress}},
+ // NextHopInterface: &NexthopInterfaces{
+ // NexthopInterface: []NexthopInterface{nexthopInterface}},
+ })
+ }
+ }
+
+ if len(ipv4Prefixes) > 0 {
+ af.Ipv4 = &IPv4StaticRoute{
+ Unicast: UnicastStaticRoute{
+ Prefixes: Prefixes{
+ Prefix: ipv4Prefixes,
+ },
+ },
+ }
+ }
+
+ if len(ipv6Prefixes) > 0 {
+ af.Ipv6 = &IPv6StaticRoute{
+ Unicast: UnicastStaticRoute{
+ Prefixes: Prefixes{
+ Prefix: ipv6Prefixes,
+ },
+ },
+ }
+ }
+
+ if req.VRF != nil && req.VRF.Spec.Name != "" {
+ vrf := req.VRF.Spec.Name
+ staticRoute := &StaticRouteVRF{
+ VRF: vrf,
+ AddressFamily: af,
+ }
+ return p.client.Update(ctx, staticRoute)
+ }
+ return p.client.Update(ctx, &af)
+}
+
+func (p *Provider) DeleteStaticRoute(ctx context.Context, req *provider.StaticRouteRequest) error {
+ if req.VRF != nil && req.VRF.Spec.Name != "" {
+ staticRoute := &StaticRouteVRF{
+ VRF: req.VRF.Spec.Name,
+ }
+ return p.client.Delete(ctx, staticRoute)
+ }
+
+ // Delete all static routes for the VRF if no specific route is specified.
+ af := StaticRouteAddressFamily{}
+ return p.client.Delete(ctx, &af)
+}
+
func init() {
provider.Register("cisco-iosxr-gnmi", 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..d7849e53a
--- /dev/null
+++ b/internal/provider/cisco/iosxr/static_route.go
@@ -0,0 +1,89 @@
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-License-Identifier: Apache-2.0
+
+package iosxr
+
+type StaticRouteVRF struct {
+ VRF string `json:"vrf-name"`
+ AddressFamily StaticRouteAddressFamily `json:"address-family"`
+}
+
+func (s *StaticRouteVRF) XPath() string {
+ return "Cisco-IOS-XR-um-router-static-cfg:router/static/vrfs/vrf[vrf-name=" + s.VRF + "]"
+}
+
+type StaticRouteAddressFamily struct {
+ Ipv4 *IPv4StaticRoute `json:"ipv4,omitempty"`
+ Ipv6 *IPv6StaticRoute `json:"ipv6,omitempty"`
+}
+
+func (s *StaticRouteAddressFamily) XPath() string {
+ return "Cisco-IOS-XR-um-router-static-cfg:router/static"
+}
+
+type IPv4StaticRoute struct {
+ Unicast UnicastStaticRoute `json:"unicast,omitzero"`
+ Multicast MulticastStaticRoute `json:"multicast,omitzero"`
+}
+
+type IPv6StaticRoute struct {
+ Unicast UnicastStaticRoute `json:"unicast,omitzero"`
+ Multicast MulticastStaticRoute `json:"multicast,omitzero"`
+}
+
+type UnicastStaticRoute struct {
+ Prefixes Prefixes `json:"prefixes"`
+}
+
+type MulticastStaticRoute struct {
+ Prefixes Prefixes `json:"prefixes"`
+}
+
+type Prefixes struct {
+ Prefix []Prefix `json:"prefix"`
+}
+
+type Prefix struct {
+ PrefixAddress string `json:"prefix-address"`
+ PrefixLength int `json:"prefix-length"`
+ NextHopAddress *NexthopAddresses `json:"nexthop-addresses,omitempty"`
+ NextHopInterface *NexthopInterfaces `json:"nexthop-interfaces,omitzero"`
+}
+
+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,omitempty"`
+}
+
+type NexthopInterface struct {
+ 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 string, distance *int32) NexthopInterface {
+ nexthop := NexthopInterface{
+ InterfaceName: name,
+ }
+ if distance != nil && *distance >= 0 {
+ nexthop.Distance = uint32(*distance)
+ }
+ return nexthop
+}
diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go
index 72ac8e3dd..cfc5fc044 100644
--- a/internal/provider/cisco/nxos/provider.go
+++ b/internal/provider/cisco/nxos/provider.go
@@ -65,6 +65,7 @@ var (
_ provider.DHCPRelayProvider = (*Provider)(nil)
_ provider.EthernetSegmentProvider = (*Provider)(nil)
_ provider.AAAProvider = (*Provider)(nil)
+ _ provider.StaticRouteProvider = (*Provider)(nil)
)
type Provider struct {
@@ -3692,6 +3693,16 @@ func (p *Provider) DeleteAAA(ctx context.Context, req *provider.DeleteAAARequest
return p.Update(ctx, &Feature{Name: "tacacsplus", AdminSt: AdminStDisabled})
}
+func (p *Provider) EnsureStaticRoute(ctx context.Context, req *provider.EnsureStaticRouteRequest) error {
+ // TODO(sven-rosenzweig): implement static route configuration
+ return nil
+}
+
+func (p *Provider) DeleteStaticRoute(ctx context.Context, req *provider.StaticRouteRequest) error {
+ // TODO(sven-rosenzweig): implement static route deletion
+ return nil
+}
+
func init() {
provider.Register("cisco-nxos-gnmi", NewProvider)
}
diff --git a/internal/provider/provider.go b/internal/provider/provider.go
index df9a60c74..86335d360 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -759,6 +759,28 @@ type EthernetSegmentStatus 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, *EnsureStaticRouteRequest) error
+ // DeleteStaticRoute call is responsible for StaticRoute deletion on the provider.
+ DeleteStaticRoute(context.Context, *StaticRouteRequest) error
+}
+
+type EnsureStaticRouteRequest struct {
+ StaticRoute *v1alpha1.StaticRoute
+ ProviderConfig *ProviderConfig
+ VRF *v1alpha1.VRF
+}
+
+type StaticRouteRequest struct {
+ StaticRoute *v1alpha1.StaticRoute
+ ProviderConfig *ProviderConfig
+ VRF *v1alpha1.VRF
+}
+
var mu sync.RWMutex
// ProviderFunc returns a new [Provider] instance.