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
560 lines
40 KiB
YAML
560 lines
40 KiB
YAML
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; }
|