263 lines
11 KiB
Bash
Executable File
263 lines
11 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 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" <<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
|
|
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.'
|