From 57708290215f3a7a1e8c14c3224e7486a4e4a098 Mon Sep 17 00:00:00 2001 From: dv Date: Mon, 16 Mar 2026 00:50:30 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=BE=D0=BF=D1=8B=D1=82=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BA=D0=B0=D1=82=D0=B0=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 5 +- .woodpecker.yml | 96 +++++++++++++++++++++++++++++++++++++++ Dockerfile | 13 ++++-- deploy/docker-compose.yml | 44 ++++++++++++++++++ docker-compose.yml | 8 +++- gunicorn.conf.py | 78 +++++++++++++++++++++++++++++++ scripts/rollback.sh | 59 ++++++++++++++++++++++++ 7 files changed, 297 insertions(+), 6 deletions(-) create mode 100644 .woodpecker.yml create mode 100644 deploy/docker-compose.yml create mode 100644 gunicorn.conf.py create mode 100644 scripts/rollback.sh diff --git a/.gitignore b/.gitignore index dbe39c6..b432071 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,7 @@ # Прочее частое *.secret secrets.json -config.local.* \ No newline at end of file +config.local.* + +# +intradbot-docker.zip \ No newline at end of file diff --git a/.woodpecker.yml b/.woodpecker.yml new file mode 100644 index 0000000..859dc40 --- /dev/null +++ b/.woodpecker.yml @@ -0,0 +1,96 @@ +# ───────────────────────────────────────────────────────────────────────────── +# Woodpecker CI Pipeline +# +# Шаги: +# 1. docker-build — сборка и пуш образа (push в main) +# 2. docker-build-tag — сборка и пуш образа с semver-тегом (git tag v*) +# 3. deploy — деплой на сервер (выполняется на агенте role=work_woodpecker) +# +# Секреты (Settings → Secrets репозитория): +# forgejo_host — хост реестра, например git.example.com +# forgejo_image — полный путь образа, например git.example.com/user/intradbot +# forgejo_username — логин (обычно = gitea.actor) +# forgejo_token — токен с правом package:write +# ───────────────────────────────────────────────────────────────────────────── + +steps: + + # ── 1а. Сборка при push в main ───────────────────────────────────────────── + - name: docker-build + image: woodpeckerci/plugin-docker-buildx + settings: + registry: + from_secret: forgejo_host + repo: + from_secret: forgejo_image + username: + from_secret: forgejo_username + password: + from_secret: forgejo_token + tags: + - latest + - "sha-${CI_COMMIT_SHA:0:8}" + cache_from: "git.ihateamerica.ru/dv/intrabot:latest" + build_args: + - "GIT_COMMIT=${CI_COMMIT_SHA:0:8}" + - "BUILDKIT_INLINE_CACHE=1" + when: + - event: push + branch: main + + # ── 1б. Сборка при git tag v* (semver) ──────────────────────────────────── + - name: docker-build-tag + image: woodpeckerci/plugin-docker-buildx + settings: + registry: + from_secret: forgejo_host + repo: + from_secret: forgejo_image + username: + from_secret: forgejo_username + password: + from_secret: forgejo_token + tags: + - "${CI_COMMIT_TAG}" # v1.2.3 + - latest + - "sha-${CI_COMMIT_SHA:0:8}" # всегда — нужно для отката + cache_from: "git.ihateamerica.ru/dv/intrabot:latest" + build_args: + - "GIT_COMMIT=${CI_COMMIT_SHA:0:8}" + - "BUILDKIT_INLINE_CACHE=1" + when: + - event: tag + + # ── 2. Деплой на продакшн-сервер ────────────────────────────────────────── + # Выполняется на Woodpecker-агенте с меткой role=work_woodpecker. + # Требует: репозиторий помечен «trusted» в настройках Woodpecker-сервера + # (иначе монтирование volumes запрещено). + - name: deploy + image: docker:cli + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /opt/intrabot:/deploy + commands: + - | + set -e + cd /deploy + + # Сохраняем ID текущего контейнерного образа (для отката) + CONTAINER=$(docker compose ps -q intrabot 2>/dev/null | head -1) + if [ -n "$CONTAINER" ]; then + OLD_IMAGE=$(docker inspect --format='{{.Image}}' "$CONTAINER") + printf '%s' "$OLD_IMAGE" > .previous_image + echo "Snapshot for rollback: $OLD_IMAGE" + fi + + # Обновляем образ и перезапускаем + docker compose pull + docker compose up -d --remove-orphans + + echo "Deployed: ${CI_COMMIT_SHA:0:8} (${CI_COMMIT_MESSAGE})" + labels: + role: work_woodpecker + when: + - event: push + branch: main + - event: tag diff --git a/Dockerfile b/Dockerfile index f4324ee..a6c9614 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,9 +7,14 @@ RUN mkdir -p /app/data COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +ARG GIT_COMMIT=dev +ARG DOCKER_IMAGE_CREATED=unknown +ENV GIT_COMMIT=$GIT_COMMIT +ENV BUILD_TIME=$DOCKER_IMAGE_CREATED + COPY . . -# Gunicorn запускает Flask в продакшене. -# intradbot.py сам запускает бота и шедулер в отдельных потоках, -# а Gunicorn обслуживает HTTP — один worker достаточно для webhook-нагрузки. -CMD ["python", "intradbot.py"] +# Продакшен: Gunicorn (gthread, 1 worker) обслуживает Flask. +# Telegram-бот и шедулер стартуют внутри воркера через gunicorn.conf.py (post_fork). +# Локальная разработка: python intradbot.py +CMD ["gunicorn", "-c", "gunicorn.conf.py", "web.server:create_app()"] diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..8682bc9 --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,44 @@ +# ───────────────────────────────────────────────────────────────────────────── +# ПРОДАКШН docker-compose.yml — размещать на сервере в /opt/intradbot/ +# ───────────────────────────────────────────────────────────────────────────── +# +# Первоначальный запуск: +# mkdir -p /opt/intradbot/{data,ssl} +# cp deploy/docker-compose.yml /opt/intradbot/docker-compose.yml +# cp .env.example /opt/intradbot/.env && nano /opt/intradbot/.env +# cp /path/to/cert.pem /opt/intradbot/ssl/ +# cp /path/to/key.pem /opt/intradbot/ssl/ +# docker login git.example.com +# cd /opt/intradbot && docker compose up -d +# +# Обновление (вручную, если нет CI): +# cd /opt/intradbot && docker compose pull && docker compose up -d +# +# Полезные команды: +# docker compose logs -f — логи в реальном времени +# docker compose ps — статус +# docker compose exec intradbot bash +# ───────────────────────────────────────────────────────────────────────────── + +services: + intradbot: + image: git.ihateamerica.ru/dv/intrabot:latest # ← замени на свой хост/юзер + restart: always + env_file: .env + ports: + - "8080:8080" + volumes: + - ./data:/app/data # SQLite БД + - ./ssl:/app/ssl:ro # cert.pem + key.pem + pull_policy: always + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 20s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" diff --git a/docker-compose.yml b/docker-compose.yml index 4985bd1..5f7e3ad 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,4 +7,10 @@ services: - "8080:8080" volumes: - ./data:/app/data - - ./ssl:/app/ssl:ro # ← реальные файлы без симлинков \ No newline at end of file + - ./ssl:/app/ssl:ro # ← реальные файлы без симлинков + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 15s \ No newline at end of file diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..4d1c2d9 --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,78 @@ +""" +Конфигурация Gunicorn для продакшена. + +Архитектура: + - 1 воркер (workers=1) — обязательно, т.к.: + * SQLite не поддерживает несколько независимых процессов без WAL + * SSE event bus (events.py) хранит состояние в памяти одного процесса + * Notifier (poster.py) инициализируется один раз в памяти воркера + - gthread: несколько потоков внутри воркера — позволяет обслуживать + одновременно SSE-поток дашборда + вебхуки + API-запросы + - post_fork: стартует Telegram-бот и шедулер внутри воркера (не в мастере) + +Локальная разработка: python intradbot.py (Flask dev server) +Продакшен (Docker): gunicorn -c gunicorn.conf.py "web.server:create_app()" +""" + +import os +import threading +import time + +# ── Сервер ──────────────────────────────────────────────────────────────────── + +bind = f"0.0.0.0:{os.environ.get('PORT', '8080')}" +workers = 1 # строго 1 — см. комментарий выше +worker_class = "gthread" # многопоточный воркер для SSE + вебхуков одновременно +threads = 4 # потоков: SSE-клиенты + API-запросы + запас +timeout = 120 # достаточно для долгих SSE-соединений +keepalive = 5 + +# ── Логирование ─────────────────────────────────────────────────────────────── + +accesslog = "-" # stdout +errorlog = "-" # stderr +loglevel = "info" +access_log_format = '%(h)s "%(r)s" %(s)s %(b)s %(D)sµs' + +# ── Хуки жизненного цикла ──────────────────────────────────────────────────── + +def on_starting(server): + """Инициализация до форка воркеров (выполняется в мастер-процессе).""" + import logging + import config + import database + from intradesk import reference + + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + handlers=[logging.StreamHandler()], + ) + + os.makedirs(os.path.dirname(config.DB_FILE), exist_ok=True) + database.init_db() + reference.seed_static() + reference.load_all() + server.log.info("Database and reference data initialized.") + + +def post_fork(server, worker): + """ + Выполняется в каждом воркер-процессе после fork. + Запускает Telegram-бот и шедулер как daemon-потоки внутри воркера. + """ + import schedule + from intradesk import reference + from bot.telegram import start_bot + + threading.Thread(target=start_bot, daemon=True, name="telegram-bot").start() + + schedule.every().monday.at("10:00").do(reference.load_all) + + def _run_scheduler(): + while True: + schedule.run_pending() + time.sleep(1) + + threading.Thread(target=_run_scheduler, daemon=True, name="scheduler").start() + worker.log.info("Telegram bot and scheduler started in worker.") diff --git a/scripts/rollback.sh b/scripts/rollback.sh new file mode 100644 index 0000000..97554a1 --- /dev/null +++ b/scripts/rollback.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# ───────────────────────────────────────────────────────────────────────────── +# Откат к предыдущему образу +# +# Использование (на сервере): +# bash /opt/intradbot/scripts/rollback.sh +# +# Как работает: +# Перед каждым деплоем Woodpecker сохраняет ID текущего образа +# в /opt/intradbot/.previous_image. +# Этот скрипт перетегирует сохранённый образ как :latest и перезапускает +# контейнер без обращения к registry. +# ───────────────────────────────────────────────────────────────────────────── +set -e + +DEPLOY_DIR="/opt/intradbot" +COMPOSE_FILE="$DEPLOY_DIR/docker-compose.yml" +PREV_FILE="$DEPLOY_DIR/.previous_image" + +cd "$DEPLOY_DIR" + +if [ ! -f "$PREV_FILE" ]; then + echo "ERROR: файл $PREV_FILE не найден — предыдущий деплой не был записан" + exit 1 +fi + +PREV_IMAGE=$(cat "$PREV_FILE" | tr -d '[:space:]') +if [ -z "$PREV_IMAGE" ]; then + echo "ERROR: $PREV_FILE пустой" + exit 1 +fi + +# Проверяем, что образ ещё есть локально (не был удалён docker prune) +if ! docker image inspect "$PREV_IMAGE" > /dev/null 2>&1; then + echo "ERROR: образ $PREV_IMAGE не найден локально — используй ручной откат:" + echo " docker compose pull # затем подставь нужный sha-тег в docker-compose.yml" + exit 1 +fi + +# Определяем полное имя образа из docker-compose.yml +TARGET_IMAGE=$(grep 'image:' "$COMPOSE_FILE" | awk '{print $2}' | head -1) +if [ -z "$TARGET_IMAGE" ]; then + echo "ERROR: не удалось извлечь image из $COMPOSE_FILE" + exit 1 +fi + +echo "Откат:" +echo " образ сейчас : $(docker compose ps -q intradbot 2>/dev/null | xargs -r docker inspect --format='{{.Image}}' | head -1 || echo 'нет контейнера')" +echo " откатываемся : $PREV_IMAGE" + +# Перетегируем старый образ как :latest локально +docker tag "$PREV_IMAGE" "$TARGET_IMAGE" + +# Перезапускаем без pull (используем локальный образ) +docker compose up -d --no-build + +echo "" +echo "Откат выполнен. Текущий образ: $TARGET_IMAGE" +echo "Для проверки: docker compose ps"