From f5fe06c38dd6dabfa3adb41d28ead0cdcf933aad Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 28 Sep 2026 08:06:35 +0000 Subject: [PATCH 01/10] docs: adopt Kubernetes watch vocabulary, and record the facts behind it Add a facts page on Kubernetes watch options (selectors, membership events, initial events, bookmarks, recovery) with upstream references, and move the attribution-grace ordering explainer into spec/ with its overlapping-watch limitation corrected. Definitions now use collection, watch, and initial events in prose; identifiers keep their spelling until a code migration. The watches-and-labels design records the plan for issue #146. Co-Authored-By: Claude Opus 5.5 --- .gitignore | 1 + docs/INDEX.md | 10 +- docs/architecture.md | 200 ++++----- docs/definitions.md | 65 ++- .../design/commitrequest-save-wait-options.md | 2 +- docs/design/data-plane-triggering.md | 2 +- docs/design/watches-labels-simplification.md | 383 ++++++++++++++++++ docs/facts/kubernetes-watch-options.md | 231 +++++++++++ ...ch-event-ordering-and-attribution-grace.md | 160 -------- docs/finished/attribution-fact-stream.md | 2 +- docs/finished/redis-key-schema-v3.md | 2 +- docs/spec/README.md | 1 + docs/spec/attribution.md | 2 +- ...ch-event-ordering-and-attribution-grace.md | 135 ++++++ 14 files changed, 910 insertions(+), 286 deletions(-) create mode 100644 docs/design/watches-labels-simplification.md create mode 100644 docs/facts/kubernetes-watch-options.md delete mode 100644 docs/facts/watch-event-ordering-and-attribution-grace.md create mode 100644 docs/spec/watch-event-ordering-and-attribution-grace.md diff --git a/.gitignore b/.gitignore index 37decd612..7727d8792 100644 --- a/.gitignore +++ b/.gitignore @@ -61,3 +61,4 @@ loadtest .claude/* .agents/* /external-sources/ +/docs/local-only/* diff --git a/docs/INDEX.md b/docs/INDEX.md index 1d5b27a0e..f11addeab 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -53,13 +53,18 @@ Provider-specific setup that needed writing down: credential Secret is shaped differently from every other provider's, and the three test layers that cover a provider CI cannot reach. -**Before you name anything:** [`definitions.md`](definitions.md) — one name per concept (cell, branch -worker, author attribution, refusal, folder), the two clusters and the two namespace directions, and +**Before you name anything:** [`definitions.md`](definitions.md) — one name per concept (resource collection, +watch, branch worker, author attribution, refusal, folder), the two clusters and the two namespace directions, and the nine rules that decide a CRD field, condition type, reason, enum value or printer column. It is the third of three rulebooks, beside [`style-guide.md`](style-guide.md) for prose and [`config-flag-conventions.md`](config-flag-conventions.md) for flags. A name that contradicts it is a defect rather than a variation. +**Before changing source watches:** +[`facts/kubernetes-watch-options.md`](facts/kubernetes-watch-options.md) covers server-side +label and field selection, membership events, initial state, bookmarks, and recovery, with +Kubernetes documentation and upstream source references. + **Before you change any CRD field:** [`facts/crd-upgrade-strategies.md`](facts/crd-upgrade-strategies.md) — the two honest strategies for removing or renaming one, a decision matrix keyed on whether pruning fails open or closed, and the @@ -78,6 +83,7 @@ misled. Full list in [`spec/README.md`](spec/README.md); the ones that carry a |---|---| | [`manifest-system.md`](spec/manifest-system.md) | the whole live → Git pipeline, and every invariant below in summary | | [`attribution.md`](spec/attribution.md) | **how a commit gets its author.** Deletion is attributed at intent time; the publish half files a fact under the strongest key it has, the join half walks the tiers strongest-first, and neither branches on the type. The single reference, folded from six design records | +| [`watch-event-ordering-and-attribution-grace.md`](spec/watch-event-ordering-and-attribution-grace.md) | How attribution waits and branch queues preserve per-watch order, and the limits across overlapping watches. | | [`current-manifest-support-review.md`](spec/current-manifest-support-review.md) | all-or-nothing folder claim; never half-write a multi-doc file; **refuse rather than prune** | | [`manifestedit-field-ownership-spike.md`](spec/manifestedit-field-ownership-spike.md) | the API wins — full-object ownership, never field-subset | | [`reconcile-via-watchlist-mark-and-sweep.md`](spec/reconcile-via-watchlist-mark-and-sweep.md) | **no bookmark, no sweep** | diff --git a/docs/architecture.md b/docs/architecture.md index 20b71d034..5df9e0523 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -110,8 +110,8 @@ designed but not built. request shape it will accept. **Redis/Valkey is optional but advised.** The default configured-author mode runs without it: a plain -`helm install` comes up healthy and watches cold-replay on restart. When an endpoint is configured, -Redis stores watch resume cursors (warm restarts) and the small coordination records used by +`helm install` comes up healthy and watches initialize from current state on startup. When an endpoint +is configured, Redis stores cursors for reconnecting running watches and the coordination records used by CommitRequest author capture and HA. Attribution does not require it on its own: its facts travel on a selectable transport, Redis Streams by default and an in-process ring with `--author-attribution-transport=memory`, which is refused with more than one replica. HA will require @@ -144,20 +144,19 @@ The easy part is "write YAML to Git." The hard parts shape the whole architectur The solution, in the vocabulary used throughout this document: -- **Watch is the only object-state source.** Each `GitTarget` opens one Kubernetes watch per claimed - `(GVR, scope)` with `sendInitialEvents=true`. The apiserver delivers a watch's events already ordered - by `resourceVersion` for that type, so there is nothing to re-order. Every Git write derives from - persisted state the watch observed. -- **Watches are per `GitTarget` and scaled by claims.** A watch opens only for the claimed ∩ followable - `(GVR, scope)` set, so cost scales with what `GitTarget`s claim, not with cluster type count. -- **Recovery prefers watch.** A new watch normally starts with `sendInitialEvents`, establishes a - current snapshot boundary, and runs a **mark-and-sweep**: any Git file whose object is no longer - present is deleted. When Redis has a fresh per-type cursor, the operator skips the snapshot and - resumes a normal watch from that resourceVersion. Cursors are keyed by `GitTarget` UID and carry a - TTL refreshed on every watch event and bookmark, so a live watch keeps its cursor warm while a deleted - one's cursor expires, and a stale resourceVersion (`410 Gone`) rebuilds from a fresh replay. - Older APIs that reject `sendInitialEvents` fall back to LIST plus buffered WATCH. The sweep fires on - snapshot establishment, **never on a timer**. +- **Watch is the only object-state source.** Each `GitTarget` selects eligible resource collections. + The planner chooses one served API version per group/resource/namespace collection and opens its + watch. Delivery order within one watch does not order arrivals across overlapping watches. +- **Watch cost follows selected collections.** The planner opens watches for selected, eligible + collections rather than every type in the cluster. All-namespace and named-namespace collections + remain separate when rules select both. +- **Initialization and reconnect differ.** The first attempt after startup or watch replacement + requests initial state for the new render-fidelity revision. Later attempts in the same watch + loop can use a stored resume RV; expired history requires fresh initialization. A completed + snapshot permits cleanup only within its observed collection and pruning policy. Cursors use + target UID, group/resource, and namespace, with a TTL refreshed by events and bookmarks. + Unsupported initial-event requests use LIST with a buffered watch. Snapshot reconciliation + is triggered by initialization, never by a timer. - **Audit, when enabled, only names the author.** It is an optional attribution lookup; a missing or late fact costs author fidelity, never correctness. With attribution disabled the product commits as the configured committer. With attribution enabled, an unresolved live change is visibly authored as @@ -514,11 +513,10 @@ attribution facts and appends them to that route's per-type fact stream. A follo reads the streams for the types it watches into a bounded, TTL'd in-process index. That index is read only by the resolver in step 3; it never creates or repairs object state. -**And if the watch had been lost?** A delete that happened while no watch was running is reconciled on -the next watch (re)connect: the `sendInitialEvents` replay plus **mark-and-sweep** removes any Git file -whose object no longer exists. See -[State Ingestion and Not Losing Deletes](#state-ingestion-and-not-losing-deletes). -No path silently drops a delete. +**And if the watch had been lost?** A reconnect can recover a retained deletion event from its +resume RV. When a fresh snapshot is needed, reconciliation can infer absence within its observed +collection; `Always` permits that cleanup, while `Never` and `OnEvent` retain snapshot orphans. +See [State Ingestion and Not Losing Deletes](#state-ingestion-and-not-losing-deletes). ### Normal commit flow @@ -615,45 +613,47 @@ declared placement policy, or a folder that kustomize builds from a single root. ## State ingestion and not losing deletes -This is the heart of the system. Object state is ingested by **watch**, and the guarantee is: every -persisted mutation observed while watching reaches Git, and no delete is ever silently dropped across a -gap. +Object state arrives through Kubernetes watches. Selected, relevant changes feed the writer; +recovery establishes current state when event history is unavailable. Git commit grouping and +pruning policy determine which intermediate states and removals appear in the repository. ### Watch is already ordered by resource version -The apiserver delivers a watch's events already ordered by `resourceVersion` for that type, so there is -**nothing to re-order**: a `MODIFIED` always lands after the create it modifies. Each event carries GVR, -scope, event type (`ADDED` / `MODIFIED` / `DELETED`, plus the transport `BOOKMARK` and `ERROR`), -namespace/name/UID/resourceVersion/deletionTimestamp, and the sanitized object body. The -`initial-events-end` bookmark marks the end of a replay, and an `ERROR` such as `410 Gone` triggers a -fresh `sendInitialEvents` reconnect. +One watch delivers live changes in order. Independent watch handlers can still interleave +observations of the same object; the [ordering contract](spec/watch-event-ordering-and-attribution-grace.md) +states that boundary. The manager derives resource identity and operation from the source event, +including deletion intent, then sanitizes content before routing it. + +Initial events establish current state, so their object RVs need not arrive in mutation order. +The annotated initial-events-end bookmark marks completion. Ordinary bookmarks report progress, +and an error such as `410 Gone` requires rebuilding from current state. ### Recovery: resume, replay, or list plus mark-and-sweep -Losing a watch (pod eviction, rollout, crash, `410 Gone`) is normal. When Redis has a cursor for the -watch shard, the next session first opens a normal watch from that resourceVersion. If the apiserver -can supply all events since that cursor, the watch continues from there. If the cursor is -expired, or no cursor exists, the watch opens with `sendInitialEvents=true` and -`ResourceVersionMatch=NotOlderThan`, so the apiserver streams current state as a replay of `ADDED` -events terminated by the `initial-events-end` bookmark. The operator runs a **mark-and-sweep** over -that replay: - -1. every replayed object is **marked** `ADDED`, up to `initial-events-end`; -2. at the bookmark, any Git file under that GitTarget whose object was **not** marked no longer exists, - so a `DELETED` is emitted for it (committer-authored, because the actual delete was never witnessed); -3. then the watch streams live events. - -**This mark-and-sweep is load-bearing and fires only on watch re-establishment, never on a timer**: -there is no periodic **object** LIST or hourly object-drift sweep, and no periodic `Namespace` LIST -in a source cluster either. The sweep is the only -thing that reconciles a delete that happened while no watch was running, so it is what makes the watch safe -to lose and restart. It is applied through the same per-type reconcile/writer machinery as live writes (see -[Mark and Sweep Resync](#mark-and-sweep-resync)). - -If the apiserver forbids `sendInitialEvents` for a type, the operator logs an explicit warning, starts a -normal watch, buffers its events, performs a LIST snapshot, runs the same scoped mark-and-sweep from -that list, and only then lets the buffered watch events through. This is the compatibility path for -older aggregated API servers that do not implement streaming lists. +The first attempt after process startup or watch replacement requests initial state, even when +Redis holds a cursor. `runTargetWatch` starts with `resumeFromCursor=false`: the new render-fidelity +revision needs fresh observation. Subsequent attempts in the same loop may open a normal watch +from the stored RV. If history has expired, or no usable cursor exists, initialization establishes +current state again. + +Streaming initialization uses `sendInitialEvents=true` and `resourceVersionMatch=NotOlderThan`: + +1. The manager collects current objects until the annotated initial-events-end bookmark. +2. It enqueues a snapshot reconciliation for that observed collection. Managed documents absent + from the desired set are removed only when `prune.mode: Always` permits inferred deletion; + `Never` and `OnEvent` retain them. Terminating objects are excluded from desired state by the + deletion-as-intent rule. +3. Subsequent live events enter the branch worker after the accepted snapshot request. This queue + order does not mean the snapshot's Git write has already completed. + +Reconciliation edits managed documents, which can share a YAML file. A snapshot for one namespace +cannot establish absence in another. A failed observation cannot establish absence anywhere. +See [Mark and Sweep Resync](#mark-and-sweep-resync). + +There is no periodic object LIST or timer-driven object-drift sweep. If the API server rejects +streaming initialization, `targetWatchListAndStream` first opens and buffers a watch, then takes +a LIST snapshot. It enqueues the same scoped reconciliation and processes buffered events newer +than the snapshot's RV. This ordering is the compatibility path for APIs without initial events. ### Relevance filtering is product code @@ -710,9 +710,9 @@ travel: | `memory` | an in-process ring | single replica, no Redis; every fact is lost on restart, by design | `memory` removes *attribution's* need for Redis. It does not on its own remove the install's: -`--redis-addr` also holds each `GitTarget`'s watch resume cursors, independently of attribution. So -dropping the Valkey StatefulSet as well means every watch cold-replays on restart instead of -resuming, across every target. +`--redis-addr` also holds watch resume cursors, independently of attribution. Without that cursor +store, reconnect attempts initialize from current state. The first attempt after a process restart +requests initial state with either configuration. | Endpoint | Role | |---|---| @@ -956,25 +956,28 @@ invalid `email` falls back to a derived address under `noreply.cluster.local`. ## Watch event ordering -The grace window is a per-event wait on a **single-threaded watch goroutine** (one goroutine per -`(GitTarget, GVR, scope)`), and the downstream `GitTargetEventStream → BranchWorker` path is a synchronous -FIFO. So: - -- **Same object / same type order is strictly preserved.** An object's events all flow through one watch - goroutine in `resourceVersion` order; the grace wait is head-of-line (it delays the next event, never - lets it overtake), so an older mutation can never overwrite a newer one. -- **The cost is throughput, not ordering.** A long wait stalls its own watch up to the grace window. -- **Unrelated objects on different types** (separate concurrent watches) may interleave or be grouped - into commits differently than wall-clock, and `resourceVersion` is not comparable across types anyway. - They are usually different files, but they *can* share one, as different documents of a multi-document YAML, - e.g. when a human or kustomize placed them together, so "different files" is not guaranteed. It still - does not affect the materialized state: every write carries its object's current state, all writes to a - branch funnel through one BranchWorker, and the editor patches each document in place without disturbing - its siblings. Only *which* commit each lands in and *when* can differ, and only within the commit window. - a few seconds at most. - -The full analysis, worked examples, and the future non-blocking option are in -[Watch event ordering under the attribution grace window](facts/watch-event-ordering-and-attribution-grace.md). +The grace window is a per-event wait on a **single-threaded watch goroutine**. Each running +collection watch uses one chosen GVR and namespace scope. The downstream +`GitTargetEventStream → BranchWorker` path preserves enqueue order in a FIFO. + +- **Order within one watch is preserved.** The loop resolves attribution and routes one event + before reading the next. Later events on that watch cannot overtake the attribution wait. +- **Independent watches can overlap.** An all-namespace watch and a named-namespace watch can + deliver the same object. Their concurrent handlers establish no common mutation order, and + the worker's FIFO does not sort their arrivals by `resourceVersion`. The + [target watch plan](design/target-watch-plan.md#where-the-bound-is-thin) records this limitation. +- **The wait affects throughput and grouping.** Each unresolved event can delay its watch by + the grace window. Accumulated queueing can add further delay, and different authors can split + commits. Intermediate object states can also collapse into one commit window. +- **Branch writes remain serialized.** Different objects can share a multi-document YAML file; + the branch worker edits their documents sequentially. That serialization prevents concurrent + file edits but does not establish source ordering across overlapping watches. RVs also do + not provide a common clock across resource types. + +The current processing model and worked examples are in +[Watch event ordering under the attribution grace window](spec/watch-event-ordering-and-attribution-grace.md). +For server-side selectors, initial state, bookmarks, and resume options, see +[Kubernetes watch options](facts/kubernetes-watch-options.md). *** @@ -1059,24 +1062,25 @@ catalog, registry, dynamic client, and reachability can fail or recover without source. Discovery trigger informers (CRDs / APIServices) run only in the control plane and accelerate its catalog refresh; remote source catalogs use the periodic refresh. -On `Start` it bootstraps the RuleStore from existing rules, refreshes the API catalog, updates the -TypeRegistry, builds watched type tables, and opens one watch per claimed ∩ followable `(GVR, scope)`. +On `Start` it bootstraps the RuleStore, refreshes the API catalog and TypeRegistry, resolves rule +selection, and builds the collection watch plan. ### Opening watches -On each GitTarget reconcile the controller resolves the GitTarget's claimed ∩ followable `(GVR, scope)` -set. Fully specified GVRs are claimed unconditionally; wildcard rules and rules without a version are -resolved fail closed against discovery. For each `(GVR, scope)` the manager runs one watch goroutine that -opens the watch with `sendInitialEvents=true`, folds the replay into a desired set, runs the -mark-and-sweep at `initial-events-end`, then streams live events through the relevance filter and the -author resolver into the GitTargetEventStream. On disconnect or `410 Gone` the goroutine reconnects and -repeats the replay + sweep. See +On each GitTarget reconcile, rule resolution combines selection with discovery and mirroring +eligibility. `targetWatchStreams` groups selected served versions by group/resource/namespace, +chooses one version per collection, and unions the current operation filters. The plan diff +keeps unaffected watches running and starts or replaces only changed entries. + +Each managed watch has one goroutine. Its first attempt requests initial state, enqueues scoped +snapshot reconciliation, and then routes live events through filtering and author resolution. +Later attempts may resume a cursor; see [Recovery: resume, replay, or list plus mark-and-sweep](#recovery-resume-replay-or-list-plus-mark-and-sweep). -There is **no periodic object LIST, object checkpoint, or timer-driven object-drift sweep**. The sweep -fires only on watch re-establishment. Selector-based source-namespace authorization is separate: it may -periodically list Namespace labels in the relevant source cluster, but that list never materializes objects -or triggers a Git sweep. +There is no periodic object LIST or timer-driven object-drift sweep. Source-namespace selection +uses delegated rule configuration and the source credential's permissions; it does not require +reading source Namespace labels. Control-cluster Namespace labels can govern `accessFrom`, which +is a separate provider-reference authorization boundary. ### Rule change reconcile @@ -1387,9 +1391,10 @@ by the validating webhook, not derived from the audit attribution index. Controllers watch their dependencies so dependents reconcile quickly after spec changes: - `GitTargetReconciler` watches `GitProvider`, `ClusterProvider`, `Namespace`, `WatchRule`, and - `ClusterWatchRule`. Provider readiness/spec changes and namespace-label changes promptly re-check - source authorization; rules re-declare the claimed `(GVR, scope)` set. It deliberately does **not** - watch encryption Secrets, so their recovery is picked up by periodic reconciliation without retaining + `ClusterWatchRule`. Provider changes re-check connection and authorization inputs; control-cluster + Namespace label changes re-check provider-reference access. Rule changes re-resolve selected + collections. It deliberately does **not** watch encryption Secrets, so their recovery is picked up + by periodic reconciliation without retaining every Secret value in the control-plane cache. - `WatchRuleReconciler` / `ClusterWatchRuleReconciler` watch `GitTarget` and `GitProvider`, populate the RuleStore, and trigger the rule-change reconcile. @@ -1422,7 +1427,7 @@ flowchart TD F --> G[Register WatchRule + ClusterWatchRule controllers] 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] + H -->|no| Hj[Skip cursor store; reconnects initialize again] Hi --> Hq{author-attribution?} Hj --> Hq Hq -->|yes| I[Select fact transport; start fact index + follower;
wire audit fact extractor + audit HTTP server + resolver] @@ -1435,9 +1440,10 @@ flowchart TD ``` Redis is optional in configured-author 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. The binary's `--author-attribution` flag defaults to +Redis readiness gate keeps the pod not-ready until Redis is reachable. Later reconnect attempts +can resume stored RVs. When `--redis-addr` is empty, the cursor store is skipped and reconnects +initialize again. The first attempt after startup or watch replacement requests initial state in +both cases. The binary's `--author-attribution` flag defaults to on: the fact transport is constructed, the fact index and its follower are started, 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`. Which transport it builds is `--author-attribution-transport`: `redis` @@ -1605,7 +1611,7 @@ Deeper dives live under [docs/design/](design/): the two halves, the tier ladder, and the wait. - [Attribution facts as a stream, not a keyspace](finished/attribution-fact-stream.md): the shipped transport seam, the in-process index, and what running without Redis costs. -- [Watch event ordering under the attribution grace window](facts/watch-event-ordering-and-attribution-grace.md) +- [Watch event ordering under the attribution grace window](spec/watch-event-ordering-and-attribution-grace.md) - [Reconcile via watchlist mark and sweep](spec/reconcile-via-watchlist-mark-and-sweep.md) - [CommitRequest design](spec/commitrequest-design.md) diff --git a/docs/definitions.md b/docs/definitions.md index 35bd1c504..3a21e8934 100644 --- a/docs/definitions.md +++ b/docs/definitions.md @@ -67,31 +67,51 @@ A sentence about namespace policy names one of those two. Never "namespace acces ## Read side -**Cell.** The unit of watching: a `(GitTarget, group, resource, namespace)` tuple. The served -version is carried as data, not as part of the identity, so a type served at two versions is one -cell and not two. One cell is one raw watch. "Cell" is the only word for this; it is not a "watch -target", a "type" or a "scope". - -**Stream.** The open watch behind one cell, and its readiness state: `Replaying`, `Streaming` or -`Blocked`. A stream is a thing that runs; a cell is a thing that is selected. `status.streams` on a -`GitTarget` or a rule counts by **type**, not by cell: one resource watched in three namespaces is -three cells and one entry in the count, in the weakest of their three states. - -**Replay.** The initial burst of events a watch opened with `sendInitialEvents=true` delivers before -`initial-events-end`. It is the API server's own term for it. - -**Cursor.** A stored `resourceVersion` a stream resumes from after a restart, so the restart does -not cold-replay. +Use Kubernetes' collection, watch, and initial-events vocabulary in explanatory prose; see +[Kubernetes watch options](facts/kubernetes-watch-options.md). Existing identifiers such as +`CellKey`, `SourceCell`, `Replaying`, and `status.streams` keep their current spelling until an +explicit code or API migration. This is an intentional transition, not a runtime behavior change. + +**Resource collection.** Objects of one group/resource within a namespace selection. In the +current planner, [`CellKey`](../internal/types/cell.go) contains `(group, resource, namespace)`; +its containing `GitTarget` supplies target and source-cluster context. The served API version is +separate data. An all-namespace collection can overlap a named-namespace collection. `Cell` is +the legacy project name for this boundary. A collection is distinct from both its resource type +and that type's namespaced or cluster-scoped classification. + +**Watch.** The managed observation of a resource collection through Kubernetes watch requests. +It can initialize, stream events, lose a connection, and reconnect. The implementation currently +tracks `Replaying`, `Streaming`, and `Blocked` states. Write `WatchRule` or `ClusterWatchRule` +when naming configuration kinds; a rule selects objects, while a watch observes them. + +**Watch stream.** Events delivered by an open watch connection. `Stream` is also used in legacy +implementation names for the managed watch. `status.streams` counts **resource types**: watching +one type in three namespaces produces three collection watches but one status entry, using the +weakest collection state. Changing the vocabulary does not change that public field's unit. + +**Initial events.** Synthetic events representing the current selected objects, requested with +`sendInitialEvents=true` and completed by the `k8s.io/initial-events-end` bookmark. Kubernetes +calls this streaming lists. The implementation's `replay` names refer to this initialization; +they do not imply a history of every mutation. + +**Resume resourceVersion.** An observed position used to reconnect a running watch. The code +and storage call it a cursor. The first attempt after startup or watch replacement initializes +current state even if a stored cursor exists; later attempts in that watch's loop may resume. +A cursor records observation progress, not successful Git publication. **Catalog.** One source cluster's normalized discovery result. It holds no judgment about whether a type should be watched. -**Followable.** A verdict about a type, from the relevance funnel: can this type be watched at all. -Separate from **claimed**, which is a verdict about a rule: does any rule select this type. A cell -exists where claimed and followable meet. +**Eligible for mirroring.** The product verdict called `followable` in code: discovery and +mirroring policy permit the type. Kubernetes watch support alone is insufficient. **Selected +by a rule** is the demand called `claimed` in code. The current planner opens watches where +selection and eligibility meet. -**Sweep.** The mark-and-sweep at the end of a replay that deletes managed documents the source no -longer has. A sweep acts on intent, never on an inability to observe. +**Snapshot reconciliation.** Applying an observed collection to managed Git documents within +that collection. Its sweep can remove documents inferred absent only when pruning policy permits +it; `Always` enables this cleanup, while `Never` and `OnEvent` retain snapshot orphans. +Observation failure supplies no evidence of absence. The code calls this write-side operation +`resync`; client-go informer resync instead notifies handlers about cached objects. ## Write side @@ -325,8 +345,9 @@ outage. |---|---|---| | sync, export, back up | mirror | "sync" implies two-way convergence, which this is not | | repo path, directory | folder | one word for the thing a `GitTarget` owns | -| watch target, type, scope (for a watched unit) | cell | "type" and "scope" are both already taken | -| initial sync, backfill | replay | it is the API server's own term | +| cell, watch target, type, scope (for the object set) | resource collection | identifies the object set without overloading resource type or resource scope | +| stream (for the managed lifecycle) | watch | reserve watch stream for delivered events; configuration kinds keep their full names | +| initial sync, backfill, replay (for current state) | initial events or initialization | follows Kubernetes streaming-list terminology | | git worker, commit worker | branch worker | the tuple it owns is keyed by branch | | attribution (bare) | author attribution, render attribution | two unrelated concepts share the word | | `GitRepoConfig` | `GitProvider` | the kind was renamed; the string survived in two reasons | diff --git a/docs/design/commitrequest-save-wait-options.md b/docs/design/commitrequest-save-wait-options.md index f267cbf39..f7236d35b 100644 --- a/docs/design/commitrequest-save-wait-options.md +++ b/docs/design/commitrequest-save-wait-options.md @@ -502,7 +502,7 @@ the participant's message on the commit. code depends on it, so each phase changes that page in the same commit. - [`../spec/commitrequest-admission-authorship.md`](../spec/commitrequest-admission-authorship.md), where the submitter comes from and why the lookup is present-or-never. -- [`../facts/watch-event-ordering-and-attribution-grace.md`](../facts/watch-event-ordering-and-attribution-grace.md), +- [`../spec/watch-event-ordering-and-attribution-grace.md`](../spec/watch-event-ordering-and-attribution-grace.md), the head-of-line wait this page proposes to wait behind rather than shorten. - [`attribution-removal-wait-options.md`](attribution-removal-wait-options.md), the same shape of question on the attribution side: what evidence is worth waiting for, and for how long. diff --git a/docs/design/data-plane-triggering.md b/docs/design/data-plane-triggering.md index 4d30f4de1..6762bcfa3 100644 --- a/docs/design/data-plane-triggering.md +++ b/docs/design/data-plane-triggering.md @@ -264,7 +264,7 @@ same question for the control plane. the model was arrived at, and [Reconcile via WatchList and mark-and-sweep](../spec/reconcile-via-watchlist-mark-and-sweep.md) is the mechanism §4 depends on. -[Watch event ordering under the attribution grace window](../facts/watch-event-ordering-and-attribution-grace.md) +[Watch event ordering under the attribution grace window](../spec/watch-event-ordering-and-attribution-grace.md) has the worked examples. **Scopes and streams.** A sweep is bounded by the exact slice its snapshot was diff --git a/docs/design/watches-labels-simplification.md b/docs/design/watches-labels-simplification.md new file mode 100644 index 000000000..29953dba4 --- /dev/null +++ b/docs/design/watches-labels-simplification.md @@ -0,0 +1,383 @@ +# Resource collections and watches: simplifying the operator model + +Review and proposal based on repository commit `65233aaf`, inspected on 2026-09-28. +The definitions now adopt Kubernetes terminology. Internal type changes, object selectors, and +the design decisions below remain proposed; source references describe current behavior. + +Use **resource collection** for the observed object set and **watch** for its observation lifecycle. +Keep compiled rule selection consistent through live processing and Git reconciliation. Include +[issue #146: per-rule objectSelector](https://github.com/ConfigButler/gitops-reverser/issues/146) +before fixing the replacement key and plan shape. Label selection is absent from the current API. + +This proposal builds on [Definitions](../definitions.md), [Architecture](../architecture.md), +and [Configuration](../configuration.md). Kubernetes behavior and upstream evidence live in +[Kubernetes watch options](../facts/kubernetes-watch-options.md); this document owns the +operator-specific interpretation and design choices. + +## Design priorities + +Kubernetes terminology and API semantics are the design target. Prefer its existing resource +identity types, label selectors, list/watch behavior, and controller conventions wherever they +express the required behavior. Project-specific concepts need a responsibility that those +primitives do not already cover. + +The project is pre-1.0, and a larger refactor is acceptable. Judge alternatives by the clarity +and complexity of the resulting model, including its failure and recovery paths. Internal +types, controller boundaries, API fields, and observability names can change together when +that produces a more coherent design. Record migration and versioning requirements as part +of the change; preserving the existing structure is not a design goal. + +Correctness, source authorization, and explicit selection and pruning semantics remain +requirements. Existing types and tests are evidence about those requirements, not a mandate +to retain every current abstraction or behavior. `CollectionKey` below is a candidate whose +need and shape must be tested against the resolved selection model. + +## Kubernetes facts this proposal relies on + +| Reference | Design question it informs | +|---|---| +| [Watch endpoints and namespace scope](../facts/kubernetes-watch-options.md#what-one-watch-covers) | What belongs in a collection identity? | +| [Label selectors](../facts/kubernetes-watch-options.md#label-selection) | How should rule selection compile and combine? | +| [Membership event semantics](../facts/kubernetes-watch-options.md#event-types-describe-membership-in-the-selected-collection) | What do entry and exit mean for operations, attribution, and pruning? | +| [Initial state and recovery](../facts/kubernetes-watch-options.md#initial-state-progress-and-recovery) | When is a new snapshot required, and what does a cursor prove? | +| [Selectors and authorization](../facts/kubernetes-watch-options.md#selectors-and-authorization) | When is broader observation authorized? | +| [Other capabilities and limits](../facts/kubernetes-watch-options.md#other-capabilities-and-limits) | What work remains in the operator even with server-side filtering? | + +Those references hold the selector syntax, request options, and event-transition details. +The proposal below uses them without repeating the protocol explanation. + +## The architectural story + +The proposed default observes a complete group/resource/namespace collection and evaluates +object selectors locally. Use Kubernetes resource identities and `metav1.LabelSelector`, with +one resolved watch plan carrying complete rule clauses. Broad observation preserves evidence +about objects that exist but are not selected; only selected desired content is written. + +```mermaid +flowchart TD + D[Discovery and mirroring policy] --> P[Resolved collection watch plan] + R[Authorized rules and compiled clauses] --> P + P --> W[Watch the complete permitted collection] + W --> O[Source presence and deletion intent] + W --> S[Local rule selection] + O --> C[Policy-controlled snapshot reconciliation] + S --> C + S --> L[Accepted live writes] + C --> B[Branch worker queue] + L --> B +``` + +This is the proposed selector design. Current watches already observe broadly, but do not carry +object-selector clauses or separate presence evidence from selected desired state. Source +readiness, queue acceptance, and Git publication remain distinct observations. + +## Model selection independently of watch connections + +[`CellKey`](../../internal/types/cell.go) contains `Group`, `Resource`, and `Namespace`; `Matches` +tests resource membership. It carries no connection or runtime state. Snapshot cleanup, +render-fidelity results, and queued-event provenance all use this boundary while watches can be +blocked, disconnected, or replaced. Naming every use a stream would obscure that responsibility. + +`CollectionKey` would identify the version-independent observed collection. The containing +`GitTarget` supplies target and source-cluster context. Local selectors belong in its plan +specification; they do not require separate transport keys. A selected subset remains a distinct +write-policy boundary even when several rules share that observation. + +Three distinctions must remain explicit in the resulting model: + +- **Collection identity and served version.** `targetWatchStreams` chooses one served version + per collection and combines operations; `cellSpec` stores that version separately. A version + change restarts the watch without changing membership. `ResyncScope` likewise separates + `CellKey` from `Version`. A requested served version is not necessarily the storage version. +- **Resource scope and namespace selection.** An empty namespace selects all namespaces for a + namespaced type; discovery distinguishes this from a cluster-scoped type. `WatchRule` selects + namespaced resources, including `sourceNamespace: "*"`; `ClusterWatchRule` selects + cluster-scoped resources. See [source namespace configuration](../configuration.md#watching-a-different-source-namespace). +- **Eligibility and API capability.** [`Evaluate`](../../internal/typeset/funnel.go) checks + discovery trust, verbs, origin, sensitivity, and policy. Calling its verdict `watchable` would + conceal mirroring restrictions beyond Kubernetes' support for watches. + +The earlier reason for `cell` was that `type` and `scope` already had meanings. **Resource +collection** avoids both overloads and is Kubernetes vocabulary. Reserve resource type for +`GroupResource`, resource scope for namespaced versus cluster-scoped, and namespace selection +for the collection's namespace extent. `WatchRule` names a configuration kind; **watch** names +the API activity. Using each kind's full name avoids the proposed naming collision without +inventing another word for Kubernetes watches. + +## Suggested vocabulary + +| Current wording or identifier | Candidate | Meaning | +|---|---|---| +| Cell / `CellKey` | Resource collection / `CollectionKey` | Observed object set, independent of locally selected subsets | +| `CellKeyFor`, `SourceCell` | `CollectionKeyFor`, `SourceCollection` | Construction and provenance of that boundary | +| `cellSpec` | `watchSpec` | Served version and compiled selection policy | +| Claimed | Selected by a rule | User demand | +| Followable | Eligible for mirroring | Discovery and product-policy verdict | +| Stream, for running activity | Watch | Managed observation lifecycle | +| Stream, for delivered data | Watch stream | Events from a connection | +| `GitTargetEventStream` | `TargetEventForwarder` | Forwarding adapter with no watch connection or event buffer | +| Replay, for startup | Initialization / initial events | Establishing current state | +| Cursor | Resume `resourceVersion` | Position used for reconnecting | +| Resync, for Git work | Snapshot reconciliation | Applying the observed collection to managed documents | + +The definitions contract now accepts this prose vocabulary; implementation names are migration +candidates. Public fields, conditions, metrics, and enum values can follow the same redesign, +with their migration impact recorded. +Client-go informer resync has different semantics from this project's snapshot-carrying +`ResyncRequest`; the proposed wording makes that distinction explicit. + +## Current implementation and source references + +| Responsibility | Source and behavior | +|---|---| +| Discovery and eligibility | [API resource catalog](../../internal/watch/api_resource_catalog.go) normalizes discovery; [type registry](../../internal/typeset/registry.go) tracks eligibility and uncertain disappearance. | +| Rule compilation and resolution | [Rule store](../../internal/rulestore/store.go), [resolver](../../internal/watch/watched_type_resolver.go), and [watched-type table](../../internal/watch/watched_type_table.go) resolve target coverage and combine namespace operations. | +| Watch planning | [`targetWatchStreams`](../../internal/watch/target_watch.go) chooses versions; [`diffTargetWatchPlans`](../../internal/watch/target_watch_plan.go) classifies keep, start, restart, and stop. | +| Initialization and resume | `targetWatchReplayAndStream` requests initial events, `NotOlderThan`, and bookmarks; `targetWatchResumeAndStream` uses a stored RV and bookmarks. | +| Compatibility fallback | `targetWatchListAndStream` buffers an opened watch before LIST, applies the snapshot, then keeps events newer than its watermark. | +| Endpoint and event processing | Callers of `openTargetWatch` and `openTargetList` supply no label or field selectors. `routeLiveTargetWatchEvent` applies operation policy, skips unchanged sanitized updates, resolves attribution, and routes events. | +| Event forwarding | [`GitTargetEventStream`](../../internal/reconcile/git_target_event_stream.go) enqueues live events on the branch worker. | +| Snapshot application | [`ResyncScope`](../../internal/git/types.go) and [resync flush](../../internal/git/resync_flush.go) bound policy-controlled writes and cleanup. | +| Render evidence | [Render-fidelity gate](../../internal/git/render_fidelity_gate.go) associates results with the collection and watch revision. | + +Function names without a separate link above are in `target_watch.go`. + +## Include object selection before fixing the model + +Issue #146 requests `metav1.LabelSelector` on each rule item. Its Cozystack example selects +Secrets labeled `internal.cozystack.io/tenantresource=true`. A label-defined application can use +several selected resource types; no additional resource-group kind is needed. + +The issue sketch predates the watch-first architecture. Translate its audit filtering, +`namespaceSelector`, `v1alpha1`, and namespaced `ClusterWatchRule` examples to the current model: +watch-supplied object state, optional audit attribution, and `WatchRule.sourceNamespace`. + +### Proposed API contract + +Add optional `objectSelector` to both +[`ResourceRule`](../../api/v1alpha3/watchrule_types.go) and +[`ClusterResourceRule`](../../api/v1alpha3/clusterwatchrule_types.go), evaluated on source object +labels before sanitization. This example is proposed and is not accepted by the current CRD: + +```yaml +apiVersion: configbutler.ai/v1alpha3 +kind: WatchRule +metadata: + name: tenant-handoff-secrets + namespace: tenant-a +spec: + gitTargetRef: + name: tenant-config + rules: + - apiGroups: [""] + apiVersions: ["v1"] + resources: ["secrets"] + operations: [CREATE, UPDATE, DELETE] + objectSelector: + matchLabels: + internal.cozystack.io/tenantresource: "true" +``` + +Use the standard selector semantics linked above. Define omitted and empty selectors as matching +all otherwise permitted objects, normalizing the helper's nil behavior explicitly. Reject invalid +selectors; a valid empty result initializes successfully. Existing authorization, mirroring +eligibility, and Secret encryption requirements remain applicable. + +### Preserve complete selection clauses + +Within a rule, type, namespace, and object selection must all match. Across rules, accept an +event if any complete clause accepts it. For example: + +| Selector | Operations | +|---|---| +| `team=a` | `UPDATE` | +| `team=b` | `DELETE` | + +Independent unions would wrongly permit updates to `team=b` and deletes of `team=a`. Preserve +and compile each `(object selector, operation filter)` pair. A broad selector on one rule must +not erase another rule's operation restrictions. + +`foldTargetReplayEvent` and the LIST fallback construct desired state separately from live +operation filtering. Apply object selection to both snapshot paths as well as live events; +keep presence evidence for unselected objects instead of dropping them from all snapshot inputs. + +### Decision: observe broadly and filter locally + +Use one raw watch for each resolved base collection, with local matching of complete clauses. +This choice exposes label changes as ordinary object updates and keeps a complete inventory +for snapshot reconciliation. It also permits a direct comparison with standard client-go +reflectors or informers; adopting Kubernetes terms does not require server-side label filtering. + +```text +observation key: group/resource + namespace selection, within one GitTarget/source context +watch spec: chosen served version + canonical namespace/selector/operation clauses +snapshot input: complete source presence + selected desired objects + eligible prune candidates +runtime state: watch cancellation + observation progress + selection/render revision +``` + +Native RBAC does not provide label-based object permissions, so the extra cost on such sources +is traffic, decoding, and state for unselected objects. However, authorization webhooks can +inspect selectors, as the facts explain. This design requires permission to observe the broad +collection; a denied request remains an observation failure. It does not silently downgrade +a selector-restricted source to an apparently complete empty snapshot. + +Server-side filtering remains a later optimization, with equivalent exit and absence evidence +required first. A single-clause collection still has those requirements. Metadata-only watches +omit manifest content, and sharding does not express the selected label groups. + +### Compile clauses into one resolved plan + +`targetWatchStreams` currently unions `OperationSet` values, and `operationSpec` renders only +operations. Replace this flattening with complete clauses carried from the rule store through +resolution and the watch plan. Include selectors, operations, and namespace restrictions in +canonical comparison; normalize requirement/value ordering and clause order. + +A semantic clause edit restarts initialization under a new selection/render revision, even if +its observation key stays the same. This observes unchanged objects that newly match. Preserve +unaffected watches and avoid restarts for mechanical reorderings. These are central change +points, but compilation, snapshot payloads, matching, and write-side cleanup also change. + +The broader refactor can remove intermediate table/spec conversions and forwarding adapters +where their responsibilities collapse. Neither `CellKey` nor `ResyncScope` must retain its +current shape merely to make #146 a small patch. Preserve authorization and operation clauses +when considering shared all-namespace and named-namespace observation; local label filtering +alone does not remove that existing overlap. + +### Decision: retain deselected content and preserve deletion intent + +Operation filters describe source lifecycle changes inferred from unfiltered watches, including +updates made by PATCH. They cannot promise exact audit verbs. In particular, the live entry point +is `operationForLiveTargetWatchEvent`: an object carrying `deletionTimestamp` becomes `DELETE` +before physical removal. [`desiredFromObject`](../../internal/watch/scope_resolve.go) excludes +terminating objects from snapshot desired state for the same reason. + +Adopt these policies for #146: + +- Label entry is an `UPDATE` on an existing object, subject to the matching clause's operations; + initialization can still establish its current desired state. +- Label exit or selector removal retains the last mirrored document. It does not synthesize a + delete or overwrite it with excluded content. Another matching rule can continue managing it. +- A source deletion or deletion intent remains eligible for removal under the applicable rule + and pruning policy. Determine that before treating an event as ordinary label deselection; + preserve the membership evidence needed to apply the correct clause. +- `Always` may infer cleanup for eligible managed objects absent from a complete observation, + including logically absent terminating objects. A present, non-terminating, unselected object + is retained. `Never` and `OnEvent` do not infer deletion from snapshot absence. + +Retained manifests can restore a removed label on a later GitOps apply. That is a documented +consequence of retaining content. No cleanup is inferred outside active authorized observation, +and withdrawal of a rule must not turn its previously covered collection into an empty snapshot. + +### Separate source presence from selected desired state + +Broad observation supplies the evidence, but the existing writer cannot yet use it this way. +`ResyncScope.Matches` tests resource identity, and `resyncPlan` interprets absence from `Desired` +as potential deletion. Passing only selected objects would still turn present-but-unselected +objects into prune candidates. Passing every object as desired would instead write excluded +content. Extend the snapshot/planner contract to distinguish those two inputs. + +Pruning also needs explicit evidence of which documents the target manages under this selection. +A document must not become eligible merely because its group/resource/namespace matches; that +would expose never-selected documents to cleanup. Preserve or reconstruct membership evidence +across selector edits and restart, and protect objects still covered by another rule. Unknown +membership or incomplete observation cannot authorize deletion. See +[deletion policy](../configuration.md#deletion-policy-specprunemode). + +### Selection revisions still matter for recovery + +Local filtering lets the transport cursor remain keyed by the observed base collection. The +current Redis implementation takes a GVR argument but persists target UID, group/resource, and +namespace; [`watchCursorKey`](../../internal/queue/redis_store.go) deliberately drops the version. +There is no need for one cursor per local selector. Selection changes still need fresh desired +state and render evidence, so canonical clause changes must invalidate initialization results. + +The only production cursor lookup consumer found is `targetWatchReplayAndStream`. Its first +attempt ignores the returned cursor for resume; later loop attempts may use it. Persistent +cursors therefore provide no normal warm-start shortcut. They can still be consulted after a +failed first initialization, before a fresh cursor has replaced the old value. Test that path +against render readiness before replacing the store with an in-memory cursor. Cursor durability +also does not make accepted Git writes durable. Redis has other attribution and coordination +roles, so simplifying this store does not by itself remove Redis from every deployment. + +## Ordering and attribution belong to the implementation contract + +The [attribution-grace explainer](../spec/watch-event-ordering-and-attribution-grace.md) owns the +current call sequence and its limits. Inline author resolution preserves enqueue order within +one watch. The branch worker serializes arrivals but does not merge overlapping watches by RV; +all-namespace and named-namespace watches can both deliver one object. + +The [target watch plan](../design/target-watch-plan.md#where-the-bound-is-thin) already records +that gap. Architecture now states the per-watch boundary. Label selection extends the overlap +question, so sharing or stale-event handling needs its own guarantee and tests. This review +identifies the missing guarantee without claiming a new runtime reproduction. + +The spec now retains the ordered-release requirement for any concurrent attribution work. +The definitions and architecture also distinguish collection keys from target context and +first-attempt initialization from later cursor resume. + +## Follow-up implementation checks + +- `streamStatusesByType` folds collection state into resource-type counts, while the open-watch + gauge counts connections. Preserve explicit units when redesigning status. +- Source comments on `NamespaceOps`, `ClusterWide`, and `WatchScopes` still describe older rule + semantics; update them with the code refactor. The architecture now distinguishes source + namespace selection from control-cluster provider access. +- Verify cursor failure/reconnect paths and the in-memory write queue before promising startup + resume or durable delivery; see [write-queue durability](../architecture.md#durability-of-the-write-queue-planned). + +## Simplification and implementation sequence + +1. Define the target model using Kubernetes concepts and resolve issue #146's membership, + operation, attribution, retention, and recovery contract. Identify the responsibilities that + still require project-specific types. +2. Prototype the broad-observation plan with complete clauses and separate snapshot inputs. + Compare native client-go building blocks with custom watch management on correctness, + recovery, state, and memory costs. Include overlapping-source ordering in that comparison. +3. Inventory status, metrics, version resolution, and manifest-analysis consumers. Replace + redundant representations and adapters as part of the coherent refactor; a preliminary + mechanical rename is optional. +4. Apply the accepted model across snapshot and live paths, API and status naming, and + documentation. Record intentional behavior changes and their migration requirements. +5. Validate the required invariants and selector scenarios below. Make ordering and restart + durability guarantees explicit, including any remaining gaps. + +The current source path uses dynamic watches and temporary snapshots; control-plane informers +serve a different purpose. Existing library choices do not constrain the target design. +Broader changes, including CRDs or deployment components, should each explain the responsibility +they simplify and how the resulting model meets its correctness and authorization requirements. + +## Invariants and acceptance tests + +| Invariant | Existing test reference | +|---|---| +| Two served versions resolve to one collection watch | `TestTargetWatchStreams_OneStreamPerCellAcrossServedVersions` in [target watch tests](../../internal/watch/target_watch_test.go) | +| All-namespace and named-namespace selections retain their filters | `TestTargetWatchSpecs_NamedAndClusterWideScopesStayDistinctStreams` in the same file | +| Unchanged watches survive unrelated rule changes | `TestReplaceGitTargetWatches_AddingARuleStartsOnlyTheNewCell` in the same file | +| Replacement initializes before live delivery | `TestReplaceGitTargetWatches_ReusesUnchangedSetAndRestartsOnSpecChange` in the same file | +| Served-version changes preserve collection identity | `TestTargetWatchPlanFor_AServedVersionBumpKeepsTheSameCell` in [plan tests](../../internal/watch/target_watch_plan_test.go) | +| Cleanup preserves sibling namespaces | `TestResync_NamespaceScopedSweepLeavesSiblingNamespacesAlone` in [scope tests](../../internal/git/resync_scope_test.go) | +| Pending collections hold back type readiness | `TestStreamSummaryForWatchRule_OnePendingNamespaceHoldsTheTypeBack` in [readiness tests](../../internal/watch/source_namespace_stream_summary_test.go) | + +Also preserve revision checks, observation failure versus authoritative absence, and +[source provenance](../../internal/git/source_cell.go): queued provenance is diagnostic, and +cancellation occurs at the producer rather than by filtering queued writes against the latest plan. + +Issue #146 needs additional tests for: + +- Omitted, empty, invalid, and zero-match selectors; consistent initial-event, LIST, and live selection. +- Label entry and exit under the agreed policies, including `deletionTimestamp` on a membership change. +- Snapshots retain present unselected objects without writing their content, protect never-selected + documents, and handle terminating objects consistently with live deletion intent. +- Overlapping clauses, preservation of operation filters, and retention while another rule selects. +- Selector edits include unchanged existing objects and invalidate old render evidence; equivalent + clause reorderings keep watches running. Shared base-collection cursors remain independent of labels. +- Recovery of membership after restart, failed first initialization with a stored cursor, source + authorization including rejected broad watches, and Secret encryption constraints. + +These are proposed implementation checks. This documentation update does not implement selectors +or establish their runtime correctness. Run `task lint-docs` and Vale for documentation changes; +follow [AGENTS.md](../../AGENTS.md) for later code and watch-plumbing validation. + +Keep this working proposal local as requested. When implementation is ready, put the accepted +argument and behavior changes in the #146 PR description or a tracked design record so reviewers +can assess them without access to this file. diff --git a/docs/facts/kubernetes-watch-options.md b/docs/facts/kubernetes-watch-options.md new file mode 100644 index 000000000..17cb6e4b5 --- /dev/null +++ b/docs/facts/kubernetes-watch-options.md @@ -0,0 +1,231 @@ +# Kubernetes watch options and selection semantics + +> **reference:** Kubernetes watch requests, filtering, and event semantics. +> Index: [`../INDEX.md`](../INDEX.md) +> +> Checked: 2026-09-28. Source links are pinned to upstream release tags. +> Feature availability depends on the API server version, feature gates, and API implementation. +> Client library support alone does not establish server support. + +**A Kubernetes watch can filter objects by label on the server.** The `labelSelector` query +parameter applies to LIST and WATCH requests. It selects objects of the requested resource +type, such as ConfigMaps labeled `app=payments`. See Kubernetes +[LIST and WATCH filtering](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#list-and-watch-filtering). + +Related Kubernetes references: + +| Document | Topic | +|---|---| +| [Resource versions](resource-versions.md) | Snapshot watermarks, resume points, and expired history. | +| [API resource catalog](kubernetes-api-resource-catalog.md) and [API discovery](kubernetes-api-discovery.md) | Which resource endpoints exist and advertise `list` and `watch`. | +| [Subresources](subresources.md) | How endpoints such as `/status` and `/scale` relate to the parent object. | + +## What one watch covers + +A watch is a request against one resource endpoint, identified by group, version, and resource +(GVR). For a namespaced type, the URL chooses one namespace or all namespaces. For example: + +```text +/api/v1/namespaces/team-a/secrets +/api/v1/secrets +/apis/apps/v1/namespaces/team-a/deployments +``` + +The first two address the same resource type with different namespace scopes. A label selector +further narrows the objects returned. Labels do not select API groups or discover resource types; +watching labeled Secrets and ConfigMaps still requires two resource endpoints. + +Namespace scope, label selector, and field selector intersect: an object must satisfy all of +them. Watching objects in namespaces whose **Namespace objects** have particular labels needs +separate namespace discovery and selection. There is no `namespaceSelector` watch parameter. +See the upstream [resource URI reference](https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-uris) +and the server +[`SelectionPredicate.Matches`](https://github.com/kubernetes/apiserver/blob/v0.37.0/pkg/storage/selection_predicate.go#L96) +implementation, which combines label and field predicates. + +## Selectors and authorization + +Kubernetes RBAC does not match requested objects by label. Its +[`RuleAllows` implementation](https://github.com/kubernetes/kubernetes/blob/v1.36.1/plugin/pkg/auth/authorizer/rbac/rbac.go#L178) +matches resource requests by verb, API group, resource, and name. A label selector therefore +does not create a label-based RBAC permission boundary. RBAC restrictions using `resourceNames` +do require a matching `metadata.name` field selector for LIST and WATCH; see +[resource-name restrictions](https://kubernetes.io/docs/reference/access-authn-authz/rbac/#referring-to-resources). + +Authorization mechanisms can differ. Kubernetes passes label and field selectors to +[authorization webhooks](https://kubernetes.io/docs/reference/access-authn-authz/webhook/), +which may use them in access decisions. See +[`ResourceAttributes.LabelSelector`](https://github.com/kubernetes/api/blob/v0.37.0/authorization/v1/types.go#L127) +and the [webhook request conversion](https://github.com/kubernetes/apiserver/blob/v0.37.0/plugin/pkg/authorizer/webhook/webhook.go#L320). +Authorization of a filtered request therefore does not universally imply authorization of an +unfiltered request. Each request remains subject to the configured authorizer chain. + +## Request options + +The REST query parameters below correspond to +[`metav1.ListOptions`](https://github.com/kubernetes/apimachinery/blob/v0.37.0/pkg/apis/meta/v1/types.go#L361). +The dynamic client's +[`Watch` method](https://github.com/kubernetes/client-go/blob/v0.37.0/dynamic/simple.go#L264) +sets `watch=true` itself. Options with feature dependencies need a compatible +server; discovery's `watch` verb alone does not establish support for every option. + +| Option | What it controls | Important boundary | +|---|---|---| +| `watch=true` | Streams changes to the selected collection. | One request addresses one GVR and namespace scope. | +| `labelSelector` | Selects objects by `metadata.labels`. | Empty selects everything within the other constraints. | +| `fieldSelector` | Selects objects by supported field values. | Supported fields depend on the resource type. | +| `resourceVersion=R` | Resumes changes after an observed collection version. | An expired version requires rebuilding current state. | +| `sendInitialEvents=true` | Starts with synthetic `ADDED` events representing current selected objects, then streams changes. | Requires streaming-list support and `resourceVersionMatch=NotOlderThan`. | +| `resourceVersionMatch=NotOlderThan` | Sets the freshness bound for initial events. | With an unset RV, initial state is at least as fresh as the start of request processing. Ordinary cursor-resume watches omit this option. | +| `allowWatchBookmarks=true` | Requests `BOOKMARK` progress events. | Ordinary bookmarks have no guaranteed frequency or delivery. | +| `timeoutSeconds` | Bounds the request's total duration. | Activity does not reset it; the client still needs reconnection logic. | +| `shardSelector` | Selects hash ranges of supported metadata fields. | Alpha, gated by `ShardedListAndWatch`; see below. | +| `limit`, `continue` | Paginate a LIST. | These are not watch pagination or event-count controls. | + +The [upstream option validation](https://github.com/kubernetes/apimachinery/blob/v0.37.0/pkg/apis/meta/internalversion/validation/validation.go#L53) +defines legal combinations. `resourceVersionMatch=Exact` applies to LIST freshness; watches +use the resume and initial-state modes described above. + +## Label selection + +For example, these options request the current matching ConfigMaps followed by live changes +on a namespaced ConfigMap endpoint: + +```go +sendInitial := true +opts := metav1.ListOptions{ + LabelSelector: "app=payments", + SendInitialEvents: &sendInitial, + ResourceVersionMatch: metav1.ResourceVersionMatchNotOlderThan, + AllowWatchBookmarks: true, +} +``` + +The [label selector syntax](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#label-selectors) +supports equality (`app=payments`), inequality (`app!=payments`), set membership +(`environment in (production,staging)`), exclusion (`tier notin (frontend)`), existence +(`backup`), and absence (`!backup`). Comma-separated requirements are ANDed. Set membership +offers alternatives for one key; arbitrary OR between complete selectors is unsupported. +Negative requirements also match objects missing the key, so use an existence requirement +when absence should be excluded. + +A CRD field of type `metav1.LabelSelector` expresses the same selection through `matchLabels` +and `matchExpressions`. Convert it to the query string with +[`metav1.LabelSelectorAsSelector`](https://github.com/kubernetes/apimachinery/blob/v0.37.0/pkg/apis/meta/v1/helpers.go#L36). +An optional field needs an explicit default: that helper treats a nil selector as matching +nothing and an empty selector as matching everything. + +### Event types describe membership in the selected collection + +The upstream [`watch.Event` types](https://github.com/kubernetes/apimachinery/blob/v0.37.0/pkg/watch/watch.go#L54) +are `ADDED`, `MODIFIED`, `DELETED`, `BOOKMARK`, and `ERROR`. Bookmarks and errors report +progress and stream status. Object events on a filtered watch report transitions into, within, +and out of its result set: + +| Matched before? | Matches after? | Watch event | +|---|---|---| +| No | No | None. | +| No | Yes | `ADDED`, including an existing object gaining a matching label. | +| Yes | Yes | `MODIFIED` for an object update. | +| Yes | No | `DELETED`, including a label change that stops matching. | + +An actual object deletion also yields `DELETED` when the object previously matched. Therefore, +`DELETED` alone cannot distinguish deletion from leaving the selector. Synthetic initial +`ADDED` events likewise do not prove that objects are newly created. + +The kube-apiserver +[`cacheWatcher.convertToWatchEvent` implementation](https://github.com/kubernetes/apiserver/blob/v0.37.0/pkg/storage/cacher/cache_watcher.go#L373) +evaluates both old and new membership. For a membership exit, it sends the **previous object +content with the new event's resourceVersion**. Rechecking the labels on that payload does +not establish whether the object still matches in the cluster. + +A label-changing PATCH can produce `ADDED` or `DELETED`; watch event types do not identify +the original API verb or the request actor. Kubernetes +[auditing](https://kubernetes.io/docs/tasks/debug/debug-cluster/audit/) records request information +separately from the object watch protocol. + +## Field selection + +[Field selectors](https://kubernetes.io/docs/concepts/overview/working-with-objects/field-selectors/) +filter supported fields, for example `metadata.name=example` to follow one named object or +`status.phase=Running` for Pods. They select whole objects; they do not select which fields +appear in the response. Supported fields vary by type, and unsupported fields produce an +error. Field selectors support `=`, `==`, and `!=`, with comma-separated AND requirements. +They do not support label-style `in` and `notin` expressions. + +For custom resources, `metadata.name` and `metadata.namespace` are supported. Additional fields +must be declared in the CRD's `spec.versions[*].selectableFields`; see +[selectable fields for custom resources](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#crd-selectable-fields). +Arbitrary JSONPath or CEL predicates are not general watch filters. + +Field-filtered watches have the same membership-transition issue as label-filtered watches. +For example, a Pod leaving `status.phase=Running` can produce `DELETED` on that filtered watch. + +## Initial state, progress, and recovery + +Without explicit initial-event settings, upstream documents an unset RV as current-state +initialization and `resourceVersion="0"` as initialization at any available version, potentially +stale. An observed nonzero RV resumes subsequent changes. See the +[watch resourceVersion semantics](https://kubernetes.io/docs/reference/using-api/api-concepts/#semantics-for-watch). +Use streaming initial events when the client needs an explicit snapshot-completion boundary. + +A client maintaining a materialized view needs both the current selected set and subsequent +changes. Two Kubernetes patterns support this: + +1. **LIST, then WATCH.** List with the intended scope and selectors, retain the list's + `metadata.resourceVersion`, then watch with the same selection from that RV. If history + expires before the watch can resume, rebuild from a fresh snapshot. +2. **Streaming initial events.** Request `sendInitialEvents=true` and + `resourceVersionMatch=NotOlderThan`. Initial objects arrive as synthetic `ADDED` events. + Request bookmarks with `allowWatchBookmarks=true`. A bookmark annotated + `k8s.io/initial-events-end: "true"` marks completion, followed by live changes. See + [streaming lists](https://kubernetes.io/docs/reference/using-api/api-concepts/#streaming-lists) + and the upstream + [initial-events contract and annotation](https://github.com/kubernetes/apimachinery/blob/v0.37.0/pkg/apis/meta/v1/types.go#L442). + +Initial events represent current state, not a history of all mutations during downtime. +Their object RVs need not appear in mutation order. + +[Ordinary bookmarks](https://kubernetes.io/docs/reference/using-api/api-concepts/#watch-bookmarks) +report observation progress. They carry no snapshot-completion guarantee without the +initial-events annotation, and they do not acknowledge a consumer's durable write. The +[`AllowWatchBookmarks` contract](https://github.com/kubernetes/apimachinery/blob/v0.37.0/pkg/apis/meta/v1/types.go#L379) +also permits a server to send no ordinary bookmarks during a session. + +On a disconnect or timeout, reconnect from an appropriate observed RV. Handle errors both +when opening the request and as `ERROR` events on an established stream. `410 Gone` means +the requested history is unavailable and current state must be rebuilt. See upstream +[watch recovery](https://kubernetes.io/docs/reference/using-api/api-concepts/#efficient-detection-of-changes). + +Use the same selection for the snapshot, initial events, and every reconnection. A cursor +does not identify or restore its selector. If a selector changes, resuming only from the old +cursor can miss newly included objects that have not changed since that point. + +## Other capabilities and limits + +**Metadata-only watches.** +[Content negotiation](https://kubernetes.io/docs/reference/using-api/api-concepts/#metadata-only-fetches) +can request `PartialObjectMetadata` objects. The client-go +[metadata client's `Watch` method](https://github.com/kubernetes/client-go/blob/v0.37.0/metadata/metadata.go#L253) +implements this with an `Accept` header. The representation omits object content such as +`spec` and `status`; it does not suppress events caused by changes to those fields. Support +depends on the serving API, particularly for aggregated APIs. + +**Sharded watches.** The alpha `shardSelector` option partitions by hashes of object UID or +namespace. It requires `ShardedListAndWatch`; unsupported servers can ignore the option. +Clients must verify support before relying on disjoint shards. Sharding partitions workload +across consumers; label selectors express object membership. See +[sharded list and watch](https://kubernetes.io/docs/reference/using-api/api-concepts/#sharded-list-and-watch) +and the [`ShardSelector` contract](https://github.com/kubernetes/apimachinery/blob/v0.37.0/pkg/apis/meta/v1/types.go#L469). + +**Application-side processing.** Standard watch options do not provide filters for only +`MODIFIED` events, only changes to `spec`, an annotation value, a user identity, or an arbitrary +combination of kinds. These are outside the `ListOptions` contract. Consumers can apply +additional predicates after receiving events. + +**Independent streams.** Overlapping watches can deliver the same object. The API server +preserves event delivery order within each cache watcher; see +[`sendWatchCacheEvent`](https://github.com/kubernetes/apiserver/blob/v0.37.0/pkg/storage/cacher/cache_watcher.go#L415). +That is a per-request property. Merging independent consumers' arrivals requires client-side +coordination; server-side filtering supplies no such merge or deduplication. Initial-state +events remain subject to the snapshot semantics above. diff --git a/docs/facts/watch-event-ordering-and-attribution-grace.md b/docs/facts/watch-event-ordering-and-attribution-grace.md deleted file mode 100644 index bbfa1bde2..000000000 --- a/docs/facts/watch-event-ordering-and-attribution-grace.md +++ /dev/null @@ -1,160 +0,0 @@ -# Watch event ordering under the attribution grace window - -> **reference** — durable background. Index: [`../INDEX.md`](../INDEX.md) -> -> Status: **explainer / decision record** -> Date: 2026-06-26 -> Related: [Watch-first ingestion architecture](../finished/watch-first-ingestion-architecture.md), -> [`internal/watch/target_watch.go`](../../internal/watch/target_watch.go), -> [`internal/watch/author_resolver.go`](../../internal/watch/author_resolver.go), -> [`internal/reconcile/git_target_event_stream.go`](../../internal/reconcile/git_target_event_stream.go), -> [`internal/git/branch_worker.go`](../../internal/git/branch_worker.go) - -## The question - -When a live watch event arrives, the resolver waits up to a bounded **grace window** -(`--author-attribution-grace`, default `3s`) for a matching audit fact before the event ships. Does that wait -**reorder** watch events relative to each other? Can the order of commits in Git end up different from -the order the mutations happened in the cluster? - -Short answer: **for any single object — and for any single resource type — order is strictly -preserved. The grace window can never make an older mutation overwrite a newer one, and it never -reorders same-object events.** What it *can* change is throughput (it serializes a watch behind the -wait), the *grouping and number* of commits, and the relative commit timing of **unrelated** objects on -different types. None of those affect the materialized Git state. - -## The execution model that makes this true - -The guarantee falls directly out of how the watch data plane is wired. - -**One goroutine per `(GitTarget, GVR, scope)` watch.** `replaceGitTargetWatches` starts one -`go runTargetWatch(...)` per claimed `(GVR, scope)`. Each watch has its own goroutine and its own event -loop ([`targetWatchReplayAndStream`](../../internal/watch/target_watch.go)): - -```go -for { - select { - case <-ctx.Done(): - return nil - case ev, ok := <-w.ResultChan(): - // handle ev fully — including the grace-window wait — before the next read - nextReplaying, err := m.handleTargetWatchSessionEvent(ctx, ..., ev, replaying, &replay) - } -} -``` - -**The resolver blocks *inline* in that loop.** On the live path, -`handleTargetWatchSessionEvent → routeLiveTargetWatchEvent → attachAuthor → AuthorResolver.ResolveAuthor` -all run synchronously in the watch goroutine. `ResolveAuthor` polls the attribution index and **sleeps -up to the grace window** before returning. Only after it returns is the event handed to -`RouteToGitTargetEventStream`, and only after *that* returns does the loop read the next event from the -channel. - -So a single watch processes its events **strictly one at a time, in arrival order**, and the grace wait -is *head-of-line* on that one watch — it delays the next event but never lets it overtake the current -one. - -**The downstream is a synchronous FIFO.** -[`GitTargetEventStream.OnWatchEvent`](../../internal/reconcile/git_target_event_stream.go) is a -pass-through: it stamps the GitTarget identity and calls `branchWorker.Enqueue(event)` with no buffering -or dedup. [`BranchWorker`](../../internal/git/branch_worker.go) is a single buffered `eventQueue` -channel drained by one loop, so **enqueue order is processing order**, and within an open commit window -repeated writes to the same path are last-write-wins. - -The apiserver delivers a watch's events **already ordered by `resourceVersion`** for that type. The -chain above never reorders, so that cluster order is preserved all the way into the commit window. - -## Why same-object order can never flip - -The decisive fact: **an object belongs to exactly one `(GitTarget, GVR, scope)` watch.** All of its -events — every `MODIFIED`, the `DELETED` — flow through the *same* single-threaded goroutine, in RV -order. The grace wait blocks that goroutine, so event *N* (wait included) is fully enqueued before event -*N+1* is even read. - -Worked example — two updates to the same ConfigMap, U1 (RV 100) then U2 (RV 101): - -1. U1 arrives. The resolver waits for U1's fact (say it is slow and the full 3s elapses). -2. U2 is sitting in the watch channel buffer, **unread** — the goroutine is blocked on U1. -3. U1 resolves (matched or expired) and is enqueued to the BranchWorker. -4. *Now* U2 is read, resolved, and enqueued. - -BranchWorker sees `U1, U2`; the window’s last-write-wins yields U2’s content. The newer mutation always -wins, and history shows U1 before U2. U2 can never "jump ahead" of U1, because it is not processed until -U1 is done. - -This holds even when attribution is **mixed**. If U1 expires to the committer and U2 matches a human -author, the commit-window author bucket changes between them ([`open_window.go`](../../internal/git/open_window.go) -accepts one `(author, GitTarget)` pair at a time), so U1 finalizes as one commit (committer) and U2 as -the next (the human). That is *two* commits where a single-author burst would have produced one — but -they are still in order (U1 then U2), and the final file is U2. - -## What the grace window *can* change (and why it is safe) - -**1. Throughput / latency on a single type (the real cost).** Because the wait is head-of-line, one -unmatched event stalls its whole watch for up to the grace window. On a busy, status-churny type whose -events frequently have no audit fact, this serializes the stream and adds up to `grace` of latency per -unmatched event. This is the genuine price of doing the join inline; it is a *throughput* property, not -a correctness or ordering one. It is bounded (never a barrier), and it expires to the committer rather -than blocking state. - -**2. Commit grouping and count.** As shown above, per-event author resolution can split what used to be -one commit into several, or coalesce differently than audit-first did. The watch-first design accepts -this explicitly: *more* commits when attribution is mixed, *fewer* when bursts/downtime collapse to -current state. - -**3. Relative ordering of *unrelated* objects across different types.** Different `(GVR, scope)` watches -run **concurrently**, each with its own independent grace wait, and they all feed the *same* BranchWorker -FIFO. So if a ConfigMap event waits out its 3s grace while a Deployment event matches immediately, the -Deployment can be enqueued — and committed — **before** the ConfigMap, even if the ConfigMap mutation -happened first in wall-clock. This is safe because: - -- they are **different objects on different types → different files in Git**, so there is no - same-path race and the materialized state is identical regardless of interleaving; -- Kubernetes `resourceVersion` is **only comparable within one group/resource** anyway, so there was - never a cross-type ordering guarantee to preserve; -- this concurrency already existed in watch-first before attribution (one goroutine per type); the grace - window only *widens* the timing skew, it does not introduce a new class of reordering. - -## Summary table - -| Scenario | Order preserved? | Notes | -|---|---|---| -| Two events on the **same object** | **Yes, always** | Same single-threaded watch; serial; RV order kept. Newer never lost. | -| Two events on the **same type**, different objects | **Yes** | Same watch goroutine, FIFO into the worker. | -| Events on **different types** (different watches) | Not guaranteed (and never was) | Concurrent watches + independent grace; different files, so materialized state is unaffected; RV is not cross-type comparable. | -| Mixed attribution (some matched, some committer) | Same-object order preserved | May split into more commits via author bucketing. | -| A late fact arriving **after** a commit shipped | N/A | Never rewrites a shipped commit; the event already committed as committer. | - -## Replay vs live - -The grace window applies to the **live** path only. During a `sendInitialEvents` replay (Mode B), events -are folded into a single desired set and applied as **one committer-authored resync** (the mark-and-sweep) -with no per-event resolver and no per-event ordering concern — a replay is a snapshot, not an ordered -stream. The transition is clean: the replay resync is enqueued, then live events follow through the -resolver in RV order. - -## If head-of-line latency ever becomes a problem (future) - -The inline blocking wait is the simplest correct design and it is what ships today. If a deployment with -high-volume, low-attribution types finds the per-event grace latency unacceptable, the resolver could be -made **non-blocking** without sacrificing ordering, by decoupling the wait from the watch goroutine and -re-imposing per-watch order on egress: - -- hand each live event to a per-`(GVR, scope)` ordered pipeline that starts the index lookup - immediately and only *holds* an event until its grace expires **or** all earlier events on that watch - have shipped (a sequence-numbered reassembly buffer); -- ship in sequence order, so a fast-resolving later event can never overtake a still-waiting earlier one - on the same watch. - -This preserves the exact ordering guarantees above while letting the watch goroutine keep reading. It is -deliberately **not** built yet: it adds a bounded per-watch buffer and reassembly logic, and the simple -inline wait is correct and adequate until measurements say otherwise. **Do not** parallelize attribution -per event without such a reassembly buffer — naive concurrency *would* let a fast-matched later event -overtake a slow earlier one on the same object and break same-object ordering. - -## Bottom line - -The grace window is a **per-event, bounded wait on a single-threaded watch**. It can delay a stream and -change how commits are grouped or how unrelated objects interleave, but it cannot reorder the events of -one object or one type, and it cannot let an older mutation overwrite a newer one. Same-object Git -history stays in cluster order; the materialized tree is always correct. diff --git a/docs/finished/attribution-fact-stream.md b/docs/finished/attribution-fact-stream.md index e5cea3bf4..4c893e0f4 100644 --- a/docs/finished/attribution-fact-stream.md +++ b/docs/finished/attribution-fact-stream.md @@ -592,7 +592,7 @@ shipped commit, and it is why the wait exists at all. `(GitTarget, GVR, scope)` that finishes one event before reading the next, feeding one FIFO into the branch worker. Attribution decides what author is stamped, never when an event is routed relative to its siblings. See -[`watch-event-ordering-and-attribution-grace.md`](../facts/watch-event-ordering-and-attribution-grace.md), +[`watch-event-ordering-and-attribution-grace.md`](../spec/watch-event-ordering-and-attribution-grace.md), including its warning that resolving events concurrently without a sequence-numbered reassembly buffer would let a later event overtake an earlier one on the same object. Faster resolution makes that refactor tempting. It is still out of scope, and still wrong without the buffer. diff --git a/docs/finished/redis-key-schema-v3.md b/docs/finished/redis-key-schema-v3.md index 530c45904..ae2ad5765 100644 --- a/docs/finished/redis-key-schema-v3.md +++ b/docs/finished/redis-key-schema-v3.md @@ -26,7 +26,7 @@ > [`internal/watch/author_resolver.go`](../../internal/watch/author_resolver.go), > [CommitRequest authorship from admission](../spec/commitrequest-admission-authorship.md), > [attribution](../spec/attribution.md), -> [watch event ordering & attribution grace](../facts/watch-event-ordering-and-attribution-grace.md), +> [watch event ordering & attribution grace](../spec/watch-event-ordering-and-attribution-grace.md), > [watch-first ingestion architecture](watch-first-ingestion-architecture.md). ## 1. Today (`v2`) and the companion plan diff --git a/docs/spec/README.md b/docs/spec/README.md index 5132eb9c8..d87e94925 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -32,6 +32,7 @@ three lifecycle classes and was unreadable spread across them. The rows below st | Spec | What it pins | |---|---| | [`attribution.md`](attribution.md) | **how a commit gets its author**: deletion-at-intent, the publish and join halves, the tier ladder, the audit-route partition, and what the metrics can and cannot answer | +| [`watch-event-ordering-and-attribution-grace.md`](watch-event-ordering-and-attribution-grace.md) | Per-watch ordering through attribution and enqueue, including the overlapping-watch limitation. | | [`current-manifest-support-review.md`](current-manifest-support-review.md) | the manifest store, plan/apply/flush, and the all-or-nothing folder claim | | [`../layout/contextual-namespace.md`](../layout/contextual-namespace.md) | kustomize graph-aware namespace inference; the supported subset | | [`reconcile-via-watchlist-mark-and-sweep.md`](reconcile-via-watchlist-mark-and-sweep.md) | initial reconcile; **no bookmark, no sweep** | diff --git a/docs/spec/attribution.md b/docs/spec/attribution.md index 10be00223..83acb7dd9 100644 --- a/docs/spec/attribution.md +++ b/docs/spec/attribution.md @@ -15,7 +15,7 @@ > [`../design/open-asks-priority.md`](../design/open-asks-priority.md), and the one decision with > its own record is [`../design/attribution-removal-wait-options.md`](../design/attribution-removal-wait-options.md). > -> Related: [`../facts/watch-event-ordering-and-attribution-grace.md`](../facts/watch-event-ordering-and-attribution-grace.md), +> Related: [`watch-event-ordering-and-attribution-grace.md`](watch-event-ordering-and-attribution-grace.md), > [`commitrequest-admission-authorship.md`](commitrequest-admission-authorship.md), > [`../attribution-setup-guide.md`](../attribution-setup-guide.md), > [`../interpreting-metrics.md`](../interpreting-metrics.md). diff --git a/docs/spec/watch-event-ordering-and-attribution-grace.md b/docs/spec/watch-event-ordering-and-attribution-grace.md new file mode 100644 index 000000000..c38a352bd --- /dev/null +++ b/docs/spec/watch-event-ordering-and-attribution-grace.md @@ -0,0 +1,135 @@ +# Watch event ordering under the attribution grace window + +> **contract:** current watch processing and ordering boundaries. Index: [`../INDEX.md`](../INDEX.md) +> +> Status: **implementation explainer** +> Checked: 2026-09-28 +> Related: [Kubernetes watch options](../facts/kubernetes-watch-options.md), +> [Watch-first ingestion architecture](../finished/watch-first-ingestion-architecture.md), +> [Attribution contract](attribution.md) + +## The question + +When a live watch event arrives, author resolution can wait up to the bounded +`--author-attribution-grace` window (default `3s`) for a matching audit fact. Can that wait +reorder events or change the order of Git commits? + +**The wait preserves delivery order within one watch.** Its event loop resolves and routes +one event before reading the next. Different watches run concurrently, and overlapping watches +can observe the same object. The inline wait alone therefore does not establish a global +same-object or same-resource-type ordering guarantee. + +## The execution model + +For each `GitTarget`, [`targetWatchStreams`](../../internal/watch/target_watch.go) chooses one +served version per resource collection. Its [`CellKey`](../../internal/types/cell.go) contains +group, resource, and namespace selection; the containing target supplies target and source-cluster +context. An all-namespace collection and a named-namespace collection remain separate, so their +object sets can overlap. + +Each running watch has its own goroutine. Its event pump handles an event synchronously before +reading another. On the live path, the call chain is: + +```text +watch event + -> routeLiveTargetWatchEvent + -> attachAuthor + -> AuthorResolver.ResolveAuthor + -> RouteToGitTargetEventStream + -> BranchWorker.Enqueue +``` + +Operation filtering and unchanged-content checks can discard events before attribution. +For events that need attribution, +[`ResolveAuthor`](../../internal/watch/author_resolver.go) calls the attribution index's +`Await` method inline. It waits for a resolution or the grace deadline, subject to cancellation. +Configured-author mode has no resolver and does not wait. + +[`GitTargetEventStream.OnWatchEvent`](../../internal/reconcile/git_target_event_stream.go) +forwards events to the [branch worker](../../internal/git/branch_worker.go). The worker accepts +live writes into its FIFO queue. It preserves enqueue order; it does not merge independent +source streams by `resourceVersion`. A full queue causes the route to fail so that the watch +cursor does not advance past the refused event. + +## Two updates on one watch + +Suppose one watch delivers updates U1 and U2 for the same ConfigMap, in that order: + +1. U1 reaches attribution and waits for a fact. +2. The watch loop has not yet read U2. +3. U1 resolves or expires, then enters the worker queue. +4. The loop reads U2, resolves it, and enqueues it after U1. + +The attribution wait cannot make U2 overtake U1 on this path. If both updates are accepted and +applied to the same document, U2 supplies the later content. A commit window can combine both +updates into one commit, so Git need not retain U1 as a separate historical state. + +Author resolution can change grouping. The [open commit window](../../internal/git/open_window.go) +groups compatible author and GitTarget work. Different authors can cause consecutive events +to land in separate commits while preserving their processing order. + +With attribution enabled, an unresolved event uses the explicit +`unknown (attribution unresolved)` author. With attribution disabled, the configured committer +also authors the change. A fact arriving after a commit is written does not rewrite it. + +### Preserve ordered release if attribution becomes concurrent + +Do not parallelize per-event attribution without an in-order release buffer. A later event +with a fast lookup must wait for earlier events on its watch. Release an event only after its +own lookup resolves or expires **and** every predecessor has been released. Bound the buffer +and handle cancellation explicitly. The current inline path supplies this ordering without +such a buffer; the requirement does not establish ordering across different watches. + +## Overlapping watches are a separate ordering problem + +An object does **not** necessarily belong to exactly one watch. For example, a GitTarget can +follow ConfigMaps across all namespaces and also follow ConfigMaps in `team-a`. Both watches +can deliver `team-a/example`. The +[target watch plan](../design/target-watch-plan.md#where-the-bound-is-thin) explicitly records +that nothing orders these overlapping collections against one another. + +Each watch preserves its own order, but that does not order their combined arrivals. If +watch A waits on an older event while watch B advances, a newer observation from B can reach +the worker before the older observation from A. A FIFO preserves that arrival order; it cannot +establish which observation is newer by itself. Content deduplication also does not establish +source mutation order. + +This is a limitation of the existing design, not proof that every overlap causes stale output. +Correctness across overlapping sources needs a separate ordering or stale-event policy. Label +selectors make the issue more visible because two differently filtered watches can share objects. + +## What the grace window changes + +**Latency and throughput.** An unmatched event delays later events on its watch, including +those for other objects. The grace bounds each attribution wait; accumulated queueing can make +end-to-end delay longer. Unchanged sanitized updates are skipped before attribution, so they +do not pay this wait. Sustained delays can increase buffering and reconnect pressure. + +**Commit grouping.** Waiting changes when events enter the worker, and author changes can split +commit windows. A watch-driven materialized view does not promise one commit per API mutation. + +**Interleaving across watches.** Independent waits can change which watch reaches the worker +first. Different objects can share one multi-document YAML file. The single branch worker +serializes their edits; file separation is not the reason that writes avoid concurrent editing. +This serialization does not add source ordering across overlapping watches. + +| Scenario | What is guaranteed by the inline wait and FIFO? | +|---|---| +| Two forwarded live events on one watch | The later event cannot overtake the earlier event during attribution and enqueue. | +| Two objects of one type on different watches | No common source order is established. | +| The same object on overlapping watches | Each watch is ordered internally; their merged arrival order can differ from mutation order. | +| Events from different resource types | No common mutation order is established; RVs are not a cross-type clock. | +| Mixed matched and unresolved attribution on one watch | The wait preserves enqueue order but may change commit grouping. | +| A fact arriving after its commit is written | The existing commit remains unchanged. | + +## Initial state versus live events + +During streaming initial events, the manager collects a desired set until the +`k8s.io/initial-events-end` bookmark. It submits that snapshot as a configured-author resync, +without per-object attribution waits. Initial objects represent current state; their delivery +order does not reconstruct historical mutations. + +The accepted resync enters the worker queue before subsequent live events from that watch. +The watch does not wait for the Git write to finish before reading those live events. Queue +order and successful Git completion are distinct facts. Other watches can enqueue work during +this process, so this transition does not resolve overlapping-source ordering. From fc75266f60f755f562c5181aa9a6eaf9c217b3d6 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 28 Sep 2026 08:13:01 +0000 Subject: [PATCH 02/10] docs: select #146's labels on the kube-apiserver Rewrite the watches-and-labels design around server-side selection: the rule's objectSelector joins the collection key and is sent as the watch's labelSelector, membership follows the Kubernetes event semantics, and the Always sweep is bounded by the selector evaluated on Git document labels. Overlapping collections of one type must share a selector. Co-Authored-By: Claude Opus 5.5 --- docs/INDEX.md | 1 + docs/design/watches-labels-simplification.md | 536 +++++++------------ 2 files changed, 189 insertions(+), 348 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index f11addeab..569dc692e 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -134,6 +134,7 @@ so you never have to read a proposal to find out it already shipped. | [`placement-visibility-and-declared-defaults.md`](design/placement-visibility-and-declared-defaults.md) | **design, half built.** The three questions the inference deletion left, decided and then built in two goes. PR #291 shipped the deletion and none of the eight items queued behind it; the residue was filed as [#295](https://github.com/ConfigButler/gitops-reverser/issues/295) — **which shipped in 0.42.1 via [#319](https://github.com/ConfigButler/gitops-reverser/pull/319) and is what reversed the layout model** — and [#296](https://github.com/ConfigButler/gitops-reverser/issues/296), whose largest half then shipped in [#326](https://github.com/ConfigButler/gitops-reverser/pull/326): `status.layout` became `status.placement`, and the ambiguous render root came with it as a refusal rather than a third counter outcome. The `declared` metric value has since split into `by_type`/`default` too, which was the one row with an external consumer because it changes a label a dashboard may select on. What still stands and is still unbuilt is the rest of the legibility set: the canonical path as a template constant, and `{kindLower}`, tracked on [#339](https://github.com/ConfigButler/gitops-reverser/issues/339) now that #296 is closed; the Event on the GitTarget was dropped. Its Question 2, a CRD default for `placement.default`, is **re-opened rather than superseded** and now lives as an open question in [`layout/model.md`](layout/model.md); the argument against it is legibility, since #319 removed the correctness objection | | [`gittarget-api-wave.md`](design/gittarget-api-wave.md) | **partly built**, filed as [#294](https://github.com/ConfigButler/gitops-reverser/issues/294). One breaking wave on `GitTarget`, of which steps 2 to 7 have **shipped**: `spec.suspend`, `status.placement` and the reconcile-request annotation in [#326](https://github.com/ConfigButler/gitops-reverser/pull/326), and B4's `commitWindow`/`commit.message` move plus the source-scope deletion in [#330](https://github.com/ConfigButler/gitops-reverser/pull/330). Organizing principle: **the folder is described on the GitTarget, the connection describes only the connection** — and this is where that became a struct boundary rather than a sentence, since grouping a field is free only in a release that is already breaking. `spec.mode` and `GitTarget.spec.interval` are both **dropped**, with re-open triggers, and `status.lastHandledReconcileAt` was dropped in the build for a stated reason. What is left is **step 8, the riders** — asserted `CommitRequest.spec.author`, the `CommitRequest` lifecycle hole, the `default` `ClusterProvider` message — tracked on [#339](https://github.com/ConfigButler/gitops-reverser/issues/339); #294 is closed. The reference collapse and the `scope` removal shipped in [#333](https://github.com/ConfigButler/gitops-reverser/pull/333); the `TooManyStreams` cap and the Event on a changed resolution were dropped. Also records that staying `v1alpha3` on loud rejections is a **one-consumer countdown** | | [`target-watch-plan.md`](design/target-watch-plan.md) | **partly built.** The diff is built and applied; removal semantics are not. The companion to [`watch-manager-ownership.md`](design/watch-manager-ownership.md): the ownership page says WHO applies a plan, this one says WHAT a plan is and what changing it may touch. A cell — group, resource, namespace, deliberately no served version — is the one identity the watch stream, the render-fidelity scope and the mark-and-sweep boundary all agree on, because a key that does not round-trip to the scope it sweeps under is the class of error that deletes user data. The plan is diffed into `keep`/`start`/`restart`/`stop` and applied per cell, so adding one WatchRule stops replaying every unrelated cell into a queue shared with other tenants; a `restart` is a served-version change, which is why the version is spec DATA rather than identity. Readiness and the fidelity revision are per scope, so a KEPT cell holds the result its own replay produced rather than being asked to prove itself again over an unrelated edit. `stop` never touches files — removal is a Git-side sweep under the target's existing `spec.prune.mode`, not a watch-layer delete. "Cut at the producer" is the accepted consequence: nothing fences the queue, so a deselected cell may leave a short tail of writes, bounded by the queue and converged afterwards. Still open: the `stop` classification wants a settled `TypeRemoved` from `typeset` (see TODO), and removal on INTENT is undecided. | +| [`watches-labels-simplification.md`](design/watches-labels-simplification.md) | **proposed.** Per-rule `objectSelector` (#146), selected by the kube-apiserver: the selector joins the collection key and is sent as the watch's `labelSelector`, so one collection is one list/watch request. Membership follows Kubernetes, so removing the label removes the document under the prune policy. Overlapping collections of one type must share a selector, and the `Always` sweep is bounded by the selector evaluated on the Git document's labels. | | [`attribution-removal-wait-options.md`](design/attribution-removal-wait-options.md) | a removal now waits for evidence about the DELETION rather than accepting the object's last write, which stopped it naming whoever last edited the object as the author of a deletion they did not perform. Enumerates the eight situations a resolution can be in and shows the cost is concentrated in exactly one: a removal for which no delete fact will ever arrive (a graceful pod delete, a status-only removal, a type the audit policy skips) spends the whole grace to return the answer it had at t=0, measured at ~3.1s against ~70ms when evidence is present. Prices five options against that, and recommends a per-route watermark — stop waiting once the fact stream has demonstrably moved past this event — over a second timeout flag whose right value lives in the API server's config rather than ours. Open: the decision, and how common the case is outside the e2e suite | | [`commitrequest-save-wait-options.md`](design/commitrequest-save-wait-options.md) | **design, decided** — a five-phase plan, reported from a live cluster: a `CommitRequest` can expire before the write it exists to publish arrives, because the close delay defaulted to `0` while the write is still held head-of-line waiting for an audit fact, so the save resolves `Ready=True`/`NoWindowInGrace` and the edit commits seconds later under the target's `liveTemplate`. The decision is to repair the default (`spec.closeDelay`, now a duration string defaulting to `"2s"`, **shipped as phase 0**) and then let a request **name the write it is waiting for**, reusing the `(uid, resourceVersion)` identity the attribution join already keys on, including its settled `ExactCapable` rule. Records why the fact index cannot BE the wait (it sits upstream of the watch event, so a fact-keyed wait fires before the window opens) and what to take from it anyway — the register-then-check waiter discipline, and a best-effort retrospective oracle bounded by a TUNABLE TTL. Twelve situations, and the page is deliberate about the two it cannot fix cheaply: row 11 (a saturated queue is rejected in the WATCH path and never reaches the worker, so no worker-side wait can diagnose it) is an accepted, named gap rather than a promise, and row 10 is fixed only by a hook in the fidelity-drop branch that marks a wait terminally UNSATISFIABLE — satisfying before that gate would arm a finalize for a write that never commits. Carries a **preconditions chapter** for saves on today's build (audit policy first, then delay sizing, fidelity warm-up, save-button debounce, and assert on the commit rather than on `Ready`). `CommitterActivity` is deferred, not recommended: by its own arrival-not-existence rule it cannot satisfy the already-arrived case. Open: situation 12 (one actor, two saves, one window — blocks phase 2), whether the fact-index lookup earns its coupling, and how an unsatisfied wait is graded | | [`watch-and-catalog-architecture.md`](design/watch-and-catalog-architecture.md) | the target three-layer watch model — **needs a human call before building** | diff --git a/docs/design/watches-labels-simplification.md b/docs/design/watches-labels-simplification.md index 29953dba4..7d26a6b71 100644 --- a/docs/design/watches-labels-simplification.md +++ b/docs/design/watches-labels-simplification.md @@ -1,163 +1,30 @@ -# Resource collections and watches: simplifying the operator model +# Label selection for watch rules -Review and proposal based on repository commit `65233aaf`, inspected on 2026-09-28. -The definitions now adopt Kubernetes terminology. Internal type changes, object selectors, and -the design decisions below remain proposed; source references describe current behavior. +> **proposed**: nothing here is built. Design for +> [issue #146](https://github.com/ConfigButler/gitops-reverser/issues/146). +> Index: [`../INDEX.md`](../INDEX.md) +> Date: 2026-09-28, checked against `main` at `b43ecf61`. +> Related: [Kubernetes watch options](../facts/kubernetes-watch-options.md), +> [Definitions](../definitions.md), +> [Watch event ordering](../spec/watch-event-ordering-and-attribution-grace.md), +> [Target watch plan](target-watch-plan.md) -Use **resource collection** for the observed object set and **watch** for its observation lifecycle. -Keep compiled rule selection consistent through live processing and Git reconciliation. Include -[issue #146: per-rule objectSelector](https://github.com/ConfigButler/gitops-reverser/issues/146) -before fixing the replacement key and plan shape. Label selection is absent from the current API. +Issue #146 asks for a label selector on each watch rule, so that a GitTarget can mirror, for +example, only the Secrets labeled `internal.cozystack.io/tenantresource=true`. -This proposal builds on [Definitions](../definitions.md), [Architecture](../architecture.md), -and [Configuration](../configuration.md). Kubernetes behavior and upstream evidence live in -[Kubernetes watch options](../facts/kubernetes-watch-options.md); this document owns the -operator-specific interpretation and design choices. +**The kube-apiserver does the selecting.** A rule's selector becomes the `labelSelector` of +the list and watch requests the operator already makes. One resource collection is exactly one +Kubernetes list/watch request: group, resource, namespace, and label selector. The operator +evaluates no label logic on the event path, and it adopts the Kubernetes membership semantics +as they are rather than modeling membership itself. -## Design priorities +The cost is more watches: one per distinct selector. That cost is accepted. -Kubernetes terminology and API semantics are the design target. Prefer its existing resource -identity types, label selectors, list/watch behavior, and controller conventions wherever they -express the required behavior. Project-specific concepts need a responsibility that those -primitives do not already cover. +## The API -The project is pre-1.0, and a larger refactor is acceptable. Judge alternatives by the clarity -and complexity of the resulting model, including its failure and recovery paths. Internal -types, controller boundaries, API fields, and observability names can change together when -that produces a more coherent design. Record migration and versioning requirements as part -of the change; preserving the existing structure is not a design goal. - -Correctness, source authorization, and explicit selection and pruning semantics remain -requirements. Existing types and tests are evidence about those requirements, not a mandate -to retain every current abstraction or behavior. `CollectionKey` below is a candidate whose -need and shape must be tested against the resolved selection model. - -## Kubernetes facts this proposal relies on - -| Reference | Design question it informs | -|---|---| -| [Watch endpoints and namespace scope](../facts/kubernetes-watch-options.md#what-one-watch-covers) | What belongs in a collection identity? | -| [Label selectors](../facts/kubernetes-watch-options.md#label-selection) | How should rule selection compile and combine? | -| [Membership event semantics](../facts/kubernetes-watch-options.md#event-types-describe-membership-in-the-selected-collection) | What do entry and exit mean for operations, attribution, and pruning? | -| [Initial state and recovery](../facts/kubernetes-watch-options.md#initial-state-progress-and-recovery) | When is a new snapshot required, and what does a cursor prove? | -| [Selectors and authorization](../facts/kubernetes-watch-options.md#selectors-and-authorization) | When is broader observation authorized? | -| [Other capabilities and limits](../facts/kubernetes-watch-options.md#other-capabilities-and-limits) | What work remains in the operator even with server-side filtering? | - -Those references hold the selector syntax, request options, and event-transition details. -The proposal below uses them without repeating the protocol explanation. - -## The architectural story - -The proposed default observes a complete group/resource/namespace collection and evaluates -object selectors locally. Use Kubernetes resource identities and `metav1.LabelSelector`, with -one resolved watch plan carrying complete rule clauses. Broad observation preserves evidence -about objects that exist but are not selected; only selected desired content is written. - -```mermaid -flowchart TD - D[Discovery and mirroring policy] --> P[Resolved collection watch plan] - R[Authorized rules and compiled clauses] --> P - P --> W[Watch the complete permitted collection] - W --> O[Source presence and deletion intent] - W --> S[Local rule selection] - O --> C[Policy-controlled snapshot reconciliation] - S --> C - S --> L[Accepted live writes] - C --> B[Branch worker queue] - L --> B -``` - -This is the proposed selector design. Current watches already observe broadly, but do not carry -object-selector clauses or separate presence evidence from selected desired state. Source -readiness, queue acceptance, and Git publication remain distinct observations. - -## Model selection independently of watch connections - -[`CellKey`](../../internal/types/cell.go) contains `Group`, `Resource`, and `Namespace`; `Matches` -tests resource membership. It carries no connection or runtime state. Snapshot cleanup, -render-fidelity results, and queued-event provenance all use this boundary while watches can be -blocked, disconnected, or replaced. Naming every use a stream would obscure that responsibility. - -`CollectionKey` would identify the version-independent observed collection. The containing -`GitTarget` supplies target and source-cluster context. Local selectors belong in its plan -specification; they do not require separate transport keys. A selected subset remains a distinct -write-policy boundary even when several rules share that observation. - -Three distinctions must remain explicit in the resulting model: - -- **Collection identity and served version.** `targetWatchStreams` chooses one served version - per collection and combines operations; `cellSpec` stores that version separately. A version - change restarts the watch without changing membership. `ResyncScope` likewise separates - `CellKey` from `Version`. A requested served version is not necessarily the storage version. -- **Resource scope and namespace selection.** An empty namespace selects all namespaces for a - namespaced type; discovery distinguishes this from a cluster-scoped type. `WatchRule` selects - namespaced resources, including `sourceNamespace: "*"`; `ClusterWatchRule` selects - cluster-scoped resources. See [source namespace configuration](../configuration.md#watching-a-different-source-namespace). -- **Eligibility and API capability.** [`Evaluate`](../../internal/typeset/funnel.go) checks - discovery trust, verbs, origin, sensitivity, and policy. Calling its verdict `watchable` would - conceal mirroring restrictions beyond Kubernetes' support for watches. - -The earlier reason for `cell` was that `type` and `scope` already had meanings. **Resource -collection** avoids both overloads and is Kubernetes vocabulary. Reserve resource type for -`GroupResource`, resource scope for namespaced versus cluster-scoped, and namespace selection -for the collection's namespace extent. `WatchRule` names a configuration kind; **watch** names -the API activity. Using each kind's full name avoids the proposed naming collision without -inventing another word for Kubernetes watches. - -## Suggested vocabulary - -| Current wording or identifier | Candidate | Meaning | -|---|---|---| -| Cell / `CellKey` | Resource collection / `CollectionKey` | Observed object set, independent of locally selected subsets | -| `CellKeyFor`, `SourceCell` | `CollectionKeyFor`, `SourceCollection` | Construction and provenance of that boundary | -| `cellSpec` | `watchSpec` | Served version and compiled selection policy | -| Claimed | Selected by a rule | User demand | -| Followable | Eligible for mirroring | Discovery and product-policy verdict | -| Stream, for running activity | Watch | Managed observation lifecycle | -| Stream, for delivered data | Watch stream | Events from a connection | -| `GitTargetEventStream` | `TargetEventForwarder` | Forwarding adapter with no watch connection or event buffer | -| Replay, for startup | Initialization / initial events | Establishing current state | -| Cursor | Resume `resourceVersion` | Position used for reconnecting | -| Resync, for Git work | Snapshot reconciliation | Applying the observed collection to managed documents | - -The definitions contract now accepts this prose vocabulary; implementation names are migration -candidates. Public fields, conditions, metrics, and enum values can follow the same redesign, -with their migration impact recorded. -Client-go informer resync has different semantics from this project's snapshot-carrying -`ResyncRequest`; the proposed wording makes that distinction explicit. - -## Current implementation and source references - -| Responsibility | Source and behavior | -|---|---| -| Discovery and eligibility | [API resource catalog](../../internal/watch/api_resource_catalog.go) normalizes discovery; [type registry](../../internal/typeset/registry.go) tracks eligibility and uncertain disappearance. | -| Rule compilation and resolution | [Rule store](../../internal/rulestore/store.go), [resolver](../../internal/watch/watched_type_resolver.go), and [watched-type table](../../internal/watch/watched_type_table.go) resolve target coverage and combine namespace operations. | -| Watch planning | [`targetWatchStreams`](../../internal/watch/target_watch.go) chooses versions; [`diffTargetWatchPlans`](../../internal/watch/target_watch_plan.go) classifies keep, start, restart, and stop. | -| Initialization and resume | `targetWatchReplayAndStream` requests initial events, `NotOlderThan`, and bookmarks; `targetWatchResumeAndStream` uses a stored RV and bookmarks. | -| Compatibility fallback | `targetWatchListAndStream` buffers an opened watch before LIST, applies the snapshot, then keeps events newer than its watermark. | -| Endpoint and event processing | Callers of `openTargetWatch` and `openTargetList` supply no label or field selectors. `routeLiveTargetWatchEvent` applies operation policy, skips unchanged sanitized updates, resolves attribution, and routes events. | -| Event forwarding | [`GitTargetEventStream`](../../internal/reconcile/git_target_event_stream.go) enqueues live events on the branch worker. | -| Snapshot application | [`ResyncScope`](../../internal/git/types.go) and [resync flush](../../internal/git/resync_flush.go) bound policy-controlled writes and cleanup. | -| Render evidence | [Render-fidelity gate](../../internal/git/render_fidelity_gate.go) associates results with the collection and watch revision. | - -Function names without a separate link above are in `target_watch.go`. - -## Include object selection before fixing the model - -Issue #146 requests `metav1.LabelSelector` on each rule item. Its Cozystack example selects -Secrets labeled `internal.cozystack.io/tenantresource=true`. A label-defined application can use -several selected resource types; no additional resource-group kind is needed. - -The issue sketch predates the watch-first architecture. Translate its audit filtering, -`namespaceSelector`, `v1alpha1`, and namespaced `ClusterWatchRule` examples to the current model: -watch-supplied object state, optional audit attribution, and `WatchRule.sourceNamespace`. - -### Proposed API contract - -Add optional `objectSelector` to both +Add an optional `objectSelector` of type `metav1.LabelSelector` to [`ResourceRule`](../../api/v1alpha3/watchrule_types.go) and -[`ClusterResourceRule`](../../api/v1alpha3/clusterwatchrule_types.go), evaluated on source object -labels before sanitization. This example is proposed and is not accepted by the current CRD: +[`ClusterResourceRule`](../../api/v1alpha3/clusterwatchrule_types.go): ```yaml apiVersion: configbutler.ai/v1alpha3 @@ -170,214 +37,187 @@ spec: name: tenant-config rules: - apiGroups: [""] - apiVersions: ["v1"] resources: ["secrets"] - operations: [CREATE, UPDATE, DELETE] objectSelector: matchLabels: internal.cozystack.io/tenantresource: "true" ``` -Use the standard selector semantics linked above. Define omitted and empty selectors as matching -all otherwise permitted objects, normalizing the helper's nil behavior explicitly. Reject invalid -selectors; a valid empty result initializes successfully. Existing authorization, mirroring -eligibility, and Secret encryption requirements remain applicable. +The name and type follow the admission webhook `objectSelector`, which sits beside the same +`apiGroups`/`resources`/`operations` rule shape that `ResourceRule` already copies. The +matching semantics are those of list and watch, described below; admission's old-or-new match +does not apply to a watch. -### Preserve complete selection clauses +The structured type is the Kubernetes convention for a selector in an API object, so it gets +schema validation and reads the same as every other selector in a cluster. It converts +losslessly to the query string with `metav1.LabelSelectorAsSelector(sel).String()`, and that +string is what the operator sends. A raw string field would save one conversion and lose the +schema. -Within a rule, type, namespace, and object selection must all match. Across rules, accept an -event if any complete clause accepts it. For example: +Validation, at admission and again at compile time: -| Selector | Operations | -|---|---| -| `team=a` | `UPDATE` | -| `team=b` | `DELETE` | - -Independent unions would wrongly permit updates to `team=b` and deletes of `team=a`. Preserve -and compile each `(object selector, operation filter)` pair. A broad selector on one rule must -not erase another rule's operation restrictions. +- **Omitted and `{}` both select everything.** `LabelSelectorAsSelector` maps nil to "nothing", + so the compiler normalizes an omitted selector explicitly rather than inheriting that. +- **An unparsable selector refuses the rule.** A valid selector that matches no objects is not an + error: its watch initializes to an empty collection. +- **A selector may not use a label that sanitize strips.** `isOperationalLabel` in + [`internal/sanitize/types.go`](../../internal/sanitize/types.go) removes the Flux, kro, and + applyset labels before a document reaches Git. The prune boundary below reads labels from Git, + so a selector on a stripped key could never be evaluated there. -`foldTargetReplayEvent` and the LIST fallback construct desired state separately from live -operation filtering. Apply object selection to both snapshot paths as well as live events; -keep presence evidence for unselected objects instead of dropping them from all snapshot inputs. +## One collection is one list/watch request -### Decision: observe broadly and filter locally - -Use one raw watch for each resolved base collection, with local matching of complete clauses. -This choice exposes label changes as ordinary object updates and keeps a complete inventory -for snapshot reconciliation. It also permits a direct comparison with standard client-go -reflectors or informers; adopting Kubernetes terms does not require server-side label filtering. +Today a collection (in code, [`CellKey`](../../internal/types/cell.go)) is +`(group, resource, namespace)` within a GitTarget, and the served version is carried as data. +The selector joins the identity: ```text -observation key: group/resource + namespace selection, within one GitTarget/source context -watch spec: chosen served version + canonical namespace/selector/operation clauses -snapshot input: complete source presence + selected desired objects + eligible prune candidates -runtime state: watch cancellation + observation progress + selection/render revision +collection: group, resource, namespace ("" = all namespaces), canonical label selector +watch spec: served version + operation filter ``` -Native RBAC does not provide label-based object permissions, so the extra cost on such sources -is traffic, decoding, and state for unselected objects. However, authorization webhooks can -inspect selectors, as the facts explain. This design requires permission to observe the broad -collection; a denied request remains an observation failure. It does not silently downgrade -a selector-restricted source to an apparently complete empty snapshot. - -Server-side filtering remains a later optimization, with equivalent exit and absence evidence -required first. A single-clause collection still has those requirements. Metadata-only watches -omit manifest content, and sharding does not express the selected label groups. - -### Compile clauses into one resolved plan - -`targetWatchStreams` currently unions `OperationSet` values, and `operationSpec` renders only -operations. Replace this flattening with complete clauses carried from the rule store through -resolution and the watch plan. Include selectors, operations, and namespace restrictions in -canonical comparison; normalize requirement/value ordering and clause order. - -A semantic clause edit restarts initialization under a new selection/render revision, even if -its observation key stays the same. This observes unchanged objects that newly match. Preserve -unaffected watches and avoid restarts for mechanical reorderings. These are central change -points, but compilation, snapshot payloads, matching, and write-side cleanup also change. - -The broader refactor can remove intermediate table/spec conversions and forwarding adapters -where their responsibilities collapse. Neither `CellKey` nor `ResyncScope` must retain its -current shape merely to make #146 a small patch. Preserve authorization and operation clauses -when considering shared all-namespace and named-namespace observation; local label filtering -alone does not remove that existing overlap. - -### Decision: retain deselected content and preserve deletion intent - -Operation filters describe source lifecycle changes inferred from unfiltered watches, including -updates made by PATCH. They cannot promise exact audit verbs. In particular, the live entry point -is `operationForLiveTargetWatchEvent`: an object carrying `deletionTimestamp` becomes `DELETE` -before physical removal. [`desiredFromObject`](../../internal/watch/scope_resolve.go) excludes -terminating objects from snapshot desired state for the same reason. - -Adopt these policies for #146: - -- Label entry is an `UPDATE` on an existing object, subject to the matching clause's operations; - initialization can still establish its current desired state. -- Label exit or selector removal retains the last mirrored document. It does not synthesize a - delete or overwrite it with excluded content. Another matching rule can continue managing it. -- A source deletion or deletion intent remains eligible for removal under the applicable rule - and pruning policy. Determine that before treating an event as ordinary label deselection; - preserve the membership evidence needed to apply the correct clause. -- `Always` may infer cleanup for eligible managed objects absent from a complete observation, - including logically absent terminating objects. A present, non-terminating, unselected object - is retained. `Never` and `OnEvent` do not infer deletion from snapshot absence. - -Retained manifests can restore a removed label on a later GitOps apply. That is a documented -consequence of retaining content. No cleanup is inferred outside active authorized observation, -and withdrawal of a rule must not turn its previously covered collection into an empty snapshot. - -### Separate source presence from selected desired state - -Broad observation supplies the evidence, but the existing writer cannot yet use it this way. -`ResyncScope.Matches` tests resource identity, and `resyncPlan` interprets absence from `Desired` -as potential deletion. Passing only selected objects would still turn present-but-unselected -objects into prune candidates. Passing every object as desired would instead write excluded -content. Extend the snapshot/planner contract to distinguish those two inputs. - -Pruning also needs explicit evidence of which documents the target manages under this selection. -A document must not become eligible merely because its group/resource/namespace matches; that -would expose never-selected documents to cleanup. Preserve or reconstruct membership evidence -across selector edits and restart, and protect objects still covered by another rule. Unknown -membership or incomplete observation cannot authorize deletion. See -[deletion policy](../configuration.md#deletion-policy-specprunemode). - -### Selection revisions still matter for recovery - -Local filtering lets the transport cursor remain keyed by the observed base collection. The -current Redis implementation takes a GVR argument but persists target UID, group/resource, and -namespace; [`watchCursorKey`](../../internal/queue/redis_store.go) deliberately drops the version. -There is no need for one cursor per local selector. Selection changes still need fresh desired -state and render evidence, so canonical clause changes must invalidate initialization results. - -The only production cursor lookup consumer found is `targetWatchReplayAndStream`. Its first -attempt ignores the returned cursor for resume; later loop attempts may use it. Persistent -cursors therefore provide no normal warm-start shortcut. They can still be consulted after a -failed first initialization, before a fresh cursor has replaced the old value. Test that path -against render readiness before replacing the store with an in-memory cursor. Cursor durability -also does not make accepted Git writes durable. Redis has other attribution and coordination -roles, so simplifying this store does not by itself remove Redis from every deployment. - -## Ordering and attribution belong to the implementation contract - -The [attribution-grace explainer](../spec/watch-event-ordering-and-attribution-grace.md) owns the -current call sequence and its limits. Inline author resolution preserves enqueue order within -one watch. The branch worker serializes arrivals but does not merge overlapping watches by RV; -all-namespace and named-namespace watches can both deliver one object. - -The [target watch plan](../design/target-watch-plan.md#where-the-bound-is-thin) already records -that gap. Architecture now states the per-watch boundary. Label selection extends the overlap -question, so sharing or stale-event handling needs its own guarantee and tests. This review -identifies the missing guarantee without claiming a new runtime reproduction. - -The spec now retains the ordered-release requirement for any concurrent attribution work. -The definitions and architecture also distinguish collection keys from target context and -first-attempt initialization from later cursor resume. - -## Follow-up implementation checks - -- `streamStatusesByType` folds collection state into resource-type counts, while the open-watch - gauge counts connections. Preserve explicit units when redesigning status. -- Source comments on `NamespaceOps`, `ClusterWide`, and `WatchScopes` still describe older rule - semantics; update them with the code refactor. The architecture now distinguishes source - namespace selection from control-cluster provider access. -- Verify cursor failure/reconnect paths and the in-memory write queue before promising startup - resume or durable delivery; see [write-queue durability](../architecture.md#durability-of-the-write-queue-planned). - -## Simplification and implementation sequence - -1. Define the target model using Kubernetes concepts and resolve issue #146's membership, - operation, attribution, retention, and recovery contract. Identify the responsibilities that - still require project-specific types. -2. Prototype the broad-observation plan with complete clauses and separate snapshot inputs. - Compare native client-go building blocks with custom watch management on correctness, - recovery, state, and memory costs. Include overlapping-source ordering in that comparison. -3. Inventory status, metrics, version resolution, and manifest-analysis consumers. Replace - redundant representations and adapters as part of the coherent refactor; a preliminary - mechanical rename is optional. -4. Apply the accepted model across snapshot and live paths, API and status naming, and - documentation. Record intentional behavior changes and their migration requirements. -5. Validate the required invariants and selector scenarios below. Make ordering and restart - durability guarantees explicit, including any remaining gaps. - -The current source path uses dynamic watches and temporary snapshots; control-plane informers -serve a different purpose. Existing library choices do not constrain the target design. -Broader changes, including CRDs or deployment components, should each explain the responsibility -they simplify and how the resulting model meets its correctness and authorization requirements. - -## Invariants and acceptance tests - -| Invariant | Existing test reference | +[`targetWatchStreams`](../../internal/watch/target_watch.go) currently unions the operation +filters of every rule that lands on one collection. That union stays correct, because every +rule on one collection now selects exactly the same objects. Rules with different selectors +land on different collections, each with its own operation filter. Separate unions of +selectors and operations would have let `team=a` rules' operations apply to `team=b` objects; +keying on the selector makes that impossible without any clause machinery. + +The canonical string comes from the parsed `labels.Selector`, which sorts requirements by key, +so reordering `matchLabels` or `matchExpressions` in a rule does not restart a watch. + +### One selector per overlapping collection + +Two collections of one group/resource in one GitTarget overlap when their namespaces are equal, +or when one of them is all-namespaces. **Overlapping collections must use the same selector.** +Disjoint ones are unrestricted, so the multi-tenant case works: the `tenant-a` and `tenant-b` +WatchRules may each carry their own selector for Secrets. + +The restriction exists because two watches over the same objects are not ordered against each +other; see [Overlapping watches](../spec/watch-event-ordering-and-attribution-grace.md#overlapping-watches-are-a-separate-ordering-problem). +With different selectors, one object can leave one collection (`DELETED`) while it stays in the +other (`MODIFIED`), and the Git outcome would depend on which event arrives first. + +When a rule would violate this, the oldest rule (by creation timestamp, then name) keeps the +collection, and the newer rule reports the refusal on its own status. Keys that need several +values stay expressible in one selector with a set-based requirement, such as +`team in (a,b)`. + +The all-namespaces-plus-named-namespace overlap that exists today, both unselected, is +unchanged by this proposal. + +## Membership follows Kubernetes + +A label-filtered watch reports membership transitions; see +[event types](../facts/kubernetes-watch-options.md#event-types-describe-membership-in-the-selected-collection). +The operator maps them through the existing operation mapping without reinterpreting them: + +| Source change | Watch event | Operation | +|---|---|---| +| A matching object is created, or an existing object gains the label | `ADDED` | `CREATE` | +| A matching object changes and still matches | `MODIFIED` | `UPDATE` | +| A matching object gets a `deletionTimestamp` | `MODIFIED` | `DELETE` (deletion intent, as today) | +| A matching object is deleted, or loses the label | `DELETED` | `DELETE` | + +The last row is the consequence to accept: **removing the label removes the document**, under +the GitTarget's prune policy. With the default `OnEvent`, the file is deleted; with `Never`, it +is retained; a rule without `DELETE` in its operations ignores both causes alike. The Git folder +then shows what `kubectl get -l ` shows, which is the point of offloading selection. + +Telling a deselection apart from a deletion would need a follow-up GET after each `DELETED`, +and a failed GET would leave the answer unknown. That is out of scope; it can be added later if +users need deselection to retain. + +## Snapshot and prune boundary + +Initial events, and the LIST fallback, carry the same selector, so a snapshot is exactly the +collection. The writer's sweep must then be bounded by the same collection, or objects that +were never selected would look absent and `Always` would remove their documents. + +[`ResyncScope`](../../internal/git/types.go) gains the selector, and its match reads the +**labels on the Git document**: a document is inside the collection when its group, resource, +and namespace match and its labels match the selector. Git labels are the last mirrored +membership. A document whose object lost the label while no watch was running still carries the +label, is absent from the snapshot, and is swept under `Always`. That is the same result the live +`DELETED` would have produced. A document that never matched is never swept. + +Today `ResyncScope.Matches` receives only a `ResourceIdentifier`, and `resyncPlan` passes it to +`BuildScopedPlan`. The scope predicate has to see the document's labels, so that signature +changes. + +## Rule edits + +A selector edit changes the collection key. The planner stops the old watch and starts a new +one, which initializes from initial events, so objects that match the new selector without +having changed since are still mirrored. + +Documents that only the old selector covered are retained, as documents are today when a rule +stops covering a namespace. Removing or narrowing a rule never deletes content. + +## Recovery and status + +- **Cursors.** [`watchCursorKey`](../../internal/queue/redis_store.go) persists target UID, + group/resource, and namespace. It adds the canonical selector (or a hash of it), so a cursor + is never resumed under a different selection. A new selector is a new key, and the first + attempt of a new watch initializes regardless; see the cursor entry in + [Definitions](../definitions.md). +- **Render fidelity** is already per collection and per watch revision, and it follows the key. +- **`status.streams`** counts resource types. The overlap restriction keeps a type from + appearing under two selectors in one namespace, but a type watched in two namespaces with two + selectors still folds into one entry, as a type in two namespaces does today. + +## Cost and authorization + +Each distinct selector is one more watch on the kube-apiserver's watch cache, which is cheap on +the server. The operator receives only selected objects, which matters most for large +populations such as Helm release Secrets. + +The operator's credential needs `list` and `watch` on the resource either way; RBAC does not +match objects by label. An authorization webhook can see the selector; see +[selectors and authorization](../facts/kubernetes-watch-options.md#selectors-and-authorization). +A filtered request is therefore the least-privilege request, and a denial is an observation +failure, never an empty snapshot. + +## Out of scope + +- **Different selectors on overlapping collections.** This needs per-object membership across + collections and a per-object `resourceVersion` guard. `resourceVersion` is comparable within + one group/resource on kube-apiserver; see [resource versions](../facts/resource-versions.md). + The same mechanism would also close the existing all-namespaces-plus-named overlap. +- **Retaining deselected documents** through a follow-up GET. +- **Field selectors.** They use the same request machinery, but supported fields vary by type. +- **Identifier renames.** [Definitions](../definitions.md) already uses the Kubernetes terms in + prose. The code follows in its own change: `CellKey` → `CollectionKey`, `SourceCell` → + `SourceCollection`, `cellSpec` → `watchSpec`, and the stream, replay, and cursor names. + +## Tests + +Keep the invariants the current tests pin: + +| Invariant | Test | |---|---| -| Two served versions resolve to one collection watch | `TestTargetWatchStreams_OneStreamPerCellAcrossServedVersions` in [target watch tests](../../internal/watch/target_watch_test.go) | -| All-namespace and named-namespace selections retain their filters | `TestTargetWatchSpecs_NamedAndClusterWideScopesStayDistinctStreams` in the same file | -| Unchanged watches survive unrelated rule changes | `TestReplaceGitTargetWatches_AddingARuleStartsOnlyTheNewCell` in the same file | -| Replacement initializes before live delivery | `TestReplaceGitTargetWatches_ReusesUnchangedSetAndRestartsOnSpecChange` in the same file | -| Served-version changes preserve collection identity | `TestTargetWatchPlanFor_AServedVersionBumpKeepsTheSameCell` in [plan tests](../../internal/watch/target_watch_plan_test.go) | -| Cleanup preserves sibling namespaces | `TestResync_NamespaceScopedSweepLeavesSiblingNamespacesAlone` in [scope tests](../../internal/git/resync_scope_test.go) | -| Pending collections hold back type readiness | `TestStreamSummaryForWatchRule_OnePendingNamespaceHoldsTheTypeBack` in [readiness tests](../../internal/watch/source_namespace_stream_summary_test.go) | - -Also preserve revision checks, observation failure versus authoritative absence, and -[source provenance](../../internal/git/source_cell.go): queued provenance is diagnostic, and -cancellation occurs at the producer rather than by filtering queued writes against the latest plan. - -Issue #146 needs additional tests for: - -- Omitted, empty, invalid, and zero-match selectors; consistent initial-event, LIST, and live selection. -- Label entry and exit under the agreed policies, including `deletionTimestamp` on a membership change. -- Snapshots retain present unselected objects without writing their content, protect never-selected - documents, and handle terminating objects consistently with live deletion intent. -- Overlapping clauses, preservation of operation filters, and retention while another rule selects. -- Selector edits include unchanged existing objects and invalidate old render evidence; equivalent - clause reorderings keep watches running. Shared base-collection cursors remain independent of labels. -- Recovery of membership after restart, failed first initialization with a stored cursor, source - authorization including rejected broad watches, and Secret encryption constraints. - -These are proposed implementation checks. This documentation update does not implement selectors -or establish their runtime correctness. Run `task lint-docs` and Vale for documentation changes; -follow [AGENTS.md](../../AGENTS.md) for later code and watch-plumbing validation. - -Keep this working proposal local as requested. When implementation is ready, put the accepted -argument and behavior changes in the #146 PR description or a tracked design record so reviewers -can assess them without access to this file. +| Two served versions resolve to one collection watch | `TestTargetWatchStreams_OneStreamPerCellAcrossServedVersions` | +| All-namespace and named-namespace collections keep their own filters | `TestTargetWatchSpecs_NamedAndClusterWideScopesStayDistinctStreams` | +| Unchanged watches survive unrelated rule changes | `TestReplaceGitTargetWatches_AddingARuleStartsOnlyTheNewCell` | +| Replacement initializes before live delivery | `TestReplaceGitTargetWatches_ReusesUnchangedSetAndRestartsOnSpecChange` | +| A served-version change keeps the collection | `TestTargetWatchPlanFor_AServedVersionBumpKeepsTheSameCell` | +| A sweep leaves sibling namespaces alone | `TestResync_NamespaceScopedSweepLeavesSiblingNamespacesAlone` | + +The first four live in `internal/watch/target_watch_test.go`, the fifth in +`target_watch_plan_test.go`, and the last in `internal/git/resync_scope_test.go`. + +Add: + +- Omitted, `{}`, invalid, zero-match, and stripped-key selectors. +- The selector reaches initial events, the LIST fallback, and the live watch alike. +- Two collections of one type with different selectors in disjoint namespaces both run; an + overlapping pair refuses the newer rule. +- Reordered selector requirements keep the watch; a semantic edit restarts it and mirrors + unchanged objects that now match. +- Label entry and exit produce `CREATE` and `DELETE` under each prune mode, and the author of a + label-removing PATCH is attributed or explicitly unresolved. +- An `Always` sweep removes a document whose label matches but whose object is gone, and leaves a + never-selected document in the same namespace. +- A cursor recorded under one selector is not resumed under another. +- An e2e run against a real kube-apiserver, since label-filtered initial events are server + behavior that unit fakes do not reproduce. From 2fc422d86f8f4ffcca183a37b0022589f202a9ca Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 28 Sep 2026 08:29:08 +0000 Subject: [PATCH 03/10] docs: settle #146's deselection and overlap decisions Removing a label is a deletion from the watch's point of view, and spec.prune.mode alone decides whether the document leaves Git; there is no separate retention option. Overlapping collections with different selectors are refused. Record what the user-facing docs must say about both. Co-Authored-By: Claude Opus 5.5 --- docs/INDEX.md | 2 +- docs/design/watches-labels-simplification.md | 46 +++++++++++++++----- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index 569dc692e..a4f44806c 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -134,7 +134,7 @@ so you never have to read a proposal to find out it already shipped. | [`placement-visibility-and-declared-defaults.md`](design/placement-visibility-and-declared-defaults.md) | **design, half built.** The three questions the inference deletion left, decided and then built in two goes. PR #291 shipped the deletion and none of the eight items queued behind it; the residue was filed as [#295](https://github.com/ConfigButler/gitops-reverser/issues/295) — **which shipped in 0.42.1 via [#319](https://github.com/ConfigButler/gitops-reverser/pull/319) and is what reversed the layout model** — and [#296](https://github.com/ConfigButler/gitops-reverser/issues/296), whose largest half then shipped in [#326](https://github.com/ConfigButler/gitops-reverser/pull/326): `status.layout` became `status.placement`, and the ambiguous render root came with it as a refusal rather than a third counter outcome. The `declared` metric value has since split into `by_type`/`default` too, which was the one row with an external consumer because it changes a label a dashboard may select on. What still stands and is still unbuilt is the rest of the legibility set: the canonical path as a template constant, and `{kindLower}`, tracked on [#339](https://github.com/ConfigButler/gitops-reverser/issues/339) now that #296 is closed; the Event on the GitTarget was dropped. Its Question 2, a CRD default for `placement.default`, is **re-opened rather than superseded** and now lives as an open question in [`layout/model.md`](layout/model.md); the argument against it is legibility, since #319 removed the correctness objection | | [`gittarget-api-wave.md`](design/gittarget-api-wave.md) | **partly built**, filed as [#294](https://github.com/ConfigButler/gitops-reverser/issues/294). One breaking wave on `GitTarget`, of which steps 2 to 7 have **shipped**: `spec.suspend`, `status.placement` and the reconcile-request annotation in [#326](https://github.com/ConfigButler/gitops-reverser/pull/326), and B4's `commitWindow`/`commit.message` move plus the source-scope deletion in [#330](https://github.com/ConfigButler/gitops-reverser/pull/330). Organizing principle: **the folder is described on the GitTarget, the connection describes only the connection** — and this is where that became a struct boundary rather than a sentence, since grouping a field is free only in a release that is already breaking. `spec.mode` and `GitTarget.spec.interval` are both **dropped**, with re-open triggers, and `status.lastHandledReconcileAt` was dropped in the build for a stated reason. What is left is **step 8, the riders** — asserted `CommitRequest.spec.author`, the `CommitRequest` lifecycle hole, the `default` `ClusterProvider` message — tracked on [#339](https://github.com/ConfigButler/gitops-reverser/issues/339); #294 is closed. The reference collapse and the `scope` removal shipped in [#333](https://github.com/ConfigButler/gitops-reverser/pull/333); the `TooManyStreams` cap and the Event on a changed resolution were dropped. Also records that staying `v1alpha3` on loud rejections is a **one-consumer countdown** | | [`target-watch-plan.md`](design/target-watch-plan.md) | **partly built.** The diff is built and applied; removal semantics are not. The companion to [`watch-manager-ownership.md`](design/watch-manager-ownership.md): the ownership page says WHO applies a plan, this one says WHAT a plan is and what changing it may touch. A cell — group, resource, namespace, deliberately no served version — is the one identity the watch stream, the render-fidelity scope and the mark-and-sweep boundary all agree on, because a key that does not round-trip to the scope it sweeps under is the class of error that deletes user data. The plan is diffed into `keep`/`start`/`restart`/`stop` and applied per cell, so adding one WatchRule stops replaying every unrelated cell into a queue shared with other tenants; a `restart` is a served-version change, which is why the version is spec DATA rather than identity. Readiness and the fidelity revision are per scope, so a KEPT cell holds the result its own replay produced rather than being asked to prove itself again over an unrelated edit. `stop` never touches files — removal is a Git-side sweep under the target's existing `spec.prune.mode`, not a watch-layer delete. "Cut at the producer" is the accepted consequence: nothing fences the queue, so a deselected cell may leave a short tail of writes, bounded by the queue and converged afterwards. Still open: the `stop` classification wants a settled `TypeRemoved` from `typeset` (see TODO), and removal on INTENT is undecided. | -| [`watches-labels-simplification.md`](design/watches-labels-simplification.md) | **proposed.** Per-rule `objectSelector` (#146), selected by the kube-apiserver: the selector joins the collection key and is sent as the watch's `labelSelector`, so one collection is one list/watch request. Membership follows Kubernetes, so removing the label removes the document under the prune policy. Overlapping collections of one type must share a selector, and the `Always` sweep is bounded by the selector evaluated on the Git document's labels. | +| [`watches-labels-simplification.md`](design/watches-labels-simplification.md) | **accepted, not built.** Per-rule `objectSelector` (#146), selected by the kube-apiserver: the selector joins the collection key and is sent as the watch's `labelSelector`, so one collection is one list/watch request. Removing the label is a deletion as far as the watch can tell, and `spec.prune.mode` decides whether the document leaves Git; there is no separate retention option. A rule whose collection would overlap another's with a different selector is refused, and the `Always` sweep is bounded by the selector evaluated on the Git document's labels. | | [`attribution-removal-wait-options.md`](design/attribution-removal-wait-options.md) | a removal now waits for evidence about the DELETION rather than accepting the object's last write, which stopped it naming whoever last edited the object as the author of a deletion they did not perform. Enumerates the eight situations a resolution can be in and shows the cost is concentrated in exactly one: a removal for which no delete fact will ever arrive (a graceful pod delete, a status-only removal, a type the audit policy skips) spends the whole grace to return the answer it had at t=0, measured at ~3.1s against ~70ms when evidence is present. Prices five options against that, and recommends a per-route watermark — stop waiting once the fact stream has demonstrably moved past this event — over a second timeout flag whose right value lives in the API server's config rather than ours. Open: the decision, and how common the case is outside the e2e suite | | [`commitrequest-save-wait-options.md`](design/commitrequest-save-wait-options.md) | **design, decided** — a five-phase plan, reported from a live cluster: a `CommitRequest` can expire before the write it exists to publish arrives, because the close delay defaulted to `0` while the write is still held head-of-line waiting for an audit fact, so the save resolves `Ready=True`/`NoWindowInGrace` and the edit commits seconds later under the target's `liveTemplate`. The decision is to repair the default (`spec.closeDelay`, now a duration string defaulting to `"2s"`, **shipped as phase 0**) and then let a request **name the write it is waiting for**, reusing the `(uid, resourceVersion)` identity the attribution join already keys on, including its settled `ExactCapable` rule. Records why the fact index cannot BE the wait (it sits upstream of the watch event, so a fact-keyed wait fires before the window opens) and what to take from it anyway — the register-then-check waiter discipline, and a best-effort retrospective oracle bounded by a TUNABLE TTL. Twelve situations, and the page is deliberate about the two it cannot fix cheaply: row 11 (a saturated queue is rejected in the WATCH path and never reaches the worker, so no worker-side wait can diagnose it) is an accepted, named gap rather than a promise, and row 10 is fixed only by a hook in the fidelity-drop branch that marks a wait terminally UNSATISFIABLE — satisfying before that gate would arm a finalize for a write that never commits. Carries a **preconditions chapter** for saves on today's build (audit policy first, then delay sizing, fidelity warm-up, save-button debounce, and assert on the commit rather than on `Ready`). `CommitterActivity` is deferred, not recommended: by its own arrival-not-existence rule it cannot satisfy the already-arrived case. Open: situation 12 (one actor, two saves, one window — blocks phase 2), whether the fact-index lookup earns its coupling, and how an unsatisfied wait is graded | | [`watch-and-catalog-architecture.md`](design/watch-and-catalog-architecture.md) | the target three-layer watch model — **needs a human call before building** | diff --git a/docs/design/watches-labels-simplification.md b/docs/design/watches-labels-simplification.md index 7d26a6b71..094a6d3f9 100644 --- a/docs/design/watches-labels-simplification.md +++ b/docs/design/watches-labels-simplification.md @@ -1,6 +1,6 @@ # Label selection for watch rules -> **proposed**: nothing here is built. Design for +> **accepted, not built**: the decisions below are settled. Design for > [issue #146](https://github.com/ConfigButler/gitops-reverser/issues/146). > Index: [`../INDEX.md`](../INDEX.md) > Date: 2026-09-28, checked against `main` at `b43ecf61`. @@ -98,7 +98,8 @@ other; see [Overlapping watches](../spec/watch-event-ordering-and-attribution-gr With different selectors, one object can leave one collection (`DELETED`) while it stays in the other (`MODIFIED`), and the Git outcome would depend on which event arrives first. -When a rule would violate this, the oldest rule (by creation timestamp, then name) keeps the +**Decided:** a rule that would create an overlapping collection with a different selector is +refused. The oldest rule (by creation timestamp, then name) keeps the collection, and the newer rule reports the refusal on its own status. Keys that need several values stay expressible in one selector with a set-based requirement, such as `team in (a,b)`. @@ -119,14 +120,35 @@ The operator maps them through the existing operation mapping without reinterpre | A matching object gets a `deletionTimestamp` | `MODIFIED` | `DELETE` (deletion intent, as today) | | A matching object is deleted, or loses the label | `DELETED` | `DELETE` | -The last row is the consequence to accept: **removing the label removes the document**, under -the GitTarget's prune policy. With the default `OnEvent`, the file is deleted; with `Never`, it -is retained; a rule without `DELETE` in its operations ignores both causes alike. The Git folder -then shows what `kubectl get -l ` shows, which is the point of offloading selection. +**Decided: removing the label is a deletion.** From the watch's point of view the two are the +same event, and the operator does not try to tell them apart. The Git folder shows what +`kubectl get -l ` shows, which is the point of offloading selection. Whether the +document leaves Git is decided by `spec.prune.mode`, exactly as for a deleted object: -Telling a deselection apart from a deletion would need a follow-up GET after each `DELETED`, -and a failed GET would leave the answer unknown. That is out of scope; it can be added later if -users need deselection to retain. +| `spec.prune.mode` | Label removed while the watch runs | Label removed while nothing watched | +|---|---|---| +| `Never` | Document kept | Document kept | +| `OnEvent` (default) | Document removed | Document kept | +| `Always` | Document removed | Document removed at the next snapshot sweep | + +A rule without `DELETE` in its operations ignores the event for both causes alike. There is no +separate retention option for deselected objects; `Never` is the way to keep them. Telling a +deselection apart from a deletion would need a follow-up GET after each `DELETED`, and a failed +GET would leave the answer unknown, so this is not planned. + +### User-facing documentation + +The configuration reference for `objectSelector` must say, before any example: + +- A selector defines which objects are mirrored. An object that stops matching leaves the + mirror the same way a deleted object does, because Kubernetes reports both as one `DELETED` + event. +- The table above, including the default: with `OnEvent`, removing the label removes the + document from Git. +- If the folder is applied elsewhere by a GitOps tool that prunes, removing the label there + deletes the object there. +- A rule whose collection would overlap another rule's with a different selector is refused, + with the reason on the rule's status. ## Snapshot and prune boundary @@ -184,7 +206,6 @@ failure, never an empty snapshot. collections and a per-object `resourceVersion` guard. `resourceVersion` is comparable within one group/resource on kube-apiserver; see [resource versions](../facts/resource-versions.md). The same mechanism would also close the existing all-namespaces-plus-named overlap. -- **Retaining deselected documents** through a follow-up GET. - **Field selectors.** They use the same request machinery, but supported fields vary by type. - **Identifier renames.** [Definitions](../definitions.md) already uses the Kubernetes terms in prose. The code follows in its own change: `CellKey` → `CollectionKey`, `SourceCell` → @@ -216,8 +237,9 @@ Add: unchanged objects that now match. - Label entry and exit produce `CREATE` and `DELETE` under each prune mode, and the author of a label-removing PATCH is attributed or explicitly unresolved. -- An `Always` sweep removes a document whose label matches but whose object is gone, and leaves a - never-selected document in the same namespace. +- A label removed while nothing watched: the document stays under `Never` and `OnEvent`, and the + next `Always` sweep removes it. The same sweep leaves a never-selected document in the same + namespace alone. - A cursor recorded under one selector is not resumed under another. - An e2e run against a real kube-apiserver, since label-filtered initial events are server behavior that unit fakes do not reproduce. From 69836e700023d92311d72f07220104ab53134085 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 28 Sep 2026 08:57:25 +0000 Subject: [PATCH 04/10] docs: desiging further --- docs/design/watches-labels-simplification.md | 216 ++++++++++++++++-- ...ch-event-ordering-and-attribution-grace.md | 36 ++- 2 files changed, 227 insertions(+), 25 deletions(-) diff --git a/docs/design/watches-labels-simplification.md b/docs/design/watches-labels-simplification.md index 094a6d3f9..f2882d44a 100644 --- a/docs/design/watches-labels-simplification.md +++ b/docs/design/watches-labels-simplification.md @@ -1,6 +1,8 @@ # Label selection for watch rules -> **accepted, not built**: the decisions below are settled. Design for +> **accepted direction, not built**: server-side selection and membership semantics are settled. +> Kustomize support and rule-edit retention below are proposed refinements awaiting a choice. +> Design for > [issue #146](https://github.com/ConfigButler/gitops-reverser/issues/146). > Index: [`../INDEX.md`](../INDEX.md) > Date: 2026-09-28, checked against `main` at `b43ecf61`. @@ -48,11 +50,9 @@ The name and type follow the admission webhook `objectSelector`, which sits besi matching semantics are those of list and watch, described below; admission's old-or-new match does not apply to a watch. -The structured type is the Kubernetes convention for a selector in an API object, so it gets -schema validation and reads the same as every other selector in a cluster. It converts -losslessly to the query string with `metav1.LabelSelectorAsSelector(sel).String()`, and that -string is what the operator sends. A raw string field would save one conversion and lose the -schema. +The structured type follows the Kubernetes convention for a selector in an API object. +`metav1.LabelSelectorAsSelector` validates and compiles it. The compiler then normalizes the +requirements as described below and sends the resulting query string to the API server. Validation, at admission and again at compile time: @@ -83,8 +83,35 @@ land on different collections, each with its own operation filter. Separate unio selectors and operations would have let `team=a` rules' operations apply to `team=b` objects; keying on the selector makes that impossible without any clause machinery. -The canonical string comes from the parsed `labels.Selector`, which sorts requirements by key, -so reordering `matchLabels` or `matchExpressions` in a rule does not restart a watch. +### Canonical selector identity + +The compiler produces one stable string for the watch request and every collection key. +`labels.Selector.String()` alone does not provide that identity: +[`ByKey.Less`](https://github.com/kubernetes/apimachinery/blob/v0.37.0/pkg/labels/selector.go#L152) +sorts requirements only by key. Two requirements on the same key can retain their input order: + +```text +team in (a,b),team notin (b) +team notin (b),team in (a,b) +``` + +Both select the same objects. Treating their strings as distinct would restart a watch, change +its cursor key, and potentially refuse a compatible rule as an overlap conflict. + +Normalize after validation: + +1. Omitted and empty selectors both become the empty query string. +2. Represent `matchLabels` equality as a singleton `In` requirement, so it has the same identity + as the corresponding `matchExpressions` entry. +3. Sort and deduplicate each requirement's values, then deduplicate identical requirements. +4. Sort complete requirements by key, operator, and values. Serialize them in that order; + sorting only by key again would lose the tie-break. + +This normalization preserves matching semantics and makes presentation changes inert. It does +not attempt general Boolean simplification: `team in (a,b),team notin (b)` and `team in (a)` may +still have different identities. Overlap compatibility compares the normalized identities. +Watch planning, operation union, cursor storage, resync coalescing, and render-fidelity scopes +must all use the same identity. ### One selector per overlapping collection @@ -136,6 +163,42 @@ separate retention option for deselected objects; `Never` is the way to keep the deselection apart from a deletion would need a follow-up GET after each `DELETED`, and a failed GET would leave the answer unknown, so this is not planned. +### Attribution for filtered removals + +A Git `DELETE` describes what the writer does. Its attribution must also retain the source watch +event type, the presence of a nonempty selector, and the unsanitized UID, `resourceVersion`, and +deletion timestamp. Those observations distinguish the attribution policies without a follow-up +GET or local label matching. + +The current [`attachAuthor`](../../internal/watch/target_watch.go) sets `ExactCapable=false` +for every Git deletion. [`FactIndex.Await`](../../internal/queue/fact_index.go) can then return +an earlier writer's fact when the grace expires. For example, Alice edits at RV 10, Bob removes +the selector label at RV 11, and Bob's audit fact is missing. Reusing that lookup attributes +Bob's change to Alice instead of reporting unresolved attribution. + +Filtered `DELETED` events therefore need a strict lookup policy: + +- Join a fact to the observed mutation using the same audit route, group/resource, UID, and RV. + Wait up to the existing grace for eligible evidence; otherwise use the unresolved author. +- Do not fall through to the latest writer, a sticky deletion pointer, collection membership or + scope, or a name-only match. Those facts do not establish which mutation ended this membership. +- An exact PATCH or UPDATE fact can name a label-exit mutation when the event's previous object + has no deletion timestamp. The payload contains the old labels, so its labels cannot prove + the new selection state; the server's event provides that observation. +- If the previous object is already terminating, an exact finalizer PATCH must not name the + actor who requested deletion. Require an exact deletion fact or leave this completion event + unresolved. Physical deletions whose facts lack the matching UID/RV also remain unresolved. + +This intentionally gives filtered removals a narrower attribution guarantee. A live +`MODIFIED` carrying a deletion timestamp still uses the existing deletion-intent policy, and +unfiltered watches keep their current policy. Merely setting `ExactCapable=true` is insufficient: +the current lookup still has broader fallback tiers, and a finalizer PATCH needs the distinction +above. The implementation needs an explicit policy at the resolver/index boundary. + +Resolution remains inline, so both a matched fact and a grace expiry preserve the +[per-watch release order](../spec/watch-event-ordering-and-attribution-grace.md#two-updates-on-one-watch). +Attribution uncertainty changes the author, not the membership transition or prune decision. + ### User-facing documentation The configuration reference for `objectSelector` must say, before any example: @@ -149,6 +212,11 @@ The configuration reference for `objectSelector` must say, before any example: deletes the object there. - A rule whose collection would overlap another rule's with a different selector is refused, with the reason on the rule's status. +- The selected Kustomize support boundary, including how render failures are reported, and the + rule-edit retention behavior below. A filtered snapshot is compared with effective Git + labels, which can differ from the live labels after missed updates. +- A filtered removal with insufficient audit evidence uses the unresolved author. It is still + processed according to the prune policy. ## Snapshot and prune boundary @@ -156,16 +224,69 @@ Initial events, and the LIST fallback, carry the same selector, so a snapshot is collection. The writer's sweep must then be bounded by the same collection, or objects that were never selected would look absent and `Always` would remove their documents. -[`ResyncScope`](../../internal/git/types.go) gains the selector, and its match reads the -**labels on the Git document**: a document is inside the collection when its group, resource, -and namespace match and its labels match the selector. Git labels are the last mirrored -membership. A document whose object lost the label while no watch was running still carries the -label, is absent from the snapshot, and is swept under `Always`. That is the same result the live -`DELETED` would have produced. A document that never matched is never swept. - -Today `ResyncScope.Matches` receives only a `ResourceIdentifier`, and `resyncPlan` passes it to -`BuildScopedPlan`. The scope predicate has to see the document's labels, so that signature -changes. +[`ResyncScope`](../../internal/git/types.go) gains the normalized selector. A sweep candidate +must have both a matching effective group/resource/namespace and matching **effective Git +labels**, determined at the Git revision the writer evaluates. A candidate absent from the +selected snapshot can then be removed under `Always`. + +For plain YAML, those labels are the document's `metadata.labels`. For Kustomize, they are the +rendered object's labels. The source file may omit or override them: the writer deliberately +preserves that source form, as +[`TestSourceForm_InjectedMetadataStaysOutOfTheSource`](../../internal/manifestanalyzer/source_form_test.go) +demonstrates. Raw source labels cannot define a Kustomize sweep boundary. + +For example, an overlay adds `env=prod` to an unlabeled source document. An `env=prod` watch +selects the live object, and its document must be eligible for an `Always` sweep if the object +disappears. Conversely, an `env!=prod` watch excludes it. Testing the unlabeled source file in +that second case would select and delete a document outside the watched collection. + +Effective Git labels are the state represented by the evaluated Git revision. They are not a +durable record of every previously observed membership: rule edits, missed watch events, and +independent Git edits can change the comparison. The retention boundary is specified below. + +### Kustomize support boundary + +**Proposed choice: allow verified Kustomize support.** Existing support already includes +[`DocumentModel.Rendered`](../../internal/manifestanalyzer/store.go) and the complete +[`RenderedOverrides.Object`](../../internal/manifestanalyzer/overrides_attribution.go). +Use that render and its source mapping for membership, within the existing supported layouts. +The alternative is to refuse filtered targets that use Kustomize for the first implementation; +that reduces the initial integration work but excludes currently supported repositories. + +Under the proposed choice: + +- Before applying a filtered snapshot, classify each candidate in its effective type and + namespace scope as plain YAML with readable labels, Kustomize with verified rendered labels + and source mapping, or unknown. An unknown candidate refuses the snapshot before any writes; + it cannot be interpreted as an empty label set or silently left outside the scope. +- A nil `Rendered` value does not prove the document is plain YAML. It can also mean conflicting + render roots or unavailable attribution. Require explicit evidence of the document's render + context before selecting the plain-YAML path. Report the candidate and refusal on the target. +- The labels and source mapping must come from the same Git revision as the plan. Preserve the + existing write-fan-in and + [`VerifyBatchRenders`](../../internal/manifestanalyzer/render_verify.go) checks for live writes + and sweeps. Filtering cannot authorize an edit that changes an unrelated rendered object. +- A failed render, conflicting roots, or unreadable label metadata refuses the affected write + or snapshot. Surface the failure through the existing acceptance/render-fidelity reporting; + it must not report successful convergence. Re-evaluate after Git changes. + +This requires integration work in the writer. Availability of the rendered object alone does +not establish the new selector checks. Unsupported Kustomize features stay outside the existing +[support boundary](support-boundary/support-contract.md). + +### Scope integration and queue ordering + +Today `ResyncScope.Matches` receives only a `ResourceIdentifier`, and +[`resyncPlan`](../../internal/git/resync_flush.go) passes it to `BuildScopedPlan`. The sweep +predicate needs effective labels and a way to report unknown membership as a refusal. A Boolean +predicate over an identifier cannot express that boundary. + +Keep queue-ordering checks separate from the sweep predicate. +[`markResyncTailForWriteLocked`](../../internal/git/branch_worker.go) currently uses scope +matching to prevent a new snapshot from moving ahead of an already queued live write. A Git +`DELETE` carries no object payload. That check must conservatively match target, group/resource, +and namespace even when labels are unavailable. Overmatching can prevent coalescing; dropping +the ordering check can let an older deletion erase an object restored by a newer snapshot. ## Rule edits @@ -173,8 +294,27 @@ A selector edit changes the collection key. The planner stops the old watch and one, which initializes from initial events, so objects that match the new selector without having changed since are still mirrored. -Documents that only the old selector covered are retained, as documents are today when a rule -stops covering a namespace. Removing or narrowing a rule never deletes content. +**Proposed choice: keep the existing prune policy and qualify retention.** Stopping a collection +does not enqueue a cleanup of its former scope. The new collection's snapshot applies the +configured prune mode to its effective Git-side selection. Documents outside that selection +are retained by that sweep. Other active collections can still cover and update them. + +This does not guarantee retention of every live object a narrowed rule now excludes. Consider: + +1. The old selector is `team in (a,b)`, and Git represents an object with `team=a`. +2. While observation is interrupted, its live label changes to `team=b`. +3. The rule narrows to `team=a` before observation resumes. +4. The new snapshot excludes the object, but its effective Git labels still match `team=a`. + +Under `Always`, the new snapshot removes the document. Under `Never` and `OnEvent`, that snapshot +retains it. A Git document already representing `team=b` stays outside the new sweep under all +three modes. An operation filter that missed an earlier UPDATE can leave the same stale-label +case even without a disconnected watch. + +Strict retention across rule narrowing is the alternative. It requires a separate record or +transition protocol to distinguish retired coverage from absence inside the new collection; +the filtered snapshot and current Git labels alone cannot establish that history. The proposed +choice adds no retention ledger and makes this prune behavior explicit in the configuration docs. ## Recovery and status @@ -233,13 +373,41 @@ Add: - The selector reaches initial events, the LIST fallback, and the live watch alike. - Two collections of one type with different selectors in disjoint namespaces both run; an overlapping pair refuses the newer rule. -- Reordered selector requirements keep the watch; a semantic edit restarts it and mirrors - unchanged objects that now match. -- Label entry and exit produce `CREATE` and `DELETE` under each prune mode, and the author of a - label-removing PATCH is attributed or explicitly unresolved. +- Reordered requirements, including two expressions on the same key, reordered values, and + duplicate requirements keep the watch and cursor key. `matchLabels: {team: a}` and its singleton + `In` expression share an identity and pass overlap compatibility. A selection change starts + a new collection and mirrors unchanged objects that now match. +- Label entry and exit produce `CREATE` and `DELETE` under each prune mode. +- Alice's earlier RV 10 fact is present; Bob removes the label at RV 11. With Bob's exact fact, + resolve Bob, including when it arrives during grace. Without it, expire as unresolved even + when Alice's fact, an unrelated deletion pointer, or a collection deletion fact is available. +- A filtered physical deletion with insufficient exact evidence is unresolved. A terminating + object's exact finalizer-PATCH fact does not author its removal; deletion-intent `MODIFIED` + events and unfiltered watches retain their existing policy. +- An event waiting for its author cannot be overtaken by a later event on the same watch, and + cancellation during the wait prevents enqueue. See the ordering spec's + [regression cases](../spec/watch-event-ordering-and-attribution-grace.md#regression-cases). - A label removed while nothing watched: the document stays under `Never` and `OnEvent`, and the next `Always` sweep removes it. The same sweep leaves a never-selected document in the same namespace alone. +- Kustomize adds `env=prod`, while the source manifest has no `env` label. With an `env=prod` + selector, an empty snapshot removes its document under `Always`; `Never` and `OnEvent` retain + it. With `env!=prod`, the same snapshot leaves it untouched under every prune mode. Repeat + with a source label overridden by the overlay, and with a `DoesNotExist` selector. +- Kustomize roots disagree about a candidate's effective labels, rendering fails, or label + metadata cannot be read. Under the proposed verified-support choice, refuse the snapshot + before writing, report the failure, and succeed after the ambiguity is corrected. Prove that + nil `Rendered` cannot silently enter the plain-YAML path. Keep the plain-YAML control case. +- A selected Kustomize live update or deletion would change another root's unselected object. + The existing render/write-fan-in checks refuse the write. Rechecking a changed Git revision + uses that revision's labels and source mapping. +- Narrow `team in (a,b)` to `team=a` after a missed `a` to `b` label change. Git still represents + `team=a`. Under the proposed prune-policy choice, `Always` removes the document; `Never` and + `OnEvent` retain it. Repeat with the UPDATE excluded by the operation filter. A control case + whose Git labels already say `team=b` is retained by the new sweep under every mode. +- Queue snapshot R100, a label-exit `DELETE` at R101 with no object payload, then a reconnect + snapshot R103 where the object matches again. Preserve all three FIFO positions so the final + object is present. The delete must prevent R103 from coalescing into R100's earlier position. - A cursor recorded under one selector is not resumed under another. - An e2e run against a real kube-apiserver, since label-filtered initial events are server behavior that unit fakes do not reproduce. diff --git a/docs/spec/watch-event-ordering-and-attribution-grace.md b/docs/spec/watch-event-ordering-and-attribution-grace.md index c38a352bd..5b2ebb47c 100644 --- a/docs/spec/watch-event-ordering-and-attribution-grace.md +++ b/docs/spec/watch-event-ordering-and-attribution-grace.md @@ -6,7 +6,8 @@ > Checked: 2026-09-28 > Related: [Kubernetes watch options](../facts/kubernetes-watch-options.md), > [Watch-first ingestion architecture](../finished/watch-first-ingestion-architecture.md), -> [Attribution contract](attribution.md) +> [Attribution contract](attribution.md), +> [Label-selection design](../design/watches-labels-simplification.md) ## The question @@ -72,6 +73,13 @@ With attribution enabled, an unresolved event uses the explicit `unknown (attribution unresolved)` author. With attribution disabled, the configured committer also authors the change. A fact arriving after a commit is written does not rewrite it. +The index determines what counts as a resolution. Its current removal lookup can hold an +earlier writer's fact as a fallback and return it at grace expiry; see +[`FactIndex.Await`](../../internal/queue/fact_index.go). Ordered delivery does not establish +that this author caused a label-exit mutation. The proposed +[filtered-removal attribution policy](../design/watches-labels-simplification.md#attribution-for-filtered-removals) +requires stricter evidence while preserving the same inline ordering. That policy is not built. + ### Preserve ordered release if attribution becomes concurrent Do not parallelize per-event attribution without an in-order release buffer. A later event @@ -133,3 +141,29 @@ The accepted resync enters the worker queue before subsequent live events from t The watch does not wait for the Git write to finish before reading those live events. Queue order and successful Git completion are distinct facts. Other watches can enqueue work during this process, so this transition does not resolve overlapping-source ordering. + +## Regression cases + +The following cases specify the ordering checks to preserve when adding label selection or +changing attribution. They are test requirements; this list does not claim each has a dedicated +test today. + +- Deliver U1 and U2 for the same object on one watch. Hold U1's attribution while U2's fact is + already available. U2 must not route before U1 resolves or expires. Repeat with U1 unresolved + and U2 resolved, and verify that the later accepted update supplies the final content. +- Cancel a watch while attribution waits. It must not enqueue the waiting event on exit; the + replacement initializes current state. The existing cancellation case lives in + [`target_watch_test.go`](../../internal/watch/target_watch_test.go). +- Enqueue an initial snapshot followed by a live update, then enqueue another snapshot for the + same scope. Resync coalescing must preserve the intervening live write's position. The existing + queue checks live in [`resync_push_test.go`](../../internal/git/resync_push_test.go). +- Refuse an event because the worker queue is full. The routing error must leave its watch + cursor unadvanced; queue admission is the boundary asserted by this check. +- Exercise overlapping watches with delayed attribution on one. Assert each watch's own order; + do not infer a global mutation order from their combined arrivals. The label-selection design + refuses conflicting selectors but retains the existing same-selector namespace overlap. + +The label-selection design owns the additional +[selector, attribution, and prune cases](../design/watches-labels-simplification.md#tests), including +missing label-removal audit facts. Those cases concern which content and author reach Git in +addition to the ordering checks here. From 1806d3c0df7b6afebc8916ae6d3a002d8a1bd11a Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 28 Sep 2026 10:08:45 +0000 Subject: [PATCH 05/10] chore: more insights --- docs/design/watches-labels-simplification.md | 204 +++++++++++++++---- 1 file changed, 161 insertions(+), 43 deletions(-) diff --git a/docs/design/watches-labels-simplification.md b/docs/design/watches-labels-simplification.md index f2882d44a..b4ff8f2f5 100644 --- a/docs/design/watches-labels-simplification.md +++ b/docs/design/watches-labels-simplification.md @@ -20,6 +20,10 @@ Kubernetes list/watch request: group, resource, namespace, and label selector. T evaluates no label logic on the event path, and it adopts the Kubernetes membership semantics as they are rather than modeling membership itself. +Snapshot pruning under `Always` introduces a separate Git-side selection decision. Server-side +filtering does not establish which Git documents a snapshot may remove; the +[Kustomize support boundary](#kustomize-support-boundary) explains that distinction and its limits. + The cost is more watches: one per distinct selector. That cost is accepted. ## The API @@ -158,6 +162,10 @@ document leaves Git is decided by `spec.prune.mode`, exactly as for a deleted ob | `OnEvent` (default) | Document removed | Document kept | | `Always` | Document removed | Document removed at the next snapshot sweep | +The `Always` row requires support for pruning the target's Git layout with a selector. The +proposed first implementation below defers that combination for Kustomize. It must report an +unsupported combination explicitly. + A rule without `DELETE` in its operations ignores the event for both causes alike. There is no separate retention option for deselected objects; `Never` is the way to keep them. Telling a deselection apart from a deletion would need a follow-up GET after each `DELETED`, and a failed @@ -212,17 +220,22 @@ The configuration reference for `objectSelector` must say, before any example: deletes the object there. - A rule whose collection would overlap another rule's with a different selector is refused, with the reason on the rule's status. -- The selected Kustomize support boundary, including how render failures are reported, and the - rule-edit retention behavior below. A filtered snapshot is compared with effective Git - labels, which can differ from the live labels after missed updates. +- The selected Kustomize support boundary, including unsupported prune combinations and render + failures. Under `Always`, a filtered snapshot is compared with effective Git labels; admission, + later updates, or independent Git edits can make those differ from live labels. +- The rule-edit retention behavior below, and that `OnEvent` retains documents when removal + events are missed and recovery starts from a fresh snapshot. - A filtered removal with insufficient audit evidence uses the unresolved author. It is still processed according to the prune policy. ## Snapshot and prune boundary Initial events, and the LIST fallback, carry the same selector, so a snapshot is exactly the -collection. The writer's sweep must then be bounded by the same collection, or objects that -were never selected would look absent and `Always` would remove their documents. +selected collection. Under `Always`, the writer also needs a Git-side scope: without one, an +empty filtered snapshot could make every document of that resource type appear removable. + +The rendered-label approach below specifies that scope if filtered `Always` pruning is enabled +for Kustomize. Supporting filtered watches with `OnEvent` or `Never` does not require this sweep. [`ResyncScope`](../../internal/git/types.go) gains the normalized selector. A sweep candidate must have both a matching effective group/resource/namespace and matching **effective Git @@ -246,32 +259,108 @@ independent Git edits can change the comparison. The retention boundary is speci ### Kustomize support boundary -**Proposed choice: allow verified Kustomize support.** Existing support already includes -[`DocumentModel.Rendered`](../../internal/manifestanalyzer/store.go) and the complete +Kustomize support and filtered snapshot pruning are separate decisions. Kustomize builds +manifests before they reach the API server; the server selects the stored objects resulting +from those manifests. See the upstream +[Kustomize documentation](https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/). +The operator already runs the Kustomize library in +[`renderRoot`](../../internal/manifestanalyzer/kustomize_render.go), and exposes its output through +[`DocumentModel.Rendered`](../../internal/manifestanalyzer/store.go) and [`RenderedOverrides.Object`](../../internal/manifestanalyzer/overrides_attribution.go). -Use that render and its source mapping for membership, within the existing supported layouts. -The alternative is to refuse filtered targets that use Kustomize for the first implementation; -that reduces the initial integration work but excludes currently supported repositories. - -Under the proposed choice: - -- Before applying a filtered snapshot, classify each candidate in its effective type and - namespace scope as plain YAML with readable labels, Kustomize with verified rendered labels - and source mapping, or unknown. An unknown candidate refuses the snapshot before any writes; - it cannot be interpreted as an empty label set or silently left outside the scope. -- A nil `Rendered` value does not prove the document is plain YAML. It can also mean conflicting - render roots or unavailable attribution. Require explicit evidence of the document's render - context before selecting the plain-YAML path. Report the candidate and refusal on the target. -- The labels and source mapping must come from the same Git revision as the plan. Preserve the - existing write-fan-in and - [`VerifyBatchRenders`](../../internal/manifestanalyzer/render_verify.go) checks for live writes - and sweeps. Filtering cannot authorize an edit that changes an unrelated rendered object. -- A failed render, conflicting roots, or unreadable label metadata refuses the affected write - or snapshot. Surface the failure through the existing acceptance/render-fidelity reporting; - it must not report successful convergence. Re-evaluate after Git changes. -This requires integration work in the writer. Availability of the rendered object alone does -not establish the new selector checks. Unsupported Kustomize features stay outside the existing +A server-selected live event can use the existing source-editing machinery. An `Always` sweep +also needs to select Git documents locally, using the compiled Kubernetes selector against +their effective labels. This uses Kubernetes' selector library and the existing renderer; +it requires no simulation of API-server admission or controller behavior. + +```mermaid +flowchart LR + A[Live API objects] --> B[API-server label selection] + B --> C[Selected snapshot] + D[Git files] --> E[Existing Kustomize renderer] + E --> F[Local label selection] + C --> G[Compare for Always pruning] + F --> G +``` + +With `OnEvent` or `Never`, a fresh snapshot upserts the selected objects and retains documents +that are absent. There is no absence-based deletion to scope by Git labels. The existing +[`resyncPlanPolicy`](../../internal/git/resync_flush.go) already makes this prune-mode distinction. +Source mapping and render verification still apply to the writes that the snapshot or live +events request. + +### Options for the first implementation + +**Proposed recommendation: support filtered Kustomize watches with `OnEvent` and `Never`, and +defer filtered `Always` pruning for Kustomize.** This is a proposal awaiting a choice. It keeps +the default prune mode useful while leaving the Git-side selection contract explicit. + +| Option | Added work | Main consequence | +|---|---|---| +| `OnEvent` and `Never` first | Reuse supported Kustomize editing; reject filtered `Always` | Missing removal events can leave retained documents | +| Add `Always` using rendered Git labels | Scope sweeps by rendered labels and source mapping | Git-side scope can differ from live membership | +| Add a durable membership inventory | Persist observed membership and its Git ownership | Adds recovery, adoption, and rule-transition state | +| Block all filtered Kustomize targets initially | Detect and refuse the combination | Also excludes layouts usable with `OnEvent` and `Never` | + +Under the first option, reject a nonempty selector with `Always` when its resource collection +requires Kustomize context in the target. Do this before applying the affected snapshot or live +writes. Recheck when rules, prune mode, or Git layout change, and report the reason on the +target and referencing rules. An unsupported combination must not silently weaken the prune +mode. Omitted and empty selectors keep the existing unfiltered behavior. + +A durable inventory could record the observed membership of previously mirrored objects. +It still needs a source mapping, ownership records tied to successful Git writes, and explicit +handling of restarts and rule changes. Pre-existing Git documents have no such history, so +adoption must be defined. This is a larger design than reusing the renderer. + +Blocking Kustomize alone does not remove local selector evaluation from `Always`: plain YAML +also needs a Git-side scope. Requiring no local label matching anywhere would defer this +rendered-label pruning approach for all filtered targets. + +### Limits and refusal behavior + +**A successful render establishes only the Git representation. Cluster membership can differ.** +[Mutating admission](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/) +can change an object before storage, and controllers can change its labels later. Deployment-time +substitutions or other transformations outside the supplied Git build inputs are also outside +the local renderer's knowledge. The operator must not attempt to reproduce those processes. + +For example, Git renders `env=prod`, but admission changes the stored label to `env=qa`. An +`env=prod` snapshot excludes the object. Under the proposed Git-side `Always` rule, its document +is eligible for removal even though the object still exists. A GitOps tool that applies that +Git deletion with pruning can then delete the live object. The same mismatch can occur with +plain YAML. This consequence must be accepted and documented before enabling that pruning rule. + +Git edits, missed events, and operation filters also mean rendered labels cannot prove whether +an object previously belonged to a watch. In particular, the renderer does not resolve the +[rule-narrowing retention case](#rule-edits). `OnEvent` avoids inferring deletion from a fresh +snapshot, at the cost of retaining documents for missed removals. An observed label exit still +removes its document under `OnEvent`, subject to the operation filter and existing write checks. + +If rendered-label `Always` support is chosen, the writer needs these boundaries: + +- Before a sweep, classify candidates in its effective type and namespace scope as plain YAML + with readable labels, Kustomize with verified rendered labels and source mapping, or unknown. + Unknown membership refuses the affected snapshot before any writes. It cannot be interpreted + as an empty label set or silently left outside the scope. +- A nil `Rendered` value can mean conflicting render roots or unavailable attribution. Require + explicit evidence of plain-YAML context before reading raw source labels as effective labels. +- A source shared by incompatible overlays can affect another environment when edited or + deleted. Preserve the existing write-fan-in and + [`VerifyBatchRenders`](../../internal/manifestanalyzer/render_verify.go) checks for live writes + and sweeps; selector membership alone cannot authorize that source change. +- A failed render, conflicting roots, or unreadable required label metadata refuses the affected + operation. Name the candidate and reason through the existing acceptance/render-fidelity + reporting. Do not report successful convergence or fall back to a broader sweep. Retry after + the relevant inputs change. These new sweep checks are additional to existing write checks. +- Evaluate labels, source mapping, and the write plan against the same Git revision. Reuse its + rendered objects when possible and invalidate them when the inputs change. Rendering consumes + CPU and memory; this feature should not introduce a separate build for every watch event. + +The proposed LIST/WATCH requests stay filtered. Once watch history has expired, a fresh LIST or +GET cannot recover a deleted object's historical labels for the writer. Availability of a +rendered object is useful evidence, but integration and tests must establish the new scope +checks. Unsupported Kustomize features stay outside the existing [support boundary](support-boundary/support-contract.md). ### Scope integration and queue ordering @@ -306,10 +395,10 @@ This does not guarantee retention of every live object a narrowed rule now exclu 3. The rule narrows to `team=a` before observation resumes. 4. The new snapshot excludes the object, but its effective Git labels still match `team=a`. -Under `Always`, the new snapshot removes the document. Under `Never` and `OnEvent`, that snapshot -retains it. A Git document already representing `team=b` stays outside the new sweep under all -three modes. An operation filter that missed an earlier UPDATE can leave the same stale-label -case even without a disconnected watch. +Where filtered `Always` is supported, the new snapshot removes the document. Under `Never` and +`OnEvent`, that snapshot retains it. A Git document already representing `team=b` stays outside +the new sweep under all three modes. An operation filter that missed an earlier UPDATE can +leave the same stale-label case even without a disconnected watch. Strict retention across rule narrowing is the alternative. It requires a separate record or transition protocol to distinguish retired coverage from absence inside the new collection; @@ -388,16 +477,8 @@ Add: cancellation during the wait prevents enqueue. See the ordering spec's [regression cases](../spec/watch-event-ordering-and-attribution-grace.md#regression-cases). - A label removed while nothing watched: the document stays under `Never` and `OnEvent`, and the - next `Always` sweep removes it. The same sweep leaves a never-selected document in the same - namespace alone. -- Kustomize adds `env=prod`, while the source manifest has no `env` label. With an `env=prod` - selector, an empty snapshot removes its document under `Always`; `Never` and `OnEvent` retain - it. With `env!=prod`, the same snapshot leaves it untouched under every prune mode. Repeat - with a source label overridden by the overlay, and with a `DoesNotExist` selector. -- Kustomize roots disagree about a candidate's effective labels, rendering fails, or label - metadata cannot be read. Under the proposed verified-support choice, refuse the snapshot - before writing, report the failure, and succeed after the ambiguity is corrected. Prove that - nil `Rendered` cannot silently enter the plain-YAML path. Keep the plain-YAML control case. + next supported `Always` sweep removes it. The same sweep leaves a document whose effective + Git labels do not match the selector untouched, even in the same namespace. - A selected Kustomize live update or deletion would change another root's unselected object. The existing render/write-fan-in checks refuse the write. Rechecking a changed Git revision uses that revision's labels and source mapping. @@ -411,3 +492,40 @@ Add: - A cursor recorded under one selector is not resumed under another. - An e2e run against a real kube-apiserver, since label-filtered initial events are server behavior that unit fakes do not reproduce. + +### Kustomize support and pruning limits + +For the proposed `OnEvent`/`Never` first implementation: + +- An overlay supplies `env=prod`; its source manifest omits the label. Filtered initial and + live events update that source through the existing writer. No additional Git-side label + selection is needed for pruning. The render and source mapping still have to pass the existing + write checks. +- A live label exit removes the document with `OnEvent` and retains it with `Never`. When the + event is missed and recovery instead delivers an empty fresh snapshot, both modes retain the + document, even if its rendered labels still match. +- A nonempty selector combined with `Always` and Kustomize context refuses the affected work + before any writes and reports the unsupported combination. Repeat after changing an existing + target from `OnEvent` to `Always`, and after Git introduces Kustomize context. Omitted and empty + selectors keep existing behavior. + +If rendered-label `Always` support is chosen, also require: + +- Kustomize adds `env=prod`, while the source manifest has no `env` label. With an `env=prod` + selector, an empty snapshot removes its document under `Always`. With `env!=prod`, it leaves + the document untouched. Repeat with a source label overridden by the overlay and a + `DoesNotExist` selector; source labels must not determine rendered membership. +- Git renders `env=prod`, while admission or a later controller stores `env=qa`. The `env=prod` + snapshot excludes the object. Assert the documented Git-side rule: `Always` may remove its + document despite the object's existence; `OnEvent` and `Never` retain it on that snapshot. + Include plain-YAML and Kustomize cases so the test does not imply rendering solves this gap. +- Kustomize roots disagree about a candidate's labels, rendering fails, or required label + metadata cannot be read. Refuse the affected snapshot before writing, report the failure, and + recover after the inputs are corrected. Prove that nil `Rendered` cannot silently enter the + plain-YAML path and unknown labels cannot be treated as an empty set. Keep a valid plain-YAML + control case. +- Git changes between plan construction and application. Rebuild or revalidate against the new + revision before pruning; cached labels from the previous revision cannot authorize deletion. + +These `Always` cases describe the deferred option's contract. They do not make it part of the +first implementation while the support choice remains open. From 02e4cd93dab0381b947a08c1976ecf45d7f4508e Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 28 Sep 2026 10:09:07 +0000 Subject: [PATCH 06/10] chore: even more --- docs/design/watches-labels-simplification.md | 762 +++++++----------- docs/spec/attribution.md | 7 +- ...ch-event-ordering-and-attribution-grace.md | 18 +- 3 files changed, 301 insertions(+), 486 deletions(-) diff --git a/docs/design/watches-labels-simplification.md b/docs/design/watches-labels-simplification.md index b4ff8f2f5..6ceab6f07 100644 --- a/docs/design/watches-labels-simplification.md +++ b/docs/design/watches-labels-simplification.md @@ -1,531 +1,335 @@ # Label selection for watch rules -> **accepted direction, not built**: server-side selection and membership semantics are settled. -> Kustomize support and rule-edit retention below are proposed refinements awaiting a choice. -> Design for -> [issue #146](https://github.com/ConfigButler/gitops-reverser/issues/146). -> Index: [`../INDEX.md`](../INDEX.md) -> Date: 2026-09-28, checked against `main` at `b43ecf61`. -> Related: [Kubernetes watch options](../facts/kubernetes-watch-options.md), -> [Definitions](../definitions.md), -> [Watch event ordering](../spec/watch-event-ordering-and-attribution-grace.md), -> [Target watch plan](target-watch-plan.md) - -Issue #146 asks for a label selector on each watch rule, so that a GitTarget can mirror, for -example, only the Secrets labeled `internal.cozystack.io/tenantresource=true`. - -**The kube-apiserver does the selecting.** A rule's selector becomes the `labelSelector` of -the list and watch requests the operator already makes. One resource collection is exactly one -Kubernetes list/watch request: group, resource, namespace, and label selector. The operator -evaluates no label logic on the event path, and it adopts the Kubernetes membership semantics -as they are rather than modeling membership itself. - -Snapshot pruning under `Always` introduces a separate Git-side selection decision. Server-side -filtering does not establish which Git documents a snapshot may remove; the -[Kustomize support boundary](#kustomize-support-boundary) explains that distinction and its limits. - -The cost is more watches: one per distinct selector. That cost is accepted. +> **Design requirements; implementation pending.** All three prune modes, including `Always` +> with supported Kustomize layouts, are required from the first release. +> [Issue #146](https://github.com/ConfigButler/gitops-reverser/issues/146). +> Index: [`../INDEX.md`](../INDEX.md). Checked: 2026-09-28, source at `69836e70`. +> Related: [Kubernetes watch facts](../facts/kubernetes-watch-options.md), +> [Definitions](../definitions.md), [Ordering](../spec/watch-event-ordering-and-attribution-grace.md). + +**Kubernetes is the source of truth. Git follows the cluster closely.** Within a GitTarget's +managed type and namespace scope, `Always` removes resource documents absent from a complete +server-selected snapshot, including documents that never matched. Git labels and historical +membership do not exempt a document. An object outside the selection is absent from this mirror +even if it still exists in Kubernetes. `OnEvent` and `Never` explicitly limit deletion. + +The kube-apiserver selects objects through native LIST/WATCH `labelSelector` parameters. The +operator joins their identities to Git and uses its existing source-editing machinery. It +performs no local label matching for mirror membership or pruning. This contract makes filtered +`Always` possible without admission emulation or a durable membership inventory. ## The API -Add an optional `objectSelector` of type `metav1.LabelSelector` to +Add optional `objectSelector: metav1.LabelSelector` to [`ResourceRule`](../../api/v1alpha3/watchrule_types.go) and [`ClusterResourceRule`](../../api/v1alpha3/clusterwatchrule_types.go): ```yaml -apiVersion: configbutler.ai/v1alpha3 -kind: WatchRule -metadata: - name: tenant-handoff-secrets - namespace: tenant-a -spec: - gitTargetRef: - name: tenant-config - rules: - - apiGroups: [""] - resources: ["secrets"] - objectSelector: - matchLabels: - internal.cozystack.io/tenantresource: "true" +rules: + - apiGroups: [""] + resources: ["secrets"] + objectSelector: + matchLabels: + internal.cozystack.io/tenantresource: "true" ``` -The name and type follow the admission webhook `objectSelector`, which sits beside the same -`apiGroups`/`resources`/`operations` rule shape that `ResourceRule` already copies. The -matching semantics are those of list and watch, described below; admission's old-or-new match -does not apply to a watch. +The field follows the existing admission-style rule vocabulary, but uses LIST/WATCH matching +semantics; admission's old-or-new matching does not apply. Validate at admission and compilation +with `metav1.LabelSelectorAsSelector`. Omitted and `{}` both select everything: normalize nil +explicitly because the helper otherwise means “nothing.” Invalid selectors refuse the rule; +a valid zero-match selector initializes an empty collection. -The structured type follows the Kubernetes convention for a selector in an API object. -`metav1.LabelSelectorAsSelector` validates and compiles it. The compiler then normalizes the -requirements as described below and sends the resulting query string to the API server. - -Validation, at admission and again at compile time: - -- **Omitted and `{}` both select everything.** `LabelSelectorAsSelector` maps nil to "nothing", - so the compiler normalizes an omitted selector explicitly rather than inheriting that. -- **An unparsable selector refuses the rule.** A valid selector that matches no objects is not an - error: its watch initializes to an empty collection. -- **A selector may not use a label that sanitize strips.** `isOperationalLabel` in - [`internal/sanitize/types.go`](../../internal/sanitize/types.go) removes the Flux, kro, and - applyset labels before a document reaches Git. The prune boundary below reads labels from Git, - so a selector on a stripped key could never be evaluated there. +Selectors may use labels that [`sanitize`](../../internal/sanitize/types.go) strips before writing +Git. Selection happens on stored API objects, and labels need not survive in Git for identity +comparison. Preserve unsanitized watch provenance for attribution. ## One collection is one list/watch request -Today a collection (in code, [`CellKey`](../../internal/types/cell.go)) is -`(group, resource, namespace)` within a GitTarget, and the served version is carried as data. -The selector joins the identity: +Extend the current [`CellKey`](../../internal/types/cell.go) within a GitTarget: ```text -collection: group, resource, namespace ("" = all namespaces), canonical label selector -watch spec: served version + operation filter +collection: group, resource, namespace ("" = all namespaces), canonical label selector +watch spec: served version + operation filter ``` -[`targetWatchStreams`](../../internal/watch/target_watch.go) currently unions the operation -filters of every rule that lands on one collection. That union stays correct, because every -rule on one collection now selects exactly the same objects. Rules with different selectors -land on different collections, each with its own operation filter. Separate unions of -selectors and operations would have let `team=a` rules' operations apply to `team=b` objects; -keying on the selector makes that impossible without any clause machinery. +[`targetWatchStreams`](../../internal/watch/target_watch.go) unions operations only for identical +collections. Unioning selectors and operations separately would let one rule's operations apply +to another rule's objects. Served version remains data, so changing it does not move Git files. ### Canonical selector identity -The compiler produces one stable string for the watch request and every collection key. -`labels.Selector.String()` alone does not provide that identity: -[`ByKey.Less`](https://github.com/kubernetes/apimachinery/blob/v0.37.0/pkg/labels/selector.go#L152) -sorts requirements only by key. Two requirements on the same key can retain their input order: - -```text -team in (a,b),team notin (b) -team notin (b),team in (a,b) -``` - -Both select the same objects. Treating their strings as distinct would restart a watch, change -its cursor key, and potentially refuse a compatible rule as an overlap conflict. +Validate, normalize equality in `matchLabels` to singleton `In`, sort and deduplicate values, +deduplicate requirements, then sort by the complete `(key, operator, values)` tuple and serialize. +Omitted and empty selectors yield the empty string. This is syntactic normalization, with no +general Boolean simplification. Reuse the identity for requests, planning, cursors, resync +coalescing, render fidelity, and retention scopes. -Normalize after validation: - -1. Omitted and empty selectors both become the empty query string. -2. Represent `matchLabels` equality as a singleton `In` requirement, so it has the same identity - as the corresponding `matchExpressions` entry. -3. Sort and deduplicate each requirement's values, then deduplicate identical requirements. -4. Sort complete requirements by key, operator, and values. Serialize them in that order; - sorting only by key again would lose the tie-break. - -This normalization preserves matching semantics and makes presentation changes inert. It does -not attempt general Boolean simplification: `team in (a,b),team notin (b)` and `team in (a)` may -still have different identities. Overlap compatibility compares the normalized identities. -Watch planning, operation union, cursor storage, resync coalescing, and render-fidelity scopes -must all use the same identity. +`labels.Selector.String()` alone is insufficient: +[`ByKey.Less`](https://github.com/kubernetes/apimachinery/blob/v0.37.0/pkg/labels/selector.go#L158) +compares only keys. Test both orders of `team in (a,b),team notin (b)`, plus reordered values, +duplicates, and equivalent `matchLabels`/singleton-`In` forms; none may restart a watch or cause +an overlap refusal. ### One selector per overlapping collection -Two collections of one group/resource in one GitTarget overlap when their namespaces are equal, -or when one of them is all-namespaces. **Overlapping collections must use the same selector.** -Disjoint ones are unrestricted, so the multi-tenant case works: the `tenant-a` and `tenant-b` -WatchRules may each carry their own selector for Secrets. - -The restriction exists because two watches over the same objects are not ordered against each -other; see [Overlapping watches](../spec/watch-event-ordering-and-attribution-grace.md#overlapping-watches-are-a-separate-ordering-problem). -With different selectors, one object can leave one collection (`DELETED`) while it stays in the -other (`MODIFIED`), and the Git outcome would depend on which event arrives first. +Collections overlap when their group/resource matches and their namespaces are equal or one is +all-namespaces. **Overlapping collections must have the same canonical selector.** Different +selectors in disjoint namespaces are allowed. For a conflict, the oldest rule by creation +timestamp, then name, keeps the collection; the newer rule reports refusal. -**Decided:** a rule that would create an overlapping collection with a different selector is -refused. The oldest rule (by creation timestamp, then name) keeps the -collection, and the newer rule reports the refusal on its own status. Keys that need several -values stay expressible in one selector with a set-based requirement, such as -`team in (a,b)`. - -The all-namespaces-plus-named-namespace overlap that exists today, both unselected, is -unchanged by this proposal. +Without this restriction, one watch could delete an object another still selects, and independent +snapshots could prune each other's documents. Use a single set-based selector for several values. +The existing same-selector all-namespace/named-namespace overlap remains supported with its +[known ordering limitation](../spec/watch-event-ordering-and-attribution-grace.md#overlapping-watches-are-a-separate-ordering-problem). ## Membership follows Kubernetes -A label-filtered watch reports membership transitions; see -[event types](../facts/kubernetes-watch-options.md#event-types-describe-membership-in-the-selected-collection). -The operator maps them through the existing operation mapping without reinterpreting them: +Use the server's [membership events](../facts/kubernetes-watch-options.md#event-types-describe-membership-in-the-selected-collection): -| Source change | Watch event | Operation | +| Source observation | Watch event | Git operation | |---|---|---| -| A matching object is created, or an existing object gains the label | `ADDED` | `CREATE` | -| A matching object changes and still matches | `MODIFIED` | `UPDATE` | -| A matching object gets a `deletionTimestamp` | `MODIFIED` | `DELETE` (deletion intent, as today) | -| A matching object is deleted, or loses the label | `DELETED` | `DELETE` | +| Matching object created, or existing object enters selection | `ADDED` | `CREATE` | +| Selected object changes and remains selected | `MODIFIED` | `UPDATE` | +| Selected object acquires `deletionTimestamp` | `MODIFIED` | `DELETE` (existing deletion-intent policy) | +| Object disappears or leaves selection | `DELETED` | `DELETE` | -**Decided: removing the label is a deletion.** From the watch's point of view the two are the -same event, and the operator does not try to tell them apart. The Git folder shows what -`kubectl get -l ` shows, which is the point of offloading selection. Whether the -document leaves Git is decided by `spec.prune.mode`, exactly as for a deleted object: +Label exit and physical deletion have the same Git membership effect. No follow-up GET or local +label check distinguishes them. Prune policy decides whether the document is removed: -| `spec.prune.mode` | Label removed while the watch runs | Label removed while nothing watched | +| Mode | Observed exit/deletion | Absent from a complete snapshot | |---|---|---| -| `Never` | Document kept | Document kept | -| `OnEvent` (default) | Document removed | Document kept | -| `Always` | Document removed | Document removed at the next snapshot sweep | - -The `Always` row requires support for pruning the target's Git layout with a selector. The -proposed first implementation below defers that combination for Kustomize. It must report an -unsupported combination explicitly. - -A rule without `DELETE` in its operations ignores the event for both causes alike. There is no -separate retention option for deselected objects; `Never` is the way to keep them. Telling a -deselection apart from a deletion would need a follow-up GET after each `DELETED`, and a failed -GET would leave the answer unknown, so this is not planned. +| `Never` | Retain | Retain | +| `OnEvent` (default) | Remove | Retain | +| `Always` | Remove | Remove | -### Attribution for filtered removals - -A Git `DELETE` describes what the writer does. Its attribution must also retain the source watch -event type, the presence of a nonempty selector, and the unsanitized UID, `resourceVersion`, and -deletion timestamp. Those observations distinguish the attribution policies without a follow-up -GET or local label matching. - -The current [`attachAuthor`](../../internal/watch/target_watch.go) sets `ExactCapable=false` -for every Git deletion. [`FactIndex.Await`](../../internal/queue/fact_index.go) can then return -an earlier writer's fact when the grace expires. For example, Alice edits at RV 10, Bob removes -the selector label at RV 11, and Bob's audit fact is missing. Reusing that lookup attributes -Bob's change to Alice instead of reporting unresolved attribution. - -Filtered `DELETED` events therefore need a strict lookup policy: - -- Join a fact to the observed mutation using the same audit route, group/resource, UID, and RV. - Wait up to the existing grace for eligible evidence; otherwise use the unresolved author. -- Do not fall through to the latest writer, a sticky deletion pointer, collection membership or - scope, or a name-only match. Those facts do not establish which mutation ended this membership. -- An exact PATCH or UPDATE fact can name a label-exit mutation when the event's previous object - has no deletion timestamp. The payload contains the old labels, so its labels cannot prove - the new selection state; the server's event provides that observation. -- If the previous object is already terminating, an exact finalizer PATCH must not name the - actor who requested deletion. Require an exact deletion fact or leave this completion event - unresolved. Physical deletions whose facts lack the matching UID/RV also remain unresolved. - -This intentionally gives filtered removals a narrower attribution guarantee. A live -`MODIFIED` carrying a deletion timestamp still uses the existing deletion-intent policy, and -unfiltered watches keep their current policy. Merely setting `ExactCapable=true` is insufficient: -the current lookup still has broader fallback tiers, and a finalizer PATCH needs the distinction -above. The implementation needs an explicit policy at the resolver/index boundary. - -Resolution remains inline, so both a matched fact and a grace expiry preserve the -[per-watch release order](../spec/watch-event-ordering-and-attribution-grace.md#two-updates-on-one-watch). -Attribution uncertainty changes the author, not the membership transition or prune decision. - -### User-facing documentation - -The configuration reference for `objectSelector` must say, before any example: - -- A selector defines which objects are mirrored. An object that stops matching leaves the - mirror the same way a deleted object does, because Kubernetes reports both as one `DELETED` - event. -- The table above, including the default: with `OnEvent`, removing the label removes the - document from Git. -- If the folder is applied elsewhere by a GitOps tool that prunes, removing the label there - deletes the object there. -- A rule whose collection would overlap another rule's with a different selector is refused, - with the reason on the rule's status. -- The selected Kustomize support boundary, including unsupported prune combinations and render - failures. Under `Always`, a filtered snapshot is compared with effective Git labels; admission, - later updates, or independent Git edits can make those differ from live labels. -- The rule-edit retention behavior below, and that `OnEvent` retains documents when removal - events are missed and recovery starts from a fresh snapshot. -- A filtered removal with insufficient audit evidence uses the unresolved author. It is still - processed according to the prune policy. +Operation filters apply to live events. Excluding `DELETE` suppresses observed removals, but +initial snapshots remain complete and `Always` can still prune absent identities. `Never` +disables both deletion paths. All modes cover plain YAML and supported Kustomize layouts. ## Snapshot and prune boundary -Initial events, and the LIST fallback, carry the same selector, so a snapshot is exactly the -selected collection. Under `Always`, the writer also needs a Git-side scope: without one, an -empty filtered snapshot could make every document of that resource type appear removable. - -The rendered-label approach below specifies that scope if filtered `Always` pruning is enabled -for Kustomize. Supporting filtered watches with `OnEvent` or `Never` does not require this sweep. +For one target and source cluster: -[`ResyncScope`](../../internal/git/types.go) gains the normalized selector. A sweep candidate -must have both a matching effective group/resource/namespace and matching **effective Git -labels**, determined at the Git revision the writer evaluates. A candidate absent from the -selected snapshot can then be removed under `Always`. - -For plain YAML, those labels are the document's `metadata.labels`. For Kustomize, they are the -rendered object's labels. The source file may omit or override them: the writer deliberately -preserves that source form, as -[`TestSourceForm_InjectedMetadataStaysOutOfTheSource`](../../internal/manifestanalyzer/source_form_test.go) -demonstrates. Raw source labels cannot define a Kustomize sweep boundary. - -For example, an overlay adds `env=prod` to an unlabeled source document. An `env=prod` watch -selects the live object, and its document must be eligible for an `Always` sweep if the object -disappears. Conversely, an `env!=prod` watch excludes it. Testing the unlabeled source file in -that second case would select and delete a document outside the watched collection. +```text +C = managed Git identities of this group/resource in the requested namespace scope +D = identities in the complete server-selected snapshot, excluding terminating objects +Always removals = C - D +OnEvent/Never retained documents = C - D +``` -Effective Git labels are the state represented by the evaluated Git revision. They are not a -durable record of every previously observed membership: rule edits, missed watch events, and -independent Git edits can change the comparison. The retention boundary is specified below. +Update identities present in both sets; create Git representations for identities only in `D`. +The target path and write boundaries constrain `C`. Other types and sibling namespaces stay +outside this sweep. No raw or rendered Git-label predicate narrows `C`. + +If Git holds Secrets A and B in `tenant-a`, but the request returns only A, `Always` removes B, +even if B never matched. The retaining modes keep it. Intentionally keeping unselected objects +requires a retaining mode or a separate target path; ownership is not inferred from old labels. + +Match group/resource, effective namespace, and name within the source-cluster context. Name and +namespace alone collide across kinds. API version is representation data; UID distinguishes +incarnations for attribution, while Git identity survives recreation. See +[`ResourceIdentifier`](../../internal/types/identifier.go) and +[Kubernetes object identity](https://kubernetes.io/docs/reference/using-api/api-concepts/#object-names). + +### Retention reporting + +Keep orphan classification for `OnEvent` and `Never`. Under this ownership contract, every +in-scope identity in `C - D` is a document that `Always` would remove, including never-selected +ones. Counting it as retained is intentional; suppressing the count would hide non-convergence. +The count means “absent from the selected mirror,” without claiming physical cluster deletion. + +[`planGitOnly`](../../internal/manifestanalyzer/plan.go) already counts these suppressed drops. +`applyResyncPlan` carries `RetainedOrphans` into its result, and `tallyPruneRetention` publishes +per-type metrics in [`resync_flush.go`](../../internal/git/resync_flush.go). Status uses the +[`retention roll-up`](../../internal/watch/retention_rollup.go), consistent with +[`RetainedDocuments`](../../api/v1alpha3/gittarget_types.go)'s converged-mirror definition. +Include the selector in scope identity and preserve revision checks so retired reports cannot +restore stale counts. A failed observation must not publish a new zero count. +The current roll-up sums per-scope counts, so all-namespace/named-namespace overlap can count a +document twice. That existing limitation is separate from counting never-selected documents. + +### Complete observation and installation timing + +Initial events, every LIST page, the fallback watch, and resumed watches must carry the same +selector. Only completed initialization or a complete consistent LIST supplies `D`; see the +[watch facts](../facts/kubernetes-watch-options.md). Failure, denial, expired pagination, or +unavailable discovery produces no sweep. A successful empty snapshot authorizes removing all +`C` under `Always`. Keep [`desiredFromObject`](../../internal/watch/scope_resolve.go)'s exclusion +of terminating objects. Each snapshot covers its own structural scope; RV is no cross-type clock. + +A complete API observation does not prove that a deployment controller finished installing a +Git revision. Current [`mappingRefusals`](../../internal/manifestanalyzer/acceptance.go) checks +followability/scope, and [`RenderTokenDivergences`](../../internal/manifestanalyzer/render_fidelity.go) +checks rendered tokens against live fields. Neither proves every Git object was installed; see +[render fidelity](support-boundary/render-fidelity.md). Requiring every object to remain present +would also prevent cleanup after offline deletions. + +Enable mirroring when the cluster is ready to be authoritative. Under `Always`, objects awaiting +installation and new Git-first additions can be pruned before deployment. Coordinating that +handoff is an operating responsibility; this design adds no orchestrator reconciliation barrier. ### Kustomize support boundary -Kustomize support and filtered snapshot pruning are separate decisions. Kustomize builds -manifests before they reach the API server; the server selects the stored objects resulting -from those manifests. See the upstream -[Kustomize documentation](https://kubernetes.io/docs/tasks/manage-kubernetes-objects/kustomization/). -The operator already runs the Kustomize library in -[`renderRoot`](../../internal/manifestanalyzer/kustomize_render.go), and exposes its output through -[`DocumentModel.Rendered`](../../internal/manifestanalyzer/store.go) and -[`RenderedOverrides.Object`](../../internal/manifestanalyzer/overrides_attribution.go). - -A server-selected live event can use the existing source-editing machinery. An `Always` sweep -also needs to select Git documents locally, using the compiled Kubernetes selector against -their effective labels. This uses Kubernetes' selector library and the existing renderer; -it requires no simulation of API-server admission or controller behavior. - -```mermaid -flowchart LR - A[Live API objects] --> B[API-server label selection] - B --> C[Selected snapshot] - D[Git files] --> E[Existing Kustomize renderer] - E --> F[Local label selection] - C --> G[Compare for Always pruning] - F --> G -``` - -With `OnEvent` or `Never`, a fresh snapshot upserts the selected objects and retains documents -that are absent. There is no absence-based deletion to scope by Git labels. The existing -[`resyncPlanPolicy`](../../internal/git/resync_flush.go) already makes this prune-mode distinction. -Source mapping and render verification still apply to the writes that the snapshot or live -events request. +Use the existing [Kustomize editing boundary](support-boundary/kustomize-support-boundary.md). +Rendering resolves effective identities and source locations and verifies edits. It performs no +label-based prune selection. Existing machinery includes +[`renderRoot`](../../internal/manifestanalyzer/kustomize_render.go), +[`DocumentModel.Rendered`](../../internal/manifestanalyzer/store.go), and +[`VerifyBatchRenders`](../../internal/manifestanalyzer/render_verify.go). + +For Kustomize, `C` describes objects represented by the target's render roots. Patch documents +and build directives are context; an inherited object already suppressed by a deletion patch +is absent from `C` despite its base file remaining. Resolve overlay-supplied namespaces before +identity comparison. Unavailable rendering is an error, never an empty object set. + +Live [`applyDelete`](../../internal/git/plan_flush.go) already handles inherited objects through +an overlay-local `$patch: delete`. Snapshot `applyResyncPlan` instead calls `dropDocument`, which +only removes YAML documents. Day-one `Always` requires a shared semantic removal path that: + +- Resolves effective identities back to source documents and preserves multi-document siblings. +- Cleans `resources:` references when the final document leaves a file. +- Removes inherited objects through overlay-local patches, preserving bases and sibling overlays. +- Records removal intent and verifies intended absence plus unchanged unrelated rendered objects. +- Handles re-entry by retiring a recognizable operator-owned deletion patch and its reference, + then upserting and verifying presence. Persist artifact ownership in Git; an edited patch, + uncertain ownership, or path collision refuses the batch. Retries must be idempotent. + +Retain separate prune gates, attribution, and live/sweep counters. Refuse ambiguous identity or +source mapping, escaped write boundaries, and failed render verification before commit. Nil +`Rendered` is not proof of plain-YAML context. Report the affected candidate and retry after +relevant inputs change; skipped removals must not count as convergence. Build and apply against +one Git revision, rebuilding mappings after fetch/conflict changes. Existing unsupported features +remain unsupported, and no shared base becomes writable through this feature. + +Admission/controller labels can differ from Git labels in either direction: trust the selected +identity set. Removing a representation can cause downstream GitOps pruning to delete the live +object. Rule narrowing or a valid but mistaken selector has the same consequence; the operator +cannot distinguish that mistake from an intentionally smaller mirror. -### Options for the first implementation - -**Proposed recommendation: support filtered Kustomize watches with `OnEvent` and `Never`, and -defer filtered `Always` pruning for Kustomize.** This is a proposal awaiting a choice. It keeps -the default prune mode useful while leaving the Git-side selection contract explicit. +### Attribution for filtered removals -| Option | Added work | Main consequence | -|---|---|---| -| `OnEvent` and `Never` first | Reuse supported Kustomize editing; reject filtered `Always` | Missing removal events can leave retained documents | -| Add `Always` using rendered Git labels | Scope sweeps by rendered labels and source mapping | Git-side scope can differ from live membership | -| Add a durable membership inventory | Persist observed membership and its Git ownership | Adds recovery, adoption, and rule-transition state | -| Block all filtered Kustomize targets initially | Detect and refuse the combination | Also excludes layouts usable with `OnEvent` and `Never` | - -Under the first option, reject a nonempty selector with `Always` when its resource collection -requires Kustomize context in the target. Do this before applying the affected snapshot or live -writes. Recheck when rules, prune mode, or Git layout change, and report the reason on the -target and referencing rules. An unsupported combination must not silently weaken the prune -mode. Omitted and empty selectors keep the existing unfiltered behavior. - -A durable inventory could record the observed membership of previously mirrored objects. -It still needs a source mapping, ownership records tied to successful Git writes, and explicit -handling of restarts and rule changes. Pre-existing Git documents have no such history, so -adoption must be defined. This is a larger design than reusing the renderer. - -Blocking Kustomize alone does not remove local selector evaluation from `Always`: plain YAML -also needs a Git-side scope. Requiring no local label matching anywhere would defer this -rendered-label pruning approach for all filtered targets. - -### Limits and refusal behavior - -**A successful render establishes only the Git representation. Cluster membership can differ.** -[Mutating admission](https://kubernetes.io/docs/reference/access-authn-authz/admission-controllers/) -can change an object before storage, and controllers can change its labels later. Deployment-time -substitutions or other transformations outside the supplied Git build inputs are also outside -the local renderer's knowledge. The operator must not attempt to reproduce those processes. - -For example, Git renders `env=prod`, but admission changes the stored label to `env=qa`. An -`env=prod` snapshot excludes the object. Under the proposed Git-side `Always` rule, its document -is eligible for removal even though the object still exists. A GitOps tool that applies that -Git deletion with pruning can then delete the live object. The same mismatch can occur with -plain YAML. This consequence must be accepted and documented before enabling that pruning rule. - -Git edits, missed events, and operation filters also mean rendered labels cannot prove whether -an object previously belonged to a watch. In particular, the renderer does not resolve the -[rule-narrowing retention case](#rule-edits). `OnEvent` avoids inferring deletion from a fresh -snapshot, at the cost of retaining documents for missed removals. An observed label exit still -removes its document under `OnEvent`, subject to the operation filter and existing write checks. - -If rendered-label `Always` support is chosen, the writer needs these boundaries: - -- Before a sweep, classify candidates in its effective type and namespace scope as plain YAML - with readable labels, Kustomize with verified rendered labels and source mapping, or unknown. - Unknown membership refuses the affected snapshot before any writes. It cannot be interpreted - as an empty label set or silently left outside the scope. -- A nil `Rendered` value can mean conflicting render roots or unavailable attribution. Require - explicit evidence of plain-YAML context before reading raw source labels as effective labels. -- A source shared by incompatible overlays can affect another environment when edited or - deleted. Preserve the existing write-fan-in and - [`VerifyBatchRenders`](../../internal/manifestanalyzer/render_verify.go) checks for live writes - and sweeps; selector membership alone cannot authorize that source change. -- A failed render, conflicting roots, or unreadable required label metadata refuses the affected - operation. Name the candidate and reason through the existing acceptance/render-fidelity - reporting. Do not report successful convergence or fall back to a broader sweep. Retry after - the relevant inputs change. These new sweep checks are additional to existing write checks. -- Evaluate labels, source mapping, and the write plan against the same Git revision. Reuse its - rendered objects when possible and invalidate them when the inputs change. Rendering consumes - CPU and memory; this feature should not introduce a separate build for every watch event. - -The proposed LIST/WATCH requests stay filtered. Once watch history has expired, a fresh LIST or -GET cannot recover a deleted object's historical labels for the writer. Availability of a -rendered object is useful evidence, but integration and tests must establish the new scope -checks. Unsupported Kustomize features stay outside the existing -[support boundary](support-boundary/support-contract.md). +The current [`attachAuthor`](../../internal/watch/target_watch.go) sets `ExactCapable=false` for +all Git deletions. That **does not skip the exact join**: [`FactIndex.Lookup`](../../internal/queue/fact_index.go) +tries the sticky deletion pointer first, then UID/RV, then weaker fallbacks. If Bob's label-exit +fact is missing, Alice's earlier write can author the removal after grace expires. + +There is also an avoidable delay. When an exact PATCH/UPDATE wins lookup, `awaitsBetterEvidence` +still treats it as a write fallback and waits the remaining grace for delete evidence (default +`3s`). This is not true of every exit: eligible delete evidence can finish the wait earlier. + +Filtered `DELETED` needs an explicit lookup policy, retaining raw event type, selector presence, +UID/RV, and deletion timestamp. Require matching audit route, group/resource, UID, and RV; +eligible evidence returns immediately, including a late arrival during grace. A nonterminating +previous object permits an exact PATCH/UPDATE to name a label exit. A terminating previous object +requires exact deletion evidence; a finalizer PATCH must not name the deletion initiator. +Without eligible evidence, expire as unresolved. Do not use last-writer, sticky, collection, +RV-only, or name-only fallbacks for these filtered removals. + +This deliberately extends the [current attribution contract](../spec/attribution.md#three-rules-that-are-easy-to-miss): +physical-removal fallbacks cannot establish who changed a label. Keep existing unfiltered and +`MODIFIED` deletion-intent behavior. Merely changing `ExactCapable` is insufficient; weaker +fallbacks remain. Update the attribution contract with the implementation. Resolution stays +inline and ordered; uncertainty affects authorship only. Resyncs retain configured authorship. ### Scope integration and queue ordering -Today `ResyncScope.Matches` receives only a `ResourceIdentifier`, and -[`resyncPlan`](../../internal/git/resync_flush.go) passes it to `BuildScopedPlan`. The sweep -predicate needs effective labels and a way to report unknown membership as a refusal. A Boolean -predicate over an identifier cannot express that boundary. - -Keep queue-ordering checks separate from the sweep predicate. -[`markResyncTailForWriteLocked`](../../internal/git/branch_worker.go) currently uses scope -matching to prevent a new snapshot from moving ahead of an already queued live write. A Git -`DELETE` carries no object payload. That check must conservatively match target, group/resource, -and namespace even when labels are unavailable. Overmatching can prevent coalescing; dropping -the ordering check can let an older deletion erase an object restored by a newer snapshot. - -## Rule edits - -A selector edit changes the collection key. The planner stops the old watch and starts a new -one, which initializes from initial events, so objects that match the new selector without -having changed since are still mirrored. - -**Proposed choice: keep the existing prune policy and qualify retention.** Stopping a collection -does not enqueue a cleanup of its former scope. The new collection's snapshot applies the -configured prune mode to its effective Git-side selection. Documents outside that selection -are retained by that sweep. Other active collections can still cover and update them. - -This does not guarantee retention of every live object a narrowed rule now excludes. Consider: - -1. The old selector is `team in (a,b)`, and Git represents an object with `team=a`. -2. While observation is interrupted, its live label changes to `team=b`. -3. The rule narrows to `team=a` before observation resumes. -4. The new snapshot excludes the object, but its effective Git labels still match `team=a`. - -Where filtered `Always` is supported, the new snapshot removes the document. Under `Never` and -`OnEvent`, that snapshot retains it. A Git document already representing `team=b` stays outside -the new sweep under all three modes. An operation filter that missed an earlier UPDATE can -leave the same stale-label case even without a disconnected watch. - -Strict retention across rule narrowing is the alternative. It requires a separate record or -transition protocol to distinguish retired coverage from absence inside the new collection; -the filtered snapshot and current Git labels alone cannot establish that history. The proposed -choice adds no retention ledger and makes this prune behavior explicit in the configuration docs. - -## Recovery and status - -- **Cursors.** [`watchCursorKey`](../../internal/queue/redis_store.go) persists target UID, - group/resource, and namespace. It adds the canonical selector (or a hash of it), so a cursor - is never resumed under a different selection. A new selector is a new key, and the first - attempt of a new watch initializes regardless; see the cursor entry in - [Definitions](../definitions.md). -- **Render fidelity** is already per collection and per watch revision, and it follows the key. -- **`status.streams`** counts resource types. The overlap restriction keeps a type from - appearing under two selectors in one namespace, but a type watched in two namespaces with two - selectors still folds into one entry, as a type in two namespaces does today. - -## Cost and authorization - -Each distinct selector is one more watch on the kube-apiserver's watch cache, which is cheap on -the server. The operator receives only selected objects, which matters most for large -populations such as Helm release Secrets. - -The operator's credential needs `list` and `watch` on the resource either way; RBAC does not -match objects by label. An authorization webhook can see the selector; see -[selectors and authorization](../facts/kubernetes-watch-options.md#selectors-and-authorization). -A filtered request is therefore the least-privilege request, and a denial is an observation -failure, never an empty snapshot. - -## Out of scope - -- **Different selectors on overlapping collections.** This needs per-object membership across - collections and a per-object `resourceVersion` guard. `resourceVersion` is comparable within - one group/resource on kube-apiserver; see [resource versions](../facts/resource-versions.md). - The same mechanism would also close the existing all-namespaces-plus-named overlap. -- **Field selectors.** They use the same request machinery, but supported fields vary by type. -- **Identifier renames.** [Definitions](../definitions.md) already uses the Kubernetes terms in - prose. The code follows in its own change: `CellKey` → `CollectionKey`, `SourceCell` → - `SourceCollection`, `cellSpec` → `watchSpec`, and the stream, replay, and cursor names. +Keep [`ResyncScope.Matches`](../../internal/git/types.go) structural: target, group/resource, and +namespace. The collection identity additionally carries the selector. Update the `CellKey`/scope +comments to distinguish the selected collection from the Git objects it owns. +[`markResyncTailForWriteLocked`](../../internal/git/branch_worker.go) must keep matching identifiers: +a `DELETE` may have no object payload, and different selector generations can affect the same file. +A live write prevents a newer snapshot from coalescing ahead of it; selector identities must not +share coalescing keys. + +For replacement, quiesce the retired producer's enqueue path before the new producer enqueues +its initial snapshot. Accepted old work drains ahead of it; none may arrive afterward and restore +an excluded object. Current `targetWatchSet.stop` only cancels. Add producer completion or enqueue +serialization, without making the branch worker consult watch configuration. Preserve the +[ordering regression cases](../spec/watch-event-ordering-and-attribution-grace.md#regression-cases). + +## Rule edits, recovery, and status + +A selector edit starts a new collection and complete snapshot over the same type/namespace +ownership scope. Narrowing `team in (a,b)` to `team=a` removes excluded identities under `Always`, +regardless of stale Git labels or missed UPDATEs; retaining modes keep them on that snapshot. +Widening initializes newly selected objects without waiting for mutations. Equivalent canonical +selectors keep the watch. Removing a type/namespace entirely enqueues no cleanup of its retired +scope; existing [namespace retention](../configuration.md#deletion-policy-specprunemode) still applies. + +Add the canonical selector to [`watchCursorKey`](../../internal/queue/redis_store.go); never resume +under another selector. Render-fidelity and retention scopes follow that same identity and watch +revision. `status.streams` continues counting resource types, rather than watch connections. + +## Documentation and boundaries + +The configuration reference must lead with cluster authority, the prune table and `OnEvent` +default, and the removal of never-selected documents under `Always`. Explain rule narrowing, +installation timing, downstream GitOps deletion, overlap refusals, Kustomize write limits, +retention counts, and unresolved attribution. These are consequences of the chosen contract. + +Filtered requests reduce transferred objects but add watches; measure the cost. Keep requests +filtered because [authorization can depend on selectors](../facts/kubernetes-watch-options.md#selectors-and-authorization). +A denial cannot become an empty snapshot. Field selectors and identifier renames remain separate +work. Different selectors over overlapping scopes need union membership and stale-observation +handling; independent sweeps are insufficient. The existing same-selector overlap ordering gap +also remains outside this change. ## Tests -Keep the invariants the current tests pin: - -| Invariant | Test | -|---|---| -| Two served versions resolve to one collection watch | `TestTargetWatchStreams_OneStreamPerCellAcrossServedVersions` | -| All-namespace and named-namespace collections keep their own filters | `TestTargetWatchSpecs_NamedAndClusterWideScopesStayDistinctStreams` | -| Unchanged watches survive unrelated rule changes | `TestReplaceGitTargetWatches_AddingARuleStartsOnlyTheNewCell` | -| Replacement initializes before live delivery | `TestReplaceGitTargetWatches_ReusesUnchangedSetAndRestartsOnSpecChange` | -| A served-version change keeps the collection | `TestTargetWatchPlanFor_AServedVersionBumpKeepsTheSameCell` | -| A sweep leaves sibling namespaces alone | `TestResync_NamespaceScopedSweepLeavesSiblingNamespacesAlone` | - -The first four live in `internal/watch/target_watch_test.go`, the fifth in -`target_watch_plan_test.go`, and the last in `internal/git/resync_scope_test.go`. - -Add: - -- Omitted, `{}`, invalid, zero-match, and stripped-key selectors. -- The selector reaches initial events, the LIST fallback, and the live watch alike. -- Two collections of one type with different selectors in disjoint namespaces both run; an - overlapping pair refuses the newer rule. -- Reordered requirements, including two expressions on the same key, reordered values, and - duplicate requirements keep the watch and cursor key. `matchLabels: {team: a}` and its singleton - `In` expression share an identity and pass overlap compatibility. A selection change starts - a new collection and mirrors unchanged objects that now match. -- Label entry and exit produce `CREATE` and `DELETE` under each prune mode. -- Alice's earlier RV 10 fact is present; Bob removes the label at RV 11. With Bob's exact fact, - resolve Bob, including when it arrives during grace. Without it, expire as unresolved even - when Alice's fact, an unrelated deletion pointer, or a collection deletion fact is available. -- A filtered physical deletion with insufficient exact evidence is unresolved. A terminating - object's exact finalizer-PATCH fact does not author its removal; deletion-intent `MODIFIED` - events and unfiltered watches retain their existing policy. -- An event waiting for its author cannot be overtaken by a later event on the same watch, and - cancellation during the wait prevents enqueue. See the ordering spec's - [regression cases](../spec/watch-event-ordering-and-attribution-grace.md#regression-cases). -- A label removed while nothing watched: the document stays under `Never` and `OnEvent`, and the - next supported `Always` sweep removes it. The same sweep leaves a document whose effective - Git labels do not match the selector untouched, even in the same namespace. -- A selected Kustomize live update or deletion would change another root's unselected object. - The existing render/write-fan-in checks refuse the write. Rechecking a changed Git revision - uses that revision's labels and source mapping. -- Narrow `team in (a,b)` to `team=a` after a missed `a` to `b` label change. Git still represents - `team=a`. Under the proposed prune-policy choice, `Always` removes the document; `Never` and - `OnEvent` retain it. Repeat with the UPDATE excluded by the operation filter. A control case - whose Git labels already say `team=b` is retained by the new sweep under every mode. -- Queue snapshot R100, a label-exit `DELETE` at R101 with no object payload, then a reconnect - snapshot R103 where the object matches again. Preserve all three FIFO positions so the final - object is present. The delete must prevent R103 from coalescing into R100's earlier position. -- A cursor recorded under one selector is not resumed under another. -- An e2e run against a real kube-apiserver, since label-filtered initial events are server - behavior that unit fakes do not reproduce. - -### Kustomize support and pruning limits - -For the proposed `OnEvent`/`Never` first implementation: - -- An overlay supplies `env=prod`; its source manifest omits the label. Filtered initial and - live events update that source through the existing writer. No additional Git-side label - selection is needed for pruning. The render and source mapping still have to pass the existing - write checks. -- A live label exit removes the document with `OnEvent` and retains it with `Never`. When the - event is missed and recovery instead delivers an empty fresh snapshot, both modes retain the - document, even if its rendered labels still match. -- A nonempty selector combined with `Always` and Kustomize context refuses the affected work - before any writes and reports the unsupported combination. Repeat after changing an existing - target from `OnEvent` to `Always`, and after Git introduces Kustomize context. Omitted and empty - selectors keep existing behavior. - -If rendered-label `Always` support is chosen, also require: - -- Kustomize adds `env=prod`, while the source manifest has no `env` label. With an `env=prod` - selector, an empty snapshot removes its document under `Always`. With `env!=prod`, it leaves - the document untouched. Repeat with a source label overridden by the overlay and a - `DoesNotExist` selector; source labels must not determine rendered membership. -- Git renders `env=prod`, while admission or a later controller stores `env=qa`. The `env=prod` - snapshot excludes the object. Assert the documented Git-side rule: `Always` may remove its - document despite the object's existence; `OnEvent` and `Never` retain it on that snapshot. - Include plain-YAML and Kustomize cases so the test does not imply rendering solves this gap. -- Kustomize roots disagree about a candidate's labels, rendering fails, or required label - metadata cannot be read. Refuse the affected snapshot before writing, report the failure, and - recover after the inputs are corrected. Prove that nil `Rendered` cannot silently enter the - plain-YAML path and unknown labels cannot be treated as an empty set. Keep a valid plain-YAML - control case. -- Git changes between plan construction and application. Rebuild or revalidate against the new - revision before pruning; cached labels from the previous revision cannot authorize deletion. - -These `Always` cases describe the deferred option's contract. They do not make it part of the -first implementation while the support choice remains open. +Preserve existing version, operation-union, unchanged-watch, and replacement checks in +[`target_watch_test.go`](../../internal/watch/target_watch_test.go) and +[`target_watch_plan_test.go`](../../internal/watch/target_watch_plan_test.go), plus sibling-namespace +protection in [`resync_scope_test.go`](../../internal/git/resync_scope_test.go). Add: + +### Selection and pruning + +- Omitted, empty, invalid, zero-match, and sanitizer-stripped-label selectors. Assert identical + query propagation on every initialization, fallback, pagination, and resume path. +- The canonicalization cases above preserve cursor/watch identity; changed selections initialize + afresh. Disjoint namespaces permit distinct selectors; overlaps refuse the newer rule. +- Equal names across resource types/namespaces remain distinct. Version changes and new UIDs do + not orphan Git representations. Missing Git objects use placement; mapping failures refuse. +- Observe entry/exit under all modes. Exclude live `DELETE`, then show `Always` still prunes on a + fresh snapshot. Keep terminating-object behavior and test missed events and excluded UPDATEs. +- With `C={A,B}` and `D={A}`, including never-selected B, `OnEvent`/`Never` retain B and report one + retained document; `Always` removes B and reports zero retained. Check status and per-type + metrics. Other namespaces/types stay untouched. An empty `D` removes all `C` only under `Always`. +- Narrow a selector with matching, stale, and missing Git labels; the same absent identities are + removed under `Always`. Widening adds unchanged newly selected objects. Retiring coverage does + not sweep it, and retired reports cannot overwrite current retention counts. +- Interrupted initialization, a failed/expired LIST page, denied authorization, and unavailable + discovery enqueue no sweep or new zero retention report. A complete empty snapshot does sweep. +- Pre-installation and new Git-first documents are removable under `Always`. Switching prune mode + or introducing a supported Kustomize layout keeps full support without silently retaining. + +### Kustomize + +- Overlay-injected/overridden labels with positive, negative, and `DoesNotExist` selectors; + admission/controller drift in both directions; stripped labels. Only server membership decides + `D`, for both plain YAML and Kustomize. Resolve effective namespaces before matching. +- Partial multi-document deletion preserves siblings; final-document deletion cleans references + and leaves the root buildable. An inherited removal authors an overlay patch, with base and + sibling overlay unchanged. Require parity between live and snapshot removals. +- Restart and re-enter a previously removed inherited object: retire its owned patch, verify + presence, and prove idempotency. Edited/unowned patches and occupied paths refuse without loss. +- Conflicting roots, absent attribution, unavailable renders, out-of-bound writes, and changes to + unrelated rendered objects refuse before commit. Nil `Rendered` cannot imply plain YAML; keep + a valid plain-YAML control. Git revision changes invalidate old mappings before writes. + +### Attribution and ordering + +- Alice's RV 10 fact plus Bob's exact RV 11 label-exit PATCH resolves Bob immediately, without + consuming the full grace, even with unrelated sticky/collection evidence available. Repeat + with Bob arriving during grace and both PATCH and UPDATE. +- Without Bob's fact, expire unresolved despite Alice's fact and available sticky, collection, + RV-only, and name-only fallbacks. Exact filtered deletion evidence resolves immediately; + insufficient evidence remains unresolved. A terminating finalizer PATCH cannot author removal. +- Preserve unfiltered/deletion-intent attribution. Cancel during grace and refuse a full queue + without advancing its cursor. Later events cannot overtake an earlier attribution wait. +- Queue R100 snapshot, payload-free R101 exit, then R103 snapshot where the object re-enters. + R103 cannot coalesce into R100's position; final content contains the object. +- Pause a retired producer before enqueue, replace its selector, and initialize an empty snapshot. + Old work enters ahead of the replacement or is canceled, never afterward. Repeat with old + snapshots and a full queue while unrelated collections continue. +- Run e2e against a real kube-apiserver for initial events, label entry/exit, and offline `Always` + recovery; unit fakes cannot establish these server semantics. diff --git a/docs/spec/attribution.md b/docs/spec/attribution.md index 83acb7dd9..07ac2d81c 100644 --- a/docs/spec/attribution.md +++ b/docs/spec/attribution.md @@ -12,8 +12,11 @@ > why facts are a per-type stream rather than a keyspace. > > What is still **open** is not here. It is ranked in -> [`../design/open-asks-priority.md`](../design/open-asks-priority.md), and the one decision with -> its own record is [`../design/attribution-removal-wait-options.md`](../design/attribution-removal-wait-options.md). +> [`../design/open-asks-priority.md`](../design/open-asks-priority.md). Removal-wait alternatives are in +> [`../design/attribution-removal-wait-options.md`](../design/attribution-removal-wait-options.md). +> The [planned filtered-removal policy](../design/watches-labels-simplification.md#attribution-for-filtered-removals) +> adds stricter evidence and immediate release of eligible exact matches for label-filtered +> `DELETED` events. It is not implemented; the behavior below remains the current contract. > > Related: [`watch-event-ordering-and-attribution-grace.md`](watch-event-ordering-and-attribution-grace.md), > [`commitrequest-admission-authorship.md`](commitrequest-admission-authorship.md), diff --git a/docs/spec/watch-event-ordering-and-attribution-grace.md b/docs/spec/watch-event-ordering-and-attribution-grace.md index 5b2ebb47c..03c9bd0c5 100644 --- a/docs/spec/watch-event-ordering-and-attribution-grace.md +++ b/docs/spec/watch-event-ordering-and-attribution-grace.md @@ -73,12 +73,14 @@ With attribution enabled, an unresolved event uses the explicit `unknown (attribution unresolved)` author. With attribution disabled, the configured committer also authors the change. A fact arriving after a commit is written does not rewrite it. -The index determines what counts as a resolution. Its current removal lookup can hold an -earlier writer's fact as a fallback and return it at grace expiry; see -[`FactIndex.Await`](../../internal/queue/fact_index.go). Ordered delivery does not establish -that this author caused a label-exit mutation. The proposed +The index determines what counts as a resolution. For a removal query, even an exact UID/RV +PATCH match is held while [`FactIndex.Await`](../../internal/queue/fact_index.go) waits for deletion +evidence, up to the grace deadline. `ExactCapable=false` does not disable the exact lookup. +When the exact fact is missing, an earlier writer's fallback can be returned at expiry; ordered +delivery does not establish that this author caused a label-exit mutation. The proposed [filtered-removal attribution policy](../design/watches-labels-simplification.md#attribution-for-filtered-removals) -requires stricter evidence while preserving the same inline ordering. That policy is not built. +requires stricter evidence and releases eligible exact matches immediately, preserving the same +inline ordering. That policy is not built. ### Preserve ordered release if attribution becomes concurrent @@ -159,6 +161,12 @@ test today. queue checks live in [`resync_push_test.go`](../../internal/git/resync_push_test.go). - Refuse an event because the worker queue is full. The routing error must leave its watch cursor unadvanced; queue admission is the boundary asserted by this check. +- For the proposed selector replacement, pause the retired producer immediately before enqueue and + initialize the replacement. The retired work must enter ahead of the replacement snapshot or + be canceled. It cannot arrive behind that snapshot and restore an excluded object. The + [label-selection design](../design/watches-labels-simplification.md#scope-integration-and-queue-ordering) + requires a producer handoff for this case; the current cancellation checks alone do not + establish it. - Exercise overlapping watches with delayed attribution on one. Assert each watch's own order; do not infer a global mutation order from their combined arrivals. The label-selection design refuses conflicting selectors but retains the existing same-selector namespace overlap. From 974be81f15c24538f1a861390b589ad40f267a7b Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 28 Sep 2026 10:36:33 +0000 Subject: [PATCH 07/10] chore: prepare stage 1 --- docs/INDEX.md | 3 +- docs/definitions.md | 3 + docs/design/collection-terminology-rename.md | 586 ++++++++++++++++++ docs/design/watches-labels-simplification.md | 34 +- ...ch-event-ordering-and-attribution-grace.md | 4 +- 5 files changed, 612 insertions(+), 18 deletions(-) create mode 100644 docs/design/collection-terminology-rename.md diff --git a/docs/INDEX.md b/docs/INDEX.md index a4f44806c..34017e697 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -134,7 +134,8 @@ so you never have to read a proposal to find out it already shipped. | [`placement-visibility-and-declared-defaults.md`](design/placement-visibility-and-declared-defaults.md) | **design, half built.** The three questions the inference deletion left, decided and then built in two goes. PR #291 shipped the deletion and none of the eight items queued behind it; the residue was filed as [#295](https://github.com/ConfigButler/gitops-reverser/issues/295) — **which shipped in 0.42.1 via [#319](https://github.com/ConfigButler/gitops-reverser/pull/319) and is what reversed the layout model** — and [#296](https://github.com/ConfigButler/gitops-reverser/issues/296), whose largest half then shipped in [#326](https://github.com/ConfigButler/gitops-reverser/pull/326): `status.layout` became `status.placement`, and the ambiguous render root came with it as a refusal rather than a third counter outcome. The `declared` metric value has since split into `by_type`/`default` too, which was the one row with an external consumer because it changes a label a dashboard may select on. What still stands and is still unbuilt is the rest of the legibility set: the canonical path as a template constant, and `{kindLower}`, tracked on [#339](https://github.com/ConfigButler/gitops-reverser/issues/339) now that #296 is closed; the Event on the GitTarget was dropped. Its Question 2, a CRD default for `placement.default`, is **re-opened rather than superseded** and now lives as an open question in [`layout/model.md`](layout/model.md); the argument against it is legibility, since #319 removed the correctness objection | | [`gittarget-api-wave.md`](design/gittarget-api-wave.md) | **partly built**, filed as [#294](https://github.com/ConfigButler/gitops-reverser/issues/294). One breaking wave on `GitTarget`, of which steps 2 to 7 have **shipped**: `spec.suspend`, `status.placement` and the reconcile-request annotation in [#326](https://github.com/ConfigButler/gitops-reverser/pull/326), and B4's `commitWindow`/`commit.message` move plus the source-scope deletion in [#330](https://github.com/ConfigButler/gitops-reverser/pull/330). Organizing principle: **the folder is described on the GitTarget, the connection describes only the connection** — and this is where that became a struct boundary rather than a sentence, since grouping a field is free only in a release that is already breaking. `spec.mode` and `GitTarget.spec.interval` are both **dropped**, with re-open triggers, and `status.lastHandledReconcileAt` was dropped in the build for a stated reason. What is left is **step 8, the riders** — asserted `CommitRequest.spec.author`, the `CommitRequest` lifecycle hole, the `default` `ClusterProvider` message — tracked on [#339](https://github.com/ConfigButler/gitops-reverser/issues/339); #294 is closed. The reference collapse and the `scope` removal shipped in [#333](https://github.com/ConfigButler/gitops-reverser/pull/333); the `TooManyStreams` cap and the Event on a changed resolution were dropped. Also records that staying `v1alpha3` on loud rejections is a **one-consumer countdown** | | [`target-watch-plan.md`](design/target-watch-plan.md) | **partly built.** The diff is built and applied; removal semantics are not. The companion to [`watch-manager-ownership.md`](design/watch-manager-ownership.md): the ownership page says WHO applies a plan, this one says WHAT a plan is and what changing it may touch. A cell — group, resource, namespace, deliberately no served version — is the one identity the watch stream, the render-fidelity scope and the mark-and-sweep boundary all agree on, because a key that does not round-trip to the scope it sweeps under is the class of error that deletes user data. The plan is diffed into `keep`/`start`/`restart`/`stop` and applied per cell, so adding one WatchRule stops replaying every unrelated cell into a queue shared with other tenants; a `restart` is a served-version change, which is why the version is spec DATA rather than identity. Readiness and the fidelity revision are per scope, so a KEPT cell holds the result its own replay produced rather than being asked to prove itself again over an unrelated edit. `stop` never touches files — removal is a Git-side sweep under the target's existing `spec.prune.mode`, not a watch-layer delete. "Cut at the producer" is the accepted consequence: nothing fences the queue, so a deselected cell may leave a short tail of writes, bounded by the queue and converged afterwards. Still open: the `stop` classification wants a settled `TypeRemoved` from `typeset` (see TODO), and removal on INTENT is undecided. | -| [`watches-labels-simplification.md`](design/watches-labels-simplification.md) | **accepted, not built.** Per-rule `objectSelector` (#146), selected by the kube-apiserver: the selector joins the collection key and is sent as the watch's `labelSelector`, so one collection is one list/watch request. Removing the label is a deletion as far as the watch can tell, and `spec.prune.mode` decides whether the document leaves Git; there is no separate retention option. A rule whose collection would overlap another's with a different selector is refused, and the `Always` sweep is bounded by the selector evaluated on the Git document's labels. | +| [`collection-terminology-rename.md`](design/collection-terminology-rename.md) | **step 1, planned.** Replace cells with resource collections and remove per-rule `operations` from both rule kinds. Rules observe every object event; GitTarget prune mode controls removal. Includes the rename census (760 Go identifiers, 265 documentation occurrences), a separate operation-filter inventory, API/Helm migration, compatibility boundaries, and test cases. | +| [`watches-labels-simplification.md`](design/watches-labels-simplification.md) | **step 2, accepted design; not built.** After the collection rename and operation-filter removal, add native per-rule `objectSelector` (#146). Kubernetes is authoritative: `Always` compares selected identities with all managed Git identities in the type/namespace scope, without comparing Git labels. Full support includes supported Kustomize layouts from the first release, plus filtered-removal attribution and ordered producer replacement. | | [`attribution-removal-wait-options.md`](design/attribution-removal-wait-options.md) | a removal now waits for evidence about the DELETION rather than accepting the object's last write, which stopped it naming whoever last edited the object as the author of a deletion they did not perform. Enumerates the eight situations a resolution can be in and shows the cost is concentrated in exactly one: a removal for which no delete fact will ever arrive (a graceful pod delete, a status-only removal, a type the audit policy skips) spends the whole grace to return the answer it had at t=0, measured at ~3.1s against ~70ms when evidence is present. Prices five options against that, and recommends a per-route watermark — stop waiting once the fact stream has demonstrably moved past this event — over a second timeout flag whose right value lives in the API server's config rather than ours. Open: the decision, and how common the case is outside the e2e suite | | [`commitrequest-save-wait-options.md`](design/commitrequest-save-wait-options.md) | **design, decided** — a five-phase plan, reported from a live cluster: a `CommitRequest` can expire before the write it exists to publish arrives, because the close delay defaulted to `0` while the write is still held head-of-line waiting for an audit fact, so the save resolves `Ready=True`/`NoWindowInGrace` and the edit commits seconds later under the target's `liveTemplate`. The decision is to repair the default (`spec.closeDelay`, now a duration string defaulting to `"2s"`, **shipped as phase 0**) and then let a request **name the write it is waiting for**, reusing the `(uid, resourceVersion)` identity the attribution join already keys on, including its settled `ExactCapable` rule. Records why the fact index cannot BE the wait (it sits upstream of the watch event, so a fact-keyed wait fires before the window opens) and what to take from it anyway — the register-then-check waiter discipline, and a best-effort retrospective oracle bounded by a TUNABLE TTL. Twelve situations, and the page is deliberate about the two it cannot fix cheaply: row 11 (a saturated queue is rejected in the WATCH path and never reaches the worker, so no worker-side wait can diagnose it) is an accepted, named gap rather than a promise, and row 10 is fixed only by a hook in the fidelity-drop branch that marks a wait terminally UNSATISFIABLE — satisfying before that gate would arm a finalize for a write that never commits. Carries a **preconditions chapter** for saves on today's build (audit policy first, then delay sizing, fidelity warm-up, save-button debounce, and assert on the commit rather than on `Ready`). `CommitterActivity` is deferred, not recommended: by its own arrival-not-existence rule it cannot satisfy the already-arrived case. Open: situation 12 (one actor, two saves, one window — blocks phase 2), whether the fact-index lookup earns its coupling, and how an unsatisfied wait is graded | | [`watch-and-catalog-architecture.md`](design/watch-and-catalog-architecture.md) | the target three-layer watch model — **needs a human call before building** | diff --git a/docs/definitions.md b/docs/definitions.md index 3a21e8934..e1aee2544 100644 --- a/docs/definitions.md +++ b/docs/definitions.md @@ -71,6 +71,9 @@ Use Kubernetes' collection, watch, and initial-events vocabulary in explanatory [Kubernetes watch options](facts/kubernetes-watch-options.md). Existing identifiers such as `CellKey`, `SourceCell`, `Replaying`, and `status.streams` keep their current spelling until an explicit code or API migration. This is an intentional transition, not a runtime behavior change. +The [first implementation step](design/collection-terminology-rename.md) removes the cell vocabulary +and per-rule operation filters; [label selection](design/watches-labels-simplification.md) follows +as a separate behavior change. **Resource collection.** Objects of one group/resource within a namespace selection. In the current planner, [`CellKey`](../internal/types/cell.go) contains `(group, resource, namespace)`; diff --git a/docs/design/collection-terminology-rename.md b/docs/design/collection-terminology-rename.md new file mode 100644 index 000000000..129635c45 --- /dev/null +++ b/docs/design/collection-terminology-rename.md @@ -0,0 +1,586 @@ +# Replace cells with collections and remove event filters + +> **Implementation plan; not built. Step 1 of 2.** Complete this simplification before +> [label selection and pruning](watches-labels-simplification.md), which is step 2. +> Index: [`../INDEX.md`](../INDEX.md). Audited: 2026-09-28, source at `69836e70`. +> Rename counts describe tracked working-tree files before adding this plan and its cross-references; +> the existing documentation edits are included. The operation-filter audit is separate, against +> unchanged source at `02e4cd93`. Recount both before implementation. + +**Remove the cell concept from active code and documentation.** Call the selected object set a +resource collection and the process observing it a watch. This is the vocabulary already chosen +in [Definitions](../definitions.md#read-side). The object set remains necessary; its project-specific +name and duplicate vocabulary disappear. + +**Remove `spec.rules[].operations` from both WatchRule and ClusterWatchRule.** Rules select resource +collections, whose complete lifecycle the operator observes. `GitTarget.spec.prune.mode` decides +whether an observed removal deletes a Git document. There is no second switch to suppress creates, +updates, or deletions within a selected collection. + +Kubernetes remains the source of truth and Git follows it. The collection rename preserves behavior; +removing operation filters is an explicit breaking API and behavior change in this same first step. +Step 2 adds server-side selectors, identity-based `Always` pruning for supported Kustomize layouts, +and the required attribution and producer-ordering changes. + +## Result and scope + +The resulting code reads: + +```go +type CollectionKey struct { + Group string + Resource string + Namespace string +} + +// Existing structures, with their fields renamed: +// targetWatchPlan.Collections map[types.CollectionKey]watchSpec +// ResyncScope.Collection types.CollectionKey +// Event.SourceCollection types.CollectionKey +// ResyncRequest.SourceCollection types.CollectionKey +``` + +A collection still belongs to one GitTarget/source-cluster context. Its key remains versionless; +served version stays in the watch specification, which loses its operation filter. An empty +namespace retains its existing cluster-wide meaning. An all-namespace collection remains distinct +from a named namespace collection, including when they overlap. Zero-valued provenance remains unclaimed. + +Make a direct rename throughout the dependency graph. Do not leave `type CellKey = CollectionKey`, +deprecated constructors, duplicate fields, or a wrapper carrying the old vocabulary. Keep +`ResyncScope`: it describes the Git comparison boundary, which step 2 must distinguish from a +label-filtered collection. The rename adds neither selectors nor new state. + +## Remove the rule operation filter + +### Contract and reason + +The current filter lives on +[`ResourceRule.Operations`](../../api/v1alpha3/watchrule_types.go) and +[`ClusterResourceRule.Operations`](../../api/v1alpha3/clusterwatchrule_types.go). It is applied by +[`routeLiveTargetWatchEvent`](../../internal/watch/target_watch.go) after the API server delivers +the event. Initial snapshots already include the full collection. Consequently, a create-only rule +can still write existing objects during initialization, and excluding `DELETE` does not stop +`Always` from pruning an absent identity during a snapshot. Removing the field makes the rule +describe the object set consistently across initialization and live observation. + +After this step, every selected collection processes `ADDED`, `MODIFIED`, and `DELETED` events. +Keep bookmark/error handling, content deduplication, sanitization, attribution, and write gates. +Observing every object event does not require a Git commit for an unchanged object. Keep the +existing mapping from a terminating object's `MODIFIED` event to deletion intent. + +| GitTarget prune mode | Observed removal | Absence from a complete snapshot | +|---|---|---| +| `Never` | Retain Git document | Retain Git document | +| `OnEvent` (default) | Remove Git document | Retain Git document | +| `Always` | Remove Git document | Remove Git document | + +All modes continue accepting creates and updates. There is no create-only or update-only mirror, +replacement event filter, or implicit `operations: ["*"]` field. This also gives step 2 one rule +for label exits: observe the exit, then apply prune policy. + +### Additional source and configuration inventory + +This focused scan counts Go **identifier tokens**, using the scanner described below and exact +spellings in this table. It finds **316 occurrences in 23 Go files**: 94 in 10 production files, +214 in 12 test files, and eight in the generated deepcopy file. These are review/migration counts; +some identifiers move or survive in a different role. They overlap the cell census and must not +be added to it. Lowercase locals such as `ops`, comments, and string renderings need the source +walkthrough too. Two unrelated `Operations` tokens in Git commit counters are excluded. + +| Identifier(s) | Occurrences | Action | +|---|---:|---| +| `Operations` | 57 | Remove rule, compiled-selector, and watch-spec fields and references | +| `OperationSet`, `NamespaceOps` | 47 / 29 | Remove operation sets; retain namespace membership | +| `matchesOperations`, `operationsString`, `operationSpec`, `renderTargetWatchSpecs` | 3 / 3 / 2 / 3 | Delete filter matching and rendering | +| `watchOutcomeOperationFiltered` | 4 | Delete obsolete ingest outcome and its tests | +| `OperationType` | 59 | Remove API/filter uses; move the remaining event classification into `internal/types` | +| `OperationCreate`, `OperationUpdate`, `OperationDelete`, `OperationAll` | 55 / 23 / 19 / 12 | Retain the three event tags internally; delete wildcard selection | + +The configuration inventory adds **two CRD properties**, **two generated deepcopy blocks**, and +**one Helm values-schema property**. There are **17 `operations:` entries in 10 active example, +values, or documentation files**, excluding the two CRD properties: + +- [`config/samples/`](../../config/samples/): two examples, one entry each. +- [`charts/gitops-reverser/values.yaml`](../../charts/gitops-reverser/values.yaml): one starter-rule entry; + remove the matching property from [`values.schema.json`](../../charts/gitops-reverser/values.schema.json). +- [`docs/configuration.md`](../configuration.md): two examples, plus its two generated settings rows. +- [`test/e2e/setup/demo-only/`](../../test/e2e/setup/demo-only/): two example entries. +- [`test/e2e/templates/`](../../test/e2e/templates/): ten entries across four rule templates + (`demo/clusterwatchrule-demo`, `demo/watchrule-all`, `manager/clusterwatchrule-crd`, + and `restart/watchrule-wildcard`, all `.tmpl`). + +Kubernetes admission webhook `rules[].operations` fields in `config/webhook/` and chart webhook +templates remain required for their own API. Do not remove them with a global YAML replacement. +Keep audit verbs, `git.Event.Operation`, commit-operation counts, and mutation classification. +The synthetic CRD-field parser fixture in `hack/crdfields/main_test.go` is also unrelated. + +### Implementation changes + +1. **API and packaging.** Remove both rule fields and the API's operation enum. Keep the event + classification type and three concrete tags in `internal/types`, with their existing string + values, and update audit/watch consumers. Regenerate deepcopy, CRDs, chart CRD copies through + `task helm-sync`, and the configuration settings index through `task settings-index`. +2. **Rule compilation.** Remove the slice, cloning, and matching check from + [`compiledSelector`](../../internal/rulestore/store.go), including the operation parameters on + `GetMatchingRules`, `GetMatchingClusterRules`, and their helpers. Those public matcher methods + currently have test callers only; preserve their namespace/type contract while simplifying them. +3. **Selection and planning.** In [`watched_type_table.go`](../../internal/watch/watched_type_table.go), + replace `NamespaceOps` with `NamespaceScopes map[string]struct{}` and remove `watchSelection.ops`. + Deduplicate namespace selections and served versions without any operation union. Keep named + and all-namespace collections distinct; this change does not resolve their ordering limitation. +4. **Running watches.** In [`target_watch.go`](../../internal/watch/target_watch.go), use a declared + watch-key set instead of `map[targetWatchKey]OperationSet`. Remove `targetWatchStream.ops`, + `runningTargetWatch.spec`, the operation-rendering maps/helpers, and the live `Match` gate. + Keep `watchSpec{Version: ...}` for plan comparison; version changes still restart a collection. + Update startup arguments, plan descriptions, sorted-key helpers, and their callers together. +5. **Fingerprints and diagnostics.** Remove operation components from the two rule fingerprints in + [`watched_type_resolver.go`](../../internal/watch/watched_type_resolver.go) and from the effective + plan fingerprint in [`owner_observability.go`](../../internal/watch/owner_observability.go). + Update the in-memory hashes while preserving durable cursor keys. Drop operation suffixes + from plan logs. Retire `watch_events_total{outcome="operation_filtered"}`; retain the metric name, other + outcomes, and the exactly-once event census. Update its telemetry comments and documentation. + +The internal tag move must preserve [`VerbToOperation`](../../internal/auditutil/objectref.go), +[`IdentityFromAuditEvent`](../../internal/auditutil/identity.go), deletion body preference, +deletion-intent mapping, and author lookup. Do not remove audit mutation filtering or change the +grace policy as a side effect of deleting a user-configurable rule filter. + +### Upgrade contract + +Keep `v1alpha3` and remove the field directly, consistent with the project's pre-1.0 migration +approach. Update live rules, checked-in manifests, and Helm values; no alias or compatibility toggle +survives. A former subset such as `[CREATE]` now observes updates and removals too. If Git documents +must survive removals, set the target's prune mode to `Never` before removing the old field or +upgrading. This policy applies to the whole GitTarget; mixed per-rule retention has no automatic +equivalent. Create-only/update-only behavior has no replacement. + +Do not promise every stale manifest is rejected. Kubernetes +[field validation](https://kubernetes.io/docs/reference/using-api/api-concepts/#field-validation) +rejects unknown fields in `Strict` mode; `Warn` and `Ignore` can accept the request while +[pruning the unknown field](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#field-pruning). +The Helm values schema rejects the removed starter-rule key. Document and test these paths so a +leftover field cannot be mistaken for active deletion protection. + +Preserve the first-session snapshot in [`runTargetWatch`](../../internal/watch/target_watch.go), +even with a stored cursor, so current objects converge after upgrade. A snapshot cannot recover +the historical deletion events a previous filter discarded: `OnEvent` retains those old orphans; +`Always` can remove them through its existing sweep. This change does not retroactively fabricate +deletions or their authors. Document the API removal and retired metric outcome in `UPGRADING.md`. + +## Rename inventory and counting method + +The audit scans `git ls-files`, reading working-tree contents. Go files are tokenized with +`go/scanner`; identifier occurrences are separated from comment and string occurrences. Other +tracked text is scanned for the standalone word or an identifier component `cell`/`cells`, +including camel case and underscores. Counts are occurrences, not matching lines or distinct +symbols. Thus `Cell types.CellKey` contributes two identifiers. `cancelled` contributes none. + +The broad census has **1,452 occurrences in 93 files**. Manual classification finds: + +| Population | Files | Identifier occurrences | Comment occurrences | String/text occurrences | Total | +|---|---:|---:|---:|---:|---:| +| Collection-related production Go | 27 | 396 | 204 | 25 | 625 | +| Collection-related Go tests | 27 | 364 | 74 | 52 | 490 | +| Active documentation/reference cleanup | 18 | n/a | n/a | 265 | 265 | +| **Rename work** | **72** | **760** | **278** | **342** | **1,380** | + +The remaining 72 occurrences are 33 unrelated Go occurrences, 22 unrelated non-Go occurrences, +and 17 historical references. File categories can overlap: an index or investigation can contain +both current terminology and historical quotes. The 760 Go identifiers use **49 distinct +spellings**, including **24 test function names**. The core declarations are two types, 12 +production functions/methods, and eight struct fields; three test helpers also need renaming. + +There are **four file moves**, **24 production log-field occurrences across six key spellings**, +and **one production error-message occurrence**. No collection-related `cell` occurs in a +serialized CRD field, generated CRD/chart schema, metric name/label, or Redis cursor key segment. +The API source does contain five comment occurrences, listed below. + +### Identifier map + +Counts include declarations and references, including tests, but exclude comments and strings. +Identical local spellings in different scopes share a row; these are not symbol-resolution counts. + +| Existing spelling | Replacement | Occurrences | +|---|---|---:| +| `CellKey` | `CollectionKey` | 213 | +| `CellKeyFor` | `CollectionKeyFor` | 50 | +| `Cell` | `Collection` | 70 | +| `Cells` | `Collections` | 17 | +| `cellSpec` | `watchSpec` | 38 | +| `SourceCell` | `SourceCollection` | 16 | +| `sourceCell` | `sourceCollection` | 13 | +| `sourceCellForLog` | `sourceCollectionForLog` | 11 | +| `sourceCellForEvents` | `sourceCollectionForEvents` | 4 | +| `refusalCell` | `refusalCollection` | 3 | +| `RefusedCell` | `RefusedCollection` | 7 | +| `RefusedCellSet` | `RefusedCollectionSet` | 6 | +| `CellSet` | `CollectionSet` | 4 | +| `cellsForWatchKeys` | `collectionsForWatchKeys` | 5 | +| `deduplicateCells` | `deduplicateCollections` | 4 | +| `keysByCell` | `keysByCollection` | 3 | +| `sortCells` | `sortCollections` | 6 | +| `describeCells` | `describeCollections` | 4 | +| `cell` | `collection` | 180 | +| `cells` | `collections` | 48 | +| `byCell` | `byCollection` | 2 | +| `gotCell` | `gotCollection` | 2 | +| `planCell` | `planCollection` | 18 | +| `configMapCell` | `configMapCollection` | 8 | +| `cellForTest` | `collectionForTest` | 4 | +| 24 test function names containing `Cell`/`Cells` | Replace the collection component in each name | 24 | + +`Cell` covers both `targetWatchKey.Cell()` and the fields on `ResyncScope` and `gitPathRefusal`. +`sourceCell` covers both `targetWatchStream.sourceCell()` and `WriteRequest.sourceCell()`. +Rename their actual declarations and callers together; avoid substring replacement across all Go. + +### File moves + +| Existing file | New path | +|---|---| +| [`internal/types/cell.go`](../../internal/types/cell.go) | `internal/types/collection.go` | +| [`internal/types/cell_test.go`](../../internal/types/cell_test.go) | `internal/types/collection_test.go` | +| [`internal/git/source_cell.go`](../../internal/git/source_cell.go) | `internal/git/source_collection.go` | +| [`internal/git/source_cell_test.go`](../../internal/git/source_cell_test.go) | `internal/git/source_collection_test.go` | + +Keep the source-provenance rationale with its renamed helper. Update Markdown links and plain +path references in Go comments, including `watchrule_types.go`, `watched_type_resolver.go`, and +`internal/git/types.go`. New paths above are planned paths; their links become valid after the move. + +## Walk through the affected behavior + +### Identity, planning, and watch lifecycle + +Start with the key and constructor in `internal/types`. Preserve equality, comparability, +`String()` output, namespace matching, and version omission byte for byte. Then update +[`target_watch_plan.go`](../../internal/watch/target_watch_plan.go): `Collections` maps keys to +`watchSpec`; `Keep`, `Start`, `Restart`, and `Stop` retain their meaning and ordering. Operation-only +differences disappear from that specification; served-version changes and forced restarts remain. + +In [`target_watch.go`](../../internal/watch/target_watch.go), rename key conversion and provenance +helpers and update every keyed map, loop variable, and cancellation comment. Adding an unrelated +rule still preserves running watches; changing served version remains a restart of the same +collection. This phase does not change cancellation timing or fix the known replacement race. + +[`stream_readiness.go`](../../internal/watch/stream_readiness.go), +[`watch_plane_state.go`](../../internal/watch/watch_plane_state.go), and namespace planning must +use the same key. Preserve map-copy isolation and the weakest-state roll-up per resource type. +A type observed in three namespaces still counts as one type in public status. + +### Git writes, resyncs, and refusals + +Rename provenance in `Event`, `ResyncRequest`, write aggregation, queue logging, and +[`refusal_touch.go`](../../internal/git/refusal_touch.go). Preserve these distinct cases: + +- A single producer supplies its collection; mixed-producer or whole-target work remains unclaimed. +- `ResyncRequest.refusalCollection()` continues deriving its key from the resync scope. It must + not be replaced by diagnostic `SourceCollection`, which answers a different question. +- A successful collection clears only that collection's write refusal and queued refusal commit. + Whole-target scan refusals retain their separate meaning and precedence. +- A nil `ResyncScope` continues covering the whole target. `CollectionKey{}` as provenance means + unclaimed; it must not become an all-resources wildcard in `Matches`. + +[`markResyncTailForWriteLocked`](../../internal/git/branch_worker.go) still matches target and +structural resource identity, including payload-free deletes. Provenance remains diagnostic; +no consumer-side watch-plan filter or generation fence is introduced. + +### Readiness, render fidelity, and retention + +Update both halves of the render-fidelity gate and all acceptance/retention maps. Scope revisions +must remain attached to the watch incarnation that produced each report. Kept watches retain +their result; stale canceled reports remain rejected. Renaming `CellSet`/`RefusedCellSet` must +preserve the distinction between a scoped refusal and a target-wide refusal. + +Retention continues reporting documents the prune policy kept outside the selected snapshot. +Counts, revision checks, and the existing overlap-counting limitation remain as implemented. +Neither retention nor readiness changes units in this phase. + +## Compatibility surfaces + +| Surface | Step 1 contract | +|---|---| +| Internal Go types, fields, helpers, and tests | Direct rename; no compatibility aliases | +| `cell` log key, 13 occurrences | `collection` | +| `sourceCell` log key, 7 occurrences | `sourceCollection` | +| `keepCells`, `startCells`, `restartCells`, `stopCells`, one each | Corresponding `*Collections` keys | +| Duplicate-collection error in `targetWatchPlanFor`, one occurrence | Replace the word `cell`; retain detection and values | +| `CollectionKey.String()` and `ResyncScope.String()` | Preserve rendered values; coalescing/refusal keys use them | +| CRD fields, condition types/reasons, printer columns | Remove only the two `rules[].operations` properties; preserve the remaining contract | +| Metrics and their labels/values | Retire `watch_events_total`'s `operation_filtered` outcome; preserve remaining definitions | +| Redis cursor and author keys, TTLs | Preserve bytes and versions | +| Plan fingerprints and log descriptions | Remove operation components; preserve collection/version identity | +| Git paths, placement, encryption, prune policy | Preserve behavior; every live object event now reaches the normal processing path | + +The six log keys change in place. Document the mapping in `docs/UPGRADING.md` when implementing, +using present tense as required by the repository. Mention the change for log-query consumers; +do not invent duplicate old/new fields as a compatibility layer. + +[`watchCursorKey`](../../internal/queue/redis_store.go) already encodes target UID, group/resource, +and namespace under `:watch:v1:`. It contains no cell segment. Preserve its golden strings in +[`key_prefix_test.go`](../../internal/queue/key_prefix_test.go), including the arbitrary +`cell-a:tenant-7` user prefix. That sample is not collection identity. Step 2 owns selector-aware +cursor identity and any resulting storage-version decision. + +The five rename occurrences in [`watchrule_types.go`](../../api/v1alpha3/watchrule_types.go) are +rationale or helper comments. Keep them outside field doc comments and Kubebuilder marker blocks. +Use the repository's description-stripped schema comparison for comment-only edits. For the full +step, allow exactly the two `rules[].operations` property removals, including their item enums; +all remaining validation, resource scope, defaults, required fields, and serialization must match. + +### Adjacent vocabulary reviewed, outside this rename + +These counts are Go identifier occurrences in tracked files, including tests. They are separate +from the cell census. They identify the cost of a broader migration without folding it into this +prerequisite. + +| Existing identifier(s) | Occurrences | Disposition | +|---|---:|---| +| `targetWatchStream`, `targetWatchStreams`, `startTargetWatchStreams` | 18 / 6 / 3 | Keep in step 1; managed-watch naming can be changed independently | +| `targetWatchReplayAndStream`, `foldTargetReplayEvent`, `enqueueReplayResync` | 6 / 5 / 3 | Keep identifiers; prose uses initialization/initial events | +| `StreamState`, `StreamStateReplaying` | 10 / 15 | Keep; changing public state/reason vocabulary needs its own complete map | +| `targetStreamStatus`, `StreamSummary`, `StreamsStatus` | 30 / 34 / 14 | Keep status vocabulary and type-count semantics | +| `watchCursorKey` | 9 | Keep; resume-resourceVersion storage remains unchanged | +| `resyncPlan` | 2 | Keep the existing Git snapshot-reconciliation helper | + +A watch stream, Redis fact stream, and table cell are valid terms. The acceptance criterion is +removal of the project-specific collection synonym, not removal of those words from every file. +In particular, renaming `status.streams` to `collections` would misdescribe its current type count. + +## Documentation work + +Update all 18 current documents in the inventory below, including diagrams, section titles, +code examples, and references to types or test names. Keep release/commit/branch quotes literal. +The plan itself necessarily records the old spelling for migration; it is not an active alias. + +- In Definitions, remove the transitional `CellKey`/`SourceCell` explanation when the code lands. + Define the collection directly and point broader status vocabulary to its unchanged contract. +- Update architecture, components, commit-message scope descriptions, and metrics explanations. + A metric still describes the same observation after its explanatory prose says collection. +- Rename the heading “What a cell leaving means” in `target-watch-plan.md` and fix its inbound + anchor in `typeset-owns-discovery-grace.md`, plus prose citations in `internal/typeset/lifecycle.go`, + `docs/TODO.md`, `data-plane-triggering.md`, and `watch-manager-ownership.md`. +- Review `watch-and-catalog-architecture.md` and `target-watch-plan.md` as older design records. + Updating vocabulary does not adopt their old removal proposals or add missing implementations. +- Rebase step 2's source references and examples onto `CollectionKey`, `SourceCollection`, and + `watchSpec`. Keep its decisions about cluster authority and full `Always` support intact. +- Remove operation-filter instructions from `configuration.md`, `architecture.md`, `components.md`, + and the older watch-plan/source-scope designs. Their namespace isolation argument still applies. + Update `spec/gittarget-isolation-on-rule-change.md` to describe duplicate collection selection + without operation unions, and `spec/watch-event-ordering-and-attribution-grace.md` to remove the + filter before attribution. Update `interpreting-metrics.md` and `design/metrics-observability-plan.md` + for the retired ingest outcome. Cover the changed rule contract in `README.md` and `UPGRADING.md`. + +Historical exceptions: the one CHANGELOG entry, 13 occurrences in the two `docs/finished/` records, +and three literal branch/deleted-symbol references in `watch-plane-status-convergence-failures.md`. +Preserve their historical spelling. If a historical link points at a moved source file, update +its destination or pin the historical source; do not leave a broken relative link. + +Unrelated exclusions are deliberate: table/layout cells in `hack/crdfields`, +`hack/gitops-layouts-baseline.sh`, Markdown lint/style examples, the Git request ledger, manifest +comparison tables, census metric buckets, the push-conflict matrix, and sparse expansion matrices. +The sample Redis prefix is also unrelated. These account for the 55 unrelated occurrences. + +## Implementation sequence + +1. **Capture the baseline.** Recount the working tree, record the commit and any existing changes, + and classify new hits. Confirm the desired `CollectionKey`/`watchSpec` names do not collide. +2. **Rename the Go graph and four files together.** Start with types and fields, then planner, + watch/queue producers, Git consumers, gates, and tests. Keep the declarations and every caller + in one reviewable change. Use symbol-aware edits; no temporary alias layer is needed. +3. **Remove operation selection across the same graph.** Apply the API, planner, matcher, live + routing, internal-tag, metric, and packaging changes above. Remove filter-specific tests while + preserving their independent namespace, version, ownership, and copy-isolation assertions. +4. **Update diagnostics and documentation.** Apply the six log-key mappings, the error message, + prose, paths, headings, and affected test names. Cover the deliberate behavior/API change and + diagnostic changes in the upgrade entry. The original rename counts are a baseline; some of + those sites disappear with the filter removal instead of being renamed. +5. **Inspect generated output and run the checks below.** Verify the exact schema delta and new + behavior, as well as the preserved invariants. No selector implementation belongs here. +6. **Land step 1 independently.** Its scope is terminology plus removal of event selection. + Rebase the label-selection plan onto it before beginning step 2. + +No Go renames are performed by creating this document. Implementation and its full validation +suite are the next task. + +### Validation for the implementation + +Run `task fmt`, `task generate`, `task manifests`, `task helm-sync`, `task settings-index`, and +`task vet`, then the required `task lint` and `task test`. Validate Helm rendering/schema and CRD +installation with the changed rule shapes. Preserve any coverage-baseline increase. Verify Docker +with `docker info` before running `task test-e2e`; run e2e sequentially as the repository requires. +Because the change spans watch plumbing, follow the repository's local-e2e-before-push rule if publishing. +The current documentation-only task requires `task lint-docs` and direct Vale on edited documents. + +Reuse existing tests for the rename; add focused regressions for operation-filter removal. +Inspect coverage of these preserved behaviors: + +| Invariant | Existing evidence to preserve | +|---|---| +| Versionless identity, namespace matching, stable display | `cell_test.go` (renamed), `target_watch_plan_test.go` | +| One watch per collection across served versions; separate namespace scopes | `target_watch_test.go`, `source_namespace_stream_summary_test.go` | +| Unrelated edits keep watches/readiness; forced changes restart the expected set | `target_watch_plan_test.go`, `target_watch_test.go` | +| Nil/zero/mixed provenance and diagnostic rendering | `source_cell_test.go` (renamed), `git_path_refusal_test.go` | +| Scoped snapshots protect sibling namespaces and preserve prune modes | `resync_scope_test.go` in watch and Git packages | +| FIFO and payload-free delete fences prevent unsafe resync coalescing | `internal/git/resync_push_test.go` | +| A recovered collection cannot clear another's refusal | `refusal_observation_test.go`, `git_path_acceptance_scan_test.go` | +| Stale revisions cannot reopen fidelity or restore retired retention counts | `render_fidelity_gate_test.go`, `retention_rollup_test.go` | +| Cursor bytes and prefixes are stable | `internal/queue/key_prefix_test.go`, `redis_store_test.go` | +| Type-count status unchanged; exactly two API properties removed | Stream-summary/controller tests and generated-schema comparison | + +Required operation-removal cases: + +| Case | Expected result / test location | +|---|---| +| Both rule kinds omit `operations` | Valid CRs; selected collections observe creates, updates, and deletes | +| Old manifests still send the field | Raw requests with `fieldValidation=Strict` fail; `Warn`/`Ignore` drop it, with all-event behavior; cover both CRDs in envtest | +| Helm starter rule still supplies the key | Values-schema validation rejects it; default and corrected examples render and install | +| Live create, changed update, physical delete, terminating `MODIFIED` | Normal mapping and routing in `target_watch_test.go`; no operation-selection gate | +| Delete with each prune mode | `OnEvent`/`Always` remove; `Never` retains; snapshot-only absence still removes only under `Always` | +| Create, unchanged update, delete, recreate under the same name | No-op update suppression survives; delete clears cached content so recreation is written | +| Duplicate rules and several served versions | One collection per namespace scope; deterministic version choice; no operation-union state | +| Named and all-namespace selection coexist | Both scopes remain; removing one rule preserves the other and its readiness | +| Rule-store snapshot isolation | Remaining group/version/resource/namespace slices still cannot mutate stored state | +| A version edit or forced recheck | Restarts the expected collections; replace the old operation-edit restart test with a version edit | +| New watch incarnation with a saved cursor | Starts with a complete snapshot; `OnEvent` does not invent deletions missed by old filters | +| Audit create, PATCH/UPDATE, delete/deletecollection | Same tag strings, body priority, and attribution behavior after the type moves to `internal/types` | +| Delivered watch events and ingest metrics | Exactly one existing applicable outcome each; none emits `operation_filtered` | + +### Completion criteria + +- No collection-related old identifier, comment, live log key, or active explanatory term remains + in the audited source graph. No `CellKey` alias or duplicate old/new representation survives. +- Four files are moved, source/doc links resolve, and surviving affected test names use collection. +- The only old spellings remaining are the explicit unrelated/historical exceptions and migration + documentation. Raw `rg cell` is not a valid zero-occurrence gate because it also matches + `cancelled` and unrelated table cells. +- Both rule operation fields and their schema, starter-config, matcher, planner, and live-filter + machinery are gone. Internal operation tags remain; no replacement selection switch exists. +- Outside the two removed API properties and retired metric outcome, serialized contracts and + metric definitions are unchanged. Redis keys, prune policy, collection equality/matching, + ordering, revision behavior, and status units remain intact. All object events are processed; + formerly filtered live events can now change Git. Diagnostics match the documented table. +- Required checks pass and step 2 references the renamed interfaces. Label support is still unbuilt. + +## Handoff to step 2 + +| Concern | After step 1 | Label-selection implementation | +|---|---|---| +| Collection identity | Group/resource/namespace | Add canonical selector consistently | +| Watch specification | Served version only; all object events observed | Preserve the same separation from identity; no operation filter | +| Rule contract | Resources, groups, versions, namespace scope | Add `objectSelector`; prune mode still controls removals | +| Provenance | `SourceCollection`, diagnostic | Carry selector-aware identity; retain raw attribution evidence | +| Git sweep predicate | Structural `ResyncScope.Matches` | Keep structural ownership; compare with the selected snapshot | +| Queue coalescing | Existing identifier-based ordering fence | Include selector identity; preserve the structural write fence | +| Watch replacement | Existing cancellation behavior | Add the producer handoff specified by step 2 | +| Redis resume position | Existing `:watch:v1:` bytes | Prevent resume under a different selector; decide format migration there | +| Pruning/Kustomize/attribution | Existing behavior | Full `Always`, shared semantic removal, and filtered-removal attribution | + +Do not make `CollectionKey.Matches` evaluate labels when step 2 adds a selector. The object set +observed by a watch and the structural scope of Git objects owned by its mirror answer different +questions. Step 1 gives them accurate names; step 2 establishes the new membership behavior. + +## Per-file audit + +This is the baseline inventory, not a claim that implementation has happened. `IDs` counts Go +identifier occurrences; `comments` and `strings` count matched words/components inside those tokens. + +### Go files + +| File | IDs | Comments | Strings | Total | +|---|---:|---:|---:|---:| +| [`api/v1alpha3/watchrule_types.go`](../../api/v1alpha3/watchrule_types.go) | 0 | 5 | 0 | 5 | +| [`internal/authz/source_namespace.go`](../../internal/authz/source_namespace.go) | 0 | 3 | 0 | 3 | +| [`internal/authz/source_namespace_test.go`](../../internal/authz/source_namespace_test.go) | 0 | 1 | 3 | 4 | +| [`internal/git/branch_worker.go`](../../internal/git/branch_worker.go) | 14 | 7 | 5 | 26 | +| [`internal/git/commit.go`](../../internal/git/commit.go) | 4 | 0 | 0 | 4 | +| [`internal/git/git_path_refusal.go`](../../internal/git/git_path_refusal.go) | 7 | 2 | 1 | 10 | +| [`internal/git/git_path_refusal_test.go`](../../internal/git/git_path_refusal_test.go) | 16 | 0 | 0 | 16 | +| [`internal/git/refusal_observation_test.go`](../../internal/git/refusal_observation_test.go) | 9 | 9 | 3 | 21 | +| [`internal/git/refusal_touch.go`](../../internal/git/refusal_touch.go) | 23 | 14 | 1 | 38 | +| [`internal/git/refusal_touch_test.go`](../../internal/git/refusal_touch_test.go) | 2 | 2 | 0 | 4 | +| [`internal/git/render_fidelity_gate.go`](../../internal/git/render_fidelity_gate.go) | 13 | 6 | 0 | 19 | +| [`internal/git/render_fidelity_gate_test.go`](../../internal/git/render_fidelity_gate_test.go) | 16 | 7 | 8 | 31 | +| [`internal/git/resync_flush.go`](../../internal/git/resync_flush.go) | 2 | 3 | 0 | 5 | +| [`internal/git/resync_push_test.go`](../../internal/git/resync_push_test.go) | 8 | 0 | 1 | 9 | +| [`internal/git/resync_scope_test.go`](../../internal/git/resync_scope_test.go) | 2 | 1 | 1 | 4 | +| [`internal/git/source_cell.go`](../../internal/git/source_cell.go) | 13 | 17 | 0 | 30 | +| [`internal/git/source_cell_test.go`](../../internal/git/source_cell_test.go) | 19 | 0 | 4 | 23 | +| [`internal/git/types.go`](../../internal/git/types.go) | 17 | 19 | 0 | 36 | +| [`internal/git/write_boundary_precondition_test.go`](../../internal/git/write_boundary_precondition_test.go) | 1 | 0 | 0 | 1 | +| [`internal/rulestore/store.go`](../../internal/rulestore/store.go) | 0 | 1 | 0 | 1 | +| [`internal/telemetry/exporter.go`](../../internal/telemetry/exporter.go) | 0 | 1 | 0 | 1 | +| [`internal/types/cell.go`](../../internal/types/cell.go) | 6 | 11 | 0 | 17 | +| [`internal/types/cell_test.go`](../../internal/types/cell_test.go) | 10 | 3 | 2 | 15 | +| [`internal/typeset/lifecycle.go`](../../internal/typeset/lifecycle.go) | 0 | 2 | 0 | 2 | +| [`internal/watch/config_plane_split_review_fixes_test.go`](../../internal/watch/config_plane_split_review_fixes_test.go) | 1 | 0 | 0 | 1 | +| [`internal/watch/event_router.go`](../../internal/watch/event_router.go) | 22 | 0 | 6 | 28 | +| [`internal/watch/event_router_test.go`](../../internal/watch/event_router_test.go) | 8 | 1 | 0 | 9 | +| [`internal/watch/git_path_acceptance.go`](../../internal/watch/git_path_acceptance.go) | 29 | 9 | 0 | 38 | +| [`internal/watch/git_path_acceptance_scan_test.go`](../../internal/watch/git_path_acceptance_scan_test.go) | 14 | 2 | 2 | 18 | +| [`internal/watch/git_path_acceptance_test.go`](../../internal/watch/git_path_acceptance_test.go) | 53 | 4 | 1 | 58 | +| [`internal/watch/gitpath_events.go`](../../internal/watch/gitpath_events.go) | 0 | 2 | 0 | 2 | +| [`internal/watch/manager.go`](../../internal/watch/manager.go) | 3 | 1 | 0 | 4 | +| [`internal/watch/owner_observability.go`](../../internal/watch/owner_observability.go) | 0 | 1 | 0 | 1 | +| [`internal/watch/owner_test.go`](../../internal/watch/owner_test.go) | 4 | 3 | 0 | 7 | +| [`internal/watch/render_fidelity_gate.go`](../../internal/watch/render_fidelity_gate.go) | 19 | 4 | 2 | 25 | +| [`internal/watch/resync_scope_test.go`](../../internal/watch/resync_scope_test.go) | 1 | 0 | 0 | 1 | +| [`internal/watch/retention_rollup.go`](../../internal/watch/retention_rollup.go) | 12 | 12 | 5 | 29 | +| [`internal/watch/retention_rollup_test.go`](../../internal/watch/retention_rollup_test.go) | 40 | 8 | 4 | 52 | +| [`internal/watch/source_namespace_planning_test.go`](../../internal/watch/source_namespace_planning_test.go) | 0 | 3 | 0 | 3 | +| [`internal/watch/source_namespace_stream_summary_test.go`](../../internal/watch/source_namespace_stream_summary_test.go) | 1 | 3 | 1 | 5 | +| [`internal/watch/source_namespace_test.go`](../../internal/watch/source_namespace_test.go) | 1 | 1 | 0 | 2 | +| [`internal/watch/stream_readiness.go`](../../internal/watch/stream_readiness.go) | 49 | 7 | 0 | 56 | +| [`internal/watch/stream_readiness_test.go`](../../internal/watch/stream_readiness_test.go) | 24 | 1 | 1 | 26 | +| [`internal/watch/target_watch.go`](../../internal/watch/target_watch.go) | 93 | 42 | 0 | 135 | +| [`internal/watch/target_watch_plan.go`](../../internal/watch/target_watch_plan.go) | 64 | 23 | 5 | 92 | +| [`internal/watch/target_watch_plan_test.go`](../../internal/watch/target_watch_plan_test.go) | 120 | 6 | 14 | 140 | +| [`internal/watch/target_watch_session_end_test.go`](../../internal/watch/target_watch_session_end_test.go) | 1 | 2 | 1 | 4 | +| [`internal/watch/target_watch_test.go`](../../internal/watch/target_watch_test.go) | 13 | 13 | 6 | 32 | +| [`internal/watch/watch_event_metrics.go`](../../internal/watch/watch_event_metrics.go) | 0 | 2 | 0 | 2 | +| [`internal/watch/watch_event_metrics_test.go`](../../internal/watch/watch_event_metrics_test.go) | 0 | 1 | 0 | 1 | +| [`internal/watch/watch_plane_state.go`](../../internal/watch/watch_plane_state.go) | 5 | 5 | 0 | 10 | +| [`internal/watch/watched_type_resolver.go`](../../internal/watch/watched_type_resolver.go) | 1 | 5 | 0 | 6 | +| [`test/e2e/audit_route_attribution_e2e_test.go`](../../test/e2e/audit_route_attribution_e2e_test.go) | 0 | 2 | 0 | 2 | +| [`test/e2e/refusal_empty_commit_e2e_test.go`](../../test/e2e/refusal_empty_commit_e2e_test.go) | 0 | 1 | 0 | 1 | + +### Current documents + +The counts omit the unrelated matrix cell in `docs/INDEX.md` and the three literal historical +references in the status investigation. Other historical and unrelated documents are excluded. + +| Document | Occurrences to review | +|---|---:| +| [`docs/INDEX.md`](../INDEX.md) | 5 | +| [`docs/TODO.md`](../TODO.md) | 4 | +| [`docs/UPGRADING.md`](../UPGRADING.md) | 2 | +| [`docs/architecture.md`](../architecture.md) | 6 | +| [`docs/commit-messages.md`](../commit-messages.md) | 1 | +| [`docs/components.md`](../components.md) | 9 | +| [`docs/definitions.md`](../definitions.md) | 6 | +| [`docs/design/data-plane-triggering.md`](../design/data-plane-triggering.md) | 14 | +| [`docs/design/gittarget-configuration-freshness.md`](../design/gittarget-configuration-freshness.md) | 1 | +| [`docs/design/source-scope-simplification.md`](../design/source-scope-simplification.md) | 15 | +| [`docs/design/target-watch-plan.md`](../design/target-watch-plan.md) | 96 | +| [`docs/design/watch-and-catalog-architecture.md`](../design/watch-and-catalog-architecture.md) | 76 | +| [`docs/design/watch-manager-ownership.md`](../design/watch-manager-ownership.md) | 11 | +| [`docs/design/watch-plane-status-convergence-failures.md`](../design/watch-plane-status-convergence-failures.md) | 12 | +| [`docs/design/watches-labels-simplification.md`](../design/watches-labels-simplification.md) | 3 | +| [`docs/interpreting-metrics.md`](../interpreting-metrics.md) | 1 | +| [`docs/spec/typeset-owns-discovery-grace.md`](../spec/typeset-owns-discovery-grace.md) | 1 | +| [`docs/spec/watch-event-ordering-and-attribution-grace.md`](../spec/watch-event-ordering-and-attribution-grace.md) | 2 | + +### Reproduce the census + +Start from tracked files so ignored `docs/local-only/`, external checkouts, module caches, and +build artifacts do not inflate the plan. This component expression avoids `cancelled`: + +```bash +git grep -n -E 'Cell(s)?([^a-z]|$)|(^|[^A-Za-z0-9])(cell|cells|CELL|CELLS)([A-Z0-9_]|[^A-Za-z0-9_]|$)' -- '*.go' '*.md' +git ls-files '*cell*' '*Cell*' +``` + +That command is for finding candidates; it prints matching lines, so its line count is not the +occurrence count above. To reproduce the precise totals, enumerate `git ls-files -z`, scan Go +with `go/scanner` using `scanner.ScanComments`, and count matching components separately in +`token.IDENT`, `token.COMMENT`, and `token.STRING`. Scan other text using the same component rule, +then apply the unrelated/historical exclusions documented above. Recount on the implementation +base; this plan and new cross-references intentionally add migration mentions after the baseline. diff --git a/docs/design/watches-labels-simplification.md b/docs/design/watches-labels-simplification.md index 6ceab6f07..51130b577 100644 --- a/docs/design/watches-labels-simplification.md +++ b/docs/design/watches-labels-simplification.md @@ -2,6 +2,7 @@ > **Design requirements; implementation pending.** All three prune modes, including `Always` > with supported Kustomize layouts, are required from the first release. +> **Step 2 of 2:** implement after the [collection rename and event-filter removal](collection-terminology-rename.md). > [Issue #146](https://github.com/ConfigButler/gitops-reverser/issues/146). > Index: [`../INDEX.md`](../INDEX.md). Checked: 2026-09-28, source at `69836e70`. > Related: [Kubernetes watch facts](../facts/kubernetes-watch-options.md), @@ -45,16 +46,16 @@ comparison. Preserve unsanitized watch provenance for attribution. ## One collection is one list/watch request -Extend the current [`CellKey`](../../internal/types/cell.go) within a GitTarget: +Extend `CollectionKey` from step 1 (currently [`CellKey`](../../internal/types/cell.go)) within a GitTarget: ```text collection: group, resource, namespace ("" = all namespaces), canonical label selector -watch spec: served version + operation filter +watch spec: served version ``` -[`targetWatchStreams`](../../internal/watch/target_watch.go) unions operations only for identical -collections. Unioning selectors and operations separately would let one rule's operations apply -to another rule's objects. Served version remains data, so changing it does not move Git files. +Step 1 removes rule operation filters. Deduplicate identical collections and choose their served +version deterministically; every collection observes all object event types. Served version remains +data, so changing it does not move Git files. Keep each selector attached to its own collection. ### Canonical selector identity @@ -102,9 +103,9 @@ label check distinguishes them. Prune policy decides whether the document is rem | `OnEvent` (default) | Remove | Retain | | `Always` | Remove | Remove | -Operation filters apply to live events. Excluding `DELETE` suppresses observed removals, but -initial snapshots remain complete and `Always` can still prune absent identities. `Never` -disables both deletion paths. All modes cover plain YAML and supported Kustomize layouts. +Every observed removal reaches the normal processing path; there is no rule operation filter. +`Never` disables both Git deletion paths while continuing to mirror creates and updates. All +modes cover plain YAML and supported Kustomize layouts. ## Snapshot and prune boundary @@ -234,7 +235,7 @@ inline and ordered; uncertainty affects authorship only. Resyncs retain configur ### Scope integration and queue ordering Keep [`ResyncScope.Matches`](../../internal/git/types.go) structural: target, group/resource, and -namespace. The collection identity additionally carries the selector. Update the `CellKey`/scope +namespace. The collection identity additionally carries the selector. Update the `CollectionKey`/scope comments to distinguish the selected collection from the Git objects it owns. [`markResyncTailForWriteLocked`](../../internal/git/branch_worker.go) must keep matching identifiers: a `DELETE` may have no object payload, and different selector generations can affect the same file. @@ -269,14 +270,14 @@ retention counts, and unresolved attribution. These are consequences of the chos Filtered requests reduce transferred objects but add watches; measure the cost. Keep requests filtered because [authorization can depend on selectors](../facts/kubernetes-watch-options.md#selectors-and-authorization). -A denial cannot become an empty snapshot. Field selectors and identifier renames remain separate -work. Different selectors over overlapping scopes need union membership and stale-observation -handling; independent sweeps are insufficient. The existing same-selector overlap ordering gap -also remains outside this change. +A denial cannot become an empty snapshot. Field selectors remain separate work; the collection +rename and operation-filter removal are prerequisites in step 1. Different selectors over +overlapping scopes need union membership and stale-observation handling; independent sweeps are +insufficient. The existing same-selector overlap ordering gap also remains outside this change. ## Tests -Preserve existing version, operation-union, unchanged-watch, and replacement checks in +Preserve step 1's version, duplicate-collection, unchanged-watch, and replacement checks in [`target_watch_test.go`](../../internal/watch/target_watch_test.go) and [`target_watch_plan_test.go`](../../internal/watch/target_watch_plan_test.go), plus sibling-namespace protection in [`resync_scope_test.go`](../../internal/git/resync_scope_test.go). Add: @@ -289,8 +290,9 @@ protection in [`resync_scope_test.go`](../../internal/git/resync_scope_test.go). afresh. Disjoint namespaces permit distinct selectors; overlaps refuse the newer rule. - Equal names across resource types/namespaces remain distinct. Version changes and new UIDs do not orphan Git representations. Missing Git objects use placement; mapping failures refuse. -- Observe entry/exit under all modes. Exclude live `DELETE`, then show `Always` still prunes on a - fresh snapshot. Keep terminating-object behavior and test missed events and excluded UPDATEs. +- Observe entry, label exit, and physical deletion under all modes, with no operation filter. + Simulate missed deletes and updates during disconnection: a fresh snapshot restores present + object state and prunes absent identities only under `Always`. Keep terminating-object behavior. - With `C={A,B}` and `D={A}`, including never-selected B, `OnEvent`/`Never` retain B and report one retained document; `Always` removes B and reports zero retained. Check status and per-type metrics. Other namespaces/types stay untouched. An empty `D` removes all `C` only under `Always`. diff --git a/docs/spec/watch-event-ordering-and-attribution-grace.md b/docs/spec/watch-event-ordering-and-attribution-grace.md index 03c9bd0c5..c5b3a09bd 100644 --- a/docs/spec/watch-event-ordering-and-attribution-grace.md +++ b/docs/spec/watch-event-ordering-and-attribution-grace.md @@ -40,7 +40,9 @@ watch event -> BranchWorker.Enqueue ``` -Operation filtering and unchanged-content checks can discard events before attribution. +Operation filtering and unchanged-content checks can discard events before attribution in the +current implementation. [Step 1](../design/collection-terminology-rename.md#remove-the-rule-operation-filter) +removes the rule operation filter; unchanged-content suppression and ordered attribution remain. For events that need attribution, [`ResolveAuthor`](../../internal/watch/author_resolver.go) calls the attribution index's `Await` method inline. It waits for a resolution or the grace deadline, subject to cancellation. From 8830cee6dd5cb608382132139b96f436443c4b5d Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 28 Sep 2026 10:43:01 +0000 Subject: [PATCH 08/10] docs: apply review fixes and gate the new watch docs Rename the architecture heading that contradicted its section, drop the cell vocabulary from the target-watch-plan index row, pin the RBAC link to v1.37.0, and match the index row style. Add the four new watch docs to the prose gate; the two step plans say to leave it once they ship. Co-Authored-By: Claude Opus 5.5 --- .docs-lint-scope | 6 ++++++ docs/INDEX.md | 4 ++-- docs/architecture.md | 2 +- docs/design/collection-terminology-rename.md | 3 ++- docs/design/watches-labels-simplification.md | 3 +++ docs/facts/kubernetes-watch-options.md | 2 +- docs/spec/README.md | 2 +- 7 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.docs-lint-scope b/.docs-lint-scope index 2ced2c7e6..23f454de7 100644 --- a/.docs-lint-scope +++ b/.docs-lint-scope @@ -29,3 +29,9 @@ docs/spec/attribution.md docs/components.md docs/quickstart.md docs/demo/commit-window.md +docs/facts/kubernetes-watch-options.md +docs/spec/watch-event-ordering-and-attribution-grace.md +# Temporary: the watch-rule step 1 and step 2 plans. Each plan's completion +# criteria says to remove its line here once that step ships. +docs/design/collection-terminology-rename.md +docs/design/watches-labels-simplification.md diff --git a/docs/INDEX.md b/docs/INDEX.md index 34017e697..11c608551 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -83,7 +83,7 @@ misled. Full list in [`spec/README.md`](spec/README.md); the ones that carry a |---|---| | [`manifest-system.md`](spec/manifest-system.md) | the whole live → Git pipeline, and every invariant below in summary | | [`attribution.md`](spec/attribution.md) | **how a commit gets its author.** Deletion is attributed at intent time; the publish half files a fact under the strongest key it has, the join half walks the tiers strongest-first, and neither branches on the type. The single reference, folded from six design records | -| [`watch-event-ordering-and-attribution-grace.md`](spec/watch-event-ordering-and-attribution-grace.md) | How attribution waits and branch queues preserve per-watch order, and the limits across overlapping watches. | +| [`watch-event-ordering-and-attribution-grace.md`](spec/watch-event-ordering-and-attribution-grace.md) | how attribution waits and branch queues preserve **per-watch** order, and why that order does not extend across overlapping watches | | [`current-manifest-support-review.md`](spec/current-manifest-support-review.md) | all-or-nothing folder claim; never half-write a multi-doc file; **refuse rather than prune** | | [`manifestedit-field-ownership-spike.md`](spec/manifestedit-field-ownership-spike.md) | the API wins — full-object ownership, never field-subset | | [`reconcile-via-watchlist-mark-and-sweep.md`](spec/reconcile-via-watchlist-mark-and-sweep.md) | **no bookmark, no sweep** | @@ -133,7 +133,7 @@ so you never have to read a proposal to find out it already shipped. | [`proving-the-metric-surface.md`](design/proving-the-metric-surface.md) | **design, partly built.** What identity an operator needs to locate a failure, and what an e2e check has to do to be worth trusting. Written after a metric value that was declared, switched on, surfaced as a condition reason and counted turned out to have no producer at all, and the check added to catch that class then skipped whenever the counter read zero — so deleting every recording call left the suite green. Two rules follow: an assertion must not take its trigger from the metric under test, and no enum value may be reachable only through an injected fake. Carries a cardinality policy that separates bounded configuration identity from per-request identity, a table of which instrument families should own `source_cluster` directly rather than by join, and the PromQL reasoning for `increase()` versus `max_over_time` (neither applies mechanically: one cannot see an event before the first sample, the other cannot reconstruct totals across a reset). Steps 1–4 are built; step 5, diagnostics export, the `e2e_run` scrape label and a pinned-seed regression are not | | [`placement-visibility-and-declared-defaults.md`](design/placement-visibility-and-declared-defaults.md) | **design, half built.** The three questions the inference deletion left, decided and then built in two goes. PR #291 shipped the deletion and none of the eight items queued behind it; the residue was filed as [#295](https://github.com/ConfigButler/gitops-reverser/issues/295) — **which shipped in 0.42.1 via [#319](https://github.com/ConfigButler/gitops-reverser/pull/319) and is what reversed the layout model** — and [#296](https://github.com/ConfigButler/gitops-reverser/issues/296), whose largest half then shipped in [#326](https://github.com/ConfigButler/gitops-reverser/pull/326): `status.layout` became `status.placement`, and the ambiguous render root came with it as a refusal rather than a third counter outcome. The `declared` metric value has since split into `by_type`/`default` too, which was the one row with an external consumer because it changes a label a dashboard may select on. What still stands and is still unbuilt is the rest of the legibility set: the canonical path as a template constant, and `{kindLower}`, tracked on [#339](https://github.com/ConfigButler/gitops-reverser/issues/339) now that #296 is closed; the Event on the GitTarget was dropped. Its Question 2, a CRD default for `placement.default`, is **re-opened rather than superseded** and now lives as an open question in [`layout/model.md`](layout/model.md); the argument against it is legibility, since #319 removed the correctness objection | | [`gittarget-api-wave.md`](design/gittarget-api-wave.md) | **partly built**, filed as [#294](https://github.com/ConfigButler/gitops-reverser/issues/294). One breaking wave on `GitTarget`, of which steps 2 to 7 have **shipped**: `spec.suspend`, `status.placement` and the reconcile-request annotation in [#326](https://github.com/ConfigButler/gitops-reverser/pull/326), and B4's `commitWindow`/`commit.message` move plus the source-scope deletion in [#330](https://github.com/ConfigButler/gitops-reverser/pull/330). Organizing principle: **the folder is described on the GitTarget, the connection describes only the connection** — and this is where that became a struct boundary rather than a sentence, since grouping a field is free only in a release that is already breaking. `spec.mode` and `GitTarget.spec.interval` are both **dropped**, with re-open triggers, and `status.lastHandledReconcileAt` was dropped in the build for a stated reason. What is left is **step 8, the riders** — asserted `CommitRequest.spec.author`, the `CommitRequest` lifecycle hole, the `default` `ClusterProvider` message — tracked on [#339](https://github.com/ConfigButler/gitops-reverser/issues/339); #294 is closed. The reference collapse and the `scope` removal shipped in [#333](https://github.com/ConfigButler/gitops-reverser/pull/333); the `TooManyStreams` cap and the Event on a changed resolution were dropped. Also records that staying `v1alpha3` on loud rejections is a **one-consumer countdown** | -| [`target-watch-plan.md`](design/target-watch-plan.md) | **partly built.** The diff is built and applied; removal semantics are not. The companion to [`watch-manager-ownership.md`](design/watch-manager-ownership.md): the ownership page says WHO applies a plan, this one says WHAT a plan is and what changing it may touch. A cell — group, resource, namespace, deliberately no served version — is the one identity the watch stream, the render-fidelity scope and the mark-and-sweep boundary all agree on, because a key that does not round-trip to the scope it sweeps under is the class of error that deletes user data. The plan is diffed into `keep`/`start`/`restart`/`stop` and applied per cell, so adding one WatchRule stops replaying every unrelated cell into a queue shared with other tenants; a `restart` is a served-version change, which is why the version is spec DATA rather than identity. Readiness and the fidelity revision are per scope, so a KEPT cell holds the result its own replay produced rather than being asked to prove itself again over an unrelated edit. `stop` never touches files — removal is a Git-side sweep under the target's existing `spec.prune.mode`, not a watch-layer delete. "Cut at the producer" is the accepted consequence: nothing fences the queue, so a deselected cell may leave a short tail of writes, bounded by the queue and converged afterwards. Still open: the `stop` classification wants a settled `TypeRemoved` from `typeset` (see TODO), and removal on INTENT is undecided. | +| [`target-watch-plan.md`](design/target-watch-plan.md) | **partly built.** The diff is built and applied; removal semantics are not. The companion to [`watch-manager-ownership.md`](design/watch-manager-ownership.md): the ownership page says WHO applies a plan, this one says WHAT a plan is and what changing it may touch. A resource collection — group, resource, namespace, deliberately no served version — is the one identity the watch stream, the render-fidelity scope and the mark-and-sweep boundary all agree on, because a key that does not round-trip to the scope it sweeps under is the class of error that deletes user data. The plan is diffed into `keep`/`start`/`restart`/`stop` and applied per collection, so adding one WatchRule stops re-initializing every unrelated collection into a queue shared with other tenants; a `restart` is a served-version change, which is why the version is spec DATA rather than identity. Readiness and the fidelity revision are per scope, so a KEPT collection holds the result its own initialization produced rather than being asked to prove itself again over an unrelated edit. `stop` never touches files — removal is a Git-side sweep under the target's existing `spec.prune.mode`, not a watch-layer delete. "Cut at the producer" is the accepted consequence: nothing fences the queue, so a deselected collection may leave a short tail of writes, bounded by the queue and converged afterwards. Still open: the `stop` classification wants a settled `TypeRemoved` from `typeset` (see TODO), and removal on INTENT is undecided. | | [`collection-terminology-rename.md`](design/collection-terminology-rename.md) | **step 1, planned.** Replace cells with resource collections and remove per-rule `operations` from both rule kinds. Rules observe every object event; GitTarget prune mode controls removal. Includes the rename census (760 Go identifiers, 265 documentation occurrences), a separate operation-filter inventory, API/Helm migration, compatibility boundaries, and test cases. | | [`watches-labels-simplification.md`](design/watches-labels-simplification.md) | **step 2, accepted design; not built.** After the collection rename and operation-filter removal, add native per-rule `objectSelector` (#146). Kubernetes is authoritative: `Always` compares selected identities with all managed Git identities in the type/namespace scope, without comparing Git labels. Full support includes supported Kustomize layouts from the first release, plus filtered-removal attribution and ordered producer replacement. | | [`attribution-removal-wait-options.md`](design/attribution-removal-wait-options.md) | a removal now waits for evidence about the DELETION rather than accepting the object's last write, which stopped it naming whoever last edited the object as the author of a deletion they did not perform. Enumerates the eight situations a resolution can be in and shows the cost is concentrated in exactly one: a removal for which no delete fact will ever arrive (a graceful pod delete, a status-only removal, a type the audit policy skips) spends the whole grace to return the answer it had at t=0, measured at ~3.1s against ~70ms when evidence is present. Prices five options against that, and recommends a per-route watermark — stop waiting once the fact stream has demonstrably moved past this event — over a second timeout flag whose right value lives in the API server's config rather than ours. Open: the decision, and how common the case is outside the e2e suite | diff --git a/docs/architecture.md b/docs/architecture.md index 5df9e0523..a4f3d2097 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -617,7 +617,7 @@ Object state arrives through Kubernetes watches. Selected, relevant changes feed recovery establishes current state when event history is unavailable. Git commit grouping and pruning policy determine which intermediate states and removals appear in the repository. -### Watch is already ordered by resource version +### Ordering within one watch One watch delivers live changes in order. Independent watch handlers can still interleave observations of the same object; the [ordering contract](spec/watch-event-ordering-and-attribution-grace.md) diff --git a/docs/design/collection-terminology-rename.md b/docs/design/collection-terminology-rename.md index 129635c45..7742b2371 100644 --- a/docs/design/collection-terminology-rename.md +++ b/docs/design/collection-terminology-rename.md @@ -408,7 +408,6 @@ Run `task fmt`, `task generate`, `task manifests`, `task helm-sync`, `task setti installation with the changed rule shapes. Preserve any coverage-baseline increase. Verify Docker with `docker info` before running `task test-e2e`; run e2e sequentially as the repository requires. Because the change spans watch plumbing, follow the repository's local-e2e-before-push rule if publishing. -The current documentation-only task requires `task lint-docs` and direct Vale on edited documents. Reuse existing tests for the rename; add focused regressions for operation-filter removal. Inspect coverage of these preserved behaviors: @@ -459,6 +458,8 @@ Required operation-removal cases: ordering, revision behavior, and status units remain intact. All object events are processed; formerly filtered live events can now change Git. Diagnostics match the documented table. - Required checks pass and step 2 references the renamed interfaces. Label support is still unbuilt. +- Remove this plan's line from [`.docs-lint-scope`](../../.docs-lint-scope). It is gated only while + the plan is active; once this step ships, the plan is a historical record and leaves the prose gate. ## Handoff to step 2 diff --git a/docs/design/watches-labels-simplification.md b/docs/design/watches-labels-simplification.md index 51130b577..e8e1fadcb 100644 --- a/docs/design/watches-labels-simplification.md +++ b/docs/design/watches-labels-simplification.md @@ -275,6 +275,9 @@ rename and operation-filter removal are prerequisites in step 1. Different selec overlapping scopes need union membership and stale-observation handling; independent sweeps are insufficient. The existing same-selector overlap ordering gap also remains outside this change. +When this step ships, remove this design's line from [`.docs-lint-scope`](../../.docs-lint-scope). +It is gated only while the design is active; afterwards it is a historical record. + ## Tests Preserve step 1's version, duplicate-collection, unchanged-watch, and replacement checks in diff --git a/docs/facts/kubernetes-watch-options.md b/docs/facts/kubernetes-watch-options.md index 17cb6e4b5..275196596 100644 --- a/docs/facts/kubernetes-watch-options.md +++ b/docs/facts/kubernetes-watch-options.md @@ -46,7 +46,7 @@ implementation, which combines label and field predicates. ## Selectors and authorization Kubernetes RBAC does not match requested objects by label. Its -[`RuleAllows` implementation](https://github.com/kubernetes/kubernetes/blob/v1.36.1/plugin/pkg/auth/authorizer/rbac/rbac.go#L178) +[`RuleAllows` implementation](https://github.com/kubernetes/kubernetes/blob/v1.37.0/plugin/pkg/auth/authorizer/rbac/rbac.go#L191) matches resource requests by verb, API group, resource, and name. A label selector therefore does not create a label-based RBAC permission boundary. RBAC restrictions using `resourceNames` do require a matching `metadata.name` field selector for LIST and WATCH; see diff --git a/docs/spec/README.md b/docs/spec/README.md index d87e94925..69c41c438 100644 --- a/docs/spec/README.md +++ b/docs/spec/README.md @@ -32,7 +32,7 @@ three lifecycle classes and was unreadable spread across them. The rows below st | Spec | What it pins | |---|---| | [`attribution.md`](attribution.md) | **how a commit gets its author**: deletion-at-intent, the publish and join halves, the tier ladder, the audit-route partition, and what the metrics can and cannot answer | -| [`watch-event-ordering-and-attribution-grace.md`](watch-event-ordering-and-attribution-grace.md) | Per-watch ordering through attribution and enqueue, including the overlapping-watch limitation. | +| [`watch-event-ordering-and-attribution-grace.md`](watch-event-ordering-and-attribution-grace.md) | **per-watch** ordering through attribution and enqueue, and the overlapping-watch limitation | | [`current-manifest-support-review.md`](current-manifest-support-review.md) | the manifest store, plan/apply/flush, and the all-or-nothing folder claim | | [`../layout/contextual-namespace.md`](../layout/contextual-namespace.md) | kustomize graph-aware namespace inference; the supported subset | | [`reconcile-via-watchlist-mark-and-sweep.md`](reconcile-via-watchlist-mark-and-sweep.md) | initial reconcile; **no bookmark, no sweep** | From d73cb135f8b022b7d665c3f5e5167f6f53a03ac4 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 28 Sep 2026 11:38:32 +0000 Subject: [PATCH 09/10] feat(api)!: replace cells with resource collections and remove rule operations Rules select whole resource collections. WatchRule and ClusterWatchRule lose spec.rules[].operations: every selected collection observes creates, updates and deletions, and GitTarget spec.prune.mode alone decides whether an observed removal deletes a Git document. The operation event tags move to internal/types; watch_events_total drops the operation_filtered outcome. The project-specific "cell" becomes Kubernetes' resource collection in code, logs and docs: CellKey is CollectionKey, cellSpec is watchSpec (served version only), SourceCell is SourceCollection, and the cell/sourceCell/ *Cells log keys become collection/sourceCollection/*Collections. Redis keys and CollectionKey.String() output are unchanged. BREAKING CHANGE: rules[].operations is removed. A rule that listed a subset now also mirrors updates and removals; set spec.prune.mode: Never on the GitTarget before upgrading to keep documents on removal. See UPGRADING.md. Co-Authored-By: Claude Opus 5.5 --- .docs-lint-scope | 5 +- README.md | 4 +- api/v1alpha3/clusterwatchrule_types.go | 8 - api/v1alpha3/watchrule_types.go | 38 +- api/v1alpha3/zz_generated.deepcopy.go | 10 - charts/gitops-reverser/values.schema.json | 5 - charts/gitops-reverser/values.yaml | 6 +- cmd/main_chart_quickstart_rules_test.go | 62 ++++ .../configbutler.ai_clusterwatchrules.yaml | 17 - .../crd/bases/configbutler.ai_watchrules.yaml | 21 +- config/samples/clusterwatchrule.yaml | 3 +- config/samples/quickstart-watchrule.yaml | 3 +- docs/INDEX.md | 2 +- docs/TODO.md | 8 +- docs/UPGRADING.md | 73 +++- docs/architecture.md | 34 +- docs/commit-messages.md | 2 +- docs/components.md | 18 +- docs/configuration.md | 21 +- docs/definitions.md | 24 +- docs/demo/commit-window.md | 3 +- docs/design/collection-terminology-rename.md | 21 +- docs/design/data-plane-triggering.md | 40 +-- .../gittarget-configuration-freshness.md | 2 +- docs/design/metrics-observability-plan.md | 6 +- docs/design/source-scope-simplification.md | 40 +-- docs/design/target-watch-plan.md | 338 +++++++++--------- docs/design/watch-and-catalog-architecture.md | 206 +++++------ docs/design/watch-manager-ownership.md | 24 +- ...watch-plane-status-convergence-failures.md | 26 +- docs/design/watches-labels-simplification.md | 12 +- docs/interpreting-metrics.md | 6 +- .../gittarget-isolation-on-rule-change.md | 33 +- docs/spec/typeset-owns-discovery-grace.md | 2 +- ...ch-event-ordering-and-attribution-grace.md | 16 +- hack/crdfields/fields.yaml | 6 - internal/auditutil/identity.go | 8 +- internal/auditutil/identity_test.go | 18 +- internal/auditutil/objectref.go | 10 +- internal/auditutil/objectref_test.go | 16 +- internal/authz/source_namespace.go | 6 +- internal/authz/source_namespace_test.go | 8 +- .../removed_rule_operations_test.go | 85 +++++ internal/git/branch_worker.go | 30 +- internal/git/commit.go | 8 +- internal/git/git_path_refusal.go | 12 +- internal/git/git_path_refusal_test.go | 23 +- internal/git/refusal_observation_test.go | 40 +-- internal/git/refusal_touch.go | 50 +-- internal/git/refusal_touch_test.go | 4 +- internal/git/render_fidelity_gate.go | 38 +- internal/git/render_fidelity_gate_test.go | 56 +-- internal/git/resync_flush.go | 10 +- internal/git/resync_push_test.go | 8 +- internal/git/resync_scope_test.go | 6 +- internal/git/source_cell_test.go | 39 -- .../{source_cell.go => source_collection.go} | 40 +-- internal/git/source_collection_test.go | 39 ++ internal/git/types.go | 48 +-- .../git/write_boundary_precondition_test.go | 2 +- internal/rulestore/store.go | 49 +-- internal/rulestore/store_test.go | 316 ++++------------ internal/telemetry/exporter.go | 7 +- internal/types/{cell.go => collection.go} | 35 +- .../{cell_test.go => collection_test.go} | 30 +- internal/types/operation.go | 18 + internal/typeset/lifecycle.go | 4 +- .../config_plane_split_review_fixes_test.go | 3 +- internal/watch/event_router.go | 39 +- internal/watch/event_router_test.go | 16 +- internal/watch/git_path_acceptance.go | 60 ++-- .../watch/git_path_acceptance_scan_test.go | 32 +- internal/watch/git_path_acceptance_test.go | 78 ++-- internal/watch/gitpath_events.go | 4 +- internal/watch/helpers_test.go | 4 +- internal/watch/live_content_dedup_test.go | 13 +- internal/watch/manager.go | 14 +- internal/watch/owner_observability.go | 11 +- internal/watch/owner_test.go | 14 +- internal/watch/prune_declaration_test.go | 4 +- internal/watch/render_fidelity_gate.go | 36 +- internal/watch/resync_scope_test.go | 2 +- internal/watch/retention_rollup.go | 50 +-- internal/watch/retention_rollup_test.go | 81 +++-- .../watch/source_namespace_planning_test.go | 8 +- .../source_namespace_stream_summary_test.go | 10 +- internal/watch/source_namespace_test.go | 4 +- internal/watch/stream_readiness.go | 75 ++-- internal/watch/stream_readiness_test.go | 42 +-- internal/watch/target_watch.go | 335 ++++++++--------- internal/watch/target_watch_plan.go | 144 ++++---- internal/watch/target_watch_plan_test.go | 238 ++++++------ .../watch/target_watch_session_end_test.go | 10 +- internal/watch/target_watch_shutdown_test.go | 4 +- internal/watch/target_watch_test.go | 268 +++++++------- internal/watch/terminating_snapshot_test.go | 2 +- internal/watch/watch_event_metrics.go | 23 +- internal/watch/watch_event_metrics_test.go | 15 +- internal/watch/watch_plane_state.go | 18 +- internal/watch/watched_type_resolver.go | 40 +-- internal/watch/watched_type_table.go | 102 ++---- internal/watch/watched_type_table_test.go | 46 +-- test/e2e/audit_route_attribution_e2e_test.go | 4 +- test/e2e/refusal_empty_commit_e2e_test.go | 2 +- .../podinfos-intent/2-reverse-gitops.yaml | 3 +- .../test/coffeeconfig-reverse-gitops.yaml | 3 +- .../templates/demo/clusterwatchrule-demo.tmpl | 6 +- test/e2e/templates/demo/watchrule-all.tmpl | 18 +- .../manager/clusterwatchrule-crd.tmpl | 3 +- .../templates/restart/watchrule-wildcard.tmpl | 3 +- 110 files changed, 1927 insertions(+), 2168 deletions(-) create mode 100644 cmd/main_chart_quickstart_rules_test.go create mode 100644 internal/controller/removed_rule_operations_test.go delete mode 100644 internal/git/source_cell_test.go rename internal/git/{source_cell.go => source_collection.go} (61%) create mode 100644 internal/git/source_collection_test.go rename internal/types/{cell.go => collection.go} (60%) rename internal/types/{cell_test.go => collection_test.go} (60%) create mode 100644 internal/types/operation.go diff --git a/.docs-lint-scope b/.docs-lint-scope index 23f454de7..6fc1d805c 100644 --- a/.docs-lint-scope +++ b/.docs-lint-scope @@ -31,7 +31,6 @@ docs/quickstart.md docs/demo/commit-window.md docs/facts/kubernetes-watch-options.md docs/spec/watch-event-ordering-and-attribution-grace.md -# Temporary: the watch-rule step 1 and step 2 plans. Each plan's completion -# criteria says to remove its line here once that step ships. -docs/design/collection-terminology-rename.md +# Temporary: the watch-rule step 2 plan. Its completion criteria says to remove +# its line here once that step ships. docs/design/watches-labels-simplification.md diff --git a/README.md b/README.md index 1e1011e40..122a2bca6 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,9 @@ agent over MCP. Custom resources configure it: `GitProvider` for repository and credentials, `GitTarget` for branch and folder, `WatchRule` or `ClusterWatchRule` for what to capture, and `ClusterProvider` for the -source cluster, created as `default` on install. See [Configuration](docs/configuration.md). +source cluster, created as `default` on install. A rule selects whole resource collections, including +their deletions; the `GitTarget`'s prune mode decides whether a deletion removes the file from Git. +See [Configuration](docs/configuration.md). By default it watches its own cluster. Add more of each as you need them: one operator can serve several source clusters, repositories, branches, and folders. diff --git a/api/v1alpha3/clusterwatchrule_types.go b/api/v1alpha3/clusterwatchrule_types.go index 1f69bb427..ec3bf79c9 100644 --- a/api/v1alpha3/clusterwatchrule_types.go +++ b/api/v1alpha3/clusterwatchrule_types.go @@ -42,14 +42,6 @@ type ClusterWatchRuleSpec struct { // ClusterResourceRule defines which CLUSTER-SCOPED resources to watch. It deliberately has no // sourceNamespace: cluster-scoped objects have no namespace, so there is nothing to select. type ClusterResourceRule struct { - // Operations to watch. If empty, watches all operations (CREATE, UPDATE, DELETE). - // Supports: CREATE, UPDATE, DELETE, or * (wildcard for all operations). - // Examples: - // - ["CREATE", "UPDATE"] watches only creation and updates - // - ["*"] or [] watches all operations - // +optional - Operations []OperationType `json:"operations,omitempty"` - // APIGroups to match. Empty string ("") matches the core API group. // If omitted, GitOps Reverser resolves the resource name across all served API groups. // Wildcards supported: "*" matches all groups. diff --git a/api/v1alpha3/watchrule_types.go b/api/v1alpha3/watchrule_types.go index eba733170..e0ed6b43a 100644 --- a/api/v1alpha3/watchrule_types.go +++ b/api/v1alpha3/watchrule_types.go @@ -9,21 +9,6 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// OperationType specifies the type of operation that triggers a watch event. -// +kubebuilder:validation:Enum=CREATE;UPDATE;DELETE;* -type OperationType string - -const ( - // OperationCreate matches resource creation events. - OperationCreate OperationType = "CREATE" - // OperationUpdate matches resource update events. - OperationUpdate OperationType = "UPDATE" - // OperationDelete matches resource deletion events. - OperationDelete OperationType = "DELETE" - // OperationAll matches all operation types. - OperationAll OperationType = "*" -) - // WatchRuleSpec defines the desired state of WatchRule. // WatchRule selects NAMESPACED resources on its GitTarget's source cluster. Each rules[] item // carries its own source namespace: omitted for this WatchRule's own namespace, an explicit name, @@ -36,7 +21,9 @@ type WatchRuleSpec struct { // Rules define which resources to watch, and in which source namespaces. // Multiple rules create a logical OR - a resource matching ANY rule is watched. - // Each rule can specify operations, API groups, versions, resource types, and a source namespace. + // Each rule can specify API groups, versions, resource types, and a source namespace. + // A selected resource collection is observed through its whole lifecycle; the GitTarget's + // spec.prune.mode decides whether an observed removal deletes the Git document. // +required // +kubebuilder:validation:MinItems=1 Rules []ResourceRule `json:"rules"` @@ -46,14 +33,6 @@ type WatchRuleSpec struct { // Omitted API groups and versions are resolved from the served Kubernetes API surface. // All fields except Resources are optional. type ResourceRule struct { - // Operations to watch. If empty, watches all operations (CREATE, UPDATE, DELETE). - // Supports: CREATE, UPDATE, DELETE, or * (wildcard for all operations). - // Examples: - // - ["CREATE", "UPDATE"] watches only creation and updates, ignoring deletions - // - ["*"] or [] watches all operations - // +optional - Operations []OperationType `json:"operations,omitempty"` - // APIGroups to match. Empty string ("") matches the core API group. // If omitted, GitOps Reverser resolves the resource name across all served API groups. // Wildcards supported: "*" matches all groups. @@ -104,11 +83,10 @@ type ResourceRule struct { // A denied explicit name refuses the WHOLE WatchRule rather than trimming that item: mirroring // two of the three namespaces a rule asked for is worse than a loud failure. // - // A cluster-wide cell is a PEER of a named-namespace cell on the same type, never a - // replacement: each rule carries its own operations filter, and collapsing the two once widened - // a named rule's stream while discarding that filter (see CellKey in internal/types/cell.go). - // A target carrying both therefore runs two streams over overlapping objects, and that is - // correct. + // A cluster-wide collection is a PEER of a named-namespace collection on the same type, never a + // replacement: collapsing the two once widened a named rule's stream to every namespace its + // credential could read (see CollectionKey in internal/types/collection.go). A target carrying + // both therefore runs two streams over overlapping objects, and that is correct. // SourceNamespace is the namespace this item watches IN THE SOURCE CLUSTER its GitTarget // mirrors from: omitted for this WatchRule's own namespace, an exact name for one other, or @@ -140,7 +118,7 @@ const SourceNamespaceWildcard = "*" // EffectiveSourceNamespace is the source-cluster namespace this ITEM names, given the namespace of // the WatchRule that carries it: spec.rules[].sourceNamespace when set, and the rule's OWN // namespace otherwise. For a wildcard item it returns "*", which is not a namespace name: the -// caller compiles it to the cluster-wide cell (the empty namespace) rather than watching it. +// caller compiles it to the cluster-wide collection (the empty namespace) rather than watching it. // // It is controller logic rather than an API-server default because an apiserver default cannot // refer to metadata.namespace. diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index dbfcfeefe..8d7fd8e32 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -217,11 +217,6 @@ func (in *ClusterProviderStatus) DeepCopy() *ClusterProviderStatus { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterResourceRule) DeepCopyInto(out *ClusterResourceRule) { *out = *in - if in.Operations != nil { - in, out := &in.Operations, &out.Operations - *out = make([]OperationType, len(*in)) - copy(*out, *in) - } if in.APIGroups != nil { in, out := &in.APIGroups, &out.APIGroups *out = make([]string, len(*in)) @@ -1006,11 +1001,6 @@ func (in *PrunePolicy) DeepCopy() *PrunePolicy { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ResourceRule) DeepCopyInto(out *ResourceRule) { *out = *in - if in.Operations != nil { - in, out := &in.Operations, &out.Operations - *out = make([]OperationType, len(*in)) - copy(*out, *in) - } if in.APIGroups != nil { in, out := &in.APIGroups, &out.APIGroups *out = make([]string, len(*in)) diff --git a/charts/gitops-reverser/values.schema.json b/charts/gitops-reverser/values.schema.json index 4f867df72..a77f48347 100644 --- a/charts/gitops-reverser/values.schema.json +++ b/charts/gitops-reverser/values.schema.json @@ -569,11 +569,6 @@ "type": "object", "additionalProperties": false, "properties": { - "operations": { - "type": "array", - "minItems": 1, - "items": { "enum": ["CREATE", "UPDATE", "DELETE"] } - }, "apiGroups": { "type": "array", "items": { "type": "string" } }, "apiVersions": { "type": "array", "items": { "type": "string" } }, "resources": { "type": "array", "items": { "type": "string" } } diff --git a/charts/gitops-reverser/values.yaml b/charts/gitops-reverser/values.yaml index e29de98b5..a0734e34b 100644 --- a/charts/gitops-reverser/values.yaml +++ b/charts/gitops-reverser/values.yaml @@ -455,11 +455,7 @@ quickstart: watchRule: name: example-watchrule rules: - - operations: - - CREATE - - UPDATE - - DELETE - apiGroups: + - apiGroups: - "" apiVersions: - v1 diff --git a/cmd/main_chart_quickstart_rules_test.go b/cmd/main_chart_quickstart_rules_test.go new file mode 100644 index 000000000..9f8a66881 --- /dev/null +++ b/cmd/main_chart_quickstart_rules_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "os" + "os/exec" + "testing" + + "github.com/stretchr/testify/require" +) + +// quickstartChartInputs are read in-process so `go test` treats them as cache inputs; helm reads +// the chart in a subprocess the test log cannot see. +var quickstartChartInputs = []string{ + "../charts/gitops-reverser/values.yaml", + "../charts/gitops-reverser/values.schema.json", + "../charts/gitops-reverser/templates/quickstart.yaml", +} + +func helmTemplateQuickstart(t *testing.T, setValues ...string) ([]byte, error) { + t.Helper() + for _, path := range quickstartChartInputs { + _, err := os.ReadFile(path) + require.NoError(t, err) + } + args := []string{ + "template", "gitops-reverser", "../charts/gitops-reverser", + "--show-only", "templates/quickstart.yaml", + "--set", "quickstart.enabled=true", + "--set", "quickstart.gitProvider.url=https://example.com/repo.git", + } + for _, sv := range setValues { + args = append(args, "--set", sv) + } + return exec.Command("helm", args...).CombinedOutput() +} + +// The starter WatchRule renders without a rules[].operations key: a rule selects a resource +// collection, which is observed through its whole lifecycle. +func TestChartQuickstart_StarterWatchRuleRendersWithoutOperations(t *testing.T) { + out, err := helmTemplateQuickstart(t) + require.NoErrorf(t, err, "helm template failed: %s", out) + + rendered := string(out) + require.Contains(t, rendered, "kind: WatchRule") + require.Contains(t, rendered, "- configmaps") + require.NotContains(t, rendered, "operations") +} + +// rules[].operations is gone from the API. A values file that still sets it must fail the render +// rather than install a rule the user believes suppresses deletions: the removed key is not +// deletion protection, the GitTarget's prune mode is. +func TestChartQuickstart_RemovedOperationsKeyFailsTheRender(t *testing.T) { + out, err := helmTemplateQuickstart(t, + "quickstart.watchRule.rules[0].resources[0]=configmaps", + "quickstart.watchRule.rules[0].operations[0]=CREATE", + ) + require.Errorf(t, err, "render should have failed, got:\n%s", out) + require.Contains(t, string(out), + "at '/quickstart/watchRule/rules/0': additional properties 'operations' not allowed") +} diff --git a/config/crd/bases/configbutler.ai_clusterwatchrules.yaml b/config/crd/bases/configbutler.ai_clusterwatchrules.yaml index e8ac29cba..f139af482 100644 --- a/config/crd/bases/configbutler.ai_clusterwatchrules.yaml +++ b/config/crd/bases/configbutler.ai_clusterwatchrules.yaml @@ -131,23 +131,6 @@ spec: items: type: string type: array - operations: - description: |- - Operations to watch. If empty, watches all operations (CREATE, UPDATE, DELETE). - Supports: CREATE, UPDATE, DELETE, or * (wildcard for all operations). - Examples: - - ["CREATE", "UPDATE"] watches only creation and updates - - ["*"] or [] watches all operations - items: - description: OperationType specifies the type of operation - that triggers a watch event. - enum: - - CREATE - - UPDATE - - DELETE - - '*' - type: string - type: array resources: description: |- Resources to match (plural names like "nodes", "clusterroles"). diff --git a/config/crd/bases/configbutler.ai_watchrules.yaml b/config/crd/bases/configbutler.ai_watchrules.yaml index 960af424c..ff8d93525 100644 --- a/config/crd/bases/configbutler.ai_watchrules.yaml +++ b/config/crd/bases/configbutler.ai_watchrules.yaml @@ -96,7 +96,9 @@ spec: description: |- Rules define which resources to watch, and in which source namespaces. Multiple rules create a logical OR - a resource matching ANY rule is watched. - Each rule can specify operations, API groups, versions, resource types, and a source namespace. + Each rule can specify API groups, versions, resource types, and a source namespace. + A selected resource collection is observed through its whole lifecycle; the GitTarget's + spec.prune.mode decides whether an observed removal deletes the Git document. items: description: |- ResourceRule defines a set of namespaced resources to watch. @@ -134,23 +136,6 @@ spec: items: type: string type: array - operations: - description: |- - Operations to watch. If empty, watches all operations (CREATE, UPDATE, DELETE). - Supports: CREATE, UPDATE, DELETE, or * (wildcard for all operations). - Examples: - - ["CREATE", "UPDATE"] watches only creation and updates, ignoring deletions - - ["*"] or [] watches all operations - items: - description: OperationType specifies the type of operation - that triggers a watch event. - enum: - - CREATE - - UPDATE - - DELETE - - '*' - type: string - type: array resources: description: |- Resources to match (plural names like "pods", "configmaps"). diff --git a/config/samples/clusterwatchrule.yaml b/config/samples/clusterwatchrule.yaml index 48a26706a..842f318c6 100644 --- a/config/samples/clusterwatchrule.yaml +++ b/config/samples/clusterwatchrule.yaml @@ -7,7 +7,6 @@ spec: name: example-target namespace: default rules: - - operations: [CREATE, UPDATE, DELETE] - apiGroups: ["apiextensions.k8s.io"] + - apiGroups: ["apiextensions.k8s.io"] apiVersions: ["v1"] resources: ["customresourcedefinitions"] diff --git a/config/samples/quickstart-watchrule.yaml b/config/samples/quickstart-watchrule.yaml index 9ece3bd3c..5c1cff920 100644 --- a/config/samples/quickstart-watchrule.yaml +++ b/config/samples/quickstart-watchrule.yaml @@ -7,7 +7,6 @@ spec: gitTargetRef: name: example-target rules: - - operations: [CREATE, UPDATE, DELETE] - apiGroups: [""] + - apiGroups: [""] apiVersions: ["v1"] resources: ["configmaps"] diff --git a/docs/INDEX.md b/docs/INDEX.md index 11c608551..72a294204 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -134,7 +134,7 @@ so you never have to read a proposal to find out it already shipped. | [`placement-visibility-and-declared-defaults.md`](design/placement-visibility-and-declared-defaults.md) | **design, half built.** The three questions the inference deletion left, decided and then built in two goes. PR #291 shipped the deletion and none of the eight items queued behind it; the residue was filed as [#295](https://github.com/ConfigButler/gitops-reverser/issues/295) — **which shipped in 0.42.1 via [#319](https://github.com/ConfigButler/gitops-reverser/pull/319) and is what reversed the layout model** — and [#296](https://github.com/ConfigButler/gitops-reverser/issues/296), whose largest half then shipped in [#326](https://github.com/ConfigButler/gitops-reverser/pull/326): `status.layout` became `status.placement`, and the ambiguous render root came with it as a refusal rather than a third counter outcome. The `declared` metric value has since split into `by_type`/`default` too, which was the one row with an external consumer because it changes a label a dashboard may select on. What still stands and is still unbuilt is the rest of the legibility set: the canonical path as a template constant, and `{kindLower}`, tracked on [#339](https://github.com/ConfigButler/gitops-reverser/issues/339) now that #296 is closed; the Event on the GitTarget was dropped. Its Question 2, a CRD default for `placement.default`, is **re-opened rather than superseded** and now lives as an open question in [`layout/model.md`](layout/model.md); the argument against it is legibility, since #319 removed the correctness objection | | [`gittarget-api-wave.md`](design/gittarget-api-wave.md) | **partly built**, filed as [#294](https://github.com/ConfigButler/gitops-reverser/issues/294). One breaking wave on `GitTarget`, of which steps 2 to 7 have **shipped**: `spec.suspend`, `status.placement` and the reconcile-request annotation in [#326](https://github.com/ConfigButler/gitops-reverser/pull/326), and B4's `commitWindow`/`commit.message` move plus the source-scope deletion in [#330](https://github.com/ConfigButler/gitops-reverser/pull/330). Organizing principle: **the folder is described on the GitTarget, the connection describes only the connection** — and this is where that became a struct boundary rather than a sentence, since grouping a field is free only in a release that is already breaking. `spec.mode` and `GitTarget.spec.interval` are both **dropped**, with re-open triggers, and `status.lastHandledReconcileAt` was dropped in the build for a stated reason. What is left is **step 8, the riders** — asserted `CommitRequest.spec.author`, the `CommitRequest` lifecycle hole, the `default` `ClusterProvider` message — tracked on [#339](https://github.com/ConfigButler/gitops-reverser/issues/339); #294 is closed. The reference collapse and the `scope` removal shipped in [#333](https://github.com/ConfigButler/gitops-reverser/pull/333); the `TooManyStreams` cap and the Event on a changed resolution were dropped. Also records that staying `v1alpha3` on loud rejections is a **one-consumer countdown** | | [`target-watch-plan.md`](design/target-watch-plan.md) | **partly built.** The diff is built and applied; removal semantics are not. The companion to [`watch-manager-ownership.md`](design/watch-manager-ownership.md): the ownership page says WHO applies a plan, this one says WHAT a plan is and what changing it may touch. A resource collection — group, resource, namespace, deliberately no served version — is the one identity the watch stream, the render-fidelity scope and the mark-and-sweep boundary all agree on, because a key that does not round-trip to the scope it sweeps under is the class of error that deletes user data. The plan is diffed into `keep`/`start`/`restart`/`stop` and applied per collection, so adding one WatchRule stops re-initializing every unrelated collection into a queue shared with other tenants; a `restart` is a served-version change, which is why the version is spec DATA rather than identity. Readiness and the fidelity revision are per scope, so a KEPT collection holds the result its own initialization produced rather than being asked to prove itself again over an unrelated edit. `stop` never touches files — removal is a Git-side sweep under the target's existing `spec.prune.mode`, not a watch-layer delete. "Cut at the producer" is the accepted consequence: nothing fences the queue, so a deselected collection may leave a short tail of writes, bounded by the queue and converged afterwards. Still open: the `stop` classification wants a settled `TypeRemoved` from `typeset` (see TODO), and removal on INTENT is undecided. | -| [`collection-terminology-rename.md`](design/collection-terminology-rename.md) | **step 1, planned.** Replace cells with resource collections and remove per-rule `operations` from both rule kinds. Rules observe every object event; GitTarget prune mode controls removal. Includes the rename census (760 Go identifiers, 265 documentation occurrences), a separate operation-filter inventory, API/Helm migration, compatibility boundaries, and test cases. | +| [`collection-terminology-rename.md`](design/collection-terminology-rename.md) | **step 1, shipped.** Replaced cells with resource collections and removed per-rule `operations` from both rule kinds. Rules observe every object event; GitTarget prune mode controls removal. Records the rename census (760 Go identifiers, 265 documentation occurrences), the operation-filter inventory, API/Helm migration, compatibility boundaries, and test cases. | | [`watches-labels-simplification.md`](design/watches-labels-simplification.md) | **step 2, accepted design; not built.** After the collection rename and operation-filter removal, add native per-rule `objectSelector` (#146). Kubernetes is authoritative: `Always` compares selected identities with all managed Git identities in the type/namespace scope, without comparing Git labels. Full support includes supported Kustomize layouts from the first release, plus filtered-removal attribution and ordered producer replacement. | | [`attribution-removal-wait-options.md`](design/attribution-removal-wait-options.md) | a removal now waits for evidence about the DELETION rather than accepting the object's last write, which stopped it naming whoever last edited the object as the author of a deletion they did not perform. Enumerates the eight situations a resolution can be in and shows the cost is concentrated in exactly one: a removal for which no delete fact will ever arrive (a graceful pod delete, a status-only removal, a type the audit policy skips) spends the whole grace to return the answer it had at t=0, measured at ~3.1s against ~70ms when evidence is present. Prices five options against that, and recommends a per-route watermark — stop waiting once the fact stream has demonstrably moved past this event — over a second timeout flag whose right value lives in the API server's config rather than ours. Open: the decision, and how common the case is outside the e2e suite | | [`commitrequest-save-wait-options.md`](design/commitrequest-save-wait-options.md) | **design, decided** — a five-phase plan, reported from a live cluster: a `CommitRequest` can expire before the write it exists to publish arrives, because the close delay defaulted to `0` while the write is still held head-of-line waiting for an audit fact, so the save resolves `Ready=True`/`NoWindowInGrace` and the edit commits seconds later under the target's `liveTemplate`. The decision is to repair the default (`spec.closeDelay`, now a duration string defaulting to `"2s"`, **shipped as phase 0**) and then let a request **name the write it is waiting for**, reusing the `(uid, resourceVersion)` identity the attribution join already keys on, including its settled `ExactCapable` rule. Records why the fact index cannot BE the wait (it sits upstream of the watch event, so a fact-keyed wait fires before the window opens) and what to take from it anyway — the register-then-check waiter discipline, and a best-effort retrospective oracle bounded by a TUNABLE TTL. Twelve situations, and the page is deliberate about the two it cannot fix cheaply: row 11 (a saturated queue is rejected in the WATCH path and never reaches the worker, so no worker-side wait can diagnose it) is an accepted, named gap rather than a promise, and row 10 is fixed only by a hook in the fidelity-drop branch that marks a wait terminally UNSATISFIABLE — satisfying before that gate would arm a finalize for a write that never commits. Carries a **preconditions chapter** for saves on today's build (audit policy first, then delay sizing, fidelity warm-up, save-button debounce, and assert on the commit rather than on `Ready`). `CommitterActivity` is deferred, not recommended: by its own arrival-not-existence rule it cannot satisfy the already-arrived case. Open: situation 12 (one actor, two saves, one window — blocks phase 2), whether the fact-index lookup earns its coupling, and how an unsatisfied wait is graded | diff --git a/docs/TODO.md b/docs/TODO.md index 75dcd1a93..439ed8582 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -94,7 +94,7 @@ This file is meant to track the smaller current backlog, not historical notes. - [ ] Reduce duplication between `WatchRule` and `ClusterWatchRule` code paths where it makes sense. - [ ] Revisit `StreamsStatus.PendingSample`, whose five-entry cap stops being - representative now that `sourceNamespace: "*"` produces one cluster-wide cell instead of N named + representative now that `sourceNamespace: "*"` produces one cluster-wide collection instead of N named ones. This is all that survives of a "collapse the wildcard fan-out" item: the redefinition that shipped in [#330](https://github.com/ConfigButler/gitops-reverser/pull/330) deleted the fan-out rather than optimizing it, so the optimization it described is no longer a thing to build. @@ -107,8 +107,8 @@ This file is meant to track the smaller current backlog, not historical notes. of the only signal that can separate a type genuinely withdrawn (a settled `TypeRemoved`, past `RemovalGrace`) from a discovery wobble, and mistaking the second for the first deletes a user's manifests. Its intended consumer is the `stop` classification in - [target-watch-plan.md](design/target-watch-plan.md), "What a cell leaving means": a settled - removal drops its cell from the plan and the Git-side sweep converges the mirror under the + [target-watch-plan.md](design/target-watch-plan.md), "What a collection leaving means": a + settled removal drops its collection from the plan and the Git-side sweep converges the mirror under the target's existing `spec.prune.mode`. Nothing has to be decided first — only removal on *intent* waits on an open question. Tracked here so an unconsumed producer with a good comment on it does not quietly rot into dead code. @@ -139,7 +139,7 @@ This file is meant to track the smaller current backlog, not historical notes. [watch-plane-status-convergence-failures.md](design/watch-plane-status-convergence-failures.md) §3. - **The question to answer:** widening `prune.mode` sets `force`, which classifies every cell as + **The question to answer:** widening `prune.mode` sets `force`, which classifies every collection as `restart` and issues fresh revisions, while the 30s periodic sweep also re-runs `retainTargetRetentionScopes` for every declared target. Can a pass landing *while a replay is in flight* install a revision that differs from the one the in-flight replay captured at start? diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 207479433..911053218 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -7,6 +7,75 @@ guidance that the changelog's breaking-change entries link to. We are pre-1.0, so breaking changes bump the **minor** version (release-please is configured with `bump-minor-pre-major`) rather than the major. Read the relevant entry before upgrading across it. +## Rules select whole resource collections: `rules[].operations` is gone + +> [!WARNING] +> **Breaking, in place, in `v1alpha3`.** `WatchRule` and `ClusterWatchRule` no longer have +> `spec.rules[].operations`. A rule that listed a subset such as `[CREATE]` now also mirrors updates +> and removals. If Git documents must survive removals from the cluster, set the target's +> `spec.prune.mode` to `Never` **before** upgrading. + +A rule selects resource collections, and every selected collection is observed through its whole +lifecycle: creates, updates, and deletions. What an observed removal does to Git is the +`GitTarget`'s [deletion policy](configuration.md#deletion-policy-specprunemode), and nothing else: + +| `spec.prune.mode` | Observed removal | Absent from a complete snapshot | +|---|---|---| +| `Never` | Git document kept | Git document kept | +| `OnEvent` (default) | Git document removed | Git document kept | +| `Always` | Git document removed | Git document removed | + +The filter never described the object set consistently. It was applied only to live events after +the API server delivered them, so a create-only rule still wrote every existing object during +initialization, and excluding `DELETE` never stopped `Always` from sweeping an absent document. + +### Before you upgrade + +Find the rules that set the field: + +```bash +kubectl get watchrules,clusterwatchrules -A -o json \ + | jq -r '.items[] | select(any(.spec.rules[]; has("operations"))) + | "\(.kind)\t\(.metadata.namespace // "-")\t\(.metadata.name)"' +``` + +For each one that relied on dropping `DELETE`, set `spec.prune.mode: Never` on its `GitTarget`. +The policy covers the whole target; a target fed by several rules cannot keep one rule's removals +and apply another's. There is no replacement for a create-only or update-only mirror. + +### After the upgrade + +Remove `operations` from your manifests and Helm values. A leftover field is not deletion +protection, and whether it fails loudly depends on how it is applied: + +- A request with [field validation](https://kubernetes.io/docs/reference/using-api/api-concepts/#field-validation) + `Strict` (the `kubectl` default) is **refused** with `unknown field "spec.rules[0].operations"`. +- `Warn` and `Ignore` **accept** the request and + [prune](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#field-pruning) + the field, so the rule observes every event. +- The chart's `quickstart.watchRule.rules[]` refuses the key in `values.schema.json`, which fails + `helm install`, `upgrade`, and `template`. + +Each running watch starts with a complete snapshot, so current objects converge after the upgrade. +A snapshot cannot recover a deletion the old filter discarded: under `OnEvent` a document for an +object deleted while `DELETE` was filtered stays in Git, and `Always` removes it on its next sweep. + +### Metrics and logs + +`gitopsreverser_watch_events_total` no longer has the `operation_filtered` outcome. The remaining +outcomes and their meanings are unchanged. + +Log fields that named the watched object set use the Kubernetes term: + +| Old key | New key | +|---|---| +| `cell` | `collection` | +| `sourceCell` | `sourceCollection` | +| `keepCells`, `startCells`, `restartCells`, `stopCells` | `keepCollections`, `startCollections`, `restartCollections`, `stopCollections` | + +Plan descriptions in those logs drop the operation suffix: a collection renders as +`configmaps in team-a@v1` rather than `configmaps in team-a=[*]@v1`. + ## One name per concept: reasons, columns, status fields and one spec block are renamed > [!WARNING] @@ -554,7 +623,7 @@ is unaffected. That makes `LabelValue` empty for any commit containing a `DELETE `reconcileTemplate` gains no fields, but its **default** now names the namespace of a namespace-scoped reconcile: `chore: reconcile 4 configmaps in team-a (last resourceVersion: 1331)`. -A reconcile runs per (type, namespace) cell, so that run covered exactly one namespace, and without +A reconcile runs per (type, namespace) resource collection, so that run covered exactly one namespace, and without the name a target watching one type in several namespaces wrote identical subjects for each. A whole-target or all-namespaces reconcile is unchanged, because an empty `Namespace` there means the run was not namespace-scoped at all — a fact no single word states truthfully, so the subject @@ -654,7 +723,7 @@ this is an observability change, and no mirror behaves differently because of it | `branch_worker_queue_depth` | `git_queue_depth` | same labels, and read at scrape time rather than published by the worker loop, so it no longer reports 0 while a stalled loop holds work | | `objects_written_total`, `resync_sweep_deletes_total`, `prune_retained_documents_total` | `git_documents_total{outcome}` | one counter over one population. `outcome` is `written` / `deleted_live` / `deleted_sweep` / `unchanged` / `retained`. The old counters measured input EVENTS in a flush, not documents, so the numbers change as well as the names | | `resync_background_failures_total` | `git_resync_failures_total` | same labels | -| `target_reconcile_completed_total{trigger}` | `watch_recovery_total{mode}` | `mode` is `cursor_resume` / `type_reconcile` / `replay` / `list_fallback`. The old `trigger` label documented a value (`rule_change`) the code never emitted. It carries `group` and `resource` but no `version`: a recovery covers a cell, which has no served version | +| `target_reconcile_completed_total{trigger}` | `watch_recovery_total{mode}` | `mode` is `cursor_resume` / `type_reconcile` / `replay` / `list_fallback`. The old `trigger` label documented a value (`rule_change`) the code never emitted. It carries `group` and `resource` but no `version`: a recovery covers a resource collection, which has no served version | | `watched_types` | `watch_types{state}` | `state` is `streaming` / `replaying` / `blocked`; `sum by (gittarget_name)` is the old value, and `state="blocked"` is the difference between resolved and running | | `watch_plan_oldest_dirty_age_seconds` | `watch_plan_oldest_dirty_since_timestamp_seconds` | a Unix timestamp, so read it as `time() - `. The age froze during exactly the stall it was there to report, because the loop that publishes it was the loop that was stuck | | `secret_encryption_{attempts,success,failures,cache_hits,marker_skips}_total` | `secret_encryptions_total{outcome}` | `outcome` is `encrypted` / `failed` / `cached`. The old `cache_hits / attempts` "cache effectiveness" ratio divided over disjoint populations and could exceed 1; use `sum by (outcome) (rate(...))` | diff --git a/docs/architecture.md b/docs/architecture.md index a4f3d2097..407a69ff3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -288,19 +288,19 @@ events to a same namespace `GitTarget`; each of its rule items names the source A `ClusterWatchRule` selects CLUSTER SCOPED resources, with an explicit namespace `gitTargetRef` and no namespace selection of its own. Both share the rule model: -- `spec.rules[]`: OR resource rules (`MinItems=1`). -- `rules[].operations`: `CREATE` / `UPDATE` / `DELETE` / `*`; omitted means all. +- `spec.rules[]`: OR resource rules (`MinItems=1`). A rule selects whole resource collections: every + selected collection is observed through creates, updates, and deletions, and the `GitTarget`'s + `spec.prune.mode` decides whether an observed removal deletes the Git document. - `rules[].apiGroups`: omitted resolves the named resource across served groups; `""` is the core group; `*` is all. - `rules[].apiVersions`: omitted means the preferred served version. - `rules[].resources`: plural resource names or `*`. - `WatchRule` adds `rules[].sourceNamespace`: omitted for the rule's own namespace, an exact name, or `*` for every namespace the source credential can read. Anything but the rule's own namespace passes - the source-namespace gate (`SourceNamespaceAuthorized`). A `*` compiles to ONE cluster-wide cell - (the empty namespace, which for a namespaced GVR is the all-namespaces collection), so it opens one - stream and one list per matched type however large the cluster is. That cell is a peer of any - named-namespace cell on the same type, never a replacement: each rule keeps its own operations - filter. + the source-namespace gate (`SourceNamespaceAuthorized`). A `*` compiles to ONE cluster-wide + collection (the empty namespace, which for a namespaced GVR is the all-namespaces collection), so it + opens one stream and one list per matched type however large the cluster is. That collection is a + peer of any named-namespace collection on the same type, never a replacement. - `ClusterWatchRule` has no scope or namespace choice of its own: the kind is the choice. Subresources are rejected in rule resources. Mirroring operates on top level resources; the selected @@ -1029,14 +1029,13 @@ intersection gets a watch. - **Source**: [internal/watch/watched_type_table.go](../internal/watch/watched_type_table.go) A projection for each `GitTarget` from the type registry, filtered by that target's rules, recording -resolved GVK/GVR/scope plus namespace and operation coverage. **This is where rule matching effectively +resolved GVK/GVR/scope plus namespace coverage. **This is where rule matching effectively happens:** it resolves the set of `(GVR, scope)` a `GitTarget` claims, so the watch manager opens one watch per claimed ∩ followable `(GVR, scope)` and scopes each watch's events back to that GitTarget's -source namespaces. A `sourceNamespace: "*"` rule resolves here to ONE cluster-wide cell (the empty -namespace, which for a namespaced GVR is the all-namespaces collection), so it has a single stream -and a single mark-and-sweep boundary however many namespaces it covers. That cell is a peer of any -named-namespace cell on the same type, never a replacement for one: each rule keeps its own -operation filter. +source namespaces. A `sourceNamespace: "*"` rule resolves here to ONE cluster-wide collection (the +empty namespace, which for a namespaced GVR is the all-namespaces collection), so it has a single +stream and a single mark-and-sweep boundary however many namespaces it covers. That collection is a +peer of any named-namespace collection on the same type, never a replacement for one. *** @@ -1069,11 +1068,12 @@ selection, and builds the collection watch plan. On each GitTarget reconcile, rule resolution combines selection with discovery and mirroring eligibility. `targetWatchStreams` groups selected served versions by group/resource/namespace, -chooses one version per collection, and unions the current operation filters. The plan diff -keeps unaffected watches running and starts or replaces only changed entries. +and chooses one version per collection. The plan diff keeps unaffected watches running, starts +new collections, and replaces a collection only when its served version changes. Each managed watch has one goroutine. Its first attempt requests initial state, enqueues scoped -snapshot reconciliation, and then routes live events through filtering and author resolution. +snapshot reconciliation, and then routes every live object event through unchanged-content +suppression and author resolution. Later attempts may resume a cursor; see [Recovery: resume, replay, or list plus mark-and-sweep](#recovery-resume-replay-or-list-plus-mark-and-sweep). @@ -1472,7 +1472,7 @@ as one funnel and its loss paths as one selector: - **Ingest.** `gitopsreverser_watch_events_total{gittarget_*,group,version,resource,outcome}` counts every delivered watch event exactly once, at `routeLiveTargetWatchEvent`, the single switch carrying every terminal branch. `outcome` separates the pipeline working (`routed`, `unchanged`, - `operation_filtered`, `bookmark`) from loss (`route_failed`). Beside it, + `bookmark`) from loss (`route_failed`). Beside it, `_watch_event_handling_seconds` (stream occupancy, the head-of-line signal), `_watch_sessions_ended_total{reason}` and `_watch_replay_duration_seconds` (`410` pressure and what a rebuild costs), `_watch_recovery_total{mode}`, and `_watch_types{state}`. diff --git a/docs/commit-messages.md b/docs/commit-messages.md index f4d2c4f54..1164a4081 100644 --- a/docs/commit-messages.md +++ b/docs/commit-messages.md @@ -233,7 +233,7 @@ and can be absent, including a pure sweep. Guard optional values as in the examp `{{.Revision}}` is rejected, and the `GitTarget` says so on its `Validated` condition. See [the upgrade note](UPGRADING.md#reconciletemplates-revision-is-now-resourceversion). -A reconcile runs per *cell* (a (type, namespace) pair) rather than per target, so a +A reconcile runs per *resource collection* (a (type, namespace) pair) rather than per target, so a namespace-scoped run covers exactly one namespace and the default subject names it: ```text diff --git a/docs/components.md b/docs/components.md index e2433dfe0..756d30f51 100644 --- a/docs/components.md +++ b/docs/components.md @@ -39,7 +39,7 @@ flowchart TB subgraph DP["Data plane"] WM["watch.Manager
runnable, leader-elected"] - TW["target watches
one per (GitTarget, cell)"] + TW["target watches
one per (GitTarget, collection)"] ER["EventRouter"] GTES["reconcile.GitTargetEventStream"] SAN["internal/sanitize"] @@ -93,14 +93,14 @@ this GitTarget claim". Every source cluster gets its own instance of the whole s | `APIResourceCatalog` | [`internal/watch/api_resource_catalog.go`](../internal/watch/api_resource_catalog.go) | turns one discovery result into a policy-annotated `typeset.Scan`. Holds no judgment | | Followability registry | [`internal/typeset/registry.go`](../internal/typeset/registry.go) | applies "additions fast, removals slow": retain-on-error, and a removal grace before a type is called withdrawn | | Relevance funnel | [`internal/typeset/funnel.go`](../internal/typeset/funnel.go) | the pure function that judges one type followable, and names the single reason when it is not | -| `WatchedTypeTable` | [`internal/watch/watched_type_table.go`](../internal/watch/watched_type_table.go) | the per-GitTarget resident set of claimed and followable `(GVR, scope)` with its operation filter, which `targetWatchStreams` collapses to one stream per cell | +| `WatchedTypeTable` | [`internal/watch/watched_type_table.go`](../internal/watch/watched_type_table.go) | the per-GitTarget resident set of claimed and followable `(GVR, scope)` with its namespace scopes, which `targetWatchStreams` collapses to one stream per resource collection | | `clusterContext` | [`internal/watch/cluster_context.go`](../internal/watch/cluster_context.go) | one per distinct cluster: catalog, registry, dynamic client, discovery client, reachability | | Type lifecycle | [`internal/typeset/lifecycle.go`](../internal/typeset/lifecycle.go) | names each verdict transition (`TypeActivated`, `TypeWobbling`, `TypeRecovered`, `TypeRemoved`, `TypeRefused`) so a consumer reacts to an edge instead of diffing tables. `Registry.Subscribe` has no observer yet; it is a future input to the watch plan | ### Data plane -State ingestion. One raw watch per `(GitTarget, cell)`, where a cell is group, resource and -namespace and the served version is carried as data rather than identity. Each is sanitized and +State ingestion. One raw watch per `(GitTarget, resource collection)`, where a collection is group, +resource and namespace and the served version is carried as data rather than identity. Each is sanitized and routed to a branch worker. | Component | Path | Role | @@ -214,9 +214,9 @@ watched-type table: They agree today only because each re-derives the same answer from the same inputs, and the runner then cancels and rebuilds every stream whenever any part of the set changes. That is the problem -[`design/target-watch-plan.md`](design/target-watch-plan.md) sets out to fix: diff the plan by cell, -and start, restart or cancel only the cells that changed. The branch worker's queue is untouched by -that plan, so a canceled cell's already-queued work still runs. +[`design/target-watch-plan.md`](design/target-watch-plan.md) sets out to fix: diff the plan by +collection, and start, restart or cancel only the collections that changed. The branch worker's queue +is untouched by that plan, so a canceled collection's already-queued work still runs. ## From a served type to an open stream @@ -241,7 +241,7 @@ sequenceDiagram R->>R: additions fast, removals slow R->>W: followable set Note over W: intersect with compiled rules
and the admitted namespaces - W->>S: claimed ∩ followable, one stream per cell + W->>S: claimed ∩ followable, one stream per collection S->>K: WATCH sendInitialEvents=true K-->>S: ADDED replay, then initial-events-end S->>G: desired set + mark-and-sweep @@ -266,7 +266,7 @@ Three gates can stop a type before it reaches a stream: - [`architecture.md`](architecture.md) for how the pieces work together, starting at Ground Rules and Mental Model. - [`design/watch-and-catalog-architecture.md`](design/watch-and-catalog-architecture.md) for the - target three-layer watch model: cells, the confidence model, and the managed projection. + target three-layer watch model: collections, the confidence model, and the managed projection. - [`design/target-watch-plan.md`](design/target-watch-plan.md) for the implementable plan that replaces wholesale stream replacement with an incremental diff. - [`design/data-plane-triggering.md`](design/data-plane-triggering.md) for why that refactor is diff --git a/docs/configuration.md b/docs/configuration.md index e3aa4a82e..af715aba9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -100,7 +100,6 @@ The destination fields are immutable: to move a target, delete it and create a n | `rules[].resources` | **required** | Plural resource names to watch | | `rules[].apiGroups` | resolved from the resource name | API groups to match. `[""]` is the core group. Omitted resolves the named resource across the served surface, and selects nothing when more than one group serves that name | | `rules[].apiVersions` | the preferred served version | API versions to match. `["*"]` watches every served version | -| `rules[].operations` | `CREATE`, `UPDATE`, `DELETE` | Which operations produce a write | | `rules[].sourceNamespace` | the rule's own namespace | Namespace to watch in the source cluster, or `*`. See [watching a different source namespace](#watching-a-different-source-namespace) | ### `ClusterWatchRule` (cluster-scoped): what to capture, cluster-wide @@ -113,7 +112,6 @@ The same shape as a `WatchRule`, for cluster-scoped types. It selects no namespa | `rules[].resources` | **required** | Plural cluster-scoped resource names to watch | | `rules[].apiGroups` | resolved from the resource name | API groups to match. Omitted resolves the named resource across the served surface, and selects nothing when more than one group serves that name | | `rules[].apiVersions` | the preferred served version | API versions to match. `["*"]` watches every served version | -| `rules[].operations` | `CREATE`, `UPDATE`, `DELETE` | Which operations produce a write | ### `CommitRequest` (namespaced): save now @@ -1675,9 +1673,9 @@ namespace. Connection count is bounded per matched type, but object count, traff still grow with the cluster. Access is bounded by the source credential's RBAC and by nothing else, which is why it is refused outright while `allowAnySourceNamespace` is false. -A `"*"` item and a named-namespace item for the same type are **peers**, not duplicates: each rule -carries its own `operations` filter, so a target holding both runs two streams over overlapping -objects. That is correct rather than something to tune away. +A `"*"` item and a named-namespace item for the same type are **peers**, not duplicates: each is its +own resource collection, so a target holding both runs two streams over overlapping objects. That is +correct rather than something to tune away. The outcome for all items is aggregated into one `SourceNamespaceAuthorized` condition, also shown by `kubectl get watchrules -o wide`. A **denied** explicit name refuses the whole `WatchRule` @@ -1694,10 +1692,11 @@ A namespace allow-list cannot partition **cluster-scoped** objects, which have n see another tenant's cluster-scoped objects, give each tenant its own `ClusterProvider` and credential, so that credential's RBAC is the boundary. -Each entry in `spec.rules` is a logical OR. A resource matching any rule is watched. The rule fields -are: +Each entry in `spec.rules` is a logical OR. A resource matching any rule is watched. A rule selects +whole resource collections: every selected collection is observed through creates, updates, and +deletions, and the `GitTarget`'s [deletion policy](#deletion-policy-specprunemode) decides whether an +observed removal deletes the Git document. There is no per-rule operation filter. The rule fields are: -- `operations`: `CREATE`, `UPDATE`, `DELETE`, or `*`; omitted means all operations. - `apiGroups`: `""` for the core group, `*` for all groups, or omitted to resolve the named resource across the served API surface. A name served by more than one group is ambiguous when the group is omitted, and is watched in none of them: name the group. @@ -1719,8 +1718,7 @@ spec: gitTargetRef: name: example-target rules: - - operations: [CREATE, UPDATE, DELETE] - apiGroups: [""] + - apiGroups: [""] apiVersions: ["v1"] resources: ["configmaps", "secrets"] ``` @@ -1750,8 +1748,7 @@ spec: name: example-target namespace: default rules: - - operations: [CREATE, UPDATE, DELETE] - apiGroups: ["rbac.authorization.k8s.io"] + - apiGroups: ["rbac.authorization.k8s.io"] apiVersions: ["v1"] resources: ["clusterroles", "clusterrolebindings"] ``` diff --git a/docs/definitions.md b/docs/definitions.md index e1aee2544..c1e75f6d9 100644 --- a/docs/definitions.md +++ b/docs/definitions.md @@ -68,19 +68,17 @@ A sentence about namespace policy names one of those two. Never "namespace acces ## Read side Use Kubernetes' collection, watch, and initial-events vocabulary in explanatory prose; see -[Kubernetes watch options](facts/kubernetes-watch-options.md). Existing identifiers such as -`CellKey`, `SourceCell`, `Replaying`, and `status.streams` keep their current spelling until an -explicit code or API migration. This is an intentional transition, not a runtime behavior change. -The [first implementation step](design/collection-terminology-rename.md) removes the cell vocabulary -and per-rule operation filters; [label selection](design/watches-labels-simplification.md) follows -as a separate behavior change. - -**Resource collection.** Objects of one group/resource within a namespace selection. In the -current planner, [`CellKey`](../internal/types/cell.go) contains `(group, resource, namespace)`; -its containing `GitTarget` supplies target and source-cluster context. The served API version is -separate data. An all-namespace collection can overlap a named-namespace collection. `Cell` is -the legacy project name for this boundary. A collection is distinct from both its resource type -and that type's namespaced or cluster-scoped classification. +[Kubernetes watch options](facts/kubernetes-watch-options.md). Status identifiers such as +`Replaying` and `status.streams` keep their spelling; their public contract is unchanged. + +**Resource collection.** Objects of one group/resource within a namespace selection. +[`CollectionKey`](../internal/types/collection.go) contains `(group, resource, namespace)`; its +containing `GitTarget` supplies target and source-cluster context. The served API version is +separate data. An all-namespace collection can overlap a named-namespace collection. A collection +is distinct from both its resource type and that type's namespaced or cluster-scoped +classification. A rule selects whole collections: every selected collection is observed through +creates, updates, and deletions, and `GitTarget.spec.prune.mode` decides what a removal does to +Git. **Watch.** The managed observation of a resource collection through Kubernetes watch requests. It can initialize, stream events, lose a connection, and reconnect. The implementation currently diff --git a/docs/demo/commit-window.md b/docs/demo/commit-window.md index bcc5dcece..44a34c68d 100644 --- a/docs/demo/commit-window.md +++ b/docs/demo/commit-window.md @@ -57,7 +57,8 @@ spec: resources: [configmaps, serviceaccounts] ``` -Omitting `operations` selects `CREATE`, `UPDATE`, and `DELETE` by default. +The rule mirrors each selected collection's creates, updates, and deletions; the target's +`spec.prune.mode` decides whether a deletion removes the Git document. Two fields exist for the sake of a legible demo rather than because the window needs them: diff --git a/docs/design/collection-terminology-rename.md b/docs/design/collection-terminology-rename.md index 7742b2371..de384a9cf 100644 --- a/docs/design/collection-terminology-rename.md +++ b/docs/design/collection-terminology-rename.md @@ -1,7 +1,8 @@ # Replace cells with collections and remove event filters -> **Implementation plan; not built. Step 1 of 2.** Complete this simplification before -> [label selection and pruning](watches-labels-simplification.md), which is step 2. +> **Implemented; step 1 of 2.** This is now a historical record of the migration; the code and +> the current documents are the reference. [Label selection and pruning](watches-labels-simplification.md) +> is step 2. > Index: [`../INDEX.md`](../INDEX.md). Audited: 2026-09-28, source at `69836e70`. > Rename counts describe tracked working-tree files before adding this plan and its cross-references; > the existing documentation edits are included. The operation-filter audit is separate, against @@ -237,10 +238,10 @@ Rename their actual declarations and callers together; avoid substring replaceme | Existing file | New path | |---|---| -| [`internal/types/cell.go`](../../internal/types/cell.go) | `internal/types/collection.go` | -| [`internal/types/cell_test.go`](../../internal/types/cell_test.go) | `internal/types/collection_test.go` | -| [`internal/git/source_cell.go`](../../internal/git/source_cell.go) | `internal/git/source_collection.go` | -| [`internal/git/source_cell_test.go`](../../internal/git/source_cell_test.go) | `internal/git/source_collection_test.go` | +| `internal/types/cell.go` | [`internal/types/collection.go`](../../internal/types/collection.go) | +| `internal/types/cell_test.go` | [`internal/types/collection_test.go`](../../internal/types/collection_test.go) | +| `internal/git/source_cell.go` | [`internal/git/source_collection.go`](../../internal/git/source_collection.go) | +| `internal/git/source_cell_test.go` | [`internal/git/source_collection_test.go`](../../internal/git/source_collection_test.go) | Keep the source-provenance rationale with its renamed helper. Update Markdown links and plain path references in Go comments, including `watchrule_types.go`, `watched_type_resolver.go`, and @@ -503,14 +504,14 @@ identifier occurrences; `comments` and `strings` count matched words/components | [`internal/git/resync_flush.go`](../../internal/git/resync_flush.go) | 2 | 3 | 0 | 5 | | [`internal/git/resync_push_test.go`](../../internal/git/resync_push_test.go) | 8 | 0 | 1 | 9 | | [`internal/git/resync_scope_test.go`](../../internal/git/resync_scope_test.go) | 2 | 1 | 1 | 4 | -| [`internal/git/source_cell.go`](../../internal/git/source_cell.go) | 13 | 17 | 0 | 30 | -| [`internal/git/source_cell_test.go`](../../internal/git/source_cell_test.go) | 19 | 0 | 4 | 23 | +| `internal/git/source_cell.go` | 13 | 17 | 0 | 30 | +| `internal/git/source_cell_test.go` | 19 | 0 | 4 | 23 | | [`internal/git/types.go`](../../internal/git/types.go) | 17 | 19 | 0 | 36 | | [`internal/git/write_boundary_precondition_test.go`](../../internal/git/write_boundary_precondition_test.go) | 1 | 0 | 0 | 1 | | [`internal/rulestore/store.go`](../../internal/rulestore/store.go) | 0 | 1 | 0 | 1 | | [`internal/telemetry/exporter.go`](../../internal/telemetry/exporter.go) | 0 | 1 | 0 | 1 | -| [`internal/types/cell.go`](../../internal/types/cell.go) | 6 | 11 | 0 | 17 | -| [`internal/types/cell_test.go`](../../internal/types/cell_test.go) | 10 | 3 | 2 | 15 | +| `internal/types/cell.go` | 6 | 11 | 0 | 17 | +| `internal/types/cell_test.go` | 10 | 3 | 2 | 15 | | [`internal/typeset/lifecycle.go`](../../internal/typeset/lifecycle.go) | 0 | 2 | 0 | 2 | | [`internal/watch/config_plane_split_review_fixes_test.go`](../../internal/watch/config_plane_split_review_fixes_test.go) | 1 | 0 | 0 | 1 | | [`internal/watch/event_router.go`](../../internal/watch/event_router.go) | 22 | 0 | 6 | 28 | diff --git a/docs/design/data-plane-triggering.md b/docs/design/data-plane-triggering.md index 6762bcfa3..ea6a5bb4c 100644 --- a/docs/design/data-plane-triggering.md +++ b/docs/design/data-plane-triggering.md @@ -10,7 +10,7 @@ **The short version.** Adding a single WatchRule tears down and replays every watch stream a GitTarget has. Under load that floods a queue which is shared with -other tenants. The fix is to reconcile only the cells that changed, and it lives +other tenants. The fix is to reconcile only the collections that changed, and it lives in [TargetWatchPlan](target-watch-plan.md). This page is the background: what went wrong, how the pipeline works, and the one ordering rule any fix has to respect. @@ -83,7 +83,7 @@ flowchart TB API -->|"audit webhook"| AUD["audit ingest"] AUD --> FS["per-type audit fact stream
(Redis)"] - API -->|"watch"| TW["target watch
one goroutine per
(GitTarget, cell)"] + API -->|"watch"| TW["target watch
one goroutine per
(GitTarget, collection)"] TW --> SAN["sanitize + followability
+ no-op suppression"] SAN --> GR["attribution grace window
(head-of-line, per event)"] @@ -120,10 +120,10 @@ resync". This is the finding that reframed everything else, and it is now built: [target-watch-plan.md](target-watch-plan.md) carries the design and the state. -`targetWatchSpecs(table)` already computed the desired stream set as a map from -one cell to that stream's operation filter, and what was missing was a -**per-cell** diff. The comparison was whole-set equality, so a declaration was -either untouched or replaced wholesale: +`targetWatchSpecs(table)` already computed the desired stream set as a map keyed +by collection, and what was missing was a **per-collection** diff. The comparison +was whole-set equality, so a declaration was either untouched or replaced +wholesale: ```mermaid flowchart TB @@ -150,14 +150,14 @@ change instead of by deletion. The cause is structural: the render-fidelity **epoch is per-target**. One epoch covers every scope, so a scope that resumed from its cursor instead of replaying -would stay pending in the new epoch forever. Making readiness per cell is what -unlocks the diff. +would stay pending in the new epoch forever. Making readiness per collection is +what unlocks the diff. One subtlety worth carrying forward: when a rule **moves**, both scopes are invalidated. Editing a rule's `sourceNamespace` from `team-b` to `team-c` -produces a `stop` for the old cell and a `start` for the new one. What happens to -the departing cell's documents is decided by the cause table in -[TargetWatchPlan](target-watch-plan.md), "What a cell leaving means". +produces a `stop` for the old collection and a `start` for the new one. What +happens to the departing collection's documents is decided by the cause table in +[TargetWatchPlan](target-watch-plan.md), "What a collection leaving means". ## 3. The queue is doing two unrelated jobs @@ -233,22 +233,22 @@ proposal to replace the resync payload with a bare dirty set was dropped. ## 5. Where we are The plan lives in [TargetWatchPlan](target-watch-plan.md), which is the single -place to read it from. In outline: diff the plan by cell and log it, then apply -the diff so unrelated replays stop. The semantics of removal come after, +place to read it from. In outline: diff the plan by collection and log it, then +apply the diff so unrelated replays stop. The semantics of removal come after, deliberately. That plan applies its decisions by starting and canceling streams, and nothing filters the queue. A branch worker serves one GitProvider and branch rather than one GitTarget, so its queue is shared across tenants and must not hold per-tenant -configuration. A short tail of writes from a cell that has been deselected is -therefore accepted: it is bounded by the queue, and the files it touches are +configuration. A short tail of writes from a collection that has been deselected +is therefore accepted: it is bounded by the queue, and the files it touches are retained anyway. Shipped so far: the storm source is fixed and resyncs coalesce ([#312](https://github.com/ConfigButler/gitops-reverser/pull/312)); the ordering -hazard that coalescing introduced is fenced; and cell identity is settled, with -one stream per cell and the producing cell stamped on queued work. The diff -itself is unbuilt. +hazard that coalescing introduced is fenced; and collection identity is settled, +with one stream per collection and the producing collection stamped on queued +work. The diff itself is unbuilt. The writes stay a log throughout. Only the resync half of the queue is in question, and the answer is to make the stream set incremental rather than to @@ -270,7 +270,7 @@ has the worked examples. **Scopes and streams.** A sweep is bounded by the exact slice its snapshot was gathered over, and a cluster-wide scope is a **peer** of a named namespace rather than a replacement for it: collapsing the two widened the named rule's stream to -every namespace its credential could read and discarded its operation filter. +every namespace its credential could read. **Deletion and retention.** `spec.prune.mode` ([`../configuration.md`](../configuration.md), deletion policy) already draws the @@ -284,6 +284,6 @@ gives `RemovalGrace`, which the plan consumes rather than re-decides. that grace lives, [Type followability](../spec/type-followability.md) defines what may be mirrored at all, and [the GVK/GVR mapping layer](../spec/gvk-gvr-mapping-layer.md) is the identity -resolution the plan's cell turns on. +resolution the plan's collection turns on. [Unsupported folder refusal](../spec/unsupported-folder-refusal-plan.md) decides what a folder may contain before any of this runs. diff --git a/docs/design/gittarget-configuration-freshness.md b/docs/design/gittarget-configuration-freshness.md index 1fa594b6e..ec61ff9a5 100644 --- a/docs/design/gittarget-configuration-freshness.md +++ b/docs/design/gittarget-configuration-freshness.md @@ -24,7 +24,7 @@ GitTarget object. It does not move when a WatchRule or ClusterWatchRule changes. `StreamsRunning=True` answers whether every stream in the current applied plan is streaming. A target can therefore report healthy streams for its old plan while a newly added rule is still -waiting for the owner loop to apply its new cell. +waiting for the owner loop to apply its new collection. The target-level question is: diff --git a/docs/design/metrics-observability-plan.md b/docs/design/metrics-observability-plan.md index 63b9ee692..91d71599b 100644 --- a/docs/design/metrics-observability-plan.md +++ b/docs/design/metrics-observability-plan.md @@ -211,7 +211,7 @@ during an incident and cheap enough not to cause one; the last two are about whe | Class | Means | Belongs on the exceptions panel? | |---|---|---| - | **expected** | the pipeline working: `routed`, `unchanged`, `operation_filtered`, `cached`, `retained`, `deleted_*` | no | + | **expected** | the pipeline working: `routed`, `unchanged`, `cached`, `retained`, `deleted_*` | no | | **degraded** | it worked, on weaker evidence or in a fallback shape: `name` and `deletecollection_scope` tiers, `list_fallback` recovery, `unresolved` authorship | no, but a trend panel | | **recoverable** | it failed and will be retried: a failed push holding its writes, a resync that will re-run | only as a rate, never as a loss total | | **loss** | an observed change that did not reach Git and nothing will retry: `route_failed`, a queue drop, a placement refusal, a trimmed fact | yes | @@ -263,7 +263,7 @@ flowchart TB subgraph WATCHPATH["Object state: internal/watch"] W["watch session"] -->|"watch_events_total"| R["routeLiveTargetWatchEvent"] WS["session ends"] -.->|"watch_sessions_ended_total{reason}
watch_replay_duration_seconds"| W - R -->|"outcome=unchanged / operation_filtered / not_object"| DROPF(["filtered: expected"]) + R -->|"outcome=unchanged / not_object"| DROPF(["filtered: expected"]) R -->|"outcome=route_failed"| DROPR(["LOSS: until the next resync"]) end subgraph AUDITPATH["Authorship (optional): internal/webhook + internal/queue"] @@ -295,7 +295,7 @@ drawn apart, and why an audit outage is never a mirror outage. | Stage | Counter | Expected values | Loss | |---|---|---|---| -| 1 Ingest | `watch_events_total` | `routed`, `unchanged`, `operation_filtered`, `bookmark`, `shutdown` | `route_failed` | +| 1 Ingest | `watch_events_total` | `routed`, `unchanged`, `bookmark`, `shutdown` | `route_failed` | | 2 Queue | `git_queue_drops_total` | (no increment) | every increment | | 3 Write | `git_documents_total` | `written`, `deleted_live`, `deleted_sweep`, `unchanged`, `retained` | `placement_refusals_total`, beside it | | 4 Commit | `git_commits_total` | all: recorded on a successful push, so it cannot claim a commit the remote never took |: | diff --git a/docs/design/source-scope-simplification.md b/docs/design/source-scope-simplification.md index a0e4acda0..3041f0ebb 100644 --- a/docs/design/source-scope-simplification.md +++ b/docs/design/source-scope-simplification.md @@ -148,28 +148,28 @@ source cluster, which is the cost this whole exercise is trying to remove. Three | **Keep enumeration** | as today, against some other policy | keeps the cross-cluster `Namespace` read, which was the point of the deletion | **Decided: redefine as cluster-wide**, rejected outright while `allowAnySourceNamespace` is false. -It applies to **both** halves of a cell's traffic (the initial list that warms the cell, and the -watch that follows it), because they are the same collection read two ways, and splitting them -would mean enumerating namespaces for the replay after all, which is the read this exercise -deletes. +It applies to **both** halves of a collection's traffic (the initial list that warms the +collection, and the watch that follows it), because they are the same collection read two ways, +and splitting them would mean enumerating namespaces for the replay after all, which is the read +this exercise deletes. The plumbing already exists, which makes this the cheapest of the three as well as the clearest. -`CellKey.Namespace` is already documented as "empty is a genuinely cluster-wide (all-namespaces) -cell" ([`cell.go`](../../internal/types/cell.go)), and both +`CollectionKey.Namespace` is already documented as "empty is a genuinely cluster-wide +(all-namespaces) collection" ([`collection.go`](../../internal/types/collection.go)), and both [`openTargetWatch`](../../internal/watch/target_watch.go) and `openTargetList` already branch on it: a non-empty namespace calls `resource.Namespace(ns)`, an empty one calls `resource.Watch` directly, which for a namespaced GVR is the all-namespaces collection. Readiness, retention -rollup, and event routing all key on `CellKey` already, and records carry the object's own -`u.GetNamespace()` rather than the cell's, so placement is unaffected. What changes is only the -planner: `*` compiles to one cell instead of calling `EnumerateSourceNamespaces` for N. That is a -deletion in `watchrule_compile.go`, not new machinery. - -One trap, and the code already records it. A cluster-wide cell is a **peer** of a named-namespace -cell on the same type, never a replacement, because each rule carries its own `operations` filter. -`CellKey`'s doc comment names the bug from a previous attempt: collapsing the two "widened the named -rule's stream to every namespace its credential could read and discarded its operation filter". So -a target carrying both `*` and a named rule for one type runs two streams over overlapping objects, -and that is correct rather than something to optimize away. +rollup, and event routing all key on `CollectionKey` already, and records carry the object's own +`u.GetNamespace()` rather than the collection's, so placement is unaffected. What changes is only +the planner: `*` compiles to one collection instead of calling `EnumerateSourceNamespaces` for N. +That is a deletion in `watchrule_compile.go`, not new machinery. + +One trap, and the code already records it. A cluster-wide collection is a **peer** of a +named-namespace collection on the same type, never a replacement. `CollectionKey`'s doc comment +names the bug from a previous attempt: collapsing the two "widened the named rule's stream to every +namespace its credential could read". So a target carrying both `*` and a named rule for one type +runs two streams over overlapping objects, and that is correct rather than something to optimize +away. It is what a Kubernetes reader expects `*` to mean, and its failure is a clean 403 rather than a silent empty set. @@ -220,7 +220,7 @@ authorization and would read better elsewhere. A `SelfSubjectAccessReview` pass under the provider's own credential. It needs no new grant, since every identity may issue one, and it reports two things a user cannot otherwise get: which of the -requested cells are reachable, and whether write verbs are permitted on them. +requested collections are reachable, and whether write verbs are permitted on them. Phrase the condition as **"no write permission observed for the requested resources at review time"**. It cannot prove the mirror is unable to write: a review covers the verbs and resources @@ -257,8 +257,8 @@ Then the deletion, then the `SelfSubjectAccessReview` work, which is additive an ## Open questions - ~~Which `*` option is taken.~~ **Decided: cluster-wide**, list and watch both, above. -- `CellKey.String()` renders an empty namespace as a bare type name ("configmaps"), which read as - cluster-scoped when only `ClusterWatchRule` produced those cells. Once `*` produces them for +- `CollectionKey.String()` renders an empty namespace as a bare type name ("configmaps"), which read as + cluster-scoped when only `ClusterWatchRule` produced those collections. Once `*` produces them for namespaced types it wants a distinct rendering, "configmaps in all namespaces" or similar, in logs and status messages. - Does the chart keep an `allowAnySourceNamespace` value, and does the quickstart set it true? A diff --git a/docs/design/target-watch-plan.md b/docs/design/target-watch-plan.md index 8a13acb73..fc73c2b9f 100644 --- a/docs/design/target-watch-plan.md +++ b/docs/design/target-watch-plan.md @@ -1,4 +1,4 @@ -# Target watch plan: reconcile the changed cells +# Target watch plan: reconcile the changed collections > **partly built**: the diff is built and applied. Removal semantics are not, > and the deletion the diff unlocks has not been made. @@ -10,7 +10,7 @@ **The short version.** A GitTarget's watch set is replaced wholesale today, so changing one rule replays all of them. This plan diffs the set instead and acts -only on the cells that changed. It applies that decision by starting and +only on the collections that changed. It applies that decision by starting and canceling streams, and deliberately leaves the branch worker's queue alone. New to this area? Read @@ -20,19 +20,20 @@ prompted this, then come back here. ## The problem -A **cell** is one `(group, resource, namespace)` slice of one `GitTarget`. The -served API version is data carried by the stream rather than part of the cell's -identity, because Git paths are versionless and a storage-version bump must not -move a file. +A **resource collection**, or **collection**, is one `(group, resource, +namespace)` slice of one `GitTarget`. The served API version is data carried by +the stream rather than part of the collection's identity, because Git paths are +versionless and a storage-version bump must not move a file. -The desired watch set was already a map from a cell to an operation filter, but -any difference in that map canceled every stream for the target and replayed -every cell, so adding one rule replayed unrelated resources and could fill the -branch worker's shared queue. +The desired watch set was already a map keyed by collection, but any difference +in that map canceled every stream for the target and replayed every collection, +so adding one rule replayed unrelated resources and could fill the branch +worker's shared queue. The refactor changes the unit of work from the whole target to the changed -cells. That part is built; what remains is what a cell LEAVING means, and the -mechanism the old behavior forced into existence and no longer justifies. +collections. That part is built; what remains is what a collection LEAVING +means, and the mechanism the old behavior forced into existence and no longer +justifies. ## The queue is shared, so it stays dumb @@ -52,17 +53,17 @@ little. **The plan is applied by starting and canceling streams. Nothing filters the queue.** -Once an item is on the FIFO it will be applied. A canceled stream's goroutine may -still be in flight, so a configuration change can be followed by a short tail of -writes from a cell that is no longer selected. That is accepted behavior, for -three reasons: +Once an item is on the FIFO it will be applied. A canceled stream's goroutine +may still be in flight, so a configuration change can be followed by a short +tail of writes from a collection that is no longer selected. That is accepted +behavior, for three reasons: - the tail is bounded by the queue, at most 100 items ahead plus one commit window flush, and it drains on its own; -- the content it writes is real observed state, gathered while the cell was still - selected; -- what happens to a deselected cell's **files** is a separate and deliberate - decision, made in "What a cell leaving means" below. Today the answer is +- the content it writes is real observed state, gathered while the collection + was still selected; +- what happens to a deselected collection's **files** is a separate and deliberate + decision, made in "What a collection leaving means" below. Today the answer is retention, so a late write updates a file that is being kept anyway. The only requirement this places on the design is on the producer side: @@ -76,22 +77,24 @@ All three are dropped, and this table exists so none of them grows back: | Dropped | Why it existed | Why it goes | | --- | --- | --- | -| Per-cell **lease** on every queued item | Tell a canceled stream's in-flight item from its replacement's | A late item is allowed, so there is nothing to reject. **Removed** | -| **Tombstones** for stopped cells | Reject queued work after a `stop` | Retiring one meant knowing no work carrying it could remain in flight, which the queue cannot answer | -| Target-wide **plan generation** and `BeginDelta` | Order plan transitions and reset readiness per epoch | Readiness is per cell and keeps its own revision internally | - -The **cell** stays on queued work, so a write or a drop record can name the slice -of the mirror it speaks for. That is what a saturated queue has to be diagnosed -from. Nothing is rejected on it, and nothing should be: the moment the worker -judges an item, the shared queue has learned about tenant configuration again. - -The **lease** went with the fence it was built for. It was the stream incarnation -stamped beside the cell, and its only purpose was to let a consumer tell a -canceled stream's item from its replacement's. Keeping a field against a fence -that will never be built is how a retired design grows back, so it was deleted -rather than deprecated. That left `git.Provenance` holding one field, so the type -collapsed with it: queued work now carries a `types.CellKey` named `SourceCell`, -and `sourceCellForLog` renders the zero cell as "unclaimed". +| Per-collection **lease** on every queued item | Tell a canceled stream's in-flight item from its replacement's | A late item is allowed, so there is nothing to reject. **Removed** | +| **Tombstones** for stopped collections | Reject queued work after a `stop` | Retiring one meant knowing no work carrying it could remain in flight, which the queue cannot answer | +| Target-wide **plan generation** and `BeginDelta` | Order plan transitions and reset readiness per epoch | Readiness is per collection and keeps its own revision internally | + +The **collection** stays on queued work, so a write or a drop record can name +the slice of the mirror it speaks for. That is what a saturated queue has to be +diagnosed from. Nothing is rejected on it, and nothing should be: the moment the +worker judges an item, the shared queue has learned about tenant configuration +again. + +The **lease** went with the fence it was built for. It was the stream +incarnation stamped beside the collection, and its only purpose was to let a +consumer tell a canceled stream's item from its replacement's. Keeping a field +against a fence that will never be built is how a retired design grows back, so +it was deleted rather than deprecated. That left `git.Provenance` holding one +field, so the type collapsed with it: queued work now carries a +`types.CollectionKey` named `SourceCollection`, and `sourceCollectionForLog` +renders the zero collection as "unclaimed". ### Where the bound is thin @@ -105,39 +108,40 @@ this is a difference of degree, and the queue bound applies. If that window ever needs to be tighter, the fix belongs on the producer: refuse to enqueue once the stream's context is canceled, inside the same critical section as the send. -**Ordering between overlapping cells.** A cluster-wide cell and a namespaced cell -can both deliver one object, and nothing orders them against each other. That is -unchanged by this plan. Closing it needs a single ordering domain per type, which -is a larger change. +**Ordering between overlapping collections.** A cluster-wide collection and a +namespaced collection can both deliver one object, and nothing orders them +against each other. That is unchanged by this plan. Closing it needs a single +ordering domain per type, which is a larger change. ## Diff the plan **Built**, in `internal/watch/target_watch_plan.go`. The manager computes a -desired plan from the authoritative watched-type table: a map from cell to -specification, where the specification is the canonical operation filter and the -served version the stream opens at. +desired plan from the authoritative watched-type table: a map from collection to +specification, where the specification is the served version the stream opens +at. Every selected collection observes ADDED, MODIFIED, and DELETED alike, so +there is no per-rule event filter to carry. Comparing the previous and desired plans gives four outcomes: | Outcome | Condition | Action | | --- | --- | --- | | `keep` | key and specification unchanged | Leave the stream and its readiness result alone | -| `start` | key only in the desired plan | Open the stream, replay that cell, then follow live events | -| `restart` | key in both, specification changed | Cancel, replace, and replay that cell | +| `start` | key only in the desired plan | Open the stream, replay that collection, then follow live events | +| `restart` | key in both, specification changed | Cancel, replace, and replay that collection | | `stop` | key only in the previous plan | Cancel and drop the key | -An operation-filter change is a `restart` rather than a `keep`. The whole-set -replacement handled that correctly by accident, and a diff comparing keys alone -would have regressed it. A served-version change is also a `restart`, because a -watch is opened at a concrete version. A forced recovery classifies every cell as -`restart` and needs no state machine of its own. +A served-version change is a `restart` rather than a `keep`, because a watch is +opened at a concrete version. The whole-set replacement handled that correctly +by accident, and a diff comparing keys alone would have regressed it. A forced +recovery classifies every collection as `restart` and needs no state machine of +its own. One subtlety is invisible in that table and worth stating, because getting it wrong looks like working code. A running stream is keyed by a watch key that embeds the full GVR, so diffing THOSE keys would read a storage-version bump as a -`stop` of one key plus a `start` of another: the cell would be canceled, +`stop` of one key plus a `start` of another: the collection would be canceled, replayed, and stripped of its readiness result, for a change that moves no file. -The plan is keyed by the cell and carries the version as specification data, +The plan is keyed by the collection and carries the version as specification data, which makes the same event a single `restart`. Because nothing filters the queue, `stop` is cheap: cancel the stream, drop the @@ -145,23 +149,23 @@ key, and let whatever is already queued drain. `stop` never touches files. The mirror is converged afterwards by a sweep, described in "Removal is a Git-side sweep". -## What a cell leaving means +## What a collection leaving means -A cell can leave the plan for two very different kinds of reason, and only one of -them is a statement about the world: +A collection can leave the plan for two very different kinds of reason, and only +one of them is a statement about the world: | Kind | Examples | Effect | | --- | --- | --- | -| **Authoritative** | A rule deleted or narrowed, a namespace deselected, a label revoked, a settled `TypeRemoved` past `RemovalGrace` | The cell stops contributing to the desired set. The sweep may then remove its documents, gated by `prune.mode` | -| **Uncertain** | Discovery wobble, list failure, RBAC denial, unreachable source cluster | **Abort the sweep.** An ungatherable cell must never present as an empty one | +| **Authoritative** | A rule deleted or narrowed, a namespace deselected, a label revoked, a settled `TypeRemoved` past `RemovalGrace` | The collection stops contributing to the desired set. The sweep may then remove its documents, gated by `prune.mode` | +| **Uncertain** | Discovery wobble, list failure, RBAC denial, unreachable source cluster | **Abort the sweep.** An ungatherable collection must never present as an empty one | That second row is the one that must never be got wrong, and it is why "walk the folder and delete anything uncovered" cannot stand on its own: an incomplete plan is indistinguishable from a narrowed one at the moment of the walk. Coverage is an invariant worth asserting, and an unsafe action to take. -Everything else follows from putting a cell in the right row. There is no third -action, no per-cause policy, and no held-out set. +Everything else follows from putting a collection in the right row. There is no +third action, no per-cause policy, and no held-out set. ### Why a withdrawn type is removed rather than kept @@ -197,12 +201,12 @@ through a policy of its own. ### The withdrawal signal, and the mechanism to avoid -[Type lifecycle events and wobble settling](../spec/type-lifecycle-events-and-wobble-settling.md) -produces this distinction already. `internal/typeset` emits the per-type -lifecycle, and `RemovalGrace` separates a wobble from a removal, so the waiting is -part of the abstraction rather than something this plan adds. -`Registry.Subscribe` has no production consumer yet; the plan's `stop` -classification is the natural one. +[Type lifecycle events and wobble +settling](../spec/type-lifecycle-events-and-wobble-settling.md) produces this +distinction already. `internal/typeset` emits the per-type lifecycle, and +`RemovalGrace` separates a wobble from a removal, so the waiting is part of the +abstraction rather than something this plan adds. `Registry.Subscribe` has no +production consumer yet; the plan's `stop` classification is the natural one. The signal to consume is a **settled `TypeRemoved`**. Do not substitute direct observation of a CRD delete. Local CRD and APIService informers run in the @@ -239,15 +243,16 @@ Whichever is chosen, it has to be written where users read it, not only here. ## Removal is a Git-side sweep -The question "which files does this cell own?" is the wrong one to answer. -Answering it means a **managed projection**, a per-cell file index maintained in -the watch layer, and that layer has no business knowing about files. +The question "which files does this collection own?" is the wrong one to answer. +Answering it means a **managed projection**, a per-collection file index +maintained in the watch layer, and that layer has no business knowing about +files. Turn it around. Walk the managed documents in the GitTarget subtree and ask of each one: **is this still wanted?** A document is wanted when it appears in the -gathered state of some currently selected cell. Anything else is a candidate for -removal. No projection is needed, because the question is asked of files rather -than of cells. +gathered state of some currently selected collection. Anything else is a +candidate for removal. No projection is needed, because the question is asked of +files rather than of collections. This is the right layer for it. Deciding what may be removed from Git already lives on the Git side, along with the acceptance gate, `.gittargetignore`, and @@ -276,25 +281,25 @@ cluster, not the file walk. ### The guard everything rests on **The union gather is all-or-nothing.** A scoped gather already aborts and -produces nothing on a partial stream. Across a union of cells the rule has to be -stronger: if any selected cell fails to gather, for any reason, abandon the whole -sweep and remove nothing. Otherwise an outage presents as "these objects are -gone" and the sweep deletes a tenant's manifests. +produces nothing on a partial stream. Across a union of collections the rule has +to be stronger: if any selected collection fails to gather, for any reason, +abandon the whole sweep and remove nothing. Otherwise an outage presents as +"these objects are gone" and the sweep deletes a tenant's manifests. `prune.mode` does not cover this on its own, because `Always` is a standing setting rather than consent for one particular sweep. A settled withdrawal is not a gather failure. The type is gone, so it is no -longer a selected cell, and it contributes nothing to the desired set without -holding anything up. The distinction between "a cell I should be able to gather -and cannot" and "a cell that is legitimately gone" is the wobble-versus-withdrawal -call, which `typeset` already owns. +longer a selected collection, and it contributes nothing to the desired set +without holding anything up. The distinction between "a collection I should be +able to gather and cannot" and "a collection that is legitimately gone" is the +wobble-versus-withdrawal call, which `typeset` already owns. That leaves a clean division of labor: ```text typeset decides present / wobbling / settled-gone -watch gathers every selected cell, all-or-nothing +watch gathers every selected collection, all-or-nothing git sweeps anything absent from desired, gated by prune.mode ``` @@ -305,12 +310,12 @@ being upgraded, and assume `prune.mode: Always` unless stated otherwise. | Situation | What `typeset` says | What happens to Git | | --- | --- | --- | -| CRD disappears for 20s during a rolling operator upgrade | `TypeWobbling`; `RemovalGrace` (60s) has not elapsed | Nothing. The cell holds, and a sweep in flight aborts. Documents untouched | -| Operator uninstalled for good; Kubernetes cascade-deletes the widgets | Settled `TypeRemoved` after the grace | The cell leaves the plan, so its documents are absent from desired and swept. The folder applies cleanly again | +| CRD disappears for 20s during a rolling operator upgrade | `TypeWobbling`; `RemovalGrace` (60s) has not elapsed | Nothing. The collection holds, and a sweep in flight aborts. Documents untouched | +| Operator uninstalled for good; Kubernetes cascade-deletes the widgets | Settled `TypeRemoved` after the grace | The collection leaves the plan, so its documents are absent from desired and swept. The folder applies cleanly again | | Same, but the target is `prune.mode: Never` | Settled `TypeRemoved` | Nothing is removed. This is the archive mirror the mode exists for | -| Source cluster unreachable while a sweep is triggered | The cell is selected but cannot be gathered | The union gather aborts. Nothing is removed anywhere in the target, even under `Always` | -| RBAC for `widgets` revoked | List returns `Forbidden`, so the cell cannot be gathered | Same as above. A permission loss is not a deselection | -| The `team-a` label is revoked, so the namespace leaves the watch set | Nothing. The type is healthy; the cell was deselected by **intent** | Governed by the open decision above. Today, nothing is removed | +| Source cluster unreachable while a sweep is triggered | The collection is selected but cannot be gathered | The union gather aborts. Nothing is removed anywhere in the target, even under `Always` | +| RBAC for `widgets` revoked | List returns `Forbidden`, so the collection cannot be gathered | Same as above. A permission loss is not a deselection | +| The `team-a` label is revoked, so the namespace leaves the watch set | Nothing. The type is healthy; the collection was deselected by **intent** | Governed by the open decision above. Today, nothing is removed | The fourth and fifth rows are the ones worth internalizing. Both look like "the objects are gone" from inside a naive walk, and in both cases removing anything @@ -326,8 +331,8 @@ would be destroying a tenant's manifests during an outage. - A per-cause action table. There is one gate, `prune.mode`, and one guard. - `stop` touching files at all. It cancels the stream and drops the key; the sweep converges the mirror afterwards. -- The need to prevent the accepted tail. A late write from a deselected cell is - removed by the next sweep rather than fenced at the queue. +- The need to prevent the accepted tail. A late write from a deselected + collection is removed by the next sweep rather than fenced at the queue. Deletion becomes level-triggered convergence instead of an edge-triggered side effect of a configuration change, which is the same split this system already @@ -360,9 +365,10 @@ Two implementation traps, both already paid for once: later request can reuse that key after the older marker is already queued. The match deciding whether a write falls inside a resync scope is by object -identity. Two overlapping cells can both deliver one object, so the producing -cell cannot be recovered from the object. Over-matching may forgo a coalesce, and -it can never move a snapshot ahead of a write it might overwrite. +identity. Two overlapping collections can both deliver one object, so the +producing collection cannot be recovered from the object. Over-matching may +forgo a coalesce, and it can never move a snapshot ahead of a write it might +overwrite. This fence is built and shipped. It is recorded here because it holds only while the reason for it stays legible. @@ -403,43 +409,43 @@ channel each request carries is a second obstacle. ## Readiness -Readiness is per cell. A new or restarted cell is pending until its replay -finishes, an unchanged cell keeps its prior clean or divergent result, and a -removed cell leaves the target's readiness reduction. +Readiness is per collection. A new or restarted collection is pending until its +replay finishes, an unchanged collection keeps its prior clean or divergent +result, and a removed collection leaves the target's readiness reduction. The readiness store may use an internal revision to ignore a late report, but that revision stays an implementation detail of the store. It is not a second watch protocol and it does not travel through queued work. That is what -`RenderFidelityGate` keeps: one revision per scope, issued when the cell's stream -starts and carried by that stream alone, so a retired stream's tail is stale by -construction. +`RenderFidelityGate` keeps: one revision per scope, issued when the collection's +stream starts and carried by that stream alone, so a retired stream's tail is +stale by construction. An unrelated plan change must not clear a divergence. A target held open by a render-fidelity divergence stays held, because adding a WatchRule is no evidence -that the divergence was resolved. Only a successful replay of the divergent cell -clears it. +that the divergence was resolved. Only a successful replay of the divergent +collection clears it. The gate used to key this on a single per-target epoch that every declaration bumped, which was coherent only while every declaration restarted every stream. -Applying the plan per cell breaks it in both directions at once: a kept cell -would sit pending under an epoch it never replayed for, holding the target -Unknown and closing its writes forever, and an unrelated edit would clear a -divergence nothing had re-measured. The revision had to become per scope before -the diff could be applied at all. +Applying the plan per collection breaks it in both directions at once: a kept +collection would sit pending under an epoch it never replayed for, holding the +target Unknown and closing its writes forever, and an unrelated edit would clear +a divergence nothing had re-measured. The revision had to become per scope +before the diff could be applied at all. Retention rides the same revisions, because it used that epoch for scope -eviction. The roll-up is keyed by cell, so the plan installs its selected cells -and their revisions, and a count is accepted only for a cell the plan holds, at -the revision it holds. A cell that leaves takes its count with it; a cell that is -kept keeps the count nothing re-measured. +eviction. The roll-up is keyed by collection, so the plan installs its selected +collections and their revisions, and a count is accepted only for a collection +the plan holds, at the revision it holds. A collection that leaves takes its +count with it; a collection that is kept keeps the count nothing re-measured. -### The one divergence that belongs to no cell +### The one divergence that belongs to no collection A divergence a live WRITE discovers is target-level, and it cannot be filed under -a cell. Two facts settle that, and both were checked rather than assumed: +a collection. Two facts settle that, and both were checked rather than assumed: -- a commit window batches events from several cells of one GitTarget, so the - refused flush is not one cell's work; +- a commit window batches events from several collections of one GitTarget, so the + refused flush is not one collection's work; - the refusal names Git **paths**, and resolving a path back to a type needs the GVK-to-GVR mapping this plan deliberately stopped depending on (see "What this deletes from the plan"). @@ -448,8 +454,8 @@ So the gate holds it beside the scopes rather than inside one, and only a plan that restarts every scope clears it: a forced recheck, or a target starting from nothing. That is stricter than `GitPathAccepted`, which any one successful resync clears, and the asymmetry is deliberate. Acceptance is a property of the subtree -that any successful flush re-proves. Fidelity is per-cell evidence, and one -cell's clean replay says nothing about a token another cell writes. +that any successful flush re-proves. Fidelity is per-collection evidence, and one +collection's clean replay says nothing about a token another collection writes. ## Where this stands, and what to merge @@ -457,28 +463,29 @@ Three changes, each independently shippable. **The first two are built.** ### Built -Cell identity (`types.CellKey`, versionless, one stream per cell); the source -cell stamped on queued items, with no lease beside it; the coalescing tail fence; -the whole-target mark-and-sweep itself, which needs a caller rather than an -implementation; and: +Collection identity (`types.CollectionKey`, versionless, one stream per +collection); the source collection stamped on queued items, with no lease beside +it; the coalescing tail fence; the whole-target mark-and-sweep itself, which +needs a caller rather than an implementation; and: **1. The diff.** `target_watch_plan.go` computes the desired plan and classifies it against the running streams into `keep` / `start` / `restart` / `stop`. Every -reconcile logs the four outcomes with each cell named, an all-`keep` one +reconcile logs the four outcomes with each collection named, an all-`keep` one included. -**2. Applying it.** A `targetWatchSet` is a map of running streams keyed by cell, -each with its own cancel, so `stop` and `restart` cancel one cell and `keep` -touches nothing. Readiness follows: a kept cell holds the result its own replay -produced. Cancellation is prompt at the producer: a canceled stream stops -enqueuing before routing a live event and before enqueueing a replay snapshot it -can no longer report on. The render-fidelity revision and the retention roll-up -moved with it, for the reasons under "Readiness". +**2. Applying it.** A `targetWatchSet` is a map of running streams keyed by +collection, each with its own cancel, so `stop` and `restart` cancel one +collection and `keep` touches nothing. Readiness follows: a kept collection +holds the result its own replay produced. Cancellation is prompt at the +producer: a canceled stream stops enqueuing before routing a live event and +before enqueueing a replay snapshot it can no longer report on. The +render-fidelity revision and the retention roll-up moved with it, for the +reasons under "Readiness". Two mechanisms went with the wholesale replacement they existed for: the set-wide cancel, and the whole-map specification comparison that answered "did -anything change". The diff answers that per cell, so an unchanged reconcile is -"nothing to start, nothing to stop" rather than an early return. +anything change". The diff answers that per collection, so an unchanged +reconcile is "nothing to start, nothing to stop" rather than an early return. ### The merge boundary is here @@ -498,17 +505,18 @@ performance fix hostage to it. Merge here. ### Then, in this order 1. **Measure, then delete coalescing.** This is the largest deletion available, - and change 2 is what makes it safe (see "Whether coalescing should survive the - diff"). It needs a number from a running workload, which is a second reason to - merge first: the measurement is the real resync rate once one rule edit - produces one restart. If it sits comfortably inside the queue, `pendingResyncs`, - `tailPassed`, `ErrResyncSuperseded` and the tail fence go together. + and change 2 is what makes it safe (see "Whether coalescing should survive + the diff"). It needs a number from a running workload, which is a second + reason to merge first: the measurement is the real resync rate once one rule + edit produces one restart. If it sits comfortably inside the queue, + `pendingResyncs`, `tailPassed`, `ErrResyncSuperseded` and the tail fence go + together. 2. **Change 3, removal semantics.** Subscribe to the registry so a settled - `TypeRemoved` drops its cell as an authoritative cause, and give the + `TypeRemoved` drops its collection as an authoritative cause, and give the whole-target sweep a producer: an all-or-nothing union gather across the - selected cells, enqueued as a nil-scope resync under the existing `prune.mode` - gate. Withdrawal converges on that alone. Sweeping on **intent** waits for the - open decision. + selected collections, enqueued as a nil-scope resync under the existing + `prune.mode` gate. Withdrawal converges on that alone. Sweeping on **intent** + waits for the open decision. The order is deliberate. Change 3 carries a product question and adds a new resync producer; the coalescing deletion carries neither and is pure subtraction. @@ -523,9 +531,9 @@ Two small debts, recorded so they are paid rather than discovered: - `streamRevisions` fills in zero revisions when no shared fidelity gate is wired. That path exists only because the gate can be absent in test wiring, and it is a fork in the code that a required gate would delete. -- The retention roll-up now drops a count for a cell its plan has not installed - yet. That is the correct fence, but it is an ordering requirement a caller can - trip, where eviction by epoch could not be got wrong. +- The retention roll-up now drops a count for a collection its plan has not + installed yet. That is the correct fence, but it is an ordering requirement a + caller can trip, where eviction by epoch could not be got wrong. If a concurrency problem later resists prompt cancellation and FIFO ordering, document that specific failure before adding any fence, then add the smallest one @@ -537,37 +545,39 @@ A scenario list rather than a test plan. Each needs a failing-first test. The ones changes 1 and 2 cover are marked; the rest belong to the work still ahead. **Classification**, covered in `internal/watch/target_watch_test.go`. Adding a -rule starts one cell and leaves every existing cell running. Removing one of -several rules stops one cell and leaves the others untouched. An operation-filter -edit restarts that cell alone. A no-op edit, such as a status write, classifies -everything as `keep` and preserves readiness results. - -**The accepted tail**, partly covered. Work queued before a cell was deselected -is still applied, and its commit is well-formed. A canceled stream stops -enqueuing promptly, so the tail is bounded by the queue rather than by how long -the goroutine lives. A write and a drop record both name the producing cell, with -no lease. What is asserted today is the producer half: a canceled stream -enqueues nothing. That the tail's own commit is well-formed is an end-to-end -claim no unit test makes. +rule starts one collection and leaves every existing collection running. +Removing one of several rules stops one collection and leaves the others +untouched. A served-version edit restarts that collection alone. A no-op edit, +such as a status write, classifies everything as `keep` and preserves readiness +results. + +**The accepted tail**, partly covered. Work queued before a collection was +deselected is still applied, and its commit is well-formed. A canceled stream +stops enqueuing promptly, so the tail is bounded by the queue rather than by how +long the goroutine lives. A write and a drop record both name the producing +collection, with no lease. What is asserted today is the producer half: a +canceled stream enqueues nothing. That the tail's own commit is well-formed is +an end-to-end claim no unit test makes. **Ordering.** A restart while events for that scope are queued does not apply an older event after a newer snapshot. A resync never coalesces past a queued write for the same scope. Queue saturation drops or coalesces without any accepted request failing to run. -**Cause and policy.** A discovery wobble holds every cell, stopping nothing and -deleting nothing. An unreachable source cluster, and an RBAC denial, both hold -and never present as deselection. +**Cause and policy.** A discovery wobble holds every collection, stopping +nothing and deleting nothing. An unreachable source cluster, and an RBAC denial, +both hold and never present as deselection. -**The sweep.** A union gather in which one cell fails removes nothing at all, -even under `prune.mode: Always`. A settled `TypeRemoved` past `RemovalGrace` -sweeps that type's documents under `Always` and keeps them under `Never`. A -wobble inside the grace sweeps nothing. A cell deselected by intent is removed -only under the mode the open decision names. Auxiliary and retained files inside -an accepted folder are never touched. A sweep run twice with no cluster change is -a no-op the second time. +**The sweep.** A union gather in which one collection fails removes nothing at +all, even under `prune.mode: Always`. A settled `TypeRemoved` past +`RemovalGrace` sweeps that type's documents under `Always` and keeps them under +`Never`. A wobble inside the grace sweeps nothing. A collection deselected by +intent is removed only under the mode the open decision names. Auxiliary and +retained files inside an accepted folder are never touched. A sweep run twice +with no cluster change is a no-op the second time. **Render fidelity**, covered in `internal/git/render_fidelity_gate_test.go`. A -diverged cell stays diverged across an unrelated plan change, and writes do not -reopen. The diverged cell reporting clean reopens writes, once. A divergence a -live write found is cleared only by a plan that restarts every scope. +diverged collection stays diverged across an unrelated plan change, and writes +do not reopen. The diverged collection reporting clean reopens writes, once. A +divergence a live write found is cleared only by a plan that restarts every +scope. diff --git a/docs/design/watch-and-catalog-architecture.md b/docs/design/watch-and-catalog-architecture.md index 97c00d4f5..2b5ec7c11 100644 --- a/docs/design/watch-and-catalog-architecture.md +++ b/docs/design/watch-and-catalog-architecture.md @@ -84,7 +84,7 @@ An active `GitTarget` must respond to a newly added or removed CRD by changing - GVR withdrawn → stop that one informer; apply the configured withdrawal policy to only that GVR's files. -The unit of reconciliation is the **(GitTarget, GVR) cell**, not the whole +The unit of reconciliation is the **(GitTarget, GVR) collection**, not the whole GitTarget. Rationale (the user's, and worth stating as a requirement): because live audit/event coverage is high-quality, a full-target re-list is rarely needed — the only thing a new GVR genuinely needs is its *initial seed*; steady state is @@ -99,17 +99,17 @@ and writing the difference would mirror phantom deletions into git. Concretely: -- A delete is only ever written for a cell whose source-of-truth was +- A delete is only ever written for a collection whose source-of-truth was **authoritatively observed** (a successful list of that GVR, or a real DELETE event for that object) **or** whose removal was **intent-driven** (the rules no longer select it — see 1.6). - If a GVR's list fails, its GV is degraded, or the controller lacks permission, - that cell is **left untouched and retried** — an *inability to observe* must + that collection is **left untouched and retried** — an *inability to observe* must never be read as a deletion. -- The dividing line is **intent vs observability**: a cell leaving the desired +- The dividing line is **intent vs observability**: a collection leaving the desired set because of a rule/scope/policy change is an intent-driven mutation and may - delete; a cell that is merely unobservable right now must hold. -- This invariant is per-cell, so one flaky GVR cannot corrupt an entire target. + delete; a collection that is merely unobservable right now must hold. +- This invariant is per-collection, so one flaky GVR cannot corrupt an entire target. ## 1.5 Convergence, determinism, idempotency @@ -122,10 +122,10 @@ Concretely: - **Idempotency.** Re-running a reconcile with unchanged inputs produces no commit. -## 1.6 A cell can disappear for several reasons; the cause decides the action +## 1.6 A collection can disappear for several reasons; the cause decides the action -This is the most important semantic to get right. "Cell removed" is **not** one -case, and collapsing them all into one policy is wrong. A cell can leave a +This is the most important semantic to get right. "Collection removed" is **not** one +case, and collapsing them all into one policy is wrong. A collection can leave a target's desired set because of an **intent** change or because of an **observability** change, and the mirror must treat those oppositely: @@ -141,37 +141,37 @@ target's desired set because of an **intent** change or because of an | Cause | Class | Mirror action | |---|---|---| -| Rule deleted / narrowed / ops changed so the cell is no longer selected | intent | **Delete** the cell's managed files — required to keep the mirror a deterministic projection of the rules. | -| Namespace / scope change | intent | Old cell deselected → delete its managed files; new cell selected → seed it. | +| Rule deleted / narrowed so the collection is no longer selected | intent | **Delete** the collection's managed files — required to keep the mirror a deterministic projection of the rules. | +| Namespace / scope change | intent | Old collection deselected → delete its managed files; new collection selected → seed it. | | GitTarget rewiring (provider/branch/path change) | intent | Projection moves: delete from the old path, seed at the new one. | | Resource policy now excludes the kind | intent/config | Likely **delete** (open question 6) — it is a config-driven deselection, not an outage. | | Surface withdrawal (CRD uninstalled) | observability | **Keep** files (proposed) and record withdrawal in status/log. Absence is an operator surface change, not GitOps intent; deleting hundreds of files on a CRD upgrade/reinstall would be destructive and noisy. | | Object deleted while kind still served | observability (authoritative) | Real deletion, driven by DELETE event / authoritative re-list → **delete** the file. | -| GV degraded / list failed / permission denied | observability (non-authoritative) | **Hold** the cell — never delete (see 1.4). | +| GV degraded / list failed / permission denied | observability (non-authoritative) | **Hold** the collection — never delete (see 1.4). | The principle: **only intent-driven removals and authoritatively-observed deletions mutate the mirror; non-authoritative absences hold.** The reconciler -must therefore know *why* a cell left the desired set, not merely *that* it did. +must therefore know *why* a collection left the desired set, not merely *that* it did. This requires a notion of **managed projection** (see 1.7): the set of files a -given cell owns, so "delete the cell's files" is well-defined and scoped. +given collection owns, so "delete the collection's files" is well-defined and scoped. ## 1.7 Requirements you may be missing Beyond the four beliefs above, a complete design needs: - **Deterministic file ownership (managed projection).** ~~There must be a - deterministic mapping from a cell to the set of repo paths it owns.~~ + deterministic mapping from a collection to the set of repo paths it owns.~~ **Dropped.** [target-watch-plan.md](target-watch-plan.md) turns the question - around: rather than asking which files a cell owns, the sweep walks the managed - documents and asks of each whether it is still wanted. That needs no projection - and keeps file knowledge on the Git side, where the acceptance gate and the - retention policy already live. + around: rather than asking which files a collection owns, the sweep walks the + managed documents and asks of each whether it is still wanted. That needs no + projection and keeps file knowledge on the Git side, where the acceptance gate + and the retention policy already live. - **RBAC / least privilege.** "Watch everything" means the controller's ServiceAccount must `list`/`watch` every selected GVR. A wildcard implies broad RBAC. Required: either a documented broad ClusterRole, or per-GVR - permission-denied handling that degrades that *cell* gracefully (treat as + permission-denied handling that degrades that *collection* gracefully (treat as "cannot observe" → never delete, surface in status) instead of failing the target. - **Scale bounds & backpressure.** One informer per selected GVR across the whole @@ -183,7 +183,7 @@ Beyond the four beliefs above, a complete design needs: could wipe the mirror. - **Observability / status conditions.** `WatchRule`/`ClusterWatchRule`/`GitTarget` status must show: resolved GVR count (incl. wildcard expansion), degraded - surface, withdrawn types, permission failures, and unseeded/retrying cells. + surface, withdrawn types, permission failures, and unseeded/retrying collections. - **Version selection.** A resource served at multiple versions must mirror exactly one (preferred) to avoid duplicate files; define wildcard-version behavior (proposal: still collapse to preferred unless a specific version is @@ -197,9 +197,9 @@ Beyond the four beliefs above, a complete design needs: - **Migration / backward compatibility.** Changing selector semantics changes what *existing* rules watch. Required: no silent expansion of an existing user's scope on upgrade — a migration story or explicit opt-in. -- **Causal ordering per cell.** A GVR's initial seed must be reconciled before its - live events are flushed, so the seed isn't overwritten or double-counted. Today - this is done per-target; it must hold per-cell. +- **Causal ordering per collection.** A GVR's initial seed must be reconciled + before its live events are flushed, so the seed isn't overwritten or + double-counted. Today this is done per-target; it must hold per-collection. --- @@ -251,7 +251,7 @@ ReconcileForRuleChange (manager.go:740) > **Part 2 predates the watch-first rewrite and is a survey, not a current map.** The snapshot > path it describes — `currentRuleSetSnapshots`, `emitSnapshotForRuleChange`, `RequestClusterState`, > the `compareGVRs` informer diff — no longer exists: object state comes from target watches, one -> stream per cell, replaying under a scoped mark-and-sweep +> stream per collection, replaying under a scoped mark-and-sweep > ([data-plane-triggering.md](data-plane-triggering.md), [target-watch-plan.md](target-watch-plan.md)). > The REQUIREMENTS in Part 1 and the target model in Part 3 stand; read 2.3 and 2.4 as the reasoning > that led here rather than as a description of the tree. @@ -273,13 +273,13 @@ ReconcileForRuleChange (manager.go:740) (manager.go:559-594) both resolve rules independently — duplicated logic that has already drifted (the bug that birthed [watchrule-gvr-resolution-plan.md](../spec/type-followability.md)). -2. **Snapshots are whole-target, not per-cell.** Adding one GVR re-lists and +2. **Snapshots are whole-target, not per-collection.** Adding one GVR re-lists and re-diffs the *entire* target (`emitSnapshotForRuleChange` → `RequestClusterState` over all GVRs). Violates requirement 1.3. 3. **One failure aborts the whole target.** A single GVR list failure or blocking miss aborts the entire target snapshot (manager.go:596-628). Safe but coarse; at wildcard scale it means a target may rarely snapshot. Violates the - *per-cell* form of requirement 1.4. + *per-collection* form of requirement 1.4. 4. **Wildcards refused** at planning (requirement 1.2 unmet) — and the CRD field docs advertise the opposite, so a `*` rule applies cleanly and silently watches nothing. @@ -296,9 +296,9 @@ ReconcileForRuleChange (manager.go:740) The strategy: make the **catalog** a proper live model that emits deltas and confidence, make the **watch plan** a single deterministic projection consumed in -one place, and make **reconciliation per-cell and incremental**. This removes the -duplicate resolution, the whole-target snapshots, and most of the miss/degraded -special-casing in one move. +one place, and make **reconciliation per-collection and incremental**. This +removes the duplicate resolution, the whole-target snapshots, and most of the +miss/degraded special-casing in one move. ## 3.1 Layer 1 — `ClusterSurface` (evolve the catalog) @@ -309,24 +309,23 @@ Promote `APIResourceCatalog` into the authoritative, observable allowed/listable/scope/preferred metadata. (`served`/`degraded` already exist; add explicit `withdrawn` with a debounce before a degraded GV is declared withdrawn.) -- **Per-cell confidence, not a global flag.** Expose +- **Per-collection confidence, not a global flag.** Expose `AuthoritativeFor(GVR) Confidence` (and a startup-trust gate), **not** a global `Authoritative() bool`. A single boolean would either block healthy targets because one unrelated APIService is degraded, or get bypassed and become unsafe. - Confidence travels with each planned cell so destructive decisions are gated - per cell (1.4). Degradation is observed at GV granularity (discovery fails per + Confidence travels with each planned collection so destructive decisions are gated + per collection (1.4). Degradation is observed at GV granularity (discovery fails per GroupVersion) and projected down to the GVRs in that GV — see open question 7 on GV-vs-GVR granularity. - **Current surface + generation is the source of truth; deltas are only triggers.** Expose a queryable current surface snapshot (with its `generation`) - as the authority, and a `Subscribe() <-chan SurfaceDelta` - (`{GVR, Change: Added|Withdrawn|Degraded|Recovered}`) purely as an - *optimization* that tells consumers *when* and *roughly what* to re-evaluate. - Consumers must establish correctness by **re-diffing desired cells against - active cells** against the current surface, never by replaying deltas as if they - were complete — deltas can be missed across restart, backpressure, or - subscription churn. Deltas make reconvergence cheap; the re-diff makes it - correct. + as the authority, and a `Subscribe() <-chan SurfaceDelta` (`{GVR, Change: + Added|Withdrawn|Degraded|Recovered}`) purely as an *optimization* that tells + consumers *when* and *roughly what* to re-evaluate. Consumers must establish + correctness by **re-diffing desired collections against active collections** + against the current surface, never by replaying deltas as if they were complete + — deltas can be missed across restart, backpressure, or subscription churn. + Deltas make reconvergence cheap; the re-diff makes it correct. - **Absorbs transience centrally.** No consumer ever sees a flapping CRD as add/remove churn; the surface only reports `Withdrawn` after the debounce. This is requirement 1.1, in one place, deleting the need for scattered @@ -338,15 +337,15 @@ A single `WatchPlan` projects rules onto the surface, used by **both** informers and snapshots (kills gap 2.4.1): - `Plan(rules, surface) → map[GitTarget]TargetPlan`, where a `TargetPlan` is a - set of **cells**: `{GVR, scope, namespaces, ops}`. + set of **collections**: `{GVR, scope, namespaces}`. - **Wildcards are projection, not expansion-at-apply** (requirement 1.2): `*` - selects all matching `served` cells from the surface *now*; the plan is + selects all matching `served` collections from the surface *now*; the plan is recomputed on every surface delta, so membership tracks the cluster live. - **Empty vs `*`**: empty = strict unique-or-error; `*` = all. One small, explicit rule — not a pile of miss reasons. - **Resolution errors still exist and still matter.** Strict-empty ambiguity, not-served, and disallowed are real errors the resolver must report. The - critical rule: an unresolved or errored selector must mark its cells + critical rule: an unresolved or errored selector must mark its collections **unresolved/held**, never collapse them into an *empty desired set* — otherwise a failed resolve looks like "the user wants nothing here" and the reconciler deletes the projection (1.4). **Resolution failure ≠ empty desired state.** @@ -354,96 +353,98 @@ and snapshots (kills gap 2.4.1): spread across two resolution sites and the snapshot layer — not error handling itself, which consolidates into one place with one meaning. -## 3.3 Layer 3 — per-cell reconciler (incremental) +## 3.3 Layer 3 — per-collection reconciler (incremental) -Replace whole-target snapshotting with a **cell reconciler** driven by two delta +Replace whole-target snapshotting with a **collection reconciler** driven by two delta sources — rule changes and surface deltas — joined against current plans: ```text SurfaceDelta{GVR, …} ─┐ (triggers only) -RuleChange{target} ─┼─► RE-DIFF desired cells (WatchPlan over current -periodic tick ─┘ surface+generation) vs active cells + managed files +RuleChange{target} ─┼─► RE-DIFF desired collections (WatchPlan over current +periodic tick ─┘ surface+generation) vs active collections + managed files │ ▼ - per-cell actions: - cell appeared → start informer + SEED ONLY THIS GVR (cell-scoped) → additions - cell removed → classify cause (1.6): - intent (rule/scope/policy) → stop informer + DELETE the - cell's managed files - surface withdrawal → stop informer + KEEP files, - record in status - non-authoritative (degraded/ - denied/list-failed) → HOLD: do not stop as removed, - do not delete, retry - cell unchanged → nothing + per-collection actions: + collection appeared → start informer + SEED ONLY THIS GVR (collection-scoped) → additions + collection removed → classify cause (1.6): + intent (rule/scope/policy) → stop informer + DELETE the + collection's managed files + surface withdrawal → stop informer + KEEP files, + record in status + non-authoritative (degraded/ + denied/list-failed) → HOLD: do not stop as removed, + do not delete, retry + collection unchanged → nothing ``` Two things this diagram makes explicit, both from review: -- **Correctness is the re-diff, not the delta.** Desired cells are recomputed +- **Correctness is the re-diff, not the delta.** Desired collections are recomputed from the `WatchPlan` over the *current* surface snapshot (and its generation) - and diffed against active cells and the managed-file set. Deltas and the + and diffed against active collections and the managed-file set. Deltas and the periodic tick only *schedule* a re-diff; missing a delta can never cause divergence because the next re-diff is authoritative (§3.1). -- **Repo-side scoping must match.** Seeding/diffing/deleting a cell operates only - on that cell's managed files (1.7). `RequestRepoState` must be answerable at - cell granularity — list/diff just `{target, GVR, scope, namespaces}` — or the - per-cell path silently degrades back into whole-target snapshotting. +- **Repo-side scoping must match.** Seeding/diffing/deleting a collection operates + only on that collection's managed files (1.7). `RequestRepoState` must be + answerable at collection granularity — list/diff just `{target, GVR, scope, + namespaces}` — or the per-collection path silently degrades back into + whole-target snapshotting. Properties this gives us for free: - **Incremental** (requirement 1.3): a new CRD seeds exactly one GVR's objects on the targets that select it; everything else is untouched. -- **Per-cell safety** (requirement 1.4): seeding lists only that GVR; on failure - the cell stays `unseeded/retrying` and writes nothing — no other cell, no - deletions. The "partial view = phantom deletes" failure mode becomes - structurally impossible because deletes are scoped to a cell that just listed - successfully. -- **Convergence** (requirement 1.5): a failed/degraded cell is simply retried on +- **Per-collection safety** (requirement 1.4): seeding lists only that GVR; on + failure the collection stays `unseeded/retrying` and writes nothing — no other + collection, no deletions. The "partial view = phantom deletes" failure mode + becomes structurally impossible because deletes are scoped to a collection that + just listed successfully. +- **Convergence** (requirement 1.5): a failed/degraded collection is simply retried on the next relevant delta or tick; no target-wide churn. - **Full re-snapshot becomes the fallback**, triggered only by explicit resync/repair, not routine surface changes. -Per-cell causal ordering reuses the existing RECONCILING→LIVE buffering, scoped to -the cell (requirement 1.7). +Per-collection causal ordering reuses the existing RECONCILING→LIVE buffering, +scoped to the collection (requirement 1.7). -**Seed transport — streaming-list with a per-GVR fallback.** A cell seeds by +**Seed transport — streaming-list with a per-GVR fallback.** A collection seeds by opening a single streaming-list watch (`SendInitialEvents=true`, `ResourceVersionMatch=NotOlderThan`, `AllowWatchBookmarks=true`) rather than a mega-LIST: the apiserver replays each existing object as a synthetic `ADDED` and closes the initial set with a `k8s.io/initial-events-end` bookmark, whose resourceVersion becomes the live resume cursor -([target_watch.go](../../internal/watch/target_watch.go)). That replay window -*is* the cell's seed — exactly the cell-scoped, one-watch-per-GVR shape this model -wants, with no re-list race that could miss an in-flight delete. +([target_watch.go](../../internal/watch/target_watch.go)). That replay window *is* +the collection's seed — exactly the collection-scoped, one-watch-per-GVR shape +this model wants, with no re-list race that could miss an in-flight delete. This only holds where the serving apiserver honors `SendInitialEvents` and emits -the terminating bookmark. Standard kube-apiserver and `genericregistry.Store`-backed -resources do; hand-written `rest.Storage` in aggregated servers may not, and the -hazard is **per-GVR, not per-APIService** — one aggregated binary can serve a -conformant group beside a non-conformant one. So the fallback is keyed by GVR and -is structural, not a kill switch: when the streaming option is unsupported the -cell drops to a plain **LIST + buffered WATCH** for that one GVR -([`watchListUnsupported` → `targetWatchListAndStream`](../../internal/watch/target_watch.go)), -yielding the same seed-then-stream result while every other cell keeps the -streaming path. There is deliberately no binary-level "disable streaming-list" -switch — the per-GVR fallback is the emergency valve. +the terminating bookmark. Standard kube-apiserver and +`genericregistry.Store`-backed resources do; hand-written `rest.Storage` in +aggregated servers may not, and the hazard is **per-GVR, not per-APIService** — +one aggregated binary can serve a conformant group beside a non-conformant one. So +the fallback is keyed by GVR and is structural, not a kill switch: when the +streaming option is unsupported the collection drops to a plain **LIST + buffered +WATCH** for that one GVR ([`watchListUnsupported` → +`targetWatchListAndStream`](../../internal/watch/target_watch.go)), yielding the +same seed-then-stream result while every other collection keeps the streaming +path. There is deliberately no binary-level "disable streaming-list" switch — the +per-GVR fallback is the emergency valve. Operationally this is an audit per unfamiliar cluster, not a one-time task: for each non-local `APIService` a target watches, confirm it is `genericregistry.Store`-based, handles `SendInitialEvents` in its `Watch` (grep its source for `SendInitialEvents` / `k8s.io/initial-events-end`), or rides the -plain-LIST fallback. The runtime fallback means a missed audit degrades one cell +plain-LIST fallback. The runtime fallback means a missed audit degrades one collection gracefully instead of hanging its seed. ## 3.4 How the existing docs fold in - **Discovery-lag fix** → disappears as a special case: the surface never reports - a degraded GVR as withdrawn, so a transient gap can't drop a cell or erase a - baseline. The plan-hash machinery is replaced by cell-level delta diffing. + a degraded GVR as withdrawn, so a transient gap can't drop a collection or erase a + baseline. The plan-hash machinery is replaced by collection-level delta diffing. - **Wildcard semantics doc** → becomes the spec for `WatchPlan` projection. - **Wildcard support plan** → its phases map onto building Layers 1–3; the - "snapshot robustness" risk (its Phase 2) is resolved structurally by per-cell + "snapshot robustness" risk (its Phase 2) is resolved structurally by per-collection seeding rather than needing a bespoke list-failure policy. ## 3.5 Suggested build order @@ -455,11 +456,12 @@ Each step is independently shippable and leaves the system correct. 2. **Add surface state + deltas** to the catalog (`served/degraded/withdrawn`, `Authoritative()`, `Subscribe()`), with withdrawal debounce. Internal; no consumer change yet. -3. **Per-cell seeding** for *added* cells (incremental seed), keeping whole-target - snapshot as the fallback path. Closes requirement 1.3 for additions. -4. **Per-cell withdrawal policy** (requirement 1.6) for removed cells. +3. **Per-collection seeding** for *added* collections (incremental seed), keeping + whole-target snapshot as the fallback path. Closes requirement 1.3 for + additions. +4. **Per-collection withdrawal policy** (requirement 1.6) for removed collections. 5. **Enable wildcards** in `WatchPlan` projection (requirement 1.2) — now safe - because Layers 1–3 handle dynamic membership, per-cell safety, and scale. + because Layers 1–3 handle dynamic membership, per-collection safety, and scale. 6. **Fix the field docs + admission validation** to match (and add status conditions, RBAC guidance, scale caps). @@ -467,12 +469,12 @@ Each step is independently shippable and leaves the system correct. This design *removes* code as much as it adds: one resolver instead of two; one authority for "is the surface trustworthy" instead of miss-reason handling spread -across resolver, snapshot, and selection layers; structural per-cell safety +across resolver, snapshot, and selection layers; structural per-collection safety instead of the whole-target abort + discovery-lag patch. The number of distinct "cases" a maintainer must hold in their head drops from "many `ResolveMiss` reasons × two resolution sites × whole-target abort rules × discovery-lag bookkeeping" to "a surface with three states (queried as the source of truth, -with deltas as cheap triggers), projected by one resolver into cells that each +with deltas as cheap triggers), projected by one resolver into collections that each reconcile independently and classify their own removal cause." --- @@ -484,7 +486,7 @@ reconcile independently and classify their own removal cause." 2. **Surface-withdrawal default**: keep files on CRD uninstall (proposed) or delete them? Per-GitTarget override? (This is the *observability* class in 1.6.) 3. **RBAC model for wildcard**: ship a broad ClusterRole, or require users to - grant it and degrade cells gracefully on permission-denied? + grant it and degrade collections gracefully on permission-denied? 4. **Migration**: how do existing rules behave on upgrade — frozen semantics, or opt-in to the new projection? 5. **Scale cap**: hard limit on informer count per controller, or @@ -500,7 +502,7 @@ reconcile independently and classify their own removal cause." that `Withdrawn(old)+Added(new)` (risks a delete+reseed and file churn), an `Updated` delta, or an explicit **migration** action (move/rewrite files, never delete)? Affects whether a routine apiserver upgrade churns the mirror. -9. **Rule-deselection disposition** (ref 1.6): when a rule stops selecting a cell, - delete its managed files immediately (proposed — preserves the deterministic - mirror) or leave them as *unmanaged historical* files? If the latter, define - how unmanaged files are marked and ever reclaimed. +9. **Rule-deselection disposition** (ref 1.6): when a rule stops selecting a + collection, delete its managed files immediately (proposed — preserves the + deterministic mirror) or leave them as *unmanaged historical* files? If the + latter, define how unmanaged files are marked and ever reclaimed. diff --git a/docs/design/watch-manager-ownership.md b/docs/design/watch-manager-ownership.md index 5a48c046e..bd9c96792 100644 --- a/docs/design/watch-manager-ownership.md +++ b/docs/design/watch-manager-ownership.md @@ -16,7 +16,7 @@ the data plane reports back through the same channel, and every change to one Gi configuration inside a 2s silence window becomes a single pass over it. This is a follow-up to [target-watch-plan.md](target-watch-plan.md). That plan made the -WORK cheap, per cell. This one is about how often the work is asked for, by whom, and +WORK cheap, per collection. This one is about how often the work is asked for, by whom, and what happens when one target's work will not finish. ## What happens today @@ -82,7 +82,7 @@ others. ```text controllers ──Trigger{target, uid, reason}──┐ -streams ──Report{target, cell, state, revision}──┤ +streams ──Report{target, collection, state, revision}──┤ branch workers ──Report{target, fidelity|retention}──┤ ▼ owner loop @@ -97,9 +97,9 @@ controllers ──read latest snapshot───────────── sets and the per-target captures. Nothing else mutates them. **Cancellation is requested by the owner, and completion is reported back.** The owner -decides that a cell stops; the stream goroutine observes its context, unwinds, and posts +decides that a collection stops; the stream goroutine observes its context, unwinds, and posts a report. It does not reach back for a manager lock on its way out. That inverts what -change 2 does today, where `set.stop(cell)` runs `cancel()` while holding +change 2 does today, where `set.stop(collection)` runs `cancel()` while holding `targetWatchesMu` and the woken goroutine then contends for the same mutex. **Reads are snapshot reads.** The owner publishes an immutable projection after each @@ -179,7 +179,7 @@ regression. That is now **reversed**, and the reason is the case above. Applying a GitTarget together with its rules is the normal way this configuration arrives, and object order within an apply is not guaranteed. If the GitTarget is declared the instant it lands, it is declared with **no rules yet**: a plan with zero -cells, immediately superseded as each rule arrives. The "responsive" version therefore +collections, immediately superseded as each rule arrives. The "responsive" version therefore manufactures a transient empty plan on every cold start, and then does the real work four times over. @@ -240,9 +240,9 @@ So the isolation boundary is **per target**: a yield rather than a drop. **A timeout must never install an empty plan.** A pass that could not gather is not a -pass that found nothing, and the difference is the whole of "What a cell leaving means" -in [target-watch-plan.md](target-watch-plan.md): an ungatherable cell must never present -as an absent one. So a deadline produces exactly this and nothing else: +pass that found nothing, and the difference is the whole of "What a collection leaving +means" in [target-watch-plan.md](target-watch-plan.md): an ungatherable collection must never +present as an absent one. So a deadline produces exactly this and nothing else: ```text pass failed @@ -448,7 +448,7 @@ are deliberately not merged into the trigger queue, and a stale snapshot overtaking a newer write. This page is about the trigger side of the watch manager. They are different queues with different failure modes, and neither substitutes for the other. -- **The per-cell diff.** The plan classification is what makes an unnecessary pass +- **The per-collection diff.** The plan classification is what makes an unnecessary pass cheap; this makes the pass unnecessary less often. Both are worth having. - **Leader election.** The manager already runs only on the elected leader (`NeedLeaderElection`), so a single owner loop adds no new assumption. @@ -574,13 +574,13 @@ deadline, so its context is cancelled the moment it returns — and streams were children of it. Every stream therefore died the instant its plan finished being applied. The signature is worth recording, because it reads like health: the plan log says `start:1`, the -stream set holds the cell, and every later pass reports it as `keep:1` and so never restarts it. +stream set holds the collection, and every later pass reports it as `keep:1` and so never restarts it. Readiness never leaves `Replaying`, the render-fidelity gate never leaves `Rechecking`, and every WatchRule pointing at that target sits `Ready=False` forever. Nothing logs an error, because nothing failed. So the parent of a target watch is `Manager.watchLifetime`, set once by `Start`. Cancelling one -stream is the owner's decision, made per cell through the plan diff — never a side effect of a +stream is the owner's decision, made per collection through the plan diff — never a side effect of a context going out of scope. The pass deadline bounds the pass. ### Toggling a rule off and on inside the window is not a replay @@ -609,7 +609,7 @@ rule has been planned at all, and it is published by a different controller than compiled the rule — so it can still be describing the previous plan. A rule's own `StreamsRunning` has neither problem: it is written by the reconcile that compiled the -rule, from the rule's own compiled cells. It is the right gate whenever a spec changes a rule on a +rule, from the rule's own compiled collections. It is the right gate whenever a spec changes a rule on a target that is already mirroring, and `waitForWatchRuleStreamsRunning` existed unused for exactly this. diff --git a/docs/design/watch-plane-status-convergence-failures.md b/docs/design/watch-plane-status-convergence-failures.md index 800afcec2..895e8f465 100644 --- a/docs/design/watch-plane-status-convergence-failures.md +++ b/docs/design/watch-plane-status-convergence-failures.md @@ -101,12 +101,12 @@ box names the function that does the work. flowchart TD subgraph owner["internal/watch — owner loop (single goroutine)"] PLAN["replaceGitTargetWatches()
target_watch.go"] - GATE0["RenderFidelityGate.Reconcile()
issues one revision per cell"] + GATE0["RenderFidelityGate.Reconcile()
issues one revision per collection"] START["startTargetWatchStreams()
stream carries its revision"] PLAN --> GATE0 --> START end - subgraph cell["per-cell stream goroutine (one per watched cell)"] + subgraph collection["per-collection stream goroutine (one per watched collection)"] REPLAY["runTargetWatch() → replay complete"] ENQ["enqueueReplayResync()
→ enqueueScopedResync()"] START --> REPLAY --> ENQ @@ -120,7 +120,7 @@ flowchart TD subgraph drain["drain goroutine (one per resync)"] DRAIN["drainScopedResync()"] - MARK["MarkTargetRenderFidelityScopeClean(revision, cell)"] + MARK["MarkTargetRenderFidelityScopeClean(revision, collection)"] REPLY --> DRAIN --> MARK end @@ -166,8 +166,8 @@ Two properties of this picture did most of the damage during the investigation: ### 2.3 The race itself -Concurrency is what makes the conflict, and a scope-per-cell design supplies it: each scope report -enqueues the GitTarget, so a 61-scope target produced ~66 reconciles in four seconds. +Concurrency is what makes the conflict, and a scope-per-collection design supplies it: each scope +report enqueues the GitTarget, so a 61-scope target produced ~66 reconciles in four seconds. ```mermaid sequenceDiagram @@ -247,11 +247,11 @@ designed to lose sometimes. Recorded so they are not re-derived. -**A1/A2 — cell-identity mismatch.** The theory was that the rule's expected cell set and the plan's -opened set were computed from different snapshots. A diagnostic was built for it, and a narrowing -fix written and reverted for breaking three tests that deliberately encode the opposite invariant. -The diagnostic then fired **zero times** for the rule that failed. It was instrumenting the wrong -subsystem: the cells never disagreed. +**A1/A2 — collection-identity mismatch.** The theory was that the rule's expected collection set +and the plan's opened set were computed from different snapshots. A diagnostic was built for it, +and a narrowing fix written and reverted for breaking three tests that deliberately encode the +opposite invariant. The diagnostic then fired **zero times** for the rule that failed. It was +instrumenting the wrong subsystem: the collections never disagreed. **A stale prune mode on the resync** (Failure B's first theory, §3.3) — implemented, passed the very spec it targeted once, and reverted when the logs contradicted its mechanism. @@ -267,7 +267,7 @@ Six components in this chain logged what they REJECTED and said nothing about wh | --- | --- | --- | | the render-fidelity condition | a constant string naming nothing | names each pending scope and the revision it owes | | `recordScope` revision mismatch | silent | recorded on the scope, surfaced in the condition | -| `recordScope` `!found` guards | silent | logged, naming cell and revision | +| `recordScope` `!found` guards | silent | logged, naming collection and revision | | zero-revision report | silent early return | logged as anomalous | | fidelity accept path | silent | logged (this is what proved the gate converged) | | retention roll-up accept | silent | Info on the one ambiguous shape, V(1) otherwise | @@ -404,7 +404,7 @@ one behavioural fix (§4.9) is four lines. | 1 | The render-fidelity condition names its pending scopes and the revision each owes, instead of a constant string | `c87db68d` | | 2 | Deleted `explainNotRunning` / `notRunningHypothesis` / `cellNames` — ~70 lines aimed at the wrong subsystem — and lowered the supersession log | `17a57352` | | 3 | The resync drain starts even when the enqueue was dropped, so a queue-full reply is read by the drain the contract promises will read it | `fc04b15b` | -| 4 | All four fidelity refusal branches name the cell, the revision and the gate's message | `32b75e02` | +| 4 | All four fidelity refusal branches name the collection, the revision and the gate's message | `32b75e02` | | 5 | A refused revision is recorded on the scope and surfaced in the condition | `f1ae80a9` | | 6 | The retention roll-up reports the one ambiguous acceptance (re-measured, same answer) at Info and the routine one at `V(1)` | `8ceb5902` | | 7 | The fidelity accept path is logged — this is what proved the gate converges | `e3356796` | @@ -443,7 +443,7 @@ The chain in §2.2 is instrumented at every hop, so a reproduction should be rea | `status write lost a race` on the target, then a stale condition | A's mechanism, recurring — check the requeue that followed it | | `render scope result accepted` … `state:True`, condition still stale | the gate is fine; the failure is downstream in publication | | `a render scope result was not applied` | the gate refused a report — read `reportedRevision` against the owed revision | -| `stream carries no revision` | the plan pass and the gate disagree about the cell | +| `stream carries no revision` | the plan pass and the gate disagree about the collection | | `a GitTarget reconcile request was dropped` | a load-bearing notification was crowded out | | `retention report accepted but published nothing` | a re-measurement produced the count already published — the bug is upstream of the roll-up | | `superseded by a newer resync` | the coalescing path skipped this scope's reports; check the replacement reported | diff --git a/docs/design/watches-labels-simplification.md b/docs/design/watches-labels-simplification.md index e8e1fadcb..b8b562932 100644 --- a/docs/design/watches-labels-simplification.md +++ b/docs/design/watches-labels-simplification.md @@ -2,7 +2,8 @@ > **Design requirements; implementation pending.** All three prune modes, including `Always` > with supported Kustomize layouts, are required from the first release. -> **Step 2 of 2:** implement after the [collection rename and event-filter removal](collection-terminology-rename.md). +> **Step 2 of 2.** Step 1, the [collection rename and event-filter removal](collection-terminology-rename.md), +> has shipped. > [Issue #146](https://github.com/ConfigButler/gitops-reverser/issues/146). > Index: [`../INDEX.md`](../INDEX.md). Checked: 2026-09-28, source at `69836e70`. > Related: [Kubernetes watch facts](../facts/kubernetes-watch-options.md), @@ -46,14 +47,14 @@ comparison. Preserve unsanitized watch provenance for attribution. ## One collection is one list/watch request -Extend `CollectionKey` from step 1 (currently [`CellKey`](../../internal/types/cell.go)) within a GitTarget: +Extend [`CollectionKey`](../../internal/types/collection.go) within a GitTarget: ```text collection: group, resource, namespace ("" = all namespaces), canonical label selector -watch spec: served version +watch spec: served version (watchSpec) ``` -Step 1 removes rule operation filters. Deduplicate identical collections and choose their served +Rules have no operation filter. Deduplicate identical collections and choose their served version deterministically; every collection observes all object event types. Served version remains data, so changing it does not move Git files. Keep each selector attached to its own collection. @@ -270,8 +271,7 @@ retention counts, and unresolved attribution. These are consequences of the chos Filtered requests reduce transferred objects but add watches; measure the cost. Keep requests filtered because [authorization can depend on selectors](../facts/kubernetes-watch-options.md#selectors-and-authorization). -A denial cannot become an empty snapshot. Field selectors remain separate work; the collection -rename and operation-filter removal are prerequisites in step 1. Different selectors over +A denial cannot become an empty snapshot. Field selectors remain separate work. Different selectors over overlapping scopes need union membership and stale-observation handling; independent sweeps are insufficient. The existing same-selector overlap ordering gap also remains outside this change. diff --git a/docs/interpreting-metrics.md b/docs/interpreting-metrics.md index 6852bb9be..f482c04b1 100644 --- a/docs/interpreting-metrics.md +++ b/docs/interpreting-metrics.md @@ -189,11 +189,11 @@ boundary, the commit, the push. Background: | Metric | Type | Labels | Notes | | --- | --- | --- | --- | -| `watch_events_total` | counter | `gittarget_namespace`, `gittarget_name`, `group`, `version`, `resource`, `outcome` | The ingest census: every delivered watch event, exactly once. `outcome` is `routed` / `unchanged` / `operation_filtered` / `not_object` / `bookmark` / `shutdown` / `stream_error` / `route_failed`. | +| `watch_events_total` | counter | `gittarget_namespace`, `gittarget_name`, `group`, `version`, `resource`, `outcome` | The ingest census: every delivered watch event, exactly once. `outcome` is `routed` / `unchanged` / `not_object` / `bookmark` / `shutdown` / `stream_error` / `route_failed`. | | `watch_event_handling_seconds` | histogram | `group`, `version`, `resource` | How long a stream was **busy** on one event, attribution wait included. Occupancy, not queue delay. | | `watch_sessions_ended_total` | counter | `group`, `version`, `resource`, `reason` | `closed` (the API server's routine watch timeout: the most common ending on a healthy cluster) / `expired` (cursor out of history, forcing a rebuild) / `error` / `stopped`. | | `watch_replay_duration_seconds` | histogram | `group`, `version`, `resource` | Time to `initial-events-end`: what a `410` storm charges. | -| `watch_recovery_total` | counter | `gittarget_namespace`, `gittarget_name`, `group`, `resource`, `mode` | One per completed recovery. `mode` is `cursor_resume` / `type_reconcile` / `replay` / `list_fallback`. No `version`: a recovery covers a cell. | +| `watch_recovery_total` | counter | `gittarget_namespace`, `gittarget_name`, `group`, `resource`, `mode` | One per completed recovery. `mode` is `cursor_resume` / `type_reconcile` / `replay` / `list_fallback`. No `version`: a recovery covers a resource collection. | | `watch_types` | gauge | `source_cluster`, `gittarget_namespace`, `gittarget_name`, `state` | Types this target resolves, by `streaming` / `replaying` / `blocked`. `sum` is the resolved total. | | `watch_streams_open` | gauge | `source_cluster`, `gittarget_namespace`, `gittarget_name` | Watch **sessions** currently open against the source cluster. Not the same as types: one type across three namespaces is three sessions. | | `git_documents_total` | counter | `gittarget_namespace`, `gittarget_name`, `group`, `version`, `resource`, `outcome` | The write-boundary census, per **document**. `outcome` is `written` / `deleted_live` / `deleted_sweep` / `unchanged` / `retained` / `refused`. | @@ -320,7 +320,7 @@ red trains people to ignore it. Four classes: | Class | Values | Read as | | --- | --- | --- | -| **expected** | `routed`, `unchanged`, `operation_filtered`, `bookmark`, `shutdown`, `written`, `deleted_live`, `deleted_sweep`, `retained`, `cached` | the pipeline working. `unchanged` is a document considered and found identical; `refused` is a document the writer declined to place, which is loss | +| **expected** | `routed`, `unchanged`, `bookmark`, `shutdown`, `written`, `deleted_live`, `deleted_sweep`, `retained`, `cached` | the pipeline working. `unchanged` is a document considered and found identical; `refused` is a document the writer declined to place, which is loss | | **degraded** | `author_kind="unresolved"`, `mode="list_fallback"`, `not_object`, `stream_error`, weak attribution tiers | working, on weaker evidence | | **recoverable** | `git_pushes_total{outcome="failed"}` — the writes are retained and retried | alert on it **sustained with no successes**, never on one occurrence | | **loss** | `route_failed`, any `git_queue_drops_total`, any `git_commit_failures_total`, `placement_refusals_total`, `git_documents_total{outcome="refused"}` | an observed change that did not reach Git | diff --git a/docs/spec/gittarget-isolation-on-rule-change.md b/docs/spec/gittarget-isolation-on-rule-change.md index 4b753be06..2d46814de 100644 --- a/docs/spec/gittarget-isolation-on-rule-change.md +++ b/docs/spec/gittarget-isolation-on-rule-change.md @@ -1,11 +1,13 @@ # GitTarget Isolation on Rule Changes > **Status: ✅ Implemented and shipped.** Snapshot selection is now driven purely -> by a per-target *effective watch plan* hash (resolved GVR + scope + unioned -> operations + destination); the global `force` flag is gone. Validated by unit +> by a per-target *effective watch plan* hash (resolved GVR + scope + +> destination); the global `force` flag is gone. Validated by unit > tests, a non-Serial e2e regression, and a `--procs=4` smoke run (21/21). See > the Rollout Plan below for the per-step landing notes. The sections before it -> are kept as the original design rationale. +> are kept as the original design rationale. Rule `operations` were later removed +> from both rule kinds ([collection rename](../design/collection-terminology-rename.md)), +> so the plan no longer carries an operation component. ## Problem @@ -170,7 +172,7 @@ This is the target-scoped half. `currentRuleSetSnapshots()` walks the same `RuleStore`, but instead of flattening everything into one GVR set it groups entries **by GitTarget** (`gitDest.Key()`) and hashes each group. Today it hashes the raw rule text; after the fix it hashes the target's *effective watch plan* -(resolved GVR + scope + operations + destination — see Section 2): +(resolved GVR + scope + destination — see Section 2): ```text RuleStore ─► currentRuleSetSnapshots() ─► []ruleSetSnapshotTarget{gitDest, hash} @@ -268,7 +270,6 @@ already captures it: |---|---|---| | `apiGroups` / `apiVersions` / `resources` (incl. wildcards) | **Replace** with the resolved GVR set | These are *inputs* to resolution; the resolved GVR is the *output*, intersected with the catalog. The resolved set is strictly more accurate — it catches wildcard-meets-new-CRD, which the raw patterns miss. Hashing the raw patterns is exactly the blind spot that forced `force` to exist. | | `scope` (namespaced/cluster) | **Replace** with resolved namespace scope | An input to namespace resolution; the resolved scope subsumes it. | -| `operations` | **Keep** | Not part of GVR resolution. Changes *which events* become commits (the existing narrowing test depends on an operations-only change still emitting a snapshot). | | destination: `provider` / `branch` / `path` | **Keep** | Not part of GVR resolution. Changes *where* writes land. | | source rule identity (namespace/name) | **Drop** | Pure identity — it does not change the effective watch surface. A redundant duplicate rule resolving to the same plan should not trigger a no-op snapshot. | @@ -277,18 +278,15 @@ non-resolvable fields that still affect writes: - **resolved GVR** (the missing ingredient today) - resolved namespace / cluster scope -- operations - destination details that affect writes: provider, branch, and path -When more than one rule for the same target resolves to the same GVR, the plan -entry's `operations` is the **union** across those rules — operations add up, -there is no first-wins precedence. This matches how matching already works: -`RuleStore.GetMatchingRules` evaluates per-operation and returns every rule whose -operation set covers it, so a resource covered by rule A=`[CREATE]` and rule -B=`[UPDATE]` is effectively watched for both. The plan builder must preserve that -union rather than letting one rule's operations shadow another's. This invariant -is pinned by `TestGetMatchingRules_OverlappingRulesUnionOperations` in -`internal/rulestore/store_test.go`. +When more than one rule for the same target selects the same GVR and namespace +scope, they select **one** resource collection: the plan holds one entry, and +there is no first-wins precedence between the rules. `RuleStore.GetMatchingRules` +returns every rule that selects a resource, pinned by +`TestGetMatchingRules_OverlappingRulesAreAdditive` in +`internal/rulestore/store_test.go`. Every selected collection observes creates, +updates, and deletions; the target's `spec.prune.mode` decides what a removal does. Then: @@ -379,9 +377,8 @@ cross-spec timing in the full e2e suite. dimension) pin the surrounding contract. 2. ✅ Done — `currentRuleSetSnapshots` now hashes the effective watch plan via a `targetWatchPlan`: it **replaces** the raw resource-matching patterns and - scope with the resolved GVR + namespace scope, **keeps** operations - (unioned across rules per GVR) and the destination (provider/branch/path), - and **drops** source rule identity. A target with no resolvable rules has an + scope with the resolved GVR + namespace scope, **keeps** the destination + (provider/branch/path), and **drops** source rule identity. A target with no resolvable rules has an empty plan and is omitted. 3. ✅ Done — `snapshotTargetsNeedingDelivery` no longer takes a `force` argument; selection relies solely on per-target hash diffs. diff --git a/docs/spec/typeset-owns-discovery-grace.md b/docs/spec/typeset-owns-discovery-grace.md index 895aef039..e21077317 100644 --- a/docs/spec/typeset-owns-discovery-grace.md +++ b/docs/spec/typeset-owns-discovery-grace.md @@ -23,7 +23,7 @@ its own: where the plan still refers to that axis it says explicitly that the axis is deleted, so nothing here has to be mentally subtracted. `Registry.Subscribe` and `LifecycleEvent` survive with no production consumer, deliberately: see - [target-watch-plan.md](../design/target-watch-plan.md#what-a-cell-leaving-means). + [target-watch-plan.md](../design/target-watch-plan.md#what-a-collection-leaving-means). - `Stats()`/`DegradedGroupVersions()` survive as per-scan facts (gauge/log surfaces unchanged); registry tests for the relocated semantics live in `internal/typeset/scan_test.go`. diff --git a/docs/spec/watch-event-ordering-and-attribution-grace.md b/docs/spec/watch-event-ordering-and-attribution-grace.md index c5b3a09bd..a3f1fc607 100644 --- a/docs/spec/watch-event-ordering-and-attribution-grace.md +++ b/docs/spec/watch-event-ordering-and-attribution-grace.md @@ -23,10 +23,10 @@ same-object or same-resource-type ordering guarantee. ## The execution model For each `GitTarget`, [`targetWatchStreams`](../../internal/watch/target_watch.go) chooses one -served version per resource collection. Its [`CellKey`](../../internal/types/cell.go) contains -group, resource, and namespace selection; the containing target supplies target and source-cluster -context. An all-namespace collection and a named-namespace collection remain separate, so their -object sets can overlap. +served version per resource collection. Its +[`CollectionKey`](../../internal/types/collection.go) contains group, resource, and namespace +selection; the containing target supplies target and source-cluster context. An all-namespace +collection and a named-namespace collection remain separate, so their object sets can overlap. Each running watch has its own goroutine. Its event pump handles an event synchronously before reading another. On the live path, the call chain is: @@ -40,11 +40,9 @@ watch event -> BranchWorker.Enqueue ``` -Operation filtering and unchanged-content checks can discard events before attribution in the -current implementation. [Step 1](../design/collection-terminology-rename.md#remove-the-rule-operation-filter) -removes the rule operation filter; unchanged-content suppression and ordered attribution remain. -For events that need attribution, -[`ResolveAuthor`](../../internal/watch/author_resolver.go) calls the attribution index's +Every object event in a selected collection reaches this path; there is no rule operation filter. +Unchanged-content suppression can discard an event before attribution. For events that need +attribution, [`ResolveAuthor`](../../internal/watch/author_resolver.go) calls the attribution index's `Await` method inline. It waits for a resolution or the grace deadline, subject to cancellation. Configured-author mode has no resolver and does not wait. diff --git a/hack/crdfields/fields.yaml b/hack/crdfields/fields.yaml index 09d5f69ed..f6b147bcf 100644 --- a/hack/crdfields/fields.yaml +++ b/hack/crdfields/fields.yaml @@ -151,9 +151,6 @@ kinds: - path: rules.apiVersions default: the preferred served version doc: API versions to match. `["*"]` watches every served version - - path: rules.operations - default: "`CREATE`, `UPDATE`, `DELETE`" - doc: Which operations produce a write - path: rules.sourceNamespace default: the rule's own namespace doc: >- @@ -178,9 +175,6 @@ kinds: - path: rules.apiVersions default: the preferred served version doc: API versions to match. `["*"]` watches every served version - - path: rules.operations - default: "`CREATE`, `UPDATE`, `DELETE`" - doc: Which operations produce a write - kind: CommitRequest title: "`CommitRequest` (namespaced): save now" diff --git a/internal/auditutil/identity.go b/internal/auditutil/identity.go index 9a01aaa5e..3b4b280cb 100644 --- a/internal/auditutil/identity.go +++ b/internal/auditutil/identity.go @@ -9,7 +9,7 @@ import ( "k8s.io/apimachinery/pkg/types" auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" - configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + itypes "github.com/ConfigButler/gitops-reverser/internal/types" ) // AuditObjectIdentity is the (namespace, name, uid) of the object referenced by @@ -38,7 +38,7 @@ type AuditObjectIdentity struct { // // A malformed body is ignored: the helper never panics and never overwrites a // value that was already present on objectRef. -func IdentityFromAuditEvent(event auditv1.Event, op configv1alpha3.OperationType) AuditObjectIdentity { +func IdentityFromAuditEvent(event auditv1.Event, op itypes.OperationType) AuditObjectIdentity { var id AuditObjectIdentity if event.ObjectRef != nil { id.Namespace = event.ObjectRef.Namespace @@ -55,8 +55,8 @@ func IdentityFromAuditEvent(event auditv1.Event, op configv1alpha3.OperationType // bodyPriority returns the (preferred, fallback) audit bodies for op. For // non-delete ops the response body carries the authoritative post-write state; // for deletes the request body carries the authoritative pre-delete state. -func bodyPriority(event auditv1.Event, op configv1alpha3.OperationType) (*runtime.Unknown, *runtime.Unknown) { - if op == configv1alpha3.OperationDelete { +func bodyPriority(event auditv1.Event, op itypes.OperationType) (*runtime.Unknown, *runtime.Unknown) { + if op == itypes.OperationDelete { return event.RequestObject, event.ResponseObject } return event.ResponseObject, event.RequestObject diff --git a/internal/auditutil/identity_test.go b/internal/auditutil/identity_test.go index 3111646cb..99c8bde57 100644 --- a/internal/auditutil/identity_test.go +++ b/internal/auditutil/identity_test.go @@ -12,7 +12,7 @@ import ( "k8s.io/apimachinery/pkg/types" auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" - configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + itypes "github.com/ConfigButler/gitops-reverser/internal/types" ) func rawCommitRequestBody(t *testing.T, namespace, name, uid string) *runtime.Unknown { @@ -46,7 +46,7 @@ func TestIdentityFromAuditEvent_ObjectRefWins(t *testing.T) { } ev.ResponseObject = rawCommitRequestBody(t, "team-a", "save-from-body", "uid-from-body") - got := IdentityFromAuditEvent(ev, configv1alpha3.OperationCreate) + got := IdentityFromAuditEvent(ev, itypes.OperationCreate) assert.Equal(t, "team-a", got.Namespace) assert.Equal(t, "save-1", got.Name, "objectRef.name must win when present") @@ -66,7 +66,7 @@ func TestIdentityFromAuditEvent_BackfillsNameFromResponseObject(t *testing.T) { } ev.ResponseObject = rawCommitRequestBody(t, "team-a", "save-generated-abcde", "uid-resp") - got := IdentityFromAuditEvent(ev, configv1alpha3.OperationCreate) + got := IdentityFromAuditEvent(ev, itypes.OperationCreate) assert.Equal(t, "team-a", got.Namespace) assert.Equal(t, "save-generated-abcde", got.Name, @@ -84,7 +84,7 @@ func TestIdentityFromAuditEvent_BackfillsNamespaceFromBody(t *testing.T) { } ev.ResponseObject = rawCommitRequestBody(t, "team-from-body", "save-x", "uid-x") - got := IdentityFromAuditEvent(ev, configv1alpha3.OperationCreate) + got := IdentityFromAuditEvent(ev, itypes.OperationCreate) assert.Equal(t, "team-from-body", got.Namespace, "missing objectRef.namespace must be backfilled from the audit body") @@ -104,7 +104,7 @@ func TestIdentityFromAuditEvent_FallsBackToRequestObject_NonDelete(t *testing.T) // ResponseObject but must fall back to RequestObject when it is absent. ev.RequestObject = rawCommitRequestBody(t, "team-a", "save-from-request", "uid-req") - got := IdentityFromAuditEvent(ev, configv1alpha3.OperationCreate) + got := IdentityFromAuditEvent(ev, itypes.OperationCreate) assert.Equal(t, "save-from-request", got.Name) assert.Equal(t, types.UID("uid-req"), got.UID) @@ -123,7 +123,7 @@ func TestIdentityFromAuditEvent_DeletePrefersRequestObject(t *testing.T) { ev.RequestObject = rawCommitRequestBody(t, "team-a", "from-request", "uid-req") ev.ResponseObject = rawCommitRequestBody(t, "team-a", "from-response", "uid-resp") - got := IdentityFromAuditEvent(ev, configv1alpha3.OperationDelete) + got := IdentityFromAuditEvent(ev, itypes.OperationDelete) assert.Equal(t, "from-request", got.Name, "delete operations must prefer requestObject for identity") @@ -140,7 +140,7 @@ func TestIdentityFromAuditEvent_NoBodyKeepsObjectRef(t *testing.T) { }, } - got := IdentityFromAuditEvent(ev, configv1alpha3.OperationCreate) + got := IdentityFromAuditEvent(ev, itypes.OperationCreate) assert.Equal(t, "team-a", got.Namespace) assert.Equal(t, "save-1", got.Name) @@ -150,7 +150,7 @@ func TestIdentityFromAuditEvent_NoBodyKeepsObjectRef(t *testing.T) { func TestIdentityFromAuditEvent_NoObjectRefNoBody(t *testing.T) { ev := auditv1.Event{Verb: "create"} - got := IdentityFromAuditEvent(ev, configv1alpha3.OperationCreate) + got := IdentityFromAuditEvent(ev, itypes.OperationCreate) assert.Empty(t, got.Namespace) assert.Empty(t, got.Name) @@ -167,7 +167,7 @@ func TestIdentityFromAuditEvent_IgnoresMalformedBody(t *testing.T) { } ev.ResponseObject = &runtime.Unknown{Raw: []byte("not-json")} - got := IdentityFromAuditEvent(ev, configv1alpha3.OperationCreate) + got := IdentityFromAuditEvent(ev, itypes.OperationCreate) // Should not panic and should keep the objectRef-derived fields. assert.Equal(t, "team-a", got.Namespace) diff --git a/internal/auditutil/objectref.go b/internal/auditutil/objectref.go index 3681e6ca9..640e12b52 100644 --- a/internal/auditutil/objectref.go +++ b/internal/auditutil/objectref.go @@ -9,18 +9,18 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" - configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + itypes "github.com/ConfigButler/gitops-reverser/internal/types" ) // VerbToOperation maps mutating Kubernetes audit verbs to watch operations. -func VerbToOperation(verb string) (configv1alpha3.OperationType, bool) { +func VerbToOperation(verb string) (itypes.OperationType, bool) { switch strings.ToLower(verb) { case "create": - return configv1alpha3.OperationCreate, true + return itypes.OperationCreate, true case "update", "patch": - return configv1alpha3.OperationUpdate, true + return itypes.OperationUpdate, true case "delete", "deletecollection": - return configv1alpha3.OperationDelete, true + return itypes.OperationDelete, true default: return "", false } diff --git a/internal/auditutil/objectref_test.go b/internal/auditutil/objectref_test.go index be8ad037b..9b006c483 100644 --- a/internal/auditutil/objectref_test.go +++ b/internal/auditutil/objectref_test.go @@ -7,7 +7,7 @@ import ( auditv1 "k8s.io/apiserver/pkg/apis/audit/v1" - configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + itypes "github.com/ConfigButler/gitops-reverser/internal/types" ) func TestSplitAPIVersion(t *testing.T) { @@ -36,15 +36,15 @@ func TestSplitAPIVersion(t *testing.T) { func TestVerbToOperation(t *testing.T) { tests := []struct { verb string - op configv1alpha3.OperationType + op itypes.OperationType ok bool }{ - {verb: "create", op: configv1alpha3.OperationCreate, ok: true}, - {verb: "CREATE", op: configv1alpha3.OperationCreate, ok: true}, - {verb: "update", op: configv1alpha3.OperationUpdate, ok: true}, - {verb: "patch", op: configv1alpha3.OperationUpdate, ok: true}, - {verb: "delete", op: configv1alpha3.OperationDelete, ok: true}, - {verb: "deletecollection", op: configv1alpha3.OperationDelete, ok: true}, + {verb: "create", op: itypes.OperationCreate, ok: true}, + {verb: "CREATE", op: itypes.OperationCreate, ok: true}, + {verb: "update", op: itypes.OperationUpdate, ok: true}, + {verb: "patch", op: itypes.OperationUpdate, ok: true}, + {verb: "delete", op: itypes.OperationDelete, ok: true}, + {verb: "deletecollection", op: itypes.OperationDelete, ok: true}, {verb: "get", ok: false}, } diff --git a/internal/authz/source_namespace.go b/internal/authz/source_namespace.go index fdb9f2600..829a1c679 100644 --- a/internal/authz/source_namespace.go +++ b/internal/authz/source_namespace.go @@ -50,7 +50,7 @@ type SourceNamespaceDecision struct { Requested string // Namespaces is the RESOLVED source-namespace set this item watches. It is a single-element // slice in every case: one name, or the EMPTY STRING for a wildcard, which the planner reads as - // the cluster-wide cell. It is meaningful only when the item is admitted. + // the cluster-wide collection. It is meaningful only when the item is admitted. Namespaces []string // Allowed reports whether the item may contribute selections. Allowed bool @@ -188,9 +188,9 @@ func (g *itemGate) decide( base.Allowed = true base.Reason = ReasonSourceNamespaceAllowed if item.IsSourceNamespaceWildcard() { - // The empty namespace IS the cluster-wide cell: openTargetList and openTargetWatch branch on + // The empty namespace IS the cluster-wide collection: openTargetList and openTargetWatch branch on // it, and for a namespaced GVR it is the all-namespaces collection. It is a peer of any - // named-namespace cell on the same type, never a replacement for one. + // named-namespace collection on the same type, never a replacement for one. base.Namespaces = []string{""} base.Message = fmt.Sprintf( "%s: ClusterProvider %q sets spec.allowAnySourceNamespace, so this item watches every "+ diff --git a/internal/authz/source_namespace_test.go b/internal/authz/source_namespace_test.go index f92750ed1..222a0f151 100644 --- a/internal/authz/source_namespace_test.go +++ b/internal/authz/source_namespace_test.go @@ -138,7 +138,7 @@ func TestResolveWatchRuleSourceScope(t *testing.T) { reason: authz.ReasonSourceNamespaceNotAllowed, }, { - name: "the wildcard resolves to the cluster-wide cell", + name: "the wildcard resolves to the cluster-wide collection", items: []string{snWildcard}, delegate: true, admitted: true, @@ -310,7 +310,7 @@ func TestResolvedSourceScope_Fingerprint(t *testing.T) { assert.Equal(t, named.Fingerprint(), same.Fingerprint(), "an unchanged resolution must fingerprint identically, or the table rebuilds forever") assert.NotEqual(t, named.Fingerprint(), wildcard.Fingerprint(), - "a named namespace and the cluster-wide cell are different watches") + "a named namespace and the cluster-wide collection are different watches") } // An empty rule is vacuously authorized. It is reachable through the API only transiently, and a @@ -342,7 +342,7 @@ func TestResolvedSourceScope_NamespacesForIsBoundsChecked(t *testing.T) { } // The aggregate message deduplicates and sorts, so a rule whose items overlap does not report the -// same namespace twice, and the cluster-wide cell is spelled out rather than shown as the empty +// same namespace twice, and the cluster-wide collection is spelled out rather than shown as the empty // string an operator would read as a missing value. func TestResolveWatchRuleSourceScope_AggregateMessageIsDeduplicatedAndLegible(t *testing.T) { reader := snReader(t, snTarget(), snClusterProvider(true)) @@ -355,5 +355,5 @@ func TestResolveWatchRuleSourceScope_AggregateMessageIsDeduplicatedAndLegible(t assert.Equal(t, 1, strings.Count(resolved.Message, snSourceNS), "a namespace two items both name is reported once") assert.Contains(t, resolved.Message, "every namespace (cluster-wide)", - `the cluster-wide cell must be named, not rendered as an empty string`) + `the cluster-wide collection must be named, not rendered as an empty string`) } diff --git a/internal/controller/removed_rule_operations_test.go b/internal/controller/removed_rule_operations_test.go new file mode 100644 index 000000000..344a00eeb --- /dev/null +++ b/internal/controller/removed_rule_operations_test.go @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +// TestRemovedRuleOperations_StaleManifestsAreRejectedOrPruned covers a manifest written before +// rules[].operations was removed from both rule kinds. The field is gone, so it must never be +// mistaken for deletion protection: a Strict request is refused outright, and a Warn or Ignore +// request is accepted with the field pruned, which leaves a rule that observes every event. +// +// Kubernetes decides between the two by the request's field validation, not the CRD, so all three +// modes are asserted rather than the rejection alone. +func TestRemovedRuleOperations_StaleManifestsAreRejectedOrPruned(t *testing.T) { + env := &envtest.Environment{ + CRDDirectoryPaths: []string{"../../config/crd/bases"}, + ErrorIfCRDPathMissing: true, + } + cfg, err := env.Start() + require.NoError(t, err, "start envtest control plane") + t.Cleanup(func() { _ = env.Stop() }) + + c, err := client.New(cfg, client.Options{}) + require.NoError(t, err) + ctx := context.Background() + + staleRule := func(kind, name string) *unstructured.Unstructured { + gitTargetRef := map[string]any{"name": "target"} + metadata := map[string]any{"name": name} + if kind == "ClusterWatchRule" { + gitTargetRef["namespace"] = "default" + } else { + metadata["namespace"] = "default" + } + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "configbutler.ai/v1alpha3", + "kind": kind, + "metadata": metadata, + "spec": map[string]any{ + "gitTargetRef": gitTargetRef, + "rules": []any{map[string]any{ + "operations": []any{"CREATE"}, + "resources": []any{"configmaps"}, + }}, + }, + }} + } + + for _, kind := range []string{"WatchRule", "ClusterWatchRule"} { + t.Run(kind+"/Strict", func(t *testing.T) { + err := c.Create(ctx, staleRule(kind, "strict"), client.FieldValidation("Strict")) + require.Error(t, err) + require.Contains(t, err.Error(), `unknown field "spec.rules[0].operations"`) + }) + + for _, mode := range []string{"Warn", "Ignore"} { + t.Run(kind+"/"+mode, func(t *testing.T) { + name := "lenient-" + strings.ToLower(mode) + obj := staleRule(kind, name) + require.NoError(t, c.Create(ctx, obj, client.FieldValidation(mode))) + + stored := &unstructured.Unstructured{} + stored.SetGroupVersionKind(obj.GroupVersionKind()) + require.NoError(t, c.Get(ctx, client.ObjectKeyFromObject(obj), stored)) + rules, found, err := unstructured.NestedSlice(stored.Object, "spec", "rules") + require.NoError(t, err) + require.True(t, found) + require.Len(t, rules, 1) + rule, ok := rules[0].(map[string]any) + require.True(t, ok) + require.NotContains(t, rule, "operations", "the removed field is pruned, not stored") + require.Equal(t, []any{"configmaps"}, rule["resources"]) + }) + } + } +} diff --git a/internal/git/branch_worker.go b/internal/git/branch_worker.go index aa3b672c5..c7d279ab1 100644 --- a/internal/git/branch_worker.go +++ b/internal/git/branch_worker.go @@ -180,10 +180,10 @@ type BranchWorker struct { // borrowing the repository lock for it would order the two for no reason. refusalTouchMu sync.Mutex lastRefusalTouch map[string]time.Time - // coveredRefusal records, per GitTarget AND WATCHED CELL, the refusal observation the last + // coveredRefusal records, per GitTarget AND WATCHED COLLECTION, the refusal observation the last // empty commit was made for: a digest of the objects that were refused together with the // issues raised about them. A recheck that observes exactly the same thing is already covered - // by that commit, so it is not another trigger. It is dropped when a resync for that cell is + // by that commit, so it is not another trigger. It is dropped when a resync for that collection is // ACCEPTED — see refusalRecovered, which is what makes a live edit re-made after a revert // count as new, and what keeps one watched type's success from speaking for another's. // Guarded by refusalTouchMu with the rate limit, because the two are read together. @@ -593,7 +593,7 @@ func (w *BranchWorker) EnqueueResync(request *ResyncRequest) bool { w.Log.V(1).Info("Resync request coalesced into the one already queued", "resources", len(request.Desired), "gitTarget", request.GitTargetNamespace+"/"+request.GitTargetName, - "sourceCell", sourceCellForLog(request.SourceCell)) + "sourceCollection", sourceCollectionForLog(request.SourceCollection)) return true } else if queued { // A write inside this scope was queued behind the marker. Coalescing here @@ -621,7 +621,7 @@ func (w *BranchWorker) EnqueueResync(request *ResyncRequest) bool { w.Log.V(1).Info("Resync request enqueued", "resources", len(request.Desired), "gitTarget", request.GitTargetNamespace+"/"+request.GitTargetName, - "sourceCell", sourceCellForLog(request.SourceCell)) + "sourceCollection", sourceCollectionForLog(request.SourceCollection)) return true default: w.inflightItems.Add(-1) @@ -630,7 +630,7 @@ func (w *BranchWorker) EnqueueResync(request *ResyncRequest) bool { w.recordQueueDrop(queueDropResync) w.Log.Error(nil, "Event queue full, resync request dropped", "gitTarget", request.GitTargetNamespace+"/"+request.GitTargetName, - "sourceCell", sourceCellForLog(request.SourceCell)) + "sourceCollection", sourceCollectionForLog(request.SourceCollection)) request.reply(ResyncResult{Err: ErrFinalizeQueueFull}) return false } @@ -645,7 +645,7 @@ func (w *BranchWorker) EnqueueResync(request *ResyncRequest) bool { // fields empty, so reading the request alone would silently never match the only path this fence // exists for. // -// The scope match is by object identity, not producing stream, because the cell that produced an +// The scope match is by object identity, not producing stream, because the collection that produced an // event cannot be recovered from it. Over-matching is deliberate: it can forgo a coalesce, never // wrongly permit one. func (w *BranchWorker) markResyncTailForWriteLocked(request *WriteRequest) { @@ -745,7 +745,7 @@ func (w *BranchWorker) enqueueRequest(request *WriteRequest) bool { "events", len(request.Events), "mode", request.CommitMode, "gitTarget", request.GitTargetName, - "sourceCell", sourceCellForLog(request.sourceCell())) + "sourceCollection", sourceCollectionForLog(request.sourceCollection())) // Nothing publishes depth here: the gauge reads inflightItems at scrape // time, so an enqueue is visible to the next scrape whether or not the // loop has woken to notice it. @@ -754,14 +754,14 @@ func (w *BranchWorker) enqueueRequest(request *WriteRequest) bool { w.pendingResyncsMu.Unlock() w.inflightItems.Add(-1) w.recordQueueDrop(queueDropWrite) - // Name the producing cell on a drop. A saturated queue is diagnosed from what was + // Name the producing collection on a drop. A saturated queue is diagnosed from what was // dropped and by whom: the 595-in-16-seconds storm was one GitTarget, and the next - // one may be one CELL of one GitTarget. + // one may be one COLLECTION of one GitTarget. w.Log.Error(nil, "Event queue full, request dropped", "events", len(request.Events), "mode", request.CommitMode, "gitTarget", request.GitTargetName, - "sourceCell", sourceCellForLog(request.sourceCell())) + "sourceCollection", sourceCollectionForLog(request.sourceCollection())) return false } } @@ -985,7 +985,7 @@ type branchWorkerEventLoop struct { // overwrite another's pending work: A queues, B replaces it, B is then suspended, and A's // authorized commit is gone with nothing to report it. Execution still coalesces — one commit // on the branch satisfies every entry due at that moment — but the INTENT is kept per target - // and per watched cell, so one cell recovering cancels only its own obligation. + // and per watched collection, so one collection recovering cancels only its own obligation. refusalPending map[refusalKey]pendingRefusalTouch // attachTimer fires at the earliest pending finalize deadline, so an attached @@ -1229,10 +1229,10 @@ func (l *branchWorkerEventLoop) handleAtomicRequest(request *WriteRequest) { // dropped in both cases. name, namespace := atomicRefusalTarget(request) if isRefusal, refused := l.w.reportPathRefusal( - err, name, namespace, request.sourceCell()); isRefusal { + err, name, namespace, request.sourceCollection()); isRefusal { l.w.recordCommitFailure(commitFailureKindAtomic, commitFailureRefused) l.touchBranchForRefusal(name, namespace, err.Error(), refused, - refusalObservationForEvents(request.Events, refused), request.sourceCell()) + refusalObservationForEvents(request.Events, refused), request.sourceCollection()) } else { l.w.recordCommitFailure(commitFailureKindAtomic, commitFailureError) l.w.Log.Error(err, "Atomic commit failed; dropping request", "events", len(request.Events)) @@ -1456,10 +1456,10 @@ func (l *branchWorkerEventLoop) finalizeOpenWindowWithReason(reason windowFinali // window is dropped either way — the events are already lost to the failed flush, // and the next resync re-derives them. if isRefusal, refused := l.w.reportPathRefusal( - err, targetName, targetNamespace, sourceCellForEvents(events)); isRefusal { + err, targetName, targetNamespace, sourceCollectionForEvents(events)); isRefusal { l.w.recordCommitFailure(commitFailureKindWindow, commitFailureRefused) l.touchBranchForRefusal(targetName, targetNamespace, err.Error(), refused, - refusalObservationForEvents(events, refused), sourceCellForEvents(events)) + refusalObservationForEvents(events, refused), sourceCollectionForEvents(events)) } else { l.w.recordCommitFailure(commitFailureKindWindow, commitFailureError) l.w.Log.Error(err, "Commit failed; dropping open window", diff --git a/internal/git/commit.go b/internal/git/commit.go index e3aa2c963..bb3c8bd63 100644 --- a/internal/git/commit.go +++ b/internal/git/commit.go @@ -61,11 +61,11 @@ func renderReconcileCommitMessage( ResourceVersion: resourceVersion, } if scope != nil { - data.Group = scope.Cell.Group + data.Group = scope.Collection.Group data.Version = scope.Version - data.Resource = scope.Cell.Resource - data.APIVersion = buildAPIVersion(scope.Cell.Group, scope.Version) - data.Namespace = scope.Cell.Namespace + data.Resource = scope.Collection.Resource + data.APIVersion = buildAPIVersion(scope.Collection.Group, scope.Version) + data.Namespace = scope.Collection.Namespace } return renderCommitTemplate("reconcile", config.Message.ReconcileTemplate, data) } diff --git a/internal/git/git_path_refusal.go b/internal/git/git_path_refusal.go index 85c04b2e3..35a9a5183 100644 --- a/internal/git/git_path_refusal.go +++ b/internal/git/git_path_refusal.go @@ -29,7 +29,7 @@ func (w *BranchWorker) normalWritesAllowed(targetName, targetNamespace string) b // why the reason mapping lives there and not here. type PathRefusalReporter func( target itypes.ResourceReference, - cell itypes.CellKey, + collection itypes.CollectionKey, refused *manifestanalyzer.AcceptanceRefusedError, ) @@ -40,7 +40,7 @@ type PathRefusalReporter func( // two are not the same fact. A refusal reported here was found by READING, with nobody writing // and nothing lost yet; one reported through PathRefusalReporter aborted a write that had work in // hand. They also recover differently — see Manager.MarkTargetGitPathScanAccepted — and a single -// hook would have had to carry a cell that a whole-folder scan does not have. +// hook would have had to carry a collection that a whole-folder scan does not have. type ScanAcceptanceReporter func( target itypes.ResourceReference, refused *manifestanalyzer.AcceptanceRefusedError, @@ -73,7 +73,7 @@ func (w *BranchWorker) reportScanAcceptance( // a healthy target elsewhere. // // Recovery is the resync path's job: once the human fixes the Git path, the next successful resync -// for the same cell clears the condition. A live write never clears it, because a live write that +// for the same collection clears the condition. A live write never clears it, because a live write that // happens to avoid the offending file proves nothing about the rest of the subtree. // // The *AcceptanceRefusedError is returned alongside so the caller can decide what the refusal @@ -83,7 +83,7 @@ func (w *BranchWorker) reportPathRefusal( err error, targetName string, targetNamespace string, - cell itypes.CellKey, + collection itypes.CollectionKey, ) (bool, *manifestanalyzer.AcceptanceRefusedError) { var refused *manifestanalyzer.AcceptanceRefusedError if !errors.As(err, &refused) { @@ -98,9 +98,9 @@ func (w *BranchWorker) reportPathRefusal( } target := itypes.NewResourceReference(targetName, targetNamespace) w.Log.Info("Live write refused: unsupported GitTarget path content", - "gitTarget", target.String(), "sourceCell", sourceCellForLog(cell), "detail", refused.Error()) + "gitTarget", target.String(), "sourceCollection", sourceCollectionForLog(collection), "detail", refused.Error()) if w.pathRefusal != nil { - w.pathRefusal(target, cell, refused) + w.pathRefusal(target, collection, refused) } return true, refused } diff --git a/internal/git/git_path_refusal_test.go b/internal/git/git_path_refusal_test.go index 5b38f1493..25a290950 100644 --- a/internal/git/git_path_refusal_test.go +++ b/internal/git/git_path_refusal_test.go @@ -30,37 +30,37 @@ func refusedError() error { // captureRefusals installs a recording reporter on a bare worker. type capturedRefusal struct { - target types.ResourceReference - cell types.CellKey + target types.ResourceReference + collection types.CollectionKey } func captureRefusals(w *BranchWorker) *[]capturedRefusal { seen := &[]capturedRefusal{} w.pathRefusal = func( target types.ResourceReference, - cell types.CellKey, + collection types.CollectionKey, _ *manifestanalyzer.AcceptanceRefusedError, ) { - *seen = append(*seen, capturedRefusal{target: target, cell: cell}) + *seen = append(*seen, capturedRefusal{target: target, collection: collection}) } return seen } -func TestReportPathRefusal_ReportsAttributedRefusalWithSourceCell(t *testing.T) { +func TestReportPathRefusal_ReportsAttributedRefusalWithSourceCollection(t *testing.T) { w := &BranchWorker{Log: logr.Discard()} seen := &[]types.ResourceReference{} - cell := types.CellKeyFor(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, "apps") + collection := types.CollectionKeyFor(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, "apps") w.pathRefusal = func( target types.ResourceReference, - gotCell types.CellKey, + gotCollection types.CollectionKey, _ *manifestanalyzer.AcceptanceRefusedError, ) { *seen = append(*seen, target) - assert.Equal(t, cell, gotCell) + assert.Equal(t, collection, gotCollection) } - isRefusal, refused := w.reportPathRefusal(refusedError(), "podinfo-test", "team-a", cell) + isRefusal, refused := w.reportPathRefusal(refusedError(), "podinfo-test", "team-a", collection) assert.True(t, isRefusal) assert.NotNil(t, refused, "a refusal must hand its issues back for the caller to act on") require.Len(t, *seen, 1) @@ -73,7 +73,8 @@ func TestReportPathRefusal_PassesThroughNonRefusal(t *testing.T) { w := &BranchWorker{Log: logr.Discard()} seen := captureRefusals(w) - isRefusal, refused := w.reportPathRefusal(errors.New("remote hung up"), "podinfo-test", "team-a", types.CellKey{}) + isRefusal, refused := w.reportPathRefusal( + errors.New("remote hung up"), "podinfo-test", "team-a", types.CollectionKey{}) assert.False(t, isRefusal) assert.Nil(t, refused) assert.Empty(t, *seen, "a transient write fault must not be reported as a Git path refusal") @@ -92,7 +93,7 @@ func TestReportPathRefusal_UnattributableRefusalIsNotRecorded(t *testing.T) { w := &BranchWorker{Log: logr.Discard()} seen := captureRefusals(w) - gotRefusal, _ := w.reportPathRefusal(refusedError(), c.name, c.ns, types.CellKey{}) + gotRefusal, _ := w.reportPathRefusal(refusedError(), c.name, c.ns, types.CollectionKey{}) assert.True(t, gotRefusal, "an unattributable refusal is still a refusal, not a write fault") assert.Empty(t, *seen, diff --git a/internal/git/refusal_observation_test.go b/internal/git/refusal_observation_test.go index 4a1135d2a..17ab68e94 100644 --- a/internal/git/refusal_observation_test.go +++ b/internal/git/refusal_observation_test.go @@ -92,7 +92,7 @@ func refuseResync(t *testing.T, f *ledgerFixture, l *branchWorkerEventLoop, imag } // deploymentResyncScope is the per-type reconcile a refused Deployment arrives on. Every refusal -// in these tests is filed under a cell, because that is what the real path carries and what the +// in these tests is filed under a collection, because that is what the real path carries and what the // dedupe is keyed by. func deploymentResyncScope() ResyncScope { return ResyncScopeFor( @@ -205,7 +205,7 @@ func TestRefusalObservation_NothingIsCoveredUntilACommitIsMade(t *testing.T) { "and it must not overwrite what is covered") } -// TestRefusalObservation_RecoveryIsScopedToTheCellThatRecovered is the recovery half, and the +// TestRefusalObservation_RecoveryIsScopedToTheCollectionThatRecovered is the recovery half, and the // regression a review found in it. // // Without any recovery the fence would be a permanent mute: a user re-making the same live edit @@ -214,7 +214,7 @@ func TestRefusalObservation_NothingIsCoveredUntilACommitIsMade(t *testing.T) { // the loop coming back by another door: a GitTarget watches several types, a no-op ConfigMap // resync succeeds every time it runs, and the still-refused Deployment would be re-armed by every // one of them. A successful evaluation may only speak for what it evaluated. -func TestRefusalObservation_RecoveryIsScopedToTheCellThatRecovered(t *testing.T) { +func TestRefusalObservation_RecoveryIsScopedToTheCollectionThatRecovered(t *testing.T) { w := refusalTouchWorker(t, configv1alpha3.GitTargetSpec{ OnRefusal: configv1alpha3.RefusalActionPushEmptyCommit, }) @@ -226,32 +226,32 @@ func TestRefusalObservation_RecoveryIsScopedToTheCellThatRecovered(t *testing.T) w.recordRefusalObservation(deployments, "observation-1") w.recordRefusalObservation(configMaps, "observation-2") - loop.refusalRecovered(editingRef(), configMaps.cell) + loop.refusalRecovered(editingRef(), configMaps.collection) assert.True(t, w.refusalAlreadyCovered(deployments, "observation-1"), "one watched type succeeding is no evidence about another that is still refused") assert.False(t, w.refusalAlreadyCovered(configMaps, "observation-2"), - "the cell that was accepted has nothing outstanding") + "the collection that was accepted has nothing outstanding") - loop.refusalRecovered(editingRef(), deployments.cell) + loop.refusalRecovered(editingRef(), deployments.collection) assert.False(t, w.refusalAlreadyCovered(deployments, "observation-1"), - "and once its own cell is accepted, the next refusal there is new again") + "and once its own collection is accepted, the next refusal there is new again") - // The ZERO cell is a whole-GitTarget evaluation, which does speak for every cell it holds. + // The ZERO collection is a whole-GitTarget evaluation, which does speak for every collection it holds. w.recordRefusalObservation(deployments, "observation-1") w.recordRefusalObservation(configMaps, "observation-2") - loop.refusalRecovered(editingRef(), itypes.CellKey{}) + loop.refusalRecovered(editingRef(), itypes.CollectionKey{}) assert.False(t, w.refusalAlreadyCovered(deployments, "observation-1")) assert.False(t, w.refusalAlreadyCovered(configMaps, "observation-2")) } -// TestRefusalObservation_RecoveryCancelsTheQueuedCommitForThatCell is the other half of the same +// TestRefusalObservation_RecoveryCancelsTheQueuedCommitForThatCollection is the other half of the same // regression. // -// Dropping the digest is not enough: a commit already queued for that cell finds nothing covering +// Dropping the digest is not enough: a commit already queued for that collection finds nothing covering // it when its timer fires and moves the branch anyway, for a refusal that has since been accepted. -// The obligation has to go with the memory — and only that cell's obligation. -func TestRefusalObservation_RecoveryCancelsTheQueuedCommitForThatCell(t *testing.T) { +// The obligation has to go with the memory — and only that collection's obligation. +func TestRefusalObservation_RecoveryCancelsTheQueuedCommitForThatCollection(t *testing.T) { w := refusalTouchWorker(t, configv1alpha3.GitTargetSpec{ OnRefusal: configv1alpha3.RefusalActionPushEmptyCommit, }) @@ -264,12 +264,12 @@ func TestRefusalObservation_RecoveryCancelsTheQueuedCommitForThatCell(t *testing loop.armTrailingRefusalTouch(configMaps, "the configmap was refused", "observation-2", 0) require.Len(t, loop.refusalPending, 2) - loop.refusalRecovered(editingRef(), deployments.cell) + loop.refusalRecovered(editingRef(), deployments.collection) assert.NotContains(t, loop.refusalPending, deployments, "a queued commit must not survive the acceptance of the object it was queued for") assert.Contains(t, loop.refusalPending, configMaps, - "and the cell that is still refused keeps its obligation") + "and the collection that is still refused keeps its obligation") // Nothing was committed for the cancelled entry, so it must not have spent the window either. limited, _ := w.refusalRateLimited(editingRef()) @@ -374,7 +374,7 @@ func TestRefusalTouch_AnAcceptedSiblingTypeDoesNotRearmAStandingRefusal(t *testi refuseResync(t, f, l, "ghcr.io/example/podinfo:9.9.9") require.Equal(t, before+1, remoteCommits(t, f), "the first refusal earns its commit") - // A different watched cell, with nothing to write and nothing to refuse. + // A different watched collection, with nothing to write and nothing to refuse. configMaps := ResyncScopeFor(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, "default") result := make(chan ResyncResult, 1) l.applyResync(&ResyncRequest{ @@ -495,7 +495,7 @@ func overlayRefusalFixture(t *testing.T, slug string) *ledgerFixture { return f } -// TestRefusalTouch_RecoveryCancelsACommitQueuedForTheSameCell is the handler-level regression for +// TestRefusalTouch_RecoveryCancelsACommitQueuedForTheSameCollection is the handler-level regression for // recovery: the same transition as the focused state test, but driven through the real applyResync // hook against a real Git remote, so it holds the hook's PLACEMENT and not only the state it // writes. It is the case a review reproduced. @@ -504,7 +504,7 @@ func overlayRefusalFixture(t *testing.T, slug string) *ledgerFixture { // second edit inside the rate-limit window queues a trailing commit, and then the object is put // back to what the folder renders and is accepted. The queued commit is now asking the reconciler // to re-apply on account of a refusal that no longer exists, so it must not fire. -func TestRefusalTouch_RecoveryCancelsACommitQueuedForTheSameCell(t *testing.T) { +func TestRefusalTouch_RecoveryCancelsACommitQueuedForTheSameCollection(t *testing.T) { f := overlayRefusalFixture(t, "recovery-cancels-queued") l := newBranchWorkerEventLoop(f.worker, time.Hour) t.Cleanup(l.stopTimers) @@ -513,8 +513,8 @@ func TestRefusalTouch_RecoveryCancelsACommitQueuedForTheSameCell(t *testing.T) { scope := ResyncScopeFor( schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}, "production") key := refusalKey{ - target: itypes.NewResourceReference(ledgerTargetName, "default"), - cell: scope.Cell, + target: itypes.NewResourceReference(ledgerTargetName, "default"), + collection: scope.Collection, } // observe mirrors one per-type reconcile. An empty logLevel is the object exactly as the // folder renders it; anything else is a base-owned field edit with nowhere to land. diff --git a/internal/git/refusal_touch.go b/internal/git/refusal_touch.go index 746688666..73c1abb6c 100644 --- a/internal/git/refusal_touch.go +++ b/internal/git/refusal_touch.go @@ -31,25 +31,25 @@ import ( // a second apart are served by one commit, and the reconcile it triggers covers both. const refusalTouchInterval = time.Minute -// refusalKey identifies ONE unresolved refusal: the GitTarget that owns it, and the watched cell +// refusalKey identifies ONE unresolved refusal: the GitTarget that owns it, and the watched collection // it is about. // -// The cell is in the key because a review found what a target-wide key gets wrong in both +// The collection is in the key because a review found what a target-wide key gets wrong in both // directions. A target watches several types; a successful ConfigMap resync says nothing about a // Deployment that is still refused, so clearing the whole target on it re-armed the standing -// refusal and the commit loop came back. And a queued commit for one cell must survive another -// cell recovering. This is the same granularity the GitPathAccepted condition already uses — the -// watch layer blocks and clears per cell — so the memory and the status now agree about what is +// refusal and the commit loop came back. And a queued commit for one collection must survive another +// collection recovering. This is the same granularity the GitPathAccepted condition already uses — the +// watch layer blocks and clears per collection — so the memory and the status now agree about what is // still refused. // -// The zero cell is the whole-GitTarget evaluation, which speaks for everything the target holds. +// The zero collection is the whole-GitTarget evaluation, which speaks for everything the target holds. type refusalKey struct { - target itypes.ResourceReference - cell itypes.CellKey + target itypes.ResourceReference + collection itypes.CollectionKey } func (k refusalKey) String() string { - return k.target.String() + " " + sourceCellForLog(k.cell) + return k.target.String() + " " + sourceCollectionForLog(k.collection) } // executeRefusalTouch creates the empty commit. The tree is untouched on purpose, so @@ -185,7 +185,7 @@ func (l *branchWorkerEventLoop) touchBranchForRefusal( targetName, targetNamespace, detail string, refused *manifestanalyzer.AcceptanceRefusedError, observation string, - cell itypes.CellKey, + collection itypes.CollectionKey, ) { if targetName == "" || targetNamespace == "" { return @@ -199,15 +199,15 @@ func (l *branchWorkerEventLoop) touchBranchForRefusal( if !l.w.refusalConsent(l.w.ctx, target) { return } - key := refusalKey{target: target, cell: cell} + key := refusalKey{target: target, collection: collection} - // An observation this cell's last commit already covered is not a new trigger. Checked here, + // An observation this collection's last commit already covered is not a new trigger. Checked here, // after consent and before the rate limit, so a recheck of unchanged input neither commits nor // arms the trailing timer: arming it would turn the rate limit into a schedule, which is the // loop this fence exists to close. if l.w.refusalAlreadyCovered(key, observation) { l.w.Log.V(1).Info("Refusal unchanged since the last empty commit; not committing again", - "gitTarget", target.String(), "sourceCell", sourceCellForLog(cell), "branch", l.w.Branch) + "gitTarget", target.String(), "sourceCollection", sourceCollectionForLog(collection), "branch", l.w.Branch) return } @@ -318,7 +318,7 @@ func (w *BranchWorker) recordRefusalObservation(key refusalKey, observation stri w.coveredRefusal[key] = observation } -// refusalRecovered records that an evaluation for this target and cell was ACCEPTED, so whatever +// refusalRecovered records that an evaluation for this target and collection was ACCEPTED, so whatever // refusal was outstanding there is over: the memory of what the last commit covered is dropped, // and any commit still queued for it is cancelled. // @@ -333,26 +333,26 @@ func (w *BranchWorker) recordRefusalObservation(key refusalKey, observation stri // cannot tell the second from the first, so only the acceptance in between can. Cancelling the // queued commit is the other half: an obligation whose object has since been accepted has nothing // left to ask for, and firing it anyway would move the branch for a refusal that no longer exists. -func (l *branchWorkerEventLoop) refusalRecovered(target itypes.ResourceReference, cell itypes.CellKey) { +func (l *branchWorkerEventLoop) refusalRecovered(target itypes.ResourceReference, collection itypes.CollectionKey) { if target.Name == "" || target.Namespace == "" { return } - l.w.clearRefusalObservations(target, cell) + l.w.clearRefusalObservations(target, collection) for _, key := range sortedRefusalKeys(l.refusalPending) { - if key.target == target && (cell == (itypes.CellKey{}) || key.cell == cell) { + if key.target == target && (collection == (itypes.CollectionKey{}) || key.collection == collection) { delete(l.refusalPending, key) } } l.rearmRefusalTimer() } -// clearRefusalObservations forgets what this cell's last empty commit covered. The ZERO cell is a -// whole-GitTarget evaluation, which does speak for every cell, so it clears all of them. -func (w *BranchWorker) clearRefusalObservations(target itypes.ResourceReference, cell itypes.CellKey) { +// clearRefusalObservations forgets what this collection's last empty commit covered. The ZERO collection is a +// whole-GitTarget evaluation, which does speak for every collection, so it clears all of them. +func (w *BranchWorker) clearRefusalObservations(target itypes.ResourceReference, collection itypes.CollectionKey) { w.refusalTouchMu.Lock() defer w.refusalTouchMu.Unlock() - if cell != (itypes.CellKey{}) { - delete(w.coveredRefusal, refusalKey{target: target, cell: cell}) + if collection != (itypes.CollectionKey{}) { + delete(w.coveredRefusal, refusalKey{target: target, collection: collection}) return } for key := range w.coveredRefusal { @@ -427,9 +427,9 @@ func (l *branchWorkerEventLoop) flushPendingRefusalTouch() { if !l.w.refusalConsent(l.w.ctx, entry.key.target) { continue } - // Re-checked here as well as on arrival: another commit for this cell may have been made + // Re-checked here as well as on arrival: another commit for this collection may have been made // while this entry waited, and if it covered the same observation this one has nothing - // left to ask for. An entry whose cell RECOVERED in the meantime is not here at all — + // left to ask for. An entry whose collection RECOVERED in the meantime is not here at all — // refusalRecovered removed it. if l.w.refusalAlreadyCovered(entry.key, entry.observation) { continue @@ -505,7 +505,7 @@ func (l *branchWorkerEventLoop) commitRefusalTouch(key refusalKey, detail, obser // Every other refusal means the folder is unusable. An empty commit cannot fix a folder, only a // human can, and the reconciler may be mid-way through its own corrections there, so those are // left alone. They also reach a different reporter entirely: a folder-level refusal is normally -// found by the per-type reconcile, which blocks the cell through the event router rather than +// found by the per-type reconcile, which blocks the collection through the event router rather than // coming through here at all. func refusalIsAWriteBoundary(refused *manifestanalyzer.AcceptanceRefusedError) bool { if refused == nil || !refused.AllIssuesOfKinds( diff --git a/internal/git/refusal_touch_test.go b/internal/git/refusal_touch_test.go index b7d3f4fab..dd49d85b7 100644 --- a/internal/git/refusal_touch_test.go +++ b/internal/git/refusal_touch_test.go @@ -61,10 +61,10 @@ func editingRef() itypes.ResourceReference { return itypes.NewResourceReference("editing", "team-a") } -// refusalKeyFor is the (target, cell) a refusal is filed under. Most tests care about one cell and +// refusalKeyFor is the (target, collection) a refusal is filed under. Most tests care about one collection and // name it here so the key they arm is the key they assert on. func refusalKeyFor(target itypes.ResourceReference, resource string) refusalKey { - return refusalKey{target: target, cell: itypes.CellKey{Resource: resource, Namespace: target.Namespace}} + return refusalKey{target: target, collection: itypes.CollectionKey{Resource: resource, Namespace: target.Namespace}} } func editingKey() refusalKey { return refusalKeyFor(editingRef(), "deployments") } diff --git a/internal/git/render_fidelity_gate.go b/internal/git/render_fidelity_gate.go index 1e81ee5b0..6998c6e11 100644 --- a/internal/git/render_fidelity_gate.go +++ b/internal/git/render_fidelity_gate.go @@ -39,7 +39,7 @@ type RenderFidelityStatus struct { } // pendingScopeSampleLimit bounds how many pending scopes the condition message names. A target -// can watch many cells, and a condition message is read by humans and matched by tests; the count +// can watch many collections, and a condition message is read by humans and matched by tests; the count // in front of the list stays exact whatever the list is truncated to. const pendingScopeSampleLimit = 5 @@ -64,7 +64,7 @@ type renderFidelityTargetState struct { // because that is the cheapest place to keep a monotonic source; what a report is judged // against is the SCOPE's revision. revision uint64 - scopes map[types.CellKey]renderFidelityScopeResult + scopes map[types.CollectionKey]renderFidelityScopeResult // awaitingFreshMeasurement says that everything this target has proved describes a repository // it no longer writes to, so nothing it holds may admit a write. Only a COMPLETE fresh // measurement clears it, which is the rule writeDivergence already lives by. @@ -81,10 +81,10 @@ type renderFidelityTargetState struct { // machine. A restarted scope closes writes until it reports clean again. A single divergence // latches False for that scope's revision; a later success from another scope cannot reopen it. // -// Revisions are PER SCOPE rather than per target, because the watch plan is applied per cell: -// a cell whose stream is left running across a plan change keeps its result and its revision, -// and only the cells that were started or restarted go back to pending. A target-wide epoch -// would have marked every cell pending on every plan edit — closing writes on a target whose +// Revisions are PER SCOPE rather than per target, because the watch plan is applied per collection: +// a collection whose stream is left running across a plan change keeps its result and its revision, +// and only the collections that were started or restarted go back to pending. A target-wide epoch +// would have marked every collection pending on every plan edit — closing writes on a target whose // streams never moved — and would have cleared a divergence that nothing re-measured. type RenderFidelityGate struct { mu sync.RWMutex @@ -100,7 +100,7 @@ func NewRenderFidelityGate() *RenderFidelityGate { // Reconcile installs target's current scope set and returns the revision every scope must // report under, alongside the resulting status. // -// A scope is one independently replayed target-watch cell, so the namespace is part of it: a +// A scope is one independently replayed target-watch collection, so the namespace is part of it: a // GitTarget can watch one type in more than one namespace, and each reports its own result. // // - a scope in restarted is given a FRESH revision and goes back to pending; @@ -116,21 +116,21 @@ func NewRenderFidelityGate() *RenderFidelityGate { // It returns Unknown while scopes are pending, or True for the vacuous zero-scope case. func (g *RenderFidelityGate) Reconcile( target types.ResourceReference, - scopes []types.CellKey, - restarted []types.CellKey, -) (RenderFidelityStatus, map[types.CellKey]uint64) { + scopes []types.CollectionKey, + restarted []types.CollectionKey, +) (RenderFidelityStatus, map[types.CollectionKey]uint64) { g.mu.Lock() defer g.mu.Unlock() if g.targets == nil { g.targets = map[string]renderFidelityTargetState{} } - restart := make(map[types.CellKey]struct{}, len(restarted)) + restart := make(map[types.CollectionKey]struct{}, len(restarted)) for _, scope := range restarted { restart[scope] = struct{}{} } state := g.targets[target.Key()] - next := make(map[types.CellKey]renderFidelityScopeResult, len(scopes)) - revisions := make(map[types.CellKey]uint64, len(scopes)) + next := make(map[types.CollectionKey]renderFidelityScopeResult, len(scopes)) + revisions := make(map[types.CollectionKey]uint64, len(scopes)) fresh := 0 for _, scope := range scopes { result, carried := state.scopes[scope] @@ -169,7 +169,7 @@ func (g *RenderFidelityGate) Reconcile( func (g *RenderFidelityGate) RecordScopeClean( target types.ResourceReference, revision uint64, - scope types.CellKey, + scope types.CollectionKey, ) (RenderFidelityStatus, bool) { return g.recordScope(target, revision, scope, nil) } @@ -179,7 +179,7 @@ func (g *RenderFidelityGate) RecordScopeClean( func (g *RenderFidelityGate) RecordScopeDivergence( target types.ResourceReference, revision uint64, - scope types.CellKey, + scope types.CollectionKey, divergence manifestanalyzer.RenderDivergence, ) (RenderFidelityStatus, bool) { return g.recordScope(target, revision, scope, &divergence) @@ -188,7 +188,7 @@ func (g *RenderFidelityGate) RecordScopeDivergence( func (g *RenderFidelityGate) recordScope( target types.ResourceReference, revision uint64, - scope types.CellKey, + scope types.CollectionKey, divergence *manifestanalyzer.RenderDivergence, ) (RenderFidelityStatus, bool) { g.mu.Lock() @@ -282,7 +282,7 @@ func (g *RenderFidelityGate) Invalidate(target types.ResourceReference) { g.targets = map[string]renderFidelityTargetState{} } state := g.targets[target.Key()] - pending := make(map[types.CellKey]renderFidelityScopeResult, len(state.scopes)) + pending := make(map[types.CollectionKey]renderFidelityScopeResult, len(state.scopes)) for scope := range state.scopes { state.revision++ pending[scope] = renderFidelityScopeResult{revision: state.revision} @@ -314,7 +314,7 @@ func reduceRenderFidelity(state renderFidelityTargetState) RenderFidelityStatus if state.writeDivergence != nil { return renderFidelityDivergedStatus(state, *state.writeDivergence, countCleanScopes(state)) } - scopes := make([]types.CellKey, 0, len(state.scopes)) + scopes := make([]types.CollectionKey, 0, len(state.scopes)) for scope := range state.scopes { scopes = append(scopes, scope) } @@ -355,7 +355,7 @@ func reduceRenderFidelity(state renderFidelityTargetState) RenderFidelityStatus // The revision is part of the answer, not decoration. The failure mode this diagnoses is a scope // holding a revision that no running stream will ever report under, so "which revision" is // exactly what separates "still replaying" from "stuck for ever". -func pendingScopesMessage(state renderFidelityTargetState, scopes []types.CellKey, clean int) string { +func pendingScopesMessage(state renderFidelityTargetState, scopes []types.CollectionKey, clean int) string { names := make([]string, 0, len(scopes)) for _, scope := range scopes { result := state.scopes[scope] diff --git a/internal/git/render_fidelity_gate_test.go b/internal/git/render_fidelity_gate_test.go index d825e127d..17e013082 100644 --- a/internal/git/render_fidelity_gate_test.go +++ b/internal/git/render_fidelity_gate_test.go @@ -114,28 +114,28 @@ func TestRenderFidelityGate_PendingScopeCannotOpenLiveWindow(t *testing.T) { } // restartAll is the whole-plan reconcile: every scope is restarted, which is what a forced -// recheck does and what every declaration did before the plan was diffed per cell. +// recheck does and what every declaration did before the plan was diffed per collection. func restartAll( gate *RenderFidelityGate, target types.ResourceReference, - scopes ...types.CellKey, -) (RenderFidelityStatus, map[types.CellKey]uint64) { + scopes ...types.CollectionKey, +) (RenderFidelityStatus, map[types.CollectionKey]uint64) { return gate.Reconcile(target, scopes, scopes) } -func fidelityScope(group, resource string) types.CellKey { - return types.CellKeyFor( +func fidelityScope(group, resource string) types.CollectionKey { + return types.CollectionKeyFor( schema.GroupVersionResource{Group: group, Version: "v1", Resource: resource}, "default") } -// The scope set is keyed by cell, so a stream that reports its result after a served-version +// The scope set is keyed by collection, so a stream that reports its result after a served-version // bump still lands on the scope its revision was issued for. Keying on the full GVR left the result // matching nothing, and the target stayed Unknown — writes closed — until the next restart. func TestRenderFidelityGate_ScopeIdentityIgnoresServedVersion(t *testing.T) { gate := NewRenderFidelityGate() target := types.ResourceReference{Namespace: "default", Name: "app"} - deployments := func(version string) types.CellKey { - return types.CellKeyFor( + deployments := func(version string) types.CollectionKey { + return types.CollectionKeyFor( schema.GroupVersionResource{Group: "apps", Version: version, Resource: "deployments"}, "team-a") } @@ -143,12 +143,12 @@ func TestRenderFidelityGate_ScopeIdentityIgnoresServedVersion(t *testing.T) { require.Equal(t, RenderFidelityUnknown, status.State) status, applied := gate.RecordScopeClean(target, revisions[deployments("v1")], deployments("v1beta1")) - require.True(t, applied, "the same cell, observed at another served version, is the same scope") + require.True(t, applied, "the same collection, observed at another served version, is the same scope") assert.Equal(t, RenderFidelityTrue, status.State) } -// The point of a per-scope revision: a plan change that leaves a cell's stream running must not -// ask that cell to prove itself again. A target-wide epoch closed writes on every plan edit. +// The point of a per-scope revision: a plan change that leaves a collection's stream running must not +// ask that collection to prove itself again. A target-wide epoch closed writes on every plan edit. func TestRenderFidelityGate_KeptScopeKeepsItsResult(t *testing.T) { gate := NewRenderFidelityGate() target := types.NewResourceReference("apps", "default") @@ -160,16 +160,16 @@ func TestRenderFidelityGate_KeptScopeKeepsItsResult(t *testing.T) { require.True(t, applied) require.True(t, gate.AllowsWrites(target)) - // A second cell joins the plan. Only it is restarted. + // A second collection joins the plan. Only it is restarted. status, next := gate.Reconcile(target, - []types.CellKey{deployment, configMap}, []types.CellKey{configMap}) - assert.Equal(t, RenderFidelityUnknown, status.State, "the new cell has not replayed yet") - assert.Equal(t, revisions[deployment], next[deployment], "a kept cell keeps its revision") + []types.CollectionKey{deployment, configMap}, []types.CollectionKey{configMap}) + assert.Equal(t, RenderFidelityUnknown, status.State, "the new collection has not replayed yet") + assert.Equal(t, revisions[deployment], next[deployment], "a kept collection keeps its revision") status, applied = gate.RecordScopeClean(target, next[configMap], configMap) require.True(t, applied) assert.Equal(t, RenderFidelityTrue, status.State, - "the kept cell's earlier result still counts, so one replay is enough") + "the kept collection's earlier result still counts, so one replay is enough") } // A target held open by a divergence stays held: adding a WatchRule is no evidence that the @@ -186,15 +186,15 @@ func TestRenderFidelityGate_UnrelatedPlanChangeCannotClearADivergence(t *testing require.True(t, applied) status, next := gate.Reconcile(target, - []types.CellKey{deployment, configMap}, []types.CellKey{configMap}) + []types.CollectionKey{deployment, configMap}, []types.CollectionKey{configMap}) assert.Equal(t, RenderFidelityFalse, status.State) _, applied = gate.RecordScopeClean(target, next[configMap], configMap) require.True(t, applied) assert.Equal(t, RenderFidelityFalse, gate.Status(target).State, - "only a replay of the DIVERGENT cell may clear it") + "only a replay of the DIVERGENT collection may clear it") status, reopened := gate.Reconcile(target, - []types.CellKey{deployment, configMap}, []types.CellKey{deployment}) + []types.CollectionKey{deployment, configMap}, []types.CollectionKey{deployment}) assert.Equal(t, RenderFidelityUnknown, status.State) _, applied = gate.RecordScopeClean(target, reopened[deployment], deployment) require.True(t, applied) @@ -208,7 +208,7 @@ func TestRenderFidelityGate_WriteDivergenceClearsOnlyOnAFullRestart(t *testing.T target := types.NewResourceReference("apps", "default") deployment := fidelityScope("apps", "deployments") configMap := fidelityScope("", "configmaps") - scopes := []types.CellKey{deployment, configMap} + scopes := []types.CollectionKey{deployment, configMap} _, revisions := restartAll(gate, target, scopes...) for _, scope := range scopes { @@ -218,11 +218,11 @@ func TestRenderFidelityGate_WriteDivergenceClearsOnlyOnAFullRestart(t *testing.T gate.Fail(target, manifestanalyzer.RenderDivergence{Field: "data.region", Token: "${REGION}"}) require.False(t, gate.AllowsWrites(target)) - _, partial := gate.Reconcile(target, scopes, []types.CellKey{deployment}) + _, partial := gate.Reconcile(target, scopes, []types.CollectionKey{deployment}) _, applied := gate.RecordScopeClean(target, partial[deployment], deployment) require.True(t, applied) assert.Equal(t, RenderFidelityFalse, gate.Status(target).State, - "restarting one cell is not a fresh measurement of the target") + "restarting one collection is not a fresh measurement of the target") status, full := restartAll(gate, target, scopes...) assert.Equal(t, RenderFidelityUnknown, status.State, "the write divergence is cleared, not the pending scopes") @@ -233,7 +233,7 @@ func TestRenderFidelityGate_WriteDivergenceClearsOnlyOnAFullRestart(t *testing.T assert.True(t, gate.AllowsWrites(target)) } -// A cell that left the plan is gone: its stream's tail cannot report into it, and its absence +// A collection that left the plan is gone: its stream's tail cannot report into it, and its absence // cannot hold the target Unknown. func TestRenderFidelityGate_DroppedScopeStopsCounting(t *testing.T) { gate := NewRenderFidelityGate() @@ -246,11 +246,11 @@ func TestRenderFidelityGate_DroppedScopeStopsCounting(t *testing.T) { require.True(t, applied) require.Equal(t, RenderFidelityUnknown, gate.Status(target).State) - status, _ := gate.Reconcile(target, []types.CellKey{deployment}, nil) - assert.Equal(t, RenderFidelityTrue, status.State, "the pending cell left the plan with its stream") + status, _ := gate.Reconcile(target, []types.CollectionKey{deployment}, nil) + assert.Equal(t, RenderFidelityTrue, status.State, "the pending collection left the plan with its stream") _, applied = gate.RecordScopeClean(target, revisions[configMap], configMap) - assert.False(t, applied, "a dropped cell's tail reports into nothing") + assert.False(t, applied, "a dropped collection's tail reports into nothing") } // A plan that selects nothing restarts every scope only vacuously, which is the absence of a @@ -305,13 +305,13 @@ func TestRenderFidelityGate_PendingMessageNamesTheScope(t *testing.T) { assert.Contains(t, status.Message, fmt.Sprintf("revision %d", revisions[configMap])) } -// TestRenderFidelityGate_PendingMessageIsBounded keeps an unbounded cell set from producing an +// TestRenderFidelityGate_PendingMessageIsBounded keeps an unbounded collection set from producing an // unbounded condition message, while keeping the COUNT exact — a truncated list that also // truncated the count would understate how far from converged the target is. func TestRenderFidelityGate_PendingMessageIsBounded(t *testing.T) { gate := NewRenderFidelityGate() target := types.NewResourceReference("apps", "default") - scopes := make([]types.CellKey, 0, pendingScopeSampleLimit+3) + scopes := make([]types.CollectionKey, 0, pendingScopeSampleLimit+3) for i := range pendingScopeSampleLimit + 3 { scopes = append(scopes, fidelityScope("apps", fmt.Sprintf("kind%02d", i))) } diff --git a/internal/git/resync_flush.go b/internal/git/resync_flush.go index b4e1d4400..4487b01f1 100644 --- a/internal/git/resync_flush.go +++ b/internal/git/resync_flush.go @@ -175,15 +175,15 @@ func (l *branchWorkerEventLoop) applyResync(req *ResyncRequest) { if err := l.w.commitPendingWrites([]PendingWrite{*pendingWrite}, len(l.pendingWrites) > 0); err != nil { // A refusal reaches the caller on ResyncResult.Err, where the watch layer classifies it - // and blocks the cell. spec.onRefusal needs it HERE too, and this is the path that + // and blocks the collection. spec.onRefusal needs it HERE too, and this is the path that // matters: a per-type reconcile evaluates the same objects a live write would, so it is // normally what discovers a write-boundary refusal first. Hooking only the live-event path - // left the action almost unreachable, because by the time an edit arrives the cell this + // left the action almost unreachable, because by the time an edit arrives the collection this // refusal blocks is already blocked. var refused *manifestanalyzer.AcceptanceRefusedError if errors.As(err, &refused) { l.touchBranchForRefusal(req.GitTargetName, req.GitTargetNamespace, err.Error(), refused, - refusalObservationForDesired(req.Desired, refused), req.refusalCell()) + refusalObservationForDesired(req.Desired, refused), req.refusalCollection()) } l.w.Log.Error(err, "Resync commit failed; dropping request", "resources", len(req.Desired)) req.reply(ResyncResult{Err: err}) @@ -194,10 +194,10 @@ func (l *branchWorkerEventLoop) applyResync(req *ResyncRequest) { // last empty commit covered is forgotten and any commit still queued for it is cancelled. This // is the recovery path the dedupe depends on — a per-type reconcile runs after the reconciler // reverts the edit, and it is what makes the NEXT refusal, including a re-made byte-identical - // one, a new trigger rather than a repeat. Scoped to the cell this request evaluated, because + // one, a new trigger rather than a repeat. Scoped to the collection this request evaluated, because // a ConfigMap resync succeeding is no evidence about a Deployment that is still refused. l.refusalRecovered( - itypes.NewResourceReference(req.GitTargetName, req.GitTargetNamespace), req.refusalCell()) + itypes.NewResourceReference(req.GitTargetName, req.GitTargetNamespace), req.refusalCollection()) // Only retain the resync's own pending write when it actually committed. A no-op // resync (e.g. the empty initial snapshot before any rule selects a resource) diff --git a/internal/git/resync_push_test.go b/internal/git/resync_push_test.go index 7b69c1ea9..11d09f04e 100644 --- a/internal/git/resync_push_test.go +++ b/internal/git/resync_push_test.go @@ -104,16 +104,16 @@ func TestEnqueueResync_CoalescesSameScope(t *testing.T) { // the cluster has already moved past. func TestEnqueueResync_CoalescingCarriesTheSurvivingRequest(t *testing.T) { w := &BranchWorker{Log: logr.Discard(), Branch: "main", eventQueue: make(chan WorkItem, 1)} - cell := types.CellKeyFor(configmapsGVRForScope, "team-a") + collection := types.CollectionKeyFor(configmapsGVRForScope, "team-a") scope := ResyncScopeFor(configmapsGVRForScope, "team-a") require.True(t, w.EnqueueResync(&ResyncRequest{ GitTargetNamespace: "ns", GitTargetName: "target", ResourceVersion: "1", Scope: &scope, - SourceCell: cell, Result: make(chan ResyncResult, 1), + SourceCollection: collection, Result: make(chan ResyncResult, 1), })) require.True(t, w.EnqueueResync(&ResyncRequest{ GitTargetNamespace: "ns", GitTargetName: "target", ResourceVersion: "2", Scope: &scope, - SourceCell: cell, Result: make(chan ResyncResult, 1), + SourceCollection: collection, Result: make(chan ResyncResult, 1), })) item := <-w.eventQueue @@ -121,7 +121,7 @@ func TestEnqueueResync_CoalescingCarriesTheSurvivingRequest(t *testing.T) { current := w.takePendingResync(item.Resync) assert.Equal(t, "2", current.ResourceVersion, "the newer snapshot is the one that runs, at the marker's position") - assert.Equal(t, cell, current.SourceCell, "and it still names the cell that gathered it") + assert.Equal(t, collection, current.SourceCollection, "and it still names the collection that gathered it") } // TestEnqueueResync_AcceptedRequestAlwaysRuns pins the atomicity of insert-and-queue diff --git a/internal/git/resync_scope_test.go b/internal/git/resync_scope_test.go index a01ae7c03..790212a79 100644 --- a/internal/git/resync_scope_test.go +++ b/internal/git/resync_scope_test.go @@ -103,7 +103,7 @@ func TestResyncScope_StringIsNilSafeAndNamesTheNamespace(t *testing.T) { allNamespaces := resyncScopePtr(configmapsGVRForScope, "") assert.Equal(t, "configmaps", allNamespaces.String(), - "the scope renders its cell, which carries no served version: a version bump must not "+ + "the scope renders its collection, which carries no served version: a version bump must not "+ "split the deferred-heal key or the coalescing key for one sweep boundary") scoped := resyncScopePtr(configmapsGVRForScope, "team-a") @@ -224,14 +224,14 @@ func TestResync_ClusterWideScopeStillSweepsEveryNamespace(t *testing.T) { // A scope's identity must round-trip to the boundary it sweeps. It did not: the scope carried // a full GVR while Matches compared group, resource and namespace only, so two served versions // of one resource were two coalescing keys, two deferred-heal keys and two render-fidelity -// scopes — over one sweep boundary. The version is now data on the scope, and the cell is the +// scopes — over one sweep boundary. The version is now data on the scope, and the collection is the // identity. func TestResyncScope_ServedVersionIsDataNotIdentity(t *testing.T) { v1 := ResyncScopeFor(schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}, "team-a") v1beta1 := ResyncScopeFor( schema.GroupVersionResource{Group: "apps", Version: "v1beta1", Resource: "deployments"}, "team-a") - assert.Equal(t, v1.Cell, v1beta1.Cell, "one sweep boundary is one identity") + assert.Equal(t, v1.Collection, v1beta1.Collection, "one sweep boundary is one identity") assert.Equal(t, "v1", v1.Version, "the served version survives as data") assert.Equal(t, "v1beta1", v1beta1.Version) assert.Equal(t, diff --git a/internal/git/source_cell_test.go b/internal/git/source_cell_test.go deleted file mode 100644 index 7b24c9566..000000000 --- a/internal/git/source_cell_test.go +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package git - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "k8s.io/apimachinery/pkg/runtime/schema" - - "github.com/ConfigButler/gitops-reverser/internal/types" -) - -func cellForTest(namespace string) types.CellKey { - return types.CellKeyFor(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, namespace) -} - -func TestSourceCellForLog(t *testing.T) { - assert.Equal(t, "unclaimed", sourceCellForLog(types.CellKey{}), - "the zero cell is how every non-stream producer queues work, and a log line has to say so") - assert.Equal(t, "configmaps in team-a", sourceCellForLog(cellForTest("team-a")), - "a storm is diagnosed from which cell produced the write") -} - -func TestWriteRequest_SourceCell(t *testing.T) { - claimed := cellForTest("team-a") - - assert.Equal(t, types.CellKey{}, (&WriteRequest{}).sourceCell(), "no events, nothing claimed it") - assert.Equal(t, claimed, - (&WriteRequest{Events: []Event{{SourceCell: claimed}}}).sourceCell(), - "the live path wraps one event, and that event's cell is the request's") - - mixed := &WriteRequest{Events: []Event{ - {SourceCell: claimed}, - {SourceCell: cellForTest("team-b")}, - }} - assert.Equal(t, types.CellKey{}, mixed.sourceCell(), - "a request spanning cells speaks for none of them; it must not borrow the first one's identity") -} diff --git a/internal/git/source_cell.go b/internal/git/source_collection.go similarity index 61% rename from internal/git/source_cell.go rename to internal/git/source_collection.go index 98548e86c..ddff38150 100644 --- a/internal/git/source_cell.go +++ b/internal/git/source_collection.go @@ -6,67 +6,67 @@ import ( "github.com/ConfigButler/gitops-reverser/internal/types" ) -// The source cell on a queued work item names the target-watch cell that produced it. +// The source collection on a queued work item names the target-watch collection that produced it. // // # It is diagnostic, and deliberately stays that way // // Once an item is on the branch worker's FIFO it WILL be applied. Nothing filters the queue on -// the source cell, and nothing should: a branch worker serves one GitProvider and branch rather +// the source collection, and nothing should: a branch worker serves one GitProvider and branch rather // than one GitTarget, so its queue is shared across tenants, and judging an item would mean // reaching back into the watch plan of whichever GitTarget the item names, per item, per // tenant. The plan is applied by starting and canceling streams instead, at the producer. // // The consequence is accepted: a canceled stream's goroutine can still be in flight, so a -// configuration change may be followed by a short tail of writes from a cell that is no longer +// configuration change may be followed by a short tail of writes from a collection that is no longer // selected. That tail is bounded by the queue, the files it touches are retained anyway, and // the Git-side sweep converges the mirror afterwards. See // docs/design/target-watch-plan.md, "Cut at the producer". // -// An earlier design stamped a stream LEASE beside the cell, so a consumer could tell a canceled +// An earlier design stamped a stream LEASE beside the collection, so a consumer could tell a canceled // stream's in-flight item from its replacement's. There is no such consumer and there will not // be one, so the lease is gone. If a fence is ever genuinely needed it belongs on the producer, // as a refusal to enqueue once the stream's context is canceled. Document the specific failure // first. // -// The GitTarget is deliberately NOT repeated in it. Every item that carries a source cell +// The GitTarget is deliberately NOT repeated in it. Every item that carries a source collection // already names its target (an Event's GitTargetName/GitTargetNamespace, a ResyncRequest's), // and a second copy is a second answer waiting to disagree: the coalescing fence already had to // pick between a request-level and an event-level target, and reading the wrong one made it // silently never fire. // -// The zero cell means "no cell claimed this": a reconcile, a bootstrap, or any control-plane +// The zero collection means "no collection claimed this": a reconcile, a bootstrap, or any control-plane // path that speaks for the whole GitTarget rather than for one watched slice. An unclaimed item // is not suspicious, it is how every non-stream producer queues work. -// sourceCellForLog renders a source cell for logs: "configmaps in team-a", or "unclaimed" for -// the zero cell. Reading a storm means knowing which cell produced a write, and the drop +// sourceCollectionForLog renders a source collection for logs: "configmaps in team-a", or "unclaimed" for +// the zero collection. Reading a storm means knowing which collection produced a write, and the drop // records a saturated queue leaves behind are where that is read from. -func sourceCellForLog(c types.CellKey) string { - if c == (types.CellKey{}) { +func sourceCollectionForLog(c types.CollectionKey) string { + if c == (types.CollectionKey{}) { return "unclaimed" } return c.String() } -// sourceCell is the write request's source cell: the one its events agree on, or the zero cell +// sourceCollection is the write request's source collection: the one its events agree on, or the zero collection // when they do not. The live path wraps exactly one event, which is the path that carries a -// cell; an atomic reconcile carries many events and speaks for no single cell, so it reads +// collection; an atomic reconcile carries many events and speaks for no single collection, so it reads // unclaimed, which is what it is. -func (r *WriteRequest) sourceCell() types.CellKey { +func (r *WriteRequest) sourceCollection() types.CollectionKey { if r == nil || len(r.Events) == 0 { - return types.CellKey{} + return types.CollectionKey{} } - return sourceCellForEvents(r.Events) + return sourceCollectionForEvents(r.Events) } -func sourceCellForEvents(events []Event) types.CellKey { +func sourceCollectionForEvents(events []Event) types.CollectionKey { if len(events) == 0 { - return types.CellKey{} + return types.CollectionKey{} } - first := events[0].SourceCell + first := events[0].SourceCollection for i := range events { - if events[i].SourceCell != first { - return types.CellKey{} + if events[i].SourceCollection != first { + return types.CollectionKey{} } } return first diff --git a/internal/git/source_collection_test.go b/internal/git/source_collection_test.go new file mode 100644 index 000000000..9cc050bc0 --- /dev/null +++ b/internal/git/source_collection_test.go @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: Apache-2.0 + +package git + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/ConfigButler/gitops-reverser/internal/types" +) + +func collectionForTest(namespace string) types.CollectionKey { + return types.CollectionKeyFor(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, namespace) +} + +func TestSourceCollectionForLog(t *testing.T) { + assert.Equal(t, "unclaimed", sourceCollectionForLog(types.CollectionKey{}), + "the zero collection is how every non-stream producer queues work, and a log line has to say so") + assert.Equal(t, "configmaps in team-a", sourceCollectionForLog(collectionForTest("team-a")), + "a storm is diagnosed from which collection produced the write") +} + +func TestWriteRequest_SourceCollection(t *testing.T) { + claimed := collectionForTest("team-a") + + assert.Equal(t, types.CollectionKey{}, (&WriteRequest{}).sourceCollection(), "no events, nothing claimed it") + assert.Equal(t, claimed, + (&WriteRequest{Events: []Event{{SourceCollection: claimed}}}).sourceCollection(), + "the live path wraps one event, and that event's collection is the request's") + + mixed := &WriteRequest{Events: []Event{ + {SourceCollection: claimed}, + {SourceCollection: collectionForTest("team-b")}, + }} + assert.Equal(t, types.CollectionKey{}, mixed.sourceCollection(), + "a request spanning collections speaks for none of them; it must not borrow the first one's identity") +} diff --git a/internal/git/types.go b/internal/git/types.go index e14f6b273..72bf11233 100644 --- a/internal/git/types.go +++ b/internal/git/types.go @@ -92,7 +92,7 @@ const ( // DefaultCommitterEmail matches the default operator email in Git history. DefaultCommitterEmail = "noreply@configbutler.ai" // DefaultReconcileCommitMessageTemplate names the synced type AND, when the run covered one, - // the namespace, so the otherwise indistinguishable per-cell reconciles one GitTarget + // the namespace, so the otherwise indistinguishable per-collection reconciles one GitTarget // produces are self-describing: a target watching configmaps in team-a and in team-b would // otherwise write two byte-identical subjects. Plural resource alone for readability; add // {{.APIVersion}} when plural collisions matter. @@ -389,20 +389,20 @@ type WorkItem struct { } // ResyncScope restricts a resync's mark-and-sweep to the slice of the mirror the desired -// snapshot was actually gathered over: one cell, and the served version that cell was +// snapshot was actually gathered over: one collection, and the served version that collection was // gathered at. // // The invariant: THE SWEEP SCOPE MUST BE EXACTLY THE SCOPE THE DESIRED SET WAS GATHERED OVER. // Narrower deletes documents that were never in scope; wider silently leaves documents unmanaged. -// The namespace lives inside the cell so a per-namespace replay cannot reach the sweep carrying +// The namespace lives inside the collection so a per-namespace replay cannot reach the sweep carrying // only its type — a replay of one namespace once swept every other namespace's documents. type ResyncScope struct { - // Cell is the sweep boundary and the scope's identity: group, resource, namespace. - Cell types.CellKey + // Collection is the sweep boundary and the scope's identity: group, resource, namespace. + Collection types.CollectionKey // Version is the served version the desired set was gathered at. It is DATA, not // identity: it renders the reconcile commit message's {{.APIVersion}} and names the - // version a snapshot came from in logs, and it is deliberately absent from the cell - // key, so a scope always round-trips to the boundary it sweeps (types.CellKey). + // version a snapshot came from in logs, and it is deliberately absent from the collection + // key, so a scope always round-trips to the boundary it sweeps (types.CollectionKey). Version string } @@ -410,7 +410,7 @@ type ResyncScope struct { // namespace it was gathered in. It is the only constructor: going through it is what keeps // the version on the data side of the type and out of the identity. func ResyncScopeFor(gvr schema.GroupVersionResource, namespace string) ResyncScope { - return ResyncScope{Cell: types.CellKeyFor(gvr, namespace), Version: gvr.Version} + return ResyncScope{Collection: types.CollectionKeyFor(gvr, namespace), Version: gvr.Version} } // GVR reconstructs the served GroupVersionResource this scope was gathered with, for the @@ -419,7 +419,7 @@ func (s *ResyncScope) GVR() schema.GroupVersionResource { if s == nil { return schema.GroupVersionResource{} } - return schema.GroupVersionResource{Group: s.Cell.Group, Version: s.Version, Resource: s.Cell.Resource} + return schema.GroupVersionResource{Group: s.Collection.Group, Version: s.Version, Resource: s.Collection.Resource} } // String renders the scope for logs and for the deferred-heal key. It is nil-safe: a nil @@ -428,7 +428,7 @@ func (s *ResyncScope) String() string { if s == nil { return "" } - return s.Cell.String() + return s.Collection.String() } // Matches reports whether a resolved resource identity falls inside this scope. A nil scope @@ -438,7 +438,7 @@ func (s *ResyncScope) Matches(ri types.ResourceIdentifier) bool { if s == nil { return true } - return s.Cell.Matches(ri) + return s.Collection.Matches(ri) } // ResyncRequest is a synchronous resync of one GitTarget against a complete, @@ -468,24 +468,24 @@ type ResyncRequest struct { // acceptance gate. Forced GitTarget rechecks use it because their trigger is often "I changed // Git; look again", and the local checkout may still hold the refused revision. RefreshRemote bool - // SourceCell names the target-watch cell that gathered this snapshot. Zero for a - // whole-GitTarget resync, which speaks for no single cell. Diagnostic only: nothing - // filters the queue on it. See source_cell.go. - SourceCell types.CellKey + // SourceCollection names the target-watch collection that gathered this snapshot. Zero for a + // whole-GitTarget resync, which speaks for no single collection. Diagnostic only: nothing + // filters the queue on it. See source_collection.go. + SourceCollection types.CollectionKey // Result receives exactly one reply. It is buffered (cap 1) by the emitter so // the worker never blocks delivering it. Result chan ResyncResult } -// refusalCell is the watched cell this request speaks for: its scope's cell for a per-type -// reconcile, and the ZERO cell for a whole-GitTarget resync, which speaks for every cell the +// refusalCollection is the watched collection this request speaks for: its scope's collection for a per-type +// reconcile, and the ZERO collection for a whole-GitTarget resync, which speaks for every collection the // target holds rather than for one of them. It is what keys a refusal's dedupe memory and its // queued commit, so that one watched type's success neither clears nor re-arms another's. -func (r *ResyncRequest) refusalCell() types.CellKey { +func (r *ResyncRequest) refusalCollection() types.CollectionKey { if r == nil || r.Scope == nil { - return types.CellKey{} + return types.CollectionKey{} } - return r.Scope.Cell + return r.Scope.Collection } // resyncKey identifies the slice of a mirror a resync reconciles: one GitTarget, @@ -517,7 +517,7 @@ type pendingResync struct { func resyncKeyFor(request *ResyncRequest) resyncKey { key := resyncKey{namespace: request.GitTargetNamespace, name: request.GitTargetName} if request.Scope != nil { - key.scope = request.Scope.Cell.String() + key.scope = request.Scope.Collection.String() } return key } @@ -636,10 +636,10 @@ type Event struct { // BootstrapOptions controls path-scoped bootstrap file staging for this event. BootstrapOptions pathBootstrapOptions - // SourceCell names the target-watch cell that produced this event. Zero for every + // SourceCollection names the target-watch collection that produced this event. Zero for every // non-stream producer (reconcile, bootstrap, the admission path). Diagnostic only: - // nothing filters the queue on it. See source_cell.go. - SourceCell types.CellKey + // nothing filters the queue on it. See source_collection.go. + SourceCollection types.CollectionKey } // IsFieldPatch reports whether the event carries a bounded field patch instead of diff --git a/internal/git/write_boundary_precondition_test.go b/internal/git/write_boundary_precondition_test.go index 1d8641984..bc2df6f20 100644 --- a/internal/git/write_boundary_precondition_test.go +++ b/internal/git/write_boundary_precondition_test.go @@ -232,7 +232,7 @@ func TestEventLoop_LiveFanInRefusal_FailsGitTargetAndCommitsNothing(t *testing.T var refusedTargets []types.ResourceReference worker.pathRefusal = func( target types.ResourceReference, - _ types.CellKey, + _ types.CollectionKey, refused *manifestanalyzer.AcceptanceRefusedError, ) { refusedTargets = append(refusedTargets, target) diff --git a/internal/rulestore/store.go b/internal/rulestore/store.go index 5272cc247..6b7afcbd8 100644 --- a/internal/rulestore/store.go +++ b/internal/rulestore/store.go @@ -38,8 +38,8 @@ type CompiledRule struct { ResourceRules []CompiledResourceRule } -// compiledSelector is the TYPE filter both rule kinds compile to: the operations, groups, versions -// and resource plurals an item selects, with nothing about where the object lives. +// compiledSelector is the TYPE filter both rule kinds compile to: the groups, versions and +// resource plurals an item selects, with nothing about where the object lives. // // It is one type because the answer has to be one answer. The namespaced and cluster-scoped rules // carried byte-identical copies of all four checks, so a fix to how a wildcard or a subresource @@ -47,8 +47,6 @@ type CompiledRule struct { // different call paths, so nothing failed loudly when they disagreed. Scope is the only axis the // two rules genuinely differ on, and it stays outside: see CompiledResourceRule.SourceNamespaces. type compiledSelector struct { - // Operations specifies which operations trigger this rule. - Operations []configv1alpha3.OperationType // APIGroups specifies which API groups this rule matches. APIGroups []string // APIVersions specifies which API versions this rule matches. @@ -60,13 +58,12 @@ type compiledSelector struct { // clone returns a selector sharing no backing array with the receiver. // // The store hands compiled rules to callers and takes them from a CR it does not own, so every -// crossing of that boundary copies. Struct assignment is not enough: it duplicates four slice +// crossing of that boundary copies. Struct assignment is not enough: it duplicates three slice // HEADERS, leaving the store's rule and the caller's snapshot writing through to the same arrays — // and a caller writing there reaches store state behind the mutex entirely. An empty slice clones // to nil, which every reader here already treats as "match all", exactly as before. func (s *compiledSelector) clone() compiledSelector { return compiledSelector{ - Operations: append([]configv1alpha3.OperationType(nil), s.Operations...), APIGroups: append([]string(nil), s.APIGroups...), APIVersions: append([]string(nil), s.APIVersions...), Resources: append([]string(nil), s.Resources...), @@ -197,7 +194,6 @@ func (s *RuleStore) AddOrUpdateWatchRule( namespaces = append([]string(nil), sourceNamespaces[i]...) } selector := compiledSelector{ - Operations: r.Operations, APIGroups: r.APIGroups, APIVersions: r.APIVersions, Resources: r.Resources, @@ -267,7 +263,6 @@ func (s *RuleStore) AddOrUpdateClusterWatchRule( for _, r := range rule.Spec.Rules { selector := compiledSelector{ - Operations: r.Operations, APIGroups: r.APIGroups, APIVersions: r.APIVersions, Resources: r.Resources, @@ -314,14 +309,12 @@ func (s *RuleStore) IsReady() bool { // Parameters: // - obj: The Kubernetes object to match; its namespace is used for WatchRule filtering // - resourcePlural: The plural form of the resource (e.g., "pods", "deployments") -// - operation: The operation type (CREATE, UPDATE, DELETE) // - apiGroup: The API group of the resource (empty string for core API) // - apiVersion: The API version of the resource // - isClusterScoped: Whether the resource is cluster-scoped func (s *RuleStore) GetMatchingRules( obj client.Object, resourcePlural string, - operation configv1alpha3.OperationType, apiGroup string, apiVersion string, isClusterScoped bool, @@ -341,7 +334,7 @@ func (s *RuleStore) GetMatchingRules( continue // WatchRule can't match cluster resources } - if rule.matches(eventNamespace, resourcePlural, operation, apiGroup, apiVersion) { + if rule.matches(eventNamespace, resourcePlural, apiGroup, apiVersion) { matchingRules = append(matchingRules, rule) } } @@ -352,14 +345,12 @@ func (s *RuleStore) GetMatchingRules( // This handles both cluster-scoped and namespaced resources with per-rule scope matching. // Parameters: // - resourcePlural: The plural form of the resource (e.g., "nodes", "pods") -// - operation: The operation type (CREATE, UPDATE, DELETE) // - apiGroup: The API group of the resource (empty string for core API) // - apiVersion: The API version of the resource // - isClusterScoped: Whether the resource is cluster-scoped // - namespaceLabels: Labels of the namespace (ignored in simplified MVP) func (s *RuleStore) GetMatchingClusterRules( resourcePlural string, - operation configv1alpha3.OperationType, apiGroup string, apiVersion string, isClusterScoped bool, @@ -373,7 +364,6 @@ func (s *RuleStore) GetMatchingClusterRules( if s.clusterRuleMatches( clusterRule, resourcePlural, - operation, apiGroup, apiVersion, isClusterScoped, @@ -393,7 +383,6 @@ func (s *RuleStore) GetMatchingClusterRules( func (s *RuleStore) clusterRuleMatches( clusterRule CompiledClusterRule, resourcePlural string, - operation configv1alpha3.OperationType, apiGroup string, apiVersion string, isClusterScoped bool, @@ -402,7 +391,7 @@ func (s *RuleStore) clusterRuleMatches( return false } for _, rule := range clusterRule.Rules { - if rule.matchesType(resourcePlural, operation, apiGroup, apiVersion) { + if rule.matchesType(resourcePlural, apiGroup, apiVersion) { return true } } @@ -417,13 +406,12 @@ func (s *RuleStore) clusterRuleMatches( func (r *CompiledRule) matches( eventNamespace string, resourcePlural string, - operation configv1alpha3.OperationType, apiGroup string, apiVersion string, ) bool { // Check if any resource rule matches (logical OR) for _, rule := range r.ResourceRules { - if rule.matches(eventNamespace, resourcePlural, operation, apiGroup, apiVersion) { + if rule.matches(eventNamespace, resourcePlural, apiGroup, apiVersion) { return true } } @@ -435,7 +423,6 @@ func (r *CompiledRule) matches( func (r *CompiledResourceRule) matches( eventNamespace string, resourcePlural string, - operation configv1alpha3.OperationType, apiGroup string, apiVersion string, ) bool { @@ -445,14 +432,14 @@ func (r *CompiledResourceRule) matches( return false } - return r.matchesType(resourcePlural, operation, apiGroup, apiVersion) + return r.matchesType(resourcePlural, apiGroup, apiVersion) } // matchesSourceNamespace checks the event's namespace against this item's RESOLVED set. An event // with no namespace is left to the caller's cluster-scope check rather than being filtered here. // // An EMPTY ENTRY in the set is the cluster-wide selection a `sourceNamespace: "*"` item compiles -// to, and it matches every namespace. The identity is the same one CellKey uses (its Namespace doc +// to, and it matches every namespace. The identity is the same one CollectionKey uses (its Namespace doc // records why), so the router and the stream planner agree about what the item selects. Without // this the item would open a cluster-wide stream and then route none of its events, since no real // object carries the empty namespace. @@ -468,17 +455,13 @@ func (r *CompiledResourceRule) matchesSourceNamespace(eventNamespace string) boo return false } -// matchesType answers the whole type filter: operations, group, version, resource plural. An empty +// matchesType answers the whole type filter: group, version, resource plural. An empty // list means "match all" on every axis except the resource plural, which is required. func (s *compiledSelector) matchesType( resourcePlural string, - operation configv1alpha3.OperationType, apiGroup string, apiVersion string, ) bool { - if !s.matchesOperations(operation) { - return false - } if !matchesAny(s.APIGroups, apiGroup) { return false } @@ -488,20 +471,6 @@ func (s *compiledSelector) matchesType( return s.resourceMatches(resourcePlural) } -// matchesOperations checks if the operation matches any in the rule. -func (s *compiledSelector) matchesOperations(operation configv1alpha3.OperationType) bool { - if len(s.Operations) == 0 { - return true // Empty = match all - } - - for _, op := range s.Operations { - if op == configv1alpha3.OperationAll || op == operation { - return true - } - } - return false -} - // matchesAny is the group and version test, which are the same test: an empty list matches all, and // "*" is the only wildcard either axis accepts. func matchesAny(patterns []string, value string) bool { diff --git a/internal/rulestore/store_test.go b/internal/rulestore/store_test.go index 0c157567e..b42420eba 100644 --- a/internal/rulestore/store_test.go +++ b/internal/rulestore/store_test.go @@ -47,7 +47,6 @@ func TestAddOrUpdateWatchRule(t *testing.T) { Spec: configv1alpha3.WatchRuleSpec{ Rules: []configv1alpha3.ResourceRule{ { - Operations: []configv1alpha3.OperationType{configv1alpha3.OperationCreate}, APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"pods"}, @@ -135,7 +134,6 @@ func TestAddOrUpdateClusterWatchRule(t *testing.T) { Spec: configv1alpha3.ClusterWatchRuleSpec{ Rules: []configv1alpha3.ClusterResourceRule{ { - Operations: []configv1alpha3.OperationType{configv1alpha3.OperationAll}, APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"nodes"}, @@ -261,7 +259,6 @@ func TestGetMatchingRules(t *testing.T) { Spec: configv1alpha3.WatchRuleSpec{ Rules: []configv1alpha3.ResourceRule{ { - Operations: []configv1alpha3.OperationType{configv1alpha3.OperationCreate}, APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"pods"}, @@ -276,7 +273,6 @@ func TestGetMatchingRules(t *testing.T) { Spec: configv1alpha3.WatchRuleSpec{ Rules: []configv1alpha3.ResourceRule{ { - Operations: []configv1alpha3.OperationType{configv1alpha3.OperationAll}, APIGroups: []string{"apps"}, APIVersions: []string{"v1"}, Resources: []string{"deployments"}, @@ -295,7 +291,6 @@ func TestGetMatchingRules(t *testing.T) { tests := []struct { name string resourcePlural string - operation configv1alpha3.OperationType apiGroup string apiVersion string isClusterScoped bool @@ -303,9 +298,8 @@ func TestGetMatchingRules(t *testing.T) { expectedNames []string }{ { - name: "Match pod CREATE", + name: "Match pod", resourcePlural: "pods", - operation: configv1alpha3.OperationCreate, apiGroup: "", apiVersion: "v1", isClusterScoped: false, @@ -313,29 +307,8 @@ func TestGetMatchingRules(t *testing.T) { expectedNames: []string{"pod-create-rule"}, }, { - name: "No match pod UPDATE", - resourcePlural: "pods", - operation: configv1alpha3.OperationUpdate, - apiGroup: "", - apiVersion: "v1", - isClusterScoped: false, - expectedCount: 0, - expectedNames: []string{}, - }, - { - name: "Match deployment CREATE", - resourcePlural: "deployments", - operation: configv1alpha3.OperationCreate, - apiGroup: "apps", - apiVersion: "v1", - isClusterScoped: false, - expectedCount: 1, - expectedNames: []string{"deployment-rule"}, - }, - { - name: "Match deployment UPDATE (OperationAll)", + name: "Match deployment", resourcePlural: "deployments", - operation: configv1alpha3.OperationUpdate, apiGroup: "apps", apiVersion: "v1", isClusterScoped: false, @@ -345,7 +318,6 @@ func TestGetMatchingRules(t *testing.T) { { name: "No match cluster-scoped resource", resourcePlural: "nodes", - operation: configv1alpha3.OperationCreate, apiGroup: "", apiVersion: "v1", isClusterScoped: true, @@ -359,7 +331,6 @@ func TestGetMatchingRules(t *testing.T) { matches := store.GetMatchingRules( nil, tt.resourcePlural, - tt.operation, tt.apiGroup, tt.apiVersion, tt.isClusterScoped, @@ -385,32 +356,30 @@ func TestGetMatchingRules(t *testing.T) { } } -// TestGetMatchingRules_OverlappingRulesUnionOperations pins the semantics for -// "two WatchRules match the same resource": operations *add up* (union), there -// is no first-wins precedence and no single rule that "owns" the resource. -// -// Matching is evaluated per-operation: GetMatchingRules returns every rule whose -// operation set includes the operation being tested (an empty set means all). -// So for pods covered by rule A=[CREATE] and rule B=[UPDATE]: -// - a CREATE matches A only, -// - an UPDATE matches B only, -// - a DELETE matches neither (the union is exactly {CREATE, UPDATE}, not "all"), -// - and when two rules both cover CREATE, *both* are returned (additive, not -// deduped to one). -// -// This matters for the per-target effective watch plan -// (docs/spec/gittarget-isolation-on-rule-change.md): a target's watched -// operation set for a GVR is the union across its rules, and each returned -// CompiledRule carries its own GitTarget, so two complementary rules pointing at -// the same target make that target watch the union. -func TestGetMatchingRules_OverlappingRulesUnionOperations(t *testing.T) { +func TestRuleStore_Readiness(t *testing.T) { + store := NewStore() + if store.IsReady() { + t.Fatal("new store must not report ready before bootstrap") + } + + store.MarkReady() + + if !store.IsReady() { + t.Fatal("store must report ready after MarkReady") + } +} + +// TestGetMatchingRules_OverlappingRulesAreAdditive pins the semantics for "two WatchRules match +// the same resource": both are returned, with no first-wins precedence and no single rule that +// "owns" the resource. Each returned CompiledRule carries its own GitTarget, so the per-target +// watch plan (docs/spec/gittarget-isolation-on-rule-change.md) sees every rule that selects it. +func TestGetMatchingRules_OverlappingRulesAreAdditive(t *testing.T) { store := NewStore() - podsRule := func(name string, ops ...configv1alpha3.OperationType) configv1alpha3.WatchRule { + podsRule := func(name string) configv1alpha3.WatchRule { r := configv1alpha3.WatchRule{ Spec: configv1alpha3.WatchRuleSpec{ Rules: []configv1alpha3.ResourceRule{{ - Operations: ops, APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"pods"}, @@ -421,86 +390,19 @@ func TestGetMatchingRules_OverlappingRulesUnionOperations(t *testing.T) { r.Namespace = "default" return r } - - // Two rules covering the same resource with complementary operations, plus a - // second CREATE rule to prove matches are additive rather than first-wins. - store.AddOrUpdateWatchRule( - podsRule("pod-create", configv1alpha3.OperationCreate), - ownNamespaceScope(podsRule("pod-create", configv1alpha3.OperationCreate)), - "dest-a", "default", "repo", "gitops-system", "main", "a") - store.AddOrUpdateWatchRule( - podsRule("pod-update", configv1alpha3.OperationUpdate), - ownNamespaceScope(podsRule("pod-update", configv1alpha3.OperationUpdate)), - "dest-a", "default", "repo", "gitops-system", "main", "a") - store.AddOrUpdateWatchRule( - podsRule("pod-create-2", configv1alpha3.OperationCreate), - ownNamespaceScope(podsRule("pod-create-2", configv1alpha3.OperationCreate)), - "dest-b", "default", "repo", "gitops-system", "main", "b") - - tests := []struct { - name string - operation configv1alpha3.OperationType - expectedNames []string - }{ - { - name: "CREATE returns both CREATE rules (additive, not first-wins)", - operation: configv1alpha3.OperationCreate, - expectedNames: []string{"pod-create", "pod-create-2"}, - }, - { - name: "UPDATE returns the UPDATE rule (operations union across rules)", - operation: configv1alpha3.OperationUpdate, - expectedNames: []string{"pod-update"}, - }, - { - name: "DELETE matches nothing (union is exactly CREATE+UPDATE, not all)", - operation: configv1alpha3.OperationDelete, - expectedNames: []string{}, - }, + for _, name := range []string{"pods-a", "pods-b"} { + rule := podsRule(name) + store.AddOrUpdateWatchRule(rule, ownNamespaceScope(rule), "dest-a", "default", "repo", + "gitops-system", "main", "a") } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - matches := store.GetMatchingRules(nil, "pods", tt.operation, "", "v1", false) - - gotNames := make(map[string]struct{}, len(matches)) - for _, m := range matches { - gotNames[m.Source.Name] = struct{}{} - } - - if len(matches) != len(tt.expectedNames) { - t.Errorf("expected %d matches %v, got %d %v", - len(tt.expectedNames), tt.expectedNames, len(matches), keys(gotNames)) - } - for _, want := range tt.expectedNames { - if _, ok := gotNames[want]; !ok { - t.Errorf("expected rule %q to match %s, got %v", - want, tt.operation, keys(gotNames)) - } - } - }) - } -} - -// keys returns the keys of a set, for readable test failure messages. -func keys(m map[string]struct{}) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) + matches := store.GetMatchingRules(nil, "pods", "", "v1", false) + got := map[string]bool{} + for _, m := range matches { + got[m.Source.Name] = true } - return out -} - -func TestRuleStore_Readiness(t *testing.T) { - store := NewStore() - if store.IsReady() { - t.Fatal("new store must not report ready before bootstrap") - } - - store.MarkReady() - - if !store.IsReady() { - t.Fatal("store must report ready after MarkReady") + if len(matches) != 2 || !got["pods-a"] || !got["pods-b"] { + t.Fatalf("both overlapping rules must match, got %v", got) } } @@ -516,7 +418,6 @@ func TestGetMatchingClusterRules(t *testing.T) { Spec: configv1alpha3.ClusterWatchRuleSpec{ Rules: []configv1alpha3.ClusterResourceRule{ { - Operations: []configv1alpha3.OperationType{configv1alpha3.OperationAll}, APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"nodes"}, @@ -532,7 +433,6 @@ func TestGetMatchingClusterRules(t *testing.T) { Spec: configv1alpha3.ClusterWatchRuleSpec{ Rules: []configv1alpha3.ClusterResourceRule{ { - Operations: []configv1alpha3.OperationType{configv1alpha3.OperationAll}, APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"pods"}, @@ -564,7 +464,6 @@ func TestGetMatchingClusterRules(t *testing.T) { tests := []struct { name string resourcePlural string - operation configv1alpha3.OperationType apiGroup string apiVersion string isClusterScoped bool @@ -574,7 +473,6 @@ func TestGetMatchingClusterRules(t *testing.T) { { name: "Match cluster-scoped nodes", resourcePlural: "nodes", - operation: configv1alpha3.OperationCreate, apiGroup: "", apiVersion: "v1", isClusterScoped: true, @@ -584,7 +482,6 @@ func TestGetMatchingClusterRules(t *testing.T) { { name: "No match: a namespaced object never matches a ClusterWatchRule", resourcePlural: "pods", - operation: configv1alpha3.OperationUpdate, apiGroup: "", apiVersion: "v1", isClusterScoped: false, @@ -594,7 +491,6 @@ func TestGetMatchingClusterRules(t *testing.T) { { name: "No match: cluster rule doesn't match namespaced scope", resourcePlural: "nodes", - operation: configv1alpha3.OperationCreate, apiGroup: "", apiVersion: "v1", isClusterScoped: false, @@ -607,7 +503,6 @@ func TestGetMatchingClusterRules(t *testing.T) { t.Run(tt.name, func(t *testing.T) { matches := store.GetMatchingClusterRules( tt.resourcePlural, - tt.operation, tt.apiGroup, tt.apiVersion, tt.isClusterScoped, @@ -667,59 +562,6 @@ func TestResourceMatching(t *testing.T) { } } -// TestOperationMatching verifies operation matching logic, for both rule kinds at once. -func TestOperationMatching(t *testing.T) { - tests := []struct { - name string - ruleOperations []configv1alpha3.OperationType - operation configv1alpha3.OperationType - shouldMatch bool - }{ - {"Empty matches all", []configv1alpha3.OperationType{}, configv1alpha3.OperationCreate, true}, - { - "Wildcard matches all", - []configv1alpha3.OperationType{configv1alpha3.OperationAll}, - configv1alpha3.OperationCreate, - true, - }, - { - "Exact match CREATE", - []configv1alpha3.OperationType{configv1alpha3.OperationCreate}, - configv1alpha3.OperationCreate, - true, - }, - { - "No match CREATE vs UPDATE", - []configv1alpha3.OperationType{configv1alpha3.OperationCreate}, - configv1alpha3.OperationUpdate, - false, - }, - { - "Multiple operations match", - []configv1alpha3.OperationType{configv1alpha3.OperationCreate, configv1alpha3.OperationUpdate}, - configv1alpha3.OperationUpdate, - true, - }, - { - "Multiple operations no match", - []configv1alpha3.OperationType{configv1alpha3.OperationCreate, configv1alpha3.OperationUpdate}, - configv1alpha3.OperationDelete, - false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - selector := compiledSelector{Operations: tt.ruleOperations} - - result := selector.matchesOperations(tt.operation) - if result != tt.shouldMatch { - t.Errorf("Expected match=%v, got %v", tt.shouldMatch, result) - } - }) - } -} - // TestAPIGroupMatching verifies API group matching, for both rule kinds at once. func TestAPIGroupMatching(t *testing.T) { tests := []struct { @@ -889,7 +731,7 @@ func TestConcurrentAccess(t *testing.T) { defer wg.Done() for range numOperations { _ = store.SnapshotWatchRules() - _ = store.GetMatchingRules(nil, "pods", configv1alpha3.OperationCreate, "", "v1", false) + _ = store.GetMatchingRules(nil, "pods", "", "v1", false) } }() } @@ -911,12 +753,10 @@ func TestMultipleResourceRules(t *testing.T) { Spec: configv1alpha3.WatchRuleSpec{ Rules: []configv1alpha3.ResourceRule{ { - Operations: []configv1alpha3.OperationType{configv1alpha3.OperationCreate}, - Resources: []string{"pods"}, + Resources: []string{"pods"}, }, { - Operations: []configv1alpha3.OperationType{configv1alpha3.OperationUpdate}, - Resources: []string{"services"}, + Resources: []string{"services"}, }, }, }, @@ -927,22 +767,16 @@ func TestMultipleResourceRules(t *testing.T) { store.AddOrUpdateWatchRule( rule, ownNamespaceScope(rule), "dest", "default", "repo", "gitops-system", "main", "test") - // Should match pod CREATE - matches := store.GetMatchingRules(nil, "pods", configv1alpha3.OperationCreate, "", "v1", false) - if len(matches) != 1 { - t.Errorf("Expected to match pod CREATE, got %d matches", len(matches)) - } - - // Should match service UPDATE - matches = store.GetMatchingRules(nil, "services", configv1alpha3.OperationUpdate, "", "v1", false) - if len(matches) != 1 { - t.Errorf("Expected to match service UPDATE, got %d matches", len(matches)) + // Either item selects its resource: the items are a logical OR. + for _, resource := range []string{"pods", "services"} { + if matches := store.GetMatchingRules(nil, resource, "", "v1", false); len(matches) != 1 { + t.Errorf("Expected to match %s, got %d matches", resource, len(matches)) + } } - // Should NOT match pod UPDATE - matches = store.GetMatchingRules(nil, "pods", configv1alpha3.OperationUpdate, "", "v1", false) - if len(matches) != 0 { - t.Errorf("Should not match pod UPDATE, got %d matches", len(matches)) + // A resource neither item names is not selected. + if matches := store.GetMatchingRules(nil, "configmaps", "", "v1", false); len(matches) != 0 { + t.Errorf("Should not match configmaps, got %d matches", len(matches)) } } @@ -957,7 +791,6 @@ func TestGetMatchingRules_MustFilterByNamespaceForNamespacedWatchRule(t *testing rule := configv1alpha3.WatchRule{ Spec: configv1alpha3.WatchRuleSpec{ Rules: []configv1alpha3.ResourceRule{{ - Operations: []configv1alpha3.OperationType{configv1alpha3.OperationAll}, APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"services"}, @@ -974,7 +807,7 @@ func TestGetMatchingRules_MustFilterByNamespaceForNamespacedWatchRule(t *testing sameNSObject.SetNamespace("tilt-playground") // Same namespace as rule — MUST match. - sameNS := store.GetMatchingRules(sameNSObject, "services", configv1alpha3.OperationCreate, "", "v1", false) + sameNS := store.GetMatchingRules(sameNSObject, "services", "", "v1", false) if len(sameNS) != 1 { t.Errorf("expected same-namespace match to return 1 rule, got %d", len(sameNS)) } @@ -983,7 +816,7 @@ func TestGetMatchingRules_MustFilterByNamespaceForNamespacedWatchRule(t *testing foreignNSObject.SetNamespace("gitops-reverser") // Different namespace — MUST NOT match. - foreignNS := store.GetMatchingRules(foreignNSObject, "services", configv1alpha3.OperationCreate, "", "v1", false) + foreignNS := store.GetMatchingRules(foreignNSObject, "services", "", "v1", false) if len(foreignNS) != 0 { t.Fatalf("expected foreign-namespace match to return 0 rules, got %d", len(foreignNS)) } @@ -996,7 +829,6 @@ func TestGetMatchingRules_NamespacedWatchRule_NamespaceContract(t *testing.T) { rule := configv1alpha3.WatchRule{ Spec: configv1alpha3.WatchRuleSpec{ Rules: []configv1alpha3.ResourceRule{{ - Operations: []configv1alpha3.OperationType{configv1alpha3.OperationAll}, APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"services"}, @@ -1014,7 +846,6 @@ func TestGetMatchingRules_NamespacedWatchRule_NamespaceContract(t *testing.T) { Spec: configv1alpha3.ClusterWatchRuleSpec{ GitTargetRef: meta.NamespacedObjectReference{Name: "cluster-target", Namespace: "ops"}, Rules: []configv1alpha3.ClusterResourceRule{{ - Operations: []configv1alpha3.OperationType{configv1alpha3.OperationAll}, APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"services"}, @@ -1028,7 +859,7 @@ func TestGetMatchingRules_NamespacedWatchRule_NamespaceContract(t *testing.T) { obj := &unstructured.Unstructured{} obj.SetNamespace("tilt-playground") - matches := store.GetMatchingRules(obj, "services", configv1alpha3.OperationCreate, "", "v1", false) + matches := store.GetMatchingRules(obj, "services", "", "v1", false) if len(matches) != 1 { t.Fatalf("expected same-namespace match to return 1 rule, got %d", len(matches)) } @@ -1038,7 +869,7 @@ func TestGetMatchingRules_NamespacedWatchRule_NamespaceContract(t *testing.T) { obj := &unstructured.Unstructured{} obj.SetNamespace("gitops-reverser") - matches := store.GetMatchingRules(obj, "services", configv1alpha3.OperationCreate, "", "v1", false) + matches := store.GetMatchingRules(obj, "services", "", "v1", false) if len(matches) != 0 { t.Fatalf("expected foreign-namespace match to return 0 rules, got %d", len(matches)) } @@ -1047,7 +878,6 @@ func TestGetMatchingRules_NamespacedWatchRule_NamespaceContract(t *testing.T) { t.Run("a ClusterWatchRule never matches a namespaced object", func(t *testing.T) { matches := store.GetMatchingClusterRules( "services", - configv1alpha3.OperationCreate, "", "v1", false, @@ -1106,7 +936,7 @@ func TestGetMatchingRules_PerItemSourceNamespaces(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { matches := store.GetMatchingRules( - object(tc.namespace), tc.resource, configv1alpha3.OperationCreate, "", "v1", false) + object(tc.namespace), tc.resource, "", "v1", false) if len(matches) != tc.want { t.Fatalf("expected %d matches, got %d", tc.want, len(matches)) } @@ -1138,7 +968,7 @@ func TestGetMatchingRules_WildcardItemMatchesEveryResolvedNamespace(t *testing.T obj := &unstructured.Unstructured{} obj.SetNamespace(ns) if got := store.GetMatchingRules( - obj, "configmaps", configv1alpha3.OperationCreate, "", "v1", false); len(got) != 1 { + obj, "configmaps", "", "v1", false); len(got) != 1 { t.Fatalf("expected the wildcard item to match %s, got %d matches", ns, len(got)) } } @@ -1146,7 +976,7 @@ func TestGetMatchingRules_WildcardItemMatchesEveryResolvedNamespace(t *testing.T obj := &unstructured.Unstructured{} obj.SetNamespace("tenant-acme") if got := store.GetMatchingRules( - obj, "configmaps", configv1alpha3.OperationCreate, "", "v1", false); len(got) != 0 { + obj, "configmaps", "", "v1", false); len(got) != 0 { t.Fatalf("a wildcard resolves to the ADMITTED set only; the rule's own namespace is not "+ "implicitly included, got %d matches", len(got)) } @@ -1165,13 +995,11 @@ func TestGetMatchingRules_WildcardItemMatchesEveryResolvedNamespace(t *testing.T func TestSharedSelector_RoutesAlikeThroughBothEntryPoints(t *testing.T) { t.Parallel() - operations := []configv1alpha3.OperationType{configv1alpha3.OperationCreate} store := NewStore() watchRule := configv1alpha3.WatchRule{ Spec: configv1alpha3.WatchRuleSpec{ Rules: []configv1alpha3.ResourceRule{{ - Operations: operations, APIGroups: []string{"apps"}, APIVersions: []string{"v1"}, Resources: []string{"deployments"}, @@ -1188,7 +1016,6 @@ func TestSharedSelector_RoutesAlikeThroughBothEntryPoints(t *testing.T) { clusterRule := configv1alpha3.ClusterWatchRule{ Spec: configv1alpha3.ClusterWatchRuleSpec{ Rules: []configv1alpha3.ClusterResourceRule{{ - Operations: operations, APIGroups: []string{"apps"}, APIVersions: []string{"v1"}, Resources: []string{"deployments"}, @@ -1205,28 +1032,25 @@ func TestSharedSelector_RoutesAlikeThroughBothEntryPoints(t *testing.T) { foreign.SetNamespace("tenant-other") probes := []struct { - name string - object *unstructured.Unstructured - resource string - operation configv1alpha3.OperationType - group string - version string - want bool + name string + object *unstructured.Unstructured + resource string + group string + version string + want bool // clusterWant differs only where scope, not the selector, decides. clusterWant *bool }{ {name: "exact hit", object: inScope, resource: "deployments", - operation: configv1alpha3.OperationCreate, group: "apps", version: "v1", want: true}, - {name: "operation not selected", object: inScope, resource: "deployments", - operation: configv1alpha3.OperationDelete, group: "apps", version: "v1", want: false}, + group: "apps", version: "v1", want: true}, {name: "wrong group", object: inScope, resource: "deployments", - operation: configv1alpha3.OperationCreate, group: "batch", version: "v1", want: false}, + group: "batch", version: "v1", want: false}, {name: "wrong version", object: inScope, resource: "deployments", - operation: configv1alpha3.OperationCreate, group: "apps", version: "v2", want: false}, + group: "apps", version: "v2", want: false}, {name: "wrong resource", object: inScope, resource: "statefulsets", - operation: configv1alpha3.OperationCreate, group: "apps", version: "v1", want: false}, + group: "apps", version: "v1", want: false}, {name: "unauthorized namespace", object: foreign, resource: "deployments", - operation: configv1alpha3.OperationCreate, group: "apps", version: "v1", + group: "apps", version: "v1", want: false, clusterWant: ptr(true)}, } @@ -1235,7 +1059,7 @@ func TestSharedSelector_RoutesAlikeThroughBothEntryPoints(t *testing.T) { t.Parallel() namespaced := store.GetMatchingRules( - probe.object, probe.resource, probe.operation, probe.group, probe.version, false) + probe.object, probe.resource, probe.group, probe.version, false) if got := len(namespaced) == 1; got != probe.want { t.Errorf("WatchRule routing: want match=%v, got %v", probe.want, got) } @@ -1247,7 +1071,7 @@ func TestSharedSelector_RoutesAlikeThroughBothEntryPoints(t *testing.T) { wantCluster = *probe.clusterWant } cluster := store.GetMatchingClusterRules( - probe.resource, probe.operation, probe.group, probe.version, true, nil) + probe.resource, probe.group, probe.version, true, nil) if got := len(cluster) == 1; got != wantCluster { t.Errorf("ClusterWatchRule routing: want match=%v, got %v", wantCluster, got) } @@ -1298,10 +1122,9 @@ func TestSnapshotWatchRules_SourceNamespacesStayDefensivelyCopied(t *testing.T) func TestStore_OwnsItsSelectorSlices(t *testing.T) { t.Parallel() - // selectorOf reads the four slices back off whichever kind the case compiled, so one table can + // selectorOf reads the three slices back off whichever kind the case compiled, so one table can // assert the same property for both. type selectors struct { - operations []configv1alpha3.OperationType apiGroups []string apiVersions []string resources []string @@ -1317,7 +1140,6 @@ func TestStore_OwnsItsSelectorSlices(t *testing.T) { name: "WatchRule", install: func(store *RuleStore) (func(), func() selectors) { item := configv1alpha3.ResourceRule{ - Operations: []configv1alpha3.OperationType{configv1alpha3.OperationCreate}, APIGroups: []string{"apps"}, APIVersions: []string{"v1"}, Resources: []string{"deployments"}, @@ -1331,13 +1153,12 @@ func TestStore_OwnsItsSelectorSlices(t *testing.T) { "provider", "tenant-acme", "main", "clusters") return func() { - item.Operations[0] = configv1alpha3.OperationDelete item.APIGroups[0] = "batch" item.APIVersions[0] = "v2" item.Resources[0] = "statefulsets" }, func() selectors { snap := store.SnapshotWatchRules()[0].ResourceRules[0] - return selectors{snap.Operations, snap.APIGroups, snap.APIVersions, snap.Resources} + return selectors{snap.APIGroups, snap.APIVersions, snap.Resources} } }, }, @@ -1345,7 +1166,6 @@ func TestStore_OwnsItsSelectorSlices(t *testing.T) { name: "ClusterWatchRule", install: func(store *RuleStore) (func(), func() selectors) { item := configv1alpha3.ClusterResourceRule{ - Operations: []configv1alpha3.OperationType{configv1alpha3.OperationCreate}, APIGroups: []string{"apps"}, APIVersions: []string{"v1"}, Resources: []string{"deployments"}, @@ -1360,29 +1180,24 @@ func TestStore_OwnsItsSelectorSlices(t *testing.T) { "tenant-acme", "main", "clusters") return func() { - item.Operations[0] = configv1alpha3.OperationDelete item.APIGroups[0] = "batch" item.APIVersions[0] = "v2" item.Resources[0] = "statefulsets" }, func() selectors { snap := store.SnapshotClusterWatchRules()[0].Rules[0] - return selectors{snap.Operations, snap.APIGroups, snap.APIVersions, snap.Resources} + return selectors{snap.APIGroups, snap.APIVersions, snap.Resources} } }, }, } want := selectors{ - operations: []configv1alpha3.OperationType{configv1alpha3.OperationCreate}, apiGroups: []string{"apps"}, apiVersions: []string{"v1"}, resources: []string{"deployments"}, } assertSelectors := func(t *testing.T, boundary string, got selectors) { t.Helper() - if got.operations[0] != want.operations[0] { - t.Errorf("%s: operations reached the store: %v", boundary, got.operations) - } if got.apiGroups[0] != want.apiGroups[0] { t.Errorf("%s: apiGroups reached the store: %v", boundary, got.apiGroups) } @@ -1407,7 +1222,6 @@ func TestStore_OwnsItsSelectorSlices(t *testing.T) { // Boundary two: a snapshot the store handed out, which callers may freely modify. snapshot := stored() - snapshot.operations[0] = configv1alpha3.OperationDelete snapshot.apiGroups[0] = "batch" snapshot.apiVersions[0] = "v2" snapshot.resources[0] = "statefulsets" diff --git a/internal/telemetry/exporter.go b/internal/telemetry/exporter.go index a387fe806..7e63c0d70 100644 --- a/internal/telemetry/exporter.go +++ b/internal/telemetry/exporter.go @@ -136,8 +136,8 @@ var ( // WatchEventsTotal is the ingest census: every event a target watch delivers is counted here // exactly once, labelled by {gittarget_namespace, gittarget_name, group, version, resource, // outcome}. It is the first stage of the pipeline and, until it existed, the only stage with no - // instrument at all — "nothing is happening", "the rules filtered everything" and "delivery is - // failing" were indistinguishable from outside. + // instrument at all — "nothing is happening", "nothing changed" and "delivery is failing" + // were indistinguishable from outside. // // outcome is a frozen enum, and its values fall into three classes rather than "healthy and // unhealthy" — several of these are correct outcomes: @@ -146,7 +146,6 @@ var ( // routed — reached the writer. The number the funnel starts from. // unchanged — a live UPDATE whose sanitized content equals what Git already // holds: the /status-churn case, expected in volume. - // operation_filtered — the rule's operation set does not select this verb. // bookmark — a watch bookmark, carrying cursor progress and no object. // shutdown — the stream was cancelled while the event waited for its author. // Not loss: a restart replays and resyncs the object. @@ -211,7 +210,7 @@ var ( // It was called TargetReconcileCompletedTotal with a `trigger` label whose documented value // (`rule_change`) the code never emitted. The name described the caller rather than the event. // - // No `version` label: a recovery covers a CELL, which is keyed by group/resource, and the + // No `version` label: a recovery covers a COLLECTION, which is keyed by group/resource, and the // per-type reconcile path genuinely does not know a served version. An empty version on that // arm beside a populated one on the cursor-resume arm would be worse than no label. // diff --git a/internal/types/cell.go b/internal/types/collection.go similarity index 60% rename from internal/types/cell.go rename to internal/types/collection.go index b53216f08..d7f62185a 100644 --- a/internal/types/cell.go +++ b/internal/types/collection.go @@ -6,47 +6,48 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" ) -// CellKey identifies one watched slice of one cluster: a type, and optionally one namespace. +// CollectionKey identifies one resource collection of one cluster: a type, and optionally one +// namespace. // It is the shared identity of a target-watch stream, the render-fidelity scope that stream // reports into, and the mark-and-sweep boundary its resync runs under. One key, one boundary. // // # The version is deliberately absent // -// A cell is named by GROUP, RESOURCE and NAMESPACE, never by served version. The three +// A collection is named by GROUP, RESOURCE and NAMESPACE, never by served version. The three // identities above used to carry a full GroupVersionResource while the only thing that -// actually compared them — the sweep's Matches — ignored the version, so two cells differing +// actually compared them — the sweep's Matches — ignored the version, so two collections differing // only in served version were distinct keys but one sweep boundary. A key that does not // round-trip to the scope it sweeps under is the one class of error that deletes user data, // so the version was removed from the identity rather than added to the comparison. // // This matches the identity Git already uses: [ResourceIdentifier.ToGitPath] is versionless, -// so a storage-version bump moves no file. A cell whose identity changed with the served +// so a storage-version bump moves no file. A collection whose identity changed with the served // version would sweep a boundary that the files inside it do not share. // // The served version is still needed — to open a watch, to render a reconcile commit message — -// but it travels as DATA on whatever carries the cell, never as part of the key. -type CellKey struct { +// but it travels as DATA on whatever carries the collection, never as part of the key. +type CollectionKey struct { // Group is the API group, empty for the core group. Group string // Resource is the plural resource name, e.g. "configmaps". Resource string - // Namespace restricts the cell to one namespace. Empty is a genuinely cluster-wide - // (all-namespaces) cell, which is a PEER of any named namespace on the same type and never a - // replacement for it: collapsing the two widened the named rule's stream to every namespace - // its credential could read and discarded its operation filter. + // Namespace restricts the collection to one namespace. Empty is a genuinely cluster-wide + // (all-namespaces) collection, which is a PEER of any named namespace on the same type and never + // a replacement for it: collapsing the two widened the named rule's stream to every namespace + // its credential could read. Namespace string } -// CellKeyFor builds a cell key from a served GVR and a namespace, dropping the version. +// CollectionKeyFor builds a collection key from a served GVR and a namespace, dropping the version. // It is the single conversion from "the version we happen to be serving" to "the slice we // manage", so no call site has to remember that the version is not identity. -func CellKeyFor(gvr schema.GroupVersionResource, namespace string) CellKey { - return CellKey{Group: gvr.Group, Resource: gvr.Resource, Namespace: namespace} +func CollectionKeyFor(gvr schema.GroupVersionResource, namespace string) CollectionKey { + return CollectionKey{Group: gvr.Group, Resource: gvr.Resource, Namespace: namespace} } -// String renders the cell for logs, map keys and status messages: +// String renders the collection for logs, map keys and status messages: // "configmaps in team-a", "deployments.apps" (cluster-wide). -func (c CellKey) String() string { +func (c CollectionKey) String() string { name := c.Resource if c.Group != "" { name += "." + c.Group @@ -57,10 +58,10 @@ func (c CellKey) String() string { return name + " in " + c.Namespace } -// Matches reports whether a resolved resource identity falls inside this cell. An empty +// Matches reports whether a resolved resource identity falls inside this collection. An empty // Namespace matches every namespace for the type; the version is not compared, for the // reason given on the type. -func (c CellKey) Matches(ri ResourceIdentifier) bool { +func (c CollectionKey) Matches(ri ResourceIdentifier) bool { if ri.Group != c.Group || ri.Resource != c.Resource { return false } diff --git a/internal/types/cell_test.go b/internal/types/collection_test.go similarity index 60% rename from internal/types/cell_test.go rename to internal/types/collection_test.go index 16b95529d..6dc83ef3b 100644 --- a/internal/types/cell_test.go +++ b/internal/types/collection_test.go @@ -9,47 +9,47 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" ) -// The property the whole type exists for: the served version is not identity. Two cells of -// one logical resource that differ only in served version are ONE cell, because they are one +// The property the whole type exists for: the served version is not identity. Two collections of +// one logical resource that differ only in served version are ONE collection, because they are one // sweep boundary — the discrepancy recorded in docs/design/target-watch-plan.md, // "Diff the plan". -func TestCellKeyFor_DropsTheServedVersion(t *testing.T) { - v1 := CellKeyFor(schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}, "team-a") - v1beta1 := CellKeyFor( +func TestCollectionKeyFor_DropsTheServedVersion(t *testing.T) { + v1 := CollectionKeyFor(schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}, "team-a") + v1beta1 := CollectionKeyFor( schema.GroupVersionResource{Group: "apps", Version: "v1beta1", Resource: "deployments"}, "team-a") - assert.Equal(t, v1, v1beta1, "one logical resource in one namespace is one cell") + assert.Equal(t, v1, v1beta1, "one logical resource in one namespace is one collection") assert.Equal(t, v1.String(), v1beta1.String(), "and one map key, wherever the string is the key") } -func TestCellKey_StringNamesTheTypeAndTheNamespace(t *testing.T) { - clusterWide := CellKeyFor(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, "") +func TestCollectionKey_StringNamesTheTypeAndTheNamespace(t *testing.T) { + clusterWide := CollectionKeyFor(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, "") assert.Equal(t, "configmaps", clusterWide.String()) - grouped := CellKeyFor(schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}, "") + grouped := CollectionKeyFor(schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}, "") assert.Equal(t, "deployments.apps", grouped.String()) - scoped := CellKeyFor(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, "team-a") + scoped := CollectionKeyFor(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, "team-a") assert.Equal(t, "configmaps in team-a", scoped.String()) assert.NotEqual(t, clusterWide.String(), scoped.String(), - "a cluster-wide cell is a PEER of a named namespace, never the same key") + "a cluster-wide collection is a PEER of a named namespace, never the same key") } -func TestCellKey_Matches(t *testing.T) { +func TestCollectionKey_Matches(t *testing.T) { cmInTeamA := NewResourceIdentifier("", "v1", "configmaps", "team-a", "app") cmInTeamB := NewResourceIdentifier("", "v1", "configmaps", "team-b", "app") deployInTeamA := NewResourceIdentifier("apps", "v1", "deployments", "team-a", "app") - clusterWide := CellKeyFor(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, "") + clusterWide := CollectionKeyFor(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, "") assert.True(t, clusterWide.Matches(cmInTeamA)) assert.True(t, clusterWide.Matches(cmInTeamB), "an empty namespace is every namespace") assert.False(t, clusterWide.Matches(deployInTeamA)) - scoped := CellKeyFor(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, "team-a") + scoped := CollectionKeyFor(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, "team-a") assert.True(t, scoped.Matches(cmInTeamA)) assert.False(t, scoped.Matches(cmInTeamB)) - // A resource observed at another served version is still inside the cell: the mirror + // A resource observed at another served version is still inside the collection: the mirror // stores it at one versionless path, so a sweep that skipped it would leave it unmanaged. assert.True(t, scoped.Matches(NewResourceIdentifier("", "v2", "configmaps", "team-a", "app"))) } diff --git a/internal/types/operation.go b/internal/types/operation.go new file mode 100644 index 000000000..aeeda084a --- /dev/null +++ b/internal/types/operation.go @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: Apache-2.0 + +package types + +// OperationType classifies one observed change to an object: its creation, an update, or its +// deletion. It tags watch events and audit verbs alike, so both paths agree on what happened. +// It is not a selection switch: every selected resource collection is observed through its +// whole lifecycle, and the GitTarget's prune mode decides what a deletion does to Git. +type OperationType string + +const ( + // OperationCreate tags an object's creation. + OperationCreate OperationType = "CREATE" + // OperationUpdate tags a change to an existing object. + OperationUpdate OperationType = "UPDATE" + // OperationDelete tags an object's deletion. + OperationDelete OperationType = "DELETE" +) diff --git a/internal/typeset/lifecycle.go b/internal/typeset/lifecycle.go index 02d8abc3b..28dc69352 100644 --- a/internal/typeset/lifecycle.go +++ b/internal/typeset/lifecycle.go @@ -72,9 +72,9 @@ type Observer func(LifecycleEvent) // only signal that can tell a type genuinely withdrawn (a settled TypeRemoved, past // RemovalGrace) from a discovery wobble, and mistaking the second for the first is what deletes // a user's manifests. Its consumer is the `stop` classification in -// docs/design/target-watch-plan.md, "What a cell leaving means". Subscribing and classifying is +// docs/design/target-watch-plan.md, "What a collection leaving means". Subscribing and classifying is // the first half of that plan's third change and needs nothing decided first: a settled removal -// drops its cell from the plan, and the Git-side sweep converges the mirror under the target's +// drops its collection from the plan, and the Git-side sweep converges the mirror under the target's // existing spec.prune.mode. Only removal on INTENT waits on an open decision. Do not delete this // as dead: rebuilding the settle window and the grace correctly is the expensive half, and it is // already written and tested here. The Materializer, which consumed these events before the diff --git a/internal/watch/config_plane_split_review_fixes_test.go b/internal/watch/config_plane_split_review_fixes_test.go index d1a109a41..bcdd39c4a 100644 --- a/internal/watch/config_plane_split_review_fixes_test.go +++ b/internal/watch/config_plane_split_review_fixes_test.go @@ -165,9 +165,8 @@ func TestResourceReferenceFromKey_RoundTrips(t *testing.T) { func installFakeWatch(m *Manager, ref types.ResourceReference) *bool { cancelled := new(bool) key := targetWatchKey{GVR: configmapsGVR} - m.targetWatchSet(ref).streams[key.Cell()] = &runningTargetWatch{ + m.targetWatchSet(ref).streams[key.Collection()] = &runningTargetWatch{ key: key, - spec: "[*]", cancel: func() { *cancelled = true }, } return cancelled diff --git a/internal/watch/event_router.go b/internal/watch/event_router.go index ed3d92156..d90b791f1 100644 --- a/internal/watch/event_router.go +++ b/internal/watch/event_router.go @@ -167,7 +167,7 @@ func (r *EventRouter) enqueueScopedResync( ctx context.Context, gitDest types.ResourceReference, scope git.ResyncScope, - sourceCell types.CellKey, + sourceCollection types.CollectionKey, desired []manifestanalyzer.DesiredResource, resourceVersion string, heal bool, @@ -184,7 +184,7 @@ func (r *EventRouter) enqueueScopedResync( GitTargetName: gitDest.Name, GitTargetNamespace: gitDest.Namespace, Scope: &scope, - SourceCell: sourceCell, + SourceCollection: sourceCollection, Heal: heal, RefreshRemote: refreshRemote, Result: resultCh, @@ -206,7 +206,7 @@ func resyncScopeForWatchKey(key targetWatchKey) git.ResyncScope { // never re-fires the gather. func (r *EventRouter) drainScopedResync( gitDest types.ResourceReference, - cell types.CellKey, + collection types.CollectionKey, kind string, renderFidelityEpoch uint64, resultCh chan git.ResyncResult, @@ -214,29 +214,34 @@ func (r *EventRouter) drainScopedResync( select { case result := <-resultCh: if result.Err != nil { - r.handleScopedResyncError(gitDest, cell, kind, renderFidelityEpoch, result.Err) + r.handleScopedResyncError(gitDest, collection, kind, renderFidelityEpoch, result.Err) return } r.Log.V(1).Info("per-type "+kind+" applied", - "gitDest", gitDest.String(), "cell", cell.String(), + "gitDest", gitDest.String(), "collection", collection.String(), "created", result.Stats.Created, "updated", result.Stats.Updated, "deleted", result.Stats.Deleted) if r.WatchManager != nil { - r.WatchManager.MarkTargetGitPathScopeAccepted(gitDest, cell) - r.WatchManager.MarkTargetRenderFidelityScopeClean(gitDest, renderFidelityEpoch, cell) + r.WatchManager.MarkTargetGitPathScopeAccepted(gitDest, collection) + r.WatchManager.MarkTargetRenderFidelityScopeClean(gitDest, renderFidelityEpoch, collection) // Recorded for every applied resync, including the ones that retained nothing: zero // is the converged signal and is only meaningful if it is published as actively as a // non-zero count. r.WatchManager.MarkTargetRetention( - gitDest, cell, renderFidelityEpoch, result.Stats.PruneMode, result.Stats.Retained) + gitDest, collection, renderFidelityEpoch, result.Stats.PruneMode, result.Stats.Retained) } // Count an applied per-type RECONCILE as a completed GitTarget reconcile so the // per-pod counter advances after a restart — the drain signal the restart-reconcile // e2e gate reads (a sweep is excluded; it is a removal, not a steady-state reconcile). if kind == "reconcile" && r.WatchManager != nil { - r.WatchManager.recordWatchRecovery(gitDest, cell.Group, cell.Resource, recoveryModeTypeReconcile) + r.WatchManager.recordWatchRecovery( + gitDest, + collection.Group, + collection.Resource, + recoveryModeTypeReconcile, + ) } case <-time.After(resyncSignalTimeout): - r.Log.Error(nil, "per-type "+kind+" timed out", "gitDest", gitDest.String(), "cell", cell.String()) + r.Log.Error(nil, "per-type "+kind+" timed out", "gitDest", gitDest.String(), "collection", collection.String()) r.recordBackgroundResyncFailure(gitDest) } } @@ -248,7 +253,7 @@ func (r *EventRouter) drainScopedResync( // remains observable. func (r *EventRouter) handleScopedResyncError( gitDest types.ResourceReference, - cell types.CellKey, + collection types.CollectionKey, kind string, renderFidelityEpoch uint64, err error, @@ -257,18 +262,18 @@ func (r *EventRouter) handleScopedResyncError( if errors.As(err, &refused) { if refused.AllIssuesOfKinds(manifestanalyzer.IssueRenderDoesNotMatchLive) { r.Log.Info("per-type "+kind+" found a render-vs-live divergence", - "gitDest", gitDest.String(), "cell", cell.String(), "detail", refused.Error()) + "gitDest", gitDest.String(), "collection", collection.String(), "detail", refused.Error()) if r.WatchManager != nil { r.WatchManager.MarkTargetRenderFidelityScopeDiverged( - gitDest, renderFidelityEpoch, cell, renderFidelityDivergence(refused)) + gitDest, renderFidelityEpoch, collection, renderFidelityDivergence(refused)) } return } r.Log.Info("per-type "+kind+" refused: unsupported GitTarget path content", - "gitDest", gitDest.String(), "cell", cell.String(), "detail", refused.Error()) + "gitDest", gitDest.String(), "collection", collection.String(), "detail", refused.Error()) if r.WatchManager != nil { r.WatchManager.MarkTargetGitPathScopeRefused( - gitDest, cell, gitPathRefusalReason(refused), refused.BlockMessage()) + gitDest, collection, gitPathRefusalReason(refused), refused.BlockMessage()) } return } @@ -285,10 +290,10 @@ func (r *EventRouter) handleScopedResyncError( // skips the RETENTION report too, and lowering it re-blinded that path in the very local // reproduction of Failure B that followed. Lower it again only when B is closed. r.Log.Info("per-type "+kind+" superseded by a newer resync; its roll-up reports were skipped", - "gitDest", gitDest.String(), "cell", cell.String()) + "gitDest", gitDest.String(), "collection", collection.String()) return } - r.Log.Error(err, "per-type "+kind+" failed", "gitDest", gitDest.String(), "cell", cell.String()) + r.Log.Error(err, "per-type "+kind+" failed", "gitDest", gitDest.String(), "collection", collection.String()) r.recordBackgroundResyncFailure(gitDest) } diff --git a/internal/watch/event_router_test.go b/internal/watch/event_router_test.go index a5887b50b..fa555fd1d 100644 --- a/internal/watch/event_router_test.go +++ b/internal/watch/event_router_test.go @@ -103,7 +103,7 @@ func TestEnqueueScopedResync_ReportsMissingWorker(t *testing.T) { context.Background(), types.NewResourceReference("team-a-config", "team-a"), git.ResyncScopeFor(configmapsGVR, ""), - types.CellKey{}, + types.CollectionKey{}, nil, "12", false, @@ -137,7 +137,7 @@ func TestEnqueueScopedResync_ReportsGoneGitTargetAsTerminal(t *testing.T) { context.Background(), types.NewResourceReference("deleted-target", "team-a"), git.ResyncScopeFor(configmapsGVR, ""), - types.CellKey{}, + types.CollectionKey{}, nil, "12", false, @@ -173,7 +173,7 @@ func TestDrainScopedResync_TreatsSupersededAsSuccess(t *testing.T) { defer close(done) router.drainScopedResync( types.NewResourceReference("team-a-config", "team-a"), - types.CellKeyFor(configmapsGVR, ""), + types.CollectionKeyFor(configmapsGVR, ""), "sweep", 0, resultCh, @@ -204,7 +204,7 @@ func TestDrainScopedResync_CompletesSuccessfulResult(t *testing.T) { go func() { router.drainScopedResync( types.NewResourceReference("team-a-config", "team-a"), - types.CellKeyFor(configmapsGVR, ""), + types.CollectionKeyFor(configmapsGVR, ""), "reconcile", 0, resultCh, @@ -244,7 +244,7 @@ func TestDrainScopedResync_RefusalMarksGitPathRefused(t *testing.T) { resultCh := make(chan git.ResyncResult, 1) resultCh <- git.ResyncResult{Err: fmt.Errorf("execute pending writes: %w", refusal)} - router.drainScopedResync(gitDest, key.Cell(), "reconcile", 0, resultCh) + router.drainScopedResync(gitDest, key.Collection(), "reconcile", 0, resultCh) gitPath := mgr.GitPathAcceptanceForGitTarget(gitDest) @@ -376,7 +376,7 @@ func TestServiceCommitRequest_RegisteredWorkerResolvesNoOpenWindow(t *testing.T) // // enqueueReplayResync used to return on !enqueued BEFORE starting the drain, leaving that reply in // a buffered channel nobody read. With it went the only calls that mark acceptance, render -// fidelity and retention for the cell, so the render-fidelity scope owed a report under a revision +// fidelity and retention for the collection, so the render-fidelity scope owed a report under a revision // no running stream would ever report again — which pins the GitTarget at Ready=False and, through // GitTargetReady, every WatchRule pointing at it. // @@ -395,14 +395,14 @@ func TestDrainScopedResync_QueueFullIsDrainedNotOrphaned(t *testing.T) { router := NewEventRouter(workerManager, mgr, client, logr.Discard()) gitDest := types.NewResourceReference("team-a-config", "team-a") - cell := types.CellKeyFor(configmapsGVR, "") + collection := types.CollectionKeyFor(configmapsGVR, "") resultCh := make(chan git.ResyncResult, 1) resultCh <- git.ResyncResult{Err: git.ErrFinalizeQueueFull} done := make(chan struct{}) go func() { defer close(done) - router.drainScopedResync(gitDest, cell, "reconcile", 1, resultCh) + router.drainScopedResync(gitDest, collection, "reconcile", 1, resultCh) }() select { diff --git a/internal/watch/git_path_acceptance.go b/internal/watch/git_path_acceptance.go index eed3786f4..ebc35dcc9 100644 --- a/internal/watch/git_path_acceptance.go +++ b/internal/watch/git_path_acceptance.go @@ -17,21 +17,21 @@ type gitPathRefusal struct { Refused bool Reason string Message string - // Cell is the watched cell a WRITE refusal was found for, and CellSet says whether it was + // Collection is the watched collection a WRITE refusal was found for, and CollectionSet says whether it was // scoped at all. Recovery compares it, so an unrelated successful resync cannot clear a // refusal it proved nothing about. - Cell types.CellKey - CellSet bool - At metav1.Time + Collection types.CollectionKey + CollectionSet bool + At metav1.Time } // gitPathAcceptance holds the two verdicts about one folder SEPARATELY, and that separation is the // design rather than an implementation detail. // // Two producers answer the same question and they are not interchangeable. A WRITE refusal is -// evidence that something with work in hand could not proceed; it names a cell, and only a resync -// that wrote that cell proves it gone. A SCAN refusal is evidence that a read of the folder found -// structure nobody can write; it has no cell, and the read that raised it is what clears it. +// evidence that something with work in hand could not proceed; it names a collection, and only a resync +// that wrote that collection proves it gone. A SCAN refusal is evidence that a read of the folder found +// structure nobody can write; it has no collection, and the read that raised it is what clears it. // // They shared one slot in the first cut, and every one of the three defects a review found came // from that: each producer overwrote the other's verdict, so a scan could erase an unresolved @@ -49,17 +49,17 @@ type gitPathAcceptance struct { // The precedence is fixed rather than most-recent on purpose. Recency would let two producers // running at their own cadences move the published view back and forth, which is the feedback // loop this structure exists to remove. A write refusal wins because it is the more actionable of -// the two: it names the cell whose successful resync is the way out. +// the two: it names the collection whose successful resync is the way out. func (a gitPathAcceptance) published() GitPathAcceptanceStatus { switch { case a.write.Refused: return GitPathAcceptanceStatus{ - Accepted: false, - Reason: a.write.Reason, - Message: a.write.Message, - At: a.write.At, - RefusedCell: a.write.Cell, - RefusedCellSet: a.write.CellSet, + Accepted: false, + Reason: a.write.Reason, + Message: a.write.Message, + At: a.write.At, + RefusedCollection: a.write.Collection, + RefusedCollectionSet: a.write.CollectionSet, } case a.scan.Refused: return GitPathAcceptanceStatus{ @@ -79,8 +79,8 @@ func sameAs(a, b GitPathAcceptanceStatus) bool { return a.Accepted == b.Accepted && a.Reason == b.Reason && a.Message == b.Message && - a.RefusedCell == b.RefusedCell && - a.RefusedCellSet == b.RefusedCellSet + a.RefusedCollection == b.RefusedCollection && + a.RefusedCollectionSet == b.RefusedCollectionSet } // ReportGitPathRefusal records a write plan the branch worker refused on a live-event path, @@ -90,21 +90,21 @@ func sameAs(a, b GitPathAcceptanceStatus) bool { // whether it was a live write or a background resync that hit it. func (m *Manager) ReportGitPathRefusal( gitDest types.ResourceReference, - cell types.CellKey, + collection types.CollectionKey, refused *manifestanalyzer.AcceptanceRefusedError, ) { if refused.AllIssuesOfKinds(manifestanalyzer.IssueRenderDoesNotMatchLive) { m.MarkTargetRenderFidelityDiverged(gitDest, renderFidelityDivergence(refused)) return } - m.MarkTargetGitPathScopeRefused(gitDest, cell, gitPathRefusalReason(refused), refused.BlockMessage()) + m.MarkTargetGitPathScopeRefused(gitDest, collection, gitPathRefusalReason(refused), refused.BlockMessage()) } // ReportGitPathScan publishes what a read-only scan of the folder found: a structural refusal // nobody tried to write, or its recovery when refused is nil. // // It is the refresher's channel, and it is separate from ReportGitPathRefusal because a scan has -// no cell: it reads the whole folder, so its verdict is target-wide and cannot take part in the +// no collection: it reads the whole folder, so its verdict is target-wide and cannot take part in the // scoped recovery that write refusals depend on. // // Structure-only acceptance cannot produce a render-fidelity issue — that one needs the live @@ -128,7 +128,7 @@ func (m *Manager) ReportGitPathScan( // snapshot and enqueues a reconcile, so the happy-path resync stream does not enqueue one per // event. func (m *Manager) MarkTargetGitPathRefused(gitDest types.ResourceReference, reason, message string) { - m.MarkTargetGitPathScopeRefused(gitDest, types.CellKey{}, reason, message) + m.MarkTargetGitPathScopeRefused(gitDest, types.CollectionKey{}, reason, message) } // MarkTargetGitPathScopeRefused records that a scoped writer pass failed the structure-only @@ -136,18 +136,18 @@ func (m *Manager) MarkTargetGitPathRefused(gitDest types.ResourceReference, reas // unrelated successful resync from clearing it. func (m *Manager) MarkTargetGitPathScopeRefused( gitDest types.ResourceReference, - cell types.CellKey, + collection types.CollectionKey, reason string, message string, ) { m.mutateGitPathAcceptance(gitDest, func(a *gitPathAcceptance) { a.write = gitPathRefusal{ - Refused: true, - Reason: reason, - Message: message, - Cell: cell, - CellSet: cell != (types.CellKey{}), - At: metav1.Now(), + Refused: true, + Reason: reason, + Message: message, + Collection: collection, + CollectionSet: collection != (types.CollectionKey{}), + At: metav1.Now(), } }) } @@ -173,19 +173,19 @@ func (m *Manager) MarkTargetGitPathAccepted(gitDest types.ResourceReference) { } // MarkTargetGitPathScopeAccepted clears a write refusal only when the successful resync covered -// the same cell that produced it. A successful replay of secrets must not hide a still impossible +// the same collection that produced it. A successful replay of secrets must not hide a still impossible // configmaps path, because the GitTarget status is the operator's only durable clue. // // It clears the SCAN verdict unconditionally, and that asymmetry is deliberate: the resync ran the // same structure-only gate the scan runs, over the same folder, and passed. That is strictly // stronger evidence than a read, so a scan refusal left standing beside it would be stale. -func (m *Manager) MarkTargetGitPathScopeAccepted(gitDest types.ResourceReference, cell types.CellKey) { +func (m *Manager) MarkTargetGitPathScopeAccepted(gitDest types.ResourceReference, collection types.CollectionKey) { m.mutateGitPathAcceptance(gitDest, func(a *gitPathAcceptance) { a.scan = gitPathRefusal{} if !a.write.Refused { return } - if cell != (types.CellKey{}) && (!a.write.CellSet || a.write.Cell != cell) { + if collection != (types.CollectionKey{}) && (!a.write.CollectionSet || a.write.Collection != collection) { return } a.write = gitPathRefusal{} diff --git a/internal/watch/git_path_acceptance_scan_test.go b/internal/watch/git_path_acceptance_scan_test.go index 21082d6a3..971cb4baf 100644 --- a/internal/watch/git_path_acceptance_scan_test.go +++ b/internal/watch/git_path_acceptance_scan_test.go @@ -22,7 +22,7 @@ func scanRefusal() *manifestanalyzer.AcceptanceRefusedError { } } -func configMapCell() types.CellKey { return types.CellKey{Resource: "configmaps"} } +func configMapCollection() types.CollectionKey { return types.CollectionKey{Resource: "configmaps"} } // TestReportGitPathScan_RaisesAndRecovers is the pair the refresher needs: a read publishes a // refusal nobody tried to write, and a later read that passes takes it back. Both wake the @@ -36,7 +36,7 @@ func TestReportGitPathScan_RaisesAndRecovers(t *testing.T) { status := m.GitPathAcceptanceForGitTarget(gitDest) assert.False(t, status.Accepted) - assert.False(t, status.RefusedCellSet, "a whole-folder scan has no cell to scope it to") + assert.False(t, status.RefusedCollectionSet, "a whole-folder scan has no collection to scope it to") require.Len(t, events, 1) m.ReportGitPathScan(gitDest, nil) @@ -56,13 +56,13 @@ func TestGitPathAcceptance_AStandingRefusalIsReportedOnceByEachProducer(t *testi gitDest := types.NewResourceReference("checkout", "shop") m.ReportGitPathScan(gitDest, scanRefusal()) - m.MarkTargetGitPathScopeRefused(gitDest, configMapCell(), "Unsupported", "the same folder, from a write") + m.MarkTargetGitPathScopeRefused(gitDest, configMapCollection(), "Unsupported", "the same folder, from a write") settled := len(events) // Three more rounds of exactly what is already known. for range 3 { m.ReportGitPathScan(gitDest, scanRefusal()) - m.MarkTargetGitPathScopeRefused(gitDest, configMapCell(), "Unsupported", "the same folder, from a write") + m.MarkTargetGitPathScopeRefused(gitDest, configMapCollection(), "Unsupported", "the same folder, from a write") } assert.Len(t, events, settled, @@ -78,22 +78,28 @@ func TestGitPathAcceptance_AScanNeverErasesAnUnresolvedWriteRefusal(t *testing.T m := &Manager{} gitDest := types.NewResourceReference("checkout", "shop") - m.MarkTargetGitPathScopeRefused(gitDest, configMapCell(), "WriteBoundary", "this write may not touch that file") + m.MarkTargetGitPathScopeRefused( + gitDest, + configMapCollection(), + "WriteBoundary", + "this write may not touch that file", + ) m.ReportGitPathScan(gitDest, scanRefusal()) m.ReportGitPathScan(gitDest, nil) status := m.GitPathAcceptanceForGitTarget(gitDest) require.False(t, status.Accepted, "no write has proved the write-boundary refusal resolved") assert.Equal(t, "WriteBoundary", status.Reason) - assert.Equal(t, configMapCell(), status.RefusedCell, "and its cell survived, so recovery still has a key") + assert.Equal(t, configMapCollection(), status.RefusedCollection, + "and its collection survived, so recovery still has a key") // The write's own recovery still works, and clears it. - m.MarkTargetGitPathScopeAccepted(gitDest, configMapCell()) + m.MarkTargetGitPathScopeAccepted(gitDest, configMapCollection()) assert.True(t, m.GitPathAcceptanceForGitTarget(gitDest).Accepted) } // TestGitPathAcceptance_AScanRefusalIsRecordedEvenWhenItReadsLikeTheWriteRefusal. A write refusal -// can be unscoped — a commit window spanning several cells reports one — and a scan refusal about +// can be unscoped — a commit window spanning several collections reports one — and a scan refusal about // the same folder can carry the same reason and message. Dropping the scan's verdict for looking // identical left a record whose provenance said "write", so the later clean scan could not clear // it and the target stayed refused with nothing able to recover it. @@ -123,19 +129,19 @@ func TestMarkTargetGitPathScopeAccepted_ClearsTheScanVerdictToo(t *testing.T) { gitDest := types.NewResourceReference("checkout", "shop") m.ReportGitPathScan(gitDest, scanRefusal()) - m.MarkTargetGitPathScopeAccepted(gitDest, configMapCell()) + m.MarkTargetGitPathScopeAccepted(gitDest, configMapCollection()) assert.True(t, m.GitPathAcceptanceForGitTarget(gitDest).Accepted) } -// TestMarkTargetGitPathScopeAccepted_LeavesAnotherCellsRefusal keeps the rule the scoping exists +// TestMarkTargetGitPathScopeAccepted_LeavesAnotherCollectionsRefusal keeps the rule the scoping exists // for: a successful replay of one type must not hide a still impossible path in another. -func TestMarkTargetGitPathScopeAccepted_LeavesAnotherCellsRefusal(t *testing.T) { +func TestMarkTargetGitPathScopeAccepted_LeavesAnotherCollectionsRefusal(t *testing.T) { m := &Manager{} gitDest := types.NewResourceReference("checkout", "shop") - m.MarkTargetGitPathScopeRefused(gitDest, configMapCell(), "WriteBoundary", "configmaps are stuck") - m.MarkTargetGitPathScopeAccepted(gitDest, types.CellKey{Resource: "secrets"}) + m.MarkTargetGitPathScopeRefused(gitDest, configMapCollection(), "WriteBoundary", "configmaps are stuck") + m.MarkTargetGitPathScopeAccepted(gitDest, types.CollectionKey{Resource: "secrets"}) assert.False(t, m.GitPathAcceptanceForGitTarget(gitDest).Accepted) } diff --git a/internal/watch/git_path_acceptance_test.go b/internal/watch/git_path_acceptance_test.go index c6827a4c5..8e0b5c6b3 100644 --- a/internal/watch/git_path_acceptance_test.go +++ b/internal/watch/git_path_acceptance_test.go @@ -31,9 +31,9 @@ import ( func TestReportGitPathRefusal_SurfacesWriteBoundaryRefusal(t *testing.T) { mgr := &Manager{Log: logr.Discard()} gitDest := types.NewResourceReference("podinfo-test", "team-a") - cell := types.CellKeyFor(configmapsGVR, "apps") + collection := types.CollectionKeyFor(configmapsGVR, "apps") - mgr.ReportGitPathRefusal(gitDest, cell, &manifestanalyzer.AcceptanceRefusedError{ + mgr.ReportGitPathRefusal(gitDest, collection, &manifestanalyzer.AcceptanceRefusedError{ Issues: []manifestanalyzer.AcceptanceIssue{{ Kind: manifestanalyzer.IssueWriteFanIn, Path: "base/deployment.yaml", @@ -46,8 +46,8 @@ func TestReportGitPathRefusal_SurfacesWriteBoundaryRefusal(t *testing.T) { assert.Equal(t, "WriteBoundaryRefused", gitPath.Reason, "a pure write-boundary refusal must not hide behind the umbrella UnsupportedContent reason") assert.Contains(t, gitPath.Message, "base/deployment.yaml", "the refusal must name the offending file") - assert.Equal(t, cell, gitPath.RefusedCell) - assert.True(t, gitPath.RefusedCellSet) + assert.Equal(t, collection, gitPath.RefusedCollection) + assert.True(t, gitPath.RefusedCollectionSet) assert.Empty(t, mgr.watchPlane().streams, "a Git path refusal must not mutate stream readiness") } @@ -57,7 +57,7 @@ func TestReportGitPathRefusal_ContentRefusalKeepsUmbrellaReason(t *testing.T) { mgr := &Manager{Log: logr.Discard()} gitDest := types.NewResourceReference("podinfo-test", "team-a") - mgr.ReportGitPathRefusal(gitDest, types.CellKey{}, &manifestanalyzer.AcceptanceRefusedError{ + mgr.ReportGitPathRefusal(gitDest, types.CollectionKey{}, &manifestanalyzer.AcceptanceRefusedError{ Issues: []manifestanalyzer.AcceptanceIssue{{ Kind: manifestanalyzer.IssueForeignFile, Path: "notes.txt", @@ -88,15 +88,15 @@ func TestGitPathAcceptance_MessageChangePublishesStatus(t *testing.T) { func TestGitPathAcceptance_SiblingScopeSuccessDoesNotClearRefusal(t *testing.T) { mgr := &Manager{Log: logr.Discard()} gitDest := types.NewResourceReference("podinfo-test", "team-a") - configMaps := types.CellKeyFor(configmapsGVR, "apps") - secrets := types.CellKeyFor(schema.GroupVersionResource{Version: "v1", Resource: "secrets"}, "apps") + configMaps := types.CollectionKeyFor(configmapsGVR, "apps") + secrets := types.CollectionKeyFor(schema.GroupVersionResource{Version: "v1", Resource: "secrets"}, "apps") mgr.MarkTargetGitPathScopeRefused(gitDest, configMaps, "UnsupportedContent", "configmaps refused") mgr.MarkTargetGitPathScopeAccepted(gitDest, secrets) gitPath := mgr.GitPathAcceptanceForGitTarget(gitDest) assert.False(t, gitPath.Accepted, "an unrelated successful scope must not clear the target refusal") - assert.Equal(t, configMaps, gitPath.RefusedCell) + assert.Equal(t, configMaps, gitPath.RefusedCollection) mgr.MarkTargetGitPathScopeAccepted(gitDest, configMaps) assert.True(t, mgr.GitPathAcceptanceForGitTarget(gitDest).Accepted) @@ -105,15 +105,15 @@ func TestGitPathAcceptance_SiblingScopeSuccessDoesNotClearRefusal(t *testing.T) func TestGitPathAcceptance_ScopedSuccessDoesNotClearUnscopedRefusal(t *testing.T) { mgr := &Manager{Log: logr.Discard()} gitDest := types.NewResourceReference("podinfo-test", "team-a") - configMaps := types.CellKeyFor(configmapsGVR, "apps") + configMaps := types.CollectionKeyFor(configmapsGVR, "apps") mgr.MarkTargetGitPathRefused(gitDest, "UnsupportedContent", "whole target refused") mgr.MarkTargetGitPathScopeAccepted(gitDest, configMaps) assert.False(t, mgr.GitPathAcceptanceForGitTarget(gitDest).Accepted, - "a cell success must not clear a target-wide refusal") + "a collection success must not clear a target-wide refusal") - mgr.MarkTargetGitPathScopeAccepted(gitDest, types.CellKey{}) + mgr.MarkTargetGitPathScopeAccepted(gitDest, types.CollectionKey{}) assert.True(t, mgr.GitPathAcceptanceForGitTarget(gitDest).Accepted, "a whole-target proof may clear an unscoped refusal") } @@ -141,22 +141,22 @@ func TestRenderFidelityStatus_ReducesTheCurrentPlansScopes(t *testing.T) { revisions := manager.restartAllFidelityScopes(target, deployment, other) - manager.MarkTargetRenderFidelityScopeClean(target, revisions[deployment.Cell()], deployment.Cell()) + manager.MarkTargetRenderFidelityScopeClean(target, revisions[deployment.Collection()], deployment.Collection()) assert.Equal(t, git.RenderFidelityUnknown, manager.RenderFidelityForGitTarget(target).State) - manager.MarkTargetRenderFidelityScopeDiverged(target, revisions[other.Cell()], other.Cell(), + manager.MarkTargetRenderFidelityScopeDiverged(target, revisions[other.Collection()], other.Collection(), manifestanalyzer.RenderDivergence{Field: "data.region", Token: "${REGION}"}) assert.Equal(t, git.RenderFidelityFalse, manager.RenderFidelityForGitTarget(target).State) - manager.MarkTargetRenderFidelityScopeClean(target, revisions[other.Cell()], other.Cell()) + manager.MarkTargetRenderFidelityScopeClean(target, revisions[other.Collection()], other.Collection()) assert.Equal(t, git.RenderFidelityFalse, manager.RenderFidelityForGitTarget(target).State, "a later clean result cannot overwrite the failed scope under the same revision") fresh := manager.restartAllFidelityScopes(target, deployment, other) - manager.MarkTargetRenderFidelityScopeClean(target, revisions[deployment.Cell()], deployment.Cell()) + manager.MarkTargetRenderFidelityScopeClean(target, revisions[deployment.Collection()], deployment.Collection()) assert.Equal(t, git.RenderFidelityUnknown, manager.RenderFidelityForGitTarget(target).State, "a stale result from the previous revision must be ignored") - manager.MarkTargetRenderFidelityScopeClean(target, fresh[deployment.Cell()], deployment.Cell()) - manager.MarkTargetRenderFidelityScopeClean(target, fresh[other.Cell()], other.Cell()) + manager.MarkTargetRenderFidelityScopeClean(target, fresh[deployment.Collection()], deployment.Collection()) + manager.MarkTargetRenderFidelityScopeClean(target, fresh[other.Collection()], other.Collection()) assert.Equal(t, git.RenderFidelityTrue, manager.RenderFidelityForGitTarget(target).State) } @@ -165,16 +165,16 @@ func TestRenderFidelityStatus_ReducesTheCurrentPlansScopes(t *testing.T) { func (m *Manager) restartAllFidelityScopes( target types.ResourceReference, keys ...targetWatchKey, -) map[types.CellKey]uint64 { - cells := cellsForWatchKeys(keys) - revisions, _ := m.reconcileTargetRenderFidelity(target, cells, cells) +) map[types.CollectionKey]uint64 { + collections := collectionsForWatchKeys(keys) + revisions, _ := m.reconcileTargetRenderFidelity(target, collections, collections) return revisions } // A stream carries the revision it was STARTED with, so a cancelled stream still in flight with // a replay result cannot report a scope clean under a revision it never replayed for. Reading -// the cell's current revision when the result was ready is what made that possible, and a scope is -// now a cell — so a stream retired by a served-version change lands squarely on the live cell's +// the collection's current revision when the result was ready is what made that possible, and a scope is +// now a collection — so a stream retired by a served-version change lands squarely on the live collection's // scope instead of missing it. func TestTargetWatchStream_CarriesTheRevisionItWasStartedWith(t *testing.T) { workerManager := git.NewWorkerManager( @@ -191,18 +191,18 @@ func TestTargetWatchStream_CarriesTheRevisionItWasStartedWith(t *testing.T) { v2 := targetWatchKey{ GVR: schema.GroupVersionResource{Group: "apps", Version: "v2", Resource: "deployments"}, Namespace: "apps"} - retired := manager.restartAllFidelityScopes(target, v1)[v1.Cell()] + retired := manager.restartAllFidelityScopes(target, v1)[v1.Collection()] require.NotZero(t, retired, "the revision a started stream captures") - live := manager.restartAllFidelityScopes(target, v2)[v2.Cell()] + live := manager.restartAllFidelityScopes(target, v2)[v2.Collection()] require.NotEqual(t, retired, live) - // The retired v1 stream reports its replay clean. Same cell as the live v2 stream, older + // The retired v1 stream reports its replay clean. Same collection as the live v2 stream, older // revision: it must not reopen writes for a snapshot the new plan never gathered. - manager.MarkTargetRenderFidelityScopeClean(target, retired, v1.Cell()) + manager.MarkTargetRenderFidelityScopeClean(target, retired, v1.Collection()) assert.Equal(t, git.RenderFidelityUnknown, manager.RenderFidelityForGitTarget(target).State) - manager.MarkTargetRenderFidelityScopeClean(target, live, v2.Cell()) + manager.MarkTargetRenderFidelityScopeClean(target, live, v2.Collection()) assert.Equal(t, git.RenderFidelityTrue, manager.RenderFidelityForGitTarget(target).State, "the live stream's own result is what reopens writes") } @@ -218,7 +218,7 @@ func TestReportGitPathRefusal_RenderFidelityKeepsGitPathAccepted(t *testing.T) { manager.EventRouter = NewEventRouter(workerManager, manager, nil, logr.Discard()) target := types.NewResourceReference("podinfo-test", "team-a") - manager.ReportGitPathRefusal(target, types.CellKey{}, &manifestanalyzer.AcceptanceRefusedError{ + manager.ReportGitPathRefusal(target, types.CollectionKey{}, &manifestanalyzer.AcceptanceRefusedError{ Issues: []manifestanalyzer.AcceptanceIssue{{ Kind: manifestanalyzer.IssueRenderDoesNotMatchLive, Field: "data.region", Token: "${REGION}", }}, @@ -254,17 +254,17 @@ func TestMarkRenderFidelityScopeClean_NamesAResultTheGateWouldNotTake(t *testing manager := &Manager{Log: log} manager.EventRouter = NewEventRouter(workerManager, manager, nil, logr.Discard()) target := types.NewResourceReference("podinfo-test", "team-a") - cell := targetWatchKey{GVR: configmapsGVR, Namespace: "apps"} + collection := targetWatchKey{GVR: configmapsGVR, Namespace: "apps"} - first := manager.restartAllFidelityScopes(target, cell) + first := manager.restartAllFidelityScopes(target, collection) // Restart it so the earlier revision is genuinely superseded rather than merely absent. - manager.restartAllFidelityScopes(target, cell) + manager.restartAllFidelityScopes(target, collection) - manager.MarkTargetRenderFidelityScopeClean(target, first[cell.Cell()], cell.Cell()) + manager.MarkTargetRenderFidelityScopeClean(target, first[collection.Collection()], collection.Collection()) joined := strings.Join(*lines, "\n") assert.Contains(t, joined, "a render scope result was not applied") - assert.Contains(t, joined, cell.Cell().String()) + assert.Contains(t, joined, collection.Collection().String()) assert.Equal(t, git.RenderFidelityUnknown, manager.RenderFidelityForGitTarget(target).State, "a refused report must not converge the target") } @@ -283,10 +283,10 @@ func TestMarkRenderFidelityScopeClean_NamesAReportWithNoRevision(t *testing.T) { manager := &Manager{Log: log} manager.EventRouter = NewEventRouter(workerManager, manager, nil, logr.Discard()) target := types.NewResourceReference("podinfo-test", "team-a") - cell := targetWatchKey{GVR: configmapsGVR, Namespace: "apps"} - manager.restartAllFidelityScopes(target, cell) + collection := targetWatchKey{GVR: configmapsGVR, Namespace: "apps"} + manager.restartAllFidelityScopes(target, collection) - manager.MarkTargetRenderFidelityScopeClean(target, 0, cell.Cell()) + manager.MarkTargetRenderFidelityScopeClean(target, 0, collection.Collection()) assert.Contains(t, strings.Join(*lines, "\n"), "stream carries no revision") } @@ -313,14 +313,14 @@ func TestRenderFidelityStatus_PublishesTheCurrentStatusNotTheObservedOne(t *test revisions := manager.restartAllFidelityScopes(target, first, second) // Complete the set, so the gate is True... - manager.MarkTargetRenderFidelityScopeClean(target, revisions[first.Cell()], first.Cell()) - manager.MarkTargetRenderFidelityScopeClean(target, revisions[second.Cell()], second.Cell()) + manager.MarkTargetRenderFidelityScopeClean(target, revisions[first.Collection()], first.Collection()) + manager.MarkTargetRenderFidelityScopeClean(target, revisions[second.Collection()], second.Collection()) require.Equal(t, git.RenderFidelityTrue, manager.RenderFidelityForGitTarget(target).State) // ...then let a straggler re-report the scope it already reported. Under the old behaviour it // published the status IT computed; the published projection must still describe a converged // target, because that is what the gate says. - manager.MarkTargetRenderFidelityScopeClean(target, revisions[first.Cell()], first.Cell()) + manager.MarkTargetRenderFidelityScopeClean(target, revisions[first.Collection()], first.Collection()) published := manager.watchPlane().fidelity[target.Key()] assert.Equal(t, git.RenderFidelityTrue, published.State, diff --git a/internal/watch/gitpath_events.go b/internal/watch/gitpath_events.go index b66848a2e..4df8fae75 100644 --- a/internal/watch/gitpath_events.go +++ b/internal/watch/gitpath_events.go @@ -60,7 +60,7 @@ func (m *Manager) enqueueGitTargetReconcile(gitDest types.ResourceReference) { } } -// StreamStateEvents returns a channel carrying a GenericEvent for the GitTarget whose per-cell +// StreamStateEvents returns a channel carrying a GenericEvent for the GitTarget whose per-collection // stream readiness just changed, so a controller projecting that readiness re-reconciles on the // transition instead of on its next periodic requeue. // @@ -87,7 +87,7 @@ func (m *Manager) StreamStateEvents() <-chan event.GenericEvent { // Keeping the two channels apart is the point, because every send here is best-effort and a full // buffer is a DROP. GitPathEvents carries acceptance, render-fidelity and retention transitions, // and losing one of those costs up to RequeueSteadyInterval — five minutes of status describing a -// sweep that already happened. Stream transitions are higher-volume (every cell, on every plan +// sweep that already happened. Stream transitions are higher-volume (every collection, on every plan // change, plus a flap per distinct error message) and losing one costs at most // RequeueStreamSettleInterval. Put them in one buffer and the cheap events CROWD OUT the expensive // ones under exactly the load where it matters least to lose them and most to keep them. diff --git a/internal/watch/helpers_test.go b/internal/watch/helpers_test.go index 03887929b..e813054c0 100644 --- a/internal/watch/helpers_test.go +++ b/internal/watch/helpers_test.go @@ -26,8 +26,8 @@ func itemScope(namespaces ...string) [][]string { } // testStream is a running stream, for the tests that drive one stream directly. -func testStream(key targetWatchKey, ops OperationSet) targetWatchStream { - return targetWatchStream{key: key, ops: ops} +func testStream(key targetWatchKey) targetWatchStream { + return targetWatchStream{key: key} } func TestNormalizeResource(t *testing.T) { diff --git a/internal/watch/live_content_dedup_test.go b/internal/watch/live_content_dedup_test.go index 1bc3b1c8f..86440192f 100644 --- a/internal/watch/live_content_dedup_test.go +++ b/internal/watch/live_content_dedup_test.go @@ -9,7 +9,6 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" - configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" "github.com/ConfigButler/gitops-reverser/internal/git" "github.com/ConfigButler/gitops-reverser/internal/types" ) @@ -29,7 +28,7 @@ func callSkip(m *Manager, gitDest types.ResourceReference, uid, content, op stri Identifier: types.NewResourceIdentifier("apps", "v1", "deployments", "ns", "d"), Operation: op, } - if op != string(configv1alpha3.OperationDelete) { + if op != string(types.OperationDelete) { event.Object = &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "apps/v1", "kind": "Deployment", @@ -44,9 +43,9 @@ func TestSkipUnchangedLiveUpdate(t *testing.T) { m := &Manager{} dest := types.NewResourceReference("gt", "ns") - create := string(configv1alpha3.OperationCreate) - update := string(configv1alpha3.OperationUpdate) - del := string(configv1alpha3.OperationDelete) + create := string(types.OperationCreate) + update := string(types.OperationUpdate) + del := string(types.OperationDelete) // A CREATE always routes and seeds the cache. assert.False(t, callSkip(m, dest, "uid-1", "A", create), "CREATE must always route") @@ -69,7 +68,7 @@ func TestSkipUnchangedLiveUpdate(t *testing.T) { func TestSkipUnchangedLiveUpdate_FirstSeenUpdateRoutes(t *testing.T) { m := &Manager{} dest := types.NewResourceReference("gt", "ns") - assert.False(t, callSkip(m, dest, "uid-x", "A", string(configv1alpha3.OperationUpdate)), + assert.False(t, callSkip(m, dest, "uid-x", "A", string(types.OperationUpdate)), "a first-seen UPDATE has no baseline and must route") } @@ -80,7 +79,7 @@ func TestSkipUnchangedLiveUpdate_PerGitTargetIsolation(t *testing.T) { destA := types.NewResourceReference("gt-a", "ns") destB := types.NewResourceReference("gt-b", "ns") - create := string(configv1alpha3.OperationCreate) + create := string(types.OperationCreate) assert.False(t, callSkip(m, destA, "uid-1", "A", create)) // destB has never seen this object: its CREATE still routes. assert.False(t, callSkip(m, destB, "uid-1", "A", create), "a different GitTarget dedups independently") diff --git a/internal/watch/manager.go b/internal/watch/manager.go index b0a97c958..4c24dcd6a 100644 --- a/internal/watch/manager.go +++ b/internal/watch/manager.go @@ -190,7 +190,7 @@ type Manager struct { // deadline and its context is cancelled the moment it returns, so parenting a stream to it // would kill that stream the instant the plan finished being applied — the plan would read as // applied while nothing was watching. A stream's lifetime is the manager's; the pass's - // deadline bounds the pass. Cancelling one stream is the owner's decision, made per cell + // deadline bounds the pass. Cancelling one stream is the owner's decision, made per collection // through the plan diff, never a side effect of a context going out of scope. // // nil on a Manager that was never started, which is the shape tests drive; streamParent then @@ -225,12 +225,12 @@ type Manager struct { // GitPathAcceptanceStatus is the whole-target write-safety status for a GitTarget path. type GitPathAcceptanceStatus struct { - Accepted bool - Reason string - Message string - At metav1.Time - RefusedCell types.CellKey - RefusedCellSet bool + Accepted bool + Reason string + Message string + At metav1.Time + RefusedCollection types.CollectionKey + RefusedCollectionSet bool } const ( diff --git a/internal/watch/owner_observability.go b/internal/watch/owner_observability.go index 2c617b37c..55c1b152a 100644 --- a/internal/watch/owner_observability.go +++ b/internal/watch/owner_observability.go @@ -186,15 +186,14 @@ func (m *Manager) clearDeclareForce(ref types.ResourceReference, consumed uint64 // watchPlanFingerprint renders one GitTarget's declared stream set to a comparable value, so two // projections of the tables can be diffed per target without keeping a type-to-target index. // -// It hashes exactly what the plan is built from — the cell each stream covers, the version it -// opens at, and the operation filter it applies — so a catalog change that moves none of those -// for a target is, correctly, no reason to replan it. +// It hashes exactly what the plan is built from — the collection each stream covers and the +// version it opens at — so a catalog change that moves neither for a target is, correctly, no +// reason to replan it. func watchPlanFingerprint(table WatchedTypeTable) uint64 { - specs := targetWatchSpecs(table) - keys := sortedTargetWatchSpecKeys(specs) + keys := targetWatchKeys(table) parts := make([]string, 0, len(keys)) for _, key := range keys { - parts = append(parts, fmt.Sprintf("%s@%s=%s", key.GVR.String(), key.Namespace, specs[key])) + parts = append(parts, fmt.Sprintf("%s@%s", key.GVR.String(), key.Namespace)) } return xxhash.Sum64String(strings.Join(parts, "\x00")) } diff --git a/internal/watch/owner_test.go b/internal/watch/owner_test.go index 5fc6d80c8..a5ff39d27 100644 --- a/internal/watch/owner_test.go +++ b/internal/watch/owner_test.go @@ -274,7 +274,7 @@ func TestWatchPlanFingerprint_MovesOnTheStreamSetAndNotOnTheTargetName(t *testin } // A timeout must never install an empty plan. A pass that could not gather is not a pass that -// found nothing, and an ungatherable cell must never present as an absent one. +// found nothing, and an ungatherable collection must never present as an absent one. func TestOwner_AFailedPassLeavesTheRunningPlanUntouched(t *testing.T) { m := ownerTestManager() // Discovery is broken, so this cluster's API surface can never be observed. @@ -432,7 +432,7 @@ func TestOwner_APassNeverDialsForAnUnobservedCluster(t *testing.T) { // A pass runs under a deadline, and its context is cancelled the moment it returns. A stream // parented to it therefore dies the instant the plan finishes being applied — the plan reads as // applied while nothing is watching, readiness never leaves Replaying, and the next pass sees the -// cell as KEPT so it never restarts either. A stream's lifetime is the manager's. +// collection as KEPT so it never restarts either. A stream's lifetime is the manager's. func TestOwner_AStreamOutlivesThePassThatStartedIt(t *testing.T) { lifetime, stopManager := context.WithCancel(context.Background()) defer stopManager() @@ -453,11 +453,9 @@ func TestOwner_AStreamOutlivesThePassThatStartedIt(t *testing.T) { started := m.startTargetWatchStreams( passCtx, m.targetWatchSet(gitDest), - keysByCell([]targetWatchKey{{GVR: configmapsGVR, Namespace: "apps"}}), - map[targetWatchKey]OperationSet{}, - map[targetWatchKey]string{}, - map[types.CellKey]uint64{}, - []types.CellKey{types.CellKeyFor(configmapsGVR, "apps")}, + keysByCollection([]targetWatchKey{{GVR: configmapsGVR, Namespace: "apps"}}), + map[types.CollectionKey]uint64{}, + []types.CollectionKey{types.CollectionKeyFor(configmapsGVR, "apps")}, false, ) require.Len(t, started, 1) @@ -468,7 +466,7 @@ func TestOwner_AStreamOutlivesThePassThatStartedIt(t *testing.T) { require.NoError(t, streamCtx.Err(), "the stream must survive the pass that started it") - // It is the MANAGER's lifetime that ends it — and, before that, the owner's own per-cell + // It is the MANAGER's lifetime that ends it — and, before that, the owner's own per-collection // cancel through the plan diff. stopManager() assert.Eventually(t, func() bool { return streamCtx.Err() != nil }, time.Second, 5*time.Millisecond, diff --git a/internal/watch/prune_declaration_test.go b/internal/watch/prune_declaration_test.go index 8ed6c556e..13a4e72a4 100644 --- a/internal/watch/prune_declaration_test.go +++ b/internal/watch/prune_declaration_test.go @@ -149,8 +149,8 @@ func TestReplaceGitTargetWatches_ForceReplaysAnUnchangedSet(t *testing.T) { table := WatchedTypeTable{ GitDest: gitDest, Types: []WatchedType{{ - GVR: configmapsGVR, - NamespaceOps: map[string]OperationSet{"apps": {"CREATE": struct{}{}}}, + GVR: configmapsGVR, + NamespaceScopes: map[string]struct{}{"apps": {}}, }}, } require.NoError(t, manager.replaceGitTargetWatches(ctx, table)) diff --git a/internal/watch/render_fidelity_gate.go b/internal/watch/render_fidelity_gate.go index 96f9af471..8a5a111e1 100644 --- a/internal/watch/render_fidelity_gate.go +++ b/internal/watch/render_fidelity_gate.go @@ -23,7 +23,7 @@ func (m *Manager) fidelityGate() *git.RenderFidelityGate { // reconcileTargetRenderFidelity installs the target's current scope set and returns the revision // each scope's stream must report under, plus whether the caller should enqueue a status refresh. // -// Only the cells in restarted go back to pending; a cell whose stream was left running keeps its +// Only the collections in restarted go back to pending; a collection whose stream was left running keeps its // revision and its result, so an unrelated plan change neither closes writes on it nor clears a // divergence nothing re-measured. A nil map means no shared gate is wired, which the mark path // treats as the legacy data path. @@ -33,14 +33,14 @@ func (m *Manager) fidelityGate() *git.RenderFidelityGate { // wrong — is one of the four hazards the ownership design names. func (m *Manager) reconcileTargetRenderFidelity( target types.ResourceReference, - cells []types.CellKey, - restarted []types.CellKey, -) (map[types.CellKey]uint64, bool) { + collections []types.CollectionKey, + restarted []types.CollectionKey, +) (map[types.CollectionKey]uint64, bool) { gate := m.fidelityGate() if gate == nil { return nil, false } - status, revisions := gate.Reconcile(target, cells, restarted) + status, revisions := gate.Reconcile(target, collections, restarted) return revisions, m.publishRenderFidelityStatus(target, status) } @@ -55,13 +55,13 @@ func (m *Manager) RenderFidelityForGitTarget(target types.ResourceReference) Ren return gate.Status(target) } -// MarkTargetRenderFidelityScopeClean records one complete clean replay result from the cell's +// MarkTargetRenderFidelityScopeClean records one complete clean replay result from the collection's // current stream. A stale cancellation tail carries the retired stream's revision, so the gate // ignores it and it cannot reopen a failed target. func (m *Manager) MarkTargetRenderFidelityScopeClean( target types.ResourceReference, revision uint64, - cell types.CellKey, + collection types.CollectionKey, ) { gate := m.fidelityGate() if gate == nil { @@ -70,12 +70,12 @@ func (m *Manager) MarkTargetRenderFidelityScopeClean( if revision == 0 { // A wired gate always issues a non-zero revision, so a stream reporting under zero was // started without one. Its result is unusable and its scope keeps owing a report. - m.logUnappliedFidelityReport(target, cell, revision, "clean (stream carries no revision)") + m.logUnappliedFidelityReport(target, collection, revision, "clean (stream carries no revision)") return } - status, applied := gate.RecordScopeClean(target, revision, cell) + status, applied := gate.RecordScopeClean(target, revision, collection) if !applied { - m.logUnappliedFidelityReport(target, cell, revision, "clean") + m.logUnappliedFidelityReport(target, collection, revision, "clean") return } // TEMPORARY at Info, while Failure A is open. The refusal paths above are logged and the @@ -84,9 +84,9 @@ func (m *Manager) MarkTargetRenderFidelityScopeClean( // produced exactly that silence, which decided nothing. // // It is bounded: a scope accepts one report per revision, and a revision only moves when the - // plan restarts the cell. Lower it to V(1) once A is named. + // plan restarts the collection. Lower it to V(1) once A is named. m.Log.WithName("render-fidelity").Info("render scope result accepted", - "gitDest", target.String(), "cell", cell.String(), "revision", revision, + "gitDest", target.String(), "collection", collection.String(), "revision", revision, "state", string(status.State), "status", status.Message) // Publish the gate's CURRENT status, not the one this drain observed. Sibling drains record // concurrently and their publishes can reorder, so a drain that saw "1 of 4 pending" could @@ -108,13 +108,13 @@ func (m *Manager) MarkTargetRenderFidelityScopeClean( // converge on its own. func (m *Manager) logUnappliedFidelityReport( target types.ResourceReference, - cell types.CellKey, + collection types.CollectionKey, revision uint64, kind string, ) { m.Log.WithName("render-fidelity").Info( "a render scope result was not applied; this scope still owes a report and cannot converge alone", - "gitDest", target.String(), "cell", cell.String(), "reportedRevision", revision, "result", kind, + "gitDest", target.String(), "collection", collection.String(), "reportedRevision", revision, "result", kind, "status", m.RenderFidelityForGitTarget(target).Message) } @@ -122,7 +122,7 @@ func (m *Manager) logUnappliedFidelityReport( func (m *Manager) MarkTargetRenderFidelityScopeDiverged( target types.ResourceReference, revision uint64, - cell types.CellKey, + collection types.CollectionKey, divergence manifestanalyzer.RenderDivergence, ) { gate := m.fidelityGate() @@ -132,12 +132,12 @@ func (m *Manager) MarkTargetRenderFidelityScopeDiverged( if revision == 0 { // A wired gate always issues a non-zero revision, so a stream reporting under zero was // started without one. Its result is unusable and its scope keeps owing a report. - m.logUnappliedFidelityReport(target, cell, revision, "diverged (stream carries no revision)") + m.logUnappliedFidelityReport(target, collection, revision, "diverged (stream carries no revision)") return } - _, applied := gate.RecordScopeDivergence(target, revision, cell, divergence) + _, applied := gate.RecordScopeDivergence(target, revision, collection, divergence) if !applied { - m.logUnappliedFidelityReport(target, cell, revision, "diverged") + m.logUnappliedFidelityReport(target, collection, revision, "diverged") return } // Current status, for the same reason as the clean path above. diff --git a/internal/watch/resync_scope_test.go b/internal/watch/resync_scope_test.go index 5df21b654..6b27c8f4c 100644 --- a/internal/watch/resync_scope_test.go +++ b/internal/watch/resync_scope_test.go @@ -28,7 +28,7 @@ func TestResyncScopeForWatchKey_CarriesBothHalvesOfTheScope(t *testing.T) { // A ClusterWatchRule's stream gathers every namespace, so its sweep must too. scope := resyncScopeForWatchKey(targetWatchKey{GVR: gvr}) assert.Equal(t, git.ResyncScopeFor(gvr, ""), scope) - assert.Empty(t, scope.Cell.Namespace) + assert.Empty(t, scope.Collection.Namespace) }) t.Run("two namespaces of one type produce distinct scopes", func(t *testing.T) { diff --git a/internal/watch/retention_rollup.go b/internal/watch/retention_rollup.go index ad690e867..46d64fbc8 100644 --- a/internal/watch/retention_rollup.go +++ b/internal/watch/retention_rollup.go @@ -36,22 +36,22 @@ type RetentionSummary struct { LastChangedAt time.Time } -// targetRetentionScope is one cell's count, stamped with the stream revision that produced it. +// targetRetentionScope is one collection's count, stamped with the stream revision that produced it. type targetRetentionScope struct { revision uint64 retained int reported bool // reportedRevision is the revision of the report that produced `retained`. It separates a // stream RE-reporting under the revision it already reported (routine, every resync) from a - // NEW incarnation measuring the cell afresh and arriving at the same number. Only the second + // NEW incarnation measuring the collection afresh and arriving at the same number. Only the second // is interesting: it is the one an unchanged published count can be hiding. reportedRevision uint64 } -// targetRetentionState is one GitTarget's per-cell counts, covering exactly the cells its +// targetRetentionState is one GitTarget's per-collection counts, covering exactly the collections its // current watch plan selects. type targetRetentionState struct { - scopes map[types.CellKey]targetRetentionScope + scopes map[types.CollectionKey]targetRetentionScope mode v1alpha3.PruneMode observed time.Time } @@ -76,16 +76,16 @@ func (s targetRetentionState) anyReported() bool { // MarkTargetRetention records what one scope's resync retained. // // Scope lifecycle is handled by the watch plan rather than by eviction here: -// retainTargetRetentionScopes installs the selected cells and the revision each one's stream -// reports under, so a cell that leaves the plan takes its count with it, and a stale in-flight +// retainTargetRetentionScopes installs the selected collections and the revision each one's stream +// reports under, so a collection that leaves the plan takes its count with it, and a stale in-flight // reply from a cancelled watch — which carries the retired stream's revision — cannot resurrect -// or overwrite a count. A report for a cell the plan does not hold is dropped for the same +// or overwrite a count. A report for a collection the plan does not hold is dropped for the same // reason. // // Zero is recorded as actively as any other number: it is the converged signal. func (m *Manager) MarkTargetRetention( gitDest types.ResourceReference, - cell types.CellKey, + collection types.CollectionKey, revision uint64, mode v1alpha3.PruneMode, retained int, @@ -96,7 +96,7 @@ func (m *Manager) MarkTargetRetention( // target would enqueue on every resync of every scope forever. // A dropped report is recorded, not swallowed. Dropping is correct -- it is how a tail from a // replaced stream is kept out -- but the CONSEQUENCE is that the published count no longer - // describes the mirror, and nothing re-measures it until the plan next restarts this cell, + // describes the mirror, and nothing re-measures it until the plan next restarts this collection, // which for a settled target is the steady requeue away. A roll-up that silently stops // advancing is the same class of invisible failure the storm was: it reads exactly like a // converged one. @@ -105,9 +105,9 @@ func (m *Manager) MarkTargetRetention( var remeasured, changed bool m.mutateWatchPlane(func(s *watchPlaneState) bool { state := s.retention[gitDest.Key()] - scope, selected := state.scopes[cell] + scope, selected := state.scopes[collection] if !selected { - dropped = "the cell is not in the current watch plan" + dropped = "the collection is not in the current watch plan" return false } if revision != scope.revision { @@ -129,7 +129,7 @@ func (m *Manager) MarkTargetRetention( scope.retained = retained scope.reported = true scope.reportedRevision = revision - state.scopes[cell] = scope + state.scopes[collection] = scope state.mode = mode.OrDefault() s.retention[gitDest.Key()] = state changed = !priorReported || state.total() != priorTotal || state.mode != priorMode @@ -152,8 +152,8 @@ func (m *Manager) MarkTargetRetention( }) if dropped != "" { m.Log.WithName("retention").Info( - "retention report dropped; the published count is now stale until this cell is replanned", - "gitDest", gitDest.String(), "cell", cell.String(), "reason", dropped, + "retention report dropped; the published count is now stale until this collection is replanned", + "gitDest", gitDest.String(), "collection", collection.String(), "reason", dropped, "reportedRevision", revision, "installedRevision", installed, "retained", retained) return } @@ -163,18 +163,18 @@ func (m *Manager) MarkTargetRetention( // refusals above have been logged since c24844a1; the acceptances were not, and that asymmetry // is why B could be narrowed to this function and no further. // - // Info is reserved for the one shape that is genuinely ambiguous: a cell RE-MEASURED under a + // Info is reserved for the one shape that is genuinely ambiguous: a collection RE-MEASURED under a // new revision that arrived at the number already published, so the mutation was discarded and // status did not move. A stream re-reporting under the revision it already reported is routine // -- it happens on every resync of every steady scope, and logging it at Info put ~100 lines // into a single e2e run, which is how a diagnostic becomes noise instead of evidence. log := m.Log.WithName("retention").WithValues( - "gitDest", gitDest.String(), "cell", cell.String(), + "gitDest", gitDest.String(), "collection", collection.String(), "revision", revision, "mode", string(mode.OrDefault()), "retained", retained) switch { case !changed && remeasured: log.Info("retention report accepted but published nothing; a fresh measurement of this " + - "cell produced the count already published") + "collection produced the count already published") case !changed: log.V(1).Info("retention report accepted; nothing an operator sees moved") default: @@ -183,22 +183,22 @@ func (m *Manager) MarkTargetRetention( } } -// retainTargetRetentionScopes installs the cells the current watch plan selects, and the stream -// revision each one reports under. A cell that left the plan is dropped along with its count; a -// cell that stayed keeps the count it last reported, because its stream may not have moved. +// retainTargetRetentionScopes installs the collections the current watch plan selects, and the stream +// revision each one reports under. A collection that left the plan is dropped along with its count; a +// collection that stayed keeps the count it last reported, because its stream may not have moved. func (m *Manager) retainTargetRetentionScopes( gitDest types.ResourceReference, - revisions map[types.CellKey]uint64, + revisions map[types.CollectionKey]uint64, ) { m.mutateWatchPlane(func(s *watchPlaneState) bool { state := s.retention[gitDest.Key()] - next := make(map[types.CellKey]targetRetentionScope, len(revisions)) - for cell, revision := range revisions { - scope := state.scopes[cell] + next := make(map[types.CollectionKey]targetRetentionScope, len(revisions)) + for collection, revision := range revisions { + scope := state.scopes[collection] // A restarted stream reports under a new revision. Its previous count stands until the // replacement reports, which is a truer answer than zeroing a scope nobody re-measured. scope.revision = revision - next[cell] = scope + next[collection] = scope } state.scopes = next s.retention[gitDest.Key()] = state diff --git a/internal/watch/retention_rollup_test.go b/internal/watch/retention_rollup_test.go index e1f0a610e..a49723b3a 100644 --- a/internal/watch/retention_rollup_test.go +++ b/internal/watch/retention_rollup_test.go @@ -15,16 +15,19 @@ import ( ) var ( - retentionCMScope = types.CellKeyFor(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, "") - retentionSecretScope = types.CellKeyFor(schema.GroupVersionResource{Version: "v1", Resource: "secrets"}, "") + retentionCMScope = types.CollectionKeyFor( + schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, + "", + ) + retentionSecretScope = types.CollectionKeyFor(schema.GroupVersionResource{Version: "v1", Resource: "secrets"}, "") ) -// retentionPlan installs the cells a watch plan selects at the given stream revision, the step -// every declaration performs before those cells' resyncs can report. -func retentionPlan(m *Manager, gitDest types.ResourceReference, revision uint64, cells ...types.CellKey) { - revisions := make(map[types.CellKey]uint64, len(cells)) - for _, cell := range cells { - revisions[cell] = revision +// retentionPlan installs the collections a watch plan selects at the given stream revision, the step +// every declaration performs before those collections' resyncs can report. +func retentionPlan(m *Manager, gitDest types.ResourceReference, revision uint64, collections ...types.CollectionKey) { + revisions := make(map[types.CollectionKey]uint64, len(collections)) + for _, collection := range collections { + revisions[collection] = revision } m.retainTargetRetentionScopes(gitDest, revisions) } @@ -94,9 +97,9 @@ func TestRetentionRollup_ANewPlanDropsScopesThatLeftIt(t *testing.T) { "a scope that left the plan must take its count with it") } -// A kept cell's stream is not restarted, so nothing re-reports for it. Its count has to survive +// A kept collection's stream is not restarted, so nothing re-reports for it. Its count has to survive // the plan change, or every unrelated rule edit would zero a target's retention until the next -// resync of a cell that never moved. +// resync of a collection that never moved. func TestRetentionRollup_AKeptScopeKeepsItsCount(t *testing.T) { m := &Manager{} gitDest := types.NewResourceReference("acme", "tenant-acme") @@ -104,7 +107,7 @@ func TestRetentionRollup_AKeptScopeKeepsItsCount(t *testing.T) { m.MarkTargetRetention(gitDest, retentionCMScope, 1, v1alpha3.PruneOnEvent, 2) // A rule is added: secrets start at a fresh revision, configmaps keep theirs. - m.retainTargetRetentionScopes(gitDest, map[types.CellKey]uint64{ + m.retainTargetRetentionScopes(gitDest, map[types.CollectionKey]uint64{ retentionCMScope: 1, retentionSecretScope: 2, }) @@ -200,30 +203,30 @@ func TestRetentionRollup_EnqueuesOnChangeOnly(t *testing.T) { } // A dropped retention report leaves the published count describing a mirror that has moved on, -// and nothing re-measures it until the cell is replanned — which for a settled target is the +// and nothing re-measures it until the collection is replanned — which for a settled target is the // steady requeue away. Dropping is correct; dropping SILENTLY is what made a reproducible CI // failure (retainedDocuments stuck at its pre-sweep value after prune.mode was widened, // on a target whose files had been swept) take log archaeology to narrow and still not name. func TestRetentionRollup_ADroppedReportSaysSoAndWhy(t *testing.T) { for _, tc := range []struct { name string - cell types.CellKey + collection types.CollectionKey revision uint64 wantReason string wantInstalled string }{ { name: "a stream the plan has replaced", - cell: retentionCMScope, + collection: retentionCMScope, revision: 1, wantReason: "the reporting stream has been replaced", wantInstalled: `"installedRevision"=2`, }, { - name: "a cell the plan no longer holds", - cell: retentionSecretScope, + name: "a collection the plan no longer holds", + collection: retentionSecretScope, revision: 2, - wantReason: "the cell is not in the current watch plan", + wantReason: "the collection is not in the current watch plan", wantInstalled: `"installedRevision"=0`, }, } { @@ -234,7 +237,7 @@ func TestRetentionRollup_ADroppedReportSaysSoAndWhy(t *testing.T) { retentionPlan(m, gitDest, 2, retentionCMScope) m.MarkTargetRetention(gitDest, retentionCMScope, 2, v1alpha3.PruneOnEvent, 1) - m.MarkTargetRetention(gitDest, tc.cell, tc.revision, v1alpha3.PruneOnEvent, 0) + m.MarkTargetRetention(gitDest, tc.collection, tc.revision, v1alpha3.PruneOnEvent, 0) assert.Equal(t, 1, m.RetentionForGitTarget(gitDest).RetainedDocuments, "the drop itself still stands: a stale report must not move the count") @@ -269,27 +272,27 @@ func TestMarkTargetRetention_SaysWhenAnAcceptedReportPublishesNothing(t *testing log, lines := recordingLogger() m := &Manager{Log: log} gitDest := types.NewResourceReference("acme", "tenant-acme") - cell := types.CellKeyFor(configmapsGVR, "apps") - m.retainTargetRetentionScopes(gitDest, map[types.CellKey]uint64{cell: 7}) + collection := types.CollectionKeyFor(configmapsGVR, "apps") + m.retainTargetRetentionScopes(gitDest, map[types.CollectionKey]uint64{collection: 7}) // First report publishes: it moves Reported from false to true. - m.MarkTargetRetention(gitDest, cell, 7, v1alpha3.PruneOnEvent, 2) + m.MarkTargetRetention(gitDest, collection, 7, v1alpha3.PruneOnEvent, 2) require.Equal(t, 2, m.RetentionForGitTarget(gitDest).RetainedDocuments) // A re-report under the SAME revision is routine — every resync of a steady scope does it — // and must stay quiet, or the diagnostic drowns in its own noise. - m.MarkTargetRetention(gitDest, cell, 7, v1alpha3.PruneOnEvent, 2) + m.MarkTargetRetention(gitDest, collection, 7, v1alpha3.PruneOnEvent, 2) assert.NotContains(t, strings.Join(*lines, "\n"), "published nothing", "a re-report under the revision already reported is routine and must not be Info") - // A RE-MEASUREMENT: the plan restarts the cell, the replacement stream reports under its new + // A RE-MEASUREMENT: the plan restarts the collection, the replacement stream reports under its new // revision, and arrives at the number already published. The mutation is discarded and status // does not move — the one shape a stale published count can hide behind. - m.retainTargetRetentionScopes(gitDest, map[types.CellKey]uint64{cell: 8}) - m.MarkTargetRetention(gitDest, cell, 8, v1alpha3.PruneOnEvent, 2) + m.retainTargetRetentionScopes(gitDest, map[types.CollectionKey]uint64{collection: 8}) + m.MarkTargetRetention(gitDest, collection, 8, v1alpha3.PruneOnEvent, 2) assert.Contains(t, strings.Join(*lines, "\n"), - "a fresh measurement of this cell produced the count already published") + "a fresh measurement of this collection produced the count already published") } // A re-measurement announces itself ONCE. @@ -297,26 +300,26 @@ func TestMarkTargetRetention_SaysWhenAnAcceptedReportPublishesNothing(t *testing // The Info log above is deliberate and rare: it marks the one shape a stale published count can // hide behind. It only stays rare if the revision marker survives the report that raised it — and // mutateWatchPlane discards the whole clone when the mutation reports no operator-visible change, -// which dropped the marker with it. Every later resync of that cell then looked like a fresh +// which dropped the marker with it. Every later resync of that collection then looked like a fresh // re-measurement and said so again, turning a diagnostic into the noise it was written to avoid. func TestMarkTargetRetention_RemeasurementIsAnnouncedOnce(t *testing.T) { log, lines := recordingLogger() m := &Manager{Log: log} gitDest := types.NewResourceReference("acme", "tenant-acme") - cell := types.CellKeyFor(configmapsGVR, "apps") + collection := types.CollectionKeyFor(configmapsGVR, "apps") - m.retainTargetRetentionScopes(gitDest, map[types.CellKey]uint64{cell: 7}) - m.MarkTargetRetention(gitDest, cell, 7, v1alpha3.PruneOnEvent, 2) + m.retainTargetRetentionScopes(gitDest, map[types.CollectionKey]uint64{collection: 7}) + m.MarkTargetRetention(gitDest, collection, 7, v1alpha3.PruneOnEvent, 2) - // The cell restarts and its replacement reports the same count under a new revision: one + // The collection restarts and its replacement reports the same count under a new revision: one // announcement. Every resync after it is routine, under a revision now already reported. - m.retainTargetRetentionScopes(gitDest, map[types.CellKey]uint64{cell: 8}) + m.retainTargetRetentionScopes(gitDest, map[types.CollectionKey]uint64{collection: 8}) for range 4 { - m.MarkTargetRetention(gitDest, cell, 8, v1alpha3.PruneOnEvent, 2) + m.MarkTargetRetention(gitDest, collection, 8, v1alpha3.PruneOnEvent, 2) } announcements := strings.Count(strings.Join(*lines, "\n"), - "a fresh measurement of this cell produced the count already published") + "a fresh measurement of this collection produced the count already published") assert.Equal(t, 1, announcements, "the marker must persist so a re-measurement is announced once, not on every later resync") } @@ -327,14 +330,14 @@ func TestMarkTargetRetention_UnchangedRemeasurementPublishesNothing(t *testing.T log, _ := recordingLogger() m := &Manager{Log: log} gitDest := types.NewResourceReference("acme", "tenant-acme") - cell := types.CellKeyFor(configmapsGVR, "apps") + collection := types.CollectionKeyFor(configmapsGVR, "apps") - m.retainTargetRetentionScopes(gitDest, map[types.CellKey]uint64{cell: 7}) - m.MarkTargetRetention(gitDest, cell, 7, v1alpha3.PruneOnEvent, 2) + m.retainTargetRetentionScopes(gitDest, map[types.CollectionKey]uint64{collection: 7}) + m.MarkTargetRetention(gitDest, collection, 7, v1alpha3.PruneOnEvent, 2) before := m.RetentionForGitTarget(gitDest) - m.retainTargetRetentionScopes(gitDest, map[types.CellKey]uint64{cell: 8}) - m.MarkTargetRetention(gitDest, cell, 8, v1alpha3.PruneOnEvent, 2) + m.retainTargetRetentionScopes(gitDest, map[types.CollectionKey]uint64{collection: 8}) + m.MarkTargetRetention(gitDest, collection, 8, v1alpha3.PruneOnEvent, 2) after := m.RetentionForGitTarget(gitDest) assert.Equal(t, before.RetainedDocuments, after.RetainedDocuments) diff --git a/internal/watch/source_namespace_planning_test.go b/internal/watch/source_namespace_planning_test.go index 8e2b217be..218b0fe7c 100644 --- a/internal/watch/source_namespace_planning_test.go +++ b/internal/watch/source_namespace_planning_test.go @@ -126,10 +126,10 @@ func TestCollectWatchRuleSelections_WildcardIsOneClusterWideScope(t *testing.T) "a wildcard compiles to the all-namespaces collection, read once") } -// A cluster-wide cell is a PEER of a named-namespace cell on the same type, never a replacement. -// Each rule carries its own operations filter, and collapsing the two once widened the named rule's -// stream to every namespace its credential could read while discarding that filter (CellKey's own -// doc comment records the bug). Two streams over overlapping objects is the correct outcome here. +// A cluster-wide collection is a PEER of a named-namespace collection on the same type, never a +// replacement. Collapsing the two once widened the named rule's stream to every namespace its +// credential could read (CollectionKey's own doc comment records the bug). Two streams over +// overlapping objects is the correct outcome here. func TestCollectWatchRuleSelections_WildcardIsAPeerOfANamedNamespace(t *testing.T) { manager, store := makeWatchedTypeManager(t) addRule(store, diff --git a/internal/watch/source_namespace_stream_summary_test.go b/internal/watch/source_namespace_stream_summary_test.go index f4af2ca29..4324cde93 100644 --- a/internal/watch/source_namespace_stream_summary_test.go +++ b/internal/watch/source_namespace_stream_summary_test.go @@ -37,10 +37,10 @@ func srcnsSummaryManager(t *testing.T) *Manager { } } -// seedStreamState records one target's per-cell stream status, the surface the summary reads. +// seedStreamState records one target's per-collection stream status, the surface the summary reads. func (m *Manager) seedStreamState(gitDest types.ResourceReference, key targetWatchKey, status targetStreamStatus) { m.mutateWatchPlane(func(s *watchPlaneState) bool { - return setStreamState(s, gitDest.Key(), key.Cell(), status) + return setStreamState(s, gitDest.Key(), key.Collection(), status) }) } @@ -111,7 +111,7 @@ func TestStreamSummaryForWatchRule_WrongNamespaceKeyMisses(t *testing.T) { assert.False(t, summary.StreamsRunning()) } -// A wildcard resolves to the CLUSTER-WIDE cell — the empty namespace — which exists only in the +// A wildcard resolves to the CLUSTER-WIDE collection — the empty namespace — which exists only in the // compiled rule. A summary rebuilt from the spec would look for a stream under the rule's own // namespace, find nothing, and report a perfectly healthy rule as permanently not-ready. func TestStreamSummaryForWatchRule_WildcardReadsTheCompiledRule(t *testing.T) { @@ -146,12 +146,12 @@ func TestStreamSummaryForWatchRule_WildcardIsNotFoundUnderANamedKey(t *testing.T summary := m.StreamSummaryForWatchRule(rule) assert.False(t, summary.StreamsRunning(), - "the cluster-wide cell is a PEER of a named one, so a named stream does not satisfy it") + "the cluster-wide collection is a PEER of a named one, so a named stream does not satisfy it") } // The roll-up folds every resolved namespace of a type, so one namespace still replaying holds the // type back. Reachable now through two named items rather than through a wildcard, which resolves -// to a single cell. +// to a single collection. func TestStreamSummaryForWatchRule_OnePendingNamespaceHoldsTheTypeBack(t *testing.T) { m := srcnsSummaryManager(t) rule := srcnsOverrideRule("repo-config") diff --git a/internal/watch/source_namespace_test.go b/internal/watch/source_namespace_test.go index c56cb6a9a..9cd0fb43a 100644 --- a/internal/watch/source_namespace_test.go +++ b/internal/watch/source_namespace_test.go @@ -149,10 +149,10 @@ func TestBootstrap_AuthorizedOverrideCompilesWithItsSourceNamespace(t *testing.T "Source still names the WatchRule object in the control plane") } -// "*" compiles to the EMPTY namespace, which is the cluster-wide cell — not to an enumerated set, +// "*" compiles to the EMPTY namespace, which is the cluster-wide collection — not to an enumerated set, // and not to the rule's own namespace. This is the redefinition, asserted at the compile path that // produces the store entry every stream and every resync scope is projected from. -func TestCompileWatchRule_WildcardCompilesToTheClusterWideCell(t *testing.T) { +func TestCompileWatchRule_WildcardCompilesToTheClusterWideCollection(t *testing.T) { ctx := context.Background() m := snbManager(t, snbGitTarget(), snbGitProvider(), snbClusterProvider(true), diff --git a/internal/watch/stream_readiness.go b/internal/watch/stream_readiness.go index 58fea1fd4..c5a627d60 100644 --- a/internal/watch/stream_readiness.go +++ b/internal/watch/stream_readiness.go @@ -73,9 +73,9 @@ func (s StreamSummary) StreamsRunning() bool { } // markTargetStreamState is a stream goroutine's report of its own readiness, recorded under its -// CELL rather than under the (versioned) key the stream happens to run at. The rule-level roll-up +// COLLECTION rather than under the (versioned) key the stream happens to run at. The rule-level roll-up // resolves what it expects from the type registry, which serves one record per version, while the -// declared stream set runs one stream per cell. Keyed by version, a rule matching two served +// declared stream set runs one stream per collection. Keyed by version, a rule matching two served // versions of one resource would expect a stream that by construction never exists, and would // report permanently not-ready while its stream ran perfectly. // @@ -84,19 +84,19 @@ func (s StreamSummary) StreamsRunning() bool { // cancelled never contends for the lock the cancellation was issued under. func (m *Manager) markTargetStreamState( gitDest types.ResourceReference, - cell types.CellKey, + collection types.CollectionKey, state StreamState, reason string, message string, ) { changed := m.mutateWatchPlane(func(s *watchPlaneState) bool { - return setStreamState(s, gitDest.Key(), cell, targetStreamStatus{ + return setStreamState(s, gitDest.Key(), collection, targetStreamStatus{ state: state, reason: reason, message: message, }) }) - // A cell reaching Streaming is the last thing that has to happen before this target and every + // A collection reaching Streaming is the last thing that has to happen before this target and every // rule pointing at it can honestly say StreamsRunning=True, so the transition is worth an // event. Without one the data plane converges in about two seconds and the status follows up // to ten seconds later, on RequeueStreamSettleInterval, having learned nothing in between. @@ -114,22 +114,22 @@ func (m *Manager) markTargetStreamState( } } -// setStreamState records one cell's status and reports whether it moved. +// setStreamState records one collection's status and reports whether it moved. func setStreamState( s *watchPlaneState, targetKey string, - cell types.CellKey, + collection types.CollectionKey, status targetStreamStatus, ) bool { states := s.streams[targetKey] if states == nil { - states = map[types.CellKey]targetStreamStatus{} + states = map[types.CollectionKey]targetStreamStatus{} s.streams[targetKey] = states } - if prior, had := states[cell]; had && prior == status { + if prior, had := states[collection]; had && prior == status { return false } - states[cell] = status + states[collection] = status return true } @@ -139,9 +139,8 @@ func (m *Manager) StreamSummaryForGitTarget(gitDest types.ResourceReference) Str if !ok { return streamSummaryForTypes(nil, nil, nil) } - specs := targetWatchSpecs(table) names := streamDisplayNamesForTable(table) - return m.streamSummaryForExpectedKeys(gitDest, cellsForWatchKeys(sortedTargetWatchSpecKeys(specs)), names) + return m.streamSummaryForExpectedKeys(gitDest, collectionsForWatchKeys(targetWatchKeys(table)), names) } // StreamSummaryForWatchRule reports stream readiness for one namespaced WatchRule, resolved @@ -172,19 +171,19 @@ func (m *Manager) StreamSummaryForWatchRule(rule configv1alpha3.WatchRule) Strea reg := m.registryForGitTarget(gitDest) m.refreshClusterTypeRegistry(m.cluster(m.clusterIDForGitTarget(gitDest))) records := reg.Followable() - var cells []types.CellKey + var collections []types.CollectionKey names := map[schema.GroupResource]string{} for _, rr := range compiled.ResourceRules { matched := matchFollowableRecords( records, rr.APIGroups, rr.APIVersions, rr.Resources, configv1alpha3.ResourceScopeNamespaced) for _, rec := range matched { for _, namespace := range rr.SourceNamespaces { - cells = append(cells, types.CellKeyFor(rec.Identity.GVR, namespace)) + collections = append(collections, types.CollectionKeyFor(rec.Identity.GVR, namespace)) } names[rec.Identity.GVR.GroupResource()] = streamDisplayName(rec.Identity.GVR) } } - return m.streamSummaryForExpectedKeys(gitDest, deduplicateCells(cells), names) + return m.streamSummaryForExpectedKeys(gitDest, deduplicateCollections(collections), names) } // StreamSummaryForClusterWatchRule reports stream readiness for one ClusterWatchRule, resolved @@ -195,30 +194,30 @@ func (m *Manager) StreamSummaryForClusterWatchRule(rule configv1alpha3.ClusterWa reg := m.registryForGitTarget(gitDest) m.refreshClusterTypeRegistry(m.cluster(m.clusterIDForGitTarget(gitDest))) records := reg.Followable() - var cells []types.CellKey + var collections []types.CollectionKey names := map[schema.GroupResource]string{} for _, rr := range rule.Spec.Rules { matched := matchFollowableRecords( records, rr.APIGroups, rr.APIVersions, rr.Resources, configv1alpha3.ResourceScopeCluster) for _, rec := range matched { - cells = append(cells, types.CellKeyFor(rec.Identity.GVR, "")) + collections = append(collections, types.CollectionKeyFor(rec.Identity.GVR, "")) names[rec.Identity.GVR.GroupResource()] = streamDisplayName(rec.Identity.GVR) } } - return m.streamSummaryForExpectedKeys(gitDest, deduplicateCells(cells), names) + return m.streamSummaryForExpectedKeys(gitDest, deduplicateCollections(collections), names) } func (m *Manager) streamSummaryForExpectedKeys( gitDest types.ResourceReference, - expected []types.CellKey, + expected []types.CollectionKey, displayNames map[schema.GroupResource]string, ) StreamSummary { return streamSummaryForTypes(expected, m.watchPlane().streams[gitDest.Key()], displayNames) } func streamSummaryForTypes( - expected []types.CellKey, - states map[types.CellKey]targetStreamStatus, + expected []types.CollectionKey, + states map[types.CollectionKey]targetStreamStatus, displayNames map[schema.GroupResource]string, ) StreamSummary { byGVR := streamStatusesByType(expected, states) @@ -230,20 +229,20 @@ func streamSummaryForTypes( return out } -// streamStatusesByType reduces the per-cell states to one row per TYPE: a rule watching one +// streamStatusesByType reduces the per-collection states to one row per TYPE: a rule watching one // resource in three namespaces reports one stream, in its weakest state, which is the ratio // users have always seen in status. func streamStatusesByType( - expected []types.CellKey, - states map[types.CellKey]targetStreamStatus, + expected []types.CollectionKey, + states map[types.CollectionKey]targetStreamStatus, ) map[schema.GroupResource]targetStreamStatus { byType := map[schema.GroupResource]targetStreamStatus{} - for _, cell := range deduplicateCells(expected) { - status, ok := states[cell] + for _, collection := range deduplicateCollections(expected) { + status, ok := states[collection] if !ok { status = targetStreamStatus{state: StreamStateReplaying, reason: StreamReasonInitialReplay} } - gr := schema.GroupResource{Group: cell.Group, Resource: cell.Resource} + gr := schema.GroupResource{Group: collection.Group, Resource: collection.Resource} current, seen := byType[gr] if !seen || strongerStreamStatus(status, current) { byType[gr] = status @@ -370,24 +369,24 @@ func groupResourceDisplayName(gr schema.GroupResource) string { return gr.Resource + "." + gr.Group } -// cellsForWatchKeys projects a declared stream set onto the cells it covers. -func cellsForWatchKeys(keys []targetWatchKey) []types.CellKey { - out := make([]types.CellKey, 0, len(keys)) +// collectionsForWatchKeys projects a declared stream set onto the collections it covers. +func collectionsForWatchKeys(keys []targetWatchKey) []types.CollectionKey { + out := make([]types.CollectionKey, 0, len(keys)) for _, key := range keys { - out = append(out, key.Cell()) + out = append(out, key.Collection()) } return out } -func deduplicateCells(cells []types.CellKey) []types.CellKey { - seen := map[types.CellKey]struct{}{} - out := make([]types.CellKey, 0, len(cells)) - for _, cell := range cells { - if _, ok := seen[cell]; ok { +func deduplicateCollections(collections []types.CollectionKey) []types.CollectionKey { + seen := map[types.CollectionKey]struct{}{} + out := make([]types.CollectionKey, 0, len(collections)) + for _, collection := range collections { + if _, ok := seen[collection]; ok { continue } - seen[cell] = struct{}{} - out = append(out, cell) + seen[collection] = struct{}{} + out = append(out, collection) } sort.Slice(out, func(i, j int) bool { return out[i].String() < out[j].String() }) return out diff --git a/internal/watch/stream_readiness_test.go b/internal/watch/stream_readiness_test.go index 3f2d9c188..f140c53d7 100644 --- a/internal/watch/stream_readiness_test.go +++ b/internal/watch/stream_readiness_test.go @@ -16,15 +16,15 @@ import ( func TestStreamSummaryForTypes_AggregatesByType(t *testing.T) { configmaps := schema.GroupVersionResource{Version: "v1", Resource: "configmaps"} secrets := schema.GroupVersionResource{Version: "v1", Resource: "secrets"} - expected := []types.CellKey{ - types.CellKeyFor(configmaps, "a"), - types.CellKeyFor(configmaps, "b"), - types.CellKeyFor(secrets, "a"), + expected := []types.CollectionKey{ + types.CollectionKeyFor(configmaps, "a"), + types.CollectionKeyFor(configmaps, "b"), + types.CollectionKeyFor(secrets, "a"), } - states := map[types.CellKey]targetStreamStatus{ - types.CellKeyFor(configmaps, "a"): {state: StreamStateStreaming}, - types.CellKeyFor(configmaps, "b"): {state: StreamStateReplaying, reason: StreamReasonInitialReplay}, - types.CellKeyFor(secrets, "a"): {state: StreamStateStreaming}, + states := map[types.CollectionKey]targetStreamStatus{ + types.CollectionKeyFor(configmaps, "a"): {state: StreamStateStreaming}, + types.CollectionKeyFor(configmaps, "b"): {state: StreamStateReplaying, reason: StreamReasonInitialReplay}, + types.CollectionKeyFor(secrets, "a"): {state: StreamStateStreaming}, } summary := streamSummaryForTypes(expected, states, nil) @@ -39,10 +39,10 @@ func TestStreamSummaryForTypes_AggregatesByType(t *testing.T) { func TestStreamSummaryForTypes_BlockedOutranksReplaying(t *testing.T) { configmaps := schema.GroupVersionResource{Version: "v1", Resource: "configmaps"} secrets := schema.GroupVersionResource{Version: "v1", Resource: "secrets"} - expected := []types.CellKey{types.CellKeyFor(configmaps, ""), types.CellKeyFor(secrets, "")} - states := map[types.CellKey]targetStreamStatus{ - types.CellKeyFor(configmaps, ""): {state: StreamStateReplaying, reason: StreamReasonInitialReplay}, - types.CellKeyFor(secrets, ""): {state: StreamStateBlocked, reason: StreamReasonWatchError}, + expected := []types.CollectionKey{types.CollectionKeyFor(configmaps, ""), types.CollectionKeyFor(secrets, "")} + states := map[types.CollectionKey]targetStreamStatus{ + types.CollectionKeyFor(configmaps, ""): {state: StreamStateReplaying, reason: StreamReasonInitialReplay}, + types.CollectionKeyFor(secrets, ""): {state: StreamStateBlocked, reason: StreamReasonWatchError}, } summary := streamSummaryForTypes(expected, states, nil) @@ -56,20 +56,20 @@ func TestStreamSummaryForTypes_BlockedOutranksReplaying(t *testing.T) { // The regression this keying exists to prevent. A rule can match two SERVED VERSIONS of one // resource (a `*` apiVersions wildcard over, say, autoscaling/v1 and autoscaling/v2), while the -// declared set runs exactly one stream for that cell. Expecting a per-version stream meant +// declared set runs exactly one stream for that collection. Expecting a per-version stream meant // expecting one that by construction never exists, and the rule reported permanently // not-ready while its stream ran perfectly. func TestStreamSummaryForTypes_TwoServedVersionsAreOneStream(t *testing.T) { v1 := schema.GroupVersionResource{Group: "autoscaling", Version: "v1", Resource: "horizontalpodautoscalers"} v2 := schema.GroupVersionResource{Group: "autoscaling", Version: "v2", Resource: "horizontalpodautoscalers"} - expected := []types.CellKey{types.CellKeyFor(v1, "team-a"), types.CellKeyFor(v2, "team-a")} - states := map[types.CellKey]targetStreamStatus{ - types.CellKeyFor(v2, "team-a"): {state: StreamStateStreaming}, + expected := []types.CollectionKey{types.CollectionKeyFor(v1, "team-a"), types.CollectionKeyFor(v2, "team-a")} + states := map[types.CollectionKey]targetStreamStatus{ + types.CollectionKeyFor(v2, "team-a"): {state: StreamStateStreaming}, } summary := streamSummaryForTypes(expected, states, nil) - assert.Equal(t, 1, summary.Total, "one cell is one stream, whatever versions serve it") + assert.Equal(t, 1, summary.Total, "one collection is one stream, whatever versions serve it") assert.Equal(t, 1, summary.Ready) assert.True(t, summary.StreamsRunning()) } @@ -80,7 +80,7 @@ func TestStreamSummaryForTypes_TwoServedVersionsAreOneStream(t *testing.T) { func TestMarkTargetStreamState_NotifiesOnTheTransitionOnly(t *testing.T) { m := &Manager{Log: logr.Discard()} gitDest := types.NewResourceReference("target", "team-a") - cell := types.CellKeyFor(configmapsGVR, "apps") + collection := types.CollectionKeyFor(configmapsGVR, "apps") // One subscriber per consumer, because a Go channel has one consumer and three controllers // project this state. @@ -92,7 +92,7 @@ func TestMarkTargetStreamState_NotifiesOnTheTransitionOnly(t *testing.T) { // stale status where a dropped stream transition costs ten seconds. acceptanceEvents := m.GitPathEvents() - m.markTargetStreamState(gitDest, cell, StreamStateReplaying, StreamReasonInitialReplay, "replaying") + m.markTargetStreamState(gitDest, collection, StreamStateReplaying, StreamReasonInitialReplay, "replaying") for name, ch := range map[string]<-chan event.GenericEvent{ "watch rules": ruleEvents, "cluster watch rules": clusterRuleEvents, "the GitTarget": targetEvents, } { @@ -112,14 +112,14 @@ func TestMarkTargetStreamState_NotifiesOnTheTransitionOnly(t *testing.T) { // The data plane reports readiness continuously. An event per REPORT rather than per CHANGE // would enqueue every rule of a target on every watch event it handles. - m.markTargetStreamState(gitDest, cell, StreamStateReplaying, StreamReasonInitialReplay, "replaying") + m.markTargetStreamState(gitDest, collection, StreamStateReplaying, StreamReasonInitialReplay, "replaying") select { case <-ruleEvents: t.Fatal("a re-report of an unchanged state must not enqueue anything") default: } - m.markTargetStreamState(gitDest, cell, StreamStateStreaming, StreamReasonAllStreamsReady, "streaming") + m.markTargetStreamState(gitDest, collection, StreamStateStreaming, StreamReasonAllStreamsReady, "streaming") select { case <-ruleEvents: default: diff --git a/internal/watch/target_watch.go b/internal/watch/target_watch.go index 695d6600b..c556a42cc 100644 --- a/internal/watch/target_watch.go +++ b/internal/watch/target_watch.go @@ -21,7 +21,6 @@ import ( "k8s.io/apimachinery/pkg/watch" "k8s.io/utils/ptr" - configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" "github.com/ConfigButler/gitops-reverser/internal/git" "github.com/ConfigButler/gitops-reverser/internal/manifestanalyzer" "github.com/ConfigButler/gitops-reverser/internal/queue" @@ -52,45 +51,44 @@ func targetWatchClosedErr(ctx context.Context) error { } } -// targetWatchSet is one GitTarget's running streams, keyed by the cell each one covers. The -// plan is applied cell by cell, so cancellation is too: there is no set-wide cancel, because a -// single one is what made adding a rule replay every unrelated cell into a queue shared with +// targetWatchSet is one GitTarget's running streams, keyed by the collection each one covers. The +// plan is applied collection by collection, so cancellation is too: there is no set-wide cancel, because a +// single one is what made adding a rule replay every unrelated collection into a queue shared with // other tenants. type targetWatchSet struct { - streams map[types.CellKey]*runningTargetWatch + streams map[types.CollectionKey]*runningTargetWatch } // runningTargetWatch is one live stream: what it was started for, and how to stop it. type runningTargetWatch struct { key targetWatchKey - spec string cancel context.CancelFunc } // plan is the set's side of the diff: what is running right now. func (s *targetWatchSet) plan() targetWatchPlan { - plan := targetWatchPlan{Cells: make(map[types.CellKey]cellSpec, len(s.streams))} - for cell, running := range s.streams { - plan.Cells[cell] = cellSpec{Operations: running.spec, Version: running.key.GVR.Version} + plan := targetWatchPlan{Collections: make(map[types.CollectionKey]watchSpec, len(s.streams))} + for collection, running := range s.streams { + plan.Collections[collection] = watchSpec{Version: running.key.GVR.Version} } return plan } -// stop cancels one cell's stream and drops it. It never touches files: a deselected cell's +// stop cancels one collection's stream and drops it. It never touches files: a deselected collection's // documents are converged by a Git-side sweep, not by the watch layer. -func (s *targetWatchSet) stop(cell types.CellKey) { - running, ok := s.streams[cell] +func (s *targetWatchSet) stop(collection types.CollectionKey) { + running, ok := s.streams[collection] if !ok { return } running.cancel() - delete(s.streams, cell) + delete(s.streams, collection) } // stopAll cancels every stream, for a GitTarget that is going away. func (s *targetWatchSet) stopAll() { - for cell := range s.streams { - s.stop(cell) + for collection := range s.streams { + s.stop(collection) } } @@ -99,41 +97,41 @@ type targetWatchKey struct { Namespace string } -// targetWatchStream is one running target watch: the cell it covers and the operation filter -// it applies. +// targetWatchStream is one running target watch: the collection it covers. It observes every +// object event in that collection; the GitTarget's prune mode, not the stream, decides what a +// removal does to Git. // // Nothing about a stream fences the work it queues. Once an item is on the branch worker's // FIFO it will be applied, and a canceled stream's goroutine can still be in flight, so a -// short tail of writes from a deselected cell is accepted rather than rejected on arrival. The +// short tail of writes from a deselected collection is accepted rather than rejected on arrival. The // plan is applied by canceling streams, at the producer. type targetWatchStream struct { key targetWatchKey - ops OperationSet // refreshRemote asks the worker to inspect the remote tip before applying this stream's // replay. It is set on forced GitTarget rechecks, where a human may have fixed or broken the // folder directly in Git and the local checkout is the stale thing being tested. refreshRemote bool - // revision is the incarnation of this stream's CELL, issued by the render-fidelity gate + // revision is the incarnation of this stream's COLLECTION, issued by the render-fidelity gate // when the stream was started. It is captured at start, not read when a replay result is // ready: a cancelled stream that read the current revision on its way out would report its - // scope clean under a revision it never replayed for. Since a fidelity scope is a cell, a - // stream retired by a served-version change lands on the live cell's scope rather than + // scope clean under a revision it never replayed for. Since a fidelity scope is a collection, a + // stream retired by a served-version change lands on the live collection's scope rather than // missing it, so the capture is what keeps it out. revision uint64 } -// sourceCell is what this stream stamps on the work it queues: the cell that produced it. -func (s targetWatchStream) sourceCell() types.CellKey { - return s.key.Cell() +// sourceCollection is what this stream stamps on the work it queues: the collection that produced it. +func (s targetWatchStream) sourceCollection() types.CollectionKey { + return s.key.Collection() } -// Cell is this stream's identity everywhere it crosses a subsystem boundary: the sweep scope -// its replay runs under, the render-fidelity scope it reports into, and the source cell stamped +// Collection is this stream's identity everywhere it crosses a subsystem boundary: the sweep scope +// its replay runs under, the render-fidelity scope it reports into, and the source collection stamped // on the work it queues. The served version stays on the key — a stream has to open a watch -// with a concrete version — but it is not part of the cell, so the key always round-trips to +// with a concrete version — but it is not part of the collection, so the key always round-trips to // the boundary it sweeps. -func (k targetWatchKey) Cell() types.CellKey { - return types.CellKeyFor(k.GVR, k.Namespace) +func (k targetWatchKey) Collection() types.CollectionKey { + return types.CollectionKeyFor(k.GVR, k.Namespace) } // ensureGitTargetWatches makes the GitTarget's raw watch set match its current claimed, @@ -206,13 +204,11 @@ func (m *Manager) replaceGitTargetWatches( table WatchedTypeTable, forceRecheck ...bool, ) error { - streams := targetWatchStreams(table) - specs := renderTargetWatchSpecs(streams) - keys := sortedTargetWatchSpecKeys(specs) + keys := targetWatchKeys(table) force := len(forceRecheck) > 0 && forceRecheck[0] log := m.Log.WithName("target-watch").WithValues("gitDest", table.GitDest.String()) - desired, err := targetWatchPlanFor(specs) + desired, err := targetWatchPlanFor(keys) if err != nil { return fmt.Errorf("build the watch plan for %s: %w", table.GitDest.String(), err) } @@ -220,22 +216,22 @@ func (m *Manager) replaceGitTargetWatches( set := m.targetWatchSet(table.GitDest) previous := set.plan() diff := diffTargetWatchPlans(previous, desired, force) - // Cancel before starting: a restarted cell's replacement must not race its predecessor for - // the same cell's readiness and fidelity result. - for _, cell := range diff.Stop { - set.stop(cell) - } - for _, cell := range diff.Restart { - set.stop(cell) - } - starting := append(append([]types.CellKey{}, diff.Start...), diff.Restart...) - sortCells(starting) - cells := cellsForWatchKeys(keys) - m.resetTargetStreamStates(table.GitDest, cells, starting) - revisions, fidelityChanged := m.reconcileTargetRenderFidelity(table.GitDest, cells, starting) - started := m.startTargetWatchStreams(ctx, set, keysByCell(keys), streams, specs, revisions, starting, force) - - m.retainTargetRetentionScopes(table.GitDest, streamRevisions(cells, revisions)) + // Cancel before starting: a restarted collection's replacement must not race its predecessor for + // the same collection's readiness and fidelity result. + for _, collection := range diff.Stop { + set.stop(collection) + } + for _, collection := range diff.Restart { + set.stop(collection) + } + starting := append(append([]types.CollectionKey{}, diff.Start...), diff.Restart...) + sortCollections(starting) + collections := collectionsForWatchKeys(keys) + m.resetTargetStreamStates(table.GitDest, collections, starting) + revisions, fidelityChanged := m.reconcileTargetRenderFidelity(table.GitDest, collections, starting) + started := m.startTargetWatchStreams(ctx, set, keysByCollection(keys), revisions, starting, force) + + m.retainTargetRetentionScopes(table.GitDest, streamRevisions(collections, revisions)) if fidelityChanged { m.enqueueGitTargetReconcile(table.GitDest) } @@ -248,7 +244,7 @@ func (m *Manager) replaceGitTargetWatches( // delivered on two streams, which is legitimate scoping but doubles the events for // objects in that namespace. That is invisible in a bare count. log.V(1).Info("watch-first target watch set reconciled", - "watchCount", len(keys), "streams", describeWatchKeys(keys, specs)) + "watchCount", len(keys), "streams", describeWatchKeys(keys)) return nil } @@ -259,7 +255,7 @@ func (m *Manager) targetWatchSet(gitDest types.ResourceReference) *targetWatchSe } set := m.targetWatches[gitDest.Key()] if set == nil { - set = &targetWatchSet{streams: map[types.CellKey]*runningTargetWatch{}} + set = &targetWatchSet{streams: map[types.CollectionKey]*runningTargetWatch{}} m.targetWatches[gitDest.Key()] = set } return set @@ -272,33 +268,30 @@ type startingTargetWatch struct { stream targetWatchStream } -// startTargetWatchStreams registers one stream per cell in starting, each with its own cancel, +// startTargetWatchStreams registers one stream per collection in starting, each with its own cancel, // and returns them to be launched once the whole plan has been applied. Owner-loop only. func (m *Manager) startTargetWatchStreams( ctx context.Context, set *targetWatchSet, - byCell map[types.CellKey]targetWatchKey, - streams map[targetWatchKey]OperationSet, - specs map[targetWatchKey]string, - revisions map[types.CellKey]uint64, - starting []types.CellKey, + byCollection map[types.CollectionKey]targetWatchKey, + revisions map[types.CollectionKey]uint64, + starting []types.CollectionKey, refreshRemote bool, ) []startingTargetWatch { out := make([]startingTargetWatch, 0, len(starting)) - for _, cell := range starting { - watchKey, ok := byCell[cell] + for _, collection := range starting { + watchKey, ok := byCollection[collection] if !ok { continue } streamCtx, cancel := context.WithCancel(m.streamParent(ctx)) - set.streams[cell] = &runningTargetWatch{key: watchKey, spec: specs[watchKey], cancel: cancel} + set.streams[collection] = &runningTargetWatch{key: watchKey, cancel: cancel} out = append(out, startingTargetWatch{ ctx: streamCtx, stream: targetWatchStream{ key: watchKey, - ops: streams[watchKey], refreshRemote: refreshRemote, - revision: revisions[cell], + revision: revisions[collection], }, }) } @@ -314,28 +307,28 @@ func (m *Manager) streamParent(fallback context.Context) context.Context { return fallback } -// keysByCell indexes the declared keys by the cell each one covers. targetWatchStreams -// guarantees one key per cell, which targetWatchPlanFor has already asserted by this point. -func keysByCell(keys []targetWatchKey) map[types.CellKey]targetWatchKey { - out := make(map[types.CellKey]targetWatchKey, len(keys)) +// keysByCollection indexes the declared keys by the collection each one covers. targetWatchStreams +// guarantees one key per collection, which targetWatchPlanFor has already asserted by this point. +func keysByCollection(keys []targetWatchKey) map[types.CollectionKey]targetWatchKey { + out := make(map[types.CollectionKey]targetWatchKey, len(keys)) for _, key := range keys { - out[key.Cell()] = key + out[key.Collection()] = key } return out } -// resetTargetStreamStates makes the readiness surface match the plan: cells that left it are -// dropped, and the cells whose streams are being started or restarted go back to replaying. A -// cell that is merely KEPT keeps its prior clean or divergent result, because an unrelated plan +// resetTargetStreamStates makes the readiness surface match the plan: collections that left it are +// dropped, and the collections whose streams are being started or restarted go back to replaying. A +// collection that is merely KEPT keeps its prior clean or divergent result, because an unrelated plan // change is no evidence about it. func (m *Manager) resetTargetStreamStates( gitDest types.ResourceReference, - declared []types.CellKey, - replaying []types.CellKey, + declared []types.CollectionKey, + replaying []types.CollectionKey, ) { - selected := make(map[types.CellKey]struct{}, len(declared)) - for _, cell := range declared { - selected[cell] = struct{}{} + selected := make(map[types.CollectionKey]struct{}, len(declared)) + for _, collection := range declared { + selected[collection] = struct{}{} } replay := targetStreamStatus{ state: StreamStateReplaying, @@ -346,18 +339,18 @@ func (m *Manager) resetTargetStreamStates( targetKey := gitDest.Key() states := s.streams[targetKey] if states == nil { - states = map[types.CellKey]targetStreamStatus{} + states = map[types.CollectionKey]targetStreamStatus{} s.streams[targetKey] = states } changed := false - for cell := range states { - if _, ok := selected[cell]; !ok { - delete(states, cell) + for collection := range states { + if _, ok := selected[collection]; !ok { + delete(states, collection) changed = true } } - for _, cell := range replaying { - if setStreamState(s, targetKey, cell, replay) { + for _, collection := range replaying { + if setStreamState(s, targetKey, collection, replay) { changed = true } } @@ -365,16 +358,16 @@ func (m *Manager) resetTargetStreamStates( }) } -// describeWatchKeys renders the declared streams as "@=" -// so a declare log names exactly what is being watched and under which operation filter. -func describeWatchKeys(keys []targetWatchKey, specs map[targetWatchKey]string) string { +// describeWatchKeys renders the declared streams as "@" +// so a declare log names exactly what is being watched. +func describeWatchKeys(keys []targetWatchKey) string { parts := make([]string, 0, len(keys)) for _, key := range keys { scope := key.Namespace if scope == "" { scope = "*cluster-wide*" } - parts = append(parts, fmt.Sprintf("%s@%s=%s", key.GVR.String(), scope, specs[key])) + parts = append(parts, fmt.Sprintf("%s@%s", key.GVR.String(), scope)) } return strings.Join(parts, " | ") } @@ -399,55 +392,45 @@ func (m *Manager) forgetGitTargetWatches(gitDest types.ResourceReference) { m.forgetTargetRetention(gitDest) } -// targetWatchStreams computes a GitTarget's declared stream set: ONE stream per cell, carrying -// the served version it opens at and the union of the operation filters that selected it. +// targetWatchStreams computes a GitTarget's declared stream set: ONE stream per collection, carrying +// the served version it opens at. // -// One stream per cell is the invariant the whole sweep boundary rests on. A cell is -// group/resource/namespace (types.CellKey), so two followable records of one logical resource -// at different served versions, selected under the same scope, are ONE cell: one sweep +// One stream per collection is the invariant the whole sweep boundary rests on. A collection is +// group/resource/namespace (types.CollectionKey), so two followable records of one logical resource +// at different served versions, selected under the same scope, are ONE collection: one sweep // boundary, one render-fidelity scope, one coalescing key. Streaming both would mean two // snapshots of one boundary, each sweeping the documents the other gathered. So the version is -// chosen once, deterministically, and the operation filters are unioned rather than dropped, so -// no rule loses coverage to the collapse. +// chosen once, deterministically. // // A cluster-wide scope ("") stays a peer of any named namespace on the same type, never a -// replacement for it: collapsing THOSE widened the named rule's stream and dropped its -// operation set (pr2-stream-scope-collapse.md). They are different cells, and both stream. -func targetWatchStreams(table WatchedTypeTable) map[targetWatchKey]OperationSet { - chosen := map[types.CellKey]targetWatchKey{} - chosenPreferred := map[types.CellKey]bool{} - ops := map[types.CellKey]OperationSet{} +// replacement for it: collapsing THOSE widened the named rule's stream +// (pr2-stream-scope-collapse.md). They are different collections, and both stream. +func targetWatchStreams(table WatchedTypeTable) map[targetWatchKey]struct{} { + chosen := map[types.CollectionKey]targetWatchKey{} + chosenPreferred := map[types.CollectionKey]bool{} for _, wt := range table.Types { for _, ns := range wt.WatchScopes() { - cell := types.CellKeyFor(wt.GVR, ns) + collection := types.CollectionKeyFor(wt.GVR, ns) candidate := targetWatchKey{GVR: wt.GVR, Namespace: ns} - if prior, seen := chosen[cell]; !seen || - preferServedVersion(prior, chosenPreferred[cell], candidate, wt.Preferred) { - chosen[cell] = candidate - chosenPreferred[cell] = wt.Preferred - } - set, ok := ops[cell] - if !ok { - set = OperationSet{} - ops[cell] = set - } - for op := range wt.NamespaceOps[ns] { - set[op] = struct{}{} + if prior, seen := chosen[collection]; !seen || + preferServedVersion(prior, chosenPreferred[collection], candidate, wt.Preferred) { + chosen[collection] = candidate + chosenPreferred[collection] = wt.Preferred } } } - out := make(map[targetWatchKey]OperationSet, len(chosen)) - for cell, key := range chosen { - out[key] = ops[cell] + out := make(map[targetWatchKey]struct{}, len(chosen)) + for _, key := range chosen { + out[key] = struct{}{} } return out } // preferServedVersion reports whether the candidate should replace the currently chosen stream -// for one cell. The API server's preferred version wins; between two non-preferred (or two +// for one collection. The API server's preferred version wins; between two non-preferred (or two // preferred) records the higher-sorting version wins, which is arbitrary but STABLE — a rule // edit or a rediscovery must not flap the served version, because every flap would cancel the -// stream and replay the whole cell. +// stream and replay the whole collection. func preferServedVersion( prior targetWatchKey, priorPreferred bool, @@ -460,25 +443,15 @@ func preferServedVersion( return candidate.GVR.Version > prior.GVR.Version } -// targetWatchSpecs renders the declared stream set as the comparable per-stream spec: -// everything about a stream that, when it changes, invalidates the running one. The -// operations are rendered to their canonical string because a map is neither comparable -// nor safe to retain by reference. -func targetWatchSpecs(table WatchedTypeTable) map[targetWatchKey]string { - return renderTargetWatchSpecs(targetWatchStreams(table)) +// targetWatchKeys returns the declared stream set in a stable order: one key per collection, +// carrying the served version its watch opens at. +func targetWatchKeys(table WatchedTypeTable) []targetWatchKey { + return sortedTargetWatchKeys(targetWatchStreams(table)) } -func renderTargetWatchSpecs(streams map[targetWatchKey]OperationSet) map[targetWatchKey]string { - out := make(map[targetWatchKey]string, len(streams)) - for key, ops := range streams { - out[key] = operationSpec(ops) - } - return out -} - -func sortedTargetWatchSpecKeys(specs map[targetWatchKey]string) []targetWatchKey { - out := make([]targetWatchKey, 0, len(specs)) - for key := range specs { +func sortedTargetWatchKeys(keys map[targetWatchKey]struct{}) []targetWatchKey { + out := make([]targetWatchKey, 0, len(keys)) + for key := range keys { out = append(out, key) } sort.Slice(out, func(i, j int) bool { @@ -490,13 +463,6 @@ func sortedTargetWatchSpecKeys(specs map[targetWatchKey]string) []targetWatchKey return out } -func operationSpec(ops OperationSet) string { - if len(ops) == 0 { - return "*" - } - return fmt.Sprint(ops.Sorted()) -} - func (m *Manager) runTargetWatch( ctx context.Context, log logr.Logger, @@ -511,7 +477,7 @@ func (m *Manager) runTargetWatch( releaseFacts := m.followFactsForWatch(gitDest, stream.key) defer releaseFacts() - // Starting a stream issues its cell a fresh fidelity revision, so its first session must + // Starting a stream issues its collection a fresh fidelity revision, so its first session must // replay even when a durable cursor exists: resuming would leave that scope pending under // the new revision forever. Later reconnects may resume from their cursors because they stay // within the same stream, and so within the same revision. @@ -534,7 +500,7 @@ func (m *Manager) runTargetWatch( } if err != nil { if state, reason, mark := targetStreamStateForSessionEnd(err); mark { - m.markTargetStreamState(gitDest, stream.key.Cell(), state, reason, err.Error()) + m.markTargetStreamState(gitDest, stream.key.Collection(), state, reason, err.Error()) } log.Info("target watch session ended; reconnecting", "gvr", stream.key.GVR.String(), "namespace", stream.key.Namespace, "err", err.Error()) @@ -549,7 +515,7 @@ func (m *Manager) runTargetWatch( // grading is worth publishing at all. // // A session ENDING says only that it ended. Whether anything is WRONG is the next open's answer, -// and every open-failure path below already marks the cell Blocked/WatchError one backoff later. +// and every open-failure path below already marks the collection Blocked/WatchError one backoff later. // Reporting a clean end as Blocked cost a Warning event, a Ready flip and a cadence change on a // perfectly healthy cluster, every time the API server hit its randomized watch timeout — roughly // every forty minutes, per type. The protocol working is not a failure. @@ -610,7 +576,7 @@ func (m *Manager) targetWatchReplayAndStream( recordWatchSessionEnded(ctx, stream.key.GVR, sessionEndedExpired) m.markTargetStreamState( gitDest, - stream.key.Cell(), + stream.key.Collection(), StreamStateReplaying, StreamReasonExpiredResourceVersion, "stored watch cursor expired; rebuilding from a fresh replay", @@ -633,7 +599,7 @@ func (m *Manager) targetWatchReplayAndStream( } m.markTargetStreamState( gitDest, - stream.key.Cell(), + stream.key.Collection(), StreamStateReplaying, reason, "target watch replay in progress", @@ -652,7 +618,7 @@ func (m *Manager) targetWatchReplayAndStream( } m.markTargetStreamState( gitDest, - stream.key.Cell(), + stream.key.Collection(), StreamStateBlocked, StreamReasonWatchError, err.Error(), @@ -732,7 +698,7 @@ func (m *Manager) targetWatchResumeAndStream( } m.markTargetStreamState( gitDest, - stream.key.Cell(), + stream.key.Collection(), StreamStateBlocked, StreamReasonWatchError, err.Error(), @@ -752,7 +718,7 @@ func (m *Manager) targetWatchResumeAndStream( "namespace", stream.key.Namespace, "resourceVersion", cursor) m.markTargetStreamState( gitDest, - stream.key.Cell(), + stream.key.Collection(), StreamStateStreaming, StreamReasonAllStreamsReady, "target watch resumed from durable cursor", @@ -777,7 +743,7 @@ func (m *Manager) targetWatchListAndStream( } m.markTargetStreamState( gitDest, - stream.key.Cell(), + stream.key.Collection(), StreamStateBlocked, StreamReasonWatchError, err.Error(), @@ -802,7 +768,7 @@ func (m *Manager) targetWatchListAndStream( } m.markTargetStreamState( gitDest, - stream.key.Cell(), + stream.key.Collection(), StreamStateBlocked, StreamReasonWatchError, err.Error(), @@ -827,7 +793,7 @@ func (m *Manager) targetWatchListAndStream( "count", len(desired), "resourceVersion", resourceVersion) m.markTargetStreamState( gitDest, - stream.key.Cell(), + stream.key.Collection(), StreamStateStreaming, StreamReasonAllStreamsReady, "target watch list fallback complete", @@ -864,7 +830,7 @@ func (m *Manager) handleTargetWatchSessionEvent( *replay = nil m.markTargetStreamState( gitDest, - stream.key.Cell(), + stream.key.Collection(), StreamStateStreaming, StreamReasonAllStreamsReady, "target watch replay complete", @@ -923,7 +889,7 @@ func (m *Manager) enqueueReplayResync( } // Cancellation has to be prompt on the PRODUCER side: nothing filters the branch worker's // queue, so what bounds a retired stream's tail is how quickly it stops enqueuing. A snapshot - // gathered for a cell that has since been stopped or restarted has nothing left to report + // gathered for a collection that has since been stopped or restarted has nothing left to report // into either. select { case <-ctx.Done(): @@ -932,12 +898,12 @@ func (m *Manager) enqueueReplayResync( } // The stream's PLAN revision (stream.revision, a render-fidelity generation counter — not the // resourceVersion this snapshot is pinned to) is the one this stream was STARTED with, never - // the cell's current one. A cancelled stream can still be in flight with a replay result, and + // the collection's current one. A cancelled stream can still be in flight with a replay result, and // reading that revision here would let it report a scope clean under a revision it never // replayed for — reopening writes on the strength of a snapshot the new plan never gathered. // The gate already ignores a superseded revision; capturing it at start is what makes it stale. resultCh, enqueued, err := m.EventRouter.enqueueScopedResync( - ctx, gitDest, resyncScopeForWatchKey(stream.key), stream.sourceCell(), desired, resourceVersion, false, + ctx, gitDest, resyncScopeForWatchKey(stream.key), stream.sourceCollection(), desired, resourceVersion, false, stream.refreshRemote) if err != nil { return err @@ -946,14 +912,14 @@ func (m *Manager) enqueueReplayResync( // case: EnqueueResync has ALREADY delivered ErrFinalizeQueueFull on the reply channel for the // drain to record, so returning here instead left that reply in a buffered channel nobody ever // read -- and with it went the only calls that mark acceptance, render fidelity and retention - // for this cell. The render-fidelity scope then owed a report under a revision no running + // for this collection. The render-fidelity scope then owed a report under a revision no running // stream would ever report again, which pins the GitTarget at Ready=False and, through it, // every WatchRule pointing at it. // // The stream.key (GVR + namespace) is threaded to the drain for diagnostics. A refused // Git path acceptance is target-level state, so the drain records GitPathAccepted=False rather // than mutating this stream's watch readiness. - go m.EventRouter.drainScopedResync(gitDest, stream.key.Cell(), "reconcile", stream.revision, resultCh) + go m.EventRouter.drainScopedResync(gitDest, stream.key.Collection(), "reconcile", stream.revision, resultCh) if !enqueued { return fmt.Errorf("target replay resync for %s on %s dropped: %w", stream.key.GVR.String(), gitDest.String(), git.ErrFinalizeQueueFull) @@ -1075,15 +1041,11 @@ func (m *Manager) routeLiveTargetWatchEvent( return rv, nil } op := operationForLiveTargetWatchEvent(ev.Type, u) - if !stream.ops.Match(op) { - recordWatchEvent(ctx, gitDest, stream.key.GVR, watchOutcomeOperationFiltered) - return rv, nil - } event := targetWatchGitEvent(stream.key.GVR, u, op) - // Stamp the producing cell and stream incarnation before the event leaves the stream. + // Stamp the producing collection and stream incarnation before the event leaves the stream. // It is the only place both are known: downstream, a cluster-wide and a namespaced - // stream deliver the same object and the cell can no longer be recovered from it. - event.SourceCell = stream.sourceCell() + // stream deliver the same object and the collection can no longer be recovered from it. + event.SourceCollection = stream.sourceCollection() // Carry the source cluster so the git writer resolves this document's GVK->GVR // against the cluster it was watched on, never a union of all clusters. event.SourceCluster = m.clusterIDForGitTarget(gitDest) @@ -1159,7 +1121,7 @@ func (m *Manager) attachAuthor( // deletionTimestamp, both mapped to OperationDelete) has an RV that never matches the // author fact's post-write RV, so it may consult the /last pointer; a create/update is // exact-capable and must not fall through to /last. - exactCapable := event.Operation != string(configv1alpha3.OperationDelete) + exactCapable := event.Operation != string(types.OperationDelete) // event.SourceCluster (stamped just above, before this call) is the ClusterProvider NAME this // event was watched on; auditRouteForCluster turns it into the AUDIT ROUTE the handler filed // facts under. The two differ whenever several providers name one cluster: an API server has one @@ -1209,7 +1171,7 @@ func (m *Manager) skipUnchangedLiveUpdate( op string, ) bool { key := liveContentDedupKey(gitDest, gvr, u) - if op == string(configv1alpha3.OperationDelete) { + if op == string(types.OperationDelete) { m.liveContentDedup.Delete(key) return false } @@ -1217,7 +1179,7 @@ func (m *Manager) skipUnchangedLiveUpdate( if !ok { return false } - if op == string(configv1alpha3.OperationUpdate) { + if op == string(types.OperationUpdate) { if prev, loaded := m.liveContentDedup.Load(key); loaded { if prevHash, isStr := prev.(string); isStr && prevHash == hash { return true @@ -1272,7 +1234,7 @@ func targetWatchGitEvent(gvr schema.GroupVersionResource, u *unstructured.Unstru ResourceVersion: u.GetResourceVersion(), Generation: u.GetGeneration(), } - if op != string(configv1alpha3.OperationDelete) { + if op != string(types.OperationDelete) { event.Object = sanitize.Sanitize(u) } return event @@ -1281,11 +1243,11 @@ func targetWatchGitEvent(gvr schema.GroupVersionResource, u *unstructured.Unstru func operationForWatchEvent(eventType watch.EventType) string { switch eventType { case watch.Added: - return string(configv1alpha3.OperationCreate) + return string(types.OperationCreate) case watch.Modified: - return string(configv1alpha3.OperationUpdate) + return string(types.OperationUpdate) case watch.Deleted: - return string(configv1alpha3.OperationDelete) + return string(types.OperationDelete) case watch.Bookmark, watch.Error: return "" default: @@ -1305,24 +1267,11 @@ func operationForWatchEvent(eventType watch.EventType) string { // holds. See docs/spec/attribution.md §1. func operationForLiveTargetWatchEvent(eventType watch.EventType, u *unstructured.Unstructured) string { if u != nil && u.GetDeletionTimestamp() != nil { - return string(configv1alpha3.OperationDelete) + return string(types.OperationDelete) } return operationForWatchEvent(eventType) } -// Match reports whether the operation is included in the operation set. A nil or -// empty set means all operations, matching WatchRule semantics. -func (s OperationSet) Match(op string) bool { - if len(s) == 0 { - return true - } - if _, ok := s["*"]; ok { - return true - } - _, ok := s[op] - return ok -} - // openTargetWatch opens a watch against the cluster the GitTarget mirrors from. clusterID is // LocalClusterID for a single-cluster GitTarget, which resolves to the in-cluster dynamic // client exactly as before; a remote id resolves to that source cluster's dynamic client, @@ -1534,17 +1483,19 @@ func sleepOrDone(ctx context.Context, d time.Duration) bool { } } -// streamRevisions is the per-cell revision map every consumer of a stream's reports is keyed by. +// streamRevisions is the per-collection revision map every consumer of a stream's reports is keyed by. // It fills in zeros when no shared render-fidelity gate is wired — the legacy data path, where a // zero revision is what the mark functions already treat as "not gated" — so the retention -// roll-up still tracks exactly the cells the plan selects. -func streamRevisions(cells []types.CellKey, revisions map[types.CellKey]uint64) map[types.CellKey]uint64 { +// roll-up still tracks exactly the collections the plan selects. +func streamRevisions( + collections []types.CollectionKey, revisions map[types.CollectionKey]uint64, +) map[types.CollectionKey]uint64 { if revisions != nil { return revisions } - out := make(map[types.CellKey]uint64, len(cells)) - for _, cell := range cells { - out[cell] = 0 + out := make(map[types.CollectionKey]uint64, len(collections)) + for _, collection := range collections { + out[collection] = 0 } return out } diff --git a/internal/watch/target_watch_plan.go b/internal/watch/target_watch_plan.go index d35d9aa1a..efb4b55d8 100644 --- a/internal/watch/target_watch_plan.go +++ b/internal/watch/target_watch_plan.go @@ -12,59 +12,57 @@ import ( "github.com/ConfigButler/gitops-reverser/internal/types" ) -// cellSpec is everything about one cell's stream that, when it changes, invalidates the running -// one: the canonical operation filter, and the served version the watch is opened at. +// watchSpec is everything about one collection's stream that, when it changes, invalidates the running +// one: the served version the watch is opened at. // -// The version is spec DATA rather than identity. A cell is versionless -// (see [types.CellKey]), so a storage-version bump is one cell whose spec changed — a -// `restart` — and not the retirement of one key plus the birth of another. Diffing -// `map[targetWatchKey]string` directly would classify it the second way, which would replay -// the cell AND drop its readiness result rather than replacing the stream in place. -type cellSpec struct { - // Operations is the canonical, sorted operation filter, as rendered by operationSpec. - Operations string +// The version is spec DATA rather than identity. A collection is versionless +// (see [types.CollectionKey]), so a storage-version bump is one collection whose spec changed — a +// `restart` — and not the retirement of one key plus the birth of another. Diffing the +// declared targetWatchKeys directly would classify it the second way, which would replay +// the collection AND drop its readiness result rather than replacing the stream in place. +type watchSpec struct { // Version is the served version the stream opens its watch at. Version string } -// targetWatchPlan is the desired watch set of one GitTarget, keyed by cell. +// targetWatchPlan is the desired watch set of one GitTarget, keyed by collection. type targetWatchPlan struct { - Cells map[types.CellKey]cellSpec + Collections map[types.CollectionKey]watchSpec } -// targetWatchPlanDiff is the classification of a previous plan against a desired one. Every cell +// targetWatchPlanDiff is the classification of a previous plan against a desired one. Every collection // named by either plan appears in exactly one of the four lists, each sorted for a stable log. // // It is what the streams are driven from: keep leaves a stream and its readiness result alone, // start and restart open one, and stop cancels one and drops its key without touching files. type targetWatchPlanDiff struct { - // Keep is the cells whose key and specification are unchanged. - Keep []types.CellKey - // Start is the cells only in the desired plan. - Start []types.CellKey - // Restart is the cells in both plans whose specification changed — and, on a forced - // recheck, every desired cell. - Restart []types.CellKey - // Stop is the cells only in the previous plan. - Stop []types.CellKey + // Keep is the collections whose key and specification are unchanged. + Keep []types.CollectionKey + // Start is the collections only in the desired plan. + Start []types.CollectionKey + // Restart is the collections in both plans whose specification changed — and, on a forced + // recheck, every desired collection. + Restart []types.CollectionKey + // Stop is the collections only in the previous plan. + Stop []types.CollectionKey } -// targetWatchPlanFor re-keys a rendered spec map by cell, carrying the served version across as -// spec data. +// targetWatchPlanFor re-keys the declared streams by collection, carrying the served version across +// as spec data. // -// [targetWatchStreams] guarantees one stream per cell, so the re-keying cannot collide — but a +// [targetWatchStreams] guarantees one stream per collection, so the re-keying cannot collide — but a // collision would silently drop one of the two streams from the plan, and the plan is what the // streams are started from, so it is asserted here rather than assumed. -func targetWatchPlanFor(specs map[targetWatchKey]string) (targetWatchPlan, error) { - plan := targetWatchPlan{Cells: make(map[types.CellKey]cellSpec, len(specs))} - for _, key := range sortedTargetWatchSpecKeys(specs) { - cell := key.Cell() - if prior, seen := plan.Cells[cell]; seen { +func targetWatchPlanFor(keys []targetWatchKey) (targetWatchPlan, error) { + plan := targetWatchPlan{Collections: make(map[types.CollectionKey]watchSpec, len(keys))} + for _, key := range keys { + collection := key.Collection() + if prior, seen := plan.Collections[collection]; seen { return targetWatchPlan{}, fmt.Errorf( - "two declared streams share the cell %s: served versions %s and %s", - cell, prior.Version, key.GVR.Version) + "two declared streams share the collection %s: served versions %s and %s", + collection, prior.Version, key.GVR.Version) } - plan.Cells[cell] = cellSpec{Operations: specs[key], Version: key.GVR.Version} + plan.Collections[collection] = watchSpec{Version: key.GVR.Version} } return plan, nil } @@ -72,60 +70,60 @@ func targetWatchPlanFor(specs map[targetWatchKey]string) (targetWatchPlan, error // diffTargetWatchPlans classifies previous against desired, per the table in // docs/design/target-watch-plan.md, "Diff the plan". // -// `force` is a forced recovery, not a fifth outcome: it classifies every desired cell as a +// `force` is a forced recovery, not a fifth outcome: it classifies every desired collection as a // restart, including one the previous plan never held, so a recheck reopens the whole target -// without a state machine of its own. Cells the desired plan dropped are still `stop`. +// without a state machine of its own. Collections the desired plan dropped are still `stop`. func diffTargetWatchPlans(previous, desired targetWatchPlan, force bool) targetWatchPlanDiff { var diff targetWatchPlanDiff - for cell, want := range desired.Cells { - switch have, running := previous.Cells[cell]; { + for collection, want := range desired.Collections { + switch have, running := previous.Collections[collection]; { case force: - diff.Restart = append(diff.Restart, cell) + diff.Restart = append(diff.Restart, collection) case !running: - diff.Start = append(diff.Start, cell) + diff.Start = append(diff.Start, collection) case have != want: - diff.Restart = append(diff.Restart, cell) + diff.Restart = append(diff.Restart, collection) default: - diff.Keep = append(diff.Keep, cell) + diff.Keep = append(diff.Keep, collection) } } - for cell := range previous.Cells { - if _, wanted := desired.Cells[cell]; !wanted { - diff.Stop = append(diff.Stop, cell) + for collection := range previous.Collections { + if _, wanted := desired.Collections[collection]; !wanted { + diff.Stop = append(diff.Stop, collection) } } - sortCells(diff.Keep) - sortCells(diff.Start) - sortCells(diff.Restart) - sortCells(diff.Stop) + sortCollections(diff.Keep) + sortCollections(diff.Start) + sortCollections(diff.Restart) + sortCollections(diff.Stop) return diff } -func sortCells(cells []types.CellKey) { - sort.Slice(cells, func(i, j int) bool { - if cells[i].Group != cells[j].Group { - return cells[i].Group < cells[j].Group +func sortCollections(collections []types.CollectionKey) { + sort.Slice(collections, func(i, j int) bool { + if collections[i].Group != collections[j].Group { + return collections[i].Group < collections[j].Group } - if cells[i].Resource != cells[j].Resource { - return cells[i].Resource < cells[j].Resource + if collections[i].Resource != collections[j].Resource { + return collections[i].Resource < collections[j].Resource } - return cells[i].Namespace < cells[j].Namespace + return collections[i].Namespace < collections[j].Namespace }) } -// describeCells renders cells as "=@", the same convention +// describeCollections renders collections as "@", the same convention // [describeWatchKeys] uses: name every stream rather than only counting them, because a count -// cannot tell an operator WHICH cell a plan change is about to replay. -func describeCells(cells []types.CellKey, plan targetWatchPlan) string { - parts := make([]string, 0, len(cells)) - for _, cell := range cells { - spec := plan.Cells[cell] - parts = append(parts, fmt.Sprintf("%s=%s@%s", cell, spec.Operations, spec.Version)) +// cannot tell an operator WHICH collection a plan change is about to replay. +func describeCollections(collections []types.CollectionKey, plan targetWatchPlan) string { + parts := make([]string, 0, len(collections)) + for _, collection := range collections { + spec := plan.Collections[collection] + parts = append(parts, fmt.Sprintf("%s@%s", collection, spec.Version)) } return strings.Join(parts, " | ") } -// logTargetWatchPlanDiff records the classification once per reconcile, naming every cell it +// logTargetWatchPlanDiff records the classification once per reconcile, naming every collection it // acted on. An all-keep reconcile is logged too: "nothing changed" is the most common outcome // and the one an operator most wants confirmed. func logTargetWatchPlanDiff(log logr.Logger, previous, desired targetWatchPlan, diff targetWatchPlanDiff) { @@ -135,20 +133,20 @@ func logTargetWatchPlanDiff(log logr.Logger, previous, desired targetWatchPlan, "restart", len(diff.Restart), "stop", len(diff.Stop), } - // A stopped cell is gone from the desired plan, so its specification only survives on the + // A stopped collection is gone from the desired plan, so its specification only survives on the // previous one. for _, named := range []struct { - key string - cells []types.CellKey - plan targetWatchPlan + key string + collections []types.CollectionKey + plan targetWatchPlan }{ - {"keepCells", diff.Keep, desired}, - {"startCells", diff.Start, desired}, - {"restartCells", diff.Restart, desired}, - {"stopCells", diff.Stop, previous}, + {"keepCollections", diff.Keep, desired}, + {"startCollections", diff.Start, desired}, + {"restartCollections", diff.Restart, desired}, + {"stopCollections", diff.Stop, previous}, } { - if len(named.cells) > 0 { - kv = append(kv, named.key, describeCells(named.cells, named.plan)) + if len(named.collections) > 0 { + kv = append(kv, named.key, describeCollections(named.collections, named.plan)) } } log.Info("target watch plan reconciled", kv...) diff --git a/internal/watch/target_watch_plan_test.go b/internal/watch/target_watch_plan_test.go index 9b2f8556e..99a238b71 100644 --- a/internal/watch/target_watch_plan_test.go +++ b/internal/watch/target_watch_plan_test.go @@ -20,48 +20,48 @@ var ( planApps = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"} ) -func planCell(gvr schema.GroupVersionResource, namespace string) types.CellKey { - return types.CellKeyFor(gvr, namespace) +func planCollection(gvr schema.GroupVersionResource, namespace string) types.CollectionKey { + return types.CollectionKeyFor(gvr, namespace) } -func planOf(cells map[types.CellKey]cellSpec) targetWatchPlan { - return targetWatchPlan{Cells: cells} +func planOf(collections map[types.CollectionKey]watchSpec) targetWatchPlan { + return targetWatchPlan{Collections: collections} } -func TestTargetWatchPlanFor_ReKeysByCellAndCarriesTheVersion(t *testing.T) { - plan, err := targetWatchPlanFor(map[targetWatchKey]string{ - {GVR: planV1, Namespace: "team-a"}: "[CREATE]", - {GVR: planApps}: "[*]", +func TestTargetWatchPlanFor_ReKeysByCollectionAndCarriesTheVersion(t *testing.T) { + plan, err := targetWatchPlanFor([]targetWatchKey{ + {GVR: planV1, Namespace: "team-a"}, + {GVR: planApps}, }) require.NoError(t, err) - require.Len(t, plan.Cells, 2) - assert.Equal(t, cellSpec{Operations: "[CREATE]", Version: "v1"}, plan.Cells[planCell(planV1, "team-a")]) - assert.Equal(t, cellSpec{Operations: "[*]", Version: "v1"}, plan.Cells[planCell(planApps, "")]) + require.Len(t, plan.Collections, 2) + assert.Equal(t, watchSpec{Version: "v1"}, plan.Collections[planCollection(planV1, "team-a")]) + assert.Equal(t, watchSpec{Version: "v1"}, plan.Collections[planCollection(planApps, "")]) } -// The whole point of re-keying: one storage-version bump is ONE cell, so it is a restart of that -// cell rather than a stop of one key plus a start of another. -func TestTargetWatchPlanFor_AServedVersionBumpKeepsTheSameCell(t *testing.T) { - before, err := targetWatchPlanFor(map[targetWatchKey]string{{GVR: planV1, Namespace: "team-a"}: "[CREATE]"}) +// The whole point of re-keying: one storage-version bump is ONE collection, so it is a restart of that +// collection rather than a stop of one key plus a start of another. +func TestTargetWatchPlanFor_AServedVersionBumpKeepsTheSameCollection(t *testing.T) { + before, err := targetWatchPlanFor([]targetWatchKey{{GVR: planV1, Namespace: "team-a"}}) require.NoError(t, err) - after, err := targetWatchPlanFor(map[targetWatchKey]string{{GVR: planV1beta, Namespace: "team-a"}: "[CREATE]"}) + after, err := targetWatchPlanFor([]targetWatchKey{{GVR: planV1beta, Namespace: "team-a"}}) require.NoError(t, err) diff := diffTargetWatchPlans(before, after, false) - assert.Equal(t, []types.CellKey{planCell(planV1, "team-a")}, diff.Restart) + assert.Equal(t, []types.CollectionKey{planCollection(planV1, "team-a")}, diff.Restart) assert.Empty(t, diff.Start) assert.Empty(t, diff.Stop) assert.Empty(t, diff.Keep) } -// targetWatchStreams guarantees one stream per cell, so this is an assertion on an invariant +// targetWatchStreams guarantees one stream per collection, so this is an assertion on an invariant // rather than a case the renderer can produce. -func TestTargetWatchPlanFor_RejectsTwoStreamsOnOneCell(t *testing.T) { - _, err := targetWatchPlanFor(map[targetWatchKey]string{ - {GVR: planV1, Namespace: "team-a"}: "[CREATE]", - {GVR: planV1beta, Namespace: "team-a"}: "[CREATE]", +func TestTargetWatchPlanFor_RejectsTwoStreamsOnOneCollection(t *testing.T) { + _, err := targetWatchPlanFor([]targetWatchKey{ + {GVR: planV1, Namespace: "team-a"}, + {GVR: planV1beta, Namespace: "team-a"}, }) require.Error(t, err) @@ -73,85 +73,77 @@ func TestTargetWatchPlanFor_EmptySpecsIsAnEmptyPlan(t *testing.T) { plan, err := targetWatchPlanFor(nil) require.NoError(t, err) - assert.Empty(t, plan.Cells) + assert.Empty(t, plan.Collections) } func TestDiffTargetWatchPlans(t *testing.T) { - teamA := planCell(planV1, "team-a") - teamB := planCell(planV1, "team-b") - clusterWide := planCell(planApps, "") - create := cellSpec{Operations: "[CREATE]", Version: "v1"} - update := cellSpec{Operations: "[UPDATE]", Version: "v1"} - createV1beta := cellSpec{Operations: "[CREATE]", Version: "v1beta1"} + teamA := planCollection(planV1, "team-a") + teamB := planCollection(planV1, "team-b") + clusterWide := planCollection(planApps, "") + v1 := watchSpec{Version: "v1"} + v1beta := watchSpec{Version: "v1beta1"} tests := []struct { name string previous targetWatchPlan desired targetWatchPlan force bool - keep []types.CellKey - start []types.CellKey - restart []types.CellKey - stop []types.CellKey + keep []types.CollectionKey + start []types.CollectionKey + restart []types.CollectionKey + stop []types.CollectionKey }{ { - name: "an unchanged plan keeps every cell", - previous: planOf(map[types.CellKey]cellSpec{teamA: create, teamB: update}), - desired: planOf(map[types.CellKey]cellSpec{teamA: create, teamB: update}), - keep: []types.CellKey{teamA, teamB}, + name: "an unchanged plan keeps every collection", + previous: planOf(map[types.CollectionKey]watchSpec{teamA: v1, teamB: v1}), + desired: planOf(map[types.CollectionKey]watchSpec{teamA: v1, teamB: v1}), + keep: []types.CollectionKey{teamA, teamB}, }, { - name: "adding a rule starts one cell and keeps the rest", - previous: planOf(map[types.CellKey]cellSpec{teamA: create}), - desired: planOf(map[types.CellKey]cellSpec{teamA: create, teamB: create}), - keep: []types.CellKey{teamA}, - start: []types.CellKey{teamB}, + name: "adding a rule starts one collection and keeps the rest", + previous: planOf(map[types.CollectionKey]watchSpec{teamA: v1}), + desired: planOf(map[types.CollectionKey]watchSpec{teamA: v1, teamB: v1}), + keep: []types.CollectionKey{teamA}, + start: []types.CollectionKey{teamB}, }, { - name: "removing one of several rules stops one cell", - previous: planOf(map[types.CellKey]cellSpec{teamA: create, teamB: create}), - desired: planOf(map[types.CellKey]cellSpec{teamA: create}), - keep: []types.CellKey{teamA}, - stop: []types.CellKey{teamB}, + name: "removing one of several rules stops one collection", + previous: planOf(map[types.CollectionKey]watchSpec{teamA: v1, teamB: v1}), + desired: planOf(map[types.CollectionKey]watchSpec{teamA: v1}), + keep: []types.CollectionKey{teamA}, + stop: []types.CollectionKey{teamB}, }, { - name: "an operation-filter edit restarts that cell alone", - previous: planOf(map[types.CellKey]cellSpec{teamA: create, teamB: create}), - desired: planOf(map[types.CellKey]cellSpec{teamA: update, teamB: create}), - keep: []types.CellKey{teamB}, - restart: []types.CellKey{teamA}, + name: "a served-version change restarts that collection alone", + previous: planOf(map[types.CollectionKey]watchSpec{teamA: v1, teamB: v1}), + desired: planOf(map[types.CollectionKey]watchSpec{teamA: v1beta, teamB: v1}), + keep: []types.CollectionKey{teamB}, + restart: []types.CollectionKey{teamA}, }, { - name: "a served-version change restarts that cell alone", - previous: planOf(map[types.CellKey]cellSpec{teamA: create, teamB: create}), - desired: planOf(map[types.CellKey]cellSpec{teamA: createV1beta, teamB: create}), - keep: []types.CellKey{teamB}, - restart: []types.CellKey{teamA}, - }, - { - name: "a forced recheck restarts every desired cell, new ones included", - previous: planOf(map[types.CellKey]cellSpec{teamA: create}), - desired: planOf(map[types.CellKey]cellSpec{teamA: create, teamB: create}), + name: "a forced recheck restarts every desired collection, new ones included", + previous: planOf(map[types.CollectionKey]watchSpec{teamA: v1}), + desired: planOf(map[types.CollectionKey]watchSpec{teamA: v1, teamB: v1}), force: true, - restart: []types.CellKey{teamA, teamB}, + restart: []types.CollectionKey{teamA, teamB}, }, { - name: "a forced recheck still stops a dropped cell", - previous: planOf(map[types.CellKey]cellSpec{teamA: create, teamB: create}), - desired: planOf(map[types.CellKey]cellSpec{teamA: create}), + name: "a forced recheck still stops a dropped collection", + previous: planOf(map[types.CollectionKey]watchSpec{teamA: v1, teamB: v1}), + desired: planOf(map[types.CollectionKey]watchSpec{teamA: v1}), force: true, - restart: []types.CellKey{teamA}, - stop: []types.CellKey{teamB}, + restart: []types.CollectionKey{teamA}, + stop: []types.CollectionKey{teamB}, }, { name: "a cold start starts everything", - desired: planOf(map[types.CellKey]cellSpec{teamA: create, clusterWide: create}), - start: []types.CellKey{teamA, clusterWide}, + desired: planOf(map[types.CollectionKey]watchSpec{teamA: v1, clusterWide: v1}), + start: []types.CollectionKey{teamA, clusterWide}, }, { name: "an emptied plan stops everything", - previous: planOf(map[types.CellKey]cellSpec{teamA: create, clusterWide: create}), - stop: []types.CellKey{teamA, clusterWide}, + previous: planOf(map[types.CollectionKey]watchSpec{teamA: v1, clusterWide: v1}), + stop: []types.CollectionKey{teamA, clusterWide}, }, { name: "an empty plan on both sides classifies nothing", @@ -170,44 +162,44 @@ func TestDiffTargetWatchPlans(t *testing.T) { } } -// A cluster-wide cell is a PEER of a named namespace on the same type, so narrowing one rule +// A cluster-wide collection is a PEER of a named namespace on the same type, so narrowing one rule // must not be read as a change to the other. -func TestDiffTargetWatchPlans_ClusterWideAndNamespacedAreDistinctCells(t *testing.T) { - clusterWide := planCell(planV1, "") - namespaced := planCell(planV1, "team-a") - create := cellSpec{Operations: "[CREATE]", Version: "v1"} +func TestDiffTargetWatchPlans_ClusterWideAndNamespacedAreDistinctCollections(t *testing.T) { + clusterWide := planCollection(planV1, "") + namespaced := planCollection(planV1, "team-a") + create := watchSpec{Version: "v1"} diff := diffTargetWatchPlans( - planOf(map[types.CellKey]cellSpec{clusterWide: create}), - planOf(map[types.CellKey]cellSpec{namespaced: create}), + planOf(map[types.CollectionKey]watchSpec{clusterWide: create}), + planOf(map[types.CollectionKey]watchSpec{namespaced: create}), false, ) - assert.Equal(t, []types.CellKey{namespaced}, diff.Start) - assert.Equal(t, []types.CellKey{clusterWide}, diff.Stop) + assert.Equal(t, []types.CollectionKey{namespaced}, diff.Start) + assert.Equal(t, []types.CollectionKey{clusterWide}, diff.Stop) } -func TestDescribeCells_NamesEveryCellWithItsSpec(t *testing.T) { - teamA := planCell(planV1, "team-a") - clusterWide := planCell(planApps, "") - plan := planOf(map[types.CellKey]cellSpec{ - teamA: {Operations: "[CREATE]", Version: "v1"}, - clusterWide: {Operations: "[*]", Version: "v1beta1"}, +func TestDescribeCollections_NamesEveryCollectionWithItsSpec(t *testing.T) { + teamA := planCollection(planV1, "team-a") + clusterWide := planCollection(planApps, "") + plan := planOf(map[types.CollectionKey]watchSpec{ + teamA: {Version: "v1"}, + clusterWide: {Version: "v1beta1"}, }) - got := describeCells([]types.CellKey{clusterWide, teamA}, plan) + got := describeCollections([]types.CollectionKey{clusterWide, teamA}, plan) - assert.Equal(t, "deployments.apps=[*]@v1beta1 | configmaps in team-a=[CREATE]@v1", got) - assert.Empty(t, describeCells(nil, plan)) + assert.Equal(t, "deployments.apps@v1beta1 | configmaps in team-a@v1", got) + assert.Empty(t, describeCollections(nil, plan)) } func TestLogTargetWatchPlanDiff_NamesEveryCategoryItHas(t *testing.T) { log, lines := recordingLogger() - teamA := planCell(planV1, "team-a") - teamB := planCell(planV1, "team-b") - create := cellSpec{Operations: "[CREATE]", Version: "v1"} - previous := planOf(map[types.CellKey]cellSpec{teamA: create, teamB: create}) - desired := planOf(map[types.CellKey]cellSpec{teamA: create}) + teamA := planCollection(planV1, "team-a") + teamB := planCollection(planV1, "team-b") + create := watchSpec{Version: "v1"} + previous := planOf(map[types.CollectionKey]watchSpec{teamA: create, teamB: create}) + desired := planOf(map[types.CollectionKey]watchSpec{teamA: create}) logTargetWatchPlanDiff(log, previous, desired, diffTargetWatchPlans(previous, desired, false)) @@ -215,30 +207,30 @@ func TestLogTargetWatchPlanDiff_NamesEveryCategoryItHas(t *testing.T) { line := (*lines)[0] assert.Contains(t, line, `"keep"=1`) assert.Contains(t, line, `"stop"=1`) - assert.Contains(t, line, `"keepCells"="configmaps in team-a=[CREATE]@v1"`) - // A stopped cell has no desired spec left, so it is described from the previous plan. - assert.Contains(t, line, `"stopCells"="configmaps in team-b=[CREATE]@v1"`) - assert.NotContains(t, line, "startCells") - assert.NotContains(t, line, "restartCells") + assert.Contains(t, line, `"keepCollections"="configmaps in team-a@v1"`) + // A stopped collection has no desired spec left, so it is described from the previous plan. + assert.Contains(t, line, `"stopCollections"="configmaps in team-b@v1"`) + assert.NotContains(t, line, "startCollections") + assert.NotContains(t, line, "restartCollections") } // An all-keep reconcile must still be classified and logged: "nothing changed" is the most -// common outcome, and it is the one that proves no unrelated cell was replayed. +// common outcome, and it is the one that proves no unrelated collection was replayed. func TestReplaceGitTargetWatches_LogsAnAllKeepReconcileAndTouchesNothing(t *testing.T) { log, lines := recordingLogger() gitDest := types.NewResourceReference("target", "default") table := WatchedTypeTable{ GitDest: gitDest, Types: []WatchedType{{ - GVR: configmapsGVR, - NamespaceOps: map[string]OperationSet{"apps": {"CREATE": struct{}{}}}, + GVR: configmapsGVR, + NamespaceScopes: map[string]struct{}{"apps": {}}, }}, } manager := &Manager{Log: log} cancelled := false key := targetWatchKey{GVR: configmapsGVR, Namespace: "apps"} - manager.targetWatchSet(gitDest).streams[key.Cell()] = &runningTargetWatch{ - key: key, spec: "[CREATE]", cancel: func() { cancelled = true }, + manager.targetWatchSet(gitDest).streams[key.Collection()] = &runningTargetWatch{ + key: key, cancel: func() { cancelled = true }, } require.NoError(t, manager.replaceGitTargetWatches(context.Background(), table)) @@ -246,58 +238,58 @@ func TestReplaceGitTargetWatches_LogsAnAllKeepReconcileAndTouchesNothing(t *test require.Equal(t, 1, countContaining(*lines, "target watch plan reconciled")) planLine := firstContaining(t, *lines, "target watch plan reconciled") assert.Contains(t, planLine, `"keep"=1`) - assert.Contains(t, planLine, `"keepCells"="configmaps in apps=[CREATE]@v1"`) - assert.False(t, cancelled, "a kept cell's stream must not be cancelled") + assert.Contains(t, planLine, `"keepCollections"="configmaps in apps@v1"`) + assert.False(t, cancelled, "a kept collection's stream must not be cancelled") } // Two resources in one group sort by resource, which is the branch a group-only comparison // would leave arbitrary. func TestDiffTargetWatchPlans_SortsWithinAGroupByResource(t *testing.T) { - deployments := planCell(planApps, "team-a") - statefulsets := planCell( + deployments := planCollection(planApps, "team-a") + statefulsets := planCollection( schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "statefulsets"}, "team-a") - create := cellSpec{Operations: "[CREATE]", Version: "v1"} + create := watchSpec{Version: "v1"} diff := diffTargetWatchPlans( targetWatchPlan{}, - planOf(map[types.CellKey]cellSpec{statefulsets: create, deployments: create}), + planOf(map[types.CollectionKey]watchSpec{statefulsets: create, deployments: create}), false, ) - assert.Equal(t, []types.CellKey{deployments, statefulsets}, diff.Start) + assert.Equal(t, []types.CollectionKey{deployments, statefulsets}, diff.Start) } // The set's own side of the diff is what is RUNNING, so a plan change is measured against the // streams rather than against the declaration that started them. func TestTargetWatchSet_PlanDescribesTheRunningStreams(t *testing.T) { - set := &targetWatchSet{streams: map[types.CellKey]*runningTargetWatch{}} + set := &targetWatchSet{streams: map[types.CollectionKey]*runningTargetWatch{}} key := targetWatchKey{GVR: planV1beta, Namespace: "team-a"} - set.streams[key.Cell()] = &runningTargetWatch{key: key, spec: "[CREATE]", cancel: func() {}} + set.streams[key.Collection()] = &runningTargetWatch{key: key, cancel: func() {}} plan := set.plan() assert.Equal(t, - map[types.CellKey]cellSpec{planCell(planV1, "team-a"): {Operations: "[CREATE]", Version: "v1beta1"}}, - plan.Cells, - "the cell is versionless; the served version it opened at is spec data") + map[types.CollectionKey]watchSpec{planCollection(planV1, "team-a"): {Version: "v1beta1"}}, + plan.Collections, + "the collection is versionless; the served version it opened at is spec data") } -func TestTargetWatchSet_StopCancelsOneCellAndStopAllTheRest(t *testing.T) { - set := &targetWatchSet{streams: map[types.CellKey]*runningTargetWatch{}} +func TestTargetWatchSet_StopCancelsOneCollectionAndStopAllTheRest(t *testing.T) { + set := &targetWatchSet{streams: map[types.CollectionKey]*runningTargetWatch{}} stopped := map[string]bool{} for _, ns := range []string{"team-a", "team-b"} { key := targetWatchKey{GVR: planV1, Namespace: ns} - set.streams[key.Cell()] = &runningTargetWatch{ - key: key, spec: "[*]", cancel: func() { stopped[ns] = true }, + set.streams[key.Collection()] = &runningTargetWatch{ + key: key, cancel: func() { stopped[ns] = true }, } } - set.stop(planCell(planV1, "team-a")) + set.stop(planCollection(planV1, "team-a")) assert.True(t, stopped["team-a"]) assert.False(t, stopped["team-b"]) assert.Len(t, set.streams, 1) - set.stop(planCell(planV1, "team-a")) + set.stop(planCollection(planV1, "team-a")) set.stopAll() assert.True(t, stopped["team-b"]) assert.Empty(t, set.streams) diff --git a/internal/watch/target_watch_session_end_test.go b/internal/watch/target_watch_session_end_test.go index 0dc11fb3e..8263dd2f7 100644 --- a/internal/watch/target_watch_session_end_test.go +++ b/internal/watch/target_watch_session_end_test.go @@ -115,16 +115,16 @@ func TestRunTargetWatch_CleanCloseIsSilentAndTheNextOpenReports(t *testing.T) { done := make(chan struct{}) go func() { defer close(done) - manager.runTargetWatch(ctx, logr.Discard(), gitDest, testStream(key, nil)) + manager.runTargetWatch(ctx, logr.Discard(), gitDest, testStream(key)) }() - // The first session opens and marks the cell Replaying while it folds initial events. + // The first session opens and marks the collection Replaying while it folds initial events. require.Eventually(t, func() bool { return streamStateFor(manager, gitDest, key) == StreamStateReplaying - }, time.Second, 10*time.Millisecond, "the first session marks its cell replaying") + }, time.Second, 10*time.Millisecond, "the first session marks its collection replaying") // Now end that session the way the API server ends one: close the result channel. This is a - // clean end, and it must publish NOTHING — the cell keeps the state the session left it in. + // clean end, and it must publish NOTHING — the collection keeps the state the session left it in. firstWatch.Stop() assert.Never(t, func() bool { return streamStateFor(manager, gitDest, key) == StreamStateBlocked @@ -147,7 +147,7 @@ func TestRunTargetWatch_CleanCloseIsSilentAndTheNextOpenReports(t *testing.T) { } func streamStatusFor(m *Manager, gitDest types.ResourceReference, key targetWatchKey) targetStreamStatus { - return m.watchPlane().streams[gitDest.Key()][key.Cell()] + return m.watchPlane().streams[gitDest.Key()][key.Collection()] } func streamStateFor(m *Manager, gitDest types.ResourceReference, key targetWatchKey) StreamState { diff --git a/internal/watch/target_watch_shutdown_test.go b/internal/watch/target_watch_shutdown_test.go index f8c12d939..3657c9fa6 100644 --- a/internal/watch/target_watch_shutdown_test.go +++ b/internal/watch/target_watch_shutdown_test.go @@ -35,7 +35,7 @@ func TestStreamLiveTargetWatchEvents_CanceledContextIsNotAnError(t *testing.T) { events := make(chan watch.Event) close(events) // ...which is why the watch closed - err := m.streamLiveTargetWatchEvents(ctx, logr.Discard(), gitDest, testStream(key, OperationSet{}), events) + err := m.streamLiveTargetWatchEvents(ctx, logr.Discard(), gitDest, testStream(key), events) require.NoErrorf(t, err, "iteration %d: a closed watch during shutdown is not an error", i) } } @@ -53,6 +53,6 @@ func TestStreamLiveTargetWatchEvents_ClosedChannelWhileRunningIsAnError(t *testi close(events) err := m.streamLiveTargetWatchEvents( - context.Background(), logr.Discard(), gitDest, testStream(key, OperationSet{}), events) + context.Background(), logr.Discard(), gitDest, testStream(key), events) require.ErrorIs(t, err, errTargetWatchClosed) } diff --git a/internal/watch/target_watch_test.go b/internal/watch/target_watch_test.go index 40737eba3..68a8326e8 100644 --- a/internal/watch/target_watch_test.go +++ b/internal/watch/target_watch_test.go @@ -26,105 +26,87 @@ import ( "github.com/ConfigButler/gitops-reverser/internal/types" ) -func TestTargetWatchSpecs_UsesOneWatchPerScope(t *testing.T) { +func TestTargetWatchKeys_UsesOneWatchPerScope(t *testing.T) { + clusterRoles := schema.GroupVersionResource{ + Group: "rbac.authorization.k8s.io", + Version: "v1", + Resource: "clusterroles", + } table := WatchedTypeTable{ GitDest: types.NewResourceReference("target", "default"), Types: []WatchedType{ - { - GVR: configmapsGVR, - NamespaceOps: map[string]OperationSet{ - "apps": {"CREATE": struct{}{}}, - "ops": {"UPDATE": struct{}{}}, - }, - }, - { - GVR: schema.GroupVersionResource{ - Group: "rbac.authorization.k8s.io", - Version: "v1", - Resource: "clusterroles", - }, - NamespaceOps: map[string]OperationSet{ - "": {"*": struct{}{}}, - }, - }, + {GVR: configmapsGVR, NamespaceScopes: map[string]struct{}{"apps": {}, "ops": {}}}, + {GVR: clusterRoles, NamespaceScopes: map[string]struct{}{"": {}}}, }, } - specs := targetWatchSpecs(table) - - require.Len(t, specs, 3) - assert.Equal(t, "[CREATE]", specs[targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}]) - assert.Equal(t, "[UPDATE]", specs[targetWatchKey{GVR: configmapsGVR, Namespace: "ops"}]) - assert.Equal(t, "[*]", specs[targetWatchKey{ - GVR: schema.GroupVersionResource{Group: "rbac.authorization.k8s.io", Version: "v1", Resource: "clusterroles"}, - }]) + assert.Equal(t, []targetWatchKey{ + {GVR: configmapsGVR, Namespace: "apps"}, + {GVR: configmapsGVR, Namespace: "ops"}, + {GVR: clusterRoles}, + }, targetWatchKeys(table)) } -// A cluster-wide selection and a named-namespace selection on the SAME GVR are two streams, -// each keeping its own operation filters. Collapsing them to one all-namespaces watch gave the -// named rule events from every namespace the credential could read, under the cluster-wide -// rule's operation filter rather than its own. -func TestTargetWatchSpecs_NamedAndClusterWideScopesStayDistinctStreams(t *testing.T) { +// A cluster-wide selection and a named-namespace selection on the SAME GVR are two streams. +// Collapsing them to one all-namespaces watch gave the named rule events from every namespace +// the credential could read. +func TestTargetWatchKeys_NamedAndClusterWideScopesStayDistinctStreams(t *testing.T) { table := WatchedTypeTable{ GitDest: types.NewResourceReference("target", "default"), Types: []WatchedType{{ - GVR: configmapsGVR, - NamespaceOps: map[string]OperationSet{ - "": {"UPDATE": struct{}{}}, - "team-a": {"CREATE": struct{}{}}, - }, + GVR: configmapsGVR, + NamespaceScopes: map[string]struct{}{"": {}, "team-a": {}}, }}, } - specs := targetWatchSpecs(table) - - require.Len(t, specs, 2, "a cluster-wide selection must not swallow the named-namespace stream") - assert.Equal(t, "[UPDATE]", specs[targetWatchKey{GVR: configmapsGVR}], - "the cluster-wide stream keeps its own operation set") - assert.Equal(t, "[CREATE]", specs[targetWatchKey{GVR: configmapsGVR, Namespace: "team-a"}], - "the named stream keeps its own operation set instead of inheriting the cluster-wide one") + assert.Equal(t, []targetWatchKey{ + {GVR: configmapsGVR}, + {GVR: configmapsGVR, Namespace: "team-a"}, + }, targetWatchKeys(table), "a cluster-wide selection must not swallow the named-namespace stream") } -// Two served versions of one resource, selected under the same scope, are ONE cell: one sweep +// Two served versions of one resource, selected under the same scope, are ONE collection: one sweep // boundary, one render-fidelity scope, one coalescing key. Streaming both would put two // snapshots on that boundary, each sweeping the documents the other gathered. -func TestTargetWatchStreams_OneStreamPerCellAcrossServedVersions(t *testing.T) { +func TestTargetWatchStreams_OneStreamPerCollectionAcrossServedVersions(t *testing.T) { v1beta1 := schema.GroupVersionResource{Group: "apps", Version: "v1beta1", Resource: "deployments"} v1 := schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"} table := WatchedTypeTable{ GitDest: types.NewResourceReference("target", "default"), Types: []WatchedType{ - {GVR: v1beta1, NamespaceOps: map[string]OperationSet{"team-a": {"CREATE": struct{}{}}}}, - {GVR: v1, Preferred: true, NamespaceOps: map[string]OperationSet{"team-a": {"UPDATE": struct{}{}}}}, + {GVR: v1beta1, NamespaceScopes: map[string]struct{}{"team-a": {}}}, + {GVR: v1, Preferred: true, NamespaceScopes: map[string]struct{}{"team-a": {}}}, }, } streams := targetWatchStreams(table) - require.Len(t, streams, 1, "one cell is one stream, whatever versions serve it") - ops, ok := streams[targetWatchKey{GVR: v1, Namespace: "team-a"}] + require.Len(t, streams, 1, "one collection is one stream, whatever versions serve it") + _, ok := streams[targetWatchKey{GVR: v1, Namespace: "team-a"}] require.True(t, ok, "the preferred served version is the one streamed") - assert.True(t, ops.Match("CREATE"), - "the collapsed record's operation filter is unioned in, so no rule loses coverage") - assert.True(t, ops.Match("UPDATE")) - assert.False(t, ops.Match("DELETE")) } // The choice must be stable: a re-declare that flapped the served version would cancel the -// stream and replay the whole cell every time. +// stream and replay the whole collection every time. func TestTargetWatchStreams_ServedVersionChoiceIsStable(t *testing.T) { v1 := schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"} v2 := schema.GroupVersionResource{Group: "apps", Version: "v2", Resource: "deployments"} - ops := map[string]OperationSet{"": {"*": struct{}{}}} - ascending := WatchedTypeTable{Types: []WatchedType{{GVR: v1, NamespaceOps: ops}, {GVR: v2, NamespaceOps: ops}}} - descending := WatchedTypeTable{Types: []WatchedType{{GVR: v2, NamespaceOps: ops}, {GVR: v1, NamespaceOps: ops}}} + scopes := map[string]struct{}{"": {}} + ascending := WatchedTypeTable{Types: []WatchedType{ + {GVR: v1, NamespaceScopes: scopes}, {GVR: v2, NamespaceScopes: scopes}, + }} + descending := WatchedTypeTable{Types: []WatchedType{ + {GVR: v2, NamespaceScopes: scopes}, {GVR: v1, NamespaceScopes: scopes}, + }} - assert.Equal(t, targetWatchSpecs(ascending), targetWatchSpecs(descending), + assert.Equal(t, targetWatchKeys(ascending), targetWatchKeys(descending), "neither declaration order nor map iteration may decide which version streams") _, ok := targetWatchStreams(ascending)[targetWatchKey{GVR: v2}] assert.True(t, ok, "with no preferred record the higher-sorting version wins, deterministically") } +// A served-version change is the specification change that restarts a collection: the same +// collection reopens at the new version, and its first session replays rather than resuming. func TestReplaceGitTargetWatches_ReusesUnchangedSetAndRestartsOnSpecChange(t *testing.T) { gitDest := types.NewResourceReference("target", "default") ctx, cancel := context.WithCancel(context.Background()) @@ -151,8 +133,8 @@ func TestReplaceGitTargetWatches_ReusesUnchangedSetAndRestartsOnSpecChange(t *te first := WatchedTypeTable{ GitDest: gitDest, Types: []WatchedType{{ - GVR: configmapsGVR, - NamespaceOps: map[string]OperationSet{"apps": {"CREATE": struct{}{}}}, + GVR: configmapsGVR, + NamespaceScopes: map[string]struct{}{"apps": {}}, }}, } require.NoError(t, manager.replaceGitTargetWatches(ctx, first)) @@ -168,8 +150,8 @@ func TestReplaceGitTargetWatches_ReusesUnchangedSetAndRestartsOnSpecChange(t *te changed := WatchedTypeTable{ GitDest: gitDest, Types: []WatchedType{{ - GVR: configmapsGVR, - NamespaceOps: map[string]OperationSet{"apps": {"UPDATE": struct{}{}}}, + GVR: schema.GroupVersionResource{Version: "v2", Resource: "configmaps"}, + NamespaceScopes: map[string]struct{}{"apps": {}}, }}, } require.NoError(t, manager.replaceGitTargetWatches(ctx, changed)) @@ -196,7 +178,7 @@ func TestRouteLiveTargetWatchEvent_ForwardsObjectEventsAsCommitter(t *testing.T) context.Background(), logr.Discard(), gitDest, - testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}, OperationSet{"CREATE": struct{}{}}), + testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}), watch.Event{Type: watch.Added, Object: obj}, ) @@ -211,10 +193,10 @@ func TestRouteLiveTargetWatchEvent_ForwardsObjectEventsAsCommitter(t *testing.T) assert.Empty(t, event.Object.GetResourceVersion(), "live events are sanitized before entering Git") } -// The source cell is stamped where it is known. Downstream it cannot be reconstructed: a -// cluster-wide and a namespaced stream deliver the same object, so the producing cell is not +// The source collection is stamped where it is known. Downstream it cannot be reconstructed: a +// cluster-wide and a namespaced stream deliver the same object, so the producing collection is not // recoverable from the event itself. -func TestRouteLiveTargetWatchEvent_StampsTheProducingCell(t *testing.T) { +func TestRouteLiveTargetWatchEvent_StampsTheProducingCollection(t *testing.T) { gitDest := types.NewResourceReference("target", "default") enqueuer := &recordingEnqueuer{} stream := reconcile.NewGitTargetEventStream(gitDest.Name, gitDest.Namespace, enqueuer, logr.Discard()) @@ -225,7 +207,6 @@ func TestRouteLiveTargetWatchEvent_StampsTheProducingCell(t *testing.T) { manager := &Manager{EventRouter: router} watching := targetWatchStream{ key: targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}, - ops: OperationSet{"CREATE": struct{}{}}, } _, err := manager.routeLiveTargetWatchEvent( @@ -238,11 +219,16 @@ func TestRouteLiveTargetWatchEvent_StampsTheProducingCell(t *testing.T) { require.NoError(t, err) require.Len(t, enqueuer.events, 1) - assert.Equal(t, types.CellKeyFor(configmapsGVR, "apps"), enqueuer.events[0].SourceCell) + assert.Equal(t, types.CollectionKeyFor(configmapsGVR, "apps"), enqueuer.events[0].SourceCollection) } -func TestRouteLiveTargetWatchEvent_RespectsOperationFilters(t *testing.T) { - gitDest := types.NewResourceReference("target", "default") +// A selected collection is observed through its whole lifecycle: there is no per-rule operation +// filter left to drop a create, an update or a deletion before it reaches the writer. What a +// deletion does to Git is the GitTarget's prune mode, decided downstream. +func TestRouteLiveTargetWatchEvent_ForwardsEveryObjectEventType(t *testing.T) { + reader, err := telemetry.InitTestExporter() + require.NoError(t, err) + gitDest := types.NewResourceReference("my-target", "my-ns") enqueuer := &recordingEnqueuer{} stream := reconcile.NewGitTargetEventStream(gitDest.Name, gitDest.Namespace, enqueuer, logr.Discard()) router := &EventRouter{ @@ -250,17 +236,24 @@ func TestRouteLiveTargetWatchEvent_RespectsOperationFilters(t *testing.T) { gitTargetStreams: map[string]*reconcile.GitTargetEventStream{gitDest.Key(): stream}, } manager := &Manager{EventRouter: router} + watching := testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}) - _, err := manager.routeLiveTargetWatchEvent( - context.Background(), - logr.Discard(), - gitDest, - testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}, OperationSet{"DELETE": struct{}{}}), - watch.Event{Type: watch.Modified, Object: configMapObject("13")}, - ) + for _, ev := range []watch.Event{ + {Type: watch.Added, Object: configMapObject("12")}, + {Type: watch.Modified, Object: terminatingConfigMapObject("13")}, + {Type: watch.Deleted, Object: configMapObject("14")}, + } { + _, err := manager.routeLiveTargetWatchEvent(context.Background(), logr.Discard(), gitDest, watching, ev) + require.NoError(t, err) + } - require.NoError(t, err) - assert.Empty(t, enqueuer.events) + require.Len(t, enqueuer.events, 3, "every object event reaches the writer") + assert.Equal(t, "CREATE", enqueuer.events[0].Operation) + assert.Equal(t, "DELETE", enqueuer.events[1].Operation) + assert.Equal(t, "DELETE", enqueuer.events[2].Operation) + routed, ok := telemetry.CollectInt64Sum(reader, watchEventsMetric, ingestMatch(watchOutcomeRouted)) + require.True(t, ok) + assert.Equal(t, int64(3), routed, "each delivered event is counted once, as routed") } func TestRouteLiveTargetWatchEvent_AttributesAuthorFromResolver(t *testing.T) { @@ -288,7 +281,7 @@ func TestRouteLiveTargetWatchEvent_AttributesAuthorFromResolver(t *testing.T) { context.Background(), logr.Discard(), gitDest, - testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}, OperationSet{"CREATE": struct{}{}}), + testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}), watch.Event{Type: watch.Added, Object: configMapObject("12")}, ) @@ -316,7 +309,7 @@ func TestRouteLiveTargetWatchEvent_DeletionTimestampRendersAsDelete(t *testing.T context.Background(), logr.Discard(), gitDest, - testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}, OperationSet{"DELETE": struct{}{}}), + testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}), watch.Event{Type: watch.Modified, Object: terminatingConfigMapObject("20")}, ) @@ -344,7 +337,7 @@ func TestRouteLiveTargetWatchEvent_ModifiedWithoutDeletionTimestampRendersAsUpda context.Background(), logr.Discard(), gitDest, - testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}, OperationSet{"UPDATE": struct{}{}}), + testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}), watch.Event{Type: watch.Modified, Object: configMapObject("21")}, ) @@ -368,12 +361,11 @@ func TestRouteLiveTargetWatchEvent_TerminatingThenDeletedAreIdenticalRemovals(t } manager := &Manager{EventRouter: router} key := targetWatchKey{GVR: configmapsGVR, Namespace: "apps"} - ops := OperationSet{"DELETE": struct{}{}} - _, err := manager.routeLiveTargetWatchEvent(context.Background(), logr.Discard(), gitDest, testStream(key, ops), + _, err := manager.routeLiveTargetWatchEvent(context.Background(), logr.Discard(), gitDest, testStream(key), watch.Event{Type: watch.Modified, Object: terminatingConfigMapObject("20")}) require.NoError(t, err) - _, err = manager.routeLiveTargetWatchEvent(context.Background(), logr.Discard(), gitDest, testStream(key, ops), + _, err = manager.routeLiveTargetWatchEvent(context.Background(), logr.Discard(), gitDest, testStream(key), watch.Event{Type: watch.Deleted, Object: configMapObject("22")}) require.NoError(t, err) @@ -394,7 +386,7 @@ func TestHandleTargetWatchSessionEvent_CompletesReplayWithoutRouter(t *testing.T context.Background(), logr.Discard(), gitDest, - testStream(key, nil), + testStream(key), watch.Event{Type: watch.Added, Object: configMapObject("10")}, true, &replay, @@ -410,7 +402,7 @@ func TestHandleTargetWatchSessionEvent_CompletesReplayWithoutRouter(t *testing.T context.Background(), logr.Discard(), gitDest, - testStream(key, nil), + testStream(key), watch.Event{Type: watch.Bookmark, Object: bookmark}, true, &replay, @@ -440,7 +432,7 @@ func TestTargetWatchReplayAndStream_ReturnsWhenContextCancels(t *testing.T) { ctx, logr.Discard(), types.NewResourceReference("target", "default"), - testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}, nil), + testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}), false, ) }() @@ -493,7 +485,7 @@ func TestTargetWatchReplayAndStream_FallsBackWhenReplayWatchIsForbidden(t *testi ctx, logr.Discard(), gitDest, - testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}, nil), + testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}), true, ) }() @@ -553,7 +545,7 @@ func TestTargetWatchReplayAndStream_ResumesFromStoredCursor(t *testing.T) { ctx, logr.Discard(), gitDest, - testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}, nil), + testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}), true, ) }() @@ -599,24 +591,13 @@ func TestOpenTargetWatch_UsesConfiguredHook(t *testing.T) { } func TestTargetWatchOperationHelpers(t *testing.T) { - specs := map[targetWatchKey]string{ - {GVR: configmapsGVR, Namespace: "b"}: "[CREATE]", - {GVR: configmapsGVR, Namespace: "a"}: "[UPDATE]", - } - keys := sortedTargetWatchSpecKeys(specs) + keys := sortedTargetWatchKeys(map[targetWatchKey]struct{}{ + {GVR: configmapsGVR, Namespace: "b"}: {}, + {GVR: configmapsGVR, Namespace: "a"}: {}, + }) require.Len(t, keys, 2) assert.Equal(t, "a", keys[0].Namespace) - table := WatchedTypeTable{Types: []WatchedType{{ - GVR: configmapsGVR, - NamespaceOps: map[string]OperationSet{"": {"CREATE": struct{}{}}}, - }}} - streams := targetWatchStreams(table) - require.Len(t, streams, 1) - assert.True(t, streams[targetWatchKey{GVR: configmapsGVR}].Match("CREATE")) - assert.False(t, streams[targetWatchKey{GVR: configmapsGVR}].Match("UPDATE")) - assert.True(t, OperationSet(nil).Match("DELETE")) - assert.True(t, OperationSet{"*": struct{}{}}.Match("UPDATE")) assert.Equal(t, "DELETE", operationForWatchEvent(watch.Deleted)) assert.Empty(t, operationForWatchEvent(watch.Error)) @@ -637,7 +618,7 @@ func TestFoldTargetReplayEvent_AccumulatesUntilInitialEventsBookmark(t *testing. done, rv, err := manager.foldTargetReplayEvent( logr.Discard(), gitDest, - testStream(key, nil), + testStream(key), watch.Event{Type: watch.Added, Object: configMapObject("10")}, &desired, ) @@ -652,7 +633,7 @@ func TestFoldTargetReplayEvent_AccumulatesUntilInitialEventsBookmark(t *testing. done, rv, err = manager.foldTargetReplayEvent( logr.Discard(), gitDest, - testStream(key, nil), + testStream(key), watch.Event{Type: watch.Bookmark, Object: bookmark}, &desired, ) @@ -669,9 +650,8 @@ func TestForgetGitTargetWatches_CancelsAndRemovesSet(t *testing.T) { childCtx, childCancel := context.WithCancel(ctx) watchKey := targetWatchKey{GVR: configmapsGVR, Namespace: "apps"} manager := &Manager{} - manager.targetWatchSet(gitDest).streams[watchKey.Cell()] = &runningTargetWatch{ - key: watchKey, - spec: "[*]", + manager.targetWatchSet(gitDest).streams[watchKey.Collection()] = &runningTargetWatch{ + key: watchKey, cancel: func() { childCancel() close(cancelled) @@ -720,7 +700,7 @@ func TestTargetWatchReplayAndStream_ExpiredCursorFallsBackToFreshReplay(t *testi go func() { done <- manager.targetWatchReplayAndStream( ctx, logr.Discard(), gitDest, - testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}, nil), true, + testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}), true, ) }() @@ -949,7 +929,7 @@ func TestFollowFactsForWatch_IsANoOpWithoutAttribution(t *testing.T) { } // planTestManager is a manager whose target watches open fake watches, recording each one so a -// test can tell which cells were started and which were left alone. +// test can tell which collections were started and which were left alone. func planTestManager(t *testing.T, gitDest types.ResourceReference) (*Manager, chan openedWatch) { t.Helper() opened := make(chan openedWatch, 8) @@ -971,15 +951,15 @@ func planTestManager(t *testing.T, gitDest types.ResourceReference) (*Manager, c return manager, opened } -// planTable is a one-type table watching the given namespaces for CREATE. +// planTable is a one-type table watching the given namespaces. func planTable(gitDest types.ResourceReference, namespaces ...string) WatchedTypeTable { - ops := map[string]OperationSet{} + scopes := map[string]struct{}{} for _, ns := range namespaces { - ops[ns] = OperationSet{"CREATE": struct{}{}} + scopes[ns] = struct{}{} } return WatchedTypeTable{ GitDest: gitDest, - Types: []WatchedType{{GVR: configmapsGVR, NamespaceOps: ops}}, + Types: []WatchedType{{GVR: configmapsGVR, NamespaceScopes: scopes}}, } } @@ -996,10 +976,10 @@ func assertStopped(t *testing.T, w openedWatch) { "expected the stream for namespace %q to be cancelled", w.namespace) } -// The performance goal of the whole refactor: adding a rule starts ONE cell, and every cell +// The performance goal of the whole refactor: adding a rule starts ONE collection, and every collection // already running keeps running. Replacing the set wholesale replayed all of them into a queue // shared with every other tenant on the branch. -func TestReplaceGitTargetWatches_AddingARuleStartsOnlyTheNewCell(t *testing.T) { +func TestReplaceGitTargetWatches_AddingARuleStartsOnlyTheNewCollection(t *testing.T) { gitDest := types.NewResourceReference("target", "default") ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -1012,14 +992,14 @@ func TestReplaceGitTargetWatches_AddingARuleStartsOnlyTheNewCell(t *testing.T) { require.NoError(t, manager.replaceGitTargetWatches(ctx, planTable(gitDest, "apps", "ops"))) started := receiveOpenedWatch(t, opened) - assert.Equal(t, "ops", started.namespace, "only the new cell is started") + assert.Equal(t, "ops", started.namespace, "only the new collection is started") assertNoOpenedWatch(t, opened) assertStillRunning(t, apps) } -// Removing one of several rules cancels that cell alone. It never touches files: the mirror is +// Removing one of several rules cancels that collection alone. It never touches files: the mirror is // converged afterwards by a Git-side sweep. -func TestReplaceGitTargetWatches_RemovingARuleStopsOnlyThatCell(t *testing.T) { +func TestReplaceGitTargetWatches_RemovingARuleStopsOnlyThatCollection(t *testing.T) { gitDest := types.NewResourceReference("target", "default") ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -1035,13 +1015,13 @@ func TestReplaceGitTargetWatches_RemovingARuleStopsOnlyThatCell(t *testing.T) { assertStopped(t, byNamespace["ops"]) assertStillRunning(t, byNamespace["apps"]) assertNoOpenedWatch(t, opened) - _, stillTracked := manager.targetWatches[gitDest.Key()].streams[types.CellKeyFor(configmapsGVR, "ops")] - assert.False(t, stillTracked, "a stopped cell's key is dropped") + _, stillTracked := manager.targetWatches[gitDest.Key()].streams[types.CollectionKeyFor(configmapsGVR, "ops")] + assert.False(t, stillTracked, "a stopped collection's key is dropped") } -// An operation-filter change invalidates the running stream, so that cell restarts — and only -// that cell. A diff comparing keys alone would have called it a keep. -func TestReplaceGitTargetWatches_AnOperationEditRestartsThatCellAlone(t *testing.T) { +// A served-version change invalidates the running stream, so that collection restarts — and only +// that collection. A diff comparing collection keys alone would have called it a keep. +func TestReplaceGitTargetWatches_AVersionEditRestartsThatCollectionAlone(t *testing.T) { gitDest := types.NewResourceReference("target", "default") ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -1053,27 +1033,27 @@ func TestReplaceGitTargetWatches_AnOperationEditRestartsThatCellAlone(t *testing edited := WatchedTypeTable{ GitDest: gitDest, - Types: []WatchedType{{ - GVR: configmapsGVR, - NamespaceOps: map[string]OperationSet{ - "apps": {"UPDATE": struct{}{}}, - "ops": {"CREATE": struct{}{}}, + Types: []WatchedType{ + {GVR: configmapsGVR, NamespaceScopes: map[string]struct{}{"ops": {}}}, + { + GVR: schema.GroupVersionResource{Version: "v2", Resource: "configmaps"}, + NamespaceScopes: map[string]struct{}{"apps": {}}, }, - }}, + }, } require.NoError(t, manager.replaceGitTargetWatches(ctx, edited)) restarted := receiveOpenedWatch(t, opened) assert.Equal(t, "apps", restarted.namespace) - assert.True(t, *restarted.opts.SendInitialEvents, "a restarted cell replays under its fresh revision") + assert.True(t, *restarted.opts.SendInitialEvents, "a restarted collection replays under its fresh revision") assertNoOpenedWatch(t, opened) assertStopped(t, byNamespace["apps"]) assertStillRunning(t, byNamespace["ops"]) } -// Readiness is per cell, so a cell that is merely KEPT holds the result its own replay produced. +// Readiness is per collection, so a collection that is merely KEPT holds the result its own replay produced. // Resetting it would report a target as replaying because an unrelated rule was added. -func TestReplaceGitTargetWatches_KeptCellKeepsItsReadinessResult(t *testing.T) { +func TestReplaceGitTargetWatches_KeptCollectionKeepsItsReadinessResult(t *testing.T) { gitDest := types.NewResourceReference("target", "default") ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -1091,15 +1071,15 @@ func TestReplaceGitTargetWatches_KeptCellKeepsItsReadinessResult(t *testing.T) { receiveOpenedWatch(t, opened) states := manager.watchPlane().streams[gitDest.Key()] - apps := states[types.CellKeyFor(configmapsGVR, "apps")] - ops := states[types.CellKeyFor(configmapsGVR, "ops")] - assert.Equal(t, StreamStateStreaming, apps.state, "a kept cell keeps its prior result") - assert.Equal(t, StreamStateReplaying, ops.state, "a started cell is pending its first replay") + apps := states[types.CollectionKeyFor(configmapsGVR, "apps")] + ops := states[types.CollectionKeyFor(configmapsGVR, "ops")] + assert.Equal(t, StreamStateStreaming, apps.state, "a kept collection keeps its prior result") + assert.Equal(t, StreamStateReplaying, ops.state, "a started collection is pending its first replay") } -// A forced recheck is a recovery, not a fifth outcome: every cell restarts, including one that +// A forced recheck is a recovery, not a fifth outcome: every collection restarts, including one that // did not change, which is what gives a widened prune policy a snapshot to sweep against. -func TestReplaceGitTargetWatches_ForceRestartsEveryCell(t *testing.T) { +func TestReplaceGitTargetWatches_ForceRestartsEveryCollection(t *testing.T) { gitDest := types.NewResourceReference("target", "default") ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -1137,7 +1117,7 @@ func TestRouteLiveTargetWatchEvent_ACancelledStreamStopsEnqueuing(t *testing.T) ctx, logr.Discard(), gitDest, - testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}, OperationSet{"CREATE": struct{}{}}), + testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}), watch.Event{Type: watch.Added, Object: configMapObject("12")}, ) @@ -1173,7 +1153,7 @@ func TestRouteLiveTargetWatchEvent_ClassifiesExpiredCursorAtTheBoundary(t *testi context.Background(), logr.Discard(), types.NewResourceReference("target", "default"), - testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}, OperationSet{"CREATE": struct{}{}}), + testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}), watch.Event{Type: watch.Error, Object: tc.status}, ) @@ -1206,7 +1186,7 @@ func TestRouteLiveTargetWatchEvent_CountsNonExpiredErrorFrames(t *testing.T) { context.Background(), logr.Discard(), types.NewResourceReference("target", "default"), - testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}, OperationSet{"CREATE": struct{}{}}), + testStream(targetWatchKey{GVR: configmapsGVR, Namespace: "apps"}), watch.Event{Type: watch.Error, Object: &metav1.Status{ Reason: metav1.StatusReasonInternalError, Message: "boom", }}, diff --git a/internal/watch/terminating_snapshot_test.go b/internal/watch/terminating_snapshot_test.go index 1786fb2e2..5b201e8a8 100644 --- a/internal/watch/terminating_snapshot_test.go +++ b/internal/watch/terminating_snapshot_test.go @@ -45,7 +45,7 @@ func TestFoldTargetReplayEvent_SkipsTerminatingObjects(t *testing.T) { done, rv, err := manager.foldTargetReplayEvent( logr.Discard(), types.NewResourceReference("target", "default"), - testStream(key, nil), + testStream(key), watch.Event{Type: eventType, Object: terminatingConfigMapObject("7")}, &desired, ) diff --git a/internal/watch/watch_event_metrics.go b/internal/watch/watch_event_metrics.go index 17810ccca..549fe6187 100644 --- a/internal/watch/watch_event_metrics.go +++ b/internal/watch/watch_event_metrics.go @@ -16,24 +16,23 @@ import ( ) // The ingest stage's instruments. Until these existed the first stage of the pipeline emitted -// nothing at all, so "nothing is happening", "the rules filtered everything" and "delivery is -// failing" were indistinguishable from outside — and the funnel could not be drawn at all. +// nothing at all, so "nothing is happening", "nothing changed" and "delivery is failing" were +// indistinguishable from outside — and the funnel could not be drawn at all. // // routeLiveTargetWatchEvent is the single honest boundary: one switch carrying every terminal // branch an event can take. Counting there rather than at each branch's site is what makes the // census exhaustive by construction. // Ingest outcomes. See telemetry.WatchEventsTotal for what each class means; the short version is -// that only routeFailed is loss, and unchanged/operationFiltered/bookmark are the pipeline working. +// that only routeFailed is loss, and unchanged/bookmark are the pipeline working. const ( - watchOutcomeRouted = "routed" - watchOutcomeUnchanged = "unchanged" - watchOutcomeOperationFiltered = "operation_filtered" - watchOutcomeNotObject = "not_object" - watchOutcomeBookmark = "bookmark" - watchOutcomeRouteFailed = "route_failed" - watchOutcomeShutdown = "shutdown" - watchOutcomeStreamError = "stream_error" + watchOutcomeRouted = "routed" + watchOutcomeUnchanged = "unchanged" + watchOutcomeNotObject = "not_object" + watchOutcomeBookmark = "bookmark" + watchOutcomeRouteFailed = "route_failed" + watchOutcomeShutdown = "shutdown" + watchOutcomeStreamError = "stream_error" ) // Watch session end reasons. @@ -60,7 +59,7 @@ func gvrAttrs(gvr schema.GroupVersionResource) []attribute.KeyValue { } // groupResourceAttrs is the {group, resource} pair, for the instruments whose unit of work is a -// CELL rather than a served version. CellKey carries no version, so a recovery counted from the +// COLLECTION rather than a served version. CollectionKey carries no version, so a recovery counted from the // per-type reconcile path genuinely does not know one; an empty `version` on that arm beside a // populated one on the cursor-resume arm would be worse than no label at all. func groupResourceAttrs(group, resource string) []attribute.KeyValue { diff --git a/internal/watch/watch_event_metrics_test.go b/internal/watch/watch_event_metrics_test.go index 4acaee160..150d95d44 100644 --- a/internal/watch/watch_event_metrics_test.go +++ b/internal/watch/watch_event_metrics_test.go @@ -47,23 +47,22 @@ func TestRecordWatchEvent_CountsPerTargetTypeAndOutcome(t *testing.T) { assert.Equal(t, int64(1), unchanged) } -// A filtered event and a lost one must be different series, because they mean opposite things: one -// is the rules working, the other is an observed change that never reached Git. Folding them +// An unchanged event and a lost one must be different series, because they mean opposite things: +// one is the pipeline working, the other is an observed change that never reached Git. Folding them // together is exactly what made "nothing is happening" and "delivery is failing" look alike. -func TestRecordWatchEvent_LossIsDistinctFromFiltering(t *testing.T) { +func TestRecordWatchEvent_LossIsDistinctFromUnchanged(t *testing.T) { reader, err := telemetry.InitTestExporter() require.NoError(t, err) gitDest := types.NewResourceReference("my-target", "my-ns") gvr := schema.GroupVersionResource{Version: "v1", Resource: "configmaps"} - recordWatchEvent(t.Context(), gitDest, gvr, watchOutcomeOperationFiltered) + recordWatchEvent(t.Context(), gitDest, gvr, watchOutcomeUnchanged) recordWatchEvent(t.Context(), gitDest, gvr, watchOutcomeRouteFailed) - filtered, ok := telemetry.CollectInt64Sum(reader, watchEventsMetric, - ingestMatch(watchOutcomeOperationFiltered)) + unchanged, ok := telemetry.CollectInt64Sum(reader, watchEventsMetric, ingestMatch(watchOutcomeUnchanged)) require.True(t, ok) - assert.Equal(t, int64(1), filtered) + assert.Equal(t, int64(1), unchanged) lost, ok := telemetry.CollectInt64Sum(reader, watchEventsMetric, ingestMatch(watchOutcomeRouteFailed)) require.True(t, ok) @@ -102,7 +101,7 @@ func TestSessionEndReason_ACleanCloseIsNotAnError(t *testing.T) { "teardown still outranks the close it causes") } -// The recovery counter carries group/resource and no version: a recovery covers a CELL, and the +// The recovery counter carries group/resource and no version: a recovery covers a COLLECTION, and the // per-type reconcile path does not know a served version. An empty version label on one arm beside // a populated one on another would be worse than no label at all. func TestRecordWatchRecovery_CarriesNoVersionLabel(t *testing.T) { diff --git a/internal/watch/watch_plane_state.go b/internal/watch/watch_plane_state.go index b6e38ae09..1fa03b8ff 100644 --- a/internal/watch/watch_plane_state.go +++ b/internal/watch/watch_plane_state.go @@ -20,14 +20,14 @@ import ( // snapshot rather than a critical section, and targetWatchesMu / targetRetentionMu guarded maps // whose only writers are now reports. type watchPlaneState struct { - // streams is the readiness surface, keyed by GitTarget and CELL — not by the served version + // streams is the readiness surface, keyed by GitTarget and COLLECTION — not by the served version // the stream runs at. See markTargetStreamState for why the version is absent. - streams map[string]map[types.CellKey]targetStreamStatus + streams map[string]map[types.CollectionKey]targetStreamStatus // acceptance is the target-side structure-gate projection, published as GitPathAccepted. acceptance map[string]gitPathAcceptance // fidelity is the projected state of the shared worker gate, published as RenderMatchesLive. fidelity map[string]git.RenderFidelityStatus - // retention is each GitTarget's per-cell retained-document counts, epoch-keyed so a cell that + // retention is each GitTarget's per-collection retained-document counts, epoch-keyed so a collection that // leaves the watch plan takes its count with it. retention map[string]targetRetentionState // uids maps a GitTarget key to the object UID captured at declare. The data plane keys resume @@ -94,7 +94,7 @@ func (s DeclareStatus) Settled() bool { func newWatchPlaneState() *watchPlaneState { return &watchPlaneState{ - streams: map[string]map[types.CellKey]targetStreamStatus{}, + streams: map[string]map[types.CollectionKey]targetStreamStatus{}, acceptance: map[string]gitPathAcceptance{}, fidelity: map[string]git.RenderFidelityStatus{}, retention: map[string]targetRetentionState{}, @@ -108,11 +108,11 @@ func newWatchPlaneState() *watchPlaneState { } // clone copies the state deeply enough that the published predecessor stays immutable: the -// nested per-cell maps are copied too, because a reader holding the old snapshot must not see a -// cell appear inside it. +// nested per-collection maps are copied too, because a reader holding the old snapshot must not see a +// collection appear inside it. func (s *watchPlaneState) clone() *watchPlaneState { out := &watchPlaneState{ - streams: make(map[string]map[types.CellKey]targetStreamStatus, len(s.streams)), + streams: make(map[string]map[types.CollectionKey]targetStreamStatus, len(s.streams)), acceptance: copyMap(s.acceptance), fidelity: copyMap(s.fidelity), retention: make(map[string]targetRetentionState, len(s.retention)), @@ -123,8 +123,8 @@ func (s *watchPlaneState) clone() *watchPlaneState { passes: copyMap(s.passes), layouts: copyMap(s.layouts), } - for key, cells := range s.streams { - out.streams[key] = copyMap(cells) + for key, collections := range s.streams { + out.streams[key] = copyMap(collections) } for key, state := range s.retention { state.scopes = copyMap(state.scopes) diff --git a/internal/watch/watched_type_resolver.go b/internal/watch/watched_type_resolver.go index 2fadde26a..4629c6363 100644 --- a/internal/watch/watched_type_resolver.go +++ b/internal/watch/watched_type_resolver.go @@ -137,10 +137,9 @@ func (m *Manager) watchTypeSamples() []telemetry.GaugeSample { const statesPerTarget = 3 samples := make([]telemetry.GaugeSample, 0, len(tables)*statesPerTarget) for _, table := range tables { - specs := targetWatchSpecs(table) summary := m.streamSummaryForExpectedKeys( table.GitDest, - cellsForWatchKeys(sortedTargetWatchSpecKeys(specs)), + collectionsForWatchKeys(targetWatchKeys(table)), streamDisplayNamesForTable(table), ) // A fixed order, not a map range: the samples are what a scrape reads, and an exporter's @@ -334,14 +333,11 @@ func (m *Manager) collectWatchRuleSelections( // // A WILDCARD item emits the empty key deliberately: "*" is one cluster-wide list // and watch, which for a namespaced GVR is the all-namespaces collection. That - // cell is a PEER of any named-namespace cell on the same type, never a - // replacement, because each rule carries its own operations filter — collapsing - // the two once widened a named rule's stream and discarded that filter (CellKey in - // internal/types/cell.go). An omitted item still resolves to a concrete name. + // collection is a PEER of any named-namespace collection on the same type, never a + // replacement — collapsing the two once widened a named rule's stream (CollectionKey + // in internal/types/collection.go). An omitted item still resolves to a concrete name. for _, namespace := range rr.SourceNamespaces { - ts.selections = append(ts.selections, watchSelection{ - record: rec, namespace: namespace, ops: rr.Operations, - }) + ts.selections = append(ts.selections, watchSelection{record: rec, namespace: namespace}) } } } @@ -367,9 +363,7 @@ func (m *Manager) collectClusterWatchRuleSelections( matched := matchFollowableRecords( records, rr.APIGroups, rr.APIVersions, rr.Resources, configv1alpha3.ResourceScopeCluster) for _, rec := range matched { - ts.selections = append(ts.selections, watchSelection{ - record: rec, namespace: "", ops: rr.Operations, - }) + ts.selections = append(ts.selections, watchSelection{record: rec, namespace: ""}) } } } @@ -532,7 +526,7 @@ func (m *Manager) rulesFingerprint() uint64 { // watches. Each item's src= component MUST be that item's RESOLVED source-namespace SET, not the // WatchRule object's own namespace and not the requested value. // -// It is now derivable from the rule spec alone: a wildcard resolves to the one cluster-wide cell +// It is now derivable from the rule spec alone: a wildcard resolves to the one cluster-wide collection // rather than to a set that depended on a GitTarget policy and another cluster's Namespace labels. // It stays keyed on the RESOLVED set anyway, because the two still differ for a wildcard — "*" // resolves to the empty namespace — and because a fingerprint that describes what is actually @@ -544,10 +538,9 @@ func watchRuleFingerprint(rule rulestore.CompiledRule) string { rule.GitTargetNamespace, rule.GitTargetRef, watchPlanDest(rule.GitProviderNamespace, rule.GitProviderRef, rule.Branch, rule.Path)) for _, rr := range rule.ResourceRules { - fmt.Fprintf(&b, "|rr[g=%s;v=%s;r=%s;op=%s;src=%s]", + fmt.Fprintf(&b, "|rr[g=%s;v=%s;r=%s;src=%s]", strings.Join(rr.APIGroups, ","), strings.Join(rr.APIVersions, ","), - strings.Join(rr.Resources, ","), operationsString(rr.Operations), - strings.Join(rr.SourceNamespaces, ",")) + strings.Join(rr.Resources, ","), strings.Join(rr.SourceNamespaces, ",")) } return b.String() } @@ -561,20 +554,9 @@ func clusterWatchRuleFingerprint(rule rulestore.CompiledClusterRule) string { rule.GitTargetNamespace, rule.GitTargetRef, watchPlanDest(rule.GitProviderNamespace, rule.GitProviderRef, rule.Branch, rule.Path)) for _, rr := range rule.Rules { - fmt.Fprintf(&b, "|rr[g=%s;v=%s;r=%s;op=%s]", + fmt.Fprintf(&b, "|rr[g=%s;v=%s;r=%s]", strings.Join(rr.APIGroups, ","), strings.Join(rr.APIVersions, ","), - strings.Join(rr.Resources, ","), operationsString(rr.Operations)) + strings.Join(rr.Resources, ",")) } return b.String() } - -func operationsString(ops []configv1alpha3.OperationType) string { - if len(ops) == 0 { - return "" - } - out := make([]string, len(ops)) - for i, op := range ops { - out[i] = string(op) - } - return strings.Join(out, ",") -} diff --git a/internal/watch/watched_type_table.go b/internal/watch/watched_type_table.go index ad3078e03..8b1b24819 100644 --- a/internal/watch/watched_type_table.go +++ b/internal/watch/watched_type_table.go @@ -12,41 +12,6 @@ import ( "github.com/ConfigButler/gitops-reverser/internal/typeset" ) -// OperationSet is the set of operation filters recorded for a watched type in one -// namespace. The sentinel "*" means all operations and subsumes the rest, exactly -// as the effective-plan hash encodes operations today. -type OperationSet map[string]struct{} - -// add folds a rule's operation slice into the set, normalising an empty slice and -// the explicit OperationAll to the "*" sentinel. -func (s OperationSet) add(ops []configv1alpha3.OperationType) { - if len(ops) == 0 { - s["*"] = struct{}{} - return - } - for _, op := range ops { - if op == configv1alpha3.OperationAll { - s["*"] = struct{}{} - continue - } - s[string(op)] = struct{}{} - } -} - -// Sorted returns the operations in a stable order, collapsing to ["*"] when the -// all-operations sentinel is present. -func (s OperationSet) Sorted() []string { - if _, all := s["*"]; all { - return []string{"*"} - } - out := make([]string, 0, len(s)) - for op := range s { - out = append(out, op) - } - sort.Strings(out) - return out -} - // WatchedType is one followable type a GitTarget watches: a (GVK, GVR, scope) triple // plus the namespace scope and served-version metadata, projected straight from the // type registry's followable set. The registry owns identity (GVK<->GVR is 1:1 there), @@ -60,11 +25,10 @@ type WatchedType struct { ServedVersion string Preferred bool - // NamespaceOps maps each watched namespace to the union of operation filters - // for this type in that namespace. The empty-string key is a cluster-wide - // stream: a cluster-scoped resource, or a namespaced resource a ClusterWatchRule - // follows across every namespace. - NamespaceOps map[string]OperationSet + // NamespaceScopes is the set of namespaces this type is watched in. The empty-string + // key is a cluster-wide collection: a cluster-scoped resource, or a namespaced resource + // a WatchRule follows across every namespace. + NamespaceScopes map[string]struct{} } // ClusterWide reports whether this type is gathered under a cluster-wide scope: true for a @@ -73,7 +37,7 @@ type WatchedType struct { // namespaced type may carry the cluster-wide scope alongside named ones, and each is streamed // in its own right. Use WatchScopes to enumerate them. func (t WatchedType) ClusterWide() bool { - _, ok := t.NamespaceOps[""] + _, ok := t.NamespaceScopes[""] return ok } @@ -84,18 +48,17 @@ func (t WatchedType) ClusterWide() bool { // // A cluster-wide selection does NOT suppress co-resident named namespaces. A WatchRule // scoped to one namespace and a ClusterWatchRule scoped cluster-wide, on the same GVR and -// the same GitTarget, stay two scopes here, each keeping its own operation filters. This -// previously collapsed to a single cluster-wide scope, which silently widened the named -// rule's stream to every namespace the credential could read and discarded its operation -// set — a gate bypass once a WatchRule declares the source namespaces it is authorized -// for. +// the same GitTarget, stay two scopes here. This previously collapsed to a single +// cluster-wide scope, which silently widened the named rule's stream to every namespace the +// credential could read — a gate bypass once a WatchRule declares the source namespaces it +// is authorized for. // // Every read site must project the same scope set, because a gather's scope becomes the // mark-and-sweep's scope: a gather wider than the stream that triggered it deletes // managed documents that were never in scope (see git.ResyncScope). func (t WatchedType) WatchScopes() []string { - out := make([]string, 0, len(t.NamespaceOps)) - for ns := range t.NamespaceOps { + out := make([]string, 0, len(t.NamespaceScopes)) + for ns := range t.NamespaceScopes { out = append(out, ns) } sort.Strings(out) @@ -117,23 +80,21 @@ type WatchedTypeTable struct { } // watchSelection is one followable registry record a rule selected for a GitTarget, -// with the namespace it was selected under ("" = cluster-wide stream) and the rule's -// operation filters. +// with the namespace it was selected under ("" = cluster-wide collection). type watchSelection struct { record typeset.TypeRecord namespace string - ops []configv1alpha3.OperationType } -// watchedTypeAccum accumulates one followable record's namespace/operation scope while -// folding a GitTarget's selections. +// watchedTypeAccum accumulates one followable record's namespace scopes while folding a +// GitTarget's selections. type watchedTypeAccum struct { - record typeset.TypeRecord - namespaceOps map[string]OperationSet + record typeset.TypeRecord + namespaceScopes map[string]struct{} } // buildWatchedTypeTable folds a GitTarget's selected followable records into its -// watched-type table, unioning each record's per-namespace operation filters. Identity +// watched-type table, deduplicating each record's namespace scopes. Identity // and followability are already settled by the registry, so this is a pure fold with no // catalog lookup and no conflict decision. func buildWatchedTypeTable( @@ -146,36 +107,31 @@ func buildWatchedTypeTable( gvr := sel.record.Identity.GVR acc := byGVR[gvr] if acc == nil { - acc = &watchedTypeAccum{record: sel.record, namespaceOps: map[string]OperationSet{}} + acc = &watchedTypeAccum{record: sel.record, namespaceScopes: map[string]struct{}{}} byGVR[gvr] = acc } - opSet := acc.namespaceOps[sel.namespace] - if opSet == nil { - opSet = OperationSet{} - acc.namespaceOps[sel.namespace] = opSet - } - opSet.add(sel.ops) + acc.namespaceScopes[sel.namespace] = struct{}{} } table := WatchedTypeTable{GitDest: gitDest, ResolvedAt: generation} for _, acc := range byGVR { - table.Types = append(table.Types, watchedTypeFromRecord(acc.record, acc.namespaceOps)) + table.Types = append(table.Types, watchedTypeFromRecord(acc.record, acc.namespaceScopes)) } sortWatchedTypes(table.Types) return table } // watchedTypeFromRecord copies a followable registry record's identity into a -// WatchedType, attaching the per-namespace operation scope the rules folded. -func watchedTypeFromRecord(rec typeset.TypeRecord, namespaceOps map[string]OperationSet) WatchedType { +// WatchedType, attaching the namespace scopes the rules folded. +func watchedTypeFromRecord(rec typeset.TypeRecord, namespaceScopes map[string]struct{}) WatchedType { return WatchedType{ - GVK: rec.Identity.GVK, - GVR: rec.Identity.GVR, - Namespaced: rec.Identity.Scope == typeset.ScopeNamespaced, - Scope: resourceScopeFor(rec.Identity.Scope), - ServedVersion: rec.Identity.GVR.Version, - Preferred: rec.Preferred, - NamespaceOps: namespaceOps, + GVK: rec.Identity.GVK, + GVR: rec.Identity.GVR, + Namespaced: rec.Identity.Scope == typeset.ScopeNamespaced, + Scope: resourceScopeFor(rec.Identity.Scope), + ServedVersion: rec.Identity.GVR.Version, + Preferred: rec.Preferred, + NamespaceScopes: namespaceScopes, } } diff --git a/internal/watch/watched_type_table_test.go b/internal/watch/watched_type_table_test.go index 73d7c668b..2207fa836 100644 --- a/internal/watch/watched_type_table_test.go +++ b/internal/watch/watched_type_table_test.go @@ -79,52 +79,24 @@ func TestBuildWatchedTypeTable_ClusterWideDoesNotCollapseNamedNamespaces(t *test assert.True(t, wt.ClusterWide(), "the cluster-wide scope is still present") assert.Equal(t, []string{"", "team-a"}, wt.WatchScopes(), "a cluster-wide selection must not swallow a co-resident named namespace") - assert.Contains(t, wt.NamespaceOps, "") - assert.Contains(t, wt.NamespaceOps, "team-a") + assert.Contains(t, wt.NamespaceScopes, "") + assert.Contains(t, wt.NamespaceScopes, "team-a") } -// Each scope keeps its own operation filters. A CREATE-only WatchRule co-resident with an -// UPDATE-only ClusterWatchRule must not have its filter replaced by the cluster-wide one. -func TestBuildWatchedTypeTable_ClusterWideDoesNotCollapseNamedOperationSets(t *testing.T) { +// Duplicate selections of one namespace fold into one scope: two rules selecting the same type in +// the same namespace are one collection, and nothing about either rule survives beyond that scope. +func TestBuildWatchedTypeTable_DuplicateSelectionsShareOneScope(t *testing.T) { cm := nsRecord("", "configmaps", "ConfigMap") selections := []watchSelection{ - {record: cm, namespace: "team-a", ops: []configv1alpha3.OperationType{configv1alpha3.OperationCreate}}, - {record: cm, namespace: "", ops: []configv1alpha3.OperationType{configv1alpha3.OperationUpdate}}, - } - - table := buildWatchedTypeTable(testGitDest(), 1, selections) - - require.Len(t, table.Types, 1) - wt := table.Types[0] - assert.Equal(t, []string{"CREATE"}, wt.NamespaceOps["team-a"].Sorted()) - assert.Equal(t, []string{"UPDATE"}, wt.NamespaceOps[""].Sorted()) -} - -func TestBuildWatchedTypeTable_OperationsUnionPerNamespace(t *testing.T) { - cm := nsRecord("", "configmaps", "ConfigMap") - selections := []watchSelection{ - {record: cm, namespace: "team-a", ops: []configv1alpha3.OperationType{configv1alpha3.OperationCreate}}, - {record: cm, namespace: "team-a", ops: []configv1alpha3.OperationType{configv1alpha3.OperationUpdate}}, - {record: cm, namespace: "team-b", ops: []configv1alpha3.OperationType{configv1alpha3.OperationAll}}, - } - - table := buildWatchedTypeTable(testGitDest(), 1, selections) - - require.Len(t, table.Types, 1) - wt := table.Types[0] - assert.Equal(t, []string{"CREATE", "UPDATE"}, wt.NamespaceOps["team-a"].Sorted()) - assert.Equal(t, []string{"*"}, wt.NamespaceOps["team-b"].Sorted()) -} - -func TestBuildWatchedTypeTable_EmptyOperationsAreAllOperations(t *testing.T) { - selections := []watchSelection{ - {record: nsRecord("", "configmaps", "ConfigMap"), namespace: "team-a"}, + {record: cm, namespace: "team-a"}, + {record: cm, namespace: "team-a"}, + {record: cm, namespace: "team-b"}, } table := buildWatchedTypeTable(testGitDest(), 1, selections) require.Len(t, table.Types, 1) - assert.Equal(t, []string{"*"}, table.Types[0].NamespaceOps["team-a"].Sorted()) + assert.Equal(t, []string{"team-a", "team-b"}, table.Types[0].WatchScopes()) } func TestBuildWatchedTypeTable_ClusterScopedType(t *testing.T) { diff --git a/test/e2e/audit_route_attribution_e2e_test.go b/test/e2e/audit_route_attribution_e2e_test.go index 3a34c26b2..d0866863c 100644 --- a/test/e2e/audit_route_attribution_e2e_test.go +++ b/test/e2e/audit_route_attribution_e2e_test.go @@ -203,8 +203,8 @@ var _ = Describe("Audit route attribution", Label("manager"), Ordered, func() { "SourceNamespaceAuthorized", "True", "SourceNamespaceAllowed", "") // Gate on THIS rule's streams, not the target's roll-up. The roll-up answers "is every // stream this target has running", which a target that was already mirroring answers True - // to before the new rule's cell has been planned at all. What this spec depends on is the - // new cell, and the rule-scoped summary is the thing that names it. + // to before the new rule's collection has been planned at all. What this spec depends on is the + // new collection, and the rule-scoped summary is the thing that names it. waitForWatchRuleStreamsRunning(overrideRule, testNs) DeferCleanup(func() { cleanupWatchRule(overrideRule, testNs) }) diff --git a/test/e2e/refusal_empty_commit_e2e_test.go b/test/e2e/refusal_empty_commit_e2e_test.go index 419cd8f0e..ff9ad1502 100644 --- a/test/e2e/refusal_empty_commit_e2e_test.go +++ b/test/e2e/refusal_empty_commit_e2e_test.go @@ -18,7 +18,7 @@ import ( // // The refusal has to be the right KIND, and choosing it is most of what this spec is about. A // folder-level refusal (a foreign file, unparseable YAML) is normally found by the per-type -// reconcile, which blocks the cell through the event router and never reaches the live-write path +// reconcile, which blocks the collection through the event router and never reaches the live-write path // the action hangs off. It is also the case where an empty commit achieves nothing: only a human // can repair a folder. // diff --git a/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml b/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml index 97607913b..973a77d00 100644 --- a/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml +++ b/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml @@ -30,7 +30,6 @@ spec: gitTargetRef: name: demo-podinfos rules: - - operations: [CREATE, UPDATE, DELETE] - apiGroups: ["kro.run"] + - apiGroups: ["kro.run"] apiVersions: ["v1alpha1"] resources: ["podinfoapps"] diff --git a/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml b/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml index 81b18d295..79cb4a9f0 100644 --- a/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml +++ b/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml @@ -30,7 +30,6 @@ spec: gitTargetRef: name: demo-coffeeconfig rules: - - operations: [CREATE, UPDATE, DELETE] - apiGroups: ["examples.configbutler.ai"] + - apiGroups: ["examples.configbutler.ai"] apiVersions: ["v1alpha1"] resources: ["coffeeconfigs"] diff --git a/test/e2e/templates/demo/clusterwatchrule-demo.tmpl b/test/e2e/templates/demo/clusterwatchrule-demo.tmpl index 3029f2766..575977ae4 100644 --- a/test/e2e/templates/demo/clusterwatchrule-demo.tmpl +++ b/test/e2e/templates/demo/clusterwatchrule-demo.tmpl @@ -7,11 +7,9 @@ spec: name: {{ .DestinationName }} namespace: {{ .Namespace }} rules: - - operations: [CREATE, UPDATE, DELETE] - apiGroups: [""] + - apiGroups: [""] apiVersions: ["v1"] resources: ["namespaces"] - - operations: [CREATE, UPDATE, DELETE] - apiGroups: ["configbutler.ai"] + - apiGroups: ["configbutler.ai"] apiVersions: ["v1alpha1"] resources: ["clusterwatchrules"] diff --git a/test/e2e/templates/demo/watchrule-all.tmpl b/test/e2e/templates/demo/watchrule-all.tmpl index 1894fcdec..f4703027c 100644 --- a/test/e2e/templates/demo/watchrule-all.tmpl +++ b/test/e2e/templates/demo/watchrule-all.tmpl @@ -7,27 +7,21 @@ spec: gitTargetRef: name: {{ .DestinationName }} rules: - - operations: [CREATE, UPDATE, DELETE] - apiGroups: [""] + - apiGroups: [""] apiVersions: ["v1"] resources: ["configmaps", "secrets", "services", "serviceaccounts"] - - operations: [CREATE, UPDATE, DELETE] - apiGroups: ["apps"] + - apiGroups: ["apps"] apiVersions: ["v1"] resources: ["deployments"] - - operations: [CREATE, UPDATE, DELETE] - apiGroups: ["rbac.authorization.k8s.io"] + - apiGroups: ["rbac.authorization.k8s.io"] apiVersions: ["v1"] resources: ["roles", "rolebindings"] - - operations: [CREATE, UPDATE, DELETE] - apiGroups: ["traefik.io"] + - apiGroups: ["traefik.io"] apiVersions: ["v1alpha1"] resources: ["ingressroutes"] - - operations: [CREATE, UPDATE, DELETE] - apiGroups: ["examples.configbutler.ai"] + - apiGroups: ["examples.configbutler.ai"] apiVersions: ["v1alpha1"] resources: ["quizsessions", "quizsubmissions"] - - operations: [CREATE, UPDATE, DELETE] - apiGroups: ["configbutler.ai"] + - apiGroups: ["configbutler.ai"] apiVersions: ["v1alpha1"] resources: ["gitproviders", "gittargets", "watchrules"] diff --git a/test/e2e/templates/manager/clusterwatchrule-crd.tmpl b/test/e2e/templates/manager/clusterwatchrule-crd.tmpl index 6514435c1..bf8099e32 100644 --- a/test/e2e/templates/manager/clusterwatchrule-crd.tmpl +++ b/test/e2e/templates/manager/clusterwatchrule-crd.tmpl @@ -7,7 +7,6 @@ spec: name: {{ .DestinationName }} namespace: {{ .Namespace }} rules: - - operations: [CREATE, UPDATE, DELETE] - apiGroups: [apiextensions.k8s.io] + - apiGroups: [apiextensions.k8s.io] apiVersions: [v1] resources: [customresourcedefinitions] diff --git a/test/e2e/templates/restart/watchrule-wildcard.tmpl b/test/e2e/templates/restart/watchrule-wildcard.tmpl index 68adee98b..f7073aff4 100644 --- a/test/e2e/templates/restart/watchrule-wildcard.tmpl +++ b/test/e2e/templates/restart/watchrule-wildcard.tmpl @@ -16,7 +16,6 @@ spec: # cluster-scope-only. The conversion is the one UPGRADING.md prescribes: a WatchRule # in the namespace whose objects are mirrored. sourceNamespace is omitted, so the rule # watches its own namespace — which is where the fixture's orders live. - - operations: [CREATE, UPDATE, DELETE] - apiGroups: ["{{ .Group }}"] + - apiGroups: ["{{ .Group }}"] apiVersions: ["*"] resources: ["icecreamorders"] From 9e1fd01212652507782b2be0850b8e138bd9540e Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 28 Sep 2026 12:10:48 +0000 Subject: [PATCH 10/10] docs: drop operation scopes from the followability spec TargetView's FollowedType carries NamespaceScopes, matching the WatchedTypeTable after rule operations were removed. Co-Authored-By: Claude Opus 5.5 --- docs/spec/type-followability.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/spec/type-followability.md b/docs/spec/type-followability.md index fe3aff713..8a7c3a647 100644 --- a/docs/spec/type-followability.md +++ b/docs/spec/type-followability.md @@ -47,7 +47,7 @@ cluster ─ scan ─▶ Observation raw per-type facts: discovery + CR ▼ ▼ ▼ mapper live set TargetView (per GitTarget) ByGVK/ByGVR Followable() folds followable records into - (no separate namespace/operation scope; never + (no separate namespace scopes; never mapper object) recomputes followability ``` @@ -58,7 +58,7 @@ cluster ─ scan ─▶ Observation raw per-type facts: discovery + CR followability verdict. It owns identity rules, the deny/sensitive policy, the live set, and the 60-second removal grace. Every consumer reads from it. - **`TargetView`** is a GitTarget's projection of *already-followable* records into - namespaces and operations. It never recomputes followability. + namespace scopes. It never recomputes followability. The mapper is not a layer — it is `TypeRegistry.ByGVK`. WatchRule expansion is not a layer — it is a function that builds a `TargetView` by asking the registry. @@ -259,8 +259,8 @@ type TargetView struct { } type FollowedType struct { - Record TypeRecord // copied from the registry, never recomputed - NamespaceOps map[string]OperationSet + Record TypeRecord // copied from the registry, never recomputed + NamespaceScopes map[string]struct{} } func BuildTargetView(