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