Skip to content

feat(sdk): bind AICRConfig to the facade for verify and recipe - #2243

Merged
mchmarny merged 4 commits into
mainfrom
feat/sdk-config-binding
Aug 18, 2026
Merged

mchmarny merged 4 commits into
mainfrom
feat/sdk-config-binding

Conversation

@mchmarny

Copy link
Copy Markdown
Member

Summary

Adds a facade-owned Config — LoadConfig, WrapConfig, Unwrap, and per-section derivations for spec.verify and spec.recipe — and routes the CLI's reads of those two sections through it.

Motivation / Context

AICRConfig was CLI-only: pkg/config appeared across pkg/cli and nowhere in pkg/client/v1, so a team that had standardized on a committed document could not consume it from their own tooling.

The sharper problem is correctness, not ergonomics. The flags-over-config precedence rule lived only in the CLI, so any non-CLI consumer reimplemented it — and the two could silently disagree about the effective settings for the same file.

Fixes: #2026
Related: #2016, #2024

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactoring (no functional changes)
  • Build/CI/tooling

Component(s) Affected

  • CLI (cmd/aicr, pkg/cli)
  • API server (cmd/aicrd, pkg/server)
  • Recipe engine / data (pkg/recipe)
  • Bundlers (pkg/bundler, pkg/component/*)
  • Collectors / snapshotter (pkg/collector, pkg/snapshotter)
  • Validator (pkg/validator)
  • Core libraries (pkg/errors, pkg/k8s)
  • Docs/examples (docs/, examples/)
  • Other: SDK facade (pkg/client/v1), pkg/config

Implementation Notes

Config derives options; it never applies them. A Config does not attach to a Client and is never consulted implicitly. Each method returns a populated value the caller may then override.

That is the load-bearing decision. The facade's options are plain structs, so a field left at its zero value is indistinguishable from one a caller set to zero deliberately — there is no equivalent of the CLI's cmd.IsSet. An implicit WithConfig merge would have to guess, and would hand the config's value back to a caller who had deliberately cleared a setting. Fixing that properly would mean pointer-wrapping already-shipped option structs.

Deriving keeps precedence to one readable line at the call site, and mirrors what the CLI already does: build from config, then let an explicitly-set flag win.

The flag half necessarily stays in pkg/cli. stringFlagOrConfig and friends are flag-aware by construction; only that layer knows whether a flag was set. So #2026's "no parallel application path" is achieved for loading, resolving, and mapping — not for precedence, which cannot move.

Every method is nil-safe, because the CLI derives unconditionally before it knows whether --config was supplied.

Wrapper, not alias. AICRConfig is a versioned YAML schema with ~30 nested types; aliasing would freeze all of them under the API-diff gate, and Go cannot attach methods to an alias of another package's type anyway. Unwrap() keeps the raw document reachable, with a godoc note that needing it signals a missing derivation.

ToInternalCriteria is exported alongside the existing ToInternalAllowLists. applyCriteriaFromConfig merges into a *recipe.Criteria whose fields are enum types, so a caller deriving criteria from a config needs a supported way back rather than reconstructing the enums by hand.

RecipeProfile / RecipeAccountingMode overlap RecipeResolveOptions on purpose. The options form suits SDK callers; a caller applying its own precedence first — the CLI overlaying an explicitly-set flag — needs the raw value. A test asserts the two agree.

Scoped to verify and recipe, as agreed. spec.bundle, spec.validate, and spec.snapshot follow in a second PR; deriving all five at once is ~80 fields of mapping in a single review.

Test fixtures are per-section because the schema requires it. My first attempt used one "everything" document and the config validator rejected it: spec.recipe.criteria and spec.recipe.input.snapshot are mutually exclusive, and a document must carry at least one section. The validation was right and the fixture was wrong — splitting them also exercises the realistic case of a document configuring some sections and not others.

Testing

make qualify   # exit 0

New pkg/client/v1/config_test.go covers the verify mapping field-by-field, the recipe derivations, the nil-Config contract, section-absent-derives-zero in both directions, loader error propagation, the raw accessors agreeing with the options form, and the ToInternalCriteria round trip.

Coverage: pkg/client/v1 83.2% → 83.6% (+0.4%), pkg/cli 74.8% → 74.8% (0.0%). No new exported function is below 83%; LoadConfig, WrapConfig, Unwrap, RecipeSource, RecipeProfile, SnapshotPath, IsCriteriaStrict, and ToInternalCriteria are at 100%.

Risk Assessment

  • Low — Isolated change, well-tested, easy to revert
  • Medium — Touches multiple components or has broader impact
  • High — Breaking change, affects critical paths, or complex rollout

Additive on the facade. The risk is the CLI rewiring across five files, but each site is a like-for-like swap: same values, same precedence, same errors. The one behavioral subtlety is recipeClientFromCmd, where --data still wins over spec.recipe.data exactly as before.

Rollout notes: No migration. pkg/config remains importable; this adds a supported path without removing one.

Checklist

  • Tests pass locally (make test with -race)
  • Linter passes (make lint)
  • I did not skip/disable tests to make CI green
  • I added/updated tests for new functionality
  • I updated docs if user-facing behavior changed
  • Changes follow existing patterns in the codebase
  • Commits are cryptographically signed (git commit -S)
AICRConfig was CLI-only: pkg/config appeared across pkg/cli and nowhere
in pkg/client/v1, so a team that had standardized on a committed
document could not consume it from their own tooling. Worse, the
flags-over-config precedence rule lived only in the CLI, so any non-CLI
consumer reimplemented it and the two could silently disagree about the
effective settings for the same file.

Adds a facade-owned Config: LoadConfig (path or HTTP(S) URL), WrapConfig
for documents parsed elsewhere, Unwrap, and per-section derivations for
spec.verify and spec.recipe.

Config DERIVES options rather than applying them, and does not attach to
a Client. The facade's options are plain structs, so a field left at its
zero value is indistinguishable from one a caller set to zero
deliberately -- there is no equivalent of the CLI's cmd.IsSet. An
implicit merge would have to guess, and would hand the config's value
back to a caller who had deliberately cleared a setting. Deriving makes
precedence one readable line at the call site, and mirrors what the CLI
already does: build from config, then let an explicitly-set flag win.
The flag half necessarily stays in pkg/cli, the only layer that knows.

Every method is nil-safe, because the CLI derives unconditionally before
it knows whether --config was supplied.

Routes the CLI's spec.verify and spec.recipe reads through the facade:
bundle_verify.go, root.go (recipe source), query.go, recipe.go, and
mirror.go. The two remaining cfg.Recipe() calls are OutputPath and
OutputFormat -- CLI presentation, deliberately not facade surface.

Exports ToInternalCriteria alongside the existing ToInternalAllowLists.
applyCriteriaFromConfig merges into a *recipe.Criteria whose fields are
enum types, so a caller deriving criteria from a config needs a
supported way back rather than reconstructing the enums by hand.

RecipeProfile and RecipeAccountingMode are raw accessors that overlap
RecipeResolveOptions on purpose: the options form suits SDK callers,
while a caller applying its own precedence first needs the raw value. A
test asserts the two agree.

Scoped to verify and recipe as agreed. spec.bundle, spec.validate, and
spec.snapshot follow in a second PR; deriving all five at once would be
~80 fields of mapping in one review.

Test fixtures are per-section because the schema requires it:
spec.recipe.criteria and spec.recipe.input.snapshot are mutually
exclusive and a document must carry at least one section, so a single
"everything" fixture is not a valid AICRConfig. That also exercises the
realistic case of a document configuring some sections and not others.

Fixes: #2026
Signed-off-by: Mark Chmarny <mark@chmarny.com>
@mchmarny mchmarny self-assigned this Aug 18, 2026
@mchmarny mchmarny added area/sdk theme/community Contributor onboarding, docs, and external engagement labels Aug 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor
@github-actions

github-actions Bot commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Report ✅

Metric Value
Coverage 83.1%
Threshold 80%
Status Pass
Coverage Badge
![Coverage](https://img.shields.io/badge/coverage-83.1%25-brightgreen)

Merging this branch will increase overall coverage

Impacted Packages Coverage Δ 🤖
github.com/NVIDIA/aicr/pkg/cli 74.88% (+0.08%) 👍
github.com/NVIDIA/aicr/pkg/client/v1 83.76% (+0.52%) 👍
github.com/NVIDIA/aicr/pkg/config 93.53% (ø)

Coverage by file

Changed files (no unit tests)

Changed File Coverage Δ Total Covered Missed 🤖
github.com/NVIDIA/aicr/pkg/cli/bundle_verify.go 88.06% (ø) 67 59 8
github.com/NVIDIA/aicr/pkg/cli/mirror.go 27.63% (ø) 76 21 55
github.com/NVIDIA/aicr/pkg/cli/query.go 79.47% (+0.66%) 151 120 (+1) 31 (-1) 👍
github.com/NVIDIA/aicr/pkg/cli/recipe.go 90.30% (+1.58%) 134 (+1) 121 (+3) 13 (-2) 👍
github.com/NVIDIA/aicr/pkg/cli/root.go 83.94% (-1.02%) 137 (+4) 115 (+2) 22 (+2) 👎
github.com/NVIDIA/aicr/pkg/client/v1/config.go 93.44% (+93.44%) 61 (+61) 57 (+57) 4 (+4) 🌟
github.com/NVIDIA/aicr/pkg/client/v1/translate.go 81.33% (+0.25%) 75 (+1) 61 (+1) 14 👍
github.com/NVIDIA/aicr/pkg/config/validate.go 98.46% (ø) 65 64 1

Please note that the "Total", "Covered", and "Missed" counts above refer to code statements instead of lines of code. The value in brackets refers to the test coverage of that file in the old version of the code.

@coderabbitai

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

@github-actions

This comment was marked as resolved.

Blocking review finding on #2243: a config naming a criteria value that
exists only in an external catalog could not be loaded at all.

config.Load validated criteria against a nil registry -- the EMBEDDED
catalog -- before spec.recipe.data could construct the provider whose
registry defines the value. Reproduced on the public path with an
overlay contributing service: ncp-review:

  LoadConfig ... [INVALID_REQUEST] invalid service type: ncp-review

That made the provider-aware RecipeCriteria(client.CriteriaRegistry())
path this PR documents unreachable: you could never get past loading to
build the Client whose registry knows the value.

Membership is now checked where a registry exists. Every consumer of
spec.recipe.criteria already routes through ResolveCriteriaWithRegistry
-- the facade's RecipeCriteria and the CLI's applyCriteriaFromConfig --
so a bogus value still fails closed, against the right catalog rather
than the wrong one. Nodes stays a load-time check: a negative count is
malformed regardless of catalog, so there is nothing to defer.

The eager cases in TestValidate_Errors are relocated rather than
dropped. TestValidate_DefersCriteriaMembership now pins both halves --
Validate accepts an external value, ResolveCriteriaWithRegistry still
rejects an unknown one -- so the deferral cannot quietly become silent
acceptance. TestResolveCriteria_InvalidEnums already covered the
per-field membership errors.

Adds the requested regression test over the whole chain, since each hop
is where it previously broke: LoadConfig -> RecipeSource -> NewClient ->
LoadCatalog -> RecipeCriteria, against a fixture overlay contributing a
service the embedded catalog does not know.

That test also pins the strict-mode interaction, which is why it first
failed under make test and passed in isolation: the suite runs with
AICR_CRITERIA_STRICT=1, and strict mode exists precisely to fence off
externally-contributed values. The test disables it deliberately for the
accept case and re-enables it to assert the reject case, rather than
asserting the opposite of what strict mode is for.

Two further review points:

  - loadFacadeConfig called pkg/config.Load through loadCmdConfig,
    keeping the parallel loader path this issue exists to remove. It now
    calls aicr.LoadConfig, so validation and error handling cannot drift
    between what the CLI sees and what an SDK consumer sees.
  - The loader-guard test claimed error codes survive but only checked
    for non-nil. It now asserts ErrCodeInvalidRequest for a malformed
    document and ErrCodeNotFound for a missing file.

Signed-off-by: Mark Chmarny <mark@chmarny.com>
@mchmarny

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

@mchmarny

This comment was marked as resolved.

@coderabbitai

This comment was marked as resolved.

coderabbitai[bot]

This comment was marked as resolved.

Four review findings on #2243.

recipeCmd and five other commands still reached pkg/config.Load through
loadCmdConfig, so a second loader path survived the previous fix. That
matters more than it reads: the blocking finding on this PR was itself a
validation divergence between two phases, and a second loader is how
that class of bug persists unnoticed.

Fixed at loadCmdConfig rather than at the six call sites -- it now
delegates to loadFacadeConfig and unwraps, so every config-aware command
routes through aicr.LoadConfig with no call-site churn. Nothing in
pkg/cli calls config.Load any more.

The call sites keep *config.AICRConfig deliberately. They thread it
through ~10 helper signatures for the spec.bundle / spec.validate /
spec.snapshot sections the facade does not project yet, so converting
now would sprinkle Unwrap() -- the escape hatch this PR's own godoc
flags as a missing-derivation signal -- and then remove it again in the
follow-up. The divergence risk closes in the loader regardless of type.

The integrator guide advertised the provider-aware path while omitting
the step that makes it work: without LoadCatalog the registry is never
seeded with overlay-contributed values, so RecipeCriteria rejects
exactly the value the example exists to demonstrate. Same defect class
as the blocking finding, and anyone following it verbatim would conclude
the fix had not worked. The LoadConfig godoc had the right order; the
guide now matches it, and matches the regression test.

A comment in bundle_verify attributed the identity-pattern and
IgnoreTLog/Key validation to the config derivation. VerifyBundle is what
performs both; the derivation projects spec.verify and nothing else, and
IgnoreTLog has no config field at all. Reworded to say what each layer
does, including why the pairing check is repeated in the CLI -- to word
the message in flags rather than struct fields, which otherwise reads as
redundant.

The ToInternalCriteria test left Platform empty, so that branch of the
mapping never ran and a silent drop would have passed. The fixture now
sets it and both tests assert it; verified by mutation, since commenting
out the mapping previously changed nothing.

Signed-off-by: Mark Chmarny <mark@chmarny.com>
@mchmarny
mchmarny marked this pull request as ready for review August 18, 2026 16:20
@mchmarny
mchmarny requested a review from a team as a code owner August 18, 2026 16:20
coderabbitai[bot]

This comment was marked as resolved.

@mchmarny
mchmarny enabled auto-merge (squash) August 18, 2026 17:06

@njhensley njhensley left a comment

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.

Multi-persona review — Approve with comments

Method: 4 independent persona reviewers (Correctness · Security · Domain/Architecture · Test-coverage) → adversarial senior meta-reviewer re-derived every finding from the resolved code at df3b4c47. Legend: 🔴 Blocker · 🟠 Major · 🟡 Minor · 🔵 Nitpick.

Overall assessment

A careful, well-argued refactor that holds up. The correctness pass found the CLI rewiring like-for-like at every site — same values, same flag-over-config precedence, same error codes — including the two spots most likely to hide a bug: the recipeClientFromCmd restructure in root.go and the mode string-conversion change in query.go. The security pass confirmed the load-time → consumption-time criteria-membership deferral stays fail-closed on all three consuming commands (recipe/query/mirror each LoadCatalog then resolve through the per-provider registry; the server never consumes config criteria; a value in no catalog is still rejected at consume time), and that the IgnoreTLog / trust-floor invariants are preserved (no config field, empty MinTrustLevel still defaults to max, config-supplied identity regexp gets the same ValidateIdentityPattern gate as the flag).

No blockers, no majors. One real 🟡 (a godoc grouping defect on the new public symbol) plus a few 🔵 polish items, all inline.

Additional nitpick (not anchored to a changed line)

  • 🔵 Residual spec.recipe reads bypass the facade — recipeOutputPath (pkg/cli/recipe.go:267) and parseRecipeOutputFormat (277) still read cfg.Recipe().OutputPath() / .OutputFormat() directly on the unwrapped *config.AICRConfig, while criteria/data/profile/accounting reads moved to the facade. These are spec.recipe reads just as projectable as the ones that moved, so the boundary is non-uniform. Defensible as a staged split (consistent with the deferred bundle/validate/snapshot sections) — worth either projecting them too or noting spec.recipe.output as intentionally deferred.

Pre-existing follow-up (out of scope for this PR)

  • 🔵 criteriaStrict honored only by aicr recipe, not query/mirror (pkg/cli/query.go, pkg/cli/mirror.go). A config pairing criteriaStrict: true with an external --data overlay resolves the external value on query/mirror (strict never applied there). This is real but pre-existing — pre-PR recipe.go already applied strict and query/mirror never referenced it; this PR only swaps the accessor. The membership deferral makes it observable but is not a regression. Worth a separate issue.

Confirmed non-issues (examined, not defects)

  • CLI rewiring behaviorally equivalent at every site (values, precedence, error codes).
  • Criteria deferral fail-closed on every consumption path; strict-mode fencing verified.
  • Trust floor intact: no IgnoreTLog config field, empty MinTrustLevel→max, config identity regexp validated.
  • loadCmdConfig now routes through aicr.LoadConfig; no config.Load( remains in pkg/cli (single-loader goal met).
  • Docs snippets match real signatures and the mandatory LoadConfig → NewClient → LoadCatalog → RecipeCriteria ordering.
  • Every new exported method has an exercising + nil-Config test; deferral regression tests pin both halves (accept-at-load, reject-at-consume).

Summary

🔴 Blocker 🟠 Major 🟡 Minor 🔵 Nitpick Pre-existing
0 0 1 4 1

Nothing blocks merge; the 🟡 godoc fix is a one-line blank-line move on the exported SDK symbol and is the only item I'd land before merge.

Comment thread pkg/client/v1/translate.go
Comment thread pkg/cli/root.go
Comment thread pkg/client/v1/config_test.go
Comment thread pkg/client/v1/config.go
@mchmarny
mchmarny merged commit 710b2b9 into main Aug 18, 2026
74 checks passed
@mchmarny
mchmarny deleted the feat/sdk-config-binding branch August 18, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/cli area/docs area/sdk size/XL theme/community Contributor onboarding, docs, and external engagement

2 participants