feat(prod): запуск admin.food-market.kz — бэкапы (hourly/daily/офсайт), restore-verify, мониторинг, deploy-prod
- prod-backup.sh (tier hourly 48ч / daily 30д + uploads), prod-backup-offsite-pull.sh (DR на dev-vm) - prod-restore-verify.sh: restore во временную БД + сверка числа строк, дроп - prod-monitor.sh: api/web/public/БД/диск/5xx → Telegram владельцу (+ --test) - deploy-prod.sh: guard'ы (prod-postgres, .env chmod600, не dev-пароль, quick-login off, SEED_DEMO_DATA=false, issuer), бэкап ДО миграций, только --yes, НЕ в ночных - systemd таймеры (backup-hourly/daily, monitor, offsite-pull); docs/prod-runbook.md Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a7b8bbc593
commit
b49abfdbd7
86
deploy/deploy-prod.sh
Executable file
86
deploy/deploy-prod.sh
Executable file
|
|
@ -0,0 +1,86 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# ПРОД food-market — деплой ТОЛЬКО по явной команде (--yes). Делает БЭКАП ДО миграций.
|
||||
# НЕ встраивать в ночные задачи: прод обновляется вручную.
|
||||
#
|
||||
# Запуск (с dev-vm):
|
||||
# ~/deploy-prod.sh <api-tag> <web-tag> --yes # сборка+push+деплой
|
||||
# ~/deploy-prod.sh --yes # взять теги из прод .env (не пересобирать)
|
||||
#
|
||||
# Guards: прод-compose указывает на food-market-prod-postgres (не shared-postgres),
|
||||
# .env chmod 600, quick-login выкл, демо-данные выкл, issuer = admin.food-market.kz.
|
||||
# Публичный сайт (public) НЕ трогается.
|
||||
set -uo pipefail
|
||||
|
||||
PROD_HOST="${PROD_HOST:-nns@192.168.1.190}"
|
||||
PROD_DIR="${PROD_DIR:-~/food-market/deploy}"
|
||||
REGISTRY="${REGISTRY:-192.168.1.193:5001}"
|
||||
REPO="${REPO:-/home/nns/food-market}"
|
||||
|
||||
API_TAG=""; WEB_TAG=""; YES=0; BUILD=0
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--yes) YES=1 ;;
|
||||
--build) BUILD=1 ;;
|
||||
-*) echo "Unknown flag: $a" >&2; exit 2 ;;
|
||||
*) if [[ -z "$API_TAG" ]]; then API_TAG="$a"; elif [[ -z "$WEB_TAG" ]]; then WEB_TAG="$a"; fi ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ $YES -ne 1 ]]; then
|
||||
echo "ОТКАЗ: прод-деплой требует явного --yes. Это боевой сервер."
|
||||
echo "Usage: $0 [<api-tag> <web-tag>] --yes [--build]"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
log() { echo "[$(date -Is)] [deploy-prod] $*"; }
|
||||
fail() { echo "[deploy-prod] FAIL: $*" >&2; exit 1; }
|
||||
|
||||
# ── Guard 1: прод-compose не ссылается на чужой shared-postgres ──
|
||||
REMOTE_COMPOSE="$(ssh -o StrictHostKeyChecking=no "$PROD_HOST" "cat $PROD_DIR/docker-compose.yml" 2>/dev/null)" || fail "не прочитать прод-compose"
|
||||
echo "$REMOTE_COMPOSE" | grep -q 'container_name: food-market-prod-postgres' || fail "в прод-compose нет food-market-prod-postgres"
|
||||
if echo "$REMOTE_COMPOSE" | grep -qE 'container_name:[[:space:]]*shared-postgres|name:[[:space:]]*food-market-postgres-data[[:space:]]*$|127\.0\.0\.1:5434:5432'; then
|
||||
fail "прод-compose ссылается на shared-postgres/чужой том/порт 5434 — СТОП"
|
||||
fi
|
||||
|
||||
# ── Guard 2: прод .env санитария ──
|
||||
REMOTE_ENV="$(ssh -o StrictHostKeyChecking=no "$PROD_HOST" "cat $PROD_DIR/.env" 2>/dev/null)" || fail "не прочитать прод .env"
|
||||
PERM="$(ssh -o StrictHostKeyChecking=no "$PROD_HOST" "stat -c %a $PROD_DIR/.env" 2>/dev/null)"
|
||||
[[ "$PERM" == "600" ]] || fail "прод .env права $PERM (должно 600)"
|
||||
echo "$REMOTE_ENV" | grep -qiE '^POSTGRES_PASSWORD=food_market_dev$' && fail "прод использует dev-пароль БД — СТОП"
|
||||
echo "$REMOTE_ENV" | grep -qiE '^QUICK_LOGIN_ENABLED=true' && fail "на проде включён quick-login — СТОП"
|
||||
echo "$REMOTE_ENV" | grep -qiE '^SEED_DEMO_DATA=false' || fail "на проде не выставлен SEED_DEMO_DATA=false — СТОП"
|
||||
echo "$REMOTE_ENV" | grep -qiE '^OPENIDDICT_ISSUER=https://admin\.food-market\.kz' || fail "issuer не admin.food-market.kz — СТОП"
|
||||
log "guards OK"
|
||||
|
||||
# ── (опц.) сборка + push релизных образов ──
|
||||
if [[ $BUILD -eq 1 ]]; then
|
||||
[[ -n "$API_TAG" && -n "$WEB_TAG" ]] || { API_TAG="$(cd "$REPO" && git rev-parse --short HEAD)"; WEB_TAG="$API_TAG"; }
|
||||
log "build+push api=$API_TAG web=$WEB_TAG"
|
||||
( cd "$REPO" && docker build --build-arg LOCAL_REGISTRY=$REGISTRY --build-arg GIT_SHA="$API_TAG" -f deploy/Dockerfile.api -t $REGISTRY/food-market-api:$API_TAG . ) || fail "build api"
|
||||
( cd "$REPO" && docker build --build-arg LOCAL_REGISTRY=$REGISTRY -f deploy/Dockerfile.web -t $REGISTRY/food-market-web:$WEB_TAG . ) || fail "build web"
|
||||
docker push $REGISTRY/food-market-api:$API_TAG || fail "push api"
|
||||
docker push $REGISTRY/food-market-web:$WEB_TAG || fail "push web"
|
||||
# обновить теги в прод .env
|
||||
ssh -o StrictHostKeyChecking=no "$PROD_HOST" "cd $PROD_DIR && sed -i 's/^API_TAG=.*/API_TAG=$API_TAG/; s/^WEB_TAG=.*/WEB_TAG=$WEB_TAG/' .env"
|
||||
fi
|
||||
|
||||
# ── БЭКАП ДО миграций (обязательно) ──
|
||||
log "бэкап БД ДО деплоя…"
|
||||
ssh -o StrictHostKeyChecking=no "$PROD_HOST" "FM_BACKUP_TIER=daily $PROD_DIR/prod-backup.sh" 2>&1 | tail -4 || fail "бэкап не прошёл — деплой остановлен"
|
||||
|
||||
# ── деплой: pull + up postgres+api+web (НЕ public) ──
|
||||
log "pull + up (миграции применяются при старте api)…"
|
||||
ssh -o StrictHostKeyChecking=no "$PROD_HOST" "cd $PROD_DIR && docker compose pull postgres api web && docker compose up -d postgres api web" 2>&1 | tail -10 || fail "compose up"
|
||||
|
||||
# ── ждём healthy + smoke ──
|
||||
log "жду api healthy…"
|
||||
OK=0
|
||||
for i in $(seq 1 30); do
|
||||
if ssh -o StrictHostKeyChecking=no "$PROD_HOST" "curl -fsS http://localhost:8080/health/ready 2>/dev/null | grep -q Healthy"; then OK=1; break; fi
|
||||
sleep 3
|
||||
done
|
||||
[[ $OK -eq 1 ]] || fail "api не стал Healthy — проверь логи food-market-api"
|
||||
PCODE=$(curl -s -o /dev/null -w '%{http_code}' https://admin.food-market.kz/health/ready 2>/dev/null || echo 000)
|
||||
log "публичный /health/ready → HTTP $PCODE"
|
||||
log "✓ прод-деплой завершён (api=$(ssh $PROD_HOST "grep ^API_TAG= $PROD_DIR/.env | cut -d= -f2"))"
|
||||
18
deploy/prod-backup-offsite-pull.sh
Executable file
18
deploy/prod-backup-offsite-pull.sh
Executable file
|
|
@ -0,0 +1,18 @@
|
|||
#!/usr/bin/env bash
|
||||
# Офсайт-копия прод-бэкапов на ДРУГОЙ хост (dev-vm 192.168.1.193). Запускается
|
||||
# НА dev-vm по таймеру: тянет последний daily-дамп прода (prod-vm) сюда, хранит 30 дн.
|
||||
# Направление dev→prod (рабочий SSH). Это «другая VM» для DR.
|
||||
set -uo pipefail
|
||||
PROD_HOST="${PROD_HOST:-nns@192.168.1.190}"
|
||||
PROD_BACKUP_DIR="${PROD_BACKUP_DIR:-/opt/food-market-data/backups/daily}"
|
||||
DEST="${FM_OFFSITE_LOCAL:-$HOME/fm-prod-backups}"
|
||||
RET_DAYS="${FM_OFFSITE_RETENTION_DAYS:-30}"
|
||||
mkdir -p "$DEST"
|
||||
latest=$(ssh -o StrictHostKeyChecking=no "$PROD_HOST" "ls -t $PROD_BACKUP_DIR/db-*.dump 2>/dev/null | head -1" 2>/dev/null)
|
||||
[ -n "$latest" ] || { echo "[offsite] нет daily-дампов на проде"; exit 1; }
|
||||
base=$(basename "$latest")
|
||||
if [ -f "$DEST/$base" ]; then echo "[offsite] уже есть: $base"; else
|
||||
scp -o StrictHostKeyChecking=no "$PROD_HOST:$latest" "$DEST/" && echo "[offsite] скопирован $base → $DEST"
|
||||
fi
|
||||
find "$DEST" -maxdepth 1 -name 'db-*.dump' -mtime +"$RET_DAYS" -print -delete
|
||||
echo "[offsite] готово ($(ls "$DEST"/db-*.dump 2>/dev/null | wc -l) копий)"
|
||||
78
deploy/prod-backup.sh
Executable file
78
deploy/prod-backup.sh
Executable file
|
|
@ -0,0 +1,78 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# ПРОД food-market: резервная копия БД (pg_dump -Fc) с тирами и офсайт-копией.
|
||||
#
|
||||
# Тиры (FM_BACKUP_TIER):
|
||||
# hourly — каждый час, хранение 48 ч → $FM_BACKUP_DIR/hourly
|
||||
# daily — ежедневно, хранение 30 дн → $FM_BACKUP_DIR/daily (+ офсайт-копия)
|
||||
#
|
||||
# Офсайт: daily-дамп копируется на другой хост/диск (FM_OFFSITE_DEST, scp-назначение
|
||||
# вида user@host:/path ИЛИ локальный путь на другом диске). Копия ротационно
|
||||
# хранится FM_OFFSITE_RETENTION_DAYS (по умолчанию 30).
|
||||
#
|
||||
# Контейнер БД — food-market-prod-postgres (НЕ shared-postgres с чужими базами!).
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER="${FM_PG_CONTAINER:-food-market-prod-postgres}"
|
||||
DB="${FM_PG_DB:-food_market}"
|
||||
DB_USER="${FM_PG_USER:-food_market}"
|
||||
BASE_DIR="${FM_BACKUP_DIR:-/opt/food-market-data/backups}"
|
||||
UPLOADS_DIR="${FM_UPLOADS_DIR:-/opt/food-market-data/uploads}"
|
||||
TIER="${FM_BACKUP_TIER:-daily}"
|
||||
OFFSITE_DEST="${FM_OFFSITE_DEST:-}" # напр. nns@192.168.1.193:/home/nns/fm-prod-backups
|
||||
OFFSITE_RETENTION="${FM_OFFSITE_RETENTION_DAYS:-30}"
|
||||
|
||||
case "$TIER" in
|
||||
hourly) DIR="$BASE_DIR/hourly"; RET_MIN=2880 ;; # 48 ч = 2880 мин
|
||||
daily) DIR="$BASE_DIR/daily"; RET_MIN=43200 ;; # 30 дн = 43200 мин
|
||||
*) echo "FM_BACKUP_TIER должен быть hourly|daily (дано: $TIER)" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
TS="$(date +%Y%m%d-%H%M%S)"
|
||||
DB_FILE="$DIR/db-$TS.dump"
|
||||
log() { echo "[$(date -Is)] [$TIER] $*"; }
|
||||
mkdir -p "$DIR"
|
||||
|
||||
if ! docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then
|
||||
log "ОШИБКА: контейнер '$CONTAINER' не запущен — бэкап невозможен." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "Дамп '$DB' → $DB_FILE"
|
||||
if docker exec "$CONTAINER" pg_dump -U "$DB_USER" -d "$DB" -Fc > "$DB_FILE.tmp"; then
|
||||
mv "$DB_FILE.tmp" "$DB_FILE"
|
||||
log "Готово: $(du -h "$DB_FILE" | cut -f1)"
|
||||
else
|
||||
rm -f "$DB_FILE.tmp"; log "ОШИБКА: pg_dump завершился с ошибкой." >&2; exit 1
|
||||
fi
|
||||
|
||||
# uploads архивируем только в daily (меняются реже, чем БД).
|
||||
if [ "$TIER" = "daily" ] && [ -d "$UPLOADS_DIR" ]; then
|
||||
UP="$DIR/uploads-$TS.tgz"
|
||||
tar czf "$UP.tmp" -C "$(dirname "$UPLOADS_DIR")" "$(basename "$UPLOADS_DIR")" && mv "$UP.tmp" "$UP"
|
||||
log "uploads → $UP ($(du -h "$UP" | cut -f1))"
|
||||
fi
|
||||
|
||||
# Офсайт-копия (только daily).
|
||||
if [ "$TIER" = "daily" ] && [ -n "$OFFSITE_DEST" ]; then
|
||||
if [[ "$OFFSITE_DEST" == *:* ]]; then
|
||||
# удалённый scp-назначение
|
||||
host="${OFFSITE_DEST%%:*}"; path="${OFFSITE_DEST#*:}"
|
||||
ssh -o StrictHostKeyChecking=no "$host" "mkdir -p '$path'" 2>/dev/null || true
|
||||
if scp -o StrictHostKeyChecking=no "$DB_FILE" "$OFFSITE_DEST/" 2>/dev/null; then
|
||||
log "офсайт-копия → $OFFSITE_DEST"
|
||||
ssh -o StrictHostKeyChecking=no "$host" "find '$path' -maxdepth 1 -name 'db-*.dump' -mtime +$OFFSITE_RETENTION -delete" 2>/dev/null || true
|
||||
else
|
||||
log "ВНИМАНИЕ: офсайт-копия не удалась (scp $OFFSITE_DEST)" >&2
|
||||
fi
|
||||
else
|
||||
mkdir -p "$OFFSITE_DEST"
|
||||
cp "$DB_FILE" "$OFFSITE_DEST/" && log "офсайт-копия → $OFFSITE_DEST"
|
||||
find "$OFFSITE_DEST" -maxdepth 1 -name 'db-*.dump' -mtime +"$OFFSITE_RETENTION" -delete 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
log "Ротация $DIR: удаляю старше $RET_MIN мин."
|
||||
find "$DIR" -maxdepth 1 -type f -name 'db-*.dump' -mmin +"$RET_MIN" -print -delete
|
||||
find "$DIR" -maxdepth 1 -type f -name 'uploads-*.tgz' -mmin +"$RET_MIN" -print -delete
|
||||
log "Бэкап завершён."
|
||||
80
deploy/prod-monitor.sh
Executable file
80
deploy/prod-monitor.sh
Executable file
|
|
@ -0,0 +1,80 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# ПРОД food-market: мониторинг здоровья. Проверяет api/web/БД, место на диске и
|
||||
# число 5xx в логах api за последний интервал. При проблеме шлёт владельцу в
|
||||
# Telegram (бот уже есть; token/chat — ~/.fm-watchdog/telegram-{token,chat}).
|
||||
#
|
||||
# Запуск из systemd-таймера (каждые ~5 мин). Аргументы:
|
||||
# (без аргументов) — обычная проверка, уведомляет ТОЛЬКО при проблеме.
|
||||
# --test — отправляет тестовое уведомление «мониторинг жив» и выходит.
|
||||
# --heartbeat — всегда шлёт короткий статус (для ручной проверки).
|
||||
set -uo pipefail
|
||||
|
||||
WATCH_DIR="${WATCH_DIR:-$HOME/.fm-watchdog}"
|
||||
API_URL="${FM_API_URL:-http://localhost:8080/health/ready}"
|
||||
WEB_URL="${FM_WEB_URL:-http://localhost:8081/}"
|
||||
PUBLIC_URL="${FM_PUBLIC_HEALTH:-https://admin.food-market.kz/health/ready}"
|
||||
CONTAINER="${FM_PG_CONTAINER:-food-market-prod-postgres}"
|
||||
API_CONTAINER="${FM_API_CONTAINER:-food-market-api}"
|
||||
DISK_PATH="${FM_DISK_PATH:-/opt/food-market-data}"
|
||||
DISK_WARN_PCT="${FM_DISK_WARN_PCT:-85}"
|
||||
LOG="${FM_MONITOR_LOG:-$WATCH_DIR/prod-monitor.log}"
|
||||
|
||||
log() { echo "[$(date -Is)] $*" | tee -a "$LOG" >/dev/null; }
|
||||
|
||||
notify() {
|
||||
local msg="$1"
|
||||
local token chat
|
||||
token="$(cat "$WATCH_DIR/telegram-token" 2>/dev/null || true)"
|
||||
chat="$(cat "$WATCH_DIR/telegram-chat" 2>/dev/null || true)"
|
||||
if [[ -z "$token" || -z "$chat" ]]; then
|
||||
log "telegram token/chat не настроены — notify пропущен: $msg"; return
|
||||
fi
|
||||
curl -sS --max-time 10 -X POST "https://api.telegram.org/bot$token/sendMessage" \
|
||||
--data-urlencode "chat_id=$chat" \
|
||||
--data-urlencode "text=$msg" >/dev/null 2>&1 \
|
||||
&& log "notify отправлено: $msg" || log "notify FAILED: $msg"
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "--test" ]]; then
|
||||
notify "✅ ALL MARKET прод: тест мониторинга $(date '+%Y-%m-%d %H:%M'). Если видите это — уведомления работают."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
problems=()
|
||||
|
||||
# API /health/ready
|
||||
if ! curl -fsS --max-time 10 "$API_URL" 2>/dev/null | grep -q '"status":"Healthy"'; then
|
||||
problems+=("API /health/ready не Healthy ($API_URL)")
|
||||
fi
|
||||
# Web SPA
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$WEB_URL" 2>/dev/null || echo 000)
|
||||
[[ "$code" =~ ^(200|301|302)$ ]] || problems+=("web вернул HTTP $code ($WEB_URL)")
|
||||
# Публичный вход (через gateway)
|
||||
pcode=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$PUBLIC_URL" 2>/dev/null || echo 000)
|
||||
[[ "$pcode" =~ ^(200)$ ]] || problems+=("публичный $PUBLIC_URL → HTTP $pcode")
|
||||
# БД
|
||||
if ! docker exec "$CONTAINER" pg_isready -U food_market -d food_market >/dev/null 2>&1; then
|
||||
problems+=("БД $CONTAINER не отвечает (pg_isready)")
|
||||
fi
|
||||
# Диск
|
||||
pct=$(df --output=pcent "$DISK_PATH" 2>/dev/null | tail -1 | tr -dc '0-9')
|
||||
if [[ -n "$pct" && "$pct" -ge "$DISK_WARN_PCT" ]]; then
|
||||
problems+=("диск $DISK_PATH заполнен на ${pct}% (порог ${DISK_WARN_PCT}%)")
|
||||
fi
|
||||
# 5xx в логах api за последние 5 минут
|
||||
fivexx=$(docker logs "$API_CONTAINER" --since 5m 2>&1 | grep -cE ' (50[0-9]) ' || true)
|
||||
if [[ -n "$fivexx" && "$fivexx" -gt 0 ]]; then
|
||||
problems+=("$fivexx ответов 5xx в логах api за 5 мин")
|
||||
fi
|
||||
|
||||
if [[ ${#problems[@]} -gt 0 ]]; then
|
||||
msg="🔴 ALL MARKET прод — проблемы:"$'\n'"$(printf -- '- %s\n' "${problems[@]}")"
|
||||
log "ПРОБЛЕМЫ: ${problems[*]}"
|
||||
notify "$msg"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log "OK: api/web/public/БД/диск(${pct}%)/5xx=0"
|
||||
[[ "${1:-}" == "--heartbeat" ]] && notify "🟢 ALL MARKET прод OK $(date '+%H:%M') (диск ${pct}%)"
|
||||
exit 0
|
||||
56
deploy/prod-restore-verify.sh
Executable file
56
deploy/prod-restore-verify.sh
Executable file
|
|
@ -0,0 +1,56 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# ПРОД food-market: проверка восстановления бэкапа. Берёт последний daily-дамп
|
||||
# (или путь из аргумента), ВОССТАНАВЛИВАЕТ во ВРЕМЕННУЮ БД в том же контейнере
|
||||
# postgres, сверяет число строк ключевых таблиц с боевой БД, затем удаляет
|
||||
# временную БД. Боевую БД НЕ трогает (только читает count'ы).
|
||||
#
|
||||
# Выход 0 — восстановление успешно и counts совпали; !=0 — проблема (для мониторинга).
|
||||
set -uo pipefail
|
||||
|
||||
CONTAINER="${FM_PG_CONTAINER:-food-market-prod-postgres}"
|
||||
DB="${FM_PG_DB:-food_market}"
|
||||
DB_USER="${FM_PG_USER:-food_market}"
|
||||
BASE_DIR="${FM_BACKUP_DIR:-/opt/food-market-data/backups}"
|
||||
TMP_DB="food_market_restore_test"
|
||||
DUMP="${1:-}"
|
||||
|
||||
log() { echo "[$(date -Is)] [restore-verify] $*"; }
|
||||
fail() { log "FAIL: $*"; cleanup; exit 1; }
|
||||
cleanup() { docker exec "$CONTAINER" psql -U "$DB_USER" -d "$DB" -c "DROP DATABASE IF EXISTS $TMP_DB;" >/dev/null 2>&1 || true; }
|
||||
|
||||
[ -n "$DUMP" ] || DUMP="$(ls -t "$BASE_DIR"/daily/db-*.dump "$BASE_DIR"/hourly/db-*.dump 2>/dev/null | head -1)"
|
||||
[ -n "$DUMP" ] && [ -f "$DUMP" ] || fail "дамп не найден (ни в аргументе, ни в $BASE_DIR)"
|
||||
log "дамп: $DUMP ($(du -h "$DUMP" | cut -f1))"
|
||||
|
||||
trap cleanup EXIT
|
||||
cleanup
|
||||
log "создаю временную БД $TMP_DB"
|
||||
docker exec "$CONTAINER" psql -U "$DB_USER" -d "$DB" -c "CREATE DATABASE $TMP_DB;" >/dev/null 2>&1 || fail "CREATE DATABASE"
|
||||
|
||||
log "pg_restore → $TMP_DB"
|
||||
if ! docker exec -i "$CONTAINER" pg_restore -U "$DB_USER" -d "$TMP_DB" --no-owner --no-privileges < "$DUMP" >/tmp/fm-restore.log 2>&1; then
|
||||
# pg_restore может вернуть !=0 на безобидных warning'ах (owner/extension); проверяем,
|
||||
# что таблицы реально появились — это решающий критерий.
|
||||
log "pg_restore завершился с предупреждениями (проверяю таблицы):"
|
||||
tail -5 /tmp/fm-restore.log 2>/dev/null | sed 's/^/ /' || true
|
||||
fi
|
||||
|
||||
# Сверка числа строк ключевых таблиц: боевая vs восстановленная.
|
||||
TABLES="users roles organizations products supplies retail_sales money_transactions stocks"
|
||||
mismatch=0
|
||||
for t in $TABLES; do
|
||||
live=$(docker exec "$CONTAINER" psql -U "$DB_USER" -d "$DB" -A -t -c "SELECT count(*) FROM $t;" 2>/dev/null | tr -d '[:space:]')
|
||||
rest=$(docker exec "$CONTAINER" psql -U "$DB_USER" -d "$TMP_DB" -A -t -c "SELECT count(*) FROM $t;" 2>/dev/null | tr -d '[:space:]')
|
||||
live="${live:-ERR}"; rest="${rest:-ERR}"
|
||||
if [ "$live" = "$rest" ] && [ "$live" != "ERR" ]; then
|
||||
log " $t: $live = $rest ✓"
|
||||
else
|
||||
log " $t: боевая=$live восстановлено=$rest ✗"; mismatch=1
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$mismatch" = 0 ] || fail "число строк не совпало (см. выше)"
|
||||
log "✓ Восстановление проверено: все ключевые таблицы совпали."
|
||||
# cleanup выполнит trap EXIT.
|
||||
exit 0
|
||||
9
deploy/systemd/food-market-backup-daily.service
Normal file
9
deploy/systemd/food-market-backup-daily.service
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
[Unit]
|
||||
Description=food-market PROD daily DB backup (30d retention + uploads)
|
||||
After=docker.service
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Environment=FM_BACKUP_TIER=daily
|
||||
ExecStart=/home/nns/food-market/deploy/prod-backup.sh
|
||||
ExecStartPost=/home/nns/food-market/deploy/prod-restore-verify.sh
|
||||
User=nns
|
||||
7
deploy/systemd/food-market-backup-daily.timer
Normal file
7
deploy/systemd/food-market-backup-daily.timer
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[Unit]
|
||||
Description=Run food-market PROD daily backup
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:30:00
|
||||
Persistent=true
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
8
deploy/systemd/food-market-backup-hourly.service
Normal file
8
deploy/systemd/food-market-backup-hourly.service
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[Unit]
|
||||
Description=food-market PROD hourly DB backup (48h retention)
|
||||
After=docker.service
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Environment=FM_BACKUP_TIER=hourly
|
||||
ExecStart=/home/nns/food-market/deploy/prod-backup.sh
|
||||
User=nns
|
||||
8
deploy/systemd/food-market-backup-hourly.timer
Normal file
8
deploy/systemd/food-market-backup-hourly.timer
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[Unit]
|
||||
Description=Run food-market PROD hourly backup
|
||||
[Timer]
|
||||
OnCalendar=hourly
|
||||
Persistent=true
|
||||
RandomizedDelaySec=120
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
6
deploy/systemd/food-market-offsite-pull.service
Normal file
6
deploy/systemd/food-market-offsite-pull.service
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
[Unit]
|
||||
Description=food-market PROD offsite backup pull to dev-vm (DR copy)
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/home/nns/pull-prod-backup.sh
|
||||
User=nns
|
||||
7
deploy/systemd/food-market-offsite-pull.timer
Normal file
7
deploy/systemd/food-market-offsite-pull.timer
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[Unit]
|
||||
Description=Pull food-market PROD daily backup offsite (04:00)
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 04:00:00
|
||||
Persistent=true
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
7
deploy/systemd/food-market-prod-monitor.service
Normal file
7
deploy/systemd/food-market-prod-monitor.service
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[Unit]
|
||||
Description=food-market PROD health monitor (telegram alerts)
|
||||
After=docker.service
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/home/nns/food-market/deploy/prod-monitor.sh
|
||||
User=nns
|
||||
7
deploy/systemd/food-market-prod-monitor.timer
Normal file
7
deploy/systemd/food-market-prod-monitor.timer
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
[Unit]
|
||||
Description=Run food-market PROD monitor every 5 min
|
||||
[Timer]
|
||||
OnBootSec=3min
|
||||
OnUnitActiveSec=5min
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
|
|
@ -1,5 +1,13 @@
|
|||
# Запуск прод-сервера admin.food-market.kz — прогресс
|
||||
|
||||
## ✅ ГОТОВО (09.10.2026): прод поднят и пуст, бэкап/восстановление проверены, мониторинг шлёт тест
|
||||
Прод-стек (postgres 5437 + api 8080 + web 8081) поднят из релиз-образов SHA `77464d6`. БД пуста
|
||||
(orgs=0, products=0; только SuperAdmin `admin@food-market.local` + 4 системные роли; демо-орг нет).
|
||||
Вход через https://admin.food-market.kz = 200, quick-login = 404. Бэкапы: hourly(48ч)/daily(30д)+
|
||||
офсайт на dev-vm; restore-verify прошёл (все ключевые таблицы совпали). Мониторинг (5 мин) шлёт в
|
||||
Telegram владельцу — тест-уведомление отправлено ✓. `~/deploy-prod.sh` с guard'ами (только --yes,
|
||||
backup до миграций, не в ночных). Рунбук: `docs/prod-runbook.md`.
|
||||
|
||||
Приоритет №2 (09.10.2026). Цель: поднять ПУСТОЙ рабочий прод для перехода магазина на ALL MARKET.
|
||||
Прод-vm 192.168.1.190, compose `~/food-market/deploy` (свой `food-market-prod-postgres:5437`,
|
||||
НЕ shared-postgres). gateway nginx проксирует admin.food-market.kz → :8081 (web). Стенд и прод
|
||||
|
|
|
|||
64
docs/prod-runbook.md
Normal file
64
docs/prod-runbook.md
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Рунбук ПРОД admin.food-market.kz (ALL MARKET)
|
||||
|
||||
Боевой сервер магазина. Прод-vm **192.168.1.190**, compose **`~/food-market/deploy`**, своя БД
|
||||
**`food-market-prod-postgres:5437`** (НЕ shared-postgres!). gateway nginx (на хосте, только читать)
|
||||
проксирует admin.food-market.kz → :8081 (web). Публичный сайт food-market.kz — НЕ трогаем.
|
||||
|
||||
## Чек-лист готовности (выполнено 09.10.2026)
|
||||
- [x] **Образы — релизные теги** (SHA main, не `test`/`latest`): `API_TAG`/`WEB_TAG` в `.env`.
|
||||
- [x] **Секреты прода отдельны от стенда**, в `~/food-market/deploy/.env` **chmod 600**:
|
||||
- `POSTGRES_PASSWORD` — сильный (НЕ dev-дефолт). БД своя (5437).
|
||||
- `OPENIDDICT_ISSUER=https://admin.food-market.kz/`. OpenIddict PFX-ключи генерируются на
|
||||
сервере в томе `food-market-api-data` (очищен перед стартом → свежие прод-ключи, отдельные
|
||||
от стенда/dev). `OPENIDDICT_CERT_PASSWORD` пуст (ключи защищены правами ФС).
|
||||
- `SEED_SUPERADMIN_PASSWORD` — временный, см. ниже.
|
||||
- VAPID (web-push) — автогенерация в БД (PlatformSettings), секрет не нужен.
|
||||
- SMTP — НЕ настроен (задаёт владелец; до настройки сброс пароля по email неактивен).
|
||||
- [x] **Выключено на проде**: quick-login (`QUICK_LOGIN_ENABLED=false` + code safety-override по
|
||||
issuer), стендовые подсказки, демо-сидеры и тест-орги (`SEED_DEMO_DATA=false` → нет «Demo Market»).
|
||||
- [x] **SuperAdmin** `admin@food-market.local` — пароль из `SEED_SUPERADMIN_PASSWORD`; **копия
|
||||
пароля: `~/.fm-watchdog/prod-superadmin-initial.txt` (chmod 600) на dev-vm**. ⚠ Владелец ОБЯЗАН
|
||||
сменить пароль при первом входе (UI → профиль). Рекомендуется также завести персональный аккаунт.
|
||||
- [x] Проверено: вход SuperAdmin через https://admin.food-market.kz = HTTP 200; БД пуста
|
||||
(orgs=0, products=0, users=1=SuperAdmin, roles=4 системных); web SPA = 200; quick-login = 404.
|
||||
|
||||
## Деплой прода (ТОЛЬКО вручную)
|
||||
```
|
||||
~/deploy-prod.sh --yes # взять теги из прод .env, backup → up
|
||||
~/deploy-prod.sh <api> <web> --yes --build # пересобрать+push+деплой с новыми тегами
|
||||
```
|
||||
- Скрипт с guard'ами: проверяет что compose = food-market-prod-postgres (не shared-postgres),
|
||||
`.env` chmod 600, НЕ dev-пароль БД, quick-login off, SEED_DEMO_DATA=false, issuer прод.
|
||||
- Делает **бэкап БД ДО** pull/миграций. Миграции применяются автоматически при старте api
|
||||
(`AppDbContext.Database.Migrate()` → `/health/ready` = Healthy только после миграций).
|
||||
- Поднимает `postgres api web` (public НЕ трогает). НЕ в ночных задачах.
|
||||
|
||||
## Бэкапы и восстановление
|
||||
- **Ежечасно** (хранение 48 ч): `food-market-backup-hourly.timer` → `/opt/food-market-data/backups/hourly`.
|
||||
- **Ежедневно 03:30** (хранение 30 дн + uploads): `food-market-backup-daily.timer` →
|
||||
`…/backups/daily`; `ExecStartPost` — автопроверка восстановления.
|
||||
- **Офсайт (другая VM)**: dev-vm таймер `food-market-offsite-pull.timer` (04:00) тянет последний
|
||||
daily-дамп на dev-vm `~/fm-prod-backups` (хранение 30 дн).
|
||||
- Формат — `pg_dump -Fc` (pg_restore с параллелизмом/выборочно).
|
||||
- **Проверка восстановления** (вручную): `~/food-market/deploy/prod-restore-verify.sh` — restore
|
||||
последнего дампа во временную БД `food_market_restore_test` + сверка числа строк с боевой +
|
||||
дроп временной. Боевую БД не трогает. Проверено 09.10: все ключевые таблицы совпали.
|
||||
|
||||
## Мониторинг
|
||||
- `food-market-prod-monitor.timer` (каждые 5 мин): api `/health/ready`, web, публичный вход, БД
|
||||
(`pg_isready`), место на диске (порог 85%), 5xx в логах api за 5 мин. При проблеме — Telegram
|
||||
владельцу (бот: `~/.fm-watchdog/telegram-{token,chat}` на prod-vm). Лог: `~/.fm-watchdog/prod-monitor.log`.
|
||||
- Тест уведомления: `~/food-market/deploy/prod-monitor.sh --test` (отправлено 09.10 ✓).
|
||||
|
||||
## Откат (rollback)
|
||||
1. Предыдущий образ: в registry по прошлому SHA. `~/deploy-prod.sh <prev-api> <prev-web> --yes`
|
||||
(без `--build`) — вернёт прежние теги (бэкап снимется автоматически до переключения).
|
||||
2. Данные: восстановить из дампа —
|
||||
`docker exec -i food-market-prod-postgres pg_restore -U food_market -d food_market --clean --if-exists < <дамп>`
|
||||
(на пустую/битую БД; при живой — сначала остановить api). Дампы: `/opt/food-market-data/backups/{hourly,daily}` или офсайт dev-vm `~/fm-prod-backups`.
|
||||
3. Проверить `/health/ready` и вход. Если проблема в миграции — откат образа на прошлый SHA
|
||||
(миграции в этом проекте аддитивные; down-миграции не применяем на проде).
|
||||
|
||||
## НЕ трогать
|
||||
shared-postgres (5434) и его базы (sklad/purchase/calcman/makesales); gateway nginx (только чтение);
|
||||
стенд (test.admin.food-market.kz); публичный сайт food-market.kz (контейнер public, PUBLIC_TAG=test).
|
||||
Loading…
Reference in a new issue