Complete DDS training workflow and delivery package
This commit is contained in:
parent
68dd83c7c2
commit
4c4b91064f
229 changed files with 11969 additions and 1024 deletions
|
|
@ -7,13 +7,16 @@
|
|||
честно говорим, чего не хватает.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
from getpass import getuser
|
||||
from pathlib import Path
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
|
@ -23,51 +26,122 @@ DIR = Path(__file__).resolve().parents[2] / "backups"
|
|||
#: Контейнер базы в docker compose проекта.
|
||||
CONTAINER = os.environ.get("POSTGRES_CONTAINER", "lct-hack-postgres-1")
|
||||
TIMEOUT_S = 120
|
||||
# pg_dump 17 добавляет эту настройку в plain SQL, а целевая PostgreSQL 16 из
|
||||
# Compose её ещё не знает. Остальной plain dump обратно совместим; удаляем
|
||||
# только служебный SET, не данные и не DDL.
|
||||
INCOMPATIBLE_SETTINGS = (b"SET transaction_timeout = 0;\n",)
|
||||
|
||||
|
||||
class BackupError(RuntimeError):
|
||||
"""Копию снять нечем или не удалось. Сообщение показывается администратору."""
|
||||
|
||||
|
||||
def _restrict_file_permissions(fd: int, path: Path, platform_name: str | None = None) -> None:
|
||||
"""Закрыть резервную копию от остальных локальных пользователей.
|
||||
|
||||
chmod(0o600) не задаёт ACL на Windows, поэтому там icacls удаляет
|
||||
наследуемые разрешения и оставляет полный доступ владельцу процесса и
|
||||
системной учётной записи. При ошибке копию не публикуем.
|
||||
"""
|
||||
platform_name = platform_name or os.name
|
||||
if platform_name == "nt":
|
||||
username = getuser()
|
||||
domain = os.environ.get("USERDOMAIN")
|
||||
if domain and "\\" not in username:
|
||||
username = f"{domain}\\{username}"
|
||||
system_root = Path(os.environ.get("SystemRoot", "C:\\Windows"))
|
||||
icacls = system_root / "System32" / "icacls.exe"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[str(icacls), str(path), "/inheritance:r", "/grant:r",
|
||||
f"{username}:(F)", "*S-1-5-18:(F)"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise BackupError("не удалось задать ACL резервной копии через icacls") from exc
|
||||
if result.returncode != 0:
|
||||
raise BackupError("Windows не применила закрытый ACL к резервной копии")
|
||||
return
|
||||
if hasattr(os, "fchmod"):
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
except OSError as exc:
|
||||
raise BackupError("не удалось ограничить права резервной копии") from exc
|
||||
else:
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except OSError as exc:
|
||||
raise BackupError("не удалось ограничить права резервной копии") from exc
|
||||
|
||||
|
||||
def _dsn() -> tuple[str, str, str, str, str]:
|
||||
"""Разобрать DATABASE_URL на части для pg_dump."""
|
||||
url = get_settings().database_url
|
||||
match = re.match(r".*://([^:]+):([^@]+)@([^:/]+):(\d+)/(\w+)", url)
|
||||
if not match:
|
||||
raise BackupError(f"не разобрать DATABASE_URL: {url}")
|
||||
return match.groups() # user, password, host, port, database
|
||||
try:
|
||||
parsed = urlsplit(url)
|
||||
user = unquote(parsed.username or "")
|
||||
password = unquote(parsed.password or "")
|
||||
host = parsed.hostname or ""
|
||||
port = parsed.port
|
||||
database = unquote(parsed.path.lstrip("/"))
|
||||
except ValueError as exc:
|
||||
# Не включать исходный URL в диагностику: он содержит пароль.
|
||||
raise BackupError("не удалось разобрать DATABASE_URL") from exc
|
||||
if not all((user, password, host, port, database)) or "/" in database:
|
||||
raise BackupError("не удалось разобрать DATABASE_URL")
|
||||
return user, password, host, str(port), database
|
||||
|
||||
|
||||
def listing() -> list[dict]:
|
||||
"""Готовые копии, новые первыми."""
|
||||
if not DIR.exists():
|
||||
items = []
|
||||
try:
|
||||
with os.scandir(DIR) as entries:
|
||||
for entry in entries:
|
||||
if not entry.name.endswith(".sql") or not entry.is_file(follow_symlinks=False):
|
||||
continue
|
||||
stat = entry.stat(follow_symlinks=False)
|
||||
items.append({
|
||||
"name": entry.name,
|
||||
"size_bytes": stat.st_size,
|
||||
"at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||
})
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
items = [
|
||||
{
|
||||
"name": path.name,
|
||||
"size_bytes": path.stat().st_size,
|
||||
"at": datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc),
|
||||
}
|
||||
for path in DIR.glob("*.sql")
|
||||
]
|
||||
except OSError as exc:
|
||||
raise BackupError(f"не удалось прочитать каталог резервных копий: {exc}") from exc
|
||||
return sorted(items, key=lambda item: item["at"], reverse=True)
|
||||
|
||||
|
||||
def create() -> dict:
|
||||
"""Снять копию. Возвращает описание файла, как в `listing()`."""
|
||||
user, password, host, port, database = _dsn()
|
||||
DIR.mkdir(parents=True, exist_ok=True)
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
||||
try:
|
||||
DIR.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
raise BackupError(f"недоступен каталог резервных копий: {exc}") from exc
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S-%f")
|
||||
target = DIR / f"lct-{stamp}.sql"
|
||||
|
||||
if shutil.which("pg_dump"):
|
||||
command = ["pg_dump", "-h", host, "-p", port, "-U", user, "-d", database]
|
||||
env = {**os.environ, "PGPASSWORD": password}
|
||||
stdin_data = None
|
||||
elif shutil.which("docker"):
|
||||
# В контейнере база доступна как localhost, а не по хосту из DSN.
|
||||
command = ["docker", "exec", "-e", f"PGPASSWORD={password}", CONTAINER,
|
||||
"pg_dump", "-U", user, "-d", database]
|
||||
# Пароль передаём через stdin, а не аргументом `docker exec -e`,
|
||||
# поскольку аргументы процесса могут быть видны другим пользователям.
|
||||
script = (
|
||||
"IFS= read -r encoded_password || exit 1; "
|
||||
'PGPASSWORD="$(printf %s "$encoded_password" | base64 -d)" || exit 1; '
|
||||
'export PGPASSWORD; exec pg_dump -U "$1" -d "$2"'
|
||||
)
|
||||
command = ["docker", "exec", "-i", CONTAINER, "sh", "-c", script,
|
||||
"lct-pg-dump", user, database]
|
||||
env = dict(os.environ)
|
||||
stdin_data = base64.b64encode(password.encode("utf-8")) + b"\n"
|
||||
else:
|
||||
raise BackupError(
|
||||
"нечем снять копию: нет ни pg_dump, ни docker. "
|
||||
|
|
@ -75,7 +149,9 @@ def create() -> dict:
|
|||
)
|
||||
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, timeout=TIMEOUT_S, env=env)
|
||||
result = subprocess.run(
|
||||
command, capture_output=True, timeout=TIMEOUT_S, env=env, input=stdin_data
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise BackupError(f"pg_dump не уложился в {TIMEOUT_S} с") from exc
|
||||
except OSError as exc:
|
||||
|
|
@ -84,10 +160,38 @@ def create() -> dict:
|
|||
if result.returncode != 0:
|
||||
raise BackupError(result.stderr.decode(errors="replace")[:300] or "pg_dump завершился с ошибкой")
|
||||
|
||||
target.write_bytes(result.stdout)
|
||||
log.info("резервная копия: %s, %d КБ", target.name, target.stat().st_size // 1024)
|
||||
dump = result.stdout
|
||||
for setting in INCOMPATIBLE_SETTINGS:
|
||||
dump = dump.replace(setting, b"")
|
||||
if not dump.startswith(b"--\n-- PostgreSQL database dump"):
|
||||
raise BackupError("pg_dump вернул неожиданный или пустой формат")
|
||||
# Публикуем только полностью записанный dump. При аварийном завершении
|
||||
# процесса список копий не должен показывать обрезанный файл как готовый.
|
||||
try:
|
||||
fd, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=DIR)
|
||||
except OSError as exc:
|
||||
raise BackupError(f"не удалось создать временный файл копии: {exc}") from exc
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as stream:
|
||||
_restrict_file_permissions(stream.fileno(), temporary)
|
||||
stream.write(dump)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
temporary.replace(target)
|
||||
except Exception as exc:
|
||||
try:
|
||||
temporary.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
log.exception("не удалось удалить временную копию %s", temporary.name)
|
||||
if isinstance(exc, OSError):
|
||||
raise BackupError(f"не удалось записать резервную копию: {exc}") from exc
|
||||
raise
|
||||
size_bytes = len(dump)
|
||||
created_at = datetime.now(timezone.utc)
|
||||
log.info("резервная копия: %s, %d КБ", target.name, size_bytes // 1024)
|
||||
return {
|
||||
"name": target.name,
|
||||
"size_bytes": target.stat().st_size,
|
||||
"at": datetime.fromtimestamp(target.stat().st_mtime, tz=timezone.utc),
|
||||
"size_bytes": size_bytes,
|
||||
"at": created_at,
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue