fix(deploy): verify Docker clone updates against the served release
This commit is contained in:
1 parent
3bac126ace
commit
cbaa115d56
15 files changed
+321
-187
No files matched your search
@@ -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
|
||||
@@ -41,3 +41,6 @@ blob-report/
|
||||
.aider*
|
||||
|
||||
/public/vendor/tinymce/
|
||||
|
||||
# Shared deployment lock (never application source)
|
||||
.deploy.lock
|
||||
+9
-13
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}");
|
||||
});
|
||||
@@ -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,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 () => {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user