From f3fb5868dfbc0275d909a5f1c7c908912750e373 Mon Sep 17 00:00:00 2001 From: IK Date: Tue, 22 Sep 2026 07:48:43 +0700 Subject: [PATCH] fix: persist media decisions across production restores --- compose.production.yaml | 18 +++++++++ deploy/README.md | 6 +-- deploy/backup.sh | 8 +++- deploy/restore.sh | 4 ++ deploy/test-backup-restore.sh | 9 ++++- deploy/test-production-bootstrap.sh | 4 ++ docs/ROADMAP.md | 2 +- rf4_research/media_store.py | 59 +++++++++++++++++++++++++++++ tests/test_media_store.py | 41 ++++++++++++++++++++ 9 files changed, 145 insertions(+), 6 deletions(-) create mode 100644 rf4_research/media_store.py create mode 100644 tests/test_media_store.py diff --git a/compose.production.yaml b/compose.production.yaml index 6f0a22e..c4e9bfe 100644 --- a/compose.production.yaml +++ b/compose.production.yaml @@ -123,6 +123,7 @@ services: S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY} S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY} S3_BUCKET: ${S3_BUCKET:-catch-screenshots} + MEDIA_ROOT: /app/runtime-media OFFICIAL_RECORDS_URL: ${OFFICIAL_RECORDS_URL:-https://rf4game.de/records/region/RU/} OFFICIAL_RECORDS_REGION: ${OFFICIAL_RECORDS_REGION:-RU} OFFICIAL_RECORDS_CATEGORY: ${OFFICIAL_RECORDS_CATEGORY:-records} @@ -144,6 +145,7 @@ services: migrate: {condition: service_completed_successfully} minio: {condition: service_healthy} minio-init: {condition: service_completed_successfully} + media-init: {condition: service_completed_successfully} healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/ready')"] interval: 10s @@ -152,6 +154,8 @@ services: networks: [backend, edge] security_opt: [no-new-privileges:true] logging: *default-logging + volumes: + - media_data:/app/runtime-media deploy: resources: limits: {cpus: "1.00", memory: 1G} @@ -173,6 +177,19 @@ services: resources: limits: {cpus: "0.50", memory: 256M} + media-init: + build: + context: . + dockerfile: apps/api/Dockerfile + restart: "no" + user: "0:0" + command: ["sh", "-c", "python -c \"from pathlib import Path; from rf4_research.media_store import bootstrap_media_store; print(bootstrap_media_store(Path('/app/data/media'), Path('/app/runtime-media')))\" && chown -R 10001:10001 /app/runtime-media"] + volumes: + - media_data:/app/runtime-media + networks: [backend] + security_opt: [no-new-privileges:true] + logging: *default-logging + web: build: context: ./apps/web @@ -249,5 +266,6 @@ networks: volumes: postgres_data: minio_data: + media_data: caddy_data: caddy_config: diff --git a/deploy/README.md b/deploy/README.md index 3261718..7b6992c 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -116,20 +116,20 @@ curl -fsS https://rf4spotter.ru/ready ## 6. Резервное копирование и восстановление -Храните копии вне диска приложения. Скрипт создаёт PostgreSQL dump, архив MinIO и контрольные суммы в новом каталоге с UTC-временем: +Храните копии вне диска приложения. Скрипт создаёт PostgreSQL dump, архив MinIO, архив persistent media store и контрольные суммы в новом каталоге с UTC-временем. Media manifest и файлы решений восстанавливаются вместе с provenance: ```bash ./deploy/backup.sh /srv/rf4-backups ``` -Восстановление заменяет содержимое PostgreSQL и MinIO данными из выбранной копии, временно останавливая API и web. Это намеренно защищённая подтверждением операция: +Восстановление заменяет содержимое PostgreSQL, MinIO и persistent media store данными из выбранной копии, временно останавливая API и web. Это намеренно защищённая подтверждением операция: ```bash CONFIRM_RESTORE=rf4-spotter ./deploy/restore.sh /srv/rf4-backups/20260906T120000Z curl -fsS https://rf4spotter.ru/ready ``` -Репозиторий содержит изолированный drill, который создаёт отдельные Compose volumes, портит тестовые данные, восстанавливает их и сравнивает PostgreSQL и MinIO: +Репозиторий содержит изолированный drill, который создаёт отдельные Compose volumes, портит тестовые данные, восстанавливает их и сравнивает PostgreSQL, MinIO и media store: ```bash ./deploy/test-backup-restore.sh diff --git a/deploy/backup.sh b/deploy/backup.sh index 27cb2ff..e0b0465 100755 --- a/deploy/backup.sh +++ b/deploy/backup.sh @@ -21,6 +21,9 @@ fi mkdir -p "$target" chmod 700 "$target" export BACKUP_DIRECTORY="$backup_root" +media_tmp=$(mktemp -d "$backup_root/.rf4-media-backup.XXXXXX") +cleanup() { rm -rf "$media_tmp"; } +trap cleanup EXIT INT TERM env_file=${COMPOSE_ENV_FILE:-.env.production} compose="docker compose --env-file $env_file -f compose.production.yaml" @@ -28,6 +31,9 @@ $compose exec -T db sh -c 'pg_dump --format=custom --no-owner --no-acl -U "$POST $compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null && mc stat "local/$S3_BUCKET" >/dev/null && mc mirror --overwrite "local/$S3_BUCKET" "/backup/'"$stamp"'/minio" && chmod -R a+rwX "/backup/'"$stamp"'/minio"' tar -czf "$target/minio.tar.gz" -C "$target" minio rm -rf "$target/minio" -(cd "$target" && sha256sum postgres.dump minio.tar.gz > SHA256SUMS) +$compose cp api:/app/runtime-media/. "$media_tmp/" +tar -czf "$target/media.tar.gz" -C "$media_tmp" . +(cd "$target" && sha256sum postgres.dump minio.tar.gz media.tar.gz > SHA256SUMS) chmod 600 "$target/postgres.dump" "$target/minio.tar.gz" "$target/SHA256SUMS" +chmod 600 "$target/media.tar.gz" echo "Backup created: $target" diff --git a/deploy/restore.sh b/deploy/restore.sh index 0a661b3..b585998 100755 --- a/deploy/restore.sh +++ b/deploy/restore.sh @@ -14,12 +14,15 @@ esac source_dir=${1%/} test -f "$source_dir/postgres.dump" test -f "$source_dir/minio.tar.gz" +test -f "$source_dir/media.tar.gz" (cd "$source_dir" && sha256sum -c SHA256SUMS) backup_root=$(dirname "$source_dir") export BACKUP_DIRECTORY="$backup_root" restore_tmp=$(mktemp -d "$backup_root/.rf4-restore.XXXXXX") trap 'rm -rf "$restore_tmp"' EXIT INT TERM tar -xzf "$source_dir/minio.tar.gz" -C "$restore_tmp" +mkdir -p "$restore_tmp/media" +tar -xzf "$source_dir/media.tar.gz" -C "$restore_tmp/media" restore_name=$(basename "$restore_tmp") env_file=${COMPOSE_ENV_FILE:-.env.production} @@ -27,5 +30,6 @@ compose="docker compose --env-file $env_file -f compose.production.yaml" $compose stop api web $compose exec -T db sh -c 'pg_restore --clean --if-exists --exit-on-error --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < "$source_dir/postgres.dump" $compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null && mc stat "local/$S3_BUCKET" >/dev/null && mc rm --recursive --force "local/$S3_BUCKET" >/dev/null 2>&1 && mc mirror --overwrite "/backup/'"$restore_name"'/minio" "local/$S3_BUCKET"' +tar -cf - -C "$restore_tmp/media" . | $compose run --rm --no-deps -T api sh -c 'find /app/runtime-media -mindepth 1 -delete && tar -xf - -C /app/runtime-media' $compose up -d ${RESTORE_START_SERVICES:-minio api web proxy} echo "Restore completed from: $source_dir" diff --git a/deploy/test-backup-restore.sh b/deploy/test-backup-restore.sh index 4209598..62fc242 100755 --- a/deploy/test-backup-restore.sh +++ b/deploy/test-backup-restore.sh @@ -8,6 +8,8 @@ project="rf4-restore-drill-$$" export COMPOSE_PROJECT_NAME="$project" export COMPOSE_ENV_FILE=.env.production.example export BACKUP_DIRECTORY="$drill_root" +export POSTGRES_PASSWORD=restore-drill-postgres-password +export DATABASE_URL=postgresql+psycopg://rf4:restore-drill-postgres-password@db:5432/rf4_spotter compose="docker compose --env-file $COMPOSE_ENV_FILE -f compose.production.yaml" cleanup() { @@ -18,8 +20,11 @@ trap cleanup EXIT INT TERM $compose up -d --wait db minio $compose run --rm minio-init +$compose run --rm media-init +$compose up -d --wait api $compose exec -T db sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "create table restore_drill (value text primary key); insert into restore_drill values ('"'"'database-ok'"'"');"' >/dev/null $compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null; mc stat "local/$S3_BUCKET" >/dev/null; printf object-ok | mc pipe "local/$S3_BUCKET/restore-drill.txt"' >/dev/null +$compose exec -T api sh -c 'printf media-ok > /app/runtime-media/restore-drill.txt' ./deploy/backup.sh "$drill_root" backup=$(find "$drill_root" -mindepth 1 -maxdepth 1 -type d ! -name '.rf4-restore.*' | head -n 1) @@ -29,6 +34,8 @@ $compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http: CONFIRM_RESTORE=rf4-spotter RESTORE_START_SERVICES=minio ./deploy/restore.sh "$backup" db_value=$($compose exec -T db sh -c 'psql -At -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "select value from restore_drill;"') object_value=$($compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null; mc cat "local/$S3_BUCKET/restore-drill.txt"') +media_value=$($compose run --rm --no-deps api sh -c 'cat /app/runtime-media/restore-drill.txt') test "$db_value" = "database-ok" test "$object_value" = "object-ok" -echo "Backup/restore drill passed for PostgreSQL and MinIO" +test "$media_value" = "media-ok" +echo "Backup/restore drill passed for PostgreSQL, MinIO, and media decisions" diff --git a/deploy/test-production-bootstrap.sh b/deploy/test-production-bootstrap.sh index fcda591..d19d063 100755 --- a/deploy/test-production-bootstrap.sh +++ b/deploy/test-production-bootstrap.sh @@ -24,6 +24,10 @@ trap cleanup EXIT INT TERM $compose up --build -d --wait db minio api web curl -fsS "http://127.0.0.1:$BOOTSTRAP_API_PORT/ready" >/dev/null curl -fsS "http://127.0.0.1:$BOOTSTRAP_WEB_PORT/" >/dev/null +manifest_digest=$($compose exec -T api sha256sum /app/runtime-media/manifest.json | awk '{print $1}') +test -n "$manifest_digest" +$compose up -d --force-recreate --wait api +test "$manifest_digest" = "$($compose exec -T api sha256sum /app/runtime-media/manifest.json | awk '{print $1}')" curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep -qi '^x-frame-options: DENY' curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep -qi '^cross-origin-opener-policy: same-origin' diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 22c999a..24e0a96 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -23,7 +23,7 @@ R-пункты уточняют критерии существующих B/G/U/ их частичным выполнением. Коммит после каждого действительно закрытого пункта. Доказательства и ограничения: [аудит](PROJECT_AUDIT_2026-09-22.md). -- [ ] **R01 · P1 · Долговременное хранение media-решений (A04/B23/A07).** Persistent store для manifest/assets, bootstrap из Git, release merge и backup/restore. Критерий: publish/rollback переживают recreate и восстановление вместе с provenance. +- [x] **R01 · P1 · Долговременное хранение media-решений (A04/B23/A07).** Production Compose получил named `media_data` volume и отдельный root-only `media-init`: baseline из Git атомарно merge-ится только для новых assets, существующие решения/provenance не заменяются, volume передаётся API-пользователю. `backup.sh` сохраняет persistent manifest/assets в `media.tar.gz`, `restore.sh` восстанавливает их вместе с PostgreSQL и MinIO. Изолированный bootstrap подтвердил сохранение manifest после `--force-recreate` API; backup/restore drill подтвердил checksum и восстановление контрольного media-решения. Production secrets, внешнее backup-хранилище и серверный A07 acceptance-run остаются открытыми. - [ ] **R02 · P1 · Публикация выбранных media и защита от гонок (A04/B23).** Явные asset IDs, preview, версия manifest, lock и проверка файлов. Критерий: скрытые кандидаты не публикуются, устаревшее решение — конфликт, параллельные CLI/API не теряют изменения. - [ ] **R03 · P1 · Идемпотентная отправка улова.** Сверять payload/срок ключа во всех ветках, включая IntegrityError и завершённый upload; обработать конкурентное создание Spot/Bait. Критерий: одинаковый повтор возвращает тот же результат, другой payload — 409, ожидаемые гонки не дают 500. - [ ] **R04 · P1 · Восстановление загрузки скриншота.** Атомарное одноразовое сохранение, повтор после потерянного ответа, очистка idempotency-cookie после восстановления. Критерий: нет лишних объектов, retry завершает форму и следующий новый улов отправляется. diff --git a/rf4_research/media_store.py b/rf4_research/media_store.py new file mode 100644 index 0000000..bdf7f5e --- /dev/null +++ b/rf4_research/media_store.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import json +import shutil +from datetime import datetime, timezone +from pathlib import Path + + +def _safe_path(root: Path, relative: str) -> Path | None: + target = (root / relative).resolve() + if not target.is_relative_to(root.resolve()): + return None + return target + + +def bootstrap_media_store(source_root: Path, target_root: Path) -> dict[str, int]: + """Merge the immutable image baseline into the persistent media volume. + + Existing decisions and files in the volume always win. New baseline assets + are copied in on release, so a recreated container cannot reset decisions + while a newer Git baseline can still add assets. + """ + source_manifest_path = source_root / "manifest.json" + target_manifest_path = target_root / "manifest.json" + source = json.loads(source_manifest_path.read_text(encoding="utf-8")) + target = json.loads(target_manifest_path.read_text(encoding="utf-8")) if target_manifest_path.exists() else {"version": 1, "assets": []} + target_by_url = {item.get("asset_url"): item for item in target.get("assets", [])} + added = 0 + copied = 0 + target_root.mkdir(parents=True, exist_ok=True) + + for source_item in source.get("assets", []): + url = source_item.get("asset_url") + if not url or url in target_by_url: + continue + item = dict(source_item) + target_by_url[url] = item + target.setdefault("assets", []).append(item) + added += 1 + + for item in target.get("assets", []): + for relative in [item.get("local_path"), *[variant.get("local_path") for variant in item.get("derivatives", [])]]: + if not isinstance(relative, str): + continue + source_path = _safe_path(source_root, relative) + target_path = _safe_path(target_root, relative) + if not source_path or not target_path or not source_path.is_file() or target_path.exists(): + continue + target_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source_path, target_path) + copied += 1 + + target["version"] = max(int(source.get("version", 1)), int(target.get("version", 1))) + if added or copied or not target_manifest_path.exists(): + target["updated_at"] = datetime.now(timezone.utc).isoformat() + temporary = target_manifest_path.with_suffix(".json.tmp") + temporary.write_text(json.dumps(target, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + temporary.replace(target_manifest_path) + return {"added_assets": added, "copied_files": copied} diff --git a/tests/test_media_store.py b/tests/test_media_store.py new file mode 100644 index 0000000..a77f50a --- /dev/null +++ b/tests/test_media_store.py @@ -0,0 +1,41 @@ +import json +from pathlib import Path + +from rf4_research.media_store import bootstrap_media_store + + +def test_bootstrap_merges_new_baseline_without_replacing_decisions(tmp_path: Path) -> None: + source = tmp_path / "source" + target = tmp_path / "target" + (source / "files").mkdir(parents=True) + (source / "files/new.bin").write_bytes(b"new") + (source / "manifest.json").write_text(json.dumps({"version": 2, "assets": [{ + "asset_url": "https://new.example/asset", "status": "approved", + "local_path": "files/new.bin", + }]}), encoding="utf-8") + target.mkdir() + (target / "manifest.json").write_text(json.dumps({"version": 1, "assets": [{ + "asset_url": "https://new.example/asset", "status": "superseded", + "local_path": "files/new.bin", "review_note": "owner decision", + }]}), encoding="utf-8") + + result = bootstrap_media_store(source, target) + + assert result == {"added_assets": 0, "copied_files": 1} + saved = json.loads((target / "manifest.json").read_text(encoding="utf-8")) + assert saved["assets"][0]["status"] == "superseded" + assert saved["assets"][0]["review_note"] == "owner decision" + assert (target / "files/new.bin").read_bytes() == b"new" + + +def test_bootstrap_adds_assets_from_new_release(tmp_path: Path) -> None: + source = tmp_path / "source" + target = tmp_path / "target" + (source / "files").mkdir(parents=True) + (source / "files/new.bin").write_bytes(b"new") + (source / "manifest.json").write_text(json.dumps({"version": 1, "assets": [{ + "asset_url": "https://new.example/asset", "local_path": "files/new.bin", + }]}), encoding="utf-8") + + assert bootstrap_media_store(source, target) == {"added_assets": 1, "copied_files": 1} + assert (target / "files/new.bin").exists()