From 975934373b70fc7728ee22b6e87ea7acb214e691 Mon Sep 17 00:00:00 2001 From: Shahovalov MIkhail Date: Thu, 20 Aug 2026 06:02:55 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D1=82=D1=81=D0=B5=D0=B2=20=D0=B1=D0=B0?= =?UTF-8?q?=D0=B7=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20source.exclude=5Fdata?= =?UTF-8?q?bases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit list_databases() прячит базы по массиву строк: подстрока без регистра или маска со * ? [. Фильтр — единственное место рождения списка, поэтому одинаков для меню, пульта и list; явное имя в CLI не трогает. --- CLAUDE.md | 3 +++ README.md | 8 ++++++++ config.example.json | 1 + pg_stand_sync.py | 25 ++++++++++++++++++++++++- tests_tui.py | 29 +++++++++++++++++++++++++++++ 5 files changed, 65 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8150d2a..bad5c8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,6 +75,9 @@ matches(text, query) ; общий фильтр для pick() и обоих с `kind` (`dump` или `sync`), а правки полей уезжают в `OpSpec.overrides` и накладываются копией конфига в `op_cfg()`. Файл `config.json` пульт не переписывает, значения живут до выхода в `DashboardScreen.op_defaults`. +- `source.exclude_databases` отсеивается в `list_databases()` — единственном месте, где + список рождается, поэтому фильтр одинаков для меню, пульта и `list`. Явное имя базы в CLI + через список не проходит и потому не фильтруется. - Имя базы — параметр, а не поле конфига. `source` описывает только сервер (`maintenance_database` — куда подключаться, чтобы прочитать `pg_database`), `target.database: null` означает «локально как на стенде»; переопределяется `--target-db`. diff --git a/README.md b/README.md index 89d43db..76d69eb 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ python pg_stand_sync.py restore dumps/zpas-20260820-031332.dump | `dump_dir` | `dumps` | куда складывать дампы (относительный путь — от каталога скрипта) | | `keep_dumps` | `5` | сколько последних дампов хранить, `0` — не чистить | | `source.maintenance_database` | `postgres` | база, к которой подключаться для чтения списка баз | +| `source.exclude_databases` | `[]` | массив строк: базы, которые не показывать в списках | | `source.password_env` | — | имя переменной окружения с паролем вместо `password` в файле | | `target.database` | `null` | имя локальной базы; `null` — как на стенде | | `target.recreate` | `true` | DROP + CREATE локальной базы перед восстановлением | @@ -150,6 +151,13 @@ python pg_stand_sync.py restore dumps/zpas-20260820-031332.dump | `restore.exit_on_error` | `false` | падать на первой ошибке восстановления | | `post_restore_sql` | `[]` | список SQL, выполняемых в целевой базе после восстановления | +```json +"exclude_databases": ["_old", "keycloak", "*_tmp"] +``` + +Строка без спецсимволов — подстрока (регистр не важен), со `*`, `?` или `[` — маска. +Фильтр действует только на списки: базу, названную прямо (`sync zpas_old`), он не прячет. + Пароль ищется в три шага: `password` в конфиге → переменная окружения из `password_env` → запрос с терминала (один раз за запуск, ввод скрыт). В неинтерактивном запуске третьего шага нет — либо `password_env`, либо `pgpass.conf`. Пароль всегда уходит в дочерний процесс через diff --git a/config.example.json b/config.example.json index 4538eb4..c4b8bc5 100644 --- a/config.example.json +++ b/config.example.json @@ -7,6 +7,7 @@ "host": "postgresql.lan", "port": 5432, "maintenance_database": "postgres", + "exclude_databases": [], "user": "postgres", "password": null, "password_env": "STAND_PGPASSWORD", diff --git a/pg_stand_sync.py b/pg_stand_sync.py index cbe4655..9306f1f 100644 --- a/pg_stand_sync.py +++ b/pg_stand_sync.py @@ -7,6 +7,7 @@ from __future__ import annotations import argparse +import fnmatch import getpass import importlib.util import json @@ -358,8 +359,28 @@ def psql_exec(tools: dict, node: dict, dbname: str, sql: str, what: str, run(cmd, env, what, dry_run) +def excluded(name: str, patterns) -> bool: + """Прячется ли база фильтром: подстрока без регистра, а со звёздочкой — маска.""" + low = name.lower() + for raw in patterns or []: + pattern = str(raw).strip().lower() + if not pattern: + continue + if any(ch in pattern for ch in "*?["): + if fnmatch.fnmatch(low, pattern): + return True + elif pattern in low: + return True + return False + + def list_databases(tools: dict, node: dict, label: str) -> list: - """Список баз сервера: (имя, человекочитаемый размер).""" + """Список баз сервера: (имя, человекочитаемый размер). + + Отсев по node.exclude_databases делается здесь, в единственном месте, где список + вообще рождается: и меню, и пульт, и `list` получают его уже без мусорных баз. + Явное имя базы в CLI фильтр не трогает — оно списком не ходит. + """ admin_db = node.get("maintenance_database") or node.get("database") or "postgres" env = conn_env(node, label, admin_db) sql = ( @@ -375,6 +396,8 @@ def list_databases(tools: dict, node: dict, label: str) -> list: if not line.strip(): continue name, _, size = line.partition("\t") + if excluded(name, node.get("exclude_databases")): + continue rows.append((name, size or "")) return rows diff --git a/tests_tui.py b/tests_tui.py index 43dca2d..d87cae9 100644 --- a/tests_tui.py +++ b/tests_tui.py @@ -1034,3 +1034,32 @@ def test_arrows_walk_dialog_fields(cfg, monkeypatch): await pilot.press("escape") await pilot.pause() asyncio.run(scenario()) + + +def test_exclude_databases_hides_rows(monkeypatch): + """exclude_databases — массив строк: подстрока без регистра, со звёздочкой — маска.""" + assert core.excluded("zpas_test", ["test"]) + assert core.excluded("ZPAS_TEST", ["test"]) # регистр не важен + assert core.excluded("zpas_old_2", ["*_old_?"]) # маска + assert not core.excluded("zpas", ["test", "*_old"]) + assert not core.excluded("zpas", []) # пустой список ничего не прячет + assert not core.excluded("zpas", ["", " "]) # и пустые строки тоже + + node = {"host": "stand", "user": "postgres", "password": "x", + "maintenance_database": "postgres", + "exclude_databases": ["_old", "keycloak", "*_tmp"]} + monkeypatch.setattr(core, "capture", lambda cmd, env, what: + "zpas\t1284 MB\nzpas_old\t312 MB\nkeycloak\t12 MB\n" + "media\t8 MB\nbuild_tmp\t1 MB\n") + rows = core.list_databases({"psql": "psql"}, node, "стенд") + assert [name for name, _ in rows] == ["zpas", "media"] + + +def test_exclude_databases_does_not_touch_explicit_name(cfg, monkeypatch): + """Скрытая из списка база остаётся доступной, если имя названо прямо в CLI.""" + cfg["source"]["exclude_databases"] = ["zpas"] + called = [] + monkeypatch.setattr(core, "dump", lambda cfg_, tools, dbname, out, dry: + called.append(dbname) or out) + core.do_dump(cfg, {"pg_dump": "pg_dump"}, ["zpas"], dry_run=True) + assert called == ["zpas"]