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 }} # #3471: секрет ретранслятора Telegram Bot API (tg-relay). Пусто — # профиль relay не включаем (см. PROFILES ниже), а не падаем в # рестарт-луп: контейнер сам делает SystemExit на пустом секрете. TG_RELAY_SECRET: ${{ secrets.TG_RELAY_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,TG_RELAY_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 # Ретранслятор Telegram Bot API (#3471, PR #3487 сломал прод: сервис # без profiles уходил в SystemExit на пустом секрете и висел в # Restarting). Профиль relay включаем НЕЗАВИСИМО от alerts — это # разные каналы (один шлёт алерты боту, другой ретранслирует # продуктовый Bot API трафик с Selectel). PROFILES — список через # запятую, как того требует COMPOSE_PROFILES. if [ -n "${TG_RELAY_SECRET:-}" ]; then PROFILES="${PROFILES:+$PROFILES,}relay" echo "Ретранслятор Telegram: секрет задан, профиль relay включён." else echo "::warning title=Резервный ретранслятор Telegram выключен::TG_RELAY_SECRET пуст — tg-relay не поднимается (профиль relay выключен). Продуктовый Telegram-трафик пойдёт напрямую с Selectel, где теряется примерно каждый четвёртый короткий запрос." 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" # Сравнение через case, а не "=": PROFILES теперь может быть # комбинацией через запятую ("alerts,relay") с тех пор, как #3471 # завёл независимый профиль relay — точное равенство строке # "alerts" сломалось бы молча в тот момент, когда оба профиля # включены разом. case ",$PROFILES," in *,alerts,*) echo '- targets: ["alertmanager:9093"]' >> "$AM_TARGETS_FILE" echo " labels:" >> "$AM_TARGETS_FILE" echo " host: infra" >> "$AM_TARGETS_FILE" echo "Prometheus: приёмник alertmanager:9093 прописан в целях." ;; *) # Пустой список, а НЕ отсутствующий файл: одиночный бинд-маунт # несуществующего пути docker подменяет каталогом, и Prometheus # не стартует вовсе. echo "# Профиль alerts выключен — приёмников нет." >> "$AM_TARGETS_FILE" echo "[]" >> "$AM_TARGETS_FILE" echo "Prometheus: профиль alerts выключен — целей нет, это штатно." ;; esac # ── 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 # ── alert-ack / tg-relay: код монтируется с хоста ──────────────── # Тот же класс бага, что у Alertmanager (см. ниже) и Caddyfile: # `up -d` сравнивает ОПИСАНИЕ сервиса, а не содержимое бинд-маунта. # alert-ack и tg-relay получают код именно бинд-маунтом файла # (./ops/metrics/{alert-ack,tg-relay}/app.py:/app/app.py:ro), а не # сборкой образа — правка app.py оставляет уже запущенный # контейнер работать на СТАРОМ коде в памяти интерпретатора сколько # угодно, и `up -d` этого не видит вовсе. # # Пойман на проде 12.09.2026: PR #3490 (фикс alert-ack) слился, # `git reset --hard` обновил файл на диске (grep по новому # комментарию находил его), а gendesign-alert-ack, запущенный за # 25 минут до этого, продолжал отвечать по старой логике — # зелёный деплой, тихо неверное поведение. Починил только ручной # `docker restart gendesign-alert-ack`. force-recreate здесь — # замена этому ручному шагу. # # case ",$PROFILES," — пересоздаём только если профиль сервиса # реально включён в ЭТОМ прогоне, иначе force-recreate ругается на # несуществующий контейнер (сервис не создан вовсе). case ",$PROFILES," in *,alerts,*) COMPOSE_PROFILES="$PROFILES" \ docker compose -p gendesign-metrics -f docker-compose.metrics.yml \ up -d --force-recreate alert-ack echo "alert-ack: контейнер пересоздан — код монтируется с хоста, up -d его не подхватывает (#3490)." ;; esac case ",$PROFILES," in *,relay,*) COMPOSE_PROFILES="$PROFILES" \ docker compose -p gendesign-metrics -f docker-compose.metrics.yml \ up -d --force-recreate tg-relay echo "tg-relay: контейнер пересоздан — код монтируется с хоста, up -d его не подхватывает (#3490)." ;; esac # ── 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; }