Skip to content

Commit 24ce794

Browse files
bootcclaude
andcommitted
Add Redis / Valkey storage backend with optional Sentinel
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>
1 parent 7f4a761 commit 24ce794

9 files changed

Lines changed: 1736 additions & 35 deletions

File tree

cmd/puppet-ca/config.go

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,10 @@ type serverConfig struct {
7777

7878
// Storage backend selection. "filesystem" (default) stores all CA data
7979
// under CADir; "etcd" keeps CA cert, key, CRL, inventory, serial, CSRs
80-
// and signed certs in an etcd cluster (per-subject generated private
81-
// keys always remain on local disk under CADir).
80+
// and signed certs in an etcd cluster; "redis" (alias "valkey") keeps
81+
// the same state in a Redis/Valkey instance or Sentinel-managed primary
82+
// (per-subject generated private keys always remain on local disk under
83+
// CADir, regardless of backend).
8284
StorageBackend string `yaml:"storage_backend"`
8385
EtcdEndpoints []string `yaml:"etcd_endpoints"`
8486
EtcdKeyPrefix string `yaml:"etcd_key_prefix"`
@@ -90,6 +92,25 @@ type serverConfig struct {
9092
EtcdTLSCertFile string `yaml:"etcd_tls_cert_file"`
9193
EtcdTLSKeyFile string `yaml:"etcd_tls_key_file"`
9294

95+
// Redis/Valkey backend. RedisAddrs is used in direct mode; when
96+
// RedisSentinelMasterName is set, the client resolves the primary via
97+
// RedisSentinelAddrs and follows failovers automatically.
98+
RedisAddrs []string `yaml:"redis_addrs"`
99+
RedisSentinelMasterName string `yaml:"redis_sentinel_master_name"`
100+
RedisSentinelAddrs []string `yaml:"redis_sentinel_addrs"`
101+
RedisSentinelUsername string `yaml:"redis_sentinel_username"`
102+
RedisSentinelPassword string `yaml:"redis_sentinel_password"`
103+
RedisDB int `yaml:"redis_db"`
104+
RedisUsername string `yaml:"redis_username"`
105+
RedisPassword string `yaml:"redis_password"`
106+
RedisKeyPrefix string `yaml:"redis_key_prefix"`
107+
RedisDialTimeoutSec int `yaml:"redis_dial_timeout_sec"`
108+
RedisRequestTimeoutSec int `yaml:"redis_request_timeout_sec"`
109+
RedisLockTTLSec int `yaml:"redis_lock_ttl_sec"`
110+
RedisTLSCAFile string `yaml:"redis_tls_ca_file"`
111+
RedisTLSCertFile string `yaml:"redis_tls_cert_file"`
112+
RedisTLSKeyFile string `yaml:"redis_tls_key_file"`
113+
93114
// Local-file overrides. When set, the named asset is read/written via
94115
// this filesystem path regardless of the selected backend. Typical use:
95116
// keep the CA cert and/or key on local disk (or a mounted secret volume)
@@ -280,6 +301,59 @@ func applyServerEnv(cfg *serverConfig) {
280301
if v := os.Getenv("PUPPET_CA_ETCD_TLS_KEY_FILE"); v != "" {
281302
cfg.EtcdTLSKeyFile = v
282303
}
304+
if v := os.Getenv("PUPPET_CA_REDIS_ADDRS"); v != "" {
305+
cfg.RedisAddrs = splitAndTrim(v, ",")
306+
}
307+
if v := os.Getenv("PUPPET_CA_REDIS_SENTINEL_MASTER_NAME"); v != "" {
308+
cfg.RedisSentinelMasterName = v
309+
}
310+
if v := os.Getenv("PUPPET_CA_REDIS_SENTINEL_ADDRS"); v != "" {
311+
cfg.RedisSentinelAddrs = splitAndTrim(v, ",")
312+
}
313+
if v := os.Getenv("PUPPET_CA_REDIS_SENTINEL_USERNAME"); v != "" {
314+
cfg.RedisSentinelUsername = v
315+
}
316+
if v := os.Getenv("PUPPET_CA_REDIS_SENTINEL_PASSWORD"); v != "" {
317+
cfg.RedisSentinelPassword = v
318+
}
319+
if v := os.Getenv("PUPPET_CA_REDIS_DB"); v != "" {
320+
if n, err := strconv.Atoi(v); err == nil && n >= 0 {
321+
cfg.RedisDB = n
322+
}
323+
}
324+
if v := os.Getenv("PUPPET_CA_REDIS_USERNAME"); v != "" {
325+
cfg.RedisUsername = v
326+
}
327+
if v := os.Getenv("PUPPET_CA_REDIS_PASSWORD"); v != "" {
328+
cfg.RedisPassword = v
329+
}
330+
if v := os.Getenv("PUPPET_CA_REDIS_KEY_PREFIX"); v != "" {
331+
cfg.RedisKeyPrefix = v
332+
}
333+
if v := os.Getenv("PUPPET_CA_REDIS_DIAL_TIMEOUT_SEC"); v != "" {
334+
if n, err := strconv.Atoi(v); err == nil && n > 0 {
335+
cfg.RedisDialTimeoutSec = n
336+
}
337+
}
338+
if v := os.Getenv("PUPPET_CA_REDIS_REQUEST_TIMEOUT_SEC"); v != "" {
339+
if n, err := strconv.Atoi(v); err == nil && n > 0 {
340+
cfg.RedisRequestTimeoutSec = n
341+
}
342+
}
343+
if v := os.Getenv("PUPPET_CA_REDIS_LOCK_TTL_SEC"); v != "" {
344+
if n, err := strconv.Atoi(v); err == nil && n > 0 {
345+
cfg.RedisLockTTLSec = n
346+
}
347+
}
348+
if v := os.Getenv("PUPPET_CA_REDIS_TLS_CA_FILE"); v != "" {
349+
cfg.RedisTLSCAFile = v
350+
}
351+
if v := os.Getenv("PUPPET_CA_REDIS_TLS_CERT_FILE"); v != "" {
352+
cfg.RedisTLSCertFile = v
353+
}
354+
if v := os.Getenv("PUPPET_CA_REDIS_TLS_KEY_FILE"); v != "" {
355+
cfg.RedisTLSKeyFile = v
356+
}
283357
if v := os.Getenv("PUPPET_CA_CA_CERT_FILE"); v != "" {
284358
cfg.CACertFile = v
285359
}

cmd/puppet-ca/main.go

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,25 @@ func buildBackendSpec(cfg *serverConfig, absCADir string) (storage.BackendSpec,
9696
TLSKeyFile: cfg.EtcdTLSKeyFile,
9797
}
9898
}
99+
if kind == storage.BackendRedis {
100+
spec.Redis = storage.RedisSpec{
101+
Addrs: cfg.RedisAddrs,
102+
SentinelMasterName: cfg.RedisSentinelMasterName,
103+
SentinelAddrs: cfg.RedisSentinelAddrs,
104+
SentinelUsername: cfg.RedisSentinelUsername,
105+
SentinelPassword: cfg.RedisSentinelPassword,
106+
DB: cfg.RedisDB,
107+
Username: cfg.RedisUsername,
108+
Password: cfg.RedisPassword,
109+
KeyPrefix: cfg.RedisKeyPrefix,
110+
DialTimeoutSec: cfg.RedisDialTimeoutSec,
111+
RequestTimeoutSec: cfg.RedisRequestTimeoutSec,
112+
LockTTLSec: cfg.RedisLockTTLSec,
113+
TLSCAFile: cfg.RedisTLSCAFile,
114+
TLSCertFile: cfg.RedisTLSCertFile,
115+
TLSKeyFile: cfg.RedisTLSKeyFile,
116+
}
117+
}
99118
return spec, nil
100119
}
101120

@@ -192,11 +211,15 @@ func main() {
192211
encryptCAKey bool
193212
caKeyPassphraseFile string
194213
singleProcess bool
195-
storageBackend string
196-
etcdEndpoints []string
197-
etcdKeyPrefix string
198-
caCertFile string
199-
caKeyFile string
214+
storageBackend string
215+
etcdEndpoints []string
216+
etcdKeyPrefix string
217+
redisAddrs []string
218+
redisSentinelMasterName string
219+
redisSentinelAddrs []string
220+
redisKeyPrefix string
221+
caCertFile string
222+
caKeyFile string
200223
)
201224

202225
cmd := &cobra.Command{
@@ -278,6 +301,18 @@ func main() {
278301
if cmd.Flags().Changed("etcd-key-prefix") {
279302
cfg.EtcdKeyPrefix = etcdKeyPrefix
280303
}
304+
if cmd.Flags().Changed("redis-addrs") {
305+
cfg.RedisAddrs = redisAddrs
306+
}
307+
if cmd.Flags().Changed("redis-sentinel-master-name") {
308+
cfg.RedisSentinelMasterName = redisSentinelMasterName
309+
}
310+
if cmd.Flags().Changed("redis-sentinel-addrs") {
311+
cfg.RedisSentinelAddrs = redisSentinelAddrs
312+
}
313+
if cmd.Flags().Changed("redis-key-prefix") {
314+
cfg.RedisKeyPrefix = redisKeyPrefix
315+
}
281316
if cmd.Flags().Changed("ca-cert-file") {
282317
cfg.CACertFile = caCertFile
283318
}
@@ -631,9 +666,13 @@ func main() {
631666
f.BoolVar(&encryptCAKey, "encrypt-ca-key", false, "Encrypt the CA private key at rest (AES-256-GCM + Argon2id); a passphrase is auto-generated if not provided")
632667
f.StringVar(&caKeyPassphraseFile, "ca-key-passphrase-file", "", "Path to file containing the CA key passphrase (first line used)")
633668
f.BoolVar(&singleProcess, "single-process", false, "Disable CA key isolation (run signer and frontend in a single process)")
634-
f.StringVar(&storageBackend, "storage-backend", "", "Storage backend: 'filesystem' (default) or 'etcd'")
669+
f.StringVar(&storageBackend, "storage-backend", "", "Storage backend: 'filesystem' (default), 'etcd', or 'redis' (alias 'valkey')")
635670
f.StringSliceVar(&etcdEndpoints, "etcd-endpoints", nil, "Comma-separated etcd cluster endpoints (e.g. https://etcd1:2379,https://etcd2:2379)")
636671
f.StringVar(&etcdKeyPrefix, "etcd-key-prefix", "", "etcd key namespace for this CA (default: /puppet-ca)")
672+
f.StringSliceVar(&redisAddrs, "redis-addrs", nil, "Comma-separated Redis/Valkey addresses for direct connections (e.g. redis-0:6379)")
673+
f.StringVar(&redisSentinelMasterName, "redis-sentinel-master-name", "", "Redis Sentinel primary name; set to enable Sentinel-managed failover")
674+
f.StringSliceVar(&redisSentinelAddrs, "redis-sentinel-addrs", nil, "Comma-separated Redis Sentinel addresses (e.g. sentinel-0:26379,sentinel-1:26379)")
675+
f.StringVar(&redisKeyPrefix, "redis-key-prefix", "", "Redis key namespace for this CA (default: puppet-ca)")
637676
f.StringVar(&caCertFile, "ca-cert-file", "", "Keep the CA certificate at this local path regardless of storage backend")
638677
f.StringVar(&caKeyFile, "ca-key-file", "", "Keep the CA private key at this local path regardless of storage backend")
639678

0 commit comments

Comments
 (0)