Skip to content

feat(recipe): implement the ADR-015 profile core - #1933

Open
yuanchen8911 wants to merge 1 commit into
NVIDIA:mainfrom
yuanchen8911:feat/adr15-profile-core
Open

feat(recipe): implement the ADR-015 profile core#1933
yuanchen8911 wants to merge 1 commit into
NVIDIA:mainfrom
yuanchen8911:feat/adr15-profile-core

Conversation

@yuanchen8911

@yuanchen8911 yuanchen8911 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Implement the ADR-015 configuration-profile core without adopting it in an existing recipe family. This adds fail-closed profile resolution and ownership locking, strict v2 API and artifact contracts, and documentation for the supported integration boundaries.

Motivation / Context

Recipes need to capture mutually exclusive, qualified installation modes as part of the resolved recipe rather than relying on bundle-time overrides. This is the first roll-in PR: AKS adoption will follow, then GKE adoption.

Fixes: N/A
Related: #1761, #1716

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: API contract, client SDK, serializer, configuration, mirror discovery, evidence, and TestGrid publishing

Implementation Notes

  • Enforce one effective profile declaration per resolved composition. Selection is deterministic through the declared default or an explicit name=value.
  • Validate declaration structure, union totality (every value assigns the same flattened override paths), ownership conflicts, canonical JSON/YAML-shaped override containers with recursively string-keyed and acyclic values, and an explicit scalar allowlist with finite numbers, JSON round-trip-safe integers, and YAML timestamps.
  • Limit value-bearing profile ownership to Helm components. Kustomize components may participate only through a valueless presence lock because their deployment path does not consume Helm values.
  • Record the selected profile and owned paths in aicr.run/v1alpha3 RecipeResult artifacts. Unprofiled results retain the legacy API version and behavior.
  • Resolve directly loaded profile overlays through the active catalog and require the effective applied declaration to structurally match after JSON normalization. Equivalent renamed overlays are accepted; stale or divergent declarations fail closed.
  • Revalidate inline and hydrated profile-owned values when adopting or loading raw RecipeResult artifacts, before observation or serialization, and enforce the same lock at recipe, bundle, mirror, and Argo CD Helm boundaries.
  • Honor cancellation throughout catalog profile projection and response conversion. Sort profile-owned component checks so invalid external compositions produce deterministic diagnostics.
  • Preserve v1 compatibility while exposing strict profile-aware /v2/recipe, /v2/query, and /v2/bundle routes. V2 POST bodies accept only application/json and application/x-yaml, and nested criteria reject unknown fields.
  • Keep HTTP route versions and persisted recipe apiVersions independent. /v2/recipe can return either aicr.run/v1alpha2 or aicr.run/v1alpha3, while /v2/bundle accepts both plus versionless legacy artifacts.
  • Align the published /v1/bundle request schema with the input the runtime already accepts. BundleRecipeRequest requires neither header field and admits an empty apiVersion or kind, matching a legacy artifact decoded and remarshaled through RecipeResult (neither field carries omitempty). Header enums move from RecipeResponseBase onto each wrapper, so GET /v1/recipe responses stay strict. Fixes the same contract drift as Align /v1/bundle OpenAPI schema with versionless legacy recipe support #1941 on the v1 route.
  • Model /v2/bundle as a version-partitioned union without an apiVersion discriminator. Legacy artifacts stamped aicr.run/v1alpha2 or omitting apiVersion reuse the additive known-field schema and cannot carry a profile lock; aicr.run/v1alpha3 is closed and requires metadata.selectedProfile.
  • Decode metadata.excludedOverlays by artifact version. Legacy artifacts accept string entries and additive objects with a nonempty name; v1alpha3 requires closed objects with a nonempty name and a recognized optional reason. Profile artifacts also require complete constraintWarnings entries; the schema defines every ComponentRef field type and closes nested expected-resource objects.
  • Document supported 401, 404, 503, and 504 /v2/bundle failures from vendored-chart and upstream operations.
  • Preserve ConfigMap authorization failures as UNAUTHORIZED and context or Kubernetes API timeout failures as TIMEOUT. Warn when a preinstalled driver conflicts with a profile-owned driver setting.
  • Keep unsupported advertiser, allocation-policy, canonical-descriptor, and qualification-evidence fields fail closed. No recipe family adopts profiles in this PR.

Testing

# Full PR validation completed before the final review-fix deltas.
unset GITLAB_TOKEN && make qualify
golangci-lint run -c .golangci.yaml ./...

# Focused validation of the latest review-fix delta, rerun after the latest rebase.
GOFLAGS="-mod=vendor" go test -race -count=1 ./pkg/recipe \
  -run '^(TestValidateProfileDeclaration|TestValidateRecipeMetadataProfileYAMLScalars|TestProfileResolutionGuards|TestListCatalogWithProfiles_ProjectsEffectiveDeclaration|TestListCatalogWithProfilesCanceled|TestValidateProfileValuesRejectsInvalidBaseline|TestValidateProfileValuesRejectsUnsupportedArtifactScalars|TestValidateProfileValuesScopesArtifactValidationToOwnedPaths|TestValidateProfileValuesKustomizeOwnership)$'
GOFLAGS="-mod=vendor" go test -race -count=1 ./pkg/client/v1 \
  -run '^(TestResolveRecipeWithProfile|TestAdoptRecipe_DeepCopiesForClientIsolation|TestAdoptRecipe_RejectsCyclicProfileOverridesBeforeDeepCopy)$'
GOFLAGS="-mod=vendor" go test -race -count=1 ./pkg/bundler/deployer/argocdhelm \
  -run '^(TestGenerate_ProfileLockTemplate|TestHasGeneratedProfileLockHeader_BoundedRead|TestWriteProfileLockTemplateRejectsUnownedExistingFile)$'

# Focused OpenAPI validation from the preceding review-fix delta.
GOFLAGS="-mod=vendor" go test -race -count=1 ./pkg/server \
  -run '^TestOpenAPIV2BundleContract$'

# Focused validation from the preceding review-fix delta.
GOFLAGS="-mod=vendor" go test -race ./pkg/recipe \
  -run '^(TestMetadataStore_EvaluateOverlayConstraints|TestEvaluateMixinConstraintsRejectsIncompleteConstraint)$'
GOFLAGS="-mod=vendor" go test -race ./pkg/serializer \
  -run '^TestClassifyConfigMapGetError$'
GOFLAGS="-mod=vendor" go test -race ./pkg/server \
  -run '^TestProfileAwareBundleEndpoints$'

# Affected-package lint and contract checks across the review-fix deltas.
golangci-lint run -c .golangci.yaml \
  ./pkg/recipe/... ./pkg/server/... ./pkg/serializer/...
yamllint -c .yamllint.yaml api/aicr/v1/server.yaml
git diff --check origin/main...HEAD

# Documentation-only version-axis clarification.
./tools/check-docs-mdx
lychee --offline --no-progress \
  docs/user/api-reference.md docs/integrator/data-flow.md docs/integrator/go-library.md

The full qualification run passed, including race-enabled unit tests, the 80% aggregate coverage gate (81.0%), lint, 23 end-to-end suites, vulnerability scanning, license checks, and repository-specific validation. Coverage comparisons against origin/main found no affected package decrease greater than 0.5 percentage points.

Across the review-fix deltas, the focused race tests above and affected-package Go lint passed with zero issues. Focused YAML lint and diff checks passed, and independent review feedback on the delta was addressed. The final documentation changes passed the MDX safety check, offline link checks, diff check, and an independent review with zero findings. Full qualification, full-module lint, and coverage were not rerun after those deltas.

Independent verification (commit c0db66781)

The CLI, server, and aicrd were rebuilt from this revision and compared against main at 905bec6e1. Every comparison asserts non-empty output and reports byte or file counts. A run that fails identically on both binaries is classified separately and never registers as a pass.

Regression — no output difference anywhere. 800 comparisons across the full catalog (all 100 resolvable entries; monitoring-hpa is excluded because its criteria are entirely any and nothing resolves it).

  • recipe: 100 comparisons, 8,306,000 bytes of generated YAML, 0 diffs
  • bundle: 500 comparisons (100 entries × helm, argocd, argocd-helm, flux, helmfile), 22,603 files compared with diff -r, 0 diffs
  • query: 100 comparisons, 0 diffs
  • mirror list: 100 comparisons, 0 diffs
  • recipe list: identical, 14,240 bytes

Fifty bundle comparisons produced no comparison: the ten AKS entries fail on both binaries at the keyless-toleration gate, which requires --accelerated-node-toleration. They are counted as bothfail, not as agreement. The AKS bundle path is covered separately by the live-cluster run below, which passes that flag.

Profile mechanism. The catalog declares no profile, so the regression sweep above exercises only the unprofiled path. Two things cover the new path instead.

TestProfileResolutionEndToEnd and its siblings in pkg/recipe/profile_integration_test.go layer a profiled overlay from testdata/profile-overlay/ over the embedded catalog and resolve through the ordinary Builder, so criteria matching, declaration discovery, value selection, override merging, and ownership locking run together rather than as isolated units. Eleven subtests cover default and explicit selection, the owned-path union, override delivery to the componentRefs, the raw-artifact gate, four fail-closed selections, and the legacy half of the biconditional. Both directions were confirmed to fail when the fixture is perturbed: flipping the declared default fails the selection assertion, and dropping one value's override trips union totality.

The fixture is test input, not catalog. It carries no distinguishing constraint, which ADR-015 requires of a shippable declaration, and the file says so — the driver-ownership signal does not exist yet.

Beyond CI, the same mechanism was driven manually through an out-of-tree --data overlay to cover bundle rendering, which the integration tests do not reach.

  • Both values resolve to aicr.run/v1alpha3 with metadata.selectedProfile populated, and bundle cleanly under helm (321 files), argocd (290), and argocd-helm (307) — 1,836 files in total.
  • Selecting the other value changes exactly the two owned paths plus the recorded selection, and nothing else: a three-line diff between the resolved recipes.
  • ownedPaths spans both components, including the synthetic enabled presence path.
  • Fail-closed paths reject as expected: unknown profile value, wrong profile name, and a --set diverging from a profile-owned path.
  • The main binary reads the same profiled overlay as aicr.run/v1alpha2 with no selectedProfile, confirming the mechanism is inert to anything that does not understand it.

Live cluster — AKS H100 (aicr-test6, 2× ND96isr_H100_v5, Kubernetes 1.35). Run against a pinned kubeconfig with the cluster identity verified before use.

  • Snapshot captured from real hardware: 111,221 bytes.
  • Recipe resolved from that live snapshot is identical on both binaries: 79,057 bytes.
  • Bundles from the live recipe are identical across all five deployers: 274 files.
  • aicr validate passed all three phases against the cluster — deployment, conformance, and performance including nccl-all-reduce-bw.

API surface, live against aicrd. Fifteen checks, all passing; byte counts are asserted so a 200 with an empty body fails.

  • /v1/recipe and /v2/recipe return identical bodies for unprofiled criteria: 76,439 bytes each.
  • /v2 accepts application/json and application/x-yaml, and rejects a missing content type, text/plain, and the undeclared application/yaml alias.
  • /v2 rejects unknown envelope fields and unknown query parameters; /v1 remains lenient as designed. Both reject explicit profile input against an unprofiled composition.
  • /v2/query returns a 16,168-byte body.

Review-fix verification. Each fix in this round was checked against a failing state rather than only a passing one.

  • Union totality now matches ADR-015, which evaluates it before synthetic presence paths are added. The earlier implementation folded the synthetic enabled marker into the comparison and rejected a declaration whose values legitimately differ in which components they reference presence-only.
  • Inline ownership requires PathPresent for every non-synthetic owned path. Requiring merely "not absent" left a hole: overrides of {"driver": null} observe as PathBlocked inline, and mergeValues deletes a nil-valued key, so the hydrated observation is PathAbsent and neither check fired. Reproduced before the fix, and the added test fails when the fix is reverted.
  • Profile-value constraints are now shape-validated at catalog load, matching the fail-closed treatment overlay and mixin constraints already receive.
  • The /v2/bundle schema widening was validated with a draft-4 schema validator over real generated artifacts — the check a request-validating gateway or codegen client performs, and one a live request cannot make, since the server accepted these bodies either way.
artifact pre-fix schema this revision
apiVersion: "", kind: "" rejected — matched no oneOf branch accepted
apiVersion absent accepted accepted
aicr.run/v1alpha2 accepted accepted
aicr.run/v1alpha3 profiled accepted accepted

Each shape matches exactly one branch, so the widening admits the round-tripped legacy body without relaxing the versioned ones.

The full ./pkg/... ./cmd/... suite passes: 75 packages ok, 0 failures. golangci-lint run ./... reports 0 issues.

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

The mechanism spans recipe generation, APIs, clients, serialization, and bundle paths. It remains dormant until a recipe composition declares a profile. Compatibility tests cover unprofiled and legacy behavior, while strict v2 media-type and artifact handling intentionally reject undeclared or contradictory inputs.

Rollout notes: No recipe family adopts profiles in this PR. Follow-up PRs will adopt the mechanism for AKS first and GKE next.

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) — GPG signing info

@yuanchen8911 yuanchen8911 changed the title WIP: Add ADR-015 configuration profile core WIP: Implement the profile core in ADR-015 Jul 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Recipe evidence check

No leaf overlays affected by this PR.

This gate is warning-only and never blocks merge.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds configuration profiles with strict validation, profile-aware resolution, selected-profile metadata, ownership locking, and catalog projection. Introduces strict v2 recipe, query, and bundle endpoints while preserving legacy v1 behavior. Propagates profile selection through CLI, configuration, client APIs, bundling, Helm deployment, mirror discovery, serialization, evidence projection, and TestGrid publication. Updates API schemas and documentation accordingly.

Estimated code review effort: 5 (Critical) | ~120 minutes

Suggested labels: area/recipes, area/tests

Suggested reviewers: mchmarny, lockwobr

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main change: implementing the ADR-015 profile core.
Description check ✅ Passed The description is clearly aligned with the profile-core changes and rollout scope described in the PR.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
pkg/server/serve_test.go (1)

80-103: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

This test asserts against its own literal, not against Serve's route table.

routes is a copy of the map in serve.go, so len(routes) != 6 and the membership loop can never detect a route that Serve forgot to register — the two can drift freely. Extracting the map into an unexported newRoutes(h, bh) helper in serve.go and having both Serve and this test call it would make the v2 additions actually covered.

♻️ Sketch
+// serve.go
+func newRoutes(h *recipeHandler, bh *bundleHandler) map[string]http.HandlerFunc {
+	return map[string]http.HandlerFunc{
+		"/v1/recipe": h.HandleRecipes,
+		"/v1/query":  h.HandleQuery,
+		"/v1/bundle": bh.HandleBundles,
+		"/v2/recipe": h.HandleRecipesV2,
+		"/v2/query":  h.HandleQueryV2,
+		"/v2/bundle": bh.HandleBundlesV2,
+	}
+}
-	routes := map[string]http.HandlerFunc{
-		"/v1/recipe": h.HandleRecipes,
-		...
-	}
+	routes := newRoutes(h, bh)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/server/serve_test.go` around lines 80 - 103, Extract the route map
construction from Serve into an unexported newRoutes(h, bh) helper in serve.go,
returning the complete v1 and v2 route table. Update Serve to use this helper,
and update the test to obtain routes through newRoutes instead of duplicating
the literal map so membership and count assertions validate the production route
table.
docs/integrator/data-flow.md (1)

260-285: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Show metadata.selectedProfile in the structure diagram.

The new text promises selected profile and owned paths, but the diagram’s metadata section does not show them. Add the v1alpha3-only field to avoid presenting an incomplete artifact shape.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/integrator/data-flow.md` around lines 260 - 285, Add
metadata.selectedProfile to the RecipeResult structure diagram’s metadata
section, marking it as v1alpha3-only and showing the selected profile value.
Keep the existing metadata fields and diagram structure unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@api/aicr/v1/server.yaml`:
- Around line 2236-2263: Align the v2 request schema with the handler’s strict
decoding by documenting that nested criteria rejects unknown fields. Update the
`Criteria` schema or introduce a v2-specific criteria schema, or explicitly
state this constraint in the `RecipeV2Request` and `QueryV2Request`
descriptions; keep the existing envelope structure and strict behavior
unchanged.
- Around line 855-1275: Add a 413 Payload Too Large response to the POST
/v2/recipe and POST /v2/query OpenAPI definitions, matching the handlers’
MaxRecipePOSTBytes behavior. Document the response with the standard
X-Request-Id header and Error schema, alongside the existing 400/429/500
responses for createRecipeV2 and createQueryV2.

In `@docs/design/015-recipe-configuration-profiles.md`:
- Around line 311-316: Update the `#1761` reference in the fragment schema
section so it remains inline text or uses valid Markdown link syntax; do not
leave it at the start of a line where it is parsed as an ATX heading.

In `@docs/integrator/data-flow.md`:
- Around line 260-262: Update the Markdown fence around the RecipeResult diagram
in the data-flow documentation to use the text language label (`text`) instead
of an unlabeled fence, while preserving the diagram content unchanged.

In `@docs/integrator/recipe-development.md`:
- Around line 503-509: Revise the “Activation gate” section to remove the
requirement that the resolved overlay declare gpu-operator.driver.enabled=false
before driver auto-detection runs. Explain that profiles only prevent injection
from modifying an owned path, and document the existing static configuration
prerequisite separately while preserving the only-false behavior and EKS
warning.

In `@pkg/client/v1/aicr_internal_test.go`:
- Around line 1201-1228: Strengthen TestAdoptRecipe_BoundsProviderIO by ensuring
the deadlineRequiredProvider wrapper records ReadFile/WalkDir activity and
asserting the counter is non-zero after AdoptRecipe succeeds. Use a
profile-bearing recipe fixture with RecipeProfileAPIVersion and
Metadata.SelectedProfile if needed to force PrepareAndValidateWithContext into
ValidateProfileValuesWithContext and exercise the bounded provider I/O path.

In `@pkg/mirror/discover_test.go`:
- Around line 394-422: Extract the repeated profiled recipe setup into a
package-scoped newProfiledRecipe helper, preserving the gpu-operator component
and SelectedProfile values. Also consolidate the duplicated ComponentPath.Parse
logic into a mustParseOverride(t, raw) helper, then replace both fixture copies
and local parsing helpers with these shared functions.
- Around line 699-728: Add the complementary test
TestDiscover_RegistryFailureWithoutOverridesIsNotFatal near
TestDiscover_OverrideAliasRegistryFailureIsFatal, using the malformed registry
and component setup but no WithValueOverrides option. Assert Discover returns no
error, preserving the behavior that registry parsing is skipped when no
overrides are configured.

In `@pkg/recipe/catalog.go`:
- Around line 98-113: The per-entry logic in
MetadataStore.ListCatalogWithProfiles repeatedly scans overlays and resolves
inheritance, causing quadratic catalog listing. Memoize resolved profile
declarations using each entry’s criteria fingerprint, or pre-resolve once per
declaring overlay and reuse the results when assigning profileSummary; preserve
the existing error propagation and output behavior.

In `@pkg/recipe/loader.go`:
- Around line 90-114: Update LoadFromFileWithProvider to read the profile
artifact once and retain its raw bytes, then pass those bytes into
recipe.DecodeRecipeResult so strict profile validation and inputAPIVersion
detection use the same document. Remove the second
serializer.FromFileWithKubeconfigContext decode for RecipeProfileAPIVersion
while preserving provider binding through dp and the existing kind validation.

In `@pkg/recipe/profile_resolution.go`:
- Around line 223-225: Replace the unstable sort.Slice call ordering
mergedSpec.Constraints with a stable sort while preserving the existing
Name-based comparison. Update the sorting logic in the profile resolution flow
so constraints with identical names retain their original order and produce
reproducible artifacts.
- Around line 227-236: Update the component lookup in the profile-resolution
loop around componentIndex and mergedSpec.ComponentRefs to check whether
profileRef.Name exists before using its index. If the component is unknown, fail
closed using the surrounding function’s established error-handling behavior;
only initialize, copy, and merge Overrides after a valid index is confirmed.

In `@pkg/recipe/profile_test.go`:
- Around line 231-252: Update the table-driven tests around
ValidateProfileDeclaration to add a wantOwned map[string][]string field to each
case and compare owned against it without branching on tt.name. Add or adjust an
invalid case so Default is a valid identifier while a per-value name is invalid,
ensuring profile.go’s per-value validation path is exercised.

In `@pkg/recipe/profile.go`:
- Around line 402-410: Move the r == nil guard in PrepareAndValidateWithContext
before calling r.PrepareAndValidate(), returning nil immediately for a nil
receiver. Preserve the existing validation flow for non-nil results, including
the Metadata.SelectedProfile check and ValidateProfileValuesWithContext call.

In `@pkg/serializer/reader_test.go`:
- Around line 1470-1475: Add a table-driven test case alongside the existing
“legacy non-strict JSON remains additive” case in the reader tests, using
non-strict JSON input containing a valid JSON value followed by another
document. Assert that this trailing-document input remains accepted under legacy
non-strict behavior, while keeping the existing strict-mode rejection coverage
unchanged.

In `@pkg/server/bundle_handler.go`:
- Around line 109-125: The decode paths return bare errors and lose structured
error codes. In pkg/server/bundle_handler.go lines 109-125, update the JSON
decoder, io.ReadAll, and recipe.DecodeRecipeResult failures to use
aicrerrors.PropagateOrWrap with the appropriate error code and context; in
pkg/recipe/decode.go lines 35-60, wrap serializer.NewReader failures with
errors.PropagateOrWrap. Preserve existing successful decode behavior and
structured codes.

In `@pkg/server/recipe_handler.go`:
- Around line 128-176: Remove the unreachable second http.MaxBytesError handling
branch after body decoding; io.ReadAll in the recipe POST read path already
handles and returns for that error. Keep the existing read-error handling and
decode/validation flow unchanged for all other errors.
- Around line 629-655: The body-format resolution logic in
legacyRecipeBodyFormat and legacyQueryBodyFormat should be moved into shared
helpers under pkg/recipe, alongside the corresponding parsers. Preserve each
parser’s exact Content-Type rules, including YAML recognition and JSON defaults
for criteria versus JSON detection and YAML defaults for query bodies, then
update the v1 profile guard to call those shared helpers instead of local
implementations.

---

Outside diff comments:
In `@docs/integrator/data-flow.md`:
- Around line 260-285: Add metadata.selectedProfile to the RecipeResult
structure diagram’s metadata section, marking it as v1alpha3-only and showing
the selected profile value. Keep the existing metadata fields and diagram
structure unchanged.

In `@pkg/server/serve_test.go`:
- Around line 80-103: Extract the route map construction from Serve into an
unexported newRoutes(h, bh) helper in serve.go, returning the complete v1 and v2
route table. Update Serve to use this helper, and update the test to obtain
routes through newRoutes instead of duplicating the literal map so membership
and count assertions validate the production route table.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 49c7e94d-51a1-40a2-91a9-bc757c76a29a

📥 Commits

Reviewing files that changed from the base of the PR and between 97e3f8b and 09e5cec.

📒 Files selected for processing (65)
  • api/aicr/v1/server.yaml
  • docs/contributor/api-server.md
  • docs/contributor/recipe.md
  • docs/design/015-recipe-configuration-profiles.md
  • docs/integrator/data-flow.md
  • docs/integrator/go-library.md
  • docs/integrator/recipe-development.md
  • docs/user/api-reference.md
  • docs/user/cli-config.md
  • docs/user/cli-reference.md
  • pkg/bundler/bundler.go
  • pkg/bundler/bundler_test.go
  • pkg/bundler/deployer/argocdhelm/argocdhelm.go
  • pkg/bundler/deployer/argocdhelm/argocdhelm_test.go
  • pkg/bundler/validations/checks.go
  • pkg/bundler/validations/checks_test.go
  • pkg/cli/consts.go
  • pkg/cli/mirror.go
  • pkg/cli/query.go
  • pkg/cli/query_test.go
  • pkg/cli/recipe.go
  • pkg/cli/recipe_test.go
  • pkg/client/v1/aicr.go
  • pkg/client/v1/aicr_internal_test.go
  • pkg/client/v1/aicr_test.go
  • pkg/client/v1/bundle.go
  • pkg/client/v1/gpu_driver_state.go
  • pkg/client/v1/stability_test.go
  • pkg/client/v1/types.go
  • pkg/component/overrides.go
  • pkg/component/overrides_test.go
  • pkg/config/accessors.go
  • pkg/config/accessors_test.go
  • pkg/config/config.go
  • pkg/config/config_test.go
  • pkg/config/validate.go
  • pkg/evidence/project/synthesize.go
  • pkg/evidence/project/synthesize_test.go
  • pkg/mirror/discover.go
  • pkg/mirror/discover_test.go
  • pkg/recipe/builder.go
  • pkg/recipe/catalog.go
  • pkg/recipe/decode.go
  • pkg/recipe/loader.go
  • pkg/recipe/loader_test.go
  • pkg/recipe/metadata.go
  • pkg/recipe/metadata_store.go
  • pkg/recipe/metadata_store_test.go
  • pkg/recipe/profile.go
  • pkg/recipe/profile_resolution.go
  • pkg/recipe/profile_test.go
  • pkg/recipe/query.go
  • pkg/serializer/reader.go
  • pkg/serializer/reader_test.go
  • pkg/server/bundle_handler.go
  • pkg/server/bundle_handler_test.go
  • pkg/server/consts.go
  • pkg/server/doc.go
  • pkg/server/recipe_handler.go
  • pkg/server/recipe_handler_test.go
  • pkg/server/serve.go
  • pkg/server/serve_test.go
  • pkg/server/server.go
  • tools/testgrid-publish/bundle.go
  • tools/testgrid-publish/bundle_test.go

Comment thread api/aicr/v1/server.yaml
Comment thread api/aicr/v1/server.yaml
Comment thread docs/design/015-recipe-configuration-profiles.md Outdated
Comment thread docs/integrator/data-flow.md Outdated
Comment thread docs/integrator/recipe-development.md Outdated
Comment thread pkg/recipe/profile.go
Comment thread pkg/serializer/reader_test.go
Comment thread pkg/server/bundle_handler.go
Comment thread pkg/server/recipe_handler.go
Comment thread pkg/server/recipe_handler.go Outdated
@yuanchen8911
yuanchen8911 force-pushed the feat/adr15-profile-core branch from 09e5cec to dd0b710 Compare July 28, 2026 05:20
@yuanchen8911

Copy link
Copy Markdown
Contributor Author

Addressed the two outside-diff observations in dd0b710:

  • The RecipeResult diagram now shows metadata.selectedProfile with its name, value, owned paths, and v1alpha3-only scope.
  • The production route table now comes from newRoutes, and TestRouteConfiguration exercises that same table instead of duplicating it.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

♻️ Duplicate comments (2)
pkg/serializer/reader_test.go (1)

1463-1521: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Still missing the legacy non-strict YAML trailing-document case.

A past review flagged this same gap: the legacy/non-strict rows only cover JSON (unknown field + trailing document); non-strict YAML is untested for trailing-document tolerance. A regression that applies YAML trailing-document rejection unconditionally would pass this table undetected.

💚 Proposed additional case
 		{
 			name:   "legacy non-strict JSON accepts trailing document",
 			format: FormatJSON,
 			input:  `{"name":"test","value":1} {"name":"other","value":2}`,
 		},
+		{
+			name:   "legacy non-strict YAML tolerates trailing document",
+			format: FormatYAML,
+			input:  "name: test\nvalue: 1\n---\nname: other\nvalue: 2\n",
+		},
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/serializer/reader_test.go` around lines 1463 - 1521, Add a non-strict
YAML test case to TestReader_Strict covering multiple YAML documents separated
by a document marker, with strict set to false and no expected error. Keep the
existing legacy non-strict JSON cases unchanged and verify the test preserves
trailing-document tolerance for YAML.
pkg/recipe/catalog.go (1)

94-120: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Memoization keyed on leaf Criteria likely doesn't fix the quadratic re-resolution.

resolvedByCriteria caches per-entry Criteria, but ListCatalog returns one entry per overlay (leaves and ancestors) and each overlay's Spec.Criteria is typically distinct from its siblings to disambiguate it — so cache hits are rare in practice, and FindMatchingOverlays + resolveProfileDeclaration (itself walking resolveInheritanceChain per matched overlay) still run close to once per catalog entry. This is the same cost profile flagged in a prior review of this function; consider memoizing per declaring overlay/ancestor chain instead (e.g., resolve declarations once per distinct base+ancestor set, then project onto entries) to actually amortize the cost across aicr recipe list calls.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/recipe/catalog.go` around lines 94 - 120, Replace the per-entry
resolvedByCriteria memoization in ListCatalogWithProfiles with caching keyed by
each distinct declaring overlay or base/ancestor chain, so
resolveProfileDeclaration and resolveInheritanceChain are performed once per
shared declaration set rather than once per leaf Criteria. Reuse the cached
effective profile when projecting profileSummary onto catalog entries, while
preserving existing filtering and resolution error behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@api/aicr/v1/server.yaml`:
- Around line 2383-2386: Update the advertiser field description in the API
schema to state that it must be absent for now because ValidateProfileContract
rejects any non-empty value, including external; retain the enum and clarify
that support is reserved for the future GKE allocation-policy extension.
- Around line 1262-1293: Update the POST /v2/bundle response definitions to
include a 413 Payload Too Large response, matching the existing Error schema and
response-header conventions used by the route and the corresponding v2
recipe/query POST endpoints.

In `@pkg/mirror/discover.go`:
- Around line 389-399: Add a context-cancellation check at the start of the
ComponentRefs loop in GetComponentRegistryFor, before per-component override
lookup and hydration work. Return the context error immediately when canceled,
while preserving the existing effectiveOverridesForComponent behavior and error
propagation.

In `@pkg/recipe/criteria_test.go`:
- Around line 1124-1159: Extend TestParseCriteriaFromBody with a table-driven
case using malformed JSON and an unrecognized content type, then assert the
returned error matches the unsupported-content-type error contract from the
branch in ParseCriteriaFromBody. Preserve the existing cases and verify both the
error outcome and any expected result values for this new input.

In `@pkg/recipe/profile.go`:
- Around line 165-217: Update the union-totality tracking in the profile value
loop so every referenced component contributes its synthetic enabled path to
valuePaths, including components with no overrides. Use the same component/path
representation as ownedSet, sort it with the existing paths, and keep the
comparison against expected so values with different component sets are
rejected.

In `@pkg/recipe/query_request.go`:
- Around line 35-42: Update QueryRequestBodyFormat to normalize contentType case
before checking for "json", so uppercase and mixed-case JSON media types return
serializer.FormatJSON while existing YAML fallback behavior remains unchanged.
Update the corresponding test expectation to cover the case-insensitive JSON
behavior.

In `@pkg/serializer/reader.go`:
- Around line 484-519: Update ReadFileBytesWithKubeconfigContext to return the
bytes already buffered by NewFileReaderWithContext via reader.input instead of
wrapping it with io.LimitReader and io.ReadAll. Remove the redundant length
validation while preserving the existing serializer creation, close handling,
format, and error behavior.

In `@pkg/server/bundle_handler_test.go`:
- Around line 161-201: Add two HTTP-level subtests alongside the existing v2
bundle cases using the shared post helper: submit profileBundleBody(t) with
set=gpu-operator:driver.enabled=true and dynamic=gpu-operator:driver.enabled
query overrides, and assert each response returns http.StatusBadRequest. These
tests should cover the profile lock enforced by ValidateProfileLock.

In `@pkg/server/recipe_handler.go`:
- Around line 227-231: Update the documented 400 responses for v1 recipe and
query endpoints in server.yaml to include the profile-based rejection from the
v1 handler, clearly indicating that profiled recipes require /v2/recipe. Keep
the existing malformed-criteria, allowlist, and uncovered-dimension causes
unchanged.
- Around line 557-565: Update decodeStrictV2Envelope and bodyHasTopLevelProfile
so serializer construction and deserialization failures are wrapped with
pkg/errors and contextual messages, ensuring client-facing errors retain the
INVALID_REQUEST code prefix. Propagate errors that already carry the correct
pkg/errors code without double-wrapping them, including failures returned by
Deserialize.

---

Duplicate comments:
In `@pkg/recipe/catalog.go`:
- Around line 94-120: Replace the per-entry resolvedByCriteria memoization in
ListCatalogWithProfiles with caching keyed by each distinct declaring overlay or
base/ancestor chain, so resolveProfileDeclaration and resolveInheritanceChain
are performed once per shared declaration set rather than once per leaf
Criteria. Reuse the cached effective profile when projecting profileSummary onto
catalog entries, while preserving existing filtering and resolution error
behavior.

In `@pkg/serializer/reader_test.go`:
- Around line 1463-1521: Add a non-strict YAML test case to TestReader_Strict
covering multiple YAML documents separated by a document marker, with strict set
to false and no expected error. Keep the existing legacy non-strict JSON cases
unchanged and verify the test preserves trailing-document tolerance for YAML.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 22042aa9-be7e-44f0-9dc3-e8004e89ade4

📥 Commits

Reviewing files that changed from the base of the PR and between 09e5cec and dd0b710.

📒 Files selected for processing (69)
  • api/aicr/v1/server.yaml
  • docs/contributor/api-server.md
  • docs/contributor/recipe.md
  • docs/design/015-recipe-configuration-profiles.md
  • docs/integrator/data-flow.md
  • docs/integrator/go-library.md
  • docs/integrator/recipe-development.md
  • docs/user/api-reference.md
  • docs/user/cli-config.md
  • docs/user/cli-reference.md
  • pkg/bundler/bundler.go
  • pkg/bundler/bundler_test.go
  • pkg/bundler/deployer/argocdhelm/argocdhelm.go
  • pkg/bundler/deployer/argocdhelm/argocdhelm_test.go
  • pkg/bundler/validations/checks.go
  • pkg/bundler/validations/checks_test.go
  • pkg/cli/consts.go
  • pkg/cli/mirror.go
  • pkg/cli/query.go
  • pkg/cli/query_test.go
  • pkg/cli/recipe.go
  • pkg/cli/recipe_test.go
  • pkg/client/v1/aicr.go
  • pkg/client/v1/aicr_internal_test.go
  • pkg/client/v1/aicr_test.go
  • pkg/client/v1/bundle.go
  • pkg/client/v1/gpu_driver_state.go
  • pkg/client/v1/stability_test.go
  • pkg/client/v1/types.go
  • pkg/component/overrides.go
  • pkg/component/overrides_test.go
  • pkg/config/accessors.go
  • pkg/config/accessors_test.go
  • pkg/config/config.go
  • pkg/config/config_test.go
  • pkg/config/validate.go
  • pkg/evidence/project/synthesize.go
  • pkg/evidence/project/synthesize_test.go
  • pkg/mirror/discover.go
  • pkg/mirror/discover_test.go
  • pkg/recipe/builder.go
  • pkg/recipe/catalog.go
  • pkg/recipe/criteria.go
  • pkg/recipe/criteria_test.go
  • pkg/recipe/decode.go
  • pkg/recipe/loader.go
  • pkg/recipe/loader_test.go
  • pkg/recipe/metadata.go
  • pkg/recipe/metadata_store.go
  • pkg/recipe/metadata_store_test.go
  • pkg/recipe/profile.go
  • pkg/recipe/profile_resolution.go
  • pkg/recipe/profile_test.go
  • pkg/recipe/query.go
  • pkg/recipe/query_request.go
  • pkg/recipe/query_test.go
  • pkg/serializer/reader.go
  • pkg/serializer/reader_test.go
  • pkg/server/bundle_handler.go
  • pkg/server/bundle_handler_test.go
  • pkg/server/consts.go
  • pkg/server/doc.go
  • pkg/server/recipe_handler.go
  • pkg/server/recipe_handler_test.go
  • pkg/server/serve.go
  • pkg/server/serve_test.go
  • pkg/server/server.go
  • tools/testgrid-publish/bundle.go
  • tools/testgrid-publish/bundle_test.go

Comment thread api/aicr/v1/server.yaml
Comment thread api/aicr/v1/server.yaml Outdated
Comment thread pkg/mirror/discover.go
Comment thread pkg/recipe/criteria_test.go
Comment thread pkg/recipe/profile.go
Comment thread pkg/recipe/query_request.go
Comment thread pkg/serializer/reader.go
Comment thread pkg/server/bundle_handler_test.go
Comment thread pkg/server/recipe_handler.go
Comment thread pkg/server/recipe_handler.go
@yuanchen8911
yuanchen8911 force-pushed the feat/adr15-profile-core branch from dd0b710 to 01d8670 Compare July 28, 2026 05:58

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
pkg/recipe/profile.go (1)

165-217: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Union totality still excludes the synthetic enabled path, so values can reference different component sets.

valuePaths collects only override-derived paths (Line 196) while ownedSet gains enabled for every referenced component (Line 205). A value that lists a componentRef with no overrides and a sibling value that omits that component entirely both produce identical valuePaths and pass the totality check, yet OwnedPaths locks a component the sibling never declares.

🐛 Include component presence in the totality comparison
 			if ownedSet[ref.Name] == nil {
 				ownedSet[ref.Name] = make(map[string]struct{})
 			}
 			ownedSet[ref.Name][profileComponentEnabledPath] = struct{}{}
+			valuePaths = append(valuePaths, ref.Name+":"+profileComponentEnabledPath)
 		}
 		sort.Strings(valuePaths)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/recipe/profile.go` around lines 165 - 217, Update the union-totality path
collection in the loop over value.ComponentRefs so every referenced component
contributes its synthetic profileComponentEnabledPath to valuePaths, including
refs with no overrides. Keep the existing override-path and ownedSet handling,
ensure the component identifier and enabled path use the same representation as
expected, and compare values based on complete component presence.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/bundler/bundler.go`:
- Around line 309-312: Guard b.Config before invoking buildDynamicValuesMap in
the surrounding bundling flow, ensuring zero-value or struct-literal
DefaultBundler instances return through the existing validation path instead of
dereferencing a nil configuration. Preserve the current dynamicValues error
propagation for configured bundlers.

In `@pkg/recipe/loader.go`:
- Around line 144-151: Move the unsupported inputAPIVersion validation
immediately after inputAPIVersion is captured, before the overlay branch and any
RecipeMetadata.BuildFromCriteria hydration. Preserve the existing acceptance of
empty, supported, and RecipeProfileAPIVersion values, while returning the same
invalid-request error for unsupported versions.

In `@pkg/serializer/reader_test.go`:
- Around line 200-239: Refactor TestReadFileBytesWithKubeconfigContext into a
table-driven test containing rows for the local file, missing file, invalid
ConfigMap URI, and ConfigMap kubeconfig error cases. Store each row’s
setup/input and expected bytes/format or errors.ErrCode, then run them through
one subtest loop while preserving the existing assertions and per-case temporary
file or kubeconfig setup.

In `@pkg/serializer/reader.go`:
- Around line 123-127: Extract the duplicated option-application loop from
NewReader and NewFileReaderWithContext into a shared applyReaderOptions helper
accepting *Reader and variadic ReaderOption values; invoke this helper from both
constructors while preserving the existing nil-option check and application
order.

---

Duplicate comments:
In `@pkg/recipe/profile.go`:
- Around line 165-217: Update the union-totality path collection in the loop
over value.ComponentRefs so every referenced component contributes its synthetic
profileComponentEnabledPath to valuePaths, including refs with no overrides.
Keep the existing override-path and ownedSet handling, ensure the component
identifier and enabled path use the same representation as expected, and compare
values based on complete component presence.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: 79c2e062-f83c-4b06-921e-b09defabef28

📥 Commits

Reviewing files that changed from the base of the PR and between dd0b710 and 01d8670.

📒 Files selected for processing (69)
  • api/aicr/v1/server.yaml
  • docs/contributor/api-server.md
  • docs/contributor/recipe.md
  • docs/design/015-recipe-configuration-profiles.md
  • docs/integrator/data-flow.md
  • docs/integrator/go-library.md
  • docs/integrator/recipe-development.md
  • docs/user/api-reference.md
  • docs/user/cli-config.md
  • docs/user/cli-reference.md
  • pkg/bundler/bundler.go
  • pkg/bundler/bundler_test.go
  • pkg/bundler/deployer/argocdhelm/argocdhelm.go
  • pkg/bundler/deployer/argocdhelm/argocdhelm_test.go
  • pkg/bundler/validations/checks.go
  • pkg/bundler/validations/checks_test.go
  • pkg/cli/consts.go
  • pkg/cli/mirror.go
  • pkg/cli/query.go
  • pkg/cli/query_test.go
  • pkg/cli/recipe.go
  • pkg/cli/recipe_test.go
  • pkg/client/v1/aicr.go
  • pkg/client/v1/aicr_internal_test.go
  • pkg/client/v1/aicr_test.go
  • pkg/client/v1/bundle.go
  • pkg/client/v1/gpu_driver_state.go
  • pkg/client/v1/stability_test.go
  • pkg/client/v1/types.go
  • pkg/component/overrides.go
  • pkg/component/overrides_test.go
  • pkg/config/accessors.go
  • pkg/config/accessors_test.go
  • pkg/config/config.go
  • pkg/config/config_test.go
  • pkg/config/validate.go
  • pkg/evidence/project/synthesize.go
  • pkg/evidence/project/synthesize_test.go
  • pkg/mirror/discover.go
  • pkg/mirror/discover_test.go
  • pkg/recipe/builder.go
  • pkg/recipe/catalog.go
  • pkg/recipe/criteria.go
  • pkg/recipe/criteria_test.go
  • pkg/recipe/decode.go
  • pkg/recipe/loader.go
  • pkg/recipe/loader_test.go
  • pkg/recipe/metadata.go
  • pkg/recipe/metadata_store.go
  • pkg/recipe/metadata_store_test.go
  • pkg/recipe/profile.go
  • pkg/recipe/profile_resolution.go
  • pkg/recipe/profile_test.go
  • pkg/recipe/query.go
  • pkg/recipe/query_request.go
  • pkg/recipe/query_test.go
  • pkg/serializer/reader.go
  • pkg/serializer/reader_test.go
  • pkg/server/bundle_handler.go
  • pkg/server/bundle_handler_test.go
  • pkg/server/consts.go
  • pkg/server/doc.go
  • pkg/server/recipe_handler.go
  • pkg/server/recipe_handler_test.go
  • pkg/server/serve.go
  • pkg/server/serve_test.go
  • pkg/server/server.go
  • tools/testgrid-publish/bundle.go
  • tools/testgrid-publish/bundle_test.go

Comment thread pkg/bundler/bundler.go
Comment thread pkg/recipe/loader.go Outdated
Comment thread pkg/serializer/reader_test.go
Comment thread pkg/serializer/reader.go Outdated
@yuanchen8911
yuanchen8911 force-pushed the feat/adr15-profile-core branch from 01d8670 to 9bf3f52 Compare July 28, 2026 06:16

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pkg/recipe/metadata_store.go (1)

1029-1101: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Sound profile resolution logic; consider extracting the shared finalize+validate block.

BuildRecipeResultWithProfile (lines 1084-1100) and BuildRecipeResultWithEvaluatorAndProfile (lines 1251-1268) duplicate the same "apply profile → finalize → stamp APIVersion/Metadata.SelectedProfileValidateProfileValuesWithContext" sequence. Since this is the contract-enforcement path (profile artifact tagging + validation), duplicating it risks the two paths drifting apart on a future fix.

♻️ Suggested consolidation
func (s *MetadataStore) finalizeWithProfile(
	ctx context.Context, criteria *Criteria, mergedSpec *RecipeMetadataSpec,
	appliedOverlays []string, effectiveProfile *ProfileDeclaration, selection string,
	evaluator ConstraintEvaluatorFunc,
) (*RecipeResult, error) {
	selected, err := applyEffectiveProfile(mergedSpec, effectiveProfile, selection, evaluator)
	if err != nil {
		return nil, err
	}
	result, err := finalizeRecipeResult(s.provider, criteria, mergedSpec, appliedOverlays) //nolint:contextcheck
	if err != nil {
		return nil, err
	}
	if selected == nil {
		return result, nil
	}
	result.APIVersion = RecipeProfileAPIVersion
	result.Metadata.SelectedProfile = selected
	if err := result.ValidateProfileValuesWithContext(ctx); err != nil {
		return nil, err
	}
	return result, nil
}

Both call sites would call this helper, with the evaluator path additionally setting result.Metadata.ExcludedOverlays/ConstraintWarnings on the returned result.

Also applies to: 1113-1270

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pkg/recipe/metadata_store.go` around lines 1029 - 1101, Extract the shared
apply-profile, finalize, profile metadata tagging, and validation sequence from
BuildRecipeResultWithProfile and BuildRecipeResultWithEvaluatorAndProfile into a
MetadataStore helper such as finalizeWithProfile. Pass the merged spec,
overlays, effective profile, selection, and evaluator through the helper;
preserve the evaluator path’s additional ExcludedOverlays and ConstraintWarnings
assignments after the helper returns.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/serializer/reader.go`:
- Around line 617-623: Update the ConfigMap retrieval error handling in the
reader flow around k8sClient.CoreV1().ConfigMaps(namespace).Get to classify
ErrCodeNotFound only when apierrors.IsNotFound(err) is true; preserve the
existing not-found message for that case and propagate other errors with their
appropriate status instead of wrapping them as not found.
- Around line 617-618: Rename the shared timeout symbol ConfigMapWriteTimeout to
the neutral ConfigMapTimeout, updating its definition and every read/write
reference consistently, including the context timeout setup in the reader flow.

In `@pkg/server/bundle_handler_test.go`:
- Around line 204-216: The “v2 rejects unknown query parameter” subtest
duplicates request setup instead of using the existing helper. Replace the
manual httptest request and recorder setup with post(t, true,
"?profie=gpuStack%3Ddriver-installed", profileBundleBody(t)), then retain the
existing status assertion against the helper’s response.

In `@pkg/server/bundle_handler.go`:
- Around line 323-342: The duplicated allowlist in
validateV2BundleQueryParameters must stay synchronized with ParseBundleConfig.
Derive validation from the parser’s key definitions where possible; otherwise
add a test that compares both allowlists and fails when they diverge, ensuring
newly supported query parameters are accepted by /v2/bundle.

---

Outside diff comments:
In `@pkg/recipe/metadata_store.go`:
- Around line 1029-1101: Extract the shared apply-profile, finalize, profile
metadata tagging, and validation sequence from BuildRecipeResultWithProfile and
BuildRecipeResultWithEvaluatorAndProfile into a MetadataStore helper such as
finalizeWithProfile. Pass the merged spec, overlays, effective profile,
selection, and evaluator through the helper; preserve the evaluator path’s
additional ExcludedOverlays and ConstraintWarnings assignments after the helper
returns.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: f2eea5d4-58cd-4156-b798-7c9fd7597c01

📥 Commits

Reviewing files that changed from the base of the PR and between 01d8670 and 9bf3f52.

📒 Files selected for processing (69)
  • api/aicr/v1/server.yaml
  • docs/contributor/api-server.md
  • docs/contributor/recipe.md
  • docs/design/015-recipe-configuration-profiles.md
  • docs/integrator/data-flow.md
  • docs/integrator/go-library.md
  • docs/integrator/recipe-development.md
  • docs/user/api-reference.md
  • docs/user/cli-config.md
  • docs/user/cli-reference.md
  • pkg/bundler/bundler.go
  • pkg/bundler/bundler_test.go
  • pkg/bundler/deployer/argocdhelm/argocdhelm.go
  • pkg/bundler/deployer/argocdhelm/argocdhelm_test.go
  • pkg/bundler/validations/checks.go
  • pkg/bundler/validations/checks_test.go
  • pkg/cli/consts.go
  • pkg/cli/mirror.go
  • pkg/cli/query.go
  • pkg/cli/query_test.go
  • pkg/cli/recipe.go
  • pkg/cli/recipe_test.go
  • pkg/client/v1/aicr.go
  • pkg/client/v1/aicr_internal_test.go
  • pkg/client/v1/aicr_test.go
  • pkg/client/v1/bundle.go
  • pkg/client/v1/gpu_driver_state.go
  • pkg/client/v1/stability_test.go
  • pkg/client/v1/types.go
  • pkg/component/overrides.go
  • pkg/component/overrides_test.go
  • pkg/config/accessors.go
  • pkg/config/accessors_test.go
  • pkg/config/config.go
  • pkg/config/config_test.go
  • pkg/config/validate.go
  • pkg/evidence/project/synthesize.go
  • pkg/evidence/project/synthesize_test.go
  • pkg/mirror/discover.go
  • pkg/mirror/discover_test.go
  • pkg/recipe/builder.go
  • pkg/recipe/catalog.go
  • pkg/recipe/criteria.go
  • pkg/recipe/criteria_test.go
  • pkg/recipe/decode.go
  • pkg/recipe/loader.go
  • pkg/recipe/loader_test.go
  • pkg/recipe/metadata.go
  • pkg/recipe/metadata_store.go
  • pkg/recipe/metadata_store_test.go
  • pkg/recipe/profile.go
  • pkg/recipe/profile_resolution.go
  • pkg/recipe/profile_test.go
  • pkg/recipe/query.go
  • pkg/recipe/query_request.go
  • pkg/recipe/query_test.go
  • pkg/serializer/reader.go
  • pkg/serializer/reader_test.go
  • pkg/server/bundle_handler.go
  • pkg/server/bundle_handler_test.go
  • pkg/server/consts.go
  • pkg/server/doc.go
  • pkg/server/recipe_handler.go
  • pkg/server/recipe_handler_test.go
  • pkg/server/serve.go
  • pkg/server/serve_test.go
  • pkg/server/server.go
  • tools/testgrid-publish/bundle.go
  • tools/testgrid-publish/bundle_test.go

Comment thread pkg/serializer/reader.go
Comment thread pkg/serializer/reader.go
Comment thread pkg/server/bundle_handler_test.go
Comment thread pkg/server/bundle_handler.go
@yuanchen8911
yuanchen8911 force-pushed the feat/adr15-profile-core branch from 9bf3f52 to 1f416f4 Compare July 28, 2026 14:18

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pkg/mirror/discover_test.go`:
- Around line 466-493: Extend TestDiscover_SetEnabledFalseSkipsComponent into a
table-driven test covering both disabled and non-boolean enabled overrides.
Retain the existing false case assertions, and add an invalid value case that
verifies Discover returns ErrCodeInvalidRequest with the contextual error from
prepareMirrorCandidate, without rendering or retaining the component.

In `@pkg/recipe/metadata_store.go`:
- Around line 1084-1100: The profile-processing tail is duplicated across the
build paths and can drift over time. Extract the shared apply-profile, finalize,
metadata-stamping, and validation sequence into a helper such as
MetadataStore.finalizeWithProfile, parameterized by ctx, criteria, mergedSpec,
appliedOverlays, effectiveProfile, selection, and evaluator; preserve each
caller’s distinct trailing excluded-overlay assignment.

In `@pkg/recipe/profile_test.go`:
- Around line 909-971: Add table cases to the profile validation tests covering
both baseline-side rejection gates: a baseline owned path resolving to
PathBlocked, expecting the “recipe path … is blocked” error, and ownedPaths
naming a component absent from the recipe, expecting the “profile-owned
component %q is absent from the recipe” error. Reuse the existing fixtures and
test structure around the current cases to verify both failures remain
fail-closed.

In `@pkg/recipe/profile.go`:
- Around line 414-444: The profile validation loops over map-backed OwnedPaths
nondeterministically and performs hydration without cancellation checks. Update
ValidateProfileValuesWithContext and ValidateProfileLock to collect and sort
owned component keys before iterating, and check ctx.Done() at the start of each
iteration before hydration, returning the context error when cancelled; preserve
existing validation and error behavior otherwise.
- Around line 518-541: Update ValidateProfileLock and
ValidateProfileValuesWithContext to reuse the hydrated component-values map
produced during validation, passing it through instead of calling
GetValuesForComponentWithContext again for each profile-owned component.
Preserve existing validation and error behavior while ensuring each component’s
values are loaded only once per lock check.

In `@pkg/server/recipe_handler.go`:
- Around line 501-516: The duplicate-value validation currently used by
singleQueryValue must also cover every allowlisted query key in
validateV2QueryParameters. Update that v2 validation flow so conflicting
repeated values such as service=eks and service=gke return the existing
invalid-request error, while repeated identical values remain accepted and
unrelated keys retain their current handling.
- Around line 572-577: Update v2EnvelopeFormat to distinguish recognized JSON
and YAML media types instead of defaulting every non-JSON value to YAML; treat
an empty or unsupported Content-Type as invalid and propagate
ErrCodeInvalidRequest through the strict v2 request handling path. Preserve YAML
selection only for explicitly recognized YAML media types.
- Around line 98-101: Extract the shared v2 query-key allowlist used by both
handlers into a package-level set, preserving the existing common keys. Update
the `/v2/query` handler’s validation around validateV2QueryParameters to add
selector-specific support without mutating or duplicating the shared set, while
the other v2 handler continues using only the common allowlist.

In `@tools/testgrid-publish/bundle_test.go`:
- Around line 120-136: Update the “profile artifact rejected until publication
support lands” test case in the parseCriteria test table to assert the expected
rejection message using wantErrContains: “profile-bearing TestGrid publication
is deferred to the profile adoption rollout”, while retaining wantErr: true.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Enterprise

Run ID: d37b7169-116b-4de5-9b71-381ce32330f4

📥 Commits

Reviewing files that changed from the base of the PR and between 9bf3f52 and 1f416f4.

📒 Files selected for processing (71)
  • api/aicr/v1/server.yaml
  • docs/contributor/api-server.md
  • docs/contributor/recipe.md
  • docs/design/015-recipe-configuration-profiles.md
  • docs/integrator/data-flow.md
  • docs/integrator/go-library.md
  • docs/integrator/recipe-development.md
  • docs/user/api-reference.md
  • docs/user/cli-config.md
  • docs/user/cli-reference.md
  • pkg/bundler/bundler.go
  • pkg/bundler/bundler_test.go
  • pkg/bundler/deployer/argocdhelm/argocdhelm.go
  • pkg/bundler/deployer/argocdhelm/argocdhelm_test.go
  • pkg/bundler/handler.go
  • pkg/bundler/handler_test.go
  • pkg/bundler/validations/checks.go
  • pkg/bundler/validations/checks_test.go
  • pkg/cli/consts.go
  • pkg/cli/mirror.go
  • pkg/cli/query.go
  • pkg/cli/query_test.go
  • pkg/cli/recipe.go
  • pkg/cli/recipe_test.go
  • pkg/client/v1/aicr.go
  • pkg/client/v1/aicr_internal_test.go
  • pkg/client/v1/aicr_test.go
  • pkg/client/v1/bundle.go
  • pkg/client/v1/gpu_driver_state.go
  • pkg/client/v1/stability_test.go
  • pkg/client/v1/types.go
  • pkg/component/overrides.go
  • pkg/component/overrides_test.go
  • pkg/config/accessors.go
  • pkg/config/accessors_test.go
  • pkg/config/config.go
  • pkg/config/config_test.go
  • pkg/config/validate.go
  • pkg/evidence/project/synthesize.go
  • pkg/evidence/project/synthesize_test.go
  • pkg/mirror/discover.go
  • pkg/mirror/discover_test.go
  • pkg/recipe/builder.go
  • pkg/recipe/catalog.go
  • pkg/recipe/criteria.go
  • pkg/recipe/criteria_test.go
  • pkg/recipe/decode.go
  • pkg/recipe/loader.go
  • pkg/recipe/loader_test.go
  • pkg/recipe/metadata.go
  • pkg/recipe/metadata_store.go
  • pkg/recipe/metadata_store_test.go
  • pkg/recipe/profile.go
  • pkg/recipe/profile_resolution.go
  • pkg/recipe/profile_test.go
  • pkg/recipe/query.go
  • pkg/recipe/query_request.go
  • pkg/recipe/query_test.go
  • pkg/serializer/reader.go
  • pkg/serializer/reader_test.go
  • pkg/server/bundle_handler.go
  • pkg/server/bundle_handler_test.go
  • pkg/server/consts.go
  • pkg/server/doc.go
  • pkg/server/recipe_handler.go
  • pkg/server/recipe_handler_test.go
  • pkg/server/serve.go
  • pkg/server/serve_test.go
  • pkg/server/server.go
  • tools/testgrid-publish/bundle.go
  • tools/testgrid-publish/bundle_test.go

Comment thread pkg/mirror/discover_test.go Outdated
Comment thread pkg/recipe/metadata_store.go
Comment thread pkg/recipe/profile_test.go
Comment thread pkg/recipe/profile.go
Comment thread pkg/recipe/profile.go Outdated
Comment thread pkg/server/recipe_handler.go Outdated
Comment thread pkg/server/recipe_handler.go
Comment thread pkg/server/recipe_handler.go Outdated
Comment thread tools/testgrid-publish/bundle_test.go
@yuanchen8911
yuanchen8911 force-pushed the feat/adr15-profile-core branch 2 times, most recently from 42309c6 to 12062c6 Compare July 28, 2026 14:51
@yuanchen8911
yuanchen8911 force-pushed the feat/adr15-profile-core branch 6 times, most recently from 2ace4ec to 9e65073 Compare July 28, 2026 21:29
@github-actions

Copy link
Copy Markdown
Contributor

@yuanchen8911 this PR now has merge conflicts with main. Please rebase to resolve them.

@yuanchen8911
yuanchen8911 force-pushed the feat/adr15-profile-core branch from 9e65073 to ad2c4c5 Compare July 28, 2026 22:26
@yuanchen8911
yuanchen8911 removed the request for review from njhensley July 29, 2026 04:59
@yuanchen8911 yuanchen8911 changed the title Implement the profile core in ADR-015 WIP: Implement the profile core in ADR-015 Jul 29, 2026
@yuanchen8911
yuanchen8911 force-pushed the feat/adr15-profile-core branch 3 times, most recently from ee22ef8 to 58c5386 Compare July 29, 2026 14:48

@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 — ADR-015 configuration-profile core

Method: four independent persona reviewers (Correctness/Domain · Security · API-contract · Operability/Go-idioms) reviewed in parallel, then every finding was re-derived from the resolved code by an adversarial senior meta-reviewer. Anchors pinned to head 58c53860.

Tier legend: 🔴 Blocker · 🟠 Major · 🟡 Minor · 🔵 Nitpick

Overall assessment

This is an unusually defensive, fail-closed implementation, and it is dormant — no recipe family adopts profiles yet. Across all four lenses, no blocker or major issue survived independent verification. Verified as correct: the cyclic-reference detector accurately mirrors what serializer.DeepCopyAny recurses into; the scalar allowlist covers the JSON 2⁵³ boundary and float truncation; ctx.Done() is checked in every owned-component loop; the Argo CD Helm render-time guard cannot be bypassed by false/null/empty containers; strict v2 media-type + decode paths hold; 5xx responses withhold the internal cause; and the OpenAPI↔code contract is tightly gated. Determinism-sensitive paths use MarshalYAMLDeterministic. The lazily-created static/ handling (checkStaticPath / removeStaleStaticDir) is clean — Lstat symlink rejection, correct 4xx-vs-5xx coding, fail-loud stale-dir removal.

Two minor gaps and three nitpicks are inline below. Both minors fail closed.

Recommendation: ✅ Approve with comments.

Confirmed non-issue (examined, refuted)

  • Post-loop ctx.Err() in ListCatalogWithProfiles (pkg/recipe/catalog.go:138): not redundant — the in-loop check runs at the top of each iteration, so the post-loop check is the only guard covering cancellation during the final iteration's body. Legitimate fail-closed final guard.

Summary

🔴 Blocker 🟠 Major 🟡 Minor 🔵 Nitpick
0 0 2 3

F1 (union-totality) and F2 (v1/v2 schema-vs-server drift) are the two worth addressing before the AKS adoption PR builds on this.

Comment thread pkg/recipe/profile.go
expected = valuePaths
continue
}
if !slices.Equal(valuePaths, expected) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor — Union-totality check excludes the synthetic "enabled" path, so a presence-only componentRef escapes totality and over-locks siblings

The per-value totality check at profile.go:217 compares valuePaths, which is built only from flattened override paths (profile.go:201). A componentRef with empty/absent overrides (e.g. - name: Y with no overrides) is accepted (only an empty name is rejected) and contributes zero entries to valuePaths — yet ownedSet[Y]["enabled"] is stamped unconditionally for every ref (profile.go:210). Because OwnedPaths is declaration-wide, two profile values that reference different component sets via presence-only refs pass slices.Equal(valuePaths, expected), and Y's presence lock then leaks to every selection.

applyEffectiveProfile (profile_resolution.go) subsequently requires every owned component enabled, so selecting a value that never referenced Y is rejected with "component Y is missing or disabled" whenever Y is disabled in the surviving composition — surprising and hard to diagnose. It fails closed (never over-permits), so this is minor, not a blocker.

Suggested fix: fold the synthetic enabled marker into the per-value totality surface (append ref.Name+":enabled" to valuePaths), or validate that the set of referenced component names is identical across all values, so presence-only refs are subject to the same value=value equality check as override-bearing refs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e0385f0. Confirmed the mechanism first: valuePaths is appended to only inside the for _, path := range paths loop, while the synthetic enabled marker is stamped into ownedSet unconditionally per ref. A presence-only ref therefore contributes nothing to the totality comparison while still landing in the declaration-wide OwnedPaths.

Took the first of your two suggestions — the marker now joins the totality surface (ref.Name + ":" + profileComponentEnabledPath appended alongside the flattened paths), so presence-only refs are compared value-to-value like any other. That is equivalent to your second suggestion for this case and needs no separate component-name set.

Added a TestValidateProfileDeclaration case pairing a value that references nfd presence-only against one that does not. It fails with union totality when the appended marker is removed, so the guard has a demonstrated failing state rather than only passing today.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction: the fix cited above was subsequently reverted, deliberately. Including the synthetic enabled path in totality contradicted ADR-015, which specifies totality is evaluated before synthetic presence paths are added and that the per-component enabled path is part of ownedPaths but exempt from totality (see Deferred Decision 3 and the 2026-07-27 amendment). Two independent reviewers converged on the same code-vs-ADR conflict, and the ADR was taken as authoritative, so the current head restores the exemption and the test now asserts the ADR semantics.

On the over-locking concern itself: a presence-only componentRef still contributes its synthetic enabled path to ownedPaths, so the lock surface is intentional — what the exemption changes is only that a value may reference a component its siblings do not, without every sibling being forced to reference it too. If you think the ADR itself is wrong here, happy to reopen that in the ADR rather than diverge the implementation from it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: the fix commit referenced above was rewritten by later amends. At the current head a201987 this landed in its final form — and note the direction reversed after cross-checking ADR-015: totality is evaluated over leaf-flattened override paths only, with the synthetic enabled marker exempt (ADR-015 evaluates totality before synthetic presence is added). The test now asserts a presence-only ref is accepted, with the declaration-wide OwnedPaths still carrying the presence lock. The integration suite in pkg/recipe/profile_integration_test.go exercises this end to end.

Comment thread api/aicr/v1/server.yaml Outdated
not:
required: [selectedProfile]

BundleRecipeV2Request:

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Minor — /v2/bundle oneOf rejects empty-header legacy artifacts that the Go decoder and /v1/bundle accept

RecipeResult has no omitempty on Kind/APIVersion, so a round-tripped legacy artifact emits kind: "" + apiVersion: "". DecodeRecipeResult (pkg/recipe/decode.go:46-55) only enforces kind when apiVersion == v1alpha3, so the server accepts that empty-header body on both routes. This same PR deliberately widened /v1/bundle's BundleRecipeRequest to admit it (enum ["", aicr.run/v1alpha2] / ["", RecipeResult]) to fix the #1941 drift.

But BundleRecipeV2Request's oneOf (server.yaml:2880) has three branches — LegacyRecipeResponse (→ RecipeResponse, apiVersion enum [v1alpha2,v1alpha3], kind enum [RecipeResult]), VersionlessLegacyRecipeResponse (requires kind, not:required[apiVersion]), and ProfileRecipeResponse (v1alpha3) — and an empty-header body matches zero of them (spec-invalid). So /v2/bundle is stricter than its own server and stricter than /v1/bundle on an identical body.

This is a spec-vs-server contract gap (server more permissive than the documented /v2 union) — codegen clients / gateway request-validators would reject a body aicrd actually processes. No runtime break for clients that send kind: RecipeResult.

Suggested fix: widen VersionlessLegacyRecipeResponse (or add a branch) to admit apiVersion: "" + kind: "", mirroring the /v1/bundle BundleRecipeRequest widening, so both routes' schemas match the round-tripped legacy shape.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e0385f0, and this became the alignment work after #1943 merged.

VersionlessLegacyRecipeResponse required apiVersion absent (not: required: [apiVersion]) and kind in [RecipeResult], so an empty-header body matched zero branches exactly as you described. It is now widened to mirror /v1/bundle's BundleRecipeRequest: apiVersion enum [""] with the field no longer prohibited, and kind enum ["", RecipeResult].

The empty-only enum is what keeps oneOf well-formed — a versioned artifact still fails this branch, so each shape matches exactly one:

body matching branch
apiVersion: "", kind: "" Versionless
apiVersion absent Versionless
aicr.run/v1alpha2 Legacy
aicr.run/v1alpha3 + selectedProfile Profile

The sync test asserted the old constraint (not.required contains apiVersion), so it was pinning the over-strict contract. It now asserts the enums instead, with a comment recording why prohibiting apiVersion outright was wrong.

Note this rebase also merged the two BundleRecipeRequest and RecipeResponse definitions that #1943 and this PR each introduced at different points in the file — git took both silently and only yamllint caught the duplication.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: this is in its final form at head a201987. VersionlessLegacyRecipeResponse admits apiVersion enum [""] and kind enum ["", RecipeResult], so the empty-header round-trip body matches exactly one oneOf branch; the sync test asserts the enums. Validated with a draft-4 schema validator over real artifacts (empty-header accepted, versioned shapes unchanged).

fmt.Sprintf("refusing to overwrite non-AICR profile lock template %q", outputPath))
}
content := []byte(buf.String())
if err := os.WriteFile(outputPath, content, 0600); err != nil {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nitpick — Profile lock template inspect+write follow symlinks (no O_NOFOLLOW)

inspectProfileLockTemplate (argocdhelm.go:539, os.Open) and the write (argocdhelm.go:508, os.WriteFile) both follow symlinks at the fixed path templates/aicr-profile-lock.yaml. A pre-planted symlink there yields a fail-closed refusal (header mismatch) in the common case, or a 0600 overwrite through the symlink if the target happens to begin with the exact AICR header. Requires attacker write access to the operator's output dir, so low severity — but the new #1942 fix in this same file added checkStaticPath (argocdhelm.go), which uses os.Lstat to reject a symlink at the static/ path, and pkg/corroborate/meta.go / verifier.go / checksum/inventory.go already use O_NOFOLLOW. The lock-template write is now the odd one out within its own file.

Suggested fix: open with os.OpenFile(..., os.O_CREATE|os.O_WRONLY|os.O_NOFOLLOW, 0600) (and O_NOFOLLOW on the inspect open) to match the existing pattern.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified the mechanism: inspectProfileLockTemplate opens with os.Open and the write uses os.WriteFile, so both follow a symlink at that fixed path.

Not changing it, on reachability. templatesDir is created by the generator itself (SafeJoin + MkdirAll at argocdhelm.go:395-399), and checksum.ValidateOutputRoot runs at bundler.go:343 before any deployer, rejecting a symlink anywhere under the output root. So a pre-existing symlink cannot survive to this point on the DefaultBundler path — reaching it needs a local process planting one inside the generation window, and anything with write access to --output can already rewrite the finished bundle and its checksums.txt. Closing it properly means openat-based operations through the deployer's whole write path, which buys nothing in a supported configuration.

This is the same class and disposition as the checkStaticPath TOCTOU on #1950, where the conclusion was to scope the comment rather than model an attacker who already has write access. Happy to reconsider if you would rather see the guard extended for consistency — it is a judgement call, not a factual dispute.

// explicitly null profile. Both JSON and YAML decode null into a nil *string,
// but the strict v2 contract permits only a string in name=value form when the
// field is present.
func validateV2EnvelopeProfile(data []byte, contentType string, profile *string) error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nitpick — v2 envelope body is parsed twice for present-vs-null profile detection

validateV2EnvelopeProfile (recipe_handler.go:613) re-Unmarshals the already-strict-decoded, size-bounded body via bodyHasTopLevelProfile purely to distinguish an omitted profile from an explicit null (a nil *string can't tell them apart). No DoS concern (body is capped by MaxRecipePOSTBytes), and this is close to the standard idiom for present-vs-null detection — flagging only as a minor hot-path inefficiency.

Optional: capture presence during the primary strict decode via a json.RawMessage / yaml.Node field on the envelope to avoid the second pass.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: bodyHasTopLevelProfile re-unmarshals the already-decoded body at recipe_handler.go:171 and :323, in addition to the strict decode that produced the envelope.

Leaving it as is for this PR. The body is size-bounded before either pass, so the cost is a second parse of a small payload on the v2 envelope path only, and the second parse buys a distinction the strict decoder genuinely cannot express: an omitted profile versus an explicit profile: null. Folding that into the primary decode means either a *string with a custom unmarshaler or a json.RawMessage peek, both of which add more surface than the duplicate parse removes.

If you would prefer it collapsed, the cleaner shape is decoding into a struct with Profile *string plus a presence sentinel rather than re-parsing — worth doing as its own change with its own tests rather than inside the profile core.

Comment thread pkg/recipe/profile.go
}

// PathsIntersect reports exact, ancestor, or descendant path intersection.
func PathsIntersect(a, b string) bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Nitpick — Exported PathsIntersect has no direct unit test

Exported PathsIntersect (profile.go:787) has no direct test; it's exercised only via isDeferredAllocationPolicyPath, the dynamic-path guard, and OwnsProfilePath. It is therefore not at 0% coverage and does not trip the repo's new-exported-func block rule — but its ancestor/descendant/exact-match semantics are subtle and the allocation-policy deferral gate depends on it being exact.

Suggested fix: add a small table-driven test (a.b vs a.b.c → true; a.b vs a.c → false; gpuResourcesEnabledOverride vs gpuResourcesEnabledOverrideExtra → false, no false-prefix match).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in e0385f0. TestPathsIntersect covers the exported helper directly with 12 cases, each asserted in both argument orders since the function must be symmetric.

The cases that matter for the profile lock are the ancestor/descendant pairs (driver vs driver.enabled, both directions) and the segment-boundary one: driver must not intersect driverRoot, since comparison is per dot-segment rather than per character. A regression there would otherwise surface only indirectly through isDeferredAllocationPolicyPath, the dynamic-path guard, or OwnsProfilePath.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: TestPathsIntersect is at the current head a201987 (the SHA cited above was rewritten by later amends) — 12 cases, both argument orders, including the driver/driverRoot segment-boundary case.

@yuanchen8911
yuanchen8911 force-pushed the feat/adr15-profile-core branch 6 times, most recently from 6dafeb8 to 12062c6 Compare July 29, 2026 19:34
@yuanchen8911
yuanchen8911 marked this pull request as draft July 29, 2026 19:45
@yuanchen8911
yuanchen8911 force-pushed the feat/adr15-profile-core branch 9 times, most recently from 26449e4 to a201987 Compare July 30, 2026 00:55
@yuanchen8911 yuanchen8911 changed the title WIP: Implement the profile core in ADR-015 Implement the profile core in ADR-015 Jul 30, 2026
@yuanchen8911
yuanchen8911 marked this pull request as ready for review July 30, 2026 00:55
Signed-off-by: Yuan Chen <yuanchen97@gmail.com>
@yuanchen8911
yuanchen8911 force-pushed the feat/adr15-profile-core branch from a201987 to c0db667 Compare July 30, 2026 03:08
@yuanchen8911
yuanchen8911 requested a review from njhensley July 30, 2026 03:12
@yuanchen8911 yuanchen8911 changed the title Implement the profile core in ADR-015 feat(recipe): implement the ADR-015 profile core Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants