ContainerImage.Pinniped/internal/execcredcache/execcredcache.go

160 lines
4.7 KiB
Go

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