// 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)) } }