diff --git a/candy/plugin-build/drive.go b/candy/plugin-build/drive.go index acf2445..ee911b6 100644 --- a/candy/plugin-build/drive.go +++ b/candy/plugin-build/drive.go @@ -78,12 +78,30 @@ func resolveBuild(ctx context.Context, ex *sdk.Executor, req spec.BuildRequest, // refs (the BuildReply.Written provenance). The engine RESOLVE runs plugin-side (resolveBuildEngine, // K3 U6); the layer MERGE crosses to verb:oci; the podman exec + the build-order orchestration live HERE. func runBoxBuild(ctx context.Context, ex *sdk.Executor, req spec.BuildRequest) ([]string, error) { + return runBoxBuildWithEngine(ctx, ex, req, "") +} + +// buildEngineFor picks the engine the BUILD bridges its OCI export into. `build:resolve` +// reports the host-wide runtime engine; a `build:ensure` caller that asked for a DIFFERENT +// run engine must win, or a locally-built image lands in the host engine's store and the +// deploy that asked for containerd never finds it (the alias step fails +// `image "...": not found`, and a pod bed cannot run on containerd). +func buildEngineFor(resolved, requested string) string { + if requested != "" { + return requested + } + return resolved +} + +// runBoxBuildWithEngine is runBoxBuild with an explicit run-engine override. `requested` +// empty preserves the pre-existing host-runtime resolution. +func runBoxBuildWithEngine(ctx context.Context, ex *sdk.Executor, req spec.BuildRequest, requested string) ([]string, error) { reply, err := resolveBuild(ctx, ex, req, false) if err != nil { return nil, err } cfg := driveConfig{ - RunEngine: reply.EngineName, + RunEngine: buildEngineFor(reply.EngineName, requested), Platform: reply.Platform, Cache: reply.Cache, Push: req.Push, diff --git a/candy/plugin-build/ensure.go b/candy/plugin-build/ensure.go index 3e18814..cb75a9b 100644 --- a/candy/plugin-build/ensure.go +++ b/candy/plugin-build/ensure.go @@ -34,6 +34,7 @@ package build // and the podman pull/tag exec are pure sdk/kit + os/exec. import ( + "bytes" "context" "encoding/json" "fmt" @@ -125,11 +126,12 @@ func ensureRemoteRef(ctx context.Context, ex *sdk.Executor, image, stripped, run } } fmt.Fprintf(os.Stderr, "ensure-image: building remote %s from cached source\n", image) - if _, berr := runBoxBuild(ctx, ex, spec.BuildRequest{Boxes: []string{rr.BoxName}, Dir: rr.CacheDir}); berr != nil { + built, berr := runBoxBuildWithEngine(ctx, ex, spec.BuildRequest{Boxes: []string{rr.BoxName}, Dir: rr.CacheDir}, runEngine) + if berr != nil { return fmt.Errorf("ensure-image %q: pull failed and remote build failed: %w", image, berr) } return aliasBuiltImageOntoRequestedRef(ctx, image, runEngine, func() string { - return resolveRemoteImageRef(ctx, ex, rr.CacheDir, rr.BoxName) + return builtRefForBox(built, rr.BoxName) }) } @@ -138,15 +140,15 @@ func ensureRemoteRef(ctx context.Context, ex *sdk.Executor, image, stripped, run // build `image` ITSELF must resolve in local storage. A box build produces the project's // CalVer-tagged ref, so a full input ref (pinned to a different tag, or tagless) needs an alias. // -// resolveProduced is a CALLBACK, deliberately, because it must run AFTER the build: every producer -// of it bottoms out in ResolveShellImageRef with an empty tag, which READS THE LOCAL IMAGE STORE -// (spec/container/local_image_coneb.go — empty tag → ResolveNewestLocalCalVer). Resolved BEFORE -// the build, on a store that does not yet hold the image, it degrades to the bare -// `/` — which both EQUALS `image` (silently defeating a `produced != image` guard) -// and names a ref the build never created. That was a real cold-store defect: the first run on a -// fresh store left the requested ref absent and failed ~1000 log lines later with podman's -// `image not known`, while every subsequent run passed because run 1's build had seeded the store -// — so the bug reproduced only on the fresh-machine path R10 exists to cover. +// resolveProduced is a CALLBACK, deliberately, because it must run AFTER the build. It must +// return the ref the build drive actually produced (builtRefForBox over the drive's returned +// FullTags). Re-resolving it from a local image store instead reads the DEFAULT engine's store, +// which is the wrong store once the caller overrode the run engine (and a prefixed bed tag such +// as `check-engine-containerd-pod-2026.309.1951` still carries a CalVer suffix, so the wrong +// store can return the REQUESTED tag itself and defeat the `produced != image` guard). Resolving +// before the build is equally wrong: it degrades to the bare `/`, which both +// equals `image` and names a ref the build never created (the cold-store `image not known` +// defect R10 exists to cover). // // A failed alias is a HARD error, not a warning: the caller cannot proceed without the ref, and a // warning here only relocates the failure to a downstream message that names neither this build @@ -169,6 +171,28 @@ func aliasBuiltImageOntoRequestedRef(ctx context.Context, image, runEngine strin return nil } +// builtRefForBox picks the ref the build produced for box from the build drive's returned +// FullTags. The drive's OWN return value is the authoritative produced ref. Re-resolving it +// from a local image store (the former callback) reads the DEFAULT engine's store — which is +// exactly the wrong store once build:ensure has overridden the run engine — and can return the +// requested tag itself (defeating the `produced != image` guard) because a prefixed bed tag +// still carries a CalVer suffix. +func builtRefForBox(built []string, box string) string { + for _, ref := range built { + repo := ref + if colon := strings.LastIndex(repo, ":"); colon > strings.LastIndex(repo, "/") { + repo = repo[:colon] + } + if repo == box || strings.HasSuffix(repo, "/"+box) { + return ref + } + } + if len(built) == 1 { + return built[0] + } + return "" +} + // resolveRemoteImageRef computes the registry pull ref for a remote box PLUGIN-SIDE, loading the // cached repo's cfg via the K1 loader reverse legs (the host "remote-image-resolve" seam now does // ONLY the git clone/cache — K1 loader wave, deploykit.ResolveSpecBox shed from charly core). It @@ -232,17 +256,18 @@ func ensureProjectRef(ctx context.Context, ex *sdk.Executor, image, dir, runEngi } fmt.Fprintf(os.Stderr, "ensure-image: building %s locally\n", fallbackShort) - if _, berr := runBoxBuild(ctx, ex, spec.BuildRequest{ + built, berr := runBoxBuildWithEngine(ctx, ex, spec.BuildRequest{ Boxes: []string{fallbackShort}, Dir: dir, IncludeDisabled: true, Jobs: 4, - }); berr != nil { + }, runEngine) + if berr != nil { return fmt.Errorf("ensure-image %q: pull failed and local build failed: %w", image, berr) } return aliasBuiltImageOntoRequestedRef(ctx, image, runEngine, func() string { - return resolveImageRefPlugin(fallbackShort, cfg) + return builtRefForBox(built, fallbackShort) }) } @@ -359,9 +384,22 @@ func remoteImageResolve(ctx context.Context, ex *sdk.Executor, ref, tag string) // back to a local build. func enginePull(ctx context.Context, runEngine, ref string) error { cmd := exec.CommandContext(ctx, container.EngineBinary(runEngine), "pull", ref) - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr - return cmd.Run() + // CAPTURE the engine's output. A pull miss is an EXPECTED fallback here — the caller + // rebuilds the image locally — so the engine's own `level=fatal` line must not escape into + // a bed's log as an unhandled error (the R10 zero-warning gate counts it, even though the + // config path exits 0 after the fallback). The caller reports the concise failure, and the + // captured reason rides the returned ERROR so a genuine pull failure (network/auth) keeps + // its diagnostic instead of collapsing to "exit status 1". + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + if err := cmd.Run(); err != nil { + if detail := strings.TrimSpace(out.String()); detail != "" { + return fmt.Errorf("%w: %s", err, detail) + } + return err + } + return nil } // engineTag adds a second tag to an existing local image. Used to satisfy a tag-pinned input ref diff --git a/candy/plugin-build/ensure_alias_test.go b/candy/plugin-build/ensure_alias_test.go index c470869..3496c01 100644 --- a/candy/plugin-build/ensure_alias_test.go +++ b/candy/plugin-build/ensure_alias_test.go @@ -90,3 +90,72 @@ func TestAliasBuiltImage_FullRefPredicateSeparatesTheCases(t *testing.T) { t.Error("a bare short name must NOT read as a full ref") } } + +// TestBuildEngineFor pins the run-engine override the local build fallback needs: the +// build:resolve reply names the HOST runtime engine, but a build:ensure caller that asked +// for a different run engine must have the build bridge its OCI export into THAT engine's +// store. Without the override a locally-built image lands in podman and the deploy that +// asked for containerd never finds it (the alias fails `image "...": not found`). +func TestBuildEngineFor(t *testing.T) { + cases := []struct { + name string + resolved string + requested string + want string + }{ + {"requested run engine wins", "podman", "containerd", "containerd"}, + {"empty requested keeps the resolved engine", "podman", "", "podman"}, + {"both empty stays empty", "", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := buildEngineFor(tc.resolved, tc.requested); got != tc.want { + t.Fatalf("buildEngineFor(%q, %q) = %q, want %q", tc.resolved, tc.requested, got, tc.want) + } + }) + } +} + +// TestBuiltRefForBox pins the produced-ref source: the build drive's returned FullTags are +// authoritative. Re-resolving from a local store reads the default engine's store (wrong once +// the run engine is overridden) and can return the requested prefixed bed tag itself. +func TestBuiltRefForBox(t *testing.T) { + cases := []struct { + name string + built []string + box string + want string + }{ + { + name: "single built ref for the requested box", + built: []string{"ghcr.io/opencharly/probe-engine-image:2026.309.1953"}, + box: "probe-engine-image", + want: "ghcr.io/opencharly/probe-engine-image:2026.309.1953", + }, + { + name: "picks the matching box out of several", + built: []string{"ghcr.io/opencharly/other:2026.309.1953", "ghcr.io/opencharly/probe-engine-image:2026.309.1953"}, + box: "probe-engine-image", + want: "ghcr.io/opencharly/probe-engine-image:2026.309.1953", + }, + { + name: "single ref with a registry-namespaced leaf still matches", + built: []string{"ghcr.io/opencharly/probe-engine-image:2026.309.1953"}, + box: "opencharly/probe-engine-image", + want: "ghcr.io/opencharly/probe-engine-image:2026.309.1953", + }, + { + name: "nothing built", + built: nil, + box: "probe-engine-image", + want: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := builtRefForBox(tc.built, tc.box); got != tc.want { + t.Fatalf("builtRefForBox(%v, %q) = %q, want %q", tc.built, tc.box, got, tc.want) + } + }) + } +}