Compare commits
4
Commits
215af73388
...
049f1ef30a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
049f1ef30a | ||
|
|
868278fdba | ||
|
|
926182a6cf | ||
|
|
883ad2c73b |
@@ -38,7 +38,13 @@ RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изо
|
|||||||
|
|
||||||
Все пять community-парсеров подключены к отдельному scheduler-процессу. Попытка резервируется в PostgreSQL до HTTP-запроса, поэтому ошибки тоже расходуют cooldown. Блокировка и минимальный интервал 1800 секунд действуют на весь домен; endpoint одного сайта выбираются по самому давнему запуску и не голодают. Ручной production-запуск использует тот же журнал: `docker compose exec api python -m app.cli fetch-community rf4stat-fishing`. Локально scheduler включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения.
|
Все пять community-парсеров подключены к отдельному scheduler-процессу. Попытка резервируется в PostgreSQL до HTTP-запроса, поэтому ошибки тоже расходуют cooldown. Блокировка и минимальный интервал 1800 секунд действуют на весь домен; endpoint одного сайта выбираются по самому давнему запуску и не голодают. Ручной production-запуск использует тот же журнал: `docker compose exec api python -m app.cli fetch-community rf4stat-fishing`. Локально scheduler включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения.
|
||||||
|
|
||||||
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически.
|
Медиасборщик индексирует разрешённые изображения отдельно от публичного каталога: manifest хранит исходную страницу, URL, предполагаемый тип сущности и время обнаружения, а оригиналы сохраняются по SHA-256 без hotlink. Индексация страницы и загрузка каждого файла используют общий 30-минутный cooldown домена; непроверенный asset не публикуется автоматически. Локальный `media_cli --audit` без сетевых запросов проверяет хэши, файлы, MIME, размеры, approved-сопоставления и отсутствие бесхозных оригиналов.
|
||||||
|
|
||||||
|
Для измерений на собственном сервере подготовлен read-only `deploy/load-smoke.py`: он считает p50/p95/max и HTTP-коды для activity/records, а при наличии `ADMIN_TOKEN` — staging/moderation. Методика и безопасные ступени нагрузки описаны в [docs/load-testing.md](docs/load-testing.md); локальные цифры не выдаются за production baseline.
|
||||||
|
|
||||||
|
В production MinIO root credentials доступны только одноразовому init-контейнеру. API использует отдельного пользователя с доступом исключительно к `S3_BUCKET`: просмотр bucket, чтение, запись и удаление его объектов без глобального списка bucket и без права создавать новые.
|
||||||
|
|
||||||
|
Production release отделяет Alembic от runtime: одноразовый `migrate` должен успешно завершиться до запуска новой версии API. Перед изменением схемы создаётся backup; совместимый rollback возвращает предыдущие images, несовместимый — восстанавливает предрелизную копию данных вместо непроверенного `alembic downgrade`.
|
||||||
|
|
||||||
После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации.
|
После повторных ошибок scheduler увеличивает паузу экспоненциально до 24 часов и возвращается к 30 минутам после успеха. Публичная страница `/status` показывает свежесть и состояние источников без URL запросов, внутренних ошибок и другой диагностической информации.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -8,4 +8,4 @@ COPY --chown=rf4:rf4 apps/api .
|
|||||||
COPY --chown=rf4:rf4 rf4_research ./rf4_research
|
COPY --chown=rf4:rf4 rf4_research ./rf4_research
|
||||||
USER rf4
|
USER rf4
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["sh", "-c", "alembic upgrade head && python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log"]
|
CMD ["sh", "-c", "python -m app.seed && uvicorn app.main:app --host 0.0.0.0 --port 8000 --no-access-log"]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import Any
|
|||||||
from sqlalchemy import func, select, text
|
from sqlalchemy import func, select, text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
from .models import CommunityImportRun, DataSource, ImportStatus, OfficialRecordImport
|
from .models import CommunityImportRun, DataSource, ImportStatus, OfficialRecordImport
|
||||||
|
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ def readiness_report(
|
|||||||
ready = False
|
ready = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
s3.list_buckets()
|
s3.head_bucket(Bucket=settings.s3_bucket)
|
||||||
components["minio"] = {"status": "ready"}
|
components["minio"] = {"status": "ready"}
|
||||||
except Exception:
|
except Exception:
|
||||||
components["minio"] = {"status": "unavailable"}
|
components["minio"] = {"status": "unavailable"}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ from functools import lru_cache
|
|||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
from botocore.client import BaseClient
|
from botocore.client import BaseClient
|
||||||
from botocore.exceptions import ClientError
|
|
||||||
from PIL import Image, UnidentifiedImageError
|
from PIL import Image, UnidentifiedImageError
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
@@ -63,10 +62,7 @@ def upload_screenshot(raw: bytes, *, filename: str | None = None, content_type:
|
|||||||
body, extension, mime = prepare_image(raw)
|
body, extension, mime = prepare_image(raw)
|
||||||
key = f"reports/{uuid.uuid4()}.{extension}"
|
key = f"reports/{uuid.uuid4()}.{extension}"
|
||||||
s3 = client()
|
s3 = client()
|
||||||
try:
|
|
||||||
s3.head_bucket(Bucket=settings.s3_bucket)
|
s3.head_bucket(Bucket=settings.s3_bucket)
|
||||||
except ClientError:
|
|
||||||
s3.create_bucket(Bucket=settings.s3_bucket)
|
|
||||||
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=body, ContentType=mime)
|
s3.put_object(Bucket=settings.s3_bucket, Key=key, Body=body, ContentType=mime)
|
||||||
return key
|
return key
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ from app.readiness import readiness_report
|
|||||||
|
|
||||||
|
|
||||||
class AvailableStorage:
|
class AvailableStorage:
|
||||||
def list_buckets(self) -> dict[str, list[object]]:
|
def head_bucket(self, *, Bucket: str) -> dict[str, object]:
|
||||||
return {"Buckets": []}
|
assert Bucket
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
class UnavailableStorage:
|
class UnavailableStorage:
|
||||||
def list_buckets(self) -> None:
|
def head_bucket(self, *, Bucket: str) -> None:
|
||||||
raise ConnectionError("fixture unavailable")
|
raise ConnectionError("fixture unavailable")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+27
-1
@@ -85,13 +85,21 @@ services:
|
|||||||
test "$$MINIO_ROOT_USER" != "$$S3_ACCESS_KEY" &&
|
test "$$MINIO_ROOT_USER" != "$$S3_ACCESS_KEY" &&
|
||||||
test "$$MINIO_ROOT_PASSWORD" != "$$S3_SECRET_KEY" &&
|
test "$$MINIO_ROOT_PASSWORD" != "$$S3_SECRET_KEY" &&
|
||||||
mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" >/dev/null &&
|
mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" >/dev/null &&
|
||||||
|
mc mb --ignore-existing "local/$$S3_BUCKET" >/dev/null &&
|
||||||
|
printf '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetBucketLocation","s3:ListBucket"],"Resource":["arn:aws:s3:::%s"]},{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject","s3:DeleteObject"],"Resource":["arn:aws:s3:::%s/*"]}]}' "$$S3_BUCKET" "$$S3_BUCKET" > /tmp/rf4-app-policy.json &&
|
||||||
|
mc admin policy create local rf4-app-bucket /tmp/rf4-app-policy.json >/dev/null &&
|
||||||
(mc admin user info local "$$S3_ACCESS_KEY" >/dev/null 2>&1 || mc admin user add local "$$S3_ACCESS_KEY" "$$S3_SECRET_KEY") &&
|
(mc admin user info local "$$S3_ACCESS_KEY" >/dev/null 2>&1 || mc admin user add local "$$S3_ACCESS_KEY" "$$S3_SECRET_KEY") &&
|
||||||
mc admin policy attach local readwrite --user "$$S3_ACCESS_KEY" >/dev/null
|
(mc admin policy detach local readwrite --user "$$S3_ACCESS_KEY" >/dev/null 2>&1 || true) &&
|
||||||
|
mc admin policy attach local rf4-app-bucket --user "$$S3_ACCESS_KEY" >/dev/null &&
|
||||||
|
mc alias set app http://minio:9000 "$$S3_ACCESS_KEY" "$$S3_SECRET_KEY" >/dev/null &&
|
||||||
|
mc stat "app/$$S3_BUCKET" >/dev/null &&
|
||||||
|
! mc ls app >/dev/null 2>&1
|
||||||
environment:
|
environment:
|
||||||
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER}
|
MINIO_ROOT_USER: ${MINIO_ROOT_USER:?Set MINIO_ROOT_USER}
|
||||||
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD}
|
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:?Set MINIO_ROOT_PASSWORD}
|
||||||
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}
|
||||||
depends_on:
|
depends_on:
|
||||||
minio: {condition: service_healthy}
|
minio: {condition: service_healthy}
|
||||||
networks: [backend]
|
networks: [backend]
|
||||||
@@ -133,6 +141,7 @@ services:
|
|||||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
depends_on:
|
depends_on:
|
||||||
db: {condition: service_healthy}
|
db: {condition: service_healthy}
|
||||||
|
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}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -147,6 +156,23 @@ services:
|
|||||||
resources:
|
resources:
|
||||||
limits: {cpus: "1.00", memory: 1G}
|
limits: {cpus: "1.00", memory: 1G}
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/api/Dockerfile
|
||||||
|
restart: "no"
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL}
|
||||||
|
depends_on:
|
||||||
|
db: {condition: service_healthy}
|
||||||
|
command: ["alembic", "upgrade", "head"]
|
||||||
|
networks: [backend]
|
||||||
|
security_opt: [no-new-privileges:true]
|
||||||
|
logging: *default-logging
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits: {cpus: "0.50", memory: 256M}
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: ./apps/web
|
context: ./apps/web
|
||||||
|
|||||||
+15
-1
@@ -51,6 +51,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
ports:
|
ports:
|
||||||
@@ -61,6 +63,18 @@ services:
|
|||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 12
|
retries: 12
|
||||||
|
|
||||||
|
migrate:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/api/Dockerfile
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql+psycopg://rf4:rf4_local@db:5432/rf4_spotter
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
command: ["alembic", "upgrade", "head"]
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build: ./apps/web
|
build: ./apps/web
|
||||||
environment:
|
environment:
|
||||||
@@ -87,7 +101,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
command: ["sh", "-c", "alembic upgrade head && python -m app.cli import-records"]
|
command: ["python", "-m", "app.cli", "import-records"]
|
||||||
|
|
||||||
scheduler:
|
scheduler:
|
||||||
build:
|
build:
|
||||||
|
|||||||
+8
-5
@@ -21,7 +21,7 @@ openssl rand -base64 36 # отдельные значения для БД, ADMI
|
|||||||
docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'ОТДЕЛЬНЫЙ ADMIN-ПАРОЛЬ'
|
docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'ОТДЕЛЬНЫЙ ADMIN-ПАРОЛЬ'
|
||||||
```
|
```
|
||||||
|
|
||||||
Заполните `.env.production`. Хеш Caddy содержит символы `$`, поэтому значение `ADMIN_BASIC_PASSWORD_HASH` в env-файле заключите в одинарные кавычки. `MINIO_ROOT_*` и `S3_*` обязаны быть разными: init-контейнер проверяет это, API получает только application credentials. Если пароль PostgreSQL содержит специальные символы, в `DATABASE_URL` нужна URL-кодированная форма того же пароля. `.env.production` нельзя коммитить или пересылать вместе с логами.
|
Заполните `.env.production`. Хеш Caddy содержит символы `$`, поэтому значение `ADMIN_BASIC_PASSWORD_HASH` в env-файле заключите в одинарные кавычки. `MINIO_ROOT_*` и `S3_*` обязаны быть разными: init-контейнер root-доступом создаёт `S3_BUCKET`, назначает приложению только list/location этого bucket и get/put/delete его объектов, затем проверяет отсутствие глобального list. API получает только application credentials и не может создавать bucket. Если пароль PostgreSQL содержит специальные символы, в `DATABASE_URL` нужна URL-кодированная форма того же пароля. `.env.production` нельзя коммитить или пересылать вместе с логами.
|
||||||
|
|
||||||
## 3. Проверка и первый запуск
|
## 3. Проверка и первый запуск
|
||||||
|
|
||||||
@@ -38,7 +38,8 @@ docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'ОТДЕЛ
|
|||||||
```bash
|
```bash
|
||||||
docker compose --env-file .env.production -f compose.production.yaml config --quiet
|
docker compose --env-file .env.production -f compose.production.yaml config --quiet
|
||||||
docker compose --env-file .env.production -f compose.production.yaml build
|
docker compose --env-file .env.production -f compose.production.yaml build
|
||||||
docker compose --env-file .env.production -f compose.production.yaml up -d
|
docker compose --env-file .env.production -f compose.production.yaml run --rm migrate
|
||||||
|
docker compose --env-file .env.production -f compose.production.yaml up -d --no-deps api web community-scheduler
|
||||||
docker compose --env-file .env.production -f compose.production.yaml ps
|
docker compose --env-file .env.production -f compose.production.yaml ps
|
||||||
curl -fsS https://rf4spotter.ru/health
|
curl -fsS https://rf4spotter.ru/health
|
||||||
curl -fsS https://rf4spotter.ru/ready
|
curl -fsS https://rf4spotter.ru/ready
|
||||||
@@ -64,7 +65,7 @@ curl -fsS -H "Authorization: Bearer $ADMIN_TOKEN" -o rf4spotter-diagnostics.json
|
|||||||
|
|
||||||
По умолчанию временно используются только loopback-порты `14321` и `18000`; PostgreSQL и MinIO наружу не публикуются. Контур и volumes удаляются после проверки. Drill успешно пройден 6 сентября 2026 года.
|
По умолчанию временно используются только loopback-порты `14321` и `18000`; PostgreSQL и MinIO наружу не публикуются. Контур и volumes удаляются после проверки. Drill успешно пройден 6 сентября 2026 года.
|
||||||
|
|
||||||
API-контейнер перед стартом применяет Alembic-миграции и запускает идемпотентный seed. В production он добавляет только минимальные справочники и точки; демонстрационные уловы жёстко отключены настройкой `SEED_DEMO_DATA=false`.
|
Одноразовый `migrate` применяет Alembic до rollout API; при ошибке новый runtime не запускается. API при старте выполняет только идемпотентный seed: в production он добавляет минимальные справочники и точки, а демонстрационные уловы жёстко отключены `SEED_DEMO_DATA=false`.
|
||||||
|
|
||||||
Проверка TLS и маршрутизации:
|
Проверка TLS и маршрутизации:
|
||||||
|
|
||||||
@@ -92,12 +93,14 @@ docker compose --env-file .env.production -f compose.production.yaml exec api py
|
|||||||
```bash
|
```bash
|
||||||
git pull --ff-only
|
git pull --ff-only
|
||||||
docker compose --env-file .env.production -f compose.production.yaml build
|
docker compose --env-file .env.production -f compose.production.yaml build
|
||||||
docker compose --env-file .env.production -f compose.production.yaml up -d
|
./deploy/backup.sh /srv/rf4-backups
|
||||||
|
docker compose --env-file .env.production -f compose.production.yaml run --rm migrate
|
||||||
|
docker compose --env-file .env.production -f compose.production.yaml up -d --no-deps api web community-scheduler
|
||||||
docker compose --env-file .env.production -f compose.production.yaml ps
|
docker compose --env-file .env.production -f compose.production.yaml ps
|
||||||
curl -fsS https://rf4spotter.ru/ready
|
curl -fsS https://rf4spotter.ru/ready
|
||||||
```
|
```
|
||||||
|
|
||||||
Перед обновлением со сменой схемы обязателен backup PostgreSQL. Не удаляйте volumes и не используйте `down -v`.
|
Перед обновлением со сменой схемы обязателен backup PostgreSQL. Если миграция не началась или схема обратно совместима, верните предыдущий Git commit/images и повторите rollout без downgrade Alembic. Если миграция изменила данные несовместимо, остановите API/web и восстановите сделанный перед release backup через `restore.sh`, затем запустите предыдущую ревизию. Не удаляйте volumes, не используйте `down -v` и не выполняйте `alembic downgrade` без отдельно проверенного плана конкретной миграции.
|
||||||
|
|
||||||
## 6. Резервное копирование и восстановление
|
## 6. Резервное копирование и восстановление
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -25,7 +25,7 @@ export BACKUP_DIRECTORY="$backup_root"
|
|||||||
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"
|
||||||
$compose exec -T db sh -c 'pg_dump --format=custom --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > "$target/postgres.dump"
|
$compose exec -T db sh -c 'pg_dump --format=custom --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > "$target/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 mb --ignore-existing "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)
|
(cd "$target" && sha256sum postgres.dump minio.tar.gz > SHA256SUMS)
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Small read-only latency probe for alpha sizing; not a capacity benchmark."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import concurrent.futures
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
import statistics
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
|
PUBLIC = {
|
||||||
|
"activity": "/api/v1/activity?hours=72&limit=20",
|
||||||
|
"records": "/api/v1/records?limit=50",
|
||||||
|
}
|
||||||
|
ADMIN = {
|
||||||
|
"staging": "/api/v1/admin/external-observations?status=review&limit=50",
|
||||||
|
"moderation": "/api/v1/admin/catch-reports?status=pending&limit=50",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def percentile(values: list[float], percent: float) -> float:
|
||||||
|
return sorted(values)[max(0, math.ceil(len(values) * percent) - 1)]
|
||||||
|
|
||||||
|
|
||||||
|
def request_once(url: str, token: str | None, timeout: float) -> tuple[float, int]:
|
||||||
|
headers = {"Accept": "application/json", "User-Agent": "RF4-Spotter-load-smoke/0.1"}
|
||||||
|
if token:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=timeout) as response:
|
||||||
|
response.read()
|
||||||
|
status = response.status
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
exc.read()
|
||||||
|
status = exc.code
|
||||||
|
return (time.perf_counter() - started) * 1000, status
|
||||||
|
|
||||||
|
|
||||||
|
def probe(name: str, url: str, *, token: str | None, requests: int, concurrency: int, timeout: float) -> dict:
|
||||||
|
request_once(url, token, timeout) # one explicit warm-up excluded from statistics
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool:
|
||||||
|
samples = list(pool.map(lambda _: request_once(url, token, timeout), range(requests)))
|
||||||
|
latencies = [sample[0] for sample in samples]
|
||||||
|
statuses: dict[str, int] = {}
|
||||||
|
for _, status in samples:
|
||||||
|
statuses[str(status)] = statuses.get(str(status), 0) + 1
|
||||||
|
return {
|
||||||
|
"scenario": name, "requests": requests, "concurrency": concurrency,
|
||||||
|
"p50_ms": round(statistics.median(latencies), 2),
|
||||||
|
"p95_ms": round(percentile(latencies, .95), 2),
|
||||||
|
"max_ms": round(max(latencies), 2), "statuses": statuses,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="Read-only RF4 alpha latency probe")
|
||||||
|
parser.add_argument("--base-url", required=True)
|
||||||
|
parser.add_argument("--requests", type=int, default=100)
|
||||||
|
parser.add_argument("--concurrency", type=int, default=5)
|
||||||
|
parser.add_argument("--timeout", type=float, default=10)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if not 1 <= args.requests <= 10_000 or not 1 <= args.concurrency <= 100:
|
||||||
|
parser.error("requests must be 1..10000 and concurrency 1..100")
|
||||||
|
base = args.base_url.rstrip("/")
|
||||||
|
token = os.environ.get("ADMIN_TOKEN")
|
||||||
|
results = [probe(name, base + path, token=None, requests=args.requests, concurrency=args.concurrency, timeout=args.timeout) for name, path in PUBLIC.items()]
|
||||||
|
if token:
|
||||||
|
results.extend(probe(name, base + path, token=token, requests=args.requests, concurrency=args.concurrency, timeout=args.timeout) for name, path in ADMIN.items())
|
||||||
|
print(json.dumps({"base_url": base, "admin_scenarios_skipped": not bool(token), "results": results}, ensure_ascii=False, indent=2))
|
||||||
|
return 1 if any(any(not status.startswith("2") for status in result["statuses"]) for result in results) else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+1
-1
@@ -26,6 +26,6 @@ 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"
|
||||||
$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 rm --recursive --force "local/$S3_BUCKET" >/dev/null 2>&1 || true; mc mb --ignore-existing "local/$S3_BUCKET" >/dev/null && 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"'
|
||||||
$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"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ 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 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 mb --ignore-existing "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
|
||||||
|
|
||||||
./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)
|
||||||
|
|||||||
+4
-3
@@ -34,6 +34,7 @@
|
|||||||
- [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений.
|
- [x] **B12 · Эмблема сочетания.** Страница «водоём + рыба» получила составной атласный seal: собственный отпечаток водоёма пересекается со смысловым силуэтом рыбы. Так визуальная идентичность сопровождает всю иерархию каталога и не требует внешних изображений.
|
||||||
- [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`.
|
- [x] **B13 · Навигационная леска атласа.** Разрозненные ссылки назад на detail-страницах заменены доступной breadcrumb-цепочкой с мотивом лески и узлов. Страница точки связывает главную, водоём и координаты; сочетание — каталог, водоём и рыбу. Текущий узел всегда подписан текстом и отмечен `aria-current`.
|
||||||
- [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания.
|
- [x] **B14 · Атласные переходы сущностей.** Боковые списки рыб и водоёмов на detail-страницах получили компактные силуэты и отпечатки рядом с полным текстовым названием. Знаки продолжают систему каталога в рабочей навигации, а стрелка явно показывает переход к странице сочетания.
|
||||||
|
- [x] **B15 · Целостность медиакаталога.** Локальный audit сводит статусы очереди и проверяет наличие файлов, SHA-256, фактические dimensions/MIME, каноническое соответствие approved-записей и бесхозные файлы. Проверка не обращается в сеть и может использоваться как pre-publication gate.
|
||||||
|
|
||||||
- [ ] **Q01 · Документы источников.** Приложить или дать устойчивые ссылки на первичные разрешения RF4DB, RF4-STAT, RF4MAP и RF4 Posts; для каждого зафиксировать атрибуцию, точный production-лимит, срок хранения и процедуру удаления.
|
- [ ] **Q01 · Документы источников.** Приложить или дать устойчивые ссылки на первичные разрешения RF4DB, RF4-STAT, RF4MAP и RF4 Posts; для каждого зафиксировать атрибуцию, точный production-лимит, срок хранения и процедуру удаления.
|
||||||
- [ ] **Q02 · Управляемое удаление источника.** Добавить обнаружение изменённых/удалённых опубликованных записей без отдельного частого обхода: статус, журнал решения и безопасное исключение из активности после проверки.
|
- [ ] **Q02 · Управляемое удаление источника.** Добавить обнаружение изменённых/удалённых опубликованных записей без отдельного частого обхода: статус, журнал решения и безопасное исключение из активности после проверки.
|
||||||
@@ -41,9 +42,9 @@
|
|||||||
- [x] **Q04 · Состояния ожидания.** Асинхронные admin-очереди получили каркасные карточки, `aria-busy`, очистку при ошибке и поддержку `prefers-reduced-motion`. Публичные страницы остаются SSR и не показывают искусственный skeleton; форма уже блокирует повторную отправку и сообщает «Отправка…».
|
- [x] **Q04 · Состояния ожидания.** Асинхронные admin-очереди получили каркасные карточки, `aria-busy`, очистку при ошибке и поддержку `prefers-reduced-motion`. Публичные страницы остаются SSR и не показывают искусственный skeleton; форма уже блокирует повторную отправку и сообщает «Отправка…».
|
||||||
- [x] **Q05 · Базовая визуальная матрица.** Главная проверена в браузере на 320/390/768/1280 px, ключевые public-маршруты — на 320 px; удалён корневой `min-width`, создававший горизонтальный scroll. Добавлен E2E-контракт для `/`, records, report, waterbodies, status и видимого skip-link. Ширина 320 px также покрывает reflow, эквивалентный 200% zoom для окна 640 px. Расширенная матрица наполненных/длинных/error-состояний остаётся постоянной частью приёмки UI, а не отдельным блокером.
|
- [x] **Q05 · Базовая визуальная матрица.** Главная проверена в браузере на 320/390/768/1280 px, ключевые public-маршруты — на 320 px; удалён корневой `min-width`, создававший горизонтальный scroll. Добавлен E2E-контракт для `/`, records, report, waterbodies, status и видимого skip-link. Ширина 320 px также покрывает reflow, эквивалентный 200% zoom для окна 640 px. Расширенная матрица наполненных/длинных/error-состояний остаётся постоянной частью приёмки UI, а не отдельным блокером.
|
||||||
- [x] **Q06 · Performance baseline.** На локальной production-сборке после оптимизации hero: performance 100, LCP 1,66 с, FCP 1,15 с, CLS 0,023, TBT 9 мс. Устранены найденные Lighthouse проблемы контраста и accessible name; методика и бюджеты записаны в [performance-baseline.md](performance-baseline.md). Полевой INP измеряется только после запуска.
|
- [x] **Q06 · Performance baseline.** На локальной production-сборке после оптимизации hero: performance 100, LCP 1,66 с, FCP 1,15 с, CLS 0,023, TBT 9 мс. Устранены найденные Lighthouse проблемы контраста и accessible name; методика и бюджеты записаны в [performance-baseline.md](performance-baseline.md). Полевой INP измеряется только после запуска.
|
||||||
- [ ] **Q07 · Нагрузочная методика.** Подготовить воспроизводимый сценарий измерения p95 для activity, records, staging и moderation без объявления результатов до запуска на целевом сервере.
|
- [x] **Q07 · Нагрузочная методика.** Добавлен read-only runner для activity, records, staging и moderation с warm-up, p50/p95/max, распределением HTTP-кодов и ограниченной concurrency. Методика фиксирует контекст запуска, ступени нагрузки и бюджеты, но не объявляет результатов до трёх прогонов на целевом сервере.
|
||||||
- [ ] **Q08 · Политика MinIO.** Ограничить app credentials одним bucket и добавить безопасную автоматическую проверку policy; root credentials оставить только bootstrap-задаче.
|
- [x] **Q08 · Политика MinIO.** Production bootstrap создаёт bucket и отдельную policy только с bucket location/list и get/put/delete его объектов. App credentials проверяются через bucket stat и отрицательную проверку глобального list; API больше не требует `ListAllMyBuckets` и не пытается создавать bucket. Root credentials остаются только у init-задачи.
|
||||||
- [ ] **Q09 · Release-процедура.** Разделить миграционный/release-шаг и запуск приложения либо документировать выбранную стратегию отката; проверить upgrade с предыдущей ревизии на копии данных.
|
- [ ] **Q09 · Release-процедура — код готов.** Миграции вынесены из API runtime в одноразовый `migrate` service; API запускается только после успешного Alembic upgrade. Документированы backup, rollout и два варианта отката. Осталось проверить upgrade с предыдущей ревизии на копии данных в изолированном Compose drill.
|
||||||
- [ ] **Q10 · Документальная ревизия.** После каждого пакета обновлять этот файл и README, не возвращая закрытые R/A/T-задачи в активный backlog.
|
- [ ] **Q10 · Документальная ревизия.** После каждого пакета обновлять этот файл и README, не возвращая закрытые R/A/T-задачи в активный backlog.
|
||||||
|
|
||||||
### Дополнения после ревизии PROJECT_AUDIT_2026-09-10
|
### Дополнения после ревизии PROJECT_AUDIT_2026-09-10
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ Fallback строится на собственных лёгких SVG: осмы
|
|||||||
|
|
||||||
С 12 сентября 2026 года разрешён локальный сбор медиаресурсов. `python -m rf4_research.media_cli URL` делает один учтённый HTML-запрос и добавляет кандидатов в `data/media/manifest.json`; `--download-one` сохраняет ровно один ожидающий asset по SHA-256 в `data/media/files/`. Оба режима используют общий cooldown площадки. Перед записью проверяются raster-формат, совпадение MIME и предел 40 Мп; manifest получает размеры, объём и время загрузки. Постоянные 404/410, запрет доступа и невалидный файл получают отдельные статусы и не запирают начало очереди. Статус `queued` или `stored` не означает соответствие сущности или разрешение на публичный вывод. Ручная команда с `--review-url`, `--decision`, `--entity-type` и `--entity-key` переводит сохранённый файл в `approved`; неподходящий материал явно получает `rejected`.
|
С 12 сентября 2026 года разрешён локальный сбор медиаресурсов. `python -m rf4_research.media_cli URL` делает один учтённый HTML-запрос и добавляет кандидатов в `data/media/manifest.json`; `--download-one` сохраняет ровно один ожидающий asset по SHA-256 в `data/media/files/`. Оба режима используют общий cooldown площадки. Перед записью проверяются raster-формат, совпадение MIME и предел 40 Мп; manifest получает размеры, объём и время загрузки. Постоянные 404/410, запрет доступа и невалидный файл получают отдельные статусы и не запирают начало очереди. Статус `queued` или `stored` не означает соответствие сущности или разрешение на публичный вывод. Ручная команда с `--review-url`, `--decision`, `--entity-type` и `--entity-key` переводит сохранённый файл в `approved`; неподходящий материал явно получает `rejected`.
|
||||||
|
|
||||||
|
`python -m rf4_research.media_cli --audit` не использует сеть: команда выводит сводку статусов и завершается с ошибкой при отсутствующем/изменённом файле, несовпадении SHA-256 или dimensions/MIME, некорректном approved-сопоставлении и наличии бесхозного файла.
|
||||||
|
|
||||||
Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU.
|
Координат, проводки, времени поимки и устойчивого внешнего идентификатора записи в этой таблице нет. Локализация зависит от домена/языка страницы: проверенная `.de`-страница возвращает немецкие названия рыб, водоёмов и приманок даже для региона RU.
|
||||||
|
|
||||||
## Рекомендуемый режим получения
|
## Рекомендуемый режим получения
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# Нагрузочная проверка открытой альфы
|
||||||
|
|
||||||
|
Цель — получить воспроизводимый baseline задержки чтения, а не доказать предельную производительность. Проверка выполняется только на принадлежащем проекту staging/production после наполнения реалистичным разрешённым набором данных. Внешние источники парсеров не затрагиваются.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 deploy/load-smoke.py --base-url https://rf4spotter.ru --requests 100 --concurrency 5 > load-baseline.json
|
||||||
|
ADMIN_TOKEN='секрет' python3 deploy/load-smoke.py --base-url https://rf4spotter.ru --requests 100 --concurrency 5 > load-admin-baseline.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Без `ADMIN_TOKEN` измеряются `activity` и `records`; с токеном добавляются staging и moderation. Скрипт выполняет только GET, делает один исключённый warm-up на сценарий и выводит число ответов по HTTP-кодам, p50, p95 и максимум. Токен не попадает в результат.
|
||||||
|
|
||||||
|
Перед запуском зафиксировать ревизию Git, конфигурацию сервера, число строк основных таблиц, concurrency и время UTC. Первый безопасный прогон: 100 запросов при concurrency 5. Повышать до 10/20 только при стабильном error rate 0%, наблюдая CPU, RAM, PostgreSQL connections и дисковый I/O. Между ступенями выдерживать две минуты.
|
||||||
|
|
||||||
|
Предварительный бюджет открытой альфы: p95 публичных чтений ≤ 500 мс, admin-очередей ≤ 1000 мс, HTTP 5xx = 0%. Это критерий расследования, а не обещание пользователям. При превышении сначала снять `EXPLAIN (ANALYZE, BUFFERS)` по [query-performance.md](query-performance.md), затем менять запросы/индексы. Результаты не вносить в README до повторения минимум три раза на целевом сервере.
|
||||||
@@ -120,6 +120,40 @@ def review_asset(
|
|||||||
return item
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def audit_media_catalog(root: Path) -> dict:
|
||||||
|
manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
|
||||||
|
statuses: dict[str, int] = {}
|
||||||
|
issues: list[str] = []
|
||||||
|
referenced: set[str] = set()
|
||||||
|
for item in manifest.get("assets", []):
|
||||||
|
status = str(item.get("status", "unknown"))
|
||||||
|
statuses[status] = statuses.get(status, 0) + 1
|
||||||
|
local_path = item.get("local_path")
|
||||||
|
if status in {"stored", "approved"}:
|
||||||
|
if not isinstance(local_path, str):
|
||||||
|
issues.append(f"{item['asset_url']}: {status} asset has no local_path")
|
||||||
|
continue
|
||||||
|
referenced.add(local_path)
|
||||||
|
target = root / local_path
|
||||||
|
if not target.is_file():
|
||||||
|
issues.append(f"{item['asset_url']}: local file is missing")
|
||||||
|
continue
|
||||||
|
body = target.read_bytes()
|
||||||
|
if hashlib.sha256(body).hexdigest() != item.get("sha256"):
|
||||||
|
issues.append(f"{item['asset_url']}: SHA-256 mismatch")
|
||||||
|
try:
|
||||||
|
width, height, mime = inspect_image(body)
|
||||||
|
if (width, height, mime) != (item.get("width"), item.get("height"), item.get("content_type")):
|
||||||
|
issues.append(f"{item['asset_url']}: image metadata mismatch")
|
||||||
|
except ValueError as exc:
|
||||||
|
issues.append(f"{item['asset_url']}: {exc}")
|
||||||
|
if status == "approved" and (not item.get("entity_key") or item.get("entity_type") not in {"fish", "waterbody", "tackle", "reference"}):
|
||||||
|
issues.append(f"{item['asset_url']}: approved asset has no valid canonical mapping")
|
||||||
|
files_root = root / "files"
|
||||||
|
orphaned = sorted(str(path.relative_to(root)) for path in files_root.rglob("*") if path.is_file() and str(path.relative_to(root)) not in referenced) if files_root.exists() else []
|
||||||
|
return {"total": sum(statuses.values()), "statuses": statuses, "issues": issues, "orphaned_files": orphaned}
|
||||||
|
|
||||||
|
|
||||||
def inspect_image(body: bytes) -> tuple[int, int, str]:
|
def inspect_image(body: bytes) -> tuple[int, int, str]:
|
||||||
try:
|
try:
|
||||||
with Image.open(io.BytesIO(body)) as image:
|
with Image.open(io.BytesIO(body)) as image:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import urllib.error
|
|||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from .community_cli import USER_AGENT, _StrictRedirectHandler, _validate_url_before_io, check_and_reserve, fetch_html, fetch_site_key
|
from .community_cli import USER_AGENT, _StrictRedirectHandler, _validate_url_before_io, check_and_reserve, fetch_html, fetch_site_key
|
||||||
from .media_assets import extract_media_candidates, merge_manifest, reclassify_manifest, review_asset, store_asset
|
from .media_assets import audit_media_catalog, extract_media_candidates, merge_manifest, reclassify_manifest, review_asset, store_asset
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_ROOT = Path("data/media")
|
DEFAULT_ROOT = Path("data/media")
|
||||||
@@ -57,10 +57,15 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
parser.add_argument("--entity-type", choices=("fish", "waterbody", "tackle", "reference"))
|
parser.add_argument("--entity-type", choices=("fish", "waterbody", "tackle", "reference"))
|
||||||
parser.add_argument("--entity-key")
|
parser.add_argument("--entity-key")
|
||||||
parser.add_argument("--note")
|
parser.add_argument("--note")
|
||||||
|
parser.add_argument("--audit", action="store_true", help="Verify manifest metadata, hashes and local files without network access")
|
||||||
parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
|
parser.add_argument("--root", type=Path, default=DEFAULT_ROOT)
|
||||||
parser.add_argument("--state-file", type=Path, default=Path(".cache/community-fetch-state.json"))
|
parser.add_argument("--state-file", type=Path, default=Path(".cache/community-fetch-state.json"))
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
try:
|
try:
|
||||||
|
if args.audit:
|
||||||
|
report = audit_media_catalog(args.root)
|
||||||
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||||
|
return 1 if report["issues"] or report["orphaned_files"] else 0
|
||||||
if args.review_url:
|
if args.review_url:
|
||||||
if not args.decision:
|
if not args.decision:
|
||||||
parser.error("--decision is required with --review-url")
|
parser.error("--decision is required with --review-url")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import io
|
|||||||
import pytest
|
import pytest
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from rf4_research.media_assets import extract_media_candidates, inspect_image, merge_manifest, review_asset, store_asset
|
from rf4_research.media_assets import audit_media_catalog, extract_media_candidates, inspect_image, merge_manifest, review_asset, store_asset
|
||||||
|
|
||||||
|
|
||||||
def test_extracts_and_classifies_unique_https_media() -> None:
|
def test_extracts_and_classifies_unique_https_media() -> None:
|
||||||
@@ -59,3 +59,20 @@ def test_review_requires_stored_asset_and_canonical_mapping(tmp_path: Path) -> N
|
|||||||
review_asset(path, asset_url=item.asset_url, decision="approved", entity_type="fish")
|
review_asset(path, asset_url=item.asset_url, decision="approved", entity_type="fish")
|
||||||
reviewed = review_asset(path, asset_url=item.asset_url, decision="approved", entity_type="fish", entity_key="pike", note="matched by name")
|
reviewed = review_asset(path, asset_url=item.asset_url, decision="approved", entity_type="fish", entity_key="pike", note="matched by name")
|
||||||
assert (reviewed["status"], reviewed["entity_key"]) == ("approved", "pike")
|
assert (reviewed["status"], reviewed["entity_key"]) == ("approved", "pike")
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_audit_detects_tampering_and_orphans(tmp_path: Path) -> None:
|
||||||
|
item = extract_media_candidates('<img src="/fish/pike.png">', source_page="https://example.test")[0]
|
||||||
|
path = tmp_path / "manifest.json"
|
||||||
|
manifest = merge_manifest(path, [item])
|
||||||
|
image = io.BytesIO()
|
||||||
|
Image.new("RGB", (2, 2)).save(image, format="PNG")
|
||||||
|
digest, relative, width, height, mime = store_asset(tmp_path, image.getvalue(), content_type="image/png", source_url=item.asset_url)
|
||||||
|
manifest["assets"][0].update({"status": "stored", "sha256": digest, "local_path": relative, "width": width, "height": height, "content_type": mime})
|
||||||
|
path.write_text(__import__("json").dumps(manifest), encoding="utf-8")
|
||||||
|
assert audit_media_catalog(tmp_path)["issues"] == []
|
||||||
|
(tmp_path / relative).write_bytes(b"changed")
|
||||||
|
(tmp_path / "files" / "orphan.png").write_bytes(b"orphan")
|
||||||
|
report = audit_media_catalog(tmp_path)
|
||||||
|
assert any("SHA-256 mismatch" in issue for issue in report["issues"])
|
||||||
|
assert "files/orphan.png" in report["orphaned_files"]
|
||||||
|
|||||||
Reference in New Issue
Block a user