"""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}" )