Compare commits

...
5 Commits
Author SHA1 Message Date
ik 6e60988ac9 docs: record rf4spotter deployment status
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-06 14:46:11 +07:00
ik e6653d4c77 feat: add production health monitoring 2026-09-06 14:44:38 +07:00
ik 2200336524 feat: add production data retention 2026-09-06 14:37:58 +07:00
ik 0f075469c8 test: verify clean production bootstrap 2026-09-06 14:34:05 +07:00
ik 66fb1625da feat: verify production backup restore 2026-09-06 14:24:26 +07:00
23 changed files with 612 additions and 38 deletions
+14
View File
@@ -24,4 +24,18 @@ OFFICIAL_RECORDS_REGION=RU
OFFICIAL_RECORDS_CATEGORY=records OFFICIAL_RECORDS_CATEGORY=records
OFFICIAL_IMPORT_REQUIRED=false OFFICIAL_IMPORT_REQUIRED=false
IMPORT_INTERVAL_SECONDS=3600 IMPORT_INTERVAL_SECONDS=3600
# Data retention for the closed alpha; see docs/data-retention.md.
RETENTION_SUBMISSION_DAYS=1
RETENTION_UNREVIEWED_DAYS=30
RETENTION_APPROVED_PERSONAL_DAYS=180
RETENTION_STAGING_DAYS=90
RETENTION_AUDIT_DAYS=365
RETENTION_PUBLISHED_PAYLOAD_DAYS=365
# Host-side monitoring. BACKUP_ROOT must match the backup.sh destination.
BACKUP_ROOT=/srv/rf4-backups
MONITOR_DISK_MAX_PERCENT=85
MONITOR_TLS_MIN_DAYS=14
MONITOR_BACKUP_MAX_HOURS=26
LOG_LEVEL=INFO LOG_LEVEL=INFO
+10 -4
View File
@@ -14,7 +14,13 @@ RF4 Spotter — неофициальный сервис свежих точек
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md). Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
Production-контур для домена `rf4spotter.ru`, TLS, секреты, backup/restore и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки. Production seed добавляет только справочники — демонстрационные уловы отключены. Production-контур для домена `rf4spotter.ru`, TLS, секреты, backup/restore и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки. Production seed добавляет только справочники — демонстрационные уловы отключены. Изолированные проверки `deploy/test-production-bootstrap.sh` и `deploy/test-backup-restore.sh` подтверждают старт с пустых volumes и восстановление данных.
Политика минимизации данных и ежедневная dry-run-first очистка описаны в [`docs/data-retention.md`](docs/data-retention.md).
Host-side мониторинг контейнеров, readiness, диска, резервных копий и TLS описан в [`docs/production-monitoring.md`](docs/production-monitoring.md).
Фактическое состояние DNS/TLS домена и серверный чек-лист ведутся в [`docs/deployment-status.md`](docs/deployment-status.md).
Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты. Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты.
@@ -79,7 +85,7 @@ docker compose up --build
## Что реализовано ## Что реализовано
- FastAPI и SQLAlchemy 2; - FastAPI и SQLAlchemy 2;
- PostgreSQL 17 и миграции Alembic до `0009`; - PostgreSQL 17 и миграции Alembic до `0010`;
- идемпотентный seed с двумя точками и свежими демо-уловами; - идемпотентный seed с двумя точками и свежими демо-уловами;
- `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки; - `GET /api/v1/activity` с фильтрами периода, водоёма, рыбы, способа и сортировки;
- `GET /api/v1/spots/{id}` и `/catches`; - `GET /api/v1/spots/{id}` и `/catches`;
@@ -91,7 +97,7 @@ docker compose up --build
- идемпотентный импорт официальных записей с журналом запусков; - идемпотентный импорт официальных записей с журналом запусков;
- публичная страница `/records` с источником и временем последнего импорта; - публичная страница `/records` с источником и временем последнего импорта;
- форма `/report`, защищённые admin API и журнал модерации; - форма `/report`, защищённые admin API и журнал модерации;
- отдельные состояния ошибки создания заявки и загрузки скриншота; неудачный скриншот можно добавить повторно по ID уже сохранённой заявки; - отдельные состояния ошибки создания заявки и загрузки скриншота; неудачный скриншот можно добавить повторно по ID и одноразовому секрету уже сохранённой заявки;
- honeypot и постоянный rate limit в PostgreSQL с HMAC-отпечатками вместо исходных IP; - honeypot и постоянный rate limit в PostgreSQL с HMAC-отпечатками вместо исходных IP;
- скриншоты уловов в MinIO/S3 с проверкой MIME, расширения, размера и фактического содержимого, повторным кодированием и очисткой метаданных; - скриншоты уловов в MinIO/S3 с проверкой MIME, расширения, размера и фактического содержимого, повторным кодированием и очисткой метаданных;
- административная очередь `/admin/moderation` с одобрением, отклонением и обезличенным удалением записи с аудитом. - административная очередь `/admin/moderation` с одобрением, отклонением и обезличенным удалением записи с аудитом.
@@ -159,7 +165,7 @@ curl -H "Authorization: Bearer change-me-in-production" \
Перед внешним развёртыванием обязательно замените демонстрационные `ADMIN_TOKEN`, `RATE_LIMIT_SECRET`, `S3_ACCESS_KEY` и `S3_SECRET_KEY`. Форма принимает JPEG, PNG и WebP до 8 МБ; API сверяет MIME и расширение с фактическим форматом, повторно кодирует изображение и удаляет EXIF перед сохранением в MinIO. Модератор получает временную подписанную ссылку через admin API. Перед внешним развёртыванием обязательно замените демонстрационные `ADMIN_TOKEN`, `RATE_LIMIT_SECRET`, `S3_ACCESS_KEY` и `S3_SECRET_KEY`. Форма принимает JPEG, PNG и WebP до 8 МБ; API сверяет MIME и расширение с фактическим форматом, повторно кодирует изображение и удаляет EXIF перед сохранением в MinIO. Модератор получает временную подписанную ссылку через admin API.
Если создание записи прошло успешно, а загрузка скриншота завершилась ошибкой, форма сохраняет ID заявки и предлагает повторить только загрузку изображения. Повторно отправлять сам улов не требуется. Если создание записи прошло успешно, а загрузка скриншота завершилась ошибкой, форма сохраняет на один час ID заявки и одноразовый секрет в защищённой `HttpOnly` cookie и предлагает повторить только загрузку изображения. Повторно отправлять сам улов не требуется; один UUID заявки не даёт права изменить чужую запись.
Очередь модерации доступна по адресу <http://localhost:4321/admin/moderation>. Администратор вводит `ADMIN_TOKEN`; интерфейс держит его только в памяти открытой страницы и не сохраняет в URL или браузерном хранилище. Очередь модерации доступна по адресу <http://localhost:4321/admin/moderation>. Администратор вводит `ADMIN_TOKEN`; интерфейс держит его только в памяти открытой страницы и не сохраняет в URL или браузерном хранилище.
+18 -1
View File
@@ -3,10 +3,14 @@ from __future__ import annotations
import argparse import argparse
import json import json
import sys import sys
from dataclasses import asdict
from .config import settings
from .database import SessionLocal from .database import SessionLocal
from .importer import import_records from .importer import import_records
from .community_importer import stage_observations from .community_importer import stage_observations
from .retention import RetentionPolicy, apply_retention
from .storage import delete_screenshot
def main() -> int: def main() -> int:
@@ -19,12 +23,14 @@ def main() -> int:
community = sub.add_parser("stage-community-json") community = sub.add_parser("stage-community-json")
community.add_argument("--input", default="-", help="JSON array path or - for stdin") community.add_argument("--input", default="-", help="JSON array path or - for stdin")
community.add_argument("--limit", type=int, default=500) community.add_argument("--limit", type=int, default=500)
cleanup = sub.add_parser("cleanup-retention")
cleanup.add_argument("--apply", action="store_true", help="apply changes; default is dry-run")
args = parser.parse_args() args = parser.parse_args()
with SessionLocal() as session: with SessionLocal() as session:
if args.command == "import-records": if args.command == "import-records":
run = import_records(session, url=args.url, region=args.region, category=args.category) run = import_records(session, url=args.url, region=args.region, category=args.category)
print(f"import {run.status.value}: seen={run.rows_seen} created={run.rows_created} updated={run.rows_updated}") print(f"import {run.status.value}: seen={run.rows_seen} created={run.rows_created} updated={run.rows_updated}")
else: elif args.command == "stage-community-json":
if not 1 <= args.limit <= 5000: if not 1 <= args.limit <= 5000:
parser.error("--limit must be between 1 and 5000") parser.error("--limit must be between 1 and 5000")
stream = sys.stdin if args.input == "-" else open(args.input, encoding="utf-8") stream = sys.stdin if args.input == "-" else open(args.input, encoding="utf-8")
@@ -37,6 +43,17 @@ def main() -> int:
parser.error("input must be a JSON array") parser.error("input must be a JSON array")
created, updated = stage_observations(session, payload[:args.limit]) created, updated = stage_observations(session, payload[:args.limit])
print(f"staged: created={created} updated={updated}") print(f"staged: created={created} updated={updated}")
else:
policy = RetentionPolicy(
submission_days=settings.retention_submission_days,
unreviewed_days=settings.retention_unreviewed_days,
approved_personal_days=settings.retention_approved_personal_days,
staging_days=settings.retention_staging_days,
audit_days=settings.retention_audit_days,
published_payload_days=settings.retention_published_payload_days,
)
counts = apply_retention(session, policy=policy, dry_run=not args.apply, delete_object=delete_screenshot)
print(json.dumps({"mode": "apply" if args.apply else "dry-run", "policy": asdict(policy), "counts": counts}, ensure_ascii=False))
return 0 return 0
+6
View File
@@ -17,6 +17,12 @@ class Settings(BaseSettings):
official_records_category: str = "records" official_records_category: str = "records"
official_import_required: bool = False official_import_required: bool = False
seed_demo_data: bool = True seed_demo_data: bool = True
retention_submission_days: int = Field(default=1, ge=1)
retention_unreviewed_days: int = Field(default=30, ge=7)
retention_approved_personal_days: int = Field(default=180, ge=30)
retention_staging_days: int = Field(default=90, ge=30)
retention_audit_days: int = Field(default=365, ge=90)
retention_published_payload_days: int = Field(default=365, ge=90)
import_interval_seconds: int = Field(default=3600, ge=3600) import_interval_seconds: int = Field(default=3600, ge=3600)
rate_limit_secret: str = "change-rate-limit-secret" rate_limit_secret: str = "change-rate-limit-secret"
log_level: str = "INFO" log_level: str = "INFO"
+108
View File
@@ -0,0 +1,108 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Callable
from sqlalchemy import delete, or_, select
from sqlalchemy.orm import Session
from .models import CatchReport, ExternalObservation, ModerationEvent, ModerationStatus, SourceType, SubmissionAttempt
@dataclass(frozen=True)
class RetentionPolicy:
submission_days: int = 1
unreviewed_days: int = 30
approved_personal_days: int = 180
staging_days: int = 90
audit_days: int = 365
published_payload_days: int = 365
def apply_retention(
session: Session, *, policy: RetentionPolicy = RetentionPolicy(),
now: datetime | None = None, dry_run: bool = True,
delete_object: Callable[[str], None] | None = None,
) -> dict[str, int]:
current = now or datetime.now(timezone.utc)
counts = {
"submission_attempts": 0,
"user_reports_anonymized": 0,
"pending_reports_expired": 0,
"screenshots_deleted": 0,
"staging_observations_deleted": 0,
"published_payloads_cleared": 0,
"moderation_events_deleted": 0,
}
attempts = list(session.scalars(select(SubmissionAttempt.id).where(
SubmissionAttempt.created_at < current - timedelta(days=policy.submission_days),
)))
counts["submission_attempts"] = len(attempts)
candidate_cutoff = current - timedelta(days=min(policy.unreviewed_days, policy.approved_personal_days))
candidates = list(session.scalars(select(CatchReport).where(
CatchReport.source_type == SourceType.user,
CatchReport.reported_at < candidate_cutoff,
or_(
CatchReport.player_name.is_not(None), CatchReport.source_url.is_not(None),
CatchReport.raw_payload.is_not(None), CatchReport.screenshot_key.is_not(None),
CatchReport.screenshot_upload_token_hash.is_not(None),
),
)))
reports = [item for item in candidates if item.reported_at.replace(tzinfo=item.reported_at.tzinfo or timezone.utc) < current - timedelta(
days=policy.approved_personal_days if item.moderation_status == ModerationStatus.approved else policy.unreviewed_days,
)]
counts["user_reports_anonymized"] = len(reports)
counts["screenshots_deleted"] = sum(bool(item.screenshot_key) for item in reports)
counts["pending_reports_expired"] = sum(item.moderation_status == ModerationStatus.pending for item in reports)
stale = list(session.scalars(select(ExternalObservation).where(
ExternalObservation.catch_report_id.is_(None),
ExternalObservation.status != "published",
ExternalObservation.last_seen_at < current - timedelta(days=policy.staging_days),
)))
counts["staging_observations_deleted"] = len(stale)
published = list(session.scalars(select(ExternalObservation).where(
ExternalObservation.status == "published",
ExternalObservation.last_seen_at < current - timedelta(days=policy.published_payload_days),
)))
published = [item for item in published if item.payload]
counts["published_payloads_cleared"] = len(published)
events = list(session.scalars(select(ModerationEvent.id).where(
ModerationEvent.created_at < current - timedelta(days=policy.audit_days),
)))
counts["moderation_events_deleted"] = len(events)
if dry_run:
return counts
if counts["screenshots_deleted"] and delete_object is None:
raise ValueError("delete_object is required when retained screenshots must be deleted")
for report in reports:
if report.screenshot_key and delete_object:
delete_object(report.screenshot_key)
if report.moderation_status == ModerationStatus.pending:
session.add(ModerationEvent(
catch_report=report, created_at=current,
previous_status=ModerationStatus.pending, new_status=ModerationStatus.rejected,
moderator="retention-policy", reason="pending report retention period expired",
))
report.moderation_status = ModerationStatus.rejected
report.player_name = None
report.source_url = None
report.raw_payload = None
report.screenshot_key = None
report.screenshot_upload_token_hash = None
for observation in stale:
session.delete(observation)
for observation in published:
observation.payload = {}
if attempts:
session.execute(delete(SubmissionAttempt).where(SubmissionAttempt.id.in_(attempts)))
if events:
session.execute(delete(ModerationEvent).where(ModerationEvent.id.in_(events)))
session.commit()
return counts
+59
View File
@@ -0,0 +1,59 @@
from datetime import datetime, timedelta, timezone
from sqlalchemy import create_engine, func, select
from sqlalchemy.orm import Session
from app.database import Base
from app.models import CatchReport, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, SourceType, SubmissionAttempt, Waterbody
from app.retention import apply_retention
NOW = datetime(2026, 9, 6, tzinfo=timezone.utc)
def test_retention_dry_run_then_apply() -> None:
engine = create_engine("sqlite://")
Base.metadata.create_all(engine)
with Session(engine) as db:
fish = Fish(slug="pike", name_ru="Щука", trophy_weight_g=10_000)
waterbody = Waterbody(slug="lake", name_ru="Озеро", unlock_level=1)
source = DataSource(key="rf4db", name="RF4DB", base_url="https://rf4db.com", default_confidence=60, enabled=False)
pending = CatchReport(
fish=fish, waterbody=waterbody, weight_g=1000, reported_at=NOW - timedelta(days=31),
player_name="Private", source_type=SourceType.user, source_confidence=60,
moderation_status=ModerationStatus.pending, raw_payload={"comment": "private"},
screenshot_key="reports/old.jpg", screenshot_upload_token_hash="a" * 64,
)
approved = CatchReport(
fish=fish, waterbody=waterbody, weight_g=2000, reported_at=NOW - timedelta(days=181),
player_name="Old winner", source_type=SourceType.user, source_confidence=60,
moderation_status=ModerationStatus.approved, raw_payload={"comment": "old"},
)
fresh = CatchReport(
fish=fish, waterbody=waterbody, weight_g=3000, reported_at=NOW - timedelta(days=10),
player_name="Fresh", source_type=SourceType.user, source_confidence=60,
moderation_status=ModerationStatus.pending, raw_payload={"comment": "fresh"},
)
db.add_all([source, pending, approved, fresh])
db.flush()
db.add(SubmissionAttempt(client_hash="x" * 64, created_at=NOW - timedelta(days=2)))
db.add(ModerationEvent(catch_report=approved, created_at=NOW - timedelta(days=366), previous_status=ModerationStatus.pending, new_status=ModerationStatus.approved, moderator="admin"))
db.add(ExternalObservation(source=source, source_external_id="stale", source_url="https://rf4db.com/1", fish_name="Щука", waterbody_name="Озеро", first_seen_at=NOW - timedelta(days=100), last_seen_at=NOW - timedelta(days=100), status="staged", payload={"raw": True}))
db.add(ExternalObservation(source=source, source_external_id="published", source_url="https://rf4db.com/2", fish_name="Щука", waterbody_name="Озеро", first_seen_at=NOW - timedelta(days=400), last_seen_at=NOW - timedelta(days=400), status="published", payload={"raw": True}, catch_report=approved))
db.commit()
preview = apply_retention(db, now=NOW)
assert preview == {"submission_attempts": 1, "user_reports_anonymized": 2, "pending_reports_expired": 1, "screenshots_deleted": 1, "staging_observations_deleted": 1, "published_payloads_cleared": 1, "moderation_events_deleted": 1}
assert db.get(CatchReport, pending.id).player_name == "Private"
deleted: list[str] = []
assert apply_retention(db, now=NOW, dry_run=False, delete_object=deleted.append) == preview
assert deleted == ["reports/old.jpg"]
assert db.get(CatchReport, pending.id).moderation_status == ModerationStatus.rejected
assert db.get(CatchReport, pending.id).player_name is None
assert db.get(CatchReport, approved.id).raw_payload is None
assert db.get(CatchReport, fresh.id).player_name == "Fresh"
assert db.scalar(select(func.count()).select_from(SubmissionAttempt)) == 0
assert db.scalar(select(func.count()).select_from(ExternalObservation)) == 1
assert db.scalar(select(ExternalObservation)).payload == {}
assert db.scalar(select(func.count()).select_from(ModerationEvent)) == 1
+2
View File
@@ -1,5 +1,7 @@
FROM node:22-alpine AS build FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
ARG PUBLIC_API_URL=http://localhost:8000
ENV PUBLIC_API_URL=$PUBLIC_API_URL
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm install
COPY . . COPY . .
+2 -1
View File
@@ -9,7 +9,8 @@
"start": "node ./dist/server/entry.mjs", "start": "node ./dist/server/entry.mjs",
"check": "astro check", "check": "astro check",
"test:unit": "node --test tests/unit/*.test.ts", "test:unit": "node --test tests/unit/*.test.ts",
"test:e2e": "playwright test" "test:e2e": "playwright test",
"test:bootstrap": "playwright test tests/production-bootstrap.spec.ts"
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "^0.9.10", "@astrojs/check": "^0.9.10",
@@ -0,0 +1,23 @@
import { expect, test } from "@playwright/test";
test("production bootstrap supports submission and moderation", async ({ page, request }) => {
const player = `Bootstrap Player ${Date.now()}`;
const x = 7000 + Date.now() % 1000;
const y = x + 1;
await page.goto("/");
await expect(page.getByRole("heading", { name: "Выбирай место, пока клюёт." })).toBeVisible();
await page.goto("/report");
await page.getByLabel("Координата X *").fill(String(x));
await page.getByLabel("Координата Y *").fill(String(y));
await page.getByLabel("Вес, граммы *").fill("4321");
await page.getByLabel("Ник игрока").fill(player);
await page.getByRole("button", { name: "Отправить на проверку" }).click();
await expect(page.getByText("Улов отправлен на модерацию. Спасибо!")).toBeVisible();
const response = await request.get(`${process.env.BOOTSTRAP_API_URL}/api/v1/admin/catch-reports?status=pending`, {
headers: { Authorization: `Bearer ${process.env.BOOTSTRAP_ADMIN_TOKEN}` },
});
expect(response.ok()).toBeTruthy();
expect(await response.text()).toContain(player);
});
+16 -3
View File
@@ -89,6 +89,12 @@ services:
SEED_DEMO_DATA: "false" SEED_DEMO_DATA: "false"
IMPORT_INTERVAL_SECONDS: ${IMPORT_INTERVAL_SECONDS:-3600} IMPORT_INTERVAL_SECONDS: ${IMPORT_INTERVAL_SECONDS:-3600}
RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:?Set RATE_LIMIT_SECRET} RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:?Set RATE_LIMIT_SECRET}
RETENTION_SUBMISSION_DAYS: ${RETENTION_SUBMISSION_DAYS:-1}
RETENTION_UNREVIEWED_DAYS: ${RETENTION_UNREVIEWED_DAYS:-30}
RETENTION_APPROVED_PERSONAL_DAYS: ${RETENTION_APPROVED_PERSONAL_DAYS:-180}
RETENTION_STAGING_DAYS: ${RETENTION_STAGING_DAYS:-90}
RETENTION_AUDIT_DAYS: ${RETENTION_AUDIT_DAYS:-365}
RETENTION_PUBLISHED_PAYLOAD_DAYS: ${RETENTION_PUBLISHED_PAYLOAD_DAYS:-365}
LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_LEVEL: ${LOG_LEVEL:-INFO}
depends_on: depends_on:
db: {condition: service_healthy} db: {condition: service_healthy}
@@ -105,7 +111,10 @@ services:
limits: {cpus: "1.00", memory: 1G} limits: {cpus: "1.00", memory: 1G}
web: web:
build: ./apps/web build:
context: ./apps/web
args:
PUBLIC_API_URL: https://${SITE_DOMAIN:?Set SITE_DOMAIN}
restart: unless-stopped restart: unless-stopped
environment: environment:
PUBLIC_API_URL: https://${SITE_DOMAIN:?Set SITE_DOMAIN} PUBLIC_API_URL: https://${SITE_DOMAIN:?Set SITE_DOMAIN}
@@ -124,11 +133,15 @@ services:
limits: {cpus: "0.75", memory: 512M} limits: {cpus: "0.75", memory: 512M}
storage-tool: storage-tool:
image: alpine:3.22 image: minio/mc:RELEASE.2025-07-21T05-28-08Z
profiles: [tools] profiles: [tools]
environment:
S3_ACCESS_KEY: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY}
S3_SECRET_KEY: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
S3_BUCKET: ${S3_BUCKET:-catch-screenshots}
volumes: volumes:
- minio_data:/data
- ${BACKUP_DIRECTORY:-./backups}:/backup - ${BACKUP_DIRECTORY:-./backups}:/backup
networks: [backend]
security_opt: [no-new-privileges:true] security_opt: [no-new-privileges:true]
networks: networks:
+43 -7
View File
@@ -2,6 +2,8 @@
Production-контур рассчитан на один Linux-сервер с Docker Compose. Наружу публикуются только Caddy `80/443`; PostgreSQL, FastAPI и MinIO не имеют host-портов. Административные страницы защищены одновременно Caddy Basic Auth и API bearer token. Production-контур рассчитан на один Linux-сервер с Docker Compose. Наружу публикуются только Caddy `80/443`; PostgreSQL, FastAPI и MinIO не имеют host-портов. Административные страницы защищены одновременно Caddy Basic Auth и API bearer token.
Текущее состояние публичных DNS/TLS и незакрытые инфраструктурные действия ведутся в [`docs/deployment-status.md`](../docs/deployment-status.md).
## 1. DNS и сервер ## 1. DNS и сервер
Создайте A-записи `rf4spotter.ru` и `files.rf4spotter.ru` на публичный IPv4 сервера. При наличии рабочего IPv6 добавьте AAAA для обоих имён. До запуска убедитесь, что извне доступны TCP 80/443 и UDP 443; SSH ограничьте своим IP или VPN. Порты 4321, 8000, 9000, 9001 и 5432 открывать нельзя. Создайте A-записи `rf4spotter.ru` и `files.rf4spotter.ru` на публичный IPv4 сервера. При наличии рабочего IPv6 добавьте AAAA для обоих имён. До запуска убедитесь, что извне доступны TCP 80/443 и UDP 443; SSH ограничьте своим IP или VPN. Порты 4321, 8000, 9000, 9001 и 5432 открывать нельзя.
@@ -32,6 +34,14 @@ curl -fsS https://rf4spotter.ru/health
curl -fsS https://rf4spotter.ru/ready curl -fsS https://rf4spotter.ru/ready
``` ```
До запуска на сервере можно воспроизвести полный production bootstrap на пустых изолированных volumes. Скрипт собирает образы, применяет миграции, проверяет отсутствие демо-уловов, readiness и отправку заявки:
```bash
./deploy/test-production-bootstrap.sh
```
По умолчанию временно используются только loopback-порты `14321` и `18000`; PostgreSQL и MinIO наружу не публикуются. Контур и volumes удаляются после проверки. Drill успешно пройден 6 сентября 2026 года.
API-контейнер перед стартом применяет Alembic-миграции и запускает идемпотентный seed. В production он добавляет только минимальные справочники и точки; демонстрационные уловы жёстко отключены настройкой `SEED_DEMO_DATA=false`. API-контейнер перед стартом применяет Alembic-миграции и запускает идемпотентный seed. В production он добавляет только минимальные справочники и точки; демонстрационные уловы жёстко отключены настройкой `SEED_DEMO_DATA=false`.
Проверка TLS и маршрутизации: Проверка TLS и маршрутизации:
@@ -73,20 +83,46 @@ curl -fsS https://rf4spotter.ru/ready
./deploy/backup.sh /srv/rf4-backups ./deploy/backup.sh /srv/rf4-backups
``` ```
Восстановление заменяет содержимое PostgreSQL и MinIO данными из выбранной копии, временно останавливая API, web и MinIO. Это намеренно защищённая подтверждением операция: Восстановление заменяет содержимое PostgreSQL и MinIO данными из выбранной копии, временно останавливая 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
``` ```
Перед приглашением пользователей проведите учебное восстановление на отдельном сервере или с отдельным Compose project name. Одной только успешной архивации недостаточно. После проверки настройте ежедневный запуск `backup.sh`, шифрование/выгрузку копий во внешнее хранилище и уведомление при ошибке; храните минимум 7 ежедневных и 4 еженедельных копии. Репозиторий содержит изолированный drill, который создаёт отдельные Compose volumes, портит тестовые данные, восстанавливает их и сравнивает PostgreSQL и MinIO:
## 7. Что ещё блокирует приглашение альфа-пользователей ```bash
./deploy/test-backup-restore.sh
```
- учебное восстановление PostgreSQL и MinIO на изолированном контуре; Drill успешно пройден 6 сентября 2026 года. На целевом сервере всё равно проведите учебное восстановление с реальной зашифрованной копией перед приглашением пользователей. Затем настройте ежедневный запуск `backup.sh`, выгрузку копий во внешнее хранилище и уведомление при ошибке; храните минимум 7 ежедневных и 4 еженедельных копии.
- проверка полного bootstrap на пустых volumes;
- политика хранения и удаления пользовательских данных; ## 7. Ежедневное обслуживание
- базовый мониторинг `/ready`, диска и срока TLS-сертификата.
После успешного backup сначала проверьте план очистки, затем примените его:
```bash
docker compose --env-file .env.production -f compose.production.yaml exec -T api python -m app.cli cleanup-retention
docker compose --env-file .env.production -f compose.production.yaml exec -T api python -m app.cli cleanup-retention --apply
```
Сроки и состав данных описаны в [`docs/data-retention.md`](../docs/data-retention.md). Автоматизацию включайте только после проверки dry-run на рабочем наборе.
## 8. Мониторинг
После настройки DNS, TLS и первого backup выполните:
```bash
./deploy/monitor.sh
```
Проверка контролирует контейнеры, `/ready`, диск, свежесть backup и срок TLS. Установка systemd timer, пороги и порядок реакции описаны в [`docs/production-monitoring.md`](../docs/production-monitoring.md). До подключения реального канала уведомлений одного журнала systemd недостаточно.
## 9. Что ещё блокирует приглашение альфа-пользователей
- подключение уведомлений о сбоях мониторинга;
- проверка DNS/TLS и полного запуска на целевом сервере;
- внешнее зашифрованное хранилище резервных копий.
До закрытия этих пунктов контур можно поднять для технической проверки домена, но не следует открывать форму реальным пользователям. До закрытия этих пунктов контур можно поднять для технической проверки домена, но не следует открывать форму реальным пользователям.
+10 -3
View File
@@ -14,13 +14,20 @@ esac
backup_root=${1%/} backup_root=${1%/}
stamp=$(date -u +%Y%m%dT%H%M%SZ) stamp=$(date -u +%Y%m%dT%H%M%SZ)
target="$backup_root/$stamp" target="$backup_root/$stamp"
if [ -e "$target" ]; then
echo "Backup target already exists: $target" >&2
exit 1
fi
mkdir -p "$target" mkdir -p "$target"
chmod 700 "$target" chmod 700 "$target"
export BACKUP_DIRECTORY="$backup_root" export BACKUP_DIRECTORY="$backup_root"
compose="docker compose --env-file .env.production -f compose.production.yaml" env_file=${COMPOSE_ENV_FILE:-.env.production}
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 storage-tool tar -czf "/backup/$stamp/minio.tar.gz" -C /data . $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"'
sha256sum "$target/postgres.dump" "$target/minio.tar.gz" > "$target/SHA256SUMS" tar -czf "$target/minio.tar.gz" -C "$target" minio
rm -rf "$target/minio"
(cd "$target" && sha256sum postgres.dump minio.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"
echo "Backup created: $target" echo "Backup created: $target"
+8
View File
@@ -0,0 +1,8 @@
services:
api:
ports:
- "127.0.0.1:${BOOTSTRAP_API_PORT:-18000}:8000"
web:
networks: [backend, edge]
ports:
- "127.0.0.1:${BOOTSTRAP_WEB_PORT:-14321}:4321"
+80
View File
@@ -0,0 +1,80 @@
#!/bin/sh
set -u
repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
cd "$repo" || exit 2
env_file=${COMPOSE_ENV_FILE:-.env.production}
if [ ! -r "$env_file" ]; then
printf 'CRITICAL rf4spotter failures="env-file-unreadable"\n'
exit 2
fi
read_env() {
key=$1
value=$(sed -n "s/^${key}=//p" "$env_file" | tail -n 1)
printf '%s' "$value"
}
site_domain=${SITE_DOMAIN:-$(read_env SITE_DOMAIN)}
backup_root=${BACKUP_ROOT:-$(read_env BACKUP_ROOT)}
disk_max=${MONITOR_DISK_MAX_PERCENT:-$(read_env MONITOR_DISK_MAX_PERCENT)}
tls_days=${MONITOR_TLS_MIN_DAYS:-$(read_env MONITOR_TLS_MIN_DAYS)}
backup_hours=${MONITOR_BACKUP_MAX_HOURS:-$(read_env MONITOR_BACKUP_MAX_HOURS)}
disk_max=${disk_max:-85}
tls_days=${tls_days:-14}
backup_hours=${backup_hours:-26}
for value in "$disk_max" "$tls_days" "$backup_hours"; do
case "$value" in ''|*[!0-9]*) printf 'CRITICAL rf4spotter failures="monitor-threshold-invalid"\n'; exit 2 ;; esac
done
base_url=${MONITOR_BASE_URL:-https://$site_domain}
compose="docker compose --env-file $env_file -f compose.production.yaml"
failures=""
fail() {
failures="${failures}${failures:+; }$1"
}
running=$($compose ps --status running --services 2>/dev/null) || running=""
for service in proxy db minio api web; do
printf '%s\n' "$running" | grep -qx "$service" || fail "service:$service"
done
ready_payload=$(curl -fsS --max-time 10 "$base_url/ready" 2>/dev/null) || ready_payload=""
printf '%s' "$ready_payload" | grep -Eq '"status"[[:space:]]*:[[:space:]]*"ready"' || fail "readiness"
disk_used=$(df -Pk "$repo" | awk 'NR==2 {gsub(/%/, "", $5); print $5}')
case "$disk_used" in
''|*[!0-9]*) fail "disk:unknown" ;;
*) [ "$disk_used" -lt "$disk_max" ] || fail "disk:${disk_used}%" ;;
esac
if [ -z "$backup_root" ] || [ ! -d "$backup_root" ]; then
fail "backup:directory"
else
recent_backup=$(find "$backup_root" -mindepth 2 -maxdepth 2 -name SHA256SUMS -mmin "-$((backup_hours * 60))" -print -quit 2>/dev/null)
if [ -z "$recent_backup" ]; then
fail "backup:stale"
else
(cd "$(dirname "$recent_backup")" && sha256sum -c --quiet SHA256SUMS) >/dev/null 2>&1 || fail "backup:checksum"
fi
fi
if [ -z "$site_domain" ]; then
fail "tls:domain"
else
tls_seconds=$((tls_days * 86400))
certificate=$(openssl s_client -servername "$site_domain" -connect "$site_domain:443" </dev/null 2>/dev/null | openssl x509 -outform PEM 2>/dev/null) || certificate=""
if [ -z "$certificate" ]; then
fail "tls:unavailable"
else
printf '%s\n' "$certificate" | openssl x509 -checkend "$tls_seconds" -noout >/dev/null 2>&1 || fail "tls:expires-soon"
fi
fi
timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
if [ -n "$failures" ]; then
printf 'CRITICAL rf4spotter timestamp=%s failures="%s"\n' "$timestamp" "$failures"
exit 1
fi
printf 'OK rf4spotter timestamp=%s disk_used=%s%% backup_max_age=%sh tls_min=%sd\n' "$timestamp" "$disk_used" "$backup_hours" "$tls_days"
+9 -5
View File
@@ -16,12 +16,16 @@ test -f "$source_dir/postgres.dump"
test -f "$source_dir/minio.tar.gz" test -f "$source_dir/minio.tar.gz"
(cd "$source_dir" && sha256sum -c SHA256SUMS) (cd "$source_dir" && sha256sum -c SHA256SUMS)
backup_root=$(dirname "$source_dir") backup_root=$(dirname "$source_dir")
stamp=$(basename "$source_dir")
export BACKUP_DIRECTORY="$backup_root" export BACKUP_DIRECTORY="$backup_root"
restore_tmp=$(mktemp -d "$backup_root/.rf4-restore.XXXXXX")
trap 'rm -rf "$restore_tmp"' EXIT INT TERM
tar -xzf "$source_dir/minio.tar.gz" -C "$restore_tmp"
restore_name=$(basename "$restore_tmp")
compose="docker compose --env-file .env.production -f compose.production.yaml" env_file=${COMPOSE_ENV_FILE:-.env.production}
$compose stop api web minio compose="docker compose --env-file $env_file -f compose.production.yaml"
$compose stop api web
$compose exec -T db sh -c 'pg_restore --clean --if-exists --exit-on-error --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < "$source_dir/postgres.dump" $compose 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 storage-tool sh -c 'find /data -mindepth 1 -delete && tar -xzf "/backup/'"$stamp"'/minio.tar.gz" -C /data' $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 up -d 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"
@@ -0,0 +1,9 @@
[Unit]
Description=RF4 Spotter production health check
After=docker.service network-online.target
[Service]
Type=oneshot
User=rf4spotter
WorkingDirectory=/opt/rf4-spotter
ExecStart=/opt/rf4-spotter/deploy/monitor.sh
+11
View File
@@ -0,0 +1,11 @@
[Unit]
Description=Run RF4 Spotter health check every five minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=5min
RandomizedDelaySec=30s
Persistent=true
[Install]
WantedBy=timers.target
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
set -eu
repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
cd "$repo"
drill_root=$(mktemp -d /tmp/rf4-restore-drill.XXXXXX)
project="rf4-restore-drill-$$"
export COMPOSE_PROJECT_NAME="$project"
export COMPOSE_ENV_FILE=.env.production.example
export BACKUP_DIRECTORY="$drill_root"
compose="docker compose --env-file $COMPOSE_ENV_FILE -f compose.production.yaml"
cleanup() {
$compose down --volumes --remove-orphans >/dev/null 2>&1 || true
rm -rf "$drill_root"
}
trap cleanup EXIT INT TERM
$compose up -d --wait db minio
$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
./deploy/backup.sh "$drill_root"
backup=$(find "$drill_root" -mindepth 1 -maxdepth 1 -type d ! -name '.rf4-restore.*' | head -n 1)
$compose exec -T db sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "drop table restore_drill;"' >/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 rm --force "local/$S3_BUCKET/restore-drill.txt"' >/dev/null
CONFIRM_RESTORE=rf4-spotter RESTORE_START_SERVICES=minio ./deploy/restore.sh "$backup"
db_value=$($compose exec -T db sh -c 'psql -At -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "select value from restore_drill;"')
object_value=$($compose run --rm --entrypoint /bin/sh storage-tool -c 'mc alias set local http://minio:9000 "$S3_ACCESS_KEY" "$S3_SECRET_KEY" >/dev/null; mc cat "local/$S3_BUCKET/restore-drill.txt"')
test "$db_value" = "database-ok"
test "$object_value" = "object-ok"
echo "Backup/restore drill passed for PostgreSQL and MinIO"
+32
View File
@@ -0,0 +1,32 @@
#!/bin/sh
set -eu
repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
cd "$repo"
project="rf4-bootstrap-$$"
export COMPOSE_PROJECT_NAME="$project"
export BOOTSTRAP_API_PORT=${BOOTSTRAP_API_PORT:-18000}
export BOOTSTRAP_WEB_PORT=${BOOTSTRAP_WEB_PORT:-14321}
export POSTGRES_PASSWORD=bootstrap-postgres-password
export DATABASE_URL=postgresql+psycopg://rf4:bootstrap-postgres-password@db:5432/rf4_spotter
compose="docker compose --env-file .env.production.example -f compose.production.yaml -f deploy/compose.bootstrap.yaml"
cleanup() {
status=$?
if [ "$status" -ne 0 ]; then
$compose ps >&2 || true
$compose logs --no-color api web db minio >&2 || true
fi
$compose down --volumes --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
$compose up --build -d --wait db minio api web
curl -fsS "http://127.0.0.1:$BOOTSTRAP_API_PORT/ready" >/dev/null
curl -fsS "http://127.0.0.1:$BOOTSTRAP_WEB_PORT/" >/dev/null
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select version_num from alembic_version')" = "0010"
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from fish')" = "2"
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from waterbody')" = "2"
test "$($compose exec -T db psql -At -U rf4 -d rf4_spotter -c 'select count(*) from catch_report')" = "0"
WEB_URL="http://127.0.0.1:$BOOTSTRAP_WEB_PORT" BOOTSTRAP_API_URL="http://127.0.0.1:$BOOTSTRAP_API_PORT" BOOTSTRAP_ADMIN_TOKEN=replace-with-at-least-32-random-characters npm --prefix apps/web run test:bootstrap
echo "Production bootstrap passed from empty volumes"
+12 -14
View File
@@ -2,7 +2,7 @@
Этот файл — рабочий источник правды по развитию проекта. После завершения задачи её чекбокс меняется с `[ ]` на `[x]`, рядом добавляется ссылка на коммит или короткое подтверждение проверки. Новые задачи добавляются в соответствующий этап, а не хранятся только в переписке. Этот файл — рабочий источник правды по развитию проекта. После завершения задачи её чекбокс меняется с `[ ]` на `[x]`, рядом добавляется ссылка на коммит или короткое подтверждение проверки. Новые задачи добавляются в соответствующий этап, а не хранятся только в переписке.
Последняя сверка плана со спецификацией, кодом и UI/UX-аудитом: 5 сентября 2026 года. Последняя сверка плана со спецификацией, кодом и UI/UX-аудитом: 6 сентября 2026 года.
Обозначения: Обозначения:
@@ -57,16 +57,17 @@
## Подготовка MVP к пилоту ## Подготовка MVP к пилоту
- [x] Добавить health/readiness-проверки PostgreSQL, MinIO, API и импорта; отразить их в Compose (`/health` без зависимостей, `/ready` с компонентами и режимом обязательного импорта). - [x] Добавить health/readiness-проверки PostgreSQL, MinIO, API и импорта; отразить их в Compose (`/health` без зависимостей, `/ready` с компонентами и режимом обязательного импорта).
- [x] Добавить host-side production monitor контейнеров, `/ready`, диска, свежести/целостности backup и срока TLS; подготовлены systemd timer и runbook, реальный канал уведомлений подключается на сервере.
- [x] Добавить структурированные JSON-логи без пользовательских секретов и персональных технических данных (whitelist полей, redaction, request ID; Uvicorn access-log отключён). - [x] Добавить структурированные JSON-логи без пользовательских секретов и персональных технических данных (whitelist полей, redaction, request ID; Uvicorn access-log отключён).
- [x] Добавить Gitea Actions CI: backend tests, Astro check/build, E2E и применение всех миграций на чистой PostgreSQL; сохранять логи Compose и Playwright-артефакты при падении (`.gitea/workflows/ci.yml`). - [x] Добавить Gitea Actions CI: backend tests, Astro check/build, E2E и применение всех миграций на чистой PostgreSQL; сохранять логи Compose и Playwright-артефакты при падении (`.gitea/workflows/ci.yml`).
- [ ] Добавить отдельный тест полного bootstrap: пустые volumes → миграции → seed → readiness → основной E2E. - [x] Добавить отдельный тест полного bootstrap: пустые production volumes → миграции `0010` → seed без демо-уловов → readiness → браузерная отправка и проверка moderation API (`deploy/test-production-bootstrap.sh`, 6 сентября 2026).
- [x] Сделать seed устойчивым к частично заполненной БД: справочники досеиваются независимо, демо-уловы идемпотентны и принудительно отключены в production; повторный/частичный запуск покрыт конфигурационными и интеграционными проверками. - [x] Сделать seed устойчивым к частично заполненной БД: справочники досеиваются независимо, демо-уловы идемпотентны и принудительно отключены в production; повторный/частичный запуск покрыт конфигурационными и интеграционными проверками.
- [ ] Проверить списочные API по требованию раздела 12: пагинация, предсказуемая сортировка и валидация фильтров для справочников, импортов, модерации и внешнего staging. - [ ] Проверить списочные API по требованию раздела 12: пагинация, предсказуемая сортировка и валидация фильтров для справочников, импортов, модерации и внешнего staging.
- [ ] Проверить необходимые индексы PostgreSQL и планы запросов для activity, модерации, дедупликации и очистки rate limit; зафиксировать допустимый бюджет запросов пилота. - [ ] Проверить необходимые индексы PostgreSQL и планы запросов для activity, модерации, дедупликации и очистки rate limit; зафиксировать допустимый бюджет запросов пилота.
- [ ] Провести security-проверку admin-аутентификации, CORS, security headers, загрузок и управления секретами; вынести допустимые origins в конфигурацию и исключить демонстрационные секреты в production-режиме. - [ ] Провести security-проверку admin-аутентификации, CORS, security headers, загрузок и управления секретами; вынести допустимые origins в конфигурацию и исключить демонстрационные секреты в production-режиме.
- [x] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно. - [x] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно.
- [ ] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавить документированную очистку/анонимизацию. - [x] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавлены настраиваемая dry-run-first очистка, тест и `docs/data-retention.md`.
- [ ] Добавить резервное копирование и документированное восстановление PostgreSQL и MinIO: скрипты, контрольные суммы и runbook готовы; остаётся учебное восстановление на отдельных временных volumes. - [x] Добавить резервное копирование и документированное восстановление PostgreSQL и MinIO: консистентные `pg_dump` и MinIO API mirror, контрольные суммы, runbook и успешный изолированный drill с намеренным удалением данных (6 сентября 2026).
- [ ] Проверить доступность интерфейса: клавиатура, focus states, контраст, подписи полей и семантика таблиц/карточек. - [ ] Проверить доступность интерфейса: клавиатура, focus states, контраст, подписи полей и семантика таблиц/карточек.
- [ ] Провести Lighthouse-проверку основных страниц и устранить критические проблемы производительности. - [ ] Провести Lighthouse-проверку основных страниц и устранить критические проблемы производительности.
- [x] Провести UI/UX-аудит desktop/mobile и сформировать приоритетный план (`docs/UI_UX_AUDIT.md`). - [x] Провести UI/UX-аудит desktop/mobile и сформировать приоритетный план (`docs/UI_UX_AUDIT.md`).
@@ -79,7 +80,7 @@
- [ ] Добавить smoke-проверку административной очереди внешних источников на desktop/mobile без публикации реальных записей. - [ ] Добавить smoke-проверку административной очереди внешних источников на desktop/mobile без публикации реальных записей.
- [ ] Обновить README: архитектура, все переменные окружения, импорт, модерация, backup/restore, эксплуатация логов и известные ограничения. - [ ] Обновить README: архитектура, все переменные окружения, импорт, модерация, backup/restore, эксплуатация логов и известные ограничения.
- [ ] Выбрать лицензию кода и политику использования данных. - [ ] Выбрать лицензию кода и политику использования данных.
- [ ] Завершить production-профиль для `rf4spotter.ru`: Compose, Caddy/TLS, закрытые внутренние сервисы, CORS, resource limits, fail-fast секреты и runbook добавлены; остаются backup/restore и проверка на целевом сервере. - [ ] Завершить production-профиль для `rf4spotter.ru`: приложение готово; внешняя проверка 6 сентября выявила неверный сертификат `*.timeweb.ru` на корневом домене и отсутствие DNS для `files.rf4spotter.ru`; требуются IP целевого сервера, DNS, запуск и канал уведомлений (`docs/deployment-status.md`).
- [ ] Заменить демонстрационные секреты и определить целевое размещение перед внешней публикацией. - [ ] Заменить демонстрационные секреты и определить целевое размещение перед внешней публикацией.
## Источники данных и согласование ## Источники данных и согласование
@@ -115,17 +116,14 @@
## Ближайший рабочий пакет ## Ближайший рабочий пакет
Технические health/readiness и безопасные логи готовы. Следующие пункты выполняются строго по одному: Технический production-контур, health/readiness, backup/restore и безопасные логи готовы. Следующие пункты выполняются строго по одному:
1. защита официального импорта от конкурентных запусков; 1. защита официального импорта от конкурентных запусков;
2. полный bootstrap-тест и исправление seed для частично заполненной БД; 2. пагинация/сортировка списочных API и индексы PostgreSQL;
3. security-аудит admin/CORS/headers/secrets и повторной загрузки скриншота; 3. UI/UX-пакеты B–D: мобильная главная, форма и рекорды;
4. backup/restore PostgreSQL и MinIO с реальной проверкой восстановления; 4. accessibility/admin safety и Lighthouse;
5. пагинация/сортировка списочных API и индексы PostgreSQL; 5. мониторинг, DNS/TLS и проверка production-профиля на целевом сервере;
6. UI/UX-пакет A: единая шкала активности и числительные; 6. финальное обновление README, лицензия кода и политика данных.
7. UI/UX-пакеты B–D: мобильная главная, форма и рекорды;
8. accessibility/admin safety и Lighthouse;
9. production-профиль и финальное обновление README.
После каждого пункта необходимо: После каждого пункта необходимо:
+33
View File
@@ -0,0 +1,33 @@
# Политика хранения данных закрытой альфы
Политика минимизирует персональные и диагностические данные, не разрушая обезличенную статистику клёва. Сроки считаются от `reported_at`, `created_at` или `last_seen_at` соответствующей записи.
| Данные | Срок | Действие |
|---|---:|---|
| HMAC-отпечатки IP для rate limit | 1 день | удалить запись |
| Pending/rejected пользовательские уловы | 30 дней | стереть ник, комментарий, source URL, токен и скриншот; просроченный pending перевести в rejected с системным событием |
| Ник, комментарий и скриншот одобренного пользовательского улова | 180 дней | анонимизировать, сохранив рыбу, точку, вес, снасть и время для агрегатов |
| Неопубликованные/rejected staging-наблюдения внешних источников | 90 дней без обновления | удалить запись целиком |
| Raw payload опубликованного внешнего наблюдения | 365 дней без обновления | очистить payload, сохранив URL, внешний ID и каноническую связь |
| События модерации | 365 дней | удалить событие; сама обезличенная запись улова остаётся |
| Официальные рекорды и их публичные поля | пока запись актуальна | сохранять для сверки; удаление регулируется процедурой обновления источника |
Сроки задаются переменными `RETENTION_*_DAYS` из `.env.production`. Минимальные значения ограничены конфигурацией, чтобы ошибочное значение не вызвало немедленную массовую очистку.
## Запуск
Сначала обязателен просмотр плана без изменений:
```bash
docker compose --env-file .env.production -f compose.production.yaml exec -T api python -m app.cli cleanup-retention
```
После проверки счётчиков и свежего backup:
```bash
docker compose --env-file .env.production -f compose.production.yaml exec -T api python -m app.cli cleanup-retention --apply
```
Команда выводит JSON с режимом, применёнными сроками и числом затронутых сущностей. Для альфы её следует запускать ежедневно после успешного backup. Ошибка удаления объекта MinIO прерывает обработку базы; повторный запуск безопасен.
Администратор может вручную удалить пользовательскую заявку раньше срока через очередь модерации. В таком случае ник, исходный payload и скриншот удаляются немедленно, а обезличенный tombstone и событие аудита остаются до штатной очистки.
+29
View File
@@ -0,0 +1,29 @@
# Статус развёртывания rf4spotter.ru
Последняя внешняя read-only проверка: 6 сентября 2026 года.
## Что видно публично сейчас
| Проверка | Результат | Статус |
|---|---|---|
| `A rf4spotter.ru` | `176.57.210.144` | требует подтверждения, что это IP целевого сервера |
| HTTPS `rf4spotter.ru` | сервер отвечает, но сертификат имеет SAN только `*.timeweb.ru` и `timeweb.ru` | не готово |
| `A files.rf4spotter.ru` | имя не резолвится | не готово |
| HTTPS `files.rf4spotter.ru` | невозможен без DNS | не готово |
Текущий сертификат выпущен GlobalSign для `*.timeweb.ru`, действует с 17 июня 2026 до 2 января 2027 года и не подтверждает `rf4spotter.ru`. Это похоже на ответ парковочной/хостинговой площадки, но назначение IP необходимо подтвердить в панели DNS или на целевом сервере.
## Следующие действия на инфраструктуре
- [ ] Определить публичный IPv4 целевого Linux-сервера и подтвердить доступ по SSH.
- [ ] Установить `A` для `rf4spotter.ru` на этот IPv4.
- [ ] Установить `A` для `files.rf4spotter.ru` на тот же IPv4.
- [ ] Добавлять `AAAA` только при реально настроенном публичном IPv6; иначе удалить ошибочную AAAA.
- [ ] Разрешить входящие TCP `80/443` и UDP `443`, не публикуя `4321`, `8000`, `9000`, `9001`, `5432`.
- [ ] Дождаться, пока оба имени одинаково резолвятся через несколько публичных резолверов.
- [ ] Разместить проект, создать `.env.production`, заменить все заглушки и выполнить production bootstrap из runbook.
- [ ] Убедиться, что Caddy получил сертификаты с SAN `rf4spotter.ru` и `files.rf4spotter.ru`.
- [ ] Выполнить `curl` для `/`, `/health`, `/ready` и MinIO health через оба домена.
- [ ] Создать первую внешнюю резервную копию, запустить `deploy/monitor.sh` и подключить уведомления.
Не следует отключать TLS verification или сохранять текущий чужой сертификат в исключения: после правильных DNS и доступных портов Caddy должен получить корректный сертификат автоматически.
+45
View File
@@ -0,0 +1,45 @@
# Мониторинг закрытой альфы
`deploy/monitor.sh` — host-side проверка production-контура. Она не изменяет данные и не печатает секреты. Успех возвращает exit code `0` и одну строку `OK`; любая проблема возвращает `1`/`2` и строку `CRITICAL`.
Проверяются:
- контейнеры `proxy`, `db`, `minio`, `api`, `web` находятся в running;
- публичный `https://rf4spotter.ru/ready` сообщает `ready`;
- файловая система проекта заполнена менее чем на 85%;
- последняя полная резервная копия не старше 26 часов и проходит проверку `SHA256SUMS`;
- TLS-сертификат домена действует ещё минимум 14 дней.
Пороги задаются в `.env.production`: `BACKUP_ROOT`, `MONITOR_DISK_MAX_PERCENT`, `MONITOR_BACKUP_MAX_HOURS`, `MONITOR_TLS_MIN_DAYS`. Проверка TLS использует SNI и поэтому обнаруживает как истечение, так и выдачу сертификата для неверного домена.
## Ручная проверка
```bash
./deploy/monitor.sh
echo $?
```
Первый успешный запуск следует сделать только после появления DNS, TLS и первой резервной копии. До этого `CRITICAL` для этих компонентов ожидаем.
## systemd timer
Шаблоны рассчитаны на пользователя `rf4spotter` и каталог `/opt/rf4-spotter`. При другом размещении измените оба пути в service-файле.
```bash
sudo cp deploy/systemd/rf4spotter-monitor.service /etc/systemd/system/
sudo cp deploy/systemd/rf4spotter-monitor.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now rf4spotter-monitor.timer
systemctl list-timers rf4spotter-monitor.timer
journalctl -u rf4spotter-monitor.service -n 20 --no-pager
```
Сам timer записывает результат в journal. Для реального оповещения подключите failed unit к существующему серверному мониторингу либо настройте внешний HTTPS-monitor на `/ready`; уведомления должны приходить минимум по состояниям readiness, disk, backup и TLS. Не передавайте `.env.production` или вывод `docker compose config` внешнему сервису.
## Реакция
1. `service:*` — посмотреть `docker compose ... ps` и логи конкретного контейнера; не выполнять `down -v`.
2. `readiness` — проверить `/ready`, PostgreSQL и MinIO; на время сбоя остановить приём пользовательских заявок.
3. `disk:*` — сначала перенести старые backup во внешнее хранилище; не удалять Docker volumes вслепую.
4. `backup:*` — запустить `deploy/backup.sh`, проверить `SHA256SUMS` и устранить причину пропуска расписания.
5. `tls:*` — проверить DNS, доступность портов 80/443 и логи Caddy; не отключать проверку сертификата.