-
Notifications
You must be signed in to change notification settings - Fork 75
fix(recipe,client): reject duplicate ComponentRef names, omit disable… #1917
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
70b865d
486b11a
59de9b7
2109ea2
dc5a9d6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -857,6 +857,9 @@ func facadeResultFromInternal(r *recipe.RecipeResult, name string) *RecipeResult | |
| internal: r, | ||
| } | ||
| for _, c := range r.ComponentRefs { | ||
| if !c.IsEnabled() { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Minor — Facade To be clear up front, this change is better than advertised: Blast radius: Docs only, but observable: Fix: Add the qualifier to both doc pages — e.g. " |
||
| continue | ||
| } | ||
| // Project the chart the component actually deploys: a source-only | ||
| // Helm ref falls back to the component name (the deployers' | ||
| // EffectiveChart rule), so SDK consumers never see an empty chart | ||
|
|
@@ -1055,6 +1058,16 @@ func (c *Client) BundleComponents(ctx context.Context, r *RecipeResult) ([]Compo | |
| return nil, preflightErr | ||
| } | ||
|
|
||
| // Agree with DefaultBundler.Make (bundler.go), which fails closed with | ||
| // ErrCodeInvalidRequest when every component is disabled. Without this, | ||
| // an all-disabled recipe silently returns an empty, successful bundle | ||
| // list here — an SDK caller that loops over the result and deploys | ||
| // would silently no-op instead of getting an actionable error. | ||
| if len(r.Components) == 0 && r.internal != nil && len(r.internal.ComponentRefs) > 0 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Minor — All-disabled guard: comment claims parity with The Blast radius: SDK callers of Fix: Guard on On @coderabbitai's version of this finding — it argued the guard should key off
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I withdraw my earlier preference for keying the guard off The guard should therefore be simply: if len(r.Components) == 0 {
return nil, errors.New(errors.ErrCodeInvalidRequest,
"recipe has no enabled components")
}This removes the dead ✏️ Learnings added
|
||
| return nil, errors.New(errors.ErrCodeInvalidRequest, | ||
| "recipe has no enabled components") | ||
| } | ||
|
Comment on lines
+1061
to
+1069
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Determine all-disabled status from internal refs.
🤖 Prompt for AI Agents |
||
|
|
||
| bundles := make([]ComponentBundle, 0, len(r.Components)) | ||
| for i := range r.Components { | ||
| // Bail on every iteration so a long recipe doesn't hold | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -304,3 +304,103 @@ func TestPrepareAndValidate_RejectsReservedDeployerName(t *testing.T) { | |
| }) | ||
| } | ||
| } | ||
|
|
||
| // TestPrepareAndValidate_RejectsDuplicateNames verifies the common | ||
| // validation boundary fails closed on a recipe with two component refs | ||
| // sharing the same non-empty Name. Component names key value | ||
| // materialization, override routing, validation, filtering, and deployment | ||
| // ordering throughout the bundler/validations/deployer paths, all of which | ||
| // assume a unique name -> ref mapping. Disabled refs participate too, so a | ||
| // disabled duplicate can't silently mask an enabled ref of the same name | ||
| // (or vice versa). See #1874. | ||
| func TestPrepareAndValidate_RejectsDuplicateNames(t *testing.T) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Nitpick — Worth one more edge case: reserved-key-vs-duplicate precedence Two refs both named Blast radius: Test completeness only. Fix: Add the precedence case. (Three-way duplicates exercise no new path, and whitespace-padded names are treated as distinct — consistent with the total absence of name normalization anywhere in the codebase — so both of those are skippable.) |
||
| tests := []struct { | ||
| name string | ||
| refs []ComponentRef | ||
| wantErr bool | ||
| }{ | ||
| { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Nitpick — Positional struct literals where every sibling table in the file uses keyed fields
Blast radius: Readability only. Keyed literals survive a field insertion into the anonymous struct without silently reassigning values, which is why the file already prefers them. Fix: Add the |
||
| name: "adjacent duplicate", | ||
| refs: []ComponentRef{ | ||
| {Name: "gpu-operator", Type: ComponentTypeHelm, Source: "https://charts.example.com", Version: "1.0.0"}, | ||
| {Name: "gpu-operator", Type: ComponentTypeHelm, Source: "https://charts.example.com", Version: "1.0.0"}, | ||
| }, | ||
| wantErr: true, | ||
| }, | ||
| { | ||
| name: "non-adjacent duplicate", | ||
| refs: []ComponentRef{ | ||
| {Name: "a", Type: ComponentTypeHelm, Source: "https://charts.example.com", Version: "1.0.0"}, | ||
| {Name: "b", Type: ComponentTypeHelm, Source: "https://charts.example.com", Version: "1.0.0"}, | ||
| {Name: "a", Type: ComponentTypeHelm, Source: "https://charts.example.com", Version: "1.0.0"}, | ||
| }, | ||
| wantErr: true, | ||
| }, | ||
| { | ||
| name: "disabled duplicate still rejected", | ||
| refs: []ComponentRef{ | ||
| {Name: "a", Type: ComponentTypeHelm, Source: "https://charts.example.com", Version: "1.0.0"}, | ||
| {Name: "a", Overrides: map[string]any{"enabled": false}}, | ||
| }, | ||
| wantErr: true, | ||
| }, | ||
| { | ||
| // Both refs are enabled and nameless — the actual shape that | ||
| // reaches the deployers if the name=="" exemption were ever | ||
| // removed. Disabled nameless refs would pass trivially for an | ||
| // unrelated reason (backfillComponentTypes/ValidateCoherence | ||
| // skip disabled refs entirely), so this must use enabled ones | ||
| // to pin the exemption it's named for. | ||
| name: "empty names not flagged", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Nitpick — The strengthened fixture is right, but note it now blesses a degenerate shape The rewrite from disabled to enabled refs is a genuine improvement and the comment explaining why is exactly right. Worth naming what it now durably pins, though: two enabled, fully-shaped, nameless Helm refs sail through the entire Blast radius: Pre-existing, not introduced here. But those refs are exactly as unroutable downstream as the duplicates this PR rejects — Fix: Nothing for this PR. A follow-up making an empty |
||
| refs: []ComponentRef{ | ||
| {Name: "", Type: ComponentTypeHelm, Source: "https://charts.example.com", Version: "1.0.0"}, | ||
| {Name: "", Type: ComponentTypeHelm, Source: "https://charts.example.com", Version: "1.0.0"}, | ||
| }, | ||
| wantErr: false, | ||
| }, | ||
| { | ||
| name: "unique names ok", | ||
| refs: []ComponentRef{ | ||
| {Name: "a", Type: ComponentTypeHelm, Source: "https://charts.example.com", Version: "1.0.0"}, | ||
| {Name: "b", Type: ComponentTypeHelm, Source: "https://charts.example.com", Version: "1.0.0"}, | ||
| }, | ||
| wantErr: false, | ||
| }, | ||
| { | ||
| // Reserved-key rejection runs before the duplicate check, so | ||
| // two refs both named ReservedDeployerKey must surface the | ||
| // "reserved" message, never the "duplicate" one. Deliberate | ||
| // precedence; pinned here so an inversion is caught. | ||
| name: "reserved-key precedence over duplicate check", | ||
| refs: []ComponentRef{ | ||
| {Name: ReservedDeployerKey, Overrides: map[string]any{"enabled": false}}, | ||
| {Name: ReservedDeployerKey, Overrides: map[string]any{"enabled": false}}, | ||
| }, | ||
| wantErr: true, | ||
| }, | ||
| } | ||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| r := &RecipeResult{ComponentRefs: tt.refs} | ||
| err := r.PrepareAndValidate() | ||
| if (err != nil) != tt.wantErr { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Nitpick — Test asserts only The check is currently pinned — mutation-tested via Blast radius: Test only. The sibling Fix: Behind the existing |
||
| t.Fatalf("PrepareAndValidate() error = %v, wantErr %v", err, tt.wantErr) | ||
| } | ||
| if !tt.wantErr { | ||
| return | ||
| } | ||
| if !stderrors.Is(err, errors.New(errors.ErrCodeInvalidRequest, "")) { | ||
| t.Errorf("expected ErrCodeInvalidRequest, got: %v", err) | ||
| } | ||
| if tt.name == "reserved-key precedence over duplicate check" { | ||
| if !strings.Contains(err.Error(), "reserved") { | ||
| t.Errorf("error should mention the reservation, got: %v", err) | ||
| } | ||
| return | ||
| } | ||
| if !strings.Contains(err.Error(), "duplicate") { | ||
| t.Errorf("error should mention the duplicate, got: %v", err) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -534,13 +534,13 @@ func (r *RecipeResult) backfillComponentTypes() error { | |
|
|
||
| // PrepareAndValidate normalizes a RecipeResult's component refs and rejects | ||
| // incoherent ones, in the required order: reject refs named with the reserved | ||
| // deployer override key (all refs, enabled or disabled), back-fill missing | ||
| // deployer override key, reject duplicate non-empty ref names, back-fill missing | ||
| // types on enabled refs from the registry, canonicalize the type casing, then | ||
| // validate | ||
| // coherence (which itself only inspects enabled refs). Boundaries | ||
| // that produce a RecipeResult WITHOUT running ApplyRegistryDefaults — file load | ||
| // (LoadFromFileWithProvider) and external adoption (client adoptRecipe) — call | ||
| // this single method so the three steps cannot drift or be partially applied | ||
| // this single method so the four steps cannot drift or be partially applied | ||
| // (e.g. validating before canonicalizing would reject legitimate lowercase | ||
| // types; skipping the back-fill would reject type-less registry refs). The | ||
| // resolve path (finalizeRecipeResult) instead back-fills via ApplyRegistryDefaults | ||
|
|
@@ -563,13 +563,47 @@ func (r *RecipeResult) PrepareAndValidate() error { | |
| fmt.Sprintf("recipe component %q uses the reserved deployer override key as its name; %q is reserved for --set deployer:<key> Argo deployer options", r.ComponentRefs[i].Name, ReservedDeployerKey)) | ||
| } | ||
| } | ||
| // Reject duplicate component-ref names (enabled or disabled) BEFORE | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Minor — Criteria-resolve path still bypasses the duplicate check
Blast radius: Limited. Every deploy path still fails closed — Fix: Follow-up is fine: extract a shared |
||
| // registry back-fill. See validateRefNames for the rationale. | ||
| if err := validateRefNames(r.ComponentRefs); err != nil { | ||
| return err | ||
| } | ||
| if err := r.backfillComponentTypes(); err != nil { | ||
| return err | ||
| } | ||
| canonicalizeComponentTypes(r.ComponentRefs) | ||
| return r.ValidateCoherence() | ||
| } | ||
|
|
||
| // validateRefNames rejects duplicate non-empty component-ref names (enabled | ||
| // or disabled). Component names key value materialization, override | ||
| // routing, validation, filtering, and deployment ordering throughout the | ||
| // bundler/validations/deployer paths, all of which assume a unique | ||
| // name -> ref mapping. AICR-generated recipes never produce duplicates, but | ||
| // a hand-authored or externally-adopted recipe can, and a disabled | ||
| // duplicate must not be allowed to silently mask an enabled ref of the same | ||
| // name (or vice versa) — so ALL refs participate in the check, not just | ||
| // enabled ones. Empty names are exempt: they are never used as a lookup | ||
| // key. Shared between PrepareAndValidate (the file-load, adopt, and | ||
| // bundle/validate -r / POST /v1/bundle boundary) and finalizeRecipeResult | ||
| // (the criteria-resolve boundary), so both fail closed on the same | ||
| // recipes. See #1874. | ||
| func validateRefNames(refs []ComponentRef) error { | ||
| seen := make(map[string]int, len(refs)) | ||
| for i := range refs { | ||
| name := refs[i].Name | ||
| if name == "" { | ||
| continue | ||
| } | ||
| if first, ok := seen[name]; ok { | ||
| return errors.New(errors.ErrCodeInvalidRequest, | ||
| fmt.Sprintf("recipe has duplicate component ref name %q (refs %d and %d)", name, first, i)) | ||
| } | ||
| seen[name] = i | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| // ValidateCoherence rejects enabled ComponentRefs whose deployment-shape fields | ||
| // are internally inconsistent (see coherenceProblem), aggregating every | ||
| // offender into one ErrCodeInvalidRequest so the author sees all problems at | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -968,6 +968,18 @@ func finalizeRecipeResult(provider DataProvider, criteria *Criteria, mergedSpec | |
| } | ||
| result.Metadata.AppliedOverlays = appliedOverlays | ||
|
|
||
| // Reject duplicate component-ref names before they reach ValidateCoherence | ||
| // or any caller of BuildRecipeResult/ResolveRecipe. RecipeMetadataSpec.Merge | ||
| // silently last-wins-collapses same-name refs via componentMap, so this | ||
| // only fires when an external --data base carries duplicates AND no | ||
| // matching overlay merge collapses them. Mirrors the same check | ||
| // PrepareAndValidate runs at the file-load/adopt/bundle boundary, so the | ||
| // criteria-resolve boundary (aicr recipe -o, aicr query, | ||
| // Client.ResolveRecipe) fails closed on the same recipes too. See #1874. | ||
| if err := validateRefNames(mergedSpec.ComponentRefs); err != nil { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔵 Nitpick — This gate is effectively unreachable — honest defense-in-depth, but worth knowing (and untested) Reproduced twice, independently. Blast radius: None today — no in-tree recipe has duplicates, and the deploy boundary fails closed regardless. Deliberately not tiered higher: the comment directly above already says exactly this ("only fires when an external --data base carries duplicates AND no matching overlay merge collapses them"), so the code doesn't misrepresent itself, and the docs added in this commit are correctly scoped to the POST /v1/bundle request body, which does reject via Fix: No change required. Two notes if you want to revisit later: (1) to make it actually bite, validate pre-merge in |
||
| return nil, err | ||
| } | ||
|
|
||
| // Reject refs whose deployment-shape fields are incoherent (e.g. a Helm ref | ||
| // that also carries a Kustomize tag/path), after defaults populate Type. | ||
| // The same check also runs at the load and adopt boundaries (see | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔵 Nitpick — Uniqueness description omits the empty-name exemption
Confirming @coderabbitai's finding, and I checked the escape hatch it didn't:
required: [name]on line 1628 is a presence assertion, not non-emptiness ("name": ""satisfies it), andpkg/serverperforms no runtime OpenAPI validation — the spec is documentation-only (its only in-repo consumers are a doc comment inpkg/server/doc.goand the enum-drift test; there's nokin-openapi/libopenapiingo.mod). SovalidateRefNames'name == ""exemption really is reachable and repeated empty names really are accepted.Blast radius: Documentation accuracy only — a nameless enabled ref is already degenerate. Same wording applies to
docs/user/api-reference.md:487-489.Fix: Append "(refs with an empty name are exempt)" to both descriptions — or, better, make an empty
Nameon an enabled ref acoherenceProblem, which makes the docs true as written and closes the case below.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Length of output: 10022
@njhensley, confirmed.validateRefNamesdeliberately exemptsname == "", and the PR objective preserves that behavior. The OpenAPI and user API reference therefore overstate the uniqueness guarantee. I agree the appropriate scope here is to document the exemption rather than change enabled-ref validation.✏️ Learnings added