Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion api/aicr/v1/server.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1653,7 +1653,10 @@ components:
properties:
name:
type: string
description: Component name
description: >-
Component name. Must be unique within componentRefs (across

Copy link
Copy Markdown
Member

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), and pkg/server performs no runtime OpenAPI validation — the spec is documentation-only (its only in-repo consumers are a doc comment in pkg/server/doc.go and the enum-drift test; there's no kin-openapi/libopenapi in go.mod). So validateRefNames' 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 Name on an enabled ref a coherenceProblem, which makes the docs true as written and closes the case below.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- duplicate-name validation and tests ---'
rg -n -C 5 'func validateRefNames|validateRefNames\(|RejectsDuplicateNames|name == ""' \
  pkg/recipe

printf '%s\n' '--- OpenAPI component-name contract ---'
rg -n -C 4 'Component name\. Must be unique|empty name|componentRefs.*unique' \
  api/aicr/v1/server.yaml

printf '%s\n' '--- user API-reference component-name contract ---'
rg -n -C 4 'componentRefs|duplicate name|Must be unique|unique within' \
  docs/user/api-reference.md

Length of output: 10022


@njhensley, confirmed. validateRefNames deliberately exempts name == "", 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.

  • Update the component-name uniqueness descriptions in the OpenAPI schema and user API reference.
  • State that uniqueness applies to non-empty names, while empty names are exempt, without changing validation behavior.

✏️ Learnings added
Learnt from: njhensley
Repo: NVIDIA/aicr PR: 1917
File: api/aicr/v1/server.yaml:1633-1633
Timestamp: 2026-07-28T16:20:33.619Z
Learning: In the Go recipe validation contract, `pkg/recipe/metadata.go` function `validateRefNames` exempts `ComponentRef.Name == ""` from duplicate-name validation; only duplicate non-empty names are rejected, including when either ref is disabled. Public API documentation must state this exemption because the OpenAPI schema’s `required: [name]` enforces property presence rather than a non-empty value, and the server does not perform runtime OpenAPI validation.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


  • 📌 Create a pull request with these changes

both enabled and disabled refs); a duplicate name is
rejected with 400.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
type:
type: string
enum: [Helm, Kustomize]
Expand Down
4 changes: 3 additions & 1 deletion docs/integrator/go-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,9 @@ identifiers. When you already hold a `pkg/recipe.AllowLists`, use

`ResolveRecipe` takes the stable `RecipeRequest` shape and returns the
facade `RecipeResult` — a deliberately small struct exposing the
`Name`, `Version`, and `Components` of the resolved recipe. When you
`Name`, `Version`, and `Components` of the resolved recipe. `Components`
lists enabled (deployable) components only; disabled refs remain visible
via `Resolved().ComponentRefs`. When you
already hold an `*aicr.Criteria` value — for example, a REST handler
that parsed criteria from an incoming HTTP request and wrapped them with
`aicr.WrapCriteria` — use `ResolveRecipeFromCriteria`. It returns the
Expand Down
2 changes: 1 addition & 1 deletion docs/integrator/public-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ aliases — the table below documents which.
| `aicr.Phase`, `aicr.PhaseDeployment` / `PhasePerformance` / `PhaseConformance` | string consts | **Facade-owned**. Values match `pkg/validator/v1` constants verbatim for byte-identical wire round-trip. |
| `aicr.ReportSummary` | `pkg/validator/ctrf.Summary` | **Facade-owned struct** with the CTRF count fields. |
| `aicr.ValidateOption` | `pkg/validator.Option` | **Facade-owned** functional-option type that captures into an internal struct and translates at call time. |
| `aicr.RecipeResult` | `pkg/recipe.RecipeResult` | **Facade-owned struct** exposing `Name`, `Version`, `TranslatedAt`, and `Components`. Call `Resolved()` for the full upstream `*pkg/recipe.RecipeResult` (constraints, deployment order, validation config, metadata). The previous `aicr.Recipe` alias was removed in #1115; `ResolveRecipeFromCriteria` and `ResolveRecipeFromSnapshot` now return `*RecipeResult`. |
| `aicr.RecipeResult` | `pkg/recipe.RecipeResult` | **Facade-owned struct** exposing `Name`, `Version`, `TranslatedAt`, and `Components` (enabled/deployable components only — disabled refs remain visible via `Resolved().ComponentRefs`). Call `Resolved()` for the full upstream `*pkg/recipe.RecipeResult` (constraints, deployment order, validation config, metadata). The previous `aicr.Recipe` alias was removed in #1115; `ResolveRecipeFromCriteria` and `ResolveRecipeFromSnapshot` now return `*RecipeResult`. |
| `aicr.AllowLists` | `pkg/recipe.AllowLists` | **Facade-owned struct** with `[]string` fields (Accelerators / Services / Intents / OSTypes). Use `aicr.WrapAllowLists` to lift a `*pkg/recipe.AllowLists`. |
| `aicr.Criteria` | `pkg/recipe.Criteria` | **Facade-owned struct** whose enum-typed fields (Service / Accelerator / Intent / OS / Platform) project to plain strings; Nodes stays an `int` per the facade's string/int contract. Use `aicr.WrapCriteria` to lift a `*pkg/recipe.Criteria`. |
| `aicr.CriteriaRegistry` | `pkg/recipe.CriteriaRegistry` | Documented transparent alias. Kept as an alias intentionally because the registry is behavior-rich (`ParseService`, `SetStrict`, `Values`, ...) and carries mutable per-`DataProvider` state — wrapping would either break the per-Client identity coupling (copy) or add no isolation win over the alias (pointer). |
Expand Down
3 changes: 3 additions & 0 deletions docs/user/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,9 @@ These are the recipe **components** in [`recipes/registry.yaml`](https://github.
> carrying surrounding whitespace are rejected — deployers consume them
> verbatim.
> Incoherent refs are rejected with HTTP 400 naming the component.
> Component ref names must also be unique within a recipe (enabled or
> disabled refs); a duplicate name is rejected with HTTP 400 naming the
> conflicting positions.

```shell
# Basic: pipe recipe to bundle
Expand Down
13 changes: 13 additions & 0 deletions pkg/client/v1/aicr.go
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,9 @@ func facadeResultFromInternal(r *recipe.RecipeResult, name string) *RecipeResult
internal: r,
}
for _, c := range r.ComponentRefs {
if !c.IsEnabled() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🟡 Minor — Facade Components narrowing isn't reflected in the integrator docs

To be clear up front, this change is better than advertised: applyRegistryDefaults populates Type on disabled refs too, so pre-PR Client.BundleComponents returned fully-populated Helm bundles for components that must never deploy — diverging from DefaultBundler.Make's filterEnabledComponents. This is a genuine bug fix. The types.go:271 godoc ("lists the deployable components") already supports it, and the BundleComponents "1:1 — same order, same length" promise is intact because bundles derive from the already-filtered r.Components.

Blast radius: Docs only, but observable: recipes/overlays/ocp.yaml disables ~10 componentRefs, so an SDK caller resolving an OCP recipe sees a materially shorter Components slice after upgrade. docs/integrator/go-library.md#L276 and docs/integrator/public-api.md:67 both describe the field with no enabled-only qualifier.

Fix: Add the qualifier to both doc pages — e.g. "Components — enabled (deployable) components only; disabled refs remain visible via Resolved().ComponentRefs" — and point the types.go:271 godoc at Resolved() for the full set.

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
Expand Down Expand Up @@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🟡 Minor — All-disabled guard: comment claims parity with DefaultBundler.Make it doesn't quite have, plus a dead conjunct and no test

The len(r.internal.ComponentRefs) > 0 conjunct means a zero-componentRef recipe still returns ([], nil) here, while DefaultBundler.MakefilterEnabledComponents (bundler.go#L969) returns ErrCodeInvalidRequest for that same input — so "Agree with DefaultBundler.Make" overstates it. Separately r.internal != nil is dead: line 1004 already returns on nil internal. And rg 'no enabled components' hits only the two production sites — the guard has no test at all.

Blast radius: SDK callers of Client.BundleComponents on a zero-ref recipe (reachable via LoadRecipe of a hand-authored file) still silently no-op. Low frequency; the failure is a missing error rather than a wrong artifact.

Fix: Guard on len(r.Components) == 0 alone — dropping both conjuncts makes the two paths agree exactly — and add an all-disabled plus a zero-ref case in pkg/client/v1.


On @coderabbitai's version of this finding — it argued the guard should key off r.internal.ComponentRefs because Components is caller-mutable. I'd respectfully decline that part: this method's own godoc tells callers to "pass that call's *RecipeResult unchanged," and docs/user/api-reference.md:472 directs subsetting through the bundlers filter, so hand-trimming Components is unsupported by convention — and an actionable error beats the old silent empty success even in that case. The zero-ref divergence is the part worth fixing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@njhensley, agreed. Your distinction is correct: BundleComponents should reject a result with no deployable facade components, whether that comes from all-disabled refs or from an empty recipe.

I withdraw my earlier preference for keying the guard off r.internal.ComponentRefs. Given the documented “pass unchanged” contract, Components is the appropriate operational surface here, and returning an actionable error is preferable to silently succeeding for unsupported caller mutation as well.

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 r.internal != nil check, makes the DefaultBundler.Make parity comment accurate, and closes the zero-ref LoadRecipe path. Adding table-driven facade tests for both an all-disabled recipe and a zero-component-ref recipe would cover the intended contract.


✏️ Learnings added
Learnt from: njhensley
Repo: NVIDIA/aicr PR: 1917
File: pkg/client/v1/aicr.go:1066-1066
Timestamp: 2026-07-28T16:20:16.370Z
Learning: In `pkg/client/v1/aicr.go`, `Client.BundleComponents` documents that callers must pass the `*aicr.RecipeResult` returned by resolution unchanged. Although `RecipeResult.Components` is publicly mutable, caller-side subsetting or mutation is unsupported; an empty `Components` slice should yield an actionable invalid-request error rather than a successful no-op.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

return nil, errors.New(errors.ErrCodeInvalidRequest,
"recipe has no enabled components")
}
Comment on lines +1061 to +1069

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Determine all-disabled status from internal refs.

Components is public and caller-modifiable. Clearing it on a resolved recipe that still has enabled internal refs now incorrectly returns “recipe has no enabled components.” Inspect r.internal.ComponentRefs for enabled refs instead, or explicitly reject a modified facade.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/client/v1/aicr.go` around lines 1061 - 1069, Update the all-disabled
validation in the recipe bundling flow to determine enabled status from
r.internal.ComponentRefs rather than the caller-modifiable r.Components facade.
Return ErrCodeInvalidRequest only when the internal refs contain no enabled
components, while preserving normal bundling when enabled internal refs exist.


bundles := make([]ComponentBundle, 0, len(r.Components))
for i := range r.Components {
// Bail on every iteration so a long recipe doesn't hold
Expand Down
28 changes: 28 additions & 0 deletions pkg/client/v1/aicr_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1368,3 +1368,31 @@ func TestFacadeResultFromInternal_ChartProjection(t *testing.T) {
}
}
}

// TestFacadeResultFromInternal_OmitsDisabledComponents pins the facade
// contract: only deployable (enabled) components appear in the SDK
// facade result. See #1874.
func TestFacadeResultFromInternal_OmitsDisabledComponents(t *testing.T) {
t.Parallel()

internal := &recipe.RecipeResult{
ComponentRefs: []recipe.ComponentRef{
{Name: "enabled-a", Type: recipe.ComponentTypeHelm, Source: "https://charts.example.com", Version: "1.0.0"},
{
Name: "disabled-b",
Type: recipe.ComponentTypeHelm,
Source: "https://charts.example.com",
Version: "1.0.0",
Overrides: map[string]any{"enabled": false},
},
},
}

out := facadeResultFromInternal(internal, "test")
if len(out.Components) != 1 {
t.Fatalf("components = %d, want 1", len(out.Components))
}
if out.Components[0].Name != "enabled-a" {
t.Errorf("got component %q, want enabled-a", out.Components[0].Name)
}
}
4 changes: 3 additions & 1 deletion pkg/client/v1/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,9 @@ type RecipeResult struct {
// internal RecipeResult currently carries no build timestamp.
TranslatedAt time.Time

// Components lists the deployable components in the recipe.
// Components lists the deployable components in the recipe — enabled
// component refs only. Disabled refs are omitted; call Resolved() for
// the full underlying ComponentRefs (enabled and disabled).
Components []ComponentRef

// internal holds the upstream pkg/recipe.RecipeResult so
Expand Down
100 changes: 100 additions & 0 deletions pkg/recipe/componentref_coherence_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 deployer hit the reserved-key loop first (metadata.go:560) and surface the "reserved" message, never the "duplicate" one. That precedence is deliberate and nothing currently pins it — and since neither the code nor the message is asserted today, a precedence inversion would be invisible.

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
}{
{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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

TestPrepareAndValidate_RejectsReservedDeployerName twenty lines above uses name: / refs: / wantErr:, as does TestComponentRefCoherenceProblem earlier in the file.

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: / refs: / wantErr: keys.

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",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 PrepareAndValidate boundary (reserved-key → validateRefNames → backfill → canonicalize → ValidateCoherence).

Blast radius: Pre-existing, not introduced here. But those refs are exactly as unroutable downstream as the duplicates this PR rejects — Name is the key for values materialization, override routing, and deployment ordering.

Fix: Nothing for this PR. A follow-up making an empty Name on an enabled ref a coherenceProblem would close it and make the new API docs true as written.

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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🔵 Nitpick — Test asserts only err != nil, not the error code or message

The check is currently pinned — mutation-tested via go test -overlay with the production block stripped, and all three wantErr:true subtests fail. So this is drift risk, not a present false pass: PrepareAndValidate has four rejection paths (reserved key, back-fill failure, canonicalization, coherence), so a future fixture tweak making one ref merely incoherent would keep this green while the duplicate rule silently rots.

Blast radius: Test only. The sibling TestPrepareAndValidate_RejectsReservedDeployerName fifteen lines up already models the fix, asserting both the code and the message.

Fix: Behind the existing wantErr branch, add stderrors.Is(err, errors.New(errors.ErrCodeInvalidRequest, "")) and strings.Contains(err.Error(), "duplicate"). Two lines.

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)
}
})
}
}
40 changes: 40 additions & 0 deletions pkg/recipe/loader_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@
package recipe

import (
stderrors "errors"
"os"
"path/filepath"
"strings"
"testing"

"github.com/NVIDIA/aicr/pkg/errors"
)

// newTestLayeredProvider builds a LayeredDataProvider over a temp dir holding a
Expand Down Expand Up @@ -128,4 +132,40 @@ func TestLoadFromFileWithProvider(t *testing.T) {
t.Errorf("DataProvider() = %v, want nil for nil-dp path", rec.DataProvider())
}
})

t.Run("hand-authored duplicate componentRef names are rejected", func(t *testing.T) {
// Exercises the most-used boundary — a hand-authored, already-
// hydrated RecipeResult file loaded via LoadFromFileWithProvider,
// which is the shape `aicr bundle -r`/`aicr validate -r` and
// POST /v1/bundle load. See #1874.
layered := newTestLayeredProvider(t)
dir := t.TempDir()
path := filepath.Join(dir, "recipe.yaml")
content := "kind: RecipeResult\n" +
"apiVersion: aicr.run/v1alpha2\n" +
"criteria:\n service: eks\n" +
"componentRefs:\n" +
" - name: gpu-operator\n" +
" type: Helm\n" +
" source: https://charts.example.com\n" +
" version: \"1.0.0\"\n" +
" - name: gpu-operator\n" +
" type: Helm\n" +
" source: https://charts.example.com\n" +
" version: \"1.0.0\"\n"
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write recipe: %v", err)
}

_, err := LoadFromFileWithProvider(t.Context(), path, "", "vtest", layered)
if err == nil {
t.Fatal("expected an error for duplicate componentRef names, got nil")
}
if !stderrors.Is(err, errors.New(errors.ErrCodeInvalidRequest, "")) {
t.Errorf("expected ErrCodeInvalidRequest, got: %v", err)
}
if !strings.Contains(err.Error(), "duplicate") {
t.Errorf("error should mention the duplicate, got: %v", err)
}
})
}
38 changes: 36 additions & 2 deletions pkg/recipe/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🟡 Minor — Criteria-resolve path still bypasses the duplicate check

finalizeRecipeResult calls ValidateCoherence directly and never PrepareAndValidate (metadata_store.go#L977). With an external --data base carrying duplicate refs and no matching overlay, initBaseMergedSpec plain-copies them and the duplicates reach aicr recipe -o, aicr query, and Client.ResolveRecipe.

Blast radius: Limited. Every deploy path still fails closed — bundler.Make calls PrepareAndValidate at bundler.go:237 — so the worst case is an emitted recipe that a later aicr bundle -r rejects. #1874's acceptance criteria asked for exactly what shipped, and the #1656 reserved-name check has the identical gap, so this is a scope boundary rather than a miss.

Fix: Follow-up is fine: extract a shared validateRefNames() and call it from finalizeRecipeResult alongside ValidateCoherence, closing both gaps at once. Worth a code comment either way — RecipeMetadataSpec.Merge silently last-wins-collapses same-name refs via componentMap, so when an overlay does merge, the duplicate vanishes without warning.

// 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
Expand Down
12 changes: 12 additions & 0 deletions pkg/recipe/metadata_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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. RecipeMetadataSpec.Merge rebuilds ComponentRefs from a name-keyed map, so a single overlay merge last-wins-collapses duplicates before validateRefNames sees them. And because LayeredDataProvider layers an external --data dir over the embedded catalog, the embedded monitoring-hpa overlay (criteria: {intent: any}) always matches — an external tree cannot delete it — so ≥1 overlay always merges. Copying recipes/ to /tmp, duplicating the nfd ref in overlays/base.yaml, and running aicr recipe --data … --service eks --accelerator h100 --intent training --os ubuntu exits 0 with a single nfd.

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 adoptRecipePrepareAndValidate.

Fix: No change required. Two notes if you want to revisit later: (1) to make it actually bite, validate pre-merge in initBaseMergedSpec plus per-overlay inside mergeOverlayChains; (2) there's no test covering this call site — a store-level test with a duplicate-carrying base and no matching overlay would have surfaced the inertness at authoring time, and without one the line is free to stay dead silently.

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
Expand Down
Loading