Skip to content

Commit 916dae3

Browse files
authored
feat(recipe): add generation-time runtime inventory selection (#2317)
Signed-off-by: Mark Chmarny <mark@chmarny.com>
1 parent db8dcb9 commit 916dae3

23 files changed

Lines changed: 1125 additions & 34 deletions

api/aicr/v1/server.yaml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2622,6 +2622,13 @@ components:
26222622
mode:
26232623
type: string
26242624
enum: [disabled, customer-managed, aicr-provided]
2625+
runtimeInventory:
2626+
type: object
2627+
required: [mode]
2628+
properties:
2629+
mode:
2630+
type: string
2631+
enum: [enabled, disabled]
26252632
componentRefs:
26262633
type: array
26272634
items:
@@ -2812,7 +2819,10 @@ components:
28122819
allOf:
28132820
- $ref: "#/components/schemas/RecipeResponseBase/properties/configuration"
28142821
- type: object
2815-
required: [slurm]
2822+
# At least one section, but not any particular one: a recipe may
2823+
# record a runtime-inventory selection without Slurm accounting, or
2824+
# the reverse. Requiring `slurm` rejected the former outright.
2825+
minProperties: 1
28162826
additionalProperties: false
28172827
properties:
28182828
slurm:
@@ -2828,6 +2838,14 @@ components:
28282838
mode:
28292839
type: string
28302840
enum: [disabled, customer-managed, aicr-provided]
2841+
runtimeInventory:
2842+
type: object
2843+
required: [mode]
2844+
additionalProperties: false
2845+
properties:
2846+
mode:
2847+
type: string
2848+
enum: [enabled, disabled]
28312849

28322850
ProfileRecipeResponse:
28332851
allOf:

docs/design/019-k8s-aibom-runtime-inventory.md

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -555,9 +555,9 @@ semantics.
555555

556556
## Follow-Up Decisions
557557

558-
Three of the six requirements below are resolved by
558+
Four of the six requirements below are resolved by
559559
[Amendment: stock adoption on one GKE recipe](#amendment-stock-adoption-on-one-gke-recipe);
560-
one remains open, and two are planned work tracked in the epic. The list is
560+
the remaining two are planned work tracked in the epic. The list is
561561
retained as originally written; the amendment records what changed, what
562562
remains open, and where the rest is tracked.
563563

@@ -634,25 +634,50 @@ cluster and establishing the pattern for later stock adoptions. This is
634634
recorded plainly rather than framed as customer demand, because the
635635
requirement exists to prevent adoption justified only by availability.
636636

637-
### Still open: selection and opt-out semantics
637+
### E. Selection and opt-out semantics
638638

639-
Unresolved and deliberately not decided here. `ComponentRef.IsEnabled()`
640-
already reads a recipe-recorded `enabled` override, which satisfies "recipe-
641-
recorded" and is not the bundle-time toggle Decision 2 rejects. What remains
642-
undecided is how a user generating from a stock recipe declines the
643-
component: by authoring a custom overlay using the existing mechanism, or by
644-
a generation-time flag that `aicr recipe` records into the emitted recipe.
639+
Resolved 2026-08-20. A **generation-time flag recorded in the emitted recipe**,
640+
modelled on the existing `--slurm-accounting-mode` selection rather than
641+
invented:
645642

646-
The second shape changes the CLI contract, so it is a decision rather than an
647-
implementation detail. It must be resolved before the overlay change in C
648-
merges, and whichever shape is chosen is recorded by amending this section.
643+
```bash
644+
aicr recipe ... --runtime-inventory disabled
645+
```
646+
647+
The selection is recorded as `configuration.runtimeInventory.mode`, the recipe's
648+
`apiVersion` becomes `ConfiguredRecipeResultAPIVersion`, and the component's ref
649+
carries `install: false`. `ComponentRef.IsEnabled()` already reads that key, so
650+
the component leaves the resolved set, the bundle, and deployment validation.
651+
652+
This satisfies Decision 2's specific objection. A bundle-time
653+
`--set k8s-aibom:enabled=false` was rejected because it changes neither the
654+
recipe nor its health checks; here both change, and the health-check half comes
655+
for free because the check lives on the component's own ref rather than on a
656+
sibling. That is simpler than the Slurm accounting precedent, which has to
657+
append and omit a check on a different component.
658+
659+
Passing the flag on a recipe that does not declare the component is an error,
660+
not a silent no-op. Selecting a mode there is a mistake — wrong criteria, a
661+
typo, a recipe that never carried it — and succeeding quietly would record a
662+
decision the recipe cannot honor. The check runs before the configuration is
663+
written, so a rejected build leaves no partial record.
664+
665+
The same selection is available in an `AICRConfig` document at
666+
`spec.recipe.configuration.runtimeInventory.mode`.
667+
668+
**Scope boundary worth naming.** This is the second entry under
669+
`RecipeConfiguration`, and the pattern is one bespoke selection per optional
670+
component. That is deliberate: this ADR asks for this component specifically,
671+
and a generic per-component disable would need a policy for which components
672+
may be declined at all — nothing should let a recipe decline `gpu-operator`.
673+
A third entry is the signal to revisit rather than extend by reflex.
649674

650675
### Requirement status
651676

652677
| Follow-Up requirement | Status |
653678
|---|---|
654679
| Exact recipe families in scope | Resolved — C |
655-
| Selection and opt-out semantics | **Open**see above |
680+
| Selection and opt-out semantics | ResolvedE |
656681
| Non-alpha storage API and migration policy | Resolved — A |
657682
| Concrete user-demand case | Resolved — D |
658683
| Managed-cluster qualification and measured cost | Planned — [#2271](https://github.com/NVIDIA/aicr/issues/2271) |

docs/integrator/go-library.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -721,7 +721,7 @@ func resolveCommittedConfig(ctx context.Context) (retErr error) {
721721
if err != nil {
722722
return err
723723
}
724-
opts, err := cfg.RecipeResolveOptions() // spec.recipe.profile + accounting mode
724+
opts, err := cfg.RecipeResolveOptions() // profile + accounting + runtime inventory
725725
if err != nil {
726726
return err
727727
}
@@ -769,8 +769,8 @@ derive step rather than the load step.
769769
| `BundleVerifyOptions()` | `spec.verify.policy` + `spec.verify.trust` |
770770
| `RecipeSource()` | `spec.recipe.data` |
771771
| `RecipeCriteria(reg)` | `spec.recipe.criteria` |
772-
| `RecipeResolveOptions()` | `spec.recipe.profile`, `spec.recipe.configuration.slurm.accounting.mode` |
773-
| `RecipeProfile()` / `RecipeAccountingMode()` | the same two, raw, for callers applying their own precedence first |
772+
| `RecipeResolveOptions()` | `spec.recipe.profile`, `spec.recipe.configuration.slurm.accounting.mode`, `spec.recipe.configuration.runtimeInventory.mode` |
773+
| `RecipeProfile()` / `RecipeAccountingMode()` / `RecipeRuntimeInventoryMode()` | the same three, raw, for callers applying their own precedence first |
774774
| `SnapshotPath()` | `spec.recipe.input.snapshot` |
775775
| `IsCriteriaStrict()` | `spec.recipe.criteriaStrict` |
776776

docs/user/cli-reference.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,7 @@ Generate recipes using direct system parameters:
459459
| `--platform` | | string | Platform/framework type: dynamo, kubeflow, nim, runai, slurm |
460460
| `--profile` | | string | Profile selection in exact `name=value` form (e.g. `gpuStack=operator-managed` on AKS or `gpuStack=driver-installer` on GKE); omit to use the declaration's default (`gpuStack=azure-managed` on AKS, `gpuStack=gke-default` on GKE) |
461461
| `--slurm-accounting-mode` | | string | Slurm accounting ownership: disabled (default), customer-managed, aicr-provided |
462+
| `--runtime-inventory` | | string | Runtime AI inventory (`k8s-aibom`) selection: `enabled`, `disabled`. Recorded in the generated recipe |
462463
| `--nodes` | | int | Number of GPU nodes in the cluster |
463464
| `--output` | `-o` | string | Output file (default: stdout) |
464465
| `--format` | `-t` | string | Format: json, yaml, table (default: yaml) |
@@ -534,6 +535,7 @@ target-cluster conflict detection.
534535
| `--platform` | | string | Explicit platform/framework type, including slurm |
535536
| `--profile` | | string | Profile selection in exact `name=value` form; omit to use the declaration's default |
536537
| `--slurm-accounting-mode` | | string | Slurm accounting ownership: disabled (default), customer-managed, aicr-provided |
538+
| `--runtime-inventory` | | string | Runtime AI inventory (`k8s-aibom`) selection: `enabled`, `disabled`. Recorded in the generated recipe |
537539
| `--output` | `-o` | string | Output destination (file, ConfigMap URI, or stdout) |
538540
| `--format` | `-t` | string | Format: json, yaml, table (default: yaml) |
539541
| `--kubeconfig` | `-k` | string | Path to kubeconfig file (used when `--snapshot` or `--output` is a ConfigMap URI; overrides KUBECONFIG env) |

docs/user/component-catalog.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,41 @@ storage. So the check catches a stranded upgrade that crosses a
366366
storage-version boundary, such as the 1.2.0 to 1.3.0 move this pin made, and
367367
does not catch one within a boundary, such as 1.0.0 to 1.2.0.
368368

369+
**Declining the component.** No stock recipe declares `k8s-aibom` today, so the
370+
flag applies to a recipe that adds it through a custom overlay — the shape shown
371+
above. Point `--data` at the directory holding that overlay and generate with
372+
`--runtime-inventory disabled`:
373+
374+
```bash
375+
aicr recipe --service gke --accelerator h100 --os cos --intent inference \
376+
--data ./my-recipes --runtime-inventory disabled -o recipe.yaml
377+
```
378+
379+
Passing the flag against a recipe that does not declare the component is an
380+
error, not a silent no-op:
381+
382+
```console
383+
$ aicr recipe --service gke --accelerator h100 --os cos --intent inference \
384+
--runtime-inventory disabled
385+
[INVALID_REQUEST] runtime inventory mode "disabled" requires the recipe to
386+
declare component "k8s-aibom"; this recipe does not resolve it
387+
```
388+
389+
The selection is recorded in the emitted recipe as
390+
`configuration.runtimeInventory.mode`, and the component's ref carries
391+
`install: false`, so the component and its health check are both absent from
392+
the bundle and from deployment validation. A bundle-time
393+
`--set k8s-aibom:enabled=false` is **not** equivalent and is not a supported
394+
way to decline the component: it changes neither the recipe nor its health
395+
checks, which is why [ADR-019](https://github.com/NVIDIA/aicr/blob/main/docs/design/019-k8s-aibom-runtime-inventory.md)
396+
rejects it as a selection contract.
397+
398+
A wrong `--service` or a typo therefore surfaces instead of producing a recipe
399+
that claims a decision it never applied.
400+
401+
The same selection is available in an `AICRConfig` document as
402+
`spec.recipe.configuration.runtimeInventory.mode`.
403+
369404
**Overriding the chart version requires overriding this assertion.** Assert
370405
content is static YAML with no templating, so the expected storage version is
371406
a literal tied to the registry's pinned chart, currently `v1beta1` for chart

pkg/cli/consts.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ const (
3737
flagPlatform = "platform"
3838
flagProfile = "profile"
3939
flagSlurmAccountingMode = "slurm-accounting-mode"
40+
flagRuntimeInventory = "runtime-inventory"
4041
flagNoHealth = "no-health"
4142
)
4243

pkg/cli/query.go

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ Use in shell scripts:
9696
Flags: queryCmdFlags(),
9797
Action: func(ctx context.Context, cmd *cli.Command) error {
9898
if err := validateSingleValueFlags(cmd, "service", "accelerator", "intent", "os", "platform",
99-
flagProfile, flagSlurmAccountingMode, "snapshot", "config", "format", "selector"); err != nil {
99+
flagProfile, flagSlurmAccountingMode, flagRuntimeInventory, "snapshot", "config", "format", "selector"); err != nil {
100100
return err
101101
}
102102

@@ -164,7 +164,7 @@ Use in shell scripts:
164164
func buildRecipeFromCmdWithConfig(ctx context.Context, cmd *cli.Command, cfg *appcfg.AICRConfig, client *aicr.Client) (*aicr.RecipeResult, error) {
165165
reg := client.CriteriaRegistry()
166166
profile := stringFlagOrConfig(cmd, flagProfile, aicr.WrapConfig(cfg).RecipeProfile())
167-
resolveOpts, err := accountingResolveOptions(cmd, cfg)
167+
resolveOpts, err := buildSelectionResolveOptions(cmd, cfg)
168168
if err != nil {
169169
return nil, err
170170
}
@@ -263,6 +263,45 @@ func accountingResolveOptions(cmd *cli.Command, cfg *appcfg.AICRConfig) ([]aicr.
263263
return []aicr.RecipeResolveOption{aicr.WithAccountingMode(value)}, nil
264264
}
265265

266+
// runtimeInventoryResolveOptions turns the --runtime-inventory flag, or the
267+
// equivalent AICRConfig field, into a resolve option. Mirrors
268+
// accountingResolveOptions: the flag wins, the config file is the fallback,
269+
// and an absent selection leaves the recipe's own declaration alone.
270+
func runtimeInventoryResolveOptions(cmd *cli.Command, cfg *appcfg.AICRConfig) ([]aicr.RecipeResolveOption, error) {
271+
value := cmd.String(flagRuntimeInventory)
272+
if !cmd.IsSet(flagRuntimeInventory) {
273+
if cfg == nil {
274+
return nil, nil
275+
}
276+
mode, present, err := aicr.WrapConfig(cfg).RecipeRuntimeInventoryMode()
277+
if err != nil {
278+
return nil, err
279+
}
280+
if !present {
281+
return nil, nil
282+
}
283+
value = mode
284+
}
285+
if _, err := recipe.ParseRuntimeInventoryMode(value); err != nil {
286+
return nil, err
287+
}
288+
return []aicr.RecipeResolveOption{aicr.WithRuntimeInventoryMode(value)}, nil
289+
}
290+
291+
// buildSelectionResolveOptions gathers every generation-time selection into one
292+
// option slice, so callers cannot wire one and forget the other.
293+
func buildSelectionResolveOptions(cmd *cli.Command, cfg *appcfg.AICRConfig) ([]aicr.RecipeResolveOption, error) {
294+
opts, err := accountingResolveOptions(cmd, cfg)
295+
if err != nil {
296+
return nil, err
297+
}
298+
riOpts, err := runtimeInventoryResolveOptions(cmd, cfg)
299+
if err != nil {
300+
return nil, err
301+
}
302+
return append(opts, riOpts...), nil
303+
}
304+
266305
// statedDimensions converts the touched set into the argument
267306
// aicr.WithSnapshotCriteriaRelaxation expects: the dimensions the user stated
268307
// explicitly, which the facade must never relax. Order is canonical so the

pkg/cli/query_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,3 +291,70 @@ spec:
291291
}
292292
return dir
293293
}
294+
295+
// TestRecipeAndQueryCommandsRejectInvalidRuntimeInventoryMode exercises
296+
// runtimeInventoryResolveOptions' flag-set branch on both commands that expose
297+
// the flag.
298+
//
299+
// Without this the function is covered only by its "flag unset and no config"
300+
// fallthrough, which returns before touching the value — so a parse or wiring
301+
// defect in the branch operators actually use would not be observed.
302+
func TestRecipeAndQueryCommandsRejectInvalidRuntimeInventoryMode(t *testing.T) {
303+
tests := []struct {
304+
name string
305+
cmd func() *cli.Command
306+
args []string
307+
}{
308+
{
309+
name: "recipe",
310+
cmd: recipeCmd,
311+
args: []string{"recipe", "--service", "eks", "--runtime-inventory", "off"},
312+
},
313+
{
314+
name: "query",
315+
cmd: queryCmd,
316+
args: []string{
317+
"query", "--service", "eks", "--selector", "deploymentOrder",
318+
"--runtime-inventory", "off",
319+
},
320+
},
321+
}
322+
for _, tt := range tests {
323+
t.Run(tt.name, func(t *testing.T) {
324+
err := tt.cmd().Run(t.Context(), tt.args)
325+
if err == nil {
326+
t.Fatal("command error = nil, want rejection of an invalid runtime inventory mode")
327+
}
328+
// Assert the code, since that is what callers branch on; a text
329+
// match alone would accept an unrelated error carrying similar
330+
// wording. The message check stays to distinguish which
331+
// invalid-request this is.
332+
if !stderrors.Is(err, errors.New(errors.ErrCodeInvalidRequest, "")) {
333+
t.Errorf("command error = %v, want ErrCodeInvalidRequest", err)
334+
}
335+
if !strings.Contains(err.Error(), "invalid runtime inventory mode") {
336+
t.Fatalf("command error = %v, want an invalid-mode rejection", err)
337+
}
338+
})
339+
}
340+
}
341+
342+
// TestRecipeCommandRejectsRuntimeInventoryWithoutComponent covers the flag-set
343+
// branch reaching a successful parse and then failing closed at build time,
344+
// which is the path an operator hits after a typo in --service.
345+
func TestRecipeCommandRejectsRuntimeInventoryWithoutComponent(t *testing.T) {
346+
err := recipeCmd().Run(t.Context(), []string{
347+
"recipe", "--service", "gke", "--accelerator", "h100",
348+
"--os", "cos", "--intent", "inference",
349+
"--runtime-inventory", "disabled",
350+
})
351+
if err == nil {
352+
t.Fatal("command error = nil, want rejection for a recipe that does not declare the component")
353+
}
354+
if !stderrors.Is(err, errors.New(errors.ErrCodeInvalidRequest, "")) {
355+
t.Errorf("command error = %v, want ErrCodeInvalidRequest", err)
356+
}
357+
if !strings.Contains(err.Error(), "requires the recipe to declare component") {
358+
t.Fatalf("command error = %v, want the missing-component rejection", err)
359+
}
360+
}

pkg/cli/recipe.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ func recipeCmdFlags() []cli.Flag {
7676
Usage: fmt.Sprintf("Slurm accounting ownership mode (%s)", strings.Join(recipe.AccountingModes(), ", ")),
7777
Category: catQueryParameters,
7878
}, recipe.AccountingModes),
79+
withCompletions(&cli.StringFlag{
80+
Name: flagRuntimeInventory,
81+
Usage: fmt.Sprintf(
82+
"Runtime AI inventory (k8s-aibom) selection (%s). Recorded in the generated recipe; "+
83+
"disabling removes the component and its health check",
84+
strings.Join(recipe.RuntimeInventoryModes(), ", ")),
85+
Category: catQueryParameters,
86+
}, recipe.RuntimeInventoryModes),
7987
&cli.IntFlag{
8088
Name: "nodes",
8189
Usage: "Number of worker/GPU nodes in the cluster",
@@ -149,7 +157,7 @@ Override snapshot-detected criteria:
149157
Flags: recipeCmdFlags(),
150158
Action: func(ctx context.Context, cmd *cli.Command) error {
151159
if err := validateSingleValueFlags(cmd, flagService, flagAccelerator, flagIntent, flagOS,
152-
flagPlatform, flagProfile, flagSlurmAccountingMode, "snapshot", "config", flagOutput,
160+
flagPlatform, flagProfile, flagSlurmAccountingMode, flagRuntimeInventory, "snapshot", "config", flagOutput,
153161
flagFormat); err != nil {
154162
return err
155163
}

pkg/client/v1/aicr.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -859,10 +859,14 @@ func recipeBuildOptions(opts ...RecipeResolveOption) (*recipeResolveConfig, []re
859859
if err != nil {
860860
return nil, nil, err
861861
}
862-
if cfg.accountingMode == nil {
863-
return cfg, nil, nil
862+
var buildOpts []recipe.BuildOption
863+
if cfg.accountingMode != nil {
864+
buildOpts = append(buildOpts, recipe.WithAccountingMode(*cfg.accountingMode))
864865
}
865-
return cfg, []recipe.BuildOption{recipe.WithAccountingMode(*cfg.accountingMode)}, nil
866+
if cfg.runtimeInventoryMode != nil {
867+
buildOpts = append(buildOpts, recipe.WithRuntimeInventoryMode(*cfg.runtimeInventoryMode))
868+
}
869+
return cfg, buildOpts, nil
866870
}
867871

868872
func resolveRecipeConfig(opts ...RecipeResolveOption) (*recipeResolveConfig, error) {

0 commit comments

Comments
 (0)