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>
221 lines
9 KiB
Bash
Executable file
221 lines
9 KiB
Bash
Executable file
#!/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
|