feat(docker): prepare portable images with runtime hotel configuration
CI / check (push) Successful in 1m6s
CI / deploy (push) Successful in 1m23s

This commit is contained in:
Simo committed 2026-09-07 22:37:53 +02:00
1 parent 745d0b7247
commit c4496e710b
28 files changed
+622 -104

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
// Fail before listening if an installation has no valid runtime configuration.
import { spawn } from "node:child_process";
import { pathToFileURL } from "node:url";
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 (import.meta.url === pathToFileURL(process.argv[1]).href) {
try {
validateRuntime(process.env);
const child = spawn(process.execPath, ["server.js"], { stdio: "inherit" });
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;
}
}
+23 -4
View File
@@ -10,6 +10,7 @@ mkdir -p "$(dirname "$LOG_FILE")"
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE"; }
die() { log "ERROR: $*"; exit 1; }
migration_image=""
remote_migration_image=""
previous_image=""
previous_release=""
rollback_tag=""
@@ -45,6 +46,7 @@ finish() {
fi
fi
if [[ -n "$migration_image" ]]; then docker image rm "$migration_image" >>"$LOG_FILE" 2>&1 || true; fi
if [[ -n "$remote_migration_image" ]]; then docker image rm "$remote_migration_image" >>"$LOG_FILE" 2>&1 || true; fi
# Keep the recovery tag on failure for manual recovery, including same-commit rebuilds.
if [[ ( "$status" = 0 || "$cutover" = 0 ) && -n "$rollback_tag" ]]; then docker image rm "epicnext-cms:$rollback_tag" >>"$LOG_FILE" 2>&1 || true; fi
exit "$status"
@@ -81,15 +83,27 @@ if [[ -n "$previous_container" ]]; then
docker image tag "$previous_image" "epicnext-cms:$rollback_tag"
fi
log "Building release $CMS_RELEASE from $DIR"
# The builder contains the matching migration source and locked dependencies.
# The migrations stage contains matching source and locked dependencies.
# No Node/package manager installation on the host is required.
migration_image="epicnext-cms-migrations:$CMS_RELEASE"
docker build --network=host --target builder --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" -t "$migration_image" . >>"$LOG_FILE" 2>&1
docker compose build --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" cms >>"$LOG_FILE" 2>&1
if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then
[[ "$CMS_IMAGE_REPOSITORY" =~ ^[a-z0-9.-]+(:[0-9]+)?/[a-z0-9._/-]+$ ]] || die "Invalid CMS_IMAGE_REPOSITORY; use registry/owner/image without a tag."
log "Pulling prebuilt application and matching migrations for $CMS_RELEASE"
docker pull "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE" >>"$LOG_FILE" 2>&1
remote_migration_image="$CMS_IMAGE_REPOSITORY:$CMS_RELEASE-migrations"
docker pull "$remote_migration_image" >>"$LOG_FILE" 2>&1
docker tag "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE" "epicnext-cms:$CMS_RELEASE"
docker tag "$CMS_IMAGE_REPOSITORY:$CMS_RELEASE-migrations" "$migration_image"
else
docker build --network=host --target migrations --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" -t "$migration_image" . >>"$LOG_FILE" 2>&1
docker compose build --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" cms >>"$LOG_FILE" 2>&1
fi
expected_image="$(docker image inspect --format '{{.Id}}' "epicnext-cms:$CMS_RELEASE")"
revision="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$expected_image")"
[[ "$revision" = "$CMS_RELEASE" ]] || die "Built image has revision $revision, expected $CMS_RELEASE."
docker run --rm --network host --entrypoint pnpm "$migration_image" db:migrate >>"$LOG_FILE" 2>&1
migration_mounts=(--mount "type=bind,source=$DIR/.env,target=/app/.env,readonly")
if [[ -f "$DIR/.env.local" ]]; then migration_mounts+=(--mount "type=bind,source=$DIR/.env.local,target=/app/.env.local,readonly"); fi
docker run --rm --network host "${migration_mounts[@]}" --entrypoint pnpm "$migration_image" db:migrate >>"$LOG_FILE" 2>&1
log "Build and migrations completed; recreating only the CMS service."
cutover=1
docker compose up -d --no-deps --no-build --force-recreate cms >>"$LOG_FILE" 2>&1
@@ -120,6 +134,11 @@ for release in "${releases[@]}"; do
if [[ "${#kept[@]}" -lt 2 ]]; then kept+=("$release"); continue; fi
# Even stopped containers belonging to other deployments protect an image.
if users="$(docker ps -aq --filter "ancestor=epicnext-cms:$release")" && [[ -z "$users" ]] && docker image rm "epicnext-cms:$release" >>"$LOG_FILE" 2>&1; then
if [[ -n "${CMS_IMAGE_REPOSITORY:-}" ]]; then
if remote_users="$(docker ps -aq --filter "ancestor=$CMS_IMAGE_REPOSITORY:$release")" && [[ -z "$remote_users" ]]; then
docker image rm "$CMS_IMAGE_REPOSITORY:$release" >>"$LOG_FILE" 2>&1 || true
fi
fi
log "Removed superseded release tag $release"
else
pending+=("$release")
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
set -Eeuo pipefail
: "${REGISTRY_SERVER:?Missing Gitea server URL}"
: "${REGISTRY_REPOSITORY:?Missing owner/repository}"
: "${REGISTRY_USER:?Configure CONTAINER_REGISTRY_USER}"
: "${REGISTRY_TOKEN:?Configure CONTAINER_REGISTRY_TOKEN with package write access}"
sha="$(git rev-parse HEAD)"
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || exit 1
registry="${REGISTRY_SERVER#https://}"
registry="${registry%/}"
[[ "$REGISTRY_SERVER" = https://* && "$registry" != */* ]] || { echo "Registry must use HTTPS at the Gitea server root" >&2; exit 1; }
repository="${REGISTRY_REPOSITORY,,}"
[[ "$repository" =~ ^[a-z0-9._-]+/[a-z0-9._-]+$ ]] || exit 1
image="$registry/$repository:$sha"
# Isolate credentials from the self-hosted runner's normal Docker configuration.
export DOCKER_CONFIG
DOCKER_CONFIG="$(mktemp -d)"
context="$(mktemp -d)"
trap 'rm -rf -- "$DOCKER_CONFIG" "$context"' EXIT
# Build only the committed source, never untracked files from a shared runner.
git archive HEAD | tar -x -C "$context"
printf '%s' "$REGISTRY_TOKEN" | docker login "$registry" --username "$REGISTRY_USER" --password-stdin
unset REGISTRY_TOKEN
docker build --network=host --build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" "$context"
docker build --network=host --target migrations -t "$image-migrations" "$context"
node scripts/verify-portable-image.mjs "$image" "$sha"
# Publish only after the same application image passed both runtime configurations.
docker push "$image-migrations"
docker push "$image"
echo "Published application and migrations: $image"
+151
View File
@@ -0,0 +1,151 @@
// Runs only on a Linux Docker host. No production DB, network or volumes are used.
import { execFileSync } from "node:child_process";
import { createServer } from "node:http";
import { setTimeout as delay } from "node:timers/promises";
const [image, release] = process.argv.slice(2);
if (!image || !/^[0-9a-f]{40}$/.test(release ?? ""))
throw new Error("Usage: verify-portable-image.mjs IMAGE COMMIT");
const docker = (...args) =>
execFileSync("docker", args, { encoding: "utf8", timeout: 60000 }).trim();
const id = docker("image", "inspect", "--format", "{{.Id}}", image);
const inspect = JSON.parse(docker("image", "inspect", image))[0];
if (
(inspect.Config.Env ?? []).some((value) =>
/^(DATABASE_URL|AUTH_SECRET|HOTEL_NAME)=/.test(value),
)
)
throw new Error("Image contains installation configuration");
docker(
"run",
"--rm",
"--entrypoint",
"node",
image,
"-e",
'for(const p of [".env",".env.local",".env.production",".env.production.local"]){if(require("fs").existsSync(p))throw Error("Environment file in image: "+p)}',
);
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a9xkAAAAASUVORK5CYII=",
"base64",
);
const requests = [];
const upstream = createServer((req, res) => {
requests.push(req.url);
res.writeHead(200, { "content-type": "image/png" });
res.end(png);
});
await new Promise((resolve) => upstream.listen(0, "127.0.0.1", resolve));
const origin = `http://127.0.0.1:${upstream.address().port}`;
try {
for (const hotel of ["alpha", "beta"]) {
const reservation = createServer();
await new Promise((resolve) => reservation.listen(0, "127.0.0.1", resolve));
const port = reservation.address().port;
await new Promise((resolve) => reservation.close(resolve));
const name = `cms-portability-${process.pid}-${hotel}`;
const base = `http://127.0.0.1:${port}`;
try {
docker(
"run",
"--detach",
"--name",
name,
"--network",
"host",
"--env",
`PORT=${port}`,
"--env",
"HOSTNAME=127.0.0.1",
"--env",
`HOTEL_NAME=Fixture ${hotel}`,
"--env",
`APP_URL=${base}`,
"--env",
`AUTH_SECRET=portability-${hotel}-not-a-production-secret-000000`,
"--env",
"DATABASE_URL=mysql://fixture:[email protected]:9/fixture",
"--env",
"DATABASE_CONNECT_TIMEOUT_MS=500",
"--env",
"RCON_PORT=9",
"--env",
"RCON_TIMEOUT_MS=100",
"--env",
"RCON_MAX_RETRIES=1",
"--env",
"AUTH_TRUST_HOST=true",
"--env",
`IMAGER_URL=${origin}/${hotel}/avatar`,
"--env",
`BADGE_URL=${origin}/${hotel}/badges`,
id,
);
let ready = false;
for (let attempt = 0; attempt < 30; attempt++) {
try {
const r = await fetch(`${base}/api/health`, {
signal: AbortSignal.timeout(2000),
});
if ((await r.json()).release === release) {
ready = true;
break;
}
} catch {}
await delay(1000);
}
if (!ready) throw new Error(`${hotel}: expected HTTP release not served`);
const page = await fetch(`${base}/login`, {
signal: AbortSignal.timeout(30000),
});
const html = await page.text();
if (
!page.ok ||
!html.includes(`Fixture ${hotel}`) ||
!html.includes(base)
)
throw new Error(
`${hotel}: hotel name/domain were not resolved at runtime`,
);
const manifest = await fetch(`${base}/manifest.webmanifest`, {
signal: AbortSignal.timeout(15000),
});
if ((await manifest.json()).name !== `Fixture ${hotel}`)
throw new Error(`${hotel}: manifest contains build-time settings`);
for (const path of ["/robots.txt", "/sitemap.xml"]) {
const response = await fetch(base + path, {
signal: AbortSignal.timeout(15000),
});
if (!response.ok || !(await response.text()).includes(base))
throw new Error(`${hotel}: ${path} contains build-time domain`);
}
const avatar = await fetch(
`${base}/api/imaging/avatar?figure=hd-180-1&img_format=png`,
{ signal: AbortSignal.timeout(15000) },
);
if (
!avatar.ok ||
!requests.some((url) => url.startsWith(`/${hotel}/avatar?`))
)
throw new Error(`${hotel}: wrong avatar upstream`);
const badge = await fetch(`${base}/api/imaging/badge?code=ADM`, {
redirect: "manual",
signal: AbortSignal.timeout(15000),
});
if (badge.headers.get("location") !== `${origin}/${hotel}/badges/ADM.gif`)
throw new Error(`${hotel}: wrong badge URL`);
console.log(
`Verified ${hotel}: same image ${id}, runtime avatar and badge configuration, release ${release}`,
);
} catch (error) {
console.error(docker("logs", name, "--tail", "40"));
throw error;
} finally {
try {
docker("rm", "--force", name);
} catch {}
}
}
} finally {
await new Promise((resolve) => upstream.close(resolve));
}