Merge pull request #98 from suzerain-io/get_kubeconfig_cli
Organize Pinniped CLI into subcommands; Add get-kubeconfig subcommand
This commit is contained in:
commit
db98f2810f
@ -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
|
||||
|
126
cmd/pinniped/cmd/exchange_credential.go
Normal file
126
cmd/pinniped/cmd/exchange_credential.go
Normal file
@ -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)
|
||||
}
|
@ -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)
|
347
cmd/pinniped/cmd/get_kubeconfig.go
Normal file
347
cmd/pinniped/cmd/get_kubeconfig.go
Normal file
@ -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,
|
||||
}
|
||||
}
|
642
cmd/pinniped/cmd/get_kubeconfig_test.go
Normal file
642
cmd/pinniped/cmd/get_kubeconfig_test.go
Normal file
@ -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{}))
|
||||
}
|
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)
|
||||
}
|
||||
}
|
31
cmd/pinniped/cmd/testdata/kubeconfig.yaml
vendored
Normal file
31
cmd/pinniped/cmd/testdata/kubeconfig.yaml
vendored
Normal file
@ -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
|
@ -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()
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
115
doc/demo.md
Normal file
115
doc/demo.md
Normal file
@ -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
|
||||
```
|
2
go.mod
2
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
|
||||
|
3
go.sum
3
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=
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
25
internal/here/doc.go
Normal file
25
internal/here/doc.go
Normal file
@ -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)
|
||||
}
|
104
internal/here/doc_test.go
Normal file
104
internal/here/doc_test.go
Normal file
@ -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{}))
|
||||
}
|
@ -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,
|
||||
}
|
||||
|
91
test/integration/cli_test.go
Normal file
91
test/integration/cli_test.go
Normal file
@ -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)
|
||||
}
|
@ -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-----
|
||||
`))
|
||||
)
|
||||
|
||||
|
102
test/integration/common_test.go
Normal file
102
test/integration/common_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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()
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user