diff --git a/.github/workflows/deploy-hands-server.yml b/.github/workflows/deploy-hands-server.yml index 557f76a..e3ee4cb 100644 --- a/.github/workflows/deploy-hands-server.yml +++ b/.github/workflows/deploy-hands-server.yml @@ -83,6 +83,12 @@ jobs: - name: Render production Wrangler config working-directory: worker + # GITHUB_SHA is already present in every step, so this line changes nothing at + # runtime. It is here because the renderer now reads it to stamp the container + # image, and an ambient variable is an invisible dependency: someone reading + # either file alone cannot see that the stamp comes from here. + env: + GITHUB_SHA: ${{ github.sha }} run: node scripts/render-production-config.mjs --output wrangler.hands.generated.jsonc - name: Generate Worker types diff --git a/container/Dockerfile b/container/Dockerfile index 5230681..7db9611 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -82,6 +82,19 @@ RUN npm ci --include=dev --no-audit --no-fund # Copy container source COPY src ./src +# Build stamp, so /health can answer "which build is serving" rather than only +# "something is serving". Supplied by `image_vars` in the Worker's container config, +# which wrangler passes as a build arg; unset outside a real deploy, and /health then +# reports "unknown" rather than pretending to know. +# +# Deliberately placed after every expensive layer. GIT_SHA changes on every deploy, and +# an ARG invalidates the cache for everything below it - declared up with the other ARGs +# it would rebuild the Rust minidump-stackwalk, the apt layer, the Android SDK and +# `npm ci` on every single deploy. Here it invalidates only the two lines beneath it, +# and `COPY src` above already changes whenever the source does. +ARG GIT_SHA="" +ENV BUILD_SHA=$GIT_SHA + EXPOSE 8080 # Run via tsx (no build step needed in container; Cloudflare Containers build with Docker) diff --git a/container/src/server.ts b/container/src/server.ts index 1200693..c7fe87c 100644 --- a/container/src/server.ts +++ b/container/src/server.ts @@ -55,7 +55,20 @@ interface MinidumpReport { crashing_thread?: { thread_index?: number; frames?: MinidumpFrame[] }; } -app.get("/health", (c) => c.json({ ok: true, service: "multi-parser" })); +// `build` is the commit the image was built from, baked in as BUILD_SHA at image build +// time (Dockerfile) rather than injected at runtime, so it identifies *which* build is +// serving. Its usefulness here does not depend on the value: the image running in +// production today has no code that emits this key at all, so the key appearing is +// itself proof the image was rebuilt and is serving, and the key being absent means the +// old image is still up - never "the probe did not reach the container". A rollout is +// therefore its own before/after control. +app.get("/health", (c) => + c.json({ + ok: true, + service: "multi-parser", + build: process.env.BUILD_SHA || "unknown", + }), +); // Delta/differential update patch generation (task #246). JSON body with two // signed URLs `old_url` and `new_url` (both APKs in R2); the container fetches diff --git a/worker/scripts/render-production-config.mjs b/worker/scripts/render-production-config.mjs index 5371bd1..9c2a628 100644 --- a/worker/scripts/render-production-config.mjs +++ b/worker/scripts/render-production-config.mjs @@ -121,6 +121,26 @@ if (reporterSessionActiveKeyVersion) { delete config.vars.FEEDBACK_REPORTER_SESSION_ACTIVE_KEY_VERSION; } +// Build stamp for the container image, so a rollout can be verified by reading the +// container rather than by trusting that the workflow went green. `image_vars` is +// wrangler's build-arg channel - its own type says "available to the image at +// build-time only" - and the Dockerfile turns GIT_SHA into BUILD_SHA, which /health +// reports. +// +// Left absent when GITHUB_SHA is unset (local renders), rather than defaulting to a +// placeholder: a stamp that always has *some* value cannot distinguish "built by a +// deploy" from "built by hand", and this exists precisely to make that distinguishable. +const gitSha = (process.env.GITHUB_SHA ?? "").trim(); +const containerApp = config.containers?.find( + (entry) => entry.class_name === "ApkParserContainer", +); +if (!containerApp) { + throw new Error("ApkParserContainer is missing from the base config"); +} +if (gitSha) { + containerApp.image_vars = { ...containerApp.image_vars, GIT_SHA: gitSha }; +} + mkdirSync(dirname(outputPath), { recursive: true }); writeFileSync(outputPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); console.log(`Rendered production Wrangler config: ${outputPath}`); diff --git a/worker/src/index.ts b/worker/src/index.ts index 2152478..934c5a6 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -644,6 +644,46 @@ admin.onError((err, c) => { ); }); +// Container build readback. The deploy pipeline has no container probe at all: it passes +// --containers-rollout and prints the value, so "the workflow went green" has been the +// only evidence that a rebuilt image is serving. This route is the instrument for the +// container-side criterion - read it before and after a rollout. +// +// `build` comes from the container's own /health. On the image running today that key +// does not exist, so *key absent* means the old image is still serving and *key present* +// means the new one is - the old image cannot fabricate a key its code never emits, which +// rules out "the probe passed but hit the old image". +// +// Admin-gated on purpose. The exact deployed commit narrows a public repository's tree to +// one revision for anyone asking which known issues currently apply, and this readback is +// run by operators, not by clients. +admin.get("/api/admin/container/build", async (c) => { + const container = await getRandom(c.env.APK_PARSER, 1); + const res = await container.fetch(new Request("http://container/health")); + const body = await res.text(); + let parsed: unknown; + try { + parsed = JSON.parse(body); + } catch { + // Surface the raw body rather than a parse error: a container that answers with + // something unparseable is a different failure from one that does not answer, and + // collapsing them is how "no result" starts reading like "clean result". + return c.json({ ok: false, status: res.status, raw: body.slice(0, 512) }, 502); + } + const build = + typeof parsed === "object" && parsed !== null && "build" in parsed + ? (parsed as { build?: unknown }).build + : undefined; + return c.json({ + ok: res.ok, + status: res.status, + // null, not omitted: an absent key here would be indistinguishable from this route + // having failed to read one, which is the exact confusion the stamp exists to end. + build: typeof build === "string" ? build : null, + stamped: typeof build === "string", + }); +}); + admin.get("/api/orgs", handleListOrgs); admin.get("/api/orgs/:orgId/members", requireOrgRole("orgId", "viewer"), handleListOrgMembers); admin.patch("/api/orgs/:orgId/members/:accountId", requireOrgRole("orgId", "admin"), handleUpdateOrgMember);