Merge remote-tracking branch 'upstream/main' into discovery_doc

This commit is contained in:
Ryan Richard 2020-07-30 17:28:35 -07:00
commit 2e05e032ee
15 changed files with 370 additions and 120 deletions

View File

@ -63,8 +63,6 @@ linters-settings:
lines: 125 lines: 125
statements: 50 statements: 50
goheader: goheader:
template: |- template-path: hack/header.txt
Copyright 2020 VMware, Inc.
SPDX-License-Identifier: Apache-2.0
goimports: goimports:
local-prefixes: github.com/suzerain-io local-prefixes: github.com/suzerain-io

View File

@ -24,6 +24,8 @@ WORKDIR /work
# Get dependencies first so they can be cached as a layer # Get dependencies first so they can be cached as a layer
COPY go.mod . COPY go.mod .
COPY go.sum . COPY go.sum .
COPY pkg/client/go.mod ./pkg/client/go.mod
COPY pkg/client/go.sum ./pkg/client/go.sum
RUN go mod download RUN go mod download
# Copy only the production source code to avoid cache misses when editing other files # Copy only the production source code to avoid cache misses when editing other files
COPY cmd ./cmd COPY cmd ./cmd

View File

@ -13,6 +13,7 @@ import (
"os" "os"
"time" "time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
"github.com/suzerain-io/placeholder-name/internal/constable" "github.com/suzerain-io/placeholder-name/internal/constable"
@ -28,7 +29,7 @@ func main() {
} }
type envGetter func(string) (string, bool) type envGetter func(string) (string, bool)
type tokenExchanger func(ctx context.Context, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) type tokenExchanger func(ctx context.Context, token, caBundle, apiEndpoint string) (*client.Credential, error)
const ErrMissingEnvVar = constable.Error("failed to login: environment variable not set") const ErrMissingEnvVar = constable.Error("failed to login: environment variable not set")
@ -51,11 +52,28 @@ func run(envGetter envGetter, tokenExchanger tokenExchanger, outputWriter io.Wri
return envVarNotSetError("PLACEHOLDER_NAME_K8S_API_ENDPOINT") return envVarNotSetError("PLACEHOLDER_NAME_K8S_API_ENDPOINT")
} }
execCredential, err := tokenExchanger(ctx, token, caBundle, apiEndpoint) cred, err := tokenExchanger(ctx, token, caBundle, apiEndpoint)
if err != nil { if err != nil {
return fmt.Errorf("failed to login: %w", err) return fmt.Errorf("failed to login: %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(execCredential)
if err != nil { if err != nil {
return fmt.Errorf("failed to marshal response to stdout: %w", err) return fmt.Errorf("failed to marshal response to stdout: %w", err)

View File

@ -15,9 +15,8 @@ import (
"github.com/sclevine/spec" "github.com/sclevine/spec"
"github.com/sclevine/spec/report" "github.com/sclevine/spec/report"
"github.com/stretchr/testify/require" "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/placeholder-name/pkg/client"
"github.com/suzerain-io/placeholder-name/test/library" "github.com/suzerain-io/placeholder-name/test/library"
) )
@ -68,7 +67,7 @@ func TestRun(t *testing.T) {
when("the token exchange fails", func() { when("the token exchange fails", func() {
it.Before(func() { it.Before(func() {
tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*client.Credential, error) {
return nil, fmt.Errorf("some error") return nil, fmt.Errorf("some error")
} }
}) })
@ -81,10 +80,8 @@ func TestRun(t *testing.T) {
when("the JSON encoder fails", func() { when("the JSON encoder fails", func() {
it.Before(func() { it.Before(func() {
tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*client.Credential, error) {
return &clientauthenticationv1beta1.ExecCredential{ return &client.Credential{Token: "some token"}, nil
Status: &clientauthenticationv1beta1.ExecCredentialStatus{Token: "some token"},
}, nil
} }
}) })
@ -96,12 +93,10 @@ func TestRun(t *testing.T) {
when("the token exchange times out", func() { when("the token exchange times out", func() {
it.Before(func() { it.Before(func() {
tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*client.Credential, error) {
select { select {
case <-time.After(100 * time.Millisecond): case <-time.After(100 * time.Millisecond):
return &clientauthenticationv1beta1.ExecCredential{ return &client.Credential{Token: "some token"}, nil
Status: &clientauthenticationv1beta1.ExecCredentialStatus{Token: "some token"},
}, nil
case <-ctx.Done(): case <-ctx.Done():
return nil, ctx.Err() return nil, ctx.Err()
} }
@ -118,14 +113,14 @@ func TestRun(t *testing.T) {
var actualToken, actualCaBundle, actualAPIEndpoint string var actualToken, actualCaBundle, actualAPIEndpoint string
it.Before(func() { it.Before(func() {
tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { tokenExchanger = func(ctx context.Context, token, caBundle, apiEndpoint string) (*client.Credential, error) {
actualToken, actualCaBundle, actualAPIEndpoint = token, caBundle, apiEndpoint actualToken, actualCaBundle, actualAPIEndpoint = token, caBundle, apiEndpoint
return &clientauthenticationv1beta1.ExecCredential{ now := time.Date(2020, 7, 29, 1, 2, 3, 0, time.UTC)
TypeMeta: metav1.TypeMeta{ return &client.Credential{
Kind: "ExecCredential", ExpirationTimestamp: &now,
APIVersion: "client.authentication.k8s.io/v1beta1", ClientCertificateData: "some certificate",
}, ClientKeyData: "some key",
Status: &clientauthenticationv1beta1.ExecCredentialStatus{Token: "some token"}, Token: "some token",
}, nil }, nil
} }
}) })
@ -141,6 +136,9 @@ func TestRun(t *testing.T) {
"apiVersion": "client.authentication.k8s.io/v1beta1", "apiVersion": "client.authentication.k8s.io/v1beta1",
"spec": {}, "spec": {},
"status": { "status": {
"expirationTimestamp":"2020-07-29T01:02:03Z",
"clientCertificateData": "some certificate",
"clientKeyData":"some key",
"token": "some token" "token": "some token"
} }
}` }`

3
go.mod
View File

@ -13,6 +13,7 @@ require (
github.com/suzerain-io/controller-go v0.0.0-20200730212956-7f99b569ca9f github.com/suzerain-io/controller-go v0.0.0-20200730212956-7f99b569ca9f
github.com/suzerain-io/placeholder-name-api v0.0.0-20200730131400-4a1da8d7e70b github.com/suzerain-io/placeholder-name-api v0.0.0-20200730131400-4a1da8d7e70b
github.com/suzerain-io/placeholder-name-client-go v0.0.0-20200729202601-9b4b6d38494c github.com/suzerain-io/placeholder-name-client-go v0.0.0-20200729202601-9b4b6d38494c
github.com/suzerain-io/placeholder-name/pkg/client v0.0.0-00010101000000-000000000000
k8s.io/api v0.19.0-rc.0 k8s.io/api v0.19.0-rc.0
k8s.io/apimachinery v0.19.0-rc.0 k8s.io/apimachinery v0.19.0-rc.0
k8s.io/apiserver v0.19.0-rc.0 k8s.io/apiserver v0.19.0-rc.0
@ -23,3 +24,5 @@ require (
k8s.io/utils v0.0.0-20200619165400-6e3d28b6ed19 k8s.io/utils v0.0.0-20200619165400-6e3d28b6ed19
sigs.k8s.io/yaml v1.2.0 sigs.k8s.io/yaml v1.2.0
) )
replace github.com/suzerain-io/placeholder-name/pkg/client => ./pkg/client

1
go.sum
View File

@ -746,6 +746,7 @@ golang.org/x/tools v0.0.0-20200519015757-0d0afa43d58a/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20200602230032-c00d67ef29d0/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200602230032-c00d67ef29d0/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200625211823-6506e20df31f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200625211823-6506e20df31f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200626171337-aa94e735be7f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200626171337-aa94e735be7f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200707134715-9e0a013e855f/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200710042808-f1c4188a97a1 h1:rD1FcWVsRaMY+l8biE9jbWP5MS/CJJ/90a9TMkMgNrM= golang.org/x/tools v0.0.0-20200710042808-f1c4188a97a1 h1:rD1FcWVsRaMY+l8biE9jbWP5MS/CJJ/90a9TMkMgNrM=
golang.org/x/tools v0.0.0-20200710042808-f1c4188a97a1/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200710042808-f1c4188a97a1/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

5
hack/test-unit.sh Executable file
View File

@ -0,0 +1,5 @@
#!/bin/bash
set -e
go test -race ./...
( cd pkg/client && go test -race ./... )

View File

@ -138,10 +138,11 @@ func (r *REST) Create(ctx context.Context, obj runtime.Object, createValidation
return failureResponse(), nil return failureResponse(), nil
} }
expires := metav1.NewTime(time.Now().UTC().Add(clientCertificateTTL))
return &placeholderapi.LoginRequest{ return &placeholderapi.LoginRequest{
Status: placeholderapi.LoginRequestStatus{ Status: placeholderapi.LoginRequestStatus{
Credential: &placeholderapi.LoginRequestCredential{ Credential: &placeholderapi.LoginRequestCredential{
ExpirationTimestamp: nil, ExpirationTimestamp: &expires,
ClientCertificateData: string(certPEM), ClientCertificateData: string(certPEM),
ClientKeyData: string(keyPEM), ClientKeyData: string(keyPEM),
}, },

View File

@ -153,6 +153,13 @@ func TestCreateSucceedsWhenGivenATokenAndTheWebhookAuthenticatesTheToken(t *test
response, err := callCreate(context.Background(), storage, validLoginRequestWithToken(requestToken)) response, err := callCreate(context.Background(), storage, validLoginRequestWithToken(requestToken))
require.NoError(t, err) require.NoError(t, err)
require.IsType(t, &placeholderapi.LoginRequest{}, response)
expires := response.(*placeholderapi.LoginRequest).Status.Credential.ExpirationTimestamp
require.NotNil(t, expires)
require.InDelta(t, time.Now().Add(1*time.Hour).Unix(), expires.Unix(), 5)
response.(*placeholderapi.LoginRequest).Status.Credential.ExpirationTimestamp = nil
require.Equal(t, response, &placeholderapi.LoginRequest{ require.Equal(t, response, &placeholderapi.LoginRequest{
Status: placeholderapi.LoginRequestStatus{ Status: placeholderapi.LoginRequestStatus{
User: &placeholderapi.User{ User: &placeholderapi.User{

View File

@ -6,77 +6,160 @@ SPDX-License-Identifier: Apache-2.0
package client package client
import ( import (
"bytes"
"context" "context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt" "fmt"
"net/http"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "net/url"
clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" "path/filepath"
"k8s.io/client-go/tools/clientcmd" "time"
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. var (
const ErrLoginFailed = constable.Error("login failed") // ErrLoginFailed is returned by ExchangeToken when the server rejects the login request.
ErrLoginFailed = fmt.Errorf("login failed")
func ExchangeToken(ctx context.Context, token, caBundle, apiEndpoint string) (*clientauthenticationv1beta1.ExecCredential, error) { // ErrInvalidAPIEndpoint is returned by ExchangeToken when the provided API endpoint is invalid.
clientset, err := getClient(apiEndpoint, caBundle) ErrInvalidAPIEndpoint = fmt.Errorf("invalid API endpoint")
if err != nil {
return nil, fmt.Errorf("could not get API client: %w", err) // ErrInvalidCABundle is returned by ExchangeToken when the provided CA bundle is invalid.
ErrInvalidCABundle = fmt.Errorf("invalid CA bundle")
)
const (
// loginRequestsAPIPath is the API path for the v1alpha1 LoginRequest API.
loginRequestsAPIPath = "/apis/placeholder.suzerain-io.github.io/v1alpha1/loginrequests"
// userAgent is the user agent header value sent with requests.
userAgent = "placeholder-name"
)
func loginRequest(ctx context.Context, apiEndpoint *url.URL, token string) (*http.Request, error) {
type LoginRequestTokenCredential struct {
Value string `json:"value"`
} }
type LoginRequestSpec struct {
resp, err := clientset.PlaceholderV1alpha1().LoginRequests().Create(ctx, &placeholderv1alpha1.LoginRequest{ Type string `json:"type"`
Spec: placeholderv1alpha1.LoginRequestSpec{ Token *LoginRequestTokenCredential `json:"token"`
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 != "" { body := struct {
return nil, fmt.Errorf("%w: %s", ErrLoginFailed, resp.Status.Message) APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Metadata struct {
CreationTimestamp *string `json:"creationTimestamp"`
} `json:"metadata"`
Spec LoginRequestSpec `json:"spec"`
Status struct{} `json:"status"`
}{
APIVersion: "placeholder.suzerain-io.github.io/v1alpha1",
Kind: "LoginRequest",
Spec: LoginRequestSpec{Type: "token", Token: &LoginRequestTokenCredential{Value: token}},
} }
bodyJSON, err := json.Marshal(&body)
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,
},
}, 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 { if err != nil {
return nil, err return nil, err
} }
return placeholderclientset.NewForConfig(cfg)
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 LoginRequest API URL by appending the API path to the main API endpoint.
placeholderEndpointURL := *endpointURL
placeholderEndpointURL.Path = filepath.Join(placeholderEndpointURL.Path, loginRequestsAPIPath)
// 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/placeholder.suzerain-io.github.io/v1alpha1/loginrequests" request.
req, err := loginRequest(ctx, &placeholderEndpointURL, 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 login: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("could not login: server returned status %d", 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 login response: %w", err)
}
if respBody.Status.Credential == nil || respBody.Status.Message != "" {
return nil, fmt.Errorf("%w: %s", ErrLoginFailed, 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 login response: %w", err)
}
result.ExpirationTimestamp = &expiration
}
return &result, nil
} }

View File

@ -7,18 +7,14 @@ package client
import ( import (
"context" "context"
"encoding/json"
"encoding/pem" "encoding/pem"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"time"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
placeholderv1alpha1 "github.com/suzerain-io/placeholder-name-api/pkg/apis/placeholder/v1alpha1"
) )
func startTestServer(t *testing.T, handler http.HandlerFunc) (string, string) { func startTestServer(t *testing.T, handler http.HandlerFunc) (string, string) {
@ -39,8 +35,45 @@ func TestExchangeToken(t *testing.T) {
t.Run("invalid configuration", func(t *testing.T) { t.Run("invalid configuration", func(t *testing.T) {
t.Parallel() t.Parallel()
got, err := ExchangeToken(ctx, "", "", "") for _, tt := range []struct {
require.EqualError(t, err, "could not get API client: invalid configuration: no configuration has been provided, try setting KUBERNETES_MASTER environment variable") 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) {})
got, err := ExchangeToken(nil, "", caBundle, endpoint)
require.EqualError(t, err, `could not build request: net/http: nil Context`)
require.Nil(t, got) require.Nil(t, got)
}) })
@ -53,7 +86,43 @@ func TestExchangeToken(t *testing.T) {
}) })
got, err := ExchangeToken(ctx, "", caBundle, endpoint) 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.EqualError(t, err, `could not login: 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 login:")
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 login response: invalid character 'o' in literal null (expecting 'u')`)
require.Nil(t, got) require.Nil(t, got)
}) })
@ -62,10 +131,19 @@ func TestExchangeToken(t *testing.T) {
// Start a test server that returns success but with an error message // Start a test server that returns success but with an error message
caBundle, endpoint := startTestServer(t, func(w http.ResponseWriter, r *http.Request) { caBundle, endpoint := startTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "application/json") w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(&placeholderv1alpha1.LoginRequest{ w.WriteHeader(http.StatusCreated)
TypeMeta: metav1.TypeMeta{APIVersion: "placeholder.suzerain-io.github.io/v1alpha1", Kind: "LoginRequest"}, _, _ = w.Write([]byte(`
Status: placeholderv1alpha1.LoginRequestStatus{Message: "some login failure"}, {
}) "kind": "LoginRequest",
"apiVersion": "placeholder.suzerain-io.github.io/v1alpha1",
"metadata": {
"creationTimestamp": null
},
"spec": {},
"status": {
"message": "some login failure"
}
}`))
}) })
got, err := ExchangeToken(ctx, "", caBundle, endpoint) got, err := ExchangeToken(ctx, "", caBundle, endpoint)
@ -73,11 +151,40 @@ func TestExchangeToken(t *testing.T) {
require.Nil(t, got) 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": "LoginRequest",
"apiVersion": "placeholder.suzerain-io.github.io/v1alpha1",
"metadata": {
"creationTimestamp": null
},
"spec": {},
"status": {
"credential": {
"expirationTimestamp": "invalid"
}
}
}`))
})
got, err := ExchangeToken(ctx, "", caBundle, endpoint)
require.EqualError(t, err, `invalid login 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.Run("success", func(t *testing.T) {
t.Parallel() t.Parallel()
// Start a test server that returns successfully and asserts various properties of the request. // 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) { caBundle, endpoint := startTestServer(t, func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/apis/placeholder.suzerain-io.github.io/v1alpha1/loginrequests", r.URL.Path)
require.Equal(t, "application/json", r.Header.Get("content-type")) require.Equal(t, "application/json", r.Header.Get("content-type"))
body, err := ioutil.ReadAll(r.Body) body, err := ioutil.ReadAll(r.Body)
@ -91,7 +198,9 @@ func TestExchangeToken(t *testing.T) {
}, },
"spec": { "spec": {
"type": "token", "type": "token",
"token": {} "token": {
"value": "test-token"
}
}, },
"status": {} "status": {}
}`, }`,
@ -99,28 +208,34 @@ func TestExchangeToken(t *testing.T) {
) )
w.Header().Set("content-type", "application/json") w.Header().Set("content-type", "application/json")
_ = json.NewEncoder(w).Encode(&placeholderv1alpha1.LoginRequest{ w.WriteHeader(http.StatusCreated)
TypeMeta: metav1.TypeMeta{APIVersion: "placeholder.suzerain-io.github.io/v1alpha1", Kind: "LoginRequest"}, _, _ = w.Write([]byte(`
Status: placeholderv1alpha1.LoginRequestStatus{ {
Credential: &placeholderv1alpha1.LoginRequestCredential{ "kind": "LoginRequest",
ClientCertificateData: "test-certificate", "apiVersion": "placeholder.suzerain-io.github.io/v1alpha1",
ClientKeyData: "test-key", "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, "", caBundle, endpoint) got, err := ExchangeToken(ctx, "test-token", caBundle, endpoint)
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, &clientauthenticationv1beta1.ExecCredential{ expires := time.Date(2020, 07, 30, 15, 52, 1, 0, time.UTC)
TypeMeta: metav1.TypeMeta{ require.Equal(t, &Credential{
Kind: "ExecCredential", ExpirationTimestamp: &expires,
APIVersion: "client.authentication.k8s.io/v1beta1", Token: "test-token",
},
Status: &clientauthenticationv1beta1.ExecCredentialStatus{
ClientCertificateData: "test-certificate", ClientCertificateData: "test-certificate",
ClientKeyData: "test-key", ClientKeyData: "test-key",
},
}, got) }, got)
}) })
} }

5
pkg/client/go.mod Normal file
View File

@ -0,0 +1,5 @@
module github.com/suzerain-io/placeholder-name/pkg/client
go 1.14
require github.com/stretchr/testify v1.6.1

11
pkg/client/go.sum Normal file
View File

@ -0,0 +1,11 @@
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/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/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=

View File

@ -67,9 +67,11 @@ func TestClient(t *testing.T) {
clientConfig := library.NewClientConfig(t) clientConfig := library.NewClientConfig(t)
resp, err := client.ExchangeToken(ctx, tmcClusterToken, string(clientConfig.CAData), clientConfig.Host) resp, err := client.ExchangeToken(ctx, tmcClusterToken, string(clientConfig.CAData), clientConfig.Host)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, resp.ExpirationTimestamp)
require.InDelta(t, time.Until(*resp.ExpirationTimestamp), 1*time.Hour, float64(5*time.Second))
// Create a client using the certificate and key returned by the token exchange. // 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)) validClient := library.NewClientsetWithConfig(t, library.NewClientConfigWithCertAndKey(t, resp.ClientCertificateData, resp.ClientKeyData))
// Make a version request, which should succeed even without any authorization. // Make a version request, which should succeed even without any authorization.
_, err = validClient.Discovery().ServerVersion() _, err = validClient.Discovery().ServerVersion()

View File

@ -55,7 +55,8 @@ func TestSuccessfulLoginRequest(t *testing.T) {
require.Empty(t, response.Status.Credential.Token) require.Empty(t, response.Status.Credential.Token)
require.NotEmpty(t, response.Status.Credential.ClientCertificateData) require.NotEmpty(t, response.Status.Credential.ClientCertificateData)
require.NotEmpty(t, response.Status.Credential.ClientKeyData) require.NotEmpty(t, response.Status.Credential.ClientKeyData)
require.Nil(t, response.Status.Credential.ExpirationTimestamp) require.NotNil(t, response.Status.Credential.ExpirationTimestamp)
require.InDelta(t, time.Until(response.Status.Credential.ExpirationTimestamp.Time), 1*time.Hour, float64(5*time.Second))
require.NotNil(t, response.Status.User) require.NotNil(t, response.Status.User)
require.NotEmpty(t, response.Status.User.Name) require.NotEmpty(t, response.Status.User.Name)
require.Contains(t, response.Status.User.Groups, "tmc:member") require.Contains(t, response.Status.User.Groups, "tmc:member")