feat: API-surface trigger gating, Redis key prefix, pkg/manifestanalyzer + scan-folder/scan-repo rename#221
Conversation
…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>
📝 WalkthroughWalkthroughThe 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. ChangesManifest analyzer public contract
Redis key prefix configuration
API-surface trigger lifecycle
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
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
Possibly related PRs
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
pkg/manifestanalyzer/repo.go (1)
123-130: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider initializing nil
CandidatesByLayoutinWriteJSONfor contract consistency.
WriteJSONinitializes nilCandidatesto[]Candidate{}but does not handle a nilSummary.CandidatesByLayoutmap. Since that field lacksomitempty, a nil map marshals as"candidatesByLayout": nullrather than"candidatesByLayout": {}, which can break consumers expecting an object.repoReportFromalways initializes the map, soScanRepooutput is safe, butWriteJSONis 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 valueUse
s.keyPrefixdirectly instead of re-resolving on everykey()call.
s.keyPrefixwas already resolved viaresolveKeyPrefixwhenRedisStorewas constructed and passed toCommandAuthorStore. TheresolveKeyPrefixcall here is redundant and inconsistent withwatchCursorKeyinredis_store.go(line 153), which usess.keyPrefixdirectly. 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
📒 Files selected for processing (26)
.coverage-baselinecharts/gitops-reverser/templates/deployment.yamlcharts/gitops-reverser/values.yamlcmd/main.gocmd/main_audit_server_test.gocmd/manifest-analyzer/main.godocs/UPGRADING.mddocs/design/gitops-api/f8-repo-discovery-and-onboarding-scan.mdinternal/manifestanalyzer/render.gointernal/manifestanalyzer/render_test.gointernal/manifestanalyzer/repowalk.gointernal/manifestanalyzer/repowalk_test.gointernal/queue/attribution_index.gointernal/queue/command_author_store.gointernal/queue/key_prefix.gointernal/queue/key_prefix_test.gointernal/queue/redis_store.gointernal/watch/api_resource_catalog.gointernal/watch/api_surface_triggers_test.gointernal/watch/manager.gointernal/watch/manager_catalog.gopkg/manifestanalyzer/doc.gopkg/manifestanalyzer/folder.gopkg/manifestanalyzer/folder_test.gopkg/manifestanalyzer/repo.gopkg/manifestanalyzer/repo_test.go
💤 Files with no reviewable changes (3)
- internal/manifestanalyzer/repowalk.go
- internal/manifestanalyzer/render.go
- internal/manifestanalyzer/render_test.go
3acd731 to
ebb14c5
Compare
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>
ebb14c5 to
f7499c1
Compare
…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>
…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>
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 resourcesThe APIService trigger informer was created unconditionally. On an API server without an
aggregation layer (kcp, and other non-aggregating control planes)
apiregistration.k8s.iois 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 testablewithout an API server.
feat(queue):--redis-key-prefixso reversers can share one RedisRedis offers 16 logical databases, and
--redis-dbwas the only separator this operatorprovided. 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 aRedisStoreConfigfieldthreaded into
RedisStore,AttributionIndexandCommandAuthorStore, and surfaced as--redis-key-prefix/queue.redis.keyPrefix. It defaults to the historic value, so anupgrade 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 thewrong keyspace.
%is rejected for the same reason againstescapeKeyField.:is allowed(a prefix like
cell-a:tenant-7nests naturally); a trailing:is normalized away.feat(pkg): publishpkg/manifestanalyzeras the stable adoption contractThe 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 adownstream 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/manifestanalyzerpublishes two entry points —ScanFolder(may this folder be adopted,and if not, why) and
ScanRepo(which folders in this repository could be) — over its ownJSON-tagged types carrying a
SchemaVersion. It delegates every decision tointernal/manifestanalyzer, so it cannot disagree with the operator; the public shape is acopy rather than a type alias, so the engine stays free to move.
Breaking, for
manifest-analyzer --format jsononlyThe CLI's
--format jsonfor both modes now goes through this package, which is what keepsthe two contracts identical. That drops
planfrom folder-scan JSON (structurally always emptythere — no cluster state, no desired resources) and adds
schemaVersion.--mode analyzeand every
--format textoutput are unchanged. Documented indocs/UPGRADING.md.
refactor(manifest-analyzer)!: name the scan modes after the question they answerFreezing
pkg/manifestanalyzeras a supported contract is the moment to make the CLI and thedocs speak the same two nouns — folder and repo — before anyone depends on the old ones.
--mode scan--mode scan-folderGitTarget? (ScanFolder)--mode repo-walker--mode scan-repoScanRepo)repo-walkernamed an internal traversal phase, not a contract: it promised "descenddirectories" where the job is really "classify this Git source for adoptable targets". A bare
scanwas fine while it was the only scan, and became asymmetric the moment a repo-level scanexisted next to it.
No back-compat aliases. Both retired names now exit 2 as usage errors rather than falling
through to the default
analyzemode — the only way a stale invocation fails loudly instead ofsilently 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:
WalkRepo→ScanRepo,walkRepoFS→scanRepoFS,repowalk.go→scan_repo.go, testdata corpusrepo-walker/→scan-repo/.--mode analyzeand
--mode discoveryare untouched.CHANGELOG.mdkeeps its historicalrepo-walkerreleasenote — release-please regenerates it from commit messages.
fix(chart):--redis-usernamequoting (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 atthe chart defaults (username is empty, so
withskips the arg), so it has never fired — but a userwho sets
queue.redis.auth.usernamegets"reverser", quotes included, sent as the Redis ACLusername. It is a separate commit so it can be dropped without touching the rest.
Validation
task lintandtask testpass (unit coverage 75.4%, baseline raised from 75.3% and committed).The first push of this branch went red on the two
INSTALL_MODE=helme2e legs(
quickstart-install,image-refresh) while bothconfig-dirlegs passed. The manager was inCrashLoopBackOff:- --redis-key-prefix={{ . | quote }}renders a YAML plain scalar, so itsdouble quotes are literal characters and the flag arrived as
--redis-key-prefix="gitops-reverser".ValidateKeyPrefixrejects",parseFlagsWithArgsreturned an error, and the binary exitedbefore it could serve anything.
Three things had to be true at once for this to escape:
task test-e2edefaults toINSTALL_MODE=config-dir, which never renders the chart.helm lintandhelm templatein the chart job only check that the chart renders, never thatthe rendered argv is accepted.
keyPrefixis the first arg of its kind with anon-empty default.
Fixed by rendering the whole argument through the
printf-then-quoteform the chart already usesfor
--additional-sensitive-resources, and pinned by a newTestChartRendersArgsTheBinaryAccepts,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 andpasses on the fixed one. It reads the chart files in-process so
go testtreats them as cacheinputs — otherwise a chart-only edit would replay a cached PASS.
That test also needs
helm-sync(it renders every template, including therbac.yamlthat readsthe gitignored
charts/gitops-reverser/config/role.yaml), sotask testgained 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-helmpasses locally with the manager
1/1 Running,0restarts, and argv--redis-key-prefix=gitops-reverser.🤖 Generated with Claude Code