diff --git a/Makefile b/Makefile
index a4fe820341..6d604cce79 100644
--- a/Makefile
+++ b/Makefile
@@ -188,7 +188,8 @@ e2e-templates: $(addprefix $(E2E_NO_ARTIFACT_TEMPLATES_DIR)/, \
cluster-template-flatcar-sysext.yaml \
cluster-template-no-bastion.yaml \
cluster-template-health-monitor.yaml \
- cluster-template-capi-v1beta1.yaml)
+ cluster-template-capi-v1beta1.yaml \
+ cluster-template-cluster-identity.yaml)
# Currently no templates that require CI artifacts
# $(addprefix $(E2E_TEMPLATES_DIR)/, add-templates-here.yaml) \
diff --git a/PROJECT b/PROJECT
index 602e6dca82..a66e876837 100644
--- a/PROJECT
+++ b/PROJECT
@@ -23,5 +23,8 @@ resources:
- group: infrastructure
kind: OpenStackServer
version: v1alpha1
+- group: infrastructure
+ kind: OpenStackClusterIdentity
+ version: v1alpha1
- group: infrastructure
version: "2"
diff --git a/api/v1alpha1/openstackclusteridentity_types.go b/api/v1alpha1/openstackclusteridentity_types.go
new file mode 100644
index 0000000000..d8dbff3f54
--- /dev/null
+++ b/api/v1alpha1/openstackclusteridentity_types.go
@@ -0,0 +1,68 @@
+/*
+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 v1alpha1
+
+import (
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// OpenStackCredentialSecretReference references a Secret containing OpenStack credentials.
+type OpenStackCredentialSecretReference struct {
+ // Name of the Secret which contains a `clouds.yaml` key (and optionally `cacert`).
+ // +kubebuilder:validation:Required
+ Name string `json:"name"`
+
+ // Namespace where the Secret resides.
+ // +kubebuilder:validation:Required
+ Namespace string `json:"namespace"`
+}
+
+// OpenStackClusterIdentitySpec defines the desired state for an OpenStackClusterIdentity.
+type OpenStackClusterIdentitySpec struct {
+ // SecretRef references the credentials Secret containing a `clouds.yaml` file.
+ // +kubebuilder:validation:Required
+ SecretRef OpenStackCredentialSecretReference `json:"secretRef"`
+
+ // NamespaceSelector limits which namespaces may use this identity. If nil, all namespaces are allowed.
+ // +optional
+ NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty"`
+}
+
+// +genclient
+// +kubebuilder:object:root=true
+// +kubebuilder:resource:path=openstackclusteridentities,scope=Cluster,categories=cluster-api,shortName=osci
+
+// OpenStackClusterIdentity is a cluster-scoped identity that centralizes OpenStack credentials.
+type OpenStackClusterIdentity struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ Spec OpenStackClusterIdentitySpec `json:"spec,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// OpenStackClusterIdentityList contains a list of OpenStackClusterIdentity.
+type OpenStackClusterIdentityList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []OpenStackClusterIdentity `json:"items"`
+}
+
+func init() {
+ SchemeBuilder.Register(&OpenStackClusterIdentity{}, &OpenStackClusterIdentityList{})
+}
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index 6a2484af9e..079f533085 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -21,12 +21,107 @@ limitations under the License.
package v1alpha1
import (
- "k8s.io/api/core/v1"
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1"
corev1beta1 "sigs.k8s.io/cluster-api/api/core/v1beta1"
)
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *OpenStackClusterIdentity) DeepCopyInto(out *OpenStackClusterIdentity) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ in.Spec.DeepCopyInto(&out.Spec)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenStackClusterIdentity.
+func (in *OpenStackClusterIdentity) DeepCopy() *OpenStackClusterIdentity {
+ if in == nil {
+ return nil
+ }
+ out := new(OpenStackClusterIdentity)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *OpenStackClusterIdentity) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *OpenStackClusterIdentityList) DeepCopyInto(out *OpenStackClusterIdentityList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]OpenStackClusterIdentity, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenStackClusterIdentityList.
+func (in *OpenStackClusterIdentityList) DeepCopy() *OpenStackClusterIdentityList {
+ if in == nil {
+ return nil
+ }
+ out := new(OpenStackClusterIdentityList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *OpenStackClusterIdentityList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *OpenStackClusterIdentitySpec) DeepCopyInto(out *OpenStackClusterIdentitySpec) {
+ *out = *in
+ out.SecretRef = in.SecretRef
+ if in.NamespaceSelector != nil {
+ in, out := &in.NamespaceSelector, &out.NamespaceSelector
+ *out = new(v1.LabelSelector)
+ (*in).DeepCopyInto(*out)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenStackClusterIdentitySpec.
+func (in *OpenStackClusterIdentitySpec) DeepCopy() *OpenStackClusterIdentitySpec {
+ if in == nil {
+ return nil
+ }
+ out := new(OpenStackClusterIdentitySpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *OpenStackCredentialSecretReference) DeepCopyInto(out *OpenStackCredentialSecretReference) {
+ *out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenStackCredentialSecretReference.
+func (in *OpenStackCredentialSecretReference) DeepCopy() *OpenStackCredentialSecretReference {
+ if in == nil {
+ return nil
+ }
+ out := new(OpenStackCredentialSecretReference)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *OpenStackFloatingIPPool) DeepCopyInto(out *OpenStackFloatingIPPool) {
*out = *in
@@ -250,7 +345,7 @@ func (in *OpenStackServerSpec) DeepCopyInto(out *OpenStackServerSpec) {
}
if in.FloatingIPPoolRef != nil {
in, out := &in.FloatingIPPoolRef, &out.FloatingIPPoolRef
- *out = new(v1.TypedLocalObjectReference)
+ *out = new(corev1.TypedLocalObjectReference)
(*in).DeepCopyInto(*out)
}
out.IdentityRef = in.IdentityRef
@@ -296,7 +391,7 @@ func (in *OpenStackServerSpec) DeepCopyInto(out *OpenStackServerSpec) {
}
if in.UserDataRef != nil {
in, out := &in.UserDataRef, &out.UserDataRef
- *out = new(v1.LocalObjectReference)
+ *out = new(corev1.LocalObjectReference)
**out = **in
}
if in.SchedulerHintAdditionalProperties != nil {
@@ -333,7 +428,7 @@ func (in *OpenStackServerStatus) DeepCopyInto(out *OpenStackServerStatus) {
}
if in.Addresses != nil {
in, out := &in.Addresses, &out.Addresses
- *out = make([]v1.NodeAddress, len(*in))
+ *out = make([]corev1.NodeAddress, len(*in))
copy(*out, *in)
}
if in.Resolved != nil {
diff --git a/api/v1beta1/identity_types.go b/api/v1beta1/identity_types.go
index 41a74bf686..85add4f626 100644
--- a/api/v1beta1/identity_types.go
+++ b/api/v1beta1/identity_types.go
@@ -20,14 +20,23 @@ package v1beta1
// provider identity to be used to provision cluster resources.
// +kubebuilder:validation:XValidation:rule="(!has(self.region) && !has(oldSelf.region)) || self.region == oldSelf.region",message="region is immutable"
type OpenStackIdentityReference struct {
- // Name is the name of a secret in the same namespace as the resource being provisioned.
- // The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
- // The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+ // Type specifies the identity reference type. Defaults to Secret for backward compatibility.
+ // +kubebuilder:validation:Enum=Secret;ClusterIdentity
+ // +kubebuilder:default=Secret
// +kubebuilder:validation:Required
+ Type string `json:"type,omitempty"`
+
+ // Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+ // or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+ // The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
+ // The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+ // +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
Name string `json:"name"`
// CloudName specifies the name of the entry in the clouds.yaml file to use.
// +kubebuilder:validation:Required
+ // +kubebuilder:validation:MinLength=1
CloudName string `json:"cloudName"`
// Region specifies an OpenStack region to use. If specified, it overrides
diff --git a/cmd/models-schema/zz_generated.openapi.go b/cmd/models-schema/zz_generated.openapi.go
index 23b7707f4d..a6c2ea4334 100644
--- a/cmd/models-schema/zz_generated.openapi.go
+++ b/cmd/models-schema/zz_generated.openapi.go
@@ -315,6 +315,10 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"k8s.io/apimachinery/pkg/runtime.TypeMeta": schema_k8sio_apimachinery_pkg_runtime_TypeMeta(ref),
"k8s.io/apimachinery/pkg/runtime.Unknown": schema_k8sio_apimachinery_pkg_runtime_Unknown(ref),
"k8s.io/apimachinery/pkg/version.Info": schema_k8sio_apimachinery_pkg_version_Info(ref),
+ "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackClusterIdentity": schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackClusterIdentity(ref),
+ "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackClusterIdentityList": schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackClusterIdentityList(ref),
+ "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackClusterIdentitySpec": schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackClusterIdentitySpec(ref),
+ "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackCredentialSecretReference": schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackCredentialSecretReference(ref),
"sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackFloatingIPPool": schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackFloatingIPPool(ref),
"sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackFloatingIPPoolList": schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackFloatingIPPoolList(ref),
"sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackFloatingIPPoolSpec": schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackFloatingIPPoolSpec(ref),
@@ -16754,6 +16758,155 @@ func schema_k8sio_apimachinery_pkg_version_Info(ref common.ReferenceCallback) co
}
}
+func schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackClusterIdentity(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "OpenStackClusterIdentity is a cluster-scoped identity that centralizes OpenStack credentials.",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "kind": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "apiVersion": {
+ SchemaProps: spec.SchemaProps{
+ Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "metadata": {
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
+ },
+ },
+ "spec": {
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackClusterIdentitySpec"),
+ },
+ },
+ },
+ },
+ },
+ Dependencies: []string{
+ "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta", "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackClusterIdentitySpec"},
+ }
+}
+
+func schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackClusterIdentityList(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "OpenStackClusterIdentityList contains a list of OpenStackClusterIdentity.",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "kind": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "apiVersion": {
+ SchemaProps: spec.SchemaProps{
+ Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "metadata": {
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
+ },
+ },
+ "items": {
+ SchemaProps: spec.SchemaProps{
+ Type: []string{"array"},
+ Items: &spec.SchemaOrArray{
+ Schema: &spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Default: map[string]interface{}{},
+ Ref: ref("sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackClusterIdentity"),
+ },
+ },
+ },
+ },
+ },
+ },
+ Required: []string{"items"},
+ },
+ },
+ Dependencies: []string{
+ "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta", "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackClusterIdentity"},
+ }
+}
+
+func schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackClusterIdentitySpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "OpenStackClusterIdentitySpec defines the desired state for an OpenStackClusterIdentity.",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "secretRef": {
+ SchemaProps: spec.SchemaProps{
+ Description: "SecretRef references the credentials Secret containing a `clouds.yaml` file.",
+ Default: map[string]interface{}{},
+ Ref: ref("sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackCredentialSecretReference"),
+ },
+ },
+ "namespaceSelector": {
+ SchemaProps: spec.SchemaProps{
+ Description: "NamespaceSelector limits which namespaces may use this identity. If nil, all namespaces are allowed.",
+ Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelector"),
+ },
+ },
+ },
+ Required: []string{"secretRef"},
+ },
+ },
+ Dependencies: []string{
+ "k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelector", "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackCredentialSecretReference"},
+ }
+}
+
+func schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackCredentialSecretReference(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "OpenStackCredentialSecretReference references a Secret containing OpenStack credentials.",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "name": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Name of the Secret which contains a `clouds.yaml` key (and optionally `cacert`).",
+ Default: "",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "namespace": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Namespace where the Secret resides.",
+ Default: "",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ },
+ Required: []string{"name", "namespace"},
+ },
+ },
+ }
+}
+
func schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackFloatingIPPool(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -19120,9 +19273,16 @@ func schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_OpenStackIdenti
Description: "OpenStackIdentityReference is a reference to an infrastructure provider identity to be used to provision cluster resources.",
Type: []string{"object"},
Properties: map[string]spec.Schema{
+ "type": {
+ SchemaProps: spec.SchemaProps{
+ Description: "Type specifies the identity reference type. Defaults to Secret for backward compatibility.",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
"name": {
SchemaProps: spec.SchemaProps{
- Description: "Name is the name of a secret in the same namespace as the resource being provisioned. The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file. The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.",
+ Description: "Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned, or the name of an OpenStackClusterIdentity (type=ClusterIdentity). The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file. The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.",
Default: "",
Type: []string{"string"},
Format: "",
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclusteridentities.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclusteridentities.yaml
new file mode 100644
index 0000000000..4cdedd73d9
--- /dev/null
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclusteridentities.yaml
@@ -0,0 +1,115 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.18.0
+ name: openstackclusteridentities.infrastructure.cluster.x-k8s.io
+spec:
+ group: infrastructure.cluster.x-k8s.io
+ names:
+ categories:
+ - cluster-api
+ kind: OpenStackClusterIdentity
+ listKind: OpenStackClusterIdentityList
+ plural: openstackclusteridentities
+ shortNames:
+ - osci
+ singular: openstackclusteridentity
+ scope: Cluster
+ versions:
+ - name: v1alpha1
+ schema:
+ openAPIV3Schema:
+ description: OpenStackClusterIdentity is a cluster-scoped identity that centralizes
+ OpenStack credentials.
+ properties:
+ apiVersion:
+ description: |-
+ APIVersion defines the versioned schema of this representation of an object.
+ Servers should convert recognized schemas to the latest internal value, and
+ may reject unrecognized values.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+ type: string
+ kind:
+ description: |-
+ Kind is a string value representing the REST resource this object represents.
+ Servers may infer this from the endpoint the client submits requests to.
+ Cannot be updated.
+ In CamelCase.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ metadata:
+ type: object
+ spec:
+ description: OpenStackClusterIdentitySpec defines the desired state for
+ an OpenStackClusterIdentity.
+ properties:
+ namespaceSelector:
+ description: NamespaceSelector limits which namespaces may use this
+ identity. If nil, all namespaces are allowed.
+ properties:
+ matchExpressions:
+ description: matchExpressions is a list of label selector requirements.
+ The requirements are ANDed.
+ items:
+ description: |-
+ A label selector requirement is a selector that contains values, a key, and an operator that
+ relates the key and values.
+ properties:
+ key:
+ description: key is the label key that the selector applies
+ to.
+ type: string
+ operator:
+ description: |-
+ operator represents a key's relationship to a set of values.
+ Valid operators are In, NotIn, Exists and DoesNotExist.
+ type: string
+ values:
+ description: |-
+ values is an array of string values. If the operator is In or NotIn,
+ the values array must be non-empty. If the operator is Exists or DoesNotExist,
+ the values array must be empty. This array is replaced during a strategic
+ merge patch.
+ items:
+ type: string
+ type: array
+ x-kubernetes-list-type: atomic
+ required:
+ - key
+ - operator
+ type: object
+ type: array
+ x-kubernetes-list-type: atomic
+ matchLabels:
+ additionalProperties:
+ type: string
+ description: |-
+ matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+ map is equivalent to an element of matchExpressions, whose key field is "key", the
+ operator is "In", and the values array contains only "value". The requirements are ANDed.
+ type: object
+ type: object
+ x-kubernetes-map-type: atomic
+ secretRef:
+ description: SecretRef references the credentials Secret containing
+ a `clouds.yaml` file.
+ properties:
+ name:
+ description: Name of the Secret which contains a `clouds.yaml`
+ key (and optionally `cacert`).
+ type: string
+ namespace:
+ description: Namespace where the Secret resides.
+ type: string
+ required:
+ - name
+ - namespace
+ type: object
+ required:
+ - secretRef
+ type: object
+ type: object
+ served: true
+ storage: true
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclusters.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclusters.yaml
index d533d9812e..4cf9b3daec 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclusters.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclusters.yaml
@@ -508,12 +508,15 @@ spec:
cloudName:
description: CloudName specifies the name of the entry
in the clouds.yaml file to use.
+ minLength: 1
type: string
name:
description: |-
- Name is the name of a secret in the same namespace as the resource being provisioned.
- The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
- The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+ Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+ or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+ The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
+ The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+ minLength: 1
type: string
region:
description: |-
@@ -521,9 +524,18 @@ spec:
any value in clouds.yaml. If specified for an OpenStackMachine, its
value will be included in providerID.
type: string
+ type:
+ default: Secret
+ description: Type specifies the identity reference type.
+ Defaults to Secret for backward compatibility.
+ enum:
+ - Secret
+ - ClusterIdentity
+ type: string
required:
- cloudName
- name
+ - type
type: object
x-kubernetes-validations:
- message: region is immutable
@@ -1518,12 +1530,15 @@ spec:
cloudName:
description: CloudName specifies the name of the entry in the
clouds.yaml file to use.
+ minLength: 1
type: string
name:
description: |-
- Name is the name of a secret in the same namespace as the resource being provisioned.
- The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
- The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+ Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+ or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+ The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
+ The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+ minLength: 1
type: string
region:
description: |-
@@ -1531,9 +1546,18 @@ spec:
any value in clouds.yaml. If specified for an OpenStackMachine, its
value will be included in providerID.
type: string
+ type:
+ default: Secret
+ description: Type specifies the identity reference type. Defaults
+ to Secret for backward compatibility.
+ enum:
+ - Secret
+ - ClusterIdentity
+ type: string
required:
- cloudName
- name
+ - type
type: object
x-kubernetes-validations:
- message: region is immutable
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclustertemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclustertemplates.yaml
index 7dbf05390c..084846a638 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclustertemplates.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclustertemplates.yaml
@@ -497,12 +497,15 @@ spec:
cloudName:
description: CloudName specifies the name of the
entry in the clouds.yaml file to use.
+ minLength: 1
type: string
name:
description: |-
- Name is the name of a secret in the same namespace as the resource being provisioned.
- The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
- The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+ Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+ or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+ The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
+ The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+ minLength: 1
type: string
region:
description: |-
@@ -510,9 +513,18 @@ spec:
any value in clouds.yaml. If specified for an OpenStackMachine, its
value will be included in providerID.
type: string
+ type:
+ default: Secret
+ description: Type specifies the identity reference
+ type. Defaults to Secret for backward compatibility.
+ enum:
+ - Secret
+ - ClusterIdentity
+ type: string
required:
- cloudName
- name
+ - type
type: object
x-kubernetes-validations:
- message: region is immutable
@@ -1525,12 +1537,15 @@ spec:
cloudName:
description: CloudName specifies the name of the entry
in the clouds.yaml file to use.
+ minLength: 1
type: string
name:
description: |-
- Name is the name of a secret in the same namespace as the resource being provisioned.
- The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
- The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+ Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+ or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+ The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
+ The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+ minLength: 1
type: string
region:
description: |-
@@ -1538,9 +1553,18 @@ spec:
any value in clouds.yaml. If specified for an OpenStackMachine, its
value will be included in providerID.
type: string
+ type:
+ default: Secret
+ description: Type specifies the identity reference type.
+ Defaults to Secret for backward compatibility.
+ enum:
+ - Secret
+ - ClusterIdentity
+ type: string
required:
- cloudName
- name
+ - type
type: object
x-kubernetes-validations:
- message: region is immutable
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackfloatingippools.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackfloatingippools.yaml
index e4ecd7e2a1..71e24ac453 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackfloatingippools.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackfloatingippools.yaml
@@ -126,12 +126,15 @@ spec:
cloudName:
description: CloudName specifies the name of the entry in the
clouds.yaml file to use.
+ minLength: 1
type: string
name:
description: |-
- Name is the name of a secret in the same namespace as the resource being provisioned.
- The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
- The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+ Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+ or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+ The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
+ The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+ minLength: 1
type: string
region:
description: |-
@@ -139,9 +142,18 @@ spec:
any value in clouds.yaml. If specified for an OpenStackMachine, its
value will be included in providerID.
type: string
+ type:
+ default: Secret
+ description: Type specifies the identity reference type. Defaults
+ to Secret for backward compatibility.
+ enum:
+ - Secret
+ - ClusterIdentity
+ type: string
required:
- cloudName
- name
+ - type
type: object
x-kubernetes-validations:
- message: region is immutable
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachines.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachines.yaml
index 608664dc2b..0dc88ef8c7 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachines.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachines.yaml
@@ -195,12 +195,15 @@ spec:
cloudName:
description: CloudName specifies the name of the entry in the
clouds.yaml file to use.
+ minLength: 1
type: string
name:
description: |-
- Name is the name of a secret in the same namespace as the resource being provisioned.
- The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
- The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+ Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+ or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+ The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
+ The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+ minLength: 1
type: string
region:
description: |-
@@ -208,9 +211,18 @@ spec:
any value in clouds.yaml. If specified for an OpenStackMachine, its
value will be included in providerID.
type: string
+ type:
+ default: Secret
+ description: Type specifies the identity reference type. Defaults
+ to Secret for backward compatibility.
+ enum:
+ - Secret
+ - ClusterIdentity
+ type: string
required:
- cloudName
- name
+ - type
type: object
x-kubernetes-validations:
- message: region is immutable
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 1d37f987be..e57463f1c3 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachinetemplates.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachinetemplates.yaml
@@ -186,12 +186,15 @@ spec:
cloudName:
description: CloudName specifies the name of the entry
in the clouds.yaml file to use.
+ minLength: 1
type: string
name:
description: |-
- Name is the name of a secret in the same namespace as the resource being provisioned.
- The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
- The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+ Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+ or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+ The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
+ The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+ minLength: 1
type: string
region:
description: |-
@@ -199,9 +202,18 @@ spec:
any value in clouds.yaml. If specified for an OpenStackMachine, its
value will be included in providerID.
type: string
+ type:
+ default: Secret
+ description: Type specifies the identity reference type.
+ Defaults to Secret for backward compatibility.
+ enum:
+ - Secret
+ - ClusterIdentity
+ type: string
required:
- cloudName
- name
+ - type
type: object
x-kubernetes-validations:
- message: region is immutable
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackservers.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackservers.yaml
index 99905f06ed..31de37e0a9 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackservers.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackservers.yaml
@@ -192,12 +192,15 @@ spec:
cloudName:
description: CloudName specifies the name of the entry in the
clouds.yaml file to use.
+ minLength: 1
type: string
name:
description: |-
- Name is the name of a secret in the same namespace as the resource being provisioned.
- The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
- The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+ Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+ or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+ The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
+ The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+ minLength: 1
type: string
region:
description: |-
@@ -205,9 +208,18 @@ spec:
any value in clouds.yaml. If specified for an OpenStackMachine, its
value will be included in providerID.
type: string
+ type:
+ default: Secret
+ description: Type specifies the identity reference type. Defaults
+ to Secret for backward compatibility.
+ enum:
+ - Secret
+ - ClusterIdentity
+ type: string
required:
- cloudName
- name
+ - type
type: object
x-kubernetes-validations:
- message: region is immutable
diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml
index c195c1519e..24258bb532 100644
--- a/config/crd/kustomization.yaml
+++ b/config/crd/kustomization.yaml
@@ -8,6 +8,7 @@ labels:
# It should be run by config/
resources:
- bases/infrastructure.cluster.x-k8s.io_openstackclusters.yaml
+- bases/infrastructure.cluster.x-k8s.io_openstackclusteridentities.yaml
- bases/infrastructure.cluster.x-k8s.io_openstackmachines.yaml
- bases/infrastructure.cluster.x-k8s.io_openstackmachinetemplates.yaml
- bases/infrastructure.cluster.x-k8s.io_openstackclustertemplates.yaml
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index 5428aab1cb..dc44ee6b1d 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -18,6 +18,7 @@ rules:
- apiGroups:
- ""
resources:
+ - namespaces
- secrets
verbs:
- get
@@ -46,6 +47,14 @@ rules:
- get
- list
- watch
+- apiGroups:
+ - infrastructure.cluster.x-k8s.io
+ resources:
+ - openstackclusteridentities
+ verbs:
+ - get
+ - list
+ - watch
- apiGroups:
- infrastructure.cluster.x-k8s.io
resources:
diff --git a/controllers/openstackcluster_controller.go b/controllers/openstackcluster_controller.go
index abb5ee2262..3989fc155e 100644
--- a/controllers/openstackcluster_controller.go
+++ b/controllers/openstackcluster_controller.go
@@ -75,6 +75,8 @@ type OpenStackClusterReconciler struct {
// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=openstackclusters,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=openstackclusters/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=clusters;clusters/status,verbs=get;list;watch
+// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch
+// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=openstackclusteridentities,verbs=get;list;watch
func (r *OpenStackClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, reterr error) {
log := ctrl.LoggerFrom(ctx)
diff --git a/controllers/openstackcluster_controller_test.go b/controllers/openstackcluster_controller_test.go
index 48c9bf19e4..548e7a28c1 100644
--- a/controllers/openstackcluster_controller_test.go
+++ b/controllers/openstackcluster_controller_test.go
@@ -39,6 +39,7 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
+ infrav1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1"
infrav1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1"
"sigs.k8s.io/cluster-api-provider-openstack/pkg/cloud/services/networking"
"sigs.k8s.io/cluster-api-provider-openstack/pkg/scope"
@@ -89,7 +90,12 @@ var _ = Describe("OpenStackCluster controller", func() {
},
},
},
- Spec: infrav1.OpenStackClusterSpec{},
+ Spec: infrav1.OpenStackClusterSpec{
+ IdentityRef: infrav1.OpenStackIdentityReference{
+ Name: "test-creds",
+ CloudName: "openstack",
+ },
+ },
Status: infrav1.OpenStackClusterStatus{},
}
capiCluster = &clusterv1.Cluster{
@@ -148,6 +154,120 @@ var _ = Describe("OpenStackCluster controller", func() {
framework.DeleteNamespace(ctx, input)
})
+ It("should create OpenStackClusterIdentity (CRD present)", func() {
+ err := k8sClient.Create(ctx, testCluster)
+ Expect(err).To(BeNil())
+ err = k8sClient.Create(ctx, capiCluster)
+ Expect(err).To(BeNil())
+
+ id := &infrav1alpha1.OpenStackClusterIdentity{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: fmt.Sprintf("id-%d", GinkgoRandomSeed()),
+ },
+ Spec: infrav1alpha1.OpenStackClusterIdentitySpec{
+ SecretRef: infrav1alpha1.OpenStackCredentialSecretReference{
+ Name: "creds",
+ Namespace: "capo-system",
+ },
+ },
+ }
+ Expect(k8sClient.Create(ctx, id)).To(Succeed())
+
+ // Cleanup cluster-scoped resource since it won't be deleted with namespace
+ DeferCleanup(func() {
+ Expect(k8sClient.Delete(ctx, id)).To(Succeed())
+ })
+ })
+
+ It("should successfully create OpenStackCluster with valid identityRef", func() {
+ testCluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+ Name: "creds",
+ CloudName: "openstack",
+ // Type should default to "Secret"
+ }
+ err := k8sClient.Create(ctx, testCluster)
+ Expect(err).To(BeNil())
+ err = k8sClient.Create(ctx, capiCluster)
+ Expect(err).To(BeNil())
+
+ // Verify the object was created and Type was defaulted
+ created := &infrav1.OpenStackCluster{}
+ err = k8sClient.Get(ctx, client.ObjectKey{Name: testCluster.Name, Namespace: testCluster.Namespace}, created)
+ Expect(err).To(Succeed())
+ Expect(created.Spec.IdentityRef.Type).To(Equal("Secret"))
+ Expect(created.Spec.IdentityRef.Name).To(Equal("creds"))
+ Expect(created.Spec.IdentityRef.CloudName).To(Equal("openstack"))
+ })
+
+ It("should successfully create OpenStackCluster with ClusterIdentity type", func() {
+ testCluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+ Type: "ClusterIdentity",
+ Name: "global-creds",
+ CloudName: "openstack",
+ Region: "RegionOne",
+ }
+ err := k8sClient.Create(ctx, testCluster)
+ Expect(err).To(BeNil())
+ err = k8sClient.Create(ctx, capiCluster)
+ Expect(err).To(BeNil())
+
+ // Verify all fields are preserved
+ created := &infrav1.OpenStackCluster{}
+ err = k8sClient.Get(ctx, client.ObjectKey{Name: testCluster.Name, Namespace: testCluster.Namespace}, created)
+ Expect(err).To(Succeed())
+ Expect(created.Spec.IdentityRef.Type).To(Equal("ClusterIdentity"))
+ Expect(created.Spec.IdentityRef.Name).To(Equal("global-creds"))
+ Expect(created.Spec.IdentityRef.CloudName).To(Equal("openstack"))
+ Expect(created.Spec.IdentityRef.Region).To(Equal("RegionOne"))
+ })
+
+ It("should fail when namespace is denied access to ClusterIdentity", func() {
+ testCluster.SetName("identity-access-denied")
+ testCluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+ Type: "ClusterIdentity",
+ Name: "test-cluster-identity",
+ CloudName: "openstack",
+ }
+
+ err := k8sClient.Create(ctx, testCluster)
+ Expect(err).To(BeNil())
+ err = k8sClient.Create(ctx, capiCluster)
+ Expect(err).To(BeNil())
+
+ identityAccessErr := &scope.IdentityAccessDeniedError{
+ IdentityName: "test-cluster-identity",
+ RequesterNamespace: testNamespace,
+ }
+ mockScopeFactory.SetClientScopeCreateError(identityAccessErr)
+
+ req := createRequestFromOSCluster(testCluster)
+ result, err := reconciler.Reconcile(ctx, req)
+
+ Expect(err).To(MatchError(identityAccessErr))
+ Expect(result).To(Equal(reconcile.Result{}))
+ })
+
+ It("should reject updates that modify identityRef.region (immutable)", func() {
+ testCluster.Spec = infrav1.OpenStackClusterSpec{
+ IdentityRef: infrav1.OpenStackIdentityReference{
+ Type: "Secret",
+ Name: "creds",
+ CloudName: "openstack",
+ Region: "RegionOne",
+ },
+ }
+ err := k8sClient.Create(ctx, testCluster)
+ Expect(err).To(BeNil())
+ err = k8sClient.Create(ctx, capiCluster)
+ Expect(err).To(BeNil())
+
+ // Try to update region
+ fetched := &infrav1.OpenStackCluster{}
+ Expect(k8sClient.Get(ctx, types.NamespacedName{Name: testClusterName, Namespace: testNamespace}, fetched)).To(Succeed())
+ fetched.Spec.IdentityRef.Region = "RegionTwo"
+ Expect(k8sClient.Update(ctx, fetched)).ToNot(Succeed())
+ })
+
It("should do nothing when owner is missing", func() {
testCluster.SetName("missing-owner")
testCluster.SetOwnerReferences([]metav1.OwnerReference{})
@@ -197,6 +317,10 @@ var _ = Describe("OpenStackCluster controller", func() {
It("should be able to reconcile when bastion is explicitly disabled and does not exist", func() {
testCluster.SetName("no-bastion-explicit")
testCluster.Spec = infrav1.OpenStackClusterSpec{
+ IdentityRef: infrav1.OpenStackIdentityReference{
+ Name: "test-creds",
+ CloudName: "openstack",
+ },
Bastion: &infrav1.Bastion{Enabled: ptr.To(false)},
}
err := k8sClient.Create(ctx, testCluster)
@@ -221,7 +345,12 @@ var _ = Describe("OpenStackCluster controller", func() {
})
It("should delete an existing bastion even if its uuid is not stored in status", func() {
testCluster.SetName("delete-existing-bastion")
- testCluster.Spec = infrav1.OpenStackClusterSpec{}
+ testCluster.Spec = infrav1.OpenStackClusterSpec{
+ IdentityRef: infrav1.OpenStackIdentityReference{
+ Name: "test-creds",
+ CloudName: "openstack",
+ },
+ }
err := k8sClient.Create(ctx, testCluster)
Expect(err).To(BeNil())
err = k8sClient.Create(ctx, capiCluster)
@@ -252,6 +381,10 @@ var _ = Describe("OpenStackCluster controller", func() {
testCluster.SetName("subnet-filtering")
testCluster.Spec = infrav1.OpenStackClusterSpec{
+ IdentityRef: infrav1.OpenStackIdentityReference{
+ Name: "test-creds",
+ CloudName: "openstack",
+ },
Bastion: &infrav1.Bastion{
Enabled: ptr.To(true),
Spec: &bastionSpec,
@@ -322,6 +455,10 @@ var _ = Describe("OpenStackCluster controller", func() {
testCluster.SetName("subnet-filtering")
testCluster.Spec = infrav1.OpenStackClusterSpec{
+ IdentityRef: infrav1.OpenStackIdentityReference{
+ Name: "test-creds",
+ CloudName: "openstack",
+ },
Bastion: &infrav1.Bastion{
Enabled: ptr.To(true),
Spec: &bastionSpec,
@@ -399,6 +536,10 @@ var _ = Describe("OpenStackCluster controller", func() {
testCluster.SetName("subnet-filtering")
testCluster.Spec = infrav1.OpenStackClusterSpec{
+ IdentityRef: infrav1.OpenStackIdentityReference{
+ Name: "test-creds",
+ CloudName: "openstack",
+ },
DisableAPIServerFloatingIP: ptr.To(true),
APIServerFixedIP: ptr.To("10.0.0.1"),
DisableExternalNetwork: ptr.To(true),
@@ -442,6 +583,10 @@ var _ = Describe("OpenStackCluster controller", func() {
testCluster.SetName("pre-existing-network-components-by-id")
testCluster.Spec = infrav1.OpenStackClusterSpec{
+ IdentityRef: infrav1.OpenStackIdentityReference{
+ Name: "test-creds",
+ CloudName: "openstack",
+ },
Network: &infrav1.NetworkParam{
ID: ptr.To(clusterNetworkID),
},
@@ -501,6 +646,10 @@ var _ = Describe("OpenStackCluster controller", func() {
testCluster.SetName("pre-existing-network-components-by-id")
testCluster.Spec = infrav1.OpenStackClusterSpec{
+ IdentityRef: infrav1.OpenStackIdentityReference{
+ Name: "test-creds",
+ CloudName: "openstack",
+ },
Network: &infrav1.NetworkParam{
Filter: &infrav1.NetworkFilter{
Name: clusterNetworkName,
diff --git a/controllers/openstackmachine_controller.go b/controllers/openstackmachine_controller.go
index 51e1c91d53..d54fecf39a 100644
--- a/controllers/openstackmachine_controller.go
+++ b/controllers/openstackmachine_controller.go
@@ -77,6 +77,8 @@ const (
// +kubebuilder:rbac:groups=ipam.cluster.x-k8s.io,resources=ipaddresses;ipaddresses/status,verbs=get;list;watch
// +kubebuilder:rbac:groups="",resources=secrets;,verbs=get;list;watch
// +kubebuilder:rbac:groups="",resources=events,verbs=get;list;watch;create;update;patch
+// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch
+// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=openstackclusteridentities,verbs=get;list;watch
func (r *OpenStackMachineReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, reterr error) {
log := ctrl.LoggerFrom(ctx)
diff --git a/controllers/openstackserver_controller.go b/controllers/openstackserver_controller.go
index 8ea05b8c93..524324d96d 100644
--- a/controllers/openstackserver_controller.go
+++ b/controllers/openstackserver_controller.go
@@ -81,6 +81,8 @@ type OpenStackServerReconciler struct {
// +kubebuilder:rbac:groups=ipam.cluster.x-k8s.io,resources=ipaddressclaims;ipaddressclaims/status,verbs=get;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=ipam.cluster.x-k8s.io,resources=ipaddresses;ipaddresses/status,verbs=get;list;watch
// +kubebuilder:rbac:groups=openstack.k-orc.cloud,resources=images,verbs=get;list;watch
+// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch
+// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=openstackclusteridentities,verbs=get;list;watch
func (r *OpenStackServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, reterr error) {
log := ctrl.LoggerFrom(ctx)
diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md
index 91ce91ac19..70b6090904 100644
--- a/docs/book/src/SUMMARY.md
+++ b/docs/book/src/SUMMARY.md
@@ -8,6 +8,7 @@
- [hosted control plane](./topics/hosted-control-plane.md)
- [move from bootstrap](./topics/mover.md)
- [trouble shooting](./topics/troubleshooting.md)
+ - [OpenStackClusterIdentity](./topics/openstack-cluster-identity.md)
- [CRD Changes](./topics/crd-changes/index.md)
- [v1alpha4 to v1alpha5](./topics/crd-changes/v1alpha4-to-v1alpha5.md)
- [v1alpha5 to v1alpha6](./topics/crd-changes/v1alpha5-to-v1alpha6.md)
diff --git a/docs/book/src/api/v1alpha1/api.md b/docs/book/src/api/v1alpha1/api.md
index 87776efd96..4c14706b75 100644
--- a/docs/book/src/api/v1alpha1/api.md
+++ b/docs/book/src/api/v1alpha1/api.md
@@ -4,8 +4,95 @@
Resource Types:
+OpenStackClusterIdentity
+
+
+
OpenStackClusterIdentity is a cluster-scoped identity that centralizes OpenStack credentials.
+
+
+
+
+| Field |
+Description |
+
+
+
+
+
+apiVersion
+string |
+
+
+infrastructure.cluster.x-k8s.io/v1alpha1
+
+ |
+
+
+
+kind
+string
+ |
+OpenStackClusterIdentity |
+
+
+
+metadata
+
+Kubernetes meta/v1.ObjectMeta
+
+ |
+
+Refer to the Kubernetes API documentation for the fields of the
+metadata field.
+ |
+
+
+
+spec
+
+
+OpenStackClusterIdentitySpec
+
+
+ |
+
+
+
+
+
+
+secretRef
+
+
+OpenStackCredentialSecretReference
+
+
+ |
+
+ SecretRef references the credentials Secret containing a clouds.yaml file.
+ |
+
+
+
+namespaceSelector
+
+Kubernetes meta/v1.LabelSelector
+
+ |
+
+(Optional)
+ NamespaceSelector limits which namespaces may use this identity. If nil, all namespaces are allowed.
+ |
+
+
+ |
+
+
+
OpenStackServer
@@ -314,6 +401,91 @@ OpenStackServerStatus
+
OpenStackClusterIdentitySpec
+
+
+(Appears on:
+OpenStackClusterIdentity)
+
+
+
OpenStackClusterIdentitySpec defines the desired state for an OpenStackClusterIdentity.
+
+
+
+
+| Field |
+Description |
+
+
+
+
+
+secretRef
+
+
+OpenStackCredentialSecretReference
+
+
+ |
+
+ SecretRef references the credentials Secret containing a clouds.yaml file.
+ |
+
+
+
+namespaceSelector
+
+Kubernetes meta/v1.LabelSelector
+
+ |
+
+(Optional)
+ NamespaceSelector limits which namespaces may use this identity. If nil, all namespaces are allowed.
+ |
+
+
+
+OpenStackCredentialSecretReference
+
+
+(Appears on:
+OpenStackClusterIdentitySpec)
+
+
+
OpenStackCredentialSecretReference references a Secret containing OpenStack credentials.
+
+
+
+
+| Field |
+Description |
+
+
+
+
+
+name
+
+string
+
+ |
+
+ Name of the Secret which contains a clouds.yaml key (and optionally cacert).
+ |
+
+
+
+namespace
+
+string
+
+ |
+
+ Namespace where the Secret resides.
+ |
+
+
+
OpenStackFloatingIPPool
diff --git a/docs/book/src/api/v1beta1/api.md b/docs/book/src/api/v1beta1/api.md
index 6937c5dd61..0eb377d117 100644
--- a/docs/book/src/api/v1beta1/api.md
+++ b/docs/book/src/api/v1beta1/api.md
@@ -3277,15 +3277,27 @@ provider identity to be used to provision cluster resources.
+type
+
+string
+
+ |
+
+ Type specifies the identity reference type. Defaults to Secret for backward compatibility.
+ |
+
+
+
name
string
|
- Name is the name of a secret in the same namespace as the resource being provisioned.
-The secret must contain a key named clouds.yaml which contains an OpenStack clouds.yaml file.
-The secret may optionally contain a key named cacert containing a PEM-encoded CA certificate.
+Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+The Secret must contain a key named clouds.yaml which contains an OpenStack clouds.yaml file.
+The Secret may optionally contain a key named cacert containing a PEM-encoded CA certificate.
|
diff --git a/docs/book/src/topics/openstack-cluster-identity.md b/docs/book/src/topics/openstack-cluster-identity.md
new file mode 100644
index 0000000000..281e68a88a
--- /dev/null
+++ b/docs/book/src/topics/openstack-cluster-identity.md
@@ -0,0 +1,100 @@
+# Centralized credentials with OpenStackClusterIdentity
+
+This guide explains how to centralize OpenStack credentials using a cluster-scoped OpenStackClusterIdentity and reference it from clusters and machines.
+
+## Overview
+- OpenStackClusterIdentity (cluster-scoped): stores a reference to a Secret that contains `clouds.yaml` (and optional `cacert`), and optionally restricts which namespaces may use it via `namespaceSelector`.
+- OpenStackIdentityReference (on OpenStackCluster/OpenStackMachine/OpenStackServer): carries `type`, `name`, and `cloudName`.
+ - `type: Secret` (default): `name` is the Secret name in the same namespace.
+ - `type: ClusterIdentity`: `name` is the OpenStackClusterIdentity name; Secret location is taken from the identity.
+ - For both types, `cloudName` is required and selects the entry in `clouds.yaml`.
+
+## Prerequisites
+- A Secret containing OpenStack credentials in `clouds.yaml`:
+```yaml
+apiVersion: v1
+kind: Secret
+metadata:
+ name: openstack-credentials
+ namespace: capo-system
+stringData:
+ clouds.yaml: |
+ clouds:
+ openstack:
+ auth:
+ auth_url: https://keystone.example.com/
+ application_credential_id:
+ application_credential_secret:
+ region_name: RegionOne
+ interface: public
+ identity_api_version: 3
+ auth_type: v3applicationcredential
+ # Optional CA certificate
+ # cacert: |
+ # -----BEGIN CERTIFICATE-----
+ # ...
+ # -----END CERTIFICATE-----
+```
+
+## Create an OpenStackClusterIdentity
+- Optionally restrict which namespaces can use it with `namespaceSelector`.
+```yaml
+apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1
+kind: OpenStackClusterIdentity
+metadata:
+ name: production-openstack
+spec:
+ secretRef:
+ name: openstack-credentials
+ namespace: capo-system
+ namespaceSelector:
+ matchExpressions:
+ - key: kubernetes.io/metadata.name
+ operator: In
+ values: [team-a, team-b]
+```
+
+## Reference the identity from OpenStackCluster
+- Use `type: ClusterIdentity`, specify the identity `name`, and the `cloudName` to select the clouds.yaml entry.
+```yaml
+apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
+kind: OpenStackCluster
+metadata:
+ name: cluster-a
+ namespace: team-a
+spec:
+ identityRef:
+ type: ClusterIdentity
+ name: production-openstack
+ cloudName: openstack
+```
+
+## Using a Secret directly (default)
+- If you don’t need cross-namespace identities, use a Secret in the same namespace.
+```yaml
+spec:
+ identityRef:
+ # type defaults to Secret
+ name: my-secret
+ cloudName: openstack
+```
+
+## Access control behavior
+- If `namespaceSelector` is not set: all namespaces may use the identity.
+- If set: only namespaces matching the selector may use the identity.
+- If access is denied, the controller returns an error and reconciliation fails. A Warning event can be emitted by the controller with a reason such as `IdentityAccessDenied`.
+
+## RBAC requirements
+Ensure the controller has permissions to read: (included in role.yaml by default)
+```yaml
+- apiGroups: [""]
+ resources: ["namespaces"]
+ verbs: ["get"]
+- apiGroups: ["infrastructure.cluster.x-k8s.io"]
+ resources: ["openstackclusteridentities"]
+ verbs: ["get","list","watch"]
+```
+
+## Notes
+- `cloudName` is required on `identityRef` for both `type: Secret` and `type: ClusterIdentity`.
+- The Secret must contain a `clouds.yaml` key, and may optionally contain `cacert`.
diff --git a/docs/proposals/20250722-openstackclusteridentity.md b/docs/proposals/20250722-openstackclusteridentity.md
index 73a9e78fda..0f19a1f6e3 100644
--- a/docs/proposals/20250722-openstackclusteridentity.md
+++ b/docs/proposals/20250722-openstackclusteridentity.md
@@ -73,7 +73,7 @@ type OpenStackIdentityReference struct {
// Type specifies the identity reference type
// +kubebuilder:validation:Enum=Secret;ClusterIdentity
// +kubebuilder:default=Secret
- // +kubebuilder:validation:XValidation:rule="self == 'Secret' ? has(self.cloudName) : !has(self.cloudName)",message="cloudName required for Secret type, forbidden for ClusterIdentity type"
+ // +kubebuilder:validation:XValidation:rule="has(self.cloudName)",message="cloudName is required"
// +kubebuilder:validation:XValidation:rule="has(self.name)",message="name is required"
// +optional
Type string `json:"type,omitempty"`
@@ -82,7 +82,7 @@ type OpenStackIdentityReference struct {
// +optional
Name string `json:"name,omitempty"`
- // CloudName required for Secret type, forbidden for ClusterIdentity type
+ // CloudName required for both types
// +optional
CloudName string `json:"cloudName,omitempty"`
@@ -174,7 +174,7 @@ type OpenStackIdentityReference struct {
// Type specifies the identity reference type
// +kubebuilder:validation:Enum=Secret;ClusterIdentity
// +kubebuilder:default=Secret
- // +kubebuilder:validation:XValidation:rule="self == 'Secret' ? has(self.cloudName) : !has(self.cloudName)",message="cloudName required for Secret type, forbidden for ClusterIdentity type"
+ // +kubebuilder:validation:XValidation:rule="has(self.cloudName)",message="cloudName is required"
// +kubebuilder:validation:XValidation:rule="has(self.name)",message="name is required"
// +optional
Type string `json:"type,omitempty"`
@@ -195,7 +195,7 @@ type OpenStackIdentityReference struct {
**CEL Validation Rules:**
1. **Name Required**: `name` field is always required for both types
-2. **CloudName Logic**: Required for Secret type, forbidden for ClusterIdentity type
+2. **CloudName Logic**: Required for both Secret and ClusterIdentity types.
3. **Type Safety**: Enum validation ensures only valid types are accepted
### Backward Compatibility
@@ -288,6 +288,7 @@ spec:
identityRef:
type: ClusterIdentity
name: prod-openstack
+ cloudName: openstack
```
### Explicit Secret Type (optional)
diff --git a/pkg/generated/applyconfiguration/api/v1alpha1/openstackclusteridentity.go b/pkg/generated/applyconfiguration/api/v1alpha1/openstackclusteridentity.go
new file mode 100644
index 0000000000..4a75dae4d9
--- /dev/null
+++ b/pkg/generated/applyconfiguration/api/v1alpha1/openstackclusteridentity.go
@@ -0,0 +1,255 @@
+/*
+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 v1alpha1
+
+import (
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ types "k8s.io/apimachinery/pkg/types"
+ managedfields "k8s.io/apimachinery/pkg/util/managedfields"
+ v1 "k8s.io/client-go/applyconfigurations/meta/v1"
+ apiv1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1"
+ internal "sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/applyconfiguration/internal"
+)
+
+// OpenStackClusterIdentityApplyConfiguration represents a declarative configuration of the OpenStackClusterIdentity type for use
+// with apply.
+type OpenStackClusterIdentityApplyConfiguration struct {
+ v1.TypeMetaApplyConfiguration `json:",inline"`
+ *v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"`
+ Spec *OpenStackClusterIdentitySpecApplyConfiguration `json:"spec,omitempty"`
+}
+
+// OpenStackClusterIdentity constructs a declarative configuration of the OpenStackClusterIdentity type for use with
+// apply.
+func OpenStackClusterIdentity(name, namespace string) *OpenStackClusterIdentityApplyConfiguration {
+ b := &OpenStackClusterIdentityApplyConfiguration{}
+ b.WithName(name)
+ b.WithNamespace(namespace)
+ b.WithKind("OpenStackClusterIdentity")
+ b.WithAPIVersion("infrastructure.cluster.x-k8s.io/v1alpha1")
+ return b
+}
+
+// ExtractOpenStackClusterIdentity extracts the applied configuration owned by fieldManager from
+// openStackClusterIdentity. If no managedFields are found in openStackClusterIdentity for fieldManager, a
+// OpenStackClusterIdentityApplyConfiguration is returned with only the Name, Namespace (if applicable),
+// APIVersion and Kind populated. It is possible that no managed fields were found for because other
+// field managers have taken ownership of all the fields previously owned by fieldManager, or because
+// the fieldManager never owned fields any fields.
+// openStackClusterIdentity must be a unmodified OpenStackClusterIdentity API object that was retrieved from the Kubernetes API.
+// ExtractOpenStackClusterIdentity provides a way to perform a extract/modify-in-place/apply workflow.
+// Note that an extracted apply configuration will contain fewer fields than what the fieldManager previously
+// applied if another fieldManager has updated or force applied any of the previously applied fields.
+// Experimental!
+func ExtractOpenStackClusterIdentity(openStackClusterIdentity *apiv1alpha1.OpenStackClusterIdentity, fieldManager string) (*OpenStackClusterIdentityApplyConfiguration, error) {
+ return extractOpenStackClusterIdentity(openStackClusterIdentity, fieldManager, "")
+}
+
+// ExtractOpenStackClusterIdentityStatus is the same as ExtractOpenStackClusterIdentity except
+// that it extracts the status subresource applied configuration.
+// Experimental!
+func ExtractOpenStackClusterIdentityStatus(openStackClusterIdentity *apiv1alpha1.OpenStackClusterIdentity, fieldManager string) (*OpenStackClusterIdentityApplyConfiguration, error) {
+ return extractOpenStackClusterIdentity(openStackClusterIdentity, fieldManager, "status")
+}
+
+func extractOpenStackClusterIdentity(openStackClusterIdentity *apiv1alpha1.OpenStackClusterIdentity, fieldManager string, subresource string) (*OpenStackClusterIdentityApplyConfiguration, error) {
+ b := &OpenStackClusterIdentityApplyConfiguration{}
+ err := managedfields.ExtractInto(openStackClusterIdentity, internal.Parser().Type("io.k8s.sigs.cluster-api-provider-openstack.api.v1alpha1.OpenStackClusterIdentity"), fieldManager, b, subresource)
+ if err != nil {
+ return nil, err
+ }
+ b.WithName(openStackClusterIdentity.Name)
+ b.WithNamespace(openStackClusterIdentity.Namespace)
+
+ b.WithKind("OpenStackClusterIdentity")
+ b.WithAPIVersion("infrastructure.cluster.x-k8s.io/v1alpha1")
+ return b, nil
+}
+
+// WithKind sets the Kind 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 Kind field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithKind(value string) *OpenStackClusterIdentityApplyConfiguration {
+ b.TypeMetaApplyConfiguration.Kind = &value
+ return b
+}
+
+// WithAPIVersion sets the APIVersion 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 APIVersion field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithAPIVersion(value string) *OpenStackClusterIdentityApplyConfiguration {
+ b.TypeMetaApplyConfiguration.APIVersion = &value
+ return b
+}
+
+// WithName sets the Name 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 Name field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithName(value string) *OpenStackClusterIdentityApplyConfiguration {
+ b.ensureObjectMetaApplyConfigurationExists()
+ b.ObjectMetaApplyConfiguration.Name = &value
+ return b
+}
+
+// WithGenerateName sets the GenerateName 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 GenerateName field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithGenerateName(value string) *OpenStackClusterIdentityApplyConfiguration {
+ b.ensureObjectMetaApplyConfigurationExists()
+ b.ObjectMetaApplyConfiguration.GenerateName = &value
+ return b
+}
+
+// WithNamespace sets the Namespace 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 Namespace field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithNamespace(value string) *OpenStackClusterIdentityApplyConfiguration {
+ b.ensureObjectMetaApplyConfigurationExists()
+ b.ObjectMetaApplyConfiguration.Namespace = &value
+ return b
+}
+
+// WithUID sets the UID 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 UID field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithUID(value types.UID) *OpenStackClusterIdentityApplyConfiguration {
+ b.ensureObjectMetaApplyConfigurationExists()
+ b.ObjectMetaApplyConfiguration.UID = &value
+ return b
+}
+
+// WithResourceVersion sets the ResourceVersion 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 ResourceVersion field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithResourceVersion(value string) *OpenStackClusterIdentityApplyConfiguration {
+ b.ensureObjectMetaApplyConfigurationExists()
+ b.ObjectMetaApplyConfiguration.ResourceVersion = &value
+ return b
+}
+
+// WithGeneration sets the Generation 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 Generation field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithGeneration(value int64) *OpenStackClusterIdentityApplyConfiguration {
+ b.ensureObjectMetaApplyConfigurationExists()
+ b.ObjectMetaApplyConfiguration.Generation = &value
+ return b
+}
+
+// WithCreationTimestamp sets the CreationTimestamp 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 CreationTimestamp field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithCreationTimestamp(value metav1.Time) *OpenStackClusterIdentityApplyConfiguration {
+ b.ensureObjectMetaApplyConfigurationExists()
+ b.ObjectMetaApplyConfiguration.CreationTimestamp = &value
+ return b
+}
+
+// WithDeletionTimestamp sets the DeletionTimestamp 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 DeletionTimestamp field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithDeletionTimestamp(value metav1.Time) *OpenStackClusterIdentityApplyConfiguration {
+ b.ensureObjectMetaApplyConfigurationExists()
+ b.ObjectMetaApplyConfiguration.DeletionTimestamp = &value
+ return b
+}
+
+// WithDeletionGracePeriodSeconds sets the DeletionGracePeriodSeconds 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 DeletionGracePeriodSeconds field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithDeletionGracePeriodSeconds(value int64) *OpenStackClusterIdentityApplyConfiguration {
+ b.ensureObjectMetaApplyConfigurationExists()
+ b.ObjectMetaApplyConfiguration.DeletionGracePeriodSeconds = &value
+ return b
+}
+
+// WithLabels puts the entries into the Labels field in the declarative configuration
+// and returns the receiver, so that objects can be build by chaining "With" function invocations.
+// If called multiple times, the entries provided by each call will be put on the Labels field,
+// overwriting an existing map entries in Labels field with the same key.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithLabels(entries map[string]string) *OpenStackClusterIdentityApplyConfiguration {
+ b.ensureObjectMetaApplyConfigurationExists()
+ if b.ObjectMetaApplyConfiguration.Labels == nil && len(entries) > 0 {
+ b.ObjectMetaApplyConfiguration.Labels = make(map[string]string, len(entries))
+ }
+ for k, v := range entries {
+ b.ObjectMetaApplyConfiguration.Labels[k] = v
+ }
+ return b
+}
+
+// WithAnnotations puts the entries into the Annotations field in the declarative configuration
+// and returns the receiver, so that objects can be build by chaining "With" function invocations.
+// If called multiple times, the entries provided by each call will be put on the Annotations field,
+// overwriting an existing map entries in Annotations field with the same key.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithAnnotations(entries map[string]string) *OpenStackClusterIdentityApplyConfiguration {
+ b.ensureObjectMetaApplyConfigurationExists()
+ if b.ObjectMetaApplyConfiguration.Annotations == nil && len(entries) > 0 {
+ b.ObjectMetaApplyConfiguration.Annotations = make(map[string]string, len(entries))
+ }
+ for k, v := range entries {
+ b.ObjectMetaApplyConfiguration.Annotations[k] = v
+ }
+ return b
+}
+
+// WithOwnerReferences adds the given value to the OwnerReferences field in the declarative configuration
+// and returns the receiver, so that objects can be build by chaining "With" function invocations.
+// If called multiple times, values provided by each call will be appended to the OwnerReferences field.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithOwnerReferences(values ...*v1.OwnerReferenceApplyConfiguration) *OpenStackClusterIdentityApplyConfiguration {
+ b.ensureObjectMetaApplyConfigurationExists()
+ for i := range values {
+ if values[i] == nil {
+ panic("nil value passed to WithOwnerReferences")
+ }
+ b.ObjectMetaApplyConfiguration.OwnerReferences = append(b.ObjectMetaApplyConfiguration.OwnerReferences, *values[i])
+ }
+ return b
+}
+
+// WithFinalizers adds the given value to the Finalizers field in the declarative configuration
+// and returns the receiver, so that objects can be build by chaining "With" function invocations.
+// If called multiple times, values provided by each call will be appended to the Finalizers field.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithFinalizers(values ...string) *OpenStackClusterIdentityApplyConfiguration {
+ b.ensureObjectMetaApplyConfigurationExists()
+ for i := range values {
+ b.ObjectMetaApplyConfiguration.Finalizers = append(b.ObjectMetaApplyConfiguration.Finalizers, values[i])
+ }
+ return b
+}
+
+func (b *OpenStackClusterIdentityApplyConfiguration) ensureObjectMetaApplyConfigurationExists() {
+ if b.ObjectMetaApplyConfiguration == nil {
+ b.ObjectMetaApplyConfiguration = &v1.ObjectMetaApplyConfiguration{}
+ }
+}
+
+// WithSpec sets the Spec 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 Spec field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithSpec(value *OpenStackClusterIdentitySpecApplyConfiguration) *OpenStackClusterIdentityApplyConfiguration {
+ b.Spec = value
+ return b
+}
+
+// GetName retrieves the value of the Name field in the declarative configuration.
+func (b *OpenStackClusterIdentityApplyConfiguration) GetName() *string {
+ b.ensureObjectMetaApplyConfigurationExists()
+ return b.ObjectMetaApplyConfiguration.Name
+}
diff --git a/pkg/generated/applyconfiguration/api/v1alpha1/openstackclusteridentityspec.go b/pkg/generated/applyconfiguration/api/v1alpha1/openstackclusteridentityspec.go
new file mode 100644
index 0000000000..e8d4ce41d6
--- /dev/null
+++ b/pkg/generated/applyconfiguration/api/v1alpha1/openstackclusteridentityspec.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 v1alpha1
+
+import (
+ v1 "k8s.io/client-go/applyconfigurations/meta/v1"
+)
+
+// OpenStackClusterIdentitySpecApplyConfiguration represents a declarative configuration of the OpenStackClusterIdentitySpec type for use
+// with apply.
+type OpenStackClusterIdentitySpecApplyConfiguration struct {
+ SecretRef *OpenStackCredentialSecretReferenceApplyConfiguration `json:"secretRef,omitempty"`
+ NamespaceSelector *v1.LabelSelectorApplyConfiguration `json:"namespaceSelector,omitempty"`
+}
+
+// OpenStackClusterIdentitySpecApplyConfiguration constructs a declarative configuration of the OpenStackClusterIdentitySpec type for use with
+// apply.
+func OpenStackClusterIdentitySpec() *OpenStackClusterIdentitySpecApplyConfiguration {
+ return &OpenStackClusterIdentitySpecApplyConfiguration{}
+}
+
+// WithSecretRef sets the SecretRef 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 SecretRef field is set to the value of the last call.
+func (b *OpenStackClusterIdentitySpecApplyConfiguration) WithSecretRef(value *OpenStackCredentialSecretReferenceApplyConfiguration) *OpenStackClusterIdentitySpecApplyConfiguration {
+ b.SecretRef = value
+ return b
+}
+
+// WithNamespaceSelector sets the NamespaceSelector 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 NamespaceSelector field is set to the value of the last call.
+func (b *OpenStackClusterIdentitySpecApplyConfiguration) WithNamespaceSelector(value *v1.LabelSelectorApplyConfiguration) *OpenStackClusterIdentitySpecApplyConfiguration {
+ b.NamespaceSelector = value
+ return b
+}
diff --git a/pkg/generated/applyconfiguration/api/v1alpha1/openstackcredentialsecretreference.go b/pkg/generated/applyconfiguration/api/v1alpha1/openstackcredentialsecretreference.go
new file mode 100644
index 0000000000..5ab540b707
--- /dev/null
+++ b/pkg/generated/applyconfiguration/api/v1alpha1/openstackcredentialsecretreference.go
@@ -0,0 +1,48 @@
+/*
+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 v1alpha1
+
+// OpenStackCredentialSecretReferenceApplyConfiguration represents a declarative configuration of the OpenStackCredentialSecretReference type for use
+// with apply.
+type OpenStackCredentialSecretReferenceApplyConfiguration struct {
+ Name *string `json:"name,omitempty"`
+ Namespace *string `json:"namespace,omitempty"`
+}
+
+// OpenStackCredentialSecretReferenceApplyConfiguration constructs a declarative configuration of the OpenStackCredentialSecretReference type for use with
+// apply.
+func OpenStackCredentialSecretReference() *OpenStackCredentialSecretReferenceApplyConfiguration {
+ return &OpenStackCredentialSecretReferenceApplyConfiguration{}
+}
+
+// WithName sets the Name 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 Name field is set to the value of the last call.
+func (b *OpenStackCredentialSecretReferenceApplyConfiguration) WithName(value string) *OpenStackCredentialSecretReferenceApplyConfiguration {
+ b.Name = &value
+ return b
+}
+
+// WithNamespace sets the Namespace 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 Namespace field is set to the value of the last call.
+func (b *OpenStackCredentialSecretReferenceApplyConfiguration) WithNamespace(value string) *OpenStackCredentialSecretReferenceApplyConfiguration {
+ b.Namespace = &value
+ return b
+}
diff --git a/pkg/generated/applyconfiguration/api/v1beta1/openstackidentityreference.go b/pkg/generated/applyconfiguration/api/v1beta1/openstackidentityreference.go
index 5703481b11..acb31b6ee4 100644
--- a/pkg/generated/applyconfiguration/api/v1beta1/openstackidentityreference.go
+++ b/pkg/generated/applyconfiguration/api/v1beta1/openstackidentityreference.go
@@ -21,6 +21,7 @@ package v1beta1
// OpenStackIdentityReferenceApplyConfiguration represents a declarative configuration of the OpenStackIdentityReference type for use
// with apply.
type OpenStackIdentityReferenceApplyConfiguration struct {
+ Type *string `json:"type,omitempty"`
Name *string `json:"name,omitempty"`
CloudName *string `json:"cloudName,omitempty"`
Region *string `json:"region,omitempty"`
@@ -32,6 +33,14 @@ func OpenStackIdentityReference() *OpenStackIdentityReferenceApplyConfiguration
return &OpenStackIdentityReferenceApplyConfiguration{}
}
+// WithType sets the Type 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 Type field is set to the value of the last call.
+func (b *OpenStackIdentityReferenceApplyConfiguration) WithType(value string) *OpenStackIdentityReferenceApplyConfiguration {
+ b.Type = &value
+ return b
+}
+
// WithName sets the Name 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 Name field is set to the value of the last call.
diff --git a/pkg/generated/applyconfiguration/internal/internal.go b/pkg/generated/applyconfiguration/internal/internal.go
index 20e8f16cd5..b11934fa54 100644
--- a/pkg/generated/applyconfiguration/internal/internal.go
+++ b/pkg/generated/applyconfiguration/internal/internal.go
@@ -85,6 +85,38 @@ var schemaYAML = typed.YAMLObject(`types:
elementType:
namedType: __untyped_deduced_
elementRelationship: separable
+- name: io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelector
+ map:
+ fields:
+ - name: matchExpressions
+ type:
+ list:
+ elementType:
+ namedType: io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelectorRequirement
+ elementRelationship: atomic
+ - name: matchLabels
+ type:
+ map:
+ elementType:
+ scalar: string
+ elementRelationship: atomic
+- name: io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelectorRequirement
+ map:
+ fields:
+ - name: key
+ type:
+ scalar: string
+ default: ""
+ - name: operator
+ type:
+ scalar: string
+ default: ""
+ - name: values
+ type:
+ list:
+ elementType:
+ scalar: string
+ elementRelationship: atomic
- name: io.k8s.apimachinery.pkg.apis.meta.v1.ManagedFieldsEntry
map:
fields:
@@ -200,6 +232,44 @@ var schemaYAML = typed.YAMLObject(`types:
elementRelationship: atomic
- name: io.k8s.apimachinery.pkg.apis.meta.v1.Time
scalar: untyped
+- name: io.k8s.sigs.cluster-api-provider-openstack.api.v1alpha1.OpenStackClusterIdentity
+ map:
+ fields:
+ - name: apiVersion
+ type:
+ scalar: string
+ - name: kind
+ type:
+ scalar: string
+ - name: metadata
+ type:
+ namedType: io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta
+ default: {}
+ - name: spec
+ type:
+ namedType: io.k8s.sigs.cluster-api-provider-openstack.api.v1alpha1.OpenStackClusterIdentitySpec
+ default: {}
+- name: io.k8s.sigs.cluster-api-provider-openstack.api.v1alpha1.OpenStackClusterIdentitySpec
+ map:
+ fields:
+ - name: namespaceSelector
+ type:
+ namedType: io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelector
+ - name: secretRef
+ type:
+ namedType: io.k8s.sigs.cluster-api-provider-openstack.api.v1alpha1.OpenStackCredentialSecretReference
+ default: {}
+- name: io.k8s.sigs.cluster-api-provider-openstack.api.v1alpha1.OpenStackCredentialSecretReference
+ map:
+ fields:
+ - name: name
+ type:
+ scalar: string
+ default: ""
+ - name: namespace
+ type:
+ scalar: string
+ default: ""
- name: io.k8s.sigs.cluster-api-provider-openstack.api.v1alpha1.OpenStackServer
map:
fields:
@@ -920,6 +990,9 @@ var schemaYAML = typed.YAMLObject(`types:
- name: region
type:
scalar: string
+ - name: type
+ type:
+ scalar: string
- name: io.k8s.sigs.cluster-api-provider-openstack.api.v1beta1.OpenStackMachine
map:
fields:
diff --git a/pkg/generated/applyconfiguration/utils.go b/pkg/generated/applyconfiguration/utils.go
index dfa39a2abf..494dc2afa8 100644
--- a/pkg/generated/applyconfiguration/utils.go
+++ b/pkg/generated/applyconfiguration/utils.go
@@ -34,6 +34,12 @@ import (
func ForKind(kind schema.GroupVersionKind) interface{} {
switch kind {
// Group=infrastructure.cluster.x-k8s.io, Version=v1alpha1
+ case v1alpha1.SchemeGroupVersion.WithKind("OpenStackClusterIdentity"):
+ return &apiv1alpha1.OpenStackClusterIdentityApplyConfiguration{}
+ case v1alpha1.SchemeGroupVersion.WithKind("OpenStackClusterIdentitySpec"):
+ return &apiv1alpha1.OpenStackClusterIdentitySpecApplyConfiguration{}
+ case v1alpha1.SchemeGroupVersion.WithKind("OpenStackCredentialSecretReference"):
+ return &apiv1alpha1.OpenStackCredentialSecretReferenceApplyConfiguration{}
case v1alpha1.SchemeGroupVersion.WithKind("OpenStackServer"):
return &apiv1alpha1.OpenStackServerApplyConfiguration{}
case v1alpha1.SchemeGroupVersion.WithKind("OpenStackServerSpec"):
diff --git a/pkg/generated/clientset/clientset/typed/api/v1alpha1/api_client.go b/pkg/generated/clientset/clientset/typed/api/v1alpha1/api_client.go
index 971900326e..788c390be9 100644
--- a/pkg/generated/clientset/clientset/typed/api/v1alpha1/api_client.go
+++ b/pkg/generated/clientset/clientset/typed/api/v1alpha1/api_client.go
@@ -28,6 +28,7 @@ import (
type InfrastructureV1alpha1Interface interface {
RESTClient() rest.Interface
+ OpenStackClusterIdentitiesGetter
OpenStackServersGetter
}
@@ -36,6 +37,10 @@ type InfrastructureV1alpha1Client struct {
restClient rest.Interface
}
+func (c *InfrastructureV1alpha1Client) OpenStackClusterIdentities(namespace string) OpenStackClusterIdentityInterface {
+ return newOpenStackClusterIdentities(c, namespace)
+}
+
func (c *InfrastructureV1alpha1Client) OpenStackServers(namespace string) OpenStackServerInterface {
return newOpenStackServers(c, namespace)
}
diff --git a/pkg/generated/clientset/clientset/typed/api/v1alpha1/fake/fake_api_client.go b/pkg/generated/clientset/clientset/typed/api/v1alpha1/fake/fake_api_client.go
index 01944c1a45..57bb4ca897 100644
--- a/pkg/generated/clientset/clientset/typed/api/v1alpha1/fake/fake_api_client.go
+++ b/pkg/generated/clientset/clientset/typed/api/v1alpha1/fake/fake_api_client.go
@@ -28,6 +28,10 @@ type FakeInfrastructureV1alpha1 struct {
*testing.Fake
}
+func (c *FakeInfrastructureV1alpha1) OpenStackClusterIdentities(namespace string) v1alpha1.OpenStackClusterIdentityInterface {
+ return newFakeOpenStackClusterIdentities(c, namespace)
+}
+
func (c *FakeInfrastructureV1alpha1) OpenStackServers(namespace string) v1alpha1.OpenStackServerInterface {
return newFakeOpenStackServers(c, namespace)
}
diff --git a/pkg/generated/clientset/clientset/typed/api/v1alpha1/fake/fake_openstackclusteridentity.go b/pkg/generated/clientset/clientset/typed/api/v1alpha1/fake/fake_openstackclusteridentity.go
new file mode 100644
index 0000000000..c7ae6ff3c9
--- /dev/null
+++ b/pkg/generated/clientset/clientset/typed/api/v1alpha1/fake/fake_openstackclusteridentity.go
@@ -0,0 +1,53 @@
+/*
+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 client-gen. DO NOT EDIT.
+
+package fake
+
+import (
+ gentype "k8s.io/client-go/gentype"
+ v1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1"
+ apiv1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/applyconfiguration/api/v1alpha1"
+ typedapiv1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/clientset/clientset/typed/api/v1alpha1"
+)
+
+// fakeOpenStackClusterIdentities implements OpenStackClusterIdentityInterface
+type fakeOpenStackClusterIdentities struct {
+ *gentype.FakeClientWithListAndApply[*v1alpha1.OpenStackClusterIdentity, *v1alpha1.OpenStackClusterIdentityList, *apiv1alpha1.OpenStackClusterIdentityApplyConfiguration]
+ Fake *FakeInfrastructureV1alpha1
+}
+
+func newFakeOpenStackClusterIdentities(fake *FakeInfrastructureV1alpha1, namespace string) typedapiv1alpha1.OpenStackClusterIdentityInterface {
+ return &fakeOpenStackClusterIdentities{
+ gentype.NewFakeClientWithListAndApply[*v1alpha1.OpenStackClusterIdentity, *v1alpha1.OpenStackClusterIdentityList, *apiv1alpha1.OpenStackClusterIdentityApplyConfiguration](
+ fake.Fake,
+ namespace,
+ v1alpha1.SchemeGroupVersion.WithResource("openstackclusteridentities"),
+ v1alpha1.SchemeGroupVersion.WithKind("OpenStackClusterIdentity"),
+ func() *v1alpha1.OpenStackClusterIdentity { return &v1alpha1.OpenStackClusterIdentity{} },
+ func() *v1alpha1.OpenStackClusterIdentityList { return &v1alpha1.OpenStackClusterIdentityList{} },
+ func(dst, src *v1alpha1.OpenStackClusterIdentityList) { dst.ListMeta = src.ListMeta },
+ func(list *v1alpha1.OpenStackClusterIdentityList) []*v1alpha1.OpenStackClusterIdentity {
+ return gentype.ToPointerSlice(list.Items)
+ },
+ func(list *v1alpha1.OpenStackClusterIdentityList, items []*v1alpha1.OpenStackClusterIdentity) {
+ list.Items = gentype.FromPointerSlice(items)
+ },
+ ),
+ fake,
+ }
+}
diff --git a/pkg/generated/clientset/clientset/typed/api/v1alpha1/generated_expansion.go b/pkg/generated/clientset/clientset/typed/api/v1alpha1/generated_expansion.go
index 7bfe8593d1..a6b176dc09 100644
--- a/pkg/generated/clientset/clientset/typed/api/v1alpha1/generated_expansion.go
+++ b/pkg/generated/clientset/clientset/typed/api/v1alpha1/generated_expansion.go
@@ -18,4 +18,6 @@ limitations under the License.
package v1alpha1
+type OpenStackClusterIdentityExpansion interface{}
+
type OpenStackServerExpansion interface{}
diff --git a/pkg/generated/clientset/clientset/typed/api/v1alpha1/openstackclusteridentity.go b/pkg/generated/clientset/clientset/typed/api/v1alpha1/openstackclusteridentity.go
new file mode 100644
index 0000000000..80d7788f92
--- /dev/null
+++ b/pkg/generated/clientset/clientset/typed/api/v1alpha1/openstackclusteridentity.go
@@ -0,0 +1,70 @@
+/*
+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 client-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+ context "context"
+
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ types "k8s.io/apimachinery/pkg/types"
+ watch "k8s.io/apimachinery/pkg/watch"
+ gentype "k8s.io/client-go/gentype"
+ apiv1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1"
+ applyconfigurationapiv1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/applyconfiguration/api/v1alpha1"
+ scheme "sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/clientset/clientset/scheme"
+)
+
+// OpenStackClusterIdentitiesGetter has a method to return a OpenStackClusterIdentityInterface.
+// A group's client should implement this interface.
+type OpenStackClusterIdentitiesGetter interface {
+ OpenStackClusterIdentities(namespace string) OpenStackClusterIdentityInterface
+}
+
+// OpenStackClusterIdentityInterface has methods to work with OpenStackClusterIdentity resources.
+type OpenStackClusterIdentityInterface interface {
+ Create(ctx context.Context, openStackClusterIdentity *apiv1alpha1.OpenStackClusterIdentity, opts v1.CreateOptions) (*apiv1alpha1.OpenStackClusterIdentity, error)
+ Update(ctx context.Context, openStackClusterIdentity *apiv1alpha1.OpenStackClusterIdentity, opts v1.UpdateOptions) (*apiv1alpha1.OpenStackClusterIdentity, 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) (*apiv1alpha1.OpenStackClusterIdentity, error)
+ List(ctx context.Context, opts v1.ListOptions) (*apiv1alpha1.OpenStackClusterIdentityList, error)
+ 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 *apiv1alpha1.OpenStackClusterIdentity, err error)
+ Apply(ctx context.Context, openStackClusterIdentity *applyconfigurationapiv1alpha1.OpenStackClusterIdentityApplyConfiguration, opts v1.ApplyOptions) (result *apiv1alpha1.OpenStackClusterIdentity, err error)
+ OpenStackClusterIdentityExpansion
+}
+
+// openStackClusterIdentities implements OpenStackClusterIdentityInterface
+type openStackClusterIdentities struct {
+ *gentype.ClientWithListAndApply[*apiv1alpha1.OpenStackClusterIdentity, *apiv1alpha1.OpenStackClusterIdentityList, *applyconfigurationapiv1alpha1.OpenStackClusterIdentityApplyConfiguration]
+}
+
+// newOpenStackClusterIdentities returns a OpenStackClusterIdentities
+func newOpenStackClusterIdentities(c *InfrastructureV1alpha1Client, namespace string) *openStackClusterIdentities {
+ return &openStackClusterIdentities{
+ gentype.NewClientWithListAndApply[*apiv1alpha1.OpenStackClusterIdentity, *apiv1alpha1.OpenStackClusterIdentityList, *applyconfigurationapiv1alpha1.OpenStackClusterIdentityApplyConfiguration](
+ "openstackclusteridentities",
+ c.RESTClient(),
+ scheme.ParameterCodec,
+ namespace,
+ func() *apiv1alpha1.OpenStackClusterIdentity { return &apiv1alpha1.OpenStackClusterIdentity{} },
+ func() *apiv1alpha1.OpenStackClusterIdentityList { return &apiv1alpha1.OpenStackClusterIdentityList{} },
+ ),
+ }
+}
diff --git a/pkg/generated/informers/externalversions/api/v1alpha1/interface.go b/pkg/generated/informers/externalversions/api/v1alpha1/interface.go
index f94a37f0b9..93a99b5171 100644
--- a/pkg/generated/informers/externalversions/api/v1alpha1/interface.go
+++ b/pkg/generated/informers/externalversions/api/v1alpha1/interface.go
@@ -24,6 +24,8 @@ import (
// Interface provides access to all the informers in this group version.
type Interface interface {
+ // OpenStackClusterIdentities returns a OpenStackClusterIdentityInformer.
+ OpenStackClusterIdentities() OpenStackClusterIdentityInformer
// OpenStackServers returns a OpenStackServerInformer.
OpenStackServers() OpenStackServerInformer
}
@@ -39,6 +41,11 @@ func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakList
return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions}
}
+// OpenStackClusterIdentities returns a OpenStackClusterIdentityInformer.
+func (v *version) OpenStackClusterIdentities() OpenStackClusterIdentityInformer {
+ return &openStackClusterIdentityInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions}
+}
+
// OpenStackServers returns a OpenStackServerInformer.
func (v *version) OpenStackServers() OpenStackServerInformer {
return &openStackServerInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions}
diff --git a/pkg/generated/informers/externalversions/api/v1alpha1/openstackclusteridentity.go b/pkg/generated/informers/externalversions/api/v1alpha1/openstackclusteridentity.go
new file mode 100644
index 0000000000..4899bed466
--- /dev/null
+++ b/pkg/generated/informers/externalversions/api/v1alpha1/openstackclusteridentity.go
@@ -0,0 +1,102 @@
+/*
+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 informer-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+ context "context"
+ time "time"
+
+ v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ runtime "k8s.io/apimachinery/pkg/runtime"
+ watch "k8s.io/apimachinery/pkg/watch"
+ cache "k8s.io/client-go/tools/cache"
+ clusterapiprovideropenstackapiv1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1"
+ clientset "sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/clientset/clientset"
+ internalinterfaces "sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/informers/externalversions/internalinterfaces"
+ apiv1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/listers/api/v1alpha1"
+)
+
+// OpenStackClusterIdentityInformer provides access to a shared informer and lister for
+// OpenStackClusterIdentities.
+type OpenStackClusterIdentityInformer interface {
+ Informer() cache.SharedIndexInformer
+ Lister() apiv1alpha1.OpenStackClusterIdentityLister
+}
+
+type openStackClusterIdentityInformer struct {
+ factory internalinterfaces.SharedInformerFactory
+ tweakListOptions internalinterfaces.TweakListOptionsFunc
+ namespace string
+}
+
+// NewOpenStackClusterIdentityInformer constructs a new informer for OpenStackClusterIdentity type.
+// Always prefer using an informer factory to get a shared informer instead of getting an independent
+// one. This reduces memory footprint and number of connections to the server.
+func NewOpenStackClusterIdentityInformer(client clientset.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer {
+ return NewFilteredOpenStackClusterIdentityInformer(client, namespace, resyncPeriod, indexers, nil)
+}
+
+// NewFilteredOpenStackClusterIdentityInformer constructs a new informer for OpenStackClusterIdentity type.
+// Always prefer using an informer factory to get a shared informer instead of getting an independent
+// one. This reduces memory footprint and number of connections to the server.
+func NewFilteredOpenStackClusterIdentityInformer(client clientset.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer {
+ return cache.NewSharedIndexInformer(
+ &cache.ListWatch{
+ ListFunc: func(options v1.ListOptions) (runtime.Object, error) {
+ if tweakListOptions != nil {
+ tweakListOptions(&options)
+ }
+ return client.InfrastructureV1alpha1().OpenStackClusterIdentities(namespace).List(context.Background(), options)
+ },
+ WatchFunc: func(options v1.ListOptions) (watch.Interface, error) {
+ if tweakListOptions != nil {
+ tweakListOptions(&options)
+ }
+ return client.InfrastructureV1alpha1().OpenStackClusterIdentities(namespace).Watch(context.Background(), options)
+ },
+ ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) {
+ if tweakListOptions != nil {
+ tweakListOptions(&options)
+ }
+ return client.InfrastructureV1alpha1().OpenStackClusterIdentities(namespace).List(ctx, options)
+ },
+ WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) {
+ if tweakListOptions != nil {
+ tweakListOptions(&options)
+ }
+ return client.InfrastructureV1alpha1().OpenStackClusterIdentities(namespace).Watch(ctx, options)
+ },
+ },
+ &clusterapiprovideropenstackapiv1alpha1.OpenStackClusterIdentity{},
+ resyncPeriod,
+ indexers,
+ )
+}
+
+func (f *openStackClusterIdentityInformer) defaultInformer(client clientset.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer {
+ return NewFilteredOpenStackClusterIdentityInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions)
+}
+
+func (f *openStackClusterIdentityInformer) Informer() cache.SharedIndexInformer {
+ return f.factory.InformerFor(&clusterapiprovideropenstackapiv1alpha1.OpenStackClusterIdentity{}, f.defaultInformer)
+}
+
+func (f *openStackClusterIdentityInformer) Lister() apiv1alpha1.OpenStackClusterIdentityLister {
+ return apiv1alpha1.NewOpenStackClusterIdentityLister(f.Informer().GetIndexer())
+}
diff --git a/pkg/generated/informers/externalversions/generic.go b/pkg/generated/informers/externalversions/generic.go
index a1d13ef1ef..c603ff05b2 100644
--- a/pkg/generated/informers/externalversions/generic.go
+++ b/pkg/generated/informers/externalversions/generic.go
@@ -54,6 +54,8 @@ func (f *genericInformer) Lister() cache.GenericLister {
func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) {
switch resource {
// Group=infrastructure.cluster.x-k8s.io, Version=v1alpha1
+ case v1alpha1.SchemeGroupVersion.WithResource("openstackclusteridentities"):
+ return &genericInformer{resource: resource.GroupResource(), informer: f.Infrastructure().V1alpha1().OpenStackClusterIdentities().Informer()}, nil
case v1alpha1.SchemeGroupVersion.WithResource("openstackservers"):
return &genericInformer{resource: resource.GroupResource(), informer: f.Infrastructure().V1alpha1().OpenStackServers().Informer()}, nil
diff --git a/pkg/generated/listers/api/v1alpha1/expansion_generated.go b/pkg/generated/listers/api/v1alpha1/expansion_generated.go
index bd2ce4125c..1a9f025d8a 100644
--- a/pkg/generated/listers/api/v1alpha1/expansion_generated.go
+++ b/pkg/generated/listers/api/v1alpha1/expansion_generated.go
@@ -18,6 +18,14 @@ limitations under the License.
package v1alpha1
+// OpenStackClusterIdentityListerExpansion allows custom methods to be added to
+// OpenStackClusterIdentityLister.
+type OpenStackClusterIdentityListerExpansion interface{}
+
+// OpenStackClusterIdentityNamespaceListerExpansion allows custom methods to be added to
+// OpenStackClusterIdentityNamespaceLister.
+type OpenStackClusterIdentityNamespaceListerExpansion interface{}
+
// OpenStackServerListerExpansion allows custom methods to be added to
// OpenStackServerLister.
type OpenStackServerListerExpansion interface{}
diff --git a/pkg/generated/listers/api/v1alpha1/openstackclusteridentity.go b/pkg/generated/listers/api/v1alpha1/openstackclusteridentity.go
new file mode 100644
index 0000000000..2251390b29
--- /dev/null
+++ b/pkg/generated/listers/api/v1alpha1/openstackclusteridentity.go
@@ -0,0 +1,70 @@
+/*
+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 lister-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+ labels "k8s.io/apimachinery/pkg/labels"
+ listers "k8s.io/client-go/listers"
+ cache "k8s.io/client-go/tools/cache"
+ apiv1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1"
+)
+
+// OpenStackClusterIdentityLister helps list OpenStackClusterIdentities.
+// All objects returned here must be treated as read-only.
+type OpenStackClusterIdentityLister interface {
+ // List lists all OpenStackClusterIdentities in the indexer.
+ // Objects returned here must be treated as read-only.
+ List(selector labels.Selector) (ret []*apiv1alpha1.OpenStackClusterIdentity, err error)
+ // OpenStackClusterIdentities returns an object that can list and get OpenStackClusterIdentities.
+ OpenStackClusterIdentities(namespace string) OpenStackClusterIdentityNamespaceLister
+ OpenStackClusterIdentityListerExpansion
+}
+
+// openStackClusterIdentityLister implements the OpenStackClusterIdentityLister interface.
+type openStackClusterIdentityLister struct {
+ listers.ResourceIndexer[*apiv1alpha1.OpenStackClusterIdentity]
+}
+
+// NewOpenStackClusterIdentityLister returns a new OpenStackClusterIdentityLister.
+func NewOpenStackClusterIdentityLister(indexer cache.Indexer) OpenStackClusterIdentityLister {
+ return &openStackClusterIdentityLister{listers.New[*apiv1alpha1.OpenStackClusterIdentity](indexer, apiv1alpha1.Resource("openstackclusteridentity"))}
+}
+
+// OpenStackClusterIdentities returns an object that can list and get OpenStackClusterIdentities.
+func (s *openStackClusterIdentityLister) OpenStackClusterIdentities(namespace string) OpenStackClusterIdentityNamespaceLister {
+ return openStackClusterIdentityNamespaceLister{listers.NewNamespaced[*apiv1alpha1.OpenStackClusterIdentity](s.ResourceIndexer, namespace)}
+}
+
+// OpenStackClusterIdentityNamespaceLister helps list and get OpenStackClusterIdentities.
+// All objects returned here must be treated as read-only.
+type OpenStackClusterIdentityNamespaceLister interface {
+ // List lists all OpenStackClusterIdentities in the indexer for a given namespace.
+ // Objects returned here must be treated as read-only.
+ List(selector labels.Selector) (ret []*apiv1alpha1.OpenStackClusterIdentity, err error)
+ // Get retrieves the OpenStackClusterIdentity from the indexer for a given namespace and name.
+ // Objects returned here must be treated as read-only.
+ Get(name string) (*apiv1alpha1.OpenStackClusterIdentity, error)
+ OpenStackClusterIdentityNamespaceListerExpansion
+}
+
+// openStackClusterIdentityNamespaceLister implements the OpenStackClusterIdentityNamespaceLister
+// interface.
+type openStackClusterIdentityNamespaceLister struct {
+ listers.ResourceIndexer[*apiv1alpha1.OpenStackClusterIdentity]
+}
diff --git a/pkg/scope/provider.go b/pkg/scope/provider.go
index d23e3323a4..bb21afb495 100644
--- a/pkg/scope/provider.go
+++ b/pkg/scope/provider.go
@@ -31,12 +31,15 @@ import (
osclient "github.com/gophercloud/utils/v2/client"
"github.com/gophercloud/utils/v2/openstack/clientconfig"
corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/cache"
"k8s.io/klog/v2"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/yaml"
+ infrav1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1"
infrav1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1"
"sigs.k8s.io/cluster-api-provider-openstack/pkg/clients"
"sigs.k8s.io/cluster-api-provider-openstack/version"
@@ -47,6 +50,16 @@ const (
CASecretKey = "cacert"
)
+// IdentityAccessDeniedError is returned when a namespace is not permitted to use a ClusterIdentity.
+type IdentityAccessDeniedError struct {
+ IdentityName string
+ RequesterNamespace string
+}
+
+func (e *IdentityAccessDeniedError) Error() string {
+ return fmt.Sprintf("namespace %s not allowed to use cluster identity %s", e.RequesterNamespace, e.IdentityName)
+}
+
type providerScopeFactory struct {
clientCache *cache.LRUExpireCache
}
@@ -69,8 +82,45 @@ func (f *providerScopeFactory) NewClientScopeFromObject(ctx context.Context, ctr
var cloud clientconfig.Cloud
var caCert []byte
- var err error
- cloud, caCert, err = getCloudFromSecret(ctx, ctrlClient, *namespace, identityRef.Name, identityRef.CloudName)
+ // Determine which secret to read based on identity type
+ var secretNamespace string
+ var secretName string
+
+ switch identityRef.Type {
+ case "", "Secret":
+ secretNamespace = *namespace
+ secretName = identityRef.Name
+ logger.V(4).Info("Using Secret for OpenStack credentials", "namespace", secretNamespace, "name", secretName, "cloudName", identityRef.CloudName)
+ case "ClusterIdentity":
+ // Fetch cluster-scoped identity and validate namespace access
+ identity := &infrav1alpha1.OpenStackClusterIdentity{}
+ if err := ctrlClient.Get(ctx, types.NamespacedName{Name: identityRef.Name}, identity); err != nil {
+ return nil, fmt.Errorf("failed to get OpenStackClusterIdentity %s: %w", identityRef.Name, err)
+ }
+ // Validate selector (if any) against the caller namespace
+ if identity.Spec.NamespaceSelector != nil {
+ ns := &corev1.Namespace{}
+ if err := ctrlClient.Get(ctx, types.NamespacedName{Name: *namespace}, ns); err != nil {
+ return nil, fmt.Errorf("failed to get namespace %s: %w", *namespace, err)
+ }
+ selector, err := metav1.LabelSelectorAsSelector(identity.Spec.NamespaceSelector)
+ if err != nil {
+ return nil, fmt.Errorf("invalid namespace selector on identity %s: %w", identity.Name, err)
+ }
+ if !selector.Matches(labels.Set(ns.Labels)) {
+ logger.V(2).Info("Namespace not allowed to use ClusterIdentity", "identity", identity.Name, "namespace", *namespace)
+ return nil, &IdentityAccessDeniedError{IdentityName: identity.Name, RequesterNamespace: *namespace}
+ }
+ }
+ secretNamespace = identity.Spec.SecretRef.Namespace
+ secretName = identity.Spec.SecretRef.Name
+ logger.V(4).Info("Using ClusterIdentity for OpenStack credentials", "identity", identityRef.Name, "secretNamespace", secretNamespace, "secretName", secretName, "cloudName", identityRef.CloudName)
+ default:
+ return nil, fmt.Errorf("unsupported identity type: %s", identityRef.Type)
+ }
+
+ // Read cloud from the resolved secret using the provided cloudName
+ cloud, caCert, err := getCloudFromSecret(ctx, ctrlClient, secretNamespace, secretName, identityRef.CloudName)
if err != nil {
return nil, err
}
diff --git a/pkg/scope/provider_resolution_test.go b/pkg/scope/provider_resolution_test.go
new file mode 100644
index 0000000000..e5c86f2fea
--- /dev/null
+++ b/pkg/scope/provider_resolution_test.go
@@ -0,0 +1,304 @@
+/*
+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 scope
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/go-logr/logr"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/client-go/kubernetes/scheme"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+ infrav1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1"
+ infrav1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1"
+)
+
+const (
+ testNSA = "ns-a"
+ testNSAllowed = "allowed-team"
+ testNSTeamY = "team-y"
+ testNSTeamZ = "team-z"
+ testNSTeamW = "team-w"
+ testNSTeamA = "team-a"
+ testNSCapo = "capo-system"
+ resTestCloudName = "mycloud"
+ testProdCloud = "prodcloud"
+)
+
+var (
+ testValidCloudsYAML = []byte(`clouds:
+ mycloud:
+ auth:
+ auth_url: https://keystone.example.com/
+ application_credential_id: id
+ application_credential_secret: secret
+ region_name: RegionOne
+`)
+ testProdCloudsYAML = []byte(`clouds:
+ prodcloud:
+ auth:
+ auth_url: https://keystone.prod.com/
+ application_credential_id: prod-id
+ application_credential_secret: prod-secret
+ region_name: RegionOne
+`)
+ testEmptyCloudsYAML = []byte("clouds: {}\n")
+ testDefaultCloudsYAML = []byte("clouds: { default: {} }\n")
+)
+
+// ensureSchemes creates a runtime scheme with all required API types for testing.
+func ensureSchemes(t *testing.T) *runtime.Scheme {
+ t.Helper()
+ local := runtime.NewScheme()
+ if err := scheme.AddToScheme(local); err != nil {
+ t.Fatalf("failed to add core scheme: %v", err)
+ }
+ if err := infrav1.AddToScheme(local); err != nil {
+ t.Fatalf("failed to add v1beta1 scheme: %v", err)
+ }
+ if err := infrav1alpha1.AddToScheme(local); err != nil {
+ t.Fatalf("failed to add v1alpha1 scheme: %v", err)
+ }
+ return local
+}
+
+// createResTestSecret creates a test Secret with the given namespace, name, and data.
+func createResTestSecret(namespace, name string, data map[string][]byte) *corev1.Secret {
+ secret := &corev1.Secret{}
+ secret.Namespace = namespace
+ secret.Name = name
+ secret.Data = data
+ return secret
+}
+
+// createTestNamespace creates a test Namespace with the given name and labels.
+func createTestNamespace(name string, labels map[string]string) *corev1.Namespace {
+ ns := &corev1.Namespace{}
+ ns.Name = name
+ ns.Labels = labels
+ return ns
+}
+
+// createTestClusterIdentity creates a test OpenStackClusterIdentity with the given name and namespace selector.
+func createTestClusterIdentity(name string, selector *metav1.LabelSelector) *infrav1alpha1.OpenStackClusterIdentity {
+ identity := &infrav1alpha1.OpenStackClusterIdentity{}
+ identity.Name = name
+ identity.Spec.SecretRef = infrav1alpha1.OpenStackCredentialSecretReference{
+ Name: "creds",
+ Namespace: testNSCapo,
+ }
+ identity.Spec.NamespaceSelector = selector
+ return identity
+}
+
+// newFakeClient creates a fake Kubernetes client with the provided scheme and objects.
+func newFakeClient(sch *runtime.Scheme, objs ...client.Object) client.Client {
+ return fake.NewClientBuilder().WithScheme(sch).WithObjects(objs...).Build()
+}
+
+// assertResolutionReached ensures the error is not caused by credential resolution (missing secret/namespace or access denied).
+// We still expect an error (typically from OpenStack auth) when running full scope creation with the real factory.
+func assertResolutionReached(t *testing.T, err error) {
+ t.Helper()
+ if err == nil {
+ t.Fatalf("expected OpenStack auth error, got success")
+ }
+ if strings.Contains(err.Error(), "secret") && strings.Contains(err.Error(), "not found") {
+ t.Fatalf("credential resolution failed: %v", err)
+ }
+ var denied *IdentityAccessDeniedError
+ if errors.As(err, &denied) {
+ t.Fatalf("credential resolution failed: %v", err)
+ }
+}
+
+// assertDenied verifies that the error is an IdentityAccessDeniedError.
+func assertDenied(t *testing.T, err error) {
+ t.Helper()
+ var denied *IdentityAccessDeniedError
+ if err == nil || !errors.As(err, &denied) {
+ t.Fatalf("expected IdentityAccessDeniedError, got %T %v", err, err)
+ }
+}
+
+// assertNotDenied verifies that the error is NOT an IdentityAccessDeniedError.
+func assertNotDenied(t *testing.T, err error) {
+ t.Helper()
+ var denied *IdentityAccessDeniedError
+ if errors.As(err, &denied) {
+ t.Fatalf("did not expect IdentityAccessDeniedError, got %v", err)
+ }
+}
+
+// TestNewClientScopeFromObject_Resolution tests credential resolution logic for both Secret and ClusterIdentity paths.
+func TestNewClientScopeFromObject_Resolution(t *testing.T) {
+ t.Parallel()
+ localScheme := ensureSchemes(t)
+ type testCase struct {
+ name string
+ objects []client.Object
+ namespace string
+ identity infrav1.OpenStackIdentityReference
+ assertErr func(*testing.T, error)
+ }
+
+ cases := []testCase{
+ {
+ name: "secret path returns scope",
+ objects: []client.Object{
+ createResTestSecret(testNSA, "valid-creds", map[string][]byte{CloudsSecretKey: testValidCloudsYAML}),
+ },
+ namespace: testNSA,
+ identity: infrav1.OpenStackIdentityReference{Name: "valid-creds", CloudName: resTestCloudName},
+ assertErr: func(t *testing.T, err error) {
+ t.Helper()
+ assertResolutionReached(t, err)
+ },
+ },
+ {
+ name: "clusteridentity returns scope when selector allows",
+ objects: []client.Object{
+ createTestNamespace(testNSAllowed, map[string]string{"env": "prod"}),
+ createTestClusterIdentity("prod-id", &metav1.LabelSelector{MatchLabels: map[string]string{"env": "prod"}}),
+ createResTestSecret(testNSCapo, "creds", map[string][]byte{CloudsSecretKey: testProdCloudsYAML}),
+ },
+ namespace: testNSAllowed,
+ identity: infrav1.OpenStackIdentityReference{Type: "ClusterIdentity", Name: "prod-id", CloudName: testProdCloud},
+ assertErr: func(t *testing.T, err error) {
+ t.Helper()
+ assertResolutionReached(t, err)
+ },
+ },
+ {
+ name: "secret path: missing secret returns error",
+ objects: []client.Object{},
+ namespace: testNSA,
+ identity: infrav1.OpenStackIdentityReference{Name: "missing", CloudName: "cloudA"},
+ assertErr: func(t *testing.T, err error) {
+ t.Helper()
+ if err == nil {
+ t.Fatalf("expected error")
+ }
+ },
+ },
+ {
+ name: "secret path: empty cloudName returns error",
+ objects: []client.Object{createResTestSecret(testNSA, "creds", map[string][]byte{CloudsSecretKey: testEmptyCloudsYAML})},
+ namespace: testNSA,
+ identity: infrav1.OpenStackIdentityReference{Name: "creds", CloudName: ""},
+ assertErr: func(t *testing.T, err error) {
+ t.Helper()
+ if err == nil {
+ t.Fatalf("expected error")
+ }
+ },
+ },
+ {
+ name: "clusteridentity: identity not found",
+ objects: []client.Object{},
+ namespace: "team-x",
+ identity: infrav1.OpenStackIdentityReference{Type: "ClusterIdentity", Name: "missing-id", CloudName: "cloudA"},
+ assertErr: func(t *testing.T, err error) {
+ t.Helper()
+ if err == nil {
+ t.Fatalf("expected error")
+ }
+ },
+ },
+ {
+ name: "clusteridentity: selector denies -> access denied",
+ objects: []client.Object{
+ createTestNamespace(testNSTeamY, nil),
+ createTestClusterIdentity("prod-id", &metav1.LabelSelector{MatchLabels: map[string]string{"allowed": "true"}}),
+ createResTestSecret(testNSCapo, "creds", map[string][]byte{CloudsSecretKey: testEmptyCloudsYAML}),
+ },
+ namespace: testNSTeamY,
+ identity: infrav1.OpenStackIdentityReference{Type: "ClusterIdentity", Name: "prod-id", CloudName: "cloudA"},
+ assertErr: func(t *testing.T, err error) {
+ t.Helper()
+ assertDenied(t, err)
+ },
+ },
+ {
+ name: "clusteridentity: selector nil allows (not denied)",
+ objects: []client.Object{
+ createTestNamespace(testNSTeamZ, nil),
+ createTestClusterIdentity("any-id", nil),
+ createResTestSecret(testNSCapo, "creds", map[string][]byte{CloudsSecretKey: testDefaultCloudsYAML}),
+ },
+ namespace: testNSTeamZ,
+ identity: infrav1.OpenStackIdentityReference{Type: "ClusterIdentity", Name: "any-id", CloudName: "default"},
+ assertErr: func(t *testing.T, err error) {
+ t.Helper()
+ assertNotDenied(t, err)
+ },
+ },
+ {
+ name: "clusteridentity: empty selector matches all (not denied)",
+ objects: []client.Object{
+ createTestNamespace(testNSTeamW, nil),
+ createTestClusterIdentity("empty-selector-id", &metav1.LabelSelector{}),
+ createResTestSecret(testNSCapo, "creds", map[string][]byte{CloudsSecretKey: testDefaultCloudsYAML}),
+ },
+ namespace: testNSTeamW,
+ identity: infrav1.OpenStackIdentityReference{Type: "ClusterIdentity", Name: "empty-selector-id", CloudName: "default"},
+ assertErr: func(t *testing.T, err error) {
+ t.Helper()
+ assertNotDenied(t, err)
+ },
+ },
+ {
+ name: "clusteridentity: cross-namespace secret allowed (not denied)",
+ objects: []client.Object{
+ createTestNamespace(testNSTeamA, nil),
+ createTestNamespace(testNSCapo, nil),
+ createTestClusterIdentity("cross-ns-id", nil),
+ createResTestSecret(testNSCapo, "creds", map[string][]byte{CloudsSecretKey: testDefaultCloudsYAML}),
+ },
+ namespace: testNSTeamA,
+ identity: infrav1.OpenStackIdentityReference{Type: "ClusterIdentity", Name: "cross-ns-id", CloudName: "default"},
+ assertErr: func(t *testing.T, err error) {
+ t.Helper()
+ assertNotDenied(t, err)
+ },
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ ctx := context.Background()
+ c := newFakeClient(localScheme, tc.objects...)
+ // Always use real factory with fake k8s client - this tests credential resolution
+ // without making OpenStack API calls
+ factory := &providerScopeFactory{}
+
+ srv := &infrav1alpha1.OpenStackServer{}
+ srv.Namespace = tc.namespace
+ srv.Spec.IdentityRef = tc.identity
+
+ _, err := factory.NewClientScopeFromObject(ctx, c, nil, logr.Discard(), srv)
+ tc.assertErr(t, err)
+ })
+ }
+}
diff --git a/pkg/scope/provider_test.go b/pkg/scope/provider_test.go
new file mode 100644
index 0000000000..345f241883
--- /dev/null
+++ b/pkg/scope/provider_test.go
@@ -0,0 +1,190 @@
+/*
+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 scope
+
+import (
+ "context"
+ "testing"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/client-go/kubernetes/scheme"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+)
+
+const (
+ testNamespace = "test-ns"
+ testCloudName = "mycloud"
+ testRegion = "RegionOne"
+)
+
+var (
+ testCloudsYAML = []byte(`clouds:
+ mycloud:
+ auth:
+ auth_url: https://keystone.example.com/
+ application_credential_id: id
+ application_credential_secret: secret
+ region_name: RegionOne
+ interface: public
+ identity_api_version: 3
+ auth_type: v3applicationcredential
+`)
+ testCACert = []byte("-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----\n")
+)
+
+// buildCoreScheme creates a runtime scheme with core Kubernetes API types for testing.
+func buildCoreScheme(t *testing.T) *runtime.Scheme {
+ t.Helper()
+ sch := runtime.NewScheme()
+ if err := scheme.AddToScheme(sch); err != nil {
+ t.Fatalf("failed to add core scheme: %v", err)
+ }
+ return sch
+}
+
+// createTestSecret creates a test Secret in the test namespace with the given name and data.
+func createTestSecret(name string, data map[string][]byte) *corev1.Secret {
+ secret := &corev1.Secret{}
+ secret.Namespace = testNamespace
+ secret.Name = name
+ secret.Data = data
+ return secret
+}
+
+// TestGetCloudFromSecret_SuccessWithCACert tests successful cloud retrieval when CA certificate is present.
+func TestGetCloudFromSecret_SuccessWithCACert(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+
+ secretName := "os-cred"
+ secret := createTestSecret(secretName, map[string][]byte{
+ CloudsSecretKey: testCloudsYAML,
+ CASecretKey: testCACert,
+ })
+
+ c := fake.NewClientBuilder().WithScheme(buildCoreScheme(t)).WithObjects(secret).Build()
+
+ cloud, gotCACert, err := getCloudFromSecret(ctx, c, testNamespace, secretName, testCloudName)
+ if err != nil {
+ t.Fatalf("getCloudFromSecret returned error: %v", err)
+ }
+ if cloud.RegionName != testRegion {
+ t.Fatalf("expected %s region, got %q", testRegion, cloud.RegionName)
+ }
+ if len(gotCACert) == 0 {
+ t.Fatalf("expected non-empty caCert")
+ }
+}
+
+// TestGetCloudFromSecret_SuccessWithoutCACert tests successful cloud retrieval when CA certificate is not present.
+func TestGetCloudFromSecret_SuccessWithoutCACert(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+
+ secretName := "os-cred-no-ca" //nolint:gosec
+ secret := createTestSecret(secretName, map[string][]byte{
+ CloudsSecretKey: testCloudsYAML,
+ })
+
+ c := fake.NewClientBuilder().WithScheme(buildCoreScheme(t)).WithObjects(secret).Build()
+
+ cloud, gotCACert, err := getCloudFromSecret(ctx, c, testNamespace, secretName, testCloudName)
+ if err != nil {
+ t.Fatalf("getCloudFromSecret returned error: %v", err)
+ }
+ if cloud.RegionName != testRegion {
+ t.Fatalf("expected %s region, got %q", testRegion, cloud.RegionName)
+ }
+ if gotCACert != nil {
+ t.Fatalf("expected nil caCert when not present, got %d bytes", len(gotCACert))
+ }
+}
+
+// TestGetCloudFromSecret_MissingSecret tests error handling when the secret does not exist.
+func TestGetCloudFromSecret_MissingSecret(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+
+ c := fake.NewClientBuilder().WithScheme(buildCoreScheme(t)).Build()
+
+ _, _, err := getCloudFromSecret(ctx, c, testNamespace, "missing", testCloudName)
+ if err == nil {
+ t.Fatalf("expected error for missing secret, got nil")
+ }
+}
+
+// TestGetCloudFromSecret_MissingCloudsKey tests error handling when the clouds.yaml key is missing from the secret.
+func TestGetCloudFromSecret_MissingCloudsKey(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+
+ secretName := "no-clouds"
+ secret := createTestSecret(secretName, map[string][]byte{
+ // intentionally no CloudsSecretKey
+ "other": []byte("x"),
+ })
+
+ c := fake.NewClientBuilder().WithScheme(buildCoreScheme(t)).WithObjects(secret).Build()
+
+ _, _, err := getCloudFromSecret(ctx, c, testNamespace, secretName, testCloudName)
+ if err == nil {
+ t.Fatalf("expected error for missing clouds.yaml key, got nil")
+ }
+}
+
+// TestGetCloudFromSecret_EmptyCloudName tests error handling when cloudName is empty.
+func TestGetCloudFromSecret_EmptyCloudName(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+
+ secretName := "any"
+ secret := createTestSecret(secretName, map[string][]byte{
+ CloudsSecretKey: []byte("clouds: {}\n"),
+ })
+
+ c := fake.NewClientBuilder().WithScheme(buildCoreScheme(t)).WithObjects(secret).Build()
+
+ _, _, err := getCloudFromSecret(ctx, c, testNamespace, secretName, "")
+ if err == nil {
+ t.Fatalf("expected error when cloudName is empty, got nil")
+ }
+}
+
+// TestGetCloudFromSecret_InvalidCloudName tests behavior when cloudName does not exist in clouds.yaml.
+func TestGetCloudFromSecret_InvalidCloudName(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+
+ secretName := "cred"
+ secret := createTestSecret(secretName, map[string][]byte{
+ CloudsSecretKey: testCloudsYAML,
+ })
+
+ c := fake.NewClientBuilder().WithScheme(buildCoreScheme(t)).WithObjects(secret).Build()
+
+ cloud, ca, err := getCloudFromSecret(ctx, c, testNamespace, secretName, "missing-cloud")
+ if err != nil {
+ t.Fatalf("expected no error for unknown cloudName (returned zero-value), got: %v", err)
+ }
+ if ca != nil {
+ t.Fatalf("expected nil caCert for missing key, got %d bytes", len(ca))
+ }
+ if cloud.RegionName != "" || cloud.AuthInfo != nil {
+ t.Fatalf("expected zero-value cloud for unknown cloudName, got RegionName=%q AuthInfo-nil=%v", cloud.RegionName, cloud.AuthInfo == nil)
+ }
+}
diff --git a/test/e2e/data/e2e_conf.yaml b/test/e2e/data/e2e_conf.yaml
index ec208285d0..9914f0eb09 100644
--- a/test/e2e/data/e2e_conf.yaml
+++ b/test/e2e/data/e2e_conf.yaml
@@ -170,6 +170,7 @@ providers:
- sourcePath: "../data/shared/provider/metadata.yaml"
- sourcePath: "./infrastructure-openstack-no-artifact/cluster-template.yaml"
- sourcePath: "./infrastructure-openstack-no-artifact/cluster-template-without-lb.yaml"
+ - sourcePath: "./infrastructure-openstack-no-artifact/cluster-template-cluster-identity.yaml"
replacements:
- old: gcr.io/k8s-staging-capi-openstack/capi-openstack-controller:dev
new: gcr.io/k8s-staging-capi-openstack/capi-openstack-controller:e2e
diff --git a/test/e2e/data/kustomize/cluster-identity/kustomization.yaml b/test/e2e/data/kustomize/cluster-identity/kustomization.yaml
new file mode 100644
index 0000000000..276d952a31
--- /dev/null
+++ b/test/e2e/data/kustomize/cluster-identity/kustomization.yaml
@@ -0,0 +1,13 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+- ../default
+- openstackclusteridentity.yaml
+
+patches:
+- path: patch-openstackcluster-identityref.yaml
+ target:
+ kind: OpenStackCluster
+ name: \${CLUSTER_NAME}
+
diff --git a/test/e2e/data/kustomize/cluster-identity/openstackclusteridentity.yaml b/test/e2e/data/kustomize/cluster-identity/openstackclusteridentity.yaml
new file mode 100644
index 0000000000..273249c7c4
--- /dev/null
+++ b/test/e2e/data/kustomize/cluster-identity/openstackclusteridentity.yaml
@@ -0,0 +1,9 @@
+apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1
+kind: OpenStackClusterIdentity
+metadata:
+ name: ${CLUSTER_NAME}-cluster-identity
+spec:
+ secretRef:
+ name: ${CLUSTER_NAME}-cloud-config
+ namespace: ${NAMESPACE}
+
diff --git a/test/e2e/data/kustomize/cluster-identity/patch-openstackcluster-identityref.yaml b/test/e2e/data/kustomize/cluster-identity/patch-openstackcluster-identityref.yaml
new file mode 100644
index 0000000000..eaa43cdaa8
--- /dev/null
+++ b/test/e2e/data/kustomize/cluster-identity/patch-openstackcluster-identityref.yaml
@@ -0,0 +1,8 @@
+---
+- op: replace
+ path: /spec/identityRef
+ value:
+ type: ClusterIdentity
+ name: ${CLUSTER_NAME}-cluster-identity
+ cloudName: ${OPENSTACK_CLOUD}
+
diff --git a/test/e2e/shared/defaults.go b/test/e2e/shared/defaults.go
index ab57acd1e7..e14af48925 100644
--- a/test/e2e/shared/defaults.go
+++ b/test/e2e/shared/defaults.go
@@ -63,6 +63,7 @@ const (
FlavorFlatcarSysext = "flatcar-sysext"
FlavorHealthMonitor = "health-monitor"
FlavorCapiV1Beta1 = "capi-v1beta1"
+ FlavorClusterIdentity = "cluster-identity"
)
// DefaultScheme returns the default scheme to use for testing.
diff --git a/test/e2e/suites/apivalidations/filters_test.go b/test/e2e/suites/apivalidations/filters_test.go
index 9814b5f84a..7ca4fc7307 100644
--- a/test/e2e/suites/apivalidations/filters_test.go
+++ b/test/e2e/suites/apivalidations/filters_test.go
@@ -49,6 +49,11 @@ var _ = Describe("Filter API validations", func() {
cluster = &infrav1.OpenStackCluster{}
cluster.Namespace = namespace.Name
cluster.GenerateName = clusterNamePrefix
+ // Provide a minimal valid IdentityRef by default so cluster create succeeds
+ cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+ Name: "creds",
+ CloudName: "openstack",
+ }
})
DescribeTable("Allow valid neutron filter tags", func(tags []infrav1.FilterByNeutronTags) {
diff --git a/test/e2e/suites/apivalidations/openstackcluster_test.go b/test/e2e/suites/apivalidations/openstackcluster_test.go
index 6d4bf1861b..f78401b411 100644
--- a/test/e2e/suites/apivalidations/openstackcluster_test.go
+++ b/test/e2e/suites/apivalidations/openstackcluster_test.go
@@ -47,6 +47,11 @@ var _ = Describe("OpenStackCluster API validations", func() {
cluster = &infrav1.OpenStackCluster{}
cluster.Namespace = namespace.Name
cluster.GenerateName = clusterNamePrefix
+ // Provide a minimal valid IdentityRef by default so success-case tests can create the cluster
+ cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+ Name: "creds",
+ CloudName: "openstack",
+ }
})
It("should allow the smallest permissible cluster spec", func() {
@@ -235,5 +240,118 @@ var _ = Describe("OpenStackCluster API validations", func() {
u := unstructuredClusterWithAPIPort(math.MaxUint16)
Expect(createObj(u)).To(Succeed(), "OpenStackCluster creation should succeed")
})
+
+ // OpenStackIdentityReference validation tests
+ It("should reject when identityRef.name is missing", func() {
+ cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+ CloudName: "openstack",
+ // Name missing
+ }
+ Expect(createObj(cluster)).NotTo(Succeed(), "OpenStackCluster creation should fail when identityRef.name is missing")
+ })
+
+ It("should reject when identityRef.cloudName is missing", func() {
+ cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+ Name: "creds",
+ // CloudName missing
+ }
+ Expect(createObj(cluster)).NotTo(Succeed(), "OpenStackCluster creation should fail when identityRef.cloudName is missing")
+ })
+
+ It("should default identityRef.type to Secret when omitted", func() {
+ cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+ Name: "creds",
+ CloudName: "openstack",
+ // Type omitted -> should default to Secret
+ }
+ Expect(createObj(cluster)).To(Succeed(), "OpenStackCluster creation should succeed")
+
+ fetched := &infrav1.OpenStackCluster{}
+ Expect(k8sClient.Get(ctx, types.NamespacedName{Name: cluster.Name, Namespace: cluster.Namespace}, fetched)).To(Succeed(), "OpenStackCluster fetch should succeed")
+ Expect(fetched.Spec.IdentityRef.Type).To(Equal("Secret"), "identityRef.type should default to Secret")
+ })
+
+ It("should reject updates that modify identityRef.region (immutable)", func() {
+ cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+ Name: "creds",
+ CloudName: "openstack",
+ Region: "RegionOne",
+ }
+ Expect(createObj(cluster)).To(Succeed(), "OpenStackCluster creation should succeed")
+
+ // Attempt to change region
+ cluster.Spec.IdentityRef.Region = "RegionTwo"
+ Expect(k8sClient.Update(ctx, cluster)).NotTo(Succeed(), "Updating identityRef.region should fail")
+ })
+
+ It("should reject updates that set identityRef.region when previously unset", func() {
+ cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+ Name: "creds",
+ CloudName: "openstack",
+ // Region omitted initially
+ }
+ Expect(createObj(cluster)).To(Succeed(), "OpenStackCluster creation should succeed")
+
+ // Attempt to set region after creation
+ cluster.Spec.IdentityRef.Region = "RegionOne"
+ Expect(k8sClient.Update(ctx, cluster)).NotTo(Succeed(), "Setting identityRef.region post-creation should fail")
+ })
+
+ It("should accept identityRef.type=ClusterIdentity with required fields", func() {
+ cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+ Type: "ClusterIdentity",
+ Name: "prod-id",
+ CloudName: "openstack",
+ }
+ Expect(createObj(cluster)).To(Succeed(), "OpenStackCluster creation should succeed for ClusterIdentity type")
+ })
+
+ // Edge case tests
+
+ It("should reject when identityRef is completely missing", func() {
+ // Explicitly clear identityRef to simulate missing values
+ cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{}
+ Expect(createObj(cluster)).NotTo(Succeed(), "OpenStackCluster creation should fail when identityRef is missing")
+ })
+
+ It("should reject when identityRef.name is empty string", func() {
+ cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+ Name: "", // Empty string
+ CloudName: "openstack",
+ }
+ Expect(createObj(cluster)).NotTo(Succeed(), "OpenStackCluster creation should fail when identityRef.name is empty")
+ })
+
+ It("should reject when identityRef.cloudName is empty string", func() {
+ cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+ Name: "creds",
+ CloudName: "", // Empty string
+ }
+ Expect(createObj(cluster)).NotTo(Succeed(), "OpenStackCluster creation should fail when identityRef.cloudName is empty")
+ })
+
+ It("should reject invalid identityRef.type value", func() {
+ // Need to use unstructured since Go types won't allow invalid enum
+ obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(cluster)
+ Expect(err).NotTo(HaveOccurred(), "converting cluster to unstructured")
+
+ u := &unstructured.Unstructured{}
+ u.Object = obj
+ u.SetGroupVersionKind(schema.GroupVersionKind{
+ Group: infrav1.SchemeGroupVersion.Group,
+ Version: infrav1.SchemeGroupVersion.Version,
+ Kind: "OpenStackCluster",
+ })
+
+ // Set invalid type
+ spec := obj["spec"].(map[string]any)
+ spec["identityRef"] = map[string]any{
+ "type": "InvalidType",
+ "name": "creds",
+ "cloudName": "openstack",
+ }
+
+ Expect(createObj(u)).NotTo(Succeed(), "OpenStackCluster creation should fail with invalid identityRef.type")
+ })
})
})
diff --git a/test/e2e/suites/e2e/e2e_test.go b/test/e2e/suites/e2e/e2e_test.go
index 6186fb5976..3fbdd48627 100644
--- a/test/e2e/suites/e2e/e2e_test.go
+++ b/test/e2e/suites/e2e/e2e_test.go
@@ -331,6 +331,33 @@ var _ = Describe("e2e tests [PR-Blocking]", func() {
})
})
+ Describe("Workload cluster (cluster-identity)", func() {
+ It("should be creatable and deletable", func(ctx context.Context) {
+ shared.Logf("Creating a cluster with ClusterIdentity")
+ clusterName := fmt.Sprintf("cluster-%s", namespace.Name)
+ configCluster := defaultConfigCluster(clusterName, namespace.Name)
+ configCluster.ControlPlaneMachineCount = ptr.To(int64(1))
+ configCluster.WorkerMachineCount = ptr.To(int64(1))
+ configCluster.Flavor = shared.FlavorClusterIdentity
+ createCluster(ctx, configCluster, clusterResources)
+
+ md := clusterResources.MachineDeployments
+ workerMachines := framework.GetMachinesByMachineDeployments(ctx, framework.GetMachinesByMachineDeploymentsInput{
+ Lister: e2eCtx.Environment.BootstrapClusterProxy.GetClient(),
+ ClusterName: clusterName,
+ Namespace: namespace.Name,
+ MachineDeployment: *md[0],
+ })
+ controlPlaneMachines := framework.GetControlPlaneMachinesByCluster(ctx, framework.GetControlPlaneMachinesByClusterInput{
+ Lister: e2eCtx.Environment.BootstrapClusterProxy.GetClient(),
+ ClusterName: clusterName,
+ Namespace: namespace.Name,
+ })
+ Expect(workerMachines).To(HaveLen(int(*configCluster.WorkerMachineCount)))
+ Expect(controlPlaneMachines).To(HaveLen(int(*configCluster.ControlPlaneMachineCount)))
+ })
+ })
+
Describe("Workload cluster (no bastion)", func() {
It("should be creatable and deletable", func(ctx context.Context) {
shared.Logf("Creating a cluster")