Compare commits

..
4 Commits
Author SHA1 Message Date
ik 049f1ef30a refactor: separate migrations from API runtime
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / dependency-audit (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-12 16:02:19 +07:00
ik 868278fdba security: restrict MinIO app to one bucket 2026-09-12 16:01:00 +07:00
ik 926182a6cf feat: add alpha load-test methodology 2026-09-12 15:58:07 +07:00
ik 883ad2c73b feat: audit media catalog integrity 2026-09-12 15:57:04 +07:00
18 changed files with 226 additions and 26 deletions
+7 -1
View File
@@ -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
View File
@@ -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"]
+2 -1
View File
@@ -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"}
-4
View File
@@ -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
+4 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+80
View File
@@ -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
View File
@@ -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"
+1 -1
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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.
## Рекомендуемый режим получения ## Рекомендуемый режим получения
+14
View File
@@ -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 до повторения минимум три раза на целевом сервере.
+34
View File
@@ -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:
+6 -1
View File
@@ -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")
+18 -1
View File
@@ -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"]