gendesign/.forgejo/workflows/deploy-metrics.yml
lekss361 127c9a5c2a
All checks were successful
Deploy / changes (push) Successful in 11s
Deploy / build-frontend (push) Has been skipped
Deploy / deploy-caddy (push) Has been skipped
Deploy Metrics / server (push) Successful in 32s
Deploy / build-backend (push) Successful in 51s
Deploy / build-worker (push) Successful in 58s
Deploy Metrics / agent-infra (push) Successful in 29s
Deploy Metrics / agent-apps (push) Successful in 31s
Deploy / deploy (push) Successful in 1m18s
Deploy / deploy-status (push) Successful in 2s
Deploy / perimeter-smoke (push) Successful in 1m46s
Merge pull request 'Деплой метрик перечитывает конфиг Prometheus, а не только кладёт его на диск' (#3476) from fix/3467-metrics-deploy-reload into main
2026-09-12 11:18:28 +00:00

560 lines
40 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
# #3078: канал доставки алертов приходит ИЗ СЕКРЕТОВ ACTIONS, а не только
# из окружения инфраструктурного хоста. Раньше эти три переменные брались
# исключительно из файла окружения на машине — то есть включить алерты
# можно было только правкой прод-файла руками по ssh. Это и держало #3078
# открытым дольше нужного: стек был готов, а положить в него токен было
# некуда, кроме как в обход репозитория.
#
# Порядок разрешения важен: инжектированные значения ставятся ДО того, как
# скрипт подхватит окружение машины, поэтому файл на хосте, если ключи в
# нём заданы, ПЕРЕОПРЕДЕЛЯЕТ секреты. Так и задумано — у машины остаётся
# последнее слово, а секреты работают как разумный дефолт.
- name: Поднять серверный стек
uses: appleboy/ssh-action@v1.0.3
env:
METRICS_TELEGRAM_BOT_TOKEN: ${{ secrets.METRICS_TELEGRAM_BOT_TOKEN }}
METRICS_TELEGRAM_CHAT_ID: ${{ secrets.METRICS_TELEGRAM_CHAT_ID }}
METRICS_TELEGRAM_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_TOPIC_ID }}
METRICS_TELEGRAM_INFRA_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_INFRA_TOPIC_ID }}
METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }}
ALERT_ACK_GLITCHTIP_SECRET: ${{ secrets.ALERT_ACK_GLITCHTIP_SECRET }}
with:
envs: METRICS_TELEGRAM_BOT_TOKEN,METRICS_TELEGRAM_CHAT_ID,METRICS_TELEGRAM_TOPIC_ID,METRICS_TELEGRAM_INFRA_TOPIC_ID,METRICS_TELEGRAM_ONCALL,ALERT_ACK_GLITCHTIP_SECRET
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
# Тема КЛИЕНТСКИХ инцидентов задаётся НЕ здесь. Их отправляет
# сервис alert-ack, читая METRICS_TELEGRAM_TOPIC_ID из своего
# окружения (docker-compose.metrics.yml): маршрут
# telegram-clients уходит вебхуком, а не в Telegram напрямую,
# поэтому в конфиг Alertmanager эта тема не попадает вовсе.
# Печатаем её только затем, чтобы по логу деплоя было видно,
# куда пойдут инциденты.
if [ -n "${METRICS_TELEGRAM_TOPIC_ID:-}" ]; then
echo "Клиентские инциденты: тема ${METRICS_TELEGRAM_TOPIC_ID}, адресует alert-ack."
else
echo "::warning title=Тема клиентских инцидентов не задана::METRICS_TELEGRAM_TOPIC_ID пуст — alert-ack отправит инцидент в общую тему чата, где его не читают."
fi
# Инфраструктурная тема (#3163): telegram/telegram-heartbeat
# адресуют СЮДА, отдельно от темы клиентских инцидентов — иначе
# инфраструктурный шум (диск, память, просевший экспортер) и
# клиентский инцидент смешиваются в одной ленте и приучают
# пролистывать обе.
#
# Подставляем ЦЕЛОЙ СТРОКОЙ, а не значением, потому что envsubst
# не умеет условий: при пустом топике в конфиг попал бы
# `message_thread_id:` без значения, и Alertmanager не стартовал
# бы вовсе — то есть алертинг исчез бы целиком, а не «ушёл не в
# ту тему».
#
# Значение по умолчанию стоит ЗДЕСЬ, а не в секрете. Номер темы
# форума секретом не является: в репозитории уже лежат домены,
# пути на хостах, имена контейнеров и внешние адреса. Зато шаг
# «завести секрет руками» — это отказ, который уже случился:
# 27.08 два прогона подряд молча откатились на тему клиентских
# инцидентов, и тема «метрики» осталась пустой при полностью
# зелёном деплое.
#
# Прежний откат на METRICS_TELEGRAM_TOPIC_ID убран намеренно: он
# давал ровно то состояние, ради ухода от которого всё и
# затевалось — весь инфраструктурный поток в теме клиентских
# инцидентов, — и сообщал об этом строкой в логе, которую никто
# не читает. Молчаливое «почти правильно» хуже явной поломки.
#
# Переменная окружения по-прежнему перекрывает значение: переезд
# темы или другой чат решается ею, без правки кода.
INFRA_TOPIC_ID="${METRICS_TELEGRAM_INFRA_TOPIC_ID:-245}"
METRICS_TELEGRAM_INFRA_TOPIC_LINE=" message_thread_id: ${INFRA_TOPIC_ID}"
if [ -n "${METRICS_TELEGRAM_INFRA_TOPIC_ID:-}" ]; then
echo "Инфраструктура: тема ${INFRA_TOPIC_ID} из окружения."
else
echo "Инфраструктура: тема ${INFRA_TOPIC_ID} по умолчанию (METRICS_TELEGRAM_INFRA_TOPIC_ID не задана)."
fi
# Резервный приёмник GlitchTip (#3471) отвечает 503 на любой
# запрос, пока секрет пуст: тихо принимать чужие алерты настежь
# хуже, чем не принимать вовсе. Молчаливого отказа тут быть не
# должно — деплой обязан сказать, что канал не поднялся.
if [ -z "${ALERT_ACK_GLITCHTIP_SECRET:-}" ]; then
echo "::warning title=Резервный канал GlitchTip выключен::ALERT_ACK_GLITCHTIP_SECRET пуст — alert-ack отвечает 503 на /glitchtip, и при падении продуктового бэкенда его ошибки доставлять будет нечем."
fi
if [ -n "${METRICS_TELEGRAM_ONCALL:-}" ]; then
echo "Клиентские инциденты: зовём ${METRICS_TELEGRAM_ONCALL} поимённо."
else
echo "::warning title=Дежурный не задан::METRICS_TELEGRAM_ONCALL пуст — при клиентском инциденте сообщение придёт без упоминания и потеряется в общем потоке (#3078)."
fi
# rm перед записью обязателен: после chown ниже файл принадлежит 65534
# с правами 600, и на СЛЕДУЮЩЕМ деплое перенаправление в него уже не
# запишет. Каталог принадлежит деплой-пользователю, поэтому пересоздать
# файл он может, а перезаписать — нет.
#
# NB: rm обязан стоять ДО префикса переменных ниже. В #3127 он встал
# МЕЖДУ строками продолжения команды — и весь вызов envsubst уехал в
# комментарий, то есть конфиг переставал рендериться вовсе.
rm -f ops/metrics/alertmanager/alertmanager.yml
METRICS_TELEGRAM_BOT_TOKEN="$METRICS_TELEGRAM_BOT_TOKEN" \
METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \
METRICS_TELEGRAM_INFRA_TOPIC_LINE="$METRICS_TELEGRAM_INFRA_TOPIC_LINE" \
METRICS_TELEGRAM_ONCALL="${METRICS_TELEGRAM_ONCALL:-}" \
envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_INFRA_TOPIC_LINE} ${METRICS_TELEGRAM_ONCALL}' \
< ops/metrics/alertmanager/alertmanager.yml.tmpl \
> ops/metrics/alertmanager/alertmanager.yml
chmod 600 ops/metrics/alertmanager/alertmanager.yml
# В файле лежит токен бота, поэтому 600 не ослабляем. Но и amtool
# ниже, и сам Alertmanager в образе prom/alertmanager работают под
# `nobody` (65534) и файл владельца-деплойщика прочитать не могут:
# проверка падала на `open /tmp/am.yml: permission denied`, а
# контейнер после подъёма упал бы ровно там же. Гейт не поймали
# раньше только потому, что без токена профиль alerts вообще не
# включался и эта ветка не исполнялась ни разу.
#
# Отдаём файл тому, кто его читает. chown делаем одноразовым
# контейнером от root: passwordless sudo на хосте нет, а
# бинд-маунт правит host-инод напрямую.
#
# Почему не 644: это внесло бы токен в список файлов, читаемых
# любым локальным пользователем машины. Владение 65534 при 600
# оставляет доступ ровно у контейнера, и ни у кого больше.
docker run --rm --user 0:0 -v "$PWD/ops/metrics/alertmanager/alertmanager.yml:/tmp/am.yml" --entrypoint chown "$(grep -oE 'prom/alertmanager:[^ ]+' docker-compose.metrics.yml | head -1)" 65534:65534 /tmp/am.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 поднимается."
# Конфиг перерисован — значит у файла НОВЫЙ инод (см. rm выше).
# Помечаем, чтобы ниже пересоздать контейнер: почему это
# обязательно — объяснено у самого пересоздания.
ALERTMANAGER_RERENDERED=1
else
echo "::warning title=Алерты выключены::METRICS_TELEGRAM_BOT_TOKEN/CHAT_ID не заданы. Метрики и логи собираются, но при срабатывании правила НИКТО не будет уведомлён. Канал доставки — открытый вопрос #3078."
fi
# ── Цели file_sd для Prometheus (#3155) ────────────────────────
# Включатель профиля и цель для Prometheus обязаны стоять в ОДНОМ
# условии. Пока они жили порознь, вышло так: 27.08 профиль alerts
# подняли, Alertmanager стартовал Up и healthy, а файл целей остался
# плейсхолдером [] — Prometheus не видел ни одного приёмника
# (activeAlertmanagers: []) и сложил 1568 уведомлений в
# prometheus_notifications_dropped_total. Ни ошибки в логах, ни
# красного деплоя: снаружи алертинг выглядел рабочим.
#
# Пишем ДО `up`: свежесозданный контейнер обязан увидеть готовый
# файл. И пишем усечением на месте (`:>` вместо rm) — инод
# сохраняется, поэтому работающий Prometheus подхватывает
# содержимое сам, без пересоздания. Это ровно та ловушка одиночного
# бинд-маунта, что описана у Alertmanager ниже, только здесь её
# удаётся обойти, не трогая контейнер.
AM_TARGETS_FILE=ops/metrics/prometheus/alertmanager_targets.gen.yml
: > "$AM_TARGETS_FILE"
echo "# Файл рендерится деплоем (deploy-metrics.yml), правки руками затрутся." >> "$AM_TARGETS_FILE"
if [ "$PROFILES" = "alerts" ]; then
echo '- targets: ["alertmanager:9093"]' >> "$AM_TARGETS_FILE"
echo " labels:" >> "$AM_TARGETS_FILE"
echo " host: infra" >> "$AM_TARGETS_FILE"
echo "Prometheus: приёмник alertmanager:9093 прописан в целях."
else
# Пустой список, а НЕ отсутствующий файл: одиночный бинд-маунт
# несуществующего пути docker подменяет каталогом, и Prometheus
# не стартует вовсе.
echo "# Профиль alerts выключен — приёмников нет." >> "$AM_TARGETS_FILE"
echo "[]" >> "$AM_TARGETS_FILE"
echo "Prometheus: профиль alerts выключен — целей нет, это штатно."
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
# ── Alertmanager: пересоздать, если конфиг перерисовали ─────────
# `up -d` выше СЧИТАЕТ alertmanager неизменившимся: он сравнивает
# описание сервиса, а содержимое бинд-маунта в это сравнение не
# входит. Контейнер продолжает работать — и продолжает держать
# СТАРЫЙ инод файла: `rm` при рендере не правит файл на месте, а
# создаёт новый, и открытый дескриптор внутри контейнера смотрит
# на прежний, уже удалённый.
#
# Отказ полностью беззвучный и оттого злой. На диске лежит новый
# конфиг, `amtool check-config` его проверяет и одобряет, деплой
# зелёный — а маршрутизация работает по старому. Пойман на проде
# 27.08: после #3136 на диске уже стоял `webhook_configs` на
# alert-ack, а контейнер всё ещё слал напрямую в Telegram. Значит
# и прежние правки маршрутов доезжали лишь тогда, когда контейнер
# пересоздавался по другой причине.
#
# Перезагрузка по SIGHUP/API не помогает: она перечитывает тот же
# открытый инод. Помогает только пересоздание контейнера.
if [ "${ALERTMANAGER_RERENDERED:-0}" = "1" ]; then
COMPOSE_PROFILES="$PROFILES" \
docker compose -p gendesign-metrics -f docker-compose.metrics.yml \
up -d --force-recreate alertmanager
echo "Alertmanager: контейнер пересоздан — иначе читал бы конфиг по старому иноду."
fi
# ── 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
# ── Prometheus: конфиг/правила лежат на диске, `up -d` их не
# перечитывает ────────────────────────────────────────────────
# Тот же класс бага, что у Caddyfile и alertmanager.yml выше:
# docker compose сравнивает описание сервиса, а НЕ содержимое
# бинд-маунта, поэтому уже работающий контейнер продолжает жить
# со старым конфигом сколько угодно — на проде дошло до 16 суток
# незамеченными (#3467): lastConfigTime совпадал со startTime
# контейнера при каждом зелёном деплое, менявшем ops/metrics/prometheus/**.
#
# У Prometheus, в отличие от Alertmanager (см. комментарий выше),
# /-/reload переоткрывает файлы ПО ПУТИ заново, поэтому новый инод
# после `git reset --hard` подхватывается без пересоздания
# контейнера. --web.enable-lifecycle уже включён в compose ради
# этого шага (см. docker-compose.metrics.yml) — просто раньше
# никто не звал сам reload.
#
# promtool проверяет ОБА файла ДО reload: битый конфиг не должен
# положить работающий Prometheus молчаливым откатом на дефолты.
if docker exec gendesign-prometheus promtool check config /etc/prometheus/prometheus.yml \
&& docker exec gendesign-prometheus sh -c 'promtool check rules /etc/prometheus/rules/*.yml'; then
LAST_CONFIG_BEFORE="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
docker exec gendesign-prometheus wget -q -O /dev/null --post-data='' http://localhost:9090/-/reload
# lastConfigTime обновляется на КАЖДЫЙ успешный reload, даже
# если содержимое конфига не поменялось — значит сравнение
# "было/стало" надёжно ловит и несостоявшийся reload, и
# изменившиеся правила.
LAST_CONFIG_AFTER=""
for i in $(seq 1 10); do
LAST_CONFIG_AFTER="$(docker exec gendesign-prometheus wget -qO- http://localhost:9090/api/v1/status/runtimeinfo | grep -oE '"lastConfigTime":"[^"]*"')"
[ -n "$LAST_CONFIG_AFTER" ] && [ "$LAST_CONFIG_AFTER" != "$LAST_CONFIG_BEFORE" ] && break
sleep 1
done
if [ -z "$LAST_CONFIG_AFTER" ] || [ "$LAST_CONFIG_AFTER" = "$LAST_CONFIG_BEFORE" ]; then
echo "ОШИБКА: reload Prometheus не подтверждён — lastConfigTime не изменился ($LAST_CONFIG_BEFORE)."
exit 1
fi
echo "Prometheus: конфиг и правила проверены, reload подтверждён ($LAST_CONFIG_BEFORE -> $LAST_CONFIG_AFTER)."
else
echo "ОШИБКА: конфиг/правила Prometheus не проходят promtool — reload НЕ выполнен, работающий Prometheus остаётся на прежнем конфиге."
exit 1
fi
# ═══ АГЕНТЫ — оба хоста ═══════════════════════════════════════════════════
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
# Конфиг Alloy — бинд-маунт ОДНОГО файла, а `git reset --hard` выше пишет
# его новым инодом: `up -d` изменения не видит, контейнер держит старый.
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 --force-recreate alloy
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
[ "$(stat -c %i ops/metrics/alloy/alloy-apps.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }
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
# Конфиг Alloy — бинд-маунт ОДНОГО файла, а `git reset --hard` (джоба server)
# пишет его новым инодом: `up -d` изменения не видит, контейнер держит старый.
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 --force-recreate alloy
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
[ "$(stat -c %i ops/metrics/alloy/alloy-infra.alloy)" = "$(docker exec gendesign-alloy stat -c %i /etc/alloy/config.alloy)" ] \
|| { echo "::error::alloy читает старый инод конфига"; exit 1; }