1. Вступление: время поработать с кодом

Здравствуйте! В первой части мы подробно обсудили причины создания автоматизированной системы развертывания с GitHub Webhook и необходимые подготовительные шаги, а во второй части рассмотрели общую архитектуру и ключевую роль сервиса вебхука FastAPI. Теперь пришло время перенести нашу идею на реальный код.

Если вы не смотрели предыдущие части, вы можете ознакомиться с ними по следующим ссылкам.

Создание моей автоматизированной системы развертывания с GitHub Webhook

1 - Зачем реализовывать это самостоятельно? 2 - Общая архитектура и проектирование процесса

В третьей части мы завершим базовую настройку среды на стадии и вместе создадим основу вебхука сервера FastAPI, который сможет безопасно принимать и проверять запросы GitHub Webhook. Этот процесс приближает нас на шаг к реальной автоматизированной системе развертывания!

2. Начальная настройка среды на стадии

Первое, что нужно сделать, это подготовить среду на стадии, где будет происходить развертывание, чтобы мы могли запускать код. Подключитесь к серверу по SSH и следуйте приведенным ниже шагам.

2.1. Подготовка среды разработки Python

Настройка виртуальной среды Python на сервере является хорошей практикой для предотвращения конфликтов зависимостей между проектами и поддержания чистой среды разработки. Установите необходимые пакеты с pip в виртуальную среду venv.

# Установка FastAPI, Uvicorn и python-dotenv для чтения .env файла
pip install fastapi uvicorn python-dotenv

2.2. Проверка установки Git, Docker, Docker Compose

Как уже упоминалось в предыдущей части, Git, Docker и Docker Compose являются основными инструментами для автоматизированного развертывания. Предполагается, что у читателей уже установлены эти инструменты на сервере.

Если они не установлены, сначала проверьте их наличие с помощью следующих команд.

git --version
docker --version
docker compose version # или docker-compose --version (в зависимости от способа установки)

Если они не установлены, пожалуйста, следуйте инструкциям в официальной документации для установки каждого инструмента. Если вам нужно более подробное руководство по установке Docker, вы можете ознакомиться с этой статьей: Руководство по установке Docker

Примечание: После установки Docker необходимо предоставить группе пользователю права доступа и выйти из системы, а затем войти снова, чтобы использовать команды docker без sudo.

sudo usermod -aG docker $USER

После предоставления прав Docker обязательно выйдите из системы на сервере и войдите снова, чтобы изменения вступили в силу. Проверьте работу с помощью команды docker run hello-world без sudo.

Схема создания вебхука сервера FastAPI

3. Создание базовой структуры вебхука сервера FastAPI

Теперь создадим базовый каркас приложения FastAPI, которое будет принимать вебхуки. В каталоге ~/projects/webhook_server создайте файл main.py и вставьте следующий код.

3.1. Создание файла main.py и инициализация приложения FastAPI

Я собрал всю логику в файле main.py для объяснения, но вам стоит разбить логику на отдельные модули и импортировать их для удобства в вашем реальном проекте. Вот пример кода.

# ~/projects/webhook_server/main.py
import hmac
import hashlib
import os
import logging
import subprocess
from pathlib import Path
from typing import Optional

from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
from dotenv import load_dotenv, dotenv_values

# Загрузка файла .env (для использования переменных окружения)
load_dotenv()

# Настройка логирования
logging.basicConfig(
    level=logging.INFO, # Запись логов уровнем INFO и выше
    format='%(asctime)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler("webhook_server.log"), # Сохранение логов в файл
        logging.StreamHandler() # Вывод логов в консоль
    ]
)
logger = logging.getLogger(__name__)

app = FastAPI()

# Загрузка секрета вебхука GitHub из переменных окружения
GITHUB_WEBHOOK_SECRET = os.getenv("GITHUB_WEBHOOK_SECRET")

# Тестовая корневая конечная точка: в производстве рекомендуется удалить ее для безопасности.
@app.get("/")
async def read_root():
    return {"message": "Сервер вебхука работает!"}

# Конечная точка для получения вебхука от GitHub
@app.post("/webhook")
async def github_webhook(request: Request, background_tasks: BackgroundTasks):
    logger.info("Запрос вебхука получен.")

    # 1. Проверка секрета вебхука GitHub (это должен быть первый шаг безопасности)
    if GITHUB_WEBHOOK_SECRET:
        signature = request.headers.get("X-Hub-Signature-256")
        if not signature:
            logger.warning("Отсутствует заголовок подписи. Операция отменена.")
            raise HTTPException(status_code=401, detail="Отсутствует заголовок X-Hub-Signature-256")

        body = await request.body()
        expected_signature = "sha256=" + hmac.new(
            GITHUB_WEBHOOK_SECRET.encode('utf-8'),
            body,
            hashlib.sha256
        ).hexdigest()

        if not hmac.compare_digest(expected_signature, signature):
            logger.warning("Недопустимая подпись. Операция отменена.")
            raise HTTPException(status_code=401, detail="Недопустимая подпись")
        else:
            logger.info("Подпись успешно проверена.")
    else:
        logger.warning("GITHUB_WEBHOOK_SECRET не установлен. Пропущена проверка подписи (НЕ РЕКОМЕНДУЕТСЯ ДЛЯ ПРОИЗВОДСТВА).")

    # 2. Парсинг тела HTTP (Payload)
    try:
        payload = await request.json()
        event_type = request.headers.get("X-GitHub-Event")
        # Извлечение имени репозитория из полезной нагрузки вебхука GitHub.
        repo_name = payload.get("repository", {}).get("name", "unknown_repository")
        ref = payload.get("ref", "unknown_ref")
        pusher_name = payload.get("pusher", {}).get("name", "unknown_pusher")

        logger.info(f"Событие: {event_type}, Репозиторий: {repo_name}, Референс: {ref}, Пушер: {pusher_name}")

        # Фоновый запуск логики развертывания
        background_tasks.add_task(handle_deploy, repo_name)

    except Exception as e:
        logger.error(f"Ошибка парсинга полезной нагрузки вебхука или обработки запроса: {e}")
        raise HTTPException(status_code=400, detail=f"Ошибка обработки запроса: {e}")

    # Немедленный ответ GitHub
    return {"message": "Вебхук получен, обработка начата в фоне!"}

# Функция для обработки логики развертывания
def handle_deploy(repo_name: str):
    if repo_name == "deployer":
        logger.info("⚙️ Пропущено самообновление развёртывателя.")
        return

    # Чтение локального пути каждого репозитория из переменных окружения.
    repo_paths = {
        "sample_project1": os.getenv("SAMPLE_PROJECT_1_PATH"),
        "sample_project2": os.getenv("SAMPLE_PROJECT_2_PATH"),
        "sample_project3": os.getenv("SAMPLE_PROJECT_3_PATH"),
    }

    # Проверка на наличие изменений.
    repo_path: Optional[str] = repo_paths.get(repo_name)

    if not repo_path:
        logger.warning(f"🚫 Неизвестный репозиторий: {repo_name}")
        return

    repo_path = Path(repo_path)

    env_path = repo_path / ".env"
    env_vars = dotenv_values(env_path) if env_path.exists() else {}
    is_debug = env_vars.get("DEBUG", "false").lower() == "true"
    deploy_color = env_vars.get("COLOR", "red")
    project_name = str(deploy_color) + p_name.get(repo_name, repo_name)

    docker_compose_file = "docker-compose.dev.yml" if is_debug else "docker-compose.prod.yml"

    compose_file = repo_path / docker_compose_file

    logger.info(f"Извлечение последнего кода для {repo_name} в {repo_path}...")
    try:
        subprocess.run(["git", "-C", repo_path, "pull"], check=True, capture_output=True, text=True)
        logger.info(f"Git pull успешен для {repo_name}.")
    except subprocess.CalledProcessError as e:
        logger.error(f"Git pull не удался для {repo_name}: {e.stderr}")
        return

    logger.info(f"Проверка изменений в {repo_name} для принятия решения о повторной сборке...")
    changed = should_rebuild(repo_path)

    try:
        if changed:
            logger.info(f"🚀 Обнаружены изменения. Сборка и развертывание {repo_name} с помощью {docker_compose_file}...")
            subprocess.run(["docker", "compose", "-p", project_name, "-f", compose_file, "up", "-d", "--build"], check=True, capture_output=True, text=True)
            logger.info(f"Docker compose сборка и развертывание успешны для {repo_name}.")
        else:
            logger.info(f"✨ Нет значительных изменений. Развёртывание {repo_name} без повторной сборки изображений с использованием {docker_compose_file}...")
            subprocess.run(["docker", "compose", "-p", project_name, "--no-deps", "-f", compose_file, "up", "-d"], check=True, capture_output=True, text=True)
            logger.info(f"Docker compose успешен для {repo_name}.")
    except subprocess.CalledProcessError as e:
        logger.error(f"Docker compose не удался для {repo_name}: {e.stderr}")
        return

    logger.info(f"✅ Задача развертывания для {repo_name} завершена.")

# Функция для определения необходимости повторной сборки изображения Docker
def should_rebuild(repo_path: Path) -> bool:
    trigger_files = [
        "requirements.txt", "Dockerfile", "Dockerfile.dev", "Dockerfile.prod",
        ".dockerignore", ".env"
    ]

    logger.info(f"Проверка git diff для {repo_path}...")
    try:
        result = subprocess.run(
            ["git", "diff", "--name-only", "HEAD~1"],
            cwd=repo_path,
            capture_output=True, text=True, check=True
        )
        changed_files_in_git = result.stdout.strip().splitlines()
        logger.info(f"Измененные файлы в git: {changed_files_in_git}")
    except subprocess.CalledProcessError as e:
        logger.error(f"Git diff не удался для {repo_path}: {e.stderr}")
        return True

    if any(f in changed_files_in_git for f in trigger_files):
        logger.info("Обнаружены изменения триггерных файлов через git diff. Повторная сборка необходима.")
        return True

    rebuild_trigger_file = repo_path / "REBUILD_TRIGGER"
    if rebuild_trigger_file.exists():
        try:
            rebuild_trigger_file.unlink()
            logger.info("Обнаружен триггерный файл REBUILD_TRIGGER. Повторная сборка обязательна, файл удалён.")
            return True
        except Exception as e:
            logger.error(f"Ошибка при удалении файла REBUILD_TRIGGER: {e}")
            return True

    logger.info("Триггерные файлы для повторной сборки не найдены. Повторная сборка не требуется.")
    return False

3.2. Определение конечной точки /webhook

В коде, под декоратором app.post("/webhook"), определена асинхронная функция github_webhook, которая и является конечной точкой для получения запросов от GitHub Webhook. Эта функция обрабатывает входящие POST запросы и позволяет получить доступ к заголовкам и содержимому (Payload) через объект Request. BackgroundTasks в FastAPI позволяет выполнять длительные задачи, такие как развертывание, в фоновом режиме, при этом отправляя немедленный ответ GitHub.

4. Реализация логики проверки GitHub Webhook Secret

Наиболее важным элементом безопасности в приведённом коде является логика проверки с использованием GITHUB_WEBHOOK_SECRET.

4.1. Важность значения Secret

При настройке вебхука GitHub вы можете задать Secret значение. Это значение используется GitHub для генерации хеш-значения, комбинируя тело запроса и значение секрета, и отправляет его в заголовке X-Hub-Signature-256. Наш сервер вебхука создает хеш-значение тем же способом и сравнивает его с хеш-значением, отправленным GitHub, чтобы убедиться, что запрос пришел точно от GitHub и что данные не были подменены при передаче.

4.2. Проверка с использованием заголовка X-Hub-Signature-256

Как вы видели в коде, мы используем модули hmac и hashlib для выполнения проверки секрета.

  1. Настройка секрета: Сначала создайте файл .env в каталоге ~/projects/webhook_server и запишите туда значение секрета, которое будете использовать для настройки вебхука GitHub. Этот файл следует добавить в .gitignore, чтобы он не попал в Git-репозиторий. Это значение секрета будет использоваться в будущем в настройках вебхуков репозитория GitHub.
# ~/projects/webhook_server/.env
GITHUB_WEBHOOK_SECRET="Введите_значение_секрета_для_настройки_GitHub_вебхука_здесь"
  1. Код FastAPI: В main.py мы используем библиотеку python-dotenv для загрузки этого файла .env, и с помощью os.getenv() получаем значение GITHUB_WEBHOOK_SECRET. Каждый раз, когда поступает запрос вебхука, мы извлекаем значение подписи через Request.headers.get("X-Hub-Signature-256") и используем hmac.new() для вычисления ожидаемой подписи для сравнения.

Эта логика проверки является неотъемлемой защитой от внешних атак на конечную точку вебхука, поэтому ни в коем случае не следует её пропускать!

5. Настройка системы логирования

В коде используется модуль logging для базовой настройки логирования.

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler("webhook_server.log"), # Сохранение в файл webhook_server.log
        logging.StreamHandler() # Вывод в консоль
    ]
)
logger = logging.getLogger(__name__)

С такой настройкой каждая вебхука, поступающая на сервер, будет записываться в файл webhook_server.log, и одновременно выводиться в терминал. Позже, когда логика развертывания станет сложнее, через логирование будет легко определить, на каком этапе возникла проблема.

6. Запуск вебхука сервера с помощью Uvicorn и локальное тестирование

Теперь давайте кратко протестируем, правильно ли работает созданный нами сервер вебхука FastAPI на локальном сервере. В каталоге ~/projects/webhook_server на сервере стадии активируйте виртуальную среду и выполните следующую команду.

# Убедитесь, что виртуальная среда активирована (проверьте, имеется ли (venv) в начале строки)
# source venv/bin/activate # Запустите эту команду, если она не была активирована

uvicorn main:app --host 0.0.0.0 --port 8000 --reload

Опция `--reload` автоматически перезапускает сервер при изменении кода, что удобно в процессе разработки.

Перейдите в браузере по адресу 127.0.0.1:8000/docs, чтобы проверить, корректно ли сгенерирована документация API. Если документация отображается, значит приложение работает нормально. Если вы знаете IP-адрес сервера (например, 192.168.1.100), вы можете отправить тестовый запрос с помощью следующей команды curl с локального устройства.

```bash

Замените IP-адрес сервера на 'YOUR_SERVER_IP'.

'YOUR_WEBHOOK_SECRET' нужно заменить на реальное значение из файла .env.

Включает простые тестовые JSON-данные.

curl -X POST http://YOUR_SERVER_IP:8000/webhook \ -H "Content-Type: application/json" \ -H "X-GitHub-Event: push" \ -H "X-Hub-Signature-256: sha256=$(echo -n '{"repository":{"name":"your-repo"},"ref":"refs/heads/main","pusher":{"name":"test-user"}}' | openssl dgst -sha256 -hmac 'YOUR_WEBHOOK_SECRET' | awk '{print $2}')" \ -d '{"repository":{"name":"your-repo"},"ref":"refs/heads/main","pusher":{"name":"test-user"}}'

```

Убедитесь, что в терминале выводится сообщение Webhook received and processing started in background! и оно также записывается в файл логов сервера (webhook_server.log). Если возникают ошибки, вы можете отладить их, изучая сообщения об ошибках, выводимые в терминале.

7. Заключение: анонс следующей части

В этой части мы построили среду на стадии и завершили создание базовой структуры FastAPI вебхука сервера, который может безопасно принимать запросы извне.

В следующей части мы будем использовать созданную основу для реализации логики развертывания (Git Pull, определение, требуется ли повторная сборка Docker-изображения, выполнение Docker Compose) с использованием модуля subprocess, а также подробно расскажем о том, как зарегистрировать этот сервер вебхука FastAPI как сервис Systemd, чтобы он автоматически запускался при перезагрузке сервера. Ожидайте с нетерпением!