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.

+

+ + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescription
+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.

+

+ + + + + + + + + + + + + + + + + +
FieldDescription
+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.

+

+ + + + + + + + + + + + + + + + + +
FieldDescription
+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")