From 8f93fbb87b9e056e6e86c0a6f4373730aaae1826 Mon Sep 17 00:00:00 2001 From: Matt Moyer Date: Tue, 25 Aug 2020 10:48:14 -0500 Subject: [PATCH] Make `./pkg/client` into an internal package using the native k8s client. This should simplify our build/test setup quite a bit, since it means we have only a single module (at the top level) with all hand-written code. I'll leave `module.sh` alone for now but we may be able to simplify that a bit more. Signed-off-by: Matt Moyer --- Dockerfile | 2 - cmd/pinniped/main.go | 24 +--- cmd/pinniped/main_test.go | 44 ++++-- go.mod | 2 - go.sum | 7 + internal/client/client.go | 85 +++++++++++ internal/client/client_test.go | 133 ++++++++++++++++++ pkg/client/client.go | 165 ---------------------- pkg/client/client_test.go | 242 -------------------------------- pkg/client/go.mod | 11 -- pkg/client/go.sum | 22 --- test/integration/client_test.go | 8 +- 12 files changed, 261 insertions(+), 484 deletions(-) create mode 100644 internal/client/client.go create mode 100644 internal/client/client_test.go delete mode 100644 pkg/client/client.go delete mode 100644 pkg/client/client_test.go delete mode 100644 pkg/client/go.mod delete mode 100644 pkg/client/go.sum diff --git a/Dockerfile b/Dockerfile index d455e195f..863344249 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,7 +19,6 @@ RUN printf "machine github.com\n\ WORKDIR /work # Get dependencies first so they can be cached as a layer COPY go.* ./ -COPY pkg/client/go.* ./pkg/client/ COPY generated/1.19/apis/go.* ./generated/1.19/apis/ COPY generated/1.19/client/go.* ./generated/1.19/client/ RUN go mod download @@ -35,7 +34,6 @@ COPY hack ./hack # Build the executable binary (CGO_ENABLED=0 means static linking) RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(hack/get-ldflags.sh)" -o out ./cmd/pinniped-server/... - # Use a runtime image based on Debian slim FROM debian:10.5-slim diff --git a/cmd/pinniped/main.go b/cmd/pinniped/main.go index 450fab43c..b232d4009 100644 --- a/cmd/pinniped/main.go +++ b/cmd/pinniped/main.go @@ -13,11 +13,10 @@ import ( "os" "time" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 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/pkg/client" ) func main() { @@ -29,7 +28,7 @@ func main() { } type envGetter func(string) (string, bool) -type tokenExchanger func(ctx context.Context, token, caBundle, apiEndpoint string) (*client.Credential, error) +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") @@ -57,24 +56,7 @@ func run(envGetter envGetter, tokenExchanger tokenExchanger, outputWriter io.Wri return fmt.Errorf("failed to get credential: %w", err) } - var expiration *metav1.Time - if cred.ExpirationTimestamp != nil { - t := metav1.NewTime(*cred.ExpirationTimestamp) - expiration = &t - } - execCredential := clientauthenticationv1beta1.ExecCredential{ - TypeMeta: metav1.TypeMeta{ - Kind: "ExecCredential", - APIVersion: "client.authentication.k8s.io/v1beta1", - }, - Status: &clientauthenticationv1beta1.ExecCredentialStatus{ - ExpirationTimestamp: expiration, - Token: cred.Token, - ClientCertificateData: cred.ClientCertificateData, - ClientKeyData: cred.ClientKeyData, - }, - } - err = json.NewEncoder(outputWriter).Encode(execCredential) + err = json.NewEncoder(outputWriter).Encode(cred) if err != nil { return fmt.Errorf("failed to marshal response to stdout: %w", err) } diff --git a/cmd/pinniped/main_test.go b/cmd/pinniped/main_test.go index 3a9b0c4be..261fa50b9 100644 --- a/cmd/pinniped/main_test.go +++ b/cmd/pinniped/main_test.go @@ -12,13 +12,13 @@ import ( "testing" "time" - "github.com/suzerain-io/pinniped/internal/testutil" - "github.com/sclevine/spec" "github.com/sclevine/spec/report" "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" - "github.com/suzerain-io/pinniped/pkg/client" + "github.com/suzerain-io/pinniped/internal/testutil" ) func TestRun(t *testing.T) { @@ -68,7 +68,7 @@ func TestRun(t *testing.T) { when("the token exchange fails", func() { it.Before(func() { - tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*client.Credential, error) { + tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { return nil, fmt.Errorf("some error") } }) @@ -81,8 +81,12 @@ func TestRun(t *testing.T) { when("the JSON encoder fails", func() { it.Before(func() { - tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*client.Credential, error) { - return &client.Credential{Token: "some token"}, nil + tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { + return &clientauthenticationv1beta1.ExecCredential{ + Status: &clientauthenticationv1beta1.ExecCredentialStatus{ + Token: "some token", + }, + }, nil } }) @@ -94,10 +98,14 @@ func TestRun(t *testing.T) { when("the token exchange times out", func() { it.Before(func() { - tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*client.Credential, error) { + tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { select { case <-time.After(100 * time.Millisecond): - return &client.Credential{Token: "some token"}, nil + return &clientauthenticationv1beta1.ExecCredential{ + Status: &clientauthenticationv1beta1.ExecCredentialStatus{ + Token: "some token", + }, + }, nil case <-ctx.Done(): return nil, ctx.Err() } @@ -114,14 +122,20 @@ func TestRun(t *testing.T) { var actualToken, actualCaBundle, actualAPIEndpoint string it.Before(func() { - tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*client.Credential, error) { + tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { actualToken, actualCaBundle, actualAPIEndpoint = token, caBundle, apiEndpoint - now := time.Date(2020, 7, 29, 1, 2, 3, 0, time.UTC) - return &client.Credential{ - ExpirationTimestamp: &now, - ClientCertificateData: "some certificate", - ClientKeyData: "some key", - Token: "some token", + now := metav1.NewTime(time.Date(2020, 7, 29, 1, 2, 3, 0, time.UTC)) + return &clientauthenticationv1beta1.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExecCredential", + APIVersion: "client.authentication.k8s.io/v1beta1", + }, + Status: &clientauthenticationv1beta1.ExecCredentialStatus{ + ExpirationTimestamp: &now, + ClientCertificateData: "some certificate", + ClientKeyData: "some key", + Token: "some token", + }, }, nil } }) diff --git a/go.mod b/go.mod index 612cd9eb3..3428b5dff 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,6 @@ require ( github.com/suzerain-io/controller-go v0.0.0-20200730212956-7f99b569ca9f github.com/suzerain-io/pinniped/generated/1.19/apis v0.0.0-00010101000000-000000000000 github.com/suzerain-io/pinniped/generated/1.19/client v0.0.0-00010101000000-000000000000 - github.com/suzerain-io/pinniped/pkg/client v0.0.0-00010101000000-000000000000 k8s.io/api v0.19.0-rc.0 k8s.io/apimachinery v0.19.0-rc.0 k8s.io/apiserver v0.19.0-rc.0 @@ -30,5 +29,4 @@ require ( replace ( github.com/suzerain-io/pinniped/generated/1.19/apis => ./generated/1.19/apis github.com/suzerain-io/pinniped/generated/1.19/client => ./generated/1.19/client - github.com/suzerain-io/pinniped/pkg/client => ./pkg/client ) diff --git a/go.sum b/go.sum index 367f652b0..3d8af9cd2 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,7 @@ cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6A cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.51.0 h1:PvKAVQWCtlGUSlZkGW3QLelKaWq7KYv/MW1EboG8bfM= cloud.google.com/go v0.51.0/go.mod h1:hWtGJ6gnXH+KgDv+V0zFGDvpi07n3z8ZNj3T1RW0Gcw= cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= @@ -14,15 +15,21 @@ cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiy dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8= github.com/Azure/go-autorest/autorest v0.9.0/go.mod h1:xyHB1BMZT0cuDHU7I0+g046+BFDTQ8rEZB0s4Yfa6bI= +github.com/Azure/go-autorest/autorest v0.9.6 h1:5YWtOnckcudzIw8lPPBcWOnmIFWMtHci1ZWAZulMSx0= github.com/Azure/go-autorest/autorest v0.9.6/go.mod h1:/FALq9T/kS7b5J5qsQ+RSTUdAmGFqi0vUdVNNx8q630= github.com/Azure/go-autorest/autorest/adal v0.5.0/go.mod h1:8Z9fGy2MpX0PvDjB1pEgQTmVqjGhiHBW7RJJEciWzS0= +github.com/Azure/go-autorest/autorest/adal v0.8.2 h1:O1X4oexUxnZCaEUGsvMnr8ZGj8HI37tNezwY4npRqA0= github.com/Azure/go-autorest/autorest/adal v0.8.2/go.mod h1:ZjhuQClTqx435SRJ2iMlOxPYt3d2C/T/7TiQCVZSn3Q= github.com/Azure/go-autorest/autorest/date v0.1.0/go.mod h1:plvfp3oPSKwf2DNjlBjWF/7vwR+cUD/ELuzDCXwHUVA= +github.com/Azure/go-autorest/autorest/date v0.2.0 h1:yW+Zlqf26583pE43KhfnhFcdmSWlm5Ew6bxipnr/tbM= github.com/Azure/go-autorest/autorest/date v0.2.0/go.mod h1:vcORJHLJEh643/Ioh9+vPmf1Ij9AEBM5FuBIXLmIy0g= github.com/Azure/go-autorest/autorest/mocks v0.1.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= github.com/Azure/go-autorest/autorest/mocks v0.2.0/go.mod h1:OTyCOPRA2IgIlWxVYxBee2F5Gr4kF2zd2J5cFRaIDN0= +github.com/Azure/go-autorest/autorest/mocks v0.3.0 h1:qJumjCaCudz+OcqE9/XtEPfvtOjOmKaui4EOpFI6zZc= github.com/Azure/go-autorest/autorest/mocks v0.3.0/go.mod h1:a8FDP3DYzQ4RYfVAxAN3SVSiiO77gL2j2ronKKP0syM= +github.com/Azure/go-autorest/logger v0.1.0 h1:ruG4BSDXONFRrZZJ2GUXDiUyVpayPmb1GnWeHDdaNKY= github.com/Azure/go-autorest/logger v0.1.0/go.mod h1:oExouG+K6PryycPJfVSxi/koC6LSNgds39diKLz7Vrc= +github.com/Azure/go-autorest/tracing v0.5.0 h1:TRn4WjSnkcSy5AEG3pnbtFSwNtwzjr4VYyQflFE619k= github.com/Azure/go-autorest/tracing v0.5.0/go.mod h1:r/s2XiOKccPW3HrqB+W0TQzfbtp2fGCgRFtBroKn4Dk= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= diff --git a/internal/client/client.go b/internal/client/client.go new file mode 100644 index 000000000..73930b50a --- /dev/null +++ b/internal/client/client.go @@ -0,0 +1,85 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +// Package client is a wrapper for interacting with Pinniped's CredentialRequest API. +package client + +import ( + "context" + "errors" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/suzerain-io/pinniped/generated/1.19/apis/pinniped/v1alpha1" + "github.com/suzerain-io/pinniped/generated/1.19/client/clientset/versioned" +) + +// ErrLoginFailed is returned by ExchangeToken when the server rejects the login request. +var ErrLoginFailed = errors.New("login failed") + +// ExchangeToken exchanges an opaque token using the Pinniped CredentialRequest API, returning a client-go ExecCredential valid on the target cluster. +func ExchangeToken(ctx context.Context, token string, caBundle string, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { + client, err := getClient(apiEndpoint, caBundle) + if err != nil { + return nil, fmt.Errorf("could not get API client: %w", err) + } + + resp, err := client.PinnipedV1alpha1().CredentialRequests().Create(ctx, &v1alpha1.CredentialRequest{ + Spec: v1alpha1.CredentialRequestSpec{ + Type: v1alpha1.TokenCredentialType, + Token: &v1alpha1.CredentialRequestTokenCredential{ + Value: token, + }, + }, + }, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("could not login: %w", err) + } + if resp.Status.Credential == nil || resp.Status.Message != nil { + return nil, fmt.Errorf("%w: %s", ErrLoginFailed, *resp.Status.Message) + } + + return &clientauthenticationv1beta1.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExecCredential", + APIVersion: "client.authentication.k8s.io/v1beta1", + }, + Status: &clientauthenticationv1beta1.ExecCredentialStatus{ + ExpirationTimestamp: &resp.Status.Credential.ExpirationTimestamp, + ClientCertificateData: resp.Status.Credential.ClientCertificateData, + ClientKeyData: resp.Status.Credential.ClientKeyData, + Token: resp.Status.Credential.Token, + }, + }, nil +} + +// getClient returns an anonymous client for the Pinniped API at the provided endpoint/CA bundle. +func getClient(apiEndpoint string, caBundle string) (versioned.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", &clientcmd.ConfigOverrides{}, nil).ClientConfig() + if err != nil { + return nil, err + } + return versioned.NewForConfig(cfg) +} diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 000000000..30b1b795e --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,133 @@ +/* +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" + "time" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" + + "github.com/suzerain-io/pinniped/generated/1.19/apis/pinniped/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 credentialrequests.pinniped.dev)`) + 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 + errorMessage := "some login failure" + caBundle, endpoint := startTestServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + _ = json.NewEncoder(w).Encode(&v1alpha1.CredentialRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "pinniped.dev/v1alpha1", Kind: "CredentialRequest"}, + Status: v1alpha1.CredentialRequestStatus{Message: &errorMessage}, + }) + }) + + 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() + expires := metav1.NewTime(time.Now().Truncate(time.Second)) + + // 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, http.MethodPost, r.Method) + require.Equal(t, "/apis/pinniped.dev/v1alpha1/credentialrequests", r.URL.Path) + require.Equal(t, "application/json", r.Header.Get("content-type")) + + body, err := ioutil.ReadAll(r.Body) + require.NoError(t, err) + require.JSONEq(t, + `{ + "kind": "CredentialRequest", + "apiVersion": "pinniped.dev/v1alpha1", + "metadata": { + "creationTimestamp": null + }, + "spec": { + "type": "token", + "token": {} + }, + "status": {} + }`, + string(body), + ) + + w.Header().Set("content-type", "application/json") + _ = json.NewEncoder(w).Encode(&v1alpha1.CredentialRequest{ + TypeMeta: metav1.TypeMeta{APIVersion: "pinniped.dev/v1alpha1", Kind: "CredentialRequest"}, + Status: v1alpha1.CredentialRequestStatus{ + Credential: &v1alpha1.CredentialRequestCredential{ + ExpirationTimestamp: expires, + ClientCertificateData: "test-certificate", + ClientKeyData: "test-key", + }, + }, + }) + }) + + got, err := ExchangeToken(ctx, "", caBundle, endpoint) + require.NoError(t, err) + require.Equal(t, &clientauthenticationv1beta1.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + Kind: "ExecCredential", + APIVersion: "client.authentication.k8s.io/v1beta1", + }, + Status: &clientauthenticationv1beta1.ExecCredentialStatus{ + ClientCertificateData: "test-certificate", + ClientKeyData: "test-key", + ExpirationTimestamp: &expires, + }, + }, got) + }) +} diff --git a/pkg/client/client.go b/pkg/client/client.go deleted file mode 100644 index 2e8b595c8..000000000 --- a/pkg/client/client.go +++ /dev/null @@ -1,165 +0,0 @@ -/* -Copyright 2020 VMware, Inc. -SPDX-License-Identifier: Apache-2.0 -*/ - -package client - -import ( - "bytes" - "context" - "crypto/tls" - "crypto/x509" - "encoding/json" - "fmt" - "net/http" - "net/url" - "path/filepath" - "time" -) - -var ( - // ErrCredentialRequestFailed is returned by ExchangeToken when the server rejects the credential request. - ErrCredentialRequestFailed = fmt.Errorf("credential request failed") - - // ErrInvalidAPIEndpoint is returned by ExchangeToken when the provided API endpoint is invalid. - ErrInvalidAPIEndpoint = fmt.Errorf("invalid API endpoint") - - // ErrInvalidCABundle is returned by ExchangeToken when the provided CA bundle is invalid. - ErrInvalidCABundle = fmt.Errorf("invalid CA bundle") -) - -const ( - // credentialRequestsAPIPath is the API path for the v1alpha1 CredentialRequest API. - credentialRequestsAPIPath = "/apis/pinniped.dev/v1alpha1/credentialrequests" - - // userAgent is the user agent header value sent with requests. - userAgent = "pinniped" -) - -func credentialRequest(ctx context.Context, apiEndpoint *url.URL, token string) (*http.Request, error) { - type CredentialRequestTokenCredential struct { - Value string `json:"value"` - } - type CredentialRequestSpec struct { - Type string `json:"type"` - Token *CredentialRequestTokenCredential `json:"token"` - } - body := struct { - APIVersion string `json:"apiVersion"` - Kind string `json:"kind"` - Metadata struct { - CreationTimestamp *string `json:"creationTimestamp"` - } `json:"metadata"` - Spec CredentialRequestSpec `json:"spec"` - Status struct{} `json:"status"` - }{ - APIVersion: "pinniped.dev/v1alpha1", - Kind: "CredentialRequest", - Spec: CredentialRequestSpec{Type: "token", Token: &CredentialRequestTokenCredential{Value: token}}, - } - bodyJSON, err := json.Marshal(&body) - if err != nil { - return nil, err - } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiEndpoint.String(), bytes.NewReader(bodyJSON)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", userAgent) - return req, nil -} - -// Credential is the output of an ExchangeToken operation. It is equivalent to the data -// in the Kubernetes client.authentication.k8s.io/v1beta1 ExecCredentialStatus type. -type Credential struct { - // ExpirationTimestamp indicates a time when the provided credentials expire. - ExpirationTimestamp *time.Time - - // Token is a bearer token used by the client for request authentication. - Token string - - // PEM-encoded client TLS certificates (including intermediates, if any). - ClientCertificateData string - - // PEM-encoded private key for the above certificate. - ClientKeyData string -} - -func ExchangeToken(ctx context.Context, token, caBundle, apiEndpoint string) (*Credential, error) { - // Parse and validate the provided API endpoint. - endpointURL, err := url.Parse(apiEndpoint) - if err != nil { - return nil, fmt.Errorf("%w: %s", ErrInvalidAPIEndpoint, err.Error()) - } - if endpointURL.Scheme != "https" { - return nil, fmt.Errorf(`%w: protocol must be "https", not %q`, ErrInvalidAPIEndpoint, endpointURL.Scheme) - } - - // Form the CredentialRequest API URL by appending the API path to the main API endpoint. - pinnipedEndpointURL := *endpointURL - pinnipedEndpointURL.Path = filepath.Join(pinnipedEndpointURL.Path, credentialRequestsAPIPath) - - // Initialize a TLS client configuration from the provided CA bundle. - tlsConfig := tls.Config{ - MinVersion: tls.VersionTLS12, - RootCAs: x509.NewCertPool(), - } - if !tlsConfig.RootCAs.AppendCertsFromPEM([]byte(caBundle)) { - return nil, fmt.Errorf("%w: no certificates found", ErrInvalidCABundle) - } - - // Create a request object for the "POST /apis/pinniped.dev/v1alpha1/credentialrequests" request. - req, err := credentialRequest(ctx, &pinnipedEndpointURL, token) - if err != nil { - return nil, fmt.Errorf("could not build request: %w", err) - } - - client := http.Client{Transport: &http.Transport{TLSClientConfig: &tlsConfig}} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("could not get credential: %w", err) - } - defer func() { _ = resp.Body.Close() }() - if resp.StatusCode != http.StatusCreated { - return nil, fmt.Errorf("%w: server returned status %d", ErrCredentialRequestFailed, resp.StatusCode) - } - - var respBody struct { - APIVersion string `json:"apiVersion"` - Kind string `json:"kind"` - Status struct { - Credential *struct { - ExpirationTimestamp string `json:"expirationTimestamp"` - Token string `json:"token"` - ClientCertificateData string `json:"clientCertificateData"` - ClientKeyData string `json:"clientKeyData"` - } - Message string `json:"message"` - } `json:"status"` - } - if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil { - return nil, fmt.Errorf("invalid credential response: %w", err) - } - - if respBody.Status.Credential == nil || respBody.Status.Message != "" { - return nil, fmt.Errorf("%w: %s", ErrCredentialRequestFailed, respBody.Status.Message) - } - - result := Credential{ - Token: respBody.Status.Credential.Token, - ClientCertificateData: respBody.Status.Credential.ClientCertificateData, - ClientKeyData: respBody.Status.Credential.ClientKeyData, - } - if str := respBody.Status.Credential.ExpirationTimestamp; str != "" { - expiration, err := time.Parse(time.RFC3339, str) - if err != nil { - return nil, fmt.Errorf("invalid credential response: %w", err) - } - result.ExpirationTimestamp = &expiration - } - - return &result, nil -} diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go deleted file mode 100644 index 11849f5a5..000000000 --- a/pkg/client/client_test.go +++ /dev/null @@ -1,242 +0,0 @@ -/* -Copyright 2020 VMware, Inc. -SPDX-License-Identifier: Apache-2.0 -*/ - -package client - -import ( - "context" - "encoding/pem" - "io/ioutil" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -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() - for _, tt := range []struct { - name string - caBundle string - apiEndpoint string - wantErr string - }{ - { - name: "bad URL", - apiEndpoint: "%@Q$!", - wantErr: `invalid API endpoint: parse "%@Q$!": invalid URL escape "%@Q"`, - }, - { - name: "plain HTTP URL", - apiEndpoint: "http://example.com", - wantErr: `invalid API endpoint: protocol must be "https", not "http"`, - }, - { - name: "no CA certs", - apiEndpoint: "https://example.com", - caBundle: "", - wantErr: `invalid CA bundle: no certificates found`, - }, - } { - tt := tt - t.Run(tt.name, func(t *testing.T) { - got, err := ExchangeToken(ctx, "", tt.caBundle, tt.apiEndpoint) - require.EqualError(t, err, tt.wantErr) - require.Nil(t, got) - }) - } - }) - - t.Run("request creation failure", func(t *testing.T) { - t.Parallel() - // Start a test server that doesn't do anything. - caBundle, endpoint := startTestServer(t, func(w http.ResponseWriter, r *http.Request) {}) - - //nolint:staticcheck // ignore "do not pass a nil Context" linter error since that's what we're testing here. - got, err := ExchangeToken(nil, "", caBundle, endpoint) - require.EqualError(t, err, `could not build request: net/http: nil Context`) - 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, `credential request failed: server returned status 500`) - require.Nil(t, got) - }) - - t.Run("request failure", func(t *testing.T) { - t.Parallel() - - clientTimeout := 500 * time.Millisecond - - // Start a test server that is slow to respond. - caBundle, endpoint := startTestServer(t, func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusCreated) - time.Sleep(2 * clientTimeout) - _, _ = w.Write([]byte("slow response")) - }) - - // Make a request using short timeout. - ctx, cancel := context.WithTimeout(ctx, clientTimeout) - defer cancel() - - got, err := ExchangeToken(ctx, "", caBundle, endpoint) - require.Error(t, err) - require.Contains(t, err.Error(), "context deadline exceeded") - require.Contains(t, err.Error(), "could not get credential:") - require.Nil(t, got) - }) - - t.Run("server invalid JSON", 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.StatusCreated) - _, _ = w.Write([]byte("not valid json")) - }) - - got, err := ExchangeToken(ctx, "", caBundle, endpoint) - require.EqualError(t, err, `invalid credential response: invalid character 'o' in literal null (expecting 'u')`) - require.Nil(t, got) - }) - - t.Run("credential request 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") - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(` - { - "kind": "CredentialRequest", - "apiVersion": "pinniped.dev/v1alpha1", - "metadata": { - "creationTimestamp": null - }, - "spec": {}, - "status": { - "message": "some credential request failure" - } - }`)) - }) - - got, err := ExchangeToken(ctx, "", caBundle, endpoint) - require.EqualError(t, err, `credential request failed: some credential request failure`) - require.Nil(t, got) - }) - - t.Run("invalid timestamp 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") - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(` - { - "kind": "CredentialRequest", - "apiVersion": "pinniped.dev/v1alpha1", - "metadata": { - "creationTimestamp": null - }, - "spec": {}, - "status": { - "credential": { - "expirationTimestamp": "invalid" - } - } - }`)) - }) - - got, err := ExchangeToken(ctx, "", caBundle, endpoint) - require.EqualError(t, err, `invalid credential response: parsing time "invalid" as "2006-01-02T15:04:05Z07:00": cannot parse "invalid" as "2006"`) - 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, http.MethodPost, r.Method) - require.Equal(t, "/apis/pinniped.dev/v1alpha1/credentialrequests", r.URL.Path) - require.Equal(t, "application/json", r.Header.Get("content-type")) - - body, err := ioutil.ReadAll(r.Body) - require.NoError(t, err) - require.JSONEq(t, - `{ - "kind": "CredentialRequest", - "apiVersion": "pinniped.dev/v1alpha1", - "metadata": { - "creationTimestamp": null - }, - "spec": { - "type": "token", - "token": { - "value": "test-token" - } - }, - "status": {} - }`, - string(body), - ) - - w.Header().Set("content-type", "application/json") - w.WriteHeader(http.StatusCreated) - _, _ = w.Write([]byte(` - { - "kind": "CredentialRequest", - "apiVersion": "pinniped.dev/v1alpha1", - "metadata": { - "creationTimestamp": null - }, - "spec": {}, - "status": { - "credential": { - "expirationTimestamp": "2020-07-30T15:52:01Z", - "token": "test-token", - "clientCertificateData": "test-certificate", - "clientKeyData": "test-key" - } - } - }`)) - }) - - got, err := ExchangeToken(ctx, "test-token", caBundle, endpoint) - require.NoError(t, err) - expires := time.Date(2020, 07, 30, 15, 52, 1, 0, time.UTC) - require.Equal(t, &Credential{ - ExpirationTimestamp: &expires, - Token: "test-token", - ClientCertificateData: "test-certificate", - ClientKeyData: "test-key", - }, got) - }) -} diff --git a/pkg/client/go.mod b/pkg/client/go.mod deleted file mode 100644 index 8849f0aab..000000000 --- a/pkg/client/go.mod +++ /dev/null @@ -1,11 +0,0 @@ -module github.com/suzerain-io/pinniped/pkg/client - -go 1.14 - -require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/kr/text v0.2.0 // indirect - github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect - github.com/stretchr/testify v1.6.1 - gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect -) diff --git a/pkg/client/go.sum b/pkg/client/go.sum deleted file mode 100644 index b43d86f01..000000000 --- a/pkg/client/go.sum +++ /dev/null @@ -1,22 +0,0 @@ -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/test/integration/client_test.go b/test/integration/client_test.go index 563ed2493..38d476aed 100644 --- a/test/integration/client_test.go +++ b/test/integration/client_test.go @@ -13,7 +13,7 @@ import ( "github.com/stretchr/testify/require" - "github.com/suzerain-io/pinniped/pkg/client" + "github.com/suzerain-io/pinniped/internal/client" "github.com/suzerain-io/pinniped/test/library" ) @@ -71,11 +71,11 @@ func TestClient(t *testing.T) { clientConfig := library.NewClientConfig(t) resp, err := client.ExchangeToken(ctx, tmcClusterToken, string(clientConfig.CAData), clientConfig.Host) require.NoError(t, err) - require.NotNil(t, resp.ExpirationTimestamp) - require.InDelta(t, time.Until(*resp.ExpirationTimestamp), 1*time.Hour, float64(3*time.Minute)) + require.NotNil(t, resp.Status.ExpirationTimestamp) + require.InDelta(t, time.Until(resp.Status.ExpirationTimestamp.Time), 1*time.Hour, float64(3*time.Minute)) // Create a client using the certificate and key returned by the token exchange. - validClient := library.NewClientsetWithCertAndKey(t, resp.ClientCertificateData, resp.ClientKeyData) + validClient := library.NewClientsetWithCertAndKey(t, resp.Status.ClientCertificateData, resp.Status.ClientKeyData) // Make a version request, which should succeed even without any authorization. _, err = validClient.Discovery().ServerVersion()