Два режима работы и выбор базы из списка
Интерактивное меню (Dump+Restore / Dump / Restore) и CLI-команды sync/dump/restore/list/dumps. База и файл дампа выбираются из списка с фильтрацией по вводу, имя базы больше не зашито в конфиг. Пароль берётся из конфига, переменной окружения или спрашивается с терминала; без пароля утилиты вызываются с --no-password, иначе psql подвисает на приглашении. @
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
+368
-56
@@ -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:
|
return 0
|
||||||
log("режим --dump-only: restore пропущен")
|
|
||||||
prune(dump_dir, int(cfg.get("keep_dumps", 5)))
|
if command == "dumps":
|
||||||
return 0
|
for path in local_dumps(cfg):
|
||||||
|
st = path.stat()
|
||||||
|
print(f"{path.name:<48} {st.st_size / 1024 / 1024:>8.1f} МБ "
|
||||||
|
f"{datetime.fromtimestamp(st.st_mtime):%d.%m.%Y %H:%M}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
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("перенос завершён")
|
||||||
|
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
|
||||||
|
|
||||||
recreate = bool(cfg["target"].get("recreate", True)) and not args.no_recreate
|
|
||||||
if recreate:
|
|
||||||
recreate_target(cfg, tools, args.dry_run)
|
|
||||||
restore(cfg, tools, dump_file, recreate, args.dry_run)
|
|
||||||
post_sql(cfg, tools, args.dry_run)
|
|
||||||
if not args.restore_only:
|
|
||||||
prune(dump_dir, int(cfg.get("keep_dumps", 5)))
|
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user