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."""

View File

@@ -382,6 +382,9 @@
<section class="section">
<h2 class="section-title">Capture image or deploy</h2>
<p class="backup-deploy-hint">To <strong>capture (backup)</strong> an image from a device: connect it in USB boot mode or register over network. When the device appears below, click <strong>Backup</strong> to save its eMMC to a file. To write an image to a device, click <strong>Deploy</strong> (requires a golden image).</p>
<p id="shrinkOptionWrap" class="backup-deploy-hint" style="display:none; margin-top:0.5rem;">
<label><input type="checkbox" id="shrinkAfterBackup" /> Shrink after backup</label> <span class="backups-mono" style="font-size:0.8rem;">(reduces size; requires PiShrink on host)</span>
</p>
<div id="pendingDevices"></div>
<div id="noPendingPlaceholder" class="placeholder-actions" style="display:none;">
<span>Connect a device to see:</span>
@@ -506,12 +509,16 @@
container.innerHTML = '';
let hasAny = false;
const shrinkWrap = document.getElementById('shrinkOptionWrap');
if (usb) {
hasAny = true;
if (shrinkWrap) shrinkWrap.style.display = 'block';
const el = document.createElement('div');
el.className = 'device-item';
el.innerHTML = '<div class="device-info"><div class="device-type">USB boot</div><div class="device-desc">Device connected — choose Backup or Deploy</div></div><div class="device-actions"><button type="button" class="btn btn-outline" data-source="usb" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="usb" data-action="deploy">Deploy</button></div>';
container.appendChild(el);
} else {
if (shrinkWrap) shrinkWrap.style.display = 'none';
}
(network || []).forEach(function(d) {
hasAny = true;
@@ -532,6 +539,8 @@
const mac = btn.getAttribute('data-mac');
const body = { source: source, action: action };
if (mac) body.mac = mac;
const shrinkCb = document.getElementById('shrinkAfterBackup');
if (action === 'backup' && shrinkCb && shrinkCb.checked) body.shrink = true;
fetch('/api/device-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
.then(function(r) { return r.json(); })
.then(function(data) {
@@ -557,12 +566,15 @@
tr.dataset.name = b.name;
const displayName = (b.display_name || b.name);
const desc = (b.description || '');
const isRawImg = b.name.endsWith('.img') && !b.name.endsWith('.img.gz') && !b.name.endsWith('.img.xz');
const shrinkBtn = isRawImg ? '<button type="button" class="btn btn-outline btn-sm shrink-btn" data-name="' + escapeHtml(b.name) + '" title="Shrink image (PiShrink)">Shrink</button> ' : '';
tr.innerHTML =
'<td class="backup-name-edit" data-field="name" title="Click to rename">' + escapeHtml(displayName) + '</td>' +
'<td class="backup-desc-edit" data-field="description" title="Click to add description">' + escapeHtml(desc) + '</td>' +
'<td class="backups-mono">' + fmtSize(b.size) + '</td>' +
'<td class="backups-mono">' + fmtDate(b.mtime) + '</td>' +
'<td class="actions-cell">' +
shrinkBtn +
'<button type="button" class="btn btn-primary btn-sm set-golden-btn" data-name="' + escapeHtml(b.name) + '">Set as golden</button> ' +
'<button type="button" class="btn btn-outline btn-sm rename-file-btn" data-name="' + escapeHtml(b.name) + '" title="Rename file">Rename file</button> ' +
'<a href="/api/backups/' + encodeURIComponent(b.name) + '" download class="btn btn-outline btn-sm download-link">Download</a>' +
@@ -572,6 +584,7 @@
bindBackupEdits();
bindSetGolden();
bindRenameFile();
bindShrink();
}
function bindRenameFile() {
@@ -648,6 +661,25 @@
});
}
function bindShrink() {
document.querySelectorAll('.shrink-btn').forEach(function(btn) {
btn.onclick = function() {
const name = btn.getAttribute('data-name');
if (!confirm('Shrink this image with PiShrink? This reduces file size and may take a few minutes.\n\n' + name)) return;
btn.disabled = true;
btn.textContent = 'Shrinking…';
fetch('/api/backups/' + encodeURIComponent(name) + '/shrink', { method: 'POST' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) { fetchBackups(); }
else alert(data.error || data.detail || 'Failed');
})
.catch(function() { alert('Request failed'); })
.finally(function() { btn.disabled = false; btn.textContent = 'Shrink'; });
};
});
}
function fetchGoldenInfo() {
fetch('/api/golden-info').then(function(r) { return r.json(); }).then(function(d) {
const el = document.getElementById('goldenHint');