338 lines
13 KiB
Python
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
|