From e799dc27826ae8c7bdddda81c0b8ad6d501676d8 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Thu, 3 Sep 2026 09:40:33 +0000 Subject: [PATCH 1/9] docs: reconcile the layout and API-wave pages with what shipped in 0.43.0 Four pages still advertised themselves as proposals for work that has since merged, which is the one failure mode the status labels exist to prevent. layout/model.md said "a proposal, not a plan of record. Nothing here binds" while every field it proposed is in the CRDs: spec.suspend, status.placement and the Ambiguous rule from #326, useKustomize and serializeNamespace from #328. Its own build table already said both PRs had shipped, so only the label disagreed with the page under it. created-root-namespace.md said "being built", and kustomization_bootstrap.go cites it by path as the reason a created root carries no namespace. Its history paragraph also credited #328 with writing namespace: into every root it created; that was the cut under review, not the cut that merged. gittarget-api-wave.md counted only steps 6 and 7 as shipped. Steps 2 to 5 shipped too, minus status.lastHandledReconcileAt, which #326 deliberately replaced with an in-memory tracker for a reason worth keeping next to the field's absence. Its version-strategy section predicted a growing graveyard of refused fields; measured on main, the residue is one field, because this wave removed its fields outright and paid for that in UPGRADING.md instead. placement-visibility-and-declared-defaults.md is now half built rather than mostly unbuilt: status.layout shipped as status.placement, and the ambiguous render root with it. Also sweeps the two backlogs those pages feed. TODO.md's output-layout item becomes the four questions model.md left open; the wildcard fan-out item shrinks to the PendingSample cap, which is all that survives a redefinition that deleted the fan-out rather than optimizing it; and two items are added for work that is breaking and therefore belongs in a breaking release: removing ClusterWatchRule.spec.rules[].scope, whose "removed one release from now" has stood since 0.39.0, and the reference-shape collapse among the wave's riders, which prunes defaulted group/kind fields with no error. open-asks-priority.md gets a second sweep note: #22, #11 and #10 are gone. Co-Authored-By: Claude Opus 5 --- docs/INDEX.md | 12 ++-- docs/TODO.md | 61 ++++++++++++------- docs/UPGRADING.md | 2 +- docs/design/created-root-namespace.md | 16 +++-- docs/design/gittarget-api-wave.md | 38 +++++++++--- docs/design/open-asks-priority.md | 16 ++++- ...cement-visibility-and-declared-defaults.md | 20 ++++-- docs/layout/README.md | 9 +-- docs/layout/model.md | 11 +++- 9 files changed, 127 insertions(+), 58 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index 7930a22e..9825a629 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -111,14 +111,13 @@ decided), **design, decided** (decision made, not built), **partly built**, **bu **deferred** (parked, kept as a decision record). The label is the first thing in the page, so you never have to read a proposal to find out it already shipped. -### Open — 15 pages +### Open — 14 pages | Doc | Open question | |---|---| | [`open-asks-priority.md`](design/open-asks-priority.md) | **the work queue.** Merges three overlapping backlogs — the gitops-api consumer asks, the API-surface block left unbuilt by the status and configuration-model review, and the config-surface proposal (B1–B6) — into one ordered queue under four stated tests, and says where we deliberately do **not** do what was asked. The standing caveat narrowed once the layout model reversed: a Tier 2 entry belongs to postponed [#294](https://github.com/ConfigButler/gitops-reverser/issues/294) only if it breaks a `GitTarget` field, and everything else is independently schedulable. Makes one design call against what was asked: **delete Option C sibling inference** rather than ship an off-switch for it, because it let a human's edit to the repository change operator behavior with nothing in status recording the move. That deletion has shipped, and "what the deletion taught" records what building it found. **F9 is Tier 1**: the only item whose answer is unknown rather than whose work is unscheduled, and it gates planning the enum work | -| [`placement-visibility-and-declared-defaults.md`](design/placement-visibility-and-declared-defaults.md) | **design.** The three questions the inference deletion left, **decided and then not built**: 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). Its Question 2 is superseded outright by [`layout/model.md`](layout/model.md). What still stands: keep `canonical` as the name for the built-in path and split `declared` into `byType`/`default`; **no CRD default for `placement.default`**, on the structural argument that a defaulted default is never empty and so shadows the kustomize-root rung; `status.layout` instead, over the `MarkTargetRetention` seam that already enqueues on change; and `{kindLower}`, not a `toLower` function | -| [`created-root-namespace.md`](design/created-root-namespace.md) | **design, decided.** One question with five answers: what namespace a `kustomization.yaml` the operator CREATES should carry. Decided **B, never write one** — `spec.serializeNamespace: false` means the artifact does not encode its deployment namespace, and adding a root must not quietly change that contract; the namespace comes from the documents when the field is unset or `true`, and from the installer (Flux `targetNamespace`, Argo `destination.namespace`) when it is `false`. Records the three facts an earlier draft got wrong (a namespace-less root is ordinary, both installers supply one, and what refused it was our own fidelity gate rather than kustomize), and carries the scoped fidelity rule that follows: the namespace is ignored in the render comparison ONLY when the governing root sets none, so a root that declares `namespace: shop` still rejects a live `billing` object. Also the sibling call: under `useKustomize` a placement no `resources:` list would name is refused rather than committed unrendered | -| [`gittarget-api-wave.md`](design/gittarget-api-wave.md) | **design**, filed as [#294](https://github.com/ConfigButler/gitops-reverser/issues/294). What is left of one breaking wave on `GitTarget` after the layout model reversed and left it: B4's `commitWindow`/`commit.message` move off the connection, the source-scope deletion (the only member that makes the API smaller), and the riders. Organizing principle: **the folder is described on the GitTarget, the connection describes only the connection** — and this is where that becomes 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. Records that F9's envtest stays OUTSIDE the wave and gates it, and that staying `v1alpha3` on loud rejections is a **one-consumer countdown**, not a constant | +| [`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. What still stands and is still unbuilt is the legibility set: split `declared` into `byType`/`default` (**the one row with an external consumer — it changes a metric label value a dashboard may select on**), the canonical path as a template constant, and `{kindLower}`. 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, `meta.LocalObjectReference` for our six reference shapes, the `TooManyStreams` cap, the `default` `ClusterProvider` message — so #294 wants narrowing to those rather than closing. Also records that staying `v1alpha3` on loud rejections is a **one-consumer countdown**, and that the residue this pattern has actually left is one field: `ClusterWatchRule.spec.rules[].scope`, whose "removed one release from now" has stood since 0.39.0 | | [`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. | | [`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 | | [`watch-and-catalog-architecture.md`](design/watch-and-catalog-architecture.md) | the target three-layer watch model — **needs a human call before building** | @@ -131,7 +130,7 @@ so you never have to read a proposal to find out it already shipped. | [`sensitive-resource-diagnostics-follow-up.md`](design/sensitive-resource-diagnostics-follow-up.md) | deferred diagnostics | | [`e2e-git-server-choice.md`](design/e2e-git-server-choice.md) | stay on Gitea or move to Forgejo — the `_csrf` pin is fixable in place on both, so the migration is now a preference call, not a fix; also why we adopt no SDK either way | -### Built, and kept here anyway — 4 pages +### Built, and kept here anyway — 5 pages These have shipped. They stay in `design/` under the exception above, because Go source cites them by path as the rationale for what the code does, and `finished/` declares @@ -139,6 +138,7 @@ itself non-binding. Read them as history that the code still points at. | Doc | Open question | |---|---| +| [`created-root-namespace.md`](design/created-root-namespace.md) | **built.** One question with five answers: what namespace a `kustomization.yaml` the operator CREATES should carry. Decided **B, never write one** — `spec.serializeNamespace: false` means the artifact does not encode its deployment namespace, and adding a root must not quietly change that contract; the namespace comes from the documents when the field is unset or `true`, and from the installer (Flux `targetNamespace`, Argo `destination.namespace`) when it is `false`. Records the three facts an earlier draft got wrong (a namespace-less root is ordinary, both installers supply one, and what refused it was our own fidelity gate rather than kustomize), and carries the scoped fidelity rule that follows: the namespace is ignored in the render comparison ONLY when the governing root sets none, so a root that declares `namespace: shop` still rejects a live `billing` object. Shipped in #328, which is also why [`kustomization_bootstrap.go`](../internal/git/kustomization_bootstrap.go) cites the page by path. Also the sibling call: under `useKustomize` a placement no `resources:` list would name is refused rather than committed unrendered | | [`watch-manager-ownership.md`](design/watch-manager-ownership.md) | A rule edit used to be applied inline by the controller worker that observed it, and it re-planned EVERY GitTarget rather than the one the rule names: 1256 plan reconciles across 28 targets in one e2e run, peaking at 78 in a second, behind two network calls, on a shared worker pool. The watch manager had no owner, so eleven mutexes stood in for one. Now controllers post a trigger naming a GitTarget and return, one loop owns the plan and paces itself, and repeated triggers for one target collapse into a single pass. The debounce is framed around how the config is actually edited: a GitTarget and its rules are one piece of configuration applied together, so a per-target ROLLING SILENCE window of 2s (max wait ~10s) turns a five-object `kubectl apply` into one pass, the same mechanism `DefaultCommitWindow` already uses one layer down on the write path. That reverses an earlier revision's "never debounce the first declaration": declaring a GitTarget the instant it lands means declaring it with no rules yet, which manufactures a transient EMPTY plan on every cold start, and an empty plan is what vacuously cleared a write divergence in the fidelity gate. States the contract as "one settled configuration adjustment, not one function invocation": the window is a heuristic and never a correctness boundary (Kubernetes has no apply-complete event), a per-target DIRTY SEQUENCE means a change arriving mid-pass is never lost, and the pass reads a coherent rule-store snapshot rather than the rule that triggered it. Carries the deletion inventory, because the point is that the system got smaller: four trigger mechanisms collapsed to one (`signalCatalogRefresh` and `catalogRefreshCh` are gone), `refreshRunningTargetWatches` and its running-set filter are gone (that filter is why a target whose first declare never completed was never picked up again), and six mutexes went for stated reasons, with `RenderFidelityGate.mu` and the two event-channel locks kept and justified. The four steps shipped: the local-cluster discovery call is bounded (a real defect — the legacy non-context `ServerGroupsAndResources()` ran with no deadline at all), the owner loop carries the debounce, dirty sequence, per-target deadline and 2s/5s/10s/30s/1m backoff, the plan now carries NO lock while the projection is a published snapshot, and catalog invalidation is scoped by diffing each target's rendered plan across the re-projection. "What shipped" records where the implementation departed from the page, including the one bug only e2e caught: streams were parented to the PASS context, which a deadline cancels the moment the pass returns, so every stream died the instant its plan was applied — and it reads like health, because the plan logs `start:1`, every later pass reports `keep:1` and never restarts it, and nothing logs an error while readiness sits at `Replaying` and every WatchRule sits `Ready=False`. A stream's parent is the manager's lifetime; the pass deadline bounds the pass. A second e2e catch is a BEHAVIORAL consequence worth knowing: toggling a rule off and on inside the settle window is no longer a replay — it used to tear the stream down and re-establish it because each apply replanned synchronously, and it is now one pass over a plan that never changed. Correct (a net-zero change is no change; widening `prune.mode` remains the supported force) but a real difference in what an operator gesture does. Both specs that broke were also gating on the wrong thing: asking a GitTarget "are all your streams running" about a change to ONE rule, which a target that is already mirroring answers True to before that rule has been planned, and which a different controller publishes than the one that compiled the rule — so the rule's OWN StreamsRunning is the gate, and `waitForWatchRuleStreamsRunning` existed unused for exactly this. Departures: reports became a published snapshot rather than a second channel; ISOLATION came from taking the I/O off the loop rather than from the deadline (a pass never dials, the shared refresh runs on its own goroutine, and the deadline is the backstop it should have been — a first cut that kept two network calls on the loop had one unreachable cluster holding every healthy target, which is the same availability failure relocated); DELETION names an incarnation resolved when it is queued, because both production callers react to a NotFound and carry no UID, so a UID-less delete matched everything and could tear down the successor of a same-name recreate; and persistent failure surfaces as `WatchPlanFailing`, where pending means "no pass has ever landed", not "dirty right now". Step 3's type-to-target index did not earn its staleness. Still open: whether the settle window ever needs to be configurable | | [`docs-linting.md`](design/docs-linting.md) | how to mechanize [`style-guide.md`](style-guide.md) with markdownlint-cli2 and Vale. Both are wired into `task lint`, gated on the files [`.docs-lint-scope`](../.docs-lint-scope) lists rather than the whole tree: 102 of 174 files fail markdownlint and 148 of 174 fail Vale, so the two backlogs need different gates. Open: how the scope list grows to cover the tree, the `MD013` limit, and whether `AGENTS.md` and the chart READMEs are in scope | | [`azure-devops-multi-ack.md`](design/azure-devops-multi-ack.md) | go-git v6 was the answer: why Azure DevOps rejects our fetches, and what to do instead of PR [#292](https://github.com/ConfigButler/gitops-reverser/pull/292)'s bundled `git` binary. The capability filter fails in two independent halves: advertising `multi_ack` is a four-line change, but v5 then cannot parse the multi-ACK **response**, which only a fetch with `have` lines provokes. That is why **Flux ships ADO support on v5 with no git binary — it never fetches**, only `CloneContext`, so it never enters the path v5 cannot serve; our persistent-clone-plus-incremental-fetch design is the opposite, which makes the trim alone insufficient for us. **go-git v6 already implements `multi_ack`** (PR #1204, in every v6 tag; upstream then deleted their ADO example saying it "works out of the box"), and its churn in the packages we import runs 96 → 39 → **1** → **9** removals per alpha, so it is one settled breaking wave rather than a moving target; the migration is four known API removals over two rewritten files, `transport.AuthMethod` being the invasive one. Prices PR #292 as measured rather than argued: the image goes **217 MB → 940 MB**, of which 723 MB is a `cp -rL` that dereferences 165 hardlinks to one binary (a one-character fix), arm64 is unaffected and native, but **Trivy reports zero findings on both images** while the new one carries git 2.54.0, OpenSSH 10.3p1 and OpenSSL 3.5.7 as loose files no package database describes — so the CRITICAL gate is blind to a third of the runtime. Also catches an unflagged non-ADO regression (`Depth: 1` dropped, so every provider full-fetches) and 10% patch coverage on an untestable path. The unlock is that **canonical `git upload-pack` advertises `multi_ack`** (verified), so the Gitea already in the e2e lab plus a 400-injecting proxy is a faithful ADO simulator — no tenant needed, and the only way any option becomes CI-testable. Four options priced, and Option A (v6) is the one shipped. Carries a measured **capability matrix** over our three network calls with two diagrams, which narrows the blast radius to **one call, `repo.Fetch`**: `receive-pack` never advertises `multi_ack` (measured), so **the atomic push is out of scope for every option** — its safety rests on the same-session advertisement plus the server-side `Old`/`New` compare-and-swap in `packp.Command`, neither of which touches `upload-pack`, and we already push from a shallow store today. v6 keeps that pattern 1:1 (`Handshake` → `GetRemoteRefs`/`Push`, same `[]*packp.Command`), which is an argument *for* migrating. Records what the migration actually cost, including the four v6 behaviour changes it surfaced — two of them settings v6 reads from the environment and fails closed on, invisible to unit tests | @@ -155,7 +155,7 @@ by path from Go source. |---|---|---| | [`contextual-namespace.md`](layout/contextual-namespace.md) | **spec** | kustomize namespace inference; the supported subset | | [`new-file-placement-rules.md`](layout/new-file-placement-rules.md) | **spec** | where a new resource's file goes: declared, the folder's one kustomize root, canonical. Sibling inference is removed, and kept as history | -| [`model.md`](layout/model.md) | **design** | **reversed, and much smaller than it was.** The earlier thesis wanted `spec.placement` replaced by a `spec.layout` discriminated union; three of its five arguments were retired by [#319](https://github.com/ConfigButler/gitops-reverser/pull/319), which made registration an invariant. So the template **stays** and gains two optional booleans: **`spec.placement.useKustomize`** (create and maintain the folder's root; registering into a root that already exists is an invariant, not a setting) and **`spec.serializeNamespace`** (a `*bool`, because unset must keep meaning "infer" — no plain default preserves today's behavior), which sits one level up because it governs the bytes of every write and the identity a managed document is found by, not just new files. Carries four kustomize facts **measured** against v5.8.1, three of which contradict the earlier model; the `status.placement` stanza and the post-scan pass; and the build order. The headline is what it deletes — `spec.layout`, `kind`, `scope`, `kustomize.create`, the `LayoutProfile` question, the migration, **the post-scan supplier guard** (the supplier of a namespace-free folder lives in another cluster and may not even be single, so the check fires on the correct configuration), and **the dry-run framing of `spec.suspend`** (a scratch branch is a better preview and needs nothing built, so `suspend` is a panic knob and `status.placement` explains writes rather than previewing them) — so the largest breaking change in the queue stops being breaking at all | +| [`model.md`](layout/model.md) | **built** | **reversed, much smaller than it was, and now shipped** in #326 and #328. The earlier thesis wanted `spec.placement` replaced by a `spec.layout` discriminated union; three of its five arguments were retired by [#319](https://github.com/ConfigButler/gitops-reverser/pull/319), which made registration an invariant. So the template **stays** and gains two optional booleans: **`spec.placement.useKustomize`** (create and maintain the folder's root; registering into a root that already exists is an invariant, not a setting) and **`spec.serializeNamespace`** (a `*bool`, because unset must keep meaning "infer" — no plain default preserves today's behavior), which sits one level up because it governs the bytes of every write and the identity a managed document is found by, not just new files. Carries four kustomize facts **measured** against v5.8.1, three of which contradict the earlier model; the `status.placement` stanza and the post-scan pass; and the build order. The headline is what it deletes — `spec.layout`, `kind`, `scope`, `kustomize.create`, the `LayoutProfile` question, the migration, **the post-scan supplier guard** (the supplier of a namespace-free folder lives in another cluster and may not even be single, so the check fires on the correct configuration), and **the dry-run framing of `spec.suspend`** (a scratch branch is a better preview and needs nothing built, so `suspend` is a panic knob and `status.placement` explains writes rather than previewing them) — so the largest breaking change in the queue stops being breaking at all | The worked examples that used to sit in this folder are now the **layout corpus** at [`test/fixtures/layout-corpus/`](../test/fixtures/layout-corpus/README.md), because a test executes diff --git a/docs/TODO.md b/docs/TODO.md index 14b899fe..a53c79a4 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -64,30 +64,45 @@ This file is meant to track the smaller current backlog, not historical notes. Secrets and CozyStack `tenantsecrets`; resources with sensitive fields under shapes such as `spec.credentials` need an explicit field policy or full-file encryption decision. -- [ ] Revisit output layout. **Now designed and postponed, not open-ended, and the answer has - reversed since [#293](https://github.com/ConfigButler/gitops-reverser/issues/293) was filed.** The - path template **stays**; what it could not express becomes two optional booleans, - `spec.placement.useKustomize` and `spec.serializeNamespace`. [layout/model.md](layout/model.md) - carries the reversal, the fields and the order. The placement work - is no longer breaking, so it no longer needs - [#294](https://github.com/ConfigButler/gitops-reverser/issues/294); the issues still describe the - discriminated union and want updating. - Deliberately **not** in 0.41.0, which already carries the new attribution model and the - sibling-inference removal. Multiple resources per file is bundle support, which exists for - match-first today and is a separate question from where a *new* file goes. +- [ ] Decide the four layout questions [`layout/model.md`](layout/model.md#open-questions) left open. + The model itself is **built** — the path template stayed and gained `spec.placement.useKustomize` + and `spec.serializeNamespace`, shipped in + [#326](https://github.com/ConfigButler/gitops-reverser/pull/326) and + [#328](https://github.com/ConfigButler/gitops-reverser/pull/328) with the corpus that executes it. + None of the four blocks anything: nested roots per directory (re-open trigger written down), + refusing rather than reporting when a governing root disappears, a CRD default for + `placement.default` (legibility only, since #319 removed the correctness objection), and a + namespace-local `GitProvider` so three targets in three namespaces need not copy one credential. + +- [ ] Remove `ClusterWatchRule.spec.rules[].scope`. It is the **whole** deprecation graveyard in the + CRDs: everything else this project has removed was removed outright, with + [`UPGRADING.md`](UPGRADING.md) as the migration. The field is retained purely so that re-applying + a manifest still saying `Namespaced` fails, and its own doc comment says "Removed one release from + now" — written for 0.39.0, and it has now stood through 0.40, 0.41 and 0.42. Delete the field, the + `DeclaresNamespacedScope` compile-path refusal that backs it, and the `Deprecated:` line in the + CRD. Breaking, so it wants a release that is breaking anyway; the reasoning is in + [`design/gittarget-api-wave.md`](design/gittarget-api-wave.md#version-strategy-stay-v1alpha3). + +- [ ] Build the riders that are all that is left of the `GitTarget` API wave + ([`design/gittarget-api-wave.md`](design/gittarget-api-wave.md), filed as + [#294](https://github.com/ConfigButler/gitops-reverser/issues/294), which wants narrowing to + them): an asserted `CommitRequest.spec.author`, the `CommitRequest` lifecycle hole, + `meta.LocalObjectReference` for our six reference shapes, the `TooManyStreams` cap, and the + `default` `ClusterProvider` message. **Only the reference-shape collapse is breaking**, and it is + breaking in the quiet way: `GitProviderReference`, `ClusterProviderReference`, + `LocalSecretReference` and friends each carry a defaulted, enum-of-one `group`/`kind`, so every + stored object has those persisted and collapsing the shape prunes them with no error. It belongs + in a breaking release or in none. - [ ] Reduce duplication between `WatchRule` and `ClusterWatchRule` code paths where it makes sense. -- [ ] Collapse wildcard source-namespace stream fan-out. **Superseded as a standalone item** — - [`source-scope-simplification.md`](design/source-scope-simplification.md#sourcenamespace--needs-its-own-decision) - is the definition of record for `sourceNamespace: "*"`, and the redefinition it decides deletes - this fan-out rather than optimizing it. The direction recorded here (a cluster-wide stream whose - resync scope carries a namespace **set**) is *not* what was decided, and building it would be - building the thing the wave removes. - - What survives the redefinition and still needs doing: revisit - `WatchRuleStreamsStatus.PendingSample`, whose five-entry cap stops being representative once a - wildcard produces one cluster-wide cell instead of N named ones. +- [ ] Revisit `WatchRuleStreamsStatus.PendingSample`, whose five-entry cap stops being + representative now that `sourceNamespace: "*"` produces one cluster-wide cell 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. + [`source-scope-simplification.md`](design/source-scope-simplification.md) is the definition of + record. - [ ] Subscribe the watch plane to `typeset.Registry` lifecycle events. `Registry.Subscribe` has **no production observer**: the events are computed on every `Update` @@ -147,8 +162,8 @@ This file is meant to track the smaller current backlog, not historical notes. - [ ] Handle resources whose GVK cannot be resolved against the live cluster. A manifest may reference a `apiVersion`/`kind` whose CRD is not installed, so the RESTMapper cannot map it to a GVR. This is already a problem today and also blocks the manifest-inventory - work in [docs/design/manifest/manifest-inventory-file-agnostic-placement.md](spec/manifest-system.md): - indexing must record the manifest identity and defer rather than fail the whole scan. + work in [`spec/manifest-system.md`](spec/manifest-system.md): indexing must record the manifest + identity and defer rather than fail the whole scan. ## Future directions worth revisiting diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index f01a73ca..b19a72de 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -311,7 +311,7 @@ spec: One target is one environment is one write partition, which is what makes authorization, audit and review line up with the environment boundary. The reasoning is in -[`layout/shapes/README.md`](../test/fixtures/layout-corpus/shapes/README.md#why-only-a-leaf-can-be-a-kustomize-target). +[`layout-corpus/shapes/README.md`](../test/fixtures/layout-corpus/shapes/README.md#why-only-a-leaf-can-be-a-kustomize-target). `status.placement.mode` and `status.placement.renderRoot` report what the scan resolved, and both are published before a target has written anything — so a target you have just declared already diff --git a/docs/design/created-root-namespace.md b/docs/design/created-root-namespace.md index fb26dea5..a008c8f3 100644 --- a/docs/design/created-root-namespace.md +++ b/docs/design/created-root-namespace.md @@ -1,12 +1,18 @@ # What namespace does a created `kustomization.yaml` carry? -> **design, decided**: being built. Index: [`../INDEX.md`](../INDEX.md) -> Date: 2026-09-01. +> **built**: decided as B, and implemented. Index: [`../INDEX.md`](../INDEX.md) +> Date: 2026-09-01, built in #328 itself. +> +> The created root carries no `namespace:`, and +> [`kustomization_bootstrap.go`](../../internal/git/kustomization_bootstrap.go) cites this page by +> path as the reason. That citation is why the document stays in `design/` rather than moving to +> `finished/`. > > One question, five answers, and the reason the obvious one is wrong. It came out of review of -> [#328](https://github.com/ConfigButler/gitops-reverser/pull/328), which shipped -> `spec.placement.useKustomize` writing `namespace:` into every root it created. This page records -> the choice so the next reader does not have to re-derive it from a diff. +> [#328](https://github.com/ConfigButler/gitops-reverser/pull/328), whose first cut had +> `spec.placement.useKustomize` write `namespace:` into every root it created. That cut did not +> merge: #328 shipped answer B, and this page records the choice so the next reader does not have +> to re-derive it from a diff. ## The question diff --git a/docs/design/gittarget-api-wave.md b/docs/design/gittarget-api-wave.md index a0749f0d..29a8ed02 100644 --- a/docs/design/gittarget-api-wave.md +++ b/docs/design/gittarget-api-wave.md @@ -1,7 +1,13 @@ # The wave after placement left it -> **partly built**: a sequencing proposal. Steps 6 and 7 (B4 and the source-scope deletion) **shipped** -> on 2026-09-01; step 8, the riders, was trimmed under this page's own rule and is unbuilt. +> **partly built**: a sequencing proposal. Steps 2 to 7 have all **shipped** — `spec.suspend`, +> `status.placement` and the reconcile-request annotation in +> [#326](https://github.com/ConfigButler/gitops-reverser/pull/326), B4 and the source-scope deletion +> in [#330](https://github.com/ConfigButler/gitops-reverser/pull/330) on 2026-09-01. **Step 8, the +> riders, is the only unbuilt member**, trimmed under this page's own rule; step 5 (an Event on a +> changed resolution) is additive and also unbuilt. What that leaves of +> [#294](https://github.com/ConfigButler/gitops-reverser/issues/294) is the riders alone, so the +> issue wants narrowing rather than closing. > Index: [`../INDEX.md`](../INDEX.md) > Date: 2026-08-28 (originally 2026-07-30). > @@ -231,9 +237,13 @@ status: mode: KustomizeRoot renderRoot: . resolvedAtRevision: 9f3c1ab - lastHandledReconcileAt: "2026-07-30T09:14:22Z" ``` +**`status.lastHandledReconcileAt` was not built.** #326 tracks the last handled request in memory +instead, so a standing annotation forces one re-read rather than one per reconcile, and a restart +costs one extra re-check rather than a field every consumer has to understand +([`gittarget_reconcile_request.go`](../../internal/controller/gittarget_reconcile_request.go)). + The placement fields are shown in place so the object reads as a whole, but they carry defaults equal to today's behavior and are additive: only `spec.commit` and the riders make this release breaking. The status stanza is specified where it is built, in @@ -275,11 +285,20 @@ rejection is cheap because a single coordinated bump can absorb it; a second con wave cost two, on two schedules. Two things follow, and both belong on this repo's roadmap rather than only on the consumer's: -- **Each wave leaves residue.** A refused field stays in the schema to say "no, not that anymore" — - `allowedSourceNamespaces` now, more later. That graveyard is a real cost to a newcomer reading the - CRD, and it is paid per wave, so the number of remaining waves on `v1alpha3` is finite. Sweeping - the refusals is what `v1alpha4` should be for, and it should be one version bump carrying the - removals rather than a version bump per change. +- **Each wave leaves residue.** A refused field stays in the schema to say "no, not that anymore". + That graveyard is a real cost to a newcomer reading the CRD, and it is paid per wave, so the + number of remaining waves on `v1alpha3` is finite. Sweeping the refusals is what `v1alpha4` should + be for, and it should be one version bump carrying the removals rather than a version bump per + change. + + **What the residue turned out to be, measured on `main` after #330: one field.** This wave itself + left none — `allowedSourceNamespaces` and the two relocated `GitProvider` fields were **removed + outright** rather than refused, which is what + [`../UPGRADING.md`](../UPGRADING.md) is a hand-written migration for and what + [`../facts/crd-upgrade-strategies.md`](../facts/crd-upgrade-strategies.md) prices. The one + survivor is `ClusterWatchRule.spec.rules[].scope`, whose own doc comment says "Removed one release + from now" and which has now stood through 0.40, 0.41 and 0.42. Its countdown has expired, so a + release that is breaking anyway is where it goes. - **The coupling is a dependency, not a courtesy.** Staying on `v1alpha3` requires the consumer to keep pace with this repo's release cadence. If it cannot, the choice is not "revisit" in the abstract — it is a real API version with a real conversion path, and the time to notice is before @@ -295,7 +314,8 @@ Dependencies first, then the things that only need the object to be breaking. highest-value gap: a way to stop the writes that is not deleting the object. 3. **`status.placement`** plus the post-scan validation pass. Independent of step 2 — it explains a write that already happened, rather than previewing one that has not. -4. **`requestedAt` + `lastHandledReconcileAt`.** On-demand refresh of step 3. +4. **`requestedAt`.** On-demand refresh of step 3. Shipped without the + `lastHandledReconcileAt` echo, for the reason recorded above. 5. **Events on a changed resolution**, over the existing recorder. 6. **B4**, as `spec.commit`. Last of the principle items, and the one that makes the object coherent. 7. **The source-scope deletion.** Independent of every step above, so it can be written in parallel; diff --git a/docs/design/open-asks-priority.md b/docs/design/open-asks-priority.md index 85876a02..18c2537d 100644 --- a/docs/design/open-asks-priority.md +++ b/docs/design/open-asks-priority.md @@ -2,7 +2,21 @@ > **design**: a priority call, not a plan of record. Nothing here binds until scheduled. > Index: [`../INDEX.md`](../INDEX.md) -> Date: 2026-07-29, swept 2026-07-30 against the 0.41.0 release. +> Date: 2026-07-29, swept 2026-07-30 against the 0.41.0 release and again 2026-09-03 against 0.43.0. +> +> **What the second sweep found.** The postponed wave is no longer postponed: `spec.suspend`, +> `status.placement` and the reconcile-request annotation shipped in +> [#326](https://github.com/ConfigButler/gitops-reverser/pull/326); `useKustomize` and +> `serializeNamespace` in [#328](https://github.com/ConfigButler/gitops-reverser/pull/328); B4 and +> the source-scope deletion in [#330](https://github.com/ConfigButler/gitops-reverser/pull/330), +> which is what makes 0.43.0 a breaking release. Of the consumer asks this page ranks, **#22, #11 +> and #10 are gone**: the `permanent` doc comment was rewritten, the two encoders became one +> (`internal/yamlstyle`, which is why a create and an update now emit identical bytes), and sibling +> inference — the thing #10 was about — was deleted outright. What is left of the consumer list is +> **#15** (a declared `auditRoute` that has received zero facts should say so), **#23** (which actor +> deletion-as-intent picks when a controller clears a finalizer), and **#6** (a movable +> `GitTarget` destination), and of the wave, the riders. The tiers below have not been re-ordered +> against that; read them as the argument, and [`../TODO.md`](../TODO.md) as the queue. > > **Where this stands as of the sweep.** `0.41.0` is the attribution release: the fact stream, the > sticky removal pointer, the metric relabel, the name tier, and the analyzer/encoder corrections, diff --git a/docs/design/placement-visibility-and-declared-defaults.md b/docs/design/placement-visibility-and-declared-defaults.md index bb413444..415a3bd1 100644 --- a/docs/design/placement-visibility-and-declared-defaults.md +++ b/docs/design/placement-visibility-and-declared-defaults.md @@ -1,7 +1,14 @@ # Placement, made visible: naming, a declared default, and `status.layout` -> **design, decided**: mostly not built. Index: [`../INDEX.md`](../INDEX.md) -> Date: 2026-07-30, reconciled 2026-07-30 against what PR #291 actually contains. +> **design, decided**: half built. Index: [`../INDEX.md`](../INDEX.md) +> Date: 2026-07-30, reconciled 2026-09-03 against what has since shipped. +> +> **`status.layout` shipped as `status.placement`, and the ambiguous render root with it**, both in +> [#326](https://github.com/ConfigButler/gitops-reverser/pull/326) — so the two largest rows of the +> table below are done. What is left is the legibility set: the `declared` metric-value split, the +> canonical path as a template constant, `{kindLower}`, and the re-opened CRD default. The metric +> split is the only one with an external consumer, because it changes a label value a dashboard may +> already select on. > > **The decisions below stand. The build list does not.** An earlier revision of this page said > "everything decided here lands in that same PR", and that turned out to be wrong: PR #291 shipped @@ -504,19 +511,20 @@ kustomization naming a namespace other than the resource's own no longer renders different object). `source="canonical"` kept its name, which is this page's Question 1 answered by doing nothing. -Everything else below is **decided and unbuilt**. It is filed as GitHub issues so it is legible -without reading this page, and the two correctness items are separated from the legibility ones +Everything else below was **decided and unbuilt** when this page was written; two rows have since +shipped and are marked. The rest is filed as GitHub issues so it is legible without reading this +page, and the two correctness items are separated from the legibility ones because they are worth different urgency: | Item | State | Why it is where it is | |---|---|---| | F10: register a declared path with the kustomization that governs it | **SHIPPED** in 0.42.1 (#319) | One `byType` line into a subdirectory silently produced a file nothing renders. The fix made registration an invariant, which is what reversed [`model.md`](../layout/model.md) | | Drop the `{version}` requirement from `IdentityCompletePlacementTemplate` | **SHIPPED** in 0.42.1 (#319) | It contradicted the versionless-path decision, and it was what made any future spec default fail our own gate | -| `status.layout` | **filed** | The durable half of "what did the operator understand about this folder". Wants the layout model's vocabulary, so it follows it | +| `status.layout` | **SHIPPED** as `status.placement` in #326 | The durable half of "what did the operator understand about this folder". It waited for the layout model's vocabulary and shipped with it | | Split `declared` into `byType` and `default`; unify the prose on "canonical" | **filed, legibility** | A catch-all quietly swallowing a type you meant to name looks identical to a rule working | | `{kindLower}` | **filed, legibility** | Small, self-contained | | Canonical path as a template constant | **filed, cleanup** | Removes the hand-written duplication; what a future default would reuse | -| `renderRootReason: Ambiguous` | **filed** | Belongs with `status.layout`, and the layout model decides the refuse-or-write policy | +| `renderRootReason: Ambiguous` | **SHIPPED** in #326 | Shipped with `status.placement`, and the layout model decided the policy: a folder covering two roots REFUSES the placement rather than writing it unrendered | | A CRD default for `placement.default` | **re-opened** | The reversal took away the answer this row used to give. There is no `layout.kind`; the template stays, and now that the ancestor walk registers a defaulted path, the objection is legibility rather than correctness. It is an open question in [`model.md`](../layout/model.md) | The original build list follows, because each entry says *how* to build the thing and that is the part diff --git a/docs/layout/README.md b/docs/layout/README.md index 174139a5..cfc2e863 100644 --- a/docs/layout/README.md +++ b/docs/layout/README.md @@ -13,7 +13,7 @@ had in the old layout. Read the label before you read the page. |---|---|---| | [`new-file-placement-rules.md`](new-file-placement-rules.md) | **spec** | where a brand-new resource's file goes: declared, the folder's one kustomize root, canonical. Go source cites it by path, and `task lint-docs` checks those citations | | [`contextual-namespace.md`](contextual-namespace.md) | **spec** | kustomize graph-aware namespace inference, and the supported subset. This is the inference `serializeNamespace` overrides | -| [`model.md`](model.md) | **design** | the proposal, reversed and much smaller: the path template **stays**, and gains two optional booleans — `spec.placement.useKustomize`, and `spec.serializeNamespace` one level up because it governs every write rather than only new files. Carries the status stanza, the post-scan pass, and the order the work is built in | +| [`model.md`](model.md) | **built** | the proposal, reversed and much smaller, and now shipped: the path template **stays**, and gained two optional booleans — `spec.placement.useKustomize`, and `spec.serializeNamespace` one level up because it governs every write rather than only new files. Carries the status stanza, the post-scan pass, the order the work was built in, and the four questions it deliberately left open | ## The worked examples are a test, and they live in the test tree @@ -37,10 +37,11 @@ that produced them. What moved is the evidence. ## What is deliberately not here -- [`../design/gittarget-api-wave.md`](../design/gittarget-api-wave.md) sequences the breaking work on - `GitTarget`. The placement work is additive and is not part of it. +- [`../design/gittarget-api-wave.md`](../design/gittarget-api-wave.md) sequenced the breaking work on + `GitTarget`, which shipped in #330. The placement work was additive and was never part of it. - [`../design/placement-visibility-and-declared-defaults.md`](../design/placement-visibility-and-declared-defaults.md) - holds the three questions the sibling-inference deletion left. Decided, mostly unbuilt. Its + holds the three questions the sibling-inference deletion left. Decided; `status.placement` has + since shipped and the rest is unbuilt. Its Question 2 (a CRD default for `placement.default`) is **reopened** by [`model.md`](model.md)'s reversal, not superseded by it. - [`../design/support-boundary/`](../design/support-boundary/README.md) owns what the operator may diff --git a/docs/layout/model.md b/docs/layout/model.md index c8202ed5..e43cb64c 100644 --- a/docs/layout/model.md +++ b/docs/layout/model.md @@ -1,9 +1,14 @@ # The template was the right primitive. Two things were missing -> **design**: a proposal, not a plan of record. Nothing here binds until scheduled. +> **built**: everything this page proposed has shipped — `spec.suspend`, `status.placement` and the +> post-scan `Ambiguous` rule in [#326](https://github.com/ConfigButler/gitops-reverser/pull/326), +> `spec.placement.useKustomize` and `spec.serializeNamespace` with the one-source-namespace refusal +> in [#328](https://github.com/ConfigButler/gitops-reverser/pull/328), and the corpus that proves +> them in both. What is still open is the four questions at the end, none of which blocks anything. > Index: [`../INDEX.md`](../INDEX.md) -> Date: 2026-08-29. Supersedes this document's own earlier thesis, which argued that a path template -> is the wrong primitive and should be replaced by a `spec.layout` discriminated union. That argument +> Date: 2026-08-29, shipped 2026-09-02. Supersedes this document's own earlier thesis, which argued +> that a path template is the wrong primitive and should be replaced by a `spec.layout` discriminated +> union. That argument > no longer holds; why it stopped holding is the first section, because a reversal is worth more than > a quiet edit. > From 988c9cfed9f4a1a26ae6139e8647340ba3aab93b Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Thu, 3 Sep 2026 09:58:43 +0000 Subject: [PATCH 2/9] feat(api)!: delete ClusterWatchRule scope, collapse every reference onto Flux's, split the placement metric Three changes that are breaking, or breaking-adjacent, and therefore belong in a release that is already breaking. ClusterWatchRule.spec.rules[].scope is gone. It was the whole deprecation graveyard in the CRDs: retained since 0.39.0 purely so re-applying a manifest still saying "Namespaced" would fail, with a doc comment promising removal one release later. That was four minors ago. Which scope a rule watches is decided by which KIND you write, so the field had one legal value and no job. The compile-path refusal, its condition reason and its message go with it. ResourceScope survives as what it always was underneath: an internal matching vocabulary with no field in any CRD. The six reference shapes become two of Flux's. GitProviderReference, ClusterProviderReference, LocalTargetReference and LocalSecretReference each carried a defaulted, enum-of-one group/kind alongside the name, so four types existed to say "a GitProvider is a GitProvider". They are now meta.LocalObjectReference; NamespacedTargetReference is meta.NamespacedObjectReference. This is the quiet kind of breaking: the defaults meant every stored object persisted those fields, so the collapse prunes them with no error, which is why it goes in this release or in none. Flux's namespaced reference makes namespace optional, so ClusterWatchRule's targetRef keeps its "namespace is required" invariant as a CEL rule rather than by owning a struct; the MinLength=1 that our own types carried on name is kept the same way. KnownHostsReference stays ours, because its kind is a real two-member choice (ConfigMap or Secret) rather than an enum of one. The placement metric's source="declared" splits into by_type and default. A catch-all quietly answering for a type you meant to name explicitly was reported identically to the byType line working as intended, which is the one question the metric exists to answer. Not an API break, but it changes a label value a dashboard may select on, so it travels with the ones that are. Also drops a now-unreachable check that encryption.secretRef.kind is "Secret", and re-points the envtest that pins the apiserver's status-update behaviour onto a field that still exists: no field uses the loud-rejection pattern any more, but the property it rests on is worth keeping measured. Co-Authored-By: Claude Opus 5 --- api/v1alpha3/clusterprovider_types.go | 24 ------ api/v1alpha3/clusterwatchrule_types.go | 76 +++-------------- api/v1alpha3/commitrequest_types.go | 4 +- api/v1alpha3/gitprovider_types.go | 25 +----- api/v1alpha3/gittarget_types.go | 35 ++------ api/v1alpha3/helpers_test.go | 22 +++-- api/v1alpha3/namespace_matcher_test.go | 17 ---- api/v1alpha3/watchrule_types.go | 27 +------ api/v1alpha3/zz_generated.deepcopy.go | 81 +------------------ .../configbutler.ai_clusterwatchrules.yaml | 44 +++------- .../bases/configbutler.ai_commitrequests.yaml | 19 +---- .../bases/configbutler.ai_gitproviders.yaml | 26 +----- .../crd/bases/configbutler.ai_gittargets.yaml | 53 +++--------- .../crd/bases/configbutler.ai_watchrules.yaml | 24 ++---- .../authz/clusterprovider_admission_test.go | 3 +- internal/authz/source_namespace_test.go | 7 +- .../clusterwatchrule_admission_test.go | 11 ++- .../controller/clusterwatchrule_controller.go | 4 - .../clusterwatchrule_controller_test.go | 9 +-- .../commitrequest_controller_test.go | 5 +- .../commitrequest_controller_unit_test.go | 3 +- .../controller/dependency_watches_test.go | 7 +- .../controller/gitprovider_controller_test.go | 7 +- .../gitprovider_controller_unit_test.go | 9 ++- .../controller/gittarget_controller_test.go | 61 +++++++------- .../gittarget_controller_unit_test.go | 3 +- .../controller/gittarget_immutability_test.go | 5 +- .../gittarget_placement_validation_test.go | 3 +- .../gittarget_source_cluster_test.go | 13 +-- .../stored_superseded_value_status_test.go | 77 +++++++++--------- .../superseded_fields_admission_test.go | 68 ++++++++-------- internal/controller/watchrule_controller.go | 8 +- .../controller/watchrule_controller_test.go | 16 ++-- .../watchrule_source_namespace_test.go | 7 +- internal/git/branch_worker_credread_test.go | 3 +- internal/git/branch_worker_loop_test.go | 3 +- internal/git/branch_worker_split_test.go | 3 +- internal/git/branch_worker_test.go | 25 +++--- internal/git/credentials_test.go | 5 +- internal/git/encryption.go | 8 -- internal/git/encryption_test.go | 7 +- internal/git/placement_metrics.go | 2 +- internal/git/placement_metrics_test.go | 38 +++++++-- internal/git/secret_write_test.go | 5 +- internal/git/source_namespaces_test.go | 3 +- internal/git/worker_manager_test.go | 3 +- .../git/write_boundary_precondition_test.go | 3 +- internal/manifestanalyzer/placement.go | 42 ++++++---- internal/manifestanalyzer/placement_test.go | 36 ++++++++- internal/rulestore/store_test.go | 7 +- internal/watch/bootstrap_admission_test.go | 71 +++++----------- .../config_plane_split_review_fixes_test.go | 7 +- internal/watch/event_router_test.go | 7 +- internal/watch/manager_snapshot_test.go | 3 +- internal/watch/manager_startup_test.go | 9 ++- internal/watch/rule_status_test.go | 1 - .../source_namespace_stream_summary_test.go | 3 +- internal/watch/source_namespace_test.go | 7 +- internal/watch/stream_readiness.go | 2 +- internal/watch/watched_type_helpers_test.go | 5 +- internal/watch/watched_type_resolver_test.go | 3 +- internal/watch/watchrule_compile.go | 18 ----- ...tchrule_source_namespace_admission_test.go | 3 +- .../podinfos-intent/2-reverse-gitops.yaml | 2 - .../test/coffeeconfig-reverse-gitops.yaml | 2 - test/playground/config/gittarget.yaml | 1 - test/playground/config/watchrule.yaml | 1 - 67 files changed, 418 insertions(+), 723 deletions(-) diff --git a/api/v1alpha3/clusterprovider_types.go b/api/v1alpha3/clusterprovider_types.go index 713d0467..a98727ba 100644 --- a/api/v1alpha3/clusterprovider_types.go +++ b/api/v1alpha3/clusterprovider_types.go @@ -14,30 +14,6 @@ import ( // every other provider's, and it is never created by the operator. const DefaultClusterProviderName = "default" -// ClusterProviderReference references the cluster-scoped ClusterProvider a GitTarget sources -// FROM. It is the read-side peer of GitProviderReference (which names the WRITE destination): -// a GitTarget names one ClusterProvider by name and its author-attribution facts, kube client, -// and namespace authorization all follow from that single reference. Group and Kind are typed -// (with defaults) for consistency with the project's other typed references. -type ClusterProviderReference struct { - // API Group of the referent. - // +kubebuilder:default=configbutler.ai - // +kubebuilder:validation:Enum=configbutler.ai - Group string `json:"group,omitempty"` - - // Kind of the referent. - // Optional because this reference currently only supports a single kind (ClusterProvider). - // +optional - // +kubebuilder:validation:Enum=ClusterProvider - // +kubebuilder:default=ClusterProvider - Kind string `json:"kind,omitempty"` - - // Name of the referent. - // +required - // +kubebuilder:validation:MinLength=1 - Name string `json:"name"` -} - // ClusterProviderSpec defines the desired state of ClusterProvider. // // kubeConfig is IMMUTABLE and OPTIONAL: which physical cluster a provider name means must not diff --git a/api/v1alpha3/clusterwatchrule_types.go b/api/v1alpha3/clusterwatchrule_types.go index 71d0435d..73796e64 100644 --- a/api/v1alpha3/clusterwatchrule_types.go +++ b/api/v1alpha3/clusterwatchrule_types.go @@ -3,14 +3,14 @@ package v1alpha3 import ( + meta "github.com/fluxcd/pkg/apis/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// ResourceScope names a Kubernetes resource's scope. It is an INTERNAL matching vocabulary: the -// resolver uses both constants to align a rule's selector with the discovered scope of each type -// (a WatchRule always resolves Namespaced records, a ClusterWatchRule always Cluster ones). The -// only field that still exposes it — ClusterResourceRule.scope — is narrowed to Cluster alone, so -// "Namespaced" is no longer a public choice anywhere in the API. +// ResourceScope names a Kubernetes resource's scope. It is a purely INTERNAL matching vocabulary +// with no field in any CRD: the resolver uses both constants to align a rule's selector with the +// discovered scope of each type. A WatchRule always resolves Namespaced records, a ClusterWatchRule +// always Cluster ones, and which kind you write is the whole of how scope is chosen. type ResourceScope string const ( @@ -21,37 +21,14 @@ const ( ResourceScopeNamespaced ResourceScope = "Namespaced" ) -type NamespacedTargetReference struct { - // API Group of the referent. - // +kubebuilder:validation:Enum=configbutler.ai - // +kubebuilder:default=configbutler.ai - Group string `json:"group,omitempty"` - - // Kind of the referent. - // Optional because this reference currently only supports a single kind (GitTarget). - // Keeping it optional allows users to omit it while still benefiting from CRD defaulting. - // +optional - // +kubebuilder:validation:Enum=GitTarget - // +kubebuilder:default=GitTarget - Kind string `json:"kind,omitempty"` - - // Name of the referent. - // +required - // +kubebuilder:validation:MinLength=1 - Name string `json:"name"` - - // Required because ClusterWatchRule has no namespace. - // +required - // +kubebuilder:validation:MinLength=1 - Namespace string `json:"namespace"` -} - // ClusterWatchRuleSpec defines the desired state of ClusterWatchRule. type ClusterWatchRuleSpec struct { - // TargetRef references the GitTarget to use. - // Must specify namespace. + // TargetRef names the GitTarget this rule feeds. A ClusterWatchRule has no namespace of its + // own, so the namespace is required here rather than defaulted. // +required - TargetRef NamespacedTargetReference `json:"targetRef"` + // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.targetRef.name must not be empty" + // +kubebuilder:validation:XValidation:rule="has(self.namespace) && self.namespace != ''",message="spec.targetRef.namespace is required: a ClusterWatchRule has no namespace to default to" + TargetRef meta.NamespacedObjectReference `json:"targetRef"` // Rules define which CLUSTER-SCOPED resources to watch. // Multiple rules create a logical OR - a resource matching ANY rule is watched. @@ -110,39 +87,6 @@ type ClusterResourceRule struct { // +kubebuilder:validation:items:MinLength=1 // +kubebuilder:validation:items:Pattern=`^[^/]*$` Resources []string `json:"resources"` - - // Retained in the schema purely so re-applying a manifest that still says "Namespaced" FAILS. - // Deleting it would be silent twice over: pruning drops the value without an error, and a - // stored pre-release object would keep its value with no Go field left to refuse it. - - // Scope is REMOVED as a choice: a ClusterWatchRule is cluster-scoped only, so "Cluster" is the - // only accepted value and also the default, making the field omittable. To watch NAMESPACED - // resources, use a WatchRule in the tenant namespace and set spec.rules[].sourceNamespace. - // - // Deprecated: ClusterWatchRule is cluster-scope-only; use WatchRule with - // spec.rules[].sourceNamespace for namespaced resources. Removed one release from now, or at - // v1beta1. - // +optional - // +kubebuilder:default=Cluster - // +kubebuilder:validation:Enum=Cluster - Scope ResourceScope `json:"scope,omitempty"` -} - -// DeclaresNamespacedScope reports whether a STORED ClusterWatchRule still selects namespaced -// resources through the removed scope choice. Admission rejects the value, but an object written -// before this release keeps it in etcd, so the compile path must refuse it rather than let the -// rule resolve as if it had asked for cluster scope. -// -// It keys on the STORED value, not on what the selector happens to resolve: `resources: ["*"]` -// legitimately resolves cluster-scoped records, so inferring the refusal from the resolution would -// be ambiguous exactly where it matters. -func (s *ClusterWatchRuleSpec) DeclaresNamespacedScope() bool { - for i := range s.Rules { - if s.Rules[i].Scope != "" && s.Rules[i].Scope != ResourceScopeCluster { - return true - } - } - return false } // ClusterWatchRuleStatus defines the observed state of ClusterWatchRule. diff --git a/api/v1alpha3/commitrequest_types.go b/api/v1alpha3/commitrequest_types.go index d69082cc..d6f9216f 100644 --- a/api/v1alpha3/commitrequest_types.go +++ b/api/v1alpha3/commitrequest_types.go @@ -3,6 +3,7 @@ package v1alpha3 import ( + meta "github.com/fluxcd/pkg/apis/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -16,7 +17,8 @@ type CommitRequestSpec struct { // TargetRef names the GitTarget whose open commit window to finalize. // The GitTarget must be in the same namespace as this CommitRequest. // +required - TargetRef LocalTargetReference `json:"targetRef"` + // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.targetRef.name must not be empty" + TargetRef meta.LocalObjectReference `json:"targetRef"` // Message is an optional commit message for the finalized commit. When // omitted, the generated grouped-commit message is used. diff --git a/api/v1alpha3/gitprovider_types.go b/api/v1alpha3/gitprovider_types.go index a2843d3f..78207d46 100644 --- a/api/v1alpha3/gitprovider_types.go +++ b/api/v1alpha3/gitprovider_types.go @@ -3,6 +3,7 @@ package v1alpha3 import ( + meta "github.com/fluxcd/pkg/apis/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -25,7 +26,7 @@ type GitProviderSpec struct { URL string `json:"url"` // SecretRef for authentication credentials (may be nil for public repos) - SecretRef *LocalSecretReference `json:"secretRef,omitempty"` + SecretRef *meta.LocalObjectReference `json:"secretRef,omitempty"` // KnownHostsRef optionally points at a namespace-local ConfigMap or Secret holding SSH // known_hosts, so host trust can be centralized across GitProviders on the same host instead @@ -48,24 +49,6 @@ type GitProviderSpec struct { Commit *CommitSpec `json:"commit,omitempty"` } -// LocalSecretReference is a typed reference to a Secret in the same namespace. -type LocalSecretReference struct { - // Group of the referent. - // +kubebuilder:default="" - // +optional - Group string `json:"group,omitempty"` - - // Kind of the referent. - // +kubebuilder:validation:Enum=Secret - // +kubebuilder:default=Secret - // +optional - Kind string `json:"kind,omitempty"` - - // Name of the Secret. - // +kubebuilder:validation:MinLength=1 - Name string `json:"name"` -} - // KnownHostsReference points at a namespace-local ConfigMap or Secret that holds SSH known_hosts // host-trust material. The data is read from the "known_hosts" key, falling back to // "ssh_known_hosts" (the key Argo CD's argocd-ssh-known-hosts-cm ConfigMap uses, for host keys @@ -92,7 +75,7 @@ type EncryptionSpec struct { // SecretRef references namespace-local Secret data used by the encryption provider. // +optional - SecretRef LocalSecretReference `json:"secretRef,omitempty"` + SecretRef meta.LocalObjectReference `json:"secretRef,omitempty"` // Age configures age-specific encryption behavior for SOPS. // +optional @@ -214,7 +197,7 @@ type CommitMessageSpec struct { type CommitSigningSpec struct { // SecretRef references the Secret containing the signing key material. // Expected keys will be defined by the signing implementation. - SecretRef LocalSecretReference `json:"secretRef"` + SecretRef meta.LocalObjectReference `json:"secretRef"` // GenerateWhenMissing causes the operator to generate signing key material // in the referenced Secret when it is missing. diff --git a/api/v1alpha3/gittarget_types.go b/api/v1alpha3/gittarget_types.go index 8ae6cbbc..80c8de99 100644 --- a/api/v1alpha3/gittarget_types.go +++ b/api/v1alpha3/gittarget_types.go @@ -3,34 +3,10 @@ package v1alpha3 import ( + meta "github.com/fluxcd/pkg/apis/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) -// GitProviderReference references the GitProvider that backs a GitTarget. Many GitTargets may -// reference the same GitProvider; the reference is always to a GitProvider in the GitTarget's own -// namespace. Group and Kind are typed (with defaults) for consistency with the project's other -// local references and so the schema is explicit about what it accepts — currently only -// configbutler.ai/GitProvider. -type GitProviderReference struct { - // API Group of the referent. - // +kubebuilder:default=configbutler.ai - // +kubebuilder:validation:Enum=configbutler.ai - Group string `json:"group,omitempty"` - - // Kind of the referent. - // Optional because this reference currently only supports a single kind (GitProvider). - // Keeping it optional allows users to omit it while still benefiting from CRD defaulting. - // +optional - // +kubebuilder:validation:Enum=GitProvider - // +kubebuilder:default=GitProvider - Kind string `json:"kind,omitempty"` - - // Name of the referent. - // +required - // +kubebuilder:validation:MinLength=1 - Name string `json:"name"` -} - // GitTargetSpec defines the desired state of GitTarget. // // The destination fields — providerRef, branch, and path — are immutable. A @@ -52,10 +28,12 @@ type GitProviderReference struct { // always populated (never nil) and always jumpable. // +kubebuilder:validation:XValidation:rule="self.clusterProviderRef == oldSelf.clusterProviderRef",message="spec.clusterProviderRef is immutable; delete and recreate the GitTarget to change the cluster it mirrors" type GitTargetSpec struct { - // ProviderRef references the GitProvider that backs this target. + // ProviderRef names the GitProvider that backs this target, in this GitTarget's own namespace. + // Many GitTargets may name the same GitProvider. // Immutable: delete and recreate the GitTarget to change its destination. // +required - ProviderRef GitProviderReference `json:"providerRef"` + // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.providerRef.name must not be empty" + ProviderRef meta.LocalObjectReference `json:"providerRef"` // Branch to use for this target. // Must be one of the allowed branches in the provider. @@ -128,7 +106,8 @@ type GitTargetSpec struct { // Immutable: a folder's source cluster is part of what the folder means; delete and recreate. // +kubebuilder:default={name: "default"} // +optional - ClusterProviderRef *ClusterProviderReference `json:"clusterProviderRef,omitempty"` + // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.clusterProviderRef.name must not be empty" + ClusterProviderRef *meta.LocalObjectReference `json:"clusterProviderRef,omitempty"` // Mutable, unlike the destination fields above: recovering from a scope mistake must not // require recreating the GitTarget, which is the one operation that loses the folder's history. diff --git a/api/v1alpha3/helpers_test.go b/api/v1alpha3/helpers_test.go index 6bb5d3f4..94a9be5c 100644 --- a/api/v1alpha3/helpers_test.go +++ b/api/v1alpha3/helpers_test.go @@ -241,7 +241,7 @@ func TestSourceCluster_DefaultsToDefaultProviderName(t *testing.T) { tests := []struct { name string - ref *ClusterProviderReference + ref *meta.LocalObjectReference want string }{ { @@ -253,20 +253,18 @@ func TestSourceCluster_DefaultsToDefaultProviderName(t *testing.T) { // An empty Name is rejected by CRD validation, but the helper must not depend on // admission having run — an unvalidated object still has to yield a usable key. name: "empty name resolves to the default provider name", - ref: &ClusterProviderReference{Name: ""}, + ref: &meta.LocalObjectReference{Name: ""}, want: DefaultClusterProviderName, }, { name: "explicit name is returned verbatim", - ref: &ClusterProviderReference{Name: "prod-eu"}, + ref: &meta.LocalObjectReference{Name: "prod-eu"}, want: "prod-eu", }, { name: "explicitly naming default is the same as omitting the ref", - ref: &ClusterProviderReference{ - Group: "configbutler.ai", - Kind: "ClusterProvider", - Name: DefaultClusterProviderName, + ref: &meta.LocalObjectReference{ + Name: DefaultClusterProviderName, }, want: DefaultClusterProviderName, }, @@ -294,21 +292,21 @@ func TestIsLocalSource_IsANameTest(t *testing.T) { tests := []struct { name string - ref *ClusterProviderReference + ref *meta.LocalObjectReference want bool }{ {name: "omitted ref is local", ref: nil, want: true}, - {name: "empty name is local", ref: &ClusterProviderReference{Name: ""}, want: true}, + {name: "empty name is local", ref: &meta.LocalObjectReference{Name: ""}, want: true}, { name: "explicit default is local", - ref: &ClusterProviderReference{Name: DefaultClusterProviderName}, + ref: &meta.LocalObjectReference{Name: DefaultClusterProviderName}, want: true, }, - {name: "any other name is not local", ref: &ClusterProviderReference{Name: "prod-eu"}, want: false}, + {name: "any other name is not local", ref: &meta.LocalObjectReference{Name: "prod-eu"}, want: false}, { // Near-miss names must not be treated as the default; the comparison is exact. name: "a name that merely contains \"default\" is not local", - ref: &ClusterProviderReference{Name: "default-eu"}, + ref: &meta.LocalObjectReference{Name: "default-eu"}, want: false, }, } diff --git a/api/v1alpha3/namespace_matcher_test.go b/api/v1alpha3/namespace_matcher_test.go index 562c6cbc..529cc291 100644 --- a/api/v1alpha3/namespace_matcher_test.go +++ b/api/v1alpha3/namespace_matcher_test.go @@ -123,23 +123,6 @@ func TestResourceRule_EffectiveSourceNamespace(t *testing.T) { assert.True(t, item.OverridesSourceNamespace(own)) } -// TestClusterWatchRuleSpec_DeclaresNamespacedScope covers the other half of decision 10. The -// refusal keys on the STORED value, never on what the selector happens to resolve. -func TestClusterWatchRuleSpec_DeclaresNamespacedScope(t *testing.T) { - clusterOnly := ClusterWatchRuleSpec{Rules: []ClusterResourceRule{ - {Resources: []string{"customresourcedefinitions"}, Scope: ResourceScopeCluster}, - {Resources: []string{"*"}}, - }} - assert.False(t, clusterOnly.DeclaresNamespacedScope(), - "an omitted scope defaults to Cluster and a wildcard selector is not itself a refusal") - - stored := ClusterWatchRuleSpec{Rules: []ClusterResourceRule{ - {Resources: []string{"nodes"}, Scope: ResourceScopeCluster}, - {Resources: []string{"configmaps"}, Scope: ResourceScopeNamespaced}, - }} - assert.True(t, stored.DeclaresNamespacedScope(), "one namespaced item refuses the whole rule") -} - // The security default in one line: the delegation flag must be false on a provider that never // mentions it, so a WatchRule may watch only its own namespace until a platform admin says // otherwise. diff --git a/api/v1alpha3/watchrule_types.go b/api/v1alpha3/watchrule_types.go index 0bad55da..4bd81a5a 100644 --- a/api/v1alpha3/watchrule_types.go +++ b/api/v1alpha3/watchrule_types.go @@ -5,6 +5,7 @@ package v1alpha3 import ( "fmt" + meta "github.com/fluxcd/pkg/apis/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -23,35 +24,15 @@ const ( OperationAll OperationType = "*" ) -type LocalTargetReference struct { - // API Group of the referent. - // +kubebuilder:default=configbutler.ai - // +kubebuilder:validation:Enum=configbutler.ai - Group string `json:"group,omitempty"` - - // Kind of the referent. - // Optional because this reference currently only supports a single kind (GitTarget). - // Keeping it optional allows users to omit it while still benefiting from CRD defaulting. - // +optional - // +kubebuilder:validation:Enum=GitTarget - // +kubebuilder:default=GitTarget - Kind string `json:"kind,omitempty"` - - // Name of the referent. - // +required - // +kubebuilder:validation:MinLength=1 - Name string `json:"name"` -} - // 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, // or "*" for every namespace the GitTarget admits. type WatchRuleSpec struct { - // TargetRef references the GitTarget to use. - // Must be in the same namespace. + // TargetRef names the GitTarget this rule feeds, in this WatchRule's own namespace. // +required - TargetRef LocalTargetReference `json:"targetRef"` + // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.targetRef.name must not be empty" + TargetRef meta.LocalObjectReference `json:"targetRef"` // Rules define which resources to watch, and in which source namespaces. // Multiple rules create a logical OR - a resource matching ANY rule is watched. diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index 944312cf..debf5d34 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -8,7 +8,7 @@ package v1alpha3 import ( "github.com/fluxcd/pkg/apis/meta" - "k8s.io/apimachinery/pkg/apis/meta/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -122,21 +122,6 @@ func (in *ClusterProviderList) DeepCopyObject() runtime.Object { return nil } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ClusterProviderReference) DeepCopyInto(out *ClusterProviderReference) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ClusterProviderReference. -func (in *ClusterProviderReference) DeepCopy() *ClusterProviderReference { - if in == nil { - return nil - } - out := new(ClusterProviderReference) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterProviderSpec) DeepCopyInto(out *ClusterProviderSpec) { *out = *in @@ -591,27 +576,12 @@ func (in *GitProviderList) DeepCopyObject() runtime.Object { return nil } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *GitProviderReference) DeepCopyInto(out *GitProviderReference) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitProviderReference. -func (in *GitProviderReference) DeepCopy() *GitProviderReference { - if in == nil { - return nil - } - out := new(GitProviderReference) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitProviderSpec) DeepCopyInto(out *GitProviderSpec) { *out = *in if in.SecretRef != nil { in, out := &in.SecretRef, &out.SecretRef - *out = new(LocalSecretReference) + *out = new(meta.LocalObjectReference) **out = **in } if in.KnownHostsRef != nil { @@ -833,7 +803,7 @@ func (in *GitTargetSpec) DeepCopyInto(out *GitTargetSpec) { } if in.ClusterProviderRef != nil { in, out := &in.ClusterProviderRef, &out.ClusterProviderRef - *out = new(ClusterProviderReference) + *out = new(meta.LocalObjectReference) **out = **in } if in.Prune != nil { @@ -929,36 +899,6 @@ func (in *KnownHostsReference) DeepCopy() *KnownHostsReference { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *LocalSecretReference) DeepCopyInto(out *LocalSecretReference) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalSecretReference. -func (in *LocalSecretReference) DeepCopy() *LocalSecretReference { - if in == nil { - return nil - } - out := new(LocalSecretReference) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *LocalTargetReference) DeepCopyInto(out *LocalTargetReference) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalTargetReference. -func (in *LocalTargetReference) DeepCopy() *LocalTargetReference { - if in == nil { - return nil - } - out := new(LocalTargetReference) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NamespaceMatcher) DeepCopyInto(out *NamespaceMatcher) { *out = *in @@ -984,21 +924,6 @@ func (in *NamespaceMatcher) DeepCopy() *NamespaceMatcher { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *NamespacedTargetReference) DeepCopyInto(out *NamespacedTargetReference) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespacedTargetReference. -func (in *NamespacedTargetReference) DeepCopy() *NamespacedTargetReference { - if in == nil { - return nil - } - out := new(NamespacedTargetReference) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PrunePolicy) DeepCopyInto(out *PrunePolicy) { *out = *in diff --git a/config/crd/bases/configbutler.ai_clusterwatchrules.yaml b/config/crd/bases/configbutler.ai_clusterwatchrules.yaml index a88bc057..36092e3a 100644 --- a/config/crd/bases/configbutler.ai_clusterwatchrules.yaml +++ b/config/crd/bases/configbutler.ai_clusterwatchrules.yaml @@ -143,19 +143,6 @@ spec: type: string minItems: 1 type: array - scope: - default: Cluster - description: |- - Scope is REMOVED as a choice: a ClusterWatchRule is cluster-scoped only, so "Cluster" is the - only accepted value and also the default, making the field omittable. To watch NAMESPACED - resources, use a WatchRule in the tenant namespace and set spec.rules[].sourceNamespace. - - Deprecated: ClusterWatchRule is cluster-scope-only; use WatchRule with - spec.rules[].sourceNamespace for namespaced resources. Removed one release from now, or at - v1beta1. - enum: - - Cluster - type: string required: - resources type: object @@ -163,36 +150,25 @@ spec: type: array targetRef: description: |- - TargetRef references the GitTarget to use. - Must specify namespace. + TargetRef names the GitTarget this rule feeds. A ClusterWatchRule has no namespace of its + own, so the namespace is required here rather than defaulted. properties: - group: - default: configbutler.ai - description: API Group of the referent. - enum: - - configbutler.ai - type: string - kind: - default: GitTarget - description: |- - Kind of the referent. - Optional because this reference currently only supports a single kind (GitTarget). - Keeping it optional allows users to omit it while still benefiting from CRD defaulting. - enum: - - GitTarget - type: string name: description: Name of the referent. - minLength: 1 type: string namespace: - description: Required because ClusterWatchRule has no namespace. - minLength: 1 + description: Namespace of the referent, when not specified it + acts as LocalObjectReference. type: string required: - name - - namespace type: object + x-kubernetes-validations: + - message: spec.targetRef.name must not be empty + rule: self.name != '' + - message: 'spec.targetRef.namespace is required: a ClusterWatchRule + has no namespace to default to' + rule: has(self.namespace) && self.namespace != '' required: - rules - targetRef diff --git a/config/crd/bases/configbutler.ai_commitrequests.yaml b/config/crd/bases/configbutler.ai_commitrequests.yaml index 530809f6..1703ef91 100644 --- a/config/crd/bases/configbutler.ai_commitrequests.yaml +++ b/config/crd/bases/configbutler.ai_commitrequests.yaml @@ -101,28 +101,15 @@ spec: TargetRef names the GitTarget whose open commit window to finalize. The GitTarget must be in the same namespace as this CommitRequest. properties: - group: - default: configbutler.ai - description: API Group of the referent. - enum: - - configbutler.ai - type: string - kind: - default: GitTarget - description: |- - Kind of the referent. - Optional because this reference currently only supports a single kind (GitTarget). - Keeping it optional allows users to omit it while still benefiting from CRD defaulting. - enum: - - GitTarget - type: string name: description: Name of the referent. - minLength: 1 type: string required: - name type: object + x-kubernetes-validations: + - message: spec.targetRef.name must not be empty + rule: self.name != '' required: - targetRef type: object diff --git a/config/crd/bases/configbutler.ai_gitproviders.yaml b/config/crd/bases/configbutler.ai_gitproviders.yaml index d560e918..88a456cc 100644 --- a/config/crd/bases/configbutler.ai_gitproviders.yaml +++ b/config/crd/bases/configbutler.ai_gitproviders.yaml @@ -98,19 +98,8 @@ spec: SecretRef references the Secret containing the signing key material. Expected keys will be defined by the signing implementation. properties: - group: - default: "" - description: Group of the referent. - type: string - kind: - default: Secret - description: Kind of the referent. - enum: - - Secret - type: string name: - description: Name of the Secret. - minLength: 1 + description: Name of the referent. type: string required: - name @@ -146,19 +135,8 @@ spec: description: SecretRef for authentication credentials (may be nil for public repos) properties: - group: - default: "" - description: Group of the referent. - type: string - kind: - default: Secret - description: Kind of the referent. - enum: - - Secret - type: string name: - description: Name of the Secret. - minLength: 1 + description: Name of the referent. type: string required: - name diff --git a/config/crd/bases/configbutler.ai_gittargets.yaml b/config/crd/bases/configbutler.ai_gittargets.yaml index 66ce84f0..16219146 100644 --- a/config/crd/bases/configbutler.ai_gittargets.yaml +++ b/config/crd/bases/configbutler.ai_gittargets.yaml @@ -129,27 +129,15 @@ spec: name is "default" and must exist; it may be in-cluster or remote. Immutable: a folder's source cluster is part of what the folder means; delete and recreate. properties: - group: - default: configbutler.ai - description: API Group of the referent. - enum: - - configbutler.ai - type: string - kind: - default: ClusterProvider - description: |- - Kind of the referent. - Optional because this reference currently only supports a single kind (ClusterProvider). - enum: - - ClusterProvider - type: string name: description: Name of the referent. - minLength: 1 type: string required: - name type: object + x-kubernetes-validations: + - message: spec.clusterProviderRef.name must not be empty + rule: self.name != '' commit: description: |- Commit configures how this target's writes are batched into commits, and how those commits @@ -241,19 +229,8 @@ spec: description: SecretRef references namespace-local Secret data used by the encryption provider. properties: - group: - default: "" - description: Group of the referent. - type: string - kind: - default: Secret - description: Kind of the referent. - enum: - - Secret - type: string name: - description: Name of the Secret. - minLength: 1 + description: Name of the referent. type: string required: - name @@ -330,31 +307,19 @@ spec: type: object providerRef: description: |- - ProviderRef references the GitProvider that backs this target. + ProviderRef names the GitProvider that backs this target, in this GitTarget's own namespace. + Many GitTargets may name the same GitProvider. Immutable: delete and recreate the GitTarget to change its destination. properties: - group: - default: configbutler.ai - description: API Group of the referent. - enum: - - configbutler.ai - type: string - kind: - default: GitProvider - description: |- - Kind of the referent. - Optional because this reference currently only supports a single kind (GitProvider). - Keeping it optional allows users to omit it while still benefiting from CRD defaulting. - enum: - - GitProvider - type: string name: description: Name of the referent. - minLength: 1 type: string required: - name type: object + x-kubernetes-validations: + - message: spec.providerRef.name must not be empty + rule: self.name != '' prune: description: |- Prune controls which deletion paths may remove documents from this target's folder: an diff --git a/config/crd/bases/configbutler.ai_watchrules.yaml b/config/crd/bases/configbutler.ai_watchrules.yaml index 6b438ed7..762dc52a 100644 --- a/config/crd/bases/configbutler.ai_watchrules.yaml +++ b/config/crd/bases/configbutler.ai_watchrules.yaml @@ -178,32 +178,18 @@ spec: minItems: 1 type: array targetRef: - description: |- - TargetRef references the GitTarget to use. - Must be in the same namespace. + description: TargetRef names the GitTarget this rule feeds, in this + WatchRule's own namespace. properties: - group: - default: configbutler.ai - description: API Group of the referent. - enum: - - configbutler.ai - type: string - kind: - default: GitTarget - description: |- - Kind of the referent. - Optional because this reference currently only supports a single kind (GitTarget). - Keeping it optional allows users to omit it while still benefiting from CRD defaulting. - enum: - - GitTarget - type: string name: description: Name of the referent. - minLength: 1 type: string required: - name type: object + x-kubernetes-validations: + - message: spec.targetRef.name must not be empty + rule: self.name != '' required: - rules - targetRef diff --git a/internal/authz/clusterprovider_admission_test.go b/internal/authz/clusterprovider_admission_test.go index 98aa4697..a74242e6 100644 --- a/internal/authz/clusterprovider_admission_test.go +++ b/internal/authz/clusterprovider_admission_test.go @@ -7,6 +7,7 @@ import ( "errors" "testing" + "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -36,7 +37,7 @@ func targetIn(providerName string) *configv1alpha3.GitTarget { ObjectMeta: metav1.ObjectMeta{Name: "mirror", Namespace: testNS}, } if providerName != "" { - t.Spec.ClusterProviderRef = &configv1alpha3.ClusterProviderReference{Name: providerName} + t.Spec.ClusterProviderRef = &meta.LocalObjectReference{Name: providerName} } return t } diff --git a/internal/authz/source_namespace_test.go b/internal/authz/source_namespace_test.go index 1d8a2e9e..2d226bde 100644 --- a/internal/authz/source_namespace_test.go +++ b/internal/authz/source_namespace_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -43,8 +44,8 @@ func snTarget() *configv1alpha3.GitTarget { return &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: snTargetName, Namespace: snTenantNS}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: configv1alpha3.GitProviderReference{Name: "acme-git"}, - ClusterProviderRef: &configv1alpha3.ClusterProviderReference{Name: snProvider}, + ProviderRef: meta.LocalObjectReference{Name: "acme-git"}, + ClusterProviderRef: &meta.LocalObjectReference{Name: snProvider}, Branch: "main", Path: "tenants/acme", }, @@ -62,7 +63,7 @@ func snRule(sourceNamespaces ...string) *configv1alpha3.WatchRule { return &configv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: snRuleName, Namespace: snTenantNS}, Spec: configv1alpha3.WatchRuleSpec{ - TargetRef: configv1alpha3.LocalTargetReference{Name: snTargetName}, + TargetRef: meta.LocalObjectReference{Name: snTargetName}, Rules: items, }, } diff --git a/internal/controller/clusterwatchrule_admission_test.go b/internal/controller/clusterwatchrule_admission_test.go index 08b41a69..dca0bd1b 100644 --- a/internal/controller/clusterwatchrule_admission_test.go +++ b/internal/controller/clusterwatchrule_admission_test.go @@ -7,6 +7,7 @@ import ( "errors" "testing" + meta "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -97,8 +98,8 @@ func cwaGitTarget() *configbutleraiv1alpha3.GitTarget { return &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: cwaTargetName, Namespace: cwaTargetNS}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{Name: "git"}, - ClusterProviderRef: &configbutleraiv1alpha3.ClusterProviderReference{Name: cwaProviderName}, + ProviderRef: meta.LocalObjectReference{Name: "git"}, + ClusterProviderRef: &meta.LocalObjectReference{Name: cwaProviderName}, Branch: "main", Path: "clusters/prod", }, @@ -122,9 +123,7 @@ func cwaClusterWatchRule() *configbutleraiv1alpha3.ClusterWatchRule { return &configbutleraiv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: cwaRuleName, Generation: 1}, Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ - TargetRef: configbutleraiv1alpha3.NamespacedTargetReference{ - Kind: "GitTarget", Name: cwaTargetName, Namespace: cwaTargetNS, - }, + TargetRef: meta.NamespacedObjectReference{Name: cwaTargetName, Namespace: cwaTargetNS}, Rules: []configbutleraiv1alpha3.ClusterResourceRule{{ Resources: []string{"customresourcedefinitions"}, APIGroups: []string{"apiextensions.k8s.io"}, @@ -435,7 +434,7 @@ func TestClusterProviderToClusterWatchRules(t *testing.T) { elsewhere := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "other-mirror", Namespace: "team-b"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ClusterProviderRef: &configbutleraiv1alpha3.ClusterProviderReference{Name: "prod-us-1"}, + ClusterProviderRef: &meta.LocalObjectReference{Name: "prod-us-1"}, }, } diff --git a/internal/controller/clusterwatchrule_controller.go b/internal/controller/clusterwatchrule_controller.go index 230502b2..a2e48b21 100644 --- a/internal/controller/clusterwatchrule_controller.go +++ b/internal/controller/clusterwatchrule_controller.go @@ -44,10 +44,6 @@ const ( // re-exported from internal/watch, where the shared compile path both bootstrap and this // reconciler call decides it, so the two can never drift. ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized = watch.ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized - - // ClusterWatchRuleReasonScopeNotSupported is the terminal reason for a STORED ClusterWatchRule - // that still selects namespaced resources through the removed scope choice. - ClusterWatchRuleReasonScopeNotSupported = watch.ClusterWatchRuleReasonScopeNotSupported ) // ClusterWatchRuleReconciler reconciles a ClusterWatchRule object. diff --git a/internal/controller/clusterwatchrule_controller_test.go b/internal/controller/clusterwatchrule_controller_test.go index fffa2e95..68be0e62 100644 --- a/internal/controller/clusterwatchrule_controller_test.go +++ b/internal/controller/clusterwatchrule_controller_test.go @@ -5,6 +5,7 @@ package controller import ( "context" + meta "github.com/fluxcd/pkg/apis/meta" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -23,13 +24,11 @@ var _ = Describe("ClusterWatchRule Controller", func() { Name: "invalid-subresource-cluster-rule", }, Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ - TargetRef: configbutleraiv1alpha3.NamespacedTargetReference{ - Kind: "GitTarget", + TargetRef: meta.NamespacedObjectReference{ Name: "target", Namespace: "default", }, Rules: []configbutleraiv1alpha3.ClusterResourceRule{{ - Scope: configbutleraiv1alpha3.ResourceScopeNamespaced, Resources: []string{"pods/*"}, }}, }, @@ -72,14 +71,12 @@ var _ = Describe("ClusterWatchRule Controller", func() { Name: "missing-target-rule", }, Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ - TargetRef: configbutleraiv1alpha3.NamespacedTargetReference{ - Kind: "GitTarget", + TargetRef: meta.NamespacedObjectReference{ Name: "nonexistent-target", Namespace: "default", }, Rules: []configbutleraiv1alpha3.ClusterResourceRule{ { - Scope: configbutleraiv1alpha3.ResourceScopeCluster, Resources: []string{"nodes"}, }, }, diff --git a/internal/controller/commitrequest_controller_test.go b/internal/controller/commitrequest_controller_test.go index 17d97e42..911408ab 100644 --- a/internal/controller/commitrequest_controller_test.go +++ b/internal/controller/commitrequest_controller_test.go @@ -5,6 +5,7 @@ package controller import ( "time" + meta "github.com/fluxcd/pkg/apis/meta" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" apimeta "k8s.io/apimachinery/pkg/api/meta" @@ -29,7 +30,7 @@ var _ = Describe("CommitRequest controller", func() { Namespace: namespace, }, Spec: configbutleraiv1alpha3.CommitRequestSpec{ - TargetRef: configbutleraiv1alpha3.LocalTargetReference{ + TargetRef: meta.LocalObjectReference{ Name: "team-a-config", }, Message: "increase checkout API memory", @@ -57,7 +58,7 @@ var _ = Describe("CommitRequest controller", func() { Namespace: namespace, }, Spec: configbutleraiv1alpha3.CommitRequestSpec{ - TargetRef: configbutleraiv1alpha3.LocalTargetReference{ + TargetRef: meta.LocalObjectReference{ Name: "team-a-config", }, }, diff --git a/internal/controller/commitrequest_controller_unit_test.go b/internal/controller/commitrequest_controller_unit_test.go index 9b21fb58..0ac65655 100644 --- a/internal/controller/commitrequest_controller_unit_test.go +++ b/internal/controller/commitrequest_controller_unit_test.go @@ -9,6 +9,7 @@ import ( "testing" "unicode/utf8" + meta "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -72,7 +73,7 @@ func newCommitRequest(name string) *configv1alpha3.CommitRequest { UID: types.UID("uid-" + name), }, Spec: configv1alpha3.CommitRequestSpec{ - TargetRef: configv1alpha3.LocalTargetReference{Name: "team-a-config"}, + TargetRef: meta.LocalObjectReference{Name: "team-a-config"}, Message: "save: " + name, }, } diff --git a/internal/controller/dependency_watches_test.go b/internal/controller/dependency_watches_test.go index 740e194e..80a6dbc4 100644 --- a/internal/controller/dependency_watches_test.go +++ b/internal/controller/dependency_watches_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + meta "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -65,7 +66,7 @@ func gitTarget(name, namespace, providerName string) *configbutleraiv1alpha3.Git return &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{Name: providerName}, + ProviderRef: meta.LocalObjectReference{Name: providerName}, Branch: "main", }, } @@ -75,7 +76,7 @@ func watchRule(name, namespace, targetName string) *configbutleraiv1alpha3.Watch return &configbutleraiv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, Spec: configbutleraiv1alpha3.WatchRuleSpec{ - TargetRef: configbutleraiv1alpha3.LocalTargetReference{Name: targetName}, + TargetRef: meta.LocalObjectReference{Name: targetName}, }, } } @@ -84,7 +85,7 @@ func clusterWatchRule(name, targetName, targetNamespace string) *configbutleraiv return &configbutleraiv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: name}, Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ - TargetRef: configbutleraiv1alpha3.NamespacedTargetReference{ + TargetRef: meta.NamespacedObjectReference{ Name: targetName, Namespace: targetNamespace, }, diff --git a/internal/controller/gitprovider_controller_test.go b/internal/controller/gitprovider_controller_test.go index 28e1fbad..177babf2 100644 --- a/internal/controller/gitprovider_controller_test.go +++ b/internal/controller/gitprovider_controller_test.go @@ -10,6 +10,7 @@ import ( "encoding/pem" "time" + meta "github.com/fluxcd/pkg/apis/meta" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" @@ -316,7 +317,7 @@ var _ = Describe("GitProvider Controller", func() { Spec: configbutleraiv1alpha3.GitProviderSpec{ URL: "git@github.com:test/repo.git", AllowedBranches: []string{"main"}, - SecretRef: &configbutleraiv1alpha3.LocalSecretReference{ + SecretRef: &meta.LocalObjectReference{ Name: "nonexistent-secret", }, }, @@ -377,7 +378,7 @@ var _ = Describe("GitProvider Controller", func() { Spec: configbutleraiv1alpha3.GitProviderSpec{ URL: "git@github.com:test/repo.git", AllowedBranches: []string{"main"}, - SecretRef: &configbutleraiv1alpha3.LocalSecretReference{ + SecretRef: &meta.LocalObjectReference{ Name: "malformed-secret", }, }, @@ -439,7 +440,7 @@ var _ = Describe("GitProvider Controller", func() { AllowedBranches: []string{"main"}, Commit: &configbutleraiv1alpha3.CommitSpec{ Signing: &configbutleraiv1alpha3.CommitSigningSpec{ - SecretRef: configbutleraiv1alpha3.LocalSecretReference{ + SecretRef: meta.LocalObjectReference{ Name: "signing-secret", }, }, diff --git a/internal/controller/gitprovider_controller_unit_test.go b/internal/controller/gitprovider_controller_unit_test.go index 85a29ebc..2938dc9d 100644 --- a/internal/controller/gitprovider_controller_unit_test.go +++ b/internal/controller/gitprovider_controller_unit_test.go @@ -6,6 +6,7 @@ import ( "context" "testing" + meta "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -29,7 +30,7 @@ func TestValidateCommitConfiguration_SigningEnabled(t *testing.T) { Spec: configbutleraiv1alpha3.GitProviderSpec{ Commit: &configbutleraiv1alpha3.CommitSpec{ Signing: &configbutleraiv1alpha3.CommitSigningSpec{ - SecretRef: configbutleraiv1alpha3.LocalSecretReference{ + SecretRef: meta.LocalObjectReference{ Name: "signing-secret", }, }, @@ -53,7 +54,7 @@ func TestEnsureSigningKey_GeneratesMissingSecret(t *testing.T) { Spec: configbutleraiv1alpha3.GitProviderSpec{ Commit: &configbutleraiv1alpha3.CommitSpec{ Signing: &configbutleraiv1alpha3.CommitSigningSpec{ - SecretRef: configbutleraiv1alpha3.LocalSecretReference{Name: "signing-secret"}, + SecretRef: meta.LocalObjectReference{Name: "signing-secret"}, GenerateWhenMissing: true, }, }, @@ -95,7 +96,7 @@ func TestEnsureSigningKey_UsesExistingKey(t *testing.T) { Spec: configbutleraiv1alpha3.GitProviderSpec{ Commit: &configbutleraiv1alpha3.CommitSpec{ Signing: &configbutleraiv1alpha3.CommitSigningSpec{ - SecretRef: configbutleraiv1alpha3.LocalSecretReference{Name: "signing-secret"}, + SecretRef: meta.LocalObjectReference{Name: "signing-secret"}, }, }, }, @@ -125,7 +126,7 @@ func TestEnsureSigningKey_MissingSecretWithoutGeneration(t *testing.T) { Spec: configbutleraiv1alpha3.GitProviderSpec{ Commit: &configbutleraiv1alpha3.CommitSpec{ Signing: &configbutleraiv1alpha3.CommitSigningSpec{ - SecretRef: configbutleraiv1alpha3.LocalSecretReference{Name: "signing-secret"}, + SecretRef: meta.LocalObjectReference{Name: "signing-secret"}, }, }, }, diff --git a/internal/controller/gittarget_controller_test.go b/internal/controller/gittarget_controller_test.go index fab4edc9..2bb61b0b 100644 --- a/internal/controller/gittarget_controller_test.go +++ b/internal/controller/gittarget_controller_test.go @@ -8,6 +8,7 @@ import ( "time" "filippo.io/age" + meta "github.com/fluxcd/pkg/apis/meta" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" corev1 "k8s.io/api/core/v1" @@ -33,7 +34,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: "missing-provider", }, Branch: "main", @@ -99,7 +100,7 @@ var _ = Describe("GitTarget Controller Security", func() { Spec: configbutleraiv1alpha3.GitProviderSpec{ URL: "https://github.com/test-org/test-repo.git", AllowedBranches: []string{"main", "develop"}, - SecretRef: &configbutleraiv1alpha3.LocalSecretReference{ + SecretRef: &meta.LocalObjectReference{ Name: "test-secret", // Dummy secret }, }, @@ -114,7 +115,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: "test-provider-security", }, Branch: unauthorizedBranch, @@ -191,7 +192,7 @@ var _ = Describe("GitTarget Controller Security", func() { Spec: configbutleraiv1alpha3.GitProviderSpec{ URL: "https://github.com/test-org/test-repo.git", AllowedBranches: []string{"main", "feature/*"}, - SecretRef: &configbutleraiv1alpha3.LocalSecretReference{ + SecretRef: &meta.LocalObjectReference{ Name: "test-secret", }, }, @@ -205,7 +206,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: "test-provider-allowed", }, Branch: "feature/allowed", @@ -276,7 +277,7 @@ var _ = Describe("GitTarget Controller Security", func() { Spec: configbutleraiv1alpha3.GitProviderSpec{ URL: "https://github.com/test-org/test-repo.git", AllowedBranches: []string{"main"}, - SecretRef: &configbutleraiv1alpha3.LocalSecretReference{ + SecretRef: &meta.LocalObjectReference{ Name: "test-secret", }, }, @@ -289,7 +290,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: "test-provider-two-axis", }, Branch: "main", @@ -335,7 +336,7 @@ var _ = Describe("GitTarget Controller Security", func() { "feature/*", "release/v*", }, - SecretRef: &configbutleraiv1alpha3.LocalSecretReference{ + SecretRef: &meta.LocalObjectReference{ Name: "test-secret", }, }, @@ -367,7 +368,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: "test-provider-glob", }, Branch: tc.branch, @@ -448,7 +449,7 @@ var _ = Describe("GitTarget Controller Security", func() { Spec: configbutleraiv1alpha3.GitProviderSpec{ URL: "https://github.com/test-org/test-repo.git", AllowedBranches: []string{"main", "develop"}, - SecretRef: &configbutleraiv1alpha3.LocalSecretReference{ + SecretRef: &meta.LocalObjectReference{ Name: "test-secret", }, }, @@ -462,7 +463,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: "test-provider-conflict", }, Branch: "main", @@ -497,7 +498,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: "test-provider-conflict", }, Branch: "main", @@ -567,7 +568,7 @@ var _ = Describe("GitTarget Controller Security", func() { Spec: configbutleraiv1alpha3.GitProviderSpec{ URL: "https://github.com/test-org/test-repo.git", AllowedBranches: []string{"main"}, - SecretRef: &configbutleraiv1alpha3.LocalSecretReference{ + SecretRef: &meta.LocalObjectReference{ Name: "test-secret", }, }, @@ -581,7 +582,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: "test-provider-no-conflict", }, Branch: "main", @@ -597,7 +598,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: "test-provider-no-conflict", }, Branch: "main", @@ -655,7 +656,7 @@ var _ = Describe("GitTarget Controller Security", func() { Spec: configbutleraiv1alpha3.GitProviderSpec{ URL: "https://github.com/test-org/test-repo.git", AllowedBranches: []string{"main"}, - SecretRef: &configbutleraiv1alpha3.LocalSecretReference{ + SecretRef: &meta.LocalObjectReference{ Name: "test-secret", }, }, @@ -669,7 +670,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: "test-provider-nested", }, Branch: "main", @@ -703,7 +704,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: "test-provider-nested", }, Branch: "main", @@ -783,14 +784,14 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: "test-provider-generate-enc-secret", }, Branch: "main", Path: "test-path", Encryption: &configbutleraiv1alpha3.EncryptionSpec{ Provider: "sops", - SecretRef: configbutleraiv1alpha3.LocalSecretReference{ + SecretRef: meta.LocalObjectReference{ Name: "generated-sops-age-key", }, Age: &configbutleraiv1alpha3.AgeEncryptionSpec{ @@ -848,14 +849,14 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: "test-provider-no-generate-enc-secret", }, Branch: "main", Path: "test-path", Encryption: &configbutleraiv1alpha3.EncryptionSpec{ Provider: "sops", - SecretRef: configbutleraiv1alpha3.LocalSecretReference{ + SecretRef: meta.LocalObjectReference{ Name: "missing-sops-age-key", }, Age: &configbutleraiv1alpha3.AgeEncryptionSpec{ @@ -925,14 +926,14 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: "test-provider-no-enc-recipients", }, Branch: "main", Path: "test-path", Encryption: &configbutleraiv1alpha3.EncryptionSpec{ Provider: "sops", - SecretRef: configbutleraiv1alpha3.LocalSecretReference{ + SecretRef: meta.LocalObjectReference{ Name: "unused-encryption-secret", }, Age: &configbutleraiv1alpha3.AgeEncryptionSpec{ @@ -1018,14 +1019,14 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: "test-provider-update-enc-secret", }, Branch: "main", Path: "test-path", Encryption: &configbutleraiv1alpha3.EncryptionSpec{ Provider: "sops", - SecretRef: configbutleraiv1alpha3.LocalSecretReference{ + SecretRef: meta.LocalObjectReference{ Name: "existing-sops-secret", }, Age: &configbutleraiv1alpha3.AgeEncryptionSpec{ @@ -1078,14 +1079,14 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: "test-provider-recreate-enc-secret", }, Branch: "main", Path: "test-path", Encryption: &configbutleraiv1alpha3.EncryptionSpec{ Provider: "sops", - SecretRef: configbutleraiv1alpha3.LocalSecretReference{ + SecretRef: meta.LocalObjectReference{ Name: "recreated-sops-age-key", }, Age: &configbutleraiv1alpha3.AgeEncryptionSpec{ @@ -1173,14 +1174,14 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: "test-provider-existing-agekey", }, Branch: "main", Path: "test-path", Encryption: &configbutleraiv1alpha3.EncryptionSpec{ Provider: "sops", - SecretRef: configbutleraiv1alpha3.LocalSecretReference{ + SecretRef: meta.LocalObjectReference{ Name: "existing-agekey-secret", }, Age: &configbutleraiv1alpha3.AgeEncryptionSpec{ diff --git a/internal/controller/gittarget_controller_unit_test.go b/internal/controller/gittarget_controller_unit_test.go index 3b08c78c..cfff2219 100644 --- a/internal/controller/gittarget_controller_unit_test.go +++ b/internal/controller/gittarget_controller_unit_test.go @@ -7,6 +7,7 @@ import ( "errors" "testing" + meta "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -93,7 +94,7 @@ func TestCheckForConflicts_ListErrorFailsClosed(t *testing.T) { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "target-a", Namespace: "default"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{Name: "provider-a"}, + ProviderRef: meta.LocalObjectReference{Name: "provider-a"}, Branch: "main", Path: "apps", }, diff --git a/internal/controller/gittarget_immutability_test.go b/internal/controller/gittarget_immutability_test.go index 65cf1b35..4344485a 100644 --- a/internal/controller/gittarget_immutability_test.go +++ b/internal/controller/gittarget_immutability_test.go @@ -6,6 +6,7 @@ import ( "context" "time" + meta "github.com/fluxcd/pkg/apis/meta" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -33,7 +34,7 @@ var _ = Describe("GitTarget Destination Immutability", func() { gitTarget := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: key.Name, Namespace: key.Namespace}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{Name: "prov-a"}, + ProviderRef: meta.LocalObjectReference{Name: "prov-a"}, Branch: "main", Path: "apps", }, @@ -81,7 +82,7 @@ var _ = Describe("GitTarget Destination Immutability", func() { base := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: key.Name, Namespace: key.Namespace}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{Name: "prov-a"}, + ProviderRef: meta.LocalObjectReference{Name: "prov-a"}, Branch: "main", }, } diff --git a/internal/controller/gittarget_placement_validation_test.go b/internal/controller/gittarget_placement_validation_test.go index 4b9ca8dd..80c55e3b 100644 --- a/internal/controller/gittarget_placement_validation_test.go +++ b/internal/controller/gittarget_placement_validation_test.go @@ -6,6 +6,7 @@ import ( "context" "testing" + meta "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" apimeta "k8s.io/apimachinery/pkg/api/meta" @@ -171,7 +172,7 @@ func TestEvaluateValidatedGate_InvalidPlacementPolicy(t *testing.T) { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "target-a", Namespace: ns}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{Name: "provider-a"}, + ProviderRef: meta.LocalObjectReference{Name: "provider-a"}, Branch: "main", Path: "apps", Placement: &configbutleraiv1alpha3.GitTargetPlacementSpec{ diff --git a/internal/controller/gittarget_source_cluster_test.go b/internal/controller/gittarget_source_cluster_test.go index 29b3f2c8..8f368a28 100644 --- a/internal/controller/gittarget_source_cluster_test.go +++ b/internal/controller/gittarget_source_cluster_test.go @@ -7,6 +7,7 @@ import ( "errors" "testing" + meta "github.com/fluxcd/pkg/apis/meta" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -126,7 +127,7 @@ func TestCheckSourceAuthorization(t *testing.T) { return &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: "team-a"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ClusterProviderRef: &configbutleraiv1alpha3.ClusterProviderReference{Name: providerName}, + ClusterProviderRef: &meta.LocalObjectReference{Name: providerName}, }, } } @@ -225,7 +226,7 @@ func TestCheckSourceAuthorization_ReadErrorsRequeue(t *testing.T) { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: "team-a"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ClusterProviderRef: &configbutleraiv1alpha3.ClusterProviderReference{Name: "prod-eu-1"}, + ClusterProviderRef: &meta.LocalObjectReference{Name: "prod-eu-1"}, }, } provider := &configbutleraiv1alpha3.ClusterProvider{ @@ -334,10 +335,10 @@ func TestReconcile_UnauthorizedNamespaceStartsNoWatch(t *testing.T) { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: ns, UID: "gt-uid"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{Name: "gp"}, + ProviderRef: meta.LocalObjectReference{Name: "gp"}, Branch: "main", Path: "apps", - ClusterProviderRef: &configbutleraiv1alpha3.ClusterProviderReference{Name: providerName}, + ClusterProviderRef: &meta.LocalObjectReference{Name: providerName}, }, } @@ -484,7 +485,7 @@ func TestGitProviderReadiness_AllScenarios(t *testing.T) { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: "team-a"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{Name: "prov"}, + ProviderRef: meta.LocalObjectReference{Name: "prov"}, }, } got := r.gitProviderReadiness(context.Background(), target, "team-a") @@ -509,7 +510,7 @@ func TestResolveSourceClusterProvider(t *testing.T) { return &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: "tenant"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ClusterProviderRef: &configbutleraiv1alpha3.ClusterProviderReference{Name: providerName}, + ClusterProviderRef: &meta.LocalObjectReference{Name: providerName}, }, } } diff --git a/internal/controller/stored_superseded_value_status_test.go b/internal/controller/stored_superseded_value_status_test.go index 89beb2c2..4e68adf0 100644 --- a/internal/controller/stored_superseded_value_status_test.go +++ b/internal/controller/stored_superseded_value_status_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + meta "github.com/fluxcd/pkg/apis/meta" apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -18,16 +19,23 @@ import ( configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" ) -// This is the gate the whole loud-rejection pattern rests on, and it is a claim about the -// APISERVER rather than about our code — so it is pinned by execution rather than by reading. +// This is a claim about the APISERVER rather than about our code, so it is pinned by execution +// rather than by reading: a status update onto an object whose STORED spec no longer validates +// against its own CRD is ACCEPTED. // -// The pattern keeps a superseded field in the schema and narrows it so that re-applying a stored -// value FAILS (ClusterWatchRule.spec.rules[].scope: Namespaced set the precedent; GitTarget's -// allowedSourceNamespaces and GitProvider's push/commit.message now follow it). It only works if -// the controller can still write a status update onto an object whose STORED spec no longer -// validates: the one object that most needs to explain why it was refused is the object carrying -// the refused value. If the apiserver re-validated the whole object on a status-subresource -// update, that write would be rejected 422 and the refusal would be unreportable. +// It is the precondition for the "loud rejection" pattern — keep a superseded field in the schema +// and narrow it so re-applying a stored value FAILS — because that pattern only works if the one +// object that most needs to explain why it was refused can still carry the explanation. If the +// apiserver re-validated the whole object on a status-subresource update, that write would be +// rejected 422 and the refusal would be unreportable. +// +// No field in this API uses the pattern today: ClusterWatchRule.spec.rules[].scope was the last +// one and 0.43.0 removed it, so every superseded field is now deleted outright with +// docs/UPGRADING.md carrying the migration. The property is measured anyway, because it is what +// makes the pattern available the next time a field has to go, and the two strategies are priced +// against each other in docs/facts/crd-upgrade-strategies.md. The narrowing below is therefore +// synthetic: it edits the SERVED schema of a field that does exist, which is exactly the shape a +// real narrowing would take. // // The worry was that CRD Validation Ratcheting (beta and default-on from 1.30, GA in 1.33) was // doing the work, and would therefore vanish on an older cluster or with the gate off. It is not: @@ -35,9 +43,6 @@ import ( // explicitly on AND explicitly off, and on the version this module builds against — same answer // every time. (On 1.33+ the gate cannot be turned off: kube-apiserver refuses to start on // `CRDValidationRatcheting=false`, which is why this test does not try.) -// -// If this test ever fails, the fallback is in docs/design/gittarget-api-wave.md: widen the enum -// back and rely on the compile-path refusal plus a loud Stalled condition. func TestStoredSupersededValue_StatusUpdateIsAccepted(t *testing.T) { env := &envtest.Environment{ CRDDirectoryPaths: []string{"../../config/crd/bases"}, @@ -65,65 +70,64 @@ func TestStoredSupersededValue_StatusUpdateIsAccepted(t *testing.T) { } ctx := context.Background() - // Widen the enum so a "stored" object carrying the superseded value can exist at all. This is - // how an object written by an EARLIER release looks to the current schema. - setScopeEnum(ctx, t, c, "Cluster", "Namespaced") + // Store an object under the schema as it ships. This is how an object written by an EARLIER + // release looks before the field it carries is narrowed under it. stored := &configbutleraiv1alpha3.ClusterWatchRule{ - ObjectMeta: metav1.ObjectMeta{Name: "stored-namespaced-scope"}, + ObjectMeta: metav1.ObjectMeta{Name: "stored-superseded-value"}, Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ - TargetRef: configbutleraiv1alpha3.NamespacedTargetReference{Name: "t", Namespace: "default"}, + TargetRef: meta.NamespacedObjectReference{Name: "t", Namespace: "default"}, Rules: []configbutleraiv1alpha3.ClusterResourceRule{{ Resources: []string{"configmaps"}, - Scope: configbutleraiv1alpha3.ResourceScopeNamespaced, }}, }, } createEventually(ctx, t, c, stored) - // Narrow it back to what ships. The object in etcd now no longer validates against its own CRD. - setScopeEnum(ctx, t, c, "Cluster") + // Narrow the served schema so the stored value is no longer admissible. The object in etcd now + // no longer validates against its own CRD. + setResourcesEnum(ctx, t, c, "nodes") requireCreateRejected(ctx, t, c) var refused configbutleraiv1alpha3.ClusterWatchRule if err := c.Get(ctx, client.ObjectKey{Name: stored.Name}, &refused); err != nil { t.Fatalf("get stored object: %v", err) } - //nolint:staticcheck // reading the deprecated field is the point: the value must have survived. - if got := refused.Spec.Rules[0].Scope; got != configbutleraiv1alpha3.ResourceScopeNamespaced { - t.Fatalf("stored spec value did not survive the narrowing: scope = %q, want %q", - got, configbutleraiv1alpha3.ResourceScopeNamespaced) + if got := refused.Spec.Rules[0].Resources[0]; got != "configmaps" { + t.Fatalf("stored spec value did not survive the narrowing: resources[0] = %q, want %q", + got, "configmaps") } refused.Status.ObservedGeneration = refused.Generation refused.Status.Conditions = []metav1.Condition{{ Type: "Stalled", Status: metav1.ConditionTrue, - Reason: "ClusterScopeOnly", - Message: "spec.rules[].scope: Namespaced is no longer supported", + Reason: "ResourceNotSupported", + Message: "spec.rules[].resources: configmaps is no longer supported", LastTransitionTime: metav1.Now(), ObservedGeneration: refused.Generation, }} if err := c.Status().Update(ctx, &refused); err != nil { t.Fatalf("status update on an object whose STORED spec no longer validates was rejected: %v\n"+ - "The loud-rejection pattern is unsafe here. Fall back to widening the enum and reporting "+ - "the refusal from the compile path (docs/design/gittarget-api-wave.md).", err) + "The loud-rejection pattern is unsafe, and a future field removal cannot rely on it "+ + "(docs/facts/crd-upgrade-strategies.md).", err) } } -// setScopeEnum rewrites the served enum for ClusterWatchRule spec.rules[].scope. -func setScopeEnum(ctx context.Context, t *testing.T, c client.Client, values ...string) { +// setResourcesEnum narrows the served schema for ClusterWatchRule spec.rules[].resources to the +// given values, standing in for a real field narrowing. +func setResourcesEnum(ctx context.Context, t *testing.T, c client.Client, values ...string) { t.Helper() var crd apiextv1.CustomResourceDefinition if err := c.Get(ctx, client.ObjectKey{Name: "clusterwatchrules.configbutler.ai"}, &crd); err != nil { t.Fatalf("get CRD: %v", err) } items := crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"].Properties["rules"].Items.Schema - scope := items.Properties["scope"] - scope.Enum = nil + resources := items.Properties["resources"] + resources.Items.Schema.Enum = nil for _, v := range values { - scope.Enum = append(scope.Enum, apiextv1.JSON{Raw: fmt.Appendf(nil, "%q", v)}) + resources.Items.Schema.Enum = append(resources.Items.Schema.Enum, apiextv1.JSON{Raw: fmt.Appendf(nil, "%q", v)}) } - items.Properties["scope"] = scope + items.Properties["resources"] = resources if err := c.Update(ctx, &crd); err != nil { t.Fatalf("update CRD schema: %v", err) } @@ -142,7 +146,7 @@ func createEventually(ctx context.Context, t *testing.T, c client.Client, obj cl obj.SetResourceVersion("") time.Sleep(100 * time.Millisecond) } - t.Fatalf("create never succeeded under the widened schema: %v", err) + t.Fatalf("create never succeeded under the shipped schema: %v", err) } // requireCreateRejected blocks until the NARROWED schema is the one being served, proven by a @@ -155,10 +159,9 @@ func requireCreateRejected(ctx context.Context, t *testing.T, c client.Client) { probe := &configbutleraiv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("narrowing-probe-%d", i)}, Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ - TargetRef: configbutleraiv1alpha3.NamespacedTargetReference{Name: "t", Namespace: "default"}, + TargetRef: meta.NamespacedObjectReference{Name: "t", Namespace: "default"}, Rules: []configbutleraiv1alpha3.ClusterResourceRule{{ Resources: []string{"configmaps"}, - Scope: configbutleraiv1alpha3.ResourceScopeNamespaced, }}, }, } diff --git a/internal/controller/superseded_fields_admission_test.go b/internal/controller/superseded_fields_admission_test.go index 8058847d..d3995f07 100644 --- a/internal/controller/superseded_fields_admission_test.go +++ b/internal/controller/superseded_fields_admission_test.go @@ -5,49 +5,56 @@ package controller import ( "context" + meta "github.com/fluxcd/pkg/apis/meta" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" ) -// ClusterWatchRule.spec.rules[].scope was NARROWED rather than deleted, and this is the guard that -// it still is. It runs against the GENERATED CRDs, so it fails the moment somebody "cleans up" the -// field out of the Go types. +// Every field this project has superseded is now DELETED outright rather than retained and +// narrowed, and these specs run against the GENERATED CRDs so they say what that actually means. // // Deleting a field is the silent option: CRD pruning happens on write, so once the schema drops a // field, re-applying a legacy manifest is ACCEPTED with the value pruned away — no error anywhere. -// A retained-but-narrowed field turns that into an apply-time rejection an operator cannot miss. +// The first spec pins exactly that, because it is the behaviour docs/UPGRADING.md's pre-upgrade +// inventory exists to compensate for: if an apply of a legacy manifest ever started failing +// instead, the migration guidance would be wrong in the other direction. // -// The GitTarget/GitProvider/ClusterProvider fields this release removed took the OTHER option -// deliberately: they are deleted outright, so an old manifest is accepted and pruned, and -// docs/UPGRADING.md carries a pre-upgrade inventory instead. That trade is priced there, and the -// last spec below is what stops the replacement spellings regressing unnoticed. +// The remaining specs pin the replacement spellings, which is what stops a typo in one of them +// surfacing as a silently pruned field and a mirror behaving as if unconfigured. var _ = Describe("Superseded source-scope fields", func() { - It("rejects ClusterWatchRule scope: Namespaced at admission", func() { + It("accepts and PRUNES a legacy manifest that still sets rules[].scope", func() { ctx := context.Background() - rule := &configbutleraiv1alpha3.ClusterWatchRule{ - ObjectMeta: metav1.ObjectMeta{Name: "legacy-namespaced-scope"}, - Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ - TargetRef: configbutleraiv1alpha3.NamespacedTargetReference{ - Name: "any-target", Namespace: "default", - }, - Rules: []configbutleraiv1alpha3.ClusterResourceRule{{ - Resources: []string{"configmaps"}, - Scope: configbutleraiv1alpha3.ResourceScopeNamespaced, + // Applied as unstructured, because the Go types no longer have the field to set. This is + // how a manifest written for 0.42 or earlier reaches a 0.43 apiserver. + legacy := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "configbutler.ai/v1alpha3", + "kind": "ClusterWatchRule", + "metadata": map[string]any{"name": "legacy-namespaced-scope"}, + "spec": map[string]any{ + "targetRef": map[string]any{"name": "any-target", "namespace": "default"}, + "rules": []any{map[string]any{ + "resources": []any{"configmaps"}, + "scope": "Namespaced", }}, }, - } + }} - err := k8sClient.Create(ctx, rule) + Expect(k8sClient.Create(ctx, legacy)).To(Succeed(), + "a removed field is pruned on write, so a legacy manifest applies cleanly") + DeferCleanup(func() { _ = k8sClient.Delete(ctx, legacy) }) - Expect(err).To(HaveOccurred(), - "a legacy namespaced ClusterWatchRule must FAIL to apply, never be silently pruned") - Expect(err.Error()).To(ContainSubstring("scope")) + rules, _, err := unstructured.NestedSlice(legacy.Object, "spec", "rules") + Expect(err).NotTo(HaveOccurred()) + Expect(rules).To(HaveLen(1)) + Expect(rules[0]).NotTo(HaveKey("scope"), + "the value is dropped silently, which is why UPGRADING.md asks for an inventory first") }) It("accepts the replacement spellings on every kind the wave touched", func() { @@ -74,7 +81,7 @@ var _ = Describe("Superseded source-scope fields", func() { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "renamed-commit", Namespace: "default"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{Name: "any-provider"}, + ProviderRef: meta.LocalObjectReference{Name: "any-provider"}, Branch: "main", Path: "clusters/prod", Commit: &configbutleraiv1alpha3.GitTargetCommitSpec{ @@ -102,7 +109,7 @@ var _ = Describe("Superseded source-scope fields", func() { rule := &configbutleraiv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "cluster-only-rule"}, Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ - TargetRef: configbutleraiv1alpha3.NamespacedTargetReference{ + TargetRef: meta.NamespacedObjectReference{ Name: "any-target", Namespace: "default", }, Rules: []configbutleraiv1alpha3.ClusterResourceRule{{ @@ -112,12 +119,9 @@ var _ = Describe("Superseded source-scope fields", func() { }, } - Expect(k8sClient.Create(ctx, rule)).To(Succeed()) + Expect(k8sClient.Create(ctx, rule)).To(Succeed(), + "a ClusterWatchRule names types and nothing else: which scope it watches is the kind") DeferCleanup(func() { _ = k8sClient.Delete(ctx, rule) }) - - //nolint:staticcheck // reading the deprecated field is the point: it must still default. - Expect(rule.Spec.Rules[0].Scope).To(Equal(configbutleraiv1alpha3.ResourceScopeCluster), - "the field is omittable and defaults to Cluster, so a converted manifest need not set it") }) It("accepts rules[].sourceNamespace, including the wildcard", func() { @@ -126,7 +130,7 @@ var _ = Describe("Superseded source-scope fields", func() { rule := &configbutleraiv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "per-item-source", Namespace: "default"}, Spec: configbutleraiv1alpha3.WatchRuleSpec{ - TargetRef: configbutleraiv1alpha3.LocalTargetReference{Name: "any-target"}, + TargetRef: meta.LocalObjectReference{Name: "any-target"}, Rules: []configbutleraiv1alpha3.ResourceRule{ {Resources: []string{"configmaps"}}, {Resources: []string{"secrets"}, SourceNamespace: "repo-config"}, @@ -145,7 +149,7 @@ var _ = Describe("Superseded source-scope fields", func() { rule := &configbutleraiv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "malformed-source", Namespace: "default"}, Spec: configbutleraiv1alpha3.WatchRuleSpec{ - TargetRef: configbutleraiv1alpha3.LocalTargetReference{Name: "any-target"}, + TargetRef: meta.LocalObjectReference{Name: "any-target"}, Rules: []configbutleraiv1alpha3.ResourceRule{{ Resources: []string{"configmaps"}, SourceNamespace: "Not A Namespace", }}, diff --git a/internal/controller/watchrule_controller.go b/internal/controller/watchrule_controller.go index 80d728d6..b6df2b1c 100644 --- a/internal/controller/watchrule_controller.go +++ b/internal/controller/watchrule_controller.go @@ -131,7 +131,7 @@ func (r *WatchRuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // reconcileWatchRuleViaTarget validates and stores a WatchRule that references a GitTarget. // watchRuleGitTarget names the GitTarget a WatchRule writes through. targetRef is a -// LocalTargetReference, so the GitTarget is always in the rule's own namespace. +// meta.LocalObjectReference, so the GitTarget is always in the rule's own namespace. // // It carries no UID, and that is correct rather than an omission: a rule-derived reference has // none to carry, and the watch-plane owner resolves the trigger against the UID the GitTarget @@ -168,7 +168,7 @@ func (r *WatchRuleReconciler) reconcileWatchRuleViaTarget( ready := gitTargetReadyCondition(target) st.set(ConditionTypeGitTargetReady, ready.Status, ready.Reason, ready.Message) - // Resolve the GitProvider named by the target. A GitProviderReference is a + // Resolve the GitProvider named by the target. A meta.LocalObjectReference is a // name-only reference to a GitProvider in the GitTarget's own namespace. providerName := target.Spec.ProviderRef.Name providerNS := target.Namespace // GitProvider is namespace-local to the GitTarget @@ -336,7 +336,7 @@ func (r *WatchRuleReconciler) clusterProviderToWatchRules( return nil } - // A WatchRule's targetRef is a LocalTargetReference, so candidates always live in their + // A WatchRule's targetRef is a meta.LocalObjectReference, so candidates always live in their // GitTarget's own namespace — collect the affected (namespace, target name) pairs. affected := make(map[types.NamespacedName]struct{}, len(targets.Items)) for i := range targets.Items { @@ -372,7 +372,7 @@ func (r *WatchRuleReconciler) clusterProviderToWatchRules( // gitTargetToWatchRules maps a GitTarget event to every WatchRule in the // GitTarget's namespace that references it. WatchRule.spec.targetRef is a -// LocalTargetReference, so candidates only live in the same namespace as the +// meta.LocalObjectReference, so candidates only live in the same namespace as the // GitTarget. func (r *WatchRuleReconciler) gitTargetToWatchRules( ctx context.Context, diff --git a/internal/controller/watchrule_controller_test.go b/internal/controller/watchrule_controller_test.go index 4894a554..73263bb2 100644 --- a/internal/controller/watchrule_controller_test.go +++ b/internal/controller/watchrule_controller_test.go @@ -5,6 +5,7 @@ package controller import ( "context" + meta "github.com/fluxcd/pkg/apis/meta" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/api/errors" @@ -27,8 +28,7 @@ var _ = Describe("WatchRule Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.WatchRuleSpec{ - TargetRef: configbutleraiv1alpha3.LocalTargetReference{ - Kind: "GitTarget", + TargetRef: meta.LocalObjectReference{ Name: "target", }, Rules: []configbutleraiv1alpha3.ResourceRule{{ @@ -64,7 +64,7 @@ var _ = Describe("WatchRule Controller", func() { Spec: configbutleraiv1alpha3.GitProviderSpec{ URL: "https://github.com/test/repo.git", AllowedBranches: []string{"*"}, - SecretRef: &configbutleraiv1alpha3.LocalSecretReference{ + SecretRef: &meta.LocalObjectReference{ Name: "git-credentials", }, }, @@ -78,7 +78,7 @@ var _ = Describe("WatchRule Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: "test-provider", }, Branch: "main", @@ -96,8 +96,7 @@ var _ = Describe("WatchRule Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.WatchRuleSpec{ - TargetRef: configbutleraiv1alpha3.LocalTargetReference{ - Kind: "GitTarget", + TargetRef: meta.LocalObjectReference{ Name: "test-target", }, Rules: []configbutleraiv1alpha3.ResourceRule{ @@ -195,7 +194,7 @@ var _ = Describe("WatchRule Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: "local-provider", }, Branch: "main", @@ -211,8 +210,7 @@ var _ = Describe("WatchRule Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.WatchRuleSpec{ - TargetRef: configbutleraiv1alpha3.LocalTargetReference{ - Kind: "GitTarget", + TargetRef: meta.LocalObjectReference{ Name: "local-target", }, Rules: []configbutleraiv1alpha3.ResourceRule{ diff --git a/internal/controller/watchrule_source_namespace_test.go b/internal/controller/watchrule_source_namespace_test.go index ff5cb3a1..404ca013 100644 --- a/internal/controller/watchrule_source_namespace_test.go +++ b/internal/controller/watchrule_source_namespace_test.go @@ -7,6 +7,7 @@ import ( "errors" "testing" + meta "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -34,8 +35,8 @@ func wrsnGitTarget() *configbutleraiv1alpha3.GitTarget { return &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: wrsnTarget, Namespace: wrsnTenantNS}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: configbutleraiv1alpha3.GitProviderReference{Name: "git"}, - ClusterProviderRef: &configbutleraiv1alpha3.ClusterProviderReference{Name: wrsnProvider}, + ProviderRef: meta.LocalObjectReference{Name: "git"}, + ClusterProviderRef: &meta.LocalObjectReference{Name: wrsnProvider}, Branch: "main", Path: "tenants/acme", }, @@ -71,7 +72,7 @@ func wrsnWatchRule(sourceNamespaces ...string) *configbutleraiv1alpha3.WatchRule return &configbutleraiv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: wrsnRule, Namespace: wrsnTenantNS, Generation: 1}, Spec: configbutleraiv1alpha3.WatchRuleSpec{ - TargetRef: configbutleraiv1alpha3.LocalTargetReference{Name: wrsnTarget}, + TargetRef: meta.LocalObjectReference{Name: wrsnTarget}, Rules: items, }, } diff --git a/internal/git/branch_worker_credread_test.go b/internal/git/branch_worker_credread_test.go index eac0c4f2..a201465b 100644 --- a/internal/git/branch_worker_credread_test.go +++ b/internal/git/branch_worker_credread_test.go @@ -8,6 +8,7 @@ import ( "sync/atomic" "testing" + "github.com/fluxcd/pkg/apis/meta" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -43,7 +44,7 @@ func TestCommitPendingWrites_ResolvesCredentialsOncePerPushCycle(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "test-repo", Namespace: "default"}, Spec: configv1alpha3.GitProviderSpec{ URL: "file://" + remotePath, - SecretRef: &configv1alpha3.LocalSecretReference{Name: credsSecretName}, + SecretRef: &meta.LocalObjectReference{Name: credsSecretName}, }, } // username/password resolves to HTTP basic auth, which the file:// transport ignores — so the diff --git a/internal/git/branch_worker_loop_test.go b/internal/git/branch_worker_loop_test.go index 2e0ca8e0..473b79ef 100644 --- a/internal/git/branch_worker_loop_test.go +++ b/internal/git/branch_worker_loop_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "github.com/fluxcd/pkg/apis/meta" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -24,7 +25,7 @@ func TestCommitWindowFor_DefaultsAndParsing(t *testing.T) { target := func(name string, window *string) *configv1alpha3.GitTarget { spec := configv1alpha3.GitTargetSpec{ - ProviderRef: configv1alpha3.GitProviderReference{Name: "p"}, + ProviderRef: meta.LocalObjectReference{Name: "p"}, Branch: "main", Path: "clusters/prod", } diff --git a/internal/git/branch_worker_split_test.go b/internal/git/branch_worker_split_test.go index 4d6ca788..525aeeac 100644 --- a/internal/git/branch_worker_split_test.go +++ b/internal/git/branch_worker_split_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/fluxcd/pkg/apis/meta" "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/config" "github.com/go-git/go-git/v6/plumbing" @@ -95,7 +96,7 @@ func createGitTarget( Namespace: "default", }, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: configv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: worker.GitProviderRef, }, Branch: worker.Branch, diff --git a/internal/git/branch_worker_test.go b/internal/git/branch_worker_test.go index bf7e73f9..5c3316af 100644 --- a/internal/git/branch_worker_test.go +++ b/internal/git/branch_worker_test.go @@ -10,6 +10,7 @@ import ( "time" "filippo.io/age" + "github.com/fluxcd/pkg/apis/meta" "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/config" "github.com/go-git/go-git/v6/plumbing" @@ -440,14 +441,14 @@ func TestBranchWorker_EnsurePathBootstrapped_RendersAllResolvedRecipients(t *tes target := &configv1alpha3.GitTarget{} target.Name = "bootstrap-target" target.Namespace = "default" - target.Spec.ProviderRef = configv1alpha3.GitProviderReference{ + target.Spec.ProviderRef = meta.LocalObjectReference{ Name: "test-repo", } target.Spec.Branch = "main" target.Spec.Path = "clusters/dev" target.Spec.Encryption = &configv1alpha3.EncryptionSpec{ Provider: "sops", - SecretRef: configv1alpha3.LocalSecretReference{ + SecretRef: meta.LocalObjectReference{ Name: encryptionSecret.Name, }, Age: &configv1alpha3.AgeEncryptionSpec{ @@ -733,7 +734,7 @@ func TestBranchWorker_CommitAndPushRequest_UsesProviderCommitterAndTargetMessage target := &configv1alpha3.GitTarget{} target.Name = "audit-target" target.Namespace = "default" - target.Spec.ProviderRef = configv1alpha3.GitProviderReference{Name: "test-repo"} + target.Spec.ProviderRef = meta.LocalObjectReference{Name: "test-repo"} target.Spec.Branch = "main" target.Spec.Path = "clusters/dev" target.Spec.Commit = &configv1alpha3.GitTargetCommitSpec{ @@ -823,7 +824,7 @@ func TestBranchWorker_CommitAndPushRequest_UsesBatchTemplateForAtomicRequest(t * target := &configv1alpha3.GitTarget{} target.Name = "demo-target" target.Namespace = "default" - target.Spec.ProviderRef = configv1alpha3.GitProviderReference{Name: "test-repo"} + target.Spec.ProviderRef = meta.LocalObjectReference{Name: "test-repo"} target.Spec.Branch = "main" target.Spec.Path = "clusters/dev" target.Spec.Commit = &configv1alpha3.GitTargetCommitSpec{ @@ -949,7 +950,7 @@ func TestBranchWorker_CommitAndPushRequest_SignsCommitWhenConfigured(t *testing. URL: remoteURL, Commit: &configv1alpha3.CommitSpec{ Signing: &configv1alpha3.CommitSigningSpec{ - SecretRef: configv1alpha3.LocalSecretReference{Name: "signing-secret"}, + SecretRef: meta.LocalObjectReference{Name: "signing-secret"}, }, }, }, @@ -1046,7 +1047,7 @@ func TestBranchWorker_CommitAndPushRequest_SkipsWriteWhenSigningSecretIsInvalid( URL: remoteURL, Commit: &configv1alpha3.CommitSpec{ Signing: &configv1alpha3.CommitSigningSpec{ - SecretRef: configv1alpha3.LocalSecretReference{Name: "signing-secret"}, + SecretRef: meta.LocalObjectReference{Name: "signing-secret"}, }, }, }, @@ -1112,14 +1113,14 @@ func createTargetWithEncryption( target := &configv1alpha3.GitTarget{} target.Name = name target.Namespace = namespace - target.Spec.ProviderRef = configv1alpha3.GitProviderReference{ + target.Spec.ProviderRef = meta.LocalObjectReference{ Name: providerName, } target.Spec.Branch = branch target.Spec.Path = path target.Spec.Encryption = &configv1alpha3.EncryptionSpec{ Provider: "sops", - SecretRef: configv1alpha3.LocalSecretReference{ + SecretRef: meta.LocalObjectReference{ Name: "sops-age-key", }, Age: &configv1alpha3.AgeEncryptionSpec{ @@ -1142,7 +1143,7 @@ func createTargetWithoutEncryption( target := &configv1alpha3.GitTarget{} target.Name = name target.Namespace = namespace - target.Spec.ProviderRef = configv1alpha3.GitProviderReference{ + target.Spec.ProviderRef = meta.LocalObjectReference{ Name: providerName, } target.Spec.Branch = branch @@ -1168,14 +1169,14 @@ func createTargetWithEncryptionSecretData( target := &configv1alpha3.GitTarget{} target.Name = name target.Namespace = namespace - target.Spec.ProviderRef = configv1alpha3.GitProviderReference{ + target.Spec.ProviderRef = meta.LocalObjectReference{ Name: providerName, } target.Spec.Branch = branch target.Spec.Path = path target.Spec.Encryption = &configv1alpha3.EncryptionSpec{ Provider: "sops", - SecretRef: configv1alpha3.LocalSecretReference{ + SecretRef: meta.LocalObjectReference{ Name: encryptionSecret.Name, }, Age: &configv1alpha3.AgeEncryptionSpec{ @@ -1210,7 +1211,7 @@ func attachEncryptionToTarget( require.NoError(t, k8sClient.Get(ctx, client.ObjectKey{Name: targetName, Namespace: targetNamespace}, target)) target.Spec.Encryption = &configv1alpha3.EncryptionSpec{ Provider: "sops", - SecretRef: configv1alpha3.LocalSecretReference{ + SecretRef: meta.LocalObjectReference{ Name: encryptionSecret.Name, }, Age: &configv1alpha3.AgeEncryptionSpec{ diff --git a/internal/git/credentials_test.go b/internal/git/credentials_test.go index fb108c88..070dc4f5 100644 --- a/internal/git/credentials_test.go +++ b/internal/git/credentials_test.go @@ -10,6 +10,7 @@ import ( "encoding/pem" "testing" + "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" gossh "golang.org/x/crypto/ssh" @@ -262,7 +263,7 @@ func TestGetAuthFromSecret_FetchPaths(t *testing.T) { c := credTestClient(t, secret) provider := &configv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: "ns"}, - Spec: configv1alpha3.GitProviderSpec{SecretRef: &configv1alpha3.LocalSecretReference{Name: "creds"}}, + Spec: configv1alpha3.GitProviderSpec{SecretRef: &meta.LocalObjectReference{Name: "creds"}}, } auth, err := credentialFromSecret(context.Background(), c, provider, SSHHostKeyConfig{}) require.NoError(t, err) @@ -273,7 +274,7 @@ func TestGetAuthFromSecret_FetchPaths(t *testing.T) { c := credTestClient(t) provider := &configv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: "ns"}, - Spec: configv1alpha3.GitProviderSpec{SecretRef: &configv1alpha3.LocalSecretReference{Name: "absent"}}, + Spec: configv1alpha3.GitProviderSpec{SecretRef: &meta.LocalObjectReference{Name: "absent"}}, } _, err := credentialFromSecret(context.Background(), c, provider, SSHHostKeyConfig{}) require.Error(t, err) diff --git a/internal/git/encryption.go b/internal/git/encryption.go index bbb8accc..e881b17d 100644 --- a/internal/git/encryption.go +++ b/internal/git/encryption.go @@ -120,14 +120,6 @@ func resolveSecretRecipients( return nil, nil } - secretKind := strings.TrimSpace(encryptionSpec.SecretRef.Kind) - if secretKind != "" && secretKind != "Secret" { - return nil, fmt.Errorf( - "encryption.secretRef.kind must be Secret, got %q", - encryptionSpec.SecretRef.Kind, - ) - } - secretName := strings.TrimSpace(encryptionSpec.SecretRef.Name) if secretName == "" { return nil, errors.New( diff --git a/internal/git/encryption_test.go b/internal/git/encryption_test.go index 720b6692..e71f0c39 100644 --- a/internal/git/encryption_test.go +++ b/internal/git/encryption_test.go @@ -8,6 +8,7 @@ import ( "testing" "filippo.io/age" + "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -165,7 +166,7 @@ func TestResolveTargetEncryption(t *testing.T) { Spec: v1alpha3.GitTargetSpec{ Encryption: &v1alpha3.EncryptionSpec{ Provider: EncryptionProviderSOPS, - SecretRef: v1alpha3.LocalSecretReference{ + SecretRef: meta.LocalObjectReference{ Name: "enc-secret", }, Age: &v1alpha3.AgeEncryptionSpec{ @@ -195,7 +196,7 @@ func TestResolveTargetEncryption(t *testing.T) { Spec: v1alpha3.GitTargetSpec{ Encryption: &v1alpha3.EncryptionSpec{ Provider: EncryptionProviderSOPS, - SecretRef: v1alpha3.LocalSecretReference{ + SecretRef: meta.LocalObjectReference{ Name: "enc-secret", }, Age: &v1alpha3.AgeEncryptionSpec{ @@ -235,7 +236,7 @@ func TestResolveTargetEncryption(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "target", Namespace: "default"}, Spec: v1alpha3.GitTargetSpec{ Encryption: &v1alpha3.EncryptionSpec{ - SecretRef: v1alpha3.LocalSecretReference{ + SecretRef: meta.LocalObjectReference{ Name: "enc-secret", }, Age: &v1alpha3.AgeEncryptionSpec{ diff --git a/internal/git/placement_metrics.go b/internal/git/placement_metrics.go index f6129ac0..22d01360 100644 --- a/internal/git/placement_metrics.go +++ b/internal/git/placement_metrics.go @@ -69,7 +69,7 @@ const ( // placementDispositionAppended is a placement that added a document to a file that // already held one — only ever reachable through a declared bundling template now that // sibling inference is gone, which is what makes the two dispositions worth splitting: - // `disposition="appended"` with `source!="declared"` should not exist. + // `disposition="appended"` with a source other than `by_type` or `default` should not exist. placementDispositionAppended = "appended" ) diff --git a/internal/git/placement_metrics_test.go b/internal/git/placement_metrics_test.go index 42453990..151207c1 100644 --- a/internal/git/placement_metrics_test.go +++ b/internal/git/placement_metrics_test.go @@ -108,9 +108,10 @@ func TestPlacementMetrics_CanonicalFallbackNamesTargetAndType(t *testing.T) { } // A declared template is the answer to a canonical fall-back, so the two must be -// distinguishable in the same series: `source="declared"` is how an operator confirms the -// byType line they added is actually the one being used. -func TestPlacementMetrics_DeclaredPlacementIsCountedAsDeclared(t *testing.T) { +// distinguishable in the same series: `source="by_type"` is how an operator confirms the +// byType line they added is actually the one being used, and it is reported separately from +// `source="default"` so a catch-all swallowing a type does not read as that line working. +func TestPlacementMetrics_ByTypePlacementIsCountedAsByType(t *testing.T) { reader, err := telemetry.InitTestExporter() require.NoError(t, err) worktree := newWorktreeForTest(t) @@ -121,10 +122,10 @@ func TestPlacementMetrics_DeclaredPlacementIsCountedAsDeclared(t *testing.T) { flushWithPolicy(t, worktree, policy, targetedConfigMapEvent()) got, ok := telemetry.CollectInt64Sum(reader, placementsMetric, placementLabels("configmaps", map[string]string{ - "source": "declared", + "source": "by_type", "disposition": "new_file", })) - require.True(t, ok, "expected a declared placement sample") + require.True(t, ok, "expected a byType placement sample") assert.Equal(t, int64(1), got) } @@ -142,7 +143,7 @@ func TestPlacementMetrics_DeclaredBundleRecordsAppendedDisposition(t *testing.T) flushWithPolicy(t, worktree, policy, targetedConfigMapEvent()) got, ok := telemetry.CollectInt64Sum(reader, placementsMetric, placementLabels("configmaps", map[string]string{ - "source": "declared", + "source": "by_type", "disposition": "appended", })) require.True(t, ok, "expected an appended placement sample") @@ -389,3 +390,28 @@ func TestPlacementMetrics_DeclaredSubdirectoryEntryIsAdded(t *testing.T) { }) assert.False(t, hasFailed && failed > 0, "no entry may be counted as failed") } + +// The catch-all gets its own series, and this is the reason the split exists: a `default` +// template answering for a type is indistinguishable, in behaviour, from the byType line that +// type was supposed to have. Only the label tells an operator which one they are looking at. +func TestPlacementMetrics_DefaultTemplateIsCountedSeparatelyFromByType(t *testing.T) { + reader, err := telemetry.InitTestExporter() + require.NoError(t, err) + worktree := newWorktreeForTest(t) + policy := &manifestanalyzer.PlacementPolicy{Default: "{namespace}/{name}.yaml"} + + flushWithPolicy(t, worktree, policy, targetedConfigMapEvent()) + + got, ok := telemetry.CollectInt64Sum(reader, placementsMetric, placementLabels("configmaps", map[string]string{ + "source": "default", + "disposition": "new_file", + })) + require.True(t, ok, "expected a default-template placement sample") + assert.Equal(t, int64(1), got) + + _, byType := telemetry.CollectInt64Sum(reader, placementsMetric, placementLabels("configmaps", map[string]string{ + "source": "by_type", + "disposition": "new_file", + })) + assert.False(t, byType, "a catch-all must never be reported as an exact byType rule") +} diff --git a/internal/git/secret_write_test.go b/internal/git/secret_write_test.go index b637c649..c9e8d27d 100644 --- a/internal/git/secret_write_test.go +++ b/internal/git/secret_write_test.go @@ -9,6 +9,7 @@ import ( "time" "filippo.io/age" + "github.com/fluxcd/pkg/apis/meta" gogit "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/config" "github.com/go-git/go-git/v6/plumbing" @@ -67,7 +68,7 @@ func secretTargetObjects(t *testing.T, providerName, branch, path string) []clie Namespace: "default", }, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: configv1alpha3.GitProviderReference{ + ProviderRef: meta.LocalObjectReference{ Name: providerName, }, Branch: branch, @@ -80,7 +81,7 @@ func secretTargetObjects(t *testing.T, providerName, branch, path string) []clie ExtractFromSecret: true, }, }, - SecretRef: configv1alpha3.LocalSecretReference{ + SecretRef: meta.LocalObjectReference{ Name: "sops-age-key", }, }, diff --git a/internal/git/source_namespaces_test.go b/internal/git/source_namespaces_test.go index 4ab9626a..e41ec036 100644 --- a/internal/git/source_namespaces_test.go +++ b/internal/git/source_namespaces_test.go @@ -5,6 +5,7 @@ package git import ( "testing" + "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -20,7 +21,7 @@ func sourceNamespaceRule(name, targetName, sourceNamespace string) *configv1alph return &configv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "shop"}, Spec: configv1alpha3.WatchRuleSpec{ - TargetRef: configv1alpha3.LocalTargetReference{Name: targetName}, + TargetRef: meta.LocalObjectReference{Name: targetName}, Rules: []configv1alpha3.ResourceRule{{ Resources: []string{"configmaps"}, SourceNamespace: sourceNamespace, diff --git a/internal/git/worker_manager_test.go b/internal/git/worker_manager_test.go index 7d6f1e4b..842fbcc2 100644 --- a/internal/git/worker_manager_test.go +++ b/internal/git/worker_manager_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/fluxcd/pkg/apis/meta" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -88,7 +89,7 @@ func createTargetForRegister( target := &configv1alpha3.GitTarget{} target.Name = name target.Namespace = testTargetNamespace - target.Spec.ProviderRef = configv1alpha3.GitProviderReference{ + target.Spec.ProviderRef = meta.LocalObjectReference{ Name: providerName, } target.Spec.Branch = branch diff --git a/internal/git/write_boundary_precondition_test.go b/internal/git/write_boundary_precondition_test.go index 8088e743..8dba2972 100644 --- a/internal/git/write_boundary_precondition_test.go +++ b/internal/git/write_boundary_precondition_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + "github.com/fluxcd/pkg/apis/meta" gogit "github.com/go-git/go-git/v6" "github.com/go-git/go-git/v6/config" "github.com/go-git/go-git/v6/plumbing" @@ -202,7 +203,7 @@ func diamondGitTarget(providerName, branch string) *configv1alpha3.GitTarget { return &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "podinfo-test", Namespace: "default"}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: configv1alpha3.GitProviderReference{Name: providerName}, + ProviderRef: meta.LocalObjectReference{Name: providerName}, Branch: branch, Path: "", }, diff --git a/internal/manifestanalyzer/placement.go b/internal/manifestanalyzer/placement.go index f3772c29..e782419b 100644 --- a/internal/manifestanalyzer/placement.go +++ b/internal/manifestanalyzer/placement.go @@ -49,14 +49,21 @@ type PlacementRequest struct { // The values are a public observability contract (metric label values), so lower_snake_case and // not renamed lightly. // -// Exactly three, closed by construction: a declaration, one structural fact about the folder, and -// the built-in path. Nothing reads the repository's layout to guess intent. +// Exactly four, closed by construction: two kinds of declaration, one structural fact about the +// folder, and the built-in path. Nothing reads the repository's layout to guess intent. +// +// The two declared values are separate because a catch-all quietly swallowing a type you meant to +// name explicitly otherwise looks identical to a rule working as intended, and telling those apart +// is the whole job of a metric that answers "is a placement rule missing?". type PlacementSource string const ( - // PlacementSourceDeclared is Option B: an explicit placement.byType/default - // template matched. - PlacementSourceDeclared PlacementSource = "declared" + // PlacementSourceByType is an explicit placement.byType entry for this exact type. + PlacementSourceByType PlacementSource = "by_type" + // PlacementSourceDefault is the declared catch-all, placement.default: no byType entry named + // this type, so the target's own fallback template answered instead. Distinct from + // PlacementSourceCanonical, which is the absence of any declaration at all. + PlacementSourceDefault PlacementSource = "default" // PlacementSourceKustomizeRoot is the structural fallback: no declared template // matched, and the whole writable subtree is governed by exactly one supported // kustomization, so the new document goes beside it and into its resources: list @@ -160,8 +167,8 @@ func (e *PlacementRefusedError) Unwrap() error { return e.cause } func LocateNew(store *ManifestStore, policy *PlacementPolicy, req PlacementRequest) (PlacementResult, error) { vars := placementVars(req) - if path, ok, err := resolveDeclared(policy, req, vars); err == nil && ok { - return finishPlacement(store, req, path, PlacementSourceDeclared) + if path, source, ok, err := resolveDeclared(policy, req, vars); err == nil && ok { + return finishPlacement(store, req, path, source) } if path, ok := resolveKustomizeRoot(store, req); ok { @@ -420,25 +427,32 @@ func canonicalPath(req PlacementRequest) string { // --- Option B: declared type-map placement ------------------------------------- -func resolveDeclared(policy *PlacementPolicy, req PlacementRequest, vars map[string]string) (string, bool, error) { +// resolveDeclared renders the declared template that answers for this type, and reports WHICH +// declaration answered: an exact byType entry, or the target's catch-all default. +func resolveDeclared( + policy *PlacementPolicy, + req PlacementRequest, + vars map[string]string, +) (string, PlacementSource, bool, error) { if policy == nil { - return "", false, nil + return "", "", false, nil } key := PlacementTypeKey(req.Identifier.Group, req.Identifier.Version, req.Identifier.Resource) var tmpl string + var source PlacementSource switch { case strings.TrimSpace(policy.ByType[key]) != "": - tmpl = policy.ByType[key] + tmpl, source = policy.ByType[key], PlacementSourceByType case strings.TrimSpace(policy.Default) != "": - tmpl = policy.Default + tmpl, source = policy.Default, PlacementSourceDefault default: - return "", false, nil + return "", "", false, nil } rendered, err := RenderPlacementTemplate(tmpl, vars) if err != nil { - return "", false, err + return "", "", false, err } - return rendered, true, nil + return rendered, source, true, nil } // PlacementTypeKey renders the exact-type key used by GitTargetPlacementSpec.ByType: diff --git a/internal/manifestanalyzer/placement_test.go b/internal/manifestanalyzer/placement_test.go index d67bb558..ef941b75 100644 --- a/internal/manifestanalyzer/placement_test.go +++ b/internal/manifestanalyzer/placement_test.go @@ -124,7 +124,7 @@ func TestLocateNew_WriteScope_RebasesDeclared(t *testing.T) { if err != nil { t.Fatalf("LocateNew: %v", err) } - if res.Source != PlacementSourceDeclared { + if res.Source != PlacementSourceDefault { t.Fatalf("expected a declared placement, got %s", res.Source) } if res.Path != "overlays/production/app/configmaps.yaml" { @@ -334,7 +334,7 @@ func TestLocateNew_DeclaredIntoKustomizeContext_OmitsNamespace(t *testing.T) { if err != nil { t.Fatalf("LocateNew: %v", err) } - if res.Source != PlacementSourceDeclared { + if res.Source != PlacementSourceDefault { t.Fatalf("expected a declared placement, got %s", res.Source) } if !res.NamespaceInherited { @@ -421,7 +421,7 @@ func TestLocateNew_DeclaredOutranksTheKustomizeRoot(t *testing.T) { t.Fatalf("LocateNew: %v", err) } want := "app/configmaps.yaml" - if res.Path != want || res.Source != PlacementSourceDeclared { + if res.Path != want || res.Source != PlacementSourceByType { t.Fatalf("got %+v, want the declared template %q to win over the kustomize root", res, want) } } @@ -862,3 +862,33 @@ func TestLocateNew_DeclaredTemplateEscapingPath_Refused(t *testing.T) { t.Fatal("expected an error for a declared template that escapes spec.path") } } + +// TestLocateNew_DistinguishesByTypeFromDefault pins the split that makes the placement metric +// answer "is a rule missing?". A byType entry naming this exact type and a catch-all default both +// produce a declared path; reported as one value, a default quietly swallowing a type you meant to +// name explicitly looks identical to a rule working as intended. +func TestLocateNew_DistinguishesByTypeFromDefault(t *testing.T) { + store := placementStore(t, fstest.MapFS{}) + policy := &PlacementPolicy{ + ByType: map[string]string{"v1/configmaps": "config/{name}.yaml"}, + Default: "catchall/{name}.yaml", + } + + named, err := LocateNew(store, policy, newConfigMapRequest("cache", "app")) + if err != nil { + t.Fatalf("LocateNew: %v", err) + } + if named.Source != PlacementSourceByType { + t.Fatalf("an exact byType entry must report by_type, got %s", named.Source) + } + + unnamed := newConfigMapRequest("cache", "app") + unnamed.Identifier.Resource = "secrets" + fellThrough, err := LocateNew(store, policy, unnamed) + if err != nil { + t.Fatalf("LocateNew: %v", err) + } + if fellThrough.Source != PlacementSourceDefault { + t.Fatalf("a type no byType entry names must report default, got %s", fellThrough.Source) + } +} diff --git a/internal/rulestore/store_test.go b/internal/rulestore/store_test.go index 0ad1be99..0bcb4e28 100644 --- a/internal/rulestore/store_test.go +++ b/internal/rulestore/store_test.go @@ -6,6 +6,7 @@ import ( "sync" "testing" + meta "github.com/fluxcd/pkg/apis/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/types" @@ -138,7 +139,6 @@ func TestAddOrUpdateClusterWatchRule(t *testing.T) { APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"nodes"}, - Scope: configv1alpha3.ResourceScopeCluster, }, }, }, @@ -227,7 +227,6 @@ func TestDeleteClusterWatchRule(t *testing.T) { Rules: []configv1alpha3.ClusterResourceRule{ { Resources: []string{"nodes"}, - Scope: configv1alpha3.ResourceScopeCluster, }, }, }, @@ -521,7 +520,6 @@ func TestGetMatchingClusterRules(t *testing.T) { APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"nodes"}, - Scope: configv1alpha3.ResourceScopeCluster, }, }, }, @@ -844,7 +842,6 @@ func TestSnapshotClusterWatchRules(t *testing.T) { Rules: []configv1alpha3.ClusterResourceRule{ { Resources: []string{"nodes"}, - Scope: configv1alpha3.ResourceScopeCluster, }, }, }, @@ -1027,7 +1024,7 @@ func TestGetMatchingRules_NamespacedWatchRule_NamespaceContract(t *testing.T) { configv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "cluster-services"}, Spec: configv1alpha3.ClusterWatchRuleSpec{ - TargetRef: configv1alpha3.NamespacedTargetReference{Name: "cluster-target", Namespace: "ops"}, + TargetRef: meta.NamespacedObjectReference{Name: "cluster-target", Namespace: "ops"}, Rules: []configv1alpha3.ClusterResourceRule{{ Operations: []configv1alpha3.OperationType{configv1alpha3.OperationAll}, APIGroups: []string{""}, diff --git a/internal/watch/bootstrap_admission_test.go b/internal/watch/bootstrap_admission_test.go index fcecc081..44890721 100644 --- a/internal/watch/bootstrap_admission_test.go +++ b/internal/watch/bootstrap_admission_test.go @@ -6,6 +6,7 @@ import ( "context" "testing" + meta "github.com/fluxcd/pkg/apis/meta" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -34,8 +35,8 @@ func bootGitTarget() *configv1alpha3.GitTarget { return &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: bootTargetName, Namespace: bootTargetNS}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: configv1alpha3.GitProviderReference{Name: "git"}, - ClusterProviderRef: &configv1alpha3.ClusterProviderReference{Name: bootProviderName}, + ProviderRef: meta.LocalObjectReference{Name: "git"}, + ClusterProviderRef: &meta.LocalObjectReference{Name: bootProviderName}, Branch: "main", Path: "clusters/prod", }, @@ -59,9 +60,7 @@ func bootClusterWatchRule() *configv1alpha3.ClusterWatchRule { return &configv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: bootRuleName}, Spec: configv1alpha3.ClusterWatchRuleSpec{ - TargetRef: configv1alpha3.NamespacedTargetReference{ - Kind: "GitTarget", Name: bootTargetName, Namespace: bootTargetNS, - }, + TargetRef: meta.NamespacedObjectReference{Name: bootTargetName, Namespace: bootTargetNS}, Rules: []configv1alpha3.ClusterResourceRule{{ Resources: []string{"customresourcedefinitions"}, APIGroups: []string{"apiextensions.k8s.io"}, @@ -70,17 +69,6 @@ func bootClusterWatchRule() *configv1alpha3.ClusterWatchRule { } } -// bootNamespacedClusterWatchRule is a STORED pre-release object: `scope: Namespaced` is rejected at -// admission from this release on, but etcd still holds objects written before it. -func bootNamespacedClusterWatchRule() *configv1alpha3.ClusterWatchRule { - rule := bootClusterWatchRule() - rule.Spec.Rules = []configv1alpha3.ClusterResourceRule{ - {Resources: []string{"customresourcedefinitions"}, APIGroups: []string{"apiextensions.k8s.io"}}, - {Resources: []string{"configmaps"}, Scope: configv1alpha3.ResourceScopeNamespaced}, - } - return rule -} - func bootManager(t *testing.T, objects ...client.Object) *Manager { t.Helper() return &Manager{ @@ -155,8 +143,8 @@ func TestBootstrapRuleStore_SkipsUnauthorizedRuleButStillReady(t *testing.T) { admittedTarget := &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "ok-mirror", Namespace: "team-ok"}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: configv1alpha3.GitProviderReference{Name: "git"}, - ClusterProviderRef: &configv1alpha3.ClusterProviderReference{Name: bootProviderName}, + ProviderRef: meta.LocalObjectReference{Name: "git"}, + ClusterProviderRef: &meta.LocalObjectReference{Name: bootProviderName}, Branch: "main", Path: "clusters/ok", }, @@ -186,33 +174,10 @@ func TestBootstrapRuleStore_SkipsUnauthorizedRuleButStillReady(t *testing.T) { "the store must still be marked ready: a refused rule is a refusal, not a startup failure") } -// TestBootstrap_PreExistingNamespacedClusterRuleIsRefused is THE cluster-scope-only test. -// -// A ClusterWatchRule stored with `scope: Namespaced` before this release keeps that value in etcd, -// and bootstrap seeds the store BEFORE the first reconcile can publish any status. So the refusal -// has to live in the shared compile path: a reconciler-only check would let every restart open a -// cluster-wide namespaced watch for the whole startup window. This asserts the state at the moment -// MarkReady() returns — the only moment that proves it. -func TestBootstrap_PreExistingNamespacedClusterRuleIsRefused(t *testing.T) { - m := bootManager(t, - bootGitTarget(), bootGitProvider(), - bootClusterProvider(&configv1alpha3.NamespaceMatcher{Names: []string{bootTargetNS}}), - bootNamespacedClusterWatchRule(), - &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, - ) - - require.NoError(t, m.bootstrapRuleStore(context.Background(), logr.Discard()), - "a refused rule is a refusal, not a startup failure") - - assert.Empty(t, bootCompiledNames(m), - "a stored namespaced ClusterWatchRule must compile NO stream before status can be published") - assert.True(t, m.RuleStore.IsReady()) -} - -// TestBootstrapClusterWatchRule_WildcardStillResolvesItsClusterScopedTypes: the refusal keys on the -// STORED scope, not on what the selector happens to resolve. `resources: ["*"]` legitimately -// resolves cluster-scoped records — inferring the refusal from the resolution would break exactly -// the rule that the restart fixture exists to protect. +// TestBootstrapClusterWatchRule_WildcardStillResolvesItsClusterScopedTypes pins what a wildcard +// selector means on the cluster kind. Which scope a rule watches is decided by which KIND you +// write, never by a field, so `resources: ["*"]` on a ClusterWatchRule resolves the cluster-scoped +// records and nothing else. func TestBootstrapClusterWatchRule_WildcardStillResolvesItsClusterScopedTypes(t *testing.T) { rule := bootClusterWatchRule() rule.Spec.Rules = []configv1alpha3.ClusterResourceRule{{ @@ -233,7 +198,8 @@ func TestBootstrapClusterWatchRule_WildcardStillResolvesItsClusterScopedTypes(t // TestCompileClusterWatchRule_RefusalRemovesAnAlreadyCompiledRule is the REVOCATION contract for the // cluster kind: a rule accepted earlier and then refused must have its compiled rule REMOVED, not -// merely reported unready. +// merely reported unready. The refusal used here is the ClusterProvider withdrawing the target's +// namespace, which is the one refusal the compile path can reach at runtime. func TestCompileClusterWatchRule_RefusalRemovesAnAlreadyCompiledRule(t *testing.T) { ctx := context.Background() m := bootManager(t, @@ -248,15 +214,18 @@ func TestCompileClusterWatchRule_RefusalRemovesAnAlreadyCompiledRule(t *testing. require.True(t, decision.Admitted) require.Len(t, bootCompiledNames(m), 1, "precondition: the rule is compiled") - // Somebody re-applies the pre-release manifest (or an old object is re-observed). + // The platform admin narrows the ClusterProvider so it no longer admits the target's namespace. + var provider configv1alpha3.ClusterProvider + require.NoError(t, m.Client.Get(ctx, client.ObjectKey{Name: bootProviderName}, &provider)) + provider.Spec.AccessFrom = &configv1alpha3.NamespaceMatcher{Names: []string{"someone-else"}} + require.NoError(t, m.Client.Update(ctx, &provider)) + decision, err = CompileClusterWatchRule( - ctx, m.Client, m.RuleStore, *bootNamespacedClusterWatchRule(), *bootGitTarget(), *bootGitProvider()) + ctx, m.Client, m.RuleStore, *bootClusterWatchRule(), *bootGitTarget(), *bootGitProvider()) require.NoError(t, err) assert.False(t, decision.Admitted) - assert.Equal(t, ClusterWatchRuleReasonScopeNotSupported, decision.Reason) - assert.Contains(t, decision.Message, "rules[].sourceNamespace", - "the refusal must name the replacement, because the migration is cross-kind") + assert.Equal(t, ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized, decision.Reason) assert.Empty(t, bootCompiledNames(m), "a refused rule must be removed from the store, not left running with a bad condition") } diff --git a/internal/watch/config_plane_split_review_fixes_test.go b/internal/watch/config_plane_split_review_fixes_test.go index 68ff27c4..ac0b81e7 100644 --- a/internal/watch/config_plane_split_review_fixes_test.go +++ b/internal/watch/config_plane_split_review_fixes_test.go @@ -8,6 +8,7 @@ import ( "fmt" "testing" + meta "github.com/fluxcd/pkg/apis/meta" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -304,7 +305,7 @@ func TestResolveWatchRuleResources_ResolvesAgainstSourceCluster(t *testing.T) { remoteOnly := configv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "r", Namespace: "test-ns"}, Spec: configv1alpha3.WatchRuleSpec{ - TargetRef: configv1alpha3.LocalTargetReference{Name: "t"}, + TargetRef: meta.LocalObjectReference{Name: "t"}, Rules: []configv1alpha3.ResourceRule{ {APIGroups: []string{"example.com"}, Resources: []string{"widgets"}}, }, @@ -318,7 +319,7 @@ func TestResolveWatchRuleResources_ResolvesAgainstSourceCluster(t *testing.T) { localOnly := configv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "r2", Namespace: "test-ns"}, Spec: configv1alpha3.WatchRuleSpec{ - TargetRef: configv1alpha3.LocalTargetReference{Name: "t"}, + TargetRef: meta.LocalObjectReference{Name: "t"}, Rules: []configv1alpha3.ResourceRule{{Resources: []string{"deployments"}}}, }, } @@ -337,7 +338,7 @@ func TestResolveClusterWatchRuleResources_ResolvesAgainstSourceCluster(t *testin rule := configv1alpha3.ClusterWatchRule{ Spec: configv1alpha3.ClusterWatchRuleSpec{ - TargetRef: configv1alpha3.NamespacedTargetReference{Name: "t", Namespace: "test-ns"}, + TargetRef: meta.NamespacedObjectReference{Name: "t", Namespace: "test-ns"}, Rules: []configv1alpha3.ClusterResourceRule{{ APIGroups: []string{"example.com"}, Resources: []string{"widgetclasses"}, diff --git a/internal/watch/event_router_test.go b/internal/watch/event_router_test.go index a0a5d484..7e2f0bc5 100644 --- a/internal/watch/event_router_test.go +++ b/internal/watch/event_router_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + meta "github.com/fluxcd/pkg/apis/meta" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -87,7 +88,7 @@ func TestEnqueueScopedResync_ReportsMissingWorker(t *testing.T) { gitTarget := &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "team-a-config", Namespace: "team-a"}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: configv1alpha3.GitProviderReference{Name: "team-a-provider"}, + ProviderRef: meta.LocalObjectReference{Name: "team-a-provider"}, Branch: "main", }, } @@ -273,7 +274,7 @@ func TestServiceCommitRequest_NoWorkerResolvesNoOpenWindow(t *testing.T) { gitTarget := &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "team-a-config", Namespace: "team-a"}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: configv1alpha3.GitProviderReference{Name: "team-a-provider"}, + ProviderRef: meta.LocalObjectReference{Name: "team-a-provider"}, Branch: "main", }, } @@ -298,7 +299,7 @@ func TestServiceCommitRequest_RegisteredWorkerResolvesNoOpenWindow(t *testing.T) gitTarget := &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "team-a-config", Namespace: "team-a"}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: configv1alpha3.GitProviderReference{Name: "team-a-provider"}, + ProviderRef: meta.LocalObjectReference{Name: "team-a-provider"}, Branch: "main", }, } diff --git a/internal/watch/manager_snapshot_test.go b/internal/watch/manager_snapshot_test.go index 7d0604d5..675f1019 100644 --- a/internal/watch/manager_snapshot_test.go +++ b/internal/watch/manager_snapshot_test.go @@ -6,6 +6,7 @@ import ( "context" "testing" + meta "github.com/fluxcd/pkg/apis/meta" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -68,7 +69,7 @@ func addSecretsWatchRule(store *rulestore.RuleStore) { rule := configv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "wr-secrets", Namespace: "ns-a"}, Spec: configv1alpha3.WatchRuleSpec{ - TargetRef: configv1alpha3.LocalTargetReference{Name: "my-target"}, + TargetRef: meta.LocalObjectReference{Name: "my-target"}, Rules: []configv1alpha3.ResourceRule{{ APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"secrets"}, }}, diff --git a/internal/watch/manager_startup_test.go b/internal/watch/manager_startup_test.go index de378cfd..a86de5ed 100644 --- a/internal/watch/manager_startup_test.go +++ b/internal/watch/manager_startup_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + meta "github.com/fluxcd/pkg/apis/meta" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -28,7 +29,7 @@ func TestManagerStart_MustSeedRuleStoreFromExistingWatchRules(t *testing.T) { existingWatchRule := &configv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "playground-watchrule", Namespace: "tilt-playground"}, Spec: configv1alpha3.WatchRuleSpec{ - TargetRef: configv1alpha3.LocalTargetReference{Name: "playground-target"}, + TargetRef: meta.LocalObjectReference{Name: "playground-target"}, Rules: []configv1alpha3.ResourceRule{{ APIGroups: []string{""}, APIVersions: []string{"v1"}, @@ -39,7 +40,7 @@ func TestManagerStart_MustSeedRuleStoreFromExistingWatchRules(t *testing.T) { existingGitTarget := &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "playground-target", Namespace: "tilt-playground"}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: configv1alpha3.GitProviderReference{Name: "playground-provider"}, + ProviderRef: meta.LocalObjectReference{Name: "playground-provider"}, Branch: "main", Path: "live-cluster", }, @@ -95,7 +96,7 @@ func TestManagerStart_MustSeedRuleStoreFromExistingClusterWatchRules(t *testing. existingClusterWatchRule := &configv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "cluster-namespaces"}, Spec: configv1alpha3.ClusterWatchRuleSpec{ - TargetRef: configv1alpha3.NamespacedTargetReference{Name: "ops-target", Namespace: "ops"}, + TargetRef: meta.NamespacedObjectReference{Name: "ops-target", Namespace: "ops"}, Rules: []configv1alpha3.ClusterResourceRule{{ APIGroups: []string{""}, APIVersions: []string{"v1"}, @@ -106,7 +107,7 @@ func TestManagerStart_MustSeedRuleStoreFromExistingClusterWatchRules(t *testing. existingGitTarget := &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "ops-target", Namespace: "ops"}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: configv1alpha3.GitProviderReference{Name: "ops-provider"}, + ProviderRef: meta.LocalObjectReference{Name: "ops-provider"}, Branch: "main", Path: "cluster-state", }, diff --git a/internal/watch/rule_status_test.go b/internal/watch/rule_status_test.go index d55b3017..22bf733d 100644 --- a/internal/watch/rule_status_test.go +++ b/internal/watch/rule_status_test.go @@ -64,7 +64,6 @@ func TestResolveClusterWatchRuleResources_WildcardWatchesManyTypes(t *testing.T) APIGroups: []string{"*"}, APIVersions: []string{"*"}, Resources: []string{"*"}, - Scope: configv1alpha3.ResourceScopeNamespaced, }}, }}) diff --git a/internal/watch/source_namespace_stream_summary_test.go b/internal/watch/source_namespace_stream_summary_test.go index 3f0178d2..00cd84c7 100644 --- a/internal/watch/source_namespace_stream_summary_test.go +++ b/internal/watch/source_namespace_stream_summary_test.go @@ -5,6 +5,7 @@ package watch import ( "testing" + meta "github.com/fluxcd/pkg/apis/meta" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -47,7 +48,7 @@ func srcnsOverrideRule(sourceNamespace string) configv1alpha3.WatchRule { return configv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "repo-config-rule", Namespace: "tenant-acme"}, Spec: configv1alpha3.WatchRuleSpec{ - TargetRef: configv1alpha3.LocalTargetReference{Name: "acme"}, + TargetRef: meta.LocalObjectReference{Name: "acme"}, Rules: []configv1alpha3.ResourceRule{{ Resources: []string{"configmaps"}, SourceNamespace: sourceNamespace, }}, diff --git a/internal/watch/source_namespace_test.go b/internal/watch/source_namespace_test.go index bf41994b..9ca077ae 100644 --- a/internal/watch/source_namespace_test.go +++ b/internal/watch/source_namespace_test.go @@ -6,6 +6,7 @@ import ( "context" "testing" + meta "github.com/fluxcd/pkg/apis/meta" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -31,8 +32,8 @@ func snbGitTarget() *configv1alpha3.GitTarget { return &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: snbTarget, Namespace: snbTenantNS}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: configv1alpha3.GitProviderReference{Name: "git"}, - ClusterProviderRef: &configv1alpha3.ClusterProviderReference{Name: snbProvider}, + ProviderRef: meta.LocalObjectReference{Name: "git"}, + ClusterProviderRef: &meta.LocalObjectReference{Name: snbProvider}, Branch: "main", Path: "tenants/acme", }, @@ -66,7 +67,7 @@ func snbWatchRule(sourceNamespaces ...string) *configv1alpha3.WatchRule { return &configv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: snbRule, Namespace: snbTenantNS}, Spec: configv1alpha3.WatchRuleSpec{ - TargetRef: configv1alpha3.LocalTargetReference{Name: snbTarget}, + TargetRef: meta.LocalObjectReference{Name: snbTarget}, Rules: items, }, } diff --git a/internal/watch/stream_readiness.go b/internal/watch/stream_readiness.go index 7c322475..37239371 100644 --- a/internal/watch/stream_readiness.go +++ b/internal/watch/stream_readiness.go @@ -157,7 +157,7 @@ func (m *Manager) StreamSummaryForGitTarget(gitDest types.ResourceReference) Str // A rule that is not compiled expects no streams, which is correct: the gate refused it, or the // store has not been seeded yet. func (m *Manager) StreamSummaryForWatchRule(rule configv1alpha3.WatchRule) StreamSummary { - // The GitTarget is in the rule's OWN namespace (targetRef is a LocalTargetReference), but the + // The GitTarget is in the rule's OWN namespace (targetRef is a meta.LocalObjectReference), but the // streams are keyed on the namespaces being WATCHED. gitDest := types.NewResourceReference(rule.Spec.TargetRef.Name, rule.Namespace) if m.RuleStore == nil { diff --git a/internal/watch/watched_type_helpers_test.go b/internal/watch/watched_type_helpers_test.go index b4228c30..48a39df3 100644 --- a/internal/watch/watched_type_helpers_test.go +++ b/internal/watch/watched_type_helpers_test.go @@ -6,6 +6,7 @@ import ( "sync" "testing" + meta "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -21,7 +22,7 @@ func clusterRuleForResource(name, resource string) configv1alpha3.ClusterWatchRu return configv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: name}, Spec: configv1alpha3.ClusterWatchRuleSpec{ - TargetRef: configv1alpha3.NamespacedTargetReference{ + TargetRef: meta.NamespacedObjectReference{ Name: "test-target", Namespace: "test-ns", }, @@ -39,7 +40,7 @@ func watchRuleForTarget(name, gitTargetName, namespace string) configv1alpha3.Wa return configv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, Spec: configv1alpha3.WatchRuleSpec{ - TargetRef: configv1alpha3.LocalTargetReference{Name: gitTargetName}, + TargetRef: meta.LocalObjectReference{Name: gitTargetName}, Rules: []configv1alpha3.ResourceRule{{ APIGroups: []string{""}, APIVersions: []string{"v1"}, diff --git a/internal/watch/watched_type_resolver_test.go b/internal/watch/watched_type_resolver_test.go index 3e2a5eaf..cb8562c2 100644 --- a/internal/watch/watched_type_resolver_test.go +++ b/internal/watch/watched_type_resolver_test.go @@ -5,6 +5,7 @@ package watch import ( "testing" + meta "github.com/fluxcd/pkg/apis/meta" "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -163,7 +164,7 @@ func TestRefreshWatchedTypeTables_ExcludesAmbiguousGVK(t *testing.T) { configv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "rule-widgets"}, Spec: configv1alpha3.ClusterWatchRuleSpec{ - TargetRef: configv1alpha3.NamespacedTargetReference{Name: "test-target", Namespace: "test-ns"}, + TargetRef: meta.NamespacedObjectReference{Name: "test-target", Namespace: "test-ns"}, Rules: []configv1alpha3.ClusterResourceRule{{ APIGroups: []string{"example.com"}, APIVersions: []string{"v1"}, diff --git a/internal/watch/watchrule_compile.go b/internal/watch/watchrule_compile.go index 4f03031f..9f323d87 100644 --- a/internal/watch/watchrule_compile.go +++ b/internal/watch/watchrule_compile.go @@ -125,14 +125,6 @@ func CompileClusterWatchRule( }, nil } - if rule.Spec.DeclaresNamespacedScope() { - store.DeleteClusterWatchRule(key) - return ClusterWatchRuleDecision{ - Reason: ClusterWatchRuleReasonScopeNotSupported, - Message: ClusterWatchRuleNamespacedScopeMessage, - }, nil - } - store.AddOrUpdateClusterWatchRule( rule, target.Name, target.Namespace, @@ -155,18 +147,8 @@ const ( // point of view the single fact that matters is that this rule may not compile against this // target. The Message carries which of the two it was. ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized = "GitTargetNamespaceNotAuthorized" - - // ClusterWatchRuleReasonScopeNotSupported is the terminal reason for a STORED ClusterWatchRule - // that still selects namespaced resources through the removed scope choice. - ClusterWatchRuleReasonScopeNotSupported = "ClusterScopeOnly" ) -// ClusterWatchRuleNamespacedScopeMessage is the operator-facing refusal for a stored -// scope: Namespaced. It names the replacement, because the migration is cross-kind and cannot be -// performed automatically. -const ClusterWatchRuleNamespacedScopeMessage = "ClusterWatchRule is cluster-scoped only; watch " + - "namespaced resources with a WatchRule and `rules[].sourceNamespace`." - // ClusterWatchRuleDecision is the outcome of the shared ClusterWatchRule compile path. type ClusterWatchRuleDecision struct { // Admitted reports whether the rule compiled. diff --git a/internal/webhook/watchrule_source_namespace_admission_test.go b/internal/webhook/watchrule_source_namespace_admission_test.go index b05d89cb..07a5baea 100644 --- a/internal/webhook/watchrule_source_namespace_admission_test.go +++ b/internal/webhook/watchrule_source_namespace_admission_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "testing" + "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" admissionv1 "k8s.io/api/admission/v1" @@ -37,7 +38,7 @@ func watchRuleFor(name, sourceNamespace string) *v1alpha3.WatchRule { return &v1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "shop"}, Spec: v1alpha3.WatchRuleSpec{ - TargetRef: v1alpha3.LocalTargetReference{Name: "checkout-artifact"}, + TargetRef: meta.LocalObjectReference{Name: "checkout-artifact"}, Rules: []v1alpha3.ResourceRule{{ Resources: []string{"configmaps"}, SourceNamespace: sourceNamespace, 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 e0beb4bd..9b484213 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 @@ -16,7 +16,6 @@ metadata: name: demo-podinfos spec: providerRef: - kind: GitProvider name: demo branch: nl-stuff path: podinfos @@ -29,7 +28,6 @@ metadata: name: demo-podinfos spec: targetRef: - kind: GitTarget name: demo-podinfos rules: - operations: [CREATE, UPDATE, DELETE] 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 60e0412f..a268ef61 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 @@ -16,7 +16,6 @@ metadata: name: demo-coffeeconfig spec: providerRef: - kind: GitProvider name: demo-coffeeconfig branch: demo-test path: voter-coffee @@ -29,7 +28,6 @@ metadata: name: demo-coffeeconfig spec: targetRef: - kind: GitTarget name: demo-coffeeconfig rules: - operations: [CREATE, UPDATE, DELETE] diff --git a/test/playground/config/gittarget.yaml b/test/playground/config/gittarget.yaml index 084f9038..045c43d7 100644 --- a/test/playground/config/gittarget.yaml +++ b/test/playground/config/gittarget.yaml @@ -5,7 +5,6 @@ metadata: namespace: tilt-playground spec: providerRef: - kind: GitProvider name: playground-provider branch: main # Keep playground writes under a folder. Use "." only when testing repo-root ownership. diff --git a/test/playground/config/watchrule.yaml b/test/playground/config/watchrule.yaml index 2c39d755..f1cfd681 100644 --- a/test/playground/config/watchrule.yaml +++ b/test/playground/config/watchrule.yaml @@ -5,7 +5,6 @@ metadata: namespace: tilt-playground spec: targetRef: - kind: GitTarget name: playground-target rules: - resources: ["deployments", "services", "configmaps", "secrets"] From d705ba6dbd8b3031fc00880bb70152a545a72fa9 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Thu, 3 Sep 2026 10:04:25 +0000 Subject: [PATCH 3/9] docs: migration notes for the API cleanup, and a crispness pass on the guides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UPGRADING gains three entries: the reference collapse, the ClusterWatchRule scope removal, and the placement metric's label split. The scope entry's old "retained for one release as a loud rejection" section is marked superseded rather than deleted, because why the shim existed is still worth reading. The pre-upgrade inventory now covers all five kinds and says which rows carry a value to migrate and which are only a line to delete: nothing is lost when the removed field had exactly one legal value. architecture.md and configuration.md get the newcomer pass. Development history comes out — what a field used to be called, which release removed sibling inference, what a target "did before these fields existed" — because a guide should say what the thing does, and UPGRADING is where the before is already written down. "Why the two provider types have different scopes" loses two paragraphs of rationale it was restating in its own table. interpreting-metrics.md and new-file-placement-rules.md follow the metric split; the placement metric's `declared` value no longer exists. Deletes docs/bug-report.md: an incident report from a consumer, fixed by #263's audit-route attribution and confirmed adopted by the reporter. The one ask that outlived it — a ClusterProvider whose declared auditRoute has received zero facts should say so — moves into TODO.md, where it is now the highest-consequence silent failure on the list. Co-Authored-By: Claude Opus 5 --- docs/TODO.md | 9 ++ docs/UPGRADING.md | 124 ++++++++++++++-- docs/architecture.md | 17 +-- docs/bug-report.md | 182 ------------------------ docs/configuration.md | 61 +++----- docs/interpreting-metrics.md | 13 +- docs/layout/new-file-placement-rules.md | 5 +- 7 files changed, 162 insertions(+), 249 deletions(-) delete mode 100644 docs/bug-report.md diff --git a/docs/TODO.md b/docs/TODO.md index a53c79a4..ce836257 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -21,6 +21,15 @@ This file is meant to track the smaller current backlog, not historical notes. runs attribution in-process. Only useful until HA lands (HA needs the shared Redis store); low priority. +- [ ] Make a lost audit route loud. A `ClusterProvider` whose declared + `spec.attribution.auditRoute` has received **zero** facts should say so in a condition, before the + first commit lands wrong. Today, if the write route and the read route diverge, nothing errors: + mirroring stays perfect and every commit is authored `unknown (attribution unresolved)`. That is + visible in Git and in `attribution_resolutions_total{result="absent"}`, but only after the fact — + and for a consumer whose product is attribution it is the highest-consequence silent failure + there is. Zero facts on a declared route is something the operator knows and nobody else can see. + This is the last open ask from the consumer's revision-11 list that has a failure mode behind it. + - [ ] Prevent same-repository write collisions across multiple `GitProvider` objects. Decide whether the fix should be validation, a shared queue/lock per repo, or both. Until then, keep recommending one `GitProvider` per repository. diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index b19a72de..a005767d 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -9,9 +9,21 @@ We are pre-1.0, so breaking changes bump the **minor** version (release-please i ## Safe upgrade order for the GitTarget API changes -Five fields are **removed outright** in this release, across three kinds. There is no shim, no +Fields are **removed outright** in this release, across five kinds. There is no shim, no conversion webhook and no refusal to catch you: a removed field is pruned from the schema, so the -next two entries are the whole migration and they are yours to apply by hand. +entries below are the whole migration and they are yours to apply by hand. + +| Kind | Removed | +|---|---| +| `GitTarget` | `spec.allowedSourceNamespaces` | +| `GitProvider` | `spec.push.commitWindow`, `spec.commit.message` | +| `ClusterProvider` | `spec.allowedNamespaces` (renamed), `spec.allowSourceNamespaceOverride` (renamed) | +| `ClusterWatchRule` | `spec.rules[].scope` | +| every reference field | the `group` and `kind` sub-fields | + +The last two rows need no value migration at all — nothing you set is lost, because every value +those fields could hold was the only one they accepted. Delete the lines and move on. Everything +above them is a real value that has somewhere new to be. **Take the inventory FIRST, before you upgrade anything.** This is not a stylistic preference. Once the new CRDs are applied, the old values are no longer returned by the API server at all — not @@ -35,6 +47,16 @@ kubectl get gitproviders -A -o json | | "gitprovider \(.metadata.namespace)/\(.metadata.name): window<-\(.spec.push.commitWindow) message<-\(.spec.commit.message)"' +# The reference fields and the ClusterWatchRule scope, which carry no information to migrate but +# will be pruned out from under a manifest that still sets them: +kubectl get gittargets,watchrules,commitrequests -A -o json | + jq -r '.items[] | select((.spec.targetRef.kind? // .spec.providerRef.kind?) != null) + | "\(.kind) \(.metadata.namespace)/\(.metadata.name): drop the group/kind under its *Ref"' + +kubectl get clusterwatchrules -o json | + jq -r '.items[] | select(any(.spec.rules[]?; has("scope"))) + | "clusterwatchrule \(.metadata.name): drop spec.rules[].scope"' + # The rules whose MEANING changes even though their YAML does not: kubectl get watchrules -A -o json | jq -r '.items[] | select(.spec.rules[]?.sourceNamespace == "*") @@ -100,6 +122,84 @@ running under the new defaults. That is a deliberate trade for a much smaller co in [`facts/crd-upgrade-strategies.md`](facts/crd-upgrade-strategies.md), and it is why the inventory is step 1 rather than a footnote. +## Every reference is `{name}` — `group` and `kind` are gone + +Six reference shapes become two, and both are Flux's own +([`fluxcd/pkg/apis/meta`](https://pkg.go.dev/github.com/fluxcd/pkg/apis/meta)): +`meta.LocalObjectReference` for a reference into the same namespace, and +`meta.NamespacedObjectReference` where the namespace has to be spelled out. + +```yaml +# Before # After +spec: spec: + providerRef: providerRef: + group: configbutler.ai name: platform + kind: GitProvider + name: platform +``` + +The same edit applies to `GitTarget.spec.providerRef` and `.spec.clusterProviderRef`, +`WatchRule.spec.targetRef`, `ClusterWatchRule.spec.targetRef`, `CommitRequest.spec.targetRef`, +`GitProvider.spec.secretRef`, and the `secretRef` under `spec.encryption`. + +**Nothing is lost.** Each of those `group` and `kind` fields was an enum with exactly one member and +a default equal to it, so no manifest could ever say anything but `configbutler.ai` and the one kind +the field accepts. What the sub-fields cost was four near-identical Go types and a schema that +implied a choice nobody had. + +**They are removed, not refused**, so a manifest that still sets them applies cleanly with the +values pruned. That is harmless here — the pruned value was the only legal one — but it does mean +`kubectl apply` will not tell you your manifests are out of date. Tidy them at your convenience. + +`GitProvider.spec.knownHostsRef` keeps its `kind`, and that is not an oversight: it chooses between +a ConfigMap and a Secret, which is a real choice rather than an enum of one. + +## `ClusterWatchRule.spec.rules[].scope` is removed + +The field has accepted only `Cluster` since 0.39.0, where it was kept in the schema so that +re-applying a manifest still saying `Namespaced` would fail loudly rather than be silently pruned. +That shim has done its release, and four more besides. It is now gone, along with the +`ClusterScopeOnly` compile refusal that backed it. + +```yaml +spec: + rules: + - resources: [customresourcedefinitions] + apiGroups: [apiextensions.k8s.io] + # scope: Cluster <- delete this line +``` + +A `ClusterWatchRule` watches cluster-scoped types and a `WatchRule` watches namespaced ones; which +you write **is** how scope is chosen, and has been since 0.39.0. If you still have a manifest +setting `scope: Namespaced`, it has not been mirroring anything since then, and the migration is the +same one that entry describes: a `WatchRule` in the tenant namespace with +`spec.rules[].sourceNamespace`. + +From this release the value is pruned instead of rejected, so an unconverted manifest applies +without complaint. That is the trade for deleting the field, and it is why the +[inventory](#safe-upgrade-order-for-the-gittarget-api-changes) lists it. + +## The placement metric reports which declaration answered (breaking for dashboards) + +`gitops_reverser_placements_total`'s `source` label had one value, `declared`, covering both an +exact `placement.byType` entry and the catch-all `placement.default`. It now has two: + +| Was | Is | +|---|---| +| `source="declared"` | `source="by_type"` — an exact `placement.byType` entry named this type | +| `source="declared"` | `source="default"` — no `byType` entry named it, so `placement.default` answered | + +`kustomize_root` and `canonical` are unchanged. + +The split exists because the two were the one thing this metric is asked to tell apart. A `default` +template quietly answering for a type you meant to name explicitly produces a working mirror at a +path you did not intend, and reported as `declared` it looked exactly like the `byType` line you +added doing its job. + +**Any query selecting `source="declared"` returns no data after the upgrade** rather than failing, +which is the failure mode worth knowing about. Replace it with `source=~"by_type|default"` to keep +the old meaning, or split the panel to get the new one. + ## Commit batching and message templates are GitTarget fields `GitProvider.spec.push.commitWindow` and `GitProvider.spec.commit.message` are now @@ -369,8 +469,9 @@ sum by (gittarget_namespace, gittarget_name, group, version, resource) ( ``` Each series is a type that took the built-in path. For a canonical-layout folder that is simply the -layout. For a folder with a convention of its own it is the `byType` line to add. `source="declared"` -and `source="kustomize_root"` need no attention. Two companions ship with it: +layout. For a folder with a convention of its own it is the `byType` line to add. The declared +sources (`by_type` and `default`, one value `declared` in the release this entry describes) and +`source="kustomize_root"` need no attention. Two companions ship with it: `gitopsreverser_placement_refusals_total{reason}` (resources the writer declined to place — each one is absent from the mirror) and `gitopsreverser_placement_kustomization_entries_total{outcome}`, whose `failed` value is a new file committed outside every render. See @@ -729,13 +830,16 @@ spec: ### `ClusterWatchRule.spec.rules[].scope` is retained for one release as a loud rejection -The field was not deleted, because deleting it is the **silent** option: CRD pruning happens on -write, so a re-applied legacy manifest would be accepted with the value dropped — no error anywhere — -and the rule would quietly stop mirroring namespaced objects. +> **Superseded.** The shim described here did its job and the field is now **deleted** — see +> [its own entry above](#clusterwatchrulespecrulesscope-is-removed). What follows is why it existed. + +The field was not deleted at the time, because deleting it is the **silent** option: CRD pruning +happens on write, so a re-applied legacy manifest would be accepted with the value dropped — no +error anywhere — and the rule would quietly stop mirroring namespaced objects. -It is now optional, defaults to `Cluster`, and its enum accepts only `Cluster`. Applying -`scope: Namespaced` is **rejected at apply time**, and a stored one is refused at compile with -`ClusterScopeOnly`. The field is removed entirely one release from now, or at `v1beta1`. +It was made optional, defaulting to `Cluster`, with an enum accepting only `Cluster`. Applying +`scope: Namespaced` was **rejected at apply time**, and a stored one refused at compile with +`ClusterScopeOnly`. No such shim exists for `WatchRule.spec.sourceNamespace`, and none is needed: that field never reached a release, so no stored object can carry it and no manifest in the wild sets it. It is simply diff --git a/docs/architecture.md b/docs/architecture.md index 9109c6ea..3f40edf9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,8 +52,8 @@ Every write to that branch goes through the worker's single event loop and commi **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 -CommitRequest author capture and HA. Attribution no longer requires it on its own: its facts travel -on a selectable transport, Redis Streams by default and an in-process ring with +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 Redis as the shared store across replicas. @@ -242,8 +242,7 @@ namespace selection of its own. Both share the rule model: 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. -- `ClusterWatchRule` has no scope or namespace choice. `rules[].scope` is deprecated, accepts only - `Cluster`, and a stored `Namespaced` value is refused at compile time. +- `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 `/scale` subresource effect is translated separately into a parent `spec.replicas` field patch. @@ -291,8 +290,7 @@ How attribution and finalization interact is described under One materialization from a source provider to a Git destination: `(cluster provider, provider, branch, path)`. Key fields: -- `spec.providerRef`: a `GitProvider` in the same namespace (`group`/`kind` default to - `configbutler.ai`/`GitProvider`, the only accepted values). +- `spec.providerRef`: a `GitProvider` in the same namespace, by name. - `spec.clusterProviderRef`: a cluster-scoped source `ClusterProvider`; it defaults to `{name: default}`. - `spec.branch`: immutable branch, validated against `GitProvider.spec.allowedBranches`. - `spec.path`: immutable, required path under the repo (`MinLength=1`; `.` means repo root and must be @@ -1160,10 +1158,9 @@ placed never moves a file already in Git. A new resource is placed by the first `{spec.path}/{namespace}/{group}/{resource}/{name}.yaml`: namespace-first, group omitted for core, no version, `_cluster/` for cluster-scoped, `.sops.yaml` for sensitive. -**The layout of the folder's other documents is not an input.** An earlier release followed it (sibling -inference), which made a human's edit to the repository change where the operator wrote next, with no -Kubernetes object changing and nothing in status recording the move. It was removed; a layout the ladder -cannot derive is declared in `spec.placement`, and +**The layout of the folder's other documents is not an input.** Where a new file goes is decided by the +three rungs above and nothing else, so a human's edit to the repository never changes where the +operator writes next. A layout the ladder cannot derive is declared in `spec.placement`, and `gitopsreverser_placements_total{source="canonical"}` names the target and type that needs the line. Sensitivity is a write-safety classifier, not a placement input: whatever path is chosen, a sensitive diff --git a/docs/bug-report.md b/docs/bug-report.md deleted file mode 100644 index 845f1462..00000000 --- a/docs/bug-report.md +++ /dev/null @@ -1,182 +0,0 @@ -# Bug report — attribution is silently `unresolved` for objects mirrored through a non-`default` ClusterProvider - -**For:** the gitops-reverser team -**Found by:** gitops-api, while spiking the one-reverser consolidation against branch -`feat/gittarget-prune-mode-pr5` -**Date:** 2026-07-21 -**Reverser version:** branch `feat/gittarget-prune-mode-pr5` @ `b9c2e495` (past v0.38.0), built locally -as `gitops-reverser:e2e-local`, run in configured **attribution mode** (`--author-attribution`, -`--author-attribution-grace=10s`, `--redis-addr` set). -**Severity:** medium — mirroring is correct; **author attribution is silently lost**. It is only -visible at all because of the new `unknown (attribution unresolved)` author on this branch — which is -exactly what surfaced it. - -## Summary - -Every object mirrored through a GitTarget whose `spec.clusterProviderRef` names a **dedicated -in-cluster ClusterProvider** (one created with `kubeConfig` omitted, so it points at the operator's -own cluster, but with a **name other than `default`**) is committed as -`unknown (attribution unresolved) `, even though the -same actor's writes through the **`default`** ClusterProvider attribute correctly. - -The audit facts *are* being written; they are simply never matched for objects whose GitTarget -resolves attribution under a non-`default` cluster identity. Our leading hypothesis is that the -attribution fact index is keyed per source-cluster (ClusterProvider), and a ClusterProvider with no -audit route delivering facts under **its own** identity — which a dedicated in-cluster provider is, -because the local apiserver's audit is filed under `default` — has an empty fact index, so every -lookup misses. - -## Evidence (two back-to-back runs, reverser restarted between them to zero the counters) - -Workload: the branch's own `manager`-labelled specs, focused to two Describe blocks — -`WatchRule source namespace` (`test/e2e/source_namespace_e2e_test.go`) and -`Manager GitTarget prune policy` (`test/e2e/prune_mode_e2e_test.go`). Both create `configmaps` on the -same in-cluster apiserver, as the same e2e identity, under the same audit policy. - -**`gitopsreverser_attribution_resolutions_total`, `sum by (result,resource)` — identical across runs:** - -| result | resource | Run A | Run B | -|---|---|---|---| -| `exact_user` | configmaps | 9 | 9 | -| `weak` | configmaps | 3 | 3 | -| `absent` | configmaps | **5** | **5** | - -**The 5 `absent` are, in both runs, exactly the objects the source-namespace spec mirrors** — and -*only* those (the prune spec's objects all resolved): - -```text -[CREATE] v1/configmaps/srcns-mirrored → unknown (attribution unresolved) -[CREATE] v1/configmaps/srcns-wildcard-admitted → unknown (attribution unresolved) -[CREATE] v1/configmaps/srcns-two-cm-a → unknown (attribution unresolved) -[CREATE] v1/configmaps/srcns-two-cm-b → unknown (attribution unresolved) -[CREATE] v1/configmaps/srcns-refused-cm → unknown (attribution unresolved) -``` - -**The `absent` resolutions waited the FULL grace and no fact ever arrived** -(`gitopsreverser_attribution_resolution_wait_seconds_bucket{result="absent"}`): - -```text -wait <= 10.0s : 0 -wait <= +Inf : 5 ← all 5 exceeded the 10s grace; a fact was never matched -``` - -**Facts are being written — they are just not matched for these objects** -(`gitopsreverser_attribution_fact_events_total`, Run B): - -```text -written = 66 -matched = 12 ← == exact_user(9) + weak(3); the resolved population only -deletecollection_expanded = 18 -(no expired_unmatched, no late) -``` - -`written=66 / matched=12` with **zero `expired_unmatched`** means the resolver is looking up a key -under which no fact was ever filed — not a fact that was written and then aged out. - -## The one structural difference between resolved and unresolved objects - -| | resolves (`exact_user`/`weak`) | unresolved (`absent`) | -|---|---|---| -| spec | `Manager GitTarget prune policy` | `WatchRule source namespace` | -| `GitTarget.spec.clusterProviderRef` | omitted → defaults to `{name: default}` | a **dedicated** in-cluster provider `srcns-delegating` (created via `applyInClusterClusterProvider`, `kubeConfig` omitted) | -| `WatchRule` source namespace | own namespace | overridden (`rules[].sourceNamespace`, incl. `"*"`) | - -The split is **100% clean and reproducible**: every `default`-provider object resolved, every -dedicated-provider object was `absent`. That rules out a stochastic grace-window/load effect (which -would be flaky and would not respect the provider boundary). - -The source-namespace override and the dedicated ClusterProvider are both present on the srcns objects, -but the code path below shows the **override is irrelevant** and the **ClusterProvider identity is the -whole cause**. - -## Root cause (confirmed in code + config, not just correlation) - -The attribution fact index is keyed by **provider name**, and the write side and read side derive that -name from two different places that do not agree for a non-`default` in-cluster ClusterProvider: - -1. **Facts are written under the provider name of the audit ROUTE.** `AuditHandler.resolveRoute` - (`internal/webhook/audit_handler.go`) maps `/audit-webhook/` → provider ``, or resolves - it per-event from `--audit-cluster-annotation-key` on the bare `/audit-webhook`. It then calls - `RecordFact(ctx, providerName, event)`, which stores `factKeyExact/Last/RV(providerName, …)` - (`internal/queue/attribution_index.go:129,182,385-395`). - - In this environment the apiserver posts to **`/audit-webhook/default`** (its - `webhook-config.yaml`) and the reverser has **no** `--audit-cluster-annotation-key`, so **every** - local fact is filed under provider `default`. -2. **Facts are read under the GitTarget's ClusterProvider name.** The resolver calls - `LookupAuthorResolution(ctx, providerName, …)` with `providerName = GitTarget.SourceCluster()` — the - `spec.clusterProviderRef` name. For the srcns targets that is **`srcns-delegating`**. - -`default` (write) ≠ `srcns-delegating` (read) → every lookup misses → `absent`, full grace, `written` -but never `matched`. The object's namespace matches on both sides, which is why the `sourceNamespace` -override plays no part. - -**In short:** a ClusterProvider that attribution *reads* under its own name, but under which no audit -route ever *writes* a fact, yields silent `unresolved` for everything mirrored through it. A dedicated -in-cluster ClusterProvider (`kubeConfig` omitted, name ≠ `default`) is exactly that, because the local -apiserver's audit is filed under `default`. - -## Why this matters to us (gitops-api) - -Our one-reverser consolidation gives **every tenant workspace its own (non-`default`) -ClusterProvider** and relies on per-actor attribution as its core value. In our real topology each -workspace is *remote* and posts to the bare `/audit-webhook` with `--audit-cluster-annotation-key= -kcp.io/cluster`, so facts *are* filed under the provider name a GitTarget resolves to — provided the -provider is named exactly the annotation value and the flag is set. This finding is therefore not -(we believe) a blocker for the remote path, but it is a **loud warning about how silent the failure -mode is**: the moment the audit-write name and the GitTarget-read name diverge — a missing -annotation-key flag, a provider named differently from its `kcp.io/cluster` hash, or an in-cluster -provider whose audit still routes to `default` — **every commit becomes `unresolved` with no error, -no condition, and no failed reconcile**. Attribution is our product's core value, so a -misconfiguration that silently drops it (rather than failing loudly) is high-consequence for us. - -## What is NOT affected - -- **Mirroring is correct.** All five objects are mirrored to Git under the right folders; only the - commit *author* is wrong. -- The `default` provider path is unaffected. - -## Reproduction - -1. Bring up the e2e harness on the branch (`task _cluster-ready && task prepare-e2e`; needs - `controller-gen` on PATH and `HOST_PROJECT_PATH` set — see this repo's `hack/spikes/README.md`). -2. `kubectl -n gitops-reverser rollout restart deploy/gitops-reverser` to zero the counters. -3. Run the two specs: - - ```bash - go run github.com/onsi/ginkgo/v2/ginkgo --label-filter='manager' \ - --focus='WatchRule source namespace|Manager GitTarget prune policy' ./test/e2e/ - ``` - -4. Query Prometheus: `sum by (result,resource) (gitopsreverser_attribution_resolutions_total)` and - `sum by (op) (gitopsreverser_attribution_fact_events_total)`; and `git log --author` the mirrored - repos under `.stamps/repos/*/` for `attribution unresolved`. - -## What we'd most like fixed - -**Make this failure loud.** A GitTarget (or ClusterProvider) with attribution enabled whose provider -has **received zero audit facts under its own name** should surface a condition / warning, instead of -silently authoring every commit as `unresolved`. The `unknown (attribution unresolved)` author on this -branch already makes it *visible in git*, which is how we found it — a status condition would make it -*actionable* before a single commit lands wrong. - -## Open questions for the team - -1. Is a **dedicated in-cluster ClusterProvider** (`kubeConfig` omitted, name ≠ `default`) intended to - be a supported attribution configuration at all? If yes, where should its facts come from, given the - local apiserver's audit is filed under `default`? If no, admission could reject/warn on it. -2. Confirmation that the **remote path we depend on** — bare `/audit-webhook` + - `--audit-cluster-annotation-key`, provider named exactly the annotation value — files facts under - the same name the GitTarget resolves to. (Our reading of the code says yes; we'd value a confirmation - and, ideally, an e2e that asserts a *remote-CP* commit is attributed, since the current - `source-cluster` spec only exercises an unreachable kubeconfig.) -3. Would you accept a small e2e that pins this: a GitTarget on the `default` provider **with** a - `sourceNamespace` override attributes correctly (isolating the override as innocent), while a - dedicated-provider GitTarget does not? We have the harness set up and can contribute it. - -## Root-cause code references - -- `internal/queue/attribution_index.go` — `RecordFact(providerName,…)` (:129), `writeFactKeys` (:182), - `matchFactKey` under `factKeyExact/Last/RV(providerName,…)` (:385–395), `LookupAuthorResolution(providerName,…)` (:360). -- `internal/webhook/audit_handler.go` — `resolveRoute` / `providerRouteForPath` (:155–204): route → provider name. -- This environment: apiserver `webhook-config.yaml` server `…/audit-webhook/default`; reverser args carry - no `--audit-cluster-annotation-key`; srcns GitTargets `clusterProviderRef: srcns-delegating`. diff --git a/docs/configuration.md b/docs/configuration.md index 5924150a..6737c09a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -38,29 +38,19 @@ multiple watch rules. ## Why the two provider types have different scopes -`GitProvider` and `ClusterProvider` are both named connections, but their scope follows what they -identify and who normally owns their credentials. API symmetry was not a goal. A Git -destination is normally a team's write boundary, so a namespaced `GitProvider` keeps the repository -credential and its consumers together. A source cluster is a shared physical identity: its client, -discovery surface, watch state, and attribution partition must mean the same thing to every target -that uses it. That makes `ClusterProvider` cluster-scoped. - -| Object | Scope | What it represents | Why | -|---|---|---|---| -| `GitProvider` | Namespace | A Git destination and the credentials allowed to write it | A repository destination is normally owned by one team. Keeping the provider and its Secret in that team's namespace makes the ownership boundary direct. | -| `ClusterProvider` | Cluster | One physical Kubernetes source cluster | A source cluster can feed targets in several namespaces, while its connection, watch state, and attribution identity must stay the same everywhere. | +| Object | Scope | What it represents | +|---|---|---| +| `GitProvider` | Namespace | A Git destination and the credentials allowed to write it. A repository is normally one team's write boundary, so the provider and its Secret sit in that team's namespace. | +| `ClusterProvider` | Cluster | One Kubernetes source cluster. It can feed targets in several namespaces, and its connection, watch state and attribution identity must mean the same thing to all of them. | There is no default `GitProvider`: the operator cannot infer a safe repository, branch, or write -credential. `GitTarget.spec.clusterProviderRef` instead defaults to the conventionally opinionated -name `default`. That is a convenient, concrete reference. It does not claim that `default` is always -the local cluster. +credential, so every `GitTarget` names one. `GitTarget.spec.clusterProviderRef` does default, to the +conventional name `default`. That is a concrete reference to jump to; it does not claim that +`default` is the local cluster. -`ClusterProvider.spec.accessFrom` is the control-cluster authorization boundary for that -shared source connection: it determines which namespaces may contain `GitTarget`s that reference -the provider. It does not select namespaces in the source cluster or grant permissions there. If a -platform later needs a shared, platform-owned Git destination, that should be a separate -cluster-scoped Git-destination concept with an explicit ownership model, leaving the namespaced -`GitProvider` to mean what it means today. +`ClusterProvider.spec.accessFrom` decides which control-cluster namespaces may contain `GitTarget`s +that reference the provider. It does not select namespaces in the source cluster and grants nothing +there. ## `GitProvider` @@ -446,11 +436,10 @@ Use conditions for automation. ### `GitTarget.spec.commit`: how writes become commits `spec.commit` says how this target's writes are batched into commits and how those commits are -phrased. Both halves used to live on the `GitProvider` (as `spec.push.commitWindow` and -`spec.commit.message`) and moved here because they describe the folder being written rather than -the route to the repository. Two `GitTarget`s sharing one `GitProvider` can now disagree about -both: an RBAC folder that wants a commit per change and an app folder that wants a burst coalesced -no longer have to be two connections. +phrased. Both describe the folder being written rather than the route to the repository, which is +why they sit here and not on the `GitProvider`: two `GitTarget`s sharing one connection can disagree +about both, so an RBAC folder that wants a commit per change and an app folder that wants a burst +coalesced need not be two connections. ```yaml spec: @@ -785,10 +774,7 @@ For each new resource the operator walks this order and stops at the first that **The operator does not read the rest of your repository to place a file.** Where you keep the other ConfigMaps does not decide where a new ConfigMap goes: a layout the ladder above cannot derive is one -you declare. That is deliberate. An earlier version followed the surrounding layout, which meant -editing the repository silently changed where the operator wrote next, with no Kubernetes object -changing and nothing recording the move. If you are upgrading from a release that had it, see -[`UPGRADING.md`](UPGRADING.md). +you declare. So editing the repository never changes where the operator writes next. #### Knowing when you need a rule @@ -802,8 +788,11 @@ sum by (gittarget_namespace, gittarget_name, group, version, resource) ( ) ``` -`source="declared"` is a path you asked for, and `source="kustomize_root"` is a folder whose own -structure answered. Neither needs attention, and two companions matter as much: +`source="by_type"` is the exact rule you wrote for that type, `source="default"` is your catch-all +answering for a type no `byType` line names, and `source="kustomize_root"` is a folder whose own +structure answered. None needs attention, though a `default` series climbing where you expected +`by_type` is worth a look: it means a rule you thought you had written is not matching. Two +companions matter as much: - `gitopsreverser_placement_refusals_total{reason}` counts resources the operator **did not write**: a template that escapes `spec.path` (`invalid_path`), a sensitive resource whose path is already @@ -894,8 +883,8 @@ co-mingled with a plaintext document. Two consequences for your templates: ### Choosing between the two layout fields -`spec.serializeNamespace` and `spec.placement.useKustomize` answer two independent questions, and a -target that sets neither behaves exactly as it did before they existed. Start here, then read the +`spec.serializeNamespace` and `spec.placement.useKustomize` answer two independent questions. Both +are optional; a target that sets neither infers the answer per document. Start here, then read the section for whichever you set. **Question 1: where does the namespace of a mirrored object live?** @@ -1139,7 +1128,7 @@ so one `WatchRule` can follow different resource types in different namespaces. | `rules[].sourceNamespace` | Meaning | |---|---| -| omitted | the `WatchRule`'s own namespace: legacy behavior, byte for byte | +| omitted | the `WatchRule`'s own namespace | | an exact name | one source namespace | | `"*"` | every namespace the source credential can read, as one cluster-wide watch | @@ -1259,10 +1248,6 @@ it is intentionally cluster-global, limited only by its source credential's Kube sparingly. It grants the widest reach of any rule kind and usually belongs to cluster-admin-managed setups. -> `spec.rules[].scope` is deprecated and accepts only `Cluster` (its default). Re-applying a -> pre-release manifest that still says `scope: Namespaced` is **rejected**. See -> [UPGRADING.md](UPGRADING.md) for the conversion. - ## `CommitRequest` `CommitRequest` is a one-shot "save now" signal for a same-namespace `GitTarget`. It does not create diff --git a/docs/interpreting-metrics.md b/docs/interpreting-metrics.md index 03cd1193..db8aeac3 100644 --- a/docs/interpreting-metrics.md +++ b/docs/interpreting-metrics.md @@ -79,7 +79,7 @@ signals. Background: [architecture.md → Git Write Architecture](architecture.m | `git_operations_total` | counter | — | Events that produced Git work in a flush. | | `objects_written_total` | counter | — | Objects that resulted in a file write in a flush. | | `resync_sweep_deletes_total` | counter | `group`, `version`, `resource` | Managed documents deleted by mark-and-sweep resyncs. Steady-state watch deletes do not increment this. | -| `placements_total` | counter | `source`, `disposition`, `gittarget_namespace`, `gittarget_name`, `group`, `version`, `resource` | One per new document written at a resolved path. `source` is `declared` / `kustomize_root` / `canonical`; `disposition` is `new_file` / `appended`. | +| `placements_total` | counter | `source`, `disposition`, `gittarget_namespace`, `gittarget_name`, `group`, `version`, `resource` | One per new document written at a resolved path. `source` is `by_type` / `default` / `kustomize_root` / `canonical`; `disposition` is `new_file` / `appended`. | | `placement_refusals_total` | counter | `reason`, `gittarget_namespace`, `gittarget_name`, `group`, `version`, `resource` | One per new resource the writer declined to place. Every increment is a resource **absent** from the mirror. | | `placement_kustomization_entries_total` | counter | `outcome`, `gittarget_namespace`, `gittarget_name` | The `resources:` entry a newly placed file needs: `added`, `no_change`, `failed`. | | `branch_worker_queue_depth` | gauge | `provider_namespace`, `provider_name`, `branch` | Pending + in-flight + committed-but-unpushed work; reads 0 only when the worker has fully drained. | @@ -164,13 +164,14 @@ one — and a zero rate is the steady state, not a broken exporter. | `source` | Means | Needs attention? | | --- | --- | --- | -| `declared` | a `spec.placement.byType` or `.default` template matched | no — this is what you asked for | +| `by_type` | a `spec.placement.byType` entry named this exact type | no — this is what you asked for | +| `default` | no `byType` entry named the type, so the catch-all `spec.placement.default` answered | **maybe** — a rule you meant to write may not be matching | | `kustomize_root` | the folder is governed by exactly one supported kustomization, so the file went beside it and joined its `resources:` list | no — the folder's own structure decided | | `canonical` | nothing else applied, so the built-in `{namespace}/{group}/{resource}/{name}.yaml` path was used | **maybe** — see below | **Which types are falling back, and in which target?** Each series is a candidate for one -`placement.byType` line. This is the signal that replaced sibling inference: the operator no longer -guesses a hand-authored layout from the folder, so this is how you learn a layout needs declaring: +`placement.byType` line. The operator never guesses a hand-authored layout from the folder, so this +is how you learn a layout needs declaring: ```promql sum by (gittarget_namespace, gittarget_name, group, version, resource) ( @@ -182,8 +183,8 @@ layout and always will be. It is worth acting on when the folder has a conventio told about — the file lands somewhere tidy but not where the rest of that type lives. **Is a bundling policy actually bundling?** `disposition="appended"` proves documents are joining an -existing file rather than each getting their own. It should only ever appear with -`source="declared"`; the fallbacks never append: +existing file rather than each getting their own. It should only ever appear with `source="by_type"` +or `source="default"`; the fallbacks never append: ```promql sum by (source, disposition) (increase(gitopsreverser_placements_total[24h])) diff --git a/docs/layout/new-file-placement-rules.md b/docs/layout/new-file-placement-rules.md index 7b15b2aa..59d195cb 100644 --- a/docs/layout/new-file-placement-rules.md +++ b/docs/layout/new-file-placement-rules.md @@ -1249,9 +1249,8 @@ placement: ``` after which the same ConfigMap is appended to `all.yaml`, and the metric moves to -`source="declared", disposition="appended"`. **This is the behaviour change**: before the -Option C deletion the bundle was extended with no declaration at all. One line of YAML buys -back the old behaviour, and it says on the page what used to be a guess. +`source="by_type", disposition="appended"`. Bundling is something a target declares: one line +of YAML, and it says on the page what would otherwise be a guess. If instead the folder is a kustomize overlay — one `kustomization.yaml` governing the whole subtree — no declaration is needed: the new document lands beside it and joins its From 35af3a71055458bae87eb7ff87f3c0bf2d9faf13 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Thu, 3 Sep 2026 10:32:31 +0000 Subject: [PATCH 4/9] fix(test): drop the pruned reference sub-fields from the e2e fixtures Every e2e manifest still spelled its references the old way, and kubectl does not prune quietly: strict field validation is its default, so each apply failed with `unknown field "spec.targetRef.kind"` and took 30 specs with it. That is worth more than the fix. The removal was documented as accepted-and- silently-pruned, which is only half true and the wrong half to lead with: a hand-applied manifest is REJECTED and names the field, while a client applying with field validation off accepts it and prunes. Both halves are now measured in docs/facts/crd-upgrade-strategies.md, UPGRADING.md says which client gets which, and the envtest that pins the quiet path says in its comment that it is deliberately taking the non-strict route. Co-Authored-By: Claude Opus 5 --- docs/UPGRADING.md | 36 +++++++++++++------ docs/facts/crd-upgrade-strategies.md | 22 ++++++++++-- .../superseded_fields_admission_test.go | 7 ++-- test/e2e/audit_route_attribution_e2e_test.go | 2 -- .../deployment_scale_subresource_e2e_test.go | 1 - test/e2e/prune_mode_e2e_test.go | 1 - test/e2e/render_fidelity_e2e_test.go | 1 - test/e2e/source_cluster_e2e_test.go | 7 ++-- test/e2e/source_namespace_e2e_test.go | 1 - test/e2e/suspend_e2e_test.go | 1 - .../aggregated-api/watchrule-flunder.tmpl | 1 - .../bi-directional/watchrule-secret.tmpl | 1 - test/e2e/templates/demo/watchrule-all.tmpl | 1 - test/e2e/templates/gittarget.tmpl | 1 - .../templates/manager/gittarget-prune.tmpl | 1 - .../manager/watchrule-configmap.tmpl | 1 - .../manager/watchrule-resources.tmpl | 1 - test/e2e/templates/watchrule-secret.tmpl | 1 - test/e2e/templates/watchrule.tmpl | 1 - .../watchrule_configmap_secret_e2e_test.go | 1 - 20 files changed, 53 insertions(+), 36 deletions(-) diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index a005767d..27d37f3b 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -116,11 +116,23 @@ NS:.metadata.namespace,NAME:.metadata.name,COMMIT:.spec.commit the manifest still carries an old spelling. Check `COMMIT` renders **both** halves you migrated — a `map[window:...]` with no `message:` key means the templates did not come across. -**What you will NOT get is a warning.** Because the fields are removed rather than retained, an -object still carrying an old spelling is accepted with the value silently pruned — the mirror keeps -running under the new defaults. That is a deliberate trade for a much smaller code surface, priced -in [`facts/crd-upgrade-strategies.md`](facts/crd-upgrade-strategies.md), and it is why the inventory -is step 1 rather than a footnote. +**Whether you get a warning depends on the client, and the one that matters is the quiet one.** +`kubectl apply` asks for strict field validation by default (since 1.25), so a manifest still +carrying a removed spelling is REJECTED and names the field: + +```text +error: ... strict decoding error: unknown field "spec.allowedSourceNamespaces" +``` + +A client that does not ask for strict validation — `kubectl apply --validate=ignore`, and any +controller applying with `fieldValidation: Ignore`, which includes some GitOps tooling — is +**accepted with the value silently pruned**, and the mirror carries on under the new defaults with +nothing reporting it. So if you apply by hand you will be told; if Flux or Argo CD applies your +manifests for you, check what your tool does before assuming a green sync means a migrated object. + +Either way the inventory is step 1 rather than a footnote, because a pruned value is not recoverable +from the API. The two removal strategies are priced in +[`facts/crd-upgrade-strategies.md`](facts/crd-upgrade-strategies.md). ## Every reference is `{name}` — `group` and `kind` are gone @@ -147,9 +159,10 @@ a default equal to it, so no manifest could ever say anything but `configbutler. the field accepts. What the sub-fields cost was four near-identical Go types and a schema that implied a choice nobody had. -**They are removed, not refused**, so a manifest that still sets them applies cleanly with the -values pruned. That is harmless here — the pruned value was the only legal one — but it does mean -`kubectl apply` will not tell you your manifests are out of date. Tidy them at your convenience. +**They are removed, not refused.** `kubectl apply` will therefore reject a manifest that still sets +them, naming the field (`unknown field "spec.providerRef.kind"`), while a client applying with +field validation off accepts it and prunes. Nothing breaks either way, since the pruned value was +the only legal one — but a strict apply will stop until you delete the lines. `GitProvider.spec.knownHostsRef` keeps its `kind`, and that is not an oversight: it chooses between a ConfigMap and a Secret, which is a real choice rather than an enum of one. @@ -175,9 +188,10 @@ setting `scope: Namespaced`, it has not been mirroring anything since then, and same one that entry describes: a `WatchRule` in the tenant namespace with `spec.rules[].sourceNamespace`. -From this release the value is pruned instead of rejected, so an unconverted manifest applies -without complaint. That is the trade for deleting the field, and it is why the -[inventory](#safe-upgrade-order-for-the-gittarget-api-changes) lists it. +From this release the value is no longer part of the schema, so `kubectl apply` reports it as an +unknown field and a non-strict client prunes it silently. Either way it stops being a rejection with +a message that names the replacement, which is what the shim bought and what the +[inventory](#safe-upgrade-order-for-the-gittarget-api-changes) now stands in for. ## The placement metric reports which declaration answered (breaking for dashboards) diff --git a/docs/facts/crd-upgrade-strategies.md b/docs/facts/crd-upgrade-strategies.md index 91ba15f2..e33cfb9d 100644 --- a/docs/facts/crd-upgrade-strategies.md +++ b/docs/facts/crd-upgrade-strategies.md @@ -20,12 +20,30 @@ governed by how many consumers you have, not by which is technically nicer: - **Retain and refuse** — keep the field in the schema, reject it, for one release. Costs code, buys a signal. -- **Delete outright** — remove it, accept that stored values are pruned silently, put the migration - in a document. Costs nothing, buys nothing, and is correct when the population is small enough to +- **Delete outright** — remove it, accept that stored values are pruned, put the migration in a + document. Costs nothing, buys nothing, and is correct when the population is small enough to migrate by hand. A **conversion webhook is not a third option** for most changes. See the matrix. +### How silent "pruned silently" actually is — measured + +Pruning is not uniformly quiet, and which half of it a user meets is decided by their client rather +than by the schema. Measured against this project's own CRDs on 1.31, applying a `GitTarget` that +still carries the removed `spec.allowedSourceNamespaces`: + +| Client | Result | +|---|---| +| `kubectl apply` (strict field validation, the default since 1.25) | **rejected**: `strict decoding error: unknown field "spec.allowedSourceNamespaces"` | +| `kubectl apply --validate=ignore` | accepted, field absent from the stored object | +| A controller-runtime client, and any apply with `fieldValidation: Ignore` | accepted, field absent from the stored object | + +So "delete outright" buys a signal after all for anyone applying by hand — the loud half of +retain-and-refuse, without the schema residue, though with a message that names the field rather +than its replacement. It buys nothing for a GitOps controller reconciling the manifest on the user's +behalf, which is the population that matters here and the reason the migration still belongs in a +document. + ## Decision matrix Pick the row that matches the change, then read across. diff --git a/internal/controller/superseded_fields_admission_test.go b/internal/controller/superseded_fields_admission_test.go index d3995f07..80269199 100644 --- a/internal/controller/superseded_fields_admission_test.go +++ b/internal/controller/superseded_fields_admission_test.go @@ -31,8 +31,11 @@ var _ = Describe("Superseded source-scope fields", func() { It("accepts and PRUNES a legacy manifest that still sets rules[].scope", func() { ctx := context.Background() - // Applied as unstructured, because the Go types no longer have the field to set. This is - // how a manifest written for 0.42 or earlier reaches a 0.43 apiserver. + // Applied as unstructured, because the Go types no longer have the field to set, and + // through a client that does not ask for strict field validation. That is the QUIET half + // of a removal and the one worth pinning: `kubectl apply` would reject this manifest by + // naming the unknown field, but a GitOps controller reconciling it on the user's behalf + // may not. See docs/facts/crd-upgrade-strategies.md. legacy := &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "configbutler.ai/v1alpha3", "kind": "ClusterWatchRule", diff --git a/test/e2e/audit_route_attribution_e2e_test.go b/test/e2e/audit_route_attribution_e2e_test.go index 89962109..3a58a692 100644 --- a/test/e2e/audit_route_attribution_e2e_test.go +++ b/test/e2e/audit_route_attribution_e2e_test.go @@ -203,7 +203,6 @@ metadata: namespace: %s spec: providerRef: - kind: GitProvider name: %s branch: main path: %s @@ -225,7 +224,6 @@ metadata: namespace: %s spec: targetRef: - kind: GitTarget name: %s rules: - resources: ["configmaps"] diff --git a/test/e2e/deployment_scale_subresource_e2e_test.go b/test/e2e/deployment_scale_subresource_e2e_test.go index ba24ee25..8d77d5bb 100644 --- a/test/e2e/deployment_scale_subresource_e2e_test.go +++ b/test/e2e/deployment_scale_subresource_e2e_test.go @@ -75,7 +75,6 @@ metadata: namespace: %s spec: targetRef: - kind: GitTarget name: %s rules: - apiGroups: ["apps"] diff --git a/test/e2e/prune_mode_e2e_test.go b/test/e2e/prune_mode_e2e_test.go index 40761dd8..b865ccc8 100644 --- a/test/e2e/prune_mode_e2e_test.go +++ b/test/e2e/prune_mode_e2e_test.go @@ -355,7 +355,6 @@ metadata: namespace: %s spec: providerRef: - kind: GitProvider name: %s branch: main path: %s diff --git a/test/e2e/render_fidelity_e2e_test.go b/test/e2e/render_fidelity_e2e_test.go index 72675298..0e71c8fb 100644 --- a/test/e2e/render_fidelity_e2e_test.go +++ b/test/e2e/render_fidelity_e2e_test.go @@ -220,7 +220,6 @@ metadata: namespace: %s spec: targetRef: - kind: GitTarget name: %s rules: - apiGroups: ["apps"] diff --git a/test/e2e/source_cluster_e2e_test.go b/test/e2e/source_cluster_e2e_test.go index ffe04040..813325c9 100644 --- a/test/e2e/source_cluster_e2e_test.go +++ b/test/e2e/source_cluster_e2e_test.go @@ -204,7 +204,6 @@ metadata: namespace: %s spec: providerRef: - kind: GitProvider name: %s branch: main path: %s @@ -358,7 +357,7 @@ var _ = Describe("Manager source cluster / ClusterProvider attribution", Label(" kind: GitTarget metadata: {name: %s, namespace: %s} spec: - providerRef: {kind: GitProvider, name: %s} + providerRef: {name: %s} branch: main path: clusters/local commit: @@ -419,7 +418,7 @@ spec: kind: WatchRule metadata: {name: %s-rule, namespace: %s} spec: - targetRef: {kind: GitTarget, name: %s} + targetRef: {name: %s} rules: - resources: ["configmaps"] `, ws, testNs, target) @@ -485,7 +484,7 @@ spec: kind: WatchRule metadata: {name: %s-rule, namespace: %s} spec: - targetRef: {kind: GitTarget, name: %s} + targetRef: {name: %s} rules: - resources: ["configmaps"] `, c.ws, testNs, target) diff --git a/test/e2e/source_namespace_e2e_test.go b/test/e2e/source_namespace_e2e_test.go index 66612e2a..cc6b7573 100644 --- a/test/e2e/source_namespace_e2e_test.go +++ b/test/e2e/source_namespace_e2e_test.go @@ -305,7 +305,6 @@ metadata: namespace: %s spec: targetRef: - kind: GitTarget name: %s rules: - resources: ["configmaps"] diff --git a/test/e2e/suspend_e2e_test.go b/test/e2e/suspend_e2e_test.go index 28daa3df..861ee4db 100644 --- a/test/e2e/suspend_e2e_test.go +++ b/test/e2e/suspend_e2e_test.go @@ -214,7 +214,6 @@ metadata: namespace: %s spec: providerRef: - kind: GitProvider name: %s branch: main path: %s diff --git a/test/e2e/templates/aggregated-api/watchrule-flunder.tmpl b/test/e2e/templates/aggregated-api/watchrule-flunder.tmpl index 52549806..808a4224 100644 --- a/test/e2e/templates/aggregated-api/watchrule-flunder.tmpl +++ b/test/e2e/templates/aggregated-api/watchrule-flunder.tmpl @@ -5,7 +5,6 @@ metadata: namespace: {{ .Namespace }} spec: targetRef: - kind: GitTarget name: {{ .GitTargetName }} rules: - apiGroups: ["wardle.example.com"] diff --git a/test/e2e/templates/bi-directional/watchrule-secret.tmpl b/test/e2e/templates/bi-directional/watchrule-secret.tmpl index 296dc376..a20e244a 100644 --- a/test/e2e/templates/bi-directional/watchrule-secret.tmpl +++ b/test/e2e/templates/bi-directional/watchrule-secret.tmpl @@ -5,7 +5,6 @@ metadata: namespace: {{ .Namespace }} spec: targetRef: - kind: GitTarget name: {{ .DestinationName }} rules: - resources: ["secrets"] diff --git a/test/e2e/templates/demo/watchrule-all.tmpl b/test/e2e/templates/demo/watchrule-all.tmpl index 5c44f7dd..b2ed1f29 100644 --- a/test/e2e/templates/demo/watchrule-all.tmpl +++ b/test/e2e/templates/demo/watchrule-all.tmpl @@ -5,7 +5,6 @@ metadata: namespace: {{ .Namespace }} spec: targetRef: - kind: GitTarget name: {{ .DestinationName }} rules: - operations: [CREATE, UPDATE, DELETE] diff --git a/test/e2e/templates/gittarget.tmpl b/test/e2e/templates/gittarget.tmpl index e01cb9a8..fda63e1e 100644 --- a/test/e2e/templates/gittarget.tmpl +++ b/test/e2e/templates/gittarget.tmpl @@ -5,7 +5,6 @@ metadata: namespace: {{ .Namespace }} spec: providerRef: - kind: GitProvider name: {{ .ProviderName }} branch: {{ .Branch }} path: {{ .Path }} diff --git a/test/e2e/templates/manager/gittarget-prune.tmpl b/test/e2e/templates/manager/gittarget-prune.tmpl index 491ffda9..5e48115c 100644 --- a/test/e2e/templates/manager/gittarget-prune.tmpl +++ b/test/e2e/templates/manager/gittarget-prune.tmpl @@ -5,7 +5,6 @@ metadata: namespace: {{ .Namespace }} spec: providerRef: - kind: GitProvider name: {{ .ProviderName }} branch: {{ .Branch }} path: {{ .Path }} diff --git a/test/e2e/templates/manager/watchrule-configmap.tmpl b/test/e2e/templates/manager/watchrule-configmap.tmpl index 970ba872..1a506bbd 100644 --- a/test/e2e/templates/manager/watchrule-configmap.tmpl +++ b/test/e2e/templates/manager/watchrule-configmap.tmpl @@ -5,7 +5,6 @@ metadata: namespace: {{.Namespace}} spec: targetRef: - kind: GitTarget name: {{.DestinationName}} rules: - resources: ["configmaps"] diff --git a/test/e2e/templates/manager/watchrule-resources.tmpl b/test/e2e/templates/manager/watchrule-resources.tmpl index 010b15d1..53fa0dc6 100644 --- a/test/e2e/templates/manager/watchrule-resources.tmpl +++ b/test/e2e/templates/manager/watchrule-resources.tmpl @@ -5,7 +5,6 @@ metadata: namespace: {{.Namespace}} spec: targetRef: - kind: GitTarget name: {{.DestinationName}} rules: - resources: [{{.Resources}}] diff --git a/test/e2e/templates/watchrule-secret.tmpl b/test/e2e/templates/watchrule-secret.tmpl index 78b52681..2bb06fa3 100644 --- a/test/e2e/templates/watchrule-secret.tmpl +++ b/test/e2e/templates/watchrule-secret.tmpl @@ -5,7 +5,6 @@ metadata: namespace: {{.Namespace}} spec: targetRef: - kind: GitTarget name: {{.DestinationName}} rules: - resources: ["secrets"] diff --git a/test/e2e/templates/watchrule.tmpl b/test/e2e/templates/watchrule.tmpl index f65499e5..ec95e687 100644 --- a/test/e2e/templates/watchrule.tmpl +++ b/test/e2e/templates/watchrule.tmpl @@ -5,7 +5,6 @@ metadata: namespace: {{.Namespace}} spec: targetRef: - kind: GitTarget name: {{.DestinationName}} rules: - resources: ["deployments", "services", "configmaps", "secrets"] diff --git a/test/e2e/watchrule_configmap_secret_e2e_test.go b/test/e2e/watchrule_configmap_secret_e2e_test.go index 51b13dfa..37f84697 100644 --- a/test/e2e/watchrule_configmap_secret_e2e_test.go +++ b/test/e2e/watchrule_configmap_secret_e2e_test.go @@ -139,7 +139,6 @@ metadata: namespace: %s spec: targetRef: - kind: GitTarget name: %s rules: - apiGroups: ["*"] From 2b25ef0306ac0a195d51988f6d46db98d8abc412 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Thu, 3 Sep 2026 11:16:38 +0000 Subject: [PATCH 5/9] fix(api): refuse an empty reference name again, and finish the removal notes Collapsing our reference types onto Flux's dropped the MinLength=1 they carried on name, and I restored it only on the provider and target references. The three Secret references were left open, and one of them fails OPEN: GitProvider.spec.secretRef reads an absent credential as anonymous access for a public repository, so {name: ""} silently downgraded a private repository instead of failing. The other two (encryption and commit signing) fail closed at reconcile, which is still a regression from admission-time to runtime. All three get the constraint back as a field-level CEL rule, which is where it has to live now that the struct comes from another module. The generated schema is compared against the pre-collapse one: same required list, same effect, so this restores the old constraint rather than tightening it. The pre-upgrade inventory missed ClusterWatchRule's targetRef and every GitProvider Secret reference, including signing. It now walks every reference on every kind, and the entry lists them in a table rather than a sentence that was already incomplete when it was written. Also drops two comments that still described the scope: Namespaced compile refusal this branch deleted. Co-Authored-By: Claude Opus 5 --- api/v1alpha3/gitprovider_types.go | 9 ++++ api/v1alpha3/zz_generated.deepcopy.go | 2 +- .../bases/configbutler.ai_gitproviders.yaml | 16 +++++- .../crd/bases/configbutler.ai_gittargets.yaml | 3 ++ docs/UPGRADING.md | 26 ++++++--- .../controller/clusterwatchrule_controller.go | 18 +++---- .../superseded_fields_admission_test.go | 53 +++++++++++++++++++ internal/watch/bootstrap.go | 3 +- 8 files changed, 107 insertions(+), 23 deletions(-) diff --git a/api/v1alpha3/gitprovider_types.go b/api/v1alpha3/gitprovider_types.go index 78207d46..f4eb2a4d 100644 --- a/api/v1alpha3/gitprovider_types.go +++ b/api/v1alpha3/gitprovider_types.go @@ -26,6 +26,13 @@ type GitProviderSpec struct { URL string `json:"url"` // SecretRef for authentication credentials (may be nil for public repos) + // + // An OMITTED secretRef means anonymous access. An empty name is refused rather than treated as + // one: a typo there would otherwise downgrade a private repository to anonymous silently, and + // the failure would surface as an authentication error against the remote rather than as a + // mistake in this object. + // +optional + // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.secretRef.name must not be empty; omit secretRef entirely for anonymous access" SecretRef *meta.LocalObjectReference `json:"secretRef,omitempty"` // KnownHostsRef optionally points at a namespace-local ConfigMap or Secret holding SSH @@ -74,6 +81,7 @@ type EncryptionSpec struct { Provider string `json:"provider"` // SecretRef references namespace-local Secret data used by the encryption provider. + // +kubebuilder:validation:XValidation:rule="self.name != ''",message="encryption.secretRef.name must not be empty" // +optional SecretRef meta.LocalObjectReference `json:"secretRef,omitempty"` @@ -196,6 +204,7 @@ type CommitMessageSpec struct { // CommitSigningSpec configures commit signing. type CommitSigningSpec struct { // SecretRef references the Secret containing the signing key material. + // +kubebuilder:validation:XValidation:rule="self.name != ''",message="commit.signing.secretRef.name must not be empty" // Expected keys will be defined by the signing implementation. SecretRef meta.LocalObjectReference `json:"secretRef"` diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index debf5d34..73c9ab54 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -8,7 +8,7 @@ package v1alpha3 import ( "github.com/fluxcd/pkg/apis/meta" - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) diff --git a/config/crd/bases/configbutler.ai_gitproviders.yaml b/config/crd/bases/configbutler.ai_gitproviders.yaml index 88a456cc..9101f6eb 100644 --- a/config/crd/bases/configbutler.ai_gitproviders.yaml +++ b/config/crd/bases/configbutler.ai_gitproviders.yaml @@ -104,6 +104,9 @@ spec: required: - name type: object + x-kubernetes-validations: + - message: commit.signing.secretRef.name must not be empty + rule: self.name != '' required: - secretRef type: object @@ -132,8 +135,13 @@ spec: - name type: object secretRef: - description: SecretRef for authentication credentials (may be nil - for public repos) + description: |- + SecretRef for authentication credentials (may be nil for public repos) + + An OMITTED secretRef means anonymous access. An empty name is refused rather than treated as + one: a typo there would otherwise downgrade a private repository to anonymous silently, and + the failure would surface as an authentication error against the remote rather than as a + mistake in this object. properties: name: description: Name of the referent. @@ -141,6 +149,10 @@ spec: required: - name type: object + x-kubernetes-validations: + - message: spec.secretRef.name must not be empty; omit secretRef entirely + for anonymous access + rule: self.name != '' url: description: |- URL of the repository (HTTP/SSH). diff --git a/config/crd/bases/configbutler.ai_gittargets.yaml b/config/crd/bases/configbutler.ai_gittargets.yaml index 16219146..25d1802c 100644 --- a/config/crd/bases/configbutler.ai_gittargets.yaml +++ b/config/crd/bases/configbutler.ai_gittargets.yaml @@ -235,6 +235,9 @@ spec: required: - name type: object + x-kubernetes-validations: + - message: encryption.secretRef.name must not be empty + rule: self.name != '' required: - provider type: object diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 27d37f3b..9bdfef9f 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -48,10 +48,16 @@ kubectl get gitproviders -A -o json | message<-\(.spec.commit.message)"' # The reference fields and the ClusterWatchRule scope, which carry no information to migrate but -# will be pruned out from under a manifest that still sets them: -kubectl get gittargets,watchrules,commitrequests -A -o json | - jq -r '.items[] | select((.spec.targetRef.kind? // .spec.providerRef.kind?) != null) - | "\(.kind) \(.metadata.namespace)/\(.metadata.name): drop the group/kind under its *Ref"' +# will be pruned out from under a manifest that still sets them. Every *Ref on every kind, not +# just the two obvious ones: +kubectl get gittargets,watchrules,clusterwatchrules,commitrequests,gitproviders -A -o json | + jq -r '.items[] + | . as $o + | [ (.spec.providerRef, .spec.clusterProviderRef, .spec.targetRef, + .spec.secretRef, .spec.encryption.secretRef, .spec.commit.signing.secretRef) + | select(. != null) | select(has("kind") or has("group")) ] + | select(length > 0) + | "\($o.kind) \($o.metadata.namespace // "-")/\($o.metadata.name): drop group/kind under \(length) reference(s)"' kubectl get clusterwatchrules -o json | jq -r '.items[] | select(any(.spec.rules[]?; has("scope"))) @@ -150,9 +156,15 @@ spec: spec: name: platform ``` -The same edit applies to `GitTarget.spec.providerRef` and `.spec.clusterProviderRef`, -`WatchRule.spec.targetRef`, `ClusterWatchRule.spec.targetRef`, `CommitRequest.spec.targetRef`, -`GitProvider.spec.secretRef`, and the `secretRef` under `spec.encryption`. +The same edit applies to every reference on every kind: + +| Kind | Reference fields | +|---|---| +| `GitTarget` | `spec.providerRef`, `spec.clusterProviderRef`, `spec.encryption.secretRef` | +| `GitProvider` | `spec.secretRef`, `spec.commit.signing.secretRef` | +| `WatchRule` | `spec.targetRef` | +| `ClusterWatchRule` | `spec.targetRef` | +| `CommitRequest` | `spec.targetRef` | **Nothing is lost.** Each of those `group` and `kind` fields was an enum with exactly one member and a default equal to it, so no manifest could ever say anything but `configbutler.ai` and the one kind diff --git a/internal/controller/clusterwatchrule_controller.go b/internal/controller/clusterwatchrule_controller.go index a2e48b21..f8210710 100644 --- a/internal/controller/clusterwatchrule_controller.go +++ b/internal/controller/clusterwatchrule_controller.go @@ -232,18 +232,14 @@ func (r *ClusterWatchRuleReconciler) reconcileClusterWatchRuleViaTarget( } // gateClusterWatchRule is the ClusterWatchRule gate and the ONE place this controller compiles a -// cluster rule. It runs the shared compile path, which applies two refusals in order: +// cluster rule. It runs the shared compile path, whose refusal is the ClusterProvider namespace +// admission of the referenced GitTarget: a ClusterWatchRule is cluster-scoped and its targetRef +// carries a REQUIRED namespace, so it may name a GitTarget in ANY namespace and widen that target's +// mirror scope cluster-wide. Compiling such a rule without re-applying the target's own provider +// admission would let it mirror through a credential whose accessFrom never admitted that target. // -// 1. the ClusterProvider namespace admission of the referenced GitTarget. A ClusterWatchRule is -// cluster-scoped and its targetRef carries a REQUIRED namespace, so it may name a GitTarget in -// ANY namespace and widen that target's mirror scope cluster-wide. Compiling such a rule -// without re-applying the target's own provider admission would let it mirror through a -// credential whose accessFrom never admitted that target. -// 2. the cluster-scope-only narrowing: a STORED rule that still says `scope: Namespaced` compiles -// no stream. Admission rejects the value on write, but a pre-release object keeps it in etcd. -// -// Both live in internal/watch rather than here because the startup bootstrap must apply exactly the -// same refusals BEFORE the first reconcile — otherwise every restart reopens the window they close. +// It lives in internal/watch rather than here because the startup bootstrap must apply exactly the +// same refusal BEFORE the first reconcile — otherwise every restart reopens the window it closes. // // It returns handled=false when the rule compiled and the reconcile should continue; handled=true // means the reconcile is over and the caller must return the accompanying result and error diff --git a/internal/controller/superseded_fields_admission_test.go b/internal/controller/superseded_fields_admission_test.go index 80269199..def71e41 100644 --- a/internal/controller/superseded_fields_admission_test.go +++ b/internal/controller/superseded_fields_admission_test.go @@ -166,3 +166,56 @@ var _ = Describe("Superseded source-scope fields", func() { "at compile time") }) }) + +// Collapsing our reference types onto Flux's dropped the MinLength=1 those types carried on name, +// and `{name: ""}` is not a harmless value: GitProvider.spec.secretRef treats an absent credential +// as anonymous access, so an empty name would downgrade a private repository to anonymous rather +// than fail. The constraint is back as a field-level CEL rule on every reference, and this is the +// guard that it stays there. +var _ = Describe("Reference names must not be empty", func() { + It("refuses an empty secretRef name rather than reading it as anonymous access", func() { + ctx := context.Background() + + provider := &configbutleraiv1alpha3.GitProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "empty-secret-name", Namespace: "default"}, + Spec: configbutleraiv1alpha3.GitProviderSpec{ + URL: "https://example.com/repo.git", + AllowedBranches: []string{"main"}, + SecretRef: &meta.LocalObjectReference{Name: ""}, + }, + } + + err := k8sClient.Create(ctx, provider) + + Expect(err).To(HaveOccurred(), + "an empty secretRef.name must be refused, not read as anonymous access") + Expect(err.Error()).To(ContainSubstring("must not be empty")) + }) + + It("refuses an empty name on the provider and target references", func() { + ctx := context.Background() + + target := &configbutleraiv1alpha3.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "empty-provider-name", Namespace: "default"}, + Spec: configbutleraiv1alpha3.GitTargetSpec{ + ProviderRef: meta.LocalObjectReference{Name: ""}, + Branch: "main", + Path: "clusters/prod", + }, + } + Expect(k8sClient.Create(ctx, target)).NotTo(Succeed(), + "an empty providerRef.name names nothing and must be refused at admission") + + rule := &configbutleraiv1alpha3.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "empty-target-name", Namespace: "default"}, + Spec: configbutleraiv1alpha3.WatchRuleSpec{ + TargetRef: meta.LocalObjectReference{Name: ""}, + Rules: []configbutleraiv1alpha3.ResourceRule{ + {Resources: []string{"configmaps"}}, + }, + }, + } + Expect(k8sClient.Create(ctx, rule)).NotTo(Succeed(), + "an empty targetRef.name names nothing and must be refused at admission") + }) +}) diff --git a/internal/watch/bootstrap.go b/internal/watch/bootstrap.go index a4a6da51..789f8fae 100644 --- a/internal/watch/bootstrap.go +++ b/internal/watch/bootstrap.go @@ -90,8 +90,7 @@ func (m *Manager) bootstrapClusterWatchRule(ctx context.Context, rule configv1al // Route through the SHARED gated compile path, never straight at the store. Bootstrap runs // BEFORE the first reconcile on every restart, so a gate the reconciler alone enforced would be // bypassed for the whole startup window — long enough to compile a rule and plan a stream for a - // target the provider never admitted, or to open a namespaced watch for a stored - // `scope: Namespaced` this release no longer supports. A refusal is not fatal to startup: the + // target the provider never admitted. A refusal is not fatal to startup: the // rule is simply left out of the store, and the reconciler's own gate re-decides (and can grant // it) as soon as the controller's initial sync reaches this rule. decision, err := CompileClusterWatchRule(ctx, m.Client, m.RuleStore, rule, target, provider) From e3c076d4ff3528d8ab8fbb0d226a8859e2a37746 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Thu, 3 Sep 2026 11:23:07 +0000 Subject: [PATCH 6/9] feat(api)!: name every reference after the kind it points at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapsing onto Flux's LocalObjectReference took the kind out of the type, which puts it on the field name: `{name: platform}` says nothing about what `platform` is, so the field has to. Flux does exactly this — generic `sourceRef` where several kinds are valid, specific `secretRef` and `certSecretRef` where one is. Shipping the collapse without the renames would have adopted the half of that convention that loses information. GitTarget.spec.providerRef -> spec.gitProviderRef WatchRule.spec.targetRef -> spec.gitTargetRef ClusterWatchRule.spec.targetRef -> spec.gitTargetRef CommitRequest.spec.targetRef -> spec.gitTargetRef GitTarget is the case that earns it: `providerRef` sat directly beside `clusterProviderRef`, so the shorter name read as "the provider" in the one object that has two. Everything else keeps its name because it already names its referent or its role: clusterProviderRef, the three secretRefs, knownHostsRef, and the refs inside kubeConfig. Unlike every other removal in this release, no client can apply the old spelling and be silently pruned: the new name is required, so a stale manifest is rejected as an unknown field by a strict client and as a missing required field by a non-strict one. What it does cost is a stall — a stored object stops serving the old value the moment the CRDs land — which is the re-apply UPGRADING already asks for in one sync, now applying to every object. The one real hazard was GitTarget's immutability rule: the apply that migrates a stored target is also the apply that first sets an immutable field, and a plain `self.x == oldSelf.x` rejects it because oldSelf has no such key. That would have forced delete-and-recreate on every target, and it is why a two-release migration looked necessary. Guarding the rule on `!has(oldSelf.gitProviderRef)` opens a one-way door instead: it cannot loosen anything, because a required field is never absent on an object created from here on. Measured both ways in TestRenamedRequiredField_StoredObjectCanAdoptIt, which also gives the status-update envtest back a real subject. Co-Authored-By: Claude Opus 5 --- api/v1alpha3/clusterwatchrule_types.go | 12 +- api/v1alpha3/commitrequest_types.go | 8 +- api/v1alpha3/gittarget_types.go | 23 ++- api/v1alpha3/watchrule_types.go | 8 +- api/v1alpha3/zz_generated.deepcopy.go | 8 +- .../gitops-reverser/templates/quickstart.yaml | 4 +- .../configbutler.ai_clusterwatchrules.yaml | 48 ++--- .../bases/configbutler.ai_commitrequests.yaml | 32 ++-- .../crd/bases/configbutler.ai_gittargets.yaml | 38 ++-- .../crd/bases/configbutler.ai_watchrules.yaml | 30 +-- config/samples/clusterwatchrule.yaml | 2 +- config/samples/commitrequest.yaml | 2 +- config/samples/quickstart-gittarget.yaml | 2 +- config/samples/quickstart-watchrule.yaml | 2 +- docs/UPGRADING.md | 84 ++++++++- docs/architecture.md | 14 +- docs/azure-devops-getting-started.md | 4 +- docs/configuration.md | 22 +-- docs/design/gittarget-api-wave.md | 6 +- docs/design/source-scope-simplification.md | 2 +- .../repo-discovery-and-onboarding-scan.md | 2 +- docs/facts/subresources.md | 2 +- docs/finished/config-plane-split.md | 8 +- .../e2e-aggregated-apiserver-test-design.md | 4 +- ...fig-surface-for-a-structured-repository.md | 2 +- .../direction-and-configuration-surface.md | 4 +- docs/installing-apps-as-krm.md | 2 +- docs/layout/model.md | 2 +- docs/layout/new-file-placement-rules.md | 4 +- docs/spec/commitrequest-design.md | 2 +- internal/authz/source_namespace_test.go | 6 +- .../clusterwatchrule_admission_test.go | 12 +- .../controller/clusterwatchrule_controller.go | 42 ++--- .../clusterwatchrule_controller_test.go | 4 +- .../controller/commitrequest_controller.go | 6 +- .../commitrequest_controller_test.go | 4 +- .../commitrequest_controller_unit_test.go | 4 +- .../controller/dependency_watches_test.go | 10 +- internal/controller/gittarget_controller.go | 38 ++-- .../controller/gittarget_controller_test.go | 34 ++-- .../gittarget_controller_unit_test.go | 6 +- .../controller/gittarget_immutability_test.go | 18 +- .../gittarget_placement_validation_test.go | 6 +- .../controller/gittarget_source_cluster.go | 2 +- .../gittarget_source_cluster_test.go | 22 +-- .../controller/renamed_required_field_test.go | 176 ++++++++++++++++++ .../stored_superseded_value_status_test.go | 4 +- .../superseded_fields_admission_test.go | 26 +-- internal/controller/watchrule_controller.go | 30 +-- .../controller/watchrule_controller_test.go | 10 +- .../watchrule_source_namespace_test.go | 6 +- internal/git/branch_worker_loop_test.go | 6 +- internal/git/branch_worker_split_test.go | 2 +- internal/git/branch_worker_test.go | 12 +- internal/git/layout_corpus_test.go | 2 +- internal/git/secret_write_test.go | 2 +- internal/git/source_namespaces.go | 2 +- internal/git/source_namespaces_test.go | 2 +- internal/git/worker_manager.go | 2 +- internal/git/worker_manager_test.go | 2 +- .../git/write_boundary_precondition_test.go | 6 +- internal/rulestore/store_test.go | 2 +- internal/watch/bootstrap.go | 8 +- internal/watch/bootstrap_admission_test.go | 10 +- .../config_plane_split_review_fixes_test.go | 8 +- internal/watch/event_router.go | 4 +- internal/watch/event_router_test.go | 12 +- internal/watch/manager_catalog.go | 6 +- internal/watch/manager_snapshot_test.go | 2 +- internal/watch/manager_startup_test.go | 16 +- .../watch/source_namespace_planning_test.go | 2 +- .../source_namespace_stream_summary_test.go | 2 +- internal/watch/source_namespace_test.go | 6 +- internal/watch/stream_readiness.go | 6 +- internal/watch/watched_type_helpers_test.go | 4 +- internal/watch/watched_type_resolver.go | 12 +- internal/watch/watched_type_resolver_test.go | 2 +- internal/watch/watchrule_compile.go | 2 +- .../watchrule_source_namespace_admission.go | 4 +- ...tchrule_source_namespace_admission_test.go | 4 +- test/e2e/audit_route_attribution_e2e_test.go | 4 +- test/e2e/commit_request_e2e_test.go | 6 +- .../deployment_scale_subresource_e2e_test.go | 2 +- test/e2e/e2e_test.go | 4 +- test/e2e/helpers.go | 2 +- test/e2e/prune_mode_e2e_test.go | 2 +- test/e2e/render_fidelity_e2e_test.go | 2 +- .../podinfos-intent/2-reverse-gitops.yaml | 4 +- .../test/coffeeconfig-reverse-gitops.yaml | 4 +- test/e2e/source_cluster_e2e_test.go | 8 +- test/e2e/source_namespace_e2e_test.go | 2 +- test/e2e/suspend_e2e_test.go | 2 +- .../aggregated-api/watchrule-flunder.tmpl | 2 +- .../bi-directional/watchrule-secret.tmpl | 2 +- .../templates/demo/clusterwatchrule-demo.tmpl | 2 +- test/e2e/templates/demo/watchrule-all.tmpl | 2 +- test/e2e/templates/gittarget.tmpl | 2 +- .../manager/clusterwatchrule-crd.tmpl | 2 +- .../templates/manager/gittarget-prune.tmpl | 2 +- .../manager/watchrule-configmap.tmpl | 2 +- .../manager/watchrule-helmrelease.tmpl | 2 +- .../manager/watchrule-resources.tmpl | 2 +- .../templates/restart/watchrule-wildcard.tmpl | 2 +- test/e2e/templates/watchrule-crd.tmpl | 2 +- test/e2e/templates/watchrule-secret.tmpl | 2 +- test/e2e/templates/watchrule.tmpl | 2 +- test/e2e/unsupported_folder_e2e_test.go | 2 +- .../watchrule_configmap_secret_e2e_test.go | 2 +- .../1-flat-serialized/config/gittarget.yaml | 2 +- .../1-flat-serialized/config/watchrule.yaml | 2 +- .../config/gittarget-second-namespace.yaml | 2 +- .../config/gittarget.yaml | 2 +- .../config/watchrule-second-namespace.yaml | 2 +- .../config/watchrule.yaml | 2 +- .../3-tree-serialized/config/gittarget.yaml | 2 +- .../3-tree-serialized/config/watchrule.yaml | 2 +- .../config/gittarget.yaml | 2 +- .../config/watchrule.yaml | 2 +- .../config/gittarget-empty-folder.yaml | 2 +- .../config/gittarget.yaml | 2 +- .../config/watchrule.yaml | 2 +- .../config/gittarget-app-root.yaml | 2 +- .../config/gittarget-prod.yaml | 2 +- .../config/gittarget-test.yaml | 2 +- .../config/watchrule-prod.yaml | 2 +- .../config/gittarget-prod.yaml | 2 +- .../config/watchrule-prod.yaml | 2 +- .../config/gittarget-prod.yaml | 2 +- .../config/watchrule-prod.yaml | 2 +- .../homelab-argocd/config/gittarget.yaml | 2 +- .../homelab-argocd/config/watchrule.yaml | 2 +- .../homelab-flux/config/gittarget-media.yaml | 2 +- .../homelab-flux/config/gittarget.yaml | 2 +- .../homelab-flux/config/watchrule-media.yaml | 2 +- .../homelab-flux/config/watchrule.yaml | 2 +- test/playground/config/gittarget.yaml | 2 +- test/playground/config/watchrule.yaml | 2 +- 137 files changed, 700 insertions(+), 431 deletions(-) create mode 100644 internal/controller/renamed_required_field_test.go diff --git a/api/v1alpha3/clusterwatchrule_types.go b/api/v1alpha3/clusterwatchrule_types.go index 73796e64..15f151fb 100644 --- a/api/v1alpha3/clusterwatchrule_types.go +++ b/api/v1alpha3/clusterwatchrule_types.go @@ -23,12 +23,12 @@ const ( // ClusterWatchRuleSpec defines the desired state of ClusterWatchRule. type ClusterWatchRuleSpec struct { - // TargetRef names the GitTarget this rule feeds. A ClusterWatchRule has no namespace of its + // GitTargetRef names the GitTarget this rule feeds. A ClusterWatchRule has no namespace of its // own, so the namespace is required here rather than defaulted. // +required - // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.targetRef.name must not be empty" - // +kubebuilder:validation:XValidation:rule="has(self.namespace) && self.namespace != ''",message="spec.targetRef.namespace is required: a ClusterWatchRule has no namespace to default to" - TargetRef meta.NamespacedObjectReference `json:"targetRef"` + // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.gitTargetRef.name must not be empty" + // +kubebuilder:validation:XValidation:rule="has(self.namespace) && self.namespace != ''",message="spec.gitTargetRef.namespace is required: a ClusterWatchRule has no namespace to default to" + GitTargetRef meta.NamespacedObjectReference `json:"gitTargetRef"` // Rules define which CLUSTER-SCOPED resources to watch. // Multiple rules create a logical OR - a resource matching ANY rule is watched. @@ -115,7 +115,7 @@ type ClusterWatchRuleStatus struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:scope=Cluster -// +kubebuilder:printcolumn:name="Target",type=string,JSONPath=`.spec.targetRef.name` +// +kubebuilder:printcolumn:name="Target",type=string,JSONPath=`.spec.gitTargetRef.name` // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` // +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason` // +kubebuilder:printcolumn:name="Streams",type=string,JSONPath=`.status.streams.summary` @@ -127,7 +127,7 @@ type ClusterWatchRuleStatus struct { // from — Nodes, PersistentVolumes, StorageClasses, ClusterRoles, CRDs, and the like. Scope is // carried by the rule KIND, so it has no per-rule scope choice and no source-namespace selection. // -// It is cluster-scoped and requires cluster-admin permissions. Its targetRef names a GitTarget +// It is cluster-scoped and requires cluster-admin permissions. Its gitTargetRef names a GitTarget // (namespace required), whose namespace must be admitted by that target's ClusterProvider. To // mirror NAMESPACED resources use a WatchRule in the tenant namespace and set // spec.rules[].sourceNamespace, whose "*" reaches every namespace the source credential can read. diff --git a/api/v1alpha3/commitrequest_types.go b/api/v1alpha3/commitrequest_types.go index d6f9216f..cde89e22 100644 --- a/api/v1alpha3/commitrequest_types.go +++ b/api/v1alpha3/commitrequest_types.go @@ -14,11 +14,11 @@ import ( // // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="CommitRequest spec is immutable after creation" type CommitRequestSpec struct { - // TargetRef names the GitTarget whose open commit window to finalize. + // GitTargetRef names the GitTarget whose open commit window to finalize. // The GitTarget must be in the same namespace as this CommitRequest. // +required - // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.targetRef.name must not be empty" - TargetRef meta.LocalObjectReference `json:"targetRef"` + // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.gitTargetRef.name must not be empty" + GitTargetRef meta.LocalObjectReference `json:"gitTargetRef"` // Message is an optional commit message for the finalized commit. When // omitted, the generated grouped-commit message is used. @@ -89,7 +89,7 @@ type CommitRequestStatus struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status -// +kubebuilder:printcolumn:name="GitTarget",type=string,JSONPath=`.spec.targetRef.name` +// +kubebuilder:printcolumn:name="GitTarget",type=string,JSONPath=`.spec.gitTargetRef.name` // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` // +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason` // +kubebuilder:printcolumn:name="SHA",type=string,JSONPath=`.status.sha` diff --git a/api/v1alpha3/gittarget_types.go b/api/v1alpha3/gittarget_types.go index 80c8de99..ef9d10bb 100644 --- a/api/v1alpha3/gittarget_types.go +++ b/api/v1alpha3/gittarget_types.go @@ -9,7 +9,7 @@ import ( // GitTargetSpec defines the desired state of GitTarget. // -// The destination fields — providerRef, branch, and path — are immutable. A +// The destination fields — gitProviderRef, branch, and path — are immutable. A // GitTarget materializes the watched resources at exactly one (provider, branch, // folder); changing where it writes would orphan the old materialization and require // migrating manifests between repositories/branches/folders. Instead of reconciling @@ -18,22 +18,29 @@ import ( // simple — a successful snapshot can never be silently invalidated by a destination // change. // -// +kubebuilder:validation:XValidation:rule="self.providerRef == oldSelf.providerRef",message="spec.providerRef is immutable; delete and recreate the GitTarget to change its destination" +// The gitProviderRef rule is guarded on oldSelf rather than written as a plain equality, and the +// guard is a one-way migration door rather than a loosening. A GitTarget stored before this field +// was named gitProviderRef serves NO value for it (a field outside the structural schema is not +// served, measured in TestRenamedRequiredField_StoredObjectCanAdoptIt), so a plain equality would +// reject the very apply that migrates it and force a delete-and-recreate. Because the field is +// REQUIRED, it can never be absent on an object created from this release on, so the door is shut +// for everything except the objects it exists for. +// +kubebuilder:validation:XValidation:rule="!has(oldSelf.gitProviderRef) || self.gitProviderRef == oldSelf.gitProviderRef",message="spec.gitProviderRef is immutable; delete and recreate the GitTarget to change its destination" // +kubebuilder:validation:XValidation:rule="self.branch == oldSelf.branch",message="spec.branch is immutable; delete and recreate the GitTarget to change its destination" // +kubebuilder:validation:XValidation:rule="self.path == oldSelf.path",message="spec.path is immutable; delete and recreate the GitTarget to change its destination" // // spec.clusterProviderRef names the SOURCE cluster a GitTarget mirrors FROM (see its field doc). It // is immutable — a folder's source cluster is part of what the folder means, like -// providerRef/branch/path above — and defaults to a ClusterProvider named "default", so it is +// gitProviderRef/branch/path above — and defaults to a ClusterProvider named "default", so it is // always populated (never nil) and always jumpable. // +kubebuilder:validation:XValidation:rule="self.clusterProviderRef == oldSelf.clusterProviderRef",message="spec.clusterProviderRef is immutable; delete and recreate the GitTarget to change the cluster it mirrors" type GitTargetSpec struct { - // ProviderRef names the GitProvider that backs this target, in this GitTarget's own namespace. - // Many GitTargets may name the same GitProvider. + // GitProviderRef names the GitProvider that backs this target, in this GitTarget's own + // namespace. Many GitTargets may name the same GitProvider. // Immutable: delete and recreate the GitTarget to change its destination. // +required - // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.providerRef.name must not be empty" - ProviderRef meta.LocalObjectReference `json:"providerRef"` + // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.gitProviderRef.name must not be empty" + GitProviderRef meta.LocalObjectReference `json:"gitProviderRef"` // Branch to use for this target. // Must be one of the allowed branches in the provider. @@ -426,7 +433,7 @@ type GitTargetRetentionStatus struct { // +kubebuilder:printcolumn:name="ClusterProviderReady",type=string,JSONPath=`.status.conditions[?(@.type=="ClusterProviderReady")].status`,priority=1 // +kubebuilder:printcolumn:name="Status",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].message`,priority=1 // +kubebuilder:printcolumn:name="Encryption",type=string,JSONPath=`.spec.encryption.provider`,priority=1 -// +kubebuilder:printcolumn:name="Provider",type=string,JSONPath=`.spec.providerRef.name`,priority=1 +// +kubebuilder:printcolumn:name="Provider",type=string,JSONPath=`.spec.gitProviderRef.name`,priority=1 // +kubebuilder:printcolumn:name="Branch",type=string,JSONPath=`.spec.branch`,priority=1 // +kubebuilder:printcolumn:name="Path",type=string,JSONPath=`.spec.path`,priority=1 // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` diff --git a/api/v1alpha3/watchrule_types.go b/api/v1alpha3/watchrule_types.go index 4bd81a5a..39d67309 100644 --- a/api/v1alpha3/watchrule_types.go +++ b/api/v1alpha3/watchrule_types.go @@ -29,10 +29,10 @@ const ( // carries its own source namespace: omitted for this WatchRule's own namespace, an explicit name, // or "*" for every namespace the GitTarget admits. type WatchRuleSpec struct { - // TargetRef names the GitTarget this rule feeds, in this WatchRule's own namespace. + // GitTargetRef names the GitTarget this rule feeds, in this WatchRule's own namespace. // +required - // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.targetRef.name must not be empty" - TargetRef meta.LocalObjectReference `json:"targetRef"` + // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.gitTargetRef.name must not be empty" + GitTargetRef meta.LocalObjectReference `json:"gitTargetRef"` // Rules define which resources to watch, and in which source namespaces. // Multiple rules create a logical OR - a resource matching ANY rule is watched. @@ -224,7 +224,7 @@ type WatchRuleStreamsStatus struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:resource:scope=Namespaced -// +kubebuilder:printcolumn:name="Target",type=string,JSONPath=`.spec.targetRef.name` +// +kubebuilder:printcolumn:name="Target",type=string,JSONPath=`.spec.gitTargetRef.name` // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` // +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason` // +kubebuilder:printcolumn:name="Streams",type=string,JSONPath=`.status.streams.summary` diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index 73c9ab54..fe505e05 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -281,7 +281,7 @@ func (in *ClusterWatchRuleList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ClusterWatchRuleSpec) DeepCopyInto(out *ClusterWatchRuleSpec) { *out = *in - out.TargetRef = in.TargetRef + out.GitTargetRef = in.GitTargetRef if in.Rules != nil { in, out := &in.Rules, &out.Rules *out = make([]ClusterResourceRule, len(*in)) @@ -405,7 +405,7 @@ func (in *CommitRequestList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CommitRequestSpec) DeepCopyInto(out *CommitRequestSpec) { *out = *in - out.TargetRef = in.TargetRef + out.GitTargetRef = in.GitTargetRef } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CommitRequestSpec. @@ -785,7 +785,7 @@ func (in *GitTargetRetentionStatus) DeepCopy() *GitTargetRetentionStatus { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitTargetSpec) DeepCopyInto(out *GitTargetSpec) { *out = *in - out.ProviderRef = in.ProviderRef + out.GitProviderRef = in.GitProviderRef if in.Encryption != nil { in, out := &in.Encryption, &out.Encryption *out = new(EncryptionSpec) @@ -1036,7 +1036,7 @@ func (in *WatchRuleList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WatchRuleSpec) DeepCopyInto(out *WatchRuleSpec) { *out = *in - out.TargetRef = in.TargetRef + out.GitTargetRef = in.GitTargetRef if in.Rules != nil { in, out := &in.Rules, &out.Rules *out = make([]ResourceRule, len(*in)) diff --git a/charts/gitops-reverser/templates/quickstart.yaml b/charts/gitops-reverser/templates/quickstart.yaml index fced202f..15082ff9 100644 --- a/charts/gitops-reverser/templates/quickstart.yaml +++ b/charts/gitops-reverser/templates/quickstart.yaml @@ -34,7 +34,7 @@ metadata: {{- include "gitops-reverser.labels" . | nindent 4 }} app.kubernetes.io/component: quickstart spec: - providerRef: + gitProviderRef: name: {{ .Values.quickstart.gitProvider.name }} branch: {{ .Values.quickstart.gitTarget.branch | quote }} path: {{ .Values.quickstart.gitTarget.path | quote }} @@ -59,7 +59,7 @@ metadata: {{- include "gitops-reverser.labels" . | nindent 4 }} app.kubernetes.io/component: quickstart spec: - targetRef: + gitTargetRef: name: {{ .Values.quickstart.gitTarget.name }} rules: {{- toYaml .Values.quickstart.watchRule.rules | nindent 4 }} diff --git a/config/crd/bases/configbutler.ai_clusterwatchrules.yaml b/config/crd/bases/configbutler.ai_clusterwatchrules.yaml index 36092e3a..8cccdcec 100644 --- a/config/crd/bases/configbutler.ai_clusterwatchrules.yaml +++ b/config/crd/bases/configbutler.ai_clusterwatchrules.yaml @@ -15,7 +15,7 @@ spec: scope: Cluster versions: - additionalPrinterColumns: - - jsonPath: .spec.targetRef.name + - jsonPath: .spec.gitTargetRef.name name: Target type: string - jsonPath: .status.conditions[?(@.type=="Ready")].status @@ -46,7 +46,7 @@ spec: from — Nodes, PersistentVolumes, StorageClasses, ClusterRoles, CRDs, and the like. Scope is carried by the rule KIND, so it has no per-rule scope choice and no source-namespace selection. - It is cluster-scoped and requires cluster-admin permissions. Its targetRef names a GitTarget + It is cluster-scoped and requires cluster-admin permissions. Its gitTargetRef names a GitTarget (namespace required), whose namespace must be admitted by that target's ClusterProvider. To mirror NAMESPACED resources use a WatchRule in the tenant namespace and set spec.rules[].sourceNamespace, whose "*" reaches every namespace the source credential can read. @@ -71,6 +71,27 @@ spec: spec: description: spec defines the desired state of ClusterWatchRule. properties: + gitTargetRef: + description: |- + GitTargetRef names the GitTarget this rule feeds. A ClusterWatchRule has no namespace of its + own, so the namespace is required here rather than defaulted. + properties: + name: + description: Name of the referent. + type: string + namespace: + description: Namespace of the referent, when not specified it + acts as LocalObjectReference. + type: string + required: + - name + type: object + x-kubernetes-validations: + - message: spec.gitTargetRef.name must not be empty + rule: self.name != '' + - message: 'spec.gitTargetRef.namespace is required: a ClusterWatchRule + has no namespace to default to' + rule: has(self.namespace) && self.namespace != '' rules: description: |- Rules define which CLUSTER-SCOPED resources to watch. @@ -148,30 +169,9 @@ spec: type: object minItems: 1 type: array - targetRef: - description: |- - TargetRef names the GitTarget this rule feeds. A ClusterWatchRule has no namespace of its - own, so the namespace is required here rather than defaulted. - properties: - name: - description: Name of the referent. - type: string - namespace: - description: Namespace of the referent, when not specified it - acts as LocalObjectReference. - type: string - required: - - name - type: object - x-kubernetes-validations: - - message: spec.targetRef.name must not be empty - rule: self.name != '' - - message: 'spec.targetRef.namespace is required: a ClusterWatchRule - has no namespace to default to' - rule: has(self.namespace) && self.namespace != '' required: + - gitTargetRef - rules - - targetRef type: object status: description: status defines the observed state of ClusterWatchRule. diff --git a/config/crd/bases/configbutler.ai_commitrequests.yaml b/config/crd/bases/configbutler.ai_commitrequests.yaml index 1703ef91..b0531357 100644 --- a/config/crd/bases/configbutler.ai_commitrequests.yaml +++ b/config/crd/bases/configbutler.ai_commitrequests.yaml @@ -15,7 +15,7 @@ spec: scope: Namespaced versions: - additionalPrinterColumns: - - jsonPath: .spec.targetRef.name + - jsonPath: .spec.gitTargetRef.name name: GitTarget type: string - jsonPath: .status.conditions[?(@.type=="Ready")].status @@ -83,6 +83,20 @@ spec: maximum: 300 minimum: 0 type: integer + gitTargetRef: + description: |- + GitTargetRef names the GitTarget whose open commit window to finalize. + The GitTarget must be in the same namespace as this CommitRequest. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + x-kubernetes-validations: + - message: spec.gitTargetRef.name must not be empty + rule: self.name != '' message: description: |- Message is an optional commit message for the finalized commit. When @@ -96,22 +110,8 @@ spec: minLength: 1 pattern: ^[^\x00-\x09\x0B-\x1F\x7F]*$ type: string - targetRef: - description: |- - TargetRef names the GitTarget whose open commit window to finalize. - The GitTarget must be in the same namespace as this CommitRequest. - properties: - name: - description: Name of the referent. - type: string - required: - - name - type: object - x-kubernetes-validations: - - message: spec.targetRef.name must not be empty - rule: self.name != '' required: - - targetRef + - gitTargetRef type: object x-kubernetes-validations: - message: CommitRequest spec is immutable after creation diff --git a/config/crd/bases/configbutler.ai_gittargets.yaml b/config/crd/bases/configbutler.ai_gittargets.yaml index 25d1802c..f32ea806 100644 --- a/config/crd/bases/configbutler.ai_gittargets.yaml +++ b/config/crd/bases/configbutler.ai_gittargets.yaml @@ -72,7 +72,7 @@ spec: name: Encryption priority: 1 type: string - - jsonPath: .spec.providerRef.name + - jsonPath: .spec.gitProviderRef.name name: Provider priority: 1 type: string @@ -241,6 +241,21 @@ spec: required: - provider type: object + gitProviderRef: + description: |- + GitProviderRef names the GitProvider that backs this target, in this GitTarget's own + namespace. Many GitTargets may name the same GitProvider. + Immutable: delete and recreate the GitTarget to change its destination. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + x-kubernetes-validations: + - message: spec.gitProviderRef.name must not be empty + rule: self.name != '' path: description: |- Path within the repository to write resources to, relative to the repository @@ -308,21 +323,6 @@ spec: kustomization lists is a file kustomize never builds. type: boolean type: object - providerRef: - description: |- - ProviderRef names the GitProvider that backs this target, in this GitTarget's own namespace. - Many GitTargets may name the same GitProvider. - Immutable: delete and recreate the GitTarget to change its destination. - properties: - name: - description: Name of the referent. - type: string - required: - - name - type: object - x-kubernetes-validations: - - message: spec.providerRef.name must not be empty - rule: self.name != '' prune: description: |- Prune controls which deletion paths may remove documents from this target's folder: an @@ -390,13 +390,13 @@ spec: type: boolean required: - branch + - gitProviderRef - path - - providerRef type: object x-kubernetes-validations: - - message: spec.providerRef is immutable; delete and recreate the GitTarget + - message: spec.gitProviderRef is immutable; delete and recreate the GitTarget to change its destination - rule: self.providerRef == oldSelf.providerRef + rule: '!has(oldSelf.gitProviderRef) || self.gitProviderRef == oldSelf.gitProviderRef' - message: spec.branch is immutable; delete and recreate the GitTarget to change its destination rule: self.branch == oldSelf.branch diff --git a/config/crd/bases/configbutler.ai_watchrules.yaml b/config/crd/bases/configbutler.ai_watchrules.yaml index 762dc52a..c3d8e22d 100644 --- a/config/crd/bases/configbutler.ai_watchrules.yaml +++ b/config/crd/bases/configbutler.ai_watchrules.yaml @@ -15,7 +15,7 @@ spec: scope: Namespaced versions: - additionalPrinterColumns: - - jsonPath: .spec.targetRef.name + - jsonPath: .spec.gitTargetRef.name name: Target type: string - jsonPath: .status.conditions[?(@.type=="Ready")].status @@ -75,6 +75,19 @@ spec: spec: description: spec defines the desired state of WatchRule properties: + gitTargetRef: + description: GitTargetRef names the GitTarget this rule feeds, in + this WatchRule's own namespace. + properties: + name: + description: Name of the referent. + type: string + required: + - name + type: object + x-kubernetes-validations: + - message: spec.gitTargetRef.name must not be empty + rule: self.name != '' rules: description: |- Rules define which resources to watch, and in which source namespaces. @@ -177,22 +190,9 @@ spec: type: object minItems: 1 type: array - targetRef: - description: TargetRef names the GitTarget this rule feeds, in this - WatchRule's own namespace. - properties: - name: - description: Name of the referent. - type: string - required: - - name - type: object - x-kubernetes-validations: - - message: spec.targetRef.name must not be empty - rule: self.name != '' required: + - gitTargetRef - rules - - targetRef type: object status: description: status defines the observed state of WatchRule diff --git a/config/samples/clusterwatchrule.yaml b/config/samples/clusterwatchrule.yaml index da563599..48a26706 100644 --- a/config/samples/clusterwatchrule.yaml +++ b/config/samples/clusterwatchrule.yaml @@ -3,7 +3,7 @@ kind: ClusterWatchRule metadata: name: example-clusterwatchrule spec: - targetRef: + gitTargetRef: name: example-target namespace: default rules: diff --git a/config/samples/commitrequest.yaml b/config/samples/commitrequest.yaml index 9faddad5..da3cc4d5 100644 --- a/config/samples/commitrequest.yaml +++ b/config/samples/commitrequest.yaml @@ -11,7 +11,7 @@ metadata: generateName: save- namespace: default spec: - targetRef: + gitTargetRef: name: quickstart-gittarget # Optional commit message (1-1024 Unicode characters). Newlines are allowed # for a subject plus body; other control characters are rejected. When diff --git a/config/samples/quickstart-gittarget.yaml b/config/samples/quickstart-gittarget.yaml index 46759c29..88d5cfb5 100644 --- a/config/samples/quickstart-gittarget.yaml +++ b/config/samples/quickstart-gittarget.yaml @@ -4,7 +4,7 @@ metadata: name: example-target namespace: default spec: - providerRef: + gitProviderRef: name: example-provider branch: main # Required relative repository path. Use "." only to deliberately write at repo root. diff --git a/config/samples/quickstart-watchrule.yaml b/config/samples/quickstart-watchrule.yaml index 1cd6f0dd..9ece3bd3 100644 --- a/config/samples/quickstart-watchrule.yaml +++ b/config/samples/quickstart-watchrule.yaml @@ -4,7 +4,7 @@ metadata: name: example-watchrule namespace: default spec: - targetRef: + gitTargetRef: name: example-target rules: - operations: [CREATE, UPDATE, DELETE] diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 9bdfef9f..06ed7443 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -20,8 +20,11 @@ entries below are the whole migration and they are yours to apply by hand. | `ClusterProvider` | `spec.allowedNamespaces` (renamed), `spec.allowSourceNamespaceOverride` (renamed) | | `ClusterWatchRule` | `spec.rules[].scope` | | every reference field | the `group` and `kind` sub-fields | +| `GitTarget`, `WatchRule`, `ClusterWatchRule`, `CommitRequest` | `providerRef` / `targetRef` are **renamed** to `gitProviderRef` / `gitTargetRef` | -The last two rows need no value migration at all — nothing you set is lost, because every value +The rename is the one every object needs, and the only one no client can get silently wrong; it has +[its own entry](#every-reference-names-the-kind-it-points-at). The two rows above it need no value +migration at all — nothing you set is lost, because every value those fields could hold was the only one they accepted. Delete the lines and move on. Everything above them is a real value that has somewhere new to be. @@ -63,6 +66,9 @@ kubectl get clusterwatchrules -o json | jq -r '.items[] | select(any(.spec.rules[]?; has("scope"))) | "clusterwatchrule \(.metadata.name): drop spec.rules[].scope"' +# The rename affects EVERY GitTarget and every rule, so it needs no select: see its own entry for +# the conversion. + # The rules whose MEANING changes even though their YAML does not: kubectl get watchrules -A -o json | jq -r '.items[] | select(.spec.rules[]?.sourceNamespace == "*") @@ -140,6 +146,82 @@ Either way the inventory is step 1 rather than a footnote, because a pruned valu from the API. The two removal strategies are priced in [`facts/crd-upgrade-strategies.md`](facts/crd-upgrade-strategies.md). +## Every reference names the kind it points at + +Four fields are renamed. This is the same change as the one below, seen from the other side: once a +reference carries only a `name`, the FIELD NAME is the only thing left saying what it points at, so +a field called `providerRef` sitting next to `clusterProviderRef` stops being readable. + +| Kind | Was | Is | +|---|---|---| +| `GitTarget` | `spec.providerRef` | `spec.gitProviderRef` | +| `WatchRule` | `spec.targetRef` | `spec.gitTargetRef` | +| `ClusterWatchRule` | `spec.targetRef` | `spec.gitTargetRef` | +| `CommitRequest` | `spec.targetRef` | `spec.gitTargetRef` | + +Unchanged, because each already names its referent or its role: `GitTarget.spec.clusterProviderRef`, +`GitProvider.spec.secretRef` and `spec.commit.signing.secretRef`, `GitTarget.spec.encryption.secretRef`, +`GitProvider.spec.knownHostsRef`, and the `configMapRef` / `secretRef` inside +`ClusterProvider.spec.kubeConfig`. + +### This one cannot be applied silently wrong + +Every other removal in this release can be pruned quietly by a non-strict client. These four cannot, +because the new name is **required**: an object naming only the old one is rejected by every client, +strict or not. + +| Client | Applying the old spelling after the upgrade | +|---|---| +| `kubectl apply` (strict, the default) | rejected: `unknown field "spec.providerRef"` | +| `--validate=ignore`, a controller applying with field validation off | rejected: `spec.gitProviderRef: Required value` | + +So there is no silent-damage case to hunt for. What there IS, and what makes this the entry to read +before you upgrade, is a **stall**: a stored object stops serving the old value the moment the new +CRDs land, so every `GitTarget` and every rule is missing its reference until you re-apply. Writes +stop; nothing is lost. This is the same stall [step 4](#safe-upgrade-order-for-the-gittarget-api-changes) +already asks you to close in one sync — it now applies to every object rather than only to the ones +using the fields that moved. + +### Inventory + +Every object of these four kinds is affected, so the inventory is a conversion rather than a search: + +```bash +# Everything that needs the edit, which is everything: +kubectl get gittargets,watchrules,clusterwatchrules -A -o json | + jq -r '.items[] | "\(.kind) \(.metadata.namespace // "-")/\(.metadata.name)"' +``` + +`CommitRequest` needs no migration: they are one-shot objects you create and never update, so the +ones in flight during an upgrade finalize under the old name and the next one you create uses the +new one. + +### Conversion + +A mechanical rename in your manifests, then one apply: + +```bash +# In your GitOps repository, not against the cluster. +grep -rl 'providerRef:\|targetRef:' . | + xargs sed -i 's/\bproviderRef:/gitProviderRef:/g; s/\btargetRef:/gitTargetRef:/g' +``` + +Check the result before committing: `clusterProviderRef` must NOT become `clusterGitProviderRef` +(the `\b` above is what prevents that, but a review costs nothing). + +### `GitTarget` migrates in place, and that took a guard + +`spec.gitProviderRef` is immutable, and the apply that migrates a stored `GitTarget` is also the +apply that sets that immutable field for the first time. Written as a plain equality, the +immutability rule would reject exactly that apply — `oldSelf` has no such field — and the only way +out would be deleting and recreating every target. + +The rule is therefore guarded: `!has(oldSelf.gitProviderRef) || self.gitProviderRef == oldSelf.gitProviderRef`. +It opens a one-way door for objects stored before the rename and cannot loosen anything afterwards, +because a required field is never absent on an object created from this release on. Both halves are +measured in `TestRenamedRequiredField_StoredObjectCanAdoptIt`, so **you do not need to delete and +recreate anything**: re-apply your manifests and the targets adopt the new name. + ## Every reference is `{name}` — `group` and `kind` are gone Six reference shapes become two, and both are Flux's own diff --git a/docs/architecture.md b/docs/architecture.md index 3f40edf9..e81cbcc1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -176,11 +176,11 @@ Git destination. `ClusterProvider` supplies the source connection and authorizat ```mermaid graph LR subgraph CONTROL["Control plane: operator cluster"] - WR[WatchRule] -->|targetRef| GT[GitTarget] - CWR[ClusterWatchRule] -->|targetRef| GT - CR[CommitRequest] -->|targetRef| GT + WR[WatchRule] -->|gitTargetRef| GT[GitTarget] + CWR[ClusterWatchRule] -->|gitTargetRef| GT + CR[CommitRequest] -->|gitTargetRef| GT GT -->|clusterProviderRef| CP[ClusterProvider] - GT -->|providerRef| GP[GitProvider] + GT -->|gitProviderRef| GP[GitProvider] KCFG[("Remote kubeconfig Secret\noperator namespace")] WM["Watch manager\nper-provider source context"] end @@ -226,7 +226,7 @@ by provider name and are released when no `GitTarget` references them. Scope is carried by the rule KIND. A `WatchRule` selects NAMESPACED resources and routes matching events to a same namespace `GitTarget`; each of its rule items names the source namespace it watches. -A `ClusterWatchRule` selects CLUSTER SCOPED resources, with an explicit namespace `targetRef` and no +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`). @@ -264,7 +264,7 @@ The two namespace controls intentionally live in different planes: A one shot "save now" signal that finalizes the open commit window for a same namespace `GitTarget` instead of waiting for the silence timer. The **entire spec is immutable**. Key fields: -- `spec.targetRef.name`: target whose open window should be finalized. +- `spec.gitTargetRef.name`: target whose open window should be finalized. - `spec.message`: optional verbatim commit message (1–1024 chars, no control characters). - `spec.closeDelaySeconds`: optional `0–300s` delay before the window is closed, so the author's own in flight changes can join the window before it closes. @@ -303,7 +303,7 @@ Key fields: may batch and phrase their commits differently. A branch worker serves a `(provider, branch)` pair and resolves the window per open window, since a window is bound to exactly one target. -`providerRef`, `clusterProviderRef`, `branch`, and `path` are immutable so a target cannot silently +`gitProviderRef`, `clusterProviderRef`, `branch`, and `path` are immutable so a target cannot silently orphan an old materialization or change its source cluster. The controller also rejects path overlaps between GitTargets sharing a provider and branch. diff --git a/docs/azure-devops-getting-started.md b/docs/azure-devops-getting-started.md index ef4a1ce2..293ab692 100644 --- a/docs/azure-devops-getting-started.md +++ b/docs/azure-devops-getting-started.md @@ -53,7 +53,7 @@ metadata: name: ado-target namespace: my-namespace spec: - providerRef: + gitProviderRef: name: ado-provider branch: path: clusters/my-cluster @@ -64,7 +64,7 @@ metadata: name: ado-rule namespace: my-namespace spec: - targetRef: + gitTargetRef: name: ado-target rules: - resources: ["configmaps"] diff --git a/docs/configuration.md b/docs/configuration.md index 6737c09a..e1bd837a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -390,7 +390,7 @@ metadata: name: example-target namespace: default spec: - providerRef: + gitProviderRef: name: example-provider # Omit clusterProviderRef to reference the user-created ClusterProvider named "default". # clusterProviderRef: {name: prod-eu-1} selects a different source provider. @@ -413,7 +413,7 @@ and age details, see [sops-age-guide.md](sops-age-guide.md). and `kind` default to `configbutler.ai` / `GitProvider`, so in practice you only set `name`. `spec.clusterProviderRef` references a cluster-scoped `ClusterProvider`. It defaults to -`{name: default}` when omitted. That is intentionally different from `providerRef`: a source cluster +`{name: default}` when omitted. That is intentionally different from `gitProviderRef`: a source cluster is a shared physical identity, while a Git destination and its credential normally belong to the target's namespace. The default name can represent either an in-cluster or remote source according to the `ClusterProvider` the user created. @@ -555,7 +555,7 @@ spec: ```yaml spec: - providerRef: {name: homelab} + gitProviderRef: {name: homelab} branch: gitops-preview # not main path: apps/checkout ``` @@ -742,7 +742,7 @@ counted, so a `Never` target can report `0` while still declining to mirror dele The count is refreshed when a resync runs, so it lags a change in the cluster until the next one. Read `observedTime` before treating a `0` as live. -`spec.prune` is mutable (unlike `providerRef`, `branch`, and `path`), so a target can be moved to +`spec.prune` is mutable (unlike `gitProviderRef`, `branch`, and `path`), so a target can be moved to `Always` once its watch scope is confirmed, without recreating it. Widening it to `Always` re-lists the target's watched scopes, so the cleanup runs on the edit instead of waiting for the next replay. Tightening it applies to the next write and leaves the streams alone, which is what makes it usable @@ -1114,7 +1114,7 @@ when its GitTarget reports `GitPathAccepted=False`. The important fields are: -- `spec.targetRef.name`: target to write to +- `spec.gitTargetRef.name`: target to write to - `spec.rules`: one or more resource-match rules - `spec.rules[].sourceNamespace`: the source-cluster namespace that item watches; omitted means the rule's own namespace @@ -1149,7 +1149,7 @@ metadata: name: repo-config namespace: tenant-acme spec: - targetRef: + gitTargetRef: name: acme rules: - resources: [configmaps] # omitted → tenant-acme, this rule's own namespace @@ -1203,7 +1203,7 @@ metadata: name: example-watchrule namespace: default spec: - targetRef: + gitTargetRef: name: example-target rules: - operations: [CREATE, UPDATE, DELETE] @@ -1222,7 +1222,7 @@ namespace. namespaced resources across namespaces, use a `WatchRule` with [`rules[].sourceNamespace`](#watching-a-different-source-namespace). -Because it is cluster-scoped, its `targetRef` must include the namespace of the referenced +Because it is cluster-scoped, its `gitTargetRef` must include the namespace of the referenced `GitTarget`, and that namespace must be admitted by the target's `ClusterProvider`. Example: @@ -1233,7 +1233,7 @@ kind: ClusterWatchRule metadata: name: cluster-rbac spec: - targetRef: + gitTargetRef: name: example-target namespace: default rules: @@ -1256,7 +1256,7 @@ for the request's author instead of waiting for `GitTarget.spec.commit.window`. The important fields are: -- `spec.targetRef.name`: target whose open window should be finalized +- `spec.gitTargetRef.name`: target whose open window should be finalized - `spec.message`: optional verbatim commit message - `spec.closeDelaySeconds`: optional 0-300 second delay before the open window is closed, after the request author is known, an extra collect window @@ -1270,7 +1270,7 @@ metadata: name: save-now namespace: default spec: - targetRef: + gitTargetRef: name: example-target message: "save default/example-target" closeDelaySeconds: 2 diff --git a/docs/design/gittarget-api-wave.md b/docs/design/gittarget-api-wave.md index 29a8ed02..a8253c0e 100644 --- a/docs/design/gittarget-api-wave.md +++ b/docs/design/gittarget-api-wave.md @@ -62,7 +62,7 @@ The principle above has been a sentence in a document for two drafts. This wave becomes a struct boundary, because grouping a field costs nothing extra in a release that is breaking anyway, and costs a bump in every release that is not. -`GitTargetSpec` today is flat: `providerRef`, `branch`, `path`, `encryption`, `placement`, +`GitTargetSpec` today is flat: `gitProviderRef`, `branch`, `path`, `encryption`, `placement`, `clusterProviderRef`, `allowedSourceNamespaces`, `prune`. Queued on top of it were `suspend`, `serializeNamespace`, `useKustomize`, `commitWindow` and `commit.message` — five more members on a spec that already flattens six orthogonal axes. Left alone, this object accumulates faster than it @@ -88,7 +88,7 @@ Two groupings, and one deliberate exception: a category we made up rather than one the API already has. `prune` is its own struct already. After the wave the spec reads as named axes rather than a list: the immutable destination -(`providerRef`/`branch`/`path`), `encryption`, `clusterProviderRef`, `placement`, `commit`, `prune`, +(`gitProviderRef`/`branch`/`path`), `encryption`, `clusterProviderRef`, `placement`, `commit`, `prune`, and two object-level switches — `suspend` and `serializeNamespace`. That is the test for the next field too: a new member joins an axis, or names a new one, or is genuinely object-level — and if it can do none of the three it is probably not a `GitTarget` field. @@ -203,7 +203,7 @@ metadata: reconcile.configbutler.ai/requestedAt: "2026-07-30T09:14:22Z" spec: # --- the connection: unchanged, and now only the connection --- - providerRef: + gitProviderRef: name: platform branch: main path: clusters/prod diff --git a/docs/design/source-scope-simplification.md b/docs/design/source-scope-simplification.md index 0b573de9..dd4cb010 100644 --- a/docs/design/source-scope-simplification.md +++ b/docs/design/source-scope-simplification.md @@ -71,7 +71,7 @@ to disambiguate each other. ## Why the rest goes `GitTarget.spec.allowedSourceNamespaces` presents itself as a destination policy: "it belongs to -the DESTINATION, not to any requesting rule". It cannot be one. `WatchRule.spec.targetRef` is a +the DESTINATION, not to any requesting rule". It cannot be one. `WatchRule.spec.gitTargetRef` is a `LocalTargetReference` ("Must be in the same namespace"), `GitTarget.spec.providerRef` is local too, and `spec.path` is immutable, so the chain from a Git folder back to the object that fills it never leaves one namespace: diff --git a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md index 077f72bd..11cdb960 100644 --- a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md +++ b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md @@ -198,7 +198,7 @@ comment — the shipped report is strict JSON.) "proposedGitTarget": { // FUTURE — not emitted by the first cut "spec": { - "providerRef": { "name": "" }, + "gitProviderRef": { "name": "" }, "branch": "", "path": "apps/podinfo/overlays/test" } diff --git a/docs/facts/subresources.md b/docs/facts/subresources.md index 2127c1f2..37531fea 100644 --- a/docs/facts/subresources.md +++ b/docs/facts/subresources.md @@ -197,7 +197,7 @@ metadata: name: platform namespace: default spec: - providerRef: + gitProviderRef: name: platform-config branch: main path: clusters/dev diff --git a/docs/finished/config-plane-split.md b/docs/finished/config-plane-split.md index 08afe39f..f64b6085 100644 --- a/docs/finished/config-plane-split.md +++ b/docs/finished/config-plane-split.md @@ -29,7 +29,7 @@ That single `rest.Config` serves two jobs that are conceptually unrelated: - **the watched cluster** — where the resources it mirrors to Git actually live. Because `GitProvider.spec.secretRef` is a local reference and -`WatchRule.spec.targetRef` must name a same-namespace `GitTarget`, all four +`WatchRule.spec.gitTargetRef` must name a same-namespace `GitTarget`, all four objects have to sit in one namespace **on the cluster being watched**. Nothing chose this; it fell out of having one kubeconfig. @@ -51,7 +51,7 @@ single-cluster default, which needs no configuration and behaves exactly as toda apiVersion: configbutler.ai/v1alpha3 kind: GitTarget spec: - providerRef: { name: acme } + gitProviderRef: { name: acme } branch: main path: clusters/acme kubeConfig: # NEW — omit for "the cluster I run in" @@ -268,7 +268,7 @@ destination). This redesign keeps the good structure and removes the coupling. | Closed PR (#220) | This redesign | Why | |---|---|---| -| `sourceCluster` **mutable**, part of the retarget lifecycle (`observedDestination`, `retargetingTo`, teardown-before-validation). | `spec.kubeConfig` **immutable**, exactly like `providerRef`/`branch`/`path` are on `main` today. | The source of a folder's content is destination identity. On `main` the destination is already immutable; extending it is the minimal, coherent change. Mutability is retarget's problem (#6), and can subsume it later. | +| `sourceCluster` **mutable**, part of the retarget lifecycle (`observedDestination`, `retargetingTo`, teardown-before-validation). | `spec.kubeConfig` **immutable**, exactly like `gitProviderRef`/`branch`/`path` are on `main` today. | The source of a folder's content is destination identity. On `main` the destination is already immutable; extending it is the minimal, coherent change. Mutability is retarget's problem (#6), and can subsume it later. | | Source cluster **stamped onto every `CompiledRule`**; a `spec` change raced rule recompilation, needing `CompiledSourceClusters`, `sourceClusterRulesCaughtUp`, and a "rules disagree → watch nothing" state. | Source cluster is a **GitTarget property captured on `Declare`**, the same way `gitTargetUIDs` already is. | It *is* a GitTarget property; normalize it as one. Because it is immutable, there is no "spec changed, rules haven't caught up" window — the entire race apparatus disappears. | | `key` **schema-defaulted to `value.yaml`**. | `key` optional, **no schema default**; resolver reads `value` then `value.yaml`. | `value.yaml` alone would **fail** on a standard Flux kubeconfig Secret, whose key is `value`. Matching Flux's fallback order is simpler *and* more compatible. | | Resolver did a bare `RESTConfigFromKubeConfig(raw)`. | Resolver **rejects** a kubeconfig carrying `exec`/insecure-TLS (legible `Validated=False`), unless flag-opted-in. | An operator-supplied kubeconfig is attacker-adjacent input; an `exec` stanza runs a binary in the operator Pod. Flux *strips* these silently; we reject for legibility. | @@ -301,7 +301,7 @@ import meta "github.com/fluxcd/pkg/apis/meta" // this one rule, plus wiring the provider path, is the whole future enablement. // +kubebuilder:validation:XValidation:rule="!has(self.kubeConfig) || !has(self.kubeConfig.configMapRef)",message="spec.kubeConfig.configMapRef (provider auth) is not yet supported; use secretRef" type GitTargetSpec struct { - // … providerRef, branch, path (unchanged, still immutable) … + // … gitProviderRef, branch, path (unchanged, still immutable) … // KubeConfig names the SOURCE CLUSTER this GitTarget mirrors FROM: the kubeconfig // determines both the cluster and the credentials to reach it. Omitted means the cluster diff --git a/docs/finished/e2e-aggregated-apiserver-test-design.md b/docs/finished/e2e-aggregated-apiserver-test-design.md index 0ce30724..f0594e7c 100644 --- a/docs/finished/e2e-aggregated-apiserver-test-design.md +++ b/docs/finished/e2e-aggregated-apiserver-test-design.md @@ -665,7 +665,7 @@ metadata: name: {{ .Name }} namespace: {{ .Namespace }} spec: - targetRef: + gitTargetRef: name: {{ .GitTargetName }} rules: - operations: ["CREATE", "UPDATE", "DELETE"] @@ -695,7 +695,7 @@ kind: ClusterWatchRule metadata: name: {{ .Name }} spec: - targetRef: + gitTargetRef: name: {{ .GitTargetName }} namespace: {{ .GitTargetNamespace }} rules: diff --git a/docs/future/config-surface-for-a-structured-repository.md b/docs/future/config-surface-for-a-structured-repository.md index 125321a7..ae077697 100644 --- a/docs/future/config-surface-for-a-structured-repository.md +++ b/docs/future/config-surface-for-a-structured-repository.md @@ -283,7 +283,7 @@ With B4: kind: GitTarget metadata: {name: platform-rbac} spec: - providerRef: {name: platform-repo} + gitProviderRef: {name: platform-repo} branch: main path: platform/rbac push: diff --git a/docs/future/direction-and-configuration-surface.md b/docs/future/direction-and-configuration-surface.md index 54606ccd..878f99c7 100644 --- a/docs/future/direction-and-configuration-surface.md +++ b/docs/future/direction-and-configuration-surface.md @@ -175,7 +175,7 @@ kind: GitTarget metadata: name: shop-config spec: - providerRef: + gitProviderRef: name: artifacts-repo branch: main path: apps/shop @@ -194,7 +194,7 @@ metadata: name: shop-config-content namespace: shop # rules subscribe from inside the one admitted namespace spec: - targetRef: + gitTargetRef: name: shop-config rules: - apiGroups: ["config.shop.example"] diff --git a/docs/installing-apps-as-krm.md b/docs/installing-apps-as-krm.md index 1cfb9451..d7d4edb3 100644 --- a/docs/installing-apps-as-krm.md +++ b/docs/installing-apps-as-krm.md @@ -139,7 +139,7 @@ metadata: name: watch-helmreleases namespace: apps spec: - targetRef: + gitTargetRef: name: my-gittarget rules: - apiGroups: ["helm.toolkit.fluxcd.io"] diff --git a/docs/layout/model.md b/docs/layout/model.md index e43cb64c..2dba1802 100644 --- a/docs/layout/model.md +++ b/docs/layout/model.md @@ -342,7 +342,7 @@ folder. The way to see what a target would do is to let one do it, somewhere har ```yaml spec: - providerRef: {name: homelab} + gitProviderRef: {name: homelab} branch: gitops-preview # not main path: apps/checkout ``` diff --git a/docs/layout/new-file-placement-rules.md b/docs/layout/new-file-placement-rules.md index 59d195cb..a6706874 100644 --- a/docs/layout/new-file-placement-rules.md +++ b/docs/layout/new-file-placement-rules.md @@ -87,7 +87,7 @@ per-type policy explicit: apiVersion: configbutler.ai/v1alpha3 kind: GitTarget spec: - providerRef: + gitProviderRef: name: platform branch: main path: clusters/prod @@ -118,7 +118,7 @@ escape hatch if users later need scope-wide or metadata-aware placement. ## Current implementation, as reviewed -The current `GitTargetSpec` has `providerRef`, `branch`, `path`, and optional +The current `GitTargetSpec` has `gitProviderRef`, `branch`, `path`, and optional `encryption`; it has no placement policy yet ([api/v1alpha3/gittarget_types.go](../../api/v1alpha3/gittarget_types.go)). diff --git a/docs/spec/commitrequest-design.md b/docs/spec/commitrequest-design.md index 8cec1d28..b7606e49 100644 --- a/docs/spec/commitrequest-design.md +++ b/docs/spec/commitrequest-design.md @@ -9,7 +9,7 @@ worker to close that window after the requested collect delay. ## Request and window contract -The request identifies the target in `spec.targetRef.name`, may provide `spec.message`, and sets +The request identifies the target in `spec.gitTargetRef.name`, may provide `spec.message`, and sets `spec.closeDelaySeconds` (0–300 seconds). It is handled by the target’s single branch worker, so resource events and the attach request share one FIFO. diff --git a/internal/authz/source_namespace_test.go b/internal/authz/source_namespace_test.go index 2d226bde..f92750ed 100644 --- a/internal/authz/source_namespace_test.go +++ b/internal/authz/source_namespace_test.go @@ -44,7 +44,7 @@ func snTarget() *configv1alpha3.GitTarget { return &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: snTargetName, Namespace: snTenantNS}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: "acme-git"}, + GitProviderRef: meta.LocalObjectReference{Name: "acme-git"}, ClusterProviderRef: &meta.LocalObjectReference{Name: snProvider}, Branch: "main", Path: "tenants/acme", @@ -63,8 +63,8 @@ func snRule(sourceNamespaces ...string) *configv1alpha3.WatchRule { return &configv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: snRuleName, Namespace: snTenantNS}, Spec: configv1alpha3.WatchRuleSpec{ - TargetRef: meta.LocalObjectReference{Name: snTargetName}, - Rules: items, + GitTargetRef: meta.LocalObjectReference{Name: snTargetName}, + Rules: items, }, } } diff --git a/internal/controller/clusterwatchrule_admission_test.go b/internal/controller/clusterwatchrule_admission_test.go index dca0bd1b..705734cb 100644 --- a/internal/controller/clusterwatchrule_admission_test.go +++ b/internal/controller/clusterwatchrule_admission_test.go @@ -98,7 +98,7 @@ func cwaGitTarget() *configbutleraiv1alpha3.GitTarget { return &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: cwaTargetName, Namespace: cwaTargetNS}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: "git"}, + GitProviderRef: meta.LocalObjectReference{Name: "git"}, ClusterProviderRef: &meta.LocalObjectReference{Name: cwaProviderName}, Branch: "main", Path: "clusters/prod", @@ -123,7 +123,7 @@ func cwaClusterWatchRule() *configbutleraiv1alpha3.ClusterWatchRule { return &configbutleraiv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: cwaRuleName, Generation: 1}, Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ - TargetRef: meta.NamespacedObjectReference{Name: cwaTargetName, Namespace: cwaTargetNS}, + GitTargetRef: meta.NamespacedObjectReference{Name: cwaTargetName, Namespace: cwaTargetNS}, Rules: []configbutleraiv1alpha3.ClusterResourceRule{{ Resources: []string{"customresourcedefinitions"}, APIGroups: []string{"apiextensions.k8s.io"}, @@ -441,8 +441,8 @@ func TestClusterProviderToClusterWatchRules(t *testing.T) { ruleFor := func(name, targetName, targetNS string) *configbutleraiv1alpha3.ClusterWatchRule { r := cwaClusterWatchRule() r.Name = name - r.Spec.TargetRef.Name = targetName - r.Spec.TargetRef.Namespace = targetNS + r.Spec.GitTargetRef.Name = targetName + r.Spec.GitTargetRef.Namespace = targetNS return r } @@ -476,8 +476,8 @@ func TestNamespaceToClusterWatchRules(t *testing.T) { ruleFor := func(name, targetName, targetNS string) *configbutleraiv1alpha3.ClusterWatchRule { r := cwaClusterWatchRule() r.Name = name - r.Spec.TargetRef.Name = targetName - r.Spec.TargetRef.Namespace = targetNS + r.Spec.GitTargetRef.Name = targetName + r.Spec.GitTargetRef.Namespace = targetNS return r } diff --git a/internal/controller/clusterwatchrule_controller.go b/internal/controller/clusterwatchrule_controller.go index f8210710..c4d5293c 100644 --- a/internal/controller/clusterwatchrule_controller.go +++ b/internal/controller/clusterwatchrule_controller.go @@ -67,13 +67,13 @@ type ClusterWatchRuleReconciler struct { // Reconcile is part of the main kubernetes reconciliation loop which aims to // move the current state of the cluster closer to the desired state. // clusterWatchRuleGitTarget names the GitTarget a ClusterWatchRule writes through. Unlike a -// WatchRule's, its targetRef carries a namespace of its own. +// WatchRule's, its gitTargetRef carries a namespace of its own. // // It carries no UID, and that is correct rather than an omission: a rule-derived reference has // none to carry, and the watch-plane owner resolves the trigger against the UID the GitTarget // controller captured. See resolveGitTargetUID. func clusterWatchRuleGitTarget(rule *configbutleraiv1alpha3.ClusterWatchRule) reverserTypes.ResourceReference { - return reverserTypes.NewResourceReference(rule.Spec.TargetRef.Name, rule.Spec.TargetRef.Namespace) + return reverserTypes.NewResourceReference(rule.Spec.GitTargetRef.Name, rule.Spec.GitTargetRef.Namespace) } func (r *ClusterWatchRuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { @@ -104,7 +104,7 @@ func (r *ClusterWatchRuleReconciler) Reconcile(ctx context.Context, req ctrl.Req log.Info("Starting ClusterWatchRule validation", "name", clusterRule.Name, - "target", clusterRule.Spec.TargetRef, + "target", clusterRule.Spec.GitTargetRef, "generation", clusterRule.Generation, "resourceVersion", clusterRule.ResourceVersion) st := beginStatus(r.Client, r.Recorder, &clusterRule, &clusterRule.Status.Conditions) @@ -138,7 +138,7 @@ func (r *ClusterWatchRuleReconciler) reconcileClusterWatchRuleViaTarget( log := logf.FromContext(ctx).WithName("reconcileClusterWatchRuleViaTarget") // Target is required - if clusterRule.Spec.TargetRef.Name == "" { + if clusterRule.Spec.GitTargetRef.Name == "" { st.set( ConditionTypeGitTargetReady, metav1.ConditionFalse, @@ -150,7 +150,7 @@ func (r *ClusterWatchRuleReconciler) reconcileClusterWatchRuleViaTarget( } // For ClusterWatchRule, target namespace must be specified - targetNS := clusterRule.Spec.TargetRef.Namespace + targetNS := clusterRule.Spec.GitTargetRef.Namespace if targetNS == "" { st.set( ConditionTypeGitTargetReady, @@ -164,24 +164,24 @@ func (r *ClusterWatchRuleReconciler) reconcileClusterWatchRuleViaTarget( // Fetch GitTarget var target configbutleraiv1alpha3.GitTarget - targetKey := types.NamespacedName{Name: clusterRule.Spec.TargetRef.Name, Namespace: targetNS} + targetKey := types.NamespacedName{Name: clusterRule.Spec.GitTargetRef.Name, Namespace: targetNS} if err := r.Get(ctx, targetKey, &target); err != nil { log.Error(err, "Failed to get referenced GitTarget", - "gitTargetName", clusterRule.Spec.TargetRef.Name, + "gitTargetName", clusterRule.Spec.GitTargetRef.Name, "gitTargetNamespace", targetNS) st.set( ConditionTypeGitTargetReady, metav1.ConditionFalse, ClusterWatchRuleReasonGitTargetNotFound, fmt.Sprintf("Referenced GitTarget '%s/%s' not found: %v", - targetNS, clusterRule.Spec.TargetRef.Name, err), + targetNS, clusterRule.Spec.GitTargetRef.Name, err), ) return r.stallRule(ctx, st, ClusterWatchRuleReasonGitTargetNotFound, "Referenced GitTarget not found") } r.setGitTargetReadyCondition(st, target) // Resolve GitProvider from target - providerName := target.Spec.ProviderRef.Name + providerName := target.Spec.GitProviderRef.Name providerNS := target.Namespace // Same as GitTarget var provider configbutleraiv1alpha3.GitProvider @@ -225,15 +225,15 @@ func (r *ClusterWatchRuleReconciler) reconcileClusterWatchRuleViaTarget( msg := fmt.Sprintf( "ClusterWatchRule is ready and monitoring resources via GitTarget '%s/%s'", - clusterRule.Spec.TargetRef.Namespace, - clusterRule.Spec.TargetRef.Name, + clusterRule.Spec.GitTargetRef.Namespace, + clusterRule.Spec.GitTargetRef.Name, ) return r.commitRule(ctx, st, ruleReadiness(clusterRule.Status.Conditions, "ClusterWatchRule", msg)) } // gateClusterWatchRule is the ClusterWatchRule gate and the ONE place this controller compiles a // cluster rule. It runs the shared compile path, whose refusal is the ClusterProvider namespace -// admission of the referenced GitTarget: a ClusterWatchRule is cluster-scoped and its targetRef +// admission of the referenced GitTarget: a ClusterWatchRule is cluster-scoped and its gitTargetRef // carries a REQUIRED namespace, so it may name a GitTarget in ANY namespace and widen that target's // mirror scope cluster-wide. Compiling such a rule without re-applying the target's own provider // admission would let it mirror through a credential whose accessFrom never admitted that target. @@ -486,7 +486,7 @@ func (r *ClusterWatchRuleReconciler) namespaceToClusterWatchRules( return r.clusterWatchRulesTargeting(ctx, affected, obj) } -// clusterWatchRulesTargeting returns a request for every ClusterWatchRule whose targetRef names one +// clusterWatchRulesTargeting returns a request for every ClusterWatchRule whose gitTargetRef names one // of the given GitTargets. Requests carry a name only — ClusterWatchRule is cluster-scoped. func (r *ClusterWatchRuleReconciler) clusterWatchRulesTargeting( ctx context.Context, @@ -507,8 +507,8 @@ func (r *ClusterWatchRuleReconciler) clusterWatchRulesTargeting( for i := range rules.Items { rule := &rules.Items[i] key := types.NamespacedName{ - Name: rule.Spec.TargetRef.Name, - Namespace: rule.Spec.TargetRef.Namespace, + Name: rule.Spec.GitTargetRef.Name, + Namespace: rule.Spec.GitTargetRef.Namespace, } if _, ok := targets[key]; !ok { continue @@ -521,7 +521,7 @@ func (r *ClusterWatchRuleReconciler) clusterWatchRulesTargeting( } // gitTargetToClusterWatchRules maps a GitTarget event to every ClusterWatchRule -// whose targetRef matches it. ClusterWatchRule is cluster-scoped, so the lookup +// whose gitTargetRef matches it. ClusterWatchRule is cluster-scoped, so the lookup // is cluster-wide. func (r *ClusterWatchRuleReconciler) gitTargetToClusterWatchRules( ctx context.Context, @@ -536,10 +536,10 @@ func (r *ClusterWatchRuleReconciler) gitTargetToClusterWatchRules( var requests []ctrlreconcile.Request for i := range rules.Items { rule := &rules.Items[i] - if rule.Spec.TargetRef.Name != obj.GetName() { + if rule.Spec.GitTargetRef.Name != obj.GetName() { continue } - if rule.Spec.TargetRef.Namespace != obj.GetNamespace() { + if rule.Spec.GitTargetRef.Namespace != obj.GetNamespace() { continue } requests = append(requests, ctrlreconcile.Request{ @@ -567,7 +567,7 @@ func (r *ClusterWatchRuleReconciler) gitProviderToClusterWatchRules( matchingTargets := make(map[types.NamespacedName]struct{}) for i := range targets.Items { t := &targets.Items[i] - if t.Spec.ProviderRef.Name == obj.GetName() { + if t.Spec.GitProviderRef.Name == obj.GetName() { matchingTargets[types.NamespacedName{Name: t.Name, Namespace: t.Namespace}] = struct{}{} } } @@ -585,8 +585,8 @@ func (r *ClusterWatchRuleReconciler) gitProviderToClusterWatchRules( for i := range rules.Items { rule := &rules.Items[i] key := types.NamespacedName{ - Name: rule.Spec.TargetRef.Name, - Namespace: rule.Spec.TargetRef.Namespace, + Name: rule.Spec.GitTargetRef.Name, + Namespace: rule.Spec.GitTargetRef.Namespace, } if _, ok := matchingTargets[key]; !ok { continue diff --git a/internal/controller/clusterwatchrule_controller_test.go b/internal/controller/clusterwatchrule_controller_test.go index 68be0e62..c6f5629d 100644 --- a/internal/controller/clusterwatchrule_controller_test.go +++ b/internal/controller/clusterwatchrule_controller_test.go @@ -24,7 +24,7 @@ var _ = Describe("ClusterWatchRule Controller", func() { Name: "invalid-subresource-cluster-rule", }, Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ - TargetRef: meta.NamespacedObjectReference{ + GitTargetRef: meta.NamespacedObjectReference{ Name: "target", Namespace: "default", }, @@ -71,7 +71,7 @@ var _ = Describe("ClusterWatchRule Controller", func() { Name: "missing-target-rule", }, Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ - TargetRef: meta.NamespacedObjectReference{ + GitTargetRef: meta.NamespacedObjectReference{ Name: "nonexistent-target", Namespace: "default", }, diff --git a/internal/controller/commitrequest_controller.go b/internal/controller/commitrequest_controller.go index b21a34f8..2a0403e6 100644 --- a/internal/controller/commitrequest_controller.go +++ b/internal/controller/commitrequest_controller.go @@ -164,7 +164,7 @@ func (r *CommitRequestReconciler) Reconcile(ctx context.Context, req ctrl.Reques UID: string(commitRequest.UID), Author: author.Author, Attribution: attribution.gitOutcome(), - GitTargetName: commitRequest.Spec.TargetRef.Name, + GitTargetName: commitRequest.Spec.GitTargetRef.Name, GitTargetNamespace: commitRequest.Namespace, Message: capCommitRequestMessage(commitRequest.Spec.Message), CloseDelaySeconds: commitRequest.Spec.CloseDelaySeconds, @@ -189,7 +189,7 @@ func (r *CommitRequestReconciler) Reconcile(ctx context.Context, req ctrl.Reques if result.Err != nil { log.Error(result.Err, "CommitRequest finalize failed", - "gitTarget", commitRequest.Spec.TargetRef.Name, "name", req.NamespacedName) + "gitTarget", commitRequest.Spec.GitTargetRef.Name, "name", req.NamespacedName) } r.writeTerminalStatus(ctx, log, commitRequest, result, result.Err, attribution) return ctrl.Result{}, nil @@ -318,7 +318,7 @@ func (r *CommitRequestReconciler) writeTerminalStatus( "sha", current.Status.SHA, "outcome", result.Outcome, "finalizeError", finalizeError, - "gitTarget", current.Spec.TargetRef.Name, + "gitTarget", current.Spec.GitTargetRef.Name, "age", time.Since(current.CreationTimestamp.Time).String()) return } diff --git a/internal/controller/commitrequest_controller_test.go b/internal/controller/commitrequest_controller_test.go index 911408ab..0638b2bf 100644 --- a/internal/controller/commitrequest_controller_test.go +++ b/internal/controller/commitrequest_controller_test.go @@ -30,7 +30,7 @@ var _ = Describe("CommitRequest controller", func() { Namespace: namespace, }, Spec: configbutleraiv1alpha3.CommitRequestSpec{ - TargetRef: meta.LocalObjectReference{ + GitTargetRef: meta.LocalObjectReference{ Name: "team-a-config", }, Message: "increase checkout API memory", @@ -58,7 +58,7 @@ var _ = Describe("CommitRequest controller", func() { Namespace: namespace, }, Spec: configbutleraiv1alpha3.CommitRequestSpec{ - TargetRef: meta.LocalObjectReference{ + GitTargetRef: meta.LocalObjectReference{ Name: "team-a-config", }, }, diff --git a/internal/controller/commitrequest_controller_unit_test.go b/internal/controller/commitrequest_controller_unit_test.go index 0ac65655..022a9c4c 100644 --- a/internal/controller/commitrequest_controller_unit_test.go +++ b/internal/controller/commitrequest_controller_unit_test.go @@ -73,8 +73,8 @@ func newCommitRequest(name string) *configv1alpha3.CommitRequest { UID: types.UID("uid-" + name), }, Spec: configv1alpha3.CommitRequestSpec{ - TargetRef: meta.LocalObjectReference{Name: "team-a-config"}, - Message: "save: " + name, + GitTargetRef: meta.LocalObjectReference{Name: "team-a-config"}, + Message: "save: " + name, }, } } diff --git a/internal/controller/dependency_watches_test.go b/internal/controller/dependency_watches_test.go index 80a6dbc4..222ecdcb 100644 --- a/internal/controller/dependency_watches_test.go +++ b/internal/controller/dependency_watches_test.go @@ -66,8 +66,8 @@ func gitTarget(name, namespace, providerName string) *configbutleraiv1alpha3.Git return &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: providerName}, - Branch: "main", + GitProviderRef: meta.LocalObjectReference{Name: providerName}, + Branch: "main", }, } } @@ -76,7 +76,7 @@ func watchRule(name, namespace, targetName string) *configbutleraiv1alpha3.Watch return &configbutleraiv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, Spec: configbutleraiv1alpha3.WatchRuleSpec{ - TargetRef: meta.LocalObjectReference{Name: targetName}, + GitTargetRef: meta.LocalObjectReference{Name: targetName}, }, } } @@ -85,7 +85,7 @@ func clusterWatchRule(name, targetName, targetNamespace string) *configbutleraiv return &configbutleraiv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: name}, Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ - TargetRef: meta.NamespacedObjectReference{ + GitTargetRef: meta.NamespacedObjectReference{ Name: targetName, Namespace: targetNamespace, }, @@ -159,7 +159,7 @@ func TestGitProviderToGitTargets(t *testing.T) { } // TestGitTargetToClusterWatchRules verifies that a GitTarget event enqueues -// every ClusterWatchRule whose targetRef matches both name and namespace. +// every ClusterWatchRule whose gitTargetRef matches both name and namespace. func TestGitTargetToClusterWatchRules(t *testing.T) { tests := []struct { name string diff --git a/internal/controller/gittarget_controller.go b/internal/controller/gittarget_controller.go index 2087d649..6991a28e 100644 --- a/internal/controller/gittarget_controller.go +++ b/internal/controller/gittarget_controller.go @@ -749,18 +749,22 @@ func (r *GitTargetReconciler) ensureEventStream( return nil, errors.New("worker manager is not configured") } - worker, exists := r.WorkerManager.GetWorkerForTarget(target.Spec.ProviderRef.Name, providerNS, target.Spec.Branch) + worker, exists := r.WorkerManager.GetWorkerForTarget( + target.Spec.GitProviderRef.Name, + providerNS, + target.Spec.Branch, + ) if !exists { if err := r.WorkerManager.EnsureWorker( context.Background(), - target.Spec.ProviderRef.Name, + target.Spec.GitProviderRef.Name, providerNS, target.Spec.Branch, ); err != nil { return nil, fmt.Errorf( "failed to ensure branch worker for provider=%s/%s branch=%s: %w", providerNS, - target.Spec.ProviderRef.Name, + target.Spec.GitProviderRef.Name, target.Spec.Branch, err, ) @@ -768,7 +772,7 @@ func (r *GitTargetReconciler) ensureEventStream( var ensured bool worker, ensured = r.WorkerManager.GetWorkerForTarget( - target.Spec.ProviderRef.Name, + target.Spec.GitProviderRef.Name, providerNS, target.Spec.Branch, ) @@ -776,7 +780,7 @@ func (r *GitTargetReconciler) ensureEventStream( return nil, fmt.Errorf( "branch worker not found for provider=%s/%s branch=%s", providerNS, - target.Spec.ProviderRef.Name, + target.Spec.GitProviderRef.Name, target.Spec.Branch, ) } @@ -798,10 +802,10 @@ func (r *GitTargetReconciler) validateProviderAndBranch( providerNS string, ) (bool, string, string, error) { var gp configbutleraiv1alpha3.GitProvider - gpKey := k8stypes.NamespacedName{Name: target.Spec.ProviderRef.Name, Namespace: providerNS} + gpKey := k8stypes.NamespacedName{Name: target.Spec.GitProviderRef.Name, Namespace: providerNS} if err := r.Get(ctx, gpKey, &gp); err != nil { if apierrors.IsNotFound(err) { - msg := fmt.Sprintf("Referenced GitProvider '%s/%s' not found", providerNS, target.Spec.ProviderRef.Name) + msg := fmt.Sprintf("Referenced GitProvider '%s/%s' not found", providerNS, target.Spec.GitProviderRef.Name) return false, msg, GitTargetReasonProviderNotFound, nil } return false, "", "", err @@ -825,7 +829,7 @@ func (r *GitTargetReconciler) validateProviderAndBranch( target.Spec.Branch, gp.Spec.AllowedBranches, providerNS, - target.Spec.ProviderRef.Name, + target.Spec.GitProviderRef.Name, ) return false, msg, GitTargetReasonBranchNotAllowed, nil } @@ -855,7 +859,7 @@ func (r *GitTargetReconciler) checkForConflicts( if existing.Namespace == target.Namespace && existing.Name == target.Name { continue } - if existing.Namespace != providerNS || existing.Spec.ProviderRef.Name != target.Spec.ProviderRef.Name { + if existing.Namespace != providerNS || existing.Spec.GitProviderRef.Name != target.Spec.GitProviderRef.Name { continue } if existing.Spec.Branch != target.Spec.Branch || @@ -876,7 +880,7 @@ func (r *GitTargetReconciler) checkForConflicts( existing.Name, existing.CreationTimestamp.Format(time.RFC3339), providerNS, - target.Spec.ProviderRef.Name, + target.Spec.GitProviderRef.Name, target.Spec.Branch, target.Spec.Path, ) @@ -889,7 +893,7 @@ func (r *GitTargetReconciler) checkForConflicts( existing.Name, existing.CreationTimestamp.Format(time.RFC3339), providerNS, - target.Spec.ProviderRef.Name, + target.Spec.GitProviderRef.Name, target.Spec.Branch, ) } @@ -1239,25 +1243,25 @@ func (r *GitTargetReconciler) SetupWithManager(mgr ctrl.Manager) error { // in its own namespace), so the GitTarget re-Declares its watched-type set when a rule changes. func (r *GitTargetReconciler) watchRuleToGitTarget(_ context.Context, obj client.Object) []ctrlreconcile.Request { wr, ok := obj.(*configbutleraiv1alpha3.WatchRule) - if !ok || wr.Spec.TargetRef.Name == "" { + if !ok || wr.Spec.GitTargetRef.Name == "" { return nil } return []ctrlreconcile.Request{{NamespacedName: k8stypes.NamespacedName{ - Name: wr.Spec.TargetRef.Name, Namespace: wr.Namespace, + Name: wr.Spec.GitTargetRef.Name, Namespace: wr.Namespace, }}} } -// clusterWatchRuleToGitTarget enqueues the GitTarget a ClusterWatchRule targets (its TargetRef +// clusterWatchRuleToGitTarget enqueues the GitTarget a ClusterWatchRule targets (its GitTargetRef // carries the namespace), for the same reason as watchRuleToGitTarget. func (r *GitTargetReconciler) clusterWatchRuleToGitTarget( _ context.Context, obj client.Object, ) []ctrlreconcile.Request { cwr, ok := obj.(*configbutleraiv1alpha3.ClusterWatchRule) - if !ok || cwr.Spec.TargetRef.Name == "" { + if !ok || cwr.Spec.GitTargetRef.Name == "" { return nil } return []ctrlreconcile.Request{{NamespacedName: k8stypes.NamespacedName{ - Name: cwr.Spec.TargetRef.Name, Namespace: cwr.Spec.TargetRef.Namespace, + Name: cwr.Spec.GitTargetRef.Name, Namespace: cwr.Spec.GitTargetRef.Namespace, }}} } @@ -1278,7 +1282,7 @@ func (r *GitTargetReconciler) gitProviderToGitTargets( var requests []ctrlreconcile.Request for i := range targets.Items { t := &targets.Items[i] - if t.Spec.ProviderRef.Name != obj.GetName() { + if t.Spec.GitProviderRef.Name != obj.GetName() { continue } requests = append(requests, ctrlreconcile.Request{ diff --git a/internal/controller/gittarget_controller_test.go b/internal/controller/gittarget_controller_test.go index 2bb61b0b..0824256a 100644 --- a/internal/controller/gittarget_controller_test.go +++ b/internal/controller/gittarget_controller_test.go @@ -34,7 +34,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: "missing-provider", }, Branch: "main", @@ -115,7 +115,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: "test-provider-security", }, Branch: unauthorizedBranch, @@ -206,7 +206,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: "test-provider-allowed", }, Branch: "feature/allowed", @@ -290,7 +290,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: "test-provider-two-axis", }, Branch: "main", @@ -368,7 +368,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: "test-provider-glob", }, Branch: tc.branch, @@ -463,7 +463,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: "test-provider-conflict", }, Branch: "main", @@ -498,7 +498,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: "test-provider-conflict", }, Branch: "main", @@ -582,7 +582,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: "test-provider-no-conflict", }, Branch: "main", @@ -598,7 +598,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: "test-provider-no-conflict", }, Branch: "main", @@ -670,7 +670,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: "test-provider-nested", }, Branch: "main", @@ -704,7 +704,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: "test-provider-nested", }, Branch: "main", @@ -784,7 +784,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: "test-provider-generate-enc-secret", }, Branch: "main", @@ -849,7 +849,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: "test-provider-no-generate-enc-secret", }, Branch: "main", @@ -926,7 +926,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: "test-provider-no-enc-recipients", }, Branch: "main", @@ -1019,7 +1019,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: "test-provider-update-enc-secret", }, Branch: "main", @@ -1079,7 +1079,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: "test-provider-recreate-enc-secret", }, Branch: "main", @@ -1174,7 +1174,7 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: "test-provider-existing-agekey", }, Branch: "main", diff --git a/internal/controller/gittarget_controller_unit_test.go b/internal/controller/gittarget_controller_unit_test.go index cfff2219..4886cc51 100644 --- a/internal/controller/gittarget_controller_unit_test.go +++ b/internal/controller/gittarget_controller_unit_test.go @@ -94,9 +94,9 @@ func TestCheckForConflicts_ListErrorFailsClosed(t *testing.T) { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "target-a", Namespace: "default"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: "provider-a"}, - Branch: "main", - Path: "apps", + GitProviderRef: meta.LocalObjectReference{Name: "provider-a"}, + Branch: "main", + Path: "apps", }, } diff --git a/internal/controller/gittarget_immutability_test.go b/internal/controller/gittarget_immutability_test.go index 4344485a..d73d15f1 100644 --- a/internal/controller/gittarget_immutability_test.go +++ b/internal/controller/gittarget_immutability_test.go @@ -15,7 +15,7 @@ import ( configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" ) -// A GitTarget's destination — providerRef, branch, path — is immutable: changing where +// A GitTarget's destination — gitProviderRef, branch, path — is immutable: changing where // it materializes would orphan the old materialization, so the API server rejects the // change (CEL transition rules) and a relocation is a delete + recreate. This replaces // the alternative of reconciling a destination move (which would need a generation-aware @@ -27,16 +27,16 @@ var _ = Describe("GitTarget Destination Immutability", func() { interval = time.Millisecond * 250 ) - It("rejects changes to providerRef, branch, and path but allows a no-op update", func() { + It("rejects changes to gitProviderRef, branch, and path but allows a no-op update", func() { ctx := context.Background() key := types.NamespacedName{Name: "immutable-target", Namespace: "default"} gitTarget := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: key.Name, Namespace: key.Namespace}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: "prov-a"}, - Branch: "main", - Path: "apps", + GitProviderRef: meta.LocalObjectReference{Name: "prov-a"}, + Branch: "main", + Path: "apps", }, } Expect(k8sClient.Create(ctx, gitTarget)).Should(Succeed()) @@ -71,8 +71,8 @@ var _ = Describe("GitTarget Destination Immutability", func() { gt.Spec.Branch = "develop" }, "spec.branch is immutable") expectImmutable(func(gt *configbutleraiv1alpha3.GitTarget) { - gt.Spec.ProviderRef.Name = "prov-b" - }, "spec.providerRef is immutable") + gt.Spec.GitProviderRef.Name = "prov-b" + }, "spec.gitProviderRef is immutable") }) It("requires a non-empty path: rejects an omitted or empty path but allows an explicit \".\" root", func() { @@ -82,8 +82,8 @@ var _ = Describe("GitTarget Destination Immutability", func() { base := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: key.Name, Namespace: key.Namespace}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: "prov-a"}, - Branch: "main", + GitProviderRef: meta.LocalObjectReference{Name: "prov-a"}, + Branch: "main", }, } diff --git a/internal/controller/gittarget_placement_validation_test.go b/internal/controller/gittarget_placement_validation_test.go index 80c55e3b..a1abb2e6 100644 --- a/internal/controller/gittarget_placement_validation_test.go +++ b/internal/controller/gittarget_placement_validation_test.go @@ -172,9 +172,9 @@ func TestEvaluateValidatedGate_InvalidPlacementPolicy(t *testing.T) { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "target-a", Namespace: ns}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: "provider-a"}, - Branch: "main", - Path: "apps", + GitProviderRef: meta.LocalObjectReference{Name: "provider-a"}, + Branch: "main", + Path: "apps", Placement: &configbutleraiv1alpha3.GitTargetPlacementSpec{ Default: "{bogus}/all.yaml", }, diff --git a/internal/controller/gittarget_source_cluster.go b/internal/controller/gittarget_source_cluster.go index 2c3dbfca..26633ec2 100644 --- a/internal/controller/gittarget_source_cluster.go +++ b/internal/controller/gittarget_source_cluster.go @@ -162,7 +162,7 @@ func (r *GitTargetReconciler) gitProviderReadiness( target *configbutleraiv1alpha3.GitTarget, providerNS string, ) conditionValue { - key := k8stypes.NamespacedName{Name: target.Spec.ProviderRef.Name, Namespace: providerNS} + key := k8stypes.NamespacedName{Name: target.Spec.GitProviderRef.Name, Namespace: providerNS} notReady := func(status metav1.ConditionStatus, format string, args ...any) conditionValue { return conditionValue{ Status: status, diff --git a/internal/controller/gittarget_source_cluster_test.go b/internal/controller/gittarget_source_cluster_test.go index 8f368a28..cfead478 100644 --- a/internal/controller/gittarget_source_cluster_test.go +++ b/internal/controller/gittarget_source_cluster_test.go @@ -135,14 +135,14 @@ func TestCheckSourceAuthorization(t *testing.T) { tests := []struct { name string objects []client.Object - providerRef string + gitProviderRef string wantAuthorized bool wantReason string }{ { name: "provider not found -> hard gate (NotReady, no mirroring)", objects: []client.Object{ns}, - providerRef: "absent", + gitProviderRef: "absent", wantAuthorized: false, wantReason: GitTargetReasonClusterProviderNotFound, }, @@ -151,14 +151,14 @@ func TestCheckSourceAuthorization(t *testing.T) { objects: []client.Object{ provider(&configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}), ns, }, - providerRef: "prod-eu-1", wantAuthorized: true, + gitProviderRef: "prod-eu-1", wantAuthorized: true, }, { name: "provider does not allow the namespace -> refused", objects: []client.Object{ provider(&configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-b"}}), ns, }, - providerRef: "prod-eu-1", wantAuthorized: false, wantReason: GitTargetReasonNamespaceNotAuthorized, + gitProviderRef: "prod-eu-1", wantAuthorized: false, wantReason: GitTargetReasonNamespaceNotAuthorized, }, { name: "provider allows the namespace by label selector", @@ -167,7 +167,7 @@ func TestCheckSourceAuthorization(t *testing.T) { Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "trusted"}}, }), ns, }, - providerRef: "prod-eu-1", wantAuthorized: true, + gitProviderRef: "prod-eu-1", wantAuthorized: true, }, { // A selector the API accepted but that cannot compile must FAIL CLOSED. Treating an @@ -182,7 +182,7 @@ func TestCheckSourceAuthorization(t *testing.T) { }, }), ns, }, - providerRef: "prod-eu-1", wantAuthorized: false, wantReason: GitTargetReasonNamespaceNotAuthorized, + gitProviderRef: "prod-eu-1", wantAuthorized: false, wantReason: GitTargetReasonNamespaceNotAuthorized, }, { // A missing Namespace object is not an error: the policy is still evaluated, just with @@ -191,7 +191,7 @@ func TestCheckSourceAuthorization(t *testing.T) { objects: []client.Object{ provider(&configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}), }, - providerRef: "prod-eu-1", wantAuthorized: true, + gitProviderRef: "prod-eu-1", wantAuthorized: true, }, { name: "namespace object absent -> selector-only policy denies (no labels to match)", @@ -200,14 +200,14 @@ func TestCheckSourceAuthorization(t *testing.T) { Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "trusted"}}, }), }, - providerRef: "prod-eu-1", wantAuthorized: false, wantReason: GitTargetReasonNamespaceNotAuthorized, + gitProviderRef: "prod-eu-1", wantAuthorized: false, wantReason: GitTargetReasonNamespaceNotAuthorized, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { cl := fake.NewClientBuilder().WithScheme(scScheme(t)).WithObjects(tc.objects...).Build() r := &GitTargetReconciler{Client: cl} - ok, reason, msg, err := r.checkSourceAuthorization(context.Background(), target(tc.providerRef)) + ok, reason, msg, err := r.checkSourceAuthorization(context.Background(), target(tc.gitProviderRef)) require.NoError(t, err) assert.Equal(t, tc.wantAuthorized, ok) if !tc.wantAuthorized { @@ -335,7 +335,7 @@ func TestReconcile_UnauthorizedNamespaceStartsNoWatch(t *testing.T) { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: ns, UID: "gt-uid"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: "gp"}, + GitProviderRef: meta.LocalObjectReference{Name: "gp"}, Branch: "main", Path: "apps", ClusterProviderRef: &meta.LocalObjectReference{Name: providerName}, @@ -485,7 +485,7 @@ func TestGitProviderReadiness_AllScenarios(t *testing.T) { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: "team-a"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: "prov"}, + GitProviderRef: meta.LocalObjectReference{Name: "prov"}, }, } got := r.gitProviderReadiness(context.Background(), target, "team-a") diff --git a/internal/controller/renamed_required_field_test.go b/internal/controller/renamed_required_field_test.go new file mode 100644 index 00000000..a77654f1 --- /dev/null +++ b/internal/controller/renamed_required_field_test.go @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "fmt" + "testing" + "time" + + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +// TestRenamedRequiredField_StoredObjectCanAdoptIt is the measurement behind the guard on +// GitTarget's gitProviderRef immutability rule, and the reason renaming a required reference did +// not need a two-release migration. +// +// The hazard is real and this test reproduces it: a GitTarget stored before the rename serves NO +// value for the new name and no value for the old one either — a field outside the structural +// schema is not served, so the loss is immediate on the CRD upgrade rather than on the next write. +// The object is therefore missing a REQUIRED field, and the apply that fixes it is also the apply +// that sets an IMMUTABLE one. Written as a plain `self.x == oldSelf.x`, that rule rejects the +// migrating apply outright, because oldSelf has no such key: the user's only route would be +// delete-and-recreate. +// +// Guarding it as `!has(oldSelf.x) || self.x == oldSelf.x` opens a one-way door. It cannot loosen +// anything, because a required field can never be absent on an object created from this release +// on — which the last assertion here holds to. +// +// The schema surgery below stands in for the release: widen the served schema so an object can be +// stored without the field, then narrow it back to what ships. +func TestRenamedRequiredField_StoredObjectCanAdoptIt(t *testing.T) { + env := &envtest.Environment{ + CRDDirectoryPaths: []string{"../../config/crd/bases"}, + ErrorIfCRDPathMissing: true, + } + cfg, err := env.Start() + if err != nil { + t.Fatalf("start envtest control plane: %v", err) + } + t.Cleanup(func() { _ = env.Stop() }) + + sch := runtime.NewScheme() + if err := scheme.AddToScheme(sch); err != nil { + t.Fatal(err) + } + if err := apiextv1.AddToScheme(sch); err != nil { + t.Fatal(err) + } + c, err := client.New(cfg, client.Options{Scheme: sch}) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + + target := func(name string, withRef bool) *unstructured.Unstructured { + spec := map[string]any{"branch": "main", "path": "clusters/prod"} + if withRef { + spec["gitProviderRef"] = map[string]any{"name": "platform"} + } + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "configbutler.ai/v1alpha3", + "kind": "GitTarget", + "metadata": map[string]any{"name": name, "namespace": "default"}, + "spec": spec, + }} + } + + // Stand in for the pre-rename release: gitProviderRef not yet required. + setGitProviderRefRequired(ctx, t, c, false) + legacy := target("stored-before-the-rename", false) + createUntilServed(ctx, t, c, legacy) + + // Ship the rename: the field is required from here on. + setGitProviderRefRequired(ctx, t, c, true) + requireRefLessCreateRejected(ctx, t, c, target) + + // The migrating apply: one update that sets a required, immutable field for the first time. + stored := &unstructured.Unstructured{} + stored.SetAPIVersion("configbutler.ai/v1alpha3") + stored.SetKind("GitTarget") + if err := c.Get(ctx, client.ObjectKey{Name: legacy.GetName(), Namespace: "default"}, stored); err != nil { + t.Fatalf("get stored object: %v", err) + } + if err := unstructured.SetNestedMap( + stored.Object, map[string]any{"name": "platform"}, "spec", "gitProviderRef"); err != nil { + t.Fatal(err) + } + if err := c.Update(ctx, stored); err != nil { + t.Fatalf("a stored object could not adopt the renamed required field in one apply: %v\n"+ + "Without this, upgrading across the rename would force delete-and-recreate on every "+ + "GitTarget. See the guard on spec.gitProviderRef in api/v1alpha3/gittarget_types.go.", err) + } + + // And the door is shut for everything created after the rename. + fresh := target("created-after-the-rename", true) + if err := c.Create(ctx, fresh); err != nil { + t.Fatalf("create under the shipped schema: %v", err) + } + if err := unstructured.SetNestedMap( + fresh.Object, map[string]any{"name": "somewhere-else"}, "spec", "gitProviderRef"); err != nil { + t.Fatal(err) + } + if err := c.Update(ctx, fresh); err == nil { + t.Fatal("gitProviderRef is no longer immutable: the migration guard has leaked into " + + "objects that were created with the field") + } +} + +// setGitProviderRefRequired flips whether the SERVED GitTarget schema requires spec.gitProviderRef, +// standing in for the release boundary the rename crosses. +func setGitProviderRefRequired(ctx context.Context, t *testing.T, c client.Client, required bool) { + t.Helper() + var crd apiextv1.CustomResourceDefinition + if err := c.Get(ctx, client.ObjectKey{Name: "gittargets.configbutler.ai"}, &crd); err != nil { + t.Fatalf("get CRD: %v", err) + } + spec := crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"] + kept := make([]string, 0, len(spec.Required)+1) + for _, r := range spec.Required { + if r != "gitProviderRef" { + kept = append(kept, r) + } + } + if required { + kept = append(kept, "gitProviderRef") + } + spec.Required = kept + crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"] = spec + if err := c.Update(ctx, &crd); err != nil { + t.Fatalf("update CRD schema: %v", err) + } +} + +// createUntilServed retries a create until the apiserver is serving the schema change. A CRD schema +// update is not visible to the CR handler synchronously, so a single attempt is a flake. +func createUntilServed(ctx context.Context, t *testing.T, c client.Client, obj client.Object) { + t.Helper() + deadline := time.Now().Add(30 * time.Second) + var err error + for time.Now().Before(deadline) { + if err = c.Create(ctx, obj); err == nil { + return + } + obj.SetResourceVersion("") + time.Sleep(100 * time.Millisecond) + } + t.Fatalf("create never succeeded under the widened schema: %v", err) +} + +// requireRefLessCreateRejected blocks until the NARROWED schema is the one being served, proven by +// a create that must fail. Waiting on the rejection rather than on a sleep is what stops the +// migration assertion passing vacuously against the widened schema. +func requireRefLessCreateRejected( + ctx context.Context, + t *testing.T, + c client.Client, + target func(string, bool) *unstructured.Unstructured, +) { + t.Helper() + deadline := time.Now().Add(30 * time.Second) + for i := 0; time.Now().Before(deadline); i++ { + probe := target(fmt.Sprintf("narrowing-probe-%d", i), false) + if err := c.Create(ctx, probe); err != nil { + return + } + _ = c.Delete(ctx, probe) + time.Sleep(100 * time.Millisecond) + } + t.Fatal("gitProviderRef never became required, so the migration assertion would be vacuous") +} diff --git a/internal/controller/stored_superseded_value_status_test.go b/internal/controller/stored_superseded_value_status_test.go index 4e68adf0..4f49fb6c 100644 --- a/internal/controller/stored_superseded_value_status_test.go +++ b/internal/controller/stored_superseded_value_status_test.go @@ -75,7 +75,7 @@ func TestStoredSupersededValue_StatusUpdateIsAccepted(t *testing.T) { stored := &configbutleraiv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "stored-superseded-value"}, Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ - TargetRef: meta.NamespacedObjectReference{Name: "t", Namespace: "default"}, + GitTargetRef: meta.NamespacedObjectReference{Name: "t", Namespace: "default"}, Rules: []configbutleraiv1alpha3.ClusterResourceRule{{ Resources: []string{"configmaps"}, }}, @@ -159,7 +159,7 @@ func requireCreateRejected(ctx context.Context, t *testing.T, c client.Client) { probe := &configbutleraiv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: fmt.Sprintf("narrowing-probe-%d", i)}, Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ - TargetRef: meta.NamespacedObjectReference{Name: "t", Namespace: "default"}, + GitTargetRef: meta.NamespacedObjectReference{Name: "t", Namespace: "default"}, Rules: []configbutleraiv1alpha3.ClusterResourceRule{{ Resources: []string{"configmaps"}, }}, diff --git a/internal/controller/superseded_fields_admission_test.go b/internal/controller/superseded_fields_admission_test.go index def71e41..27cccec9 100644 --- a/internal/controller/superseded_fields_admission_test.go +++ b/internal/controller/superseded_fields_admission_test.go @@ -41,7 +41,7 @@ var _ = Describe("Superseded source-scope fields", func() { "kind": "ClusterWatchRule", "metadata": map[string]any{"name": "legacy-namespaced-scope"}, "spec": map[string]any{ - "targetRef": map[string]any{"name": "any-target", "namespace": "default"}, + "gitTargetRef": map[string]any{"name": "any-target", "namespace": "default"}, "rules": []any{map[string]any{ "resources": []any{"configmaps"}, "scope": "Namespaced", @@ -84,9 +84,9 @@ var _ = Describe("Superseded source-scope fields", func() { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "renamed-commit", Namespace: "default"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: "any-provider"}, - Branch: "main", - Path: "clusters/prod", + GitProviderRef: meta.LocalObjectReference{Name: "any-provider"}, + Branch: "main", + Path: "clusters/prod", Commit: &configbutleraiv1alpha3.GitTargetCommitSpec{ Window: ptr.To("30s"), Message: &configbutleraiv1alpha3.CommitMessageSpec{ @@ -112,7 +112,7 @@ var _ = Describe("Superseded source-scope fields", func() { rule := &configbutleraiv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "cluster-only-rule"}, Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ - TargetRef: meta.NamespacedObjectReference{ + GitTargetRef: meta.NamespacedObjectReference{ Name: "any-target", Namespace: "default", }, Rules: []configbutleraiv1alpha3.ClusterResourceRule{{ @@ -133,7 +133,7 @@ var _ = Describe("Superseded source-scope fields", func() { rule := &configbutleraiv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "per-item-source", Namespace: "default"}, Spec: configbutleraiv1alpha3.WatchRuleSpec{ - TargetRef: meta.LocalObjectReference{Name: "any-target"}, + GitTargetRef: meta.LocalObjectReference{Name: "any-target"}, Rules: []configbutleraiv1alpha3.ResourceRule{ {Resources: []string{"configmaps"}}, {Resources: []string{"secrets"}, SourceNamespace: "repo-config"}, @@ -152,7 +152,7 @@ var _ = Describe("Superseded source-scope fields", func() { rule := &configbutleraiv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "malformed-source", Namespace: "default"}, Spec: configbutleraiv1alpha3.WatchRuleSpec{ - TargetRef: meta.LocalObjectReference{Name: "any-target"}, + GitTargetRef: meta.LocalObjectReference{Name: "any-target"}, Rules: []configbutleraiv1alpha3.ResourceRule{{ Resources: []string{"configmaps"}, SourceNamespace: "Not A Namespace", }}, @@ -198,24 +198,24 @@ var _ = Describe("Reference names must not be empty", func() { target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "empty-provider-name", Namespace: "default"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: ""}, - Branch: "main", - Path: "clusters/prod", + GitProviderRef: meta.LocalObjectReference{Name: ""}, + Branch: "main", + Path: "clusters/prod", }, } Expect(k8sClient.Create(ctx, target)).NotTo(Succeed(), - "an empty providerRef.name names nothing and must be refused at admission") + "an empty gitProviderRef.name names nothing and must be refused at admission") rule := &configbutleraiv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "empty-target-name", Namespace: "default"}, Spec: configbutleraiv1alpha3.WatchRuleSpec{ - TargetRef: meta.LocalObjectReference{Name: ""}, + GitTargetRef: meta.LocalObjectReference{Name: ""}, Rules: []configbutleraiv1alpha3.ResourceRule{ {Resources: []string{"configmaps"}}, }, }, } Expect(k8sClient.Create(ctx, rule)).NotTo(Succeed(), - "an empty targetRef.name names nothing and must be refused at admission") + "an empty gitTargetRef.name names nothing and must be refused at admission") }) }) diff --git a/internal/controller/watchrule_controller.go b/internal/controller/watchrule_controller.go index b6df2b1c..ca6027e2 100644 --- a/internal/controller/watchrule_controller.go +++ b/internal/controller/watchrule_controller.go @@ -88,7 +88,7 @@ func (r *WatchRuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( log.Info("Starting WatchRule validation", "name", watchRule.Name, "namespace", watchRule.Namespace, - "target", watchRule.Spec.TargetRef, + "target", watchRule.Spec.GitTargetRef, "generation", watchRule.Generation, "resourceVersion", watchRule.ResourceVersion) st := beginStatus(r.Client, r.Recorder, &watchRule, &watchRule.Status.Conditions) @@ -117,7 +117,7 @@ func (r *WatchRuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( ) // Route by configuration surface (Target is required now) - if watchRule.Spec.TargetRef.Name == "" { + if watchRule.Spec.GitTargetRef.Name == "" { st.set( ConditionTypeGitTargetReady, metav1.ConditionFalse, @@ -130,14 +130,14 @@ func (r *WatchRuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( } // reconcileWatchRuleViaTarget validates and stores a WatchRule that references a GitTarget. -// watchRuleGitTarget names the GitTarget a WatchRule writes through. targetRef is a +// watchRuleGitTarget names the GitTarget a WatchRule writes through. gitTargetRef is a // meta.LocalObjectReference, so the GitTarget is always in the rule's own namespace. // // It carries no UID, and that is correct rather than an omission: a rule-derived reference has // none to carry, and the watch-plane owner resolves the trigger against the UID the GitTarget // controller captured. See resolveGitTargetUID. func watchRuleGitTarget(rule *configbutleraiv1alpha3.WatchRule) reverserTypes.ResourceReference { - return reverserTypes.NewResourceReference(rule.Spec.TargetRef.Name, rule.Namespace) + return reverserTypes.NewResourceReference(rule.Spec.GitTargetRef.Name, rule.Namespace) } func (r *WatchRuleReconciler) reconcileWatchRuleViaTarget( @@ -152,16 +152,16 @@ func (r *WatchRuleReconciler) reconcileWatchRuleViaTarget( // Fetch GitTarget var target configbutleraiv1alpha3.GitTarget - targetKey := types.NamespacedName{Name: watchRule.Spec.TargetRef.Name, Namespace: targetNS} + targetKey := types.NamespacedName{Name: watchRule.Spec.GitTargetRef.Name, Namespace: targetNS} if err := r.Get(ctx, targetKey, &target); err != nil { log.Error(err, "Failed to get referenced GitTarget", - "gitTargetName", watchRule.Spec.TargetRef.Name, + "gitTargetName", watchRule.Spec.GitTargetRef.Name, "gitTargetNamespace", targetNS) st.set( ConditionTypeGitTargetReady, metav1.ConditionFalse, WatchRuleReasonGitTargetNotFound, - fmt.Sprintf("Referenced GitTarget '%s/%s' not found: %v", targetNS, watchRule.Spec.TargetRef.Name, err), + fmt.Sprintf("Referenced GitTarget '%s/%s' not found: %v", targetNS, watchRule.Spec.GitTargetRef.Name, err), ) return r.stallRule(ctx, st, WatchRuleReasonGitTargetNotFound, "Referenced GitTarget not found") } @@ -170,7 +170,7 @@ func (r *WatchRuleReconciler) reconcileWatchRuleViaTarget( // Resolve the GitProvider named by the target. A meta.LocalObjectReference is a // name-only reference to a GitProvider in the GitTarget's own namespace. - providerName := target.Spec.ProviderRef.Name + providerName := target.Spec.GitProviderRef.Name providerNS := target.Namespace // GitProvider is namespace-local to the GitTarget var provider configbutleraiv1alpha3.GitProvider @@ -211,7 +211,7 @@ func (r *WatchRuleReconciler) reconcileWatchRuleViaTarget( msg := fmt.Sprintf( "WatchRule is ready and monitoring resources via GitTarget '%s/%s'", targetNS, - watchRule.Spec.TargetRef.Name, + watchRule.Spec.GitTargetRef.Name, ) return r.commitRule(ctx, st, ruleReadiness(watchRule.Status.Conditions, "WatchRule", msg)) } @@ -336,7 +336,7 @@ func (r *WatchRuleReconciler) clusterProviderToWatchRules( return nil } - // A WatchRule's targetRef is a meta.LocalObjectReference, so candidates always live in their + // A WatchRule's gitTargetRef is a meta.LocalObjectReference, so candidates always live in their // GitTarget's own namespace — collect the affected (namespace, target name) pairs. affected := make(map[types.NamespacedName]struct{}, len(targets.Items)) for i := range targets.Items { @@ -359,7 +359,7 @@ func (r *WatchRuleReconciler) clusterProviderToWatchRules( var requests []ctrlreconcile.Request for i := range rules.Items { rule := &rules.Items[i] - key := types.NamespacedName{Name: rule.Spec.TargetRef.Name, Namespace: rule.Namespace} + key := types.NamespacedName{Name: rule.Spec.GitTargetRef.Name, Namespace: rule.Namespace} if _, ok := affected[key]; !ok { continue } @@ -371,7 +371,7 @@ func (r *WatchRuleReconciler) clusterProviderToWatchRules( } // gitTargetToWatchRules maps a GitTarget event to every WatchRule in the -// GitTarget's namespace that references it. WatchRule.spec.targetRef is a +// GitTarget's namespace that references it. WatchRule.spec.gitTargetRef is a // meta.LocalObjectReference, so candidates only live in the same namespace as the // GitTarget. func (r *WatchRuleReconciler) gitTargetToWatchRules( @@ -387,7 +387,7 @@ func (r *WatchRuleReconciler) gitTargetToWatchRules( var requests []ctrlreconcile.Request for i := range rules.Items { rule := &rules.Items[i] - if rule.Spec.TargetRef.Name != obj.GetName() { + if rule.Spec.GitTargetRef.Name != obj.GetName() { continue } requests = append(requests, ctrlreconcile.Request{ @@ -415,7 +415,7 @@ func (r *WatchRuleReconciler) gitProviderToWatchRules( matchingTargets := make(map[string]struct{}) for i := range targets.Items { t := &targets.Items[i] - if t.Spec.ProviderRef.Name == obj.GetName() { + if t.Spec.GitProviderRef.Name == obj.GetName() { matchingTargets[t.Name] = struct{}{} } } @@ -432,7 +432,7 @@ func (r *WatchRuleReconciler) gitProviderToWatchRules( var requests []ctrlreconcile.Request for i := range rules.Items { rule := &rules.Items[i] - if _, ok := matchingTargets[rule.Spec.TargetRef.Name]; !ok { + if _, ok := matchingTargets[rule.Spec.GitTargetRef.Name]; !ok { continue } requests = append(requests, ctrlreconcile.Request{ diff --git a/internal/controller/watchrule_controller_test.go b/internal/controller/watchrule_controller_test.go index 73263bb2..df26bb11 100644 --- a/internal/controller/watchrule_controller_test.go +++ b/internal/controller/watchrule_controller_test.go @@ -28,7 +28,7 @@ var _ = Describe("WatchRule Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.WatchRuleSpec{ - TargetRef: meta.LocalObjectReference{ + GitTargetRef: meta.LocalObjectReference{ Name: "target", }, Rules: []configbutleraiv1alpha3.ResourceRule{{ @@ -78,7 +78,7 @@ var _ = Describe("WatchRule Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: "test-provider", }, Branch: "main", @@ -96,7 +96,7 @@ var _ = Describe("WatchRule Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.WatchRuleSpec{ - TargetRef: meta.LocalObjectReference{ + GitTargetRef: meta.LocalObjectReference{ Name: "test-target", }, Rules: []configbutleraiv1alpha3.ResourceRule{ @@ -194,7 +194,7 @@ var _ = Describe("WatchRule Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: "local-provider", }, Branch: "main", @@ -210,7 +210,7 @@ var _ = Describe("WatchRule Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.WatchRuleSpec{ - TargetRef: meta.LocalObjectReference{ + GitTargetRef: meta.LocalObjectReference{ Name: "local-target", }, Rules: []configbutleraiv1alpha3.ResourceRule{ diff --git a/internal/controller/watchrule_source_namespace_test.go b/internal/controller/watchrule_source_namespace_test.go index 404ca013..65a9dc0d 100644 --- a/internal/controller/watchrule_source_namespace_test.go +++ b/internal/controller/watchrule_source_namespace_test.go @@ -35,7 +35,7 @@ func wrsnGitTarget() *configbutleraiv1alpha3.GitTarget { return &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: wrsnTarget, Namespace: wrsnTenantNS}, Spec: configbutleraiv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: "git"}, + GitProviderRef: meta.LocalObjectReference{Name: "git"}, ClusterProviderRef: &meta.LocalObjectReference{Name: wrsnProvider}, Branch: "main", Path: "tenants/acme", @@ -72,8 +72,8 @@ func wrsnWatchRule(sourceNamespaces ...string) *configbutleraiv1alpha3.WatchRule return &configbutleraiv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: wrsnRule, Namespace: wrsnTenantNS, Generation: 1}, Spec: configbutleraiv1alpha3.WatchRuleSpec{ - TargetRef: meta.LocalObjectReference{Name: wrsnTarget}, - Rules: items, + GitTargetRef: meta.LocalObjectReference{Name: wrsnTarget}, + Rules: items, }, } } diff --git a/internal/git/branch_worker_loop_test.go b/internal/git/branch_worker_loop_test.go index 473b79ef..bcba6850 100644 --- a/internal/git/branch_worker_loop_test.go +++ b/internal/git/branch_worker_loop_test.go @@ -25,9 +25,9 @@ func TestCommitWindowFor_DefaultsAndParsing(t *testing.T) { target := func(name string, window *string) *configv1alpha3.GitTarget { spec := configv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: "p"}, - Branch: "main", - Path: "clusters/prod", + GitProviderRef: meta.LocalObjectReference{Name: "p"}, + Branch: "main", + Path: "clusters/prod", } if window != nil { spec.Commit = &configv1alpha3.GitTargetCommitSpec{Window: window} diff --git a/internal/git/branch_worker_split_test.go b/internal/git/branch_worker_split_test.go index 525aeeac..78247546 100644 --- a/internal/git/branch_worker_split_test.go +++ b/internal/git/branch_worker_split_test.go @@ -96,7 +96,7 @@ func createGitTarget( Namespace: "default", }, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: worker.GitProviderRef, }, Branch: worker.Branch, diff --git a/internal/git/branch_worker_test.go b/internal/git/branch_worker_test.go index 5c3316af..72d1135c 100644 --- a/internal/git/branch_worker_test.go +++ b/internal/git/branch_worker_test.go @@ -441,7 +441,7 @@ func TestBranchWorker_EnsurePathBootstrapped_RendersAllResolvedRecipients(t *tes target := &configv1alpha3.GitTarget{} target.Name = "bootstrap-target" target.Namespace = "default" - target.Spec.ProviderRef = meta.LocalObjectReference{ + target.Spec.GitProviderRef = meta.LocalObjectReference{ Name: "test-repo", } target.Spec.Branch = "main" @@ -734,7 +734,7 @@ func TestBranchWorker_CommitAndPushRequest_UsesProviderCommitterAndTargetMessage target := &configv1alpha3.GitTarget{} target.Name = "audit-target" target.Namespace = "default" - target.Spec.ProviderRef = meta.LocalObjectReference{Name: "test-repo"} + target.Spec.GitProviderRef = meta.LocalObjectReference{Name: "test-repo"} target.Spec.Branch = "main" target.Spec.Path = "clusters/dev" target.Spec.Commit = &configv1alpha3.GitTargetCommitSpec{ @@ -824,7 +824,7 @@ func TestBranchWorker_CommitAndPushRequest_UsesBatchTemplateForAtomicRequest(t * target := &configv1alpha3.GitTarget{} target.Name = "demo-target" target.Namespace = "default" - target.Spec.ProviderRef = meta.LocalObjectReference{Name: "test-repo"} + target.Spec.GitProviderRef = meta.LocalObjectReference{Name: "test-repo"} target.Spec.Branch = "main" target.Spec.Path = "clusters/dev" target.Spec.Commit = &configv1alpha3.GitTargetCommitSpec{ @@ -1113,7 +1113,7 @@ func createTargetWithEncryption( target := &configv1alpha3.GitTarget{} target.Name = name target.Namespace = namespace - target.Spec.ProviderRef = meta.LocalObjectReference{ + target.Spec.GitProviderRef = meta.LocalObjectReference{ Name: providerName, } target.Spec.Branch = branch @@ -1143,7 +1143,7 @@ func createTargetWithoutEncryption( target := &configv1alpha3.GitTarget{} target.Name = name target.Namespace = namespace - target.Spec.ProviderRef = meta.LocalObjectReference{ + target.Spec.GitProviderRef = meta.LocalObjectReference{ Name: providerName, } target.Spec.Branch = branch @@ -1169,7 +1169,7 @@ func createTargetWithEncryptionSecretData( target := &configv1alpha3.GitTarget{} target.Name = name target.Namespace = namespace - target.Spec.ProviderRef = meta.LocalObjectReference{ + target.Spec.GitProviderRef = meta.LocalObjectReference{ Name: providerName, } target.Spec.Branch = branch diff --git a/internal/git/layout_corpus_test.go b/internal/git/layout_corpus_test.go index ef3647ee..6b705462 100644 --- a/internal/git/layout_corpus_test.go +++ b/internal/git/layout_corpus_test.go @@ -403,7 +403,7 @@ func readCorpusSourceNamespaces( // Strict, for the same reason the GitTarget above is: a fixture naming a field the API // does not have must fail to parse rather than be quietly ignored. require.NoError(t, yaml.UnmarshalStrict(raw, &rule), "parsing %s", path) - require.Equal(t, target.Name, rule.Spec.TargetRef.Name, + require.Equal(t, target.Name, rule.Spec.GitTargetRef.Name, "%s points at a different GitTarget than the scenario's config", path) for _, item := range rule.Spec.Rules { if item.IsSourceNamespaceWildcard() { diff --git a/internal/git/secret_write_test.go b/internal/git/secret_write_test.go index c9e8d27d..f2726217 100644 --- a/internal/git/secret_write_test.go +++ b/internal/git/secret_write_test.go @@ -68,7 +68,7 @@ func secretTargetObjects(t *testing.T, providerName, branch, path string) []clie Namespace: "default", }, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{ + GitProviderRef: meta.LocalObjectReference{ Name: providerName, }, Branch: branch, diff --git a/internal/git/source_namespaces.go b/internal/git/source_namespaces.go index 98a2a09f..60da2d64 100644 --- a/internal/git/source_namespaces.go +++ b/internal/git/source_namespaces.go @@ -47,7 +47,7 @@ func resolveSourceNamespaces( seen := map[string]struct{}{target.Namespace: {}} for i := range rules.Items { rule := &rules.Items[i] - if rule.Spec.TargetRef.Name != target.Name { + if rule.Spec.GitTargetRef.Name != target.Name { continue } for _, item := range rule.Spec.Rules { diff --git a/internal/git/source_namespaces_test.go b/internal/git/source_namespaces_test.go index e41ec036..bf0e0f63 100644 --- a/internal/git/source_namespaces_test.go +++ b/internal/git/source_namespaces_test.go @@ -21,7 +21,7 @@ func sourceNamespaceRule(name, targetName, sourceNamespace string) *configv1alph return &configv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "shop"}, Spec: configv1alpha3.WatchRuleSpec{ - TargetRef: meta.LocalObjectReference{Name: targetName}, + GitTargetRef: meta.LocalObjectReference{Name: targetName}, Rules: []configv1alpha3.ResourceRule{{ Resources: []string{"configmaps"}, SourceNamespace: sourceNamespace, diff --git a/internal/git/worker_manager.go b/internal/git/worker_manager.go index d6215d51..c641a431 100644 --- a/internal/git/worker_manager.go +++ b/internal/git/worker_manager.go @@ -286,7 +286,7 @@ func (m *WorkerManager) ReconcileWorkers(ctx context.Context) error { key := BranchKey{ RepoNamespace: providerNS, - RepoName: target.Spec.ProviderRef.Name, + RepoName: target.Spec.GitProviderRef.Name, Branch: target.Spec.Branch, } neededWorkers[key] = true diff --git a/internal/git/worker_manager_test.go b/internal/git/worker_manager_test.go index 842fbcc2..830c2999 100644 --- a/internal/git/worker_manager_test.go +++ b/internal/git/worker_manager_test.go @@ -89,7 +89,7 @@ func createTargetForRegister( target := &configv1alpha3.GitTarget{} target.Name = name target.Namespace = testTargetNamespace - target.Spec.ProviderRef = meta.LocalObjectReference{ + target.Spec.GitProviderRef = meta.LocalObjectReference{ Name: providerName, } target.Spec.Branch = branch diff --git a/internal/git/write_boundary_precondition_test.go b/internal/git/write_boundary_precondition_test.go index 8dba2972..2c2db4b5 100644 --- a/internal/git/write_boundary_precondition_test.go +++ b/internal/git/write_boundary_precondition_test.go @@ -203,9 +203,9 @@ func diamondGitTarget(providerName, branch string) *configv1alpha3.GitTarget { return &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "podinfo-test", Namespace: "default"}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: providerName}, - Branch: branch, - Path: "", + GitProviderRef: meta.LocalObjectReference{Name: providerName}, + Branch: branch, + Path: "", }, } } diff --git a/internal/rulestore/store_test.go b/internal/rulestore/store_test.go index 0bcb4e28..61ad94d0 100644 --- a/internal/rulestore/store_test.go +++ b/internal/rulestore/store_test.go @@ -1024,7 +1024,7 @@ func TestGetMatchingRules_NamespacedWatchRule_NamespaceContract(t *testing.T) { configv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "cluster-services"}, Spec: configv1alpha3.ClusterWatchRuleSpec{ - TargetRef: meta.NamespacedObjectReference{Name: "cluster-target", Namespace: "ops"}, + GitTargetRef: meta.NamespacedObjectReference{Name: "cluster-target", Namespace: "ops"}, Rules: []configv1alpha3.ClusterResourceRule{{ Operations: []configv1alpha3.OperationType{configv1alpha3.OperationAll}, APIGroups: []string{""}, diff --git a/internal/watch/bootstrap.go b/internal/watch/bootstrap.go index 789f8fae..2f4b3dc5 100644 --- a/internal/watch/bootstrap.go +++ b/internal/watch/bootstrap.go @@ -50,7 +50,7 @@ func (m *Manager) bootstrapWatchRule(ctx context.Context, rule configv1alpha3.Wa targetNS := rule.Namespace target, provider, err := m.resolveTargetAndProvider(ctx, client.ObjectKey{ - Name: rule.Spec.TargetRef.Name, + Name: rule.Spec.GitTargetRef.Name, Namespace: targetNS, }) if err != nil { @@ -77,10 +77,10 @@ func (m *Manager) bootstrapWatchRule(ctx context.Context, rule configv1alpha3.Wa } func (m *Manager) bootstrapClusterWatchRule(ctx context.Context, rule configv1alpha3.ClusterWatchRule) error { - targetNS := rule.Spec.TargetRef.Namespace + targetNS := rule.Spec.GitTargetRef.Namespace target, provider, err := m.resolveTargetAndProvider(ctx, client.ObjectKey{ - Name: rule.Spec.TargetRef.Name, + Name: rule.Spec.GitTargetRef.Name, Namespace: targetNS, }) if err != nil { @@ -115,7 +115,7 @@ func (m *Manager) resolveTargetAndProvider( } providerKey := client.ObjectKey{ - Name: target.Spec.ProviderRef.Name, + Name: target.Spec.GitProviderRef.Name, Namespace: target.Namespace, } var provider configv1alpha3.GitProvider diff --git a/internal/watch/bootstrap_admission_test.go b/internal/watch/bootstrap_admission_test.go index 44890721..528eb79d 100644 --- a/internal/watch/bootstrap_admission_test.go +++ b/internal/watch/bootstrap_admission_test.go @@ -35,7 +35,7 @@ func bootGitTarget() *configv1alpha3.GitTarget { return &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: bootTargetName, Namespace: bootTargetNS}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: "git"}, + GitProviderRef: meta.LocalObjectReference{Name: "git"}, ClusterProviderRef: &meta.LocalObjectReference{Name: bootProviderName}, Branch: "main", Path: "clusters/prod", @@ -60,7 +60,7 @@ func bootClusterWatchRule() *configv1alpha3.ClusterWatchRule { return &configv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: bootRuleName}, Spec: configv1alpha3.ClusterWatchRuleSpec{ - TargetRef: meta.NamespacedObjectReference{Name: bootTargetName, Namespace: bootTargetNS}, + GitTargetRef: meta.NamespacedObjectReference{Name: bootTargetName, Namespace: bootTargetNS}, Rules: []configv1alpha3.ClusterResourceRule{{ Resources: []string{"customresourcedefinitions"}, APIGroups: []string{"apiextensions.k8s.io"}, @@ -143,7 +143,7 @@ func TestBootstrapRuleStore_SkipsUnauthorizedRuleButStillReady(t *testing.T) { admittedTarget := &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "ok-mirror", Namespace: "team-ok"}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: "git"}, + GitProviderRef: meta.LocalObjectReference{Name: "git"}, ClusterProviderRef: &meta.LocalObjectReference{Name: bootProviderName}, Branch: "main", Path: "clusters/ok", @@ -151,8 +151,8 @@ func TestBootstrapRuleStore_SkipsUnauthorizedRuleButStillReady(t *testing.T) { } admittedRule := bootClusterWatchRule() admittedRule.Name = "admitted-rule" - admittedRule.Spec.TargetRef.Name = "ok-mirror" - admittedRule.Spec.TargetRef.Namespace = "team-ok" + admittedRule.Spec.GitTargetRef.Name = "ok-mirror" + admittedRule.Spec.GitTargetRef.Namespace = "team-ok" m := bootManager(t, bootGitTarget(), bootGitProvider(), bootClusterWatchRule(), diff --git a/internal/watch/config_plane_split_review_fixes_test.go b/internal/watch/config_plane_split_review_fixes_test.go index ac0b81e7..d1a109a4 100644 --- a/internal/watch/config_plane_split_review_fixes_test.go +++ b/internal/watch/config_plane_split_review_fixes_test.go @@ -305,7 +305,7 @@ func TestResolveWatchRuleResources_ResolvesAgainstSourceCluster(t *testing.T) { remoteOnly := configv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "r", Namespace: "test-ns"}, Spec: configv1alpha3.WatchRuleSpec{ - TargetRef: meta.LocalObjectReference{Name: "t"}, + GitTargetRef: meta.LocalObjectReference{Name: "t"}, Rules: []configv1alpha3.ResourceRule{ {APIGroups: []string{"example.com"}, Resources: []string{"widgets"}}, }, @@ -319,8 +319,8 @@ func TestResolveWatchRuleResources_ResolvesAgainstSourceCluster(t *testing.T) { localOnly := configv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "r2", Namespace: "test-ns"}, Spec: configv1alpha3.WatchRuleSpec{ - TargetRef: meta.LocalObjectReference{Name: "t"}, - Rules: []configv1alpha3.ResourceRule{{Resources: []string{"deployments"}}}, + GitTargetRef: meta.LocalObjectReference{Name: "t"}, + Rules: []configv1alpha3.ResourceRule{{Resources: []string{"deployments"}}}, }, } resolved, message = m.ResolveWatchRuleResources(context.Background(), localOnly) @@ -338,7 +338,7 @@ func TestResolveClusterWatchRuleResources_ResolvesAgainstSourceCluster(t *testin rule := configv1alpha3.ClusterWatchRule{ Spec: configv1alpha3.ClusterWatchRuleSpec{ - TargetRef: meta.NamespacedObjectReference{Name: "t", Namespace: "test-ns"}, + GitTargetRef: meta.NamespacedObjectReference{Name: "t", Namespace: "test-ns"}, Rules: []configv1alpha3.ClusterResourceRule{{ APIGroups: []string{"example.com"}, Resources: []string{"widgetclasses"}, diff --git a/internal/watch/event_router.go b/internal/watch/event_router.go index eedcf8b8..82a1abf5 100644 --- a/internal/watch/event_router.go +++ b/internal/watch/event_router.go @@ -115,7 +115,7 @@ func (r *EventRouter) ServiceCommitRequest( } worker, exists := r.WorkerManager.GetWorkerForTarget( - gitTarget.Spec.ProviderRef.Name, + gitTarget.Spec.GitProviderRef.Name, gitTarget.Namespace, // provider is in the same namespace as the target gitTarget.Spec.Branch, ) @@ -176,7 +176,7 @@ func (r *EventRouter) resolveWorkerForGitDest( return nil, fmt.Errorf("get GitTarget %s: %w", gitDest.String(), err) } worker, exists := r.WorkerManager.GetWorkerForTarget( - gitTarget.Spec.ProviderRef.Name, + gitTarget.Spec.GitProviderRef.Name, gitTarget.Namespace, // provider is in the same namespace as the target gitTarget.Spec.Branch, ) diff --git a/internal/watch/event_router_test.go b/internal/watch/event_router_test.go index 7e2f0bc5..6a418dc9 100644 --- a/internal/watch/event_router_test.go +++ b/internal/watch/event_router_test.go @@ -88,8 +88,8 @@ func TestEnqueueScopedResync_ReportsMissingWorker(t *testing.T) { gitTarget := &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "team-a-config", Namespace: "team-a"}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: "team-a-provider"}, - Branch: "main", + GitProviderRef: meta.LocalObjectReference{Name: "team-a-provider"}, + Branch: "main", }, } client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(gitTarget).Build() @@ -274,8 +274,8 @@ func TestServiceCommitRequest_NoWorkerResolvesNoOpenWindow(t *testing.T) { gitTarget := &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "team-a-config", Namespace: "team-a"}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: "team-a-provider"}, - Branch: "main", + GitProviderRef: meta.LocalObjectReference{Name: "team-a-provider"}, + Branch: "main", }, } client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(gitTarget).Build() @@ -299,8 +299,8 @@ func TestServiceCommitRequest_RegisteredWorkerResolvesNoOpenWindow(t *testing.T) gitTarget := &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "team-a-config", Namespace: "team-a"}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: "team-a-provider"}, - Branch: "main", + GitProviderRef: meta.LocalObjectReference{Name: "team-a-provider"}, + Branch: "main", }, } client := fake.NewClientBuilder().WithScheme(scheme).WithObjects(provider, gitTarget).Build() diff --git a/internal/watch/manager_catalog.go b/internal/watch/manager_catalog.go index 9ef86292..ed6aa153 100644 --- a/internal/watch/manager_catalog.go +++ b/internal/watch/manager_catalog.go @@ -369,13 +369,13 @@ func (m *Manager) ResolveWatchRuleResources( scope: configv1alpha3.ResourceScopeNamespaced, }) } - gitDest := types.NewResourceReference(rule.Spec.TargetRef.Name, rule.Namespace) + gitDest := types.NewResourceReference(rule.Spec.GitTargetRef.Name, rule.Namespace) return m.resolveRuleResourceStatus(gitDest, selectors) } // ResolveClusterWatchRuleResources reports one ClusterWatchRule's resource-resolution // status for controller feedback. See resolveRuleResourceStatus. A ClusterWatchRule is -// cluster-scoped, so its targetRef names both the GitTarget and its namespace; the status +// cluster-scoped, so its gitTargetRef names both the GitTarget and its namespace; the status // resolves against that GitTarget's source cluster. func (m *Manager) ResolveClusterWatchRuleResources( _ context.Context, @@ -390,7 +390,7 @@ func (m *Manager) ResolveClusterWatchRuleResources( scope: configv1alpha3.ResourceScopeCluster, }) } - gitDest := types.NewResourceReference(rule.Spec.TargetRef.Name, rule.Spec.TargetRef.Namespace) + gitDest := types.NewResourceReference(rule.Spec.GitTargetRef.Name, rule.Spec.GitTargetRef.Namespace) return m.resolveRuleResourceStatus(gitDest, selectors) } diff --git a/internal/watch/manager_snapshot_test.go b/internal/watch/manager_snapshot_test.go index 675f1019..b9977654 100644 --- a/internal/watch/manager_snapshot_test.go +++ b/internal/watch/manager_snapshot_test.go @@ -69,7 +69,7 @@ func addSecretsWatchRule(store *rulestore.RuleStore) { rule := configv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "wr-secrets", Namespace: "ns-a"}, Spec: configv1alpha3.WatchRuleSpec{ - TargetRef: meta.LocalObjectReference{Name: "my-target"}, + GitTargetRef: meta.LocalObjectReference{Name: "my-target"}, Rules: []configv1alpha3.ResourceRule{{ APIGroups: []string{""}, APIVersions: []string{"v1"}, Resources: []string{"secrets"}, }}, diff --git a/internal/watch/manager_startup_test.go b/internal/watch/manager_startup_test.go index a86de5ed..4160a885 100644 --- a/internal/watch/manager_startup_test.go +++ b/internal/watch/manager_startup_test.go @@ -29,7 +29,7 @@ func TestManagerStart_MustSeedRuleStoreFromExistingWatchRules(t *testing.T) { existingWatchRule := &configv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "playground-watchrule", Namespace: "tilt-playground"}, Spec: configv1alpha3.WatchRuleSpec{ - TargetRef: meta.LocalObjectReference{Name: "playground-target"}, + GitTargetRef: meta.LocalObjectReference{Name: "playground-target"}, Rules: []configv1alpha3.ResourceRule{{ APIGroups: []string{""}, APIVersions: []string{"v1"}, @@ -40,9 +40,9 @@ func TestManagerStart_MustSeedRuleStoreFromExistingWatchRules(t *testing.T) { existingGitTarget := &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "playground-target", Namespace: "tilt-playground"}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: "playground-provider"}, - Branch: "main", - Path: "live-cluster", + GitProviderRef: meta.LocalObjectReference{Name: "playground-provider"}, + Branch: "main", + Path: "live-cluster", }, } existingGitProvider := &configv1alpha3.GitProvider{ @@ -96,7 +96,7 @@ func TestManagerStart_MustSeedRuleStoreFromExistingClusterWatchRules(t *testing. existingClusterWatchRule := &configv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "cluster-namespaces"}, Spec: configv1alpha3.ClusterWatchRuleSpec{ - TargetRef: meta.NamespacedObjectReference{Name: "ops-target", Namespace: "ops"}, + GitTargetRef: meta.NamespacedObjectReference{Name: "ops-target", Namespace: "ops"}, Rules: []configv1alpha3.ClusterResourceRule{{ APIGroups: []string{""}, APIVersions: []string{"v1"}, @@ -107,9 +107,9 @@ func TestManagerStart_MustSeedRuleStoreFromExistingClusterWatchRules(t *testing. existingGitTarget := &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "ops-target", Namespace: "ops"}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: "ops-provider"}, - Branch: "main", - Path: "cluster-state", + GitProviderRef: meta.LocalObjectReference{Name: "ops-provider"}, + Branch: "main", + Path: "cluster-state", }, } existingGitProvider := &configv1alpha3.GitProvider{ diff --git a/internal/watch/source_namespace_planning_test.go b/internal/watch/source_namespace_planning_test.go index e6a37155..8e2b217b 100644 --- a/internal/watch/source_namespace_planning_test.go +++ b/internal/watch/source_namespace_planning_test.go @@ -32,7 +32,7 @@ func watchRuleWithSource(name, gitTargetName, sourceNamespace string) configv1al // would hand the store — so the planning assertions run against the real shape. func addRule(store *rulestore.RuleStore, rule configv1alpha3.WatchRule, scope [][]string) { store.AddOrUpdateWatchRule( - rule, scope, rule.Spec.TargetRef.Name, "test-ns", "test-provider", "test-ns", "main", "test-path") + rule, scope, rule.Spec.GitTargetRef.Name, "test-ns", "test-provider", "test-ns", "main", "test-path") } // makeStoreWithScope compiles one WatchRule with a given resolved scope into a fresh store, for the diff --git a/internal/watch/source_namespace_stream_summary_test.go b/internal/watch/source_namespace_stream_summary_test.go index 00cd84c7..f4af2ca2 100644 --- a/internal/watch/source_namespace_stream_summary_test.go +++ b/internal/watch/source_namespace_stream_summary_test.go @@ -48,7 +48,7 @@ func srcnsOverrideRule(sourceNamespace string) configv1alpha3.WatchRule { return configv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "repo-config-rule", Namespace: "tenant-acme"}, Spec: configv1alpha3.WatchRuleSpec{ - TargetRef: meta.LocalObjectReference{Name: "acme"}, + GitTargetRef: meta.LocalObjectReference{Name: "acme"}, Rules: []configv1alpha3.ResourceRule{{ Resources: []string{"configmaps"}, SourceNamespace: sourceNamespace, }}, diff --git a/internal/watch/source_namespace_test.go b/internal/watch/source_namespace_test.go index 9ca077ae..c56cb6a9 100644 --- a/internal/watch/source_namespace_test.go +++ b/internal/watch/source_namespace_test.go @@ -32,7 +32,7 @@ func snbGitTarget() *configv1alpha3.GitTarget { return &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: snbTarget, Namespace: snbTenantNS}, Spec: configv1alpha3.GitTargetSpec{ - ProviderRef: meta.LocalObjectReference{Name: "git"}, + GitProviderRef: meta.LocalObjectReference{Name: "git"}, ClusterProviderRef: &meta.LocalObjectReference{Name: snbProvider}, Branch: "main", Path: "tenants/acme", @@ -67,8 +67,8 @@ func snbWatchRule(sourceNamespaces ...string) *configv1alpha3.WatchRule { return &configv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: snbRule, Namespace: snbTenantNS}, Spec: configv1alpha3.WatchRuleSpec{ - TargetRef: meta.LocalObjectReference{Name: snbTarget}, - Rules: items, + GitTargetRef: meta.LocalObjectReference{Name: snbTarget}, + Rules: items, }, } } diff --git a/internal/watch/stream_readiness.go b/internal/watch/stream_readiness.go index 37239371..58fea1fd 100644 --- a/internal/watch/stream_readiness.go +++ b/internal/watch/stream_readiness.go @@ -157,9 +157,9 @@ func (m *Manager) StreamSummaryForGitTarget(gitDest types.ResourceReference) Str // A rule that is not compiled expects no streams, which is correct: the gate refused it, or the // store has not been seeded yet. func (m *Manager) StreamSummaryForWatchRule(rule configv1alpha3.WatchRule) StreamSummary { - // The GitTarget is in the rule's OWN namespace (targetRef is a meta.LocalObjectReference), but the + // The GitTarget is in the rule's OWN namespace (gitTargetRef is a meta.LocalObjectReference), but the // streams are keyed on the namespaces being WATCHED. - gitDest := types.NewResourceReference(rule.Spec.TargetRef.Name, rule.Namespace) + gitDest := types.NewResourceReference(rule.Spec.GitTargetRef.Name, rule.Namespace) if m.RuleStore == nil { return streamSummaryForTypes(nil, nil, nil) } @@ -191,7 +191,7 @@ func (m *Manager) StreamSummaryForWatchRule(rule configv1alpha3.WatchRule) Strea // against the source cluster its GitTarget mirrors from. It always matches cluster-scoped records, // because a ClusterWatchRule is cluster-scope-only. func (m *Manager) StreamSummaryForClusterWatchRule(rule configv1alpha3.ClusterWatchRule) StreamSummary { - gitDest := types.NewResourceReference(rule.Spec.TargetRef.Name, rule.Spec.TargetRef.Namespace) + gitDest := types.NewResourceReference(rule.Spec.GitTargetRef.Name, rule.Spec.GitTargetRef.Namespace) reg := m.registryForGitTarget(gitDest) m.refreshClusterTypeRegistry(m.cluster(m.clusterIDForGitTarget(gitDest))) records := reg.Followable() diff --git a/internal/watch/watched_type_helpers_test.go b/internal/watch/watched_type_helpers_test.go index 48a39df3..c44ed8e0 100644 --- a/internal/watch/watched_type_helpers_test.go +++ b/internal/watch/watched_type_helpers_test.go @@ -22,7 +22,7 @@ func clusterRuleForResource(name, resource string) configv1alpha3.ClusterWatchRu return configv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: name}, Spec: configv1alpha3.ClusterWatchRuleSpec{ - TargetRef: meta.NamespacedObjectReference{ + GitTargetRef: meta.NamespacedObjectReference{ Name: "test-target", Namespace: "test-ns", }, @@ -40,7 +40,7 @@ func watchRuleForTarget(name, gitTargetName, namespace string) configv1alpha3.Wa return configv1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, Spec: configv1alpha3.WatchRuleSpec{ - TargetRef: meta.LocalObjectReference{Name: gitTargetName}, + GitTargetRef: meta.LocalObjectReference{Name: gitTargetName}, Rules: []configv1alpha3.ResourceRule{{ APIGroups: []string{""}, APIVersions: []string{"v1"}, diff --git a/internal/watch/watched_type_resolver.go b/internal/watch/watched_type_resolver.go index 4a722e9d..8b771d27 100644 --- a/internal/watch/watched_type_resolver.go +++ b/internal/watch/watched_type_resolver.go @@ -291,9 +291,9 @@ func (m *Manager) collectWatchRuleSelections( get func(types.ResourceReference, string, string, string, string) *targetSelections, ) { for _, rule := range m.RuleStore.SnapshotWatchRules() { - targetRef := types.NewResourceReference(rule.GitTargetRef, rule.GitTargetNamespace) - records := recordsFor(m.clusterIDForGitTarget(targetRef)) - ts := get(targetRef, rule.GitProviderNamespace, rule.GitProviderRef, rule.Branch, rule.Path) + gitTargetRef := types.NewResourceReference(rule.GitTargetRef, rule.GitTargetNamespace) + records := recordsFor(m.clusterIDForGitTarget(gitTargetRef)) + ts := get(gitTargetRef, rule.GitProviderNamespace, rule.GitProviderRef, rule.Branch, rule.Path) for _, rr := range rule.ResourceRules { matched := matchFollowableRecords( records, rr.APIGroups, rr.APIVersions, rr.Resources, configv1alpha3.ResourceScopeNamespaced) @@ -333,9 +333,9 @@ func (m *Manager) collectClusterWatchRuleSelections( get func(types.ResourceReference, string, string, string, string) *targetSelections, ) { for _, rule := range m.RuleStore.SnapshotClusterWatchRules() { - targetRef := types.NewResourceReference(rule.GitTargetRef, rule.GitTargetNamespace) - records := recordsFor(m.clusterIDForGitTarget(targetRef)) - ts := get(targetRef, rule.GitProviderNamespace, rule.GitProviderRef, rule.Branch, rule.Path) + gitTargetRef := types.NewResourceReference(rule.GitTargetRef, rule.GitTargetNamespace) + records := recordsFor(m.clusterIDForGitTarget(gitTargetRef)) + ts := get(gitTargetRef, rule.GitProviderNamespace, rule.GitProviderRef, rule.Branch, rule.Path) for _, rr := range rule.Rules { matched := matchFollowableRecords( records, rr.APIGroups, rr.APIVersions, rr.Resources, configv1alpha3.ResourceScopeCluster) diff --git a/internal/watch/watched_type_resolver_test.go b/internal/watch/watched_type_resolver_test.go index cb8562c2..03f129b5 100644 --- a/internal/watch/watched_type_resolver_test.go +++ b/internal/watch/watched_type_resolver_test.go @@ -164,7 +164,7 @@ func TestRefreshWatchedTypeTables_ExcludesAmbiguousGVK(t *testing.T) { configv1alpha3.ClusterWatchRule{ ObjectMeta: metav1.ObjectMeta{Name: "rule-widgets"}, Spec: configv1alpha3.ClusterWatchRuleSpec{ - TargetRef: meta.NamespacedObjectReference{Name: "test-target", Namespace: "test-ns"}, + GitTargetRef: meta.NamespacedObjectReference{Name: "test-target", Namespace: "test-ns"}, Rules: []configv1alpha3.ClusterResourceRule{{ APIGroups: []string{"example.com"}, APIVersions: []string{"v1"}, diff --git a/internal/watch/watchrule_compile.go b/internal/watch/watchrule_compile.go index 9f323d87..d5dec5fa 100644 --- a/internal/watch/watchrule_compile.go +++ b/internal/watch/watchrule_compile.go @@ -93,7 +93,7 @@ func itemNamespaces(resolved authz.ResolvedSourceScope) [][]string { // Two refusals, both terminal, in this order: // // 1. the referenced GitTarget's namespace must be admitted by that target's ClusterProvider — a -// ClusterWatchRule's targetRef carries a namespace, so it can name a target in ANY namespace +// ClusterWatchRule's gitTargetRef carries a namespace, so it can name a target in ANY namespace // and widen that target's mirror scope cluster-wide; // 2. the rule must not carry a stored scope other than "Cluster". Admission rejects the value on // write, but a pre-release object keeps it in etcd, and resolving it as if it had asked for diff --git a/internal/webhook/watchrule_source_namespace_admission.go b/internal/webhook/watchrule_source_namespace_admission.go index c9dbde46..3b78f2f4 100644 --- a/internal/webhook/watchrule_source_namespace_admission.go +++ b/internal/webhook/watchrule_source_namespace_admission.go @@ -58,7 +58,7 @@ func (h *ValidateOperatorTypesHandler) validateWatchRuleSourceNamespaces( } var target v1alpha3.GitTarget - targetKey := k8stypes.NamespacedName{Namespace: rule.Namespace, Name: rule.Spec.TargetRef.Name} + targetKey := k8stypes.NamespacedName{Namespace: rule.Namespace, Name: rule.Spec.GitTargetRef.Name} if err := h.Client.Get(ctx, targetKey, &target); err != nil { if !apierrors.IsNotFound(err) { log.Error(err, "could not read the GitTarget this rule names", "gitTarget", targetKey) @@ -124,7 +124,7 @@ func (h *ValidateOperatorTypesHandler) admittedSourceNamespaces( admitted := map[string]struct{}{target.Namespace: {}} for i := range rules.Items { sibling := &rules.Items[i] - if sibling.Name == excludeRule || sibling.Spec.TargetRef.Name != target.Name { + if sibling.Name == excludeRule || sibling.Spec.GitTargetRef.Name != target.Name { continue } for _, item := range sibling.Spec.Rules { diff --git a/internal/webhook/watchrule_source_namespace_admission_test.go b/internal/webhook/watchrule_source_namespace_admission_test.go index 07a5baea..b0b83f69 100644 --- a/internal/webhook/watchrule_source_namespace_admission_test.go +++ b/internal/webhook/watchrule_source_namespace_admission_test.go @@ -38,7 +38,7 @@ func watchRuleFor(name, sourceNamespace string) *v1alpha3.WatchRule { return &v1alpha3.WatchRule{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "shop"}, Spec: v1alpha3.WatchRuleSpec{ - TargetRef: meta.LocalObjectReference{Name: "checkout-artifact"}, + GitTargetRef: meta.LocalObjectReference{Name: "checkout-artifact"}, Rules: []v1alpha3.ResourceRule{{ Resources: []string{"configmaps"}, SourceNamespace: sourceNamespace, @@ -173,7 +173,7 @@ func TestWatchRuleAdmission_UnevaluatableRequestsAreAllowed(t *testing.T) { func TestWatchRuleAdmission_OtherTargetsRulesAreNotCounted(t *testing.T) { no := false elsewhere := watchRuleFor("elsewhere", "billing") - elsewhere.Spec.TargetRef.Name = "other-artifact" + elsewhere.Spec.GitTargetRef.Name = "other-artifact" handler := watchRuleHandler(t, namespaceFreeTarget(&no), elsewhere) response := handler.Handle(t.Context(), watchRuleReview(t, watchRuleFor("content", ""), admissionv1.Create)) diff --git a/test/e2e/audit_route_attribution_e2e_test.go b/test/e2e/audit_route_attribution_e2e_test.go index 3a58a692..b952ad68 100644 --- a/test/e2e/audit_route_attribution_e2e_test.go +++ b/test/e2e/audit_route_attribution_e2e_test.go @@ -202,7 +202,7 @@ metadata: name: %s namespace: %s spec: - providerRef: + gitProviderRef: name: %s branch: main path: %s @@ -223,7 +223,7 @@ metadata: name: %s namespace: %s spec: - targetRef: + gitTargetRef: name: %s rules: - resources: ["configmaps"] diff --git a/test/e2e/commit_request_e2e_test.go b/test/e2e/commit_request_e2e_test.go index 6b134522..ccc543c4 100644 --- a/test/e2e/commit_request_e2e_test.go +++ b/test/e2e/commit_request_e2e_test.go @@ -413,7 +413,7 @@ metadata: name: %s namespace: %s spec: - targetRef: + gitTargetRef: name: %s message: %q closeDelaySeconds: %d @@ -460,7 +460,7 @@ metadata: generateName: %s namespace: %s spec: - targetRef: + gitTargetRef: name: %s message: %q closeDelaySeconds: 8 @@ -485,7 +485,7 @@ metadata: name: %s namespace: %s spec: - targetRef: + gitTargetRef: name: %s message: %q `, name, namespace, gitTargetName, message) diff --git a/test/e2e/deployment_scale_subresource_e2e_test.go b/test/e2e/deployment_scale_subresource_e2e_test.go index 8d77d5bb..d81a88bc 100644 --- a/test/e2e/deployment_scale_subresource_e2e_test.go +++ b/test/e2e/deployment_scale_subresource_e2e_test.go @@ -74,7 +74,7 @@ metadata: name: %s namespace: %s spec: - targetRef: + gitTargetRef: name: %s rules: - apiGroups: ["apps"] diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 7b824ec0..3e17f09b 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -276,13 +276,13 @@ func gitTargetConditionDetail(obj unstructured.Unstructured, ns string) string { if kind != "watchrule" && kind != "clusterwatchrule" { return "" } - targetName, found, _ := unstructured.NestedString(obj.Object, "spec", "targetRef", "name") + targetName, found, _ := unstructured.NestedString(obj.Object, "spec", "gitTargetRef", "name") if !found || targetName == "" { return "" } targetNS := ns if kind == "clusterwatchrule" { - if v, ok, _ := unstructured.NestedString(obj.Object, "spec", "targetRef", "namespace"); ok { + if v, ok, _ := unstructured.NestedString(obj.Object, "spec", "gitTargetRef", "namespace"); ok { targetNS = v } } diff --git a/test/e2e/helpers.go b/test/e2e/helpers.go index e64006a4..2b63b81c 100644 --- a/test/e2e/helpers.go +++ b/test/e2e/helpers.go @@ -394,7 +394,7 @@ func expectServiceRoutesToPod(g Gomega, serviceName, expectedPod string) { "endpoints", serviceName, "-o", - "jsonpath={.subsets[*].addresses[*].targetRef.name}", + "jsonpath={.subsets[*].addresses[*].gitTargetRef.name}", ) g.Expect(err).NotTo(HaveOccurred(), "Failed to get %s endpoints", serviceName) diff --git a/test/e2e/prune_mode_e2e_test.go b/test/e2e/prune_mode_e2e_test.go index b865ccc8..d4ce08ae 100644 --- a/test/e2e/prune_mode_e2e_test.go +++ b/test/e2e/prune_mode_e2e_test.go @@ -354,7 +354,7 @@ metadata: name: %s namespace: %s spec: - providerRef: + gitProviderRef: name: %s branch: main path: %s diff --git a/test/e2e/render_fidelity_e2e_test.go b/test/e2e/render_fidelity_e2e_test.go index 0e71c8fb..f315e445 100644 --- a/test/e2e/render_fidelity_e2e_test.go +++ b/test/e2e/render_fidelity_e2e_test.go @@ -219,7 +219,7 @@ metadata: name: %s namespace: %s spec: - targetRef: + gitTargetRef: name: %s rules: - apiGroups: ["apps"] 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 9b484213..97607913 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 @@ -15,7 +15,7 @@ kind: GitTarget metadata: name: demo-podinfos spec: - providerRef: + gitProviderRef: name: demo branch: nl-stuff path: podinfos @@ -27,7 +27,7 @@ kind: WatchRule metadata: name: demo-podinfos spec: - targetRef: + gitTargetRef: name: demo-podinfos rules: - operations: [CREATE, UPDATE, DELETE] 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 a268ef61..81b18d29 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 @@ -15,7 +15,7 @@ kind: GitTarget metadata: name: demo-coffeeconfig spec: - providerRef: + gitProviderRef: name: demo-coffeeconfig branch: demo-test path: voter-coffee @@ -27,7 +27,7 @@ kind: WatchRule metadata: name: demo-coffeeconfig spec: - targetRef: + gitTargetRef: name: demo-coffeeconfig rules: - operations: [CREATE, UPDATE, DELETE] diff --git a/test/e2e/source_cluster_e2e_test.go b/test/e2e/source_cluster_e2e_test.go index 813325c9..f1470cb7 100644 --- a/test/e2e/source_cluster_e2e_test.go +++ b/test/e2e/source_cluster_e2e_test.go @@ -203,7 +203,7 @@ metadata: name: %s namespace: %s spec: - providerRef: + gitProviderRef: name: %s branch: main path: %s @@ -357,7 +357,7 @@ var _ = Describe("Manager source cluster / ClusterProvider attribution", Label(" kind: GitTarget metadata: {name: %s, namespace: %s} spec: - providerRef: {name: %s} + gitProviderRef: {name: %s} branch: main path: clusters/local commit: @@ -418,7 +418,7 @@ spec: kind: WatchRule metadata: {name: %s-rule, namespace: %s} spec: - targetRef: {name: %s} + gitTargetRef: {name: %s} rules: - resources: ["configmaps"] `, ws, testNs, target) @@ -484,7 +484,7 @@ spec: kind: WatchRule metadata: {name: %s-rule, namespace: %s} spec: - targetRef: {name: %s} + gitTargetRef: {name: %s} rules: - resources: ["configmaps"] `, c.ws, testNs, target) diff --git a/test/e2e/source_namespace_e2e_test.go b/test/e2e/source_namespace_e2e_test.go index cc6b7573..24993099 100644 --- a/test/e2e/source_namespace_e2e_test.go +++ b/test/e2e/source_namespace_e2e_test.go @@ -304,7 +304,7 @@ metadata: name: %s namespace: %s spec: - targetRef: + gitTargetRef: name: %s rules: - resources: ["configmaps"] diff --git a/test/e2e/suspend_e2e_test.go b/test/e2e/suspend_e2e_test.go index 861ee4db..17cc79ca 100644 --- a/test/e2e/suspend_e2e_test.go +++ b/test/e2e/suspend_e2e_test.go @@ -213,7 +213,7 @@ metadata: name: %s namespace: %s spec: - providerRef: + gitProviderRef: name: %s branch: main path: %s diff --git a/test/e2e/templates/aggregated-api/watchrule-flunder.tmpl b/test/e2e/templates/aggregated-api/watchrule-flunder.tmpl index 808a4224..741ec20b 100644 --- a/test/e2e/templates/aggregated-api/watchrule-flunder.tmpl +++ b/test/e2e/templates/aggregated-api/watchrule-flunder.tmpl @@ -4,7 +4,7 @@ metadata: name: {{ .Name }} namespace: {{ .Namespace }} spec: - targetRef: + gitTargetRef: name: {{ .GitTargetName }} rules: - apiGroups: ["wardle.example.com"] diff --git a/test/e2e/templates/bi-directional/watchrule-secret.tmpl b/test/e2e/templates/bi-directional/watchrule-secret.tmpl index a20e244a..785008f4 100644 --- a/test/e2e/templates/bi-directional/watchrule-secret.tmpl +++ b/test/e2e/templates/bi-directional/watchrule-secret.tmpl @@ -4,7 +4,7 @@ metadata: name: {{ .Name }} namespace: {{ .Namespace }} spec: - targetRef: + gitTargetRef: name: {{ .DestinationName }} rules: - resources: ["secrets"] diff --git a/test/e2e/templates/demo/clusterwatchrule-demo.tmpl b/test/e2e/templates/demo/clusterwatchrule-demo.tmpl index efcaa467..3029f276 100644 --- a/test/e2e/templates/demo/clusterwatchrule-demo.tmpl +++ b/test/e2e/templates/demo/clusterwatchrule-demo.tmpl @@ -3,7 +3,7 @@ kind: ClusterWatchRule metadata: name: {{ .Name }} spec: - targetRef: + gitTargetRef: name: {{ .DestinationName }} namespace: {{ .Namespace }} rules: diff --git a/test/e2e/templates/demo/watchrule-all.tmpl b/test/e2e/templates/demo/watchrule-all.tmpl index b2ed1f29..1894fcde 100644 --- a/test/e2e/templates/demo/watchrule-all.tmpl +++ b/test/e2e/templates/demo/watchrule-all.tmpl @@ -4,7 +4,7 @@ metadata: name: {{ .Name }} namespace: {{ .Namespace }} spec: - targetRef: + gitTargetRef: name: {{ .DestinationName }} rules: - operations: [CREATE, UPDATE, DELETE] diff --git a/test/e2e/templates/gittarget.tmpl b/test/e2e/templates/gittarget.tmpl index fda63e1e..ca73dfa2 100644 --- a/test/e2e/templates/gittarget.tmpl +++ b/test/e2e/templates/gittarget.tmpl @@ -4,7 +4,7 @@ metadata: name: {{ .Name }} namespace: {{ .Namespace }} spec: - providerRef: + gitProviderRef: name: {{ .ProviderName }} branch: {{ .Branch }} path: {{ .Path }} diff --git a/test/e2e/templates/manager/clusterwatchrule-crd.tmpl b/test/e2e/templates/manager/clusterwatchrule-crd.tmpl index c857c3ee..6514435c 100644 --- a/test/e2e/templates/manager/clusterwatchrule-crd.tmpl +++ b/test/e2e/templates/manager/clusterwatchrule-crd.tmpl @@ -3,7 +3,7 @@ kind: ClusterWatchRule metadata: name: {{ .Name }} spec: - targetRef: + gitTargetRef: name: {{ .DestinationName }} namespace: {{ .Namespace }} rules: diff --git a/test/e2e/templates/manager/gittarget-prune.tmpl b/test/e2e/templates/manager/gittarget-prune.tmpl index 5e48115c..a8466e1a 100644 --- a/test/e2e/templates/manager/gittarget-prune.tmpl +++ b/test/e2e/templates/manager/gittarget-prune.tmpl @@ -4,7 +4,7 @@ metadata: name: {{ .Name }} namespace: {{ .Namespace }} spec: - providerRef: + gitProviderRef: name: {{ .ProviderName }} branch: {{ .Branch }} path: {{ .Path }} diff --git a/test/e2e/templates/manager/watchrule-configmap.tmpl b/test/e2e/templates/manager/watchrule-configmap.tmpl index 1a506bbd..cf7a9d9a 100644 --- a/test/e2e/templates/manager/watchrule-configmap.tmpl +++ b/test/e2e/templates/manager/watchrule-configmap.tmpl @@ -4,7 +4,7 @@ metadata: name: {{.Name}} namespace: {{.Namespace}} spec: - targetRef: + gitTargetRef: name: {{.DestinationName}} rules: - resources: ["configmaps"] diff --git a/test/e2e/templates/manager/watchrule-helmrelease.tmpl b/test/e2e/templates/manager/watchrule-helmrelease.tmpl index c9fdf343..c182a1df 100644 --- a/test/e2e/templates/manager/watchrule-helmrelease.tmpl +++ b/test/e2e/templates/manager/watchrule-helmrelease.tmpl @@ -4,7 +4,7 @@ metadata: name: {{ .Name }} namespace: {{ .Namespace }} spec: - targetRef: + gitTargetRef: name: {{ .DestinationName }} rules: - apiGroups: ["helm.toolkit.fluxcd.io"] diff --git a/test/e2e/templates/manager/watchrule-resources.tmpl b/test/e2e/templates/manager/watchrule-resources.tmpl index 53fa0dc6..cc780da0 100644 --- a/test/e2e/templates/manager/watchrule-resources.tmpl +++ b/test/e2e/templates/manager/watchrule-resources.tmpl @@ -4,7 +4,7 @@ metadata: name: {{.Name}} namespace: {{.Namespace}} spec: - targetRef: + gitTargetRef: name: {{.DestinationName}} rules: - resources: [{{.Resources}}] diff --git a/test/e2e/templates/restart/watchrule-wildcard.tmpl b/test/e2e/templates/restart/watchrule-wildcard.tmpl index 15121228..68adee98 100644 --- a/test/e2e/templates/restart/watchrule-wildcard.tmpl +++ b/test/e2e/templates/restart/watchrule-wildcard.tmpl @@ -4,7 +4,7 @@ metadata: name: {{ .Name }} namespace: {{ .Namespace }} spec: - targetRef: + gitTargetRef: name: {{ .DestinationName }} rules: # apiVersions: ["*"] is the documented "match all versions" form diff --git a/test/e2e/templates/watchrule-crd.tmpl b/test/e2e/templates/watchrule-crd.tmpl index 74e69a1c..4d6ae726 100644 --- a/test/e2e/templates/watchrule-crd.tmpl +++ b/test/e2e/templates/watchrule-crd.tmpl @@ -4,7 +4,7 @@ metadata: name: {{ .Name }} namespace: {{ .Namespace }} spec: - targetRef: + gitTargetRef: name: {{ .DestinationName }} rules: - apiGroups: ["{{ .Group }}"] diff --git a/test/e2e/templates/watchrule-secret.tmpl b/test/e2e/templates/watchrule-secret.tmpl index 2bb06fa3..4b64667b 100644 --- a/test/e2e/templates/watchrule-secret.tmpl +++ b/test/e2e/templates/watchrule-secret.tmpl @@ -4,7 +4,7 @@ metadata: name: {{.Name}} namespace: {{.Namespace}} spec: - targetRef: + gitTargetRef: name: {{.DestinationName}} rules: - resources: ["secrets"] diff --git a/test/e2e/templates/watchrule.tmpl b/test/e2e/templates/watchrule.tmpl index ec95e687..9c6a7c52 100644 --- a/test/e2e/templates/watchrule.tmpl +++ b/test/e2e/templates/watchrule.tmpl @@ -4,7 +4,7 @@ metadata: name: {{.Name}} namespace: {{.Namespace}} spec: - targetRef: + gitTargetRef: name: {{.DestinationName}} rules: - resources: ["deployments", "services", "configmaps", "secrets"] diff --git a/test/e2e/unsupported_folder_e2e_test.go b/test/e2e/unsupported_folder_e2e_test.go index 4c9a7b3f..fa4e3e8d 100644 --- a/test/e2e/unsupported_folder_e2e_test.go +++ b/test/e2e/unsupported_folder_e2e_test.go @@ -173,7 +173,7 @@ kind: ClusterWatchRule metadata: name: %s spec: - targetRef: + gitTargetRef: name: %s namespace: %s rules: diff --git a/test/e2e/watchrule_configmap_secret_e2e_test.go b/test/e2e/watchrule_configmap_secret_e2e_test.go index 37f84697..1ea7de7e 100644 --- a/test/e2e/watchrule_configmap_secret_e2e_test.go +++ b/test/e2e/watchrule_configmap_secret_e2e_test.go @@ -138,7 +138,7 @@ metadata: name: %s namespace: %s spec: - targetRef: + gitTargetRef: name: %s rules: - apiGroups: ["*"] diff --git a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml index 5a94c5bf..e9040021 100644 --- a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml @@ -4,7 +4,7 @@ metadata: name: prod-mirror namespace: homelab-config spec: - providerRef: + gitProviderRef: name: mirror-repository clusterProviderRef: name: prod diff --git a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/watchrule.yaml b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/watchrule.yaml index cb208585..06262c7b 100644 --- a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/watchrule.yaml +++ b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/watchrule.yaml @@ -4,7 +4,7 @@ metadata: name: prod-mirror-content namespace: homelab-config spec: - targetRef: + gitTargetRef: name: prod-mirror rules: - apiGroups: [""] diff --git a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml index 8907a53b..447612a4 100644 --- a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml +++ b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml @@ -12,7 +12,7 @@ metadata: name: checkout-artifact namespace: shop spec: - providerRef: + gitProviderRef: name: artifacts-repository clusterProviderRef: name: apps diff --git a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml index 1e8a61a3..5bfded53 100644 --- a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml @@ -4,7 +4,7 @@ metadata: name: checkout-artifact namespace: shop spec: - providerRef: + gitProviderRef: name: artifacts-repository branch: main path: apps/checkout diff --git a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/watchrule-second-namespace.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/watchrule-second-namespace.yaml index 226dd1db..f5d58a7b 100644 --- a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/watchrule-second-namespace.yaml +++ b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/watchrule-second-namespace.yaml @@ -6,7 +6,7 @@ metadata: name: checkout-content-billing namespace: shop spec: - targetRef: + gitTargetRef: name: checkout-artifact rules: - apiGroups: [""] diff --git a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/watchrule.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/watchrule.yaml index 4596a2c1..b925c7fe 100644 --- a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/watchrule.yaml +++ b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/watchrule.yaml @@ -4,7 +4,7 @@ metadata: name: checkout-content namespace: shop spec: - targetRef: + gitTargetRef: name: checkout-artifact rules: - apiGroups: [""] diff --git a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml index f68dfc9d..e4ce394b 100644 --- a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml @@ -4,7 +4,7 @@ metadata: name: home-cluster-state namespace: homelab-config spec: - providerRef: + gitProviderRef: name: mirror-repository clusterProviderRef: name: home diff --git a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/watchrule.yaml b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/watchrule.yaml index 34a8d52b..9f4a4831 100644 --- a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/watchrule.yaml +++ b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/watchrule.yaml @@ -4,7 +4,7 @@ metadata: name: home-workloads namespace: homelab-config spec: - targetRef: + gitTargetRef: name: home-cluster-state rules: - apiGroups: [""] diff --git a/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml index 94acd828..c537c6c7 100644 --- a/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml @@ -4,7 +4,7 @@ metadata: name: checkout-artifact namespace: shop spec: - providerRef: + gitProviderRef: name: artifacts-repository branch: main path: apps/checkout diff --git a/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/watchrule.yaml b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/watchrule.yaml index c2661ce6..ccfd99bb 100644 --- a/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/watchrule.yaml +++ b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/watchrule.yaml @@ -4,7 +4,7 @@ metadata: name: checkout-content namespace: shop spec: - targetRef: + gitTargetRef: name: checkout-artifact rules: - apiGroups: [""] diff --git a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml index 35d3c02e..72c93e8a 100644 --- a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml +++ b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml @@ -6,7 +6,7 @@ metadata: name: checkout namespace: shop spec: - providerRef: + gitProviderRef: name: app-repository branch: main path: apps/checkout diff --git a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml index c90be2df..91ea47a9 100644 --- a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml @@ -4,7 +4,7 @@ metadata: name: checkout namespace: shop spec: - providerRef: + gitProviderRef: name: app-repository branch: main path: apps/checkout diff --git a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/watchrule.yaml b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/watchrule.yaml index f7580155..6c42eb4f 100644 --- a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/watchrule.yaml +++ b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/watchrule.yaml @@ -4,7 +4,7 @@ metadata: name: checkout-content namespace: shop spec: - targetRef: + gitTargetRef: name: checkout rules: - apiGroups: [""] diff --git a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml index 3e7fd164..7763f965 100644 --- a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml +++ b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml @@ -13,7 +13,7 @@ metadata: name: checkout-app namespace: shop-prod spec: - providerRef: + gitProviderRef: name: app-repository branch: main path: apps/checkout diff --git a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml index df2b9f36..879559c7 100644 --- a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml +++ b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml @@ -4,7 +4,7 @@ metadata: name: checkout-prod namespace: shop-prod spec: - providerRef: + gitProviderRef: name: app-repository branch: main # A LEAF overlay, not the app root. One target = one overlay = one environment = diff --git a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml index 1cb16c80..2e5ad526 100644 --- a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml +++ b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml @@ -6,7 +6,7 @@ metadata: name: checkout-test namespace: shop-test spec: - providerRef: + gitProviderRef: name: app-repository branch: main path: apps/checkout/overlays/test diff --git a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/watchrule-prod.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/watchrule-prod.yaml index 3d4295f3..f0ec7979 100644 --- a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/watchrule-prod.yaml +++ b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/watchrule-prod.yaml @@ -4,7 +4,7 @@ metadata: name: checkout-prod-content namespace: shop-prod spec: - targetRef: + gitTargetRef: name: checkout-prod rules: - apiGroups: [""] diff --git a/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml index e13b21c7..f40558a7 100644 --- a/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml +++ b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml @@ -4,7 +4,7 @@ metadata: name: checkout-prod namespace: shop-prod spec: - providerRef: + gitProviderRef: name: app-repository branch: main # The leaf again, and here it is not a preference. envs/prod and envs/test both diff --git a/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/watchrule-prod.yaml b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/watchrule-prod.yaml index 3d4295f3..f0ec7979 100644 --- a/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/watchrule-prod.yaml +++ b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/watchrule-prod.yaml @@ -4,7 +4,7 @@ metadata: name: checkout-prod-content namespace: shop-prod spec: - targetRef: + gitTargetRef: name: checkout-prod rules: - apiGroups: [""] diff --git a/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml index 9e8fb736..7fa9bdb3 100644 --- a/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml +++ b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml @@ -4,7 +4,7 @@ metadata: name: checkout-prod namespace: shop-prod spec: - providerRef: + gitProviderRef: name: app-repository branch: main # The leaf overlay. The base one directory up is READ to render this folder and is diff --git a/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/watchrule-prod.yaml b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/watchrule-prod.yaml index 97957363..0edf1ff6 100644 --- a/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/watchrule-prod.yaml +++ b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/watchrule-prod.yaml @@ -4,7 +4,7 @@ metadata: name: checkout-prod-workloads namespace: shop-prod spec: - targetRef: + gitTargetRef: name: checkout-prod rules: - apiGroups: ["apps"] diff --git a/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml index 3c2b03cb..66618705 100644 --- a/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml @@ -4,7 +4,7 @@ metadata: name: argocd-applications namespace: argocd spec: - providerRef: + gitProviderRef: name: homelab-repository branch: main path: bootstrap/argocd-applications diff --git a/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/watchrule.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/watchrule.yaml index e2cdda4c..15b3d7e3 100644 --- a/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/watchrule.yaml +++ b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/watchrule.yaml @@ -4,7 +4,7 @@ metadata: name: argocd-applications namespace: argocd spec: - targetRef: + gitTargetRef: name: argocd-applications rules: - apiGroups: ["argoproj.io"] diff --git a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml index 103d689d..164f3aa6 100644 --- a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml +++ b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml @@ -8,7 +8,7 @@ metadata: name: flux-media namespace: flux-system spec: - providerRef: + gitProviderRef: name: homelab-repository branch: main path: apps/home/media diff --git a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml index c0e0eea3..9c0b88ce 100644 --- a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml @@ -4,7 +4,7 @@ metadata: name: flux-sources namespace: flux-system spec: - providerRef: + gitProviderRef: name: homelab-repository branch: main # Not clusters/home/flux-system. That directory belongs to `flux bootstrap`, and a diff --git a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/watchrule-media.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/watchrule-media.yaml index e236aa0e..df57c5d5 100644 --- a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/watchrule-media.yaml +++ b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/watchrule-media.yaml @@ -4,7 +4,7 @@ metadata: name: flux-media namespace: flux-system spec: - targetRef: + gitTargetRef: name: flux-media rules: - apiGroups: ["helm.toolkit.fluxcd.io"] diff --git a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/watchrule.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/watchrule.yaml index 5f04fa39..d9cc9dfa 100644 --- a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/watchrule.yaml +++ b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/watchrule.yaml @@ -4,7 +4,7 @@ metadata: name: flux-sources namespace: flux-system spec: - targetRef: + gitTargetRef: name: flux-sources rules: - apiGroups: ["source.toolkit.fluxcd.io"] diff --git a/test/playground/config/gittarget.yaml b/test/playground/config/gittarget.yaml index 045c43d7..18f170d0 100644 --- a/test/playground/config/gittarget.yaml +++ b/test/playground/config/gittarget.yaml @@ -4,7 +4,7 @@ metadata: name: playground-target namespace: tilt-playground spec: - providerRef: + gitProviderRef: name: playground-provider branch: main # Keep playground writes under a folder. Use "." only when testing repo-root ownership. diff --git a/test/playground/config/watchrule.yaml b/test/playground/config/watchrule.yaml index f1cfd681..4ce06335 100644 --- a/test/playground/config/watchrule.yaml +++ b/test/playground/config/watchrule.yaml @@ -4,7 +4,7 @@ metadata: name: playground-watchrule namespace: tilt-playground spec: - targetRef: + gitTargetRef: name: playground-target rules: - resources: ["deployments", "services", "configmaps", "secrets"] From fe3ea7a1befc4cbf97e7d9b3ec091172e1e1df55 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Thu, 3 Sep 2026 11:46:37 +0000 Subject: [PATCH 7/9] fix(test): stop renaming Kubernetes' own targetRef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rename swept a kubectl jsonpath and turned core/v1 Endpoints' `addresses[].targetRef` into `gitTargetRef`, so the selector matched nothing, the helper saw zero endpoint addresses, and "should expose the controller service" failed on an empty list rather than on anything real. It reproduced three times before I read it properly — twice locally and once on CI's full-manager leg — which is what a mechanical rename across 140 files deserves. The rest of the diff was re-audited for the same mistake against every Kubernetes field that shares these names; this jsonpath was the only one. Co-Authored-By: Claude Opus 5 --- test/e2e/helpers.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/e2e/helpers.go b/test/e2e/helpers.go index 2b63b81c..4ca8c1ac 100644 --- a/test/e2e/helpers.go +++ b/test/e2e/helpers.go @@ -394,7 +394,9 @@ func expectServiceRoutesToPod(g Gomega, serviceName, expectedPod string) { "endpoints", serviceName, "-o", - "jsonpath={.subsets[*].addresses[*].gitTargetRef.name}", + // core/v1 Endpoints' OWN targetRef, not one of ours: this names the Pod behind an + // endpoint address. It is spelled targetRef because Kubernetes spells it that way. + "jsonpath={.subsets[*].addresses[*].targetRef.name}", ) g.Expect(err).NotTo(HaveOccurred(), "Failed to get %s endpoints", serviceName) From c57606be0f67279a2cf6aedf45f9c94baa6acd53 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Thu, 3 Sep 2026 11:56:03 +0000 Subject: [PATCH 8/9] fix(commitrequest): fail a pruned gitTargetRef instead of retrying it forever MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A CommitRequest still pending when the rename lands serves an EMPTY gitTargetRef like every other object, and unlike every other object it cannot be repaired: its spec is wholly immutable, so no apply can put the name back. The reconciler passed that empty name to the router, which fetched `GitTarget ""`, failed, and retried on an object that will never resolve. It is now terminal — Stalled=True, reason GitTargetRefPruned — with a message saying to delete it, which is the only repair there is. An empty name is otherwise unreachable, because admission refuses one on every path, so the condition says exactly what happened rather than reporting a transient "get GitTarget" error for a permanent state. UPGRADING said CommitRequest needed no migration, on the reasoning that one-shot objects are never updated. That was the wrong half of the truth: they are never updated, which is precisely why this one cannot be fixed. The entry now asks for a drain before upgrading, with a query for pending requests and one for the stuck ones afterwards, and says plainly that nothing is lost from Git either way — an unfinalized request only means its writes were not flushed early. Also fixes a sample that pointed at a GitTarget no sample defines (`quickstart-gittarget`, where everything else uses `example-target`, which is also the chart's quickstart name), and four current-behaviour docs still spelling `spec.providerRef` or the `declared` metric value — both missed by the rename sweep, the first because the pattern excluded a preceding dot. The migration envtest now stores its object under a schema that really spells the field the old way, and asserts the old value is gone on READ before attempting the migrating apply. It previously created an object without the new field, which proved the guard worked but not the loss it exists for. Co-Authored-By: Claude Opus 5 --- .coverage-baseline | 2 +- config/samples/README.md | 8 +- config/samples/commitrequest.yaml | 2 +- docs/INDEX.md | 2 +- docs/UPGRADING.md | 54 +++++++-- docs/architecture.md | 2 +- docs/configuration.md | 6 +- ...cement-visibility-and-declared-defaults.md | 9 +- docs/layout/model.md | 6 +- docs/layout/new-file-placement-rules.md | 3 +- .../controller/commitrequest_controller.go | 65 +++++++++-- .../commitrequest_controller_unit_test.go | 47 ++++++++ internal/controller/commitrequest_finalize.go | 1 + .../controller/renamed_required_field_test.go | 108 ++++++++++++++---- 14 files changed, 258 insertions(+), 57 deletions(-) diff --git a/.coverage-baseline b/.coverage-baseline index 1ed6f92d..cd27a8f4 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -77.1 +77.2 diff --git a/config/samples/README.md b/config/samples/README.md index 6c5238f6..66a58071 100644 --- a/config/samples/README.md +++ b/config/samples/README.md @@ -5,6 +5,12 @@ These samples are quick starting points for common GitOps Reverser setups. - `quickstart-gitprovider.yaml`: Minimal `GitProvider` with credentials. - `quickstart-gittarget.yaml`: Minimal `GitTarget` using a non-root `spec.path` and SOPS encryption auto-generation. - `quickstart-watchrule.yaml`: Minimal `WatchRule` for ConfigMaps. +- `clusterprovider.yaml`: The conventional `default` provider (in-cluster) and a remote one, showing + `accessFrom` and `allowAnySourceNamespace`. - `clusterwatchrule.yaml`: Minimal `ClusterWatchRule` for cluster-scoped resources. - `commitrequest.yaml`: Minimal `CommitRequest` — an on-demand "save" signal that finalizes a - `GitTarget`'s open commit window. + `GitTarget`'s open commit window. It uses `metadata.generateName`, so apply it with + `kubectl create -f`: `kubectl apply` refuses a generated name. + +Every sample references `example-target` / `example-provider`, the same names the chart's +`quickstart` values use, so the set is internally consistent and can be applied together. diff --git a/config/samples/commitrequest.yaml b/config/samples/commitrequest.yaml index da3cc4d5..7f972c00 100644 --- a/config/samples/commitrequest.yaml +++ b/config/samples/commitrequest.yaml @@ -12,7 +12,7 @@ metadata: namespace: default spec: gitTargetRef: - name: quickstart-gittarget + name: example-target # Optional commit message (1-1024 Unicode characters). Newlines are allowed # for a subject plus body; other control characters are rejected. When # omitted, the generated grouped-commit message is used. diff --git a/docs/INDEX.md b/docs/INDEX.md index 9825a629..0d6719d5 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -116,7 +116,7 @@ so you never have to read a proposal to find out it already shipped. | Doc | Open question | |---|---| | [`open-asks-priority.md`](design/open-asks-priority.md) | **the work queue.** Merges three overlapping backlogs — the gitops-api consumer asks, the API-surface block left unbuilt by the status and configuration-model review, and the config-surface proposal (B1–B6) — into one ordered queue under four stated tests, and says where we deliberately do **not** do what was asked. The standing caveat narrowed once the layout model reversed: a Tier 2 entry belongs to postponed [#294](https://github.com/ConfigButler/gitops-reverser/issues/294) only if it breaks a `GitTarget` field, and everything else is independently schedulable. Makes one design call against what was asked: **delete Option C sibling inference** rather than ship an off-switch for it, because it let a human's edit to the repository change operator behavior with nothing in status recording the move. That deletion has shipped, and "what the deletion taught" records what building it found. **F9 is Tier 1**: the only item whose answer is unknown rather than whose work is unscheduled, and it gates planning the enum work | -| [`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. What still stands and is still unbuilt is the legibility set: split `declared` into `byType`/`default` (**the one row with an external consumer — it changes a metric label value a dashboard may select on**), the canonical path as a template constant, and `{kindLower}`. 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 | +| [`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}`. 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, `meta.LocalObjectReference` for our six reference shapes, the `TooManyStreams` cap, the `default` `ClusterProvider` message — so #294 wants narrowing to those rather than closing. Also records that staying `v1alpha3` on loud rejections is a **one-consumer countdown**, and that the residue this pattern has actually left is one field: `ClusterWatchRule.spec.rules[].scope`, whose "removed one release from now" has stood since 0.39.0 | | [`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. | | [`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/UPGRADING.md b/docs/UPGRADING.md index 06ed7443..c52de042 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -192,9 +192,44 @@ kubectl get gittargets,watchrules,clusterwatchrules -A -o json | jq -r '.items[] | "\(.kind) \(.metadata.namespace // "-")/\(.metadata.name)"' ``` -`CommitRequest` needs no migration: they are one-shot objects you create and never update, so the -ones in flight during an upgrade finalize under the old name and the next one you create uses the -new one. +### `CommitRequest` is the one that needs a drain + +An earlier revision of this entry said `CommitRequest` needed no migration because they are one-shot +objects. That is wrong in the one case that matters, and it is the only part of this release with a +window you have to plan around. + +A `CommitRequest` that is still pending when the new CRDs land serves an EMPTY `gitTargetRef`, like +every other object. Unlike every other object, it **cannot be repaired**: `CommitRequest.spec` is +wholly immutable, so no apply can put the name back. The request names no `GitTarget`, so it can +never finalize. + +The operator no longer retries such a request forever. It fails it terminally with +`Stalled=True, reason=GitTargetRefPruned` and a message saying to delete it, because an empty name +is otherwise unreachable: admission refuses one on every path, so the only thing that can produce it +is this upgrade. + +**Before you upgrade, drain.** Stop creating `CommitRequest`s and wait until every existing one is +terminal — `Ready=True` or `Stalled=True`: + +```bash +kubectl get commitrequests -A -o json | + jq -r '.items[] + | select([.status.conditions[]? | select(.type == "Ready" or .type == "Stalled") + | select(.status == "True")] | length == 0) + | "still pending: \(.metadata.namespace)/\(.metadata.name)"' +``` + +An empty result means the drain is complete. If you upgrade without draining, nothing is lost from +Git — an unfinalized request only means its writes were not flushed early, and the next commit +window picks them up — but the request objects themselves are stuck, and the fix is to delete them: + +```bash +kubectl get commitrequests -A -o json | + jq -r '.items[] + | select(any(.status.conditions[]?; .type == "Stalled" and .reason == "GitTargetRefPruned")) + | "-n \(.metadata.namespace) \(.metadata.name)"' | + xargs -r -L1 kubectl delete commitrequest +``` ### Conversion @@ -242,11 +277,14 @@ The same edit applies to every reference on every kind: | Kind | Reference fields | |---|---| -| `GitTarget` | `spec.providerRef`, `spec.clusterProviderRef`, `spec.encryption.secretRef` | +| `GitTarget` | `spec.gitProviderRef`, `spec.clusterProviderRef`, `spec.encryption.secretRef` | | `GitProvider` | `spec.secretRef`, `spec.commit.signing.secretRef` | -| `WatchRule` | `spec.targetRef` | -| `ClusterWatchRule` | `spec.targetRef` | -| `CommitRequest` | `spec.targetRef` | +| `WatchRule` | `spec.gitTargetRef` | +| `ClusterWatchRule` | `spec.gitTargetRef` | +| `CommitRequest` | `spec.gitTargetRef` | + +(Spelled with the new names, because [the rename](#every-reference-names-the-kind-it-points-at) +lands in the same release: you make both edits in one pass.) **Nothing is lost.** Each of those `group` and `kind` fields was an enum with exactly one member and a default equal to it, so no manifest could ever say anything but `configbutler.ai` and the one kind @@ -254,7 +292,7 @@ the field accepts. What the sub-fields cost was four near-identical Go types and implied a choice nobody had. **They are removed, not refused.** `kubectl apply` will therefore reject a manifest that still sets -them, naming the field (`unknown field "spec.providerRef.kind"`), while a client applying with +them, naming the field (`unknown field "spec.gitProviderRef.kind"`), while a client applying with field validation off accepts it and prunes. Nothing breaks either way, since the pruned value was the only legal one — but a strict apply will stop until you delete the lines. diff --git a/docs/architecture.md b/docs/architecture.md index e81cbcc1..4a4d0934 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -290,7 +290,7 @@ How attribution and finalization interact is described under One materialization from a source provider to a Git destination: `(cluster provider, provider, branch, path)`. Key fields: -- `spec.providerRef`: a `GitProvider` in the same namespace, by name. +- `spec.gitProviderRef`: a `GitProvider` in the same namespace, by name. - `spec.clusterProviderRef`: a cluster-scoped source `ClusterProvider`; it defaults to `{name: default}`. - `spec.branch`: immutable branch, validated against `GitProvider.spec.allowedBranches`. - `spec.path`: immutable, required path under the repo (`MinLength=1`; `.` means repo root and must be diff --git a/docs/configuration.md b/docs/configuration.md index e1bd837a..617904a3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -362,7 +362,7 @@ immutable. The important fields are: -- `spec.providerRef`: which `GitProvider` backs this target +- `spec.gitProviderRef`: which `GitProvider` backs this target - `spec.clusterProviderRef`: which `ClusterProvider` supplies resources; omit it to reference the user-created `default` provider - `spec.branch`: which allowed branch to write to @@ -409,8 +409,8 @@ dedicated to this target. If you enable `spec.encryption`, that applies to `Secret` resource writes for this target. For SOPS and age details, see [sops-age-guide.md](sops-age-guide.md). -`spec.providerRef` references a `GitProvider` in the same namespace as the `GitTarget`. Its `group` -and `kind` default to `configbutler.ai` / `GitProvider`, so in practice you only set `name`. +`spec.gitProviderRef` references a `GitProvider` in the same namespace as the `GitTarget`, by name. +The field name says what it points at, so the reference itself carries nothing but the `name`. `spec.clusterProviderRef` references a cluster-scoped `ClusterProvider`. It defaults to `{name: default}` when omitted. That is intentionally different from `gitProviderRef`: a source cluster diff --git a/docs/design/placement-visibility-and-declared-defaults.md b/docs/design/placement-visibility-and-declared-defaults.md index 415a3bd1..d76c2a74 100644 --- a/docs/design/placement-visibility-and-declared-defaults.md +++ b/docs/design/placement-visibility-and-declared-defaults.md @@ -5,10 +5,9 @@ > > **`status.layout` shipped as `status.placement`, and the ambiguous render root with it**, both in > [#326](https://github.com/ConfigButler/gitops-reverser/pull/326) — so the two largest rows of the -> table below are done. What is left is the legibility set: the `declared` metric-value split, the -> canonical path as a template constant, `{kindLower}`, and the re-opened CRD default. The metric -> split is the only one with an external consumer, because it changes a label value a dashboard may -> already select on. +> table below are done, and the `declared` metric value has since split into `by_type`/`default`. +> What is left is the rest of the legibility set: the canonical path as a template constant, +> `{kindLower}`, and the re-opened CRD default. > > **The decisions below stand. The build list does not.** An earlier revision of this page said > "everything decided here lands in that same PR", and that turned out to be wrong: PR #291 shipped @@ -521,7 +520,7 @@ because they are worth different urgency: | F10: register a declared path with the kustomization that governs it | **SHIPPED** in 0.42.1 (#319) | One `byType` line into a subdirectory silently produced a file nothing renders. The fix made registration an invariant, which is what reversed [`model.md`](../layout/model.md) | | Drop the `{version}` requirement from `IdentityCompletePlacementTemplate` | **SHIPPED** in 0.42.1 (#319) | It contradicted the versionless-path decision, and it was what made any future spec default fail our own gate | | `status.layout` | **SHIPPED** as `status.placement` in #326 | The durable half of "what did the operator understand about this folder". It waited for the layout model's vocabulary and shipped with it | -| Split `declared` into `byType` and `default`; unify the prose on "canonical" | **filed, legibility** | A catch-all quietly swallowing a type you meant to name looks identical to a rule working | +| Split `declared` into `byType` and `default`; unify the prose on "canonical" | **SHIPPED** as `by_type`/`default` | A catch-all quietly swallowing a type you meant to name looks identical to a rule working | | `{kindLower}` | **filed, legibility** | Small, self-contained | | Canonical path as a template constant | **filed, cleanup** | Removes the hand-written duplication; what a future default would reuse | | `renderRootReason: Ambiguous` | **SHIPPED** in #326 | Shipped with `status.placement`, and the layout model decided the policy: a folder covering two roots REFUSES the placement rather than writing it unrendered | diff --git a/docs/layout/model.md b/docs/layout/model.md index 2dba1802..1e16c810 100644 --- a/docs/layout/model.md +++ b/docs/layout/model.md @@ -330,9 +330,9 @@ change at all. affects only files written afterwards, and a folder can hold documents placed under two templates. Match-first identity keeps finding and updating them in place. The immutability-plus-CEL-widening machinery an earlier draft proposed was invented to protect a discriminator that no longer exists. -- **`placements_total` keeps its `source` label** — today `declared`, `kustomize_root` and - `canonical`, with the `declared` split into `byType`/`default` still queued. It names the rung - that answered rather than a resolved layout kind, so nothing here breaks a label. +- **`placements_total` keeps its `source` label** — `by_type`, `default`, `kustomize_root` and + `canonical`. It names the rung that answered rather than a resolved layout kind, so nothing here + breaks a label. - **`{kindLower}` and the versionless identity fix** are template features and stay queued. ## Previewing a target: point it at a scratch branch diff --git a/docs/layout/new-file-placement-rules.md b/docs/layout/new-file-placement-rules.md index a6706874..7131e005 100644 --- a/docs/layout/new-file-placement-rules.md +++ b/docs/layout/new-file-placement-rules.md @@ -584,7 +584,8 @@ and each one either answers or declines: | Step | Mechanism | `placements_total{source}` | Decides because | |---|---|---|---| -| 1 | declared `placement.byType`, then `placement.default` | `declared` | the GitTarget said so | +| 1a | declared `placement.byType` for this exact type | `by_type` | the GitTarget named this type | +| 1b | declared `placement.default`, the catch-all | `default` | the GitTarget named a fallback | | 2 | the folder's single supported kustomization root | `kustomize_root` | a file that root cannot reach never renders | | 3 | canonical `{namespaceOrCluster}/{group}/{resource}/{name}.yaml` | `canonical` | nothing else did | diff --git a/internal/controller/commitrequest_controller.go b/internal/controller/commitrequest_controller.go index 2a0403e6..84bd9359 100644 --- a/internal/controller/commitrequest_controller.go +++ b/internal/controller/commitrequest_controller.go @@ -139,20 +139,17 @@ func (r *CommitRequestReconciler) Reconcile(ctx context.Context, req ctrl.Reques return ctrl.Result{}, nil } + if handled, err := r.refusePrunedGitTargetRef(ctx, commitRequest); handled || err != nil { + return ctrl.Result{}, err + } + // 1. ATTRIBUTE: settle the request's actor synchronously (present-or-never). // A hit names the admission submitter; a miss claims no actor. Either way the // decision is final — there is no wait and no requeue for the author. author, attribution := r.attributeAuthor(ctx, commitRequest) - // First sight: stamp the still-running conditions so the object reports its - // progress (kstatus InProgress) and AuthorAttributed is settled immediately. A - // disabled controller returns above, so it never stamps. - if findCondition(commitRequest.Status.Conditions, ConditionTypeReady) == nil { - markCommitRequestWaitingForCloseDelay(commitRequest, attribution) - if err := r.Status().Update(ctx, commitRequest); err != nil { - return ctrl.Result{}, err - } - log.V(1).Info("Stamped CommitRequest in-progress conditions", "name", req.NamespacedName) + if err := r.stampFirstSightConditions(ctx, commitRequest, attribution); err != nil { + return ctrl.Result{}, err } // 2. ATTACH + POLL: register the attach idempotently the instant we attribute @@ -195,6 +192,56 @@ func (r *CommitRequestReconciler) Reconcile(ctx context.Context, req ctrl.Reques return ctrl.Result{}, nil } +// stampFirstSightConditions stamps the still-running conditions the first time a request is seen, +// so the object reports its progress (kstatus InProgress) and AuthorAttributed is settled +// immediately. A disabled controller returns before this, so it never stamps. +func (r *CommitRequestReconciler) stampFirstSightConditions( + ctx context.Context, + commitRequest *configbutleraiv1alpha3.CommitRequest, + attribution commitRequestAttribution, +) error { + if findCondition(commitRequest.Status.Conditions, ConditionTypeReady) != nil { + return nil + } + markCommitRequestWaitingForCloseDelay(commitRequest, attribution) + if err := r.Status().Update(ctx, commitRequest); err != nil { + return err + } + logf.FromContext(ctx).V(1).Info("Stamped CommitRequest in-progress conditions", + "name", client.ObjectKeyFromObject(commitRequest)) + return nil +} + +// refusePrunedGitTargetRef fails a CommitRequest whose spec names no GitTarget, which can only be +// one thing: an object stored before spec.targetRef was renamed to spec.gitTargetRef, whose old +// value the apiserver stopped serving the moment the new CRDs landed. Admission refuses an empty +// name on every path, so nothing else can produce it. +// +// It is terminal rather than retried, and it has to be, because the object cannot be repaired: +// CommitRequest.spec is wholly immutable, so no apply can put the name back. Retrying would spend +// the controller's attention on an object that will never resolve, and would report a transient +// "get GitTarget" error for a permanent condition. The message names the only fix there is. +func (r *CommitRequestReconciler) refusePrunedGitTargetRef( + ctx context.Context, + commitRequest *configbutleraiv1alpha3.CommitRequest, +) (bool, error) { + if commitRequest.Spec.GitTargetRef.Name != "" { + return false, nil + } + + failCommitRequest(commitRequest, crReasonGitTargetRefPruned, + "spec.gitTargetRef is empty: this request was created before spec.targetRef was renamed, "+ + "and its value was pruned by the upgrade. A CommitRequest spec is immutable, so this "+ + "one cannot be repaired — delete it and create a new one.") + if err := r.Status().Update(ctx, commitRequest); err != nil { + return true, err + } + logf.FromContext(ctx).Info( + "CommitRequest names no GitTarget: created before the gitTargetRef rename", + "name", client.ObjectKeyFromObject(commitRequest)) + return true, nil +} + // attributeAuthor settles the commit author with a single synchronous lookup of the // submitter captured at admission (present-or-never). It never waits: a nil // AuthorLookup (the validate-operator-types webhook is disabled) or a miss both claim no diff --git a/internal/controller/commitrequest_controller_unit_test.go b/internal/controller/commitrequest_controller_unit_test.go index 022a9c4c..d97a7eb4 100644 --- a/internal/controller/commitrequest_controller_unit_test.go +++ b/internal/controller/commitrequest_controller_unit_test.go @@ -589,3 +589,50 @@ func TestTruncateUTF8(t *testing.T) { assert.Equal(t, "aé", truncateUTF8("aéé", 3)) assert.True(t, utf8.ValidString(truncateUTF8(strings.Repeat("世", 100), 7))) } + +// A CommitRequest stored before spec.targetRef became spec.gitTargetRef serves an EMPTY name after +// the upgrade prunes it, and its spec is immutable, so no apply can put the name back. The +// reconciler must say that once and stop, rather than retry a "get GitTarget" that names the empty +// string and can never succeed. +func TestCommitRequestReconciler_PrunedGitTargetRefIsTerminal(t *testing.T) { + request := &configv1alpha3.CommitRequest{ + ObjectMeta: metav1.ObjectMeta{ + Name: "stored-before-the-rename", + Namespace: "team-a", + UID: "cr-uid", + }, + Spec: configv1alpha3.CommitRequestSpec{ + GitTargetRef: meta.LocalObjectReference{Name: ""}, + }, + } + + scheme := runtime.NewScheme() + require.NoError(t, configv1alpha3.AddToScheme(scheme)) + k8sClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(request). + WithStatusSubresource(request). + Build() + + finalizer := &fakeFinalizer{} + reconciler := &CommitRequestReconciler{Client: k8sClient, Scheme: scheme, Finalizer: finalizer} + + result, err := reconciler.Reconcile(context.Background(), ctrl.Request{ + NamespacedName: types.NamespacedName{Name: request.Name, Namespace: request.Namespace}, + }) + + require.NoError(t, err, "an unrepairable request is a terminal outcome, not a reconcile error") + assert.Zero(t, result.RequeueAfter, "it must not be retried: no apply can ever fix it") + assert.Empty(t, finalizer.calls, "the router must never be asked for GitTarget \"\"") + + var got configv1alpha3.CommitRequest + require.NoError(t, k8sClient.Get(context.Background(), + types.NamespacedName{Name: request.Name, Namespace: request.Namespace}, &got)) + assert.True(t, commitRequestIsTerminal(&got), "the request must reach a terminal condition") + stalled := findCondition(got.Status.Conditions, ConditionTypeStalled) + require.NotNil(t, stalled) + assert.Equal(t, metav1.ConditionTrue, stalled.Status) + assert.Equal(t, crReasonGitTargetRefPruned, stalled.Reason) + assert.Contains(t, stalled.Message, "delete it and create a new one", + "the message must name the only repair there is, because the spec is immutable") +} diff --git a/internal/controller/commitrequest_finalize.go b/internal/controller/commitrequest_finalize.go index ac161d31..11c44d45 100644 --- a/internal/controller/commitrequest_finalize.go +++ b/internal/controller/commitrequest_finalize.go @@ -30,6 +30,7 @@ const ( crReasonCommitterFallback = "CommitterFallback" crReasonAuthorCaptureDisabled = "AuthorCaptureDisabled" crReasonPushed = "Pushed" + crReasonGitTargetRefPruned = "GitTargetRefPruned" ) // noWindowInGraceMessage is the prose for a NoWindowInGrace outcome: the grace diff --git a/internal/controller/renamed_required_field_test.go b/internal/controller/renamed_required_field_test.go index a77654f1..af717b9d 100644 --- a/internal/controller/renamed_required_field_test.go +++ b/internal/controller/renamed_required_field_test.go @@ -5,6 +5,7 @@ package controller import ( "context" "fmt" + "strings" "testing" "time" @@ -71,27 +72,39 @@ func TestRenamedRequiredField_StoredObjectCanAdoptIt(t *testing.T) { }} } - // Stand in for the pre-rename release: gitProviderRef not yet required. - setGitProviderRefRequired(ctx, t, c, false) - legacy := target("stored-before-the-rename", false) + // Stand in for the PRE-rename release: the schema serves `providerRef` and does not know + // `gitProviderRef` at all. Storing under this schema is what makes the assertions below mean + // something — an object created without the new field under the SHIPPED schema would prove only + // that the guard works, not that the loss it exists for is real. + renameGitProviderRefTo(ctx, t, c, "providerRef") + legacy := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "configbutler.ai/v1alpha3", + "kind": "GitTarget", + "metadata": map[string]any{"name": "stored-before-the-rename", "namespace": "default"}, + "spec": map[string]any{ + "providerRef": map[string]any{"name": "platform"}, + "branch": "main", + "path": "clusters/prod", + }, + }} createUntilServed(ctx, t, c, legacy) - // Ship the rename: the field is required from here on. - setGitProviderRefRequired(ctx, t, c, true) + // Ship the rename. + renameGitProviderRefTo(ctx, t, c, "gitProviderRef") requireRefLessCreateRejected(ctx, t, c, target) - // The migrating apply: one update that sets a required, immutable field for the first time. - stored := &unstructured.Unstructured{} - stored.SetAPIVersion("configbutler.ai/v1alpha3") - stored.SetKind("GitTarget") - if err := c.Get(ctx, client.ObjectKey{Name: legacy.GetName(), Namespace: "default"}, stored); err != nil { - t.Fatalf("get stored object: %v", err) - } + // FIRST CLAIM: the old value is gone on READ, immediately, with no write in between. This is + // what makes the rename a stall rather than a silent mis-configuration, and it is the premise + // docs/UPGRADING.md's "re-apply every object" step rests on. + afterRename := requirePrunedOfBothSpellings(ctx, t, c, legacy.GetName()) + + // SECOND CLAIM: the migrating apply works. One update sets a required, immutable field for the + // first time, which is the shape the guard exists for. if err := unstructured.SetNestedMap( - stored.Object, map[string]any{"name": "platform"}, "spec", "gitProviderRef"); err != nil { + afterRename.Object, map[string]any{"name": "platform"}, "spec", "gitProviderRef"); err != nil { t.Fatal(err) } - if err := c.Update(ctx, stored); err != nil { + if err := c.Update(ctx, afterRename); err != nil { t.Fatalf("a stored object could not adopt the renamed required field in one apply: %v\n"+ "Without this, upgrading across the rename would force delete-and-recreate on every "+ "GitTarget. See the guard on spec.gitProviderRef in api/v1alpha3/gittarget_types.go.", err) @@ -112,25 +125,45 @@ func TestRenamedRequiredField_StoredObjectCanAdoptIt(t *testing.T) { } } -// setGitProviderRefRequired flips whether the SERVED GitTarget schema requires spec.gitProviderRef, -// standing in for the release boundary the rename crosses. -func setGitProviderRefRequired(ctx context.Context, t *testing.T, c client.Client, required bool) { +// renameGitProviderRefTo rewrites the SERVED GitTarget schema so the provider reference is spelled +// `to` — property, required list and all — standing in for the release boundary the rename crosses. +// Crossing it in the test the way the release crosses it is the whole point: a field the schema no +// longer describes is a field the apiserver no longer serves. +func renameGitProviderRefTo(ctx context.Context, t *testing.T, c client.Client, to string) { t.Helper() + const ( + oldName = "providerRef" + newName = "gitProviderRef" + ) + from := newName + if to == newName { + from = oldName + } + var crd apiextv1.CustomResourceDefinition if err := c.Get(ctx, client.ObjectKey{Name: "gittargets.configbutler.ai"}, &crd); err != nil { t.Fatalf("get CRD: %v", err) } spec := crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"] - kept := make([]string, 0, len(spec.Required)+1) + if prop, ok := spec.Properties[from]; ok { + spec.Properties[to] = prop + delete(spec.Properties, from) + } + required := make([]string, 0, len(spec.Required)) for _, r := range spec.Required { - if r != "gitProviderRef" { - kept = append(kept, r) + if r == from { + r = to } + required = append(required, r) } - if required { - kept = append(kept, "gitProviderRef") + spec.Required = required + // The rules move with the field. A CEL rule is compiled against the schema, so leaving one + // naming a property that no longer exists makes the CRD itself invalid — which is a useful + // thing to have learned: the immutability rule and the field it guards cannot drift apart. + for i := range spec.XValidations { + spec.XValidations[i].Rule = strings.ReplaceAll(spec.XValidations[i].Rule, from, to) + spec.XValidations[i].Message = strings.ReplaceAll(spec.XValidations[i].Message, from, to) } - spec.Required = kept crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"] = spec if err := c.Update(ctx, &crd); err != nil { t.Fatalf("update CRD schema: %v", err) @@ -174,3 +207,32 @@ func requireRefLessCreateRejected( } t.Fatal("gitProviderRef never became required, so the migration assertion would be vacuous") } + +// requirePrunedOfBothSpellings reads the stored object back and holds the premise of the migration: +// after the rename it serves NEITHER the old name (the schema no longer describes it) nor the new +// one (nothing has written it). It returns the object so the caller can attempt the migration. +func requirePrunedOfBothSpellings( + ctx context.Context, + t *testing.T, + c client.Client, + name string, +) *unstructured.Unstructured { + t.Helper() + obj := &unstructured.Unstructured{} + obj.SetAPIVersion("configbutler.ai/v1alpha3") + obj.SetKind("GitTarget") + if err := c.Get(ctx, client.ObjectKey{Name: name, Namespace: "default"}, obj); err != nil { + t.Fatalf("get stored object after the rename: %v", err) + } + spec, _, err := unstructured.NestedMap(obj.Object, "spec") + if err != nil { + t.Fatal(err) + } + if _, ok := spec["providerRef"]; ok { + t.Fatalf("the pruned field is still served, so this test is not reproducing the migration: %v", spec) + } + if _, ok := spec["gitProviderRef"]; ok { + t.Fatalf("the stored object gained the new field without being written: %v", spec) + } + return obj +} From 2e58af0d2e142c1920084d8ed16ee8efd6e48e18 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Thu, 3 Sep 2026 12:11:04 +0000 Subject: [PATCH 9/9] docs(architecture): record why GitProvider is namespaced, and what would change it The crispness pass cut a paragraph saying that a shared, platform-owned Git destination should be a separate cluster-scoped kind rather than a rescoped GitProvider. I read it as future-looking rationale; it was a recorded design decision, which is a different thing, and the question came straight back the moment it was gone. Restored in architecture.md, where rationale belongs rather than in the configuration guide, and extended with the two shapes that would actually solve the duplication it costs (an accessFrom policy on GitProvider, or a separate cluster-scoped kind, both additive) and with the constraint that rules out the obvious third: a CRD's spec.scope is immutable once the definition is Established, so converting GitProvider in place is not available at any price. Co-Authored-By: Claude Opus 5 --- docs/architecture.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/architecture.md b/docs/architecture.md index 4a4d0934..f17c59e5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,6 +24,18 @@ that shared source; it does not grant source-cluster RBAC or select source names is the `ClusterProvider` name alone, with no API-server identity probe: two providers configured for the same server deliberately remain separate source partitions. +The asymmetry is not an oversight, and it is worth stating what would change it. A source cluster is +an inherent singleton; a Git destination is not, and a namespaced `GitProvider` keeps a repository's +write credential in the namespace of whoever owns that repository, which is what makes a team able +to start mirroring without a platform admin. The known cost is duplication: a cluster whose +`GitTarget`s live in several namespaces needs a copy of one credential in each of them. If that ever +has to be solved, the two additive shapes are a `GitProvider` that admits other namespaces through +an `accessFrom` policy of its own (the `ClusterProvider` pattern, applied to the destination), or a +separate cluster-scoped destination kind with an explicit ownership model. **Converting +`GitProvider` itself is not one of the options**: a CRD's `spec.scope` is immutable once the +definition is `Established`, so the change would mean deleting the definition (and every +`GitProvider` in the cluster with it) and recreating both. + *** ## Ground rules