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
This commit is contained in:
Ryan Richard
2020-09-11 17:56:05 -07:00
parent 19c671a60a
commit da7c981f14
10 changed files with 490 additions and 104 deletions

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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{}))
}

30
cmd/pinniped/cmd/root.go Normal file
View File

@@ -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)
}
}

View File

@@ -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()
}