fix(deploy): verify Docker clone updates against the served release
CI / check (push) Successful in 59s
CI / deploy (push) Failing after 1m21s

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) # Runtime write targets; bound as RW volumes at runtime (see docker-compose.yml)
public/nitro-assets public/nitro-assets
public/swf public/swf
.deploy.lock
logs
+3
View File
@@ -41,3 +41,6 @@ blob-report/
.aider* .aider*
/public/vendor/tinymce/ /public/vendor/tinymce/
# Shared deployment lock (never application source)
.deploy.lock
+9 -13
View File
@@ -1,19 +1,11 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1
# node:alpine = latest Node within the supported LTS major (tracks the newest # Pin the runtime to the supported engine; update both stages deliberately.
# patch automatically; currently v26.x, which satisfies package.json's FROM node:26.8.1-alpine AS builder
# engines ">=26.8.1 <27").
FROM node:alpine AS builder
WORKDIR /app WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# Real release id supplied by CI (ci-deploy.sh) so next.config.ts skips git # Keep the bootstrap aligned with package.json packageManager.
# 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.
RUN apk add --no-cache git \ 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 # pnpm-workspace.yaml + .npmrc must be present too: the lockfile records the
# overrides from pnpm-workspace.yaml, and --frozen-lockfile rejects a build # overrides from pnpm-workspace.yaml, and --frozen-lockfile rejects a build
# where the workspace config is absent (ERR_PNPM_LOCKFILE_CONFIG_MISMATCH). # 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 fetch --ignore-scripts
RUN pnpm install --frozen-lockfile --ignore-scripts --offline RUN pnpm install --frozen-lockfile --ignore-scripts --offline
COPY . . COPY . .
ARG NEXT_DEPLOYMENT_ID="unknown"
ENV NEXT_DEPLOYMENT_ID="$NEXT_DEPLOYMENT_ID"
RUN pnpm run build 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 WORKDIR /app
ENV NODE_ENV=production \ ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \ 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 # 3. Ensure the write directories are owned by www-data (UID/GID 33) so the
# container user can write imports/uploads to them. # 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 # 4. Build, migrate and start a release tied to the Git commit.
docker compose up -d --build # 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 # 5. Follow logs
# 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
docker compose logs -f cms 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 From your clone, run:
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`:
```bash ```bash
crontab -e # Also verifies that your public domain serves the expected commit:
# 30 3 * * * /var/www/atom-nexst/scripts/docker-update.sh >> /var/www/atom-nexst/logs/docker-update.cron.log 2>&1 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 ```bash
cd /var/www/atom-nexst git rev-parse HEAD
./scripts/docker-update.sh docker inspect --format '{{.Image}}' epicnext-cms
# log: logs/docker-update.log 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 Both HTTP responses must report the expected `release`. `unknown` means the image
a `git pull` can never clobber local edits, and leaves the container running if was built without a commit ID. If the local endpoint is current but the public
health fails so you can debug it (exit 3). Failed runs are reported in the log; one is old, check nginx's upstream, other CMS processes and proxy/CDN caching.
an exit of 0 means the CMS is healthy on the new commit. 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 | | Command | Purpose |
| ------------------------------------------ | ------------------------------------ | | --- | --- |
| `docker compose up -d --build` | Build and start in background | | `bash scripts/docker-update.sh` | Pull, build, migrate, recreate and verify |
| `docker compose down` | Stop and remove containers | | `docker compose logs -f cms` | View CMS logs |
| `docker compose logs -f cms` | Follow CMS logs | | `docker compose exec cms sh` | Open a shell in the running CMS |
| `docker compose exec cms sh` | Open a shell in the CMS container | | `docker compose restart cms` | Restart the existing release |
| `docker compose restart cms` | Restart the CMS container | | `docker compose down` | Stop services; does not update code |
| `docker compose pull && docker compose up -d --build` | Update and redeploy | | `pnpm db:up` / `pnpm db:down` | Manage the optional MariaDB service |
| `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 |
### How Package Manager Detection Works For a manual build, export `CMS_RELEASE=$(git rev-parse HEAD)` before
`docker compose build cms`; deployment and migration verification remain your
The Dockerfile checks for lockfiles in this order: responsibility. Docker uses the committed pnpm lockfile and pinned Node version.
The build cache can stay enabled: copying changed source invalidates the
1. **`pnpm-lock.yaml`** → uses pnpm (fastest, recommended) application build layer. Deleting all Docker cache is not an update mechanism.
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`).
### Volumes ### 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: **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 ```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 sudo chmod -R o+rX ./public/nitro-assets ./public/swf ./storage
``` ```
+3
View File
@@ -1,8 +1,11 @@
services: services:
cms: cms:
image: epicnext-cms:${CMS_RELEASE:-local}
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
NEXT_DEPLOYMENT_ID: ${CMS_RELEASE:-unknown}
# The host disables Docker iptables (daemon.json: "iptables": false), so # The host disables Docker iptables (daemon.json: "iptables": false), so
# build containers on the bridge network have no outbound NAT/DNS. Build on # 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. # 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 pnpm exec playwright install chromium
echo "Building $image" 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 \ DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" . --build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
check_current check_current
@@ -136,6 +131,7 @@ candidate_attempted=1
"$image" "$image"
) )
healthy 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 PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
# Publish the latest alias only after health and browser checks pass. # Publish the latest alias only after health and browser checks pass.
docker tag "$image" epicnext-cms:latest 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)" doing "5. Port 3002 state (host CMS clash)"
if (echo >/dev/tcp/127.0.0.1/"$CMS_PORT") >/dev/null 2>&1; then 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 else
ok "port $CMS_PORT free" ok "port $CMS_PORT free"
fi fi
+54 -108
View File
@@ -1,119 +1,65 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# ============================================================================== # Update a Linux Docker Compose clone from its configured Git upstream.
# docker-update.sh — Automatic daily update for the Dockerized EpicNext-CMS. set -Eeuo pipefail
#
# 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
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$DIR" || exit 2 cd "$DIR"
# Share the CI lock before pulling or touching the live application.
exec 9>"$DIR/.deploy.lock" exec 9>"$DIR/.deploy.lock"
flock -w 1800 9 || exit 2 flock -w 1800 9
LOG_FILE="${LOG_FILE:-$DIR/logs/docker-update.log}" 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"; } # An existing CI deployment is a different owner of the same host port.
die() { log "ERROR: $*"; exit "${2:-2}"; } 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."
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
fi fi
log "preflight OK" [[ -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."
# --- 0b. Guard: uncommitted changes would break git pull / taint deploys --- script_before="$(git hash-object scripts/docker-update.sh)"
if ! { git diff --quiet --exit-code && git diff --cached --quiet --exit-code; }; then git pull --ff-only >>"$LOG_FILE" 2>&1
die "working tree has uncommitted changes; commit or stash first" 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 fi
export CMS_RELEASE="$(git rev-parse HEAD)"
# --- 1. Pull latest --- [[ "$CMS_RELEASE" =~ ^[0-9a-f]{40}$ ]] || die "Invalid Git commit."
git pull --ff-only --quiet 2>>"$LOG_FILE" [[ -f .env ]] || die "Create .env before installing or updating."
pull_status=$? docker info >/dev/null
if [ $pull_status -ne 0 ]; then docker compose config --quiet
die "git pull failed (status $pull_status)" 1 log "Building release $CMS_RELEASE from $DIR"
fi # The builder contains the matching migration source and locked dependencies.
log "git pull OK: $(git rev-parse --short HEAD)" # No Node/package manager installation on the host is required.
migration_image="epicnext-cms-migrations:$CMS_RELEASE"
# --- 2. Host-side migrations (idempotent; only applies CMS-owned tables) --- docker build --network=host --target builder --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" -t "$migration_image" . >>"$LOG_FILE" 2>&1
if [ -f pnpm-lock.yaml ] && command -v pnpm >/dev/null 2>&1; then docker compose build --build-arg NEXT_DEPLOYMENT_ID="$CMS_RELEASE" cms >>"$LOG_FILE" 2>&1
pnpm db:migrate >>"$LOG_FILE" 2>&1 || die "db:migrate (pnpm) failed" expected_image="$(docker image inspect --format '{{.Id}}' "epicnext-cms:$CMS_RELEASE")"
elif command -v npm >/dev/null 2>&1; then revision="$(docker image inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$expected_image")"
npm run db:migrate >>"$LOG_FILE" 2>&1 || die "db:migrate (npm) failed" [[ "$revision" = "$CMS_RELEASE" ]] || die "Built image has revision $revision, expected $CMS_RELEASE."
else docker run --rm --network host --entrypoint pnpm "$migration_image" db:migrate >>"$LOG_FILE" 2>&1
die "no package manager found for migrations" 2 log "Build and migrations completed; recreating only the CMS service."
fi docker compose up -d --no-deps --no-build --force-recreate cms >>"$LOG_FILE" 2>&1
log "db:migrate OK" container="$(docker compose ps -q cms)"
[[ -n "$container" ]] || die "Compose did not start the CMS container."
# --- 3. Node major gate (patches auto, major upgrades need review) --- actual_image="$(docker inspect --format '{{.Image}}' "$container")"
# `node:alpine` floats within, then across, Node majors. Patches/minors are [[ "$actual_image" = "$expected_image" ]] || die "Running image $actual_image differs from built image $expected_image."
# safe to apply silently; a NEW major (e.g. 26 -> 27) is a breaking risk for # Verify the actual HTTP response, not an environment variable supplied at run time.
# native addons / Next compatibility, so require an explicit review before it 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)}'
# 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) ---
healthy=0 healthy=0
for i in $(seq 1 16); do for attempt in $(seq 1 30); do
status="$(docker inspect --format='{{.State.Health.Status}}' epicnext-cms 2>/dev/null || true)" 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
case "$status" in sleep 3
healthy) healthy=1; break ;;
unhealthy) break ;;
esac
sleep 15
done done
[[ "$healthy" = 1 ]] || die "HTTP health/release verification failed. The candidate remains available for diagnosis; no success was recorded."
if [ "$healthy" -eq 1 ]; then if [[ -n "${CMS_PUBLIC_URL:-}" ]]; then
log "CMS healthy after update (commit $(git rev-parse --short HEAD))" [[ "$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 else
log "WARNING: container not healthy (status='${status:-unknown}')" log "Public domain was not checked. Set CMS_PUBLIC_URL to verify reverse proxy/CDN routing as well."
# Leave the container running so it can be debugged; report failure exit.
exit 3
fi fi
log "Verified release $CMS_RELEASE, image $actual_image, container $container"
# --- 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
+1
View File
@@ -57,6 +57,7 @@ export async function GET() {
emulator, emulator,
resend: resendAvailable, resend: resendAvailable,
node: process.version, node: process.version,
release: process.env.NEXT_PUBLIC_CMS_RELEASE ?? "unknown",
uptime: Math.round(process.uptime()), uptime: Math.round(process.uptime()),
time: new Date().toISOString(), time: new Date().toISOString(),
}); });
+6 -1
View File
@@ -80,7 +80,12 @@ describe("deployment transaction", () => {
"docker tag sha256:old epicnext-cms:previous", "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", "restores the exact previous container after %s",
(scenario) => { (scenario) => {
const result = simulate(scenario); const result = simulate(scenario);
+13
View File
@@ -29,3 +29,16 @@ describe("Docker build cache", () => {
expect(dockerfile).not.toContain("yarn install --production"); 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 { 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 os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
CatalogExportQueue, CatalogExportQueue,
publishCatalogFiles,
recoverCatalogQueue, recoverCatalogQueue,
sqlValue, sqlValue,
} from "./catalog-git-core"; } from "./catalog-git-core";
import { gitProcessEnvironment } from "./git-process-environment";
describe("catalog export", () => { describe("catalog export", () => {
it("recovers queue entries owned by a terminated local process", async () => { it("recovers queue entries owned by a terminated local process", async () => {
+3
View File
@@ -42,3 +42,6 @@ docker() {
esac esac
} }
export -f git flock pnpm curl sleep docker 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