From a11ea8444d5deb8898f04bb87349d489ed6c5989 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 23:27:06 +0100 Subject: [PATCH 1/2] build: ship a wget for health checks written outside the image The Wolfi runner has no wget, but health checks outside this repository call it: compose files copied from here up to v1.46.6 run 'wget --no-verbose --tries=1 --spider .../api/health', and the TrueNAS catalog app renders 'wget --quiet --spider '. Either replaces the image's HEALTHCHECK, so such a container never turned healthy and a proxy that routes on health dropped its traffic. /usr/local/bin/wget now runs scripts/wget-shim.mjs: the URL, --spider, -q, -nv, --tries, --timeout/-T, -O - and -O /dev/null, -S; other flags are ignored. Exit 0 on 2xx/3xx, 1 on any other status, a refused connection or the timeout. It follows no redirect and refuses any other output file before sending a request, so it is no download tool. The image's own HEALTHCHECK and the bundled compose file keep the Node probe. --- Dockerfile | 14 ++ scripts/wget-shim.mjs | 182 ++++++++++++++++++ src/__tests__/healthcheck-boot-storm.test.ts | 9 +- src/__tests__/wget-shim.test.ts | 186 +++++++++++++++++++ 4 files changed, 387 insertions(+), 4 deletions(-) create mode 100644 scripts/wget-shim.mjs create mode 100644 src/__tests__/wget-shim.test.ts diff --git a/Dockerfile b/Dockerfile index ad7a8e70a..e04edaaf8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -401,6 +401,20 @@ RUN env -u DATABASE_URL healthlog-tsx scripts/repair-intake-anomalies.ts 2>&1 | RUN mkdir -p /opt/geolite2 && chown nextjs:nodejs /opt/geolite2 COPY --chown=nextjs:nodejs assets/geolite2/ /opt/geolite2/ +# `wget` for health checks written outside the image. A compose file copied +# from this repository up to v1.46.6 and the TrueNAS catalog app both call +# `wget --spider` against /api/health, and their check replaces the +# HEALTHCHECK below; the Wolfi base has no wget, so without this every such +# container stayed unhealthy. `scripts/wget-shim.mjs` understands the flags +# those checks use, exits like wget, and writes nowhere but stdout. The +# image's own HEALTHCHECK and the bundled compose file use the Node probe. +# Root-owned: the app user can run it but not replace it. +COPY --from=builder /app/scripts/wget-shim.mjs /usr/local/lib/healthlog/wget-shim.mjs +RUN printf '#!/bin/sh\nexec node /usr/local/lib/healthlog/wget-shim.mjs "$@"\n' > /usr/local/bin/wget && \ + chmod 755 /usr/local/bin/wget && \ + wget 2>&1 | grep -q "health check shim" && \ + ! wget --no-verbose --tries=1 --spider http://127.0.0.1:9/api/health + # ISO-8601 build timestamp — /api/version returns it as `builtAt`. # Declared this late in the stage on purpose: the value differs on # every CI run, and every instruction after an ARG shares its cache diff --git a/scripts/wget-shim.mjs b/scripts/wget-shim.mjs new file mode 100644 index 000000000..fde4d75e8 --- /dev/null +++ b/scripts/wget-shim.mjs @@ -0,0 +1,182 @@ +// @ts-check +/** + * `wget` in the image (`/usr/local/bin/wget` runs this file): enough of wget + * for a container health check, and nothing more. + * + * The runtime base has no wget, but health checks written outside the image + * call it: compose files copied from this repository up to v1.46.6 run + * `wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health`, + * and the TrueNAS catalog app renders `wget --quiet --spider `. Such a + * check replaces the image's own HEALTHCHECK, so without a `wget` the + * container would never turn healthy. The image's HEALTHCHECK and the + * bundled compose file use a `node -e` fetch instead; this file exists only + * for those external callers. + * + * Understood: the URL, `--spider`, `-q`/`--quiet`, `-nv`/`--no-verbose`, + * `-t N`/`--tries=N`, `-T N`/`--timeout=N`, `-O -` and `-O /dev/null` + * (also `-O-`, `--output-document=…`), `-S`/`--server-response`. Any other + * flag is ignored. It is not a download tool: it writes a body only to + * standard output (`-O -`), refuses any other `-O` target before sending a + * request, never follows a redirect, and speaks plain http/https only. + * + * Exit 0 on a 2xx or 3xx answer, 1 on any other status, a refused or + * failed connection, or the timeout, and 2 on a command line it will not + * run. Without `-T` a request gives up after 30 s; Docker's own health + * check timeout usually ends it sooner. + */ + +import { pathToFileURL } from "node:url"; + +const DEFAULT_TIMEOUT_SECONDS = 30; + +/** Flags whose value may follow as the next argument. */ +const VALUE_FLAGS = new Set(["-O", "-T", "-t", "-o", "-U", "-P", "--header"]); + +/** + * @param {string[]} argv + * @returns {{ url: string | null; spider: boolean; quiet: boolean; + * verbose: boolean; tries: number; timeout: number; output: string | null; + * serverResponse: boolean }} + */ +function parseArgs(argv) { + const opts = { + url: /** @type {string | null} */ (null), + spider: false, + quiet: false, + verbose: true, + tries: 1, + timeout: DEFAULT_TIMEOUT_SECONDS, + output: /** @type {string | null} */ (null), + serverResponse: false, + }; + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i] ?? ""; + /** @param {string} name */ + const inline = (name) => + arg.startsWith(`${name}=`) ? arg.slice(name.length + 1) : null; + /** The value of a short flag, attached (`-T4`) or as the next argument. */ + const shortValue = () => { + if (arg.length > 2) return arg.slice(2).replace(/^=/, ""); + i += 1; + return argv[i] ?? ""; + }; + if (/^https?:\/\//i.test(arg)) { + opts.url ??= arg; + } else if (arg === "--spider") { + opts.spider = true; + } else if (arg === "-q" || arg === "--quiet") { + opts.quiet = true; + } else if (arg === "-nv" || arg === "--no-verbose") { + opts.verbose = false; + } else if (arg === "-S" || arg === "--server-response") { + opts.serverResponse = true; + } else if (arg.startsWith("--tries=")) { + opts.tries = count(inline("--tries"), 1); + } else if (arg.startsWith("-t")) { + opts.tries = count(shortValue(), 1); + } else if (arg.startsWith("--timeout=")) { + opts.timeout = count(inline("--timeout"), DEFAULT_TIMEOUT_SECONDS); + } else if (arg.startsWith("-T")) { + opts.timeout = count(shortValue(), DEFAULT_TIMEOUT_SECONDS); + } else if (arg.startsWith("--output-document=")) { + opts.output = inline("--output-document"); + } else if (arg.startsWith("-O")) { + opts.output = shortValue(); + } else if (VALUE_FLAGS.has(arg)) { + // A flag this shim ignores, with its value as the next argument. + i += 1; + } else if (/^-q[a-zA-Z]*O/.test(arg)) { + // `-qO-` / `-qO /dev/null`: quiet plus an output target, bundled. + opts.quiet = true; + const rest = arg.slice(arg.indexOf("O") + 1); + opts.output = rest || (argv[++i] ?? ""); + } + // Anything else is ignored. + } + // wget treats 0 tries as "retry forever"; a health check must not. + if (opts.tries < 1) opts.tries = 1; + return opts; +} + +/** + * @param {string | null} value + * @param {number} fallback + */ +function count(value, fallback) { + const n = Number(value); + return Number.isFinite(n) && n >= 0 ? n : fallback; +} + +/** @param {string} message @param {boolean} quiet */ +function say(message, quiet) { + if (!quiet) process.stderr.write(`wget: ${message}\n`); +} + +/** + * @param {string[]} argv + * @returns {Promise} the exit code + */ +async function run(argv) { + const opts = parseArgs(argv); + if (!opts.url) { + process.stderr.write("wget: missing URL (health check shim)\n"); + return 2; + } + const toStdout = opts.output === "-"; + if (opts.output !== null && !toStdout && opts.output !== "/dev/null") { + process.stderr.write( + `wget: refusing to write ${opts.output}: this image's wget is a health check shim (-O - and -O /dev/null only)\n`, + ); + return 2; + } + let lastError = ""; + for (let attempt = 1; attempt <= opts.tries; attempt += 1) { + try { + const response = await fetch(opts.url, { + redirect: "manual", + signal: AbortSignal.timeout(Math.max(opts.timeout, 0.001) * 1000), + }); + if (opts.serverResponse) { + process.stderr.write( + ` HTTP/1.1 ${response.status} ${response.statusText}\n`, + ); + for (const [name, value] of response.headers) { + process.stderr.write(` ${name}: ${value}\n`); + } + } + if (toStdout && !opts.spider && response.body) { + for await (const chunk of response.body) process.stdout.write(chunk); + } else { + await response.body?.cancel(); + } + const ok = response.status >= 200 && response.status < 400; + if (ok) { + if (opts.verbose) say(`${opts.url}: ${response.status}`, opts.quiet); + return 0; + } + say(`server returned error: HTTP ${response.status}`, opts.quiet); + return 1; + } catch (err) { + lastError = + err instanceof Error && err.name === "TimeoutError" + ? "download timed out" + : err instanceof Error + ? err.cause instanceof Error + ? err.cause.message + : err.message + : String(err); + } + } + say(`${opts.url}: ${lastError}`, opts.quiet); + return 1; +} + +if ( + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + run(process.argv.slice(2)).then( + (code) => process.exit(code), + () => process.exit(1), + ); +} diff --git a/src/__tests__/healthcheck-boot-storm.test.ts b/src/__tests__/healthcheck-boot-storm.test.ts index 6a780da5b..4592bcc61 100644 --- a/src/__tests__/healthcheck-boot-storm.test.ts +++ b/src/__tests__/healthcheck-boot-storm.test.ts @@ -80,12 +80,13 @@ function imageCheck(): Check { } /** - * The probe is Node's fetch, aborted by `AbortSignal.timeout(ms)`. The - * image carries no wget or curl, so a probe that names one would fail every - * time and mark a healthy container unhealthy. + * The probe is Node's fetch, aborted by `AbortSignal.timeout(ms)`. The image + * carries no curl, and its `wget` is a shim kept for health checks written + * outside the repository (`wget-shim.test.ts`); the image's own check and the + * bundled compose file do not lean on it, so neither names one. */ function probeTimeout(command: string): number { - expect(command, "the probe needs a binary the image has").not.toMatch( + expect(command, "the image's own probe is the Node fetch").not.toMatch( /\b(wget|curl)\b/, ); const m = diff --git a/src/__tests__/wget-shim.test.ts b/src/__tests__/wget-shim.test.ts new file mode 100644 index 000000000..0acfa1297 --- /dev/null +++ b/src/__tests__/wget-shim.test.ts @@ -0,0 +1,186 @@ +/** + * The image's `wget` answers the health checks written outside this + * repository the way wget would. + * + * The runtime base carries no wget. A compose file copied from this + * repository up to v1.46.6 checks health with + * `wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health`, + * and the TrueNAS catalog app renders `wget --quiet --spider `; either + * replaces the image's own HEALTHCHECK. Without a `wget` such a container is + * unhealthy forever and a proxy that routes on health drops its traffic. So + * the image installs `scripts/wget-shim.mjs` as `/usr/local/bin/wget`, and + * this file runs that script as the health check would: exit 0 on a healthy + * answer, 1 on a 503, a refused connection or a timeout, and no file + * written anywhere. + */ +import { spawn } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +const ROOT = process.cwd(); +const SHIM = join(ROOT, "scripts", "wget-shim.mjs"); + +let server: Server; +let base = ""; + +beforeAll(async () => { + server = createServer((req, res) => { + if (req.url === "/ok") { + res.writeHead(200, { "content-type": "application/json" }); + res.end('{"status":"ok"}'); + } else if (req.url === "/down") { + res.writeHead(503, { "content-type": "application/json" }); + res.end('{"status":"degraded"}'); + } else if (req.url === "/moved") { + res.writeHead(302, { location: "http://192.0.2.1/elsewhere" }); + res.end(); + } else if (req.url === "/slow") { + // Never answers; the shim's timeout has to end the request. + } else { + res.writeHead(404); + res.end(); + } + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; +}); + +afterAll(async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(() => resolve())); +}); + +function wget( + args: string[], + cwd = ROOT, +): Promise<{ + code: number | null; + stdout: string; + stderr: string; + ms: number; +}> { + return new Promise((resolve) => { + const started = Date.now(); + const child = spawn(process.execPath, [SHIM, ...args], { cwd }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d: Buffer) => (stdout += d.toString())); + child.stderr.on("data", (d: Buffer) => (stderr += d.toString())); + child.on("close", (code) => + resolve({ code, stdout, stderr, ms: Date.now() - started }), + ); + }); +} + +/** A port nothing listens on: bind one, note it, close it again. */ +async function closedPort(): Promise { + const probe = createServer(); + await new Promise((resolve) => probe.listen(0, "127.0.0.1", resolve)); + const { port } = probe.address() as AddressInfo; + await new Promise((resolve) => probe.close(() => resolve())); + return port; +} + +// The two command lines found in the wild, with the URL left to the case. +const LINES: Array<[string, (url: string) => string[]]> = [ + [ + "compose up to v1.46.6", + (url) => ["--no-verbose", "--tries=1", "--timeout=4", "--spider", url], + ], + [ + "compose without a timeout", + (url) => ["--no-verbose", "--tries=1", "--spider", url], + ], + ["TrueNAS catalog app", (url) => ["--quiet", "--spider", url]], +]; + +describe.each(LINES)("the %s health check", (_name, line) => { + it("passes on a healthy server", async () => { + expect((await wget(line(`${base}/ok`))).code).toBe(0); + }); + + it("fails on a 503", async () => { + expect((await wget(line(`${base}/down`))).code).toBe(1); + }); + + it("fails on a refused connection", async () => { + const port = await closedPort(); + expect((await wget(line(`http://127.0.0.1:${port}/api/health`))).code).toBe( + 1, + ); + }); +}); + +describe("wget shim", () => { + it("gives up at its timeout instead of hanging", async () => { + const result = await wget(["-q", "-T", "1", "--spider", `${base}/slow`]); + expect(result.code).toBe(1); + expect(result.ms).toBeLessThan(5_000); + }); + + it("counts a redirect as an answer and does not follow it", async () => { + expect((await wget(["-q", "--spider", `${base}/moved`])).code).toBe(0); + }); + + it("prints the body for -O - and stays quiet with -q", async () => { + const result = await wget(["-qO-", `${base}/ok`]); + expect(result.code).toBe(0); + expect(result.stdout).toBe('{"status":"ok"}'); + expect(result.stderr).toBe(""); + }); + + it("accepts -O /dev/null and -S", async () => { + const result = await wget(["-S", "-O", "/dev/null", `${base}/ok`]); + expect(result.code).toBe(0); + expect(result.stderr).toMatch(/HTTP\/1\.1 200/); + }); + + it("ignores flags it does not know", async () => { + const result = await wget([ + "--no-check-certificate", + "--header", + "X-Probe: 1", + "-c", + "--spider", + `${base}/ok`, + ]); + expect(result.code).toBe(0); + }); + + it("is not a download tool: it refuses any other output file and writes none", async () => { + const dir = mkdtempSync(join(tmpdir(), "wget-shim-")); + try { + const target = join(dir, "health.json"); + const refused = await wget(["-O", target, `${base}/ok`], dir); + expect(refused.code).toBe(2); + expect(existsSync(target)).toBe(false); + // Without -O real wget saves `index.html`; the shim discards the body. + const bare = await wget([`${base}/ok`], dir); + expect(bare.code).toBe(0); + expect(existsSync(join(dir, "ok"))).toBe(false); + expect(existsSync(join(dir, "index.html"))).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("refuses a command line without a URL", async () => { + expect((await wget(["--spider"])).code).toBe(2); + }); + + it("is what the image installs as wget", () => { + const dockerfile = readFileSync(join(ROOT, "Dockerfile"), "utf8"); + const runner = dockerfile.slice(dockerfile.search(/^FROM .* AS runner$/m)); + expect(runner).toContain( + "COPY --from=builder /app/scripts/wget-shim.mjs /usr/local/lib/healthlog/wget-shim.mjs", + ); + expect(runner).toContain( + `exec node /usr/local/lib/healthlog/wget-shim.mjs "$@"\\n' > /usr/local/bin/wget`, + ); + }); +}); From 42cae738f17ec1c327d0da13b16806d89d2114d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 3 Nov 2026 23:27:06 +0100 Subject: [PATCH 2/2] build: pin the runner's Node and PostgreSQL client packages nodejs-22 and postgresql-18-client are installed at exact package versions, so a rebuild of the same commit runs the same Node and the same pg_dump; apk upgrade still moves the libraries under them. No updater reads an apk pin. The image scan does the job instead: Wolfi records the fixed version for each advisory, so a pinned package with a published fix turns the HIGH/CRITICAL gate red and names the version. A guard holds the pin format and keeps the client's minor on the bundled database's. --- Dockerfile | 14 +++++- src/__tests__/runtime-package-pins.test.ts | 56 ++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/runtime-package-pins.test.ts diff --git a/Dockerfile b/Dockerfile index e04edaaf8..04585fd95 100644 --- a/Dockerfile +++ b/Dockerfile @@ -106,7 +106,19 @@ FROM cgr.dev/chainguard/wolfi-base:latest@sha256:629d9acaceda7746cd9293365eb0d8e # # busybox, part of the base, is the shell the entrypoint runs under and # carries `tar`/`gzip` for the GeoLite2 extraction and `addgroup`/`adduser`. -RUN apk add --no-cache nodejs-22 tzdata postgresql-18-client && \ +# +# The two packages the app runs on are pinned to an exact package version, +# so a rebuild of the same commit runs the same Node and the same pg_dump; +# `apk upgrade` cannot move a pinned package, and moves only the libraries +# under them. No updater reads these pins. What moves them is the image +# scan: Wolfi records the fixed package version for every advisory, so a +# pinned package with a published fix turns the HIGH/CRITICAL gate red and +# names the version to pin. To bump, take the newest from +# `apk policy nodejs-22` / `apk policy postgresql-18-client` in the pinned +# base. `src/__tests__/runtime-package-pins.test.ts` holds the pin format, +# and the client's minor to the bundled database's in docker-compose.yml. +# tzdata stays unpinned on purpose: a zone rule change is a fix to take. +RUN apk add --no-cache nodejs-22=22.23.2-r1 tzdata postgresql-18-client=18.6-r7 && \ apk upgrade --no-cache && \ mkdir -p /usr/local/bin COPY --from=builder /app/.native-abi /tmp/.native-abi diff --git a/src/__tests__/runtime-package-pins.test.ts b/src/__tests__/runtime-package-pins.test.ts new file mode 100644 index 000000000..b032090c3 --- /dev/null +++ b/src/__tests__/runtime-package-pins.test.ts @@ -0,0 +1,56 @@ +/** + * The runtime packages the image installs from apk are pinned, and the + * PostgreSQL client moves with the bundled database. + * + * The runner installs Node and the PostgreSQL client from the Wolfi + * repository. Unpinned, a rebuild of the same commit could ship another + * Node or another pg_dump than the one the release was checked with. No + * updater reads an apk pin; the image scan is what flags a pinned package + * once Wolfi publishes a fix (the Dockerfile comment explains the bump), so + * this file only holds the pins' shape and their agreement with the rest of + * the repository. + */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = process.cwd(); +const DOCKERFILE = readFileSync(join(ROOT, "Dockerfile"), "utf8"); +const COMPOSE = readFileSync(join(ROOT, "docker-compose.yml"), "utf8"); + +function runner(): string { + const start = DOCKERFILE.search(/^FROM .* AS runner$/m); + expect(start, "the Dockerfile has a runner stage").toBeGreaterThan(0); + return DOCKERFILE.slice(start); +} + +function apkAdds(): string[] { + return [...runner().matchAll(/apk add --no-cache ([^&\n\\]+)/g)].flatMap( + (m) => m[1]!.trim().split(/\s+/), + ); +} + +describe("runtime package pins", () => { + it("pins nodejs-22 to one exact Node 22 package version", () => { + const pins = apkAdds().filter((p) => p.startsWith("nodejs")); + expect(pins).toHaveLength(1); + expect(pins[0]).toMatch(/^nodejs-22=22\.\d+\.\d+-r\d+$/); + }); + + it("pins postgresql-18-client to the bundled database's minor", () => { + const pins = apkAdds().filter((p) => p.startsWith("postgresql")); + expect(pins).toHaveLength(1); + const client = /^postgresql-18-client=(18\.\d+)-r\d+$/.exec(pins[0]!); + expect(client, `an exact pin, not ${pins[0]}`).not.toBeNull(); + const db = /^\s*image:\s*postgres:(\d+\.\d+)-alpine$/m.exec(COMPOSE); + expect(db, "the compose file pins the database").not.toBeNull(); + // One decision: a database minor bump moves the client with it. + expect(client![1]).toBe(db![1]); + }); + + it("leaves no other package floating except tzdata and the build-only npm", () => { + const unpinned = apkAdds().filter((p) => !p.includes("=")); + expect(new Set(unpinned)).toEqual(new Set(["tzdata", "npm"])); + }); +});