#!/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" < {}, 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 hashlib, 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)) def image_id(ref): return 'sha256:' + hashlib.sha256(ref.encode()).hexdigest() 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] == 'image': print(image_id(args[-1])) 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 '.Image' in fmt: print(image_id(c['image'])) elif '.Config.Labels' in fmt: print(c['hash']) elif '.State.Health' in fmt: print(('true' if c['running'] else 'false') + ' ' + c['health']) 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'] def service_hash(service): config = {'image': ref(service)} if service != 'frontend': config.update({k:v for k,v in env.items() if k not in ('FRONTEND_IMAGE','FRONTEND_PORT')}) return hashlib.sha256(json.dumps(config,sort_keys=True).encode()).hexdigest() if cmd[0] == 'config': if '--environment' in cmd: print('\n'.join(k+'='+v for k,v in env.items())) elif '--hash' in cmd: print(cmd[-1] + ' ' + service_hash(cmd[-1])) 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), hash=service_hash(name), env=env, running=True, health='unhealthy' if fault == name else 'healthy') 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" < "$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 unchanged_runtime [[ $(count_backups) == 1 ]] : > "$case_dir/calls" ok v1.0.1 ! grep -Eq '"(stop|up)".*"(backend|migration)"' "$case_dir/calls" [[ $(count_backups) == 1 ]] : > "$case_dir/calls" ok v1.1.0 ! grep -Eq '"(stop|up)".*"frontend"' "$case_dir/calls" [[ $(count_backups) == 2 ]] printf '\nLOG_LEVEL=debug\n' >> "$case_dir/production.env" ok v1.1.0 [[ $(count_backups) == 3 ]] 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, no-op, selective update, config drift, 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) ! grep -Eq '"(stop|up)".*"(backend|migration)"' "$case_dir/calls" ;; 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.'