From da7c981f14abb5586714201cc005e9b58a8a2c02 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Fri, 11 Sep 2020 17:56:05 -0700 Subject: [PATCH 1/9] Organize Pinniped CLI into subcommands; Add get-kubeconfig subcommand - Add flag parsing and help messages for root command, `exchange-credential` subcommand, and new `get-kubeconfig` subcommand - The new `get-kubeconfig` subcommand is a work in progress in this commit - Also add here.Doc() and here.Docf() to enable nice heredocs in our code --- cmd/pinniped/cmd/exchange_credential.go | 102 +++++++++++++ .../exchange_credential_test.go} | 20 +-- cmd/pinniped/cmd/get_kubeconfig.go | 136 ++++++++++++++++++ cmd/pinniped/cmd/get_kubeconfig_test.go | 76 ++++++++++ cmd/pinniped/cmd/root.go | 30 ++++ cmd/pinniped/main.go | 61 +------- go.mod | 2 + go.sum | 38 +---- internal/here/doc.go | 25 ++++ internal/here/doc_test.go | 104 ++++++++++++++ 10 files changed, 490 insertions(+), 104 deletions(-) create mode 100644 cmd/pinniped/cmd/exchange_credential.go rename cmd/pinniped/{main_test.go => cmd/exchange_credential_test.go} (86%) create mode 100644 cmd/pinniped/cmd/get_kubeconfig.go create mode 100644 cmd/pinniped/cmd/get_kubeconfig_test.go create mode 100644 cmd/pinniped/cmd/root.go create mode 100644 internal/here/doc.go create mode 100644 internal/here/doc_test.go diff --git a/cmd/pinniped/cmd/exchange_credential.go b/cmd/pinniped/cmd/exchange_credential.go new file mode 100644 index 00000000..a9f3566f --- /dev/null +++ b/cmd/pinniped/cmd/exchange_credential.go @@ -0,0 +1,102 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "time" + + "github.com/spf13/cobra" + clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + + "github.com/suzerain-io/pinniped/internal/client" + "github.com/suzerain-io/pinniped/internal/constable" + "github.com/suzerain-io/pinniped/internal/here" +) + +//nolint: gochecknoinits +func init() { + exchangeCredentialCmd := &cobra.Command{ + Run: runExchangeCredential, + Args: cobra.NoArgs, // do not accept positional arguments for this command + Use: "exchange-credential", + Short: "Exchange a credential for a cluster-specific access credential", + Long: here.Doc(` + Exchange a credential which proves your identity for a time-limited, + cluster-specific access credential. + + Designed to be conveniently used as an credential plugin for kubectl. + See the help message for 'pinniped get-kubeconfig' for more + information about setting up a kubeconfig file using Pinniped. + + Requires all of the following environment variables, which are + typically set in the kubeconfig: + - PINNIPED_TOKEN: the token to send to Pinniped for exchange + - PINNIPED_CA_BUNDLE: the CA bundle to trust when calling + Pinniped's HTTPS endpoint + - PINNIPED_K8S_API_ENDPOINT: the URL for the Pinniped credential + exchange API + + For more information about credential plugins in general, see + https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins + `), + } + + rootCmd.AddCommand(exchangeCredentialCmd) +} + +type envGetter func(string) (string, bool) +type tokenExchanger func(ctx context.Context, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) + +const ErrMissingEnvVar = constable.Error("failed to get credential: environment variable not set") + +func runExchangeCredential(_ *cobra.Command, _ []string) { + err := exchangeCredential(os.LookupEnv, client.ExchangeToken, os.Stdout, 30*time.Second) + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "%s\n", err.Error()) + os.Exit(1) + } +} + +func exchangeCredential(envGetter envGetter, tokenExchanger tokenExchanger, outputWriter io.Writer, timeout time.Duration) error { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + token, varExists := envGetter("PINNIPED_TOKEN") + if !varExists { + return envVarNotSetError("PINNIPED_TOKEN") + } + + caBundle, varExists := envGetter("PINNIPED_CA_BUNDLE") + if !varExists { + return envVarNotSetError("PINNIPED_CA_BUNDLE") + } + + apiEndpoint, varExists := envGetter("PINNIPED_K8S_API_ENDPOINT") + if !varExists { + return envVarNotSetError("PINNIPED_K8S_API_ENDPOINT") + } + + cred, err := tokenExchanger(ctx, token, caBundle, apiEndpoint) + if err != nil { + return fmt.Errorf("failed to get credential: %w", err) + } + + err = json.NewEncoder(outputWriter).Encode(cred) + if err != nil { + return fmt.Errorf("failed to marshal response to stdout: %w", err) + } + + return nil +} + +func envVarNotSetError(varName string) error { + return fmt.Errorf("%w: %s", ErrMissingEnvVar, varName) +} diff --git a/cmd/pinniped/main_test.go b/cmd/pinniped/cmd/exchange_credential_test.go similarity index 86% rename from cmd/pinniped/main_test.go rename to cmd/pinniped/cmd/exchange_credential_test.go index 261fa50b..db834020 100644 --- a/cmd/pinniped/main_test.go +++ b/cmd/pinniped/cmd/exchange_credential_test.go @@ -3,7 +3,7 @@ Copyright 2020 VMware, Inc. SPDX-License-Identifier: Apache-2.0 */ -package main +package cmd import ( "bytes" @@ -21,8 +21,8 @@ import ( "github.com/suzerain-io/pinniped/internal/testutil" ) -func TestRun(t *testing.T) { - spec.Run(t, "main.run", func(t *testing.T, when spec.G, it spec.S) { +func TestExchangeCredential(t *testing.T) { + spec.Run(t, "cmd.exchangeCredential", func(t *testing.T, when spec.G, it spec.S) { var r *require.Assertions var buffer *bytes.Buffer var tokenExchanger tokenExchanger @@ -49,19 +49,19 @@ func TestRun(t *testing.T) { when("env vars are missing", func() { it("returns an error when PINNIPED_TOKEN is missing", func() { delete(fakeEnv, "PINNIPED_TOKEN") - err := run(envGetter, tokenExchanger, buffer, 30*time.Second) + err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) r.EqualError(err, "failed to get credential: environment variable not set: PINNIPED_TOKEN") }) it("returns an error when PINNIPED_CA_BUNDLE is missing", func() { delete(fakeEnv, "PINNIPED_CA_BUNDLE") - err := run(envGetter, tokenExchanger, buffer, 30*time.Second) + err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) r.EqualError(err, "failed to get credential: environment variable not set: PINNIPED_CA_BUNDLE") }) it("returns an error when PINNIPED_K8S_API_ENDPOINT is missing", func() { delete(fakeEnv, "PINNIPED_K8S_API_ENDPOINT") - err := run(envGetter, tokenExchanger, buffer, 30*time.Second) + err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) r.EqualError(err, "failed to get credential: environment variable not set: PINNIPED_K8S_API_ENDPOINT") }) }) @@ -74,7 +74,7 @@ func TestRun(t *testing.T) { }) it("returns an error", func() { - err := run(envGetter, tokenExchanger, buffer, 30*time.Second) + err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) r.EqualError(err, "failed to get credential: some error") }) }) @@ -91,7 +91,7 @@ func TestRun(t *testing.T) { }) it("returns an error", func() { - err := run(envGetter, tokenExchanger, &testutil.ErrorWriter{ReturnError: fmt.Errorf("some IO error")}, 30*time.Second) + err := exchangeCredential(envGetter, tokenExchanger, &testutil.ErrorWriter{ReturnError: fmt.Errorf("some IO error")}, 30*time.Second) r.EqualError(err, "failed to marshal response to stdout: some IO error") }) }) @@ -113,7 +113,7 @@ func TestRun(t *testing.T) { }) it("returns an error", func() { - err := run(envGetter, tokenExchanger, buffer, 1*time.Millisecond) + err := exchangeCredential(envGetter, tokenExchanger, buffer, 1*time.Millisecond) r.EqualError(err, "failed to get credential: context deadline exceeded") }) }) @@ -141,7 +141,7 @@ func TestRun(t *testing.T) { }) it("writes the execCredential to the given writer", func() { - err := run(envGetter, tokenExchanger, buffer, 30*time.Second) + err := exchangeCredential(envGetter, tokenExchanger, buffer, 30*time.Second) r.NoError(err) r.Equal(fakeEnv["PINNIPED_TOKEN"], actualToken) r.Equal(fakeEnv["PINNIPED_CA_BUNDLE"], actualCaBundle) diff --git a/cmd/pinniped/cmd/get_kubeconfig.go b/cmd/pinniped/cmd/get_kubeconfig.go new file mode 100644 index 00000000..7bceadc1 --- /dev/null +++ b/cmd/pinniped/cmd/get_kubeconfig.go @@ -0,0 +1,136 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package cmd + +import ( + "fmt" + "io" + "os" + + "github.com/ghodss/yaml" + "github.com/spf13/cobra" + clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + v1 "k8s.io/client-go/tools/clientcmd/api/v1" + + "github.com/suzerain-io/pinniped/internal/here" +) + +const ( + getKubeConfigCmdTokenFlagName = "token" +) + +//nolint: gochecknoinits +func init() { + getKubeConfigCmd := &cobra.Command{ + Run: runGetKubeConfig, + Args: cobra.NoArgs, // do not accept positional arguments for this command + Use: "get-kubeconfig", + Short: "Print a kubeconfig for authenticating into a cluster via Pinniped", + Long: here.Doc(` + Print a kubeconfig for authenticating into a cluster via Pinniped. + + Assumes that you have admin-like access to the cluster using your + current kubeconfig context, in order to access Pinniped's metadata. + + Prints a kubeconfig which is suitable to access the cluster using + Pinniped as the authentication mechanism. This kubeconfig output + can be saved to a file and used with future kubectl commands, e.g.: + pinniped get-kubeconfig --token $MY_TOKEN > $HOME/mycluster-kubeconfig + kubectl --kubeconfig $HOME/mycluster-kubeconfig get pods + `), + } + + rootCmd.AddCommand(getKubeConfigCmd) + + getKubeConfigCmd.Flags().StringP( + getKubeConfigCmdTokenFlagName, + "t", + "", + "The credential to include in the resulting kubeconfig output (Required)", + ) + err := getKubeConfigCmd.MarkFlagRequired(getKubeConfigCmdTokenFlagName) + if err != nil { + panic(err) + } +} + +func runGetKubeConfig(cmd *cobra.Command, _ []string) { + token := cmd.Flag(getKubeConfigCmdTokenFlagName).Value.String() + + err := getKubeConfig(os.Stdout, token) + + if err != nil { + _, _ = fmt.Fprintf(os.Stderr, "error: %s\n", err.Error()) + os.Exit(1) + } +} + +func getKubeConfig(outputWriter io.Writer, token string) error { + clusterName := "pinniped-cluster" + userName := "pinniped-user" + + fullPathToSelf, err := os.Executable() + if err != nil { + return fmt.Errorf("could not find path to self: %w", err) + } + + config := v1.Config{ + Kind: "Config", + APIVersion: v1.SchemeGroupVersion.Version, + Preferences: v1.Preferences{ + Colors: false, // TODO what does this setting do? + Extensions: nil, + }, + Clusters: []v1.NamedCluster{ + { + Name: clusterName, + Cluster: v1.Cluster{}, // TODO fill in server and cert authority and such + }, + }, + AuthInfos: []v1.NamedAuthInfo{ + { + Name: userName, + AuthInfo: v1.AuthInfo{ + Exec: &v1.ExecConfig{ + Command: fullPathToSelf, + Args: []string{"exchange-credential"}, + Env: []v1.ExecEnvVar{ + {Name: "PINNIPED_K8S_API_ENDPOINT", Value: ""}, // TODO fill in value + {Name: "PINNIPED_CA_BUNDLE", Value: ""}, // TODO fill in value + {Name: "PINNIPED_TOKEN", Value: token}, + }, + APIVersion: clientauthenticationv1beta1.SchemeGroupVersion.String(), + InstallHint: "The Pinniped CLI is required to authenticate to the current cluster.\n" + + "For more information, please visit https://pinniped.dev", + }, + }, + }, + }, + Contexts: []v1.NamedContext{ + { + Name: clusterName, + Context: v1.Context{ + Cluster: clusterName, + AuthInfo: userName, + }, + }, + }, + CurrentContext: clusterName, + Extensions: nil, + } + + output, err := yaml.Marshal(&config) + if err != nil { + return fmt.Errorf("YAML serialization error: %w", err) + } + + _, err = fmt.Fprint(outputWriter, string(output)) + if err != nil { + return fmt.Errorf("output write error: %w", err) + } + + return nil +} diff --git a/cmd/pinniped/cmd/get_kubeconfig_test.go b/cmd/pinniped/cmd/get_kubeconfig_test.go new file mode 100644 index 00000000..feccb96b --- /dev/null +++ b/cmd/pinniped/cmd/get_kubeconfig_test.go @@ -0,0 +1,76 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package cmd + +import ( + "bytes" + "os" + "testing" + + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" + "github.com/stretchr/testify/require" + + "github.com/suzerain-io/pinniped/internal/here" +) + +func TestGetKubeConfig(t *testing.T) { + spec.Run(t, "cmd.getKubeConfig", func(t *testing.T, when spec.G, it spec.S) { + var r *require.Assertions + var buffer *bytes.Buffer + var fullPathToSelf string + + it.Before(func() { + r = require.New(t) + buffer = new(bytes.Buffer) + + var err error + fullPathToSelf, err = os.Executable() + r.NoError(err) + }) + + it("writes the kubeconfig to the given writer", func() { + err := getKubeConfig(buffer, "some-token") + r.NoError(err) + + expectedYAML := here.Docf(` + apiVersion: v1 + clusters: + - cluster: + server: "" + name: pinniped-cluster + contexts: + - context: + cluster: pinniped-cluster + user: pinniped-user + name: pinniped-cluster + current-context: pinniped-cluster + kind: Config + preferences: {} + users: + - name: pinniped-user + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - exchange-credential + command: %s + env: + - name: PINNIPED_K8S_API_ENDPOINT + value: "" + - name: PINNIPED_CA_BUNDLE + value: "" + - name: PINNIPED_TOKEN + value: some-token + installHint: |- + The Pinniped CLI is required to authenticate to the current cluster. + For more information, please visit https://pinniped.dev + `, fullPathToSelf) + + r.Equal(expectedYAML, buffer.String()) + }) + }, spec.Parallel(), spec.Report(report.Terminal{})) +} diff --git a/cmd/pinniped/cmd/root.go b/cmd/pinniped/cmd/root.go new file mode 100644 index 00000000..3ad05292 --- /dev/null +++ b/cmd/pinniped/cmd/root.go @@ -0,0 +1,30 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +//nolint: gochecknoglobals +var rootCmd = &cobra.Command{ + Use: "pinniped", + Short: "pinniped", + Long: "pinniped is the client-side binary for use with Pinniped-enabled Kubernetes clusters.", + SilenceUsage: true, // do not print usage message when commands fail +} + +// Execute adds all child commands to the root command and sets flags appropriately. +// This is called by main.main(). It only needs to happen once to the rootCmd. +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/cmd/pinniped/main.go b/cmd/pinniped/main.go index b232d400..314019af 100644 --- a/cmd/pinniped/main.go +++ b/cmd/pinniped/main.go @@ -5,65 +5,8 @@ SPDX-License-Identifier: Apache-2.0 package main -import ( - "context" - "encoding/json" - "fmt" - "io" - "os" - "time" - - clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" - - "github.com/suzerain-io/pinniped/internal/client" - "github.com/suzerain-io/pinniped/internal/constable" -) +import "github.com/suzerain-io/pinniped/cmd/pinniped/cmd" func main() { - err := run(os.LookupEnv, client.ExchangeToken, os.Stdout, 30*time.Second) - if err != nil { - _, _ = fmt.Fprintf(os.Stderr, "%s\n", err.Error()) - os.Exit(1) - } -} - -type envGetter func(string) (string, bool) -type tokenExchanger func(ctx context.Context, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) - -const ErrMissingEnvVar = constable.Error("failed to get credential: environment variable not set") - -func run(envGetter envGetter, tokenExchanger tokenExchanger, outputWriter io.Writer, timeout time.Duration) error { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - token, varExists := envGetter("PINNIPED_TOKEN") - if !varExists { - return envVarNotSetError("PINNIPED_TOKEN") - } - - caBundle, varExists := envGetter("PINNIPED_CA_BUNDLE") - if !varExists { - return envVarNotSetError("PINNIPED_CA_BUNDLE") - } - - apiEndpoint, varExists := envGetter("PINNIPED_K8S_API_ENDPOINT") - if !varExists { - return envVarNotSetError("PINNIPED_K8S_API_ENDPOINT") - } - - cred, err := tokenExchanger(ctx, token, caBundle, apiEndpoint) - if err != nil { - return fmt.Errorf("failed to get credential: %w", err) - } - - err = json.NewEncoder(outputWriter).Encode(cred) - if err != nil { - return fmt.Errorf("failed to marshal response to stdout: %w", err) - } - - return nil -} - -func envVarNotSetError(varName string) error { - return fmt.Errorf("%w: %s", ErrMissingEnvVar, varName) + cmd.Execute() } diff --git a/go.mod b/go.mod index 246020d6..65cdc141 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module github.com/suzerain-io/pinniped go 1.14 require ( + github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/davecgh/go-spew v1.1.1 + github.com/ghodss/yaml v1.0.0 github.com/go-logr/logr v0.2.1 github.com/golang/mock v1.4.4 github.com/golangci/golangci-lint v1.31.0 diff --git a/go.sum b/go.sum index fb22470c..3fb7c565 100644 --- a/go.sum +++ b/go.sum @@ -36,6 +36,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/Djarvur/go-err113 v0.0.0-20200511133814-5174e21577d5 h1:XTrzB+F8+SpRmbhAH8HLxhiiG6nYNwaBZjrFps1oWEk= github.com/Djarvur/go-err113 v0.0.0-20200511133814-5174e21577d5/go.mod h1:4UJr5HIiMZrwgkSPdsjy2uOQExX/WEILpIrO9UPGuXs= +github.com/MakeNowJust/heredoc/v2 v2.0.1 h1:rlCHh70XXXv7toz95ajQWOWQnN4WNLt0TdpZYIR/J6A= +github.com/MakeNowJust/heredoc/v2 v2.0.1/go.mod h1:6/2Abh5s+hc3g9nbWLe9ObDIOhaRrqsyY9MWy+4JdRM= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46 h1:lsxEuwrXEAokXB9qhlbKWPpo3KMLZQ5WB5WLQRW1uq0= @@ -98,8 +100,6 @@ github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfc github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/daixiang0/gci v0.0.0-20200727065011-66f1df783cb2 h1:3Lhhps85OdA8ezsEKu+IA1hE+DBTjt/fjd7xNCrHbVA= -github.com/daixiang0/gci v0.0.0-20200727065011-66f1df783cb2/go.mod h1:+AV8KmHTGxxwp/pY84TLQfFKp2vuKXXJVzF3kD/hfR4= github.com/daixiang0/gci v0.2.4 h1:BUCKk5nlK2m+kRIsoj+wb/5hazHvHeZieBKWd9Afa8Q= github.com/daixiang0/gci v0.2.4/go.mod h1:+AV8KmHTGxxwp/pY84TLQfFKp2vuKXXJVzF3kD/hfR4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -132,16 +132,14 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/go-critic/go-critic v0.5.0 h1:Ic2p5UCl5fX/2WX2w8nroPpPhxRNsNTMlJzsu/uqwnM= -github.com/go-critic/go-critic v0.5.0/go.mod h1:4jeRh3ZAVnRYhuWdOEvwzVqLUpxMSoAT0xZ74JsTPlo= github.com/go-critic/go-critic v0.5.2 h1:3RJdgf6u4NZUumoP8nzbqiiNT8e1tC2Oc7jlgqre/IA= github.com/go-critic/go-critic v0.5.2/go.mod h1:cc0+HvdE3lFpqLecgqMaJcvWWH77sLdBp+wLGPM1Yyo= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= -github.com/go-lintpack/lintpack v0.5.2/go.mod h1:NwZuYi2nUHho8XEIZ6SIxihrnPoqBTDqfpXvXAN0sXM= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= github.com/go-logr/logr v0.1.0/go.mod h1:ixOQHD9gLJUVQQ2ZOR7zLEifBX6tGkNJF4QyIY7sIas= @@ -175,17 +173,13 @@ github.com/go-toolsmith/astcast v1.0.0 h1:JojxlmI6STnFVG9yOImLeGREv8W2ocNUM+iOhR github.com/go-toolsmith/astcast v1.0.0/go.mod h1:mt2OdQTeAQcY4DQgPSArJjHCcOwlX+Wl/kwN+LbLGQ4= github.com/go-toolsmith/astcopy v1.0.0 h1:OMgl1b1MEpjFQ1m5ztEO06rz5CUd3oBv9RF7+DyvdG8= github.com/go-toolsmith/astcopy v1.0.0/go.mod h1:vrgyG+5Bxrnz4MZWPF+pI4R8h3qKRjjyvV/DSez4WVQ= -github.com/go-toolsmith/astequal v0.0.0-20180903214952-dcb477bfacd6/go.mod h1:H+xSiq0+LtiDC11+h1G32h7Of5O3CYFJ99GVbS5lDKY= github.com/go-toolsmith/astequal v1.0.0 h1:4zxD8j3JRFNyLN46lodQuqz3xdKSrur7U/sr0SDS/gQ= github.com/go-toolsmith/astequal v1.0.0/go.mod h1:H+xSiq0+LtiDC11+h1G32h7Of5O3CYFJ99GVbS5lDKY= -github.com/go-toolsmith/astfmt v0.0.0-20180903215011-8f8ee99c3086/go.mod h1:mP93XdblcopXwlyN4X4uodxXQhldPGZbcEJIimQHrkg= github.com/go-toolsmith/astfmt v1.0.0 h1:A0vDDXt+vsvLEdbMFJAUBI/uTbRw1ffOPnxsILnFL6k= github.com/go-toolsmith/astfmt v1.0.0/go.mod h1:cnWmsOAuq4jJY6Ct5YWlVLmcmLMn1JUPuQIHCY7CJDw= github.com/go-toolsmith/astinfo v0.0.0-20180906194353-9809ff7efb21/go.mod h1:dDStQCHtmZpYOmjRP/8gHHnCCch3Zz3oEgCdZVdtweU= -github.com/go-toolsmith/astp v0.0.0-20180903215135-0af7e3c24f30/go.mod h1:SV2ur98SGypH1UjcPpCatrV5hPazG6+IfNHbkDXBRrk= github.com/go-toolsmith/astp v1.0.0 h1:alXE75TXgcmupDsMK1fRAy0YUzLzqPVvBKoyWV+KPXg= github.com/go-toolsmith/astp v1.0.0/go.mod h1:RSyrtpVlfTFGDYRbrjyWP1pYu//tSFcvdYrA8meBmLI= -github.com/go-toolsmith/pkgload v0.0.0-20181119091011-e9e65178eee8/go.mod h1:WoMrjiy4zvdS+Bg6z9jZH82QXwkcgCBX6nOfnmdaHks= github.com/go-toolsmith/pkgload v1.0.0 h1:4DFWWMXVfbcN5So1sBNW9+yeiMqLFGl1wFLTL5R0Tgg= github.com/go-toolsmith/pkgload v1.0.0/go.mod h1:5eFArkbO80v7Z0kdngIxsRXRMTaX4Ilcwuh3clNrQJc= github.com/go-toolsmith/strparse v1.0.0 h1:Vcw78DnpCAKlM20kSbAyO4mPfJn/lyYA4BJUDxe2Jb4= @@ -197,8 +191,6 @@ github.com/go-xmlfmt/xmlfmt v0.0.0-20191208150333-d5b6f63a941b h1:khEcpUM4yFcxg4 github.com/go-xmlfmt/xmlfmt v0.0.0-20191208150333-d5b6f63a941b/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/gofrs/flock v0.7.1 h1:DP+LD/t0njgoPBvT5MJLeliUIVQR03hiKR6vezdwHlc= -github.com/gofrs/flock v0.7.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gofrs/flock v0.8.0 h1:MSdYClljsF3PbENUUEx85nkWfJSGfzYI9yEBZOJz6CY= github.com/gofrs/flock v0.8.0/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= @@ -244,8 +236,6 @@ github.com/golangci/gocyclo v0.0.0-20180528144436-0a533e8fa43d h1:pXTK/gkVNs7Zyy github.com/golangci/gocyclo v0.0.0-20180528144436-0a533e8fa43d/go.mod h1:ozx7R9SIwqmqf5pRP90DhR2Oay2UIjGuKheCBCNwAYU= github.com/golangci/gofmt v0.0.0-20190930125516-244bba706f1a h1:iR3fYXUjHCR97qWS8ch1y9zPNsgXThGwjKPrYfqMPks= github.com/golangci/gofmt v0.0.0-20190930125516-244bba706f1a/go.mod h1:9qCChq59u/eW8im404Q2WWTrnBUQKjpNYKMbU4M7EFU= -github.com/golangci/golangci-lint v1.30.0 h1:UhdK5WbO0GBd7W+k2lOD7BEJH4Wsa7zKfw8m3/aEJGQ= -github.com/golangci/golangci-lint v1.30.0/go.mod h1:5t0i3wHlqQc9deBBvZsP+a/4xz7cfjV+zhp5U0Mzp14= github.com/golangci/golangci-lint v1.31.0 h1:+m9I3LEmxXLpymkXRPkDQGzOVBmBYm16UtDiXqZxWek= github.com/golangci/golangci-lint v1.31.0/go.mod h1:aMQuNCA+NDU5+4jLL5pEuFHoue0IznKE2+/GsFvvs8A= github.com/golangci/ineffassign v0.0.0-20190609212857-42439a7714cc h1:gLLhTLMk2/SutryVJ6D4VZCU3CUqr8YloG7FPIBWFpI= @@ -358,8 +348,6 @@ github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvW github.com/kisielk/errcheck v1.2.0/go.mod h1:/BMXB+zMLi60iA8Vv6Ksmxu/1UDYcXs4uQLJ+jE2L00= github.com/kisielk/gotool v1.0.0 h1:AV2c/EiW3KqPNT9ZKl07ehoAGi4C5/01Cfbblndcapg= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.10.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= -github.com/klauspost/compress v1.10.5/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.10.7/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.10.10/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -436,8 +424,6 @@ github.com/nbutton23/zxcvbn-go v0.0.0-20180912185939-ae427f1e4c1d h1:AREM5mwr4u1 github.com/nbutton23/zxcvbn-go v0.0.0-20180912185939-ae427f1e4c1d/go.mod h1:o96djdrsSGy3AWPyBgZMAGfxZNfgntdJG+11KU4QvbU= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/nishanths/exhaustive v0.0.0-20200708172631-8866003e3856 h1:W3KBC2LFyfgd+wNudlfgCCsTo4q97MeNWrfz8/wSdSc= -github.com/nishanths/exhaustive v0.0.0-20200708172631-8866003e3856/go.mod h1:wBEpHwM2OdmeNpdCvRPUlkEbBuaFmcK4Wv8Q7FuGW3c= github.com/nishanths/exhaustive v0.0.0-20200811152831-6cf413ae40e0 h1:eMV1t2NQRc3r1k3guWiv/zEeqZZP6kPvpUfy6byfL1g= github.com/nishanths/exhaustive v0.0.0-20200811152831-6cf413ae40e0/go.mod h1:wBEpHwM2OdmeNpdCvRPUlkEbBuaFmcK4Wv8Q7FuGW3c= github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= @@ -492,8 +478,6 @@ github.com/prometheus/procfs v0.1.3 h1:F0+tqvhOksq22sc6iCHF5WGlWjdwj92p0udFh1VFB github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/quasilyte/go-consistent v0.0.0-20190521200055-c6f3937de18c/go.mod h1:5STLWrekHfjyYwxBRVRXNOSewLJ3PWfDJd1VyTS21fI= -github.com/quasilyte/go-ruleguard v0.1.2-0.20200318202121-b00d7a75d3d8 h1:DvnesvLtRPQOvaUbfXfh0tpMHg29by0H7F2U+QIkSu8= -github.com/quasilyte/go-ruleguard v0.1.2-0.20200318202121-b00d7a75d3d8/go.mod h1:CGFX09Ci3pq9QZdj86B+VGIdNj4VyCo2iPOGS9esB/k= github.com/quasilyte/go-ruleguard v0.2.0 h1:UOVMyH2EKkxIfzrULvA9n/tO+HtEhqD9mrLSWMr5FwU= github.com/quasilyte/go-ruleguard v0.2.0/go.mod h1:2RT/tf0Ce0UDj5y243iWKosQogJd8+1G3Rs2fxmlYnw= github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95 h1:L8QM9bvf68pVdQ3bCFZMDmnt9yqcMBro1pC7F+IPYMY= @@ -534,8 +518,6 @@ github.com/soheilhy/cmux v0.1.4 h1:0HKaf1o97UwFjHH9o5XsHUOF+tqmdA7KEzXLpiyaw0E= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/sonatard/noctx v0.0.1 h1:VC1Qhl6Oxx9vvWo3UDgrGXYCeKCe3Wbw7qAWL6FrmTY= github.com/sonatard/noctx v0.0.1/go.mod h1:9D2D/EoULe8Yy2joDHJj7bv3sZoq9AaSb8B4lqBjiZI= -github.com/sourcegraph/go-diff v0.5.3 h1:lhIKJ2nXLZZ+AfbHpYxTn0pXpNTTui0DX7DO3xeb1Zs= -github.com/sourcegraph/go-diff v0.5.3/go.mod h1:v9JDtjCE4HHHCZGId75rg8gkKKa98RVjBcBGsVmMmak= github.com/sourcegraph/go-diff v0.6.0 h1:WbN9e/jD8ujU+o0vd9IFN5AEwtfB0rn/zM/AANaClqQ= github.com/sourcegraph/go-diff v0.6.0/go.mod h1:iBszgVvyxdc8SFZ7gm69go2KDdt3ag071iBaWPF6cjs= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= @@ -555,12 +537,8 @@ github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= -github.com/spf13/viper v1.7.0 h1:xVKxvI7ouOI5I+U9s2eeiUfMaWBVoXA3AWskkrqK0VM= -github.com/spf13/viper v1.7.0/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= github.com/spf13/viper v1.7.1 h1:pM5oEahlgWv/WnHXpgbKz7iLIxRf65tye2Ci+XFK5sk= github.com/spf13/viper v1.7.1/go.mod h1:8WkrPz2fc9jxqZNCJI/76HCieCp4Q8HaLFoCha5qpdg= -github.com/ssgreg/nlreturn/v2 v2.0.1 h1:+lm6xFjVuNw/9t/Fh5sIwfNWefiD5bddzc6vwJ1TvRI= -github.com/ssgreg/nlreturn/v2 v2.0.1/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= github.com/ssgreg/nlreturn/v2 v2.1.0 h1:6/s4Rc49L6Uo6RLjhWZGBpWWjfzk2yrf1nIW8m4wgVA= github.com/ssgreg/nlreturn/v2 v2.1.0/go.mod h1:E/iiPB78hV7Szg2YfRgyIrk1AD6JVMTRkkxBiELzh2I= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -587,8 +565,6 @@ github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1 github.com/tommy-muehle/go-mnd v1.3.1-0.20200224220436-e6f9a994e8fa h1:RC4maTWLKKwb7p1cnoygsbKIgNlJqSYBeAFON3Ar8As= github.com/tommy-muehle/go-mnd v1.3.1-0.20200224220436-e6f9a994e8fa/go.mod h1:dSUh0FtTP8VhvkL1S+gUR1OKd9ZnSaozuI6r3m6wOig= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= -github.com/ultraware/funlen v0.0.2 h1:Av96YVBwwNSe4MLR7iI/BIa3VyI7/djnto/pK3Uxbdo= -github.com/ultraware/funlen v0.0.2/go.mod h1:Dp4UiAus7Wdb9KUZsYWZEWiRzGuM2kXM1lPbfaF6xhA= github.com/ultraware/funlen v0.0.3 h1:5ylVWm8wsNwH5aWo9438pwvsK0QiqVuUrt9bn7S/iLA= github.com/ultraware/funlen v0.0.3/go.mod h1:Dp4UiAus7Wdb9KUZsYWZEWiRzGuM2kXM1lPbfaF6xhA= github.com/ultraware/whitespace v0.0.4 h1:If7Va4cM03mpgrNH9k49/VOicWpGoG70XPBFFODYDsg= @@ -597,9 +573,7 @@ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijb github.com/uudashr/gocognit v1.0.1 h1:MoG2fZ0b/Eo7NXoIwCVFLG5JED3qgQz5/NEE+rOsjPs= github.com/uudashr/gocognit v1.0.1/go.mod h1:j44Ayx2KW4+oB6SWMv8KsmHzZrOInQav7D3cQMJ5JUM= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.12.0/go.mod h1:229t1eWu9UXTPmoUkbpN/fctKPBY4IJoFXQnxHGXy6E= github.com/valyala/fasthttp v1.15.1/go.mod h1:YOKImeEosDdBPnxc0gy7INqi3m1zK6A+xl6TwOBhHCA= -github.com/valyala/quicktemplate v1.5.1/go.mod h1:v7yYWpBEiutDyNfVaph6oC/yKwejzVyTX/2cwwHxyok= github.com/valyala/quicktemplate v1.6.2/go.mod h1:mtEJpQtUiBV0SHhMX6RtiJtqxncgrfmjcUy5T68X8TM= github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2 h1:eY9dn8+vbi4tKz5Qo6v2eYzo7kUS51QINcR5jNpbZS8= @@ -749,7 +723,6 @@ golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20181030221726-6c7e314b6563/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20181117154741-2ddaf7f79a09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190110163146-51295c7ec13a/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190221204921-83362c3779f5/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -762,7 +735,6 @@ golang.org/x/tools v0.0.0-20190322203728-c1a832b0ad89/go.mod h1:LCzVGOaR6xXOjkQ3 golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= -golang.org/x/tools v0.0.0-20190521203540-521d6ed310dd/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= golang.org/x/tools v0.0.0-20190614205625-5aca471b1d59/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= @@ -881,8 +853,6 @@ honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= -honnef.co/go/tools v0.0.1-2020.1.4 h1:UoveltGrhghAA7ePc+e+QYDHXrBps2PqFZiHkGR/xK8= -honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.5 h1:nI5egYTGJakVyOryqLs1cQO5dO0ksin5XXs2pspk75k= honnef.co/go/tools v0.0.1-2020.1.5/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= k8s.io/api v0.19.0 h1:XyrFIJqTYZJ2DU7FBE/bSPz7b1HvbVBuBf07oeo6eTc= @@ -925,5 +895,3 @@ sigs.k8s.io/structured-merge-diff/v4 v4.0.1/go.mod h1:bJZC9H9iH24zzfZ/41RGcq60oK sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o= sigs.k8s.io/yaml v1.2.0 h1:kr/MCeFWJWTwyaHoR9c8EjH9OumOmoF9YGiZd7lFm/Q= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= -sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4 h1:JPJh2pk3+X4lXAkZIk2RuE/7/FoK9maXw+TNPJhVS/c= -sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0= diff --git a/internal/here/doc.go b/internal/here/doc.go new file mode 100644 index 00000000..5c486c91 --- /dev/null +++ b/internal/here/doc.go @@ -0,0 +1,25 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package here + +import ( + "strings" + + "github.com/MakeNowJust/heredoc/v2" +) + +const ( + tab = "\t" + fourSpaces = " " +) + +func Doc(s string) string { + return strings.ReplaceAll(heredoc.Doc(s), tab, fourSpaces) +} + +func Docf(raw string, args ...interface{}) string { + return strings.ReplaceAll(heredoc.Docf(raw, args...), tab, fourSpaces) +} diff --git a/internal/here/doc_test.go b/internal/here/doc_test.go new file mode 100644 index 00000000..611049d8 --- /dev/null +++ b/internal/here/doc_test.go @@ -0,0 +1,104 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package here + +import ( + "testing" + + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" + "github.com/stretchr/testify/require" +) + +func TestDoc(t *testing.T) { + spec.Run(t, "here.Doc", func(t *testing.T, when spec.G, it spec.S) { + var r *require.Assertions + + it.Before(func() { + r = require.New(t) + }) + + it("returns single-line strings unchanged", func() { + r.Equal("the quick brown fox", Doc("the quick brown fox")) + r.Equal(" the quick brown fox", Doc(" the quick brown fox")) + }) + + it("returns multi-line strings with indentation removed", func() { + r.Equal( + "the quick brown fox\njumped over the\nlazy dog", + Doc(`the quick brown fox + jumped over the + lazy dog`), + ) + }) + + it("ignores the first empty line and the whitespace in the last line", func() { + r.Equal( + "the quick brown fox\njumped over the\nlazy dog\n", + Doc(` + the quick brown fox + jumped over the + lazy dog + `), + ) + }) + + it("turns all tabs into 4 spaces", func() { + r.Equal( + "the quick brown fox\n jumped over the\n lazy dog\n", + Doc(` + the quick brown fox + jumped over the + lazy dog + `), + ) + }) + }, spec.Parallel(), spec.Report(report.Terminal{})) + + spec.Run(t, "here.Docf", func(t *testing.T, when spec.G, it spec.S) { + var r *require.Assertions + + it.Before(func() { + r = require.New(t) + }) + + it("returns single-line strings unchanged", func() { + r.Equal("the quick brown fox", Docf("the quick brown %s", "fox")) + r.Equal(" the quick brown fox", Docf(" the %s brown %s", "quick", "fox")) + }) + + it("returns multi-line strings with indentation removed", func() { + r.Equal( + "the quick brown fox\njumped over the\nlazy dog", + Docf(`the quick brown %s + jumped over the + lazy %s`, "fox", "dog"), + ) + }) + + it("ignores the first empty line and the whitespace in the last line", func() { + r.Equal( + "the quick brown fox\njumped over the\nlazy dog\n", + Docf(` + the quick brown %s + jumped over the + lazy %s + `, "fox", "dog"), + ) + }) + + it("turns all tabs into 4 spaces", func() { + r.Equal( + "the quick brown fox\n jumped over the\n lazy dog\n", + Docf(` + the quick brown %s + jumped over the + lazy %s + `, "fox", "dog"), + ) + }) + }, spec.Parallel(), spec.Report(report.Terminal{})) +} From 2cdc3defb7394e62b1d205c075e0d6e9919c90df Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Fri, 11 Sep 2020 18:15:24 -0700 Subject: [PATCH 2/9] Use here.Doc() in a few more places that were begging for it --- .../controller/issuerconfig/publisher_test.go | 19 +++++---- internal/server/server.go | 10 +++-- test/integration/client_test.go | 42 ++++++++++--------- 3 files changed, 38 insertions(+), 33 deletions(-) diff --git a/internal/controller/issuerconfig/publisher_test.go b/internal/controller/issuerconfig/publisher_test.go index f45d8390..2e7b6987 100644 --- a/internal/controller/issuerconfig/publisher_test.go +++ b/internal/controller/issuerconfig/publisher_test.go @@ -8,7 +8,6 @@ package issuerconfig import ( "context" "errors" - "strings" "testing" "time" @@ -27,6 +26,7 @@ import ( pinnipedfake "github.com/suzerain-io/pinniped/generated/1.19/client/clientset/versioned/fake" pinnipedinformers "github.com/suzerain-io/pinniped/generated/1.19/client/informers/externalversions" "github.com/suzerain-io/pinniped/internal/controllerlib" + "github.com/suzerain-io/pinniped/internal/here" "github.com/suzerain-io/pinniped/internal/testutil" ) @@ -256,14 +256,15 @@ func TestSync(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "cluster-info", Namespace: "kube-public"}, // Note that go fmt puts tabs in our file, which we must remove from our configmap yaml below. Data: map[string]string{ - "kubeconfig": strings.ReplaceAll(` - kind: Config - apiVersion: v1 - clusters: - - name: "" - cluster: - certificate-authority-data: "`+caData+`" - server: "`+kubeServerURL+`"`, "\t", " "), + "kubeconfig": here.Docf(` + kind: Config + apiVersion: v1 + clusters: + - name: "" + cluster: + certificate-authority-data: "%s" + server: "%s"`, + caData, kubeServerURL), "uninteresting-key": "uninteresting-value", }, } diff --git a/internal/server/server.go b/internal/server/server.go index 1dcdb808..4c146ca8 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -29,6 +29,7 @@ import ( "github.com/suzerain-io/pinniped/internal/controller/issuerconfig" "github.com/suzerain-io/pinniped/internal/controllermanager" "github.com/suzerain-io/pinniped/internal/downward" + "github.com/suzerain-io/pinniped/internal/here" "github.com/suzerain-io/pinniped/internal/provider" "github.com/suzerain-io/pinniped/internal/registry/credentialrequest" "github.com/suzerain-io/pinniped/pkg/config" @@ -62,10 +63,11 @@ func (a *App) Run() error { // Create the server command and save it into the App. func (a *App) addServerCommand(ctx context.Context, args []string, stdout, stderr io.Writer) { cmd := &cobra.Command{ - Use: `pinniped-server`, - Long: "pinniped-server provides a generic API for mapping an external\n" + - "credential from somewhere to an internal credential to be used for\n" + - "authenticating to the Kubernetes API.", + Use: "pinniped-server", + Long: here.Doc(` + pinniped-server provides a generic API for mapping an external + credential from somewhere to an internal credential to be used for + authenticating to the Kubernetes API.`), RunE: func(cmd *cobra.Command, args []string) error { return a.runServer(ctx) }, Args: cobra.NoArgs, } diff --git a/test/integration/client_test.go b/test/integration/client_test.go index ad480d1a..138db6d7 100644 --- a/test/integration/client_test.go +++ b/test/integration/client_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/require" "github.com/suzerain-io/pinniped/internal/client" + "github.com/suzerain-io/pinniped/internal/here" "github.com/suzerain-io/pinniped/test/library" ) @@ -28,27 +29,28 @@ Test certificate and private key that should get an authentication error. Genera [1]: https://github.com/cloudflare/cfssl */ var ( - testCert = strings.TrimSpace(` ------BEGIN CERTIFICATE----- -MIICBDCCAaugAwIBAgIUeidKWlZQuoKfBGydObI1hMwzt9cwCgYIKoZIzj0EAwIw -SDELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNp -c2NvMRQwEgYDVQQDEwtleGFtcGxlLm5ldDAeFw0yMDA3MjgxOTI3MDBaFw0yMTA3 -MjgxOTI3MDBaMEgxCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMN -U2FuIEZyYW5jaXNjbzEUMBIGA1UEAxMLZXhhbXBsZS5uZXQwWTATBgcqhkjOPQIB -BggqhkjOPQMBBwNCAARk7XBC+OjYmrXOhm7RaJiHW4Q5VsE+iMV90Bzq7ansqAhb -04RI63Y7YPwu1aExutjLvnkWCrgf2ze8KB+8djUBo3MwcTAOBgNVHQ8BAf8EBAMC -BaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAw -HQYDVR0OBBYEFG0oZxV+LHUKfE4gQ67xfHJuGQ/4MBMGA1UdEQQMMAqCCHRlc3R1 -c2VyMAoGCCqGSM49BAMCA0cAMEQCIEwPZhPpYhYHndfTEsWOxnxzJkmhAcYIMCeJ -d9kyq/fPAiBNCJw1MCLT8LjNlyUZCfwI2zuI3e0w6vuau89oj2zvVA== ------END CERTIFICATE----- + testCert = here.Doc(` + -----BEGIN CERTIFICATE----- + MIICBDCCAaugAwIBAgIUeidKWlZQuoKfBGydObI1hMwzt9cwCgYIKoZIzj0EAwIw + SDELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAkNBMRYwFAYDVQQHEw1TYW4gRnJhbmNp + c2NvMRQwEgYDVQQDEwtleGFtcGxlLm5ldDAeFw0yMDA3MjgxOTI3MDBaFw0yMTA3 + MjgxOTI3MDBaMEgxCzAJBgNVBAYTAlVTMQswCQYDVQQIEwJDQTEWMBQGA1UEBxMN + U2FuIEZyYW5jaXNjbzEUMBIGA1UEAxMLZXhhbXBsZS5uZXQwWTATBgcqhkjOPQIB + BggqhkjOPQMBBwNCAARk7XBC+OjYmrXOhm7RaJiHW4Q5VsE+iMV90Bzq7ansqAhb + 04RI63Y7YPwu1aExutjLvnkWCrgf2ze8KB+8djUBo3MwcTAOBgNVHQ8BAf8EBAMC + BaAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMAwGA1UdEwEB/wQCMAAw + HQYDVR0OBBYEFG0oZxV+LHUKfE4gQ67xfHJuGQ/4MBMGA1UdEQQMMAqCCHRlc3R1 + c2VyMAoGCCqGSM49BAMCA0cAMEQCIEwPZhPpYhYHndfTEsWOxnxzJkmhAcYIMCeJ + d9kyq/fPAiBNCJw1MCLT8LjNlyUZCfwI2zuI3e0w6vuau89oj2zvVA== + -----END CERTIFICATE----- `) - testKey = maskKey(strings.TrimSpace(` ------BEGIN EC TESTING KEY----- -MHcCAQEEIAqkBGGKTH5GzLx8XZLAHEFW2E8jT+jpy0p6w6MMR7DkoAoGCCqGSM49 -AwEHoUQDQgAEZO1wQvjo2Jq1zoZu0WiYh1uEOVbBPojFfdAc6u2p7KgIW9OESOt2 -O2D8LtWhMbrYy755Fgq4H9s3vCgfvHY1AQ== ------END EC TESTING KEY----- + + testKey = maskKey(here.Doc(` + -----BEGIN EC TESTING KEY----- + MHcCAQEEIAqkBGGKTH5GzLx8XZLAHEFW2E8jT+jpy0p6w6MMR7DkoAoGCCqGSM49 + AwEHoUQDQgAEZO1wQvjo2Jq1zoZu0WiYh1uEOVbBPojFfdAc6u2p7KgIW9OESOt2 + O2D8LtWhMbrYy755Fgq4H9s3vCgfvHY1AQ== + -----END EC TESTING KEY----- `)) ) From 872330bee9a816f0f893224db10e68a9a463ce72 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Sun, 13 Sep 2020 10:22:27 -0700 Subject: [PATCH 3/9] Require newer version of kubectl in prepare-for-integration-tests.sh - Using the dry run option requires version 1.18+ --- hack/prepare-for-integration-tests.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/hack/prepare-for-integration-tests.sh b/hack/prepare-for-integration-tests.sh index d68fb6eb..b5d60ac0 100755 --- a/hack/prepare-for-integration-tests.sh +++ b/hack/prepare-for-integration-tests.sh @@ -88,6 +88,12 @@ check_dependency kapp "Please install kapp. e.g. 'brew tap k14s/tap && brew inst check_dependency kubectl "Please install kubectl. e.g. 'brew install kubectl' for MacOS" check_dependency htpasswd "Please install htpasswd. Should be pre-installed on MacOS. Usually found in 'apache2-utils' package for linux." +# Require kubectl >= 1.18.x +if [ "$(kubectl version --client=true --short | cut -d '.' -f 2)" -lt 18 ]; then + echo "kubectl >= 1.18.x is required, you have $(kubectl version --client=true --short | cut -d ':' -f2)" + exit 1 +fi + # # Setup kind and build the app # From 4379d2772c0b09c1eddbf9c5324f8431ea81f3fc Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Mon, 14 Sep 2020 19:07:18 -0700 Subject: [PATCH 4/9] CLI `get-kubeconfig` command reads kubeconfig and CredentialIssuerConfig --- cmd/pinniped/cmd/get_kubeconfig.go | 274 ++++++++--- cmd/pinniped/cmd/get_kubeconfig_test.go | 430 ++++++++++++++++-- cmd/pinniped/cmd/testdata/kubeconfig.yaml | 31 ++ ...eate_or_update_credential_issuer_config.go | 4 +- internal/controller/issuerconfig/publisher.go | 10 +- 5 files changed, 652 insertions(+), 97 deletions(-) create mode 100644 cmd/pinniped/cmd/testdata/kubeconfig.yaml diff --git a/cmd/pinniped/cmd/get_kubeconfig.go b/cmd/pinniped/cmd/get_kubeconfig.go index 7bceadc1..a92a4f96 100644 --- a/cmd/pinniped/cmd/get_kubeconfig.go +++ b/cmd/pinniped/cmd/get_kubeconfig.go @@ -6,20 +6,35 @@ SPDX-License-Identifier: Apache-2.0 package cmd import ( + "bytes" + "context" + "encoding/base64" "fmt" "io" "os" + "time" "github.com/ghodss/yaml" "github.com/spf13/cobra" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" v1 "k8s.io/client-go/tools/clientcmd/api/v1" + "github.com/suzerain-io/pinniped/generated/1.19/apis/crdpinniped/v1alpha1" + pinnipedclientset "github.com/suzerain-io/pinniped/generated/1.19/client/clientset/versioned" + "github.com/suzerain-io/pinniped/internal/controller/issuerconfig" "github.com/suzerain-io/pinniped/internal/here" ) const ( - getKubeConfigCmdTokenFlagName = "token" + getKubeConfigCmdTokenFlagName = "token" + getKubeConfigCmdKubeconfigFlagName = "kubeconfig" + getKubeConfigCmdKubeconfigContextFlagName = "kubeconfig-context" + getKubeConfigCmdPinnipedNamespaceFlagName = "pinniped-namespace" ) //nolint: gochecknoinits @@ -31,10 +46,15 @@ func init() { Short: "Print a kubeconfig for authenticating into a cluster via Pinniped", Long: here.Doc(` Print a kubeconfig for authenticating into a cluster via Pinniped. - - Assumes that you have admin-like access to the cluster using your - current kubeconfig context, in order to access Pinniped's metadata. - + + Requires admin-like access to the cluster using the current + kubeconfig context in order to access Pinniped's metadata. + The current kubeconfig is found similar to how kubectl finds it: + using the value of the --kubeconfig option, or if that is not + specified then from the value of the KUBECONFIG environment + variable, or if that is not specified then it defaults to + .kube/config in your home directory. + Prints a kubeconfig which is suitable to access the cluster using Pinniped as the authentication mechanism. This kubeconfig output can be saved to a file and used with future kubectl commands, e.g.: @@ -47,20 +67,54 @@ func init() { getKubeConfigCmd.Flags().StringP( getKubeConfigCmdTokenFlagName, - "t", "", - "The credential to include in the resulting kubeconfig output (Required)", + "", + "Credential to include in the resulting kubeconfig output (Required)", ) err := getKubeConfigCmd.MarkFlagRequired(getKubeConfigCmdTokenFlagName) if err != nil { panic(err) } + + getKubeConfigCmd.Flags().StringP( + getKubeConfigCmdKubeconfigFlagName, + "", + "", + "Path to the kubeconfig file", + ) + + getKubeConfigCmd.Flags().StringP( + getKubeConfigCmdKubeconfigContextFlagName, + "", + "", + "Kubeconfig context override", + ) + + getKubeConfigCmd.Flags().StringP( + getKubeConfigCmdPinnipedNamespaceFlagName, + "", + "pinniped", + "Namespace in which Pinniped was installed", + ) } func runGetKubeConfig(cmd *cobra.Command, _ []string) { token := cmd.Flag(getKubeConfigCmdTokenFlagName).Value.String() + kubeconfigPathOverride := cmd.Flag(getKubeConfigCmdKubeconfigFlagName).Value.String() + currentContextOverride := cmd.Flag(getKubeConfigCmdKubeconfigContextFlagName).Value.String() + pinnipedInstallationNamespace := cmd.Flag(getKubeConfigCmdPinnipedNamespaceFlagName).Value.String() - err := getKubeConfig(os.Stdout, token) + err := getKubeConfig( + os.Stdout, + os.Stderr, + token, + kubeconfigPathOverride, + currentContextOverride, + pinnipedInstallationNamespace, + func(restConfig *rest.Config) (pinnipedclientset.Interface, error) { + return pinnipedclientset.NewForConfig(restConfig) + }, + ) if err != nil { _, _ = fmt.Fprintf(os.Stderr, "error: %s\n", err.Error()) @@ -68,45 +122,157 @@ func runGetKubeConfig(cmd *cobra.Command, _ []string) { } } -func getKubeConfig(outputWriter io.Writer, token string) error { - clusterName := "pinniped-cluster" - userName := "pinniped-user" +func getKubeConfig( + outputWriter io.Writer, + warningsWriter io.Writer, + token string, + kubeconfigPathOverride string, + currentContextNameOverride string, + pinnipedInstallationNamespace string, + kubeClientCreator func(restConfig *rest.Config) (pinnipedclientset.Interface, error), +) error { + if token == "" { + return fmt.Errorf("--" + getKubeConfigCmdTokenFlagName + " flag value cannot be empty") + } fullPathToSelf, err := os.Executable() if err != nil { return fmt.Errorf("could not find path to self: %w", err) } - config := v1.Config{ - Kind: "Config", - APIVersion: v1.SchemeGroupVersion.Version, - Preferences: v1.Preferences{ - Colors: false, // TODO what does this setting do? - Extensions: nil, - }, + clientConfig := newClientConfig(kubeconfigPathOverride, currentContextNameOverride) + + currentKubeConfig, err := clientConfig.RawConfig() + if err != nil { + return err + } + + credentialIssuerConfig, err := fetchPinnipedCredentialIssuerConfig(clientConfig, kubeClientCreator, pinnipedInstallationNamespace) + if err != nil { + return err + } + + v1Cluster, err := copyCurrentClusterFromExistingKubeConfig(err, currentKubeConfig, currentContextNameOverride) + if err != nil { + return err + } + + // TODO handle when credentialIssuerConfig has no Status or no KubeConfigInfo + + err = issueWarningForNonMatchingServerOrCA(v1Cluster, credentialIssuerConfig, warningsWriter) + if err != nil { + return err + } + + config := newPinnipedKubeconfig(v1Cluster, fullPathToSelf, token) + + err = writeConfigAsYAML(outputWriter, config) + if err != nil { + return err + } + + return nil +} + +func issueWarningForNonMatchingServerOrCA(v1Cluster v1.Cluster, credentialIssuerConfig *v1alpha1.CredentialIssuerConfig, warningsWriter io.Writer) error { + credentialIssuerConfigCA, err := base64.StdEncoding.DecodeString(credentialIssuerConfig.Status.KubeConfigInfo.CertificateAuthorityData) + if err != nil { + return err + } + if v1Cluster.Server != credentialIssuerConfig.Status.KubeConfigInfo.Server || + !bytes.Equal(v1Cluster.CertificateAuthorityData, credentialIssuerConfigCA) { + _, err := warningsWriter.Write([]byte("WARNING: Server and certificate authority did not match between local kubeconfig and Pinniped's CredentialIssuerConfig on the cluster. Using local kubeconfig values.\n")) + if err != nil { + return fmt.Errorf("output write error: %w", err) + } + } + return nil +} + +func fetchPinnipedCredentialIssuerConfig(clientConfig clientcmd.ClientConfig, kubeClientCreator func(restConfig *rest.Config) (pinnipedclientset.Interface, error), pinnipedInstallationNamespace string) (*v1alpha1.CredentialIssuerConfig, error) { + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, err + } + clientset, err := kubeClientCreator(restConfig) + if err != nil { + return nil, err + } + + ctx, cancelFunc := context.WithTimeout(context.Background(), time.Second*20) + defer cancelFunc() + + credentialIssuerConfig, err := clientset.CrdV1alpha1().CredentialIssuerConfigs(pinnipedInstallationNamespace).Get(ctx, issuerconfig.ConfigName, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, fmt.Errorf( + `CredentialIssuerConfig "%s" was not found in namespace "%s". Is Pinniped installed on this cluster in namespace "%s"?`, + issuerconfig.ConfigName, + pinnipedInstallationNamespace, + pinnipedInstallationNamespace, + ) + } + return nil, err + } + + return credentialIssuerConfig, nil +} + +func newClientConfig(kubeconfigPathOverride string, currentContextName string) clientcmd.ClientConfig { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + loadingRules.ExplicitPath = kubeconfigPathOverride + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{ + CurrentContext: currentContextName, + }) + return clientConfig +} + +func writeConfigAsYAML(outputWriter io.Writer, config v1.Config) error { + output, err := yaml.Marshal(&config) + if err != nil { + return fmt.Errorf("YAML serialization error: %w", err) + } + + _, err = outputWriter.Write(output) + if err != nil { + return fmt.Errorf("output write error: %w", err) + } + + return nil +} + +func copyCurrentClusterFromExistingKubeConfig(err error, currentKubeConfig clientcmdapi.Config, currentContextNameOverride string) (v1.Cluster, error) { + v1Cluster := v1.Cluster{} + + contextName := currentKubeConfig.CurrentContext + if currentContextNameOverride != "" { + contextName = currentContextNameOverride + } + + err = v1.Convert_api_Cluster_To_v1_Cluster( + currentKubeConfig.Clusters[currentKubeConfig.Contexts[contextName].Cluster], + &v1Cluster, + nil, + ) + if err != nil { + return v1.Cluster{}, err + } + + return v1Cluster, nil +} + +func newPinnipedKubeconfig(v1Cluster v1.Cluster, fullPathToSelf string, token string) v1.Config { + clusterName := "pinniped-cluster" + userName := "pinniped-user" + + return v1.Config{ + Kind: "Config", + APIVersion: v1.SchemeGroupVersion.Version, + Preferences: v1.Preferences{}, Clusters: []v1.NamedCluster{ { Name: clusterName, - Cluster: v1.Cluster{}, // TODO fill in server and cert authority and such - }, - }, - AuthInfos: []v1.NamedAuthInfo{ - { - Name: userName, - AuthInfo: v1.AuthInfo{ - Exec: &v1.ExecConfig{ - Command: fullPathToSelf, - Args: []string{"exchange-credential"}, - Env: []v1.ExecEnvVar{ - {Name: "PINNIPED_K8S_API_ENDPOINT", Value: ""}, // TODO fill in value - {Name: "PINNIPED_CA_BUNDLE", Value: ""}, // TODO fill in value - {Name: "PINNIPED_TOKEN", Value: token}, - }, - APIVersion: clientauthenticationv1beta1.SchemeGroupVersion.String(), - InstallHint: "The Pinniped CLI is required to authenticate to the current cluster.\n" + - "For more information, please visit https://pinniped.dev", - }, - }, + Cluster: v1Cluster, }, }, Contexts: []v1.NamedContext{ @@ -118,19 +284,25 @@ func getKubeConfig(outputWriter io.Writer, token string) error { }, }, }, + AuthInfos: []v1.NamedAuthInfo{ + { + Name: userName, + AuthInfo: v1.AuthInfo{ + Exec: &v1.ExecConfig{ + Command: fullPathToSelf, + Args: []string{"exchange-credential"}, + Env: []v1.ExecEnvVar{ + {Name: "PINNIPED_K8S_API_ENDPOINT", Value: v1Cluster.Server}, + {Name: "PINNIPED_CA_BUNDLE", Value: string(v1Cluster.CertificateAuthorityData)}, + {Name: "PINNIPED_TOKEN", Value: token}, + }, + APIVersion: clientauthenticationv1beta1.SchemeGroupVersion.String(), + InstallHint: "The Pinniped CLI is required to authenticate to the current cluster.\n" + + "For more information, please visit https://pinniped.dev", + }, + }, + }, + }, CurrentContext: clusterName, - Extensions: nil, } - - output, err := yaml.Marshal(&config) - if err != nil { - return fmt.Errorf("YAML serialization error: %w", err) - } - - _, err = fmt.Fprint(outputWriter, string(output)) - if err != nil { - return fmt.Errorf("output write error: %w", err) - } - - return nil } diff --git a/cmd/pinniped/cmd/get_kubeconfig_test.go b/cmd/pinniped/cmd/get_kubeconfig_test.go index feccb96b..a99a4795 100644 --- a/cmd/pinniped/cmd/get_kubeconfig_test.go +++ b/cmd/pinniped/cmd/get_kubeconfig_test.go @@ -7,70 +7,422 @@ package cmd import ( "bytes" + "encoding/base64" + "fmt" "os" "testing" "github.com/sclevine/spec" "github.com/sclevine/spec/report" "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/rest" + crdpinnipedv1alpha1 "github.com/suzerain-io/pinniped/generated/1.19/apis/crdpinniped/v1alpha1" + pinnipedclientset "github.com/suzerain-io/pinniped/generated/1.19/client/clientset/versioned" + pinnipedfake "github.com/suzerain-io/pinniped/generated/1.19/client/clientset/versioned/fake" "github.com/suzerain-io/pinniped/internal/here" ) +// TODO write a test for the help message and command line flags similar to server_test.go + +func expectedKubeconfigYAML(clusterCAData, clusterServer, command, token, pinnipedEndpoint, pinnipedCABundle string) string { + return here.Docf(` + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: %s + server: %s + name: pinniped-cluster + contexts: + - context: + cluster: pinniped-cluster + user: pinniped-user + name: pinniped-cluster + current-context: pinniped-cluster + kind: Config + preferences: {} + users: + - name: pinniped-user + user: + exec: + apiVersion: client.authentication.k8s.io/v1beta1 + args: + - exchange-credential + command: %s + env: + - name: PINNIPED_K8S_API_ENDPOINT + value: %s + - name: PINNIPED_CA_BUNDLE + value: %s + - name: PINNIPED_TOKEN + value: %s + installHint: |- + The Pinniped CLI is required to authenticate to the current cluster. + For more information, please visit https://pinniped.dev + `, clusterCAData, clusterServer, command, pinnipedEndpoint, pinnipedCABundle, token) +} + +func newCredentialIssuerConfig(server, certificateAuthorityData string) *crdpinnipedv1alpha1.CredentialIssuerConfig { + return &crdpinnipedv1alpha1.CredentialIssuerConfig{ + TypeMeta: metav1.TypeMeta{ + Kind: "CredentialIssuerConfig", + APIVersion: crdpinnipedv1alpha1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "pinniped-config", + Namespace: "some-namespace", + }, + Status: crdpinnipedv1alpha1.CredentialIssuerConfigStatus{ + KubeConfigInfo: &crdpinnipedv1alpha1.CredentialIssuerConfigKubeConfigInfo{ + Server: server, + CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(certificateAuthorityData)), + }, + }, + } +} + func TestGetKubeConfig(t *testing.T) { spec.Run(t, "cmd.getKubeConfig", func(t *testing.T, when spec.G, it spec.S) { var r *require.Assertions - var buffer *bytes.Buffer + var outputBuffer *bytes.Buffer + var warningsBuffer *bytes.Buffer var fullPathToSelf string + var pinnipedClient *pinnipedfake.Clientset it.Before(func() { r = require.New(t) - buffer = new(bytes.Buffer) + + outputBuffer = new(bytes.Buffer) + warningsBuffer = new(bytes.Buffer) var err error fullPathToSelf, err = os.Executable() r.NoError(err) + + pinnipedClient = pinnipedfake.NewSimpleClientset() }) - it("writes the kubeconfig to the given writer", func() { - err := getKubeConfig(buffer, "some-token") - r.NoError(err) + when("the CredentialIssuerConfig is found on the cluster with a configuration that matches the existing kubeconfig", func() { + it.Before(func() { + r.NoError(pinnipedClient.Tracker().Add( + newCredentialIssuerConfig("https://fake-server-url-value", "fake-certificate-authority-data-value"), + )) + }) - expectedYAML := here.Docf(` - apiVersion: v1 - clusters: - - cluster: - server: "" - name: pinniped-cluster - contexts: - - context: - cluster: pinniped-cluster - user: pinniped-user - name: pinniped-cluster - current-context: pinniped-cluster - kind: Config - preferences: {} - users: - - name: pinniped-user - user: - exec: - apiVersion: client.authentication.k8s.io/v1beta1 - args: - - exchange-credential - command: %s - env: - - name: PINNIPED_K8S_API_ENDPOINT - value: "" - - name: PINNIPED_CA_BUNDLE - value: "" - - name: PINNIPED_TOKEN - value: some-token - installHint: |- - The Pinniped CLI is required to authenticate to the current cluster. - For more information, please visit https://pinniped.dev - `, fullPathToSelf) + it("writes the kubeconfig to the given writer", func() { + kubeClientCreatorFuncWasCalled := false + err := getKubeConfig(outputBuffer, + warningsBuffer, + "some-token", + "./testdata/kubeconfig.yaml", + "", + "some-namespace", + func(restConfig *rest.Config) (pinnipedclientset.Interface, error) { + kubeClientCreatorFuncWasCalled = true + r.Equal("https://fake-server-url-value", restConfig.Host) + r.Equal("fake-certificate-authority-data-value", string(restConfig.CAData)) + return pinnipedClient, nil + }, + ) + r.NoError(err) + r.True(kubeClientCreatorFuncWasCalled) - r.Equal(expectedYAML, buffer.String()) + r.Empty(warningsBuffer.String()) + r.Equal(expectedKubeconfigYAML( + base64.StdEncoding.EncodeToString([]byte("fake-certificate-authority-data-value")), + "https://fake-server-url-value", + fullPathToSelf, + "some-token", + "https://fake-server-url-value", + "fake-certificate-authority-data-value", + ), outputBuffer.String()) + }) + + when("the currentContextOverride is used to specify a context other than the default context", func() { + it.Before(func() { + // update the Server and CertificateAuthorityData to make them match the other kubeconfig context + r.NoError(pinnipedClient.Tracker().Update( + schema.GroupVersionResource{ + Group: crdpinnipedv1alpha1.GroupName, + Version: crdpinnipedv1alpha1.SchemeGroupVersion.Version, + Resource: "credentialissuerconfigs", + }, + newCredentialIssuerConfig( + "https://some-other-fake-server-url-value", + "some-other-fake-certificate-authority-data-value", + ), + "some-namespace", + )) + }) + + when("that context exists", func() { + it("writes the kubeconfig to the given writer using the specified context", func() { + kubeClientCreatorFuncWasCalled := false + err := getKubeConfig(outputBuffer, + warningsBuffer, + "some-token", + "./testdata/kubeconfig.yaml", + "some-other-context", + "some-namespace", + func(restConfig *rest.Config) (pinnipedclientset.Interface, error) { + kubeClientCreatorFuncWasCalled = true + r.Equal("https://some-other-fake-server-url-value", restConfig.Host) + r.Equal("some-other-fake-certificate-authority-data-value", string(restConfig.CAData)) + return pinnipedClient, nil + }, + ) + r.NoError(err) + r.True(kubeClientCreatorFuncWasCalled) + + r.Empty(warningsBuffer.String()) + r.Equal(expectedKubeconfigYAML( + base64.StdEncoding.EncodeToString([]byte("some-other-fake-certificate-authority-data-value")), + "https://some-other-fake-server-url-value", + fullPathToSelf, + "some-token", + "https://some-other-fake-server-url-value", + "some-other-fake-certificate-authority-data-value", + ), outputBuffer.String()) + }) + }) + + when("that context does not exist the in the current kubeconfig", func() { + it("returns an error", func() { + err := getKubeConfig(outputBuffer, + warningsBuffer, + "some-token", + "./testdata/kubeconfig.yaml", + "this-context-name-does-not-exist-in-kubeconfig.yaml", + "some-namespace", + func(restConfig *rest.Config) (pinnipedclientset.Interface, error) { return pinnipedClient, nil }, + ) + r.EqualError(err, `context "this-context-name-does-not-exist-in-kubeconfig.yaml" does not exist`) + r.Empty(warningsBuffer.String()) + r.Empty(outputBuffer.String()) + }) + }) + }) + + when("the token passed in is empty", func() { + it("returns an error", func() { + err := getKubeConfig(outputBuffer, + warningsBuffer, + "", + "./testdata/kubeconfig.yaml", + "", + "some-namespace", + func(restConfig *rest.Config) (pinnipedclientset.Interface, error) { return pinnipedClient, nil }, + ) + r.EqualError(err, "--token flag value cannot be empty") + r.Empty(warningsBuffer.String()) + r.Empty(outputBuffer.String()) + }) + }) + + when("the kubeconfig path passed refers to a file that does not exist", func() { + it("returns an error", func() { + err := getKubeConfig(outputBuffer, + warningsBuffer, + "some-token", + "./testdata/this-file-does-not-exist.yaml", + "", + "some-namespace", + func(restConfig *rest.Config) (pinnipedclientset.Interface, error) { return pinnipedClient, nil }, + ) + r.EqualError(err, "stat ./testdata/this-file-does-not-exist.yaml: no such file or directory") + r.Empty(warningsBuffer.String()) + r.Empty(outputBuffer.String()) + }) + }) + + when("the kubeconfig path parameter is empty", func() { + it.Before(func() { + // Note that this is technically polluting other parallel tests in this file, but other tests + // are always specifying the kubeconfigPathOverride parameter, so they're not actually looking + // at the value of this environment variable. + r.NoError(os.Setenv("KUBECONFIG", "./testdata/kubeconfig.yaml")) + }) + + it.After(func() { + r.NoError(os.Unsetenv("KUBECONFIG")) + }) + + it("falls back to using the KUBECONFIG env var to find the kubeconfig file", func() { + kubeClientCreatorFuncWasCalled := false + err := getKubeConfig(outputBuffer, + warningsBuffer, + "some-token", + "", + "", + "some-namespace", + func(restConfig *rest.Config) (pinnipedclientset.Interface, error) { + kubeClientCreatorFuncWasCalled = true + r.Equal("https://fake-server-url-value", restConfig.Host) + r.Equal("fake-certificate-authority-data-value", string(restConfig.CAData)) + return pinnipedClient, nil + }, + ) + r.NoError(err) + r.True(kubeClientCreatorFuncWasCalled) + + r.Empty(warningsBuffer.String()) + r.Equal(expectedKubeconfigYAML( + base64.StdEncoding.EncodeToString([]byte("fake-certificate-authority-data-value")), + "https://fake-server-url-value", + fullPathToSelf, + "some-token", + "https://fake-server-url-value", + "fake-certificate-authority-data-value", + ), outputBuffer.String()) + }) + }) + + when("the wrong pinniped namespace is passed in", func() { + it("returns an error", func() { + kubeClientCreatorFuncWasCalled := false + err := getKubeConfig(outputBuffer, + warningsBuffer, + "some-token", + "./testdata/kubeconfig.yaml", + "", + "this-is-the-wrong-namespace", + func(restConfig *rest.Config) (pinnipedclientset.Interface, error) { + kubeClientCreatorFuncWasCalled = true + r.Equal("https://fake-server-url-value", restConfig.Host) + r.Equal("fake-certificate-authority-data-value", string(restConfig.CAData)) + return pinnipedClient, nil + }, + ) + r.EqualError(err, `CredentialIssuerConfig "pinniped-config" was not found in namespace "this-is-the-wrong-namespace". Is Pinniped installed on this cluster in namespace "this-is-the-wrong-namespace"?`) + r.True(kubeClientCreatorFuncWasCalled) + }) + }) }) + + when("the CredentialIssuerConfig is found on the cluster with a configuration that does not match the existing kubeconfig", func() { + when("the Server doesn't match", func() { + it.Before(func() { + r.NoError(pinnipedClient.Tracker().Add( + newCredentialIssuerConfig("non-matching-pinniped-server-url", "fake-certificate-authority-data-value"), + )) + }) + + it("writes the kubeconfig to the given writer using the values found in the local kubeconfig and issues a warning", func() { + kubeClientCreatorFuncWasCalled := false + err := getKubeConfig(outputBuffer, + warningsBuffer, + "some-token", + "./testdata/kubeconfig.yaml", + "", + "some-namespace", + func(restConfig *rest.Config) (pinnipedclientset.Interface, error) { + kubeClientCreatorFuncWasCalled = true + r.Equal("https://fake-server-url-value", restConfig.Host) + r.Equal("fake-certificate-authority-data-value", string(restConfig.CAData)) + return pinnipedClient, nil + }, + ) + r.NoError(err) + r.True(kubeClientCreatorFuncWasCalled) + + r.Equal( + "WARNING: Server and certificate authority did not match between local kubeconfig and Pinniped's CredentialIssuerConfig on the cluster. Using local kubeconfig values.\n", + warningsBuffer.String(), + ) + r.Equal(expectedKubeconfigYAML( + base64.StdEncoding.EncodeToString([]byte("fake-certificate-authority-data-value")), + "https://fake-server-url-value", + fullPathToSelf, + "some-token", + "https://fake-server-url-value", + "fake-certificate-authority-data-value", + ), outputBuffer.String()) + }) + }) + + when("the CA doesn't match", func() { + it.Before(func() { + r.NoError(pinnipedClient.Tracker().Add( + newCredentialIssuerConfig("https://fake-server-url-value", "non-matching-certificate-authority-data-value"), + )) + }) + + it("writes the kubeconfig to the given writer using the values found in the local kubeconfig and issues a warning", func() { + kubeClientCreatorFuncWasCalled := false + err := getKubeConfig(outputBuffer, + warningsBuffer, + "some-token", + "./testdata/kubeconfig.yaml", + "", + "some-namespace", + func(restConfig *rest.Config) (pinnipedclientset.Interface, error) { + kubeClientCreatorFuncWasCalled = true + r.Equal("https://fake-server-url-value", restConfig.Host) + r.Equal("fake-certificate-authority-data-value", string(restConfig.CAData)) + return pinnipedClient, nil + }, + ) + r.NoError(err) + r.True(kubeClientCreatorFuncWasCalled) + + r.Equal( + "WARNING: Server and certificate authority did not match between local kubeconfig and Pinniped's CredentialIssuerConfig on the cluster. Using local kubeconfig values.\n", + warningsBuffer.String(), + ) + r.Equal(expectedKubeconfigYAML( + base64.StdEncoding.EncodeToString([]byte("fake-certificate-authority-data-value")), + "https://fake-server-url-value", + fullPathToSelf, + "some-token", + "https://fake-server-url-value", + "fake-certificate-authority-data-value", + ), outputBuffer.String()) + }) + }) + }) + + when("the CredentialIssuerConfig does not exist on the cluster", func() { + it("returns an error", func() { + kubeClientCreatorFuncWasCalled := false + err := getKubeConfig(outputBuffer, + warningsBuffer, + "some-token", + "./testdata/kubeconfig.yaml", + "", + "some-namespace", + func(restConfig *rest.Config) (pinnipedclientset.Interface, error) { + kubeClientCreatorFuncWasCalled = true + r.Equal("https://fake-server-url-value", restConfig.Host) + r.Equal("fake-certificate-authority-data-value", string(restConfig.CAData)) + return pinnipedClient, nil + }, + ) + r.True(kubeClientCreatorFuncWasCalled) + r.EqualError(err, `CredentialIssuerConfig "pinniped-config" was not found in namespace "some-namespace". Is Pinniped installed on this cluster in namespace "some-namespace"?`) + r.Empty(warningsBuffer.String()) + r.Empty(outputBuffer.String()) + }) + }) + + when("there is an error while getting the CredentialIssuerConfig from the cluster", func() { + it("returns an error", func() { + err := getKubeConfig(outputBuffer, + warningsBuffer, + "some-token", + "./testdata/kubeconfig.yaml", + "", + "some-namespace", + func(restConfig *rest.Config) (pinnipedclientset.Interface, error) { + return nil, fmt.Errorf("some error getting CredentialIssuerConfig") + }, + ) + r.EqualError(err, "some error getting CredentialIssuerConfig") + r.Empty(warningsBuffer.String()) + r.Empty(outputBuffer.String()) + }) + }) + }, spec.Parallel(), spec.Report(report.Terminal{})) } diff --git a/cmd/pinniped/cmd/testdata/kubeconfig.yaml b/cmd/pinniped/cmd/testdata/kubeconfig.yaml new file mode 100644 index 00000000..c89a226e --- /dev/null +++ b/cmd/pinniped/cmd/testdata/kubeconfig.yaml @@ -0,0 +1,31 @@ +apiVersion: v1 +clusters: + - cluster: + certificate-authority-data: ZmFrZS1jZXJ0aWZpY2F0ZS1hdXRob3JpdHktZGF0YS12YWx1ZQ== # fake-certificate-authority-data-value + server: https://fake-server-url-value + name: kind-kind + - cluster: + certificate-authority-data: c29tZS1vdGhlci1mYWtlLWNlcnRpZmljYXRlLWF1dGhvcml0eS1kYXRhLXZhbHVl # some-other-fake-certificate-authority-data-value + server: https://some-other-fake-server-url-value + name: some-other-cluster +contexts: + - context: + cluster: kind-kind + user: kind-kind + name: kind-kind + - context: + cluster: some-other-cluster + user: some-other-user + name: some-other-context +current-context: kind-kind +kind: Config +preferences: {} +users: + - name: kind-kind + user: + client-certificate-data: ZmFrZS1jbGllbnQtY2VydGlmaWNhdGUtZGF0YS12YWx1ZQ== # fake-client-certificate-data-value + client-key-data: ZmFrZS1jbGllbnQta2V5LWRhdGEtdmFsdWU= # fake-client-key-data-value + - name: some-other-user + user: + client-certificate-data: c29tZS1vdGhlci1mYWtlLWNsaWVudC1jZXJ0aWZpY2F0ZS1kYXRhLXZhbHVl # some-other-fake-client-certificate-data-value + client-key-data: c29tZS1vdGhlci1mYWtlLWNsaWVudC1rZXktZGF0YS12YWx1ZQ== # some-other-fake-client-key-data-value diff --git a/internal/controller/issuerconfig/create_or_update_credential_issuer_config.go b/internal/controller/issuerconfig/create_or_update_credential_issuer_config.go index 07138789..99410e9b 100644 --- a/internal/controller/issuerconfig/create_or_update_credential_issuer_config.go +++ b/internal/controller/issuerconfig/create_or_update_credential_issuer_config.go @@ -28,7 +28,7 @@ func CreateOrUpdateCredentialIssuerConfig( existingCredentialIssuerConfig, err := pinnipedClient. CrdV1alpha1(). CredentialIssuerConfigs(credentialIssuerConfigNamespace). - Get(ctx, configName, metav1.GetOptions{}) + Get(ctx, ConfigName, metav1.GetOptions{}) notFound := k8serrors.IsNotFound(err) if err != nil && !notFound { @@ -39,7 +39,7 @@ func CreateOrUpdateCredentialIssuerConfig( ctx, existingCredentialIssuerConfig, notFound, - configName, + ConfigName, credentialIssuerConfigNamespace, pinnipedClient, applyUpdatesToCredentialIssuerConfigFunc) diff --git a/internal/controller/issuerconfig/publisher.go b/internal/controller/issuerconfig/publisher.go index 46774514..82bf5293 100644 --- a/internal/controller/issuerconfig/publisher.go +++ b/internal/controller/issuerconfig/publisher.go @@ -24,10 +24,10 @@ import ( const ( ClusterInfoNamespace = "kube-public" + ConfigName = "pinniped-config" + clusterInfoName = "cluster-info" clusterInfoConfigMapKey = "kubeconfig" - - configName = "pinniped-config" ) type publisherController struct { @@ -64,7 +64,7 @@ func NewPublisherController( ), withInformer( credentialIssuerConfigInformer, - pinnipedcontroller.NameAndNamespaceExactMatchFilterFactory(configName, namespace), + pinnipedcontroller.NameAndNamespaceExactMatchFilterFactory(ConfigName, namespace), controllerlib.InformerOption{}, ), ) @@ -114,7 +114,7 @@ func (c *publisherController) Sync(ctx controllerlib.Context) error { existingCredentialIssuerConfigFromInformerCache, err := c.credentialIssuerConfigInformer. Lister(). CredentialIssuerConfigs(c.namespace). - Get(configName) + Get(ConfigName) notFound = k8serrors.IsNotFound(err) if err != nil && !notFound { return fmt.Errorf("could not get credentialissuerconfig: %w", err) @@ -131,7 +131,7 @@ func (c *publisherController) Sync(ctx controllerlib.Context) error { ctx.Context, existingCredentialIssuerConfigFromInformerCache, notFound, - configName, + ConfigName, c.namespace, c.pinnipedClient, updateServerAndCAFunc) From 879d847ffb092387db9587aea26f6be50af310a9 Mon Sep 17 00:00:00 2001 From: Andrew Keesler Date: Tue, 15 Sep 2020 10:04:25 -0400 Subject: [PATCH 5/9] cmd/pinniped/cmd: add get-kubeconfig cli tests Signed-off-by: Andrew Keesler --- cmd/pinniped/cmd/get_kubeconfig.go | 75 +++++++-- cmd/pinniped/cmd/get_kubeconfig_test.go | 215 +++++++++++++++++++++++- 2 files changed, 271 insertions(+), 19 deletions(-) diff --git a/cmd/pinniped/cmd/get_kubeconfig.go b/cmd/pinniped/cmd/get_kubeconfig.go index a92a4f96..1c4937ca 100644 --- a/cmd/pinniped/cmd/get_kubeconfig.go +++ b/cmd/pinniped/cmd/get_kubeconfig.go @@ -26,6 +26,7 @@ import ( "github.com/suzerain-io/pinniped/generated/1.19/apis/crdpinniped/v1alpha1" pinnipedclientset "github.com/suzerain-io/pinniped/generated/1.19/client/clientset/versioned" + "github.com/suzerain-io/pinniped/internal/constable" "github.com/suzerain-io/pinniped/internal/controller/issuerconfig" "github.com/suzerain-io/pinniped/internal/here" ) @@ -39,8 +40,42 @@ const ( //nolint: gochecknoinits func init() { - getKubeConfigCmd := &cobra.Command{ - Run: runGetKubeConfig, + rootCmd.AddCommand(newGetKubeConfigCmd(os.Args, os.Stdout, os.Stderr).cmd) +} + +type getKubeConfigCommand struct { + // runFunc is called by the cobra.Command.Run hook. It is included here for + // testability. + runFunc func( + stdout, stderr io.Writer, + token, kubeconfigPathOverride, currentContextOverride, pinnipedInstallationNamespace string, + ) + + // cmd is the cobra.Command for this CLI command. It is included here for + // testability. + cmd *cobra.Command +} + +func newGetKubeConfigCmd(args []string, stdout, stderr io.Writer) *getKubeConfigCommand { + c := &getKubeConfigCommand{ + runFunc: runGetKubeConfig, + } + + c.cmd = &cobra.Command{ + Run: func(cmd *cobra.Command, _ []string) { + token := cmd.Flag(getKubeConfigCmdTokenFlagName).Value.String() + kubeconfigPathOverride := cmd.Flag(getKubeConfigCmdKubeconfigFlagName).Value.String() + currentContextOverride := cmd.Flag(getKubeConfigCmdKubeconfigContextFlagName).Value.String() + pinnipedInstallationNamespace := cmd.Flag(getKubeConfigCmdPinnipedNamespaceFlagName).Value.String() + c.runFunc( + stdout, + stderr, + token, + kubeconfigPathOverride, + currentContextOverride, + pinnipedInstallationNamespace, + ) + }, Args: cobra.NoArgs, // do not accept positional arguments for this command Use: "get-kubeconfig", Short: "Print a kubeconfig for authenticating into a cluster via Pinniped", @@ -63,50 +98,52 @@ func init() { `), } - rootCmd.AddCommand(getKubeConfigCmd) + c.cmd.SetArgs(args) + c.cmd.SetOut(stdout) + c.cmd.SetErr(stderr) - getKubeConfigCmd.Flags().StringP( + c.cmd.Flags().StringP( getKubeConfigCmdTokenFlagName, "", "", "Credential to include in the resulting kubeconfig output (Required)", ) - err := getKubeConfigCmd.MarkFlagRequired(getKubeConfigCmdTokenFlagName) + err := c.cmd.MarkFlagRequired(getKubeConfigCmdTokenFlagName) if err != nil { panic(err) } - getKubeConfigCmd.Flags().StringP( + c.cmd.Flags().StringP( getKubeConfigCmdKubeconfigFlagName, "", "", "Path to the kubeconfig file", ) - getKubeConfigCmd.Flags().StringP( + c.cmd.Flags().StringP( getKubeConfigCmdKubeconfigContextFlagName, "", "", "Kubeconfig context override", ) - getKubeConfigCmd.Flags().StringP( + c.cmd.Flags().StringP( getKubeConfigCmdPinnipedNamespaceFlagName, "", "pinniped", "Namespace in which Pinniped was installed", ) + + return c } -func runGetKubeConfig(cmd *cobra.Command, _ []string) { - token := cmd.Flag(getKubeConfigCmdTokenFlagName).Value.String() - kubeconfigPathOverride := cmd.Flag(getKubeConfigCmdKubeconfigFlagName).Value.String() - currentContextOverride := cmd.Flag(getKubeConfigCmdKubeconfigContextFlagName).Value.String() - pinnipedInstallationNamespace := cmd.Flag(getKubeConfigCmdPinnipedNamespaceFlagName).Value.String() - +func runGetKubeConfig( + stdout, stderr io.Writer, + token, kubeconfigPathOverride, currentContextOverride, pinnipedInstallationNamespace string, +) { err := getKubeConfig( - os.Stdout, - os.Stderr, + stdout, + stderr, token, kubeconfigPathOverride, currentContextOverride, @@ -152,13 +189,15 @@ func getKubeConfig( return err } + if credentialIssuerConfig.Status.KubeConfigInfo == nil { + return constable.Error(`CredentialIssuerConfig "pinniped-config" was missing KubeConfigInfo`) + } + v1Cluster, err := copyCurrentClusterFromExistingKubeConfig(err, currentKubeConfig, currentContextNameOverride) if err != nil { return err } - // TODO handle when credentialIssuerConfig has no Status or no KubeConfigInfo - err = issueWarningForNonMatchingServerOrCA(v1Cluster, credentialIssuerConfig, warningsWriter) if err != nil { return err diff --git a/cmd/pinniped/cmd/get_kubeconfig_test.go b/cmd/pinniped/cmd/get_kubeconfig_test.go index a99a4795..e08316cf 100644 --- a/cmd/pinniped/cmd/get_kubeconfig_test.go +++ b/cmd/pinniped/cmd/get_kubeconfig_test.go @@ -9,6 +9,7 @@ import ( "bytes" "encoding/base64" "fmt" + "io" "os" "testing" @@ -25,7 +26,180 @@ import ( "github.com/suzerain-io/pinniped/internal/here" ) -// TODO write a test for the help message and command line flags similar to server_test.go +const ( + knownGoodUsage = ` +Usage: + get-kubeconfig [flags] + +Flags: + -h, --help help for get-kubeconfig + --kubeconfig string Path to the kubeconfig file + --kubeconfig-context string Kubeconfig context override + --pinniped-namespace string Namespace in which Pinniped was installed (default "pinniped") + --token string Credential to include in the resulting kubeconfig output (Required) + +` + + knownGoodHelp = `Print a kubeconfig for authenticating into a cluster via Pinniped. + +Requires admin-like access to the cluster using the current +kubeconfig context in order to access Pinniped's metadata. +The current kubeconfig is found similar to how kubectl finds it: +using the value of the --kubeconfig option, or if that is not +specified then from the value of the KUBECONFIG environment +variable, or if that is not specified then it defaults to +.kube/config in your home directory. + +Prints a kubeconfig which is suitable to access the cluster using +Pinniped as the authentication mechanism. This kubeconfig output +can be saved to a file and used with future kubectl commands, e.g.: + pinniped get-kubeconfig --token $MY_TOKEN > $HOME/mycluster-kubeconfig + kubectl --kubeconfig $HOME/mycluster-kubeconfig get pods + +Usage: + get-kubeconfig [flags] + +Flags: + -h, --help help for get-kubeconfig + --kubeconfig string Path to the kubeconfig file + --kubeconfig-context string Kubeconfig context override + --pinniped-namespace string Namespace in which Pinniped was installed (default "pinniped") + --token string Credential to include in the resulting kubeconfig output (Required) +` +) + +func TestNewGetKubeConfigCmd(t *testing.T) { + spec.Run(t, "newGetKubeConfigCmd", func(t *testing.T, when spec.G, it spec.S) { + var r *require.Assertions + var stdout, stderr *bytes.Buffer + + it.Before(func() { + r = require.New(t) + + stdout, stderr = bytes.NewBuffer([]byte{}), bytes.NewBuffer([]byte{}) + }) + + it("passes all flags to runFunc", func() { + args := []string{ + "--token", "some-token", + "--kubeconfig", "some-kubeconfig", + "--kubeconfig-context", "some-kubeconfig-context", + "--pinniped-namespace", "some-pinniped-namespace", + } + c := newGetKubeConfigCmd(args, stdout, stderr) + + runFuncCalled := false + c.runFunc = func( + out, err io.Writer, + token, kubeconfigPathOverride, currentContextOverride, pinnipedInstallationNamespace string, + ) { + runFuncCalled = true + r.Equal("some-token", token) + r.Equal("some-kubeconfig", kubeconfigPathOverride) + r.Equal("some-kubeconfig-context", currentContextOverride) + r.Equal("some-pinniped-namespace", pinnipedInstallationNamespace) + } + + r.NoError(c.cmd.Execute()) + r.True(runFuncCalled) + r.Empty(stdout.String()) + r.Empty(stderr.String()) + }) + + it("requires the 'token' flag", func() { + args := []string{ + "--kubeconfig", "some-kubeconfig", + "--kubeconfig-context", "some-kubeconfig-context", + "--pinniped-namespace", "some-pinniped-namespace", + } + c := newGetKubeConfigCmd(args, stdout, stderr) + + runFuncCalled := false + c.runFunc = func( + out, err io.Writer, + token, kubeconfigPathOverride, currentContextOverride, pinnipedInstallationNamespace string, + ) { + runFuncCalled = true + } + + errorMessage := `required flag(s) "token" not set` + r.EqualError(c.cmd.Execute(), errorMessage) + r.False(runFuncCalled) + + output := "Error: " + errorMessage + knownGoodUsage + r.Equal(output, stdout.String()) + r.Empty(stderr.String()) + }) + + it("defaults the flags correctly", func() { + args := []string{ + "--token", "some-token", + } + c := newGetKubeConfigCmd(args, stdout, stderr) + + runFuncCalled := false + c.runFunc = func( + out, err io.Writer, + token, kubeconfigPathOverride, currentContextOverride, pinnipedInstallationNamespace string, + ) { + runFuncCalled = true + r.Equal("some-token", token) + r.Equal("", kubeconfigPathOverride) + r.Equal("", currentContextOverride) + r.Equal("pinniped", pinnipedInstallationNamespace) + } + + r.NoError(c.cmd.Execute()) + r.True(runFuncCalled) + r.Empty(stdout.String()) + r.Empty(stderr.String()) + }) + + it("fails when args are passed", func() { + args := []string{ + "--token", "some-token", + "some-arg", + } + c := newGetKubeConfigCmd(args, stdout, stderr) + + runFuncCalled := false + c.runFunc = func( + out, err io.Writer, + token, kubeconfigPathOverride, currentContextOverride, pinnipedInstallationNamespace string, + ) { + runFuncCalled = true + } + + errorMessage := `unknown command "some-arg" for "get-kubeconfig"` + r.EqualError(c.cmd.Execute(), errorMessage) + r.False(runFuncCalled) + + output := "Error: " + errorMessage + knownGoodUsage + r.Equal(output, stdout.String()) + r.Empty(stderr.String()) + }) + + it("prints a nice help message", func() { + args := []string{ + "--help", + } + c := newGetKubeConfigCmd(args, stdout, stderr) + + runFuncCalled := false + c.runFunc = func( + out, err io.Writer, + token, kubeconfigPathOverride, currentContextOverride, pinnipedInstallationNamespace string, + ) { + runFuncCalled = true + } + + r.NoError(c.cmd.Execute()) + r.False(runFuncCalled) + r.Equal(knownGoodHelp, stdout.String()) + r.Empty(stderr.String()) + }) + }, spec.Parallel(), spec.Report(report.Terminal{})) +} func expectedKubeconfigYAML(clusterCAData, clusterServer, command, token, pinnipedEndpoint, pinnipedCABundle string) string { return here.Docf(` @@ -383,6 +557,45 @@ func TestGetKubeConfig(t *testing.T) { }) }) + when("the CredentialIssuerConfig is found on the cluster with an empty KubeConfigInfo", func() { + it.Before(func() { + r.NoError(pinnipedClient.Tracker().Add( + &crdpinnipedv1alpha1.CredentialIssuerConfig{ + TypeMeta: metav1.TypeMeta{ + Kind: "CredentialIssuerConfig", + APIVersion: crdpinnipedv1alpha1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "pinniped-config", + Namespace: "some-namespace", + }, + Status: crdpinnipedv1alpha1.CredentialIssuerConfigStatus{}, + }, + )) + }) + + it("returns an error", func() { + kubeClientCreatorFuncWasCalled := false + err := getKubeConfig(outputBuffer, + warningsBuffer, + "some-token", + "./testdata/kubeconfig.yaml", + "", + "some-namespace", + func(restConfig *rest.Config) (pinnipedclientset.Interface, error) { + kubeClientCreatorFuncWasCalled = true + r.Equal("https://fake-server-url-value", restConfig.Host) + r.Equal("fake-certificate-authority-data-value", string(restConfig.CAData)) + return pinnipedClient, nil + }, + ) + r.True(kubeClientCreatorFuncWasCalled) + r.EqualError(err, `CredentialIssuerConfig "pinniped-config" was missing KubeConfigInfo`) + r.Empty(warningsBuffer.String()) + r.Empty(outputBuffer.String()) + }) + }) + when("the CredentialIssuerConfig does not exist on the cluster", func() { it("returns an error", func() { kubeClientCreatorFuncWasCalled := false From 82ef9e480654a2eddcdc3b084717c12b6ead1bf0 Mon Sep 17 00:00:00 2001 From: Andrew Keesler Date: Tue, 15 Sep 2020 11:00:00 -0400 Subject: [PATCH 6/9] cmd/pinniped/cmd: fix some linting errors Signed-off-by: Andrew Keesler --- cmd/pinniped/cmd/get_kubeconfig.go | 12 ++++++------ cmd/pinniped/cmd/get_kubeconfig_test.go | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/cmd/pinniped/cmd/get_kubeconfig.go b/cmd/pinniped/cmd/get_kubeconfig.go index 1c4937ca..ff9b98ad 100644 --- a/cmd/pinniped/cmd/get_kubeconfig.go +++ b/cmd/pinniped/cmd/get_kubeconfig.go @@ -169,7 +169,7 @@ func getKubeConfig( kubeClientCreator func(restConfig *rest.Config) (pinnipedclientset.Interface, error), ) error { if token == "" { - return fmt.Errorf("--" + getKubeConfigCmdTokenFlagName + " flag value cannot be empty") + return constable.Error("--" + getKubeConfigCmdTokenFlagName + " flag value cannot be empty") } fullPathToSelf, err := os.Executable() @@ -193,7 +193,7 @@ func getKubeConfig( return constable.Error(`CredentialIssuerConfig "pinniped-config" was missing KubeConfigInfo`) } - v1Cluster, err := copyCurrentClusterFromExistingKubeConfig(err, currentKubeConfig, currentContextNameOverride) + v1Cluster, err := copyCurrentClusterFromExistingKubeConfig(currentKubeConfig, currentContextNameOverride) if err != nil { return err } @@ -244,12 +244,12 @@ func fetchPinnipedCredentialIssuerConfig(clientConfig clientcmd.ClientConfig, ku credentialIssuerConfig, err := clientset.CrdV1alpha1().CredentialIssuerConfigs(pinnipedInstallationNamespace).Get(ctx, issuerconfig.ConfigName, metav1.GetOptions{}) if err != nil { if apierrors.IsNotFound(err) { - return nil, fmt.Errorf( + return nil, constable.Error(fmt.Sprintf( `CredentialIssuerConfig "%s" was not found in namespace "%s". Is Pinniped installed on this cluster in namespace "%s"?`, issuerconfig.ConfigName, pinnipedInstallationNamespace, pinnipedInstallationNamespace, - ) + )) } return nil, err } @@ -280,7 +280,7 @@ func writeConfigAsYAML(outputWriter io.Writer, config v1.Config) error { return nil } -func copyCurrentClusterFromExistingKubeConfig(err error, currentKubeConfig clientcmdapi.Config, currentContextNameOverride string) (v1.Cluster, error) { +func copyCurrentClusterFromExistingKubeConfig(currentKubeConfig clientcmdapi.Config, currentContextNameOverride string) (v1.Cluster, error) { v1Cluster := v1.Cluster{} contextName := currentKubeConfig.CurrentContext @@ -288,7 +288,7 @@ func copyCurrentClusterFromExistingKubeConfig(err error, currentKubeConfig clien contextName = currentContextNameOverride } - err = v1.Convert_api_Cluster_To_v1_Cluster( + err := v1.Convert_api_Cluster_To_v1_Cluster( currentKubeConfig.Clusters[currentKubeConfig.Contexts[contextName].Cluster], &v1Cluster, nil, diff --git a/cmd/pinniped/cmd/get_kubeconfig_test.go b/cmd/pinniped/cmd/get_kubeconfig_test.go index e08316cf..87ad7658 100644 --- a/cmd/pinniped/cmd/get_kubeconfig_test.go +++ b/cmd/pinniped/cmd/get_kubeconfig_test.go @@ -201,6 +201,7 @@ func TestNewGetKubeConfigCmd(t *testing.T) { }, spec.Parallel(), spec.Report(report.Terminal{})) } +//nolint: unparam func expectedKubeconfigYAML(clusterCAData, clusterServer, command, token, pinnipedEndpoint, pinnipedCABundle string) string { return here.Docf(` apiVersion: v1 @@ -636,6 +637,5 @@ func TestGetKubeConfig(t *testing.T) { r.Empty(outputBuffer.String()) }) }) - }, spec.Parallel(), spec.Report(report.Terminal{})) } From 831df90c93148a4f41c25aa15bcd38f71e30e58a Mon Sep 17 00:00:00 2001 From: Andrew Keesler Date: Tue, 15 Sep 2020 11:00:38 -0400 Subject: [PATCH 7/9] test/integration: add integration test for pinniped cli --- test/integration/cli_test.go | 91 ++++++++++++++++++ test/integration/common_test.go | 102 +++++++++++++++++++++ test/integration/credentialrequest_test.go | 67 ++------------ test/library/client.go | 16 ++++ 4 files changed, 217 insertions(+), 59 deletions(-) create mode 100644 test/integration/cli_test.go create mode 100644 test/integration/common_test.go diff --git a/test/integration/cli_test.go b/test/integration/cli_test.go new file mode 100644 index 00000000..968af4e9 --- /dev/null +++ b/test/integration/cli_test.go @@ -0,0 +1,91 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ +package integration + +import ( + "context" + "io/ioutil" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/suzerain-io/pinniped/test/library" +) + +func TestCLI(t *testing.T) { + library.SkipUnlessIntegration(t) + library.SkipUnlessClusterHasCapability(t, library.ClusterSigningKeyIsAvailable) + token := library.GetEnv(t, "PINNIPED_TEST_USER_TOKEN") + namespaceName := library.GetEnv(t, "PINNIPED_NAMESPACE") + testUsername := library.GetEnv(t, "PINNIPED_TEST_USER_USERNAME") + expectedTestUserGroups := strings.Split( + strings.ReplaceAll(library.GetEnv(t, "PINNIPED_TEST_USER_GROUPS"), " ", ""), ",", + ) + + // Build pinniped CLI. + pinnipedExe, cleanupFunc := buildPinnipedCLI(t) + defer cleanupFunc() + + // Run pinniped CLI to get kubeconfig. + kubeConfig := runPinnipedCLI(t, pinnipedExe, token, namespaceName) + + // Create Kubernetes client with kubeconfig from pinniped CLI. + kubeClient := library.NewClientsetForKubeConfig(t, kubeConfig) + + // Validate that we can auth to the API via our user. + ctx, cancelFunc := context.WithTimeout(context.Background(), time.Second*3) + defer cancelFunc() + + adminClient := library.NewClientset(t) + + t.Run("access as user", accessAsUserTest(ctx, adminClient, testUsername, kubeClient)) + for _, group := range expectedTestUserGroups { + group := group + t.Run( + "access as group "+group, + accessAsGroupTest(ctx, adminClient, group, kubeClient), + ) + } +} + +func buildPinnipedCLI(t *testing.T) (string, func()) { + t.Helper() + + pinnipedExeDir, err := ioutil.TempDir("", "pinniped-cli-test-*") + require.NoError(t, err) + + pinnipedExe := filepath.Join(pinnipedExeDir, "pinniped") + output, err := exec.Command( + "go", + "build", + "-o", + pinnipedExe, + "github.com/suzerain-io/pinniped/cmd/pinniped", + ).CombinedOutput() + require.NoError(t, err, string(output)) + + return pinnipedExe, func() { + require.NoError(t, os.RemoveAll(pinnipedExeDir)) + } +} + +func runPinnipedCLI(t *testing.T, pinnipedExe, token, namespaceName string) string { + t.Helper() + + output, err := exec.Command( + pinnipedExe, + "get-kubeconfig", + "--token", token, + "--pinniped-namespace", namespaceName, + ).CombinedOutput() + require.NoError(t, err, string(output)) + + return string(output) +} diff --git a/test/integration/common_test.go b/test/integration/common_test.go new file mode 100644 index 00000000..abd35975 --- /dev/null +++ b/test/integration/common_test.go @@ -0,0 +1,102 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ +package integration + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + v1 "k8s.io/api/core/v1" + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// accessAsUserTest runs a generic test in which a clientUnderTest operating with username +// testUsername tries to auth to the kube API (i.e., list namespaces). +// +// Use this function if you want to simply validate that a user can auth to the kube API after +// performing a Pinniped credential exchange. +func accessAsUserTest( + ctx context.Context, + adminClient kubernetes.Interface, + testUsername string, + clientUnderTest kubernetes.Interface, +) func(t *testing.T) { + return func(t *testing.T) { + addTestClusterRoleBinding(ctx, t, adminClient, &rbacv1.ClusterRoleBinding{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "integration-test-user-readonly-role-binding", + }, + Subjects: []rbacv1.Subject{{ + Kind: rbacv1.UserKind, + APIGroup: rbacv1.GroupName, + Name: testUsername, + }}, + RoleRef: rbacv1.RoleRef{ + Kind: "ClusterRole", + APIGroup: rbacv1.GroupName, + Name: "view", + }, + }) + + // Use the client which is authenticated as the test user to list namespaces + var listNamespaceResponse *v1.NamespaceList + var err error + var canListNamespaces = func() bool { + listNamespaceResponse, err = clientUnderTest.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + return err == nil + } + assert.Eventually(t, canListNamespaces, 3*time.Second, 250*time.Millisecond) + require.NoError(t, err) // prints out the error and stops the test in case of failure + require.NotEmpty(t, listNamespaceResponse.Items) + } +} + +// accessAsGroupTest runs a generic test in which a clientUnderTest with membership in group +// testGroup tries to auth to the kube API (i.e., list namespaces). +// +// Use this function if you want to simply validate that a user can auth to the kube API (via +// a group membership) after performing a Pinniped credential exchange. +func accessAsGroupTest( + ctx context.Context, + adminClient kubernetes.Interface, + testGroup string, + clientUnderTest kubernetes.Interface, +) func(t *testing.T) { + return func(t *testing.T) { + addTestClusterRoleBinding(ctx, t, adminClient, &rbacv1.ClusterRoleBinding{ + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "integration-test-group-readonly-role-binding", + }, + Subjects: []rbacv1.Subject{{ + Kind: rbacv1.GroupKind, + APIGroup: rbacv1.GroupName, + Name: testGroup, + }}, + RoleRef: rbacv1.RoleRef{ + Kind: "ClusterRole", + APIGroup: rbacv1.GroupName, + Name: "view", + }, + }) + + // Use the client which is authenticated as the test user to list namespaces + var listNamespaceResponse *v1.NamespaceList + var err error + var canListNamespaces = func() bool { + listNamespaceResponse, err = clientUnderTest.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + return err == nil + } + assert.Eventually(t, canListNamespaces, 3*time.Second, 250*time.Millisecond) + require.NoError(t, err) // prints out the error and stops the test in case of failure + require.NotEmpty(t, listNamespaceResponse.Items) + } +} diff --git a/test/integration/credentialrequest_test.go b/test/integration/credentialrequest_test.go index 28557858..ba8bd773 100644 --- a/test/integration/credentialrequest_test.go +++ b/test/integration/credentialrequest_test.go @@ -14,9 +14,7 @@ import ( "testing" "time" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - v1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -61,65 +59,16 @@ func TestSuccessfulCredentialRequest(t *testing.T) { response.Status.Credential.ClientKeyData, ) - t.Run("access as user", func(t *testing.T) { - addTestClusterRoleBinding(ctx, t, adminClient, &rbacv1.ClusterRoleBinding{ - TypeMeta: metav1.TypeMeta{}, - ObjectMeta: metav1.ObjectMeta{ - Name: "integration-test-user-readonly-role-binding", - }, - Subjects: []rbacv1.Subject{{ - Kind: rbacv1.UserKind, - APIGroup: rbacv1.GroupName, - Name: testUsername, - }}, - RoleRef: rbacv1.RoleRef{ - Kind: "ClusterRole", - APIGroup: rbacv1.GroupName, - Name: "view", - }, - }) - - // Use the client which is authenticated as the test user to list namespaces - var listNamespaceResponse *v1.NamespaceList - var canListNamespaces = func() bool { - listNamespaceResponse, err = clientWithCertFromCredentialRequest.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) - return err == nil - } - assert.Eventually(t, canListNamespaces, 3*time.Second, 250*time.Millisecond) - require.NoError(t, err) // prints out the error and stops the test in case of failure - require.NotEmpty(t, listNamespaceResponse.Items) - }) - + t.Run( + "access as user", + accessAsUserTest(ctx, adminClient, testUsername, clientWithCertFromCredentialRequest), + ) for _, group := range expectedTestUserGroups { group := group - t.Run("access as group "+group, func(t *testing.T) { - addTestClusterRoleBinding(ctx, t, adminClient, &rbacv1.ClusterRoleBinding{ - TypeMeta: metav1.TypeMeta{}, - ObjectMeta: metav1.ObjectMeta{ - Name: "integration-test-group-readonly-role-binding", - }, - Subjects: []rbacv1.Subject{{ - Kind: rbacv1.GroupKind, - APIGroup: rbacv1.GroupName, - Name: group, - }}, - RoleRef: rbacv1.RoleRef{ - Kind: "ClusterRole", - APIGroup: rbacv1.GroupName, - Name: "view", - }, - }) - - // Use the client which is authenticated as the test user to list namespaces - var listNamespaceResponse *v1.NamespaceList - var canListNamespaces = func() bool { - listNamespaceResponse, err = clientWithCertFromCredentialRequest.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) - return err == nil - } - assert.Eventually(t, canListNamespaces, 3*time.Second, 250*time.Millisecond) - require.NoError(t, err) // prints out the error and stops the test in case of failure - require.NotEmpty(t, listNamespaceResponse.Items) - }) + t.Run( + "access as group "+group, + accessAsGroupTest(ctx, adminClient, group, clientWithCertFromCredentialRequest), + ) } } diff --git a/test/library/client.go b/test/library/client.go index a7e895db..1eb13328 100644 --- a/test/library/client.go +++ b/test/library/client.go @@ -36,6 +36,22 @@ func NewClientset(t *testing.T) kubernetes.Interface { return newClientsetWithConfig(t, NewClientConfig(t)) } +func NewClientsetForKubeConfig(t *testing.T, kubeConfig string) kubernetes.Interface { + t.Helper() + + kubeConfigFile, err := ioutil.TempFile("", "pinniped-cli-test-*") + require.NoError(t, err) + defer os.Remove(kubeConfigFile.Name()) + + _, err = kubeConfigFile.Write([]byte(kubeConfig)) + require.NoError(t, err) + + restConfig, err := clientcmd.BuildConfigFromFlags("", kubeConfigFile.Name()) + require.NoError(t, err) + + return newClientsetWithConfig(t, restConfig) +} + func NewClientsetWithCertAndKey(t *testing.T, clientCertificateData, clientKeyData string) kubernetes.Interface { t.Helper() From 4ced58b5b7da8052b884e0f1152e2d790e08a8d7 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Tue, 15 Sep 2020 09:05:40 -0700 Subject: [PATCH 8/9] Add help/usage units for CLI `exchange-credential` subcommand --- cmd/pinniped/cmd/exchange_credential.go | 40 ++++++-- cmd/pinniped/cmd/exchange_credential_test.go | 97 ++++++++++++++++++++ cmd/pinniped/cmd/get_kubeconfig_test.go | 73 +++++++-------- 3 files changed, 166 insertions(+), 44 deletions(-) diff --git a/cmd/pinniped/cmd/exchange_credential.go b/cmd/pinniped/cmd/exchange_credential.go index a9f3566f..87f7b0f2 100644 --- a/cmd/pinniped/cmd/exchange_credential.go +++ b/cmd/pinniped/cmd/exchange_credential.go @@ -23,19 +23,39 @@ import ( //nolint: gochecknoinits func init() { - exchangeCredentialCmd := &cobra.Command{ - Run: runExchangeCredential, + rootCmd.AddCommand(newExchangeCredentialCmd(os.Args, os.Stdout, os.Stderr).cmd) +} + +type exchangeCredentialCommand struct { + // runFunc is called by the cobra.Command.Run hook. It is included here for + // testability. + runFunc func(stdout, stderr io.Writer) + + // cmd is the cobra.Command for this CLI command. It is included here for + // testability. + cmd *cobra.Command +} + +func newExchangeCredentialCmd(args []string, stdout, stderr io.Writer) *exchangeCredentialCommand { + c := &exchangeCredentialCommand{ + runFunc: runExchangeCredential, + } + + c.cmd = &cobra.Command{ + Run: func(cmd *cobra.Command, _ []string) { + c.runFunc(stdout, stderr) + }, Args: cobra.NoArgs, // do not accept positional arguments for this command Use: "exchange-credential", Short: "Exchange a credential for a cluster-specific access credential", Long: here.Doc(` Exchange a credential which proves your identity for a time-limited, cluster-specific access credential. - + Designed to be conveniently used as an credential plugin for kubectl. See the help message for 'pinniped get-kubeconfig' for more information about setting up a kubeconfig file using Pinniped. - + Requires all of the following environment variables, which are typically set in the kubeconfig: - PINNIPED_TOKEN: the token to send to Pinniped for exchange @@ -43,13 +63,17 @@ func init() { Pinniped's HTTPS endpoint - PINNIPED_K8S_API_ENDPOINT: the URL for the Pinniped credential exchange API - + For more information about credential plugins in general, see https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins `), } - rootCmd.AddCommand(exchangeCredentialCmd) + c.cmd.SetArgs(args) + c.cmd.SetOut(stdout) + c.cmd.SetErr(stderr) + + return c } type envGetter func(string) (string, bool) @@ -57,8 +81,8 @@ type tokenExchanger func(ctx context.Context, token, caBundle, apiEndpoint strin const ErrMissingEnvVar = constable.Error("failed to get credential: environment variable not set") -func runExchangeCredential(_ *cobra.Command, _ []string) { - err := exchangeCredential(os.LookupEnv, client.ExchangeToken, os.Stdout, 30*time.Second) +func runExchangeCredential(stdout, _ io.Writer) { + err := exchangeCredential(os.LookupEnv, client.ExchangeToken, stdout, 30*time.Second) if err != nil { _, _ = fmt.Fprintf(os.Stderr, "%s\n", err.Error()) os.Exit(1) diff --git a/cmd/pinniped/cmd/exchange_credential_test.go b/cmd/pinniped/cmd/exchange_credential_test.go index db834020..0bf77bdc 100644 --- a/cmd/pinniped/cmd/exchange_credential_test.go +++ b/cmd/pinniped/cmd/exchange_credential_test.go @@ -9,6 +9,7 @@ import ( "bytes" "context" "fmt" + "io" "testing" "time" @@ -18,9 +19,105 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + "github.com/suzerain-io/pinniped/internal/here" "github.com/suzerain-io/pinniped/internal/testutil" ) +var ( + knownGoodUsageForExchangeCredential = here.Doc(` + Usage: + exchange-credential [flags] + + Flags: + -h, --help help for exchange-credential + + `) + + knownGoodHelpForExchangeCredential = here.Doc(` + Exchange a credential which proves your identity for a time-limited, + cluster-specific access credential. + + Designed to be conveniently used as an credential plugin for kubectl. + See the help message for 'pinniped get-kubeconfig' for more + information about setting up a kubeconfig file using Pinniped. + + Requires all of the following environment variables, which are + typically set in the kubeconfig: + - PINNIPED_TOKEN: the token to send to Pinniped for exchange + - PINNIPED_CA_BUNDLE: the CA bundle to trust when calling + Pinniped's HTTPS endpoint + - PINNIPED_K8S_API_ENDPOINT: the URL for the Pinniped credential + exchange API + + For more information about credential plugins in general, see + https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins + + Usage: + exchange-credential [flags] + + Flags: + -h, --help help for exchange-credential + `) +) + +func TestNewCredentialExchangeCmd(t *testing.T) { + spec.Run(t, "newCredentialExchangeCmd", func(t *testing.T, when spec.G, it spec.S) { + var r *require.Assertions + var stdout, stderr *bytes.Buffer + + it.Before(func() { + r = require.New(t) + + stdout, stderr = bytes.NewBuffer([]byte{}), bytes.NewBuffer([]byte{}) + }) + + it("calls runFunc and does not print usage or help when correct arguments and flags are used", func() { + c := newExchangeCredentialCmd([]string{}, stdout, stderr) + + runFuncCalled := false + c.runFunc = func(out, err io.Writer) { + runFuncCalled = true + } + + r.NoError(c.cmd.Execute()) + r.True(runFuncCalled) + r.Empty(stdout.String()) + r.Empty(stderr.String()) + }) + + it("fails when args are passed", func() { + c := newExchangeCredentialCmd([]string{"some-arg"}, stdout, stderr) + + runFuncCalled := false + c.runFunc = func(out, err io.Writer) { + runFuncCalled = true + } + + errorMessage := `unknown command "some-arg" for "exchange-credential"` + r.EqualError(c.cmd.Execute(), errorMessage) + r.False(runFuncCalled) + + output := "Error: " + errorMessage + "\n" + knownGoodUsageForExchangeCredential + r.Equal(output, stdout.String()) + r.Empty(stderr.String()) + }) + + it("prints a nice help message", func() { + c := newExchangeCredentialCmd([]string{"--help"}, stdout, stderr) + + runFuncCalled := false + c.runFunc = func(out, err io.Writer) { + runFuncCalled = true + } + + r.NoError(c.cmd.Execute()) + r.False(runFuncCalled) + r.Equal(knownGoodHelpForExchangeCredential, stdout.String()) + r.Empty(stderr.String()) + }) + }, spec.Parallel(), spec.Report(report.Terminal{})) +} + func TestExchangeCredential(t *testing.T) { spec.Run(t, "cmd.exchangeCredential", func(t *testing.T, when spec.G, it spec.S) { var r *require.Assertions diff --git a/cmd/pinniped/cmd/get_kubeconfig_test.go b/cmd/pinniped/cmd/get_kubeconfig_test.go index 87ad7658..06738998 100644 --- a/cmd/pinniped/cmd/get_kubeconfig_test.go +++ b/cmd/pinniped/cmd/get_kubeconfig_test.go @@ -26,46 +26,47 @@ import ( "github.com/suzerain-io/pinniped/internal/here" ) -const ( - knownGoodUsage = ` -Usage: - get-kubeconfig [flags] +var ( + knownGoodUsageForGetKubeConfig = here.Doc(` + Usage: + get-kubeconfig [flags] -Flags: - -h, --help help for get-kubeconfig - --kubeconfig string Path to the kubeconfig file - --kubeconfig-context string Kubeconfig context override - --pinniped-namespace string Namespace in which Pinniped was installed (default "pinniped") - --token string Credential to include in the resulting kubeconfig output (Required) + Flags: + -h, --help help for get-kubeconfig + --kubeconfig string Path to the kubeconfig file + --kubeconfig-context string Kubeconfig context override + --pinniped-namespace string Namespace in which Pinniped was installed (default "pinniped") + --token string Credential to include in the resulting kubeconfig output (Required) -` + `) - knownGoodHelp = `Print a kubeconfig for authenticating into a cluster via Pinniped. + knownGoodHelpForGetKubeConfig = here.Doc(` + Print a kubeconfig for authenticating into a cluster via Pinniped. -Requires admin-like access to the cluster using the current -kubeconfig context in order to access Pinniped's metadata. -The current kubeconfig is found similar to how kubectl finds it: -using the value of the --kubeconfig option, or if that is not -specified then from the value of the KUBECONFIG environment -variable, or if that is not specified then it defaults to -.kube/config in your home directory. + Requires admin-like access to the cluster using the current + kubeconfig context in order to access Pinniped's metadata. + The current kubeconfig is found similar to how kubectl finds it: + using the value of the --kubeconfig option, or if that is not + specified then from the value of the KUBECONFIG environment + variable, or if that is not specified then it defaults to + .kube/config in your home directory. -Prints a kubeconfig which is suitable to access the cluster using -Pinniped as the authentication mechanism. This kubeconfig output -can be saved to a file and used with future kubectl commands, e.g.: - pinniped get-kubeconfig --token $MY_TOKEN > $HOME/mycluster-kubeconfig - kubectl --kubeconfig $HOME/mycluster-kubeconfig get pods + Prints a kubeconfig which is suitable to access the cluster using + Pinniped as the authentication mechanism. This kubeconfig output + can be saved to a file and used with future kubectl commands, e.g.: + pinniped get-kubeconfig --token $MY_TOKEN > $HOME/mycluster-kubeconfig + kubectl --kubeconfig $HOME/mycluster-kubeconfig get pods -Usage: - get-kubeconfig [flags] + Usage: + get-kubeconfig [flags] -Flags: - -h, --help help for get-kubeconfig - --kubeconfig string Path to the kubeconfig file - --kubeconfig-context string Kubeconfig context override - --pinniped-namespace string Namespace in which Pinniped was installed (default "pinniped") - --token string Credential to include in the resulting kubeconfig output (Required) -` + Flags: + -h, --help help for get-kubeconfig + --kubeconfig string Path to the kubeconfig file + --kubeconfig-context string Kubeconfig context override + --pinniped-namespace string Namespace in which Pinniped was installed (default "pinniped") + --token string Credential to include in the resulting kubeconfig output (Required) + `) ) func TestNewGetKubeConfigCmd(t *testing.T) { @@ -126,7 +127,7 @@ func TestNewGetKubeConfigCmd(t *testing.T) { r.EqualError(c.cmd.Execute(), errorMessage) r.False(runFuncCalled) - output := "Error: " + errorMessage + knownGoodUsage + output := "Error: " + errorMessage + "\n" + knownGoodUsageForGetKubeConfig r.Equal(output, stdout.String()) r.Empty(stderr.String()) }) @@ -174,7 +175,7 @@ func TestNewGetKubeConfigCmd(t *testing.T) { r.EqualError(c.cmd.Execute(), errorMessage) r.False(runFuncCalled) - output := "Error: " + errorMessage + knownGoodUsage + output := "Error: " + errorMessage + "\n" + knownGoodUsageForGetKubeConfig r.Equal(output, stdout.String()) r.Empty(stderr.String()) }) @@ -195,7 +196,7 @@ func TestNewGetKubeConfigCmd(t *testing.T) { r.NoError(c.cmd.Execute()) r.False(runFuncCalled) - r.Equal(knownGoodHelp, stdout.String()) + r.Equal(knownGoodHelpForGetKubeConfig, stdout.String()) r.Empty(stderr.String()) }) }, spec.Parallel(), spec.Report(report.Terminal{})) From cecd691a841188f9838191bb22412c743add9530 Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Tue, 15 Sep 2020 12:10:20 -0700 Subject: [PATCH 9/9] Add demo instructions Signed-off-by: Andrew Keesler --- README.md | 4 + deploy-local-user-authenticator/README.md | 5 +- deploy/README.md | 5 +- doc/demo.md | 115 ++++++++++++++++++++++ 4 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 doc/demo.md diff --git a/README.md b/README.md index c53a2f3a..63cf3800 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,10 @@ built with the [Pinniped Go client library](generated). ![implementation](doc/img/pinniped.svg) +## Trying Pinniped + +Care to kick the tires? It's easy to [install and try Pinniped](doc/demo.md). + ## Installation Currently, Pinniped supports self-hosted clusters where the Kube Controller Manager pod diff --git a/deploy-local-user-authenticator/README.md b/deploy-local-user-authenticator/README.md index a4f4b88f..c305e113 100644 --- a/deploy-local-user-authenticator/README.md +++ b/deploy-local-user-authenticator/README.md @@ -14,8 +14,9 @@ User accounts can be created and edited dynamically using `kubectl` commands (se ## Tools -This example deployment uses `ytt` from [Carvel](https://carvel.dev/) to template the YAML files. -Either [install `ytt`](https://get-ytt.io/) or use the [container image from Dockerhub](https://hub.docker.com/r/k14s/image/tags). +This example deployment uses `ytt` and `kapp` from [Carvel](https://carvel.dev/) to template the YAML files +and to deploy the app. +Either [install `ytt` and `kapp`](https://carvel.dev/) or use the [container image from Dockerhub](https://hub.docker.com/r/k14s/image/tags). As well, this demo requires a tool capable of generating a `bcrypt` hash in order to interact with the webhook. The example below uses `htpasswd`, which is installed on most macOS systems, and can be diff --git a/deploy/README.md b/deploy/README.md index 96b0c344..005647ef 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -9,8 +9,9 @@ for details. ## Tools -This example deployment uses `ytt` from [Carvel](https://carvel.dev/) to template the YAML files. -Either [install `ytt`](https://get-ytt.io/) or use the [container image from Dockerhub](https://hub.docker.com/r/k14s/image/tags). +This example deployment uses `ytt` and `kapp` from [Carvel](https://carvel.dev/) to template the YAML files +and to deploy the app. +Either [install `ytt` and `kapp`](https://carvel.dev/) or use the [container image from Dockerhub](https://hub.docker.com/r/k14s/image/tags). ## Procedure diff --git a/doc/demo.md b/doc/demo.md new file mode 100644 index 00000000..8ffb93f8 --- /dev/null +++ b/doc/demo.md @@ -0,0 +1,115 @@ +# Trying Pinniped + +## Prerequisites + +1. A Kubernetes cluster of a type supported by Pinniped. + Currently, Pinniped supports self-hosted clusters where the Kube Controller Manager pod + is accessible from Pinniped's pods. + Support for other types of Kubernetes distributions is coming soon. + + Don't have a cluster handy? Consider using [kind](https://kind.sigs.k8s.io/) on your local machine. + See below for an example of using kind. + +1. A kubeconfig where the current context points to that cluster and has admin-like + privileges on that cluster. + + Don't have an identity provider of a type supported by Pinniped handy? + Start by installing `local-user-authenticator` on the same cluster where you would like to try Pinniped + by following the directions in [deploy-local-user-authenticator/README.md](../deploy-local-user-authenticator/README.md). + See below for an example of deploying this on kind. + +## Steps + +### General Steps + +1. Install Pinniped by following the directions in [deploy/README.md](../deploy/README.md). +1. Download the Pinniped CLI from [Pinniped's github Releases page](https://github.com/suzerain-io/pinniped/releases/latest). +1. Generate a kubeconfig using the Pinniped CLI. Run `pinniped get-kubeconfig --help` for more information. +1. Run `kubectl` commands using the generated kubeconfig to authenticate using Pinniped during those commands. + +### Specific Example of Deploying on kind Using `local-user-authenticator` as the Identity Provider + +1. Install the tools required for the following steps. + + - This example deployment uses `ytt` and `kapp` from [Carvel](https://carvel.dev/) to template the YAML files + and to deploy the app. + Either [install `ytt` and `kapp`](https://carvel.dev/) or use the [container image from Dockerhub](https://hub.docker.com/r/k14s/image/tags). + E.g. `brew install k14s/tap/ytt k14s/tap/kapp` on a Mac. + + - [Install kind](https://kind.sigs.k8s.io/docs/user/quick-start/), if not already installed. e.g. `brew install kind` on a Mac. + + - kind depends on Docker. If not already installed, [install Docker](https://docs.docker.com/get-docker/), e.g. `brew cask install docker` on a Mac. + + - This demo requires `kubectl`, which comes with Docker, or can be [installed separately](https://kubernetes.io/docs/tasks/tools/install-kubectl/). + + - This demo requires a tool capable of generating a `bcrypt` hash in order to interact with + the webhook. The example below uses `htpasswd`, which is installed on most macOS systems, and can be + installed on some Linux systems via the `apache2-utils` package (e.g., `apt-get install + apache2-utils`). + +1. Create a new Kubernetes cluster using `kind create cluster`. Optionally provide a cluster name using the `--name` flag. + kind will automatically update your kubeconfig to point to the new cluster. + +1. Clone this repo. + + ```bash + git clone https://github.com/suzerain-io/pinniped.git /tmp/pinniped --depth 1 + ``` + +1. Deploy the `local-user-authenticator` app: + + ```bash + cd /tmp/pinniped/deploy-local-user-authenticator + ytt --file . | kapp deploy --yes --app local-user-authenticator --diff-changes --file - + ``` + +1. Create a test user. + + ```bash + kubectl create secret generic pinny-the-seal \ + --namespace local-user-authenticator \ + --from-literal=groups=group1,group2 \ + --from-literal=passwordHash=$(htpasswd -nbBC 10 x password123 | sed -e "s/^x://") + ``` + +1. Fetch the auto-generated CA bundle for the `local-user-authenticator`'s HTTP TLS endpoint. + + ```bash + kubectl get secret api-serving-cert --namespace local-user-authenticator \ + -o jsonpath={.data.caCertificate} \ + | base64 -d \ + | tee /tmp/local-user-authenticator-ca + ``` +1. Deploy Pinniped. + + ```bash + cd /tmp/pinniped/deploy + ytt --file . | kapp deploy --yes --app pinniped --diff-changes --file - \ + --data-value "webhook_url=https://local-user-authenticator.local-user-authenticator.svc/authenticate" \ + --data-value "webhook_ca_bundle=$(cat /tmp/local-user-authenticator-ca)" + ``` + +1. Download the latest version of the Pinniped CLI binary for your platform + from [Pinniped's github Releases page](https://github.com/suzerain-io/pinniped/releases/latest). + +1. Move the Pinniped CLI binary to your preferred directory and add the executable bit, + e.g. `chmod +x /usr/local/bin/pinniped`. + +1. Generate a kubeconfig. + + ```bash + pinniped get-kubeconfig --token "pinny-the-seal:password123" > /tmp/pinniped-kubeconfig + ``` + +1. Create RBAC rules for the test user to give them permissions to perform actions on the cluster. + For example, grant the test user permission to view all cluster resources. + + ```bash + kubectl create clusterrolebinding pinny-can-read --clusterrole view --user pinny-the-seal + ``` + +1. Use the generated kubeconfig to issue arbitrary `kubectl` commands as the `pinny-the-seal` user. + + ```bash + kubectl --kubeconfig /tmp/pinniped-kubeconfig get pods -n pinniped + ```