feat: add production deployment for rf4spotter.ru
This commit is contained in:
@@ -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
|
||||
@@ -2,6 +2,7 @@ __pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.cache/
|
||||
.env.production
|
||||
.venv/
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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__})
|
||||
|
||||
@@ -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})
|
||||
@@ -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:
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 и проверка на целевом сервере.
|
||||
- [ ] Заменить демонстрационные секреты и определить целевое размещение перед внешней публикацией.
|
||||
|
||||
## Источники данных и согласование
|
||||
|
||||
Reference in New Issue
Block a user