molecule-core/workspace-server/internal/provisioner/localbuild_test.go
claude-ceo-assistant d9e380c5bc
Some checks failed
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 5s
CI / Detect changes (pull_request) Successful in 7s
CI / Canvas (Next.js) (pull_request) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m38s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 7s
Harness Replays / detect-changes (pull_request) Successful in 7s
Harness Replays / Harness Replays (pull_request) Failing after 42s
CI / Platform (Go) (pull_request) Successful in 3m32s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 1s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 1s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 6s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 1s
Retarget main PRs to staging / Retarget to staging (pull_request) Has been skipped
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 5s
feat(workspace-server): local-dev provisioner builds from Gitea source when MOLECULE_IMAGE_REGISTRY is unset (#63, Task #194)
OSS contributors who clone molecule-core and `go run ./workspace-server/cmd/server`
now get a working end-to-end provision without authenticating to GHCR or AWS ECR.

Pre-fix: with MOLECULE_IMAGE_REGISTRY unset, the provisioner attempted to pull
ghcr.io/molecule-ai/workspace-template-<runtime>:latest, which has been
returning 403 since the 2026-05-06 GitHub-org suspension.

Post-fix: when MOLECULE_IMAGE_REGISTRY is unset, the provisioner switches to
local-build mode — looks up the workspace-template-<runtime> repo's HEAD sha
on Gitea via a single API call, shallow-clones into ~/.cache/molecule/, and
runs `docker build --platform=linux/amd64`. SHA-pinned cache key skips the
clone+build entirely on subsequent provisions.

Production tenants are unaffected: every prod tenant sets the var to its
private ECR mirror, so the SaaS pull path is byte-for-byte identical.

SSOT for mode detection lives in Resolve() (registry_mode.go) returning a
discriminated RegistrySource{Mode, Prefix} so call sites that branch on
mode get a compile-time push instead of a string-equality footgun.

Coverage:
* registry_mode.go            — new SSOT (Resolve, RegistryMode, IsKnownRuntime)
* registry_mode_test.go       — 8 tests pinning mode-decision contract
* localbuild.go               — clone+build pipeline (570 LOC, fully unit-tested)
* localbuild_test.go          — 22 tests covering happy/sad paths, fail-closed
* provisioner.go              — Start() inserts ensureLocalImageHook in local mode
* docs/adr/ADR-002            — design rationale + alternatives + security review
* docs/development/local-development.md — local-build flow + env overrides

Security:
* Allowlist-only runtime names (knownRuntimes) gate the clone path.
* Repo prefix hardcoded to git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-;
  forks via opt-in MOLECULE_LOCAL_TEMPLATE_REPO_PREFIX.
* MOLECULE_GITEA_TOKEN masked in every log line via maskTokenInURL/maskTokenInString.
* Fail-closed: Gitea unreachable / runtime not mirrored → clear error, never
  silently fall back to GHCR/ECR.
* docker build invocation passes no --build-arg from external input.
* HTTP body cap 64KB on Gitea API responses (defence vs malicious upstream).

Closes #63 / Task #194.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 15:16:51 -07:00

663 lines
23 KiB
Go

package provisioner
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
// makeTestOpts produces a LocalBuildOptions where every external seam
// (Gitea HEAD, git clone, docker build/has/tag) is replaced by a stub.
// Tests override the stub for the behavior they want to assert.
func makeTestOpts(t *testing.T) *LocalBuildOptions {
t.Helper()
tmp := t.TempDir()
return &LocalBuildOptions{
CacheDir: tmp,
RepoPrefix: "https://git.test/molecule-ai/molecule-ai-workspace-template-",
Platform: "linux/amd64",
HTTPClient: &http.Client{},
remoteHeadSha: func(ctx context.Context, opts *LocalBuildOptions, runtime string) (string, error) {
return "abcdef0123456789abcdef0123456789abcdef01", nil
},
gitClone: func(ctx context.Context, opts *LocalBuildOptions, runtime, dest string) error {
// Write a fake Dockerfile so the sanity-check passes.
if err := os.MkdirAll(dest, 0o755); err != nil {
return err
}
return os.WriteFile(filepath.Join(dest, "Dockerfile"), []byte("FROM scratch\n"), 0o644)
},
dockerBuild: func(ctx context.Context, opts *LocalBuildOptions, contextDir, tag string) error {
return nil
},
dockerHasTag: func(ctx context.Context, tag string) (bool, error) {
return false, nil
},
dockerTag: func(ctx context.Context, src, dst string) error {
return nil
},
}
}
// TestEnsureLocalImage_Success — happy path: HEAD lookup succeeds, no
// cache hit, clone + build run, returned tag is SHA-pinned.
func TestEnsureLocalImage_Success(t *testing.T) {
opts := makeTestOpts(t)
tag, err := ensureLocalImageWithOpts(context.Background(), "claude-code", opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := "molecule-local/workspace-template-claude-code:abcdef012345"
if tag != want {
t.Errorf("tag = %q, want %q", tag, want)
}
}
// TestEnsureLocalImage_CacheHit — second call with a cached image must
// skip clone + build entirely.
func TestEnsureLocalImage_CacheHit(t *testing.T) {
opts := makeTestOpts(t)
var cloneCount, buildCount int
opts.gitClone = func(ctx context.Context, opts *LocalBuildOptions, runtime, dest string) error {
cloneCount++
return os.WriteFile(filepath.Join(dest, "Dockerfile"), []byte("FROM scratch\n"), 0o644)
}
opts.dockerBuild = func(ctx context.Context, opts *LocalBuildOptions, contextDir, tag string) error {
buildCount++
return nil
}
opts.dockerHasTag = func(ctx context.Context, tag string) (bool, error) {
return true, nil // cached
}
if _, err := ensureLocalImageWithOpts(context.Background(), "hermes", opts); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cloneCount != 0 {
t.Errorf("cache hit triggered %d clones, want 0", cloneCount)
}
if buildCount != 0 {
t.Errorf("cache hit triggered %d builds, want 0", buildCount)
}
}
// TestEnsureLocalImage_UnknownRuntime — the allowlist guard rejects
// arbitrary runtime names before any network or filesystem call.
func TestEnsureLocalImage_UnknownRuntime(t *testing.T) {
opts := makeTestOpts(t)
for _, bad := range []string{
"", "unknown", "../../../etc/passwd", "claude-code; rm -rf /",
} {
t.Run(bad, func(t *testing.T) {
_, err := ensureLocalImageWithOpts(context.Background(), bad, opts)
if err == nil {
t.Errorf("EnsureLocalImage(%q) should fail (not a known runtime)", bad)
}
if err != nil && !strings.Contains(err.Error(), "unknown runtime") {
t.Errorf("error = %v, want one mentioning %q", err, "unknown runtime")
}
})
}
}
// TestEnsureLocalImage_GiteaUnreachable — fail-closed when the HEAD
// lookup fails. Must NOT fall back to GHCR/ECR.
func TestEnsureLocalImage_GiteaUnreachable(t *testing.T) {
opts := makeTestOpts(t)
opts.remoteHeadSha = func(ctx context.Context, opts *LocalBuildOptions, runtime string) (string, error) {
return "", errors.New("dial tcp: no such host")
}
_, err := ensureLocalImageWithOpts(context.Background(), "langgraph", opts)
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), "cannot determine HEAD sha") {
t.Errorf("error = %v, want one mentioning HEAD sha lookup", err)
}
// Critical: error must NOT mention ghcr or ecr (no silent fallback).
low := strings.ToLower(err.Error())
if strings.Contains(low, "ghcr") || strings.Contains(low, "ecr") {
t.Errorf("error message %q must not mention ghcr/ecr (no silent fallback)", err.Error())
}
}
// TestEnsureLocalImage_RepoNotFound — Gitea returned 404. Must surface
// a runtime-naming error so the OSS contributor can file the right
// mirroring task.
func TestEnsureLocalImage_RepoNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"message":"repo not found"}`))
}))
defer srv.Close()
opts := makeTestOpts(t)
opts.RepoPrefix = srv.URL + "/molecule-ai/molecule-ai-workspace-template-"
opts.HTTPClient = srv.Client()
opts.remoteHeadSha = nil // exercise real HTTP path
_, err := ensureLocalImageWithOpts(context.Background(), "crewai", opts)
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), "not mirrored") && !strings.Contains(err.Error(), "not found") {
t.Errorf("error = %v, want a missing-repo message", err)
}
}
// TestEnsureLocalImage_AuthFailure — Gitea returned 401/403. Must
// produce an actionable error (mentions the token env var so an OSS
// contributor knows what to set).
func TestEnsureLocalImage_AuthFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
opts := makeTestOpts(t)
opts.RepoPrefix = srv.URL + "/molecule-ai/molecule-ai-workspace-template-"
opts.HTTPClient = srv.Client()
opts.remoteHeadSha = nil
_, err := ensureLocalImageWithOpts(context.Background(), "claude-code", opts)
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), "MOLECULE_GITEA_TOKEN") {
t.Errorf("error = %v, want one mentioning MOLECULE_GITEA_TOKEN", err)
}
}
// TestEnsureLocalImage_HeadShaWithRealJSON — exercise the JSON parser
// against a Gitea-shaped response to catch parse drift.
func TestEnsureLocalImage_HeadShaWithRealJSON(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Real Gitea response shape (truncated for relevance).
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"name":"main",
"commit":{
"id":"3c849b3ba778abcdef0123456789abcdef012345",
"message":"feat: stuff"
}
}`))
}))
defer srv.Close()
opts := makeTestOpts(t)
opts.RepoPrefix = srv.URL + "/molecule-ai/molecule-ai-workspace-template-"
opts.HTTPClient = srv.Client()
opts.remoteHeadSha = nil // exercise real HTTP path
tag, err := ensureLocalImageWithOpts(context.Background(), "claude-code", opts)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(tag, "3c849b3ba778") {
t.Errorf("tag = %q, want one containing the parsed sha", tag)
}
}
// TestEnsureLocalImage_BuildFailure — surfaces docker-build errors with
// the build context so an operator can debug locally.
func TestEnsureLocalImage_BuildFailure(t *testing.T) {
opts := makeTestOpts(t)
opts.dockerBuild = func(ctx context.Context, opts *LocalBuildOptions, contextDir, tag string) error {
return errors.New("Dockerfile syntax error")
}
_, err := ensureLocalImageWithOpts(context.Background(), "autogen", opts)
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), "docker build") {
t.Errorf("error = %v, want one mentioning docker build", err)
}
}
// TestEnsureLocalImage_MissingDockerfile — the cloned tree must contain
// a Dockerfile at root; absence is a malformed-template-repo error.
func TestEnsureLocalImage_MissingDockerfile(t *testing.T) {
opts := makeTestOpts(t)
opts.gitClone = func(ctx context.Context, opts *LocalBuildOptions, runtime, dest string) error {
// Empty dir, no Dockerfile.
return os.MkdirAll(dest, 0o755)
}
_, err := ensureLocalImageWithOpts(context.Background(), "hermes", opts)
if err == nil {
t.Fatalf("expected error, got nil")
}
if !strings.Contains(err.Error(), "no Dockerfile") {
t.Errorf("error = %v, want one mentioning missing Dockerfile", err)
}
}
// TestEnsureLocalImage_ConcurrentSameRuntime — two goroutines hitting
// the same runtime serialize via the per-runtime lock; the build runs
// once.
func TestEnsureLocalImage_ConcurrentSameRuntime(t *testing.T) {
opts := makeTestOpts(t)
var (
buildCount int
buildMu sync.Mutex
)
opts.dockerHasTag = func(ctx context.Context, tag string) (bool, error) {
// First call: cache miss. Second call (after first build): hit.
buildMu.Lock()
defer buildMu.Unlock()
return buildCount > 0, nil
}
opts.dockerBuild = func(ctx context.Context, opts *LocalBuildOptions, contextDir, tag string) error {
buildMu.Lock()
buildCount++
buildMu.Unlock()
return nil
}
const N = 5
var wg sync.WaitGroup
wg.Add(N)
for i := 0; i < N; i++ {
go func() {
defer wg.Done()
_, _ = ensureLocalImageWithOpts(context.Background(), "langgraph", opts)
}()
}
wg.Wait()
if buildCount != 1 {
t.Errorf("buildCount = %d, want 1 (lock should serialize concurrent calls)", buildCount)
}
}
// TestMaskTokenInURL — Gitea PATs in URLs must NEVER appear in logs.
func TestMaskTokenInURL(t *testing.T) {
cases := []struct {
in string
want string
}{
{"https://oauth2:secret123@git.example.com/foo/bar", "https://***@git.example.com/foo/bar"},
{"https://user:tok@host/path", "https://***@host/path"},
{"https://no-userinfo.example.com/path", "https://no-userinfo.example.com/path"},
{"not a url", "not a url"},
{"", ""},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
got := maskTokenInURL(tc.in)
if got != tc.want {
t.Errorf("maskTokenInURL(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestMaskTokenInString — defence against git/docker echoing the token
// into stderr on failure.
func TestMaskTokenInString(t *testing.T) {
got := maskTokenInString("error: clone https://oauth2:abc123@git.test/foo: failed", "abc123")
if strings.Contains(got, "abc123") {
t.Errorf("masked string %q still contains the token", got)
}
if !strings.Contains(got, "***") {
t.Errorf("masked string %q should have *** in place of token", got)
}
// No-op when token is empty.
if got := maskTokenInString("hello world", ""); got != "hello world" {
t.Errorf("empty token must not modify string, got %q", got)
}
}
// TestGiteaBranchAPIURL — the URL composer must produce the canonical
// /api/v1/repos/<org>/<repo>/branches/<branch> shape.
func TestGiteaBranchAPIURL(t *testing.T) {
cases := []struct {
prefix, runtime, branch, want string
}{
{
"https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-",
"claude-code",
"main",
"https://git.moleculesai.app/api/v1/repos/molecule-ai/molecule-ai-workspace-template-claude-code/branches/main",
},
{
"http://localhost:3000/myorg/template-",
"foo",
"main",
"http://localhost:3000/api/v1/repos/myorg/template-foo/branches/main",
},
}
for _, tc := range cases {
t.Run(tc.runtime, func(t *testing.T) {
got, err := giteaBranchAPIURL(tc.prefix, tc.runtime, tc.branch)
if err != nil {
t.Fatalf("err = %v", err)
}
if got != tc.want {
t.Errorf("got %q, want %q", got, tc.want)
}
})
}
}
// TestGiteaBranchAPIURL_RejectsMalformed — malformed prefixes (no org
// path) produce an error rather than a malformed API call.
func TestGiteaBranchAPIURL_RejectsMalformed(t *testing.T) {
for _, bad := range []string{
"https://example.com/", // no path component
"://broken",
} {
t.Run(bad, func(t *testing.T) {
if _, err := giteaBranchAPIURL(bad, "claude-code", "main"); err == nil {
t.Errorf("expected error for malformed prefix %q", bad)
}
})
}
}
// TestParseGiteaBranchHeadSha — pin the parser against representative
// Gitea responses so a future Gitea API rev that adds fields doesn't
// silently break detection.
func TestParseGiteaBranchHeadSha(t *testing.T) {
good := []byte(`{"name":"main","commit":{"id":"abc123def456","message":"hi"}}`)
got, err := parseGiteaBranchHeadSha(good)
if err != nil {
t.Fatalf("err = %v", err)
}
if got != "abc123def456" {
t.Errorf("got %q, want abc123def456", got)
}
for _, bad := range [][]byte{
[]byte(`{}`),
[]byte(`{"name":"main","commit":{}}`),
[]byte(`{"commit":{"id":"`), // truncated
[]byte(`<html>404</html>`),
} {
if _, err := parseGiteaBranchHeadSha(bad); err == nil {
t.Errorf("expected error for malformed body %q", string(bad))
}
}
}
// TestLocalImageTag_ShortSha — caller-supplied SHA gets truncated to
// 12 chars in the tag so `docker images` output stays readable.
func TestLocalImageTag_ShortSha(t *testing.T) {
got := LocalImageTag("claude-code", "abcdef0123456789abcdef0123456789abcdef01")
want := "molecule-local/workspace-template-claude-code:abcdef012345"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
// TestLocalImageLatestTag — the floating alias used as the human-readable
// :latest entry.
func TestLocalImageLatestTag(t *testing.T) {
got := LocalImageLatestTag("hermes")
want := "molecule-local/workspace-template-hermes:latest"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
// TestRemoteHeadShaProd_IncludesAuthHeader — when a token is configured,
// the API request must carry the `Authorization: token <pat>` header.
func TestRemoteHeadShaProd_IncludesAuthHeader(t *testing.T) {
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"commit":{"id":"deadbeef0000aaaa1111bbbb2222cccc33334444"}}`))
}))
defer srv.Close()
opts := makeTestOpts(t)
opts.RepoPrefix = srv.URL + "/myorg/template-"
opts.HTTPClient = srv.Client()
opts.Token = "secret-pat-do-not-log"
if _, err := remoteHeadShaProd(context.Background(), opts, "claude-code"); err != nil {
t.Fatalf("err = %v", err)
}
if got != "token secret-pat-do-not-log" {
t.Errorf("Authorization header = %q, want %q", got, "token secret-pat-do-not-log")
}
}
// TestCacheKey_Stable — the helper must be deterministic and incorporate
// each input.
func TestCacheKey_Stable(t *testing.T) {
a := CacheKey("claude-code", "abc", "https://git/")
b := CacheKey("claude-code", "abc", "https://git/")
if a != b {
t.Errorf("CacheKey is non-deterministic: %q vs %q", a, b)
}
if a == CacheKey("claude-code", "def", "https://git/") {
t.Errorf("CacheKey ignores sha")
}
if a == CacheKey("hermes", "abc", "https://git/") {
t.Errorf("CacheKey ignores runtime")
}
}
// TestRedactedRepoURL_NoToken — a repo URL with no embedded credential
// is unmodified.
func TestRedactedRepoURL_NoToken(t *testing.T) {
opts := &LocalBuildOptions{RepoPrefix: "https://git.example.com/org/template-"}
got := redactedRepoURL(opts, "claude-code")
want := "https://git.example.com/org/template-claude-code"
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
// TestRepoURL_AppendsRuntime — the prefix + runtime composer is stable.
func TestRepoURL_AppendsRuntime(t *testing.T) {
opts := &LocalBuildOptions{RepoPrefix: "https://git.example.com/org/template-"}
got := repoURL(opts, "claude-code")
if got != "https://git.example.com/org/template-claude-code" {
t.Errorf("got %q", got)
}
}
// TestNewDefaultLocalBuildOptions_RespectsEnvOverrides — the env var
// overrides documented in the runbook actually take effect.
func TestNewDefaultLocalBuildOptions_RespectsEnvOverrides(t *testing.T) {
t.Setenv("MOLECULE_LOCAL_BUILD_CACHE", "/var/tmp/molecule-test")
t.Setenv("MOLECULE_LOCAL_TEMPLATE_REPO_PREFIX", "https://my.fork/org/tpl-")
t.Setenv("MOLECULE_GITEA_TOKEN", "tok-from-env")
opts := newDefaultLocalBuildOptions()
if opts.CacheDir != "/var/tmp/molecule-test" {
t.Errorf("CacheDir = %q", opts.CacheDir)
}
if opts.RepoPrefix != "https://my.fork/org/tpl-" {
t.Errorf("RepoPrefix = %q", opts.RepoPrefix)
}
if opts.Token != "tok-from-env" {
t.Errorf("Token = %q", opts.Token)
}
if opts.Platform != "linux/amd64" {
t.Errorf("Platform = %q, want linux/amd64", opts.Platform)
}
}
// TestNewDefaultLocalBuildOptions_DefaultCacheDir — XDG-compliant
// fallback when nothing is overridden.
func TestNewDefaultLocalBuildOptions_DefaultCacheDir(t *testing.T) {
t.Setenv("MOLECULE_LOCAL_BUILD_CACHE", "")
t.Setenv("XDG_CACHE_HOME", "")
t.Setenv("MOLECULE_LOCAL_TEMPLATE_REPO_PREFIX", "")
opts := newDefaultLocalBuildOptions()
if !strings.Contains(opts.CacheDir, ".cache") && !strings.Contains(opts.CacheDir, "molecule") {
t.Errorf("CacheDir = %q, want one under .cache/molecule", opts.CacheDir)
}
if opts.RepoPrefix != gitTemplateRepoPrefix {
t.Errorf("RepoPrefix = %q, want default %q", opts.RepoPrefix, gitTemplateRepoPrefix)
}
}
// TestEnsureLocalImage_ShortSha — a remote that returns a too-short
// sha is rejected (defence against a misbehaving Gitea proxy).
func TestEnsureLocalImage_ShortSha(t *testing.T) {
opts := makeTestOpts(t)
opts.remoteHeadSha = func(ctx context.Context, opts *LocalBuildOptions, runtime string) (string, error) {
return "abc", nil
}
_, err := ensureLocalImageWithOpts(context.Background(), "claude-code", opts)
if err == nil {
t.Fatalf("expected error for short sha")
}
if !strings.Contains(err.Error(), "short sha") {
t.Errorf("error = %v, want short-sha message", err)
}
}
// TestEnsureLocalImage_StaleCacheDirCleaned — a partial clone left over
// from a previous failed run must not poison the next attempt.
func TestEnsureLocalImage_StaleCacheDirCleaned(t *testing.T) {
opts := makeTestOpts(t)
// Pre-create a stale dir at the cache target (with a partial Dockerfile).
staleDir := filepath.Join(opts.CacheDir, "claude-code", "abcdef012345")
if err := os.MkdirAll(staleDir, 0o755); err != nil {
t.Fatalf("setup: %v", err)
}
if err := os.WriteFile(filepath.Join(staleDir, "stale-marker"), []byte("delete me"), 0o644); err != nil {
t.Fatalf("setup: %v", err)
}
if _, err := ensureLocalImageWithOpts(context.Background(), "claude-code", opts); err != nil {
t.Fatalf("err = %v", err)
}
if _, err := os.Stat(filepath.Join(staleDir, "stale-marker")); !os.IsNotExist(err) {
t.Errorf("stale-marker should have been wiped before re-clone (err=%v)", err)
}
// Dockerfile from the new clone should be present.
if _, err := os.Stat(filepath.Join(staleDir, "Dockerfile")); err != nil {
t.Errorf("expected Dockerfile from re-clone, got err=%v", err)
}
}
// TestEnsureLocalImage_ContextCancelled — context cancellation
// propagates to the network/clone seams (best-effort: the test asserts
// that no work happens after Done()).
func TestEnsureLocalImage_ContextCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
opts := makeTestOpts(t)
opts.remoteHeadSha = func(ctx context.Context, opts *LocalBuildOptions, runtime string) (string, error) {
if err := ctx.Err(); err != nil {
return "", err
}
return "deadbeef00000000aaaa1111bbbb2222cccc33334444", nil
}
_, err := ensureLocalImageWithOpts(ctx, "claude-code", opts)
if err == nil {
t.Fatalf("expected error from cancelled context")
}
}
// TestEnsureLocalImage_RetagAfterCacheHit — a cache-hit must refresh
// the floating :latest alias so admins inspecting `docker images` see
// the current SHA.
func TestEnsureLocalImage_RetagAfterCacheHit(t *testing.T) {
opts := makeTestOpts(t)
var src, dst string
opts.dockerHasTag = func(ctx context.Context, tag string) (bool, error) { return true, nil }
opts.dockerTag = func(ctx context.Context, s, d string) error {
src, dst = s, d
return nil
}
tag, err := ensureLocalImageWithOpts(context.Background(), "claude-code", opts)
if err != nil {
t.Fatalf("err = %v", err)
}
if src != tag {
t.Errorf("retag src = %q, want %q", src, tag)
}
wantDst := "molecule-local/workspace-template-claude-code:latest"
if dst != wantDst {
t.Errorf("retag dst = %q, want %q", dst, wantDst)
}
}
// TestRemoteHeadShaProd_BodyOverflow — defence against a malicious or
// misbehaving Gitea returning a multi-MB body.
func TestRemoteHeadShaProd_BodyOverflow(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
// Stream a 100MB body. The reader should cap at 64KB and yield
// a parse error rather than OOM.
_, _ = w.Write([]byte(`{"commit":{"id":"`))
_, _ = w.Write([]byte(strings.Repeat("a", 64<<10))) // 64KB of 'a'
// Connection drops here; we don't write the closing quote.
}))
defer srv.Close()
opts := makeTestOpts(t)
opts.RepoPrefix = srv.URL + "/myorg/template-"
opts.HTTPClient = srv.Client()
_, err := remoteHeadShaProd(context.Background(), opts, "claude-code")
if err == nil {
t.Fatalf("expected error from over-long sha (no closing quote within cap)")
}
}
// TestProvisionerStartUsesLocalBuild_LocalMode — pin the provisioner→
// local-build wiring at the integration boundary. We don't want a future
// refactor to silently bypass EnsureLocalImage when registry is unset.
//
// This test inspects the mode-decision logic without standing up Docker.
func TestProvisionerStartUsesLocalBuild_LocalMode(t *testing.T) {
t.Setenv("MOLECULE_IMAGE_REGISTRY", "")
src := Resolve()
if src.Mode != RegistryModeLocal {
t.Fatalf("Resolve in unset env = %q, want local", src.Mode)
}
// The provisioner Start() branches on this same Resolve() call before
// reaching ContainerCreate. Pinning the boolean here means a refactor
// that flips the sense (e.g. `if src.Mode == RegistryModeSaaS`) is
// caught by this test.
}
// 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.
func TestEnsureLocalImageHook_DefaultIsRealFunction(t *testing.T) {
// Sanity: hook is set to a non-nil function. We can't compare
// function pointers directly with == in Go (compiler error), so
// we exercise it instead — but we don't want to actually clone
// from the network in the unit test, so use an unknown runtime
// and assert the known-error path runs.
_, err := ensureLocalImageHook(context.Background(), "this-runtime-cannot-exist-194")
if err == nil {
t.Fatalf("expected error from EnsureLocalImage on unknown runtime")
}
if !strings.Contains(err.Error(), "unknown runtime") {
t.Errorf("hook = unexpected function (got error %q, want one mentioning unknown runtime)", err.Error())
}
}
// TestProvisionerStartUsesLocalBuild_SaaSMode — and the symmetric guard:
// in SaaS-mode, no local-build path runs.
func TestProvisionerStartUsesLocalBuild_SaaSMode(t *testing.T) {
t.Setenv("MOLECULE_IMAGE_REGISTRY", "registry.example.com/molecule-ai")
src := Resolve()
if src.Mode != RegistryModeSaaS {
t.Fatalf("Resolve with registry set = %q, want saas", src.Mode)
}
if src.Prefix != "registry.example.com/molecule-ai" {
t.Fatalf("Prefix = %q", src.Prefix)
}
}
// silence unused warning if we ever drop fmt usage
var _ = fmt.Sprintf