Skip to content

Add Redis / Valkey storage backend with optional Sentinel - #12

Merged
trevor-vaughan merged 8 commits into
voxpupuli:mainfrom
bootc:valkey-backend
May 7, 2026
Merged

Add Redis / Valkey storage backend with optional Sentinel#12
trevor-vaughan merged 8 commits into
voxpupuli:mainfrom
bootc:valkey-backend

Conversation

@bootc

@bootc bootc commented Apr 18, 2026

Copy link
Copy Markdown
Member

Builds on #11 (distributed locking). Adds Redis (and wire-compatible Valkey) as a third storage backend, sharing the cross-node coordination story with etcd.

Summary

  • Redis backend (internal/storage/redis.go): stores CA cert/key, CRL, serial, inventory (and its HMAC), CSRs, and signed certs in Redis/Valkey. Keys use the same 8-byte big-endian unix-nano mtime prefix as etcd so ModTime is served without a second round-trip, preserving If-Modified-Since on GET /crl. List uses SCAN iteration, not KEYS.
  • Atomic inventory appends. AppendLine runs a server-side Lua script that reads the current value, strips the mtime prefix, appends the new line, and rewrites with a fresh mtime in one atomic step — no WATCH/MULTI/EXEC ping-pong needed.
  • Distributed Locker. Implemented with SET NX PX + a per-acquisition random token + a background heartbeat that extends the TTL while the holder is alive + a token-matching Lua unlock so a stale unlock after TTL expiry can't release another holder's lock. Because SET NX is not re-entrant from one client, every distributed lock is wrapped in a per-name process-local sync.Mutex first — same pattern the etcd Locker uses for concurrency.Mutex session re-entry.
  • Connectivity. Direct connection via redis.NewClient or HA failover via redis.NewFailoverClient when a Sentinel master name and sentinel addresses are configured. Both are driven through redis.UniversalClient, so the backend code path is single.
  • Configuration surface. New --storage-backend=redis (alias valkey), --redis-addrs, --redis-sentinel-master-name, --redis-sentinel-addrs, --redis-key-prefix CLI flags; matching redis_* YAML config keys; full set of PUPPET_CA_REDIS_* env vars (addrs, sentinel, auth, DB, key prefix, dial/request timeouts, lock TTL, TLS CA/cert/key files).
  • Docs. docs/storage-backends.md gains a full Redis/Valkey section: key layout, coordination semantics, direct and Sentinel configuration examples, and an updated three-column backend comparison table with a row on cross-node lock guarantees.

Notes

  • Sentinel replication is asynchronous, so cross-node lock guarantees narrow briefly during a master failover — operators who need strict linearizability should stick with the etcd backend. This caveat is called out in the docs.
  • No behavioural change for existing deployments. Defaults are unchanged; Redis is only activated when --storage-backend=redis (or =valkey) is set.

bootc and others added 6 commits April 17, 2026 19:03
Abstract the storage layer behind a Backend interface with a logical
key namespace (KeyCACert, KeyCAKey, KeyCRL, KeySerial, KeyInventory,
KeyInventoryHMAC, KeyHMACKey, KeyCAPubKey, and csr/cert prefixes).
Add FilesystemBackend implementing the interface, preserving the
existing Puppet CA layout and atomic-write / HMAC semantics.

StorageService becomes a facade over Backend, exposing content-oriented
methods (GetCACert/SaveCACert/HasCACert, GetCSR/SaveCSR/..., etc.)
while keeping legacy *Path() methods for callers that still need
filesystem paths via an optional PathProvider interface. Per-subject
generated private keys continue to live on local disk through a
separate localPrivateKeyDir, independent of the configured backend.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace direct os.ReadFile/os.WriteFile/os.Stat calls against
Storage.*Path() with the new content-oriented methods
(GetCACert/SaveCACert/HasCACert, GetCAKey/SaveCAKey, GetSerial,
TouchInventory, CRLModTime, etc.) so callers no longer assume a
filesystem-backed store.

The *Path() accessors are still used for log messages and for
resolving the optional CA-key passphrase path, which remain
filesystem-scoped. Tests in the storage package retain a handful of
filesystem-specific assertions (permissions, mtime) that validate the
FilesystemBackend directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implement a Backend that stores CA cert/key, CRL, serial, inventory
(and its HMAC), CSRs, and signed certificates in an etcd v3 cluster.
Per-subject generated private keys continue to live on local disk
regardless of the configured backend.

Values are wrapped with an 8-byte big-endian unix-nano mtime prefix so
ModTime is served from the same key without a second round-trip,
keeping If-Modified-Since semantics on GET /crl working. AppendLine
uses an etcd Txn guarded on the key's ModRevision with bounded retry,
so inventory appends stay atomic across multiple puppet-ca processes
sharing a cluster.

A BackendSpec + NewServiceFromSpec helper selects the backend at
startup; puppet-ca (frontend, signer, and single-process modes)
builds the spec from new storage_backend / etcd_* config fields and
matching PUPPET_CA_* env vars.

Unit tests for the pure helpers (key translation, blob encoding) run
by default. An etcd_integration build tag gates round-trip tests that
spin up an in-process embedded etcd, including concurrent cross-client
AppendLine to prove the Txn-based append holds under contention.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Introduce an OverlayBackend that wraps any Backend and redirects a fixed
set of logical keys to explicit local filesystem paths. Surface it
through BackendSpec as CACertFile / CAKeyFile, wired up via new
ca_cert_file / ca_key_file config keys, matching PUPPET_CA_CA_*_FILE
env vars, and --ca-cert-file / --ca-key-file CLI flags. Combined with
the etcd backend this lets operators keep the CA material on a mounted
secret volume while CSRs, signed certs, CRL, inventory and serial all
live in the shared cluster.

Also add --storage-backend, --etcd-endpoints, --etcd-key-prefix CLI
flags so the backend choice doesn't require a config file, and
document everything in docs/storage-backends.md (key layout, config
examples for both backends, security considerations for the CA key in
etcd, and pointers for adding further backends).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The key-isolation frontend read the CA cert with
os.ReadFile(filepath.Join(absCADir, "ca_crt.pem")), bypassing the storage
service and the overlay backend. When the CA cert is mounted via an overlay
(e.g. a Kubernetes secret at /run/secrets/puppet-ca-ca/tls.crt), no file
exists under absCADir and the frontend failed with ENOENT immediately after
a successful PSK handshake with the signer.

Defer the handshake and cert read until after the storage service is
constructed, then read via store.GetCACert so the overlay is honoured.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Before this change, the etcd backend only had cross-node atomicity for
inventory AppendLine (via a ModRevision-guarded Txn). Everything else
that did a read-modify-write — CRL rotation during Revoke, CSR submission
with autosign, and first-run bootstrap — was protected only by a
process-local sync.RWMutex, so two replicas could race: losing a
revocation when both read the same CRL and wrote their own, issuing two
certs for the same subject, or generating two different CAs from a
simultaneous bootstrap.

Introduce an optional Locker capability on Backend, implemented on the
etcd backend via concurrency.Mutex over a lease-backed session, and
consumed through a new StorageService.WithLock helper that falls back to
a process-local named mutex when the backend does not (or cannot) provide
one. The filesystem backend keeps its single-process semantics
unchanged; OverlayBackend delegates to its base so CA-cert/key overrides
still participate in cluster-wide coordination.

Wire the CA layer through WithLock:
  - bootstrap: Init loads without a lock on the fast path; on the slow
    path it acquires a bootstrap lock, re-checks, and either loads the
    winner's CA or generates a new one.
  - crl: Revoke serialises the read-modify-write of the CRL so
    concurrent revocations from different replicas don't clobber each
    other.
  - subject:<name>: Sign, SignWithTTL, and SaveRequest serialise on a
    per-subject lock so two replicas can't both pass eviction and
    produce duplicate certificates for the same identity.

concurrency.Mutex is not safe for re-entry by multiple goroutines
sharing one session, so the etcd Locker wraps every distributed lock in
a process-local per-name mutex first; unit tests cover the serialisation
invariants under both local fallback and cross-session contention.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
bootc and others added 2 commits April 19, 2026 21:52
Init's fast path runs finishLoadExisting() whenever loadCA() succeeds, which
it does even when cert+key are served from a local-file overlay against a
freshly-provisioned remote backend. In that setup the CRL, inventory, and
serial counter live in the backend and don't exist yet, so loadCRLCache()
failed with fs.ErrNotExist and startup aborted — bootstrapCA() never ran
because loadCA() had already succeeded.

Detect the missing CRL in finishLoadExisting(), take the bootstrap lock,
re-check, and then generate an empty CRL signed by the loaded CA key,
TouchInventory, and WriteSerial if absent. Skip seeding in frontend-only
mode (ExternalSigner != nil) since the signer process owns bootstrapping.

Covered by two new specs in internal/ca/ca_test.go: one asserts the
supporting state is created on first Init with an overlay-style cert/key,
the other asserts Init is idempotent across repeated calls.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds a third storage backend alongside filesystem and etcd, backed by a
Redis- (or Valkey-) compatible server. The backend targets clusters that
already run Redis/Valkey as HA infrastructure and want to reuse it for CA
state rather than stand up etcd.

Connectivity supports both direct (redis_addrs) and Sentinel-managed
failover (redis_sentinel_master_name + redis_sentinel_addrs) via
go-redis's FailoverClient, with ACL auth and TLS for both the primary and
the Sentinels.

Storage layout mirrors the etcd backend's shape: values carry an 8-byte
big-endian UnixNano mtime prefix so ModTime is answered from the same
round-trip as the value; atomic cross-replica inventory appends are
performed by a server-side Lua script that reads, strips the old mtime,
appends, and writes back in one step.

Distributed locks — surfaced through the existing Locker capability and
StorageService.WithLock — use the standard Redis recipe: SET NX PX with a
per-acquisition random token, a background heartbeat that extends the
TTL via a token-checking Lua script, and an Unlock that runs the
token-matching delete script so a stale caller cannot release a lock
another holder has since acquired. A per-name process-local mutex wraps
the distributed lock the same way the etcd backend wraps concurrency.Mutex,
because SET NX is not re-entrant from a single client either.

Under Sentinel the replication is asynchronous, so an in-flight failover
can narrow the lock guarantee; this is documented with a pointer to the
etcd backend for operators needing strict linearizability.

Tests:
  - 10 unit tests against in-process miniredis (CRUD, ModTime, List,
    concurrent AppendLine across two backends, end-to-end via
    StorageService, cross-replica lock mutual exclusion / serialisation /
    distinct-names / cross-backend, and stale-token-safe Unlock after a
    fast-forwarded TTL expiry).
  - An opt-in integration suite behind -tags=redis_integration driven by
    PUPPET_CA_TEST_REDIS_ADDR that exercises the same behaviours against a
    real Redis / Valkey.

Config is exposed via YAML, PUPPET_CA_REDIS_* env vars, and CLI flags
(--storage-backend redis|valkey, --redis-addrs, --redis-sentinel-*,
--redis-key-prefix). docs/storage-backends.md gains a full section with
the key layout, coordination semantics, direct and Sentinel
configurations, and an updated backend comparison table.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@trevor-vaughan
trevor-vaughan merged commit 3706b62 into voxpupuli:main May 7, 2026
7 checks passed
@bootc
bootc deleted the valkey-backend branch May 9, 2026 07:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants