8 Commits
Author SHA1 Message Date
ksv741 c655fd7594 build: deploy frontend 1.3.0 and backend 2.4.0 (#15) 2026-11-02 06:21:58 +03:00
ksv741 fc6d43e5dd build: deploy backend 2.3.1 (#13) 2026-10-24 21:46:58 +03:00
ksv741 a6b1de09d1 build: deploy frontend 1.2.1 and backend 2.3.0 (#10) 2026-10-24 20:16:26 +03:00
ksv741 52afa850d0 build: deploy frontend 1.2.0 and backend 2.2.0 (#7) 2026-10-18 18:08:19 +03:00
ksv741 a45f750feb build: deploy frontend 1.1.0 and backend 2.1.0 (#5) 2026-10-13 07:48:50 +03:00
ksv741 87c8411115 build: release application bundle 0.1.3 2026-10-07 18:56:21 +03:00
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
ksv741 2f8163e53d build: release persistent T-Bank deployment 2026-09-27 00:31:39 +03:00
4 changed files with 49 additions and 77 deletions
+16 -10
View File
@@ -12,9 +12,16 @@
изменения рабочего checkout не подменяют содержимое релиза. изменения рабочего checkout не подменяют содержимое релиза.
Образы загружаются из `nexus.personal.ksv741.ru/docker-hosted/wealthledger/`. Образы загружаются из `nexus.personal.ksv741.ru/docker-hosted/wealthledger/`.
Комплект `v0.1.1` закрепляет frontend `0.3.0` с PWA и backend `0.2.3`; Комплект `v0.1.8` закрепляет frontend `1.3.0` и backend `2.4.0`.
образы опубликованы для `linux/amd64`. Не создавайте тег комплекта до проверки Комплект `v0.1.6` закрепляет backend `2.3.0`, образ которого не был опубликован.
пары по разделу выпуска. Работа новой пары на сервере не проверялась перед публикацией комплекта.
OpenAPI-snapshot frontend `1.3.0` побайтно совпадает с контрактом backend `2.4.0`.
Backend `2.4.0` добавляет одну аддитивную миграцию: таблицу
`TbankInstrumentClassification` (только `CREATE TABLE`). Штатный `deploy.sh`
выполняет остановку backend, backup и `prisma migrate deploy`; существующие
данные, `JWT_SECRET` и `TOKEN_ENCRYPTION_KEY` сохраняются. Откат на `v0.1.7`
с этой таблицей в базе не проверялся.
## Подготовка сервера ## Подготовка сервера
@@ -126,12 +133,11 @@ Backups и `.deploy` всегда находятся рядом с фактич
1. Проверка тега, настроек, данных и CA; загрузка образов и проверка прав 1. Проверка тега, настроек, данных и CA; загрузка образов и проверка прав
runtime user. Ошибка до остановки сохраняет работающие сервисы. runtime user. Ошибка до остановки сохраняет работающие сервисы.
2. Сравнение образов и конфигурации с реальными контейнерами. При исправной 2. Stop backend → копия **всего** data directory с WAL/SHM → migration →
неизменной установке — no-op. Если изменился только frontend, backend backend readiness. Только для `--init` пустой базы backup не требуется.
продолжает работать, backup и migration не запускаются. 3. Запуск frontend после готовности backend. Каждый вызов выполняет этот путь,
3. Если изменился backend или его конфигурация: stop → копия **всего** data включая повтор того же тега и frontend-only update. No-op/change detection
directory с WAL/SHM → migration → backend readiness. Неизменный frontend отсутствуют; приняты короткий простой API и дополнительный backup.
не пересоздаётся; если меняется и он, запускается после готовности backend.
4. Успех записывается в `.deploy/current` только после проверки запуска. 4. Успех записывается в `.deploy/current` только после проверки запуска.
Краткий простой допустим. После обновления проверьте через HTTPS deep link, Краткий простой допустим. После обновления проверьте через HTTPS deep link,
login/refresh, существующие карты и чтение зашифрованного подключения. login/refresh, существующие карты и чтение зашифрованного подключения.
@@ -236,7 +242,7 @@ cp -a /opt/wealthledger/backups/CHOSEN_BACKUP/data /opt/wealthledger/data
``` ```
Tests используют настоящий Git, backup-файлы и file locks, а Docker заменяют Tests используют настоящий Git, backup-файлы и file locks, а Docker заменяют
управляемым double. Проверяются update/no-op, смена настроек, сохранение ключей, управляемым double. Проверяются полные повторные updates, настройки, ключи,
ошибки pull/backup/migration/readiness, конкуренция и незавершённая операция. ошибки pull/backup/migration/readiness, конкуренция и незавершённая операция.
Отдельная проверка реального Docker для restore: `bash tests/deploy.sh --docker-restore`. Отдельная проверка реального Docker для restore: `bash tests/deploy.sh --docker-restore`.
Она создаёт временный project, заменяет bind-каталог и проверяет пересоздание Она создаёт временный project, заменяет bind-каталог и проверяет пересоздание
+19 -42
View File
@@ -166,49 +166,26 @@ quiet compose run --rm --no-deps -T --entrypoint node backend -e '
process.exit(1); process.exit(1);
' || die 'Image preflight failed: check data permissions, CA certificate and keys' ' || die 'Image preflight failed: check data permissions, CA certificate and keys'
matches() { mark stop
local id=$1 service=$2 image=$3 name hash quiet compose stop backend
[[ -n $id && -s $state/current ]] || return 1 [[ -z $backend || $(inspect "$backend" '{{.State.Running}}') == false ]] || die 'Backend did not stop'
read -r name hash <<< "$(compose config --hash "$service")" migration=$(container migration)
[[ $name == "$service" && $hash =~ ^[0-9a-f]{64}$ ]] || return 1 [[ -z $migration || $(inspect "$migration" '{{.State.Running}}') == false ]] || die 'A migration writer appeared; backup was not started'
[[ $(inspect "$id" '{{index .Config.Labels "com.docker.compose.config-hash"}}') == "$hash" && \ if [[ $initialize != yes ]]; then
$(inspect "$id" '{{.Image}}') == "$(dock image inspect --format '{{.Id}}' "$image")" && \ mark backup
$(inspect "$id" '{{.State.Running}} {{if .State.Health}}{{.State.Health.Status}}{{end}}') == 'true healthy' ]] mkdir -p -- "$backups"
} partial=$(mktemp -d "$backups/.partial-$(date -u +%Y%m%dT%H%M%SZ).XXXXXXXX")
change_backend=yes change_frontend=yes quiet cp -a -- "$data" "$partial/data" || die 'Backup failed; partial copy is NOT usable; backend remains stopped'
if matches "$backend" backend "$backend_image"; then change_backend=no; fi backup="$backups/$(basename -- "$partial" | sed 's/^\.partial-//')"
if matches "$frontend" frontend "$frontend_image"; then change_frontend=no; fi mv -- "$partial" "$backup"
if [[ $change_backend == no && $change_frontend == no ]]; then printf 'Backup: %s\n' "$backup"
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
fi 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 stage=ready
record > "$state/current.tmp"; mv -- "$state/current.tmp" "$state/current" record > "$state/current.tmp"; mv -- "$state/current.tmp" "$state/current"
rm -- "$state/pending" rm -- "$state/pending"
+3 -3
View File
@@ -1,3 +1,3 @@
# Verify this pair before publishing the deployment release tag. # Pinned application images for this deployment bundle.
FRONTEND_IMAGE=nexus.personal.ksv741.ru/docker-hosted/wealthledger/frontend:0.3.0 FRONTEND_IMAGE=nexus.personal.ksv741.ru/docker-hosted/wealthledger/frontend:1.3.0
BACKEND_IMAGE=nexus.personal.ksv741.ru/docker-hosted/wealthledger/backend:0.2.3 BACKEND_IMAGE=nexus.personal.ksv741.ru/docker-hosted/wealthledger/backend:2.4.0
+11 -22
View File
@@ -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' cat > "$sandbox/bin/docker" <<'PY'
#!/usr/bin/env python3 #!/usr/bin/env python3
import hashlib, json, os, pathlib, sys import json, os, pathlib, sys
base = pathlib.Path(__file__).resolve().parent.parent base = pathlib.Path(__file__).resolve().parent.parent
case = pathlib.Path((base / 'case').read_text()) case = pathlib.Path((base / 'case').read_text())
args = sys.argv[1:] 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: with (case / 'calls').open('a') as log:
log.write(json.dumps(args) + '\n') log.write(json.dumps(args) + '\n')
def save(): db.write_text(json.dumps(state)) 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') if args[0] == 'context': print('unix:///test/docker.sock')
elif args[0] == 'info': pass elif args[0] == 'info': pass
elif args[0] == 'ps': elif args[0] == 'ps':
service = next(a.split('=')[-1] for a in args if 'com.docker.compose.service=' in a) service = next(a.split('=')[-1] for a in args if 'com.docker.compose.service=' in a)
if service in state: print(service) if service in state: print(service)
elif args[0] == 'image': print(image_id(args[-1]))
elif args[0] == 'inspect': elif args[0] == 'inspect':
fmt, name = args[2], args[3] fmt, name = args[2], args[3]
c = state[name] c = state[name]
if '.Mounts' in fmt: print(str(case / 'data')) 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.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 '.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') elif '.State.Running' in fmt: print('true' if c['running'] else 'false')
else: raise AssertionError(fmt) else: raise AssertionError(fmt)
elif args[0] == 'compose': elif args[0] == 'compose':
@@ -103,13 +98,8 @@ elif args[0] == 'compose':
k,v = line.split('=',1); env[k] = v k,v = line.split('=',1); env[k] = v
cmd = args[args.index('-f')+2:] cmd = args[args.index('-f')+2:]
def ref(service): return env['FRONTEND_IMAGE' if service == 'frontend' else 'BACKEND_IMAGE'] 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 cmd[0] == 'config':
if '--environment' in cmd: print('\n'.join(k+'='+v for k,v in env.items())) 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': elif cmd[0] == 'pull':
if fault == 'pull': sys.exit(1) if fault == 'pull': sys.exit(1)
elif cmd[0] == 'run': elif cmd[0] == 'run':
@@ -125,8 +115,7 @@ elif args[0] == 'compose':
if fault == 'migration': sys.exit(1) if fault == 'migration': sys.exit(1)
with (case/'data/wealthledger.db').open('a') as f: f.write(' migrated') with (case/'data/wealthledger.db').open('a') as f: f.write(' migrated')
else: else:
state[name] = dict(image=ref(name), hash=service_hash(name), env=env, running=True, state[name] = dict(image=ref(name), env=env, running=True)
health='unhealthy' if fault == name else 'healthy')
save() save()
if fault == name: sys.exit(1) if fault == name: sys.exit(1)
else: raise AssertionError(cmd) 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' contains "$case_dir/.deploy/current" 'backend=registry.test/backend:1.0.0'
: > "$case_dir/calls" : > "$case_dir/calls"
ok v1.0.0 ok v1.0.0
unchanged_runtime contains "$case_dir/calls" '"stop", "backend"'
[[ $(count_backups) == 1 ]] [[ $(count_backups) == 2 ]]
: > "$case_dir/calls" : > "$case_dir/calls"
ok v1.0.1 ok v1.0.1
! grep -Eq '"(stop|up)".*"(backend|migration)"' "$case_dir/calls" contains "$case_dir/calls" '"stop", "backend"'
[[ $(count_backups) == 1 ]] [[ $(count_backups) == 3 ]]
: > "$case_dir/calls" : > "$case_dir/calls"
ok v1.1.0 ok v1.1.0
! grep -Eq '"(stop|up)".*"frontend"' "$case_dir/calls" contains "$case_dir/calls" '"frontend"'
[[ $(count_backups) == 2 ]] [[ $(count_backups) == 4 ]]
printf '\nLOG_LEVEL=debug\n' >> "$case_dir/production.env" printf '\nLOG_LEVEL=debug\n' >> "$case_dir/production.env"
ok v1.1.0 ok v1.1.0
[[ $(count_backups) == 3 ]] [[ $(count_backups) == 5 ]]
printf '\nJWT_SECRET=changed-secret\n' >> "$case_dir/production.env" printf '\nJWT_SECRET=changed-secret\n' >> "$case_dir/production.env"
: > "$case_dir/calls" : > "$case_dir/calls"
fails v1.1.0 fails v1.1.0
unchanged_runtime unchanged_runtime
! grep -q 'changed-secret\|test-secret-that' "$case_dir/output" ! 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 new_case init
rm "$case_dir/data/"* 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' ;; copy) ! grep -Eq '"up".*"migration"' "$case_dir/calls"; contains "$case_dir/output" 'Backup failed' ;;
migration) ! grep -Eq '"up".*"backend"' "$case_dir/calls" ;; migration) ! grep -Eq '"up".*"backend"' "$case_dir/calls" ;;
backend) contains "$case_dir/calls" '"stop", "backend"' ;; 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 esac
if [[ $fault != pull && $fault != preflight ]]; then if [[ $fault != pull && $fault != preflight ]]; then
[[ -f $case_dir/.deploy/pending ]] [[ -f $case_dir/.deploy/pending ]]