From 33bc7e4acf5fa78e70934268237b5af634f07ab6 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Wed, 26 Aug 2026 12:08:37 +0300 Subject: [PATCH] =?UTF-8?q?fix(observability):=20=D0=BF=D1=80=D0=B8=D0=B2?= =?UTF-8?q?=D0=B8=D0=BB=D0=B5=D0=B3=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=BD=D1=83=D1=8E=20=D1=80=D0=BE=D0=BB=D1=8C=20=D1=81=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D1=88=D0=B8=D0=B2=D0=B0=D0=B5=D0=BC=20=D1=83=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BD=D1=82=D0=B5=D0=B9=D0=BD=D0=B5=D1=80=D0=B0,?= =?UTF-8?q?=20=D0=B0=20=D0=BD=D0=B5=20=D1=83=D0=B3=D0=B0=D0=B4=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0=D0=B5=D0=BC=20(#3078)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Стек наблюдаемости не поднялся ни на одном хосте после мержа #3099: джоба server упала, agent-apps и agent-infra пропустились как зависимые. Причина (задача 23657, 26.08 08:55): err: ОШИБКА: не нашёл роль с правом CREATE ROLE в gendesign-infra-postgres setup-metrics-grafana-role.sh искал привилегированную роль перебором трёх имён - glitchtip, forgejo, postgres. Ни одно не совпадает ни с одним реальным кластером проекта: infra-postgres -> infra, gendesign-postgres-1 -> gendesign, tradein-postgres -> tradein. Комментарий над перебором сам предупреждал, что "угадывать postgres неверно", и дальше шло угадывание. Замер на живом контейнере 26.08 (read-only, ничего не создавалось): POSTGRES_USER изнутри контейнера: infra glitchtip - отказ, forgejo - отказ, postgres - отказ, infra - 1 Стало: имя берём из POSTGRES_USER самого контейнера - это та переменная, которой роль и создана при initdb, то есть источник истины. Прежний список оставлен ПОСЛЕ него запасным путём для кластера не из образа postgres. Попутно - глоб в paths деплоя. Воркфлоу запускает ТРИ setup-скрипта, а в триггере стоял только setup-metrics-secrets.sh: правка двух остальных не заводила выкат, и на хосте молча оставалась старая версия. Тот же класс, что #2203 закрыл глобом ops/*.sh. Добавлен тест, который сверяет запускаемые скрипты с шаблонами paths - на исходном воркфлоу он краснеет, указывая на setup-metrics-exporter-dsn.sh. Тесты (6) исполняют РЕАЛЬНЫЙ скрипт с подставным docker и проверяют фактический выбор роли, а не наличие правильных слов в комментарии. Фальсификация: на исходном коде краснеют 3 из 5 ролевых тестов; проходят только те два, что фиксируют сохранённое поведение (запасной перебор и громкая ошибка при отсутствии привилегий). tests/ops целиком - 28 passed. --- .forgejo/workflows/deploy-metrics.yml | 6 +- .../ops/test_3078_metrics_role_discovery.py | 181 ++++++++++++++++++ scripts/setup-metrics-grafana-role.sh | 19 +- 3 files changed, 204 insertions(+), 2 deletions(-) create mode 100644 backend/tests/ops/test_3078_metrics_role_discovery.py diff --git a/.forgejo/workflows/deploy-metrics.yml b/.forgejo/workflows/deploy-metrics.yml index 64bfbb5e..2b00d3bb 100644 --- a/.forgejo/workflows/deploy-metrics.yml +++ b/.forgejo/workflows/deploy-metrics.yml @@ -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: diff --git a/backend/tests/ops/test_3078_metrics_role_discovery.py b/backend/tests/ops/test_3078_metrics_role_discovery.py new file mode 100644 index 00000000..8d26bbfc --- /dev/null +++ b/backend/tests/ops/test_3078_metrics_role_discovery.py @@ -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}" + ) diff --git a/scripts/setup-metrics-grafana-role.sh b/scripts/setup-metrics-grafana-role.sh index 3da10af8..6220051c 100644 --- a/scripts/setup-metrics-grafana-role.sh +++ b/scripts/setup-metrics-grafana-role.sh @@ -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 -- 2.45.3