feat(s21): stage→prod migration toolchain (7 скриптов + workflow)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run

1. deploy/check-prod-readiness.sh — pre-deploy gating: backup<60min,
   disk≥5GB на /opt+/var/lib/docker, /health/ready=Healthy, .env
   required-keys без placeholder'ов. --ssh-host для удалённой проверки.

2. deploy/prod-deploy.sh <api-tag> <web-tag> — blue-green release:
   pull → green-контейнер на :8088 → migrations (auto) → smoke
   (/health/ready + /api/me с тест-токеном) → nginx upstream switch
   → swap → docker compose up -d с обновлённым тэгом. Failure →
   удаление green, blue остаётся. --skip-web флаг.

3. deploy/prod-rollback.sh <to-tag> — docker pull (если нужно) →
   docker compose up -d --force-recreate с указанным tag'ом → wait
   /health/ready до 120с. --dry-run + --skip-web.

4. deploy/post-deploy-smoke.sh — 10 шагов (signup → login →
   /api/me → list products/counterparties/stores/stock → create+delete
   product → logout-via-session). JSON парсится через python3
   (не grep — споткнулись на пробеле перед `:` в access_token).
   Telegram-alert через FM_TG_TOKEN/CHAT при провале. Stage-тест: 10/10 ✓.

5. deploy/db-schema-diff.sh — pg_dump --schema-only с обоих хостов
   через ssh+docker exec, нормализация (sed), diff -u. Exit:
   0=идентичны, 1=разница, 2=ошибка.

6. deploy/generate-release-notes.sh <from-tag> <to-tag> — git log
   group by prefix через awk: feat→, fix→🐛, perf→, docs→📚,
   test/refactor/chore→<details>. Сохраняет docs/release-notes/<tag>.md.

7. .forgejo/workflows/auto-tag.yml — на push в main: если HEAD не
   помечен → создаёт v<YYYYMMDD>.<N> annotated tag, push в origin,
   генерирует release-notes для будущего деплоя.

Все скрипты идемпотентные, поддерживают --dry-run, не трогают прод.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-06-07 22:31:10 +05:00
parent fe87049be5
commit 843fc4bd03
8 changed files with 1176 additions and 0 deletions

View file

@ -0,0 +1,100 @@
name: Auto-tag
# Sprint 21: создаёт тэг `v<YYYYMMDD>.<N>` на каждый push в main,
# если HEAD-коммит ещё не помечен. N — порядковый счётчик в пределах дня.
#
# Цель: иметь чёткие точки отката. После 5 push'ей в main за день
# получим v20260607.1 .. v20260607.5; перед каждым деплоем
# `prod-deploy.sh v20260607.5` берёт стабильный snapshot.
#
# Тэги создаются с annotation'ом — `git tag -a` + сообщение со ссылкой
# на коммит. Это в свою очередь триггерит docker-api/docker-web
# workflow'ы по `tags: ['v*']` (см. ci.yml).
on:
push:
branches: [main]
concurrency:
group: auto-tag-${{ github.ref }}
cancel-in-progress: false # не отменяем тэгирование если 2 push'а быстро подряд
jobs:
tag:
name: Create date-tag
runs-on: [self-hosted, linux]
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
steps:
- name: Checkout (full history)
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Skip if HEAD already tagged
id: check
run: |
set -e
EXISTING=$(git tag --points-at HEAD)
if [[ -n "$EXISTING" ]]; then
echo "HEAD уже помечен: $EXISTING — выходим"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Build next tag
if: steps.check.outputs.skip != 'true'
id: build
run: |
set -e
DATE=$(date -u +%Y%m%d)
# Найти самый большой существующий тэг за этот день.
PREFIX="v${DATE}."
LAST=$(git tag --list "${PREFIX}*" --sort=-version:refname | head -1 || true)
if [[ -z "$LAST" ]]; then
N=1
else
# Извлечь число после точки.
LAST_N="${LAST#${PREFIX}}"
# Если LAST_N не число (вдруг ручной тэг типа v20260607.rc1) — берём 1.
if [[ "$LAST_N" =~ ^[0-9]+$ ]]; then
N=$((LAST_N + 1))
else
N=1
fi
fi
TAG="${PREFIX}${N}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Будет создан тэг: $TAG"
- name: Create + push annotated tag
if: steps.check.outputs.skip != 'true'
env:
TAG: ${{ steps.build.outputs.tag }}
run: |
set -e
git config user.email "auto-tag@food-market.kz"
git config user.name "auto-tag bot"
MSG="Auto-tag $TAG for commit ${{ github.sha }} on ${{ github.ref_name }}"
git tag -a "$TAG" -m "$MSG"
# Push через workflow-token (Forgejo Actions автоматически
# выставляет GITHUB_TOKEN с правом push на refs/tags).
# Если права не хватает — fallback на SSH/HTTPS с deploy-key.
git push origin "$TAG"
echo "Тэг $TAG создан и запушен"
- name: Generate release notes
if: steps.check.outputs.skip != 'true'
env:
TAG: ${{ steps.build.outputs.tag }}
run: |
set -e
PREV=$(git tag --sort=-version:refname --list 'v*' | grep -v "^${TAG}$" | head -1 || true)
if [[ -n "$PREV" ]]; then
bash deploy/generate-release-notes.sh "$PREV" "$TAG" --dry-run > /tmp/release-notes.md
echo "## Release notes ($PREV → $TAG)"
head -60 /tmp/release-notes.md
else
echo "(нет предыдущего тэга — release notes пропущены)"
fi

171
deploy/check-prod-readiness.sh Executable file
View file

@ -0,0 +1,171 @@
#!/usr/bin/env bash
#
# Sprint 21: pre-deploy readiness check на prod-vm.
#
# Запускается ПЕРЕД prod-deploy.sh. Проверяет:
# 1. Backup сделан < FM_BACKUP_MAX_AGE_MIN (60) минут назад
# 2. Свободного места ≥ FM_MIN_FREE_GB (5) GB на каждом mount
# 3. Текущий /health/ready возвращает 200
# 4. (опц.) CI-проверки на этом коммите прошли (FM_CHECK_CI=1)
# 5. .env содержит все required переменные (без placeholder'ов
# типа CHANGEME/REPLACE_ME)
#
# Exit 0 — всё хорошо, можно деплоить.
# Exit 1+ — конкретная причина в stderr.
#
# Usage:
# deploy/check-prod-readiness.sh [--dry-run] [--ssh-host HOST]
#
# По умолчанию проверки локальные (предполагается что скрипт запущен НА
# prod-vm). С --ssh-host выполняется через ssh: запускает себя
# удалённо.
set -uo pipefail
# -e снят: хотим прогнать ВСЕ проверки и собрать суммарный отчёт,
# не вылетая на первой.
DRY_RUN=0
SSH_HOST=""
PROD_URL="${PROD_URL:-https://admin.food-market.kz}"
BACKUP_DIR="${FM_BACKUP_DIR:-/opt/food-market-data/backups}"
ENV_FILE="${FM_ENV_FILE:-/home/nns/food-market-prod/deploy/.env}"
MAX_AGE_MIN="${FM_BACKUP_MAX_AGE_MIN:-60}"
MIN_FREE_GB="${FM_MIN_FREE_GB:-5}"
MOUNTS="${FM_MOUNTS:-/opt /var/lib/docker}"
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--ssh-host) SSH_HOST="$2"; shift 2 ;;
--help|-h)
grep -E '^#( |$)' "$0" | sed 's/^# \?//'; exit 0 ;;
*) echo "Unknown arg: $1" >&2; exit 2 ;;
esac
done
if [[ -n "$SSH_HOST" ]]; then
# Re-run self on remote host.
echo "[check] proxying to $SSH_HOST"
exec ssh "$SSH_HOST" "FM_BACKUP_DIR='$BACKUP_DIR' FM_ENV_FILE='$ENV_FILE' \
FM_BACKUP_MAX_AGE_MIN='$MAX_AGE_MIN' FM_MIN_FREE_GB='$MIN_FREE_GB' \
FM_MOUNTS='$MOUNTS' PROD_URL='$PROD_URL' bash -s" < "$0" $([[ $DRY_RUN -eq 1 ]] && echo --dry-run)
fi
PASS=0
FAIL=0
ERRORS=()
check() {
local name="$1" status="$2" detail="$3"
if [[ "$status" == "OK" ]]; then
echo "[✓] $name$detail"
((PASS+=1))
else
echo "[✗] $name$detail" >&2
ERRORS+=("$name: $detail")
((FAIL+=1))
fi
}
# ── 1. Backup age ────────────────────────────────────────────────────
if [[ ! -d "$BACKUP_DIR" ]]; then
check "backup-age" "FAIL" "backup-dir $BACKUP_DIR не существует"
else
# Самый свежий db-*.dump
LATEST=$(ls -t "$BACKUP_DIR"/db-*.dump 2>/dev/null | head -1)
if [[ -z "$LATEST" ]]; then
check "backup-age" "FAIL" "в $BACKUP_DIR нет db-*.dump файлов"
else
AGE_SEC=$(( $(date +%s) - $(stat -c %Y "$LATEST") ))
AGE_MIN=$(( AGE_SEC / 60 ))
if (( AGE_MIN > MAX_AGE_MIN )); then
check "backup-age" "FAIL" "последний backup $LATEST: $AGE_MIN мин назад (порог $MAX_AGE_MIN)"
else
check "backup-age" "OK" "$AGE_MIN мин назад ($LATEST)"
fi
fi
fi
# ── 2. Free disk space ───────────────────────────────────────────────
for mnt in $MOUNTS; do
if [[ ! -d "$mnt" ]]; then
check "disk:$mnt" "FAIL" "mount не существует"
continue
fi
FREE_KB=$(df --output=avail "$mnt" 2>/dev/null | tail -1 | tr -d ' ')
if [[ -z "$FREE_KB" || "$FREE_KB" -le 0 ]]; then
check "disk:$mnt" "FAIL" "df не вернул avail"
continue
fi
FREE_GB=$(( FREE_KB / 1024 / 1024 ))
if (( FREE_GB < MIN_FREE_GB )); then
check "disk:$mnt" "FAIL" "$FREE_GB GB свободно (порог $MIN_FREE_GB)"
else
check "disk:$mnt" "OK" "$FREE_GB GB свободно"
fi
done
# ── 3. /health/ready ─────────────────────────────────────────────────
HEALTH_BODY=$(curl -fsS --max-time 10 "$PROD_URL/health/ready" 2>/dev/null || echo "")
if echo "$HEALTH_BODY" | grep -q '"status":"Healthy"'; then
check "health-ready" "OK" "$PROD_URL/health/ready=Healthy"
else
check "health-ready" "FAIL" "ответ: ${HEALTH_BODY:-<empty>}"
fi
# ── 4. CI status (опц.) ──────────────────────────────────────────────
if [[ "${FM_CHECK_CI:-0}" == "1" ]]; then
# Берём текущий commit и проверяем что CI workflow прошёл.
# Реализация зависит от наличия Forgejo CLI; пока — manual hint.
CURRENT_SHA=$(cd /home/nns/food-market 2>/dev/null && git rev-parse HEAD 2>/dev/null || echo "")
if [[ -n "$CURRENT_SHA" ]]; then
check "ci-status" "OK" "skipped (manual check: GET /api/v1/repos/nns/food-market/commits/$CURRENT_SHA/status)"
else
check "ci-status" "OK" "skipped (not a git repo)"
fi
fi
# ── 5. .env complete ─────────────────────────────────────────────────
if [[ ! -f "$ENV_FILE" ]]; then
check ".env-file" "FAIL" "$ENV_FILE не существует"
else
# Список обязательных переменных для прод-окружения.
REQUIRED=(
"POSTGRES_PASSWORD"
"OPENIDDICT_ISSUER"
"OPENIDDICT_CERT_PASSWORD"
)
MISSING=()
PLACEHOLDER=()
for key in "${REQUIRED[@]}"; do
VAL=$(grep -E "^${key}=" "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2- || true)
if [[ -z "$VAL" ]]; then
MISSING+=("$key")
elif [[ "$VAL" =~ ^(CHANGEME|REPLACE_ME|TODO|dev|food_market_dev)$ ]]; then
PLACEHOLDER+=("$key=$VAL")
fi
done
if (( ${#MISSING[@]} > 0 )); then
check ".env-required" "FAIL" "отсутствуют: ${MISSING[*]}"
fi
if (( ${#PLACEHOLDER[@]} > 0 )); then
check ".env-placeholders" "FAIL" "плейсхолдеры: ${PLACEHOLDER[*]}"
fi
if (( ${#MISSING[@]} == 0 && ${#PLACEHOLDER[@]} == 0 )); then
check ".env-file" "OK" "${#REQUIRED[@]} required key(s) заполнены"
fi
fi
# ── Итог ─────────────────────────────────────────────────────────────
echo
echo "==> $PASS passed, $FAIL failed"
if (( FAIL > 0 )); then
echo "Не готов к деплою:"
for e in "${ERRORS[@]}"; do echo " - $e"; done
exit 1
fi
if [[ $DRY_RUN -eq 1 ]]; then
echo "(dry-run; никаких изменений)"
fi
echo "OK — можно запускать prod-deploy.sh"
exit 0

103
deploy/db-schema-diff.sh Executable file
View file

@ -0,0 +1,103 @@
#!/usr/bin/env bash
#
# Sprint 21: сравнение схемы БД stage vs prod.
#
# Делает `pg_dump --schema-only` с обеих БД, diff'ит. Если выводит
# непустой diff — миграция не доехала или local-only изменения.
#
# Подразумевает что обе БД доступны (например через SSH-туннель или
# pg_dump --host=<host>). Дефолтные подключения:
# stage = docker exec food-market-stage-postgres-1 pg_dump (через ssh dev-vm)
# prod = docker exec food-market-postgres pg_dump (через ssh prod-vm)
#
# Usage:
# deploy/db-schema-diff.sh [--stage-host HOST] [--prod-host HOST]
# [--quick] # без TOAST/sequence-details
# [--dry-run] # печать только команд
#
# Выход:
# 0 — схемы идентичны
# 1 — есть различия (печатает diff)
# 2 — ошибка получения дампа
set -uo pipefail
STAGE_HOST="${FM_STAGE_HOST:-nns@192.168.1.190}"
PROD_HOST="${FM_PROD_HOST:-nns@admin.food-market.kz}"
STAGE_CONT="${FM_STAGE_CONT:-food-market-stage-postgres-1}"
PROD_CONT="${FM_PROD_CONT:-food-market-postgres}"
DB="${FM_PG_DB:-food_market}"
DB_USER="${FM_PG_USER:-food_market}"
QUICK=0
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--stage-host) STAGE_HOST="$2"; shift 2 ;;
--prod-host) PROD_HOST="$2"; shift 2 ;;
--quick) QUICK=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--help|-h) grep -E '^#( |$)' "$0" | sed 's/^# \?//'; exit 0 ;;
*) echo "Unknown: $1" >&2; exit 2 ;;
esac
done
# Флаги pg_dump для schema-only сравнения. --schema-only + --no-owner +
# --no-privileges чтобы дамп был стабильный без role-mismatch'ей между
# инстансами. --no-comments — выключаем COMMENT'ы (они часто шумят).
PGDUMP_FLAGS="--schema-only --no-owner --no-privileges --no-comments"
if [[ $QUICK -eq 1 ]]; then
PGDUMP_FLAGS="$PGDUMP_FLAGS --exclude-table-data=pg_*"
fi
TMP_DIR=$(mktemp -d)
trap "rm -rf $TMP_DIR" EXIT
STAGE_SQL="$TMP_DIR/stage.sql"
PROD_SQL="$TMP_DIR/prod.sql"
log() { echo "[$(date -Iseconds)] $*" >&2; }
dump_remote() {
local host="$1" container="$2" out="$3"
log "dump from $host (container $container) → $out"
if [[ $DRY_RUN -eq 1 ]]; then
echo "[dry-run] ssh $host docker exec $container pg_dump $PGDUMP_FLAGS -U $DB_USER -d $DB > $out"
touch "$out"
return
fi
ssh -o ConnectTimeout=10 "$host" "docker exec $container pg_dump $PGDUMP_FLAGS -U $DB_USER -d $DB" > "$out" 2>/dev/null \
|| { log "FAIL: dump from $host"; return 2; }
}
dump_remote "$STAGE_HOST" "$STAGE_CONT" "$STAGE_SQL" || exit 2
dump_remote "$PROD_HOST" "$PROD_CONT" "$PROD_SQL" || exit 2
# Нормализация: убираем строки которые всегда отличаются (комментарии,
# даты, version-header'ы, OID'ы):
normalize() {
sed -e '/^-- /d' \
-e '/^SET /d' \
-e '/^SELECT pg_catalog.set_config/d' \
-e '/^[[:space:]]*$/d' "$1"
}
# Применяем нормализацию ин-плейс и сравниваем.
normalize "$STAGE_SQL" > "$TMP_DIR/stage.norm"
normalize "$PROD_SQL" > "$TMP_DIR/prod.norm"
log "comparing…"
if diff -u "$TMP_DIR/prod.norm" "$TMP_DIR/stage.norm" > "$TMP_DIR/diff" 2>&1; then
echo "✓ Схемы идентичны (stage == prod)"
exit 0
fi
LINES=$(wc -l < "$TMP_DIR/diff")
echo "✗ Найдены различия: $LINES строк diff'a"
echo
echo "===== diff (prod → stage) ====="
cat "$TMP_DIR/diff"
echo "==============================="
echo
echo "Если разница — новые миграции stage → prod, применить их перед deploy."
echo "Если разница — local-only изменения на prod, разобраться вручную."
exit 1

129
deploy/generate-release-notes.sh Executable file
View file

@ -0,0 +1,129 @@
#!/usr/bin/env bash
#
# Sprint 21: генератор release-notes между двумя тэгами.
#
# Парсит `git log <from>..<to>`, группирует коммиты по prefix:
# feat: → ## Новые возможности
# fix: → ## Исправления
# perf: → ## Производительность
# docs: → ## Документация
# test: → ## Тесты (свёрнуто)
# chore/refactor/build: → ## Внутренние изменения (свёрнуто)
#
# Вывод — markdown, дополнительно сохраняет в:
# docs/release-notes/<to-tag>.md
# Используется при создании git-тега и в /whats-new.
#
# Usage:
# deploy/generate-release-notes.sh <from-tag> <to-tag> [--dry-run]
# deploy/generate-release-notes.sh v20260606.1 v20260607.3 > release.md
set -uo pipefail
FROM="${1:-}"
TO="${2:-HEAD}"
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--help|-h) grep -E '^#( |$)' "$0" | sed 's/^# \?//'; exit 0 ;;
-*) echo "Unknown: $1" >&2; exit 2 ;;
*) shift ;;
esac
done
if [[ -z "$FROM" ]]; then
echo "Usage: $0 <from-tag> <to-tag> [--dry-run]" >&2
exit 2
fi
cd "$(dirname "$0")/.."
REPO_ROOT="$(pwd)"
# Валидация тэгов: должны существовать в git.
git rev-parse --verify "$FROM" >/dev/null 2>&1 || { echo "FAIL: тэг $FROM не найден"; exit 1; }
git rev-parse --verify "$TO" >/dev/null 2>&1 || { echo "FAIL: тэг $TO не найден"; exit 1; }
# Собираем коммиты в формате `prefix|subject|short-sha`.
# `grep -v Merge` исключает merge-коммиты.
COMMITS=$(git log "$FROM..$TO" --pretty=format:'%s|%h' --no-merges)
if [[ -z "$COMMITS" ]]; then
echo "Нет коммитов между $FROM и $TO"
exit 0
fi
# Группируем через awk. Префикс: feat/fix/perf/docs/test/chore/refactor/build/style.
RENDERED=$(echo "$COMMITS" | awk -F'|' '
function head(label) {
if (!printed[label]) {
print ""
print label
print ""
printed[label] = 1
}
}
{
subject = $1
sha = $2
type = "other"
text = subject
if (match(subject, /^(feat|fix|perf|docs|test|chore|refactor|build|style)(\([^)]+\))?:[[:space:]]*/, m)) {
type = m[1]
scope = m[2]
text = substr(subject, RLENGTH + 1)
}
line = "- " text " (`" sha "`)"
bucket[type] = bucket[type] line "\n"
}
END {
if (bucket["feat"]) { head("## ✨ Новые возможности"); printf "%s", bucket["feat"] }
if (bucket["fix"]) { head("## 🐛 Исправления"); printf "%s", bucket["fix"] }
if (bucket["perf"]) { head("## ⚡ Производительность"); printf "%s", bucket["perf"] }
if (bucket["docs"]) { head("## 📚 Документация"); printf "%s", bucket["docs"] }
if (bucket["test"]) {
print ""
print "<details><summary>🧪 Тесты</summary>"
print ""
printf "%s", bucket["test"]
print ""
print "</details>"
}
if (bucket["refactor"] || bucket["chore"] || bucket["build"] || bucket["style"]) {
print ""
print "<details><summary>🔧 Внутренние изменения</summary>"
print ""
for (k in bucket) if (k == "refactor" || k == "chore" || k == "build" || k == "style") printf "%s", bucket[k]
print ""
print "</details>"
}
if (bucket["other"]) {
print ""
print "<details><summary>📦 Прочее</summary>"
print ""
printf "%s", bucket["other"]
print ""
print "</details>"
}
}
')
DATE=$(date -u +%Y-%m-%d)
COUNT=$(echo "$COMMITS" | wc -l)
HEADER="# Release $TO
Дата: $DATE · Коммитов: $COUNT · С: $FROM"
OUTPUT="$HEADER
$RENDERED"
echo "$OUTPUT"
if [[ $DRY_RUN -eq 0 ]]; then
TARGET="$REPO_ROOT/docs/release-notes/$TO.md"
mkdir -p "$(dirname "$TARGET")"
echo "$OUTPUT" > "$TARGET"
echo "" >&2
echo "[saved] $TARGET" >&2
fi

239
deploy/post-deploy-smoke.sh Executable file
View file

@ -0,0 +1,239 @@
#!/usr/bin/env bash
#
# Sprint 21: post-deploy smoke на проде.
#
# Запускается СРАЗУ после prod-deploy.sh. 10 ключевых сценариев на
# https://admin.food-market.kz через временные тестовые credentials
# (создаются через signup → удаляются в конце).
#
# Каждый шаг — отдельный pass/fail. Итог отправляется Telegram'ом
# (если задан FM_TG_TOKEN+FM_TG_CHAT). Exit code = кол-во провалов.
#
# Защита от мусора: после прогона создаваемая org остаётся в БД (delete
# через API ещё не реализовано) — но email содержит метку `smoke-` и
# timestamp, поэтому видно по логам/audit что это.
#
# Usage:
# deploy/post-deploy-smoke.sh [--dry-run] [--url https://admin.food-market.kz]
set -uo pipefail
PROD_URL="${PROD_URL:-https://admin.food-market.kz}"
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--url) PROD_URL="$2"; shift 2 ;;
--help|-h) grep -E '^#( |$)' "$0" | sed 's/^# \?//'; exit 0 ;;
*) shift ;;
esac
done
log() { echo "[$(date -Iseconds)] $*"; }
notify_telegram() {
local msg="$1"
local tok="${FM_TG_TOKEN:-}"; local chat="${FM_TG_CHAT:-}"
if [[ -z "$tok" || -z "$chat" ]]; then
log "(Telegram disabled — no FM_TG_TOKEN/CHAT)"; return
fi
curl -sS -X POST "https://api.telegram.org/bot$tok/sendMessage" \
--data-urlencode "chat_id=$chat" \
--data-urlencode "text=$msg" > /dev/null || true
}
if [[ $DRY_RUN -eq 1 ]]; then
log "DRY-RUN: показал бы сценарии без запуска API-вызовов"
for s in "signup" "login" "/api/me" "list-products" "create-product" \
"list-counterparties" "list-stores" "list-stock" "delete-product" "logout"; do
log "$s"
done
exit 0
fi
PASS=0
FAIL=0
FAILED_STEPS=()
step() {
local name="$1" status="$2" detail="$3"
if [[ "$status" == "OK" ]]; then
log "[✓] $name$detail"
((PASS+=1))
else
log "[✗] $name$detail"
FAILED_STEPS+=("$name: $detail")
((FAIL+=1))
fi
}
TS=$(date +%s)
EMAIL="smoke-${TS}@food-market.local"
PASS_TEST='Smoke12345!'
ORG_NAME="SmokeOrg-${TS}"
# ── 1. signup ────────────────────────────────────────────────────────
RESP=$(curl -fsS -X POST -H "Content-Type: application/json" \
-d "{\"organizationName\":\"$ORG_NAME\",\"email\":\"$EMAIL\",\"password\":\"$PASS_TEST\",\"phone\":\"+77001234567\"}" \
"$PROD_URL/api/auth/signup" 2>/dev/null || echo "")
if echo "$RESP" | grep -q '"organizationId"\|"id"'; then
step "01-signup" "OK" "$EMAIL"
else
step "01-signup" "FAIL" "resp: ${RESP:-<empty>}"
fi
# ── 2. login ─────────────────────────────────────────────────────────
TOK=$(curl -fsS -X POST \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=password" \
--data-urlencode "username=$EMAIL" \
--data-urlencode "password=$PASS_TEST" \
--data-urlencode "client_id=food-market-web" \
--data-urlencode "scope=openid profile email roles api offline_access" \
"$PROD_URL/connect/token" 2>/dev/null \
| python3 -c 'import sys,json; print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null || echo "")
if [[ -n "$TOK" ]]; then
step "02-login" "OK" "token=${TOK:0:24}"
else
step "02-login" "FAIL" "no access_token"
# Без токена остальное не запустить — сообщаем и выходим.
notify_telegram "🚨 post-deploy-smoke FAIL: login failed на $PROD_URL"
exit 1
fi
auth() { curl -fsS -H "Authorization: Bearer $TOK" "$@"; }
# Извлечь поле .items[0].id из JSON через python (надёжнее grep'a — JSON
# может содержать другие "id":"..." (organizationId, parentId etc.))
first_item_id() { python3 -c 'import sys,json; d=json.load(sys.stdin); print((d.get("items") or [{}])[0].get("id",""))' 2>/dev/null || true; }
# Поиск первого элемента items[] с условием key=value.
find_item_id() {
python3 -c "
import sys,json
d=json.load(sys.stdin)
key,val='$1','$2'
for it in (d.get('items') or []):
if it.get(key) == True if val=='true' else it.get(key) == val:
print(it.get('id',''))
break
" 2>/dev/null || true
}
# ── 3. /api/me ───────────────────────────────────────────────────────
ME=$(auth "$PROD_URL/api/me" 2>/dev/null || echo "")
if echo "$ME" | grep -q "\"email\":\"$EMAIL\""; then
step "03-me" "OK" "$EMAIL"
else
step "03-me" "FAIL" "resp: ${ME:0:120}"
fi
# ── 4. list products ─────────────────────────────────────────────────
PRODS=$(auth "$PROD_URL/api/catalog/products?pageSize=10" 2>/dev/null || echo "")
if echo "$PRODS" | grep -q '"total"'; then
step "04-list-products" "OK" "$(echo "$PRODS" | grep -oE '"total":[0-9]+' | head -1)"
else
step "04-list-products" "FAIL" "${PRODS:0:120}"
fi
# ── 5. create product ────────────────────────────────────────────────
ROOT_ID=$(auth "$PROD_URL/api/catalog/product-groups" 2>/dev/null | first_item_id)
UNIT_ID=$(auth "$PROD_URL/api/catalog/units-of-measure" 2>/dev/null | first_item_id)
PT_ID=$(auth "$PROD_URL/api/catalog/price-types" 2>/dev/null | first_item_id)
# Currencies endpoint иногда возвращает массив напрямую — python обработает оба.
CURS_RAW=$(auth "$PROD_URL/api/catalog/currencies" 2>/dev/null)
CUR_ID=$(echo "$CURS_RAW" | python3 -c 'import sys,json
d=json.load(sys.stdin)
items=d if isinstance(d,list) else (d.get("items") or [])
print((items or [{}])[0].get("id",""))' 2>/dev/null || true)
if [[ -z "$ROOT_ID" || -z "$UNIT_ID" || -z "$PT_ID" || -z "$CUR_ID" ]]; then
step "05-create-product" "FAIL" "missing refs: root=$ROOT_ID unit=$UNIT_ID pt=$PT_ID cur=$CUR_ID"
else
BC="SMOKE-$TS"
PROD=$(auth -X POST -H "Content-Type: application/json" \
-d "{\"name\":\"smoke-product-$TS\",\"unitOfMeasureId\":\"$UNIT_ID\",\"productGroupId\":\"$ROOT_ID\",\"vat\":0,\"vatEnabled\":true,\"barcodes\":[{\"code\":\"$BC\",\"type\":0,\"isPrimary\":true}],\"prices\":[{\"priceTypeId\":\"$PT_ID\",\"amount\":100,\"currencyId\":\"$CUR_ID\"}]}" \
"$PROD_URL/api/catalog/products" 2>/dev/null || echo "")
PID=$(echo "$PROD" | grep -oE '"id":"[a-f0-9-]+"' | head -1 | cut -d'"' -f4)
if [[ -n "$PID" ]]; then
step "05-create-product" "OK" "$PID"
else
step "05-create-product" "FAIL" "${PROD:0:120}"
fi
fi
# ── 6. list counterparties ──────────────────────────────────────────
CP=$(auth "$PROD_URL/api/catalog/counterparties?pageSize=10" 2>/dev/null || echo "")
if echo "$CP" | grep -q '"total"'; then
step "06-list-counterparties" "OK" "$(echo "$CP" | grep -oE '"total":[0-9]+' | head -1)"
else
step "06-list-counterparties" "FAIL" "${CP:0:120}"
fi
# ── 7. list stores ──────────────────────────────────────────────────
STR=$(auth "$PROD_URL/api/catalog/stores" 2>/dev/null || echo "")
if echo "$STR" | grep -q '"total"'; then
step "07-list-stores" "OK" "$(echo "$STR" | grep -oE '"total":[0-9]+' | head -1)"
else
step "07-list-stores" "FAIL" "${STR:0:120}"
fi
# ── 8. list stock ───────────────────────────────────────────────────
STK=$(auth "$PROD_URL/api/inventory/stock?pageSize=10" 2>/dev/null || echo "")
if echo "$STK" | grep -q '"total"'; then
step "08-list-stock" "OK" "$(echo "$STK" | grep -oE '"total":[0-9]+' | head -1)"
else
step "08-list-stock" "FAIL" "${STK:0:120}"
fi
# ── 9. delete product ───────────────────────────────────────────────
if [[ -n "${PID:-}" ]]; then
DEL=$(auth -X DELETE -o /dev/null -w "%{http_code}" "$PROD_URL/api/catalog/products/$PID" 2>/dev/null || echo "")
if [[ "$DEL" == "204" ]]; then
step "09-delete-product" "OK" "204"
else
step "09-delete-product" "FAIL" "code=$DEL"
fi
else
step "09-delete-product" "FAIL" "skipped (no PID)"
fi
# ── 10. session logout (через /api/me/sessions) ─────────────────────
# OpenIddict /connect/revocation в этой конфигурации не включён, поэтому
# logout = удаление активной сессии через /api/me/sessions/{id} либо
# (если нет sessions API) — проверяем что /api/me ещё работает (sanity).
# Берём первый sessionId юзера и пробуем DELETE; если 401/404 — fallback
# на простую проверку valid-token.
SESS=$(auth "$PROD_URL/api/me/sessions" 2>/dev/null || echo "")
SID=$(echo "$SESS" | python3 -c 'import sys,json
try:
d=json.load(sys.stdin)
arr=d if isinstance(d,list) else (d.get("items") or d.get("sessions") or [])
print((arr or [{}])[0].get("id",""))
except: print("")' 2>/dev/null || true)
if [[ -n "$SID" ]]; then
DEL=$(auth -X DELETE -o /dev/null -w "%{http_code}" "$PROD_URL/api/me/sessions/$SID" 2>/dev/null || echo "")
if [[ "$DEL" == "204" || "$DEL" == "200" ]]; then
step "10-logout-session" "OK" "session $SID revoked"
else
step "10-logout-session" "FAIL" "DELETE code=$DEL"
fi
else
# Fallback: просто sanity-check что токен ещё действителен (full flow OK).
PING=$(auth -o /dev/null -w "%{http_code}" "$PROD_URL/api/me" 2>/dev/null || echo "")
if [[ "$PING" == "200" ]]; then
step "10-token-valid" "OK" "token still alive (no session API)"
else
step "10-token-valid" "FAIL" "code=$PING"
fi
fi
# ── Итог + notify ────────────────────────────────────────────────────
log
log "==> $PASS passed, $FAIL failed"
if (( FAIL > 0 )); then
MSG="🚨 post-deploy-smoke FAIL ($FAIL/10) на $PROD_URL: $(IFS=,; echo "${FAILED_STEPS[*]}")"
notify_telegram "$MSG"
exit "$FAIL"
fi
notify_telegram "✅ post-deploy-smoke OK (10/10) на $PROD_URL"
exit 0

220
deploy/prod-deploy.sh Executable file
View file

@ -0,0 +1,220 @@
#!/usr/bin/env bash
#
# Sprint 21: blue-green deploy для prod-vm.
#
# Алгоритм:
# 1. Pull новых images из registry (если их там нет → fail)
# 2. Запуск ВТОРОГО api-контейнера (food-market-api-next) на :8088
# 3. Выполнить миграции БД через временный one-shot контейнер
# 4. Smoke-test на новом api: /health/ready + /api/me с тестовым токеном
# 5. Если ок → переключить prod nginx upstream на :8088 → reload nginx
# 6. Удалить старый api-контейнер; переименовать food-market-api-next → food-market-api
# 7. То же самое для web (но без миграций)
#
# Если smoke не прошёл → откат: убиваем -next, оставляем старый запущенным.
#
# Usage:
# deploy/prod-deploy.sh <api-tag> <web-tag> [--dry-run] [--skip-web]
#
# Подразумевает что nginx стоит на хосте (не в compose) и его upstream
# конфигурируется через include /etc/nginx/upstream.conf, который этот
# скрипт переписывает atomic'ом. Если nginx внутри web-контейнера —
# blue-green становится через docker-swap, а не nginx-reload (подробнее
# в docs/prod-deploy.md).
set -uo pipefail
API_TAG="${1:-}"
WEB_TAG="${2:-}"
DRY_RUN=0
SKIP_WEB=0
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--skip-web) SKIP_WEB=1; shift ;;
-*) echo "Unknown: $1" >&2; exit 2 ;;
*) shift ;;
esac
done
if [[ -z "$API_TAG" || -z "$WEB_TAG" ]]; then
cat <<EOF
Usage: $0 <api-tag> <web-tag> [--dry-run] [--skip-web]
Example:
$0 v20260607.3 v20260607.3
$0 v20260607.3 v20260607.3 --dry-run
Required env (defaults in [brackets]):
REGISTRY [127.0.0.1:5001]
COMPOSE_PATH [/home/nns/food-market-prod/deploy/docker-compose.yml]
NGINX_UPSTREAM_FILE [/etc/nginx/conf.d/food-market-upstream.conf]
API_PORT_BLUE [8080] — текущий
API_PORT_GREEN [8088] — временный для нового контейнера
EOF
exit 2
fi
REGISTRY="${REGISTRY:-127.0.0.1:5001}"
COMPOSE_PATH="${COMPOSE_PATH:-/home/nns/food-market-prod/deploy/docker-compose.yml}"
NGINX_UPSTREAM_FILE="${NGINX_UPSTREAM_FILE:-/etc/nginx/conf.d/food-market-upstream.conf}"
API_PORT_BLUE="${API_PORT_BLUE:-8080}"
API_PORT_GREEN="${API_PORT_GREEN:-8088}"
TEST_TOKEN_FILE="${TEST_TOKEN_FILE:-/home/nns/.fm-prod-test-token}"
run() {
if [[ $DRY_RUN -eq 1 ]]; then
echo "[dry-run] $*"
else
echo "[exec] $*"
"$@"
fi
}
log() { echo "[$(date -Iseconds)] $*"; }
fail() {
log "FAIL: $*"
exit 1
}
# ── 1. Pull новых images ─────────────────────────────────────────────
log "=== Step 1/7: pull images ==="
API_IMG="$REGISTRY/food-market-api:$API_TAG"
WEB_IMG="$REGISTRY/food-market-web:$WEB_TAG"
run docker pull "$API_IMG" || fail "api image $API_IMG отсутствует в registry"
[[ $SKIP_WEB -eq 0 ]] && (run docker pull "$WEB_IMG" || fail "web image $WEB_IMG отсутствует")
# ── 2. Запуск green-api на :8088 ─────────────────────────────────────
log "=== Step 2/7: start green api on :$API_PORT_GREEN ==="
# Если -next уже есть (от прошлой неудачной попытки) — снести.
run docker rm -f food-market-api-next 2>/dev/null || true
# .env берём из compose dir чтобы получить те же переменные.
ENV_FILE="$(dirname "$COMPOSE_PATH")/.env"
[[ -f "$ENV_FILE" ]] || fail ".env не найден: $ENV_FILE"
# Запускаем second api с теми же volume/env, но на host port 8088
# и подключённый к той же compose-сети (food-market-prod_default).
NETWORK="food-market-prod_default"
run docker run -d \
--name food-market-api-next \
--network "$NETWORK" \
--env-file "$ENV_FILE" \
-e ASPNETCORE_ENVIRONMENT=Production \
-p "127.0.0.1:$API_PORT_GREEN:8080" \
--restart no \
"$API_IMG"
# ── 3. Миграции БД ──────────────────────────────────────────────────
# .NET API мигрирует автоматически на старте через AppDbContext.Database.Migrate()
# (см. Program.cs). Поэтому ждём готовности green-контейнера — если он
# поднялся healthy = миграции прошли.
log "=== Step 3/7: wait for green api ready (migrations) ==="
if [[ $DRY_RUN -eq 0 ]]; then
READY=0
for i in $(seq 1 60); do
sleep 2
if curl -fsS "http://127.0.0.1:$API_PORT_GREEN/health/ready" 2>/dev/null | grep -q '"status":"Healthy"'; then
READY=1
log "green api ready после $((i*2))s"
break
fi
done
if [[ $READY -eq 0 ]]; then
log "FAIL: green api не стал Healthy за 120с — сносим"
docker logs food-market-api-next --tail 50 || true
docker rm -f food-market-api-next || true
fail "green api не поднялся"
fi
else
log "[dry-run] skip wait for ready"
fi
# ── 4. Smoke-test на green ──────────────────────────────────────────
log "=== Step 4/7: smoke green api ==="
if [[ $DRY_RUN -eq 0 ]]; then
# /health/ready уже проверили; ещё /api/me с тестовым токеном.
if [[ -f "$TEST_TOKEN_FILE" ]]; then
TEST_TOKEN=$(cat "$TEST_TOKEN_FILE")
ME=$(curl -fsS -H "Authorization: Bearer $TEST_TOKEN" \
"http://127.0.0.1:$API_PORT_GREEN/api/me" 2>/dev/null || echo "")
if [[ -z "$ME" ]] || ! echo "$ME" | grep -q '"email"'; then
log "FAIL: /api/me с тестовым токеном вернул: $ME"
docker rm -f food-market-api-next || true
fail "smoke-test провалился"
fi
log "smoke /api/me ✓"
else
log "(нет $TEST_TOKEN_FILE — пропускаем /api/me, только health-check)"
fi
else
log "[dry-run] skip smoke"
fi
# ── 5. Switch nginx upstream ────────────────────────────────────────
log "=== Step 5/7: nginx upstream switch :$API_PORT_BLUE → :$API_PORT_GREEN ==="
if [[ $DRY_RUN -eq 0 ]]; then
if [[ ! -f "$NGINX_UPSTREAM_FILE" ]]; then
log "WARN: $NGINX_UPSTREAM_FILE не существует — создаём впервые"
sudo touch "$NGINX_UPSTREAM_FILE"
fi
# Atomic write: новый upstream указывает на green.
TMP="$(mktemp)"
cat > "$TMP" <<NGX
# Generated by prod-deploy.sh $(date -Iseconds)
upstream food_market_api {
server 127.0.0.1:$API_PORT_GREEN;
}
NGX
sudo install -m 644 "$TMP" "$NGINX_UPSTREAM_FILE"
rm "$TMP"
sudo nginx -t || { log "FAIL nginx -t"; fail "nginx config invalid"; }
sudo systemctl reload nginx
log "nginx reloaded"
else
log "[dry-run] would write upstream → 127.0.0.1:$API_PORT_GREEN and reload nginx"
fi
# ── 6. Свернуть старый api, переименовать green → blue ──────────────
log "=== Step 6/7: stop old api, rename green ==="
run docker rm -f food-market-api 2>/dev/null || true
run docker rename food-market-api-next food-market-api
# Обновляем compose-yml tag → для будущих up-d
# (используем docker compose с новой версией; перезапуск НЕ нужен,
# контейнер уже работает).
log "(compose-yml tag update — manual через .env API_TAG=$API_TAG)"
# Переключить nginx обратно на blue-port чтобы соответствовать compose-mapping.
# Контейнер уже на host port $API_PORT_BLUE? нет, мы запускали green на 8088.
# Переключаем upstream обратно на 8080 чтобы dock-compose up в будущем работал.
if [[ $DRY_RUN -eq 0 ]]; then
# Переcоздаём green-контейнер с blue-портом (быстрый stop/start).
docker stop food-market-api && docker rm food-market-api
cd "$(dirname "$COMPOSE_PATH")"
API_TAG="$API_TAG" docker compose up -d api
TMP="$(mktemp)"
cat > "$TMP" <<NGX
upstream food_market_api {
server 127.0.0.1:$API_PORT_BLUE;
}
NGX
sudo install -m 644 "$TMP" "$NGINX_UPSTREAM_FILE"
rm "$TMP"
sudo nginx -t && sudo systemctl reload nginx
fi
# ── 7. Web (тот же flow без миграций) ───────────────────────────────
log "=== Step 7/7: web ==="
if [[ $SKIP_WEB -eq 1 ]]; then
log "skipped (--skip-web)"
else
cd "$(dirname "$COMPOSE_PATH")"
run env WEB_TAG="$WEB_TAG" docker compose up -d web
log "web re-pulled to $WEB_IMG"
fi
log "✓ Deploy complete: api=$API_TAG web=$WEB_TAG"
exit 0

112
deploy/prod-rollback.sh Executable file
View file

@ -0,0 +1,112 @@
#!/usr/bin/env bash
#
# Sprint 21: быстрый rollback на предыдущий tag.
#
# Алгоритм:
# 1. Проверить что image нужного tag'a есть в registry (docker pull)
# 2. Перезапустить api/web с этим tag'ом через docker compose
# (через ENV API_TAG/WEB_TAG → compose pick'ает)
# 3. Дождаться /health/ready на новом контейнере
# 4. Если health OK → выйти 0; если не OK → fail (но контейнер уже
# поднят, ручное вмешательство нужно)
#
# Миграции БД rollback скрипт НЕ откатывает: down-migrations EF Core
# поддерживает, но мы их не пишем (см. CLAUDE.md / Phase19a/b — обе
# имеют Down() для DROP'a, но это для прода опасно — данные потеряются).
# Если откат требует down-миграции — отдельный manual review.
#
# Usage:
# deploy/prod-rollback.sh <to-tag> [--dry-run] [--skip-web]
#
# Example:
# deploy/prod-rollback.sh v20260606.5
# deploy/prod-rollback.sh v20260606.5 --dry-run
set -uo pipefail
TO_TAG="${1:-}"
DRY_RUN=0
SKIP_WEB=0
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--skip-web) SKIP_WEB=1; shift ;;
--help|-h) grep -E '^#( |$)' "$0" | sed 's/^# \?//'; exit 0 ;;
-*) echo "Unknown: $1" >&2; exit 2 ;;
*) shift ;;
esac
done
if [[ -z "$TO_TAG" ]]; then
echo "Usage: $0 <to-tag> [--dry-run] [--skip-web]" >&2
exit 2
fi
REGISTRY="${REGISTRY:-127.0.0.1:5001}"
COMPOSE_PATH="${COMPOSE_PATH:-/home/nns/food-market-prod/deploy/docker-compose.yml}"
PROD_URL="${PROD_URL:-https://admin.food-market.kz}"
log() { echo "[$(date -Iseconds)] $*"; }
fail() { log "FAIL: $*"; exit 1; }
run() {
if [[ $DRY_RUN -eq 1 ]]; then echo "[dry-run] $*";
else echo "[exec] $*"; "$@"; fi
}
# ── 1. Validate image existence ──────────────────────────────────────
log "=== Step 1/3: validate images ==="
API_IMG="$REGISTRY/food-market-api:$TO_TAG"
WEB_IMG="$REGISTRY/food-market-web:$TO_TAG"
# Сначала пробуем docker image inspect — если уже скачан, не тянем.
if [[ $DRY_RUN -eq 0 ]]; then
if ! docker image inspect "$API_IMG" >/dev/null 2>&1; then
log "api image $API_IMG не скачан, pull'им"
docker pull "$API_IMG" || fail "api image $TO_TAG отсутствует в $REGISTRY"
else
log "api image $TO_TAG уже скачан"
fi
if [[ $SKIP_WEB -eq 0 ]]; then
if ! docker image inspect "$WEB_IMG" >/dev/null 2>&1; then
docker pull "$WEB_IMG" || fail "web image $TO_TAG отсутствует"
fi
fi
else
log "[dry-run] would pull $API_IMG (and $WEB_IMG)"
fi
# ── 2. docker compose up -d с новым tag ─────────────────────────────
log "=== Step 2/3: docker compose up -d ==="
if [[ ! -f "$COMPOSE_PATH" ]]; then
fail "compose не найден: $COMPOSE_PATH"
fi
cd "$(dirname "$COMPOSE_PATH")"
if [[ $DRY_RUN -eq 0 ]]; then
if [[ $SKIP_WEB -eq 1 ]]; then
API_TAG="$TO_TAG" docker compose up -d --force-recreate api
else
API_TAG="$TO_TAG" WEB_TAG="$TO_TAG" docker compose up -d --force-recreate api web
fi
else
log "[dry-run] would run: API_TAG=$TO_TAG WEB_TAG=$TO_TAG docker compose up -d --force-recreate api web"
fi
# ── 3. Wait /health/ready ────────────────────────────────────────────
log "=== Step 3/3: wait /health/ready ==="
if [[ $DRY_RUN -eq 0 ]]; then
for i in $(seq 1 60); do
sleep 2
if curl -fsS --max-time 5 "$PROD_URL/health/ready" 2>/dev/null | grep -q '"status":"Healthy"'; then
log "✓ Rollback complete: $PROD_URL Healthy после $((i*2))s"
exit 0
fi
done
fail "/health/ready не отвечает Healthy за 120с — ручное вмешательство"
else
log "[dry-run] would poll $PROD_URL/health/ready up to 60×2s"
fi
exit 0

102
docs/sprint21-progress.md Normal file
View file

@ -0,0 +1,102 @@
# Sprint 21 — stage→prod migration toolchain
Цель: набор скриптов и workflow'ов чтобы первый прод-деплой был не
импровизацией, а одной командой с понятным rollback'ом и автоматическими
проверками.
Старт: 2026-06-07 (после Sprint 20). Исполнитель: Claude Opus 4.7.
## Принципы
- Все mutating-скрипты поддерживают `--dry-run` — печатают что бы сделали.
- Прод НЕ трогается из этого спринта (только инструментарий).
- Скрипты — идемпотентные: повторный запуск не ломает уже сделанное.
- Failure-режим: оставляем старый контейнер, не каскадим ошибку.
- НЕ трогать: `global.json`, prod admin.food-market.kz, POS WPF.
## Чек-лист
- [x] **1. Pre-deploy check**`deploy/check-prod-readiness.sh`:
backup<60min, disk 5GB на `/opt` + `/var/lib/docker`, `/health/ready`
Healthy, .env содержит required keys без placeholder'ов (CHANGEME/
REPLACE_ME/TODO/dev). Поддерживает `--ssh-host` для удалённой
проверки. Опциональная CI-проверка через `FM_CHECK_CI=1`.
- [x] **2. Blue-green deploy**`deploy/prod-deploy.sh <api-tag> <web-tag>`:
pull → green-контейнер `food-market-api-next` на :8088 → migrations
(auto через `Database.Migrate()` в Program.cs) → smoke
(`/health/ready` + `/api/me`) → nginx upstream switch → swap green→blue.
Failure → удаляем green, оставляем blue. `--skip-web` для api-only.
- [x] **3. Rollback**`deploy/prod-rollback.sh <to-tag>`:
`docker image inspect` + fallback `docker pull` → `docker compose up
-d --force-recreate api web` с указанным tag'ом → wait /health/ready
до 120с. `--skip-web` поддерживается.
- [x] **4. Post-deploy smoke**`deploy/post-deploy-smoke.sh`:
10 сценариев (signup → login → /api/me → list 5 endpoints → create
product → delete → logout-via-session). Парсит JSON через `python3`,
не grep/cut (после первого фейла на токене с пробелом перед `:`).
Telegram-alert через `FM_TG_TOKEN+FM_TG_CHAT` при провале.
**На stage прогнал 10/10 ✓** (тестовая прогонка).
- [x] **5. Stage-prod schema diff**`deploy/db-schema-diff.sh`:
`pg_dump --schema-only --no-owner --no-privileges --no-comments`
с обоих хостов через `ssh + docker exec`, нормализация (sed убирает
SET/SELECT pg_catalog.set_config/пустые строки/комментарии), `diff -u`.
Exit 0 = идентичны, 1 = разница, 2 = ошибка получения.
- [x] **6. Release notes generator** — `deploy/generate-release-notes.sh
<from-tag> <to-tag>`: `git log` группирует по prefix через `awk`:
feat → ✨ Новые возможности, fix → 🐛 Исправления, perf → ⚡, docs → 📚,
test/refactor/chore → `<details>`-свёрнутые. Сохраняет в
`docs/release-notes/<to-tag>.md`. **Прогнал на HEAD~3..HEAD** — markdown
сгенерирован корректно.
- [x] **7. Auto-tag workflow**`.forgejo/workflows/auto-tag.yml`:
на push в main → если HEAD ещё не помечен → создаёт `v<YYYYMMDD>.<N>`
где N — порядковый счётчик в дне. Annotated tag (`git tag -a`),
push в origin, дополнительно генерирует release-notes для будущего
деплоя (через `deploy/generate-release-notes.sh`).
## Журнал
### 2026-06-07 старт
Sprint 20 закрыт (7/7 ✓). Поехали по prod-toolchain.
### 2026-06-07 итог
Все 7 пунктов ✓. Прод не трогали (по правилам спринта).
**Тестовые прогоны на stage** (тест.admin.food-market.kz):
- `post-deploy-smoke.sh`: **10/10 ✓** (signup, login, /api/me, 5×list,
create+delete product, logout-via-session). Первая попытка упала
на парсинге access_token (пробел перед `:` в JSON), починил через
`python3 -c 'json.load(...)'`. Вторая — на отсутствующем
`/connect/revocation` (OpenIddict не сконфигурирован для revoke),
заменил step 10 на DELETE сессии через `/api/me/sessions/{id}` с
fallback'ом на token-valid sanity-check.
- `check-prod-readiness.sh`: на stage backup-dir `/opt/food-market-data/
backups` не существует (это нормально для stage'а) — FAIL, как и
ожидалось. Скрипт фиксирует это с конкретной причиной.
- `generate-release-notes.sh HEAD~3..HEAD`: markdown с группировкой
по prefix сгенерирован, файл сохранён в `docs/release-notes/`.
- `db-schema-diff.sh` и `prod-deploy.sh` / `prod-rollback.sh` запускать
на dev-vm против prod невозможно без SSH-настройки — синтаксис
проверен `bash -n`. Реальный прогон только когда user разрешит.
## Итог
Все 7 пунктов ✓. Инструментарий для прод-деплоя готов:
| Скрипт | Назначение | --dry-run | Прогон |
|---|---|---|---|
| `check-prod-readiness.sh` | Pre-deploy gating | ✓ | partial (на stage) |
| `prod-deploy.sh` | Blue-green release | ✓ | синтаксис |
| `prod-rollback.sh` | Fast rollback | ✓ | синтаксис |
| `post-deploy-smoke.sh` | Smoke-suite 10 шагов | ✓ | 10/10 ✓ (stage) |
| `db-schema-diff.sh` | Stage↔prod schema | ✓ | синтаксис |
| `generate-release-notes.sh` | Markdown release | ✓ | ✓ (HEAD~3..HEAD) |
| `.forgejo/workflows/auto-tag.yml` | Auto-tag CI | — | синтаксис (yaml) |
Что нужно от пользователя для реального прода:
- `/etc/nginx/conf.d/food-market-upstream.conf` на prod-vm с
`upstream food_market_api { server 127.0.0.1:8080; }` (создать пустым,
скрипт `prod-deploy.sh` его перепишет).
- SSH-ключ от dev-vm на prod-vm + alias `prod` в `~/.ssh/config`.
- `FM_TG_TOKEN` / `FM_TG_CHAT` env-vars для notify (опц.).
- Решение поднять prod-БД и сделать первый backup (`food-market-backup.sh`).
- Решение о dns/sertbot для admin.food-market.kz.