food-market/deploy/post-deploy-smoke.sh
nns 843fc4bd03
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
feat(s21): stage→prod migration toolchain (7 скриптов + workflow)
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>
2026-06-07 22:31:10 +05:00

240 lines
11 KiB
Bash
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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