#!/usr/bin/env bash # One local Linux Docker host. Run from the checkout; never source env files. set +x set -euo pipefail umask 077 die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) if [[ ${1-} != --snapshot ]]; then tag=${1-} [[ $tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || die 'Usage: ./deploy.sh vX.Y.Z [--env-file /path/production.env] [--init]' shift config="$root/../production.env" initialize=no while (($#)); do case $1 in --init) initialize=yes; shift ;; --env-file) (($# >= 2)) || die 'Missing env filename'; config=$2; shift 2 ;; *) die 'Unknown argument' ;; esac done [[ -f $config && -r $config ]] || die 'Create a readable production.env outside the checkout first' config="$(cd -- "$(dirname -- "$config")" && pwd -P)/$(basename -- "$config")" [[ $config != "$root/"* ]] || die 'Server env must be outside the checkout' git -C "$root" fetch --quiet --no-tags origin "refs/tags/$tag:refs/tags/$tag" || die 'Cannot fetch tag; absent or changed tags are not accepted' commit=$(git -C "$root" rev-parse --verify "refs/tags/$tag^{commit}") snapshot=$(mktemp -d "${TMPDIR:-/tmp}/wealthledger-release.XXXXXXXX") trap 'rm -rf -- "$snapshot"' EXIT git -C "$root" archive "$commit" | tar -x -C "$snapshot" [[ -f $snapshot/deploy.sh ]] || die 'The selected tag has no deploy.sh' bash "$snapshot/deploy.sh" --snapshot "$tag" "$commit" "$config" "$initialize" "$root" exit fi # Internal handoff: execute the script and Compose file from the SAME commit. (($# == 6)) || die 'Invalid snapshot invocation' tag=$2 commit=$3 config=$4 initialize=$5 checkout=$6 [[ $tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ && $commit =~ ^[0-9a-f]{40}$ ]] || die 'Invalid release identity' for tool in docker flock; do command -v "$tool" >/dev/null || die "Install $tool first"; done # Docker transport/auth settings are allowed; caller COMPOSE_* and image/env # overrides are not. Compose alone parses production.env and release.env. docker_env=(env -i "PATH=$PATH" "HOME=$HOME") for key in DOCKER_HOST DOCKER_CONTEXT DOCKER_CONFIG DOCKER_TLS_VERIFY DOCKER_CERT_PATH DOCKER_DEFAULT_PLATFORM; do [[ -z ${!key-} ]] || docker_env+=("$key=${!key}") done dock() { "${docker_env[@]}" docker "$@"; } compose() { dock compose --project-name wealthledger --project-directory "$root" \ --env-file "$config" --env-file "$root/release.env" -f "$root/compose.yaml" "$@" } quiet() { "$@" >/dev/null 2>&1; } frontend_image= backend_image= while IFS= read -r line || [[ -n $line ]]; do case $line in ''|'#'*) continue ;; FRONTEND_IMAGE=*) [[ -z $frontend_image ]] || die 'Duplicate frontend image'; frontend_image=${line#*=} ;; BACKEND_IMAGE=*) [[ -z $backend_image ]] || die 'Duplicate backend image'; backend_image=${line#*=} ;; *) die 'release.env may contain only the two image references' ;; esac done < "$root/release.env" for image in "$frontend_image" "$backend_image"; do [[ $image =~ ^[a-zA-Z0-9./:_-]+:[0-9]+\.[0-9]+\.[0-9]+$ ]] || die 'Use explicit versioned images in release.env' done quiet compose config --quiet || die 'Invalid Compose configuration or missing settings' settings=$(compose config --environment 2>/dev/null) || die 'Cannot read Compose settings' setting() { printf '%s\n' "$settings" | sed -n "s/^$1=//p"; } data=$(setting DATA_DIRECTORY) cert=$(setting TBANK_CA_CERT_FILE) [[ $data == /* && -d $data && $cert == /* && -f $cert && -r $cert ]] || die 'Data directory and CA certificate must exist at absolute paths' data=$(cd -- "$data" && pwd -P) [[ $data != / && $data != "$checkout" && $data != "$checkout/"* && $cert != "$checkout/"* ]] || die 'Persistent resources must be outside the checkout' [[ $config != "$data/"* ]] || die 'Keep server secrets outside the data backup' parent=$(dirname -- "$data") state="$parent/.deploy" backups="$parent/backups" [[ $data != "$state" && $data != "$backups" && $data != "$backups/"* ]] || die 'Data overlaps deployment state or backups' [[ ! -L $state && ! -L $backups ]] || die 'State and backups must not be symlinks' mkdir -p -- "$state" exec 9>"$state/lock" flock -n 9 || die 'Another deployment is running' [[ ! -e $state/pending ]] || die "Unfinished deployment: inspect $state/pending and follow README recovery before retrying" # A remote daemon would back up this machine while modifying another host. endpoint=${DOCKER_HOST-} if [[ -z $endpoint || -n ${DOCKER_CONTEXT-} ]]; then endpoint=$(dock context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null) fi [[ $endpoint == unix://* ]] || die 'Use a local Docker daemon, not a remote context' quiet dock info || die 'Docker daemon is unavailable' container() { local ids ids=$(dock ps -aq --filter label=com.docker.compose.project=wealthledger \ --filter "label=com.docker.compose.service=$1" --filter label=com.docker.compose.oneoff=False) [[ $ids != *$'\n'* ]] || die "Multiple $1 containers; resolve before deployment" printf '%s' "$ids" } inspect() { dock inspect --format "$2" "$1"; } backend=$(container backend) frontend=$(container frontend) migration=$(container migration) [[ -z $migration || $(inspect "$migration" '{{.State.Running}}') == false ]] || die 'Migration is already running' if [[ $initialize == yes ]]; then [[ -z $backend && -z $frontend && -z $migration && ! -e $state/current ]] || die '--init is only for a new installation' [[ -z $(ls -A -- "$data") ]] || die '--init requires an empty data directory' else [[ -s $data/wealthledger.db ]] || die 'Existing wealthledger.db is missing; use --init only for a NEW empty installation' fi for file in wealthledger.db wealthledger.db-wal wealthledger.db-shm; do [[ ! -L $data/$file ]] || die 'Database files must not be symlinks; a backup must contain the data, not links' done previous_backend=none previous_frontend=none if [[ -n $backend ]]; then mounted=$(inspect "$backend" '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}') [[ $mounted == "$data" ]] || die 'Existing backend uses another data directory; refusing to replace it' previous_backend=$(inspect "$backend" '{{.Config.Image}}') existing_env=$(inspect "$backend" '{{range .Config.Env}}{{println .}}{{end}}') for key in JWT_SECRET TOKEN_ENCRYPTION_KEY; do old=$(printf '%s\n' "$existing_env" | sed -n "s/^$key=//p") [[ -n $old && $old == "$(setting "$key")" ]] || die "Preserve the existing $key when updating this database" done unset existing_env old fi [[ -z $frontend ]] || previous_frontend=$(inspect "$frontend" '{{.Config.Image}}') unset settings stage=preflight backup=none record() { printf 'release=%s\ncommit=%s\nbackend=%s\nfrontend=%s\nprevious_backend=%s\nprevious_frontend=%s\nstage=%s\nbackup=%s\n' \ "$tag" "$commit" "$backend_image" "$frontend_image" "$previous_backend" "$previous_frontend" "$stage" "$backup" } mark() { stage=$1; record > "$state/pending.tmp"; mv -- "$state/pending.tmp" "$state/pending"; } finish() { local code=$? trap - EXIT if ((code != 0)); then if [[ $stage == backend-start ]]; then quiet compose stop backend || true; fi if [[ $stage == migration ]]; then quiet compose stop migration || true; fi printf 'Deployment failed at %s (exit %s). No automatic rollback.\n' "$stage" "$code" >&2 printf 'Previous: frontend=%s backend=%s\nBackup: %s\n' "$previous_frontend" "$previous_backend" "$backup" >&2 [[ ! -f $state/pending ]] || printf 'Recovery record: %s/pending\n' "$state" >&2 fi exit "$code" } trap finish EXIT trap 'exit 130' INT trap 'exit 143' TERM printf 'Preparing %s (%s)...\n' "$tag" "$commit" quiet compose pull frontend backend migration || die 'Image pull failed; running services were not stopped' # Runs only validation, never the API entrypoint or migrations. Check permissions # as the image's real user and validate the mounted certificate before downtime. quiet compose run --rm --no-deps -T --entrypoint node backend -e ' const fs = require("node:fs"), crypto = require("node:crypto"); fs.accessSync("/data", fs.constants.R_OK | fs.constants.W_OK); for (const name of ["wealthledger.db", "wealthledger.db-wal", "wealthledger.db-shm"]) if (fs.existsSync("/data/" + name)) fs.accessSync("/data/" + name, fs.constants.R_OK | fs.constants.W_OK); new crypto.X509Certificate(fs.readFileSync(process.env.TBANK_CA_CERT_PATH)); if ((process.env.JWT_SECRET || "").length < 32 || Buffer.from(process.env.TOKEN_ENCRYPTION_KEY || "", "base64").length !== 32) process.exit(1); ' || die 'Image preflight failed: check data permissions, CA certificate and keys' mark stop quiet compose stop backend [[ -z $backend || $(inspect "$backend" '{{.State.Running}}') == false ]] || die 'Backend did not stop' migration=$(container migration) [[ -z $migration || $(inspect "$migration" '{{.State.Running}}') == false ]] || die 'A migration writer appeared; backup was not started' if [[ $initialize != yes ]]; then mark backup mkdir -p -- "$backups" partial=$(mktemp -d "$backups/.partial-$(date -u +%Y%m%dT%H%M%SZ).XXXXXXXX") quiet cp -a -- "$data" "$partial/data" || die 'Backup failed; partial copy is NOT usable; backend remains stopped' backup="$backups/$(basename -- "$partial" | sed 's/^\.partial-//')" mv -- "$partial" "$backup" printf 'Backup: %s\n' "$backup" fi mark migration quiet compose up --no-deps --force-recreate --abort-on-container-exit --exit-code-from migration migration mark backend-start quiet compose up -d --no-deps --wait --wait-timeout 120 backend mark frontend-start quiet compose up -d --no-deps --wait --wait-timeout 120 frontend stage=ready record > "$state/current.tmp"; mv -- "$state/current.tmp" "$state/current" rm -- "$state/pending" printf 'Installed %s. frontend=%s backend=%s\n' "$tag" "$frontend_image" "$backend_image"