commit 6f85fcb0f6e6e0be2f86bda20d3349191960f996 Author: Sergey Krylov Date: Sun Sep 20 18:48:55 2026 +0300 feat: add versioned deployment bundle diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e3bc697 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Store the filled file OUTSIDE this checkout, e.g. /opt/wealthledger/production.env. +# chmod 600 production.env. Preserve existing keys when adopting an installation. +FRONTEND_ORIGIN=https://wealthledger.example.com +FRONTEND_PORT=8080 +DATA_DIRECTORY=/opt/wealthledger/data +TBANK_CA_CERT_FILE=/opt/wealthledger/certs/tbank-ca.pem +TBANK_CA_CERT_PATH=/certs/tbank-ca.pem +JWT_SECRET= +TOKEN_ENCRYPTION_KEY= +LOG_LEVEL=info diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d32754 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.env +.env.* +!.env.example +production.env +data/ +certs/ +backups/ +.deploy/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f381ce --- /dev/null +++ b/README.md @@ -0,0 +1,255 @@ +# WealthLedger deploy + +Установка и обновление одного Linux-сервера с Docker Compose и SQLite: + +```bash +./deploy.sh vX.Y.Z +``` + +`vX.Y.Z` — Git-тег **этого репозитория**. Его [release.env](release.env) +закрепляет полные адреса frontend и backend. Версии компонентов могут различаться. +Скрипт получает тег из `origin` и выполняет код и Compose выбранного commit; +изменения рабочего checkout не подменяют содержимое релиза. + +Образы загружаются из `nexus.personal.ksv741.ru/docker-hosted/wealthledger/`. +Текущий кандидат пары — frontend/backend `0.2.3`; образы опубликованы для +`linux/amd64`. Не создавайте тег комплекта до проверки пары по разделу выпуска. + +## Подготовка сервера + +Выполняется один раз. Автоматическая установка Docker, настройка DNS, HTTPS, +firewall и registry в скрипт не входят. + +1. Установите Git, Bash, Docker Engine с Compose plugin (2.24.4 или новее), + `flock` из util-linux и стандартные `tar`, `cp`, `mktemp`. Используется + локальный Docker daemon через Unix socket, не удалённый context. +2. Настройте внешний HTTPS reverse proxy: весь домен направляется на + `127.0.0.1:8080`. Отдельный upstream `/api` не нужен. Порт можно поменять + через `FRONTEND_PORT`; `FRONTEND_ORIGIN` должен совпадать с HTTPS origin. +3. Выберите одного оператора с доступом к Docker, Git origin, ключам и данным. + Скрипту нужны права читать всю БД и создавать копии. Процесс backend работает + с UID/GID `1000:1000`; при другом UID оператора настройте доступ заранее. + Запускайте команды из одной и той же учётной записи, чтобы Git и Docker + использовали подготовленные SSH-ключи и registry credentials. +4. Войдите в Nexus, вводя пароль интерактивно, без записи в shell history: + + ```bash + docker login nexus.personal.ksv741.ru --username wealthledger-ci + ``` + +5. Разместите checkout отдельно от постоянных ресурсов. Пример: + + ```text + /opt/wealthledger/ + ├── deploy/ # этот Git checkout + │ ├── compose.yaml + │ ├── deploy.sh + │ ├── release.env # версии frontend и backend + │ ├── .env.example + │ └── README.md + ├── production.env # секреты и настройки, chmod 600 + ├── certs/tbank-ca.pem # CA для подключения к T-Bank, не HTTPS сайта + ├── data/wealthledger.db + ├── backups/ # согласованные копии, без автоочистки + └── .deploy/ # lock, current и при сбое pending + ``` + + ```bash + git clone ssh://git@git.personal.ksv741.ru:2222/WealthLedger/deploy.git /opt/wealthledger/deploy + cd /opt/wealthledger/deploy + install -m 600 .env.example ../production.env + ``` + + Последнюю команду выполняйте только при отсутствии `production.env`: + существующий файл с ключами не перезаписывайте. + +6. Заполните [настройки](.env.example) защищённым редактором. `DATA_DIRECTORY` + и `TBANK_CA_CERT_FILE` — существующие абсолютные пути, не внутри checkout. + `TBANK_CA_CERT_PATH` — путь внутри контейнера; сертификат монтируется read-only. + Каталог данных должен быть доступен для записи UID 1000, сертификат — для + чтения. Не отключайте проверку TLS вместо установки корректного CA. +7. Проверьте доступ к образам, свободное место под полную копию data и существующую + схему HTTPS. Сохраните ключ шифрования отдельно от локальных backups. + +Не храните рабочие секреты в Git, не выполняйте `source production.env` и не +печатайте полный `docker compose config` с реальными ключами. + +## Подключение существующей установки + +Сохраните её Compose и текущие image references до перехода. Заполните +`production.env` **прежними** `JWT_SECRET` и `TOKEN_ENCRYPTION_KEY` и укажите +действующий data directory и сертификат. Не копируйте один `.db` из работающего +приложения и не создавайте новые ключи: старые подключения должны расшифровываться. + +Проект Compose должен называться `wealthledger`, сервисы — `backend`, `frontend` +и `migration`, база — `wealthledger.db`. Оператор должен исключить другие +процессы записи в этот каталог; старый и новый способы запуска параллельно +не используются. Скрипт откажется менять data mount или действующие ключи. + +```bash +cd /opt/wealthledger/deploy +./deploy.sh vX.Y.Z +``` + +Первый переход всегда выполняет полный цикл с backup, поскольку ещё нет +подтверждённого baseline комплекта. Данные не переносятся и не переименовываются. +`--init` для действующей установки не применяется. + +## Новая установка + +Только для нового пустого data directory подготовьте UID/GID `1000:1000`, +permissions `0700` и новые ключи: JWT secret не короче 32 символов и отдельный +32-byte encryption key в base64. Например, `openssl rand -hex 32` и +`openssl rand -base64 32` в защищённом терминале; значения перенесите в env. +Предварительно установите сертификат T-Bank. + +```bash +./deploy.sh vX.Y.Z --init +``` + +Отсутствие файла БД без `--init` считается ошибкой, а не разрешением создать +пустую базу. `--init` отклоняется при наличии прежних контейнеров, успешной +установки или непустого data directory. Для первой пустой базы backup не нужен. + +Если env расположен иначе, используйте `--env-file /absolute/path/production.env`. +Backups и `.deploy` всегда находятся рядом с фактическим data directory, +поэтому другой env-файл не создаёт отдельную блокировку для той же базы. + +## Обновление + +После публикации нового тега комплекта достаточно той же команды: + +```bash +./deploy.sh vX.Y.Z +``` + +1. Проверка тега, настроек, данных и CA; загрузка образов и проверка прав + runtime user. Ошибка до остановки сохраняет работающие сервисы. +2. Сравнение образов и конфигурации с реальными контейнерами. При исправной + неизменной установке — no-op. Если изменился только frontend, backend + продолжает работать, backup и migration не запускаются. +3. Если изменился backend или его конфигурация: stop → копия **всего** data + directory с WAL/SHM → migration → backend readiness. Неизменный frontend + не пересоздаётся; если меняется и он, запускается после готовности backend. +4. Успех записывается в `.deploy/current` только после проверки запуска. + Краткий простой допустим. После обновления проверьте через HTTPS deep link, + login/refresh, существующие карты и чтение зашифрованного подключения. + +Проверка сертификата/прав запускает кратковременный контейнер с Node validation, +не второй API. Контейнерная готовность не заменяет browser smoke. + +## Ошибка или прерывание + +Скрипт возвращает ненулевой код, этап ошибки, предыдущие images и расположение +готовой копии. Он не печатает raw application logs или секреты. После начала +изменений `.deploy/pending` сохраняет безопасные сведения для восстановления; +следующая команда остановится, пока оператор не разберёт инцидент. + +- Ошибка backup: миграции не запускались, backend остановлен. Скрытые каталоги + `backups/.partial-*` — незавершённые копии, использовать их для restore нельзя. +- Ошибка migration: новый backend не запускается. Не применяйте `migrate reset` + или автоматический `resolve`; сначала выясните состояние схемы. +- Ошибка готовности backend: неисправный backend останавливается. Старый image + автоматически не запускается — он может быть несовместим с новой схемой. +- Ошибка frontend: исправный backend остаётся работающим, БД не восстанавливается. +- После жёсткого прерывания migration может ещё работать: перед любым копированием + или восстановлением проверьте контейнеры и остановите писателей. + +Посмотрите `docker ps -a` и `.deploy/pending`. Для диагностики соответствующего +контейнера используйте локально `docker logs `; перед передачей логов +убедитесь, что в них нет чувствительных данных. + +## Ручной откат и восстановление + +`deploy.sh` не является автоматическим rollback: выбор старого тега сам по себе +не доказывает совместимость со схемой. Сначала проверьте план на отдельной копии +данных с прежним ключом, другим Compose project и отдельным портом. + +Подготовьте снимок выбранного **совместимого** тега для ручных Compose-команд: + +```bash +RECOVERY_BUNDLE=$(mktemp -d) +git archive vCOMPATIBLE.VERSION.HERE | tar -x -C "$RECOVERY_BUNDLE" +unset FRONTEND_IMAGE BACKEND_IMAGE COMPOSE_FILE COMPOSE_PROJECT_NAME COMPOSE_PROFILES +COMPOSE=(docker compose --project-name wealthledger \ + --env-file /opt/wealthledger/production.env \ + --env-file "$RECOVERY_BUNDLE/release.env" -f "$RECOVERY_BUNDLE/compose.yaml") +``` + +Замените демонстрационный тег настоящим. Используйте Bash и реальные абсолютные +пути. Для image rollback без изменения данных: + +```bash +"${COMPOSE[@]}" stop backend migration +"${COMPOSE[@]}" pull backend +"${COMPOSE[@]}" up -d --no-deps --wait --wait-timeout 120 backend +``` + +Для frontend аналогично выполняются `pull frontend` и `up ... frontend`. +Migration при image rollback намеренно не запускается. + +**Restore — отдельное решение с потерей всех записей после выбранного backup.** +После проверки целостности выбранной копии и наличия её ключа, при остановленных +писателях сохраните текущие данные под новым именем, не удаляя их: + +```bash +"${COMPOSE[@]}" stop backend migration +mv /opt/wealthledger/data /opt/wealthledger/data-before-restore-UNIQUE_TIMESTAMP +cp -a /opt/wealthledger/backups/CHOSEN_BACKUP/data /opt/wealthledger/data +"${COMPOSE[@]}" up -d --no-deps --force-recreate --wait --wait-timeout 120 backend +``` + +Подставьте уникальное ещё не существующее имя и конкретный backup. Убедитесь, +что backend именно пересоздан: после замены bind-каталога простой stop/start +может сохранить mount прежнего каталога и вернуть более новые данные. +Проверьте owner `1000:1000`, `PRAGMA integrity_check`, доступность данных, auth, карты +и расшифровку подключения. Убедитесь, что восстановленный image совместим +со схемой; старые migration-файлы не являются down-migrations. + +Только после проверки успешного recovery переместите `.deploy/pending` в файл +инцидента, например `.deploy/recovered-UNIQUE_TIMESTAMP`, и повторите smoke. +Если новый релиз ещё не установлен, текущая запись может оставаться прежней: +следующее обновление сверяется с реальными контейнерами. Файл `lock` не удаляйте: +блокировка освобождается процессом; удаление файла при живом процессе опасно. + +## Резервные копии + +Скрипт создаёт уникальный каталог и не перезаписывает прежние копии. Готовой +копия становится только после полного успешного `cp -a`; частичные копии +отмечены префиксом `.partial-`. Проверяйте восстановление на отдельной копии. + +Очистка только ручная. Контролируйте свободное место; не удаляйте backup, +нужный незавершённому обновлению. Копия на том же диске не защищает от его потери. +Независимое хранение ключа обязательно; внешнее backup-хранилище сюда не входит. + +## Выпуск комплекта и проверки + +1. Дождитесь публикации компонентов в Nexus. В `release.env` закрепите ровно + `FRONTEND_IMAGE` и `BACKEND_IMAGE` с version tags. Не используйте `latest`. +2. Выполните локальный gate (Python 3 нужен только для tests): + + ```bash + bash -n deploy.sh tests/deploy.sh + bash tests/deploy.sh + git diff --check + ``` + + Tests используют настоящий Git, backup-файлы и file locks, а Docker заменяют + управляемым double. Проверяются update/no-op, смена настроек, сохранение ключей, + ошибки pull/backup/migration/readiness, конкуренция и незавершённая операция. + Отдельная проверка реального Docker для restore: `bash tests/deploy.sh --docker-restore`. + Она создаёт временный project, заменяет bind-каталог и проверяет пересоздание + контейнера. На arm64 для проверки текущих amd64 images можно использовать + `DOCKER_DEFAULT_PLATFORM=linux/amd64` при наличии Docker emulation. +3. На disposable Linux-установке с настоящими images проверьте `--init`, adoption, + frontend-only/backend-only update, повтор, backup/restore и HTTPS smoke. + Сверьте OCI revision images с release commits компонентов. Для проверки + кандидата используйте временный Git origin/tag, не двигайте опубликованный тег. +4. После review и проверок примите commit в `main`, создайте аннотированный + `vX.Y.Z` именно на проверенном commit и отправьте этот тег. Версия комплекта + самостоятельная: одинаковые номера frontend/backend не требуются. + +Выпуск компонента не выпускает комплект и не обновляет сервер автоматически. +Опубликованные Git и Nexus tags не переназначаются. Для исправления нужен +новый релиз комплекта. В репозитории не нужны npm, собственный release framework +или pipeline, автоматически выбирающий последние образы. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..ed1c87b --- /dev/null +++ b/compose.yaml @@ -0,0 +1,58 @@ +name: wealthledger + +x-backend: &backend + image: ${BACKEND_IMAGE:?Set versioned backend image in release.env} + environment: + NODE_ENV: production + PORT: '3000' + DATABASE_URL: file:/data/wealthledger.db + TBANK_CA_CERT_PATH: ${TBANK_CA_CERT_PATH:-/certs/tbank-ca.pem} + FRONTEND_ORIGIN: ${FRONTEND_ORIGIN:?Set browser-visible HTTPS origin} + JWT_SECRET: ${JWT_SECRET:?Keep the existing JWT secret} + TOKEN_ENCRYPTION_KEY: ${TOKEN_ENCRYPTION_KEY:?Keep the existing encryption key} + LOG_LEVEL: ${LOG_LEVEL:-info} + volumes: + - type: bind + source: ${DATA_DIRECTORY:?Set existing absolute data directory} + target: /data + bind: + create_host_path: false + - type: bind + source: ${TBANK_CA_CERT_FILE:?Set existing absolute CA certificate filename} + target: ${TBANK_CA_CERT_PATH:-/certs/tbank-ca.pem} + read_only: true + bind: + create_host_path: false + read_only: true + init: true + security_opt: [no-new-privileges:true] + cap_drop: [ALL] + +services: + migration: + <<: *backend + command: [node, node_modules/prisma/build/index.js, migrate, deploy] + restart: 'no' + healthcheck: + disable: true + + backend: + <<: *backend + scale: 1 + restart: unless-stopped + depends_on: + migration: + condition: service_completed_successfully + + frontend: + image: ${FRONTEND_IMAGE:?Set versioned frontend image in release.env} + ports: + - '127.0.0.1:${FRONTEND_PORT:-8080}:8080' + read_only: true + tmpfs: [/tmp] + security_opt: [no-new-privileges:true] + cap_drop: [ALL] + restart: unless-stopped + depends_on: + backend: + condition: service_healthy diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..1a587c0 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,215 @@ +#!/usr/bin/env bash +# One local Linux Docker host. Run from the checkout; never source env files. +set +x +set -euo pipefail +umask 077 + +die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; } +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) + +if [[ ${1-} != --snapshot ]]; then + tag=${1-} + [[ $tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || die 'Usage: ./deploy.sh vX.Y.Z [--env-file /path/production.env] [--init]' + shift + config="$root/../production.env" + initialize=no + while (($#)); do + case $1 in + --init) initialize=yes; shift ;; + --env-file) (($# >= 2)) || die 'Missing env filename'; config=$2; shift 2 ;; + *) die 'Unknown argument' ;; + esac + done + [[ -f $config && -r $config ]] || die 'Create a readable production.env outside the checkout first' + config="$(cd -- "$(dirname -- "$config")" && pwd -P)/$(basename -- "$config")" + [[ $config != "$root/"* ]] || die 'Server env must be outside the checkout' + git -C "$root" fetch --quiet --no-tags origin "refs/tags/$tag:refs/tags/$tag" || die 'Cannot fetch tag; absent or changed tags are not accepted' + commit=$(git -C "$root" rev-parse --verify "refs/tags/$tag^{commit}") + snapshot=$(mktemp -d "${TMPDIR:-/tmp}/wealthledger-release.XXXXXXXX") + trap 'rm -rf -- "$snapshot"' EXIT + git -C "$root" archive "$commit" | tar -x -C "$snapshot" + [[ -f $snapshot/deploy.sh ]] || die 'The selected tag has no deploy.sh' + bash "$snapshot/deploy.sh" --snapshot "$tag" "$commit" "$config" "$initialize" "$root" + exit +fi + +# Internal handoff: execute the script and Compose file from the SAME commit. +(($# == 6)) || die 'Invalid snapshot invocation' +tag=$2 commit=$3 config=$4 initialize=$5 checkout=$6 +[[ $tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ && $commit =~ ^[0-9a-f]{40}$ ]] || die 'Invalid release identity' +for tool in docker flock; do command -v "$tool" >/dev/null || die "Install $tool first"; done + +# Docker transport/auth settings are allowed; caller COMPOSE_* and image/env +# overrides are not. Compose alone parses production.env and release.env. +docker_env=(env -i "PATH=$PATH" "HOME=$HOME") +for key in DOCKER_HOST DOCKER_CONTEXT DOCKER_CONFIG DOCKER_TLS_VERIFY DOCKER_CERT_PATH DOCKER_DEFAULT_PLATFORM; do + [[ -z ${!key-} ]] || docker_env+=("$key=${!key}") +done +dock() { "${docker_env[@]}" docker "$@"; } +compose() { + dock compose --project-name wealthledger --project-directory "$root" \ + --env-file "$config" --env-file "$root/release.env" -f "$root/compose.yaml" "$@" +} +quiet() { "$@" >/dev/null 2>&1; } + +frontend_image= backend_image= +while IFS= read -r line || [[ -n $line ]]; do + case $line in + ''|'#'*) continue ;; + FRONTEND_IMAGE=*) [[ -z $frontend_image ]] || die 'Duplicate frontend image'; frontend_image=${line#*=} ;; + BACKEND_IMAGE=*) [[ -z $backend_image ]] || die 'Duplicate backend image'; backend_image=${line#*=} ;; + *) die 'release.env may contain only the two image references' ;; + esac +done < "$root/release.env" +for image in "$frontend_image" "$backend_image"; do + [[ $image =~ ^[a-zA-Z0-9./:_-]+:[0-9]+\.[0-9]+\.[0-9]+$ ]] || die 'Use explicit versioned images in release.env' +done +quiet compose config --quiet || die 'Invalid Compose configuration or missing settings' +settings=$(compose config --environment 2>/dev/null) || die 'Cannot read Compose settings' +setting() { printf '%s\n' "$settings" | sed -n "s/^$1=//p"; } +data=$(setting DATA_DIRECTORY) +cert=$(setting TBANK_CA_CERT_FILE) +[[ $data == /* && -d $data && $cert == /* && -f $cert && -r $cert ]] || die 'Data directory and CA certificate must exist at absolute paths' +data=$(cd -- "$data" && pwd -P) +[[ $data != / && $data != "$checkout" && $data != "$checkout/"* && $cert != "$checkout/"* ]] || die 'Persistent resources must be outside the checkout' +[[ $config != "$data/"* ]] || die 'Keep server secrets outside the data backup' +parent=$(dirname -- "$data") +state="$parent/.deploy" +backups="$parent/backups" +[[ $data != "$state" && $data != "$backups" && $data != "$backups/"* ]] || die 'Data overlaps deployment state or backups' +[[ ! -L $state && ! -L $backups ]] || die 'State and backups must not be symlinks' +mkdir -p -- "$state" +exec 9>"$state/lock" +flock -n 9 || die 'Another deployment is running' +[[ ! -e $state/pending ]] || die "Unfinished deployment: inspect $state/pending and follow README recovery before retrying" + +# A remote daemon would back up this machine while modifying another host. +endpoint=${DOCKER_HOST-} +if [[ -z $endpoint || -n ${DOCKER_CONTEXT-} ]]; then + endpoint=$(dock context inspect --format '{{.Endpoints.docker.Host}}' 2>/dev/null) +fi +[[ $endpoint == unix://* ]] || die 'Use a local Docker daemon, not a remote context' +quiet dock info || die 'Docker daemon is unavailable' + +container() { + local ids + ids=$(dock ps -aq --filter label=com.docker.compose.project=wealthledger \ + --filter "label=com.docker.compose.service=$1" --filter label=com.docker.compose.oneoff=False) + [[ $ids != *$'\n'* ]] || die "Multiple $1 containers; resolve before deployment" + printf '%s' "$ids" +} +inspect() { dock inspect --format "$2" "$1"; } +backend=$(container backend) +frontend=$(container frontend) +migration=$(container migration) +[[ -z $migration || $(inspect "$migration" '{{.State.Running}}') == false ]] || die 'Migration is already running' +if [[ $initialize == yes ]]; then + [[ -z $backend && -z $frontend && -z $migration && ! -e $state/current ]] || die '--init is only for a new installation' + [[ -z $(ls -A -- "$data") ]] || die '--init requires an empty data directory' +else + [[ -s $data/wealthledger.db ]] || die 'Existing wealthledger.db is missing; use --init only for a NEW empty installation' +fi +for file in wealthledger.db wealthledger.db-wal wealthledger.db-shm; do + [[ ! -L $data/$file ]] || die 'Database files must not be symlinks; a backup must contain the data, not links' +done + +previous_backend=none previous_frontend=none +if [[ -n $backend ]]; then + mounted=$(inspect "$backend" '{{range .Mounts}}{{if eq .Destination "/data"}}{{.Source}}{{end}}{{end}}') + [[ $mounted == "$data" ]] || die 'Existing backend uses another data directory; refusing to replace it' + previous_backend=$(inspect "$backend" '{{.Config.Image}}') + existing_env=$(inspect "$backend" '{{range .Config.Env}}{{println .}}{{end}}') + for key in JWT_SECRET TOKEN_ENCRYPTION_KEY; do + old=$(printf '%s\n' "$existing_env" | sed -n "s/^$key=//p") + [[ -n $old && $old == "$(setting "$key")" ]] || die "Preserve the existing $key when updating this database" + done + unset existing_env old +fi +[[ -z $frontend ]] || previous_frontend=$(inspect "$frontend" '{{.Config.Image}}') +unset settings + +stage=preflight backup=none +record() { + printf 'release=%s\ncommit=%s\nbackend=%s\nfrontend=%s\nprevious_backend=%s\nprevious_frontend=%s\nstage=%s\nbackup=%s\n' \ + "$tag" "$commit" "$backend_image" "$frontend_image" "$previous_backend" "$previous_frontend" "$stage" "$backup" +} +mark() { stage=$1; record > "$state/pending.tmp"; mv -- "$state/pending.tmp" "$state/pending"; } +finish() { + local code=$? + trap - EXIT + if ((code != 0)); then + if [[ $stage == backend-start ]]; then quiet compose stop backend || true; fi + if [[ $stage == migration ]]; then quiet compose stop migration || true; fi + printf 'Deployment failed at %s (exit %s). No automatic rollback.\n' "$stage" "$code" >&2 + printf 'Previous: frontend=%s backend=%s\nBackup: %s\n' "$previous_frontend" "$previous_backend" "$backup" >&2 + [[ ! -f $state/pending ]] || printf 'Recovery record: %s/pending\n' "$state" >&2 + fi + exit "$code" +} +trap finish EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +printf 'Preparing %s (%s)...\n' "$tag" "$commit" +quiet compose pull frontend backend migration || die 'Image pull failed; running services were not stopped' +# Runs only validation, never the API entrypoint or migrations. Check permissions +# as the image's real user and validate the mounted certificate before downtime. +quiet compose run --rm --no-deps -T --entrypoint node backend -e ' + const fs = require("node:fs"), crypto = require("node:crypto"); + fs.accessSync("/data", fs.constants.R_OK | fs.constants.W_OK); + for (const name of ["wealthledger.db", "wealthledger.db-wal", "wealthledger.db-shm"]) + if (fs.existsSync("/data/" + name)) + fs.accessSync("/data/" + name, fs.constants.R_OK | fs.constants.W_OK); + new crypto.X509Certificate(fs.readFileSync(process.env.TBANK_CA_CERT_PATH)); + if ((process.env.JWT_SECRET || "").length < 32 || + Buffer.from(process.env.TOKEN_ENCRYPTION_KEY || "", "base64").length !== 32) + 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 +fi +stage=ready +record > "$state/current.tmp"; mv -- "$state/current.tmp" "$state/current" +rm -- "$state/pending" +printf 'Installed %s. frontend=%s backend=%s\n' "$tag" "$frontend_image" "$backend_image" diff --git a/release.env b/release.env new file mode 100644 index 0000000..3134563 --- /dev/null +++ b/release.env @@ -0,0 +1,3 @@ +# Verify this pair before publishing the deployment release tag. +FRONTEND_IMAGE=nexus.personal.ksv741.ru/docker-hosted/wealthledger/frontend:0.2.3 +BACKEND_IMAGE=nexus.personal.ksv741.ru/docker-hosted/wealthledger/backend:0.2.3 diff --git a/tests/deploy.sh b/tests/deploy.sh new file mode 100755 index 0000000..9447a7e --- /dev/null +++ b/tests/deploy.sh @@ -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" < {}, 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.'