feat: add versioned deployment bundle
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
#!/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'
|
||||
|
||||
matches() {
|
||||
local id=$1 service=$2 image=$3 name hash
|
||||
[[ -n $id && -s $state/current ]] || return 1
|
||||
read -r name hash <<< "$(compose config --hash "$service")"
|
||||
[[ $name == "$service" && $hash =~ ^[0-9a-f]{64}$ ]] || return 1
|
||||
[[ $(inspect "$id" '{{index .Config.Labels "com.docker.compose.config-hash"}}') == "$hash" && \
|
||||
$(inspect "$id" '{{.Image}}') == "$(dock image inspect --format '{{.Id}}' "$image")" && \
|
||||
$(inspect "$id" '{{.State.Running}} {{if .State.Health}}{{.State.Health.Status}}{{end}}') == 'true healthy' ]]
|
||||
}
|
||||
change_backend=yes change_frontend=yes
|
||||
if matches "$backend" backend "$backend_image"; then change_backend=no; fi
|
||||
if matches "$frontend" frontend "$frontend_image"; then change_frontend=no; fi
|
||||
if [[ $change_backend == no && $change_frontend == no ]]; then
|
||||
stage=ready
|
||||
record > "$state/current.tmp"; mv -- "$state/current.tmp" "$state/current"
|
||||
printf 'Already running the selected images and configuration; no update needed.\n'
|
||||
exit
|
||||
fi
|
||||
|
||||
if [[ $change_backend == yes ]]; then
|
||||
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
|
||||
fi
|
||||
if [[ $change_frontend == yes ]]; then
|
||||
mark frontend-start
|
||||
quiet compose up -d --no-deps --wait --wait-timeout 120 frontend
|
||||
fi
|
||||
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"
|
||||
Reference in New Issue
Block a user