Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/deploy-hands-server.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions container/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion container/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions worker/scripts/render-production-config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
40 changes: 40 additions & 0 deletions worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading