molecule-core/workspace-server/internal/provisioner/localbuild.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

546 lines
21 KiB
Go

package provisioner
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
// Local-build mode: clone the workspace-template-<runtime> repo from Gitea
// and `docker build` it on the host so OSS contributors can run molecule-core
// end-to-end without authenticating to (or being able to reach) GHCR/ECR.
//
// The flow:
//
// 1. ensureLocalImage(runtime) is called by the provisioner before
// ContainerCreate, but only when Resolve().Mode == RegistryModeLocal.
// 2. We compute a cache key from the Gitea repo's HEAD sha (one HTTP
// call to https://git.moleculesai.app/api/v1/repos/.../branches/main).
// 3. If `molecule-local/workspace-template-<runtime>:<sha12>` already
// exists in the local Docker image store, we return immediately.
// 4. Otherwise: shallow git-clone the repo into the cache dir, then
// `docker buildx build --platform=linux/amd64 -t <tag>` on it. We
// also tag `:latest` so `docker images` shows a friendly entry.
//
// Why amd64 emulation: the provisioner's defaultImagePlatform() forces
// linux/amd64 on Apple Silicon for parity with the (amd64-only) prod
// images. Building native arm64 in local-mode would diverge — see the
// design rationale in Issue #63 and the saved memory
// `feedback_local_must_mimic_production`.
//
// Auth: clone is anonymous (templates are public). If MOLECULE_GITEA_TOKEN
// is set, we use it via the URL's userinfo — the token is masked in
// every log line by maskTokenInURL().
//
// Failure mode: fail-closed. If Gitea is unreachable we surface a clear
// error message including the repo URL; we NEVER fall back to GHCR/ECR
// silently (would be a confusing bug for an OSS contributor who
// happens to have stale ECR creds in their docker config).
// gitTemplateRepoPrefix is the prefix all workspace-template repos live
// under on Gitea. Hardcoded so an attacker who controlled cfg.Runtime
// (defence-in-depth — today the field is platform-validated upstream)
// can only ever reach a repo under molecule-ai/.
//
// Operators who want to point local-build at a fork can override the
// full prefix via MOLECULE_LOCAL_TEMPLATE_REPO_PREFIX (e.g.
// `https://git.example.com/myorg/molecule-ai-workspace-template-`).
// Default-off; opt-in only.
const gitTemplateRepoPrefix = "https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-"
// localBuildLockMap serializes concurrent ensureLocalImage calls per
// runtime so two workspace creates that hit the cold path together don't
// race on `docker build` (Docker's daemon would serialize anyway, but
// the duplicate clone + log spam are confusing). Lock granularity is
// per-runtime, so different runtimes still build in parallel.
var (
localBuildLockMap = make(map[string]*sync.Mutex)
localBuildLockMapMu sync.Mutex
)
func runtimeBuildLock(runtime string) *sync.Mutex {
localBuildLockMapMu.Lock()
defer localBuildLockMapMu.Unlock()
if m, ok := localBuildLockMap[runtime]; ok {
return m
}
m := &sync.Mutex{}
localBuildLockMap[runtime] = m
return m
}
// LocalBuildOptions controls the local-build path. Exposed so tests can
// inject fakes without standing up a real git+docker chain. Production
// uses zero-value defaults via newDefaultLocalBuildOptions().
type LocalBuildOptions struct {
// CacheDir is the host filesystem location where cloned template
// repos are kept between builds. Empty = use $XDG_CACHE_HOME or
// $HOME/.cache. Override via env var MOLECULE_LOCAL_BUILD_CACHE.
CacheDir string
// RepoPrefix is the URL prefix all template repos hang off. Empty
// = use gitTemplateRepoPrefix. Override via env var
// MOLECULE_LOCAL_TEMPLATE_REPO_PREFIX.
RepoPrefix string
// Token, if non-empty, is sent via URL userinfo to Gitea. Default
// empty (templates are public). Override via env var
// MOLECULE_GITEA_TOKEN.
Token string
// Platform is the buildx --platform value. Empty = host default;
// today we always pass linux/amd64 because the provisioner only
// runs amd64 images. Exposed so tests can override.
Platform string
// HTTPClient is used for the Gitea-API HEAD-sha lookup. Empty =
// http.DefaultClient with a 30s timeout.
HTTPClient *http.Client
// 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
}
func newDefaultLocalBuildOptions() *LocalBuildOptions {
o := &LocalBuildOptions{
CacheDir: os.Getenv("MOLECULE_LOCAL_BUILD_CACHE"),
RepoPrefix: os.Getenv("MOLECULE_LOCAL_TEMPLATE_REPO_PREFIX"),
Token: os.Getenv("MOLECULE_GITEA_TOKEN"),
Platform: "linux/amd64",
}
if o.CacheDir == "" {
if xdg := os.Getenv("XDG_CACHE_HOME"); xdg != "" {
o.CacheDir = filepath.Join(xdg, "molecule", "workspace-template-build")
} else if home, err := os.UserHomeDir(); err == nil {
o.CacheDir = filepath.Join(home, ".cache", "molecule", "workspace-template-build")
} else {
// Last-resort fallback: /tmp. Loses the cache between reboots
// but at least lets the path produce builds.
o.CacheDir = filepath.Join(os.TempDir(), "molecule", "workspace-template-build")
}
}
if o.RepoPrefix == "" {
o.RepoPrefix = gitTemplateRepoPrefix
}
o.HTTPClient = &http.Client{Timeout: 30 * time.Second}
return o
}
// LocalImageTag formats the SHA-pinned tag for a runtime. Exported for
// tests + the provisioner's image-resolution branch.
func LocalImageTag(runtime, sha string) string {
short := sha
if len(short) > 12 {
short = short[:12]
}
return fmt.Sprintf("%s/workspace-template-%s:%s", localImagePrefix, runtime, short)
}
// LocalImageLatestTag returns the floating `:latest` form. Used as a
// human-readable alias and as the value RuntimeImage() returns in
// local-mode.
func LocalImageLatestTag(runtime string) string {
return fmt.Sprintf("%s/workspace-template-%s:latest", localImagePrefix, runtime)
}
// EnsureLocalImage is the entry point the provisioner calls before
// ContainerCreate when Resolve().Mode == RegistryModeLocal. Returns the
// image tag (SHA-pinned form) the caller should hand to Docker, or an
// error if the build/clone fails.
//
// Concurrency: per-runtime lock; parallel calls for the same runtime
// share the build, parallel calls for different runtimes proceed.
//
// Idempotent: a cached SHA-pinned tag short-circuits without network
// or docker calls. The Gitea HEAD lookup is the only network call on
// the cache-hit path.
func EnsureLocalImage(ctx context.Context, runtime string) (string, error) {
return ensureLocalImageWithOpts(ctx, runtime, newDefaultLocalBuildOptions())
}
// ensureLocalImageHook is the seam Start() calls into. Production code
// uses EnsureLocalImage; tests substitute a fake to exercise the
// provisioner-Start integration without standing up a real
// git+docker chain. Single-process scoped — never reassigned in
// production code.
var ensureLocalImageHook = EnsureLocalImage
func ensureLocalImageWithOpts(ctx context.Context, runtime string, opts *LocalBuildOptions) (string, error) {
if !IsKnownRuntime(runtime) {
return "", fmt.Errorf("local-build: refusing to build unknown runtime %q (must be one of %v)", runtime, knownRuntimes)
}
lock := runtimeBuildLock(runtime)
lock.Lock()
defer lock.Unlock()
// 1. HEAD lookup → cache key.
headFn := opts.remoteHeadSha
if headFn == nil {
headFn = remoteHeadShaProd
}
sha, err := headFn(ctx, opts, runtime)
if err != nil {
// Fail-closed: do not fall back to GHCR/ECR. The whole point of
// local-build mode is that GHCR is unreachable.
return "", fmt.Errorf("local-build: cannot determine HEAD sha for runtime %q at %s: %w", runtime, repoURL(opts, runtime), err)
}
if len(sha) < 12 {
return "", fmt.Errorf("local-build: Gitea returned a short sha %q for runtime %q (expected ≥12 chars)", sha, runtime)
}
tag := LocalImageTag(runtime, sha)
latest := LocalImageLatestTag(runtime)
// 2. Cache hit?
hasFn := opts.dockerHasTag
if hasFn == nil {
hasFn = dockerHasTagProd
}
exists, hasErr := hasFn(ctx, tag)
if hasErr != nil {
log.Printf("local-build: image inspect for %s failed (%v); will rebuild", tag, hasErr)
}
if exists {
log.Printf("local-build: cache hit for %s (sha=%s) — skipping clone+build", tag, sha[:12])
// Refresh the floating :latest alias so admins inspecting `docker
// images` see the current sha. Best-effort.
tagFn := opts.dockerTag
if tagFn == nil {
tagFn = dockerTagProd
}
if tErr := tagFn(ctx, tag, latest); tErr != nil {
log.Printf("local-build: best-effort retag of %s → %s failed: %v", tag, latest, tErr)
}
return tag, nil
}
// 3. Cold path — clone + build.
dest := filepath.Join(opts.CacheDir, runtime, sha[:12])
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return "", fmt.Errorf("local-build: prepare cache dir %q: %w", filepath.Dir(dest), err)
}
// Idempotent: if the dest exists from a previous failed run, wipe and
// re-clone so we don't build a partial tree.
if _, statErr := os.Stat(dest); statErr == nil {
if rmErr := os.RemoveAll(dest); rmErr != nil {
return "", fmt.Errorf("local-build: clean stale cache dir %q: %w", dest, rmErr)
}
}
cloneFn := opts.gitClone
if cloneFn == nil {
cloneFn = gitCloneProd
}
log.Printf("local-build: cloning %s → %s (sha=%s)", redactedRepoURL(opts, runtime), dest, sha[:12])
cloneStart := time.Now()
if err := cloneFn(ctx, opts, runtime, dest); err != nil {
// Best-effort cleanup so a half-cloned tree doesn't poison future runs.
_ = os.RemoveAll(dest)
return "", fmt.Errorf("local-build: clone %s: %w", redactedRepoURL(opts, runtime), err)
}
log.Printf("local-build: clone complete in %s", time.Since(cloneStart).Round(time.Millisecond))
// 4. Sanity-check the cloned tree contains a Dockerfile at the root.
dockerfile := filepath.Join(dest, "Dockerfile")
info, statErr := os.Stat(dockerfile)
if statErr != nil || info.IsDir() {
_ = os.RemoveAll(dest)
return "", fmt.Errorf("local-build: cloned tree at %s has no Dockerfile (template repo malformed)", dest)
}
// 5. Build.
buildFn := opts.dockerBuild
if buildFn == nil {
buildFn = dockerBuildProd
}
log.Printf("local-build: docker build start for %s (platform=%s, context=%s)", tag, opts.Platform, dest)
buildStart := time.Now()
if err := buildFn(ctx, opts, dest, tag); err != nil {
return "", fmt.Errorf("local-build: docker build %s: %w", tag, err)
}
log.Printf("local-build: docker build done for %s in %s", tag, time.Since(buildStart).Round(time.Second))
// Tag :latest as a friendly alias.
tagFn := opts.dockerTag
if tagFn == nil {
tagFn = dockerTagProd
}
if err := tagFn(ctx, tag, latest); err != nil {
log.Printf("local-build: best-effort retag of %s → %s failed: %v", tag, latest, err)
}
return tag, nil
}
// repoURL composes the full Gitea repo URL for the given runtime. The
// prefix is hardcoded by default; operators can override via env so a
// fork can point local-build at their own Gitea instance.
func repoURL(opts *LocalBuildOptions, runtime string) string {
return opts.RepoPrefix + runtime
}
// redactedRepoURL returns the same value with any embedded token replaced
// by "***". Use this for log lines.
func redactedRepoURL(opts *LocalBuildOptions, runtime string) string {
return maskTokenInURL(repoURL(opts, runtime))
}
// maskTokenInURL replaces userinfo (username:password@) in a URL with
// `***@` so log lines never echo a Gitea PAT. Returns the input as-is
// on parse failures (defence: never silently corrupt the visible URL).
//
// Implementation note: net/url's URL.User stringifier percent-encodes
// the username, so `u.User = url.User("***"); u.String()` would yield
// `https://%2A%2A%2A@host/...` — unhelpful for humans grepping logs.
// We drop the userinfo via URL.User=nil, get the canonical scheme-and-
// rest, and re-insert the literal `***@` between the scheme separator
// and the host.
func maskTokenInURL(s string) string {
u, err := url.Parse(s)
if err != nil || u.User == nil {
return s
}
u.User = nil
out := u.String()
prefix := u.Scheme + "://"
if !strings.HasPrefix(out, prefix) {
return s
}
return prefix + "***@" + out[len(prefix):]
}
// remoteHeadShaProd looks up the HEAD commit sha of branch `main` for
// the workspace-template-<runtime> repo on Gitea. We use the Gitea API
// (a single HTTPS call) rather than `git ls-remote` so we don't need a
// git binary just for the HEAD lookup — we still need git for the
// clone, but the cache-hit path stays git-free.
func remoteHeadShaProd(ctx context.Context, opts *LocalBuildOptions, runtime string) (string, error) {
// Convert a `git.example.com/org/prefix-` URL into the API form
// `git.example.com/api/v1/repos/org/prefix-<runtime>/branches/main`.
// Works for both git.moleculesai.app (default) and any forks that
// share the Gitea API shape.
apiURL, err := giteaBranchAPIURL(opts.RepoPrefix, runtime, "main")
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return "", err
}
if opts.Token != "" {
// Gitea accepts "token <PAT>" in the Authorization header for
// API calls. Userinfo is also accepted but only matters for
// the HTTPS clone, not the JSON API.
req.Header.Set("Authorization", "token "+opts.Token)
}
cli := opts.HTTPClient
if cli == nil {
cli = &http.Client{Timeout: 30 * time.Second}
}
resp, err := cli.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNotFound {
return "", fmt.Errorf("repo not found at %s — runtime %q may not be mirrored to Gitea (only claude-code/hermes/langgraph/autogen today)", apiURL, runtime)
}
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
return "", fmt.Errorf("auth failure (%d) at %s — verify MOLECULE_GITEA_TOKEN if private repo", resp.StatusCode, apiURL)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("HEAD lookup at %s returned %d", apiURL, resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
if err != nil {
return "", fmt.Errorf("read HEAD response body: %w", err)
}
// Tiny ad-hoc parser: we want commit.id, no need to drag in encoding/json
// — actually simpler to use json. Switch to it.
return parseGiteaBranchHeadSha(body)
}
// giteaBranchAPIURL maps a repo-prefix URL like
// `https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-template-`
// + runtime "claude-code" + branch "main"
// to the API URL
// `https://git.moleculesai.app/api/v1/repos/molecule-ai/molecule-ai-workspace-template-claude-code/branches/main`.
func giteaBranchAPIURL(repoPrefix, runtime, branch string) (string, error) {
u, err := url.Parse(repoPrefix + runtime)
if err != nil {
return "", fmt.Errorf("parse repo URL %q: %w", repoPrefix+runtime, err)
}
parts := strings.TrimPrefix(u.Path, "/")
parts = strings.TrimSuffix(parts, "/")
if parts == "" {
return "", fmt.Errorf("repo URL %q has empty path", repoPrefix+runtime)
}
// Expect `<org>/<repo>` (single slash) — the prefix already includes
// org+partial-repo; runtime appends the rest.
if !strings.Contains(parts, "/") {
return "", fmt.Errorf("repo URL %q missing org/repo path", repoPrefix+runtime)
}
apiURL := url.URL{
Scheme: u.Scheme,
Host: u.Host,
Path: "/api/v1/repos/" + parts + "/branches/" + branch,
}
return apiURL.String(), nil
}
// parseGiteaBranchHeadSha extracts commit.id from the Gitea
// /branches/<name> response. We use a permissive substring scan so a
// missing-key in the JSON gives a clear error rather than the
// json.Decoder's somewhat opaque "missing field" message.
func parseGiteaBranchHeadSha(body []byte) (string, error) {
// Look for `"id":"<40-hex>"` inside the commit object.
idx := strings.Index(string(body), `"id":"`)
if idx < 0 {
return "", errors.New("Gitea branch response missing commit.id field")
}
rest := string(body[idx+len(`"id":"`):])
end := strings.IndexByte(rest, '"')
if end < 0 {
return "", errors.New("Gitea branch response has malformed commit.id (no closing quote)")
}
sha := rest[:end]
if len(sha) < 7 {
return "", fmt.Errorf("Gitea returned suspiciously short sha %q", sha)
}
return sha, nil
}
// gitCloneProd shallow-clones the runtime's template repo into dest.
//
// We invoke `git` rather than implementing the protocol ourselves —
// every host that runs the workspace-server already needs git available
// (it's a hard dep of go-mod for vendored repos) and the OSS contributor
// onboarding doc lists it as a prerequisite.
func gitCloneProd(ctx context.Context, opts *LocalBuildOptions, runtime, dest string) error {
cloneURL := repoURL(opts, runtime)
if opts.Token != "" {
// HTTPS clone with userinfo: https://oauth2:<token>@host/...
u, err := url.Parse(cloneURL)
if err == nil {
u.User = url.UserPassword("oauth2", opts.Token)
cloneURL = u.String()
}
// On parse failure we silently fall through to the public URL —
// better to attempt the anonymous clone than to refuse outright.
}
cmd := exec.CommandContext(ctx, "git", "clone", "--depth=1", "--branch=main", "--single-branch", cloneURL, dest)
// Drop git's askpass prompts so we fail-fast on auth errors instead
// of hanging waiting for an interactive password.
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=/bin/echo")
out, err := cmd.CombinedOutput()
if err != nil {
// Mask the token in any error string git emits via stderr — git
// occasionally echoes the URL verbatim on failure.
errMsg := maskTokenInString(string(out), opts.Token)
return fmt.Errorf("%w: %s", err, strings.TrimSpace(errMsg))
}
return nil
}
// maskTokenInString replaces literal occurrences of the token with `***`.
// Defence against git binary or docker echoing the URL into stderr.
func maskTokenInString(s, token string) string {
if token == "" {
return s
}
return strings.ReplaceAll(s, token, "***")
}
// dockerBuildProd invokes the docker CLI to build the workspace-template
// image. We shell out rather than use the Docker SDK's ImageBuild — the
// SDK requires hand-tarballing the build context, which adds a
// non-trivial code path with its own bug surface. The docker CLI is
// already a hard dep of the workspace-server (the provisioner needs the
// daemon), so requiring the CLI binary on PATH adds nothing.
//
// Uses the legacy `docker build` (not `docker buildx build`) because
// buildx isn't always installed by default on Linux distros and the
// legacy builder produces an image the local Docker daemon picks up
// automatically. We pass --platform=linux/amd64 directly; with Docker
// 20.10+ this works without buildx because the legacy builder
// auto-promotes to BuildKit when available, falling back to v1
// otherwise (still produces an amd64 image via QEMU).
func dockerBuildProd(ctx context.Context, opts *LocalBuildOptions, contextDir, tag string) error {
args := []string{"build"}
if opts.Platform != "" {
args = append(args, "--platform="+opts.Platform)
}
args = append(args,
"-t", tag,
"-f", filepath.Join(contextDir, "Dockerfile"),
contextDir,
)
cmd := exec.CommandContext(ctx, "docker", args...)
cmd.Env = append(os.Environ(), "DOCKER_BUILDKIT=1")
out, err := cmd.CombinedOutput()
if err != nil {
// Sanitize defensive — docker build output shouldn't contain a
// token, but maskTokenInString is a no-op when token is empty.
return fmt.Errorf("%w: %s", err, strings.TrimSpace(maskTokenInString(string(out), opts.Token)))
}
return nil
}
// dockerHasTagProd returns true iff the given tag exists in the local
// image store. Used as the fast cache-hit check.
func dockerHasTagProd(ctx context.Context, tag string) (bool, error) {
cmd := exec.CommandContext(ctx, "docker", "image", "inspect", "--format={{.Id}}", tag)
out, err := cmd.CombinedOutput()
if err == nil {
return strings.TrimSpace(string(out)) != "", nil
}
// `docker image inspect` exits 1 with "Error: No such image" when
// missing — that's a definitive false, not an error condition.
low := strings.ToLower(string(out))
if strings.Contains(low, "no such image") || strings.Contains(low, "not found") {
return false, nil
}
return false, fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
// dockerTagProd creates an alias from src → dst. Used to refresh the
// floating `:latest` after a build or cache hit.
func dockerTagProd(ctx context.Context, src, dst string) error {
cmd := exec.CommandContext(ctx, "docker", "tag", src, dst)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
// CacheKey is exposed for diagnostic logs / tests so the cache-key shape
// is documented in code rather than only as a string format.
//
// cache_key = sha256(runtime || head_sha || repoPrefix)[:16]
//
// Today only the SHA is consumed, but the helper is kept for future
// extensions (e.g. include Dockerfile-content-hash to invalidate when
// only the Dockerfile changes between two runs targeting the same SHA).
func CacheKey(runtime, sha, repoPrefix string) string {
h := sha256.Sum256([]byte(runtime + "|" + sha + "|" + repoPrefix))
return hex.EncodeToString(h[:8])
}