Files
deploy/tests/deploy.sh
ksv741 a56ed85230 refactor: use one safe deployment path
Always stop, back up, migrate and check readiness instead of
maintaining image/config hash detection and selective updates.

Keep file locking, key preservation and interrupted recovery.
Repeated and frontend-only deployments may cause brief API downtime.
2026-10-03 11:38:08 +03:00

252 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
# Isolated behavioral tests: real Git, files, backups and locks; fake Docker.
# Python 3 is a test-only stdlib dependency, not a deployment dependency.
set -euo pipefail
source_dir=$(cd "$(dirname "$0")/.." && pwd -P)
sandbox=$(mktemp -d "${TMPDIR:-/tmp}/wealthledger-tests.XXXXXXXX")
sandbox=$(cd "$sandbox" && pwd -P)
trap 'rm -rf -- "$sandbox"' EXIT
if [[ ${1-} == --docker-restore ]]; then
project="wealthledger-restore-$$"
[[ -z $(docker ps -aq --filter "label=com.docker.compose.project=$project") ]] || exit 1
image=$(sed -n 's/^BACKEND_IMAGE=//p' "$source_dir/release.env")
mkdir "$sandbox/data"
printf 'before' > "$sandbox/data/value"
cat > "$sandbox/compose.yaml" <<EOF
services:
reader:
image: $image
entrypoint: [node, -e, 'setInterval(() => {}, 1000)']
healthcheck:
disable: true
volumes:
- type: bind
source: $sandbox/data
target: /data
read_only: true
EOF
dc() { docker compose --project-name "$project" -f "$sandbox/compose.yaml" "$@"; }
trap 'dc down >/dev/null 2>&1; rm -rf -- "$sandbox"' EXIT
dc up -d --wait
[[ $(dc exec -T reader cat /data/value) == before ]]
dc stop reader
mv "$sandbox/data" "$sandbox/data-before-restore"
mkdir "$sandbox/data"
printf 'restored' > "$sandbox/data/value"
# Same operation as README restore: replacing a bind source needs recreation.
dc up -d --force-recreate --wait reader
[[ $(dc exec -T reader cat /data/value) == restored ]]
echo 'PASS real Docker bind-directory replacement and restore recreation'
exit
fi
[[ $# == 0 ]] || { echo 'Usage: bash tests/deploy.sh [--docker-restore]' >&2; exit 1; }
mkdir -p "$sandbox/bin" "$sandbox/repo"
cp "$source_dir/deploy.sh" "$source_dir/compose.yaml" "$sandbox/repo/"
export PATH="$sandbox/bin:$PATH"
export GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null
git -C "$sandbox/repo" init -q
git -C "$sandbox/repo" config user.name 'Deployment test'
git -C "$sandbox/repo" config user.email 'test@example.invalid'
release() {
printf 'FRONTEND_IMAGE=registry.test/frontend:%s\nBACKEND_IMAGE=registry.test/backend:%s\n' "$2" "$3" > "$sandbox/repo/release.env"
git -C "$sandbox/repo" add .
git -C "$sandbox/repo" -c core.hooksPath=/dev/null commit -qm "$1"
git -C "$sandbox/repo" tag "$1"
}
release v1.0.0 1.0.0 1.0.0
release v1.0.1 1.0.1 1.0.0
release v1.1.0 1.0.1 1.1.0
git clone --quiet --bare "$sandbox/repo" "$sandbox/origin"
git -C "$sandbox/repo" remote add origin "$sandbox/origin"
# The checkout's pair must never win over the requested tag.
printf 'FRONTEND_IMAGE=wrong:9.9.9\nBACKEND_IMAGE=wrong:9.9.9\n' > "$sandbox/repo/release.env"
cat > "$sandbox/bin/docker" <<'PY'
#!/usr/bin/env python3
import json, os, pathlib, sys
base = pathlib.Path(__file__).resolve().parent.parent
case = pathlib.Path((base / 'case').read_text())
args = sys.argv[1:]
fault = (case / 'fault').read_text().strip() if (case / 'fault').exists() else ''
db = case / 'docker.json'
state = json.loads(db.read_text()) if db.exists() else {}
with (case / 'calls').open('a') as log:
log.write(json.dumps(args) + '\n')
def save(): db.write_text(json.dumps(state))
if args[0] == 'context': print('unix:///test/docker.sock')
elif args[0] == 'info': pass
elif args[0] == 'ps':
service = next(a.split('=')[-1] for a in args if 'com.docker.compose.service=' in a)
if service in state: print(service)
elif args[0] == 'inspect':
fmt, name = args[2], args[3]
c = state[name]
if '.Mounts' in fmt: print(str(case / 'data'))
elif '.Config.Env' in fmt: print('\n'.join(k + '=' + c['env'][k] for k in ('JWT_SECRET','TOKEN_ENCRYPTION_KEY')))
elif '.Config.Image' in fmt: print(c['image'])
elif '.State.Running' in fmt: print('true' if c['running'] else 'false')
else: raise AssertionError(fmt)
elif args[0] == 'compose':
assert 'COMPOSE_PROJECT_NAME' not in os.environ
assert 'FRONTEND_IMAGE' not in os.environ and 'BACKEND_IMAGE' not in os.environ
assert args[args.index('--project-name')+1] == 'wealthledger'
env = {}
for i, a in enumerate(args):
if a == '--env-file':
for line in pathlib.Path(args[i+1]).read_text().splitlines():
if line and not line.startswith('#'):
k,v = line.split('=',1); env[k] = v
cmd = args[args.index('-f')+2:]
def ref(service): return env['FRONTEND_IMAGE' if service == 'frontend' else 'BACKEND_IMAGE']
if cmd[0] == 'config':
if '--environment' in cmd: print('\n'.join(k+'='+v for k,v in env.items()))
elif cmd[0] == 'pull':
if fault == 'pull': sys.exit(1)
elif cmd[0] == 'run':
if fault == 'preflight': sys.exit(1)
elif cmd[0] == 'stop':
name = cmd[-1]
if name in state: state[name]['running'] = False
save()
elif cmd[0] == 'up':
name = cmd[-1]
if name == 'migration':
assert not state.get('backend',{}).get('running',False), 'migration while API is running'
if fault == 'migration': sys.exit(1)
with (case/'data/wealthledger.db').open('a') as f: f.write(' migrated')
else:
state[name] = dict(image=ref(name), env=env, running=True)
save()
if fault == name: sys.exit(1)
else: raise AssertionError(cmd)
else: raise AssertionError(args)
PY
cat > "$sandbox/bin/flock" <<'PY'
#!/usr/bin/env python3
import fcntl, sys
try: fcntl.flock(int(sys.argv[-1]), fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError: sys.exit(1)
PY
cat > "$sandbox/bin/cp" <<'SH'
#!/usr/bin/env bash
base=$(cd "$(dirname "$0")/.." && pwd -P)
case_dir=$(cat "$base/case")
if [[ -f $case_dir/fault && $(cat "$case_dir/fault") == copy ]]; then exit 1; fi
exec /bin/cp "$@"
SH
chmod +x "$sandbox/bin/"*
new_case() {
case_dir="$sandbox/$1"
mkdir -p "$case_dir/data" "$case_dir/certs"
printf '%s' "$case_dir" > "$sandbox/case"
printf 'original' > "$case_dir/data/wealthledger.db"
printf 'wal' > "$case_dir/data/wealthledger.db-wal"
printf 'shm' > "$case_dir/data/wealthledger.db-shm"
printf 'test CA' > "$case_dir/certs/ca.pem"
cat > "$case_dir/production.env" <<EOF
DATA_DIRECTORY=$case_dir/data
TBANK_CA_CERT_FILE=$case_dir/certs/ca.pem
JWT_SECRET=test-secret-that-must-never-appear-in-output
TOKEN_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
LOG_LEVEL=info
EOF
}
run() { bash "$sandbox/repo/deploy.sh" "$1" --env-file "$case_dir/production.env" "${@:2}" > "$case_dir/output" 2>&1; }
ok() { if ! run "$@"; then cat "$case_dir/output"; exit 1; fi; }
fails() { if run "$@"; then echo "Unexpected success: $*"; exit 1; fi; }
contains() { grep -q -- "$2" "$1" || { echo "Missing expected result: $2"; cat "$case_dir/output"; exit 1; }; }
count_backups() { find "$case_dir/backups" -mindepth 1 -maxdepth 1 -type d ! -name '.*' 2>/dev/null | wc -l | tr -d ' '; }
unchanged_runtime() { ! grep -Eq '"(stop|up)"' "$case_dir/calls"; }
new_case lifecycle
# Caller variables and edited checkout must not override the selected pair.
export FRONTEND_IMAGE=wrong:9.9.9 BACKEND_IMAGE=wrong:9.9.9 COMPOSE_PROJECT_NAME=wrong
ok v1.0.0
[[ $(count_backups) == 1 ]]
first_backup=$(find "$case_dir/backups" -name wealthledger.db -print)
[[ $(cat "$first_backup") == original && -f "$first_backup-wal" && -f "$first_backup-shm" ]]
contains "$case_dir/.deploy/current" 'backend=registry.test/backend:1.0.0'
: > "$case_dir/calls"
ok v1.0.0
contains "$case_dir/calls" '"stop", "backend"'
[[ $(count_backups) == 2 ]]
: > "$case_dir/calls"
ok v1.0.1
contains "$case_dir/calls" '"stop", "backend"'
[[ $(count_backups) == 3 ]]
: > "$case_dir/calls"
ok v1.1.0
contains "$case_dir/calls" '"frontend"'
[[ $(count_backups) == 4 ]]
printf '\nLOG_LEVEL=debug\n' >> "$case_dir/production.env"
ok v1.1.0
[[ $(count_backups) == 5 ]]
printf '\nJWT_SECRET=changed-secret\n' >> "$case_dir/production.env"
: > "$case_dir/calls"
fails v1.1.0
unchanged_runtime
! grep -q 'changed-secret\|test-secret-that' "$case_dir/output"
echo 'PASS adoption, backup companions, complete repeated updates and secret preservation'
new_case init
rm "$case_dir/data/"*
fails v1.0.0
ok v1.0.0 --init
[[ -s $case_dir/data/wealthledger.db && ! -d $case_dir/backups ]]
fails v1.0.0 --init
echo 'PASS explicit initialization; missing data is never silently reinitialized'
new_case linked_database
mv "$case_dir/data/wealthledger.db" "$case_dir/external.db"
ln -s "$case_dir/external.db" "$case_dir/data/wealthledger.db"
fails v1.0.0
contains "$case_dir/output" 'must not be symlinks'
echo 'PASS backup rejects database symlinks'
for fault in pull preflight copy migration backend frontend; do
new_case "failure-$fault"
ok v1.0.0
printf '%s' "$fault" > "$case_dir/fault"
: > "$case_dir/calls"
if [[ $fault == frontend ]]; then fails v1.0.1; else fails v1.1.0; fi
contains "$case_dir/.deploy/current" 'release=v1.0.0'
case $fault in
pull|preflight) unchanged_runtime; [[ ! -f $case_dir/.deploy/pending ]] ;;
copy) ! grep -Eq '"up".*"migration"' "$case_dir/calls"; contains "$case_dir/output" 'Backup failed' ;;
migration) ! grep -Eq '"up".*"backend"' "$case_dir/calls" ;;
backend) contains "$case_dir/calls" '"stop", "backend"' ;;
frontend) python3 -c 'import json,sys; assert json.load(open(sys.argv[1]))["backend"]["running"]' "$case_dir/docker.json" ;;
esac
if [[ $fault != pull && $fault != preflight ]]; then
[[ -f $case_dir/.deploy/pending ]]
: > "$case_dir/calls"
fails v1.1.0
unchanged_runtime
contains "$case_dir/output" 'Unfinished deployment'
fi
echo "PASS failure: $fault"
done
new_case concurrency
mkdir "$case_dir/.deploy"
exec 8>"$case_dir/.deploy/lock"
flock -n 8
fails v1.0.0
contains "$case_dir/output" 'Another deployment'
exec 8>&-
ok v1.0.0
# A durable interruption record must block even when the process lock is gone.
printf 'stage=migration\n' > "$case_dir/.deploy/pending"
fails v1.1.0
contains "$case_dir/output" 'Unfinished deployment'
echo 'PASS concurrency and interrupted-operation recovery boundary'
new_case tags
fails v9.9.9
git -C "$sandbox/origin" tag -f v1.0.0 v1.1.0 >/dev/null
fails v1.0.0
contains "$case_dir/output" 'Cannot fetch tag'
echo 'PASS missing and moved tags'
echo 'All deployment behavior checks passed.'