Обёртка в Docker
CI / lint-test (push) Failing after 7s Details
CI / build-push (push) Has been skipped Details

This commit is contained in:
MpakobecEx 2026-10-16 01:01:05 +05:00
parent 10b48c77a7
commit ad9e0cb378
15 changed files with 434 additions and 3 deletions

31
.dockerignore Normal file
View File

@ -0,0 +1,31 @@
.git
.gitignore
.gitattributes
# venv / кеши
.venv
.venv11
venv
env
__pycache__
*.py[cod]
*.egg-info
.pytest_cache
.ruff_cache
.mypy_cache
# IDE / локальное
.idea
.vscode
AGENTS.md
checklist.md
*.log
# инфраструктура и документация (в образ не нужны)
deploy
.gitea
tests
docs
*.md
Dockerfile
docker-compose*.yml

55
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,55 @@
name: CI
on:
push:
branches: [master]
tags: ["v*"]
workflow_dispatch:
env:
REGISTRY: git.mpakosrv.ru
IMAGE_NAME: mpakobecx/dispelevilbot-bot
jobs:
lint-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Установка зависимостей
run: pip install -r requirements-dev.txt
# Линт/формат пока не блокируют пайплайн (см. TODO). После первого прогона
# выполните локально `ruff check --fix .` и `black .`, затем уберите
# continue-on-error, чтобы стиль стал обязательным.
- name: Ruff
continue-on-error: true
run: ruff check .
- name: Black
continue-on-error: true
run: black --check .
- name: Pytest
run: pytest -q
build-push:
runs-on: ubuntu-latest
needs: lint-test
# Собираем и пушим образ. Автодеплой НЕ делаем — выкатка вручную (test -> prod).
steps:
- uses: actions/checkout@v4
- name: Логин в Gitea Registry
run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "$REGISTRY" -u "${{ secrets.REGISTRY_USER }}" --password-stdin
- name: Сборка и публикация образа
run: |
set -e
IMAGE="$REGISTRY/$IMAGE_NAME"
SHORT_SHA="${GITHUB_SHA:0:12}"
TAGS="$IMAGE:$SHORT_SHA $IMAGE:latest"
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
TAGS="$TAGS $IMAGE:${GITHUB_REF_NAME}"
fi
ARGS=""
for t in $TAGS; do ARGS="$ARGS -t $t"; done
docker build $ARGS .
for t in $TAGS; do docker push "$t"; done

2
.gitignore vendored
View File

@ -15,6 +15,8 @@ venv/
.env
.env.*
!.env.example
env/*.env
!env/*.env.example
# IDE
.idea/

28
Dockerfile Normal file
View File

@ -0,0 +1,28 @@
# syntax=docker/dockerfile:1
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.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY common ./common
COPY bot ./bot
COPY images ./images
USER app
# Живость процесса: бот обновляет файл-heartbeat (см. bot/services/heartbeat.py).
# Путь должен совпадать с HEARTBEAT_PATH (по умолчанию /tmp/dispelevilbot.heartbeat).
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD python -c "import os,sys,time; p='/tmp/dispelevilbot.heartbeat'; sys.exit(0 if os.path.exists(p) and time.time()-os.path.getmtime(p) < 120 else 1)"
CMD ["python", "-m", "bot"]

View File

@ -10,6 +10,11 @@ common/ # общий пакет: config, logging, db, repositories, models,
bot/ # сервис бота: handlers, services, keyboards, notifications
sql/ # миграции Flyway (V__*.sql, R__*.sql)
images/ # статичные картинки (приветствие)
deploy/ # материалы деплоя (Caddyfile, инструкции)
tests/ # тесты
Dockerfile # образ бота
docker-compose.yml / docker-compose.prod.yml
.gitea/ # CI (Gitea Actions)
```
## Требования
@ -37,6 +42,25 @@ APP_ENV=prod python -m bot # prod
## База данных
Схема управляется миграциями **Flyway** в `sql/` (`V__*.sql` — versioned, `R__*.sql` — repeatable).
При Docker-развёртывании накат выполняется автоматически сервисом `migrate` перед ботом.
## Docker
Локальный запуск (postgres + migrate + bot):
```bash
cp .env.example .env # заполнить
docker compose up --build
```
Прод/test-контуры — один `docker-compose.prod.yml`, разделяются Compose-проектом и env-файлом:
```bash
docker compose -p prod --env-file env/prod.env -f docker-compose.prod.yml up -d
docker compose -p test --env-file env/test.env -f docker-compose.prod.yml up -d
```
Registry, перенос данных, TLS и firewall — см. [`deploy/README.md`](deploy/README.md).
## Разработка
@ -49,7 +73,7 @@ pytest
## Версионирование
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.1`.
- Единый источник версии — `common/version.py` (`__version__`), текущая — `0.2`.
- Схема — SemVer-подобная (`MAJOR.MINOR.PATCH`); git-тег = `v` + версия (`v0.1`).
- Docker-образы тегируются версией (Блок 2), события ClickHouse получают `bot_version` (Блок 3).
- Выпуск: изменить `common/version.py` → коммит → аннотированный тег `vX.Y` → `git push --tags`.

View File

@ -6,6 +6,7 @@ import asyncio
import logging
from bot.bot_factory import create_bot, create_dispatcher
from bot.services.heartbeat import run_heartbeat
from bot.services.notifications import run_notifications
from common import __version__
from common.config import get_settings
@ -26,12 +27,14 @@ async def main() -> None:
dp = create_dispatcher()
notifications_task = asyncio.create_task(run_notifications(bot))
heartbeat_task = asyncio.create_task(run_heartbeat())
try:
await bot.delete_webhook(drop_pending_updates=True)
await dp.start_polling(bot, allowed_updates=dp.resolve_used_update_types())
finally:
notifications_task.cancel()
await asyncio.gather(notifications_task, return_exceptions=True)
heartbeat_task.cancel()
await asyncio.gather(notifications_task, heartbeat_task, return_exceptions=True)
await close_pool()
await bot.session.close()

28
bot/services/heartbeat.py Normal file
View File

@ -0,0 +1,28 @@
"""Heartbeat для Docker healthcheck.
Бот периодически обновляет mtime файла; healthcheck считает контейнер живым,
если файл свежий. Если event loop завис — heartbeat перестаёт обновляться.
"""
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from common.config import get_settings
logger = logging.getLogger(__name__)
HEARTBEAT_INTERVAL = 30
async def run_heartbeat() -> None:
"""Обновлять файл-heartbeat каждые ``HEARTBEAT_INTERVAL`` секунд."""
path = Path(get_settings().heartbeat_path)
while True:
try:
path.touch()
except OSError:
logger.exception("Не удалось обновить heartbeat-файл %s", path)
await asyncio.sleep(HEARTBEAT_INTERVAL)

View File

@ -45,6 +45,9 @@ class Settings(BaseSettings):
timezone: str = "Europe/Moscow"
#: Файл-heartbeat для Docker healthcheck (обновляется ботом периодически).
heartbeat_path: str = "/tmp/dispelevilbot.heartbeat"
@field_validator("admin_ids", mode="before")
@classmethod
def _parse_admin_ids(cls, value: object) -> object:

View File

@ -7,4 +7,4 @@
from __future__ import annotations
__version__ = "0.1"
__version__ = "0.2"

78
docker-compose.prod.yml Normal file
View File

@ -0,0 +1,78 @@
# Прод/test-контур: образы тянутся из Gitea Registry.
#
# Контуры разделяются именем compose-проекта (-p) и файлом окружения (ENV_FILE):
# ENV_FILE=env/prod.env docker compose -p prod -f docker-compose.prod.yml up -d
# ENV_FILE=env/test.env docker compose -p test -f docker-compose.prod.yml up -d
#
# Имя проекта задаёт отдельные volume'ы (pgdata) — у prod и test свои БД.
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME:-dispel_evil}
POSTGRES_USER: ${DB_USER:-de_admin}
POSTGRES_PASSWORD: ${DB_PASSWORD:?задайте DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-de_admin} -d ${DB_NAME:-dispel_evil}"]
interval: 10s
timeout: 5s
retries: 10
migrate:
image: flyway/flyway:10
command: ["migrate"]
environment:
FLYWAY_URL: jdbc:postgresql://postgres:5432/${DB_NAME:-dispel_evil}
FLYWAY_USER: ${DB_USER:-de_admin}
FLYWAY_PASSWORD: ${DB_PASSWORD:?задайте DB_PASSWORD}
FLYWAY_LOCATIONS: filesystem:/flyway/sql
FLYWAY_BASELINE_ON_MIGRATE: "true"
FLYWAY_CONNECT_RETRIES: "10"
volumes:
- ./sql:/flyway/sql:ro
depends_on:
postgres:
condition: service_healthy
restart: "no"
bot:
image: ${REGISTRY:-git.mpakosrv.ru}/mpakobecx/dispelevilbot-bot:${IMAGE_TAG:-latest}
restart: unless-stopped
env_file:
- ${ENV_FILE:-env/prod.env}
environment:
DB_HOST: postgres
DB_PORT: "5432"
depends_on:
postgres:
condition: service_healthy
migrate:
condition: service_completed_successfully
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# --- Reverse proxy и TLS (Блок 2.4) ---
# Включается, когда появится админка/Mini App (Блоки 4–5).
# Раскомментировать и положить deploy/Caddyfile:
#
# caddy:
# image: caddy:2
# restart: unless-stopped
# ports:
# - "80:80"
# - "443:443"
# volumes:
# - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
# - caddy_data:/data
# - caddy_config:/config
volumes:
pgdata:
# caddy_data:
# caddy_config:

66
docker-compose.yml Normal file
View File

@ -0,0 +1,66 @@
# Базовый compose для локального запуска и разработки.
# Секреты берутся из .env (см. .env.example).
#
# docker compose up --build
#
name: dispelevilbot
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME:-dispel_evil}
POSTGRES_USER: ${DB_USER:-de_admin}
POSTGRES_PASSWORD: ${DB_PASSWORD:?задайте DB_PASSWORD в .env}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-de_admin} -d ${DB_NAME:-dispel_evil}"]
interval: 10s
timeout: 5s
retries: 10
# ports:
# - "5432:5432" # раскомментировать для доступа к БД с хоста
# Накат миграций Flyway перед стартом бота (идемпотентно).
migrate:
image: flyway/flyway:10
command: ["migrate"]
environment:
FLYWAY_URL: jdbc:postgresql://postgres:5432/${DB_NAME:-dispel_evil}
FLYWAY_USER: ${DB_USER:-de_admin}
FLYWAY_PASSWORD: ${DB_PASSWORD:?задайте DB_PASSWORD в .env}
FLYWAY_LOCATIONS: filesystem:/flyway/sql
FLYWAY_BASELINE_ON_MIGRATE: "true"
FLYWAY_CONNECT_RETRIES: "10"
volumes:
- ./sql:/flyway/sql:ro
depends_on:
postgres:
condition: service_healthy
restart: "no"
bot:
build: .
image: dispelevilbot-bot:local
restart: unless-stopped
env_file:
- .env
environment:
# Внутри сети compose БД доступна по имени сервиса.
DB_HOST: postgres
DB_PORT: "5432"
depends_on:
postgres:
condition: service_healthy
migrate:
condition: service_completed_successfully
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
volumes:
pgdata:

26
env/prod.env.example vendored Normal file
View File

@ -0,0 +1,26 @@
# Прод-контур. Скопируйте в env/prod.env и заполните реальными значениями.
# env/*.env в git не попадают.
#
# Используется и для интерполяции, и как env_file сервиса:
# docker compose -p prod --env-file env/prod.env -f docker-compose.prod.yml up -d
ENV_FILE=env/prod.env
APP_ENV=prod
BOT_TOKEN=
# Postgres (для контейнера бота host = имя сервиса compose)
DB_HOST=postgres
DB_PORT=5432
DB_NAME=dispel_evil
DB_USER=de_admin
DB_PASSWORD=
DB_POOL_MIN=1
DB_POOL_MAX=10
DB_STATEMENT_TIMEOUT_MS=15000
PROD_CHAT_ID=
TEST_CHAT_ID=
ALERT_CHAT_ID=
ADMIN_IDS=
TIMEZONE=Europe/Moscow

24
env/test.env.example vendored Normal file
View File

@ -0,0 +1,24 @@
# Тестовый контур. Скопируйте в env/test.env и заполните.
# Отдельные токен бота, чаты и БД от прода.
#
# docker compose -p test --env-file env/test.env -f docker-compose.prod.yml up -d
ENV_FILE=env/test.env
APP_ENV=dev
BOT_TOKEN=
DB_HOST=postgres
DB_PORT=5432
DB_NAME=dispel_evil_test
DB_USER=de_admin
DB_PASSWORD=
DB_POOL_MIN=1
DB_POOL_MAX=10
DB_STATEMENT_TIMEOUT_MS=15000
PROD_CHAT_ID=
TEST_CHAT_ID=
ALERT_CHAT_ID=
ADMIN_IDS=
TIMEZONE=Europe/Moscow

53
tests/test_render.py Normal file
View File

@ -0,0 +1,53 @@
"""Тесты рендера шаблонов (без БД и сети)."""
from __future__ import annotations
import pytest
from common.texts.render import (
TemplateError,
format_tops,
format_user_list,
format_usernames,
render,
validate,
)
def test_render_substitutes_and_escapes():
template = "Привет, {username}!"
result = render(template, {"username": "<b>bad</b>"})
assert result == "Привет, &lt;b&gt;bad&lt;/b&gt;!"
def test_render_empty_value():
assert render("Ник: {nick}", {"nick": None}) == "Ник: "
def test_render_unknown_tag_raises():
with pytest.raises(TemplateError):
render("Значение {unknown}", {})
def test_render_keeps_literal_braces_untouched():
# Одиночная фигурная скобка не считается тегом.
assert render("текст { не тег", {}) == "текст { не тег"
def test_validate_reports_unknown_tags():
assert validate("Норм {username}, плохо {nope} {bad2}") == ["bad2", "nope"]
def test_format_usernames_escapes_and_joins():
rows = [{"username": "a"}, {"username": None}, {"username": "b&c"}]
assert format_usernames(rows) == "@a @b&amp;c"
def test_format_user_list_numbering():
rows = [{"username": "u1", "nick": "Nick1"}, {"username": None, "nick": None}]
assert format_user_list(rows) == "1: @u1 - Nick1\n2: — - "
def test_format_tops():
rows = [{"nick": "N1", "count": 3}, {"nick": None, "count": 1}]
assert format_tops(rows) == "1) N1 - 3\n2) — - 1"

10
tests/test_version.py Normal file
View File

@ -0,0 +1,10 @@
"""Проверка версии проекта."""
from __future__ import annotations
from common import __version__
def test_version_is_set():
assert __version__
assert __version__ == "0.2"