Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
218a289
feat(watch)!: resolve commit authors from the in-process fact index
sunib Jul 28, 2026
de9a4bd
feat(main): select the attribution fact transport
sunib Jul 28, 2026
462778f
docs(attribution): record the fact stream as shipped
sunib Jul 28, 2026
8117685
test(e2e): attribute an aggregated collection delete through the ward…
sunib Jul 28, 2026
fef99b7
fix(queue): expire idle fact streams and stop shipping unread fields
sunib Jul 28, 2026
fd67198
fix(queue): credit a collection delete to the deleter, not the last e…
sunib Jul 28, 2026
d72fd70
fix(queue): age facts from their append time and stop phantom trim gaps
sunib Jul 28, 2026
76b80e5
test(queue): prove one fact serves every GitTarget waiting for it
sunib Jul 28, 2026
8d006ab
test(webhook): assert the collection join against captured aggregated…
sunib Jul 28, 2026
3197419
fix(queue): make a removal wait for evidence about the deletion
sunib Jul 28, 2026
b12737f
fix(e2e): the author-mode probe read "no Redis" as configured-author
sunib Jul 28, 2026
0c3e399
docs(attribution): correct the record on tier order and on waiting
sunib Jul 28, 2026
bfb5fbf
fix(webhook): fail only the streams that did not append
sunib Jul 28, 2026
5e535e7
test(e2e): prove the cleanup bot is the last writer before deleting
sunib Jul 28, 2026
3084b86
fix(docs): keep the INDEX entry inside the line-length limit
sunib Jul 28, 2026
c4a381a
docs(attribution): record what the removal wait costs, per tier
sunib Jul 28, 2026
458a03d
docs(design): work out when a removal should stop waiting for its author
sunib Jul 28, 2026
3a6dd24
docs: the "no audit event" population is the audit policy, not Kubern…
sunib Jul 28, 2026
d1f3f4a
test(lab): capture the flunder delete and deletecollection, and say w…
sunib Jul 28, 2026
f76a273
fix(lab): serve the named audit route, not just the bare path
sunib Jul 28, 2026
5c34f7b
fix(lab): stop the corpus churning on timings and generated names
sunib Jul 28, 2026
5b91aef
test(lab): capture the aggregated delete and deletecollection
sunib Jul 28, 2026
0d695a0
test(lab): capture a generateName create as the aggregated rows' control
sunib Jul 28, 2026
8e40dca
feat(attribution): restore the fact's name and join on it
sunib Jul 28, 2026
1c67ea5
test(e2e): assert a CommitRequest committed, not merely that it ended
sunib Jul 28, 2026
96c0f61
docs: record what the branch's loose ends turned out to be
sunib Jul 28, 2026
81e0b56
fix(queue): let a removal reach its delete fact when the fact is keye…
sunib Jul 28, 2026
55f5262
docs: root-cause the CommitRequest window race and record the fix
sunib Jul 28, 2026
c33ed01
docs: describe attribution's two halves, and why neither knows about …
sunib Jul 29, 2026
2f8d8bf
docs: say why attribution is split in two, and that filing picks one …
sunib Jul 29, 2026
494ee06
docs: diagram the wait, and say plainly that filing is about memory
sunib Jul 29, 2026
927fb20
docs: list the attribution metrics, the gaps, and the exact_user wart
sunib Jul 29, 2026
1e9676f
docs: propose a consolidated attribution metric surface
sunib Jul 29, 2026
3e2b9aa
docs: trace the fact-stream record's claims to metrics, and find seve…
sunib Jul 29, 2026
bd2e0c1
docs: design the stream-count metrics so "reasonable" becomes measurable
sunib Jul 29, 2026
ab497d9
docs: design the three cheap metrics, and correct a wrong claim about…
sunib Jul 29, 2026
f3f452c
docs: cut the metrics proposal to a first release, and record what it…
sunib Jul 29, 2026
2de2904
feat(metrics)!: split the attribution `result` label into `tier` and …
sunib Jul 29, 2026
ad6d0a8
feat(metrics): count the attribution loss paths that were silent
sunib Jul 29, 2026
f66d28a
docs(metrics): make the follower alert fire when it has NEVER succeeded
sunib Jul 29, 2026
e9aaefc
feat(attribution): make "a fact names somebody" a wire contract, not …
sunib Jul 29, 2026
b08fd23
fix(chart): let attribution.transport=memory actually run without Redis
sunib Jul 29, 2026
930f25f
test(controller): widen the encryption-secret recreation budget to 3.…
sunib Jul 29, 2026
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
2 changes: 1 addition & 1 deletion .coverage-baseline
Original file line number Diff line number Diff line change
@@ -1 +1 @@
78.4
78.5
3 changes: 3 additions & 0 deletions .docs-lint-scope
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,6 @@
README.md
docs/architecture.md
docs/configuration.md
docs/design/attribution-branch-findings.md
docs/design/attribution-publish-and-join.md
docs/design/attribution-metrics-proposal.md
13 changes: 9 additions & 4 deletions charts/gitops-reverser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,16 +186,21 @@ nodeSelector:
| `quickstart.gitProvider.secretRef.name` | Existing Secret name used by the starter `GitProvider` | `git-creds` |
| `quickstart.gitTarget.path` | Repository path used by the starter `GitTarget`; set `.` only to deliberately target the repo root | `live-cluster` |
| `quickstart.watchRule.rules` | Rules used by the starter `WatchRule` | `configmaps create/update/delete` |
| `queue.redis.addr` | Redis/Valkey endpoint (`host:port`). Optional but advised: empty runs `configured-author` with cold-replay on restart. Set it for warm-restart cursors and for the admission webhook to actually record CommitRequest authors (admission runs as a no-op without it); **required** only when `attribution.enabled=true` | `""` |
| `queue.redis.addr` | Redis/Valkey endpoint (`host:port`). Optional but advised: empty means cold-replay on restart. Set it for warm-restart cursors and for the admission webhook to actually record CommitRequest authors (admission runs as a no-op without it); **required** when `attribution.enabled=true` unless `attribution.transport=memory` | `""` |
| `queue.redis.auth.existingSecret` | Name of a pre-created Secret holding the Redis password (only used when `queue.redis.addr` is set) | `""` |
| `queue.redis.auth.existingSecretKey` | Key within the Secret that holds the password | `password` |
| `queue.redis.auth.username` | Optional Redis ACL username | `""` |
| `queue.redis.db` | Redis logical database index. Redis offers 16; use `queue.redis.keyPrefix` to go past that | `0` |
| `queue.redis.keyPrefix` | Root of every key this release writes (watch cursors, attribution facts, command author records). Give each reverser its own prefix to share one Redis/Valkey between more reversers than `db` can separate. Changing it orphans the previous prefix's keys: cursors cold-replay once, which is safe. Allowed: `[A-Za-z0-9]`, `-`, `_`, `.`, `:` | `gitops-reverser` |
| `queue.redis.tls.enabled` | Enable TLS for Redis connection | `false` |
| `attribution.enabled` | Run audit ingress and name mirrored-resource commit authors from matching kube-apiserver audit facts | `false` |
| `attribution.ttl` | How long an attribution fact is retained waiting for the matching watch event to join it | `10m` |
| `attribution.transport` | Where attribution facts travel between the audit receiver and the watch side. `redis` appends them to Redis streams and needs `queue.redis.addr`; `memory` keeps them in an in-process ring, which needs no Redis but loses facts on a restart and is **refused at startup with `replicaCount > 1`** | `redis` |
| `attribution.ttl` | How long an attribution fact is retained waiting for the matching watch event to join it. Bounds stream retention and the in-memory index together, and is the horizon a restart replays from | `10m` |
| `attribution.grace` | Bounded per-event wait for a matching audit fact before a watch event ships as the committer | `3s` |
| `attribution.maxFactsPerType` | Cap on the facts held in memory for one (audit route, type), evicted oldest-first, so a burst on one noisy type cannot evict every other type's facts | `4096` |
| `attribution.maxFacts` | Cap on the facts held in memory across every type. Must be at least `maxFactsPerType`; overflow evicts from the type holding the most | `65536` |
| `attribution.collectionWindow` | How long after a `deletecollection` a removal in its scope may still be credited to it. It only has to cover audit batching plus clock skew, since the removal is attributed at delete-request time | `30s` |
| `attribution.collectionUIDCap` | How many object UIDs a `deletecollection` fact carries before the set is dropped and the join falls back to scope matching, which is already correct | `10000` |
| `attribution.auditRouteAnnotationKey` | Audit-event annotation naming the **audit route** each event belongs to. Empty keeps audit routes named (`/audit-webhook/<audit-route>`). Set it only for a control plane emitting **one shared audit stream** for several logical clusters: it enables the bare `/audit-webhook`, which reads the route per event. A `ClusterProvider` joins a route via `spec.attribution.auditRoute` (default: its own name). An event with no annotation is rejected (counted and logged) and never credited to a fallback | `""` |
| `clusterProvider.createDefault` | Render and own a `ClusterProvider` named `default` — the source cluster a `GitTarget` mirrors from when it omits `spec.clusterProviderRef`. The **operator never creates one**, so without this you commit the object yourself. Chart-owned: turning it off makes Helm delete the provider it created, and a `GitTarget` referencing a missing provider is held unready (`ClusterProviderNotFound`). The `quickstart` values never create one | `true` |
| `clusterProvider.default.kubeConfig.secretRef.name` | Secret (release namespace) holding a kubeconfig for the rendered `default` provider. Empty means the operator's **own in-cluster** cluster; a name points `default` at a **remote** cluster instead — the name is a convention, not a claim about which cluster it is | `""` |
Expand Down Expand Up @@ -245,8 +250,8 @@ Audit routes are **named**, including
`attribution.auditRouteAnnotationKey` is set, which turns it into the shared-stream endpoint that
resolves each event's source cluster from that annotation. The operator extracts a minimal attribution
fact from each (auditID, user, verb, resourceVersion, GVR, namespace, name, UID, status, timestamps)
into the Redis attribution index
(populated only when audit attribution is enabled). When a Redis endpoint is configured it also stores
and appends it to the per-type fact log the watch side follows
(written only when audit attribution is enabled). When a Redis endpoint is configured it also stores
each GitTarget's watch resume cursors, so reconnects resume a normal watch from the last processed
resourceVersion when the apiserver can still serve that history. Object state itself comes from
Kubernetes **watch**, not from audit; audit only names the commit author.
Expand Down
6 changes: 6 additions & 0 deletions charts/gitops-reverser/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,15 @@ spec:
{{- if not .Values.queue.redis.tls.enabled }}
- --redis-insecure
{{- end }}
- --replica-count={{ .Values.replicaCount }}
- --author-attribution={{ .Values.attribution.enabled }}
- --author-attribution-transport={{ .Values.attribution.transport }}
- --author-attribution-ttl={{ .Values.attribution.ttl }}
- --author-attribution-grace={{ .Values.attribution.grace }}
- --author-attribution-max-facts-per-type={{ .Values.attribution.maxFactsPerType }}
- --author-attribution-max-facts={{ .Values.attribution.maxFacts }}
- --author-attribution-collection-window={{ .Values.attribution.collectionWindow }}
- --author-attribution-collection-uid-cap={{ .Values.attribution.collectionUIDCap }}
{{- if .Values.attribution.auditRouteAnnotationKey }}
- --author-attribution-audit-route-annotation-key={{ .Values.attribution.auditRouteAnnotationKey }}
{{- end }}
Expand Down
18 changes: 12 additions & 6 deletions charts/gitops-reverser/templates/validate-redis.yaml
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
{{- /*
Attribution (attributed-author mode) reads and writes audit facts in Redis, so it cannot run
without an endpoint — the controller would fail at startup. Fail the render early with an
actionable message instead. The admission webhook is deliberately NOT gated here: it stays
enabled without Redis and simply no-ops command-author capture.
Attribution needs a fact TRANSPORT, which is not the same as needing Redis. The redis transport
appends facts to Redis streams, so it cannot run without an endpoint — the controller would fail at
startup, so fail the render early with an actionable message instead. The memory transport needs no
endpoint at all, and refusing it here would make a value this chart itself documents unreachable.

The memory transport's other requirement, a single replica, needs no check here: validate-replica-count.yaml
already refuses replicaCount > 1 for the whole chart, so there is no configuration this could catch.

The admission webhook is deliberately NOT gated here: it stays enabled without Redis and simply
no-ops command-author capture.
*/ -}}
{{- if and .Values.attribution.enabled (eq (trim .Values.queue.redis.addr) "") -}}
{{- fail "attribution.enabled=true requires queue.redis.addr: attributed-author mode stores audit facts in Redis. Set queue.redis.addr, or leave attribution.enabled=false." -}}
{{- if and .Values.attribution.enabled (eq .Values.attribution.transport "redis") (eq (trim .Values.queue.redis.addr) "") -}}
{{- fail "attribution.enabled=true with attribution.transport=redis requires queue.redis.addr: the redis transport carries audit facts on Redis streams. Set queue.redis.addr, select attribution.transport=memory to run attribution in-process on a single replica, or leave attribution.enabled=false." -}}
{{- end -}}
24 changes: 24 additions & 0 deletions charts/gitops-reverser/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,30 @@
"enabled": { "type": "boolean" },
"ttl": { "$ref": "#/$defs/duration" },
"grace": { "$ref": "#/$defs/duration" },
"transport": {
"type": "string",
"enum": ["redis", "memory"],
"description": "Where attribution facts travel between the audit receiver and the watch side. 'redis' (default) appends them to Redis streams and requires queue.redis.addr; 'memory' keeps them in an in-process ring, which needs no Redis but does not survive a restart and is refused at startup with replicaCount > 1."
},
"maxFactsPerType": {
"type": "integer",
"minimum": 1,
"description": "Cap on the facts held in memory for one (audit route, group/resource), evicted oldest-first, so a burst on one noisy type cannot evict every other type's facts."
},
"maxFacts": {
"type": "integer",
"minimum": 1,
"description": "Cap on the facts held in memory across every type. Must be at least maxFactsPerType; overflow evicts from the type holding the most."
},
"collectionWindow": {
"$ref": "#/$defs/duration",
"description": "How long after a deletecollection a removal in its scope may still be credited to it. It only has to cover audit batching plus clock skew, since the removal is attributed at delete-request time."
},
"collectionUIDCap": {
"type": "integer",
"minimum": 1,
"description": "How many object uids a deletecollection fact carries before the set is dropped and the join falls back to scope matching, which is already correct."
},
"auditRouteAnnotationKey": {
"type": "string",
"description": "Audit-event annotation naming the audit route each event belongs to. A ClusterProvider joins a route through spec.attribution.auditRoute, which defaults to its own name. Empty (default) keeps audit routes named (/audit-webhook/<audit-route>); set it only for one shared audit stream carrying several logical clusters, which enables the bare /audit-webhook endpoint."
Expand Down
35 changes: 31 additions & 4 deletions charts/gitops-reverser/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -204,15 +204,42 @@ queue:
# operator runs configured-author (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; requires
# a non-empty queue.redis.addr. When false, run configured-author (no audit ingress, commits authored by
# the committer); Redis is optional (leave queue.redis.addr empty to run without it).
# When true, run the audit webhook ingress and name commit authors from matching audit facts. It
# needs a fact transport, which is not the same as needing Redis: `transport: redis` requires a
# non-empty queue.redis.addr, while `transport: memory` needs none and requires replicaCount 1.
# When false, run configured-author (no audit ingress, commits authored by the committer); Redis
# stays optional either way (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.
# Where attribution facts travel between the audit receiver and the watch side.
# "redis" (the default) appends them to Redis streams and needs a non-empty queue.redis.addr; it
# is the production choice and the only one that survives a restart or reaches a second replica.
# "memory" keeps them in an in-process ring, so a single-pod install can run attribution with no
# Valkey at all — at the cost that facts do not survive a restart, and it is REFUSED at startup
# with replicaCount > 1, where the audit receiver and the resolver are no longer one process.
transport: "redis"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# How long an attribution fact is retained waiting for the matching watch event to join it. It
# bounds stream retention and the in-memory index together, and doubles as the replay horizon a
# restart warms the index from.
ttl: "10m"
# Bounded per-event wait for a matching audit fact before a watch event ships as the committer.
# Larger values raise attribution hit-rate at the cost of commit latency.
grace: "3s"
# Caps on the facts held in memory. Per-type is the primary because it is the fair one: a burst on
# one noisy type — a deletecollection over ten thousand objects, a large rollout — must not evict
# every other type's facts. The total bounds the pod with a number that does not scale with how
# many types happen to be watched; overflow evicts from the type holding the most. Evictions are
# counted on attribution_fact_index_evictions_total{reason}.
maxFactsPerType: 4096
maxFacts: 65536
# How long after a deletecollection a removal in its scope may still be credited to it. It only has
# to cover audit batching plus clock skew — the removal is attributed at delete-REQUEST time, so
# finalizers do not stretch it. Longer widens the risk of crediting an unrelated delete to the
# collection's actor.
collectionWindow: "30s"
# How many object uids a deletecollection fact carries before the set is dropped and the join falls
# back to scope matching. The fallback is already correct, so this only decides how often the
# precise path is taken; drops are counted on attribution_collection_without_uidset_total{reason}.
collectionUIDCap: 10000
# Audit-event annotation naming the AUDIT ROUTE each event belongs to. Audit routes are normally
# NAMED (/audit-webhook/<audit-route>, including /audit-webhook/default), and this is empty.
# Set it only for a control plane that emits ONE shared audit stream for several logical
Expand Down
Loading