diff --git a/PROJECT b/PROJECT index 3f0a37d5..4f18e48e 100644 --- a/PROJECT +++ b/PROJECT @@ -198,4 +198,12 @@ resources: webhooks: validation: true webhookVersion: v1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: networking.metal.ironcore.dev + kind: RoutingPolicy + path: github.com/ironcore-dev/network-operator/api/core/v1alpha1 + version: v1alpha1 version: "3" diff --git a/Tiltfile b/Tiltfile index 3cdf1820..249ce1cd 100644 --- a/Tiltfile +++ b/Tiltfile @@ -108,6 +108,9 @@ k8s_resource(new_name='vxlan-100010', objects=['vxlan-100010:evpninstance'], res k8s_yaml('./config/samples/v1alpha1_prefixset.yaml') k8s_resource(new_name='ccloud-prefixset', objects=['ccloud-prefixset:prefixset'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False) +k8s_yaml('./config/samples/v1alpha1_routingpolicy.yaml') +k8s_resource(new_name='bgp-import-policy', objects=['bgp-import-policy:routingpolicy', 'internal-networks:prefixset', 'partner-networks:prefixset', 'blocked-networks:prefixset'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False) + print('🚀 network-operator development environment') print('👉 Edit the code inside the api/, cmd/, or internal/ directories') print('👉 Tilt will automatically rebuild and redeploy when changes are detected') diff --git a/api/core/v1alpha1/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go index d136848f..7b5ea1ec 100644 --- a/api/core/v1alpha1/groupversion_info.go +++ b/api/core/v1alpha1/groupversion_info.go @@ -134,3 +134,9 @@ const ( // VRFNotFoundReason indicates that a referenced VRF was not found. VRFNotFoundReason = "VRFNotFound" ) + +// Reasons that are specific to [RoutingPolicy] objects. +const ( + // PrefixSetNotFoundReason indicates that a referenced PrefixSet was not found. + PrefixSetNotFoundReason = "PrefixSetNotFound" +) diff --git a/api/core/v1alpha1/routingpolicy_types.go b/api/core/v1alpha1/routingpolicy_types.go new file mode 100644 index 00000000..5cca5756 --- /dev/null +++ b/api/core/v1alpha1/routingpolicy_types.go @@ -0,0 +1,186 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// RoutingPolicySpec defines the desired state of RoutingPolicy +type RoutingPolicySpec 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 Banner to its provider-specific configuration. + // +optional + ProviderConfigRef *TypedLocalObjectReference `json:"providerConfigRef,omitempty"` + + // Name is the identifier of the RoutingPolicy on the device. + // Immutable. + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=63 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Name is immutable" + Name string `json:"name"` + + // A list of policy statements to apply. + // +required + // +listType=map + // +listMapKey=sequence + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Statements []PolicyStatement `json:"statements"` +} + +type PolicyStatement struct { + // The sequence number of the policy statement. + // +required + // +kubebuilder:validation:Minimum=1 + Sequence int32 `json:"sequence"` + + // Conditions define the match criteria for this statement. + // If no conditions are specified, the statement matches all routes. + // +optional + Conditions *PolicyConditions `json:"conditions,omitempty"` + + // Actions define what to do when conditions match. + // +required + Actions PolicyActions `json:"actions"` +} + +// PolicyConditions defines the match criteria for a policy statement. +type PolicyConditions struct { + // MatchPrefixSet matches routes against a PrefixSet resource. + // +optional + MatchPrefixSet *PrefixSetMatchCondition `json:"matchPrefixSet,omitempty"` +} + +// PrefixSetMatchCondition defines the condition for matching against a PrefixSet. +type PrefixSetMatchCondition struct { + // PrefixSetRef references a PrefixSet in the same namespace. + // The PrefixSet must exist and belong to the same device. + // +required + PrefixSetRef LocalObjectReference `json:"prefixSetRef"` +} + +// PolicyActions defines the actions to take when a policy statement matches. +// +kubebuilder:validation:XValidation:rule="self.routeDisposition == 'AcceptRoute' || !has(self.bgpActions)",message="bgpActions cannot be specified when routeDisposition is RejectRoute" +type PolicyActions struct { + // RouteDisposition specifies whether to accept or reject the route. + // +required + RouteDisposition RouteDisposition `json:"routeDisposition"` + + // BgpActions specifies BGP-specific actions to apply when the route is accepted. + // Only applicable when RouteDisposition is AcceptRoute. + // +optional + BgpActions *BgpActions `json:"bgpActions,omitempty"` +} + +// RouteDisposition defines the final disposition of a route. +// +kubebuilder:validation:Enum=AcceptRoute;RejectRoute +type RouteDisposition string + +const ( + // AcceptRoute permits the route and applies any configured actions. + AcceptRoute RouteDisposition = "AcceptRoute" + // RejectRoute denies the route immediately. + RejectRoute RouteDisposition = "RejectRoute" +) + +// BgpActions defines BGP-specific actions for a policy statement. +// +kubebuilder:validation:XValidation:rule="has(self.setCommunity) || has(self.setExtCommunity)",message="at least one BGP action must be specified" +type BgpActions struct { + // SetCommunity configures BGP standard community attributes. + // +optional + SetCommunity *SetCommunityAction `json:"setCommunity,omitempty"` + + // SetExtCommunity configures BGP extended community attributes. + // +optional + SetExtCommunity *SetExtCommunityAction `json:"setExtCommunity,omitempty"` +} + +// SetCommunityAction defines the action to set BGP standard communities. +type SetCommunityAction struct { + // Communities is the list of BGP standard communities to set. + // The communities must be in the format defined by [RFC 1997]. + // [RFC 1997]: https://datatracker.ietf.org/doc/html/rfc1997 + // +required + // +kubebuilder:validation:MinItems=1 + Communities []string `json:"communities"` +} + +// SetExtCommunityAction defines the action to set BGP extended communities. +type SetExtCommunityAction struct { + // Communities is the list of BGP extended communities to set. + // The communities must be in the format defined by [RFC 4360]. + // [RFC 4360]: https://datatracker.ietf.org/doc/html/rfc4360 + // +required + // +kubebuilder:validation:MinItems=1 + Communities []string `json:"communities"` +} + +// RoutingPolicyStatus defines the observed state of RoutingPolicy. +type RoutingPolicyStatus struct { + // The conditions are a list of status objects that describe the state of the RoutingPolicy. + //+listType=map + //+listMapKey=type + //+patchStrategy=merge + //+patchMergeKey=type + //+optional + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:path=routingpolicies +// +kubebuilder:resource:singular=routingpolicy +// +kubebuilder:resource:shortName=routemap +// +kubebuilder:printcolumn:name="Routing Policy",type=string,JSONPath=`.spec.name` +// +kubebuilder:printcolumn:name="Device",type=string,JSONPath=`.spec.deviceRef.name` +// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" + +// RoutingPolicy is the Schema for the routingpolicies API +type RoutingPolicy struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + // Specification of the desired state of the resource. + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + // +required + Spec RoutingPolicySpec `json:"spec,omitempty"` + + // Status of the resource. This is set and updated automatically. + // Read-only. + // More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + // +optional + Status RoutingPolicyStatus `json:"status,omitempty,omitzero"` +} + +// GetConditions implements conditions.Getter. +func (p *RoutingPolicy) GetConditions() []metav1.Condition { + return p.Status.Conditions +} + +// SetConditions implements conditions.Setter. +func (p *RoutingPolicy) SetConditions(conditions []metav1.Condition) { + p.Status.Conditions = conditions +} + +// +kubebuilder:object:root=true + +// RoutingPolicyList contains a list of RoutingPolicy +type RoutingPolicyList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []RoutingPolicy `json:"items"` +} + +func init() { + SchemeBuilder.Register(&RoutingPolicy{}, &RoutingPolicyList{}) +} diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go index 8e732e69..e6806f5c 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -714,6 +714,31 @@ func (in *BannerStatus) DeepCopy() *BannerStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BgpActions) DeepCopyInto(out *BgpActions) { + *out = *in + if in.SetCommunity != nil { + in, out := &in.SetCommunity, &out.SetCommunity + *out = new(SetCommunityAction) + (*in).DeepCopyInto(*out) + } + if in.SetExtCommunity != nil { + in, out := &in.SetExtCommunity, &out.SetExtCommunity + *out = new(SetExtCommunityAction) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BgpActions. +func (in *BgpActions) DeepCopy() *BgpActions { + if in == nil { + return nil + } + out := new(BgpActions) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Bootstrap) DeepCopyInto(out *Bootstrap) { *out = *in @@ -2268,6 +2293,67 @@ func (in *PasswordSource) DeepCopy() *PasswordSource { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PolicyActions) DeepCopyInto(out *PolicyActions) { + *out = *in + if in.BgpActions != nil { + in, out := &in.BgpActions, &out.BgpActions + *out = new(BgpActions) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PolicyActions. +func (in *PolicyActions) DeepCopy() *PolicyActions { + if in == nil { + return nil + } + out := new(PolicyActions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PolicyConditions) DeepCopyInto(out *PolicyConditions) { + *out = *in + if in.MatchPrefixSet != nil { + in, out := &in.MatchPrefixSet, &out.MatchPrefixSet + *out = new(PrefixSetMatchCondition) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PolicyConditions. +func (in *PolicyConditions) DeepCopy() *PolicyConditions { + if in == nil { + return nil + } + out := new(PolicyConditions) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PolicyStatement) DeepCopyInto(out *PolicyStatement) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = new(PolicyConditions) + (*in).DeepCopyInto(*out) + } + in.Actions.DeepCopyInto(&out.Actions) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PolicyStatement. +func (in *PolicyStatement) DeepCopy() *PolicyStatement { + if in == nil { + return nil + } + out := new(PolicyStatement) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PrefixEntry) DeepCopyInto(out *PrefixEntry) { *out = *in @@ -2348,6 +2434,22 @@ func (in *PrefixSetList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PrefixSetMatchCondition) DeepCopyInto(out *PrefixSetMatchCondition) { + *out = *in + out.PrefixSetRef = in.PrefixSetRef +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PrefixSetMatchCondition. +func (in *PrefixSetMatchCondition) DeepCopy() *PrefixSetMatchCondition { + if in == nil { + return nil + } + out := new(PrefixSetMatchCondition) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PrefixSetSpec) DeepCopyInto(out *PrefixSetSpec) { *out = *in @@ -2445,6 +2547,115 @@ func (in *RouteTarget) DeepCopy() *RouteTarget { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RoutingPolicy) DeepCopyInto(out *RoutingPolicy) { + *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 RoutingPolicy. +func (in *RoutingPolicy) DeepCopy() *RoutingPolicy { + if in == nil { + return nil + } + out := new(RoutingPolicy) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RoutingPolicy) 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 *RoutingPolicyList) DeepCopyInto(out *RoutingPolicyList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]RoutingPolicy, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RoutingPolicyList. +func (in *RoutingPolicyList) DeepCopy() *RoutingPolicyList { + if in == nil { + return nil + } + out := new(RoutingPolicyList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *RoutingPolicyList) 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 *RoutingPolicySpec) DeepCopyInto(out *RoutingPolicySpec) { + *out = *in + out.DeviceRef = in.DeviceRef + if in.ProviderConfigRef != nil { + in, out := &in.ProviderConfigRef, &out.ProviderConfigRef + *out = new(TypedLocalObjectReference) + **out = **in + } + if in.Statements != nil { + in, out := &in.Statements, &out.Statements + *out = make([]PolicyStatement, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RoutingPolicySpec. +func (in *RoutingPolicySpec) DeepCopy() *RoutingPolicySpec { + if in == nil { + return nil + } + out := new(RoutingPolicySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RoutingPolicyStatus) DeepCopyInto(out *RoutingPolicyStatus) { + *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 RoutingPolicyStatus. +func (in *RoutingPolicyStatus) DeepCopy() *RoutingPolicyStatus { + if in == nil { + return nil + } + out := new(RoutingPolicyStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *SNMP) DeepCopyInto(out *SNMP) { *out = *in @@ -2655,6 +2866,46 @@ func (in *SecretReference) DeepCopy() *SecretReference { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SetCommunityAction) DeepCopyInto(out *SetCommunityAction) { + *out = *in + if in.Communities != nil { + in, out := &in.Communities, &out.Communities + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SetCommunityAction. +func (in *SetCommunityAction) DeepCopy() *SetCommunityAction { + if in == nil { + return nil + } + out := new(SetCommunityAction) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SetExtCommunityAction) DeepCopyInto(out *SetExtCommunityAction) { + *out = *in + if in.Communities != nil { + in, out := &in.Communities, &out.Communities + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SetExtCommunityAction. +func (in *SetExtCommunityAction) DeepCopy() *SetExtCommunityAction { + if in == nil { + return nil + } + out := new(SetExtCommunityAction) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Switchport) DeepCopyInto(out *Switchport) { *out = *in diff --git a/charts/network-operator/templates/crd/networking.metal.ironcore.dev_routingpolicies.yaml b/charts/network-operator/templates/crd/networking.metal.ironcore.dev_routingpolicies.yaml new file mode 100644 index 00000000..2c0f9256 --- /dev/null +++ b/charts/network-operator/templates/crd/networking.metal.ironcore.dev_routingpolicies.yaml @@ -0,0 +1,314 @@ +{{- if .Values.crd.enable }} +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + labels: + {{- include "chart.labels" . | nindent 4 }} + annotations: + {{- if .Values.crd.keep }} + "helm.sh/resource-policy": keep + {{- end }} + controller-gen.kubebuilder.io/version: v0.19.0 + name: routingpolicies.networking.metal.ironcore.dev +spec: + group: networking.metal.ironcore.dev + names: + kind: RoutingPolicy + listKind: RoutingPolicyList + plural: routingpolicies + shortNames: + - routemap + singular: routingpolicy + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Routing Policy + type: string + - jsonPath: .spec.deviceRef.name + name: Device + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: RoutingPolicy is the Schema for the routingpolicies API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + Specification of the desired state of the resource. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + properties: + deviceRef: + description: |- + DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace. + Immutable. + properties: + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: DeviceRef is immutable + rule: self == oldSelf + name: + description: |- + Name is the identifier of the RoutingPolicy on the device. + Immutable. + maxLength: 63 + 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 Banner 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 + statements: + description: A list of policy statements to apply. + items: + properties: + actions: + description: Actions define what to do when conditions match. + properties: + bgpActions: + description: |- + BgpActions specifies BGP-specific actions to apply when the route is accepted. + Only applicable when RouteDisposition is AcceptRoute. + properties: + setCommunity: + description: SetCommunity configures BGP standard community + attributes. + properties: + communities: + description: |- + Communities is the list of BGP standard communities to set. + The communities must be in the format defined by [RFC 1997]. + [RFC 1997]: https://datatracker.ietf.org/doc/html/rfc1997 + items: + type: string + minItems: 1 + type: array + required: + - communities + type: object + setExtCommunity: + description: SetExtCommunity configures BGP extended + community attributes. + properties: + communities: + description: |- + Communities is the list of BGP extended communities to set. + The communities must be in the format defined by [RFC 4360]. + [RFC 4360]: https://datatracker.ietf.org/doc/html/rfc4360 + items: + type: string + minItems: 1 + type: array + required: + - communities + type: object + type: object + x-kubernetes-validations: + - message: at least one BGP action must be specified + rule: has(self.setCommunity) || has(self.setExtCommunity) + routeDisposition: + description: RouteDisposition specifies whether to accept + or reject the route. + enum: + - AcceptRoute + - RejectRoute + type: string + required: + - routeDisposition + type: object + x-kubernetes-validations: + - message: bgpActions cannot be specified when routeDisposition + is RejectRoute + rule: self.routeDisposition == 'AcceptRoute' || !has(self.bgpActions) + conditions: + description: |- + Conditions define the match criteria for this statement. + If no conditions are specified, the statement matches all routes. + properties: + matchPrefixSet: + description: MatchPrefixSet matches routes against a PrefixSet + resource. + properties: + prefixSetRef: + description: |- + PrefixSetRef references a PrefixSet in the same namespace. + The PrefixSet must exist and belong to the same device. + 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: + - prefixSetRef + type: object + type: object + sequence: + description: The sequence number of the policy statement. + format: int32 + minimum: 1 + type: integer + required: + - actions + - sequence + type: object + maxItems: 100 + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - sequence + x-kubernetes-list-type: map + required: + - deviceRef + - name + - statements + type: object + status: + description: |- + Status of the resource. This is set and updated automatically. + Read-only. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + properties: + conditions: + description: The conditions are a list of status objects that describe + the state of the RoutingPolicy. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +{{- end -}} diff --git a/charts/network-operator/templates/rbac/role.yaml b/charts/network-operator/templates/rbac/role.yaml index 825ab664..72e6b1f1 100644 --- a/charts/network-operator/templates/rbac/role.yaml +++ b/charts/network-operator/templates/rbac/role.yaml @@ -49,6 +49,7 @@ rules: - ospf - pim - prefixsets + - routingpolicies - snmp - syslogs - users @@ -80,6 +81,7 @@ rules: - ospf/finalizers - pim/finalizers - prefixsets/finalizers + - routingpolicies/finalizers - snmp/finalizers - syslogs/finalizers - users/finalizers @@ -105,6 +107,7 @@ rules: - ospf/status - pim/status - prefixsets/status + - routingpolicies/status - snmp/status - syslogs/status - users/status diff --git a/charts/network-operator/templates/rbac/routingpolicy_admin_role.yaml b/charts/network-operator/templates/rbac/routingpolicy_admin_role.yaml new file mode 100644 index 00000000..2f01e6b2 --- /dev/null +++ b/charts/network-operator/templates/rbac/routingpolicy_admin_role.yaml @@ -0,0 +1,28 @@ +{{- if .Values.rbac.enable }} +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over networking.metal.ironcore.dev. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + {{- include "chart.labels" . | nindent 4 }} + name: routingpolicy-admin-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - routingpolicies + verbs: + - '*' +- apiGroups: + - networking.metal.ironcore.dev + resources: + - routingpolicies/status + verbs: + - get +{{- end -}} diff --git a/charts/network-operator/templates/rbac/routingpolicy_editor_role.yaml b/charts/network-operator/templates/rbac/routingpolicy_editor_role.yaml new file mode 100644 index 00000000..54706d03 --- /dev/null +++ b/charts/network-operator/templates/rbac/routingpolicy_editor_role.yaml @@ -0,0 +1,34 @@ +{{- if .Values.rbac.enable }} +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the networking.metal.ironcore.dev. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + {{- include "chart.labels" . | nindent 4 }} + name: routingpolicy-editor-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - routingpolicies + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - routingpolicies/status + verbs: + - get +{{- end -}} diff --git a/charts/network-operator/templates/rbac/routingpolicy_viewer_role.yaml b/charts/network-operator/templates/rbac/routingpolicy_viewer_role.yaml new file mode 100644 index 00000000..a3918d11 --- /dev/null +++ b/charts/network-operator/templates/rbac/routingpolicy_viewer_role.yaml @@ -0,0 +1,30 @@ +{{- if .Values.rbac.enable }} +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to networking.metal.ironcore.dev resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + {{- include "chart.labels" . | nindent 4 }} + name: routingpolicy-viewer-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - routingpolicies + verbs: + - get + - list + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - routingpolicies/status + verbs: + - get +{{- end -}} diff --git a/cmd/main.go b/cmd/main.go index 141a83a9..089a2dcf 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -480,6 +480,16 @@ func main() { os.Exit(1) } } + if err := (&corecontroller.RoutingPolicyReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("routingpolicy-controller"), + WatchFilterValue: watchFilterValue, + Provider: prov, + }).SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "RoutingPolicy") + os.Exit(1) + } // +kubebuilder:scaffold:builder if metricsCertWatcher != nil { diff --git a/config/crd/bases/networking.metal.ironcore.dev_routingpolicies.yaml b/config/crd/bases/networking.metal.ironcore.dev_routingpolicies.yaml new file mode 100644 index 00000000..7d1e27c5 --- /dev/null +++ b/config/crd/bases/networking.metal.ironcore.dev_routingpolicies.yaml @@ -0,0 +1,307 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: routingpolicies.networking.metal.ironcore.dev +spec: + group: networking.metal.ironcore.dev + names: + kind: RoutingPolicy + listKind: RoutingPolicyList + plural: routingpolicies + shortNames: + - routemap + singular: routingpolicy + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.name + name: Routing Policy + type: string + - jsonPath: .spec.deviceRef.name + name: Device + type: string + - jsonPath: .status.conditions[?(@.type=="Ready")].status + name: Ready + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: RoutingPolicy is the Schema for the routingpolicies API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + Specification of the desired state of the resource. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + properties: + deviceRef: + description: |- + DeviceName is the name of the Device this object belongs to. The Device object must exist in the same namespace. + Immutable. + properties: + name: + description: |- + Name of the referent. + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + maxLength: 63 + minLength: 1 + type: string + required: + - name + type: object + x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: DeviceRef is immutable + rule: self == oldSelf + name: + description: |- + Name is the identifier of the RoutingPolicy on the device. + Immutable. + maxLength: 63 + 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 Banner 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 + statements: + description: A list of policy statements to apply. + items: + properties: + actions: + description: Actions define what to do when conditions match. + properties: + bgpActions: + description: |- + BgpActions specifies BGP-specific actions to apply when the route is accepted. + Only applicable when RouteDisposition is AcceptRoute. + properties: + setCommunity: + description: SetCommunity configures BGP standard community + attributes. + properties: + communities: + description: |- + Communities is the list of BGP standard communities to set. + The communities must be in the format defined by [RFC 1997]. + [RFC 1997]: https://datatracker.ietf.org/doc/html/rfc1997 + items: + type: string + minItems: 1 + type: array + required: + - communities + type: object + setExtCommunity: + description: SetExtCommunity configures BGP extended + community attributes. + properties: + communities: + description: |- + Communities is the list of BGP extended communities to set. + The communities must be in the format defined by [RFC 4360]. + [RFC 4360]: https://datatracker.ietf.org/doc/html/rfc4360 + items: + type: string + minItems: 1 + type: array + required: + - communities + type: object + type: object + x-kubernetes-validations: + - message: at least one BGP action must be specified + rule: has(self.setCommunity) || has(self.setExtCommunity) + routeDisposition: + description: RouteDisposition specifies whether to accept + or reject the route. + enum: + - AcceptRoute + - RejectRoute + type: string + required: + - routeDisposition + type: object + x-kubernetes-validations: + - message: bgpActions cannot be specified when routeDisposition + is RejectRoute + rule: self.routeDisposition == 'AcceptRoute' || !has(self.bgpActions) + conditions: + description: |- + Conditions define the match criteria for this statement. + If no conditions are specified, the statement matches all routes. + properties: + matchPrefixSet: + description: MatchPrefixSet matches routes against a PrefixSet + resource. + properties: + prefixSetRef: + description: |- + PrefixSetRef references a PrefixSet in the same namespace. + The PrefixSet must exist and belong to the same device. + 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: + - prefixSetRef + type: object + type: object + sequence: + description: The sequence number of the policy statement. + format: int32 + minimum: 1 + type: integer + required: + - actions + - sequence + type: object + maxItems: 100 + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - sequence + x-kubernetes-list-type: map + required: + - deviceRef + - name + - statements + type: object + status: + description: |- + Status of the resource. This is set and updated automatically. + Read-only. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status + properties: + conditions: + description: The conditions are a list of status objects that describe + the state of the RoutingPolicy. + 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 07a3f7b8..6eee553a 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -24,6 +24,7 @@ resources: - bases/networking.metal.ironcore.dev_vlans.yaml - bases/networking.metal.ironcore.dev_evpninstances.yaml - bases/networking.metal.ironcore.dev_prefixsets.yaml +- bases/networking.metal.ironcore.dev_routingpolicies.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index 113aacc1..6fc661ba 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -88,3 +88,6 @@ resources: - prefixset_admin_role.yaml - prefixset_editor_role.yaml - prefixset_viewer_role.yaml +- routingpolicy_admin_role.yaml +- routingpolicy_editor_role.yaml +- routingpolicy_viewer_role.yaml diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 94d6b14b..3ff55ef7 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -46,6 +46,7 @@ rules: - ospf - pim - prefixsets + - routingpolicies - snmp - syslogs - users @@ -77,6 +78,7 @@ rules: - ospf/finalizers - pim/finalizers - prefixsets/finalizers + - routingpolicies/finalizers - snmp/finalizers - syslogs/finalizers - users/finalizers @@ -102,6 +104,7 @@ rules: - ospf/status - pim/status - prefixsets/status + - routingpolicies/status - snmp/status - syslogs/status - users/status diff --git a/config/rbac/routingpolicy_admin_role.yaml b/config/rbac/routingpolicy_admin_role.yaml new file mode 100644 index 00000000..228e148d --- /dev/null +++ b/config/rbac/routingpolicy_admin_role.yaml @@ -0,0 +1,27 @@ +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over networking.metal.ironcore.dev. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: routingpolicy-admin-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - routingpolicies + verbs: + - '*' +- apiGroups: + - networking.metal.ironcore.dev + resources: + - routingpolicies/status + verbs: + - get diff --git a/config/rbac/routingpolicy_editor_role.yaml b/config/rbac/routingpolicy_editor_role.yaml new file mode 100644 index 00000000..9885eefa --- /dev/null +++ b/config/rbac/routingpolicy_editor_role.yaml @@ -0,0 +1,33 @@ +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the networking.metal.ironcore.dev. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: routingpolicy-editor-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - routingpolicies + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - routingpolicies/status + verbs: + - get diff --git a/config/rbac/routingpolicy_viewer_role.yaml b/config/rbac/routingpolicy_viewer_role.yaml new file mode 100644 index 00000000..cde74743 --- /dev/null +++ b/config/rbac/routingpolicy_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project network-operator itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to networking.metal.ironcore.dev resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: routingpolicy-viewer-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - routingpolicies + verbs: + - get + - list + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - routingpolicies/status + verbs: + - get diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index fef95924..23a31c8d 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -22,4 +22,5 @@ resources: - cisco/nx/v1alpha1_managementaccessconfig.yaml - v1alpha1_evi.yaml - v1alpha1_prefixset.yaml +- v1alpha1_routingpolicy.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/v1alpha1_routingpolicy.yaml b/config/samples/v1alpha1_routingpolicy.yaml new file mode 100644 index 00000000..567891c4 --- /dev/null +++ b/config/samples/v1alpha1_routingpolicy.yaml @@ -0,0 +1,103 @@ +--- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: RoutingPolicy +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: bgp-import-policy +spec: + deviceRef: + name: leaf1 + name: BGP-IMPORT + statements: + - sequence: 10 + conditions: + matchPrefixSet: + prefixSetRef: + name: internal-networks + actions: + routeDisposition: AcceptRoute + bgpActions: + setCommunity: + communities: + - "65137:100" + - "65137:200" + setExtCommunity: + communities: + - "65137:100" + - sequence: 20 + conditions: + matchPrefixSet: + prefixSetRef: + name: partner-networks + actions: + routeDisposition: AcceptRoute + bgpActions: + setCommunity: + communities: + - "65137:300" + - sequence: 30 + conditions: + matchPrefixSet: + prefixSetRef: + name: blocked-networks + actions: + routeDisposition: RejectRoute + - sequence: 100 + actions: + routeDisposition: AcceptRoute +--- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: PrefixSet +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: internal-networks +spec: + deviceRef: + name: leaf1 + name: INTERNAL-NETWORKS + entries: + - sequence: 10 + prefix: 10.0.0.0/8 + - sequence: 20 + prefix: 172.16.0.0/12 + - sequence: 30 + prefix: 192.168.0.0/16 +--- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: PrefixSet +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: partner-networks +spec: + deviceRef: + name: leaf1 + name: PARTNER-NETWORKS + entries: + - sequence: 10 + prefix: 198.51.100.0/24 + - sequence: 20 + prefix: 203.0.113.0/24 +--- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: PrefixSet +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: blocked-networks +spec: + deviceRef: + name: leaf1 + name: BLOCKED-NETWORKS + entries: + - sequence: 10 + prefix: 0.0.0.0/0 + maskLengthRange: + min: 25 + max: 32 diff --git a/internal/controller/core/routingpolicy_controller.go b/internal/controller/core/routingpolicy_controller.go new file mode 100644 index 00000000..02d335f0 --- /dev/null +++ b/internal/controller/core/routingpolicy_controller.go @@ -0,0 +1,384 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/api/equality" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + kerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/client-go/tools/record" + "k8s.io/klog/v2" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/builder" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/event" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/predicate" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/conditions" + "github.com/ironcore-dev/network-operator/internal/deviceutil" + "github.com/ironcore-dev/network-operator/internal/provider" +) + +// RoutingPolicyReconciler reconciles a RoutingPolicy object +type RoutingPolicyReconciler struct { + client.Client + Scheme *runtime.Scheme + + // WatchFilterValue is the label value used to filter events prior to reconciliation. + WatchFilterValue string + + // Recorder is used to record events for the controller. + // More info: https://book.kubebuilder.io/reference/raising-events + Recorder record.EventRecorder + + // Provider is the driver that will be used to create & delete the routingpolicy. + Provider provider.ProviderFunc +} + +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=routingpolicies,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=routingpolicies/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=routingpolicies/finalizers,verbs=update +// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.20.2/pkg/reconcile +// +// For more details about the method shape, read up here: +// - https://ahmet.im/blog/controller-pitfalls/#reconcile-method-shape +func (r *RoutingPolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, reterr error) { + log := ctrl.LoggerFrom(ctx) + log.Info("Reconciling resource") + + obj := new(v1alpha1.RoutingPolicy) + if err := r.Get(ctx, req.NamespacedName, obj); err != nil { + if apierrors.IsNotFound(err) { + // If the custom resource is not found then it usually means that it was deleted or not created + // In this way, we will stop the reconciliation + log.Info("Resource not found. Ignoring since object must be deleted") + return ctrl.Result{}, nil + } + // Error reading the object - requeue the request. + log.Error(err, "Failed to get resource") + return ctrl.Result{}, err + } + + prov, ok := r.Provider().(provider.RoutingPolicyProvider) + 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.RoutingPolicyProvider", + }) { + return ctrl.Result{}, r.Status().Update(ctx, obj) + } + return ctrl.Result{}, nil + } + + device, err := deviceutil.GetDeviceByName(ctx, r, obj.Namespace, obj.Spec.DeviceRef.Name) + if err != nil { + return ctrl.Result{}, err + } + + conn, err := deviceutil.GetDeviceConnection(ctx, r, device) + if err != nil { + return ctrl.Result{}, err + } + + var cfg *provider.ProviderConfig + if obj.Spec.ProviderConfigRef != nil { + cfg, err = provider.GetProviderConfig(ctx, r, obj.Namespace, obj.Spec.ProviderConfigRef) + if err != nil { + return ctrl.Result{}, err + } + } + + s := &routingPolicyScope{ + Device: device, + RoutingPolicy: obj, + Connection: conn, + ProviderConfig: cfg, + Provider: prov, + } + + if !obj.DeletionTimestamp.IsZero() { + if controllerutil.ContainsFinalizer(obj, v1alpha1.FinalizerName) { + if err := r.finalize(ctx, s); err != nil { + log.Error(err, "Failed to finalize resource") + return ctrl.Result{}, err + } + controllerutil.RemoveFinalizer(obj, v1alpha1.FinalizerName) + if err := r.Update(ctx, obj); err != nil { + log.Error(err, "Failed to remove finalizer from resource") + return ctrl.Result{}, err + } + } + log.Info("Resource is being deleted, skipping reconciliation") + return ctrl.Result{}, nil + } + + // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/finalizers + if !controllerutil.ContainsFinalizer(obj, v1alpha1.FinalizerName) { + controllerutil.AddFinalizer(obj, v1alpha1.FinalizerName) + if err := r.Update(ctx, obj); err != nil { + log.Error(err, "Failed to add finalizer to resource") + return ctrl.Result{}, err + } + log.Info("Added finalizer to resource") + return ctrl.Result{}, nil + } + + orig := obj.DeepCopy() + if conditions.InitializeConditions(obj, v1alpha1.ReadyCondition) { + log.Info("Initializing status conditions") + return ctrl.Result{}, r.Status().Update(ctx, obj) + } + + // Always attempt to update the metadata/status after reconciliation + defer func() { + if !equality.Semantic.DeepEqual(orig.ObjectMeta, obj.ObjectMeta) { + if err := r.Patch(ctx, obj, client.MergeFrom(orig)); err != nil { + log.Error(err, "Failed to update resource metadata") + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + return + } + + if !equality.Semantic.DeepEqual(orig.Status, obj.Status) { + if err := r.Status().Patch(ctx, obj, client.MergeFrom(orig)); err != nil { + log.Error(err, "Failed to update status") + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + } + }() + + if err := r.reconcile(ctx, s); err != nil { + log.Error(err, "Failed to reconcile resource") + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +var routingPolicyPrefixSetRefKey = ".spec.statements[].conditions.matchPrefixSet.prefixSetRef.name" + +// SetupWithManager sets up the controller with the Manager. +func (r *RoutingPolicyReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager) error { + labelSelector := metav1.LabelSelector{} + if r.WatchFilterValue != "" { + labelSelector.MatchLabels = map[string]string{v1alpha1.WatchLabel: r.WatchFilterValue} + } + + filter, err := predicate.LabelSelectorPredicate(labelSelector) + if err != nil { + return fmt.Errorf("failed to create label selector predicate: %w", err) + } + + if err := mgr.GetFieldIndexer().IndexField(ctx, &v1alpha1.RoutingPolicy{}, routingPolicyPrefixSetRefKey, func(obj client.Object) []string { + rp := obj.(*v1alpha1.RoutingPolicy) + var names []string + for _, stmt := range rp.Spec.Statements { + if stmt.Conditions != nil && stmt.Conditions.MatchPrefixSet != nil { + names = append(names, stmt.Conditions.MatchPrefixSet.PrefixSetRef.Name) + } + } + return names + }); err != nil { + return err + } + + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.RoutingPolicy{}). + Named("routingpolicy"). + WithEventFilter(filter). + // Watches enqueues RoutingPolicies for updates in referenced PrefixSet resources. + // Only triggers on create and delete events since PrefixSet names are immutable. + Watches( + &v1alpha1.PrefixSet{}, + handler.EnqueueRequestsFromMapFunc(r.prefixSetToRoutingPolicy), + builder.WithPredicates(predicate.Funcs{ + UpdateFunc: func(e event.UpdateEvent) bool { + return false + }, + GenericFunc: func(e event.GenericEvent) bool { + return false + }, + }), + ). + Complete(r) +} + +// scope holds the different objects that are read and used during the reconcile. +type routingPolicyScope struct { + Device *v1alpha1.Device + RoutingPolicy *v1alpha1.RoutingPolicy + Connection *deviceutil.Connection + ProviderConfig *provider.ProviderConfig + Provider provider.RoutingPolicyProvider +} + +func (r *RoutingPolicyReconciler) reconcile(ctx context.Context, s *routingPolicyScope) (reterr error) { + if s.RoutingPolicy.Labels == nil { + s.RoutingPolicy.Labels = make(map[string]string) + } + + s.RoutingPolicy.Labels[v1alpha1.DeviceLabel] = s.Device.Name + + // Ensure the RoutingPolicy is owned by the Device. + if !controllerutil.HasControllerReference(s.RoutingPolicy) { + if err := controllerutil.SetOwnerReference(s.Device, s.RoutingPolicy, r.Scheme, controllerutil.WithBlockOwnerDeletion(true)); err != nil { + return err + } + } + + statements, err := r.reconcileStatements(ctx, s) + if err != nil { + return err + } + + if err := s.Provider.Connect(ctx, s.Connection); err != nil { + return fmt.Errorf("failed to connect to provider: %w", err) + } + defer func() { + if err := s.Provider.Disconnect(ctx, s.Connection); err != nil { + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + }() + + // Ensure the RoutingPolicy is realized on the provider. + err = s.Provider.EnsureRoutingPolicy(ctx, &provider.EnsureRoutingPolicyRequest{ + Name: s.RoutingPolicy.Spec.Name, + Statements: statements, + ProviderConfig: s.ProviderConfig, + }) + + cond := conditions.FromError(err) + // As this resource is configuration only, we use the Configured condition as top-level Ready condition. + cond.Type = v1alpha1.ReadyCondition + conditions.Set(s.RoutingPolicy, cond) + + return err +} + +func (r *RoutingPolicyReconciler) reconcileStatements(ctx context.Context, s *routingPolicyScope) ([]provider.PolicyStatement, error) { + statements := make([]provider.PolicyStatement, 0, len(s.RoutingPolicy.Spec.Statements)) + + for _, stmt := range s.RoutingPolicy.Spec.Statements { + var cond []provider.PolicyCondition + if stmt.Conditions != nil && stmt.Conditions.MatchPrefixSet != nil { + prefixSet, err := r.reconcilePrefixSet(ctx, s, stmt.Conditions.MatchPrefixSet) + if err != nil { + return nil, err + } + cond = append(cond, provider.MatchPrefixSetCondition{ + PrefixSet: prefixSet, + }) + } + + statements = append(statements, provider.PolicyStatement{ + Sequence: stmt.Sequence, + Conditions: cond, + Actions: stmt.Actions, + }) + } + + return statements, nil +} + +// reconcilePrefixSet ensures that the referenced PrefixSet exists and belongs to the same device as the RoutingPolicy. +func (r *RoutingPolicyReconciler) reconcilePrefixSet(ctx context.Context, s *routingPolicyScope, c *v1alpha1.PrefixSetMatchCondition) (*v1alpha1.PrefixSet, error) { + key := client.ObjectKey{ + Name: c.PrefixSetRef.Name, + Namespace: s.RoutingPolicy.Namespace, + } + + prefixSet := new(v1alpha1.PrefixSet) + if err := r.Get(ctx, key, prefixSet); err != nil { + if apierrors.IsNotFound(err) { + conditions.Set(s.RoutingPolicy, metav1.Condition{ + Type: v1alpha1.ReadyCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.PrefixSetNotFoundReason, + Message: fmt.Sprintf("referenced PrefixSet %q not found", key), + }) + return nil, reconcile.TerminalError(fmt.Errorf("referenced PrefixSet %q not found", key)) + } + return nil, fmt.Errorf("failed to get referenced PrefixSet %q: %w", key, err) + } + + if prefixSet.Spec.DeviceRef.Name != s.Device.Name { + conditions.Set(s.RoutingPolicy, metav1.Condition{ + Type: v1alpha1.ReadyCondition, + Status: metav1.ConditionFalse, + Reason: v1alpha1.CrossDeviceReferenceReason, + Message: fmt.Sprintf("referenced PrefixSet %q does not belong to device %q", prefixSet.Name, s.Device.Name), + }) + return nil, reconcile.TerminalError(fmt.Errorf("referenced PrefixSet %q does not belong to device %q", prefixSet.Name, s.Device.Name)) + } + + return prefixSet, nil +} + +func (r *RoutingPolicyReconciler) finalize(ctx context.Context, s *routingPolicyScope) (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}) + } + }() + + return s.Provider.DeleteRoutingPolicy(ctx, &provider.DeleteRoutingPolicyRequest{ + Name: s.RoutingPolicy.Spec.Name, + }) +} + +// prefixSetToRoutingPolicy is a [handler.MapFunc] to be used to enqueue requests for reconciliation +// for RoutingPolicies when their referenced PrefixSet changes. +func (r *RoutingPolicyReconciler) prefixSetToRoutingPolicy(ctx context.Context, obj client.Object) []ctrl.Request { + prefixSet, ok := obj.(*v1alpha1.PrefixSet) + if !ok { + panic(fmt.Sprintf("Expected a PrefixSet but got a %T", obj)) + } + + log := ctrl.LoggerFrom(ctx, "PrefixSet", klog.KObj(prefixSet)) + + routingPolicies := new(v1alpha1.RoutingPolicyList) + if err := r.List(ctx, routingPolicies, client.InNamespace(prefixSet.Namespace), client.MatchingFields{routingPolicyPrefixSetRefKey: prefixSet.Spec.Name}); err != nil { + log.Error(err, "Failed to list RoutingPolicies") + return nil + } + + requests := []ctrl.Request{} + for _, rp := range routingPolicies.Items { + for _, stmt := range rp.Spec.Statements { + if stmt.Conditions != nil && stmt.Conditions.MatchPrefixSet != nil && stmt.Conditions.MatchPrefixSet.PrefixSetRef.Name == prefixSet.Spec.Name { + log.Info("Enqueuing RoutingPolicy for reconciliation", "RoutingPolicy", klog.KObj(&rp)) + requests = append(requests, ctrl.Request{ + NamespacedName: client.ObjectKey{ + Name: rp.Name, + Namespace: rp.Namespace, + }, + }) + break + } + } + } + + return requests +} diff --git a/internal/controller/core/routingpolicy_controller_test.go b/internal/controller/core/routingpolicy_controller_test.go new file mode 100644 index 00000000..dc8cbd37 --- /dev/null +++ b/internal/controller/core/routingpolicy_controller_test.go @@ -0,0 +1,293 @@ +// 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/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +var _ = Describe("RoutingPolicy Controller", func() { + Context("When reconciling a resource", func() { + const name = "test-routingpolicy" + key := client.ObjectKey{Name: name, Namespace: metav1.NamespaceDefault} + + BeforeEach(func() { + By("Creating a Device resource for testing") + device := &v1alpha1.Device{} + if err := k8sClient.Get(ctx, key, device); errors.IsNotFound(err) { + resource := &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: "192.168.10.2:9339", + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + rp := &v1alpha1.RoutingPolicy{} + err := k8sClient.Get(ctx, key, rp) + Expect(err).NotTo(HaveOccurred()) + + By("Cleaning up the RoutingPolicy resource") + Expect(k8sClient.Delete(ctx, rp)).To(Succeed()) + + By("Cleaning up the PrefixSet resource") + ps := &v1alpha1.PrefixSet{} + if err := k8sClient.Get(ctx, key, ps); err == nil { + Expect(k8sClient.Delete(ctx, ps)).To(Succeed()) + } + + device := &v1alpha1.Device{} + err = k8sClient.Get(ctx, key, device) + Expect(err).NotTo(HaveOccurred()) + + By("Cleaning up the test Device resource") + Expect(k8sClient.Delete(ctx, device, client.PropagationPolicy(metav1.DeletePropagationForeground))).To(Succeed()) + + By("Verifying the RoutingPolicy is removed from the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.RoutingPolicies.Has(name)).To(BeFalse(), "Provider shouldn't have RoutingPolicy configured anymore") + }).Should(Succeed()) + }) + + It("Should successfully reconcile the resource", func() { + rp := &v1alpha1.RoutingPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.RoutingPolicySpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Name: name, + Statements: []v1alpha1.PolicyStatement{ + { + Sequence: 10, + Actions: v1alpha1.PolicyActions{ + RouteDisposition: v1alpha1.RejectRoute, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, rp)).To(Succeed()) + + By("Adding a finalizer to the resource") + Eventually(func(g Gomega) { + resource := &v1alpha1.RoutingPolicy{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(controllerutil.ContainsFinalizer(resource, v1alpha1.FinalizerName)).To(BeTrue()) + }).Should(Succeed()) + + By("Adding the device label to the resource") + Eventually(func(g Gomega) { + resource := &v1alpha1.RoutingPolicy{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Labels).To(HaveKeyWithValue(v1alpha1.DeviceLabel, name)) + }).Should(Succeed()) + + By("Adding the device as a owner reference") + Eventually(func(g Gomega) { + resource := &v1alpha1.RoutingPolicy{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.OwnerReferences).To(HaveLen(1)) + g.Expect(resource.OwnerReferences[0].Kind).To(Equal("Device")) + g.Expect(resource.OwnerReferences[0].Name).To(Equal(name)) + }).Should(Succeed()) + + By("Updating the resource status") + Eventually(func(g Gomega) { + resource := &v1alpha1.RoutingPolicy{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Status.Conditions).To(HaveLen(1)) + g.Expect(resource.Status.Conditions[0].Type).To(Equal(v1alpha1.ReadyCondition)) + g.Expect(resource.Status.Conditions[0].Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Ensuring the resource is created in the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.RoutingPolicies.Has(name)).To(BeTrue(), "Provider should have RoutingPolicy configured") + }).Should(Succeed()) + }) + + It("Should successfully reconcile a RoutingPolicy with PrefixSet match condition and BGP actions", func() { + By("Creating a PrefixSet resource") + ps := &v1alpha1.PrefixSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.PrefixSetSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Name: "INTERNAL-NETWORKS", + Entries: []v1alpha1.PrefixEntry{ + { + Sequence: 10, + Prefix: v1alpha1.IPPrefix{Prefix: netip.MustParsePrefix("10.0.0.0/8")}, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, ps)).To(Succeed()) + + By("Creating a RoutingPolicy with PrefixSet match condition and BGP actions") + rp := &v1alpha1.RoutingPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.RoutingPolicySpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Name: name, + Statements: []v1alpha1.PolicyStatement{ + { + Sequence: 10, + Conditions: &v1alpha1.PolicyConditions{ + MatchPrefixSet: &v1alpha1.PrefixSetMatchCondition{ + PrefixSetRef: v1alpha1.LocalObjectReference{Name: name}, + }, + }, + Actions: v1alpha1.PolicyActions{ + RouteDisposition: v1alpha1.AcceptRoute, + BgpActions: &v1alpha1.BgpActions{ + SetCommunity: &v1alpha1.SetCommunityAction{ + Communities: []string{"65137:100", "65137:200"}, + }, + SetExtCommunity: &v1alpha1.SetExtCommunityAction{ + Communities: []string{"65137:100"}, + }, + }, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, rp)).To(Succeed()) + + By("Verifying the controller sets successful status conditions") + Eventually(func(g Gomega) { + resource := &v1alpha1.RoutingPolicy{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Status.Conditions).To(HaveLen(1)) + g.Expect(resource.Status.Conditions[0].Type).To(Equal(v1alpha1.ReadyCondition)) + g.Expect(resource.Status.Conditions[0].Status).To(Equal(metav1.ConditionTrue)) + }).Should(Succeed()) + + By("Verifying the RoutingPolicy is configured in the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.RoutingPolicies.Has(name)).To(BeTrue(), "Provider should have RoutingPolicy configured") + }).Should(Succeed()) + }) + + It("Should handle non-existing PrefixSet reference", func() { + By("Creating a RoutingPolicy referencing non-existing PrefixSet") + rp := &v1alpha1.RoutingPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.RoutingPolicySpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Name: name, + Statements: []v1alpha1.PolicyStatement{ + { + Sequence: 10, + Conditions: &v1alpha1.PolicyConditions{ + MatchPrefixSet: &v1alpha1.PrefixSetMatchCondition{ + PrefixSetRef: v1alpha1.LocalObjectReference{Name: name}, + }, + }, + Actions: v1alpha1.PolicyActions{ + RouteDisposition: v1alpha1.AcceptRoute, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, rp)).To(Succeed()) + + By("Verifying the controller sets PrefixSet not found status") + Eventually(func(g Gomega) { + resource := &v1alpha1.RoutingPolicy{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Status.Conditions).To(HaveLen(1)) + g.Expect(resource.Status.Conditions[0].Type).To(Equal(v1alpha1.ReadyCondition)) + g.Expect(resource.Status.Conditions[0].Status).To(Equal(metav1.ConditionFalse)) + g.Expect(resource.Status.Conditions[0].Reason).To(Equal(v1alpha1.PrefixSetNotFoundReason)) + }).Should(Succeed()) + }) + + It("Should handle PrefixSet on different device", func() { + By("Creating a PrefixSet on a different device") + ps := &v1alpha1.PrefixSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.PrefixSetSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: "different-device"}, + Name: "INTERNAL-NETWORKS", + Entries: []v1alpha1.PrefixEntry{ + { + Sequence: 10, + Prefix: v1alpha1.IPPrefix{Prefix: netip.MustParsePrefix("10.0.0.0/8")}, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, ps)).To(Succeed()) + + By("Creating a RoutingPolicy referencing the cross-device PrefixSet") + rp := &v1alpha1.RoutingPolicy{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.RoutingPolicySpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Name: name, + Statements: []v1alpha1.PolicyStatement{ + { + Sequence: 10, + Conditions: &v1alpha1.PolicyConditions{ + MatchPrefixSet: &v1alpha1.PrefixSetMatchCondition{ + PrefixSetRef: v1alpha1.LocalObjectReference{Name: name}, + }, + }, + Actions: v1alpha1.PolicyActions{ + RouteDisposition: v1alpha1.AcceptRoute, + }, + }, + }, + }, + } + Expect(k8sClient.Create(ctx, rp)).To(Succeed()) + + By("Verifying the controller sets cross-device reference status") + Eventually(func(g Gomega) { + resource := &v1alpha1.RoutingPolicy{} + g.Expect(k8sClient.Get(ctx, key, resource)).To(Succeed()) + g.Expect(resource.Status.Conditions).To(HaveLen(1)) + g.Expect(resource.Status.Conditions[0].Type).To(Equal(v1alpha1.ReadyCondition)) + g.Expect(resource.Status.Conditions[0].Status).To(Equal(metav1.ConditionFalse)) + g.Expect(resource.Status.Conditions[0].Reason).To(Equal(v1alpha1.CrossDeviceReferenceReason)) + }).Should(Succeed()) + }) + }) +}) diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go index 24a78013..bb842eda 100644 --- a/internal/controller/core/suite_test.go +++ b/internal/controller/core/suite_test.go @@ -265,6 +265,14 @@ var _ = BeforeSuite(func() { }).SetupWithManager(k8sManager) Expect(err).NotTo(HaveOccurred()) + err = (&RoutingPolicyReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + Recorder: recorder, + Provider: prov, + }).SetupWithManager(ctx, k8sManager) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = k8sManager.Start(ctx) @@ -329,46 +337,49 @@ var ( _ provider.VLANProvider = (*Provider)(nil) _ provider.EVPNInstanceProvider = (*Provider)(nil) _ provider.PrefixSetProvider = (*Provider)(nil) + _ provider.RoutingPolicyProvider = (*Provider)(nil) ) // Provider is a simple in-memory provider for testing purposes only. type Provider struct { sync.Mutex - Ports sets.Set[string] - User sets.Set[string] - Banner *string - DNS *v1alpha1.DNS - NTP *v1alpha1.NTP - ACLs sets.Set[string] - Certs sets.Set[string] - SNMP *v1alpha1.SNMP - Syslog *v1alpha1.Syslog - Access *v1alpha1.ManagementAccess - ISIS sets.Set[string] - VRF sets.Set[string] - PIM *v1alpha1.PIM - BGP *v1alpha1.BGP - BGPPeers sets.Set[string] - OSPF sets.Set[string] - VLANs sets.Set[int16] - EVIs sets.Set[int32] - PrefixSets sets.Set[string] + Ports sets.Set[string] + User sets.Set[string] + Banner *string + DNS *v1alpha1.DNS + NTP *v1alpha1.NTP + ACLs sets.Set[string] + Certs sets.Set[string] + SNMP *v1alpha1.SNMP + Syslog *v1alpha1.Syslog + Access *v1alpha1.ManagementAccess + ISIS sets.Set[string] + VRF sets.Set[string] + PIM *v1alpha1.PIM + BGP *v1alpha1.BGP + BGPPeers sets.Set[string] + OSPF sets.Set[string] + VLANs sets.Set[int16] + EVIs sets.Set[int32] + PrefixSets sets.Set[string] + RoutingPolicies sets.Set[string] } func NewProvider() *Provider { return &Provider{ - Ports: sets.New[string](), - User: sets.New[string](), - ACLs: sets.New[string](), - Certs: sets.New[string](), - ISIS: sets.New[string](), - VRF: sets.New[string](), - BGPPeers: sets.New[string](), - OSPF: sets.New[string](), - VLANs: sets.New[int16](), - EVIs: sets.New[int32](), - PrefixSets: sets.New[string](), + Ports: sets.New[string](), + User: sets.New[string](), + ACLs: sets.New[string](), + Certs: sets.New[string](), + ISIS: sets.New[string](), + VRF: sets.New[string](), + BGPPeers: sets.New[string](), + OSPF: sets.New[string](), + VLANs: sets.New[int16](), + EVIs: sets.New[int32](), + PrefixSets: sets.New[string](), + RoutingPolicies: sets.New[string](), } } @@ -693,3 +704,17 @@ func (p *Provider) DeletePrefixSet(_ context.Context, req *provider.PrefixSetReq p.PrefixSets.Delete(req.PrefixSet.Spec.Name) return nil } + +func (p *Provider) EnsureRoutingPolicy(_ context.Context, req *provider.EnsureRoutingPolicyRequest) error { + p.Lock() + defer p.Unlock() + p.RoutingPolicies.Insert(req.Name) + return nil +} + +func (p *Provider) DeleteRoutingPolicy(_ context.Context, req *provider.DeleteRoutingPolicyRequest) error { + p.Lock() + defer p.Unlock() + p.RoutingPolicies.Delete(req.Name) + return nil +} diff --git a/internal/provider/cisco/nxos/evi.go b/internal/provider/cisco/nxos/evi.go index f5fdad81..9c2acb68 100644 --- a/internal/provider/cisco/nxos/evi.go +++ b/internal/provider/cisco/nxos/evi.go @@ -31,6 +31,14 @@ func (b *BDEVI) XPath() string { return "System/evpn-items/bdevi-items/BDEvi-list[encap=" + b.Encap + "]" } +func Community(c string) (string, error) { + s, err := stdcommunity(c) + if err != nil { + return "", err + } + return "regular:" + s, nil +} + func RouteDistinguisher(rd string) (string, error) { s, err := extcommunity(rd) if err != nil { @@ -47,6 +55,29 @@ func RouteTarget(rt string) (string, error) { return "route-target:" + s, nil } +// stdcommunity converts a value to a standard community string. +func stdcommunity(s string) (string, error) { + parts := strings.SplitN(s, ":", 2) + if len(parts) != 2 { + return "", errors.New("invalid bgp community format") + } + admin, err := strconv.ParseUint(parts[0], 10, 32) + if err != nil { + return "", fmt.Errorf("invalid bgp community format: %w", err) + } + if admin > math.MaxUint16 { + return "", fmt.Errorf("standard community 'Administrator' must be in range 0–65535, got %d", admin) + } + assigned, err := strconv.ParseUint(parts[1], 10, 32) + if err != nil { + return "", fmt.Errorf("invalid bgp community format: %w", err) + } + if assigned > math.MaxUint16 { + return "", fmt.Errorf("standard community 'Assigned Number' must be in range 0–65535, got %d", assigned) + } + return "as2-nn2:" + s, nil +} + // extcommunity converts a value to an extended community string. func extcommunity(s string) (string, error) { if s == "" { @@ -54,20 +85,31 @@ func extcommunity(s string) (string, error) { } parts := strings.SplitN(s, ":", 2) if len(parts) != 2 { - return "", errors.New("invalid route distinguisher format") + return "", errors.New("invalid extended community format") } - asn, err := strconv.ParseUint(parts[1], 10, 32) + assigned, err := strconv.ParseUint(parts[1], 10, 32) if err != nil { - return "", fmt.Errorf("invalid route distinguisher format: %w", err) - } - // Type-0 - if asn > math.MaxUint16 { - return "as2-nn4:" + s, nil + return "", fmt.Errorf("invalid extended community format: %w", err) } // Type-1 if _, err := netip.ParseAddr(parts[0]); err == nil { + if assigned > math.MaxUint16 { + return "", fmt.Errorf("extended community 'Assigned Number' must be in range 0–65535, got %d", assigned) + } return "ipv4-nn2:" + s, nil } + asn, err := strconv.ParseUint(parts[0], 10, 32) + if err != nil { + return "", fmt.Errorf("invalid bgp extended community format: %w", err) + } + // Type-0 + if asn <= math.MaxUint16 { + // standard 2-byte ASN + if assigned <= math.MaxUint16 { + return "as2-nn2:" + s, nil + } + return "as2-nn4:" + s, nil + } // Type-2 return "as4-nn2:" + s, nil } diff --git a/internal/provider/cisco/nxos/grpc.go b/internal/provider/cisco/nxos/grpc.go index 4cb6ff8c..c78b8be1 100644 --- a/internal/provider/cisco/nxos/grpc.go +++ b/internal/provider/cisco/nxos/grpc.go @@ -19,7 +19,7 @@ var ( // GRPC represents the gRPC configuration on a NX-OS device. type GRPC struct { - Cert Option[string] `json:"cert,omitempty"` + Cert Option[string] `json:"cert,omitzero"` CertClientRoot string `json:"certClientRoot,omitempty"` Port int32 `json:"port"` UseVrf string `json:"useVrf,omitempty"` diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index 164ea0bc..45e6e7d2 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -52,6 +52,7 @@ var ( _ provider.PIMProvider = (*Provider)(nil) _ provider.SNMPProvider = (*Provider)(nil) _ provider.PrefixSetProvider = (*Provider)(nil) + _ provider.RoutingPolicyProvider = (*Provider)(nil) _ provider.SyslogProvider = (*Provider)(nil) _ provider.UserProvider = (*Provider)(nil) _ provider.VLANProvider = (*Provider)(nil) @@ -1621,6 +1622,55 @@ func (p *Provider) DeletePrefixSet(ctx context.Context, req *provider.PrefixSetR return p.client.Delete(ctx, s) } +func (p *Provider) EnsureRoutingPolicy(ctx context.Context, req *provider.EnsureRoutingPolicyRequest) error { + rm := new(RouteMap) + rm.Name = req.Name + for _, stmt := range req.Statements { + e := new(RouteMapEntry) + e.Order = stmt.Sequence + + for _, cond := range stmt.Conditions { + switch v := cond.(type) { + case provider.MatchPrefixSetCondition: + e.SetPrefixSet(v.PrefixSet) + default: + return fmt.Errorf("routing policy: unsupported condition type %T", cond) + } + } + + switch stmt.Actions.RouteDisposition { + case v1alpha1.AcceptRoute: + e.Action = ActionPermit + case v1alpha1.RejectRoute: + e.Action = ActionDeny + default: + return fmt.Errorf("routing policy: unsupported action %q", stmt.Actions.RouteDisposition) + } + + if stmt.Actions.BgpActions != nil { + if stmt.Actions.BgpActions.SetCommunity != nil { + if err := e.SetCommunities(stmt.Actions.BgpActions.SetCommunity.Communities); err != nil { + return err + } + } + if stmt.Actions.BgpActions.SetExtCommunity != nil { + if err := e.SetExtCommunities(stmt.Actions.BgpActions.SetExtCommunity.Communities); err != nil { + return err + } + } + } + + rm.EntItems.EntryList.Set(e) + } + return p.client.Update(ctx, rm) +} + +func (p *Provider) DeleteRoutingPolicy(ctx context.Context, req *provider.DeleteRoutingPolicyRequest) error { + rm := new(RouteMap) + rm.Name = req.Name + return p.client.Delete(ctx, rm) +} + func (p *Provider) EnsureUser(ctx context.Context, req *provider.EnsureUserRequest) error { u := new(User) u.AllowExpired = "no" diff --git a/internal/provider/cisco/nxos/routemap.go b/internal/provider/cisco/nxos/routemap.go new file mode 100644 index 00000000..04f1e4d0 --- /dev/null +++ b/internal/provider/cisco/nxos/routemap.go @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package nxos + +import ( + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/provider/cisco/gnmiext/v2" +) + +var _ gnmiext.Configurable = (*RouteMap)(nil) + +type RouteMap struct { + Name string `json:"name"` + EntItems struct { + EntryList gnmiext.List[int32, *RouteMapEntry] `json:"Entry-list,omitzero"` + } `json:"ent-items,omitzero"` +} + +func (*RouteMap) IsListItem() {} + +func (rm *RouteMap) XPath() string { + return "System/rpm-items/rtmap-items/Rule-list[name=" + rm.Name + "]" +} + +type RouteMapEntry struct { + Action Action `json:"action"` + Order int32 `json:"order"` + SrttItems struct { + ItemItems struct { + ItemList gnmiext.List[ExtCommItem, *ExtCommItem] `json:"Item-list,omitzero"` + } `json:"item-items,omitzero"` + } `json:"srtt-items,omitzero"` + SregcommItems struct { + NoCommAttr AdminSt `json:"noCommAttr"` + ItemItems struct { + ItemList gnmiext.List[string, *CommItem] `json:"Item-list,omitzero"` + } `json:"item-items,omitzero"` + } `json:"sregcomm-items,omitzero"` + MrtdstItems struct { + RsrtDstAttItems struct { + RsRtDstAttList gnmiext.List[string, *RsRtDstAtt] `json:"RsRtDstAtt-list,omitzero"` + } `json:"rsrtDstAtt-items,omitzero"` + } `json:"mrtdst-items,omitzero"` +} + +func (e *RouteMapEntry) Key() int32 { return e.Order } + +func (e *RouteMapEntry) SetCommunities(communities []string) error { + for _, comm := range communities { + c, err := Community(comm) + if err != nil { + return err + } + e.SregcommItems.NoCommAttr = AdminStDisabled + e.SregcommItems.ItemItems.ItemList.Set(&CommItem{Community: c}) + } + return nil +} + +func (e *RouteMapEntry) SetExtCommunities(communities []string) error { + for _, comm := range communities { + c, err := RouteTarget(comm) + if err != nil { + return err + } + e.SrttItems.ItemItems.ItemList.Set(&ExtCommItem{Community: c, Scope: RtExtComScopeTransitive}) + } + return nil +} + +func (e *RouteMapEntry) SetPrefixSet(ps *v1alpha1.PrefixSet) { + tdn := "/System/rpm-items/pfxlistv4-items/RuleV4-list[name='" + ps.Name + "']" + if ps.Is6() { + tdn = "/System/rpm-items/pfxlistv6-items/RuleV6-list[name='" + ps.Name + "']" + } + e.MrtdstItems.RsrtDstAttItems.RsRtDstAttList.Set(&RsRtDstAtt{TDn: tdn}) +} + +type RsRtDstAtt struct { + TDn string `json:"tDn"` +} + +func (r *RsRtDstAtt) Key() string { return r.TDn } + +type CommItem struct { + Community string `json:"community"` +} + +func (c *CommItem) Key() string { return c.Community } + +type ExtCommItem struct { + Community string `json:"community"` + Scope RtExtComScope `json:"scope"` +} + +func (c *ExtCommItem) Key() ExtCommItem { return *c } + +type RtExtComScope string + +const ( + RtExtComScopeTransitive RtExtComScope = "transitive" + RtExtComScopeNonTransitive RtExtComScope = "non-transitive" +) diff --git a/internal/provider/cisco/nxos/routemap_test.go b/internal/provider/cisco/nxos/routemap_test.go new file mode 100644 index 00000000..fdfc7610 --- /dev/null +++ b/internal/provider/cisco/nxos/routemap_test.go @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package nxos + +func init() { + e := &RouteMapEntry{} + e.Order = 10 + e.Action = ActionPermit + e.SrttItems.ItemItems.ItemList.Set(&ExtCommItem{Community: "route-target:as2-nn2:65137:107", Scope: RtExtComScopeTransitive}) + e.SregcommItems.NoCommAttr = AdminStDisabled + e.SregcommItems.ItemItems.ItemList.Set(&CommItem{Community: "regular:as2-nn2:65137:107"}) + e.MrtdstItems.RsrtDstAttItems.RsRtDstAttList.Set(&RsRtDstAtt{TDn: "/System/rpm-items/pfxlistv4-items/RuleV4-list[name='PL-CLOUD07']"}) + + rm := &RouteMap{} + rm.Name = "RM-REDIST" + rm.EntItems.EntryList.Set(e) + Register("route_map", rm) +} diff --git a/internal/provider/cisco/nxos/testdata/route_map.json b/internal/provider/cisco/nxos/testdata/route_map.json new file mode 100644 index 00000000..89ab01b6 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/route_map.json @@ -0,0 +1,48 @@ +{ + "rpm-items": { + "rtmap-items": { + "Rule-list": [ + { + "name": "RM-REDIST", + "ent-items": { + "Entry-list": [ + { + "action": "permit", + "order": 10, + "srtt-items": { + "item-items": { + "Item-list": [ + { + "community": "route-target:as2-nn2:65137:107", + "scope": "transitive" + } + ] + } + }, + "sregcomm-items": { + "noCommAttr": "disabled", + "item-items": { + "Item-list": [ + { + "community": "regular:as2-nn2:65137:107" + } + ] + } + }, + "mrtdst-items": { + "rsrtDstAtt-items": { + "RsRtDstAtt-list": [ + { + "tDn": "/System/rpm-items/pfxlistv4-items/RuleV4-list[name='PL-CLOUD07']" + } + ] + } + } + } + ] + } + } + ] + } + } +} diff --git a/internal/provider/cisco/nxos/testdata/route_map.json.txt b/internal/provider/cisco/nxos/testdata/route_map.json.txt new file mode 100644 index 00000000..31350ce0 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/route_map.json.txt @@ -0,0 +1,4 @@ +route-map RM-REDIST permit 10 + match ip address prefix-list PL-CLOUD07 + set community 65137:107 + set extcommunity rt 65137:107 diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 38e39530..cf6c2bbf 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -503,6 +503,40 @@ type PrefixSetRequest struct { ProviderConfig *ProviderConfig } +// RoutingPolicyProvider is the interface for the realization of the RoutingPolicy objects over different providers. +type RoutingPolicyProvider interface { + Provider + + // EnsureRoutingPolicy call is responsible for RoutingPolicy realization on the provider. + EnsureRoutingPolicy(context.Context, *EnsureRoutingPolicyRequest) error + // DeleteRoutingPolicy call is responsible for RoutingPolicy deletion on the provider. + DeleteRoutingPolicy(context.Context, *DeleteRoutingPolicyRequest) error +} + +type EnsureRoutingPolicyRequest struct { + Name string + Statements []PolicyStatement + ProviderConfig *ProviderConfig +} + +type PolicyStatement struct { + Sequence int32 + Conditions []PolicyCondition + Actions v1alpha1.PolicyActions +} + +type PolicyCondition interface{ isPolicyCondition() } + +type MatchPrefixSetCondition struct { + PrefixSet *v1alpha1.PrefixSet +} + +func (MatchPrefixSetCondition) isPolicyCondition() {} + +type DeleteRoutingPolicyRequest struct { + Name string +} + var mu sync.RWMutex // ProviderFunc returns a new [Provider] instance. diff --git a/internal/webhook/core/v1alpha1/vrf_webhook.go b/internal/webhook/core/v1alpha1/vrf_webhook.go index ab4d3f75..5a48b7ee 100644 --- a/internal/webhook/core/v1alpha1/vrf_webhook.go +++ b/internal/webhook/core/v1alpha1/vrf_webhook.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "math" "net/netip" "strconv" "strings" @@ -104,7 +105,7 @@ func validateRouteDistinguisher(rd string) error { } // type 1 check if ip, err := netip.ParseAddr(admin); err == nil && ip.Is4() { - if assigned > 65535 { + if assigned > math.MaxUint16 { return errors.New("type-1 'Assigned Number' is out of range (0–65535)") } return nil @@ -118,16 +119,16 @@ func validateRouteDistinguisher(rd string) error { // Reserved ASNs switch asn { - case 0, 65535, 4294967295: + case 0, math.MaxUint16, math.MaxUint32: return fmt.Errorf("ASN %d is reserved and cannot be used", asn) } // type 0: ASN 0–65535 + 32-bit number (0–4294967295) with reserved previously checked - if asn <= 65535 && assigned <= 4294967295 { + if asn <= math.MaxUint16 && assigned <= math.MaxUint32 { return nil } // type 2: ASN 65536–4294967295 + 16-bit number (0–65535) with reserved previously checked - if asn <= 4294967295 && assigned <= 65535 { + if asn <= math.MaxUint32 && assigned <= math.MaxUint16 { return nil } diff --git a/test/e2e/util.go b/test/e2e/util.go index 71e728cc..ee591c58 100644 --- a/test/e2e/util.go +++ b/test/e2e/util.go @@ -12,7 +12,7 @@ import ( "os/exec" "strings" - . "github.com/onsi/ginkgo/v2" //nolint:golint,revive + . "github.com/onsi/ginkgo/v2" //nolint:revive ) const (