diff --git a/api/v1alpha1/user_types.go b/api/v1alpha1/user_types.go index ddccb96e2..38acfc92d 100644 --- a/api/v1alpha1/user_types.go +++ b/api/v1alpha1/user_types.go @@ -46,7 +46,6 @@ type UserResourceSpec struct { // passwordRef is a reference to a Secret containing the password // for this user. The Secret must contain a key named "password". // +required - // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="passwordRef is immutable" PasswordRef KubernetesNameRef `json:"passwordRef,omitempty"` } @@ -92,4 +91,10 @@ type UserResourceStatus struct { // +kubebuilder:validation:MaxLength:=1024 // +optional PasswordExpiresAt string `json:"passwordExpiresAt,omitempty"` + + // appliedPasswordRef is the name of the Secret containing the + // password that was last applied to the OpenStack resource. + // +kubebuilder:validation:MaxLength=1024 + // +optional + AppliedPasswordRef string `json:"appliedPasswordRef,omitempty"` } diff --git a/cmd/models-schema/zz_generated.openapi.go b/cmd/models-schema/zz_generated.openapi.go index e13e9899b..6190bb940 100644 --- a/cmd/models-schema/zz_generated.openapi.go +++ b/cmd/models-schema/zz_generated.openapi.go @@ -11456,6 +11456,13 @@ func schema_openstack_resource_controller_v2_api_v1alpha1_UserResourceStatus(ref Format: "", }, }, + "appliedPasswordRef": { + SchemaProps: spec.SchemaProps{ + Description: "appliedPasswordRef is the name of the Secret containing the password that was last applied to the OpenStack resource.", + Type: []string{"string"}, + Format: "", + }, + }, }, }, }, diff --git a/config/crd/bases/openstack.k-orc.cloud_users.yaml b/config/crd/bases/openstack.k-orc.cloud_users.yaml index 201bb5eba..90bc76760 100644 --- a/config/crd/bases/openstack.k-orc.cloud_users.yaml +++ b/config/crd/bases/openstack.k-orc.cloud_users.yaml @@ -193,9 +193,6 @@ spec: maxLength: 253 minLength: 1 type: string - x-kubernetes-validations: - - message: passwordRef is immutable - rule: self == oldSelf required: - passwordRef type: object @@ -302,6 +299,12 @@ spec: description: resource contains the observed state of the OpenStack resource. properties: + appliedPasswordRef: + description: |- + appliedPasswordRef is the name of the Secret containing the + password that was last applied to the OpenStack resource. + maxLength: 1024 + type: string defaultProjectID: description: defaultProjectID is the ID of the Default Project to which the user is associated with. diff --git a/internal/controllers/user/actuator.go b/internal/controllers/user/actuator.go index 4b4683fe5..b556a6f80 100644 --- a/internal/controllers/user/actuator.go +++ b/internal/controllers/user/actuator.go @@ -22,6 +22,7 @@ import ( "github.com/gophercloud/gophercloud/v2/openstack/identity/v3/users" corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -31,8 +32,10 @@ import ( "github.com/k-orc/openstack-resource-controller/v2/internal/controllers/generic/progress" "github.com/k-orc/openstack-resource-controller/v2/internal/logging" "github.com/k-orc/openstack-resource-controller/v2/internal/osclients" + "github.com/k-orc/openstack-resource-controller/v2/internal/util/applyconfigs" "github.com/k-orc/openstack-resource-controller/v2/internal/util/dependency" orcerrors "github.com/k-orc/openstack-resource-controller/v2/internal/util/errors" + orcapplyconfigv1alpha1 "github.com/k-orc/openstack-resource-controller/v2/pkg/clients/applyconfiguration/api/v1alpha1" ) // OpenStack resource types @@ -183,6 +186,75 @@ func (actuator userActuator) DeleteResource(ctx context.Context, _ orcObjectPT, return progress.WrapError(actuator.osClient.DeleteUser(ctx, resource.ID)) } +func (actuator userActuator) reconcilePassword(ctx context.Context, obj orcObjectPT, osResource *osResourceT) progress.ReconcileStatus { + log := ctrl.LoggerFrom(ctx) + resource := obj.Spec.Resource + if resource == nil { + return nil + } + + currentRef := string(resource.PasswordRef) + var lastAppliedRef string + if obj.Status.Resource != nil { + lastAppliedRef = obj.Status.Resource.AppliedPasswordRef + } + + if lastAppliedRef == currentRef { + return nil + } + + // Read the password from the referenced Secret + secret, secretRS := dependency.FetchDependency( + ctx, actuator.k8sClient, obj.Namespace, + &resource.PasswordRef, "Secret", + func(*corev1.Secret) bool { return true }, + ) + if secretRS != nil { + return secretRS + } + + passwordBytes, ok := secret.Data["password"] + if !ok { + return progress.NewReconcileStatus().WithProgressMessage("Password secret does not contain \"password\" key") + } + password := string(passwordBytes) + + // Only call UpdateUser if this is not the first reconcile after creation. + // CreateResource already set the initial password. + if lastAppliedRef != "" { + log.V(logging.Info).Info("Updating password") + _, err := actuator.osClient.UpdateUser(ctx, osResource.ID, users.UpdateOpts{ + Password: password, + }) + + if orcerrors.IsConflict(err) { + err = orcerrors.Terminal(orcv1alpha1.ConditionReasonInvalidConfiguration, "invalid configuration updating resource: "+err.Error(), err) + } + if err != nil { + return progress.WrapError(err) + } + } + + // Update the lastAppliedPasswordRef status field via a MergePatch. + // MergePatch sets only the specified fields without claiming SSA + // ownership, so the main SSA status update won't remove this field. + statusApply := orcapplyconfigv1alpha1.UserResourceStatus(). + WithAppliedPasswordRef(currentRef) + applyConfig := orcapplyconfigv1alpha1.User(obj.Name, obj.Namespace). + WithUID(obj.UID). + WithStatus(orcapplyconfigv1alpha1.UserStatus(). + WithResource(statusApply)) + if err := actuator.k8sClient.Status().Patch(ctx, obj, + applyconfigs.Patch(types.MergePatchType, applyConfig)); err != nil { + return progress.WrapError(err) + } + + if lastAppliedRef != "" { + return progress.NeedsRefresh() + } + return nil +} + func (actuator userActuator) updateResource(ctx context.Context, obj orcObjectPT, osResource *osResourceT) progress.ReconcileStatus { log := ctrl.LoggerFrom(ctx) resource := obj.Spec.Resource @@ -259,6 +331,7 @@ func handleEnabledUpdate(updateOpts *users.UpdateOpts, resource *resourceSpecT, func (actuator userActuator) GetResourceReconcilers(ctx context.Context, orcObject orcObjectPT, osResource *osResourceT, controller interfaces.ResourceController) ([]resourceReconciler, progress.ReconcileStatus) { return []resourceReconciler{ + actuator.reconcilePassword, actuator.updateResource, }, nil } diff --git a/internal/controllers/user/tests/user-update/00-assert.yaml b/internal/controllers/user/tests/user-update/00-assert.yaml index c7a2749fc..e30fd7137 100644 --- a/internal/controllers/user/tests/user-update/00-assert.yaml +++ b/internal/controllers/user/tests/user-update/00-assert.yaml @@ -12,6 +12,7 @@ assertAll: - celExpr: "!has(user.status.resource.defaultProjectID)" # passwordExpiresAt depends on the Keystone security_compliance # configuration and is not asserted here. + - celExpr: "user.status.resource.appliedPasswordRef == 'user-update'" --- apiVersion: openstack.k-orc.cloud/v1alpha1 kind: User diff --git a/internal/controllers/user/tests/user-update/01-assert.yaml b/internal/controllers/user/tests/user-update/01-assert.yaml index 0a9fa6937..cf594b6ee 100644 --- a/internal/controllers/user/tests/user-update/01-assert.yaml +++ b/internal/controllers/user/tests/user-update/01-assert.yaml @@ -8,6 +8,7 @@ status: name: user-update-updated description: user-update-updated enabled: false + appliedPasswordRef: user-update-password-updated conditions: - type: Available status: "True" diff --git a/internal/controllers/user/tests/user-update/01-updated-resource.yaml b/internal/controllers/user/tests/user-update/01-updated-resource.yaml index dea4f5476..dd7727629 100644 --- a/internal/controllers/user/tests/user-update/01-updated-resource.yaml +++ b/internal/controllers/user/tests/user-update/01-updated-resource.yaml @@ -1,4 +1,12 @@ --- +apiVersion: v1 +kind: Secret +metadata: + name: user-update-password-updated +type: Opaque +stringData: + password: "TestPasswordUpdated" +--- apiVersion: openstack.k-orc.cloud/v1alpha1 kind: User metadata: @@ -8,3 +16,4 @@ spec: name: user-update-updated description: user-update-updated enabled: false + passwordRef: user-update-password-updated diff --git a/internal/controllers/user/tests/user-update/02-assert.yaml b/internal/controllers/user/tests/user-update/02-assert.yaml index c2c14d837..7682f1636 100644 --- a/internal/controllers/user/tests/user-update/02-assert.yaml +++ b/internal/controllers/user/tests/user-update/02-assert.yaml @@ -10,6 +10,7 @@ assertAll: - celExpr: "!has(user.status.resource.description)" # passwordExpiresAt depends on the Keystone security_compliance # configuration and is not asserted here. + - celExpr: "user.status.resource.appliedPasswordRef == 'user-update'" --- apiVersion: openstack.k-orc.cloud/v1alpha1 kind: User diff --git a/internal/controllers/user/tests/user-update/README.md b/internal/controllers/user/tests/user-update/README.md index 13da45548..160b0122d 100644 --- a/internal/controllers/user/tests/user-update/README.md +++ b/internal/controllers/user/tests/user-update/README.md @@ -6,7 +6,7 @@ Create a User using only mandatory fields. ## Step 01 -Update all mutable fields. +Update all mutable fields, including passwordRef (pointing to a new Secret). ## Step 02 diff --git a/pkg/clients/applyconfiguration/api/v1alpha1/userresourcestatus.go b/pkg/clients/applyconfiguration/api/v1alpha1/userresourcestatus.go index c23b0b6cf..db56adfbf 100644 --- a/pkg/clients/applyconfiguration/api/v1alpha1/userresourcestatus.go +++ b/pkg/clients/applyconfiguration/api/v1alpha1/userresourcestatus.go @@ -21,12 +21,13 @@ package v1alpha1 // UserResourceStatusApplyConfiguration represents a declarative configuration of the UserResourceStatus type for use // with apply. type UserResourceStatusApplyConfiguration struct { - Name *string `json:"name,omitempty"` - Description *string `json:"description,omitempty"` - DomainID *string `json:"domainID,omitempty"` - DefaultProjectID *string `json:"defaultProjectID,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - PasswordExpiresAt *string `json:"passwordExpiresAt,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + DomainID *string `json:"domainID,omitempty"` + DefaultProjectID *string `json:"defaultProjectID,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + PasswordExpiresAt *string `json:"passwordExpiresAt,omitempty"` + AppliedPasswordRef *string `json:"appliedPasswordRef,omitempty"` } // UserResourceStatusApplyConfiguration constructs a declarative configuration of the UserResourceStatus type for use with @@ -82,3 +83,11 @@ func (b *UserResourceStatusApplyConfiguration) WithPasswordExpiresAt(value strin b.PasswordExpiresAt = &value return b } + +// WithAppliedPasswordRef sets the AppliedPasswordRef 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 AppliedPasswordRef field is set to the value of the last call. +func (b *UserResourceStatusApplyConfiguration) WithAppliedPasswordRef(value string) *UserResourceStatusApplyConfiguration { + b.AppliedPasswordRef = &value + return b +} diff --git a/pkg/clients/applyconfiguration/internal/internal.go b/pkg/clients/applyconfiguration/internal/internal.go index c641e66f4..84688dc8a 100644 --- a/pkg/clients/applyconfiguration/internal/internal.go +++ b/pkg/clients/applyconfiguration/internal/internal.go @@ -3415,6 +3415,9 @@ var schemaYAML = typed.YAMLObject(`types: - name: com.github.k-orc.openstack-resource-controller.v2.api.v1alpha1.UserResourceStatus map: fields: + - name: appliedPasswordRef + type: + scalar: string - name: defaultProjectID type: scalar: string diff --git a/test/apivalidations/user_test.go b/test/apivalidations/user_test.go index 15057eb85..f38671d01 100644 --- a/test/apivalidations/user_test.go +++ b/test/apivalidations/user_test.go @@ -120,7 +120,7 @@ var _ = Describe("ORC User API validations", func() { Expect(applyObj(ctx, user, patch)).To(MatchError(ContainSubstring("defaultProjectRef is immutable"))) }) - It("should have immutable passwordRef", func(ctx context.Context) { + It("should have mutable passwordRef", func(ctx context.Context) { user := userStub(namespace) patch := baseUserPatch(user) patch.Spec.WithResource(applyconfigv1alpha1.UserResourceSpec(). @@ -129,6 +129,6 @@ var _ = Describe("ORC User API validations", func() { patch.Spec.WithResource(applyconfigv1alpha1.UserResourceSpec(). WithPasswordRef("password-b")) - Expect(applyObj(ctx, user, patch)).To(MatchError(ContainSubstring("passwordRef is immutable"))) + Expect(applyObj(ctx, user, patch)).To(Succeed()) }) }) diff --git a/website/docs/crd-reference.md b/website/docs/crd-reference.md index 07dbfbe88..9d15a005a 100644 --- a/website/docs/crd-reference.md +++ b/website/docs/crd-reference.md @@ -4458,6 +4458,7 @@ _Appears in:_ | `defaultProjectID` _string_ | defaultProjectID is the ID of the Default Project to which the user is associated with. | | MaxLength: 1024
Optional: \{\}
| | `enabled` _boolean_ | enabled defines whether a user is enabled or disabled | | Optional: \{\}
| | `passwordExpiresAt` _string_ | passwordExpiresAt is the timestamp at which the user's password expires. | | MaxLength: 1024
Optional: \{\}
| +| `appliedPasswordRef` _string_ | appliedPasswordRef is the name of the Secret containing the
password that was last applied to the OpenStack resource. | | MaxLength: 1024
Optional: \{\}
| #### UserSpec