diff --git a/README.md b/README.md index 3c7aed37..60c8e7b7 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,9 @@ simpler for other teams. > **Author attribution** is the only optional capability: it needs kube-apiserver audit delivery, which > managed control planes (EKS/GKE/AKS) generally do not expose. Without it the operator still mirrors -> state, with commits authored by the configured committer. **Valkey/Redis is required either way** — it -> holds each GitTarget's watch resume state so work is re-picked up after a restart or reconnect. +> state, with commits authored by the configured committer. Valkey/Redis is optional in committer-only +> mode: when `--redis-addr` is set, watch resume cursors are stored so restarts pick up where they left +> off; when left empty, watches cold-replay from scratch on restart. ## How it works @@ -74,7 +75,7 @@ Capturing objects served by an **aggregated API server** is supported through th ### Operating modes -Every install needs the same base: Kubernetes watch/RBAC access, **Valkey/Redis** (watch resume state), +Every install needs the same base: Kubernetes watch/RBAC access, Git credentials, and cert-manager. The only thing that varies is **author attribution**: | Mode | Attribution | Additionally needs | Commit author | @@ -137,7 +138,7 @@ kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/ kubectl wait --for=condition=ready pod -l app.kubernetes.io/instance=cert-manager -n cert-manager --timeout=300s ``` -**2. Install Valkey with auth** *(required — both modes)* +**2. Install Valkey with auth** *(required for author attribution; optional in committer-only mode)* ```bash kubectl create namespace gitops-reverser @@ -155,6 +156,8 @@ helm install valkey valkey/valkey --version 0.9.3 --namespace gitops-reverser \ **3. Install GitOps Reverser** +With Valkey (warm restarts — watches resume from last cursor): + ```bash helm install gitops-reverser \ oci://ghcr.io/configbutler/charts/gitops-reverser \ @@ -162,6 +165,16 @@ helm install gitops-reverser \ --create-namespace ``` +Without Valkey (committer-only mode — watches cold-replay on restart): + +```bash +helm install gitops-reverser \ + oci://ghcr.io/configbutler/charts/gitops-reverser \ + --namespace gitops-reverser \ + --create-namespace \ + --set queue.redis.addr="" +``` + **4. Create Git credentials** SSH deploy key example: diff --git a/charts/gitops-reverser/values.yaml b/charts/gitops-reverser/values.yaml index 80b260bd..1dfe280a 100644 --- a/charts/gitops-reverser/values.yaml +++ b/charts/gitops-reverser/values.yaml @@ -160,11 +160,13 @@ servers: # Leave empty to use the chart-generated Secret name. secretNameOverride: "" -# Redis/Valkey is a required dependency: it holds each GitTarget's watch resume cursors (so work is -# re-picked up after a restart/reconnect) and, when attribution is enabled, the audit attribution facts. +# Redis/Valkey holds each GitTarget's watch resume cursors (warm restart) and, when attribution is +# enabled, the audit attribution facts. Optional in committer-only mode: leave addr empty to run without +# Redis (watches cold-replay on restart). Required when attribution.enabled is true. queue: redis: - # Redis/Valkey endpoint (host:port). Required — the operator refuses to start without it. + # Redis/Valkey endpoint (host:port). Leave empty to run without Redis in committer-only mode; + # required when attribution.enabled is true. addr: "valkey:6379" auth: # Pre-existing Secret (same namespace) holding the Redis password. Create it before install; @@ -180,11 +182,12 @@ queue: # Commit-author attribution from audit facts. Off by default so first-time installs can prove the # Kubernetes-to-Git workflow without kube-apiserver audit webhook configuration. With it off, the -# operator runs committer-only (still requires Redis), and every mirrored-resource commit uses the +# operator runs committer-only (Redis optional — leave queue.redis.addr empty to run without it), and every mirrored-resource commit uses the # configured committer. attribution: - # When true, run the audit webhook ingress and name commit authors from matching audit facts. When - # false, run committer-only (no audit ingress, commits authored by the committer); Redis stays required. + # When true, run the audit webhook ingress and name commit authors from matching audit facts; requires + # a non-empty queue.redis.addr. When false, run committer-only (no audit ingress, commits authored by + # the committer); Redis is optional (leave queue.redis.addr empty to run without it). enabled: false # How long an attribution fact is retained waiting for the matching watch event to join it. ttl: "10m" diff --git a/cmd/main.go b/cmd/main.go index 43985ad5..556eebfb 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -173,23 +173,28 @@ func main() { WatchManager: watchMgr, }).SetupWithManager(mgr), "unable to create controller", "controller", "ClusterWatchRule") - // Valkey/Redis is a required dependency: it holds each GitTarget's watch resume cursors so work is - // re-picked up exactly where it left off after a restart or reconnect, and it underpins HA and the - // planned durable branch-worker queue. The cursor store is always wired and the readiness gate keeps - // the pod not-ready until Redis is reachable. Author attribution is a separate optional layer built - // on the same connection only when enabled (see below) — the store itself never depends on it. - redisStore, err := queue.NewRedisStore(queue.RedisStoreConfig{ - Addr: cfg.redisAddr, - Username: cfg.redisUsername, - AuthValue: cfg.redisPassword, - DB: cfg.redisDB, - TLSEnabled: !cfg.redisInsecure, - }) - fatalIfErr(err, "unable to build Redis cursor store") - watchMgr.WatchCursorStore = redisStore + // Valkey/Redis is optional. When configured it holds each GitTarget's watch resume cursor so work + // is re-picked up exactly where it left off after a restart or reconnect. When not configured the + // WatchCursorStore stays nil and watches cold-replay from scratch on restart instead of resuming. + // Author attribution and the admission webhook both require Redis; validation has already rejected + // those combinations when redis-addr is empty. + var redisStore *queue.RedisStore + var redisGate *redisReadinessGate + if cfg.redisAddr != "" { + var err error + redisStore, err = queue.NewRedisStore(queue.RedisStoreConfig{ + Addr: cfg.redisAddr, + Username: cfg.redisUsername, + AuthValue: cfg.redisPassword, + DB: cfg.redisDB, + TLSEnabled: !cfg.redisInsecure, + }) + fatalIfErr(err, "unable to build Redis cursor store") + watchMgr.WatchCursorStore = redisStore - redisGate := newRedisReadinessGate(redisStore) - fatalIfErr(mgr.Add(redisGate), "unable to add redis readiness gate") + redisGate = newRedisReadinessGate(redisStore) + fatalIfErr(mgr.Add(redisGate), "unable to add redis readiness gate") + } // Optional author attribution. When enabled, the attribution index is built on the Redis connection, // the audit webhook records minimal facts, and live watch events are author-attributed when a fact @@ -200,7 +205,8 @@ func main() { auditCertWatcher *certwatcher.CertWatcher attributionIndex *queue.AttributionIndex ) - if cfg.authorAttribution { + switch { + case cfg.authorAttribution: attributionIndex = redisStore.AttributionIndex(cfg.attributionFactTTL) auditHandler, err := webhookhandler.NewAuditHandler(webhookhandler.AuditHandlerConfig{ @@ -221,9 +227,11 @@ func main() { ) setupLog.Info("author attribution enabled: matched audit facts name the commit author", "redisAddr", cfg.redisAddr, "grace", cfg.attributionGrace.String()) - } else { + case cfg.redisAddr != "": setupLog.Info("committer-only mode: author attribution disabled; commits use the configured "+ "committer identity", "redisAddr", cfg.redisAddr) + default: + setupLog.Info("committer-only mode: no Redis configured; attribution disabled, watches cold-replay on restart") } // Setup watch manager (must be after controllers are set up) @@ -396,8 +404,10 @@ func parseFlagsWithArgs(fs *flag.FlagSet, args []string) (appConfig, error) { fs.DurationVar(&cfg.auditIdleTimeout, "audit-idle-timeout", defaultAuditIdleTimeout, "Idle timeout for the audit ingress HTTPS server (duration string; default 60s).") fs.StringVar(&cfg.redisAddr, "redis-addr", "valkey:6379", - "Redis/Valkey address (host:port). Required — it holds each GitTarget's watch resume cursors "+ - "(state continuity), and, when author attribution is enabled, the attribution facts.") + "Redis/Valkey address (host:port). Holds each GitTarget's watch resume cursors (state continuity) "+ + "and, when author attribution is enabled, the attribution facts. Leave empty to run without "+ + "Redis: watches cold-replay on restart instead of resuming. Incompatible with "+ + "--author-attribution=true or --admission-webhook.") fs.BoolVar(&cfg.authorAttribution, "author-attribution", true, "Name the real actor (human or service account) who caused each change as the Git commit author, "+ "resolved from matching audit facts; this runs the audit webhook ingress (default true). When "+ @@ -452,6 +462,7 @@ func parseFlagsWithArgs(fs *flag.FlagSet, args []string) (appConfig, error) { if err := fs.Parse(args); err != nil { return appConfig{}, err } + cfg.redisAddr = strings.TrimSpace(cfg.redisAddr) if err := validateAuditConfig(cfg); err != nil { return appConfig{}, err } @@ -501,17 +512,19 @@ func validateAuditConfig(cfg appConfig) error { if cfg.attributionFactTTL <= 0 { return fmt.Errorf("author-attribution-ttl must be > 0, got %s", cfg.attributionFactTTL) } - // Redis/Valkey is required in every mode: it holds each GitTarget's watch resume cursors. This is - // independent of author attribution, which only adds commit-author naming on top. - if strings.TrimSpace(cfg.redisAddr) == "" { - return errors.New("redis-addr is required: Valkey/Redis holds each GitTarget's watch resume cursors") - } if cfg.redisDB < 0 { return fmt.Errorf("redis-db must be >= 0, got %d", cfg.redisDB) } + if strings.TrimSpace(cfg.redisAddr) == "" { + if cfg.authorAttribution { + return errors.New("redis-addr is required when author-attribution is enabled") + } + // Committer-only mode with no Redis: watches cold-replay on restart, attribution is off. + return nil + } if !cfg.authorAttribution { - // Committer-only mode: the audit ingress server is not started, so its server/TLS settings - // are irrelevant. Redis is still required and was validated above. + // Committer-only mode with Redis configured: the audit ingress server is not started, so its + // server/TLS settings are irrelevant. return nil } if _, _, err := splitBindAddress(cfg.auditBindAddress); err != nil { @@ -539,6 +552,10 @@ func validateAdmissionWebhookConfig(cfg appConfig) error { if !cfg.admissionWebhookEnabled { return nil } + if strings.TrimSpace(cfg.redisAddr) == "" { + return errors.New("redis-addr is required when the admission webhook is enabled: " + + "command authorship requires Redis") + } if _, _, err := splitBindAddress(cfg.admissionWebhookBindAddress); err != nil { return fmt.Errorf("invalid admission-webhook-bind-address %q: %w", cfg.admissionWebhookBindAddress, err) } diff --git a/cmd/main_audit_server_test.go b/cmd/main_audit_server_test.go index 8914e471..d7836b29 100644 --- a/cmd/main_audit_server_test.go +++ b/cmd/main_audit_server_test.go @@ -124,19 +124,30 @@ func TestParseFlagsWithArgs_CustomAuditValues(t *testing.T) { assert.Equal(t, 750*time.Millisecond, cfg.attributionGrace) } -func TestParseFlagsWithArgs_RedisAddrRequired(t *testing.T) { +func TestParseFlagsWithArgs_RedisAddrRequiredWhenAttributionEnabled(t *testing.T) { fs := flag.NewFlagSet("test-redis-required", flag.ContinueOnError) - // Redis/Valkey holds each GitTarget's watch resume cursors, so an empty redis-addr is a - // hard error in every mode — committer-only disables attribution, it does not drop Redis. + // Default --author-attribution=true, so an empty redis-addr must be rejected. _, err := parseFlagsWithArgs(fs, []string{"--redis-addr="}) require.Error(t, err) - assert.Contains(t, err.Error(), "redis-addr is required") + assert.Contains(t, err.Error(), "redis-addr is required when author-attribution is enabled") +} + +func TestParseFlagsWithArgs_CommitterOnlyNoRedis(t *testing.T) { + fs := flag.NewFlagSet("test-committer-only-no-redis", flag.ContinueOnError) + // Committer-only mode with no Redis: watches cold-replay on restart, no attribution. + cfg, err := parseFlagsWithArgs(fs, []string{ + "--author-attribution=false", + "--redis-addr=", + }) + require.NoError(t, err) + assert.False(t, cfg.authorAttribution) + assert.Empty(t, cfg.redisAddr) } func TestParseFlagsWithArgs_CommitterOnlyDisablesAttribution(t *testing.T) { fs := flag.NewFlagSet("test-committer-only", flag.ContinueOnError) - // Committer-only = attribution off, Redis still required (default addr). The audit ingress server - // is not started, so its TLS / client-CA settings need not be configured. + // Committer-only = attribution off, Redis still configured (default addr). The audit ingress + // server is not started, so its TLS / client-CA settings need not be configured. cfg, err := parseFlagsWithArgs(fs, []string{ "--author-attribution=false", "--audit-client-ca-path=", @@ -214,6 +225,10 @@ func TestParseFlagsWithArgs_InvalidAuditSettings(t *testing.T) { name: "missing admission webhook cert path", args: []string{"--admission-webhook", "--admission-webhook-cert-path="}, }, + { + name: "admission webhook without redis", + args: []string{"--admission-webhook", "--admission-webhook-cert-path=/tmp/certs", "--redis-addr="}, + }, } for _, tt := range tests { diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index cd641bba..f89d0676 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -37,7 +37,9 @@ The chart default for `attribution.enabled` changed from `true` to `false`. A de renders the audit receiver Service or audit TLS Secrets, and mirrored-resource commits are authored by the configured committer identity. -Redis/Valkey is still required. It stores each `GitTarget`'s watch resume cursors in both modes. +Redis/Valkey is optional in committer-only mode. Set `--redis-addr` to store watch resume cursors (warm +restart); leave it empty to cold-replay from scratch on restart. Attribution mode still requires a +non-empty `--redis-addr`. **Migration** diff --git a/docs/architecture.md b/docs/architecture.md index bda28990..0fdba14b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1026,8 +1026,11 @@ flowchart TD D --> E[Create WorkerManager + register Runnable] E --> F[Create Watch Manager + EventRouter; inject TypeRegistry] F --> G[Register WatchRule + ClusterWatchRule controllers] - G --> H[Create Redis cursor store + wire WatchCursorStore + readiness gate - required] - H --> Hq{author-attribution?} + G --> H{redis-addr set?} + H -->|yes| Hi[Create Redis cursor store + wire WatchCursorStore + readiness gate] + H -->|no| Hj[Skip Redis — WatchCursorStore nil; watches cold-replay on restart] + Hi --> Hq{author-attribution?} + Hj --> Hq Hq -->|yes| I[Build attribution index + audit fact extractor + audit HTTP server + resolver] Hq -->|no| J[Committer-only: no attribution index; audit webhook skipped] I --> K[Setup + register Watch Manager] @@ -1037,13 +1040,14 @@ flowchart TD M --> N[mgr.Start] ``` -Redis is required: the cursor store is wired unconditionally and a Redis readiness gate keeps the pod -not-ready until Redis is reachable. With `--author-attribution` on (the default), the attribution index is -built on the Redis connection, the audit HTTP handler is wired with the fact extractor, the watch manager -gets the author resolver, the CommitRequest controller gets the index as its `AuthorLookup`, and the audit +Redis is optional in committer-only mode. When `--redis-addr` is set, the cursor store is wired and a +Redis readiness gate keeps the pod not-ready until Redis is reachable; watches resume from their last +stored resourceVersion after a restart. When `--redis-addr` is empty, the cursor store is skipped and +watches cold-replay from scratch on restart instead. With `--author-attribution` on (the default), a +non-empty `--redis-addr` is required: the attribution index is built on the Redis connection, the audit +HTTP handler is wired with the fact extractor, the watch manager gets the author resolver, and the audit ingress is added to `/readyz`. With `--author-attribution=false` (committer-only) no attribution index is -built and the audit webhook is skipped entirely; every commit is committer-authored. Redis stays required -either way. +built and the audit webhook is skipped entirely; every commit is committer-authored. *** diff --git a/docs/config-flag-conventions.md b/docs/config-flag-conventions.md index 7b0edece..21e89467 100644 --- a/docs/config-flag-conventions.md +++ b/docs/config-flag-conventions.md @@ -120,7 +120,7 @@ tells the operator how to fix it, not just what's wrong. Flux and our own say so *and* explain what it is for: ``` -redis-addr is required: Valkey/Redis holds each GitTarget's watch resume cursors +redis-addr is required when author-attribution is enabled ``` ## Boolean help-text checklist @@ -132,7 +132,7 @@ Before merging a new or renamed boolean flag, confirm: - [ ] Help text states the default explicitly. - [ ] Default is the value a careful operator would pick blind. - [ ] Flag shares its prefix with the rest of its component's flags. -- [ ] Any required-dependency caveat that survives the "off" state is spelled out (e.g. "Redis is still required"). +- [ ] Any required-dependency caveat that survives the "off" state is spelled out (e.g. "Redis is required when attribution is enabled"). ## Numbers, durations, and sizes — five more rules diff --git a/docs/configuration.md b/docs/configuration.md index fd873f83..c10b286d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -679,11 +679,12 @@ author to each watch event by matching a fact (by resourceVersion/UID) within a The same Redis connection also stores per-watch resume cursors, so short reconnects can resume a normal watch from the last processed resourceVersion when the apiserver can still serve that history. -Redis/Valkey is **required**: it stores each GitTarget's watch resume cursors (state continuity, so a -restart or reconnect resumes where it left off), and when attribution is enabled it also stores the audit -facts. The Helm chart defaults to **committer-only** (`attribution.enabled: false`): the audit webhook is -unused and every mirrored-resource commit is authored by the configured committer. Redis stays required -either way. +Valkey/Redis is **optional in committer-only mode**: when `--redis-addr` is set, watch resume cursors are +stored so restarts pick up where they left off; when left empty, watches cold-replay from scratch on +restart instead. When author attribution is enabled (`--author-attribution=true`), a non-empty +`--redis-addr` is required — attribution facts and resume cursors both use the same connection. The Helm +chart defaults to **committer-only** (`attribution.enabled: false`): the audit webhook is unused and every +mirrored-resource commit is authored by the configured committer. ```yaml queue: