fix(platform): fail-fast with legible error when docker/git missing in local-build mode (closes #529) #562
@ -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 —
|
||||
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user