diff --git a/PROJECT b/PROJECT index 9ca8813c..3f0a37d5 100644 --- a/PROJECT +++ b/PROJECT @@ -187,4 +187,15 @@ resources: kind: EVPNInstance path: github.com/ironcore-dev/network-operator/api/core/v1alpha1 version: v1alpha1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: networking.metal.ironcore.dev + kind: PrefixSet + path: github.com/ironcore-dev/network-operator/api/core/v1alpha1 + version: v1alpha1 + webhooks: + validation: true + webhookVersion: v1 version: "3" diff --git a/Tiltfile b/Tiltfile index 185b4c80..3cdf1820 100644 --- a/Tiltfile +++ b/Tiltfile @@ -105,6 +105,9 @@ k8s_resource(new_name='vlan-10', objects=['vlan-10:vlan'], trigger_mode=TRIGGER_ k8s_yaml('./config/samples/v1alpha1_evi.yaml') k8s_resource(new_name='vxlan-100010', objects=['vxlan-100010:evpninstance'], resource_deps=['vlan-10'], trigger_mode=TRIGGER_MODE_MANUAL, auto_init=False) +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) + 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/prefixset_types.go b/api/core/v1alpha1/prefixset_types.go new file mode 100644 index 00000000..27229ea6 --- /dev/null +++ b/api/core/v1alpha1/prefixset_types.go @@ -0,0 +1,143 @@ +// 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" +) + +// PrefixSetSpec defines the desired state of PrefixSet +type PrefixSetSpec 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 name of the PrefixSet. + // Immutable. + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=32 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Name is immutable" + Name string `json:"name"` + + // A list of entries to apply. + // The address families (IPv4, IPv6) of all prefixes in the list must match. + // +required + // +listType=map + // +listMapKey=sequence + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=100 + Entries []PrefixEntry `json:"entries"` +} + +type PrefixEntry struct { + // The sequence number of the Prefix entry. + // +required + // +kubebuilder:validation:Minimum=1 + Sequence int32 `json:"sequence"` + + // IP prefix. Can be IPv4 or IPv6. + // Use 0.0.0.0/0 (::/0) to represent 'any'. + // +required + Prefix IPPrefix `json:"prefix"` + + // Optional mask length range for the prefix. + // If not specified, only the exact prefix length is matched. + // +optional + MaskLengthRange *MaskLengthRange `json:"maskLengthRange,omitempty"` +} + +type MaskLengthRange struct { + // Minimum mask length. + // +required + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=128 + Min int8 `json:"min"` + + // Maximum mask length. + // +required + // +kubebuilder:validation:Minimum=0 + // +kubebuilder:validation:Maximum=128 + Max int8 `json:"max"` +} + +// PrefixSetStatus defines the observed state of PrefixSet. +type PrefixSetStatus struct { + // The conditions are a list of status objects that describe the state of the PrefixSet. + //+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=prefixsets +// +kubebuilder:resource:singular=prefixset +// +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" + +// PrefixSet is the Schema for the prefixsets API +type PrefixSet 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 PrefixSetSpec `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 PrefixSetStatus `json:"status,omitzero"` +} + +// GetConditions implements conditions.Getter. +func (p *PrefixSet) GetConditions() []metav1.Condition { + return p.Status.Conditions +} + +// SetConditions implements conditions.Setter. +func (p *PrefixSet) SetConditions(conditions []metav1.Condition) { + p.Status.Conditions = conditions +} + +// Is4 reports whether entries of the PrefixSet are IPv4 addresses. +func (p *PrefixSet) Is4() bool { + // Note: We can safely check only the first entry because + // validation ensures all entries are of the same IP family. + return len(p.Spec.Entries) > 0 && p.Spec.Entries[0].Prefix.Addr().Is4() +} + +// Is6 reports whether entries of the PrefixSet are IPv6 addresses. +func (p *PrefixSet) Is6() bool { + // Note: We can safely check only the first entry because + // validation ensures all entries are of the same IP family. + return len(p.Spec.Entries) > 0 && p.Spec.Entries[0].Prefix.Addr().Is6() +} + +// +kubebuilder:object:root=true + +// PrefixSetList contains a list of PrefixSet +type PrefixSetList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []PrefixSet `json:"items"` +} + +func init() { + SchemeBuilder.Register(&PrefixSet{}, &PrefixSetList{}) +} diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go index d2660a1e..8e732e69 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -1788,6 +1788,21 @@ func (in *ManagementAccessStatus) DeepCopy() *ManagementAccessStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *MaskLengthRange) DeepCopyInto(out *MaskLengthRange) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new MaskLengthRange. +func (in *MaskLengthRange) DeepCopy() *MaskLengthRange { + if in == nil { + return nil + } + out := new(MaskLengthRange) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *MultiChassis) DeepCopyInto(out *MultiChassis) { *out = *in @@ -2253,6 +2268,136 @@ 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 *PrefixEntry) DeepCopyInto(out *PrefixEntry) { + *out = *in + in.Prefix.DeepCopyInto(&out.Prefix) + if in.MaskLengthRange != nil { + in, out := &in.MaskLengthRange, &out.MaskLengthRange + *out = new(MaskLengthRange) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PrefixEntry. +func (in *PrefixEntry) DeepCopy() *PrefixEntry { + if in == nil { + return nil + } + out := new(PrefixEntry) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PrefixSet) DeepCopyInto(out *PrefixSet) { + *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 PrefixSet. +func (in *PrefixSet) DeepCopy() *PrefixSet { + if in == nil { + return nil + } + out := new(PrefixSet) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PrefixSet) 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 *PrefixSetList) DeepCopyInto(out *PrefixSetList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]PrefixSet, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PrefixSetList. +func (in *PrefixSetList) DeepCopy() *PrefixSetList { + if in == nil { + return nil + } + out := new(PrefixSetList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PrefixSetList) 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 *PrefixSetSpec) DeepCopyInto(out *PrefixSetSpec) { + *out = *in + out.DeviceRef = in.DeviceRef + if in.ProviderConfigRef != nil { + in, out := &in.ProviderConfigRef, &out.ProviderConfigRef + *out = new(TypedLocalObjectReference) + **out = **in + } + if in.Entries != nil { + in, out := &in.Entries, &out.Entries + *out = make([]PrefixEntry, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PrefixSetSpec. +func (in *PrefixSetSpec) DeepCopy() *PrefixSetSpec { + if in == nil { + return nil + } + out := new(PrefixSetSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PrefixSetStatus) DeepCopyInto(out *PrefixSetStatus) { + *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 PrefixSetStatus. +func (in *PrefixSetStatus) DeepCopy() *PrefixSetStatus { + if in == nil { + return nil + } + out := new(PrefixSetStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RendezvousPoint) DeepCopyInto(out *RendezvousPoint) { *out = *in diff --git a/charts/network-operator/templates/crd/networking.metal.ironcore.dev_prefixsets.yaml b/charts/network-operator/templates/crd/networking.metal.ironcore.dev_prefixsets.yaml new file mode 100644 index 00000000..eda67ec3 --- /dev/null +++ b/charts/network-operator/templates/crd/networking.metal.ironcore.dev_prefixsets.yaml @@ -0,0 +1,249 @@ +{{- 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: prefixsets.networking.metal.ironcore.dev +spec: + group: networking.metal.ironcore.dev + names: + kind: PrefixSet + listKind: PrefixSetList + plural: prefixsets + singular: prefixset + scope: Namespaced + versions: + - additionalPrinterColumns: + - 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: PrefixSet is the Schema for the prefixsets 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 + entries: + description: |- + A list of entries to apply. + The address families (IPv4, IPv6) of all prefixes in the list must match. + items: + properties: + maskLengthRange: + description: |- + Optional mask length range for the prefix. + If not specified, only the exact prefix length is matched. + properties: + max: + description: Maximum mask length. + maximum: 128 + minimum: 0 + type: integer + min: + description: Minimum mask length. + maximum: 128 + minimum: 0 + type: integer + required: + - max + - min + type: object + prefix: + description: |- + IP prefix. Can be IPv4 or IPv6. + Use 0.0.0.0/0 (::/0) to represent 'any'. + format: cidr + type: string + sequence: + description: The sequence number of the Prefix entry. + format: int32 + minimum: 1 + type: integer + required: + - prefix + - sequence + type: object + maxItems: 100 + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - sequence + x-kubernetes-list-type: map + name: + description: |- + Name is the name of the PrefixSet. + Immutable. + maxLength: 32 + 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 + required: + - deviceRef + - entries + - name + 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 PrefixSet. + 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/prefixset_admin_role.yaml b/charts/network-operator/templates/rbac/prefixset_admin_role.yaml new file mode 100644 index 00000000..fc76ce6a --- /dev/null +++ b/charts/network-operator/templates/rbac/prefixset_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: prefixset-admin-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - prefixsets + verbs: + - '*' +- apiGroups: + - networking.metal.ironcore.dev + resources: + - prefixsets/status + verbs: + - get +{{- end -}} diff --git a/charts/network-operator/templates/rbac/prefixset_editor_role.yaml b/charts/network-operator/templates/rbac/prefixset_editor_role.yaml new file mode 100644 index 00000000..a3892a98 --- /dev/null +++ b/charts/network-operator/templates/rbac/prefixset_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: prefixset-editor-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - prefixsets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - prefixsets/status + verbs: + - get +{{- end -}} diff --git a/charts/network-operator/templates/rbac/prefixset_viewer_role.yaml b/charts/network-operator/templates/rbac/prefixset_viewer_role.yaml new file mode 100644 index 00000000..3d7bf7d8 --- /dev/null +++ b/charts/network-operator/templates/rbac/prefixset_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: prefixset-viewer-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - prefixsets + verbs: + - get + - list + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - prefixsets/status + verbs: + - get +{{- end -}} diff --git a/charts/network-operator/templates/rbac/role.yaml b/charts/network-operator/templates/rbac/role.yaml index 587ca11e..825ab664 100644 --- a/charts/network-operator/templates/rbac/role.yaml +++ b/charts/network-operator/templates/rbac/role.yaml @@ -48,6 +48,7 @@ rules: - ntp - ospf - pim + - prefixsets - snmp - syslogs - users @@ -78,6 +79,7 @@ rules: - ntp/finalizers - ospf/finalizers - pim/finalizers + - prefixsets/finalizers - snmp/finalizers - syslogs/finalizers - users/finalizers @@ -102,6 +104,7 @@ rules: - ntp/status - ospf/status - pim/status + - prefixsets/status - snmp/status - syslogs/status - users/status diff --git a/charts/network-operator/templates/webhook/webhooks.yaml b/charts/network-operator/templates/webhook/webhooks.yaml index 4ba6c8a3..0691ab93 100644 --- a/charts/network-operator/templates/webhook/webhooks.yaml +++ b/charts/network-operator/templates/webhook/webhooks.yaml @@ -31,6 +31,26 @@ webhooks: - v1alpha1 resources: - interfaces + - name: prefixset-v1alpha1.kb.io + clientConfig: + service: + name: network-operator-webhook-service + namespace: {{ .Release.Namespace }} + path: /validate-networking-metal-ironcore-dev-v1alpha1-prefixset + failurePolicy: Fail + sideEffects: None + admissionReviewVersions: + - v1 + rules: + - operations: + - CREATE + - UPDATE + apiGroups: + - networking.metal.ironcore.dev + apiVersions: + - v1alpha1 + resources: + - prefixsets - name: vrf-v1alpha1.kb.io clientConfig: service: diff --git a/cmd/main.go b/cmd/main.go index 6691b281..141a83a9 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -438,6 +438,28 @@ func main() { os.Exit(1) } + if err := (&corecontroller.EVPNInstanceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("evpn-instance-controller"), + WatchFilterValue: watchFilterValue, + Provider: prov, + }).SetupWithManager(ctx, mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "EVPNInstance") + os.Exit(1) + } + + if err := (&corecontroller.PrefixSetReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: mgr.GetEventRecorderFor("prefixset-controller"), + WatchFilterValue: watchFilterValue, + Provider: prov, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "PrefixSet") + 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") @@ -451,15 +473,12 @@ func main() { os.Exit(1) } } - if err := (&corecontroller.EVPNInstanceReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: mgr.GetEventRecorderFor("evpn-instance-controller"), - WatchFilterValue: watchFilterValue, - Provider: prov, - }).SetupWithManager(ctx, mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "EVPNInstance") - os.Exit(1) + + if os.Getenv("ENABLE_WEBHOOKS") != "false" { + if err := webhookv1alpha1.SetupPrefixSetWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "PrefixSet") + os.Exit(1) + } } // +kubebuilder:scaffold:builder diff --git a/config/crd/bases/networking.metal.ironcore.dev_prefixsets.yaml b/config/crd/bases/networking.metal.ironcore.dev_prefixsets.yaml new file mode 100644 index 00000000..56889994 --- /dev/null +++ b/config/crd/bases/networking.metal.ironcore.dev_prefixsets.yaml @@ -0,0 +1,242 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + name: prefixsets.networking.metal.ironcore.dev +spec: + group: networking.metal.ironcore.dev + names: + kind: PrefixSet + listKind: PrefixSetList + plural: prefixsets + singular: prefixset + scope: Namespaced + versions: + - additionalPrinterColumns: + - 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: PrefixSet is the Schema for the prefixsets 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 + entries: + description: |- + A list of entries to apply. + The address families (IPv4, IPv6) of all prefixes in the list must match. + items: + properties: + maskLengthRange: + description: |- + Optional mask length range for the prefix. + If not specified, only the exact prefix length is matched. + properties: + max: + description: Maximum mask length. + maximum: 128 + minimum: 0 + type: integer + min: + description: Minimum mask length. + maximum: 128 + minimum: 0 + type: integer + required: + - max + - min + type: object + prefix: + description: |- + IP prefix. Can be IPv4 or IPv6. + Use 0.0.0.0/0 (::/0) to represent 'any'. + format: cidr + type: string + sequence: + description: The sequence number of the Prefix entry. + format: int32 + minimum: 1 + type: integer + required: + - prefix + - sequence + type: object + maxItems: 100 + minItems: 1 + type: array + x-kubernetes-list-map-keys: + - sequence + x-kubernetes-list-type: map + name: + description: |- + Name is the name of the PrefixSet. + Immutable. + maxLength: 32 + 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 + required: + - deviceRef + - entries + - name + 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 PrefixSet. + 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 782ca184..07a3f7b8 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -23,6 +23,7 @@ resources: - bases/nx.cisco.networking.metal.ironcore.dev_managementaccessconfigs.yaml - bases/networking.metal.ironcore.dev_vlans.yaml - bases/networking.metal.ironcore.dev_evpninstances.yaml +- bases/networking.metal.ironcore.dev_prefixsets.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index db30d741..113aacc1 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -85,3 +85,6 @@ resources: - evpninstance_admin_role.yaml - evpninstance_editor_role.yaml - evpninstance_viewer_role.yaml +- prefixset_admin_role.yaml +- prefixset_editor_role.yaml +- prefixset_viewer_role.yaml diff --git a/config/rbac/prefixset_admin_role.yaml b/config/rbac/prefixset_admin_role.yaml new file mode 100644 index 00000000..8177fdb8 --- /dev/null +++ b/config/rbac/prefixset_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: prefixset-admin-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - prefixsets + verbs: + - '*' +- apiGroups: + - networking.metal.ironcore.dev + resources: + - prefixsets/status + verbs: + - get diff --git a/config/rbac/prefixset_editor_role.yaml b/config/rbac/prefixset_editor_role.yaml new file mode 100644 index 00000000..78e417ed --- /dev/null +++ b/config/rbac/prefixset_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: prefixset-editor-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - prefixsets + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - prefixsets/status + verbs: + - get diff --git a/config/rbac/prefixset_viewer_role.yaml b/config/rbac/prefixset_viewer_role.yaml new file mode 100644 index 00000000..150c7957 --- /dev/null +++ b/config/rbac/prefixset_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: prefixset-viewer-role +rules: +- apiGroups: + - networking.metal.ironcore.dev + resources: + - prefixsets + verbs: + - get + - list + - watch +- apiGroups: + - networking.metal.ironcore.dev + resources: + - prefixsets/status + verbs: + - get diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index d12eafe7..94d6b14b 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -45,6 +45,7 @@ rules: - ntp - ospf - pim + - prefixsets - snmp - syslogs - users @@ -75,6 +76,7 @@ rules: - ntp/finalizers - ospf/finalizers - pim/finalizers + - prefixsets/finalizers - snmp/finalizers - syslogs/finalizers - users/finalizers @@ -99,6 +101,7 @@ rules: - ntp/status - ospf/status - pim/status + - prefixsets/status - snmp/status - syslogs/status - users/status diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 6cbe9356..fef95924 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -21,4 +21,5 @@ resources: - cisco/nx/v1alpha1_system.yaml - cisco/nx/v1alpha1_managementaccessconfig.yaml - v1alpha1_evi.yaml +- v1alpha1_prefixset.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/v1alpha1_prefixset.yaml b/config/samples/v1alpha1_prefixset.yaml new file mode 100644 index 00000000..8bfd807e --- /dev/null +++ b/config/samples/v1alpha1_prefixset.yaml @@ -0,0 +1,17 @@ +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: PrefixSet +metadata: + labels: + app.kubernetes.io/name: network-operator + app.kubernetes.io/managed-by: kustomize + name: ccloud-prefixset +spec: + deviceRef: + name: leaf1 + name: CCLOUD + entries: + - sequence: 10 + prefix: 10.3.192.0/21 + maskLengthRange: + min: 21 + max: 24 diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index d6d76a63..72b88cd0 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -24,6 +24,26 @@ webhooks: resources: - interfaces sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-networking-metal-ironcore-dev-v1alpha1-prefixset + failurePolicy: Fail + name: prefixset-v1alpha1.kb.io + rules: + - apiGroups: + - networking.metal.ironcore.dev + apiVersions: + - v1alpha1 + operations: + - CREATE + - UPDATE + resources: + - prefixsets + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/internal/controller/core/prefixset_controller.go b/internal/controller/core/prefixset_controller.go new file mode 100644 index 00000000..75b182c2 --- /dev/null +++ b/internal/controller/core/prefixset_controller.go @@ -0,0 +1,251 @@ +// 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" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + "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" +) + +// PrefixSetReconciler reconciles a PrefixSet object +type PrefixSetReconciler 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 prefixset. + Provider provider.ProviderFunc +} + +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=prefixsets,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=prefixsets/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=networking.metal.ironcore.dev,resources=prefixsets/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 *PrefixSetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (_ ctrl.Result, reterr error) { + log := ctrl.LoggerFrom(ctx) + log.Info("Reconciling resource") + + obj := new(v1alpha1.PrefixSet) + 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.PrefixSetProvider) + 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.PrefixSetProvider", + }) { + 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 := &prefixSetScope{ + Device: device, + PrefixSet: 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 +} + +// SetupWithManager sets up the controller with the Manager. +func (r *PrefixSetReconciler) SetupWithManager(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) + } + + return ctrl.NewControllerManagedBy(mgr). + For(&v1alpha1.PrefixSet{}). + Named("prefixset"). + WithEventFilter(filter). + Complete(r) +} + +// scope holds the different objects that are read and used during the reconcile. +type prefixSetScope struct { + Device *v1alpha1.Device + PrefixSet *v1alpha1.PrefixSet + Connection *deviceutil.Connection + ProviderConfig *provider.ProviderConfig + Provider provider.PrefixSetProvider +} + +func (r *PrefixSetReconciler) reconcile(ctx context.Context, s *prefixSetScope) (reterr error) { + if s.PrefixSet.Labels == nil { + s.PrefixSet.Labels = make(map[string]string) + } + + s.PrefixSet.Labels[v1alpha1.DeviceLabel] = s.Device.Name + + // Ensure the PrefixSet is owned by the Device. + if !controllerutil.HasControllerReference(s.PrefixSet) { + if err := controllerutil.SetOwnerReference(s.Device, s.PrefixSet, r.Scheme, controllerutil.WithBlockOwnerDeletion(true)); 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 PrefixSet is realized on the provider. + err := s.Provider.EnsurePrefixSet(ctx, &provider.PrefixSetRequest{ + PrefixSet: s.PrefixSet, + 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.PrefixSet, cond) + + return err +} + +func (r *PrefixSetReconciler) finalize(ctx context.Context, s *prefixSetScope) (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.DeletePrefixSet(ctx, &provider.PrefixSetRequest{ + PrefixSet: s.PrefixSet, + ProviderConfig: s.ProviderConfig, + }) +} diff --git a/internal/controller/core/prefixset_controller_test.go b/internal/controller/core/prefixset_controller_test.go new file mode 100644 index 00000000..4aa645ec --- /dev/null +++ b/internal/controller/core/prefixset_controller_test.go @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +var _ = Describe("PrefixSet Controller", func() { + Context("When reconciling a resource", func() { + const name = "test-prefixset" + const set = "CCLOUD" + key := client.ObjectKey{Name: name, Namespace: metav1.NamespaceDefault} + + BeforeEach(func() { + By("Creating the custom resource for the Kind Device") + 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()) + } + + By("Creating the custom resource for the Kind PrefixSet") + prefixset := &v1alpha1.PrefixSet{} + if err := k8sClient.Get(ctx, key, prefixset); errors.IsNotFound(err) { + resource := &v1alpha1.PrefixSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.PrefixSetSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: name}, + Name: set, + Entries: []v1alpha1.PrefixEntry{ + { + Sequence: 10, + Prefix: v1alpha1.MustParsePrefix("192.168.1.0/24"), + }, + { + Sequence: 20, + Prefix: v1alpha1.MustParsePrefix("10.0.0.0/8"), + }, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + var resource client.Object = &v1alpha1.PrefixSet{} + err := k8sClient.Get(ctx, key, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance PrefixSet") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + + resource = &v1alpha1.Device{} + err = k8sClient.Get(ctx, key, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance Device") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + + By("Ensuring the resource is deleted from the provider") + Eventually(func(g Gomega) { + g.Expect(testProvider.PrefixSets.Has(set)).To(BeFalse(), "Provider should not have PrefixSet configured") + }).Should(Succeed()) + }) + + It("Should successfully reconcile the resource", func() { + By("Adding a finalizer to the resource") + Eventually(func(g Gomega) { + resource := &v1alpha1.PrefixSet{} + 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.PrefixSet{} + 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.PrefixSet{} + 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.PrefixSet{} + 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.PrefixSets.Has(set)).To(BeTrue(), "Provider should have PrefixSet configured") + }).Should(Succeed()) + }) + }) +}) diff --git a/internal/controller/core/suite_test.go b/internal/controller/core/suite_test.go index 9835d185..24a78013 100644 --- a/internal/controller/core/suite_test.go +++ b/internal/controller/core/suite_test.go @@ -257,6 +257,14 @@ var _ = BeforeSuite(func() { }).SetupWithManager(ctx, k8sManager) Expect(err).NotTo(HaveOccurred()) + err = (&PrefixSetReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + Recorder: recorder, + Provider: prov, + }).SetupWithManager(k8sManager) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = k8sManager.Start(ctx) @@ -320,44 +328,47 @@ var ( _ provider.OSPFProvider = (*Provider)(nil) _ provider.VLANProvider = (*Provider)(nil) _ provider.EVPNInstanceProvider = (*Provider)(nil) + _ provider.PrefixSetProvider = (*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] + 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] } 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](), + 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](), } } @@ -667,3 +678,18 @@ func (p *Provider) DeleteEVPNInstance(_ context.Context, req *provider.EVPNInsta p.EVIs.Delete(req.EVPNInstance.Spec.VNI) return nil } + +// EnsurePrefixSet implements provider.PrefixSetProvider. +func (p *Provider) EnsurePrefixSet(_ context.Context, req *provider.PrefixSetRequest) error { + p.Lock() + defer p.Unlock() + p.PrefixSets.Insert(req.PrefixSet.Spec.Name) + return nil +} + +func (p *Provider) DeletePrefixSet(_ context.Context, req *provider.PrefixSetRequest) error { + p.Lock() + defer p.Unlock() + p.PrefixSets.Delete(req.PrefixSet.Spec.Name) + return nil +} diff --git a/internal/provider/cisco/nxos/prefix.go b/internal/provider/cisco/nxos/prefix.go new file mode 100644 index 00000000..ea03c321 --- /dev/null +++ b/internal/provider/cisco/nxos/prefix.go @@ -0,0 +1,47 @@ +// 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/internal/provider/cisco/gnmiext/v2" +) + +var _ gnmiext.Configurable = (*PrefixList)(nil) + +type PrefixList struct { + Name string `json:"name"` + EntItems struct { + EntryList gnmiext.List[int32, *PrefixEntry] `json:"Entry-list"` + } `json:"ent-items"` + // Is6 indicates whether this is an IPv6 prefix list. This field is not serialized to JSON + // and is only used internally to determine the correct XPath for the prefix list. + Is6 bool `json:"-"` +} + +func (*PrefixList) IsListItem() {} + +func (p *PrefixList) XPath() string { + if p.Is6 { + return "System/rpm-items/pfxlistv6-items/RuleV6-list[name=" + p.Name + "]" + } + return "System/rpm-items/pfxlistv4-items/RuleV4-list[name=" + p.Name + "]" +} + +type PrefixEntry struct { + Action Action `json:"action"` + Criteria Criteria `json:"criteria"` + FromPfxLen int8 `json:"fromPfxLen"` + Order int32 `json:"order"` + Pfx string `json:"pfx"` + ToPfxLen int8 `json:"toPfxLen"` +} + +func (e *PrefixEntry) Key() int32 { return e.Order } + +type Criteria string + +const ( + CriteriaExact Criteria = "exact" + CriteriaInexact Criteria = "inexact" +) diff --git a/internal/provider/cisco/nxos/prefix_test.go b/internal/provider/cisco/nxos/prefix_test.go new file mode 100644 index 00000000..5fd125a5 --- /dev/null +++ b/internal/provider/cisco/nxos/prefix_test.go @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package nxos + +func init() { + p := &PrefixList{} + p.Name = "TEST" + p.EntItems.EntryList.Set(&PrefixEntry{ + Order: 10, + Action: ActionPermit, + Criteria: CriteriaInexact, + Pfx: "10.0.0.0/8", + FromPfxLen: 24, + ToPfxLen: 24, + }) + Register("prefix", p) +} diff --git a/internal/provider/cisco/nxos/provider.go b/internal/provider/cisco/nxos/provider.go index d3f29aa6..164ea0bc 100644 --- a/internal/provider/cisco/nxos/provider.go +++ b/internal/provider/cisco/nxos/provider.go @@ -51,6 +51,7 @@ var ( _ provider.OSPFProvider = (*Provider)(nil) _ provider.PIMProvider = (*Provider)(nil) _ provider.SNMPProvider = (*Provider)(nil) + _ provider.PrefixSetProvider = (*Provider)(nil) _ provider.SyslogProvider = (*Provider)(nil) _ provider.UserProvider = (*Provider)(nil) _ provider.VLANProvider = (*Provider)(nil) @@ -1590,6 +1591,36 @@ func (p *Provider) DeletePIM(ctx context.Context, _ *provider.DeletePIMRequest) return p.client.Delete(ctx, new(StaticRPItems), new(AnycastPeerItems), new(PIMIfItems)) } +func (p *Provider) EnsurePrefixSet(ctx context.Context, req *provider.PrefixSetRequest) error { + s := new(PrefixList) + s.Name = req.PrefixSet.Spec.Name + s.Is6 = req.PrefixSet.Is6() + for _, entry := range req.PrefixSet.Spec.Entries { + e := new(PrefixEntry) + e.Action = ActionPermit + e.Criteria = CriteriaExact + e.Order = entry.Sequence + e.Pfx = entry.Prefix.String() + bits := int8(entry.Prefix.Bits()) // #nosec G115 + if entry.MaskLengthRange != nil && (entry.MaskLengthRange.Min != bits || entry.MaskLengthRange.Max != bits) { + e.Criteria = CriteriaInexact + e.ToPfxLen = entry.MaskLengthRange.Max + if entry.MaskLengthRange.Min != bits { + e.FromPfxLen = entry.MaskLengthRange.Min + } + } + s.EntItems.EntryList.Set(e) + } + return p.client.Update(ctx, s) +} + +func (p *Provider) DeletePrefixSet(ctx context.Context, req *provider.PrefixSetRequest) error { + s := new(PrefixList) + s.Name = req.PrefixSet.Spec.Name + s.Is6 = req.PrefixSet.Is6() + return p.client.Delete(ctx, s) +} + func (p *Provider) EnsureUser(ctx context.Context, req *provider.EnsureUserRequest) error { u := new(User) u.AllowExpired = "no" diff --git a/internal/provider/cisco/nxos/testdata/prefix.json b/internal/provider/cisco/nxos/testdata/prefix.json new file mode 100644 index 00000000..8820a862 --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/prefix.json @@ -0,0 +1,23 @@ +{ + "rpm-items": { + "pfxlistv4-items": { + "RuleV4-list": [ + { + "name": "TEST", + "ent-items": { + "Entry-list": [ + { + "action": "permit", + "criteria": "inexact", + "fromPfxLen": 24, + "order": 10, + "pfx": "10.0.0.0/8", + "toPfxLen": 24 + } + ] + } + } + ] + } + } +} diff --git a/internal/provider/cisco/nxos/testdata/prefix.json.txt b/internal/provider/cisco/nxos/testdata/prefix.json.txt new file mode 100644 index 00000000..2c26984b --- /dev/null +++ b/internal/provider/cisco/nxos/testdata/prefix.json.txt @@ -0,0 +1 @@ +ip prefix-list TEST seq 10 permit 10.0.0.0/8 eq 24 diff --git a/internal/provider/provider.go b/internal/provider/provider.go index d0c59822..38e39530 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -488,6 +488,21 @@ type EVPNInstanceRequest struct { VLAN *v1alpha1.VLAN } +// PrefixSetProvider is the interface for the realization of the PrefixSet objects over different providers. +type PrefixSetProvider interface { + Provider + + // EnsurePrefixSet call is responsible for PrefixSet realization on the provider. + EnsurePrefixSet(context.Context, *PrefixSetRequest) error + // DeletePrefixSet call is responsible for PrefixSet deletion on the provider. + DeletePrefixSet(context.Context, *PrefixSetRequest) error +} + +type PrefixSetRequest struct { + PrefixSet *v1alpha1.PrefixSet + ProviderConfig *ProviderConfig +} + var mu sync.RWMutex // ProviderFunc returns a new [Provider] instance. diff --git a/internal/webhook/core/v1alpha1/prefixset_webhook.go b/internal/webhook/core/v1alpha1/prefixset_webhook.go new file mode 100644 index 00000000..6a754982 --- /dev/null +++ b/internal/webhook/core/v1alpha1/prefixset_webhook.go @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + "context" + "errors" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +// log is for logging in this package. +var prefixsetlog = logf.Log.WithName("prefixset-resource") + +// SetupPrefixSetWebhookWithManager registers the webhook for PrefixSets in the manager. +func SetupPrefixSetWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(&v1alpha1.PrefixSet{}). + WithValidator(&PrefixSetCustomValidator{}). + Complete() +} + +// +kubebuilder:webhook:path=/validate-networking-metal-ironcore-dev-v1alpha1-prefixset,mutating=false,failurePolicy=Fail,sideEffects=None,groups=networking.metal.ironcore.dev,resources=prefixsets,verbs=create;update,versions=v1alpha1,name=prefixset-v1alpha1.kb.io,admissionReviewVersions=v1 + +// PrefixSetCustomValidator struct is responsible for validating the PrefixSet resource +// when it is created, updated, or deleted. +type PrefixSetCustomValidator struct{} + +var _ webhook.CustomValidator = &PrefixSetCustomValidator{} + +// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type PrefixSet. +func (v *PrefixSetCustomValidator) ValidateCreate(_ context.Context, obj runtime.Object) (admission.Warnings, error) { + ps, ok := obj.(*v1alpha1.PrefixSet) + if !ok { + return nil, fmt.Errorf("expected a PrefixSets object but got %T", obj) + } + + prefixsetlog.Info("Validation for PrefixSets upon creation", "name", ps.GetName()) + + return nil, validatePrefixSetSpec(ps) +} + +// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type PrefixSet. +func (v *PrefixSetCustomValidator) ValidateUpdate(_ context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) { + prev, ok := oldObj.(*v1alpha1.PrefixSet) + if !ok { + return nil, fmt.Errorf("expected a PrefixSets object for the oldObj but got %T", oldObj) + } + + curr, ok := newObj.(*v1alpha1.PrefixSet) + if !ok { + return nil, fmt.Errorf("expected a PrefixSets object for the newObj but got %T", newObj) + } + + prefixsetlog.Info("Validation for PrefixSets upon update", "name", curr.GetName()) + + if err := validatePrefixSetSpec(curr); err != nil { + return nil, err + } + + if len(prev.Spec.Entries) > 0 && prev.Is6() != curr.Is6() { + return nil, errors.New("cannot change IP family of a PrefixSet once created") + } + + return nil, nil +} + +// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type PrefixSet. +func (v *PrefixSetCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) { + return nil, nil +} + +// validatePrefixSetSpec performs validation on the PrefixSet spec. +func validatePrefixSetSpec(ps *v1alpha1.PrefixSet) error { + var is6 bool + var errAgg []error + for i, ent := range ps.Spec.Entries { + if i > 0 && ent.Prefix.Addr().Is6() != is6 { + errAgg = append(errAgg, errors.New("all prefixes in a PrefixSet must be of the same IP family")) + } + is6 = ent.Prefix.Addr().Is6() + if ent.MaskLengthRange != nil { + bits := ent.Prefix.Bits() + rmin := int(ent.MaskLengthRange.Min) + rmax := int(ent.MaskLengthRange.Max) + + if rmin < bits { + errAgg = append(errAgg, fmt.Errorf("entry %d: mask length range min %d is invalid for prefix %s", i, rmin, ent.Prefix.String())) + } + if rmax < bits { + errAgg = append(errAgg, fmt.Errorf("entry %d: mask length range max %d is invalid for prefix %s", i, rmax, ent.Prefix.String())) + } + if rmin > rmax { + errAgg = append(errAgg, fmt.Errorf("entry %d: mask length range min %d cannot be greater than max %d", i, rmin, rmax)) + } + const maxBits = 32 + if !is6 && rmin > maxBits { + errAgg = append(errAgg, fmt.Errorf("entry %d: mask length range min %d exceeds maximum %d bits for IPv4", i, rmin, maxBits)) + } + if !is6 && rmax > maxBits { + errAgg = append(errAgg, fmt.Errorf("entry %d: mask length range max %d exceeds maximum %d bits for IPv4", i, rmax, maxBits)) + } + } + } + return errors.Join(errAgg...) +} diff --git a/internal/webhook/core/v1alpha1/prefixset_webhook_test.go b/internal/webhook/core/v1alpha1/prefixset_webhook_test.go new file mode 100644 index 00000000..9d79f614 --- /dev/null +++ b/internal/webhook/core/v1alpha1/prefixset_webhook_test.go @@ -0,0 +1,270 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha1 + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +var _ = Describe("PrefixSet Webhook", func() { + var ( + ctx context.Context + obj *v1alpha1.PrefixSet + oldObj *v1alpha1.PrefixSet + validator PrefixSetCustomValidator + ) + + BeforeEach(func() { + ctx = context.Background() + obj = &v1alpha1.PrefixSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-prefix-set", + Namespace: "default", + }, + Spec: v1alpha1.PrefixSetSpec{ + DeviceRef: v1alpha1.LocalObjectReference{Name: "test-device"}, + Name: "TEST", + }, + } + oldObj = obj.DeepCopy() + validator = PrefixSetCustomValidator{} + }) + + Describe("ValidateCreate", func() { + It("should allow creation with valid IPv4 prefixes", func() { + obj.Spec.Entries = []v1alpha1.PrefixEntry{ + { + Sequence: 10, + Prefix: v1alpha1.MustParsePrefix("192.168.1.0/24"), + }, + { + Sequence: 20, + Prefix: v1alpha1.MustParsePrefix("10.0.0.0/8"), + }, + } + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should allow creation with valid IPv6 prefixes", func() { + obj.Spec.Entries = []v1alpha1.PrefixEntry{ + { + Sequence: 10, + Prefix: v1alpha1.MustParsePrefix("2001:db8::/32"), + }, + { + Sequence: 20, + Prefix: v1alpha1.MustParsePrefix("fe80::/10"), + }, + } + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should allow creation with mask length ranges", func() { + obj.Spec.Entries = []v1alpha1.PrefixEntry{ + { + Sequence: 10, + Prefix: v1alpha1.MustParsePrefix("192.168.0.0/16"), + MaskLengthRange: &v1alpha1.MaskLengthRange{ + Min: 16, + Max: 24, + }, + }, + } + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should reject creation with mixed IP families", func() { + obj.Spec.Entries = []v1alpha1.PrefixEntry{ + { + Sequence: 10, + Prefix: v1alpha1.MustParsePrefix("192.168.1.0/24"), + }, + { + Sequence: 20, + Prefix: v1alpha1.MustParsePrefix("2001:db8::/32"), + }, + } + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).To(HaveOccurred()) + }) + + It("should reject creation with invalid mask length range min less than prefix bits", func() { + obj.Spec.Entries = []v1alpha1.PrefixEntry{ + { + Sequence: 10, + Prefix: v1alpha1.MustParsePrefix("192.168.0.0/24"), + MaskLengthRange: &v1alpha1.MaskLengthRange{ + Min: 16, // Less than prefix bits (24) + Max: 28, + }, + }, + } + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).To(HaveOccurred()) + }) + + It("should reject creation with invalid mask length range max less than prefix bits", func() { + obj.Spec.Entries = []v1alpha1.PrefixEntry{ + { + Sequence: 10, + Prefix: v1alpha1.MustParsePrefix("192.168.0.0/24"), + MaskLengthRange: &v1alpha1.MaskLengthRange{ + Min: 24, + Max: 20, // Less than prefix bits (24) + }, + }, + } + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).To(HaveOccurred()) + }) + + It("should reject creation with min greater than max in mask length range", func() { + obj.Spec.Entries = []v1alpha1.PrefixEntry{ + { + Sequence: 10, + Prefix: v1alpha1.MustParsePrefix("192.168.0.0/16"), + MaskLengthRange: &v1alpha1.MaskLengthRange{ + Min: 28, + Max: 24, // Min > Max + }, + }, + } + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).To(HaveOccurred()) + }) + + It("should reject creation with IPv4 mask length exceeding 32 bits", func() { + obj.Spec.Entries = []v1alpha1.PrefixEntry{ + { + Sequence: 10, + Prefix: v1alpha1.MustParsePrefix("192.168.0.0/16"), + MaskLengthRange: &v1alpha1.MaskLengthRange{ + Min: 16, + Max: 64, // Exceeds IPv4 maximum of 32 + }, + }, + } + _, err := validator.ValidateCreate(ctx, obj) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("ValidateUpdate", func() { + It("should allow update with valid IPv4 prefixes", func() { + obj.Spec.Entries = []v1alpha1.PrefixEntry{ + { + Sequence: 10, + Prefix: v1alpha1.MustParsePrefix("192.168.2.0/24"), + }, + { + Sequence: 20, + Prefix: v1alpha1.MustParsePrefix("10.1.0.0/16"), + }, + } + _, err := validator.ValidateUpdate(ctx, oldObj, obj) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should allow update with valid IPv6 prefixes", func() { + obj.Spec.Entries = []v1alpha1.PrefixEntry{ + { + Sequence: 10, + Prefix: v1alpha1.MustParsePrefix("2001:db8:1::/48"), + }, + { + Sequence: 20, + Prefix: v1alpha1.MustParsePrefix("fe80::/64"), + }, + } + _, err := validator.ValidateUpdate(ctx, oldObj, obj) + Expect(err).NotTo(HaveOccurred()) + }) + + It("should reject update with IP family change", func() { + obj.Spec.Entries = []v1alpha1.PrefixEntry{ + { + Sequence: 10, + Prefix: v1alpha1.MustParsePrefix("192.168.2.0/24"), + }, + { + Sequence: 20, + Prefix: v1alpha1.MustParsePrefix("10.1.0.0/16"), + }, + } + oldObj.Spec.Entries = []v1alpha1.PrefixEntry{ + { + Sequence: 10, + Prefix: v1alpha1.MustParsePrefix("2001:db8:1::/48"), + }, + { + Sequence: 20, + Prefix: v1alpha1.MustParsePrefix("fe80::/64"), + }, + } + _, err := validator.ValidateUpdate(ctx, oldObj, obj) + Expect(err).To(HaveOccurred()) + }) + + It("should reject update with mixed IP families", func() { + obj.Spec.Entries = []v1alpha1.PrefixEntry{ + { + Sequence: 10, + Prefix: v1alpha1.MustParsePrefix("192.168.1.0/24"), + }, + { + Sequence: 20, + Prefix: v1alpha1.MustParsePrefix("2001:db8::/32"), + }, + } + _, err := validator.ValidateUpdate(ctx, oldObj, obj) + Expect(err).To(HaveOccurred()) + }) + + It("should reject update with invalid mask length ranges", func() { + obj.Spec.Entries = []v1alpha1.PrefixEntry{ + { + Sequence: 10, + Prefix: v1alpha1.MustParsePrefix("192.168.0.0/24"), + MaskLengthRange: &v1alpha1.MaskLengthRange{ + Min: 32, + Max: 28, // Min > Max + }, + }, + } + _, err := validator.ValidateUpdate(ctx, oldObj, obj) + Expect(err).To(HaveOccurred()) + }) + + It("should reject update with IPv4 mask length exceeding 32 bits", func() { + obj.Spec.Entries = []v1alpha1.PrefixEntry{ + { + Sequence: 10, + Prefix: v1alpha1.MustParsePrefix("10.0.0.0/8"), + MaskLengthRange: &v1alpha1.MaskLengthRange{ + Min: 8, + Max: 64, // Exceeds IPv4 maximum of 32 + }, + }, + } + _, err := validator.ValidateUpdate(ctx, oldObj, obj) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("ValidateDelete", func() { + It("should always allow deletion", func() { + _, err := validator.ValidateDelete(ctx, obj) + Expect(err).NotTo(HaveOccurred()) + }) + }) +}) diff --git a/internal/webhook/core/v1alpha1/webhook_suite_test.go b/internal/webhook/core/v1alpha1/webhook_suite_test.go index 4a72569f..daed7d9e 100644 --- a/internal/webhook/core/v1alpha1/webhook_suite_test.go +++ b/internal/webhook/core/v1alpha1/webhook_suite_test.go @@ -101,6 +101,9 @@ var _ = BeforeSuite(func() { err = SetupInterfaceWebhookWithManager(mgr) Expect(err).NotTo(HaveOccurred()) + err = SetupPrefixSetWebhookWithManager(mgr) + Expect(err).NotTo(HaveOccurred()) + // +kubebuilder:scaffold:webhook go func() {