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..ba53448bd --- /dev/null +++ b/docs/user_docs/cli/kbcli_cluster_change-password.md @@ -0,0 +1,72 @@ +--- +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, 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. + +``` +kbcli cluster change-password [flags] +``` + +### Examples + +``` + # 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 + 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. + --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. + -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 Specify the new password; prompts interactively when omitted. + --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/hack/rotate-postgres-passwords.sh b/hack/rotate-postgres-passwords.sh new file mode 100755 index 000000000..c9350baa6 --- /dev/null +++ b/hack/rotate-postgres-passwords.sh @@ -0,0 +1,449 @@ +#!/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="" +SCOPE="" +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. 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 + (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 + ;; + --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 + 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 + +[[ "$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" +[[ "$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" \ + --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 + | (.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(namespaceMatchesScope($namespace)) + | 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(namespaceMatchesScope($namespace)) + | 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(namespaceMatchesScope($namespace)) + | 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 new file mode 100644 index 000000000..7e8da0a94 --- /dev/null +++ b/pkg/cmd/accounts/change_password.go @@ -0,0 +1,710 @@ +/* +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" + "os" + "sort" + "strings" + "time" + + "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" + "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" + + 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" +) + +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 + AccountRootSecretWaitTimeout time.Duration + + targetSecret *corev1.Secret + targetSecrets []*corev1.Secret + authSecret *corev1.Secret + oldPassword string + oldPasswords map[string]string + authUser string + authPassword string + + kubeClient kubernetes.Interface + passwordChanger passwordChanger + readPassword func() ([]byte, error) +} + +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", "", "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().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.") +} + +func (o *ChangePasswordOptions) Validate() error { + 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 { + 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") + } + if o.AccountRootSecretWaitTimeout < 0 { + return errors.New("account-root-secret-timeout cannot be negative") + } + return nil +} + +func (o *ChangePasswordOptions) Complete() error { + explicitInstance := o.PodName != "" + if err := o.AccountBaseOptions.Complete(); err != nil { + return err + } + o.setDatabaseDefaults() + if err := o.validateComponentStatus(); err != nil { + return err + } + + 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) 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 { + 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) + } + 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 { + 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(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") + } + 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 +} + +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 + } + 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 + 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 isMongoCharacterType(characterType) { + return defaultMongoPasswordUser + } + return defaultPasswordUser +} + +func defaultPasswordDatabaseForCharacterType(characterType string) string { + if isMongoCharacterType(characterType) { + 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..6e23005a8 --- /dev/null +++ b/pkg/cmd/accounts/change_password_mongodb.go @@ -0,0 +1,186 @@ +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 + 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 +` + defaultMongoAccountRootSecretWaitTimeout = 30 * time.Second + mongoAccountRootSecretPollInterval = time.Second +) + +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 (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 (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) && + 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\"); }", + 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..9119e1323 --- /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;\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 { + 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..74e0a5a0e --- /dev/null +++ b/pkg/cmd/accounts/change_password_test.go @@ -0,0 +1,485 @@ +/* +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" + "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" +) + +func TestBuildPostgresPasswordSQL(t *testing.T) { + got := buildPostgresPasswordSQL(`role"name`, `p'ass\word`) + 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) + } +} + +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"); }` + 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"), + 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 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 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" + 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") + 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..790fa96f4 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(` + # 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 + 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),