Add CLI caching of cluster-specific credentials.
Signed-off-by: Matt Moyer <moyerm@vmware.com>
This commit is contained in:
parent
fec24d307e
commit
2296faaeef
@ -22,6 +22,7 @@ import (
|
|||||||
clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
|
clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
|
||||||
"k8s.io/klog/v2/klogr"
|
"k8s.io/klog/v2/klogr"
|
||||||
|
|
||||||
|
"go.pinniped.dev/internal/execcredcache"
|
||||||
"go.pinniped.dev/internal/groupsuffix"
|
"go.pinniped.dev/internal/groupsuffix"
|
||||||
"go.pinniped.dev/pkg/conciergeclient"
|
"go.pinniped.dev/pkg/conciergeclient"
|
||||||
"go.pinniped.dev/pkg/oidcclient"
|
"go.pinniped.dev/pkg/oidcclient"
|
||||||
@ -65,6 +66,7 @@ type oidcLoginFlags struct {
|
|||||||
conciergeEndpoint string
|
conciergeEndpoint string
|
||||||
conciergeCABundle string
|
conciergeCABundle string
|
||||||
conciergeAPIGroupSuffix string
|
conciergeAPIGroupSuffix string
|
||||||
|
credentialCachePath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command {
|
func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command {
|
||||||
@ -95,6 +97,7 @@ func oidcLoginCommand(deps oidcLoginCommandDeps) *cobra.Command {
|
|||||||
cmd.Flags().StringVar(&flags.conciergeEndpoint, "concierge-endpoint", "", "API base for the Concierge endpoint")
|
cmd.Flags().StringVar(&flags.conciergeEndpoint, "concierge-endpoint", "", "API base for the Concierge endpoint")
|
||||||
cmd.Flags().StringVar(&flags.conciergeCABundle, "concierge-ca-bundle-data", "", "CA bundle to use when connecting to the Concierge")
|
cmd.Flags().StringVar(&flags.conciergeCABundle, "concierge-ca-bundle-data", "", "CA bundle to use when connecting to the Concierge")
|
||||||
cmd.Flags().StringVar(&flags.conciergeAPIGroupSuffix, "concierge-api-group-suffix", groupsuffix.PinnipedDefaultSuffix, "Concierge API group suffix")
|
cmd.Flags().StringVar(&flags.conciergeAPIGroupSuffix, "concierge-api-group-suffix", groupsuffix.PinnipedDefaultSuffix, "Concierge API group suffix")
|
||||||
|
cmd.Flags().StringVar(&flags.credentialCachePath, "credential-cache", filepath.Join(mustGetConfigDir(), "credentials.yaml"), "Cluster-specific credentials cache path (\"\" disables the cache)")
|
||||||
|
|
||||||
mustMarkHidden(cmd, "debug-session-cache")
|
mustMarkHidden(cmd, "debug-session-cache")
|
||||||
mustMarkRequired(cmd, "issuer")
|
mustMarkRequired(cmd, "issuer")
|
||||||
@ -164,6 +167,20 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin
|
|||||||
opts = append(opts, oidcclient.WithClient(client))
|
opts = append(opts, oidcclient.WithClient(client))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Look up cached credentials based on a hash of all the CLI arguments.
|
||||||
|
cacheKey := struct {
|
||||||
|
Args []string `json:"args"`
|
||||||
|
}{
|
||||||
|
Args: os.Args[1:],
|
||||||
|
}
|
||||||
|
var credCache *execcredcache.Cache
|
||||||
|
if flags.credentialCachePath != "" {
|
||||||
|
credCache = execcredcache.New(flags.credentialCachePath)
|
||||||
|
if cred := credCache.Get(cacheKey); cred != nil {
|
||||||
|
return json.NewEncoder(cmd.OutOrStdout()).Encode(cred)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Do the basic login to get an OIDC token.
|
// Do the basic login to get an OIDC token.
|
||||||
token, err := deps.login(flags.issuer, flags.clientID, opts...)
|
token, err := deps.login(flags.issuer, flags.clientID, opts...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -181,6 +198,11 @@ func runOIDCLogin(cmd *cobra.Command, deps oidcLoginCommandDeps, flags oidcLogin
|
|||||||
return fmt.Errorf("could not complete Concierge credential exchange: %w", err)
|
return fmt.Errorf("could not complete Concierge credential exchange: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If there was a credential cache, save the resulting credential for future use.
|
||||||
|
if credCache != nil {
|
||||||
|
credCache.Put(cacheKey, cred)
|
||||||
|
}
|
||||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(cred)
|
return json.NewEncoder(cmd.OutOrStdout()).Encode(cred)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -64,6 +64,7 @@ func TestLoginOIDCCommand(t *testing.T) {
|
|||||||
--concierge-authenticator-type string Concierge authenticator type (e.g., 'webhook', 'jwt')
|
--concierge-authenticator-type string Concierge authenticator type (e.g., 'webhook', 'jwt')
|
||||||
--concierge-ca-bundle-data string CA bundle to use when connecting to the Concierge
|
--concierge-ca-bundle-data string CA bundle to use when connecting to the Concierge
|
||||||
--concierge-endpoint string API base for the Concierge endpoint
|
--concierge-endpoint string API base for the Concierge endpoint
|
||||||
|
--credential-cache string Cluster-specific credentials cache path ("" disables the cache) (default "` + cfgDir + `/credentials.yaml")
|
||||||
--enable-concierge Use the Concierge to login
|
--enable-concierge Use the Concierge to login
|
||||||
-h, --help help for oidc
|
-h, --help help for oidc
|
||||||
--issuer string OpenID Connect issuer URL
|
--issuer string OpenID Connect issuer URL
|
||||||
@ -189,6 +190,7 @@ func TestLoginOIDCCommand(t *testing.T) {
|
|||||||
"--concierge-endpoint", "https://127.0.0.1:1234/",
|
"--concierge-endpoint", "https://127.0.0.1:1234/",
|
||||||
"--concierge-ca-bundle-data", base64.StdEncoding.EncodeToString(testCA.Bundle()),
|
"--concierge-ca-bundle-data", base64.StdEncoding.EncodeToString(testCA.Bundle()),
|
||||||
"--concierge-api-group-suffix", "some.suffix.com",
|
"--concierge-api-group-suffix", "some.suffix.com",
|
||||||
|
"--credential-cache", testutil.TempDir(t) + "/credentials.yaml",
|
||||||
},
|
},
|
||||||
wantOptionsCount: 7,
|
wantOptionsCount: 7,
|
||||||
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"token":"exchanged-token"}}` + "\n",
|
wantStdout: `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1beta1","spec":{},"status":{"token":"exchanged-token"}}` + "\n",
|
||||||
|
@ -9,11 +9,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
|
clientauthv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
|
||||||
|
|
||||||
|
"go.pinniped.dev/internal/execcredcache"
|
||||||
"go.pinniped.dev/internal/groupsuffix"
|
"go.pinniped.dev/internal/groupsuffix"
|
||||||
"go.pinniped.dev/pkg/conciergeclient"
|
"go.pinniped.dev/pkg/conciergeclient"
|
||||||
"go.pinniped.dev/pkg/oidcclient/oidctypes"
|
"go.pinniped.dev/pkg/oidcclient/oidctypes"
|
||||||
@ -47,6 +49,7 @@ type staticLoginParams struct {
|
|||||||
conciergeEndpoint string
|
conciergeEndpoint string
|
||||||
conciergeCABundle string
|
conciergeCABundle string
|
||||||
conciergeAPIGroupSuffix string
|
conciergeAPIGroupSuffix string
|
||||||
|
credentialCachePath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func staticLoginCommand(deps staticLoginDeps) *cobra.Command {
|
func staticLoginCommand(deps staticLoginDeps) *cobra.Command {
|
||||||
@ -69,6 +72,7 @@ func staticLoginCommand(deps staticLoginDeps) *cobra.Command {
|
|||||||
cmd.Flags().StringVar(&flags.conciergeEndpoint, "concierge-endpoint", "", "API base for the Concierge endpoint")
|
cmd.Flags().StringVar(&flags.conciergeEndpoint, "concierge-endpoint", "", "API base for the Concierge endpoint")
|
||||||
cmd.Flags().StringVar(&flags.conciergeCABundle, "concierge-ca-bundle-data", "", "CA bundle to use when connecting to the Concierge")
|
cmd.Flags().StringVar(&flags.conciergeCABundle, "concierge-ca-bundle-data", "", "CA bundle to use when connecting to the Concierge")
|
||||||
cmd.Flags().StringVar(&flags.conciergeAPIGroupSuffix, "concierge-api-group-suffix", groupsuffix.PinnipedDefaultSuffix, "Concierge API group suffix")
|
cmd.Flags().StringVar(&flags.conciergeAPIGroupSuffix, "concierge-api-group-suffix", groupsuffix.PinnipedDefaultSuffix, "Concierge API group suffix")
|
||||||
|
cmd.Flags().StringVar(&flags.credentialCachePath, "credential-cache", filepath.Join(mustGetConfigDir(), "credentials.yaml"), "Cluster-specific credentials cache path (\"\" disables the cache)")
|
||||||
|
|
||||||
cmd.RunE = func(cmd *cobra.Command, args []string) error { return runStaticLogin(cmd.OutOrStdout(), deps, flags) }
|
cmd.RunE = func(cmd *cobra.Command, args []string) error { return runStaticLogin(cmd.OutOrStdout(), deps, flags) }
|
||||||
|
|
||||||
@ -113,6 +117,22 @@ func runStaticLogin(out io.Writer, deps staticLoginDeps, flags staticLoginParams
|
|||||||
}
|
}
|
||||||
cred := tokenCredential(&oidctypes.Token{IDToken: &oidctypes.IDToken{Token: token}})
|
cred := tokenCredential(&oidctypes.Token{IDToken: &oidctypes.IDToken{Token: token}})
|
||||||
|
|
||||||
|
// Look up cached credentials based on a hash of all the CLI arguments and the current token value.
|
||||||
|
cacheKey := struct {
|
||||||
|
Args []string `json:"args"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}{
|
||||||
|
Args: os.Args[1:],
|
||||||
|
Token: token,
|
||||||
|
}
|
||||||
|
var credCache *execcredcache.Cache
|
||||||
|
if flags.credentialCachePath != "" {
|
||||||
|
credCache = execcredcache.New(flags.credentialCachePath)
|
||||||
|
if cred := credCache.Get(cacheKey); cred != nil {
|
||||||
|
return json.NewEncoder(out).Encode(cred)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If the concierge was configured, exchange the credential for a separate short-lived, cluster-specific credential.
|
// If the concierge was configured, exchange the credential for a separate short-lived, cluster-specific credential.
|
||||||
if concierge != nil {
|
if concierge != nil {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
@ -124,5 +144,12 @@ func runStaticLogin(out io.Writer, deps staticLoginDeps, flags staticLoginParams
|
|||||||
return fmt.Errorf("could not complete Concierge credential exchange: %w", err)
|
return fmt.Errorf("could not complete Concierge credential exchange: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If there was a credential cache, save the resulting credential for future use. We only save to the cache if
|
||||||
|
// the credential came from the concierge, since that's the only static token case where the cache is useful.
|
||||||
|
if credCache != nil && concierge != nil {
|
||||||
|
credCache.Put(cacheKey, cred)
|
||||||
|
}
|
||||||
|
|
||||||
return json.NewEncoder(out).Encode(cred)
|
return json.NewEncoder(out).Encode(cred)
|
||||||
}
|
}
|
||||||
|
@ -23,6 +23,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestLoginStaticCommand(t *testing.T) {
|
func TestLoginStaticCommand(t *testing.T) {
|
||||||
|
cfgDir := mustGetConfigDir()
|
||||||
|
|
||||||
testCA, err := certauthority.New("Test CA", 1*time.Hour)
|
testCA, err := certauthority.New("Test CA", 1*time.Hour)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
tmpdir := testutil.TempDir(t)
|
tmpdir := testutil.TempDir(t)
|
||||||
@ -55,6 +57,7 @@ func TestLoginStaticCommand(t *testing.T) {
|
|||||||
--concierge-authenticator-type string Concierge authenticator type (e.g., 'webhook', 'jwt')
|
--concierge-authenticator-type string Concierge authenticator type (e.g., 'webhook', 'jwt')
|
||||||
--concierge-ca-bundle-data string CA bundle to use when connecting to the Concierge
|
--concierge-ca-bundle-data string CA bundle to use when connecting to the Concierge
|
||||||
--concierge-endpoint string API base for the Concierge endpoint
|
--concierge-endpoint string API base for the Concierge endpoint
|
||||||
|
--credential-cache string Cluster-specific credentials cache path ("" disables the cache) (default "` + cfgDir + `/credentials.yaml")
|
||||||
--enable-concierge Use the Concierge to login
|
--enable-concierge Use the Concierge to login
|
||||||
-h, --help help for static
|
-h, --help help for static
|
||||||
--token string Static token to present during login
|
--token string Static token to present during login
|
||||||
|
127
internal/execcredcache/cachefile.go
Normal file
127
internal/execcredcache/cachefile.go
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package execcredcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
|
||||||
|
"sigs.k8s.io/yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// errUnsupportedVersion is returned (internally) when we encounter a version of the cache file that we
|
||||||
|
// don't understand how to handle (such as one produced by a future version of Pinniped).
|
||||||
|
errUnsupportedVersion = fmt.Errorf("unsupported credential cache version")
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// apiVersion is the Kubernetes-style API version of the credential cache file object.
|
||||||
|
apiVersion = "config.supervisor.pinniped.dev/v1alpha1"
|
||||||
|
|
||||||
|
// apiKind is the Kubernetes-style Kind of the credential cache file object.
|
||||||
|
apiKind = "CredentialCache"
|
||||||
|
|
||||||
|
// maxCacheDuration is how long a credential can remain in the cache even if it's still otherwise valid.
|
||||||
|
maxCacheDuration = 1 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
// credCache is the object which is YAML-serialized to form the contents of the cache file.
|
||||||
|
credCache struct {
|
||||||
|
metav1.TypeMeta
|
||||||
|
Entries []entry `json:"credentials"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// entry is a single credential in the cache file.
|
||||||
|
entry struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
CreationTimestamp metav1.Time `json:"creationTimestamp"`
|
||||||
|
LastUsedTimestamp metav1.Time `json:"lastUsedTimestamp"`
|
||||||
|
Credential *clientauthenticationv1beta1.ExecCredentialStatus `json:"credential"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// readCache loads a credCache from a path on disk. If the requested path does not exist, it returns an empty cache.
|
||||||
|
func readCache(path string) (*credCache, error) {
|
||||||
|
cacheYAML, err := ioutil.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
// If the file was not found, generate a freshly initialized empty cache.
|
||||||
|
return emptyCache(), nil
|
||||||
|
}
|
||||||
|
// Otherwise bubble up the error.
|
||||||
|
return nil, fmt.Errorf("could not read cache file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we read the file successfully, unmarshal it from YAML.
|
||||||
|
var cache credCache
|
||||||
|
if err := yaml.Unmarshal(cacheYAML, &cache); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid cache file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that we're reading a version of the config we understand how to parse.
|
||||||
|
if !(cache.TypeMeta.APIVersion == apiVersion && cache.TypeMeta.Kind == apiKind) {
|
||||||
|
return nil, fmt.Errorf("%w: %#v", errUnsupportedVersion, cache.TypeMeta)
|
||||||
|
}
|
||||||
|
return &cache, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// emptyCache returns an empty, initialized credCache.
|
||||||
|
func emptyCache() *credCache {
|
||||||
|
return &credCache{
|
||||||
|
TypeMeta: metav1.TypeMeta{APIVersion: apiVersion, Kind: apiKind},
|
||||||
|
Entries: make([]entry, 0, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeTo writes the cache to the specified file path.
|
||||||
|
func (c *credCache) writeTo(path string) error {
|
||||||
|
// Marshal the cache back to YAML and save it to the file.
|
||||||
|
cacheYAML, err := yaml.Marshal(c)
|
||||||
|
if err == nil {
|
||||||
|
err = ioutil.WriteFile(path, cacheYAML, 0600)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalized returns a copy of the credCache with stale entries removed and entries sorted in a canonical order.
|
||||||
|
func (c *credCache) normalized() *credCache {
|
||||||
|
result := emptyCache()
|
||||||
|
|
||||||
|
// Clean up expired/invalid tokens.
|
||||||
|
now := time.Now()
|
||||||
|
result.Entries = make([]entry, 0, len(c.Entries))
|
||||||
|
|
||||||
|
for _, e := range c.Entries {
|
||||||
|
// Eliminate any cache entries that are missing a credential or an expiration timestamp.
|
||||||
|
if e.Credential == nil || e.Credential.ExpirationTimestamp == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminate any expired credentials.
|
||||||
|
if e.Credential.ExpirationTimestamp.Time.Before(time.Now()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eliminate any entries older than maxCacheDuration.
|
||||||
|
if e.CreationTimestamp.Time.Before(now.Add(-maxCacheDuration)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result.Entries = append(result.Entries, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort the entries by creation time.
|
||||||
|
sort.SliceStable(result.Entries, func(i, j int) bool {
|
||||||
|
return result.Entries[i].CreationTimestamp.Before(&result.Entries[j].CreationTimestamp)
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
207
internal/execcredcache/cachefile_test.go
Normal file
207
internal/execcredcache/cachefile_test.go
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package execcredcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
|
||||||
|
|
||||||
|
"go.pinniped.dev/internal/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// validCache should be the same data as `testdata/valid.yaml`.
|
||||||
|
validCache = credCache{
|
||||||
|
TypeMeta: metav1.TypeMeta{APIVersion: "config.supervisor.pinniped.dev/v1alpha1", Kind: "CredentialCache"},
|
||||||
|
Entries: []entry{
|
||||||
|
{
|
||||||
|
Key: "test-key",
|
||||||
|
CreationTimestamp: metav1.NewTime(time.Date(2020, 10, 20, 18, 42, 7, 0, time.UTC).Local()),
|
||||||
|
LastUsedTimestamp: metav1.NewTime(time.Date(2020, 10, 20, 18, 45, 31, 0, time.UTC).Local()),
|
||||||
|
Credential: &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||||
|
Token: "test-token",
|
||||||
|
ExpirationTimestamp: &expTime,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
expTime = metav1.NewTime(time.Date(2020, 10, 20, 19, 46, 30, 0, time.UTC).Local())
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReadCache(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
want *credCache
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "does not exist",
|
||||||
|
path: "./testdata/does-not-exist.yaml",
|
||||||
|
want: &credCache{
|
||||||
|
TypeMeta: metav1.TypeMeta{APIVersion: "config.supervisor.pinniped.dev/v1alpha1", Kind: "CredentialCache"},
|
||||||
|
Entries: []entry{},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "other file error",
|
||||||
|
path: "./testdata/",
|
||||||
|
wantErr: "could not read cache file: read ./testdata/: is a directory",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid YAML",
|
||||||
|
path: "./testdata/invalid.yaml",
|
||||||
|
wantErr: "invalid cache file: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal string into Go value of type execcredcache.credCache",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong version",
|
||||||
|
path: "./testdata/wrong-version.yaml",
|
||||||
|
wantErr: `unsupported credential cache version: v1.TypeMeta{Kind:"NotACredentialCache", APIVersion:"config.supervisor.pinniped.dev/v2alpha6"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
path: "./testdata/valid.yaml",
|
||||||
|
want: &validCache,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got, err := readCache(tt.path)
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.EqualError(t, err, tt.wantErr)
|
||||||
|
require.Nil(t, got)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got)
|
||||||
|
require.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEmptyCache(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := emptyCache()
|
||||||
|
require.Equal(t, metav1.TypeMeta{APIVersion: "config.supervisor.pinniped.dev/v1alpha1", Kind: "CredentialCache"}, got.TypeMeta)
|
||||||
|
require.Equal(t, 0, len(got.Entries))
|
||||||
|
require.Equal(t, 1, cap(got.Entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteTo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
t.Run("io error", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tmp := testutil.TempDir(t) + "/credentials.yaml"
|
||||||
|
require.NoError(t, os.Mkdir(tmp, 0700))
|
||||||
|
err := validCache.writeTo(tmp)
|
||||||
|
require.EqualError(t, err, "open "+tmp+": is a directory")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("success", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
require.NoError(t, validCache.writeTo(testutil.TempDir(t)+"/credentials.yaml"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalized(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("empty", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
require.Equal(t, emptyCache(), emptyCache().normalized())
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nonempty", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
input := emptyCache()
|
||||||
|
now := time.Now()
|
||||||
|
oneMinuteAgo := metav1.NewTime(now.Add(-1 * time.Minute))
|
||||||
|
oneHourFromNow := metav1.NewTime(now.Add(1 * time.Hour))
|
||||||
|
input.Entries = []entry{
|
||||||
|
// Credential is nil.
|
||||||
|
{
|
||||||
|
Key: "nil-credential-key",
|
||||||
|
LastUsedTimestamp: metav1.NewTime(now),
|
||||||
|
Credential: nil,
|
||||||
|
},
|
||||||
|
// Credential's expiration is nil.
|
||||||
|
{
|
||||||
|
Key: "nil-expiration-key",
|
||||||
|
LastUsedTimestamp: metav1.NewTime(now),
|
||||||
|
Credential: &clientauthenticationv1beta1.ExecCredentialStatus{},
|
||||||
|
},
|
||||||
|
// Credential is expired.
|
||||||
|
{
|
||||||
|
Key: "expired-key",
|
||||||
|
LastUsedTimestamp: metav1.NewTime(now),
|
||||||
|
Credential: &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||||
|
ExpirationTimestamp: &oneMinuteAgo,
|
||||||
|
Token: "expired-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Credential is still valid but is older than maxCacheDuration.
|
||||||
|
{
|
||||||
|
Key: "too-old-key",
|
||||||
|
LastUsedTimestamp: metav1.NewTime(now),
|
||||||
|
CreationTimestamp: metav1.NewTime(now.Add(-3 * time.Hour)),
|
||||||
|
Credential: &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||||
|
ExpirationTimestamp: &oneHourFromNow,
|
||||||
|
Token: "too-old-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Two entries that are still valid but are out of order.
|
||||||
|
{
|
||||||
|
Key: "key-two",
|
||||||
|
CreationTimestamp: metav1.NewTime(now.Add(-1 * time.Minute)),
|
||||||
|
LastUsedTimestamp: metav1.NewTime(now),
|
||||||
|
Credential: &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||||
|
ExpirationTimestamp: &oneHourFromNow,
|
||||||
|
Token: "token-two",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "key-one",
|
||||||
|
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Minute)),
|
||||||
|
LastUsedTimestamp: metav1.NewTime(now),
|
||||||
|
Credential: &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||||
|
ExpirationTimestamp: &oneHourFromNow,
|
||||||
|
Token: "token-one",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect that all but the last two valid entries are pruned, and that they're sorted.
|
||||||
|
require.Equal(t, &credCache{
|
||||||
|
TypeMeta: metav1.TypeMeta{APIVersion: "config.supervisor.pinniped.dev/v1alpha1", Kind: "CredentialCache"},
|
||||||
|
Entries: []entry{
|
||||||
|
{
|
||||||
|
Key: "key-one",
|
||||||
|
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Minute)),
|
||||||
|
LastUsedTimestamp: metav1.NewTime(now),
|
||||||
|
Credential: &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||||
|
ExpirationTimestamp: &oneHourFromNow,
|
||||||
|
Token: "token-one",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Key: "key-two",
|
||||||
|
CreationTimestamp: metav1.NewTime(now.Add(-1 * time.Minute)),
|
||||||
|
LastUsedTimestamp: metav1.NewTime(now),
|
||||||
|
Credential: &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||||
|
ExpirationTimestamp: &oneHourFromNow,
|
||||||
|
Token: "token-two",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, input.normalized())
|
||||||
|
})
|
||||||
|
}
|
159
internal/execcredcache/execcredcache.go
Normal file
159
internal/execcredcache/execcredcache.go
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
// Package execcredcache implements a cache for Kubernetes ExecCredential data.
|
||||||
|
package execcredcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofrs/flock"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// defaultFileLockTimeout is how long we will wait trying to acquire the file lock on the cache file before timing out.
|
||||||
|
defaultFileLockTimeout = 10 * time.Second
|
||||||
|
|
||||||
|
// defaultFileLockRetryInterval is how often we will poll while waiting for the file lock to become available.
|
||||||
|
defaultFileLockRetryInterval = 10 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
type Cache struct {
|
||||||
|
path string
|
||||||
|
errReporter func(error)
|
||||||
|
trylockFunc func() error
|
||||||
|
unlockFunc func() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(path string) *Cache {
|
||||||
|
lock := flock.New(path + ".lock")
|
||||||
|
return &Cache{
|
||||||
|
path: path,
|
||||||
|
trylockFunc: func() error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), defaultFileLockTimeout)
|
||||||
|
defer cancel()
|
||||||
|
_, err := lock.TryLockContext(ctx, defaultFileLockRetryInterval)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
unlockFunc: lock.Unlock,
|
||||||
|
errReporter: func(_ error) {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Get(key interface{}) *clientauthenticationv1beta1.ExecCredential {
|
||||||
|
// If the cache file does not exist, exit immediately with no error log
|
||||||
|
if _, err := os.Stat(c.path); errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the cache and lookup the matching entry. If one exists, update its last used timestamp and return it.
|
||||||
|
var result *clientauthenticationv1beta1.ExecCredential
|
||||||
|
cacheKey := jsonSHA256Hex(key)
|
||||||
|
c.withCache(func(cache *credCache) {
|
||||||
|
// Find the existing entry, if one exists
|
||||||
|
for i := range cache.Entries {
|
||||||
|
if cache.Entries[i].Key == cacheKey {
|
||||||
|
result = &clientauthenticationv1beta1.ExecCredential{
|
||||||
|
TypeMeta: metav1.TypeMeta{
|
||||||
|
Kind: "ExecCredential",
|
||||||
|
APIVersion: "client.authentication.k8s.io/v1beta1",
|
||||||
|
},
|
||||||
|
Status: cache.Entries[i].Credential,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the last-used timestamp.
|
||||||
|
cache.Entries[i].LastUsedTimestamp = metav1.Now()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Cache) Put(key interface{}, cred *clientauthenticationv1beta1.ExecCredential) {
|
||||||
|
// Create the cache directory if it does not exist.
|
||||||
|
if err := os.MkdirAll(filepath.Dir(c.path), 0700); err != nil && !errors.Is(err, os.ErrExist) {
|
||||||
|
c.errReporter(fmt.Errorf("could not create credential cache directory: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mutate the cache to upsert the new entry.
|
||||||
|
cacheKey := jsonSHA256Hex(key)
|
||||||
|
c.withCache(func(cache *credCache) {
|
||||||
|
// Find the existing entry, if one exists
|
||||||
|
for i := range cache.Entries {
|
||||||
|
if cache.Entries[i].Key == cacheKey {
|
||||||
|
// Update the stored entry and return.
|
||||||
|
cache.Entries[i].Credential = cred.Status
|
||||||
|
cache.Entries[i].LastUsedTimestamp = metav1.Now()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If there's not an entry for this key, insert one.
|
||||||
|
now := metav1.Now()
|
||||||
|
cache.Entries = append(cache.Entries, entry{
|
||||||
|
Key: cacheKey,
|
||||||
|
CreationTimestamp: now,
|
||||||
|
LastUsedTimestamp: now,
|
||||||
|
Credential: cred.Status,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonSHA256Hex(key interface{}) string {
|
||||||
|
hash := sha256.New()
|
||||||
|
if err := json.NewEncoder(hash).Encode(key); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(hash.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// withCache is an internal helper which locks, reads the cache, processes/mutates it with the provided function, then
|
||||||
|
// saves it back to the file.
|
||||||
|
func (c *Cache) withCache(transact func(*credCache)) {
|
||||||
|
// Grab the file lock so we have exclusive access to read the file.
|
||||||
|
if err := c.trylockFunc(); err != nil {
|
||||||
|
c.errReporter(fmt.Errorf("could not lock cache file: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock the file at the end of this call, bubbling up the error if things were otherwise successful.
|
||||||
|
defer func() {
|
||||||
|
if err := c.unlockFunc(); err != nil {
|
||||||
|
c.errReporter(fmt.Errorf("could not unlock cache file: %w", err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Try to read the existing cache.
|
||||||
|
cache, err := readCache(c.path)
|
||||||
|
if err != nil {
|
||||||
|
// If that fails, fall back to resetting to a blank slate.
|
||||||
|
c.errReporter(fmt.Errorf("failed to read cache, resetting: %w", err))
|
||||||
|
cache = emptyCache()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize the cache before modifying it, to remove any entries that have already expired.
|
||||||
|
cache = cache.normalized()
|
||||||
|
|
||||||
|
// Process/mutate the cache using the provided function.
|
||||||
|
transact(cache)
|
||||||
|
|
||||||
|
// Normalize again to put everything into a known order.
|
||||||
|
cache = cache.normalized()
|
||||||
|
|
||||||
|
// Marshal the cache back to YAML and save it to the file.
|
||||||
|
if err := cache.writeTo(c.path); err != nil {
|
||||||
|
c.errReporter(fmt.Errorf("could not write cache: %w", err))
|
||||||
|
}
|
||||||
|
}
|
389
internal/execcredcache/execcredcache_test.go
Normal file
389
internal/execcredcache/execcredcache_test.go
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
// Copyright 2021 the Pinniped contributors. All Rights Reserved.
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package execcredcache
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1"
|
||||||
|
|
||||||
|
"go.pinniped.dev/internal/testutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNew(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tmp := testutil.TempDir(t) + "/credentials.yaml"
|
||||||
|
c := New(tmp)
|
||||||
|
require.NotNil(t, c)
|
||||||
|
require.Equal(t, tmp, c.path)
|
||||||
|
require.NotNil(t, c.errReporter)
|
||||||
|
c.errReporter(fmt.Errorf("some error"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
now := time.Now().Round(1 * time.Second)
|
||||||
|
oneHourFromNow := metav1.NewTime(now.Add(1 * time.Hour))
|
||||||
|
|
||||||
|
type testKey struct{ K1, K2 string }
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
makeTestFile func(t *testing.T, tmp string)
|
||||||
|
trylockFunc func(*testing.T) error
|
||||||
|
unlockFunc func(*testing.T) error
|
||||||
|
key testKey
|
||||||
|
want *clientauthenticationv1beta1.ExecCredential
|
||||||
|
wantErrors []string
|
||||||
|
wantTestFile func(t *testing.T, tmp string)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "not found",
|
||||||
|
key: testKey{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file lock error",
|
||||||
|
makeTestFile: func(t *testing.T, tmp string) { require.NoError(t, ioutil.WriteFile(tmp, []byte(""), 0600)) },
|
||||||
|
trylockFunc: func(t *testing.T) error { return fmt.Errorf("some lock error") },
|
||||||
|
unlockFunc: func(t *testing.T) error { require.Fail(t, "should not be called"); return nil },
|
||||||
|
key: testKey{},
|
||||||
|
wantErrors: []string{"could not lock cache file: some lock error"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid file",
|
||||||
|
makeTestFile: func(t *testing.T, tmp string) {
|
||||||
|
require.NoError(t, ioutil.WriteFile(tmp, []byte("invalid yaml"), 0600))
|
||||||
|
},
|
||||||
|
key: testKey{},
|
||||||
|
wantErrors: []string{
|
||||||
|
"failed to read cache, resetting: invalid cache file: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal string into Go value of type execcredcache.credCache",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid file, fail to unlock",
|
||||||
|
makeTestFile: func(t *testing.T, tmp string) { require.NoError(t, ioutil.WriteFile(tmp, []byte("invalid"), 0600)) },
|
||||||
|
trylockFunc: func(t *testing.T) error { return nil },
|
||||||
|
unlockFunc: func(t *testing.T) error { return fmt.Errorf("some unlock error") },
|
||||||
|
key: testKey{},
|
||||||
|
wantErrors: []string{
|
||||||
|
"failed to read cache, resetting: invalid cache file: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal string into Go value of type execcredcache.credCache",
|
||||||
|
"could not unlock cache file: some unlock error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unreadable file",
|
||||||
|
makeTestFile: func(t *testing.T, tmp string) {
|
||||||
|
require.NoError(t, os.Mkdir(tmp, 0700))
|
||||||
|
},
|
||||||
|
key: testKey{},
|
||||||
|
wantErrors: []string{
|
||||||
|
"failed to read cache, resetting: could not read cache file: read TEMPFILE: is a directory",
|
||||||
|
"could not write cache: open TEMPFILE: is a directory",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid file but cache miss",
|
||||||
|
makeTestFile: func(t *testing.T, tmp string) {
|
||||||
|
validCache := emptyCache()
|
||||||
|
validCache.Entries = []entry{{
|
||||||
|
Key: jsonSHA256Hex(testKey{K1: "v3", K2: "v4"}),
|
||||||
|
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Minute)),
|
||||||
|
LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Minute)),
|
||||||
|
Credential: &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||||
|
Token: "test-token",
|
||||||
|
ExpirationTimestamp: &oneHourFromNow,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
require.NoError(t, validCache.writeTo(tmp))
|
||||||
|
},
|
||||||
|
key: testKey{K1: "v1", K2: "v2"},
|
||||||
|
wantErrors: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid file but expired cache hit",
|
||||||
|
makeTestFile: func(t *testing.T, tmp string) {
|
||||||
|
validCache := emptyCache()
|
||||||
|
oneMinuteAgo := metav1.NewTime(now.Add(-1 * time.Minute))
|
||||||
|
validCache.Entries = []entry{{
|
||||||
|
Key: jsonSHA256Hex(testKey{K1: "v1", K2: "v2"}),
|
||||||
|
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Minute)),
|
||||||
|
LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Minute)),
|
||||||
|
Credential: &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||||
|
Token: "test-token",
|
||||||
|
ExpirationTimestamp: &oneMinuteAgo,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
require.NoError(t, validCache.writeTo(tmp))
|
||||||
|
},
|
||||||
|
key: testKey{K1: "v1", K2: "v2"},
|
||||||
|
wantErrors: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid file with cache hit",
|
||||||
|
makeTestFile: func(t *testing.T, tmp string) {
|
||||||
|
validCache := emptyCache()
|
||||||
|
|
||||||
|
validCache.Entries = []entry{{
|
||||||
|
Key: jsonSHA256Hex(testKey{K1: "v1", K2: "v2"}),
|
||||||
|
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Minute)),
|
||||||
|
LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Minute)),
|
||||||
|
Credential: &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||||
|
Token: "test-token",
|
||||||
|
ExpirationTimestamp: &oneHourFromNow,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
require.NoError(t, validCache.writeTo(tmp))
|
||||||
|
},
|
||||||
|
key: testKey{K1: "v1", K2: "v2"},
|
||||||
|
wantErrors: []string{},
|
||||||
|
want: &clientauthenticationv1beta1.ExecCredential{
|
||||||
|
TypeMeta: metav1.TypeMeta{
|
||||||
|
Kind: "ExecCredential",
|
||||||
|
APIVersion: "client.authentication.k8s.io/v1beta1",
|
||||||
|
},
|
||||||
|
Spec: clientauthenticationv1beta1.ExecCredentialSpec{},
|
||||||
|
Status: &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||||
|
Token: "test-token",
|
||||||
|
ExpirationTimestamp: &oneHourFromNow,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantTestFile: func(t *testing.T, tmp string) {
|
||||||
|
cache, err := readCache(tmp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, cache.Entries, 1)
|
||||||
|
require.Less(t, time.Since(cache.Entries[0].LastUsedTimestamp.Time).Nanoseconds(), (5 * time.Second).Nanoseconds())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tmp := testutil.TempDir(t) + "/sessions.yaml"
|
||||||
|
if tt.makeTestFile != nil {
|
||||||
|
tt.makeTestFile(t, tmp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize a cache with a reporter that collects errors
|
||||||
|
errors := errorCollector{t: t}
|
||||||
|
c := New(tmp)
|
||||||
|
c.errReporter = errors.report
|
||||||
|
if tt.trylockFunc != nil {
|
||||||
|
c.trylockFunc = func() error { return tt.trylockFunc(t) }
|
||||||
|
}
|
||||||
|
if tt.unlockFunc != nil {
|
||||||
|
c.unlockFunc = func() error { return tt.unlockFunc(t) }
|
||||||
|
}
|
||||||
|
|
||||||
|
got := c.Get(tt.key)
|
||||||
|
require.Equal(t, tt.want, got)
|
||||||
|
errors.require(tt.wantErrors, "TEMPFILE", tmp)
|
||||||
|
if tt.wantTestFile != nil {
|
||||||
|
tt.wantTestFile(t, tmp)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPutToken(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
now := time.Now().Round(1 * time.Second)
|
||||||
|
|
||||||
|
type testKey struct{ K1, K2 string }
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
makeTestFile func(t *testing.T, tmp string)
|
||||||
|
key testKey
|
||||||
|
cred *clientauthenticationv1beta1.ExecCredential
|
||||||
|
wantErrors []string
|
||||||
|
wantTestFile func(t *testing.T, tmp string)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "fail to create directory",
|
||||||
|
makeTestFile: func(t *testing.T, tmp string) {
|
||||||
|
require.NoError(t, ioutil.WriteFile(filepath.Dir(tmp), []byte{}, 0600))
|
||||||
|
},
|
||||||
|
wantErrors: []string{
|
||||||
|
"could not create credential cache directory: mkdir TEMPDIR: not a directory",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update to existing entry",
|
||||||
|
makeTestFile: func(t *testing.T, tmp string) {
|
||||||
|
validCache := emptyCache()
|
||||||
|
validCache.Entries = []entry{
|
||||||
|
{
|
||||||
|
Key: jsonSHA256Hex(testKey{K1: "v1", K2: "v2"}),
|
||||||
|
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Minute)),
|
||||||
|
LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Minute)),
|
||||||
|
Credential: &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||||
|
ExpirationTimestamp: timePtr(now.Add(1 * time.Hour)),
|
||||||
|
Token: "token-one",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// A second entry that was created over a day ago.
|
||||||
|
{
|
||||||
|
Key: jsonSHA256Hex(testKey{K1: "v3", K2: "v4"}),
|
||||||
|
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Hour)),
|
||||||
|
LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Hour)),
|
||||||
|
Credential: &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||||
|
ExpirationTimestamp: timePtr(now.Add(1 * time.Hour)),
|
||||||
|
Token: "token-two",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(tmp), 0700))
|
||||||
|
require.NoError(t, validCache.writeTo(tmp))
|
||||||
|
},
|
||||||
|
key: testKey{K1: "v1", K2: "v2"},
|
||||||
|
cred: &clientauthenticationv1beta1.ExecCredential{
|
||||||
|
TypeMeta: metav1.TypeMeta{
|
||||||
|
Kind: "ExecCredential",
|
||||||
|
APIVersion: "client.authentication.k8s.io/v1beta1",
|
||||||
|
},
|
||||||
|
Status: &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||||
|
ExpirationTimestamp: timePtr(now.Add(1 * time.Hour)),
|
||||||
|
Token: "token-one",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantTestFile: func(t *testing.T, tmp string) {
|
||||||
|
cache, err := readCache(tmp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, cache.Entries, 1)
|
||||||
|
require.Less(t, time.Since(cache.Entries[0].LastUsedTimestamp.Time).Nanoseconds(), (5 * time.Second).Nanoseconds())
|
||||||
|
require.Equal(t, &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||||
|
ExpirationTimestamp: timePtr(now.Add(1 * time.Hour).Local()),
|
||||||
|
Token: "token-one",
|
||||||
|
}, cache.Entries[0].Credential)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "new entry",
|
||||||
|
makeTestFile: func(t *testing.T, tmp string) {
|
||||||
|
validCache := emptyCache()
|
||||||
|
validCache.Entries = []entry{
|
||||||
|
{
|
||||||
|
Key: jsonSHA256Hex(testKey{K1: "v3", K2: "v4"}),
|
||||||
|
CreationTimestamp: metav1.NewTime(now.Add(-2 * time.Minute)),
|
||||||
|
LastUsedTimestamp: metav1.NewTime(now.Add(-1 * time.Minute)),
|
||||||
|
Credential: &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||||
|
ExpirationTimestamp: timePtr(now.Add(1 * time.Hour)),
|
||||||
|
Token: "other-token",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(tmp), 0700))
|
||||||
|
require.NoError(t, validCache.writeTo(tmp))
|
||||||
|
},
|
||||||
|
key: testKey{K1: "v1", K2: "v2"},
|
||||||
|
cred: &clientauthenticationv1beta1.ExecCredential{
|
||||||
|
TypeMeta: metav1.TypeMeta{
|
||||||
|
Kind: "ExecCredential",
|
||||||
|
APIVersion: "client.authentication.k8s.io/v1beta1",
|
||||||
|
},
|
||||||
|
Status: &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||||
|
ExpirationTimestamp: timePtr(now.Add(1 * time.Hour)),
|
||||||
|
Token: "token-one",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantTestFile: func(t *testing.T, tmp string) {
|
||||||
|
cache, err := readCache(tmp)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, cache.Entries, 2)
|
||||||
|
require.Less(t, time.Since(cache.Entries[1].LastUsedTimestamp.Time).Nanoseconds(), (5 * time.Second).Nanoseconds())
|
||||||
|
require.Equal(t, &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||||
|
ExpirationTimestamp: timePtr(now.Add(1 * time.Hour).Local()),
|
||||||
|
Token: "token-one",
|
||||||
|
}, cache.Entries[1].Credential)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "error writing cache",
|
||||||
|
makeTestFile: func(t *testing.T, tmp string) {
|
||||||
|
require.NoError(t, os.MkdirAll(tmp, 0700))
|
||||||
|
},
|
||||||
|
key: testKey{K1: "v1", K2: "v2"},
|
||||||
|
cred: &clientauthenticationv1beta1.ExecCredential{
|
||||||
|
TypeMeta: metav1.TypeMeta{
|
||||||
|
Kind: "ExecCredential",
|
||||||
|
APIVersion: "client.authentication.k8s.io/v1beta1",
|
||||||
|
},
|
||||||
|
Status: &clientauthenticationv1beta1.ExecCredentialStatus{
|
||||||
|
ExpirationTimestamp: timePtr(now.Add(1 * time.Hour)),
|
||||||
|
Token: "token-one",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wantErrors: []string{
|
||||||
|
"failed to read cache, resetting: could not read cache file: read TEMPFILE: is a directory",
|
||||||
|
"could not write cache: open TEMPFILE: is a directory",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tmp := testutil.TempDir(t) + "/cachedir/credentials.yaml"
|
||||||
|
if tt.makeTestFile != nil {
|
||||||
|
tt.makeTestFile(t, tmp)
|
||||||
|
}
|
||||||
|
// Initialize a cache with a reporter that collects errors
|
||||||
|
errors := errorCollector{t: t}
|
||||||
|
c := New(tmp)
|
||||||
|
c.errReporter = errors.report
|
||||||
|
c.Put(tt.key, tt.cred)
|
||||||
|
errors.require(tt.wantErrors, "TEMPFILE", tmp, "TEMPDIR", filepath.Dir(tmp))
|
||||||
|
if tt.wantTestFile != nil {
|
||||||
|
tt.wantTestFile(t, tmp)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHashing(t *testing.T) {
|
||||||
|
type testKey struct{ K1, K2 string }
|
||||||
|
require.Equal(t, "38e0b9de817f645c4bec37c0d4a3e58baecccb040f5718dc069a72c7385a0bed", jsonSHA256Hex(nil))
|
||||||
|
require.Equal(t, "625bb1f93dc90a1bda400fdaceb8c96328e567a0c6aaf81e7fccc68958b4565d", jsonSHA256Hex([]string{"k1", "k2"}))
|
||||||
|
require.Equal(t, "8fb659f5dd266ffd8d0c96116db1d96fe10e3879f9cb6f7e9ace016696ff69f6", jsonSHA256Hex(testKey{K1: "v1", K2: "v2"}))
|
||||||
|
require.Equal(t, "42c783a2c29f91127b064df368bda61788181d2dd1709b417f9506102ea8da67", jsonSHA256Hex(testKey{K1: "v3", K2: "v4"}))
|
||||||
|
require.Panics(t, func() { jsonSHA256Hex(&unmarshalable{}) })
|
||||||
|
}
|
||||||
|
|
||||||
|
type errorCollector struct {
|
||||||
|
t *testing.T
|
||||||
|
saw []error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *errorCollector) report(err error) {
|
||||||
|
e.saw = append(e.saw, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *errorCollector) require(want []string, subs ...string) {
|
||||||
|
require.Len(e.t, e.saw, len(want))
|
||||||
|
for i, w := range want {
|
||||||
|
for i := 0; i < len(subs); i += 2 {
|
||||||
|
w = strings.ReplaceAll(w, subs[i], subs[i+1])
|
||||||
|
}
|
||||||
|
require.EqualError(e.t, e.saw[i], w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func timePtr(from time.Time) *metav1.Time {
|
||||||
|
t := metav1.NewTime(from)
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
type unmarshalable struct{}
|
||||||
|
|
||||||
|
func (*unmarshalable) MarshalJSON() ([]byte, error) { return nil, fmt.Errorf("some MarshalJSON error") }
|
1
internal/execcredcache/testdata/invalid.yaml
vendored
Normal file
1
internal/execcredcache/testdata/invalid.yaml
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
invalid YAML
|
9
internal/execcredcache/testdata/valid.yaml
vendored
Normal file
9
internal/execcredcache/testdata/valid.yaml
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
apiVersion: config.supervisor.pinniped.dev/v1alpha1
|
||||||
|
kind: CredentialCache
|
||||||
|
credentials:
|
||||||
|
- key: "test-key"
|
||||||
|
creationTimestamp: "2020-10-20T18:42:07Z"
|
||||||
|
lastUsedTimestamp: "2020-10-20T18:45:31Z"
|
||||||
|
credential:
|
||||||
|
expirationTimestamp: "2020-10-20T19:46:30Z"
|
||||||
|
token: "test-token"
|
3
internal/execcredcache/testdata/wrong-version.yaml
vendored
Normal file
3
internal/execcredcache/testdata/wrong-version.yaml
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
apiVersion: config.supervisor.pinniped.dev/v2alpha6
|
||||||
|
kind: NotACredentialCache
|
||||||
|
credentials: []
|
Loading…
Reference in New Issue
Block a user