fix(ops): не ронять деплой метрик пустым/непереданным watchdog-секретом
Some checks failed
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 30s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Failing after 1m59s
CI / openapi-codegen-check (pull_request) Successful in 3m4s
Some checks failed
CI Trade-In / changes (pull_request) Successful in 15s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 30s
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Failing after 1m59s
CI / openapi-codegen-check (pull_request) Successful in 3m4s
amtool check-config падал на "unsupported scheme \"\" for URL": шаблон
заводил url: "${METRICS_WATCHDOG_PING_URL}" напрямую, а envsubst в
deploy-metrics.yml вызывается с явным allow-list переменных, в который
эту новую переменную не добавили — литерал плейсхолдера (или пустая
строка, если бы добавили без блока) доезжал до amtool и валил проверку
целиком, то есть ронял ВЕСЬ Alertmanager, а не только Watchdog.
Тот же приём, что уже применён для темы форума: подставляется ЦЕЛЫЙ
БЛОК (webhook_configs целиком), а не голое значение url. Собирается в
deploy-metrics.yml через METRICS_WATCHDOG_PING_BLOCK — если секрет задан,
блок с url и send_resolved; если пуст, пустая строка и ::warning в лог.
Пустой блок оставляет receiver watchdog-ping без единого *_configs — это
валидный конфиг Alertmanager (приёмник молча ничего не шлёт), а не
деградация всего алертинга. METRICS_WATCHDOG_PING_URL добавлен в env/envs
appleboy/ssh-action и в allow-list envsubst.
_render() в test_3078_alert_topic.py переписан так, чтобы подставлять
РОВНО тот набор переменных, что реально в allow-list деплоя (вытащен
регексом из самого воркфлоу), а не свой отдельный список — так и
воспроизвёлся бы этот регресс автоматически. Добавлен явный тест
test_every_template_placeholder_is_in_envsubst_allowlist на этот инвариант
и два теста на обе ветки watchdog-секрета (пуст / задан), проверенные
amtool check-config на prom/alertmanager:v0.28.0 вручную для обоих
рендеров.
This commit is contained in:
parent
dc9a93c048
commit
b8d2e74951
3 changed files with 125 additions and 17 deletions
|
|
@ -91,12 +91,16 @@ jobs:
|
||||||
METRICS_TELEGRAM_INFRA_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_INFRA_TOPIC_ID }}
|
METRICS_TELEGRAM_INFRA_TOPIC_ID: ${{ secrets.METRICS_TELEGRAM_INFRA_TOPIC_ID }}
|
||||||
METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }}
|
METRICS_TELEGRAM_ONCALL: ${{ secrets.METRICS_TELEGRAM_ONCALL }}
|
||||||
ALERT_ACK_GLITCHTIP_SECRET: ${{ secrets.ALERT_ACK_GLITCHTIP_SECRET }}
|
ALERT_ACK_GLITCHTIP_SECRET: ${{ secrets.ALERT_ACK_GLITCHTIP_SECRET }}
|
||||||
|
# #3589: URL внешнего deadman-приёмника Watchdog (healthchecks.io и
|
||||||
|
# аналоги). Пусто — watchdog-ping остаётся без конфигов, см. блок
|
||||||
|
# METRICS_WATCHDOG_PING_BLOCK ниже.
|
||||||
|
METRICS_WATCHDOG_PING_URL: ${{ secrets.METRICS_WATCHDOG_PING_URL }}
|
||||||
# #3471: секрет ретранслятора Telegram Bot API (tg-relay). Пусто —
|
# #3471: секрет ретранслятора Telegram Bot API (tg-relay). Пусто —
|
||||||
# профиль relay не включаем (см. PROFILES ниже), а не падаем в
|
# профиль relay не включаем (см. PROFILES ниже), а не падаем в
|
||||||
# рестарт-луп: контейнер сам делает SystemExit на пустом секрете.
|
# рестарт-луп: контейнер сам делает SystemExit на пустом секрете.
|
||||||
TG_RELAY_SECRET: ${{ secrets.TG_RELAY_SECRET }}
|
TG_RELAY_SECRET: ${{ secrets.TG_RELAY_SECRET }}
|
||||||
with:
|
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
|
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,METRICS_WATCHDOG_PING_URL
|
||||||
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
|
host: ${{ secrets.INFRA_DEPLOY_HOST || secrets.DEPLOY_HOST }}
|
||||||
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
|
username: ${{ secrets.INFRA_DEPLOY_USER || secrets.DEPLOY_USER }}
|
||||||
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
|
key: ${{ secrets.INFRA_DEPLOY_SSH_KEY || secrets.DEPLOY_SSH_KEY }}
|
||||||
|
|
@ -193,6 +197,24 @@ jobs:
|
||||||
echo "Инфраструктура: тема ${INFRA_TOPIC_ID} по умолчанию (METRICS_TELEGRAM_INFRA_TOPIC_ID не задана)."
|
echo "Инфраструктура: тема ${INFRA_TOPIC_ID} по умолчанию (METRICS_TELEGRAM_INFRA_TOPIC_ID не задана)."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Watchdog-пинг во внешний deadman-приёмник (healthchecks.io и
|
||||||
|
# аналоги) — заменяет регулярные сообщения в Telegram, см.
|
||||||
|
# комментарий у watchdog-ping в alertmanager.yml.tmpl. Блок
|
||||||
|
# целиком, а не значение — та же причина, что у
|
||||||
|
# METRICS_TELEGRAM_INFRA_TOPIC_LINE: пустой `url:` в
|
||||||
|
# receiver'е не деградирует, а валит amtool check-config
|
||||||
|
# целиком, то есть роняет ВЕСЬ алертинг из-за одного
|
||||||
|
# необязательного получателя. Секрет пока не заведён —
|
||||||
|
# деградация корректна: receiver остаётся без `*_configs` и
|
||||||
|
# молча ничего никуда не шлёт, amtool это пропускает.
|
||||||
|
if [ -n "${METRICS_WATCHDOG_PING_URL:-}" ]; then
|
||||||
|
METRICS_WATCHDOG_PING_BLOCK=$(printf ' webhook_configs:\n - url: "%s"\n send_resolved: false' "${METRICS_WATCHDOG_PING_URL}")
|
||||||
|
echo "Watchdog: внешний deadman-пинг настроен."
|
||||||
|
else
|
||||||
|
METRICS_WATCHDOG_PING_BLOCK=""
|
||||||
|
echo "::warning title=Watchdog без deadman-пинга::METRICS_WATCHDOG_PING_URL пуст — сторож мониторинга никуда не сообщает о своей живости. Заведи аккаунт healthchecks.io (или аналог) и секрет, иначе обрыв канала доставки не заметит никто (#3589)."
|
||||||
|
fi
|
||||||
|
|
||||||
# Резервный приёмник GlitchTip (#3471) отвечает 503 на любой
|
# Резервный приёмник GlitchTip (#3471) отвечает 503 на любой
|
||||||
# запрос, пока секрет пуст: тихо принимать чужие алерты настежь
|
# запрос, пока секрет пуст: тихо принимать чужие алерты настежь
|
||||||
# хуже, чем не принимать вовсе. Молчаливого отказа тут быть не
|
# хуже, чем не принимать вовсе. Молчаливого отказа тут быть не
|
||||||
|
|
@ -221,7 +243,8 @@ jobs:
|
||||||
METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \
|
METRICS_TELEGRAM_CHAT_ID="$METRICS_TELEGRAM_CHAT_ID" \
|
||||||
METRICS_TELEGRAM_INFRA_TOPIC_LINE="$METRICS_TELEGRAM_INFRA_TOPIC_LINE" \
|
METRICS_TELEGRAM_INFRA_TOPIC_LINE="$METRICS_TELEGRAM_INFRA_TOPIC_LINE" \
|
||||||
METRICS_TELEGRAM_ONCALL="${METRICS_TELEGRAM_ONCALL:-}" \
|
METRICS_TELEGRAM_ONCALL="${METRICS_TELEGRAM_ONCALL:-}" \
|
||||||
envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_INFRA_TOPIC_LINE} ${METRICS_TELEGRAM_ONCALL}' \
|
METRICS_WATCHDOG_PING_BLOCK="$METRICS_WATCHDOG_PING_BLOCK" \
|
||||||
|
envsubst '${METRICS_TELEGRAM_BOT_TOKEN} ${METRICS_TELEGRAM_CHAT_ID} ${METRICS_TELEGRAM_INFRA_TOPIC_LINE} ${METRICS_TELEGRAM_ONCALL} ${METRICS_WATCHDOG_PING_BLOCK}' \
|
||||||
< ops/metrics/alertmanager/alertmanager.yml.tmpl \
|
< ops/metrics/alertmanager/alertmanager.yml.tmpl \
|
||||||
> ops/metrics/alertmanager/alertmanager.yml
|
> ops/metrics/alertmanager/alertmanager.yml
|
||||||
chmod 600 ops/metrics/alertmanager/alertmanager.yml
|
chmod 600 ops/metrics/alertmanager/alertmanager.yml
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,56 @@ WORKFLOW = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
|
||||||
|
|
||||||
INFRA_TOPIC_LINE = " message_thread_id: 245"
|
INFRA_TOPIC_LINE = " message_thread_id: 245"
|
||||||
|
|
||||||
|
# Заглушки для переменных, которые деплой может подставить. Значение
|
||||||
|
# METRICS_WATCHDOG_PING_BLOCK по умолчанию пустое — это реальный дефолт
|
||||||
|
# деплоя, когда секрет не заведён (#3589), а не тестовое упрощение.
|
||||||
|
_DUMMY_VALUES = {
|
||||||
|
"METRICS_TELEGRAM_BOT_TOKEN": "123:ABC",
|
||||||
|
"METRICS_TELEGRAM_CHAT_ID": "-100123",
|
||||||
|
"METRICS_TELEGRAM_ONCALL": "",
|
||||||
|
"METRICS_WATCHDOG_PING_BLOCK": "",
|
||||||
|
}
|
||||||
|
|
||||||
def _render(infra_topic_line: str) -> dict:
|
|
||||||
"""Повторяет подстановку деплоя и разбирает результат как YAML.
|
def _envsubst_allowlist() -> set[str]:
|
||||||
|
"""Реальный список переменных, которые деплой передаёт в envsubst.
|
||||||
|
|
||||||
|
Не хардкодим копию списка — #3589 случился именно так: шаблон завёл
|
||||||
|
`${METRICS_WATCHDOG_PING_URL}`, а список envsubst в деплое не пополнили,
|
||||||
|
и тест этого не заметил, потому что сам подставлял значение мимо деплоя.
|
||||||
|
"""
|
||||||
|
assert WORKFLOW.is_file(), f"нет {WORKFLOW} — воркфлоу переехал, гейт ослеп"
|
||||||
|
text = WORKFLOW.read_text(encoding="utf-8")
|
||||||
|
m = re.search(r"envsubst '([^']+)'", text)
|
||||||
|
assert m, "не нашёл вызов envsubst в деплое"
|
||||||
|
return set(re.findall(r"\$\{(\w+)\}", m.group(1)))
|
||||||
|
|
||||||
|
|
||||||
|
def _template_placeholders() -> set[str]:
|
||||||
|
text = TMPL.read_text(encoding="utf-8")
|
||||||
|
return set(re.findall(r"\$\{(\w+)\}", text))
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_template_placeholder_is_in_envsubst_allowlist() -> None:
|
||||||
|
"""Регресс #3589: переменная шаблона обязана быть в allow-list envsubst.
|
||||||
|
|
||||||
|
Тогда в шаблоне появился `${METRICS_WATCHDOG_PING_URL}`, а список
|
||||||
|
envsubst в деплое не пополнили. envsubst подставляет ТОЛЬКО
|
||||||
|
перечисленные переменные — забытая долетает до `amtool check-config`
|
||||||
|
литералом плейсхолдера и валит проверку (`unsupported scheme ""`), то
|
||||||
|
есть роняет ВЕСЬ Alertmanager, а не только Watchdog.
|
||||||
|
"""
|
||||||
|
missing = _template_placeholders() - _envsubst_allowlist()
|
||||||
|
assert not missing, f"эти переменные шаблона деплой не подставляет: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def _render(infra_topic_line: str, watchdog_ping_block: str | None = None) -> dict:
|
||||||
|
"""Повторяет ТОЧНО ТУ ЖЕ подстановку, что делает деплой, и разбирает YAML.
|
||||||
|
|
||||||
|
Подставляются только переменные из реального allow-list envsubst деплоя
|
||||||
|
(`_envsubst_allowlist`) — не весь известный тесту набор. Так регресс
|
||||||
|
#3589 (переменная в шаблоне, забытая в allow-list) ловится именно здесь:
|
||||||
|
`assert "${" not in rendered` ниже упадёт, если что-то не подставилось.
|
||||||
|
|
||||||
Строка темы в шаблоне ровно одна — инфраструктурная (#3163). Тема
|
Строка темы в шаблоне ровно одна — инфраструктурная (#3163). Тема
|
||||||
клиентских инцидентов сюда не подставляется вовсе: маршрут
|
клиентских инцидентов сюда не подставляется вовсе: маршрут
|
||||||
|
|
@ -56,12 +103,18 @@ def _render(infra_topic_line: str) -> dict:
|
||||||
"""
|
"""
|
||||||
assert TMPL.is_file(), f"нет {TMPL} — шаблон переехал, гейт ослеп"
|
assert TMPL.is_file(), f"нет {TMPL} — шаблон переехал, гейт ослеп"
|
||||||
text = TMPL.read_text(encoding="utf-8")
|
text = TMPL.read_text(encoding="utf-8")
|
||||||
rendered = (
|
|
||||||
text.replace("${METRICS_TELEGRAM_BOT_TOKEN}", "123:ABC")
|
values = dict(_DUMMY_VALUES)
|
||||||
.replace("${METRICS_TELEGRAM_CHAT_ID}", "-100123")
|
values["METRICS_TELEGRAM_INFRA_TOPIC_LINE"] = infra_topic_line
|
||||||
.replace("${METRICS_TELEGRAM_INFRA_TOPIC_LINE}", infra_topic_line)
|
if watchdog_ping_block is not None:
|
||||||
.replace("${METRICS_WATCHDOG_PING_URL}", "https://hc-ping.com/dummy")
|
values["METRICS_WATCHDOG_PING_BLOCK"] = watchdog_ping_block
|
||||||
)
|
|
||||||
|
allowlist = _envsubst_allowlist()
|
||||||
|
rendered = text
|
||||||
|
for name, value in values.items():
|
||||||
|
if name in allowlist:
|
||||||
|
rendered = rendered.replace("${" + name + "}", value)
|
||||||
|
|
||||||
assert "${" not in rendered, (
|
assert "${" not in rendered, (
|
||||||
"в отрендеренном конфиге остался литерал плейсхолдера — "
|
"в отрендеренном конфиге остался литерал плейсхолдера — "
|
||||||
"значит в шаблоне появилась подстановка, о которой тест не знает"
|
"значит в шаблоне появилась подстановка, о которой тест не знает"
|
||||||
|
|
@ -69,6 +122,33 @@ def _render(infra_topic_line: str) -> dict:
|
||||||
return yaml.safe_load(rendered)
|
return yaml.safe_load(rendered)
|
||||||
|
|
||||||
|
|
||||||
|
def test_watchdog_receiver_without_secret_has_no_configs_and_parses() -> None:
|
||||||
|
"""Секрет не заведён (реальное состояние прода сейчас) — конфиг всё равно жив.
|
||||||
|
|
||||||
|
`watchdog-ping` остаётся без единого `*_configs` — валидный receiver,
|
||||||
|
Alertmanager его просто пропускает. Деградация корректна: Watchdog никуда
|
||||||
|
не пингует, но остальной алертинг (`telegram`, `telegram-clients`) цел.
|
||||||
|
"""
|
||||||
|
cfg = _render(INFRA_TOPIC_LINE, watchdog_ping_block="")
|
||||||
|
receivers = {r["name"]: r for r in cfg["receivers"]}
|
||||||
|
assert "watchdog-ping" in receivers, "receiver watchdog-ping пропал из конфига"
|
||||||
|
watchdog = receivers["watchdog-ping"]
|
||||||
|
assert "webhook_configs" not in watchdog, "пустой секрет не должен оставлять webhook_configs"
|
||||||
|
assert "telegram" in receivers and "telegram-clients" in receivers, (
|
||||||
|
"остальной алертинг не должен пострадать из-за пустого watchdog-секрета"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_watchdog_receiver_with_secret_gets_webhook() -> None:
|
||||||
|
"""Секрет задан — Watchdog реально пингует внешний deadman-приёмник."""
|
||||||
|
block = ' webhook_configs:\n - url: "https://hc-ping.com/dummy"\n send_resolved: false'
|
||||||
|
cfg = _render(INFRA_TOPIC_LINE, watchdog_ping_block=block)
|
||||||
|
receivers = {r["name"]: r for r in cfg["receivers"]}
|
||||||
|
hooks = receivers["watchdog-ping"].get("webhook_configs") or []
|
||||||
|
assert hooks and hooks[0].get("url") == "https://hc-ping.com/dummy"
|
||||||
|
assert hooks[0].get("send_resolved") is False
|
||||||
|
|
||||||
|
|
||||||
def _telegram_configs(cfg: dict) -> list[dict]:
|
def _telegram_configs(cfg: dict) -> list[dict]:
|
||||||
out = []
|
out = []
|
||||||
for r in cfg.get("receivers", []):
|
for r in cfg.get("receivers", []):
|
||||||
|
|
|
||||||
|
|
@ -153,11 +153,16 @@ ${METRICS_TELEGRAM_INFRA_TOPIC_LINE}
|
||||||
send_resolved: true
|
send_resolved: true
|
||||||
|
|
||||||
# Внешний deadman-приёмник вместо Telegram — см. комментарий у маршрута
|
# Внешний deadman-приёмник вместо Telegram — см. комментарий у маршрута
|
||||||
# Watchdog выше. URL — HTTPS-пинг вида healthchecks.io/ping/<uuid>,
|
# Watchdog выше. Блок целиком (не значение) подставляется деплоем в
|
||||||
# значение живёт в METRICS_WATCHDOG_PING_URL на хосте (как и остальные
|
# METRICS_WATCHDOG_PING_BLOCK — тот же приём, что у
|
||||||
# METRICS_* секреты, в git не попадает). `send_resolved: false`: сервису
|
# METRICS_TELEGRAM_INFRA_TOPIC_LINE, и по той же причине: envsubst не умеет
|
||||||
# не нужно resolved-событие, важен сам факт регулярного POST.
|
# условий. Секрет ещё не заведён на хосте — деплой в этом случае подставит
|
||||||
|
# ПУСТУЮ строку, и receiver останется без единого `*_configs`. Это валидный
|
||||||
|
# Alertmanager-конфиг: приёмник без конфигов просто молча отбрасывает
|
||||||
|
# уведомление, амtool его пропускает. Одинарная подстановка ЗНАЧЕНИЯ url
|
||||||
|
# (переменная-URL напрямую внутри готового ключа `url:`) сюда не годится:
|
||||||
|
# непустой ключ с пустым значением или литералом плейсхолдера амtool валит
|
||||||
|
# целиком (`unsupported scheme ""`), а с этим — весь Alertmanager, не
|
||||||
|
# только Watchdog.
|
||||||
- name: watchdog-ping
|
- name: watchdog-ping
|
||||||
webhook_configs:
|
${METRICS_WATCHDOG_PING_BLOCK}
|
||||||
- url: "${METRICS_WATCHDOG_PING_URL}"
|
|
||||||
send_resolved: false
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue