fix(deploy): verify Docker clone updates against the served release

This commit is contained in:
Simo committed 2026-09-07 21:17:34 +02:00
1 parent 3bac126ace
commit cbaa115d56
15 files changed
+321 -187

No files matched your search

+3
View File
@@ -22,3 +22,6 @@ bun.lockb
# Runtime write targets; bound as RW volumes at runtime (see docker-compose.yml)
public/nitro-assets
public/swf
.deploy.lock
logs
+3
View File
@@ -41,3 +41,6 @@ blob-report/
.aider*
/public/vendor/tinymce/
# Shared deployment lock (never application source)
.deploy.lock
+9 -13
View File
@@ -1,19 +1,11 @@
# syntax=docker/dockerfile:1
# node:alpine = latest Node within the supported LTS major (tracks the newest
# patch automatically; currently v26.x, which satisfies package.json's
# engines ">=26.8.1 <27").
FROM node:alpine AS builder
# Pin the runtime to the supported engine; update both stages deliberately.
FROM node:26.8.1-alpine AS builder
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
# Real release id supplied by CI (ci-deploy.sh) so next.config.ts skips git
# entirely (there is no .git in the build context). Defaults to "unknown".
ARG NEXT_DEPLOYMENT_ID="unknown"
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
# pnpm install is always pinned to the version in package.json's
# `packageManager` field (pnpm auto-selects it on install), so this global
# install only needs to exist as a bootstrap and follows the active major.
# Keep the bootstrap aligned with package.json packageManager.
RUN apk add --no-cache git \
&& npm install -g pnpm@latest
&& npm install -g pnpm@11.25.0
# pnpm-workspace.yaml + .npmrc must be present too: the lockfile records the
# overrides from pnpm-workspace.yaml, and --frozen-lockfile rejects a build
# where the workspace config is absent (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH).
@@ -23,9 +15,13 @@ COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
RUN pnpm fetch --ignore-scripts
RUN pnpm install --frozen-lockfile --ignore-scripts --offline
COPY . .
ARG NEXT_DEPLOYMENT_ID="unknown"
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
RUN pnpm run build
FROM node:alpine AS runner
FROM node:26.8.1-alpine AS runner
ARG NEXT_DEPLOYMENT_ID="unknown"
LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
+67 -56
View File
@@ -137,79 +137,89 @@ pm2 stop next 2>/dev/null || true
# 3. Ensure the write directories are owned by www-data (UID/GID 33) so the
# container user can write imports/uploads to them.
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
sudo mkdir -p ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata
# 4. Build and start
docker compose up -d --build
# 4. Build, migrate and start a release tied to the Git commit.
# Requires Linux, Bash, Git, flock and Docker Compose v2; no host Node/pnpm.
bash scripts/docker-update.sh
# 5. Run migrations. The slim runtime image has no source/tsx, so run migrations
# on the HOST (against the same database) before/after starting the container.
pnpm db:migrate # or: npm run db:migrate / yarn db:migrate
# 6. Check logs
# 5. Follow logs
docker compose logs -f cms
```
The CMS will be available at `http://localhost:3002`.
The CMS listens on port 3002 using Linux host networking. Existing database,
Redis, emulator and shared gamedata services must already be configured.
Create the mounted directories before installation; do not mount the checkout,
`.next` or `node_modules` over `/app` inside the production container.
> **Reverse proxy:** This setup is designed to run behind the existing nginx on the host. nginx serves the Nitro/Octane client (`/client/`, `/nitro-client/`) and `/gamedata/` directly from `/var/www/Octane/dist` and `/var/www/Gamedata`, and proxies `/` to the CMS on `127.0.0.1:3002`. Point `APP_URL`/`NEXT_PUBLIC_APP_URL` at the public site URL.
### Updating a Docker clone
### Automatic Updates
Updating is fully scripted — no need to touch Docker or nginx by hand. The
script `scripts/docker-update.sh` pulls the latest `main`, runs CMS migrations
on the host, rebuilds the image, recreates the container and waits for a healthy
status. It also makes sure a stale host-side PM2 CMS (`pm2 stop next`) stays
stopped so it can't clash on port 3002.
**Automatically (recommended):** a nightly cron job is already configured on a
production server that installed this setup. It runs the script every night at
03:30 and appends to `logs/docker-update.cron.log`:
From your clone, run:
```bash
crontab -e
# 30 3 * * * /var/www/atom-nexst/scripts/docker-update.sh >> /var/www/atom-nexst/logs/docker-update.cron.log 2>&1
# Also verifies that your public domain serves the expected commit:
CMS_PUBLIC_URL=https://your-hotel.example bash scripts/docker-update.sh
```
**Manually** — to update right now (same steps as the cron runs):
The script follows the **current branch's configured Git upstream**. It refuses
local uncommitted/untracked work, pulls fast-forward only and restarts itself if
the updater changed. It does not reset or delete local work. Configure the
upstream to your fork/branch if that is where you receive updates.
It builds a commit-tagged image and runs migrations in the matching builder
container, using the clone's `.env`; no host dependency installation is needed.
Only after build and migrations succeed does it recreate the CMS service. It
compares the running image ID, image revision and `/api/health` release with the
expected Git commit. With `CMS_PUBLIC_URL`, it also checks the domain through
your proxy/CDN. A healthy response from another release is an error.
`git pull` alone updates source files, not an existing container. `docker compose
restart` restarts the same image. `docker compose pull` downloads registry images;
it does not fetch changes to this Git-built CMS. Use the updater for releases.
A clone does **not** install an automatic scheduler. If desired, explicitly add a
cron entry with the absolute path of **your** checkout; keep logs in its `logs`
directory. Do not configure both CI and Compose updates for the same instance.
The updater refuses an active `epicnext-cms-app` CI-managed container.
Logs: `logs/docker-update.log`. A failure after recreation leaves the candidate
running for diagnosis and returns nonzero; this Compose updater does not promise
automatic application or database rollback. Persistent volumes are preserved.
Before production migrations, retain your normal database backup. The existing
CI deploy continues to restore its previous container on failed verification.
### Diagnose an update that is not visible
```bash
cd /var/www/atom-nexst
./scripts/docker-update.sh
# log: logs/docker-update.log
git rev-parse HEAD
docker inspect --format '{{.Image}}' epicnext-cms
docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' epicnext-cms
curl -fsS http://127.0.0.1:3002/api/health
curl -fsS https://your-hotel.example/api/health
```
The script aborts safely (exit 1) if the working tree has uncommitted changes so
a `git pull` can never clobber local edits, and leaves the container running if
health fails so you can debug it (exit 3). Failed runs are reported in the log;
an exit of 0 means the CMS is healthy on the new commit.
Both HTTP responses must report the expected `release`. `unknown` means the image
was built without a commit ID. If the local endpoint is current but the public
one is old, check nginx's upstream, other CMS processes and proxy/CDN caching.
Health responses must not be cached. The release is compiled into Next.js and
cannot be changed simply by injecting a new variable into an old container.
### Docker Commands
### Docker commands
| Command | Description |
| ------------------------------------------ | ------------------------------------ |
| `docker compose up -d --build` | Build and start in background |
| `docker compose down` | Stop and remove containers |
| `docker compose logs -f cms` | Follow CMS logs |
| `docker compose exec cms sh` | Open a shell in the CMS container |
| `docker compose restart cms` | Restart the CMS container |
| `docker compose pull && docker compose up -d --build` | Update and redeploy |
| `pnpm db:migrate` (on the **host**) | Run database migrations (slim image has no source) |
| `./scripts/docker-update.sh` | Full automated update (manual or cron) |
| `pnpm db:up` / `pnpm db:down` | Start / stop the `mariadb-turbo` bulk-load container |
| Command | Purpose |
| --- | --- |
| `bash scripts/docker-update.sh` | Pull, build, migrate, recreate and verify |
| `docker compose logs -f cms` | View CMS logs |
| `docker compose exec cms sh` | Open a shell in the running CMS |
| `docker compose restart cms` | Restart the existing release |
| `docker compose down` | Stop services; does not update code |
| `pnpm db:up` / `pnpm db:down` | Manage the optional MariaDB service |
### How Package Manager Detection Works
The Dockerfile checks for lockfiles in this order:
1. **`pnpm-lock.yaml`** → uses pnpm (fastest, recommended)
2. **`yarn.lock`** → uses yarn
3. **`package-lock.json`** → uses npm
4. **No lockfile** → falls back to `npm install`
This means you can use any package manager on your host machine — the Docker build will automatically match.
> Override the detection explicitly with `docker compose build --build-arg PACKAGE_MANAGER=pnpm` (or `npm` / `yarn`).
For a manual build, export `CMS_RELEASE=$(git rev-parse HEAD)` before
`docker compose build cms`; deployment and migration verification remain your
responsibility. Docker uses the committed pnpm lockfile and pinned Node version.
The build cache can stay enabled: copying changed source invalidates the
application build layer. Deleting all Docker cache is not an update mechanism.
### Volumes
@@ -235,7 +245,8 @@ Only the CMS (and the optional avatar imager container, see [Avatar Imaging](#av
**Container user & write permissions:** the CMS container runs as `www-data` (UID/GID 33) by default to match the host owner of `/var/www/Gamedata`. Ensure the other write volumes (`./public/nitro-assets`, `./public/swf`, `./storage`) are also owned by `www-data` on the host:
```bash
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage
sudo mkdir -p ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata
sudo chown -R 33:33 ./public/nitro-assets ./public/swf ./storage /var/www/Gamedata
sudo chmod -R o+rX ./public/nitro-assets ./public/swf ./storage
```
+3
View File
@@ -1,8 +1,11 @@
services:
cms:
image: epicnext-cms:${CMS_RELEASE:-local}
build:
context: .
dockerfile: Dockerfile
args:
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
# The host disables Docker iptables (daemon.json: "iptables": false), so
# build containers on the bridge network have no outbound NAT/DNS. Build on
# the host network instead so pnpm/npm/yarn can reach the registry.
+1 -5
View File
@@ -74,11 +74,6 @@ pnpm install --frozen-lockfile
pnpm exec playwright install chromium
echo "Building $image"
# Throw away the previous build cache before each build so disk usage doesn't
# grow unbounded across deployments. The current release image (`epicnext-cms`)
# is still reused as a base layer via `--cache-from`; only the accumulated
# BuildKit intermediate cache is discarded.
docker builder prune -af --filter "until=1h" --keep-storage=0 2>/dev/null || true
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
check_current
@@ -136,6 +131,7 @@ candidate_attempted=1
"$image"
)
healthy
node --input-type=module -e 'const r=await fetch("http://127.0.0.1:3002/api/health",{cache:"no-store",signal:AbortSignal.timeout(5000)});const d=await r.json();if(!r.ok||d.release!==process.argv[1]){console.error("Release mismatch",d.release,process.argv[1]);process.exit(1)}' "$sha"
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
# Publish the latest alias only after health and browser checks pass.
docker tag "$image" epicnext-cms:latest
+7 -1
View File
@@ -124,7 +124,13 @@ check_dep git
doing "5. Port 3002 state (host CMS clash)"
if (echo >/dev/tcp/127.0.0.1/"$CMS_PORT") >/dev/null 2>&1; then
err "port $CMS_PORT already in use on host — stop the host-side CMS (pm2 stop next) before starting the container"
if [ "$(docker inspect --format '{{.State.Running}}' epicnext-cms 2>/dev/null || true)" = true ]; then
ok "existing Compose CMS is running; use docker-update.sh to recreate and verify its release"
elif [ "$(docker inspect --format '{{.State.Running}}' epicnext-cms-app 2>/dev/null || true)" = true ]; then
wrn "CI manages the running CMS; update through CI instead of starting Compose"
else
err "port $CMS_PORT is occupied by another process; identify it before starting the CMS"
fi
else
ok "port $CMS_PORT free"
fi
+54 -108
View File
@@ -1,119 +1,65 @@
#!/usr/bin/env bash
# ==============================================================================
# docker-update.sh — Automatic daily update for the Dockerized EpicNext-CMS.
#
# Steps:
# 1. Verify the working tree is clean (uncommitted changes abort).
# 2. git pull (fast-forward only).
# 3. Run CMS migrations on the HOST (the slim runtime container has no source).
# 4. docker compose build (auto-detects pnpm/yarn/npm via lockfile).
# 5. docker compose up -d && wait for a healthy container.
# 6. Record everything in update.log.
#
# Exit codes: 0 ok, 1 update skipped, 2 build/deploy failed, 3 health failed.
# ==============================================================================
set -uo pipefail
# Update a Linux Docker Compose clone from its configured Git upstream.
set -Eeuo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$DIR" || exit 2
# Share the CI lock before pulling or touching the live application.
cd "$DIR"
exec 9>"$DIR/.deploy.lock"
flock -w 1800 9 || exit 2
flock -w 1800 9
LOG_FILE="${LOG_FILE:-$DIR/logs/docker-update.log}"
PM2_APP="${PM2_APP:-next}" # host-side CMS that must stay stopped (port 3002)
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=""
trap 'if [[ -n "$migration_image" ]]; then docker image rm "$migration_image" >>"$LOG_FILE" 2>&1 || true; fi' EXIT
trap 'log "Update failed; inspect $LOG_FILE. No volumes or local files were deleted."' ERR
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE"; }
die() { log "ERROR: $*"; exit "${2:-2}"; }
touch "$LOG_FILE"
log "=== Start docker-update ==="
# --- 0. Preflight: verify this VPS is ready (permissions, ports, deps) ---
if ! "$DIR/scripts/docker-preflight.sh"; then
die "preflight failed — fix issues first (see '--fix' flag)" 1
# An existing CI deployment is a different owner of the same host port.
if [ "$(docker inspect --format '{{.State.Running}}' epicnext-cms-app 2>/dev/null || true)" = true ]; then
die "This host is managed by CI (epicnext-cms-app). Update through CI, not a second Compose deployment."
fi
log "preflight OK"
# --- 0b. Guard: uncommitted changes would break git pull / taint deploys ---
if ! { git diff --quiet --exit-code && git diff --cached --quiet --exit-code; }; then
die "working tree has uncommitted changes; commit or stash first" 1
[[ -z "$(git status --porcelain --untracked-files=normal)" ]] || die "Working tree is not clean. Commit or stash local work first."
git rev-parse --abbrev-ref --symbolic-full-name '@{upstream}' >/dev/null || die "Configure this branch's Git upstream before updating."
script_before="$(git hash-object scripts/docker-update.sh)"
git pull --ff-only >>"$LOG_FILE" 2>&1
if [ "$script_before" != "$(git hash-object scripts/docker-update.sh)" ]; then
log "Updater changed; restarting the newly pulled script."
exec 9>&-
exec bash "$DIR/scripts/docker-update.sh"
fi
# --- 1. Pull latest ---
git pull --ff-only --quiet 2>>"$LOG_FILE"
pull_status=$?
if [ $pull_status -ne 0 ]; then
die "git pull failed (status $pull_status)" 1
fi
log "git pull OK: $(git rev-parse --short HEAD)"
# --- 2. Host-side migrations (idempotent; only applies CMS-owned tables) ---
if [ -f pnpm-lock.yaml ] && command -v pnpm >/dev/null 2>&1; then
pnpm db:migrate >>"$LOG_FILE" 2>&1 || die "db:migrate (pnpm) failed"
elif command -v npm >/dev/null 2>&1; then
npm run db:migrate >>"$LOG_FILE" 2>&1 || die "db:migrate (npm) failed"
else
die "no package manager found for migrations" 2
fi
log "db:migrate OK"
# --- 3. Node major gate (patches auto, major upgrades need review) ---
# `node:alpine` floats within, then across, Node majors. Patches/minors are
# safe to apply silently; a NEW major (e.g. 26 -> 27) is a breaking risk for
# native addons / Next compatibility, so require an explicit review before it
# goes live. Compare the major of the deployed runtime image vs the floating
# tag; abort (not deploy) when they differ.
deployed_major="$(docker inspect --format '{{.Config.Image}}' epicnext-cms 2>/dev/null || true)"
# Resolve the currently-deployed Node major from its image.
if [ -n "$deployed_major" ] && docker image inspect "$deployed_major" >/dev/null 2>&1; then
deployed_major="$(docker run --rm --entrypoint sh "$deployed_major" -c 'node -p "process.versions.node.split(\".\")[0]"' 2>/dev/null || true)"
fi
float_major="$(docker run --rm --entrypoint sh node:alpine -c 'node -p "process.versions.node.split(\".\")[0]"' 2>/dev/null || true)"
if [ -n "$deployed_major" ] && [ -n "$float_major" ] && [ "$deployed_major" != "$float_major" ]; then
die "Node major change detected (deployed v$deployed_major, floating tag v$float_major). Major upgrades require review; update engines/Dockerfile deliberately first." 1
fi
log "Node major gate OK (major=${float_major:-?})"
# --- 4. Rebuild the image ---
# Reset the BuildKit cache first so the build doesn't accumulate unbounded
# layers on disk across daily rebuilds.
docker builder prune -af --filter "until=1h" --keep-storage=0 2>>"$LOG_FILE" || true
docker compose build >>"$LOG_FILE" 2>&1 || die "docker compose build failed" 2
log "docker compose build OK"
# --- 5. Recreate the container ---
docker compose up -d >>"$LOG_FILE" 2>&1 || die "docker compose up failed" 2
log "docker compose up OK"
# --- 6. Wait for health (up to ~4 min) ---
export CMS_RELEASE="$(git rev-parse HEAD)"
[[ "$CMS_RELEASE" =~ ^[0-9a-f]{40}$ ]] || die "Invalid Git commit."
[[ -f .env ]] || die "Create .env before installing or updating."
docker info >/dev/null
docker compose config --quiet
log "Building release $CMS_RELEASE from $DIR"
# The builder contains the matching migration 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
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
log "Build and migrations completed; recreating only the CMS service."
docker compose up -d --no-deps --no-build --force-recreate cms >>"$LOG_FILE" 2>&1
container="$(docker compose ps -q cms)"
[[ -n "$container" ]] || die "Compose did not start the CMS container."
actual_image="$(docker inspect --format '{{.Image}}' "$container")"
[[ "$actual_image" = "$expected_image" ]] || die "Running image $actual_image differs from built image $expected_image."
# Verify the actual HTTP response, not an environment variable supplied at run time.
probe='const r=await fetch(process.argv[1],{cache:"no-store",signal:AbortSignal.timeout(5000)});const d=await r.json();if(!r.ok||d.database!==true||d.release!==process.argv[2]){console.error(JSON.stringify({http:r.status(),database:d.database,release:d.release,expected:process.argv[2]}));process.exit(1)}'
healthy=0
for i in $(seq 1 16); do
status="$(docker inspect --format='{{.State.Health.Status}}' epicnext-cms 2>/dev/null || true)"
case "$status" in
healthy) healthy=1; break ;;
unhealthy) break ;;
esac
sleep 15
for attempt in $(seq 1 30); do
if docker exec "$container" node --input-type=module -e "$probe" "http://127.0.0.1:3002/api/health" "$CMS_RELEASE" >>"$LOG_FILE" 2>&1; then healthy=1; break; fi
sleep 3
done
if [ "$healthy" -eq 1 ]; then
log "CMS healthy after update (commit $(git rev-parse --short HEAD))"
[[ "$healthy" = 1 ]] || die "HTTP health/release verification failed. The candidate remains available for diagnosis; no success was recorded."
if [[ -n "${CMS_PUBLIC_URL:-}" ]]; then
[[ "$CMS_PUBLIC_URL" = https://* || "$CMS_PUBLIC_URL" = http://* ]] || die "CMS_PUBLIC_URL must be an HTTP(S) URL."
docker exec "$container" node --input-type=module -e "$probe" "${CMS_PUBLIC_URL%/}/api/health?release=$CMS_RELEASE" "$CMS_RELEASE" >>"$LOG_FILE" 2>&1 || die "Public domain serves another release or is unhealthy. Check reverse proxy/CDN destination."
log "Public URL verified: $CMS_PUBLIC_URL"
else
log "WARNING: container not healthy (status='${status:-unknown}')"
# Leave the container running so it can be debugged; report failure exit.
exit 3
log "Public domain was not checked. Set CMS_PUBLIC_URL to verify reverse proxy/CDN routing as well."
fi
# --- 7. Make sure the stale host-side PM2 CMS stays stopped ---
if command -v pm2 >/dev/null 2>&1 && pm2 jlist >/dev/null 2>&1; then
if pm2 list 2>/dev/null | grep -q "${PM2_APP}"; then
pm2 stop "$PM2_APP" >/dev/null 2>&1 && log "pm2 '${PM2_APP}' kept stopped (avoids port 3002 clash)"
fi
fi
log "=== docker-update finished OK ==="
exit 0
log "Verified release $CMS_RELEASE, image $actual_image, container $container"
+1
View File
@@ -57,6 +57,7 @@ export async function GET() {
emulator,
resend: resendAvailable,
node: process.version,
release: process.env.NEXT_PUBLIC_CMS_RELEASE ?? "unknown",
uptime: Math.round(process.uptime()),
time: new Date().toISOString(),
});
+6 -1
View File
@@ -80,7 +80,12 @@ describe("deployment transaction", () => {
"docker tag sha256:old epicnext-cms:previous",
);
});
it.each(["run-failure", "health-failure", "smoke-failure"])(
it.each([
"run-failure",
"health-failure",
"smoke-failure",
"release-failure",
])(
"restores the exact previous container after %s",
(scenario) => {
const result = simulate(scenario);
+13
View File
@@ -29,3 +29,16 @@ describe("Docker build cache", () => {
expect(dockerfile).not.toContain("yarn install --production");
});
});
it("passes a compiled release to both the application build and final image", () => {
expect(dockerfile.indexOf("ARG NEXT_DEPLOYMENT_ID")).toBeGreaterThan(
dockerfile.indexOf("COPY . ."),
);
expect(dockerfile).toContain(
'LABEL org.opencontainers.image.revision="$NEXT_DEPLOYMENT_ID"',
);
expect(dockerfile.match(/FROM node:26\.8\.1-alpine/g)).toHaveLength(2);
const compose = readFileSync("docker-compose.yml", "utf8");
// biome-ignore lint/suspicious/noTemplateCurlyInString: Docker Compose interpolation, not JavaScript.
expect(compose).toContain("NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}");
});
+118
View File
@@ -0,0 +1,118 @@
import { spawnSync } from "node:child_process";
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, dirname, join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
const root = process.cwd();
const bash =
process.platform === "win32"
? ((process.env.PATH ?? "")
.split(delimiter)
.flatMap((dir) => [
join(dir, "bash.exe"),
join(dirname(dir), "bin", "bash.exe"),
join(dirname(dirname(dir)), "bin", "bash.exe"),
])
.find((path) => existsSync(path)) ?? "bash")
: "bash";
const sha = "a".repeat(40);
function simulate(scenario: string) {
const dir = mkdtempSync(join(tmpdir(), "cms-compose-test-"));
try {
mkdirSync(join(dir, "scripts"));
copyFileSync(
resolve(root, "scripts/docker-update.sh"),
join(dir, "scripts/docker-update.sh"),
);
writeFileSync(join(dir, ".env"), "HOTEL_NAME=Test\n");
const result = spawnSync(bash, [join(dir, "scripts/docker-update.sh")], {
cwd: dir,
encoding: "utf8",
timeout: 25000,
env: {
...process.env,
BASH_ENV: resolve(root, "src/test/docker-update-harness.sh"),
TEST_DIR: dir.replaceAll("\\", "/"),
TEST_SHA: sha,
SCENARIO: scenario,
CMS_PUBLIC_URL: "https://example.test",
},
});
if (result.error) throw result.error;
return {
status: result.status,
output: result.stdout + result.stderr,
calls: existsSync(join(dir, "calls"))
? readFileSync(join(dir, "calls"), "utf8")
: "",
};
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
describe("Docker clone updates", () => {
it("builds the pulled commit, migrates before recreation and verifies local/public HTTP", () => {
const r = simulate("success");
expect(r.status, r.output).toBe(0);
expect(r.calls).toContain(`--build-arg NEXT_DEPLOYMENT_ID=${sha}`);
expect(r.calls.indexOf("db:migrate")).toBeLessThan(
r.calls.indexOf("compose up"),
);
expect(r.calls).toContain(
"up -d --no-deps --no-build --force-recreate cms",
);
expect(r.calls).toContain("https://example.test/api/health");
expect(r.output).toContain(`Verified release ${sha}`);
expect(r.calls).not.toContain("prune");
});
it.each([
"dirty",
"ci-active",
"pull-failure",
"build-failure",
"migration-failure",
])("does not replace the container after %s", (scenario) => {
const r = simulate(scenario);
expect(r.status, r.output).not.toBe(0);
expect(r.calls).not.toContain("compose up");
});
it.each(["wrong-image", "wrong-release", "wrong-public", "recreate-failure"])(
"never reports success for %s",
(scenario) => {
const r = simulate(scenario);
expect(r.status, r.output).not.toBe(0);
expect(r.output).not.toContain("Verified release");
},
);
});
describe("HTTP release verification", () => {
const script = readFileSync("scripts/docker-update.sh", "utf8");
const probe = script.match(/^probe='(.+)'$/m)?.[1];
it.each([
["current", { database: true, release: sha }, 200, 0],
["old release", { database: true, release: "old" }, 200, 1],
["unknown release", { database: true, release: "unknown" }, 200, 1],
["database down", { database: false, release: sha }, 200, 1],
["HTTP failure", { database: true, release: sha }, 503, 1],
])("checks %s", (_name, body, status, expected) => {
expect(probe).toBeTruthy();
const code = `import {createServer} from "node:http";const server=createServer((q,r)=>{r.writeHead(${status},{"content-type":"application/json"});r.end(${JSON.stringify(JSON.stringify(body))});});await new Promise(resolve=>server.listen(0,"127.0.0.1",resolve));process.argv=[process.execPath,"http://127.0.0.1:"+server.address().port,${JSON.stringify(sha)}];try{${probe}}finally{server.close();}`;
const result = spawnSync(
process.execPath,
["--input-type=module", "-e", code],
{ encoding: "utf8", timeout: 10000 },
);
expect(result.error).toBeUndefined();
expect(result.status, result.stderr).toBe(expected);
});
});
+1 -3
View File
@@ -1,15 +1,13 @@
import { execFileSync } from "node:child_process";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import { mkdtemp, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
CatalogExportQueue,
publishCatalogFiles,
recoverCatalogQueue,
sqlValue,
} from "./catalog-git-core";
import { gitProcessEnvironment } from "./git-process-environment";
describe("catalog export", () => {
it("recovers queue entries owned by a terminated local process", async () => {
+3
View File
@@ -42,3 +42,6 @@ docker() {
esac
}
export -f git flock pnpm curl sleep docker
node() { echo "node $*" >> "$TEST_DIR/calls"; [ "$SCENARIO" != release-failure ]; }
export -f node
+32
View File
@@ -0,0 +1,32 @@
# Test doubles; never calls real Docker, Git remotes or databases.
git() {
echo "git $*" >> "$TEST_DIR/calls"
case "$1" in
status) if [ "$SCENARIO" = dirty ]; then echo ' M local.ts'; fi ;;
hash-object) echo unchanged ;;
rev-parse) if [ "${2:-}" = HEAD ]; then echo "$TEST_SHA"; else echo origin/main; fi ;;
pull) [ "$SCENARIO" != pull-failure ] ;;
esac
}
flock() { :; }
sleep() { :; }
docker() {
echo "docker $*" >> "$TEST_DIR/calls"
case "$1 ${2:-}" in
'inspect --format')
if [ "${@: -1}" = epicnext-cms-app ]; then [ "$SCENARIO" = ci-active ] && echo true; return 0; fi
if [ "$SCENARIO" = wrong-image ]; then echo sha256:old; else echo sha256:new; fi ;;
'image inspect')
if [[ "$*" = *org.opencontainers* ]]; then echo "$TEST_SHA"; else echo sha256:new; fi ;;
'compose config') return 0 ;;
'compose build') [ "$SCENARIO" != build-failure ] ;;
'compose up') [ "$SCENARIO" != recreate-failure ] ;;
'compose ps') echo container123 ;;
'run --rm') [ "$SCENARIO" != migration-failure ] ;;
'exec container123')
if [ "$SCENARIO" = wrong-release ]; then return 1; fi
if [ "$SCENARIO" = wrong-public ] && [[ "$*" = *example.test* ]]; then return 1; fi ;;
*) return 0 ;;
esac
}
export -f git flock sleep docker