From 588f485ae551e51e7593b411c473fd9361c668ea Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 27 Jul 2026 06:49:48 +0000 Subject: [PATCH 01/15] feat(analyzer): every refusal says whether it can ever stop being one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A consumer's onboarding wizard had to split "not supported yet" from "cannot be synced" on the only signal the type offered, a match on one code — and both halves of what it read were wrong. The axis went degenerate when overlay-fan-out-unsupported was retired, and most of the live code space is neither permanent nor pending: a folder refused for invalid-yaml is one broken document from working, and its owner was told to wait for a release. AcceptanceIssue (and the RefusalReason it projects into) now carries a Permanence and, when someone can act, an Actor. Both are set at the RAISE SITE, because only the check that raised the refusal knows: three kinds answer differently depending on which branch fired. - unsupported-kustomize is classified per construct, from the feature set the parse already computed and now carries on the retention: fixable for a build file the author broke, pending for a remote base or Helm inflation, permanent for a generator or a transformer. One code, three answers. - unresolved-krm splits where the registry answer is still in hand: a GVK nothing serves is a CRD the platform operator installs; a GVK that is served but ambiguous, denied, or missing a verb is nobody's to clear today. - refused-structural is classified from the same feature set, so the repo scan and the folder gate cannot hand a consumer two different answers about one directory. - The write path classifies its four too, since they reach a user through GitTarget status with the same guess to make. The tests are the actual ask. TestEveryIssueKindIsClassified reads the IssueKind constants out of the package source, so a new kind fails rather than reaching a consumer as silence; TestAcceptanceEmissionPathsAreClassified drives each emission path through the real gate, which is what a per-constant test cannot do; and TestEveryUnsupportedKustomizeFeatureIsClassified walks kustomize's own struct, so a kustomize bump cannot quietly reintroduce PermanenceUnknown. Co-Authored-By: Claude Opus 5 --- internal/git/kustomize_oracle_test.go | 4 + internal/git/plan_flush.go | 28 +- .../git/write_boundary_precondition_test.go | 11 +- internal/git/write_refusal_permanence_test.go | 105 ++++ internal/manifestanalyzer/acceptance.go | 39 +- internal/manifestanalyzer/analyzer.go | 13 +- internal/manifestanalyzer/gittargetignore.go | 10 + .../manifestanalyzer/kustomization_parse.go | 6 + internal/manifestanalyzer/permanence.go | 194 +++++++ internal/manifestanalyzer/permanence_test.go | 510 ++++++++++++++++++ internal/manifestanalyzer/scan_repo.go | 54 +- internal/manifestanalyzer/store.go | 31 +- .../unsupported/helm-inflation.golden.json | 3 +- .../unsupported/plain-nonkrm.golden.json | 4 +- .../unsupported-kustomize.golden.json | 3 +- 15 files changed, 991 insertions(+), 24 deletions(-) create mode 100644 internal/git/write_refusal_permanence_test.go create mode 100644 internal/manifestanalyzer/permanence.go create mode 100644 internal/manifestanalyzer/permanence_test.go diff --git a/internal/git/kustomize_oracle_test.go b/internal/git/kustomize_oracle_test.go index 94d8a720..7d801559 100644 --- a/internal/git/kustomize_oracle_test.go +++ b/internal/git/kustomize_oracle_test.go @@ -140,6 +140,10 @@ func TestPlanFlush_RefusesAWriteThatDragsASiblingAlong(t *testing.T) { "the refusal must name the object the write would have dragged along") assert.True(t, refused.AllIssuesOfKinds(manifestanalyzer.IssueRenderRefused), "it is a write-boundary refusal, so it surfaces as WriteBoundaryRefused") + // The oracle will always refuse a write it cannot vouch for, so this refusal is + // permanent on the narrow claim it makes — never a "not supported yet". + assertClassified(t, refused.Issues) + assert.Equal(t, manifestanalyzer.PermanencePermanent, refused.Issues[0].Permanence) assertFileBytes(t, kustPath, sharedEntryKustomizationYAML, "the entry is shared context; editing it would move api too") diff --git a/internal/git/plan_flush.go b/internal/git/plan_flush.go index 2bf53413..7f6b16d0 100644 --- a/internal/git/plan_flush.go +++ b/internal/git/plan_flush.go @@ -860,6 +860,10 @@ func sourceFormRefusal(filePath string, id manifestedit.Identity, err error) err Issues: []manifestanalyzer.AcceptanceIssue{{ Kind: manifestanalyzer.IssueUnplaceableEdit, Path: filePath, + // Permanent, and deliberately so: the alternative to refusing is aligning two + // lists by position, which is measurably wrong rather than merely risky (see + // the IssueUnplaceableEdit comment). No release lifts this. + Permanence: manifestanalyzer.PermanencePermanent, Message: fmt.Sprintf("%s/%s in %s: %v", id.Kind, id.Name, filePath, err), }}, @@ -874,10 +878,15 @@ func renderFidelityRefusal( issues := make([]manifestanalyzer.AcceptanceIssue, 0, len(fidelity.Divergences)) for _, divergence := range fidelity.Divergences { issues = append(issues, manifestanalyzer.AcceptanceIssue{ - Kind: manifestanalyzer.IssueRenderDoesNotMatchLive, - Path: filePath, - Field: divergence.Field, - Token: divergence.Token, + Kind: manifestanalyzer.IssueRenderDoesNotMatchLive, + Path: filePath, + // A live value that diverges from what the folder renders is out-of-band + // substitution, not a render artifact — whoever owns the deployment pipeline + // can reconcile the two. + Permanence: manifestanalyzer.PermanenceFixable, + Actor: manifestanalyzer.ActorPlatform, + Field: divergence.Field, + Token: divergence.Token, Message: fmt.Sprintf("%s/%s in %s: rendered token %q at %s does not match live", id.Kind, id.Name, filePath, divergence.Token, divergence.Field), }) @@ -917,6 +926,11 @@ func (wb *writeBatch) renderPrecondition() error { issues = append(issues, manifestanalyzer.AcceptanceIssue{ Kind: manifestanalyzer.IssueRenderRefused, Message: reason, + // This refuses a WRITE, not a folder, so "can this folder ever be + // picked" is the wrong question for it. Classified permanent on the + // narrower claim it does make: the oracle will always refuse a write + // it cannot vouch for, and no release changes that. + Permanence: manifestanalyzer.PermanencePermanent, }) } return &manifestanalyzer.AcceptanceRefusedError{Issues: issues} @@ -1511,6 +1525,9 @@ func (wb *writeBatch) pathScopePrecondition() error { issues = append(issues, manifestanalyzer.AcceptanceIssue{ Kind: manifestanalyzer.IssueWriteEscapesScope, Path: rel, + // Widening spec.path, or re-placing the write, is the GitTarget owner's call. + Permanence: manifestanalyzer.PermanenceFixable, + Actor: manifestanalyzer.ActorPlatform, Message: fmt.Sprintf( "planned write path %q escapes the GitTarget write scope: the operator only ever writes "+ "inside spec.path (reads may reach shared context such as ../../base, writes never leave it)", @@ -1562,6 +1579,9 @@ func (wb *writeBatch) fanInPrecondition() error { issues = append(issues, manifestanalyzer.AcceptanceIssue{ Kind: manifestanalyzer.IssueWriteFanIn, Path: rel, + // Not the boundary: per-render-root scoping generalizes this, so a future + // release may accept the same edit. Nobody can clear it today. + Permanence: manifestanalyzer.PermanencePending, Message: fmt.Sprintf( "planned write to %q would edit in place a source file that more than one kustomize render "+ "root reaches (write-fan-in must be 1); refusing rather than "+ diff --git a/internal/git/write_boundary_precondition_test.go b/internal/git/write_boundary_precondition_test.go index b5f093c2..26974068 100644 --- a/internal/git/write_boundary_precondition_test.go +++ b/internal/git/write_boundary_precondition_test.go @@ -148,8 +148,17 @@ func TestFanInPrecondition_RefusesAmbiguousOverrideWriteThrough(t *testing.T) { w := &BranchWorker{contentWriter: writer, mapper: deploymentMapper()} _, err := w.flushEventsToWorktree(context.Background(), worktree, "", []Event{overridesDeploymentEvent("ghcr.io/example/podinfo:9.9.9", 3)}, nil, configv1alpha3.PruneOnEvent) - assert.Contains(t, refusalIssueKinds(t, err), manifestanalyzer.IssueWriteFanIn, + issues := refusalIssues(t, err) + assert.Contains(t, issueKinds(issues), manifestanalyzer.IssueWriteFanIn, "an ambiguous-override write-through must be refused, not written through") + // Not the support boundary: per-render-root scoping generalizes this, so it is a + // "not yet" and must not be reported to a user as a redesign. + assertClassified(t, issues) + for _, issue := range issues { + if issue.Kind == manifestanalyzer.IssueWriteFanIn { + assert.Equal(t, manifestanalyzer.PermanencePending, issue.Permanence) + } + } assertFileBytes(t, filepath.Join(root, "base", "deployment.yaml"), diamondDeploymentYAML, "a refused fan-in write must leave the shared source file untouched") diff --git a/internal/git/write_refusal_permanence_test.go b/internal/git/write_refusal_permanence_test.go new file mode 100644 index 00000000..7474c3ab --- /dev/null +++ b/internal/git/write_refusal_permanence_test.go @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 + +package git + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit" + "github.com/ConfigButler/gitops-reverser/internal/manifestanalyzer" +) + +// The write path raises four of the operator's refusal kinds, and each one has to say +// whether it can ever stop being a refusal — the same contract the acceptance gate now +// carries. Classifying only the folder-level kinds would leave a consumer reading a +// GitTarget's status with the same "not supported yet" guess the ask exists to kill. +// +// See docs/design/analyzer-consumer-contract-asks.md (Ask 1). + +// refusalIssues unwraps an AcceptanceRefusedError and returns the issues it carries. +func refusalIssues(t *testing.T, err error) []manifestanalyzer.AcceptanceIssue { + t.Helper() + require.Error(t, err) + var refused *manifestanalyzer.AcceptanceRefusedError + require.ErrorAs(t, err, &refused, "error should be an AcceptanceRefusedError: %v", err) + require.NotEmpty(t, refused.Issues) + return refused.Issues +} + +// issueKinds projects the kinds out of a refusal's issues. +func issueKinds(issues []manifestanalyzer.AcceptanceIssue) []manifestanalyzer.IssueKind { + kinds := make([]manifestanalyzer.IssueKind, 0, len(issues)) + for _, issue := range issues { + kinds = append(kinds, issue.Kind) + } + return kinds +} + +// assertClassified holds the two invariants every emitted refusal must satisfy: it says +// something about the future, and it names an actor exactly when someone can act. +func assertClassified(t *testing.T, issues []manifestanalyzer.AcceptanceIssue) { + t.Helper() + for _, issue := range issues { + assert.NotEqual(t, manifestanalyzer.PermanenceUnknown, issue.Permanence, + "%s reached a consumer unclassified: the only honest sentence left is "+ + "\"this cannot be written\"", issue.Kind) + if issue.Permanence == manifestanalyzer.PermanenceFixable { + assert.NotEqual(t, manifestanalyzer.ActorUnknown, issue.Actor, + "%s is fixable but does not say by whom", issue.Kind) + } else { + assert.Equal(t, manifestanalyzer.ActorUnknown, issue.Actor, + "%s names an actor for a refusal nobody can act on", issue.Kind) + } + } +} + +// TestUnplaceableEditRefusalIsPermanent pins the classification of the one refusal that is +// deliberately never a "not yet": the alternative to refusing is aligning two lists by +// position, which is measurably wrong rather than merely risky, so no release lifts it. +func TestUnplaceableEditRefusalIsPermanent(t *testing.T) { + err := sourceFormRefusal("apps/web/deploy.yaml", + manifestedit.Identity{Kind: "Deployment", Name: "web"}, assert.AnError) + + issues := refusalIssues(t, err) + require.Len(t, issues, 1) + assert.Equal(t, manifestanalyzer.IssueUnplaceableEdit, issues[0].Kind) + assert.Equal(t, manifestanalyzer.PermanencePermanent, issues[0].Permanence) + assertClassified(t, issues) +} + +// TestRenderFidelityRefusalIsFixableByThePlatform pins the other half: a live value that +// diverges from what the folder renders is out-of-band substitution, not a render +// artifact, so whoever owns the deployment pipeline can reconcile the two — and the +// repository author, who is the one usually on the screen, cannot. +func TestRenderFidelityRefusalIsFixableByThePlatform(t *testing.T) { + err := renderFidelityRefusal("apps/web/deploy.yaml", + manifestedit.Identity{Kind: "Deployment", Name: "web"}, + &renderFidelityRefusedError{Divergences: []manifestanalyzer.RenderDivergence{ + {Field: "spec.replicas", Token: "${REPLICAS}"}, + }}) + + issues := refusalIssues(t, err) + require.Len(t, issues, 1) + assert.Equal(t, manifestanalyzer.IssueRenderDoesNotMatchLive, issues[0].Kind) + assert.Equal(t, manifestanalyzer.PermanenceFixable, issues[0].Permanence) + assert.Equal(t, manifestanalyzer.ActorPlatform, issues[0].Actor) + assertClassified(t, issues) +} + +// TestPathScopeRefusalIsFixableByThePlatform drives the real L1 precondition: widening +// spec.path, or re-placing the write, is the GitTarget owner's call and nobody else's. +func TestPathScopeRefusalIsFixableByThePlatform(t *testing.T) { + wb := &writeBatch{buffers: map[string]*fileBuffer{ + "../escape.yaml": {rel: "../escape.yaml", current: []byte("b")}, + }} + + issues := refusalIssues(t, wb.pathScopePrecondition()) + require.Len(t, issues, 1) + assert.Equal(t, manifestanalyzer.IssueWriteEscapesScope, issues[0].Kind) + assert.Equal(t, manifestanalyzer.PermanenceFixable, issues[0].Permanence) + assert.Equal(t, manifestanalyzer.ActorPlatform, issues[0].Actor) + assertClassified(t, issues) +} diff --git a/internal/manifestanalyzer/acceptance.go b/internal/manifestanalyzer/acceptance.go index 8b1c1bce..83f0861d 100644 --- a/internal/manifestanalyzer/acceptance.go +++ b/internal/manifestanalyzer/acceptance.go @@ -280,6 +280,11 @@ func unsupportedKustomizeRefusals(store *ManifestStore) []AcceptanceIssue { if !rd.Unsupported { continue } + // One code, three answers: a broken kustomization is the author's to fix, a + // remote base is ours to support later, and a generator is the support boundary. + // The construct that raised the refusal is the only thing that knows which, so + // the classification is computed from the retention's own feature set here rather + // than looked up per code. out = append(out, AcceptanceIssue{ Kind: IssueUnsupportedKustomize, Path: rd.Location.Path, @@ -290,7 +295,7 @@ func unsupportedKustomizeRefusals(store *ManifestStore) []AcceptanceIssue { "declares malformed images/replicas overrides, or is a render root kustomize cannot build; " + "the operator cannot map it back to editable source documents and will not write into this folder " + "(a kustomize-build-failed diagnostic on this path carries the build error)", - }) + }.classified(classifyKustomizeFeatures(rd.UnsupportedFeatures))) } return out } @@ -313,6 +318,9 @@ func duplicateRefusals(store *ManifestStore) []AcceptanceIssue { Kind: IssueDuplicate, Path: path, DocumentIndex: loser.DocumentIndex, + // Two documents claim one identity; the author deletes or renames one. + Permanence: PermanenceFixable, + Actor: ActorAuthor, Message: fmt.Sprintf("duplicate manifest identity %s at %s#%d; first occurrence at %s#%d", identityRef(dm.ManifestIdentity), path, loser.DocumentIndex, winner.FilePath, winner.DocumentIndex), @@ -363,6 +371,8 @@ func recordlessRefusal(store *ManifestStore, d manifestedit.Diagnostic) (Accepta return AcceptanceIssue{ Kind: IssueNonKRM, Path: d.Path, DocumentIndex: d.DocumentIndex, Message: "YAML is not a Kubernetes manifest", + // Remove it, or name it in .gittargetignore. Either way the author acts. + Permanence: PermanenceFixable, Actor: ActorAuthor, }, true case manifestedit.ReasonInvalidYAML, manifestedit.ReasonMissingSopsKey: if managed { @@ -370,6 +380,8 @@ func recordlessRefusal(store *ManifestStore, d manifestedit.Diagnostic) (Accepta } return AcceptanceIssue{ Kind: IssueInvalidYAML, Path: d.Path, DocumentIndex: d.DocumentIndex, Message: d.Message, + // The document does not parse: one broken document away from working. + Permanence: PermanenceFixable, Actor: ActorAuthor, }, true case manifestedit.ReasonNonEditable, manifestedit.ReasonDuplicateIdentity: // Accompany a managed record (handled by duplicateRefusals / the planner skip); @@ -386,6 +398,9 @@ func impureIssue(d manifestedit.Diagnostic, what string) AcceptanceIssue { Kind: IssueImpureManagedFile, Path: d.Path, DocumentIndex: d.DocumentIndex, + // Split the file: the passenger document moves to one of its own. + Permanence: PermanenceFixable, + Actor: ActorAuthor, Message: fmt.Sprintf( "a file with managed resources may contain only valid KRM documents; document #%d is %s", d.DocumentIndex, what), @@ -406,6 +421,9 @@ func mixedFileRefusals(store *ManifestStore) []AcceptanceIssue { Kind: IssueMixedFile, Path: rd.Location.Path, DocumentIndex: rd.Location.DocumentIndex, + // Move the kustomization into its own file. + Permanence: PermanenceFixable, + Actor: ActorAuthor, Message: "managed resource " + identityRef(rd.Identity) + " must not live in the allowlisted build-directive file " + rd.Location.Path, }) @@ -442,13 +460,21 @@ func mappingRefusal(ref RecordRef, dm *DocumentModel, policy AcceptancePolicy) ( switch dm.Mapping { case MappingFollowable: if outOfScope(dm, policy) { + // Right kind, wrong namespace: only the person who owns the GitTarget can + // widen its scope, and their wizard may not have that person on the screen. return refusal(IssueOutOfScope, ref, - "followable kind out of this GitTarget's scope: "+identityRef(dm.ManifestIdentity)), true + "followable kind out of this GitTarget's scope: "+identityRef(dm.ManifestIdentity), + Classification{Permanence: PermanenceFixable, Actor: ActorPlatform}), true } return AcceptanceIssue{}, false case MappingNotFollowable: + // One code, two answers, and the registry is the only thing that knows which: + // a kind the cluster has never heard of is one CRD install away from working, + // while a kind that is served but ambiguous, or missing a verb, is not something + // anyone can fix from here. resolveMapping records the answer it saw. return refusal(IssueUnresolvedKRM, ref, - "KRM "+identityRef(dm.ManifestIdentity)+" is not a followable resource type"), true + "KRM "+identityRef(dm.ManifestIdentity)+" is not a followable resource type", + dm.MappingRefusal), true case MappingNoSource: // hasAPISource gates this call, so a lone no-source document among followable // ones is not judged on followability grounds. @@ -463,13 +489,16 @@ func outOfScope(dm *DocumentModel, policy AcceptancePolicy) bool { return policy.InScope != nil && dm.ResourceIdentity != nil && !policy.InScope(*dm.ResourceIdentity) } -// refusal builds a per-document refusal at the given reference. -func refusal(kind IssueKind, ref RecordRef, message string) AcceptanceIssue { +// refusal builds a per-document refusal at the given reference, carrying the +// classification its caller decided on. +func refusal(kind IssueKind, ref RecordRef, message string, class Classification) AcceptanceIssue { return AcceptanceIssue{ Kind: kind, Path: ref.FilePath, DocumentIndex: ref.DocumentIndex, Message: message, + Permanence: class.Permanence, + Actor: class.Actor, } } diff --git a/internal/manifestanalyzer/analyzer.go b/internal/manifestanalyzer/analyzer.go index 956babb5..b581e8a9 100644 --- a/internal/manifestanalyzer/analyzer.go +++ b/internal/manifestanalyzer/analyzer.go @@ -147,6 +147,12 @@ type AcceptanceIssue struct { Path string `json:"path"` DocumentIndex int `json:"documentIndex"` Message string `json:"message"` + // Permanence says whether this refusal can ever stop being one, and Actor names who + // can clear it when it is fixable. Both are set by the check that RAISED the issue, + // because only that check knows: several kinds classify differently depending on + // which branch emitted them. Empty means unclassified — say nothing about the future. + Permanence Permanence `json:"permanence,omitempty"` + Actor Actor `json:"actor,omitempty"` // Field and Token add structured diagnostics for a render-fidelity refusal. They are empty // for structural acceptance issues, which predate the render-vs-live gate. Field string `json:"field,omitempty"` @@ -539,18 +545,21 @@ func buildIssues( if duplicates[ref] { issues = append(issues, AcceptanceIssue{ Kind: IssueDuplicate, Path: f.Path, DocumentIndex: d.Index, - Message: "duplicate of " + identityRef(d.Identity), + Message: "duplicate of " + identityRef(d.Identity), + Permanence: PermanenceFixable, Actor: ActorAuthor, }) } switch d.Class { case ClassNonKRM: issues = append(issues, AcceptanceIssue{ Kind: IssueNonKRM, Path: f.Path, DocumentIndex: d.Index, - Message: "YAML is not a Kubernetes manifest", + Message: "YAML is not a Kubernetes manifest", + Permanence: PermanenceFixable, Actor: ActorAuthor, }) case ClassInvalidYAML: issues = append(issues, AcceptanceIssue{ Kind: IssueInvalidYAML, Path: f.Path, DocumentIndex: d.Index, Message: invalidMsgs[ref], + Permanence: PermanenceFixable, Actor: ActorAuthor, }) case ClassNonYAML, ClassEmpty, ClassKRM: // Not acceptance issues: ignored files, empty documents, and valid KRM. diff --git a/internal/manifestanalyzer/gittargetignore.go b/internal/manifestanalyzer/gittargetignore.go index 233dfe12..8ecdc656 100644 --- a/internal/manifestanalyzer/gittargetignore.go +++ b/internal/manifestanalyzer/gittargetignore.go @@ -274,6 +274,9 @@ func LoadGitTargetIgnore(content []byte) (*IgnoreMatcher, []AcceptanceIssue) { issues = append(issues, AcceptanceIssue{ Kind: IssueIgnoreShadowsManaged, Path: GitTargetIgnoreFileName, + // Narrow the pattern: the file that raised this is in the repository. + Permanence: PermanenceFixable, + Actor: ActorAuthor, Message: fmt.Sprintf( "%s pattern %q matches essentially every managed write path and would blind the "+ "operator to its own files; remove it and name only specific passengers", @@ -312,6 +315,13 @@ func foreignContentRefusals(store *ManifestStore) []AcceptanceIssue { Kind: foreignIssueKind(f.Kind), Path: f.Path, Message: foreignMessage(f), + // Permanence classifies the FOLDER's prospects, not the rule's. The refusal + // of symlinks and submodules is permanent and will not be relaxed, but the + // folder in front of the reader is one `git rm` (or one .gittargetignore + // line) from being adoptable — and that is the sentence this field exists to + // write. + Permanence: PermanenceFixable, + Actor: ActorAuthor, }) } return out diff --git a/internal/manifestanalyzer/kustomization_parse.go b/internal/manifestanalyzer/kustomization_parse.go index 7663023b..81b1eb2c 100644 --- a/internal/manifestanalyzer/kustomization_parse.go +++ b/internal/manifestanalyzer/kustomization_parse.go @@ -104,6 +104,12 @@ const ( featureMalformedReplicas = "malformed-replicas" ) +// featureRenderFailed is reported for a render root kustomize itself cannot build. It is +// not a parse-time feature — the store adds it when the build fails — but it travels with +// the parsed feature set so a refusal can classify it like any other construct: a build +// that fails is the author's file to fix, not the operator's boundary. +const featureRenderFailed = "render-failed" + // The patch shapes that are refused BY NAME. `patches:` is tolerated in exactly one shape — a // `path:` to a sparse KRM document inside the scanned tree — and everything else says so rather // than falling through into a folder we would then mishandle. diff --git a/internal/manifestanalyzer/permanence.go b/internal/manifestanalyzer/permanence.go new file mode 100644 index 00000000..286a3ffa --- /dev/null +++ b/internal/manifestanalyzer/permanence.go @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: Apache-2.0 + +package manifestanalyzer + +import "sort" + +// Permanence says whether a refusal can ever stop being one. It is set by the check that +// raised the refusal, because only that check knows: the same code can be a wait, a +// redesign, or a one-commit fix depending on which branch emitted it. +// +// Consumers MUST treat an unrecognised or absent value as PermanenceUnknown and say +// nothing about the future. +// +// The taxonomy is deliberately three-valued plus a zero value. Collapsing "fixable" and +// "pending" tells a user to go fix something no user can fix; dropping the zero value +// makes an unclassified path emit a confident wrong answer instead of silence. See +// docs/design/analyzer-consumer-contract-asks.md (Ask 1). +type Permanence string + +const ( + // PermanenceUnknown is the zero value: this refusal was not classified, so say + // nothing about whether it can stop being one. + PermanenceUnknown Permanence = "" + // PermanenceFixable means a change to the repository or the GitTarget clears it + // today. Actor names who can make that change. + PermanenceFixable Permanence = "fixable" + // PermanencePending means the operator does not support this yet and a future + // release may accept it. Nobody on either side can clear it today. + PermanencePending Permanence = "pending-upstream" + // PermanencePermanent means the support boundary: never a "not yet". + PermanencePermanent Permanence = "permanent" +) + +// Actor names who can act on a fixable refusal. It is empty when the refusal is not +// fixable, or when nobody can act. +type Actor string + +const ( + // ActorUnknown is the zero value: unclassified, or nobody can act. + ActorUnknown Actor = "" + // ActorAuthor is the person who owns the files in the repository. + ActorAuthor Actor = "repository-author" + // ActorPlatform is the person who owns the GitTarget — its scope, its path, and the + // CRDs installed in the cluster it mirrors. + ActorPlatform Actor = "platform-operator" +) + +// Classification is the pair a raise site attaches to a refusal. Passing them together +// keeps a fixable refusal from losing the actor half on its way out of the check. +type Classification struct { + Permanence Permanence + Actor Actor +} + +// classified stamps a computed classification onto an issue. Raise sites with one fixed +// answer set Permanence and Actor in the literal instead; this exists for the sites whose +// answer depends on which branch fired. +func (i AcceptanceIssue) classified(c Classification) AcceptanceIssue { + i.Permanence, i.Actor = c.Permanence, c.Actor + return i +} + +// kustomizeFeatureClassification maps every unsupported kustomization construct to its +// own permanence. This is the map the ask exists for: `unsupported-kustomize` is one code +// with three different answers, so classifying it per code would be a confident wrong +// sentence for two thirds of the constructs that raise it. +// +// The three groups: +// +// - author-fixable — the file itself is broken or reaches outside the tree. Nothing +// about the support boundary is in play; one commit clears it. +// - pending-upstream — a construct the operator could model later. A remote base and a +// Helm chart are read-only context whose adoption is the same shape as the +// render-root scoping that already shipped, so calling them permanent would repeat +// the mistake that retired ReasonOverlayFanOutUnsupported. +// - permanent — the constructs whose output the writer cannot map back to a source +// document at all: content synthesised at build time, fields rewritten after the +// source is read, or arbitrary plugin code. This is the same boundary +// LayoutRefusedStructural names. +// +// Every unmodelled kustomization field key must appear here. +// TestEveryUnsupportedKustomizeFeatureIsClassified walks kustomize's own Kustomization +// struct and fails when a field outside supportedKustomizationFields is unclassified, so +// a kustomize bump cannot quietly reintroduce PermanenceUnknown. +func kustomizeFeatureClassification() map[string]Classification { + return map[string]Classification{ + // The author's own file, fixable where it stands. + featureUnparseable: {Permanence: PermanenceFixable, Actor: ActorAuthor}, + featureMalformedImages: {Permanence: PermanenceFixable, Actor: ActorAuthor}, + featureMalformedReplicas: {Permanence: PermanenceFixable, Actor: ActorAuthor}, + featurePatchOutsideTree: {Permanence: PermanenceFixable, Actor: ActorAuthor}, + featureRenderFailed: {Permanence: PermanenceFixable, Actor: ActorAuthor}, + + // Read-only context, or merge semantics, we could model later. A remote base and an + // inflated chart are context the operator does not fetch yet, not content it can + // never own — calling them permanent would repeat the mistake that retired + // ReasonOverlayFanOutUnsupported. + featureRemoteBase: {Permanence: PermanencePending}, + "helmCharts": {Permanence: PermanencePending}, + "helmGlobals": {Permanence: PermanencePending}, + "helmChartInflationGenerator": {Permanence: PermanencePending}, + "configurations": {Permanence: PermanencePending}, + "crds": {Permanence: PermanencePending}, + "openapi": {Permanence: PermanencePending}, + // Deprecated spellings FixKustomization folds before this map is consulted. Seeing + // one here means kustomize stopped folding it, which is ours to fix, not the + // author's. + "bases": {Permanence: PermanencePending}, + "imageTags": {Permanence: PermanencePending}, + + // Build-time synthesis, post-read rewriting, and plugin code: no source document to + // route an edit back into. + featurePatchInline: {Permanence: PermanencePermanent}, + featurePatchJSON6902: {Permanence: PermanencePermanent}, + "components": {Permanence: PermanencePermanent}, + "configMapGenerator": {Permanence: PermanencePermanent}, + "secretGenerator": {Permanence: PermanencePermanent}, + "generators": {Permanence: PermanencePermanent}, + "transformers": {Permanence: PermanencePermanent}, + "replacements": {Permanence: PermanencePermanent}, + "vars": {Permanence: PermanencePermanent}, + "validators": {Permanence: PermanencePermanent}, + "namePrefix": {Permanence: PermanencePermanent}, + "nameSuffix": {Permanence: PermanencePermanent}, + "patchesStrategicMerge": {Permanence: PermanencePermanent}, + "patchesJson6902": {Permanence: PermanencePermanent}, + } +} + +// classifyKustomizeFeatures reduces the unsupported constructs one kustomization declares +// to the single classification the folder's prospects deserve. The most severe answer +// wins: a folder holding both a typo and a configMapGenerator is not fixed by fixing the +// typo, so telling its author to go fix something would be wrong. +// +// An empty or wholly unrecognised feature set degrades to PermanenceUnknown — silence — +// rather than to a guess. +func classifyKustomizeFeatures(features []string) Classification { + classes := kustomizeFeatureClassification() + out := Classification{} + for _, f := range features { + c, known := classes[f] + if !known { + continue + } + if permanenceSeverity(c.Permanence) > permanenceSeverity(out.Permanence) { + out = c + } + } + return out +} + +// The order the taxonomy is reduced in when one kustomization declares several +// unsupported constructs: the most severe answer is the honest one, because fixing the +// least severe leaves the folder exactly as unadoptable as it was. +const ( + severityUnknown = iota + severityFixable + severityPending + severityPermanent +) + +// permanenceSeverity orders the taxonomy for "most severe wins". +func permanenceSeverity(p Permanence) int { + switch p { + case PermanencePermanent: + return severityPermanent + case PermanencePending: + return severityPending + case PermanenceFixable: + return severityFixable + case PermanenceUnknown: + return severityUnknown + } + return severityUnknown +} + +// retainedUnsupportedFeatures names the constructs behind one unsupported retention: the +// parsed feature set, plus featureRenderFailed when kustomize could not build the root at +// all. It returns nothing for a supported retention, so the field is empty exactly when +// Unsupported is false. +func retainedUnsupportedFeatures(unsupported bool, doc *kustomizationDoc, buildFailed bool) []string { + if !unsupported { + return nil + } + var out []string + if doc != nil { + out = append(out, doc.features...) + } + if buildFailed { + out = append(out, featureRenderFailed) + } + sort.Strings(out) + return out +} diff --git a/internal/manifestanalyzer/permanence_test.go b/internal/manifestanalyzer/permanence_test.go new file mode 100644 index 00000000..f919bdbb --- /dev/null +++ b/internal/manifestanalyzer/permanence_test.go @@ -0,0 +1,510 @@ +// SPDX-License-Identifier: Apache-2.0 + +package manifestanalyzer + +import ( + "context" + "go/ast" + "go/parser" + "go/token" + "os" + "reflect" + "strconv" + "strings" + "testing" + "testing/fstest" + + kustypes "sigs.k8s.io/kustomize/api/types" + + "github.com/ConfigButler/gitops-reverser/internal/types" + "github.com/ConfigButler/gitops-reverser/internal/typeset" +) + +// This file is the test the ask is actually about. A permanence that is merely written +// down drifts exactly like the doc comments that caused the original bug: the axis went +// degenerate when a refusal was retired, and nothing said so. Two things are pinned here: +// +// - every IssueKind constant is classified, discovered from the SOURCE rather than from +// a list someone remembers to update, so a new kind fails this test; and +// - every EMISSION PATH is classified, because three kinds answer differently depending +// on which branch raised them. A per-constant test passes while a new branch quietly +// emits PermanenceUnknown, which reproduces the original bug one level down. +// +// See docs/design/analyzer-consumer-contract-asks.md (Ask 1). + +// classificationByKind is the settled table, in the form this package can check. A kind +// whose answer depends on the branch that raised it lists every branch it can produce, so +// this file never claims a single answer where the code has two. +var classificationByKind = map[IssueKind][]Classification{ + IssueInvalidYAML: {{Permanence: PermanenceFixable, Actor: ActorAuthor}}, + IssueDuplicate: {{Permanence: PermanenceFixable, Actor: ActorAuthor}}, + IssueImpureManagedFile: {{Permanence: PermanenceFixable, Actor: ActorAuthor}}, + IssueMixedFile: {{Permanence: PermanenceFixable, Actor: ActorAuthor}}, + IssueIgnoreShadowsManaged: {{Permanence: PermanenceFixable, Actor: ActorAuthor}}, + IssueNonKRM: {{Permanence: PermanenceFixable, Actor: ActorAuthor}}, + IssueForeignFile: {{Permanence: PermanenceFixable, Actor: ActorAuthor}}, + IssueForeignSymlink: {{Permanence: PermanenceFixable, Actor: ActorAuthor}}, + IssueForeignSubmodule: {{Permanence: PermanenceFixable, Actor: ActorAuthor}}, + IssueOutOfScope: {{Permanence: PermanenceFixable, Actor: ActorPlatform}}, + IssueWriteEscapesScope: {{Permanence: PermanenceFixable, Actor: ActorPlatform}}, + IssueRenderDoesNotMatchLive: {{Permanence: PermanenceFixable, Actor: ActorPlatform}}, + IssueWriteFanIn: {{Permanence: PermanencePending}}, + IssueUnplaceableEdit: {{Permanence: PermanencePermanent}}, + IssueRenderRefused: {{Permanence: PermanencePermanent}}, + // One code, three answers — the case that proves the whole ask. + IssueUnsupportedKustomize: { + {Permanence: PermanenceFixable, Actor: ActorAuthor}, + {Permanence: PermanencePending}, + {Permanence: PermanencePermanent}, + }, + // A kind nothing serves is one CRD install away; a kind that is served but ambiguous, + // denied, or missing a verb is not. + IssueUnresolvedKRM: { + {Permanence: PermanenceFixable, Actor: ActorPlatform}, + {Permanence: PermanencePending}, + }, +} + +// TestEveryIssueKindIsClassified reads the IssueKind constants out of this package's own +// source, so adding a kind and forgetting to classify it fails here rather than reaching a +// consumer as silence. It is the guard that keeps the table above honest. +func TestEveryIssueKindIsClassified(t *testing.T) { + kinds := issueKindConstants(t) + if len(kinds) < 15 { + t.Fatalf("found only %d IssueKind constants (%v); the source scan is broken, not the code", + len(kinds), kinds) + } + for _, kind := range kinds { + classes, ok := classificationByKind[IssueKind(kind)] + if !ok { + t.Errorf("IssueKind %q has no permanence classification: a consumer receiving it can only say "+ + "\"this folder cannot be picked\". Classify it at its raise site and add it here.", kind) + continue + } + for _, c := range classes { + assertCoherent(t, kind, c) + } + } +} + +// assertCoherent holds the two invariants every classification must satisfy: it says +// something, and it names an actor exactly when someone can act. +func assertCoherent(t *testing.T, what string, c Classification) { + t.Helper() + if c.Permanence == PermanenceUnknown { + t.Errorf("%q is classified PermanenceUnknown; refusals ship classified", what) + } + if c.Permanence == PermanenceFixable && c.Actor == ActorUnknown { + t.Errorf("%q is fixable but names no actor, so it renders as \"fix your repository\" to "+ + "someone who may not be able to", what) + } + if c.Permanence != PermanenceFixable && c.Actor != ActorUnknown { + t.Errorf("%q names an actor for a refusal nobody can act on", what) + } +} + +// issueKindConstants parses the package's non-test sources and returns the value of every +// constant declared with the IssueKind type. Reflection cannot enumerate constants, and a +// hand-kept list is the thing that drifted in the first place. +func issueKindConstants(t *testing.T) []string { + t.Helper() + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("read package directory: %v", err) + } + fset := token.NewFileSet() + var out []string + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, name, nil, 0) + if err != nil { + t.Fatalf("parse %s: %v", name, err) + } + out = append(out, issueKindsInFile(file)...) + } + return out +} + +// issueKindsInFile collects the string value of every `X IssueKind = "..."` const spec. +func issueKindsInFile(file *ast.File) []string { + var out []string + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if ok && gen.Tok == token.CONST { + out = append(out, issueKindsInDecl(gen)...) + } + } + return out +} + +// issueKindsInDecl collects the IssueKind-typed constants of one const block. +func issueKindsInDecl(gen *ast.GenDecl) []string { + var out []string + for _, spec := range gen.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + if ident, ok := vs.Type.(*ast.Ident); !ok || ident.Name != "IssueKind" { + continue + } + out = append(out, stringLiterals(vs.Values)...) + } + return out +} + +// stringLiterals unquotes every string-literal expression in values. +func stringLiterals(values []ast.Expr) []string { + var out []string + for _, value := range values { + lit, ok := value.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + if s, err := strconv.Unquote(lit.Value); err == nil { + out = append(out, s) + } + } + return out +} + +// wantIssue is one refusal a case expects, and the classification a consumer must see on +// it. A slice rather than a map keyed by kind: the point is what one emission path +// produces, never a claim about the whole enum. +type wantIssue struct { + kind IssueKind + class Classification +} + +// TestAcceptanceEmissionPathsAreClassified drives the real gate over folders that raise +// each structural refusal and asserts what a consumer would actually receive. It is the +// half a per-constant test cannot do: it fails when a NEW branch emits an existing kind +// without classifying it. +func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { + cases := map[string]struct { + fsys fstest.MapFS + mapper typeset.Lookup + policy AcceptancePolicy + want []wantIssue + }{ + "invalid yaml": { + fsys: fstest.MapFS{"broken.yaml": {Data: []byte(brokenYAML)}}, + want: []wantIssue{{ + kind: IssueInvalidYAML, + class: Classification{Permanence: PermanenceFixable, Actor: ActorAuthor}, + }}, + }, + "non-krm yaml": { + fsys: fstest.MapFS{"values.yaml": {Data: []byte(plainYAML)}}, + want: []wantIssue{{ + kind: IssueNonKRM, + class: Classification{Permanence: PermanenceFixable, Actor: ActorAuthor}, + }}, + }, + "duplicate identity": { + fsys: fstest.MapFS{ + "a.yaml": {Data: []byte(configMapCYAML)}, + "b.yaml": {Data: []byte(configMapCYAML)}, + }, + want: []wantIssue{{ + kind: IssueDuplicate, + class: Classification{Permanence: PermanenceFixable, Actor: ActorAuthor}, + }}, + }, + "impure managed file": { + fsys: fstest.MapFS{"mixed.yaml": {Data: []byte(deployYAML + "---\n" + plainYAML)}}, + want: []wantIssue{{ + kind: IssueImpureManagedFile, + class: Classification{Permanence: PermanenceFixable, Actor: ActorAuthor}, + }}, + }, + "managed resource in a build directive": { + fsys: fstest.MapFS{"kustomization.yaml": {Data: []byte(kustomizationY + "---\n" + deployYAML)}}, + policy: AcceptancePolicy{Allowlist: DefaultAllowlist()}, + want: []wantIssue{{ + kind: IssueMixedFile, + class: Classification{Permanence: PermanenceFixable, Actor: ActorAuthor}, + }}, + }, + "foreign file": { + fsys: fstest.MapFS{ + "deploy.yaml": {Data: []byte(deployYAML)}, + "notes.txt": {Data: []byte("scratch\n")}, + }, + want: []wantIssue{{ + kind: IssueForeignFile, + class: Classification{Permanence: PermanenceFixable, Actor: ActorAuthor}, + }}, + }, + "out of scope": { + fsys: fstest.MapFS{"deploy.yaml": {Data: []byte(deployYAML)}}, + mapper: typeset.NewSnapshotRegistry(sampleClusterSnapshot()), + policy: AcceptancePolicy{ + InScope: func(ri types.ResourceIdentifier) bool { return ri.Namespace == "kube-system" }, + }, + want: []wantIssue{{ + kind: IssueOutOfScope, + class: Classification{Permanence: PermanenceFixable, Actor: ActorPlatform}, + }}, + }, + // The two unresolved-krm branches, which is the split the raise site exists to + // record: a Widget nothing serves is a CRD the platform operator can install; a + // Secret the policy denies is not something anyone clears from here. + "unresolved krm, kind not served": { + fsys: fstest.MapFS{"w.yaml": {Data: []byte(widgetYAMLDoc)}}, + mapper: typeset.NewSnapshotRegistry(typeset.Snapshot{Generation: 1}), + want: []wantIssue{{ + kind: IssueUnresolvedKRM, + class: Classification{Permanence: PermanenceFixable, Actor: ActorPlatform}, + }}, + }, + "unresolved krm, kind served but not followable": { + fsys: fstest.MapFS{"secret.yaml": {Data: []byte(plainSecretYAML)}}, + mapper: typeset.NewSnapshotRegistry(sampleClusterSnapshot()), + want: []wantIssue{{ + kind: IssueUnresolvedKRM, + class: Classification{Permanence: PermanencePending}, + }}, + }, + // The three unsupported-kustomize branches, through the real gate. + "unsupported kustomize, the author's own file": { + fsys: fstest.MapFS{ + "kustomization.yaml": {Data: []byte("apiVersion: kustomize.config.k8s.io/v1beta1\n" + + "kind: Kustomization\nresources: [\n")}, + }, + policy: AcceptancePolicy{Allowlist: DefaultAllowlist()}, + want: []wantIssue{{ + kind: IssueUnsupportedKustomize, + class: Classification{Permanence: PermanenceFixable, Actor: ActorAuthor}, + }}, + }, + "unsupported kustomize, not supported yet": { + fsys: fstest.MapFS{ + "kustomization.yaml": {Data: []byte("apiVersion: kustomize.config.k8s.io/v1beta1\n" + + "kind: Kustomization\nhelmCharts:\n - name: podinfo\n repo: https://example.com\n")}, + }, + policy: AcceptancePolicy{Allowlist: DefaultAllowlist()}, + want: []wantIssue{{ + kind: IssueUnsupportedKustomize, + class: Classification{Permanence: PermanencePending}, + }}, + }, + "unsupported kustomize, the support boundary": { + fsys: fstest.MapFS{ + "kustomization.yaml": {Data: []byte("apiVersion: kustomize.config.k8s.io/v1beta1\n" + + "kind: Kustomization\nconfigMapGenerator:\n - name: settings\n literals:\n - a=b\n")}, + }, + policy: AcceptancePolicy{Allowlist: DefaultAllowlist()}, + want: []wantIssue{{ + kind: IssueUnsupportedKustomize, + class: Classification{Permanence: PermanencePermanent}, + }}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + store := buildStoreFS(context.Background(), tc.fsys, tc.mapper, tc.policy.Allowlist) + acc := Accept(store, tc.policy) + if acc.Accepted { + t.Fatalf("expected a refusal to classify, got an accepted folder") + } + for _, want := range tc.want { + assertIssueClassified(t, acc, want) + } + }) + } +} + +// assertIssueClassified asserts the gate raised the expected kind, and that a consumer +// reading it is told what it can do about it. +func assertIssueClassified(t *testing.T, acc Acceptance, want wantIssue) { + t.Helper() + for _, issue := range acc.Issues { + if issue.Kind != want.kind { + continue + } + if got := (Classification{Permanence: issue.Permanence, Actor: issue.Actor}); got != want.class { + t.Errorf("%s at %s: classification = %+v, want %+v", issue.Kind, issue.Path, got, want.class) + } + return + } + t.Fatalf("expected a %s refusal, got %+v", want.kind, acc.Issues) +} + +// TestForeignEntryRefusalsAreClassified covers the symlink and submodule raise sites, +// which no in-memory filesystem can produce. They are the rule the whole table follows: +// permanence classifies the FOLDER's prospects, not the rule's. We will never follow a +// symlink, yet the folder in front of the reader is one `git rm` from being adoptable. +func TestForeignEntryRefusalsAreClassified(t *testing.T) { + store := &ManifestStore{Foreign: []ForeignEntry{ + {Kind: ForeignSymlink, Path: "link.yaml"}, + {Kind: ForeignSubmodule, Path: "vendor"}, + {Kind: ForeignFile, Path: "notes.txt"}, + }} + issues := foreignContentRefusals(store) + if len(issues) != 3 { + t.Fatalf("want three foreign refusals, got %+v", issues) + } + want := Classification{Permanence: PermanenceFixable, Actor: ActorAuthor} + for _, issue := range issues { + if got := (Classification{Permanence: issue.Permanence, Actor: issue.Actor}); got != want { + t.Errorf("%s at %s: classification = %+v, want %+v", issue.Kind, issue.Path, got, want) + } + } +} + +// TestIgnoreShadowsManagedRefusalIsClassified covers the .gittargetignore parse-time +// raise site. +func TestIgnoreShadowsManagedRefusalIsClassified(t *testing.T) { + _, issues := LoadGitTargetIgnore([]byte("*\n")) + if len(issues) != 1 || issues[0].Kind != IssueIgnoreShadowsManaged { + t.Fatalf("want one ignore-shadows-managed refusal, got %+v", issues) + } + want := Classification{Permanence: PermanenceFixable, Actor: ActorAuthor} + if got := (Classification{Permanence: issues[0].Permanence, Actor: issues[0].Actor}); got != want { + t.Errorf("classification = %+v, want %+v", got, want) + } +} + +// TestEveryUnsupportedKustomizeFeatureIsClassified walks kustomize's OWN type: every +// field outside the modelled subset can refuse a folder by name, so every one of them +// must carry an answer. A kustomize bump that adds a field fails here instead of shipping +// a refusal that says nothing. +func TestEveryUnsupportedKustomizeFeatureIsClassified(t *testing.T) { + modelled := supportedKustomizationFields() + classes := kustomizeFeatureClassification() + kt := reflect.TypeOf(kustypes.Kustomization{}) + for i := range kt.NumField() { + field := kt.Field(i) + if _, ok := modelled[field.Name]; ok { + continue + } + key := kustomizationFieldKey(field) + if _, ok := classes[key]; !ok { + t.Errorf("unmodelled kustomization field %q (%s) has no permanence: it would refuse a folder "+ + "with no answer to \"can this ever be picked\".", key, field.Name) + } + } + for _, f := range []string{ + featureUnparseable, featureRemoteBase, featureMalformedImages, featureMalformedReplicas, + featurePatchInline, featurePatchJSON6902, featurePatchOutsideTree, featureRenderFailed, + } { + if _, ok := classes[f]; !ok { + t.Errorf("feature %q has no permanence classification", f) + } + } + for f, c := range classes { + assertCoherent(t, f, c) + } +} + +// TestClassifyKustomizeFeatures_MostSevereWins pins the reduction rule. A folder holding +// both a typo and a generator is not fixed by fixing the typo, so telling its author to go +// fix something would be a confident wrong sentence. +func TestClassifyKustomizeFeatures_MostSevereWins(t *testing.T) { + fixable := Classification{Permanence: PermanenceFixable, Actor: ActorAuthor} + pending := Classification{Permanence: PermanencePending} + permanent := Classification{Permanence: PermanencePermanent} + + cases := map[string]struct { + features []string + want Classification + }{ + "nothing at all": {nil, Classification{}}, + "unrecognised only": {[]string{"someFutureField"}, Classification{}}, + "the author's file": {[]string{featureUnparseable}, fixable}, + "not yet": {[]string{featureRemoteBase}, pending}, + "the boundary": {[]string{"configMapGenerator"}, permanent}, + "fixable and pending": {[]string{featureUnparseable, featureRemoteBase}, pending}, + "pending and boundary": {[]string{featureRemoteBase, "vars"}, permanent}, + "fixable and unknown": {[]string{"someFutureField", featureUnparseable}, fixable}, + "boundary over all": {[]string{featureUnparseable, featureRemoteBase, "replacements"}, permanent}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if got := classifyKustomizeFeatures(tc.features); got != tc.want { + t.Errorf("classifyKustomizeFeatures(%v) = %+v, want %+v", tc.features, got, tc.want) + } + }) + } +} + +// TestIssuesToReasons_CarriesClassification pins the projection a consumer of ScanRepo +// actually reads. issuesToReasons is the single choke point through which every issue +// becomes a refusal reason, so a check that classifies itself must arrive intact. +func TestIssuesToReasons_CarriesClassification(t *testing.T) { + reasons := issuesToReasons([]AcceptanceIssue{{ + Kind: IssueOutOfScope, + Path: "apps/web/deploy.yaml", + Message: "out of scope", + Permanence: PermanenceFixable, + Actor: ActorPlatform, + }}) + if len(reasons) != 1 { + t.Fatalf("want one reason, got %+v", reasons) + } + if reasons[0].Permanence != PermanenceFixable || reasons[0].Actor != ActorPlatform { + t.Errorf("reason = %+v, want the issue's own classification", reasons[0]) + } +} + +// refusedStructuralReasonIn returns the first refused-structural reason in a report. +func refusedStructuralReasonIn(rep RepoReport) (RefusalReason, bool) { + for _, cand := range rep.Candidates { + for _, reason := range cand.RefusalReasons { + if reason.Code == ReasonRefusedStructural { + return reason, true + } + } + } + return RefusalReason{}, false +} + +// TestRefusedStructuralIsClassified pins the one refusal reason that is not an IssueKind. +// It reads as the support boundary, and stays permanent whenever the constructs behind it +// are unknown — but a root refused for a construct we could support later says so, because +// the gate says so for the very same folder when a NESTED kustomization declares it. Two +// answers about one directory is the bug this whole document is about. +func TestRefusedStructuralIsClassified(t *testing.T) { + const kustHeader = "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources:\n - deploy.yaml\n" + + cases := map[string]struct { + kustomization string + want Classification + }{ + "a generator is the boundary": { + kustomization: kustHeader + "configMapGenerator:\n - name: settings\n literals:\n - a=b\n", + want: Classification{Permanence: PermanencePermanent}, + }, + "helm inflation is not supported yet": { + kustomization: kustHeader + "helmCharts:\n - name: podinfo\n repo: https://example.com\n", + want: Classification{Permanence: PermanencePending}, + }, + "an unparseable build file is the author's to fix": { + kustomization: "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources: [\n", + want: Classification{Permanence: PermanenceFixable, Actor: ActorAuthor}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + fsys := fstest.MapFS{ + "app/kustomization.yaml": {Data: []byte(tc.kustomization)}, + "app/deploy.yaml": {Data: []byte(deployYAML)}, + } + rep := scanRepoFS(context.Background(), fsys) + + reason, ok := refusedStructuralReasonIn(rep) + if !ok { + t.Fatalf("expected a refused-structural candidate, got %+v", rep.Candidates) + } + if got := (Classification{Permanence: reason.Permanence, Actor: reason.Actor}); got != tc.want { + t.Errorf("refused-structural reason = %+v, want %+v", reason, tc.want) + } + }) + } +} diff --git a/internal/manifestanalyzer/scan_repo.go b/internal/manifestanalyzer/scan_repo.go index b844fd67..c6cd7503 100644 --- a/internal/manifestanalyzer/scan_repo.go +++ b/internal/manifestanalyzer/scan_repo.go @@ -73,6 +73,12 @@ const ReasonRefusedStructural = "refused-structural" type RefusalReason struct { Code string `json:"code"` Detail string `json:"detail"` + // Permanence and Actor carry the refusal's own answer to "can this folder ever be + // picked, and by whom" — projected verbatim from the acceptance issue that raised it, + // or set by the raise site for the reasons that are not issue kinds. Empty means + // unclassified; say nothing about the future. + Permanence Permanence `json:"permanence,omitempty"` + Actor Actor `json:"actor,omitempty"` } // ResourceCounts splits the KRM a candidate covers into what it renders versus what it @@ -228,10 +234,7 @@ func classifyRenderRoot( if doc := kusts[rootDir]; doc == nil || doc.unsupported { c.Layout = LayoutRefusedStructural c.AcceptedByOperator = false - c.RefusalReasons = []RefusalReason{{ - Code: ReasonRefusedStructural, - Detail: refusedStructuralDetail(kusts[rootDir], kustContent[rootDir]), - }} + c.RefusalReasons = []RefusalReason{refusedStructuralReason(kusts[rootDir], kustContent[rootDir])} c.Resources = countResources(store, rootDir, rendered) return c } @@ -395,12 +398,19 @@ func setContains(set map[string]struct{}, key string) bool { func candidateAcceptance(ctx context.Context, fsys fs.FS, dir string) Acceptance { sub, err := fs.Sub(fsys, dir) if err != nil { - return Acceptance{Issues: []AcceptanceIssue{{Kind: IssueForeignFile, Path: dir, Message: err.Error()}}} + return Acceptance{Issues: []AcceptanceIssue{{ + Kind: IssueForeignFile, Path: dir, Message: err.Error(), + Permanence: PermanenceFixable, Actor: ActorAuthor, + }}} } policy := ScanPolicy{Acceptance: AcceptancePolicy{Allowlist: WriterAllowlist()}} return Scan(ctx, sub, nil, nil, policy).Acceptance } +// issuesToReasons is the projection for the permanence pair as well as the code: it is +// already the single choke point through which every acceptance issue becomes a refusal +// reason, so a check that classifies itself reaches a consumer without any second table. +// // issuesToReasons projects acceptance-gate issues into refusal reasons so a refused plain // or self-contained kustomize candidate reports WHY — duplicate identity, non-KRM YAML, a // foreign file, a mixed build-directive file, an unsupported nested kustomization — not @@ -413,7 +423,12 @@ func issuesToReasons(issues []AcceptanceIssue) []RefusalReason { if iss.Path != "" { detail = iss.Path + ": " + iss.Message } - out = append(out, RefusalReason{Code: string(iss.Kind), Detail: detail}) + out = append(out, RefusalReason{ + Code: string(iss.Kind), + Detail: detail, + Permanence: iss.Permanence, + Actor: iss.Actor, + }) } return out } @@ -508,6 +523,33 @@ func reachedResourceFiles(kusts map[string]*kustomizationDoc) map[string]struct{ return out } +// refusedStructuralReason builds the render-root refusal, classified by the constructs +// that caused it rather than by the code. +// +// The code itself reads as the support boundary — it MEANS "the writer cannot map this +// render root back to editable source" — and permanent is the answer whenever the +// constructs are unknown. But the same folder is also judged construct by construct when +// the gate refuses a NESTED kustomization (IssueUnsupportedKustomize), and the two +// surfaces must not hand a consumer two different answers about one directory: a root +// refused for helmCharts would read "pending-upstream" through one and "permanent" +// through the other. Classifying both from the same feature set is what keeps them +// agreeing, and it follows the rule the whole table follows — classify the folder's +// prospects, not the rule's. +func refusedStructuralReason(doc *kustomizationDoc, content []byte) RefusalReason { + reason := RefusalReason{ + Code: ReasonRefusedStructural, + Detail: refusedStructuralDetail(doc, content), + Permanence: PermanencePermanent, + } + if doc == nil { + return reason + } + if class := classifyKustomizeFeatures(doc.features); class.Permanence != PermanenceUnknown { + reason.Permanence, reason.Actor = class.Permanence, class.Actor + } + return reason +} + // refusedStructuralDetail names the specific unsupported kustomize features so the // refusal is actionable, not a bare "refused". The features come off the parsed doc, which is // the same judgement the acceptance gate reads, so the scan and the operator cannot drift. diff --git a/internal/manifestanalyzer/store.go b/internal/manifestanalyzer/store.go index c0fef1bb..d74d9684 100644 --- a/internal/manifestanalyzer/store.go +++ b/internal/manifestanalyzer/store.go @@ -137,6 +137,13 @@ type RetainedDocument struct { // The acceptance gate refuses either (IssueUnsupportedKustomize) rather than writing // into content it cannot safely manage. Only ever set on a whole-file retention. Unsupported bool + // UnsupportedFeatures names the constructs that made this retention unsupported, as + // the user wrote them ("configMapGenerator", "remote-base", "render-failed"). It + // carries the parse-time answer to the refusal, which is what lets the acceptance + // gate classify the refusal's permanence per construct instead of per code — one code + // with three answers is the whole reason the permanence field exists. Sorted; empty + // when Unsupported is false. + UnsupportedFeatures []string } // FileModel is one managed file under the scanned root. Its document set and @@ -224,6 +231,13 @@ type DocumentModel struct { // because no API source is wired in. Mapping MappingOutcome + // MappingRefusal carries the permanence of a MappingNotFollowable outcome, decided + // where the registry answer is still in hand. A GVK the registry has never heard of + // is a CRD the platform operator can install; a GVK it knows but will not follow — + // ambiguous, unserved, missing a verb — is not something either side clears today. + // The acceptance gate sees only the outcome, so the split has to be recorded here. + MappingRefusal Classification + // Editable is false for SOPS-encrypted or otherwise non-patchable documents; // Cause carries the structured reason. Editable bool @@ -444,10 +458,12 @@ func buildStore( // write-fan-in guard, which needs the render to see the shared file. _, buildFailed := renderFailures[filepathToSlash(f.Path)] doc := kusts[slashDir(f.Path)] + unsupported := isKustomizationFile(f.Path) && + ((doc != nil && doc.unsupported) || buildFailed) store.Retained = append(store.Retained, RetainedDocument{ - Location: manifestedit.Location{Path: f.Path}, - Unsupported: isKustomizationFile(f.Path) && - ((doc != nil && doc.unsupported) || buildFailed), + Location: manifestedit.Location{Path: f.Path}, + Unsupported: unsupported, + UnsupportedFeatures: retainedUnsupportedFeatures(unsupported, doc, buildFailed), }) } } @@ -987,6 +1003,15 @@ func (s *ManifestStore) resolveMapping( } record, known := lookup.ByGVK(gvk) + if !known { + // Nothing serves this GVK: install the CRD (or widen what the registry may see) + // and the same folder is adoptable, so this is fixable by the platform operator. + dm.MappingRefusal = Classification{Permanence: PermanenceFixable, Actor: ActorPlatform} + } else if !record.Followable() { + // Served, but ambiguous, denied, or missing a verb. Nobody clears that from + // here; a future release deciding to follow it is the only way out. + dm.MappingRefusal = Classification{Permanence: PermanencePending} + } if known && record.Followable() { dm.Mapping = MappingFollowable namespaced := record.Identity.Scope == typeset.ScopeNamespaced diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/helm-inflation.golden.json b/internal/manifestanalyzer/testdata/scan-repo/unsupported/helm-inflation.golden.json index 9804be62..8e0faba0 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/unsupported/helm-inflation.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/unsupported/helm-inflation.golden.json @@ -7,7 +7,8 @@ "refusalReasons": [ { "code": "refused-structural", - "detail": "kustomization uses unsupported feature(s): helmCharts" + "detail": "kustomization uses unsupported feature(s): helmCharts", + "permanence": "pending-upstream" } ], "renderRoot": true, diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json b/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json index 740c8806..8de75098 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json @@ -7,7 +7,9 @@ "refusalReasons": [ { "code": "non-krm-yaml", - "detail": "values.yaml: YAML is not a Kubernetes manifest" + "detail": "values.yaml: YAML is not a Kubernetes manifest", + "permanence": "fixable", + "actor": "repository-author" } ], "renderRoot": false, diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/unsupported-kustomize.golden.json b/internal/manifestanalyzer/testdata/scan-repo/unsupported/unsupported-kustomize.golden.json index faec48e8..ec841a0d 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/unsupported/unsupported-kustomize.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/unsupported/unsupported-kustomize.golden.json @@ -7,7 +7,8 @@ "refusalReasons": [ { "code": "refused-structural", - "detail": "kustomization uses unsupported feature(s): components, crds, namePrefix, openapi, secretGenerator" + "detail": "kustomization uses unsupported feature(s): components, crds, namePrefix, openapi, secretGenerator", + "permanence": "permanent" } ], "renderRoot": true, From 391af733f93f945b6e1126e686530ceabf71c35d Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 27 Jul 2026 06:50:06 +0000 Subject: [PATCH 02/15] feat(manifestanalyzer)!: publish the permanence pair and every refusal code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine defines seventeen refusal kinds; this package re-declared fourteen. Since the projection copies the internal string through verbatim, three codes could reach a consumer that had no exported constant to match on — a consumer doing the correct thing, matching on our published constants, silently failed to recognise all three. All seventeen are now published, and a test reads both sets out of the source so the counts cannot disagree again. Issue and RefusalReason gain Permanence and Actor, so a tool can tell a wait from a redesign from a one-commit fix without inferring it from the code. RefusalReason.Code is now typed IssueKind rather than string, which makes "a candidate's refusal is the acceptance gate's own issue" compile-checked rather than merely stated. The const block above it claimed to enumerate the codes a candidate may carry while listing two of eighteen, one of them retired; it now says what it actually holds — the codes that are NOT issue kinds. BREAKING CHANGE: RefusalReason.Code is typed IssueKind, not string. Co-Authored-By: Claude Opus 5 --- pkg/manifestanalyzer/contract_test.go | 143 ++++++++++++++++++++++++++ pkg/manifestanalyzer/folder.go | 111 ++++++++++++++++---- pkg/manifestanalyzer/repo.go | 86 +++++++++++----- 3 files changed, 298 insertions(+), 42 deletions(-) create mode 100644 pkg/manifestanalyzer/contract_test.go diff --git a/pkg/manifestanalyzer/contract_test.go b/pkg/manifestanalyzer/contract_test.go new file mode 100644 index 00000000..8ffdf2dc --- /dev/null +++ b/pkg/manifestanalyzer/contract_test.go @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: Apache-2.0 + +package manifestanalyzer_test + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/require" + + "github.com/ConfigButler/gitops-reverser/pkg/manifestanalyzer" +) + +// The engine defined seventeen refusal kinds and this package re-declared fourteen. Since +// the projection copies the internal string through verbatim, the missing three could +// reach a consumer that had no exported constant to match on — a consumer doing the +// correct thing, matching on our published constants, silently failed to recognise them. +// +// This test makes the two counts unable to disagree again. It reads both sets out of the +// source, because a constant cannot be enumerated by reflection and a hand-kept list is +// exactly what drifted. +// +// See docs/design/analyzer-consumer-contract-asks.md (Ask 1). +func TestPublicIssueKindsCoverEveryEngineKind(t *testing.T) { + t.Parallel() + + engine := issueKindConstants(t, "../../internal/manifestanalyzer") + published := issueKindConstants(t, ".") + + require.GreaterOrEqual(t, len(engine), 15, "the source scan is broken, not the code") + for _, kind := range engine { + require.Contains(t, published, kind, + "the engine can raise %q but this package publishes no constant for it, so a consumer "+ + "matching on our constants cannot recognise it", kind) + } + for _, kind := range published { + if kind == string(manifestanalyzer.ReasonRefusedStructural) || + kind == string(manifestanalyzer.ReasonOverlayFanOutUnsupported) { + continue // reason codes that are deliberately not issue kinds + } + require.Contains(t, engine, kind, + "this package publishes %q but nothing can raise it", kind) + } +} + +// issueKindConstants returns the string value of every `X IssueKind = "..."` constant +// declared in the package at dir. +func issueKindConstants(t *testing.T, dir string) []string { + t.Helper() + entries, err := os.ReadDir(dir) + require.NoError(t, err, "read %s", dir) + + fset := token.NewFileSet() + var out []string + for _, entry := range entries { + name := entry.Name() + if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + file, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0) + require.NoError(t, err, "parse %s", name) + for _, decl := range file.Decls { + gen, ok := decl.(*ast.GenDecl) + if !ok || gen.Tok != token.CONST { + continue + } + out = append(out, issueKindsInDecl(gen)...) + } + } + return out +} + +func issueKindsInDecl(gen *ast.GenDecl) []string { + var out []string + for _, spec := range gen.Specs { + vs, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + if ident, ok := vs.Type.(*ast.Ident); !ok || ident.Name != "IssueKind" { + continue + } + for _, value := range vs.Values { + lit, ok := value.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + if s, err := strconv.Unquote(lit.Value); err == nil { + out = append(out, s) + } + } + } + return out +} + +// A refusal the consumer receives must say whether it can stop being one, and name who can +// clear it when it can. This is the whole ask, checked at the surface a consumer actually +// reads rather than at the raise site. +func TestScanFolder_RefusalsCarryPermanence(t *testing.T) { + t.Parallel() + + report := manifestanalyzer.ScanFolderFS(t.Context(), refusedTree()) + require.False(t, report.Status.Accepted) + require.NotEmpty(t, report.Status.Issues) + + for _, issue := range report.Status.Issues { + require.NotEqual(t, manifestanalyzer.PermanenceUnknown, issue.Permanence, + "%s reached a consumer unclassified", issue.Kind) + if issue.Permanence == manifestanalyzer.PermanenceFixable { + require.NotEqual(t, manifestanalyzer.ActorUnknown, issue.Actor, + "%s is fixable but does not say by whom", issue.Kind) + } else { + require.Equal(t, manifestanalyzer.ActorUnknown, issue.Actor, + "%s names an actor for a refusal nobody can act on", issue.Kind) + } + } +} + +// Version is never empty, so a consumer may read an ABSENT generator as "produced before +// this field shipped" rather than having to tell that apart from a build that did not know +// itself. +func TestVersion_IsNeverEmpty(t *testing.T) { + t.Parallel() + require.NotEmpty(t, manifestanalyzer.Version()) +} + +// refusedTree is a folder the operator refuses for two different reasons at once — a +// stray non-KRM document and a kustomization it cannot map back to source — so the test +// sees more than one classification path. +func refusedTree() fstest.MapFS { + return fstest.MapFS{ + "kustomization.yaml": {Data: []byte(kustomizationHelmYAML)}, + "notes.txt": {Data: []byte("scratch\n")}, + "values.yaml": {Data: []byte("replicaCount: 2\n")}, + } +} diff --git a/pkg/manifestanalyzer/folder.go b/pkg/manifestanalyzer/folder.go index 527d30a3..c7f0327b 100644 --- a/pkg/manifestanalyzer/folder.go +++ b/pkg/manifestanalyzer/folder.go @@ -11,15 +11,14 @@ import ( internalanalyzer "github.com/ConfigButler/gitops-reverser/internal/manifestanalyzer" ) -// SchemaVersion identifies the JSON contract [FolderReport] and [RepoReport] marshal to. -// It is a marker, not a promise: while the project is pre-1.0 the contract may change -// under a consumer, with or without a bump. Adding a field never bumps it, so consumers -// must ignore fields they do not know. -const SchemaVersion = "v1" - // IssueKind classifies why a folder was not accepted. The values are the operator's own // refusal codes. They are the part of an [Issue] worth matching on, and they change less // often than the surrounding shape — but pre-1.0 they can still change. +// +// The set below is complete: every kind the operator can raise is declared here, including +// the three that only a live write path emits (they reach a consumer through GitTarget +// status rather than through [ScanFolder] or [ScanRepo], which are structure-only). A +// consumer matching on these constants can recognise every code it will ever be handed. type IssueKind string const ( @@ -59,6 +58,56 @@ const ( // IssueWriteFanIn marks an in-place edit of a source file that more than one kustomize // render root reaches. IssueWriteFanIn IssueKind = "write-fan-in" + // IssueRenderRefused marks a planned write kustomize itself will not vouch for: the + // flush was re-rendered with the write applied, and the result was not the live + // object. Only a live write raises it; a structure-only scan never can. + IssueRenderRefused IssueKind = "kustomize-render-refused" + // IssueRenderDoesNotMatchLive marks a rendered ${...} value whose corresponding live + // field is absent or different. Only a live write raises it. + IssueRenderDoesNotMatchLive IssueKind = "render-does-not-match-live" + // IssueUnplaceableEdit marks a live change the writer could not place in any source + // document. Only a live write raises it. + IssueUnplaceableEdit IssueKind = "unplaceable-edit" +) + +// Permanence says whether a refusal can ever stop being one. It is set by the check that +// raised the refusal, because only that check knows — several codes classify differently +// depending on which branch emitted them, which is exactly why this is a field on the +// emitted value and not a table beside the constants. +// +// Consumers MUST treat an unrecognised or absent value as [PermanenceUnknown] and say +// nothing about the future. "Not supported yet" is a wait, "cannot be synced" is a +// redesign, and "fix your YAML" is neither; a code alone cannot tell them apart. +type Permanence string + +const ( + // PermanenceUnknown is the zero value: not classified. Say nothing. + PermanenceUnknown Permanence = "" + // PermanenceFixable means changing the repository or the GitTarget clears it today. + // [Actor] says who can make that change. + PermanenceFixable Permanence = "fixable" + // PermanencePending means a future release may accept it. Nobody can clear it today. + PermanencePending Permanence = "pending-upstream" + // PermanencePermanent is the support boundary: never a "not yet". + PermanencePermanent Permanence = "permanent" +) + +// Actor names who can act on a fixable refusal. It is empty when the refusal is not +// fixable, or when nobody can act. +// +// It matters because some refusals are fixable ONLY by the person who owns the GitTarget, +// who is often not the person reading the message: rendering an out-of-scope refusal as +// "fix your repository" to a repository author who cannot is worse than saying nothing. +type Actor string + +const ( + // ActorUnknown is the zero value: unclassified, or nobody can act. + ActorUnknown Actor = "" + // ActorAuthor is the person who owns the files in the repository. + ActorAuthor Actor = "repository-author" + // ActorPlatform is the person who owns the GitTarget — its scope, its path, and the + // CRDs installed in the cluster it mirrors. + ActorPlatform Actor = "platform-operator" ) // Issue is one reason a folder is not accepted, or one fact a stricter policy may treat @@ -72,6 +121,10 @@ type Issue struct { DocumentIndex int `json:"documentIndex"` // Message is a human-readable explanation. It is not a stable string. Message string `json:"message"` + // Permanence is empty when the check did not classify itself. + Permanence Permanence `json:"permanence,omitempty"` + // Actor is empty unless Permanence is [PermanenceFixable]. + Actor Actor `json:"actor,omitempty"` } // Identity names one Kubernetes document as it appears in the file. @@ -97,12 +150,28 @@ type RetainedDocument struct { Unsupported bool `json:"unsupported,omitempty"` } -// FolderReport answers "may this folder become a GitTarget?". +// FolderReport answers "may this folder become a GitTarget?". It is a KRM document: the +// scan REQUEST is the spec, what was FOUND is the status. See [TypeMeta] for why the +// envelope, and why the document is never served or applyable. type FolderReport struct { - SchemaVersion string `json:"schemaVersion"` - // Root is the scanned folder as passed to ScanFolder. Informational; empty for - // ScanFolderFS. + TypeMeta `json:",inline"` + + Spec FolderReportSpec `json:"spec"` + Status FolderReportStatus `json:"status"` +} + +// FolderReportSpec is the scan that was asked for. +type FolderReportSpec struct { + // Root is the scanned folder as passed to ScanFolder. Empty for ScanFolderFS. Root string `json:"root,omitempty"` + // Mode is always [ModeScanFolder]. + Mode string `json:"mode"` +} + +// FolderReportStatus is what the scan found. +type FolderReportStatus struct { + // Generator names the build that produced this report. Never empty. + Generator Generator `json:"generator"` // Accepted is the gate decision. When false, Issues says why. Accepted bool `json:"accepted"` // Issues is empty when Accepted, and never nil in the marshaled JSON. @@ -122,7 +191,7 @@ func ScanFolder(ctx context.Context, root string) (FolderReport, error) { return FolderReport{}, err } report := folderReportFrom(result.Acceptance) - report.Root = root + report.Spec.Root = root return report, nil } @@ -136,8 +205,8 @@ func ScanFolderFS(ctx context.Context, fsys fs.FS) FolderReport { // WriteJSON writes the report as indented JSON — byte-for-byte what // `manifest-analyzer --mode scan-folder --format json` prints. func (r FolderReport) WriteJSON(w io.Writer) error { - if r.Issues == nil { - r.Issues = []Issue{} + if r.Status.Issues == nil { + r.Status.Issues = []Issue{} } enc := json.NewEncoder(w) enc.SetIndent("", " ") @@ -159,16 +228,22 @@ func folderScanPolicy() internalanalyzer.ScanPolicy { // stay still while the internal one moves. func folderReportFrom(acc internalanalyzer.Acceptance) FolderReport { report := FolderReport{ - SchemaVersion: SchemaVersion, - Accepted: acc.Accepted, - Issues: make([]Issue, 0, len(acc.Issues)), + TypeMeta: TypeMeta{APIVersion: APIVersion, Kind: KindFolderReport}, + Spec: FolderReportSpec{Mode: ModeScanFolder}, + Status: FolderReportStatus{ + Generator: generator(), + Accepted: acc.Accepted, + Issues: make([]Issue, 0, len(acc.Issues)), + }, } for _, issue := range acc.Issues { - report.Issues = append(report.Issues, Issue{ + report.Status.Issues = append(report.Status.Issues, Issue{ Kind: IssueKind(issue.Kind), Path: issue.Path, DocumentIndex: issue.DocumentIndex, Message: issue.Message, + Permanence: Permanence(issue.Permanence), + Actor: Actor(issue.Actor), }) } for _, rd := range acc.Retained { @@ -185,7 +260,7 @@ func folderReportFrom(acc internalanalyzer.Acceptance) FolderReport { Name: rd.Identity.Name, } } - report.Retained = append(report.Retained, doc) + report.Status.Retained = append(report.Status.Retained, doc) } return report } diff --git a/pkg/manifestanalyzer/repo.go b/pkg/manifestanalyzer/repo.go index b40a7a05..1b1a1228 100644 --- a/pkg/manifestanalyzer/repo.go +++ b/pkg/manifestanalyzer/repo.go @@ -32,7 +32,11 @@ const ( LayoutRefusedStructural Layout = "refused-structural" ) -// Refusal reason codes a candidate may carry. +// Refusal reason codes that are NOT [IssueKind] values. This block is not the +// enumeration of what [RefusalReason.Code] can hold — it is the two codes that come from +// somewhere other than the acceptance gate. Every other code a candidate carries is an +// [IssueKind], because that is where it came from: the gate raised an issue, and +// RefusalReason is that issue projected one level up. const ( // ReasonOverlayFanOutUnsupported was the forward-looking refusal for an external-base // overlay. Render-root scoping shipped, so the scanner now ADOPTS such an overlay and no @@ -41,16 +45,25 @@ const ( // it owns); an overlay refused for a real fault carries that fault's own code. // // Deprecated: no longer emitted; kept for source compatibility. - ReasonOverlayFanOutUnsupported = "overlay-fan-out-unsupported" - // ReasonRefusedStructural is the permanent support boundary. - ReasonRefusedStructural = "refused-structural" + ReasonOverlayFanOutUnsupported IssueKind = "overlay-fan-out-unsupported" + // ReasonRefusedStructural is the permanent support boundary: a render root whose + // kustomization uses a construct the writer cannot map back to editable source. + ReasonRefusedStructural IssueKind = "refused-structural" ) // RefusalReason is one machine-readable reason a candidate is not accepted. type RefusalReason struct { - Code string `json:"code"` + // Code is an [IssueKind] value, or [ReasonRefusedStructural]. The type makes that + // relationship compile-checked rather than merely stated: a candidate's refusal is + // the acceptance gate's own issue, projected up. + Code IssueKind `json:"code"` // Detail is human-readable and not a stable string. Detail string `json:"detail"` + // Permanence says whether this refusal can ever stop being one. Empty when the check + // that raised it did not classify itself; treat that as "say nothing". + Permanence Permanence `json:"permanence,omitempty"` + // Actor is empty unless Permanence is [PermanenceFixable]. + Actor Actor `json:"actor,omitempty"` } // ResourceCounts splits the KRM a candidate covers into what it renders versus what it @@ -104,11 +117,28 @@ type RepoSummary struct { UnsupportedConstructs []string `json:"unsupportedConstructs,omitempty"` } -// RepoReport answers "which folders in this repository could become GitTargets?". +// RepoReport answers "which folders in this repository could become GitTargets?". It is a +// KRM document: the scan REQUEST is the spec, what was FOUND is the status. See +// [TypeMeta] for why the envelope, and why the document is never served or applyable. type RepoReport struct { - SchemaVersion string `json:"schemaVersion"` - // Root is the scanned repository root as passed to ScanRepo. Informational. - Root string `json:"root,omitempty"` + TypeMeta `json:",inline"` + + Spec RepoReportSpec `json:"spec"` + Status RepoReportStatus `json:"status"` +} + +// RepoReportSpec is the scan that was asked for. +type RepoReportSpec struct { + // Root is the scanned repository root as passed to ScanRepo. + Root string `json:"root,omitempty"` + // Mode is always [ModeScanRepo]. + Mode string `json:"mode"` +} + +// RepoReportStatus is what the scan found. +type RepoReportStatus struct { + // Generator names the build that produced this report. Never empty. + Generator Generator `json:"generator"` Candidates []Candidate `json:"candidates"` Summary RepoSummary `json:"summary"` } @@ -127,8 +157,8 @@ func ScanRepo(ctx context.Context, root string) (RepoReport, error) { // WriteJSON writes the report as indented JSON — byte-for-byte what // `manifest-analyzer --mode scan-repo --format json` prints. func (r RepoReport) WriteJSON(w io.Writer) error { - if r.Candidates == nil { - r.Candidates = []Candidate{} + if r.Status.Candidates == nil { + r.Status.Candidates = []Candidate{} } enc := json.NewEncoder(w) enc.SetIndent("", " ") @@ -138,27 +168,30 @@ func (r RepoReport) WriteJSON(w io.Writer) error { // repoReportFrom projects the internal discovery report onto the public contract. func repoReportFrom(rep internalanalyzer.RepoReport) RepoReport { out := RepoReport{ - SchemaVersion: SchemaVersion, - Root: rep.Root, - Candidates: make([]Candidate, 0, len(rep.Candidates)), - Summary: RepoSummary{ - CandidatesByLayout: make(map[Layout]int, len(rep.Summary.CandidatesByLayout)), - Accepted: rep.Summary.Accepted, - Refused: rep.Summary.Refused, - FleetRoot: rep.Summary.FleetRoot, - UnsupportedConstructs: rep.Summary.UnsupportedConstructs, + TypeMeta: TypeMeta{APIVersion: APIVersion, Kind: KindRepoReport}, + Spec: RepoReportSpec{Root: rep.Root, Mode: ModeScanRepo}, + Status: RepoReportStatus{ + Generator: generator(), + Candidates: make([]Candidate, 0, len(rep.Candidates)), + Summary: RepoSummary{ + CandidatesByLayout: make(map[Layout]int, len(rep.Summary.CandidatesByLayout)), + Accepted: rep.Summary.Accepted, + Refused: rep.Summary.Refused, + FleetRoot: rep.Summary.FleetRoot, + UnsupportedConstructs: rep.Summary.UnsupportedConstructs, + }, }, } for layout, n := range rep.Summary.CandidatesByLayout { - out.Summary.CandidatesByLayout[Layout(layout)] = n + out.Status.Summary.CandidatesByLayout[Layout(layout)] = n } for _, conflict := range rep.Summary.OverlapConflicts { - out.Summary.OverlapConflicts = append(out.Summary.OverlapConflicts, OverlapConflict{ + out.Status.Summary.OverlapConflicts = append(out.Status.Summary.OverlapConflicts, OverlapConflict{ Ancestor: conflict.Ancestor, Descendant: conflict.Descendant, }) } for _, cand := range rep.Candidates { - out.Candidates = append(out.Candidates, candidateFrom(cand)) + out.Status.Candidates = append(out.Status.Candidates, candidateFrom(cand)) } return out } @@ -179,7 +212,12 @@ func candidateFrom(cand internalanalyzer.RepoCandidate) Candidate { OverlapsWith: cand.OverlapsWith, } for _, reason := range cand.RefusalReasons { - out.RefusalReasons = append(out.RefusalReasons, RefusalReason{Code: reason.Code, Detail: reason.Detail}) + out.RefusalReasons = append(out.RefusalReasons, RefusalReason{ + Code: IssueKind(reason.Code), + Detail: reason.Detail, + Permanence: Permanence(reason.Permanence), + Actor: Actor(reason.Actor), + }) } return out } From 69d8785b877558ab22b171780d9ed309bbfba594 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 27 Jul 2026 06:50:06 +0000 Subject: [PATCH 03/15] feat(manifestanalyzer)!: the report is a KRM document that says what produced it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A JSON document from a binary is a contract with no compile-time signal. A tool that execs the analyzer holds the document and nothing else: it could not tell which release produced it, and our own SchemaVersion never said what a bump asserts or whether a reader should refuse a version it does not know. Both reports are now KRM documents — apiVersion and kind, the scan request in spec, the findings in status — so those three questions are answered by citing the Kubernetes API conventions instead of writing a bespoke policy. apiVersion replaces schemaVersion outright. The document is never served, never registered, and carries no metadata: it observes a path at an instant and has no identity. status.generator names the build ({name, version}), never empty: ldflags when the release build sets them, otherwise the module version runtime/debug records for `go install ...@vX.Y.Z`, otherwise "dev". A bare version string would say which release without saying which tool, and a piped report is where that bites. manifest-analyzer --version prints that release and the report apiVersion together, so one exec answers both pin questions rather than two execs and an assumption they were the same binary. The release job builds, checksums, signs and attests the binary for linux/amd64, linux/arm64 and darwin/arm64. Both verification mechanisms ship: `gh attestation verify` is the stronger claim, and a sha256sums file is what a `curl | sha256sum -c` line in a Dockerfile can consume without a token. The job also asserts the built binary reports the release it was built from, so a broken ldflags path fails the release instead of shipping a binary that lies about itself. BREAKING CHANGE: schemaVersion is replaced by apiVersion/kind, and the report fields move under spec/status. Co-Authored-By: Claude Opus 5 --- .coverage-baseline | 2 +- .github/workflows/release.yml | 110 ++++++++++++++++++++++- cmd/manifest-analyzer/main.go | 22 ++++- cmd/manifest-analyzer/main_test.go | 71 ++++++++++++--- docs/UPGRADING.md | 64 ++++++++++++++ pkg/manifestanalyzer/contract.go | 132 ++++++++++++++++++++++++++++ pkg/manifestanalyzer/doc.go | 24 ++++- pkg/manifestanalyzer/folder_test.go | 78 +++++++++------- pkg/manifestanalyzer/repo_test.go | 40 +++++---- 9 files changed, 482 insertions(+), 61 deletions(-) create mode 100644 pkg/manifestanalyzer/contract.go diff --git a/.coverage-baseline b/.coverage-baseline index f5bfc226..d916ff4b 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -77.8 +77.9 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ffa192cf..db82ca3e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -238,6 +238,14 @@ jobs: ``` **Platforms:** linux/amd64, linux/arm64 **Digest:** `${{ steps.digest.outputs.digest }}` + ## Standalone analyzer + `manifest-analyzer` answers "which folders in this repository could become + GitTargets?" without a cluster. Binaries are attached for linux/amd64, + linux/arm64 and darwin/arm64, with a `manifest-analyzer.sha256sums` file: + ```bash + curl -fsSLO https://github.com/ConfigButler/gitops-reverser/releases/download/${{ needs.release-please.outputs.tag_name }}/manifest-analyzer_linux_amd64 + curl -fsSL https://github.com/ConfigButler/gitops-reverser/releases/download/${{ needs.release-please.outputs.tag_name }}/manifest-analyzer.sha256sums | sha256sum -c --ignore-missing + ``` ## Verify this release Images are signed with cosign (keyless) and carry SLSA build provenance and an SPDX SBOM as registry attestations: @@ -364,6 +372,106 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Build, checksum, sign and attest the manifest-analyzer binary, and attach it to the + # draft release. A tool that execs the analyzer to decide which folders it may offer a + # tenant is pinning us by binary, and `go install` from source is the one unverifiable + # link in a devcontainer that pins every other tool from a release asset. + # + # Both verification mechanisms ship, because the two consumers asking for this do not + # agree on one: `gh attestation verify` is the stronger claim, and a sha256sums file is + # what a `curl | sha256sum -c` line in a Dockerfile can consume without a GitHub token. + publish-analyzer: + name: Publish manifest-analyzer + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [ci, release-please] + if: needs.release-please.outputs.release_created == 'true' + permissions: + contents: write # upload release assets + id-token: write # cosign keyless signing + attestation OIDC + attestations: write + container: + image: ${{ needs.ci.outputs.ci-image }} + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Configure Git safe directory + run: git config --global --add safe.directory /__w/gitops-reverser/gitops-reverser + + # linux/amd64 + linux/arm64 cover the devcontainers that consume this; darwin/arm64 + # covers a developer running the same scan locally. The version is stamped into the + # library, not into main, so the string the binary prints is the same one every + # report it emits carries. + - name: Build the analyzer for every published platform + env: + VERSION: ${{ needs.release-please.outputs.tag_name }} + run: | + set -euo pipefail + mkdir -p dist/analyzer + for target in linux/amd64 linux/arm64 darwin/arm64; do + os="${target%/*}" + arch="${target#*/}" + CGO_ENABLED=0 GOOS="${os}" GOARCH="${arch}" go build \ + -trimpath \ + -ldflags "-s -w -X github.com/ConfigButler/gitops-reverser/pkg/manifestanalyzer.version=${VERSION}" \ + -o "dist/analyzer/manifest-analyzer_${os}_${arch}" \ + ./cmd/manifest-analyzer + done + ls -l dist/analyzer + + - name: Verify the built binary reports the release it was built from + env: + VERSION: ${{ needs.release-please.outputs.tag_name }} + run: | + set -euo pipefail + printed="$(./dist/analyzer/manifest-analyzer_linux_amd64 --version)" + echo "${printed}" + echo "${printed}" | grep -qF "${VERSION}" || { + echo "the binary does not report ${VERSION}; the ldflags path is broken" >&2 + exit 1 + } + + - name: Write the checksums file + working-directory: dist/analyzer + run: sha256sum manifest-analyzer_* > manifest-analyzer.sha256sums + + - name: Sign the checksums file (cosign keyless) + working-directory: dist/analyzer + run: | + cosign sign-blob --bundle manifest-analyzer.sha256sums.sigstore.json --yes manifest-analyzer.sha256sums + + - name: Attest SLSA provenance for the analyzer binaries + id: attest-analyzer + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: dist/analyzer/manifest-analyzer_* + + - name: Rename provenance bundle to the Scorecard-recognized suffix + run: cp "${{ steps.attest-analyzer.outputs.bundle-path }}" dist/analyzer/manifest-analyzer.intoto.jsonl + + - name: Upload the analyzer as release assets + uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 + with: + tag_name: ${{ needs.release-please.outputs.tag_name }} + # Attach to the still-draft release; publish-release publishes it once every + # asset is in place (immutable releases reject post-publish uploads). + draft: true + files: | + dist/analyzer/manifest-analyzer_linux_amd64 + dist/analyzer/manifest-analyzer_linux_arm64 + dist/analyzer/manifest-analyzer_darwin_arm64 + dist/analyzer/manifest-analyzer.sha256sums + dist/analyzer/manifest-analyzer.sha256sums.sigstore.json + dist/analyzer/manifest-analyzer.intoto.jsonl + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Publish the release only after every asset is attached. release-please # creates the GitHub release as a draft (see release-please-config.json); # GitHub's immutable-releases enforcement freezes a release the moment it is @@ -375,7 +483,7 @@ jobs: name: Publish GitHub Release runs-on: ubuntu-latest timeout-minutes: 10 - needs: [release-please, publish-manifest, publish-helm] + needs: [release-please, publish-manifest, publish-helm, publish-analyzer] if: needs.release-please.outputs.release_created == 'true' permissions: contents: write # publish the draft release (creates the tag) diff --git a/cmd/manifest-analyzer/main.go b/cmd/manifest-analyzer/main.go index 8f9a5109..486a491d 100644 --- a/cmd/manifest-analyzer/main.go +++ b/cmd/manifest-analyzer/main.go @@ -24,6 +24,7 @@ // deferred) // discovery: raw Kubernetes API discovery dump // --format text|json output format (default text) +// --version print the release and the report apiVersion, then exit // --policy report|refuse // report: always exit 0 (analysis only) // refuse: exit 1 when the folder would be refused @@ -99,8 +100,10 @@ func runWithDiscoveryClientFactory( "kubeconfig path for --mode discovery (default: standard loading rules)", ) contextName := fs.String("context", "", "kubeconfig context for --mode discovery") + showVersion := fs.Bool("version", false, "print the release and the report apiVersion, then exit") fs.Usage = func() { fmt.Fprintln(stderr, "usage: manifest-analyzer [flags] ") + fmt.Fprintln(stderr, " manifest-analyzer --version") fmt.Fprintln(stderr, " manifest-analyzer --mode scan-repo [flags] ") fmt.Fprintln(stderr, " manifest-analyzer --mode discovery [flags]") fs.PrintDefaults() @@ -109,6 +112,10 @@ func runWithDiscoveryClientFactory( if err := fs.Parse(args); err != nil { return exitUsage } + if *showVersion { + printVersion(stdout) + return exitOK + } if !validChoices(*mode, *format, *policy, stderr) { return exitUsage } @@ -128,6 +135,19 @@ func runWithDiscoveryClientFactory( return runDirMode(*mode, fs.Arg(0), *format, *policy, stdout, stderr) } +// printVersion answers both version questions in one exec: which release this binary is, +// and which contract the documents it prints conform to. A tool that pins us by release +// and reads our JSON needs both, and a second exec to get the second answer would be an +// assumption that it ran the same binary. +// +// The version is resolved by the library, not by a main-package ldflags variable, so it is +// the same string the reports themselves carry — including for a `go install ...@vX.Y.Z` +// build, where no ldflags are applied at all. +func printVersion(stdout io.Writer) { + fmt.Fprintf(stdout, "%s %s\n", publicanalyzer.GeneratorName, publicanalyzer.Version()) + fmt.Fprintf(stdout, "report apiVersion: %s\n", publicanalyzer.APIVersion) +} + // validChoices validates the mode/format/policy enum flags, reporting the first bad one // to stderr. Splitting it out of run keeps the top-level dispatch simple. func validChoices(mode, format, policy string, stderr io.Writer) bool { @@ -255,7 +275,7 @@ func runScanFolder(dir, format, policy string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "error: %v\n", err) return exitUsage } - return scanExitCode(policy, report.Accepted) + return scanExitCode(policy, report.Status.Accepted) } scanPolicy := manifestanalyzer.ScanPolicy{ diff --git a/cmd/manifest-analyzer/main_test.go b/cmd/manifest-analyzer/main_test.go index bec4fb5b..280e7eab 100644 --- a/cmd/manifest-analyzer/main_test.go +++ b/cmd/manifest-analyzer/main_test.go @@ -15,6 +15,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/discovery" + + publicanalyzer "github.com/ConfigButler/gitops-reverser/pkg/manifestanalyzer" ) const deployYAML = `apiVersion: apps/v1 @@ -128,12 +130,57 @@ func TestRun_ScanJSON(t *testing.T) { if code != 0 { t.Fatalf("exit = %d, stderr=%s", code, errBuf.String()) } - var parsed map[string]any + var parsed struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Status struct { + Accepted bool `json:"accepted"` + Generator struct { + Name string `json:"name"` + Version string `json:"version"` + } `json:"generator"` + Issues []struct { + Kind string `json:"kind"` + Permanence string `json:"permanence"` + Actor string `json:"actor"` + } `json:"issues"` + } `json:"status"` + } if err := json.Unmarshal(out.Bytes(), &parsed); err != nil { t.Fatalf("scan JSON invalid: %v\n%s", err, out.String()) } - if _, ok := parsed["accepted"]; !ok { - t.Errorf("scan JSON missing accepted key: %s", out.String()) + if parsed.APIVersion != publicanalyzer.APIVersion || parsed.Kind != publicanalyzer.KindFolderReport { + t.Errorf("scan JSON is not a KRM document: %s", out.String()) + } + if parsed.Status.Accepted || len(parsed.Status.Issues) == 0 { + t.Fatalf("the fixture holds a stray values.yaml and must be refused: %s", out.String()) + } + // Every refusal says whether it can stop being one; without it a consumer's only + // honest sentence is "this folder cannot be picked". + for _, issue := range parsed.Status.Issues { + if issue.Permanence == "" { + t.Errorf("issue %q reached a consumer unclassified: %s", issue.Kind, out.String()) + } + if issue.Permanence == "fixable" && issue.Actor == "" { + t.Errorf("fixable issue %q does not say who can fix it: %s", issue.Kind, out.String()) + } + } + if parsed.Status.Generator.Name == "" || parsed.Status.Generator.Version == "" { + t.Errorf("scan JSON must say what produced it: %s", out.String()) + } +} + +// --version answers both pin questions in one exec: which release this binary is, and +// which contract its documents conform to. +func TestRun_Version(t *testing.T) { + var out, errBuf bytes.Buffer + if code := run([]string{"--version"}, &out, &errBuf); code != 0 { + t.Fatalf("exit = %d, want 0 (stderr=%s)", code, errBuf.String()) + } + for _, want := range []string{publicanalyzer.GeneratorName, publicanalyzer.Version(), publicanalyzer.APIVersion} { + if !strings.Contains(out.String(), want) { + t.Errorf("--version output missing %q:\n%s", want, out.String()) + } } } @@ -181,19 +228,21 @@ func TestRun_ScanRepoJSON(t *testing.T) { t.Fatalf("exit = %d, want 0 (stderr=%s)", code, errBuf.String()) } var parsed struct { - Candidates []struct { - Path string `json:"path"` - Layout string `json:"layout"` - } `json:"candidates"` - Summary map[string]any `json:"summary"` + Status struct { + Candidates []struct { + Path string `json:"path"` + Layout string `json:"layout"` + } `json:"candidates"` + Summary map[string]any `json:"summary"` + } `json:"status"` } if err := json.Unmarshal(out.Bytes(), &parsed); err != nil { t.Fatalf("scan-repo JSON invalid: %v\n%s", err, out.String()) } - if len(parsed.Candidates) != 2 { - t.Fatalf("want 2 candidates, got %d: %s", len(parsed.Candidates), out.String()) + if len(parsed.Status.Candidates) != 2 { + t.Fatalf("want 2 candidates, got %d: %s", len(parsed.Status.Candidates), out.String()) } - if parsed.Summary == nil { + if parsed.Status.Summary == nil { t.Errorf("scan-repo JSON missing summary: %s", out.String()) } } diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index be383483..69755dd8 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -7,6 +7,70 @@ guidance that the changelog's breaking-change entries link to. We are pre-1.0, so breaking changes bump the **minor** version (release-please is configured with `bump-minor-pre-major`) rather than the major. Read the relevant entry before upgrading across it. +## Unreleased: the analyzer report is a KRM document (next minor; breaking JSON change) + +`manifest-analyzer --format json` and the `pkg/manifestanalyzer` report types now emit a KRM +envelope. `schemaVersion` is gone; `apiVersion` and `kind` replace it, the scan request moves into +`spec`, and everything the scan found moves into `status`: + +```json +{ + "apiVersion": "manifestanalyzer.configbutler.ai/v1alpha1", + "kind": "RepoReport", + "spec": { "root": "/repo", "mode": "scan-repo" }, + "status": { + "generator": { "name": "manifest-analyzer", "version": "v0.39.1" }, + "candidates": [], + "summary": {} + } +} +``` + +A bespoke `schemaVersion` left three questions open: what a bump asserts, whether a reader should +refuse a version it does not know, and what bumps it at all. The Kubernetes API conventions already +answer all three, in a document every consumer of a GitOps tool has read, so the report cites that +contract instead of writing its own: `v1alpha1` may change incompatibly in any release, a reader +that does not know the version it is handed should refuse it rather than best-effort parse, and +adding a field is still not a bump. The report is never served, never registered, and not +applyable, which is why it carries no `metadata`. + +Three things arrive with the envelope: + +- **`status.generator`** names the build that produced the report (`{name, version}`), so a + document that outlives the process that made it still says which release decided its contents. + It is never empty: ldflags when the release build sets them, otherwise the module version the Go + toolchain records for `go install ...@vX.Y.Z`, otherwise the literal `"dev"`. +- **`manifest-analyzer --version`** prints that release and the report `apiVersion` together, so + one exec answers both questions. +- **Every refusal now says whether it can stop being one.** `permanence` is one of `fixable`, + `pending-upstream` or `permanent`, and `actor` (`repository-author` or `platform-operator`) says + who can clear a fixable one. See below. + +### Refusals carry a permanence, and three more codes are published + +`Issue` and `RefusalReason` gained `permanence` and `actor`, both omitted when unclassified. They +are set by the check that raised the refusal, because only that check knows: `unsupported-kustomize` +is `fixable` for a broken build file, `pending-upstream` for a remote base or Helm inflation, and +`permanent` for a generator. No per-code table can express that. + +`pkg/manifestanalyzer` also now publishes `IssueRenderRefused`, `IssueRenderDoesNotMatchLive` and +`IssueUnplaceableEdit`. The engine could already raise all three, but only a live write does, so +they reached a consumer through GitTarget status with no exported constant to match on. +`RefusalReason.Code` is now typed `IssueKind` rather than `string`, which makes "a candidate's +refusal is the acceptance gate's own issue" compile-checked rather than merely stated. + +### Migration + +- Read `apiVersion` where you read `schemaVersion`, and refuse a version you do not know. +- Move field accesses under `spec`/`status`: `report.Accepted` becomes `report.Status.Accepted`, + `report.Root` becomes `report.Spec.Root`, `report.Candidates` becomes `report.Status.Candidates`, + and so on. Golden fixtures generated from the old shape must be regenerated. +- If you inferred permanence from the code (for example, treating `refused-structural` as permanent + and everything else as "not supported yet"), delete that mapping and read `permanence`. An absent + or unrecognized value means *say nothing about the future*. +- Record the `status.generator.version` you consumed a report with, if you pin our release + elsewhere. That is the whole point of the field. + ## Unreleased: status vocabulary and two removed status fields (next minor; status-only change) Nothing in `spec` changed and no manifest you wrote needs editing. Three things a **reader of diff --git a/pkg/manifestanalyzer/contract.go b/pkg/manifestanalyzer/contract.go new file mode 100644 index 00000000..df40c6b2 --- /dev/null +++ b/pkg/manifestanalyzer/contract.go @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: Apache-2.0 + +package manifestanalyzer + +import ( + "runtime/debug" +) + +// The report envelope. A report is a KRM document — apiVersion, kind, spec, status — and +// not a bespoke JSON shape, because the three questions a bespoke shape leaves open +// ("what does a version bump assert", "must a reader refuse a version it does not know", +// "what bumps it at all") are already answered by the Kubernetes API conventions every +// consumer of a GitOps tool has read. Citing that document is cheaper than writing one. +// +// [APIVersion] replaces the former SchemaVersion marker outright. It follows the +// alpha/beta/GA contract: an alpha version may change incompatibly in any release, and a +// reader that does not know the version it is handed must refuse it rather than +// best-effort parse — the same rule every Kubernetes client already follows. Adding a +// field is still not a version bump, so ignore fields you do not recognise. +const ( + // APIVersion is the group/version of both report kinds. + APIVersion = "manifestanalyzer.configbutler.ai/v1alpha1" + // KindFolderReport is the kind of a [FolderReport]. + KindFolderReport = "FolderReport" + // KindRepoReport is the kind of a [RepoReport]. + KindRepoReport = "RepoReport" +) + +// Scan modes, as reported in a report's spec. +const ( + // ModeScanFolder is the mode of a [FolderReport]: may THIS folder become a GitTarget? + ModeScanFolder = "scan-folder" + // ModeScanRepo is the mode of a [RepoReport]: which folders in this repository could? + ModeScanRepo = "scan-repo" +) + +// TypeMeta is the KRM envelope every report carries. It is deliberately shaped like +// apimachinery's TypeMeta so a consumer that does link us can mirror or reuse it. +// +// A report is NEVER served and NEVER registered: there is no CRD, no group registration, +// and it cannot be applied. It observes a path at an instant. That is also why it carries +// no metadata — a report has no identity, a synthesized name would be noise, and worse, it +// would suggest the document can be applied. Kpt's own kind: ResourceList carries +// apiVersion, kind and its payload with no metadata, for the same reason. +type TypeMeta struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` +} + +// Generator names the build that produced a report. Never empty: a report that cannot say +// what produced it is the failure this field exists to prevent. A tool that execs the +// binary holds the JSON document and nothing else — without this it cannot tell a report +// from one release consumed against a writer from another, which is a wrong answer that +// looks entirely normal. +// +// A bare version string would not do: kind names the document, Name names what produced +// it, and a report piped into another tool is exactly where conflating the two bites. +type Generator struct { + // Name is the producing tool, e.g. "manifest-analyzer". + Name string `json:"name"` + // Version is the release, e.g. "v0.39.1", or "dev" for a build carrying no version. + Version string `json:"version"` +} + +// GeneratorName is the tool name every report carries. It names the analyzer, not the +// binary that hosts it: a report produced by the library linked into another program +// still says manifest-analyzer, because that is what decided the contents. +const GeneratorName = "manifest-analyzer" + +// version is overridden at link time with -ldflags "-X +// github.com/ConfigButler/gitops-reverser/pkg/manifestanalyzer.version=vX.Y.Z" by the +// release build. It is empty for every other build, which is not a problem: [Version] +// falls back to the module version the Go toolchain already records. +var version string + +// modulePath is this module's path, used to find our own version in the build info of a +// binary that merely links us. +const modulePath = "github.com/ConfigButler/gitops-reverser" + +// Version reports the release that produced a report, resolved in three steps: the +// ldflags-injected version if the build set one; otherwise the module version +// runtime/debug records — which is what `go install ...@vX.Y.Z` (the install path this +// package's own documentation recommends) produces for free, with no build change and no +// release-workflow change; otherwise the literal "dev". +// +// It is never empty, so a consumer may read an ABSENT generator as "produced before this +// field shipped" without having to distinguish that from "produced by a build that did +// not know itself". +func Version() string { + if version != "" { + return version + } + if v, ok := moduleVersion(); ok { + return v + } + return "dev" +} + +// moduleVersion digs this module's version out of the embedded build info. The binary may +// BE this module (go install / go build of cmd/manifest-analyzer), in which case +// Main.Version carries it, or it may merely link this package, in which case our version +// is one of the dependency entries and Main names the consumer instead. +func moduleVersion() (string, bool) { + info, ok := debug.ReadBuildInfo() + if !ok { + return "", false + } + if info.Main.Path == modulePath { + return usableVersion(info.Main.Version) + } + for _, dep := range info.Deps { + if dep != nil && dep.Path == modulePath { + return usableVersion(dep.Version) + } + } + return "", false +} + +// usableVersion rejects the placeholders the toolchain records for a build with no module +// version — a plain `go build` in a checkout reports "(devel)", and an unresolved +// dependency reports nothing at all. +func usableVersion(v string) (string, bool) { + if v == "" || v == "(devel)" { + return "", false + } + return v, true +} + +// generator builds the [Generator] every report carries. +func generator() Generator { + return Generator{Name: GeneratorName, Version: Version()} +} diff --git a/pkg/manifestanalyzer/doc.go b/pkg/manifestanalyzer/doc.go index ed88ec84..7491ccd3 100644 --- a/pkg/manifestanalyzer/doc.go +++ b/pkg/manifestanalyzer/doc.go @@ -15,8 +15,7 @@ // // GitOps Reverser is pre-1.0, and so is this package. It is the surface a tool is meant // to build on rather than reaching into internal/, but it carries no compatibility -// guarantee: fields may be renamed, removed, or given a new meaning in any release, and -// [SchemaVersion] may bump for reasons that would be breaking after 1.0. Pin a version: each +// guarantee: fields may be renamed, removed, or given a new meaning in any release. Pin a version: each // release is tagged `vX.Y.Z`, so `go get github.com/ConfigButler/gitops-reverser@vX.Y.Z` (and // `go install github.com/ConfigButler/gitops-reverser/cmd/manifest-analyzer@vX.Y.Z`) resolve to // that release rather than an opaque pseudo-version — the whole repository is one Go module, @@ -27,6 +26,27 @@ // and [RefusalReason.Detail] are prose, while [IssueKind] and the refusal reason codes // are the values worth matching on. // +// # The report is a KRM document, and it says what produced it +// +// Both reports carry [APIVersion] and a kind, the scan request in spec and the findings in +// status. A reader that does not know the apiVersion it is handed should refuse it rather +// than best-effort parse, by the same rule every Kubernetes client already follows; adding +// a field is not a version bump. The document is never served and never applyable — see +// [TypeMeta]. +// +// [FolderReportStatus.Generator] and [RepoReportStatus.Generator] name the build that +// produced the report, so a document that outlives the process that made it still says +// which release decided its contents. `manifest-analyzer --version` prints the same pair. +// +// # A refusal says whether it can ever stop being one +// +// [Issue] and [RefusalReason] carry a [Permanence] and, when something can be done about +// it, an [Actor]. A code alone cannot tell "not supported yet" from "cannot be synced" +// from "one broken document away from working", and guessing from the code is how a +// consumer ends up telling a user to wait for a release that will never come, or to go fix +// something only their platform team can. Treat an absent or unrecognised permanence as +// [PermanenceUnknown] and say nothing about the future. +// // Everything under internal/ carries no guarantee either, and is not importable from // another module. One format from there is nonetheless a contract you may build on: a // resource's identity key is "{group}/{version}/{resource}/{namespace}/{name}", with the diff --git a/pkg/manifestanalyzer/folder_test.go b/pkg/manifestanalyzer/folder_test.go index 6714acab..260bb06c 100644 --- a/pkg/manifestanalyzer/folder_test.go +++ b/pkg/manifestanalyzer/folder_test.go @@ -41,10 +41,14 @@ func TestScanFolder_AcceptsPlainKRM(t *testing.T) { report, err := manifestanalyzer.ScanFolder(context.Background(), root) require.NoError(t, err) - require.True(t, report.Accepted) - require.Empty(t, report.Issues) - require.Equal(t, manifestanalyzer.SchemaVersion, report.SchemaVersion) - require.Equal(t, root, report.Root) + require.True(t, report.Status.Accepted) + require.Empty(t, report.Status.Issues) + require.Equal(t, manifestanalyzer.APIVersion, report.APIVersion) + require.Equal(t, manifestanalyzer.KindFolderReport, report.Kind) + require.Equal(t, manifestanalyzer.ModeScanFolder, report.Spec.Mode) + require.Equal(t, root, report.Spec.Root) + require.Equal(t, manifestanalyzer.GeneratorName, report.Status.Generator.Name) + require.NotEmpty(t, report.Status.Generator.Version, "a report must always say what produced it") } // A folder the operator would refuse must come back as a successful scan with @@ -59,10 +63,10 @@ func TestScanFolder_RefusalIsNotAnError(t *testing.T) { report, err := manifestanalyzer.ScanFolder(context.Background(), root) require.NoError(t, err) - require.False(t, report.Accepted) - require.NotEmpty(t, report.Issues) - kinds := make([]manifestanalyzer.IssueKind, 0, len(report.Issues)) - for _, issue := range report.Issues { + require.False(t, report.Status.Accepted) + require.NotEmpty(t, report.Status.Issues) + kinds := make([]manifestanalyzer.IssueKind, 0, len(report.Status.Issues)) + for _, issue := range report.Status.Issues { kinds = append(kinds, issue.Kind) } require.Contains(t, kinds, manifestanalyzer.IssueUnsupportedKustomize, @@ -100,8 +104,9 @@ spec: } report := manifestanalyzer.ScanFolderFS(context.Background(), fsys) - require.True(t, report.Accepted, "a referenced values file must not refuse its folder: %+v", report.Issues) - for _, issue := range report.Issues { + require.True(t, report.Status.Accepted, + "a referenced values file must not refuse its folder: %+v", report.Status.Issues) + for _, issue := range report.Status.Issues { require.NotEqual(t, manifestanalyzer.IssueNonKRM, issue.Kind, "the values file the Application names is context, not a non-krm-yaml refusal") } @@ -135,9 +140,9 @@ spec: } report := manifestanalyzer.ScanFolderFS(context.Background(), fsys) - require.True(t, report.Accepted, "a HelmRelease-referenced values file must not refuse its folder: %+v", - report.Issues) - for _, issue := range report.Issues { + require.True(t, report.Status.Accepted, "a HelmRelease-referenced values file must not refuse its folder: %+v", + report.Status.Issues) + for _, issue := range report.Status.Issues { require.NotEqual(t, manifestanalyzer.IssueNonKRM, issue.Kind, "the values file the HelmRelease names is context, not a non-krm-yaml refusal") } @@ -158,12 +163,12 @@ func TestScanFolderFS_ReportsDuplicateIdentity(t *testing.T) { } report := manifestanalyzer.ScanFolderFS(context.Background(), fsys) - require.False(t, report.Accepted) - require.Equal(t, manifestanalyzer.SchemaVersion, report.SchemaVersion) - require.Empty(t, report.Root, "an fs.FS has no path to report") + require.False(t, report.Status.Accepted) + require.Equal(t, manifestanalyzer.APIVersion, report.APIVersion) + require.Empty(t, report.Spec.Root, "an fs.FS has no path to report") var found bool - for _, issue := range report.Issues { + for _, issue := range report.Status.Issues { if issue.Kind == manifestanalyzer.IssueDuplicate { found = true require.NotEmpty(t, issue.Path) @@ -185,11 +190,11 @@ func TestScanFolder_ReportsRetainedKustomization(t *testing.T) { report, err := manifestanalyzer.ScanFolder(context.Background(), root) require.NoError(t, err) - require.True(t, report.Accepted) - require.Len(t, report.Retained, 1) - require.Equal(t, "kustomization.yaml", report.Retained[0].Path) - require.False(t, report.Retained[0].Unsupported) - require.Nil(t, report.Retained[0].Identity, + require.True(t, report.Status.Accepted) + require.Len(t, report.Status.Retained, 1) + require.Equal(t, "kustomization.yaml", report.Status.Retained[0].Path) + require.False(t, report.Status.Retained[0].Unsupported) + require.Nil(t, report.Status.Retained[0].Identity, "a whole-file retention names no resource; only the refused mixed-file case does") } @@ -203,13 +208,14 @@ func TestScanFolder_FlagsUnsupportedRetainedKustomization(t *testing.T) { report, err := manifestanalyzer.ScanFolder(context.Background(), root) require.NoError(t, err) - require.False(t, report.Accepted) - require.Len(t, report.Retained, 1) - require.True(t, report.Retained[0].Unsupported) + require.False(t, report.Status.Accepted) + require.Len(t, report.Status.Retained, 1) + require.True(t, report.Status.Retained[0].Unsupported) } -// The JSON document is the published contract: schemaVersion is always present, and -// issues is an empty array rather than null so a consumer can iterate it unconditionally. +// The JSON document is the published contract: it is a KRM document (apiVersion/kind, +// request in spec, findings in status), it always says what produced it, and issues is an +// empty array rather than null so a consumer can iterate it unconditionally. func TestFolderReport_WriteJSON_Contract(t *testing.T) { t.Parallel() @@ -225,9 +231,21 @@ func TestFolderReport_WriteJSON_Contract(t *testing.T) { var raw map[string]any require.NoError(t, json.Unmarshal(buf.Bytes(), &raw)) - require.Equal(t, "v1", raw["schemaVersion"]) - require.Equal(t, true, raw["accepted"]) - require.NotContains(t, raw, "retained", "an empty retained set is omitted") + require.Equal(t, "manifestanalyzer.configbutler.ai/v1alpha1", raw["apiVersion"]) + require.Equal(t, "FolderReport", raw["kind"]) + require.NotContains(t, raw, "schemaVersion", "apiVersion replaces the schemaVersion marker outright") + require.NotContains(t, raw, "metadata", "a report observes a path at an instant; it has no identity") + + spec, ok := raw["spec"].(map[string]any) + require.True(t, ok, "the scan request is the spec") + require.Equal(t, "scan-folder", spec["mode"]) + + status, ok := raw["status"].(map[string]any) + require.True(t, ok, "what was found is the status") + require.Equal(t, true, status["accepted"]) + require.Equal(t, map[string]any{"name": "manifest-analyzer", "version": manifestanalyzer.Version()}, + status["generator"], "a report that cannot say what produced it is the failure this field prevents") + require.NotContains(t, status, "retained", "an empty retained set is omitted") } // The public report must survive a marshal/unmarshal round trip unchanged, or a consumer diff --git a/pkg/manifestanalyzer/repo_test.go b/pkg/manifestanalyzer/repo_test.go index fd1bf6eb..639029a8 100644 --- a/pkg/manifestanalyzer/repo_test.go +++ b/pkg/manifestanalyzer/repo_test.go @@ -36,16 +36,19 @@ func TestScanRepo_PlainPerEnvironmentFoldersAreCandidates(t *testing.T) { report, err := manifestanalyzer.ScanRepo(context.Background(), fixture(t, "supported", "plain-per-env")) require.NoError(t, err) - require.Equal(t, manifestanalyzer.SchemaVersion, report.SchemaVersion) - require.NotEmpty(t, report.Candidates) - for _, cand := range report.Candidates { + require.Equal(t, manifestanalyzer.APIVersion, report.APIVersion) + require.Equal(t, manifestanalyzer.KindRepoReport, report.Kind) + require.Equal(t, manifestanalyzer.ModeScanRepo, report.Spec.Mode) + require.Equal(t, manifestanalyzer.GeneratorName, report.Status.Generator.Name) + require.NotEmpty(t, report.Status.Candidates) + for _, cand := range report.Status.Candidates { require.Equal(t, manifestanalyzer.LayoutPlain, cand.Layout) require.True(t, cand.AcceptedByOperator, "plain KRM folders are the launch layout") require.Empty(t, cand.RefusalReasons) require.False(t, cand.RenderRoot) } - require.Equal(t, len(report.Candidates), report.Summary.Accepted) - require.Zero(t, report.Summary.Refused) + require.Equal(t, len(report.Status.Candidates), report.Status.Summary.Accepted) + require.Zero(t, report.Status.Summary.Refused) } // An external-base overlay and a permanent structural refusal are different truths a consumer @@ -61,7 +64,7 @@ func TestScanRepo_OverlayAdoptedDistinctFromStructural(t *testing.T) { require.NoError(t, err) var seen bool - for _, cand := range report.Candidates { + for _, cand := range report.Status.Candidates { require.Equal(t, manifestanalyzer.LayoutKustomizeOverlay, cand.Layout) require.True(t, cand.AcceptedByOperator, "render-root scoping adopts the external-base overlay") require.Empty(t, cand.RefusalReasons) @@ -81,13 +84,13 @@ func TestScanRepo_OverlayAdoptedDistinctFromStructural(t *testing.T) { require.NoError(t, err) var seen bool - for _, cand := range report.Candidates { + for _, cand := range report.Status.Candidates { if cand.Layout != manifestanalyzer.LayoutRefusedStructural { continue } seen = true require.False(t, cand.AcceptedByOperator) - codes := make([]string, 0, len(cand.RefusalReasons)) + codes := make([]manifestanalyzer.IssueKind, 0, len(cand.RefusalReasons)) for _, reason := range cand.RefusalReasons { codes = append(codes, reason.Code) } @@ -104,9 +107,9 @@ func TestScanRepo_ReportsOverlapConflicts(t *testing.T) { report, err := manifestanalyzer.ScanRepo(context.Background(), fixture(t, "supported", "overlapping")) require.NoError(t, err) - require.NotEmpty(t, report.Summary.OverlapConflicts, + require.NotEmpty(t, report.Status.Summary.OverlapConflicts, "two nested candidates can never both own a folder; the conflict must surface") - for _, conflict := range report.Summary.OverlapConflicts { + for _, conflict := range report.Status.Summary.OverlapConflicts { require.NotEmpty(t, conflict.Ancestor) require.NotEmpty(t, conflict.Descendant) require.NotEqual(t, conflict.Ancestor, conflict.Descendant) @@ -120,8 +123,8 @@ func TestScanRepo_FleetRootIsNeverACandidate(t *testing.T) { report, err := manifestanalyzer.ScanRepo(context.Background(), root) require.NoError(t, err) - require.True(t, report.Summary.FleetRoot) - for _, cand := range report.Candidates { + require.True(t, report.Status.Summary.FleetRoot) + for _, cand := range report.Status.Candidates { require.NotEqual(t, ".", cand.Path, "a GitTarget points at an app subtree, never the fleet root") } } @@ -143,9 +146,16 @@ func TestRepoReport_WriteJSON_Contract(t *testing.T) { var raw map[string]any require.NoError(t, json.Unmarshal(buf.Bytes(), &raw)) - require.Equal(t, "v1", raw["schemaVersion"]) - require.Contains(t, raw, "candidates") - require.Contains(t, raw, "summary") + require.Equal(t, "manifestanalyzer.configbutler.ai/v1alpha1", raw["apiVersion"]) + require.Equal(t, "RepoReport", raw["kind"]) + require.NotContains(t, raw, "schemaVersion", "apiVersion replaces the schemaVersion marker outright") + + status, ok := raw["status"].(map[string]any) + require.True(t, ok, "what was found is the status") + require.Contains(t, status, "candidates") + require.Contains(t, status, "summary") + require.Equal(t, map[string]any{"name": "manifest-analyzer", "version": manifestanalyzer.Version()}, + status["generator"]) var decoded manifestanalyzer.RepoReport require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded)) From 0fa10c0eae7fe96392307428ab532d812c4e7fc0 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 27 Jul 2026 06:50:57 +0000 Subject: [PATCH 04/15] docs: record the report envelope and the permanence pair UPGRADING gets the migration for the breaking JSON change, and the discovery design doc's "report shape as shipped" section now matches what ships: the KRM envelope, and the permanence/actor pair on every refusal reason. Co-Authored-By: Claude Opus 5 --- .../repo-discovery-and-onboarding-scan.md | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md index 6881a5f8..f157ed46 100644 --- a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md +++ b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md @@ -257,9 +257,22 @@ A tool consuming this from Go imports [`pkg/manifestanalyzer`](../../../pkg/mani supported, versioned projection of the reports below; the engine above stays internal and free to move. -**Report shape as shipped**, refining the sketch above. Per candidate: `path`, `layout`, -`acceptedByOperator`, `refusalReasons[]` (`{code, detail}`), `renderRoot`, `readScope[]`, -`inferredNamespace`, `resources`, `overlapsWith[]`. +**Report shape as shipped**, refining the sketch above. The document is a KRM envelope: +`apiVersion: manifestanalyzer.configbutler.ai/v1alpha1`, `kind: RepoReport`, the scan request +in `spec` (`root`, `mode`) and everything found in `status` (`generator`, `candidates`, +`summary`). The candidate blocks below live under `status.candidates`. Per candidate: `path`, +`layout`, `acceptedByOperator`, `refusalReasons[]` (`{code, detail, permanence, actor}`), +`renderRoot`, `readScope[]`, `inferredNamespace`, `resources`, `overlapsWith[]`. + +- **`permanence` says whether a refusal can ever stop being one** (`fixable`, + `pending-upstream`, `permanent`), and `actor` (`repository-author`, `platform-operator`) + says who can clear a fixable one. It is set by the check that raised the refusal, because + the same code answers differently depending on the branch: `unsupported-kustomize` is + `fixable` for a build file the author broke, `pending-upstream` for a remote base or Helm + inflation, and `permanent` for a generator. An absent value means say nothing about the + future. See [analyzer-consumer-contract-asks.md](../analyzer-consumer-contract-asks.md). +- **`status.generator`** names the build that produced the report, so a document that + outlives the process that made it still says which release decided its contents. - **`resources` splits the KRM two ways** instead of the sketch's single `documents.krm`: `{ rendered, editable, nonKrm }`. `rendered` counts the documents the candidate actually @@ -278,7 +291,7 @@ free to move. candidate — `Scan` with `WriterAllowlist` (kustomize build directives **plus** the operator's `.sops.yaml` bootstrap config), so a folder the writer would adopt is not falsely reported refused. A refused plain / self-contained candidate carries the gate's -issues as `refusalReasons` (`{code, detail}` — duplicate identity, non-KRM YAML, a foreign +issues as `refusalReasons` (`{code, detail, permanence, actor}` — duplicate identity, non-KRM YAML, a foreign file, an unsupported nested kustomization, …), never a bare `false`. The structural gate refuses `openapi`/`crds` alongside `configurations`, matching the [support boundary §1](kustomize-support-boundary.md). From 293572ae13c7d7b0880c7637d6bf4edc44578715 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 27 Jul 2026 07:40:55 +0000 Subject: [PATCH 05/15] fix(analyzer): classify the dynamic ignore-shadow refusal, and read the envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings. The .gittargetignore guard has two halves and only the parse-time one was classified. A pattern narrow enough to pass the initial denylist can still match a write planned later, and that refusal reaches the same reader with the same question to answer, so it now carries fixable/repository-author too. The new test drives exactly that path: a narrow pattern, no parse-time issue, a planned write that hits it. The layout baseline read the scan report at the top level and got nothing back under the KRM envelope, so every fixture rendered as "no candidate folders reported" — a whole corpus of behaviour silently blanked. It reads through .status now, and records each refusal's permanence in the process: a refusal that changes permanence has changed what a user is told to do about it, which is a boundary move like any other and belongs in this diff. UPGRADING claimed the envelope for `--format json` generally. It is the two published scan modes; `--mode analyze` renders the engine's own structural report, which was never part of the contract. Co-Authored-By: Claude Opus 5 --- docs/UPGRADING.md | 6 ++-- hack/gitops-layouts-baseline.sh | 30 +++++++++------- internal/git/plan_flush.go | 6 ++++ internal/git/write_refusal_permanence_test.go | 23 ++++++++++++ test/fixtures/gitops-layouts/support-today.md | 35 ++++++++++--------- 5 files changed, 69 insertions(+), 31 deletions(-) diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 69755dd8..abbaddce 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -9,8 +9,10 @@ We are pre-1.0, so breaking changes bump the **minor** version (release-please i ## Unreleased: the analyzer report is a KRM document (next minor; breaking JSON change) -`manifest-analyzer --format json` and the `pkg/manifestanalyzer` report types now emit a KRM -envelope. `schemaVersion` is gone; `apiVersion` and `kind` replace it, the scan request moves into +The two published scan modes — `manifest-analyzer --mode scan-folder|scan-repo --format json` — +and the `pkg/manifestanalyzer` report types now emit a KRM envelope. (`--mode analyze` is +unaffected: it renders the engine's own structural report, which is not part of the published +contract.) `schemaVersion` is gone; `apiVersion` and `kind` replace it, the scan request moves into `spec`, and everything the scan found moves into `status`: ```json diff --git a/hack/gitops-layouts-baseline.sh b/hack/gitops-layouts-baseline.sh index dfa8fc0c..5c0e8309 100755 --- a/hack/gitops-layouts-baseline.sh +++ b/hack/gitops-layouts-baseline.sh @@ -37,14 +37,14 @@ emit_summary_row() { # $1 = fixture path relative to the corpus, $2 = rc, $3 = json local name="$1" rc="$2" json="$3" local accepted refused layouts constructs outcome signal - accepted=$(jq -r '.summary.accepted // 0' <<<"$json") - refused=$(jq -r '.summary.refused // 0' <<<"$json") - layouts=$(jq -r "$MD_CELL"'(.summary.candidatesByLayout // {}) | to_entries + accepted=$(jq -r '.status.summary.accepted // 0' <<<"$json") + refused=$(jq -r '.status.summary.refused // 0' <<<"$json") + layouts=$(jq -r "$MD_CELL"'(.status.summary.candidatesByLayout // {}) | to_entries | map("\(.key)=\(.value)") | join(", ") | md | if . == "" then "-" else . end' <<<"$json") - constructs=$(jq -r "$MD_CELL"'(.summary.unsupportedConstructs // []) | join(", ") | md + constructs=$(jq -r "$MD_CELL"'(.status.summary.unsupportedConstructs // []) | join(", ") | md | if . == "" then "-" else . end' <<<"$json") - signal=$(jq -r "$MD_CELL"'[.candidates[]? | select(.acceptedByOperator == false) - | .refusalReasons[]? | "\(.code): \(.detail)" | md] + signal=$(jq -r "$MD_CELL"'[.status.candidates[]? | select(.acceptedByOperator == false) + | .refusalReasons[]? | "\(.code) [\(.permanence // "unclassified")]: \(.detail)" | md] | if length == 0 then "None" elif length > 4 then (.[0:4] | join("
")) + "
+\(length - 4) more" else join("
") end' <<<"$json") @@ -68,27 +68,28 @@ emit_summary_row() { emit_detail() { local name="$1" rc="$2" json="$3" local accepted refused constructs fleet - accepted=$(jq -r '.summary.accepted // 0' <<<"$json") - refused=$(jq -r '.summary.refused // 0' <<<"$json") - constructs=$(jq -r "$MD_CELL"'(.summary.unsupportedConstructs // []) | join(", ") | md + accepted=$(jq -r '.status.summary.accepted // 0' <<<"$json") + refused=$(jq -r '.status.summary.refused // 0' <<<"$json") + constructs=$(jq -r "$MD_CELL"'(.status.summary.unsupportedConstructs // []) | join(", ") | md | if . == "" then "none" else . end' <<<"$json") - fleet=$(jq -r '.summary.fleetRoot // false' <<<"$json") + fleet=$(jq -r '.status.summary.fleetRoot // false' <<<"$json") printf '\n## %s\n\n' "$name" printf 'Reported rc `%s`. Accepted `%s`, refused `%s`.\n' "$rc" "$accepted" "$refused" printf 'Unsupported constructs: `%s`. Fleet root: `%s`.\n\n' "$constructs" "$fleet" - if [[ "$(jq -r '(.candidates // []) | length' <<<"$json")" == "0" ]]; then + if [[ "$(jq -r '(.status.candidates // []) | length' <<<"$json")" == "0" ]]; then printf '_No candidate folders reported._\n' return fi printf '| Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons |\n' printf '|---|---|---|---|---|---|\n' - jq -r "$MD_CELL"'.candidates[] + jq -r "$MD_CELL"'.status.candidates[] | "| `\(.path | md)` | `\(.layout | md)` | \(.acceptedByOperator) | `\(.inferredNamespace // "-" | md)` | " + "\(.resources.rendered // 0)/\(.resources.editable // 0)/\(.resources.nonKrm // 0) | " - + ((.refusalReasons // []) | map("\(.code): \(.detail)" | md) | join("
") + + ((.refusalReasons // []) + | map("\(.code) [\(.permanence // "unclassified")]: \(.detail)" | md) | join("
") | if . == "" then "none" else . end) + " |"' <<<"$json" } @@ -116,6 +117,9 @@ Reading rules: input set that lives in a Git-host API. - **A missing candidate matters as much as a refusal**: it means the tool did not explain that part of the repository at all. +- Each refusal reason carries its permanence in brackets — `fixable`, `pending-upstream` + or `permanent`. A refusal that changes permanence has changed what a user is told to do + about it, which is a boundary move like any other and belongs in this diff. ## Summary diff --git a/internal/git/plan_flush.go b/internal/git/plan_flush.go index 7f6b16d0..e56bdee7 100644 --- a/internal/git/plan_flush.go +++ b/internal/git/plan_flush.go @@ -1493,6 +1493,12 @@ func (wb *writeBatch) ignoreShadowPrecondition() error { issues = append(issues, manifestanalyzer.AcceptanceIssue{ Kind: manifestanalyzer.IssueIgnoreShadowsManaged, Path: rel, + // The same answer the parse-time denylist gives, and it has to be given + // again here: a pattern narrow enough to pass the initial scan can still + // match a write planned later, and that refusal reaches the same reader. + // Narrowing the pattern is the author's to do. + Permanence: manifestanalyzer.PermanenceFixable, + Actor: manifestanalyzer.ActorAuthor, Message: fmt.Sprintf( "%s pattern %q shadows the managed write path %s; the operator would be blind to its own "+ "file. Remove the pattern or move the resource out of its match", diff --git a/internal/git/write_refusal_permanence_test.go b/internal/git/write_refusal_permanence_test.go index 7474c3ab..fee840b1 100644 --- a/internal/git/write_refusal_permanence_test.go +++ b/internal/git/write_refusal_permanence_test.go @@ -89,6 +89,29 @@ func TestRenderFidelityRefusalIsFixableByThePlatform(t *testing.T) { assertClassified(t, issues) } +// TestIgnoreShadowRefusalIsFixableByTheAuthor drives the DYNAMIC half of the +// .gittargetignore guard. The parse-time denylist catches only a catastrophic pattern; a +// narrow one passes the initial scan and can still match a write planned later, and that +// refusal reaches the same reader with the same question to answer. +func TestIgnoreShadowRefusalIsFixableByTheAuthor(t *testing.T) { + matcher, parseIssues := manifestanalyzer.LoadGitTargetIgnore([]byte("secrets/\n")) + require.Empty(t, parseIssues, "a narrow pattern must pass the parse-time denylist") + + wb := &writeBatch{ + store: &manifestanalyzer.ManifestStore{Ignore: matcher}, + buffers: map[string]*fileBuffer{ + "secrets/db.yaml": {rel: "secrets/db.yaml", current: []byte("a")}, + }, + } + + issues := refusalIssues(t, wb.ignoreShadowPrecondition()) + require.Len(t, issues, 1) + assert.Equal(t, manifestanalyzer.IssueIgnoreShadowsManaged, issues[0].Kind) + assert.Equal(t, manifestanalyzer.PermanenceFixable, issues[0].Permanence) + assert.Equal(t, manifestanalyzer.ActorAuthor, issues[0].Actor) + assertClassified(t, issues) +} + // TestPathScopeRefusalIsFixableByThePlatform drives the real L1 precondition: widening // spec.path, or re-placing the write, is the GitTarget owner's call and nobody else's. func TestPathScopeRefusalIsFixableByThePlatform(t *testing.T) { diff --git a/test/fixtures/gitops-layouts/support-today.md b/test/fixtures/gitops-layouts/support-today.md index 6169622f..5f9f0194 100644 --- a/test/fixtures/gitops-layouts/support-today.md +++ b/test/fixtures/gitops-layouts/support-today.md @@ -19,30 +19,33 @@ Reading rules: input set that lives in a Git-host API. - **A missing candidate matters as much as a refusal**: it means the tool did not explain that part of the repository at all. +- Each refusal reason carries its permanence in brackets — `fixable`, `pending-upstream` + or `permanent`. A refusal that changes permanence has changed what a user is told to do + about it, which is a boundary move like any other and belongs in this diff. ## Summary | Fixture | rc | Outcome | Accepted | Refused | Layouts | Unsupported constructs | Reported refusal signal | |---|---:|---|---:|---:|---|---|---| | 1-desired-state/argocd-app-of-apps | 0 | All reported candidates accepted | 4 | 0 | plain=4 | - | None | -| 1-desired-state/argocd-plain | 0 | Partial | 1 | 1 | plain=2 | - | non-krm-yaml: ci-metadata.yaml: YAML is not a Kubernetes manifest | +| 1-desired-state/argocd-plain | 0 | Partial | 1 | 1 | plain=2 | - | non-krm-yaml [fixable]: ci-metadata.yaml: YAML is not a Kubernetes manifest | | 1-desired-state/flux-monorepo | 0 | All reported candidates accepted | 6 | 0 | kustomize-overlay=2, kustomize-single=4 | - | None | | 1-desired-state/repo-per-environment | 0 | All reported candidates accepted | 9 | 0 | plain=9 | - | None | | 2-rendered/argocd-external-helm | 0 | All reported candidates accepted | 3 | 0 | plain=3 | - | None | | 2-rendered/helm-chart | 0 | All reported candidates accepted | 1 | 0 | plain=1 | - | None | | 2-rendered/helm-environment-values | 0 | All reported candidates accepted | 1 | 0 | plain=1 | - | None | | 2-rendered/kustomize-overlay-minimal | 0 | All reported candidates accepted | 2 | 0 | kustomize-overlay=2 | - | None | -| 2-rendered/kustomize-overlays | 0 | Partial | 1 | 3 | kustomize-single=1, refused-structural=3 | configMapGenerator, namePrefix, nameSuffix, remote-base, secretGenerator | refused-structural: kustomization uses unsupported feature(s): remote-base
refused-structural: kustomization uses unsupported feature(s): configMapGenerator, nameSuffix, secretGenerator
refused-structural: kustomization uses unsupported feature(s): configMapGenerator, namePrefix | -| 2-rendered/rendered-manifests | 0 | Partial | 3 | 2 | plain=3, refused-structural=2 | namePrefix | refused-structural: kustomization uses unsupported feature(s): namePrefix
refused-structural: kustomization uses unsupported feature(s): namePrefix | +| 2-rendered/kustomize-overlays | 0 | Partial | 1 | 3 | kustomize-single=1, refused-structural=3 | configMapGenerator, namePrefix, nameSuffix, remote-base, secretGenerator | refused-structural [pending-upstream]: kustomization uses unsupported feature(s): remote-base
refused-structural [permanent]: kustomization uses unsupported feature(s): configMapGenerator, nameSuffix, secretGenerator
refused-structural [permanent]: kustomization uses unsupported feature(s): configMapGenerator, namePrefix | +| 2-rendered/rendered-manifests | 0 | Partial | 3 | 2 | plain=3, refused-structural=2 | namePrefix | refused-structural [permanent]: kustomization uses unsupported feature(s): namePrefix
refused-structural [permanent]: kustomization uses unsupported feature(s): namePrefix | | 3-expanded/argocd-applicationset-directories | 0 | All reported candidates accepted | 5 | 0 | plain=5 | - | None | -| 3-expanded/argocd-applicationset-files | 0 | No reported candidates accepted | 0 | 1 | plain=1 | - | non-krm-yaml: chart/Chart.yaml: YAML is not a Kubernetes manifest
foreign-file: chart/templates/_helpers.tpl: foreign file chart/templates/_helpers.tpl is not a managed manifest; remove it or name it in .gittargetignore
non-krm-yaml: chart/templates/deployment.yaml: YAML is not a Kubernetes manifest
non-krm-yaml: chart/templates/service.yaml: YAML is not a Kubernetes manifest
+5 more | +| 3-expanded/argocd-applicationset-files | 0 | No reported candidates accepted | 0 | 1 | plain=1 | - | non-krm-yaml [fixable]: chart/Chart.yaml: YAML is not a Kubernetes manifest
foreign-file [fixable]: chart/templates/_helpers.tpl: foreign file chart/templates/_helpers.tpl is not a managed manifest; remove it or name it in .gittargetignore
non-krm-yaml [fixable]: chart/templates/deployment.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [fixable]: chart/templates/service.yaml: YAML is not a Kubernetes manifest
+5 more | | 3-expanded/argocd-multicluster-matrix | 0 | All reported candidates accepted | 4 | 0 | plain=4 | - | None | -| 3-expanded/flux-helmrelease | 0 | Partial | 3 | 1 | kustomize-single=3, refused-structural=1 | configMapGenerator | refused-structural: kustomization uses unsupported feature(s): configMapGenerator | +| 3-expanded/flux-helmrelease | 0 | Partial | 3 | 1 | kustomize-single=3, refused-structural=1 | configMapGenerator | refused-structural [permanent]: kustomization uses unsupported feature(s): configMapGenerator | | 3-expanded/flux-resourceset-inline | 0 | All reported candidates accepted | 1 | 0 | plain=1 | - | None | | 3-expanded/flux-resourceset-pull-requests | 0 | All reported candidates accepted | 1 | 0 | plain=1 | - | None | | 4-machine-written/flux-image-automation | 0 | All reported candidates accepted | 3 | 0 | kustomize-single=3 | - | None | | 5-opaque/sops-encrypted | 0 | All reported candidates accepted | 3 | 0 | kustomize-single=2, plain=1 | - | None | -| 6-hostile/mixed-and-hostile | 0 | Partial | 3 | 2 | plain=4, refused-structural=1 | unparseable | refused-structural: kustomization uses unsupported feature(s): unparseable (invalid Kustomization: json: unknown field "spec")
impure-managed-file: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #1 is a non-KRM document
impure-managed-file: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #2 is an empty document
foreign-file: deployment.json: foreign file deployment.json is not a managed manifest; remove it or name it in .gittargetignore | +| 6-hostile/mixed-and-hostile | 0 | Partial | 3 | 2 | plain=4, refused-structural=1 | unparseable | refused-structural [fixable]: kustomization uses unsupported feature(s): unparseable (invalid Kustomization: json: unknown field "spec")
impure-managed-file [fixable]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #1 is a non-KRM document
impure-managed-file [fixable]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #2 is an empty document
foreign-file [fixable]: deployment.json: foreign file deployment.json is not a managed manifest; remove it or name it in .gittargetignore | ## 1-desired-state/argocd-app-of-apps @@ -63,7 +66,7 @@ Unsupported constructs: `none`. Fleet root: `false`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| -| `apps/frontend` | `plain` | false | `frontend` | 6/6/1 | non-krm-yaml: ci-metadata.yaml: YAML is not a Kubernetes manifest | +| `apps/frontend` | `plain` | false | `frontend` | 6/6/1 | non-krm-yaml [fixable]: ci-metadata.yaml: YAML is not a Kubernetes manifest | | `argocd` | `plain` | true | `argocd` | 1/1/0 | none | ## 1-desired-state/flux-monorepo @@ -144,9 +147,9 @@ Unsupported constructs: `configMapGenerator, namePrefix, nameSuffix, remote-base | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| | `apps/backend/base` | `kustomize-single` | true | `-` | 2/2/0 | none | -| `apps/backend/overlays/production` | `refused-structural` | false | `backend-production` | 0/0/0 | refused-structural: kustomization uses unsupported feature(s): remote-base | -| `apps/frontend/overlays/production` | `refused-structural` | false | `frontend-production` | 2/0/3 | refused-structural: kustomization uses unsupported feature(s): configMapGenerator, nameSuffix, secretGenerator | -| `apps/frontend/overlays/staging` | `refused-structural` | false | `frontend-staging` | 2/0/1 | refused-structural: kustomization uses unsupported feature(s): configMapGenerator, namePrefix | +| `apps/backend/overlays/production` | `refused-structural` | false | `backend-production` | 0/0/0 | refused-structural [pending-upstream]: kustomization uses unsupported feature(s): remote-base | +| `apps/frontend/overlays/production` | `refused-structural` | false | `frontend-production` | 2/0/3 | refused-structural [permanent]: kustomization uses unsupported feature(s): configMapGenerator, nameSuffix, secretGenerator | +| `apps/frontend/overlays/staging` | `refused-structural` | false | `frontend-staging` | 2/0/1 | refused-structural [permanent]: kustomization uses unsupported feature(s): configMapGenerator, namePrefix | ## 2-rendered/rendered-manifests @@ -158,8 +161,8 @@ Unsupported constructs: `namePrefix`. Fleet root: `false`. | `argocd` | `plain` | true | `argocd` | 1/1/0 | none | | `rendered/production` | `plain` | true | `frontend-production` | 3/3/0 | none | | `rendered/staging` | `plain` | true | `frontend-staging` | 3/3/0 | none | -| `src/frontend/overlays/production` | `refused-structural` | false | `frontend-production` | 2/0/0 | refused-structural: kustomization uses unsupported feature(s): namePrefix | -| `src/frontend/overlays/staging` | `refused-structural` | false | `frontend-staging` | 2/0/0 | refused-structural: kustomization uses unsupported feature(s): namePrefix | +| `src/frontend/overlays/production` | `refused-structural` | false | `frontend-production` | 2/0/0 | refused-structural [permanent]: kustomization uses unsupported feature(s): namePrefix | +| `src/frontend/overlays/staging` | `refused-structural` | false | `frontend-staging` | 2/0/0 | refused-structural [permanent]: kustomization uses unsupported feature(s): namePrefix | ## 3-expanded/argocd-applicationset-directories @@ -181,7 +184,7 @@ Unsupported constructs: `none`. Fleet root: `false`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| -| `.` | `plain` | false | `argocd` | 1/1/9 | non-krm-yaml: chart/Chart.yaml: YAML is not a Kubernetes manifest
foreign-file: chart/templates/_helpers.tpl: foreign file chart/templates/_helpers.tpl is not a managed manifest; remove it or name it in .gittargetignore
non-krm-yaml: chart/templates/deployment.yaml: YAML is not a Kubernetes manifest
non-krm-yaml: chart/templates/service.yaml: YAML is not a Kubernetes manifest
non-krm-yaml: chart/values.yaml: YAML is not a Kubernetes manifest
non-krm-yaml: deployments/dev/backend.yaml: YAML is not a Kubernetes manifest
non-krm-yaml: deployments/dev/frontend.yaml: YAML is not a Kubernetes manifest
non-krm-yaml: deployments/production/backend.yaml: YAML is not a Kubernetes manifest
non-krm-yaml: deployments/production/frontend.yaml: YAML is not a Kubernetes manifest | +| `.` | `plain` | false | `argocd` | 1/1/9 | non-krm-yaml [fixable]: chart/Chart.yaml: YAML is not a Kubernetes manifest
foreign-file [fixable]: chart/templates/_helpers.tpl: foreign file chart/templates/_helpers.tpl is not a managed manifest; remove it or name it in .gittargetignore
non-krm-yaml [fixable]: chart/templates/deployment.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [fixable]: chart/templates/service.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [fixable]: chart/values.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [fixable]: deployments/dev/backend.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [fixable]: deployments/dev/frontend.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [fixable]: deployments/production/backend.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [fixable]: deployments/production/frontend.yaml: YAML is not a Kubernetes manifest | ## 3-expanded/argocd-multicluster-matrix @@ -202,7 +205,7 @@ Unsupported constructs: `configMapGenerator`. Fleet root: `false`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| -| `apps/frontend` | `refused-structural` | false | `-` | 1/1/1 | refused-structural: kustomization uses unsupported feature(s): configMapGenerator | +| `apps/frontend` | `refused-structural` | false | `-` | 1/1/1 | refused-structural [permanent]: kustomization uses unsupported feature(s): configMapGenerator | | `clusters/production` | `kustomize-single` | true | `flux-system` | 7/7/0 | none | | `infrastructure/controllers/ingress-nginx` | `kustomize-single` | true | `flux-system` | 3/3/0 | none | | `infrastructure/sources` | `kustomize-single` | true | `flux-system` | 3/3/0 | none | @@ -254,8 +257,8 @@ Unsupported constructs: `unparseable`. Fleet root: `false`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| -| `.` | `refused-structural` | false | `backend` | 0/0/5 | refused-structural: kustomization uses unsupported feature(s): unparseable (invalid Kustomization: json: unknown field "spec") | +| `.` | `refused-structural` | false | `backend` | 0/0/5 | refused-structural [fixable]: kustomization uses unsupported feature(s): unparseable (invalid Kustomization: json: unknown field "spec") | | `crossplane` | `plain` | true | `-` | 1/1/0 | none | | `kro` | `plain` | true | `-` | 1/1/0 | none | -| `mixed` | `plain` | false | `-` | 3/3/2 | impure-managed-file: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #1 is a non-KRM document
impure-managed-file: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #2 is an empty document
foreign-file: deployment.json: foreign file deployment.json is not a managed manifest; remove it or name it in .gittargetignore | +| `mixed` | `plain` | false | `-` | 3/3/2 | impure-managed-file [fixable]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #1 is a non-KRM document
impure-managed-file [fixable]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #2 is an empty document
foreign-file [fixable]: deployment.json: foreign file deployment.json is not a managed manifest; remove it or name it in .gittargetignore | | `secrets` | `plain` | true | `backend` | 1/1/0 | none | From ead12ce9e0a1c1055337a9a0b7637ffe8eb3927c Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 27 Jul 2026 08:42:08 +0000 Subject: [PATCH 06/15] refactor(analyzer)!: a refusal says whether it can be solved, in plain words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permanence asked the reader to hold a taxonomy in their head — permanent, pending-upstream, fixable — before they could read a single refusal. The solvability of a refusal belongs in the data, and the data should answer the question a person actually has: can this be solved? `Solvability` is `yes` or `no`, with `Actor` naming who can solve a `yes`. Making this a field means an issue cannot be emitted without explicitly deciding whether it can be solved and, when applicable, by whom. The third value is gone on purpose. `pending-upstream` bought the reader nothing — a folder they cannot pick today is one they cannot pick today, and the action is identical — while putting a roadmap promise in a machine-readable field, which ages into a lie in one direction or the other. So `no` now means "nobody can solve this with this release", never "not ever": a refusal a later release learns to accept simply reports differently on the next scan, because the consumer reads this value rather than a table it cached. What that gives up is the wait-versus-redesign split; what it keeps is the half that mattered, telling the person on the screen when the fix is theirs to make. Renames throughout: the JSON key, both actor constants, the emission-path tests, the goldens, the layout baseline, and the design record — which keeps the reasoning for the four-value shape it originally argued for, so a later change knows what it is overturning. UPGRADING is rewritten in the present tense, and AGENTS.md now says why: an upgrade guide is read after the release ships, so "Unreleased" and "next minor" are false by the time anyone sees them. BREAKING CHANGE: the refusal field is `solvability` (`yes`/`no`), not `permanence` (`fixable`/`pending-upstream`/`permanent`). Co-Authored-By: Claude Opus 5 --- AGENTS.md | 9 + cmd/manifest-analyzer/main_test.go | 16 +- docs/INDEX.md | 2 +- docs/UPGRADING.md | 44 ++-- .../design/analyzer-consumer-contract-asks.md | 213 ++++++++++-------- .../repo-discovery-and-onboarding-scan.md | 18 +- hack/gitops-layouts-baseline.sh | 10 +- internal/git/kustomize_oracle_test.go | 2 +- internal/git/plan_flush.go | 43 ++-- .../git/write_boundary_precondition_test.go | 2 +- ...t.go => write_refusal_solvability_test.go} | 18 +- internal/manifestanalyzer/acceptance.go | 20 +- internal/manifestanalyzer/analyzer.go | 22 +- internal/manifestanalyzer/gittargetignore.go | 10 +- internal/manifestanalyzer/permanence.go | 194 ---------------- internal/manifestanalyzer/scan_repo.go | 57 ++--- internal/manifestanalyzer/solvability.go | 198 ++++++++++++++++ ...permanence_test.go => solvability_test.go} | 185 ++++++++------- internal/manifestanalyzer/store.go | 20 +- .../unsupported/helm-inflation.golden.json | 2 +- .../unsupported/plain-nonkrm.golden.json | 2 +- .../unsupported-kustomize.golden.json | 2 +- pkg/manifestanalyzer/contract_test.go | 4 +- pkg/manifestanalyzer/doc.go | 15 +- pkg/manifestanalyzer/folder.go | 68 +++--- pkg/manifestanalyzer/repo.go | 18 +- test/fixtures/gitops-layouts/support-today.md | 38 ++-- 27 files changed, 641 insertions(+), 591 deletions(-) rename internal/git/{write_refusal_permanence_test.go => write_refusal_solvability_test.go} (88%) delete mode 100644 internal/manifestanalyzer/permanence.go create mode 100644 internal/manifestanalyzer/solvability.go rename internal/manifestanalyzer/{permanence_test.go => solvability_test.go} (65%) diff --git a/AGENTS.md b/AGENTS.md index b959de9c..0737dc9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,6 +133,15 @@ See [Documentation checks](./CONTRIBUTING.md#documentation-checks). - Update API documentation if modifying webhook behavior - Update API documentation if modifying CRDs +### `docs/UPGRADING.md` is read AFTER the release ships + +Nobody reads an upgrade guide before the version exists. Write every entry in the present +tense, describing the release the reader is upgrading to as something that already is: +"`schemaVersion` is gone; `apiVersion` replaces it", never "will be removed" or "now +gains". Do not mark an entry `Unreleased` or `(next minor)` — by the time it is read, both +are false, and a heading that says "Unreleased" about a change shipped six versions ago is +worse than no heading. Name what changed, not when it is going to. + ## VALIDATION SEQUENCE For markdown/docs-only edits, skip this full sequence unless the documentation change depends on or diff --git a/cmd/manifest-analyzer/main_test.go b/cmd/manifest-analyzer/main_test.go index 280e7eab..d062ef03 100644 --- a/cmd/manifest-analyzer/main_test.go +++ b/cmd/manifest-analyzer/main_test.go @@ -140,9 +140,9 @@ func TestRun_ScanJSON(t *testing.T) { Version string `json:"version"` } `json:"generator"` Issues []struct { - Kind string `json:"kind"` - Permanence string `json:"permanence"` - Actor string `json:"actor"` + Kind string `json:"kind"` + Solvability string `json:"solvability"` + Actor string `json:"actor"` } `json:"issues"` } `json:"status"` } @@ -155,14 +155,14 @@ func TestRun_ScanJSON(t *testing.T) { if parsed.Status.Accepted || len(parsed.Status.Issues) == 0 { t.Fatalf("the fixture holds a stray values.yaml and must be refused: %s", out.String()) } - // Every refusal says whether it can stop being one; without it a consumer's only - // honest sentence is "this folder cannot be picked". + // Every refusal says whether it can be solved; without that a consumer's only honest + // sentence is "this folder cannot be picked". for _, issue := range parsed.Status.Issues { - if issue.Permanence == "" { + if issue.Solvability == "" { t.Errorf("issue %q reached a consumer unclassified: %s", issue.Kind, out.String()) } - if issue.Permanence == "fixable" && issue.Actor == "" { - t.Errorf("fixable issue %q does not say who can fix it: %s", issue.Kind, out.String()) + if issue.Solvability == "yes" && issue.Actor == "" { + t.Errorf("solvable issue %q does not say who can solve it: %s", issue.Kind, out.String()) } } if parsed.Status.Generator.Name == "" || parsed.Status.Generator.Version == "" { diff --git a/docs/INDEX.md b/docs/INDEX.md index e5a05645..cdb8e6ff 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -71,7 +71,7 @@ Thirteen other open items: |---|---| | [`docs-linting.md`](design/docs-linting.md) | how to mechanize [`style-guide.md`](style-guide.md) with markdownlint-cli2 and Vale. Both are wired into `task lint`, gated on the files [`.docs-lint-scope`](../.docs-lint-scope) lists rather than the whole tree: 102 of 174 files fail markdownlint and 148 of 174 fail Vale, so the two backlogs need different gates. Open: how the scope list grows to cover the tree, the `MD013` limit, and whether `AGENTS.md` and the chart READMEs are in scope | | [`attribution-fact-identity.md`](design/attribution-fact-identity.md) | several `ClusterProvider`s may name one physical cluster, but a kube-apiserver posts audit to one route, so only one of those names is ever fed and every other one authors `unknown (attribution unresolved)`. Proposes a declared `spec.attribution.auditRoute` that partitions the facts instead of `metadata.name`, so several providers can share one cluster's facts while cloned clusters stay separate, ingestion loses its last Kubernetes read, and a misrouted provider becomes loud. Renames the key infix and the annotation-key flag to the same word | -| [`analyzer-consumer-contract-asks.md`](design/analyzer-consumer-contract-asks.md) | three asks from the first downstream consumer of `pkg/manifestanalyzer`, each the same failure at a different layer — a distinction our code knows, published as prose no test defends. Permanence on a `RefusalReason` (they shipped "not supported yet" for `invalid-yaml`, because one deprecated code was the only signal the type offered); the analyzer's version in its own JSON report, plus a signed release binary; and `ResourceIdentifier.Key()`'s string format as a documented contract with a golden test — it has **no test at all** today. Carries our classification of all seventeen internal `IssueKind` values (the public package exports only fourteen, and three codes can reach a consumer with no constant to match on); open: the per-construct split for `unsupported-kustomize`, and whether `Key()` or `ToGitPath()` is the identity when a preferred version bumps | +| [`analyzer-consumer-contract-asks.md`](design/analyzer-consumer-contract-asks.md) | three asks from the first downstream consumer of `pkg/manifestanalyzer`, each the same failure at a different layer — a distinction our code knows, published as prose no test defends. solvability on a `RefusalReason` (they shipped "not supported yet" for `invalid-yaml`, because one deprecated code was the only signal the type offered); the analyzer's version in its own JSON report, plus a signed release binary; and `ResourceIdentifier.Key()`'s string format as a documented contract with a golden test — it has **no test at all** today. Carries our classification of all seventeen internal `IssueKind` values (the public package exports only fourteen, and three codes can reach a consumer with no constant to match on); open: the per-construct split for `unsupported-kustomize`, and whether `Key()` or `ToGitPath()` is the identity when a preferred version bumps | | [`watch-and-catalog-architecture.md`](design/watch-and-catalog-architecture.md) | the target three-layer watch model — **needs a human call before building** | | [`metrics-observability-plan.md`](design/metrics-observability-plan.md) | the watch-stage metrics do not exist yet | | [`reconcile-triggering.md`](design/reconcile-triggering.md) | which controllers still fail to wake up | diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index abbaddce..db807e09 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -7,13 +7,13 @@ guidance that the changelog's breaking-change entries link to. We are pre-1.0, so breaking changes bump the **minor** version (release-please is configured with `bump-minor-pre-major`) rather than the major. Read the relevant entry before upgrading across it. -## Unreleased: the analyzer report is a KRM document (next minor; breaking JSON change) +## The analyzer report is a KRM document (breaking JSON change) -The two published scan modes — `manifest-analyzer --mode scan-folder|scan-repo --format json` — -and the `pkg/manifestanalyzer` report types now emit a KRM envelope. (`--mode analyze` is -unaffected: it renders the engine's own structural report, which is not part of the published -contract.) `schemaVersion` is gone; `apiVersion` and `kind` replace it, the scan request moves into -`spec`, and everything the scan found moves into `status`: +The two published scan modes (`manifest-analyzer --mode scan-folder|scan-repo --format json`) +and the `pkg/manifestanalyzer` report types emit a KRM envelope. (`--mode analyze` is unaffected: +it renders the engine's own structural report, which is not part of the published contract.) +`schemaVersion` is gone; `apiVersion` and `kind` replace it, the scan request lives in `spec`, and +everything the scan found lives in `status`: ```json { @@ -36,7 +36,7 @@ that does not know the version it is handed should refuse it rather than best-ef adding a field is still not a bump. The report is never served, never registered, and not applyable, which is why it carries no `metadata`. -Three things arrive with the envelope: +Three things come with the envelope: - **`status.generator`** names the build that produced the report (`{name, version}`), so a document that outlives the process that made it still says which release decided its contents. @@ -44,32 +44,32 @@ Three things arrive with the envelope: toolchain records for `go install ...@vX.Y.Z`, otherwise the literal `"dev"`. - **`manifest-analyzer --version`** prints that release and the report `apiVersion` together, so one exec answers both questions. -- **Every refusal now says whether it can stop being one.** `permanence` is one of `fixable`, - `pending-upstream` or `permanent`, and `actor` (`repository-author` or `platform-operator`) says - who can clear a fixable one. See below. +- **Every refusal says whether it can be solved.** `solvability` is `yes` or `no`, and `actor` + (`repository-author` or `platform-operator`) names who can solve a `yes`. See below. -### Refusals carry a permanence, and three more codes are published +### Refusals say whether they can be solved, and three more codes are published -`Issue` and `RefusalReason` gained `permanence` and `actor`, both omitted when unclassified. They -are set by the check that raised the refusal, because only that check knows: `unsupported-kustomize` -is `fixable` for a broken build file, `pending-upstream` for a remote base or Helm inflation, and -`permanent` for a generator. No per-code table can express that. +`Issue` and `RefusalReason` carry `solvability` and `actor`, both omitted when the check did not +classify itself. They are set by the check that raised the refusal, because only that check knows: +`unsupported-kustomize` is `yes` for a build file the author broke and `no` for a generator, and no +per-code table can express that. `solvability` describes the release you are running and makes no +promise about the future, so read it on every scan rather than caching a mapping from it. -`pkg/manifestanalyzer` also now publishes `IssueRenderRefused`, `IssueRenderDoesNotMatchLive` and +`pkg/manifestanalyzer` also publishes `IssueRenderRefused`, `IssueRenderDoesNotMatchLive` and `IssueUnplaceableEdit`. The engine could already raise all three, but only a live write does, so they reached a consumer through GitTarget status with no exported constant to match on. -`RefusalReason.Code` is now typed `IssueKind` rather than `string`, which makes "a candidate's -refusal is the acceptance gate's own issue" compile-checked rather than merely stated. +`RefusalReason.Code` is typed `IssueKind` rather than `string`, which makes "a candidate's refusal +is the acceptance gate's own issue" compile-checked rather than merely stated. ### Migration - Read `apiVersion` where you read `schemaVersion`, and refuse a version you do not know. - Move field accesses under `spec`/`status`: `report.Accepted` becomes `report.Status.Accepted`, `report.Root` becomes `report.Spec.Root`, `report.Candidates` becomes `report.Status.Candidates`, - and so on. Golden fixtures generated from the old shape must be regenerated. -- If you inferred permanence from the code (for example, treating `refused-structural` as permanent - and everything else as "not supported yet"), delete that mapping and read `permanence`. An absent - or unrecognized value means *say nothing about the future*. + and so on. Golden fixtures generated from the old shape need regenerating. +- If you inferred from the code whether a refusal can be solved (for example, treating + `refused-structural` as hopeless and everything else as "not supported yet"), delete that mapping + and read `solvability`. An absent or unrecognized value means *say nothing*. - Record the `status.generator.version` you consumed a report with, if you pin our release elsewhere. That is the whole point of the field. diff --git a/docs/design/analyzer-consumer-contract-asks.md b/docs/design/analyzer-consumer-contract-asks.md index d2835e1e..f2b4f01f 100644 --- a/docs/design/analyzer-consumer-contract-asks.md +++ b/docs/design/analyzer-consumer-contract-asks.md @@ -14,9 +14,14 @@ change to the JSON contract, taken now because there are two consumers and both are asking for version clarity in this same document. 2. **The three unclassified refusal codes are decided during implementation** (Ask 1). The - implementing session proposes a permanence per *raise site* from the code, and raises - each as a review comment on its PR for the maintainer to accept or overturn. They do not - ship as `PermanenceUnknown`, and they do not block the rest of Ask 1. + implementing session proposes an answer per *raise site* from the code, and raises each + as a review comment on its PR for the maintainer to accept or overturn. They do not ship + as `SolvabilityUnknown`, and they do not block the rest of Ask 1. + +3. **The field is `Solvability`, and it is two-valued** (Ask 1, decided 2026-07-27, + overturning the four-value proposal recorded further down this document). It answers + one question in words a non-native speaker reads without a glossary: *can this refusal + be solved?* `yes` (with an `Actor` naming who) or `no`. See "The shape". Each ask is one PR, in the order below — Ask 3 is unblocked, Ask 1 is scaffolding plus fourteen settled classifications, Ask 2 carries the breaking change. @@ -44,7 +49,7 @@ to solve it. --- -## Ask 1 — put the permanence of a refusal in the data +## Ask 1 — the solvability of a refusal belongs in the data ### What they asked for @@ -55,9 +60,9 @@ taxonomy one level down. ### Why — verified The consumer's onboarding wizard shows a human every folder in their repository and why -each one cannot be picked. *"Not supported yet"* is a wait; *"cannot be synced"* is a -redesign; and the two sentences were split on the only signal the type offers, a match on -one code: +each one cannot be picked. *"Go fix this file"* and *"this folder cannot be adopted"* are +different sentences to write, and they were split on the only signal the type offers, a +match on one code: ```go if r.Code == manifestanalyzer.ReasonRefusedStructural { @@ -85,8 +90,8 @@ two places: the literal `ReasonRefusedStructural`, and `issuesToReasons` ([`internal/manifestanalyzer/scan_repo.go:409`](../../internal/manifestanalyzer/scan_repo.go)), which sets `Code: string(iss.Kind)` for **every** acceptance issue. So the live value set is the **seventeen** internal `IssueKind` constants plus `refused-structural` — and most of -them are neither permanent nor pending. They are **fixable today by the person looking at -the screen**. A folder refused for `invalid-yaml` is one broken document from working, and +them are neither the support boundary nor a missing feature. They are **solvable today by +the person looking at the screen**. A folder refused for `invalid-yaml` is one broken document from working, and its owner was told "not supported yet". **A second gap surfaced while counting them, and it needs fixing in the same pass.** @@ -130,56 +135,66 @@ Two fixes, both cheap: kinds. As written it claims to be the enumeration, and it is not. They are correct that no consumer can maintain this table outside our repo. We added -fifteen codes without any of them carrying permanence. +fifteen codes without any of them saying whether they can be solved. ### The shape Additive, and inert for anyone who ignores it: ```go -// Permanence says whether a refusal can ever stop being one. It is set by the check -// that raised the refusal, because only that check knows. Consumers MUST treat an -// unrecognised or absent value as PermanenceUnknown and say nothing about the future. -type Permanence string +// Solvability answers one question about a refusal: can it be solved? It is set by +// the check that raised it, because only that check knows. It describes THIS +// RELEASE and promises nothing about the future. Consumers MUST treat an +// unrecognised or absent value as SolvabilityUnknown and say nothing. +type Solvability string const ( - PermanenceUnknown Permanence = "" // not classified; say nothing - PermanenceFixable Permanence = "fixable" // change the repo or the GitTarget - PermanencePending Permanence = "pending-upstream" // a future release may accept it - PermanencePermanent Permanence = "permanent" // the support boundary; never a "not yet" + SolvabilityUnknown Solvability = "" // not classified; say nothing + SolvabilityYes Solvability = "yes" // someone can solve it now; Actor says who + SolvabilityNo Solvability = "no" // nobody can solve it with this release ) -// Actor names who can act on a fixable refusal. Empty when unclassified or when -// nobody can act. +// Actor names who can solve a refusal. Empty unless Solvability is SolvabilityYes. type Actor string const ( - ActorUnknown Actor = "" - ActorAuthor Actor = "repository-author" // the person who owns the files - ActorPlatform Actor = "platform-operator" // the person who owns the GitTarget + ActorUnknown Actor = "" + ActorRepositoryAuthor Actor = "repository-author" // the person who owns the files + ActorPlatformOperator Actor = "platform-operator" // the person who owns the GitTarget ) type RefusalReason struct { - Code string `json:"code"` - Detail string `json:"detail"` - // Permanence is empty when the check did not classify itself. - Permanence Permanence `json:"permanence,omitempty"` - // Actor is empty unless Permanence is PermanenceFixable. + Code IssueKind `json:"code"` + Detail string `json:"detail"` + // Solvability is empty when the check did not classify itself. + Solvability Solvability `json:"solvability,omitempty"` + // Actor is empty unless Solvability is SolvabilityYes. Actor Actor `json:"actor,omitempty"` } ``` +Making this a field means an issue cannot be emitted without explicitly deciding whether it +can be solved and, when applicable, by whom. + `Issue` takes both fields for the same reason: a policy that treats an issue as blocking is making this decision already, without the data to make it. -**Four values, where the second team proposed two** (`permanent | conditional`). Take the -four deliberately: a two-valued enum drops the *not-yet* axis, which is the exact -distinction that went degenerate when `ReasonOverlayFanOutUnsupported` was retired and the -exact one that made their table wrong. Collapsing `pending-upstream` into `conditional` -tells a user to go fix something no user can fix. And `PermanenceUnknown` has to exist as -the zero value regardless, or an unclassified path emits a confident wrong answer instead -of silence. Their `retryable` naming is worth considering for the `fixable` value; the axis -count is the part not to compromise on. +**Two values, decided 2026-07-27.** This document originally argued for four, keeping a +*not-yet* axis (`pending-upstream`) beside the boundary. That was overturned during +implementation, for two reasons. It buys the reader nothing: a folder they cannot pick +today is one they cannot pick today, and the action is identical either way. And it is a +roadmap promise living in a machine-readable field, which ages into a lie in one direction +or the other — we either never ship the thing we hinted at, or someone restructures a +repository because we said "permanent" about something we shipped two releases later. So +`no` means *nobody can solve this with this release*, never *not ever*, and a refusal a +later release learns to accept simply reports differently on the next scan, because the +consumer reads this value rather than a table it cached. + +What that gives up, stated plainly: the consumer can no longer split "not supported yet" +from "cannot be synced" on this field alone. The bigger win survives intact — separating +*solvable today, by this person* from everything else — and that was always the half most +of their codes needed. `SolvabilityUnknown` still has to exist as the zero value, or an +unclassified path emits a confident wrong answer instead of silence. They also note that adding a field is additive and need not move `SchemaVersion`. Correct, and it stays correct under the KRM envelope recommended in Ask 2 — that envelope is a @@ -200,60 +215,76 @@ seventeen internal kinds; `refused-structural` is the eighteenth value and is no `IssueKind`. Check that arithmetic when adding a kind — it is the only thing keeping this table honest until the test below exists. -| Code | Permanence | Actor | Basis | +| Code | Solvable | Actor | Basis | |---|---|---|---| -| `invalid-yaml` | fixable | author | the document does not parse | -| `duplicate-identity` | fixable | author | two documents claim one identity | -| `impure-managed-file` | fixable | author | split the file | -| `mixed-managed-allowlisted` | fixable | author | move the kustomization to its own file | -| `ignore-shadows-managed` | fixable | author | narrow the `.gittargetignore` pattern | -| `non-krm-yaml` | fixable | author | remove it, or ignore it | -| `foreign-file` | fixable | author | remove it, or ignore it | -| `foreign-symlink` | fixable | author | the *rule* is permanent, the *folder* is not — remove the link | -| `foreign-submodule` | fixable | author | as above; relocate the submodule | -| `out-of-scope` | fixable | **platform** | widen the GitTarget's scope | -| `write-escapes-scope` | fixable | **platform** | widen `spec.path`, or re-place the write | -| `render-does-not-match-live` | fixable | **platform** | a diverged live value is out-of-band substitution, not a render artifact | -| `write-fan-in` | **pending** | — | the doc comment says per-render-root scoping generalizes this | -| `unplaceable-edit` | **permanent** | — | its comment argues the alternative is measurably wrong, not merely risky | -| `refused-structural` | **permanent** | — | the support boundary by definition | +| `invalid-yaml` | yes | author | the document does not parse | +| `duplicate-identity` | yes | author | two documents claim one identity | +| `impure-managed-file` | yes | author | split the file | +| `mixed-managed-allowlisted` | yes | author | move the kustomization to its own file | +| `ignore-shadows-managed` | yes | author | narrow the `.gittargetignore` pattern | +| `non-krm-yaml` | yes | author | remove it, or ignore it | +| `foreign-file` | yes | author | remove it, or ignore it | +| `foreign-symlink` | yes | author | the *rule* stands, the *folder* is one commit away — remove the link | +| `foreign-submodule` | yes | author | as above; relocate the submodule | +| `out-of-scope` | yes | **platform** | widen the GitTarget's scope | +| `write-escapes-scope` | yes | **platform** | widen `spec.path`, or re-place the write | +| `render-does-not-match-live` | yes | **platform** | a diverged live value is out-of-band substitution, not a render artifact | +| `write-fan-in` | **no** | — | the edit has nowhere safe to land while two render roots share the file | +| `unplaceable-edit` | **no** | — | its comment argues the alternative is measurably wrong, not merely risky | +| `refused-structural` | **no** | — | the support boundary, unless the constructs behind it are the author's to fix | Note what the `foreign-symlink` row settles, because it is the rule for every future -check: **permanence classifies the folder's prospects, not the rule's.** A rule we will -never relax can still produce a refusal the author clears in one commit. Classify what the -reader can do, since that is the sentence the field exists to write. +check: **solvability describes the folder, not the rule.** A rule we will never relax can +still produce a refusal the author clears in one commit. Classify what the reader can do, +since that is the sentence the field exists to write. ### Three that need a decision, not a lookup **How these three get settled — decided.** The implementing session reads each raise site, -proposes a permanence for it, and raises the proposal as a review comment on its own PR for -the maintainer to accept or overturn. They ship classified, never as `PermanenceUnknown`, -and they do not hold up the fourteen above. The starting positions below are inputs to that -proposal, not conclusions. - -**`unsupported-kustomize` — must be classified per construct at the raise site.** This is -the case that proves the whole ask: one code, both answers. Its doc comment lists -generators, components, Helm inflation, replacements, transformers, name prefixes and -remote bases; `v0.37.0` shipped exactly the kind of loosening that moves one of those, and -`patches:` already moved. Suggested starting split, for the implementer to confirm -construct by construct: - -- remote bases, Helm inflation → `pending-upstream` -- generators, replacements, transformers, name prefix/suffix → **decide**; they are the - constructs whose output the writer cannot map back to source at all, which reads - permanent, but so did `patches:` before it moved - -A static per-code map cannot express this. The field can, and it is why the field goes on -the emitted reason rather than into a table beside the constants. - -**`unresolved-krm` — splits at the raise site.** A kind absent because its CRD is not -installed is `fixable`/`platform`. A kind that is ambiguous, unserved, or missing a verb is -`pending` at best. One code, two answers, same argument as above. - -**`kustomize-render-refused` — decide what it classifies.** It refuses a *write*, not a -folder, so "can this folder ever be picked" is the wrong question for it. Either classify -it `permanent` (the oracle will always refuse a write it cannot vouch for) or leave it -`Unknown` deliberately and say so in its comment. +proposes an answer for it, and raises the proposal as a review comment on its own PR for +the maintainer to accept or overturn. They ship classified, never as `SolvabilityUnknown`, +and they do not hold up the fourteen above. **All three are settled below**; the reasoning +is kept so a later change knows what it is overturning. + +**`unsupported-kustomize` — classified per construct at the raise site.** This is the case +that proves the whole ask: one code, both answers. The split is whether a person can do +something about it today: + +- a build file that does not parse, malformed `images`/`replicas`, a patch path outside the + tree, a root kustomize cannot build → `yes`/author. One commit clears it, and no support + boundary is in play. +- generators, components, replacements, transformers, name prefix/suffix, inline and + JSON6902 patches, `vars`, `validators` → `no`. Their output cannot be mapped back to a + source document at all. +- remote bases, Helm inflation, `configurations`/`crds`/`openapi` → `no`. This release does + not fetch or model them, and neither the author nor the platform operator can change that + from where they stand. Under the four-value proposal these were `pending-upstream`; the + two-value decision folds them into `no`, which claims only what is true today. + +When several constructs are present the **least solvable** one wins: fixing a malformed +patch does not make a folder adoptable while it also declares a `configMapGenerator`. + +A static per-code map cannot express any of this. The field can, and it is why the field +goes on the emitted reason rather than into a table beside the constants. + +**`unresolved-krm` — splits at the raise site.** A kind absent because nothing serves it is +`yes`/`platform`: install the CRD and the same folder is adoptable. A kind that is served +but ambiguous, denied by policy, or missing a verb is `no`. One code, two answers, decided +where the registry's answer is still in hand rather than at the acceptance gate, which sees +only the verdict. + +**`kustomize-render-refused` → `no`.** It refuses a *write*, not a folder, so "can this +folder ever be picked" was the wrong question for it. The narrower claim is the true one: +the oracle refuses a write it cannot vouch for, and neither the repository author nor the +platform operator can make it vouch. + +**One row moved during implementation.** `refused-structural` is classified from the same +feature set as `unsupported-kustomize` rather than flatly `no`. Implementing the settled row +verbatim made the two surfaces disagree about one directory: a render root whose build file +merely does not parse read `no` through `refused-structural` while the gate called the same +fault the author's to fix through `unsupported-kustomize`. Two answers about one folder is +the bug this document is about, so both classify from one place, with `no` whenever the +constructs are unknown. ### Implementation notes @@ -264,15 +295,15 @@ it `permanent` (the oracle will always refuse a write it cannot vouch for) or le - Add a test that **every** `IssueKind` constant is classified, failing on a new unclassified kind. That test is the ask. Without it this document is prose again in two releases, which is how we got here. -- One test per constant is **not sufficient**, and the three decision cases above are why: - `unsupported-kustomize`, `unresolved-krm` and `kustomize-render-refused` each classify - differently depending on which branch raised them, so a per-constant test passes while a - new branch emits `PermanenceUnknown`. Cover each emission path, not each constant. This - is the one place the ask costs more than a constant per site, and skipping it reproduces - the original bug one level down. +- One test per constant is **not sufficient**, and the decision cases above are why: + `unsupported-kustomize` and `unresolved-krm` classify differently depending on which + branch raised them, so a per-constant test passes while a new branch emits + `SolvabilityUnknown`. Cover each emission path, not each constant. This is the one place + the ask costs more than a constant per site, and skipping it reproduces the original bug + one level down. - Reconcile the internal and public kind sets in the same pass, so the classification test covers the codes a consumer can actually receive rather than the subset we export. -- `PermanenceUnknown` must stay the zero value so an unclassified path degrades to silence +- `SolvabilityUnknown` must stay the zero value so an unclassified path degrades to silence rather than to a confident wrong sentence. ### Meanwhile, downstream @@ -552,7 +583,7 @@ It makes the change deliberate. Each is the same failure at a different layer: a distinction our code knows, published as prose no test defends. -- Ask 1 — the check knows whether a refusal is forever; the type carries a code. +- Ask 1 — the check knows whether a refusal can be solved; the type carries a code. - Ask 2 — the binary knows its version; the document carries a schema marker. - Ask 3 — `Key()` is an identity contract; the format lives in a comment. diff --git a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md index f157ed46..44342c33 100644 --- a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md +++ b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md @@ -261,16 +261,16 @@ free to move. `apiVersion: manifestanalyzer.configbutler.ai/v1alpha1`, `kind: RepoReport`, the scan request in `spec` (`root`, `mode`) and everything found in `status` (`generator`, `candidates`, `summary`). The candidate blocks below live under `status.candidates`. Per candidate: `path`, -`layout`, `acceptedByOperator`, `refusalReasons[]` (`{code, detail, permanence, actor}`), +`layout`, `acceptedByOperator`, `refusalReasons[]` (`{code, detail, solvability, actor}`), `renderRoot`, `readScope[]`, `inferredNamespace`, `resources`, `overlapsWith[]`. -- **`permanence` says whether a refusal can ever stop being one** (`fixable`, - `pending-upstream`, `permanent`), and `actor` (`repository-author`, `platform-operator`) - says who can clear a fixable one. It is set by the check that raised the refusal, because - the same code answers differently depending on the branch: `unsupported-kustomize` is - `fixable` for a build file the author broke, `pending-upstream` for a remote base or Helm - inflation, and `permanent` for a generator. An absent value means say nothing about the - future. See [analyzer-consumer-contract-asks.md](../analyzer-consumer-contract-asks.md). +- **`solvability` says whether a refusal can be solved** (`yes` or `no`), and `actor` + (`repository-author`, `platform-operator`) names who can solve a `yes`. It is set by the + check that raised the refusal, because the same code answers differently depending on the + branch: `unsupported-kustomize` is `yes` for a build file the author broke and `no` for a + generator or a remote base. It describes the release you are running and promises nothing + about the future; an absent value means say nothing. See + [analyzer-consumer-contract-asks.md](../analyzer-consumer-contract-asks.md). - **`status.generator`** names the build that produced the report, so a document that outlives the process that made it still says which release decided its contents. @@ -291,7 +291,7 @@ in `spec` (`root`, `mode`) and everything found in `status` (`generator`, `candi candidate — `Scan` with `WriterAllowlist` (kustomize build directives **plus** the operator's `.sops.yaml` bootstrap config), so a folder the writer would adopt is not falsely reported refused. A refused plain / self-contained candidate carries the gate's -issues as `refusalReasons` (`{code, detail, permanence, actor}` — duplicate identity, non-KRM YAML, a foreign +issues as `refusalReasons` (`{code, detail, solvability, actor}` — duplicate identity, non-KRM YAML, a foreign file, an unsupported nested kustomization, …), never a bare `false`. The structural gate refuses `openapi`/`crds` alongside `configurations`, matching the [support boundary §1](kustomize-support-boundary.md). diff --git a/hack/gitops-layouts-baseline.sh b/hack/gitops-layouts-baseline.sh index 5c0e8309..2d95704e 100755 --- a/hack/gitops-layouts-baseline.sh +++ b/hack/gitops-layouts-baseline.sh @@ -44,7 +44,7 @@ emit_summary_row() { constructs=$(jq -r "$MD_CELL"'(.status.summary.unsupportedConstructs // []) | join(", ") | md | if . == "" then "-" else . end' <<<"$json") signal=$(jq -r "$MD_CELL"'[.status.candidates[]? | select(.acceptedByOperator == false) - | .refusalReasons[]? | "\(.code) [\(.permanence // "unclassified")]: \(.detail)" | md] + | .refusalReasons[]? | "\(.code) [\(.solvability // "unclassified")]: \(.detail)" | md] | if length == 0 then "None" elif length > 4 then (.[0:4] | join("
")) + "
+\(length - 4) more" else join("
") end' <<<"$json") @@ -89,7 +89,7 @@ emit_detail() { | "| `\(.path | md)` | `\(.layout | md)` | \(.acceptedByOperator) | `\(.inferredNamespace // "-" | md)` | " + "\(.resources.rendered // 0)/\(.resources.editable // 0)/\(.resources.nonKrm // 0) | " + ((.refusalReasons // []) - | map("\(.code) [\(.permanence // "unclassified")]: \(.detail)" | md) | join("
") + | map("\(.code) [\(.solvability // "unclassified")]: \(.detail)" | md) | join("
") | if . == "" then "none" else . end) + " |"' <<<"$json" } @@ -117,9 +117,9 @@ Reading rules: input set that lives in a Git-host API. - **A missing candidate matters as much as a refusal**: it means the tool did not explain that part of the repository at all. -- Each refusal reason carries its permanence in brackets — `fixable`, `pending-upstream` - or `permanent`. A refusal that changes permanence has changed what a user is told to do - about it, which is a boundary move like any other and belongs in this diff. +- Each refusal reason carries in brackets whether it can be solved: `yes` or `no`. A + refusal that changes that answer has changed what a user is told to do about it, which + is a boundary move like any other and belongs in this diff. ## Summary diff --git a/internal/git/kustomize_oracle_test.go b/internal/git/kustomize_oracle_test.go index 7d801559..c01fb720 100644 --- a/internal/git/kustomize_oracle_test.go +++ b/internal/git/kustomize_oracle_test.go @@ -143,7 +143,7 @@ func TestPlanFlush_RefusesAWriteThatDragsASiblingAlong(t *testing.T) { // The oracle will always refuse a write it cannot vouch for, so this refusal is // permanent on the narrow claim it makes — never a "not supported yet". assertClassified(t, refused.Issues) - assert.Equal(t, manifestanalyzer.PermanencePermanent, refused.Issues[0].Permanence) + assert.Equal(t, manifestanalyzer.SolvabilityNo, refused.Issues[0].Solvability) assertFileBytes(t, kustPath, sharedEntryKustomizationYAML, "the entry is shared context; editing it would move api too") diff --git a/internal/git/plan_flush.go b/internal/git/plan_flush.go index e56bdee7..44c520e1 100644 --- a/internal/git/plan_flush.go +++ b/internal/git/plan_flush.go @@ -860,10 +860,10 @@ func sourceFormRefusal(filePath string, id manifestedit.Identity, err error) err Issues: []manifestanalyzer.AcceptanceIssue{{ Kind: manifestanalyzer.IssueUnplaceableEdit, Path: filePath, - // Permanent, and deliberately so: the alternative to refusing is aligning two - // lists by position, which is measurably wrong rather than merely risky (see - // the IssueUnplaceableEdit comment). No release lifts this. - Permanence: manifestanalyzer.PermanencePermanent, + // Not solvable, and deliberately so: the alternative to refusing is aligning + // two lists by position, which is measurably wrong rather than merely risky + // (see the IssueUnplaceableEdit comment). Nobody can act on it. + Solvability: manifestanalyzer.SolvabilityNo, Message: fmt.Sprintf("%s/%s in %s: %v", id.Kind, id.Name, filePath, err), }}, @@ -881,12 +881,12 @@ func renderFidelityRefusal( Kind: manifestanalyzer.IssueRenderDoesNotMatchLive, Path: filePath, // A live value that diverges from what the folder renders is out-of-band - // substitution, not a render artifact — whoever owns the deployment pipeline - // can reconcile the two. - Permanence: manifestanalyzer.PermanenceFixable, - Actor: manifestanalyzer.ActorPlatform, - Field: divergence.Field, - Token: divergence.Token, + // substitution, not a render artifact, so whoever owns the deployment + // pipeline can reconcile the two. + Solvability: manifestanalyzer.SolvabilityYes, + Actor: manifestanalyzer.ActorPlatformOperator, + Field: divergence.Field, + Token: divergence.Token, Message: fmt.Sprintf("%s/%s in %s: rendered token %q at %s does not match live", id.Kind, id.Name, filePath, divergence.Token, divergence.Field), }) @@ -926,11 +926,10 @@ func (wb *writeBatch) renderPrecondition() error { issues = append(issues, manifestanalyzer.AcceptanceIssue{ Kind: manifestanalyzer.IssueRenderRefused, Message: reason, - // This refuses a WRITE, not a folder, so "can this folder ever be - // picked" is the wrong question for it. Classified permanent on the - // narrower claim it does make: the oracle will always refuse a write - // it cannot vouch for, and no release changes that. - Permanence: manifestanalyzer.PermanencePermanent, + // This refuses a WRITE, not a folder. Nobody can solve it from the + // repository or the GitTarget: the oracle refuses a write it cannot + // vouch for, and neither side can make it vouch. + Solvability: manifestanalyzer.SolvabilityNo, }) } return &manifestanalyzer.AcceptanceRefusedError{Issues: issues} @@ -1497,8 +1496,8 @@ func (wb *writeBatch) ignoreShadowPrecondition() error { // again here: a pattern narrow enough to pass the initial scan can still // match a write planned later, and that refusal reaches the same reader. // Narrowing the pattern is the author's to do. - Permanence: manifestanalyzer.PermanenceFixable, - Actor: manifestanalyzer.ActorAuthor, + Solvability: manifestanalyzer.SolvabilityYes, + Actor: manifestanalyzer.ActorRepositoryAuthor, Message: fmt.Sprintf( "%s pattern %q shadows the managed write path %s; the operator would be blind to its own "+ "file. Remove the pattern or move the resource out of its match", @@ -1532,8 +1531,8 @@ func (wb *writeBatch) pathScopePrecondition() error { Kind: manifestanalyzer.IssueWriteEscapesScope, Path: rel, // Widening spec.path, or re-placing the write, is the GitTarget owner's call. - Permanence: manifestanalyzer.PermanenceFixable, - Actor: manifestanalyzer.ActorPlatform, + Solvability: manifestanalyzer.SolvabilityYes, + Actor: manifestanalyzer.ActorPlatformOperator, Message: fmt.Sprintf( "planned write path %q escapes the GitTarget write scope: the operator only ever writes "+ "inside spec.path (reads may reach shared context such as ../../base, writes never leave it)", @@ -1585,9 +1584,9 @@ func (wb *writeBatch) fanInPrecondition() error { issues = append(issues, manifestanalyzer.AcceptanceIssue{ Kind: manifestanalyzer.IssueWriteFanIn, Path: rel, - // Not the boundary: per-render-root scoping generalizes this, so a future - // release may accept the same edit. Nobody can clear it today. - Permanence: manifestanalyzer.PermanencePending, + // Nobody can solve this from the repository or the GitTarget: the edit + // has nowhere safe to land while two render roots share the file. + Solvability: manifestanalyzer.SolvabilityNo, Message: fmt.Sprintf( "planned write to %q would edit in place a source file that more than one kustomize render "+ "root reaches (write-fan-in must be 1); refusing rather than "+ diff --git a/internal/git/write_boundary_precondition_test.go b/internal/git/write_boundary_precondition_test.go index 26974068..c64037d4 100644 --- a/internal/git/write_boundary_precondition_test.go +++ b/internal/git/write_boundary_precondition_test.go @@ -156,7 +156,7 @@ func TestFanInPrecondition_RefusesAmbiguousOverrideWriteThrough(t *testing.T) { assertClassified(t, issues) for _, issue := range issues { if issue.Kind == manifestanalyzer.IssueWriteFanIn { - assert.Equal(t, manifestanalyzer.PermanencePending, issue.Permanence) + assert.Equal(t, manifestanalyzer.SolvabilityNo, issue.Solvability) } } diff --git a/internal/git/write_refusal_permanence_test.go b/internal/git/write_refusal_solvability_test.go similarity index 88% rename from internal/git/write_refusal_permanence_test.go rename to internal/git/write_refusal_solvability_test.go index fee840b1..375f769c 100644 --- a/internal/git/write_refusal_permanence_test.go +++ b/internal/git/write_refusal_solvability_test.go @@ -43,10 +43,10 @@ func issueKinds(issues []manifestanalyzer.AcceptanceIssue) []manifestanalyzer.Is func assertClassified(t *testing.T, issues []manifestanalyzer.AcceptanceIssue) { t.Helper() for _, issue := range issues { - assert.NotEqual(t, manifestanalyzer.PermanenceUnknown, issue.Permanence, + assert.NotEqual(t, manifestanalyzer.SolvabilityUnknown, issue.Solvability, "%s reached a consumer unclassified: the only honest sentence left is "+ "\"this cannot be written\"", issue.Kind) - if issue.Permanence == manifestanalyzer.PermanenceFixable { + if issue.Solvability == manifestanalyzer.SolvabilityYes { assert.NotEqual(t, manifestanalyzer.ActorUnknown, issue.Actor, "%s is fixable but does not say by whom", issue.Kind) } else { @@ -66,7 +66,7 @@ func TestUnplaceableEditRefusalIsPermanent(t *testing.T) { issues := refusalIssues(t, err) require.Len(t, issues, 1) assert.Equal(t, manifestanalyzer.IssueUnplaceableEdit, issues[0].Kind) - assert.Equal(t, manifestanalyzer.PermanencePermanent, issues[0].Permanence) + assert.Equal(t, manifestanalyzer.SolvabilityNo, issues[0].Solvability) assertClassified(t, issues) } @@ -84,8 +84,8 @@ func TestRenderFidelityRefusalIsFixableByThePlatform(t *testing.T) { issues := refusalIssues(t, err) require.Len(t, issues, 1) assert.Equal(t, manifestanalyzer.IssueRenderDoesNotMatchLive, issues[0].Kind) - assert.Equal(t, manifestanalyzer.PermanenceFixable, issues[0].Permanence) - assert.Equal(t, manifestanalyzer.ActorPlatform, issues[0].Actor) + assert.Equal(t, manifestanalyzer.SolvabilityYes, issues[0].Solvability) + assert.Equal(t, manifestanalyzer.ActorPlatformOperator, issues[0].Actor) assertClassified(t, issues) } @@ -107,8 +107,8 @@ func TestIgnoreShadowRefusalIsFixableByTheAuthor(t *testing.T) { issues := refusalIssues(t, wb.ignoreShadowPrecondition()) require.Len(t, issues, 1) assert.Equal(t, manifestanalyzer.IssueIgnoreShadowsManaged, issues[0].Kind) - assert.Equal(t, manifestanalyzer.PermanenceFixable, issues[0].Permanence) - assert.Equal(t, manifestanalyzer.ActorAuthor, issues[0].Actor) + assert.Equal(t, manifestanalyzer.SolvabilityYes, issues[0].Solvability) + assert.Equal(t, manifestanalyzer.ActorRepositoryAuthor, issues[0].Actor) assertClassified(t, issues) } @@ -122,7 +122,7 @@ func TestPathScopeRefusalIsFixableByThePlatform(t *testing.T) { issues := refusalIssues(t, wb.pathScopePrecondition()) require.Len(t, issues, 1) assert.Equal(t, manifestanalyzer.IssueWriteEscapesScope, issues[0].Kind) - assert.Equal(t, manifestanalyzer.PermanenceFixable, issues[0].Permanence) - assert.Equal(t, manifestanalyzer.ActorPlatform, issues[0].Actor) + assert.Equal(t, manifestanalyzer.SolvabilityYes, issues[0].Solvability) + assert.Equal(t, manifestanalyzer.ActorPlatformOperator, issues[0].Actor) assertClassified(t, issues) } diff --git a/internal/manifestanalyzer/acceptance.go b/internal/manifestanalyzer/acceptance.go index 83f0861d..c56d0eaa 100644 --- a/internal/manifestanalyzer/acceptance.go +++ b/internal/manifestanalyzer/acceptance.go @@ -319,8 +319,8 @@ func duplicateRefusals(store *ManifestStore) []AcceptanceIssue { Path: path, DocumentIndex: loser.DocumentIndex, // Two documents claim one identity; the author deletes or renames one. - Permanence: PermanenceFixable, - Actor: ActorAuthor, + Solvability: SolvabilityYes, + Actor: ActorRepositoryAuthor, Message: fmt.Sprintf("duplicate manifest identity %s at %s#%d; first occurrence at %s#%d", identityRef(dm.ManifestIdentity), path, loser.DocumentIndex, winner.FilePath, winner.DocumentIndex), @@ -372,7 +372,7 @@ func recordlessRefusal(store *ManifestStore, d manifestedit.Diagnostic) (Accepta Kind: IssueNonKRM, Path: d.Path, DocumentIndex: d.DocumentIndex, Message: "YAML is not a Kubernetes manifest", // Remove it, or name it in .gittargetignore. Either way the author acts. - Permanence: PermanenceFixable, Actor: ActorAuthor, + Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor, }, true case manifestedit.ReasonInvalidYAML, manifestedit.ReasonMissingSopsKey: if managed { @@ -381,7 +381,7 @@ func recordlessRefusal(store *ManifestStore, d manifestedit.Diagnostic) (Accepta return AcceptanceIssue{ Kind: IssueInvalidYAML, Path: d.Path, DocumentIndex: d.DocumentIndex, Message: d.Message, // The document does not parse: one broken document away from working. - Permanence: PermanenceFixable, Actor: ActorAuthor, + Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor, }, true case manifestedit.ReasonNonEditable, manifestedit.ReasonDuplicateIdentity: // Accompany a managed record (handled by duplicateRefusals / the planner skip); @@ -399,8 +399,8 @@ func impureIssue(d manifestedit.Diagnostic, what string) AcceptanceIssue { Path: d.Path, DocumentIndex: d.DocumentIndex, // Split the file: the passenger document moves to one of its own. - Permanence: PermanenceFixable, - Actor: ActorAuthor, + Solvability: SolvabilityYes, + Actor: ActorRepositoryAuthor, Message: fmt.Sprintf( "a file with managed resources may contain only valid KRM documents; document #%d is %s", d.DocumentIndex, what), @@ -422,8 +422,8 @@ func mixedFileRefusals(store *ManifestStore) []AcceptanceIssue { Path: rd.Location.Path, DocumentIndex: rd.Location.DocumentIndex, // Move the kustomization into its own file. - Permanence: PermanenceFixable, - Actor: ActorAuthor, + Solvability: SolvabilityYes, + Actor: ActorRepositoryAuthor, Message: "managed resource " + identityRef(rd.Identity) + " must not live in the allowlisted build-directive file " + rd.Location.Path, }) @@ -464,7 +464,7 @@ func mappingRefusal(ref RecordRef, dm *DocumentModel, policy AcceptancePolicy) ( // widen its scope, and their wizard may not have that person on the screen. return refusal(IssueOutOfScope, ref, "followable kind out of this GitTarget's scope: "+identityRef(dm.ManifestIdentity), - Classification{Permanence: PermanenceFixable, Actor: ActorPlatform}), true + Classification{Solvability: SolvabilityYes, Actor: ActorPlatformOperator}), true } return AcceptanceIssue{}, false case MappingNotFollowable: @@ -497,7 +497,7 @@ func refusal(kind IssueKind, ref RecordRef, message string, class Classification Path: ref.FilePath, DocumentIndex: ref.DocumentIndex, Message: message, - Permanence: class.Permanence, + Solvability: class.Solvability, Actor: class.Actor, } } diff --git a/internal/manifestanalyzer/analyzer.go b/internal/manifestanalyzer/analyzer.go index b581e8a9..caf010b7 100644 --- a/internal/manifestanalyzer/analyzer.go +++ b/internal/manifestanalyzer/analyzer.go @@ -147,12 +147,12 @@ type AcceptanceIssue struct { Path string `json:"path"` DocumentIndex int `json:"documentIndex"` Message string `json:"message"` - // Permanence says whether this refusal can ever stop being one, and Actor names who - // can clear it when it is fixable. Both are set by the check that RAISED the issue, - // because only that check knows: several kinds classify differently depending on - // which branch emitted them. Empty means unclassified — say nothing about the future. - Permanence Permanence `json:"permanence,omitempty"` - Actor Actor `json:"actor,omitempty"` + // Solvability says whether this refusal can be solved, and Actor names who can solve + // it when someone can. Both are set by the check that RAISED the issue, because only + // that check knows: several kinds answer differently depending on which branch emitted + // them. Empty means unclassified — say nothing about whether it can be solved. + Solvability Solvability `json:"solvability,omitempty"` + Actor Actor `json:"actor,omitempty"` // Field and Token add structured diagnostics for a render-fidelity refusal. They are empty // for structural acceptance issues, which predate the render-vs-live gate. Field string `json:"field,omitempty"` @@ -545,21 +545,21 @@ func buildIssues( if duplicates[ref] { issues = append(issues, AcceptanceIssue{ Kind: IssueDuplicate, Path: f.Path, DocumentIndex: d.Index, - Message: "duplicate of " + identityRef(d.Identity), - Permanence: PermanenceFixable, Actor: ActorAuthor, + Message: "duplicate of " + identityRef(d.Identity), + Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor, }) } switch d.Class { case ClassNonKRM: issues = append(issues, AcceptanceIssue{ Kind: IssueNonKRM, Path: f.Path, DocumentIndex: d.Index, - Message: "YAML is not a Kubernetes manifest", - Permanence: PermanenceFixable, Actor: ActorAuthor, + Message: "YAML is not a Kubernetes manifest", + Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor, }) case ClassInvalidYAML: issues = append(issues, AcceptanceIssue{ Kind: IssueInvalidYAML, Path: f.Path, DocumentIndex: d.Index, Message: invalidMsgs[ref], - Permanence: PermanenceFixable, Actor: ActorAuthor, + Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor, }) case ClassNonYAML, ClassEmpty, ClassKRM: // Not acceptance issues: ignored files, empty documents, and valid KRM. diff --git a/internal/manifestanalyzer/gittargetignore.go b/internal/manifestanalyzer/gittargetignore.go index 8ecdc656..a28f9807 100644 --- a/internal/manifestanalyzer/gittargetignore.go +++ b/internal/manifestanalyzer/gittargetignore.go @@ -275,8 +275,8 @@ func LoadGitTargetIgnore(content []byte) (*IgnoreMatcher, []AcceptanceIssue) { Kind: IssueIgnoreShadowsManaged, Path: GitTargetIgnoreFileName, // Narrow the pattern: the file that raised this is in the repository. - Permanence: PermanenceFixable, - Actor: ActorAuthor, + Solvability: SolvabilityYes, + Actor: ActorRepositoryAuthor, Message: fmt.Sprintf( "%s pattern %q matches essentially every managed write path and would blind the "+ "operator to its own files; remove it and name only specific passengers", @@ -315,13 +315,13 @@ func foreignContentRefusals(store *ManifestStore) []AcceptanceIssue { Kind: foreignIssueKind(f.Kind), Path: f.Path, Message: foreignMessage(f), - // Permanence classifies the FOLDER's prospects, not the rule's. The refusal + // Solvability classifies the FOLDER's prospects, not the rule's. The refusal // of symlinks and submodules is permanent and will not be relaxed, but the // folder in front of the reader is one `git rm` (or one .gittargetignore // line) from being adoptable — and that is the sentence this field exists to // write. - Permanence: PermanenceFixable, - Actor: ActorAuthor, + Solvability: SolvabilityYes, + Actor: ActorRepositoryAuthor, }) } return out diff --git a/internal/manifestanalyzer/permanence.go b/internal/manifestanalyzer/permanence.go deleted file mode 100644 index 286a3ffa..00000000 --- a/internal/manifestanalyzer/permanence.go +++ /dev/null @@ -1,194 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package manifestanalyzer - -import "sort" - -// Permanence says whether a refusal can ever stop being one. It is set by the check that -// raised the refusal, because only that check knows: the same code can be a wait, a -// redesign, or a one-commit fix depending on which branch emitted it. -// -// Consumers MUST treat an unrecognised or absent value as PermanenceUnknown and say -// nothing about the future. -// -// The taxonomy is deliberately three-valued plus a zero value. Collapsing "fixable" and -// "pending" tells a user to go fix something no user can fix; dropping the zero value -// makes an unclassified path emit a confident wrong answer instead of silence. See -// docs/design/analyzer-consumer-contract-asks.md (Ask 1). -type Permanence string - -const ( - // PermanenceUnknown is the zero value: this refusal was not classified, so say - // nothing about whether it can stop being one. - PermanenceUnknown Permanence = "" - // PermanenceFixable means a change to the repository or the GitTarget clears it - // today. Actor names who can make that change. - PermanenceFixable Permanence = "fixable" - // PermanencePending means the operator does not support this yet and a future - // release may accept it. Nobody on either side can clear it today. - PermanencePending Permanence = "pending-upstream" - // PermanencePermanent means the support boundary: never a "not yet". - PermanencePermanent Permanence = "permanent" -) - -// Actor names who can act on a fixable refusal. It is empty when the refusal is not -// fixable, or when nobody can act. -type Actor string - -const ( - // ActorUnknown is the zero value: unclassified, or nobody can act. - ActorUnknown Actor = "" - // ActorAuthor is the person who owns the files in the repository. - ActorAuthor Actor = "repository-author" - // ActorPlatform is the person who owns the GitTarget — its scope, its path, and the - // CRDs installed in the cluster it mirrors. - ActorPlatform Actor = "platform-operator" -) - -// Classification is the pair a raise site attaches to a refusal. Passing them together -// keeps a fixable refusal from losing the actor half on its way out of the check. -type Classification struct { - Permanence Permanence - Actor Actor -} - -// classified stamps a computed classification onto an issue. Raise sites with one fixed -// answer set Permanence and Actor in the literal instead; this exists for the sites whose -// answer depends on which branch fired. -func (i AcceptanceIssue) classified(c Classification) AcceptanceIssue { - i.Permanence, i.Actor = c.Permanence, c.Actor - return i -} - -// kustomizeFeatureClassification maps every unsupported kustomization construct to its -// own permanence. This is the map the ask exists for: `unsupported-kustomize` is one code -// with three different answers, so classifying it per code would be a confident wrong -// sentence for two thirds of the constructs that raise it. -// -// The three groups: -// -// - author-fixable — the file itself is broken or reaches outside the tree. Nothing -// about the support boundary is in play; one commit clears it. -// - pending-upstream — a construct the operator could model later. A remote base and a -// Helm chart are read-only context whose adoption is the same shape as the -// render-root scoping that already shipped, so calling them permanent would repeat -// the mistake that retired ReasonOverlayFanOutUnsupported. -// - permanent — the constructs whose output the writer cannot map back to a source -// document at all: content synthesised at build time, fields rewritten after the -// source is read, or arbitrary plugin code. This is the same boundary -// LayoutRefusedStructural names. -// -// Every unmodelled kustomization field key must appear here. -// TestEveryUnsupportedKustomizeFeatureIsClassified walks kustomize's own Kustomization -// struct and fails when a field outside supportedKustomizationFields is unclassified, so -// a kustomize bump cannot quietly reintroduce PermanenceUnknown. -func kustomizeFeatureClassification() map[string]Classification { - return map[string]Classification{ - // The author's own file, fixable where it stands. - featureUnparseable: {Permanence: PermanenceFixable, Actor: ActorAuthor}, - featureMalformedImages: {Permanence: PermanenceFixable, Actor: ActorAuthor}, - featureMalformedReplicas: {Permanence: PermanenceFixable, Actor: ActorAuthor}, - featurePatchOutsideTree: {Permanence: PermanenceFixable, Actor: ActorAuthor}, - featureRenderFailed: {Permanence: PermanenceFixable, Actor: ActorAuthor}, - - // Read-only context, or merge semantics, we could model later. A remote base and an - // inflated chart are context the operator does not fetch yet, not content it can - // never own — calling them permanent would repeat the mistake that retired - // ReasonOverlayFanOutUnsupported. - featureRemoteBase: {Permanence: PermanencePending}, - "helmCharts": {Permanence: PermanencePending}, - "helmGlobals": {Permanence: PermanencePending}, - "helmChartInflationGenerator": {Permanence: PermanencePending}, - "configurations": {Permanence: PermanencePending}, - "crds": {Permanence: PermanencePending}, - "openapi": {Permanence: PermanencePending}, - // Deprecated spellings FixKustomization folds before this map is consulted. Seeing - // one here means kustomize stopped folding it, which is ours to fix, not the - // author's. - "bases": {Permanence: PermanencePending}, - "imageTags": {Permanence: PermanencePending}, - - // Build-time synthesis, post-read rewriting, and plugin code: no source document to - // route an edit back into. - featurePatchInline: {Permanence: PermanencePermanent}, - featurePatchJSON6902: {Permanence: PermanencePermanent}, - "components": {Permanence: PermanencePermanent}, - "configMapGenerator": {Permanence: PermanencePermanent}, - "secretGenerator": {Permanence: PermanencePermanent}, - "generators": {Permanence: PermanencePermanent}, - "transformers": {Permanence: PermanencePermanent}, - "replacements": {Permanence: PermanencePermanent}, - "vars": {Permanence: PermanencePermanent}, - "validators": {Permanence: PermanencePermanent}, - "namePrefix": {Permanence: PermanencePermanent}, - "nameSuffix": {Permanence: PermanencePermanent}, - "patchesStrategicMerge": {Permanence: PermanencePermanent}, - "patchesJson6902": {Permanence: PermanencePermanent}, - } -} - -// classifyKustomizeFeatures reduces the unsupported constructs one kustomization declares -// to the single classification the folder's prospects deserve. The most severe answer -// wins: a folder holding both a typo and a configMapGenerator is not fixed by fixing the -// typo, so telling its author to go fix something would be wrong. -// -// An empty or wholly unrecognised feature set degrades to PermanenceUnknown — silence — -// rather than to a guess. -func classifyKustomizeFeatures(features []string) Classification { - classes := kustomizeFeatureClassification() - out := Classification{} - for _, f := range features { - c, known := classes[f] - if !known { - continue - } - if permanenceSeverity(c.Permanence) > permanenceSeverity(out.Permanence) { - out = c - } - } - return out -} - -// The order the taxonomy is reduced in when one kustomization declares several -// unsupported constructs: the most severe answer is the honest one, because fixing the -// least severe leaves the folder exactly as unadoptable as it was. -const ( - severityUnknown = iota - severityFixable - severityPending - severityPermanent -) - -// permanenceSeverity orders the taxonomy for "most severe wins". -func permanenceSeverity(p Permanence) int { - switch p { - case PermanencePermanent: - return severityPermanent - case PermanencePending: - return severityPending - case PermanenceFixable: - return severityFixable - case PermanenceUnknown: - return severityUnknown - } - return severityUnknown -} - -// retainedUnsupportedFeatures names the constructs behind one unsupported retention: the -// parsed feature set, plus featureRenderFailed when kustomize could not build the root at -// all. It returns nothing for a supported retention, so the field is empty exactly when -// Unsupported is false. -func retainedUnsupportedFeatures(unsupported bool, doc *kustomizationDoc, buildFailed bool) []string { - if !unsupported { - return nil - } - var out []string - if doc != nil { - out = append(out, doc.features...) - } - if buildFailed { - out = append(out, featureRenderFailed) - } - sort.Strings(out) - return out -} diff --git a/internal/manifestanalyzer/scan_repo.go b/internal/manifestanalyzer/scan_repo.go index c6cd7503..c1d020cc 100644 --- a/internal/manifestanalyzer/scan_repo.go +++ b/internal/manifestanalyzer/scan_repo.go @@ -55,11 +55,12 @@ const ( // LayoutRefusedStructural is a render root whose kustomization uses a feature the // contextual-namespace writer cannot map back to editable source (helm inflation, // generators, patches, components, name(pre|suf)fix, remote bases, malformed - // images/replicas). This is the permanent support boundary, never a "not yet". + // images/replicas). This is the support boundary: the folder cannot be adopted as it + // stands, and each refusal says whether anyone can solve that. LayoutRefusedStructural Layout = "refused-structural" ) -// ReasonRefusedStructural is the permanent support boundary: a render root whose kustomization +// ReasonRefusedStructural is the support boundary: a render root whose kustomization // uses a construct the writer cannot map back to editable source. It is the only render-root // refusal reason now that external-base overlays are adopted through render-root scoping (the // former forward-looking overlay-fan-out-unsupported reason is retired; the public @@ -73,12 +74,12 @@ const ReasonRefusedStructural = "refused-structural" type RefusalReason struct { Code string `json:"code"` Detail string `json:"detail"` - // Permanence and Actor carry the refusal's own answer to "can this folder ever be - // picked, and by whom" — projected verbatim from the acceptance issue that raised it, - // or set by the raise site for the reasons that are not issue kinds. Empty means - // unclassified; say nothing about the future. - Permanence Permanence `json:"permanence,omitempty"` - Actor Actor `json:"actor,omitempty"` + // Solvability and Actor carry the refusal's own answer to "can this be solved, and by + // whom" — projected verbatim from the acceptance issue that raised it, or set by the + // raise site for the reasons that are not issue kinds. Empty means unclassified; say + // nothing about whether it can be solved. + Solvability Solvability `json:"solvability,omitempty"` + Actor Actor `json:"actor,omitempty"` } // ResourceCounts splits the KRM a candidate covers into what it renders versus what it @@ -400,14 +401,14 @@ func candidateAcceptance(ctx context.Context, fsys fs.FS, dir string) Acceptance if err != nil { return Acceptance{Issues: []AcceptanceIssue{{ Kind: IssueForeignFile, Path: dir, Message: err.Error(), - Permanence: PermanenceFixable, Actor: ActorAuthor, + Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor, }}} } policy := ScanPolicy{Acceptance: AcceptancePolicy{Allowlist: WriterAllowlist()}} return Scan(ctx, sub, nil, nil, policy).Acceptance } -// issuesToReasons is the projection for the permanence pair as well as the code: it is +// issuesToReasons is the projection for the solvability pair as well as the code: it is // already the single choke point through which every acceptance issue becomes a refusal // reason, so a check that classifies itself reaches a consumer without any second table. // @@ -424,10 +425,10 @@ func issuesToReasons(issues []AcceptanceIssue) []RefusalReason { detail = iss.Path + ": " + iss.Message } out = append(out, RefusalReason{ - Code: string(iss.Kind), - Detail: detail, - Permanence: iss.Permanence, - Actor: iss.Actor, + Code: string(iss.Kind), + Detail: detail, + Solvability: iss.Solvability, + Actor: iss.Actor, }) } return out @@ -526,26 +527,26 @@ func reachedResourceFiles(kusts map[string]*kustomizationDoc) map[string]struct{ // refusedStructuralReason builds the render-root refusal, classified by the constructs // that caused it rather than by the code. // -// The code itself reads as the support boundary — it MEANS "the writer cannot map this -// render root back to editable source" — and permanent is the answer whenever the -// constructs are unknown. But the same folder is also judged construct by construct when -// the gate refuses a NESTED kustomization (IssueUnsupportedKustomize), and the two -// surfaces must not hand a consumer two different answers about one directory: a root -// refused for helmCharts would read "pending-upstream" through one and "permanent" -// through the other. Classifying both from the same feature set is what keeps them -// agreeing, and it follows the rule the whole table follows — classify the folder's -// prospects, not the rule's. +// The code itself MEANS "the writer cannot map this render root back to editable source", +// so "no" is the answer whenever the constructs are unknown. But the same folder is judged +// construct by construct when the gate refuses a NESTED kustomization +// (IssueUnsupportedKustomize), and the two surfaces must not hand a consumer two different +// answers about one directory: a root refused only because its kustomization does not +// parse is one commit from being adoptable, and saying "no" there would send its author +// away for nothing. Classifying both from the same feature set is what keeps them +// agreeing, and it follows the rule the whole table follows — describe the folder, not the +// rule. func refusedStructuralReason(doc *kustomizationDoc, content []byte) RefusalReason { reason := RefusalReason{ - Code: ReasonRefusedStructural, - Detail: refusedStructuralDetail(doc, content), - Permanence: PermanencePermanent, + Code: ReasonRefusedStructural, + Detail: refusedStructuralDetail(doc, content), + Solvability: SolvabilityNo, } if doc == nil { return reason } - if class := classifyKustomizeFeatures(doc.features); class.Permanence != PermanenceUnknown { - reason.Permanence, reason.Actor = class.Permanence, class.Actor + if class := classifyKustomizeFeatures(doc.features); class.Solvability != SolvabilityUnknown { + reason.Solvability, reason.Actor = class.Solvability, class.Actor } return reason } diff --git a/internal/manifestanalyzer/solvability.go b/internal/manifestanalyzer/solvability.go new file mode 100644 index 00000000..89ced2ee --- /dev/null +++ b/internal/manifestanalyzer/solvability.go @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: Apache-2.0 + +package manifestanalyzer + +import "sort" + +// Solvability answers one question about a refusal: can it be solved? It is set by the +// check that raised the refusal, because only that check knows — the same code answers +// differently depending on which branch emitted it. +// +// It is a statement about THIS RELEASE, never a promise about the future. "Nobody can +// solve this today" is a fact we can stand behind; "we may support it later" is a roadmap, +// and a roadmap in a machine-readable field ages into a lie in one direction or the other. +// A refusal a later release learns to accept simply reports differently on the next scan, +// because a consumer reads this value rather than a table it cached. +// +// Consumers MUST treat an unrecognised or absent value as SolvabilityUnknown and say +// nothing about whether the refusal can be solved. +// +// See docs/design/analyzer-consumer-contract-asks.md (Ask 1). +type Solvability string + +const ( + // SolvabilityUnknown is the zero value: this refusal was not classified, so say + // nothing about whether it can be solved. + SolvabilityUnknown Solvability = "" + // SolvabilityYes means someone can solve it with the current release, by changing the + // repository or the GitTarget. Actor says who. + SolvabilityYes Solvability = "yes" + // SolvabilityNo means nobody can solve it with the current release: the construct is + // outside the adoption model, or the support is simply not there. Either way the + // folder cannot be adopted as it stands, and no repository or platform change alters + // that. It carries no Actor, because there is nobody to name. + SolvabilityNo Solvability = "no" +) + +// Actor names who can solve a refusal. It is empty unless Solvability is SolvabilityYes: +// naming someone for a refusal they cannot act on is worse than naming nobody. +// +// It exists because some refusals are solvable ONLY by the person who owns the GitTarget, +// who is usually not the person reading the message. Rendering an out-of-scope refusal as +// "fix your repository" to a repository author who cannot is the failure this half +// prevents. +type Actor string + +const ( + // ActorUnknown is the zero value: nobody can solve this refusal today, or it was not + // classified. + ActorUnknown Actor = "" + // ActorRepositoryAuthor is the person who owns the files in the repository. + ActorRepositoryAuthor Actor = "repository-author" + // ActorPlatformOperator is the person who owns the GitTarget — its scope, its path, + // and the CRDs installed in the cluster it mirrors. + ActorPlatformOperator Actor = "platform-operator" +) + +// Classification is the pair a raise site attaches to a refusal. Passing them together +// keeps a solvable refusal from losing the actor half on its way out of the check. +type Classification struct { + Solvability Solvability + Actor Actor +} + +// classified stamps a computed classification onto an issue. Raise sites with one fixed +// answer set Solvability and Actor in the literal instead; this exists for the sites whose +// answer depends on which branch fired. +func (i AcceptanceIssue) classified(c Classification) AcceptanceIssue { + i.Solvability, i.Actor = c.Solvability, c.Actor + return i +} + +// kustomizeFeatureClassification maps every unsupported kustomization construct to its own +// answer. This is the map the ask exists for: `unsupported-kustomize` is one code with two +// very different answers, so classifying it per code would tell most of the people who hit +// it something false. +// +// The split is whether a person can do something about it today: +// +// - solvable by the author — the file itself is broken, or reaches outside the tree. No +// support boundary is in play; one commit clears it. +// - not solvable — content synthesised at build time, fields rewritten after the source +// is read, plugin code, or context this release does not fetch. Nothing the author or +// the platform operator does makes the folder adoptable while it declares them. +// +// Every unmodelled kustomization field key must appear here. +// TestEveryUnsupportedKustomizeFeatureIsClassified walks kustomize's own Kustomization +// struct and fails when a field outside supportedKustomizationFields is unclassified, so a +// kustomize bump cannot quietly reintroduce SolvabilityUnknown. +func kustomizeFeatureClassification() map[string]Classification { + return map[string]Classification{ + // The author's own file, solvable where it stands. + featureUnparseable: {Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, + featureMalformedImages: {Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, + featureMalformedReplicas: {Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, + featurePatchOutsideTree: {Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, + featureRenderFailed: {Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, + + // Read-only context this release does not fetch, and merge semantics it does not + // model. Nobody can make the folder adoptable while it declares them. + featureRemoteBase: {Solvability: SolvabilityNo}, + "helmCharts": {Solvability: SolvabilityNo}, + "helmGlobals": {Solvability: SolvabilityNo}, + "helmChartInflationGenerator": {Solvability: SolvabilityNo}, + "configurations": {Solvability: SolvabilityNo}, + "crds": {Solvability: SolvabilityNo}, + "openapi": {Solvability: SolvabilityNo}, + // Deprecated spellings FixKustomization folds before this map is consulted. Seeing + // one here means kustomize stopped folding it, which is ours to fix, not the + // author's — but it is still not solvable from the repository today. + "bases": {Solvability: SolvabilityNo}, + "imageTags": {Solvability: SolvabilityNo}, + + // Build-time synthesis, post-read rewriting, and plugin code: no source document + // to route an edit back into. + featurePatchInline: {Solvability: SolvabilityNo}, + featurePatchJSON6902: {Solvability: SolvabilityNo}, + "components": {Solvability: SolvabilityNo}, + "configMapGenerator": {Solvability: SolvabilityNo}, + "secretGenerator": {Solvability: SolvabilityNo}, + "generators": {Solvability: SolvabilityNo}, + "transformers": {Solvability: SolvabilityNo}, + "replacements": {Solvability: SolvabilityNo}, + "vars": {Solvability: SolvabilityNo}, + "validators": {Solvability: SolvabilityNo}, + "namePrefix": {Solvability: SolvabilityNo}, + "nameSuffix": {Solvability: SolvabilityNo}, + "patchesStrategicMerge": {Solvability: SolvabilityNo}, + "patchesJson6902": {Solvability: SolvabilityNo}, + } +} + +// classifyKustomizeFeatures reduces the unsupported constructs one kustomization declares +// to the single answer the folder deserves. The LEAST solvable one wins: a folder holding +// both a typo and a configMapGenerator is not adoptable once the typo is fixed, so telling +// its author to go fix something would be a confident wrong sentence. +// +// An empty or wholly unrecognised feature set degrades to SolvabilityUnknown — silence — +// rather than to a guess. +func classifyKustomizeFeatures(features []string) Classification { + classes := kustomizeFeatureClassification() + out := Classification{} + for _, f := range features { + c, known := classes[f] + if !known { + continue + } + if lessSolvable(c.Solvability, out.Solvability) { + out = c + } + } + return out +} + +// How solvable each answer is, most solvable first. Unknown ranks below a real answer, so +// silence never wins over something a check actually decided. +const ( + rankUnknown = iota + rankYes + rankNo +) + +// lessSolvable reports whether a is a less solvable answer than b — the reduction rule for +// a refusal raised by several constructs at once. +func lessSolvable(a, b Solvability) bool { + return solvabilityRank(a) > solvabilityRank(b) +} + +// solvabilityRank orders the answers for "least solvable wins". +func solvabilityRank(s Solvability) int { + switch s { + case SolvabilityNo: + return rankNo + case SolvabilityYes: + return rankYes + case SolvabilityUnknown: + return rankUnknown + } + return rankUnknown +} + +// retainedUnsupportedFeatures names the constructs behind one unsupported retention: the +// parsed feature set, plus featureRenderFailed when kustomize could not build the root at +// all. It returns nothing for a supported retention, so the field is empty exactly when +// Unsupported is false. +func retainedUnsupportedFeatures(unsupported bool, doc *kustomizationDoc, buildFailed bool) []string { + if !unsupported { + return nil + } + var out []string + if doc != nil { + out = append(out, doc.features...) + } + if buildFailed { + out = append(out, featureRenderFailed) + } + sort.Strings(out) + return out +} diff --git a/internal/manifestanalyzer/permanence_test.go b/internal/manifestanalyzer/solvability_test.go similarity index 65% rename from internal/manifestanalyzer/permanence_test.go rename to internal/manifestanalyzer/solvability_test.go index f919bdbb..d3014f34 100644 --- a/internal/manifestanalyzer/permanence_test.go +++ b/internal/manifestanalyzer/solvability_test.go @@ -20,15 +20,15 @@ import ( "github.com/ConfigButler/gitops-reverser/internal/typeset" ) -// This file is the test the ask is actually about. A permanence that is merely written -// down drifts exactly like the doc comments that caused the original bug: the axis went +// This file is the test the ask is actually about. An answer that is merely written down +// drifts exactly like the doc comments that caused the original bug: the code space went // degenerate when a refusal was retired, and nothing said so. Two things are pinned here: // // - every IssueKind constant is classified, discovered from the SOURCE rather than from // a list someone remembers to update, so a new kind fails this test; and -// - every EMISSION PATH is classified, because three kinds answer differently depending -// on which branch raised them. A per-constant test passes while a new branch quietly -// emits PermanenceUnknown, which reproduces the original bug one level down. +// - every EMISSION PATH is classified, because two kinds answer differently depending on +// which branch raised them. A per-constant test passes while a new branch quietly +// emits SolvabilityUnknown, which reproduces the original bug one level down. // // See docs/design/analyzer-consumer-contract-asks.md (Ask 1). @@ -36,32 +36,32 @@ import ( // whose answer depends on the branch that raised it lists every branch it can produce, so // this file never claims a single answer where the code has two. var classificationByKind = map[IssueKind][]Classification{ - IssueInvalidYAML: {{Permanence: PermanenceFixable, Actor: ActorAuthor}}, - IssueDuplicate: {{Permanence: PermanenceFixable, Actor: ActorAuthor}}, - IssueImpureManagedFile: {{Permanence: PermanenceFixable, Actor: ActorAuthor}}, - IssueMixedFile: {{Permanence: PermanenceFixable, Actor: ActorAuthor}}, - IssueIgnoreShadowsManaged: {{Permanence: PermanenceFixable, Actor: ActorAuthor}}, - IssueNonKRM: {{Permanence: PermanenceFixable, Actor: ActorAuthor}}, - IssueForeignFile: {{Permanence: PermanenceFixable, Actor: ActorAuthor}}, - IssueForeignSymlink: {{Permanence: PermanenceFixable, Actor: ActorAuthor}}, - IssueForeignSubmodule: {{Permanence: PermanenceFixable, Actor: ActorAuthor}}, - IssueOutOfScope: {{Permanence: PermanenceFixable, Actor: ActorPlatform}}, - IssueWriteEscapesScope: {{Permanence: PermanenceFixable, Actor: ActorPlatform}}, - IssueRenderDoesNotMatchLive: {{Permanence: PermanenceFixable, Actor: ActorPlatform}}, - IssueWriteFanIn: {{Permanence: PermanencePending}}, - IssueUnplaceableEdit: {{Permanence: PermanencePermanent}}, - IssueRenderRefused: {{Permanence: PermanencePermanent}}, - // One code, three answers — the case that proves the whole ask. + IssueInvalidYAML: {{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}}, + IssueDuplicate: {{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}}, + IssueImpureManagedFile: {{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}}, + IssueMixedFile: {{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}}, + IssueIgnoreShadowsManaged: {{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}}, + IssueNonKRM: {{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}}, + IssueForeignFile: {{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}}, + IssueForeignSymlink: {{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}}, + IssueForeignSubmodule: {{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}}, + IssueOutOfScope: {{Solvability: SolvabilityYes, Actor: ActorPlatformOperator}}, + IssueWriteEscapesScope: {{Solvability: SolvabilityYes, Actor: ActorPlatformOperator}}, + IssueRenderDoesNotMatchLive: {{Solvability: SolvabilityYes, Actor: ActorPlatformOperator}}, + IssueWriteFanIn: {{Solvability: SolvabilityNo}}, + IssueUnplaceableEdit: {{Solvability: SolvabilityNo}}, + IssueRenderRefused: {{Solvability: SolvabilityNo}}, + // One code, two answers — the case that proves the whole ask. A build file the author + // broke is one commit from working; a generator is not solvable at all. IssueUnsupportedKustomize: { - {Permanence: PermanenceFixable, Actor: ActorAuthor}, - {Permanence: PermanencePending}, - {Permanence: PermanencePermanent}, + {Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, + {Solvability: SolvabilityNo}, }, // A kind nothing serves is one CRD install away; a kind that is served but ambiguous, - // denied, or missing a verb is not. + // denied, or missing a verb is not solvable from either side. IssueUnresolvedKRM: { - {Permanence: PermanenceFixable, Actor: ActorPlatform}, - {Permanence: PermanencePending}, + {Solvability: SolvabilityYes, Actor: ActorPlatformOperator}, + {Solvability: SolvabilityNo}, }, } @@ -77,8 +77,8 @@ func TestEveryIssueKindIsClassified(t *testing.T) { for _, kind := range kinds { classes, ok := classificationByKind[IssueKind(kind)] if !ok { - t.Errorf("IssueKind %q has no permanence classification: a consumer receiving it can only say "+ - "\"this folder cannot be picked\". Classify it at its raise site and add it here.", kind) + t.Errorf("IssueKind %q does not say whether it can be solved: a consumer receiving it can only "+ + "say \"this folder cannot be picked\". Classify it at its raise site and add it here.", kind) continue } for _, c := range classes { @@ -87,19 +87,19 @@ func TestEveryIssueKindIsClassified(t *testing.T) { } } -// assertCoherent holds the two invariants every classification must satisfy: it says -// something, and it names an actor exactly when someone can act. +// assertCoherent holds the two invariants every classification must satisfy: it answers +// the question, and it names an actor exactly when someone can act. func assertCoherent(t *testing.T, what string, c Classification) { t.Helper() - if c.Permanence == PermanenceUnknown { - t.Errorf("%q is classified PermanenceUnknown; refusals ship classified", what) + if c.Solvability == SolvabilityUnknown { + t.Errorf("%q is classified SolvabilityUnknown; refusals ship classified", what) } - if c.Permanence == PermanenceFixable && c.Actor == ActorUnknown { - t.Errorf("%q is fixable but names no actor, so it renders as \"fix your repository\" to "+ + if c.Solvability == SolvabilityYes && c.Actor == ActorUnknown { + t.Errorf("%q is solvable but names no actor, so it renders as \"fix your repository\" to "+ "someone who may not be able to", what) } - if c.Permanence != PermanenceFixable && c.Actor != ActorUnknown { - t.Errorf("%q names an actor for a refusal nobody can act on", what) + if c.Solvability != SolvabilityYes && c.Actor != ActorUnknown { + t.Errorf("%q names an actor for a refusal nobody can solve", what) } } @@ -194,14 +194,14 @@ func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { fsys: fstest.MapFS{"broken.yaml": {Data: []byte(brokenYAML)}}, want: []wantIssue{{ kind: IssueInvalidYAML, - class: Classification{Permanence: PermanenceFixable, Actor: ActorAuthor}, + class: Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, }}, }, "non-krm yaml": { fsys: fstest.MapFS{"values.yaml": {Data: []byte(plainYAML)}}, want: []wantIssue{{ kind: IssueNonKRM, - class: Classification{Permanence: PermanenceFixable, Actor: ActorAuthor}, + class: Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, }}, }, "duplicate identity": { @@ -211,14 +211,14 @@ func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { }, want: []wantIssue{{ kind: IssueDuplicate, - class: Classification{Permanence: PermanenceFixable, Actor: ActorAuthor}, + class: Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, }}, }, "impure managed file": { fsys: fstest.MapFS{"mixed.yaml": {Data: []byte(deployYAML + "---\n" + plainYAML)}}, want: []wantIssue{{ kind: IssueImpureManagedFile, - class: Classification{Permanence: PermanenceFixable, Actor: ActorAuthor}, + class: Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, }}, }, "managed resource in a build directive": { @@ -226,7 +226,7 @@ func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { policy: AcceptancePolicy{Allowlist: DefaultAllowlist()}, want: []wantIssue{{ kind: IssueMixedFile, - class: Classification{Permanence: PermanenceFixable, Actor: ActorAuthor}, + class: Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, }}, }, "foreign file": { @@ -236,7 +236,7 @@ func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { }, want: []wantIssue{{ kind: IssueForeignFile, - class: Classification{Permanence: PermanenceFixable, Actor: ActorAuthor}, + class: Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, }}, }, "out of scope": { @@ -247,18 +247,18 @@ func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { }, want: []wantIssue{{ kind: IssueOutOfScope, - class: Classification{Permanence: PermanenceFixable, Actor: ActorPlatform}, + class: Classification{Solvability: SolvabilityYes, Actor: ActorPlatformOperator}, }}, }, // The two unresolved-krm branches, which is the split the raise site exists to // record: a Widget nothing serves is a CRD the platform operator can install; a - // Secret the policy denies is not something anyone clears from here. + // Secret the policy denies is not something anyone can solve from here. "unresolved krm, kind not served": { fsys: fstest.MapFS{"w.yaml": {Data: []byte(widgetYAMLDoc)}}, mapper: typeset.NewSnapshotRegistry(typeset.Snapshot{Generation: 1}), want: []wantIssue{{ kind: IssueUnresolvedKRM, - class: Classification{Permanence: PermanenceFixable, Actor: ActorPlatform}, + class: Classification{Solvability: SolvabilityYes, Actor: ActorPlatformOperator}, }}, }, "unresolved krm, kind served but not followable": { @@ -266,10 +266,10 @@ func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { mapper: typeset.NewSnapshotRegistry(sampleClusterSnapshot()), want: []wantIssue{{ kind: IssueUnresolvedKRM, - class: Classification{Permanence: PermanencePending}, + class: Classification{Solvability: SolvabilityNo}, }}, }, - // The three unsupported-kustomize branches, through the real gate. + // Both unsupported-kustomize branches, through the real gate. "unsupported kustomize, the author's own file": { fsys: fstest.MapFS{ "kustomization.yaml": {Data: []byte("apiVersion: kustomize.config.k8s.io/v1beta1\n" + @@ -278,10 +278,10 @@ func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { policy: AcceptancePolicy{Allowlist: DefaultAllowlist()}, want: []wantIssue{{ kind: IssueUnsupportedKustomize, - class: Classification{Permanence: PermanenceFixable, Actor: ActorAuthor}, + class: Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, }}, }, - "unsupported kustomize, not supported yet": { + "unsupported kustomize, context we do not read": { fsys: fstest.MapFS{ "kustomization.yaml": {Data: []byte("apiVersion: kustomize.config.k8s.io/v1beta1\n" + "kind: Kustomization\nhelmCharts:\n - name: podinfo\n repo: https://example.com\n")}, @@ -289,7 +289,7 @@ func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { policy: AcceptancePolicy{Allowlist: DefaultAllowlist()}, want: []wantIssue{{ kind: IssueUnsupportedKustomize, - class: Classification{Permanence: PermanencePending}, + class: Classification{Solvability: SolvabilityNo}, }}, }, "unsupported kustomize, the support boundary": { @@ -300,7 +300,7 @@ func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { policy: AcceptancePolicy{Allowlist: DefaultAllowlist()}, want: []wantIssue{{ kind: IssueUnsupportedKustomize, - class: Classification{Permanence: PermanencePermanent}, + class: Classification{Solvability: SolvabilityNo}, }}, }, } @@ -327,7 +327,7 @@ func assertIssueClassified(t *testing.T, acc Acceptance, want wantIssue) { if issue.Kind != want.kind { continue } - if got := (Classification{Permanence: issue.Permanence, Actor: issue.Actor}); got != want.class { + if got := (Classification{Solvability: issue.Solvability, Actor: issue.Actor}); got != want.class { t.Errorf("%s at %s: classification = %+v, want %+v", issue.Kind, issue.Path, got, want.class) } return @@ -337,8 +337,8 @@ func assertIssueClassified(t *testing.T, acc Acceptance, want wantIssue) { // TestForeignEntryRefusalsAreClassified covers the symlink and submodule raise sites, // which no in-memory filesystem can produce. They are the rule the whole table follows: -// permanence classifies the FOLDER's prospects, not the rule's. We will never follow a -// symlink, yet the folder in front of the reader is one `git rm` from being adoptable. +// solvability describes the FOLDER, not the rule. We will never follow a symlink, yet the +// folder in front of the reader is one `git rm` from being adoptable. func TestForeignEntryRefusalsAreClassified(t *testing.T) { store := &ManifestStore{Foreign: []ForeignEntry{ {Kind: ForeignSymlink, Path: "link.yaml"}, @@ -349,9 +349,9 @@ func TestForeignEntryRefusalsAreClassified(t *testing.T) { if len(issues) != 3 { t.Fatalf("want three foreign refusals, got %+v", issues) } - want := Classification{Permanence: PermanenceFixable, Actor: ActorAuthor} + want := Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor} for _, issue := range issues { - if got := (Classification{Permanence: issue.Permanence, Actor: issue.Actor}); got != want { + if got := (Classification{Solvability: issue.Solvability, Actor: issue.Actor}); got != want { t.Errorf("%s at %s: classification = %+v, want %+v", issue.Kind, issue.Path, got, want) } } @@ -364,8 +364,8 @@ func TestIgnoreShadowsManagedRefusalIsClassified(t *testing.T) { if len(issues) != 1 || issues[0].Kind != IssueIgnoreShadowsManaged { t.Fatalf("want one ignore-shadows-managed refusal, got %+v", issues) } - want := Classification{Permanence: PermanenceFixable, Actor: ActorAuthor} - if got := (Classification{Permanence: issues[0].Permanence, Actor: issues[0].Actor}); got != want { + want := Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor} + if got := (Classification{Solvability: issues[0].Solvability, Actor: issues[0].Actor}); got != want { t.Errorf("classification = %+v, want %+v", got, want) } } @@ -385,8 +385,8 @@ func TestEveryUnsupportedKustomizeFeatureIsClassified(t *testing.T) { } key := kustomizationFieldKey(field) if _, ok := classes[key]; !ok { - t.Errorf("unmodelled kustomization field %q (%s) has no permanence: it would refuse a folder "+ - "with no answer to \"can this ever be picked\".", key, field.Name) + t.Errorf("unmodelled kustomization field %q (%s) does not say whether it can be solved: it "+ + "would refuse a folder with no answer to \"can this be fixed\".", key, field.Name) } } for _, f := range []string{ @@ -394,7 +394,7 @@ func TestEveryUnsupportedKustomizeFeatureIsClassified(t *testing.T) { featurePatchInline, featurePatchJSON6902, featurePatchOutsideTree, featureRenderFailed, } { if _, ok := classes[f]; !ok { - t.Errorf("feature %q has no permanence classification", f) + t.Errorf("feature %q does not say whether it can be solved", f) } } for f, c := range classes { @@ -402,27 +402,26 @@ func TestEveryUnsupportedKustomizeFeatureIsClassified(t *testing.T) { } } -// TestClassifyKustomizeFeatures_MostSevereWins pins the reduction rule. A folder holding -// both a typo and a generator is not fixed by fixing the typo, so telling its author to go -// fix something would be a confident wrong sentence. -func TestClassifyKustomizeFeatures_MostSevereWins(t *testing.T) { - fixable := Classification{Permanence: PermanenceFixable, Actor: ActorAuthor} - pending := Classification{Permanence: PermanencePending} - permanent := Classification{Permanence: PermanencePermanent} +// TestClassifyKustomizeFeatures_LeastSolvableWins pins the reduction rule. A folder +// holding both a typo and a generator is not adoptable once the typo is fixed, so telling +// its author to go fix something would be a confident wrong sentence. +func TestClassifyKustomizeFeatures_LeastSolvableWins(t *testing.T) { + solvable := Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor} + unsolvable := Classification{Solvability: SolvabilityNo} cases := map[string]struct { features []string want Classification }{ - "nothing at all": {nil, Classification{}}, - "unrecognised only": {[]string{"someFutureField"}, Classification{}}, - "the author's file": {[]string{featureUnparseable}, fixable}, - "not yet": {[]string{featureRemoteBase}, pending}, - "the boundary": {[]string{"configMapGenerator"}, permanent}, - "fixable and pending": {[]string{featureUnparseable, featureRemoteBase}, pending}, - "pending and boundary": {[]string{featureRemoteBase, "vars"}, permanent}, - "fixable and unknown": {[]string{"someFutureField", featureUnparseable}, fixable}, - "boundary over all": {[]string{featureUnparseable, featureRemoteBase, "replacements"}, permanent}, + "nothing at all": {nil, Classification{}}, + "unrecognised only": {[]string{"someFutureField"}, Classification{}}, + "the author's file": {[]string{featureUnparseable}, solvable}, + "context we do not read": {[]string{featureRemoteBase}, unsolvable}, + "the boundary": {[]string{"configMapGenerator"}, unsolvable}, + "solvable and not": {[]string{featureUnparseable, featureRemoteBase}, unsolvable}, + "two unsolvable": {[]string{featureRemoteBase, "vars"}, unsolvable}, + "solvable and unknown": {[]string{"someFutureField", featureUnparseable}, solvable}, + "unsolvable over all": {[]string{featureUnparseable, featureRemoteBase, "replacements"}, unsolvable}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { @@ -438,16 +437,16 @@ func TestClassifyKustomizeFeatures_MostSevereWins(t *testing.T) { // becomes a refusal reason, so a check that classifies itself must arrive intact. func TestIssuesToReasons_CarriesClassification(t *testing.T) { reasons := issuesToReasons([]AcceptanceIssue{{ - Kind: IssueOutOfScope, - Path: "apps/web/deploy.yaml", - Message: "out of scope", - Permanence: PermanenceFixable, - Actor: ActorPlatform, + Kind: IssueOutOfScope, + Path: "apps/web/deploy.yaml", + Message: "out of scope", + Solvability: SolvabilityYes, + Actor: ActorPlatformOperator, }}) if len(reasons) != 1 { t.Fatalf("want one reason, got %+v", reasons) } - if reasons[0].Permanence != PermanenceFixable || reasons[0].Actor != ActorPlatform { + if reasons[0].Solvability != SolvabilityYes || reasons[0].Actor != ActorPlatformOperator { t.Errorf("reason = %+v, want the issue's own classification", reasons[0]) } } @@ -465,8 +464,8 @@ func refusedStructuralReasonIn(rep RepoReport) (RefusalReason, bool) { } // TestRefusedStructuralIsClassified pins the one refusal reason that is not an IssueKind. -// It reads as the support boundary, and stays permanent whenever the constructs behind it -// are unknown — but a root refused for a construct we could support later says so, because +// It reads as the support boundary, and answers "no" whenever the constructs behind it are +// unknown — but a root refused only because its build file does not parse says so, because // the gate says so for the very same folder when a NESTED kustomization declares it. Two // answers about one directory is the bug this whole document is about. func TestRefusedStructuralIsClassified(t *testing.T) { @@ -476,17 +475,17 @@ func TestRefusedStructuralIsClassified(t *testing.T) { kustomization string want Classification }{ - "a generator is the boundary": { + "a generator is not solvable": { kustomization: kustHeader + "configMapGenerator:\n - name: settings\n literals:\n - a=b\n", - want: Classification{Permanence: PermanencePermanent}, + want: Classification{Solvability: SolvabilityNo}, }, - "helm inflation is not supported yet": { + "helm inflation is not solvable": { kustomization: kustHeader + "helmCharts:\n - name: podinfo\n repo: https://example.com\n", - want: Classification{Permanence: PermanencePending}, + want: Classification{Solvability: SolvabilityNo}, }, - "an unparseable build file is the author's to fix": { + "an unparseable build file is the author's to solve": { kustomization: "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources: [\n", - want: Classification{Permanence: PermanenceFixable, Actor: ActorAuthor}, + want: Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, }, } @@ -502,7 +501,7 @@ func TestRefusedStructuralIsClassified(t *testing.T) { if !ok { t.Fatalf("expected a refused-structural candidate, got %+v", rep.Candidates) } - if got := (Classification{Permanence: reason.Permanence, Actor: reason.Actor}); got != tc.want { + if got := (Classification{Solvability: reason.Solvability, Actor: reason.Actor}); got != tc.want { t.Errorf("refused-structural reason = %+v, want %+v", reason, tc.want) } }) diff --git a/internal/manifestanalyzer/store.go b/internal/manifestanalyzer/store.go index d74d9684..c1dc2614 100644 --- a/internal/manifestanalyzer/store.go +++ b/internal/manifestanalyzer/store.go @@ -140,8 +140,8 @@ type RetainedDocument struct { // UnsupportedFeatures names the constructs that made this retention unsupported, as // the user wrote them ("configMapGenerator", "remote-base", "render-failed"). It // carries the parse-time answer to the refusal, which is what lets the acceptance - // gate classify the refusal's permanence per construct instead of per code — one code - // with three answers is the whole reason the permanence field exists. Sorted; empty + // gate decide solvability per construct instead of per code — one code with two very + // different answers is the whole reason the solvability field exists. Sorted; empty // when Unsupported is false. UnsupportedFeatures []string } @@ -231,11 +231,11 @@ type DocumentModel struct { // because no API source is wired in. Mapping MappingOutcome - // MappingRefusal carries the permanence of a MappingNotFollowable outcome, decided + // MappingRefusal carries the solvability of a MappingNotFollowable outcome, decided // where the registry answer is still in hand. A GVK the registry has never heard of // is a CRD the platform operator can install; a GVK it knows but will not follow — - // ambiguous, unserved, missing a verb — is not something either side clears today. - // The acceptance gate sees only the outcome, so the split has to be recorded here. + // ambiguous, unserved, missing a verb — is not something either side can solve. The + // acceptance gate sees only the outcome, so the split has to be recorded here. MappingRefusal Classification // Editable is false for SOPS-encrypted or otherwise non-patchable documents; @@ -1005,12 +1005,12 @@ func (s *ManifestStore) resolveMapping( record, known := lookup.ByGVK(gvk) if !known { // Nothing serves this GVK: install the CRD (or widen what the registry may see) - // and the same folder is adoptable, so this is fixable by the platform operator. - dm.MappingRefusal = Classification{Permanence: PermanenceFixable, Actor: ActorPlatform} + // and the same folder is adoptable, so the platform operator can solve it. + dm.MappingRefusal = Classification{Solvability: SolvabilityYes, Actor: ActorPlatformOperator} } else if !record.Followable() { - // Served, but ambiguous, denied, or missing a verb. Nobody clears that from - // here; a future release deciding to follow it is the only way out. - dm.MappingRefusal = Classification{Permanence: PermanencePending} + // Served, but ambiguous, denied, or missing a verb. Nobody can solve that from + // the repository or the GitTarget. + dm.MappingRefusal = Classification{Solvability: SolvabilityNo} } if known && record.Followable() { dm.Mapping = MappingFollowable diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/helm-inflation.golden.json b/internal/manifestanalyzer/testdata/scan-repo/unsupported/helm-inflation.golden.json index 8e0faba0..b6c3d13d 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/unsupported/helm-inflation.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/unsupported/helm-inflation.golden.json @@ -8,7 +8,7 @@ { "code": "refused-structural", "detail": "kustomization uses unsupported feature(s): helmCharts", - "permanence": "pending-upstream" + "solvability": "no" } ], "renderRoot": true, diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json b/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json index 8de75098..6fbc44a8 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json @@ -8,7 +8,7 @@ { "code": "non-krm-yaml", "detail": "values.yaml: YAML is not a Kubernetes manifest", - "permanence": "fixable", + "solvability": "yes", "actor": "repository-author" } ], diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/unsupported-kustomize.golden.json b/internal/manifestanalyzer/testdata/scan-repo/unsupported/unsupported-kustomize.golden.json index ec841a0d..671f2598 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/unsupported/unsupported-kustomize.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/unsupported/unsupported-kustomize.golden.json @@ -8,7 +8,7 @@ { "code": "refused-structural", "detail": "kustomization uses unsupported feature(s): components, crds, namePrefix, openapi, secretGenerator", - "permanence": "permanent" + "solvability": "no" } ], "renderRoot": true, diff --git a/pkg/manifestanalyzer/contract_test.go b/pkg/manifestanalyzer/contract_test.go index 8ffdf2dc..cb130394 100644 --- a/pkg/manifestanalyzer/contract_test.go +++ b/pkg/manifestanalyzer/contract_test.go @@ -111,9 +111,9 @@ func TestScanFolder_RefusalsCarryPermanence(t *testing.T) { require.NotEmpty(t, report.Status.Issues) for _, issue := range report.Status.Issues { - require.NotEqual(t, manifestanalyzer.PermanenceUnknown, issue.Permanence, + require.NotEqual(t, manifestanalyzer.SolvabilityUnknown, issue.Solvability, "%s reached a consumer unclassified", issue.Kind) - if issue.Permanence == manifestanalyzer.PermanenceFixable { + if issue.Solvability == manifestanalyzer.SolvabilityYes { require.NotEqual(t, manifestanalyzer.ActorUnknown, issue.Actor, "%s is fixable but does not say by whom", issue.Kind) } else { diff --git a/pkg/manifestanalyzer/doc.go b/pkg/manifestanalyzer/doc.go index 7491ccd3..699ed61e 100644 --- a/pkg/manifestanalyzer/doc.go +++ b/pkg/manifestanalyzer/doc.go @@ -38,14 +38,15 @@ // produced the report, so a document that outlives the process that made it still says // which release decided its contents. `manifest-analyzer --version` prints the same pair. // -// # A refusal says whether it can ever stop being one +// # A refusal says whether it can be solved // -// [Issue] and [RefusalReason] carry a [Permanence] and, when something can be done about -// it, an [Actor]. A code alone cannot tell "not supported yet" from "cannot be synced" -// from "one broken document away from working", and guessing from the code is how a -// consumer ends up telling a user to wait for a release that will never come, or to go fix -// something only their platform team can. Treat an absent or unrecognised permanence as -// [PermanenceUnknown] and say nothing about the future. +// [Issue] and [RefusalReason] carry a [Solvability] — "yes" or "no" — and, when someone +// can act, an [Actor]. A code alone cannot tell "one broken document away from working" +// from "this folder cannot be adopted", and guessing from the code is how a consumer ends +// up telling a user to go fix something only their platform team can, or nothing at all. +// The answer describes this release and makes no promise about the future, so read it on +// every scan rather than caching a mapping from it. Treat an absent or unrecognised value +// as [SolvabilityUnknown] and say nothing. // // Everything under internal/ carries no guarantee either, and is not importable from // another module. One format from there is nonetheless a contract you may build on: a diff --git a/pkg/manifestanalyzer/folder.go b/pkg/manifestanalyzer/folder.go index c7f0327b..d50d77f0 100644 --- a/pkg/manifestanalyzer/folder.go +++ b/pkg/manifestanalyzer/folder.go @@ -70,44 +70,50 @@ const ( IssueUnplaceableEdit IssueKind = "unplaceable-edit" ) -// Permanence says whether a refusal can ever stop being one. It is set by the check that -// raised the refusal, because only that check knows — several codes classify differently -// depending on which branch emitted them, which is exactly why this is a field on the -// emitted value and not a table beside the constants. +// Solvability answers one question about a refusal: can it be solved? It is set by the +// check that raised the refusal, because only that check knows — one code answers +// differently depending on which branch emitted it, which is why this is a field on the +// emitted value rather than a table beside the constants. // -// Consumers MUST treat an unrecognised or absent value as [PermanenceUnknown] and say -// nothing about the future. "Not supported yet" is a wait, "cannot be synced" is a -// redesign, and "fix your YAML" is neither; a code alone cannot tell them apart. -type Permanence string +// It describes THIS RELEASE and makes no promise about the future. A refusal a later +// release learns to accept simply reports differently on the next scan, so read this value +// each time rather than caching a mapping from it. +// +// Consumers MUST treat an unrecognised or absent value as [SolvabilityUnknown] and say +// nothing about whether the refusal can be solved. A code alone cannot tell "fix your +// YAML" from "this folder cannot be adopted"; that is what this field is for. +type Solvability string const ( - // PermanenceUnknown is the zero value: not classified. Say nothing. - PermanenceUnknown Permanence = "" - // PermanenceFixable means changing the repository or the GitTarget clears it today. - // [Actor] says who can make that change. - PermanenceFixable Permanence = "fixable" - // PermanencePending means a future release may accept it. Nobody can clear it today. - PermanencePending Permanence = "pending-upstream" - // PermanencePermanent is the support boundary: never a "not yet". - PermanencePermanent Permanence = "permanent" + // SolvabilityUnknown is the zero value: not classified. Say nothing. + SolvabilityUnknown Solvability = "" + // SolvabilityYes means someone can solve it with this release, by changing the + // repository or the GitTarget. [Actor] says who. + SolvabilityYes Solvability = "yes" + // SolvabilityNo means nobody can solve it with this release: the construct is outside + // the adoption model, or the support is simply not there. The folder cannot be adopted + // as it stands, and no [Actor] is named because there is nobody to name. + SolvabilityNo Solvability = "no" ) -// Actor names who can act on a fixable refusal. It is empty when the refusal is not -// fixable, or when nobody can act. +// Actor names who can solve a refusal. It is empty unless [Solvability] is +// [SolvabilityYes] — naming someone for a refusal they cannot act on is worse than naming +// nobody. // -// It matters because some refusals are fixable ONLY by the person who owns the GitTarget, +// It matters because some refusals are solvable ONLY by the person who owns the GitTarget, // who is often not the person reading the message: rendering an out-of-scope refusal as -// "fix your repository" to a repository author who cannot is worse than saying nothing. +// "fix your repository" to a repository author who cannot is the failure this half +// prevents. type Actor string const ( - // ActorUnknown is the zero value: unclassified, or nobody can act. + // ActorUnknown is the zero value: nobody can solve it today, or it was not classified. ActorUnknown Actor = "" - // ActorAuthor is the person who owns the files in the repository. - ActorAuthor Actor = "repository-author" - // ActorPlatform is the person who owns the GitTarget — its scope, its path, and the - // CRDs installed in the cluster it mirrors. - ActorPlatform Actor = "platform-operator" + // ActorRepositoryAuthor is the person who owns the files in the repository. + ActorRepositoryAuthor Actor = "repository-author" + // ActorPlatformOperator is the person who owns the GitTarget — its scope, its path, + // and the CRDs installed in the cluster it mirrors. + ActorPlatformOperator Actor = "platform-operator" ) // Issue is one reason a folder is not accepted, or one fact a stricter policy may treat @@ -121,9 +127,9 @@ type Issue struct { DocumentIndex int `json:"documentIndex"` // Message is a human-readable explanation. It is not a stable string. Message string `json:"message"` - // Permanence is empty when the check did not classify itself. - Permanence Permanence `json:"permanence,omitempty"` - // Actor is empty unless Permanence is [PermanenceFixable]. + // Solvability is empty when the check did not classify itself. + Solvability Solvability `json:"solvability,omitempty"` + // Actor is empty unless Solvability is [SolvabilityYes]. Actor Actor `json:"actor,omitempty"` } @@ -242,7 +248,7 @@ func folderReportFrom(acc internalanalyzer.Acceptance) FolderReport { Path: issue.Path, DocumentIndex: issue.DocumentIndex, Message: issue.Message, - Permanence: Permanence(issue.Permanence), + Solvability: Solvability(issue.Solvability), Actor: Actor(issue.Actor), }) } diff --git a/pkg/manifestanalyzer/repo.go b/pkg/manifestanalyzer/repo.go index 1b1a1228..2bdf7225 100644 --- a/pkg/manifestanalyzer/repo.go +++ b/pkg/manifestanalyzer/repo.go @@ -27,8 +27,8 @@ const ( // refused for a real fault carries that fault's own code, never a forward-looking one. LayoutKustomizeOverlay Layout = "kustomize-overlay" // LayoutRefusedStructural is a render root whose kustomization uses a construct the - // writer cannot map back to editable source. This is the permanent support boundary, - // never a "not yet". + // writer cannot map back to editable source. This is the support boundary; each + // refusal it carries says whether anyone can solve it. LayoutRefusedStructural Layout = "refused-structural" ) @@ -59,10 +59,10 @@ type RefusalReason struct { Code IssueKind `json:"code"` // Detail is human-readable and not a stable string. Detail string `json:"detail"` - // Permanence says whether this refusal can ever stop being one. Empty when the check + // Solvability says whether this refusal can ever stop being one. Empty when the check // that raised it did not classify itself; treat that as "say nothing". - Permanence Permanence `json:"permanence,omitempty"` - // Actor is empty unless Permanence is [PermanenceFixable]. + Solvability Solvability `json:"solvability,omitempty"` + // Actor is empty unless Solvability is [SolvabilityYes]. Actor Actor `json:"actor,omitempty"` } @@ -213,10 +213,10 @@ func candidateFrom(cand internalanalyzer.RepoCandidate) Candidate { } for _, reason := range cand.RefusalReasons { out.RefusalReasons = append(out.RefusalReasons, RefusalReason{ - Code: IssueKind(reason.Code), - Detail: reason.Detail, - Permanence: Permanence(reason.Permanence), - Actor: Actor(reason.Actor), + Code: IssueKind(reason.Code), + Detail: reason.Detail, + Solvability: Solvability(reason.Solvability), + Actor: Actor(reason.Actor), }) } return out diff --git a/test/fixtures/gitops-layouts/support-today.md b/test/fixtures/gitops-layouts/support-today.md index 5f9f0194..00358460 100644 --- a/test/fixtures/gitops-layouts/support-today.md +++ b/test/fixtures/gitops-layouts/support-today.md @@ -19,33 +19,33 @@ Reading rules: input set that lives in a Git-host API. - **A missing candidate matters as much as a refusal**: it means the tool did not explain that part of the repository at all. -- Each refusal reason carries its permanence in brackets — `fixable`, `pending-upstream` - or `permanent`. A refusal that changes permanence has changed what a user is told to do - about it, which is a boundary move like any other and belongs in this diff. +- Each refusal reason carries in brackets whether it can be solved: `yes` or `no`. A + refusal that changes that answer has changed what a user is told to do about it, which + is a boundary move like any other and belongs in this diff. ## Summary | Fixture | rc | Outcome | Accepted | Refused | Layouts | Unsupported constructs | Reported refusal signal | |---|---:|---|---:|---:|---|---|---| | 1-desired-state/argocd-app-of-apps | 0 | All reported candidates accepted | 4 | 0 | plain=4 | - | None | -| 1-desired-state/argocd-plain | 0 | Partial | 1 | 1 | plain=2 | - | non-krm-yaml [fixable]: ci-metadata.yaml: YAML is not a Kubernetes manifest | +| 1-desired-state/argocd-plain | 0 | Partial | 1 | 1 | plain=2 | - | non-krm-yaml [yes]: ci-metadata.yaml: YAML is not a Kubernetes manifest | | 1-desired-state/flux-monorepo | 0 | All reported candidates accepted | 6 | 0 | kustomize-overlay=2, kustomize-single=4 | - | None | | 1-desired-state/repo-per-environment | 0 | All reported candidates accepted | 9 | 0 | plain=9 | - | None | | 2-rendered/argocd-external-helm | 0 | All reported candidates accepted | 3 | 0 | plain=3 | - | None | | 2-rendered/helm-chart | 0 | All reported candidates accepted | 1 | 0 | plain=1 | - | None | | 2-rendered/helm-environment-values | 0 | All reported candidates accepted | 1 | 0 | plain=1 | - | None | | 2-rendered/kustomize-overlay-minimal | 0 | All reported candidates accepted | 2 | 0 | kustomize-overlay=2 | - | None | -| 2-rendered/kustomize-overlays | 0 | Partial | 1 | 3 | kustomize-single=1, refused-structural=3 | configMapGenerator, namePrefix, nameSuffix, remote-base, secretGenerator | refused-structural [pending-upstream]: kustomization uses unsupported feature(s): remote-base
refused-structural [permanent]: kustomization uses unsupported feature(s): configMapGenerator, nameSuffix, secretGenerator
refused-structural [permanent]: kustomization uses unsupported feature(s): configMapGenerator, namePrefix | -| 2-rendered/rendered-manifests | 0 | Partial | 3 | 2 | plain=3, refused-structural=2 | namePrefix | refused-structural [permanent]: kustomization uses unsupported feature(s): namePrefix
refused-structural [permanent]: kustomization uses unsupported feature(s): namePrefix | +| 2-rendered/kustomize-overlays | 0 | Partial | 1 | 3 | kustomize-single=1, refused-structural=3 | configMapGenerator, namePrefix, nameSuffix, remote-base, secretGenerator | refused-structural [no]: kustomization uses unsupported feature(s): remote-base
refused-structural [no]: kustomization uses unsupported feature(s): configMapGenerator, nameSuffix, secretGenerator
refused-structural [no]: kustomization uses unsupported feature(s): configMapGenerator, namePrefix | +| 2-rendered/rendered-manifests | 0 | Partial | 3 | 2 | plain=3, refused-structural=2 | namePrefix | refused-structural [no]: kustomization uses unsupported feature(s): namePrefix
refused-structural [no]: kustomization uses unsupported feature(s): namePrefix | | 3-expanded/argocd-applicationset-directories | 0 | All reported candidates accepted | 5 | 0 | plain=5 | - | None | -| 3-expanded/argocd-applicationset-files | 0 | No reported candidates accepted | 0 | 1 | plain=1 | - | non-krm-yaml [fixable]: chart/Chart.yaml: YAML is not a Kubernetes manifest
foreign-file [fixable]: chart/templates/_helpers.tpl: foreign file chart/templates/_helpers.tpl is not a managed manifest; remove it or name it in .gittargetignore
non-krm-yaml [fixable]: chart/templates/deployment.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [fixable]: chart/templates/service.yaml: YAML is not a Kubernetes manifest
+5 more | +| 3-expanded/argocd-applicationset-files | 0 | No reported candidates accepted | 0 | 1 | plain=1 | - | non-krm-yaml [yes]: chart/Chart.yaml: YAML is not a Kubernetes manifest
foreign-file [yes]: chart/templates/_helpers.tpl: foreign file chart/templates/_helpers.tpl is not a managed manifest; remove it or name it in .gittargetignore
non-krm-yaml [yes]: chart/templates/deployment.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [yes]: chart/templates/service.yaml: YAML is not a Kubernetes manifest
+5 more | | 3-expanded/argocd-multicluster-matrix | 0 | All reported candidates accepted | 4 | 0 | plain=4 | - | None | -| 3-expanded/flux-helmrelease | 0 | Partial | 3 | 1 | kustomize-single=3, refused-structural=1 | configMapGenerator | refused-structural [permanent]: kustomization uses unsupported feature(s): configMapGenerator | +| 3-expanded/flux-helmrelease | 0 | Partial | 3 | 1 | kustomize-single=3, refused-structural=1 | configMapGenerator | refused-structural [no]: kustomization uses unsupported feature(s): configMapGenerator | | 3-expanded/flux-resourceset-inline | 0 | All reported candidates accepted | 1 | 0 | plain=1 | - | None | | 3-expanded/flux-resourceset-pull-requests | 0 | All reported candidates accepted | 1 | 0 | plain=1 | - | None | | 4-machine-written/flux-image-automation | 0 | All reported candidates accepted | 3 | 0 | kustomize-single=3 | - | None | | 5-opaque/sops-encrypted | 0 | All reported candidates accepted | 3 | 0 | kustomize-single=2, plain=1 | - | None | -| 6-hostile/mixed-and-hostile | 0 | Partial | 3 | 2 | plain=4, refused-structural=1 | unparseable | refused-structural [fixable]: kustomization uses unsupported feature(s): unparseable (invalid Kustomization: json: unknown field "spec")
impure-managed-file [fixable]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #1 is a non-KRM document
impure-managed-file [fixable]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #2 is an empty document
foreign-file [fixable]: deployment.json: foreign file deployment.json is not a managed manifest; remove it or name it in .gittargetignore | +| 6-hostile/mixed-and-hostile | 0 | Partial | 3 | 2 | plain=4, refused-structural=1 | unparseable | refused-structural [yes]: kustomization uses unsupported feature(s): unparseable (invalid Kustomization: json: unknown field "spec")
impure-managed-file [yes]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #1 is a non-KRM document
impure-managed-file [yes]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #2 is an empty document
foreign-file [yes]: deployment.json: foreign file deployment.json is not a managed manifest; remove it or name it in .gittargetignore | ## 1-desired-state/argocd-app-of-apps @@ -66,7 +66,7 @@ Unsupported constructs: `none`. Fleet root: `false`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| -| `apps/frontend` | `plain` | false | `frontend` | 6/6/1 | non-krm-yaml [fixable]: ci-metadata.yaml: YAML is not a Kubernetes manifest | +| `apps/frontend` | `plain` | false | `frontend` | 6/6/1 | non-krm-yaml [yes]: ci-metadata.yaml: YAML is not a Kubernetes manifest | | `argocd` | `plain` | true | `argocd` | 1/1/0 | none | ## 1-desired-state/flux-monorepo @@ -147,9 +147,9 @@ Unsupported constructs: `configMapGenerator, namePrefix, nameSuffix, remote-base | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| | `apps/backend/base` | `kustomize-single` | true | `-` | 2/2/0 | none | -| `apps/backend/overlays/production` | `refused-structural` | false | `backend-production` | 0/0/0 | refused-structural [pending-upstream]: kustomization uses unsupported feature(s): remote-base | -| `apps/frontend/overlays/production` | `refused-structural` | false | `frontend-production` | 2/0/3 | refused-structural [permanent]: kustomization uses unsupported feature(s): configMapGenerator, nameSuffix, secretGenerator | -| `apps/frontend/overlays/staging` | `refused-structural` | false | `frontend-staging` | 2/0/1 | refused-structural [permanent]: kustomization uses unsupported feature(s): configMapGenerator, namePrefix | +| `apps/backend/overlays/production` | `refused-structural` | false | `backend-production` | 0/0/0 | refused-structural [no]: kustomization uses unsupported feature(s): remote-base | +| `apps/frontend/overlays/production` | `refused-structural` | false | `frontend-production` | 2/0/3 | refused-structural [no]: kustomization uses unsupported feature(s): configMapGenerator, nameSuffix, secretGenerator | +| `apps/frontend/overlays/staging` | `refused-structural` | false | `frontend-staging` | 2/0/1 | refused-structural [no]: kustomization uses unsupported feature(s): configMapGenerator, namePrefix | ## 2-rendered/rendered-manifests @@ -161,8 +161,8 @@ Unsupported constructs: `namePrefix`. Fleet root: `false`. | `argocd` | `plain` | true | `argocd` | 1/1/0 | none | | `rendered/production` | `plain` | true | `frontend-production` | 3/3/0 | none | | `rendered/staging` | `plain` | true | `frontend-staging` | 3/3/0 | none | -| `src/frontend/overlays/production` | `refused-structural` | false | `frontend-production` | 2/0/0 | refused-structural [permanent]: kustomization uses unsupported feature(s): namePrefix | -| `src/frontend/overlays/staging` | `refused-structural` | false | `frontend-staging` | 2/0/0 | refused-structural [permanent]: kustomization uses unsupported feature(s): namePrefix | +| `src/frontend/overlays/production` | `refused-structural` | false | `frontend-production` | 2/0/0 | refused-structural [no]: kustomization uses unsupported feature(s): namePrefix | +| `src/frontend/overlays/staging` | `refused-structural` | false | `frontend-staging` | 2/0/0 | refused-structural [no]: kustomization uses unsupported feature(s): namePrefix | ## 3-expanded/argocd-applicationset-directories @@ -184,7 +184,7 @@ Unsupported constructs: `none`. Fleet root: `false`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| -| `.` | `plain` | false | `argocd` | 1/1/9 | non-krm-yaml [fixable]: chart/Chart.yaml: YAML is not a Kubernetes manifest
foreign-file [fixable]: chart/templates/_helpers.tpl: foreign file chart/templates/_helpers.tpl is not a managed manifest; remove it or name it in .gittargetignore
non-krm-yaml [fixable]: chart/templates/deployment.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [fixable]: chart/templates/service.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [fixable]: chart/values.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [fixable]: deployments/dev/backend.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [fixable]: deployments/dev/frontend.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [fixable]: deployments/production/backend.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [fixable]: deployments/production/frontend.yaml: YAML is not a Kubernetes manifest | +| `.` | `plain` | false | `argocd` | 1/1/9 | non-krm-yaml [yes]: chart/Chart.yaml: YAML is not a Kubernetes manifest
foreign-file [yes]: chart/templates/_helpers.tpl: foreign file chart/templates/_helpers.tpl is not a managed manifest; remove it or name it in .gittargetignore
non-krm-yaml [yes]: chart/templates/deployment.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [yes]: chart/templates/service.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [yes]: chart/values.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [yes]: deployments/dev/backend.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [yes]: deployments/dev/frontend.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [yes]: deployments/production/backend.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [yes]: deployments/production/frontend.yaml: YAML is not a Kubernetes manifest | ## 3-expanded/argocd-multicluster-matrix @@ -205,7 +205,7 @@ Unsupported constructs: `configMapGenerator`. Fleet root: `false`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| -| `apps/frontend` | `refused-structural` | false | `-` | 1/1/1 | refused-structural [permanent]: kustomization uses unsupported feature(s): configMapGenerator | +| `apps/frontend` | `refused-structural` | false | `-` | 1/1/1 | refused-structural [no]: kustomization uses unsupported feature(s): configMapGenerator | | `clusters/production` | `kustomize-single` | true | `flux-system` | 7/7/0 | none | | `infrastructure/controllers/ingress-nginx` | `kustomize-single` | true | `flux-system` | 3/3/0 | none | | `infrastructure/sources` | `kustomize-single` | true | `flux-system` | 3/3/0 | none | @@ -257,8 +257,8 @@ Unsupported constructs: `unparseable`. Fleet root: `false`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| -| `.` | `refused-structural` | false | `backend` | 0/0/5 | refused-structural [fixable]: kustomization uses unsupported feature(s): unparseable (invalid Kustomization: json: unknown field "spec") | +| `.` | `refused-structural` | false | `backend` | 0/0/5 | refused-structural [yes]: kustomization uses unsupported feature(s): unparseable (invalid Kustomization: json: unknown field "spec") | | `crossplane` | `plain` | true | `-` | 1/1/0 | none | | `kro` | `plain` | true | `-` | 1/1/0 | none | -| `mixed` | `plain` | false | `-` | 3/3/2 | impure-managed-file [fixable]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #1 is a non-KRM document
impure-managed-file [fixable]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #2 is an empty document
foreign-file [fixable]: deployment.json: foreign file deployment.json is not a managed manifest; remove it or name it in .gittargetignore | +| `mixed` | `plain` | false | `-` | 3/3/2 | impure-managed-file [yes]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #1 is a non-KRM document
impure-managed-file [yes]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #2 is an empty document
foreign-file [yes]: deployment.json: foreign file deployment.json is not a managed manifest; remove it or name it in .gittargetignore | | `secrets` | `plain` | true | `backend` | 1/1/0 | none | From 1d6b1942562f7eb6ee3f79019c937dd60b27510a Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 27 Jul 2026 11:10:38 +0000 Subject: [PATCH 07/15] feat(manifestanalyzer)!: a whole published document, and solvable is a boolean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes that arrived together, both about the contract being legible. **You can see the document now.** Nothing in the repo showed one: the *.golden.json files pin the ENGINE's report, which has no envelope, and every public test asserted one field at a time — while the engine's own test comment claimed "pkg/manifestanalyzer owns the machine-readable contract and pins it separately". It did not. Both report kinds now have a checked-in YAML golden, regenerated from a real scan, so a reader sees what they are about to parse and a reviewer sees a change to the envelope as a diff. The folder one is refused two ways on purpose, so both answers appear side by side. `--format yaml` prints the same document, through the report's own writer so the CLI cannot serialize a shape the Go contract would not. The engine modes reject it by name: they render an internal report, and a contract-looking flag must not print a non-contract document. **Solvable is a boolean.** The string enum asked a reader to learn a vocabulary before they could read a single refusal; `solvable: true` with an actor, or `solvable: false`, asks nothing. It also removes a wart the goldens exposed: "yes" and "no" are booleans in YAML 1.1, so the enum had to be emitted quoted forever. What that gives up is worth stating rather than discovering later. The enum's zero value meant "nobody decided", so a raise site that forgot degraded to silence and a test could detect it; `false` is both the honest answer for most refusals and what a forgotten raise site produces, and at runtime the two are now indistinguishable. What still guards it is that the classification table is checked against the SOURCE — a new IssueKind fails until someone decides what it answers, and every unmodelled kustomize construct fails until it appears in the map — so the omission is caught where it is still visible, at the constant. Two consequences follow from the same fact. `solvable` is emitted ALWAYS, never omitempty, so "false" and "absent" stay distinguishable on the wire even though Go cannot tell them apart: a report from a build predating the field is the one case where "nobody said" is readable. And a feature set holding anything unrecognised now reports not solvable, where the enum skipped unknowns and could still answer "solvable" — with no third state, sending an author to fix a typo while an unclassified construct still blocks the folder is the worse error. The design record keeps the enum's argument and says why it was traded away. BREAKING CHANGE: the refusal field is the boolean `solvable`, not the string `solvability`. Co-Authored-By: Claude Opus 5 --- cmd/manifest-analyzer/main.go | 46 +++- cmd/manifest-analyzer/main_test.go | 50 ++++- docs/INDEX.md | 2 +- docs/UPGRADING.md | 15 +- .../design/analyzer-consumer-contract-asks.md | 129 ++++++------ .../repo-discovery-and-onboarding-scan.md | 14 +- hack/gitops-layouts-baseline.sh | 10 +- internal/git/kustomize_oracle_test.go | 5 +- internal/git/plan_flush.go | 22 +- .../git/write_boundary_precondition_test.go | 6 +- ...test.go => write_refusal_solvable_test.go} | 39 ++-- internal/manifestanalyzer/acceptance.go | 20 +- internal/manifestanalyzer/analyzer.go | 22 +- internal/manifestanalyzer/gittargetignore.go | 17 +- internal/manifestanalyzer/scan_repo.go | 31 ++- internal/manifestanalyzer/solvability.go | 198 ------------------ internal/manifestanalyzer/solvable.go | 144 +++++++++++++ .../{solvability_test.go => solvable_test.go} | 155 +++++++------- internal/manifestanalyzer/store.go | 4 +- .../unsupported/helm-inflation.golden.json | 2 +- .../unsupported/plain-nonkrm.golden.json | 2 +- .../unsupported-kustomize.golden.json | 2 +- pkg/manifestanalyzer/contract.go | 22 ++ pkg/manifestanalyzer/contract_test.go | 56 ++++- pkg/manifestanalyzer/doc.go | 26 ++- pkg/manifestanalyzer/folder.go | 69 +++--- pkg/manifestanalyzer/golden_test.go | 102 +++++++++ pkg/manifestanalyzer/repo.go | 36 ++-- .../testdata/folder-report.golden.yaml | 31 +++ .../testdata/repo-report.golden.yaml | 29 +++ test/fixtures/gitops-layouts/support-today.md | 38 ++-- 31 files changed, 803 insertions(+), 541 deletions(-) rename internal/git/{write_refusal_solvability_test.go => write_refusal_solvable_test.go} (72%) delete mode 100644 internal/manifestanalyzer/solvability.go create mode 100644 internal/manifestanalyzer/solvable.go rename internal/manifestanalyzer/{solvability_test.go => solvable_test.go} (72%) create mode 100644 pkg/manifestanalyzer/golden_test.go create mode 100644 pkg/manifestanalyzer/testdata/folder-report.golden.yaml create mode 100644 pkg/manifestanalyzer/testdata/repo-report.golden.yaml diff --git a/cmd/manifest-analyzer/main.go b/cmd/manifest-analyzer/main.go index 486a491d..550f1612 100644 --- a/cmd/manifest-analyzer/main.go +++ b/cmd/manifest-analyzer/main.go @@ -23,7 +23,10 @@ // applied (the repo-level refuse gate is // deferred) // discovery: raw Kubernetes API discovery dump -// --format text|json output format (default text) +// --format text|json|yaml +// output format (default text). yaml is the KRM report modes only +// (scan-folder, scan-repo): it is the same document as json, in the +// serialization a human can read and commit // --version print the release and the report apiVersion, then exit // --policy report|refuse // report: always exit 0 (analysis only) @@ -92,7 +95,7 @@ func runWithDiscoveryClientFactory( fs := flag.NewFlagSet("manifest-analyzer", flag.ContinueOnError) fs.SetOutput(stderr) mode := fs.String("mode", "analyze", "what to produce: analyze|scan-folder|scan-repo|discovery") - format := fs.String("format", "text", "output format: text|json") + format := fs.String("format", "text", "output format: text|json|yaml (yaml: scan-folder/scan-repo only)") policy := fs.String("policy", "report", "adoption policy: report|refuse (scan-repo is report-only)") kubeconfig := fs.String( "kubeconfig", @@ -152,10 +155,14 @@ func printVersion(stdout io.Writer) { // to stderr. Splitting it out of run keeps the top-level dispatch simple. func validChoices(mode, format, policy string, stderr io.Writer) bool { switch { - case mode != "analyze" && mode != "scan-folder" && mode != "scan-repo" && mode != "discovery": + case !knownMode(mode): fmt.Fprintf(stderr, "error: unknown mode %q (want analyze|scan-folder|scan-repo|discovery)\n", mode) - case format != "text" && format != "json": - fmt.Fprintf(stderr, "error: unknown format %q (want text|json)\n", format) + case format != "text" && format != "json" && format != "yaml": + fmt.Fprintf(stderr, "error: unknown format %q (want text|json|yaml)\n", format) + case format == "yaml" && !reportsKRM(mode): + // yaml serializes the KRM report. The other modes render the engine's own + // structural report, which is not that document and not a published contract. + fmt.Fprintf(stderr, "error: --format yaml is only available for --mode scan-folder or scan-repo\n") case policy != "report" && policy != "refuse": fmt.Fprintf(stderr, "error: unknown policy %q (want report|refuse)\n", policy) default: @@ -164,6 +171,17 @@ func validChoices(mode, format, policy string, stderr io.Writer) bool { return false } +// knownMode reports whether mode is one the CLI implements. +func knownMode(mode string) bool { + return mode == "analyze" || mode == "scan-folder" || mode == "scan-repo" || mode == "discovery" +} + +// reportsKRM reports whether mode emits the published KRM document (the two scan modes) +// rather than the engine's own structural report. +func reportsKRM(mode string) bool { + return mode == "scan-folder" || mode == "scan-repo" +} + // runDirMode dispatches the directory-argument modes (everything but discovery). func runDirMode(mode, dir, format, policy string, stdout, stderr io.Writer) int { switch mode { @@ -265,13 +283,13 @@ func failedGroupVersions(failed map[schema.GroupVersion]error) map[string]string // and the published Go contract are the same document and cannot drift. Text output stays // on the internal renderer, which can show the plan the public report deliberately omits. func runScanFolder(dir, format, policy string, stdout, stderr io.Writer) int { - if format == "json" { + if format == "json" || format == "yaml" { report, err := publicanalyzer.ScanFolder(context.Background(), dir) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return exitUsage } - if err := report.WriteJSON(stdout); err != nil { + if err := writeReport(stdout, format, report.WriteJSON, report.WriteYAML); err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return exitUsage } @@ -291,6 +309,16 @@ func runScanFolder(dir, format, policy string, stdout, stderr io.Writer) int { return scanExitCode(policy, result.Acceptance.Accepted) } +// writeReport renders the report in the requested machine-readable format. The two +// writers are the report's own, so the CLI cannot serialize a document the published Go +// contract would not. +func writeReport(stdout io.Writer, format string, writeJSON, writeYAML func(io.Writer) error) error { + if format == "yaml" { + return writeYAML(stdout) + } + return writeJSON(stdout) +} + func scanExitCode(policy string, accepted bool) int { if policy == "refuse" && !accepted { return exitRefused @@ -304,13 +332,13 @@ func scanExitCode(policy string, accepted bool) int { // (exitOK, or exitUsage on an I/O error); the repo-level --policy refuse gate is // deferred per the design doc. func runScanRepo(root, format string, stdout, stderr io.Writer) int { - if format == "json" { + if format == "json" || format == "yaml" { report, err := publicanalyzer.ScanRepo(context.Background(), root) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return exitUsage } - if err := report.WriteJSON(stdout); err != nil { + if err := writeReport(stdout, format, report.WriteJSON, report.WriteYAML); err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return exitUsage } diff --git a/cmd/manifest-analyzer/main_test.go b/cmd/manifest-analyzer/main_test.go index d062ef03..ad14b59b 100644 --- a/cmd/manifest-analyzer/main_test.go +++ b/cmd/manifest-analyzer/main_test.go @@ -140,9 +140,9 @@ func TestRun_ScanJSON(t *testing.T) { Version string `json:"version"` } `json:"generator"` Issues []struct { - Kind string `json:"kind"` - Solvability string `json:"solvability"` - Actor string `json:"actor"` + Kind string `json:"kind"` + Solvable bool `json:"solvable"` + Actor string `json:"actor"` } `json:"issues"` } `json:"status"` } @@ -155,13 +155,10 @@ func TestRun_ScanJSON(t *testing.T) { if parsed.Status.Accepted || len(parsed.Status.Issues) == 0 { t.Fatalf("the fixture holds a stray values.yaml and must be refused: %s", out.String()) } - // Every refusal says whether it can be solved; without that a consumer's only honest + // A solvable refusal names who can solve it; without that a consumer's only honest // sentence is "this folder cannot be picked". for _, issue := range parsed.Status.Issues { - if issue.Solvability == "" { - t.Errorf("issue %q reached a consumer unclassified: %s", issue.Kind, out.String()) - } - if issue.Solvability == "yes" && issue.Actor == "" { + if issue.Solvable && issue.Actor == "" { t.Errorf("solvable issue %q does not say who can solve it: %s", issue.Kind, out.String()) } } @@ -184,6 +181,43 @@ func TestRun_Version(t *testing.T) { } } +// --format yaml prints the same KRM document as --format json, which is the serialization +// a human can read in review and commit beside the manifests it describes. +func TestRun_ScanRepoYAML(t *testing.T) { + var out, errBuf bytes.Buffer + args := []string{"--mode", "scan-repo", "--format", "yaml", scanRepoFixture(t)} + if code := run(args, &out, &errBuf); code != 0 { + t.Fatalf("exit = %d, want 0 (stderr=%s)", code, errBuf.String()) + } + for _, want := range []string{ + "apiVersion: " + publicanalyzer.APIVersion, + "kind: " + publicanalyzer.KindRepoReport, + "spec:", + "status:", + } { + if !strings.Contains(out.String(), want) { + t.Errorf("scan-repo yaml missing %q:\n%s", want, out.String()) + } + } + if strings.Contains(out.String(), "{") { + t.Errorf("scan-repo yaml should not be JSON:\n%s", out.String()) + } +} + +// yaml serializes the KRM report, so the modes that do not produce one refuse it by name +// rather than printing the engine's internal shape under a contract-looking flag. +func TestRun_YAMLIsRejectedForTheEngineModes(t *testing.T) { + for _, mode := range []string{"analyze", "discovery"} { + var out, errBuf bytes.Buffer + if code := run([]string{"--mode", mode, "--format", "yaml", "."}, &out, &errBuf); code != exitUsage { + t.Errorf("--mode %s --format yaml: exit = %d, want %d", mode, code, exitUsage) + } + if !strings.Contains(errBuf.String(), "only available for") { + t.Errorf("--mode %s --format yaml: stderr should say why: %s", mode, errBuf.String()) + } + } +} + // scanRepoFixture builds a tiny two-folder repo: a plain KRM app folder and a kustomize // overlay reaching an out-of-subtree base, so scan-repo reports both an accepted plain // candidate and an accepted kustomize-overlay one (render-root scoping adopts the overlay). diff --git a/docs/INDEX.md b/docs/INDEX.md index cdb8e6ff..835aa331 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -71,7 +71,7 @@ Thirteen other open items: |---|---| | [`docs-linting.md`](design/docs-linting.md) | how to mechanize [`style-guide.md`](style-guide.md) with markdownlint-cli2 and Vale. Both are wired into `task lint`, gated on the files [`.docs-lint-scope`](../.docs-lint-scope) lists rather than the whole tree: 102 of 174 files fail markdownlint and 148 of 174 fail Vale, so the two backlogs need different gates. Open: how the scope list grows to cover the tree, the `MD013` limit, and whether `AGENTS.md` and the chart READMEs are in scope | | [`attribution-fact-identity.md`](design/attribution-fact-identity.md) | several `ClusterProvider`s may name one physical cluster, but a kube-apiserver posts audit to one route, so only one of those names is ever fed and every other one authors `unknown (attribution unresolved)`. Proposes a declared `spec.attribution.auditRoute` that partitions the facts instead of `metadata.name`, so several providers can share one cluster's facts while cloned clusters stay separate, ingestion loses its last Kubernetes read, and a misrouted provider becomes loud. Renames the key infix and the annotation-key flag to the same word | -| [`analyzer-consumer-contract-asks.md`](design/analyzer-consumer-contract-asks.md) | three asks from the first downstream consumer of `pkg/manifestanalyzer`, each the same failure at a different layer — a distinction our code knows, published as prose no test defends. solvability on a `RefusalReason` (they shipped "not supported yet" for `invalid-yaml`, because one deprecated code was the only signal the type offered); the analyzer's version in its own JSON report, plus a signed release binary; and `ResourceIdentifier.Key()`'s string format as a documented contract with a golden test — it has **no test at all** today. Carries our classification of all seventeen internal `IssueKind` values (the public package exports only fourteen, and three codes can reach a consumer with no constant to match on); open: the per-construct split for `unsupported-kustomize`, and whether `Key()` or `ToGitPath()` is the identity when a preferred version bumps | +| [`analyzer-consumer-contract-asks.md`](design/analyzer-consumer-contract-asks.md) | three asks from the first downstream consumer of `pkg/manifestanalyzer`, each the same failure at a different layer — a distinction our code knows, published as prose no test defends. a `solvable` flag on a `RefusalReason` (they shipped "not supported yet" for `invalid-yaml`, because one deprecated code was the only signal the type offered); the analyzer's version in its own JSON report, plus a signed release binary; and `ResourceIdentifier.Key()`'s string format as a documented contract with a golden test — it has **no test at all** today. Carries our classification of all seventeen internal `IssueKind` values (the public package exports only fourteen, and three codes can reach a consumer with no constant to match on); open: the per-construct split for `unsupported-kustomize`, and whether `Key()` or `ToGitPath()` is the identity when a preferred version bumps | | [`watch-and-catalog-architecture.md`](design/watch-and-catalog-architecture.md) | the target three-layer watch model — **needs a human call before building** | | [`metrics-observability-plan.md`](design/metrics-observability-plan.md) | the watch-stage metrics do not exist yet | | [`reconcile-triggering.md`](design/reconcile-triggering.md) | which controllers still fail to wake up | diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index db807e09..0b737cc1 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -44,15 +44,15 @@ Three things come with the envelope: toolchain records for `go install ...@vX.Y.Z`, otherwise the literal `"dev"`. - **`manifest-analyzer --version`** prints that release and the report `apiVersion` together, so one exec answers both questions. -- **Every refusal says whether it can be solved.** `solvability` is `yes` or `no`, and `actor` - (`repository-author` or `platform-operator`) names who can solve a `yes`. See below. +- **Every refusal says whether it can be solved.** `solvable` is a boolean, and `actor` + (`repository-author` or `platform-operator`) names who can solve it. See below. ### Refusals say whether they can be solved, and three more codes are published -`Issue` and `RefusalReason` carry `solvability` and `actor`, both omitted when the check did not -classify itself. They are set by the check that raised the refusal, because only that check knows: -`unsupported-kustomize` is `yes` for a build file the author broke and `no` for a generator, and no -per-code table can express that. `solvability` describes the release you are running and makes no +`Issue` and `RefusalReason` carry `solvable` (always present) and `actor` (set only when +`solvable`). They are decided by the check that raised the refusal, because only that check knows: +`unsupported-kustomize` is solvable for a build file the author broke and not for a generator, and +no per-code table can express that. `solvable` describes the release you are running and makes no promise about the future, so read it on every scan rather than caching a mapping from it. `pkg/manifestanalyzer` also publishes `IssueRenderRefused`, `IssueRenderDoesNotMatchLive` and @@ -69,7 +69,8 @@ is the acceptance gate's own issue" compile-checked rather than merely stated. and so on. Golden fixtures generated from the old shape need regenerating. - If you inferred from the code whether a refusal can be solved (for example, treating `refused-structural` as hopeless and everything else as "not supported yet"), delete that mapping - and read `solvability`. An absent or unrecognized value means *say nothing*. + and read `solvable`. A report from a build that predates the field carries no `solvable` key at + all; treat that as "nobody said", not as `false`. - Record the `status.generator.version` you consumed a report with, if you pin our release elsewhere. That is the whole point of the field. diff --git a/docs/design/analyzer-consumer-contract-asks.md b/docs/design/analyzer-consumer-contract-asks.md index f2b4f01f..32244d9b 100644 --- a/docs/design/analyzer-consumer-contract-asks.md +++ b/docs/design/analyzer-consumer-contract-asks.md @@ -15,13 +15,13 @@ asking for version clarity in this same document. 2. **The three unclassified refusal codes are decided during implementation** (Ask 1). The implementing session proposes an answer per *raise site* from the code, and raises each - as a review comment on its PR for the maintainer to accept or overturn. They do not ship - as `SolvabilityUnknown`, and they do not block the rest of Ask 1. + as a review comment on its PR for the maintainer to accept or overturn. They ship + decided, and they do not block the rest of Ask 1. -3. **The field is `Solvability`, and it is two-valued** (Ask 1, decided 2026-07-27, - overturning the four-value proposal recorded further down this document). It answers - one question in words a non-native speaker reads without a glossary: *can this refusal - be solved?* `yes` (with an `Actor` naming who) or `no`. See "The shape". +3. **The field is `Solvable`, a boolean** (Ask 1, decided 2026-07-27, overturning the + four-value enum proposed further down this document). It answers one question in words + a non-native speaker reads without a glossary: *can this refusal be solved?* `Actor` + names who, when someone can. See "The shape". Each ask is one PR, in the order below — Ask 3 is unblocked, Ask 1 is scaffolding plus fourteen settled classifications, Ask 2 carries the breaking change. @@ -142,19 +142,7 @@ fifteen codes without any of them saying whether they can be solved. Additive, and inert for anyone who ignores it: ```go -// Solvability answers one question about a refusal: can it be solved? It is set by -// the check that raised it, because only that check knows. It describes THIS -// RELEASE and promises nothing about the future. Consumers MUST treat an -// unrecognised or absent value as SolvabilityUnknown and say nothing. -type Solvability string - -const ( - SolvabilityUnknown Solvability = "" // not classified; say nothing - SolvabilityYes Solvability = "yes" // someone can solve it now; Actor says who - SolvabilityNo Solvability = "no" // nobody can solve it with this release -) - -// Actor names who can solve a refusal. Empty unless Solvability is SolvabilityYes. +// Actor names who can solve a refusal. Empty unless the refusal is Solvable. type Actor string const ( @@ -164,11 +152,12 @@ const ( ) type RefusalReason struct { - Code IssueKind `json:"code"` - Detail string `json:"detail"` - // Solvability is empty when the check did not classify itself. - Solvability Solvability `json:"solvability,omitempty"` - // Actor is empty unless Solvability is SolvabilityYes. + Code string `json:"code"` + Detail string `json:"detail"` + // Solvable says whether anyone can make this folder acceptable with THIS release. + // Decided by the check that raised the refusal, because only that check knows. + Solvable bool `json:"solvable"` + // Actor names who can solve it. Empty unless Solvable. Actor Actor `json:"actor,omitempty"` } ``` @@ -179,29 +168,45 @@ can be solved and, when applicable, by whom. `Issue` takes both fields for the same reason: a policy that treats an issue as blocking is making this decision already, without the data to make it. -**Two values, decided 2026-07-27.** This document originally argued for four, keeping a -*not-yet* axis (`pending-upstream`) beside the boundary. That was overturned during -implementation, for two reasons. It buys the reader nothing: a folder they cannot pick -today is one they cannot pick today, and the action is identical either way. And it is a -roadmap promise living in a machine-readable field, which ages into a lie in one direction -or the other — we either never ship the thing we hinted at, or someone restructures a -repository because we said "permanent" about something we shipped two releases later. So -`no` means *nobody can solve this with this release*, never *not ever*, and a refusal a -later release learns to accept simply reports differently on the next scan, because the -consumer reads this value rather than a table it cached. - -What that gives up, stated plainly: the consumer can no longer split "not supported yet" -from "cannot be synced" on this field alone. The bigger win survives intact — separating -*solvable today, by this person* from everything else — and that was always the half most -of their codes needed. `SolvabilityUnknown` still has to exist as the zero value, or an -unclassified path emits a confident wrong answer instead of silence. +**A boolean, decided 2026-07-27.** This document originally argued for a four-valued enum, +keeping a *not-yet* axis (`pending-upstream`) beside the boundary. Two things were +overturned, in two steps. + +First the *not-yet* axis. It buys the reader nothing — a folder they cannot pick today is +one they cannot pick today, and the action is identical either way — while putting a +roadmap promise in a machine-readable field, which ages into a lie in one direction or the +other: we either never ship what we hinted at, or someone restructures a repository because +we said "permanent" about something we shipped two releases later. So the field says +*nobody can solve this with this release*, never *not ever*, and a refusal a later release +learns to accept simply reports differently on the next scan, because the consumer reads +this value rather than a table it cached. + +Then the enum itself, in favour of a plain `bool`. The enum's remaining argument was its +zero value: `""` meant "nobody decided", so a forgotten raise site degraded to silence +rather than to a confident wrong answer, and a test could detect it. **The boolean gives +that up, and it is worth stating exactly what is lost:** `false` is both the honest answer +for most refusals and the value a forgotten raise site produces, so at runtime the two are +indistinguishable. What still guards it is that the classification table is checked against +the SOURCE — a new `IssueKind` fails until someone decides what it answers, and every +unmodelled kustomize construct fails until it appears in the map. That catches the omission +where it is still visible, at the constant, rather than at the emitted value. + +The trade was taken deliberately: a simpler contract for every consumer, in exchange for a +narrower net. It is also why `solvable` is emitted ALWAYS, never `omitempty` — `false` and +"absent" must stay distinguishable on the wire even though they are not in Go, so a report +that predates the field is still readable as "nobody said". + +What the two-value shape gives up in the first place: the consumer can no longer split "not +supported yet" from "cannot be synced" on this field alone. The bigger win survives intact — +separating *solvable today, by this person* from everything else — and that was always the +half most of their codes needed. They also note that adding a field is additive and need not move `SchemaVersion`. Correct, and it stays correct under the KRM envelope recommended in Ask 2 — that envelope is a separate, deliberate breaking change, and this field should not wait for it. Ship the actor field. The consumer offered it as optional; it is the cheaper half of the -same constant, and two of our codes are fixable **only** by the platform operator, whom +same constant, and two of our codes are solvable **only** by the platform operator, whom their wizard does not have on the screen. Without it, `out-of-scope` renders as "fix your repository" to someone who cannot. @@ -242,8 +247,8 @@ since that is the sentence the field exists to write. **How these three get settled — decided.** The implementing session reads each raise site, proposes an answer for it, and raises the proposal as a review comment on its own PR for -the maintainer to accept or overturn. They ship classified, never as `SolvabilityUnknown`, -and they do not hold up the fourteen above. **All three are settled below**; the reasoning +the maintainer to accept or overturn. They ship decided, and they do not hold up the +fourteen above. **All three are settled below**; the reasoning is kept so a later change knows what it is overturning. **`unsupported-kustomize` — classified per construct at the raise site.** This is the case @@ -251,15 +256,15 @@ that proves the whole ask: one code, both answers. The split is whether a person something about it today: - a build file that does not parse, malformed `images`/`replicas`, a patch path outside the - tree, a root kustomize cannot build → `yes`/author. One commit clears it, and no support + tree, a root kustomize cannot build → solvable by the author. One commit clears it, and no support boundary is in play. - generators, components, replacements, transformers, name prefix/suffix, inline and - JSON6902 patches, `vars`, `validators` → `no`. Their output cannot be mapped back to a - source document at all. -- remote bases, Helm inflation, `configurations`/`crds`/`openapi` → `no`. This release does + JSON6902 patches, `vars`, `validators` → not solvable. Their output cannot be mapped back + to a source document at all. +- remote bases, Helm inflation, `configurations`/`crds`/`openapi` → not solvable. This release does not fetch or model them, and neither the author nor the platform operator can change that from where they stand. Under the four-value proposal these were `pending-upstream`; the - two-value decision folds them into `no`, which claims only what is true today. + decision above folds them into "not solvable", which claims only what is true today. When several constructs are present the **least solvable** one wins: fixing a malformed patch does not make a folder adoptable while it also declares a `configMapGenerator`. @@ -268,23 +273,23 @@ A static per-code map cannot express any of this. The field can, and it is why t goes on the emitted reason rather than into a table beside the constants. **`unresolved-krm` — splits at the raise site.** A kind absent because nothing serves it is -`yes`/`platform`: install the CRD and the same folder is adoptable. A kind that is served -but ambiguous, denied by policy, or missing a verb is `no`. One code, two answers, decided +solvable by the platform operator: install the CRD and the same folder is adoptable. A kind +that is served but ambiguous, denied by policy, or missing a verb is not solvable. One code, two answers, decided where the registry's answer is still in hand rather than at the acceptance gate, which sees only the verdict. -**`kustomize-render-refused` → `no`.** It refuses a *write*, not a folder, so "can this +**`kustomize-render-refused` → not solvable.** It refuses a *write*, not a folder, so "can this folder ever be picked" was the wrong question for it. The narrower claim is the true one: the oracle refuses a write it cannot vouch for, and neither the repository author nor the platform operator can make it vouch. **One row moved during implementation.** `refused-structural` is classified from the same -feature set as `unsupported-kustomize` rather than flatly `no`. Implementing the settled row +feature set as `unsupported-kustomize` rather than flatly not-solvable. Implementing the settled row verbatim made the two surfaces disagree about one directory: a render root whose build file -merely does not parse read `no` through `refused-structural` while the gate called the same -fault the author's to fix through `unsupported-kustomize`. Two answers about one folder is -the bug this document is about, so both classify from one place, with `no` whenever the -constructs are unknown. +merely does not parse read "not solvable" through `refused-structural` while the gate called +the same fault the author's to fix through `unsupported-kustomize`. Two answers about one +folder is the bug this document is about, so both classify from one place, reporting not +solvable whenever the constructs are unknown. ### Implementation notes @@ -297,14 +302,14 @@ constructs are unknown. releases, which is how we got here. - One test per constant is **not sufficient**, and the decision cases above are why: `unsupported-kustomize` and `unresolved-krm` classify differently depending on which - branch raised them, so a per-constant test passes while a new branch emits - `SolvabilityUnknown`. Cover each emission path, not each constant. This is the one place - the ask costs more than a constant per site, and skipping it reproduces the original bug - one level down. + branch raised them, so a per-constant test passes while a new branch reports the wrong + answer. Cover each emission path, not each constant. This is the one place the ask costs + more than a constant per site, and skipping it reproduces the original bug one level down. - Reconcile the internal and public kind sets in the same pass, so the classification test covers the codes a consumer can actually receive rather than the subset we export. -- `SolvabilityUnknown` must stay the zero value so an unclassified path degrades to silence - rather than to a confident wrong sentence. +- `solvable` is emitted always, never `omitempty`: a boolean's zero value is a real answer, + so "false" and "absent" must stay distinguishable on the wire even though Go cannot tell + them apart. ### Meanwhile, downstream diff --git a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md index 44342c33..2ea92210 100644 --- a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md +++ b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md @@ -261,15 +261,15 @@ free to move. `apiVersion: manifestanalyzer.configbutler.ai/v1alpha1`, `kind: RepoReport`, the scan request in `spec` (`root`, `mode`) and everything found in `status` (`generator`, `candidates`, `summary`). The candidate blocks below live under `status.candidates`. Per candidate: `path`, -`layout`, `acceptedByOperator`, `refusalReasons[]` (`{code, detail, solvability, actor}`), +`layout`, `acceptedByOperator`, `refusalReasons[]` (`{code, detail, solvable, actor}`), `renderRoot`, `readScope[]`, `inferredNamespace`, `resources`, `overlapsWith[]`. -- **`solvability` says whether a refusal can be solved** (`yes` or `no`), and `actor` - (`repository-author`, `platform-operator`) names who can solve a `yes`. It is set by the +- **`solvable` says whether anyone can make the candidate acceptable with this release**, + and `actor` (`repository-author`, `platform-operator`) names who. It is decided by the check that raised the refusal, because the same code answers differently depending on the - branch: `unsupported-kustomize` is `yes` for a build file the author broke and `no` for a - generator or a remote base. It describes the release you are running and promises nothing - about the future; an absent value means say nothing. See + branch: `unsupported-kustomize` is solvable for a build file the author broke and not for + a generator or a remote base. It describes the release you are running and promises + nothing about the future. See [analyzer-consumer-contract-asks.md](../analyzer-consumer-contract-asks.md). - **`status.generator`** names the build that produced the report, so a document that outlives the process that made it still says which release decided its contents. @@ -291,7 +291,7 @@ in `spec` (`root`, `mode`) and everything found in `status` (`generator`, `candi candidate — `Scan` with `WriterAllowlist` (kustomize build directives **plus** the operator's `.sops.yaml` bootstrap config), so a folder the writer would adopt is not falsely reported refused. A refused plain / self-contained candidate carries the gate's -issues as `refusalReasons` (`{code, detail, solvability, actor}` — duplicate identity, non-KRM YAML, a foreign +issues as `refusalReasons` (`{code, detail, solvable, actor}` — duplicate identity, non-KRM YAML, a foreign file, an unsupported nested kustomization, …), never a bare `false`. The structural gate refuses `openapi`/`crds` alongside `configurations`, matching the [support boundary §1](kustomize-support-boundary.md). diff --git a/hack/gitops-layouts-baseline.sh b/hack/gitops-layouts-baseline.sh index 2d95704e..d7274b45 100755 --- a/hack/gitops-layouts-baseline.sh +++ b/hack/gitops-layouts-baseline.sh @@ -44,7 +44,7 @@ emit_summary_row() { constructs=$(jq -r "$MD_CELL"'(.status.summary.unsupportedConstructs // []) | join(", ") | md | if . == "" then "-" else . end' <<<"$json") signal=$(jq -r "$MD_CELL"'[.status.candidates[]? | select(.acceptedByOperator == false) - | .refusalReasons[]? | "\(.code) [\(.solvability // "unclassified")]: \(.detail)" | md] + | .refusalReasons[]? | "\(.code) [\(if .solvable then "solvable" else "not solvable" end)]: \(.detail)" | md] | if length == 0 then "None" elif length > 4 then (.[0:4] | join("
")) + "
+\(length - 4) more" else join("
") end' <<<"$json") @@ -89,7 +89,7 @@ emit_detail() { | "| `\(.path | md)` | `\(.layout | md)` | \(.acceptedByOperator) | `\(.inferredNamespace // "-" | md)` | " + "\(.resources.rendered // 0)/\(.resources.editable // 0)/\(.resources.nonKrm // 0) | " + ((.refusalReasons // []) - | map("\(.code) [\(.solvability // "unclassified")]: \(.detail)" | md) | join("
") + | map("\(.code) [\(if .solvable then "solvable" else "not solvable" end)]: \(.detail)" | md) | join("
") | if . == "" then "none" else . end) + " |"' <<<"$json" } @@ -117,9 +117,9 @@ Reading rules: input set that lives in a Git-host API. - **A missing candidate matters as much as a refusal**: it means the tool did not explain that part of the repository at all. -- Each refusal reason carries in brackets whether it can be solved: `yes` or `no`. A - refusal that changes that answer has changed what a user is told to do about it, which - is a boundary move like any other and belongs in this diff. +- Each refusal reason carries in brackets whether anyone can solve it. A refusal that + changes that answer has changed what a user is told to do about it, which is a boundary + move like any other and belongs in this diff. ## Summary diff --git a/internal/git/kustomize_oracle_test.go b/internal/git/kustomize_oracle_test.go index c01fb720..392cba5b 100644 --- a/internal/git/kustomize_oracle_test.go +++ b/internal/git/kustomize_oracle_test.go @@ -140,10 +140,9 @@ func TestPlanFlush_RefusesAWriteThatDragsASiblingAlong(t *testing.T) { "the refusal must name the object the write would have dragged along") assert.True(t, refused.AllIssuesOfKinds(manifestanalyzer.IssueRenderRefused), "it is a write-boundary refusal, so it surfaces as WriteBoundaryRefused") - // The oracle will always refuse a write it cannot vouch for, so this refusal is - // permanent on the narrow claim it makes — never a "not supported yet". + // The oracle refuses a write it cannot vouch for, and neither side can make it vouch. assertClassified(t, refused.Issues) - assert.Equal(t, manifestanalyzer.SolvabilityNo, refused.Issues[0].Solvability) + assert.False(t, refused.Issues[0].Solvable) assertFileBytes(t, kustPath, sharedEntryKustomizationYAML, "the entry is shared context; editing it would move api too") diff --git a/internal/git/plan_flush.go b/internal/git/plan_flush.go index 44c520e1..fd783d62 100644 --- a/internal/git/plan_flush.go +++ b/internal/git/plan_flush.go @@ -863,7 +863,7 @@ func sourceFormRefusal(filePath string, id manifestedit.Identity, err error) err // Not solvable, and deliberately so: the alternative to refusing is aligning // two lists by position, which is measurably wrong rather than merely risky // (see the IssueUnplaceableEdit comment). Nobody can act on it. - Solvability: manifestanalyzer.SolvabilityNo, + Solvable: false, Message: fmt.Sprintf("%s/%s in %s: %v", id.Kind, id.Name, filePath, err), }}, @@ -883,10 +883,10 @@ func renderFidelityRefusal( // A live value that diverges from what the folder renders is out-of-band // substitution, not a render artifact, so whoever owns the deployment // pipeline can reconcile the two. - Solvability: manifestanalyzer.SolvabilityYes, - Actor: manifestanalyzer.ActorPlatformOperator, - Field: divergence.Field, - Token: divergence.Token, + Solvable: true, + Actor: manifestanalyzer.ActorPlatformOperator, + Field: divergence.Field, + Token: divergence.Token, Message: fmt.Sprintf("%s/%s in %s: rendered token %q at %s does not match live", id.Kind, id.Name, filePath, divergence.Token, divergence.Field), }) @@ -929,7 +929,7 @@ func (wb *writeBatch) renderPrecondition() error { // This refuses a WRITE, not a folder. Nobody can solve it from the // repository or the GitTarget: the oracle refuses a write it cannot // vouch for, and neither side can make it vouch. - Solvability: manifestanalyzer.SolvabilityNo, + Solvable: false, }) } return &manifestanalyzer.AcceptanceRefusedError{Issues: issues} @@ -1496,8 +1496,8 @@ func (wb *writeBatch) ignoreShadowPrecondition() error { // again here: a pattern narrow enough to pass the initial scan can still // match a write planned later, and that refusal reaches the same reader. // Narrowing the pattern is the author's to do. - Solvability: manifestanalyzer.SolvabilityYes, - Actor: manifestanalyzer.ActorRepositoryAuthor, + Solvable: true, + Actor: manifestanalyzer.ActorRepositoryAuthor, Message: fmt.Sprintf( "%s pattern %q shadows the managed write path %s; the operator would be blind to its own "+ "file. Remove the pattern or move the resource out of its match", @@ -1531,8 +1531,8 @@ func (wb *writeBatch) pathScopePrecondition() error { Kind: manifestanalyzer.IssueWriteEscapesScope, Path: rel, // Widening spec.path, or re-placing the write, is the GitTarget owner's call. - Solvability: manifestanalyzer.SolvabilityYes, - Actor: manifestanalyzer.ActorPlatformOperator, + Solvable: true, + Actor: manifestanalyzer.ActorPlatformOperator, Message: fmt.Sprintf( "planned write path %q escapes the GitTarget write scope: the operator only ever writes "+ "inside spec.path (reads may reach shared context such as ../../base, writes never leave it)", @@ -1586,7 +1586,7 @@ func (wb *writeBatch) fanInPrecondition() error { Path: rel, // Nobody can solve this from the repository or the GitTarget: the edit // has nowhere safe to land while two render roots share the file. - Solvability: manifestanalyzer.SolvabilityNo, + Solvable: false, Message: fmt.Sprintf( "planned write to %q would edit in place a source file that more than one kustomize render "+ "root reaches (write-fan-in must be 1); refusing rather than "+ diff --git a/internal/git/write_boundary_precondition_test.go b/internal/git/write_boundary_precondition_test.go index c64037d4..6a3a69a7 100644 --- a/internal/git/write_boundary_precondition_test.go +++ b/internal/git/write_boundary_precondition_test.go @@ -151,12 +151,12 @@ func TestFanInPrecondition_RefusesAmbiguousOverrideWriteThrough(t *testing.T) { issues := refusalIssues(t, err) assert.Contains(t, issueKinds(issues), manifestanalyzer.IssueWriteFanIn, "an ambiguous-override write-through must be refused, not written through") - // Not the support boundary: per-render-root scoping generalizes this, so it is a - // "not yet" and must not be reported to a user as a redesign. + // Nobody can solve this from the repository or the GitTarget: the edit has nowhere safe + // to land while two render roots share the file. assertClassified(t, issues) for _, issue := range issues { if issue.Kind == manifestanalyzer.IssueWriteFanIn { - assert.Equal(t, manifestanalyzer.SolvabilityNo, issue.Solvability) + assert.False(t, issue.Solvable) } } diff --git a/internal/git/write_refusal_solvability_test.go b/internal/git/write_refusal_solvable_test.go similarity index 72% rename from internal/git/write_refusal_solvability_test.go rename to internal/git/write_refusal_solvable_test.go index 375f769c..7da91b89 100644 --- a/internal/git/write_refusal_solvability_test.go +++ b/internal/git/write_refusal_solvable_test.go @@ -38,17 +38,14 @@ func issueKinds(issues []manifestanalyzer.AcceptanceIssue) []manifestanalyzer.Is return kinds } -// assertClassified holds the two invariants every emitted refusal must satisfy: it says -// something about the future, and it names an actor exactly when someone can act. +// assertClassified holds the invariant every emitted refusal must satisfy: it names an +// actor exactly when someone can act. func assertClassified(t *testing.T, issues []manifestanalyzer.AcceptanceIssue) { t.Helper() for _, issue := range issues { - assert.NotEqual(t, manifestanalyzer.SolvabilityUnknown, issue.Solvability, - "%s reached a consumer unclassified: the only honest sentence left is "+ - "\"this cannot be written\"", issue.Kind) - if issue.Solvability == manifestanalyzer.SolvabilityYes { + if issue.Solvable { assert.NotEqual(t, manifestanalyzer.ActorUnknown, issue.Actor, - "%s is fixable but does not say by whom", issue.Kind) + "%s is solvable but does not say by whom", issue.Kind) } else { assert.Equal(t, manifestanalyzer.ActorUnknown, issue.Actor, "%s names an actor for a refusal nobody can act on", issue.Kind) @@ -56,25 +53,25 @@ func assertClassified(t *testing.T, issues []manifestanalyzer.AcceptanceIssue) { } } -// TestUnplaceableEditRefusalIsPermanent pins the classification of the one refusal that is -// deliberately never a "not yet": the alternative to refusing is aligning two lists by -// position, which is measurably wrong rather than merely risky, so no release lifts it. -func TestUnplaceableEditRefusalIsPermanent(t *testing.T) { +// TestUnplaceableEditRefusalIsNotSolvable pins the classification of the refusal nobody +// can act on: the alternative to refusing is aligning two lists by position, which is +// measurably wrong rather than merely risky. +func TestUnplaceableEditRefusalIsNotSolvable(t *testing.T) { err := sourceFormRefusal("apps/web/deploy.yaml", manifestedit.Identity{Kind: "Deployment", Name: "web"}, assert.AnError) issues := refusalIssues(t, err) require.Len(t, issues, 1) assert.Equal(t, manifestanalyzer.IssueUnplaceableEdit, issues[0].Kind) - assert.Equal(t, manifestanalyzer.SolvabilityNo, issues[0].Solvability) + assert.False(t, issues[0].Solvable) assertClassified(t, issues) } -// TestRenderFidelityRefusalIsFixableByThePlatform pins the other half: a live value that +// TestRenderFidelityRefusalIsSolvableByThePlatform pins the other half: a live value that // diverges from what the folder renders is out-of-band substitution, not a render // artifact, so whoever owns the deployment pipeline can reconcile the two — and the // repository author, who is the one usually on the screen, cannot. -func TestRenderFidelityRefusalIsFixableByThePlatform(t *testing.T) { +func TestRenderFidelityRefusalIsSolvableByThePlatform(t *testing.T) { err := renderFidelityRefusal("apps/web/deploy.yaml", manifestedit.Identity{Kind: "Deployment", Name: "web"}, &renderFidelityRefusedError{Divergences: []manifestanalyzer.RenderDivergence{ @@ -84,16 +81,16 @@ func TestRenderFidelityRefusalIsFixableByThePlatform(t *testing.T) { issues := refusalIssues(t, err) require.Len(t, issues, 1) assert.Equal(t, manifestanalyzer.IssueRenderDoesNotMatchLive, issues[0].Kind) - assert.Equal(t, manifestanalyzer.SolvabilityYes, issues[0].Solvability) + assert.True(t, issues[0].Solvable) assert.Equal(t, manifestanalyzer.ActorPlatformOperator, issues[0].Actor) assertClassified(t, issues) } -// TestIgnoreShadowRefusalIsFixableByTheAuthor drives the DYNAMIC half of the +// TestIgnoreShadowRefusalIsSolvableByTheAuthor drives the DYNAMIC half of the // .gittargetignore guard. The parse-time denylist catches only a catastrophic pattern; a // narrow one passes the initial scan and can still match a write planned later, and that // refusal reaches the same reader with the same question to answer. -func TestIgnoreShadowRefusalIsFixableByTheAuthor(t *testing.T) { +func TestIgnoreShadowRefusalIsSolvableByTheAuthor(t *testing.T) { matcher, parseIssues := manifestanalyzer.LoadGitTargetIgnore([]byte("secrets/\n")) require.Empty(t, parseIssues, "a narrow pattern must pass the parse-time denylist") @@ -107,14 +104,14 @@ func TestIgnoreShadowRefusalIsFixableByTheAuthor(t *testing.T) { issues := refusalIssues(t, wb.ignoreShadowPrecondition()) require.Len(t, issues, 1) assert.Equal(t, manifestanalyzer.IssueIgnoreShadowsManaged, issues[0].Kind) - assert.Equal(t, manifestanalyzer.SolvabilityYes, issues[0].Solvability) + assert.True(t, issues[0].Solvable) assert.Equal(t, manifestanalyzer.ActorRepositoryAuthor, issues[0].Actor) assertClassified(t, issues) } -// TestPathScopeRefusalIsFixableByThePlatform drives the real L1 precondition: widening +// TestPathScopeRefusalIsSolvableByThePlatform drives the real L1 precondition: widening // spec.path, or re-placing the write, is the GitTarget owner's call and nobody else's. -func TestPathScopeRefusalIsFixableByThePlatform(t *testing.T) { +func TestPathScopeRefusalIsSolvableByThePlatform(t *testing.T) { wb := &writeBatch{buffers: map[string]*fileBuffer{ "../escape.yaml": {rel: "../escape.yaml", current: []byte("b")}, }} @@ -122,7 +119,7 @@ func TestPathScopeRefusalIsFixableByThePlatform(t *testing.T) { issues := refusalIssues(t, wb.pathScopePrecondition()) require.Len(t, issues, 1) assert.Equal(t, manifestanalyzer.IssueWriteEscapesScope, issues[0].Kind) - assert.Equal(t, manifestanalyzer.SolvabilityYes, issues[0].Solvability) + assert.True(t, issues[0].Solvable) assert.Equal(t, manifestanalyzer.ActorPlatformOperator, issues[0].Actor) assertClassified(t, issues) } diff --git a/internal/manifestanalyzer/acceptance.go b/internal/manifestanalyzer/acceptance.go index c56d0eaa..3ebe2ed9 100644 --- a/internal/manifestanalyzer/acceptance.go +++ b/internal/manifestanalyzer/acceptance.go @@ -319,8 +319,8 @@ func duplicateRefusals(store *ManifestStore) []AcceptanceIssue { Path: path, DocumentIndex: loser.DocumentIndex, // Two documents claim one identity; the author deletes or renames one. - Solvability: SolvabilityYes, - Actor: ActorRepositoryAuthor, + Solvable: true, + Actor: ActorRepositoryAuthor, Message: fmt.Sprintf("duplicate manifest identity %s at %s#%d; first occurrence at %s#%d", identityRef(dm.ManifestIdentity), path, loser.DocumentIndex, winner.FilePath, winner.DocumentIndex), @@ -372,7 +372,7 @@ func recordlessRefusal(store *ManifestStore, d manifestedit.Diagnostic) (Accepta Kind: IssueNonKRM, Path: d.Path, DocumentIndex: d.DocumentIndex, Message: "YAML is not a Kubernetes manifest", // Remove it, or name it in .gittargetignore. Either way the author acts. - Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor, + Solvable: true, Actor: ActorRepositoryAuthor, }, true case manifestedit.ReasonInvalidYAML, manifestedit.ReasonMissingSopsKey: if managed { @@ -381,7 +381,7 @@ func recordlessRefusal(store *ManifestStore, d manifestedit.Diagnostic) (Accepta return AcceptanceIssue{ Kind: IssueInvalidYAML, Path: d.Path, DocumentIndex: d.DocumentIndex, Message: d.Message, // The document does not parse: one broken document away from working. - Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor, + Solvable: true, Actor: ActorRepositoryAuthor, }, true case manifestedit.ReasonNonEditable, manifestedit.ReasonDuplicateIdentity: // Accompany a managed record (handled by duplicateRefusals / the planner skip); @@ -399,8 +399,8 @@ func impureIssue(d manifestedit.Diagnostic, what string) AcceptanceIssue { Path: d.Path, DocumentIndex: d.DocumentIndex, // Split the file: the passenger document moves to one of its own. - Solvability: SolvabilityYes, - Actor: ActorRepositoryAuthor, + Solvable: true, + Actor: ActorRepositoryAuthor, Message: fmt.Sprintf( "a file with managed resources may contain only valid KRM documents; document #%d is %s", d.DocumentIndex, what), @@ -422,8 +422,8 @@ func mixedFileRefusals(store *ManifestStore) []AcceptanceIssue { Path: rd.Location.Path, DocumentIndex: rd.Location.DocumentIndex, // Move the kustomization into its own file. - Solvability: SolvabilityYes, - Actor: ActorRepositoryAuthor, + Solvable: true, + Actor: ActorRepositoryAuthor, Message: "managed resource " + identityRef(rd.Identity) + " must not live in the allowlisted build-directive file " + rd.Location.Path, }) @@ -464,7 +464,7 @@ func mappingRefusal(ref RecordRef, dm *DocumentModel, policy AcceptancePolicy) ( // widen its scope, and their wizard may not have that person on the screen. return refusal(IssueOutOfScope, ref, "followable kind out of this GitTarget's scope: "+identityRef(dm.ManifestIdentity), - Classification{Solvability: SolvabilityYes, Actor: ActorPlatformOperator}), true + Classification{Solvable: true, Actor: ActorPlatformOperator}), true } return AcceptanceIssue{}, false case MappingNotFollowable: @@ -497,7 +497,7 @@ func refusal(kind IssueKind, ref RecordRef, message string, class Classification Path: ref.FilePath, DocumentIndex: ref.DocumentIndex, Message: message, - Solvability: class.Solvability, + Solvable: class.Solvable, Actor: class.Actor, } } diff --git a/internal/manifestanalyzer/analyzer.go b/internal/manifestanalyzer/analyzer.go index caf010b7..83817f91 100644 --- a/internal/manifestanalyzer/analyzer.go +++ b/internal/manifestanalyzer/analyzer.go @@ -147,12 +147,12 @@ type AcceptanceIssue struct { Path string `json:"path"` DocumentIndex int `json:"documentIndex"` Message string `json:"message"` - // Solvability says whether this refusal can be solved, and Actor names who can solve - // it when someone can. Both are set by the check that RAISED the issue, because only - // that check knows: several kinds answer differently depending on which branch emitted - // them. Empty means unclassified — say nothing about whether it can be solved. - Solvability Solvability `json:"solvability,omitempty"` - Actor Actor `json:"actor,omitempty"` + // Solvable says whether anyone can make this folder acceptable with the current + // release, and Actor names who. Both are decided by the check that RAISED the issue, + // because only that check knows: several kinds answer differently depending on which + // branch emitted them. Actor is empty unless Solvable. + Solvable bool `json:"solvable"` + Actor Actor `json:"actor,omitempty"` // Field and Token add structured diagnostics for a render-fidelity refusal. They are empty // for structural acceptance issues, which predate the render-vs-live gate. Field string `json:"field,omitempty"` @@ -545,21 +545,21 @@ func buildIssues( if duplicates[ref] { issues = append(issues, AcceptanceIssue{ Kind: IssueDuplicate, Path: f.Path, DocumentIndex: d.Index, - Message: "duplicate of " + identityRef(d.Identity), - Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor, + Message: "duplicate of " + identityRef(d.Identity), + Solvable: true, Actor: ActorRepositoryAuthor, }) } switch d.Class { case ClassNonKRM: issues = append(issues, AcceptanceIssue{ Kind: IssueNonKRM, Path: f.Path, DocumentIndex: d.Index, - Message: "YAML is not a Kubernetes manifest", - Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor, + Message: "YAML is not a Kubernetes manifest", + Solvable: true, Actor: ActorRepositoryAuthor, }) case ClassInvalidYAML: issues = append(issues, AcceptanceIssue{ Kind: IssueInvalidYAML, Path: f.Path, DocumentIndex: d.Index, Message: invalidMsgs[ref], - Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor, + Solvable: true, Actor: ActorRepositoryAuthor, }) case ClassNonYAML, ClassEmpty, ClassKRM: // Not acceptance issues: ignored files, empty documents, and valid KRM. diff --git a/internal/manifestanalyzer/gittargetignore.go b/internal/manifestanalyzer/gittargetignore.go index a28f9807..d139cc8b 100644 --- a/internal/manifestanalyzer/gittargetignore.go +++ b/internal/manifestanalyzer/gittargetignore.go @@ -275,8 +275,8 @@ func LoadGitTargetIgnore(content []byte) (*IgnoreMatcher, []AcceptanceIssue) { Kind: IssueIgnoreShadowsManaged, Path: GitTargetIgnoreFileName, // Narrow the pattern: the file that raised this is in the repository. - Solvability: SolvabilityYes, - Actor: ActorRepositoryAuthor, + Solvable: true, + Actor: ActorRepositoryAuthor, Message: fmt.Sprintf( "%s pattern %q matches essentially every managed write path and would blind the "+ "operator to its own files; remove it and name only specific passengers", @@ -315,13 +315,12 @@ func foreignContentRefusals(store *ManifestStore) []AcceptanceIssue { Kind: foreignIssueKind(f.Kind), Path: f.Path, Message: foreignMessage(f), - // Solvability classifies the FOLDER's prospects, not the rule's. The refusal - // of symlinks and submodules is permanent and will not be relaxed, but the - // folder in front of the reader is one `git rm` (or one .gittargetignore - // line) from being adoptable — and that is the sentence this field exists to - // write. - Solvability: SolvabilityYes, - Actor: ActorRepositoryAuthor, + // Solvable describes the FOLDER, not the rule. We refuse symlinks and + // submodules and will not relax that, but the folder in front of the reader + // is one `git rm` (or one .gittargetignore line) from being adoptable, and + // that is the sentence this field exists to write. + Solvable: true, + Actor: ActorRepositoryAuthor, }) } return out diff --git a/internal/manifestanalyzer/scan_repo.go b/internal/manifestanalyzer/scan_repo.go index c1d020cc..8274d3da 100644 --- a/internal/manifestanalyzer/scan_repo.go +++ b/internal/manifestanalyzer/scan_repo.go @@ -74,12 +74,12 @@ const ReasonRefusedStructural = "refused-structural" type RefusalReason struct { Code string `json:"code"` Detail string `json:"detail"` - // Solvability and Actor carry the refusal's own answer to "can this be solved, and by - // whom" — projected verbatim from the acceptance issue that raised it, or set by the - // raise site for the reasons that are not issue kinds. Empty means unclassified; say + // Solvable and Actor carry the refusal's own answer to "can this be solved, and by + // whom" — projected verbatim from the acceptance issue that raised it, or decided by + // the raise site for the reasons that are not issue kinds. Empty means unclassified; say // nothing about whether it can be solved. - Solvability Solvability `json:"solvability,omitempty"` - Actor Actor `json:"actor,omitempty"` + Solvable bool `json:"solvable"` + Actor Actor `json:"actor,omitempty"` } // ResourceCounts splits the KRM a candidate covers into what it renders versus what it @@ -401,7 +401,7 @@ func candidateAcceptance(ctx context.Context, fsys fs.FS, dir string) Acceptance if err != nil { return Acceptance{Issues: []AcceptanceIssue{{ Kind: IssueForeignFile, Path: dir, Message: err.Error(), - Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor, + Solvable: true, Actor: ActorRepositoryAuthor, }}} } policy := ScanPolicy{Acceptance: AcceptancePolicy{Allowlist: WriterAllowlist()}} @@ -425,10 +425,10 @@ func issuesToReasons(issues []AcceptanceIssue) []RefusalReason { detail = iss.Path + ": " + iss.Message } out = append(out, RefusalReason{ - Code: string(iss.Kind), - Detail: detail, - Solvability: iss.Solvability, - Actor: iss.Actor, + Code: string(iss.Kind), + Detail: detail, + Solvable: iss.Solvable, + Actor: iss.Actor, }) } return out @@ -538,16 +538,15 @@ func reachedResourceFiles(kusts map[string]*kustomizationDoc) map[string]struct{ // rule. func refusedStructuralReason(doc *kustomizationDoc, content []byte) RefusalReason { reason := RefusalReason{ - Code: ReasonRefusedStructural, - Detail: refusedStructuralDetail(doc, content), - Solvability: SolvabilityNo, + Code: ReasonRefusedStructural, + Detail: refusedStructuralDetail(doc, content), + Solvable: false, } if doc == nil { return reason } - if class := classifyKustomizeFeatures(doc.features); class.Solvability != SolvabilityUnknown { - reason.Solvability, reason.Actor = class.Solvability, class.Actor - } + class := classifyKustomizeFeatures(doc.features) + reason.Solvable, reason.Actor = class.Solvable, class.Actor return reason } diff --git a/internal/manifestanalyzer/solvability.go b/internal/manifestanalyzer/solvability.go deleted file mode 100644 index 89ced2ee..00000000 --- a/internal/manifestanalyzer/solvability.go +++ /dev/null @@ -1,198 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package manifestanalyzer - -import "sort" - -// Solvability answers one question about a refusal: can it be solved? It is set by the -// check that raised the refusal, because only that check knows — the same code answers -// differently depending on which branch emitted it. -// -// It is a statement about THIS RELEASE, never a promise about the future. "Nobody can -// solve this today" is a fact we can stand behind; "we may support it later" is a roadmap, -// and a roadmap in a machine-readable field ages into a lie in one direction or the other. -// A refusal a later release learns to accept simply reports differently on the next scan, -// because a consumer reads this value rather than a table it cached. -// -// Consumers MUST treat an unrecognised or absent value as SolvabilityUnknown and say -// nothing about whether the refusal can be solved. -// -// See docs/design/analyzer-consumer-contract-asks.md (Ask 1). -type Solvability string - -const ( - // SolvabilityUnknown is the zero value: this refusal was not classified, so say - // nothing about whether it can be solved. - SolvabilityUnknown Solvability = "" - // SolvabilityYes means someone can solve it with the current release, by changing the - // repository or the GitTarget. Actor says who. - SolvabilityYes Solvability = "yes" - // SolvabilityNo means nobody can solve it with the current release: the construct is - // outside the adoption model, or the support is simply not there. Either way the - // folder cannot be adopted as it stands, and no repository or platform change alters - // that. It carries no Actor, because there is nobody to name. - SolvabilityNo Solvability = "no" -) - -// Actor names who can solve a refusal. It is empty unless Solvability is SolvabilityYes: -// naming someone for a refusal they cannot act on is worse than naming nobody. -// -// It exists because some refusals are solvable ONLY by the person who owns the GitTarget, -// who is usually not the person reading the message. Rendering an out-of-scope refusal as -// "fix your repository" to a repository author who cannot is the failure this half -// prevents. -type Actor string - -const ( - // ActorUnknown is the zero value: nobody can solve this refusal today, or it was not - // classified. - ActorUnknown Actor = "" - // ActorRepositoryAuthor is the person who owns the files in the repository. - ActorRepositoryAuthor Actor = "repository-author" - // ActorPlatformOperator is the person who owns the GitTarget — its scope, its path, - // and the CRDs installed in the cluster it mirrors. - ActorPlatformOperator Actor = "platform-operator" -) - -// Classification is the pair a raise site attaches to a refusal. Passing them together -// keeps a solvable refusal from losing the actor half on its way out of the check. -type Classification struct { - Solvability Solvability - Actor Actor -} - -// classified stamps a computed classification onto an issue. Raise sites with one fixed -// answer set Solvability and Actor in the literal instead; this exists for the sites whose -// answer depends on which branch fired. -func (i AcceptanceIssue) classified(c Classification) AcceptanceIssue { - i.Solvability, i.Actor = c.Solvability, c.Actor - return i -} - -// kustomizeFeatureClassification maps every unsupported kustomization construct to its own -// answer. This is the map the ask exists for: `unsupported-kustomize` is one code with two -// very different answers, so classifying it per code would tell most of the people who hit -// it something false. -// -// The split is whether a person can do something about it today: -// -// - solvable by the author — the file itself is broken, or reaches outside the tree. No -// support boundary is in play; one commit clears it. -// - not solvable — content synthesised at build time, fields rewritten after the source -// is read, plugin code, or context this release does not fetch. Nothing the author or -// the platform operator does makes the folder adoptable while it declares them. -// -// Every unmodelled kustomization field key must appear here. -// TestEveryUnsupportedKustomizeFeatureIsClassified walks kustomize's own Kustomization -// struct and fails when a field outside supportedKustomizationFields is unclassified, so a -// kustomize bump cannot quietly reintroduce SolvabilityUnknown. -func kustomizeFeatureClassification() map[string]Classification { - return map[string]Classification{ - // The author's own file, solvable where it stands. - featureUnparseable: {Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, - featureMalformedImages: {Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, - featureMalformedReplicas: {Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, - featurePatchOutsideTree: {Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, - featureRenderFailed: {Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, - - // Read-only context this release does not fetch, and merge semantics it does not - // model. Nobody can make the folder adoptable while it declares them. - featureRemoteBase: {Solvability: SolvabilityNo}, - "helmCharts": {Solvability: SolvabilityNo}, - "helmGlobals": {Solvability: SolvabilityNo}, - "helmChartInflationGenerator": {Solvability: SolvabilityNo}, - "configurations": {Solvability: SolvabilityNo}, - "crds": {Solvability: SolvabilityNo}, - "openapi": {Solvability: SolvabilityNo}, - // Deprecated spellings FixKustomization folds before this map is consulted. Seeing - // one here means kustomize stopped folding it, which is ours to fix, not the - // author's — but it is still not solvable from the repository today. - "bases": {Solvability: SolvabilityNo}, - "imageTags": {Solvability: SolvabilityNo}, - - // Build-time synthesis, post-read rewriting, and plugin code: no source document - // to route an edit back into. - featurePatchInline: {Solvability: SolvabilityNo}, - featurePatchJSON6902: {Solvability: SolvabilityNo}, - "components": {Solvability: SolvabilityNo}, - "configMapGenerator": {Solvability: SolvabilityNo}, - "secretGenerator": {Solvability: SolvabilityNo}, - "generators": {Solvability: SolvabilityNo}, - "transformers": {Solvability: SolvabilityNo}, - "replacements": {Solvability: SolvabilityNo}, - "vars": {Solvability: SolvabilityNo}, - "validators": {Solvability: SolvabilityNo}, - "namePrefix": {Solvability: SolvabilityNo}, - "nameSuffix": {Solvability: SolvabilityNo}, - "patchesStrategicMerge": {Solvability: SolvabilityNo}, - "patchesJson6902": {Solvability: SolvabilityNo}, - } -} - -// classifyKustomizeFeatures reduces the unsupported constructs one kustomization declares -// to the single answer the folder deserves. The LEAST solvable one wins: a folder holding -// both a typo and a configMapGenerator is not adoptable once the typo is fixed, so telling -// its author to go fix something would be a confident wrong sentence. -// -// An empty or wholly unrecognised feature set degrades to SolvabilityUnknown — silence — -// rather than to a guess. -func classifyKustomizeFeatures(features []string) Classification { - classes := kustomizeFeatureClassification() - out := Classification{} - for _, f := range features { - c, known := classes[f] - if !known { - continue - } - if lessSolvable(c.Solvability, out.Solvability) { - out = c - } - } - return out -} - -// How solvable each answer is, most solvable first. Unknown ranks below a real answer, so -// silence never wins over something a check actually decided. -const ( - rankUnknown = iota - rankYes - rankNo -) - -// lessSolvable reports whether a is a less solvable answer than b — the reduction rule for -// a refusal raised by several constructs at once. -func lessSolvable(a, b Solvability) bool { - return solvabilityRank(a) > solvabilityRank(b) -} - -// solvabilityRank orders the answers for "least solvable wins". -func solvabilityRank(s Solvability) int { - switch s { - case SolvabilityNo: - return rankNo - case SolvabilityYes: - return rankYes - case SolvabilityUnknown: - return rankUnknown - } - return rankUnknown -} - -// retainedUnsupportedFeatures names the constructs behind one unsupported retention: the -// parsed feature set, plus featureRenderFailed when kustomize could not build the root at -// all. It returns nothing for a supported retention, so the field is empty exactly when -// Unsupported is false. -func retainedUnsupportedFeatures(unsupported bool, doc *kustomizationDoc, buildFailed bool) []string { - if !unsupported { - return nil - } - var out []string - if doc != nil { - out = append(out, doc.features...) - } - if buildFailed { - out = append(out, featureRenderFailed) - } - sort.Strings(out) - return out -} diff --git a/internal/manifestanalyzer/solvable.go b/internal/manifestanalyzer/solvable.go new file mode 100644 index 00000000..ea5ec029 --- /dev/null +++ b/internal/manifestanalyzer/solvable.go @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 + +package manifestanalyzer + +import "sort" + +// Actor names who can solve a refusal. It is empty unless the refusal is Solvable — +// naming someone for a refusal they cannot act on is worse than naming nobody. +// +// It exists because some refusals are solvable ONLY by the person who owns the GitTarget, +// who is usually not the person reading the message. Rendering an out-of-scope refusal as +// "fix your repository" to a repository author who cannot is the failure this half +// prevents. +type Actor string + +const ( + // ActorUnknown is the zero value: nobody can solve this refusal, or the check did not + // say who. + ActorUnknown Actor = "" + // ActorRepositoryAuthor is the person who owns the files in the repository. + ActorRepositoryAuthor Actor = "repository-author" + // ActorPlatformOperator is the person who owns the GitTarget — its scope, its path, + // and the CRDs installed in the cluster it mirrors. + ActorPlatformOperator Actor = "platform-operator" +) + +// Classification is the pair a raise site attaches to a refusal: can it be solved, and by +// whom. Passing them together keeps a solvable refusal from losing the actor half on its +// way out of the check. +type Classification struct { + Solvable bool + Actor Actor +} + +// classified stamps a computed classification onto an issue. Raise sites with one fixed +// answer set Solvable and Actor in the literal instead; this exists for the sites whose +// answer depends on which branch fired. +func (i AcceptanceIssue) classified(c Classification) AcceptanceIssue { + i.Solvable, i.Actor = c.Solvable, c.Actor + return i +} + +// kustomizeFeatureClassification maps every unsupported kustomization construct to its own +// answer. This is the map the ask exists for: `unsupported-kustomize` is one code with two +// very different answers, so classifying it per code would tell most of the people who hit +// it something false. +// +// The split is whether a person can do something about it today: +// +// - solvable by the author — the file itself is broken, or reaches outside the tree. No +// support boundary is in play; one commit clears it. +// - not solvable — content synthesised at build time, fields rewritten after the source +// is read, plugin code, or context this release does not fetch. Nothing the author or +// the platform operator does makes the folder adoptable while it declares them. +// +// Every unmodelled kustomization field key must appear here. +// TestEveryUnsupportedKustomizeFeatureIsClassified walks kustomize's own Kustomization +// struct and fails when a field outside supportedKustomizationFields is missing, so a +// kustomize bump cannot quietly add a construct that refuses a folder with no answer. +func kustomizeFeatureClassification() map[string]Classification { + return map[string]Classification{ + // The author's own file, solvable where it stands. + featureUnparseable: {Solvable: true, Actor: ActorRepositoryAuthor}, + featureMalformedImages: {Solvable: true, Actor: ActorRepositoryAuthor}, + featureMalformedReplicas: {Solvable: true, Actor: ActorRepositoryAuthor}, + featurePatchOutsideTree: {Solvable: true, Actor: ActorRepositoryAuthor}, + featureRenderFailed: {Solvable: true, Actor: ActorRepositoryAuthor}, + + // Read-only context this release does not fetch, and merge semantics it does not + // model. Nobody can make the folder adoptable while it declares them. + featureRemoteBase: {Solvable: false}, + "helmCharts": {Solvable: false}, + "helmGlobals": {Solvable: false}, + "helmChartInflationGenerator": {Solvable: false}, + "configurations": {Solvable: false}, + "crds": {Solvable: false}, + "openapi": {Solvable: false}, + // Deprecated spellings FixKustomization folds before this map is consulted. Seeing + // one here means kustomize stopped folding it, which is ours to fix, not the + // author's — but it is still not solvable from the repository today. + "bases": {Solvable: false}, + "imageTags": {Solvable: false}, + + // Build-time synthesis, post-read rewriting, and plugin code: no source document + // to route an edit back into. + featurePatchInline: {Solvable: false}, + featurePatchJSON6902: {Solvable: false}, + "components": {Solvable: false}, + "configMapGenerator": {Solvable: false}, + "secretGenerator": {Solvable: false}, + "generators": {Solvable: false}, + "transformers": {Solvable: false}, + "replacements": {Solvable: false}, + "vars": {Solvable: false}, + "validators": {Solvable: false}, + "namePrefix": {Solvable: false}, + "nameSuffix": {Solvable: false}, + "patchesStrategicMerge": {Solvable: false}, + "patchesJson6902": {Solvable: false}, + } +} + +// classifyKustomizeFeatures reduces the unsupported constructs one kustomization declares +// to the single answer the folder deserves: solvable only when EVERY construct is. A +// folder holding both a typo and a configMapGenerator is not adoptable once the typo is +// fixed, so telling its author to go fix something would be a confident wrong sentence. +// +// A feature set with nothing recognised in it reports not solvable, which is the +// conservative claim: it sends nobody off to fix something they cannot. The classification +// tests keep that case unreachable. +func classifyKustomizeFeatures(features []string) Classification { + classes := kustomizeFeatureClassification() + out := Classification{} + for _, f := range features { + c, known := classes[f] + if !known { + return Classification{} + } + if !c.Solvable { + return Classification{} + } + out = c + } + return out +} + +// retainedUnsupportedFeatures names the constructs behind one unsupported retention: the +// parsed feature set, plus featureRenderFailed when kustomize could not build the root at +// all. It returns nothing for a supported retention, so the field is empty exactly when +// Unsupported is false. +func retainedUnsupportedFeatures(unsupported bool, doc *kustomizationDoc, buildFailed bool) []string { + if !unsupported { + return nil + } + var out []string + if doc != nil { + out = append(out, doc.features...) + } + if buildFailed { + out = append(out, featureRenderFailed) + } + sort.Strings(out) + return out +} diff --git a/internal/manifestanalyzer/solvability_test.go b/internal/manifestanalyzer/solvable_test.go similarity index 72% rename from internal/manifestanalyzer/solvability_test.go rename to internal/manifestanalyzer/solvable_test.go index d3014f34..a733b31b 100644 --- a/internal/manifestanalyzer/solvability_test.go +++ b/internal/manifestanalyzer/solvable_test.go @@ -24,11 +24,19 @@ import ( // drifts exactly like the doc comments that caused the original bug: the code space went // degenerate when a refusal was retired, and nothing said so. Two things are pinned here: // -// - every IssueKind constant is classified, discovered from the SOURCE rather than from -// a list someone remembers to update, so a new kind fails this test; and -// - every EMISSION PATH is classified, because two kinds answer differently depending on -// which branch raised them. A per-constant test passes while a new branch quietly -// emits SolvabilityUnknown, which reproduces the original bug one level down. +// - every IssueKind constant appears in the table below, discovered from the SOURCE +// rather than from a list someone remembers to update, so a new kind fails this test +// until someone decides what it answers; and +// - every EMISSION PATH produces the answer the table claims, because two kinds answer +// differently depending on which branch raised them. A per-constant test passes while +// a new branch quietly reports the wrong thing, which reproduces the original bug one +// level down. +// +// Solvable is a bool, so "nobody decided" is indistinguishable from "cannot be solved" at +// runtime: false is both the honest answer for most refusals and the value a forgotten +// raise site produces. That is a deliberate trade for a simpler contract, and it is why +// the table below is checked against the SOURCE — the kind list is the one place the +// omission is still visible. // // See docs/design/analyzer-consumer-contract-asks.md (Ask 1). @@ -36,32 +44,32 @@ import ( // whose answer depends on the branch that raised it lists every branch it can produce, so // this file never claims a single answer where the code has two. var classificationByKind = map[IssueKind][]Classification{ - IssueInvalidYAML: {{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}}, - IssueDuplicate: {{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}}, - IssueImpureManagedFile: {{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}}, - IssueMixedFile: {{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}}, - IssueIgnoreShadowsManaged: {{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}}, - IssueNonKRM: {{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}}, - IssueForeignFile: {{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}}, - IssueForeignSymlink: {{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}}, - IssueForeignSubmodule: {{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}}, - IssueOutOfScope: {{Solvability: SolvabilityYes, Actor: ActorPlatformOperator}}, - IssueWriteEscapesScope: {{Solvability: SolvabilityYes, Actor: ActorPlatformOperator}}, - IssueRenderDoesNotMatchLive: {{Solvability: SolvabilityYes, Actor: ActorPlatformOperator}}, - IssueWriteFanIn: {{Solvability: SolvabilityNo}}, - IssueUnplaceableEdit: {{Solvability: SolvabilityNo}}, - IssueRenderRefused: {{Solvability: SolvabilityNo}}, + IssueInvalidYAML: {{Solvable: true, Actor: ActorRepositoryAuthor}}, + IssueDuplicate: {{Solvable: true, Actor: ActorRepositoryAuthor}}, + IssueImpureManagedFile: {{Solvable: true, Actor: ActorRepositoryAuthor}}, + IssueMixedFile: {{Solvable: true, Actor: ActorRepositoryAuthor}}, + IssueIgnoreShadowsManaged: {{Solvable: true, Actor: ActorRepositoryAuthor}}, + IssueNonKRM: {{Solvable: true, Actor: ActorRepositoryAuthor}}, + IssueForeignFile: {{Solvable: true, Actor: ActorRepositoryAuthor}}, + IssueForeignSymlink: {{Solvable: true, Actor: ActorRepositoryAuthor}}, + IssueForeignSubmodule: {{Solvable: true, Actor: ActorRepositoryAuthor}}, + IssueOutOfScope: {{Solvable: true, Actor: ActorPlatformOperator}}, + IssueWriteEscapesScope: {{Solvable: true, Actor: ActorPlatformOperator}}, + IssueRenderDoesNotMatchLive: {{Solvable: true, Actor: ActorPlatformOperator}}, + IssueWriteFanIn: {{Solvable: false}}, + IssueUnplaceableEdit: {{Solvable: false}}, + IssueRenderRefused: {{Solvable: false}}, // One code, two answers — the case that proves the whole ask. A build file the author // broke is one commit from working; a generator is not solvable at all. IssueUnsupportedKustomize: { - {Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, - {Solvability: SolvabilityNo}, + {Solvable: true, Actor: ActorRepositoryAuthor}, + {Solvable: false}, }, // A kind nothing serves is one CRD install away; a kind that is served but ambiguous, // denied, or missing a verb is not solvable from either side. IssueUnresolvedKRM: { - {Solvability: SolvabilityYes, Actor: ActorPlatformOperator}, - {Solvability: SolvabilityNo}, + {Solvable: true, Actor: ActorPlatformOperator}, + {Solvable: false}, }, } @@ -77,8 +85,9 @@ func TestEveryIssueKindIsClassified(t *testing.T) { for _, kind := range kinds { classes, ok := classificationByKind[IssueKind(kind)] if !ok { - t.Errorf("IssueKind %q does not say whether it can be solved: a consumer receiving it can only "+ - "say \"this folder cannot be picked\". Classify it at its raise site and add it here.", kind) + t.Errorf("IssueKind %q is not in the settled table: decide at its raise site whether anyone "+ + "can solve it, and record the answer here. Left alone it reports \"not solvable\", which "+ + "is a claim nobody made.", kind) continue } for _, c := range classes { @@ -87,18 +96,15 @@ func TestEveryIssueKindIsClassified(t *testing.T) { } } -// assertCoherent holds the two invariants every classification must satisfy: it answers -// the question, and it names an actor exactly when someone can act. +// assertCoherent holds the invariant every classification must satisfy: it names an actor +// exactly when someone can act. func assertCoherent(t *testing.T, what string, c Classification) { t.Helper() - if c.Solvability == SolvabilityUnknown { - t.Errorf("%q is classified SolvabilityUnknown; refusals ship classified", what) - } - if c.Solvability == SolvabilityYes && c.Actor == ActorUnknown { + if c.Solvable && c.Actor == ActorUnknown { t.Errorf("%q is solvable but names no actor, so it renders as \"fix your repository\" to "+ "someone who may not be able to", what) } - if c.Solvability != SolvabilityYes && c.Actor != ActorUnknown { + if !c.Solvable && c.Actor != ActorUnknown { t.Errorf("%q names an actor for a refusal nobody can solve", what) } } @@ -194,14 +200,14 @@ func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { fsys: fstest.MapFS{"broken.yaml": {Data: []byte(brokenYAML)}}, want: []wantIssue{{ kind: IssueInvalidYAML, - class: Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, + class: Classification{Solvable: true, Actor: ActorRepositoryAuthor}, }}, }, "non-krm yaml": { fsys: fstest.MapFS{"values.yaml": {Data: []byte(plainYAML)}}, want: []wantIssue{{ kind: IssueNonKRM, - class: Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, + class: Classification{Solvable: true, Actor: ActorRepositoryAuthor}, }}, }, "duplicate identity": { @@ -211,14 +217,14 @@ func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { }, want: []wantIssue{{ kind: IssueDuplicate, - class: Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, + class: Classification{Solvable: true, Actor: ActorRepositoryAuthor}, }}, }, "impure managed file": { fsys: fstest.MapFS{"mixed.yaml": {Data: []byte(deployYAML + "---\n" + plainYAML)}}, want: []wantIssue{{ kind: IssueImpureManagedFile, - class: Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, + class: Classification{Solvable: true, Actor: ActorRepositoryAuthor}, }}, }, "managed resource in a build directive": { @@ -226,7 +232,7 @@ func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { policy: AcceptancePolicy{Allowlist: DefaultAllowlist()}, want: []wantIssue{{ kind: IssueMixedFile, - class: Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, + class: Classification{Solvable: true, Actor: ActorRepositoryAuthor}, }}, }, "foreign file": { @@ -236,7 +242,7 @@ func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { }, want: []wantIssue{{ kind: IssueForeignFile, - class: Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, + class: Classification{Solvable: true, Actor: ActorRepositoryAuthor}, }}, }, "out of scope": { @@ -247,7 +253,7 @@ func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { }, want: []wantIssue{{ kind: IssueOutOfScope, - class: Classification{Solvability: SolvabilityYes, Actor: ActorPlatformOperator}, + class: Classification{Solvable: true, Actor: ActorPlatformOperator}, }}, }, // The two unresolved-krm branches, which is the split the raise site exists to @@ -258,7 +264,7 @@ func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { mapper: typeset.NewSnapshotRegistry(typeset.Snapshot{Generation: 1}), want: []wantIssue{{ kind: IssueUnresolvedKRM, - class: Classification{Solvability: SolvabilityYes, Actor: ActorPlatformOperator}, + class: Classification{Solvable: true, Actor: ActorPlatformOperator}, }}, }, "unresolved krm, kind served but not followable": { @@ -266,7 +272,7 @@ func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { mapper: typeset.NewSnapshotRegistry(sampleClusterSnapshot()), want: []wantIssue{{ kind: IssueUnresolvedKRM, - class: Classification{Solvability: SolvabilityNo}, + class: Classification{Solvable: false}, }}, }, // Both unsupported-kustomize branches, through the real gate. @@ -278,7 +284,7 @@ func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { policy: AcceptancePolicy{Allowlist: DefaultAllowlist()}, want: []wantIssue{{ kind: IssueUnsupportedKustomize, - class: Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, + class: Classification{Solvable: true, Actor: ActorRepositoryAuthor}, }}, }, "unsupported kustomize, context we do not read": { @@ -289,7 +295,7 @@ func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { policy: AcceptancePolicy{Allowlist: DefaultAllowlist()}, want: []wantIssue{{ kind: IssueUnsupportedKustomize, - class: Classification{Solvability: SolvabilityNo}, + class: Classification{Solvable: false}, }}, }, "unsupported kustomize, the support boundary": { @@ -300,7 +306,7 @@ func TestAcceptanceEmissionPathsAreClassified(t *testing.T) { policy: AcceptancePolicy{Allowlist: DefaultAllowlist()}, want: []wantIssue{{ kind: IssueUnsupportedKustomize, - class: Classification{Solvability: SolvabilityNo}, + class: Classification{Solvable: false}, }}, }, } @@ -327,7 +333,7 @@ func assertIssueClassified(t *testing.T, acc Acceptance, want wantIssue) { if issue.Kind != want.kind { continue } - if got := (Classification{Solvability: issue.Solvability, Actor: issue.Actor}); got != want.class { + if got := (Classification{Solvable: issue.Solvable, Actor: issue.Actor}); got != want.class { t.Errorf("%s at %s: classification = %+v, want %+v", issue.Kind, issue.Path, got, want.class) } return @@ -337,7 +343,7 @@ func assertIssueClassified(t *testing.T, acc Acceptance, want wantIssue) { // TestForeignEntryRefusalsAreClassified covers the symlink and submodule raise sites, // which no in-memory filesystem can produce. They are the rule the whole table follows: -// solvability describes the FOLDER, not the rule. We will never follow a symlink, yet the +// solvability describes the FOLDER, not the rule: we will never follow a symlink, yet the // folder in front of the reader is one `git rm` from being adoptable. func TestForeignEntryRefusalsAreClassified(t *testing.T) { store := &ManifestStore{Foreign: []ForeignEntry{ @@ -349,9 +355,9 @@ func TestForeignEntryRefusalsAreClassified(t *testing.T) { if len(issues) != 3 { t.Fatalf("want three foreign refusals, got %+v", issues) } - want := Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor} + want := Classification{Solvable: true, Actor: ActorRepositoryAuthor} for _, issue := range issues { - if got := (Classification{Solvability: issue.Solvability, Actor: issue.Actor}); got != want { + if got := (Classification{Solvable: issue.Solvable, Actor: issue.Actor}); got != want { t.Errorf("%s at %s: classification = %+v, want %+v", issue.Kind, issue.Path, got, want) } } @@ -364,8 +370,8 @@ func TestIgnoreShadowsManagedRefusalIsClassified(t *testing.T) { if len(issues) != 1 || issues[0].Kind != IssueIgnoreShadowsManaged { t.Fatalf("want one ignore-shadows-managed refusal, got %+v", issues) } - want := Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor} - if got := (Classification{Solvability: issues[0].Solvability, Actor: issues[0].Actor}); got != want { + want := Classification{Solvable: true, Actor: ActorRepositoryAuthor} + if got := (Classification{Solvable: issues[0].Solvable, Actor: issues[0].Actor}); got != want { t.Errorf("classification = %+v, want %+v", got, want) } } @@ -385,8 +391,8 @@ func TestEveryUnsupportedKustomizeFeatureIsClassified(t *testing.T) { } key := kustomizationFieldKey(field) if _, ok := classes[key]; !ok { - t.Errorf("unmodelled kustomization field %q (%s) does not say whether it can be solved: it "+ - "would refuse a folder with no answer to \"can this be fixed\".", key, field.Name) + t.Errorf("unmodelled kustomization field %q (%s) is unclassified: it would refuse a folder "+ + "and report \"not solvable\" by default, which is a claim nobody made.", key, field.Name) } } for _, f := range []string{ @@ -394,7 +400,7 @@ func TestEveryUnsupportedKustomizeFeatureIsClassified(t *testing.T) { featurePatchInline, featurePatchJSON6902, featurePatchOutsideTree, featureRenderFailed, } { if _, ok := classes[f]; !ok { - t.Errorf("feature %q does not say whether it can be solved", f) + t.Errorf("feature %q is missing from the classification map", f) } } for f, c := range classes { @@ -402,26 +408,29 @@ func TestEveryUnsupportedKustomizeFeatureIsClassified(t *testing.T) { } } -// TestClassifyKustomizeFeatures_LeastSolvableWins pins the reduction rule. A folder -// holding both a typo and a generator is not adoptable once the typo is fixed, so telling -// its author to go fix something would be a confident wrong sentence. -func TestClassifyKustomizeFeatures_LeastSolvableWins(t *testing.T) { - solvable := Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor} - unsolvable := Classification{Solvability: SolvabilityNo} +// TestClassifyKustomizeFeatures_SolvableOnlyWhenEveryConstructIs pins the reduction rule. +// A folder holding both a typo and a generator is not adoptable once the typo is fixed, so +// telling its author to go fix something would be a confident wrong sentence. +func TestClassifyKustomizeFeatures_SolvableOnlyWhenEveryConstructIs(t *testing.T) { + solvable := Classification{Solvable: true, Actor: ActorRepositoryAuthor} + unsolvable := Classification{Solvable: false} cases := map[string]struct { features []string want Classification }{ "nothing at all": {nil, Classification{}}, - "unrecognised only": {[]string{"someFutureField"}, Classification{}}, + "unrecognised only": {[]string{"someFutureField"}, unsolvable}, "the author's file": {[]string{featureUnparseable}, solvable}, "context we do not read": {[]string{featureRemoteBase}, unsolvable}, "the boundary": {[]string{"configMapGenerator"}, unsolvable}, "solvable and not": {[]string{featureUnparseable, featureRemoteBase}, unsolvable}, "two unsolvable": {[]string{featureRemoteBase, "vars"}, unsolvable}, - "solvable and unknown": {[]string{"someFutureField", featureUnparseable}, solvable}, - "unsolvable over all": {[]string{featureUnparseable, featureRemoteBase, "replacements"}, unsolvable}, + // An unrecognised construct is a construct we cannot vouch for, so the answer is the + // conservative one: fixing the typo would not be enough, and saying otherwise sends + // the author off to fix something that leaves the folder just as unadoptable. + "solvable and unrecognised": {[]string{"someFutureField", featureUnparseable}, unsolvable}, + "unsolvable over all": {[]string{featureUnparseable, featureRemoteBase, "replacements"}, unsolvable}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { @@ -437,16 +446,16 @@ func TestClassifyKustomizeFeatures_LeastSolvableWins(t *testing.T) { // becomes a refusal reason, so a check that classifies itself must arrive intact. func TestIssuesToReasons_CarriesClassification(t *testing.T) { reasons := issuesToReasons([]AcceptanceIssue{{ - Kind: IssueOutOfScope, - Path: "apps/web/deploy.yaml", - Message: "out of scope", - Solvability: SolvabilityYes, - Actor: ActorPlatformOperator, + Kind: IssueOutOfScope, + Path: "apps/web/deploy.yaml", + Message: "out of scope", + Solvable: true, + Actor: ActorPlatformOperator, }}) if len(reasons) != 1 { t.Fatalf("want one reason, got %+v", reasons) } - if reasons[0].Solvability != SolvabilityYes || reasons[0].Actor != ActorPlatformOperator { + if reasons[0].Solvable != true || reasons[0].Actor != ActorPlatformOperator { t.Errorf("reason = %+v, want the issue's own classification", reasons[0]) } } @@ -477,15 +486,15 @@ func TestRefusedStructuralIsClassified(t *testing.T) { }{ "a generator is not solvable": { kustomization: kustHeader + "configMapGenerator:\n - name: settings\n literals:\n - a=b\n", - want: Classification{Solvability: SolvabilityNo}, + want: Classification{Solvable: false}, }, "helm inflation is not solvable": { kustomization: kustHeader + "helmCharts:\n - name: podinfo\n repo: https://example.com\n", - want: Classification{Solvability: SolvabilityNo}, + want: Classification{Solvable: false}, }, "an unparseable build file is the author's to solve": { kustomization: "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources: [\n", - want: Classification{Solvability: SolvabilityYes, Actor: ActorRepositoryAuthor}, + want: Classification{Solvable: true, Actor: ActorRepositoryAuthor}, }, } @@ -501,7 +510,7 @@ func TestRefusedStructuralIsClassified(t *testing.T) { if !ok { t.Fatalf("expected a refused-structural candidate, got %+v", rep.Candidates) } - if got := (Classification{Solvability: reason.Solvability, Actor: reason.Actor}); got != tc.want { + if got := (Classification{Solvable: reason.Solvable, Actor: reason.Actor}); got != tc.want { t.Errorf("refused-structural reason = %+v, want %+v", reason, tc.want) } }) diff --git a/internal/manifestanalyzer/store.go b/internal/manifestanalyzer/store.go index c1dc2614..cac2f5b5 100644 --- a/internal/manifestanalyzer/store.go +++ b/internal/manifestanalyzer/store.go @@ -1006,11 +1006,11 @@ func (s *ManifestStore) resolveMapping( if !known { // Nothing serves this GVK: install the CRD (or widen what the registry may see) // and the same folder is adoptable, so the platform operator can solve it. - dm.MappingRefusal = Classification{Solvability: SolvabilityYes, Actor: ActorPlatformOperator} + dm.MappingRefusal = Classification{Solvable: true, Actor: ActorPlatformOperator} } else if !record.Followable() { // Served, but ambiguous, denied, or missing a verb. Nobody can solve that from // the repository or the GitTarget. - dm.MappingRefusal = Classification{Solvability: SolvabilityNo} + dm.MappingRefusal = Classification{Solvable: false} } if known && record.Followable() { dm.Mapping = MappingFollowable diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/helm-inflation.golden.json b/internal/manifestanalyzer/testdata/scan-repo/unsupported/helm-inflation.golden.json index b6c3d13d..447e67fc 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/unsupported/helm-inflation.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/unsupported/helm-inflation.golden.json @@ -8,7 +8,7 @@ { "code": "refused-structural", "detail": "kustomization uses unsupported feature(s): helmCharts", - "solvability": "no" + "solvable": false } ], "renderRoot": true, diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json b/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json index 6fbc44a8..9abb80e5 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json @@ -8,7 +8,7 @@ { "code": "non-krm-yaml", "detail": "values.yaml: YAML is not a Kubernetes manifest", - "solvability": "yes", + "solvable": true, "actor": "repository-author" } ], diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/unsupported-kustomize.golden.json b/internal/manifestanalyzer/testdata/scan-repo/unsupported/unsupported-kustomize.golden.json index 671f2598..d8fdc85a 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/unsupported/unsupported-kustomize.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/unsupported/unsupported-kustomize.golden.json @@ -8,7 +8,7 @@ { "code": "refused-structural", "detail": "kustomization uses unsupported feature(s): components, crds, namePrefix, openapi, secretGenerator", - "solvability": "no" + "solvable": false } ], "renderRoot": true, diff --git a/pkg/manifestanalyzer/contract.go b/pkg/manifestanalyzer/contract.go index df40c6b2..916d4023 100644 --- a/pkg/manifestanalyzer/contract.go +++ b/pkg/manifestanalyzer/contract.go @@ -3,7 +3,11 @@ package manifestanalyzer import ( + "encoding/json" + "io" "runtime/debug" + + "sigs.k8s.io/yaml" ) // The report envelope. A report is a KRM document — apiVersion, kind, spec, status — and @@ -130,3 +134,21 @@ func usableVersion(v string) (string, bool) { func generator() Generator { return Generator{Name: GeneratorName, Version: Version()} } + +// writeJSON renders a report as indented JSON, the wire form both report kinds share. +func writeJSON(w io.Writer, report any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(report) +} + +// writeYAML renders a report as YAML through sigs.k8s.io/yaml, which round-trips via the +// JSON tags — so the two serializations name every field identically and cannot drift. +func writeYAML(w io.Writer, report any) error { + out, err := yaml.Marshal(report) + if err != nil { + return err + } + _, err = w.Write(out) + return err +} diff --git a/pkg/manifestanalyzer/contract_test.go b/pkg/manifestanalyzer/contract_test.go index cb130394..8e17ae1b 100644 --- a/pkg/manifestanalyzer/contract_test.go +++ b/pkg/manifestanalyzer/contract_test.go @@ -111,11 +111,9 @@ func TestScanFolder_RefusalsCarryPermanence(t *testing.T) { require.NotEmpty(t, report.Status.Issues) for _, issue := range report.Status.Issues { - require.NotEqual(t, manifestanalyzer.SolvabilityUnknown, issue.Solvability, - "%s reached a consumer unclassified", issue.Kind) - if issue.Solvability == manifestanalyzer.SolvabilityYes { + if issue.Solvable { require.NotEqual(t, manifestanalyzer.ActorUnknown, issue.Actor, - "%s is fixable but does not say by whom", issue.Kind) + "%s is solvable but does not say by whom", issue.Kind) } else { require.Equal(t, manifestanalyzer.ActorUnknown, issue.Actor, "%s names an actor for a refusal nobody can act on", issue.Kind) @@ -141,3 +139,53 @@ func refusedTree() fstest.MapFS { "values.yaml": {Data: []byte("replicaCount: 2\n")}, } } + +// Every refusal in the whole discovery corpus, not just the handful of trees the +// emission-path tests build by hand, must name an actor exactly when it is solvable. An +// actor on an unsolvable refusal sends someone to fix what they cannot; a solvable refusal +// with no actor renders as "fix your repository" to a reader who may have no power to. +func TestScanRepo_EveryRefusalNamesAnActorWhenSolvable(t *testing.T) { + t.Parallel() + + families, err := os.ReadDir(corpusRoot) + require.NoError(t, err) + + var refusals int + for _, family := range families { + if !family.IsDir() { + continue + } + fixtures, err := os.ReadDir(filepath.Join(corpusRoot, family.Name())) + require.NoError(t, err) + for _, f := range fixtures { + if !f.IsDir() { + continue + } + refusals += assertCorpusFixtureClassified(t, filepath.Join(corpusRoot, family.Name(), f.Name())) + } + } + require.Positive(t, refusals, "the corpus produced no refusals at all; this test proved nothing") +} + +// assertCorpusFixtureClassified scans one corpus fixture and asserts every refusal it +// reports names an actor exactly when it is solvable. It returns how many it checked. +func assertCorpusFixtureClassified(t *testing.T, root string) int { + t.Helper() + report, err := manifestanalyzer.ScanRepo(t.Context(), root) + require.NoError(t, err) + + var n int + for _, cand := range report.Status.Candidates { + for _, reason := range cand.RefusalReasons { + n++ + if reason.Solvable { + require.NotEqual(t, manifestanalyzer.ActorUnknown, reason.Actor, + "%s: %s at %s is solvable but does not say by whom", root, reason.Code, cand.Path) + } else { + require.Equal(t, manifestanalyzer.ActorUnknown, reason.Actor, + "%s: %s at %s names an actor for a refusal nobody can solve", root, reason.Code, cand.Path) + } + } + } + return n +} diff --git a/pkg/manifestanalyzer/doc.go b/pkg/manifestanalyzer/doc.go index 699ed61e..b62026f4 100644 --- a/pkg/manifestanalyzer/doc.go +++ b/pkg/manifestanalyzer/doc.go @@ -40,13 +40,15 @@ // // # A refusal says whether it can be solved // -// [Issue] and [RefusalReason] carry a [Solvability] — "yes" or "no" — and, when someone -// can act, an [Actor]. A code alone cannot tell "one broken document away from working" -// from "this folder cannot be adopted", and guessing from the code is how a consumer ends -// up telling a user to go fix something only their platform team can, or nothing at all. -// The answer describes this release and makes no promise about the future, so read it on -// every scan rather than caching a mapping from it. Treat an absent or unrecognised value -// as [SolvabilityUnknown] and say nothing. +// [Issue] and [RefusalReason] carry a `solvable` boolean and, when someone can act, an +// [Actor]. A code alone cannot tell "one broken document away from working" from "this +// folder cannot be adopted", and guessing from the code is how a consumer ends up telling +// a user to go fix something only their platform team can, or nothing at all. The answer +// describes this release and makes no promise about the future, so read it on every scan +// rather than caching a mapping from it. +// +// The field is always present. A report produced before it shipped carries no `solvable` +// key at all, which is the one case where "nobody said" is distinguishable from "no". // // Everything under internal/ carries no guarantee either, and is not importable from // another module. One format from there is nonetheless a contract you may build on: a @@ -60,8 +62,14 @@ // // The command-line equivalents are `manifest-analyzer --mode scan-folder --format json` and // `--mode scan-repo --format json`, which emit exactly the documents [FolderReport] -// and [RepoReport] marshal to. Exec the binary if Go is not your language; import this -// package if it is. +// and [RepoReport] marshal to. `--format yaml` prints the same document in the +// serialization a KRM document reads best in. Exec the binary if Go is not your language; +// import this package if it is. +// +// To see a whole document before you write a line of parsing, read +// pkg/manifestanalyzer/testdata/repo-report.golden.yaml and folder-report.golden.yaml. +// They are checked-in output, regenerated from the real scan, so they cannot describe a +// shape you will not receive. // // # What it does not do // diff --git a/pkg/manifestanalyzer/folder.go b/pkg/manifestanalyzer/folder.go index d50d77f0..deb6093e 100644 --- a/pkg/manifestanalyzer/folder.go +++ b/pkg/manifestanalyzer/folder.go @@ -4,7 +4,6 @@ package manifestanalyzer import ( "context" - "encoding/json" "io" "io/fs" @@ -70,35 +69,8 @@ const ( IssueUnplaceableEdit IssueKind = "unplaceable-edit" ) -// Solvability answers one question about a refusal: can it be solved? It is set by the -// check that raised the refusal, because only that check knows — one code answers -// differently depending on which branch emitted it, which is why this is a field on the -// emitted value rather than a table beside the constants. -// -// It describes THIS RELEASE and makes no promise about the future. A refusal a later -// release learns to accept simply reports differently on the next scan, so read this value -// each time rather than caching a mapping from it. -// -// Consumers MUST treat an unrecognised or absent value as [SolvabilityUnknown] and say -// nothing about whether the refusal can be solved. A code alone cannot tell "fix your -// YAML" from "this folder cannot be adopted"; that is what this field is for. -type Solvability string - -const ( - // SolvabilityUnknown is the zero value: not classified. Say nothing. - SolvabilityUnknown Solvability = "" - // SolvabilityYes means someone can solve it with this release, by changing the - // repository or the GitTarget. [Actor] says who. - SolvabilityYes Solvability = "yes" - // SolvabilityNo means nobody can solve it with this release: the construct is outside - // the adoption model, or the support is simply not there. The folder cannot be adopted - // as it stands, and no [Actor] is named because there is nobody to name. - SolvabilityNo Solvability = "no" -) - -// Actor names who can solve a refusal. It is empty unless [Solvability] is -// [SolvabilityYes] — naming someone for a refusal they cannot act on is worse than naming -// nobody. +// Actor names who can solve a refusal. It is empty unless the refusal is solvable — +// naming someone for a refusal they cannot act on is worse than naming nobody. // // It matters because some refusals are solvable ONLY by the person who owns the GitTarget, // who is often not the person reading the message: rendering an out-of-scope refusal as @@ -107,7 +79,8 @@ const ( type Actor string const ( - // ActorUnknown is the zero value: nobody can solve it today, or it was not classified. + // ActorUnknown is the zero value: nobody can solve this refusal, or the check did not + // say who. ActorUnknown Actor = "" // ActorRepositoryAuthor is the person who owns the files in the repository. ActorRepositoryAuthor Actor = "repository-author" @@ -127,9 +100,17 @@ type Issue struct { DocumentIndex int `json:"documentIndex"` // Message is a human-readable explanation. It is not a stable string. Message string `json:"message"` - // Solvability is empty when the check did not classify itself. - Solvability Solvability `json:"solvability,omitempty"` - // Actor is empty unless Solvability is [SolvabilityYes]. + // Solvable says whether anyone can make this folder acceptable with this release. It + // is set by the check that raised the issue, because only that check knows: one code + // answers differently depending on which branch emitted it, which is why this is a + // field on the emitted value rather than a table beside the constants. + // + // It describes THIS RELEASE and promises nothing about the future, so read it on every + // scan rather than caching a mapping from it. + Solvable bool `json:"solvable"` + // Actor names who can solve it. Empty unless Solvable — a code alone cannot tell "fix + // your YAML" from "this folder cannot be adopted", and rendering an out-of-scope + // refusal as "fix your repository" to someone who cannot is what this half prevents. Actor Actor `json:"actor,omitempty"` } @@ -211,12 +192,24 @@ func ScanFolderFS(ctx context.Context, fsys fs.FS) FolderReport { // WriteJSON writes the report as indented JSON — byte-for-byte what // `manifest-analyzer --mode scan-folder --format json` prints. func (r FolderReport) WriteJSON(w io.Writer) error { + return writeJSON(w, r.withNonNilIssues()) +} + +// WriteYAML writes the report as YAML — byte-for-byte what +// `manifest-analyzer --mode scan-folder --format yaml` prints. The report is a KRM +// document, so this is the serialization it reads best in, and the one a human can commit +// beside the manifests it describes. +func (r FolderReport) WriteYAML(w io.Writer) error { + return writeYAML(w, r.withNonNilIssues()) +} + +// withNonNilIssues returns a copy whose Issues marshals as [] rather than null, so a +// consumer can iterate it unconditionally. +func (r FolderReport) withNonNilIssues() FolderReport { if r.Status.Issues == nil { r.Status.Issues = []Issue{} } - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - return enc.Encode(r) + return r } // folderScanPolicy is the structure-only adoption gate: the default allowlist, no @@ -248,7 +241,7 @@ func folderReportFrom(acc internalanalyzer.Acceptance) FolderReport { Path: issue.Path, DocumentIndex: issue.DocumentIndex, Message: issue.Message, - Solvability: Solvability(issue.Solvability), + Solvable: issue.Solvable, Actor: Actor(issue.Actor), }) } diff --git a/pkg/manifestanalyzer/golden_test.go b/pkg/manifestanalyzer/golden_test.go new file mode 100644 index 00000000..b417753e --- /dev/null +++ b/pkg/manifestanalyzer/golden_test.go @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: Apache-2.0 + +package manifestanalyzer_test + +import ( + "bytes" + "context" + "os" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/require" + "sigs.k8s.io/yaml" + + "github.com/ConfigButler/gitops-reverser/pkg/manifestanalyzer" +) + +// A whole published document, checked in. Everything else in this package asserts one +// field at a time, which cannot answer the question a consumer actually asks — "what does +// the thing I am about to parse look like?" — and cannot show a reviewer that a change to +// the envelope changed the envelope. +// +// The goldens are YAML because the report is a KRM document: the same bytes `--format +// yaml` prints, in the serialization a human reads and a diff explains. Regenerate with +// UPDATE_GOLDEN=1 and read the diff; that diff IS the contract change. +// +// generator.version is "dev" here and always will be: a test binary carries no ldflags and +// the toolchain records "(devel)" for a module built from its own checkout, which [Version] +// deliberately rejects. So the goldens are stable without any normalization. + +// TestRepoReport_Golden pins the document `manifest-analyzer --mode scan-repo --format +// yaml` prints, over a corpus fixture whose folder is refused for a reason its author can +// solve. +func TestRepoReport_Golden(t *testing.T) { + t.Parallel() + + root := fixture(t, "unsupported", "plain-nonkrm") + report, err := manifestanalyzer.ScanRepo(context.Background(), root) + require.NoError(t, err) + + var buf bytes.Buffer + require.NoError(t, report.WriteYAML(&buf)) + assertGolden(t, "testdata/repo-report.golden.yaml", buf.Bytes()) +} + +// TestFolderReport_Golden pins the document `manifest-analyzer --mode scan-folder --format +// yaml` prints. The tree is deliberately refused two ways at once, so the golden shows both +// answers the contract can carry: a stray file its author can remove, and a kustomization +// nobody can make adoptable. +func TestFolderReport_Golden(t *testing.T) { + t.Parallel() + + report := manifestanalyzer.ScanFolderFS(context.Background(), fstest.MapFS{ + "kustomization.yaml": {Data: []byte(kustomizationHelmYAML)}, + "configmap.yaml": {Data: []byte(configMapYAML)}, + "notes.txt": {Data: []byte("scratch\n")}, + }) + + var buf bytes.Buffer + require.NoError(t, report.WriteYAML(&buf)) + assertGolden(t, "testdata/folder-report.golden.yaml", buf.Bytes()) +} + +// TestWriteYAML_IsTheSameDocumentAsWriteJSON keeps the two serializations from drifting: a +// consumer reading YAML and one reading JSON must be handed the same document, or the +// golden below documents a shape nobody receives. +func TestWriteYAML_IsTheSameDocumentAsWriteJSON(t *testing.T) { + t.Parallel() + + report, err := manifestanalyzer.ScanRepo(context.Background(), fixture(t, "unsupported", "plain-nonkrm")) + require.NoError(t, err) + + var asJSON, asYAML bytes.Buffer + require.NoError(t, report.WriteJSON(&asJSON)) + require.NoError(t, report.WriteYAML(&asYAML)) + + var fromJSON, fromYAML manifestanalyzer.RepoReport + require.NoError(t, yamlUnmarshal(asJSON.Bytes(), &fromJSON)) + require.NoError(t, yamlUnmarshal(asYAML.Bytes(), &fromYAML)) + require.Equal(t, fromJSON, fromYAML) + require.Equal(t, report, fromYAML, "the YAML form must round-trip back to the report that produced it") +} + +// assertGolden compares got against the golden file, rewriting it when UPDATE_GOLDEN=1. +func assertGolden(t *testing.T, path string, got []byte) { + t.Helper() + if os.Getenv("UPDATE_GOLDEN") == "1" { + require.NoError(t, os.WriteFile(path, got, 0o600)) + return + } + want, err := os.ReadFile(path) + require.NoError(t, err, "read %s (run UPDATE_GOLDEN=1 to create)", path) + require.Equal(t, string(want), string(got), + "the published document changed. If that is intended, run UPDATE_GOLDEN=1 and review the diff — "+ + "it is a change to what every consumer parses.") +} + +// yamlUnmarshal decodes either serialization: sigs.k8s.io/yaml routes YAML through the +// JSON tags, so one decoder reads both forms. +func yamlUnmarshal(data []byte, into any) error { + return yaml.Unmarshal(data, into) +} diff --git a/pkg/manifestanalyzer/repo.go b/pkg/manifestanalyzer/repo.go index 2bdf7225..6a577983 100644 --- a/pkg/manifestanalyzer/repo.go +++ b/pkg/manifestanalyzer/repo.go @@ -4,7 +4,6 @@ package manifestanalyzer import ( "context" - "encoding/json" "io" internalanalyzer "github.com/ConfigButler/gitops-reverser/internal/manifestanalyzer" @@ -59,10 +58,11 @@ type RefusalReason struct { Code IssueKind `json:"code"` // Detail is human-readable and not a stable string. Detail string `json:"detail"` - // Solvability says whether this refusal can ever stop being one. Empty when the check - // that raised it did not classify itself; treat that as "say nothing". - Solvability Solvability `json:"solvability,omitempty"` - // Actor is empty unless Solvability is [SolvabilityYes]. + // Solvable says whether anyone can make this candidate acceptable with this release. + // A report produced before this field shipped carries no `solvable` key at all; read + // that as "nobody said", not as false. + Solvable bool `json:"solvable"` + // Actor names who can solve it. Empty unless Solvable. Actor Actor `json:"actor,omitempty"` } @@ -157,12 +157,24 @@ func ScanRepo(ctx context.Context, root string) (RepoReport, error) { // WriteJSON writes the report as indented JSON — byte-for-byte what // `manifest-analyzer --mode scan-repo --format json` prints. func (r RepoReport) WriteJSON(w io.Writer) error { + return writeJSON(w, r.withNonNilCandidates()) +} + +// WriteYAML writes the report as YAML — byte-for-byte what +// `manifest-analyzer --mode scan-repo --format yaml` prints. The report is a KRM document, +// so this is the serialization it reads best in, and the one a human can commit beside the +// manifests it describes. +func (r RepoReport) WriteYAML(w io.Writer) error { + return writeYAML(w, r.withNonNilCandidates()) +} + +// withNonNilCandidates returns a copy whose Candidates marshals as [] rather than null, so +// a consumer can iterate it unconditionally. +func (r RepoReport) withNonNilCandidates() RepoReport { if r.Status.Candidates == nil { r.Status.Candidates = []Candidate{} } - enc := json.NewEncoder(w) - enc.SetIndent("", " ") - return enc.Encode(r) + return r } // repoReportFrom projects the internal discovery report onto the public contract. @@ -213,10 +225,10 @@ func candidateFrom(cand internalanalyzer.RepoCandidate) Candidate { } for _, reason := range cand.RefusalReasons { out.RefusalReasons = append(out.RefusalReasons, RefusalReason{ - Code: IssueKind(reason.Code), - Detail: reason.Detail, - Solvability: Solvability(reason.Solvability), - Actor: Actor(reason.Actor), + Code: IssueKind(reason.Code), + Detail: reason.Detail, + Solvable: reason.Solvable, + Actor: Actor(reason.Actor), }) } return out diff --git a/pkg/manifestanalyzer/testdata/folder-report.golden.yaml b/pkg/manifestanalyzer/testdata/folder-report.golden.yaml new file mode 100644 index 00000000..98c50cf7 --- /dev/null +++ b/pkg/manifestanalyzer/testdata/folder-report.golden.yaml @@ -0,0 +1,31 @@ +apiVersion: manifestanalyzer.configbutler.ai/v1alpha1 +kind: FolderReport +spec: + mode: scan-folder +status: + accepted: false + generator: + name: manifest-analyzer + version: dev + issues: + - documentIndex: 0 + kind: unsupported-kustomize + message: kustomization kustomization.yaml uses an unsupported feature (generators/components/helm/replacements/transformers/namePrefix/nameSuffix/remote + bases, or a patch that is not a strategic-merge document named by path), declares + malformed images/replicas overrides, or is a render root kustomize cannot build; + the operator cannot map it back to editable source documents and will not write + into this folder (a kustomize-build-failed diagnostic on this path carries the + build error) + path: kustomization.yaml + solvable: false + - actor: repository-author + documentIndex: 0 + kind: foreign-file + message: foreign file notes.txt is not a managed manifest; remove it or name it + in .gittargetignore + path: notes.txt + solvable: true + retained: + - documentIndex: 0 + path: kustomization.yaml + unsupported: true diff --git a/pkg/manifestanalyzer/testdata/repo-report.golden.yaml b/pkg/manifestanalyzer/testdata/repo-report.golden.yaml new file mode 100644 index 00000000..6ce5f72a --- /dev/null +++ b/pkg/manifestanalyzer/testdata/repo-report.golden.yaml @@ -0,0 +1,29 @@ +apiVersion: manifestanalyzer.configbutler.ai/v1alpha1 +kind: RepoReport +spec: + mode: scan-repo + root: ../../internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm +status: + candidates: + - acceptedByOperator: false + inferredNamespace: demo + layout: plain + path: app + refusalReasons: + - actor: repository-author + code: non-krm-yaml + detail: 'values.yaml: YAML is not a Kubernetes manifest' + solvable: true + renderRoot: false + resources: + editable: 1 + nonKrm: 1 + rendered: 1 + generator: + name: manifest-analyzer + version: dev + summary: + accepted: 0 + candidatesByLayout: + plain: 1 + refused: 1 diff --git a/test/fixtures/gitops-layouts/support-today.md b/test/fixtures/gitops-layouts/support-today.md index 00358460..9791f4ae 100644 --- a/test/fixtures/gitops-layouts/support-today.md +++ b/test/fixtures/gitops-layouts/support-today.md @@ -19,33 +19,33 @@ Reading rules: input set that lives in a Git-host API. - **A missing candidate matters as much as a refusal**: it means the tool did not explain that part of the repository at all. -- Each refusal reason carries in brackets whether it can be solved: `yes` or `no`. A - refusal that changes that answer has changed what a user is told to do about it, which - is a boundary move like any other and belongs in this diff. +- Each refusal reason carries in brackets whether anyone can solve it. A refusal that + changes that answer has changed what a user is told to do about it, which is a boundary + move like any other and belongs in this diff. ## Summary | Fixture | rc | Outcome | Accepted | Refused | Layouts | Unsupported constructs | Reported refusal signal | |---|---:|---|---:|---:|---|---|---| | 1-desired-state/argocd-app-of-apps | 0 | All reported candidates accepted | 4 | 0 | plain=4 | - | None | -| 1-desired-state/argocd-plain | 0 | Partial | 1 | 1 | plain=2 | - | non-krm-yaml [yes]: ci-metadata.yaml: YAML is not a Kubernetes manifest | +| 1-desired-state/argocd-plain | 0 | Partial | 1 | 1 | plain=2 | - | non-krm-yaml [solvable]: ci-metadata.yaml: YAML is not a Kubernetes manifest | | 1-desired-state/flux-monorepo | 0 | All reported candidates accepted | 6 | 0 | kustomize-overlay=2, kustomize-single=4 | - | None | | 1-desired-state/repo-per-environment | 0 | All reported candidates accepted | 9 | 0 | plain=9 | - | None | | 2-rendered/argocd-external-helm | 0 | All reported candidates accepted | 3 | 0 | plain=3 | - | None | | 2-rendered/helm-chart | 0 | All reported candidates accepted | 1 | 0 | plain=1 | - | None | | 2-rendered/helm-environment-values | 0 | All reported candidates accepted | 1 | 0 | plain=1 | - | None | | 2-rendered/kustomize-overlay-minimal | 0 | All reported candidates accepted | 2 | 0 | kustomize-overlay=2 | - | None | -| 2-rendered/kustomize-overlays | 0 | Partial | 1 | 3 | kustomize-single=1, refused-structural=3 | configMapGenerator, namePrefix, nameSuffix, remote-base, secretGenerator | refused-structural [no]: kustomization uses unsupported feature(s): remote-base
refused-structural [no]: kustomization uses unsupported feature(s): configMapGenerator, nameSuffix, secretGenerator
refused-structural [no]: kustomization uses unsupported feature(s): configMapGenerator, namePrefix | -| 2-rendered/rendered-manifests | 0 | Partial | 3 | 2 | plain=3, refused-structural=2 | namePrefix | refused-structural [no]: kustomization uses unsupported feature(s): namePrefix
refused-structural [no]: kustomization uses unsupported feature(s): namePrefix | +| 2-rendered/kustomize-overlays | 0 | Partial | 1 | 3 | kustomize-single=1, refused-structural=3 | configMapGenerator, namePrefix, nameSuffix, remote-base, secretGenerator | refused-structural [not solvable]: kustomization uses unsupported feature(s): remote-base
refused-structural [not solvable]: kustomization uses unsupported feature(s): configMapGenerator, nameSuffix, secretGenerator
refused-structural [not solvable]: kustomization uses unsupported feature(s): configMapGenerator, namePrefix | +| 2-rendered/rendered-manifests | 0 | Partial | 3 | 2 | plain=3, refused-structural=2 | namePrefix | refused-structural [not solvable]: kustomization uses unsupported feature(s): namePrefix
refused-structural [not solvable]: kustomization uses unsupported feature(s): namePrefix | | 3-expanded/argocd-applicationset-directories | 0 | All reported candidates accepted | 5 | 0 | plain=5 | - | None | -| 3-expanded/argocd-applicationset-files | 0 | No reported candidates accepted | 0 | 1 | plain=1 | - | non-krm-yaml [yes]: chart/Chart.yaml: YAML is not a Kubernetes manifest
foreign-file [yes]: chart/templates/_helpers.tpl: foreign file chart/templates/_helpers.tpl is not a managed manifest; remove it or name it in .gittargetignore
non-krm-yaml [yes]: chart/templates/deployment.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [yes]: chart/templates/service.yaml: YAML is not a Kubernetes manifest
+5 more | +| 3-expanded/argocd-applicationset-files | 0 | No reported candidates accepted | 0 | 1 | plain=1 | - | non-krm-yaml [solvable]: chart/Chart.yaml: YAML is not a Kubernetes manifest
foreign-file [solvable]: chart/templates/_helpers.tpl: foreign file chart/templates/_helpers.tpl is not a managed manifest; remove it or name it in .gittargetignore
non-krm-yaml [solvable]: chart/templates/deployment.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [solvable]: chart/templates/service.yaml: YAML is not a Kubernetes manifest
+5 more | | 3-expanded/argocd-multicluster-matrix | 0 | All reported candidates accepted | 4 | 0 | plain=4 | - | None | -| 3-expanded/flux-helmrelease | 0 | Partial | 3 | 1 | kustomize-single=3, refused-structural=1 | configMapGenerator | refused-structural [no]: kustomization uses unsupported feature(s): configMapGenerator | +| 3-expanded/flux-helmrelease | 0 | Partial | 3 | 1 | kustomize-single=3, refused-structural=1 | configMapGenerator | refused-structural [not solvable]: kustomization uses unsupported feature(s): configMapGenerator | | 3-expanded/flux-resourceset-inline | 0 | All reported candidates accepted | 1 | 0 | plain=1 | - | None | | 3-expanded/flux-resourceset-pull-requests | 0 | All reported candidates accepted | 1 | 0 | plain=1 | - | None | | 4-machine-written/flux-image-automation | 0 | All reported candidates accepted | 3 | 0 | kustomize-single=3 | - | None | | 5-opaque/sops-encrypted | 0 | All reported candidates accepted | 3 | 0 | kustomize-single=2, plain=1 | - | None | -| 6-hostile/mixed-and-hostile | 0 | Partial | 3 | 2 | plain=4, refused-structural=1 | unparseable | refused-structural [yes]: kustomization uses unsupported feature(s): unparseable (invalid Kustomization: json: unknown field "spec")
impure-managed-file [yes]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #1 is a non-KRM document
impure-managed-file [yes]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #2 is an empty document
foreign-file [yes]: deployment.json: foreign file deployment.json is not a managed manifest; remove it or name it in .gittargetignore | +| 6-hostile/mixed-and-hostile | 0 | Partial | 3 | 2 | plain=4, refused-structural=1 | unparseable | refused-structural [solvable]: kustomization uses unsupported feature(s): unparseable (invalid Kustomization: json: unknown field "spec")
impure-managed-file [solvable]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #1 is a non-KRM document
impure-managed-file [solvable]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #2 is an empty document
foreign-file [solvable]: deployment.json: foreign file deployment.json is not a managed manifest; remove it or name it in .gittargetignore | ## 1-desired-state/argocd-app-of-apps @@ -66,7 +66,7 @@ Unsupported constructs: `none`. Fleet root: `false`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| -| `apps/frontend` | `plain` | false | `frontend` | 6/6/1 | non-krm-yaml [yes]: ci-metadata.yaml: YAML is not a Kubernetes manifest | +| `apps/frontend` | `plain` | false | `frontend` | 6/6/1 | non-krm-yaml [solvable]: ci-metadata.yaml: YAML is not a Kubernetes manifest | | `argocd` | `plain` | true | `argocd` | 1/1/0 | none | ## 1-desired-state/flux-monorepo @@ -147,9 +147,9 @@ Unsupported constructs: `configMapGenerator, namePrefix, nameSuffix, remote-base | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| | `apps/backend/base` | `kustomize-single` | true | `-` | 2/2/0 | none | -| `apps/backend/overlays/production` | `refused-structural` | false | `backend-production` | 0/0/0 | refused-structural [no]: kustomization uses unsupported feature(s): remote-base | -| `apps/frontend/overlays/production` | `refused-structural` | false | `frontend-production` | 2/0/3 | refused-structural [no]: kustomization uses unsupported feature(s): configMapGenerator, nameSuffix, secretGenerator | -| `apps/frontend/overlays/staging` | `refused-structural` | false | `frontend-staging` | 2/0/1 | refused-structural [no]: kustomization uses unsupported feature(s): configMapGenerator, namePrefix | +| `apps/backend/overlays/production` | `refused-structural` | false | `backend-production` | 0/0/0 | refused-structural [not solvable]: kustomization uses unsupported feature(s): remote-base | +| `apps/frontend/overlays/production` | `refused-structural` | false | `frontend-production` | 2/0/3 | refused-structural [not solvable]: kustomization uses unsupported feature(s): configMapGenerator, nameSuffix, secretGenerator | +| `apps/frontend/overlays/staging` | `refused-structural` | false | `frontend-staging` | 2/0/1 | refused-structural [not solvable]: kustomization uses unsupported feature(s): configMapGenerator, namePrefix | ## 2-rendered/rendered-manifests @@ -161,8 +161,8 @@ Unsupported constructs: `namePrefix`. Fleet root: `false`. | `argocd` | `plain` | true | `argocd` | 1/1/0 | none | | `rendered/production` | `plain` | true | `frontend-production` | 3/3/0 | none | | `rendered/staging` | `plain` | true | `frontend-staging` | 3/3/0 | none | -| `src/frontend/overlays/production` | `refused-structural` | false | `frontend-production` | 2/0/0 | refused-structural [no]: kustomization uses unsupported feature(s): namePrefix | -| `src/frontend/overlays/staging` | `refused-structural` | false | `frontend-staging` | 2/0/0 | refused-structural [no]: kustomization uses unsupported feature(s): namePrefix | +| `src/frontend/overlays/production` | `refused-structural` | false | `frontend-production` | 2/0/0 | refused-structural [not solvable]: kustomization uses unsupported feature(s): namePrefix | +| `src/frontend/overlays/staging` | `refused-structural` | false | `frontend-staging` | 2/0/0 | refused-structural [not solvable]: kustomization uses unsupported feature(s): namePrefix | ## 3-expanded/argocd-applicationset-directories @@ -184,7 +184,7 @@ Unsupported constructs: `none`. Fleet root: `false`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| -| `.` | `plain` | false | `argocd` | 1/1/9 | non-krm-yaml [yes]: chart/Chart.yaml: YAML is not a Kubernetes manifest
foreign-file [yes]: chart/templates/_helpers.tpl: foreign file chart/templates/_helpers.tpl is not a managed manifest; remove it or name it in .gittargetignore
non-krm-yaml [yes]: chart/templates/deployment.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [yes]: chart/templates/service.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [yes]: chart/values.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [yes]: deployments/dev/backend.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [yes]: deployments/dev/frontend.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [yes]: deployments/production/backend.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [yes]: deployments/production/frontend.yaml: YAML is not a Kubernetes manifest | +| `.` | `plain` | false | `argocd` | 1/1/9 | non-krm-yaml [solvable]: chart/Chart.yaml: YAML is not a Kubernetes manifest
foreign-file [solvable]: chart/templates/_helpers.tpl: foreign file chart/templates/_helpers.tpl is not a managed manifest; remove it or name it in .gittargetignore
non-krm-yaml [solvable]: chart/templates/deployment.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [solvable]: chart/templates/service.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [solvable]: chart/values.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [solvable]: deployments/dev/backend.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [solvable]: deployments/dev/frontend.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [solvable]: deployments/production/backend.yaml: YAML is not a Kubernetes manifest
non-krm-yaml [solvable]: deployments/production/frontend.yaml: YAML is not a Kubernetes manifest | ## 3-expanded/argocd-multicluster-matrix @@ -205,7 +205,7 @@ Unsupported constructs: `configMapGenerator`. Fleet root: `false`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| -| `apps/frontend` | `refused-structural` | false | `-` | 1/1/1 | refused-structural [no]: kustomization uses unsupported feature(s): configMapGenerator | +| `apps/frontend` | `refused-structural` | false | `-` | 1/1/1 | refused-structural [not solvable]: kustomization uses unsupported feature(s): configMapGenerator | | `clusters/production` | `kustomize-single` | true | `flux-system` | 7/7/0 | none | | `infrastructure/controllers/ingress-nginx` | `kustomize-single` | true | `flux-system` | 3/3/0 | none | | `infrastructure/sources` | `kustomize-single` | true | `flux-system` | 3/3/0 | none | @@ -257,8 +257,8 @@ Unsupported constructs: `unparseable`. Fleet root: `false`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| -| `.` | `refused-structural` | false | `backend` | 0/0/5 | refused-structural [yes]: kustomization uses unsupported feature(s): unparseable (invalid Kustomization: json: unknown field "spec") | +| `.` | `refused-structural` | false | `backend` | 0/0/5 | refused-structural [solvable]: kustomization uses unsupported feature(s): unparseable (invalid Kustomization: json: unknown field "spec") | | `crossplane` | `plain` | true | `-` | 1/1/0 | none | | `kro` | `plain` | true | `-` | 1/1/0 | none | -| `mixed` | `plain` | false | `-` | 3/3/2 | impure-managed-file [yes]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #1 is a non-KRM document
impure-managed-file [yes]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #2 is an empty document
foreign-file [yes]: deployment.json: foreign file deployment.json is not a managed manifest; remove it or name it in .gittargetignore | +| `mixed` | `plain` | false | `-` | 3/3/2 | impure-managed-file [solvable]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #1 is a non-KRM document
impure-managed-file [solvable]: bundle.yaml: a file with managed resources may contain only valid KRM documents; document #2 is an empty document
foreign-file [solvable]: deployment.json: foreign file deployment.json is not a managed manifest; remove it or name it in .gittargetignore | | `secrets` | `plain` | true | `backend` | 1/1/0 | none | From 865b24126706bc5914fc36813b4f178558c0f183 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 27 Jul 2026 11:37:12 +0000 Subject: [PATCH 08/15] feat(manifestanalyzer): a candidate says what is in it, not just how much MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tool provisioning from a folder scan has to settle two things before it acts, and both gate a step that is not cheap to undo. A type the destination does not serve fails the forward apply with "no matches for kind" and then waits for the next resync rather than degrading — so the schema has to be installed before the folder is picked. And replacing a targetNamespace override means naming the exact namespace set, where inferredNamespace is one name and a folder can render into several. Candidate gains Kinds and Namespaces, both as the folder RENDERS. That is the part only this engine can answer: a consumer can scan apiVersion/kind headers itself, but the moment a layout transform is involved the two answers diverge silently, in the direction of provisioning something the writer then refuses. An overlay renders a base it does not contain, into a namespace its own files never mention; kustomize's namespace transformer OVERRIDES metadata.namespace, so the folder that says one namespace lands in another. It costs no new render. The scan already builds every render root to derive the override chains, so the inventory is a projection of objects that are already in hand — kustomize's own output, not our reading of the files. A root that fails to build reports neither set: we cannot know what it renders, and saying nothing is better than guessing. A cluster-scoped object contributes no namespace rather than an empty string, which would read as a namespace named "". Additive: a consumer that does not read the fields is unaffected. Co-Authored-By: Claude Opus 5 --- .../repo-discovery-and-onboarding-scan.md | 22 ++- .../kustomize_render_hostile_test.go | 2 +- .../manifestanalyzer/kustomize_render_test.go | 2 +- internal/manifestanalyzer/override_chain.go | 61 ++++++- .../overrides_projection_test.go | 2 +- internal/manifestanalyzer/overrides_test.go | 2 +- .../manifestanalyzer/render_inventory_test.go | 152 ++++++++++++++++++ internal/manifestanalyzer/scan_repo.go | 56 +++++++ internal/manifestanalyzer/source_form_test.go | 2 +- internal/manifestanalyzer/store.go | 12 +- .../argocd-external-helm-values.golden.json | 17 +- .../supported/base-overlays.golden.json | 51 +++++- .../supported/fleet-root.golden.json | 36 ++++- .../flux-helmrelease-values.golden.json | 12 +- .../helm-release-document.golden.json | 12 +- .../supported/kustomize-single.golden.json | 17 +- .../supported/overlapping.golden.json | 25 +++ .../supported/overlay-nested-base.golden.json | 17 +- .../supported/overlay-parked-base.golden.json | 12 +- .../supported/plain-per-env.golden.json | 34 +++- .../unsupported/plain-nonkrm.golden.json | 12 +- pkg/manifestanalyzer/repo.go | 30 ++++ pkg/manifestanalyzer/repo_test.go | 30 ++++ .../testdata/repo-report.golden.yaml | 6 + 24 files changed, 599 insertions(+), 25 deletions(-) create mode 100644 internal/manifestanalyzer/render_inventory_test.go diff --git a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md index 2ea92210..c8dc19e4 100644 --- a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md +++ b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md @@ -262,8 +262,26 @@ free to move. in `spec` (`root`, `mode`) and everything found in `status` (`generator`, `candidates`, `summary`). The candidate blocks below live under `status.candidates`. Per candidate: `path`, `layout`, `acceptedByOperator`, `refusalReasons[]` (`{code, detail, solvable, actor}`), -`renderRoot`, `readScope[]`, `inferredNamespace`, `resources`, `overlapsWith[]`. - +`renderRoot`, `readScope[]`, `inferredNamespace`, `resources`, `kinds[]`, `namespaces[]`, +`overlapsWith[]`. + +- **`kinds[]` and `namespaces[]` say what is IN a candidate, not just how much.** Both are + what the folder RENDERS, which is the part only this engine can answer: for a render root + they are read off the real kustomize build the scan already runs, so a base outside the + subtree is included and a `namespace:` transformer is already applied; for a plain folder + the documents are the render. A consumer can scan `apiVersion`/`kind` headers itself, but + the moment a layout transform is involved the two answers diverge silently — and in the + direction of provisioning something the writer then refuses. + + They gate steps that are not cheap to undo. A type the destination does not serve fails + the forward apply with `no matches for kind` and waits for the next resync rather than + degrading, so the schema has to be installed before the folder is picked. And naming a + GitTarget's allowed source namespaces, or one WatchRule rule per (type, namespace), needs + the whole set — `inferredNamespace` is one name, and a folder can render into several. + + A cluster-scoped object contributes no namespace rather than an empty one. A render root + kustomize cannot build reports neither: we do not know what it renders, and saying so is + better than guessing. - **`solvable` says whether anyone can make the candidate acceptable with this release**, and `actor` (`repository-author`, `platform-operator`) names who. It is decided by the check that raised the refusal, because the same code answers differently depending on the diff --git a/internal/manifestanalyzer/kustomize_render_hostile_test.go b/internal/manifestanalyzer/kustomize_render_hostile_test.go index a3a5ca92..5c23a3b9 100644 --- a/internal/manifestanalyzer/kustomize_render_hostile_test.go +++ b/internal/manifestanalyzer/kustomize_render_hostile_test.go @@ -123,7 +123,7 @@ func TestRenderChains_CycleIsBuiltAndRefused(t *testing.T) { kusts := parseKustomizations(files) require.Empty(t, renderRoots(kusts), "a cycle has no render root; that is the whole problem") - chains, failed := renderChains(files, kusts) + chains, _, failed := renderChains(files, kusts) require.Empty(t, chains) require.Len(t, failed, 1, "the cycle must produce exactly one refusal, not one per member") diff --git a/internal/manifestanalyzer/kustomize_render_test.go b/internal/manifestanalyzer/kustomize_render_test.go index 599a48ca..502f45a0 100644 --- a/internal/manifestanalyzer/kustomize_render_test.go +++ b/internal/manifestanalyzer/kustomize_render_test.go @@ -51,7 +51,7 @@ func TestProjection_InSyncCorpusFolderIsANoOp(t *testing.T) { skipped++ t.Skipf("not renderable, and refused by the acceptance gate: %v", err) } - chains, _ := renderChains(root.files, parseKustomizations(root.files)) + chains, _, _ := renderChains(root.files, parseKustomizations(root.files)) for _, ro := range rendered { if ro.OriginPath == "" { diff --git a/internal/manifestanalyzer/override_chain.go b/internal/manifestanalyzer/override_chain.go index 03d4aef7..a0dccb16 100644 --- a/internal/manifestanalyzer/override_chain.go +++ b/internal/manifestanalyzer/override_chain.go @@ -6,6 +6,8 @@ import ( "sort" "strings" + "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit" ) @@ -130,6 +132,12 @@ func markReachable(kusts map[string]*kustomizationDoc, dir string, covered map[s // root reaches the same document with DIFFERENT chains, which is the fan-in > 1 case // we refuse to route through. // +// It also returns what each root RENDERS TO — the distinct types and namespaces of the +// objects kustomize actually produced — because that is a question only a real build can +// answer and this is the one place a build already happens. A `namespace:` transformer +// moves every object it covers, so the types and namespaces a folder declares in its files +// and the ones it renders to are different sets whenever a layout transform is involved. +// // It also returns, per kustomization path, the roots that FAILED to build. Those // must refuse the folder, and it is important to see why a silent skip would be // unsafe rather than merely unhelpful: a root that does not build yields no chain, @@ -142,8 +150,9 @@ func markReachable(kusts map[string]*kustomizationDoc, dir string, covered map[s func renderChains( files []manifestedit.FileContent, kusts map[string]*kustomizationDoc, -) (map[chainKey]*overrideAssignment, map[string]string) { +) (map[chainKey]*overrideAssignment, map[string]RenderInventory, map[string]string) { out := map[chainKey]*overrideAssignment{} + inventory := map[string]RenderInventory{} failed := map[string]string{} // One dye plan for the whole scan, so the nonces are stable across roots and a base @@ -164,6 +173,7 @@ func renderChains( // answer is then no attribution — never a fallback to a second guess. dyed, dyeErr := renderRootWith(files, rootDir, plan.replace) attribution := attributeRoot(rendered, dyed, dyeErr, plan) + inventory[rootDir] = inventoryOf(rendered) for _, ro := range rendered { if ro.OriginPath == "" { @@ -177,7 +187,54 @@ func renderChains( record(out, key, chainOf(ro, kusts), attribution[key]) } } - return out, failed + return out, inventory, failed +} + +// inventoryOf reduces one root's rendered objects to the two distinct sets a tool needs +// before it provisions from a folder: which types must already be served where this is +// applied, and which namespaces the objects land in. +// +// A cluster-scoped object contributes no namespace rather than an empty one — there is no +// namespace to name, and an empty string in the set would read as one. +func inventoryOf(rendered []renderedObject) RenderInventory { + kinds := map[schema.GroupVersionKind]struct{}{} + namespaces := map[string]struct{}{} + for _, ro := range rendered { + kinds[ro.Object.GroupVersionKind()] = struct{}{} + if ns := ro.Object.GetNamespace(); ns != "" { + namespaces[ns] = struct{}{} + } + } + return RenderInventory{Kinds: sortedGVKs(kinds), Namespaces: sortedKeysOfSet(namespaces)} +} + +// sortedGVKs renders a GVK set in a stable order, so a report never churns on map +// iteration. +func sortedGVKs(set map[schema.GroupVersionKind]struct{}) []GVK { + out := make([]GVK, 0, len(set)) + for gvk := range set { + out = append(out, GVK{Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Group != out[j].Group { + return out[i].Group < out[j].Group + } + if out[i].Version != out[j].Version { + return out[i].Version < out[j].Version + } + return out[i].Kind < out[j].Kind + }) + return out +} + +// sortedKeysOfSet returns a set's members in sorted order. +func sortedKeysOfSet(set map[string]struct{}) []string { + out := make([]string, 0, len(set)) + for k := range set { + out = append(out, k) + } + sort.Strings(out) + return out } // chainOf reads the override chain kustomize applied to one object: the images: and diff --git a/internal/manifestanalyzer/overrides_projection_test.go b/internal/manifestanalyzer/overrides_projection_test.go index a1971545..d0f9e466 100644 --- a/internal/manifestanalyzer/overrides_projection_test.go +++ b/internal/manifestanalyzer/overrides_projection_test.go @@ -35,7 +35,7 @@ func splitFixture( t.Helper() desired := &unstructured.Unstructured{Object: live} - assignments, failed := renderChains(files, parseKustomizations(files)) + assignments, _, failed := renderChains(files, parseKustomizations(files)) require.Empty(t, failed, "the fixture must be a folder kustomize can build") assignment := assignments[chainKey{ diff --git a/internal/manifestanalyzer/overrides_test.go b/internal/manifestanalyzer/overrides_test.go index 3726c3c8..ec5278ad 100644 --- a/internal/manifestanalyzer/overrides_test.go +++ b/internal/manifestanalyzer/overrides_test.go @@ -276,7 +276,7 @@ spec: {Path: "kustomization.yaml", Content: []byte("resources:\n - deployment.yaml\n")}, } - assignments, failed := renderChains(files, parseKustomizations(files)) + assignments, _, failed := renderChains(files, parseKustomizations(files)) require.Empty(t, failed) a := assignments[chainKey{originPath: "deployment.yaml", kind: "Deployment", name: "web"}] diff --git a/internal/manifestanalyzer/render_inventory_test.go b/internal/manifestanalyzer/render_inventory_test.go new file mode 100644 index 00000000..f20ea818 --- /dev/null +++ b/internal/manifestanalyzer/render_inventory_test.go @@ -0,0 +1,152 @@ +// SPDX-License-Identifier: Apache-2.0 + +package manifestanalyzer + +import ( + "context" + "testing" + "testing/fstest" +) + +// A candidate reports the types and namespaces it RENDERS, which is the part only this +// engine can answer. A consumer can scan apiVersion/kind headers themselves — and will, +// meanwhile — but the moment a layout transform is involved, their answer and ours diverge +// silently, in the direction of provisioning something the writer then refuses. +// +// These tests pin the divergence rather than the agreement, because the agreement is the +// easy case. + +const inventoryDeployment = `apiVersion: apps/v1 +kind: Deployment +metadata: + name: web + namespace: written-in-the-file +spec: + replicas: 1 +` + +const inventoryClusterRole = `apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: viewer +rules: [] +` + +// TestRenderInventory_NamespaceIsWhatRendersNotWhatIsWritten is the case the ask exists +// for: kustomize's namespace transformer OVERRIDES metadata.namespace, so a folder whose +// files say one namespace lands in another. A consumer reading the file headers would +// provision the wrong namespace and only find out when the writer refused. +func TestRenderInventory_NamespaceIsWhatRendersNotWhatIsWritten(t *testing.T) { + fsys := fstest.MapFS{ + "base/deployment.yaml": {Data: []byte(inventoryDeployment)}, + "base/kustomization.yaml": {Data: []byte("apiVersion: kustomize.config.k8s.io/v1beta1\n" + + "kind: Kustomization\nresources:\n - deployment.yaml\n")}, + "overlays/prod/kustomization.yaml": {Data: []byte("apiVersion: kustomize.config.k8s.io/v1beta1\n" + + "kind: Kustomization\nnamespace: where-it-lands\nresources:\n - ../../base\n")}, + } + + cand := candidateAt(t, scanRepoFS(context.Background(), fsys), "overlays/prod") + + requireStrings(t, "namespaces", cand.Namespaces, []string{"where-it-lands"}) + if len(cand.Kinds) != 1 || cand.Kinds[0].Kind != "Deployment" || cand.Kinds[0].Group != "apps" { + t.Errorf("kinds = %+v, want the single apps/v1 Deployment the overlay renders", cand.Kinds) + } +} + +// TestRenderInventory_CoversTheBaseOutsideTheSubtree pins the other half of "as rendered": +// an overlay renders documents it does not contain, and those types still have to be +// served wherever it is applied. Counting only the overlay's own files would miss them. +func TestRenderInventory_CoversTheBaseOutsideTheSubtree(t *testing.T) { + fsys := fstest.MapFS{ + "base/deployment.yaml": {Data: []byte(inventoryDeployment)}, + "base/clusterrole.yaml": {Data: []byte(inventoryClusterRole)}, + "base/kustomization.yaml": {Data: []byte("apiVersion: kustomize.config.k8s.io/v1beta1\n" + + "kind: Kustomization\nresources:\n - deployment.yaml\n - clusterrole.yaml\n")}, + "overlays/prod/kustomization.yaml": {Data: []byte("apiVersion: kustomize.config.k8s.io/v1beta1\n" + + "kind: Kustomization\nnamespace: prod\nresources:\n - ../../base\n")}, + } + + cand := candidateAt(t, scanRepoFS(context.Background(), fsys), "overlays/prod") + + if len(cand.Kinds) != 2 { + t.Fatalf("kinds = %+v, want both types the overlay renders from the base", cand.Kinds) + } + // Sorted by group, then version, then kind — so "apps" precedes + // "rbac.authorization.k8s.io" and the order is part of what a consumer can rely on. + requireStrings(t, "kinds", kindNames(cand.Kinds), []string{"Deployment", "ClusterRole"}) + // The ClusterRole is cluster-scoped: it lands in no namespace and must contribute none, + // because an empty string in the set would read as a namespace called "". + requireStrings(t, "namespaces", cand.Namespaces, []string{"prod"}) +} + +// TestRenderInventory_PlainFolderReportsItsOwnDocuments covers the folder with no +// kustomization, where the documents ARE the render and nothing transforms them on the way +// to the cluster. +func TestRenderInventory_PlainFolderReportsItsOwnDocuments(t *testing.T) { + fsys := fstest.MapFS{ + "app/deployment.yaml": {Data: []byte(inventoryDeployment)}, + "app/clusterrole.yaml": {Data: []byte(inventoryClusterRole)}, + } + + cand := candidateAt(t, scanRepoFS(context.Background(), fsys), "app") + + requireStrings(t, "kinds", kindNames(cand.Kinds), []string{"Deployment", "ClusterRole"}) + requireStrings(t, "namespaces", cand.Namespaces, []string{"written-in-the-file"}) +} + +// TestRenderInventory_AbsentWhenTheRootDoesNotBuild says nothing rather than guessing: a +// root kustomize cannot build is one whose render nobody knows, and an empty inventory is +// the honest answer. The candidate is refused anyway. +func TestRenderInventory_AbsentWhenTheRootDoesNotBuild(t *testing.T) { + fsys := fstest.MapFS{ + "app/kustomization.yaml": {Data: []byte("apiVersion: kustomize.config.k8s.io/v1beta1\n" + + "kind: Kustomization\nresources:\n - does-not-exist.yaml\n")}, + } + + cand := candidateAt(t, scanRepoFS(context.Background(), fsys), "app") + + if cand.AcceptedByOperator { + t.Fatalf("a root that does not build must be refused, got accepted") + } + if len(cand.Kinds) != 0 || len(cand.Namespaces) != 0 { + t.Errorf("kinds = %+v, namespaces = %+v, want nothing: we cannot know what it renders", + cand.Kinds, cand.Namespaces) + } +} + +// candidateAt returns the candidate at path, failing the test when the scan did not report +// one there. +func candidateAt(t *testing.T, rep RepoReport, path string) RepoCandidate { + t.Helper() + for _, cand := range rep.Candidates { + if cand.Path == path { + return cand + } + } + t.Fatalf("no candidate at %q; got %+v", path, rep.Candidates) + return RepoCandidate{} +} + +// kindNames projects a GVK slice onto its kinds, for the assertions that care about which +// types are present rather than their full identity. +func kindNames(kinds []GVK) []string { + out := make([]string, 0, len(kinds)) + for _, k := range kinds { + out = append(out, k.Kind) + } + return out +} + +// requireStrings asserts an exact, ordered match — the sets are sorted, so order is part +// of the contract a consumer can rely on. +func requireStrings(t *testing.T, what string, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("%s = %v, want %v", what, got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("%s = %v, want %v", what, got, want) + } + } +} diff --git a/internal/manifestanalyzer/scan_repo.go b/internal/manifestanalyzer/scan_repo.go index 8274d3da..5d783205 100644 --- a/internal/manifestanalyzer/scan_repo.go +++ b/internal/manifestanalyzer/scan_repo.go @@ -12,6 +12,8 @@ import ( "sort" "strings" + "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit" ) @@ -82,6 +84,19 @@ type RefusalReason struct { Actor Actor `json:"actor,omitempty"` } +// RenderInventory is what a render root renders to, reduced to the two distinct sets a +// tool needs before it provisions from the folder. Both are read off a real kustomize +// build, so a `namespace:` transformer, a base outside the subtree, and any other layout +// transform are already applied — these are the objects that reach the cluster, not the +// ones the files happen to declare. +type RenderInventory struct { + // Kinds is every distinct type the folder renders, sorted. + Kinds []GVK + // Namespaces is every distinct namespace the folder's objects land in, sorted. A + // cluster-scoped object contributes nothing rather than an empty string. + Namespaces []string +} + // ResourceCounts splits the KRM a candidate covers into what it renders versus what it // can actually edit. For a plain or self-contained kustomize candidate the two are // equal; for an overlay they diverge — rendered counts the documents pulled from the @@ -125,6 +140,18 @@ type RepoCandidate struct { InferredNamespace string `json:"inferredNamespace,omitempty"` // Resources counts the KRM this candidate covers (rendered vs editable) plus non-KRM. Resources ResourceCounts `json:"resources"` + // Kinds is every distinct type this candidate RENDERS, sorted. For a render root it + // is read off a real kustomize build, so a base outside the subtree is included and a + // layout transform is already applied; for a plain folder it is the documents + // themselves. A tool must install these schemas before it points anything at the + // folder: a kind the destination does not serve fails the forward apply with "no + // matches for kind" rather than degrading. + Kinds []GVK `json:"kinds,omitempty"` + // Namespaces is every distinct namespace this candidate's objects LAND IN, sorted, + // which for a render root is the namespace transformer applied rather than whatever + // the files say. Cluster-scoped objects contribute nothing. It is the plural of + // InferredNamespace and the honest one: a folder can render into several. + Namespaces []string `json:"namespaces,omitempty"` // OverlapsWith lists other candidate paths this one nests with. Two overlapping // candidates can never both be proposed (one-owner-per-folder); the conflict is // reported, not resolved, in this cut. @@ -228,6 +255,11 @@ func classifyRenderRoot( store *ManifestStore, ) RepoCandidate { c := RepoCandidate{Path: rootDir, RenderRoot: true, InferredNamespace: renderRootNamespace(kusts, rootDir, store)} + // What the root actually renders to, from the build the scan already ran. A root that + // failed to build has none, which is honest: we cannot know what it renders. + if inv, ok := store.RenderedInventory[rootDir]; ok { + c.Kinds, c.Namespaces = inv.Kinds, inv.Namespaces + } // rendered/editable count only the documents the kustomization graph actually renders // (its resources: entries), never parked YAML a kustomization does not reference. rendered := reachedResourceFilesFrom(rootDir, kusts) @@ -273,6 +305,27 @@ func classifyRenderRoot( return c } +// plainFolderInventory is the render inventory of a folder with no kustomization: the +// documents ARE the render, so the types and namespaces come straight off them. Nothing +// transforms them on the way to the cluster, which is what makes a plain folder plain. +func plainFolderInventory(store *ManifestStore, dir string) ([]GVK, []string) { + kinds := map[schema.GroupVersionKind]struct{}{} + namespaces := map[string]struct{}{} + for filePath, fm := range store.FilesByPath { + if !pathWithin(filePath, dir) { + continue + } + for _, dm := range fm.Documents { + gvk := ParseGVK(dm.ManifestIdentity.APIVersion, dm.ManifestIdentity.Kind) + kinds[schema.GroupVersionKind{Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind}] = struct{}{} + if ns := dm.ManifestIdentity.Namespace; ns != "" { + namespaces[ns] = struct{}{} + } + } + } + return sortedGVKs(kinds), sortedKeysOfSet(namespaces) +} + // plainCandidates enumerates plain KRM leaf folders: directories that directly hold a // managed KRM document, carry no kustomization, and are not already owned by a // kustomization's resources graph (so a base a kustomization renders is not also @@ -302,11 +355,14 @@ func plainCandidates( out := make([]RepoCandidate, 0, len(dirs)) for dir := range dirs { acc := candidateAcceptance(ctx, fsys, dir) + kinds, namespaces := plainFolderInventory(store, dir) cand := RepoCandidate{ Path: dir, Layout: LayoutPlain, AcceptedByOperator: acc.Accepted, InferredNamespace: singleExplicitNamespace(store, dir), + Kinds: kinds, + Namespaces: namespaces, // A plain folder is applied directory-wise, so it renders its whole subtree // (renderedFiles nil); no kustomization graph scopes it. Resources: countResources(store, dir, nil), diff --git a/internal/manifestanalyzer/source_form_test.go b/internal/manifestanalyzer/source_form_test.go index 78f11009..40055367 100644 --- a/internal/manifestanalyzer/source_form_test.go +++ b/internal/manifestanalyzer/source_form_test.go @@ -199,7 +199,7 @@ spec: var gitRaw map[string]interface{} require.NoError(t, yaml.Unmarshal(contentOf(t, files, sourceDoc), &gitRaw)) - chains, _ := renderChains(files, parseKustomizations(files)) + chains, _, _ := renderChains(files, parseKustomizations(files)) rendered := chains[chainKey{originPath: sourceDoc, kind: "Deployment", name: "web"}].rendered _, _, err := SplitDesiredForOverrides(gitRaw, &unstructured.Unstructured{Object: live}, rendered, "") diff --git a/internal/manifestanalyzer/store.go b/internal/manifestanalyzer/store.go index cac2f5b5..ca87d7c6 100644 --- a/internal/manifestanalyzer/store.go +++ b/internal/manifestanalyzer/store.go @@ -103,6 +103,15 @@ type ManifestStore struct { // values-content-architecture.md. ValueFileRefs map[string]struct{} + // RenderedInventory records what each render root RENDERS TO, keyed by the root's + // directory: the distinct types and namespaces of the objects kustomize actually + // produced. It answers the two questions a tool must settle before it provisions from + // a folder — which types have to be served where this is applied, and which namespaces + // the objects land in — and it is read off a real build, so a `namespace:` transformer + // or a base outside the subtree is already accounted for. Empty for a root that failed + // to build, and absent for a directory that is not a render root. + RenderedInventory map[string]RenderInventory + // reachedByMultipleRoots is the set of resource-file paths (slash) that more than one // render root reaches through the resources graph — the generalised write-fan-in // signal, exposed via ReachedByMultipleRenderRoots. Computed once at build time from @@ -413,7 +422,7 @@ func buildStore( kusts := parseKustomizations(yamlFiles) resourceFiles := resourceFilePaths(yamlFiles) nsAssignments := kustomizeNamespaceAssignments(kusts, resourceFiles) - ovAssignments, renderFailures := renderChains(yamlFiles, kusts) + ovAssignments, renderedInventory, renderFailures := renderChains(yamlFiles, kusts) store := &ManifestStore{ FilesByPath: map[string]*FileModel{}, @@ -436,6 +445,7 @@ func buildStore( // The generalised write-fan-in set: files more than one render root reaches. It // is derived from the same kustomization graph renderRoots reads, so a base // shared by two overlays is flagged whether or not an override entry is at stake. + RenderedInventory: renderedInventory, reachedByMultipleRoots: renderRootFanIn(kusts), } diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values.golden.json index f87a1411..d250f7c1 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values.golden.json @@ -10,7 +10,22 @@ "rendered": 2, "editable": 2, "nonKrm": 0 - } + }, + "kinds": [ + { + "group": "argoproj.io", + "version": "v1alpha1", + "kind": "Application" + }, + { + "group": "cert-manager.io", + "version": "v1", + "kind": "ClusterIssuer" + } + ], + "namespaces": [ + "argocd" + ] } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays.golden.json index 73532bd2..57dfec6c 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays.golden.json @@ -13,7 +13,22 @@ "rendered": 2, "editable": 0, "nonKrm": 0 - } + }, + "kinds": [ + { + "group": "", + "version": "v1", + "kind": "Service" + }, + { + "group": "apps", + "version": "v1", + "kind": "Deployment" + } + ], + "namespaces": [ + "podinfo-acc" + ] }, { "path": "overlays/prod", @@ -28,7 +43,22 @@ "rendered": 2, "editable": 0, "nonKrm": 0 - } + }, + "kinds": [ + { + "group": "", + "version": "v1", + "kind": "Service" + }, + { + "group": "apps", + "version": "v1", + "kind": "Deployment" + } + ], + "namespaces": [ + "podinfo-prod" + ] }, { "path": "overlays/test", @@ -43,7 +73,22 @@ "rendered": 2, "editable": 0, "nonKrm": 0 - } + }, + "kinds": [ + { + "group": "", + "version": "v1", + "kind": "Service" + }, + { + "group": "apps", + "version": "v1", + "kind": "Deployment" + } + ], + "namespaces": [ + "podinfo-test" + ] } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/fleet-root.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/fleet-root.golden.json index 17fb8bcf..66afa61f 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/fleet-root.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/fleet-root.golden.json @@ -10,7 +10,17 @@ "rendered": 1, "editable": 1, "nonKrm": 0 - } + }, + "kinds": [ + { + "group": "apps", + "version": "v1", + "kind": "Deployment" + } + ], + "namespaces": [ + "podinfo" + ] }, { "path": "clusters/prod", @@ -22,7 +32,17 @@ "rendered": 1, "editable": 1, "nonKrm": 0 - } + }, + "kinds": [ + { + "group": "kustomize.toolkit.fluxcd.io", + "version": "v1", + "kind": "Kustomization" + } + ], + "namespaces": [ + "flux-system" + ] }, { "path": "infra/ingress-nginx", @@ -34,7 +54,17 @@ "rendered": 1, "editable": 1, "nonKrm": 0 - } + }, + "kinds": [ + { + "group": "apps", + "version": "v1", + "kind": "Deployment" + } + ], + "namespaces": [ + "infra-system" + ] } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/flux-helmrelease-values.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/flux-helmrelease-values.golden.json index 34e11e89..f3416e96 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/flux-helmrelease-values.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/flux-helmrelease-values.golden.json @@ -10,7 +10,17 @@ "rendered": 1, "editable": 1, "nonKrm": 0 - } + }, + "kinds": [ + { + "group": "helm.toolkit.fluxcd.io", + "version": "v2", + "kind": "HelmRelease" + } + ], + "namespaces": [ + "flux-system" + ] } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/helm-release-document.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/helm-release-document.golden.json index 34e11e89..f3416e96 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/helm-release-document.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/helm-release-document.golden.json @@ -10,7 +10,17 @@ "rendered": 1, "editable": 1, "nonKrm": 0 - } + }, + "kinds": [ + { + "group": "helm.toolkit.fluxcd.io", + "version": "v2", + "kind": "HelmRelease" + } + ], + "namespaces": [ + "flux-system" + ] } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/kustomize-single.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/kustomize-single.golden.json index 34c7b7d2..b9b42f31 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/kustomize-single.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/kustomize-single.golden.json @@ -10,7 +10,22 @@ "rendered": 2, "editable": 2, "nonKrm": 0 - } + }, + "kinds": [ + { + "group": "", + "version": "v1", + "kind": "Service" + }, + { + "group": "apps", + "version": "v1", + "kind": "Deployment" + } + ], + "namespaces": [ + "demo" + ] } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/overlapping.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/overlapping.golden.json index bb957e77..f2a1fff0 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/overlapping.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/overlapping.golden.json @@ -11,6 +11,21 @@ "editable": 2, "nonKrm": 0 }, + "kinds": [ + { + "group": "", + "version": "v1", + "kind": "ConfigMap" + }, + { + "group": "apps", + "version": "v1", + "kind": "Deployment" + } + ], + "namespaces": [ + "web" + ], "overlapsWith": [ "apps/web/extra" ] @@ -26,6 +41,16 @@ "editable": 1, "nonKrm": 0 }, + "kinds": [ + { + "group": "", + "version": "v1", + "kind": "ConfigMap" + } + ], + "namespaces": [ + "web" + ], "overlapsWith": [ "apps/web" ] diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base.golden.json index e9ef5b68..59d8b69c 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base.golden.json @@ -13,7 +13,22 @@ "rendered": 2, "editable": 0, "nonKrm": 0 - } + }, + "kinds": [ + { + "group": "", + "version": "v1", + "kind": "ConfigMap" + }, + { + "group": "apps", + "version": "v1", + "kind": "Deployment" + } + ], + "namespaces": [ + "nested-test" + ] } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base.golden.json index 7cf5c1ed..225fdc7b 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base.golden.json @@ -13,7 +13,17 @@ "rendered": 1, "editable": 0, "nonKrm": 0 - } + }, + "kinds": [ + { + "group": "apps", + "version": "v1", + "kind": "Deployment" + } + ], + "namespaces": [ + "parked-test" + ] } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/plain-per-env.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/plain-per-env.golden.json index 0a3b316d..401cd560 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/plain-per-env.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/plain-per-env.golden.json @@ -10,7 +10,22 @@ "rendered": 2, "editable": 2, "nonKrm": 0 - } + }, + "kinds": [ + { + "group": "", + "version": "v1", + "kind": "Service" + }, + { + "group": "apps", + "version": "v1", + "kind": "Deployment" + } + ], + "namespaces": [ + "app-prod" + ] }, { "path": "apps/app/test", @@ -22,7 +37,22 @@ "rendered": 2, "editable": 2, "nonKrm": 0 - } + }, + "kinds": [ + { + "group": "", + "version": "v1", + "kind": "Service" + }, + { + "group": "apps", + "version": "v1", + "kind": "Deployment" + } + ], + "namespaces": [ + "app-test" + ] } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json b/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json index 9abb80e5..c5d37323 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json @@ -18,7 +18,17 @@ "rendered": 1, "editable": 1, "nonKrm": 1 - } + }, + "kinds": [ + { + "group": "apps", + "version": "v1", + "kind": "Deployment" + } + ], + "namespaces": [ + "demo" + ] } ], "summary": { diff --git a/pkg/manifestanalyzer/repo.go b/pkg/manifestanalyzer/repo.go index 6a577983..850df2e7 100644 --- a/pkg/manifestanalyzer/repo.go +++ b/pkg/manifestanalyzer/repo.go @@ -75,6 +75,14 @@ type ResourceCounts struct { NonKRM int `json:"nonKrm"` } +// GroupVersionKind names one Kubernetes type. Group is empty for core types, so a +// ConfigMap is {"", "v1", "ConfigMap"}. +type GroupVersionKind struct { + Group string `json:"group"` + Version string `json:"version"` + Kind string `json:"kind"` +} + // Candidate is one folder that could become a GitTarget. type Candidate struct { // Path is slash-separated and relative to the repository root. @@ -91,6 +99,24 @@ type Candidate struct { // InferredNamespace is the namespace the candidate resolves to, when unambiguous. InferredNamespace string `json:"inferredNamespace,omitempty"` Resources ResourceCounts `json:"resources"` + // Kinds is every distinct type this folder RENDERS, sorted by group, version, kind. + // For a render root it comes off a real kustomize build, so it includes what the + // folder pulls from a base outside its own subtree; for a plain folder it is the + // documents themselves. Empty when the folder renders nothing, or when it is a render + // root kustomize could not build — there is no honest answer in that case. + // + // It is the set that must already be served wherever this folder is applied. A type + // the destination does not serve does not degrade: the apply fails with "no matches + // for kind" and waits for the next resync. + Kinds []GroupVersionKind `json:"kinds,omitempty"` + // Namespaces is every distinct namespace this folder's objects LAND IN, sorted — for a + // render root the namespace transformer applied, not what the files happen to say. + // Cluster-scoped objects contribute nothing rather than an empty string. + // + // It is the plural of InferredNamespace and the honest one: a folder can render into + // several, and a tool that has to name them (on a GitTarget's allowed source + // namespaces, or one WatchRule rule per type and namespace) needs the whole set. + Namespaces []string `json:"namespaces,omitempty"` // OverlapsWith lists candidate paths this one nests with. Two overlapping candidates // can never both become GitTargets — a folder has exactly one owner. OverlapsWith []string `json:"overlapsWith,omitempty"` @@ -222,6 +248,10 @@ func candidateFrom(cand internalanalyzer.RepoCandidate) Candidate { NonKRM: cand.Resources.NonKRM, }, OverlapsWith: cand.OverlapsWith, + Namespaces: cand.Namespaces, + } + for _, gvk := range cand.Kinds { + out.Kinds = append(out.Kinds, GroupVersionKind{Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind}) } for _, reason := range cand.RefusalReasons { out.RefusalReasons = append(out.RefusalReasons, RefusalReason{ diff --git a/pkg/manifestanalyzer/repo_test.go b/pkg/manifestanalyzer/repo_test.go index 639029a8..6526db6f 100644 --- a/pkg/manifestanalyzer/repo_test.go +++ b/pkg/manifestanalyzer/repo_test.go @@ -174,3 +174,33 @@ func TestRepoReport_WriteJSON_EmptyCandidatesIsArray(t *testing.T) { require.NoError(t, report.WriteJSON(&buf)) require.Contains(t, buf.String(), `"candidates": []`) } + +// A tool that provisions from a folder scan has to know two things before it acts, and +// both are answers only the engine can give: which types must already be served where the +// folder is applied, and which namespaces its objects land in. Reading the file headers +// gives a different answer the moment a layout transform is involved — an overlay renders +// a base it does not contain, into a namespace its files do not mention. +func TestScanRepo_CandidateReportsWhatItRenders(t *testing.T) { + t.Parallel() + + report, err := manifestanalyzer.ScanRepo(context.Background(), fixture(t, "supported", "base-overlays")) + require.NoError(t, err) + require.NotEmpty(t, report.Status.Candidates) + + for _, cand := range report.Status.Candidates { + require.Equal(t, manifestanalyzer.LayoutKustomizeOverlay, cand.Layout) + + // The overlay's own subtree holds nothing but a kustomization; every type it + // renders comes from the base outside it. + require.Zero(t, cand.Resources.Editable, "a pure passthrough overlay owns no source") + require.Contains(t, cand.Kinds, + manifestanalyzer.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}) + require.Contains(t, cand.Kinds, + manifestanalyzer.GroupVersionKind{Group: "", Version: "v1", Kind: "Service"}) + + // The namespace is the overlay's transformer applied, which is also what + // InferredNamespace names — but Namespaces is the set, and the set is what a + // caller has to enumerate. + require.Equal(t, []string{cand.InferredNamespace}, cand.Namespaces) + } +} diff --git a/pkg/manifestanalyzer/testdata/repo-report.golden.yaml b/pkg/manifestanalyzer/testdata/repo-report.golden.yaml index 6ce5f72a..778e8226 100644 --- a/pkg/manifestanalyzer/testdata/repo-report.golden.yaml +++ b/pkg/manifestanalyzer/testdata/repo-report.golden.yaml @@ -7,7 +7,13 @@ status: candidates: - acceptedByOperator: false inferredNamespace: demo + kinds: + - group: apps + kind: Deployment + version: v1 layout: plain + namespaces: + - demo path: app refusalReasons: - actor: repository-author From dde9bd39dce82cea936b9dc01f33eb5a280b8e63 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 27 Jul 2026 12:03:02 +0000 Subject: [PATCH 09/15] refactor(manifestanalyzer)!: keep each rendered type paired with its namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A set of types beside a set of namespaces reads as every combination of the two. A folder rendering a Deployment into storefront and a Service into payments published four pairs, two of which exist in no repository — and the stated use for these fields is one watch rule per (type, namespace), so the union would have authorized watches matching nothing. The pairing is the answer, not a detail of how it is spelled. renderedTypes: byNamespace: storefront: [apps/v1/Deployment, v1/Service, v1/ConfigMap] payments: [apps/v1/Deployment, v1/Service] namespaceUndeclared: [v1/ConfigMap] Types are canonical GVK strings, which is GVK.String() and the spelling summary.byGvk already uses, so the report gains no second vocabulary. The second correction is a claim I had no right to make. "A cluster-scoped object contributes no namespace" describes what I inferred; what the code observes is an object that rendered WITHOUT one, which is either cluster-scoped or namespaced and relying on the applier's default. Telling those apart needs API discovery and a structure-only scan has none, so both go to namespaceUndeclared. A clusterScoped key is reserved for the discovery-aware scan that can fill it, and until then is ABSENT rather than empty: an empty list would read as "this folder has no cluster-scoped types", which is the same species of confident wrong answer as the cross-product it replaces. A type may appear in both byNamespace and namespaceUndeclared. Two ConfigMaps, one placed and one floating, is an ordinary folder. BREAKING CHANGE: candidate.kinds and candidate.namespaces are replaced by candidate.renderedTypes. Co-Authored-By: Claude Opus 5 --- .coverage-baseline | 2 +- .../repo-discovery-and-onboarding-scan.md | 52 ++++++--- internal/manifestanalyzer/override_chain.go | 78 +++++++------- .../manifestanalyzer/render_inventory_test.go | 99 +++++++++++++---- internal/manifestanalyzer/scan_repo.go | 102 +++++++++++------- internal/manifestanalyzer/store.go | 13 ++- .../argocd-external-helm-values.golden.json | 23 ++-- .../supported/base-overlays.golden.json | 63 ++++------- .../supported/fleet-root.golden.json | 45 ++++---- .../flux-helmrelease-values.golden.json | 15 ++- .../helm-release-document.golden.json | 15 ++- .../supported/kustomize-single.golden.json | 21 ++-- .../supported/overlapping.golden.json | 36 +++---- .../supported/overlay-nested-base.golden.json | 21 ++-- .../supported/overlay-parked-base.golden.json | 15 ++- .../supported/plain-per-env.golden.json | 42 +++----- .../unsupported/helm-inflation.golden.json | 3 +- .../unsupported/plain-nonkrm.golden.json | 15 ++- .../unsupported-kustomize.golden.json | 3 +- pkg/manifestanalyzer/repo.go | 82 +++++++++----- pkg/manifestanalyzer/repo_test.go | 19 ++-- .../testdata/repo-report.golden.yaml | 10 +- 22 files changed, 409 insertions(+), 365 deletions(-) diff --git a/.coverage-baseline b/.coverage-baseline index d916ff4b..c7a703d9 100644 --- a/.coverage-baseline +++ b/.coverage-baseline @@ -1 +1 @@ -77.9 +78.0 diff --git a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md index c8dc19e4..8f66ee75 100644 --- a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md +++ b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md @@ -262,26 +262,48 @@ free to move. in `spec` (`root`, `mode`) and everything found in `status` (`generator`, `candidates`, `summary`). The candidate blocks below live under `status.candidates`. Per candidate: `path`, `layout`, `acceptedByOperator`, `refusalReasons[]` (`{code, detail, solvable, actor}`), -`renderRoot`, `readScope[]`, `inferredNamespace`, `resources`, `kinds[]`, `namespaces[]`, +`renderRoot`, `readScope[]`, `inferredNamespace`, `resources`, `renderedTypes`, `overlapsWith[]`. -- **`kinds[]` and `namespaces[]` say what is IN a candidate, not just how much.** Both are - what the folder RENDERS, which is the part only this engine can answer: for a render root - they are read off the real kustomize build the scan already runs, so a base outside the - subtree is included and a `namespace:` transformer is already applied; for a plain folder - the documents are the render. A consumer can scan `apiVersion`/`kind` headers itself, but - the moment a layout transform is involved the two answers diverge silently — and in the - direction of provisioning something the writer then refuses. - - They gate steps that are not cheap to undo. A type the destination does not serve fails +- **`renderedTypes` says what is IN a candidate, not just how much** — which type lands in + which namespace, as the folder RENDERS. For a render root it is read off the real + kustomize build the scan already runs, so a base outside the subtree is included and a + `namespace:` transformer is already applied; for a plain folder the documents are the + render. A consumer can scan `apiVersion`/`kind` headers itself, but the moment a layout + transform is involved the two answers diverge silently — and in the direction of + provisioning something the writer then refuses. + + ```yaml + renderedTypes: + byNamespace: + storefront: [apps/v1/Deployment, v1/Service, v1/ConfigMap] + payments: [apps/v1/Deployment, v1/Service] + namespaceUndeclared: [v1/ConfigMap] + ``` + + **The pairing is the contract, and a union would not do.** A set of types beside a set of + namespaces reads as every combination of the two: a folder rendering a Deployment into + `storefront` and a Service into `payments` would describe four pairs, two of which exist + in no repository, and a tool generating one watch rule per pair would authorize watches + that match nothing. `byNamespace` has only the real pairs. + + Types are canonical GVK strings — `group/version/kind`, or `version/kind` for the core + group. It is [`GVK.String`](../../../internal/manifestanalyzer/analyzer.go) and the + spelling `summary.byGvk` already uses. + + **`namespaceUndeclared` is not "cluster-scoped".** It is every type that renders WITHOUT a + namespace, which today is two facts this scan cannot separate: a genuinely cluster-scoped + type, and a namespaced type relying on whatever the applier defaults to. Deciding between + them needs API discovery, and a structure-only scan has none. A `clusterScoped` key is + reserved for the discovery-aware scan that can fill it honestly; until then it is **absent + rather than empty**, because an empty list would read as "this folder has no cluster-scoped + types" — a claim the scan cannot make. + + These gate steps that are not cheap to undo. A type the destination does not serve fails the forward apply with `no matches for kind` and waits for the next resync rather than degrading, so the schema has to be installed before the folder is picked. And naming a GitTarget's allowed source namespaces, or one WatchRule rule per (type, namespace), needs - the whole set — `inferredNamespace` is one name, and a folder can render into several. - - A cluster-scoped object contributes no namespace rather than an empty one. A render root - kustomize cannot build reports neither: we do not know what it renders, and saying so is - better than guessing. + the exact pairs — `inferredNamespace` is one name, and a folder can render into several. - **`solvable` says whether anyone can make the candidate acceptable with this release**, and `actor` (`repository-author`, `platform-operator`) names who. It is decided by the check that raised the refusal, because the same code answers differently depending on the diff --git a/internal/manifestanalyzer/override_chain.go b/internal/manifestanalyzer/override_chain.go index a0dccb16..09151259 100644 --- a/internal/manifestanalyzer/override_chain.go +++ b/internal/manifestanalyzer/override_chain.go @@ -6,8 +6,6 @@ import ( "sort" "strings" - "k8s.io/apimachinery/pkg/runtime/schema" - "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit" ) @@ -132,11 +130,11 @@ func markReachable(kusts map[string]*kustomizationDoc, dir string, covered map[s // root reaches the same document with DIFFERENT chains, which is the fan-in > 1 case // we refuse to route through. // -// It also returns what each root RENDERS TO — the distinct types and namespaces of the -// objects kustomize actually produced — because that is a question only a real build can -// answer and this is the one place a build already happens. A `namespace:` transformer -// moves every object it covers, so the types and namespaces a folder declares in its files -// and the ones it renders to are different sets whenever a layout transform is involved. +// It also returns what each root RENDERS TO — which type lands in which namespace, from +// the objects kustomize actually produced — because that is a question only a real build +// can answer and this is the one place a build already happens. A `namespace:` transformer +// moves every object it covers, so what a folder's files declare and what it renders to +// are different answers whenever a layout transform is involved. // // It also returns, per kustomization path, the roots that FAILED to build. Those // must refuse the folder, and it is important to see why a silent skip would be @@ -150,9 +148,9 @@ func markReachable(kusts map[string]*kustomizationDoc, dir string, covered map[s func renderChains( files []manifestedit.FileContent, kusts map[string]*kustomizationDoc, -) (map[chainKey]*overrideAssignment, map[string]RenderInventory, map[string]string) { +) (map[chainKey]*overrideAssignment, map[string]RenderedTypes, map[string]string) { out := map[chainKey]*overrideAssignment{} - inventory := map[string]RenderInventory{} + inventory := map[string]RenderedTypes{} failed := map[string]string{} // One dye plan for the whole scan, so the nonces are stable across roots and a base @@ -190,45 +188,53 @@ func renderChains( return out, inventory, failed } -// inventoryOf reduces one root's rendered objects to the two distinct sets a tool needs -// before it provisions from a folder: which types must already be served where this is -// applied, and which namespaces the objects land in. +// inventoryOf reduces one root's rendered objects to what a tool must know before it +// provisions from the folder: which type lands in which namespace. // -// A cluster-scoped object contributes no namespace rather than an empty one — there is no -// namespace to name, and an empty string in the set would read as one. -func inventoryOf(rendered []renderedObject) RenderInventory { - kinds := map[schema.GroupVersionKind]struct{}{} - namespaces := map[string]struct{}{} +// The pairing is the point. Publishing a set of types beside a set of namespaces loses it: +// a folder rendering Deployment into frontend and Service into backend would read as four +// combinations, two of which describe nothing, and a consumer generating one watch rule per +// pair would authorize watches that match no object in the repository. +func inventoryOf(rendered []renderedObject) RenderedTypes { + byNamespace := map[string]map[string]struct{}{} + undeclared := map[string]struct{}{} for _, ro := range rendered { - kinds[ro.Object.GroupVersionKind()] = struct{}{} - if ns := ro.Object.GetNamespace(); ns != "" { - namespaces[ns] = struct{}{} + gvk := ro.Object.GroupVersionKind() + name := GVK{Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind}.String() + ns := ro.Object.GetNamespace() + if ns == "" { + undeclared[name] = struct{}{} + continue + } + if byNamespace[ns] == nil { + byNamespace[ns] = map[string]struct{}{} } + byNamespace[ns][name] = struct{}{} + } + return RenderedTypes{ + ByNamespace: sortedTypesByNamespace(byNamespace), + NamespaceUndeclared: sortedKeysOfSet(undeclared), } - return RenderInventory{Kinds: sortedGVKs(kinds), Namespaces: sortedKeysOfSet(namespaces)} } -// sortedGVKs renders a GVK set in a stable order, so a report never churns on map -// iteration. -func sortedGVKs(set map[schema.GroupVersionKind]struct{}) []GVK { - out := make([]GVK, 0, len(set)) - for gvk := range set { - out = append(out, GVK{Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind}) +// sortedTypesByNamespace turns the accumulated sets into the sorted lists a report +// publishes, so nothing churns on map iteration. +func sortedTypesByNamespace(in map[string]map[string]struct{}) map[string][]string { + if len(in) == 0 { + return nil + } + out := make(map[string][]string, len(in)) + for ns, types := range in { + out[ns] = sortedKeysOfSet(types) } - sort.Slice(out, func(i, j int) bool { - if out[i].Group != out[j].Group { - return out[i].Group < out[j].Group - } - if out[i].Version != out[j].Version { - return out[i].Version < out[j].Version - } - return out[i].Kind < out[j].Kind - }) return out } // sortedKeysOfSet returns a set's members in sorted order. func sortedKeysOfSet(set map[string]struct{}) []string { + if len(set) == 0 { + return nil + } out := make([]string, 0, len(set)) for k := range set { out = append(out, k) diff --git a/internal/manifestanalyzer/render_inventory_test.go b/internal/manifestanalyzer/render_inventory_test.go index f20ea818..c751b8c2 100644 --- a/internal/manifestanalyzer/render_inventory_test.go +++ b/internal/manifestanalyzer/render_inventory_test.go @@ -47,10 +47,37 @@ func TestRenderInventory_NamespaceIsWhatRendersNotWhatIsWritten(t *testing.T) { cand := candidateAt(t, scanRepoFS(context.Background(), fsys), "overlays/prod") - requireStrings(t, "namespaces", cand.Namespaces, []string{"where-it-lands"}) - if len(cand.Kinds) != 1 || cand.Kinds[0].Kind != "Deployment" || cand.Kinds[0].Group != "apps" { - t.Errorf("kinds = %+v, want the single apps/v1 Deployment the overlay renders", cand.Kinds) + requireTypesByNamespace(t, cand.RenderedTypes, map[string][]string{ + "where-it-lands": {"apps/v1/Deployment"}, + }) + requireStrings(t, "namespaceUndeclared", cand.RenderedTypes.NamespaceUndeclared, nil) +} + +// TestRenderInventory_KeepsEachTypePairedWithItsOwnNamespace is the reason this is a map +// and not two lists. A folder rendering a Deployment into one namespace and a Service into +// another has exactly two pairs; published as a type set beside a namespace set it would +// read as four, and a tool generating one watch rule per pair would authorize two that +// match nothing in the repository. +func TestRenderInventory_KeepsEachTypePairedWithItsOwnNamespace(t *testing.T) { + const service = `apiVersion: v1 +kind: Service +metadata: + name: api + namespace: payments +spec: + ports: [] +` + fsys := fstest.MapFS{ + "app/deployment.yaml": {Data: []byte(inventoryDeployment)}, + "app/service.yaml": {Data: []byte(service)}, } + + cand := candidateAt(t, scanRepoFS(context.Background(), fsys), "app") + + requireTypesByNamespace(t, cand.RenderedTypes, map[string][]string{ + "written-in-the-file": {"apps/v1/Deployment"}, + "payments": {"v1/Service"}, + }) } // TestRenderInventory_CoversTheBaseOutsideTheSubtree pins the other half of "as rendered": @@ -68,15 +95,37 @@ func TestRenderInventory_CoversTheBaseOutsideTheSubtree(t *testing.T) { cand := candidateAt(t, scanRepoFS(context.Background(), fsys), "overlays/prod") - if len(cand.Kinds) != 2 { - t.Fatalf("kinds = %+v, want both types the overlay renders from the base", cand.Kinds) + requireTypesByNamespace(t, cand.RenderedTypes, map[string][]string{ + "prod": {"apps/v1/Deployment"}, + }) + // The ClusterRole renders with no namespace, and this scan cannot say WHY: it is + // cluster-scoped, but proving that needs API discovery the scan does not have. So it + // goes to namespaceUndeclared, and clusterScoped stays absent rather than claiming + // there are none. + requireStrings(t, "namespaceUndeclared", cand.RenderedTypes.NamespaceUndeclared, + []string{"rbac.authorization.k8s.io/v1/ClusterRole"}) + requireStrings(t, "clusterScoped", cand.RenderedTypes.ClusterScoped, nil) +} + +// TestRenderInventory_ATypeCanBeBothNamespacedAndNot covers the shape that looks like a +// contradiction and is not: two ConfigMaps, one carrying a namespace and one relying on +// whatever the applier defaults to. +func TestRenderInventory_ATypeCanBeBothNamespacedAndNot(t *testing.T) { + const placed = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: placed\n namespace: storefront\n" + const floating = "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: floating\n" + + fsys := fstest.MapFS{ + "app/placed.yaml": {Data: []byte(placed)}, + "app/floating.yaml": {Data: []byte(floating)}, } - // Sorted by group, then version, then kind — so "apps" precedes - // "rbac.authorization.k8s.io" and the order is part of what a consumer can rely on. - requireStrings(t, "kinds", kindNames(cand.Kinds), []string{"Deployment", "ClusterRole"}) - // The ClusterRole is cluster-scoped: it lands in no namespace and must contribute none, - // because an empty string in the set would read as a namespace called "". - requireStrings(t, "namespaces", cand.Namespaces, []string{"prod"}) + + cand := candidateAt(t, scanRepoFS(context.Background(), fsys), "app") + + requireTypesByNamespace(t, cand.RenderedTypes, map[string][]string{ + "storefront": {"v1/ConfigMap"}, + }) + requireStrings(t, "namespaceUndeclared", cand.RenderedTypes.NamespaceUndeclared, + []string{"v1/ConfigMap"}) } // TestRenderInventory_PlainFolderReportsItsOwnDocuments covers the folder with no @@ -90,8 +139,11 @@ func TestRenderInventory_PlainFolderReportsItsOwnDocuments(t *testing.T) { cand := candidateAt(t, scanRepoFS(context.Background(), fsys), "app") - requireStrings(t, "kinds", kindNames(cand.Kinds), []string{"Deployment", "ClusterRole"}) - requireStrings(t, "namespaces", cand.Namespaces, []string{"written-in-the-file"}) + requireTypesByNamespace(t, cand.RenderedTypes, map[string][]string{ + "written-in-the-file": {"apps/v1/Deployment"}, + }) + requireStrings(t, "namespaceUndeclared", cand.RenderedTypes.NamespaceUndeclared, + []string{"rbac.authorization.k8s.io/v1/ClusterRole"}) } // TestRenderInventory_AbsentWhenTheRootDoesNotBuild says nothing rather than guessing: a @@ -108,9 +160,8 @@ func TestRenderInventory_AbsentWhenTheRootDoesNotBuild(t *testing.T) { if cand.AcceptedByOperator { t.Fatalf("a root that does not build must be refused, got accepted") } - if len(cand.Kinds) != 0 || len(cand.Namespaces) != 0 { - t.Errorf("kinds = %+v, namespaces = %+v, want nothing: we cannot know what it renders", - cand.Kinds, cand.Namespaces) + if len(cand.RenderedTypes.ByNamespace) != 0 || len(cand.RenderedTypes.NamespaceUndeclared) != 0 { + t.Errorf("renderedTypes = %+v, want nothing: we cannot know what it renders", cand.RenderedTypes) } } @@ -127,14 +178,16 @@ func candidateAt(t *testing.T, rep RepoReport, path string) RepoCandidate { return RepoCandidate{} } -// kindNames projects a GVK slice onto its kinds, for the assertions that care about which -// types are present rather than their full identity. -func kindNames(kinds []GVK) []string { - out := make([]string, 0, len(kinds)) - for _, k := range kinds { - out = append(out, k.Kind) +// requireTypesByNamespace asserts the exact type-to-namespace map, which is the whole +// point of the shape: a missing pair and an invented one are both failures. +func requireTypesByNamespace(t *testing.T, got RenderedTypes, want map[string][]string) { + t.Helper() + if len(got.ByNamespace) != len(want) { + t.Fatalf("byNamespace = %v, want %v", got.ByNamespace, want) + } + for ns, types := range want { + requireStrings(t, "byNamespace["+ns+"]", got.ByNamespace[ns], types) } - return out } // requireStrings asserts an exact, ordered match — the sets are sorted, so order is part diff --git a/internal/manifestanalyzer/scan_repo.go b/internal/manifestanalyzer/scan_repo.go index 5d783205..9c50c0c8 100644 --- a/internal/manifestanalyzer/scan_repo.go +++ b/internal/manifestanalyzer/scan_repo.go @@ -12,8 +12,6 @@ import ( "sort" "strings" - "k8s.io/apimachinery/pkg/runtime/schema" - "github.com/ConfigButler/gitops-reverser/internal/git/manifestedit" ) @@ -84,17 +82,40 @@ type RefusalReason struct { Actor Actor `json:"actor,omitempty"` } -// RenderInventory is what a render root renders to, reduced to the two distinct sets a -// tool needs before it provisions from the folder. Both are read off a real kustomize -// build, so a `namespace:` transformer, a base outside the subtree, and any other layout -// transform are already applied — these are the objects that reach the cluster, not the -// ones the files happen to declare. -type RenderInventory struct { - // Kinds is every distinct type the folder renders, sorted. - Kinds []GVK - // Namespaces is every distinct namespace the folder's objects land in, sorted. A - // cluster-scoped object contributes nothing rather than an empty string. - Namespaces []string +// RenderedTypes is what a folder renders, expressed so that the pairing between a type and +// the namespace it lands in survives. A set of types beside a set of namespaces loses it: +// a folder rendering a Deployment into frontend and a Service into backend would read as +// four combinations, and a tool generating one watch rule per pair would authorize two +// that match nothing in the repository. +// +// Every type is a canonical GVK string — "group/version/kind", or "version/kind" for the +// core group, which is [GVK.String] and the same spelling Summary.ByGVK already uses. +// +// For a render root the sets come off a real kustomize build, so a base outside the subtree +// is included and a `namespace:` transformer is already applied. For a plain folder the +// documents are the render. A root that failed to build reports nothing at all: what it +// renders is not knowable. +type RenderedTypes struct { + // ByNamespace lists the types that land in each namespace, sorted, keyed by namespace. + ByNamespace map[string][]string `json:"byNamespace,omitempty"` + + // ClusterScoped lists the types that take no namespace because the API says they take + // none. + // + // It is NEVER populated today, and is deliberately absent rather than empty: deciding + // that a type is cluster-scoped requires API discovery, and this scan has none. An + // empty list would read as "this folder has no cluster-scoped types", which is a claim + // the scan cannot make — the ClusterRole is in NamespaceUndeclared instead. A + // discovery-aware scan can fill this and shrink NamespaceUndeclared accordingly. + ClusterScoped []string `json:"clusterScoped,omitempty"` + + // NamespaceUndeclared lists the types that render WITHOUT a namespace, sorted. Today + // that is two different facts the scan cannot tell apart: a cluster-scoped type, and a + // namespaced type relying on whatever namespace the applier defaults to. + // + // A type can appear here AND under ByNamespace. Two ConfigMaps, one carrying a + // namespace and one not, is an ordinary folder, not a contradiction. + NamespaceUndeclared []string `json:"namespaceUndeclared,omitempty"` } // ResourceCounts splits the KRM a candidate covers into what it renders versus what it @@ -140,18 +161,11 @@ type RepoCandidate struct { InferredNamespace string `json:"inferredNamespace,omitempty"` // Resources counts the KRM this candidate covers (rendered vs editable) plus non-KRM. Resources ResourceCounts `json:"resources"` - // Kinds is every distinct type this candidate RENDERS, sorted. For a render root it - // is read off a real kustomize build, so a base outside the subtree is included and a - // layout transform is already applied; for a plain folder it is the documents - // themselves. A tool must install these schemas before it points anything at the - // folder: a kind the destination does not serve fails the forward apply with "no - // matches for kind" rather than degrading. - Kinds []GVK `json:"kinds,omitempty"` - // Namespaces is every distinct namespace this candidate's objects LAND IN, sorted, - // which for a render root is the namespace transformer applied rather than whatever - // the files say. Cluster-scoped objects contribute nothing. It is the plural of - // InferredNamespace and the honest one: a folder can render into several. - Namespaces []string `json:"namespaces,omitempty"` + // RenderedTypes says which type lands in which namespace when this candidate renders. + // It is what a tool must know before it provisions from the folder: the schemas that + // have to be served where this is applied, and the exact (type, namespace) pairs a + // watch rule can be written for. Empty for a render root kustomize could not build. + RenderedTypes RenderedTypes `json:"renderedTypes"` // OverlapsWith lists other candidate paths this one nests with. Two overlapping // candidates can never both be proposed (one-owner-per-folder); the conflict is // reported, not resolved, in this cut. @@ -258,7 +272,7 @@ func classifyRenderRoot( // What the root actually renders to, from the build the scan already ran. A root that // failed to build has none, which is honest: we cannot know what it renders. if inv, ok := store.RenderedInventory[rootDir]; ok { - c.Kinds, c.Namespaces = inv.Kinds, inv.Namespaces + c.RenderedTypes = inv } // rendered/editable count only the documents the kustomization graph actually renders // (its resources: entries), never parked YAML a kustomization does not reference. @@ -305,25 +319,34 @@ func classifyRenderRoot( return c } -// plainFolderInventory is the render inventory of a folder with no kustomization: the -// documents ARE the render, so the types and namespaces come straight off them. Nothing -// transforms them on the way to the cluster, which is what makes a plain folder plain. -func plainFolderInventory(store *ManifestStore, dir string) ([]GVK, []string) { - kinds := map[schema.GroupVersionKind]struct{}{} - namespaces := map[string]struct{}{} +// plainFolderInventory is the rendered-type map of a folder with no kustomization: the +// documents ARE the render, so the types and the namespaces come straight off them. +// Nothing transforms them on the way to the cluster, which is what makes a plain folder +// plain. +func plainFolderInventory(store *ManifestStore, dir string) RenderedTypes { + byNamespace := map[string]map[string]struct{}{} + undeclared := map[string]struct{}{} for filePath, fm := range store.FilesByPath { if !pathWithin(filePath, dir) { continue } for _, dm := range fm.Documents { - gvk := ParseGVK(dm.ManifestIdentity.APIVersion, dm.ManifestIdentity.Kind) - kinds[schema.GroupVersionKind{Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind}] = struct{}{} - if ns := dm.ManifestIdentity.Namespace; ns != "" { - namespaces[ns] = struct{}{} + id := dm.ManifestIdentity + name := ParseGVK(id.APIVersion, id.Kind).String() + if id.Namespace == "" { + undeclared[name] = struct{}{} + continue + } + if byNamespace[id.Namespace] == nil { + byNamespace[id.Namespace] = map[string]struct{}{} } + byNamespace[id.Namespace][name] = struct{}{} } } - return sortedGVKs(kinds), sortedKeysOfSet(namespaces) + return RenderedTypes{ + ByNamespace: sortedTypesByNamespace(byNamespace), + NamespaceUndeclared: sortedKeysOfSet(undeclared), + } } // plainCandidates enumerates plain KRM leaf folders: directories that directly hold a @@ -355,14 +378,13 @@ func plainCandidates( out := make([]RepoCandidate, 0, len(dirs)) for dir := range dirs { acc := candidateAcceptance(ctx, fsys, dir) - kinds, namespaces := plainFolderInventory(store, dir) + renderedTypes := plainFolderInventory(store, dir) cand := RepoCandidate{ Path: dir, Layout: LayoutPlain, AcceptedByOperator: acc.Accepted, InferredNamespace: singleExplicitNamespace(store, dir), - Kinds: kinds, - Namespaces: namespaces, + RenderedTypes: renderedTypes, // A plain folder is applied directory-wise, so it renders its whole subtree // (renderedFiles nil); no kustomization graph scopes it. Resources: countResources(store, dir, nil), diff --git a/internal/manifestanalyzer/store.go b/internal/manifestanalyzer/store.go index ca87d7c6..55bfaf7c 100644 --- a/internal/manifestanalyzer/store.go +++ b/internal/manifestanalyzer/store.go @@ -104,13 +104,12 @@ type ManifestStore struct { ValueFileRefs map[string]struct{} // RenderedInventory records what each render root RENDERS TO, keyed by the root's - // directory: the distinct types and namespaces of the objects kustomize actually - // produced. It answers the two questions a tool must settle before it provisions from - // a folder — which types have to be served where this is applied, and which namespaces - // the objects land in — and it is read off a real build, so a `namespace:` transformer - // or a base outside the subtree is already accounted for. Empty for a root that failed - // to build, and absent for a directory that is not a render root. - RenderedInventory map[string]RenderInventory + // directory: which type lands in which namespace, from the objects kustomize actually + // produced. It is what a tool must settle before it provisions from a folder, and it is + // read off a real build, so a `namespace:` transformer or a base outside the subtree is + // already accounted for. Empty for a root that failed to build, and absent for a + // directory that is not a render root. + RenderedInventory map[string]RenderedTypes // reachedByMultipleRoots is the set of resource-file paths (slash) that more than one // render root reaches through the resources graph — the generalised write-fan-in diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values.golden.json index d250f7c1..d39603ba 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/argocd-external-helm-values.golden.json @@ -11,21 +11,16 @@ "editable": 2, "nonKrm": 0 }, - "kinds": [ - { - "group": "argoproj.io", - "version": "v1alpha1", - "kind": "Application" + "renderedTypes": { + "byNamespace": { + "argocd": [ + "argoproj.io/v1alpha1/Application" + ] }, - { - "group": "cert-manager.io", - "version": "v1", - "kind": "ClusterIssuer" - } - ], - "namespaces": [ - "argocd" - ] + "namespaceUndeclared": [ + "cert-manager.io/v1/ClusterIssuer" + ] + } } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays.golden.json index 57dfec6c..b5676719 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays.golden.json @@ -14,21 +14,14 @@ "editable": 0, "nonKrm": 0 }, - "kinds": [ - { - "group": "", - "version": "v1", - "kind": "Service" - }, - { - "group": "apps", - "version": "v1", - "kind": "Deployment" + "renderedTypes": { + "byNamespace": { + "podinfo-acc": [ + "apps/v1/Deployment", + "v1/Service" + ] } - ], - "namespaces": [ - "podinfo-acc" - ] + } }, { "path": "overlays/prod", @@ -44,21 +37,14 @@ "editable": 0, "nonKrm": 0 }, - "kinds": [ - { - "group": "", - "version": "v1", - "kind": "Service" - }, - { - "group": "apps", - "version": "v1", - "kind": "Deployment" + "renderedTypes": { + "byNamespace": { + "podinfo-prod": [ + "apps/v1/Deployment", + "v1/Service" + ] } - ], - "namespaces": [ - "podinfo-prod" - ] + } }, { "path": "overlays/test", @@ -74,21 +60,14 @@ "editable": 0, "nonKrm": 0 }, - "kinds": [ - { - "group": "", - "version": "v1", - "kind": "Service" - }, - { - "group": "apps", - "version": "v1", - "kind": "Deployment" + "renderedTypes": { + "byNamespace": { + "podinfo-test": [ + "apps/v1/Deployment", + "v1/Service" + ] } - ], - "namespaces": [ - "podinfo-test" - ] + } } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/fleet-root.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/fleet-root.golden.json index 66afa61f..b040aed0 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/fleet-root.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/fleet-root.golden.json @@ -11,16 +11,13 @@ "editable": 1, "nonKrm": 0 }, - "kinds": [ - { - "group": "apps", - "version": "v1", - "kind": "Deployment" + "renderedTypes": { + "byNamespace": { + "podinfo": [ + "apps/v1/Deployment" + ] } - ], - "namespaces": [ - "podinfo" - ] + } }, { "path": "clusters/prod", @@ -33,16 +30,13 @@ "editable": 1, "nonKrm": 0 }, - "kinds": [ - { - "group": "kustomize.toolkit.fluxcd.io", - "version": "v1", - "kind": "Kustomization" + "renderedTypes": { + "byNamespace": { + "flux-system": [ + "kustomize.toolkit.fluxcd.io/v1/Kustomization" + ] } - ], - "namespaces": [ - "flux-system" - ] + } }, { "path": "infra/ingress-nginx", @@ -55,16 +49,13 @@ "editable": 1, "nonKrm": 0 }, - "kinds": [ - { - "group": "apps", - "version": "v1", - "kind": "Deployment" + "renderedTypes": { + "byNamespace": { + "infra-system": [ + "apps/v1/Deployment" + ] } - ], - "namespaces": [ - "infra-system" - ] + } } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/flux-helmrelease-values.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/flux-helmrelease-values.golden.json index f3416e96..f6739dd7 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/flux-helmrelease-values.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/flux-helmrelease-values.golden.json @@ -11,16 +11,13 @@ "editable": 1, "nonKrm": 0 }, - "kinds": [ - { - "group": "helm.toolkit.fluxcd.io", - "version": "v2", - "kind": "HelmRelease" + "renderedTypes": { + "byNamespace": { + "flux-system": [ + "helm.toolkit.fluxcd.io/v2/HelmRelease" + ] } - ], - "namespaces": [ - "flux-system" - ] + } } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/helm-release-document.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/helm-release-document.golden.json index f3416e96..f6739dd7 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/helm-release-document.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/helm-release-document.golden.json @@ -11,16 +11,13 @@ "editable": 1, "nonKrm": 0 }, - "kinds": [ - { - "group": "helm.toolkit.fluxcd.io", - "version": "v2", - "kind": "HelmRelease" + "renderedTypes": { + "byNamespace": { + "flux-system": [ + "helm.toolkit.fluxcd.io/v2/HelmRelease" + ] } - ], - "namespaces": [ - "flux-system" - ] + } } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/kustomize-single.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/kustomize-single.golden.json index b9b42f31..b86fab80 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/kustomize-single.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/kustomize-single.golden.json @@ -11,21 +11,14 @@ "editable": 2, "nonKrm": 0 }, - "kinds": [ - { - "group": "", - "version": "v1", - "kind": "Service" - }, - { - "group": "apps", - "version": "v1", - "kind": "Deployment" + "renderedTypes": { + "byNamespace": { + "demo": [ + "apps/v1/Deployment", + "v1/Service" + ] } - ], - "namespaces": [ - "demo" - ] + } } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/overlapping.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/overlapping.golden.json index f2a1fff0..66059273 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/overlapping.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/overlapping.golden.json @@ -11,21 +11,14 @@ "editable": 2, "nonKrm": 0 }, - "kinds": [ - { - "group": "", - "version": "v1", - "kind": "ConfigMap" - }, - { - "group": "apps", - "version": "v1", - "kind": "Deployment" + "renderedTypes": { + "byNamespace": { + "web": [ + "apps/v1/Deployment", + "v1/ConfigMap" + ] } - ], - "namespaces": [ - "web" - ], + }, "overlapsWith": [ "apps/web/extra" ] @@ -41,16 +34,13 @@ "editable": 1, "nonKrm": 0 }, - "kinds": [ - { - "group": "", - "version": "v1", - "kind": "ConfigMap" + "renderedTypes": { + "byNamespace": { + "web": [ + "v1/ConfigMap" + ] } - ], - "namespaces": [ - "web" - ], + }, "overlapsWith": [ "apps/web" ] diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base.golden.json index 59d8b69c..860d52cc 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base.golden.json @@ -14,21 +14,14 @@ "editable": 0, "nonKrm": 0 }, - "kinds": [ - { - "group": "", - "version": "v1", - "kind": "ConfigMap" - }, - { - "group": "apps", - "version": "v1", - "kind": "Deployment" + "renderedTypes": { + "byNamespace": { + "nested-test": [ + "apps/v1/Deployment", + "v1/ConfigMap" + ] } - ], - "namespaces": [ - "nested-test" - ] + } } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base.golden.json index 225fdc7b..e8ff4ece 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base.golden.json @@ -14,16 +14,13 @@ "editable": 0, "nonKrm": 0 }, - "kinds": [ - { - "group": "apps", - "version": "v1", - "kind": "Deployment" + "renderedTypes": { + "byNamespace": { + "parked-test": [ + "apps/v1/Deployment" + ] } - ], - "namespaces": [ - "parked-test" - ] + } } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/plain-per-env.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/plain-per-env.golden.json index 401cd560..0be23b2a 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/plain-per-env.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/plain-per-env.golden.json @@ -11,21 +11,14 @@ "editable": 2, "nonKrm": 0 }, - "kinds": [ - { - "group": "", - "version": "v1", - "kind": "Service" - }, - { - "group": "apps", - "version": "v1", - "kind": "Deployment" + "renderedTypes": { + "byNamespace": { + "app-prod": [ + "apps/v1/Deployment", + "v1/Service" + ] } - ], - "namespaces": [ - "app-prod" - ] + } }, { "path": "apps/app/test", @@ -38,21 +31,14 @@ "editable": 2, "nonKrm": 0 }, - "kinds": [ - { - "group": "", - "version": "v1", - "kind": "Service" - }, - { - "group": "apps", - "version": "v1", - "kind": "Deployment" + "renderedTypes": { + "byNamespace": { + "app-test": [ + "apps/v1/Deployment", + "v1/Service" + ] } - ], - "namespaces": [ - "app-test" - ] + } } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/helm-inflation.golden.json b/internal/manifestanalyzer/testdata/scan-repo/unsupported/helm-inflation.golden.json index 447e67fc..fcddefbf 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/unsupported/helm-inflation.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/unsupported/helm-inflation.golden.json @@ -16,7 +16,8 @@ "rendered": 0, "editable": 0, "nonKrm": 0 - } + }, + "renderedTypes": {} } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json b/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json index c5d37323..b3e9a74b 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/unsupported/plain-nonkrm.golden.json @@ -19,16 +19,13 @@ "editable": 1, "nonKrm": 1 }, - "kinds": [ - { - "group": "apps", - "version": "v1", - "kind": "Deployment" + "renderedTypes": { + "byNamespace": { + "demo": [ + "apps/v1/Deployment" + ] } - ], - "namespaces": [ - "demo" - ] + } } ], "summary": { diff --git a/internal/manifestanalyzer/testdata/scan-repo/unsupported/unsupported-kustomize.golden.json b/internal/manifestanalyzer/testdata/scan-repo/unsupported/unsupported-kustomize.golden.json index d8fdc85a..62a438a2 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/unsupported/unsupported-kustomize.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/unsupported/unsupported-kustomize.golden.json @@ -16,7 +16,8 @@ "rendered": 1, "editable": 1, "nonKrm": 0 - } + }, + "renderedTypes": {} } ], "summary": { diff --git a/pkg/manifestanalyzer/repo.go b/pkg/manifestanalyzer/repo.go index 850df2e7..0a24251a 100644 --- a/pkg/manifestanalyzer/repo.go +++ b/pkg/manifestanalyzer/repo.go @@ -75,15 +75,44 @@ type ResourceCounts struct { NonKRM int `json:"nonKrm"` } -// GroupVersionKind names one Kubernetes type. Group is empty for core types, so a -// ConfigMap is {"", "v1", "ConfigMap"}. -type GroupVersionKind struct { - Group string `json:"group"` - Version string `json:"version"` - Kind string `json:"kind"` +// RenderedTypes says which type a folder renders into which namespace. It is expressed as +// a map rather than two lists because the PAIRING is the answer: a set of types beside a +// set of namespaces reads as every combination of the two, and a folder rendering a +// Deployment into frontend and a Service into backend would then describe two pairs that +// exist in no repository — enough to authorize a watch that matches nothing. +// +// Every type is a canonical GVK string: "group/version/kind", or "version/kind" for the +// core group. Split on "/" and count the segments; a group never contains one. +// +// apps/v1/Deployment +// v1/ConfigMap +// rbac.authorization.k8s.io/v1/ClusterRole +type RenderedTypes struct { + // ByNamespace lists the types that land in each namespace, sorted, keyed by namespace. + // These are the exact (type, namespace) pairs, and the only ones. + ByNamespace map[string][]string `json:"byNamespace,omitempty"` + + // ClusterScoped lists the types that take no namespace because the API says they take + // none. + // + // It is NEVER present today, deliberately: deciding that a type is cluster-scoped + // requires API discovery, and this scan has none. An empty list would read as "this + // folder has no cluster-scoped types", which is a claim the scan cannot make — those + // types are in [RenderedTypes.NamespaceUndeclared] instead. A discovery-aware scan can + // fill this and shrink that list accordingly, which is additive. + ClusterScoped []string `json:"clusterScoped,omitempty"` + + // NamespaceUndeclared lists the types that render WITHOUT a namespace, sorted. Today + // that is two facts this scan cannot tell apart: a cluster-scoped type, and a + // namespaced type relying on whatever namespace the applier defaults to. Treat it as + // "we do not know where these land", not as "these are cluster-scoped". + // + // A type can appear here AND under ByNamespace. Two ConfigMaps, one carrying a + // namespace and one not, is an ordinary folder rather than a contradiction. + NamespaceUndeclared []string `json:"namespaceUndeclared,omitempty"` } -// Candidate is one folder that could become a GitTarget. +// Candidate is one folder that could become a GitTarget.// Candidate is one folder that could become a GitTarget. type Candidate struct { // Path is slash-separated and relative to the repository root. Path string `json:"path"` @@ -99,24 +128,22 @@ type Candidate struct { // InferredNamespace is the namespace the candidate resolves to, when unambiguous. InferredNamespace string `json:"inferredNamespace,omitempty"` Resources ResourceCounts `json:"resources"` - // Kinds is every distinct type this folder RENDERS, sorted by group, version, kind. - // For a render root it comes off a real kustomize build, so it includes what the - // folder pulls from a base outside its own subtree; for a plain folder it is the - // documents themselves. Empty when the folder renders nothing, or when it is a render - // root kustomize could not build — there is no honest answer in that case. + // RenderedTypes says which type this folder renders into which namespace. For a render + // root it is read off a real kustomize build, so it covers what the folder pulls from a + // base outside its own subtree and has the namespace transformer already applied; for a + // plain folder the documents are the render. // - // It is the set that must already be served wherever this folder is applied. A type - // the destination does not serve does not degrade: the apply fails with "no matches - // for kind" and waits for the next resync. - Kinds []GroupVersionKind `json:"kinds,omitempty"` - // Namespaces is every distinct namespace this folder's objects LAND IN, sorted — for a - // render root the namespace transformer applied, not what the files happen to say. - // Cluster-scoped objects contribute nothing rather than an empty string. + // It answers the two questions that gate provisioning from a folder scan, and both gate + // a step that is not cheap to undo. The schemas have to be served wherever this is + // applied — a type the destination does not serve does not degrade, the apply fails + // with "no matches for kind" and waits for the next resync. And naming a GitTarget's + // allowed source namespaces, or one watch rule per (type, namespace), needs the exact + // pairs: [Candidate.InferredNamespace] is one name, and a folder can render into + // several. // - // It is the plural of InferredNamespace and the honest one: a folder can render into - // several, and a tool that has to name them (on a GitTarget's allowed source - // namespaces, or one WatchRule rule per type and namespace) needs the whole set. - Namespaces []string `json:"namespaces,omitempty"` + // Everything is empty for a render root kustomize could not build. What it renders is + // not knowable, and saying nothing is the honest answer. + RenderedTypes RenderedTypes `json:"renderedTypes"` // OverlapsWith lists candidate paths this one nests with. Two overlapping candidates // can never both become GitTargets — a folder has exactly one owner. OverlapsWith []string `json:"overlapsWith,omitempty"` @@ -248,10 +275,11 @@ func candidateFrom(cand internalanalyzer.RepoCandidate) Candidate { NonKRM: cand.Resources.NonKRM, }, OverlapsWith: cand.OverlapsWith, - Namespaces: cand.Namespaces, - } - for _, gvk := range cand.Kinds { - out.Kinds = append(out.Kinds, GroupVersionKind{Group: gvk.Group, Version: gvk.Version, Kind: gvk.Kind}) + RenderedTypes: RenderedTypes{ + ByNamespace: cand.RenderedTypes.ByNamespace, + ClusterScoped: cand.RenderedTypes.ClusterScoped, + NamespaceUndeclared: cand.RenderedTypes.NamespaceUndeclared, + }, } for _, reason := range cand.RefusalReasons { out.RefusalReasons = append(out.RefusalReasons, RefusalReason{ diff --git a/pkg/manifestanalyzer/repo_test.go b/pkg/manifestanalyzer/repo_test.go index 6526db6f..e8a7874b 100644 --- a/pkg/manifestanalyzer/repo_test.go +++ b/pkg/manifestanalyzer/repo_test.go @@ -191,16 +191,15 @@ func TestScanRepo_CandidateReportsWhatItRenders(t *testing.T) { require.Equal(t, manifestanalyzer.LayoutKustomizeOverlay, cand.Layout) // The overlay's own subtree holds nothing but a kustomization; every type it - // renders comes from the base outside it. + // renders comes from the base outside it, and lands in the namespace the overlay's + // transformer supplies rather than the one the base files declare. require.Zero(t, cand.Resources.Editable, "a pure passthrough overlay owns no source") - require.Contains(t, cand.Kinds, - manifestanalyzer.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}) - require.Contains(t, cand.Kinds, - manifestanalyzer.GroupVersionKind{Group: "", Version: "v1", Kind: "Service"}) - - // The namespace is the overlay's transformer applied, which is also what - // InferredNamespace names — but Namespaces is the set, and the set is what a - // caller has to enumerate. - require.Equal(t, []string{cand.InferredNamespace}, cand.Namespaces) + require.Equal(t, + map[string][]string{cand.InferredNamespace: {"apps/v1/Deployment", "v1/Service"}}, + cand.RenderedTypes.ByNamespace, + "each type must stay paired with the namespace it actually lands in") + require.Empty(t, cand.RenderedTypes.NamespaceUndeclared) + require.Empty(t, cand.RenderedTypes.ClusterScoped, + "clusterScoped needs API discovery this scan does not have, so it stays absent") } } diff --git a/pkg/manifestanalyzer/testdata/repo-report.golden.yaml b/pkg/manifestanalyzer/testdata/repo-report.golden.yaml index 778e8226..c0a84957 100644 --- a/pkg/manifestanalyzer/testdata/repo-report.golden.yaml +++ b/pkg/manifestanalyzer/testdata/repo-report.golden.yaml @@ -7,13 +7,7 @@ status: candidates: - acceptedByOperator: false inferredNamespace: demo - kinds: - - group: apps - kind: Deployment - version: v1 layout: plain - namespaces: - - demo path: app refusalReasons: - actor: repository-author @@ -21,6 +15,10 @@ status: detail: 'values.yaml: YAML is not a Kubernetes manifest' solvable: true renderRoot: false + renderedTypes: + byNamespace: + demo: + - apps/v1/Deployment resources: editable: 1 nonKrm: 1 From 0f816d415c2b1a22936dd1e75cee83fc17e50237 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 27 Jul 2026 12:10:45 +0000 Subject: [PATCH 10/15] refactor(manifestanalyzer): drop the clusterScoped key nothing can fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was reserved for a discovery-aware scan that does not exist, and omitempty meant it never appeared in a document — so the Go API and the wire disagreed about what the report contains, which is a worse thing to publish than a missing name. Adding it later is a new field and additive, and by then we may want a different shape than a name chosen now for something nobody has designed. The limitation it was standing in for is stated where a reader actually is: on namespaceUndeclared, which is named for what was observed rather than for what might be inferred from it. Co-Authored-By: Claude Opus 5 --- .../repo-discovery-and-onboarding-scan.md | 11 +++++----- .../manifestanalyzer/render_inventory_test.go | 5 ++--- internal/manifestanalyzer/scan_repo.go | 17 +++++---------- pkg/manifestanalyzer/repo.go | 21 +++++++------------ pkg/manifestanalyzer/repo_test.go | 2 -- 5 files changed, 19 insertions(+), 37 deletions(-) diff --git a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md index 8f66ee75..c75f7c89 100644 --- a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md +++ b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md @@ -292,12 +292,11 @@ in `spec` (`root`, `mode`) and everything found in `status` (`generator`, `candi spelling `summary.byGvk` already uses. **`namespaceUndeclared` is not "cluster-scoped".** It is every type that renders WITHOUT a - namespace, which today is two facts this scan cannot separate: a genuinely cluster-scoped - type, and a namespaced type relying on whatever the applier defaults to. Deciding between - them needs API discovery, and a structure-only scan has none. A `clusterScoped` key is - reserved for the discovery-aware scan that can fill it honestly; until then it is **absent - rather than empty**, because an empty list would read as "this folder has no cluster-scoped - types" — a claim the scan cannot make. + namespace, which is two facts this scan cannot separate: a genuinely cluster-scoped type, + and a namespaced type relying on whatever the applier defaults to. Deciding between them + needs API discovery, and a structure-only scan has none, so the honest reading is "we do + not know where these land". A scan that does have discovery can split them — in a field + added at that point, not a name reserved now for a shape nobody has designed. These gate steps that are not cheap to undo. A type the destination does not serve fails the forward apply with `no matches for kind` and waits for the next resync rather than diff --git a/internal/manifestanalyzer/render_inventory_test.go b/internal/manifestanalyzer/render_inventory_test.go index c751b8c2..98f18f58 100644 --- a/internal/manifestanalyzer/render_inventory_test.go +++ b/internal/manifestanalyzer/render_inventory_test.go @@ -100,11 +100,10 @@ func TestRenderInventory_CoversTheBaseOutsideTheSubtree(t *testing.T) { }) // The ClusterRole renders with no namespace, and this scan cannot say WHY: it is // cluster-scoped, but proving that needs API discovery the scan does not have. So it - // goes to namespaceUndeclared, and clusterScoped stays absent rather than claiming - // there are none. + // goes to namespaceUndeclared, which is named for what was observed rather than for + // what a reader might infer. requireStrings(t, "namespaceUndeclared", cand.RenderedTypes.NamespaceUndeclared, []string{"rbac.authorization.k8s.io/v1/ClusterRole"}) - requireStrings(t, "clusterScoped", cand.RenderedTypes.ClusterScoped, nil) } // TestRenderInventory_ATypeCanBeBothNamespacedAndNot covers the shape that looks like a diff --git a/internal/manifestanalyzer/scan_repo.go b/internal/manifestanalyzer/scan_repo.go index 9c50c0c8..c763c9b3 100644 --- a/internal/manifestanalyzer/scan_repo.go +++ b/internal/manifestanalyzer/scan_repo.go @@ -99,19 +99,12 @@ type RenderedTypes struct { // ByNamespace lists the types that land in each namespace, sorted, keyed by namespace. ByNamespace map[string][]string `json:"byNamespace,omitempty"` - // ClusterScoped lists the types that take no namespace because the API says they take - // none. + // NamespaceUndeclared lists the types that render WITHOUT a namespace, sorted. // - // It is NEVER populated today, and is deliberately absent rather than empty: deciding - // that a type is cluster-scoped requires API discovery, and this scan has none. An - // empty list would read as "this folder has no cluster-scoped types", which is a claim - // the scan cannot make — the ClusterRole is in NamespaceUndeclared instead. A - // discovery-aware scan can fill this and shrink NamespaceUndeclared accordingly. - ClusterScoped []string `json:"clusterScoped,omitempty"` - - // NamespaceUndeclared lists the types that render WITHOUT a namespace, sorted. Today - // that is two different facts the scan cannot tell apart: a cluster-scoped type, and a - // namespaced type relying on whatever namespace the applier defaults to. + // It is NOT a list of cluster-scoped types. It holds two facts this scan cannot tell + // apart: a genuinely cluster-scoped type, and a namespaced type relying on whatever + // namespace the applier defaults to. Separating them needs API discovery, which a + // structure-only scan does not have. // // A type can appear here AND under ByNamespace. Two ConfigMaps, one carrying a // namespace and one not, is an ordinary folder, not a contradiction. diff --git a/pkg/manifestanalyzer/repo.go b/pkg/manifestanalyzer/repo.go index 0a24251a..b9936525 100644 --- a/pkg/manifestanalyzer/repo.go +++ b/pkg/manifestanalyzer/repo.go @@ -92,20 +92,14 @@ type RenderedTypes struct { // These are the exact (type, namespace) pairs, and the only ones. ByNamespace map[string][]string `json:"byNamespace,omitempty"` - // ClusterScoped lists the types that take no namespace because the API says they take - // none. + // NamespaceUndeclared lists the types that render WITHOUT a namespace, sorted. // - // It is NEVER present today, deliberately: deciding that a type is cluster-scoped - // requires API discovery, and this scan has none. An empty list would read as "this - // folder has no cluster-scoped types", which is a claim the scan cannot make — those - // types are in [RenderedTypes.NamespaceUndeclared] instead. A discovery-aware scan can - // fill this and shrink that list accordingly, which is additive. - ClusterScoped []string `json:"clusterScoped,omitempty"` - - // NamespaceUndeclared lists the types that render WITHOUT a namespace, sorted. Today - // that is two facts this scan cannot tell apart: a cluster-scoped type, and a - // namespaced type relying on whatever namespace the applier defaults to. Treat it as - // "we do not know where these land", not as "these are cluster-scoped". + // It is NOT a list of cluster-scoped types, and must not be read as one. It holds two + // facts this scan cannot tell apart: a genuinely cluster-scoped type, and a namespaced + // type relying on whatever namespace the applier defaults to. Separating them needs API + // discovery, and a structure-only scan has none — so the honest reading is "we do not + // know where these land". A scan that does have discovery can split them, in a field + // added then rather than a name reserved now. // // A type can appear here AND under ByNamespace. Two ConfigMaps, one carrying a // namespace and one not, is an ordinary folder rather than a contradiction. @@ -277,7 +271,6 @@ func candidateFrom(cand internalanalyzer.RepoCandidate) Candidate { OverlapsWith: cand.OverlapsWith, RenderedTypes: RenderedTypes{ ByNamespace: cand.RenderedTypes.ByNamespace, - ClusterScoped: cand.RenderedTypes.ClusterScoped, NamespaceUndeclared: cand.RenderedTypes.NamespaceUndeclared, }, } diff --git a/pkg/manifestanalyzer/repo_test.go b/pkg/manifestanalyzer/repo_test.go index e8a7874b..514489cb 100644 --- a/pkg/manifestanalyzer/repo_test.go +++ b/pkg/manifestanalyzer/repo_test.go @@ -199,7 +199,5 @@ func TestScanRepo_CandidateReportsWhatItRenders(t *testing.T) { cand.RenderedTypes.ByNamespace, "each type must stay paired with the namespace it actually lands in") require.Empty(t, cand.RenderedTypes.NamespaceUndeclared) - require.Empty(t, cand.RenderedTypes.ClusterScoped, - "clusterScoped needs API discovery this scan does not have, so it stays absent") } } From 27e04fcdab62d10eeb5aa9363b3ee7659652d5d8 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 27 Jul 2026 12:44:11 +0000 Subject: [PATCH 11/15] docs: the design record shows the type the contract actually publishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shape block still declared RefusalReason.Code as string while the published type is IssueKind — the one thing in that block a consumer would copy verbatim, since mirroring our structs is the design the downstream tool chose. Co-Authored-By: Claude Opus 5 --- docs/design/analyzer-consumer-contract-asks.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/design/analyzer-consumer-contract-asks.md b/docs/design/analyzer-consumer-contract-asks.md index 32244d9b..6235b56e 100644 --- a/docs/design/analyzer-consumer-contract-asks.md +++ b/docs/design/analyzer-consumer-contract-asks.md @@ -152,8 +152,10 @@ const ( ) type RefusalReason struct { - Code string `json:"code"` - Detail string `json:"detail"` + // Code is an IssueKind value, or ReasonRefusedStructural. The type makes the + // relationship compile-checked rather than merely stated. + Code IssueKind `json:"code"` + Detail string `json:"detail"` // Solvable says whether anyone can make this folder acceptable with THIS release. // Decided by the check that raised the refusal, because only that check knows. Solvable bool `json:"solvable"` From bd0166f5413bb64d57b7475d3ce9086b75ef8a52 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 27 Jul 2026 12:48:26 +0000 Subject: [PATCH 12/15] docs: retire the analyzer-asks brief to finished/, now that it shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It was an implementation brief written against v0.39.1 and read as pending work in docs/design/, where the index lists documents by their OPEN question. It has none left. INDEX draws the line for this repo: prose about code that no longer exists gets deleted, and the rest is kept for why-is-it-like-this. The code this argues about is exactly what shipped, and the reasoning is not reconstructible from it — why the field is a boolean rather than the enum this document proposed, why the report carries no metadata, why classification sits at the raise site, why both attestation and a checksums file. So it moves rather than goes. Annotated, not rewritten. A finished document's value is the record, including the four-value shape that was overturned, so the header says what shipped and where each live definition now is, and leaves the argument intact underneath. The three questions it left open are answered in place. Dropped only the downstream team's stopgap, which described their tree at a moment that has passed. Also: the older UPGRADING entry that introduced schemaVersion now says it was replaced, so a reader landing there is not told to read a field that is gone. Co-Authored-By: Claude Opus 5 --- docs/INDEX.md | 9 ++- docs/UPGRADING.md | 5 +- .../repo-discovery-and-onboarding-scan.md | 2 +- .../analyzer-consumer-contract-asks.md | 63 ++++++++++--------- internal/git/write_refusal_solvable_test.go | 2 +- internal/manifestanalyzer/solvable_test.go | 2 +- pkg/manifestanalyzer/contract_test.go | 2 +- 7 files changed, 47 insertions(+), 38 deletions(-) rename docs/{design => finished}/analyzer-consumer-contract-asks.md (92%) diff --git a/docs/INDEX.md b/docs/INDEX.md index 835aa331..6e9c88db 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -71,7 +71,6 @@ Thirteen other open items: |---|---| | [`docs-linting.md`](design/docs-linting.md) | how to mechanize [`style-guide.md`](style-guide.md) with markdownlint-cli2 and Vale. Both are wired into `task lint`, gated on the files [`.docs-lint-scope`](../.docs-lint-scope) lists rather than the whole tree: 102 of 174 files fail markdownlint and 148 of 174 fail Vale, so the two backlogs need different gates. Open: how the scope list grows to cover the tree, the `MD013` limit, and whether `AGENTS.md` and the chart READMEs are in scope | | [`attribution-fact-identity.md`](design/attribution-fact-identity.md) | several `ClusterProvider`s may name one physical cluster, but a kube-apiserver posts audit to one route, so only one of those names is ever fed and every other one authors `unknown (attribution unresolved)`. Proposes a declared `spec.attribution.auditRoute` that partitions the facts instead of `metadata.name`, so several providers can share one cluster's facts while cloned clusters stay separate, ingestion loses its last Kubernetes read, and a misrouted provider becomes loud. Renames the key infix and the annotation-key flag to the same word | -| [`analyzer-consumer-contract-asks.md`](design/analyzer-consumer-contract-asks.md) | three asks from the first downstream consumer of `pkg/manifestanalyzer`, each the same failure at a different layer — a distinction our code knows, published as prose no test defends. a `solvable` flag on a `RefusalReason` (they shipped "not supported yet" for `invalid-yaml`, because one deprecated code was the only signal the type offered); the analyzer's version in its own JSON report, plus a signed release binary; and `ResourceIdentifier.Key()`'s string format as a documented contract with a golden test — it has **no test at all** today. Carries our classification of all seventeen internal `IssueKind` values (the public package exports only fourteen, and three codes can reach a consumer with no constant to match on); open: the per-construct split for `unsupported-kustomize`, and whether `Key()` or `ToGitPath()` is the identity when a preferred version bumps | | [`watch-and-catalog-architecture.md`](design/watch-and-catalog-architecture.md) | the target three-layer watch model — **needs a human call before building** | | [`metrics-observability-plan.md`](design/metrics-observability-plan.md) | the watch-stage metrics do not exist yet | | [`reconcile-triggering.md`](design/reconcile-triggering.md) | which controllers still fail to wake up | @@ -100,10 +99,16 @@ Five more ideas sit beside them. ## History — [`finished/`](finished/) -Twenty-two shipped plans and closed investigations. **Nothing here binds.** Read one +Twenty-one shipped plans and closed investigations. **Nothing here binds.** Read one only when you want to know *why* something is the way it is; the answer to *what it is* always lives in `spec/`. +The newest is +[`analyzer-consumer-contract-asks.md`](finished/analyzer-consumer-contract-asks.md): why a +refusal carries whether it can be solved and by whom, why the analyzer's report is a KRM +document that names the build that produced it, and why `ResourceIdentifier.Key()` is a +cross-product contract with a golden test. Shipped as #273 and #275. + Note that most of the pre-2026-07 audit-pipeline archaeology has been deleted outright: the watch-first rewrite removed `internal/gate`, the audit joiner, and the audit-as-state pipeline, so ~30 documents describing them were prose about diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 0b737cc1..a804ec65 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -599,7 +599,10 @@ nouns — **folder** and **repo**. `repo-walker` named an internal traversal phase rather than a contract, and a bare `scan` was asymmetric once a repo-level scan existed. `--mode analyze` and `--mode discovery` are unchanged. -The JSON documents also gained a `schemaVersion` field, and one field was dropped: +The JSON documents also gained a `schemaVersion` field, and one field was dropped. +(`schemaVersion` is itself gone in a later release — see "The analyzer report is a KRM +document" above, which replaces it with `apiVersion`. Read this entry as the history of a +release you are upgrading *across*, not as current advice.) - `--mode scan-folder --format json` no longer carries `plan`. In folder-scan mode the analyzer has no cluster state and no desired resources, so `plan` was structurally always diff --git a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md index c75f7c89..4a6f5907 100644 --- a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md +++ b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md @@ -309,7 +309,7 @@ in `spec` (`root`, `mode`) and everything found in `status` (`generator`, `candi branch: `unsupported-kustomize` is solvable for a build file the author broke and not for a generator or a remote base. It describes the release you are running and promises nothing about the future. See - [analyzer-consumer-contract-asks.md](../analyzer-consumer-contract-asks.md). + [analyzer-consumer-contract-asks.md](../../finished/analyzer-consumer-contract-asks.md). - **`status.generator`** names the build that produced the report, so a document that outlives the process that made it still says which release decided its contents. diff --git a/docs/design/analyzer-consumer-contract-asks.md b/docs/finished/analyzer-consumer-contract-asks.md similarity index 92% rename from docs/design/analyzer-consumer-contract-asks.md rename to docs/finished/analyzer-consumer-contract-asks.md index 6235b56e..cd729177 100644 --- a/docs/design/analyzer-consumer-contract-asks.md +++ b/docs/finished/analyzer-consumer-contract-asks.md @@ -1,30 +1,37 @@ # Three asks from a downstream consumer of the analyzer -> Status: accepted as work, scheduled as three independent PRs. Written for an implementing -> party — every claim below was checked against the tree at `v0.39.1`. -> Date: 2026-07-26, decisions recorded 2026-07-27. -> Companion to [config-surface-for-a-structured-repository.md](../future/config-surface-for-a-structured-repository.md), -> which makes the same argument one layer up: the engine learned something and the -> contract did not carry it. - -**Two decisions are settled and are not open in implementation:** - -1. **The KRM envelope is adopted** (Ask 2). `apiVersion`/`kind` replaces `schemaVersion`, - `spec` carries the scan request and `status` the findings. This is a deliberate breaking - change to the JSON contract, taken now because there are two consumers and both are - asking for version clarity in this same document. -2. **The three unclassified refusal codes are decided during implementation** (Ask 1). The - implementing session proposes an answer per *raise site* from the code, and raises each - as a review comment on its PR for the maintainer to accept or overturn. They ship - decided, and they do not block the rest of Ask 1. - -3. **The field is `Solvable`, a boolean** (Ask 1, decided 2026-07-27, overturning the - four-value enum proposed further down this document). It answers one question in words - a non-native speaker reads without a glossary: *can this refusal be solved?* `Actor` - names who, when someone can. See "The shape". - -Each ask is one PR, in the order below — Ask 3 is unblocked, Ask 1 is scaffolding plus -fourteen settled classifications, Ask 2 carries the breaking change. +> **finished** — shipped or closed. Kept for context only; **nothing here binds**. For current behaviour see [`../spec/`](../spec/). Index: [`../INDEX.md`](../INDEX.md) + +> Shipped 2026-07-27: Ask 3 as [#273](https://github.com/ConfigButler/gitops-reverser/pull/273), +> Asks 1 and 2 as [#275](https://github.com/ConfigButler/gitops-reverser/pull/275). +> Written 2026-07-26 against the tree at `v0.39.1`, for an implementing party — so it reads +> as instructions, and the line numbers and counts it cites are of that tree, not this one. + +## What shipped, and where the live answer is now + +Read this document for *why*. For *what*, read the code: the reasoning below moved into doc +comments and tests, which is the whole point it was arguing for. + +| This document proposed | What shipped | Live definition | +|---|---|---| +| A permanence enum on `RefusalReason` | `solvable bool` + `actor`, decided at the raise site | [`pkg/manifestanalyzer/repo.go`](../../pkg/manifestanalyzer/repo.go) | +| Four values, keeping a *not-yet* axis | Two, then a plain boolean — see "A boolean, decided 2026-07-27" below | same | +| A classification table maintained here | A table checked against the SOURCE, so a new kind fails the build | [`solvable_test.go`](../../internal/manifestanalyzer/solvable_test.go) | +| A KRM envelope replacing `schemaVersion` | `apiVersion`/`kind`, `spec`/`status`, `status.generator`, `--version`, `--format yaml` | [`pkg/manifestanalyzer/contract.go`](../../pkg/manifestanalyzer/contract.go) | +| `Key()` documented and golden-tested | Both, plus the versionless-identity note | [`internal/types/identifier.go`](../../internal/types/identifier.go) | + +Three questions this document left open were answered during implementation, and the +answers are recorded in place below: `unsupported-kustomize` classifies per construct, +`unresolved-krm` splits on the registry's answer, and `kustomize-render-refused` is not +solvable. One row moved: `refused-structural` classifies from the same feature set as +`unsupported-kustomize`, so the two surfaces cannot disagree about one folder. + +The three write-time kinds it was unsure about — `kustomize-render-refused`, +`render-does-not-match-live`, `unplaceable-edit` — cannot reach `ScanRepo`, but are +published anyway: they reach a consumer through GitTarget status, where the same +match-on-our-constants problem applies. + +--- The asks come from **two independent consumer teams**, arriving separately and landing on the same seams. One links `pkg/manifestanalyzer` as a module and execs `manifest-analyzer @@ -313,12 +320,6 @@ solvable whenever the constructs are unknown. so "false" and "absent" must stay distinguishable on the wire even though Go cannot tell them apart. -### Meanwhile, downstream - -They have flipped their default from "not supported yet" to unknown — an unrecognised code -now says only "this folder cannot be picked" — and deleted their `Permanent bool`. That is -worse for their user than the truth and is the honest maximum from `{Code, Detail}`. - --- ## Ask 2 — a JSON document from a binary is a contract with no compile-time signal diff --git a/internal/git/write_refusal_solvable_test.go b/internal/git/write_refusal_solvable_test.go index 7da91b89..b9c5c2d8 100644 --- a/internal/git/write_refusal_solvable_test.go +++ b/internal/git/write_refusal_solvable_test.go @@ -17,7 +17,7 @@ import ( // carries. Classifying only the folder-level kinds would leave a consumer reading a // GitTarget's status with the same "not supported yet" guess the ask exists to kill. // -// See docs/design/analyzer-consumer-contract-asks.md (Ask 1). +// See docs/finished/analyzer-consumer-contract-asks.md (Ask 1). // refusalIssues unwraps an AcceptanceRefusedError and returns the issues it carries. func refusalIssues(t *testing.T, err error) []manifestanalyzer.AcceptanceIssue { diff --git a/internal/manifestanalyzer/solvable_test.go b/internal/manifestanalyzer/solvable_test.go index a733b31b..ba84decc 100644 --- a/internal/manifestanalyzer/solvable_test.go +++ b/internal/manifestanalyzer/solvable_test.go @@ -38,7 +38,7 @@ import ( // the table below is checked against the SOURCE — the kind list is the one place the // omission is still visible. // -// See docs/design/analyzer-consumer-contract-asks.md (Ask 1). +// See docs/finished/analyzer-consumer-contract-asks.md (Ask 1). // classificationByKind is the settled table, in the form this package can check. A kind // whose answer depends on the branch that raised it lists every branch it can produce, so diff --git a/pkg/manifestanalyzer/contract_test.go b/pkg/manifestanalyzer/contract_test.go index 8e17ae1b..fdd0dd3c 100644 --- a/pkg/manifestanalyzer/contract_test.go +++ b/pkg/manifestanalyzer/contract_test.go @@ -27,7 +27,7 @@ import ( // source, because a constant cannot be enumerated by reflection and a hand-kept list is // exactly what drifted. // -// See docs/design/analyzer-consumer-contract-asks.md (Ask 1). +// See docs/finished/analyzer-consumer-contract-asks.md (Ask 1). func TestPublicIssueKindsCoverEveryEngineKind(t *testing.T) { t.Parallel() From ec66ea796ee00d821fd372a7289ecdfdd5700fea Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 27 Jul 2026 13:38:52 +0000 Subject: [PATCH 13/15] refactor(manifestanalyzer)!: delete the fleet-root flag, which was a guess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `summary.fleetRoot` required top-level clusters/ + apps/ + infra/, so it read false on the layout Flux's own docs and reference repository use (infrastructure/) and true on any repository that happens to use those three names. The better evidence this doc already prescribed — a flux-system/ directory holding gotk-sync.yaml — is still not enough, and that is why this is a deletion rather than a fix. Fleet-rootness is not a property of a repository: a directory is a cluster entry point because some cluster is POINTED at it, by a FluxInstance.spec.sync.path or a flux bootstrap somebody ran once. Bootstrap a cluster at clusters/staging in Flux's reference repo and nothing in the tree distinguishes it from clusters/production. The same repository is zero cluster entry points, one, or twelve, depending on who syncs it, and a read-only scan sees none of that. Nothing branched on it — isFleetRoot computed it, RepoSummary carried it, the public package copied it, and one line of text printed it — and the repo root is not offered as a candidate either way. Every one of the 19 corpus fixtures reported false, so the regenerated baseline loses only that line. The test that read as though it enforced something asserted a coincidence: the root is not a candidate because it holds no documents of its own, which is what it now says. A field that can only ever be a guess invites consumers to branch on it, and they would be wrong on repositories no test here ever sees. The question returns as NotAnEditingTarget, decided from the documents a folder holds. BREAKING CHANGE: `manifest-analyzer --mode scan-repo` no longer emits status.summary.fleetRoot, the text report no longer prints fleet-root=true, and pkg/manifestanalyzer's RepoSummary has no FleetRoot field. There is no replacement key; the field never changed which folders the scan offers. Co-Authored-By: Claude Opus 5 (1M context) --- docs/UPGRADING.md | 24 ++++++ .../orchestrator-knowledge-boundary.md | 75 ++++++++++++------- .../repo-discovery-and-onboarding-scan.md | 25 ++++--- hack/gitops-layouts-baseline.sh | 5 +- internal/manifestanalyzer/scan_repo.go | 39 +--------- .../supported/fleet-root.golden.json | 3 +- pkg/manifestanalyzer/repo.go | 4 - pkg/manifestanalyzer/repo_test.go | 7 +- test/fixtures/gitops-layouts/support-today.md | 38 +++++----- 9 files changed, 120 insertions(+), 100 deletions(-) diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index a804ec65..073784ab 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -7,6 +7,30 @@ guidance that the changelog's breaking-change entries link to. We are pre-1.0, so breaking changes bump the **minor** version (release-please is configured with `bump-minor-pre-major`) rather than the major. Read the relevant entry before upgrading across it. +## `summary.fleetRoot` is gone from the analyzer report (breaking JSON change) + +`manifest-analyzer --mode scan-repo` no longer emits `status.summary.fleetRoot`, the text +report no longer prints `fleet-root=true`, and `pkg/manifestanalyzer`'s `RepoSummary` no +longer has a `FleetRoot` field. A reader that indexed the key gets nothing back; there is no +replacement key to switch to, and none is needed — the field never changed which folders the +scan offers. + +It was a guess, and not one a better heuristic could rescue. It required top-level +`clusters/` + `apps/` + `infra/` directories, so it read `false` on the layout Flux's own +documentation and reference repository use (`infrastructure/`) and `true` on any repository +that happens to use those three names. The deeper problem is that fleet-rootness is not a +property of a repository at all: a directory is a cluster entry point because some cluster is +*pointed at* it — a `FluxInstance.spec.sync.path`, a `flux bootstrap` somebody ran once — and +a read-only scan of the tree cannot see that. The same repository is zero cluster entry +points, one, or twelve, depending on who syncs it. + +Nothing branched on it, here or downstream, and the repository root is not offered as a +candidate with or without it. When the question needs answering — *is this folder somebody's +bootstrap directory rather than their app?* — the answer will come from the documents a +candidate folder holds (a `FluxInstance`, a `flux-system` bootstrap `Kustomization`), which is +decidable rather than guessed. See +[orchestrator-knowledge-boundary.md](design/support-boundary/orchestrator-knowledge-boundary.md). + ## The analyzer report is a KRM document (breaking JSON change) The two published scan modes (`manifest-analyzer --mode scan-folder|scan-repo --format json`) diff --git a/docs/design/support-boundary/orchestrator-knowledge-boundary.md b/docs/design/support-boundary/orchestrator-knowledge-boundary.md index 5a2601ad..8e50dc55 100644 --- a/docs/design/support-boundary/orchestrator-knowledge-boundary.md +++ b/docs/design/support-boundary/orchestrator-knowledge-boundary.md @@ -86,13 +86,20 @@ with no seam between them: | Site | Shape of the knowledge | |---|---| | [`internal/sanitize/types.go`](../../../internal/sanitize/types.go) | Hardcoded label/annotation prefixes (`kustomize.toolkit.fluxcd.io/`, `kro.run/`, `applyset.*`) stripped before a document reaches Git. | -| [`internal/manifestanalyzer/scan_repo.go`](../../../internal/manifestanalyzer/scan_repo.go) — `isFleetRoot` | Three directory names: `clusters`, `apps`, `infra`. | +| ~~`internal/manifestanalyzer/scan_repo.go` — `isFleetRoot`~~ | Three directory names: `clusters`, `apps`, `infra`. **Deleted**, see below. | -Neither is wrong to exist. Both are in the wrong place, and the second one is -wrong on its own terms. +Neither was wrong to exist. Both were in the wrong place, and the second one was +wrong on its own terms — enough so that it is now gone rather than relocated. ## The specimen: `fleetRoot` +> **Resolved by deletion.** `isFleetRoot`, `RepoSummary.FleetRoot`, and the +> `fleet-root=true` line in the text report are gone; the JSON contract no longer +> carries `summary.fleetRoot`. The rest of this section is the reasoning that got +> it there, kept because the *concept* still needs a home — see +> [The claim vocabulary](#the-claim-vocabulary) — and because the last bullet +> below is why no better heuristic was substituted. + `fleetRoot` is worth dwelling on, because it shows precisely how this knowledge leaks when it has nowhere principled to go. @@ -119,6 +126,25 @@ Argo CD's equivalent concept is not a directory at all. It is a *document*: a root `Application` (app-of-apps) or an `ApplicationSet`. No directory-name heuristic can ever see it. +And the deeper reason a better heuristic was not substituted: **fleet-rootness is +not a property of a repository at all — it is a property of a (cluster, repo) +pair.** A directory is a fleet root because some cluster is *pointed at* it, by a +`FluxInstance.spec.sync.path` or a `flux bootstrap` somebody ran once. Bootstrap a +cluster at `clusters/staging` in Flux's own reference repository and nothing in +the tree distinguishes it from `clusters/production`; both are equally shaped, and +neither is a fleet root until a cluster claims one. The same repository is zero +fleet roots, one, or twelve, depending on who syncs it, and a structure-only scan +sees none of that. So the honest choice was not a better guess but no guess: a +field that can only ever be a guess invites consumers to branch on it, and they +would be wrong on repositories no test here ever sees. + +Nothing read it, which is why deleting it broke nobody: `isFleetRoot` computed it, +`RepoSummary` carried it, `pkg/manifestanalyzer` copied it, and `RenderRepoText` +printed one line. No code branched on it, in this repository or in the consumer +`pkg/manifestanalyzer` was exported for. The repo root is not offered as a +candidate with or without the field — nothing filters candidates by it — so the +deletion changed exactly one line of text output and one key in the JSON. + ## The tiering Four tiers of knowledge. Only the last is the one to isolate. @@ -191,7 +217,9 @@ Renovate later means adding an interpreter, not touching the acceptance gate. The vocabulary is not invented — it is read straight off the corpus. `WrittenBy` is fixtures 16 and 05. `Generated` is 14. `TransformedOutOfBand` is 10. -`OverriddenBy` is 05 and 07. `NotAnEditingTarget` is the honest `fleetRoot`. +`OverriddenBy` is 05 and 07. `NotAnEditingTarget` is what `fleetRoot` was reaching +for, decided from documents rather than directory names — and it is now the only +place that question is answered, the guess having been deleted. Note that **encryption is Tier 0b, not Tier 2.** A SOPS document is self-describing: the `sops:` stanza is in the file. No orchestrator asserts it. @@ -252,30 +280,27 @@ through `ResourceCounts`, and stop counting non-editable documents as editable. This is a **contract** fix, not only a code fix: the field is exported through [`pkg/manifestanalyzer`](../../../pkg/manifestanalyzer). -### 2. `isFleetRoot` uses the wrong evidence - -Replace the `clusters` + `apps` + `infra` directory-name test with the real -fingerprint: a `flux-system/` directory containing `gotk-sync.yaml`. Until -Tier 2 exists, that is a one-function change that both fixes the false negative -on `09-flux-monorepo` and stops the false positives. - -### 3. A public field worth renaming early +### 2. `isFleetRoot` used the wrong evidence — shipped as a deletion -`RepoSummary.FleetRoot` is exported from -[`pkg/manifestanalyzer`](../../../pkg/manifestanalyzer). It should become something -honest — `clusterEntryPoints []string`, or a `NotAnEditingTarget` claim carrying a -reason. +This was written as *replace the `clusters` + `apps` + `infra` directory-name test +with the real fingerprint, a `flux-system/` directory containing `gotk-sync.yaml`*. +That is still the better evidence, and it is still not enough: a bootstrap +directory is a bootstrap directory only because a cluster points at it, which no +scan of the repository can see. So the field went rather than the heuristic. -That package used to promise that fields are added and never repurposed or removed. -It no longer does: the project is pre-1.0, and -[`doc.go`](../../../pkg/manifestanalyzer/doc.go) now says so plainly, so a rename -costs nothing on paper. +`isFleetRoot`, `RepoSummary.FleetRoot` (internal and in +[`pkg/manifestanalyzer`](../../../pkg/manifestanalyzer)), and the `fleet-root=true` +line in the text report are removed. The question returns as +`NotAnEditingTarget` when Tier 2 exists, decided from the documents a candidate +folder holds — a `FluxInstance`, or a `flux-system` bootstrap `Kustomization`. -The real clock is **adoption, not the doc comment**. Every consumer that reads -`fleetRoot` before the rename is a consumer that has to change. Nothing does yet. -The decision should be made before the write path hardens around the name. +The public field cost nothing to remove because nothing read it: the package is +pre-1.0 and [`doc.go`](../../../pkg/manifestanalyzer/doc.go) says so, and the one +consumer it was exported for had zero references to `fleetRoot`. The clock was +**adoption, not the doc comment** — the deletion happened while that was still +true. -### 4. Onboarding, not refusal, for foreign files +### 3. Onboarding, not refusal, for foreign files `01-argocd-plain` is refused because `ci-metadata.yaml` is non-KRM — a file the Argo CD `Application` beside it already excludes via `directory.exclude`. Every @@ -295,7 +320,7 @@ None of the following is built, and none is designed here beyond the shape above | Capability | Scope | |---|---| | **Ownership axis** | `Encrypted`/non-editable in `ResourceCounts`; a core write-gate that refuses a machine-owned or generated destination; `Generated` detection from headers and `config.kubernetes.io/origin`. Fixes the three false accepts. Its first API surface is [`EncryptedSecret`](write-only-encrypted-secrets.md). | -| **Orchestrator interpreters** | `internal/gitops` claim vocabulary + registry; `argocd` and `flux` interpreters; `fleetRoot` re-expressed as `NotAnEditingTarget`; role classification (workload / control-plane / generated / machine-written) in the repo scan report. | +| **Orchestrator interpreters** | `internal/gitops` claim vocabulary + registry; `argocd` and `flux` interpreters; the cluster-entry-point question answered as `NotAnEditingTarget` (the deleted `fleetRoot`'s only honest form); role classification (workload / control-plane / generated / machine-written) in the repo scan report. | | **Resource capability registry** | Tier 0b: four knobs, three classifiers ([design](resource-capability-model.md)). Carries the derived-object gate below. | | **Derived-object gate** | Never mirror an object carrying a controller `ownerReference` — it is a controller's output, not desired state. Live-state, Tier 0, no orchestrator knowledge. Today [`sanitize`](../../../internal/sanitize/sanitize.go) *deletes* that field without ever gating on it, so an ESO- or sealed-secrets-derived `Secret` would be committed as a second source of truth. See [sealed-secrets-and-external-secrets.md](sealed-secrets-and-external-secrets.md). | diff --git a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md index 4a6f5907..442d588e 100644 --- a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md +++ b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md @@ -145,13 +145,19 @@ runs the same adoption gate over the overlay's render scope rather than blanket- | `kustomize-single` | One render root; `namespace` + `resources`/`images`/`replicas` | ✅ supported today | | `kustomize-overlay` | Base + N overlay roots, one namespace per overlay | ✅ adopted today; the render scope passes the gate and `editable` shows how much the overlay owns (a pure passthrough overlay is adopted yet `editable: 0`). Base-owned fields and new overlay objects stay write-time-planned | | `refused-structural` | Helm inflation, generators with hash suffixes, `components`, `namePrefix`/`nameSuffix`, remote bases, `configurations`/`openapi`/`crds` | ⛔ permanent — the support contract | -| `refused-fleet-root` | `clusters/` + `apps/` + `infra/` cluster-root repo (a `GitTarget` points at an app subtree, never a cluster root) | ⛔ out of scope by design | +| `refused-fleet-root` | `clusters/` + `apps/` + `infra/` cluster-root repo (a `GitTarget` points at an app subtree, never a cluster root) | ⛔ never shipped as a layout, and the repo-level flag that stood in for it is gone | | `refused-out-of-band` | Namespace/transform injected outside the folder (Flux `postBuild`/`targetNamespace`, Argo Application-level overrides) | ⛔ permanent — round-trip cannot hold | > **Shipped reconciliation (first cut).** Two rows above are *not* per-candidate -> layouts in practice. `refused-fleet-root` is emitted as a **repo-level** signal -> (`summary.fleetRoot`), because the cluster root is never itself a candidate — leaf app -> folders still surface as normal candidates. `refused-out-of-band` is **not detected**: +> layouts in practice. `refused-fleet-root` shipped as a **repo-level** flag +> (`summary.fleetRoot`) and has since been **removed**: a cluster root is one because +> a cluster points at it, which no scan of the repository can see, so the flag was a +> directory-name guess nothing read. Nothing replaces it — the repo root is not a +> candidate either way, and leaf app folders surface as normal candidates. The honest +> form is a per-folder judgement from the documents it holds (a `FluxInstance`, a +> `flux-system` bootstrap `Kustomization`), designed as `NotAnEditingTarget` in +> [orchestrator-knowledge-boundary.md](orchestrator-knowledge-boundary.md). +> `refused-out-of-band` is **not detected**: > a structure-only folder walk cannot see a transform injected by a Flux `Kustomization` > or Argo `Application`. Those CRs are themselves KRM documents in the repo, so detecting > out-of-band overrides later means parsing the fleet CRs and cross-referencing the app @@ -203,7 +209,7 @@ comment — the shipped report is strict JSON.) `resources` replaces the earlier `documents: { krm, nonKrm }` sketch with the shipped `{ rendered, editable, nonKrm }` split (see [First cut](#first-cut-shipped-2026-07-09) for why rendered and editable diverge for overlays). Plus a repo-level summary: -`candidatesByLayout`, `accepted`/`refused` counts, `overlapConflicts`, `fleetRoot`, and +`candidatesByLayout`, `accepted`/`refused` counts, `overlapConflicts`, and `unsupportedConstructs` (so a caller can say "this repo uses Helm inflation in `infra/`, which the operator does not manage"). @@ -323,7 +329,7 @@ in `spec` (`root`, `mode`) and everything found in `status` (`generator`, `candi editable: 0`) — the overlay renders a base it cannot edit, which makes the overlay gap legible at a glance. - Repo summary: `candidatesByLayout`, `accepted`, `refused`, `overlapConflicts[]`, - `fleetRoot`, `unsupportedConstructs[]`. + `unsupportedConstructs[]`. - `proposedGitTarget` is **not** emitted yet — CR proposal is deferred. **Fidelity to the operator.** `acceptedByOperator` runs the live writer's own gate per @@ -345,9 +351,10 @@ refuses `openapi`/`crds` alongside `configurations`, matching the running the same gate the live writer runs over the overlay's **render scope** (the overlay subtree plus the exact base files its graph reaches), so the report cannot drift from the runtime; the former `overlay-fan-out-unsupported` reason is retired. -- `refused-fleet-root` ships as a repo-summary flag (`fleetRoot: true`), not a - per-candidate layout: the repo root is never itself a candidate, and leaf app folders - still surface normally. `refused-out-of-band` is not detected yet — a structure-only +- `refused-fleet-root` shipped as a repo-summary flag (`fleetRoot: true`) rather than a + per-candidate layout, and that flag has since been removed as an unreadable guess (see + the note above): the repo root is not a candidate regardless, and leaf app folders + surface normally. `refused-out-of-band` is not detected yet — a structure-only scan sees no out-of-band namespace/transform signal to refuse on. - Namespace inference is purely structural (no cluster): a render root's `namespace:` transformer, else the single explicit `metadata.namespace` on a plain folder's diff --git a/hack/gitops-layouts-baseline.sh b/hack/gitops-layouts-baseline.sh index d7274b45..8d4df580 100755 --- a/hack/gitops-layouts-baseline.sh +++ b/hack/gitops-layouts-baseline.sh @@ -67,16 +67,15 @@ emit_summary_row() { emit_detail() { local name="$1" rc="$2" json="$3" - local accepted refused constructs fleet + local accepted refused constructs accepted=$(jq -r '.status.summary.accepted // 0' <<<"$json") refused=$(jq -r '.status.summary.refused // 0' <<<"$json") constructs=$(jq -r "$MD_CELL"'(.status.summary.unsupportedConstructs // []) | join(", ") | md | if . == "" then "none" else . end' <<<"$json") - fleet=$(jq -r '.status.summary.fleetRoot // false' <<<"$json") printf '\n## %s\n\n' "$name" printf 'Reported rc `%s`. Accepted `%s`, refused `%s`.\n' "$rc" "$accepted" "$refused" - printf 'Unsupported constructs: `%s`. Fleet root: `%s`.\n\n' "$constructs" "$fleet" + printf 'Unsupported constructs: `%s`.\n\n' "$constructs" if [[ "$(jq -r '(.status.candidates // []) | length' <<<"$json")" == "0" ]]; then printf '_No candidate folders reported._\n' diff --git a/internal/manifestanalyzer/scan_repo.go b/internal/manifestanalyzer/scan_repo.go index c763c9b3..3753f1b5 100644 --- a/internal/manifestanalyzer/scan_repo.go +++ b/internal/manifestanalyzer/scan_repo.go @@ -181,10 +181,6 @@ type RepoSummary struct { Refused int `json:"refused"` // OverlapConflicts lists every nesting conflict between candidates. OverlapConflicts []OverlapConflict `json:"overlapConflicts,omitempty"` - // FleetRoot is true when the repo root is a cluster/fleet root (top-level clusters/ + - // apps/ + infra/): a GitTarget points at an app subtree, never such a root. The root - // is never itself a candidate; leaf folders still surface normally. - FleetRoot bool `json:"fleetRoot,omitempty"` // UnsupportedConstructs is the sorted, de-duplicated set of unsupported kustomize // features seen across refused-structural candidates, so a product can say "this repo // uses Helm inflation, which we don't manage". @@ -243,7 +239,7 @@ func scanRepoFS(ctx context.Context, fsys fs.FS) RepoReport { return RepoReport{ Candidates: candidates, - Summary: summarize(candidates, fsys, kusts), + Summary: summarize(candidates, kusts), } } @@ -776,11 +772,10 @@ func detectOverlaps(candidates []RepoCandidate) { } } -// summarize rolls the candidates up into the repo-level summary and adds the fleet-root -// signal read from the repo's top-level directories. Unsupported constructs are -// recomputed from each refused-structural candidate's kustomization bytes, so the +// summarize rolls the candidates up into the repo-level summary. Unsupported constructs +// are recomputed from each refused-structural candidate's kustomization bytes, so the // summary shares one source of truth with the per-candidate detail. -func summarize(candidates []RepoCandidate, fsys fs.FS, kusts map[string]*kustomizationDoc) RepoSummary { +func summarize(candidates []RepoCandidate, kusts map[string]*kustomizationDoc) RepoSummary { s := RepoSummary{CandidatesByLayout: map[Layout]int{}} constructs := map[string]struct{}{} for _, c := range candidates { @@ -810,32 +805,9 @@ func summarize(candidates []RepoCandidate, fsys fs.FS, kusts map[string]*kustomi } return s.OverlapConflicts[i].Descendant < s.OverlapConflicts[j].Descendant }) - s.FleetRoot = isFleetRoot(fsys) return s } -// isFleetRoot reports whether the repo root is a cluster/fleet root: top-level -// clusters/ + apps/ + infra/ directories. A GitTarget points at an app subtree, never -// such a root. -func isFleetRoot(fsys fs.FS) bool { - entries, err := fs.ReadDir(fsys, ".") - if err != nil { - return false - } - top := map[string]struct{}{} - for _, e := range entries { - if e.IsDir() { - top[e.Name()] = struct{}{} - } - } - for _, want := range []string{"clusters", "apps", "infra"} { - if _, ok := top[want]; !ok { - return false - } - } - return true -} - // kustomizationContentByDir maps each kustomization directory to its raw bytes, so the // refused-structural detail can name the specific unsupported features. func kustomizationContentByDir(scan FolderScan) map[string][]byte { @@ -897,9 +869,6 @@ func RenderRepoText(w io.Writer, rep RepoReport) { } } fmt.Fprintf(w, "summary: accepted=%d refused=%d", rep.Summary.Accepted, rep.Summary.Refused) - if rep.Summary.FleetRoot { - fmt.Fprint(w, " fleet-root=true") - } if len(rep.Summary.OverlapConflicts) > 0 { fmt.Fprintf(w, " overlap-conflicts=%d", len(rep.Summary.OverlapConflicts)) } diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/fleet-root.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/fleet-root.golden.json index b040aed0..6efdecd2 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/fleet-root.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/fleet-root.golden.json @@ -63,7 +63,6 @@ "plain": 3 }, "accepted": 3, - "refused": 0, - "fleetRoot": true + "refused": 0 } } diff --git a/pkg/manifestanalyzer/repo.go b/pkg/manifestanalyzer/repo.go index b9936525..c68f191f 100644 --- a/pkg/manifestanalyzer/repo.go +++ b/pkg/manifestanalyzer/repo.go @@ -155,9 +155,6 @@ type RepoSummary struct { Accepted int `json:"accepted"` Refused int `json:"refused"` OverlapConflicts []OverlapConflict `json:"overlapConflicts,omitempty"` - // FleetRoot reports that the repository root is a cluster/fleet root. A GitTarget - // points at an app subtree, never at such a root. - FleetRoot bool `json:"fleetRoot,omitempty"` // UnsupportedConstructs is the sorted, de-duplicated set of unsupported kustomize // features seen across refused candidates, so a tool can say "this repository uses // Helm inflation, which the operator does not manage". @@ -236,7 +233,6 @@ func repoReportFrom(rep internalanalyzer.RepoReport) RepoReport { CandidatesByLayout: make(map[Layout]int, len(rep.Summary.CandidatesByLayout)), Accepted: rep.Summary.Accepted, Refused: rep.Summary.Refused, - FleetRoot: rep.Summary.FleetRoot, UnsupportedConstructs: rep.Summary.UnsupportedConstructs, }, }, diff --git a/pkg/manifestanalyzer/repo_test.go b/pkg/manifestanalyzer/repo_test.go index 514489cb..cdd9486f 100644 --- a/pkg/manifestanalyzer/repo_test.go +++ b/pkg/manifestanalyzer/repo_test.go @@ -116,16 +116,17 @@ func TestScanRepo_ReportsOverlapConflicts(t *testing.T) { } } -func TestScanRepo_FleetRootIsNeverACandidate(t *testing.T) { +func TestScanRepo_LeafFoldersSurfaceUnderAClusterRootLayout(t *testing.T) { t.Parallel() root := fixture(t, "supported", "fleet-root") report, err := manifestanalyzer.ScanRepo(context.Background(), root) require.NoError(t, err) - require.True(t, report.Status.Summary.FleetRoot) + require.NotEmpty(t, report.Status.Candidates, "the leaf app folders are candidates like any other") for _, cand := range report.Status.Candidates { - require.NotEqual(t, ".", cand.Path, "a GitTarget points at an app subtree, never the fleet root") + require.NotEqual(t, ".", cand.Path, + "a folder holding no documents of its own is not a candidate, whatever its subtrees look like") } } diff --git a/test/fixtures/gitops-layouts/support-today.md b/test/fixtures/gitops-layouts/support-today.md index 9791f4ae..f9a66a81 100644 --- a/test/fixtures/gitops-layouts/support-today.md +++ b/test/fixtures/gitops-layouts/support-today.md @@ -50,7 +50,7 @@ Reading rules: ## 1-desired-state/argocd-app-of-apps Reported rc `0`. Accepted `4`, refused `0`. -Unsupported constructs: `none`. Fleet root: `false`. +Unsupported constructs: `none`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| @@ -62,7 +62,7 @@ Unsupported constructs: `none`. Fleet root: `false`. ## 1-desired-state/argocd-plain Reported rc `0`. Accepted `1`, refused `1`. -Unsupported constructs: `none`. Fleet root: `false`. +Unsupported constructs: `none`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| @@ -72,7 +72,7 @@ Unsupported constructs: `none`. Fleet root: `false`. ## 1-desired-state/flux-monorepo Reported rc `0`. Accepted `6`, refused `0`. -Unsupported constructs: `none`. Fleet root: `false`. +Unsupported constructs: `none`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| @@ -86,7 +86,7 @@ Unsupported constructs: `none`. Fleet root: `false`. ## 1-desired-state/repo-per-environment Reported rc `0`. Accepted `9`, refused `0`. -Unsupported constructs: `none`. Fleet root: `false`. +Unsupported constructs: `none`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| @@ -103,7 +103,7 @@ Unsupported constructs: `none`. Fleet root: `false`. ## 2-rendered/argocd-external-helm Reported rc `0`. Accepted `3`, refused `0`. -Unsupported constructs: `none`. Fleet root: `false`. +Unsupported constructs: `none`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| @@ -114,7 +114,7 @@ Unsupported constructs: `none`. Fleet root: `false`. ## 2-rendered/helm-chart Reported rc `0`. Accepted `1`, refused `0`. -Unsupported constructs: `none`. Fleet root: `false`. +Unsupported constructs: `none`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| @@ -123,7 +123,7 @@ Unsupported constructs: `none`. Fleet root: `false`. ## 2-rendered/helm-environment-values Reported rc `0`. Accepted `1`, refused `0`. -Unsupported constructs: `none`. Fleet root: `false`. +Unsupported constructs: `none`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| @@ -132,7 +132,7 @@ Unsupported constructs: `none`. Fleet root: `false`. ## 2-rendered/kustomize-overlay-minimal Reported rc `0`. Accepted `2`, refused `0`. -Unsupported constructs: `none`. Fleet root: `false`. +Unsupported constructs: `none`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| @@ -142,7 +142,7 @@ Unsupported constructs: `none`. Fleet root: `false`. ## 2-rendered/kustomize-overlays Reported rc `0`. Accepted `1`, refused `3`. -Unsupported constructs: `configMapGenerator, namePrefix, nameSuffix, remote-base, secretGenerator`. Fleet root: `false`. +Unsupported constructs: `configMapGenerator, namePrefix, nameSuffix, remote-base, secretGenerator`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| @@ -154,7 +154,7 @@ Unsupported constructs: `configMapGenerator, namePrefix, nameSuffix, remote-base ## 2-rendered/rendered-manifests Reported rc `0`. Accepted `3`, refused `2`. -Unsupported constructs: `namePrefix`. Fleet root: `false`. +Unsupported constructs: `namePrefix`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| @@ -167,7 +167,7 @@ Unsupported constructs: `namePrefix`. Fleet root: `false`. ## 3-expanded/argocd-applicationset-directories Reported rc `0`. Accepted `5`, refused `0`. -Unsupported constructs: `none`. Fleet root: `false`. +Unsupported constructs: `none`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| @@ -180,7 +180,7 @@ Unsupported constructs: `none`. Fleet root: `false`. ## 3-expanded/argocd-applicationset-files Reported rc `0`. Accepted `0`, refused `1`. -Unsupported constructs: `none`. Fleet root: `false`. +Unsupported constructs: `none`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| @@ -189,7 +189,7 @@ Unsupported constructs: `none`. Fleet root: `false`. ## 3-expanded/argocd-multicluster-matrix Reported rc `0`. Accepted `4`, refused `0`. -Unsupported constructs: `none`. Fleet root: `false`. +Unsupported constructs: `none`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| @@ -201,7 +201,7 @@ Unsupported constructs: `none`. Fleet root: `false`. ## 3-expanded/flux-helmrelease Reported rc `0`. Accepted `3`, refused `1`. -Unsupported constructs: `configMapGenerator`. Fleet root: `false`. +Unsupported constructs: `configMapGenerator`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| @@ -213,7 +213,7 @@ Unsupported constructs: `configMapGenerator`. Fleet root: `false`. ## 3-expanded/flux-resourceset-inline Reported rc `0`. Accepted `1`, refused `0`. -Unsupported constructs: `none`. Fleet root: `false`. +Unsupported constructs: `none`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| @@ -222,7 +222,7 @@ Unsupported constructs: `none`. Fleet root: `false`. ## 3-expanded/flux-resourceset-pull-requests Reported rc `0`. Accepted `1`, refused `0`. -Unsupported constructs: `none`. Fleet root: `false`. +Unsupported constructs: `none`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| @@ -231,7 +231,7 @@ Unsupported constructs: `none`. Fleet root: `false`. ## 4-machine-written/flux-image-automation Reported rc `0`. Accepted `3`, refused `0`. -Unsupported constructs: `none`. Fleet root: `false`. +Unsupported constructs: `none`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| @@ -242,7 +242,7 @@ Unsupported constructs: `none`. Fleet root: `false`. ## 5-opaque/sops-encrypted Reported rc `0`. Accepted `3`, refused `0`. -Unsupported constructs: `none`. Fleet root: `false`. +Unsupported constructs: `none`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| @@ -253,7 +253,7 @@ Unsupported constructs: `none`. Fleet root: `false`. ## 6-hostile/mixed-and-hostile Reported rc `0`. Accepted `3`, refused `2`. -Unsupported constructs: `unparseable`. Fleet root: `false`. +Unsupported constructs: `unparseable`. | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| From c9d6236770d38d8c30fc35016054ecc02fad8030 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 27 Jul 2026 14:04:56 +0000 Subject: [PATCH 14/15] feat(manifestanalyzer): report the folder read graph, not half of it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readScope already knew which folders a candidate renders from and did not own. It reached a consumer as one line of text and no reverse edge, so the report's most confusing accept — acceptedByOperator with editable 0 — was explicable only by a reader who already knew to connect two fields. Adds readBy per candidate and summary.readEdges repo-wide. Both are projections of one relation, and the corpus-wide test asserts they cannot disagree: the edges are exactly the union of the readScopes, and every readBy has a matching edge. The identities are the point — ReachedByMultipleRenderRoots already answered the same question with the identities thrown away. readScope now covers every out-of-subtree build input rather than base directories only. It is the directory projection of renderScopePaths, the function the scoped acceptance gate renders from, because a build reads a folder through a base, through `resources: ../shared/deployment.yaml`, and through `patches: [{path: ../../p.yaml}]`, and only the first is a kustomize base. A second enumeration of "what does this build load" drifts from the one the writer obeys: the external patch was invisible to the first version of this graph for exactly that reason. Both node classes are covered without a second node list. A folder some kustomization references is never a render root — being referenced is what disqualifies it — so most edges end at a directory no candidate list mentions, and those are the edge targets absent from candidates[]: a set difference needing no render knowledge. An earlier cut published them as readOnlyBases, which was wrong twice over, since a patch directory is neither a base nor read-only in the sense the name implies. readBy is non-empty in the remaining case: a plain folder holding a file a distant kustomization lists under resources: is a candidate another folder genuinely depends on. Two fixtures, since a field nothing can populate is worse than no field: shared-file-reference carries both node classes at once, external-patch pins the edge that only a patch entry creates. The corpus baseline now records the graph, where `apps/staging reads apps/base/frontend (not a candidate)` explains its own editable 0. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/manifest-analyzer/main_test.go | 8 +- .../repo-discovery-and-onboarding-scan.md | 66 ++++++-- hack/gitops-layouts-baseline.sh | 12 ++ internal/manifestanalyzer/scan_repo.go | 152 +++++++++++++++++- .../supported/base-overlays.golden.json | 16 +- .../supported/external-patch.golden.json | 44 +++++ .../external-patch/base/deployment.yaml | 6 + .../external-patch/base/kustomization.yaml | 4 + .../overlays/test/kustomization.yaml | 10 ++ .../external-patch/patches/replicas.yaml | 6 + .../supported/overlay-nested-base.golden.json | 8 +- .../supported/overlay-parked-base.golden.json | 8 +- .../shared-file-reference.golden.json | 71 ++++++++ .../base/deployment.yaml | 4 + .../base/kustomization.yaml | 5 + .../overlays/test/kustomization.yaml | 5 + .../shared-file-reference/shared/config.yaml | 4 + .../shared/standalone.yaml | 5 + pkg/manifestanalyzer/repo.go | 57 ++++++- pkg/manifestanalyzer/repo_test.go | 130 +++++++++++++++ test/fixtures/gitops-layouts/support-today.md | 20 +++ 21 files changed, 621 insertions(+), 20 deletions(-) create mode 100644 internal/manifestanalyzer/testdata/scan-repo/supported/external-patch.golden.json create mode 100644 internal/manifestanalyzer/testdata/scan-repo/supported/external-patch/base/deployment.yaml create mode 100644 internal/manifestanalyzer/testdata/scan-repo/supported/external-patch/base/kustomization.yaml create mode 100644 internal/manifestanalyzer/testdata/scan-repo/supported/external-patch/overlays/test/kustomization.yaml create mode 100644 internal/manifestanalyzer/testdata/scan-repo/supported/external-patch/patches/replicas.yaml create mode 100644 internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference.golden.json create mode 100644 internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/base/deployment.yaml create mode 100644 internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/base/kustomization.yaml create mode 100644 internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/overlays/test/kustomization.yaml create mode 100644 internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/shared/config.yaml create mode 100644 internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/shared/standalone.yaml diff --git a/cmd/manifest-analyzer/main_test.go b/cmd/manifest-analyzer/main_test.go index ad14b59b..aa307f42 100644 --- a/cmd/manifest-analyzer/main_test.go +++ b/cmd/manifest-analyzer/main_test.go @@ -248,7 +248,13 @@ func TestRun_ScanRepoText(t *testing.T) { if code := run([]string{"--mode", "scan-repo", scanRepoFixture(t)}, &out, &errBuf); code != 0 { t.Fatalf("exit = %d, want 0 (stderr=%s)", code, errBuf.String()) } - for _, want := range []string{"candidates:", "kustomize-overlay", "accepted=2 refused=0"} { + // The base the overlay renders from is in none of those candidate lines — it is a + // candidate for nobody — so the text view names it separately or the reader cannot tell + // where the overlay's documents live. + for _, want := range []string{ + "candidates:", "kustomize-overlay", "accepted=2 refused=0", + "reads: base", "read but never offered: 1", "read by: overlays/test", + } { if !strings.Contains(out.String(), want) { t.Errorf("scan-repo text missing %q:\n%s", want, out.String()) } diff --git a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md index 442d588e..caf5a96b 100644 --- a/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md +++ b/docs/design/support-boundary/repo-discovery-and-onboarding-scan.md @@ -209,9 +209,9 @@ comment — the shipped report is strict JSON.) `resources` replaces the earlier `documents: { krm, nonKrm }` sketch with the shipped `{ rendered, editable, nonKrm }` split (see [First cut](#first-cut-shipped-2026-07-09) for why rendered and editable diverge for overlays). Plus a repo-level summary: -`candidatesByLayout`, `accepted`/`refused` counts, `overlapConflicts`, and -`unsupportedConstructs` (so a caller can say "this repo uses Helm inflation in -`infra/`, which the operator does not manage"). +`candidatesByLayout`, `accepted`/`refused` counts, `overlapConflicts`, `readEdges`, and +`unsupportedConstructs` (so a caller can say "this repo uses Helm inflation in `infra/`, +which the operator does not manage"). **Exit codes** reuse the CLI convention (`exitOK=0`, `exitRefused=1`, `exitUsage=2`). The eventual design: `--policy report` always exits 0 (pure report); @@ -268,9 +268,51 @@ free to move. in `spec` (`root`, `mode`) and everything found in `status` (`generator`, `candidates`, `summary`). The candidate blocks below live under `status.candidates`. Per candidate: `path`, `layout`, `acceptedByOperator`, `refusalReasons[]` (`{code, detail, solvable, actor}`), -`renderRoot`, `readScope[]`, `inferredNamespace`, `resources`, `renderedTypes`, +`renderRoot`, `readScope[]`, `readBy[]`, `inferredNamespace`, `resources`, `renderedTypes`, `overlapsWith[]`. +- **`readScope`, `readBy` and `summary.readEdges` are one relation, three projections** — + the repository's folder dependency graph. An edge means *this candidate's build renders + documents that live in that directory, which it does not own*. Two of the report's most + confusing outcomes are edges, and neither is legible without them. An overlay accepted + with `editable: 0` is not a puzzle once `readScope` says where its documents live. And + `overlapConflicts` answers "are these two candidates nested?", while the neighbouring + question — *is this folder one other folders render from?* — is `readBy`, and it decides + whether adopting it is merely wrong or actively disruptive. + + The relation is the directory projection of **`renderScopePaths`**, the same function the + scoped acceptance gate renders from, and that is the point: a build reads a folder through + a base directory, through a `resources: ../shared/deployment.yaml`, and through a + `patches: [{path: ../../shared/patch.yaml}]`, and only the first is a kustomize base. Any + second enumeration of "what does this build load" would drift from the one the writer + obeys — an external patch was invisible to a first version of this graph for exactly that + reason (corpus fixture `supported/external-patch`). + + The edges are **reach, not direct reference**: a root reaching a base through another base + gets an edge to each, and a directory nested under one the same root already reads is + folded into its parent. They answer "which folders does this build depend on", not "who + references whom, in what order". + + The graph's nodes are two classes, which is why `readBy` alone would not have been enough. + A folder some kustomization references is never a render root — being referenced is + exactly what disqualifies it — so it is a node the candidate list never mentions, and + **most edges end at one**. The report does not publish those separately: they are the edge + targets absent from `status.candidates`, a set difference a consumer can take without + knowing any render rule, and a published index of them would be a second thing to keep + true (an earlier cut called them `readOnlyBases`, which was also wrong twice over — a + patch directory is neither a base nor read-only in any sense the name implies). + + `readBy` is non-empty in the remaining case: a plain folder holding a file some distant + kustomization lists under `resources:` is a candidate another folder genuinely depends on + (corpus fixture `supported/shared-file-reference`). + + What this is NOT: the Flux dependency graph. `Kustomization.spec.dependsOn`, `sourceRef` + and `HelmRelease.spec.dependsOn` are document semantics, not folder structure, and some of + them are not answerable from a repository at all — Flux 2.9's `ArtifactGenerator` assembles + an artifact from copy globs, so a `spec.path` may point inside a generated tarball that + only resolves against a running cluster. Same boundary as the deleted `fleetRoot`: the scan + answers what the tree can answer. + - **`renderedTypes` says what is IN a candidate, not just how much** — which type lands in which namespace, as the folder RENDERS. For a render root it is read off the real kustomize build the scan already runs, so a base outside the subtree is included and a @@ -371,7 +413,11 @@ plain-per-env, kustomize-single, base+overlays, HelmRelease document vs. helm in unsupported kustomize (incl. `openapi`/`crds`), fleet-root, overlapping, no-krm, and regression fixtures for the counting/acceptance edges — an overlay whose base pulls in a nested base (deduped `rendered`), an overlay base holding parked YAML (excluded from -`rendered`), and a plain folder the gate refuses (issues surfaced as `refusalReasons`). +`rendered`), a plain folder the gate refuses (issues surfaced as `refusalReasons`), and two +read-graph fixtures: `shared-file-reference` carries both node classes at once (an accepted +overlay reading a base nobody is offered, plus a file inside an accepted plain candidate — +the only shape in which `readBy` is non-empty), and `external-patch` pins the edge to a +folder reached only through a `patches:` entry. ## Boundaries and non-goals @@ -441,10 +487,12 @@ nested base (deduped `rendered`), an overlay base holding parked YAML (excluded proposes neither (reports only). 3. **Read-scope depth for overlays the operator refuses today** — how far up the tree to follow `../../base` when the operator would still refuse the folder. *First cut:* - `readScope` is the **minimal** set of out-of-subtree base directories (a base nested - under another reached base is folded into its parent, so the rendered-document count - never double-counts a shared nested base). How many levels a caller should *display* - remains a presentation choice. + `readScope` is the **minimal** set of out-of-subtree directories the build reads (a base + nested under another reached base is folded into its parent, so the rendered-document + count never double-counts a shared nested base). It covers every build input outside the + subtree — base directories, individually referenced resource files, and patch files + alike. How many levels a caller should *display* remains a presentation choice. Since the + edges are reported repo-wide (`summary.readEdges`) the caller can also walk them itself. 4. **Library vs. CLI as the integration point** — *settled:* library-first (`ScanRepo`), CLI (`--mode scan-repo`) as the thin wrapper + CI gate. 5. **`overlay-fan-out-unsupported` naming** — *moot: retired.* Render-root scoping shipped and diff --git a/hack/gitops-layouts-baseline.sh b/hack/gitops-layouts-baseline.sh index 8d4df580..837898ef 100755 --- a/hack/gitops-layouts-baseline.sh +++ b/hack/gitops-layouts-baseline.sh @@ -77,6 +77,18 @@ emit_detail() { printf 'Reported rc `%s`. Accepted `%s`, refused `%s`.\n' "$rc" "$accepted" "$refused" printf 'Unsupported constructs: `%s`.\n\n' "$constructs" + # The read graph: which folder renders content out of which other folder. A candidate + # accepted with editable 0 is only explicable through these edges, and an edge target + # marked (not a candidate) is a folder the scan offers to nobody — most of them are. + local edges + edges=$(jq -r "$MD_CELL"'[.status.candidates[]?.path] as $cands + | (.status.summary.readEdges // [])[] + | "- `\(.from | md)` reads `\(.to | md)`" + + (if (.to as $t | $cands | index($t)) then "" else " (not a candidate)" end)' <<<"$json") + if [[ -n "$edges" ]]; then + printf 'Read graph:\n\n%s\n\n' "$edges" + fi + if [[ "$(jq -r '(.status.candidates // []) | length' <<<"$json")" == "0" ]]; then printf '_No candidate folders reported._\n' return diff --git a/internal/manifestanalyzer/scan_repo.go b/internal/manifestanalyzer/scan_repo.go index 3753f1b5..adf33eea 100644 --- a/internal/manifestanalyzer/scan_repo.go +++ b/internal/manifestanalyzer/scan_repo.go @@ -145,9 +145,19 @@ type RepoCandidate struct { // RenderRoot reports whether the candidate is a kustomize render root (versus a // plain KRM folder). RenderRoot bool `json:"renderRoot"` - // ReadScope lists the base directories outside this candidate's own subtree that its - // kustomization reads. Empty for plain and self-contained candidates. + // ReadScope lists the directories outside this candidate's own subtree whose content + // its build renders: base kustomization directories its resources graph reaches, and + // the directories holding individual resource files it renders from elsewhere. Empty + // for plain and self-contained candidates. ReadScope []string `json:"readScope,omitempty"` + // ReadBy is the reverse edge: the candidates whose build reads THIS directory. It is + // usually empty, and structurally so — a directory another kustomization references is + // never a render root, hence never a candidate — but not always: a plain folder holding + // one file some distant kustomization lists in resources: is a candidate that another + // folder depends on, and adopting it is the disruptive case a consumer must be able to + // see. The rest of these edges end at directories no candidate list mentions; see + // [RepoSummary.ReadEdges]. + ReadBy []string `json:"readBy,omitempty"` // InferredNamespace is the namespace the candidate resolves to: the kustomization's // namespace transformer for a render root, or the single explicit metadata.namespace // for a plain folder. Empty when none is set or the folder is ambiguous. @@ -172,6 +182,15 @@ type OverlapConflict struct { Descendant string `json:"descendant"` } +// ReadEdge is one folder-to-folder read: From's build renders documents that live in To, +// which is outside From's own subtree. From is always a candidate; To is a candidate only +// in the file-reference case described on [RepoCandidate.ReadBy], and is otherwise a +// directory the scan offers to nobody. +type ReadEdge struct { + From string `json:"from"` + To string `json:"to"` +} + // RepoSummary is the repo-level roll-up a product uses to describe onboardability. type RepoSummary struct { // CandidatesByLayout counts candidates per layout class. @@ -181,6 +200,17 @@ type RepoSummary struct { Refused int `json:"refused"` // OverlapConflicts lists every nesting conflict between candidates. OverlapConflicts []OverlapConflict `json:"overlapConflicts,omitempty"` + // ReadEdges is the repo's folder dependency graph: one edge per (candidate, directory + // it renders from outside its own subtree), sorted. It is the same relation each + // candidate's readScope/readBy report, collected in one place so a consumer can draw + // the graph without walking the candidates. + // + // Most edges end at a directory that is NOT a candidate — a folder a kustomization + // references is never a render root, so it is offered to nobody. Those nodes are the + // edge targets absent from Candidates, and nothing else identifies them: they are not + // all kustomize bases, since a referenced resource file or an out-of-subtree patch + // makes its folder one too. + ReadEdges []ReadEdge `json:"readEdges,omitempty"` // UnsupportedConstructs is the sorted, de-duplicated set of unsupported kustomize // features seen across refused-structural candidates, so a product can say "this repo // uses Helm inflation, which we don't manage". @@ -236,10 +266,14 @@ func scanRepoFS(ctx context.Context, fsys fs.FS) RepoReport { sort.Slice(candidates, func(i, j int) bool { return candidates[i].Path < candidates[j].Path }) detectOverlaps(candidates) + edges := buildReadGraph(candidates) + + summary := summarize(candidates, kusts) + summary.ReadEdges = edges return RepoReport{ Candidates: candidates, - Summary: summarize(candidates, kusts), + Summary: summary, } } @@ -263,6 +297,10 @@ func classifyRenderRoot( if inv, ok := store.RenderedInventory[rootDir]; ok { c.RenderedTypes = inv } + // Every folder this root renders from and does not own — reported for a refused root + // too, since "what does it read" is answerable from the resources graph whether or not + // the operator would adopt it. + c.ReadScope = readDirsOutside(rootDir, kusts) // rendered/editable count only the documents the kustomization graph actually renders // (its resources: entries), never parked YAML a kustomization does not reference. rendered := reachedResourceFilesFrom(rootDir, kusts) @@ -284,7 +322,6 @@ func classifyRenderRoot( // content in the overlay, an unbuildable base, an unsupported nested kustomization) // surfaces as its own reason rather than a blanket overlay refusal. c.Layout = LayoutKustomizeOverlay - c.ReadScope = outsideBases acc := overlayCandidateAcceptance(ctx, rootDir, scan, kusts) c.AcceptedByOperator = acc.Accepted if !acc.Accepted { @@ -501,6 +538,89 @@ func issuesToReasons(issues []AcceptanceIssue) []RefusalReason { return out } +// readDirsOutside returns the sorted, MINIMAL set of directories a render root reads that +// lie outside its own subtree — every folder whose content the root renders yet does not +// own. +// +// It is the directory projection of [renderScopePaths], deliberately and not incidentally: +// that function already answers "which files does this build load", it is what the scoped +// acceptance gate renders from, and any second enumeration here would drift from it. A +// base directory, a `resources: ../shared/deployment.yaml`, and a +// `patches: [{path: ../../shared/patch.yaml}]` are all the same fact — content this folder +// renders and does not own — and only one of the three is a kustomize base. +// +// Minimal in the same sense as [outOfSubtreeBases]: a directory nested under another in the +// set is dropped, since reading the parent already reaches it. +// +// This is the only relation the read graph is built from — [RepoCandidate.ReadScope], +// [RepoCandidate.ReadBy] and [RepoSummary.ReadEdges] are three projections of it, so they +// cannot disagree about which folder reads which. +func readDirsOutside(rootDir string, kusts map[string]*kustomizationDoc) []string { + dirs := map[string]struct{}{} + for file := range renderScopePaths(rootDir, kusts) { + if dir := slashDir(file); !pathWithin(dir, rootDir) { + dirs[dir] = struct{}{} + } + } + if len(dirs) == 0 { + return nil // a self-contained root reads nothing outside itself; say so with an absent key + } + out := minimalDirs(sortedKeysOf(dirs)) + sort.Strings(out) + return out +} + +// buildReadGraph turns the per-candidate read scopes into the repo-level edge list and +// fills in the reverse direction on each candidate. The edges are the transpose source: +// ReadBy is derived here rather than recomputed from the kustomization graph, so a folder +// listed as read by X always has X listing it under readScope. +func buildReadGraph(candidates []RepoCandidate) []ReadEdge { + edges := make([]ReadEdge, 0) + readers := map[string][]string{} + for _, c := range candidates { + for _, dir := range c.ReadScope { + edges = append(edges, ReadEdge{From: c.Path, To: dir}) + readers[dir] = append(readers[dir], c.Path) + } + } + sort.Slice(edges, func(i, j int) bool { + if edges[i].From != edges[j].From { + return edges[i].From < edges[j].From + } + return edges[i].To < edges[j].To + }) + for i := range candidates { + if from := readers[candidates[i].Path]; len(from) > 0 { + sort.Strings(from) + candidates[i].ReadBy = from + } + } + return edges +} + +// nonCandidateTargets returns the sorted directories the read edges point at that are not +// themselves candidates — the other half of the graph's nodes, offered to nobody while +// several candidates render from them. It is a set difference over the report's own two +// lists, computed here for the text view; the JSON contract publishes the edges and the +// candidates and lets a consumer do the same subtraction, rather than shipping an index +// that could fall out of step with either. +func nonCandidateTargets(candidates []RepoCandidate, edges []ReadEdge) []string { + isCandidate := map[string]struct{}{} + for _, c := range candidates { + isCandidate[c.Path] = struct{}{} + } + bases := map[string]struct{}{} + for _, e := range edges { + if _, ok := isCandidate[e.To]; !ok { + bases[e.To] = struct{}{} + } + } + if len(bases) == 0 { + return nil + } + return sortedKeysOf(bases) +} + // outOfSubtreeBases returns the sorted, MINIMAL base kustomization directories a render // root reaches that lie OUTSIDE its own subtree — the escaping-subtree fact that makes an // overlay unrenderable by the operator today. Bases nested within the subtree do not @@ -842,6 +962,18 @@ func sortedKeysOf[V any](m map[string]V) []string { return out } +// readersOf returns the candidates that read dir, read off the edge list so the text view +// and the JSON contract answer from the same place. +func readersOf(edges []ReadEdge, dir string) []string { + out := make([]string, 0, 1) + for _, e := range edges { + if e.To == dir { + out = append(out, e.From) + } + } + return out +} + // RenderRepoText writes a compact human summary of the repo report: one line per // candidate, then the roll-up. It is a convenience view; JSON is the contract. func RenderRepoText(w io.Writer, rep RepoReport) { @@ -864,10 +996,22 @@ func RenderRepoText(w io.Writer, rep RepoReport) { if len(c.ReadScope) > 0 { fmt.Fprintf(w, " reads: %s\n", strings.Join(c.ReadScope, ", ")) } + if len(c.ReadBy) > 0 { + fmt.Fprintf(w, " read by: %s\n", strings.Join(c.ReadBy, ", ")) + } if len(c.OverlapsWith) > 0 { fmt.Fprintf(w, " overlaps: %s\n", strings.Join(c.OverlapsWith, ", ")) } } + // The other half of the graph never appears above: candidates render from these folders + // and none of them is offered, so a reader who sees only the candidate list cannot tell + // that adopting one of those readers buys a folder whose documents live elsewhere. + if targets := nonCandidateTargets(rep.Candidates, rep.Summary.ReadEdges); len(targets) > 0 { + fmt.Fprintf(w, "read but never offered: %d\n", len(targets)) + for _, dir := range targets { + fmt.Fprintf(w, " %-40s read by: %s\n", dir, strings.Join(readersOf(rep.Summary.ReadEdges, dir), ", ")) + } + } fmt.Fprintf(w, "summary: accepted=%d refused=%d", rep.Summary.Accepted, rep.Summary.Refused) if len(rep.Summary.OverlapConflicts) > 0 { fmt.Fprintf(w, " overlap-conflicts=%d", len(rep.Summary.OverlapConflicts)) diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays.golden.json index b5676719..bdd0d61f 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/base-overlays.golden.json @@ -75,6 +75,20 @@ "kustomize-overlay": 3 }, "accepted": 3, - "refused": 0 + "refused": 0, + "readEdges": [ + { + "from": "overlays/acc", + "to": "base" + }, + { + "from": "overlays/prod", + "to": "base" + }, + { + "from": "overlays/test", + "to": "base" + } + ] } } diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/external-patch.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/external-patch.golden.json new file mode 100644 index 00000000..a5e5e99f --- /dev/null +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/external-patch.golden.json @@ -0,0 +1,44 @@ +{ + "candidates": [ + { + "path": "overlays/test", + "layout": "kustomize-overlay", + "acceptedByOperator": true, + "renderRoot": true, + "readScope": [ + "base", + "patches" + ], + "inferredNamespace": "demo", + "resources": { + "rendered": 1, + "editable": 0, + "nonKrm": 0 + }, + "renderedTypes": { + "byNamespace": { + "demo": [ + "apps/v1/Deployment" + ] + } + } + } + ], + "summary": { + "candidatesByLayout": { + "kustomize-overlay": 1 + }, + "accepted": 1, + "refused": 0, + "readEdges": [ + { + "from": "overlays/test", + "to": "base" + }, + { + "from": "overlays/test", + "to": "patches" + } + ] + } +} diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/external-patch/base/deployment.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/external-patch/base/deployment.yaml new file mode 100644 index 00000000..1f8651ce --- /dev/null +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/external-patch/base/deployment.yaml @@ -0,0 +1,6 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web +spec: + replicas: 1 diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/external-patch/base/kustomization.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/external-patch/base/kustomization.yaml new file mode 100644 index 00000000..9c2d28b0 --- /dev/null +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/external-patch/base/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deployment.yaml diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/external-patch/overlays/test/kustomization.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/external-patch/overlays/test/kustomization.yaml new file mode 100644 index 00000000..2b15764c --- /dev/null +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/external-patch/overlays/test/kustomization.yaml @@ -0,0 +1,10 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: demo +resources: + - ../../base +patches: + - path: ../../patches/replicas.yaml + target: + kind: Deployment + name: web diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/external-patch/patches/replicas.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/external-patch/patches/replicas.yaml new file mode 100644 index 00000000..a3ed0410 --- /dev/null +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/external-patch/patches/replicas.yaml @@ -0,0 +1,6 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web +spec: + replicas: 3 diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base.golden.json index 860d52cc..b71cb2d7 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-nested-base.golden.json @@ -29,6 +29,12 @@ "kustomize-overlay": 1 }, "accepted": 1, - "refused": 0 + "refused": 0, + "readEdges": [ + { + "from": "overlays/test", + "to": "base" + } + ] } } diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base.golden.json index e8ff4ece..5c292fa1 100644 --- a/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base.golden.json +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/overlay-parked-base.golden.json @@ -28,6 +28,12 @@ "kustomize-overlay": 1 }, "accepted": 1, - "refused": 0 + "refused": 0, + "readEdges": [ + { + "from": "overlays/test", + "to": "base" + } + ] } } diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference.golden.json b/internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference.golden.json new file mode 100644 index 00000000..d79024c0 --- /dev/null +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference.golden.json @@ -0,0 +1,71 @@ +{ + "candidates": [ + { + "path": "overlays/test", + "layout": "kustomize-overlay", + "acceptedByOperator": true, + "renderRoot": true, + "readScope": [ + "base", + "shared" + ], + "inferredNamespace": "demo", + "resources": { + "rendered": 2, + "editable": 0, + "nonKrm": 0 + }, + "renderedTypes": { + "byNamespace": { + "demo": [ + "apps/v1/Deployment", + "v1/ConfigMap" + ] + } + } + }, + { + "path": "shared", + "layout": "plain", + "acceptedByOperator": true, + "renderRoot": false, + "readBy": [ + "overlays/test" + ], + "inferredNamespace": "demo", + "resources": { + "rendered": 2, + "editable": 2, + "nonKrm": 0 + }, + "renderedTypes": { + "byNamespace": { + "demo": [ + "v1/ConfigMap" + ] + }, + "namespaceUndeclared": [ + "v1/ConfigMap" + ] + } + } + ], + "summary": { + "candidatesByLayout": { + "kustomize-overlay": 1, + "plain": 1 + }, + "accepted": 2, + "refused": 0, + "readEdges": [ + { + "from": "overlays/test", + "to": "base" + }, + { + "from": "overlays/test", + "to": "shared" + } + ] + } +} diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/base/deployment.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/base/deployment.yaml new file mode 100644 index 00000000..ad62c9ea --- /dev/null +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/base/deployment.yaml @@ -0,0 +1,4 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: web diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/base/kustomization.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/base/kustomization.yaml new file mode 100644 index 00000000..a48bfe15 --- /dev/null +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/base/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deployment.yaml + - ../shared/config.yaml diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/overlays/test/kustomization.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/overlays/test/kustomization.yaml new file mode 100644 index 00000000..8055fcac --- /dev/null +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/overlays/test/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: demo +resources: + - ../../base diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/shared/config.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/shared/config.yaml new file mode 100644 index 00000000..fe65bbde --- /dev/null +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/shared/config.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: shared-settings diff --git a/internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/shared/standalone.yaml b/internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/shared/standalone.yaml new file mode 100644 index 00000000..2f60547f --- /dev/null +++ b/internal/manifestanalyzer/testdata/scan-repo/supported/shared-file-reference/shared/standalone.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: standalone + namespace: demo diff --git a/pkg/manifestanalyzer/repo.go b/pkg/manifestanalyzer/repo.go index c68f191f..2c6b4479 100644 --- a/pkg/manifestanalyzer/repo.go +++ b/pkg/manifestanalyzer/repo.go @@ -106,7 +106,7 @@ type RenderedTypes struct { NamespaceUndeclared []string `json:"namespaceUndeclared,omitempty"` } -// Candidate is one folder that could become a GitTarget.// Candidate is one folder that could become a GitTarget. +// Candidate is one folder that could become a GitTarget. type Candidate struct { // Path is slash-separated and relative to the repository root. Path string `json:"path"` @@ -116,9 +116,25 @@ type Candidate struct { RefusalReasons []RefusalReason `json:"refusalReasons,omitempty"` // RenderRoot reports whether the candidate is a kustomize render root. RenderRoot bool `json:"renderRoot"` - // ReadScope lists base directories outside this candidate's subtree that its - // kustomization reads. Empty for plain and self-contained candidates. + // ReadScope lists the directories outside this candidate's subtree whose content its + // build renders — base kustomization directories its resources graph reaches, and the + // directories holding individual resource files it renders from elsewhere. Empty for + // plain and self-contained candidates. + // + // It is the field that explains the most confusing accept the scan produces: a folder + // reported acceptedByOperator with resources.editable 0 is an overlay whose documents + // live in a base it does not own. Read with [Candidate.ReadBy] and + // [RepoSummary.ReadEdges], it is one direction of the repository's folder graph. ReadScope []string `json:"readScope,omitempty"` + // ReadBy names the candidates whose build renders THIS directory's content — the edge + // that decides whether adopting a folder is merely wrong or actively disruptive. + // + // It is usually empty, and structurally so: a directory another kustomization + // references is never a render root, so it is never offered as a candidate at all, and + // those edges end at a directory no candidate list mentions instead. The exception is a + // plain folder holding a file some distant kustomization lists under resources: — a + // candidate that another folder genuinely depends on. + ReadBy []string `json:"readBy,omitempty"` // InferredNamespace is the namespace the candidate resolves to, when unambiguous. InferredNamespace string `json:"inferredNamespace,omitempty"` Resources ResourceCounts `json:"resources"` @@ -149,12 +165,43 @@ type OverlapConflict struct { Descendant string `json:"descendant"` } +// ReadEdge is one folder-to-folder read: From's build renders documents that live in To, +// a directory outside From's own subtree. +// +// The edges come from the same render rules the writer uses — which bases a kustomization +// actually reaches through resources: and relative paths — so a consumer draws the graph +// the operator would act on rather than one reconstructed from directory names. From is +// always a candidate. To is a directory offered to nobody unless it is the file-reference +// case on [Candidate.ReadBy], in which case it is a candidate too. +// +// An edge is REACH, not a direct reference: a root that reaches a base through another +// base gets one edge to each, not a chain, and a directory nested under another directory +// the same root reads is folded into its parent (reading the parent already reaches it). +// So the edges answer "which folders does this build depend on" exactly, and "who +// references whom, in what order" not at all — that is kustomize's business, and asking it +// is what produced these edges. +type ReadEdge struct { + From string `json:"from"` + To string `json:"to"` +} + // RepoSummary is the repository-level roll-up. type RepoSummary struct { CandidatesByLayout map[Layout]int `json:"candidatesByLayout"` Accepted int `json:"accepted"` Refused int `json:"refused"` OverlapConflicts []OverlapConflict `json:"overlapConflicts,omitempty"` + // ReadEdges is the repository's folder dependency graph, sorted: one edge per + // (candidate, directory it renders from outside its own subtree). It is the same + // relation [Candidate.ReadScope] and [Candidate.ReadBy] report per folder, collected so + // a consumer can draw the graph without walking the candidate list. + // + // Most edges end at a directory that is not a candidate: a folder some kustomization + // references is never a render root, so it is offered to nobody, and a consumer that + // draws only candidates draws no edges at all. Those nodes are exactly the edge targets + // absent from [RepoReportStatus.Candidates] — the report does not also publish them as a + // list, because a second copy of a set difference is a second thing to keep true. + ReadEdges []ReadEdge `json:"readEdges,omitempty"` // UnsupportedConstructs is the sorted, de-duplicated set of unsupported kustomize // features seen across refused candidates, so a tool can say "this repository uses // Helm inflation, which the operator does not manage". @@ -245,6 +292,9 @@ func repoReportFrom(rep internalanalyzer.RepoReport) RepoReport { Ancestor: conflict.Ancestor, Descendant: conflict.Descendant, }) } + for _, edge := range rep.Summary.ReadEdges { + out.Status.Summary.ReadEdges = append(out.Status.Summary.ReadEdges, ReadEdge{From: edge.From, To: edge.To}) + } for _, cand := range rep.Candidates { out.Status.Candidates = append(out.Status.Candidates, candidateFrom(cand)) } @@ -258,6 +308,7 @@ func candidateFrom(cand internalanalyzer.RepoCandidate) Candidate { AcceptedByOperator: cand.AcceptedByOperator, RenderRoot: cand.RenderRoot, ReadScope: cand.ReadScope, + ReadBy: cand.ReadBy, InferredNamespace: cand.InferredNamespace, Resources: ResourceCounts{ Rendered: cand.Resources.Rendered, diff --git a/pkg/manifestanalyzer/repo_test.go b/pkg/manifestanalyzer/repo_test.go index cdd9486f..f11a8115 100644 --- a/pkg/manifestanalyzer/repo_test.go +++ b/pkg/manifestanalyzer/repo_test.go @@ -130,6 +130,136 @@ func TestScanRepo_LeafFoldersSurfaceUnderAClusterRootLayout(t *testing.T) { } } +// A build reads a folder in three ways — a base directory, a resource file, a patch file — +// and only the first is a kustomize base. All three are the same fact for a consumer +// deciding what adopting a folder buys, so all three are edges. +func TestScanRepo_ReadGraphCoversPatchesNotJustBases(t *testing.T) { + t.Parallel() + + report, err := manifestanalyzer.ScanRepo(context.Background(), fixture(t, "supported", "external-patch")) + require.NoError(t, err) + requireReadGraphConsistent(t, report) + + require.Len(t, report.Status.Candidates, 1) + overlay := report.Status.Candidates[0] + require.True(t, overlay.AcceptedByOperator) + require.Equal(t, []string{"base", "patches"}, overlay.ReadScope, + "the folder holding an out-of-subtree patch is read exactly as the base is") + require.Equal(t, []manifestanalyzer.ReadEdge{ + {From: "overlays/test", To: "base"}, + {From: "overlays/test", To: "patches"}, + }, report.Status.Summary.ReadEdges) +} + +// The read graph is what makes the report's most confusing accept explainable: a folder +// accepted with editable 0 renders documents that live somewhere else. Both node classes +// have to survive into the contract — the directory nobody is offered, and the candidate +// another folder depends on — or a consumer can draw no edge at all. +func TestScanRepo_ReadGraphCarriesBothNodeClasses(t *testing.T) { + t.Parallel() + + report, err := manifestanalyzer.ScanRepo(context.Background(), fixture(t, "supported", "shared-file-reference")) + require.NoError(t, err) + + byPath := map[string]manifestanalyzer.Candidate{} + for _, cand := range report.Status.Candidates { + byPath[cand.Path] = cand + } + + overlay, ok := byPath["overlays/test"] + require.True(t, ok) + require.True(t, overlay.AcceptedByOperator) + require.Zero(t, overlay.Resources.Editable, "the overlay owns none of what it renders") + require.Equal(t, []string{"base", "shared"}, overlay.ReadScope, + "readScope covers both a base directory and a folder it renders a single file from") + + // "base" holds a kustomization another kustomization references, which is exactly what + // disqualifies it as a render root — so it is a node no candidate list mentions, and a + // consumer finds it as an edge target absent from the candidates. + require.NotContains(t, byPath, "base") + require.Contains(t, report.Status.Summary.ReadEdges, manifestanalyzer.ReadEdge{From: "overlays/test", To: "base"}) + + // "shared" is the other case: a candidate a consumer may offer, whose documents another + // folder already renders. Selecting it is the disruptive choice readBy exists to expose. + shared, ok := byPath["shared"] + require.True(t, ok) + require.True(t, shared.AcceptedByOperator) + require.Equal(t, []string{"overlays/test"}, shared.ReadBy) + + require.Equal(t, []manifestanalyzer.ReadEdge{ + {From: "overlays/test", To: "base"}, + {From: "overlays/test", To: "shared"}, + }, report.Status.Summary.ReadEdges) +} + +// readScope, readBy and readEdges are three projections of one relation. A consumer that +// draws the graph from any of them must get the same picture, so the transpose is checked +// against the whole corpus rather than one hand-built fixture. +func TestScanRepo_ReadGraphProjectionsAgree(t *testing.T) { + t.Parallel() + + for _, group := range []string{"supported", "unsupported"} { + entries, err := os.ReadDir(filepath.Join(corpusRoot, group)) + require.NoError(t, err) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + t.Run(filepath.Join(group, entry.Name()), func(t *testing.T) { + t.Parallel() + report, err := manifestanalyzer.ScanRepo(context.Background(), fixture(t, group, entry.Name())) + require.NoError(t, err) + requireReadGraphConsistent(t, report) + }) + } + } +} + +// requireReadGraphConsistent checks the three projections against each other: the edges are +// exactly the union of the readScopes, every readBy has a matching edge, and every edge +// runs between two directories a consumer can place — a candidate, or a node it identifies +// by the edge target being absent from the candidate list. +func requireReadGraphConsistent(t *testing.T, report manifestanalyzer.RepoReport) { + t.Helper() + + fromScope := map[manifestanalyzer.ReadEdge]struct{}{} + candidatePaths := map[string]struct{}{} + for _, cand := range report.Status.Candidates { + candidatePaths[cand.Path] = struct{}{} + for _, dir := range cand.ReadScope { + fromScope[manifestanalyzer.ReadEdge{From: cand.Path, To: dir}] = struct{}{} + } + } + + fromEdges := map[manifestanalyzer.ReadEdge]struct{}{} + for _, edge := range report.Status.Summary.ReadEdges { + fromEdges[edge] = struct{}{} + require.Contains(t, candidatePaths, edge.From, "an edge always starts at a candidate") + } + require.Equal(t, fromScope, fromEdges, "readEdges is exactly the union of the readScopes") + + for _, cand := range report.Status.Candidates { + for _, reader := range cand.ReadBy { + require.Contains(t, fromEdges, manifestanalyzer.ReadEdge{From: reader, To: cand.Path}, + "readBy is the transpose of readScope, never an independent claim") + } + } + for edge := range fromEdges { + require.NotEmpty(t, readersOf(report.Status.Summary.ReadEdges, edge.To), + "a node with no edge into it is not part of the graph") + } +} + +func readersOf(edges []manifestanalyzer.ReadEdge, dir string) []string { + var out []string + for _, edge := range edges { + if edge.To == dir { + out = append(out, edge.From) + } + } + return out +} + func TestScanRepo_MissingRootIsAnError(t *testing.T) { t.Parallel() _, err := manifestanalyzer.ScanRepo(context.Background(), filepath.Join(t.TempDir(), "absent")) diff --git a/test/fixtures/gitops-layouts/support-today.md b/test/fixtures/gitops-layouts/support-today.md index f9a66a81..3bdc40ca 100644 --- a/test/fixtures/gitops-layouts/support-today.md +++ b/test/fixtures/gitops-layouts/support-today.md @@ -74,6 +74,11 @@ Unsupported constructs: `none`. Reported rc `0`. Accepted `6`, refused `0`. Unsupported constructs: `none`. +Read graph: + +- `apps/production` reads `apps/base/frontend` (not a candidate) +- `apps/staging` reads `apps/base/frontend` (not a candidate) + | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| | `apps/production` | `kustomize-overlay` | true | `production` | 2/0/0 | none | @@ -134,6 +139,11 @@ Unsupported constructs: `none`. Reported rc `0`. Accepted `2`, refused `0`. Unsupported constructs: `none`. +Read graph: + +- `overlays/production` reads `base` (not a candidate) +- `overlays/staging` reads `base` (not a candidate) + | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| | `overlays/production` | `kustomize-overlay` | true | `frontend-production` | 2/0/0 | none | @@ -144,6 +154,11 @@ Unsupported constructs: `none`. Reported rc `0`. Accepted `1`, refused `3`. Unsupported constructs: `configMapGenerator, namePrefix, nameSuffix, remote-base, secretGenerator`. +Read graph: + +- `apps/frontend/overlays/production` reads `apps/frontend/base` (not a candidate) +- `apps/frontend/overlays/staging` reads `apps/frontend/base` (not a candidate) + | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| | `apps/backend/base` | `kustomize-single` | true | `-` | 2/2/0 | none | @@ -156,6 +171,11 @@ Unsupported constructs: `configMapGenerator, namePrefix, nameSuffix, remote-base Reported rc `0`. Accepted `3`, refused `2`. Unsupported constructs: `namePrefix`. +Read graph: + +- `src/frontend/overlays/production` reads `src/frontend/base` (not a candidate) +- `src/frontend/overlays/staging` reads `src/frontend/base` (not a candidate) + | Candidate | Layout | Accepted today | Namespace | rendered/editable/non-KRM | Refusal reasons | |---|---|---|---|---|---| | `argocd` | `plain` | true | `argocd` | 1/1/0 | none | From b7f2efa6991f91baa8e602e550fe0efc50c6f99e Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 27 Jul 2026 14:26:54 +0000 Subject: [PATCH 15/15] docs: say the read-graph section's label the way the baseline says it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "read but never offered" described the section correctly and at twice the length. The corpus baseline already marks these directories "(not a candidate)", which is the same fact in the words a reader meets first. Also clears every Vale suggestion in the two gated docs — six fillers ("actually", "simply", "really", "just", "genuinely") whose removal changes no sentence's meaning, which is the test the rule asks you to apply — and one corrective apposition in each file, restated positively. Two Correctives warnings remain, one per file, and they are density warnings rather than defects: the rule budgets three "X, not Y" constructions per file and these hold 18 and 11. Clearing them is a prose pass over docs this branch does not otherwise touch, and several of the constructions are load-bearing distinctions carrying their own headings ("Why audit, not admission, attributes a mirrored change"). Left for a change that is about those documents. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/manifest-analyzer/main_test.go | 2 +- docs/architecture.md | 14 +++++++------- docs/configuration.md | 12 ++++++------ internal/manifestanalyzer/scan_repo.go | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/cmd/manifest-analyzer/main_test.go b/cmd/manifest-analyzer/main_test.go index aa307f42..eeb5aaaa 100644 --- a/cmd/manifest-analyzer/main_test.go +++ b/cmd/manifest-analyzer/main_test.go @@ -253,7 +253,7 @@ func TestRun_ScanRepoText(t *testing.T) { // where the overlay's documents live. for _, want := range []string{ "candidates:", "kustomize-overlay", "accepted=2 refused=0", - "reads: base", "read but never offered: 1", "read by: overlays/test", + "reads: base", "not a candidate: 1", "read by: overlays/test", } { if !strings.Contains(out.String(), want) { t.Errorf("scan-repo text missing %q:\n%s", want, out.String()) diff --git a/docs/architecture.md b/docs/architecture.md index 70937804..30c726ee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -19,7 +19,7 @@ team's Git write boundary: its credential, branch policy, and targets usually be client, discovery surface, watch state, and attribution partition must remain consistent across namespaces. `allowedNamespaces` then explicitly controls which control-cluster namespaces may reference that shared source; it does not grant source-cluster RBAC or select source namespaces. The source identity -is the `ClusterProvider` name, not an API-server identity probe: two providers configured for the same +is the `ClusterProvider` name alone, with no API-server identity probe: two providers configured for the same server deliberately remain separate source partitions. *** @@ -84,7 +84,7 @@ The solution, in the vocabulary used throughout this document: by `resourceVersion` for that type, so there is nothing to re-order. Every Git write derives from persisted state the watch observed. - **Watches are per `GitTarget` and scaled by claims.** A watch opens only for the claimed ∩ followable - `(GVR, scope)` set, so cost scales with what `GitTarget`s actually claim, not with cluster type count. + `(GVR, scope)` set, so cost scales with what `GitTarget`s claim, not with cluster type count. - **Recovery prefers watch.** A new watch normally starts with `sendInitialEvents`, establishes a current snapshot boundary, and runs a **mark-and-sweep**: any Git file whose object is no longer present is deleted. When Redis has a fresh per-type cursor, the operator skips the snapshot and @@ -148,7 +148,7 @@ normal pipeline and is audit-attributed like any other resource. See ### Proving it on new Kubernetes versions -How Kubernetes actually emits these structures is not something to guess at. A separate, standalone +How Kubernetes emits these structures is not something to guess at. A separate, standalone project. The **mutation-capture lab** ([design](spec/mutation-capture-lab-design.md), [cmd/mutation-capture-lab/](../cmd/mutation-capture-lab/)) records the exact watch, audit, and admission output a real apiserver produces for each interesting scenario and commits it as normalized example YAML (a @@ -557,7 +557,7 @@ fresh `sendInitialEvents` reconnect. Losing a watch (pod eviction, rollout, crash, `410 Gone`) is normal. When Redis has a cursor for the watch shard, the next session first opens a normal watch from that resourceVersion. If the apiserver -can supply all events since that cursor, the watch simply continues from there. If the cursor is +can supply all events since that cursor, the watch continues from there. If the cursor is expired, or no cursor exists, the watch opens with `sendInitialEvents=true` and `ResourceVersionMatch=NotOlderThan`, so the apiserver streams current state as a replay of `ADDED` events terminated by the `initial-events-end` bookmark. The operator runs a **mark-and-sweep** over @@ -690,12 +690,12 @@ and the *author* (who wrote the change), and GitOps Reverser uses both on purpos - The **committer is always the operator:** the configured `GitProvider.spec.commit.committer`, defaulting to `GitOps Reverser `. Every commit, attributed or not, is committed by the - operator, because the operator is what actually wrote it to Git. + operator, because the operator is what wrote it to Git. - The **author is the real actor, but only when we are sure.** On a strong attribution match the author is set to that actor. Git always carries an author, so it is **never left blank**, and it is never a guessed person. What fills it when we are not sure depends on WHY: - **Attribution is switched off** (configured-author mode, and reconcile/resync writes that have no actor - at all): the author is the operator, identical to the committer. That is honest, because the operator really is + at all): the author is the operator, identical to the committer. That is honest, because the operator is the author. - **Attribution ran and did not resolve an actor**: the author is the explicit sentinel `unknown (attribution unresolved) `. It is deliberately @@ -1058,7 +1058,7 @@ placed never moves a file already in Git. A new resource is placed by the first brace-variable path language (`{namespace}`, `{group}`, `{resource}`, `{name}`, …). 2. **Sibling inference.** With no matching declared template, the new resource follows the layout its siblings already use: appended to the bundle its type shares, or placed one-per-file beside them. - so pointing a target at an existing folder just continues that folder's convention. When the whole + so pointing a target at an existing folder continues that folder's convention. When the whole subtree is governed by one supported kustomization and the type is brand new, the file lands beside that kustomization and gets a `resources:` entry. 3. **Canonical fallback.** With nothing to follow (an empty repo, a brand-new type), the built-in default diff --git a/docs/configuration.md b/docs/configuration.md index 340d4e38..dabe7377 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -13,7 +13,7 @@ The short version: - `ClusterWatchRule` does the same for cluster-scoped resources - `CommitRequest` optionally asks the operator to close the current commit window now -The chart's optional `quickstart` values are just a convenience layer that creates starter +The chart's optional `quickstart` values are a convenience layer that creates starter instances of those same resources. For a first trial, use the root README quick start. It runs configured-author: Git writes work without @@ -59,8 +59,8 @@ the local cluster. shared source connection: it determines which namespaces may contain `GitTarget`s that reference the provider. It does not select namespaces in the source cluster or grant permissions there. If a platform later needs a shared, platform-owned Git destination, that should be a separate -cluster-scoped Git-destination concept with an explicit ownership model, rather than changing the -meaning of the namespaced `GitProvider`. +cluster-scoped Git-destination concept with an explicit ownership model, leaving the namespaced +`GitProvider` to mean what it means today. ## `GitProvider` @@ -618,7 +618,7 @@ For each new resource the operator walks this order and stops at the first that `.sops.yaml` suffix for sensitive resources. If you set **no** `spec.placement`, only steps 3 and 4 run, which is why pointing a target at an -existing repository "just continues" that repo's conventions, and a brand-new empty repo gets the tidy +existing repository "continues" that repo's conventions, and a brand-new empty repo gets the tidy canonical layout. #### Following the existing layout (sibling inference) @@ -655,7 +655,7 @@ The boundaries that keep it predictable: not arrived yet, so it is not treated as shared. The resource takes the canonical path, which carries its own namespace segment. Guessing here would file one namespace's objects under another's folder. -- When a type genuinely lives in two layouts at once, the tie-break is deterministic (the cohort with the +- When a type lives in two layouts at once, the tie-break is deterministic (the cohort with the most members wins, then the lexically smallest path), and it is never a coin-flip. - Inference can only **continue** a layout that already exists. It cannot invent a greenfield one. "I want all ConfigMaps bundled even though none exist yet" is a job for `byType` below. @@ -702,7 +702,7 @@ named `api` in namespace `team-a`: | `{group}` | API group; **empty** for core resources | `apps` (a ConfigMap → empty) | | `{groupPath}` | the API group as a path segment; equivalent to `{group}` today (the empty core-group segment is dropped either way) | `apps` | | `{version}` | API version | `v1` | -| `{apiVersion}` | manifest `apiVersion`: `group/version`, or just `version` for core | `apps/v1` (a ConfigMap → `v1`) | +| `{apiVersion}` | manifest `apiVersion`: `group/version`, or `version` alone for core | `apps/v1` (a ConfigMap → `v1`) | | `{kind}` | manifest kind | `Deployment` | | `{scope}` | `namespaced` or `cluster` (a readable label, not a namespace-position value) | `namespaced` | | `{sensitiveSuffix}` | `.sops.yaml` for a sensitive resource, `.yaml` otherwise | `.yaml` (a Secret → `.sops.yaml`) | diff --git a/internal/manifestanalyzer/scan_repo.go b/internal/manifestanalyzer/scan_repo.go index adf33eea..516506ef 100644 --- a/internal/manifestanalyzer/scan_repo.go +++ b/internal/manifestanalyzer/scan_repo.go @@ -1007,7 +1007,7 @@ func RenderRepoText(w io.Writer, rep RepoReport) { // and none of them is offered, so a reader who sees only the candidate list cannot tell // that adopting one of those readers buys a folder whose documents live elsewhere. if targets := nonCandidateTargets(rep.Candidates, rep.Summary.ReadEdges); len(targets) > 0 { - fmt.Fprintf(w, "read but never offered: %d\n", len(targets)) + fmt.Fprintf(w, "not a candidate: %d\n", len(targets)) for _, dir := range targets { fmt.Fprintf(w, " %-40s read by: %s\n", dir, strings.Join(readersOf(rep.Summary.ReadEdges, dir), ", ")) }