diff --git a/api/bases/keystone.openstack.org_keystoneapplicationcredentials.yaml b/api/bases/keystone.openstack.org_keystoneapplicationcredentials.yaml new file mode 100644 index 00000000..302d134f --- /dev/null +++ b/api/bases/keystone.openstack.org_keystoneapplicationcredentials.yaml @@ -0,0 +1,208 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: keystoneapplicationcredentials.keystone.openstack.org +spec: + group: keystone.openstack.org + names: + kind: KeystoneApplicationCredential + listKind: KeystoneApplicationCredentialList + plural: keystoneapplicationcredentials + shortNames: + - appcred + singular: keystoneapplicationcredential + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Keystone ApplicationCredential ID + jsonPath: .status.acID + name: ACID + type: string + - description: Secret holding ApplicationCredential secret + jsonPath: .status.secretName + name: SecretName + type: string + - description: Last rotation time + format: date-time + jsonPath: .status.lastRotated + name: LastRotated + type: string + - description: When rotation becomes eligible + format: date-time + jsonPath: .status.rotationEligibleAt + name: RotationEligible + type: string + - description: Status + jsonPath: .status.conditions[0].status + name: Status + type: string + - description: Message + jsonPath: .status.conditions[0].message + name: Message + type: string + name: v1beta1 + schema: + openAPIV3Schema: + description: KeystoneApplicationCredential is the Schema for the applicationcredentials + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: KeystoneApplicationCredentialSpec defines what the user can + set + properties: + accessRules: + description: AccessRules defines which services the ApplicationCredential + is permitted to access + items: + description: ACRule defines a additional access rule for an ApplicationCredential + properties: + method: + description: Method is the HTTP verb to allow (defaults to all + if empty) + type: string + path: + description: Path is the API path to allow + type: string + service: + description: Service is the OpenStack service type + type: string + type: object + type: array + expirationDays: + default: 365 + description: ExpirationDays sets the lifetime in days for the ApplicationCredential + minimum: 2 + type: integer + gracePeriodDays: + default: 182 + description: GracePeriodDays sets how many days before expiration + the ApplicationCredential should be rotated + minimum: 1 + type: integer + passwordSelector: + description: PasswordSelector for extracting the service password + type: string + roles: + description: Roles to assign to the ApplicationCredential + items: + type: string + minItems: 1 + type: array + secret: + description: Secret containing service user password + type: string + unrestricted: + default: false + description: Unrestricted indicates whether the ApplicationCredential + may be used to create or destroy other credentials or trusts + type: boolean + userName: + description: UserName - the Keystone user under which this ApplicationCredential + is created + type: string + required: + - passwordSelector + - roles + - secret + - userName + type: object + x-kubernetes-validations: + - message: gracePeriodDays must be smaller than expirationDays + rule: self.gracePeriodDays < self.expirationDays + status: + description: KeystoneApplicationCredentialStatus defines the observed + state + properties: + acID: + description: ACID - the ID in Keystone for this ApplicationCredential + type: string + conditions: + description: Conditions + items: + description: Condition defines an observation of a API resource + operational state. + properties: + lastTransitionTime: + description: |- + Last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when + the API field changed is acceptable. + format: date-time + type: string + message: + description: A human readable message indicating details about + the transition. + type: string + reason: + description: The reason for the condition's last transition + in CamelCase. + type: string + severity: + description: |- + Severity provides a classification of Reason code, so the current situation is immediately + understandable and could act accordingly. + It is meant for situations where Status=False and it should be indicated if it is just + informational, warning (next reconciliation might fix it) or an error (e.g. DB create issue + and no actions to automatically resolve the issue can/should be done). + For conditions where Status=Unknown or Status=True the Severity should be SeverityNone. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: Type of condition in CamelCase. + type: string + required: + - lastTransitionTime + - status + - type + type: object + type: array + createdAt: + description: CreatedAt - timestap of creation + format: date-time + type: string + expiresAt: + description: ExpiresAt - time of validity expiration + format: date-time + type: string + lastRotated: + description: LastRotated - timestamp when credentials were last rotated + format: date-time + type: string + rotationEligibleAt: + description: |- + RotationEligibleAt indicates when rotation becomes eligible (start of grace period window). + Computed as ExpiresAt - GracePeriodDays. The AC can be rotated after this timestamp. + format: date-time + type: string + secretName: + description: SecretName - name of the k8s Secret storing the ApplicationCredential + secret + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/api/v1beta1/conditions.go b/api/v1beta1/conditions.go index e263abda..84b246bb 100644 --- a/api/v1beta1/conditions.go +++ b/api/v1beta1/conditions.go @@ -35,6 +35,9 @@ const ( // KeystoneServiceOSUserReadyCondition Status=True condition which indicates if the service user got created in the keystone instance is ready/was successful KeystoneServiceOSUserReadyCondition condition.Type = "KeystoneServiceOSUserReady" + + // KeystoneApplicationCredentialReadyCondition Status=True condition which indicates if the ApplicationCredential has been created and is ready + KeystoneApplicationCredentialReadyCondition condition.Type = "KeystoneApplicationCredentialReady" ) // Common Messages used by API objects. @@ -111,4 +114,16 @@ const ( // KeystoneServiceOSUserReadyErrorMessage KeystoneServiceOSUserReadyErrorMessage = "Keystone Service user error occured %s" + + // + // KeystoneApplicationCredentialReady condition messages + // + // KeystoneApplicationCredentialReadyInitMessage + KeystoneApplicationCredentialReadyInitMessage = "ApplicationCredential not yet created" + + // KeystoneApplicationCredentialReadyMessage + KeystoneApplicationCredentialReadyMessage = "ApplicationCredential ready" + + // KeystoneApplicationCredentialReadyErrorMessage + KeystoneApplicationCredentialReadyErrorMessage = "ApplicationCredential error occurred: %s" ) diff --git a/api/v1beta1/keystoneapi.go b/api/v1beta1/keystoneapi.go index b75b703d..f35a1ca5 100644 --- a/api/v1beta1/keystoneapi.go +++ b/api/v1beta1/keystoneapi.go @@ -138,6 +138,112 @@ func GetAdminServiceClient( return os, ctrlResult, nil } +// GetUserServiceClient - returns an *openstack.OpenStack object scoped as the given service user +func GetUserServiceClient( + ctx context.Context, + h *helper.Helper, + keystoneAPI *KeystoneAPI, + userName string, + secretName string, + passwordSelector string, +) (*openstack.OpenStack, ctrl.Result, error) { + + authURL, err := keystoneAPI.GetEndpoint(endpoint.EndpointInternal) + if err != nil { + return nil, ctrl.Result{}, err + } + + parsedAuthURL, err := url.Parse(authURL) + if err != nil { + return nil, ctrl.Result{}, err + } + + tlsConfig := &openstack.TLSConfig{} + if parsedAuthURL.Scheme == "https" && keystoneAPI.Spec.TLS.CaBundleSecretName != "" { + caCert, ctrlResult, err := secret.GetDataFromSecret( + ctx, + h, + keystoneAPI.Spec.TLS.CaBundleSecretName, + 10*time.Second, + tls.InternalCABundleKey) + if err != nil { + return nil, ctrlResult, err + } + if (ctrlResult != ctrl.Result{}) { + return nil, ctrlResult, + fmt.Errorf("CABundleSecret %s not found", + keystoneAPI.Spec.TLS.CaBundleSecretName) + } + + tlsConfig = &openstack.TLSConfig{ + CACerts: []string{caCert}, + } + } + + password, res, err := getPasswordFromOSPSecret(ctx, h, secretName, passwordSelector) + if err != nil { + return nil, ctrl.Result{}, fmt.Errorf("failed to get password from osp-secret for user %q: %w", userName, err) + } + if res != (ctrl.Result{}) { + return nil, res, nil + } + + scope := &gophercloud.AuthScope{ + ProjectName: "service", + DomainName: "Default", + } + + osClient, err := openstack.NewOpenStack( + ctx, + h.GetLogger(), + openstack.AuthOpts{ + AuthURL: authURL, + Username: userName, + Password: password, + TenantName: "service", + DomainName: "Default", + Region: keystoneAPI.Spec.Region, + TLS: tlsConfig, + Scope: scope, + }, + ) + if err != nil { + return nil, ctrl.Result{}, err + } + + return osClient, ctrl.Result{}, nil +} + +func getPasswordFromOSPSecret( + ctx context.Context, + h *helper.Helper, + ospSecretName, passwordSelector string, +) (string, ctrl.Result, error) { + if ospSecretName == "" { + return "", ctrl.Result{}, fmt.Errorf("secret name is empty, cannot retrieve password for selector %q", passwordSelector) + } + if passwordSelector == "" { + return "", ctrl.Result{}, fmt.Errorf("password selector is empty, cannot retrieve password from secret %q", ospSecretName) + } + data, res, err := secret.GetDataFromSecret( + ctx, + h, + ospSecretName, + 10*time.Second, + passwordSelector, + ) + if err != nil { + return "", ctrl.Result{}, fmt.Errorf("failed to get %q from Secret/%s: %w", passwordSelector, ospSecretName, err) + } + if res != (ctrl.Result{}) { + return "", res, nil + } + if data == "" { + return "", ctrl.Result{}, fmt.Errorf("password selector %q in secret %q is empty", passwordSelector, ospSecretName) + } + return data, ctrl.Result{}, nil +} + // GetScopedAdminServiceClient - get a scoped admin serviceClient for the keystoneAPI instance func GetScopedAdminServiceClient( ctx context.Context, diff --git a/api/v1beta1/keystoneapplicationcredential.go b/api/v1beta1/keystoneapplicationcredential.go new file mode 100644 index 00000000..798810f6 --- /dev/null +++ b/api/v1beta1/keystoneapplicationcredential.go @@ -0,0 +1,126 @@ +/* +Copyright 2025 + +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 v1beta1 + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/openstack-k8s-operators/lib-common/modules/common/env" + "github.com/openstack-k8s-operators/lib-common/modules/common/secret" + corev1 "k8s.io/api/core/v1" + k8s_errors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ApplicationCredentialData contains AC ID/Secret extracted from a Secret +// Used by service operators to get AC data from the Secret +type ApplicationCredentialData struct { + ID string + Secret string +} + +// GetACSecretName returns the standard AC Secret name for a service +func GetACSecretName(serviceName string) string { + return fmt.Sprintf("ac-%s-secret", serviceName) +} + +// GetACCRName returns the standard AC CR name for a service +func GetACCRName(serviceName string) string { + return fmt.Sprintf("ac-%s", serviceName) +} + +var ( + // ErrACIDMissing indicates AC_ID key missing or empty in the Secret + ErrACIDMissing = errors.New("applicationcredential secret missing AC_ID") + // ErrACSecretMissing indicates AC_SECRET key missing or empty in the Secret + ErrACSecretMissing = errors.New("applicationcredential secret missing AC_SECRET") +) + +// GetApplicationCredentialFromSecret fetches and validates AC data from the Secret +func GetApplicationCredentialFromSecret( + ctx context.Context, + c client.Client, + namespace string, + serviceName string, +) (*ApplicationCredentialData, error) { + secret := &corev1.Secret{} + key := types.NamespacedName{Namespace: namespace, Name: GetACSecretName(serviceName)} + if err := c.Get(ctx, key, secret); err != nil { + if k8s_errors.IsNotFound(err) { + return nil, nil + } + return nil, fmt.Errorf("get applicationcredential secret %s: %w", key, err) + } + + acID, okID := secret.Data["AC_ID"] + if !okID || len(acID) == 0 { + return nil, fmt.Errorf("%w: %s", ErrACIDMissing, key.String()) + } + acSecret, okSecret := secret.Data["AC_SECRET"] + if !okSecret || len(acSecret) == 0 { + return nil, fmt.Errorf("%w: %s", ErrACSecretMissing, key.String()) + } + + return &ApplicationCredentialData{ID: string(acID), Secret: string(acSecret)}, nil +} + +// VerifyApplicationCredentialsForService checks if AC secret exists and adds it to configVars. +// If the AC secret is not found or invalid, it returns ctrl.Result{} without error (AC is optional). +// If the AC secret is valid, it adds the secret hash to configVars for change tracking. +func VerifyApplicationCredentialsForService( + ctx context.Context, + c client.Client, + namespace string, + serviceName string, + configVars *map[string]env.Setter, + timeout time.Duration, +) (ctrl.Result, error) { + acSecretName := GetACSecretName(serviceName) + secretKey := types.NamespacedName{Namespace: namespace, Name: acSecretName} + + hash, res, err := secret.VerifySecret( + ctx, + secretKey, + []string{"AC_ID", "AC_SECRET"}, + c, + timeout, + ) + + // VerifySecret returns res.RequeueAfter > 0 when secret not found (not an error) + // For AC, this is optional, so we just skip it instead of requeueing + if res.RequeueAfter > 0 { + // AC secret not found - this is OK, service will use password auth + return ctrl.Result{}, nil + } + + if err != nil { + // Actual error (not NotFound) - continue with password auth + return ctrl.Result{}, nil + } + + // AC secret exists and is valid - add to configVars for hash tracking + if configVars != nil { + (*configVars)["secret-"+acSecretName] = env.SetValue(hash) + } + + return ctrl.Result{}, nil +} diff --git a/api/v1beta1/keystoneapplicationcredential_types.go b/api/v1beta1/keystoneapplicationcredential_types.go new file mode 100644 index 00000000..22c77cfd --- /dev/null +++ b/api/v1beta1/keystoneapplicationcredential_types.go @@ -0,0 +1,142 @@ +/* +Copyright 2025. + +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 v1beta1 + +import ( + "github.com/openstack-k8s-operators/lib-common/modules/common/condition" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// +kubebuilder:validation:XValidation:rule="self.gracePeriodDays < self.expirationDays",message="gracePeriodDays must be smaller than expirationDays" +// KeystoneApplicationCredentialSpec defines what the user can set +type KeystoneApplicationCredentialSpec struct { + + // Secret containing service user password + // +kubebuilder:validation:Required + Secret string `json:"secret"` + + // PasswordSelector for extracting the service password + // +kubebuilder:validation:Required + PasswordSelector string `json:"passwordSelector"` + + // UserName - the Keystone user under which this ApplicationCredential is created + UserName string `json:"userName"` + + // ExpirationDays sets the lifetime in days for the ApplicationCredential + // +kubebuilder:validation:Optional + // +kubebuilder:default=365 + // +kubebuilder:validation:Minimum=2 + ExpirationDays int `json:"expirationDays"` + + // GracePeriodDays sets how many days before expiration the ApplicationCredential should be rotated + // +kubebuilder:validation:Optional + // +kubebuilder:default=182 + // +kubebuilder:validation:Minimum=1 + GracePeriodDays int `json:"gracePeriodDays"` + + // Roles to assign to the ApplicationCredential + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinItems=1 + Roles []string `json:"roles"` + + // Unrestricted indicates whether the ApplicationCredential may be used to create or destroy other credentials or trusts + // +kubebuilder:validation:Optional + // +kubebuilder:default=false + Unrestricted bool `json:"unrestricted"` + + // AccessRules defines which services the ApplicationCredential is permitted to access + // +kubebuilder:validation:Optional + AccessRules []ACRule `json:"accessRules,omitempty"` +} + +// ACRule defines a additional access rule for an ApplicationCredential +type ACRule struct { + // Service is the OpenStack service type + // +kubebuilder:validation:Optional + Service string `json:"service,omitempty"` + + // Path is the API path to allow + // +kubebuilder:validation:Optional + Path string `json:"path,omitempty"` + + // Method is the HTTP verb to allow (defaults to all if empty) + // +kubebuilder:validation:Optional + Method string `json:"method,omitempty"` +} + +// KeystoneApplicationCredentialStatus defines the observed state +type KeystoneApplicationCredentialStatus struct { + // ACID - the ID in Keystone for this ApplicationCredential + ACID string `json:"acID,omitempty"` + + // SecretName - name of the k8s Secret storing the ApplicationCredential secret + SecretName string `json:"secretName,omitempty"` + + // Conditions + Conditions condition.Conditions `json:"conditions,omitempty"` + + // CreatedAt - timestap of creation + CreatedAt *metav1.Time `json:"createdAt,omitempty"` + + // ExpiresAt - time of validity expiration + ExpiresAt *metav1.Time `json:"expiresAt,omitempty"` + + // RotationEligibleAt indicates when rotation becomes eligible (start of grace period window). + // Computed as ExpiresAt - GracePeriodDays. The AC can be rotated after this timestamp. + // +kubebuilder:validation:Optional + RotationEligibleAt *metav1.Time `json:"rotationEligibleAt,omitempty"` + + // LastRotated - timestamp when credentials were last rotated + LastRotated *metav1.Time `json:"lastRotated,omitempty"` +} + +//+kubebuilder:object:root=true +//+kubebuilder:subresource:status +//+kubebuilder:resource:shortName=appcred +//+kubebuilder:printcolumn:name="ACID",type="string",JSONPath=".status.acID",description="Keystone ApplicationCredential ID" +//+kubebuilder:printcolumn:name="SecretName",type="string",JSONPath=".status.secretName",description="Secret holding ApplicationCredential secret" +//+kubebuilder:printcolumn:name="LastRotated",type="string",format="date-time",JSONPath=".status.lastRotated",description="Last rotation time" +//+kubebuilder:printcolumn:name="RotationEligible",type="string",format="date-time",JSONPath=".status.rotationEligibleAt",description="When rotation becomes eligible" +//+kubebuilder:printcolumn:name="Status",type="string",JSONPath=".status.conditions[0].status",description="Status" +//+kubebuilder:printcolumn:name="Message",type="string",JSONPath=".status.conditions[0].message",description="Message" + +// KeystoneApplicationCredential is the Schema for the applicationcredentials API +type KeystoneApplicationCredential struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec KeystoneApplicationCredentialSpec `json:"spec,omitempty"` + Status KeystoneApplicationCredentialStatus `json:"status,omitempty"` +} + +//+kubebuilder:object:root=true + +// KeystoneApplicationCredentialList contains a list of ApplicationCredential +type KeystoneApplicationCredentialList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []KeystoneApplicationCredential `json:"items"` +} + +func init() { + SchemeBuilder.Register(&KeystoneApplicationCredential{}, &KeystoneApplicationCredentialList{}) +} + +// IsReady - returns true if ApplicationCredential is reconciled successfully +func (ac *KeystoneApplicationCredential) IsReady() bool { + return ac.Status.Conditions.IsTrue(condition.ReadyCondition) +} diff --git a/api/v1beta1/zz_generated.deepcopy.go b/api/v1beta1/zz_generated.deepcopy.go index 463509f6..c37ea532 100644 --- a/api/v1beta1/zz_generated.deepcopy.go +++ b/api/v1beta1/zz_generated.deepcopy.go @@ -28,6 +28,21 @@ import ( "k8s.io/apimachinery/pkg/runtime" ) +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ACRule) DeepCopyInto(out *ACRule) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ACRule. +func (in *ACRule) DeepCopy() *ACRule { + if in == nil { + return nil + } + out := new(ACRule) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *APIOverrideSpec) DeepCopyInto(out *APIOverrideSpec) { *out = *in @@ -50,6 +65,21 @@ func (in *APIOverrideSpec) DeepCopy() *APIOverrideSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ApplicationCredentialData) DeepCopyInto(out *ApplicationCredentialData) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplicationCredentialData. +func (in *ApplicationCredentialData) DeepCopy() *ApplicationCredentialData { + if in == nil { + return nil + } + out := new(ApplicationCredentialData) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Endpoint) DeepCopyInto(out *Endpoint) { *out = *in @@ -307,6 +337,128 @@ func (in *KeystoneAPIStatus) DeepCopy() *KeystoneAPIStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KeystoneApplicationCredential) DeepCopyInto(out *KeystoneApplicationCredential) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeystoneApplicationCredential. +func (in *KeystoneApplicationCredential) DeepCopy() *KeystoneApplicationCredential { + if in == nil { + return nil + } + out := new(KeystoneApplicationCredential) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *KeystoneApplicationCredential) 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 *KeystoneApplicationCredentialList) DeepCopyInto(out *KeystoneApplicationCredentialList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]KeystoneApplicationCredential, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeystoneApplicationCredentialList. +func (in *KeystoneApplicationCredentialList) DeepCopy() *KeystoneApplicationCredentialList { + if in == nil { + return nil + } + out := new(KeystoneApplicationCredentialList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *KeystoneApplicationCredentialList) 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 *KeystoneApplicationCredentialSpec) DeepCopyInto(out *KeystoneApplicationCredentialSpec) { + *out = *in + if in.Roles != nil { + in, out := &in.Roles, &out.Roles + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.AccessRules != nil { + in, out := &in.AccessRules, &out.AccessRules + *out = make([]ACRule, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeystoneApplicationCredentialSpec. +func (in *KeystoneApplicationCredentialSpec) DeepCopy() *KeystoneApplicationCredentialSpec { + if in == nil { + return nil + } + out := new(KeystoneApplicationCredentialSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KeystoneApplicationCredentialStatus) DeepCopyInto(out *KeystoneApplicationCredentialStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make(condition.Conditions, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.CreatedAt != nil { + in, out := &in.CreatedAt, &out.CreatedAt + *out = (*in).DeepCopy() + } + if in.ExpiresAt != nil { + in, out := &in.ExpiresAt, &out.ExpiresAt + *out = (*in).DeepCopy() + } + if in.RotationEligibleAt != nil { + in, out := &in.RotationEligibleAt, &out.RotationEligibleAt + *out = (*in).DeepCopy() + } + if in.LastRotated != nil { + in, out := &in.LastRotated, &out.LastRotated + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KeystoneApplicationCredentialStatus. +func (in *KeystoneApplicationCredentialStatus) DeepCopy() *KeystoneApplicationCredentialStatus { + if in == nil { + return nil + } + out := new(KeystoneApplicationCredentialStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KeystoneEndpoint) DeepCopyInto(out *KeystoneEndpoint) { *out = *in diff --git a/config/crd/bases/keystone.openstack.org_keystoneapplicationcredentials.yaml b/config/crd/bases/keystone.openstack.org_keystoneapplicationcredentials.yaml new file mode 100644 index 00000000..302d134f --- /dev/null +++ b/config/crd/bases/keystone.openstack.org_keystoneapplicationcredentials.yaml @@ -0,0 +1,208 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.18.0 + name: keystoneapplicationcredentials.keystone.openstack.org +spec: + group: keystone.openstack.org + names: + kind: KeystoneApplicationCredential + listKind: KeystoneApplicationCredentialList + plural: keystoneapplicationcredentials + shortNames: + - appcred + singular: keystoneapplicationcredential + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Keystone ApplicationCredential ID + jsonPath: .status.acID + name: ACID + type: string + - description: Secret holding ApplicationCredential secret + jsonPath: .status.secretName + name: SecretName + type: string + - description: Last rotation time + format: date-time + jsonPath: .status.lastRotated + name: LastRotated + type: string + - description: When rotation becomes eligible + format: date-time + jsonPath: .status.rotationEligibleAt + name: RotationEligible + type: string + - description: Status + jsonPath: .status.conditions[0].status + name: Status + type: string + - description: Message + jsonPath: .status.conditions[0].message + name: Message + type: string + name: v1beta1 + schema: + openAPIV3Schema: + description: KeystoneApplicationCredential is the Schema for the applicationcredentials + API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: KeystoneApplicationCredentialSpec defines what the user can + set + properties: + accessRules: + description: AccessRules defines which services the ApplicationCredential + is permitted to access + items: + description: ACRule defines a additional access rule for an ApplicationCredential + properties: + method: + description: Method is the HTTP verb to allow (defaults to all + if empty) + type: string + path: + description: Path is the API path to allow + type: string + service: + description: Service is the OpenStack service type + type: string + type: object + type: array + expirationDays: + default: 365 + description: ExpirationDays sets the lifetime in days for the ApplicationCredential + minimum: 2 + type: integer + gracePeriodDays: + default: 182 + description: GracePeriodDays sets how many days before expiration + the ApplicationCredential should be rotated + minimum: 1 + type: integer + passwordSelector: + description: PasswordSelector for extracting the service password + type: string + roles: + description: Roles to assign to the ApplicationCredential + items: + type: string + minItems: 1 + type: array + secret: + description: Secret containing service user password + type: string + unrestricted: + default: false + description: Unrestricted indicates whether the ApplicationCredential + may be used to create or destroy other credentials or trusts + type: boolean + userName: + description: UserName - the Keystone user under which this ApplicationCredential + is created + type: string + required: + - passwordSelector + - roles + - secret + - userName + type: object + x-kubernetes-validations: + - message: gracePeriodDays must be smaller than expirationDays + rule: self.gracePeriodDays < self.expirationDays + status: + description: KeystoneApplicationCredentialStatus defines the observed + state + properties: + acID: + description: ACID - the ID in Keystone for this ApplicationCredential + type: string + conditions: + description: Conditions + items: + description: Condition defines an observation of a API resource + operational state. + properties: + lastTransitionTime: + description: |- + Last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when + the API field changed is acceptable. + format: date-time + type: string + message: + description: A human readable message indicating details about + the transition. + type: string + reason: + description: The reason for the condition's last transition + in CamelCase. + type: string + severity: + description: |- + Severity provides a classification of Reason code, so the current situation is immediately + understandable and could act accordingly. + It is meant for situations where Status=False and it should be indicated if it is just + informational, warning (next reconciliation might fix it) or an error (e.g. DB create issue + and no actions to automatically resolve the issue can/should be done). + For conditions where Status=Unknown or Status=True the Severity should be SeverityNone. + type: string + status: + description: Status of the condition, one of True, False, Unknown. + type: string + type: + description: Type of condition in CamelCase. + type: string + required: + - lastTransitionTime + - status + - type + type: object + type: array + createdAt: + description: CreatedAt - timestap of creation + format: date-time + type: string + expiresAt: + description: ExpiresAt - time of validity expiration + format: date-time + type: string + lastRotated: + description: LastRotated - timestamp when credentials were last rotated + format: date-time + type: string + rotationEligibleAt: + description: |- + RotationEligibleAt indicates when rotation becomes eligible (start of grace period window). + Computed as ExpiresAt - GracePeriodDays. The AC can be rotated after this timestamp. + format: date-time + type: string + secretName: + description: SecretName - name of the k8s Secret storing the ApplicationCredential + secret + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 05c6675e..12891087 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -5,6 +5,7 @@ resources: - bases/keystone.openstack.org_keystoneapis.yaml - bases/keystone.openstack.org_keystoneservices.yaml - bases/keystone.openstack.org_keystoneendpoints.yaml +- bases/keystone.openstack.org_keystoneapplicationcredentials.yaml #+kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index ac3c42ce..7361e4b8 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -19,6 +19,24 @@ rules: - patch - update - watch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - "" + resources: + - secrets/finalizers + verbs: + - create + - delete + - get + - list + - patch + - update - apiGroups: - "" resources: @@ -67,6 +85,7 @@ rules: - keystone.openstack.org resources: - keystoneapis + - keystoneapplicationcredentials - keystoneendpoints - keystoneservices verbs: @@ -81,6 +100,7 @@ rules: - keystone.openstack.org resources: - keystoneapis/finalizers + - keystoneapplicationcredentials/finalizers - keystoneendpoints/finalizers - keystoneservices/finalizers verbs: @@ -90,6 +110,7 @@ rules: - keystone.openstack.org resources: - keystoneapis/status + - keystoneapplicationcredentials/status - keystoneendpoints/status - keystoneservices/status verbs: diff --git a/controllers/keystoneapplicationcredential_controller.go b/controllers/keystoneapplicationcredential_controller.go new file mode 100644 index 00000000..38a43343 --- /dev/null +++ b/controllers/keystoneapplicationcredential_controller.go @@ -0,0 +1,543 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package controllers + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/go-logr/logr" + "github.com/gophercloud/gophercloud/v2" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/applicationcredentials" + "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/tokens" + keystonev1 "github.com/openstack-k8s-operators/keystone-operator/api/v1beta1" + "github.com/openstack-k8s-operators/lib-common/modules/common/condition" + "github.com/openstack-k8s-operators/lib-common/modules/common/helper" + oko_secret "github.com/openstack-k8s-operators/lib-common/modules/common/secret" + "github.com/openstack-k8s-operators/lib-common/modules/common/util" + corev1 "k8s.io/api/core/v1" + k8s_errors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/rand" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/record" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + ctrlLog "sigs.k8s.io/controller-runtime/pkg/log" +) + +const acSecretFinalizer = "openstack.org/ac-secret-protection" // #nosec G101 +const finalizer = "openstack.org/applicationcredential" // #nosec G101 + +// ApplicationCredentialReconciler reconciles an ApplicationCredential object +type ApplicationCredentialReconciler struct { + client.Client + Kclient kubernetes.Interface + Log logr.Logger + Scheme *runtime.Scheme + EventRecorder record.EventRecorder +} + +//+kubebuilder:rbac:groups=keystone.openstack.org,resources=keystoneapplicationcredentials,verbs=get;list;watch;create;update;patch;delete +//+kubebuilder:rbac:groups=keystone.openstack.org,resources=keystoneapplicationcredentials/status,verbs=get;update;patch +//+kubebuilder:rbac:groups=keystone.openstack.org,resources=keystoneapplicationcredentials/finalizers,verbs=update;patch +//+kubebuilder:rbac:groups=core,resources=secrets/finalizers,verbs=get;list;create;update;delete;patch +//+kubebuilder:rbac:groups=core,resources=events,verbs=create;patch + +// Reconcile reconciles a KeystoneApplicationCredential resource. +func (r *ApplicationCredentialReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := r.GetLogger(ctx) + + // Fetch the ApplicationCredential CR + instance := &keystonev1.KeystoneApplicationCredential{} + if err := r.Get(ctx, req.NamespacedName, instance); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + // Add finalizer only if not deleting and not already present + if instance.DeletionTimestamp.IsZero() && !controllerutil.ContainsFinalizer(instance, finalizer) { + base := instance.DeepCopy() + controllerutil.AddFinalizer(instance, finalizer) + if err := r.Patch(ctx, instance, client.MergeFrom(base)); err != nil { + if k8s_errors.IsConflict(err) { + return ctrl.Result{Requeue: true}, nil + } + return ctrl.Result{}, err + } + return ctrl.Result{Requeue: true}, nil + } + + // Initialize conditions on first pass + if instance.Status.Conditions == nil { + cl := condition.CreateList( + condition.UnknownCondition(keystonev1.KeystoneAPIReadyCondition, condition.InitReason, keystonev1.KeystoneAPIReadyInitMessage), + condition.UnknownCondition(keystonev1.KeystoneApplicationCredentialReadyCondition, condition.InitReason, keystonev1.KeystoneApplicationCredentialReadyInitMessage), + condition.UnknownCondition(condition.ReadyCondition, condition.InitReason, condition.ReadyInitMessage), + ) + instance.Status.Conditions.Init(&cl) + if err := r.Client.Status().Update(ctx, instance); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + + // Setup helper for use in reconciles + helperObj, err := helper.NewHelper(instance, r.Client, r.Kclient, r.Scheme, logger) + if err != nil { + return ctrl.Result{}, err + } + + // Save and defer patching all subconditions and overall Ready + saved := instance.Status.Conditions.DeepCopy() + defer func() { + if instance.DeletionTimestamp.IsZero() { + condition.RestoreLastTransitionTimes(&instance.Status.Conditions, saved) + // ApplicationCredentialReady being True implies KeystoneAPIReady is True, + // so when ApplicationCredential is ready, setup is complete + if instance.Status.Conditions.IsTrue(keystonev1.KeystoneApplicationCredentialReadyCondition) { + instance.Status.Conditions.MarkTrue(condition.ReadyCondition, condition.ReadyMessage) + } else { + instance.Status.Conditions.MarkUnknown(condition.ReadyCondition, condition.InitReason, condition.ReadyInitMessage) + instance.Status.Conditions.Set(instance.Status.Conditions.Mirror(condition.ReadyCondition)) + } + _ = helperObj.PatchInstance(ctx, instance) + } + }() + + // Fetch KeystoneAPI + keystoneAPI, err := keystonev1.GetKeystoneAPI(ctx, helperObj, instance.Namespace, nil) + if err != nil { + instance.Status.Conditions.Set(condition.FalseCondition( + keystonev1.KeystoneAPIReadyCondition, + condition.RequestedReason, + condition.SeverityInfo, + "KeystoneAPI not found: %v", + err, + )) + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + if !keystoneAPI.IsReady() { + instance.Status.Conditions.Set(condition.FalseCondition( + keystonev1.KeystoneAPIReadyCondition, + condition.RequestedReason, + condition.SeverityInfo, + keystonev1.KeystoneAPIReadyWaitingMessage, + )) + return ctrl.Result{RequeueAfter: 10 * time.Second}, nil + } + + instance.Status.Conditions.MarkTrue( + keystonev1.KeystoneAPIReadyCondition, + keystonev1.KeystoneAPIReadyMessage, + ) + + // If KeystoneAPI is being deleted, just drop finalizer + if !instance.DeletionTimestamp.IsZero() && !keystoneAPI.DeletionTimestamp.IsZero() { + if controllerutil.ContainsFinalizer(instance, finalizer) { + base := instance.DeepCopy() + controllerutil.RemoveFinalizer(instance, finalizer) + if err := r.Patch(ctx, instance, client.MergeFrom(base)); err != nil { + if k8s_errors.IsConflict(err) { + return ctrl.Result{Requeue: true}, nil + } + return ctrl.Result{}, err + } + } + return ctrl.Result{}, nil + } + + if !instance.DeletionTimestamp.IsZero() { + return r.reconcileDelete(ctx, instance) + } + return r.reconcileNormal(ctx, instance, helperObj, keystoneAPI) +} + +func (r *ApplicationCredentialReconciler) reconcileNormal( + ctx context.Context, + instance *keystonev1.KeystoneApplicationCredential, + helperObj *helper.Helper, + keystoneAPI *keystonev1.KeystoneAPI, +) (ctrl.Result, error) { + logger := r.GetLogger(ctx) + + // Decide if we need to create or rotate + doRotate, msg := needsRotation(instance) + + // Inspect current Secret status + if instance.Status.SecretName != "" { + secret := &corev1.Secret{} + key := types.NamespacedName{Namespace: instance.Namespace, Name: instance.Status.SecretName} + if err := r.Get(ctx, key, secret); err != nil { + if k8s_errors.IsNotFound(err) { + doRotate = true + msg = "ApplicationCredential secret missing, rotating" + } else { + return ctrl.Result{}, err + } + } + } + + if doRotate { + logger.Info(msg) + + // Determine if this is a rotation (existing ApplicationCredential) vs initial creation + isRotation := instance.Status.ACID != "" + + // Build a user-scoped client + userOS, userRes, userErr := keystonev1.GetUserServiceClient( + ctx, helperObj, keystoneAPI, + instance.Spec.UserName, + instance.Spec.Secret, + instance.Spec.PasswordSelector, + ) + if userErr != nil { + instance.Status.Conditions.Set(condition.FalseCondition( + keystonev1.KeystoneApplicationCredentialReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + keystonev1.KeystoneApplicationCredentialReadyErrorMessage, + userErr.Error(), + )) + return userRes, userErr + } + if userRes != (ctrl.Result{}) { + instance.Status.Conditions.Set(condition.FalseCondition( + keystonev1.KeystoneApplicationCredentialReadyCondition, + condition.RequestedReason, + condition.SeverityInfo, + "Waiting for secret %q to be available", + instance.Spec.Secret, + )) + return userRes, nil + } + + // Extract user ID from the authenticated token + userID, err := r.getUserIDFromToken(ctx, userOS.GetOSClient(), instance.Spec.UserName) + if err != nil { + logger.Error(err, "Could not get user ID from token") + instance.Status.Conditions.Set(condition.FalseCondition( + keystonev1.KeystoneApplicationCredentialReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + keystonev1.KeystoneApplicationCredentialReadyErrorMessage, + fmt.Sprintf("Failed to get user ID from token: %s", err.Error()), + )) + return ctrl.Result{}, err + } + logger.Info("Using Keystone user", "userName", instance.Spec.UserName, "userID", userID) + + // Create new ApplicationCredential in Keystone + newACName := fmt.Sprintf("%s-%s", instance.Name, rand.String(5)) + newID, newSecret, expiresAt, err := r.createACWithName( + ctx, userOS.GetOSClient(), userID, instance, newACName, + ) + if err != nil { + logger.Error(err, "Could not create ApplicationCredential in Keystone") + instance.Status.Conditions.Set(condition.FalseCondition( + keystonev1.KeystoneApplicationCredentialReadyCondition, + condition.ErrorReason, + condition.SeverityWarning, + keystonev1.KeystoneApplicationCredentialReadyErrorMessage, + fmt.Sprintf("Failed to create ApplicationCredential: %s", err.Error()), + )) + return ctrl.Result{}, err + } + + // Store it in a Secret (create or update) + secretName := fmt.Sprintf("%s-secret", instance.Name) + if err := r.storeACSecret(ctx, helperObj, instance, secretName, newID, newSecret); err != nil { + if strings.HasPrefix(err.Error(), "requeue: ") { + return ctrl.Result{RequeueAfter: 500 * time.Millisecond}, nil + } + return ctrl.Result{}, err + } + + // Capture previous expiration before updating status (for rotation event) + var previousExpiresAt string + if isRotation && instance.Status.ExpiresAt != nil { + previousExpiresAt = instance.Status.ExpiresAt.Format(time.RFC3339) + } + + // Update status + instance.Status.ACID = newID + instance.Status.SecretName = secretName + instance.Status.CreatedAt = &metav1.Time{Time: time.Now().UTC()} + instance.Status.ExpiresAt = &metav1.Time{Time: expiresAt} + + // Calculate rotation eligible time (start of grace period window) + graceDuration := time.Duration(instance.Spec.GracePeriodDays) * 24 * time.Hour + rotationEligibleAt := expiresAt.Add(-graceDuration) + instance.Status.RotationEligibleAt = &metav1.Time{Time: rotationEligibleAt} + + instance.Status.Conditions.MarkTrue(keystonev1.KeystoneApplicationCredentialReadyCondition, keystonev1.KeystoneApplicationCredentialReadyMessage) + + // Set LastRotated and emit event if this was a rotation + if isRotation { + now := metav1.Now() + instance.Status.LastRotated = &now + + // Emit event for EDPM visibility + r.EventRecorder.Event( + instance, + corev1.EventTypeNormal, + "ApplicationCredentialRotated", + fmt.Sprintf("ApplicationCredential '%s' (user: %s) rotated - EDPM nodes may need credential updates. Previous expiration: %s, New expiration: %s", + instance.Name, instance.Spec.UserName, previousExpiresAt, expiresAt.Format(time.RFC3339)), + ) + + logger.Info("ApplicationCredential rotated", "serviceName", instance.Spec.UserName) + } else { + logger.Info("ApplicationCredential created", "serviceName", instance.Spec.UserName) + } + + // patch status immediately + if err := helperObj.PatchInstance(ctx, instance); err != nil { + return ctrl.Result{}, err + } + + // requeue to check again before next rotate + nextCheck := r.computeNextRequeue(instance) + logger.Info("ApplicationCredential ready", "secret", secretName, "ACID", newID, "expiresAt", expiresAt, "nextCheck", nextCheck) + return ctrl.Result{RequeueAfter: nextCheck}, nil + } + + // ApplicationCredential already exists and is valid + // Update RotationEligibleAt in case GracePeriodDays changed + if instance.Status.ExpiresAt != nil { + graceDuration := time.Duration(instance.Spec.GracePeriodDays) * 24 * time.Hour + rotationEligibleAt := instance.Status.ExpiresAt.Add(-graceDuration) + instance.Status.RotationEligibleAt = &metav1.Time{Time: rotationEligibleAt} + } + + instance.Status.Conditions.MarkTrue(keystonev1.KeystoneApplicationCredentialReadyCondition, keystonev1.KeystoneApplicationCredentialReadyMessage) + nextCheck := r.computeNextRequeue(instance) + return ctrl.Result{RequeueAfter: nextCheck}, nil +} + +func (r *ApplicationCredentialReconciler) reconcileDelete( + ctx context.Context, + instance *keystonev1.KeystoneApplicationCredential, +) (ctrl.Result, error) { + + // Before removing our CR finalizer, allow ApplicationCredential Secret to be deleted by removing its protection finalizer + if instance.Status.SecretName != "" { + key := types.NamespacedName{Namespace: instance.Namespace, Name: instance.Status.SecretName} + secret := &corev1.Secret{} + if err := r.Get(ctx, key, secret); err == nil { + base := secret.DeepCopy() + controllerutil.RemoveFinalizer(secret, acSecretFinalizer) + if err := r.Patch(ctx, secret, client.MergeFrom(base)); err != nil { + if k8s_errors.IsConflict(err) { + return ctrl.Result{Requeue: true}, nil + } + return ctrl.Result{}, err + } + } + } + + // Remove finalizer from the ApplicationCredential CR + base := instance.DeepCopy() + controllerutil.RemoveFinalizer(instance, finalizer) + if err := r.Patch(ctx, instance, client.MergeFrom(base)); err != nil { + if k8s_errors.IsConflict(err) { + return ctrl.Result{Requeue: true}, nil + } + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +// createACWithName creates a new ApplicationCredential in Keystone +func (r *ApplicationCredentialReconciler) createACWithName( + ctx context.Context, + identClient *gophercloud.ServiceClient, + userID string, + ac *keystonev1.KeystoneApplicationCredential, + newACName string, +) (string, string, time.Time, error) { + logger := r.GetLogger(ctx) + unres := ac.Spec.Unrestricted + + roles := make([]applicationcredentials.Role, len(ac.Spec.Roles)) + for i, rn := range ac.Spec.Roles { + roles[i] = applicationcredentials.Role{Name: rn} + } + + // build any user-supplied rules (leave nil if none) + var rules []applicationcredentials.AccessRule + for _, ar := range ac.Spec.AccessRules { + if ar.Service == "" { + continue + } + rule := applicationcredentials.AccessRule{ + Service: ar.Service, + Path: ar.Path, + Method: ar.Method, + } + rules = append(rules, rule) + } + + createOpts := applicationcredentials.CreateOpts{ + Name: newACName, + Description: fmt.Sprintf("Created by keystone-operator for user %s", ac.Spec.UserName), + Unrestricted: unres, + Roles: roles, + } + + if len(rules) > 0 { + createOpts.AccessRules = rules + } + + if ac.Spec.ExpirationDays > 0 { + expires := time.Now().Add(time.Duration(ac.Spec.ExpirationDays) * 24 * time.Hour) + createOpts.ExpiresAt = &expires + } + + created, err := applicationcredentials.Create(ctx, identClient, userID, createOpts).Extract() + if err != nil { + return "", "", time.Time{}, fmt.Errorf("failed to create ApplicationCredential for user %s: %w", ac.Spec.UserName, err) + } + + logger.Info("Created ApplicationCredential in Keystone", + "ACID", created.ID, + "userID", userID, + "expiresAt", created.ExpiresAt, + ) + return created.ID, created.Secret, created.ExpiresAt, nil +} + +func (r *ApplicationCredentialReconciler) storeACSecret( + ctx context.Context, + helperObj *helper.Helper, + ac *keystonev1.KeystoneApplicationCredential, + secretName, newID, newSecret string, +) error { + + data := map[string]string{ + "AC_ID": newID, + "AC_SECRET": newSecret, + } + + // Add application-credentials label + labels := map[string]string{ + "application-credentials": "true", + } + + tmpl := []util.Template{{ + Name: secretName, + Namespace: ac.Namespace, + Type: util.TemplateTypeNone, + CustomData: data, + Labels: labels, + }} + if err := oko_secret.EnsureSecrets(ctx, helperObj, ac, tmpl, nil); err != nil { + return err + } + + // Ensure protection finalizer is present on the Secret + secret := &corev1.Secret{} + key := types.NamespacedName{Namespace: ac.Namespace, Name: secretName} + if err := helperObj.GetClient().Get(ctx, key, secret); err != nil { + return err + } + base := secret.DeepCopy() + if controllerutil.AddFinalizer(secret, acSecretFinalizer) { + if err := helperObj.GetClient().Patch(ctx, secret, client.MergeFrom(base)); err != nil { + if k8s_errors.IsConflict(err) { + return fmt.Errorf("%w: requeue: patch conflict on %s", err, secretName) + } + return err + } + } + + return nil +} + +// getUserIDFromToken extracts the user ID from the authenticated token +func (r *ApplicationCredentialReconciler) getUserIDFromToken(ctx context.Context, identClient *gophercloud.ServiceClient, username string) (string, error) { + + // Get the authenticated user from the token + user, err := tokens.Get(ctx, identClient, identClient.TokenID).ExtractUser() + if err != nil { + return "", fmt.Errorf("failed to extract user %s from token: %w", username, err) + } + + if user.ID == "" { + return "", fmt.Errorf("%w: user ID for user %s", util.ErrNotFound, username) + } + + return user.ID, nil +} + +// computeNextRequeue schedules the next check to happen at "expiry - grace" +func (r *ApplicationCredentialReconciler) computeNextRequeue(ac *keystonev1.KeystoneApplicationCredential) time.Duration { + // default requeue is 24h as minimal grace period is 1 day + defaultRequeue := 24 * time.Hour + if ac.Status.ExpiresAt == nil || ac.Status.ExpiresAt.IsZero() { + return defaultRequeue + } + + expiry := ac.Status.ExpiresAt.Time + + grace := time.Duration(ac.Spec.GracePeriodDays) * 24 * time.Hour + rotateAt := expiry.Add(-grace) + + if rotateAt.Before(time.Now()) { + // already within the grace window, so trigger rotation immediately + return 0 + } + + // otherwise check again in 24 hours + return defaultRequeue +} + +// needsRotation determines if an ApplicationCredential needs rotation. +// It checks if the ApplicationCredential exists and if it's within the grace period before expiration. +// Returns: shouldRotate: true if rotation is needed, false otherwise, logMessage: message to log +func needsRotation(ac *keystonev1.KeystoneApplicationCredential) (bool, string) { + if ac.Status.ACID == "" { + return true, "ApplicationCredential does not exist, creating" + } + expiry := ac.Status.ExpiresAt + if expiry != nil && !expiry.IsZero() { + // compute grace window + rotateAt := expiry.Add(-time.Duration(ac.Spec.GracePeriodDays) * 24 * time.Hour) + if time.Now().After(rotateAt) { + return true, "ApplicationCredential is within grace period, rotating" + } + } + return false, "" +} + +// SetupWithManager sets up the controller with the Manager. +func (r *ApplicationCredentialReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&keystonev1.KeystoneApplicationCredential{}). + Owns(&corev1.Secret{}). + Complete(r) +} + +// GetLogger returns a logger configured for this controller. +func (r *ApplicationCredentialReconciler) GetLogger(ctx context.Context) logr.Logger { + return ctrlLog.FromContext(ctx).WithName("ApplicationCredentialReconciler") +} diff --git a/docs/applicationcredentials.md b/docs/applicationcredentials.md new file mode 100644 index 00000000..10395a3c --- /dev/null +++ b/docs/applicationcredentials.md @@ -0,0 +1,212 @@ + +# ApplicationCredential Controller + + + +This document provides a brief overview of the Keystone ApplicationCredential (AC) controller. + + + +## General Information +AC controller watches `KeystoneApplicationCredential` custom resources (CR) and performs these actions: + +1. **Create** or **rotate** [ACs in Keystone](https://docs.openstack.org/keystone/2023.1/user/application_credentials.html) +2. **Store** the generated AC ID and AC secret in a k8s `Secret` +3. **Reconcile** the CR’s status (AC ID, creation/expiry time, conditions) + +Note: AC controller is not responsible for generating AC CR a service, that responsibility falls under the openstack-operator. AC controller merely watches/updates AC CR. + +## API Specification + +### KeystoneApplicationCredentialSpec +```yaml +spec: + # Secret containing service user password (default: osp-secret) + secret: osp-secret + # PasswordSelector for extracting the service password + passwordSelector: ServicePassword + # UserName - the Keystone user under which this ApplicationCredential is created + userName: barbican + # ExpirationDays sets the lifetime in days (default: 365, minimum: 2) + expirationDays: 365 + # GracePeriodDays sets rotation window (default: 182, minimum: 1) + # Must be smaller than expirationDays + gracePeriodDays: 182 + # Roles to assign to the ApplicationCredential (minimum: 1 role) + roles: + - service + # Unrestricted indicates whether AC may create/destroy other credentials + unrestricted: false + # AccessRules defines which services the AC is permitted to access + accessRules: + - service: compute + path: /servers + method: GET + - service: image + path: /images + method: GET +``` + +### KeystoneApplicationCredentialStatus +```yaml +status: + # ACID - the ID in Keystone for this ApplicationCredential + ACID: "7b23dbac20bc4f048f937415c84bb329" + # SecretName - name of the k8s Secret storing the ApplicationCredential secret + secretName: "ac-barbican-secret" + # CreatedAt - timestamp of creation + createdAt: "2025-05-29T09:02:28Z" + # ExpiresAt - time of validity expiration + expiresAt: "2026-05-29T09:02:28Z" + # Conditions + conditions: + - type: Ready + status: "True" + - type: KeystoneAPIReady + status: "True" +``` + +## Controller's Reconciliation Logic + +When the openstack-operator generates a new AC CR for Barbican `oc get appcred ac-barbican -n openstack`: + +```yaml +apiVersion: keystone.openstack.org/v1beta1 +kind: KeystoneApplicationCredential +metadata: + name: ac-barbican + namespace: openstack +spec: + expirationDays: 365 + gracePeriodDays: 182 + passwordSelector: BarbicanPassword + roles: + - service + secret: osp-secret + unrestricted: false + userName: barbican +``` + +the AC controller: +1. Reconcile() invoked + - Controller-runtime calls `Reconcile()` with `ac-barbican` + +2. Finalizer & Conditions + - Adds `openstack.org/applicationcredential` finalizer + - Initializes status conditions (`KeystoneAPIReady`, `AdminServiceClientReady`, `Ready`) + +3. Wait for KeystoneAPI + - If missing or not ready, marks condition and requeues + +4. Determine Rotation Need + - `needsRotation()` returns `true` because no AC ID exists yet + +5. Lookup User ID (Admin) + - Uses admin-scoped client to fetch the Keystone user ID for `barbican` + +6. Service‐Scoped Client + - Retrieves `BarbicanPassword` from `osp-secret` (or any other specified in the AC CR) + - Authenticates as `barbican` user + +7. Create AC in Keystone + - Calls gophercloud's `applicationcredentials.Create(...)` + - Includes access rules if specified in the CR + +8. Store Secret + - Creates a k8s `Secret` named `ac-barbican-secret` + - Adds `openstack.org/ac-secret-protection` finalizer to the Secret + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: ac-barbican-secret + namespace: openstack + finalizers: + - openstack.org/ac-secret-protection +data: + AC_ID: + AC_SECRET: +``` + +9. Update CR status + - Sets `.status.ACID`, `.status.secretName`, `.status.createdAt`, `.status.expiresAt` + - Marks AC CR ready + +10. Requeue for Next Check + - Calculates next reconcile at `expiresAt - gracePeriod` + - If already in grace window, requeues immediately, otherwise requeues after 24 h + +AC in Keystone side: +``` +openstack application credential show 7b23dbac20bc4f048f937415c84bb329 ++--------------+------------------------------------------------+ +| Field | Value | ++--------------+------------------------------------------------+ +| description | Created by keystone-operator for user barbican | +| expires_at | 2026-05-29T09:02:28.705012 | +| id | 7b23dbac20bc4f048f937415c84bb329 | +| name | ac-barbican-29090 | +| project_id | 24f05745bc2145c6a625b528ce21d7a3 | +| roles | service | +| system | None | +| unrestricted | False | +| user_id | 2ecd25b38f0d432388ad8b838e46f36d | ++--------------+------------------------------------------------+ +``` +Note: The actual AC `name` in Keystone includes a 5-character random suffix (e.g. `-29090`) to avoid collisions during rotation. + +## Rotation +When the next reconcile hits the grace window (`now ≥ expiresAt - gracePeriodDays`), `needsRotation()` returns `true` again and the controller: + +- Lookup User ID as before +- Service-Scoped Client +- Create New AC + - Generates a new Keystone AC with a fresh 5-char suffix + - Uses the same roles, unrestricted flag, access rules, and expirationDays + - Does _not_ revoke the old AC, the old credential naturally expires +- Store Updated Secret + - Overwrites the existing `ac-barbican-secret` with the new `AC_ID` and `AC_SECRET` +- Update Status + - Replaces `.status.ACID` and `.status.expiresAt` with the new values and re-marks Ready +- Requeue + - Schedules the next check at `(newExpiresAt - gracePeriodDays)` + +## Manual Rotation +Manual rotation can be triggered patching the AC CR with expiration with timestamp in the past, e.g.: + +`oc patch -n openstack keystoneapplicationcredential ac-barbican --type=merge --subresource=status -p '{"status":{"expiresAt":"2001-05-19T00:00:00Z"}}'` + +Note: Rotation is triggered by deleting AC CR itself, however that triggers services to fallback immediately to password usage. With patching just the expiration timestamp, no fallback is triggered. + +## Client-Side Helper Functions + +Service operators can use these helper functions to consume ApplicationCredential data: + +```go +import keystonev1 "github.com/openstack-k8s-operators/keystone-operator/api/v1beta1" + +// Get standard AC Secret name for a service +secretName := keystonev1.GetACSecretName("barbican") // Returns "ac-barbican-secret" + +// Get standard AC CR name for a service +crName := keystonev1.GetACCRName("barbican") // Returns "ac-barbican" + +// Fetch AC data directly from the Secret +acData, err := keystonev1.GetApplicationCredentialFromSecret( + ctx, client, namespace, secretName) +if err != nil { + // Handle error +} +if acData != nil { + // Use acData.ID and acData.Secret +} +``` + +## Validation Rules + +The API includes validation constraints: +- `gracePeriodDays` must be smaller than `expirationDays` +- `expirationDays` minimum value: 2 +- `gracePeriodDays` minimum value: 1 +- `roles` must contain at least 1 role diff --git a/go.mod b/go.mod index eefd7b0c..9caf228f 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.4 require ( github.com/go-logr/logr v1.4.3 github.com/google/uuid v1.6.0 + github.com/gophercloud/gophercloud/v2 v2.8.0 github.com/k8snetworkplumbingwg/network-attachment-definition-client v1.7.7 github.com/onsi/ginkgo/v2 v2.27.1 github.com/onsi/gomega v1.38.2 @@ -45,7 +46,6 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/gofuzz v1.2.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect - github.com/gophercloud/gophercloud/v2 v2.8.0 // indirect github.com/imdario/mergo v0.3.16 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect diff --git a/main.go b/main.go index 52e81cf0..e365979d 100644 --- a/main.go +++ b/main.go @@ -164,6 +164,16 @@ func main() { os.Exit(1) } + if err = (&controllers.ApplicationCredentialReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Kclient: kclient, + EventRecorder: mgr.GetEventRecorderFor("keystoneapplicationcredential-controller"), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "ApplicationCredential") + os.Exit(1) + } + // Acquire environmental defaults and initialize operator defaults with them keystonev1.SetupDefaults() diff --git a/tests/functional/base_test.go b/tests/functional/base_test.go index c09684e2..9d1a2ca0 100644 --- a/tests/functional/base_test.go +++ b/tests/functional/base_test.go @@ -196,3 +196,30 @@ func GetExtraMounts(kemName string, kemPath string) []map[string]any { }, } } + +// ApplicationCredential helper functions +func CreateACWithSpec(name types.NamespacedName, spec map[string]interface{}) client.Object { + raw := map[string]interface{}{ + "apiVersion": "keystone.openstack.org/v1beta1", + "kind": "KeystoneApplicationCredential", + "metadata": map[string]interface{}{ + "name": name.Name, + "namespace": name.Namespace, + }, + "spec": spec, + } + return th.CreateUnstructured(raw) +} + +func GetApplicationCredential(name types.NamespacedName) *keystonev1.KeystoneApplicationCredential { + instance := &keystonev1.KeystoneApplicationCredential{} + Eventually(func(g Gomega) { + g.Expect(k8sClient.Get(ctx, name, instance)).Should(Succeed()) + }, timeout, interval).Should(Succeed()) + return instance +} + +func ACConditionGetter(name types.NamespacedName) condition.Conditions { + instance := GetApplicationCredential(name) + return instance.Status.Conditions +} diff --git a/tests/functional/keystoneapi_controller_test.go b/tests/functional/keystoneapi_controller_test.go index 944dad7b..4f49600b 100644 --- a/tests/functional/keystoneapi_controller_test.go +++ b/tests/functional/keystoneapi_controller_test.go @@ -34,10 +34,13 @@ import ( memcachedv1 "github.com/openstack-k8s-operators/infra-operator/apis/memcached/v1beta1" topologyv1 "github.com/openstack-k8s-operators/infra-operator/apis/topology/v1beta1" + keystonev1 "github.com/openstack-k8s-operators/keystone-operator/api/v1beta1" condition "github.com/openstack-k8s-operators/lib-common/modules/common/condition" mariadb_test "github.com/openstack-k8s-operators/mariadb-operator/api/test/helpers" mariadbv1 "github.com/openstack-k8s-operators/mariadb-operator/api/v1beta1" corev1 "k8s.io/api/core/v1" + k8s_errors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" @@ -59,6 +62,8 @@ var _ = Describe("Keystone controller", func() { var memcachedSpec memcachedv1.MemcachedSpec var cronJobName types.NamespacedName var keystoneAPITopologies []types.NamespacedName + var acName types.NamespacedName + var serviceUserSecret types.NamespacedName BeforeEach(func() { @@ -2349,4 +2354,224 @@ OIDCRedirectURI "{{ .KeystoneEndpointPublic }}/v3/auth/OS-FEDERATION/websso/open }) }) + When("an ApplicationCredential CR is created", func() { + BeforeEach(func() { + acName = types.NamespacedName{Name: "ac-test-service", Namespace: namespace} + serviceUserSecret = types.NamespacedName{Name: "osp-secret", Namespace: namespace} + + th.CreateSecret(serviceUserSecret, + map[string][]byte{"ServicePassword": []byte("service-password")}) + + raw := map[string]interface{}{ + "apiVersion": "keystone.openstack.org/v1beta1", + "kind": "KeystoneApplicationCredential", + "metadata": map[string]interface{}{ + "name": acName.Name, + "namespace": acName.Namespace, + }, + "spec": map[string]interface{}{ + "userName": "test-service", + "secret": serviceUserSecret.Name, + "passwordSelector": "ServicePassword", + "expirationDays": 14, + "gracePeriodDays": 7, + "roles": []string{"admin", "service"}, + "unrestricted": false, + }, + } + th.CreateUnstructured(raw) + }) + + It("should be recognized by the controller, add a finalizer and initialize Conditions", func() { + Eventually(func(g Gomega) { + ac := &keystonev1.KeystoneApplicationCredential{} + g.Expect(k8sClient.Get(ctx, acName, ac)).To(Succeed()) + + g.Expect(ac.Finalizers).To(ContainElement("openstack.org/applicationcredential")) + + g.Expect(ac.Status.Conditions).NotTo(BeNil()) + found := false + for _, c := range ac.Status.Conditions { + if c.Type == keystonev1.KeystoneAPIReadyCondition { + found = true + break + } + } + g.Expect(found).To(BeTrue()) + }, timeout, interval).Should(Succeed()) + }) + }) + + When("a multiple ApplicationCredential CRs are created", func() { + It("should initialize properly with various configurations and handle missing KeystoneAPI", func() { + serviceUserSecret := types.NamespacedName{Name: "osp-secret", Namespace: namespace} + th.CreateSecret(serviceUserSecret, map[string][]byte{ + "ServicePassword": []byte("service-password"), + }) + + basicACName := types.NamespacedName{Name: "ac-basic-config", Namespace: namespace} + CreateACWithSpec(basicACName, map[string]interface{}{ + "userName": "test-service", + "secret": serviceUserSecret.Name, + "passwordSelector": "ServicePassword", + "expirationDays": 30, + "gracePeriodDays": 7, + "roles": []string{"service"}, + "unrestricted": false, + }) + + rulesACName := types.NamespacedName{Name: "ac-with-rules", Namespace: namespace} + CreateACWithSpec(rulesACName, map[string]interface{}{ + "userName": "test-service", + "secret": serviceUserSecret.Name, + "passwordSelector": "ServicePassword", + "expirationDays": 60, + "gracePeriodDays": 14, + "roles": []string{"admin", "service"}, + "unrestricted": false, + "accessRules": []map[string]interface{}{ + { + "service": "compute", + "path": "/servers", + "method": "GET", + }, + { + "service": "image", + "path": "/images", + "method": "GET", + }, + }, + }) + + unrestrictedACName := types.NamespacedName{Name: "ac-unrestricted", Namespace: namespace} + CreateACWithSpec(unrestrictedACName, map[string]interface{}{ + "userName": "test-service", + "secret": serviceUserSecret.Name, + "passwordSelector": "ServicePassword", + "expirationDays": 90, + "gracePeriodDays": 30, + "roles": []string{"admin"}, + "unrestricted": true, + }) + + Eventually(func(g Gomega) { + basicAC := GetApplicationCredential(basicACName) + g.Expect(basicAC.Finalizers).To(ContainElement("openstack.org/applicationcredential")) + g.Expect(basicAC.Status.Conditions).NotTo(BeNil()) + g.Expect(basicAC.Spec.ExpirationDays).To(Equal(30)) + g.Expect(basicAC.Spec.GracePeriodDays).To(Equal(7)) + g.Expect(basicAC.Spec.Roles).To(Equal([]string{"service"})) + g.Expect(basicAC.Spec.Unrestricted).To(BeFalse()) + + rulesAC := GetApplicationCredential(rulesACName) + g.Expect(rulesAC.Finalizers).To(ContainElement("openstack.org/applicationcredential")) + g.Expect(rulesAC.Status.Conditions).NotTo(BeNil()) + g.Expect(rulesAC.Spec.AccessRules).To(HaveLen(2)) + g.Expect(rulesAC.Spec.AccessRules[0].Service).To(Equal("compute")) + g.Expect(rulesAC.Spec.AccessRules[1].Service).To(Equal("image")) + g.Expect(rulesAC.Spec.Roles).To(ContainElements("admin", "service")) + + unrestrictedAC := GetApplicationCredential(unrestrictedACName) + g.Expect(unrestrictedAC.Finalizers).To(ContainElement("openstack.org/applicationcredential")) + g.Expect(unrestrictedAC.Status.Conditions).NotTo(BeNil()) + g.Expect(unrestrictedAC.Spec.Unrestricted).To(BeTrue()) + g.Expect(unrestrictedAC.Spec.ExpirationDays).To(Equal(90)) + g.Expect(unrestrictedAC.Spec.GracePeriodDays).To(Equal(30)) + + for _, acName := range []types.NamespacedName{basicACName, rulesACName, unrestrictedACName} { + ac := GetApplicationCredential(acName) + keystoneCondition := ac.Status.Conditions.Get(keystonev1.KeystoneAPIReadyCondition) + g.Expect(keystoneCondition).NotTo(BeNil()) + g.Expect(keystoneCondition.Status).NotTo(Equal(corev1.ConditionTrue), "Should wait for KeystoneAPI") + } + }, timeout, interval).Should(Succeed()) + }) + + It("should handle deletion with finalizer cleanup", func() { + serviceUserSecret := types.NamespacedName{Name: "osp-secret", Namespace: namespace} + th.CreateSecret(serviceUserSecret, map[string][]byte{ + "ServicePassword": []byte("service-password"), + }) + + deleteACName := types.NamespacedName{Name: "ac-delete-test", Namespace: namespace} + CreateACWithSpec(deleteACName, map[string]interface{}{ + "userName": "test-service", + "secret": serviceUserSecret.Name, + "passwordSelector": "ServicePassword", + "expirationDays": 30, + "gracePeriodDays": 7, + "roles": []string{"service"}, + }) + + Eventually(func(g Gomega) { + ac := GetApplicationCredential(deleteACName) + g.Expect(ac.Finalizers).To(ContainElement("openstack.org/applicationcredential")) + }, timeout, interval).Should(Succeed()) + + acInstance := GetApplicationCredential(deleteACName) + err := k8sClient.Delete(ctx, acInstance) + Expect(err).NotTo(HaveOccurred()) + + Eventually(func(g Gomega) { + ac := &keystonev1.KeystoneApplicationCredential{} + err := k8sClient.Get(ctx, deleteACName, ac) + if k8s_errors.IsNotFound(err) { + return + } + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(ac.DeletionTimestamp).NotTo(BeNil()) + }, timeout, interval).Should(Succeed()) + }) + + It("should validate expiration vs grace period constraints", func() { + serviceUserSecret := types.NamespacedName{Name: "osp-secret", Namespace: namespace} + th.CreateSecret(serviceUserSecret, map[string][]byte{ + "ServicePassword": []byte("service-password"), + }) + + validACName := types.NamespacedName{Name: "ac-valid-periods", Namespace: namespace} + CreateACWithSpec(validACName, map[string]interface{}{ + "userName": "test-service", + "secret": serviceUserSecret.Name, + "passwordSelector": "ServicePassword", + "expirationDays": 30, + "gracePeriodDays": 7, + "roles": []string{"service"}, + }) + + Eventually(func(g Gomega) { + ac := GetApplicationCredential(validACName) + g.Expect(ac.Spec.ExpirationDays).To(Equal(30)) + g.Expect(ac.Spec.GracePeriodDays).To(Equal(7)) + g.Expect(ac.Finalizers).To(ContainElement("openstack.org/applicationcredential")) + }, timeout, interval).Should(Succeed()) + + invalidACName := types.NamespacedName{Name: "ac-invalid-periods", Namespace: namespace} + invalidSpec := map[string]interface{}{ + "userName": "test-service", + "secret": serviceUserSecret.Name, + "passwordSelector": "ServicePassword", + "expirationDays": 7, + "gracePeriodDays": 10, + "roles": []string{"service"}, + } + + raw := map[string]interface{}{ + "apiVersion": "keystone.openstack.org/v1beta1", + "kind": "KeystoneApplicationCredential", + "metadata": map[string]interface{}{ + "name": invalidACName.Name, + "namespace": invalidACName.Namespace, + }, + "spec": invalidSpec, + } + + obj := &unstructured.Unstructured{} + obj.SetUnstructuredContent(raw) + err := k8sClient.Create(ctx, obj) + Expect(err).To(HaveOccurred(), "Expected creation to fail due to validation constraint") + Expect(err.Error()).To(ContainSubstring("gracePeriodDays must be smaller than expirationDays")) + }) + }) + }) diff --git a/tests/functional/suite_test.go b/tests/functional/suite_test.go index 722e1e5b..70052698 100644 --- a/tests/functional/suite_test.go +++ b/tests/functional/suite_test.go @@ -179,6 +179,14 @@ var _ = BeforeSuite(func() { }).SetupWithManager(context.Background(), k8sManager) Expect(err).ToNot(HaveOccurred()) + err = (&controllers.ApplicationCredentialReconciler{ + Client: k8sManager.GetClient(), + Scheme: k8sManager.GetScheme(), + Kclient: kclient, + Log: logger.WithName("ApplicationCredentialReconciler"), + }).SetupWithManager(k8sManager) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = k8sManager.Start(ctx)