Recipe Development Guide
This guide covers how to create, modify, and validate recipe metadata.
Quick Start: Contributing a Recipe
New to recipe development? Follow these minimal steps to contribute:
1. Copy an existing overlay (details)
2. Edit criteria and components (criteria, components)
3. Run tests (details)
4. Open PR (best practices)
- Include test output showing recipe generation works
- Explain why the recipe is needed (new hardware, workload, platform)
Overview
Recipe metadata files define component configurations for GPU-accelerated Kubernetes deployments using a base-plus-overlay architecture with three composition mechanisms — single-parent inheritance, explicit mixin composition, and criteria-wildcard matching:
- Base values (
overlays/base.yaml) - universal defaults - Intermediate recipes (
eks.yaml,eks-training.yaml) - shared configurations for categories - Leaf recipes (
gb200-eks-ubuntu-training.yaml) - hardware/workload-specific overrides - Mixins (
mixins/*.yaml) - composable fragments (OS constraints, platform components) that leaf overlays reference viaspec.mixinsinstead of duplicating content - Criteria-wildcard overlays (
gb200-any.yaml) - cross-cutting overlays picked up automatically by the resolver when their wildcard criteria match the query, without being referenced viaspec.baseorspec.mixins - Inline overrides - per-recipe customization without new files
Recipe files in recipes/ are embedded at compile time. Integrators can extend or override using the --data flag (see Advanced Topics).
For query matching and overlay merging internals, see Data Architecture.
Recipe Structure
Multi-Level Inheritance
Recipes use spec.base to inherit configurations. Chains progress from general (base) to specific (leaf):
Intermediate recipes (partial criteria) capture shared configs:
Leaf recipes (complete criteria) match user queries:
Leaf recipes with mixins compose shared fragments:
Mixins use kind: RecipeMixin and carry only constraints and componentRefs. They live in recipes/mixins/ and are applied after inheritance chain merging. See Data Architecture for details.
Some platforms declare their full component stack inline per leaf overlay rather than via a platform mixin. This is the case for --platform slurm and --platform dynamo, where each leaf carries hardware-specific tuning (GPU GRES strings, accelerator resource limits) that the mixin merge path cannot represent cleanly. Other shapes like --platform kubeflow and --intent inference still use the platform-kubeflow / platform-inference mixins shown above, since their leaf-specific tuning is minimal.
For example, --platform slurm leaves inline three componentRefs:
slinky-slurm-operator-crds— SchedMD Slinky CRDsslinky-slurm-operator— the operator and admission webhookslinky-slurm— the Slinky-managed Slurm cluster instance (Controller / LoginSet / NodeSet / RestApi), with leaf-specificoverrides(e.g. GPU GRES wiring onnodesets.slinkyandcontroller.extraConfMap)
This is the same shape dynamo-platform uses across the *-inference-dynamo leaves. See recipes/overlays/h100-eks-ubuntu-training-slurm.yaml for the full example.
When authoring a recipe targeting Talos (criteria.os: talos), append the os-talos mixin to your overlay’s spec.mixins list (e.g. spec.mixins: [os-talos], or [platform-kubeflow, os-talos] if you already mix in a non-OS fragment). OS-scoped mixins are mutually exclusive — combining os-ubuntu and os-talos in one overlay is a recipe authoring error, not a supported composition. The mixin overrides namespaces for affected components and supplies PSA-privileged Namespace manifests via componentRefs[].preManifestFiles, which are applied before each chart — see Talos integration for the component list and labels.
Cross-cutting overlays with wildcard criteria apply across one criteria dimension without being referenced via spec.base or listed in spec.mixins. The resolver can return multiple independent maximal-leaf overlays for a single query, so a service: any overlay is picked up alongside the service-specific maximal leaf and its inheritance chain:
Only use this pattern when the content is truly uniform across the wildcard dimension — if values diverge per service, keep them inline in each service-specific overlay. NCCL performance thresholds, for example, are explicitly not a good fit for this pattern: each service has a different network fabric (EFA, TCPXO, RoCE, etc.) and the same bandwidth number is rarely correct across two fabrics. The intent-scoped gb200-any-training.yaml and b200-any-training.yaml shapes that previously carried cross-service NCCL thresholds were retired (gb200-any-training in #1052, b200-any-training in #1053) in favor of per-leaf performance blocks. See Data Architecture for when to use wildcard overlays vs mixins.
Merge order: base.yaml (lowest) → intermediate → leaf → mixins (highest)
Merge rules:
- Constraints: same-named overridden, new added
- ComponentRefs: same-named merged field-by-field, new added
validation.<phase>blocks merge per-field:checksandconstraintsunion and deduplicate when non-empty (constraintsby name, overlay wins on same-name); an explicit empty list (checks: []/constraints: []) clears the inherited list, while an omitted/null field inherits it;nodeSelectionreplaced wholesale when set;timeout/infrastructureoverlay-wins-if-non-empty- Criteria: not inherited (each recipe defines its own)
- Mixin constraints/components must not conflict with the inheritance chain or other mixins
Slinky Slurm Inline Components
Leaves that need topology-aware scheduling can optionally add slinky-topograph as a fourth componentRef. Its dependencyRefs include slinky-slurm, so it deploys after the Slurm cluster chart: slinky-slurm renders and owns the slinky-slurm-config-extra ConfigMap (from its configFiles, mounted into slurmctld via the Controller CR’s configFileRefs), and Topograph patches only that ConfigMap’s topology.conf key on each sync, preserving the chart-owned cgroup.conf/gres.conf keys — Helm has to own the ConfigMap first, or a later helm upgrade on slinky-slurm would fight Topograph for ownership. The overrides block supplies the provider and engine that are specific to each leaf:
For cloud providers (gcp, aws, oci, nebius, …), slinky-topograph requires IAM access to call the cloud’s topology API. For GKE, bind a GCP service account that has roles/compute.viewer on the project via Workload Identity:
If you prefer not to bake the GCP service account into the recipe, supply it at bundle time instead:
For provider.name: dra (Kubernetes Dynamic Resource Allocation, GA in K8s 1.34), topology is sourced from the DRA API — no cloud provider IAM or ServiceAccount annotations are needed. Use dra for clusters actually running DRA drivers with GPU resource claims. Kind-based CI clusters should use the test provider with a model fixture instead (as this repo’s h100-kind-training-slurm overlay does) — a CPU-only Kind cluster has no DRA resources for the dra provider to read.
Inference performance constraints
The inference-perf performance check reads named entries from
validation.performance.constraints. Two are pass/fail thresholds
(comparator values, 10% tolerance applied by the evaluator) and the rest are
optional inputs that tune the benchmark per accelerator (bare values, no
comparator):
inference-model and inference-concurrency-per-gpu resolve with precedence
recipe constraint > AICR_INFERENCE_PERF_* catalog env > compiled default
(Qwen3-8B at 256/GPU). Set them per overlay to pick the right model and load for
each accelerator — exactly as the throughput/TTFT thresholds already vary per
overlay — while the compiled defaults cover overlays that omit them. Because the
thresholds are only meaningful at a specific model + concurrency, pin all four
together in an overlay rather than relying on the global defaults for the inputs.
inference-routing-mode resolves from the recipe only, defaulting to
dynamo-router; set gateway-epp to validate the GAIE/EPP path through the
AICR-managed inference gateway.
NCCL benchmark profile constraint
The NCCL checks (nccl-all-reduce-bw, -net, -nvls) decide applicability
from the recipe’s criteria against a service + accelerator matrix compiled
into the validator, so a recipe for a service registered only through
external --data (or an embedded service on a new accelerator) skips them by
default. The optional nccl-benchmark-profile entry in
validation.performance.constraints (a bare {accelerator}/{service} value,
no comparator) opts such a recipe into one of the embedded benchmarks:
The profile resolves from the recipe only (no env tier) and fails closed on a malformed or unknown value. Pick the pair whose fabric matches the target hardware — the profile drives the benchmark’s transport template, fabric discovery, and preflights. See Opting external recipes into a benchmark profile for the valid pairs and skip/fail semantics.
When no embedded pair matches — a genuinely private service+accelerator with a
fabric none of the shipped templates cover — supply the benchmark yourself:
ship a Kubeflow TrainingRuntime in your --data tree at
validators/performance/testdata/{accelerator}/{service}/runtime.yaml and
reference it with the nccl-benchmark-runtime-ref constraint (a bare
{accelerator}/{service} value). Run aicr validate --data <dir> ... so the
referenced file is resolvable; it is read and rendered in place of a baked-in
template, keyed on the recipe’s own criteria with no compiled applicability
entry. The runtime owns its fabric wiring (the validator skips service-specific
fabric setup — discovery, preflights, and NVLS/IMEX provisioning — but still
asserts transport for the -net/-nvls variants), must
declare a node replicatedJob, and is mutually exclusive with
nccl-benchmark-profile. Laying the file at the embedded testdata path makes it
a drop-in for upstreaming. See
Supplying a benchmark runtime for a private service.
Component Types
Helm components (most common):
The chart version comes from the registry default — see Chart Version Pinning.
Kustomize components
A component must have either helm OR kustomize configuration, not both.
patchesis not supported. ThecomponentRefs[].patchesfield is not applied by any deployer. An enabled ref that setspatchesis rejected at recipe resolution (rather than silently producing an unpatched bundle), so do not use it. See #1588.
Component Configuration
Chart Version Pinning
Do not set version: (Helm) or tag: (Kustomize) on a componentRef that
installs the component’s registry default. Resolution falls back to the
registry entry’s helm.defaultVersion / kustomize.defaultTag in
recipes/registry.yaml, which is the single source of truth for component
versions — bumping a component means bumping the registry default, in one
place.
Pin a version only when the overlay must intentionally diverge from the
registry default. For recipes contributed to this repo (the embedded
catalog), additionally declare that divergence in versionPinExemptions
(pkg/recipe/version_pin_guard_test.go) with a justification. CI rejects a
non-exempted embedded pin whenever the component has a matching registry
default: a pin that differs from it is undeclared drift, and a pin that
merely repeats it is redundant — it doubles bump churn and shields the
overlay from external registry overrides. Only Helm version divergences
can be exempted today: a Kustomize tag exemption is rejected because the
BOM variants pipeline cannot yet represent it (extend tools/bom/variants.go
first).
External --data overlays are not scanned by this guard: at resolution an
explicit pin always wins over the registry default, so external trees may
pin without declaring anything or rebuilding AICR — see
data extension.
This split keeps external data trees composable: an external --data
registry that overrides a component’s registry default (defaultVersion /
defaultTag) takes effect for every overlay that does not pin, while an
explicit pin still wins. See
issue #1616.
Configuration Patterns
Pattern 1: ValuesFile only (large, reusable configs)
Pattern 2: Overrides only (small, recipe-specific configs)
Pattern 3: Hybrid (shared base + recipe tweaks)
Value Merge Precedence
Values merge from lowest to highest precedence:
Deep merge: only specified fields replaced, unspecified preserved. Arrays replaced entirely (not element-by-element).
Example:
Configuration Profiles
A service or OS overlay may declare one configuration profile when the same
criteria combination has multiple qualified ownership modes. The first
embedded adopter is the AKS family: recipes/overlays/aks.yaml declares
gpuStack (azure-managed default, operator-managed alternative) over the GPU
driver/toolkit ownership paths.
A declaring overlay uses recipe apiVersion aicr.run/v1alpha3:
Every value must carry a distinguishing constraint. ADR-015 requires each value to declare a validation signal that distinguishes its configuration from every sibling value’s. A value with no constraints at all — or with constraints identical to a sibling’s — does not support the “validated against deployed config” claim and must not be declared. The snippet above shows the declaration shape only; it is not a declaration you should copy into an overlay.
Constraint names must be measurement paths a supported snapshot producer
actually emits — a collector, or a provider projection attached at the
snapshot orchestration layer (e.g. K8s.aks-gpu-pools.gpu-driver from
aicr snapshot --aks-gpu-pools) — in
{Type}.{Subtype}.{Key} form. The type must be one the snapshot carries —
K8s, GPU, OS, SystemD, NodeTopology, or NetworkTopology.
Paths are validated when recipe data is loaded, not when a snapshot is
evaluated. Every constraint name in spec.constraints,
spec.validation.readiness.constraints, and spec.profile.values.*.constraints
is checked against the measurement catalog (pkg/measurement/catalog.go) as the
overlay, mixin, or base file is read. A path the catalog cannot address fails
the load with the file, the field, and — where there is a near match — a
suggestion:
This closes a false-PASS. Before, a typo’d-but-grammatical path evaluated as “reading absent from this snapshot”, which the resolver treats as a signal to exclude the overlay gracefully — so the recipe resolved successfully with the constraint never evaluated. See #1783.
Three rules follow from how the extractor addresses values, and each is enforced at load:
- Subtype-level
contextis not addressable.NetworkTopology.identityemitsidentifier,machineType,gpuType,linkType, andnodeSelectortocontext, which extraction never reads. Only itsdatakeys (pf-count,rail-count) can be constrained. - Item subtypes require a selector.
NetworkTopology.pfscarries onlyitems, soNetworkTopology.pfs.railis rejected; writeNetworkTopology.pfs[0].railorNetworkTopology.pfs[rail=3].pciAddress. - Scalar subtypes reject a selector.
NetworkTopology.capabilitieshas noitems, so the[0]/[key=value]forms are invalid there andNetworkTopology.capabilities[0].sriovis rejected. WriteNetworkTopology.capabilities.sriov.
A predicate selector’s key is validated too, so
NetworkTopology.pfs[raill=3].pciAddress fails at load rather than silently
matching nothing later. Index values are not checked — bounds depend on the
snapshot, and an out-of-range index remains a runtime “not found”.
Where a producer’s key space is genuinely open — /etc/os-release fields,
sysctl paths, container image names, node label and taint keys, systemd unit
names and their D-Bus properties — the catalog accepts any key, so those paths
are still only as correct as the author makes them.
SystemD splits at the last dot, every other type at the first. A SystemD
subtype is a unit name that carries a dot (containerd.service) while its
D-Bus property keys do not, so SystemD.containerd.service.ActiveState names
subtype containerd.service and key ActiveState. Every other type has
dot-free subtypes and possibly dotted keys, which is what makes
OS.sysctl./proc/sys/kernel/osrelease and
NodeTopology.label.nvidia.com/gpu.present resolve.
Constraints are then evaluated against the snapshot when one is supplied. A path that is addressable but absent from a particular snapshot is still the designed graceful-exclusion signal: the catalog describes what a producer can emit, not what any one cluster does.
Do not borrow paths from validation.deployment.constraints, such as
Deployment.gpu-operator.version. Those are deployment-phase validator keys
evaluated against a live cluster, not snapshot readings, and Deployment is
not a measurement type.
One name is a node-set form, not a reading path:
NodeTopology.gpu-nodes.label
(#1755). No snapshot producer
emits a gpu-nodes subtype; the evaluator synthesizes the GPU-node set from
the snapshot’s NodeTopology.label readings (nodes carrying
cloud.google.com/gke-accelerator) and quantifies a label predicate over it.
Its value grammar is also not the operator grammar:
<label-key>=<value> asserts every GPU node carries the label with exactly
that value, and !<label-key> asserts no GPU node carries the key. Both
directions fail closed on a truncated node list (a snapshot captured with
--max-nodes-per-entry whose cap actually truncated a participating
reading), on an empty GPU-node universe, and on malformed or ambiguous
label readings (an encoding collision between a disambiguated entry and a
distinct dotted label name — see #2003). It is consumed by the GKE
gpuStack profile values (the positive form qualifies driver-installer, the
negated form gke-default), where each selected value’s constraint is
verified at generation when generating from a snapshot (criteria-only
generation has no snapshot evaluator and defers entirely to the
pre-flight) and re-evaluated by the validate readiness pre-flight. Outside a profile declaration, declare it under
validation.readiness.constraints, not spec.constraints — as a top-level
constraint it would exclude the overlay during snapshot-based generation on
the very cluster the diagnostic exists to fix.
Which signal qualifies a driver-ownership profile depends on the service.
The example above names none, which is why it is shape only. GPU.hardware
readings do not settle it: driver-loaded proves a driver is present, not
which mode installed it.
AKS projects each GPU agent pool’s durable gpuProfile.driver property
into a snapshot reading (K8s.aks-gpu-pools.gpu-driver). The reading
qualifies a selection, it does not make one: the selected value comes
from --profile (or the declaration’s default), and its recorded constraint
is then checked against the reading — azure-managed requires Install,
operator-managed requires None. A None-pool snapshot resolved without
--profile gpuStack=operator-managed therefore fails closed on the azure-managed
default rather than silently switching values. Unavailable, unknown, or
mixed pool values fail closed against either selection. ADR-015 resolves
this signal. The AKS family above was the first embedded adopter; the GKE
family’s gpuStack (device-plugin ownership over the #1755 node-set form,
with advertiser: external on gke-default) is the second.
No equivalent reading exists for other services yet. Declare a
driver-ownership profile only once the signal for that service exists, and
give both values symmetric constraints over it. Do not substitute a signal
that reports something adjacent: GKE’s
gke-no-default-nvidia-gpu-device-plugin label, for instance, governs
device-plugin advertisement rather than driver provisioning
(#1755 keeps the two
deliberately separate), so selecting driver modes with it can pick the wrong
configuration on a supported cluster.
Nothing in core admission blocks a constraint-free declaration — the
distinguishability rule is enforced during catalog review. A value without
constraints still resolves, still returns metadata.selectedProfile, and still
locks its ownedPaths, so shipping one produces a recipe that attests an
unqualified configuration.
Profile declarations are intentionally narrow:
- One declaration may influence a resolved composition. Put it on a shared overlay ancestor; mixins cannot declare profiles.
defaultis required. Profile and value identifiers use[A-Za-z0-9._-]+, and value names must additionally be unique case-insensitively (rejected at catalog load): evidence and corroboration derive lowercase path segments from the selected value, soOperator-Managedandoperator-managedwould collapse onto one evidence directory.- A value permits only
constraintsandcomponentRefs{name,overrides}in the core mechanism.valuesFile, component identity/deployment fields, literal dotted keys, nested empty maps, and rootoverrides.enabledare rejected. - Override leaves must be JSON/YAML scalar values. Finite numbers and YAML
timestamps are supported; non-finite
.nan/.infvalues are rejected because every resolved recipe must remain JSON-serializable. Integers must remain within the JSON round-trip-safe range, from -9007199254740991 through 9007199254740991. - Every value must assign the same flattened override paths. A profile may configure only components already enabled in the surviving composition.
- Profile values may assign overrides only to Helm components. Kustomize components do not consume values overrides, so they may be referenced only without overrides for presence locking.
- Profile constraints must distinguish sibling modes. They are evaluated fail closed during snapshot resolution and remain in the hydrated recipe for later validation. This qualification rule is enforced during catalog review; core admission does not infer whether arbitrary readings semantically distinguish two modes.
- A profile value may declare
advertiser: external(the GKEgke-defaultshape) to record a provider-managed plugin outside the recipe as THEnvidia.com/gpuadvertiser; the vocabulary is closed (empty orexternal), and the declaration extends the #1327 dual-advertisement gates and closure-locks the allocation-policy selector paths.
Select with aicr recipe --profile name=value; omission uses the declared
default. A profiled result uses aicr.run/v1alpha3 and records
metadata.selectedProfile, including declaration-wide ownedPaths. The
lock on owned paths is enforced per surface:
aicr bundlestatic overrides (any static source —--set,--set-json,--set-file, config-file): identical values are accepted, divergent values fail closed. Typed sources are always rejected for theenabledpresence key, even when identical.aicr mirror list --setoverrides: same identical-accepted / divergent-rejected rule; mirror exposes only the repeatable scalar--set(no typed flags) and does not apply a config file’sspec.bundle.deployment.setoverrides.--dynamicexports: fail closed on intersection with an owned path, regardless of value.- argocd-helm install-time values: any owned-key presence fails closed at Helm render time, even when the value is identical.
- Component presence (the synthetic
enabledowned path): removal of an owned component fails closed, and profile fragments cannot assignenabled— so reselecting a profile changes owned value paths only, never which components are present. Presence changes are catalog/composition changes.
Snapshot-driven override — gpu-operator.driver.enabled. When a recipe is
resolved from a snapshot (via aicr recipe --snapshot or
ResolveRecipeFromSnapshot), AICR reads the sampled GPU node’s driver-loaded
measurement. When the NVIDIA kernel module is already loaded, AICR injects
gpu-operator.overrides.driver.enabled=false as an Overrides entry, so it wins
over base and provider values files.
Explicit --set flags at bundle generation, such as
aicr bundle --set gpuoperator:driver.enabled=true, retain higher precedence
and can supersede the injection unless the path is profile-owned. Divergent
overrides of profile-owned paths fail closed. --set is a bundle-time flag,
not an aicr recipe flag.
Profile ownership. When a selected profile owns
gpu-operator.driver.enabled, auto-detection does not mutate that path. The
selected profile remains authoritative.
Static activation gate. Otherwise, injection fires only when the resolved
component values already set gpu-operator.driver.enabled=false and carry the
coordinated preinstalled-driver configuration. This prerequisite is independent
of whether the recipe declares an ADR-015 profile. It scopes auto-detection to
statically prepared overlays such as AKS, GKE-COS, and OKE. Bare EKS is skipped
with a warning instead of leaving the Operator half-configured. The policy is
only-false and never forces true, so recipes resolved without a snapshot use
their static defaults.
Snapshot timing. Capture the snapshot before deploying the GPU
Operator. A snapshot taken after an earlier AICR-managed driver install still
reports driver-loaded=true and could flip a re-deployment toward driverless
nodes. AICR warns when both driver-loaded=true and a gpu-operator
ClusterPolicy are present in the snapshot. See
Component Catalog › GPU Operator Driver Auto-Detect.
Typed Slurm accounting configuration
Slurm accounting ownership is resolved after catalog matching; it is not a
criteria dimension and overlays must not author
configuration.slurm.accounting. Every Slurm leaf declares stable refs for
mariadb-operator-crds, mariadb-operator, and
slurm-accounting-mariadb. The resolver derives their root install gates and
slinky-slurm.accounting.enabled from one typed mode:
The AICR install gate is consumed by ComponentRef.IsEnabled before deployer,
mirror, BOM, and health paths. It is not an upstream chart value. Mode-owned
paths are immutable at bundle time. See
ADR-016 and the
Slurm Accounting guide.
Disable a Component in an Overlay
Set overrides.enabled: false on a componentRef to drop a component a base
recipe would otherwise install. Use this when the target platform already
provides that component — for example a CSP-managed cert-manager on OKE, where
installing a second copy would conflict.
A disabled component is excluded from the recipe’s deploymentOrder and from
the generated bundle. A dependency edge pointing at it is treated as already
satisfied (the component is assumed provided externally), so components that
declare it in dependencyRefs — such as gpu-operator — still resolve and
order correctly instead of failing with a circular-dependency error. A
dependencyRefs entry that names a component which does not exist in the recipe
at all is still an error.
The disabled componentRef remains in the resolved recipe’s componentRefs
(with overrides.enabled: false) for transparency, but it cannot be re-enabled at
bundle time — --set <component>:enabled=true on a recipe-disabled component
is rejected, because re-enabling a platform-provided component would install a
conflicting second copy. Disabling is therefore an authoring decision: to ship
the component, remove the enabled: false override from the recipe/overlay.
See Enable or disable components.
File Naming Conventions
File names are for human readability—matching uses spec.criteria, not file names.
Overlay naming: {accelerator}-{service}-{os}-{intent}-{platform}.yaml (platform always last)
Constraints and Validation
Constraints
Constraints validate deployment requirements against cluster snapshots:
Common measurement paths
Operators: >=, <=, >, <, ==, !=, or exact match (no operator)
Add constraints when: recipe needs specific K8s features, driver versions, OS capabilities, or hardware. Skip when universal or redundant with component self-checks.
Validation Phases
Optional multi-phase validation beyond basic constraints:
Phases: deployment, performance, conformance (readiness constraints are evaluated implicitly)
Testing
Working with Recipes
Adding a New Recipe
When: new platform, hardware, workload type, or combined criteria
Steps:
- Create overlay in
recipes/overlays/with criteria and componentRefs - If the recipe shares OS constraints or platform components with other overlays, reference existing mixins via
spec.mixinsinstead of duplicating (or create new mixins inrecipes/mixins/) - Create component values files if using
valuesFile - Run tests:
make test - Test generation:
aicr recipe --service eks --accelerator gb200 --format yaml
Example:
Updating Recipes
Updating versions: bump the component’s registry default in
recipes/registry.yaml — overlays inherit it, so no overlay edit is needed
(see Chart Version Pinning):
Adding components:
Test changes: aicr recipe --service eks --accelerator gb200 --format yaml
Adding a Component Readiness Gate
A component can declare a readiness gate so that, when a bundle is built with aicr bundle --readiness-hooks, the deploy blocks until a component-specific signal is actually healthy — not just until the chart’s own resources report Ready. This matters for operators whose true readiness lives in a custom resource the deployer can’t assess natively (e.g. gpu-operator’s ClusterPolicy reaching status.state: ready).
Convention: drop a Chainsaw Test at recipes/components/<name>/readiness.yaml. There is no registry field to set — the bundler discovers the file by path. Components without one are simply not gated.
When --readiness-hooks is set, the bundler wraps this test into a NNN-<name>-readiness/ folder containing a Job that runs the gate CLI (ghcr.io/nvidia/aicr-gate). The deploy blocks on that Job — via helm --wait for the helm deployer (the gate Job is a post-install,post-upgrade hook, and --wait blocks on hook completion regardless of --wait-for-jobs), or via Argo CD’s built-in batch/Job health on the next sync-wave for the argocd/argocd-helm deployers. Keep spec.timeouts.assert shorter than the gate’s per-test timeout so a single poll can’t outlast one gate iteration. This is now enforced rather than advisory: the effective budget is the smaller of the authored spec.timeouts.assert and the caller’s per-component budget, so an authored value larger than the caller allows is capped rather than honored. A shorter authored value still shortens the budget as before. That caller budget is the gate’s --timeout (defaults.ReadinessGateExecTimeout, 2m) for a readiness Job, and defaults.ChainsawAssertTimeout for aicr validate --phase deployment — not the expected-resources catalog timeout, which is the outer Job envelope rather than a per-component assertion budget. See Readiness Gates for the deploy-time behavior.
Supported operations. The gate evaluates the Test in-process against its own read-only ServiceAccount — it ships no Chainsaw binary and shells out to nothing. Only assert and error are honored; every other operation (apply, create, delete, patch, update, script, command, wait, sleep, get, describe, events, podLogs, proxy) is rejected before evaluation, as are catch, finally, and cleanup blocks. A readiness test that declares one fails the gate with an invalid-request error naming the offending step.
One action per operation: a try entry that sets both assert and error is rejected. Chainsaw evaluates a single action per operation and the executor reaches assert first, so the error half — the one that forbids a shape — would never run. Split them into separate try entries.
A Test declaring no assert/error operation at all is rejected rather than passing vacuously — a check that evaluates nothing must never report healthy. If the no-op is deliberate, because readiness for that component is enforced some other way, declare it with an annotation on the Test:
The rule applies per document, so in a multi-document (---) stream each Test that carries no operations needs its own annotation.
An empty readiness file — blank, whitespace-only, or nothing but comments and --- separators — is rejected outright. There is no Test to carry the annotation, so the only honest verdict is a failure; a check whose content was lost to a truncated ConfigMap value or a bad template render must not report healthy.
A multi-document stream may hold only Test documents. Once any document in the file is a chainsaw Test, the whole stream is evaluated by the in-process executor, and nothing else reads it — so a raw Kubernetes manifest sitting alongside a Test would be silently ignored while the component still reported ready. Such a stream is rejected by naming the offending document’s kind. Ordinary punctuation (a trailing ---, a comment-only or null document) is not content and is skipped.
Resource blocks that omit metadata.namespace are scoped to the release namespace (the Job passes it via --namespace); cluster-scoped kinds, like the ClusterPolicy above, ignore it.
Best Practices
Do:
- Use minimum criteria fields needed for matching
- Keep base recipe universal and conservative
- Use mixins for shared OS constraints or platform components instead of duplicating across leaf overlays
- Always explain why settings exist (1-2 sentences)
- Follow naming conventions (
{accel}-{service}-{os}-{intent}-{platform}) - Run
make testbefore committing - Test recipe generation after changes
Don’t:
- Add environment-specific settings to base
- Over-specify criteria (too narrow = fewer matches)
- Create duplicate criteria combinations
- Duplicate OS or platform content across leaf overlays (use mixins instead)
- Skip validation tests
- Forget to update context when values change
Testing and Validation
Automated Tests
Tests in pkg/recipe/yaml_test.go validate:
- Schema conformance (YAML structure)
- Criteria enum values (service, accelerator, intent, OS, platform)
- File references (valuesFile, dependencyRefs)
- Constraint syntax (measurement paths, operators)
- No duplicate criteria
- Merge consistency
- No dependency cycles
Running Tests
Test Workflow
- Create recipe file in
recipes/ - Run
make testto validate - Test generation:
aicr recipe --service eks --accelerator gb200 --format yaml - Inspect bundle:
aicr bundle -r recipe.yaml -o ./test-bundles
Tests run automatically on PRs, main pushes, and release builds.
Advanced Topics
External Data Sources
Integrators can extend or override embedded recipe data using the --data flag without modifying the OSS codebase. This enables:
- Custom recipes for proprietary hardware
- Private component values with organization-specific settings
- Extended registries with internal Helm charts
- Rapid iteration without rebuilding binaries
- New criteria values (service / accelerator / OS / intent / platform) admitted at runtime via the catalog-driven criteria registry — no rebuild required
See Data Extension for the full walkthrough (folder layout, registry rules, strict mode, debugging). The summary below is for quick reference.
Directory structure
Usage:
Precedence: Embedded data (lowest) → External data (highest)
Behavior:
- Overlays: Same
metadata.namereplaces embedded - Registry: Merged; same-named components replaced
- Values: External valuesFile references take precedence
- Criteria values: External overlays’
spec.criteriavalues become valid CLI / API inputs at runtime via the criteria registry;--criteria-strict(orAICR_CRITERIA_STRICT=1) rejects external-only values for OSS CI gates
Validation:
Regional registry overrides
A handful of components ship images from regional, account-scoped container registries rather than a single public URI. The clearest example today is the AWS EFA device plugin, whose canonical home is <account>.dkr.ecr.<region>.amazonaws.com/eks/aws-efa-k8s-device-plugin — a per-region private ECR that every EKS node is auto-authorized to pull from. AWS publishes these add-ons regionally for three reasons: pulls go over the AWS internal backbone (no NAT egress), no Docker Hub / public-registry rate limits, and the image stays available even when the public internet or another region is degraded.
AICR ships a sensible default for each such image (e.g., us-west-2 for aws-efa), but customers deploying in a different region need to override the registry’s region segment. Two override paths cover the common cases:
Bundle-time override (single region per bundle). Use --set to bake a specific region into the bundle:
Install-time override (one bundle, many regions). Use --dynamic to declare the path as install-time-fillable, then provide the value via helm install --set (or your GitOps tool):
--dynamic is supported with helm, argocd-helm, and flux deployers; argocd does not support it (use argocd-helm instead). See Dynamic Install-Time Values for the broader pattern.
Partition-aware variants. Standard AWS uses account ID 602401143452. GovCloud and China use different accounts and URI suffixes:
Substitute the appropriate account and suffix in the --set / install-time value.
Troubleshooting
Debug overlay matching:
Common issues:
Validation:
Submitting Your Recipe
Recipes that target hardware AICR maintainers cannot independently
re-run require an evidence bundle so a reviewer can verify the
recipe without owning the hardware. The bundle is a signed,
OCI-distributed artifact that captures the resolved recipe, the
cluster snapshot, the validator phase results, a CycloneDX BOM, and
a manifest of per-file hashes. It is produced by adding two flags to
the same aicr validate invocation you already use to check the
recipe — no separate build step.
When You Need Evidence
You need an evidence bundle when your PR adds or changes a recipe
whose criteria reach hardware or a service that AICR maintainers
cannot independently re-run — most non-H100 GPUs, non-EKS services,
and specialty fabrics fall into this bucket. The recipe-evidence CI
gate posts a sticky Markdown comment on every PR touching
recipes/** and flags (warning-only — it does not block merge) any
touched recipe that has no matching per-source pointer under
recipes/evidence/<recipe>/<src>/.
The proposed material-slice canonicalization aims to let non-material
edits (comments, formatting, displayName, description, key-order)
reuse an existing pointer without a fresh bundle. That semantic slice
is not yet implemented — today’s verifier hashes the normalized
full recipe, so the collapse-to-same-digest behavior is target state,
not current. See
ADR-007 § Material-slice canonicalization
for the proposed slice definition.
Producing the Bundle
Run aicr validate against the cluster that exercises your recipe
and add --emit-attestation (writes the bundle to disk) and
--push (signs and uploads the OCI artifact):
The signer must be allowlisted. The blocking Evidence Pointer Contract gate rejects a committed pointer whose signer is not listed in
recipes/evidence/allowlist.yaml(“signer … is not in the allowlist; add a community/partner entry”). A maintainer adds your verified signer (keyed by its one-waysourceslug, or an anchoredidentityPatternfor CI) as acommunity/partnerentry — coordinate this in your PR; the pointer cannot merge until the entry exists.
--push signs the bundle (cosign keyless via Sigstore) and attaches it to the
OCI artifact as a Sigstore Bundle referrer. The tag is just a label — the
bundle is pinned by its sha256: digest — so omitting it lets aicr derive a
unique per-recipe tag (<recipe-slug>-<short-fingerprint>, e.g.
gb200-eks-ubuntu-training-3f9a1c2b4d5e).
For the full bundle layout, flag reference, tag derivation, OIDC token precedence, and registry compatibility notes, see Emitting recipe evidence. For the end-to-end producer-and-consumer walkthrough, see the Recipe Evidence Demo.
Self-Verifying Before You Open the PR
Run the verifier locally — it is the same code the warning-only
recipe-evidence verify gate runs against the committed pointer. (A
second, blocking Evidence Pointer Contract gate also checks that
the committed pointer is signed and correctly placed under
recipes/evidence/<recipe>/<src>/<digest>.yaml; signing locally and
committing the nested pointer, as above, satisfies it.) A clean local
run keeps the sticky comment green:
Exit 0 means the signature verified, the predicate parsed, every
manifest/file hash matched, and the per-phase CTRF report digests
matched the predicate. The fingerprint and BOM are only surfaced
in the report (signer identity, fingerprint dimensions, phase counts,
BOM info) — they are not cross-checked against the recipe criteria or
registry. A non-zero exit writes a structured
Markdown report describing the specific check that failed. See
aicr evidence verify
for the full check list and exit-code semantics.
What to Include in the PR
The recipe-evidence CI gate posts a Markdown summary as a sticky comment, so you do not need to inline the verifier output. The PR template has no dedicated evidence section, so add the following three pieces of context the verifier cannot infer to the PR description (the Summary or Implementation Notes section is fine):
- The OCI ref of the pushed bundle, digest-pinned, so a
maintainer can audit it directly:
ghcr.io/<owner>/aicr-evidence@sha256:<digest>. - The cluster you attested from — cloud, accelerator SKU, OS, Kubernetes version, node count. The fingerprint dimensions are in the predicate, but the human description is what the maintainer reads first.
- Evidence disposition. If
aicr evidence verifyreported a non-zero exit with a1in the JSON output’sexitfield (signature valid, recorded phase results show failures), include a short justification in the PR description. The maintainer either applies theevidence/known-failurelabel (not yet created — future state) and merges, or requests changes. See Exit-1 Review Process for what counts as an acceptable reason — broadly: optional check not applicable to your hardware, performance ceiling limited by your test bed, or a validator under known active rework.
If You Cannot Push to a Registry
You can still produce a bundle locally without --push. The
resulting ./out/summary-bundle/ directory is unsigned but
otherwise complete:
The verifier records the signature step as “skipped (unsigned)” and the manifest-hash chain becomes self-consistency only — useful for catching accidental corruption during development, but not acceptable for the CI gate, which requires a signed bundle bound to a committed pointer.
- For mechanical changes that touch
recipes/**but carry no recipe semantics (file renames, comment-only changes, license header sweeps, self-bootstrapping evidence-pipeline changes), ask a maintainer to applyevidence/exempt(not yet created — future state) per the bypass policy. Self-applying that label is not appropriate.
“I don’t have the hardware right now, please merge” is not a valid exempt path — see the bypass policy’s “Inappropriate uses.”
Reference
- Emitting recipe evidence — user-facing flag reference and bundle layout
- Recipe Evidence Demo — full producer-and-consumer walkthrough
- Maintaining Recipe Contributions — maintainer-side review checklist
- ADR-007 — bundle format and verifier semantics
See Also
- Data Architecture - Recipe generation process, overlay system, query matching algorithm
- Components - Creating new bundlers
- Maintaining Recipe Contributions - Maintainer runbook for evidence-backed recipe PRs
- CLI Reference - CLI commands for recipe and bundle generation
- API Reference - Programmatic recipe access