feat: add production deployment for rf4spotter.ru

This commit is contained in:
ik
2026-09-06 13:52:30 +07:00
parent b45ba38a00
commit a4bd395856
10 changed files with 350 additions and 3 deletions
+27
View File
@@ -0,0 +1,27 @@
# DNS: A/AAAA records for both names must point to the deployment server.
SITE_DOMAIN=rf4spotter.ru
FILES_DOMAIN=files.rf4spotter.ru
ACME_EMAIL=admin@rf4spotter.ru
POSTGRES_DB=rf4_spotter
POSTGRES_USER=rf4
POSTGRES_PASSWORD=replace-with-long-random-value
# URL-encode special characters from POSTGRES_PASSWORD in this URL.
DATABASE_URL=postgresql+psycopg://rf4:replace-with-url-encoded-password@db:5432/rf4_spotter
ADMIN_TOKEN=replace-with-at-least-32-random-characters
RATE_LIMIT_SECRET=replace-with-at-least-32-random-characters
ADMIN_BASIC_USER=rf4admin
# Generate with: docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'YOUR PASSWORD'
ADMIN_BASIC_PASSWORD_HASH=replace-with-caddy-password-hash
S3_ACCESS_KEY=replace-with-random-access-key
S3_SECRET_KEY=replace-with-at-least-32-random-characters
S3_BUCKET=catch-screenshots
OFFICIAL_RECORDS_URL=https://rf4game.de/records/region/RU/
OFFICIAL_RECORDS_REGION=RU
OFFICIAL_RECORDS_CATEGORY=records
OFFICIAL_IMPORT_REQUIRED=false
IMPORT_INTERVAL_SECONDS=3600
LOG_LEVEL=INFO
+1
View File
@@ -2,6 +2,7 @@ __pycache__/
*.py[cod]
.pytest_cache/
.cache/
.env.production
.venv/
node_modules/
dist/
+2
View File
@@ -14,6 +14,8 @@ RF4 Spotter — неофициальный сервис свежих точек
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
Production-контур для домена `rf4spotter.ru`, TLS, секреты и команды первого запуска описаны в [`deploy/README.md`](deploy/README.md). Он использует отдельный `compose.production.yaml`; локальный `compose.yaml` остаётся средой разработки.
Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты.
Актуальная инвентаризация источников и правила подключения адаптеров находятся в [`docs/data-source-audit.md`](docs/data-source-audit.md). Разрешённый технический пилот RF4DB/RF4-STAT описан в [`docs/community-source-pilot.md`](docs/community-source-pilot.md), а статус разрешений и лимитов — в [`docs/data-permissions.md`](docs/data-permissions.md). Данные сохраняются только в промежуточный staging и не влияют на индекс без явной проверки и публикации администратором.
+22 -1
View File
@@ -1,8 +1,9 @@
from pydantic import Field
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
deployment_environment: str = "development"
database_url: str = "postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter"
admin_token: str = "change-me-in-production"
s3_endpoint_url: str = "http://localhost:9000"
@@ -18,7 +19,27 @@ class Settings(BaseSettings):
import_interval_seconds: int = Field(default=3600, ge=3600)
rate_limit_secret: str = "change-rate-limit-secret"
log_level: str = "INFO"
cors_origins: list[str] = Field(default_factory=lambda: ["http://localhost:4321", "http://127.0.0.1:4321"])
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@model_validator(mode="after")
def reject_insecure_production_defaults(self) -> "Settings":
if self.deployment_environment != "production":
return self
insecure = {
"ADMIN_TOKEN": self.admin_token == "change-me-in-production" or len(self.admin_token) < 32,
"RATE_LIMIT_SECRET": self.rate_limit_secret == "change-rate-limit-secret" or len(self.rate_limit_secret) < 32,
"S3_ACCESS_KEY": self.s3_access_key == "rf4-local" or len(self.s3_access_key) < 12,
"S3_SECRET_KEY": self.s3_secret_key == "rf4-local-secret" or len(self.s3_secret_key) < 32,
}
invalid = [name for name, failed in insecure.items() if failed]
if invalid:
raise ValueError(f"insecure production settings: {', '.join(invalid)}")
if not self.cors_origins or any(not origin.startswith("https://") for origin in self.cors_origins):
raise ValueError("production CORS_ORIGINS must contain only HTTPS origins")
if not self.s3_public_endpoint_url.startswith("https://"):
raise ValueError("production S3_PUBLIC_ENDPOINT_URL must use HTTPS")
return self
settings = Settings()
+6 -1
View File
@@ -34,7 +34,7 @@ logger = logging.getLogger("rf4.api")
app = FastAPI(title="RF4 Spotter API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:4321", "http://127.0.0.1:4321"],
allow_origins=settings.cors_origins,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
@@ -50,6 +50,11 @@ async def structured_request_log(request: Request, call_next):
response = await call_next(request)
status_code = response.status_code
response.headers["X-Request-ID"] = request_id
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
if settings.deployment_environment == "production":
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
return response
except Exception as exc:
logger.error("request failed", extra={"request_id": request_id, "error_type": type(exc).__name__})
+36
View File
@@ -0,0 +1,36 @@
import pytest
from pydantic import ValidationError
from app.config import Settings
def production_settings(**changes) -> Settings:
values = {
"deployment_environment": "production",
"admin_token": "a" * 32,
"rate_limit_secret": "r" * 32,
"s3_access_key": "access-key-1234",
"s3_secret_key": "s" * 32,
"s3_public_endpoint_url": "https://files.rf4spotter.ru",
"cors_origins": ["https://rf4spotter.ru"],
}
return Settings(**(values | changes))
def test_production_settings_accept_real_domains_and_secrets() -> None:
settings = production_settings()
assert settings.cors_origins == ["https://rf4spotter.ru"]
@pytest.mark.parametrize(("field", "value"), [
("admin_token", "change-me-in-production"),
("rate_limit_secret", "short"),
("s3_access_key", "rf4-local"),
("s3_secret_key", "rf4-local-secret"),
("cors_origins", ["http://rf4spotter.ru"]),
("s3_public_endpoint_url", "http://files.rf4spotter.ru"),
])
def test_production_settings_reject_insecure_values(field: str, value: object) -> None:
with pytest.raises(ValidationError):
production_settings(**{field: value})
+134
View File
@@ -0,0 +1,134 @@
name: rf4-spotter
services:
proxy:
image: caddy:2.10.2-alpine
restart: unless-stopped
environment:
SITE_DOMAIN: ${SITE_DOMAIN:?Set SITE_DOMAIN}
FILES_DOMAIN: ${FILES_DOMAIN:?Set FILES_DOMAIN}
ACME_EMAIL: ${ACME_EMAIL:?Set ACME_EMAIL}
ADMIN_BASIC_USER: ${ADMIN_BASIC_USER:?Set ADMIN_BASIC_USER}
ADMIN_BASIC_PASSWORD_HASH: ${ADMIN_BASIC_PASSWORD_HASH:?Set ADMIN_BASIC_PASSWORD_HASH}
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
web:
condition: service_healthy
networks: [edge, backend]
security_opt: [no-new-privileges:true]
deploy:
resources:
limits: {cpus: "0.50", memory: 256M}
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-rf4_spotter}
POSTGRES_USER: ${POSTGRES_USER:-rf4}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-rf4} -d ${POSTGRES_DB:-rf4_spotter}"]
interval: 10s
timeout: 5s
retries: 10
networks: [backend]
security_opt: [no-new-privileges:true]
deploy:
resources:
limits: {cpus: "1.00", memory: 1G}
minio:
image: minio/minio:RELEASE.2025-07-23T15-54-02Z
command: server /data
restart: unless-stopped
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY:?Set S3_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY:?Set S3_SECRET_KEY}
volumes:
- minio_data:/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:9000/minio/health/live"]
interval: 10s
timeout: 5s
retries: 10
networks: [backend]
security_opt: [no-new-privileges:true]
deploy:
resources:
limits: {cpus: "0.75", memory: 1G}
api:
build:
context: .
dockerfile: apps/api/Dockerfile
restart: unless-stopped
environment:
DEPLOYMENT_ENVIRONMENT: production
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL}
ADMIN_TOKEN: ${ADMIN_TOKEN:?Set ADMIN_TOKEN}
CORS_ORIGINS: '["https://${SITE_DOMAIN:?Set SITE_DOMAIN}"]'
S3_ENDPOINT_URL: http://minio:9000
S3_PUBLIC_ENDPOINT_URL: https://${FILES_DOMAIN:?Set FILES_DOMAIN}
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}
OFFICIAL_RECORDS_URL: ${OFFICIAL_RECORDS_URL:-https://rf4game.de/records/region/RU/}
OFFICIAL_RECORDS_REGION: ${OFFICIAL_RECORDS_REGION:-RU}
OFFICIAL_RECORDS_CATEGORY: ${OFFICIAL_RECORDS_CATEGORY:-records}
OFFICIAL_IMPORT_REQUIRED: ${OFFICIAL_IMPORT_REQUIRED:-false}
IMPORT_INTERVAL_SECONDS: ${IMPORT_INTERVAL_SECONDS:-3600}
RATE_LIMIT_SECRET: ${RATE_LIMIT_SECRET:?Set RATE_LIMIT_SECRET}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
depends_on:
db: {condition: service_healthy}
minio: {condition: service_healthy}
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/ready')"]
interval: 10s
timeout: 5s
retries: 12
networks: [backend, edge]
security_opt: [no-new-privileges:true]
deploy:
resources:
limits: {cpus: "1.00", memory: 1G}
web:
build: ./apps/web
restart: unless-stopped
environment:
PUBLIC_API_URL: https://${SITE_DOMAIN:?Set SITE_DOMAIN}
API_INTERNAL_URL: http://api:8000
depends_on:
api: {condition: service_healthy}
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://localhost:4321').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 10s
timeout: 5s
retries: 10
networks: [backend]
security_opt: [no-new-privileges:true]
deploy:
resources:
limits: {cpus: "0.75", memory: 512M}
networks:
edge:
backend:
internal: true
volumes:
postgres_data:
minio_data:
caddy_data:
caddy_config:
+44
View File
@@ -0,0 +1,44 @@
{
email {$ACME_EMAIL}
admin off
}
{$SITE_DOMAIN} {
encode zstd gzip
@admin path /admin/*
handle @admin {
basic_auth {
{$ADMIN_BASIC_USER} {$ADMIN_BASIC_PASSWORD_HASH}
}
reverse_proxy web:4321
}
@api path /api/* /health /ready
handle @api {
reverse_proxy api:8000
}
handle {
reverse_proxy web:4321
}
header {
-Server
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=()"
}
}
{$FILES_DOMAIN} {
encode zstd gzip
reverse_proxy minio:9000
header {
-Server
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
}
}
+77
View File
@@ -0,0 +1,77 @@
# Развёртывание закрытой альфы rf4spotter.ru
Production-контур рассчитан на один Linux-сервер с Docker Compose. Наружу публикуются только Caddy `80/443`; PostgreSQL, FastAPI и MinIO не имеют host-портов. Административные страницы защищены одновременно Caddy Basic Auth и API bearer token.
## 1. DNS и сервер
Создайте A-записи `rf4spotter.ru` и `files.rf4spotter.ru` на публичный IPv4 сервера. При наличии рабочего IPv6 добавьте AAAA для обоих имён. До запуска убедитесь, что извне доступны TCP 80/443 и UDP 443; SSH ограничьте своим IP или VPN. Порты 4321, 8000, 9000, 9001 и 5432 открывать нельзя.
Минимум для закрытой альфы: 2 vCPU, 4 ГБ RAM и 40 ГБ SSD. Рекомендуется 4 vCPU, 8 ГБ RAM и отдельное внешнее место для резервных копий.
## 2. Секреты
На сервере:
```bash
cp .env.production.example .env.production
chmod 600 .env.production
openssl rand -base64 36 # повторить для пароля БД, ADMIN_TOKEN, RATE_LIMIT_SECRET и S3_SECRET_KEY
docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'ОТДЕЛЬНЫЙ ADMIN-ПАРОЛЬ'
```
Заполните `.env.production`. Если пароль PostgreSQL содержит специальные символы, в `DATABASE_URL` нужна URL-кодированная форма того же пароля. `.env.production` нельзя коммитить или пересылать вместе с логами.
## 3. Проверка и первый запуск
```bash
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 up -d
docker compose --env-file .env.production -f compose.production.yaml ps
curl -fsS https://rf4spotter.ru/health
curl -fsS https://rf4spotter.ru/ready
```
API-контейнер перед стартом применяет Alembic-миграции и запускает seed. До альфы демонстрационные уловы следует убрать отдельной задачей; справочники должны остаться.
Проверка TLS и маршрутизации:
```bash
curl -I https://rf4spotter.ru/
curl -I https://files.rf4spotter.ru/minio/health/live
```
MinIO health URL допустим для диагностики, но Console наружу не публикуется. Объекты доступны только по временным подписанным ссылкам.
## 4. Первичные данные
Официальный импорт запускается вручную после успешного readiness:
```bash
docker compose --env-file .env.production -f compose.production.yaml exec api python -m app.cli import-records
```
Автоматический scheduler не входит в production-файл. RF4MAP и RF4 Posts нельзя опрашивать чаще одного раза в 30 минут; до отдельной эксплуатационной задачи используйте только контролируемые ручные запуски и staging.
## 5. Обновление
```bash
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 up -d
docker compose --env-file .env.production -f compose.production.yaml ps
curl -fsS https://rf4spotter.ru/ready
```
Перед обновлением со сменой схемы обязателен backup PostgreSQL. Не удаляйте volumes и не используйте `down -v`.
## 6. Что ещё блокирует приглашение альфа-пользователей
- проверенный backup/restore PostgreSQL и MinIO;
- production-safe seed без демонстрационных уловов;
- одноразовый секрет для повторной загрузки скриншота вместо одного UUID;
- проверка полного bootstrap на пустых volumes;
- политика хранения и удаления пользовательских данных;
- базовый мониторинг `/ready`, диска и срока TLS-сертификата.
До закрытия этих пунктов контур можно поднять для технической проверки домена, но не следует открывать форму реальным пользователям.
+1 -1
View File
@@ -79,7 +79,7 @@
- [ ] Добавить smoke-проверку административной очереди внешних источников на desktop/mobile без публикации реальных записей.
- [ ] Обновить README: архитектура, все переменные окружения, импорт, модерация, backup/restore, эксплуатация логов и известные ограничения.
- [ ] Выбрать лицензию кода и политику использования данных.
- [ ] Определить production-профиль Compose/развёртывания: TLS/reverse proxy, домены, CORS, volumes, restart policy, resource limits и порядок обновления миграций.
- [ ] Завершить production-профиль для `rf4spotter.ru`: Compose, Caddy/TLS, закрытые внутренние сервисы, CORS, resource limits, fail-fast секреты и runbook добавлены; остаются backup/restore и проверка на целевом сервере.
- [ ] Заменить демонстрационные секреты и определить целевое размещение перед внешней публикацией.
## Источники данных и согласование