From 8225d5f4bc082d62f44555a3b522cef237da3d02 Mon Sep 17 00:00:00 2001
From: nikParasyr
Date: Wed, 3 Dec 2025 16:06:03 +0100
Subject: [PATCH 1/5] Extend OpenStackMachineTemplate.status api
Extend OpenStackMachineTemplate.status api with required
field for autoscaling from zero.
Note: Cpu architecture is not added as there are no standardized
settings on openstack where we can retrieve this info
---
Makefile | 1 +
api/v1beta1/openstackmachinetemplate_types.go | 24 +++-
api/v1beta1/zz_generated.deepcopy.go | 39 ++++++
api_violations.report | 6 +
cmd/models-schema/zz_generated.openapi.go | 116 +++++++++++++++++-
...er.x-k8s.io_openstackmachinetemplates.yaml | 28 +++++
docs/book/src/api/v1beta1/api.md | 90 ++++++++++++++
.../api/v1beta1/nodeinfo.go | 39 ++++++
.../api/v1beta1/openstackmachinetemplate.go | 11 +-
.../v1beta1/openstackmachinetemplatestatus.go | 52 ++++++++
.../applyconfiguration/internal/internal.go | 24 ++++
pkg/generated/applyconfiguration/utils.go | 4 +
.../api/v1beta1/openstackmachinetemplate.go | 4 +
13 files changed, 435 insertions(+), 3 deletions(-)
create mode 100644 pkg/generated/applyconfiguration/api/v1beta1/nodeinfo.go
create mode 100644 pkg/generated/applyconfiguration/api/v1beta1/openstackmachinetemplatestatus.go
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..23e35794b6 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,6 +26,26 @@ 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
@@ -35,7 +56,8 @@ 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
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..e234895f60 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,34 @@ 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
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.
+
+
+
+
+| Field |
+Description |
+
+
+
+
+
+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.
+
+
PortOpts
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
}
From 489ce9cf61ceda388d3fd331a1f8caf6f8a3ce6a Mon Sep 17 00:00:00 2001
From: nikParasyr
Date: Wed, 3 Dec 2025 16:08:38 +0100
Subject: [PATCH 2/5] Add OpenStackMachineTemplate controller
Add OSMT controller behind a feature flag with basic
skeleton
---
.../openstackmachinetemplate_controller.go | 76 +++++++++++++++++++
feature/feature.go | 10 ++-
main.go | 62 +++++++++------
3 files changed, 125 insertions(+), 23 deletions(-)
create mode 100644 controllers/openstackmachinetemplate_controller.go
diff --git a/controllers/openstackmachinetemplate_controller.go b/controllers/openstackmachinetemplate_controller.go
new file mode 100644
index 0000000000..97be6bd218
--- /dev/null
+++ b/controllers/openstackmachinetemplate_controller.go
@@ -0,0 +1,76 @@
+/*
+Copyright 2023 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"
+
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/client-go/tools/record"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller"
+
+ "sigs.k8s.io/cluster-api/util/predicates"
+
+ infrav1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1"
+ "sigs.k8s.io/cluster-api-provider-openstack/pkg/scope"
+)
+
+// 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=openstackmachinetemplatess,verbs=get;list;watch;create;
+// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=openstackmachinetemplatess/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.OpenStackMachine{}
+ 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")
+
+ return ctrl.Result{}, 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/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..dd5bbf59bd 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,20 @@ 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) {
From d5880a7f54f5703d652e8ce1e54fc5fa2fdf1afd Mon Sep 17 00:00:00 2001
From: nikParasyr
Date: Thu, 4 Dec 2025 11:30:43 +0100
Subject: [PATCH 3/5] Add logic to OpenStackMachineTemplate controller
Add logic to OpenStackMachineTemplate controller to populate
status with:
- capacity related information taken from the flavor and rootVolume
of the osmt
- operatingSystem info taken from the image of the osmt
---
api/v1beta1/openstackmachinetemplate_types.go | 9 +
...er.x-k8s.io_openstackmachinetemplates.yaml | 2 +
config/manager/manager.yaml | 2 +-
controllers/openstackmachine_controller.go | 15 +-
.../openstackmachinetemplate_controller.go | 159 ++++++-
...penstackmachinetemplate_controller_test.go | 408 ++++++++++++++++++
main.go | 1 -
pkg/clients/compute.go | 15 +
pkg/clients/mock/compute.go | 15 +
pkg/cloud/services/compute/instance.go | 10 +
pkg/utils/controllers/controllers.go | 16 +
11 files changed, 630 insertions(+), 22 deletions(-)
create mode 100644 controllers/openstackmachinetemplate_controller_test.go
diff --git a/api/v1beta1/openstackmachinetemplate_types.go b/api/v1beta1/openstackmachinetemplate_types.go
index 23e35794b6..334b9d40bc 100644
--- a/api/v1beta1/openstackmachinetemplate_types.go
+++ b/api/v1beta1/openstackmachinetemplate_types.go
@@ -50,6 +50,7 @@ type NodeInfo struct {
// +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 {
@@ -72,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/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachinetemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachinetemplates.yaml
index e234895f60..1cb5307c68 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachinetemplates.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachinetemplates.yaml
@@ -981,3 +981,5 @@ spec:
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
index 97be6bd218..4f2868636d 100644
--- a/controllers/openstackmachinetemplate_controller.go
+++ b/controllers/openstackmachinetemplate_controller.go
@@ -1,5 +1,5 @@
/*
-Copyright 2023 The Kubernetes Authors.
+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.
@@ -17,21 +17,34 @@ 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"
- "sigs.k8s.io/cluster-api/util/predicates"
-
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
+// it only updates the .status field to allow auto-scaling.
type OpenStackMachineTemplateReconciler struct {
Client client.Client
Recorder record.EventRecorder
@@ -40,14 +53,14 @@ type OpenStackMachineTemplateReconciler struct {
CaCertificates []byte // PEM encoded ca certificates.
}
-// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=openstackmachinetemplatess,verbs=get;list;watch;create;
-// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=openstackmachinetemplatess/status,verbs=get;update;patch
+// +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.OpenStackMachine{}
+ openStackMachineTemplate := &infrav1.OpenStackMachineTemplate{}
err := r.Client.Get(ctx, req.NamespacedName, openStackMachineTemplate)
if err != nil {
if apierrors.IsNotFound(err) {
@@ -59,9 +72,141 @@ func (r *OpenStackMachineTemplateReconciler) Reconcile(ctx context.Context, req
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)
diff --git a/controllers/openstackmachinetemplate_controller_test.go b/controllers/openstackmachinetemplate_controller_test.go
new file mode 100644
index 0000000000..401ce622b0
--- /dev/null
+++ b/controllers/openstackmachinetemplate_controller_test.go
@@ -0,0 +1,408 @@
+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/main.go b/main.go
index dd5bbf59bd..cf37f8a1f7 100644
--- a/main.go
+++ b/main.go
@@ -412,7 +412,6 @@ func setupReconcilers(ctx context.Context, mgr ctrl.Manager, caCerts []byte) {
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/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
+}
From 4dcc029f64e6300fc53dba0a1dd7ab1232894e98 Mon Sep 17 00:00:00 2001
From: nikParasyr
Date: Thu, 4 Dec 2025 15:07:12 +0100
Subject: [PATCH 4/5] Add documentation for AutoScaleFromZero
Add documentation for the `AutoScaleFromZero` feature flag
---
docs/book/src/SUMMARY.md | 1 +
.../autoscale-from-zero.md | 39 +++++++++++++++++++
.../experimental-features.md | 1 +
3 files changed, 41 insertions(+)
create mode 100644 docs/book/src/experimental-features/autoscale-from-zero.md
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/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
From 4156537e05c374e0a188ffe3a17bdcd79b1a6d2a Mon Sep 17 00:00:00 2001
From: nikParasyr
Date: Thu, 4 Dec 2025 15:07:51 +0100
Subject: [PATCH 5/5] Add e2e tests for autoscaling
---
...penstackmachinetemplate_controller_test.go | 16 ++++++
test/e2e/data/e2e_conf.yaml | 2 +
test/e2e/suites/e2e/autoscaler_test.go | 54 +++++++++++++++++++
3 files changed, 72 insertions(+)
create mode 100644 test/e2e/suites/e2e/autoscaler_test.go
diff --git a/controllers/openstackmachinetemplate_controller_test.go b/controllers/openstackmachinetemplate_controller_test.go
index 401ce622b0..eebf6e6d7a 100644
--- a/controllers/openstackmachinetemplate_controller_test.go
+++ b/controllers/openstackmachinetemplate_controller_test.go
@@ -1,3 +1,19 @@
+/*
+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 (
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,
+ }
+ })
+})