Files
Inv_web/database_maintenance.py
2026-08-11 12:54:09 +03:00

445 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Asynchronous PostgreSQL backup and restore jobs for Inventera."""
from __future__ import annotations
from copy import deepcopy
from datetime import datetime, timedelta
import glob
import os
from pathlib import Path
import re
import shutil
import subprocess
import tempfile
import threading
import time
import uuid
class DatabaseMaintenanceError(RuntimeError):
"""Base error exposed to the database maintenance API."""
class DatabaseMaintenanceBusy(DatabaseMaintenanceError):
"""Raised when another backup or restore job is already running."""
class PostgreSqlToolNotFound(DatabaseMaintenanceError):
"""Raised when a required PostgreSQL command-line tool is unavailable."""
class InvalidBackupFile(DatabaseMaintenanceError):
"""Raised when the uploaded file is not a PostgreSQL custom-format dump."""
class DatabaseMaintenanceManager:
def __init__(self, db_config, work_directory=None):
self._db_config = dict(db_config)
self._work_directory = Path(work_directory or self._default_work_directory())
self._jobs = {}
self._active_job_id = None
self._lock = threading.RLock()
@staticmethod
def _default_work_directory():
program_data = os.environ.get("PROGRAMDATA")
if program_data:
return os.path.join(program_data, "Inventera", "database-maintenance")
return os.path.join(tempfile.gettempdir(), "inventera", "database-maintenance")
@staticmethod
def _find_postgresql_tool(tool_name):
executable_name = f"{tool_name}.exe" if os.name == "nt" else tool_name
discovered = shutil.which(executable_name) or shutil.which(tool_name)
if discovered:
return discovered
candidates = []
discovered_psql = shutil.which("psql.exe" if os.name == "nt" else "psql")
if discovered_psql:
sibling = os.path.join(os.path.dirname(discovered_psql), executable_name)
if os.path.isfile(sibling):
candidates.append(sibling)
windows_roots = []
for environment_name in ("ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"):
value = os.environ.get(environment_name)
if value and value not in windows_roots:
windows_roots.append(value)
for root in windows_roots:
candidates.extend(
glob.glob(os.path.join(root, "PostgreSQL", "*", "bin", executable_name))
)
common_patterns = [
os.path.join("/Library/PostgreSQL", "*", "bin", executable_name),
os.path.join(
"/Applications/Postgres.app/Contents/Versions",
"*",
"bin",
executable_name,
),
os.path.join("/opt/homebrew/opt", "postgresql*", "bin", executable_name),
os.path.join("/usr/local/opt", "postgresql*", "bin", executable_name),
os.path.join("/usr/lib/postgresql", "*", "bin", executable_name),
]
for pattern in common_patterns:
candidates.extend(glob.glob(pattern))
def version_key(candidate):
version_name = Path(candidate).parent.parent.name
return tuple(int(part) for part in re.findall(r"\d+", version_name))
def candidate_key(candidate):
normalized = candidate.replace("\\", "/").lower()
if "/library/postgresql/" in normalized:
distribution_priority = 4
elif "/applications/postgres.app/" in normalized:
distribution_priority = 3
elif "/postgresql/" in normalized:
distribution_priority = 2
else:
distribution_priority = 1
return version_key(candidate), distribution_priority, normalized
candidates = sorted(set(candidates), key=candidate_key, reverse=True)
for candidate in candidates:
if os.path.isfile(candidate):
return candidate
raise PostgreSqlToolNotFound(
f"Не найден {executable_name}. Установите командные инструменты PostgreSQL."
)
@staticmethod
def _popen_options():
options = {}
if os.name == "nt":
options["creationflags"] = subprocess.CREATE_NO_WINDOW
return options
def _postgres_environment(self):
environment = os.environ.copy()
environment["PGPASSWORD"] = str(self._db_config.get("password", ""))
environment["PGCLIENTENCODING"] = "UTF8"
return environment
def _connection_arguments(self):
return [
"--host",
str(self._db_config.get("host", "localhost")),
"--port",
str(self._db_config.get("port", 5432)),
"--username",
str(self._db_config.get("user", "postgres")),
"--dbname",
str(self._db_config.get("dbname", "postgres")),
"--no-password",
]
def _ensure_work_directories(self):
backup_directory = self._work_directory / "backups"
upload_directory = self._work_directory / "uploads"
backup_directory.mkdir(parents=True, exist_ok=True)
upload_directory.mkdir(parents=True, exist_ok=True)
return backup_directory, upload_directory
def _prune_jobs_locked(self):
cutoff = datetime.now() - timedelta(hours=24)
expired_ids = [
job_id
for job_id, job in self._jobs.items()
if job.get("created_at") and job["created_at"] < cutoff
and job.get("status") not in ("queued", "running")
]
for job_id in expired_ids:
job = self._jobs.pop(job_id)
path = job.get("path")
if path:
try:
Path(path).unlink(missing_ok=True)
except OSError:
pass
def _create_job(self, kind, owner, filename=None, path=None):
with self._lock:
self._prune_jobs_locked()
if self._active_job_id:
active = self._jobs.get(self._active_job_id)
if active and active.get("status") in ("queued", "running"):
raise DatabaseMaintenanceBusy(
"Дождитесь завершения текущей операции с базой данных."
)
self._active_job_id = None
job_id = uuid.uuid4().hex
self._jobs[job_id] = {
"id": job_id,
"kind": kind,
"owner": owner,
"status": "queued",
"progress": 0,
"message": "Операция поставлена в очередь.",
"filename": filename,
"path": str(path) if path else None,
"created_at": datetime.now(),
}
self._active_job_id = job_id
return job_id
def _update_job(self, job_id, **updates):
with self._lock:
job = self._jobs.get(job_id)
if not job:
return
job.update(updates)
if job.get("status") not in ("queued", "running") and self._active_job_id == job_id:
self._active_job_id = None
def _fail_job(self, job_id, message):
self._update_job(
job_id,
status="error",
progress=100,
message=message or "Операция завершилась с ошибкой.",
)
def get_job(self, job_id, owner):
with self._lock:
job = self._jobs.get(job_id)
if not job or job.get("owner") != owner:
return None
public_job = deepcopy(job)
public_job.pop("owner", None)
public_job.pop("path", None)
created_at = public_job.get("created_at")
if created_at:
public_job["created_at"] = created_at.isoformat(timespec="seconds")
public_job["download_ready"] = (
public_job.get("kind") == "backup" and public_job.get("status") == "success"
)
return public_job
def get_backup_download(self, job_id, owner):
with self._lock:
job = self._jobs.get(job_id)
if (
not job
or job.get("owner") != owner
or job.get("kind") != "backup"
or job.get("status") != "success"
):
return None
path = job.get("path")
filename = job.get("filename")
if not path or not os.path.isfile(path):
return None
return path, filename
def start_backup(self, owner, estimated_database_size=0):
pg_dump = self._find_postgresql_tool("pg_dump")
backup_directory, _ = self._ensure_work_directories()
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
filename = f"Inventera_{timestamp}.backup"
backup_path = backup_directory / f"{uuid.uuid4().hex}_{filename}"
job_id = self._create_job("backup", owner, filename, backup_path)
worker = threading.Thread(
target=self._run_backup,
args=(job_id, pg_dump, backup_path, max(int(estimated_database_size or 0), 0)),
name=f"inventera-backup-{job_id[:8]}",
daemon=True,
)
worker.start()
return job_id
def _run_backup(self, job_id, pg_dump, backup_path, estimated_database_size):
error_path = backup_path.with_suffix(".log")
try:
self._update_job(
job_id,
status="running",
progress=3,
message="Создание резервной копии базы данных...",
)
command = [
pg_dump,
*self._connection_arguments(),
"--format=custom",
"--no-owner",
"--no-privileges",
"--file",
str(backup_path),
]
with open(error_path, "wb") as error_file:
process = subprocess.Popen(
command,
stdout=subprocess.DEVNULL,
stderr=error_file,
env=self._postgres_environment(),
**self._popen_options(),
)
fallback_progress = 3
while process.poll() is None:
if estimated_database_size > 0 and backup_path.exists():
generated_size = backup_path.stat().st_size
progress = min(92, 5 + int((generated_size / estimated_database_size) * 87))
else:
fallback_progress = min(88, fallback_progress + 1)
progress = fallback_progress
self._update_job(job_id, progress=progress)
time.sleep(0.5)
exit_code = process.returncode
if exit_code != 0:
details = self._read_error_details(error_path)
raise DatabaseMaintenanceError(
details or f"pg_dump завершился с кодом {exit_code}."
)
if not backup_path.exists() or backup_path.stat().st_size <= 5:
raise DatabaseMaintenanceError("PostgreSQL не создал файл резервной копии.")
self._update_job(
job_id,
status="success",
progress=100,
message="Резервная копия создана. Скачивание файла начнётся автоматически.",
)
except Exception as exc:
try:
backup_path.unlink(missing_ok=True)
except OSError:
pass
self._fail_job(job_id, f"Ошибка резервного копирования: {exc}")
finally:
try:
error_path.unlink(missing_ok=True)
except OSError:
pass
@staticmethod
def _read_error_details(error_path):
try:
text = error_path.read_text(encoding="utf-8", errors="replace").strip()
except OSError:
return ""
if len(text) > 1600:
text = text[-1600:]
return text
def start_restore(self, owner, uploaded_file, original_filename):
pg_restore = self._find_postgresql_tool("pg_restore")
_, upload_directory = self._ensure_work_directories()
suffix = Path(original_filename or "").suffix.lower()
if suffix not in (".backup", ".dump"):
raise InvalidBackupFile(
"Выберите резервную копию PostgreSQL с расширением .backup или .dump."
)
upload_path = upload_directory / f"{uuid.uuid4().hex}{suffix}"
job_id = self._create_job("restore", owner, original_filename or upload_path.name, upload_path)
try:
uploaded_file.save(upload_path)
with open(upload_path, "rb") as backup_file:
signature = backup_file.read(5)
if signature != b"PGDMP":
raise InvalidBackupFile(
"Файл не является резервной копией PostgreSQL, созданной Inventera."
)
except Exception:
try:
upload_path.unlink(missing_ok=True)
except OSError:
pass
self._update_job(job_id, status="error", progress=100)
raise
worker = threading.Thread(
target=self._run_restore,
args=(job_id, pg_restore, upload_path),
name=f"inventera-restore-{job_id[:8]}",
daemon=True,
)
worker.start()
return job_id
def _restore_item_count(self, pg_restore, upload_path):
result = subprocess.run(
[pg_restore, "--list", str(upload_path)],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
env=self._postgres_environment(),
check=False,
**self._popen_options(),
)
if result.returncode != 0:
return 1
text = result.stdout.decode("utf-8", errors="replace")
return max(1, sum(1 for line in text.splitlines() if line and not line.startswith(";")))
def _run_restore(self, job_id, pg_restore, upload_path):
error_lines = []
try:
self._update_job(
job_id,
status="running",
progress=3,
message="Восстановление базы данных...",
)
total_items = self._restore_item_count(pg_restore, upload_path)
command = [
pg_restore,
*self._connection_arguments(),
"--clean",
"--if-exists",
"--no-owner",
"--no-privileges",
"--exit-on-error",
"--single-transaction",
"--verbose",
str(upload_path),
]
process = subprocess.Popen(
command,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
env=self._postgres_environment(),
**self._popen_options(),
)
processed_items = 0
if process.stderr is not None:
for raw_line in iter(process.stderr.readline, b""):
line = raw_line.decode("utf-8", errors="replace").strip()
if line:
error_lines.append(line)
if len(error_lines) > 30:
error_lines.pop(0)
processed_items += 1
progress = min(96, 5 + int((processed_items / total_items) * 90))
self._update_job(
job_id,
progress=progress,
message="Восстановление объектов базы данных...",
)
exit_code = process.wait()
if exit_code != 0:
details = "\n".join(error_lines[-12:])
raise DatabaseMaintenanceError(
details or f"pg_restore завершился с кодом {exit_code}."
)
self._update_job(
job_id,
status="success",
progress=100,
message="Восстановление завершено успешно.",
)
except Exception as exc:
self._fail_job(job_id, f"Ошибка восстановления базы данных: {exc}")
finally:
try:
upload_path.unlink(missing_ok=True)
except OSError:
pass