diff --git a/.dockerignore b/.dockerignore index 82c21570..2b7fc52c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore index 1dbed918..6f60ed50 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ blob-report/ .aider* /public/vendor/tinymce/ + +# Shared deployment lock (never application source) +.deploy.lock diff --git a/Dockerfile b/Dockerfile index fca700b2..8106645c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 \ diff --git a/README.md b/README.md index 7aac9e5a..31d1095f 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/docker-compose.yml b/docker-compose.yml index ba7dd8e3..17f71ad4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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. diff --git a/scripts/ci-deploy.sh b/scripts/ci-deploy.sh index 0e1cb157..44012f84 100644 --- a/scripts/ci-deploy.sh +++ b/scripts/ci-deploy.sh @@ -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 diff --git a/scripts/docker-preflight.sh b/scripts/docker-preflight.sh index d35e3e2a..87489fb1 100755 --- a/scripts/docker-preflight.sh +++ b/scripts/docker-preflight.sh @@ -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 diff --git a/scripts/docker-update.sh b/scripts/docker-update.sh index 88252241..1324411f 100755 --- a/scripts/docker-update.sh +++ b/scripts/docker-update.sh @@ -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 \ No newline at end of file +log "Verified release $CMS_RELEASE, image $actual_image, container $container" diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 0e56de83..180d89ab 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -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(), }); diff --git a/src/lib/ci-deploy.test.ts b/src/lib/ci-deploy.test.ts index 1b8fb046..d4f5ce4b 100644 --- a/src/lib/ci-deploy.test.ts +++ b/src/lib/ci-deploy.test.ts @@ -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); diff --git a/src/lib/docker-build-contract.test.ts b/src/lib/docker-build-contract.test.ts index 03c6d20e..15185905 100644 --- a/src/lib/docker-build-contract.test.ts +++ b/src/lib/docker-build-contract.test.ts @@ -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}"); +}); diff --git a/src/lib/docker-update.test.ts b/src/lib/docker-update.test.ts new file mode 100644 index 00000000..648c8754 --- /dev/null +++ b/src/lib/docker-update.test.ts @@ -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); + }); +}); diff --git a/src/lib/services/catalog-git-core.test.ts b/src/lib/services/catalog-git-core.test.ts index 2ad26a31..85bbeacf 100644 --- a/src/lib/services/catalog-git-core.test.ts +++ b/src/lib/services/catalog-git-core.test.ts @@ -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 () => { diff --git a/src/test/ci-deploy-harness.sh b/src/test/ci-deploy-harness.sh index afc73b99..61bdaae5 100644 --- a/src/test/ci-deploy-harness.sh +++ b/src/test/ci-deploy-harness.sh @@ -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 diff --git a/src/test/docker-update-harness.sh b/src/test/docker-update-harness.sh new file mode 100644 index 00000000..435a3e93 --- /dev/null +++ b/src/test/docker-update-harness.sh @@ -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