Initial pve cloud backup app

This commit is contained in:
Codex
2026-07-15 01:00:08 +02:00
commit 7a4c561f8e
37 changed files with 5495 additions and 0 deletions
+199
View File
@@ -0,0 +1,199 @@
from __future__ import annotations
import json
import re
import shutil
import subprocess
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
@dataclass
class CommandResult:
stdout: str
stderr: str
class CommandError(RuntimeError):
def __init__(self, command: list[str], result: subprocess.CompletedProcess[str]):
super().__init__(
f"Command failed ({result.returncode}): {' '.join(command)}\n{result.stderr.strip()}"
)
self.command = command
self.returncode = result.returncode
self.stdout = result.stdout
self.stderr = result.stderr
def run(command: list[str], timeout: int | None = None) -> CommandResult:
result = subprocess.run(
command,
text=True,
capture_output=True,
timeout=timeout,
check=False,
)
if result.returncode != 0:
raise CommandError(command, result)
return CommandResult(stdout=result.stdout, stderr=result.stderr)
def find_executable(name: str) -> str:
return shutil.which(name) or ""
def pvesh_json(args: list[str], timeout: int | None = 120):
result = run(["pvesh", *args, "--output-format", "json"], timeout=timeout)
text = result.stdout.strip()
return json.loads(text) if text else None
def _extract_upid(text: str):
text = text.strip()
if not text:
return None
if text.startswith("UPID:"):
return text
match = re.search(r"UPID:[^\s\"']+", text)
if match:
return match.group(0)
try:
data = json.loads(text)
except json.JSONDecodeError:
return None
if isinstance(data, str) and data.startswith("UPID:"):
return data
if isinstance(data, dict):
for key in ("upid", "data"):
value = data.get(key)
if isinstance(value, str) and value.startswith("UPID:"):
return value
return None
def list_guests() -> list[dict]:
resources = pvesh_json(["get", "/cluster/resources", "--type", "vm"]) or []
guests: list[dict] = []
for item in resources:
raw_type = item.get("type")
guest_type = "lxc" if raw_type == "lxc" else "vm"
guests.append(
{
"vmid": int(item["vmid"]),
"name": item.get("name") or str(item["vmid"]),
"type": guest_type,
"node": item.get("node") or "",
"status": item.get("status") or "unknown",
}
)
return sorted(guests, key=lambda row: (row["node"], row["vmid"]))
def list_storages(node: str | None = None) -> list[str]:
path = f"/nodes/{node}/storage" if node else "/storage"
data = pvesh_json(["get", path]) or []
return sorted({item.get("storage") for item in data if item.get("storage")})
def start_proxmox_backup(
*,
node: str,
vmid: int,
storage: str,
mode: str,
compression: str,
) -> str:
command = [
"pvesh",
"create",
f"/nodes/{node}/vzdump",
"--vmid",
str(vmid),
"--storage",
storage,
"--mode",
mode,
"--compress",
compression,
"--output-format",
"json",
]
result = run(command, timeout=300)
upid = _extract_upid(result.stdout)
if not upid:
upid = _extract_upid(result.stderr)
if upid:
return upid
# Some pvesh versions ignore --output-format for create actions and return
# plain task text. Include both streams so failures are diagnosable without
# assuming JSON.
raise RuntimeError(
"Could not read UPID from Proxmox response: "
f"stdout={result.stdout.strip()!r} stderr={result.stderr.strip()!r}"
)
def get_task_status(node: str, upid: str) -> dict:
return pvesh_json(["get", f"/nodes/{node}/tasks/{upid}/status"]) or {}
def discover_archive(local_backup_dir: str, vmid: int, guest_type: str, started_at: str) -> Path:
base = Path(local_backup_dir)
prefix = "vzdump-lxc" if guest_type == "lxc" else "vzdump-qemu"
archive_suffixes = (
".tar",
".tar.gz",
".tar.lzo",
".tar.zst",
".vma",
".vma.gz",
".vma.lzo",
".vma.zst",
)
started = datetime.fromisoformat(started_at)
candidates = []
for path in base.glob(f"{prefix}-{vmid}-*"):
if path.is_file() and path.name.endswith(archive_suffixes):
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=started.tzinfo)
if mtime >= started:
candidates.append(path)
if not candidates:
raise FileNotFoundError(f"No backup archive found for VMID {vmid} in {base}")
return max(candidates, key=lambda p: p.stat().st_mtime)
def rclone_list_remotes(rclone_path: str) -> list[str]:
result = run([rclone_path, "listremotes"], timeout=60)
return [line.rstrip(":") for line in result.stdout.splitlines() if line.strip()]
def rclone_remote_type(rclone_path: str, remote_name: str) -> str:
remote = remote_name.rstrip(":")
result = run([rclone_path, "config", "show", remote])
for line in result.stdout.splitlines():
if line.strip().startswith("type"):
_, value = line.split("=", 1)
return value.strip()
return ""
def rclone_copyto(rclone_path: str, local_path: str, remote_object: str) -> None:
run([rclone_path, "copyto", local_path, remote_object], timeout=None)
def rclone_object_exists(rclone_path: str, remote_object: str) -> bool:
try:
run([rclone_path, "lsjson", remote_object], timeout=120)
return True
except CommandError:
return False
def rclone_delete_file(rclone_path: str, remote_object: str) -> None:
run([rclone_path, "deletefile", remote_object], timeout=300)
def rclone_rmdir(rclone_path: str, remote_dir: str) -> None:
run([rclone_path, "rmdir", remote_dir], timeout=120)