fix: persist media decisions across production restores

This commit is contained in:
ik
2026-09-22 07:48:43 +07:00
parent 104d5803fb
commit f3fb5868df
9 changed files with 145 additions and 6 deletions
+18
View File
@@ -123,6 +123,7 @@ services:
S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY} S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY} S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
S3_BUCKET: ${S3_BUCKET:-catch-screenshots} 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_URL: ${OFFICIAL_RECORDS_URL:-https://rf4game.de/records/region/RU/}
OFFICIAL_RECORDS_REGION: ${OFFICIAL_RECORDS_REGION:-RU} OFFICIAL_RECORDS_REGION: ${OFFICIAL_RECORDS_REGION:-RU}
OFFICIAL_RECORDS_CATEGORY: ${OFFICIAL_RECORDS_CATEGORY:-records} OFFICIAL_RECORDS_CATEGORY: ${OFFICIAL_RECORDS_CATEGORY:-records}
@@ -144,6 +145,7 @@ services:
migrate: {condition: service_completed_successfully} migrate: {condition: service_completed_successfully}
minio: {condition: service_healthy} minio: {condition: service_healthy}
minio-init: {condition: service_completed_successfully} minio-init: {condition: service_completed_successfully}
media-init: {condition: service_completed_successfully}
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/ready')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/ready')"]
interval: 10s interval: 10s
@@ -152,6 +154,8 @@ services:
networks: [backend, edge] networks: [backend, edge]
security_opt: [no-new-privileges:true] security_opt: [no-new-privileges:true]
logging: *default-logging logging: *default-logging
volumes:
- media_data:/app/runtime-media
deploy: deploy:
resources: resources:
limits: {cpus: "1.00", memory: 1G} limits: {cpus: "1.00", memory: 1G}
@@ -173,6 +177,19 @@ services:
resources: resources:
limits: {cpus: "0.50", memory: 256M} 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: web:
build: build:
context: ./apps/web context: ./apps/web
@@ -249,5 +266,6 @@ networks:
volumes: volumes:
postgres_data: postgres_data:
minio_data: minio_data:
media_data:
caddy_data: caddy_data:
caddy_config: caddy_config:
+3 -3
View File
@@ -116,20 +116,20 @@ curl -fsS https://rf4spotter.ru/ready
## 6. Резервное копирование и восстановление ## 6. Резервное копирование и восстановление
Храните копии вне диска приложения. Скрипт создаёт PostgreSQL dump, архив MinIO и контрольные суммы в новом каталоге с UTC-временем: Храните копии вне диска приложения. Скрипт создаёт PostgreSQL dump, архив MinIO, архив persistent media store и контрольные суммы в новом каталоге с UTC-временем. Media manifest и файлы решений восстанавливаются вместе с provenance:
```bash ```bash
./deploy/backup.sh /srv/rf4-backups ./deploy/backup.sh /srv/rf4-backups
``` ```
Восстановление заменяет содержимое PostgreSQL и MinIO данными из выбранной копии, временно останавливая API и web. Это намеренно защищённая подтверждением операция: Восстановление заменяет содержимое PostgreSQL, MinIO и persistent media store данными из выбранной копии, временно останавливая API и web. Это намеренно защищённая подтверждением операция:
```bash ```bash
CONFIRM_RESTORE=rf4-spotter ./deploy/restore.sh /srv/rf4-backups/20260906T120000Z CONFIRM_RESTORE=rf4-spotter ./deploy/restore.sh /srv/rf4-backups/20260906T120000Z
curl -fsS https://rf4spotter.ru/ready curl -fsS https://rf4spotter.ru/ready
``` ```
Репозиторий содержит изолированный drill, который создаёт отдельные Compose volumes, портит тестовые данные, восстанавливает их и сравнивает PostgreSQL и MinIO: Репозиторий содержит изолированный drill, который создаёт отдельные Compose volumes, портит тестовые данные, восстанавливает их и сравнивает PostgreSQL, MinIO и media store:
```bash ```bash
./deploy/test-backup-restore.sh ./deploy/test-backup-restore.sh
+7 -1
View File
@@ -21,6 +21,9 @@ fi
mkdir -p "$target" mkdir -p "$target"
chmod 700 "$target" chmod 700 "$target"
export BACKUP_DIRECTORY="$backup_root" 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} env_file=${COMPOSE_ENV_FILE:-.env.production}
compose="docker compose --env-file $env_file -f compose.production.yaml" 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"' $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 tar -czf "$target/minio.tar.gz" -C "$target" minio
rm -rf "$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/postgres.dump" "$target/minio.tar.gz" "$target/SHA256SUMS"
chmod 600 "$target/media.tar.gz"
echo "Backup created: $target" echo "Backup created: $target"
+4
View File
@@ -14,12 +14,15 @@ esac
source_dir=${1%/} source_dir=${1%/}
test -f "$source_dir/postgres.dump" test -f "$source_dir/postgres.dump"
test -f "$source_dir/minio.tar.gz" test -f "$source_dir/minio.tar.gz"
test -f "$source_dir/media.tar.gz"
(cd "$source_dir" && sha256sum -c SHA256SUMS) (cd "$source_dir" && sha256sum -c SHA256SUMS)
backup_root=$(dirname "$source_dir") backup_root=$(dirname "$source_dir")
export BACKUP_DIRECTORY="$backup_root" export BACKUP_DIRECTORY="$backup_root"
restore_tmp=$(mktemp -d "$backup_root/.rf4-restore.XXXXXX") restore_tmp=$(mktemp -d "$backup_root/.rf4-restore.XXXXXX")
trap 'rm -rf "$restore_tmp"' EXIT INT TERM trap 'rm -rf "$restore_tmp"' EXIT INT TERM
tar -xzf "$source_dir/minio.tar.gz" -C "$restore_tmp" 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") restore_name=$(basename "$restore_tmp")
env_file=${COMPOSE_ENV_FILE:-.env.production} 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 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 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"' $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} $compose up -d ${RESTORE_START_SERVICES:-minio api web proxy}
echo "Restore completed from: $source_dir" echo "Restore completed from: $source_dir"
+8 -1
View File
@@ -8,6 +8,8 @@ project="rf4-restore-drill-$$"
export COMPOSE_PROJECT_NAME="$project" export COMPOSE_PROJECT_NAME="$project"
export COMPOSE_ENV_FILE=.env.production.example export COMPOSE_ENV_FILE=.env.production.example
export BACKUP_DIRECTORY="$drill_root" 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" compose="docker compose --env-file $COMPOSE_ENV_FILE -f compose.production.yaml"
cleanup() { cleanup() {
@@ -18,8 +20,11 @@ trap cleanup EXIT INT TERM
$compose up -d --wait db minio $compose up -d --wait db minio
$compose run --rm minio-init $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 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 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" ./deploy/backup.sh "$drill_root"
backup=$(find "$drill_root" -mindepth 1 -maxdepth 1 -type d ! -name '.rf4-restore.*' | head -n 1) 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" 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;"') 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"') 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 "$db_value" = "database-ok"
test "$object_value" = "object-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"
+4
View File
@@ -24,6 +24,10 @@ trap cleanup EXIT INT TERM
$compose up --build -d --wait db minio api web $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_API_PORT/ready" >/dev/null
curl -fsS "http://127.0.0.1:$BOOTSTRAP_WEB_PORT/" >/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 '^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' curl -fsS -D - -o /dev/null "http://127.0.0.1:$BOOTSTRAP_API_PORT/health" | grep -qi '^cross-origin-opener-policy: same-origin'
+1 -1
View File
@@ -23,7 +23,7 @@ R-пункты уточняют критерии существующих B/G/U/
их частичным выполнением. Коммит после каждого действительно закрытого пункта. их частичным выполнением. Коммит после каждого действительно закрытого пункта.
Доказательства и ограничения: [аудит](PROJECT_AUDIT_2026-09-22.md). Доказательства и ограничения: [аудит](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 не теряют изменения. - [ ] **R02 · P1 · Публикация выбранных media и защита от гонок (A04/B23).** Явные asset IDs, preview, версия manifest, lock и проверка файлов. Критерий: скрытые кандидаты не публикуются, устаревшее решение — конфликт, параллельные CLI/API не теряют изменения.
- [ ] **R03 · P1 · Идемпотентная отправка улова.** Сверять payload/срок ключа во всех ветках, включая IntegrityError и завершённый upload; обработать конкурентное создание Spot/Bait. Критерий: одинаковый повтор возвращает тот же результат, другой payload — 409, ожидаемые гонки не дают 500. - [ ] **R03 · P1 · Идемпотентная отправка улова.** Сверять payload/срок ключа во всех ветках, включая IntegrityError и завершённый upload; обработать конкурентное создание Spot/Bait. Критерий: одинаковый повтор возвращает тот же результат, другой payload — 409, ожидаемые гонки не дают 500.
- [ ] **R04 · P1 · Восстановление загрузки скриншота.** Атомарное одноразовое сохранение, повтор после потерянного ответа, очистка idempotency-cookie после восстановления. Критерий: нет лишних объектов, retry завершает форму и следующий новый улов отправляется. - [ ] **R04 · P1 · Восстановление загрузки скриншота.** Атомарное одноразовое сохранение, повтор после потерянного ответа, очистка idempotency-cookie после восстановления. Критерий: нет лишних объектов, retry завершает форму и следующий новый улов отправляется.
+59
View File
@@ -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}
+41
View File
@@ -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()