Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
6 changes: 5 additions & 1 deletion api/aicr/v1/server.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1653,7 +1653,11 @@ 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) when non-empty; a duplicate
non-empty name is rejected with 400. Refs with an empty
name are exempt from the uniqueness check.
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
4 changes: 4 additions & 0 deletions docs/user/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,10 @@ 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) when non-empty; a duplicate non-empty name is rejected
> with HTTP 400 naming the conflicting positions. Refs with an empty
> name are exempt from the uniqueness check.

```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 whenever there is nothing to deploy — whether
// that's every component disabled or a zero-componentRef recipe. An
// SDK caller that loops over the result and deploys would otherwise
// silently no-op on either input.
if len(r.Components) == 0 {
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
82 changes: 82 additions & 0 deletions pkg/client/v1/aicr_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,60 @@ func TestBundleComponents_HelmComponentLoadsManifestFiles(t *testing.T) {
}
}

// TestBundleComponents_RejectsAllDisabled locks in that an all-disabled
// recipe is rejected the same way DefaultBundler.Make rejects it
// (bundler.go), rather than silently returning an empty, successful
// bundle list. See #1917.
func TestBundleComponents_RejectsAllDisabled(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 — Fold the two new all-disabled / zero-ref tests into one table-driven test

TestBundleComponents_RejectsAllDisabled and TestBundleComponents_RejectsZeroComponentRefs share the same shape — build a client, build a recipe, assert ErrCodeInvalidRequest — differing only in the recipe input. This is @coderabbitai's one remaining actionable comment, and it aligns with the repo's "table-driven tests (required for multiple cases)" convention in CLAUDE.md.

Blast radius: Test readability only — both tests pass and are correct as written.

Fix: Optional: one table with {name, refs, facadeComponents} cases ("all disabled", "zero componentRefs") and a shared assertion body. Two focused tests are also perfectly acceptable; take it or leave it.

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. These are two distinct inputs with the same assertion path, so consolidating them into one table-driven test would be cleaner and matches the repository convention. The current focused tests are correct, though—this remains non-blocking.


🧠 Learnings used
Learnt from: ArangoGutierrez
Repo: NVIDIA/aicr PR: 884
File: pkg/recipe/doc_test.go:27-45
Timestamp: 2026-05-15T14:08:57.038Z
Learning: When applying the repo’s “table-driven tests” guidance, only require/flag table-driven refactoring if the test is genuinely validating multiple independent cases (different inputs and expected outputs). If the test is effectively a single-invariant guard (e.g., one assertion that related constants match the current source of truth, without multiple scenarios to cover), do not require converting it to table-driven form. In practice: if there’s only one real condition/invariant being checked, allow a single test function with one set of assertions; reserve table-driven recommendations for tests that cover two or more distinct scenarios.

t.Parallel()

client := newClientForBundleTest(t)
r := newRecipeResultForBundleTest(client,
[]recipe.ComponentRef{
{
Name: "disabled-a",
Type: recipe.ComponentTypeHelm,
Overrides: map[string]any{"enabled": false},
},
},
nil, // facade Components is empty because the only ref is disabled
)
_, err := client.BundleComponents(context.Background(), r)
if err == nil {
t.Fatal("expected error for all-disabled recipe, got nil")
}
var se *aicrerrors.StructuredError
if !stderrors.As(err, &se) {
t.Fatalf("expected *aicrerrors.StructuredError, got %T: %v", err, err)
}
if se.Code != aicrerrors.ErrCodeInvalidRequest {
t.Errorf("expected ErrCodeInvalidRequest, got %s", se.Code)
}
}

// TestBundleComponents_RejectsZeroComponentRefs locks in that a recipe
// with no componentRefs at all is rejected the same way as an all-
// disabled recipe — the zero-ref divergence from DefaultBundler.Make
// that the dropped r.internal.ComponentRefs conjunct used to miss.
// See #1917.
func TestBundleComponents_RejectsZeroComponentRefs(t *testing.T) {
t.Parallel()

client := newClientForBundleTest(t)
r := newRecipeResultForBundleTest(client, nil, nil)
_, err := client.BundleComponents(context.Background(), r)
if err == nil {
t.Fatal("expected error for zero-componentRef recipe, got nil")
}
var se *aicrerrors.StructuredError
if !stderrors.As(err, &se) {
t.Fatalf("expected *aicrerrors.StructuredError, got %T: %v", err, err)
}
if se.Code != aicrerrors.ErrCodeInvalidRequest {
t.Errorf("expected ErrCodeInvalidRequest, got %s", se.Code)
}
}
Comment on lines +370 to +422

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.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Use a table-driven test for the two rejection cases.

TestBundleComponents_RejectsAllDisabled and TestBundleComponents_RejectsZeroComponentRefs duplicate setup and assertions while varying only the recipe input. Combine them into one table-driven test with subtests.

As per coding guidelines, Go tests with multiple inputs or outcomes must be table-driven.

🤖 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_internal_test.go` around lines 370 - 422, Combine
TestBundleComponents_RejectsAllDisabled and
TestBundleComponents_RejectsZeroComponentRefs into a single table-driven test
with named subtests, storing each recipe input and expected case name in the
test table. Reuse one client and shared BundleComponents invocation and
StructuredError/ErrCodeInvalidRequest assertions for both cases, while
preserving the distinct all-disabled and zero-componentRefs recipe setups.

Source: Coding guidelines


// TestRecipeResultFromInternal_PlumbsHelmFields locks in that the
// translation from pkg/recipe.ComponentRef into the facade's
// ComponentRef carries Source, Chart, and Namespace through. Without
Expand Down Expand Up @@ -1368,3 +1422,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
Loading
Loading