diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index 32fda534..c0f5a260 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -665,6 +665,7 @@ lanes: - tzdata goOsPackages: - golang-go + goBaseImage: 127.0.0.1:5000/hwlab/hwlab-go-env-base:go1-bookworm-slim bunVersion: 1.3.13 downloadStack: httpProxy: http://127.0.0.1:10808 diff --git a/scripts/artifact-publish.mjs b/scripts/artifact-publish.mjs index f84e5361..883bcd36 100644 --- a/scripts/artifact-publish.mjs +++ b/scripts/artifact-publish.mjs @@ -495,6 +495,21 @@ function buildkitCacheEvidence(args, cacheRef) { : { buildkitCacheMode: "registry", buildkitCacheRef: cacheRef }; } +function goBaseCacheRef(registryPrefix) { + return `${registryPrefix}/cache/hwlab-go-env-base`; +} + +function goBaseEvidenceFields(evidence) { + if (!evidence) return {}; + return { + goBaseImage: evidence.image, + goBaseImageStatus: evidence.status, + goBaseDigest: evidence.digest ?? null, + goBaseBuildDurationMs: evidence.durationMs ?? null, + goBaseBuildkitCacheRef: evidence.cacheRef ?? null + }; +} + function buildkitDigestFromMetadata(value) { const keys = [ "containerimage.digest", @@ -550,6 +565,53 @@ async function run(command, args, options = {}) { }; } +async function prepareBuildkitEnv(args) { + const buildEnv = { ...process.env }; + if (!args.buildkitAddr) { + buildEnv.BUILDKITD_FLAGS = process.env.BUILDKITD_FLAGS || "--oci-worker-no-process-sandbox"; + buildEnv.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR || path.join(os.tmpdir(), "hwlab-buildkit-runtime"); + await mkdir(buildEnv.XDG_RUNTIME_DIR, { recursive: true }); + } + return buildEnv; +} + +function parseRegistryImageRef(image) { + const value = String(image ?? "").trim(); + const slashIndex = value.indexOf("/"); + assert.ok(slashIndex > 0, `image reference ${value} must include a registry host`); + const registry = value.slice(0, slashIndex); + const remainder = value.slice(slashIndex + 1); + const digestIndex = remainder.indexOf("@"); + if (digestIndex >= 0) { + return { registry, repository: remainder.slice(0, digestIndex), reference: remainder.slice(digestIndex + 1) }; + } + const colonIndex = remainder.lastIndexOf(":"); + assert.ok(colonIndex > 0, `image reference ${value} must include an immutable tag`); + return { registry, repository: remainder.slice(0, colonIndex), reference: remainder.slice(colonIndex + 1) }; +} + +async function registryManifestExists(image) { + const { registry, repository, reference } = parseRegistryImageRef(image); + const protocol = registry.startsWith("127.0.0.1") || registry.startsWith("localhost") ? "http" : "https"; + const url = `${protocol}://${registry}/v2/${repository}/manifests/${reference}`; + try { + const response = await fetch(url, { + method: "GET", + headers: { + Accept: [ + "application/vnd.oci.image.index.v1+json", + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json" + ].join(", ") + } + }); + await response.arrayBuffer().catch(() => null); + return response.status === 200; + } catch { + return false; + } +} + function bunExecutable() { return process.env.HWLAB_BUN_BINARY || process.env.BUN_BINARY || "bun"; } @@ -908,6 +970,8 @@ function envReuseDockerfile(baseImage, labels = [], recipe, service = null) { const npmConfigScript = npmDownloadConfigScript(normalizedRecipe.downloadStack); const goService = service?.runtimeKind === "go-service" || service?.artifactKind === "go-service"; const goCacheEnabled = Boolean(goService && normalizedRecipe.runtimeGoModCachePath && normalizedRecipe.runtimeGoBuildCachePath); + const goBaseImageEnabled = Boolean(goCacheEnabled && normalizedRecipe.goBaseImage); + const effectiveBaseImage = goBaseImageEnabled ? normalizedRecipe.goBaseImage : baseImage; const goLauncherPath = "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs"; const goProxy = normalizedRecipe.downloadStack.goProxy || "https://proxy.golang.org,direct"; const goEnv = goCacheEnabled ? [ @@ -923,8 +987,9 @@ function envReuseDockerfile(baseImage, labels = [], recipe, service = null) { ["GOTOOLCHAIN", normalizedRecipe.goToolchain || "local"] ]) : ""; const goDownloadRetryScript = `retry_go_mod_download() { attempts=6; n=1; while ! go mod download; do if [ "$n" -ge "$attempts" ]; then return 1; fi; echo "go mod download attempt $n/$attempts failed; retrying" >&2; n=$((n + 1)); sleep 5; done; }`; + const stepTimingHelpers = `emit_hwlab_timing() { stage="$1"; status="$2"; duration_ms="$3"; node -e "console.error(JSON.stringify({event:'node-cicd-build-step-timing',stage:process.argv[1],status:process.argv[2],durationMs:Number(process.argv[3]),source:'dockerfile-run'}))" "$stage" "$status" "$duration_ms"; }; timed_stage() { label="$1"; shift; start="$(node -e "console.log(Date.now())")"; status=succeeded; "$@" || status=failed; end="$(node -e "console.log(Date.now())")"; elapsed=$((end - start)); emit_hwlab_timing "$label" "$status" "$elapsed"; if [ "$elapsed" -ge 120000 ]; then echo "{\"event\":\"env-reuse-warning\",\"warning\":\"stage-over-120s\",\"stage\":\"$label\",\"elapsedMs\":$elapsed}" >&2; fi; test "$status" = succeeded; }; timed_skip() { emit_hwlab_timing "$1" skipped 0; }`; const goDownloadScript = goCacheEnabled - ? `RUN ${downloadEnvPrefix}${goBuildEnvPrefix}set -eu; mkdir -p ${shellQuote(normalizedRecipe.runtimeGoModCachePath)} ${shellQuote(normalizedRecipe.runtimeGoBuildCachePath)}; ${goDownloadRetryScript}; retry_go_mod_download` + ? `RUN ${downloadEnvPrefix}${goBuildEnvPrefix}set -eu; ${stepTimingHelpers}; mkdir -p ${shellQuote(normalizedRecipe.runtimeGoModCachePath)} ${shellQuote(normalizedRecipe.runtimeGoBuildCachePath)}; ${goDownloadRetryScript}; timed_stage "go-mod-download" retry_go_mod_download` : null; const goReadiness = goCacheEnabled ? `; mkdir -p ${shellQuote(normalizedRecipe.runtimeGoModCachePath)} ${shellQuote(normalizedRecipe.runtimeGoBuildCachePath)}; command -v go >/dev/null; go version >/tmp/hwlab-env-go-version.txt` @@ -932,26 +997,27 @@ function envReuseDockerfile(baseImage, labels = [], recipe, service = null) { const baseOsPackages = uniquePreserveOrder(normalizedRecipe.osPackages); const goOsPackages = goCacheEnabled ? uniquePreserveOrder(normalizedRecipe.goOsPackages) : []; const downloadDependencyScript = `set -eu; ${npmConfigScript}; if [ -f package-lock.json ]; then npm ci --omit=dev --include=optional --ignore-scripts; else npm install --omit=dev --include=optional --ignore-scripts; fi; if command -v bun >/dev/null 2>&1; then bun_path="$(command -v bun)"; if [ "$bun_path" != "/usr/local/bin/bun" ]; then ln -sf "$bun_path" /usr/local/bin/bun; fi; fi; if [ ! -x /usr/local/bin/bun ]; then bun_pkg=""; case "$(uname -m)" in x86_64|amd64) bun_pkg="${normalizedRecipe.downloadStack.bunPackages.x64}@${normalizedRecipe.bunVersion}" ;; aarch64|arm64) bun_pkg="${normalizedRecipe.downloadStack.bunPackages.arm64}@${normalizedRecipe.bunVersion}" ;; *) echo "unsupported bun architecture: $(uname -m)" >&2; false ;; esac; npm install --no-save --omit=dev --include=optional --ignore-scripts "$bun_pkg"; bun_bin="node_modules/\${bun_pkg%@*}/bin/bun"; test -x "$bun_bin"; ln -sf "${runtimeRoot}/$bun_bin" /usr/local/bin/bun; fi`; - const aptRetryHelpers = `timed_stage() { label="$1"; shift; start="$(date +%s)"; "$@"; status="$?"; end="$(date +%s)"; elapsed=$((end - start)); if [ "$elapsed" -ge 120 ]; then echo "{\"event\":\"env-reuse-warning\",\"warning\":\"stage-over-120s\",\"stage\":\"$label\",\"elapsedSeconds\":$elapsed}" >&2; fi; return "$status"; }; retry_apt_update() { attempts=6; n=1; while ! apt-get update -o Acquire::Retries=3; do if [ "$n" -ge "$attempts" ]; then return 1; fi; echo "apt-get update attempt $n/$attempts failed; retrying" >&2; n=$((n + 1)); rm -rf /var/lib/apt/lists/*; sleep 5; done; }; apt_packages_ready() { for pkg in "$@"; do dpkg-query -W -f='\${Status}' "$pkg" 2>/dev/null | grep -q 'install ok installed' || return 1; done; }; retry_apt_install() { attempts=8; n=1; while true; do apt-get install -o Acquire::Retries=3 -y --no-install-recommends --fix-missing "$@" || true; if apt_packages_ready "$@"; then return 0; fi; if [ "$n" -ge "$attempts" ]; then return 1; fi; echo "apt-get install attempt $n/$attempts incomplete; retrying" >&2; n=$((n + 1)); sleep 5; timed_stage "apt-update-retry" apt-get update -o Acquire::Retries=3 || true; done; }`; + const aptRetryHelpers = `retry_apt_update() { attempts=6; n=1; while ! apt-get update -o Acquire::Retries=3; do if [ "$n" -ge "$attempts" ]; then return 1; fi; echo "apt-get update attempt $n/$attempts failed; retrying" >&2; n=$((n + 1)); rm -rf /var/lib/apt/lists/*; sleep 5; done; }; apt_packages_ready() { for pkg in "$@"; do dpkg-query -W -f='\${Status}' "$pkg" 2>/dev/null | grep -q 'install ok installed' || return 1; done; }; retry_apt_install() { attempts=8; n=1; while true; do apt-get install -o Acquire::Retries=3 -y --no-install-recommends --fix-missing "$@" || true; if apt_packages_ready "$@"; then return 0; fi; if [ "$n" -ge "$attempts" ]; then return 1; fi; echo "apt-get install attempt $n/$attempts incomplete; retrying" >&2; n=$((n + 1)); sleep 5; timed_stage "apt-update-retry" apt-get update -o Acquire::Retries=3 || true; done; }`; const aptUpdateScript = `timed_stage "apt-update" retry_apt_update || (for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.sources; do [ -f "$f" ] || continue; sed -i -e '/^[[:space:]]*deb .* bookworm-updates /d' -e 's/[[:space:]]bookworm-updates//g' -e 's/bookworm-updates[[:space:]]//g' "$f"; done; timed_stage "apt-update-fallback" retry_apt_update)`; const aptInstallSection = (label, packages) => { const packageArgs = packages.map(shellQuote).join(" "); - if (!packageArgs) return ""; - return `if apt_packages_ready ${packageArgs}; then echo "{\"event\":\"env-reuse-apt-skip\",\"stage\":\"${label}\",\"reason\":\"packages-ready\"}" >&2; else ${aptUpdateScript}; timed_stage "apt-install-${label}" retry_apt_install ${packageArgs}; fi`; + if (!packageArgs) return `timed_skip "apt-install-${label}"`; + return `if apt_packages_ready ${packageArgs}; then timed_skip "apt-install-${label}"; else ${aptUpdateScript}; timed_stage "apt-install-${label}" retry_apt_install ${packageArgs}; fi`; }; const goPackageSection = goOsPackages.length > 0 - ? `if command -v go >/dev/null 2>&1; then echo "{\"event\":\"env-reuse-go-toolchain-skip\",\"reason\":\"go-preinstalled\"}" >&2; else ${aptInstallSection("go", goOsPackages)}; fi` - : ""; + ? `if command -v go >/dev/null 2>&1; then timed_skip "apt-install-go"; else ${aptInstallSection("go", goOsPackages)}; fi` + : `timed_skip "apt-install-go"`; const osPackageSections = [aptInstallSection("base", baseOsPackages), goPackageSection].filter(Boolean).join("; "); - const osPackageScript = `set -eu; export DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC; ${aptRetryHelpers}; ${osPackageSections}; rm -rf /var/lib/apt/lists/*`; + const osPackageScript = goBaseImageEnabled + ? `set -eu; ${stepTimingHelpers}; timed_skip "apt-update"; timed_skip "apt-install-base"; timed_skip "apt-install-go"` + : `set -eu; export DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC; ${stepTimingHelpers}; ${aptRetryHelpers}; ${osPackageSections}; rm -rf /var/lib/apt/lists/*`; const aliasScript = normalizedRecipe.hwpodAliases.map((alias) => `printf '%s\\n' '#!/usr/bin/env sh' 'exec node /workspace/hwlab-boot/repo/scripts/run-bun.mjs /workspace/hwlab-boot/repo/${alias.script} \"$@\"' > /usr/local/bin/${alias.command}; chmod 755 /usr/local/bin/${alias.command}`).join("; "); const readinessScript = goCacheEnabled ? `set -eu; command -v node >/dev/null; command -v git >/dev/null; command -v sh >/dev/null${goReadiness}; test -f /usr/local/bin/hwlab-go-env-reuse-launcher.mjs` : `set -eu; command -v node >/dev/null; command -v npm >/dev/null; command -v git >/dev/null; command -v sh >/dev/null; test -d ${shellQuote(normalizedRecipe.runtimeNodeModulesPath)}; test -x /usr/local/bin/bun; /usr/local/bin/bun --version >/tmp/hwlab-env-bun-version.txt; ${aliasScript}`; return [ - `FROM ${baseImage}`, + `FROM ${effectiveBaseImage}`, `WORKDIR ${runtimeRoot}`, - ...labels.map(([name, value]) => `LABEL ${name}=${dockerfileQuote(value)}`), ...(goCacheEnabled ? [] : ["COPY package.json ./package.json", "COPY package-lock.json* ./"]), `RUN ${downloadEnvPrefix}${osPackageScript}`, ...(goCacheEnabled ? [] : [`RUN ${downloadEnvPrefix}${downloadDependencyScript}`]), @@ -960,7 +1026,8 @@ function envReuseDockerfile(baseImage, labels = [], recipe, service = null) { goCacheEnabled ? `COPY ${goLauncherPath} /usr/local/bin/hwlab-go-env-reuse-launcher.mjs` : `COPY ${normalizedRecipe.launcherPath} /usr/local/bin/hwlab-env-reuse-launcher.ts`, - `RUN ${readinessScript}`, + `RUN ${stepTimingHelpers}; timed_stage "env-readiness" sh -c ${shellQuote(readinessScript)}`, + ...labels.map(([name, value]) => `LABEL ${name}=${dockerfileQuote(value)}`), "ENV HWLAB_RUNTIME_MODE=env-reuse-git-mirror-checkout", ...(goCacheEnabled ? [] : [`ENV HWLAB_RUNTIME_NODE_MODULES=${normalizedRecipe.runtimeNodeModulesPath}`]), ...goEnv, @@ -1006,6 +1073,7 @@ function normalizeEnvReuseRecipe(recipe) { const normalized = { osPackages: normalizeStringList(recipe.osPackages), goOsPackages: normalizeStringList(recipe.goOsPackages), + goBaseImage: String(recipe.goBaseImage ?? "").trim(), bunVersion: String(recipe.bunVersion ?? "").trim(), launcherPath: normalizeRepoPath(recipe.launcherPath), runtimeNodeModulesPath: normalizeAbsolutePath(recipe.runtimeNodeModulesPath), @@ -1019,6 +1087,9 @@ function normalizeEnvReuseRecipe(recipe) { if (normalized.runtimeGoModCachePath || normalized.runtimeGoBuildCachePath) { assert.ok(normalized.goOsPackages.length > 0, "envRecipe.goOsPackages must not be empty when Go cache paths are configured"); } + if (normalized.goBaseImage) { + assert.match(normalized.goBaseImage, /^[^/]+\/.+:[^:@]+$/u, "envRecipe.goBaseImage must include registry/repository:tag"); + } assert.match(normalized.bunVersion, /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/u, "envRecipe.bunVersion must be a semver-like version"); assert.ok(normalized.launcherPath && !normalized.launcherPath.split("/").includes(".."), "envRecipe.launcherPath must be repo-relative"); assert.ok(normalized.runtimeNodeModulesPath, "envRecipe.runtimeNodeModulesPath must be absolute"); @@ -1325,6 +1396,70 @@ async function buildEnvReuseService({ args, repo, commitId, service, buildCreate return withEnvReuseArtifactFields({ artifact, service, commitId, buildCreatedAt, buildSource }); } +function goEnvBaseDockerfile(baseImage, recipe) { + const packages = uniquePreserveOrder([...recipe.osPackages, ...recipe.goOsPackages]).map(shellQuote).join(" "); + const stepTimingHelpers = `emit_hwlab_timing() { stage="$1"; status="$2"; duration_ms="$3"; node -e "console.error(JSON.stringify({event:'node-cicd-build-step-timing',stage:process.argv[1],status:process.argv[2],durationMs:Number(process.argv[3]),source:'dockerfile-run'}))" "$stage" "$status" "$duration_ms"; }; timed_stage() { label="$1"; shift; start="$(node -e "console.log(Date.now())")"; status=succeeded; "$@" || status=failed; end="$(node -e "console.log(Date.now())")"; elapsed=$((end - start)); emit_hwlab_timing "$label" "$status" "$elapsed"; test "$status" = succeeded; }; timed_skip() { emit_hwlab_timing "$1" skipped 0; }`; + const aptHelpers = `retry_apt_update() { attempts=6; n=1; while ! apt-get update -o Acquire::Retries=3; do if [ "$n" -ge "$attempts" ]; then return 1; fi; echo "apt-get update attempt $n/$attempts failed; retrying" >&2; n=$((n + 1)); rm -rf /var/lib/apt/lists/*; sleep 5; done; }; apt_packages_ready() { for pkg in "$@"; do dpkg-query -W -f='\${Status}' "$pkg" 2>/dev/null | grep -q 'install ok installed' || return 1; done; }; retry_apt_install() { attempts=8; n=1; while true; do apt-get install -o Acquire::Retries=3 -y --no-install-recommends --fix-missing "$@" || true; if apt_packages_ready "$@"; then return 0; fi; if [ "$n" -ge "$attempts" ]; then return 1; fi; echo "apt-get install attempt $n/$attempts incomplete; retrying" >&2; n=$((n + 1)); sleep 5; timed_stage "go-base-apt-update-retry" apt-get update -o Acquire::Retries=3 || true; done; }`; + const aptUpdateScript = `timed_stage "go-base-apt-update" retry_apt_update || (for f in /etc/apt/sources.list /etc/apt/sources.list.d/*.sources; do [ -f "$f" ] || continue; sed -i -e '/^[[:space:]]*deb .* bookworm-updates /d' -e 's/[[:space:]]bookworm-updates//g' -e 's/bookworm-updates[[:space:]]//g' "$f"; done; timed_stage "go-base-apt-update-fallback" retry_apt_update)`; + const installScript = packages + ? `if apt_packages_ready ${packages}; then timed_skip "go-base-apt-install"; else ${aptUpdateScript}; timed_stage "go-base-apt-install" retry_apt_install ${packages}; fi` + : `timed_skip "go-base-apt-install"`; + return [ + `FROM ${baseImage}`, + "WORKDIR /opt/hwlab-env", + `RUN set -eu; export DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC; ${stepTimingHelpers}; ${aptHelpers}; ${installScript}; command -v go >/dev/null; go version >/tmp/hwlab-go-base-version.txt; rm -rf /var/lib/apt/lists/*`, + "ENV PATH=/usr/local/bin:$PATH", + "" + ].join("\n"); +} + +async function ensureGoEnvBaseImage({ args, normalizedRecipe, tmpDir, registryOption, buildkitAddrArgs, buildEnv }) { + if (!normalizedRecipe.goBaseImage) return null; + const image = normalizedRecipe.goBaseImage; + const cacheRef = goBaseCacheRef(args.registryPrefix); + if (await registryManifestExists(image)) { + return { image, status: "present", cacheRef, durationMs: 0 }; + } + const dockerfilePath = path.join(tmpDir, "Dockerfile.go-base"); + const metadataPath = path.join(tmpDir, "metadata.go-base.json"); + await writeFile(dockerfilePath, goEnvBaseDockerfile(args.baseImage, normalizedRecipe)); + const startedAt = Date.now(); + const argsList = [ + ...buildkitAddrArgs, + "build", + "--frontend", "dockerfile.v0", + "--local", `context=${tmpDir}`, + "--local", `dockerfile=${tmpDir}`, + "--opt", "filename=Dockerfile.go-base", + "--metadata-file", metadataPath, + ...buildkitCacheArgs(args, cacheRef, registryOption), + "--output", `type=image,name=${image},push=true${registryOption}` + ]; + if (!args.quietBuild || ciTimingEnabled()) argsList.push("--progress", "plain"); + const result = await run(args.buildkitCommand, argsList, { env: buildEnv }); + emitBuildkitTimingEvents({ service: { serviceId: "hwlab-go-env-base" }, result, timingEnabled: ciTimingEnabled(buildEnv) }); + if (result.code !== 0) { + return { + image, + status: "build_failed", + cacheRef, + durationMs: Date.now() - startedAt, + logTail: tailText(`${result.stdout}\n${result.stderr}`, 12000) + }; + } + let metadata = {}; + try { + metadata = JSON.parse(await readFile(metadataPath, "utf8")); + } catch {} + return { + image, + status: "built", + digest: buildkitDigestFromMetadata(metadata), + cacheRef, + durationMs: Date.now() - startedAt + }; +} + async function buildEnvReuseServiceWithBuildkit({ args, service, ref, buildCreatedAt, buildSource, labels }) { const tmpDir = await mkdtemp(path.join(os.tmpdir(), `hwlab-env-reuse-${service.serviceId}-`)); const normalizedRecipe = normalizeEnvReuseRecipe(service.envReuseRecipe); @@ -1337,7 +1472,34 @@ async function buildEnvReuseServiceWithBuildkit({ args, service, ref, buildCreat const registryOption = buildkitRegistryOption(args.registryPrefix); const buildkitAddrArgs = args.buildkitAddr ? ["--addr", args.buildkitAddr] : []; const startedAt = Date.now(); + const buildEnv = await prepareBuildkitEnv(args); + let goBaseEvidence = null; try { + goBaseEvidence = goCacheEnabled + ? await ensureGoEnvBaseImage({ args, normalizedRecipe, tmpDir, registryOption, buildkitAddrArgs, buildEnv }) + : null; + if (goBaseEvidence?.status === "build_failed") { + return { + ...service, + image: ref, + imageTag: imageTagFromReference(ref), + buildCreatedAt, + buildSource, + status: "build_failed", + digest: "not_published", + blocker: blocker({ + type: "environment_blocker", + scope: service.serviceId, + summary: `BuildKit Go env base image build failed for ${service.serviceId}`, + next: "Inspect the Go env base BuildKit output and registry/cache availability." + }), + logTail: goBaseEvidence.logTail, + buildBackend: "buildkit-env-reuse", + ...buildkitCacheEvidence(args, cacheRef), + ...goBaseEvidenceFields(goBaseEvidence), + buildkitBuildDurationMs: Date.now() - startedAt + }; + } await writeFile(dockerfilePath, envReuseDockerfile(args.baseImage, labels, normalizedRecipe, service)); const argsList = [ ...buildkitAddrArgs, @@ -1351,12 +1513,6 @@ async function buildEnvReuseServiceWithBuildkit({ args, service, ref, buildCreat "--output", `type=image,name=${ref},push=true${registryOption}` ]; if (!args.quietBuild || ciTimingEnabled()) argsList.push("--progress", "plain"); - const buildEnv = { ...process.env }; - if (!args.buildkitAddr) { - buildEnv.BUILDKITD_FLAGS = process.env.BUILDKITD_FLAGS || "--oci-worker-no-process-sandbox"; - buildEnv.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR || path.join(os.tmpdir(), "hwlab-buildkit-runtime"); - await mkdir(buildEnv.XDG_RUNTIME_DIR, { recursive: true }); - } const result = await run(args.buildkitCommand, argsList, { env: buildEnv }); emitBuildkitTimingEvents({ service, result, timingEnabled: ciTimingEnabled(buildEnv) }); if (result.code !== 0) { @@ -1377,6 +1533,7 @@ async function buildEnvReuseServiceWithBuildkit({ args, service, ref, buildCreat logTail: tailText(`${result.stdout}\n${result.stderr}`, 12000), buildBackend: "buildkit-env-reuse", ...buildkitCacheEvidence(args, cacheRef), + ...goBaseEvidenceFields(goBaseEvidence), buildkitBuildDurationMs: result.durationMs }; } @@ -1403,6 +1560,7 @@ async function buildEnvReuseServiceWithBuildkit({ args, service, ref, buildCreat pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200), buildBackend: "buildkit-env-reuse", ...buildkitCacheEvidence(args, cacheRef), + ...goBaseEvidenceFields(goBaseEvidence), buildkitBuildDurationMs: Date.now() - startedAt }; } @@ -1417,6 +1575,7 @@ async function buildEnvReuseServiceWithBuildkit({ args, service, ref, buildCreat repositoryDigest: `${repositoryFromImageRef(ref)}@${digest}`, buildBackend: "buildkit-env-reuse", ...buildkitCacheEvidence(args, cacheRef), + ...goBaseEvidenceFields(goBaseEvidence), buildkitBuildDurationMs: Date.now() - startedAt, publishDurationMs: args.mode === "publish" ? Date.now() - startedAt : null, pushLogTail: tailText(`${result.stdout}\n${result.stderr}`, 1200) diff --git a/scripts/ci-plan.test.mjs b/scripts/ci-plan.test.mjs index e1e35fce..cb1ca5b2 100644 --- a/scripts/ci-plan.test.mjs +++ b/scripts/ci-plan.test.mjs @@ -1218,6 +1218,28 @@ test("v03 planner ignores Go launcher comment-only changes for env identity", as } }); +test("Go env build path declares base image, stable timing, and late volatile labels", async () => { + const deploy = await readStructuredFile(process.cwd(), path.join(process.cwd(), "deploy/deploy.yaml")); + assert.equal( + deploy.lanes?.v03?.envRecipe?.goBaseImage, + "127.0.0.1:5000/hwlab/hwlab-go-env-base:go1-bookworm-slim" + ); + + const source = await readFile(path.join(process.cwd(), "scripts/artifact-publish.mjs"), "utf8"); + const envDockerfileSource = source.slice( + source.indexOf("function envReuseDockerfile"), + source.indexOf("async function copyRepoFileToContext") + ); + assert.match(source, /const effectiveBaseImage = goBaseImageEnabled \? normalizedRecipe\.goBaseImage : baseImage/u); + assert.match(envDockerfileSource, /timed_stage "go-mod-download"/u); + assert.match(envDockerfileSource, /timed_stage "env-readiness"/u); + + const labelIndex = envDockerfileSource.indexOf("...labels.map(([name, value]) => `LABEL"); + const readinessIndex = envDockerfileSource.indexOf('timed_stage "env-readiness"'); + assert.ok(readinessIndex >= 0, "env readiness timing should be emitted from the Dockerfile"); + assert.ok(labelIndex > readinessIndex, "volatile labels should not invalidate expensive env build layers"); +}); + async function createFixtureRepo(options = {}) { const serviceIds = options.serviceIds ?? ["hwlab-cloud-api", "hwlab-cloud-web"]; const laneServiceIds = options.laneServiceIds ?? V02_RUNTIME_SERVICE_IDS;