Два режима работы и выбор базы из списка

Интерактивное меню (Dump+Restore / Dump / Restore) и CLI-команды
sync/dump/restore/list/dumps. База и файл дампа выбираются из списка
с фильтрацией по вводу, имя базы больше не зашито в конфиг.

Пароль берётся из конфига, переменной окружения или спрашивается с
терминала; без пароля утилиты вызываются с --no-password, иначе psql
подвисает на приглашении.
@
This commit is contained in:
2026-08-20 03:17:49 +03:00
parent 1a72fbb720
commit b30406bbf6
3 changed files with 427 additions and 69 deletions
+54 -8
View File
@@ -3,6 +3,8 @@
Перенос базы PostgreSQL со стенда на локальный сервер: `pg_dump -Fc` → пересоздание локальной БД → `pg_restore -j`. Перенос базы PostgreSQL со стенда на локальный сервер: `pg_dump -Fc` → пересоздание локальной БД → `pg_restore -j`.
Только stdlib Python 3.10+, внешних зависимостей нет. Утилиты берутся из поставки pgAdmin 4. Только stdlib Python 3.10+, внешних зависимостей нет. Утилиты берутся из поставки pgAdmin 4.
Работает в двух режимах: интерактивное меню (запуск без аргументов) и команды CLI.
## Быстрый старт ## Быстрый старт
```bash ```bash
@@ -15,16 +17,51 @@ copy config.example.json config.json
python pg_stand_sync.py python pg_stand_sync.py
``` ```
Появится меню:
```
стенд: postgres@postgresql.lan:5432
локально: postgres@localhost:5432
1. Dump + Restore — снять со стенда и залить локально
2. Dump — только снять дамп со стенда
3. Restore — залить локально готовый дамп
0. Выход
```
После выбора пункта показывается список: для Dump — базы стенда, для Restore — файлы из `dumps`.
Ввод в списке работает так:
| Ввод | Что делает |
|---|---|
| текст | фильтрует список по подстроке (регистр не важен) |
| номер | выбирает пункт; в Dump можно `2,5,7` — несколько баз |
| пусто | сбрасывает фильтр |
| `q` | назад в меню |
`config.json` в `.gitignore` — пароли в репозиторий не попадают. `config.json` в `.gitignore` — пароли в репозиторий не попадают.
## Ключи ## CLI
| Ключ | Что делает | ```bash
python pg_stand_sync.py list # базы на стенде
python pg_stand_sync.py dumps # локальные дампы
python pg_stand_sync.py dump zpas # только дамп
python pg_stand_sync.py sync zpas --target-db zpas_l # дамп + restore
python pg_stand_sync.py restore dumps/zpas-20260820-031332.dump
```
Имя базы (или файла) можно не указывать — тогда откроется тот же список с фильтрацией.
| Команда / ключ | Что делает |
|---|---| |---|---|
| `-c, --config PATH` | другой конфиг (несколько стендов — несколько json) | | `sync [DB…]` | dump со стенда + restore локально |
| `--dump-only` | снять дамп со стенда и остановиться | | `dump [DB…]` | только снять дампы со стенда (можно несколько баз) |
| `--restore-only FILE` | залить готовый `.dump` локально, без обращения к стенду | | `restore [FILE]` | залить готовый `.dump` локально, без обращения к стенду |
| `list` / `dumps` | базы стенда / файлы дампов |
| `menu` | интерактивное меню (то же, что запуск без команды) |
| `--target-db NAME` | имя локальной базы (по умолчанию как на стенде) |
| `--no-recreate` | не делать DROP/CREATE DATABASE, накатить поверх (`pg_restore --clean --if-exists`) | | `--no-recreate` | не делать DROP/CREATE DATABASE, накатить поверх (`pg_restore --clean --if-exists`) |
| `-c, --config PATH` | другой конфиг (несколько стендов — несколько json) |
| `--dry-run` | напечатать команды, ничего не выполнять | | `--dry-run` | напечатать команды, ничего не выполнять |
## Конфиг ## Конфиг
@@ -34,16 +71,25 @@ python pg_stand_sync.py
| `pg_bin_dir` | из `PATH` | каталог с `pg_dump.exe`, `pg_restore.exe`, `psql.exe` | | `pg_bin_dir` | из `PATH` | каталог с `pg_dump.exe`, `pg_restore.exe`, `psql.exe` |
| `dump_dir` | `dumps` | куда складывать дампы (относительный путь — от каталога скрипта) | | `dump_dir` | `dumps` | куда складывать дампы (относительный путь — от каталога скрипта) |
| `keep_dumps` | `5` | сколько последних дампов хранить, `0` — не чистить | | `keep_dumps` | `5` | сколько последних дампов хранить, `0` — не чистить |
| `source.maintenance_database` | `postgres` | база, к которой подключаться для чтения списка баз |
| `source.password_env` | — | имя переменной окружения с паролем вместо `password` в файле | | `source.password_env` | — | имя переменной окружения с паролем вместо `password` в файле |
| `target.database` | `null` | имя локальной базы; `null` — как на стенде |
| `target.recreate` | `true` | DROP + CREATE локальной базы перед восстановлением | | `target.recreate` | `true` | DROP + CREATE локальной базы перед восстановлением |
| `dump.schemas` / `exclude_*` | `[]` | ограничение состава: схемы, таблицы, данные таблиц | | `dump.schemas` / `exclude_*` | `[]` | ограничение состава: схемы, таблицы, данные таблиц |
| `restore.jobs` | `4` | параллельные воркеры `pg_restore` | | `restore.jobs` | `4` | параллельные воркеры `pg_restore` |
| `restore.exit_on_error` | `false` | падать на первой ошибке восстановления | | `restore.exit_on_error` | `false` | падать на первой ошибке восстановления |
| `post_restore_sql` | `[]` | список SQL, выполняемых в целевой базе после восстановления | | `post_restore_sql` | `[]` | список SQL, выполняемых в целевой базе после восстановления |
Пароли можно не хранить в файле: задать `password_env` и передать значение через окружение Пароль ищется в три шага: `password` в конфиге → переменная окружения из `password_env`
(`$env:STAND_PGPASSWORD = '...'`). Пароль всегда уходит в дочерний процесс через `PGPASSWORD`, запрос с терминала (один раз за запуск, ввод скрыт). В неинтерактивном запуске третьего шага
в командной строке не появляется. нет — либо `password_env`, либо `pgpass.conf`. Пароль всегда уходит в дочерний процесс через
`PGPASSWORD`, в командной строке не появляется; если пароля нет вовсе, утилиты вызываются с
`--no-password`, чтобы скрипт не подвисал на приглашении.
```powershell
$env:STAND_PGPASSWORD = '...'
python pg_stand_sync.py sync zpas
```
## Как это работает ## Как это работает
+5 -5
View File
@@ -4,10 +4,10 @@
"keep_dumps": 5, "keep_dumps": 5,
"source": { "source": {
"host": "stand.example.lan", "host": "postgresql.lan",
"port": 5432, "port": 5432,
"database": "zpas", "maintenance_database": "postgres",
"user": "readonly", "user": "postgres",
"password_env": "STAND_PGPASSWORD", "password_env": "STAND_PGPASSWORD",
"sslmode": "prefer", "sslmode": "prefer",
"connect_timeout": 15 "connect_timeout": 15
@@ -16,9 +16,9 @@
"target": { "target": {
"host": "localhost", "host": "localhost",
"port": 5432, "port": 5432,
"database": "zpas", "database": null,
"user": "postgres", "user": "postgres",
"password": "postgres", "password_env": "LOCAL_PGPASSWORD",
"maintenance_database": "postgres", "maintenance_database": "postgres",
"owner": "postgres", "owner": "postgres",
"recreate": true "recreate": true
+366 -54
View File
@@ -1,11 +1,16 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Перенос базы PostgreSQL со стенда на локальный сервер: pg_dump -Fc + pg_restore.""" """Перенос баз PostgreSQL со стенда на локальный сервер: pg_dump -Fc + pg_restore.
Два режима: интерактивное меню (запуск без аргументов) и команды CLI.
"""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import getpass
import json import json
import os import os
import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
@@ -14,6 +19,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
DEFAULT_CONFIG = Path(__file__).with_name("config.json") DEFAULT_CONFIG = Path(__file__).with_name("config.json")
STAMP_RE = re.compile(r"-\d{8}-\d{6}$")
# Windows-консоль по умолчанию отдаёт cp866, русский лог в ней превращается в мусор # Windows-консоль по умолчанию отдаёт cp866, русский лог в ней превращается в мусор
for stream in (sys.stdout, sys.stderr): for stream in (sys.stdout, sys.stderr):
@@ -29,10 +35,17 @@ class StepError(Exception):
pass pass
class Cancelled(Exception):
"""Пользователь отказался от выбора в интерактивном режиме."""
def log(msg: str) -> None: def log(msg: str) -> None:
print(f"[{datetime.now():%H:%M:%S}] {msg}", flush=True) print(f"[{datetime.now():%H:%M:%S}] {msg}", flush=True)
# --------------------------------------------------------------------------- конфиг
def load_config(path: Path) -> dict: def load_config(path: Path) -> dict:
if not path.exists(): if not path.exists():
raise ConfigError( raise ConfigError(
@@ -48,7 +61,7 @@ def load_config(path: Path) -> dict:
node = cfg.get(section) node = cfg.get(section)
if not isinstance(node, dict): if not isinstance(node, dict):
raise ConfigError(f'{path}: нет секции "{section}"') raise ConfigError(f'{path}: нет секции "{section}"')
for key in ("host", "database", "user"): for key in ("host", "user"):
if not node.get(key): if not node.get(key):
raise ConfigError(f"{path}: {section}.{key} не задан") raise ConfigError(f"{path}: {section}.{key} не задан")
return cfg return cfg
@@ -71,21 +84,44 @@ def resolve_tools(cfg: dict) -> dict:
return tools return tools
def conn_env(node: dict) -> dict: _password_cache: dict = {}
def resolve_password(node: dict, label: str) -> str | None:
"""Пароль: из конфига, из переменной окружения, иначе спросить один раз за запуск."""
if node.get("password"):
return str(node["password"])
var = node.get("password_env")
if var:
value = os.environ.get(var)
if value:
return value
key = (label, node.get("host"), node.get("port", 5432), node.get("user"))
if key in _password_cache:
return _password_cache[key]
if not sys.stdin.isatty():
if var:
raise ConfigError(f"переменная окружения {var} пуста, а спросить пароль негде")
return None
value = getpass.getpass(
f"пароль {node['user']}@{node['host']} ({label}), Enter — без пароля: ")
_password_cache[key] = value
return value
def conn_env(node: dict, label: str = "", dbname: str | None = None) -> dict:
"""Параметры подключения уходят в окружение, чтобы пароль не светился в командной строке.""" """Параметры подключения уходят в окружение, чтобы пароль не светился в командной строке."""
env = dict(os.environ) env = dict(os.environ)
env["PGHOST"] = str(node["host"]) env["PGHOST"] = str(node["host"])
env["PGPORT"] = str(node.get("port", 5432)) env["PGPORT"] = str(node.get("port", 5432))
env["PGUSER"] = str(node["user"]) env["PGUSER"] = str(node["user"])
env["PGDATABASE"] = str(node["database"]) database = dbname or node.get("database") or node.get("maintenance_database") or "postgres"
password = node.get("password") env["PGDATABASE"] = str(database)
password = resolve_password(node, label or node["host"])
if password: if password:
env["PGPASSWORD"] = str(password) env["PGPASSWORD"] = password
elif node.get("password_env"): else:
value = os.environ.get(node["password_env"]) env.pop("PGPASSWORD", None)
if not value:
raise ConfigError(f"переменная окружения {node['password_env']} пуста")
env["PGPASSWORD"] = value
if node.get("sslmode"): if node.get("sslmode"):
env["PGSSLMODE"] = str(node["sslmode"]) env["PGSSLMODE"] = str(node["sslmode"])
env.setdefault("PGCLIENTENCODING", "UTF8") env.setdefault("PGCLIENTENCODING", "UTF8")
@@ -93,6 +129,9 @@ def conn_env(node: dict) -> dict:
return env return env
# --------------------------------------------------------------------------- запуск утилит
def run(cmd: list, env: dict, what: str, dry_run: bool) -> None: def run(cmd: list, env: dict, what: str, dry_run: bool) -> None:
shown = " ".join(f'"{c}"' if " " in c else c for c in cmd) shown = " ".join(f'"{c}"' if " " in c else c for c in cmd)
log(f"{what}: {shown}") log(f"{what}: {shown}")
@@ -109,13 +148,107 @@ def run(cmd: list, env: dict, what: str, dry_run: bool) -> None:
log(f"{what}: готово за {time.monotonic() - started:.1f} c") log(f"{what}: готово за {time.monotonic() - started:.1f} c")
def psql_exec(tools: dict, node: dict, dbname: str, sql: str, what: str, dry_run: bool) -> None: def no_prompt(env: dict) -> list:
env = conn_env({**node, "database": dbname}) """Без пароля в окружении утилиты просят его с терминала и подвисают в скриптах."""
cmd = [tools["psql"], "--no-psqlrc", "-v", "ON_ERROR_STOP=1", "-c", sql] return [] if env.get("PGPASSWORD") else ["--no-password"]
def capture(cmd: list, env: dict, what: str) -> str:
proc = subprocess.run(cmd, env=env, text=True, encoding="utf-8", errors="replace",
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if proc.returncode != 0:
detail = (proc.stderr or "").strip().splitlines()
raise StepError(f"{what}: " + (detail[-1] if detail else f"код {proc.returncode}"))
return proc.stdout
def psql_exec(tools: dict, node: dict, dbname: str, sql: str, what: str,
dry_run: bool, label: str = "") -> None:
env = conn_env(node, label, dbname)
cmd = [tools["psql"], "--no-psqlrc", "-v", "ON_ERROR_STOP=1", *no_prompt(env), "-c", sql]
run(cmd, env, what, dry_run) run(cmd, env, what, dry_run)
def dump(cfg: dict, tools: dict, out: Path, dry_run: bool) -> Path: def list_databases(tools: dict, node: dict, label: str) -> list:
"""Список баз сервера: (имя, человекочитаемый размер)."""
admin_db = node.get("maintenance_database") or node.get("database") or "postgres"
env = conn_env(node, label, admin_db)
sql = (
"SELECT datname, "
" CASE WHEN has_database_privilege(datname, 'CONNECT') "
" THEN pg_size_pretty(pg_database_size(datname)) ELSE '-' END "
"FROM pg_database WHERE NOT datistemplate AND datallowconn ORDER BY datname;"
)
cmd = [tools["psql"], "--no-psqlrc", "-At", "-F", "\t", *no_prompt(env), "-c", sql]
out = capture(cmd, env, f"список баз {node['host']}")
rows = []
for line in out.splitlines():
if not line.strip():
continue
name, _, size = line.partition("\t")
rows.append((name, size or ""))
return rows
# --------------------------------------------------------------------------- интерактив
def ask(prompt: str) -> str:
try:
return input(prompt).strip()
except EOFError:
raise Cancelled from None
def pick(items: list, title: str, render, multi: bool = False) -> list:
"""Список с фильтрацией по вводу: текст сужает список, число(а) выбирают, пустой ввод — сброс."""
if not items:
raise StepError(f"{title}: список пуст")
if not sys.stdin.isatty():
raise StepError(f"{title}: нет терминала для выбора")
flt = ""
while True:
shown = [it for it in items if flt.lower() in render(it).lower()] if flt else list(items)
print()
print(f"{title}" + (f" фильтр: {flt!r}" if flt else ""))
if not shown:
print(" ничего не найдено")
for i, it in enumerate(shown, 1):
print(f" {i:>3}. {render(it)}")
hint = "номера через запятую" if multi else "номер"
choice = ask(f"[{hint} | текст — фильтр | пусто — сброс | q — назад]: ")
if choice.lower() in ("q", "выход", "назад"):
raise Cancelled
if not choice:
flt = ""
continue
if re.fullmatch(r"[\d\s,]+", choice):
nums = [int(n) for n in re.split(r"[\s,]+", choice) if n]
bad = [n for n in nums if not 1 <= n <= len(shown)]
if bad or not nums:
print(f" нет такого номера: {', '.join(map(str, bad)) or '-'}")
continue
if not multi and len(nums) > 1:
print(" здесь выбирается один пункт")
continue
return [shown[n - 1] for n in nums]
flt = choice
def confirm(question: str, default: bool = True) -> bool:
suffix = "[Y/n]" if default else "[y/N]"
answer = ask(f"{question} {suffix}: ").lower()
if not answer:
return default
return answer in ("y", "yes", "д", "да")
# --------------------------------------------------------------------------- шаги
def dump(cfg: dict, tools: dict, dbname: str, out: Path, dry_run: bool) -> Path:
src = cfg["source"] src = cfg["source"]
opts = cfg.get("dump", {}) opts = cfg.get("dump", {})
cmd = [tools["pg_dump"], "--format=custom"] cmd = [tools["pg_dump"], "--format=custom"]
@@ -139,18 +272,19 @@ def dump(cfg: dict, tools: dict, out: Path, dry_run: bool) -> Path:
cmd += ["--exclude-table-data", table] cmd += ["--exclude-table-data", table]
cmd += ["--file", str(out)] cmd += ["--file", str(out)]
env = conn_env(src, "стенд", dbname)
cmd += no_prompt(env)
out.parent.mkdir(parents=True, exist_ok=True) out.parent.mkdir(parents=True, exist_ok=True)
run(cmd, conn_env(src), f"дамп {src['database']}@{src['host']}", dry_run) run(cmd, env, f"дамп {dbname}@{src['host']}", dry_run)
if not dry_run: if not dry_run:
size = out.stat().st_size / 1024 / 1024 size = out.stat().st_size / 1024 / 1024
log(f"дамп: {out} ({size:.1f} МБ)") log(f"дамп: {out} ({size:.1f} МБ)")
return out return out
def recreate_target(cfg: dict, tools: dict, dry_run: bool) -> None: def recreate_target(cfg: dict, tools: dict, dbname: str, dry_run: bool) -> None:
tgt = cfg["target"] tgt = cfg["target"]
maintenance = tgt.get("maintenance_database", "postgres") maintenance = tgt.get("maintenance_database", "postgres")
dbname = tgt["database"]
quoted = '"' + dbname.replace('"', '""') + '"' quoted = '"' + dbname.replace('"', '""') + '"'
literal = "'" + dbname.replace("'", "''") + "'" literal = "'" + dbname.replace("'", "''") + "'"
@@ -158,24 +292,25 @@ def recreate_target(cfg: dict, tools: dict, dry_run: bool) -> None:
psql_exec(tools, tgt, maintenance, psql_exec(tools, tgt, maintenance,
f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity " f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
f"WHERE datname = {literal} AND pid <> pg_backend_pid();", f"WHERE datname = {literal} AND pid <> pg_backend_pid();",
"обрыв активных сессий", dry_run) "обрыв активных сессий", dry_run, "локальная")
psql_exec(tools, tgt, maintenance, f"DROP DATABASE IF EXISTS {quoted};", psql_exec(tools, tgt, maintenance, f"DROP DATABASE IF EXISTS {quoted};",
"DROP DATABASE", dry_run) "DROP DATABASE", dry_run, "локальная")
create = f"CREATE DATABASE {quoted}" create = f"CREATE DATABASE {quoted}"
if tgt.get("owner"): if tgt.get("owner"):
create += ' OWNER "' + tgt["owner"].replace('"', '""') + '"' create += ' OWNER "' + tgt["owner"].replace('"', '""') + '"'
if tgt.get("template"): if tgt.get("template"):
create += f" TEMPLATE {tgt['template']}" create += f" TEMPLATE {tgt['template']}"
psql_exec(tools, tgt, maintenance, create + ";", "CREATE DATABASE", dry_run) psql_exec(tools, tgt, maintenance, create + ";", "CREATE DATABASE", dry_run, "локальная")
def restore(cfg: dict, tools: dict, dump_file: Path, recreated: bool, dry_run: bool) -> None: def restore(cfg: dict, tools: dict, dump_file: Path, dbname: str,
recreated: bool, dry_run: bool) -> None:
tgt = cfg["target"] tgt = cfg["target"]
opts = cfg.get("restore", {}) opts = cfg.get("restore", {})
if not dry_run and not dump_file.exists(): if not dry_run and not dump_file.exists():
raise StepError(f"файл дампа не найден: {dump_file}") raise StepError(f"файл дампа не найден: {dump_file}")
cmd = [tools["pg_restore"], "--dbname", tgt["database"]] cmd = [tools["pg_restore"], "--dbname", dbname]
jobs = int(opts.get("jobs", 4)) jobs = int(opts.get("jobs", 4))
if jobs > 1: if jobs > 1:
cmd += ["--jobs", str(jobs)] cmd += ["--jobs", str(jobs)]
@@ -189,10 +324,12 @@ def restore(cfg: dict, tools: dict, dump_file: Path, recreated: bool, dry_run: b
cmd.append("--exit-on-error") cmd.append("--exit-on-error")
if opts.get("verbose"): if opts.get("verbose"):
cmd.append("--verbose") cmd.append("--verbose")
env = conn_env(tgt, "локальная", dbname)
cmd += no_prompt(env)
cmd.append(str(dump_file)) cmd.append(str(dump_file))
try: try:
run(cmd, conn_env(tgt), f"restore в {tgt['database']}@{tgt['host']}", dry_run) run(cmd, env, f"restore в {dbname}@{tgt['host']}", dry_run)
except StepError as exc: except StepError as exc:
# без --exit-on-error pg_restore возвращает 1 на любых игнорируемых ошибках # без --exit-on-error pg_restore возвращает 1 на любых игнорируемых ошибках
if opts.get("exit_on_error", False): if opts.get("exit_on_error", False):
@@ -200,11 +337,11 @@ def restore(cfg: dict, tools: dict, dump_file: Path, recreated: bool, dry_run: b
log(f"внимание: {exc} — часть объектов могла не примениться, см. вывод выше") log(f"внимание: {exc} — часть объектов могла не примениться, см. вывод выше")
def post_sql(cfg: dict, tools: dict, dry_run: bool) -> None: def post_sql(cfg: dict, tools: dict, dbname: str, dry_run: bool) -> None:
statements = cfg.get("post_restore_sql") or [] statements = cfg.get("post_restore_sql") or []
for i, sql in enumerate(statements, 1): for i, sql in enumerate(statements, 1):
psql_exec(tools, cfg["target"], cfg["target"]["database"], sql, psql_exec(tools, cfg["target"], dbname, sql,
f"post-SQL {i}/{len(statements)}", dry_run) f"post-SQL {i}/{len(statements)}", dry_run, "локальная")
def prune(dump_dir: Path, keep: int) -> None: def prune(dump_dir: Path, keep: int) -> None:
@@ -216,47 +353,222 @@ def prune(dump_dir: Path, keep: int) -> None:
old.unlink() old.unlink()
def main() -> int: # --------------------------------------------------------------------------- сценарии
ap = argparse.ArgumentParser(description="Перенос БД PostgreSQL со стенда на локальный сервер")
def dump_dir_of(cfg: dict) -> Path:
dump_dir = Path(cfg.get("dump_dir") or "dumps")
if not dump_dir.is_absolute():
dump_dir = Path(__file__).parent / dump_dir
return dump_dir
def dump_path_for(cfg: dict, dbname: str) -> Path:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
return dump_dir_of(cfg) / f"{dbname}-{stamp}.dump"
def db_from_dump_name(path: Path) -> str:
return STAMP_RE.sub("", path.stem)
def local_dumps(cfg: dict) -> list:
dump_dir = dump_dir_of(cfg)
if not dump_dir.exists():
return []
return sorted(dump_dir.glob("*.dump"), key=lambda p: p.stat().st_mtime, reverse=True)
def do_dump(cfg: dict, tools: dict, dbnames: list, dry_run: bool) -> list:
made = []
for dbname in dbnames:
made.append(dump(cfg, tools, dbname, dump_path_for(cfg, dbname), dry_run))
prune(dump_dir_of(cfg), int(cfg.get("keep_dumps", 5)))
return made
def do_restore(cfg: dict, tools: dict, dump_file: Path, target_db: str,
no_recreate: bool, dry_run: bool) -> None:
recreate = bool(cfg["target"].get("recreate", True)) and not no_recreate
if recreate:
recreate_target(cfg, tools, target_db, dry_run)
restore(cfg, tools, dump_file, target_db, recreate, dry_run)
post_sql(cfg, tools, target_db, dry_run)
def do_sync(cfg: dict, tools: dict, dbnames: list, target_db: str | None,
no_recreate: bool, dry_run: bool) -> None:
for dbname in dbnames:
dump_file = dump(cfg, tools, dbname, dump_path_for(cfg, dbname), dry_run)
local = target_db or cfg["target"].get("database") or dbname
do_restore(cfg, tools, dump_file, local, no_recreate, dry_run)
prune(dump_dir_of(cfg), int(cfg.get("keep_dumps", 5)))
# --------------------------------------------------------------------------- меню
def choose_source_dbs(cfg: dict, tools: dict, multi: bool) -> list:
log(f"читаю список баз со стенда {cfg['source']['host']}")
rows = list_databases(tools, cfg["source"], "стенд")
chosen = pick(rows, f"базы на {cfg['source']['host']}",
lambda r: f"{r[0]:<32} {r[1]}", multi=multi)
return [name for name, _ in chosen]
def choose_dump_file(cfg: dict) -> Path:
files = local_dumps(cfg)
if not files:
raise StepError(f"в {dump_dir_of(cfg)} нет файлов *.dump")
def render(p: Path) -> str:
st = p.stat()
return (f"{p.name:<44} {st.st_size / 1024 / 1024:>8.1f} МБ "
f"{datetime.fromtimestamp(st.st_mtime):%d.%m %H:%M}")
return pick(files, f"дампы в {dump_dir_of(cfg)}", render)[0]
def ask_target_db(cfg: dict, default: str) -> str:
answer = ask(f"локальная база [{default}] (Enter — оставить): ")
return answer or default
def confirm_target(cfg: dict, what: str, target_db: str) -> bool:
recreate = bool(cfg["target"].get("recreate", True))
action = (f"пересоздать (DROP+CREATE) локальную {target_db}" if recreate
else f"накатить поверх локальной {target_db}")
return confirm(f"{what}{action}?", default=not recreate)
def interactive(cfg: dict, tools: dict, dry_run: bool) -> int:
src, tgt = cfg["source"], cfg["target"]
while True:
print()
print("=" * 62)
print(f" стенд: {src['user']}@{src['host']}:{src.get('port', 5432)}")
print(f" локально: {tgt['user']}@{tgt['host']}:{tgt.get('port', 5432)}"
+ (" [dry-run]" if dry_run else ""))
print("=" * 62)
print(" 1. Dump + Restore — снять со стенда и залить локально")
print(" 2. Dump — только снять дамп со стенда")
print(" 3. Restore — залить локально готовый дамп")
print(" 0. Выход")
try:
choice = ask("выбор: ")
if choice in ("0", "q", "выход", ""):
return 0
if choice == "1":
dbs = choose_source_dbs(cfg, tools, multi=False)
target_db = ask_target_db(cfg, cfg["target"].get("database") or dbs[0])
if not confirm_target(cfg, dbs[0], target_db):
continue
do_sync(cfg, tools, dbs, target_db, no_recreate=False, dry_run=dry_run)
log("перенос завершён")
elif choice == "2":
dbs = choose_source_dbs(cfg, tools, multi=True)
do_dump(cfg, tools, dbs, dry_run)
log("дамп завершён")
elif choice == "3":
dump_file = choose_dump_file(cfg)
default_db = cfg["target"].get("database") or db_from_dump_name(dump_file)
target_db = ask_target_db(cfg, default_db)
if not confirm_target(cfg, dump_file.name, target_db):
continue
do_restore(cfg, tools, dump_file, target_db, no_recreate=False, dry_run=dry_run)
log("восстановление завершено")
else:
print("нет такого пункта")
except Cancelled:
continue
except StepError as exc:
print(f"ОШИБКА: {exc}", file=sys.stderr)
# --------------------------------------------------------------------------- CLI
def build_parser() -> argparse.ArgumentParser:
ap = argparse.ArgumentParser(
description="Перенос БД PostgreSQL со стенда на локальный сервер. "
"Без команды запускается интерактивное меню.")
ap.add_argument("-c", "--config", type=Path, default=DEFAULT_CONFIG, help="путь к config.json") ap.add_argument("-c", "--config", type=Path, default=DEFAULT_CONFIG, help="путь к config.json")
ap.add_argument("--dump-only", action="store_true", help="только снять дамп со стенда")
ap.add_argument("--restore-only", metavar="FILE", type=Path,
help="только восстановить указанный дамп локально")
ap.add_argument("--no-recreate", action="store_true",
help="не пересоздавать локальную базу перед restore")
ap.add_argument("--dry-run", action="store_true", help="показать команды, ничего не выполнять") ap.add_argument("--dry-run", action="store_true", help="показать команды, ничего не выполнять")
args = ap.parse_args() sub = ap.add_subparsers(dest="command")
p_sync = sub.add_parser("sync", help="dump + restore")
p_sync.add_argument("db", nargs="*", help="базы на стенде (без имени — выбор из списка)")
p_sync.add_argument("--target-db", help="имя локальной базы (по умолчанию как на стенде)")
p_sync.add_argument("--no-recreate", action="store_true",
help="не пересоздавать локальную базу перед restore")
p_dump = sub.add_parser("dump", help="только снять дамп со стенда")
p_dump.add_argument("db", nargs="*", help="базы на стенде (без имени — выбор из списка)")
p_restore = sub.add_parser("restore", help="только залить дамп локально")
p_restore.add_argument("file", nargs="?", type=Path,
help="файл .dump (без имени — выбор из списка)")
p_restore.add_argument("--target-db", help="имя локальной базы")
p_restore.add_argument("--no-recreate", action="store_true",
help="не пересоздавать локальную базу перед restore")
sub.add_parser("list", help="показать базы на стенде")
sub.add_parser("dumps", help="показать локальные дампы")
sub.add_parser("menu", help="интерактивное меню (то же, что запуск без команды)")
return ap
def main() -> int:
args = build_parser().parse_args()
try: try:
cfg = load_config(args.config) cfg = load_config(args.config)
tools = resolve_tools(cfg) tools = resolve_tools(cfg)
dump_dir = Path(cfg.get("dump_dir") or "dumps") command = args.command or "menu"
if not dump_dir.is_absolute():
dump_dir = Path(__file__).parent / dump_dir
if args.restore_only: if command == "menu":
dump_file = args.restore_only return interactive(cfg, tools, args.dry_run)
else:
stamp = datetime.now().strftime("%Y%m%d-%H%M%S") if command == "list":
dump_file = dump_dir / f"{cfg['source']['database']}-{stamp}.dump" for name, size in list_databases(tools, cfg["source"], "стенд"):
dump(cfg, tools, dump_file, args.dry_run) print(f"{name:<40} {size}")
if args.dump_only:
log("режим --dump-only: restore пропущен")
prune(dump_dir, int(cfg.get("keep_dumps", 5)))
return 0 return 0
recreate = bool(cfg["target"].get("recreate", True)) and not args.no_recreate if command == "dumps":
if recreate: for path in local_dumps(cfg):
recreate_target(cfg, tools, args.dry_run) st = path.stat()
restore(cfg, tools, dump_file, recreate, args.dry_run) print(f"{path.name:<48} {st.st_size / 1024 / 1024:>8.1f} МБ "
post_sql(cfg, tools, args.dry_run) f"{datetime.fromtimestamp(st.st_mtime):%d.%m.%Y %H:%M}")
if not args.restore_only: return 0
prune(dump_dir, int(cfg.get("keep_dumps", 5)))
if command in ("sync", "dump"):
dbs = args.db or choose_source_dbs(cfg, tools, multi=(command == "dump"))
if command == "dump":
do_dump(cfg, tools, dbs, args.dry_run)
log("дамп завершён")
else:
do_sync(cfg, tools, dbs, args.target_db, args.no_recreate, args.dry_run)
log("перенос завершён") log("перенос завершён")
return 0 return 0
if command == "restore":
dump_file = args.file or choose_dump_file(cfg)
target_db = (args.target_db or cfg["target"].get("database")
or db_from_dump_name(dump_file))
do_restore(cfg, tools, dump_file, target_db, args.no_recreate, args.dry_run)
log("восстановление завершено")
return 0
return 0
except (ConfigError, StepError) as exc: except (ConfigError, StepError) as exc:
print(f"ОШИБКА: {exc}", file=sys.stderr) print(f"ОШИБКА: {exc}", file=sys.stderr)
return 1 return 1
except Cancelled:
print("отменено", file=sys.stderr)
return 130
except KeyboardInterrupt: except KeyboardInterrupt:
print("прервано пользователем", file=sys.stderr) print("прервано пользователем", file=sys.stderr)
return 130 return 130