From b7461349761b60fc857ac31ada74d811b6c5a201 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 27 Aug 2026 15:55:24 +0300 Subject: [PATCH] =?UTF-8?q?fix(ops/metrics):=20postgres-exporter=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=87=D0=B0=D1=82=D0=B0=D0=BB=20=D0=BF=D0=B0=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D1=8C=20=D0=91=D0=94=20=D0=B2=20=D0=BB=D0=BE=D0=B3=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=BA=D0=B0=D0=B6=D0=B4=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Версия v0.16.0 при КАЖДОЙ неудаче сбора печатала полный DSN вместе с паролем: msg="error scraping dsn" err="queryNamespaceMappings returned 1 errors" dsn="postgresql://<роль>:<ПАРОЛЬ>@<хост>:5432/<база>?sslmode=disable" Строки уходят в Loki — около 210 в сутки с двух хостов, при ретенции 30 дней это тысячи паролей в хранилище логов (#3114). Проверял опытом, а не документацией. Стенд на скретч-контейнерах: чистый постгрес, роль без прав, тот же queries.yml что на проде — то есть ровно та ошибка, что случалась в бою. v0.16.0 → строк с паролем: 1 v0.18.0 → строк с паролем: 0 ОБХОДНОЙ ПУТЬ ИЗ ISSUE НЕ РАБОТАЕТ, и это важнее самой правки. Предлагалось передавать параметры через DATA_SOURCE_URI + DATA_SOURCE_USER + DATA_SOURCE_PASS вместо единой строки — «тогда в лог попадать нечему». Проверил на том же стенде: экспортер собирает DSN внутри и печатает его целиком точно так же, 1 строка с паролем. Реализация этого варианта была бы работой вхолостую при полном ощущении, что дыра закрыта. Паритет метрик проверен там же: все пять пользовательских запросов из queries.yml отдаются обеими версиями одинаково (PG_EXPORTER_EXTEND_QUERY_PATH в v0.18 работает), v0.18 добавляет три встроенные метрики и не теряет ни одной. Два теста сторожат нижнюю границу версии на всех трёх экспортерах. Прогон: 80 ops-тестов зелёные, ruff чист. Refs #3114 --- .../ops/test_3114_pg_exporter_version.py | 67 +++++++++++++++++++ docker-compose.metrics-agent.yml | 25 ++++++- 2 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 backend/tests/ops/test_3114_pg_exporter_version.py diff --git a/backend/tests/ops/test_3114_pg_exporter_version.py b/backend/tests/ops/test_3114_pg_exporter_version.py new file mode 100644 index 00000000..6a3e2092 --- /dev/null +++ b/backend/tests/ops/test_3114_pg_exporter_version.py @@ -0,0 +1,67 @@ +"""postgres-exporter не должен печатать пароль БД в лог. + +ЧТО БЫЛО. Версия v0.16.0 при КАЖДОЙ ошибке сбора печатала полный DSN вместе с +паролем: + + msg="error scraping dsn" err="queryNamespaceMappings returned 1 errors" + dsn="postgresql://<роль>:<ПАРОЛЬ>@<хост>:5432/<база>?sslmode=disable" + +Строки уходили в Loki — около 210 в сутки с двух хостов, при ретенции 30 дней +это тысячи паролей в хранилище логов (#3114). + +ЧТО ПРОВЕРЕНО ОПЫТОМ 27.08, а не взято из документации. Стенд: чистый постгрес +в скретч-контейнере, роль без прав, тот же `ops/metrics/postgres/queries.yml`, +что и на проде — то есть ровно та ошибка, что случалась в бою. + + v0.16.0 → строк с паролем: 1 + v0.18.0 → строк с паролем: 0 + +Отдельно опровергнут обходной путь, который предлагался в issue: форма +`DATA_SOURCE_URI` + `DATA_SOURCE_USER` + `DATA_SOURCE_PASS` НЕ помогает — +экспортер собирает DSN внутри и печатает его целиком точно так же (тот же +стенд, тот же результат: 1 строка с паролем). То есть починка «через +переменные окружения» была бы работой вхолостую. + +Паритет метрик проверен там же: все пять пользовательских запросов отдаются +обеими версиями одинаково, v0.18 добавляет три встроенные и не теряет ни одной. + +Тест сторожит только нижнюю границу версии: откат на v0.16.x вернул бы утечку. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[3] +COMPOSE = REPO_ROOT / "docker-compose.metrics-agent.yml" + +# Первая версия, в которой пароль в лог не попадает (проверено опытом, см. шапку). +MIN_SAFE = (0, 18, 0) + +_TAG = re.compile(r"postgres-exporter:v(\d+)\.(\d+)\.(\d+)") + + +def _versions() -> list[tuple[int, int, int]]: + text = COMPOSE.read_text(encoding="utf-8") + return [(int(a), int(b), int(c)) for a, b, c in _TAG.findall(text)] + + +def test_all_three_exporters_are_pinned() -> None: + """Экспортеров три — на всех трёх версия должна быть явной. + + Страховка от того, что тест ниже начнёт проверять пустоту: если кто-то + заменит тег на `latest` или удалит сервис, здесь станет видно. + """ + assert len(_versions()) == 3, ( + f"ожидались три закреплённые версии postgres-exporter, найдено: {_versions()}" + ) + + +def test_no_exporter_older_than_the_leaking_one() -> None: + """Ни один экспортер не смеет быть старше версии, где утечка закрыта.""" + for v in _versions(): + assert v >= MIN_SAFE, ( + f"postgres-exporter v{'.'.join(map(str, v))} печатает пароль БД в лог " + f"при каждой ошибке сбора — минимум v{'.'.join(map(str, MIN_SAFE))}" + ) diff --git a/docker-compose.metrics-agent.yml b/docker-compose.metrics-agent.yml index e351a56f..506be509 100644 --- a/docker-compose.metrics-agent.yml +++ b/docker-compose.metrics-agent.yml @@ -163,8 +163,27 @@ services: # ── postgres-exporter: Птица (только на Poincare) ──────────────────────────── # WAL/сутки, n_tup_upd против n_tup_hot_upd, рост TOAST, коннекты, горизонт # vacuum — то, из-за отсутствия чего раздутие копилось 91 день незамеченным. + # v0.18.0, а не v0.16.0: та печатала ПОЛНЫЙ DSN вместе с паролем в каждую + # ошибку сбора (#3114). Строка вида + # msg="error scraping dsn" err="queryNamespaceMappings returned 1 errors" + # dsn="postgresql://<роль>:<ПАРОЛЬ>@<хост>:5432/<база>?sslmode=disable" + # уходила в Loki при каждом скрейпе — около 210 строк в сутки с двух хостов, + # при ретенции 30 дней это тысячи паролей в хранилище логов. + # + # Воспроизведено на скретч-контейнерах 27.08 (постгрес + роль без прав + + # этот же queries.yml): v0.16.0 печатает пароль, v0.18.0 — нет. + # + # NB: форма DATA_SOURCE_URI + DATA_SOURCE_USER + DATA_SOURCE_PASS, которую + # предлагали как обходной путь, НЕ помогает — проверено тем же опытом: + # экспортер собирает DSN внутри и печатает его целиком точно так же. + # Утечку закрывает только версия образа. + # + # Паритет метрик проверен на том же стенде: все пять пользовательских + # запросов из queries.yml отдаются обеими версиями одинаково + # (PG_EXPORTER_EXTEND_QUERY_PATH в v0.18 работает), v0.18 добавляет три + # встроенные метрики и не теряет ни одной. postgres-exporter-gendesign: - image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0 + image: quay.io/prometheuscommunity/postgres-exporter:v0.18.0 container_name: gendesign-pg-exporter-gendesign restart: unless-stopped profiles: ["apps"] @@ -192,7 +211,7 @@ services: # ── postgres-exporter: МЕРА (только на Poincare) ───────────────────────────── postgres-exporter-tradein: - image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0 + image: quay.io/prometheuscommunity/postgres-exporter:v0.18.0 container_name: gendesign-pg-exporter-tradein restart: unless-stopped profiles: ["apps"] @@ -216,7 +235,7 @@ services: # forgejo + glitchtip. Нужен и сам по себе, и как страховка: рост базы glitchtip # ничем не ограничен — политики ретенции у GlitchTip нет вообще. postgres-exporter-infra: - image: quay.io/prometheuscommunity/postgres-exporter:v0.16.0 + image: quay.io/prometheuscommunity/postgres-exporter:v0.18.0 container_name: gendesign-pg-exporter-infra restart: unless-stopped profiles: ["infra"] -- 2.45.3