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'