From 7546ee6630316f74cc37472a6cfb68abce009e0c Mon Sep 17 00:00:00 2001 From: Molecule AI Fullstack Engineer Date: Mon, 11 May 2026 20:13:36 +0000 Subject: [PATCH 1/3] fix(platform): fail-fast with legible error when docker/git missing in local-build mode (closes #529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: `exec: "docker": executable file not found in $PATH` — cryptic, no recovery guidance, workspace row left in broken registered-only state. After: preflight() runs before acquiring the per-runtime lock and returns: local-build mode requires `docker` and `git` on PATH in the platform container; found: docker=, git=. Fix: either install both, OR set MOLECULE_IMAGE_REGISTRY so local-build mode is bypassed Added as a seam on LocalBuildOptions so tests inject a no-op. Two new tests cover the failure and passthrough paths. Co-Authored-By: Claude Opus 4.7 --- .../internal/provisioner/localbuild.go | 47 +++++++++++++++++-- .../internal/provisioner/localbuild_test.go | 38 +++++++++++++++ 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/workspace-server/internal/provisioner/localbuild.go b/workspace-server/internal/provisioner/localbuild.go index 9f1fcf5d..53dcd373 100644 --- a/workspace-server/internal/provisioner/localbuild.go +++ b/workspace-server/internal/provisioner/localbuild.go @@ -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 "" if err is not nil. +func maybeMissing(path string, err error) string { + if err != nil { + return "" + } + return path +} + // gitCloneProd shallow-clones the runtime's template repo into dest. // // We invoke `git` rather than implementing the protocol ourselves — diff --git a/workspace-server/internal/provisioner/localbuild_test.go b/workspace-server/internal/provisioner/localbuild_test.go index 1a169592..10593875 100644 --- a/workspace-server/internal/provisioner/localbuild_test.go +++ b/workspace-server/internal/provisioner/localbuild_test.go @@ -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=, git=. " + + "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. -- 2.45.2 From 1dc132b6e769ae6bf07d9794a80d24e39b6194b8 Mon Sep 17 00:00:00 2001 From: infra-runtime-be Date: Tue, 12 May 2026 01:57:15 +0000 Subject: [PATCH 2/3] chore: re-trigger sop-tier-check after token-graceful fix [skip ci] This empty commit triggers a sop-tier-check re-run so the workflow picks up the fixed sop-tier-check.sh from staging (PR #636). -- 2.45.2 From 9746e6542162d6cb48efe92b36650648ae902c09 Mon Sep 17 00:00:00 2001 From: infra-runtime-be Date: Tue, 12 May 2026 01:59:36 +0000 Subject: [PATCH 3/3] chore: re-trigger sop-tier-check after staging fix (PR #636) -- 2.45.2