mirror of
https://github.com/vmware-tanzu/pinniped.git
synced 2026-01-07 22:15:40 +00:00
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:
102
cmd/pinniped/cmd/exchange_credential.go
Normal file
102
cmd/pinniped/cmd/exchange_credential.go
Normal 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)
|
||||
}
|
||||
@@ -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)
|
||||
136
cmd/pinniped/cmd/get_kubeconfig.go
Normal file
136
cmd/pinniped/cmd/get_kubeconfig.go
Normal 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
|
||||
}
|
||||
76
cmd/pinniped/cmd/get_kubeconfig_test.go
Normal file
76
cmd/pinniped/cmd/get_kubeconfig_test.go
Normal 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
30
cmd/pinniped/cmd/root.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user