Add backup shrinking functionality to eMMC provisioning dashboard: implement API for shrinking raw .img backups using PiShrink, update UI to support shrink option after backup, and enhance documentation for backup image handling and storage options on Proxmox host.

This commit is contained in:
nearxos
2026-02-18 18:55:32 +02:00
parent f93d224e8b
commit 5ff46e67d8
9 changed files with 302 additions and 20 deletions

View File

@@ -8,6 +8,7 @@ Supports USB boot mode (ask Backup/Deploy) and network-booted devices (register,
import json
import os
import shutil
import subprocess
import time
from pathlib import Path
@@ -114,7 +115,7 @@ def _safe_backup_name(name):
"""Reject path traversal and ensure it's a backup filename we manage."""
if not name or ".." in name or "/" in name or "\\" in name:
return False
if not (name.endswith(".img") or name.endswith(".img.gz")):
if not name.endswith((".img", ".img.gz", ".img.xz")):
return False
return True
@@ -125,7 +126,7 @@ def list_backups():
meta = _load_backups_meta()
out = []
for p in sorted(BACKUPS_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if p.is_file() and p.suffix in (".img", ".img.gz") and p.name != "backups_meta.json":
if p.is_file() and p.name != "backups_meta.json" and p.name.endswith((".img", ".img.gz", ".img.xz")):
try:
st = p.stat()
m = meta.get(p.name, {})
@@ -195,6 +196,12 @@ def api_device_action():
if source == "usb":
try:
os.makedirs(os.path.dirname(ACTION_REQUEST_FILE) or ".", exist_ok=True)
# If user requested "shrink after backup", create flag so host runs PiShrink after dd
if action == "backup" and body.get("shrink"):
try:
(BASE_DIR / "shrink_next_backup").write_text("1")
except (PermissionError, OSError):
pass # host may still have SHRINK_BACKUP=1
with open(ACTION_REQUEST_FILE, "w") as f:
f.write(action)
return jsonify({"ok": True})
@@ -314,6 +321,40 @@ def api_backup_set_as_golden(name):
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/backups/<path:name>/shrink", methods=["POST"])
def api_backup_shrink(name):
"""Run PiShrink on a raw .img backup (shrinks in place). Requires PiShrink in LXC/host."""
if not _safe_backup_name(name):
return jsonify({"ok": False, "error": "invalid backup name"}), 400
if not name.endswith(".img") or name.endswith(".img.gz") or name.endswith(".img.xz"):
return jsonify({"ok": False, "error": "only raw .img files can be shrunk (not .img.gz / .img.xz)"}), 400
path = BACKUPS_DIR / name
if not path.is_file():
return jsonify({"ok": False, "error": "backup not found"}), 404
pishrink = shutil.which("pishrink.sh") or "/usr/local/bin/pishrink.sh"
if not pishrink or not os.path.isfile(pishrink):
return jsonify({"ok": False, "error": "PiShrink not installed. Install on the host/LXC (e.g. scripts/install-pishrink-on-host.sh)"}), 503
try:
proc = subprocess.run(
[pishrink, "-n", name],
cwd=str(BACKUPS_DIR),
capture_output=True,
text=True,
timeout=3600,
)
if proc.returncode != 0:
return jsonify({
"ok": False,
"error": "PiShrink failed",
"detail": (proc.stderr or proc.stdout or "").strip() or f"exit code {proc.returncode}",
}), 500
return jsonify({"ok": True, "message": f"Shrunk {name}"})
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "error": "PiShrink timed out"}), 504
except OSError as e:
return jsonify({"ok": False, "error": str(e)}), 500
@app.route("/api/backups/<path:name>", methods=["PATCH"])
def api_backup_update(name):
"""Update backup metadata (display name, description) or rename the file."""