diff --git a/Makefile b/Makefile index 286c7be33e..6fba683261 100644 --- a/Makefile +++ b/Makefile @@ -333,6 +333,7 @@ generate-codegen: generate-controller-gen $(OPENAPI_GEN) $(APPLYCONFIGURATION_GE k8s.io/api/core/v1 \ k8s.io/apimachinery/pkg/apis/meta/v1 \ k8s.io/apimachinery/pkg/runtime \ + k8s.io/apimachinery/pkg/api/resource \ k8s.io/apimachinery/pkg/version @echo "** Generating openapi.json **" go run ./cmd/models-schema | jq > ./openapi.json diff --git a/api/v1beta1/openstackmachinetemplate_types.go b/api/v1beta1/openstackmachinetemplate_types.go index a02f021025..334b9d40bc 100644 --- a/api/v1beta1/openstackmachinetemplate_types.go +++ b/api/v1beta1/openstackmachinetemplate_types.go @@ -17,6 +17,7 @@ limitations under the License. package v1beta1 import ( + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -25,17 +26,39 @@ type OpenStackMachineTemplateSpec struct { Template OpenStackMachineTemplateResource `json:"template"` } +// OpenStackMachineTemplateStatus defines the observed state of OpenStackMachineTemplate. +type OpenStackMachineTemplateStatus struct { + // Capacity defines the resource capacity for this machine. + // This value is used for autoscaling from zero operations as defined in: + // https://github.com/kubernetes-sigs/cluster-api/blob/main/docs/proposals/20210310-opt-in-autoscaling-from-zero.md + // +optional + Capacity corev1.ResourceList `json:"capacity,omitempty"` + // +optional + NodeInfo NodeInfo `json:"nodeInfo,omitempty,omitzero"` +} + +// NodeInfo contains information about the node's architecture and operating system. +// +kubebuilder:validation:MinProperties=1 +type NodeInfo struct { + // operatingSystem is a string representing the operating system of the node. + // This may be a string like 'linux' or 'windows'. + // +optional + OperatingSystem string `json:"operatingSystem,omitempty"` +} + // +genclient // +kubebuilder:object:root=true // +kubebuilder:storageversion // +kubebuilder:resource:path=openstackmachinetemplates,scope=Namespaced,categories=cluster-api,shortName=osmt +// +kubebuilder:subresource:status // OpenStackMachineTemplate is the Schema for the openstackmachinetemplates API. type OpenStackMachineTemplate struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` - Spec OpenStackMachineTemplateSpec `json:"spec,omitempty"` + Spec OpenStackMachineTemplateSpec `json:"spec,omitempty"` + Status OpenStackMachineTemplateStatus `json:"status,omitempty"` } // +kubebuilder:object:root=true @@ -50,3 +73,11 @@ type OpenStackMachineTemplateList struct { func init() { objectTypes = append(objectTypes, &OpenStackMachineTemplate{}, &OpenStackMachineTemplateList{}) } + +// GetIdentifyRef returns the object's namespace and IdentityRef if it has an IdentityRef, or nulls if it does not. +func (r *OpenStackMachineTemplate) GetIdentityRef() (*string, *OpenStackIdentityReference) { + if r.Spec.Template.Spec.IdentityRef != nil { + return &r.Namespace, r.Spec.Template.Spec.IdentityRef + } + return nil, nil +} diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 0ff6155cab..269ec1c968 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -596,6 +596,21 @@ func (in *NetworkStatusWithSubnets) DeepCopy() *NetworkStatusWithSubnets { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeInfo) DeepCopyInto(out *NodeInfo) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeInfo. +func (in *NodeInfo) DeepCopy() *NodeInfo { + if in == nil { + return nil + } + out := new(NodeInfo) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *OpenStackCluster) DeepCopyInto(out *OpenStackCluster) { *out = *in @@ -1182,6 +1197,7 @@ func (in *OpenStackMachineTemplate) DeepCopyInto(out *OpenStackMachineTemplate) 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 OpenStackMachineTemplate. @@ -1266,6 +1282,29 @@ func (in *OpenStackMachineTemplateSpec) DeepCopy() *OpenStackMachineTemplateSpec return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *OpenStackMachineTemplateStatus) DeepCopyInto(out *OpenStackMachineTemplateStatus) { + *out = *in + if in.Capacity != nil { + in, out := &in.Capacity, &out.Capacity + *out = make(v1.ResourceList, len(*in)) + for key, val := range *in { + (*out)[key] = val.DeepCopy() + } + } + out.NodeInfo = in.NodeInfo +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenStackMachineTemplateStatus. +func (in *OpenStackMachineTemplateStatus) DeepCopy() *OpenStackMachineTemplateStatus { + if in == nil { + return nil + } + out := new(OpenStackMachineTemplateStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PortOpts) DeepCopyInto(out *PortOpts) { *out = *in diff --git a/api_violations.report b/api_violations.report index baf94dfeec..58ae60e9d2 100644 --- a/api_violations.report +++ b/api_violations.report @@ -73,6 +73,12 @@ API rule violation: names_match,k8s.io/api/core/v1,RBDVolumeSource,RBDPool API rule violation: names_match,k8s.io/api/core/v1,RBDVolumeSource,RadosUser API rule violation: names_match,k8s.io/api/core/v1,VolumeSource,CephFS API rule violation: names_match,k8s.io/api/core/v1,VolumeSource,StorageOS +API rule violation: names_match,k8s.io/apimachinery/pkg/api/resource,Quantity,Format +API rule violation: names_match,k8s.io/apimachinery/pkg/api/resource,Quantity,d +API rule violation: names_match,k8s.io/apimachinery/pkg/api/resource,Quantity,i +API rule violation: names_match,k8s.io/apimachinery/pkg/api/resource,Quantity,s +API rule violation: names_match,k8s.io/apimachinery/pkg/api/resource,int64Amount,scale +API rule violation: names_match,k8s.io/apimachinery/pkg/api/resource,int64Amount,value API rule violation: names_match,k8s.io/apimachinery/pkg/apis/meta/v1,APIResourceList,APIResources API rule violation: names_match,k8s.io/apimachinery/pkg/apis/meta/v1,Duration,Duration API rule violation: names_match,k8s.io/apimachinery/pkg/apis/meta/v1,InternalEvent,Object diff --git a/cmd/models-schema/zz_generated.openapi.go b/cmd/models-schema/zz_generated.openapi.go index 84609b7d86..5b8de98397 100644 --- a/cmd/models-schema/zz_generated.openapi.go +++ b/cmd/models-schema/zz_generated.openapi.go @@ -23,6 +23,7 @@ package main import ( v1 "k8s.io/api/core/v1" + resource "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" common "k8s.io/kube-openapi/pkg/common" spec "k8s.io/kube-openapi/pkg/validation/spec" @@ -262,6 +263,8 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "k8s.io/api/core/v1.VsphereVirtualDiskVolumeSource": schema_k8sio_api_core_v1_VsphereVirtualDiskVolumeSource(ref), "k8s.io/api/core/v1.WeightedPodAffinityTerm": schema_k8sio_api_core_v1_WeightedPodAffinityTerm(ref), "k8s.io/api/core/v1.WindowsSecurityContextOptions": schema_k8sio_api_core_v1_WindowsSecurityContextOptions(ref), + "k8s.io/apimachinery/pkg/api/resource.Quantity": schema_apimachinery_pkg_api_resource_Quantity(ref), + "k8s.io/apimachinery/pkg/api/resource.int64Amount": schema_apimachinery_pkg_api_resource_int64Amount(ref), "k8s.io/apimachinery/pkg/apis/meta/v1.APIGroup": schema_pkg_apis_meta_v1_APIGroup(ref), "k8s.io/apimachinery/pkg/apis/meta/v1.APIGroupList": schema_pkg_apis_meta_v1_APIGroupList(ref), "k8s.io/apimachinery/pkg/apis/meta/v1.APIResource": schema_pkg_apis_meta_v1_APIResource(ref), @@ -352,6 +355,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.NetworkParam": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_NetworkParam(ref), "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.NetworkStatus": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_NetworkStatus(ref), "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.NetworkStatusWithSubnets": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_NetworkStatusWithSubnets(ref), + "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.NodeInfo": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_NodeInfo(ref), "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.OpenStackCluster": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_OpenStackCluster(ref), "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.OpenStackClusterList": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_OpenStackClusterList(ref), "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.OpenStackClusterSpec": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_OpenStackClusterSpec(ref), @@ -369,6 +373,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.OpenStackMachineTemplateList": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_OpenStackMachineTemplateList(ref), "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.OpenStackMachineTemplateResource": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_OpenStackMachineTemplateResource(ref), "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.OpenStackMachineTemplateSpec": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_OpenStackMachineTemplateSpec(ref), + "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.OpenStackMachineTemplateStatus": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_OpenStackMachineTemplateStatus(ref), "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.PortOpts": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_PortOpts(ref), "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.PortStatus": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_PortStatus(ref), "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.ResolvedFixedIP": schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_ResolvedFixedIP(ref), @@ -14133,6 +14138,54 @@ func schema_k8sio_api_core_v1_WindowsSecurityContextOptions(ref common.Reference } } +func schema_apimachinery_pkg_api_resource_Quantity(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.EmbedOpenAPIDefinitionIntoV2Extension(common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Quantity is a fixed-point representation of a number. It provides convenient marshaling/unmarshaling in JSON and YAML, in addition to String() and AsInt64() accessors.\n\nThe serialization format is:\n\n``` ::= \n\n\t(Note that may be empty, from the \"\" case in .)\n\n ::= 0 | 1 | ... | 9 ::= | ::= | . | . | . ::= \"+\" | \"-\" ::= | ::= | | ::= Ki | Mi | Gi | Ti | Pi | Ei\n\n\t(International System of units; See: http://physics.nist.gov/cuu/Units/binary.html)\n\n ::= m | \"\" | k | M | G | T | P | E\n\n\t(Note that 1024 = 1Ki but 1000 = 1k; I didn't choose the capitalization.)\n\n ::= \"e\" | \"E\" ```\n\nNo matter which of the three exponent forms is used, no quantity may represent a number greater than 2^63-1 in magnitude, nor may it have more than 3 decimal places. Numbers larger or more precise will be capped or rounded up. (E.g.: 0.1m will rounded up to 1m.) This may be extended in the future if we require larger or smaller quantities.\n\nWhen a Quantity is parsed from a string, it will remember the type of suffix it had, and will use the same type again when it is serialized.\n\nBefore serializing, Quantity will be put in \"canonical form\". This means that Exponent/suffix will be adjusted up or down (with a corresponding increase or decrease in Mantissa) such that:\n\n- No precision is lost - No fractional digits will be emitted - The exponent (or suffix) is as large as possible.\n\nThe sign will be omitted unless the number is negative.\n\nExamples:\n\n- 1.5 will be serialized as \"1500m\" - 1.5Gi will be serialized as \"1536Mi\"\n\nNote that the quantity will NEVER be internally represented by a floating point number. That is the whole point of this exercise.\n\nNon-canonical values will still parse as long as they are well formed, but will be re-emitted in their canonical form. (So always use canonical form, or don't diff.)\n\nThis format is intended to make it difficult to use these numbers without writing some sort of special handling code in the hopes that that will cause implementors to also use a fixed point implementation.", + OneOf: common.GenerateOpenAPIV3OneOfSchema(resource.Quantity{}.OpenAPIV3OneOfTypes()), + Format: resource.Quantity{}.OpenAPISchemaFormat(), + }, + }, + }, common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "Quantity is a fixed-point representation of a number. It provides convenient marshaling/unmarshaling in JSON and YAML, in addition to String() and AsInt64() accessors.\n\nThe serialization format is:\n\n``` ::= \n\n\t(Note that may be empty, from the \"\" case in .)\n\n ::= 0 | 1 | ... | 9 ::= | ::= | . | . | . ::= \"+\" | \"-\" ::= | ::= | | ::= Ki | Mi | Gi | Ti | Pi | Ei\n\n\t(International System of units; See: http://physics.nist.gov/cuu/Units/binary.html)\n\n ::= m | \"\" | k | M | G | T | P | E\n\n\t(Note that 1024 = 1Ki but 1000 = 1k; I didn't choose the capitalization.)\n\n ::= \"e\" | \"E\" ```\n\nNo matter which of the three exponent forms is used, no quantity may represent a number greater than 2^63-1 in magnitude, nor may it have more than 3 decimal places. Numbers larger or more precise will be capped or rounded up. (E.g.: 0.1m will rounded up to 1m.) This may be extended in the future if we require larger or smaller quantities.\n\nWhen a Quantity is parsed from a string, it will remember the type of suffix it had, and will use the same type again when it is serialized.\n\nBefore serializing, Quantity will be put in \"canonical form\". This means that Exponent/suffix will be adjusted up or down (with a corresponding increase or decrease in Mantissa) such that:\n\n- No precision is lost - No fractional digits will be emitted - The exponent (or suffix) is as large as possible.\n\nThe sign will be omitted unless the number is negative.\n\nExamples:\n\n- 1.5 will be serialized as \"1500m\" - 1.5Gi will be serialized as \"1536Mi\"\n\nNote that the quantity will NEVER be internally represented by a floating point number. That is the whole point of this exercise.\n\nNon-canonical values will still parse as long as they are well formed, but will be re-emitted in their canonical form. (So always use canonical form, or don't diff.)\n\nThis format is intended to make it difficult to use these numbers without writing some sort of special handling code in the hopes that that will cause implementors to also use a fixed point implementation.", + Type: resource.Quantity{}.OpenAPISchemaType(), + Format: resource.Quantity{}.OpenAPISchemaFormat(), + }, + }, + }) +} + +func schema_apimachinery_pkg_api_resource_int64Amount(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "int64Amount represents a fixed precision numerator and arbitrary scale exponent. It is faster than operations on inf.Dec for values that can be represented as int64.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "value": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int64", + }, + }, + "scale": { + SchemaProps: spec.SchemaProps{ + Default: 0, + Type: []string{"integer"}, + Format: "int32", + }, + }, + }, + Required: []string{"value", "scale"}, + }, + }, + } +} + func schema_pkg_apis_meta_v1_APIGroup(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -18738,6 +18791,26 @@ func schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_NetworkStatusWi } } +func schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_NodeInfo(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "NodeInfo contains information about the node's architecture and operating system.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "operatingSystem": { + SchemaProps: spec.SchemaProps{ + Description: "operatingSystem is a string representing the operating system of the node. This may be a string like 'linux' or 'windows'.", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} + func schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_OpenStackCluster(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -19772,11 +19845,17 @@ func schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_OpenStackMachin Ref: ref("sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.OpenStackMachineTemplateSpec"), }, }, + "status": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.OpenStackMachineTemplateStatus"), + }, + }, }, }, }, Dependencies: []string{ - "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta", "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.OpenStackMachineTemplateSpec"}, + "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta", "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.OpenStackMachineTemplateSpec", "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.OpenStackMachineTemplateStatus"}, } } @@ -19874,6 +19953,41 @@ func schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_OpenStackMachin } } +func schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_OpenStackMachineTemplateStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "OpenStackMachineTemplateStatus defines the observed state of OpenStackMachineTemplate.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "capacity": { + SchemaProps: spec.SchemaProps{ + Description: "Capacity defines the resource capacity for this machine. This value is used for autoscaling from zero operations as defined in: https://github.com/kubernetes-sigs/cluster-api/blob/main/docs/proposals/20210310-opt-in-autoscaling-from-zero.md", + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Allows: true, + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Ref: ref("k8s.io/apimachinery/pkg/api/resource.Quantity"), + }, + }, + }, + }, + }, + "nodeInfo": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.NodeInfo"), + }, + }, + }, + }, + }, + Dependencies: []string{ + "k8s.io/apimachinery/pkg/api/resource.Quantity", "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1.NodeInfo"}, + } +} + func schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_PortOpts(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachinetemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachinetemplates.yaml index e57463f1c3..1cb5307c68 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachinetemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachinetemplates.yaml @@ -950,6 +950,36 @@ spec: required: - template type: object + status: + description: OpenStackMachineTemplateStatus defines the observed state + of OpenStackMachineTemplate. + properties: + capacity: + additionalProperties: + anyOf: + - type: integer + - type: string + pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$ + x-kubernetes-int-or-string: true + description: |- + Capacity defines the resource capacity for this machine. + This value is used for autoscaling from zero operations as defined in: + https://github.com/kubernetes-sigs/cluster-api/blob/main/docs/proposals/20210310-opt-in-autoscaling-from-zero.md + type: object + nodeInfo: + description: NodeInfo contains information about the node's architecture + and operating system. + minProperties: 1 + properties: + operatingSystem: + description: |- + operatingSystem is a string representing the operating system of the node. + This may be a string like 'linux' or 'windows'. + type: string + type: object + type: object type: object served: true storage: true + subresources: + status: {} diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index 4177630c37..98d1a5f88e 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -24,7 +24,7 @@ spec: - "--v=2" - "--diagnostics-address=127.0.0.1:8080" - "--insecure-diagnostics=true" - - "--feature-gates=PriorityQueue=${EXP_CAPO_PRIORITY_QUEUE:=false}" + - "--feature-gates=PriorityQueue=${EXP_CAPO_PRIORITY_QUEUE:=false},AutoScaleFromZero=${EXP_CAPO_AUTOSCALE_FROM_ZERO:=false}" image: controller:latest imagePullPolicy: Always name: manager diff --git a/controllers/openstackmachine_controller.go b/controllers/openstackmachine_controller.go index 725b24b11d..7380f24c6d 100644 --- a/controllers/openstackmachine_controller.go +++ b/controllers/openstackmachine_controller.go @@ -51,6 +51,7 @@ import ( "sigs.k8s.io/cluster-api-provider-openstack/pkg/cloud/services/loadbalancer" "sigs.k8s.io/cluster-api-provider-openstack/pkg/cloud/services/networking" "sigs.k8s.io/cluster-api-provider-openstack/pkg/scope" + controllers "sigs.k8s.io/cluster-api-provider-openstack/pkg/utils/controllers" capoerrors "sigs.k8s.io/cluster-api-provider-openstack/pkg/utils/errors" ) @@ -122,7 +123,7 @@ func (r *OpenStackMachineReconciler) Reconcile(ctx context.Context, req ctrl.Req return ctrl.Result{}, nil } - infraCluster, err := r.getInfraCluster(ctx, cluster, openStackMachine) + infraCluster, err := controllers.GetInfraCluster(ctx, r.Client, cluster, openStackMachine.Namespace) if err != nil { return ctrl.Result{}, errors.New("error getting infra provider cluster") } @@ -804,15 +805,3 @@ func (r *OpenStackMachineReconciler) requestsForCluster(ctx context.Context, log } return result } - -func (r *OpenStackMachineReconciler) getInfraCluster(ctx context.Context, cluster *clusterv1.Cluster, openStackMachine *infrav1.OpenStackMachine) (*infrav1.OpenStackCluster, error) { - openStackCluster := &infrav1.OpenStackCluster{} - openStackClusterName := client.ObjectKey{ - Namespace: openStackMachine.Namespace, - Name: cluster.Spec.InfrastructureRef.Name, - } - if err := r.Client.Get(ctx, openStackClusterName, openStackCluster); err != nil { - return nil, err - } - return openStackCluster, nil -} diff --git a/controllers/openstackmachinetemplate_controller.go b/controllers/openstackmachinetemplate_controller.go new file mode 100644 index 0000000000..4f2868636d --- /dev/null +++ b/controllers/openstackmachinetemplate_controller.go @@ -0,0 +1,221 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 controllers + +import ( + "context" + "errors" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/resource" + kerrors "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/cluster-api/util" + "sigs.k8s.io/cluster-api/util/annotations" + "sigs.k8s.io/cluster-api/util/patch" + "sigs.k8s.io/cluster-api/util/predicates" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + + infrav1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1" + "sigs.k8s.io/cluster-api-provider-openstack/pkg/cloud/services/compute" + "sigs.k8s.io/cluster-api-provider-openstack/pkg/scope" + controllers "sigs.k8s.io/cluster-api-provider-openstack/pkg/utils/controllers" +) + +const imagePropertyForOS = "os_type" + +// Set here so we can easily mock it in tests. +var newComputeService = compute.NewService + +// OpenStackMachineTemplateReconciler reconciles a OpenStackMachineTemplate object. +// it only updates the .status field to allow auto-scaling. +type OpenStackMachineTemplateReconciler struct { + Client client.Client + Recorder record.EventRecorder + WatchFilterValue string + ScopeFactory scope.Factory + CaCertificates []byte // PEM encoded ca certificates. +} + +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=openstackmachinetemplates,verbs=get;list;watch +// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=openstackmachinetemplates/status,verbs=get;update;patch + +func (r *OpenStackMachineTemplateReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, reterr error) { + log := ctrl.LoggerFrom(ctx) + + // Fetch the OpenStackMachine instance. + openStackMachineTemplate := &infrav1.OpenStackMachineTemplate{} + err := r.Client.Get(ctx, req.NamespacedName, openStackMachineTemplate) + if err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, nil + } + return ctrl.Result{}, err + } + + log = log.WithValues("openStackMachineTemplate", openStackMachineTemplate.Name) + log.V(4).Info("Reconciling openStackMachineTemplate") + + // If OSMT is set for deletion, do nothing + if !openStackMachineTemplate.DeletionTimestamp.IsZero() { + log.Info("OpenStackMachineTemplate marked for deletion, skipping reconciliation") + return ctrl.Result{}, nil + } + + // Fetch the Cluster. + cluster, err := util.GetClusterFromMetadata(ctx, r.Client, openStackMachineTemplate.ObjectMeta) + if err != nil { + log.Info("openStackMachineTemplate is missing cluster label or cluster does not exist") + return ctrl.Result{}, nil + } + + log = log.WithValues("cluster", cluster.Name) + + if annotations.IsPaused(cluster, openStackMachineTemplate) { + log.Info("OpenStackMachineTemplate or linked Cluster is marked as paused. Won't reconcile") + return ctrl.Result{}, nil + } + + infraCluster, err := controllers.GetInfraCluster(ctx, r.Client, cluster, openStackMachineTemplate.Namespace) + if err != nil { + return ctrl.Result{}, errors.New("error getting infra provider cluster") + } + if infraCluster == nil { + log.Info("OpenStackCluster not ready", "name", cluster.Spec.InfrastructureRef.Name) + return ctrl.Result{}, nil + } + + log = log.WithValues("openStackCluster", infraCluster.Name) + + clientScope, err := r.ScopeFactory.NewClientScopeFromObject(ctx, r.Client, r.CaCertificates, log, openStackMachineTemplate, infraCluster) + if err != nil { + return ctrl.Result{}, err + } + scope := scope.NewWithLogger(clientScope, log) + + // Initialize the patch helper + patchHelper, err := patch.NewHelper(openStackMachineTemplate, r.Client) + if err != nil { + return ctrl.Result{}, err + } + + // Always patch the openStackMachine when exiting this function so we can persist any OpenStackMachine changes. + defer func() { + if err := patchHelper.Patch(ctx, openStackMachineTemplate); err != nil { + log.Error(err, "Failed to patch OpenStackMachineTemplate after reconciliation") + result = ctrl.Result{} + reterr = kerrors.NewAggregate([]error{reterr, err}) + } + }() + + // Handle non-deleted OpenStackMachineTemplates + if err := r.reconcileNormal(ctx, scope, openStackMachineTemplate); err != nil { + return ctrl.Result{}, err + } + log.V(4).Info("Successfully reconciled OpenStackMachineTemplate") + return ctrl.Result{}, nil +} + +func (r *OpenStackMachineTemplateReconciler) reconcileNormal(ctx context.Context, scope *scope.WithLogger, openStackMachineTemplate *infrav1.OpenStackMachineTemplate) (reterr error) { + log := scope.Logger() + + computeService, err := newComputeService(scope) + if err != nil { + return err + } + + flavorID, err := computeService.GetFlavorID(openStackMachineTemplate.Spec.Template.Spec.FlavorID, openStackMachineTemplate.Spec.Template.Spec.Flavor) + if err != nil { + return err + } + + flavor, err := computeService.GetFlavor(flavorID) + if err != nil { + return err + } + + log.V(4).Info("Retrieved flavor details", "flavorID", flavorID) + + if openStackMachineTemplate.Status.Capacity == nil { + log.V(4).Info("Initializing status capacity map") + openStackMachineTemplate.Status.Capacity = corev1.ResourceList{} + } + + if flavor.VCPUs > 0 { + openStackMachineTemplate.Status.Capacity[corev1.ResourceCPU] = *resource.NewQuantity(int64(flavor.VCPUs), resource.DecimalSI) + } + + if flavor.RAM > 0 { + // flavor.RAM is in MiB -> convert to bytes + ramBytes := int64(flavor.RAM) * 1024 * 1024 + openStackMachineTemplate.Status.Capacity[corev1.ResourceMemory] = *resource.NewQuantity(ramBytes, resource.BinarySI) + } + + if flavor.Ephemeral > 0 { + // flavor.Ephemeral is in GiB -> convert to bytes + ephemeralBytes := int64(flavor.Ephemeral) * 1024 * 1024 * 1024 + openStackMachineTemplate.Status.Capacity[corev1.ResourceEphemeralStorage] = *resource.NewQuantity(ephemeralBytes, resource.BinarySI) + } + + // storage depends on whether user boots-from-volume or not + if openStackMachineTemplate.Spec.Template.Spec.RootVolume != nil && openStackMachineTemplate.Spec.Template.Spec.RootVolume.SizeGiB > 0 { + // RootVolume.SizeGib is in GiB -> convert to bytes + storageBytes := int64(openStackMachineTemplate.Spec.Template.Spec.RootVolume.SizeGiB) * 1024 * 1024 * 1024 + openStackMachineTemplate.Status.Capacity[corev1.ResourceStorage] = *resource.NewQuantity(storageBytes, resource.BinarySI) + } else if flavor.Disk > 0 { + // flavor.Disk is in GiB -> convert to bytes + storageBytes := int64(flavor.Disk) * 1024 * 1024 * 1024 + openStackMachineTemplate.Status.Capacity[corev1.ResourceStorage] = *resource.NewQuantity(storageBytes, resource.BinarySI) + } + + imageID, err := computeService.GetImageID(ctx, r.Client, openStackMachineTemplate.Namespace, openStackMachineTemplate.Spec.Template.Spec.Image) + if err != nil { + return err + } + + image, err := computeService.GetImageDetails(*imageID) + if err != nil { + return err + } + + log.V(4).Info("Retrieved image details", "imageID", imageID) + + if image.Properties != nil { + if v, ok := image.Properties[imagePropertyForOS]; ok { + if osType, ok := v.(string); ok { + openStackMachineTemplate.Status.NodeInfo.OperatingSystem = osType + } + } + } + + return nil +} + +func (r *OpenStackMachineTemplateReconciler) SetupWithManager(ctx context.Context, mgr ctrl.Manager, options controller.Options) error { + log := ctrl.LoggerFrom(ctx) + + return ctrl.NewControllerManagedBy(mgr). + WithOptions(options). + For(&infrav1.OpenStackMachineTemplate{}). + WithEventFilter(predicates.ResourceNotPausedAndHasFilterLabel(mgr.GetScheme(), log, r.WatchFilterValue)). + // The filter below is required as we only want to reconcile objects created by cluster-api + // and not users' + WithEventFilter(predicates.ResourceIsTopologyOwned(mgr.GetScheme(), log)). + Complete(r) +} diff --git a/controllers/openstackmachinetemplate_controller_test.go b/controllers/openstackmachinetemplate_controller_test.go new file mode 100644 index 0000000000..eebf6e6d7a --- /dev/null +++ b/controllers/openstackmachinetemplate_controller_test.go @@ -0,0 +1,424 @@ +/* +Copyright 2025 The Kubernetes Authors. + +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 controllers + +import ( + "context" + "fmt" + "testing" + + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/flavors" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" + . "github.com/onsi/gomega" //nolint:revive + "go.uber.org/mock/gomock" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/utils/ptr" + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + capiannotations "sigs.k8s.io/cluster-api/util/annotations" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + infrav1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1" + "sigs.k8s.io/cluster-api-provider-openstack/pkg/scope" +) + +var ( + flavorID = "661c21bc-be52-44e3-9d2e-8d1e11623b59" + imageID = "ce96e584-7ebc-46d6-9e55-987d72e3806c" +) + +func TestOpenStackMachineTemplateReconciler_Reconcile_UnhappyPaths(t *testing.T) { + ctx := context.Background() + + scheme := runtime.NewScheme() + _ = corev1.AddToScheme(scheme) + _ = clusterv1.AddToScheme(scheme) + _ = infrav1.AddToScheme(scheme) + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "test-ns"}} + + tests := []struct { + name string + reqName string + objects []client.Object + setup func(r *OpenStackMachineTemplateReconciler) + wantErr string + }{ + { + name: "object does not exist", + reqName: "does-not-exist", + objects: []client.Object{ns}, + }, + { + name: "marked for deletion", + reqName: "to-be-deleted", + objects: func() []client.Object { + tpl := newOSMT("to-be-deleted", "c1", false, false) + now := metav1.Now() + tpl.DeletionTimestamp = &now + tpl.Finalizers = []string{"test.finalizer.cluster.x-k8s.io"} + return []client.Object{ns, tpl} + }(), + }, + { + name: "missing cluster label", + reqName: "no-cluster", + objects: []client.Object{ + ns, + func() *infrav1.OpenStackMachineTemplate { + tpl := newOSMT("no-cluster", "c1", false, false) + delete(tpl.Labels, clusterv1.ClusterNameLabel) + return tpl + }(), + }, + }, + { + name: "paused cluster", + reqName: "paused-cluster", + objects: []client.Object{ + ns, + newOSMT("paused-cluster", "paused-cluster", false, false), + newCluster("paused-cluster", "oscluster", true), + newOSCluster("oscluster"), + }, + }, + { + name: "paused tpl", + reqName: "paused-tpl", + objects: []client.Object{ + ns, + newOSMT("paused-tpl", "cluster", true, false), + newCluster("cluster", "oscluster", false), + newOSCluster("oscluster"), + }, + }, + { + name: "scope factory returns error", + reqName: "scope-error", + objects: []client.Object{ + ns, + newOSMT("scope-error", "c1", false, false), + newCluster("c1", "oscluster", false), + newOSCluster("oscluster"), + }, + setup: func(r *OpenStackMachineTemplateReconciler) { + mockCtrl := gomock.NewController(t) + mockScopeFactory := scope.NewMockScopeFactory(mockCtrl, "proj") + mockScopeFactory.SetClientScopeCreateError(fmt.Errorf("boom")) + r.ScopeFactory = mockScopeFactory + t.Cleanup(mockCtrl.Finish) + }, + wantErr: "boom", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + cl := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(tt.objects...). + Build() + + r := &OpenStackMachineTemplateReconciler{ + Client: cl, + ScopeFactory: nil, // set in setup when needed + } + + if tt.setup != nil { + tt.setup(r) + } + + req := ctrl.Request{ + NamespacedName: client.ObjectKey{ + Namespace: "test-ns", + Name: tt.reqName, + }, + } + + _, err := r.Reconcile(ctx, req) + if tt.wantErr == "" { + g.Expect(err).ToNot(HaveOccurred()) + } else { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.wantErr)) + } + }) + } +} + +func TestOpenStackMachineTemplateReconciler_reconcileNormal(t *testing.T) { + g := NewWithT(t) + ctx := context.Background() + + // --- Scheme setup --- + scheme := runtime.NewScheme() + g.Expect(corev1.AddToScheme(scheme)).To(Succeed()) + g.Expect(infrav1.AddToScheme(scheme)).To(Succeed()) + + type testCase struct { + name string + tpl *infrav1.OpenStackMachineTemplate + expect func(mf *scope.MockScopeFactory) + wantErr string + verify func(g Gomega, tpl *infrav1.OpenStackMachineTemplate) + } + + tests := []testCase{ + { + name: "error getting flavor details", + tpl: newOSMT("test-osmt", "test-cluster", false, false), + expect: func(mf *scope.MockScopeFactory) { + mf.ComputeClient. + EXPECT(). + GetFlavor(flavorID). + Return(nil, fmt.Errorf("flavor-details-error")) + }, + wantErr: "flavor-details-error", + }, + { + name: "error getting image details", + tpl: newOSMT("test-osmt", "test-cluster", false, false), + expect: func(mf *scope.MockScopeFactory) { + mf.ComputeClient. + EXPECT(). + GetFlavor(flavorID). + Return(&flavors.Flavor{ + VCPUs: 2, RAM: 1024, Disk: 5, Ephemeral: 1, + }, nil) + + mf.ImageClient. + EXPECT(). + GetImage(imageID). + Return(nil, fmt.Errorf("image-details-error")) + }, + wantErr: "image-details-error", + }, + { + name: "boot-from-image", + tpl: newOSMT("test-osmt", "test-cluster", false, false), + expect: func(mf *scope.MockScopeFactory) { + mf.ComputeClient. + EXPECT(). + GetFlavor(flavorID). + Return(&flavors.Flavor{ + VCPUs: 4, + RAM: 8192, + Disk: 50, + Ephemeral: 10, + }, nil) + + mf.ImageClient. + EXPECT(). + GetImage(imageID). + Return(&images.Image{ + ID: imageID, + Properties: map[string]any{ + imagePropertyForOS: "linux", + }, + }, nil) + }, + wantErr: "", + verify: func(g Gomega, tpl *infrav1.OpenStackMachineTemplate) { + // CPU = 4 cores + expCPU := *resource.NewQuantity(4, resource.DecimalSI) + g.Expect(tpl.Status.Capacity[corev1.ResourceCPU]).To(Equal(expCPU)) + + // Memory = 8192 MiB → bytes + ramBytes := int64(8192) * 1024 * 1024 + expMem := *resource.NewQuantity(ramBytes, resource.BinarySI) + g.Expect(tpl.Status.Capacity[corev1.ResourceMemory]).To(Equal(expMem)) + + // Ephemeral = 10 GiB → bytes + ephBytes := int64(10) * 1024 * 1024 * 1024 + expEph := *resource.NewQuantity(ephBytes, resource.BinarySI) + g.Expect(tpl.Status.Capacity[corev1.ResourceEphemeralStorage]).To(Equal(expEph)) + + // Storage = Disk = 50 GiB → bytes (because RootVolume is nil) + storageBytes := int64(50) * 1024 * 1024 * 1024 + expStorage := *resource.NewQuantity(storageBytes, resource.BinarySI) + g.Expect(tpl.Status.Capacity[corev1.ResourceStorage]).To(Equal(expStorage)) + + // OS property + g.Expect(tpl.Status.NodeInfo.OperatingSystem).To(Equal("linux")) + }, + }, + { + name: "boot-from-volume", + tpl: newOSMT("test-osmt", "test-cluster", false, true), + expect: func(mf *scope.MockScopeFactory) { + mf.ComputeClient. + EXPECT(). + GetFlavor(flavorID). + Return(&flavors.Flavor{ + VCPUs: 4, + RAM: 8192, + Disk: 50, + Ephemeral: 10, + }, nil) + + mf.ImageClient. + EXPECT(). + GetImage(imageID). + Return(&images.Image{ + ID: imageID, + Properties: map[string]any{ + imagePropertyForOS: "linux", + }, + }, nil) + }, + wantErr: "", + verify: func(g Gomega, tpl *infrav1.OpenStackMachineTemplate) { + // CPU = 4 cores + expCPU := *resource.NewQuantity(4, resource.DecimalSI) + g.Expect(tpl.Status.Capacity[corev1.ResourceCPU]).To(Equal(expCPU)) + + // Memory = 8192 MiB → bytes + ramBytes := int64(8192) * 1024 * 1024 + expMem := *resource.NewQuantity(ramBytes, resource.BinarySI) + g.Expect(tpl.Status.Capacity[corev1.ResourceMemory]).To(Equal(expMem)) + + // Ephemeral = 10 GiB → bytes + ephBytes := int64(10) * 1024 * 1024 * 1024 + expEph := *resource.NewQuantity(ephBytes, resource.BinarySI) + g.Expect(tpl.Status.Capacity[corev1.ResourceEphemeralStorage]).To(Equal(expEph)) + + // Storage = Disk = 100 GiB → bytes (because RootVolume is set) + storageBytes := int64(100) * 1024 * 1024 * 1024 + expStorage := *resource.NewQuantity(storageBytes, resource.BinarySI) + g.Expect(tpl.Status.Capacity[corev1.ResourceStorage]).To(Equal(expStorage)) + + // OS property + g.Expect(tpl.Status.NodeInfo.OperatingSystem).To(Equal("linux")) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := NewWithT(t) + + // fake k8s client (used by GetImageID) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + Build() + + // gomock controller + mockCtrl := gomock.NewController(t) + defer mockCtrl.Finish() + + // CAPO's MockScopeFactory (this is the key) + mf := scope.NewMockScopeFactory(mockCtrl, "proj") + + // The scope this function expects: + log := ctrl.Log.WithName("test") + withLogger := scope.NewWithLogger(mf, log) + + // reconciler + r := &OpenStackMachineTemplateReconciler{ + Client: k8sClient, + ScopeFactory: mf, + } + + tpl := tt.tpl.DeepCopy() + + tt.expect(mf) + + err := r.reconcileNormal(ctx, withLogger, tpl) + + if tt.wantErr == "" { + g.Expect(err).ToNot(HaveOccurred()) + if tt.verify != nil { + tt.verify(g, tpl) + } + } else { + g.Expect(err).To(HaveOccurred()) + g.Expect(err.Error()).To(ContainSubstring(tt.wantErr)) + } + }) + } +} + +func newOSMT(name, clusterName string, paused bool, rootVolume bool) *infrav1.OpenStackMachineTemplate { + osmt := &infrav1.OpenStackMachineTemplate{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-ns", + Name: name, + Labels: map[string]string{ + clusterv1.ClusterNameLabel: clusterName, + }, + }, + Spec: infrav1.OpenStackMachineTemplateSpec{ + Template: infrav1.OpenStackMachineTemplateResource{ + Spec: infrav1.OpenStackMachineSpec{ + FlavorID: ptr.To(flavorID), + Image: infrav1.ImageParam{ + ID: &imageID, + }, + }, + }, + }, + } + + if rootVolume { + osmt.Spec.Template.Spec.RootVolume = &infrav1.RootVolume{ + SizeGiB: 100, + } + } + + if paused { + capiannotations.AddAnnotations(osmt, map[string]string{ + clusterv1.PausedAnnotation: "true", + }) + } + + return osmt +} + +func newCluster(name, infraName string, paused bool) *clusterv1.Cluster { + c := &clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-ns", + Name: name, + }, + Spec: clusterv1.ClusterSpec{ + InfrastructureRef: clusterv1.ContractVersionedObjectReference{ + APIGroup: infrav1.GroupName, + Kind: "OpenStackCluster", + Name: infraName, + }, + }, + } + if paused { + c.Spec.Paused = &paused + } + return c +} + +func newOSCluster(name string) *infrav1.OpenStackCluster { + return &infrav1.OpenStackCluster{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-ns", + Name: name, + }, + } +} diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md index fd69371984..ef4c9c2b1c 100644 --- a/docs/book/src/SUMMARY.md +++ b/docs/book/src/SUMMARY.md @@ -5,6 +5,7 @@ - [Configuration](clusteropenstack/configuration.md) - [Experimental Features](./experimental-features/experimental-features.md) - [PriorityQueue](./experimental-features/priority-queue.md) + - [AutoScaleFromZero](./experimental-features/autoscale-from-zero.md) - [Topics](./topics/index.md) - [external cloud provider](./topics/external-cloud-provider.md) - [hosted control plane](./topics/hosted-control-plane.md) diff --git a/docs/book/src/api/v1beta1/api.md b/docs/book/src/api/v1beta1/api.md index ce0334eca1..e270b9d152 100644 --- a/docs/book/src/api/v1beta1/api.md +++ b/docs/book/src/api/v1beta1/api.md @@ -857,6 +857,18 @@ OpenStackMachineTemplateResource + + +status
+ + +OpenStackMachineTemplateStatus + + + + + +

APIServerLoadBalancer @@ -2321,6 +2333,38 @@ NetworkStatus

NeutronTag represents a tag on a Neutron resource. It may not be empty and may not contain commas.

+

NodeInfo +

+

+(Appears on: +OpenStackMachineTemplateStatus) +

+

+

NodeInfo contains information about the node’s architecture and operating system.

+

+ + + + + + + + + + + + + +
FieldDescription
+operatingSystem
+ +string + +
+(Optional) +

operatingSystem is a string representing the operating system of the node. +This may be a string like ‘linux’ or ‘windows’.

+

OpenStackClusterSpec

@@ -4070,6 +4114,52 @@ OpenStackMachineTemplateResource +

OpenStackMachineTemplateStatus +

+

+(Appears on: +OpenStackMachineTemplate) +

+

+

OpenStackMachineTemplateStatus defines the observed state of OpenStackMachineTemplate.

+

+ + + + + + + + + + + + + + + + + +
FieldDescription
+capacity
+ +Kubernetes core/v1.ResourceList + +
+(Optional) +

Capacity defines the resource capacity for this machine. +This value is used for autoscaling from zero operations as defined in: +https://github.com/kubernetes-sigs/cluster-api/blob/main/docs/proposals/20210310-opt-in-autoscaling-from-zero.md

+
+nodeInfo,omitempty,omitzero
+ + +NodeInfo + + +
+(Optional) +

PortOpts

diff --git a/docs/book/src/experimental-features/autoscale-from-zero.md b/docs/book/src/experimental-features/autoscale-from-zero.md new file mode 100644 index 0000000000..6ef8a19134 --- /dev/null +++ b/docs/book/src/experimental-features/autoscale-from-zero.md @@ -0,0 +1,39 @@ +# AutoScale From Zero + +> **Note:** AutoScaleFromZero is available in >= 0.14. + +> **Note**: AutoScaleFromZero can be used only in clusters using the [ClusterClass](https://cluster-api.sigs.k8s.io/tasks/experimental-features/cluster-class/) feature. + +The `AutoScaleFromZero` feature flag enables the usage of [cluster-autoscaler](https://github.com/kubernetes/autoscaler/tree/bc3f44c85df17bccc940adb7c885b192cf6135d7/cluster-autoscaler/cloudprovider/clusterapi#cluster-autoscaler-on-cluster-api) to scale from/to zero without the need of annotations. More information on how to use the cluster-autoscaler can be found [here](https://github.com/kubernetes/autoscaler/tree/bc3f44c85df17bccc940adb7c885b192cf6135d7/cluster-autoscaler/cloudprovider/clusterapi#scale-from-zero-support). + +## Enabling AutoScaleFromZero + +You can enable `AutoScaleFromZero` using the following. + +- Environment variable: `EXP_CAPO_AUTOSCALE_FROM_ZERO=true` +- clusterctl.yaml variable: `EXP_CAPO_AUTOSCALE_FROM_ZERO: true` +- --feature-gates argument: `AutoScaleFromZero=true` + +## Automatically Populated Status Fields + +> **Note**: Unsupported fields may be provided via annotations or incorporated into the controller by extending its functionality. + +The controller automatically fills two sections of `OpenStackMachineTemplate.Status`: +- **capacity** (resource quantities) +- **nodeInfo** (OS metadata) + +The following mappings describe exactly where each value originates. + +### Capacity (`Status.Capacity`) +- **CPU**: From the `VCPUs` property of the resolved OpenStack flavor + +- **Memory**: From the `RAM` property of the resolved OpenStack flavor + +- **Ephemeral Storage**: From the `Ephemeral` property of the resolved OpenStack flavor + +- **Root Storage**: Determined based on the boot method: + - If **booting from volume** taken from `OpenStackMachineTemplate.Spec.Template.Spec.RootVolume.SizeGiB` + - If **booting from image** taken from the `Disk` property of the resolved OpenStack flavor + +### Node Information (`Status.NodeInfo`) +- **Operating System**: From the `os_type` property of the resolved OpenStack image. diff --git a/docs/book/src/experimental-features/experimental-features.md b/docs/book/src/experimental-features/experimental-features.md index 384e140a70..b9cc4fc031 100644 --- a/docs/book/src/experimental-features/experimental-features.md +++ b/docs/book/src/experimental-features/experimental-features.md @@ -4,6 +4,7 @@ CAPO now ships with experimental features the users can enable. Currently CAPO has the following experimental features: * `PriorityQueue` (env var: `EXP_CAPO_PRIORITY_QUEUE`): [PriorityQueue](./priority-queue.md) +* `AutoScaleFromZero` (env var: `EXP_CAPO_AUTOSCALE_FROM_ZERO`): [AutoScaleFromZero](./autoscale-from-zero.md) ## Enabling Experimental Features for Management Clusters Started with clusterctl diff --git a/feature/feature.go b/feature/feature.go index 8da931c1dc..a0286cebd5 100644 --- a/feature/feature.go +++ b/feature/feature.go @@ -34,6 +34,13 @@ const ( // // alpha: v0.14 PriorityQueue featuregate.Feature = "PriorityQueue" + + // AutoScaleFromZero is a feature gate that enables the OpenStackMachineTemplate controller that adds + // information in OpenStackMachineTemplate.status required by the cluster-autoscaler to scale from zero + // without the addition of labels + // + // alpha: v0.14 + AutoScaleFromZero featuregate.Feature = "AutoScaleFromZero" ) func init() { @@ -44,5 +51,6 @@ func init() { // To add a new feature, define a key for it above and add it here. var defaultCAPOFeatureGates = map[featuregate.Feature]featuregate.FeatureSpec{ // Every feature should be initiated here: - PriorityQueue: {Default: false, PreRelease: featuregate.Alpha}, + PriorityQueue: {Default: false, PreRelease: featuregate.Alpha}, + AutoScaleFromZero: {Default: false, PreRelease: featuregate.Alpha}, } diff --git a/main.go b/main.go index fdefb41c41..cf37f8a1f7 100644 --- a/main.go +++ b/main.go @@ -77,28 +77,29 @@ var ( setupLog = ctrl.Log.WithName("setup") // flags. - managerOptions = flags.ManagerOptions{} - enableLeaderElection bool - leaderElectionLeaseDuration time.Duration - leaderElectionRenewDeadline time.Duration - leaderElectionRetryPeriod time.Duration - watchNamespace string - watchFilterValue string - profilerAddress string - openStackClusterConcurrency int - openStackMachineConcurrency int - syncPeriod time.Duration - restConfigQPS float32 - restConfigBurst int - webhookPort int - webhookCertDir string - healthAddr string - lbProvider string - caCertsPath string - showVersion bool - scopeCacheMaxSize int - skipCRDMigrationPhases []string - logOptions = logs.NewOptions() + managerOptions = flags.ManagerOptions{} + enableLeaderElection bool + leaderElectionLeaseDuration time.Duration + leaderElectionRenewDeadline time.Duration + leaderElectionRetryPeriod time.Duration + watchNamespace string + watchFilterValue string + profilerAddress string + openStackClusterConcurrency int + openStackMachineConcurrency int + openStackMachineTemplateConcurrency int + syncPeriod time.Duration + restConfigQPS float32 + restConfigBurst int + webhookPort int + webhookCertDir string + healthAddr string + lbProvider string + caCertsPath string + showVersion bool + scopeCacheMaxSize int + skipCRDMigrationPhases []string + logOptions = logs.NewOptions() ) func init() { @@ -148,6 +149,9 @@ func InitFlags(fs *pflag.FlagSet) { fs.IntVar(&openStackMachineConcurrency, "openstackmachine-concurrency", 10, "Number of OpenStackMachines to process simultaneously") + fs.IntVar(&openStackMachineTemplateConcurrency, "openstackmachinetemplate-concurrency", 10, + "Number of OpenStackMachineTemplates to process simultaneously") + fs.DurationVar(&syncPeriod, "sync-period", 10*time.Minute, "The minimum interval at which watched resources are reconciled (e.g. 15m)") @@ -395,6 +399,19 @@ func setupReconcilers(ctx context.Context, mgr ctrl.Manager, caCerts []byte) { setupLog.Error(err, "unable to create controller", "controller", "OpenStackServer") os.Exit(1) } + + if feature.Gates.Enabled(feature.AutoScaleFromZero) { + if err := (&controllers.OpenStackMachineTemplateReconciler{ + Client: mgr.GetClient(), + Recorder: mgr.GetEventRecorderFor("openstackmachinetemplate-controller"), + WatchFilterValue: watchFilterValue, + ScopeFactory: scopeFactory, + CaCertificates: caCerts, + }).SetupWithManager(ctx, mgr, concurrency(openStackMachineTemplateConcurrency)); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "OpenStackMachineTemplate") + os.Exit(1) + } + } } func setupWebhooks(mgr ctrl.Manager) { diff --git a/pkg/clients/compute.go b/pkg/clients/compute.go index 0338417b93..519fdb173d 100644 --- a/pkg/clients/compute.go +++ b/pkg/clients/compute.go @@ -59,6 +59,8 @@ type ComputeClient interface { ListAvailabilityZones() ([]availabilityzones.AvailabilityZone, error) ListFlavors() ([]flavors.Flavor, error) + GetFlavor(flavorID string) (*flavors.Flavor, error) + CreateServer(createOpts servers.CreateOptsBuilder, schedulerHints servers.SchedulerHintOptsBuilder) (*servers.Server, error) DeleteServer(serverID string) error GetServer(serverID string) (*servers.Server, error) @@ -126,6 +128,15 @@ func (c computeClient) ListFlavors() ([]flavors.Flavor, error) { return flavors.ExtractFlavors(allPages) } +func (c computeClient) GetFlavor(flavorID string) (*flavors.Flavor, error) { + mc := metrics.NewMetricPrometheusContext("flavor", "get") + flavor, err := flavors.Get(context.TODO(), c.client, flavorID).Extract() + if mc.ObserveRequest(err) != nil { + return nil, err + } + return flavor, nil +} + func (c computeClient) CreateServer(createOpts servers.CreateOptsBuilder, schedulerHints servers.SchedulerHintOptsBuilder) (*servers.Server, error) { mc := metrics.NewMetricPrometheusContext("server", "create") server, err := servers.Create(context.TODO(), c.client, createOpts, schedulerHints).Extract() @@ -222,6 +233,10 @@ func (e computeErrorClient) ListFlavors() ([]flavors.Flavor, error) { return nil, e.error } +func (e computeErrorClient) GetFlavor(_ string) (*flavors.Flavor, error) { + return nil, e.error +} + func (e computeErrorClient) CreateServer(_ servers.CreateOptsBuilder, _ servers.SchedulerHintOptsBuilder) (*servers.Server, error) { return nil, e.error } diff --git a/pkg/clients/mock/compute.go b/pkg/clients/mock/compute.go index c83d7a701e..6ddd94b6ea 100644 --- a/pkg/clients/mock/compute.go +++ b/pkg/clients/mock/compute.go @@ -118,6 +118,21 @@ func (mr *MockComputeClientMockRecorder) GetConsoleOutput(serverID any) *gomock. return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetConsoleOutput", reflect.TypeOf((*MockComputeClient)(nil).GetConsoleOutput), serverID) } +// GetFlavor mocks base method. +func (m *MockComputeClient) GetFlavor(flavorID string) (*flavors.Flavor, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetFlavor", flavorID) + ret0, _ := ret[0].(*flavors.Flavor) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetFlavor indicates an expected call of GetFlavor. +func (mr *MockComputeClientMockRecorder) GetFlavor(flavorID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFlavor", reflect.TypeOf((*MockComputeClient)(nil).GetFlavor), flavorID) +} + // GetServer mocks base method. func (m *MockComputeClient) GetServer(serverID string) (*servers.Server, error) { m.ctrl.T.Helper() diff --git a/pkg/cloud/services/compute/instance.go b/pkg/cloud/services/compute/instance.go index e7389a4d9b..9b89d60c50 100644 --- a/pkg/cloud/services/compute/instance.go +++ b/pkg/cloud/services/compute/instance.go @@ -25,8 +25,10 @@ import ( "time" "github.com/gophercloud/gophercloud/v2/openstack/blockstorage/v3/volumes" + "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/flavors" "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/keypairs" "github.com/gophercloud/gophercloud/v2/openstack/compute/v2/servers" + "github.com/gophercloud/gophercloud/v2/openstack/image/v2/images" "github.com/gophercloud/gophercloud/v2/openstack/networking/v2/ports" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" @@ -449,6 +451,14 @@ func (s *Service) GetFlavorID(flavorID, flavorName *string) (string, error) { return "", fmt.Errorf("no flavors were found: name=%v", *flavorName) } +func (s *Service) GetFlavor(flavorID string) (*flavors.Flavor, error) { + return s.getComputeClient().GetFlavor(flavorID) +} + +func (s *Service) GetImageDetails(imageID string) (*images.Image, error) { + return s.getImageClient().GetImage(imageID) +} + // GetManagementPort returns the port which is used for management and external // traffic. Cluster floating IPs must be associated with this port. func (s *Service) GetManagementPort(openStackCluster *infrav1.OpenStackCluster, instanceStatus *InstanceStatus) (*ports.Port, error) { diff --git a/pkg/generated/applyconfiguration/api/v1beta1/nodeinfo.go b/pkg/generated/applyconfiguration/api/v1beta1/nodeinfo.go new file mode 100644 index 0000000000..b26f3730bb --- /dev/null +++ b/pkg/generated/applyconfiguration/api/v1beta1/nodeinfo.go @@ -0,0 +1,39 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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. +*/ + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1beta1 + +// NodeInfoApplyConfiguration represents a declarative configuration of the NodeInfo type for use +// with apply. +type NodeInfoApplyConfiguration struct { + OperatingSystem *string `json:"operatingSystem,omitempty"` +} + +// NodeInfoApplyConfiguration constructs a declarative configuration of the NodeInfo type for use with +// apply. +func NodeInfo() *NodeInfoApplyConfiguration { + return &NodeInfoApplyConfiguration{} +} + +// WithOperatingSystem sets the OperatingSystem field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the OperatingSystem field is set to the value of the last call. +func (b *NodeInfoApplyConfiguration) WithOperatingSystem(value string) *NodeInfoApplyConfiguration { + b.OperatingSystem = &value + return b +} diff --git a/pkg/generated/applyconfiguration/api/v1beta1/openstackmachinetemplate.go b/pkg/generated/applyconfiguration/api/v1beta1/openstackmachinetemplate.go index 2f705cd4f5..c6c021bf4e 100644 --- a/pkg/generated/applyconfiguration/api/v1beta1/openstackmachinetemplate.go +++ b/pkg/generated/applyconfiguration/api/v1beta1/openstackmachinetemplate.go @@ -32,7 +32,8 @@ import ( type OpenStackMachineTemplateApplyConfiguration struct { v1.TypeMetaApplyConfiguration `json:",inline"` *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"` - Spec *OpenStackMachineTemplateSpecApplyConfiguration `json:"spec,omitempty"` + Spec *OpenStackMachineTemplateSpecApplyConfiguration `json:"spec,omitempty"` + Status *OpenStackMachineTemplateStatusApplyConfiguration `json:"status,omitempty"` } // OpenStackMachineTemplate constructs a declarative configuration of the OpenStackMachineTemplate type for use with @@ -248,6 +249,14 @@ func (b *OpenStackMachineTemplateApplyConfiguration) WithSpec(value *OpenStackMa return b } +// WithStatus sets the Status field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Status field is set to the value of the last call. +func (b *OpenStackMachineTemplateApplyConfiguration) WithStatus(value *OpenStackMachineTemplateStatusApplyConfiguration) *OpenStackMachineTemplateApplyConfiguration { + b.Status = value + return b +} + // GetName retrieves the value of the Name field in the declarative configuration. func (b *OpenStackMachineTemplateApplyConfiguration) GetName() *string { b.ensureObjectMetaApplyConfigurationExists() diff --git a/pkg/generated/applyconfiguration/api/v1beta1/openstackmachinetemplatestatus.go b/pkg/generated/applyconfiguration/api/v1beta1/openstackmachinetemplatestatus.go new file mode 100644 index 0000000000..eab3366465 --- /dev/null +++ b/pkg/generated/applyconfiguration/api/v1beta1/openstackmachinetemplatestatus.go @@ -0,0 +1,52 @@ +/* +Copyright 2024 The Kubernetes Authors. + +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. +*/ + +// Code generated by applyconfiguration-gen. DO NOT EDIT. + +package v1beta1 + +import ( + v1 "k8s.io/api/core/v1" +) + +// OpenStackMachineTemplateStatusApplyConfiguration represents a declarative configuration of the OpenStackMachineTemplateStatus type for use +// with apply. +type OpenStackMachineTemplateStatusApplyConfiguration struct { + Capacity *v1.ResourceList `json:"capacity,omitempty"` + NodeInfo *NodeInfoApplyConfiguration `json:"nodeInfo,omitempty"` +} + +// OpenStackMachineTemplateStatusApplyConfiguration constructs a declarative configuration of the OpenStackMachineTemplateStatus type for use with +// apply. +func OpenStackMachineTemplateStatus() *OpenStackMachineTemplateStatusApplyConfiguration { + return &OpenStackMachineTemplateStatusApplyConfiguration{} +} + +// WithCapacity sets the Capacity field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the Capacity field is set to the value of the last call. +func (b *OpenStackMachineTemplateStatusApplyConfiguration) WithCapacity(value v1.ResourceList) *OpenStackMachineTemplateStatusApplyConfiguration { + b.Capacity = &value + return b +} + +// WithNodeInfo sets the NodeInfo field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the NodeInfo field is set to the value of the last call. +func (b *OpenStackMachineTemplateStatusApplyConfiguration) WithNodeInfo(value *NodeInfoApplyConfiguration) *OpenStackMachineTemplateStatusApplyConfiguration { + b.NodeInfo = value + return b +} diff --git a/pkg/generated/applyconfiguration/internal/internal.go b/pkg/generated/applyconfiguration/internal/internal.go index deadb79120..2b68e5773b 100644 --- a/pkg/generated/applyconfiguration/internal/internal.go +++ b/pkg/generated/applyconfiguration/internal/internal.go @@ -73,6 +73,8 @@ var schemaYAML = typed.YAMLObject(`types: scalar: string default: "" elementRelationship: atomic +- name: io.k8s.apimachinery.pkg.api.resource.Quantity + scalar: untyped - name: io.k8s.apimachinery.pkg.apis.meta.v1.FieldsV1 map: elementType: @@ -806,6 +808,12 @@ var schemaYAML = typed.YAMLObject(`types: elementType: scalar: string elementRelationship: atomic +- name: io.k8s.sigs.cluster-api-provider-openstack.api.v1beta1.NodeInfo + map: + fields: + - name: operatingSystem + type: + scalar: string - name: io.k8s.sigs.cluster-api-provider-openstack.api.v1beta1.OpenStackCluster map: fields: @@ -1162,6 +1170,10 @@ var schemaYAML = typed.YAMLObject(`types: type: namedType: io.k8s.sigs.cluster-api-provider-openstack.api.v1beta1.OpenStackMachineTemplateSpec default: {} + - name: status + type: + namedType: io.k8s.sigs.cluster-api-provider-openstack.api.v1beta1.OpenStackMachineTemplateStatus + default: {} - name: io.k8s.sigs.cluster-api-provider-openstack.api.v1beta1.OpenStackMachineTemplateResource map: fields: @@ -1176,6 +1188,18 @@ var schemaYAML = typed.YAMLObject(`types: type: namedType: io.k8s.sigs.cluster-api-provider-openstack.api.v1beta1.OpenStackMachineTemplateResource default: {} +- name: io.k8s.sigs.cluster-api-provider-openstack.api.v1beta1.OpenStackMachineTemplateStatus + map: + fields: + - name: capacity + type: + map: + elementType: + namedType: io.k8s.apimachinery.pkg.api.resource.Quantity + - name: nodeInfo + type: + namedType: io.k8s.sigs.cluster-api-provider-openstack.api.v1beta1.NodeInfo + default: {} - name: io.k8s.sigs.cluster-api-provider-openstack.api.v1beta1.PortOpts map: fields: diff --git a/pkg/generated/applyconfiguration/utils.go b/pkg/generated/applyconfiguration/utils.go index b0ba50dcaa..f65edec9b7 100644 --- a/pkg/generated/applyconfiguration/utils.go +++ b/pkg/generated/applyconfiguration/utils.go @@ -98,6 +98,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &apiv1beta1.NetworkStatusApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("NetworkStatusWithSubnets"): return &apiv1beta1.NetworkStatusWithSubnetsApplyConfiguration{} + case v1beta1.SchemeGroupVersion.WithKind("NodeInfo"): + return &apiv1beta1.NodeInfoApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("OpenStackCluster"): return &apiv1beta1.OpenStackClusterApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("OpenStackClusterSpec"): @@ -124,6 +126,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &apiv1beta1.OpenStackMachineTemplateResourceApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("OpenStackMachineTemplateSpec"): return &apiv1beta1.OpenStackMachineTemplateSpecApplyConfiguration{} + case v1beta1.SchemeGroupVersion.WithKind("OpenStackMachineTemplateStatus"): + return &apiv1beta1.OpenStackMachineTemplateStatusApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("PortOpts"): return &apiv1beta1.PortOptsApplyConfiguration{} case v1beta1.SchemeGroupVersion.WithKind("PortStatus"): diff --git a/pkg/generated/clientset/clientset/typed/api/v1beta1/openstackmachinetemplate.go b/pkg/generated/clientset/clientset/typed/api/v1beta1/openstackmachinetemplate.go index ca49cb02ab..7d7f24bb6b 100644 --- a/pkg/generated/clientset/clientset/typed/api/v1beta1/openstackmachinetemplate.go +++ b/pkg/generated/clientset/clientset/typed/api/v1beta1/openstackmachinetemplate.go @@ -40,6 +40,8 @@ type OpenStackMachineTemplatesGetter interface { type OpenStackMachineTemplateInterface interface { Create(ctx context.Context, openStackMachineTemplate *apiv1beta1.OpenStackMachineTemplate, opts v1.CreateOptions) (*apiv1beta1.OpenStackMachineTemplate, error) Update(ctx context.Context, openStackMachineTemplate *apiv1beta1.OpenStackMachineTemplate, opts v1.UpdateOptions) (*apiv1beta1.OpenStackMachineTemplate, error) + // Add a +genclient:noStatus comment above the type to avoid generating UpdateStatus(). + UpdateStatus(ctx context.Context, openStackMachineTemplate *apiv1beta1.OpenStackMachineTemplate, opts v1.UpdateOptions) (*apiv1beta1.OpenStackMachineTemplate, error) Delete(ctx context.Context, name string, opts v1.DeleteOptions) error DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error Get(ctx context.Context, name string, opts v1.GetOptions) (*apiv1beta1.OpenStackMachineTemplate, error) @@ -47,6 +49,8 @@ type OpenStackMachineTemplateInterface interface { Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *apiv1beta1.OpenStackMachineTemplate, err error) Apply(ctx context.Context, openStackMachineTemplate *applyconfigurationapiv1beta1.OpenStackMachineTemplateApplyConfiguration, opts v1.ApplyOptions) (result *apiv1beta1.OpenStackMachineTemplate, err error) + // Add a +genclient:noStatus comment above the type to avoid generating ApplyStatus(). + ApplyStatus(ctx context.Context, openStackMachineTemplate *applyconfigurationapiv1beta1.OpenStackMachineTemplateApplyConfiguration, opts v1.ApplyOptions) (result *apiv1beta1.OpenStackMachineTemplate, err error) OpenStackMachineTemplateExpansion } diff --git a/pkg/utils/controllers/controllers.go b/pkg/utils/controllers/controllers.go index 8c5f8f9fa2..11d0684352 100644 --- a/pkg/utils/controllers/controllers.go +++ b/pkg/utils/controllers/controllers.go @@ -17,9 +17,13 @@ limitations under the License. package controllers import ( + "context" "fmt" "net" + clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2" + "sigs.k8s.io/controller-runtime/pkg/client" + infrav1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1" ) @@ -46,3 +50,15 @@ func ValidateSubnets(subnets []infrav1.Subnet) error { } return nil } + +func GetInfraCluster(ctx context.Context, c client.Client, cluster *clusterv1.Cluster, namespace string) (*infrav1.OpenStackCluster, error) { + openStackCluster := &infrav1.OpenStackCluster{} + openStackClusterName := client.ObjectKey{ + Namespace: namespace, + Name: cluster.Spec.InfrastructureRef.Name, + } + if err := c.Get(ctx, openStackClusterName, openStackCluster); err != nil { + return nil, err + } + return openStackCluster, nil +} diff --git a/test/e2e/data/e2e_conf.yaml b/test/e2e/data/e2e_conf.yaml index 6f7e638112..e9ad73b555 100644 --- a/test/e2e/data/e2e_conf.yaml +++ b/test/e2e/data/e2e_conf.yaml @@ -213,6 +213,7 @@ variables: KUBERNETES_VERSION: "v1.34.2" KUBERNETES_VERSION_UPGRADE_FROM: "v1.33.1" KUBERNETES_VERSION_UPGRADE_TO: "v1.34.2" + AUTOSCALER_VERSION: "v1.34.2" # NOTE: To see default images run kubeadm config images list (optionally with --kubernetes-version=vX.Y.Z) ETCD_VERSION_UPGRADE_TO: "3.5.21-0" COREDNS_VERSION_UPGRADE_TO: "v1.12.0" @@ -222,6 +223,7 @@ variables: CCM: "../../data/ccm/cloud-controller-manager.yaml" EXP_CLUSTER_RESOURCE_SET: "true" EXP_CAPO_PRIORITY_QUEUE: "false" + EXP_CAPO_AUTOSCALE_FROM_ZERO: "true" IP_FAMILY: "ipv4" OPENSTACK_BASTION_IMAGE_NAME: "cirros-0.6.1-x86_64-disk" OPENSTACK_BASTION_IMAGE_URL: https://storage.googleapis.com/artifacts.k8s-staging-capi-openstack.appspot.com/test/cirros/2022-12-05/cirros-0.6.1-x86_64-disk.img diff --git a/test/e2e/suites/e2e/autoscaler_test.go b/test/e2e/suites/e2e/autoscaler_test.go new file mode 100644 index 0000000000..0c59421f2b --- /dev/null +++ b/test/e2e/suites/e2e/autoscaler_test.go @@ -0,0 +1,54 @@ +//go:build e2e + +/* +Copyright 2025 The Kubernetes Authors. + +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 e2e + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + capi_e2e "sigs.k8s.io/cluster-api/test/e2e" + + infrav1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1" + "sigs.k8s.io/cluster-api-provider-openstack/test/e2e/shared" +) + +var _ = Describe("Autoscaler on ClusterClass cluster [Autoscaler] [ClusterClass]", func() { + capi_e2e.AutoscalerSpec(context.TODO(), func() capi_e2e.AutoscalerSpecInput { + infraAPIGroup := infrav1.GroupName + autoscalerFlavor := shared.FlavorTopology + + return capi_e2e.AutoscalerSpecInput{ + E2EConfig: e2eCtx.E2EConfig, + ClusterctlConfigPath: e2eCtx.Environment.ClusterctlConfigPath, + BootstrapClusterProxy: e2eCtx.Environment.BootstrapClusterProxy, + ArtifactFolder: e2eCtx.Settings.ArtifactFolder, + SkipCleanup: false, + Flavor: &autoscalerFlavor, + InfrastructureMachineTemplateKind: "openstackmachinetemplates", + // CAPO does not support machinePools + InfrastructureMachinePoolTemplateKind: "", + InfrastructureMachinePoolKind: "", + InfrastructureAPIGroup: infraAPIGroup, + AutoscalerVersion: e2eCtx.E2EConfig.MustGetVariable("AUTOSCALER_VERSION"), + InstallOnManagementCluster: false, + ScaleToAndFromZero: true, + PostNamespaceCreated: nil, + } + }) +})