fix(observability): стек не поднимался — привилегированную роль спрашиваем у контейнера, а не угадываем (#3078) #3105

Merged
lekss361 merged 1 commit from fix/3078-metrics-role-discovery into main 2026-08-26 09:27:30 +00:00
3 changed files with 204 additions and 2 deletions

View file

@ -22,7 +22,11 @@ on:
- "caddy/sites/infra.caddy"
- "caddy/metrics-ui.caddy.snippet"
- "caddy/metrics-ingest.caddy.snippet"
- "scripts/setup-metrics-secrets.sh"
# Глоб, а не точечный `setup-metrics-secrets.sh` (#2203: класс бага, а не
# один файл). Деплой запускает ТРИ setup-скрипта — secrets, grafana-role и
# exporter-dsn, — а в триггере стоял только первый: правка двух остальных
# не заводила выкат, и на хосте продолжала исполняться старая версия молча.
- "scripts/setup-metrics-*.sh"
- ".forgejo/workflows/deploy-metrics.yml"
workflow_dispatch:

View file

@ -0,0 +1,181 @@
"""Regression: привилегированная роль ищется у контейнера, а не угадывается (#3078).
Что произошло. `scripts/setup-metrics-grafana-role.sh` заводит read-only роль
для датасорса Grafana и для этого ищет роль с правом CREATE ROLE. Искал он её
перебором трёх имён:
for candidate in glitchtip forgejo postgres; do
Ни одно из трёх не совпадает ни с одним реальным кластером проекта имя роли
задаётся переменной `POSTGRES_USER` образа postgres и у нас везде своё:
gendesign-infra-postgres infra
gendesign-postgres-1 gendesign
tradein-postgres tradein
Поэтому деплой стека наблюдаемости падал на первом же прогоне после мержа #3099
(задача 23657, 26.08 08:55):
err: ОШИБКА: не нашёл роль с правом CREATE ROLE в gendesign-infra-postgres
и вместе с ним пропускались зависимые джобы `agent-apps` / `agent-infra` ни
одного контейнера стека не поднялось ни на одном хосте.
Замер на живом контейнере 26.08 (read-only, ничего не создавалось):
POSTGRES_USER изнутри контейнера: infra
кандидат glitchtip отказ
кандидат forgejo отказ
кандидат postgres отказ
кандидат infra 1
Фикс спросить контейнер вместо угадывания: `POSTGRES_USER` это ровно та
переменная, которой роль создана при initdb, то есть источник истины. Прежний
список оставлен ПОСЛЕ него запасным путём он пригодится кластеру, поднятому
не из образа postgres, где переменная пуста.
ИРОНИЯ, РАДИ КОТОРОЙ ЭТОТ ТЕСТ: в комментарии над самим перебором было написано
«угадывать «postgres» неверно» и дальше шло угадывание. Тест ниже исполняет
РЕАЛЬНЫЙ скрипт с подставным `docker` и проверяет фактический выбор роли, а не
наличие правильных слов в комментарии.
"""
from __future__ import annotations
import shutil
import subprocess
from pathlib import Path
import pytest
# backend/tests/ops/<этот файл> → корень репозитория
REPO_ROOT = Path(__file__).resolve().parents[3]
SCRIPT = REPO_ROOT / "scripts" / "setup-metrics-grafana-role.sh"
# См. обоснование shutil.which в test_2203_backup_trailer_grep_dashdash.py:
# голое "bash" на Windows с WSL резолвится в System32\bash.exe.
BASH = shutil.which("bash")
if BASH is None: # pragma: no cover - окружение без bash не запустит эти тесты
pytest.skip("bash не найден в PATH — тест требует shell-исполнения", allow_module_level=True)
# Подставной `docker`: отвечает за контейнер, отдаёт заданный POSTGRES_USER и
# принимает psql только от роли PRIV_ROLE. Код 28 не нужен — psql на отказе
# просто выходит ненулём, как в проде при неверной роли.
_DOCKER_STUB = r"""#!/usr/bin/env bash
case "$1" in
inspect) exit 0 ;;
exec)
args="$*"
case "$args" in
*"printf %s"*) printf '%s' "${PG_USER_STUB:-}"; exit 0 ;;
esac
u=""; prev=""
for a in "$@"; do
if [ "$prev" = "-U" ]; then u="$a"; break; fi
prev="$a"
done
case "$args" in
*-tAc*)
if [ "$u" = "${PRIV_ROLE:-}" ]; then echo 1; exit 0; fi
exit 1 ;;
*)
cat >/dev/null 2>&1 || true
if [ "$u" = "${PRIV_ROLE:-}" ]; then exit 0; fi
exit 1 ;;
esac ;;
esac
exit 0
"""
_HARNESS = r"""
set -u
stubdir=$(mktemp -d)
cat > "$stubdir/docker" <<'STUB'
@@STUB@@
STUB
chmod +x "$stubdir/docker"
export PATH="$stubdir:$PATH"
export PG_USER_STUB='@@PG_USER@@'
export PRIV_ROLE='@@PRIV@@'
export GLITCHTIP_RO_PASSWORD='stub-pass'
bash '@@SCRIPT@@'
echo "RC=$?"
"""
def _run(pg_user: str, priv_role: str) -> tuple[str, str]:
"""Гоняет РЕАЛЬНЫЙ скрипт: контейнер сообщает pg_user, привилегии есть у priv_role."""
assert SCRIPT.is_file(), f"нет {SCRIPT} — скрипт переехал, гейт ослеп"
harness = (
_HARNESS.replace("@@STUB@@", _DOCKER_STUB)
.replace("@@PG_USER@@", pg_user)
.replace("@@PRIV@@", priv_role)
.replace("@@SCRIPT@@", SCRIPT.as_posix())
)
proc = subprocess.run([BASH, "-c", harness], capture_output=True, timeout=30)
return (
proc.stdout.decode("utf-8", errors="replace"),
proc.stderr.decode("utf-8", errors="replace"),
)
def test_picks_role_reported_by_container() -> None:
"""Прод-случай: роль `infra`, которой нет ни в одном угадываемом имени.
Ядро регресса до фикса скрипт перебирал glitchtip/forgejo/postgres,
получал отказ на всех трёх и выходил с ошибкой.
"""
out, err = _run(pg_user="infra", priv_role="infra")
assert "привилегированная роль: infra" in out, f"роль из контейнера не выбрана.\n{out}\n{err}"
assert "RC=0" in out, f"скрипт должен отработать успешно.\nstdout:\n{out}\nstderr:\n{err}"
assert "не нашёл роль" not in err
@pytest.mark.parametrize("pg_user", ["gendesign", "tradein"])
def test_works_for_other_project_clusters(pg_user: str) -> None:
"""Два других кластера проекта — их имена тоже не входили в перебор."""
out, _err = _run(pg_user=pg_user, priv_role=pg_user)
assert f"привилегированная роль: {pg_user}" in out
def test_falls_back_to_name_list_when_variable_is_empty() -> None:
"""Кластер не из образа postgres: POSTGRES_USER пуст → работает прежний перебор.
Инвариант: фикс ДОБАВЛЯЕТ источник истины, а не отменяет запасной путь.
"""
out, _err = _run(pg_user="", priv_role="forgejo")
assert "привилегированная роль: forgejo" in out, f"запасной перебор сломан.\n{out}"
def test_still_fails_loudly_when_no_role_has_the_right() -> None:
"""Когда привилегий нет ни у кого — по-прежнему громкая ошибка, а не тихий успех."""
out, err = _run(pg_user="nobody", priv_role="__никто__")
assert "не нашёл роль с правом CREATE ROLE" in err, f"ошибка должна остаться громкой.\n{err}"
assert "RC=0" not in out, "скрипт не должен рапортовать успех, не создав роль"
def test_every_setup_script_the_workflow_runs_is_also_a_trigger() -> None:
"""Скрипт, который деплой запускает, обязан заводить этот же деплой.
Иначе правка скрипта не вызывает выкат, и на хосте молча остаётся старая
версия тот же класс, что #2203 закрыл глобом `ops/*.sh`. Конкретно здесь
в `paths:` стоял только `setup-metrics-secrets.sh`, а запускались три
скрипта.
"""
import fnmatch
import re
wf = REPO_ROOT / ".forgejo" / "workflows" / "deploy-metrics.yml"
assert wf.is_file(), f"нет {wf} — воркфлоу переехал, гейт ослеп"
text = wf.read_text(encoding="utf-8")
invoked = set(re.findall(r"bash\s+(scripts/setup-metrics-[\w-]+\.sh)", text))
assert invoked, "не нашёл ни одного запускаемого setup-скрипта — изменился синтаксис вызова"
patterns = re.findall(r'^\s+-\s+"([^"]+)"\s*$', text, re.M)
assert patterns, "не нашёл ни одного paths-шаблона"
for script in sorted(invoked):
assert any(fnmatch.fnmatch(script, p) for p in patterns), (
f"{script} запускается деплоем, но не входит ни в один шаблон paths: {patterns}"
)

View file

@ -25,8 +25,25 @@ fi
# Ищем роль с правом заводить других. Имя суперпользователя в этом кластере
# нигде не зафиксировано, а угадывать «postgres» неверно: в образе оно задаётся
# переменной POSTGRES_USER и здесь ею не является.
#
# Поэтому НЕ угадываем, а спрашиваем сам контейнер: POSTGRES_USER — это та самая
# переменная, которой роль и была создана при initdb, то есть источник истины.
# Прежний список был перебором трёх имён (glitchtip/forgejo/postgres), и ни одно
# из них не совпадало ни с одним реальным кластером проекта:
#
# gendesign-infra-postgres → infra
# gendesign-postgres-1 → gendesign
# tradein-postgres → tradein
#
# Из-за этого деплой стека падал на первом же прогоне (#3099, задача 23657):
# ОШИБКА: не нашёл роль с правом CREATE ROLE в gendesign-infra-postgres
# Замер 26.08 на живом контейнере: glitchtip/forgejo/postgres — отказ,
# infra — проходит. Список имён оставлен ПОСЛЕ него запасным путём: он
# пригодится кластеру, поднятому не из образа postgres, где POSTGRES_USER пуст.
SUPER=""
for candidate in glitchtip forgejo postgres; do
_pg_user="$(docker exec "$CONT" sh -c 'printf %s "${POSTGRES_USER:-}"' 2>/dev/null || true)"
for candidate in $_pg_user glitchtip forgejo postgres; do
[ -n "$candidate" ] || continue
if docker exec "$CONT" psql -U "$candidate" -d postgres -tAc \
"SELECT 1 FROM pg_roles WHERE rolname = CURRENT_USER AND (rolsuper OR rolcreaterole)" 2>/dev/null \
| grep -q 1; then