diff --git a/docs/content/reference/pgo_show.md b/docs/content/reference/pgo_show.md index d0719915..7ed4d190 100644 --- a/docs/content/reference/pgo_show.md +++ b/docs/content/reference/pgo_show.md @@ -88,5 +88,5 @@ HA * [pgo](/reference/) - pgo is a kubectl plugin for PGO, the open source Postgres Operator * [pgo show backup](/reference/pgo_show_backup/) - Show backup information for a PostgresCluster * [pgo show ha](/reference/pgo_show_ha/) - Show 'patronictl list' for a PostgresCluster. -* [pgo show user](/reference/pgo_show_user/) - Show pguser Secret details for a PostgresCluster. +* [pgo show user](/reference/pgo_show_user/) - Show details for a PostgresCluster user. diff --git a/docs/content/reference/pgo_show_user.md b/docs/content/reference/pgo_show_user.md index dbbdc96a..69344176 100644 --- a/docs/content/reference/pgo_show_user.md +++ b/docs/content/reference/pgo_show_user.md @@ -3,11 +3,15 @@ title: pgo show user --- ## pgo show user -Show pguser Secret details for a PostgresCluster. +Show details for a PostgresCluster user. ### Synopsis -Show pguser Secret details for a PostgresCluster. +Show details for a PostgresCluster user. Only shows +details for the default user for a PostgresCluster +or for users defined on the PostgresCluster spec. +Use the "--show-connection-info" flag to get the +connection info, including password. #### RBAC Requirements Resources Verbs @@ -17,35 +21,46 @@ Show pguser Secret details for a PostgresCluster. ### Usage ``` -pgo show user CLUSTER_NAME [flags] +pgo show user USER_NAME --cluster CLUSTER_NAME [flags] ``` ### Examples ``` -# Show non-sensitive contents of 'pguser' Secret -pgo show user hippo +# Show non-sensitive contents of users for "hippo" cluster +pgo show user --cluster hippo -# Show contents of 'pguser' Secret, including sensitive fields -pgo show user hippo --show-sensitive-fields +# Show non-sensitive contents of user "rhino" for "hippo" cluster +pgo show user rhino --cluster hippo + +# Show connection info for user "rhino" for "hippo" cluster, +# including sensitive password info +pgo show user rhino --cluster hippo --show-connection-info ``` ### Example output ``` -pgo show user hippo -SECRET: hippo-pguser-hippo - DBNAME: hippo - HOST: hippo-primary.postgres-operator.svc - PORT: 5432 - USER: hippo - +# Showing all the users of the "hippo" cluster +CLUSTER USERNAME +hippo hippo +hippo rhino + +# Showing the connection info for user "hippo" of cluster "hippo" +WARNING: This command will show sensitive password information. +Are you sure you want to continue? (yes/no): yes + +Connection information for hippo for hippo cluster +Connection info string: + dbname=hippo host=hippo-primary.postgres-operator.svc port=5432 user=hippo password= +Connection URL: + postgres://@hippo-primary.postgres-operator.svc:5432/hippo ``` ### Options ``` - -h, --help help for user - -f, --show-sensitive-fields show sensitive user fields + -h, --help help for user + --show-connection-info show sensitive user fields ``` ### Options inherited from parent commands diff --git a/go.mod b/go.mod index f6a9cd7d..c2155043 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( k8s.io/apimachinery v0.24.3 k8s.io/cli-runtime v0.24.1 k8s.io/client-go v0.24.3 + k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 sigs.k8s.io/structured-merge-diff/v4 v4.2.1 sigs.k8s.io/yaml v1.3.0 ) @@ -68,7 +69,6 @@ require ( gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.60.1 // indirect k8s.io/kube-openapi v0.0.0-20220328201542-3ee0da9b0b42 // indirect - k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 // indirect sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2 // indirect sigs.k8s.io/kustomize/api v0.11.4 // indirect sigs.k8s.io/kustomize/kyaml v0.13.6 // indirect diff --git a/internal/cmd/show.go b/internal/cmd/show.go index 9f7730f4..b6229d04 100644 --- a/internal/cmd/show.go +++ b/internal/cmd/show.go @@ -15,20 +15,18 @@ package cmd import ( + "bytes" "context" - "encoding/base64" "fmt" "io" "os" - "sort" "strings" + "text/tabwriter" "github.com/spf13/cobra" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" v1 "k8s.io/client-go/kubernetes/typed/core/v1" - "k8s.io/utils/strings/slices" - "sigs.k8s.io/yaml" "github.com/crunchydata/postgres-operator-client/internal" "github.com/crunchydata/postgres-operator-client/internal/util" @@ -289,9 +287,13 @@ func showHA( func newShowUserCommand(config *internal.Config) *cobra.Command { cmdShowUser := &cobra.Command{ - Use: "user CLUSTER_NAME", - Short: "Show pguser Secret details for a PostgresCluster.", - Long: `Show pguser Secret details for a PostgresCluster. + Use: "user USER_NAME --cluster CLUSTER_NAME", + Short: "Show details for a PostgresCluster user.", + Long: `Show details for a PostgresCluster user. Only shows +details for the default user for a PostgresCluster +or for users defined on the PostgresCluster spec. +Use the "--show-connection-info" flag to get the +connection info, including password. #### RBAC Requirements Resources Verbs @@ -300,133 +302,182 @@ func newShowUserCommand(config *internal.Config) *cobra.Command { ### Usage`} - cmdShowUser.Example = internal.FormatExample(`# Show non-sensitive contents of 'pguser' Secret -pgo show user hippo + cmdShowUser.Example = internal.FormatExample(`# Show non-sensitive contents of users for "hippo" cluster +pgo show user --cluster hippo -# Show contents of 'pguser' Secret, including sensitive fields -pgo show user hippo --show-sensitive-fields +# Show non-sensitive contents of user "rhino" for "hippo" cluster +pgo show user rhino --cluster hippo + +# Show connection info for user "rhino" for "hippo" cluster, +# including sensitive password info +pgo show user rhino --cluster hippo --show-connection-info ### Example output -pgo show user hippo -SECRET: hippo-pguser-hippo - DBNAME: hippo - HOST: hippo-primary.postgres-operator.svc - PORT: 5432 - USER: hippo - `) +# Showing all the users of the "hippo" cluster +CLUSTER USERNAME +hippo hippo +hippo rhino + +# Showing the connection info for user "hippo" of cluster "hippo" +WARNING: This command will show sensitive password information. +Are you sure you want to continue? (yes/no): yes + +Connection information for hippo for hippo cluster +Connection info string: + dbname=hippo host=hippo-primary.postgres-operator.svc port=5432 user=hippo password= +Connection URL: + postgres://@hippo-primary.postgres-operator.svc:5432/hippo`) var fields bool - cmdShowUser.Flags().BoolVarP(&fields, "show-sensitive-fields", "f", false, "show sensitive user fields") + cmdShowUser.Flags().BoolVar(&fields, "show-connection-info", false, "show sensitive user fields") - // Limit the number of args, that is, only one cluster name - cmdShowUser.Args = cobra.ExactArgs(1) + var cluster string + cmdShowUser.Flags().StringVarP(&cluster, "cluster", "c", "", "Set the Postgres cluster name (required)") + cobra.CheckErr(cmdShowUser.MarkFlagRequired("cluster")) + + // Limit the number of args to at most one pguser name + cmdShowUser.Args = cobra.MaximumNArgs(1) // Define the 'show backup' command cmdShowUser.RunE = func(cmd *cobra.Command, args []string) error { - stdout, err := showUser(config, args, fields) + // configure client + rest, err := config.ToRESTConfig() + if err != nil { + return err + } + client, err := v1.NewForConfig(rest) if err != nil { return err } - cmd.Print(stdout) + secretList, err := showUsers(client, config, cluster, args) + if err != nil { + return err + } - return nil + // If no user info found, exit early + if len(secretList.Items) == 0 { + notFoundMessage := "No user information found for cluster " + cluster + if len(args) > 0 { + notFoundMessage = notFoundMessage + " / user " + args[0] + } + cmd.Print(notFoundMessage + "\n") + return nil + } + + // If user info found, print + return printUsers(cmd, secretList, fields, cluster) } return cmdShowUser } -// showUser returns a string with the decoded contents of the cluster's user Secrets. -func showUser(config *internal.Config, args []string, showSensitive bool) (string, error) { - - // break out keys based on whether sensitive information is included - var fields = []string{"dbname", "host", "pgbouncer-host", "pgbouncer-port", "port", "user"} - var sensitive = []string{"jdbc-uri", "password", "pgbouncer-jdbc-uri", "pgbouncer-uri", "uri", "verifier"} - - if showSensitive { - fields = append(fields, sensitive...) - - fmt.Print("WARNING: This command will show sensitive password information." + - "\nAre you sure you want to continue? (yes/no): ") - - var confirmed *bool - for i := 0; confirmed == nil && i < 10; i++ { - // retry 10 times or until a confirmation is given or denied, - // whichever comes first - confirmed = util.Confirm(os.Stdin, os.Stdout) - } - - if confirmed == nil || !*confirmed { - return "", nil - } - } - - // configure client +// showUsers returns a string with the decoded contents of the cluster's users' Secrets. +func showUsers(client *v1.CoreV1Client, + config *internal.Config, + cluster string, + args []string, +) (*corev1.SecretList, error) { ctx := context.Background() - rest, err := config.ToRESTConfig() - if err != nil { - return "", err - } - client, err := v1.NewForConfig(rest) - if err != nil { - return "", err - } // Get the namespace. This will either be from the Kubernetes configuration // or from the --namespace (-n) flag. configNamespace, err := config.Namespace() if err != nil { - return "", err + return nil, err } - list, err := client.Secrets(configNamespace).List(ctx, metav1.ListOptions{ - LabelSelector: util.PostgresUserSecretLabels(args[0]), - }) - if err != nil { - return "", err + // Set up the labels for listing the secrets; add the user label is present in args + labelSelector := util.PostgresUserSecretLabels(cluster) + if len(args) > 0 { + labelSelector = labelSelector + + ",postgres-operator.crunchydata.com/pguser=" + args[0] } - return userData(fields, list) + return client.Secrets(configNamespace).List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + }) } -// userData returns the requested user data from the provided Secret List. -// If the Secret List is empty, return a message stating that. -func userData(fields []string, list *corev1.SecretList) (string, error) { +func printUsers(cmd *cobra.Command, + secretList *corev1.SecretList, + showSensitive bool, + clusterName string, +) error { - var output string + // If the user is asking for connection strings, we use the alternate printer. + if showSensitive { + return printUserConnectionStrings(cmd, secretList, clusterName) + } - if len(list.Items) == 0 { - output += fmt.Sprintln("No user Secrets found.") + // Set up a tabwriter that writes to stdout + writer := tabwriter.NewWriter(cmd.OutOrStdout(), 10, 2, 2, ' ', 0) + if _, err := writer.Write([]byte("\nCLUSTER\tUSERNAME\n")); err != nil { + return err + } + for _, secret := range secretList.Items { + var buf bytes.Buffer + fmt.Fprintf(&buf, "%s\t%s\n", clusterName, string(secret.Data["user"])) + if _, err := writer.Write(buf.Bytes()); err != nil { + return err + } + } + if _, err := writer.Write([]byte("\n")); err != nil { + return err } - for _, secret := range list.Items { - output += fmt.Sprintf("SECRET: %s\n", secret.Name) + return writer.Flush() +} - // sort keys - keys := make([]string, 0, len(secret.Data)) - for k := range secret.Data { - keys = append(keys, k) - } - sort.Strings(keys) +func printUserConnectionStrings(cmd *cobra.Command, + secretList *corev1.SecretList, + clusterName string, +) error { + fmt.Print("WARNING: This command will show sensitive password information." + + "\nAre you sure you want to continue? (yes/no): ") + + var confirmed *bool + for i := 0; confirmed == nil && i < 10; i++ { + // retry 10 times or until a confirmation is given or denied, + // whichever comes first + confirmed = util.Confirm(os.Stdin, os.Stdout) + } - // decode and print keys and values from Secret - for _, k := range keys { - b, err := yaml.Marshal(secret.Data[k]) - if err != nil { - return output, err - } - d := make([]byte, base64.StdEncoding.EncodedLen(len(b))) - _, err = base64.StdEncoding.Decode(d, b) - if err != nil { - return output, err - } - if slices.Contains(fields, k) { - output += fmt.Sprintf(" %s: %s\n", strings.ToUpper(k), string(d)) - } + if confirmed == nil || !*confirmed { + return nil + } + + cmd.Println() + for _, secret := range secretList.Items { + cmd.Printf("Connection information for %s for %s cluster\n", string(secret.Data["user"]), clusterName) + cmd.Println("Connection info string:") + dbname := string(secret.Data["user"]) + if dbnameSet, ok := secret.Data["dbname"]; ok { + dbname = string(dbnameSet) + } + cmd.Println(" dbname=" + dbname + + " host=" + string(secret.Data["host"]) + + " port=" + string(secret.Data["port"]) + + " user=" + string(secret.Data["user"]) + + " password=" + string(secret.Data["password"])) + cmd.Println("Connection URL:") + cmd.Println(" postgres://" + string(secret.Data["user"]) + + ":" + string(secret.Data["password"]) + + "@" + string(secret.Data["host"]) + + ":" + string(secret.Data["port"]) + + "/" + dbname) + if uri, ok := secret.Data["pgbouncer-uri"]; ok { + cmd.Println("PgBouncer connection URL:") + cmd.Println(" " + string(uri)) + + cmd.Println("JDBC PgBouncer connection URL:") + cmd.Println(" " + string(secret.Data["pgbouncer-jdbc-uri"])) } + cmd.Println() } - return output, nil + + return nil } // getPrimaryExec returns a executor function for the primary Pod to allow for diff --git a/internal/util/util.go b/internal/util/util.go deleted file mode 100644 index 856c6644..00000000 --- a/internal/util/util.go +++ /dev/null @@ -1,55 +0,0 @@ -// Copyright 2021 - 2023 Crunchy Data Solutions, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package util - -import ( - "bufio" - "fmt" - "io" - - "k8s.io/utils/strings/slices" -) - -// Confirm uses a Scanner to parse user input. A user must type in "yes" or "no" -// and then press enter. It has fuzzy matching, so "y", "Y", "yes", "YES", -// and "Yes" all count as confirmations and return 'true'. Similarly, "n", "N", -// "no", "No", "NO" all deny confirmation and return 'false'. If the input is not -// recognized, nil is returned. -func Confirm(reader io.Reader, writer io.Writer) *bool { - var response string - var boolVar bool - - scanner := bufio.NewScanner(reader) - if scanner.Scan() { - response = scanner.Text() - } - - if scanner.Err() != nil || response == "" { - fmt.Fprint(writer, "Please type yes or no and then press enter: ") - return nil - } - - yesResponses := []string{"y", "Y", "yes", "Yes", "YES"} - noResponses := []string{"n", "N", "no", "No", "NO"} - if slices.Contains(yesResponses, response) { - boolVar = true - return &boolVar - } else if slices.Contains(noResponses, response) { - return &boolVar - } else { - fmt.Fprint(writer, "Please type yes or no and then press enter: ") - return nil - } -} diff --git a/internal/util/util_test.go b/internal/util/util_test.go deleted file mode 100644 index 3aeb9cfb..00000000 --- a/internal/util/util_test.go +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright 2021 - 2023 Crunchy Data Solutions, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package util - -import ( - "bytes" - "io" - "strings" - "testing" - - "gotest.tools/v3/assert" -) - -func TestConfirmDelete(t *testing.T) { - - testsCases := []struct { - input string - invalidResponse bool - confirmed bool - }{ - {"abc", true, false}, // invalid - {"", true, false}, // invalid - {"y", false, true}, - {"Y", false, true}, - {"yes", false, true}, - {"Yes", false, true}, - {"YES", false, true}, - {"n", false, false}, - {"N", false, false}, - {"no", false, false}, - {"No", false, false}, - {"NO", false, false}, - {"yep", true, false}, // invalid - {"nope", true, false}, // invalid - } - - for _, tc := range testsCases { - t.Run("input is "+tc.input, func(t *testing.T) { - var reader io.Reader = strings.NewReader(tc.input) - var writer bytes.Buffer - confirmed := Confirm(reader, &writer) - if tc.invalidResponse { - assert.Assert(t, confirmed == nil) - response, err := writer.ReadString(':') - assert.NilError(t, err) - assert.Equal(t, response, "Please type yes or no and then press enter:") - - } else { - assert.Assert(t, confirmed != nil) - assert.Assert(t, *confirmed == tc.confirmed) - } - }) - } -} diff --git a/testing/kuttl/e2e/show/08--show-user.yaml b/testing/kuttl/e2e/show/08--show-user.yaml index 0bb1e86f..78e9564f 100644 --- a/testing/kuttl/e2e/show/08--show-user.yaml +++ b/testing/kuttl/e2e/show/08--show-user.yaml @@ -10,7 +10,7 @@ commands: ) CLI_USER=$( - kubectl-pgo --namespace "${NAMESPACE}" show user show-cluster + kubectl-pgo --namespace "${NAMESPACE}" show user --cluster show-cluster ) status=$? @@ -20,24 +20,31 @@ commands: fi # expected output - SHOW_USER_OUTPUT="SECRET: show-cluster-pguser-show-cluster - DBNAME: show-cluster - HOST: show-cluster-primary.${NAMESPACE}.svc - PORT: 5432 - USER: show-cluster" + SHOW_USER_OUTPUT=" + CLUSTER USERNAME + show-cluster show-cluster" # check command output is not empty and equals the expected output - if [[ -z $CLI_USER && "$CLI_USER" != "$SHOW_USER_OUTPUT" ]]; then + if [[ -z ${CLI_USER} || (! -z ${CLI_USER} && "${CLI_USER}" != "${SHOW_USER_OUTPUT}") ]]; then + echo "pgo command output unexpected: expected ${SHOW_USER_OUTPUT} got ${CLI_USER}" exit 1 fi CLI_USER_SENSITIVE=$( - echo yes | kubectl-pgo --namespace "${NAMESPACE}" show user show-cluster --show-sensitive-fields + echo yes | kubectl-pgo --namespace "${NAMESPACE}" show user --cluster show-cluster show-cluster --show-connection-info ) - # check command output is not empty and contains the password field - if [[ -n $CLI_USER_SENSITIVE && "$CLI_USER_SENSITIVE" == *"PASSWORD:"* ]]; then + SECRET_DATA=$(kubectl get secret -n "${NAMESPACE}" show-cluster-pguser-show-cluster -o jsonpath={.data}) + + PASSWORD=$(echo "${SECRET_DATA}" | jq -r .password | base64 -d) + USER=$(echo "${SECRET_DATA}" | jq -r .user | base64 -d) + HOST=$(echo "${SECRET_DATA}" | jq -r .host | base64 -d) + PORT=$(echo "${SECRET_DATA}" | jq -r .port | base64 -d) + + # check command output is not empty and contains the connection URL field + if [[ -n $CLI_USER_SENSITIVE && "$CLI_USER_SENSITIVE" == *"postgres://${USER}:${PASSWORD}@${HOST}:${PORT}/show-cluster"* ]]; then exit 0 fi + echo "pgo command output for connection info unexpected: got ${CLI_USER_SENSITIVE}" exit 1