From 9a5fcce8cc9fa83b4c1b84d2b32bfa1c84df7583 Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Fri, 9 Oct 2026 19:48:52 +0700 Subject: [PATCH] up --- Makefile | 36 ++++++---- README.md | 85 ++++++++++++++++++++++- tests/venue/README.md | 14 ++++ tests/venue/deploy.py | 52 ++++++++++++++ tests/venue/smoke.py | 110 ++++++++++++++++++++++++++++++ venue/.dockerignore | 7 ++ venue/.gitignore | 6 ++ venue/Dockerfile.backend | 5 ++ venue/deploy.sh | 61 +++++++++++++++++ venue/docker-compose.yml | 143 +++++++++++++++++++++++++++++++++++++++ venue/nginx.conf | 17 +++++ 11 files changed, 522 insertions(+), 14 deletions(-) create mode 100644 tests/venue/README.md create mode 100644 tests/venue/deploy.py create mode 100644 tests/venue/smoke.py create mode 100644 venue/.dockerignore create mode 100644 venue/.gitignore create mode 100644 venue/Dockerfile.backend create mode 100644 venue/deploy.sh create mode 100644 venue/docker-compose.yml create mode 100644 venue/nginx.conf diff --git a/Makefile b/Makefile index ef2c706..2e806a5 100644 --- a/Makefile +++ b/Makefile @@ -1,20 +1,30 @@ -.PHONY: check build prod-build publish +.PHONY: check build prod-build publish venue venue-update + +PROD_DIR ?= prod build: $(MAKE) -C evening_detective_server build-linux - npm --prefix evening_detective_frontend run build + @test -n "$(PROD_DIR)" + @set -e; for dir in "$(PROD_DIR)" venue; do \ + mkdir -p "$$dir/backend" "$$dir/frontend/dist"; \ + rm -rf "$$dir/migrations"; \ + mkdir -p "$$dir/migrations"; \ + cp evening_detective_server/bin/evening_detective_server "$$dir/backend/evening_detective_server"; \ + cp evening_detective_server/migrations/*.sql "$$dir/migrations/"; \ + done + CGO_ENABLED=0 GOBIN="$(abspath venue/backend)" go install github.com/pressly/goose/v3/cmd/goose@v3.27.1 + cp evening_detective_server/scripts/dev-user.sql venue/backend/dev-user.sql + npm --prefix evening_detective_frontend run type-check + npm --prefix evening_detective_frontend run build-only -- --outDir "$(abspath $(PROD_DIR)/frontend/dist)" --emptyOutDir + VITE_API_URL=/. npm --prefix evening_detective_frontend run build-only -- --outDir "$(abspath venue/frontend/dist)" --emptyOutDir -prod-build: - rm -rf prod/backend prod/frontend/dist prod/migrations - mkdir -p prod/backend prod/frontend/dist prod/migrations - - $(MAKE) -C evening_detective_server build-linux - cp evening_detective_server/bin/evening_detective_server prod/backend/evening_detective_server - - npm --prefix evening_detective_frontend run build - cp -R evening_detective_frontend/dist/. prod/frontend/dist/ - - cp evening_detective_server/migrations/*.sql prod/migrations/ +prod-build: build + +venue: + bash venue/deploy.sh + +venue-update: + bash venue/deploy.sh --build publish: @test -n "$(MSG)" || (echo 'Usage: make publish MSG="commit message"' >&2; exit 1) diff --git a/README.md b/README.md index bac54df..40609f9 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,75 @@ make dev-user По умолчанию создаётся пользователь `dev@example.com` с паролем `dev123456`. Для изменения используйте `DEV_USER_EMAIL` и `DEV_USER_PASSWORD`. +## Автономная площадка (ED-029, этапы 1–2) + +`venue/` — окружение для проведения игры на площадке без интернета. +Локальная разработка описана выше и запускается отдельно. + +Из корня workspace запустите: + +```sh +make venue +``` + +Первый запуск с интернетом сам создаёт настройки и случайные секреты в +`venue/.env`, готовит образы, запускает миграции и создаёт организатора. +Повторные запуски используют готовое окружение без интернета. +Email и пароль для входа указаны в `VENUE_USER_EMAIL` и `VENUE_USER_PASSWORD` +в `venue/.env`; существующий файл настроек не перезаписывается. +Аккаунт организатора при запуске синхронизируется с этими значениями. + +После изменения кода обновите площадку, пока интернет доступен: + +```sh +make venue-update +``` + +Сайт доступен по `http://localhost:8088`; API и материалы обслуживаются тем же +прокси. Для подключения по LAN задайте `VENUE_BIND_IP=0.0.0.0` и +`APP_BASE_URL=http://<постоянный-IP-ноутбука>:8088` в `venue/.env`, затем +повторите `make venue`. Прежние домены и доверенный HTTPS +требуют отдельной настройки DNS и сертификатов на этапе 3. + +Образы приложения, мигратора и прокси готовятся заранее. Runtime запускается +без сборки и скачивания; приложение, база и RustFS подключены только к +внутренней Docker-сети. Прокси дополнительно подключён к сети публикации порта. +База и RustFS сохраняются в именованных томах. +Миграции Goose выполняются до старта приложения. Для остановки без потери данных: + +```sh +docker compose -f venue/docker-compose.yml --env-file venue/.env down +``` + +Не используйте `down -v`: это удаляет данные. После изменений кода или миграций +выполните `make venue-update`, пока интернет доступен. Секреты, сборка, +сертификаты и пакеты игры игнорируются Git в `venue/`. + +`APP_MODE=venue` отключает SMTP, фоновые письма участия/отзывов и внешний webhook +регистрации. Аккаунты готовятся через bootstrap; почтовая регистрация аккаунта +и сброс пароля возвращают явную ошибку без изменения данных. Команды создаёт +организатор на странице игры; участие подтверждается по существующим правилам: +при создании в последние 24 часа до игры или при первом игровом действии. +Для дополнительных операторов используйте отдельные локальные email +и пароли в `.env` и снова выполните `make venue` (старые аккаунты сохраняются). +Используйте только тестовые игры до реализации возврата результатов онлайн. + +Проверки для разработчика находятся в `tests/venue/` и не нужны для запуска +площадки. `deploy.py` проверяет сценарии запуска без Docker; `smoke.py` проверяет +работающее окружение и создаёт тестовый сценарий, игру, команду и материал через API: + +```sh +python3 tests/venue/deploy.py +python3 tests/venue/smoke.py +docker compose -f venue/docker-compose.yml --env-file venue/.env down +docker compose -f venue/docker-compose.yml --env-file venue/.env up -d --no-build --pull never +python3 tests/venue/smoke.py --verify +``` + +Последняя команда проверяет ранее созданные записи и файл после перезапуска. +Тестовые данные остаются в локальной базе; повторное создание запускайте только +когда нужен новый набор. Дождитесь ответа `/api/test/ping` перед проверкой. + ## Проверки перед изменениями Сервер: @@ -93,12 +162,26 @@ make publish MSG="Исправить подсчёт ходов в игре" ``` Команда вызывает локальные build-команды репозиториев, создаёт коммит с -указанным сообщением и выполняет `git push`. Для одной только сборки используйте: +указанным сообщением и выполняет `git push`. + +Общая команда собирает бэкенд один раз и сохраняет артефакты в обе папки: +`prod/` и `venue/`. Обе используют `backend/evening_detective_server`, +`frontend/dist/` и `migrations/`; Compose называется `docker-compose.yml`, +образ бэкенда описан в `Dockerfile.backend`. Фронтенд собирается отдельно для +каждого окружения: production сохраняет текущие Vite-настройки, venue использует +API через свой прокси (`VITE_API_URL=/.`). Локальные Goose и SQL подготовки +аккаунта лежат в `venue/backend/`. + +Для сборки артефактов обоих окружений используйте: ```sh make build ``` +`make prod-build` выполняет ту же общую сборку. `make venue-update` вызывает её +и дополнительно подготавливает Docker-образы для автономного запуска. Команды +обновляют только генерируемые артефакты в `prod`; конфигурация `prod` не меняется. + ## Окружение и секреты Файлы `.env` предназначены только для локальных значений и не коммитятся. diff --git a/tests/venue/README.md b/tests/venue/README.md new file mode 100644 index 0000000..a319093 --- /dev/null +++ b/tests/venue/README.md @@ -0,0 +1,14 @@ +# Проверки площадки для разработчика + +Для работы площадки эти файлы не нужны. Обычный запуск: `make venue`. + +- `deploy.py` — проверяет первый запуск, запуск без интернета и обновление без Docker. +- `smoke.py` — проверяет работающую площадку через API. Без `--verify` создаёт + тестовые данные; с `--verify` проверяет ранее созданные данные. + +Команды из корня проекта: + +```sh +python3 tests/venue/deploy.py +python3 tests/venue/smoke.py --verify +``` diff --git a/tests/venue/deploy.py b/tests/venue/deploy.py new file mode 100644 index 0000000..b4d78a0 --- /dev/null +++ b/tests/venue/deploy.py @@ -0,0 +1,52 @@ +"""Check first, offline and update runs without Docker or production writes.""" + +from pathlib import Path +import os +import shutil +import subprocess +import tempfile + + +source = Path(__file__).resolve().parents[2] / "venue" / "deploy.sh" +for name, cold, update in [("first", True, False), ("offline", False, False), ("update", False, True)]: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + venue = root / "venue" + venue.mkdir() + shutil.copy(source, venue / "deploy.sh") + log = root / "commands.log" + bin_dir = root / "bin" + bin_dir.mkdir() + for command in ["docker", "make", "npm"]: + stub = bin_dir / command + stub.write_text( + '#!/bin/sh\n' + 'echo "$(basename "$0") $*" >> "$TEST_LOG"\n' + 'if [ "$1 $2" = "image inspect" ] && [ "$TEST_COLD" = 1 ]; then exit 1; fi\n' + ) + stub.chmod(0o755) + if not cold: + (venue / "migrations").mkdir() + (venue / ".env").write_text("EXISTING_SECRET=unchanged\n") + for artifact in ["backend/evening_detective_server", "backend/goose", "backend/dev-user.sql", "frontend/dist/index.html"]: + path = venue / artifact + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + env = {**os.environ, "PATH": str(bin_dir) + os.pathsep + os.environ["PATH"], "TEST_LOG": str(log), "TEST_COLD": str(int(cold))} + subprocess.run(["bash", str(venue / "deploy.sh"), *(["--build"] if update else [])], env=env, check=True, capture_output=True) + commands = log.read_text() + assert "volume create evening-detective-local_postgres_data" in commands, name + assert "volume create evening-detective-local_rustfs_data" in commands, name + assert "compose up -d --no-build --pull never" in commands, name + assert "compose --profile setup run --rm bootstrap" in commands, name + assert ("make -C .. build" in commands) == (cold or update), name + assert ("compose pull" in commands) == (cold or update), name + assert ("compose build --pull=false" in commands) == (cold or update), name + if cold: + config = dict(line.split("=", 1) for line in (venue / ".env").read_text().splitlines()) + assert len(config["JWT_SECRET"]) == 64 + assert len(config["VENUE_USER_PASSWORD"]) == 16 + assert (venue / ".env").stat().st_mode & 0o777 == 0o600 + else: + assert (venue / ".env").read_text() == "EXISTING_SECRET=unchanged\n", name +print("PASS: automatic first setup, offline reuse, explicit update and preserved secrets") diff --git a/tests/venue/smoke.py b/tests/venue/smoke.py new file mode 100644 index 0000000..c426647 --- /dev/null +++ b/tests/venue/smoke.py @@ -0,0 +1,110 @@ +"""Create a venue test game, then verify persistence with --verify after restart.""" + +import base64 +import datetime +import json +from pathlib import Path +import sys +import urllib.error +import urllib.request + + +root = Path(__file__).resolve().parents[2] / "venue" +assert (root / "backend" / "evening_detective_server").is_file(), "Backend artifact missing" +assert (root / "backend" / "goose").is_file(), "Migration tool missing" +assert (root / "backend" / "dev-user.sql").is_file(), "Account bootstrap SQL missing" +assert (root / "frontend" / "dist" / "index.html").is_file(), "Frontend artifact missing" +assert list((root / "migrations").glob("*.sql")), "Migrations missing" +config = dict( + line.split("=", 1) + for line in (root / ".env").read_text().splitlines() + if line and not line.startswith("#") +) +bind = config.get("VENUE_BIND_IP", "127.0.0.1") +if bind == "0.0.0.0": + bind = "127.0.0.1" +base = f'http://{bind}:{config.get("VENUE_PORT", "8088")}' +token = "" +opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + + +def request(path, body=None, raw=False): + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = "Bearer " + token + req = urllib.request.Request( + base + path, + data=None if body is None else json.dumps(body).encode(), + headers=headers, + ) + with opener.open(req, timeout=10) as response: + data = response.read() + return data if raw else json.loads(data) + + +def verify(state): + game = request(f'/api/games/{state["gameID"]}')["game"] + teams = game["teams"] + assert any( + team["ID"] == state["teamID"] + and team["participationStatus"] == "confirmed" + for team in teams + ), "Test team or confirmed participation was not preserved" + assert request("/api/files/" + state["filename"], raw=True) == base64.b64decode( + state["data"] + ), "Uploaded material was not preserved" + assert b'
' in request("/games", raw=True), "SPA route failed" + + +try: + token = request( + "/api/auth/login", + { + "email": config["VENUE_USER_EMAIL"], + "password": config["VENUE_USER_PASSWORD"], + }, + )["accessToken"] + state_file = root / "packages" / "smoke.json" + if "--verify" in sys.argv: + state = json.loads(state_file.read_text()) + else: + scenario = request("/api/scenario", {"name": "TEST venue smoke"})["ID"] + start = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(hours=2) + game = request( + "/api/games", + { + "name": "TEST venue smoke", + "description": "Disposable offline environment check", + "startAt": start.isoformat(), + "scenarioID": scenario, + "format": "online", + }, + )["ID"] + team = request( + "/api/teams", + {"gameID": game, "name": "TEST team", "participantsCount": 2}, + )["ID"] + data = base64.b64encode(b"%PDF-1.4\n%venue-smoke\n%%EOF\n").decode() + filename = request( + "/api/files/upload", {"filename": "venue-smoke.pdf", "data": data} + )["filename"] + state = {"gameID": game, "teamID": team, "filename": filename, "data": data} + state_file.parent.mkdir(exist_ok=True) + state_file.write_text(json.dumps(state)) + verify(state) + for path, body in [ + ("/api/auth/signup", {"username": "Test", "email": "test@venue.invalid", "acceptTerms": True, "acceptPrivacy": True}), + ("/api/auth/refresh-password", {"email": config["VENUE_USER_EMAIL"]}), + ]: + try: + request(path, body) + except urllib.error.HTTPError as error: + assert error.code == 400, "Unexpected disabled operation status" + assert json.loads(error.read())["code"] == 9, "Expected FailedPrecondition" + else: + raise AssertionError("Email-based account operation succeeded locally") + print("PASS: login, test game, confirmed team, material, SPA and disabled email operations") +except urllib.error.HTTPError as error: + # Do not print response bodies: authentication responses may contain credentials. + print(f"FAIL: venue API returned HTTP {error.code}", file=sys.stderr) + sys.exit(1) diff --git a/venue/.dockerignore b/venue/.dockerignore new file mode 100644 index 0000000..c4ac537 --- /dev/null +++ b/venue/.dockerignore @@ -0,0 +1,7 @@ +* +!Dockerfile.backend +!backend/ +!backend/evening_detective_server +!backend/goose +!migrations/ +!migrations/*.sql diff --git a/venue/.gitignore b/venue/.gitignore new file mode 100644 index 0000000..0adf1cd --- /dev/null +++ b/venue/.gitignore @@ -0,0 +1,6 @@ +.env +backend/ +frontend/dist/ +migrations/ +certificates/ +packages/ diff --git a/venue/Dockerfile.backend b/venue/Dockerfile.backend new file mode 100644 index 0000000..f14de3e --- /dev/null +++ b/venue/Dockerfile.backend @@ -0,0 +1,5 @@ +FROM alpine:3.22 +COPY backend/evening_detective_server /usr/local/bin/evening_detective_server +COPY backend/goose /usr/local/bin/goose +COPY migrations /migrations +CMD ["/usr/local/bin/evening_detective_server"] diff --git a/venue/deploy.sh b/venue/deploy.sh new file mode 100644 index 0000000..59f7988 --- /dev/null +++ b/venue/deploy.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd -- "$(dirname -- "${BASH_SOURCE[0]}")" +if [[ ! -f .env ]]; then + command -v openssl >/dev/null + ( + umask 077 + cat > .env <&2; exit 1 ;; +esac +for image in evening-detective-venue-app:1 postgres:15 rustfs/rustfs:latest byjg/static-httpserver:latest nginx:alpine; do + if ! docker image inspect "$image" >/dev/null 2>&1; then prepare=true; fi +done +for artifact in backend/evening_detective_server backend/goose backend/dev-user.sql frontend/dist/index.html; do + if [[ ! -f "$artifact" ]]; then prepare=true; fi +done +if [[ ! -d migrations ]]; then prepare=true; fi +if [[ "$prepare" == true ]]; then + echo 'Подготовка площадки: потребуется интернет.' + if ! docker image inspect my-go-builder >/dev/null 2>&1; then + make -C ../evening_detective_server build-builder + fi + if [[ ! -d ../evening_detective_frontend/node_modules ]]; then + npm --prefix ../evening_detective_frontend ci + fi + make -C .. build + docker compose pull postgres_evening_detective_server rustfs_evening_detective_server evening_detective_frontend proxy + docker pull alpine:3.22 + docker compose build --pull=false +fi +for volume in evening-detective-local_postgres_data evening-detective-local_rustfs_data; do + docker volume create "$volume" >/dev/null +done +docker compose up -d --no-build --pull never +docker compose --profile setup run --rm bootstrap +docker compose ps +echo 'Площадка запущена. Адрес и данные входа находятся в venue/.env.' diff --git a/venue/docker-compose.yml b/venue/docker-compose.yml new file mode 100644 index 0000000..e6c9418 --- /dev/null +++ b/venue/docker-compose.yml @@ -0,0 +1,143 @@ +name: evening-detective-venue + +x-app: &app + image: evening-detective-venue-app:1 + build: + context: . + dockerfile: Dockerfile.backend + environment: + APP_MODE: venue + DATABASE_URL: postgresql://${POSTGRES_USER:?}:${POSTGRES_PASSWORD:?}@postgres_evening_detective_server:5432/${POSTGRES_DB:?}?sslmode=disable + APP_BASE_URL: ${APP_BASE_URL:?} + FILE_PREFIX_DOMAIN: ${APP_BASE_URL:?}/api/files/ + GAME_TIMEZONE: ${GAME_TIMEZONE:-Asia/Barnaul} + JWT_SECRET: ${JWT_SECRET:?} + PARTICIPATION_SECRET: ${PARTICIPATION_SECRET:?} + S3_HOST: http://rustfs_evening_detective_server:9000 + S3_USER: ${S3_USER:?} + S3_PASSWORD: ${S3_PASSWORD:?} + S3_BUCKET: ${S3_BUCKET:-evening-detective} + +services: + postgres_evening_detective_server: + image: postgres:15 + environment: + POSTGRES_DB: ${POSTGRES_DB:?} + POSTGRES_USER: ${POSTGRES_USER:?} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?} + TZ: ${GAME_TIMEZONE:-Asia/Barnaul} + PGTZ: ${GAME_TIMEZONE:-Asia/Barnaul} + volumes: + - postgres_data:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: [CMD-SHELL, 'pg_isready -U "$${POSTGRES_USER}" -d "$${POSTGRES_DB}" -h localhost'] + interval: 5s + timeout: 3s + retries: 12 + + rustfs_evening_detective_server: + image: rustfs/rustfs:latest + environment: + RUSTFS_ACCESS_KEY: ${S3_USER:?} + RUSTFS_SECRET_KEY: ${S3_PASSWORD:?} + RUSTFS_CONSOLE_ENABLE: "false" + RUSTFS_ADDRESS: 0.0.0.0:9000 + volumes: + - rustfs_data:/data + restart: unless-stopped + healthcheck: + test: [CMD-SHELL, 'curl -f http://localhost:9000/health'] + interval: 5s + timeout: 3s + retries: 12 + start_period: 30s + + migrate: + <<: *app + entrypoint: [/bin/sh, -ec] + command: ['exec goose -dir /migrations postgres "$$DATABASE_URL" up'] + depends_on: + postgres_evening_detective_server: + condition: service_healthy + restart: "no" + + evening_detective_server: + <<: *app + depends_on: + migrate: + condition: service_completed_successfully + rustfs_evening_detective_server: + condition: service_healthy + restart: unless-stopped + healthcheck: + test: [CMD, wget, -q, -T, '2', -O, /dev/null, 'http://127.0.0.1:8090/api/test/ping'] + interval: 5s + timeout: 3s + retries: 12 + + bootstrap: + image: postgres:15 + profiles: [setup] + environment: + PGHOST: postgres_evening_detective_server + PGUSER: ${POSTGRES_USER:?} + PGPASSWORD: ${POSTGRES_PASSWORD:?} + PGDATABASE: ${POSTGRES_DB:?} + VENUE_USER_USERNAME: ${VENUE_USER_USERNAME:-Организатор} + VENUE_USER_EMAIL: ${VENUE_USER_EMAIL:-organizer@venue.invalid} + VENUE_USER_PASSWORD: ${VENUE_USER_PASSWORD:?} + volumes: + - ./backend/dev-user.sql:/bootstrap.sql:ro + entrypoint: [/bin/sh, -ec] + command: + - >- + exec psql -v ON_ERROR_STOP=1 + -v dev_username="$$VENUE_USER_USERNAME" + -v dev_email="$$VENUE_USER_EMAIL" + -v dev_password="$$VENUE_USER_PASSWORD" + -f /bootstrap.sql + depends_on: + migrate: + condition: service_completed_successfully + restart: "no" + + evening_detective_frontend: + image: byjg/static-httpserver:latest + environment: + SPA_MODE: "true" + volumes: + - ./frontend/dist:/static:ro + expose: + - "8080" + restart: unless-stopped + + proxy: + image: nginx:alpine + volumes: + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + ports: + - '${VENUE_BIND_IP:-0.0.0.0}:${VENUE_PORT:-8088}:80' + depends_on: + evening_detective_server: + condition: service_healthy + evening_detective_frontend: + condition: service_started + networks: + - default + - entrance + restart: unless-stopped + +volumes: + # Preserve existing data from the former Compose project. + postgres_data: + name: evening-detective-local_postgres_data + external: true + rustfs_data: + name: evening-detective-local_rustfs_data + external: true + +networks: + default: + internal: true + entrance: diff --git a/venue/nginx.conf b/venue/nginx.conf new file mode 100644 index 0000000..0a1febb --- /dev/null +++ b/venue/nginx.conf @@ -0,0 +1,17 @@ +server { + listen 80; + server_name _; + client_max_body_size 257m; + + location /api/ { + proxy_pass http://evening_detective_server:8090; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_read_timeout 120s; + } + + location / { + proxy_pass http://evening_detective_frontend:8080; + proxy_set_header Host $host; + } +}