147 lines
8.0 KiB
Bash
147 lines
8.0 KiB
Bash
#!/usr/bin/env bash
|
|
set -Eeuo pipefail
|
|
if [ "$(id -u)" -ne 0 ]; then exec sudo -n bash "$0" "$@"; fi
|
|
RELEASE_SHA="${1:?Release SHA is required}"
|
|
[[ "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo 'Invalid release SHA'; exit 1; }
|
|
IMAGE="waterfall-wiki:$RELEASE_SHA"
|
|
APP_ROOT=/opt/prompt
|
|
RELEASE_DIR="$APP_ROOT/releases/$RELEASE_SHA"
|
|
PORT=18131
|
|
command -v docker >/dev/null
|
|
docker compose version >/dev/null
|
|
docker image inspect "$IMAGE" >/dev/null
|
|
mkdir -p "$APP_ROOT/releases"
|
|
exec 9>"$APP_ROOT/.deploy.lock"
|
|
flock -w 300 9 || { echo 'Another PROMPT deployment is running'; exit 1; }
|
|
test "$(git -c safe.directory="$PWD" rev-parse HEAD)" = "$RELEASE_SHA"
|
|
LATEST_MAIN=$(git -c safe.directory="$PWD" ls-remote origin refs/heads/main | cut -f1)
|
|
if [ "$LATEST_MAIN" != "$RELEASE_SHA" ]; then
|
|
git -c safe.directory="$PWD" fetch origin main
|
|
if ! git -c safe.directory="$PWD" diff --quiet "$RELEASE_SHA" origin/main -- apps/waterfall-wiki Dockerfile compose.yml scripts/deploy.sh scripts/infra.sh scripts/integration.sh scripts/auth-admin.sh .gitea/workflows/waterfall-wiki.yml pnpm-workspace.yaml; then
|
|
echo 'Skipping deployment: Wiki has a newer commit'; exit 0
|
|
fi
|
|
fi
|
|
PREVIOUS=""
|
|
PREVIOUS_WEB=web
|
|
PREVIOUS_WORKER=worker
|
|
if [ -L "$APP_ROOT/current" ] && [ -d "$APP_ROOT/current" ]; then PREVIOUS=$(readlink -f "$APP_ROOT/current"); fi
|
|
if [ -n "$PREVIOUS" ] && grep -q '^ waterfall-wiki:' "$PREVIOUS/compose.yml"; then
|
|
PREVIOUS_WEB=waterfall-wiki; PREVIOUS_WORKER=waterfall-wiki-worker
|
|
fi
|
|
bash scripts/infra.sh "$IMAGE"
|
|
mkdir -p "$RELEASE_DIR"
|
|
install -m 0644 compose.yml "$RELEASE_DIR/compose.yml"
|
|
printf 'WATERFALL_WIKI_IMAGE=%s\nRELEASE_SHA=%s\nWEB_PORT=%s\nAUTH_CONFIG_FILE=/auth/config.json\n' "$IMAGE" "$RELEASE_SHA" "$PORT" > "$RELEASE_DIR/release.env"
|
|
compose_at(){ local release="$1"; shift; env -u PROMPT_IMAGE -u PROMPT_TEST_IMAGE -u AUTH_CONFIG_FILE -u AUTH_COOKIE_SECURE -u WATERFALL_WIKI_IMAGE -u WATERFALL_WIKI_TEST_IMAGE -u RELEASE_SHA -u WEB_PORT -u WEB_BIND_ADDRESS docker compose -p prompt --env-file /etc/prompt.env --env-file "$release/release.env" -f "$release/compose.yml" "$@"; }
|
|
compose(){ compose_at "$RELEASE_DIR" "$@"; }
|
|
LEGACY_WEB=0
|
|
LEGACY_WORKER=0
|
|
LEGACY_WEB_ENABLED=0
|
|
LEGACY_WORKER_ENABLED=0
|
|
if command -v systemctl >/dev/null; then
|
|
if systemctl is-active --quiet prompt.service; then LEGACY_WEB=1; fi
|
|
if systemctl is-active --quiet prompt-worker.service; then LEGACY_WORKER=1; fi
|
|
if systemctl is-enabled --quiet prompt.service 2>/dev/null; then LEGACY_WEB_ENABLED=1; fi
|
|
if systemctl is-enabled --quiet prompt-worker.service 2>/dev/null; then LEGACY_WORKER_ENABLED=1; fi
|
|
fi
|
|
CUTOVER=0
|
|
MIGRATION_STARTED=0
|
|
BACKUP_FILE=""
|
|
rollback(){
|
|
local code=$?
|
|
trap - ERR
|
|
set +e
|
|
if [ "$CUTOVER" -eq 1 ]; then
|
|
compose stop waterfall-wiki waterfall-wiki-worker
|
|
if [ "$MIGRATION_STARTED" -eq 1 ] && [ -s "$BACKUP_FILE" ]; then
|
|
if ! compose exec -T postgres pg_restore -U prompt -d prompt --clean --if-exists --exit-on-error < "$BACKUP_FILE"; then
|
|
echo 'Database rollback failed; application remains stopped. Backup retained.'
|
|
exit "$code"
|
|
fi
|
|
echo 'Restored the database backup compatible with the previous application'
|
|
fi
|
|
if [ -n "$PREVIOUS" ] && [ -f "$PREVIOUS/compose.yml" ]; then
|
|
compose_at "$PREVIOUS" up -d --no-build "$PREVIOUS_WEB" "$PREVIOUS_WORKER"
|
|
echo 'Restored the previous Docker application image'
|
|
else
|
|
compose stop waterfall-wiki waterfall-wiki-worker
|
|
compose rm -f waterfall-wiki waterfall-wiki-worker
|
|
if [ "$LEGACY_WORKER_ENABLED" -eq 1 ]; then systemctl enable prompt-worker.service; fi
|
|
if [ "$LEGACY_WEB_ENABLED" -eq 1 ]; then systemctl enable prompt.service; fi
|
|
if [ "$LEGACY_WEB" -eq 1 ]; then systemctl start prompt.service; fi
|
|
if [ "$LEGACY_WORKER" -eq 1 ]; then systemctl start prompt-worker.service; fi
|
|
echo 'Restored legacy application services if previously running'
|
|
fi
|
|
if [ -n "$PREVIOUS" ]; then
|
|
ln -sfn "$PREVIOUS" "$APP_ROOT/.current.rollback"
|
|
mv -Tf "$APP_ROOT/.current.rollback" "$APP_ROOT/current"
|
|
fi
|
|
fi
|
|
echo "Docker deployment failed (exit $code); persistent data volumes were retained"
|
|
exit "$code"
|
|
}
|
|
trap rollback ERR
|
|
# Keep the same prompt_postgres_data and prompt_objects volumes. No production down -v.
|
|
compose up -d --no-build --wait --wait-timeout 120 postgres objects
|
|
install -d -o root -g root -m 0700 /var/lib/prompt/backups
|
|
CUTOVER=1
|
|
compose stop waterfall-wiki waterfall-wiki-worker
|
|
if [ -n "$PREVIOUS" ]; then compose_at "$PREVIOUS" stop "$PREVIOUS_WEB" "$PREVIOUS_WORKER"; fi
|
|
BACKUP_FILE="/var/lib/prompt/backups/$(date -u +%Y%m%dT%H%M%S)-$RELEASE_SHA.dump"
|
|
compose exec -T postgres pg_dump -U prompt -d prompt -Fc > "$BACKUP_FILE"
|
|
find /var/lib/prompt/backups -name '*.dump' -printf '%T@ %p\n' | sort -rn | tail -n +11 | cut -d' ' -f2- | xargs -r rm --
|
|
MIGRATION_STARTED=1
|
|
compose run --rm --no-deps auth-admin
|
|
compose run --rm --no-deps migrate
|
|
compose run --rm --no-deps publish
|
|
CUTOVER=1
|
|
# One-time migration: finish a current host worker job before switching the web port.
|
|
if [ "$LEGACY_WORKER" -eq 1 ] || [ "$LEGACY_WORKER_ENABLED" -eq 1 ]; then systemctl disable --now prompt-worker.service; fi
|
|
if [ "$LEGACY_WEB" -eq 1 ] || [ "$LEGACY_WEB_ENABLED" -eq 1 ]; then systemctl disable --now prompt.service; fi
|
|
compose up -d --no-build --wait --wait-timeout 180 waterfall-wiki waterfall-wiki-worker
|
|
compose exec -T waterfall-wiki node container-health.mjs
|
|
compose exec -T waterfall-wiki node auth-cli.cjs verify
|
|
compose exec -T waterfall-wiki node auth-cli.cjs run scripts/import-character-packs.mjs
|
|
compose exec -T waterfall-wiki-worker sh -c 'test "$(id -u)" -ne 0 && ffmpeg -version >/dev/null'
|
|
# Editorial posts may be older than the first 24 entries. Verify the complete paged feed.
|
|
compose exec -T waterfall-wiki sh -c 'cat > /tmp/prompt-release-check.mjs && node auth-cli.cjs run /tmp/prompt-release-check.mjs' <<'NODE'
|
|
(async()=>{
|
|
const slugs=['learning-skills-notes','learning-skills-teaching','learning-skills-english'];
|
|
const missing=new Set(slugs),seen=new Set();let cursor='';
|
|
while(missing.size){
|
|
const query=new URLSearchParams({section:'articles',...(cursor?{cursor}:{})});
|
|
const response=await fetch('http://127.0.0.1:3000/api/feed?'+query);
|
|
if(!response.ok)throw Error('Article feed unavailable: '+response.status);
|
|
const feed=await response.json();for(const post of feed.posts)missing.delete(post.slug);
|
|
if(!missing.size||!feed.nextCursor)break;
|
|
if(seen.has(feed.nextCursor))throw Error('Article pagination did not advance');
|
|
seen.add(feed.nextCursor);cursor=feed.nextCursor;
|
|
}
|
|
if(missing.size)throw Error('Published articles missing: '+[...missing].join(', '));
|
|
for(const slug of slugs){const res=await fetch('http://127.0.0.1:3000/p/'+slug);if(!res.ok)throw Error('Article unavailable: '+slug);}
|
|
console.log('Verified three published article pages across the paged feed');
|
|
})().catch(error=>{console.error(error.message);process.exit(1)});
|
|
NODE
|
|
|
|
ln -sfn "$RELEASE_DIR" "$APP_ROOT/.current.next"
|
|
mv -Tf "$APP_ROOT/.current.next" "$APP_ROOT/current"
|
|
install -m 0755 scripts/auth-admin.sh "$APP_ROOT/auth"
|
|
compose ps
|
|
for service in waterfall-wiki waterfall-wiki-worker postgres objects; do
|
|
id=$(compose ps -q "$service")
|
|
docker inspect --format '{{.Name}} running={{.State.Running}} restart={{.HostConfig.RestartPolicy.Name}}' "$id"
|
|
done
|
|
if command -v systemctl >/dev/null; then
|
|
if systemctl is-active --quiet prompt.service || systemctl is-active --quiet prompt-worker.service; then echo 'Legacy host service is unexpectedly running'; false; fi
|
|
fi
|
|
echo "Full Docker deployment verified: web+worker+postgres+objects port=$PORT release=$RELEASE_SHA"
|
|
# Read-only network diagnostics: a private address alone is not a public entrypoint.
|
|
echo "Host: $(hostname)"
|
|
hostname -I || true
|
|
if command -v curl >/dev/null; then
|
|
for field in eipv4 public-ipv4; do
|
|
address=$(curl --noproxy '*' -fsS --connect-timeout 2 --max-time 4 "http://100.100.100.200/latest/meta-data/$field" 2>/dev/null || true)
|
|
if [[ "$address" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then echo "Instance $field: $address"; fi
|
|
done
|
|
fi
|