diff --git a/README.md b/README.md index 4c7399b..9f673ed 100644 --- a/README.md +++ b/README.md @@ -131,12 +131,11 @@ Backups и `.deploy` всегда находятся рядом с фактич 1. Проверка тега, настроек, данных и CA; загрузка образов и проверка прав runtime user. Ошибка до остановки сохраняет работающие сервисы. -2. Сравнение образов и конфигурации с реальными контейнерами. При исправной - неизменной установке — no-op. Если изменился только frontend, backend - продолжает работать, backup и migration не запускаются. -3. Если изменился backend или его конфигурация: stop → копия **всего** data - directory с WAL/SHM → migration → backend readiness. Неизменный frontend - не пересоздаётся; если меняется и он, запускается после готовности backend. +2. Stop backend → копия **всего** data directory с WAL/SHM → migration → + backend readiness. Только для `--init` пустой базы backup не требуется. +3. Запуск frontend после готовности backend. Каждый вызов выполняет этот путь, + включая повтор того же тега и frontend-only update. No-op/change detection + отсутствуют; приняты короткий простой API и дополнительный backup. 4. Успех записывается в `.deploy/current` только после проверки запуска. Краткий простой допустим. После обновления проверьте через HTTPS deep link, login/refresh, существующие карты и чтение зашифрованного подключения. @@ -241,7 +240,7 @@ cp -a /opt/wealthledger/backups/CHOSEN_BACKUP/data /opt/wealthledger/data ``` Tests используют настоящий Git, backup-файлы и file locks, а Docker заменяют - управляемым double. Проверяются update/no-op, смена настроек, сохранение ключей, + управляемым double. Проверяются полные повторные updates, настройки, ключи, ошибки pull/backup/migration/readiness, конкуренция и незавершённая операция. Отдельная проверка реального Docker для restore: `bash tests/deploy.sh --docker-restore`. Она создаёт временный project, заменяет bind-каталог и проверяет пересоздание diff --git a/deploy.sh b/deploy.sh index 1a587c0..ea33b64 100755 --- a/deploy.sh +++ b/deploy.sh @@ -166,49 +166,26 @@ quiet compose run --rm --no-deps -T --entrypoint node backend -e ' 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 +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 +mark frontend-start +quiet compose up -d --no-deps --wait --wait-timeout 120 frontend stage=ready record > "$state/current.tmp"; mv -- "$state/current.tmp" "$state/current" rm -- "$state/pending" diff --git a/tests/deploy.sh b/tests/deploy.sh index 9447a7e..9d6828f 100755 --- a/tests/deploy.sh +++ b/tests/deploy.sh @@ -63,7 +63,7 @@ printf 'FRONTEND_IMAGE=wrong:9.9.9\nBACKEND_IMAGE=wrong:9.9.9\n' > "$sandbox/rep cat > "$sandbox/bin/docker" <<'PY' #!/usr/bin/env python3 -import hashlib, json, os, pathlib, sys +import json, os, pathlib, sys base = pathlib.Path(__file__).resolve().parent.parent case = pathlib.Path((base / 'case').read_text()) args = sys.argv[1:] @@ -73,22 +73,17 @@ 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': @@ -103,13 +98,8 @@ elif args[0] == 'compose': 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': @@ -125,8 +115,7 @@ elif args[0] == 'compose': 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') + state[name] = dict(image=ref(name), env=env, running=True) save() if fault == name: sys.exit(1) else: raise AssertionError(cmd) @@ -180,25 +169,25 @@ first_backup=$(find "$case_dir/backups" -name wealthledger.db -print) contains "$case_dir/.deploy/current" 'backend=registry.test/backend:1.0.0' : > "$case_dir/calls" ok v1.0.0 -unchanged_runtime -[[ $(count_backups) == 1 ]] +contains "$case_dir/calls" '"stop", "backend"' +[[ $(count_backups) == 2 ]] : > "$case_dir/calls" ok v1.0.1 -! grep -Eq '"(stop|up)".*"(backend|migration)"' "$case_dir/calls" -[[ $(count_backups) == 1 ]] +contains "$case_dir/calls" '"stop", "backend"' +[[ $(count_backups) == 3 ]] : > "$case_dir/calls" ok v1.1.0 -! grep -Eq '"(stop|up)".*"frontend"' "$case_dir/calls" -[[ $(count_backups) == 2 ]] +contains "$case_dir/calls" '"frontend"' +[[ $(count_backups) == 4 ]] printf '\nLOG_LEVEL=debug\n' >> "$case_dir/production.env" ok v1.1.0 -[[ $(count_backups) == 3 ]] +[[ $(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, no-op, selective update, config drift, secret preservation' +echo 'PASS adoption, backup companions, complete repeated updates and secret preservation' new_case init rm "$case_dir/data/"* @@ -227,7 +216,7 @@ for fault in pull preflight copy migration backend frontend; do 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" ;; + 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 ]]