У админки появился собственный сервис, а не только место в плане

Каркас FastAPI живёт в admin/ и собирается отдельным образом: главная страница со списком будущих разделов и /healthz для healthcheck, пока без БД и логина. Сервис добавлен в оба compose (порт только на loopback до появления домена), CI теперь собирает и публикует два образа — бота и админку. Настройки админки наследуют общие, но не требуют BOT_TOKEN: сервис запускается, даже пока токен не заполнен.
This commit is contained in:
MpakobecEx 2026-10-19 03:29:51 +05:00
parent 16bd8c06b5
commit b11676764e
24 changed files with 387 additions and 17 deletions

View File

@ -2,6 +2,10 @@
.gitignore
.gitattributes
# секреты локального окружения (в образ не попадают)
.env
.env.*
# venv / кеши
.venv
.venv11

View File

@ -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

View File

@ -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

View File

@ -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 ограниченное время: раз в сутки домашний сервер **сам забирает**

View File

@ -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`.

29
admin/Dockerfile Normal file
View File

@ -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"]

4
admin/__init__.py Normal file
View File

@ -0,0 +1,4 @@
"""Сервис админки DispelEvilBot (FastAPI + HTMX).
Отдельный процесс/образ; общий код — пакет ``common``. Запуск: ``python -m admin``.
"""

31
admin/__main__.py Normal file
View File

@ -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()

40
admin/app.py Normal file
View File

@ -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

1
admin/routes/__init__.py Normal file
View File

@ -0,0 +1 @@
"""Роутеры админки."""

16
admin/routes/health.py Normal file
View File

@ -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}

34
admin/routes/pages.py Normal file
View File

@ -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,
},
)

24
admin/templates/base.html Normal file
View File

@ -0,0 +1,24 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% block title %}Админка{% endblock %} — DispelEvilBot</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script>
</head>
<body class="min-h-screen bg-slate-100 text-slate-900">
<header class="border-b border-slate-200 bg-white">
<div class="mx-auto flex max-w-5xl items-center justify-between px-4 py-4">
<a href="/" class="text-lg font-semibold">DispelEvilBot</a>
<span class="text-sm text-slate-500">админка · v{{ version }}</span>
</div>
</header>
<main class="mx-auto max-w-5xl px-4 py-8">
{% block content %}{% endblock %}
</main>
<footer class="mx-auto max-w-5xl px-4 pb-8 text-xs text-slate-400">DispelEvilBot v{{ version }}</footer>
</body>
</html>

View File

@ -0,0 +1,23 @@
{% extends "base.html" %}
{% block title %}Главная{% endblock %}
{% block content %}
<h1 class="text-2xl font-semibold">Панель администрирования</h1>
<p class="mt-2 text-slate-600">
Окружение: <span class="font-mono">{{ app_env }}</span>. Это каркас админки: разделы
появятся по мере готовности (Блок 4.1–4.3).
</p>
<div class="mt-8 grid gap-4 sm:grid-cols-2">
{% for key, title, description in sections %}
<section class="rounded-lg border border-slate-200 bg-white p-5">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium">{{ title }}</h2>
<span class="rounded bg-slate-100 px-2 py-0.5 text-xs text-slate-500">скоро</span>
</div>
<p class="mt-2 text-sm text-slate-600">{{ description }}</p>
</section>
{% endfor %}
</div>
{% endblock %}

View File

@ -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]

View File

@ -7,4 +7,4 @@
from __future__ import annotations
__version__ = "0.3.9"
__version__ = "0.4.0"

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

15
requirements-admin.txt Normal file
View File

@ -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

View File

@ -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

30
tests/test_admin.py Normal file
View File

@ -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

View File

@ -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"