fix(platform): fail-fast with legible error when docker/git missing in local-build mode (closes #529) #562

Merged
infra-runtime-be merged 3 commits from fix/529-preflight-localbuild into staging 2026-05-12 02:01:07 +00:00
2 changed files with 80 additions and 5 deletions

View File

@ -111,11 +111,12 @@ type LocalBuildOptions struct {
// remoteHeadSha + dockerBuild + gitClone are seams for tests; if
// nil, the production implementations are used.
remoteHeadSha func(ctx context.Context, opts *LocalBuildOptions, runtime string) (string, error)
gitClone func(ctx context.Context, opts *LocalBuildOptions, runtime, dest string) error
dockerBuild func(ctx context.Context, opts *LocalBuildOptions, contextDir, tag string) error
dockerHasTag func(ctx context.Context, tag string) (bool, error)
dockerTag func(ctx context.Context, src, dst string) error
remoteHeadSha func(ctx context.Context, opts *LocalBuildOptions, runtime string) (string, error)
gitClone func(ctx context.Context, opts *LocalBuildOptions, runtime, dest string) error
dockerBuild func(ctx context.Context, opts *LocalBuildOptions, contextDir, tag string) error
dockerHasTag func(ctx context.Context, tag string) (bool, error)
dockerTag func(ctx context.Context, src, dst string) error
preflightLocalBuild func() error
}
func newDefaultLocalBuildOptions() *LocalBuildOptions {
@ -187,6 +188,15 @@ func ensureLocalImageWithOpts(ctx context.Context, runtime string, opts *LocalBu
return "", fmt.Errorf("local-build: refusing to build unknown runtime %q (must be one of %v)", runtime, knownRuntimes)
}
// Fail-fast with an actionable error before acquiring the per-runtime lock.
preflight := opts.preflightLocalBuild
if preflight == nil {
preflight = preflightLocalBuildProd
}
if err := preflight(); err != nil {
return "", err
}
lock := runtimeBuildLock(runtime)
lock.Lock()
defer lock.Unlock()
@ -427,6 +437,33 @@ func parseGiteaBranchHeadSha(body []byte) (string, error) {
return sha, nil
}
// preflightLocalBuildProd checks that the `docker` and `git` binaries are
// on PATH before any build/clone operations run. Without this check the
// first exec call produces a cryptic "executable file not found" error that
// gives no actionable recovery guidance.
func preflightLocalBuildProd() error {
dockerPath, dockerErr := exec.LookPath("docker")
gitPath, gitErr := exec.LookPath("git")
if dockerErr != nil || gitErr != nil {
return fmt.Errorf(
"local-build mode requires `docker` and `git` on PATH in the platform container; "+
"found: docker=%s, git=%s. "+
"Fix: either install both, OR set MOLECULE_IMAGE_REGISTRY so local-build mode is bypassed",
maybeMissing(dockerPath, dockerErr),
maybeMissing(gitPath, gitErr),
)
}
return nil
}
// maybeMissing returns the path if found, or "<missing>" if err is not nil.
func maybeMissing(path string, err error) string {
if err != nil {
return "<missing>"
}
return path
}
// gitCloneProd shallow-clones the runtime's template repo into dest.
//
// We invoke `git` rather than implementing the protocol ourselves —

View File

@ -24,6 +24,9 @@ func makeTestOpts(t *testing.T) *LocalBuildOptions {
RepoPrefix: "https://git.test/molecule-ai/molecule-ai-workspace-template-",
Platform: "linux/amd64",
HTTPClient: &http.Client{},
preflightLocalBuild: func() error {
return nil // tests bypass the real PATH check
},
remoteHeadSha: func(ctx context.Context, opts *LocalBuildOptions, runtime string) (string, error) {
return "abcdef0123456789abcdef0123456789abcdef01", nil
},
@ -627,6 +630,41 @@ func TestProvisionerStartUsesLocalBuild_LocalMode(t *testing.T) {
// caught by this test.
}
// TestEnsureLocalImage_Hooks preflightLocalBuild — when preflight fails,
func TestEnsureLocalImage_PreflightFailsIfDockerMissing(t *testing.T) {
opts := makeTestOpts(t)
opts.preflightLocalBuild = func() error {
return fmt.Errorf(
"local-build mode requires `docker` and `git` on PATH in the platform container; " +
"found: docker=<missing>, git=<missing>. " +
"Fix: either install both, OR set MOLECULE_IMAGE_REGISTRY so local-build mode is bypassed")
}
_, err := ensureLocalImageWithOpts(context.Background(), "claude-code", opts)
if err == nil {
t.Fatalf("expected preflight error, got nil")
}
if !strings.Contains(err.Error(), "local-build mode requires") {
t.Errorf("error = %v, want preflight failure message", err)
}
if !strings.Contains(err.Error(), "MOLECULE_IMAGE_REGISTRY") {
t.Errorf("error = %v, want recovery hint mentioning MOLECULE_IMAGE_REGISTRY", err)
}
}
// TestEnsureLocalImage_PreflightOKPassesThrough — when preflight returns
// nil, execution proceeds normally.
func TestEnsureLocalImage_PreflightOKPassesThrough(t *testing.T) {
opts := makeTestOpts(t)
opts.preflightLocalBuild = func() error { return nil }
tag, err := ensureLocalImageWithOpts(context.Background(), "claude-code", opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(tag, "abcdef012345") {
t.Errorf("tag = %q, want sha in it", tag)
}
}
// TestEnsureLocalImageHook_DefaultIsRealFunction — pin that the
// production hook points at EnsureLocalImage. Tests that swap the hook
// must restore it via t.Cleanup; this test catches a leaked override.