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/cmd/pinniped/cmd/exchange_credential.go b/cmd/pinniped/cmd/exchange_credential.go new file mode 100644 index 00000000..87f7b0f2 --- /dev/null +++ b/cmd/pinniped/cmd/exchange_credential.go @@ -0,0 +1,126 @@ +/* +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() { + 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 + - 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 + `), + } + + c.cmd.SetArgs(args) + c.cmd.SetOut(stdout) + c.cmd.SetErr(stderr) + + return c +} + +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(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) + } +} + +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 57% rename from cmd/pinniped/main_test.go rename to cmd/pinniped/cmd/exchange_credential_test.go index 261fa50b..0bf77bdc 100644 --- a/cmd/pinniped/main_test.go +++ b/cmd/pinniped/cmd/exchange_credential_test.go @@ -3,12 +3,13 @@ Copyright 2020 VMware, Inc. SPDX-License-Identifier: Apache-2.0 */ -package main +package cmd import ( "bytes" "context" "fmt" + "io" "testing" "time" @@ -18,11 +19,107 @@ 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" ) -func TestRun(t *testing.T) { - spec.Run(t, "main.run", func(t *testing.T, when spec.G, it spec.S) { +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 var buffer *bytes.Buffer var tokenExchanger tokenExchanger @@ -49,19 +146,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 +171,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 +188,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 +210,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 +238,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..ff9b98ad --- /dev/null +++ b/cmd/pinniped/cmd/get_kubeconfig.go @@ -0,0 +1,347 @@ +/* +Copyright 2020 VMware, Inc. +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/constable" + "github.com/suzerain-io/pinniped/internal/controller/issuerconfig" + "github.com/suzerain-io/pinniped/internal/here" +) + +const ( + getKubeConfigCmdTokenFlagName = "token" + getKubeConfigCmdKubeconfigFlagName = "kubeconfig" + getKubeConfigCmdKubeconfigContextFlagName = "kubeconfig-context" + getKubeConfigCmdPinnipedNamespaceFlagName = "pinniped-namespace" +) + +//nolint: gochecknoinits +func init() { + 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", + Long: 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. + + 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 + `), + } + + c.cmd.SetArgs(args) + c.cmd.SetOut(stdout) + c.cmd.SetErr(stderr) + + c.cmd.Flags().StringP( + getKubeConfigCmdTokenFlagName, + "", + "", + "Credential to include in the resulting kubeconfig output (Required)", + ) + err := c.cmd.MarkFlagRequired(getKubeConfigCmdTokenFlagName) + if err != nil { + panic(err) + } + + c.cmd.Flags().StringP( + getKubeConfigCmdKubeconfigFlagName, + "", + "", + "Path to the kubeconfig file", + ) + + c.cmd.Flags().StringP( + getKubeConfigCmdKubeconfigContextFlagName, + "", + "", + "Kubeconfig context override", + ) + + c.cmd.Flags().StringP( + getKubeConfigCmdPinnipedNamespaceFlagName, + "", + "pinniped", + "Namespace in which Pinniped was installed", + ) + + return c +} + +func runGetKubeConfig( + stdout, stderr io.Writer, + token, kubeconfigPathOverride, currentContextOverride, pinnipedInstallationNamespace string, +) { + err := getKubeConfig( + stdout, + 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()) + os.Exit(1) + } +} + +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 constable.Error("--" + getKubeConfigCmdTokenFlagName + " flag value cannot be empty") + } + + fullPathToSelf, err := os.Executable() + if err != nil { + return fmt.Errorf("could not find path to self: %w", err) + } + + clientConfig := newClientConfig(kubeconfigPathOverride, currentContextNameOverride) + + currentKubeConfig, err := clientConfig.RawConfig() + if err != nil { + return err + } + + credentialIssuerConfig, err := fetchPinnipedCredentialIssuerConfig(clientConfig, kubeClientCreator, pinnipedInstallationNamespace) + if err != nil { + return err + } + + if credentialIssuerConfig.Status.KubeConfigInfo == nil { + return constable.Error(`CredentialIssuerConfig "pinniped-config" was missing KubeConfigInfo`) + } + + v1Cluster, err := copyCurrentClusterFromExistingKubeConfig(currentKubeConfig, currentContextNameOverride) + if err != nil { + return err + } + + 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, 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 + } + + 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(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: v1Cluster, + }, + }, + Contexts: []v1.NamedContext{ + { + Name: clusterName, + Context: v1.Context{ + Cluster: clusterName, + AuthInfo: userName, + }, + }, + }, + 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, + } +} diff --git a/cmd/pinniped/cmd/get_kubeconfig_test.go b/cmd/pinniped/cmd/get_kubeconfig_test.go new file mode 100644 index 00000000..06738998 --- /dev/null +++ b/cmd/pinniped/cmd/get_kubeconfig_test.go @@ -0,0 +1,642 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package cmd + +import ( + "bytes" + "encoding/base64" + "fmt" + "io" + "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" +) + +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) + + `) + + 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. + + 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 + "\n" + knownGoodUsageForGetKubeConfig + 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 + "\n" + knownGoodUsageForGetKubeConfig + 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(knownGoodHelpForGetKubeConfig, stdout.String()) + r.Empty(stderr.String()) + }) + }, spec.Parallel(), spec.Report(report.Terminal{})) +} + +//nolint: unparam +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 outputBuffer *bytes.Buffer + var warningsBuffer *bytes.Buffer + var fullPathToSelf string + var pinnipedClient *pinnipedfake.Clientset + + it.Before(func() { + r = require.New(t) + + outputBuffer = new(bytes.Buffer) + warningsBuffer = new(bytes.Buffer) + + var err error + fullPathToSelf, err = os.Executable() + r.NoError(err) + + pinnipedClient = pinnipedfake.NewSimpleClientset() + }) + + 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"), + )) + }) + + 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.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 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 + 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/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/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/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/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 + ``` diff --git a/go.mod b/go.mod index bc2905c5..d5d284a4 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/go-logr/stdr v0.2.0 github.com/golang/mock v1.4.4 diff --git a/go.sum b/go.sum index 0b7469e0..5eea03fa 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= @@ -130,6 +132,7 @@ 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.2 h1:3RJdgf6u4NZUumoP8nzbqiiNT8e1tC2Oc7jlgqre/IA= github.com/go-critic/go-critic v0.5.2/go.mod h1:cc0+HvdE3lFpqLecgqMaJcvWWH77sLdBp+wLGPM1Yyo= diff --git a/hack/prepare-for-integration-tests.sh b/hack/prepare-for-integration-tests.sh index b4e06249..9cc8c3b6 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 # 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) 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/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{})) +} diff --git a/internal/server/server.go b/internal/server/server.go index 64faa1d2..9abe25df 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -30,6 +30,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" @@ -63,10 +64,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/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/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----- `)) ) 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()