diff --git a/.dockerignore b/.dockerignore index ea158e2..9acfd19 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,10 @@ .gitignore .gitattributes +# секреты локального окружения (в образ не попадают) +.env +.env.* + # venv / кеши .venv .venv11 diff --git a/.env.example b/.env.example index cbecffc..3c5fab6 100644 --- a/.env.example +++ b/.env.example @@ -42,3 +42,6 @@ CLICKHOUSE_PASSWORD= ANALYTICS_BATCH_SIZE=200 ANALYTICS_FLUSH_INTERVAL=5.0 ANALYTICS_BUFFER_MAX=5000 + +# Админка (FastAPI, Блок 4). Порт на loopback хоста для docker compose. +ADMIN_HTTP_PORT=8000 diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 22d6653..a58030d 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -13,7 +13,8 @@ concurrency: env: REGISTRY: git.mpakosrv.ru - IMAGE_NAME: mpakobecex/dispelevilbot-bot + IMAGE_NAME_BOT: mpakobecex/dispelevilbot-bot + IMAGE_NAME_ADMIN: mpakobecex/dispelevilbot-admin jobs: lint-test: @@ -40,16 +41,22 @@ jobs: - uses: actions/checkout@v4 - name: Логин в Gitea Registry run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USER }}" --password-stdin - - name: Сборка и публикация образа + - name: Сборка и публикация образов run: | set -e - IMAGE="$REGISTRY/$IMAGE_NAME" - SHA="${GITHUB_SHA:-unknown}" - SHORT_SHA="$(echo "$SHA" | cut -c1-12)" + SHORT_SHA="$(echo "${GITHUB_SHA:-unknown}" | cut -c1-12)" VERSION="$(sed -n 's/^__version__ = "\(.*\)"/\1/p' common/version.py)" - TAGS="$IMAGE:$SHORT_SHA $IMAGE:latest" - if [ -n "$VERSION" ]; then TAGS="$TAGS $IMAGE:$VERSION"; fi - ARGS="" - for t in $TAGS; do ARGS="$ARGS -t $t"; done - docker build $ARGS . - for t in $TAGS; do docker push "$t"; done + + build_push() { + image="$1" + dockerfile="$2" + tags="$image:$SHORT_SHA $image:latest" + if [ -n "$VERSION" ]; then tags="$tags $image:$VERSION"; fi + args="" + for t in $tags; do args="$args -t $t"; done + docker build $args -f "$dockerfile" . + for t in $tags; do docker push "$t"; done + } + + build_push "$REGISTRY/$IMAGE_NAME_BOT" Dockerfile + build_push "$REGISTRY/$IMAGE_NAME_ADMIN" admin/Dockerfile diff --git a/DEPLOY.md b/DEPLOY.md index 443b309..161787c 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -21,6 +21,7 @@ cp env/test.env.example env/test.env Порт ClickHouse на хосте (`CLICKHOUSE_HTTP_PORT`) у контуров должен различаться: `prod` — `8123`, `test` — `8124` (иначе контейнеры конфликтуют на одной VPS). +Порт админки (`ADMIN_HTTP_PORT`) — так же: `prod` — `8000`, `test` — `8001`. ## 3. Логин в registry (если пакет приватный) @@ -52,7 +53,8 @@ docker compose -p test --env-file env/test.env -f docker-compose.prod.yml logs - 2. `migrate` (**Flyway**) накатывает миграции из `sql/` — для пустой БД с нуля; 3. `clickhouse` поднимается и применяет `clickhouse/init/001_events.sql` — **только при первой инициализации** пустого data-каталога; -4. `bot` стартует (`Запуск DispelEvilBot v<версия> (APP_ENV=...)`), аналитика и рассылки — в нём же. +4. `bot` стартует (`Запуск DispelEvilBot v<версия> (APP_ENV=...)`), аналитика и рассылки — в нём же; +5. `admin` стартует и отвечает на `GET /healthz` (порт на loopback хоста). Проверка контура (сервисы, миграции, Postgres, ClickHouse): @@ -79,6 +81,21 @@ docker compose -p test --env-file env/test.env -f docker-compose.prod.yml exec - Изменения схемы: `clickhouse/init/*.sql` применяются лишь на пустом data-каталоге, поэтому на существующем контуре DDL нужно применить вручную (`make ch E=...`). +## Админка (FastAPI) + +Сервис `admin` — отдельный образ `mpakobecex/dispelevilbot-admin`, общий пакет `common`. +Наружу пока **не публикуется**: только loopback хоста (`127.0.0.1:${ADMIN_HTTP_PORT}`). +Домен и TLS появятся вместе с входом через Telegram (Блоки 2.4/4.2). + +```bash +# проверка живости +curl -s http://127.0.0.1:8001/healthz # test +curl -s http://127.0.0.1:8000/healthz # prod +``` + +Локально без Docker: `pip install -r requirements-admin.txt && python -m admin` +(по умолчанию `0.0.0.0:8000`, переопределяется `ADMIN_HOST`/`ADMIN_PORT`). + ## Бэкап событий (забирает домашний сервер) Сырые события живут на VPS ограниченное время: раз в сутки домашний сервер **сам забирает** diff --git a/README.md b/README.md index 637aca0..0f3a42a 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Telegram-бот клана Dispel Evil (aiogram 3 + Postgres). Монорепо ``` common/ # общий пакет: config, logging, db, repositories, models, texts, analytics bot/ # сервис бота: handlers, services, keyboards, middlewares, notifications +admin/ # сервис админки: FastAPI + Jinja2/HTMX (Блок 4), свой Dockerfile sql/ # миграции Flyway (V__*.sql, R__*.sql) clickhouse/ # init-скрипты ClickHouse (схема аналитики) docs/ # документация (docs/events.md — события аналитики) @@ -43,6 +44,9 @@ APP_ENV=prod python -m bot # prod или через `./start.sh` (режим берётся из `APP_ENV`). +Админка (FastAPI) запускается отдельным процессом: `python -m admin` (зависимости — +`requirements-admin.txt`), по умолчанию слушает `0.0.0.0:8000`, проверка живучести — `GET /healthz`. + ## База данных Схема управляется миграциями **Flyway** в `sql/` (`V__*.sql` — versioned, `R__*.sql` — repeatable). @@ -66,13 +70,17 @@ APP_ENV=prod python -m bot # prod ## Docker -Локальный запуск (postgres + migrate + clickhouse + bot): +Локальный запуск (postgres + migrate + clickhouse + bot + admin): ```bash cp .env.example .env # заполнить docker compose up --build ``` +CI собирает два образа: `dispelevilbot-bot` и `dispelevilbot-admin`. Админка в compose +публикуется только на loopback (`127.0.0.1:${ADMIN_HTTP_PORT}`), наружу выйдет вместе +с доменом и TLS (Блок 2.4/4.2). + Прод/test-контуры — один `docker-compose.prod.yml`, разделяются Compose-проектом и env-файлом: ```bash @@ -104,7 +112,7 @@ make backup E=test # на домашнем сервере: ./scripts/clickho ## Версионирование -- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.3.9`. +- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.4.0`. - Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`). - Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3). - Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`. diff --git a/admin/Dockerfile b/admin/Dockerfile new file mode 100644 index 0000000..effa427 --- /dev/null +++ b/admin/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1 +# Образ сервиса админки (FastAPI + Jinja2/HTMX). +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +# Непривилегированный пользователь +RUN useradd --create-home --uid 1000 app + +COPY requirements-admin.txt ./ +RUN pip install --no-cache-dir -r requirements-admin.txt + +COPY common ./common +COPY admin ./admin + +USER app + +EXPOSE 8000 + +# Живость процесса: HTTP-проверка /healthz (порт берётся из ADMIN_PORT). +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD python -c "import os,urllib.request; urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('ADMIN_PORT','8000')+'/healthz', timeout=3)" + +CMD ["python", "-m", "admin"] diff --git a/admin/__init__.py b/admin/__init__.py new file mode 100644 index 0000000..6592a4e --- /dev/null +++ b/admin/__init__.py @@ -0,0 +1,4 @@ +"""Сервис админки DispelEvilBot (FastAPI + HTMX). + +Отдельный процесс/образ; общий код — пакет ``common``. Запуск: ``python -m admin``. +""" diff --git a/admin/__main__.py b/admin/__main__.py new file mode 100644 index 0000000..d8adde1 --- /dev/null +++ b/admin/__main__.py @@ -0,0 +1,31 @@ +"""Точка входа админки: python -m admin""" + +from __future__ import annotations + +import logging + +import uvicorn + +from common.config import get_admin_settings +from common.logging_config import setup_logging + +logger = logging.getLogger(__name__) + + +def main() -> None: + """Запустить uvicorn с приложением из ``admin.app``.""" + setup_logging(logging.INFO) + settings = get_admin_settings() + logger.info("Запуск админки (APP_ENV=%s) на %s:%s", settings.app_env, settings.admin_host, settings.admin_port) + # log_config=None — чтобы uvicorn не перетирал единый формат логов. + uvicorn.run( + "admin.app:create_app", + factory=True, + host=settings.admin_host, + port=settings.admin_port, + log_config=None, + ) + + +if __name__ == "__main__": + main() diff --git a/admin/app.py b/admin/app.py new file mode 100644 index 0000000..89378ab --- /dev/null +++ b/admin/app.py @@ -0,0 +1,40 @@ +"""Создание FastAPI-приложения админки.""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI +from fastapi.templating import Jinja2Templates + +from admin.routes import health, pages +from common import __version__ +from common.config import AdminSettings, get_admin_settings +from common.logging_config import setup_logging + +BASE_DIR = Path(__file__).resolve().parent +TEMPLATES_DIR = BASE_DIR / "templates" + + +def create_app(settings: AdminSettings | None = None) -> FastAPI: + """Собрать приложение: настройки, шаблоны, роутеры. + + Настройки можно передать явно (тесты); по умолчанию читаются из окружения. + """ + setup_logging() + settings = settings or get_admin_settings() + + app = FastAPI( + title="DispelEvilBot Admin", + version=__version__, + # Swagger/OpenAPI наружу пока не нужны: админка закрыта логином (Блок 4.2). + docs_url=None, + redoc_url=None, + openapi_url=None, + ) + app.state.settings = settings + app.state.templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) + + app.include_router(health.router) + app.include_router(pages.router) + return app diff --git a/admin/routes/__init__.py b/admin/routes/__init__.py new file mode 100644 index 0000000..f4723a8 --- /dev/null +++ b/admin/routes/__init__.py @@ -0,0 +1 @@ +"""Роутеры админки.""" diff --git a/admin/routes/health.py b/admin/routes/health.py new file mode 100644 index 0000000..fc5228e --- /dev/null +++ b/admin/routes/health.py @@ -0,0 +1,16 @@ +"""Служебные эндпоинты: healthcheck для Docker и мониторинга.""" + +from __future__ import annotations + +from fastapi import APIRouter, Request + +from common import __version__ + +router = APIRouter(tags=["service"]) + + +@router.get("/healthz") +async def healthz(request: Request) -> dict[str, str]: + """Вернуть состояние сервиса (используется healthcheck'ом контейнера).""" + settings = request.app.state.settings + return {"status": "ok", "version": __version__, "app_env": settings.app_env} diff --git a/admin/routes/pages.py b/admin/routes/pages.py new file mode 100644 index 0000000..9928a36 --- /dev/null +++ b/admin/routes/pages.py @@ -0,0 +1,34 @@ +"""HTML-страницы админки (серверный рендер Jinja2 + HTMX).""" + +from __future__ import annotations + +from fastapi import APIRouter, Request +from fastapi.responses import HTMLResponse + +from common import __version__ + +router = APIRouter(tags=["pages"]) + +#: Разделы админки по плану Блока 4 (пока заглушки на главной). +SECTIONS: tuple[tuple[str, str, str], ...] = ( + ("messages", "Сообщения", "Тексты бота и предпросмотр с тегами {tag}"), + ("schedules", "Расписания", "Время рассылок, дни недели и шаблон"), + ("users", "Пользователи", "Ники, звания, баланс и подписка"), + ("logs", "Логи", "События из ClickHouse с фильтрами"), +) + + +@router.get("/", response_class=HTMLResponse) +async def index(request: Request) -> HTMLResponse: + """Главная страница: версия, окружение и список будущих разделов.""" + templates = request.app.state.templates + settings = request.app.state.settings + return templates.TemplateResponse( + request=request, + name="index.html", + context={ + "version": __version__, + "app_env": settings.app_env, + "sections": SECTIONS, + }, + ) diff --git a/admin/templates/base.html b/admin/templates/base.html new file mode 100644 index 0000000..5f68b25 --- /dev/null +++ b/admin/templates/base.html @@ -0,0 +1,24 @@ + + + + + + {% block title %}Админка{% endblock %} — DispelEvilBot + + + + +
+
+ DispelEvilBot + админка · v{{ version }} +
+
+ +
+ {% block content %}{% endblock %} +
+ + + + diff --git a/admin/templates/index.html b/admin/templates/index.html new file mode 100644 index 0000000..c794ee7 --- /dev/null +++ b/admin/templates/index.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} + +{% block title %}Главная{% endblock %} + +{% block content %} +

Панель администрирования

+

+ Окружение: {{ app_env }}. Это каркас админки: разделы + появятся по мере готовности (Блок 4.1–4.3). +

+ +
+ {% for key, title, description in sections %} +
+
+

{{ title }}

+ скоро +
+

{{ description }}

+
+ {% endfor %} +
+{% endblock %} diff --git a/common/config.py b/common/config.py index d8f56ca..92e54c9 100644 --- a/common/config.py +++ b/common/config.py @@ -105,5 +105,26 @@ class Settings(BaseSettings): @lru_cache def get_settings() -> Settings: - """Вернуть singleton настроек.""" + """Вернуть singleton настроек бота.""" return Settings() # type: ignore[call-arg] + + +class AdminSettings(Settings): + """Настройки сервиса админки. + + Наследует общие поля (окружение, Postgres, таймзона, ClickHouse), но не требует + ``BOT_TOKEN``: на этапе каркаса он не нужен, а появится в проверке подписи + Telegram-логина (Блок 4.2). + """ + + bot_token: str = "" + + #: Адрес и порт, которые слушает uvicorn внутри контейнера. + admin_host: str = "0.0.0.0" + admin_port: int = 8000 + + +@lru_cache +def get_admin_settings() -> AdminSettings: + """Вернуть singleton настроек админки.""" + return AdminSettings() # type: ignore[call-arg] diff --git a/common/version.py b/common/version.py index 13e8aef..e72b0e7 100644 --- a/common/version.py +++ b/common/version.py @@ -7,4 +7,4 @@ from __future__ import annotations -__version__ = "0.3.9" +__version__ = "0.4.0" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d05e7fc..c53373b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -62,6 +62,31 @@ services: max-size: "10m" max-file: "3" + # Админка (FastAPI + Jinja2/HTMX), образ из Gitea Registry. + # Наружу пока не публикуется: только loopback хоста; домен/TLS — Блок 2.4/4.2. + admin: + image: ${REGISTRY:-git.mpakosrv.ru}/mpakobecex/dispelevilbot-admin:${IMAGE_TAG:-latest} + restart: unless-stopped + env_file: + - ${ENV_FILE:-env/prod.env} + environment: + DB_HOST: postgres + DB_PORT: "5432" + ADMIN_HOST: 0.0.0.0 + ADMIN_PORT: "8000" + ports: + - "127.0.0.1:${ADMIN_HTTP_PORT:-8000}:8000" + depends_on: + postgres: + condition: service_healthy + migrate: + condition: service_completed_successfully + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + # Аналитика событий (docs/events.md). Наружу не публикуется: # только loopback хоста для бэкапа/туннеля; бот ходит внутрь сети compose. clickhouse: diff --git a/docker-compose.yml b/docker-compose.yml index 6a648ca..b5e7f54 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -67,6 +67,34 @@ services: max-size: "10m" max-file: "3" + # Админка (FastAPI + HTMX). Порт — только loopback: наружу выйдет через reverse proxy + # вместе с TLS и доменом (Блок 2.4/4.2). + admin: + build: + context: . + dockerfile: admin/Dockerfile + image: dispelevilbot-admin:local + restart: unless-stopped + env_file: + - .env + environment: + DB_HOST: postgres + DB_PORT: "5432" + ADMIN_HOST: 0.0.0.0 + ADMIN_PORT: "8000" + ports: + - "127.0.0.1:${ADMIN_HTTP_PORT:-8000}:8000" + depends_on: + postgres: + condition: service_healthy + migrate: + condition: service_completed_successfully + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + # Аналитика событий (Блок 3). DDL — в clickhouse/init/. clickhouse: image: clickhouse/clickhouse-server:25.3 diff --git a/env/prod.env.example b/env/prod.env.example index b75e658..52b8c13 100644 --- a/env/prod.env.example +++ b/env/prod.env.example @@ -39,3 +39,7 @@ CLICKHOUSE_USER=de_bot CLICKHOUSE_PASSWORD= # Порт на хосте (для бэкапа). У test и prod он должен различаться. CLICKHOUSE_HTTP_PORT=8123 + +# Админка (FastAPI). Порт на loopback хоста; у test и prod должен различаться. +# Наружу пока не публикуется (домен/TLS — Блок 2.4). +ADMIN_HTTP_PORT=8000 diff --git a/env/test.env.example b/env/test.env.example index 168ab93..ef897f6 100644 --- a/env/test.env.example +++ b/env/test.env.example @@ -37,3 +37,7 @@ CLICKHOUSE_USER=de_bot CLICKHOUSE_PASSWORD= # Порт на хосте: у test он отличается от prod (8123), иначе конфликт на одной VPS. CLICKHOUSE_HTTP_PORT=8124 + +# Админка (FastAPI). Порт на loopback хоста; у test и prod должен различаться. +# Наружу пока не публикуется (домен/TLS — Блок 2.4). +ADMIN_HTTP_PORT=8001 diff --git a/requirements-admin.txt b/requirements-admin.txt new file mode 100644 index 0000000..325c192 --- /dev/null +++ b/requirements-admin.txt @@ -0,0 +1,15 @@ +# Зависимости сервиса админки (FastAPI). Бот использует requirements.txt. +# Python 3.12 + +fastapi>=0.115,<1.0 +uvicorn[standard]>=0.30,<1.0 +jinja2>=3.1,<4.0 + +pydantic>=2.7,<3.0 +pydantic-settings>=2.3,<3.0 + +# Postgres (async psycopg v3) — понадобится разделам шаблонов и пользователей. +psycopg[binary,pool]>=3.2,<4.0 + +# Разбор form-data (HTMX-формы). +python-multipart>=0.0.9,<1.0 diff --git a/requirements-dev.txt b/requirements-dev.txt index 90cb7fa..8410784 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -5,3 +5,5 @@ black>=24.4,<25.0 pytest>=8.2,<9.0 pytest-asyncio>=0.23,<1.0 pytest-cov>=5.0,<6.0 +# TestClient для FastAPI-админки. +httpx>=0.27,<1.0 diff --git a/tests/test_admin.py b/tests/test_admin.py new file mode 100644 index 0000000..d462363 --- /dev/null +++ b/tests/test_admin.py @@ -0,0 +1,30 @@ +"""Тесты каркаса админки: healthcheck и главная страница (без БД и сети).""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + +from admin.app import create_app +from common.config import AdminSettings + + +def _client() -> TestClient: + settings = AdminSettings(_env_file=None, bot_token="", db_password="test", app_env="dev") + return TestClient(create_app(settings)) + + +def test_healthz() -> None: + response = _client().get("/healthz") + + assert response.status_code == 200 + payload = response.json() + assert payload["status"] == "ok" + assert payload["app_env"] == "dev" + + +def test_index_renders_sections() -> None: + response = _client().get("/") + + assert response.status_code == 200 + assert "Панель администрирования" in response.text + assert "Расписания" in response.text diff --git a/tests/test_version.py b/tests/test_version.py index 3e2765b..ddac615 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -7,4 +7,4 @@ from common import __version__ def test_version_is_set(): assert __version__ - assert __version__ == "0.3.9" + assert __version__ == "0.4.0"