Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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