Skip to content

Commit af1fae7

Browse files
committed
feat(recipe): add generation-time runtime inventory selection
Closes the last open ADR-019 Follow-Up requirement: stock adoption needs "generation-time, recipe-recorded selection and opt-out semantics". ADR-019 rejects a bundle-time --set k8s-aibom:enabled=false as a selection contract because it changes neither the recipe nor its health checks. This adds --runtime-inventory enabled|disabled, modelled on the existing --slurm-accounting-mode selection rather than invented: the mode is recorded as configuration.runtimeInventory.mode, the recipe apiVersion becomes ConfiguredRecipeResultAPIVersion, and the component's ref carries install: false. ComponentRef.IsEnabled already reads that key, so the component leaves the resolved set, the bundle, and deployment validation. The health-check half comes for free: the check lives on the component's own ref, so disabling the component removes it. That is simpler than the Slurm precedent, which has to append and omit a check on a sibling component. Selecting a mode on a recipe that does not declare the component is an error, not a silent no-op. Wrong criteria, a typo, or a recipe that never carried it should surface rather than record a decision the recipe cannot honor. The guard runs before Configuration is written so a rejected build leaves no partial record, and unlike the accounting precedent it cannot be validated from criteria alone, so it lives in applyBuildConfig rather than resolveBuildConfig. The same selection is available in AICRConfig at spec.recipe.configuration.runtimeInventory.mode, with the flag taking precedence, mirroring how accounting mode resolves. Verified end to end against the generated CLI: disabled records the mode and the bundler omits the component entirely; enabled records it and keeps the component; a recipe without the component and an invalid value both fail with actionable messages. RecipeConfiguration now has two entries, one bespoke selection per optional component. That boundary is written into the ADR amendment: a generic per-component disable would need a policy for which components may be declined at all, and nothing should let a recipe decline gpu-operator. A third entry is the signal to revisit. Fixes: #2271 Signed-off-by: Mark Chmarny <mark@chmarny.com>
1 parent 3c6aaff commit af1fae7

15 files changed

Lines changed: 515 additions & 23 deletions

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/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: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,30 @@ storage. So the check catches a stranded upgrade that crosses a
354354
storage-version boundary, such as the 1.2.0 to 1.3.0 move this pin made, and
355355
does not catch one within a boundary, such as 1.0.0 to 1.2.0.
356356

357+
**Declining the component.** When a recipe declares `k8s-aibom`, generate with
358+
`--runtime-inventory disabled` to leave it out:
359+
360+
```bash
361+
aicr recipe --service gke --accelerator h100 --os cos --intent inference \
362+
--runtime-inventory disabled -o recipe.yaml
363+
```
364+
365+
The selection is recorded in the emitted recipe as
366+
`configuration.runtimeInventory.mode`, and the component's ref carries
367+
`install: false`, so the component and its health check are both absent from
368+
the bundle and from deployment validation. A bundle-time
369+
`--set k8s-aibom:enabled=false` is **not** equivalent and is not a supported
370+
way to decline the component: it changes neither the recipe nor its health
371+
checks, which is why [ADR-019](https://github.com/NVIDIA/aicr/blob/main/docs/design/019-k8s-aibom-runtime-inventory.md)
372+
rejects it as a selection contract.
373+
374+
Passing the flag on a recipe that does not declare the component is an error
375+
rather than a silent no-op, so a wrong `--service` or a typo surfaces instead
376+
of producing a recipe that claims a decision it never applied.
377+
378+
The same selection is available in an `AICRConfig` document as
379+
`spec.recipe.configuration.runtimeInventory.mode`.
380+
357381
**Overriding the chart version requires overriding this assertion.** Assert
358382
content is static YAML with no templating, so the expected storage version is
359383
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/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) {

pkg/client/v1/config.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,22 @@ func (c *Config) RecipeAccountingMode() (string, bool, error) {
269269
return string(mode), set, nil
270270
}
271271

272+
// RecipeRuntimeInventoryMode returns
273+
// spec.recipe.configuration.runtimeInventory.mode and whether the document set
274+
// one. Same raw-accessor rationale as RecipeAccountingMode.
275+
//
276+
// Returns an error when the configured value is not a valid mode.
277+
func (c *Config) RecipeRuntimeInventoryMode() (string, bool, error) {
278+
if c == nil || c.internal == nil {
279+
return "", false, nil
280+
}
281+
mode, set, err := c.internal.Recipe().ResolveRuntimeInventoryMode()
282+
if err != nil {
283+
return "", false, err
284+
}
285+
return string(mode), set, nil
286+
}
287+
272288
// SnapshotPath returns spec.recipe.input.snapshot, the snapshot a committed
273289
// config resolves against. Empty when unset; hand a non-empty value to
274290
// Client.LoadSnapshot.

pkg/client/v1/types.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -326,8 +326,9 @@ type RecipeRequest struct {
326326
type RecipeResolveOption func(*recipeResolveConfig)
327327

328328
type recipeResolveConfig struct {
329-
profile string
330-
accountingMode *recipe.AccountingMode
329+
profile string
330+
accountingMode *recipe.AccountingMode
331+
runtimeInventoryMode *recipe.RuntimeInventoryMode
331332

332333
// relaxDerived records that WithSnapshotCriteriaRelaxation was passed.
333334
// Kept separate from stated because an empty stated set is meaningful
@@ -374,6 +375,26 @@ func WithAccountingMode(mode string) RecipeResolveOption {
374375
}
375376
}
376377

378+
// WithRuntimeInventoryMode selects whether the runtime AI inventory component
379+
// is installed by a criteria- or snapshot-based resolve call. It is valid only
380+
// when the resolved recipe declares that component; an empty or invalid mode is
381+
// rejected when the resolve call runs. Omit this option to keep the recipe's
382+
// own declaration.
383+
//
384+
// Unlike a bundle-time value override, the selection is recorded in the emitted
385+
// recipe and removes the component's health check along with the component,
386+
// which is the contract ADR-019 requires for stock adoption.
387+
func WithRuntimeInventoryMode(mode string) RecipeResolveOption {
388+
return func(cfg *recipeResolveConfig) {
389+
parsed, err := recipe.ParseRuntimeInventoryMode(mode)
390+
if err != nil {
391+
cfg.recordOptErr(err)
392+
return
393+
}
394+
cfg.runtimeInventoryMode = &parsed
395+
}
396+
}
397+
377398
// RecipeResult is the stable external result shape.
378399
type RecipeResult struct {
379400
// Name is a stable identifier derived from the resolved criteria.

pkg/config/config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,15 @@ type RecipeSpec struct {
127127
// participate in recipe catalog matching.
128128
type RecipeConfigurationSpec struct {
129129
Slurm *SlurmConfigurationSpec `yaml:"slurm,omitempty" json:"slurm,omitempty"`
130+
131+
// RuntimeInventory selects whether the runtime AI inventory component
132+
// (k8s-aibom) is installed. Mirrors the --runtime-inventory flag.
133+
RuntimeInventory *RuntimeInventorySpec `yaml:"runtimeInventory,omitempty" json:"runtimeInventory,omitempty"`
134+
}
135+
136+
// RuntimeInventorySpec contains the runtime AI inventory selection.
137+
type RuntimeInventorySpec struct {
138+
Mode string `yaml:"mode,omitempty" json:"mode,omitempty"`
130139
}
131140

132141
// SlurmConfigurationSpec contains Slurm-specific desired-state inputs.

0 commit comments

Comments
 (0)