Skip to content

refactor(config): unified Resolve() per spec type for wire→domain conversion#789

Merged
mchmarny merged 1 commit into
mainfrom
refactor/config-resolve-783
May 7, 2026
Merged

refactor(config): unified Resolve() per spec type for wire→domain conversion#789
mchmarny merged 1 commit into
mainfrom
refactor/config-resolve-783

Conversation

@mchmarny

@mchmarny mchmarny commented May 7, 2026

Copy link
Copy Markdown
Member

Summary

Consolidates string→typed conversion of wire-spec fields into a single boundary: (*BundleSpec).Resolve() and (*RecipeSpec).ResolveCriteria(). Removes the three-way parallel parsing across validate.go, applyCriteriaFromConfig, and parseBundleCmdOptions.

Motivation / Context

Follow-up to #782 (review feedback from @lockwobr). Wire types in pkg/config are intentionally string-typed; conversion to domain types was happening in three places with subtly different code paths and error attribution. This refactor makes the conversion happen once at the type boundary.

Fixes: #783
Related: #782

Type of Change

  • Refactoring (no functional changes)

Component(s) Affected

  • CLI (cmd/aicr, pkg/cli)
  • Other: pkg/config

Implementation Notes

  • BundleResolved is the typed projection of BundleSpec. (*BundleSpec).Resolve() performs all string→domain conversion (bundlercfg.DeployerType, *oci.Reference, []bundlercfg.ComponentPath, []corev1.Toleration, *corev1.Taint, etc.) and attributes parse errors to their spec.bundle.<path> source.
  • (*RecipeSpec).ResolveCriteria() returns *recipe.Criteria with parsed enums; unset fields stay zero so callers can detect what to copy onto a target.
  • Both methods are nil-receiver tolerant, never return a nil pointer, and preserve the nil-vs-explicitly-empty distinction for maps and slices via maps.Clone / conditional parsing.
  • validate() delegates to Resolve*() and discards the result — there's no parallel parser to keep in sync.
  • applyCriteriaFromConfig simplifies to "call ResolveCriteria, copy non-zero fields, log overrides."
  • parseBundleCmdOptions consumes *BundleResolved directly. New small CLI helpers in pkg/cli/bundle_config.go (resolveDeployer, resolveTolerations, resolveComponentPaths, resolveTaint) and bundle.go::resolveOutputTarget layer CLI flag overrides onto typed values.
  • The dead per-field bundle accessors on BundleSpec are removed since BundleResolved is now the single consumption point. Recipe accessors stay because their fields have no enum parsing.

Behavior preservation for empty CLI strings: --deployer "", --workload-gate "", and --output "" previously masked config and fell through to documented defaults (Helm / nil / "."). The new helpers preserve this — verified with dedicated tests.

Testing

unset GITLAB_TOKEN && make test    # ok pkg/config (95.1%) + pkg/cli (62.6%)
golangci-lint run -c .golangci.yaml ./pkg/config/... ./pkg/cli/...    # 0 issues

Coverage on changed packages (vs origin/main):

Package Baseline Branch Delta
pkg/config 75.8% 95.1% +19.3pp
pkg/cli 60.7% 62.6% +1.9pp

New exported methods at 100% coverage. New CLI helpers at 90–100%. New tests:

  • pkg/config/resolve_test.go — table-driven, including nil-receiver, all-empty, all-populated, every invalid-enum case, nil-vs-explicitly-empty for selectors / tolerations / value-overrides, defensive map cloning.
  • pkg/config/accessors_test.go — nil-tolerance and population for the remaining recipe-side accessors.
  • pkg/cli/bundle_resolve_helpers_test.go — every branch of every new helper, including the empty-CLI behavior-preservation cases (TestResolveDeployer_FlagSetEmptyDefaultsToHelm, TestResolveTaint_FlagSetEmptyMasksFallback, TestResolveOutputTarget_FlagSetEmptyDefaultsToCurrentDir).

All existing integration/E2E tests in pkg/cli/config_*_test.go continue to pass unchanged.

Risk Assessment

  • Low — Pure refactor, no schema changes, behavior verified by existing + new tests.

Rollout notes: No user-visible behavior change; wire format unchanged. One internal API change: *BundleSpec no longer carries per-field accessors (RecipeInput, OutputTarget, DeploymentDeployer, etc.); callers should use (*BundleSpec).Resolve() instead. No external consumers of those accessors found in the codebase.

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 (no user-facing changes)
  • Changes follow existing patterns in the codebase
  • Commits are cryptographically signed (git commit -S)

…version

Wire types in pkg/config (BundleSpec, RecipeSpec, etc.) are intentionally
string-typed and forgiving. Conversion to domain types previously happened
in three places: pkg/config/validate.go (parse-and-discard), pkg/cli/recipe.go
applyCriteriaFromConfig (parse interleaved with override-logging), and
pkg/cli/bundle.go parseBundleCmdOptions (string accessors threaded through
parsers at consumption time).

This commit consolidates the conversion to a single boundary in pkg/config:

- (*BundleSpec).Resolve() returns *BundleResolved with typed domain values
  (DeployerType, *oci.Reference, []ComponentPath, []corev1.Toleration,
  *corev1.Taint, etc.). Errors carry "spec.bundle.<path>" attribution.
- (*RecipeSpec).ResolveCriteria() returns *recipe.Criteria with parsed
  enums, leaving unset fields zero so callers can detect what to copy.

Both methods are nil-receiver tolerant, never return nil pointers, and
preserve the nil-vs-explicitly-empty distinction for maps and slices
(matching the existing accessor semantics).

Validate methods now delegate to Resolve* (no parallel parser to maintain),
applyCriteriaFromConfig becomes "Resolve, then merge non-zero fields with
override logging," and parseBundleCmdOptions consumes BundleResolved and
layers CLI flag overrides on typed values via small helpers
(resolveDeployer, resolveTolerations, resolveComponentPaths, resolveTaint,
resolveOutputTarget). The redundant per-field bundle accessors are removed
since BundleResolved is the single consumption point. Existing recipe
accessors (SnapshotPath, OutputPath, OutputFormat, DataDir) stay because
they have no enum parsing.

Behavior is preserved end-to-end including edge cases like empty CLI
strings (--deployer "", --output "", --workload-gate "") that previously
masked config and fell through to defaults.

Fixes #783
@mchmarny mchmarny requested a review from a team as a code owner May 7, 2026 13:29
@mchmarny mchmarny self-assigned this May 7, 2026
@mchmarny mchmarny enabled auto-merge (squash) May 7, 2026 13:30
@coderabbitai

coderabbitai Bot commented May 7, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR consolidates wire-to-domain type conversion across three redundant parsing paths into dedicated Resolve() methods. BundleSpec.Resolve() and RecipeSpec.ResolveCriteria() convert string-based spec fields into strongly typed values (deployer type, OCI references, tolerations, Kubernetes taints, etc.). Validation delegates to these same methods, ensuring one parsing path. The CLI layer now consumes typed results and overlays flag overrides via new helpers (resolveDeployer, resolveComponentPaths, resolveTolerations, resolveTaint, resolveOutputTarget). Old BundleSpec accessors that returned raw strings are removed. The refactor eliminates 17+ direct accessor calls, centralizes error attribution at the conversion boundary, and preserves nil-vs-empty-map and nil-vs-empty-slice semantics throughout.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed Title clearly summarizes the main refactoring: consolidating string-to-typed conversion via unified Resolve() methods per spec type, which aligns with the substantial changes across config and CLI packages.
Description check ✅ Passed Description comprehensively explains the refactoring objectives, implementation details, testing approach, and relates to linked issues #783 and #782, providing clear context for the changes.
Linked Issues check ✅ Passed All code changes directly implement the acceptance criteria from issue #783: (1) unified Resolve() methods added to BundleSpec and RecipeSpec [resolve.go], (2) nil-receiver tolerant and never-null returns, (3) validation delegates to Resolve* methods [validate.go], (4) CLI code consumes typed BundleResolved with override helpers [bundle.go, bundle_config.go], (5) applyCriteriaFromConfig simplified to call ResolveCriteria [recipe.go], (6) tests verify nil-vs-empty preservation and parse behavior [resolve_test.go, accessors_test.go, bundle_resolve_helpers_test.go].
Out of Scope Changes check ✅ Passed All changes are in-scope: removal of per-field BundleSpec accessors, removal of CriteriaSpec.validate, and updates to CLI helpers are all justified by the consolidation strategy described in #783; no unrelated refactoring or feature additions detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/config-resolve-783

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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/cli/bundle_resolve_helpers_test.go`:
- Around line 31-440: Group the individual tests for each helper
(resolveDeployer, resolveComponentPaths, resolveTolerations, resolveTaint,
resolveOutputTarget) into single table-driven tests using a slice of test cases
and t.Run subtests; for each family replace the multiple TestResolveXxx_*
functions with one TestResolveXxx_TableDriven that defines []struct{name, args,
want, wantErr, note} and iterates cases calling the same helper
(resolveDeployer, resolveComponentPaths, resolveTolerations, resolveTaint,
resolveOutputTarget) inside t.Run, preserving the original scenarios (flag set,
no flag fallback, empty flag behavior, invalid flag error, override logging) and
reusing runWith and the same assertions per case so behavior is identical but
consolidated into subtests for clarity and reduced duplication.

In `@pkg/config/resolve.go`:
- Around line 170-176: The config path currently copies
b.Scheduling.StorageClass verbatim which allows whitespace-only values; update
the block handling b.Scheduling in resolve.go to trim whitespace from
b.Scheduling.StorageClass (e.g., strings.TrimSpace) and if the trimmed result is
empty return an invalid-request error (same style as the nodes check) instead of
assigning it to out.StorageClass, so config validation matches the CLI semantics
and Validate() will catch blank storageClass values at the spec/resolution step.
- Around line 35-38: The exported BundleResolved contract claims maps and slices
preserve nil-vs-explicitly-empty semantics but Resolve() currently normalizes
toleration slices via snapshotter.ParseTolerations, breaking that guarantee;
either narrow the godoc on BundleResolved to only promise nil-vs-empty for
fields that are actually preserved or change Resolve/BundleResolved handling so
toleration fields are not normalized (stop calling snapshotter.ParseTolerations
or preserve an explicit-empty marker and propagate it through Resolve), updating
BundleResolved godoc and the Resolve() implementation and referencing the
toleration slice fields, the BundleResolved type, the Resolve function, and
snapshotter.ParseTolerations to keep comments and behavior consistent.
- Around line 238-276: Replace the errors.Wrap calls in ResolveCriteria with
errors.PropagateOrWrap so you preserve structured error codes returned from the
recipe parse functions; specifically, where you call
recipe.ParseCriteriaServiceType, recipe.ParseCriteriaAcceleratorType,
recipe.ParseCriteriaIntentType, recipe.ParseCriteriaOSType, and
recipe.ParseCriteriaPlatformType, change the error handling from
errors.Wrap(errors.ErrCodeInvalidRequest, "...") to errors.PropagateOrWrap(err,
errors.ErrCodeInvalidRequest, "invalid spec.recipe.criteria.<field>") so the
original err from the ParseCriteria* functions is propagated while attaching the
spec-path context.
🪄 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: f2a3e86f-cf93-4372-b5e6-8bdb068d75ea

📥 Commits

Reviewing files that changed from the base of the PR and between 2f0854b and f48c858.

📒 Files selected for processing (9)
  • pkg/cli/bundle.go
  • pkg/cli/bundle_config.go
  • pkg/cli/bundle_resolve_helpers_test.go
  • pkg/cli/recipe.go
  • pkg/config/accessors.go
  • pkg/config/accessors_test.go
  • pkg/config/resolve.go
  • pkg/config/resolve_test.go
  • pkg/config/validate.go

Comment on lines +31 to +440
func TestResolveDeployer_FlagSetWinsOverConfig(t *testing.T) {
flags := []cli.Flag{&cli.StringFlag{Name: "deployer"}}
runWith(t, flags, []string{"--deployer", "argocd"}, func(c *cli.Command) {
got, err := resolveDeployer(c, bundlercfg.DeployerHelm)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != bundlercfg.DeployerArgoCD {
t.Errorf("got %q, want argocd", got)
}
})
}

func TestResolveDeployer_NoFlagUsesConfigFallback(t *testing.T) {
flags := []cli.Flag{&cli.StringFlag{Name: "deployer"}}
runWith(t, flags, []string{}, func(c *cli.Command) {
got, err := resolveDeployer(c, bundlercfg.DeployerArgoCD)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != bundlercfg.DeployerArgoCD {
t.Errorf("got %q, want argocd from config", got)
}
})
}

func TestResolveDeployer_NoFlagNoConfigDefaultsToHelm(t *testing.T) {
flags := []cli.Flag{&cli.StringFlag{Name: "deployer"}}
runWith(t, flags, []string{}, func(c *cli.Command) {
got, err := resolveDeployer(c, bundlercfg.DeployerType(""))
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != bundlercfg.DeployerHelm {
t.Errorf("got %q, want helm default", got)
}
})
}

func TestResolveDeployer_InvalidFlagReturnsError(t *testing.T) {
flags := []cli.Flag{&cli.StringFlag{Name: "deployer"}}
runWith(t, flags, []string{"--deployer", "fluxcd"}, func(c *cli.Command) {
_, err := resolveDeployer(c, bundlercfg.DeployerType(""))
if err == nil {
t.Fatal("expected error for invalid deployer")
}
if !strings.Contains(err.Error(), "invalid --deployer") {
t.Errorf("error %q must mention --deployer", err.Error())
}
})
}

func TestResolveDeployer_FlagSetEmptyDefaultsToHelm(t *testing.T) {
// `--deployer ""` matches pre-refactor behavior: empty CLI string
// masks config and falls through to the Helm default rather than
// being passed to ParseDeployerType (which rejects "").
flags := []cli.Flag{&cli.StringFlag{Name: "deployer"}}
runWith(t, flags, []string{"--deployer", ""}, func(c *cli.Command) {
got, err := resolveDeployer(c, bundlercfg.DeployerArgoCD)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != bundlercfg.DeployerHelm {
t.Errorf("got %q, want helm default", got)
}
})
}

// === resolveComponentPaths ===

func TestResolveComponentPaths_FlagSetParsesCLI(t *testing.T) {
flags := []cli.Flag{&cli.StringSliceFlag{Name: "set"}}
runWith(t, flags, []string{"--set", "gpuoperator:driver.version=570.0.0"},
func(c *cli.Command) {
got, err := resolveComponentPaths(c, "set", nil, bundlercfg.ParseValueOverrides)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 1 {
t.Errorf("got %d entries, want 1", len(got))
}
})
}

func TestResolveComponentPaths_NoFlagReturnsFallback(t *testing.T) {
flags := []cli.Flag{&cli.StringSliceFlag{Name: "set"}}
fallback := []bundlercfg.ComponentPath{{Component: "x"}}
runWith(t, flags, []string{}, func(c *cli.Command) {
got, err := resolveComponentPaths(c, "set", fallback, bundlercfg.ParseValueOverrides)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 1 || got[0].Component != "x" {
t.Errorf("expected fallback returned, got %v", got)
}
})
}

func TestResolveComponentPaths_NoFlagNilFallbackReturnsNil(t *testing.T) {
flags := []cli.Flag{&cli.StringSliceFlag{Name: "set"}}
runWith(t, flags, []string{}, func(c *cli.Command) {
got, err := resolveComponentPaths(c, "set", nil, bundlercfg.ParseValueOverrides)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != nil {
t.Errorf("expected nil, got %v", got)
}
})
}

func TestResolveComponentPaths_InvalidFlagReturnsError(t *testing.T) {
flags := []cli.Flag{&cli.StringSliceFlag{Name: "set"}}
runWith(t, flags, []string{"--set", "no-equals-sign"}, func(c *cli.Command) {
_, err := resolveComponentPaths(c, "set", nil, bundlercfg.ParseValueOverrides)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "invalid --set") {
t.Errorf("error %q must mention --set", err.Error())
}
})
}

func TestResolveComponentPaths_FlagOverridesNonEmptyConfig(t *testing.T) {
flags := []cli.Flag{&cli.StringSliceFlag{Name: "set"}}
fallback := []bundlercfg.ComponentPath{{Component: "old"}}
runWith(t, flags, []string{"--set", "gpuoperator:driver.version=570.0.0"},
func(c *cli.Command) {
got, err := resolveComponentPaths(c, "set", fallback, bundlercfg.ParseValueOverrides)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 1 || got[0].Component == "old" {
t.Errorf("expected CLI to replace fallback, got %v", got)
}
})
}

// === resolveTolerations ===

func TestResolveTolerations_FlagSetParsesCLI(t *testing.T) {
flags := []cli.Flag{&cli.StringSliceFlag{Name: "tol"}}
runWith(t, flags, []string{"--tol", "k=v:NoSchedule"}, func(c *cli.Command) {
got, err := resolveTolerations(c, "tol", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 1 || got[0].Key != "k" {
t.Errorf("got %v", got)
}
})
}

func TestResolveTolerations_NoFlagNilFallbackUsesDefault(t *testing.T) {
flags := []cli.Flag{&cli.StringSliceFlag{Name: "tol"}}
runWith(t, flags, []string{}, func(c *cli.Command) {
got, err := resolveTolerations(c, "tol", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// DefaultTolerations is a single Toleration{Op: Exists}.
if len(got) != 1 || got[0].Operator != corev1.TolerationOpExists {
t.Errorf("expected DefaultTolerations, got %v", got)
}
})
}

func TestResolveTolerations_NoFlagNonNilFallbackReturnsFallback(t *testing.T) {
flags := []cli.Flag{&cli.StringSliceFlag{Name: "tol"}}
fallback := []corev1.Toleration{{Key: "from-config"}}
runWith(t, flags, []string{}, func(c *cli.Command) {
got, err := resolveTolerations(c, "tol", fallback)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(got) != 1 || got[0].Key != "from-config" {
t.Errorf("expected fallback, got %v", got)
}
})
}

func TestResolveTolerations_NoFlagEmptyNonNilFallbackReturnsFallback(t *testing.T) {
// Explicitly empty (non-nil) fallback round-trips as an empty
// (non-nil) slice — the parser is not invoked when the flag is unset.
flags := []cli.Flag{&cli.StringSliceFlag{Name: "tol"}}
fallback := []corev1.Toleration{}
runWith(t, flags, []string{}, func(c *cli.Command) {
got, err := resolveTolerations(c, "tol", fallback)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil {
t.Fatal("expected non-nil empty, got nil")
}
if len(got) != 0 {
t.Errorf("expected empty, got %v", got)
}
})
}

func TestResolveTolerations_InvalidFlagReturnsError(t *testing.T) {
flags := []cli.Flag{&cli.StringSliceFlag{Name: "tol"}}
runWith(t, flags, []string{"--tol", "malformed"}, func(c *cli.Command) {
_, err := resolveTolerations(c, "tol", nil)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "invalid --tol") {
t.Errorf("error %q must mention --tol", err.Error())
}
})
}

// === resolveTaint ===

func TestResolveTaint_FlagSetParsesCLI(t *testing.T) {
flags := []cli.Flag{&cli.StringFlag{Name: "gate"}}
runWith(t, flags, []string{"--gate", "k=v:NoSchedule"}, func(c *cli.Command) {
got, err := resolveTaint(c, "gate", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil || got.Key != "k" {
t.Errorf("got %+v", got)
}
})
}

func TestResolveTaint_FlagSetEmptyMasksFallback(t *testing.T) {
flags := []cli.Flag{&cli.StringFlag{Name: "gate"}}
fallback := &corev1.Taint{Key: "from-config"}
// Explicit empty CLI value masks the config fallback and yields nil.
// This preserves pre-refactor behavior where stringFlagOrConfig
// surfaced "" and the caller skipped parsing entirely.
runWith(t, flags, []string{"--gate", ""}, func(c *cli.Command) {
got, err := resolveTaint(c, "gate", fallback)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != nil {
t.Errorf("expected nil (CLI empty masks fallback), got %+v", got)
}
})
}

func TestResolveTaint_NoFlagReturnsFallback(t *testing.T) {
flags := []cli.Flag{&cli.StringFlag{Name: "gate"}}
fallback := &corev1.Taint{Key: "from-config"}
runWith(t, flags, []string{}, func(c *cli.Command) {
got, err := resolveTaint(c, "gate", fallback)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil || got.Key != "from-config" {
t.Errorf("expected fallback, got %+v", got)
}
})
}

func TestResolveTaint_NoFlagNilFallbackReturnsNil(t *testing.T) {
flags := []cli.Flag{&cli.StringFlag{Name: "gate"}}
runWith(t, flags, []string{}, func(c *cli.Command) {
got, err := resolveTaint(c, "gate", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != nil {
t.Errorf("expected nil, got %+v", got)
}
})
}

func TestResolveTaint_FlagOverridesFallbackLogsOverride(t *testing.T) {
// Just exercise the override branch; we don't assert the log.
flags := []cli.Flag{&cli.StringFlag{Name: "gate"}}
fallback := &corev1.Taint{Key: "old"}
runWith(t, flags, []string{"--gate", "new=v:NoSchedule"}, func(c *cli.Command) {
got, err := resolveTaint(c, "gate", fallback)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil || got.Key != "new" {
t.Errorf("expected new taint, got %+v", got)
}
})
}

func TestResolveTaint_InvalidFlagReturnsError(t *testing.T) {
flags := []cli.Flag{&cli.StringFlag{Name: "gate"}}
runWith(t, flags, []string{"--gate", "no-effect"}, func(c *cli.Command) {
_, err := resolveTaint(c, "gate", nil)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "invalid --gate") {
t.Errorf("error %q must mention --gate", err.Error())
}
})
}

// === resolveOutputTarget ===

func TestResolveOutputTarget_FlagSetParsesCLI(t *testing.T) {
flags := []cli.Flag{&cli.StringFlag{Name: "output"}}
resolved := &appcfg.BundleResolved{}
runWith(t, flags, []string{"--output", "./mybundle"}, func(c *cli.Command) {
ref, err := resolveOutputTarget(c, resolved)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ref == nil || ref.IsOCI {
t.Errorf("expected local ref, got %+v", ref)
}
})
}

func TestResolveOutputTarget_NoFlagUsesResolvedTarget(t *testing.T) {
flags := []cli.Flag{&cli.StringFlag{Name: "output"}}
b := &appcfg.BundleSpec{Output: &appcfg.BundleOutputSpec{Target: "./from-config"}}
resolved, err := b.Resolve()
if err != nil {
t.Fatalf("Resolve: %v", err)
}
runWith(t, flags, []string{}, func(c *cli.Command) {
ref, err := resolveOutputTarget(c, resolved)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ref == nil {
t.Fatal("expected ref, got nil")
}
if ref.LocalPath == "" {
t.Errorf("expected LocalPath populated, got %+v", ref)
}
})
}

func TestResolveOutputTarget_NoFlagNoResolvedDefaultsToCurrentDir(t *testing.T) {
flags := []cli.Flag{&cli.StringFlag{Name: "output"}}
resolved := &appcfg.BundleResolved{}
runWith(t, flags, []string{}, func(c *cli.Command) {
ref, err := resolveOutputTarget(c, resolved)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ref == nil || ref.IsOCI {
t.Errorf("expected local ref defaulting to '.', got %+v", ref)
}
})
}

func TestResolveOutputTarget_InvalidFlagReturnsError(t *testing.T) {
flags := []cli.Flag{&cli.StringFlag{Name: "output"}}
resolved := &appcfg.BundleResolved{}
runWith(t, flags, []string{"--output", "oci://"}, func(c *cli.Command) {
_, err := resolveOutputTarget(c, resolved)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "invalid --output") {
t.Errorf("error %q must mention --output", err.Error())
}
})
}

func TestResolveOutputTarget_FlagSetEmptyDefaultsToCurrentDir(t *testing.T) {
// `--output ""` matches pre-refactor behavior where the empty
// string was substituted with "." before parsing rather than
// passed through (which would have produced LocalPath: "").
flags := []cli.Flag{&cli.StringFlag{Name: "output"}}
b := &appcfg.BundleSpec{Output: &appcfg.BundleOutputSpec{Target: "./from-config"}}
resolved, err := b.Resolve()
if err != nil {
t.Fatalf("Resolve: %v", err)
}
runWith(t, flags, []string{"--output", ""}, func(c *cli.Command) {
ref, err := resolveOutputTarget(c, resolved)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ref == nil || ref.IsOCI {
t.Errorf("expected local ref defaulting to '.', got %+v", ref)
}
if ref.LocalPath != "." {
t.Errorf("expected LocalPath '.', got %q", ref.LocalPath)
}
})
}

func TestResolveOutputTarget_FlagOverridesNonEmptyConfigLogs(t *testing.T) {
flags := []cli.Flag{&cli.StringFlag{Name: "output"}}
b := &appcfg.BundleSpec{Output: &appcfg.BundleOutputSpec{Target: "./old"}}
resolved, err := b.Resolve()
if err != nil {
t.Fatalf("Resolve: %v", err)
}
runWith(t, flags, []string{"--output", "./new"}, func(c *cli.Command) {
ref, err := resolveOutputTarget(c, resolved)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if ref == nil {
t.Fatal("expected ref")
}
if !strings.HasSuffix(ref.LocalPath, "new") {
t.Errorf("expected ./new override, got %+v", ref)
}
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether this test file uses table-driven patterns today.
fd -i 'bundle_resolve_helpers_test.go' --exec rg -n '^\s*func\s+Test|t\.Run\(|\[\]struct\s*\{' {}

Repository: NVIDIA/aicr

Length of output: 2028


Convert test functions into table-driven subtests for each helper family.

This file contains 27 individual test functions organized by helper (resolveDeployer, resolveComponentPaths, resolveTolerations, resolveTaint, resolveOutputTarget) with no table-driven patterns (t.Run subtests or []struct test cases). Group each helper's 5–6 scenario tests into a single table-driven test with subtests to align with Go test conventions and reduce duplication.

🤖 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/cli/bundle_resolve_helpers_test.go` around lines 31 - 440, Group the
individual tests for each helper (resolveDeployer, resolveComponentPaths,
resolveTolerations, resolveTaint, resolveOutputTarget) into single table-driven
tests using a slice of test cases and t.Run subtests; for each family replace
the multiple TestResolveXxx_* functions with one TestResolveXxx_TableDriven that
defines []struct{name, args, want, wantErr, note} and iterates cases calling the
same helper (resolveDeployer, resolveComponentPaths, resolveTolerations,
resolveTaint, resolveOutputTarget) inside t.Run, preserving the original
scenarios (flag set, no flag fallback, empty flag behavior, invalid flag error,
override logging) and reusing runWith and the same assertions per case so
behavior is identical but consolidated into subtests for clarity and reduced
duplication.

Comment thread pkg/config/resolve.go
Comment on lines +35 to +38
// Zero values mean "config did not set this field." Maps and slices
// preserve the nil-vs-explicitly-empty distinction from the wire spec —
// callers can therefore detect whether a user wrote `selector: {}` to
// clear an inherited default vs. omitted the key entirely.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Narrow the exported BundleResolved contract.

This comment says maps and slices preserve nil-vs-explicitly-empty semantics, but Resolve() does not keep that guarantee for toleration slices because explicitly empty toleration lists are normalized by snapshotter.ParseTolerations(...). Please either scope the doc to the fields that actually preserve the distinction or change the implementation so callers do not rely on a contract the API does not provide.

As per coding guidelines, "update code comments and godoc for API changes in Go files."

🤖 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/config/resolve.go` around lines 35 - 38, The exported BundleResolved
contract claims maps and slices preserve nil-vs-explicitly-empty semantics but
Resolve() currently normalizes toleration slices via
snapshotter.ParseTolerations, breaking that guarantee; either narrow the godoc
on BundleResolved to only promise nil-vs-empty for fields that are actually
preserved or change Resolve/BundleResolved handling so toleration fields are not
normalized (stop calling snapshotter.ParseTolerations or preserve an
explicit-empty marker and propagate it through Resolve), updating BundleResolved
godoc and the Resolve() implementation and referencing the toleration slice
fields, the BundleResolved type, the Resolve function, and
snapshotter.ParseTolerations to keep comments and behavior consistent.

Comment thread pkg/config/resolve.go
Comment on lines +170 to +176
if b.Scheduling != nil {
if b.Scheduling.Nodes < 0 {
return nil, errors.New(errors.ErrCodeInvalidRequest,
fmt.Sprintf("spec.bundle.scheduling.nodes must be >= 0, got %d", b.Scheduling.Nodes))
}
out.Nodes = b.Scheduling.Nodes
out.StorageClass = b.Scheduling.StorageClass

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reject blank spec.bundle.scheduling.storageClass in config too.

The CLI path trims and rejects whitespace-only --storage-class, but the config path copies b.Scheduling.StorageClass verbatim. A quoted YAML value like " " now passes Validate() and later gets injected as the storage class. Trimming and validating here would keep config and CLI semantics aligned and attribute the error to the spec path.

🤖 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/config/resolve.go` around lines 170 - 176, The config path currently
copies b.Scheduling.StorageClass verbatim which allows whitespace-only values;
update the block handling b.Scheduling in resolve.go to trim whitespace from
b.Scheduling.StorageClass (e.g., strings.TrimSpace) and if the trimmed result is
empty return an invalid-request error (same style as the nodes check) instead of
assigning it to out.StorageClass, so config validation matches the CLI semantics
and Validate() will catch blank storageClass values at the spec/resolution step.

Comment thread pkg/config/resolve.go
Comment on lines +238 to +276
if c.Service != "" {
v, err := recipe.ParseCriteriaServiceType(c.Service)
if err != nil {
return nil, errors.Wrap(errors.ErrCodeInvalidRequest,
"invalid spec.recipe.criteria.service", err)
}
out.Service = v
}
if c.Accelerator != "" {
v, err := recipe.ParseCriteriaAcceleratorType(c.Accelerator)
if err != nil {
return nil, errors.Wrap(errors.ErrCodeInvalidRequest,
"invalid spec.recipe.criteria.accelerator", err)
}
out.Accelerator = v
}
if c.Intent != "" {
v, err := recipe.ParseCriteriaIntentType(c.Intent)
if err != nil {
return nil, errors.Wrap(errors.ErrCodeInvalidRequest,
"invalid spec.recipe.criteria.intent", err)
}
out.Intent = v
}
if c.OS != "" {
v, err := recipe.ParseCriteriaOSType(c.OS)
if err != nil {
return nil, errors.Wrap(errors.ErrCodeInvalidRequest,
"invalid spec.recipe.criteria.os", err)
}
out.OS = v
}
if c.Platform != "" {
v, err := recipe.ParseCriteriaPlatformType(c.Platform)
if err != nil {
return nil, errors.Wrap(errors.ErrCodeInvalidRequest,
"invalid spec.recipe.criteria.platform", err)
}
out.Platform = v

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== pkg/errors helper =="
fd -p 'errors.go' pkg/errors -x sed -n '/func PropagateOrWrap/,/}/p' {}

echo
echo "== recipe criteria parsers and their error helpers =="
fd -p 'criteria.go' pkg/recipe -x rg -n -C2 'func ParseCriteria(ServiceType|AcceleratorType|IntentType|OSType|PlatformType)|errors\.(New|Wrap|WrapWithContext|PropagateOrWrap)' {}

echo
echo "== current ResolveCriteria wrapping sites =="
rg -n -C1 'invalid spec\.recipe\.criteria\.(service|accelerator|intent|os|platform)' pkg/config/resolve.go

Repository: NVIDIA/aicr

Length of output: 12285


Replace errors.Wrap() with errors.PropagateOrWrap() at the pkg/recipe parse boundary.

ResolveCriteria() is the single wire→domain boundary for these fields. Each recipe.ParseCriteria* function already returns a structured error with ErrCodeInvalidRequest, but the current errors.Wrap(errors.ErrCodeInvalidRequest, ...) calls overwrite it. Use errors.PropagateOrWrap(err, errors.ErrCodeInvalidRequest, "message") to preserve the original error code while attaching the spec-path context.

Lines 238–276 (all five criteria fields)
if c.Service != "" {
    v, err := recipe.ParseCriteriaServiceType(c.Service)
    if err != nil {
-       return nil, errors.Wrap(errors.ErrCodeInvalidRequest,
-           "invalid spec.recipe.criteria.service", err)
+       return nil, errors.PropagateOrWrap(err, errors.ErrCodeInvalidRequest,
+           "invalid spec.recipe.criteria.service")
    }
    out.Service = v
}

Apply the same pattern to Accelerator, Intent, OS, and Platform fields.

🤖 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/config/resolve.go` around lines 238 - 276, Replace the errors.Wrap calls
in ResolveCriteria with errors.PropagateOrWrap so you preserve structured error
codes returned from the recipe parse functions; specifically, where you call
recipe.ParseCriteriaServiceType, recipe.ParseCriteriaAcceleratorType,
recipe.ParseCriteriaIntentType, recipe.ParseCriteriaOSType, and
recipe.ParseCriteriaPlatformType, change the error handling from
errors.Wrap(errors.ErrCodeInvalidRequest, "...") to errors.PropagateOrWrap(err,
errors.ErrCodeInvalidRequest, "invalid spec.recipe.criteria.<field>") so the
original err from the ParseCriteria* functions is propagated while attaching the
spec-path context.

@github-actions

github-actions Bot commented May 7, 2026

Copy link
Copy Markdown
Contributor

Coverage Report ✅

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

Merging this branch will increase overall coverage

Impacted Packages Coverage Δ 🤖
github.com/NVIDIA/aicr/pkg/cli 62.58% (+1.90%) 👍
github.com/NVIDIA/aicr/pkg/config 95.09% (+38.14%) 🌟

Coverage by file

Changed files (no unit tests)

Changed File Coverage Δ Total Covered Missed 🤖
github.com/NVIDIA/aicr/pkg/cli/bundle.go 41.78% (+3.75%) 146 (+4) 61 (+7) 85 (-3) 👍
github.com/NVIDIA/aicr/pkg/cli/bundle_config.go 98.46% (-1.54%) 65 (+45) 64 (+44) 1 (+1) 👎
github.com/NVIDIA/aicr/pkg/cli/recipe.go 85.48% (+2.75%) 124 (-15) 106 (-9) 18 (-6) 👍
github.com/NVIDIA/aicr/pkg/config/accessors.go 100.00% (+100.00%) 18 (-63) 18 (+18) 0 (-81) 🌟
github.com/NVIDIA/aicr/pkg/config/resolve.go 100.00% (+100.00%) 94 (+94) 94 (+94) 0 🌟
github.com/NVIDIA/aicr/pkg/config/validate.go 100.00% (+1.67%) 37 (-23) 37 (-22) 0 (-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.

@mchmarny mchmarny merged commit c711a31 into main May 7, 2026
34 of 35 checks passed
@mchmarny mchmarny deleted the refactor/config-resolve-783 branch May 7, 2026 13:40
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.

refactor(config): unified Resolve() per spec type for wire→domain conversion

2 participants