diff --git a/Dockerfile b/Dockerfile index 474deb58..0017c62a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,15 +32,15 @@ COPY pkg ./pkg COPY tools ./tools COPY hack ./hack # Build the executable binary -RUN GOOS=linux GOARCH=amd64 go build -ldflags "$(hack/get-ldflags.sh)" -o out ./... +RUN GOOS=linux GOARCH=amd64 go build -ldflags "$(hack/get-ldflags.sh)" -o out ./cmd/placeholder-name-server/... FROM alpine:latest # Install CA certs and some tools for debugging RUN apk --update --no-cache add ca-certificates bash curl WORKDIR /root/ # Copy the binary from the build-env stage -COPY --from=build-env /work/out/placeholder-name placeholder-name +COPY --from=build-env /work/out/placeholder-name-server placeholder-name-server # Document the port EXPOSE 443 # Set the command -CMD ["./placeholder-name"] +CMD ["./placeholder-name-server"] diff --git a/cmd/placeholder-name-server/main.go b/cmd/placeholder-name-server/main.go new file mode 100644 index 00000000..35562733 --- /dev/null +++ b/cmd/placeholder-name-server/main.go @@ -0,0 +1,31 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package main + +import ( + "os" + + genericapiserver "k8s.io/apiserver/pkg/server" + "k8s.io/client-go/pkg/version" + "k8s.io/client-go/rest" + "k8s.io/component-base/logs" + "k8s.io/klog/v2" + + "github.com/suzerain-io/placeholder-name/internal/server" +) + +func main() { + logs.InitLogs() + defer logs.FlushLogs() + + klog.Infof("Running %s at %#v", rest.DefaultKubernetesUserAgent(), version.Get()) + + ctx := genericapiserver.SetupSignalContext() + + if err := server.New(ctx, os.Args[1:], os.Stdout, os.Stderr).Run(); err != nil { + klog.Fatal(err) + } +} diff --git a/cmd/placeholder-name/main.go b/cmd/placeholder-name/main.go index b816ad36..e6f5d881 100644 --- a/cmd/placeholder-name/main.go +++ b/cmd/placeholder-name/main.go @@ -6,26 +6,64 @@ SPDX-License-Identifier: Apache-2.0 package main import ( + "context" + "encoding/json" + "fmt" + "io" "os" + "time" - genericapiserver "k8s.io/apiserver/pkg/server" - "k8s.io/client-go/pkg/version" - "k8s.io/client-go/rest" - "k8s.io/component-base/logs" - "k8s.io/klog/v2" + "k8s.io/client-go/pkg/apis/clientauthentication" - "github.com/suzerain-io/placeholder-name/cmd/placeholder-name/app" + "github.com/suzerain-io/placeholder-name/internal/constable" + "github.com/suzerain-io/placeholder-name/pkg/client" ) func main() { - logs.InitLogs() - defer logs.FlushLogs() - - klog.Infof("Running %s at %#v", rest.DefaultKubernetesUserAgent(), version.Get()) - - ctx := genericapiserver.SetupSignalContext() - - if err := app.New(ctx, os.Args[1:], os.Stdout, os.Stderr).Run(); err != nil { - klog.Fatal(err) + 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) (*clientauthentication.ExecCredential, error) + +const ErrMissingEnvVar = constable.Error("failed to login: 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("PLACEHOLDER_NAME_TOKEN") + if !varExists { + return envVarNotSetError("PLACEHOLDER_NAME_TOKEN") + } + + caBundle, varExists := envGetter("PLACEHOLDER_NAME_CA_BUNDLE") + if !varExists { + return envVarNotSetError("PLACEHOLDER_NAME_CA_BUNDLE") + } + + apiEndpoint, varExists := envGetter("PLACEHOLDER_NAME_K8S_API_ENDPOINT") + if !varExists { + return envVarNotSetError("PLACEHOLDER_NAME_K8S_API_ENDPOINT") + } + + execCredential, err := tokenExchanger(ctx, token, caBundle, apiEndpoint) + if err != nil { + return fmt.Errorf("failed to login: %w", err) + } + + err = json.NewEncoder(outputWriter).Encode(execCredential) + if err != nil { + return fmt.Errorf("failed to marshal response to stdout: %w", err) + } + + return nil +} + +func envVarNotSetError(varName string) error { + return fmt.Errorf("%w: %s", ErrMissingEnvVar, varName) +} diff --git a/cmd/placeholder-name/main_test.go b/cmd/placeholder-name/main_test.go new file mode 100644 index 00000000..1276d6fd --- /dev/null +++ b/cmd/placeholder-name/main_test.go @@ -0,0 +1,149 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package main + +import ( + "bytes" + "context" + "fmt" + "testing" + "time" + + "github.com/sclevine/spec" + "github.com/sclevine/spec/report" + "github.com/stretchr/testify/require" + "k8s.io/client-go/pkg/apis/clientauthentication" + + "github.com/suzerain-io/placeholder-name/test/library" +) + +func TestRun(t *testing.T) { + spec.Run(t, "Run", func(t *testing.T, when spec.G, it spec.S) { + var buffer *bytes.Buffer + var tokenExchanger tokenExchanger + var fakeEnv map[string]string + + var envGetter envGetter = func(envVarName string) (string, bool) { + value, present := fakeEnv[envVarName] + if !present { + return "", false + } + return value, true + } + + it.Before(func() { + buffer = new(bytes.Buffer) + fakeEnv = map[string]string{ + "PLACEHOLDER_NAME_TOKEN": "token from env", + "PLACEHOLDER_NAME_CA_BUNDLE": "ca bundle from env", + "PLACEHOLDER_NAME_K8S_API_ENDPOINT": "k8s api from env", + } + }) + + when("env vars are missing", func() { + it("returns an error when PLACEHOLDER_NAME_TOKEN is missing", func() { + fakeEnv = map[string]string{ + "PLACEHOLDER_NAME_K8S_API_ENDPOINT": "a", + "PLACEHOLDER_NAME_CA_BUNDLE": "b", + } + err := run(envGetter, tokenExchanger, buffer, 30*time.Second) + require.EqualError(t, err, "failed to login: environment variable not set: PLACEHOLDER_NAME_TOKEN") + }) + + it("returns an error when PLACEHOLDER_NAME_CA_BUNDLE is missing", func() { + fakeEnv = map[string]string{ + "PLACEHOLDER_NAME_K8S_API_ENDPOINT": "a", + "PLACEHOLDER_NAME_TOKEN": "b", + } + err := run(envGetter, tokenExchanger, buffer, 30*time.Second) + require.EqualError(t, err, "failed to login: environment variable not set: PLACEHOLDER_NAME_CA_BUNDLE") + }) + + it("returns an error when PLACEHOLDER_NAME_K8S_API_ENDPOINT is missing", func() { + fakeEnv = map[string]string{ + "PLACEHOLDER_NAME_TOKEN": "a", + "PLACEHOLDER_NAME_CA_BUNDLE": "b", + } + err := run(envGetter, tokenExchanger, buffer, 30*time.Second) + require.EqualError(t, err, "failed to login: environment variable not set: PLACEHOLDER_NAME_K8S_API_ENDPOINT") + }) + }, spec.Parallel()) + + when("the token exchange fails", func() { + it.Before(func() { + tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*clientauthentication.ExecCredential, error) { + return nil, fmt.Errorf("some error") + } + }) + + it("returns an error", func() { + err := run(envGetter, tokenExchanger, buffer, 30*time.Second) + require.EqualError(t, err, "failed to login: some error") + }) + }, spec.Parallel()) + + when("the JSON encoder fails", func() { + it.Before(func() { + tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*clientauthentication.ExecCredential, error) { + return &clientauthentication.ExecCredential{ + Status: &clientauthentication.ExecCredentialStatus{Token: "some token"}, + }, nil + } + }) + + it("returns an error", func() { + err := run(envGetter, tokenExchanger, &library.ErrorWriter{ReturnError: fmt.Errorf("some IO error")}, 30*time.Second) + require.EqualError(t, err, "failed to marshal response to stdout: some IO error") + }) + }, spec.Parallel()) + + when("the token exchange times out", func() { + it.Before(func() { + tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*clientauthentication.ExecCredential, error) { + select { + case <-time.After(100 * time.Millisecond): + return &clientauthentication.ExecCredential{ + Status: &clientauthentication.ExecCredentialStatus{Token: "some token"}, + }, nil + case <-ctx.Done(): + return nil, ctx.Err() + } + } + }) + + it("returns an error", func() { + err := run(envGetter, tokenExchanger, buffer, 1*time.Millisecond) + require.EqualError(t, err, "failed to login: context deadline exceeded") + }) + }, spec.Parallel()) + + when("the token exchange succeeds", func() { + var actualToken, actualCaBundle, actualAPIEndpoint string + + it.Before(func() { + tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*clientauthentication.ExecCredential, error) { + actualToken, actualCaBundle, actualAPIEndpoint = token, caBundle, apiEndpoint + return &clientauthentication.ExecCredential{ + Status: &clientauthentication.ExecCredentialStatus{Token: "some token"}, + }, nil + } + }) + + it("writes the execCredential to the given writer", func() { + err := run(envGetter, tokenExchanger, buffer, 30*time.Second) + require.NoError(t, err) + require.Equal(t, fakeEnv["PLACEHOLDER_NAME_TOKEN"], actualToken) + require.Equal(t, fakeEnv["PLACEHOLDER_NAME_CA_BUNDLE"], actualCaBundle) + require.Equal(t, fakeEnv["PLACEHOLDER_NAME_K8S_API_ENDPOINT"], actualAPIEndpoint) + expected := `{ + "Spec": {"Interactive": false, "Response": null}, + "Status": {"ClientCertificateData": "", "ClientKeyData": "", "ExpirationTimestamp": null, "Token": "some token"} + }` + require.JSONEq(t, expected, buffer.String()) + }) + }, spec.Parallel()) + }, spec.Report(report.Terminal{})) +} diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml index 1b93a647..61d584e8 100644 --- a/deploy/deployment.yaml +++ b/deploy/deployment.yaml @@ -60,7 +60,7 @@ spec: #@ end imagePullPolicy: IfNotPresent command: - - ./placeholder-name + - ./placeholder-name-server args: - --config=/etc/config/placeholder-name.yaml - --downward-api-path=/etc/podinfo diff --git a/go.mod b/go.mod index 0091acad..7764b378 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/golang/mock v1.4.3 github.com/golangci/golangci-lint v1.28.1 github.com/google/go-cmp v0.4.0 + github.com/sclevine/spec v1.4.0 github.com/spf13/cobra v1.0.0 github.com/stretchr/testify v1.6.1 github.com/suzerain-io/placeholder-name-api v0.0.0-20200724000517-dc602fd8d75e diff --git a/go.sum b/go.sum index 38f799c9..503ef603 100644 --- a/go.sum +++ b/go.sum @@ -467,6 +467,8 @@ github.com/ryancurrah/gomodguard v1.1.0/go.mod h1:4O8tr7hBODaGE6VIhfJDHcwzh5GUcc github.com/ryanrolds/sqlclosecheck v0.3.0 h1:AZx+Bixh8zdUBxUA1NxbxVAS78vTPq4rCb8OUZI9xFw= github.com/ryanrolds/sqlclosecheck v0.3.0/go.mod h1:1gREqxyTGR3lVtpngyFo3hZAgk0KCtEdgEkHwDbigdA= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/sclevine/spec v1.4.0 h1:z/Q9idDcay5m5irkZ28M7PtQM4aOISzOpj4bUPkDee8= +github.com/sclevine/spec v1.4.0/go.mod h1:LvpgJaFyvQzRvc1kaDs0bulYwzC70PbiYjC4QnFHkOM= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/securego/gosec/v2 v2.3.0 h1:y/9mCF2WPDbSDpL3QDWZD3HHGrSYw0QSHnCqTfs4JPE= github.com/securego/gosec/v2 v2.3.0/go.mod h1:UzeVyUXbxukhLeHKV3VVqo7HdoQR9MrRfFmZYotn8ME= diff --git a/internal/constable/error.go b/internal/constable/error.go new file mode 100644 index 00000000..462d26a4 --- /dev/null +++ b/internal/constable/error.go @@ -0,0 +1,14 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package constable + +var _ error = Error("") + +type Error string + +func (e Error) Error() string { + return string(e) +} diff --git a/cmd/placeholder-name/app/app.go b/internal/server/server.go similarity index 96% rename from cmd/placeholder-name/app/app.go rename to internal/server/server.go index d6d6c6b9..442e4fd5 100644 --- a/cmd/placeholder-name/app/app.go +++ b/internal/server/server.go @@ -3,8 +3,8 @@ Copyright 2020 VMware, Inc. SPDX-License-Identifier: Apache-2.0 */ -// Package app is the command line entry point for placeholder-name. -package app +// Package server is the command line entry point for placeholder-name-server. +package server import ( "context" @@ -39,7 +39,7 @@ import ( "github.com/suzerain-io/placeholder-name/pkg/config" ) -// App is an object that represents the placeholder-name application. +// App is an object that represents the placeholder-name-server application. type App struct { cmd *cobra.Command @@ -68,8 +68,8 @@ func New(ctx context.Context, args []string, stdout, stderr io.Writer) *App { a.recommendedOptions.Etcd = nil // turn off etcd storage because we don't need it yet cmd := &cobra.Command{ - Use: `placeholder-name`, - Long: `placeholder-name provides a generic API for mapping an external + Use: `placeholder-name-server`, + Long: `placeholder-name-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 { diff --git a/cmd/placeholder-name/app/app_test.go b/internal/server/server_test.go similarity index 89% rename from cmd/placeholder-name/app/app_test.go rename to internal/server/server_test.go index 849925da..5ae9abe8 100644 --- a/cmd/placeholder-name/app/app_test.go +++ b/internal/server/server_test.go @@ -3,7 +3,7 @@ Copyright 2020 VMware, Inc. SPDX-License-Identifier: Apache-2.0 */ -package app +package server import ( "bytes" @@ -17,19 +17,19 @@ import ( ) const knownGoodUsage = ` -placeholder-name provides a generic API for mapping an external +placeholder-name-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. Usage: - placeholder-name [flags] + placeholder-name-server [flags] Flags: --cluster-signing-cert-file string path to cluster signing certificate --cluster-signing-key-file string path to cluster signing private key -c, --config string path to configuration file (default "placeholder-name.yaml") --downward-api-path string path to Downward API volume mount (default "/etc/podinfo") - -h, --help help for placeholder-name + -h, --help help for placeholder-name-server --log-flush-frequency duration Maximum number of seconds between log flushes (default 5s) ` @@ -52,7 +52,7 @@ func TestCommand(t *testing.T) { { name: "OneArgFails", args: []string{"tuna"}, - wantErr: `unknown command "tuna" for "placeholder-name"`, + wantErr: `unknown command "tuna" for "placeholder-name-server"`, }, { name: "ShortConfigFlagSucceeds", @@ -68,7 +68,7 @@ func TestCommand(t *testing.T) { "--config", "some/path/to/config.yaml", "tuna", }, - wantErr: `unknown command "tuna" for "placeholder-name"`, + wantErr: `unknown command "tuna" for "placeholder-name-server"`, }, } for _, test := range tests { diff --git a/cmd/placeholder-name/app/testdata/podinfo/labels b/internal/server/testdata/podinfo/labels similarity index 100% rename from cmd/placeholder-name/app/testdata/podinfo/labels rename to internal/server/testdata/podinfo/labels diff --git a/cmd/placeholder-name/app/testdata/podinfo/namespace b/internal/server/testdata/podinfo/namespace similarity index 100% rename from cmd/placeholder-name/app/testdata/podinfo/namespace rename to internal/server/testdata/podinfo/namespace diff --git a/cmd/placeholder-name/app/testdata/valid-config.yaml b/internal/server/testdata/valid-config.yaml similarity index 100% rename from cmd/placeholder-name/app/testdata/valid-config.yaml rename to internal/server/testdata/valid-config.yaml diff --git a/pkg/client/client.go b/pkg/client/client.go new file mode 100644 index 00000000..b9f372f6 --- /dev/null +++ b/pkg/client/client.go @@ -0,0 +1,82 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package client + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/pkg/apis/clientauthentication" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + placeholderv1alpha1 "github.com/suzerain-io/placeholder-name-api/pkg/apis/placeholder/v1alpha1" + placeholderclientset "github.com/suzerain-io/placeholder-name-client-go/pkg/generated/clientset/versioned" + "github.com/suzerain-io/placeholder-name/internal/constable" +) + +// ErrLoginFailed is returned by ExchangeToken when the server rejects the login request. +const ErrLoginFailed = constable.Error("login failed") + +func ExchangeToken(ctx context.Context, token, caBundle, apiEndpoint string) (*clientauthentication.ExecCredential, error) { + clientset, err := getClient(apiEndpoint, caBundle) + if err != nil { + return nil, fmt.Errorf("could not get API client: %w", err) + } + + resp, err := clientset.PlaceholderV1alpha1().LoginRequests().Create(ctx, &placeholderv1alpha1.LoginRequest{ + Spec: placeholderv1alpha1.LoginRequestSpec{ + Type: placeholderv1alpha1.TokenLoginCredentialType, + Token: &placeholderv1alpha1.LoginRequestTokenCredential{ + Value: token, + }, + }, + }, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("could not login: %w", err) + } + if resp.Status.Credential == nil || resp.Status.Message != "" { + return nil, fmt.Errorf("%w: %s", ErrLoginFailed, resp.Status.Message) + } + + return &clientauthentication.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExecCredential", + APIVersion: "client.authentication.k8s.io/v1beta1", + }, + Status: &clientauthentication.ExecCredentialStatus{ + ExpirationTimestamp: resp.Status.Credential.ExpirationTimestamp, + ClientCertificateData: resp.Status.Credential.ClientCertificateData, + ClientKeyData: resp.Status.Credential.ClientKeyData, + }, + }, nil +} + +// getClient returns an anonymous clientset for the placeholder-name API at the provided endpoint/CA bundle. +func getClient(apiEndpoint string, caBundle string) (placeholderclientset.Interface, error) { + cfg, err := clientcmd.NewNonInteractiveClientConfig(clientcmdapi.Config{ + Clusters: map[string]*clientcmdapi.Cluster{ + "cluster": { + Server: apiEndpoint, + CertificateAuthorityData: []byte(caBundle), + }, + }, + Contexts: map[string]*clientcmdapi.Context{ + "current": { + Cluster: "cluster", + AuthInfo: "client", + }, + }, + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "client": {}, + }, + }, "current", nil, nil).ClientConfig() + if err != nil { + return nil, err + } + return placeholderclientset.NewForConfig(cfg) +} diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go new file mode 100644 index 00000000..251f56e9 --- /dev/null +++ b/pkg/client/client_test.go @@ -0,0 +1,126 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package client + +import ( + "context" + "encoding/json" + "encoding/pem" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/pkg/apis/clientauthentication" + + placeholderv1alpha1 "github.com/suzerain-io/placeholder-name-api/pkg/apis/placeholder/v1alpha1" +) + +func startTestServer(t *testing.T, handler http.HandlerFunc) (string, string) { + t.Helper() + server := httptest.NewTLSServer(handler) + t.Cleanup(server.Close) + + caBundle := string(pem.EncodeToMemory(&pem.Block{ + Type: "CERTIFICATE", + Bytes: server.TLS.Certificates[0].Certificate[0], + })) + return caBundle, server.URL +} + +func TestExchangeToken(t *testing.T) { + t.Parallel() + ctx := context.Background() + + t.Run("invalid configuration", func(t *testing.T) { + t.Parallel() + got, err := ExchangeToken(ctx, "", "", "") + require.EqualError(t, err, "could not get API client: invalid configuration: no configuration has been provided, try setting KUBERNETES_MASTER environment variable") + require.Nil(t, got) + }) + + t.Run("server error", func(t *testing.T) { + t.Parallel() + // Start a test server that returns only 500 errors. + caBundle, endpoint := startTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("some server error")) + }) + + got, err := ExchangeToken(ctx, "", caBundle, endpoint) + require.EqualError(t, err, `could not login: an error on the server ("some server error") has prevented the request from succeeding (post loginrequests.placeholder.suzerain-io.github.io)`) + require.Nil(t, got) + }) + + t.Run("login failure", func(t *testing.T) { + t.Parallel() + // Start a test server that returns success but with an error message + caBundle, endpoint := startTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + _ = json.NewEncoder(w).Encode(&placeholderv1alpha1.LoginRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "placeholder.suzerain-io.github.io/v1alpha1", Kind: "LoginRequest"}, + Status: placeholderv1alpha1.LoginRequestStatus{Message: "some login failure"}, + }) + }) + + got, err := ExchangeToken(ctx, "", caBundle, endpoint) + require.EqualError(t, err, `login failed: some login failure`) + require.Nil(t, got) + }) + + t.Run("success", func(t *testing.T) { + t.Parallel() + + // Start a test server that returns successfully and asserts various properties of the request. + caBundle, endpoint := startTestServer(t, func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "application/json", r.Header.Get("content-type")) + + body, err := ioutil.ReadAll(r.Body) + require.NoError(t, err) + require.JSONEq(t, + `{ + "kind": "LoginRequest", + "apiVersion": "placeholder.suzerain-io.github.io/v1alpha1", + "metadata": { + "creationTimestamp": null + }, + "spec": { + "type": "token", + "token": {} + }, + "status": {} + }`, + string(body), + ) + + w.Header().Set("content-type", "application/json") + _ = json.NewEncoder(w).Encode(&placeholderv1alpha1.LoginRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "placeholder.suzerain-io.github.io/v1alpha1", Kind: "LoginRequest"}, + Status: placeholderv1alpha1.LoginRequestStatus{ + Credential: &placeholderv1alpha1.LoginRequestCredential{ + ClientCertificateData: "test-certificate", + ClientKeyData: "test-key", + }, + }, + }) + }) + + got, err := ExchangeToken(ctx, "", caBundle, endpoint) + require.NoError(t, err) + require.Equal(t, &clientauthentication.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExecCredential", + APIVersion: "client.authentication.k8s.io/v1beta1", + }, + Status: &clientauthentication.ExecCredentialStatus{ + ClientCertificateData: "test-certificate", + ClientKeyData: "test-key", + }, + }, got) + }) +} diff --git a/test/integration/client_test.go b/test/integration/client_test.go new file mode 100644 index 00000000..95f0fb7e --- /dev/null +++ b/test/integration/client_test.go @@ -0,0 +1,77 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package integration + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/suzerain-io/placeholder-name/pkg/client" + "github.com/suzerain-io/placeholder-name/test/library" +) + +var ( + // Test certificate and private key that should get an authentication error. Generated with + // https://github.com/cloudflare/cfssl, like this: + // $ brew install cfssl + // $ cfssl print-defaults csr | cfssl genkey -initca - | cfssljson -bare ca + // $ cfssl print-defaults csr | cfssl gencert -ca ca.pem -ca-key ca-key.pem -hostname=testuser - | cfssljson -bare client + // $ cat client.pem client-key.pem + + 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----- + `) + + testKey = strings.TrimSpace(` +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIAqkBGGKTH5GzLx8XZLAHEFW2E8jT+jpy0p6w6MMR7DkoAoGCCqGSM49 +AwEHoUQDQgAEZO1wQvjo2Jq1zoZu0WiYh1uEOVbBPojFfdAc6u2p7KgIW9OESOt2 +O2D8LtWhMbrYy755Fgq4H9s3vCgfvHY1AQ== +-----END EC PRIVATE KEY----- + `) +) + +func TestClient(t *testing.T) { + tmcClusterToken := os.Getenv("PLACEHOLDER_NAME_TMC_CLUSTER_TOKEN") + require.NotEmptyf(t, tmcClusterToken, "must specify PLACEHOLDER_NAME_TMC_CLUSTER_TOKEN env var for integration tests") + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Use an invalid certificate/key to validate that the ServerVersion API fails like we assume. + invalidClient := library.NewClientsetWithConfig(t, library.NewClientConfigWithCertAndKey(t, testCert, testKey)) + _, err := invalidClient.Discovery().ServerVersion() + require.EqualError(t, err, "the server has asked for the client to provide credentials") + + // Using the CA bundle and host from the current (admin) kubeconfig, do the token exchange. + clientConfig := library.NewClientConfig(t) + resp, err := client.ExchangeToken(ctx, tmcClusterToken, string(clientConfig.CAData), clientConfig.Host) + require.NoError(t, err) + + // Create a client using the certificate and key returned by the token exchange. + validClient := library.NewClientsetWithConfig(t, library.NewClientConfigWithCertAndKey(t, resp.Status.ClientCertificateData, resp.Status.ClientKeyData)) + + // Make a version request, which should succeed even without any authorization. + _, err = validClient.Discovery().ServerVersion() + require.NoError(t, err) +} diff --git a/test/library/ioutil.go b/test/library/ioutil.go new file mode 100644 index 00000000..44bf24ca --- /dev/null +++ b/test/library/ioutil.go @@ -0,0 +1,17 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package library + +import "io" + +// ErrorWriter implements io.Writer by returning a fixed error. +type ErrorWriter struct { + ReturnError error +} + +var _ io.Writer = &ErrorWriter{} + +func (e *ErrorWriter) Write([]byte) (int, error) { return 0, e.ReturnError }