name: Deploy Metrics # Деплой стека наблюдаемости (#3078). Двухсторонний, и это существенно: # # server — на ИНФРАСТРУКТУРНЫЙ хост (Beget): Prometheus, Loki, Grafana, # Alertmanager. Наблюдатель намеренно живёт у другого провайдера, # чем наблюдаемое. # agent — на ОБА хоста: node-exporter, cAdvisor, postgres-exporter, Alloy. # Агент на продуктовом хосте шлёт push'ем, поэтому там не открывается # ни одного входящего порта. # # Продуктовый стек не трогается вовсе: другой project-name, другие compose-файлы, # deploy.yml остаётся в стороне. on: push: branches: [main] paths: - "docker-compose.metrics.yml" - "docker-compose.metrics-agent.yml" - "ops/metrics/**" - "caddy/sites/infra.caddy" - "caddy/metrics-ui.caddy.snippet" - "caddy/metrics-ingest.caddy.snippet" # Глоб, а не точечный `setup-metrics-secrets.sh` (#2203: класс бага, а не # один файл). Деплой запускает ТРИ setup-скрипта — secrets, grafana-role и # exporter-dsn, — а в триггере стоял только первый: правка двух остальных # не заводила выкат, и на хосте продолжала исполняться старая версия молча. - "scripts/setup-metrics-*.sh" - ".forgejo/workflows/deploy-metrics.yml" workflow_dispatch: concurrency: group: deploy-metrics cancel-in-progress: false jobs: # ═══ СЕРВЕРНАЯ СТОРОНА — инфраструктурный хост ════════════════════════════ server: runs-on: ubuntu-latest if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 # Тот же приём, что в deploy-obsidian.yml (#3062, #3029): адресат и отпечаток # берутся В ПАРЕ, без перекрёстного фолбэка. Сверять ключ Beget'а с отпечатком # Poincare — гарантированный отказ. - name: Адресат и подлинность инфраструктурного хоста id: target env: INFRA_HOST: ${{ secrets.INFRA_DEPLOY_HOST }} INFRA_FINGERPRINT: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }} MAIN_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }} run: | set -euo pipefail if [ -n "${INFRA_HOST:-}" ]; then HOST_FINGERPRINT="${INFRA_FINGERPRINT:-}" SRC="INFRA_DEPLOY_SSH_FINGERPRINT" else HOST_FINGERPRINT="${MAIN_FINGERPRINT:-}" SRC="DEPLOY_SSH_FINGERPRINT" fi case "${HOST_FINGERPRINT}" in *[![:print:]]*) echo "ОШИБКА: ${SRC} содержит перевод строки или непечатный символ." >&2 exit 1 ;; esac echo "fingerprint=${HOST_FINGERPRINT}" >> "$GITHUB_OUTPUT" if [ -z "${HOST_FINGERPRINT:-}" ]; then echo "::warning title=SSH без проверки подлинности хоста::${SRC} не задан — ключ хоста НЕ проверяется (#3029)." fi - name: Поднять серверный стек uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }} username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }} key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }} port: ${{ secrets.INFRA_DEPLOY_PORT || secrets.DEPLOY_PORT || 22 }} fingerprint: ${{ steps.target.outputs.fingerprint }} command_timeout: 15m script: | set -euo pipefail cd /opt/gendesign git fetch origin main git reset --hard origin/main docker network inspect gendesign_shared >/dev/null 2>&1 \ || docker network create gendesign_shared # Окружение нужно в shell, а не только в env_file: проверки вида # ${VAR:?} у compose работают по переменным ОКРУЖЕНИЯ ПРОЦЕССА. if [ -f backend/.env.runtime ]; then set -a; . backend/.env.runtime; set +a fi # ── Проверка ДО подъёма, а не после ──────────────────────────── # Пустой токен даёт Alertmanager, который стартует зелёным и молча # ничего не шлёт. Это ровно тот класс тихого отказа, ради которого # весь стек и заводится, — ловим на пороге. missing="" for v in GRAFANA_ADMIN_PASSWORD GLITCHTIP_RO_PASSWORD \ METRICS_INGEST_USER METRICS_INGEST_PASSWORD; do eval "val=\${$v:-}" [ -z "$val" ] && missing="$missing $v" done if [ -n "$missing" ]; then echo "ОШИБКА: в окружении хоста не заданы:$missing" echo "Запусти один раз: bash scripts/setup-metrics-secrets.sh" exit 1 fi # Алерты включаются, только когда канал доставки реально задан. # Поднимать Alertmanager с пустым токеном нельзя: он стартует # зелёным и молча ничего не шлёт — ровно тот тихий отказ, ради # которого весь стек и заводится. PROFILES="" if [ -n "${METRICS_TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${METRICS_TELEGRAM_CHAT_ID:-}" ]; then PROFILES="alerts" mkdir -p ops/metrics/alertmanager # Топик форумной группы (#3078). Необязателен: без него алерты # уходят в общую тему. Подставляем ЦЕЛОЙ СТРОКОЙ, а не значением, # потому что envsubst не умеет условий — при пустом # METRICS_TELEGRAM_TOPIC_ID в конфиг попал бы `message_thread_id:` # без значения, и Alertmanager не стартовал бы вовсе. if [ -n "${METRICS_TELEGRAM_TOPIC_ID:-}" ]; then METRICS_TELEGRAM_TOPIC_LINE=" message_thread_id: ${METRICS_TELEGRAM_TOPIC_ID}" echo "Алерты: адресуются в топик ${METRICS_TELEGRAM_TOPIC_ID}." else METRICS_TELEGRAM_TOPIC_LINE="" echo "Алерты: топик не задан — уйдут в общую тему чата." fi METRICS_TELEGRAM_BOT_TOKEN="$METRICS_TELEGRAM_BOT_TOKEN" \ METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \ METRICS_TELEGRAM_TOPIC_LINE="$METRICS_TELEGRAM_TOPIC_LINE" \ envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_TOPIC_LINE}' \ < ops/metrics/alertmanager/alertmanager.yml.tmpl \ > ops/metrics/alertmanager/alertmanager.yml chmod 600 ops/metrics/alertmanager/alertmanager.yml # Проверяем ДО подъёма, как и Caddyfile ниже. Битый конфиг # Alertmanager не «деградирует» — контейнер не стартует вовсе, и # алертинг молча исчезает целиком. amtool берём из того же образа, # что и сам Alertmanager, иначе проверяли бы не ту версию схемы. if ! docker run --rm \ -v "$PWD/ops/metrics/alertmanager/alertmanager.yml:/tmp/am.yml:ro" \ --entrypoint amtool "$(grep -oE 'prom/alertmanager:[^ ]+' docker-compose.metrics.yml | head -1)" \ check-config /tmp/am.yml; then echo "ОШИБКА: конфиг Alertmanager не проходит проверку — стек не поднимаем." exit 1 fi echo "Алерты: канал задан, конфиг проверен, Alertmanager поднимается." else echo "::warning title=Алерты выключены::METRICS_TELEGRAM_BOT_TOKEN/CHAT_ID не заданы. Метрики и логи собираются, но при срабатывании правила НИКТО не будет уведомлён. Канал доставки — открытый вопрос #3078." fi # ── read-only роль для датасорса GlitchTip ───────────────────── # Идемпотентно. Прав на запись не выдаём вовсе: датасорс Grafana # обязан быть безопасен даже при полном доступе к дашбордам. bash scripts/setup-metrics-grafana-role.sh COMPOSE_PROFILES="$PROFILES" \ docker compose -p gendesign-metrics -f docker-compose.metrics.yml pull --quiet COMPOSE_PROFILES="$PROFILES" \ docker compose -p gendesign-metrics -f docker-compose.metrics.yml up -d --remove-orphans # ── Caddy: СНАЧАЛА проверить, потом применять ────────────────── # На этом хосте тот же Caddy обслуживает git., errors. и obsidian. # Синтаксическая ошибка в infra.caddy положила бы их все, включая # сам Forgejo, из которого идёт деплой. Поэтому validate — обязателен, # и reload делается только после успешной проверки. if docker compose -p gendesign -f docker-compose.prod.yml ps caddy --quiet | grep -q .; then if docker compose -p gendesign -f docker-compose.prod.yml \ exec -T caddy caddy validate --config /etc/caddy/Caddyfile; then docker compose -p gendesign -f docker-compose.prod.yml \ exec -T caddy caddy reload --config /etc/caddy/Caddyfile echo "Caddy: конфиг проверен и перезагружен." else echo "ОШИБКА: Caddyfile не проходит проверку — reload НЕ выполнен." echo "Работающий Caddy не тронут, домены живы. Чинить конфиг и повторять." exit 1 fi fi # ── Приёмка ──────────────────────────────────────────────────── for i in $(seq 1 30); do if docker exec gendesign-prometheus wget -q --spider http://localhost:9090/-/healthy 2>/dev/null; then break fi sleep 3 done docker compose -p gendesign-metrics -f docker-compose.metrics.yml ps # ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════ agent-apps: runs-on: ubuntu-latest needs: server if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 - name: Агент на продуктовом хосте uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} port: ${{ secrets.DEPLOY_PORT || 22 }} fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }} command_timeout: 15m script: | set -euo pipefail cd /opt/gendesign git fetch origin main git reset --hard origin/main docker network inspect gendesign_shared >/dev/null 2>&1 \ || docker network create gendesign_shared if [ -f backend/.env.runtime ]; then set -a; . backend/.env.runtime; set +a fi if [ -z "${METRICS_INGEST_PASSWORD:-}" ]; then echo "ОШИБКА: METRICS_INGEST_PASSWORD не задан — агенту нечем авторизоваться." echo "Запусти на инфраструктурном хосте: bash scripts/setup-metrics-secrets.sh" exit 1 fi # DSN экспортеров собираются из уже имеющихся паролей БД, если их # ещё нет. Отдельных секретов не заводим — лишняя копия пароля это # лишнее место, откуда он может утечь. bash scripts/setup-metrics-exporter-dsn.sh set -a; . backend/.env.runtime; set +a # Профиль экспортеров БД включаем, только если DSN реально собрались. # Раньше их обязательность стояла в compose (`${VAR:?}`), но compose # интерполирует ВЕСЬ файл до фильтрации по профилям — и продуктовый # агент падал на INFRA_EXPORTER_DSN, переменной сервиса, который тут # не поднимается вовсе. Проверка переехала сюда, где роль известна. # # Не падаем, а предупреждаем: alloy / node-exporter / cadvisor и сбор # логов не должны отваливаться из-за одного ненастроенного экспортера. # Тот же приём, что у Alertmanager в джобе server выше. EXPORTER_PROFILE="" missing_dsn="" [ -n "${GENDESIGN_EXPORTER_DSN:-}" ] || missing_dsn="$missing_dsn GENDESIGN_EXPORTER_DSN" [ -n "${TRADEIN_EXPORTER_DSN:-}" ] || missing_dsn="$missing_dsn TRADEIN_EXPORTER_DSN" if [ -z "$missing_dsn" ]; then EXPORTER_PROFILE="apps" else echo "::warning title=Метрики БД не собираются::не заполнены:$missing_dsn. Хостовые метрики и логи поедут, метрик Postgres не будет. Проверь, что scripts/setup-metrics-exporter-dsn.sh нашёл DATABASE_URL/TRADEIN_DATABASE_URL." fi METRICS_ROLE=apps \ METRICS_ALLOY_CONFIG=alloy-apps.alloy \ COMPOSE_PROFILES="$EXPORTER_PROFILE" \ docker compose -p gendesign-metrics-agent \ -f docker-compose.metrics-agent.yml pull --quiet METRICS_ROLE=apps \ METRICS_ALLOY_CONFIG=alloy-apps.alloy \ COMPOSE_PROFILES="$EXPORTER_PROFILE" \ docker compose -p gendesign-metrics-agent \ -f docker-compose.metrics-agent.yml up -d sleep 10 METRICS_ROLE=apps METRICS_ALLOY_CONFIG=alloy-apps.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \ docker compose -p gendesign-metrics-agent \ -f docker-compose.metrics-agent.yml ps agent-infra: runs-on: ubuntu-latest needs: server if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' steps: - uses: actions/checkout@v4 - name: Агент на инфраструктурном хосте uses: appleboy/ssh-action@v1.0.3 with: host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }} username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }} key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }} port: ${{ secrets.INFRA_DEPLOY_PORT || secrets.DEPLOY_PORT || 22 }} fingerprint: ${{ secrets.INFRA_DEPLOY_SSH_FINGERPRINT }} command_timeout: 15m script: | set -euo pipefail cd /opt/gendesign if [ -f backend/.env.runtime ]; then set -a; . backend/.env.runtime; set +a fi # Симметрично продуктовому агенту: профиль экспортера включаем, # только если DSN есть. Без этого гарда экспортер поднялся бы с # ПУСТЫМ DATA_SOURCE_NAME (в compose теперь `:-`, а не `:?`) и # молча не отдавал бы метрик — ровно тот тихий отказ, ради которого # весь стек и заводится. # # NB: INFRA_EXPORTER_DSN сейчас не собирает никто — # scripts/setup-metrics-exporter-dsn.sh знает только про # GENDESIGN_/TRADEIN_ и работает на продуктовом хосте. Пока это так, # ветка ниже всегда даёт предупреждение, и это честно: метрик # инфраструктурной БД действительно нет. EXPORTER_PROFILE="" if [ -n "${INFRA_EXPORTER_DSN:-}" ]; then EXPORTER_PROFILE="infra" else echo "::warning title=Метрики инфраструктурной БД не собираются::INFRA_EXPORTER_DSN не задан. Хостовые метрики и логи поедут, метрик Postgres инфры не будет." fi METRICS_ROLE=infra \ METRICS_ALLOY_CONFIG=alloy-infra.alloy \ COMPOSE_PROFILES="$EXPORTER_PROFILE" \ docker compose -p gendesign-metrics-agent \ -f docker-compose.metrics-agent.yml pull --quiet METRICS_ROLE=infra \ METRICS_ALLOY_CONFIG=alloy-infra.alloy \ COMPOSE_PROFILES="$EXPORTER_PROFILE" \ docker compose -p gendesign-metrics-agent \ -f docker-compose.metrics-agent.yml up -d sleep 10 METRICS_ROLE=infra METRICS_ALLOY_CONFIG=alloy-infra.alloy COMPOSE_PROFILES="$EXPORTER_PROFILE" \ docker compose -p gendesign-metrics-agent \ -f docker-compose.metrics-agent.yml ps