diff --git a/chromium-setup/emmc-provisioning/cloud-init/user-data-remote-bootstrap.example b/chromium-setup/emmc-provisioning/cloud-init/user-data-remote-bootstrap.example new file mode 100644 index 0000000..33d6ede --- /dev/null +++ b/chromium-setup/emmc-provisioning/cloud-init/user-data-remote-bootstrap.example @@ -0,0 +1,21 @@ +#cloud-config +# Example user-data that downloads and runs a script from a file server. +# Use this so you can change first-boot behaviour without rebuilding the golden image. +# Copy to boot partition as 'user-data' and set BOOTSTRAP_URL to your script URL. + +package_update: true +package_upgrade: false + +packages: + - curl # or wget if you prefer + +# Download script from file server and run it (runcmd runs after network is up) +runcmd: + - curl -fsSL "http://YOUR_FILE_SERVER/provisioning/bootstrap.sh" -o /tmp/bootstrap.sh + - chmod +x /tmp/bootstrap.sh + - /tmp/bootstrap.sh + # Optional: remove after run + # - rm -f /tmp/bootstrap.sh + +# Optional: finish cloud-init +# - cloud-init single --name cc_final_message diff --git a/chromium-setup/emmc-provisioning/dashboard/app.py b/chromium-setup/emmc-provisioning/dashboard/app.py index 00aa23d..d1e221f 100644 --- a/chromium-setup/emmc-provisioning/dashboard/app.py +++ b/chromium-setup/emmc-provisioning/dashboard/app.py @@ -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//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/", methods=["PATCH"]) def api_backup_update(name): """Update backup metadata (display name, description) or rename the file.""" diff --git a/chromium-setup/emmc-provisioning/dashboard/templates/index.html b/chromium-setup/emmc-provisioning/dashboard/templates/index.html index 3801639..141f371 100644 --- a/chromium-setup/emmc-provisioning/dashboard/templates/index.html +++ b/chromium-setup/emmc-provisioning/dashboard/templates/index.html @@ -382,6 +382,9 @@

Capture image or deploy

To capture (backup) an image from a device: connect it in USB boot mode or register over network. When the device appears below, click Backup to save its eMMC to a file. To write an image to a device, click Deploy (requires a golden image).

+