From d1535cd84ff16b64d4dbc4c0994461f54ad69218 Mon Sep 17 00:00:00 2001 From: TJ Moore Date: Mon, 30 Oct 2023 16:48:50 -0400 Subject: [PATCH 1/4] Add show user command Adds a command to show the contents of the pguser Secrets. Issue: PGO-470 --- docs/content/reference/pgo_show.md | 1 + docs/content/reference/pgo_show_user.md | 76 +++++++++ internal/cmd/show.go | 154 +++++++++++++++++- internal/util/naming.go | 9 + testing/kuttl/e2e/show/08--show-user.yaml | 43 +++++ ...bined-show.yaml => 09--combined-show.yaml} | 0 6 files changed, 281 insertions(+), 2 deletions(-) create mode 100644 docs/content/reference/pgo_show_user.md create mode 100644 testing/kuttl/e2e/show/08--show-user.yaml rename testing/kuttl/e2e/show/{08--combined-show.yaml => 09--combined-show.yaml} (100%) diff --git a/docs/content/reference/pgo_show.md b/docs/content/reference/pgo_show.md index 28f3e7fb..d0719915 100644 --- a/docs/content/reference/pgo_show.md +++ b/docs/content/reference/pgo_show.md @@ -88,4 +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. diff --git a/docs/content/reference/pgo_show_user.md b/docs/content/reference/pgo_show_user.md new file mode 100644 index 00000000..dbbdc96a --- /dev/null +++ b/docs/content/reference/pgo_show_user.md @@ -0,0 +1,76 @@ +--- +title: pgo show user +--- +## pgo show user + +Show pguser Secret details for a PostgresCluster. + +### Synopsis + +Show pguser Secret details for a PostgresCluster. + +#### RBAC Requirements + Resources Verbs + --------- ----- + secrets [list] + +### Usage + +``` +pgo show user CLUSTER_NAME [flags] +``` + +### Examples + +``` +# Show non-sensitive contents of 'pguser' Secret +pgo show user hippo + +# Show contents of 'pguser' Secret, including sensitive fields +pgo show user hippo --show-sensitive-fields + +``` +### Example output +``` +pgo show user hippo +SECRET: hippo-pguser-hippo + DBNAME: hippo + HOST: hippo-primary.postgres-operator.svc + PORT: 5432 + USER: hippo + +``` + +### Options + +``` + -h, --help help for user + -f, --show-sensitive-fields show sensitive user fields +``` + +### 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 + --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. + -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 + +* [pgo show](/reference/pgo_show/) - Show PostgresCluster details + diff --git a/internal/cmd/show.go b/internal/cmd/show.go index 42ed421c..5b1b1ba7 100644 --- a/internal/cmd/show.go +++ b/internal/cmd/show.go @@ -16,13 +16,18 @@ package cmd import ( "context" + "encoding/base64" "fmt" "io" + "os" + "sort" "strings" "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - corev1 "k8s.io/client-go/kubernetes/typed/core/v1" + v1 "k8s.io/client-go/kubernetes/typed/core/v1" + "sigs.k8s.io/yaml" "github.com/crunchydata/postgres-operator-client/internal" "github.com/crunchydata/postgres-operator-client/internal/util" @@ -77,6 +82,7 @@ HA cmdShow.AddCommand( newShowBackupCommand(config), newShowHACommand(config), + newShowUserCommand(config), ) // Limit the number of args, that is, only one cluster name @@ -278,6 +284,150 @@ func showHA( return Executor(exec).patronictl("list", output) } +// newShowUserCommand returns the decoded contents of the cluster's user Secrets. +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. + +#### RBAC Requirements + Resources Verbs + --------- ----- + secrets [list] + +### Usage`} + + cmdShowUser.Example = internal.FormatExample(`# Show non-sensitive contents of 'pguser' Secret +pgo show user hippo + +# Show contents of 'pguser' Secret, including sensitive fields +pgo show user hippo --show-sensitive-fields + +### Example output +pgo show user hippo +SECRET: hippo-pguser-hippo + DBNAME: hippo + HOST: hippo-primary.postgres-operator.svc + PORT: 5432 + USER: hippo + `) + + var fields bool + cmdShowUser.Flags().BoolVarP(&fields, "show-sensitive-fields", "f", false, "show sensitive user fields") + + // Limit the number of args, that is, only one cluster name + cmdShowUser.Args = cobra.ExactArgs(1) + + // Define the 'show backup' command + cmdShowUser.RunE = func(cmd *cobra.Command, args []string) error { + + stdout, err := showUser(config, args, fields) + if err != nil { + return err + } + + cmd.Print(stdout) + + return nil + } + + 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 = confirm(os.Stdin, os.Stdout) + } + + if confirmed == nil || !*confirmed { + return "", nil + } + } + + // configure client + 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 + } + + list, err := client.Secrets(configNamespace).List(ctx, metav1.ListOptions{ + LabelSelector: util.PostgresUserSecretLabels(args[0]), + }) + if err != nil { + return "", err + } + + return userData(fields, list) +} + +// 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) { + + var output string + + if len(list.Items) == 0 { + output += fmt.Sprintln("No user Secrets found.") + } + + for _, secret := range list.Items { + output += fmt.Sprintf("SECRET: %s\n", secret.Name) + + // sort keys + keys := make([]string, 0, len(secret.Data)) + for k := range secret.Data { + keys = append(keys, k) + } + sort.Strings(keys) + + // 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 containsString(fields, k) { + output += fmt.Sprintf(" %s: %s\n", strings.ToUpper(k), string(d)) + } + } + } + return output, nil +} + // getPrimaryExec returns a executor function for the primary Pod to allow for // commands to be run against it. func getPrimaryExec(config *internal.Config, args []string) ( @@ -291,7 +441,7 @@ func getPrimaryExec(config *internal.Config, args []string) ( if err != nil { return nil, err } - client, err := corev1.NewForConfig(rest) + client, err := v1.NewForConfig(rest) if err != nil { return nil, err } diff --git a/internal/util/naming.go b/internal/util/naming.go index 2fb6ba0f..647ab06c 100644 --- a/internal/util/naming.go +++ b/internal/util/naming.go @@ -46,6 +46,9 @@ const ( // RolePatroniLeader is the LabelRole that Patroni sets on the Pod that is // currently the leader. RolePatroniLeader = "master" + + // RolePostgresUser is the LabelRole applied to PostgreSQL user secrets. + RolePostgresUser = "pguser" ) const ( @@ -62,3 +65,9 @@ func PrimaryInstanceLabels(clusterName string) string { LabelData + "=" + DataPostgres + "," + LabelRole + "=" + RolePatroniLeader } + +// PostgresUserSecretLabels provides labels for the Postgres user Secret +func PostgresUserSecretLabels(clusterName string) string { + return LabelCluster + "=" + clusterName + "," + + LabelRole + "=" + RolePostgresUser +} diff --git a/testing/kuttl/e2e/show/08--show-user.yaml b/testing/kuttl/e2e/show/08--show-user.yaml new file mode 100644 index 00000000..0bb1e86f --- /dev/null +++ b/testing/kuttl/e2e/show/08--show-user.yaml @@ -0,0 +1,43 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: +- script: | + PRIMARY=$( + kubectl get pod --namespace "${NAMESPACE}" \ + --output name --selector ' + postgres-operator.crunchydata.com/cluster=show-cluster, + postgres-operator.crunchydata.com/role=master' + ) + + CLI_USER=$( + kubectl-pgo --namespace "${NAMESPACE}" show user show-cluster + ) + + status=$? + if [ $status -ne 0 ]; then + echo "pgo command unsuccessful" + exit 1 + 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" + + # check command output is not empty and equals the expected output + if [[ -z $CLI_USER && "$CLI_USER" != "$SHOW_USER_OUTPUT" ]]; then + exit 1 + fi + + CLI_USER_SENSITIVE=$( + echo yes | kubectl-pgo --namespace "${NAMESPACE}" show user show-cluster --show-sensitive-fields + ) + + # check command output is not empty and contains the password field + if [[ -n $CLI_USER_SENSITIVE && "$CLI_USER_SENSITIVE" == *"PASSWORD:"* ]]; then + exit 0 + fi + + exit 1 diff --git a/testing/kuttl/e2e/show/08--combined-show.yaml b/testing/kuttl/e2e/show/09--combined-show.yaml similarity index 100% rename from testing/kuttl/e2e/show/08--combined-show.yaml rename to testing/kuttl/e2e/show/09--combined-show.yaml From c42bd9903d006f03a4cce2a6ca2c8944f4eb6139 Mon Sep 17 00:00:00 2001 From: TJ Moore Date: Fri, 3 Nov 2023 16:27:47 -0400 Subject: [PATCH 2/4] Update/refactor utility functions - Replace 'containsString' function with slices.Contains. - Move 'confirm' function to util package. --- internal/cmd/delete.go | 10 ------ internal/cmd/delete_test.go | 40 ---------------------- internal/cmd/show.go | 5 +-- internal/util/util.go | 55 +++++++++++++++++++++++++++++++ internal/util/util_test.go | 66 +++++++++++++++++++++++++++++++++++++ 5 files changed, 124 insertions(+), 52 deletions(-) delete mode 100644 internal/cmd/delete_test.go create mode 100644 internal/util/util.go create mode 100644 internal/util/util_test.go diff --git a/internal/cmd/delete.go b/internal/cmd/delete.go index 3aa950bf..3d134e4e 100644 --- a/internal/cmd/delete.go +++ b/internal/cmd/delete.go @@ -110,13 +110,3 @@ postgresclusters/hippo deleted`) return cmd } - -// containsString returns true if slice contains element -func containsString(slice []string, element string) bool { - for _, elem := range slice { - if elem == element { - return true - } - } - return false -} diff --git a/internal/cmd/delete_test.go b/internal/cmd/delete_test.go deleted file mode 100644 index 5b1e3657..00000000 --- a/internal/cmd/delete_test.go +++ /dev/null @@ -1,40 +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 cmd - -import ( - "testing" - - "gotest.tools/v3/assert" -) - -func TestContainsString(t *testing.T) { - testsCases := []struct { - desc string - slice []string - element string - found bool - }{ - {"found", []string{"a", "b", "c"}, "a", true}, - {"not found", []string{"a", "b", "c"}, "x", false}, - {"not found substring", []string{"ab", "bc", "cd"}, "b", false}, - } - - for _, tc := range testsCases { - t.Run(tc.desc, func(t *testing.T) { - assert.Equal(t, containsString(tc.slice, tc.element), tc.found) - }) - } -} diff --git a/internal/cmd/show.go b/internal/cmd/show.go index 5b1b1ba7..9f7730f4 100644 --- a/internal/cmd/show.go +++ b/internal/cmd/show.go @@ -27,6 +27,7 @@ import ( 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" @@ -353,7 +354,7 @@ func showUser(config *internal.Config, args []string, showSensitive bool) (strin for i := 0; confirmed == nil && i < 10; i++ { // retry 10 times or until a confirmation is given or denied, // whichever comes first - confirmed = confirm(os.Stdin, os.Stdout) + confirmed = util.Confirm(os.Stdin, os.Stdout) } if confirmed == nil || !*confirmed { @@ -420,7 +421,7 @@ func userData(fields []string, list *corev1.SecretList) (string, error) { if err != nil { return output, err } - if containsString(fields, k) { + if slices.Contains(fields, k) { output += fmt.Sprintf(" %s: %s\n", strings.ToUpper(k), string(d)) } } diff --git a/internal/util/util.go b/internal/util/util.go new file mode 100644 index 00000000..856c6644 --- /dev/null +++ b/internal/util/util.go @@ -0,0 +1,55 @@ +// 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 new file mode 100644 index 00000000..3aeb9cfb --- /dev/null +++ b/internal/util/util_test.go @@ -0,0 +1,66 @@ +// 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) + } + }) + } +} From 7c07a82dc69e500460d8ef276c8e350622e0cf60 Mon Sep 17 00:00:00 2001 From: Ben Blattberg Date: Wed, 8 Nov 2023 14:37:05 -0600 Subject: [PATCH 3/4] Work out show user according to new feedback --- docs/content/reference/pgo_show.md | 2 +- docs/content/reference/pgo_show_user.md | 47 +++-- go.mod | 2 +- internal/cmd/show.go | 239 +++++++++++++--------- internal/util/util.go | 55 ----- internal/util/util_test.go | 66 ------ testing/kuttl/e2e/show/08--show-user.yaml | 27 ++- 7 files changed, 195 insertions(+), 243 deletions(-) delete mode 100644 internal/util/util.go delete mode 100644 internal/util/util_test.go 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 From 070d6a92e27718c2243790344c884e8fab5a382e Mon Sep 17 00:00:00 2001 From: Ben Blattberg Date: Mon, 13 Nov 2023 12:37:52 -0600 Subject: [PATCH 4/4] rename func --- internal/cmd/show.go | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/internal/cmd/show.go b/internal/cmd/show.go index b6229d04..55950da3 100644 --- a/internal/cmd/show.go +++ b/internal/cmd/show.go @@ -92,7 +92,7 @@ HA // Print the pgbackrest info output received. cmd.Printf("BACKUP\n\n") - if stdout, stderr, err := showBackup(config, args, "text", ""); err != nil { + if stdout, stderr, err := getBackup(config, args, "text", ""); err != nil { return err } else { cmd.Printf(stdout) @@ -103,7 +103,7 @@ HA // Print the patronictl list output received. cmd.Printf("\nHA\n\n") - if stdout, stderr, err := showHA(config, args, "pretty"); err != nil { + if stdout, stderr, err := getHA(config, args, "pretty"); err != nil { return err } else { cmd.Printf(stdout) @@ -182,7 +182,7 @@ stanza: db // handle validation. repoNum := strings.TrimPrefix(repoName, "repo") - stdout, stderr, err := showBackup(config, args, outputEnum.String(), repoNum) + stdout, stderr, err := getBackup(config, args, outputEnum.String(), repoNum) if err == nil { cmd.Printf(stdout) @@ -197,9 +197,9 @@ stanza: db return cmdShowBackup } -// showBackup execs into the primary Pod, runs the 'pgbackrest info' command and +// getBackup execs into the primary Pod, runs the 'pgbackrest info' command and // returns the command output and/or error -func showBackup( +func getBackup( config *internal.Config, args []string, output string, @@ -254,7 +254,7 @@ pgo show ha hippo --output json // Define the 'show backup' command cmdShowHA.RunE = func(cmd *cobra.Command, args []string) error { - stdout, stderr, err := showHA(config, args, outputEnum.String()) + stdout, stderr, err := getHA(config, args, outputEnum.String()) if err == nil { cmd.Printf(stdout) @@ -269,9 +269,9 @@ pgo show ha hippo --output json return cmdShowHA } -// showHA execs into the primary Pod, runs the 'patronictl list' command and +// getHA execs into the primary Pod, runs the 'patronictl list' command and // returns the command output and/or error -func showHA( +func getHA( config *internal.Config, args []string, output string) (string, string, error) { @@ -351,7 +351,7 @@ Connection URL: return err } - secretList, err := showUsers(client, config, cluster, args) + secretList, err := getUsers(client, config, cluster, args) if err != nil { return err } @@ -373,8 +373,8 @@ Connection URL: return cmdShowUser } -// showUsers returns a string with the decoded contents of the cluster's users' Secrets. -func showUsers(client *v1.CoreV1Client, +// getUsers returns a string with the decoded contents of the cluster's users' Secrets. +func getUsers(client *v1.CoreV1Client, config *internal.Config, cluster string, args []string,