Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## How it works

Expand All @@ -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 |
Expand Down Expand Up @@ -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)*
Comment thread
coderabbitai[bot] marked this conversation as resolved.

```bash
kubectl create namespace gitops-reverser
Expand All @@ -155,13 +156,25 @@ 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 \
--namespace 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:
Expand Down
15 changes: 9 additions & 6 deletions charts/gitops-reverser/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
addr: "valkey:6379"
auth:
# Pre-existing Secret (same namespace) holding the Redis password. Create it before install;
Expand All @@ -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"
Expand Down
71 changes: 44 additions & 27 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// 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
Expand All @@ -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{
Expand All @@ -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)
Expand Down Expand 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 "+
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
Expand Down
27 changes: 21 additions & 6 deletions cmd/main_audit_server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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=",
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion docs/UPGRADING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
20 changes: 12 additions & 8 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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.

***

Expand Down
4 changes: 2 additions & 2 deletions docs/config-flag-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
11 changes: 6 additions & 5 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down