Files
EpicNext-Cms/scripts/docker-start.mjs
T
openhands 6c3d81920e
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 32s
CI / tests-unit (push) Successful in 1m49s
CI / tests-ui (push) Successful in 2m33s
CI / tests-integration (push) Successful in 1m50s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m26s
fix(ops): supervise the job worker and stop the health probe from lying
Four production defects, all found by auditing the running host rather than
the code. Each one had a signature that looked like a network or permissions
problem and was actually a configuration or ordering bug.

jobs-worker never ran

`import "./load-env"` sat on line 3 of scripts/jobs-worker.ts, but ESM
evaluates a module's imports in source order and the first import reaches
`@/env`, which validates process.env at import time. The ZodError on
DATABASE_URL therefore fired before load-env ever executed, so the worker
could only start from a shell that had already exported the configuration.
Nothing supervised it either, so scheduled articles, catalog export, JAR and
database backups, disk alerts and the ops health probe have all been dead;
`cms:jobs-worker:heartbeat` did not exist. Moved the import to the top and
added deployment/systemd/cms-jobs-worker.service with Restart=always.

The JAR backup additionally pointed at './emulator/Arcturus.jar', which does
not exist and would go stale on the next emulator upgrade. resolveEmulatorJar
now accepts a file, a directory or a wildcard and picks the newest JAR, the
same way emulator.service picks its build, and reports an unresolvable path
once instead of logging an opaque copyFile ENOENT every night.

/api/health answered 200 with the database down

The route documented this as intentional, and ci-deploy.sh worked around it
by grepping the body for '"database":true'. The container healthcheck did not,
so Docker reported containers healthy while every page 500'd. The status is
now load-bearing: 503 when the database is unreachable, 200 otherwise. Redis
and the emulator deliberately do not fail the container — both have in-process
fallbacks, so failing them would trade a slow site for an outage.

The runtime had no V8 heap cap

NODE_OPTIONS existed only in the builder stage. With no cap, V8 sized its
heap from host memory (23.5 GB) while the container was limited to 4 GB, so
the kernel OOM-killed the process mid-request — the same failure mode as the
14 host-wide `next-build` kills. docker-start.mjs now reads the cgroup limit
(v2 with a v1 fallback) and sets 70% of it, respecting an explicit override.

Storage ownership was only repaired for one path

ci-deploy.sh chowned storage/imaging and nothing else, so
storage/catalog-git/hotel-status.json kept coming back root:root and
/api/admin/catalog/status kept throwing EACCES. All eight writable storage
paths are repaired now. The silent-failure mode is the reason this mattered:
these writes sit inside try/catch, so a wrong owner looks like a slow page
rather than an error.

nginx: robots.txt was a guaranteed 404, and TLS never resumed

`index index.html` without a `root` left every try_files resolving against
/etc/nginx/html, which sits behind a 0750 directory — the worker got EACCES
on each stat and nginx logs a failed stat at crit, which is where 149 crit
lines per scan came from. robots.txt answered from that same broken location,
so crawlers were pointed at a file they could never read while sitemap.xml
kept advertising it. Added `root`, proxied robots.txt to the CMS, added
ssl_session_cache (there was no session resumption at all), and set
Restart=on-failure in a systemd override, since the packaged unit ships
Restart=no and nginx is the only thing serving the site.

Verified against the running host: 3379 tests, typecheck and biome clean,
nginx -t passes, health returns 200 with every check green, and the worker has
run for hours at NRestarts=0 with a heartbeat refreshing each minute.
2026-10-05 20:25:22 +02:00

120 lines
4.0 KiB
JavaScript

// Fail before listening if an installation has no valid runtime configuration.
import { spawn } from "node:child_process";
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
/** Fraction of the container memory limit V8 is allowed to use for its heap.
* The rest has to cover native allocations the JS heap cannot account for:
* the mysql2 pool buffers, sharp's image pipeline, and zlib during a burst of
* RSC rendering. */
const HEAP_FRACTION = 0.7;
const MIN_HEAP_MB = 512;
/** Backstop only. The fraction is the real policy: on a 6 GB container it asks
* for 4300 MB, and a backstop at or below that would silently turn the fraction
* into a fixed number and make the two limits disagree. This exists purely so a
* nonsensical cgroup reading cannot ask for an unbounded heap. */
const MAX_HEAP_MB = 8192;
export function heapLimitMb(cgroupLimitBytes) {
if (!Number.isFinite(cgroupLimitBytes) || cgroupLimitBytes <= 0)
return MAX_HEAP_MB;
const mb = Math.floor((cgroupLimitBytes * HEAP_FRACTION) / (1024 * 1024));
return Math.min(MAX_HEAP_MB, Math.max(MIN_HEAP_MB, mb));
}
/**
* Read this container's memory ceiling from cgroup v2, falling back to v1.
* Without this the V8 heap defaults to a quarter of *host* memory, so a 4 GB
* container on a 24 GB host lets the heap grow past the limit and the kernel
* OOM-kills the process mid-request — which is what produced the
* `next-build (v16)` kills in the host logs. A container that GCs before it
* reaches the ceiling degrades to a slower page instead of a killed process.
*/
export function detectMemoryLimitMb(readFile = readFileSync) {
const candidates = [
"/sys/fs/cgroup/memory.max",
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
];
for (const path of candidates) {
let raw;
try {
raw = readFile(path, "utf8").trim();
} catch {
continue;
}
// cgroup v1 reports "max" for an unlimited cgroup; v2 uses a bare
// sentinel of a very large number on some kernels.
if (raw === "max" || raw === "") continue;
const bytes = Number(raw);
if (!Number.isFinite(bytes) || bytes <= 0) continue;
// A host-sized "limit" means no cgroup ceiling was applied.
if (bytes >= Number.MAX_SAFE_INTEGER) continue;
return heapLimitMb(bytes);
}
return heapLimitMb(Number.NaN);
}
export function runtimeNodeOptions(
existing = "",
heapMb = detectMemoryLimitMb(),
) {
const flag = `--max-old-space-size=${heapMb}`;
if (!existing.trim()) return flag;
// Respect an explicit operator override; only add the cap when absent.
if (existing.includes("--max-old-space-size")) return existing;
return `${existing} ${flag}`;
}
export function validateRuntime(settings) {
const invalid = [];
if (!settings.HOTEL_NAME?.trim() || settings.HOTEL_NAME === "Build fixture")
invalid.push("HOTEL_NAME");
if (
!settings.AUTH_SECRET ||
settings.AUTH_SECRET.length < 32 ||
settings.AUTH_SECRET.startsWith("build-fixture-")
)
invalid.push("AUTH_SECRET");
for (const [key, protocols] of [
["DATABASE_URL", ["mysql:"]],
["APP_URL", ["http:", "https:"]],
]) {
try {
if (!protocols.includes(new URL(settings[key]).protocol))
invalid.push(key);
} catch {
invalid.push(key);
}
}
if (invalid.length)
throw new Error(`Invalid runtime configuration: ${invalid.join(", ")}`);
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
try {
validateRuntime(process.env);
const heapMb = detectMemoryLimitMb();
const nodeOptions = runtimeNodeOptions(process.env.NODE_OPTIONS, heapMb);
console.log(`Starting CMS with a ${heapMb} MB V8 heap cap`);
const child = spawn(process.execPath, ["server.js"], {
stdio: "inherit",
env: { ...process.env, NODE_OPTIONS: nodeOptions },
});
for (const signal of ["SIGTERM", "SIGINT"])
process.on(signal, () => child.kill(signal));
child.on("error", () => {
console.error("CMS process could not start");
process.exitCode = 1;
});
child.on("exit", (code) => {
process.exitCode = code ?? 1;
});
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}