From f7d4f0b8945e765e53bd1f2c9d281680692875cb Mon Sep 17 00:00:00 2001 From: Yun Pan Date: Fri, 16 Oct 2026 07:00:00 +0000 Subject: [PATCH 1/6] feat: add database password change command --- docs/user_docs/cli/cli.md | 1 + docs/user_docs/cli/kbcli_cluster.md | 1 + .../cli/kbcli_cluster_change-password.md | 68 ++ pkg/cmd/accounts/change_password.go | 606 ++++++++++++++++++ pkg/cmd/accounts/change_password_mongodb.go | 49 ++ .../accounts/change_password_postgresql.go | 53 ++ pkg/cmd/accounts/change_password_test.go | 345 ++++++++++ pkg/cmd/cluster/accounts.go | 28 +- pkg/cmd/cluster/cluster.go | 1 + 9 files changed, 1151 insertions(+), 1 deletion(-) create mode 100644 docs/user_docs/cli/kbcli_cluster_change-password.md create mode 100644 pkg/cmd/accounts/change_password.go create mode 100644 pkg/cmd/accounts/change_password_mongodb.go create mode 100644 pkg/cmd/accounts/change_password_postgresql.go create mode 100644 pkg/cmd/accounts/change_password_test.go diff --git a/docs/user_docs/cli/cli.md b/docs/user_docs/cli/cli.md index 31a176dd9..bb57222f4 100644 --- a/docs/user_docs/cli/cli.md +++ b/docs/user_docs/cli/cli.md @@ -52,6 +52,7 @@ Cluster command. * [kbcli cluster backup](kbcli_cluster_backup.md) - Create a backup for the cluster. * [kbcli cluster cancel-ops](kbcli_cluster_cancel-ops.md) - Cancel the pending/creating/running OpsRequest which type is vscale or hscale. +* [kbcli cluster change-password](kbcli_cluster_change-password.md) - Change a database account password * [kbcli cluster configure](kbcli_cluster_configure.md) - Configure parameters with the specified components in the cluster. * [kbcli cluster connect](kbcli_cluster_connect.md) - Connect to a cluster or instance. * [kbcli cluster create](kbcli_cluster_create.md) - Create a cluster. diff --git a/docs/user_docs/cli/kbcli_cluster.md b/docs/user_docs/cli/kbcli_cluster.md index 6e93b10d6..5939bd8fb 100644 --- a/docs/user_docs/cli/kbcli_cluster.md +++ b/docs/user_docs/cli/kbcli_cluster.md @@ -39,6 +39,7 @@ Cluster command. * [kbcli cluster backup](kbcli_cluster_backup.md) - Create a backup for the cluster. * [kbcli cluster cancel-ops](kbcli_cluster_cancel-ops.md) - Cancel the pending/creating/running OpsRequest which type is vscale or hscale. +* [kbcli cluster change-password](kbcli_cluster_change-password.md) - Change a database account password * [kbcli cluster configure](kbcli_cluster_configure.md) - Configure parameters with the specified components in the cluster. * [kbcli cluster connect](kbcli_cluster_connect.md) - Connect to a cluster or instance. * [kbcli cluster create](kbcli_cluster_create.md) - Create a cluster. diff --git a/docs/user_docs/cli/kbcli_cluster_change-password.md b/docs/user_docs/cli/kbcli_cluster_change-password.md new file mode 100644 index 000000000..b11c2acf1 --- /dev/null +++ b/docs/user_docs/cli/kbcli_cluster_change-password.md @@ -0,0 +1,68 @@ +--- +title: kbcli cluster change-password +--- + +Change a database account password + +When automatically discovered account Secrets are immutable, kbcli deletes them after updating mutable credential Secrets so KubeBlocks can recreate them with the new password. + +``` +kbcli cluster change-password [flags] +``` + +### Examples + +``` + # change the default PostgreSQL account password and update its Secret + kbcli cluster change-password CLUSTERNAME --password PASSWD + # change the default MongoDB root password and update its Secret + kbcli cluster change-password CLUSTERNAME --password PASSWD + # change an application role password with explicit Secrets + kbcli cluster change-password CLUSTERNAME --component COMPNAME --name USERNAME --password PASSWD --secret TARGET_SECRET --auth-secret ADMIN_SECRET + # change a password on a specified instance + kbcli cluster change-password --instance INSTANCE --name USERNAME --password PASSWD +``` + +### Options + +``` + --auth-secret string Secret used for database login; defaults to the target Secret. + --component string Specify the name of component to be connected. If not specified, pick the first one. + --container string Container used for the database command; defaults to the Pod's default container. + --database string Database used for authentication and the password change. Defaults to postgres for PostgreSQL and admin for MongoDB. + -h, --help help for change-password + -i, --instance string Specify the name of instance to be connected. + --name string Specify the database account whose password should be changed. Defaults to postgres for PostgreSQL and root for MongoDB. + -p, --password string Required. Specify the new password. + --secret string Secret to update; when omitted, all related account Secrets are detected and updated. +``` + +### Options inherited from parent commands + +``` + --as string Username to impersonate for the operation. User could be a regular user or a service account in a namespace. + --as-group stringArray Group to impersonate for the operation, this flag can be repeated to specify multiple groups. + --as-uid string UID to impersonate for the operation. + --cache-dir string Default cache directory (default "$HOME/.kube/cache") + --certificate-authority string Path to a cert file for the certificate authority + --client-certificate string Path to a client certificate file for TLS + --client-key string Path to a client key file for TLS + --cluster string The name of the kubeconfig cluster to use + --context string The name of the kubeconfig context to use + --disable-compression If true, opt-out of response compression for all requests to the server + --insecure-skip-tls-verify If true, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure + --kubeconfig string Path to the kubeconfig file to use for CLI requests. + --match-server-version Require server version to match client version + -n, --namespace string If present, the namespace scope for this CLI request + --request-timeout string The length of time to wait before giving up on a single server request. Non-zero values should contain a corresponding time unit (e.g. 1s, 2m, 3h). A value of zero means don't timeout requests. (default "0") + -s, --server string The address and port of the Kubernetes API server + --tls-server-name string Server name to use for server certificate validation. If it is not provided, the hostname used to contact the server is used + --token string Bearer token for authentication to the API server + --user string The name of the kubeconfig user to use +``` + +### SEE ALSO + +* [kbcli cluster](kbcli_cluster.md) - Cluster command. + +#### Go Back to [CLI Overview](cli.md) Homepage. diff --git a/pkg/cmd/accounts/change_password.go b/pkg/cmd/accounts/change_password.go new file mode 100644 index 000000000..89f923d0b --- /dev/null +++ b/pkg/cmd/accounts/change_password.go @@ -0,0 +1,606 @@ +/* +Copyright (C) 2022-2024 ApeCloud Co., Ltd + +This file is part of KubeBlocks project + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package accounts + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "sort" + "strings" + + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + k8stypes "k8s.io/apimachinery/pkg/types" + "k8s.io/cli-runtime/pkg/genericiooptions" + "k8s.io/client-go/kubernetes" + k8sscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/kubectl/pkg/cmd/exec" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/cmd/util/podcmd" + + "github.com/apecloud/kubeblocks/pkg/constant" + + "github.com/apecloud/kbcli/pkg/printer" +) + +const ( + defaultPasswordUser = "postgres" + defaultPasswordDatabase = "postgres" + defaultMongoPasswordUser = "root" + defaultMongoPasswordDatabase = "admin" +) + +var errMissingPassword = errors.New("please specify password") + +type ChangePasswordOptions struct { + *AccountBaseOptions + UserName string + Password string + DatabaseName string + SecretName string + AuthSecretName string + + targetSecret *corev1.Secret + targetSecrets []*corev1.Secret + authSecret *corev1.Secret + oldPassword string + oldPasswords map[string]string + authUser string + authPassword string + + kubeClient kubernetes.Interface + passwordChanger passwordChanger +} + +func NewChangePasswordOptions(f cmdutil.Factory, streams genericiooptions.IOStreams) *ChangePasswordOptions { + return &ChangePasswordOptions{ + AccountBaseOptions: NewAccountBaseOptions(f, streams), + } +} + +func (o *ChangePasswordOptions) AddFlags(cmd *cobra.Command) { + o.AccountBaseOptions.AddFlags(cmd) + cmd.Flags().StringVar(&o.UserName, "name", "", "Specify the database account whose password should be changed. Defaults to postgres for PostgreSQL and root for MongoDB.") + cmd.Flags().StringVarP(&o.Password, "password", "p", "", "Required. Specify the new password.") + cmd.Flags().StringVar(&o.DatabaseName, "database", "", "Database used for authentication and the password change. Defaults to postgres for PostgreSQL and admin for MongoDB.") + cmd.Flags().StringVar(&o.SecretName, "secret", "", "Secret to update; when omitted, all related account Secrets are detected and updated.") + cmd.Flags().StringVar(&o.AuthSecretName, "auth-secret", "", "Secret used for database login; defaults to the target Secret.") + cmd.Flags().StringVar(&o.ContainerName, "container", "", "Container used for the database command; defaults to the Pod's default container.") + _ = cmd.MarkFlagRequired("password") +} + +func (o *ChangePasswordOptions) Validate() error { + if o.Password == "" { + return errMissingPassword + } + if bytes.IndexByte([]byte(o.Password), 0) >= 0 { + return errors.New("password cannot contain a NUL byte") + } + if bytes.IndexByte([]byte(o.UserName), 0) >= 0 { + return errors.New("name cannot contain a NUL byte") + } + if bytes.IndexByte([]byte(o.DatabaseName), 0) >= 0 { + return errors.New("database cannot contain a NUL byte") + } + return nil +} + +func (o *ChangePasswordOptions) Complete() error { + explicitInstance := o.PodName != "" + if err := o.AccountBaseOptions.Complete(); err != nil { + return err + } + o.setDatabaseDefaults() + + ctx := context.Background() + if !explicitInstance { + pod, err := findPasswordPod(ctx, o.kube(), o.Namespace, o.ClusterName, o.ComponentName) + if err != nil { + return err + } + o.Pod = pod + o.PodName = pod.Name + o.ExecOptions.Pod = pod + } + + if err := o.completeSecrets(ctx); err != nil { + return err + } + if o.ContainerName == "" { + container, err := podcmd.FindOrDefaultContainerByName(o.Pod, "", true, o.ErrOut) + if err != nil { + return err + } + o.ContainerName = container.Name + } + return nil +} + +func (o *ChangePasswordOptions) completeSecrets(ctx context.Context) error { + targetSecrets, err := findTargetPasswordSecrets(ctx, o.kube(), o.Namespace, o.ClusterName, o.ComponentName, o.UserName, o.SecretName) + if err != nil { + return err + } + o.targetSecrets = targetSecrets + o.targetSecret = targetSecrets[0] + + authSecretName := o.AuthSecretName + if authSecretName == "" { + authSecretName = o.targetSecret.Name + } + authSecret, err := o.kube().CoreV1().Secrets(o.Namespace).Get(ctx, authSecretName, metav1.GetOptions{}) + if err != nil { + return fmt.Errorf("get auth Secret %s/%s: %w", o.Namespace, authSecretName, err) + } + o.authSecret = authSecret + + o.oldPasswords = make(map[string]string, len(targetSecrets)) + for _, targetSecret := range targetSecrets { + targetUser, err := secretValue(targetSecret, constant.AccountNameForSecret) + if err != nil { + return fmt.Errorf("read target Secret %s/%s: %w", o.Namespace, targetSecret.Name, err) + } + if !strings.EqualFold(string(targetUser), o.UserName) { + return fmt.Errorf("Secret %s/%s belongs to user %q, not %q", o.Namespace, targetSecret.Name, targetUser, o.UserName) + } + + oldPassword, err := secretValue(targetSecret, constant.AccountPasswdForSecret) + if err != nil { + return fmt.Errorf("read target Secret %s/%s: %w", o.Namespace, targetSecret.Name, err) + } + o.oldPasswords[targetSecret.Name] = string(oldPassword) + if targetSecret.Name == o.targetSecret.Name { + o.oldPassword = string(oldPassword) + } + } + + authUser, err := secretValue(authSecret, constant.AccountNameForSecret) + if err != nil { + return fmt.Errorf("read auth Secret %s/%s: %w", o.Namespace, authSecret.Name, err) + } + authPassword, err := secretValue(authSecret, constant.AccountPasswdForSecret) + if err != nil { + return fmt.Errorf("read auth Secret %s/%s: %w", o.Namespace, authSecret.Name, err) + } + o.authUser = string(authUser) + o.authPassword = string(authPassword) + return nil +} + +func (o *ChangePasswordOptions) Run() error { + if o.passwordChanger == nil { + var err error + o.passwordChanger, err = newPasswordChanger(o.CharType, passwordChangerConfig{ + Config: o.Config, + Client: o.kube(), + Executor: o.Executor, + Namespace: o.Namespace, + Database: o.databaseName(), + Container: o.ContainerName, + }) + if err != nil { + return err + } + } + + request := passwordChangeRequest{ + PodName: o.PodName, + AuthUser: o.authUser, + AuthPassword: o.authPassword, + TargetUser: o.UserName, + NewPassword: o.Password, + } + if err := o.passwordChanger.ChangePassword(request); err != nil { + o.printGeneralInfo("fail", err.Error()) + return fmt.Errorf("%s rejected the password change; the Secret was not modified: %w", databaseTypeName(o.CharType), err) + } + + targetSecrets := o.passwordTargetSecrets() + if len(targetSecrets) == 0 { + return fmt.Errorf("no target Secret is available; run completion or pass --secret explicitly") + } + mutableSecrets := make([]*corev1.Secret, 0, len(targetSecrets)) + immutableSecrets := make([]*corev1.Secret, 0, len(targetSecrets)) + for _, targetSecret := range targetSecrets { + if secretIsImmutable(targetSecret) { + immutableSecrets = append(immutableSecrets, targetSecret) + } else { + mutableSecrets = append(mutableSecrets, targetSecret) + } + } + + updatedSecrets := make([]*corev1.Secret, 0, len(targetSecrets)) + for _, targetSecret := range mutableSecrets { + if err := patchPasswordSecret(context.Background(), o.kube(), o.Namespace, targetSecret.Name, []byte(o.Password)); err != nil { + rollbackAuthPassword := o.authPassword + if strings.EqualFold(o.authUser, o.UserName) { + rollbackAuthPassword = o.Password + } + rollbackErr := o.passwordChanger.ChangePassword(passwordChangeRequest{ + PodName: o.PodName, + AuthUser: o.authUser, + AuthPassword: rollbackAuthPassword, + TargetUser: o.UserName, + NewPassword: o.oldPassword, + }) + restoreErr := o.restorePasswordSecrets(context.Background(), updatedSecrets) + if rollbackErr != nil { + return fmt.Errorf("failed to update target Secret %s/%s and failed to roll back the %s password: %w (rollback error: %v, Secret restore error: %v)", o.Namespace, targetSecret.Name, databaseTypeName(o.CharType), err, rollbackErr, restoreErr) + } + if restoreErr != nil { + return fmt.Errorf("failed to update target Secret %s/%s; the %s password was rolled back but related Secrets could not be restored: %w (Secret restore error: %v)", o.Namespace, targetSecret.Name, databaseTypeName(o.CharType), err, restoreErr) + } + return fmt.Errorf("failed to update target Secret %s/%s; the %s password was rolled back: %w", o.Namespace, targetSecret.Name, databaseTypeName(o.CharType), err) + } + updatedSecrets = append(updatedSecrets, targetSecret) + } + for _, targetSecret := range immutableSecrets { + if err := deletePasswordSecret(context.Background(), o.kube(), o.Namespace, targetSecret.Name); err != nil { + o.printGeneralInfo("fail", err.Error()) + return fmt.Errorf("password changed and mutable target Secrets were updated, but failed to delete immutable Secret %s/%s for KubeBlocks to recreate: %w", o.Namespace, targetSecret.Name, err) + } + } + + o.printGeneralInfo("success", fmt.Sprintf("password for account %q changed successfully", o.UserName)) + if len(immutableSecrets) > 0 { + printer.Alert(o.Out, "The new password is stored in mutable Secret(s); immutable Secret(s) were deleted for KubeBlocks to recreate.\n") + } else { + printer.Alert(o.Out, "The new password is stored in the target Secret(s).\n") + } + return nil +} + +func (o *ChangePasswordOptions) passwordTargetSecrets() []*corev1.Secret { + if len(o.targetSecrets) > 0 { + return o.targetSecrets + } + if o.targetSecret != nil { + return []*corev1.Secret{o.targetSecret} + } + return nil +} + +func (o *ChangePasswordOptions) restorePasswordSecrets(ctx context.Context, targetSecrets []*corev1.Secret) error { + for _, targetSecret := range targetSecrets { + oldPassword, ok := o.oldPasswords[targetSecret.Name] + if !ok && o.targetSecret != nil && targetSecret.Name == o.targetSecret.Name { + oldPassword = o.oldPassword + ok = oldPassword != "" + } + if !ok { + return fmt.Errorf("old password for Secret %s/%s is unavailable", o.Namespace, targetSecret.Name) + } + if err := patchPasswordSecret(ctx, o.kube(), o.Namespace, targetSecret.Name, []byte(oldPassword)); err != nil { + return err + } + } + return nil +} + +func secretIsImmutable(secret *corev1.Secret) bool { + return secret != nil && secret.Immutable != nil && *secret.Immutable +} + +func (o *ChangePasswordOptions) kube() kubernetes.Interface { + if o.kubeClient != nil { + return o.kubeClient + } + return o.Client +} + +func (o *ChangePasswordOptions) setDatabaseDefaults() { + if o.UserName == "" { + o.UserName = defaultPasswordUserForCharacterType(o.CharType) + } + if o.DatabaseName == "" { + o.DatabaseName = defaultPasswordDatabaseForCharacterType(o.CharType) + } +} + +func (o *ChangePasswordOptions) databaseName() string { + if o.DatabaseName != "" { + return o.DatabaseName + } + return defaultPasswordDatabaseForCharacterType(o.CharType) +} + +func (o *ChangePasswordOptions) Exec() error { + if err := o.Validate(); err != nil { + return err + } + if err := o.Complete(); err != nil { + return err + } + return o.Run() +} + +type passwordChangeRequest struct { + PodName string + AuthUser string + AuthPassword string + TargetUser string + NewPassword string +} + +type passwordChanger interface { + ChangePassword(request passwordChangeRequest) error +} + +type passwordChangerConfig struct { + Config *rest.Config + Client kubernetes.Interface + Executor exec.RemoteExecutor + Namespace string + Database string + Container string +} + +type passwordChangerFactory func(config passwordChangerConfig) passwordChanger + +var passwordChangerFactories = map[string]passwordChangerFactory{ + "postgres": func(config passwordChangerConfig) passwordChanger { + return &postgresPasswordChanger{config: config} + }, + "postgresql": func(config passwordChangerConfig) passwordChanger { + return &postgresPasswordChanger{config: config} + }, + "mongo": func(config passwordChangerConfig) passwordChanger { + return &mongoPasswordChanger{config: config} + }, + "mongodb": func(config passwordChangerConfig) passwordChanger { + return &mongoPasswordChanger{config: config} + }, +} + +func newPasswordChanger(characterType string, config passwordChangerConfig) (passwordChanger, error) { + factory, ok := passwordChangerFactories[strings.ToLower(characterType)] + if !ok { + return nil, fmt.Errorf("changing passwords for database type %q is not supported", characterType) + } + return factory(config), nil +} + +func executePasswordCommand(config passwordChangerConfig, podName string, command []string, stdin io.Reader, stdinEnabled bool, clientName string) error { + execRequest := config.Client.CoreV1().RESTClient().Post(). + Resource("pods"). + Name(podName). + Namespace(config.Namespace). + SubResource("exec"). + VersionedParams(&corev1.PodExecOptions{ + Container: config.Container, + Command: command, + Stdin: stdinEnabled, + Stdout: true, + Stderr: true, + }, k8sscheme.ParameterCodec) + + var stdout, stderr bytes.Buffer + err := config.Executor.Execute( + "POST", + execRequest.URL(), + config.Config, + stdin, + &stdout, + &stderr, + false, + nil, + ) + if err != nil { + message := strings.TrimSpace(stderr.String()) + if message != "" { + return fmt.Errorf("execute %s: %w: %s", clientName, err, message) + } + return fmt.Errorf("execute %s: %w", clientName, err) + } + return nil +} + +func findTargetPasswordSecret(ctx context.Context, client kubernetes.Interface, namespace, cluster, component, user, explicitName string) (*corev1.Secret, error) { + targetSecrets, err := findTargetPasswordSecrets(ctx, client, namespace, cluster, component, user, explicitName) + if err != nil { + return nil, err + } + return targetSecrets[0], nil +} + +func findTargetPasswordSecrets(ctx context.Context, client kubernetes.Interface, namespace, cluster, component, user, explicitName string) ([]*corev1.Secret, error) { + secrets := client.CoreV1().Secrets(namespace) + if explicitName != "" { + secret, err := secrets.Get(ctx, explicitName, metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("get target Secret %s/%s: %w", namespace, explicitName, err) + } + return []*corev1.Secret{secret}, nil + } + + candidates := make([]string, 0, 4) + addCandidate := func(name string) { + if name == "" { + return + } + for _, candidate := range candidates { + if candidate == name { + return + } + } + candidates = append(candidates, name) + } + + accountName := strings.ReplaceAll(user, "_", "-") + if strings.EqualFold(user, defaultPasswordUser) || strings.EqualFold(user, defaultMongoPasswordUser) { + addCandidate(cluster + "-conn-credential") + } + addCandidate(cluster + "-" + component + "-account-" + accountName) + + selector := labels.Set{ + constant.AppInstanceLabelKey: cluster, + constant.ClusterAccountLabelKey: user, + }.AsSelector().String() + list, err := secrets.List(ctx, metav1.ListOptions{LabelSelector: selector}) + if err != nil && !apierrors.IsForbidden(err) { + return nil, fmt.Errorf("list account Secrets: %w", err) + } + if err == nil { + sort.Slice(list.Items, func(i, j int) bool { + return list.Items[i].Name < list.Items[j].Name + }) + for i := range list.Items { + addCandidate(list.Items[i].Name) + } + } + + targetSecrets := make([]*corev1.Secret, 0, len(candidates)) + for _, candidate := range candidates { + secret, err := secrets.Get(ctx, candidate, metav1.GetOptions{}) + if err == nil { + targetSecrets = append(targetSecrets, secret) + continue + } + if !apierrors.IsNotFound(err) { + return nil, fmt.Errorf("get candidate Secret %s/%s: %w", namespace, candidate, err) + } + } + if len(targetSecrets) == 0 { + return nil, fmt.Errorf("cannot detect the target Secret; pass --secret explicitly") + } + return targetSecrets, nil +} + +func findPasswordPod(ctx context.Context, client kubernetes.Interface, namespace, cluster, component string) (*corev1.Pod, error) { + list, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("list Pods: %w", err) + } + return selectPasswordPod(list.Items, cluster, component) +} + +func selectPasswordPod(pods []corev1.Pod, cluster, component string) (*corev1.Pod, error) { + preferred := make([]corev1.Pod, 0) + fallback := make([]corev1.Pod, 0) + for i := range pods { + pod := pods[i] + if pod.Status.Phase != corev1.PodRunning || !passwordPodMatchesComponent(pod, cluster, component) { + continue + } + role := pod.Labels[constant.RoleLabelKey] + if role == "primary" || role == "leader" { + preferred = append(preferred, pod) + } else { + fallback = append(fallback, pod) + } + } + + sort.Slice(preferred, func(i, j int) bool { return preferred[i].Name < preferred[j].Name }) + sort.Slice(fallback, func(i, j int) bool { return fallback[i].Name < fallback[j].Name }) + if len(preferred) == 1 { + return &preferred[0], nil + } + if len(preferred) > 1 { + return nil, fmt.Errorf("multiple primary/leader Pods found: %s", passwordPodNames(preferred)) + } + if len(fallback) == 1 { + return &fallback[0], nil + } + if len(fallback) > 1 { + return nil, fmt.Errorf("cannot identify a unique primary Pod; pass --instance explicitly (candidates: %s)", passwordPodNames(fallback)) + } + return nil, fmt.Errorf("no Running database Pod found for cluster %q and component %q", cluster, component) +} + +func defaultPasswordUserForCharacterType(characterType string) string { + if strings.EqualFold(characterType, "mongo") || strings.EqualFold(characterType, "mongodb") { + return defaultMongoPasswordUser + } + return defaultPasswordUser +} + +func defaultPasswordDatabaseForCharacterType(characterType string) string { + if strings.EqualFold(characterType, "mongo") || strings.EqualFold(characterType, "mongodb") { + return defaultMongoPasswordDatabase + } + return defaultPasswordDatabase +} + +func databaseTypeName(characterType string) string { + if characterType == "" { + return "database" + } + return characterType +} + +func passwordPodMatchesComponent(pod corev1.Pod, cluster, component string) bool { + if clusterLabel := pod.Labels[constant.AppInstanceLabelKey]; clusterLabel != "" && clusterLabel != cluster { + return false + } + if !strings.HasPrefix(pod.Name, cluster+"-") { + return false + } + if pod.Labels[constant.KBAppComponentLabelKey] == component || pod.Labels[constant.AppComponentLabelKey] == component { + return true + } + return strings.Contains(pod.Name, "-"+component+"-") +} + +func passwordPodNames(pods []corev1.Pod) string { + names := make([]string, 0, len(pods)) + for _, pod := range pods { + names = append(names, pod.Name) + } + return strings.Join(names, ", ") +} + +func secretValue(secret *corev1.Secret, key string) ([]byte, error) { + value, ok := secret.Data[key] + if !ok || len(value) == 0 { + return nil, fmt.Errorf("missing non-empty %q field", key) + } + return append([]byte(nil), value...), nil +} + +func patchPasswordSecret(ctx context.Context, client kubernetes.Interface, namespace, name string, password []byte) error { + patch, err := json.Marshal(map[string]map[string]string{ + "data": { + constant.AccountPasswdForSecret: base64.StdEncoding.EncodeToString(password), + }, + }) + if err != nil { + return fmt.Errorf("marshal Secret patch: %w", err) + } + if _, err = client.CoreV1().Secrets(namespace).Patch(ctx, name, k8stypes.MergePatchType, patch, metav1.PatchOptions{}); err != nil { + return fmt.Errorf("patch Secret %s/%s: %w", namespace, name, err) + } + return nil +} + +func deletePasswordSecret(ctx context.Context, client kubernetes.Interface, namespace, name string) error { + if err := client.CoreV1().Secrets(namespace).Delete(ctx, name, metav1.DeleteOptions{}); err != nil && !apierrors.IsNotFound(err) { + return fmt.Errorf("delete Secret %s/%s: %w", namespace, name, err) + } + return nil +} diff --git a/pkg/cmd/accounts/change_password_mongodb.go b/pkg/cmd/accounts/change_password_mongodb.go new file mode 100644 index 000000000..3ac34237b --- /dev/null +++ b/pkg/cmd/accounts/change_password_mongodb.go @@ -0,0 +1,49 @@ +package accounts + +import ( + "encoding/json" + "fmt" + "strings" +) + +const mongoPasswordShellCommand = `if command -v mongosh >/dev/null 2>&1; then + exec mongosh --host 127.0.0.1 --port 27017 --quiet +elif command -v mongo >/dev/null 2>&1; then + exec mongo --host 127.0.0.1 --port 27017 --quiet +else + echo "neither mongosh nor mongo is installed" >&2 + exit 127 +fi +` + +type mongoPasswordChanger struct { + config passwordChangerConfig +} + +func (c *mongoPasswordChanger) ChangePassword(request passwordChangeRequest) error { + database := c.config.Database + if database == "" { + database = defaultMongoPasswordDatabase + } + + command := []string{"sh", "-c", mongoPasswordShellCommand} + script := buildMongoPasswordScript(database, request.AuthUser, request.AuthPassword, request.TargetUser, request.NewPassword) + return executePasswordCommand(c.config, request.PodName, command, strings.NewReader(script), true, "MongoDB client") +} + +func buildMongoPasswordScript(database, authUser, authPassword, targetUser, newPassword string) string { + return fmt.Sprintf( + "var auth = db.getSiblingDB(%s).auth(%s, %s); if (!auth) { throw new Error(\"authentication failed\"); } var result = db.getSiblingDB(%s).runCommand({updateUser: %s, pwd: %s}); if (!result || result.ok !== 1) { printjson(result); throw new Error(\"updateUser failed\"); }", + quoteMongoJavaScriptString(database), + quoteMongoJavaScriptString(authUser), + quoteMongoJavaScriptString(authPassword), + quoteMongoJavaScriptString(database), + quoteMongoJavaScriptString(targetUser), + quoteMongoJavaScriptString(newPassword), + ) +} + +func quoteMongoJavaScriptString(value string) string { + encoded, _ := json.Marshal(value) + return string(encoded) +} diff --git a/pkg/cmd/accounts/change_password_postgresql.go b/pkg/cmd/accounts/change_password_postgresql.go new file mode 100644 index 000000000..bf859ad1d --- /dev/null +++ b/pkg/cmd/accounts/change_password_postgresql.go @@ -0,0 +1,53 @@ +/* +Copyright (C) 2022-2024 ApeCloud Co., Ltd + +This file is part of KubeBlocks project + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package accounts + +import "strings" + +type postgresPasswordChanger struct { + config passwordChangerConfig +} + +func (c *postgresPasswordChanger) ChangePassword(request passwordChangeRequest) error { + command := []string{ + "env", + "PGPASSWORD=" + request.AuthPassword, + "psql", + "-X", + "-v", "ON_ERROR_STOP=1", + "-h", "127.0.0.1", + "-U", request.AuthUser, + "-d", c.config.Database, + } + return executePasswordCommand(c.config, request.PodName, command, + strings.NewReader(buildPostgresPasswordSQL(request.TargetUser, request.NewPassword)), true, "psql") +} + +func buildPostgresPasswordSQL(role, password string) string { + return "SET standard_conforming_strings = on;\nALTER ROLE " + quotePostgresIdentifier(role) + " PASSWORD " + quotePostgresLiteral(password) + ";\n" +} + +func quotePostgresIdentifier(value string) string { + return `"` + strings.ReplaceAll(value, `"`, `""`) + `"` +} + +func quotePostgresLiteral(value string) string { + return `'` + strings.ReplaceAll(value, `'`, `''`) + `'` +} diff --git a/pkg/cmd/accounts/change_password_test.go b/pkg/cmd/accounts/change_password_test.go new file mode 100644 index 000000000..c9eeafaab --- /dev/null +++ b/pkg/cmd/accounts/change_password_test.go @@ -0,0 +1,345 @@ +/* +Copyright (C) 2022-2024 ApeCloud Co., Ltd + +This file is part of KubeBlocks project + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package accounts + +import ( + "context" + "io" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/genericiooptions" + "k8s.io/client-go/kubernetes/fake" + + "github.com/apecloud/kubeblocks/pkg/constant" +) + +func TestBuildPostgresPasswordSQL(t *testing.T) { + got := buildPostgresPasswordSQL(`role"name`, `p'ass\word`) + want := "SET standard_conforming_strings = on;\nALTER ROLE \"role\"\"name\" PASSWORD 'p''ass\\word';\n" + if got != want { + t.Fatalf("buildPostgresPasswordSQL() = %q, want %q", got, want) + } +} + +func TestBuildMongoPasswordScriptEscapesValues(t *testing.T) { + got := buildMongoPasswordScript("admin", `admin"user`, `a'p\ass`, `root"user`, `p'ass\word`) + want := `var auth = db.getSiblingDB("admin").auth("admin\"user", "a'p\\ass"); if (!auth) { throw new Error("authentication failed"); } var result = db.getSiblingDB("admin").runCommand({updateUser: "root\"user", pwd: "p'ass\\word"}); if (!result || result.ok !== 1) { printjson(result); throw new Error("updateUser failed"); }` + if got != want { + t.Fatalf("buildMongoPasswordScript() = %q, want %q", got, want) + } +} + +func TestSelectPasswordPodPrefersPrimary(t *testing.T) { + pods := []corev1.Pod{ + passwordTestPod("pg-postgresql-1", "replica", "postgresql"), + passwordTestPod("pg-postgresql-0", "primary", "postgresql"), + } + + got, err := selectPasswordPod(pods, "pg", "postgresql") + if err != nil { + t.Fatalf("selectPasswordPod() error = %v", err) + } + if got.Name != "pg-postgresql-0" { + t.Fatalf("selectPasswordPod() = %q, want %q", got.Name, "pg-postgresql-0") + } +} + +func TestSelectPasswordPodRejectsAmbiguousFallback(t *testing.T) { + pods := []corev1.Pod{ + passwordTestPod("pg-postgresql-0", "", "postgresql"), + passwordTestPod("pg-postgresql-1", "", "postgresql"), + } + + _, err := selectPasswordPod(pods, "pg", "postgresql") + if err == nil || !strings.Contains(err.Error(), "pass --instance explicitly") { + t.Fatalf("selectPasswordPod() error = %v, want an ambiguity error", err) + } +} + +func TestFindTargetPasswordSecretUsesDefaultCandidates(t *testing.T) { + secret := passwordTestSecret("pg-conn-credential", "postgres", "old-password") + client := fake.NewSimpleClientset(secret) + + got, err := findTargetPasswordSecret(context.Background(), client, "default", "pg", "postgresql", "postgres", "") + if err != nil { + t.Fatalf("findTargetPasswordSecret() error = %v", err) + } + if got.Name != secret.Name { + t.Fatalf("findTargetPasswordSecret() = %q, want %q", got.Name, secret.Name) + } +} + +func TestFindTargetPasswordSecretUsesMongoConnectionSecret(t *testing.T) { + secret := passwordTestSecret("mongo-conn-credential", "root", "old-password") + client := fake.NewSimpleClientset(secret) + + got, err := findTargetPasswordSecret(context.Background(), client, "default", "mongo", "mongodb", "root", "") + if err != nil { + t.Fatalf("findTargetPasswordSecret() error = %v", err) + } + if got.Name != secret.Name { + t.Fatalf("findTargetPasswordSecret() = %q, want %q", got.Name, secret.Name) + } +} + +func TestFindTargetPasswordSecretsUsesMongoConnectionAndAccountSecrets(t *testing.T) { + connectionSecret := passwordTestSecret("mongo-conn-credential", "root", "old-password") + accountSecret := passwordTestSecret("mongo-mongodb-account-root", "root", "old-password") + client := fake.NewSimpleClientset(connectionSecret, accountSecret) + + got, err := findTargetPasswordSecrets(context.Background(), client, "default", "mongo", "mongodb", "root", "") + if err != nil { + t.Fatalf("findTargetPasswordSecrets() error = %v", err) + } + if len(got) != 2 || got[0].Name != connectionSecret.Name || got[1].Name != accountSecret.Name { + t.Fatalf("findTargetPasswordSecrets() = %v, want [%q %q]", passwordSecretNames(got), connectionSecret.Name, accountSecret.Name) + } +} + +func TestFindTargetPasswordSecretUsesAccountSecret(t *testing.T) { + secret := passwordTestSecret("pg-postgresql-account-app-user", "app_user", "old-password") + secret.Labels = map[string]string{ + constant.AppInstanceLabelKey: "pg", + constant.ClusterAccountLabelKey: "app_user", + } + client := fake.NewSimpleClientset(secret) + + got, err := findTargetPasswordSecret(context.Background(), client, "default", "pg", "postgresql", "app_user", "") + if err != nil { + t.Fatalf("findTargetPasswordSecret() error = %v", err) + } + if got.Name != secret.Name { + t.Fatalf("findTargetPasswordSecret() = %q, want %q", got.Name, secret.Name) + } +} + +func TestPatchPasswordSecret(t *testing.T) { + secret := passwordTestSecret("target", "appuser", "old-password") + client := fake.NewSimpleClientset(secret) + + if err := patchPasswordSecret(context.Background(), client, "default", secret.Name, []byte("new-password")); err != nil { + t.Fatalf("patchPasswordSecret() error = %v", err) + } + + got, err := client.CoreV1().Secrets("default").Get(context.Background(), secret.Name, metav1.GetOptions{}) + if err != nil { + t.Fatalf("get patched Secret: %v", err) + } + if string(got.Data[constant.AccountPasswdForSecret]) != "new-password" { + t.Fatalf("patched password = %q, want %q", got.Data[constant.AccountPasswdForSecret], "new-password") + } +} + +func TestChangePasswordRunUpdatesSecret(t *testing.T) { + secret := passwordTestSecret("target", "appuser", "old-password") + client := fake.NewSimpleClientset(secret) + changer := &recordingPasswordChanger{} + o := newTestChangePasswordOptions(client, secret, changer) + + if err := o.Run(); err != nil { + t.Fatalf("ChangePasswordOptions.Run() error = %v", err) + } + if len(changer.requests) != 1 { + t.Fatalf("password change requests = %d, want 1", len(changer.requests)) + } + if changer.requests[0].NewPassword != "new-password" { + t.Fatalf("new password = %q, want %q", changer.requests[0].NewPassword, "new-password") + } + + got, err := client.CoreV1().Secrets("default").Get(context.Background(), secret.Name, metav1.GetOptions{}) + if err != nil { + t.Fatalf("get updated Secret: %v", err) + } + if string(got.Data[constant.AccountPasswdForSecret]) != "new-password" { + t.Fatalf("updated password = %q, want %q", got.Data[constant.AccountPasswdForSecret], "new-password") + } +} + +func TestChangePasswordRunUpdatesAllTargetSecrets(t *testing.T) { + connectionSecret := passwordTestSecret("mongo-conn-credential", "appuser", "old-password") + accountSecret := passwordTestSecret("mongo-mongodb-account-appuser", "appuser", "old-password") + client := fake.NewSimpleClientset(connectionSecret, accountSecret) + changer := &recordingPasswordChanger{} + o := newTestChangePasswordOptions(client, connectionSecret, changer) + o.targetSecrets = []*corev1.Secret{connectionSecret, accountSecret} + o.oldPasswords = map[string]string{ + connectionSecret.Name: "old-password", + accountSecret.Name: "old-password", + } + + if err := o.Run(); err != nil { + t.Fatalf("ChangePasswordOptions.Run() error = %v", err) + } + for _, name := range []string{connectionSecret.Name, accountSecret.Name} { + got, err := client.CoreV1().Secrets("default").Get(context.Background(), name, metav1.GetOptions{}) + if err != nil { + t.Fatalf("get updated Secret %s: %v", name, err) + } + if string(got.Data[constant.AccountPasswdForSecret]) != "new-password" { + t.Fatalf("updated password in Secret %s = %q, want %q", name, got.Data[constant.AccountPasswdForSecret], "new-password") + } + } +} + +func TestChangePasswordRunDeletesImmutableTargetSecret(t *testing.T) { + connectionSecret := passwordTestSecret("mongo-conn-credential", "appuser", "old-password") + accountSecret := passwordTestSecret("mongo-mongodb-account-appuser", "appuser", "old-password") + immutable := true + accountSecret.Immutable = &immutable + client := fake.NewSimpleClientset(connectionSecret, accountSecret) + changer := &recordingPasswordChanger{} + o := newTestChangePasswordOptions(client, connectionSecret, changer) + o.targetSecrets = []*corev1.Secret{connectionSecret, accountSecret} + o.oldPasswords = map[string]string{ + connectionSecret.Name: "old-password", + accountSecret.Name: "old-password", + } + + if err := o.Run(); err != nil { + t.Fatalf("ChangePasswordOptions.Run() error = %v", err) + } + updated, err := client.CoreV1().Secrets("default").Get(context.Background(), connectionSecret.Name, metav1.GetOptions{}) + if err != nil { + t.Fatalf("get updated connection Secret: %v", err) + } + if string(updated.Data[constant.AccountPasswdForSecret]) != "new-password" { + t.Fatalf("updated connection password = %q, want %q", updated.Data[constant.AccountPasswdForSecret], "new-password") + } + if _, err := client.CoreV1().Secrets("default").Get(context.Background(), accountSecret.Name, metav1.GetOptions{}); !apierrors.IsNotFound(err) { + t.Fatalf("immutable account Secret lookup error = %v, want NotFound", err) + } +} + +func TestChangePasswordRunRollsBackWhenSecretUpdateFails(t *testing.T) { + client := fake.NewSimpleClientset() + secret := passwordTestSecret("target", "appuser", "old-password") + changer := &recordingPasswordChanger{} + o := newTestChangePasswordOptions(client, secret, changer) + + err := o.Run() + if err == nil || !strings.Contains(err.Error(), "password was rolled back") { + t.Fatalf("ChangePasswordOptions.Run() error = %v, want rollback error", err) + } + if len(changer.requests) != 2 { + t.Fatalf("password change requests = %d, want 2", len(changer.requests)) + } + if changer.requests[1].AuthPassword != "admin-password" { + t.Fatalf("rollback auth password = %q, want %q", changer.requests[1].AuthPassword, "admin-password") + } + if changer.requests[1].NewPassword != "old-password" { + t.Fatalf("rollback new password = %q, want %q", changer.requests[1].NewPassword, "old-password") + } +} + +func TestNewPasswordChangerRejectsUnsupportedDatabase(t *testing.T) { + if _, err := newPasswordChanger("mysql", passwordChangerConfig{}); err == nil { + t.Fatal("newPasswordChanger() should reject unsupported databases") + } +} + +func TestNewPasswordChangerSupportsMongoDB(t *testing.T) { + for _, characterType := range []string{"mongo", "mongodb"} { + changer, err := newPasswordChanger(characterType, passwordChangerConfig{}) + if err != nil { + t.Fatalf("newPasswordChanger(%q) error = %v", characterType, err) + } + if _, ok := changer.(*mongoPasswordChanger); !ok { + t.Fatalf("newPasswordChanger(%q) type = %T, want *mongoPasswordChanger", characterType, changer) + } + } +} + +func TestDefaultPasswordValuesForCharacterType(t *testing.T) { + if got := defaultPasswordUserForCharacterType("mongodb"); got != defaultMongoPasswordUser { + t.Fatalf("MongoDB default user = %q, want %q", got, defaultMongoPasswordUser) + } + if got := defaultPasswordDatabaseForCharacterType("mongodb"); got != defaultMongoPasswordDatabase { + t.Fatalf("MongoDB default database = %q, want %q", got, defaultMongoPasswordDatabase) + } + if got := defaultPasswordUserForCharacterType("postgresql"); got != defaultPasswordUser { + t.Fatalf("PostgreSQL default user = %q, want %q", got, defaultPasswordUser) + } + if got := defaultPasswordDatabaseForCharacterType("postgresql"); got != defaultPasswordDatabase { + t.Fatalf("PostgreSQL default database = %q, want %q", got, defaultPasswordDatabase) + } +} + +type recordingPasswordChanger struct { + requests []passwordChangeRequest +} + +func (c *recordingPasswordChanger) ChangePassword(request passwordChangeRequest) error { + c.requests = append(c.requests, request) + return nil +} + +func newTestChangePasswordOptions(client *fake.Clientset, secret *corev1.Secret, changer passwordChanger) *ChangePasswordOptions { + streams, _, _, _ := genericiooptions.NewTestIOStreams() + o := NewChangePasswordOptions(nil, streams) + o.kubeClient = client + o.targetSecret = secret + o.Password = "new-password" + o.UserName = "appuser" + o.Namespace = "default" + o.PodName = "pg-postgresql-0" + o.authUser = "admin" + o.authPassword = "admin-password" + o.oldPassword = "old-password" + o.passwordChanger = changer + o.Out = io.Discard + o.ErrOut = io.Discard + return o +} + +func passwordTestPod(name, role, component string) corev1.Pod { + labels := map[string]string{ + constant.AppInstanceLabelKey: "pg", + constant.KBAppComponentLabelKey: component, + } + if role != "" { + labels[constant.RoleLabelKey] = role + } + return corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: name, Labels: labels}, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } +} + +func passwordTestSecret(name, user, password string) *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"}, + Data: map[string][]byte{ + constant.AccountNameForSecret: []byte(user), + constant.AccountPasswdForSecret: []byte(password), + }, + } +} + +func passwordSecretNames(secrets []*corev1.Secret) []string { + names := make([]string, 0, len(secrets)) + for _, secret := range secrets { + names = append(names, secret.Name) + } + return names +} diff --git a/pkg/cmd/cluster/accounts.go b/pkg/cmd/cluster/accounts.go index 50ca80787..e21184c05 100644 --- a/pkg/cmd/cluster/accounts.go +++ b/pkg/cmd/cluster/accounts.go @@ -83,7 +83,17 @@ var ( kbcli cluster revoke-role CLUSTERNAME --name USERNAME --role ROLENAME # revoke role from user for instance kbcli cluster revoke-role --instance INSTANCE --name USERNAME --role ROLENAME - `) + `) + changePasswordExamples = templates.Examples(` + # change the default PostgreSQL account password and update its Secret + kbcli cluster change-password CLUSTERNAME --password PASSWD + # change the default MongoDB root password and update its Secret + kbcli cluster change-password CLUSTERNAME --password PASSWD + # change an application role password with explicit Secrets + kbcli cluster change-password CLUSTERNAME --component COMPNAME --name USERNAME --password PASSWD --secret TARGET_SECRET --auth-secret ADMIN_SECRET + # change a password on a specified instance + kbcli cluster change-password --instance INSTANCE --name USERNAME --password PASSWD + `) ) func NewCreateAccountCmd(f cmdutil.Factory, streams genericiooptions.IOStreams) *cobra.Command { @@ -191,3 +201,19 @@ func NewRevokeOptions(f cmdutil.Factory, streams genericiooptions.IOStreams) *co o.AddFlags(cmd) return cmd } + +func NewChangePasswordCmd(f cmdutil.Factory, streams genericiooptions.IOStreams) *cobra.Command { + o := accounts.NewChangePasswordOptions(f, streams) + cmd := &cobra.Command{ + Use: "change-password", + Short: "Change a database account password", + Example: changePasswordExamples, + ValidArgsFunction: util.ResourceNameCompletionFunc(f, types.ClusterGVR()), + Run: func(cmd *cobra.Command, args []string) { + cmdutil.CheckErr(o.AccountBaseOptions.Validate(args)) + cmdutil.CheckErr(o.Exec()) + }, + } + o.AddFlags(cmd) + return cmd +} diff --git a/pkg/cmd/cluster/cluster.go b/pkg/cmd/cluster/cluster.go index 7f5c641e9..357179aaf 100644 --- a/pkg/cmd/cluster/cluster.go +++ b/pkg/cmd/cluster/cluster.go @@ -105,6 +105,7 @@ func NewClusterCmd(f cmdutil.Factory, streams genericiooptions.IOStreams) *cobra Message: "User Accounts Commands:", Commands: []*cobra.Command{ NewCreateAccountCmd(f, streams), + NewChangePasswordCmd(f, streams), NewDeleteAccountCmd(f, streams), NewDescAccountCmd(f, streams), NewListAccountsCmd(f, streams), From 34777fca0c510d521d243ebaa6ed921ae120de8c Mon Sep 17 00:00:00 2001 From: Yun Pan Date: Fri, 16 Oct 2026 07:42:05 +0000 Subject: [PATCH 2/6] fix: validate database health before password change --- .../cli/kbcli_cluster_change-password.md | 2 ++ pkg/cmd/accounts/change_password.go | 24 +++++++++++++++++++ pkg/cmd/accounts/change_password_mongodb.go | 2 +- .../accounts/change_password_postgresql.go | 2 +- pkg/cmd/accounts/change_password_test.go | 16 +++++++++++-- 5 files changed, 42 insertions(+), 4 deletions(-) diff --git a/docs/user_docs/cli/kbcli_cluster_change-password.md b/docs/user_docs/cli/kbcli_cluster_change-password.md index b11c2acf1..955f93e0d 100644 --- a/docs/user_docs/cli/kbcli_cluster_change-password.md +++ b/docs/user_docs/cli/kbcli_cluster_change-password.md @@ -6,6 +6,8 @@ Change a database account password When automatically discovered account Secrets are immutable, kbcli deletes them after updating mutable credential Secrets so KubeBlocks can recreate them with the new password. +The cluster and selected component must be in the `Running` phase. For PostgreSQL, kbcli verifies that the server is not in recovery mode before changing the password. For MongoDB, kbcli checks connectivity and verifies that the selected server is a writable primary. + ``` kbcli cluster change-password [flags] ``` diff --git a/pkg/cmd/accounts/change_password.go b/pkg/cmd/accounts/change_password.go index 89f923d0b..d247dc0c0 100644 --- a/pkg/cmd/accounts/change_password.go +++ b/pkg/cmd/accounts/change_password.go @@ -44,8 +44,10 @@ import ( cmdutil "k8s.io/kubectl/pkg/cmd/util" "k8s.io/kubectl/pkg/cmd/util/podcmd" + appsv1alpha1 "github.com/apecloud/kubeblocks/apis/apps/v1alpha1" "github.com/apecloud/kubeblocks/pkg/constant" + clusterutil "github.com/apecloud/kbcli/pkg/cluster" "github.com/apecloud/kbcli/pkg/printer" ) @@ -117,6 +119,9 @@ func (o *ChangePasswordOptions) Complete() error { return err } o.setDatabaseDefaults() + if err := o.validateComponentStatus(); err != nil { + return err + } ctx := context.Background() if !explicitInstance { @@ -142,6 +147,25 @@ func (o *ChangePasswordOptions) Complete() error { return nil } +func (o *ChangePasswordOptions) validateComponentStatus() error { + cluster, err := clusterutil.GetClusterByName(o.Dynamic, o.ClusterName, o.Namespace) + if err != nil { + return fmt.Errorf("get cluster %s/%s status: %w", o.Namespace, o.ClusterName, err) + } + componentStatus, ok := cluster.Status.Components[o.ComponentName] + if !ok { + return fmt.Errorf("component status %s not found in cluster %s", o.ComponentName, o.ClusterName) + } + return validatePasswordComponentPhase(o.ComponentName, componentStatus.Phase) +} + +func validatePasswordComponentPhase(componentName string, phase appsv1alpha1.ClusterComponentPhase) error { + if phase != appsv1alpha1.RunningClusterCompPhase { + return fmt.Errorf("component %s is not running (phase %s), please try again later", componentName, phase) + } + return nil +} + func (o *ChangePasswordOptions) completeSecrets(ctx context.Context) error { targetSecrets, err := findTargetPasswordSecrets(ctx, o.kube(), o.Namespace, o.ClusterName, o.ComponentName, o.UserName, o.SecretName) if err != nil { diff --git a/pkg/cmd/accounts/change_password_mongodb.go b/pkg/cmd/accounts/change_password_mongodb.go index 3ac34237b..e0c246bae 100644 --- a/pkg/cmd/accounts/change_password_mongodb.go +++ b/pkg/cmd/accounts/change_password_mongodb.go @@ -33,7 +33,7 @@ func (c *mongoPasswordChanger) ChangePassword(request passwordChangeRequest) err func buildMongoPasswordScript(database, authUser, authPassword, targetUser, newPassword string) string { return fmt.Sprintf( - "var auth = db.getSiblingDB(%s).auth(%s, %s); if (!auth) { throw new Error(\"authentication failed\"); } var result = db.getSiblingDB(%s).runCommand({updateUser: %s, pwd: %s}); if (!result || result.ok !== 1) { printjson(result); throw new Error(\"updateUser failed\"); }", + "var admin = db.getSiblingDB(\"admin\"); var ping = admin.runCommand({ping: 1}); if (!ping || ping.ok !== 1) { printjson(ping); throw new Error(\"MongoDB health check failed: ping\"); } var hello = admin.runCommand({hello: 1}); if (!hello || hello.ok !== 1) { hello = admin.runCommand({isMaster: 1}); } if (!hello || hello.ok !== 1 || (hello.isWritablePrimary !== true && hello.ismaster !== true)) { printjson(hello); throw new Error(\"MongoDB is not a writable primary\"); } var auth = db.getSiblingDB(%s).auth(%s, %s); if (!auth) { throw new Error(\"authentication failed\"); } var result = db.getSiblingDB(%s).runCommand({updateUser: %s, pwd: %s}); if (!result || result.ok !== 1) { printjson(result); throw new Error(\"updateUser failed\"); }", quoteMongoJavaScriptString(database), quoteMongoJavaScriptString(authUser), quoteMongoJavaScriptString(authPassword), diff --git a/pkg/cmd/accounts/change_password_postgresql.go b/pkg/cmd/accounts/change_password_postgresql.go index bf859ad1d..9119e1323 100644 --- a/pkg/cmd/accounts/change_password_postgresql.go +++ b/pkg/cmd/accounts/change_password_postgresql.go @@ -41,7 +41,7 @@ func (c *postgresPasswordChanger) ChangePassword(request passwordChangeRequest) } func buildPostgresPasswordSQL(role, password string) string { - return "SET standard_conforming_strings = on;\nALTER ROLE " + quotePostgresIdentifier(role) + " PASSWORD " + quotePostgresLiteral(password) + ";\n" + return "SET standard_conforming_strings = on;\nDO $$ BEGIN IF pg_is_in_recovery() THEN RAISE EXCEPTION 'PostgreSQL is in recovery mode and is not writable'; END IF; END $$;\nALTER ROLE " + quotePostgresIdentifier(role) + " PASSWORD " + quotePostgresLiteral(password) + ";\n" } func quotePostgresIdentifier(value string) string { diff --git a/pkg/cmd/accounts/change_password_test.go b/pkg/cmd/accounts/change_password_test.go index c9eeafaab..ff7e64a8a 100644 --- a/pkg/cmd/accounts/change_password_test.go +++ b/pkg/cmd/accounts/change_password_test.go @@ -31,12 +31,13 @@ import ( "k8s.io/cli-runtime/pkg/genericiooptions" "k8s.io/client-go/kubernetes/fake" + appsv1alpha1 "github.com/apecloud/kubeblocks/apis/apps/v1alpha1" "github.com/apecloud/kubeblocks/pkg/constant" ) func TestBuildPostgresPasswordSQL(t *testing.T) { got := buildPostgresPasswordSQL(`role"name`, `p'ass\word`) - want := "SET standard_conforming_strings = on;\nALTER ROLE \"role\"\"name\" PASSWORD 'p''ass\\word';\n" + want := "SET standard_conforming_strings = on;\nDO $$ BEGIN IF pg_is_in_recovery() THEN RAISE EXCEPTION 'PostgreSQL is in recovery mode and is not writable'; END IF; END $$;\nALTER ROLE \"role\"\"name\" PASSWORD 'p''ass\\word';\n" if got != want { t.Fatalf("buildPostgresPasswordSQL() = %q, want %q", got, want) } @@ -44,12 +45,23 @@ func TestBuildPostgresPasswordSQL(t *testing.T) { func TestBuildMongoPasswordScriptEscapesValues(t *testing.T) { got := buildMongoPasswordScript("admin", `admin"user`, `a'p\ass`, `root"user`, `p'ass\word`) - want := `var auth = db.getSiblingDB("admin").auth("admin\"user", "a'p\\ass"); if (!auth) { throw new Error("authentication failed"); } var result = db.getSiblingDB("admin").runCommand({updateUser: "root\"user", pwd: "p'ass\\word"}); if (!result || result.ok !== 1) { printjson(result); throw new Error("updateUser failed"); }` + want := `var admin = db.getSiblingDB("admin"); var ping = admin.runCommand({ping: 1}); if (!ping || ping.ok !== 1) { printjson(ping); throw new Error("MongoDB health check failed: ping"); } var hello = admin.runCommand({hello: 1}); if (!hello || hello.ok !== 1) { hello = admin.runCommand({isMaster: 1}); } if (!hello || hello.ok !== 1 || (hello.isWritablePrimary !== true && hello.ismaster !== true)) { printjson(hello); throw new Error("MongoDB is not a writable primary"); } var auth = db.getSiblingDB("admin").auth("admin\"user", "a'p\\ass"); if (!auth) { throw new Error("authentication failed"); } var result = db.getSiblingDB("admin").runCommand({updateUser: "root\"user", pwd: "p'ass\\word"}); if (!result || result.ok !== 1) { printjson(result); throw new Error("updateUser failed"); }` if got != want { t.Fatalf("buildMongoPasswordScript() = %q, want %q", got, want) } } +func TestValidatePasswordComponentPhase(t *testing.T) { + if err := validatePasswordComponentPhase("mongodb", appsv1alpha1.RunningClusterCompPhase); err != nil { + t.Fatalf("validatePasswordComponentPhase() error = %v", err) + } + + err := validatePasswordComponentPhase("mongodb", appsv1alpha1.UpdatingClusterCompPhase) + if err == nil || !strings.Contains(err.Error(), "is not running") { + t.Fatalf("validatePasswordComponentPhase() error = %v, want not-running error", err) + } +} + func TestSelectPasswordPodPrefersPrimary(t *testing.T) { pods := []corev1.Pod{ passwordTestPod("pg-postgresql-1", "replica", "postgresql"), From c9850605d56a6f9009c7f3892766e8af864d9d50 Mon Sep 17 00:00:00 2001 From: Yun Pan Date: Fri, 16 Oct 2026 09:21:52 +0000 Subject: [PATCH 3/6] feat: support interactive password input --- .../cli/kbcli_cluster_change-password.md | 6 +- pkg/cmd/accounts/change_password.go | 65 +++++++++++++++++-- pkg/cmd/accounts/change_password_test.go | 24 +++++++ pkg/cmd/cluster/accounts.go | 4 +- 4 files changed, 88 insertions(+), 11 deletions(-) diff --git a/docs/user_docs/cli/kbcli_cluster_change-password.md b/docs/user_docs/cli/kbcli_cluster_change-password.md index 955f93e0d..1fcb86dbb 100644 --- a/docs/user_docs/cli/kbcli_cluster_change-password.md +++ b/docs/user_docs/cli/kbcli_cluster_change-password.md @@ -15,8 +15,8 @@ kbcli cluster change-password [flags] ### Examples ``` - # change the default PostgreSQL account password and update its Secret - kbcli cluster change-password CLUSTERNAME --password PASSWD + # interactively change the default PostgreSQL account password and update its Secret + kbcli cluster change-password CLUSTERNAME # change the default MongoDB root password and update its Secret kbcli cluster change-password CLUSTERNAME --password PASSWD # change an application role password with explicit Secrets @@ -35,7 +35,7 @@ kbcli cluster change-password [flags] -h, --help help for change-password -i, --instance string Specify the name of instance to be connected. --name string Specify the database account whose password should be changed. Defaults to postgres for PostgreSQL and root for MongoDB. - -p, --password string Required. Specify the new password. + -p, --password string Specify the new password; prompts interactively when omitted. --secret string Secret to update; when omitted, all related account Secrets are detected and updated. ``` diff --git a/pkg/cmd/accounts/change_password.go b/pkg/cmd/accounts/change_password.go index d247dc0c0..d3e697509 100644 --- a/pkg/cmd/accounts/change_password.go +++ b/pkg/cmd/accounts/change_password.go @@ -27,10 +27,12 @@ import ( "errors" "fmt" "io" + "os" "sort" "strings" "github.com/spf13/cobra" + "golang.org/x/crypto/ssh/terminal" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -78,6 +80,7 @@ type ChangePasswordOptions struct { kubeClient kubernetes.Interface passwordChanger passwordChanger + readPassword func() ([]byte, error) } func NewChangePasswordOptions(f cmdutil.Factory, streams genericiooptions.IOStreams) *ChangePasswordOptions { @@ -89,19 +92,15 @@ func NewChangePasswordOptions(f cmdutil.Factory, streams genericiooptions.IOStre func (o *ChangePasswordOptions) AddFlags(cmd *cobra.Command) { o.AccountBaseOptions.AddFlags(cmd) cmd.Flags().StringVar(&o.UserName, "name", "", "Specify the database account whose password should be changed. Defaults to postgres for PostgreSQL and root for MongoDB.") - cmd.Flags().StringVarP(&o.Password, "password", "p", "", "Required. Specify the new password.") + cmd.Flags().StringVarP(&o.Password, "password", "p", "", "Specify the new password; prompts interactively when omitted.") cmd.Flags().StringVar(&o.DatabaseName, "database", "", "Database used for authentication and the password change. Defaults to postgres for PostgreSQL and admin for MongoDB.") cmd.Flags().StringVar(&o.SecretName, "secret", "", "Secret to update; when omitted, all related account Secrets are detected and updated.") cmd.Flags().StringVar(&o.AuthSecretName, "auth-secret", "", "Secret used for database login; defaults to the target Secret.") cmd.Flags().StringVar(&o.ContainerName, "container", "", "Container used for the database command; defaults to the Pod's default container.") - _ = cmd.MarkFlagRequired("password") } func (o *ChangePasswordOptions) Validate() error { - if o.Password == "" { - return errMissingPassword - } - if bytes.IndexByte([]byte(o.Password), 0) >= 0 { + if o.Password != "" && bytes.IndexByte([]byte(o.Password), 0) >= 0 { return errors.New("password cannot contain a NUL byte") } if bytes.IndexByte([]byte(o.UserName), 0) >= 0 { @@ -361,9 +360,63 @@ func (o *ChangePasswordOptions) Exec() error { if err := o.Complete(); err != nil { return err } + if o.Password == "" { + if err := o.promptPassword(); err != nil { + return err + } + } return o.Run() } +func (o *ChangePasswordOptions) promptPassword() error { + readPassword := o.readPassword + if readPassword == nil { + readPassword = func() ([]byte, error) { + return terminal.ReadPassword(int(os.Stdin.Fd())) + } + } + out := o.Out + if out == nil { + out = io.Discard + } + fmt.Fprint(out, "New password: ") + passwordBytes, err := readPassword() + fmt.Fprintln(out) + if err != nil { + return fmt.Errorf("read new password: %w; specify --password for non-interactive use", err) + } + password := string(passwordBytes) + if err := validatePassword(password); err != nil { + return fmt.Errorf("read new password: %w", err) + } + + fmt.Fprint(out, "Confirm password: ") + confirmationBytes, err := readPassword() + fmt.Fprintln(out) + if err != nil { + return fmt.Errorf("confirm new password: %w; specify --password for non-interactive use", err) + } + confirmation := string(confirmationBytes) + if err := validatePassword(confirmation); err != nil { + return fmt.Errorf("confirm new password: %w", err) + } + if confirmation != password { + return errors.New("confirm new password: passwords do not match") + } + o.Password = password + return nil +} + +func validatePassword(password string) error { + if password == "" { + return errMissingPassword + } + if bytes.IndexByte([]byte(password), 0) >= 0 { + return errors.New("password cannot contain a NUL byte") + } + return nil +} + type passwordChangeRequest struct { PodName string AuthUser string diff --git a/pkg/cmd/accounts/change_password_test.go b/pkg/cmd/accounts/change_password_test.go index ff7e64a8a..0d953489e 100644 --- a/pkg/cmd/accounts/change_password_test.go +++ b/pkg/cmd/accounts/change_password_test.go @@ -20,6 +20,7 @@ along with this program. If not, see . package accounts import ( + "bytes" "context" "io" "strings" @@ -43,6 +44,29 @@ func TestBuildPostgresPasswordSQL(t *testing.T) { } } +func TestPromptPassword(t *testing.T) { + streams, _, _, _ := genericiooptions.NewTestIOStreams() + var output bytes.Buffer + streams.Out = &output + o := NewChangePasswordOptions(nil, streams) + passwords := [][]byte{[]byte("new-password"), []byte("new-password")} + o.readPassword = func() ([]byte, error) { + password := passwords[0] + passwords = passwords[1:] + return password, nil + } + + if err := o.promptPassword(); err != nil { + t.Fatalf("promptPassword() error = %v", err) + } + if o.Password != "new-password" { + t.Fatalf("prompted password = %q, want %q", o.Password, "new-password") + } + if strings.Contains(output.String(), "new-password") { + t.Fatalf("prompt output contains the plaintext password: %q", output.String()) + } +} + func TestBuildMongoPasswordScriptEscapesValues(t *testing.T) { got := buildMongoPasswordScript("admin", `admin"user`, `a'p\ass`, `root"user`, `p'ass\word`) want := `var admin = db.getSiblingDB("admin"); var ping = admin.runCommand({ping: 1}); if (!ping || ping.ok !== 1) { printjson(ping); throw new Error("MongoDB health check failed: ping"); } var hello = admin.runCommand({hello: 1}); if (!hello || hello.ok !== 1) { hello = admin.runCommand({isMaster: 1}); } if (!hello || hello.ok !== 1 || (hello.isWritablePrimary !== true && hello.ismaster !== true)) { printjson(hello); throw new Error("MongoDB is not a writable primary"); } var auth = db.getSiblingDB("admin").auth("admin\"user", "a'p\\ass"); if (!auth) { throw new Error("authentication failed"); } var result = db.getSiblingDB("admin").runCommand({updateUser: "root\"user", pwd: "p'ass\\word"}); if (!result || result.ok !== 1) { printjson(result); throw new Error("updateUser failed"); }` diff --git a/pkg/cmd/cluster/accounts.go b/pkg/cmd/cluster/accounts.go index e21184c05..790fa96f4 100644 --- a/pkg/cmd/cluster/accounts.go +++ b/pkg/cmd/cluster/accounts.go @@ -85,8 +85,8 @@ var ( kbcli cluster revoke-role --instance INSTANCE --name USERNAME --role ROLENAME `) changePasswordExamples = templates.Examples(` - # change the default PostgreSQL account password and update its Secret - kbcli cluster change-password CLUSTERNAME --password PASSWD + # interactively change the default PostgreSQL account password and update its Secret + kbcli cluster change-password CLUSTERNAME # change the default MongoDB root password and update its Secret kbcli cluster change-password CLUSTERNAME --password PASSWD # change an application role password with explicit Secrets From acdd85a881ca528eb19e7bd774dfbb0c4f7d538c Mon Sep 17 00:00:00 2001 From: Yun Pan Date: Fri, 16 Oct 2026 09:43:59 +0000 Subject: [PATCH 4/6] feat: wait for MongoDB account-root secret recreation --- .../cli/kbcli_cluster_change-password.md | 4 +- pkg/cmd/accounts/change_password.go | 41 +++++++-- pkg/cmd/accounts/change_password_mongodb.go | 87 ++++++++++++++++++- pkg/cmd/accounts/change_password_test.go | 74 ++++++++++++++++ 4 files changed, 195 insertions(+), 11 deletions(-) diff --git a/docs/user_docs/cli/kbcli_cluster_change-password.md b/docs/user_docs/cli/kbcli_cluster_change-password.md index 1fcb86dbb..e26ebe752 100644 --- a/docs/user_docs/cli/kbcli_cluster_change-password.md +++ b/docs/user_docs/cli/kbcli_cluster_change-password.md @@ -4,7 +4,7 @@ title: kbcli cluster change-password Change a database account password -When automatically discovered account Secrets are immutable, kbcli deletes them after updating mutable credential Secrets so KubeBlocks can recreate them with the new password. +When automatically discovered account Secrets are immutable, kbcli deletes them after updating mutable credential Secrets so KubeBlocks can recreate them with the new password. For MongoDB, kbcli waits up to 30 seconds for the immutable `--account-root` Secret to be deleted and recreated with the new password. If deletion remains pending, kbcli still reports the password change as successful and prints a `kubectl patch secret ...` command for manually removing the Secret finalizer. The cluster and selected component must be in the `Running` phase. For PostgreSQL, kbcli verifies that the server is not in recovery mode before changing the password. For MongoDB, kbcli checks connectivity and verifies that the selected server is a writable primary. @@ -29,6 +29,8 @@ kbcli cluster change-password [flags] ``` --auth-secret string Secret used for database login; defaults to the target Secret. + --account-root-secret-timeout duration + Maximum time to wait for a MongoDB account-root Secret to be deleted and recreated. (default 30s) --component string Specify the name of component to be connected. If not specified, pick the first one. --container string Container used for the database command; defaults to the Pod's default container. --database string Database used for authentication and the password change. Defaults to postgres for PostgreSQL and admin for MongoDB. diff --git a/pkg/cmd/accounts/change_password.go b/pkg/cmd/accounts/change_password.go index d3e697509..0b47d2c21 100644 --- a/pkg/cmd/accounts/change_password.go +++ b/pkg/cmd/accounts/change_password.go @@ -30,6 +30,7 @@ import ( "os" "sort" "strings" + "time" "github.com/spf13/cobra" "golang.org/x/crypto/ssh/terminal" @@ -64,11 +65,12 @@ var errMissingPassword = errors.New("please specify password") type ChangePasswordOptions struct { *AccountBaseOptions - UserName string - Password string - DatabaseName string - SecretName string - AuthSecretName string + UserName string + Password string + DatabaseName string + SecretName string + AuthSecretName string + AccountRootSecretWaitTimeout time.Duration targetSecret *corev1.Secret targetSecrets []*corev1.Secret @@ -96,6 +98,7 @@ func (o *ChangePasswordOptions) AddFlags(cmd *cobra.Command) { cmd.Flags().StringVar(&o.DatabaseName, "database", "", "Database used for authentication and the password change. Defaults to postgres for PostgreSQL and admin for MongoDB.") cmd.Flags().StringVar(&o.SecretName, "secret", "", "Secret to update; when omitted, all related account Secrets are detected and updated.") cmd.Flags().StringVar(&o.AuthSecretName, "auth-secret", "", "Secret used for database login; defaults to the target Secret.") + cmd.Flags().DurationVar(&o.AccountRootSecretWaitTimeout, "account-root-secret-timeout", defaultMongoAccountRootSecretWaitTimeout, "Maximum time to wait for a MongoDB account-root Secret to be deleted and recreated.") cmd.Flags().StringVar(&o.ContainerName, "container", "", "Container used for the database command; defaults to the Pod's default container.") } @@ -109,6 +112,9 @@ func (o *ChangePasswordOptions) Validate() error { if bytes.IndexByte([]byte(o.DatabaseName), 0) >= 0 { return errors.New("database cannot contain a NUL byte") } + if o.AccountRootSecretWaitTimeout < 0 { + return errors.New("account-root-secret-timeout cannot be negative") + } return nil } @@ -283,17 +289,34 @@ func (o *ChangePasswordOptions) Run() error { } updatedSecrets = append(updatedSecrets, targetSecret) } + deletedImmutableSecrets := make([]*corev1.Secret, 0, len(immutableSecrets)) + stuckAccountRootSecrets := make([]*corev1.Secret, 0, len(immutableSecrets)) for _, targetSecret := range immutableSecrets { if err := deletePasswordSecret(context.Background(), o.kube(), o.Namespace, targetSecret.Name); err != nil { o.printGeneralInfo("fail", err.Error()) return fmt.Errorf("password changed and mutable target Secrets were updated, but failed to delete immutable Secret %s/%s for KubeBlocks to recreate: %w", o.Namespace, targetSecret.Name, err) } + if err := o.waitForMongoAccountRootSecret(targetSecret); err != nil { + stuckAccountRootSecrets = append(stuckAccountRootSecrets, targetSecret) + continue + } + deletedImmutableSecrets = append(deletedImmutableSecrets, targetSecret) } o.printGeneralInfo("success", fmt.Sprintf("password for account %q changed successfully", o.UserName)) - if len(immutableSecrets) > 0 { + if len(deletedImmutableSecrets) > 0 { printer.Alert(o.Out, "The new password is stored in mutable Secret(s); immutable Secret(s) were deleted for KubeBlocks to recreate.\n") - } else { + } + if len(stuckAccountRootSecrets) > 0 { + for _, targetSecret := range stuckAccountRootSecrets { + timeout := o.AccountRootSecretWaitTimeout + if timeout <= 0 { + timeout = defaultMongoAccountRootSecretWaitTimeout + } + printer.Warning(o.Out, "MongoDB password changed successfully, but deletion and recreation of account-root Secret %s/%s was not confirmed within %s. The deletion may be blocked by a finalizer. If the Secret remains stuck, run:\n %s\n", o.Namespace, targetSecret.Name, timeout, mongoAccountRootSecretFinalizerPatchCommand(o.Namespace, targetSecret.Name)) + } + } + if len(immutableSecrets) == 0 { printer.Alert(o.Out, "The new password is stored in the target Secret(s).\n") } return nil @@ -611,14 +634,14 @@ func selectPasswordPod(pods []corev1.Pod, cluster, component string) (*corev1.Po } func defaultPasswordUserForCharacterType(characterType string) string { - if strings.EqualFold(characterType, "mongo") || strings.EqualFold(characterType, "mongodb") { + if isMongoCharacterType(characterType) { return defaultMongoPasswordUser } return defaultPasswordUser } func defaultPasswordDatabaseForCharacterType(characterType string) string { - if strings.EqualFold(characterType, "mongo") || strings.EqualFold(characterType, "mongodb") { + if isMongoCharacterType(characterType) { return defaultMongoPasswordDatabase } return defaultPasswordDatabase diff --git a/pkg/cmd/accounts/change_password_mongodb.go b/pkg/cmd/accounts/change_password_mongodb.go index e0c246bae..4f714db07 100644 --- a/pkg/cmd/accounts/change_password_mongodb.go +++ b/pkg/cmd/accounts/change_password_mongodb.go @@ -1,12 +1,26 @@ package accounts import ( + "bytes" + "context" "encoding/json" + "errors" "fmt" "strings" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8stypes "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" + + "github.com/apecloud/kubeblocks/pkg/constant" ) -const mongoPasswordShellCommand = `if command -v mongosh >/dev/null 2>&1; then +const ( + mongoPasswordShellCommand = `if command -v mongosh >/dev/null 2>&1; then exec mongosh --host 127.0.0.1 --port 27017 --quiet elif command -v mongo >/dev/null 2>&1; then exec mongo --host 127.0.0.1 --port 27017 --quiet @@ -15,6 +29,9 @@ else exit 127 fi ` + defaultMongoAccountRootSecretWaitTimeout = 30 * time.Second + mongoAccountRootSecretPollInterval = time.Second +) type mongoPasswordChanger struct { config passwordChangerConfig @@ -31,6 +48,74 @@ func (c *mongoPasswordChanger) ChangePassword(request passwordChangeRequest) err return executePasswordCommand(c.config, request.PodName, command, strings.NewReader(script), true, "MongoDB client") } +func (o *ChangePasswordOptions) waitForMongoAccountRootSecret(targetSecret *corev1.Secret) error { + if !isMongoAccountRootSecret(o.CharType, o.ClusterName, o.ComponentName, o.UserName, targetSecret) { + return nil + } + timeout := o.AccountRootSecretWaitTimeout + if timeout <= 0 { + timeout = defaultMongoAccountRootSecretWaitTimeout + } + return waitForMongoAccountRootSecretRecreated( + context.Background(), + o.kube(), + o.Namespace, + targetSecret.Name, + targetSecret.UID, + []byte(o.Password), + timeout, + mongoAccountRootSecretPollInterval, + ) +} + +func isMongoAccountRootSecret(characterType, cluster, component, user string, secret *corev1.Secret) bool { + return isMongoCharacterType(characterType) && + strings.EqualFold(user, defaultMongoPasswordUser) && + secret != nil && + secret.Name == constant.GenerateAccountSecretName(cluster, component, defaultMongoPasswordUser) +} + +func isMongoCharacterType(characterType string) bool { + return strings.EqualFold(characterType, "mongo") || strings.EqualFold(characterType, "mongodb") +} + +func waitForMongoAccountRootSecretRecreated(ctx context.Context, client kubernetes.Interface, namespace, name string, oldUID k8stypes.UID, expectedPassword []byte, timeout, pollInterval time.Duration) error { + if oldUID == "" { + return fmt.Errorf("account-root Secret %s/%s has no UID; cannot verify recreation", namespace, name) + } + if timeout <= 0 { + timeout = defaultMongoAccountRootSecretWaitTimeout + } + if pollInterval <= 0 { + pollInterval = mongoAccountRootSecretPollInterval + } + + err := wait.PollUntilContextTimeout(ctx, pollInterval, timeout, true, func(ctx context.Context) (bool, error) { + secret, err := client.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{}) + switch { + case apierrors.IsNotFound(err): + return false, nil + case err != nil: + return false, err + } + if secret.DeletionTimestamp != nil || secret.UID == oldUID { + return false, nil + } + return bytes.Equal(secret.Data[constant.AccountPasswdForSecret], expectedPassword), nil + }) + if err == nil { + return nil + } + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("account-root Secret %s/%s was not deleted and recreated within %s", namespace, name, timeout) + } + return fmt.Errorf("wait for account-root Secret %s/%s to be deleted and recreated: %w", namespace, name, err) +} + +func mongoAccountRootSecretFinalizerPatchCommand(namespace, name string) string { + return fmt.Sprintf("kubectl patch secret %s -n %s --type=merge -p '{\"metadata\":{\"finalizers\":[]}}'", name, namespace) +} + func buildMongoPasswordScript(database, authUser, authPassword, targetUser, newPassword string) string { return fmt.Sprintf( "var admin = db.getSiblingDB(\"admin\"); var ping = admin.runCommand({ping: 1}); if (!ping || ping.ok !== 1) { printjson(ping); throw new Error(\"MongoDB health check failed: ping\"); } var hello = admin.runCommand({hello: 1}); if (!hello || hello.ok !== 1) { hello = admin.runCommand({isMaster: 1}); } if (!hello || hello.ok !== 1 || (hello.isWritablePrimary !== true && hello.ismaster !== true)) { printjson(hello); throw new Error(\"MongoDB is not a writable primary\"); } var auth = db.getSiblingDB(%s).auth(%s, %s); if (!auth) { throw new Error(\"authentication failed\"); } var result = db.getSiblingDB(%s).runCommand({updateUser: %s, pwd: %s}); if (!result || result.ok !== 1) { printjson(result); throw new Error(\"updateUser failed\"); }", diff --git a/pkg/cmd/accounts/change_password_test.go b/pkg/cmd/accounts/change_password_test.go index 0d953489e..d0f7d741f 100644 --- a/pkg/cmd/accounts/change_password_test.go +++ b/pkg/cmd/accounts/change_password_test.go @@ -25,12 +25,16 @@ import ( "io" "strings" "testing" + "time" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/cli-runtime/pkg/genericiooptions" "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" appsv1alpha1 "github.com/apecloud/kubeblocks/apis/apps/v1alpha1" "github.com/apecloud/kubeblocks/pkg/constant" @@ -267,6 +271,76 @@ func TestChangePasswordRunDeletesImmutableTargetSecret(t *testing.T) { } } +func TestChangePasswordRunWarnsWhenMongoAccountRootSecretDeletionSticks(t *testing.T) { + connectionSecret := passwordTestSecret("mongo-conn-credential", "root", "old-password") + accountSecret := passwordTestSecret("mongo-mongodb-account-root", "root", "old-password") + accountSecret.Immutable = boolptr(true) + accountSecret.UID = "old-account-root" + client := fake.NewSimpleClientset(connectionSecret, accountSecret) + changer := &recordingPasswordChanger{} + o := newTestChangePasswordOptions(client, connectionSecret, changer) + o.CharType = "mongodb" + o.ClusterName = "mongo" + o.ComponentName = "mongodb" + o.UserName = "root" + o.targetSecrets = []*corev1.Secret{connectionSecret, accountSecret} + o.oldPasswords = map[string]string{ + connectionSecret.Name: "old-password", + accountSecret.Name: "old-password", + } + o.AccountRootSecretWaitTimeout = 5 * time.Millisecond + var output bytes.Buffer + o.Out = &output + + if err := o.Run(); err != nil { + t.Fatalf("ChangePasswordOptions.Run() error = %v", err) + } + if !strings.Contains(output.String(), "password changed successfully") { + t.Fatalf("output = %q, want success message", output.String()) + } + if !strings.Contains(output.String(), "kubectl patch secret mongo-mongodb-account-root -n default") { + t.Fatalf("output = %q, want account-root finalizer patch command", output.String()) + } +} + +func TestWaitForMongoAccountRootSecretRecreated(t *testing.T) { + oldSecret := passwordTestSecret("mongo-mongodb-account-root", "root", "old-password") + oldSecret.UID = "old-account-root" + newSecret := oldSecret.DeepCopy() + newSecret.UID = "new-account-root" + newSecret.Data[constant.AccountPasswdForSecret] = []byte("new-password") + client := fake.NewSimpleClientset(oldSecret) + getCount := 0 + client.PrependReactor("get", "secrets", func(_ k8stesting.Action) (bool, runtime.Object, error) { + getCount++ + if getCount == 1 { + return true, nil, apierrors.NewNotFound(schema.GroupResource{Resource: "secrets"}, oldSecret.Name) + } + return true, newSecret, nil + }) + + if err := waitForMongoAccountRootSecretRecreated(context.Background(), client, "default", oldSecret.Name, oldSecret.UID, []byte("new-password"), time.Second, time.Millisecond); err != nil { + t.Fatalf("waitForMongoAccountRootSecretRecreated() error = %v", err) + } +} + +func TestWaitForMongoAccountRootSecretRecreatedTimesOut(t *testing.T) { + secret := passwordTestSecret("mongo-mongodb-account-root", "root", "old-password") + secret.UID = "old-account-root" + now := metav1.Now() + secret.DeletionTimestamp = &now + client := fake.NewSimpleClientset(secret) + + err := waitForMongoAccountRootSecretRecreated(context.Background(), client, "default", secret.Name, secret.UID, []byte("new-password"), 5*time.Millisecond, time.Millisecond) + if err == nil || !strings.Contains(err.Error(), "was not deleted and recreated") { + t.Fatalf("waitForMongoAccountRootSecretRecreated() error = %v, want timeout error", err) + } +} + +func boolptr(value bool) *bool { + return &value +} + func TestChangePasswordRunRollsBackWhenSecretUpdateFails(t *testing.T) { client := fake.NewSimpleClientset() secret := passwordTestSecret("target", "appuser", "old-password") From 729f3eed94fda54b0a43877ac9291a5d16a3c12d Mon Sep 17 00:00:00 2001 From: Yun Pan Date: Wed, 11 Nov 2026 13:20:27 +0000 Subject: [PATCH 5/6] feat(accounts): improve password rotation handling --- .../cli/kbcli_cluster_change-password.md | 2 +- hack/rotate-postgres-passwords.sh | 421 ++++++++++++++++++ pkg/cmd/accounts/change_password.go | 4 + pkg/cmd/accounts/change_password_mongodb.go | 52 +++ pkg/cmd/accounts/change_password_test.go | 30 ++ 5 files changed, 508 insertions(+), 1 deletion(-) create mode 100755 hack/rotate-postgres-passwords.sh diff --git a/docs/user_docs/cli/kbcli_cluster_change-password.md b/docs/user_docs/cli/kbcli_cluster_change-password.md index e26ebe752..ba53448bd 100644 --- a/docs/user_docs/cli/kbcli_cluster_change-password.md +++ b/docs/user_docs/cli/kbcli_cluster_change-password.md @@ -4,7 +4,7 @@ title: kbcli cluster change-password Change a database account password -When automatically discovered account Secrets are immutable, kbcli deletes them after updating mutable credential Secrets so KubeBlocks can recreate them with the new password. For MongoDB, kbcli waits up to 30 seconds for the immutable `--account-root` Secret to be deleted and recreated with the new password. If deletion remains pending, kbcli still reports the password change as successful and prints a `kubectl patch secret ...` command for manually removing the Secret finalizer. +When automatically discovered account Secrets are immutable, kbcli deletes them after updating mutable credential Secrets so KubeBlocks can recreate them with the new password. For MongoDB, if only the immutable `--account-root` Secret exists, kbcli creates or updates the legacy `-conn-credential` Secret as KubeBlocks' recreation source. It then waits up to 30 seconds for the account-root Secret to be deleted and recreated with the new password. If deletion remains pending, kbcli still reports the password change as successful and prints a `kubectl patch secret ...` command for manually removing the Secret finalizer. The cluster and selected component must be in the `Running` phase. For PostgreSQL, kbcli verifies that the server is not in recovery mode before changing the password. For MongoDB, kbcli checks connectivity and verifies that the selected server is a writable primary. diff --git a/hack/rotate-postgres-passwords.sh b/hack/rotate-postgres-passwords.sh new file mode 100755 index 000000000..8c9525c65 --- /dev/null +++ b/hack/rotate-postgres-passwords.sh @@ -0,0 +1,421 @@ +#!/usr/bin/env bash +# +# Copyright (C) 2022-2024 ApeCloud Co., Ltd +# +# 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. + +set -o errexit +set -o nounset +set -o pipefail + +CLUSTER_RESOURCE="clusters.apps.kubeblocks.io" +COMPONENT_RESOURCE="components.apps.kubeblocks.io" +CLUSTER_DEFINITION_RESOURCE="clusterdefinitions.apps.kubeblocks.io" +COMPONENT_DEFINITION_RESOURCE="componentdefinitions.apps.kubeblocks.io" + +NAMESPACE="" +CONTEXT="" +USERNAME="postgres" +PASSWORD_BYTES=24 +PASSWORD_FILE="" +DRY_RUN=false +RUNNING_ONLY=false +CONTINUE_ON_ERROR=false +KUBECTL="kubectl" +KBCLI="kbcli" +OPENSSL="openssl" +JQ="jq" + +WORK_DIR="" +RESULT_TMP="" + +show_help() { + cat <<'SHOW_HELP_EOF' +Usage: rotate-postgres-passwords.sh [options] + +Rotate the PostgreSQL account password for every PostgreSQL component managed +by KubeBlocks in a Kubernetes cluster. A unique password is generated for each +target and recorded in a local TSV file with mode 0600. + +Options: + -n, --namespace NAME Only scan this namespace. By default all namespaces + are scanned. + --context NAME Kubernetes context used by kubectl and kbcli. + --username NAME PostgreSQL account to rotate (default: postgres). + --password-bytes N Random bytes used for each hexadecimal password + (default: 24; allowed range: 16-128). + --password-file FILE Output TSV file. Default: a new file under TMPDIR. + --kubectl PATH kubectl executable (default: kubectl). + --kbcli PATH kbcli executable (default: kbcli). + --openssl PATH openssl executable (default: openssl). + --jq PATH jq executable (default: jq). + --dry-run Print discovered targets without changing passwords. + --running-only Only include components whose Cluster is Running. + --continue-on-error Continue after a failed rotation. + -h, --help Show this help. + +The output TSV has this header: + + namespaceclustercomponentusernamepasswordresult + +Passwords are written before kbcli is invoked. If kbcli fails after changing +the database but before returning success, the result remains check-manually; +keep the generated password until the target has been inspected. + +Targets are discovered from resolved Component resources and ComponentDefinition +serviceKind values. For older KubeBlocks versions, the script falls back to +Cluster componentSpecs and ClusterDefinition characterType values. +SHOW_HELP_EOF +} + +die() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +warn() { + printf 'warning: %s\n' "$*" >&2 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || die "executable not found: $1" +} + +cleanup() { + if [[ -n "$WORK_DIR" ]]; then + rm -rf -- "$WORK_DIR" + fi + if [[ -n "$RESULT_TMP" ]]; then + rm -f -- "$RESULT_TMP" + fi +} + +while (($# > 0)); do + case "$1" in + -n|--namespace) + [[ $# -ge 2 ]] || die "--namespace requires a value" + NAMESPACE=$2 + shift 2 + ;; + --namespace=*) + NAMESPACE=${1#*=} + shift + ;; + --context) + [[ $# -ge 2 ]] || die "--context requires a value" + CONTEXT=$2 + shift 2 + ;; + --context=*) + CONTEXT=${1#*=} + shift + ;; + --username) + [[ $# -ge 2 ]] || die "--username requires a value" + USERNAME=$2 + shift 2 + ;; + --username=*) + USERNAME=${1#*=} + shift + ;; + --password-bytes) + [[ $# -ge 2 ]] || die "--password-bytes requires a value" + PASSWORD_BYTES=$2 + shift 2 + ;; + --password-bytes=*) + PASSWORD_BYTES=${1#*=} + shift + ;; + --password-file) + [[ $# -ge 2 ]] || die "--password-file requires a value" + PASSWORD_FILE=$2 + shift 2 + ;; + --password-file=*) + PASSWORD_FILE=${1#*=} + shift + ;; + --kubectl) + [[ $# -ge 2 ]] || die "--kubectl requires a value" + KUBECTL=$2 + shift 2 + ;; + --kubectl=*) + KUBECTL=${1#*=} + shift + ;; + --kbcli) + [[ $# -ge 2 ]] || die "--kbcli requires a value" + KBCLI=$2 + shift 2 + ;; + --kbcli=*) + KBCLI=${1#*=} + shift + ;; + --openssl) + [[ $# -ge 2 ]] || die "--openssl requires a value" + OPENSSL=$2 + shift 2 + ;; + --openssl=*) + OPENSSL=${1#*=} + shift + ;; + --jq) + [[ $# -ge 2 ]] || die "--jq requires a value" + JQ=$2 + shift 2 + ;; + --jq=*) + JQ=${1#*=} + shift + ;; + --dry-run) + DRY_RUN=true + shift + ;; + --running-only) + RUNNING_ONLY=true + shift + ;; + --continue-on-error) + CONTINUE_ON_ERROR=true + shift + ;; + -h|--help) + show_help + exit 0 + ;; + --) + shift + break + ;; + -*) + die "unknown option: $1" + ;; + *) + die "unexpected argument: $1" + ;; + esac +done + +if (($# > 0)); then + die "unexpected argument: $1" +fi + +[[ -n "$USERNAME" ]] || die "username must not be empty" +[[ "$USERNAME" != *$'\t'* && "$USERNAME" != *$'\n'* && "$USERNAME" != *$'\r'* ]] || + die "username must not contain tabs or newlines" +[[ "$PASSWORD_BYTES" =~ ^[0-9]+$ ]] || die "password bytes must be an integer" +((PASSWORD_BYTES >= 16 && PASSWORD_BYTES <= 128)) || + die "password bytes must be between 16 and 128" + +require_command "$KUBECTL" +require_command "$JQ" +if [[ "$DRY_RUN" == false ]]; then + require_command "$KBCLI" + require_command "$OPENSSL" +fi + +umask 077 +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/rotate-postgres-passwords.XXXXXX") +trap cleanup EXIT +trap 'exit 130' HUP INT TERM + +CONTEXT_ARGS=() +NAMESPACE_ARGS=(-A) +if [[ -n "$CONTEXT" ]]; then + CONTEXT_ARGS=(--context "$CONTEXT") +fi +if [[ -n "$NAMESPACE" ]]; then + NAMESPACE_ARGS=(-n "$NAMESPACE") +fi + +fetch_resource() { + local resource=$1 + local output=$2 + + "$KUBECTL" "${CONTEXT_ARGS[@]}" get "$resource" "${NAMESPACE_ARGS[@]}" -o json >"$output" + "$JQ" -e '(.items | type) == "array"' "$output" >/dev/null +} + +CLUSTERS_JSON="$WORK_DIR/clusters.json" +COMPONENTS_JSON="$WORK_DIR/components.json" +CLUSTER_DEFINITIONS_JSON="$WORK_DIR/cluster-definitions.json" +COMPONENT_DEFINITIONS_JSON="$WORK_DIR/component-definitions.json" + +fetch_resource "$CLUSTER_RESOURCE" "$CLUSTERS_JSON" || + die "failed to list $CLUSTER_RESOURCE" + +COMPONENTS_OK=true +COMPONENT_DEFINITIONS_OK=true +CLUSTER_DEFINITIONS_OK=true +if ! fetch_resource "$COMPONENT_RESOURCE" "$COMPONENTS_JSON"; then + COMPONENTS_OK=false + printf '{"items":[]}\n' >"$COMPONENTS_JSON" + warn "failed to list $COMPONENT_RESOURCE; trying Cluster-based discovery" +fi +if ! fetch_resource "$COMPONENT_DEFINITION_RESOURCE" "$COMPONENT_DEFINITIONS_JSON"; then + COMPONENT_DEFINITIONS_OK=false + printf '{"items":[]}\n' >"$COMPONENT_DEFINITIONS_JSON" + warn "failed to list $COMPONENT_DEFINITION_RESOURCE" +fi +if ! fetch_resource "$CLUSTER_DEFINITION_RESOURCE" "$CLUSTER_DEFINITIONS_JSON"; then + CLUSTER_DEFINITIONS_OK=false + printf '{"items":[]}\n' >"$CLUSTER_DEFINITIONS_JSON" + warn "failed to list $CLUSTER_DEFINITION_RESOURCE" +fi + +if [[ "$COMPONENTS_OK" == false || "$COMPONENT_DEFINITIONS_OK" == false ]] && + [[ "$CLUSTER_DEFINITIONS_OK" == false ]]; then + die "neither ComponentDefinition-based nor legacy ClusterDefinition-based discovery is available" +fi + +TARGETS_FILE="$WORK_DIR/targets.tsv" +"$JQ" -rn \ + --slurpfile clusters "$CLUSTERS_JSON" \ + --slurpfile components "$COMPONENTS_JSON" \ + --slurpfile clusterDefinitions "$CLUSTER_DEFINITIONS_JSON" \ + --slurpfile componentDefinitions "$COMPONENT_DEFINITIONS_JSON" \ + --argjson runningOnly "$RUNNING_ONLY" ' + def clusterIsRunning($namespace; $clusterName): + any($clusters[0].items[]; + .metadata.namespace == $namespace and + .metadata.name == $clusterName and + .status.phase == "Running"); + + [ + $components[0].items[] + | .metadata.namespace as $namespace + | (.metadata.labels["app.kubernetes.io/instance-name"] // "") as $clusterName + | (.metadata.labels["apps.kubeblocks.io/component-name"] // "") as $componentName + | (.metadata.labels["apps.kubeblocks.io/component-def-ref"] // "") as $componentDefinition + | select($clusterName != "" and $componentName != "" and $componentDefinition != "") + | select(any($componentDefinitions[0].items[]; + .metadata.name == $componentDefinition and + ((.spec.serviceKind // "" | ascii_downcase) == "postgresql"))) + | select(($runningOnly | not) or clusterIsRunning($namespace; $clusterName)) + | {namespace: $namespace, cluster: $clusterName, component: $componentName} + ] + + + [ + $clusters[0].items[] + | .metadata.namespace as $namespace + | .metadata.name as $clusterName + | .spec.clusterDefinitionRef as $clusterDefinition + | .spec.componentSpecs[]? + | (.componentDef // "") as $componentDefinition + | (.name // "") as $componentName + | select($componentDefinition != "" and $componentName != "") + | select(any($componentDefinitions[0].items[]; + .metadata.name == $componentDefinition and + ((.spec.serviceKind // "" | ascii_downcase) == "postgresql"))) + | select(($runningOnly | not) or clusterIsRunning($namespace; $clusterName)) + | {namespace: $namespace, cluster: $clusterName, component: $componentName} + ] + + + [ + $clusters[0].items[] + | .metadata.namespace as $namespace + | .metadata.name as $clusterName + | .spec.clusterDefinitionRef as $clusterDefinition + | .spec.componentSpecs[]? + | (.componentDefRef // "") as $componentDefinition + | (.name // "") as $componentName + | select($clusterDefinition != null and $componentDefinition != "" and $componentName != "") + | select(any($clusterDefinitions[0].items[]; + .metadata.name == $clusterDefinition and + any(.spec.componentDefs[]?; + (.name // "") == $componentDefinition and + ((.characterType // "" | ascii_downcase) == "postgresql")))) + | select(($runningOnly | not) or clusterIsRunning($namespace; $clusterName)) + | {namespace: $namespace, cluster: $clusterName, component: $componentName} + ] + | unique_by([.namespace, .cluster, .component]) + | sort_by([.namespace, .cluster, .component])[] + | [.namespace, .cluster, .component] + | @tsv +' >"$TARGETS_FILE" + +TARGET_COUNT=$(wc -l <"$TARGETS_FILE" | tr -d '[:space:]') +if [[ "$DRY_RUN" == true ]]; then + printf 'namespace\tcluster\tcomponent\n' + cat "$TARGETS_FILE" + printf 'Discovered %s PostgreSQL component(s); no passwords were changed.\n' "$TARGET_COUNT" + exit 0 +fi + +if ((TARGET_COUNT == 0)); then + printf 'No PostgreSQL components were found; no passwords were changed.\n' + exit 0 +fi + +if [[ -z "$PASSWORD_FILE" ]]; then + PASSWORD_FILE=$(mktemp "${TMPDIR:-/tmp}/rotate-postgres-passwords.XXXXXX") +else + if [[ -e "$PASSWORD_FILE" || -L "$PASSWORD_FILE" ]]; then + die "password file already exists: $PASSWORD_FILE" + fi + : >"$PASSWORD_FILE" +fi +chmod 0600 "$PASSWORD_FILE" +RESULT_TMP="${PASSWORD_FILE}.tmp.$$" +printf 'namespace\tcluster\tcomponent\tusername\tpassword\tresult\n' >"$PASSWORD_FILE" + +printf 'Rotating %d PostgreSQL password(s). Results: %s\n' "$TARGET_COUNT" "$PASSWORD_FILE" +FAILURES=0 +LINE_NUMBER=2 +while IFS=$'\t' read -r NAMESPACE_NAME CLUSTER_NAME COMPONENT_NAME; do + PASSWORD=$("$OPENSSL" rand -hex "$PASSWORD_BYTES") + [[ "$PASSWORD" =~ ^[0-9a-fA-F]+$ ]] || die "openssl returned an invalid password" + + printf '%s\t%s\t%s\t%s\t%s\tpending\n' \ + "$NAMESPACE_NAME" "$CLUSTER_NAME" "$COMPONENT_NAME" "$USERNAME" "$PASSWORD" \ + >>"$PASSWORD_FILE" + + printf 'Rotating %s/%s/%s...\n' "$NAMESPACE_NAME" "$CLUSTER_NAME" "$COMPONENT_NAME" + KBCLI_ARGS=("${CONTEXT_ARGS[@]}" --namespace "$NAMESPACE_NAME") + if "$KBCLI" "${KBCLI_ARGS[@]}" cluster change-password "$CLUSTER_NAME" \ + --component "$COMPONENT_NAME" \ + --name "$USERNAME" \ + --password "$PASSWORD"; then + awk -F '\t' -v OFS='\t' -v lineNumber="$LINE_NUMBER" -v result=rotated ' + NR == lineNumber {$6 = result} + {print} + ' "$PASSWORD_FILE" >"$RESULT_TMP" + mv -f "$RESULT_TMP" "$PASSWORD_FILE" + else + awk -F '\t' -v OFS='\t' -v lineNumber="$LINE_NUMBER" -v result=check-manually ' + NR == lineNumber {$6 = result} + {print} + ' "$PASSWORD_FILE" >"$RESULT_TMP" + mv -f "$RESULT_TMP" "$PASSWORD_FILE" + FAILURES=$((FAILURES + 1)) + printf 'Rotation failed for %s/%s/%s; inspect it before retrying. The generated password is recorded as check-manually.\n' \ + "$NAMESPACE_NAME" "$CLUSTER_NAME" "$COMPONENT_NAME" >&2 + if [[ "$CONTINUE_ON_ERROR" == false ]]; then + exit 1 + fi + fi + LINE_NUMBER=$((LINE_NUMBER + 1)) +done <"$TARGETS_FILE" + +if ((FAILURES > 0)); then + printf 'Completed with %d failure(s). Results: %s\n' "$FAILURES" "$PASSWORD_FILE" >&2 + exit 1 +fi + +printf 'Completed %d rotation(s). Results: %s\n' "$TARGET_COUNT" "$PASSWORD_FILE" diff --git a/pkg/cmd/accounts/change_password.go b/pkg/cmd/accounts/change_password.go index 0b47d2c21..7e8da0a94 100644 --- a/pkg/cmd/accounts/change_password.go +++ b/pkg/cmd/accounts/change_password.go @@ -289,6 +289,10 @@ func (o *ChangePasswordOptions) Run() error { } updatedSecrets = append(updatedSecrets, targetSecret) } + if err := o.ensureMongoAccountRootRecreationSource(context.Background(), immutableSecrets); err != nil { + o.printGeneralInfo("fail", err.Error()) + return fmt.Errorf("password changed, but failed to prepare MongoDB account-root Secret recreation: %w", err) + } deletedImmutableSecrets := make([]*corev1.Secret, 0, len(immutableSecrets)) stuckAccountRootSecrets := make([]*corev1.Secret, 0, len(immutableSecrets)) for _, targetSecret := range immutableSecrets { diff --git a/pkg/cmd/accounts/change_password_mongodb.go b/pkg/cmd/accounts/change_password_mongodb.go index 4f714db07..6e23005a8 100644 --- a/pkg/cmd/accounts/change_password_mongodb.go +++ b/pkg/cmd/accounts/change_password_mongodb.go @@ -68,6 +68,58 @@ func (o *ChangePasswordOptions) waitForMongoAccountRootSecret(targetSecret *core ) } +func (o *ChangePasswordOptions) ensureMongoAccountRootRecreationSource(ctx context.Context, immutableSecrets []*corev1.Secret) error { + for _, targetSecret := range immutableSecrets { + if !isMongoAccountRootSecret(o.CharType, o.ClusterName, o.ComponentName, o.UserName, targetSecret) { + continue + } + + secretName := constant.GenerateDefaultConnCredential(o.ClusterName) + secrets := o.kube().CoreV1().Secrets(o.Namespace) + sourceSecret, err := secrets.Get(ctx, secretName, metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + sourceSecret, err = secrets.Create(ctx, &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: o.Namespace, + Name: secretName, + Labels: map[string]string{ + constant.AppInstanceLabelKey: o.ClusterName, + constant.ClusterAccountLabelKey: defaultMongoPasswordUser, + }, + }, + Data: map[string][]byte{ + constant.AccountNameForSecret: []byte(defaultMongoPasswordUser), + constant.AccountPasswdForSecret: []byte(o.Password), + }, + }, metav1.CreateOptions{}) + if err == nil { + return nil + } + if !apierrors.IsAlreadyExists(err) { + return fmt.Errorf("create MongoDB legacy connection Secret %s/%s: %w", o.Namespace, secretName, err) + } + sourceSecret, err = secrets.Get(ctx, secretName, metav1.GetOptions{}) + } + if err != nil { + return fmt.Errorf("get MongoDB legacy connection Secret %s/%s: %w", o.Namespace, secretName, err) + } + if secretIsImmutable(sourceSecret) { + if bytes.Equal(sourceSecret.Data[constant.AccountPasswdForSecret], []byte(o.Password)) { + return nil + } + return fmt.Errorf("MongoDB legacy connection Secret %s/%s is immutable and does not contain the new password", o.Namespace, secretName) + } + if bytes.Equal(sourceSecret.Data[constant.AccountPasswdForSecret], []byte(o.Password)) { + return nil + } + if err := patchPasswordSecret(ctx, o.kube(), o.Namespace, secretName, []byte(o.Password)); err != nil { + return fmt.Errorf("update MongoDB legacy connection Secret %s/%s: %w", o.Namespace, secretName, err) + } + return nil + } + return nil +} + func isMongoAccountRootSecret(characterType, cluster, component, user string, secret *corev1.Secret) bool { return isMongoCharacterType(characterType) && strings.EqualFold(user, defaultMongoPasswordUser) && diff --git a/pkg/cmd/accounts/change_password_test.go b/pkg/cmd/accounts/change_password_test.go index d0f7d741f..74e0a5a0e 100644 --- a/pkg/cmd/accounts/change_password_test.go +++ b/pkg/cmd/accounts/change_password_test.go @@ -303,6 +303,36 @@ func TestChangePasswordRunWarnsWhenMongoAccountRootSecretDeletionSticks(t *testi } } +func TestChangePasswordRunSupportsOnlyImmutableMongoAccountRootSecret(t *testing.T) { + accountSecret := passwordTestSecret("mongo-mongodb-account-root", "root", "old-password") + accountSecret.Immutable = boolptr(true) + accountSecret.UID = "old-account-root" + client := fake.NewSimpleClientset(accountSecret) + changer := &recordingPasswordChanger{} + o := newTestChangePasswordOptions(client, accountSecret, changer) + o.CharType = "mongodb" + o.ClusterName = "mongo" + o.ComponentName = "mongodb" + o.UserName = "root" + o.targetSecrets = []*corev1.Secret{accountSecret} + o.oldPasswords = map[string]string{accountSecret.Name: "old-password"} + o.AccountRootSecretWaitTimeout = 5 * time.Millisecond + + if err := o.Run(); err != nil { + t.Fatalf("ChangePasswordOptions.Run() error = %v", err) + } + source, err := client.CoreV1().Secrets("default").Get(context.Background(), "mongo-conn-credential", metav1.GetOptions{}) + if err != nil { + t.Fatalf("get MongoDB legacy connection Secret: %v", err) + } + if string(source.Data[constant.AccountNameForSecret]) != "root" { + t.Fatalf("legacy connection username = %q, want %q", source.Data[constant.AccountNameForSecret], "root") + } + if string(source.Data[constant.AccountPasswdForSecret]) != "new-password" { + t.Fatalf("legacy connection password = %q, want %q", source.Data[constant.AccountPasswdForSecret], "new-password") + } +} + func TestWaitForMongoAccountRootSecretRecreated(t *testing.T) { oldSecret := passwordTestSecret("mongo-mongodb-account-root", "root", "old-password") oldSecret.UID = "old-account-root" From 16c701d434d6d7ca471ecba04e920778aa416788 Mon Sep 17 00:00:00 2001 From: Yun Pan Date: Wed, 11 Nov 2026 15:55:19 +0000 Subject: [PATCH 6/6] feat(accounts): support password rotation namespace scopes --- hack/rotate-postgres-passwords.sh | 34 ++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/hack/rotate-postgres-passwords.sh b/hack/rotate-postgres-passwords.sh index 8c9525c65..c9350baa6 100755 --- a/hack/rotate-postgres-passwords.sh +++ b/hack/rotate-postgres-passwords.sh @@ -24,6 +24,7 @@ CLUSTER_DEFINITION_RESOURCE="clusterdefinitions.apps.kubeblocks.io" COMPONENT_DEFINITION_RESOURCE="componentdefinitions.apps.kubeblocks.io" NAMESPACE="" +SCOPE="" CONTEXT="" USERNAME="postgres" PASSWORD_BYTES=24 @@ -48,8 +49,12 @@ by KubeBlocks in a Kubernetes cluster. A unique password is generated for each target and recorded in a local TSV file with mode 0600. Options: - -n, --namespace NAME Only scan this namespace. By default all namespaces - are scanned. + -n, --namespace NAME Only scan this namespace. Mutually exclusive with + --scope. + --scope SCOPE Namespace selection scope: all or system. all scans + every namespace (default); system scans namespaces + whose names do not start with ns-. Mutually + exclusive with --namespace. --context NAME Kubernetes context used by kubectl and kbcli. --username NAME PostgreSQL account to rotate (default: postgres). --password-bytes N Random bytes used for each hexadecimal password @@ -111,6 +116,15 @@ while (($# > 0)); do NAMESPACE=${1#*=} shift ;; + --scope) + [[ $# -ge 2 ]] || die "--scope requires a value" + SCOPE=$2 + shift 2 + ;; + --scope=*) + SCOPE=${1#*=} + shift + ;; --context) [[ $# -ge 2 ]] || die "--context requires a value" CONTEXT=$2 @@ -216,6 +230,11 @@ if (($# > 0)); then die "unexpected argument: $1" fi +[[ "$SCOPE" == "" || "$SCOPE" == "all" || "$SCOPE" == "system" ]] || + die "scope must be all or system" +[[ -z "$NAMESPACE" || -z "$SCOPE" ]] || + die "--namespace and --scope are mutually exclusive" +[[ -n "$SCOPE" ]] || SCOPE=all [[ -n "$USERNAME" ]] || die "username must not be empty" [[ "$USERNAME" != *$'\t'* && "$USERNAME" != *$'\n'* && "$USERNAME" != *$'\r'* ]] || die "username must not contain tabs or newlines" @@ -290,13 +309,19 @@ TARGETS_FILE="$WORK_DIR/targets.tsv" --slurpfile components "$COMPONENTS_JSON" \ --slurpfile clusterDefinitions "$CLUSTER_DEFINITIONS_JSON" \ --slurpfile componentDefinitions "$COMPONENT_DEFINITIONS_JSON" \ - --argjson runningOnly "$RUNNING_ONLY" ' + --argjson runningOnly "$RUNNING_ONLY" \ + --arg namespaceScope "$SCOPE" \ + ' def clusterIsRunning($namespace; $clusterName): any($clusters[0].items[]; .metadata.namespace == $namespace and .metadata.name == $clusterName and .status.phase == "Running"); + def namespaceMatchesScope($namespace): + $namespaceScope == "all" or + ($namespaceScope == "system" and (($namespace | startswith("ns-")) | not)); + [ $components[0].items[] | .metadata.namespace as $namespace @@ -304,6 +329,7 @@ TARGETS_FILE="$WORK_DIR/targets.tsv" | (.metadata.labels["apps.kubeblocks.io/component-name"] // "") as $componentName | (.metadata.labels["apps.kubeblocks.io/component-def-ref"] // "") as $componentDefinition | select($clusterName != "" and $componentName != "" and $componentDefinition != "") + | select(namespaceMatchesScope($namespace)) | select(any($componentDefinitions[0].items[]; .metadata.name == $componentDefinition and ((.spec.serviceKind // "" | ascii_downcase) == "postgresql"))) @@ -320,6 +346,7 @@ TARGETS_FILE="$WORK_DIR/targets.tsv" | (.componentDef // "") as $componentDefinition | (.name // "") as $componentName | select($componentDefinition != "" and $componentName != "") + | select(namespaceMatchesScope($namespace)) | select(any($componentDefinitions[0].items[]; .metadata.name == $componentDefinition and ((.spec.serviceKind // "" | ascii_downcase) == "postgresql"))) @@ -336,6 +363,7 @@ TARGETS_FILE="$WORK_DIR/targets.tsv" | (.componentDefRef // "") as $componentDefinition | (.name // "") as $componentName | select($clusterDefinition != null and $componentDefinition != "" and $componentName != "") + | select(namespaceMatchesScope($namespace)) | select(any($clusterDefinitions[0].items[]; .metadata.name == $clusterDefinition and any(.spec.componentDefs[]?;