feat: add versioned deployment bundle
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
production.env
|
||||
data/
|
||||
certs/
|
||||
backups/
|
||||
.deploy/
|
||||
@@ -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 <container>`; перед передачей логов
|
||||
убедитесь, что в них нет чувствительных данных.
|
||||
|
||||
## Ручной откат и восстановление
|
||||
|
||||
`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, автоматически выбирающий последние образы.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
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