food-market/deploy/anonymize-prod.sh
nns c4a59d7bf2
Some checks failed
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
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Public / Build + push Public (test + latest) (push) Has been cancelled
Docker Web / Build + push Web (push) Has been cancelled
Docker Public / Deploy :test → test.food-market.kz (push) Has been cancelled
Docker Web / Deploy Web on stage (push) Has been cancelled
docs(prod): репа догоняет новую раскладку postgres на prod-vm
05.10.2026 общий postgres-инстанс с чужими боевыми базами (sklad, purchase,
calcman, calcman_staging, makesales + мусорная старая food_market) вынесен
из compose прода food-market в отдельный compose-проект shared-postgres
(контейнер shared-postgres, порт 5434, том food-market-postgres-data external).
Прод food-market поднимается в собственный контейнер food-market-prod-postgres
(порт 5437, том food-market-prod-postgres-data). Простой при разделении — 12с,
соседи живы. См. docs/shared-postgres-followup-progress.md.

Этот коммит синхронизирует репозиторий, чтобы следующий деплой из main не
воскресил старую раскладку:

- deploy/docker-compose.yml: container_name/port/volume приведены к прод-файлу
  (food-market-prod-postgres, 127.0.0.1:5437, food-market-prod-postgres-data).
- deploy/shared-postgres/ (новое): документационная копия соседнего compose'а,
  с явной пометкой «food-market им не управляет».
- deploy/food-market-backup.sh / anonymize-prod.sh / db-schema-diff.sh:
  defaults FM_PG_CONTAINER / FM_PROD_CONT = food-market-prod-postgres.
- docs/RUNBOOK.md: новый раздел «Postgres на prod-vm» с таблицей трёх
  инстансов и явным запретом трогать shared-postgres из наших скриптов.
- docs/backup-restore.md: контейнер в таблице и примерах — новый.

Guards (не в репе — живут в ~/deploy-stage.sh и ~/quality-watchdog.sh)
расширены симметрично: стенду запрещены shared-postgres, food-market-prod-
postgres[-data], 5434/5437; проду — food-market-postgres-data (чужие данные).
Проверка: docker compose config/ps на prod-vm shared-postgres не упоминают,
grep по репе — ни один скрипт не делает docker exec в shared-postgres.
2026-10-06 00:35:54 +05:00

190 lines
8.5 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 22: создание anonymized stage-dump'a из прод-БД.
#
# Алгоритм:
# 1. pg_dump прода в кастомном формате (-Fc)
# 2. pg_restore во временную staging-БД (`food_market_anon`)
# 3. UPDATE PII-полей в staging:
# - email → user{N}@example.kz
# - phone → +7700111{N:04}
# - passwordHash → один общий тестовый hash для пароля "Test12345!"
# - IIN / БИН → синтетические но валидные
# - имена/адреса → "Test Tester {N}" / "Тестовый адрес {N}"
# 4. pg_dump anonymized → .sql.gz файл
# 5. Удалить staging-БД
#
# Результат используется на dev-vm для воспроизведения багов на реальном
# объёме данных без утечки persistent PII.
#
# Usage:
# deploy/anonymize-prod.sh [--source <conn-uri>] [--target <conn-uri>]
# [--out <file>] [--dry-run]
#
# Default source: ssh prod docker exec food-market-prod-postgres pg_dump
# Default target: local postgres@14 with TEMP DB food_market_anon
# Default out: /home/nns/food-market-anon-YYYYMMDD.sql.gz
#
# ВАЖНО: контейнер food-market-prod-postgres (с 2026-10-05). НЕ shared-postgres —
# тот принадлежит соседнему compose-проекту с чужими боевыми базами (sklad,
# purchase, calcman, calcman_staging, makesales). См. docs/shared-postgres-followup-progress.md.
set -uo pipefail
SOURCE_HOST="${FM_PROD_HOST:-nns@admin.food-market.kz}"
SOURCE_CONTAINER="${FM_PROD_CONT:-food-market-prod-postgres}"
SOURCE_DB="${FM_PG_DB:-food_market}"
SOURCE_USER="${FM_PG_USER:-food_market}"
LOCAL_USER="${PGUSER:-nns}"
LOCAL_HOST="${PGHOST:-localhost}"
LOCAL_PORT="${PGPORT:-5432}"
TARGET_DB="food_market_anon_$$"
OUT="${FM_ANON_OUT:-$HOME/food-market-anon-$(date +%Y%m%d-%H%M%S).sql.gz}"
DRY_RUN=0
while [[ $# -gt 0 ]]; do
case "$1" in
--source-host) SOURCE_HOST="$2"; shift 2 ;;
--source-container) SOURCE_CONTAINER="$2"; shift 2 ;;
--out) OUT="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
--help|-h) grep -E '^#( |$)' "$0" | sed 's/^# \?//'; exit 0 ;;
*) echo "Unknown: $1" >&2; exit 2 ;;
esac
done
log() { echo "[$(date -Iseconds)] $*"; }
run() {
if [[ $DRY_RUN -eq 1 ]]; then echo "[dry-run] $*";
else echo "[exec] $*"; "$@"; fi
}
cleanup() {
log "cleanup: drop $TARGET_DB"
if [[ $DRY_RUN -eq 0 ]]; then
psql -h "$LOCAL_HOST" -p "$LOCAL_PORT" -U "$LOCAL_USER" -d postgres \
-c "DROP DATABASE IF EXISTS $TARGET_DB;" 2>/dev/null || true
fi
}
trap cleanup EXIT
# ── 1. pg_dump из прода ──────────────────────────────────────────────
DUMP="/tmp/food-market-prod-$$.dump"
log "Step 1/5: pg_dump from $SOURCE_HOST → $DUMP (Fc format)"
if [[ $DRY_RUN -eq 1 ]]; then
log "[dry-run] ssh $SOURCE_HOST docker exec $SOURCE_CONTAINER pg_dump -Fc -U $SOURCE_USER -d $SOURCE_DB > $DUMP"
else
ssh -o ConnectTimeout=10 "$SOURCE_HOST" \
"docker exec $SOURCE_CONTAINER pg_dump -Fc --no-owner --no-privileges -U $SOURCE_USER -d $SOURCE_DB" \
> "$DUMP" || { log "FAIL pg_dump"; exit 1; }
log "dump size: $(du -h "$DUMP" | cut -f1)"
fi
# ── 2. Создать temp-БД и restore ─────────────────────────────────────
log "Step 2/5: create $TARGET_DB + pg_restore"
if [[ $DRY_RUN -eq 0 ]]; then
psql -h "$LOCAL_HOST" -p "$LOCAL_PORT" -U "$LOCAL_USER" -d postgres \
-c "CREATE DATABASE $TARGET_DB;" || { log "FAIL create db"; exit 1; }
pg_restore -h "$LOCAL_HOST" -p "$LOCAL_PORT" -U "$LOCAL_USER" -d "$TARGET_DB" \
--no-owner --no-privileges "$DUMP" 2>/dev/null || \
log "(некритично) pg_restore warnings — продолжаем"
fi
# ── 3. Anonymize PII ─────────────────────────────────────────────────
# Hash для пароля "Test12345!" — генерируется через идентичный
# алгоритм ASP.NET Identity (PBKDF2 SHA256, 10000 iter). Чтобы не
# вводить криптографию в скрипт — берём заранее известный hash.
# Сгенерировать новый можно через `dotnet run --project dev-tools/hash-pass.cs`.
TEST_PASS_HASH='AQAAAAIAAYagAAAAEHJsxbHF3MoBGSe+1bktB4O9aERPI4j5Jt6w0iN4dCqU/5jL+i5xT8E+UEqcVf0Vqg=='
log "Step 3/5: anonymize PII in $TARGET_DB"
if [[ $DRY_RUN -eq 0 ]]; then
psql -h "$LOCAL_HOST" -p "$LOCAL_PORT" -U "$LOCAL_USER" -d "$TARGET_DB" -v ON_ERROR_STOP=1 <<SQL
-- 1. AspNetUsers (Identity): email + phone + password hash + security stamp
WITH numbered AS (
SELECT "Id", row_number() OVER (ORDER BY "Id") AS rn FROM "AspNetUsers"
)
UPDATE "AspNetUsers" u
SET
"Email" = 'user' || n.rn || '@example.kz',
"NormalizedEmail" = upper('user' || n.rn || '@example.kz'),
"UserName" = 'user' || n.rn || '@example.kz',
"NormalizedUserName" = upper('user' || n.rn || '@example.kz'),
"PhoneNumber" = '+7700111' || lpad(n.rn::text, 4, '0'),
"PasswordHash" = '$TEST_PASS_HASH',
"SecurityStamp" = encode(gen_random_bytes(16), 'hex'),
"ConcurrencyStamp" = gen_random_uuid()::text
FROM numbered n
WHERE u."Id" = n."Id";
-- 2. employees — рабочий email/телефон + полное имя
WITH numbered AS (
SELECT "Id", row_number() OVER (ORDER BY "Id") AS rn FROM employees
)
UPDATE employees e
SET
"Email" = CASE WHEN e."Email" IS NOT NULL THEN 'emp' || n.rn || '@example.kz' END,
"Phone" = CASE WHEN e."Phone" IS NOT NULL THEN '+7700222' || lpad(n.rn::text, 4, '0') END,
"FirstName" = 'Тест',
"LastName" = 'Тестов' || n.rn,
"MiddleName" = NULL,
"TaxNumber" = NULL
FROM numbered n
WHERE e."Id" = n."Id";
-- 3. counterparties — БИН/ИИН/имена/контакты
WITH numbered AS (
SELECT "Id", row_number() OVER (ORDER BY "Id") AS rn FROM counterparties
)
UPDATE counterparties c
SET
"Name" = 'Контрагент-' || n.rn,
"LegalName" = CASE WHEN c."LegalName" IS NOT NULL THEN 'ТОО Контрагент-' || n.rn END,
-- Синтетические BIN/IIN (12 цифр, не валидируем checksum здесь).
"Bin" = CASE WHEN c."Bin" IS NOT NULL THEN lpad(n.rn::text, 12, '9') END,
"Iin" = CASE WHEN c."Iin" IS NOT NULL THEN lpad(n.rn::text, 12, '8') END,
"Phone" = CASE WHEN c."Phone" IS NOT NULL THEN '+7700333' || lpad(n.rn::text, 4, '0') END,
"Email" = CASE WHEN c."Email" IS NOT NULL THEN 'cp' || n.rn || '@example.kz' END,
"Address" = CASE WHEN c."Address" IS NOT NULL THEN 'г. Тестовый, ул. Тестовая ' || n.rn END,
"BankAccount" = CASE WHEN c."BankAccount" IS NOT NULL THEN 'KZ000000' || lpad(n.rn::text, 14, '0') END,
"ContactPerson" = CASE WHEN c."ContactPerson" IS NOT NULL THEN 'Контакт ' || n.rn END,
"Notes" = NULL
FROM numbered n
WHERE c."Id" = n."Id";
-- 4. organizations: имя/БИН/телефон владельца/MoySkladToken
UPDATE organizations
SET
"MoySkladToken" = NULL,
"OwnerTelegramChatId" = NULL,
"Bin" = CASE WHEN "Bin" IS NOT NULL THEN '700700700700' END,
"Name" = 'TestOrg-' || substr("Id"::text, 1, 8);
-- 5. refresh tokens revoke all (чтобы старые stage-токены не работали)
UPDATE "OpenIddictTokens" SET "Status" = 'revoked' WHERE "Status" = 'valid';
-- 6. чистим аудит-логи и feedback (могут содержать персональные тексты)
TRUNCATE TABLE org_audit_log;
TRUNCATE TABLE super_admin_audit_log;
SQL
log "anonymize done"
fi
# ── 4. pg_dump anonymized → out ──────────────────────────────────────
log "Step 4/5: pg_dump $TARGET_DB → $OUT"
if [[ $DRY_RUN -eq 0 ]]; then
pg_dump -h "$LOCAL_HOST" -p "$LOCAL_PORT" -U "$LOCAL_USER" -d "$TARGET_DB" \
--no-owner --no-privileges --clean --if-exists \
| gzip > "$OUT"
log "anonymized dump: $(du -h "$OUT" | cut -f1) → $OUT"
fi
# ── 5. Cleanup (через trap) ──────────────────────────────────────────
log "Step 5/5: cleanup"
rm -f "$DUMP" 2>/dev/null || true
log "✓ Готово: $OUT"
log "Восстановить можно: gunzip -c $OUT | psql -d food_market_dev"
exit 0