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