feat: add versioned deployment bundle
This commit is contained in:
Executable
+262
@@ -0,0 +1,262 @@
|
||||
#!/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.'
|
||||
Reference in New Issue
Block a user