Skip to content

feat(api)!: move commit batching onto GitTarget, delete allowedSourceNamespaces, and make sourceNamespace "*" cluster-wide - #330

Open
sunib wants to merge 5 commits into
mainfrom
feat/gittarget-api-breaking-wave
Open

feat(api)!: move commit batching onto GitTarget, delete allowedSourceNamespaces, and make sourceNamespace "*" cluster-wide#330
sunib wants to merge 5 commits into
mainfrom
feat/gittarget-api-breaking-wave

Conversation

@sunib

@sunib sunib commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

PR 3 of the GitTarget API track: the breaking wave, and the only PR in the queue
that costs consumers a coordinated bump. One bump, one release.

Specified by docs/design/gittarget-api-wave.md
and docs/design/source-scope-simplification.md;
sequenced by docs/design/build-order.md.

The envtest that gated the planning, and its answer

gittarget-api-wave.md § "The envtest that has to run before any of this is planned"
asked whether the controller can write a status update onto a stored
ClusterWatchRule whose spec.rules[].scope is Namespaced, or whether the
apiserver rejects it 422. The whole loud-rejection pattern rests on the answer:
if a refused object cannot publish status, the one object that most needs to
explain itself is the one that cannot.

It can. Measured three ways — Kubernetes 1.36 (what ENVTEST_K8S_VERSION
derives from k8s.io/api v0.36.4), and 1.31 with CRDValidationRatcheting
explicitly on and explicitly off. In every case the status update
succeeded, while a create carrying the same value was rejected
Unsupported value: "Namespaced": supported values: "Cluster".

The worry was that ratcheting was doing the work and would vanish on an older
cluster. It is not: the status subresource does not re-validate spec even with
the gate off. So "keep the field, refuse the value" is safe here, no enum
widening and no fallback. (Also recorded: on 1.33+ the gate cannot be turned off
at all — kube-apiserver refuses to start on CRDValidationRatcheting=false.)

That probe ships as a regression test rather than being thrown away
(internal/controller/stored_superseded_value_status_test.go), because five
retained-and-refused fields now depend on the property.

What is in it

B4 — commitWindow and commit.message move onto GitTarget

GitProvider.spec.push.commitWindow  ->  GitTarget.spec.commit.window
GitProvider.spec.commit.message     ->  GitTarget.spec.commit.message

GitProvider is the connection: a URL, a credential, the branches it will
accept. How a folder's writes are batched and phrased describes the folder, and
two GitTargets sharing one GitProvider had no way to disagree about either.
Grouped under spec.commit rather than landing as two top-level fields — the
move is breaking either way, so the grouping is free here and would cost a bump
in any later release.

commit.committer and commit.signing stay on GitProvider: both describe the
identity that talks to the remote.

The commit window is resolved per open window rather than once per worker. A
window is bound to exactly one GitTarget by construction (it finalizes the
moment the target changes), so a (provider, branch) worker serving two targets
honours two cadences. Template validation moves with the field onto the
GitTarget's Validated gate, and now covers the window string too.

The source-scope deletion

GitTarget.spec.allowedSourceNamespaces               removed
ClusterProvider.spec.allowSourceNamespaceOverride -> spec.allowAnySourceNamespace
ClusterProvider.spec.allowedNamespaces            -> spec.accessFrom
sourceNamespace: "*"   every namespace the GitTarget admits
                    -> every namespace the credential can read, one cluster-wide watch

Four parts, shipped together because * was defined in terms of the field
being deleted.

allowedSourceNamespaces presented itself as a destination policy and could not
be one: targetRef is namespace-local, providerRef is namespace-local, and
spec.path is immutable, so the chain from a Git folder back to the object that
fills it never leaves one namespace. Its selector half was evaluated against
Namespace labels in another cluster, and that single choice produced the
three-valued verdict, the SourceScopeUnavailable degradation path, five
condition reasons, the establishing/maintaining retention memory, and the
operator's need for source-cluster Namespace get/list/watch. All of it goes.
The operator now reads no Namespace objects in a source cluster at all.

accessFrom stays, renamed. It is the one boundary available nowhere else:
source RBAC bounds what a credential may read and cannot express which
control-plane tenant may wield it.

* is now one cluster-wide list and watch, refused outright while
allowAnySourceNamespace is false. The plumbing existed — CellKey documents
the empty-namespace case and both openTargetWatch and openTargetList already
branch on it — so this is a deletion in the compile path plus one router fix, not
new machinery. A cluster-wide cell stays a peer of a named-namespace cell,
never a replacement, because each rule carries its own operations filter;
CellKey's doc comment records the bug from the previous attempt.

Loud rejections, verified against the generated CRDs

Every removed or renamed field is retained in the schema and refused with a
message naming its replacement — five of them. Pruning happens on write, so
deleting a field drops the value from a re-applied manifest with no error at all;
for allowSourceNamespaceOverride: true that would silently revoke a
delegation. An envtest applies each one and asserts the rejection names the fix.

What was left out, and why

The riders are trimmed. gittarget-api-wave.md says they can be, since
nothing depends on them, and this PR is already 70+ files across two breaking
commits. Each remaining rider is also under-specified where it stands, or
substantial in its own right:

  • ci(deps): Bump softprops/action-gh-release from 1 to 2 #5 asserted CommitRequest.spec.author — needs a fail-closed SAR
    independent of the webhook's failurePolicy. Real work, not a rider in size.
  • The CommitRequest lifecycle hole (F10) — ttlSecondsAfterFinished or an
    ownerRef, plus the delete verb. No design of record beyond the one-line ask.
  • meta.LocalObjectReference for the six reference shapes (F12 remainder) —
    a breaking change across six types with no migration written down.
  • The TooManyStreams cap — the wave document says explicitly it is "not
    planned before the * change lands". That change is in this PR, so its design
    starts now rather than shipping with it.
  • The default ClusterProvider message (B6) — already shipped. The
    existing ClusterProviderNotFound message already names both fixes
    (clusterProvider.createDefault, or commit the object). Confirmed, not assumed.

The SelfSubjectAccessReview pass is out by specification, not by trimming:
source-scope-simplification.md § "The one thing to build" puts it explicitly
outside this PR because it is additive and should not widen the one release that
costs a bump.

Two breaking semantic changes, said out loud

Both are in docs/UPGRADING.md rather than papered over:

  • allowAnySourceNamespace: false is not exactly today's posture. A declared
    allowedSourceNamespaces was exhaustive once declared, with no self-namespace
    exception
    , so it could deny a rule's own namespace. The new default matches
    the no-policy path, which is what a default install ran.
  • * widens, from "every namespace this GitTarget admits" to "every
    namespace this credential can read". For a target that declared no policy it
    already resolved to whatever the credential could see; for one that declared
    a policy, it is real, and UPGRADING.md carries a jq one-liner to find them.

Source-side label selectors are lost, with no replacement. That is the real
capability cost and it is accepted, not overlooked: an N-way restriction costs N
objects wherever it is expressed. It has its own paragraph in the migration note.

sourceNamespace: "*" keeps its spelling and changes its meaning, so it gets its
own UPGRADING.md paragraph rather than a shim — there is nothing to rename.

What PR 2 left that this touches, confirmed rather than assumed

The one-source-namespace rule needed no rewrite, and each site was read to check
it rather than taken on trust:

  • internal/git/source_namespaces.go — computes {target's own namespace} ∪ {explicit rules[].sourceNamespace} from WatchRule objects. Its doc comment's
    cross-reference to the deleted field is updated; the sentence it made stays true.
  • internal/git/namespace_policy.goSourceNamespaceWildcard and
    declaredNamespace() refuse to attribute under a wildcard. Unchanged.
  • internal/webhook/watchrule_source_namespace_admission.go — refuses *
    statically. No code change needed: it already refuses on the grounds that
    * "cannot be shown to be one namespace", which holds under both readings.
  • internal/manifestanalyzer/source_namespace_fence.go — carries its own copy of
    the * constant. The spelling survives; the comment now records that only the
    spelling is duplicated, and that the meaning has already changed once with no
    edit here.

Test coverage

Deleting test/e2e/source_namespace_e2e_test.go outright would have removed the
only end-to-end coverage of source scoping, so it is rewritten rather than
dropped
: the placement claim (Git placement follows the mirrored object's own
namespace, never the WatchRule's), the refusal path, and a wildcard spec that now
asserts the widening — two namespaces that nothing names both arrive, which is
what the redefinition means and what no API shape shows.

The planner and stream-summary suites under internal/watch/ are likewise kept
and adapted rather than deleted with the service they tested; the wildcard cases
now assert one cluster-wide scope and its peer relationship with a named one.

Net: ~2,900 lines deleted, and internal/authz's three-valued verdict
collapses to a two-valued one — an item is authorized or refused, and a refusal
is terminal, because every input is now a control-plane object the reconcile
already has.

Validation

task lint, task test (unit + coverage ratchet) and task test-e2e all pass.
The API-comment placement check from AGENTS.md was run: regenerated into a
scratch dir with controller-gen and diffed against config/crd/bases with every
description stripped — only descriptions moved, no marker was displaced.

sunib and others added 3 commits September 1, 2026 18:27
…ec.commit

GitProvider is the connection: a URL, a credential, the branches it will accept.
How a folder's writes are batched into commits and how those commits are phrased
are properties of the folder, and two GitTargets sharing one GitProvider had no
way to disagree about either.

  GitProvider.spec.push.commitWindow  -> GitTarget.spec.commit.window
  GitProvider.spec.commit.message     -> GitTarget.spec.commit.message

They are grouped under spec.commit rather than landing as two top-level fields:
the move is breaking either way, so the grouping is free in this release and
would cost a bump in any later one. commit.committer and commit.signing stay on
GitProvider, because both describe the identity that talks to the remote.

Both fields are retained in the GitProvider schema and REFUSED rather than
deleted. CRD pruning happens on write, so a deleted field would be dropped from
a re-applied manifest with no error at all, silently changing a folder's commit
cadence. A field-level CEL rule rejects a new apply naming the replacement, and
a stored object is refused by the reconciler (Stalled, CommitFieldsRelocated)
rather than half-honoured.

The commit window is resolved per open window rather than once per worker: a
window is bound to exactly one GitTarget by construction, since it finalizes the
moment the target changes. Template validation moves with the field, onto the
GitTarget's Validated gate, and now covers the window string too.

Also pins the apiserver property the whole loud-rejection pattern rests on: a
status update onto an object whose STORED spec no longer validates is accepted,
so a refused object can still explain itself. Measured on 1.31 with
CRDValidationRatcheting explicitly on and off, and on the version this module
builds against - the status subresource does not re-validate spec at all, so the
pattern does not depend on ratcheting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…r grants, and make sourceNamespace "*" cluster-wide

Four changes that ship together, because the last one is defined in terms of
the first.

  GitTarget.spec.allowedSourceNamespaces               removed
  ClusterProvider.spec.allowSourceNamespaceOverride -> spec.allowAnySourceNamespace
  ClusterProvider.spec.allowedNamespaces            -> spec.accessFrom
  sourceNamespace: "*"  every namespace the GitTarget admits
                     -> every namespace the credential can read, one cluster-wide watch

allowedSourceNamespaces presented itself as a destination policy and could not
be one: a WatchRule's targetRef is namespace-local, a GitTarget's providerRef is
namespace-local, and spec.path is immutable, so the chain from a Git folder back
to the object that fills it never leaves one namespace. Whoever can create a
WatchRule there could already write into that folder. What it actually bounded
was which source namespaces the folder's own tenant may READ, which for a
credential-scoped provider restates the credential in the one place that cannot
revoke it.

Its selector half was evaluated against Namespace labels in ANOTHER cluster, and
that single choice produced the three-valued verdict, the SourceScopeUnavailable
degradation path, five condition reasons, the establishing/maintaining retention
memory, and the operator's need for source-cluster Namespace get/list/watch. All
of it goes: ~2,900 lines net, and the operator now reads no Namespace objects in
a source cluster at all.

accessFrom STAYS, renamed. It is the one boundary available nowhere else: source
RBAC bounds what a credential may read and cannot express which control-plane
tenant may wield it. Its selector reads control-cluster labels, locally, with no
cross-cluster call, so both halves keep working.

"*" had to be redefined rather than left alone: it was defined in terms of the
deleted field, and RBAC cannot supply the missing definition (it answers "may I
watch X in namespace Y", never "which namespaces may I watch"). It is now one
cluster-wide list and watch, refused outright while allowAnySourceNamespace is
false. The plumbing existed - CellKey documents the empty-namespace case and both
openTargetWatch and openTargetList already branch on it - so this is a deletion
in the compile path plus one router fix, not new machinery. A cluster-wide cell
stays a PEER of a named-namespace cell, never a replacement, because each rule
carries its own operations filter.

Every removed or renamed field is retained in the schema and REFUSED with a
message naming its replacement, verified against the generated CRDs. Pruning
happens on write, so deleting them would drop the value from a re-applied
manifest with no error - and for allowSourceNamespaceOverride: true that would
silently revoke a delegation.

Two breaking semantic changes are stated rather than papered over in
docs/UPGRADING.md: a declared allowedSourceNamespaces could deny a rule's own
namespace, so allowAnySourceNamespace: false is not exactly today's posture; and
"*" widens for anyone who had declared a policy. Source-side label selectors are
LOST with no replacement, and that is the real capability cost.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…es, and scope the wildcard spec

Two fixtures still set GitProvider.spec.push.commitWindow, which this release
refuses: the playground GitProvider and the two demo-only manifests. The refusal
worked as designed and the e2e run caught them, so the values move to
GitTarget.spec.commit.window where they now live.

The wildcard e2e spec asked for too much. `sourceNamespace: "*"` now mirrors
every namespace the credential can read, so watching configmaps AND secrets
cluster-wide filed every service-account token in a live k3d cluster into the
fixture repository, and the render-fidelity gate had 42 secrets' worth of scopes
to settle inside a 90s window. The spec now watches ONE type, and asserts the
gate plus the two files that prove the reach - not whole-target Ready, which
over a cluster-wide mirror is a throughput property rather than a statement
about what "*" means.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Important

Review skipped

Too many files!

This PR contains 112 files, which is 12 over the limit of 100.

To get a review, reduce the PR to 100 files or fewer by splitting it into smaller PRs or changing its base branch.

Upgrade to a paid plan to raise the limit.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 5476a9de-23f7-4f2e-9899-d60b282311ca

📥 Commits

Reviewing files that changed from the base of the PR and between 18d4ac7 and 7c0c3f5.

📒 Files selected for processing (112)
  • .coverage-baseline
  • .markdownlint-cli2.jsonc
  • README.md
  • api/v1alpha3/audit_route_test.go
  • api/v1alpha3/clusterprovider_types.go
  • api/v1alpha3/clusterwatchrule_types.go
  • api/v1alpha3/gitprovider_types.go
  • api/v1alpha3/gittarget_types.go
  • api/v1alpha3/helpers_test.go
  • api/v1alpha3/namespace_matcher.go
  • api/v1alpha3/namespace_matcher_test.go
  • api/v1alpha3/shared_types.go
  • api/v1alpha3/watchrule_types.go
  • api/v1alpha3/zz_generated.deepcopy.go
  • charts/gitops-reverser/README.md
  • charts/gitops-reverser/templates/clusterprovider-default.yaml
  • charts/gitops-reverser/templates/quickstart.yaml
  • charts/gitops-reverser/values.schema.json
  • charts/gitops-reverser/values.yaml
  • config/clusterprovider-default.yaml
  • config/crd/bases/configbutler.ai_clusterproviders.yaml
  • config/crd/bases/configbutler.ai_clusterwatchrules.yaml
  • config/crd/bases/configbutler.ai_gitproviders.yaml
  • config/crd/bases/configbutler.ai_gittargets.yaml
  • config/crd/bases/configbutler.ai_watchrules.yaml
  • config/samples/clusterprovider.yaml
  • config/samples/quickstart-gitprovider.yaml
  • config/samples/quickstart-gittarget.yaml
  • docs/INDEX.md
  • docs/UPGRADING.md
  • docs/architecture.md
  • docs/attribution-setup-guide.md
  • docs/components.md
  • docs/configuration.md
  • docs/design/build-order.md
  • docs/design/gittarget-api-wave.md
  • docs/design/source-scope-simplification.md
  • docs/layout/model.md
  • docs/layout/shapes/1-flat-serialized/config/clusterprovider.yaml
  • docs/layout/shapes/1-flat-serialized/config/gittarget.yaml
  • docs/layout/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml
  • docs/layout/shapes/3-tree-serialized/config/clusterprovider.yaml
  • docs/layout/shapes/3-tree-serialized/config/gittarget.yaml
  • docs/layout/specific-examples/prerequisites/README.md
  • docs/rbac.md
  • docs/security-model.md
  • docs/spec/where-validation-lives.md
  • internal/authz/clusterprovider_admission.go
  • internal/authz/clusterprovider_admission_test.go
  • internal/authz/source_namespace.go
  • internal/authz/source_namespace_test.go
  • internal/controller/clusterprovider_controller_test.go
  • internal/controller/clusterwatchrule_admission_test.go
  • internal/controller/clusterwatchrule_controller.go
  • internal/controller/constants.go
  • internal/controller/gitprovider_controller.go
  • internal/controller/gitprovider_controller_test.go
  • internal/controller/gitprovider_controller_unit_test.go
  • internal/controller/gitprovider_relocated_fields.go
  • internal/controller/gittarget_commit_validation.go
  • internal/controller/gittarget_commit_validation_test.go
  • internal/controller/gittarget_controller.go
  • internal/controller/gittarget_source_cluster.go
  • internal/controller/gittarget_source_cluster_test.go
  • internal/controller/stored_superseded_value_status_test.go
  • internal/controller/suite_test.go
  • internal/controller/superseded_fields_admission_test.go
  • internal/controller/watchrule_controller.go
  • internal/controller/watchrule_kstatus_test.go
  • internal/controller/watchrule_source_namespace.go
  • internal/controller/watchrule_source_namespace_test.go
  • internal/git/branch_worker.go
  • internal/git/branch_worker_loop_test.go
  • internal/git/branch_worker_test.go
  • internal/git/commit_test.go
  • internal/git/namespace_policy.go
  • internal/git/pending_writes.go
  • internal/git/resync_flush.go
  • internal/git/source_namespaces.go
  • internal/git/types.go
  • internal/manifestanalyzer/source_namespace_fence.go
  • internal/rulestore/store.go
  • internal/watch/bootstrap.go
  • internal/watch/bootstrap_admission_test.go
  • internal/watch/manager.go
  • internal/watch/manager_startup_test.go
  • internal/watch/owner.go
  • internal/watch/source_namespace_planning_test.go
  • internal/watch/source_namespace_scope.go
  • internal/watch/source_namespace_stream_summary_test.go
  • internal/watch/source_namespace_test.go
  • internal/watch/watched_type_resolver.go
  • internal/watch/watchrule_compile.go
  • test/e2e/audit_route_attribution_e2e_test.go
  • test/e2e/commit_request_e2e_test.go
  • test/e2e/commit_window_batching_e2e_test.go
  • test/e2e/e2e_test.go
  • test/e2e/helpers.go
  • test/e2e/prune_mode_e2e_test.go
  • test/e2e/quickstart_framework_e2e_test.go
  • test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml
  • test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml
  • test/e2e/signing_e2e_test.go
  • test/e2e/source_cluster_e2e_test.go
  • test/e2e/source_namespace_e2e_test.go
  • test/e2e/suspend_e2e_test.go
  • test/e2e/templates/gitprovider-signing.tmpl
  • test/e2e/templates/gitprovider.tmpl
  • test/e2e/templates/gittarget.tmpl
  • test/e2e/templates/manager/gittarget-prune.tmpl
  • test/playground/config/gitprovider.yaml
  • test/playground/config/gittarget.yaml

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Review on demand using usage pricing

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…nswer

source-scope-simplification.md and gittarget-api-wave.md were written as
proposals and are now descriptions of what shipped, except the additive
SelfSubjectAccessReview pass and the trimmed riders. build-order.md's PR 3 row
and INDEX.md follow.

The wave's step-1 envtest has an answer, and it belongs at the top of the page
that asked the question: a status update onto a stored object whose spec no
longer validates is ACCEPTED, so the fallback that page describes was not needed.

The "*" section stays the definition of record; the reading it called "decided
for the wave" is now the shipped one, and the superseded reading is kept because
the migration note and several code comments still refer to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.33333% with 9 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/controller/gittarget_controller.go 25.0% 6 Missing ⚠️
internal/controller/gitprovider_controller.go 33.3% 2 Missing ⚠️
internal/git/pending_writes.go 91.6% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

…erage baseline for the deletion

The coverage ratchet failed in CI at 76.8% against a 77.4% baseline. The cause is
the deletion, not a testing regression, and it is measurable rather than a
matter of opinion: the two source-scope files this wave removes outright carried
336/371 statements covered (90.6%) on main, and removing exactly those from
main's own coverage profile takes the total from 77.22% to 76.96% by itself. The
files that shrank rather than vanished were covered similarly well. Deleting
well-covered code lowers a whole-tree average without anything becoming less
tested.

The rewritten gate is at or near 100%: ResolveWatchRuleSourceScope, decide,
overrideDelegated, aggregateSourceScope and summariseAdmitted are all fully
covered, as are the new commit-config validation and the per-target commit
window.

Before lowering anything, the two real gaps this left are closed and one piece of
dead surface is removed:

- SourceNamespaceDecision.Admitted had no callers once the verdict became
  two-valued. Deleted rather than left as an untested accessor.
- NamespacesFor's bounds check is pinned. It is indexed by an item's position in
  spec.rules, and the resolved scope and the spec are two objects, so a caller
  reading them apart must get nil rather than a panic.
- The aggregate message's deduplication is pinned, along with the cluster-wide
  cell being spelled out instead of rendered as the empty string an operator
  would read as a missing value.

The baseline moves to the measured 76.9%. CI's 76.8% sits inside the 0.5%
tolerance, and the gate ratchets up again from here on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant