feat(iam): define a KeyRotation for every consumer except poimen-memory
Eight objects cover grafana, argocd, comfyui, minio, paperless, immich, forgejo and api-gw. Each names its ZITADEL app, the Secret it writes, the consumer to roll and whether the consumer reloads hot or needs a restart. All are suspended: the controller image does not exist yet, and an unsuspended object would claim an intent that no process acts on. The README records the retirement target for the 34 .enc.yaml files: six root-of-trust secrets that cannot rotate themselves survive, everything else becomes a KeyRotation and leaves git.
This commit was merged in pull request #84.
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
iamv1 "github.com/riotpiao/key-rotation-controller/api"
|
||||
"github.com/riotpiao/key-rotation-controller/internal/zitadel"
|
||||
)
|
||||
|
||||
// MachineKeyProvider rotates a service user's private key.
|
||||
//
|
||||
// ZITADEL keeps many keys per service user, and each assertion names its key with
|
||||
// `kid`. A new key can be created while the deployed key stays valid, so this
|
||||
// provider retires the old key only after the new one proves itself. Nothing in
|
||||
// the window is invalid.
|
||||
type MachineKeyProvider struct {
|
||||
// Client talks to the instance that owns the service user.
|
||||
Client *zitadel.Client
|
||||
// AuthClientID is the API app the controller authenticates as.
|
||||
AuthClientID string
|
||||
// KeyIDFile and KeyFile name the Secret keys, defaulting to key-id and key.
|
||||
KeyIDFile string
|
||||
KeyFile string
|
||||
}
|
||||
|
||||
// Name implements Provider.
|
||||
func (p *MachineKeyProvider) Name() string { return iamv1.ProviderZitadelMachineKey }
|
||||
|
||||
// Strategy implements Provider. Machine keys overlap.
|
||||
func (p *MachineKeyProvider) Strategy() string { return iamv1.StrategyOverlap }
|
||||
|
||||
// Prepare creates a new key. The currently deployed key remains valid.
|
||||
func (p *MachineKeyProvider) Prepare(ctx context.Context, kr *iamv1.KeyRotation) (*Credential, error) {
|
||||
userID := kr.Spec.Target.MachineUserID
|
||||
if userID == "" {
|
||||
return nil, fmt.Errorf("spec.target.machineUserID is required")
|
||||
}
|
||||
key, err := p.Client.CreateMachineKey(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating a machine key: %w", err)
|
||||
}
|
||||
fields := map[string][]byte{
|
||||
p.keyField(kr): []byte(key.Key),
|
||||
p.keyIDField(kr): []byte(key.KeyID),
|
||||
}
|
||||
return &Credential{ID: key.KeyID, Fields: fields}, nil
|
||||
}
|
||||
|
||||
// Verify exchanges a token with the new key. A key that cannot obtain a token is
|
||||
// not deployed as a working credential, whatever the API said.
|
||||
func (p *MachineKeyProvider) Verify(ctx context.Context, kr *iamv1.KeyRotation, cred *Credential) error {
|
||||
keyPEM := cred.Fields[p.keyField(kr)]
|
||||
keyID := string(cred.Fields[p.keyIDField(kr)])
|
||||
return p.Client.ExchangeMachineKey(
|
||||
p.AuthClientID,
|
||||
kr.Spec.Target.MachineUserID,
|
||||
keyID,
|
||||
keyPEM,
|
||||
kr.Spec.Verify.AudienceProject,
|
||||
)
|
||||
}
|
||||
|
||||
// Retire deletes the superseded key.
|
||||
func (p *MachineKeyProvider) Retire(ctx context.Context, kr *iamv1.KeyRotation, credentialID string) error {
|
||||
return p.Client.DeleteMachineKey(kr.Spec.Target.MachineUserID, credentialID)
|
||||
}
|
||||
|
||||
func (p *MachineKeyProvider) keyField(kr *iamv1.KeyRotation) string {
|
||||
if key, ok := kr.Spec.Target.SecretKeys["key"]; ok && key != "" {
|
||||
return key
|
||||
}
|
||||
return "key"
|
||||
}
|
||||
|
||||
func (p *MachineKeyProvider) keyIDField(kr *iamv1.KeyRotation) string {
|
||||
if key, ok := kr.Spec.Target.SecretKeys["key-id"]; ok && key != "" {
|
||||
return key
|
||||
}
|
||||
return "key-id"
|
||||
}
|
||||
|
||||
// OIDCClientProvider rotates an app's client secret.
|
||||
//
|
||||
// ZITADEL stores exactly one secret per app, so creating the new secret kills the
|
||||
// old one. The strategy is atomic: publish immediately, roll the consumer, verify,
|
||||
// and if verification fails, put the previous value back.
|
||||
type OIDCClientProvider struct {
|
||||
Client *zitadel.Client
|
||||
// ClientIDFunc maps a KeyRotation to the app's client id, which the
|
||||
// introspection endpoint needs.
|
||||
ClientIDFunc func(kr *iamv1.KeyRotation) string
|
||||
// SecretKey names the Secret key that holds the secret.
|
||||
SecretKey string
|
||||
}
|
||||
|
||||
// Name implements Provider.
|
||||
func (p *OIDCClientProvider) Name() string { return iamv1.ProviderZitadelOIDCClient }
|
||||
|
||||
// Strategy implements Provider. Client secrets cannot overlap.
|
||||
func (p *OIDCClientProvider) Strategy() string { return iamv1.StrategyAtomic }
|
||||
|
||||
// Prepare regenerates the client secret.
|
||||
func (p *OIDCClientProvider) Prepare(ctx context.Context, kr *iamv1.KeyRotation) (*Credential, error) {
|
||||
projectID := kr.Spec.Target.ZitadelProjectID
|
||||
appID := kr.Spec.Target.ZitadelAppID
|
||||
if projectID == "" || appID == "" {
|
||||
return nil, fmt.Errorf("spec.target.zitadelProjectID and zitadelAppID are required")
|
||||
}
|
||||
secret, err := p.Client.GenerateClientSecret(projectID, appID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating a client secret: %w", err)
|
||||
}
|
||||
field := p.secretField(kr)
|
||||
// The credential id is the generation marker, not a ZITADEL identifier: the
|
||||
// API returns only the value.
|
||||
return &Credential{
|
||||
ID: "secret-" + shortHash(secret),
|
||||
Fields: map[string][]byte{field: []byte(secret)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Verify checks the credential against the introspection endpoint.
|
||||
func (p *OIDCClientProvider) Verify(ctx context.Context, kr *iamv1.KeyRotation, cred *Credential) error {
|
||||
if !kr.Spec.Verify.Introspect {
|
||||
return nil
|
||||
}
|
||||
clientID := ""
|
||||
if p.ClientIDFunc != nil {
|
||||
clientID = p.ClientIDFunc(kr)
|
||||
}
|
||||
if clientID == "" {
|
||||
return fmt.Errorf("no client id configured for introspection")
|
||||
}
|
||||
return p.Client.Introspect(clientID, string(cred.Fields[p.secretField(kr)]))
|
||||
}
|
||||
|
||||
// Retire is a no-op: an atomic provider has nothing to retire, because the
|
||||
// provider already invalidated the previous value.
|
||||
func (p *OIDCClientProvider) Retire(ctx context.Context, kr *iamv1.KeyRotation, credentialID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *OIDCClientProvider) secretField(kr *iamv1.KeyRotation) string {
|
||||
if p.SecretKey != "" {
|
||||
return p.SecretKey
|
||||
}
|
||||
if key, ok := kr.Spec.Target.SecretKeys["secret"]; ok && key != "" {
|
||||
return key
|
||||
}
|
||||
return "secret"
|
||||
}
|
||||
|
||||
// shortHash gives a stable, non-reversible marker for a secret so status can
|
||||
// record which generation is live without storing the value.
|
||||
func shortHash(value string) string {
|
||||
sum := 0
|
||||
for _, b := range []byte(value) {
|
||||
sum = (sum*31 + int(b)) % 100000007
|
||||
}
|
||||
encoded := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("%d", sum)))
|
||||
return encoded
|
||||
}
|
||||
|
||||
// unusedJSON keeps the import set honest if a provider needs raw JSON later.
|
||||
var _ = json.Marshal
|
||||
@@ -0,0 +1,391 @@
|
||||
// Package reconcile turns a KeyRotation object into a rotated credential.
|
||||
//
|
||||
// The state machine is identical for every provider. Only three calls differ:
|
||||
// create, verify, retire. That is what makes one controller cover machine keys,
|
||||
// client secrets and, later, the token-style providers.
|
||||
//
|
||||
// Order is the safety property:
|
||||
//
|
||||
// Prepare create the new credential
|
||||
// Publish write the Secret, roll the consumer when it cannot reload
|
||||
// Verify prove the new credential works against the provider
|
||||
// Retire delete the old credential, only for overlapping providers
|
||||
//
|
||||
// A failure before Retire leaves the old credential in place, so a broken
|
||||
// rotation never removes the thing that currently works.
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
|
||||
iamv1 "github.com/riotpiao/key-rotation-controller/api"
|
||||
)
|
||||
|
||||
// Credential is fresh credential material, before it is written to a Secret.
|
||||
type Credential struct {
|
||||
// ID identifies the credential at the provider, so it can be retired later.
|
||||
ID string
|
||||
// Fields maps a Secret key to its value.
|
||||
Fields map[string][]byte
|
||||
}
|
||||
|
||||
// Provider creates, verifies and retires one class of credential.
|
||||
type Provider interface {
|
||||
// Name is the provider name from the spec.
|
||||
Name() string
|
||||
// Strategy is overlap or atomic. Atomic providers replace in place.
|
||||
Strategy() string
|
||||
// Prepare creates a new credential at the provider.
|
||||
Prepare(ctx context.Context, kr *iamv1.KeyRotation) (*Credential, error)
|
||||
// Verify proves the credential works, using the provider's own check.
|
||||
Verify(ctx context.Context, kr *iamv1.KeyRotation, cred *Credential) error
|
||||
// Retire deletes a superseded credential. It must be safe to call twice.
|
||||
Retire(ctx context.Context, kr *iamv1.KeyRotation, credentialID string) error
|
||||
}
|
||||
|
||||
// Reconciler rotates credentials for KeyRotation objects.
|
||||
type Reconciler struct {
|
||||
client.Client
|
||||
// Providers keyed by spec.provider.
|
||||
Providers map[string]Provider
|
||||
// Now is injectable so scheduling is testable.
|
||||
Now func() time.Time
|
||||
// RolloutTimeout bounds the wait for a consumer to become available.
|
||||
RolloutTimeout time.Duration
|
||||
}
|
||||
|
||||
// previousKeySuffix keeps the superseded value beside the current one, so an
|
||||
// atomic provider can be rolled back without storing a secret in the object.
|
||||
const previousKeySuffix = ".previous"
|
||||
|
||||
// RequeueAfter is how long to wait before checking a resource again.
|
||||
func (r *Reconciler) RequeueAfter(kr *iamv1.KeyRotation) time.Duration {
|
||||
interval := time.Duration(kr.Spec.IntervalHours) * time.Hour
|
||||
if interval <= 0 {
|
||||
interval = 30 * 24 * time.Hour
|
||||
}
|
||||
if kr.Status.LastRotation == nil || kr.Status.ActiveCredentialID == "" {
|
||||
return 0 // rotate now
|
||||
}
|
||||
due := kr.Status.LastRotation.Add(interval)
|
||||
wait := due.Sub(r.now())
|
||||
if wait < 0 {
|
||||
return 0
|
||||
}
|
||||
return wait
|
||||
}
|
||||
|
||||
// Reconcile performs one rotation when the resource is due.
|
||||
func (r *Reconciler) Reconcile(ctx context.Context, req types.NamespacedName) (time.Duration, error) {
|
||||
logger := log.FromContext(ctx).WithValues("keyrotation", req.String())
|
||||
|
||||
var kr iamv1.KeyRotation
|
||||
if err := r.Get(ctx, req, &kr); err != nil {
|
||||
if apierrors.IsNotFound(err) {
|
||||
return 0, nil
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if kr.Spec.Suspend {
|
||||
return 0, r.setPhase(ctx, &kr, iamv1.PhaseSuspended, "")
|
||||
}
|
||||
|
||||
provider := r.Providers[kr.Spec.Provider]
|
||||
if provider == nil {
|
||||
return 0, r.setPhase(ctx, &kr, iamv1.PhaseFailed,
|
||||
fmt.Sprintf("no provider named %q", kr.Spec.Provider))
|
||||
}
|
||||
|
||||
wait := r.RequeueAfter(&kr)
|
||||
if wait > 0 {
|
||||
return wait, nil
|
||||
}
|
||||
|
||||
logger.Info("rotating credential", "provider", kr.Spec.Provider, "strategy", kr.Spec.Strategy)
|
||||
|
||||
previousID := kr.Status.ActiveCredentialID
|
||||
previousFields := r.readCurrentSecret(ctx, &kr)
|
||||
|
||||
// Prepare
|
||||
if err := r.setPhase(ctx, &kr, iamv1.PhasePreparing, ""); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
cred, err := provider.Prepare(ctx, &kr)
|
||||
if err != nil {
|
||||
return r.fail(ctx, &kr, iamv1.PhasePreparing, err)
|
||||
}
|
||||
|
||||
// Publish. The previous value stays beside the new one until verification
|
||||
// passes, so an atomic rotation can be undone.
|
||||
if err := r.setPhase(ctx, &kr, iamv1.PhasePropagating, ""); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := r.writeSecret(ctx, &kr, cred, previousFields); err != nil {
|
||||
return r.fail(ctx, &kr, iamv1.PhasePropagating, err)
|
||||
}
|
||||
if kr.Spec.Target.Reload != iamv1.ReloadHot {
|
||||
if err := r.rollConsumers(ctx, &kr, cred.ID); err != nil {
|
||||
return r.fail(ctx, &kr, iamv1.PhasePropagating, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify
|
||||
if err := r.setPhase(ctx, &kr, iamv1.PhaseVerifying, ""); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := provider.Verify(ctx, &kr, cred); err != nil {
|
||||
// Put the previous value back and roll again, then report the failure.
|
||||
r.restorePrevious(ctx, &kr, previousFields)
|
||||
return r.fail(ctx, &kr, iamv1.PhaseVerifying, err)
|
||||
}
|
||||
|
||||
// Complete. Retire only when the provider can overlap, because retiring an
|
||||
// atomic credential would delete the one now in use.
|
||||
if err := r.setPhase(ctx, &kr, iamv1.PhaseCompleting, ""); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if provider.Strategy() == iamv1.StrategyOverlap && previousID != "" && previousID != cred.ID {
|
||||
if err := provider.Retire(ctx, &kr, previousID); err != nil {
|
||||
logger.Error(err, "retiring the previous credential failed; it stays valid", "credential", previousID)
|
||||
}
|
||||
}
|
||||
r.clearPrevious(ctx, &kr)
|
||||
|
||||
now := metav1.NewTime(r.now())
|
||||
kr.Status.LastRotation = &now
|
||||
kr.Status.ActiveCredentialID = cred.ID
|
||||
kr.Status.PreviousCredentialID = previousID
|
||||
kr.Status.LastError = ""
|
||||
kr.Status.ObservedGeneration = kr.Generation
|
||||
if err := r.Status().Update(ctx, &kr); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := r.setPhase(ctx, &kr, iamv1.PhaseSucceeded, ""); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
logger.Info("rotation complete", "credential", cred.ID, "previous", previousID)
|
||||
|
||||
return r.RequeueAfter(&kr), nil
|
||||
}
|
||||
|
||||
func (r *Reconciler) now() time.Time {
|
||||
if r.Now != nil {
|
||||
return r.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
func (r *Reconciler) setPhase(ctx context.Context, kr *iamv1.KeyRotation, phase, message string) error {
|
||||
kr.Status.Phase = phase
|
||||
kr.Status.LastError = message
|
||||
kr.Status.ObservedGeneration = kr.Generation
|
||||
if err := r.Status().Update(ctx, kr); err != nil && !apierrors.IsConflict(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reconciler) fail(ctx context.Context, kr *iamv1.KeyRotation, phase string, cause error) (time.Duration, error) {
|
||||
kr.Status.Phase = iamv1.PhaseFailed
|
||||
kr.Status.LastError = fmt.Sprintf("%s: %v", phase, cause)
|
||||
_ = r.Status().Update(ctx, kr)
|
||||
// Retry soon, but not in a tight loop.
|
||||
return 5 * time.Minute, nil
|
||||
}
|
||||
|
||||
// secretData assembles the Secret payload: the new values, plus the superseded
|
||||
// values under a shadow key so an atomic rotation can be undone.
|
||||
func secretData(kr *iamv1.KeyRotation, cred *Credential, previous map[string][]byte) map[string][]byte {
|
||||
data := make(map[string][]byte, len(cred.Fields)*2)
|
||||
for logical, value := range cred.Fields {
|
||||
key := secretKeyFor(kr, logical)
|
||||
data[key] = value
|
||||
if old, ok := previous[key]; ok && len(old) > 0 {
|
||||
data[key+previousKeySuffix] = old
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// secretKeyFor maps a logical field name to the Secret key, defaulting to the
|
||||
// same name so a simple credential needs no mapping.
|
||||
func secretKeyFor(kr *iamv1.KeyRotation, logical string) string {
|
||||
if key, ok := kr.Spec.Target.SecretKeys[logical]; ok && key != "" {
|
||||
return key
|
||||
}
|
||||
return logical
|
||||
}
|
||||
|
||||
func (r *Reconciler) readCurrentSecret(ctx context.Context, kr *iamv1.KeyRotation) map[string][]byte {
|
||||
var secret corev1.Secret
|
||||
err := r.Get(ctx, types.NamespacedName{
|
||||
Namespace: kr.Spec.Target.SecretNamespace,
|
||||
Name: kr.Spec.Target.SecretName,
|
||||
}, &secret)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return secret.Data
|
||||
}
|
||||
|
||||
func (r *Reconciler) writeSecret(ctx context.Context, kr *iamv1.KeyRotation, cred *Credential, previous map[string][]byte) error {
|
||||
data := secretData(kr, cred, previous)
|
||||
|
||||
var secret corev1.Secret
|
||||
key := types.NamespacedName{Namespace: kr.Spec.Target.SecretNamespace, Name: kr.Spec.Target.SecretName}
|
||||
err := r.Get(ctx, key, &secret)
|
||||
if apierrors.IsNotFound(err) {
|
||||
secret = corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: key.Name,
|
||||
Namespace: key.Namespace,
|
||||
Labels: map[string]string{"app.kubernetes.io/managed-by": "key-rotation-controller"},
|
||||
},
|
||||
Type: corev1.SecretTypeOpaque,
|
||||
Data: data,
|
||||
}
|
||||
return r.Create(ctx, &secret)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
secret.Data = data
|
||||
return r.Update(ctx, &secret)
|
||||
}
|
||||
|
||||
// restorePrevious puts the shadow values back in place after a failed check.
|
||||
func (r *Reconciler) restorePrevious(ctx context.Context, kr *iamv1.KeyRotation, previous map[string][]byte) {
|
||||
if len(previous) == 0 {
|
||||
return
|
||||
}
|
||||
var secret corev1.Secret
|
||||
key := types.NamespacedName{Namespace: kr.Spec.Target.SecretNamespace, Name: kr.Spec.Target.SecretName}
|
||||
if err := r.Get(ctx, key, &secret); err != nil {
|
||||
return
|
||||
}
|
||||
// Restore every shadowed value over the current one.
|
||||
for k, v := range secret.Data {
|
||||
base := k
|
||||
if len(k) > len(previousKeySuffix) && k[len(k)-len(previousKeySuffix):] == previousKeySuffix {
|
||||
base = k[:len(k)-len(previousKeySuffix)]
|
||||
secret.Data[base] = v
|
||||
}
|
||||
}
|
||||
_ = r.Update(ctx, &secret)
|
||||
_ = r.rollConsumers(ctx, kr, "rollback")
|
||||
}
|
||||
|
||||
// clearPrevious removes the shadow values once verification passed.
|
||||
func (r *Reconciler) clearPrevious(ctx context.Context, kr *iamv1.KeyRotation) {
|
||||
var secret corev1.Secret
|
||||
key := types.NamespacedName{Namespace: kr.Spec.Target.SecretNamespace, Name: kr.Spec.Target.SecretName}
|
||||
if err := r.Get(ctx, key, &secret); err != nil {
|
||||
return
|
||||
}
|
||||
changed := false
|
||||
for k := range secret.Data {
|
||||
if len(k) > len(previousKeySuffix) && k[len(k)-len(previousKeySuffix):] == previousKeySuffix {
|
||||
delete(secret.Data, k)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
_ = r.Update(ctx, &secret)
|
||||
}
|
||||
}
|
||||
|
||||
// rollConsumers restarts each consumer so it reads the new value.
|
||||
func (r *Reconciler) rollConsumers(ctx context.Context, kr *iamv1.KeyRotation, revision string) error {
|
||||
for _, consumer := range kr.Spec.Target.Consumers {
|
||||
ns := consumer.Namespace
|
||||
if ns == "" {
|
||||
ns = kr.Namespace
|
||||
}
|
||||
switch consumer.Kind {
|
||||
case "Deployment":
|
||||
var dep appsv1.Deployment
|
||||
if err := r.Get(ctx, types.NamespacedName{Namespace: ns, Name: consumer.Name}, &dep); err != nil {
|
||||
return fmt.Errorf("consumer %s/%s: %w", ns, consumer.Name, err)
|
||||
}
|
||||
base := dep.DeepCopy()
|
||||
if dep.Spec.Template.Annotations == nil {
|
||||
dep.Spec.Template.Annotations = map[string]string{}
|
||||
}
|
||||
dep.Spec.Template.Annotations["iam.homelab.riotpiao.com/credential-revision"] = revision
|
||||
if err := r.Patch(ctx, &dep, client.MergeFrom(base)); err != nil {
|
||||
return fmt.Errorf("rolling %s/%s: %w", ns, consumer.Name, err)
|
||||
}
|
||||
case "StatefulSet":
|
||||
var sts appsv1.StatefulSet
|
||||
if err := r.Get(ctx, types.NamespacedName{Namespace: ns, Name: consumer.Name}, &sts); err != nil {
|
||||
return fmt.Errorf("consumer %s/%s: %w", ns, consumer.Name, err)
|
||||
}
|
||||
base := sts.DeepCopy()
|
||||
if sts.Spec.Template.Annotations == nil {
|
||||
sts.Spec.Template.Annotations = map[string]string{}
|
||||
}
|
||||
sts.Spec.Template.Annotations["iam.homelab.riotpiao.com/credential-revision"] = revision
|
||||
if err := r.Patch(ctx, &sts, client.MergeFrom(base)); err != nil {
|
||||
return fmt.Errorf("rolling %s/%s: %w", ns, consumer.Name, err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("consumer kind %q is not supported", consumer.Kind)
|
||||
}
|
||||
}
|
||||
return r.waitForConsumers(ctx, kr)
|
||||
}
|
||||
|
||||
// waitForConsumers blocks until every consumer reports all replicas available.
|
||||
func (r *Reconciler) waitForConsumers(ctx context.Context, kr *iamv1.KeyRotation) error {
|
||||
deadline := r.now().Add(r.rolloutTimeout())
|
||||
for {
|
||||
ready := true
|
||||
for _, consumer := range kr.Spec.Target.Consumers {
|
||||
ns := consumer.Namespace
|
||||
if ns == "" {
|
||||
ns = kr.Namespace
|
||||
}
|
||||
var dep appsv1.Deployment
|
||||
if err := r.Get(ctx, types.NamespacedName{Namespace: ns, Name: consumer.Name}, &dep); err != nil {
|
||||
continue
|
||||
}
|
||||
desired := int32(1)
|
||||
if dep.Spec.Replicas != nil {
|
||||
desired = *dep.Spec.Replicas
|
||||
}
|
||||
if dep.Status.UpdatedReplicas < desired || dep.Status.AvailableReplicas < desired {
|
||||
ready = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ready {
|
||||
return nil
|
||||
}
|
||||
if r.now().After(deadline) {
|
||||
return fmt.Errorf("consumer did not become available within %s", r.rolloutTimeout())
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reconciler) rolloutTimeout() time.Duration {
|
||||
if r.RolloutTimeout > 0 {
|
||||
return r.RolloutTimeout
|
||||
}
|
||||
return 3 * time.Minute
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package reconcile
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
|
||||
iamv1 "github.com/riotpiao/key-rotation-controller/api"
|
||||
)
|
||||
|
||||
func rotation(last *metav1.Time, activeID string, hours int32) *iamv1.KeyRotation {
|
||||
return &iamv1.KeyRotation{
|
||||
Spec: iamv1.KeyRotationSpec{
|
||||
Provider: iamv1.ProviderZitadelMachineKey,
|
||||
Strategy: iamv1.StrategyOverlap,
|
||||
IntervalHours: hours,
|
||||
},
|
||||
Status: iamv1.KeyRotationStatus{
|
||||
LastRotation: last,
|
||||
ActiveCredentialID: activeID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequeueAfterRotatesImmediatelyWhenNeverRotated(t *testing.T) {
|
||||
r := &Reconciler{Now: func() time.Time { return time.Unix(1_700_000_000, 0) }}
|
||||
if got := r.RequeueAfter(rotation(nil, "", 24)); got != 0 {
|
||||
t.Fatalf("a resource with no rotation history must rotate now, got %s", got)
|
||||
}
|
||||
if got := r.RequeueAfter(rotation(nil, "key-1", 24)); got != 0 {
|
||||
t.Fatalf("a resource with no lastRotation must rotate now, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequeueAfterWaitsForTheInterval(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
last := metav1.NewTime(now.Add(-6 * time.Hour))
|
||||
r := &Reconciler{Now: func() time.Time { return now }}
|
||||
|
||||
got := r.RequeueAfter(rotation(&last, "key-1", 24))
|
||||
want := 18 * time.Hour
|
||||
if got != want {
|
||||
t.Fatalf("expected %s until the next rotation, got %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequeueAfterRotatesWhenOverdue(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
last := metav1.NewTime(now.Add(-30 * time.Hour))
|
||||
r := &Reconciler{Now: func() time.Time { return now }}
|
||||
|
||||
if got := r.RequeueAfter(rotation(&last, "key-1", 24)); got != 0 {
|
||||
t.Fatalf("an overdue resource must rotate now, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequeueAfterFallsBackWhenNoInterval(t *testing.T) {
|
||||
now := time.Unix(1_700_000_000, 0)
|
||||
last := metav1.NewTime(now)
|
||||
r := &Reconciler{Now: func() time.Time { return now }}
|
||||
|
||||
got := r.RequeueAfter(rotation(&last, "key-1", 0))
|
||||
if got <= 0 {
|
||||
t.Fatalf("a zero interval must fall back to a default, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretDataKeepsThePreviousValueUnderAShadowKey(t *testing.T) {
|
||||
kr := &iamv1.KeyRotation{Spec: iamv1.KeyRotationSpec{
|
||||
Target: iamv1.RotationTarget{
|
||||
SecretKeys: map[string]string{"key": "jwt-key", "key-id": "jwt-key-id"},
|
||||
},
|
||||
}}
|
||||
cred := &Credential{ID: "k2", Fields: map[string][]byte{
|
||||
"key": []byte("PEM-2"),
|
||||
"key-id": []byte("k2"),
|
||||
}}
|
||||
previous := map[string][]byte{
|
||||
"jwt-key": []byte("PEM-1"),
|
||||
"jwt-key-id": []byte("k1"),
|
||||
}
|
||||
|
||||
data := secretData(kr, cred, previous)
|
||||
|
||||
if string(data["jwt-key"]) != "PEM-2" {
|
||||
t.Fatalf("current value missing, got %q", data["jwt-key"])
|
||||
}
|
||||
if string(data["jwt-key"+previousKeySuffix]) != "PEM-1" {
|
||||
t.Fatalf("previous value must be kept for rollback, got %q", data["jwt-key"+previousKeySuffix])
|
||||
}
|
||||
if string(data["jwt-key-id"+previousKeySuffix]) != "k1" {
|
||||
t.Fatalf("previous key id must be kept for rollback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretDataDefaultsToLogicalNames(t *testing.T) {
|
||||
kr := &iamv1.KeyRotation{}
|
||||
cred := &Credential{ID: "k1", Fields: map[string][]byte{"secret": []byte("s3cret")}}
|
||||
|
||||
data := secretData(kr, cred, nil)
|
||||
|
||||
if string(data["secret"]) != "s3cret" {
|
||||
t.Fatalf("an unmapped field must keep its logical name, got %v", data)
|
||||
}
|
||||
if _, shadow := data["secret"+previousKeySuffix]; shadow {
|
||||
t.Fatalf("no previous value existed, so no shadow key may be written")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretKeyForPrefersTheMapping(t *testing.T) {
|
||||
kr := &iamv1.KeyRotation{Spec: iamv1.KeyRotationSpec{
|
||||
Target: iamv1.RotationTarget{
|
||||
SecretKeys: map[string]string{"secret": "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET"},
|
||||
},
|
||||
}}
|
||||
if got := secretKeyFor(kr, "secret"); got != "GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET" {
|
||||
t.Fatalf("mapping not honoured, got %q", got)
|
||||
}
|
||||
if got := secretKeyFor(kr, "key-id"); got != "key-id" {
|
||||
t.Fatalf("unmapped field must default to its own name, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMachineKeyProviderFieldsRespectTheMapping(t *testing.T) {
|
||||
p := &MachineKeyProvider{}
|
||||
kr := &iamv1.KeyRotation{Spec: iamv1.KeyRotationSpec{
|
||||
Target: iamv1.RotationTarget{
|
||||
SecretKeys: map[string]string{"key": "private-key", "key-id": "kid"},
|
||||
},
|
||||
}}
|
||||
if p.keyField(kr) != "private-key" || p.keyIDField(kr) != "kid" {
|
||||
t.Fatalf("provider must read the mapped Secret keys")
|
||||
}
|
||||
if p.Strategy() != iamv1.StrategyOverlap {
|
||||
t.Fatalf("machine keys must be an overlapping provider")
|
||||
}
|
||||
if (&OIDCClientProvider{}).Strategy() != iamv1.StrategyAtomic {
|
||||
t.Fatalf("client secrets cannot overlap and must be atomic")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortHashIsStableAndDoesNotLeakTheValue(t *testing.T) {
|
||||
a := shortHash("super-secret-value")
|
||||
b := shortHash("super-secret-value")
|
||||
if a != b {
|
||||
t.Fatalf("the marker must be stable for the same value")
|
||||
}
|
||||
if a == "super-secret-value" {
|
||||
t.Fatalf("the marker must not be the value itself")
|
||||
}
|
||||
if shortHash("other") == a {
|
||||
t.Fatalf("different values must produce different markers")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user