diff --git a/PROJECT b/PROJECT
index fda1ee592..7e2266715 100644
--- a/PROJECT
+++ b/PROJECT
@@ -290,6 +290,13 @@ 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
+ 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 1b2f5e06f..bdbb094c2 100644
--- a/Tiltfile
+++ b/Tiltfile
@@ -188,6 +188,9 @@ k8s_resource(new_name='claim-prefix', objects=['claim-prefix:claim'], resource_d
k8s_yaml('./config/samples/v1alpha1_fabric.yaml')
k8s_resource(new_name='fabric', objects=['fabric:fabric', 'loopback-pool:ipaddresspool', 'underlay-p2p-pool:ipprefixpool'], 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 2bac14340..0999a4261 100644
--- a/api/core/v1alpha1/groupversion_info.go
+++ b/api/core/v1alpha1/groupversion_info.go
@@ -229,6 +229,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..151f3c1a1
--- /dev/null
+++ b/api/core/v1alpha1/staticroute_types.go
@@ -0,0 +1,146 @@
+// SPDX-FileCopyrightText: 2026 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
+ 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=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 a95dbc837..c1362c2fa 100644
--- a/api/core/v1alpha1/zz_generated.deepcopy.go
+++ b/api/core/v1alpha1/zz_generated.deepcopy.go
@@ -3202,6 +3202,26 @@ 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.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
@@ -4239,6 +4259,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/rbac/manager-role.yaml b/charts/network-operator/templates/rbac/manager-role.yaml
index 9e70a2667..041bc543e 100644
--- a/charts/network-operator/templates/rbac/manager-role.yaml
+++ b/charts/network-operator/templates/rbac/manager-role.yaml
@@ -97,6 +97,7 @@ rules:
- prefixsets
- routingpolicies
- snmp
+ - staticroutes
- syslogs
- users
- vlans
@@ -134,6 +135,7 @@ rules:
- prefixsets/finalizers
- routingpolicies/finalizers
- snmp/finalizers
+ - staticroutes/finalizers
- syslogs/finalizers
- users/finalizers
- vlans/finalizers
@@ -166,6 +168,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 530804326..c4b1e4528 100644
--- a/cmd/main.go
+++ b/cmd/main.go
@@ -733,6 +733,20 @@ 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,
+ 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..ee1a5a7ea
--- /dev/null
+++ b/config/crd/bases/networking.metal.ironcore.dev_staticroutes.yaml
@@ -0,0 +1,239 @@
+---
+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:
+ 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.
+ type: string
+ metric:
+ description: Metric assigns a priority to the static route.
+ Lower values indicate higher priority.
+ format: int32
+ type: integer
+ 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 3737c26a0..95058bca4 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/pool.networking.metal.ironcore.dev_indexpools.yaml
- bases/pool.networking.metal.ironcore.dev_ipaddresspools.yaml
diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml
index b9b75c997..63f26b459 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 4290e7f51..c728d40b9 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -91,6 +91,7 @@ rules:
- prefixsets
- routingpolicies
- snmp
+ - staticroutes
- syslogs
- users
- vlans
@@ -128,6 +129,7 @@ rules:
- prefixsets/finalizers
- routingpolicies/finalizers
- snmp/finalizers
+ - staticroutes/finalizers
- syslogs/finalizers
- users/finalizers
- vlans/finalizers
@@ -160,6 +162,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..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 f61dddb3b..8df54d2ad 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 53c923bea..61076e9f0 100644
--- a/docs/api-reference/index.md
+++ b/docs/api-reference/index.md
@@ -348,6 +348,7 @@ Package v1alpha1 contains API Schema definitions for the networking.metal.ironco
- [PrefixSet](#prefixset)
- [RoutingPolicy](#routingpolicy)
- [SNMP](#snmp)
+- [StaticRoute](#staticroute)
- [Syslog](#syslog)
- [User](#user)
- [VLAN](#vlan)
@@ -2181,6 +2182,7 @@ _Appears in:_
- [MulticastGroups](#multicastgroups)
- [PrefixEntry](#prefixentry)
- [RendezvousPoint](#rendezvouspoint)
+- [StaticRouteSpec](#staticroutespec)
@@ -2578,6 +2580,7 @@ _Appears in:_
- [PrefixSetSpec](#prefixsetspec)
- [RoutingPolicySpec](#routingpolicyspec)
- [SNMPSpec](#snmpspec)
+- [StaticRouteSpec](#staticroutespec)
- [SyslogSpec](#syslogspec)
- [SystemSpec](#systemspec)
- [UserSpec](#userspec)
@@ -2931,6 +2934,23 @@ _Appears in:_
| `hostReachability` _string_ | HostReachability indicates the actual method used for host reachability. | | |
+#### NextHop
+
+
+
+
+
+
+
+_Appears in:_
+- [StaticRouteSpec](#staticroutespec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `address` _string_ | Address is the IP address of the next hop for the static route. | | Optional: \{\}
Required: \{\}
|
+| `metric` _integer_ | Metric assigns a priority to the static route. Lower values indicate higher priority. | | Optional: \{\}
|
+
+
#### OSPF
@@ -3858,6 +3878,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
@@ -4024,6 +4106,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..bdeeda94e 100644
--- a/internal/controller/core/interface_controller.go
+++ b/internal/controller/core/interface_controller.go
@@ -1,4 +1,4 @@
-// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors
+// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors
// SPDX-License-Identifier: Apache-2.0
package core
diff --git a/internal/controller/core/staticroute_controller.go b/internal/controller/core/staticroute_controller.go
new file mode 100644
index 000000000..341185afa
--- /dev/null
+++ b/internal/controller/core/staticroute_controller.go
@@ -0,0 +1,508 @@
+// SPDX-FileCopyrightText: 2026 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
+ },
+ 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
+}
+
+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.StaticRouteRequest{
+ 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))
+ }
+
+ 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)
+ }
+
+ 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..a0c57cbca
--- /dev/null
+++ b/internal/controller/core/staticroute_controller_test.go
@@ -0,0 +1,212 @@
+// 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"
+ "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{
+ 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,
+ },
+ 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{
+ 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",
+ },
+ 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 5dbfcedd3..e33baf5ac 100644
--- a/internal/controller/core/suite_test.go
+++ b/internal/controller/core/suite_test.go
@@ -237,6 +237,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(),
@@ -434,6 +444,7 @@ var (
_ provider.DHCPRelayProvider = (*Provider)(nil)
_ provider.EthernetSegmentProvider = (*Provider)(nil)
_ provider.ConfigBackupProvider = (*Provider)(nil)
+ _ provider.StaticRouteProvider = (*Provider)(nil)
)
// Provider is a simple in-memory provider for testing purposes only.
@@ -474,6 +485,7 @@ type Provider struct {
StartupConfig *v1alpha1.ConfigBackup
ConfigBackups []*provider.ConfigBackupFile
StorageTotal int64
+ StaticRoutes *v1alpha1.StaticRoute
}
func NewProvider() *Provider {
@@ -1073,6 +1085,20 @@ func (p *Provider) GetEthernetSegment(name string) (string, bool) {
return esi, ok
}
+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 5184f2b4f..164317aff 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,55 @@ 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 {
+ prefix := Prefix{}
+
+ var nexthopAddress NexthopAddresses
+
+ prefixIP := req.StaticRoute.Spec.Prefix
+ for _, nextHop := range req.StaticRoute.Spec.NextHops {
+ if nextHop != nil {
+ adminDistance := nextHop.Metric
+ nexthopAddress.NexthopAddress = append(nexthopAddress.NexthopAddress,
+ NewNexthopAddress(nextHop.Address, adminDistance))
+ }
+
+ // ToDo: Implement support for next-hop interfaces if needed in the future
+ // Pass Interface Type as part of the request
+ }
+
+ prefix = Prefix{
+ PrefixAddress: prefixIP.Addr().String(),
+ PrefixLength: prefixIP.Bits(),
+ NextHopAddress: &nexthopAddress,
+ IsIpv4: prefixIP.Addr().Is4(),
+ }
+
+ if req.VRF != nil && req.VRF.Spec.Name != "" {
+ prefix.VRFName = req.VRF.Spec.Name
+ }
+
+ return p.client.Update(ctx, &prefix)
+}
+
+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("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..c38b24ac2
--- /dev/null
+++ b/internal/provider/cisco/iosxr/static_route.go
@@ -0,0 +1,65 @@
+// SPDX-FileCopyrightText: 2026 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-interfaces,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,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/provider.go b/internal/provider/provider.go
index 04a96619b..8dca920bf 100644
--- a/internal/provider/provider.go
+++ b/internal/provider/provider.go
@@ -837,6 +837,22 @@ 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, *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
+}
+
var mu sync.RWMutex
// ProviderFunc returns a new [Provider] instance.