Files

338 lines
13 KiB
Python

from __future__ import annotations
import json
import os
import shutil
import subprocess
import tempfile
import threading
import zipfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlsplit, urlunsplit
from .version import APP_VERSION
BACKUP_DIR = Path(os.getenv("BACKUP_DIR", "/data/backups"))
CONFIG_PATH = Path(os.getenv("APP_CONFIG", "/app/config/config.json"))
APPINFO_PATH = Path(os.getenv("APPINFO_PATH", "/app/config/APPINFO.json"))
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/app/app/static/uploads"))
BACKUP_INTERVAL_HOURS = max(1, int(os.getenv("BACKUP_INTERVAL_HOURS", "8")))
BACKUP_RETENTION_DAYS = max(1, int(os.getenv("BACKUP_RETENTION_DAYS", "3")))
BACKUP_PREFIX = "assetmanager-backup-"
BACKUP_SUFFIX = ".zip"
_LOCK = threading.Lock()
def _database_url() -> str:
value = os.getenv("DATABASE_URL", "")
if value.startswith("postgresql+psycopg://"):
value = "postgresql://" + value[len("postgresql+psycopg://"):]
elif value.startswith("postgres+psycopg://"):
value = "postgresql://" + value[len("postgres+psycopg://"):]
return value
def _safe_database_url() -> str:
value = _database_url()
try:
parsed = urlsplit(value)
host = parsed.hostname or ""
port = f":{parsed.port}" if parsed.port else ""
username = parsed.username or ""
auth = f"{username}@" if username else ""
return urlunsplit((parsed.scheme, f"{auth}{host}{port}", parsed.path, "", ""))
except Exception:
return "postgresql://"
def ensure_backup_dir() -> Path:
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
return BACKUP_DIR
def _is_mount_point(path: Path) -> bool:
"""Best-effort check whether path is a distinct container mount."""
try:
resolved = path.resolve()
if resolved.is_mount():
return True
mountinfo = Path("/proc/self/mountinfo")
if mountinfo.is_file():
target = str(resolved)
for line in mountinfo.read_text(encoding="utf-8", errors="replace").splitlines():
parts = line.split()
if len(parts) > 4 and parts[4].replace("\\040", " ") == target:
return True
except OSError:
pass
return False
def _directory_status(path: Path, create: bool = False) -> dict:
error = ""
writable = False
exists = path.exists()
try:
if create:
path.mkdir(parents=True, exist_ok=True)
exists = True
if exists and path.is_dir():
with tempfile.NamedTemporaryFile(prefix=".assetmanager-write-test-", dir=path, delete=True):
writable = True
except Exception as exc:
error = f"{type(exc).__name__}: {exc}"
return {
"path": str(path),
"exists": exists,
"writable": writable,
"is_mount": _is_mount_point(path) if exists else False,
"error": error,
}
def system_storage_information(log_dir: Path | None = None) -> dict:
backups = list_backups()
total_size = sum(int(item.get("size") or 0) for item in backups)
latest = backups[0] if backups else None
return {
"database_connected": True,
"config": _directory_status(CONFIG_PATH.parent),
"uploads": _directory_status(UPLOAD_DIR),
"logs": _directory_status(log_dir or Path(os.getenv("LOG_DIR", "/app/data/logs"))),
"backups": _directory_status(BACKUP_DIR, create=True),
"backup_count": len(backups),
"backup_total_size": total_size,
"latest_backup": latest,
}
def validate_backup_name(name: str) -> str:
candidate = Path(name).name
if candidate != name or not candidate.startswith(BACKUP_PREFIX) or not candidate.endswith(BACKUP_SUFFIX):
raise ValueError("invalid backup name")
return candidate
def backup_path(name: str) -> Path:
return ensure_backup_dir() / validate_backup_name(name)
def _run(command: list[str]) -> None:
process = subprocess.run(command, capture_output=True, text=True)
if process.returncode != 0:
detail = (process.stderr or process.stdout or "command failed").strip()
raise RuntimeError(detail[-2000:])
def create_backup(created_by: str = "system", reason: str = "manual") -> dict:
with _LOCK:
directory = ensure_backup_dir()
now = datetime.now(timezone.utc)
timestamp = now.strftime("%Y%m%d-%H%M%S")
final_path = directory / f"{BACKUP_PREFIX}{timestamp}{BACKUP_SUFFIX}"
sequence = 1
while final_path.exists():
final_path = directory / f"{BACKUP_PREFIX}{timestamp}-{sequence}{BACKUP_SUFFIX}"
sequence += 1
with tempfile.TemporaryDirectory(prefix="assetmanager-backup-") as temp_name:
temp = Path(temp_name)
dump_path = temp / "database.dump"
database_url = _database_url()
if not database_url:
raise RuntimeError("DATABASE_URL is not configured")
_run(["pg_dump", "--format=custom", "--no-owner", "--no-privileges", "--file", str(dump_path), database_url])
included: list[str] = ["database.dump"]
files_dir = temp / "files"
files_dir.mkdir()
if CONFIG_PATH.is_file():
shutil.copy2(CONFIG_PATH, files_dir / "config.json")
included.append("files/config.json")
if APPINFO_PATH.is_file():
shutil.copy2(APPINFO_PATH, files_dir / "APPINFO.json")
included.append("files/APPINFO.json")
if UPLOAD_DIR.is_dir():
target = files_dir / "uploads"
shutil.copytree(UPLOAD_DIR, target)
included.append("files/uploads/")
metadata = {
"format": 1,
"application": "AssetManager",
"version": _read_version(),
"created_at": now.isoformat(),
"created_by": created_by or "system",
"reason": reason,
"database": _safe_database_url(),
"included": included,
}
(temp / "metadata.json").write_text(json.dumps(metadata, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
(temp / "VERSION").write_text(metadata["version"] + "\n", encoding="utf-8")
included.append("VERSION")
temporary_zip = final_path.with_suffix(".zip.part")
try:
with zipfile.ZipFile(temporary_zip, "w", zipfile.ZIP_DEFLATED, allowZip64=True) as archive:
for path in sorted(temp.rglob("*")):
if path.is_file():
archive.write(path, path.relative_to(temp))
temporary_zip.replace(final_path)
finally:
temporary_zip.unlink(missing_ok=True)
cleanup_backups()
return describe_backup(final_path)
def _read_version() -> str:
for candidate in (Path("/app/VERSION"), Path(__file__).resolve().parent.parent / "VERSION", Path(__file__).resolve().parent / "VERSION"):
try:
value = candidate.read_text(encoding="utf-8").strip()
if value and value.casefold() != "unknown":
return value
except OSError:
pass
return APP_VERSION
def _archive_version(archive: zipfile.ZipFile, metadata: dict) -> str:
value = str(metadata.get("version") or "").strip()
if value and value.casefold() != "unknown":
return value
names = set(archive.namelist())
for name in ("VERSION", "files/VERSION", "assetmanager/VERSION"):
if name in names:
try:
value = archive.read(name).decode("utf-8", errors="replace").strip()
if value and value.casefold() != "unknown":
return value
except Exception:
pass
for name in ("files/APPINFO.json", "APPINFO.json"):
if name in names:
try:
value = str(json.loads(archive.read(name).decode("utf-8")).get("version") or "").strip()
if value and value.casefold() != "unknown":
return value
except Exception:
pass
return ""
def _metadata(path: Path) -> dict:
try:
with zipfile.ZipFile(path) as archive:
metadata = json.loads(archive.read("metadata.json").decode("utf-8"))
if not isinstance(metadata, dict):
return {}
metadata = dict(metadata)
metadata["version"] = _archive_version(archive, metadata)
return metadata
except Exception:
return {}
def describe_backup(path: Path) -> dict:
stat = path.stat()
metadata = _metadata(path)
return {
"name": path.name,
"size": stat.st_size,
"modified_at": datetime.fromtimestamp(stat.st_mtime, timezone.utc),
"created_at": metadata.get("created_at", ""),
"created_by": metadata.get("created_by", ""),
"reason": metadata.get("reason", ""),
"version": metadata.get("version", ""),
"valid": bool(metadata),
}
def list_backups() -> list[dict]:
directory = ensure_backup_dir()
items = [describe_backup(path) for path in directory.glob(f"{BACKUP_PREFIX}*{BACKUP_SUFFIX}") if path.is_file()]
return sorted(items, key=lambda item: item["modified_at"], reverse=True)
def cleanup_backups() -> int:
threshold = datetime.now(timezone.utc) - timedelta(days=BACKUP_RETENTION_DAYS)
removed = 0
for item in list_backups():
if item["modified_at"] < threshold:
backup_path(item["name"]).unlink(missing_ok=True)
removed += 1
return removed
def delete_backup(name: str) -> None:
backup_path(name).unlink()
def store_uploaded_backup(filename: str, content: bytes) -> dict:
ensure_backup_dir()
if len(content) < 100:
raise ValueError("backup file is empty")
with tempfile.NamedTemporaryFile(prefix="backup-upload-", suffix=".zip", delete=False, dir=BACKUP_DIR) as handle:
temp_path = Path(handle.name)
handle.write(content)
try:
with zipfile.ZipFile(temp_path) as archive:
names = set(archive.namelist())
if "metadata.json" not in names or "database.dump" not in names:
raise ValueError("unsupported backup archive")
metadata = json.loads(archive.read("metadata.json").decode("utf-8"))
if metadata.get("application") != "AssetManager":
raise ValueError("unsupported backup archive")
now = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
target = BACKUP_DIR / f"{BACKUP_PREFIX}{now}-upload{BACKUP_SUFFIX}"
counter = 1
while target.exists():
target = BACKUP_DIR / f"{BACKUP_PREFIX}{now}-upload-{counter}{BACKUP_SUFFIX}"
counter += 1
temp_path.replace(target)
return describe_backup(target)
finally:
temp_path.unlink(missing_ok=True)
def restore_backup(name: str) -> dict:
path = backup_path(name)
with _LOCK, tempfile.TemporaryDirectory(prefix="assetmanager-restore-") as temp_name:
temp = Path(temp_name)
with zipfile.ZipFile(path) as archive:
archive.extractall(temp)
dump_path = temp / "database.dump"
if not dump_path.is_file():
raise ValueError("database dump missing")
database_url = _database_url()
_run(["pg_restore", "--clean", "--if-exists", "--no-owner", "--no-privileges", "--dbname", database_url, str(dump_path)])
files = temp / "files"
if (files / "config.json").is_file():
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(files / "config.json", CONFIG_PATH)
if (files / "APPINFO.json").is_file():
APPINFO_PATH.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(files / "APPINFO.json", APPINFO_PATH)
if (files / "uploads").is_dir():
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
for child in UPLOAD_DIR.iterdir():
if child.is_dir():
shutil.rmtree(child)
else:
child.unlink()
shutil.copytree(files / "uploads", UPLOAD_DIR, dirs_exist_ok=True)
return _metadata(path)
def automatic_backup_loop(stop_event: threading.Event) -> None:
# Wait after application start; do not create a backup during every restart.
while not stop_event.wait(BACKUP_INTERVAL_HOURS * 3600):
try:
create_backup(created_by="system", reason="automatic")
except Exception:
# Main application logger records route errors; scheduler must stay alive.
pass