diff --git a/PROJECT b/PROJECT index dec6475..145167f 100644 --- a/PROJECT +++ b/PROJECT @@ -53,4 +53,16 @@ resources: defaulting: true validation: true webhookVersion: v1 +- api: + crdVersion: v1 + namespaced: true + domain: cluster.x-k8s.io + group: infrastructure + kind: CloudscaleClusterTemplate + path: github.com/cloudscale-ch/cluster-api-provider-cloudscale/api/v1beta2 + version: v1beta2 + webhooks: + defaulting: true + validation: true + webhookVersion: v1 version: "3" diff --git a/api/v1beta2/cloudscaleclustertemplate_types.go b/api/v1beta2/cloudscaleclustertemplate_types.go new file mode 100644 index 0000000..f508365 --- /dev/null +++ b/api/v1beta2/cloudscaleclustertemplate_types.go @@ -0,0 +1,69 @@ +/* +Copyright 2026 cloudscale.ch. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" +) + +// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN! +// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized. + +// CloudscaleClusterTemplateSpec defines the desired state of CloudscaleClusterTemplate +type CloudscaleClusterTemplateSpec struct { + Template CloudscaleClusterTemplateResource `json:"template"` +} + +// CloudscaleClusterTemplateResource contains spec for CloudscaleClusterSpec. +type CloudscaleClusterTemplateResource struct { + // +optional + ObjectMeta clusterv1.ObjectMeta `json:"metadata,omitempty"` + Spec CloudscaleClusterSpec `json:"spec"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// CloudscaleClusterTemplate is the Schema for the cloudscaleclustertemplates API +type CloudscaleClusterTemplate struct { + metav1.TypeMeta `json:",inline"` + + // metadata is a standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitzero"` + + // spec defines the desired state of CloudscaleClusterTemplate + // +required + Spec CloudscaleClusterTemplateSpec `json:"spec"` +} + +// +kubebuilder:object:root=true + +// CloudscaleClusterTemplateList contains a list of CloudscaleClusterTemplate +type CloudscaleClusterTemplateList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []CloudscaleClusterTemplate `json:"items"` +} + +func init() { + objectTypes = append(objectTypes, + &CloudscaleClusterTemplate{}, + &CloudscaleClusterTemplateList{}, + ) +} diff --git a/api/v1beta2/zz_generated.deepcopy.go b/api/v1beta2/zz_generated.deepcopy.go index e54ff44..ef98f12 100644 --- a/api/v1beta2/zz_generated.deepcopy.go +++ b/api/v1beta2/zz_generated.deepcopy.go @@ -151,6 +151,97 @@ func (in *CloudscaleClusterStatus) DeepCopy() *CloudscaleClusterStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CloudscaleClusterTemplate) DeepCopyInto(out *CloudscaleClusterTemplate) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CloudscaleClusterTemplate. +func (in *CloudscaleClusterTemplate) DeepCopy() *CloudscaleClusterTemplate { + if in == nil { + return nil + } + out := new(CloudscaleClusterTemplate) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CloudscaleClusterTemplate) 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 *CloudscaleClusterTemplateList) DeepCopyInto(out *CloudscaleClusterTemplateList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]CloudscaleClusterTemplate, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CloudscaleClusterTemplateList. +func (in *CloudscaleClusterTemplateList) DeepCopy() *CloudscaleClusterTemplateList { + if in == nil { + return nil + } + out := new(CloudscaleClusterTemplateList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *CloudscaleClusterTemplateList) 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 *CloudscaleClusterTemplateResource) DeepCopyInto(out *CloudscaleClusterTemplateResource) { + *out = *in + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CloudscaleClusterTemplateResource. +func (in *CloudscaleClusterTemplateResource) DeepCopy() *CloudscaleClusterTemplateResource { + if in == nil { + return nil + } + out := new(CloudscaleClusterTemplateResource) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CloudscaleClusterTemplateSpec) DeepCopyInto(out *CloudscaleClusterTemplateSpec) { + *out = *in + in.Template.DeepCopyInto(&out.Template) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CloudscaleClusterTemplateSpec. +func (in *CloudscaleClusterTemplateSpec) DeepCopy() *CloudscaleClusterTemplateSpec { + if in == nil { + return nil + } + out := new(CloudscaleClusterTemplateSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CloudscaleCredentialsReference) DeepCopyInto(out *CloudscaleCredentialsReference) { *out = *in diff --git a/cmd/main.go b/cmd/main.go index b17c68a..f03145a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -258,6 +258,10 @@ func main() { setupLog.Error(err, "Failed to create webhook", "webhook", "CloudscaleMachineTemplate") os.Exit(1) } + if err := webhookv1beta2.SetupCloudscaleClusterTemplateWebhookWithManager(mgr, regionInfo); err != nil { + setupLog.Error(err, "Failed to create webhook", "webhook", "CloudscaleClusterTemplate") + os.Exit(1) + } } // +kubebuilder:scaffold:builder diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_cloudscaleclustertemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_cloudscaleclustertemplates.yaml new file mode 100644 index 0000000..3a3c920 --- /dev/null +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_cloudscaleclustertemplates.yaml @@ -0,0 +1,309 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.20.1 + name: cloudscaleclustertemplates.infrastructure.cluster.x-k8s.io +spec: + group: infrastructure.cluster.x-k8s.io + names: + kind: CloudscaleClusterTemplate + listKind: CloudscaleClusterTemplateList + plural: cloudscaleclustertemplates + singular: cloudscaleclustertemplate + scope: Namespaced + versions: + - name: v1beta2 + schema: + openAPIV3Schema: + description: CloudscaleClusterTemplate is the Schema for the cloudscaleclustertemplates + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the desired state of CloudscaleClusterTemplate + properties: + template: + description: CloudscaleClusterTemplateResource contains spec for CloudscaleClusterSpec. + properties: + metadata: + description: |- + ObjectMeta is metadata that all persisted resources must have, which includes all objects + users must create. This is a copy of customizable fields from metav1.ObjectMeta. + + ObjectMeta is embedded in `Machine.Spec`, `MachineDeployment.Template` and `MachineSet.Template`, + which are not top-level Kubernetes objects. Given that metav1.ObjectMeta has lots of special cases + and read-only fields which end up in the generated CRD validation, having it as a subset simplifies + the API and some issues that can impact user experience. + + During the [upgrade to controller-tools@v2](https://github.com/kubernetes-sigs/cluster-api/pull/1054) + for v1alpha2, we noticed a failure would occur running Cluster API test suite against the new CRDs, + specifically `spec.metadata.creationTimestamp in body must be of type string: "null"`. + The investigation showed that `controller-tools@v2` behaves differently than its previous version + when handling types from [metav1](k8s.io/apimachinery/pkg/apis/meta/v1) package. + + In more details, we found that embedded (non-top level) types that embedded `metav1.ObjectMeta` + had validation properties, including for `creationTimestamp` (metav1.Time). + The `metav1.Time` type specifies a custom json marshaller that, when IsZero() is true, returns `null` + which breaks validation because the field isn't marked as nullable. + + In future versions, controller-tools@v2 might allow overriding the type and validation for embedded + types. When that happens, this hack should be revisited. + minProperties: 1 + properties: + annotations: + additionalProperties: + type: string + description: |- + annotations is an unstructured key value map stored with a resource that may be + set by external tools to store and retrieve arbitrary metadata. They are not + queryable and should be preserved when modifying objects. + More info: http://kubernetes.io/docs/user-guide/annotations + type: object + labels: + additionalProperties: + type: string + description: |- + labels is a map of string keys and values that can be used to organize and categorize + (scope and select) objects. May match selectors of replication controllers + and services. + More info: http://kubernetes.io/docs/user-guide/labels + type: object + type: object + spec: + description: CloudscaleClusterSpec defines the desired state of + CloudscaleCluster + properties: + controlPlaneEndpoint: + description: |- + ControlPlaneEndpoint represents the endpoint to communicate with the control plane. + This is set automatically from the load balancer's VIP address or floating IP. + minProperties: 1 + properties: + host: + description: host is the hostname on which the API server + is serving. + maxLength: 512 + minLength: 1 + type: string + port: + description: port is the port on which the API server + is serving. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + type: object + controlPlaneLoadBalancer: + description: ControlPlaneLoadBalancer configures the load + balancer for the control plane. + properties: + algorithm: + default: round_robin + description: Algorithm is the load balancing algorithm. + enum: + - round_robin + - least_connections + - source_ip + type: string + apiServerPort: + default: 6443 + description: APIServerPort is the port for the Kubernetes + API server. + format: int32 + maximum: 65535 + minimum: 1 + type: integer + enabled: + default: true + description: |- + Enabled controls whether a load balancer is created for the control plane. + Set to false for external control planes (e.g., hosted control plane) where the endpoint + is provided externally, or when using a floating IP without a load balancer. + type: boolean + flavor: + default: lb-standard + description: Flavor is the load balancer flavor (size). + type: string + healthMonitor: + description: HealthMonitor configures the load balancer + health monitor. + properties: + delayS: + default: 5 + description: DelayS is the interval between health + checks in seconds. + maximum: 300 + minimum: 1 + type: integer + downThreshold: + default: 3 + description: DownThreshold is the number of failed + checks to mark unhealthy. + maximum: 10 + minimum: 1 + type: integer + timeoutS: + default: 3 + description: TimeoutS is the health check timeout + in seconds. + maximum: 300 + minimum: 1 + type: integer + upThreshold: + default: 2 + description: UpThreshold is the number of successful + checks to mark healthy. + maximum: 10 + minimum: 1 + type: integer + type: object + network: + description: |- + Network places the LB VIP on a private network (internal LB). + References spec.networks[].name. Omit for a public LB. + When multiple networks are defined this field is required so the LB + pool members can be registered against a specific subnet. + type: string + type: object + credentialsRef: + description: CredentialsRef references the Secret containing + the cloudscale.ch API token. + properties: + name: + description: Name is the name of the Secret. + type: string + namespace: + description: Namespace is the namespace of the Secret. + Defaults to the cluster namespace. + type: string + required: + - name + type: object + floatingIP: + description: |- + FloatingIP configures a floating IP for a stable control plane endpoint. + When the load balancer is enabled (recommended), the floating IP is assigned + to the LB, providing a stable IP that survives LB recreation. + When using a pre-existing floating IP without a load balancer, the user must + configure a dummy interface on the control plane servers (see cloudscale.ch docs) + and ensure the control-plane machine template includes a public interface + ({type: public}), as cloudscale.ch requires a public IPv4 address to assign + a floating IP to a server. + Managed floating IPs require the load balancer to be enabled. + Floating IPs cannot be attached to a load balancer with a private VIP + (i.e. one whose ControlPlaneLoadBalancer.Network is set). + properties: + address: + description: |- + Address references a pre-existing floating IP by its address. + cloudscale.ch identifies floating IPs by their IP address rather than by UUID. + The floating IP is not deleted on cluster teardown. + Mutually exclusive with IPFamily. + type: string + ipFamily: + allOf: + - enum: + - IPv4 + - IPv6 + - DualStack + - enum: + - IPv4 + - IPv6 + description: |- + IPFamily creates a new floating IP with this IP version. + A floating IP is a single address, so DualStack is not valid here. + Mutually exclusive with Address. + type: string + type: object + networks: + description: |- + Networks define the private networks for this cluster. + Referenced by name from machine interface specs and LB config. + If empty, defaults to a single managed network named after the cluster. + items: + description: |- + NetworkSpec defines a private network for the cluster. + Exactly one of UUID or CIDR must be specified. + properties: + cidr: + description: |- + CIDR defines the subnet for a controller-managed network. + The network and subnet are created and deleted by CAPCS. + Mutually exclusive with UUID. + type: string + gatewayAddress: + description: |- + GatewayAddress is the gateway IP address for the subnet. + Only applicable when CIDR is set (managed network). + By default, no gateway is configured on the subnet. This ensures + that outbound internet traffic uses the public network interface. + Set this to a specific IP address (e.g., "10.0.0.1") only if you have configured + a NAT gateway or similar infrastructure on the private network. + type: string + name: + description: |- + Name identifies this network within the cluster. + Used to reference this network from machine interface specs and LB config. + maxLength: 63 + pattern: ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ + type: string + uuid: + description: |- + UUID references a pre-existing cloudscale.ch network. + The network is not deleted on cluster teardown. + Mutually exclusive with CIDR. + type: string + required: + - name + type: object + type: array + x-kubernetes-list-map-keys: + - name + x-kubernetes-list-type: map + region: + description: Region is the cloudscale.ch region (e.g., "rma", + "lpg"). + enum: + - rma + - lpg + type: string + zone: + description: |- + Zone is the cloudscale.ch zone (e.g., "rma1", "lpg1"). + Defaults to region + "1" if not specified. + type: string + required: + - credentialsRef + - region + type: object + required: + - spec + type: object + required: + - template + 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 27990a4..a0f4843 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -5,6 +5,7 @@ resources: - bases/infrastructure.cluster.x-k8s.io_cloudscaleclusters.yaml - bases/infrastructure.cluster.x-k8s.io_cloudscalemachines.yaml - bases/infrastructure.cluster.x-k8s.io_cloudscalemachinetemplates.yaml +- bases/infrastructure.cluster.x-k8s.io_cloudscaleclustertemplates.yaml # +kubebuilder:scaffold:crdkustomizeresource #patches: @@ -18,4 +19,4 @@ resources: #- kustomizeconfig.yaml labels: - pairs: - cluster.x-k8s.io/v1beta2: v1beta2 \ No newline at end of file + cluster.x-k8s.io/v1beta2: v1beta2 diff --git a/config/samples/infrastructure_v1beta2_cloudscaleclustertemplate.yaml b/config/samples/infrastructure_v1beta2_cloudscaleclustertemplate.yaml new file mode 100644 index 0000000..0e014a5 --- /dev/null +++ b/config/samples/infrastructure_v1beta2_cloudscaleclustertemplate.yaml @@ -0,0 +1,9 @@ +apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 +kind: CloudscaleClusterTemplate +metadata: + labels: + app.kubernetes.io/name: cluster-api-provider-cloudscale + app.kubernetes.io/managed-by: kustomize + name: cloudscaleclustertemplate-sample +spec: + # TODO(user): Add fields here diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index a837e1b..8d08230 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -3,4 +3,5 @@ resources: - infrastructure_v1beta2_cloudscalecluster.yaml - infrastructure_v1beta2_cloudscalemachine.yaml - infrastructure_v1beta2_cloudscalemachinetemplate.yaml +- infrastructure_v1beta2_cloudscaleclustertemplate.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 9b5442e..5b76240 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -24,6 +24,26 @@ webhooks: resources: - cloudscaleclusters sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /mutate-infrastructure-cluster-x-k8s-io-v1beta2-cloudscaleclustertemplate + failurePolicy: Fail + name: mcloudscaleclustertemplate-v1beta2.kb.io + rules: + - apiGroups: + - infrastructure.cluster.x-k8s.io + apiVersions: + - v1beta2 + operations: + - CREATE + - UPDATE + resources: + - cloudscaleclustertemplates + sideEffects: None - admissionReviewVersions: - v1 clientConfig: @@ -90,6 +110,26 @@ webhooks: resources: - cloudscaleclusters sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-infrastructure-cluster-x-k8s-io-v1beta2-cloudscaleclustertemplate + failurePolicy: Fail + name: vcloudscaleclustertemplate-v1beta2.kb.io + rules: + - apiGroups: + - infrastructure.cluster.x-k8s.io + apiVersions: + - v1beta2 + operations: + - CREATE + - UPDATE + resources: + - cloudscaleclustertemplates + sideEffects: None - admissionReviewVersions: - v1 clientConfig: diff --git a/internal/webhook/v1beta2/cloudscalecluster_webhook.go b/internal/webhook/v1beta2/cloudscalecluster_webhook.go index 698989d..829499e 100644 --- a/internal/webhook/v1beta2/cloudscalecluster_webhook.go +++ b/internal/webhook/v1beta2/cloudscalecluster_webhook.go @@ -22,6 +22,7 @@ import ( "net" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/validation/field" "k8s.io/utils/ptr" @@ -65,55 +66,59 @@ const defaultSubnetCIDR = "172.18.0.0/24" func (d *CloudscaleClusterCustomDefaulter) Default(_ context.Context, cluster *infrastructurev1beta2.CloudscaleCluster) error { cloudscaleclusterlog.Info("Defaulting for CloudscaleCluster", "name", cluster.GetName()) + clusterSpecDefault(&cluster.Spec, d.RegionInfo, cluster.ObjectMeta) + + return nil +} + +func clusterSpecDefault(spec *infrastructurev1beta2.CloudscaleClusterSpec, regionInfo *cloudscale.RegionInfo, objectMeta metav1.ObjectMeta) { // Default zone to region's default zone if not set - if cluster.Spec.Zone == "" { - cluster.Spec.Zone = d.RegionInfo.GetDefaultZoneForRegion(cluster.Spec.Region) + if spec.Zone == "" { + spec.Zone = regionInfo.GetDefaultZoneForRegion(spec.Region) } // Default networks: if empty, create one managed network named after the cluster - if len(cluster.Spec.Networks) == 0 { - cluster.Spec.Networks = []infrastructurev1beta2.NetworkSpec{ + if len(spec.Networks) == 0 { + spec.Networks = []infrastructurev1beta2.NetworkSpec{ { - Name: cluster.Name, + Name: objectMeta.Name, CIDR: defaultSubnetCIDR, }, } } // Default load balancer settings - if cluster.Spec.ControlPlaneLoadBalancer.Enabled == nil { - cluster.Spec.ControlPlaneLoadBalancer.Enabled = ptr.To(true) + if spec.ControlPlaneLoadBalancer.Enabled == nil { + spec.ControlPlaneLoadBalancer.Enabled = ptr.To(true) } - if cluster.Spec.ControlPlaneLoadBalancer.Algorithm == "" { - cluster.Spec.ControlPlaneLoadBalancer.Algorithm = "round_robin" + if spec.ControlPlaneLoadBalancer.Algorithm == "" { + spec.ControlPlaneLoadBalancer.Algorithm = "round_robin" } - if cluster.Spec.ControlPlaneLoadBalancer.Flavor == "" { - cluster.Spec.ControlPlaneLoadBalancer.Flavor = "lb-standard" + if spec.ControlPlaneLoadBalancer.Flavor == "" { + spec.ControlPlaneLoadBalancer.Flavor = "lb-standard" } - if cluster.Spec.ControlPlaneLoadBalancer.APIServerPort == 0 { - cluster.Spec.ControlPlaneLoadBalancer.APIServerPort = 6443 + if spec.ControlPlaneLoadBalancer.APIServerPort == 0 { + spec.ControlPlaneLoadBalancer.APIServerPort = 6443 } - if cluster.Spec.ControlPlaneLoadBalancer.HealthMonitor.DelayS == 0 { - cluster.Spec.ControlPlaneLoadBalancer.HealthMonitor.DelayS = 5 + if spec.ControlPlaneLoadBalancer.HealthMonitor.DelayS == 0 { + spec.ControlPlaneLoadBalancer.HealthMonitor.DelayS = 5 } - if cluster.Spec.ControlPlaneLoadBalancer.HealthMonitor.TimeoutS == 0 { - cluster.Spec.ControlPlaneLoadBalancer.HealthMonitor.TimeoutS = 3 + if spec.ControlPlaneLoadBalancer.HealthMonitor.TimeoutS == 0 { + spec.ControlPlaneLoadBalancer.HealthMonitor.TimeoutS = 3 } - if cluster.Spec.ControlPlaneLoadBalancer.HealthMonitor.UpThreshold == 0 { - cluster.Spec.ControlPlaneLoadBalancer.HealthMonitor.UpThreshold = 2 + if spec.ControlPlaneLoadBalancer.HealthMonitor.UpThreshold == 0 { + spec.ControlPlaneLoadBalancer.HealthMonitor.UpThreshold = 2 } - if cluster.Spec.ControlPlaneLoadBalancer.HealthMonitor.DownThreshold == 0 { - cluster.Spec.ControlPlaneLoadBalancer.HealthMonitor.DownThreshold = 3 + if spec.ControlPlaneLoadBalancer.HealthMonitor.DownThreshold == 0 { + spec.ControlPlaneLoadBalancer.HealthMonitor.DownThreshold = 3 } // Default floating IP: if set but both fields empty, default to IPv4 - if cluster.Spec.FloatingIP != nil && cluster.Spec.FloatingIP.IPFamily == nil && cluster.Spec.FloatingIP.Address == "" { + if spec.FloatingIP != nil && spec.FloatingIP.IPFamily == nil && spec.FloatingIP.Address == "" { ipv4 := infrastructurev1beta2.IPFamilyIPv4 - cluster.Spec.FloatingIP.IPFamily = &ipv4 + spec.FloatingIP.IPFamily = &ipv4 } - - return nil } // +kubebuilder:webhook:path=/validate-infrastructure-cluster-x-k8s-io-v1beta2-cloudscalecluster,mutating=false,failurePolicy=fail,sideEffects=None,groups=infrastructure.cluster.x-k8s.io,resources=cloudscaleclusters,verbs=create;update,versions=v1beta2,name=vcloudscalecluster-v1beta2.kb.io,admissionReviewVersions=v1 @@ -131,46 +136,51 @@ type CloudscaleClusterCustomValidator struct { func (v *CloudscaleClusterCustomValidator) ValidateCreate(_ context.Context, cluster *infrastructurev1beta2.CloudscaleCluster) (admission.Warnings, error) { cloudscaleclusterlog.Info("Validation for CloudscaleCluster upon creation", "name", cluster.GetName()) + allErrs := clusterSpecValidateCreate(cluster.Spec, v.RegionInfo, field.NewPath("spec")) + + if len(allErrs) > 0 { + return nil, apierrors.NewInvalid( + schema.GroupKind{Group: infrastructurev1beta2.SchemeGroupVersion.Group, Kind: "CloudscaleCluster"}, + cluster.Name, allErrs) + } + + return nil, nil +} + +func clusterSpecValidateCreate(spec infrastructurev1beta2.CloudscaleClusterSpec, regionInfo *cloudscale.RegionInfo, fldPath *field.Path) field.ErrorList { var allErrs field.ErrorList // Validate zone belongs to region - if cluster.Spec.Zone != "" { - if !v.RegionInfo.ZoneBelongsToRegion(cluster.Spec.Zone, cluster.Spec.Region) { + if spec.Zone != "" { + if !regionInfo.ZoneBelongsToRegion(spec.Zone, spec.Region) { allErrs = append(allErrs, field.Invalid( - field.NewPath("spec", "zone"), - cluster.Spec.Zone, - fmt.Sprintf("zone must belong to region %q", cluster.Spec.Region))) + fldPath.Child("zone"), + spec.Zone, + fmt.Sprintf("zone must belong to region %q", spec.Region))) } } // Validate networks - allErrs = append(allErrs, validateNetworks(cluster.Spec.Networks, field.NewPath("spec", "networks"))...) + allErrs = append(allErrs, validateNetworks(spec.Networks, fldPath.Child("networks"))...) // Validate LB network reference - if cluster.Spec.ControlPlaneLoadBalancer.Network != "" { + if spec.ControlPlaneLoadBalancer.Network != "" { allErrs = append(allErrs, validateNetworkReference( - cluster.Spec.ControlPlaneLoadBalancer.Network, - cluster.Spec.Networks, - field.NewPath("spec", "controlPlaneLoadBalancer", "network"), + spec.ControlPlaneLoadBalancer.Network, + spec.Networks, + fldPath.Child("controlPlaneLoadBalancer", "network"), )...) } // Validate floating IP - if cluster.Spec.FloatingIP != nil { - allErrs = append(allErrs, validateFloatingIP(cluster.Spec.FloatingIP, field.NewPath("spec", "floatingIP"))...) + if spec.FloatingIP != nil { + allErrs = append(allErrs, validateFloatingIP(spec.FloatingIP, fldPath.Child("floatingIP"))...) } - allErrs = append(allErrs, validateFloatingIPRequiresLBOrPreExisting(cluster)...) - allErrs = append(allErrs, validateFloatingIPRequiresPublicLB(cluster)...) - allErrs = append(allErrs, validateLBPoolMemberNetworkResolvable(cluster)...) - - if len(allErrs) > 0 { - return nil, apierrors.NewInvalid( - schema.GroupKind{Group: infrastructurev1beta2.SchemeGroupVersion.Group, Kind: "CloudscaleCluster"}, - cluster.Name, allErrs) - } - - return nil, nil + allErrs = append(allErrs, validateFloatingIPRequiresLBOrPreExisting(spec, fldPath)...) + allErrs = append(allErrs, validateFloatingIPRequiresPublicLB(spec, fldPath)...) + allErrs = append(allErrs, validateLBPoolMemberNetworkResolvable(spec, fldPath)...) + return allErrs } // ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type CloudscaleCluster. @@ -179,36 +189,39 @@ func (v *CloudscaleClusterCustomValidator) ValidateUpdate(_ context.Context, old var allErrs field.ErrorList + newClusterSpec := newCluster.Spec + oldClusterSpec := oldCluster.Spec + // Region is immutable - if newCluster.Spec.Region != oldCluster.Spec.Region { + if newClusterSpec.Region != oldClusterSpec.Region { allErrs = append(allErrs, field.Forbidden( field.NewPath("spec", "region"), "field is immutable after cluster creation")) } // Zone is immutable - if newCluster.Spec.Zone != oldCluster.Spec.Zone { + if newClusterSpec.Zone != oldClusterSpec.Zone { allErrs = append(allErrs, field.Forbidden( field.NewPath("spec", "zone"), "field is immutable after cluster creation")) } // Network immutability: existing networks cannot be modified or removed - allErrs = append(allErrs, validateNetworkImmutability(oldCluster.Spec.Networks, newCluster.Spec.Networks, field.NewPath("spec", "networks"))...) + allErrs = append(allErrs, validateNetworkImmutability(oldClusterSpec.Networks, newClusterSpec.Networks, field.NewPath("spec", "networks"))...) // Validate new networks (new entries must still pass creation validation) - allErrs = append(allErrs, validateNetworks(newCluster.Spec.Networks, field.NewPath("spec", "networks"))...) + allErrs = append(allErrs, validateNetworks(newClusterSpec.Networks, field.NewPath("spec", "networks"))...) // LoadBalancer Enabled is immutable - if ptr.Deref(newCluster.Spec.ControlPlaneLoadBalancer.Enabled, true) != ptr.Deref(oldCluster.Spec.ControlPlaneLoadBalancer.Enabled, true) { + if ptr.Deref(newClusterSpec.ControlPlaneLoadBalancer.Enabled, true) != ptr.Deref(oldClusterSpec.ControlPlaneLoadBalancer.Enabled, true) { allErrs = append(allErrs, field.Forbidden( field.NewPath("spec", "controlPlaneLoadBalancer", "enabled"), "field is immutable after cluster creation")) } // LB network is immutable once set - if oldCluster.Spec.ControlPlaneLoadBalancer.Network != "" && - newCluster.Spec.ControlPlaneLoadBalancer.Network != oldCluster.Spec.ControlPlaneLoadBalancer.Network { + if oldClusterSpec.ControlPlaneLoadBalancer.Network != "" && + newClusterSpec.ControlPlaneLoadBalancer.Network != oldClusterSpec.ControlPlaneLoadBalancer.Network { allErrs = append(allErrs, field.Forbidden( field.NewPath("spec", "controlPlaneLoadBalancer", "network"), "field is immutable once set")) @@ -217,28 +230,28 @@ func (v *CloudscaleClusterCustomValidator) ValidateUpdate(_ context.Context, old // Other LB fields are immutable: they're baked into the LB at creation // and changing them post-create would silently diverge from the live LB. allErrs = append(allErrs, validateLBImmutability( - &oldCluster.Spec.ControlPlaneLoadBalancer, - &newCluster.Spec.ControlPlaneLoadBalancer, + &oldClusterSpec.ControlPlaneLoadBalancer, + &newClusterSpec.ControlPlaneLoadBalancer, field.NewPath("spec", "controlPlaneLoadBalancer"), )...) // Validate LB network reference (for new or existing) - if newCluster.Spec.ControlPlaneLoadBalancer.Network != "" { + if newClusterSpec.ControlPlaneLoadBalancer.Network != "" { allErrs = append(allErrs, validateNetworkReference( - newCluster.Spec.ControlPlaneLoadBalancer.Network, - newCluster.Spec.Networks, + newClusterSpec.ControlPlaneLoadBalancer.Network, + newClusterSpec.Networks, field.NewPath("spec", "controlPlaneLoadBalancer", "network"), )...) } // ControlPlaneEndpoint is immutable once set - if oldCluster.Spec.ControlPlaneEndpoint.Host != "" { - if newCluster.Spec.ControlPlaneEndpoint.Host != oldCluster.Spec.ControlPlaneEndpoint.Host { + if oldClusterSpec.ControlPlaneEndpoint.Host != "" { + if newClusterSpec.ControlPlaneEndpoint.Host != oldClusterSpec.ControlPlaneEndpoint.Host { allErrs = append(allErrs, field.Forbidden( field.NewPath("spec", "controlPlaneEndpoint", "host"), "field is immutable once set")) } - if newCluster.Spec.ControlPlaneEndpoint.Port != oldCluster.Spec.ControlPlaneEndpoint.Port { + if newClusterSpec.ControlPlaneEndpoint.Port != oldClusterSpec.ControlPlaneEndpoint.Port { allErrs = append(allErrs, field.Forbidden( field.NewPath("spec", "controlPlaneEndpoint", "port"), "field is immutable once set")) @@ -246,16 +259,16 @@ func (v *CloudscaleClusterCustomValidator) ValidateUpdate(_ context.Context, old } // FloatingIP is immutable once set - allErrs = append(allErrs, validateFloatingIPImmutability(oldCluster.Spec.FloatingIP, newCluster.Spec.FloatingIP, field.NewPath("spec", "floatingIP"))...) + allErrs = append(allErrs, validateFloatingIPImmutability(oldClusterSpec.FloatingIP, newClusterSpec.FloatingIP, field.NewPath("spec", "floatingIP"))...) // Validate floating IP if set - if newCluster.Spec.FloatingIP != nil { - allErrs = append(allErrs, validateFloatingIP(newCluster.Spec.FloatingIP, field.NewPath("spec", "floatingIP"))...) + if newClusterSpec.FloatingIP != nil { + allErrs = append(allErrs, validateFloatingIP(newClusterSpec.FloatingIP, field.NewPath("spec", "floatingIP"))...) } - allErrs = append(allErrs, validateFloatingIPRequiresLBOrPreExisting(newCluster)...) - allErrs = append(allErrs, validateFloatingIPRequiresPublicLB(newCluster)...) - allErrs = append(allErrs, validateLBPoolMemberNetworkResolvable(newCluster)...) + allErrs = append(allErrs, validateFloatingIPRequiresLBOrPreExisting(newClusterSpec, field.NewPath("spec"))...) + allErrs = append(allErrs, validateFloatingIPRequiresPublicLB(newClusterSpec, field.NewPath("spec"))...) + allErrs = append(allErrs, validateLBPoolMemberNetworkResolvable(newClusterSpec, field.NewPath("spec"))...) if len(allErrs) > 0 { return nil, apierrors.NewInvalid( @@ -386,14 +399,14 @@ func validateNetworkImmutability(oldNetworks, newNetworks []infrastructurev1beta // the dummy interface in KubeadmControlPlane preKubeadmCommands. // With a managed FIP, the address isn't known until creation, // so the dummy interface can't be pre-configured. -func validateFloatingIPRequiresLBOrPreExisting(cluster *infrastructurev1beta2.CloudscaleCluster) field.ErrorList { +func validateFloatingIPRequiresLBOrPreExisting(spec infrastructurev1beta2.CloudscaleClusterSpec, fldPath *field.Path) field.ErrorList { var allErrs field.ErrorList - if cluster.Spec.FloatingIP != nil && - cluster.Spec.FloatingIP.Address == "" && - !ptr.Deref(cluster.Spec.ControlPlaneLoadBalancer.Enabled, true) { + if spec.FloatingIP != nil && + spec.FloatingIP.Address == "" && + !ptr.Deref(spec.ControlPlaneLoadBalancer.Enabled, true) { allErrs = append(allErrs, field.Invalid( - field.NewPath("spec", "floatingIP"), + fldPath.Child("floatingIP"), "", "managed floating IP requires the load balancer to be enabled; use a pre-existing floating IP if you need a floating IP without a load balancer")) } @@ -437,14 +450,14 @@ func validateLBImmutability(oldLB, newLB *infrastructurev1beta2.LoadBalancerSpec // validateFloatingIPRequiresPublicLB rejects a floating IP attached to a load balancer // that uses an internal-network VIP. cloudscale.ch floating IPs only attach to public LBs. -func validateFloatingIPRequiresPublicLB(cluster *infrastructurev1beta2.CloudscaleCluster) field.ErrorList { +func validateFloatingIPRequiresPublicLB(spec infrastructurev1beta2.CloudscaleClusterSpec, fldPath *field.Path) field.ErrorList { var allErrs field.ErrorList - if cluster.Spec.FloatingIP != nil && - ptr.Deref(cluster.Spec.ControlPlaneLoadBalancer.Enabled, true) && - cluster.Spec.ControlPlaneLoadBalancer.Network != "" { + if spec.FloatingIP != nil && + ptr.Deref(spec.ControlPlaneLoadBalancer.Enabled, true) && + spec.ControlPlaneLoadBalancer.Network != "" { allErrs = append(allErrs, field.Invalid( - field.NewPath("spec", "floatingIP"), + fldPath.Child("floatingIP"), "", "floating IPs cannot be attached to a load balancer with a private VIP; use a public load balancer or remove the floating IP")) } @@ -456,21 +469,21 @@ func validateFloatingIPRequiresPublicLB(cluster *infrastructurev1beta2.Cloudscal // when there are multiple networks and the LB is public. Without an explicit network the // controller would default the LB pool members' subnet to networks[0], which silently // breaks clusters whose machines join a different network. -func validateLBPoolMemberNetworkResolvable(cluster *infrastructurev1beta2.CloudscaleCluster) field.ErrorList { +func validateLBPoolMemberNetworkResolvable(spec infrastructurev1beta2.CloudscaleClusterSpec, fldPath *field.Path) field.ErrorList { var allErrs field.ErrorList - if !ptr.Deref(cluster.Spec.ControlPlaneLoadBalancer.Enabled, true) { + if !ptr.Deref(spec.ControlPlaneLoadBalancer.Enabled, true) { return nil } - if cluster.Spec.ControlPlaneLoadBalancer.Network != "" { + if spec.ControlPlaneLoadBalancer.Network != "" { return nil } - if len(cluster.Spec.Networks) <= 1 { + if len(spec.Networks) <= 1 { return nil } allErrs = append(allErrs, field.Required( - field.NewPath("spec", "controlPlaneLoadBalancer", "network"), + fldPath.Child("controlPlaneLoadBalancer", "network"), "must be set to one of spec.networks[].name when multiple networks are defined; the load balancer pool members need an explicit subnet to attach to")) return allErrs diff --git a/internal/webhook/v1beta2/cloudscaleclustertemplate_webhook.go b/internal/webhook/v1beta2/cloudscaleclustertemplate_webhook.go new file mode 100644 index 0000000..b84d6d7 --- /dev/null +++ b/internal/webhook/v1beta2/cloudscaleclustertemplate_webhook.go @@ -0,0 +1,107 @@ +/* +Copyright 2026 cloudscale.ch. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "context" + "reflect" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + infrastructurev1beta2 "github.com/cloudscale-ch/cluster-api-provider-cloudscale/api/v1beta2" + "github.com/cloudscale-ch/cluster-api-provider-cloudscale/internal/cloudscale" +) + +// nolint:unused +// log is for logging in this package. +var cloudscaleclustertemplatelog = logf.Log.WithName("cloudscaleclustertemplate-resource") + +// SetupCloudscaleClusterTemplateWebhookWithManager registers the webhook for CloudscaleClusterTemplate in the manager. +func SetupCloudscaleClusterTemplateWebhookWithManager(mgr ctrl.Manager, regionInfo *cloudscale.RegionInfo) error { + return ctrl.NewWebhookManagedBy(mgr, &infrastructurev1beta2.CloudscaleClusterTemplate{}). + WithValidator(&CloudscaleClusterTemplateCustomValidator{RegionInfo: regionInfo}). + WithDefaulter(&CloudscaleClusterTemplateCustomDefaulter{RegionInfo: regionInfo}). + Complete() +} + +// +kubebuilder:webhook:path=/mutate-infrastructure-cluster-x-k8s-io-v1beta2-cloudscaleclustertemplate,mutating=true,failurePolicy=fail,sideEffects=None,groups=infrastructure.cluster.x-k8s.io,resources=cloudscaleclustertemplates,verbs=create;update,versions=v1beta2,name=mcloudscaleclustertemplate-v1beta2.kb.io,admissionReviewVersions=v1 + +// CloudscaleClusterTemplateCustomDefaulter struct is responsible for setting default values on the custom resource of the +// Kind CloudscaleClusterTemplate when those are created or updated. +// +// NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, +// as it is used only for temporary operations and does not need to be deeply copied. +type CloudscaleClusterTemplateCustomDefaulter struct { + RegionInfo *cloudscale.RegionInfo +} + +// Default implements webhook.CustomDefaulter so a webhook will be registered for the Kind CloudscaleClusterTemplate. +func (d *CloudscaleClusterTemplateCustomDefaulter) Default(_ context.Context, clusterTemplate *infrastructurev1beta2.CloudscaleClusterTemplate) error { + cloudscaleclustertemplatelog.Info("Defaulting for CloudscaleClusterTemplate", "name", clusterTemplate.GetName()) + + clusterSpecDefault(&clusterTemplate.Spec.Template.Spec, d.RegionInfo, clusterTemplate.ObjectMeta) + + return nil +} + +// +kubebuilder:webhook:path=/validate-infrastructure-cluster-x-k8s-io-v1beta2-cloudscaleclustertemplate,mutating=false,failurePolicy=fail,sideEffects=None,groups=infrastructure.cluster.x-k8s.io,resources=cloudscaleclustertemplates,verbs=create;update,versions=v1beta2,name=vcloudscaleclustertemplate-v1beta2.kb.io,admissionReviewVersions=v1 + +// CloudscaleClusterTemplateCustomValidator struct is responsible for validating the CloudscaleClusterTemplate resource +// when it is created, updated, or deleted. +// +// NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, +// as this struct is used only for temporary operations and does not need to be deeply copied. +type CloudscaleClusterTemplateCustomValidator struct { + RegionInfo *cloudscale.RegionInfo +} + +// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type CloudscaleClusterTemplate. +func (v *CloudscaleClusterTemplateCustomValidator) ValidateCreate(_ context.Context, clusterTemplate *infrastructurev1beta2.CloudscaleClusterTemplate) (admission.Warnings, error) { + cloudscaleclustertemplatelog.Info("Validation for CloudscaleClusterTemplate upon creation", "name", clusterTemplate.GetName()) + + allErrs := clusterSpecValidateCreate(clusterTemplate.Spec.Template.Spec, v.RegionInfo, field.NewPath("spec", "template", "spec")) + + if len(allErrs) > 0 { + return nil, apierrors.NewInvalid( + schema.GroupKind{Group: infrastructurev1beta2.SchemeGroupVersion.Group, Kind: "CloudscaleClusterTemplate"}, + clusterTemplate.Name, allErrs) + } + + return nil, nil +} + +// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type CloudscaleClusterTemplate. +func (v *CloudscaleClusterTemplateCustomValidator) ValidateUpdate(_ context.Context, oldObj, newObj *infrastructurev1beta2.CloudscaleClusterTemplate) (admission.Warnings, error) { + cloudscaleclustertemplatelog.Info("Validation for CloudscaleClusterTemplate upon update", "name", newObj.GetName()) + + if !reflect.DeepEqual(newObj.Spec, oldObj.Spec) { + return nil, apierrors.NewBadRequest("CloudscaleClusterTemplate.Spec is immutable") + } + + return nil, nil +} + +// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type CloudscaleClusterTemplate. +func (v *CloudscaleClusterTemplateCustomValidator) ValidateDelete(_ context.Context, obj *infrastructurev1beta2.CloudscaleClusterTemplate) (admission.Warnings, error) { + cloudscaleclustertemplatelog.Info("Validation for CloudscaleClusterTemplate upon deletion", "name", obj.GetName()) + return nil, nil +} diff --git a/internal/webhook/v1beta2/cloudscaleclustertemplate_webhook_test.go b/internal/webhook/v1beta2/cloudscaleclustertemplate_webhook_test.go new file mode 100644 index 0000000..05ed3a5 --- /dev/null +++ b/internal/webhook/v1beta2/cloudscaleclustertemplate_webhook_test.go @@ -0,0 +1,539 @@ +/* +Copyright 2026 cloudscale.ch. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1beta2 + +import ( + "testing" + + . "github.com/onsi/gomega" + "k8s.io/utils/ptr" + + infrastructurev1beta2 "github.com/cloudscale-ch/cluster-api-provider-cloudscale/api/v1beta2" +) + +func newClusterTemplateWebhookTestObjects() ( + obj *infrastructurev1beta2.CloudscaleClusterTemplate, + oldObj *infrastructurev1beta2.CloudscaleClusterTemplate, + validator CloudscaleClusterTemplateCustomValidator, + defaulter CloudscaleClusterTemplateCustomDefaulter, +) { + obj = &infrastructurev1beta2.CloudscaleClusterTemplate{} + oldObj = &infrastructurev1beta2.CloudscaleClusterTemplate{} + validator = CloudscaleClusterTemplateCustomValidator{ + RegionInfo: newTestRegionInfo(), + } + defaulter = CloudscaleClusterTemplateCustomDefaulter{ + RegionInfo: newTestRegionInfo(), + } + return +} + +// ============================================================================ +// Tests for CloudscaleClusterTemplate Defaulting Webhook +// ============================================================================ + +func TestClusterTemplateDefaulting_ZoneFromRegion(t *testing.T) { + g := NewWithT(t) + obj, _, _, defaulter := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = "" + + g.Expect(defaulter.Default(ctx, obj)).To(Succeed()) + g.Expect(obj.Spec.Template.Spec.Zone).To(Equal(ZoneRma1)) +} + +func TestClusterTemplateDefaulting_ExplicitZoneNotOverridden(t *testing.T) { + g := NewWithT(t) + obj, _, _, defaulter := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = ZoneRma1 + + g.Expect(defaulter.Default(ctx, obj)).To(Succeed()) + g.Expect(obj.Spec.Template.Spec.Zone).To(Equal(ZoneRma1)) +} + +func TestClusterTemplateDefaulting_NetworksDefaultToClusterName(t *testing.T) { + g := NewWithT(t) + obj, _, _, defaulter := newClusterTemplateWebhookTestObjects() + obj.Name = "my-cluster" + obj.Spec.Template.Spec.Region = RegionRma + + g.Expect(defaulter.Default(ctx, obj)).To(Succeed()) + g.Expect(obj.Spec.Template.Spec.Networks).To(HaveLen(1)) + g.Expect(obj.Spec.Template.Spec.Networks[0].Name).To(Equal("my-cluster")) + g.Expect(obj.Spec.Template.Spec.Networks[0].CIDR).To(Equal(defaultSubnetCIDR)) +} + +func TestClusterTemplateDefaulting_ExplicitNetworksNotOverridden(t *testing.T) { + g := NewWithT(t) + obj, _, _, defaulter := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Networks = []infrastructurev1beta2.NetworkSpec{ + {Name: "custom", CIDR: "10.1.0.0/16"}, + } + + g.Expect(defaulter.Default(ctx, obj)).To(Succeed()) + g.Expect(obj.Spec.Template.Spec.Networks).To(HaveLen(1)) + g.Expect(obj.Spec.Template.Spec.Networks[0].Name).To(Equal("custom")) + g.Expect(obj.Spec.Template.Spec.Networks[0].CIDR).To(Equal("10.1.0.0/16")) +} + +func TestClusterTemplateDefaulting_LBEnabledToTrue(t *testing.T) { + g := NewWithT(t) + obj, _, _, defaulter := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.ControlPlaneLoadBalancer.Enabled = nil + + g.Expect(defaulter.Default(ctx, obj)).To(Succeed()) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.Enabled).To(Equal(ptr.To(true))) +} + +func TestClusterTemplateDefaulting_LBEnabledFalseNotOverridden(t *testing.T) { + g := NewWithT(t) + obj, _, _, defaulter := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.ControlPlaneLoadBalancer.Enabled = ptr.To(false) + + g.Expect(defaulter.Default(ctx, obj)).To(Succeed()) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.Enabled).To(Equal(ptr.To(false))) +} + +func TestClusterTemplateDefaulting_LBAlgorithm(t *testing.T) { + g := NewWithT(t) + obj, _, _, defaulter := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.ControlPlaneLoadBalancer.Algorithm = "" + + g.Expect(defaulter.Default(ctx, obj)).To(Succeed()) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.Algorithm).To(Equal("round_robin")) +} + +func TestClusterTemplateDefaulting_LBFlavor(t *testing.T) { + g := NewWithT(t) + obj, _, _, defaulter := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.ControlPlaneLoadBalancer.Flavor = "" + + g.Expect(defaulter.Default(ctx, obj)).To(Succeed()) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.Flavor).To(Equal("lb-standard")) +} + +func TestClusterTemplateDefaulting_APIServerPort(t *testing.T) { + g := NewWithT(t) + obj, _, _, defaulter := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.ControlPlaneLoadBalancer.APIServerPort = 0 + + g.Expect(defaulter.Default(ctx, obj)).To(Succeed()) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.APIServerPort).To(Equal(int32(6443))) +} + +func TestClusterTemplateDefaulting_HealthMonitorFields(t *testing.T) { + g := NewWithT(t) + obj, _, _, defaulter := newClusterTemplateWebhookTestObjects() + + g.Expect(defaulter.Default(ctx, obj)).To(Succeed()) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.HealthMonitor.DelayS).To(Equal(5)) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.HealthMonitor.TimeoutS).To(Equal(3)) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.HealthMonitor.UpThreshold).To(Equal(2)) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.HealthMonitor.DownThreshold).To(Equal(3)) +} + +func TestClusterTemplateDefaulting_ExplicitHealthMonitorNotOverridden(t *testing.T) { + g := NewWithT(t) + obj, _, _, defaulter := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.ControlPlaneLoadBalancer.HealthMonitor.DelayS = 10 + obj.Spec.Template.Spec.ControlPlaneLoadBalancer.HealthMonitor.TimeoutS = 7 + obj.Spec.Template.Spec.ControlPlaneLoadBalancer.HealthMonitor.UpThreshold = 5 + obj.Spec.Template.Spec.ControlPlaneLoadBalancer.HealthMonitor.DownThreshold = 8 + + g.Expect(defaulter.Default(ctx, obj)).To(Succeed()) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.HealthMonitor.DelayS).To(Equal(10)) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.HealthMonitor.TimeoutS).To(Equal(7)) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.HealthMonitor.UpThreshold).To(Equal(5)) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.HealthMonitor.DownThreshold).To(Equal(8)) +} + +func TestClusterTemplateDefaulting_FloatingIPDefaultsToIPv4(t *testing.T) { + g := NewWithT(t) + obj, _, _, defaulter := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.FloatingIP = &infrastructurev1beta2.FloatingIPSpec{} + + g.Expect(defaulter.Default(ctx, obj)).To(Succeed()) + g.Expect(obj.Spec.Template.Spec.FloatingIP.IPFamily).To(Equal(ptr.To(infrastructurev1beta2.IPFamilyIPv4))) +} + +func TestClusterTemplateDefaulting_FloatingIPExplicitNotOverridden(t *testing.T) { + g := NewWithT(t) + obj, _, _, defaulter := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.FloatingIP = &infrastructurev1beta2.FloatingIPSpec{ + Address: "1.2.3.4", + } + + g.Expect(defaulter.Default(ctx, obj)).To(Succeed()) + g.Expect(obj.Spec.Template.Spec.FloatingIP.IPFamily).To(BeNil()) + g.Expect(obj.Spec.Template.Spec.FloatingIP.Address).To(Equal("1.2.3.4")) +} + +func TestClusterTemplateDefaulting_AllDefaultsApplied(t *testing.T) { + g := NewWithT(t) + obj, _, _, defaulter := newClusterTemplateWebhookTestObjects() + obj.Name = "test-cluster" + obj.Spec.Template.Spec.Region = RegionRma + + g.Expect(defaulter.Default(ctx, obj)).To(Succeed()) + + g.Expect(obj.Spec.Template.Spec.Zone).To(Equal(ZoneRma1)) + g.Expect(obj.Spec.Template.Spec.Networks).To(HaveLen(1)) + g.Expect(obj.Spec.Template.Spec.Networks[0].Name).To(Equal("test-cluster")) + g.Expect(obj.Spec.Template.Spec.Networks[0].CIDR).To(Equal(defaultSubnetCIDR)) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.Enabled).To(Equal(ptr.To(true))) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.Algorithm).To(Equal("round_robin")) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.Flavor).To(Equal("lb-standard")) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.APIServerPort).To(Equal(int32(6443))) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.HealthMonitor.DelayS).To(Equal(5)) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.HealthMonitor.TimeoutS).To(Equal(3)) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.HealthMonitor.UpThreshold).To(Equal(2)) + g.Expect(obj.Spec.Template.Spec.ControlPlaneLoadBalancer.HealthMonitor.DownThreshold).To(Equal(3)) +} + +// ============================================================================ +// Tests for CloudscaleCluster Validating Webhook - Create +// ============================================================================ + +func TestClusterTemplateValidateCreate_ValidCluster(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = ZoneRma1 + obj.Spec.Template.Spec.Networks = []infrastructurev1beta2.NetworkSpec{ + {Name: "main", CIDR: defaultSubnetCIDR}, + } + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).NotTo(HaveOccurred()) +} + +func TestClusterTemplateValidateCreate_ZoneNotInRegion(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = "lpg1" + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.zone")) +} + +func TestClusterTemplateValidateCreate_UnknownZone(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = "xyz1" + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("spec.zone")) +} + +func TestClusterTemplateValidateCreate_EmptyZone(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = "" + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).NotTo(HaveOccurred()) +} + +func TestClusterTemplateValidateCreate_NetworkMustHaveUUIDOrCIDR(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = ZoneRma1 + obj.Spec.Template.Spec.Networks = []infrastructurev1beta2.NetworkSpec{ + {Name: "bad"}, // neither UUID nor CIDR + } + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("exactly one of uuid or cidr")) +} + +func TestClusterTemplateValidateCreate_NetworkBothUUIDAndCIDR(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = ZoneRma1 + obj.Spec.Template.Spec.Networks = []infrastructurev1beta2.NetworkSpec{ + {Name: "bad", UUID: "some-uuid", CIDR: "10.0.0.0/24"}, + } + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("exactly one of uuid or cidr")) +} + +func TestClusterTemplateValidateCreate_DuplicateNetworkNames(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = ZoneRma1 + obj.Spec.Template.Spec.Networks = []infrastructurev1beta2.NetworkSpec{ + {Name: "dup", CIDR: "10.0.0.0/24"}, + {Name: "dup", CIDR: "10.1.0.0/24"}, + } + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("Duplicate")) +} + +func TestClusterTemplateValidateCreate_GatewayWithinCIDR(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = ZoneRma1 + obj.Spec.Template.Spec.Networks = []infrastructurev1beta2.NetworkSpec{ + {Name: "main", CIDR: defaultSubnetCIDR, GatewayAddress: "172.18.0.1"}, + } + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).NotTo(HaveOccurred()) +} + +func TestClusterTemplateValidateCreate_GatewayOutsideCIDR(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = ZoneRma1 + obj.Spec.Template.Spec.Networks = []infrastructurev1beta2.NetworkSpec{ + {Name: "main", CIDR: defaultSubnetCIDR, GatewayAddress: "192.168.1.1"}, + } + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("gatewayAddress")) +} + +func TestClusterTemplateValidateCreate_InvalidGatewayIP(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = ZoneRma1 + obj.Spec.Template.Spec.Networks = []infrastructurev1beta2.NetworkSpec{ + {Name: "main", CIDR: defaultSubnetCIDR, GatewayAddress: "notanip"}, + } + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("gatewayAddress")) +} + +func TestClusterTemplateValidateCreate_GatewayOnPreExistingNetworkRejected(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = ZoneRma1 + obj.Spec.Template.Spec.Networks = []infrastructurev1beta2.NetworkSpec{ + {Name: "pre-existing", UUID: "some-uuid", GatewayAddress: "10.0.0.1"}, + } + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("gatewayAddress")) +} + +func TestClusterTemplateValidateCreate_LBNetworkReference(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = ZoneRma1 + obj.Spec.Template.Spec.Networks = []infrastructurev1beta2.NetworkSpec{ + {Name: "main", CIDR: defaultSubnetCIDR}, + } + obj.Spec.Template.Spec.ControlPlaneLoadBalancer.Network = "main" + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).NotTo(HaveOccurred()) +} + +func TestClusterTemplateValidateCreate_PublicLBWithMultipleNetworksRequiresExplicitNetwork(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = ZoneRma1 + obj.Spec.Template.Spec.Networks = []infrastructurev1beta2.NetworkSpec{ + {Name: "main", CIDR: defaultSubnetCIDR}, + {Name: "aux", CIDR: "10.1.0.0/24"}, + } + // Public LB (Network == ""), multiple networks → ambiguous which subnet + // pool members should attach to. + obj.Spec.Template.Spec.ControlPlaneLoadBalancer.Network = "" + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("controlPlaneLoadBalancer.network")) +} + +func TestClusterTemplateValidateCreate_LBNetworkReferenceInvalid(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = ZoneRma1 + obj.Spec.Template.Spec.Networks = []infrastructurev1beta2.NetworkSpec{ + {Name: "main", CIDR: defaultSubnetCIDR}, + } + obj.Spec.Template.Spec.ControlPlaneLoadBalancer.Network = "nonexistent" + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("controlPlaneLoadBalancer.network")) +} + +func TestClusterTemplateValidateCreate_FloatingIPValid(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = ZoneRma1 + obj.Spec.Template.Spec.FloatingIP = &infrastructurev1beta2.FloatingIPSpec{ + IPFamily: ptr.To(infrastructurev1beta2.IPFamilyIPv4), + } + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).NotTo(HaveOccurred()) +} + +func TestClusterTemplateValidateCreate_FloatingIPWithPrivateLBRejected(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = ZoneRma1 + obj.Spec.Template.Spec.Networks = []infrastructurev1beta2.NetworkSpec{ + {Name: "main", CIDR: defaultSubnetCIDR}, + } + obj.Spec.Template.Spec.ControlPlaneLoadBalancer.Network = "main" + obj.Spec.Template.Spec.FloatingIP = &infrastructurev1beta2.FloatingIPSpec{ + IPFamily: ptr.To(infrastructurev1beta2.IPFamilyIPv4), + } + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("floatingIP")) + g.Expect(err.Error()).To(ContainSubstring("private")) +} + +func TestClusterTemplateValidateCreate_FloatingIPBothFieldsInvalid(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = ZoneRma1 + obj.Spec.Template.Spec.FloatingIP = &infrastructurev1beta2.FloatingIPSpec{ + IPFamily: ptr.To(infrastructurev1beta2.IPFamilyIPv4), + Address: "1.2.3.4", + } + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("floatingIP")) + g.Expect(err.Error()).To(ContainSubstring("ipFamily")) + g.Expect(err.Error()).To(ContainSubstring("ip")) +} + +func TestClusterTemplateValidateCreate_PreExistingFloatingIPInvalidIP(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = ZoneRma1 + obj.Spec.Template.Spec.FloatingIP = &infrastructurev1beta2.FloatingIPSpec{ + Address: "not-an-ip", + } + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("floatingIP.ip")) +} + +func TestClusterTemplateValidateCreate_FloatingIPNeitherFieldInvalid(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = ZoneRma1 + obj.Spec.Template.Spec.FloatingIP = &infrastructurev1beta2.FloatingIPSpec{} + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("floatingIP")) +} + +func TestClusterTemplateValidateCreate_ManagedFloatingIPWithoutLBRejected(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = ZoneRma1 + obj.Spec.Template.Spec.ControlPlaneLoadBalancer.Enabled = ptr.To(false) + obj.Spec.Template.Spec.FloatingIP = &infrastructurev1beta2.FloatingIPSpec{ + IPFamily: ptr.To(infrastructurev1beta2.IPFamilyIPv4), + } + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring("managed floating IP")) +} + +func TestClusterTemplateValidateCreate_PreExistingFloatingIPWithoutLBAllowed(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = RegionRma + obj.Spec.Template.Spec.Zone = ZoneRma1 + obj.Spec.Template.Spec.ControlPlaneLoadBalancer.Enabled = ptr.To(false) + obj.Spec.Template.Spec.FloatingIP = &infrastructurev1beta2.FloatingIPSpec{ + Address: "1.2.3.4", + } + + _, err := validator.ValidateCreate(ctx, obj) + g.Expect(err).NotTo(HaveOccurred()) +} + +// ============================================================================ +// Tests for CloudscaleClusterTemplate Validating Webhook - Update +// ============================================================================ + +func TestClusterTemplateValidateUpdate_NoChanges(t *testing.T) { + g := NewWithT(t) + obj, oldObj, validator, _ := newClusterTemplateWebhookTestObjects() + + _, err := validator.ValidateUpdate(ctx, oldObj, obj) + g.Expect(err).NotTo(HaveOccurred()) +} + +func TestClusterTemplateValidateUpdate_AnyChange(t *testing.T) { + g := NewWithT(t) + obj, oldObj, validator, _ := newClusterTemplateWebhookTestObjects() + obj.Spec.Template.Spec.Region = "lpg" + + _, err := validator.ValidateUpdate(ctx, oldObj, obj) + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(Equal("CloudscaleClusterTemplate.Spec is immutable")) +} + +// ============================================================================ +// Tests for CloudscaleClusterTemplate Validating Webhook - Delete +// ============================================================================ + +func TestClusterTemplateValidateDelete_AlwaysSucceeds(t *testing.T) { + g := NewWithT(t) + obj, _, validator, _ := newClusterTemplateWebhookTestObjects() + + _, err := validator.ValidateDelete(ctx, obj) + g.Expect(err).NotTo(HaveOccurred()) +} diff --git a/internal/webhook/v1beta2/webhook_suite_test.go b/internal/webhook/v1beta2/webhook_suite_test.go index c4f528b..04f207e 100644 --- a/internal/webhook/v1beta2/webhook_suite_test.go +++ b/internal/webhook/v1beta2/webhook_suite_test.go @@ -113,19 +113,28 @@ func TestMain(m *testing.M) { os.Exit(1) } - err = SetupCloudscaleClusterWebhookWithManager(mgr, newTestRegionInfo()) + regionInfo := newTestRegionInfo() + flavorInfo := newTestFlavorInfo() + + err = SetupCloudscaleClusterWebhookWithManager(mgr, regionInfo) if err != nil { fmt.Fprintf(os.Stderr, "Failed to setup cluster webhook: %v\n", err) os.Exit(1) } - err = SetupCloudscaleMachineWebhookWithManager(mgr, newTestFlavorInfo()) + err = SetupCloudscaleClusterTemplateWebhookWithManager(mgr, regionInfo) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to setup cluster template webhook: %v\n", err) + os.Exit(1) + } + + err = SetupCloudscaleMachineWebhookWithManager(mgr, flavorInfo) if err != nil { fmt.Fprintf(os.Stderr, "Failed to setup machine webhook: %v\n", err) os.Exit(1) } - err = SetupCloudscaleMachineTemplateWebhookWithManager(mgr, newTestFlavorInfo()) + err = SetupCloudscaleMachineTemplateWebhookWithManager(mgr, flavorInfo) if err != nil { fmt.Fprintf(os.Stderr, "Failed to setup machine template webhook: %v\n", err) os.Exit(1) diff --git a/templates/clusterclass.yaml b/templates/clusterclass.yaml new file mode 100644 index 0000000..24de22f --- /dev/null +++ b/templates/clusterclass.yaml @@ -0,0 +1,253 @@ +apiVersion: cluster.x-k8s.io/v1beta2 +kind: ClusterClass +metadata: + name: quick-start +spec: + controlPlane: + templateRef: + apiVersion: controlplane.cluster.x-k8s.io/v1beta2 + kind: KubeadmControlPlaneTemplate + name: quick-start-control-plane + machineInfrastructure: + templateRef: + apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 + kind: CloudscaleMachineTemplate + name: quick-start-machinetemplate + healthCheck: + checks: + nodeStartupTimeoutSeconds: 900 + unhealthyNodeConditions: + - type: Ready + status: Unknown + timeoutSeconds: 300 + - type: Ready + status: "False" + timeoutSeconds: 300 + remediation: + triggerIf: + unhealthyLessThanOrEqualTo: 33% + infrastructure: + templateRef: + apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 + kind: CloudscaleClusterTemplate + name: quick-start-cluster + workers: + machineDeployments: + - class: worker + bootstrap: + templateRef: + apiVersion: bootstrap.cluster.x-k8s.io/v1beta2 + kind: KubeadmConfigTemplate + name: quick-start-worker-bootstraptemplate + infrastructure: + templateRef: + apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 + kind: CloudscaleMachineTemplate + name: quick-start-machinetemplate + healthCheck: + checks: + nodeStartupTimeoutSeconds: 600 + unhealthyNodeConditions: + - type: Ready + status: Unknown + timeoutSeconds: 300 + - type: Ready + status: "False" + timeoutSeconds: 300 + remediation: + triggerIf: + unhealthyInRange: "[0-2]" + variables: + - name: clusterEndpointHost + required: false + schema: + openAPIV3Schema: + type: string + default: "" + - name: clusterEndpointPort + required: false + schema: + openAPIV3Schema: + type: integer + default: 443 + - name: region + required: true + schema: + openAPIV3Schema: + type: string + default: lpg + - name: cloudscaleControlPlaneMachineFlavor + required: true + schema: + openAPIV3Schema: + type: string + default: flex-4-2 + - name: cloudscaleControlPlaneMachineImage + required: true + schema: + openAPIV3Schema: + type: string + default: custom:ubuntu-2404-kube-v1.35.2 + - name: cloudscaleWorkerMachineFlavor + required: true + schema: + openAPIV3Schema: + type: string + default: flex-4-2 + - name: cloudscaleWorkerMachineImage + required: true + schema: + openAPIV3Schema: + type: string + default: custom:ubuntu-2404-kube-v1.35.2 + - name: cloudscaleSSHKeys + required: true + schema: + openAPIV3Schema: + type: array + items: + type: object + properties: + name: + type: string + patches: + - name: CloudscaleClusterTemplate + definitions: + - selector: + apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 + kind: CloudscaleClusterTemplate + matchResources: + infrastructureCluster: true + jsonPatches: + - op: add + path: "/spec/template/spec/region" + valueFrom: + template: "[{{ .region | quote }}]" + - name: CloudscaleMachineTemplateControlPlane + definitions: + - selector: + apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 + kind: CloudscaleMachineTemplate + matchResources: + controlPlane: true + jsonPatches: + - op: replace + path: "/spec/template/spec/flavor" + valueFrom: + variable: cloudscaleControlPlaneMachineFlavor + - op: replace + path: "/spec/template/spec/image" + valueFrom: + variable: cloudscaleControlPlaneMachineImage + - name: CloudscaleMachineTemplateWorker + definitions: + - selector: + apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 + kind: CloudscaleMachineTemplate + matchResources: + machineDeploymentClass: + names: + - worker + jsonPatches: + - op: replace + path: "/spec/template/spec/flavor" + valueFrom: + variable: cloudscaleWorkerMachineFlavor + - op: replace + path: "/spec/template/spec/image" + valueFrom: + variable: cloudscaleWorkerMachineImage + - name: KubeadmControlPlaneTemplate + definitions: + - selector: + apiVersion: controlplane.cluster.x-k8s.io/v1beta2 + kind: KubeadmControlPlaneTemplate + matchResources: + controlPlane: true + jsonPatches: + - op: replace + path: "/spec/template/spec/kubeadmConfigSpec/users[0].sshAuthorizedKeys" + valueFrom: + variable: cloudscaleSSHKeys + - name: KubeadmConfigTemplate + definitions: + - selector: + apiVersion: bootstrap.cluster.x-k8s.io/v1beta2 + kind: KubeadmConfigTemplate + matchResources: + machineDeploymentClass: + names: + - worker + jsonPatches: + - op: replace + path: "/spec/template/spec/users[0].sshAuthorizedKeys" + valueFrom: + variable: cloudscaleSSHKeys +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 +kind: CloudscaleClusterTemplate +metadata: + name: quick-start-cluster +spec: + template: + spec: + region: lpg +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1beta2 +kind: CloudscaleMachineTemplate +metadata: + name: quick-start-machinetemplate +spec: + template: + spec: + flavor: flex-4-2 + image: custom:ubuntu-2404-kube-v1.35.2 +--- +apiVersion: controlplane.cluster.x-k8s.io/v1beta2 +kind: KubeadmControlPlaneTemplate +metadata: + name: quick-start-control-plane +spec: + template: + spec: + kubeadmConfigSpec: + initConfiguration: + nodeRegistration: + kubeletExtraArgs: + - name: cloud-provider + value: external + joinConfiguration: + nodeRegistration: + kubeletExtraArgs: + - name: cloud-provider + value: external + users: + - name: capi + groups: "adm, sudo" + shell: "/bin/bash" + sudo: "ALL=(ALL) NOPASSWD:ALL" + sshAuthorizedKeys: [] +--- +apiVersion: bootstrap.cluster.x-k8s.io/v1beta2 +kind: KubeadmConfigTemplate +metadata: + name: quick-start-worker +spec: + template: + spec: + initConfiguration: + nodeRegistration: + kubeletExtraArgs: + - name: cloud-provider + value: external + joinConfiguration: + nodeRegistration: + kubeletExtraArgs: + - name: cloud-provider + value: external + users: + - name: capi + groups: "adm, sudo" + shell: "/bin/bash" + sudo: "ALL=(ALL) NOPASSWD:ALL" + sshAuthorizedKeys: []