diff --git a/deploy/deploy-prod.sh b/deploy/deploy-prod.sh new file mode 100755 index 0000000..45c2549 --- /dev/null +++ b/deploy/deploy-prod.sh @@ -0,0 +1,86 @@ +#!/usr/bin/env bash +# +# ПРОД food-market — деплой ТОЛЬКО по явной команде (--yes). Делает БЭКАП ДО миграций. +# НЕ встраивать в ночные задачи: прод обновляется вручную. +# +# Запуск (с dev-vm): +# ~/deploy-prod.sh --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 [ ] --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"))" diff --git a/deploy/prod-backup-offsite-pull.sh b/deploy/prod-backup-offsite-pull.sh new file mode 100755 index 0000000..394532d --- /dev/null +++ b/deploy/prod-backup-offsite-pull.sh @@ -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) копий)" diff --git a/deploy/prod-backup.sh b/deploy/prod-backup.sh new file mode 100755 index 0000000..db269b5 --- /dev/null +++ b/deploy/prod-backup.sh @@ -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 "Бэкап завершён." diff --git a/deploy/prod-monitor.sh b/deploy/prod-monitor.sh new file mode 100755 index 0000000..92978d5 --- /dev/null +++ b/deploy/prod-monitor.sh @@ -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 diff --git a/deploy/prod-restore-verify.sh b/deploy/prod-restore-verify.sh new file mode 100755 index 0000000..9d485f3 --- /dev/null +++ b/deploy/prod-restore-verify.sh @@ -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 diff --git a/deploy/systemd/food-market-backup-daily.service b/deploy/systemd/food-market-backup-daily.service new file mode 100644 index 0000000..15fbc1d --- /dev/null +++ b/deploy/systemd/food-market-backup-daily.service @@ -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 diff --git a/deploy/systemd/food-market-backup-daily.timer b/deploy/systemd/food-market-backup-daily.timer new file mode 100644 index 0000000..e81759f --- /dev/null +++ b/deploy/systemd/food-market-backup-daily.timer @@ -0,0 +1,7 @@ +[Unit] +Description=Run food-market PROD daily backup +[Timer] +OnCalendar=*-*-* 03:30:00 +Persistent=true +[Install] +WantedBy=timers.target diff --git a/deploy/systemd/food-market-backup-hourly.service b/deploy/systemd/food-market-backup-hourly.service new file mode 100644 index 0000000..2f00cfe --- /dev/null +++ b/deploy/systemd/food-market-backup-hourly.service @@ -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 diff --git a/deploy/systemd/food-market-backup-hourly.timer b/deploy/systemd/food-market-backup-hourly.timer new file mode 100644 index 0000000..be484cb --- /dev/null +++ b/deploy/systemd/food-market-backup-hourly.timer @@ -0,0 +1,8 @@ +[Unit] +Description=Run food-market PROD hourly backup +[Timer] +OnCalendar=hourly +Persistent=true +RandomizedDelaySec=120 +[Install] +WantedBy=timers.target diff --git a/deploy/systemd/food-market-offsite-pull.service b/deploy/systemd/food-market-offsite-pull.service new file mode 100644 index 0000000..0478c58 --- /dev/null +++ b/deploy/systemd/food-market-offsite-pull.service @@ -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 diff --git a/deploy/systemd/food-market-offsite-pull.timer b/deploy/systemd/food-market-offsite-pull.timer new file mode 100644 index 0000000..2f1978a --- /dev/null +++ b/deploy/systemd/food-market-offsite-pull.timer @@ -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 diff --git a/deploy/systemd/food-market-prod-monitor.service b/deploy/systemd/food-market-prod-monitor.service new file mode 100644 index 0000000..99720ad --- /dev/null +++ b/deploy/systemd/food-market-prod-monitor.service @@ -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 diff --git a/deploy/systemd/food-market-prod-monitor.timer b/deploy/systemd/food-market-prod-monitor.timer new file mode 100644 index 0000000..90c848e --- /dev/null +++ b/deploy/systemd/food-market-prod-monitor.timer @@ -0,0 +1,7 @@ +[Unit] +Description=Run food-market PROD monitor every 5 min +[Timer] +OnBootSec=3min +OnUnitActiveSec=5min +[Install] +WantedBy=timers.target diff --git a/docs/prod-launch-progress.md b/docs/prod-launch-progress.md index 8d2ec5e..64d11fd 100644 --- a/docs/prod-launch-progress.md +++ b/docs/prod-launch-progress.md @@ -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). Стенд и прод diff --git a/docs/prod-runbook.md b/docs/prod-runbook.md new file mode 100644 index 0000000..fa045d7 --- /dev/null +++ b/docs/prod-runbook.md @@ -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 --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 --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).