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)
|
# 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
|
||||||
@@ -41,3 +41,6 @@ blob-report/
|
|||||||
.aider*
|
.aider*
|
||||||
|
|
||||||
/public/vendor/tinymce/
|
/public/vendor/tinymce/
|
||||||
|
|
||||||
|
# Shared deployment lock (never application source)
|
||||||
|
.deploy.lock
|
||||||
+9
-13
@@ -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 \
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
|
||||||
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
});
|
||||||
@@ -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 { 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 () => {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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