Skip to content

feat: API-surface trigger gating, Redis key prefix, pkg/manifestanalyzer + scan-folder/scan-repo rename#221

Merged
sunib merged 6 commits into
mainfrom
feat/multi-tenant-prereqs
Jul 10, 2026
Merged

feat: API-surface trigger gating, Redis key prefix, pkg/manifestanalyzer + scan-folder/scan-repo rename#221
sunib merged 6 commits into
mainfrom
feat/multi-tenant-prereqs

Conversation

@sunib

@sunib sunib commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Three self-contained pieces split out of #220 so they can land ahead of the larger
multi-tenant work. Each was a clean cherry-pick; nothing here depends on sourceCluster,
write exclusion, GitTarget retarget, or the asserted commit author, and #220 will rebase
onto this.

feat(watch): start API-surface trigger informers only for served resources

The APIService trigger informer was created unconditionally. On an API server without an
aggregation layer (kcp, and other non-aggregating control planes) apiregistration.k8s.io
is not served at all, so client-go's reflector retried and logged forever — benign,
endlessly repeated noise, which is exactly how a real error gets missed.

Both trigger informers (CustomResourceDefinition, APIService) are now gated on discovery
reporting the resource served with list+watch, and re-evaluated after every successful
catalog refresh rather than once at startup. An aggregation layer installed later is picked
up without a restart; an absent one is logged once, not once per refresh. The decision is
extracted as the pure selectAPISurfaceTriggers, so the no-aggregation case is testable
without an API server.

feat(queue): --redis-key-prefix so reversers can share one Redis

Redis offers 16 logical databases, and --redis-db was the only separator this operator
provided. An install that runs one reverser per tenant, or per branch environment, hits
that ceiling long before it hits any real Redis limit.

Every key (watch resume cursors, attribution facts, command author records) was rooted at
the compile-time const gitops-reverser. That root is now a RedisStoreConfig field
threaded into RedisStore, AttributionIndex and CommandAuthorStore, and surfaced as
--redis-key-prefix / queue.redis.keyPrefix. It defaults to the historic value, so an
upgrade that sets nothing keeps reading the keys the previous release wrote.

The prefix is validated rather than escaped: the attribution fact-index gauge SCANs
<prefix>:author:v1:audit:*, so a glob metacharacter in the prefix would silently count the
wrong keyspace. % is rejected for the same reason against escapeKeyField. : is allowed
(a prefix like cell-a:tenant-7 nests naturally); a trailing : is normalized away.

feat(pkg): publish pkg/manifestanalyzer as the stable adoption contract

The acceptance gate that decides "may this folder become a GitTarget?" is the same one the
writer enforces before it commits a byte — which makes it the authoritative answer for an
onboarding wizard. But Go forbids importing another module's internal/ tree, so a
downstream tool could only exec the binary and parse a CLI output that carried no stability
promise, or reimplement the rules and guarantee drift from the writer.

pkg/manifestanalyzer publishes two entry points — ScanFolder (may this folder be adopted,
and if not, why) and ScanRepo (which folders in this repository could be) — over its own
JSON-tagged types carrying a SchemaVersion. It delegates every decision to
internal/manifestanalyzer, so it cannot disagree with the operator; the public shape is a
copy rather than a type alias, so the engine stays free to move.

Breaking, for manifest-analyzer --format json only

The CLI's --format json for both modes now goes through this package, which is what keeps
the two contracts identical. That drops plan from folder-scan JSON (structurally always empty
there — no cluster state, no desired resources) and adds schemaVersion. --mode analyze
and every --format text output are unchanged. Documented in
docs/UPGRADING.md.

refactor(manifest-analyzer)!: name the scan modes after the question they answer

Freezing pkg/manifestanalyzer as a supported contract is the moment to make the CLI and the
docs speak the same two nouns — folder and repo — before anyone depends on the old ones.

Before After Answers
--mode scan --mode scan-folder May this folder become a GitTarget? (ScanFolder)
--mode repo-walker --mode scan-repo Which folders under this repo root could? (ScanRepo)

repo-walker named an internal traversal phase, not a contract: it promised "descend
directories" where the job is really "classify this Git source for adoptable targets". A bare
scan was fine while it was the only scan, and became asymmetric the moment a repo-level scan
existed next to it.

No back-compat aliases. Both retired names now exit 2 as usage errors rather than falling
through to the default analyze mode — the only way a stale invocation fails loudly instead of
silently analyzing something else. The contract is days old; carrying deprecated spellings costs
more than it saves. Pinned by two cases in the CLI's usage-error table.

Internal follows the same nouns: WalkRepoScanRepo, walkRepoFSscanRepoFS,
repowalk.goscan_repo.go, testdata corpus repo-walker/scan-repo/. --mode analyze
and --mode discovery are untouched. CHANGELOG.md keeps its historical repo-walker release
note — release-please regenerates it from commit messages.

fix(chart): --redis-username quoting (pre-existing; droppable)

The last commit is not part of the split. While fixing the above I found the same defect on the
line above it: - --redis-username={{ . | quote }}. It predates this branch and is unreachable at
the chart defaults (username is empty, so with skips the arg), so it has never fired — but a user
who sets queue.redis.auth.username gets "reverser", quotes included, sent as the Redis ACL
username. It is a separate commit so it can be dropped without touching the rest.

Validation

task lint and task test pass (unit coverage 75.4%, baseline raised from 75.3% and committed).

The first push of this branch went red on the two INSTALL_MODE=helm e2e legs
(quickstart-install, image-refresh) while both config-dir legs passed. The manager was in
CrashLoopBackOff: - --redis-key-prefix={{ . | quote }} renders a YAML plain scalar, so its
double quotes are literal characters and the flag arrived as --redis-key-prefix="gitops-reverser".
ValidateKeyPrefix rejects ", parseFlagsWithArgs returned an error, and the binary exited
before it could serve anything.

Three things had to be true at once for this to escape:

  • task test-e2e defaults to INSTALL_MODE=config-dir, which never renders the chart.
  • helm lint and helm template in the chart job only check that the chart renders, never that
    the rendered argv is accepted.
  • The value is only used when non-empty, and keyPrefix is the first arg of its kind with a
    non-empty default.

Fixed by rendering the whole argument through the printf-then-quote form the chart already uses
for --additional-sensitive-resources, and pinned by a new TestChartRendersArgsTheBinaryAccepts,
which renders the chart's Deployment and feeds the manager container's argv through the real
parseFlagsWithArgs. Verified as a genuine regression test: it fails on the pre-fix template and
passes on the fixed one. It reads the chart files in-process so go test treats them as cache
inputs — otherwise a chart-only edit would replay a cached PASS.

That test also needs helm-sync (it renders every template, including the rbac.yaml that reads
the gitignored charts/gitops-reverser/config/role.yaml), so task test gained it as a dependency
— otherwise the unit suite fails on a fresh checkout. Verified by deleting the synced artifacts and
re-running.

Both previously failing legs now pass in CI, and INSTALL_MODE=helm task test-e2e-quickstart-helm
passes locally with the manager 1/1 Running, 0 restarts, and argv --redis-key-prefix=gitops-reverser.

🤖 Generated with Claude Code

…urces

The APIService trigger informer was created unconditionally. On an API server
without an aggregation layer (kcp, and other non-aggregating control planes)
apiregistration.k8s.io is not served at all, so client-go's reflector retried
and logged forever — benign, endlessly repeated noise, which is exactly how a
real error gets missed.

Both trigger informers (CustomResourceDefinition, APIService) are now gated on
discovery reporting the resource served with list+watch, and re-evaluated after
every successful catalog refresh rather than once at startup. An aggregation
layer installed later is picked up without a restart; an absent one is logged
once, not once per refresh.

The decision is extracted as the pure selectAPISurfaceTriggers so the
no-aggregation case is testable without an API server.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change introduces public versioned manifest-analyzer APIs, routes CLI JSON output through them, adds configurable Redis key prefixes across queue storage and Helm deployment, and makes API-surface trigger informers respond dynamically to discovery catalog refreshes.

Changes

Manifest analyzer public contract

Layer / File(s) Summary
Public scan report contracts
pkg/manifestanalyzer/*
Adds versioned folder and repository report types, scan entry points, JSON writers, projections, and package stability documentation.
CLI JSON routing and exit behavior
cmd/manifest-analyzer/main.go, internal/manifestanalyzer/*
Routes JSON scan modes through pkg/manifestanalyzer, preserves internal text rendering, and removes internal JSON renderers.
Contract tests, golden output, and migration documentation
pkg/manifestanalyzer/*_test.go, internal/manifestanalyzer/repowalk_test.go, docs/UPGRADING.md, docs/design/gitops-api/*
Tests scan results and JSON contracts, updates golden encoding, and documents the versioned API migration.

Redis key prefix configuration

Layer / File(s) Summary
Prefix validation and store configuration
internal/queue/key_prefix.go, internal/queue/redis_store.go, cmd/main.go
Adds prefix normalization and validation, stores the resolved namespace, exposes it, and wires the CLI flag.
Namespace propagation across Redis key families
internal/queue/redis_store.go, internal/queue/attribution_index.go, internal/queue/command_author_store.go
Applies the resolved prefix to cursor, attribution, scan, and command-author keys.
Deployment wiring and isolation tests
charts/gitops-reverser/*, cmd/main_audit_server_test.go, internal/queue/key_prefix_test.go
Adds Helm configuration and tests validation, key formatting, default compatibility, and prefix isolation.

API-surface trigger lifecycle

Layer / File(s) Summary
Watchability checks and trigger selection
internal/watch/api_resource_catalog.go, internal/watch/manager_catalog.go, internal/watch/api_surface_triggers_test.go
Checks exact list/watch support and selects served, unserved, and already-running trigger resources.
Informer context and refresh lifecycle
internal/watch/manager.go, internal/watch/manager_catalog.go, internal/watch/api_surface_triggers_test.go
Arms trigger context during startup and re-evaluates informer setup after successful catalog refreshes.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant PublicAnalyzer
  participant InternalAnalyzer
  participant JSONWriter
  CLI->>PublicAnalyzer: ScanFolder or ScanRepo
  PublicAnalyzer->>InternalAnalyzer: execute scan or repository walk
  InternalAnalyzer-->>PublicAnalyzer: internal result
  PublicAnalyzer->>JSONWriter: WriteJSON(report)
  JSONWriter-->>CLI: versioned JSON output
Loading
sequenceDiagram
  participant WatchManager
  participant ResourceCatalog
  participant TriggerSelection
  participant InformerFactory
  WatchManager->>ResourceCatalog: refresh discovery
  ResourceCatalog-->>WatchManager: trusted catalog
  WatchManager->>TriggerSelection: select API-surface triggers
  TriggerSelection-->>WatchManager: served and unserved GVRs
  WatchManager->>InformerFactory: start new trigger informers
  InformerFactory-->>WatchManager: add/update/delete events
  WatchManager->>ResourceCatalog: request catalog refresh
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 47.37% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning The PR description is detailed, but it does not follow the required template sections like Type of Change, Testing, Checklist, Related Issues, or Screenshots. Reformat the description to match the repository template and add the missing sections, including type of change, testing, checklist, related issues, and notes/screenshots if applicable.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly names the three main changes: API-surface gating, Redis key prefixes, and manifestanalyzer scan rename.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/multi-tenant-prereqs

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.

@codecov

codecov Bot commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.35185% with 23 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/watch/manager_catalog.go 77.1% 10 Missing and 3 partials ⚠️
pkg/manifestanalyzer/folder.go 81.3% 6 Missing and 2 partials ⚠️
pkg/manifestanalyzer/repo.go 95.9% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
pkg/manifestanalyzer/repo.go (1)

123-130: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider initializing nil CandidatesByLayout in WriteJSON for contract consistency.

WriteJSON initializes nil Candidates to []Candidate{} but does not handle a nil Summary.CandidatesByLayout map. Since that field lacks omitempty, a nil map marshals as "candidatesByLayout": null rather than "candidatesByLayout": {}, which can break consumers expecting an object. repoReportFrom always initializes the map, so ScanRepo output is safe, but WriteJSON is a public method callable on manually constructed reports.

♻️ Proposed fix
 func (r RepoReport) WriteJSON(w io.Writer) error {
 	if r.Candidates == nil {
 		r.Candidates = []Candidate{}
 	}
+	if r.Summary.CandidatesByLayout == nil {
+		r.Summary.CandidatesByLayout = map[Layout]int{}
+	}
 	enc := json.NewEncoder(w)
 	enc.SetIndent("", "  ")
 	return enc.Encode(r)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/manifestanalyzer/repo.go` around lines 123 - 130, Update
RepoReport.WriteJSON to initialize a nil Summary.CandidatesByLayout map to an
empty map before encoding, while preserving the existing nil Candidates
normalization. Ensure manually constructed reports serialize candidatesByLayout
as an object rather than null.
internal/queue/command_author_store.go (1)

83-83: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use s.keyPrefix directly instead of re-resolving on every key() call.

s.keyPrefix was already resolved via resolveKeyPrefix when RedisStore was constructed and passed to CommandAuthorStore. The resolveKeyPrefix call here is redundant and inconsistent with watchCursorKey in redis_store.go (line 153), which uses s.keyPrefix directly. The behavior is correct (idempotent), but this adds unnecessary string operations on every key construction.

♻️ Proposed refactor for consistency
 func (s *CommandAuthorStore) key(uid types.UID) string {
-	return resolveKeyPrefix(s.keyPrefix) + commandAuthorKeySuffix + escapeKeyField(string(uid))
+	return s.keyPrefix + commandAuthorKeySuffix + escapeKeyField(string(uid))
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/queue/command_author_store.go` at line 83, Update the
CommandAuthorStore key construction method to use s.keyPrefix directly instead
of calling resolveKeyPrefix(s.keyPrefix), matching watchCursorKey and avoiding
redundant resolution on every key generation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@charts/gitops-reverser/templates/deployment.yaml`:
- Around line 74-76: Remove the `| quote` filter from the `--redis-key-prefix`
argument within the `.Values.queue.redis.keyPrefix` block so YAML passes the raw
validated value without embedded quotes; if quoting is required, quote the
entire argument string instead. Apply the same correction to the related
`--redis-username` argument if it uses the same pattern.

---

Nitpick comments:
In `@internal/queue/command_author_store.go`:
- Line 83: Update the CommandAuthorStore key construction method to use
s.keyPrefix directly instead of calling resolveKeyPrefix(s.keyPrefix), matching
watchCursorKey and avoiding redundant resolution on every key generation.

In `@pkg/manifestanalyzer/repo.go`:
- Around line 123-130: Update RepoReport.WriteJSON to initialize a nil
Summary.CandidatesByLayout map to an empty map before encoding, while preserving
the existing nil Candidates normalization. Ensure manually constructed reports
serialize candidatesByLayout as an object rather than null.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7d58aff8-02a2-405e-8304-d99ff246dd2b

📥 Commits

Reviewing files that changed from the base of the PR and between 68a3b93 and 3acd731.

📒 Files selected for processing (26)
  • .coverage-baseline
  • charts/gitops-reverser/templates/deployment.yaml
  • charts/gitops-reverser/values.yaml
  • cmd/main.go
  • cmd/main_audit_server_test.go
  • cmd/manifest-analyzer/main.go
  • docs/UPGRADING.md
  • docs/design/gitops-api/f8-repo-discovery-and-onboarding-scan.md
  • internal/manifestanalyzer/render.go
  • internal/manifestanalyzer/render_test.go
  • internal/manifestanalyzer/repowalk.go
  • internal/manifestanalyzer/repowalk_test.go
  • internal/queue/attribution_index.go
  • internal/queue/command_author_store.go
  • internal/queue/key_prefix.go
  • internal/queue/key_prefix_test.go
  • internal/queue/redis_store.go
  • internal/watch/api_resource_catalog.go
  • internal/watch/api_surface_triggers_test.go
  • internal/watch/manager.go
  • internal/watch/manager_catalog.go
  • pkg/manifestanalyzer/doc.go
  • pkg/manifestanalyzer/folder.go
  • pkg/manifestanalyzer/folder_test.go
  • pkg/manifestanalyzer/repo.go
  • pkg/manifestanalyzer/repo_test.go
💤 Files with no reviewable changes (3)
  • internal/manifestanalyzer/repowalk.go
  • internal/manifestanalyzer/render.go
  • internal/manifestanalyzer/render_test.go

Comment thread charts/gitops-reverser/templates/deployment.yaml
@sunib
sunib force-pushed the feat/multi-tenant-prereqs branch from 3acd731 to ebb14c5 Compare July 10, 2026 06:38
sunib and others added 5 commits July 10, 2026 06:59
Redis offers 16 logical databases, and --redis-db was the only separator this
operator provided. An install that runs one reverser per tenant, or per branch
environment, hits that ceiling long before it hits any real Redis limit.

Every key (watch resume cursors, attribution facts, command author records) was
rooted at the compile-time const "gitops-reverser". That root is now a
RedisStoreConfig field threaded into RedisStore, AttributionIndex and
CommandAuthorStore, and surfaced as --redis-key-prefix / queue.redis.keyPrefix.
It defaults to the historic value, so an upgrade that sets nothing keeps reading
the keys the previous release wrote.

The prefix is validated rather than escaped: the attribution fact-index gauge
SCANs "<prefix>:author:v1:audit:*", so a glob metacharacter in the prefix would
silently count the wrong keyspace. '%' is rejected for the same reason against
escapeKeyField. ':' is allowed (a prefix like "cell-a:tenant-7" nests
naturally); a trailing ':' is normalized away.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The acceptance gate that decides "may this folder become a GitTarget?" is the
same one the writer enforces before it commits a byte — which makes it the
authoritative answer for an onboarding wizard. But Go forbids importing another
module's internal/ tree, so a downstream tool could only exec the binary and
parse a CLI output that carried no stability promise, or reimplement the rules
and guarantee drift from the writer.

pkg/manifestanalyzer publishes two entry points — ScanFolder (may this folder be
adopted, and if not, why) and ScanRepo (which folders in this repository could
be) — over its own JSON-tagged types carrying a SchemaVersion. It delegates
every decision to internal/manifestanalyzer, so it cannot disagree with the
operator; the public shape is a copy rather than a type alias, so the engine
stays free to move.

The CLI's --format json for both modes now goes through this package, which is
what keeps the two contracts identical. That drops `plan` from scan-mode JSON
(structurally always empty there — no cluster state, no desired resources) and
adds schemaVersion; the internal RenderScanJSON/RenderRepoJSON are retired.
Documented in docs/UPGRADING.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`- --redis-username={{ . | quote }}` renders a YAML plain scalar whose double
quotes are literal characters, so the manager received `"reverser"` — quotes
included — and would have failed Redis ACL auth. The same defect crashed the
manager for --redis-key-prefix, which is validated; username is not, so it
would only have surfaced as an authentication failure against a real Redis.

Predates this branch and is unreachable at the chart defaults (username is
empty, so `with` skips the arg), which is why no e2e leg caught it. Rendered
now through the printf-then-quote form the chart already uses for
--additional-sensitive-resources, and pinned by the chart-args test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…they answer

pkg/manifestanalyzer freezes ScanFolder and ScanRepo as the supported contract, so
this is the moment to make the CLI and the docs speak the same two nouns — folder
and repo — before anyone depends on the old ones.

    --mode scan         ->  --mode scan-folder
    --mode repo-walker  ->  --mode scan-repo

`repo-walker` named an internal traversal phase, not a contract: it promised "descend
directories" where the job is really "classify this Git source for adoptable targets",
and it left no room for the implementation to stop being a walker. A bare `scan` was
fine while it was the only scan, and became asymmetric the moment a repo-level scan
existed next to it.

No back-compat aliases. Both retired names now exit 2 as usage errors rather than
falling through to the default analyze mode, which is the only way a stale invocation
fails loudly instead of silently analyzing something else. The contract is days old;
carrying deprecated spellings costs more than it saves.

Internal follows the same nouns: WalkRepo -> ScanRepo, walkRepoFS -> scanRepoFS,
repowalk.go -> scan_repo.go, and the testdata corpus repo-walker/ -> scan-repo/.
--mode analyze and --mode discovery are untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sunib
sunib force-pushed the feat/multi-tenant-prereqs branch from ebb14c5 to f7499c1 Compare July 10, 2026 07:01
@sunib sunib changed the title feat: API-surface trigger gating, Redis key prefix, and pkg/manifestanalyzer feat: API-surface trigger gating, Redis key prefix, pkg/manifestanalyzer + scan-folder/scan-repo rename Jul 10, 2026
@sunib
sunib merged commit 0a940b3 into main Jul 10, 2026
17 checks passed
@sunib
sunib deleted the feat/multi-tenant-prereqs branch July 10, 2026 07:23
sunib added a commit that referenced this pull request Jul 10, 2026
…e code does

Review polish from the merged #221, plus two things it turned up.

--redis-addr's help said an empty value was "Incompatible with --author-attribution=true or
--admission-webhook". Only the first half is true. validateAuditConfig requires Redis for
attribution and returns nil otherwise, and validateAdmissionWebhookConfig says so in a comment:
the admission webhook stays enabled without Redis and no-ops command-author capture, so
CommitRequests commit as the configured committer. The chart README already documented the real
behavior; the flag help was the drift. Pinned now by a flag-combination test, which also caught
that --admission-webhook-cert-path has no default and is required whenever the webhook is on --
a TLS requirement, not a Redis one.

watchCursorKey built its key from the raw s.keyPrefix while every other key family resolves it at
use. Only NewRedisStore constructs a RedisStore today, so nothing was mis-keyed, but the point of
resolveKeyPrefix is that a store built any other way still writes under the default root rather
than into an unprefixed keyspace indistinguishable from another tool's keys. Made consistent
before the key contract freezes, with a zero-value-store test that fails without it.

f8's closing line linked ../multi-tenant/README.md, which does not exist here -- those design docs
live on the multi-tenant branch. Dropped the reference rather than pointing at a file this repo
does not have. docs/README.md indexed docs/serious-bug/, deleted in 7bd1f32 and never unindexed:
same bug, removed too.

Also: the chart README gains queue.redis.keyPrefix, queue.redis.db, rbac.create and
rbac.watchAnyResource rows; docs/configuration.md explains the prefix after the Redis example; and
the chart-args contract test gains attribution-enabled cases, so the audit flag block the default
path never renders is parsed by the real flag parser too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
sunib added a commit that referenced this pull request Jul 10, 2026
…informers that stop on 403 (#223)

* fix(watch): stop an API-surface trigger informer RBAC denies

ServesWatchable asks what the API server SERVES. It is not what this ServiceAccount may
READ. An ordinary apiserver serves apiregistration.k8s.io whether or not the operator's
ClusterRole names apiservices, so a least-privilege install sails past the "is it served?"
gate, starts the informer, and takes a 403 on the reflector's first LIST — which client-go
then retries forever. That is the same benign, endlessly repeated noise the unserved-resource
gate exists to remove, and exactly how a real error gets missed.

Both trigger informers now install a watch error handler. A Forbidden stops that informer and
logs once; every other error stays with the reflector's own backoff, because a 500 or a reset
connection is transient and tearing the informer down would turn a blip into a lost trigger.
The reflector routes LIST errors through the same handler, so the denial is caught where it
actually happens.

A 403 is authoritative and will not resolve by retrying, but it CAN be granted later, so the
resource is un-started rather than blacklisted: the next successful catalog refresh re-arms
it, no restart. That required giving each trigger its own informer and context. The shared
dynamicSharedInformerFactory records an informer as started forever, so a resource stopped
for any reason could never be re-armed through it.

These two resources are conveniences — they only make the catalog notice a new CRD or
aggregated API sooner than its periodic tick would. Failing closed and quiet is the honest
answer to "you may not read this".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(rbac): make the wildcard cluster read an opt-out ClusterRole

config/rbac/role.yaml asked for `apiGroups: ["*"], resources: ["*"], verbs: [get,list,watch]`
and, beside it, `secrets: [get,list,watch,create,update,patch]`. The wildcard is honest about
the general case — a WatchRule may name any type, including one installed later — but RBAC is
ADDITIVE, so while it sat in the manager role no chart value could take back the cluster-wide
Secret read it implied. A reverser asked to mirror two CRDs on a management cluster held read
access to every credential in it.

It does not need them. The manager has held no Secret informer since Secret values stopped
being retained in the control plane: Cache.DisableFor excludes them, so every read is a direct
get of a Secret a GitProvider or GitTarget names by name. Nothing calls list, watch or patch on
a Secret. The marker was never updated to match the binary, so the shipped role still asked for
what the code had stopped using.

Three changes, none of which alter what a default install can do:

  - The wildcard moves to its own ClusterRole, gitops-reverser-watch-any-resource, created by
    default (chart: rbac.watchAnyResource, kustomize: watch-any-resource-role.yaml) and
    droppable whole. It is deliberately not a kubebuilder marker: controller-gen would fold it
    straight back into the role it must stay out of.
  - The manager role's secrets rule narrows to get;create;update — what the binary calls.
  - customresourcedefinitions and apiservices become explicit rules. They were reachable only
    through the wildcard, and the API-resource catalog and its trigger informers read both, so
    a narrowed install would have 403'd on them.

Set rbac.watchAnyResource=false, grant get/list/watch on the types your WatchRules name, and
the reverser runs with zero cluster-wide Secret access. Pinned by chart-render tests that
assert no rendered ClusterRole grants Secret list/watch once the wildcard is off, and that the
generated manager role never carries the wildcard itself. Documented in docs/rbac.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(watch): a watch closed by shutdown is not a watch error

Teardown closes the watch's result channel AND cancels the context, so both arms of the
streaming select are ready at once — and Go picks among ready select cases at random. Roughly
half of all clean shutdowns therefore returned "target watch result channel closed" instead of
nil. Nothing retried on it, but every teardown could log a failure that had not happened, and
a log that cries wolf on every shutdown is a log nobody reads.

The context is the authority: the channel closing while ctx is done means we closed it. A
channel that closes while the context is still live is still a real error — the watch died
under us and the caller must reopen — so that path is unchanged, and pinned by its own test.

Both streaming loops (the replay session and the live stream) shared the bug and now share
targetWatchClosedErr.

Found while stress-testing the API-surface trigger informers: this surfaced as an intermittent
failure of TestTargetWatchReplayAndStream_FallsBackWhenReplayWatchIsForbidden under -race,
which reproduces on main and is not caused by that change. `task test` does not pass -race, so
CI never saw it. The regression test repeats 200 times because a coin flip proves nothing once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(review): align help text, chart docs and the f8 doc with what the code does

Review polish from the merged #221, plus two things it turned up.

--redis-addr's help said an empty value was "Incompatible with --author-attribution=true or
--admission-webhook". Only the first half is true. validateAuditConfig requires Redis for
attribution and returns nil otherwise, and validateAdmissionWebhookConfig says so in a comment:
the admission webhook stays enabled without Redis and no-ops command-author capture, so
CommitRequests commit as the configured committer. The chart README already documented the real
behavior; the flag help was the drift. Pinned now by a flag-combination test, which also caught
that --admission-webhook-cert-path has no default and is required whenever the webhook is on --
a TLS requirement, not a Redis one.

watchCursorKey built its key from the raw s.keyPrefix while every other key family resolves it at
use. Only NewRedisStore constructs a RedisStore today, so nothing was mis-keyed, but the point of
resolveKeyPrefix is that a store built any other way still writes under the default root rather
than into an unprefixed keyspace indistinguishable from another tool's keys. Made consistent
before the key contract freezes, with a zero-value-store test that fails without it.

f8's closing line linked ../multi-tenant/README.md, which does not exist here -- those design docs
live on the multi-tenant branch. Dropped the reference rather than pointing at a file this repo
does not have. docs/README.md indexed docs/serious-bug/, deleted in 7bd1f32 and never unindexed:
same bug, removed too.

Also: the chart README gains queue.redis.keyPrefix, queue.redis.db, rbac.create and
rbac.watchAnyResource rows; docs/configuration.md explains the prefix after the Redis example; and
the chart-args contract test gains attribution-enabled cases, so the audit flag block the default
path never renders is parsed by the real flag parser too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(rbac): choose watched types with rbac.watchTypes.mode any|selected

The opt-out boolean told a user the wildcard was bad without helping them leave it: they
still had to hand-write a ClusterRole, bind it to the right ServiceAccount, and know which
rules the manager role already carries. Most would keep the wildcard.

    rbac:
      watchTypes:
        mode: any          # cluster-wide read; the reverser can read every Secret
        selected: []       # mode: selected -> read exactly these types, nothing else

The chart renders the ClusterRole and binding for both modes, so least privilege is a value
change rather than a piece of RBAC to maintain and keep in sync with the ServiceAccount.
`any` renders exactly the wildcard rule the boolean did, so the default install is unchanged.

Verbs are never the user's to choose. A watched type is only ever read, so the chart fixes
get/list/watch and refuses a `verbs:` key rather than let someone grant `create` to something
that will never write. It also refuses an unknown mode, and `selected` with an empty list --
which would leave the operator authorized to watch nothing, a silent failure that looks
exactly like a broken WatchRule.

The values comment now names the consequence of `any` instead of describing the mechanism:
the reverser can read every Secret in the cluster, including credentials that have nothing to
do with it. The commented example follows configmaps and deployments, which is what someone
reading it is most likely to want.

Renames the kustomize pair to gitops-reverser-watch-any to match the chart's -watch-<mode>.
Both never shipped -- rbac.watchAnyResource was introduced earlier on this branch -- so this
replaces it outright rather than deprecating it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(chart): validate rbac.watchTypes with values.schema.json, not template guards

The five `fail` calls guarding rbac.watchTypes were a hand-rolled type system. helm has one:
values.schema.json, enforced on template, lint, install and upgrade, and carried inside the
packaged chart so it protects a real `helm install` and not just a render from this repo.

Every rule the guards expressed is now a schema rule, and the errors name the offending path
rather than a sentence someone wrote once:

    at '/rbac/watchTypes/mode': value must be one of 'any', 'selected'
    at '/rbac/watchTypes/selected': minItems: got 0, want 1
    at '/rbac/watchTypes/selected/0': missing property 'apiGroups'
    at '/rbac/watchTypes/selected/0': additional properties 'verbs' not allowed

`additionalProperties: false` on rbac buys one the guards could not: an unknown key is an
error rather than a silent no-op. `rbac.watchAnyResoure` -- or a leftover `watchAnyResource`
from earlier on this branch -- now fails the install instead of quietly leaving the wildcard
in place, which is precisely how someone believes they narrowed access and has not.

The schema is deliberately partial: it constrains rbac, whose shape is load-bearing, and
leaves the rest of values.yaml free-form until a value needs a rule. Everything else still
validates, because unspecified properties are permitted at the top level.

Trade-off, stated: `helm template --skip-schema-validation` bypasses all of it. The template
then renders a ClusterRole with no rules, so the reverser is authorized to watch nothing --
it fails closed, not open. That is the right way round, and the tests cover it by asserting
the schema errors rather than the template's.

The one remaining `fail` stays: it reports a missing generated config/role.yaml, which is a
file, not a value, and no values schema can see it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(chart): a full values.schema.json, and two things it found

Extends the rbac-only schema to every value. Objects the chart owns are closed
(additionalProperties: false), so a typo fails the install instead of silently doing nothing:
`--set replicaCounts=2` or `--set servers.metric.port=8080` now name the offending path rather
than rendering the defaults and leaving the operator to wonder. Kubernetes pass-through objects
-- securityContext, affinity, resources, volumes, tolerations -- stay open, because their shape
belongs to Kubernetes and not to this chart. `global` stays open too: a parent chart may set it.

Where the binary already had a contract, the schema states it:

  logging.level          enum debug|info|error|panic -- `warn` is NOT a zap level. The chart
                         renders --zap-log-level=warn happily and the pod crash-loops on it.
  logging.stacktraceLevel enum info|error|panic (no debug)
  logging.encoder        enum json|console
  queue.redis.keyPrefix  pattern + maxLength, the same character class ValidateKeyPrefix enforces.
                         A quote in it is what CrashLoopBackOff'd the manager on this branch.
  servers.admission.timeoutSeconds  1..30, which is what the apiserver accepts
  auditService.nodePort  30000..32767
  durations              Go duration pattern (ttl, grace, timeouts, cert lifetimes)

Two suspicious things fell out of diffing what values.yaml declares against what the templates
actually read:

1. servers.enableHTTP2 was DEAD. No template referenced it, so setting it changed nothing, while
   its comment promised a mitigation for the HTTP/2 Rapid-Reset CVE class. The effective behavior
   was safe by accident -- the binary's --enable-http2 defaults to false -- but a value that reads
   like a security control and does nothing is worse than no value. Wired to the flag, rendered
   only when true, so the default install is byte-identical. Pinned by a chart-args case that
   fails against the unwired template.

2. env, volumes and volumeMounts are read by the Deployment but were never declared. They were
   invisible to anyone reading values.yaml, and a closed top-level schema would have broken them.
   Declared, documented, and covered.

Verified against every real invocation: chart defaults, `helm lint`, the e2e `helm upgrade
--install` flags, `task dist-install`, quickstart, attribution, and rbac selected mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: stop overclaiming, refresh the security model, and prune the stale future plans

Three doc problems, one of them a correctness bug in a security claim.

1. "zero cluster-wide Secret access" was FALSE. `selected` mode removes the wildcard, so the
   reverser can no longer list or watch Secrets -- but the manager ClusterRole still grants
   secrets get/create/update, cluster-scoped, because a GitProvider may name a Secret in any
   namespace. A selected install cannot DISCOVER a Secret; it can still read one whose name it
   knows. The tests always said this ("cannot enumerate Secrets", "does not grant Secret
   list/watch"); four prose sites said something stronger. They now say what the tests say, and
   docs/rbac.md carries a verb-by-verb table of what stays. Scoping get/create/update to the
   referenced namespaces needs a namespaced Role the chart cannot render without the manifests --
   named as remaining work rather than quietly implied.

2. docs/security-model.md contradicted this branch. It still said the Secret narrowing was "not
   yet an RBAC change" and that the default install grants secrets get;list;watch. Rewritten
   around the two ClusterRoles, with the parts of the (now deleted) retention plan that are
   product truth rather than plan: no Secret informer, cache DisableFor, direct GET by name, the
   5-minute steady reconcile as the rotation path, and public-recipient-only SOPS. Two mermaid
   diagrams, both parsed with the real mermaid parser rather than eyeballed.

3. The future/ plans were describing a world this branch deleted. secret-value-retention-plan.md
   was implemented; scoped-rbac-least-privilege-plan.md and secret-handling-roadmap.md both still
   opened with "Track B: not started" and documented the wildcard-in-manager-role that no longer
   exists. All three removed. What survives is two short docs: the age-recipient metadata idea
   (with the annotation-forgery rule that makes it dangerous to get wrong), and the three genuinely
   open least-privilege items -- never mirror Secrets, permission-aware followability, and the
   namespaced-Role/RBAC-generator work. Every inbound link repointed; no link left dangling.

Also: root README lists docs/rbac.md beside security-model.md, and the aggregated-apiserver design
note no longer claims config/rbac/role.yaml holds the wildcard -- it moved to watch-any-role.yaml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <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