Add cloud-init support for automated KDE installation: include a new example user-data file for EMMC provisioning that installs KDE Plasma with touch options, sets it as the default session, and configures the on-screen keyboard. Update documentation to reflect these changes. Enhance eMMC provisioning scripts with new shrink functionality and improve error handling in backup processes.

This commit is contained in:
nearxos
2026-02-19 00:00:03 +02:00
parent 262bc3e515
commit d76e19169c
12 changed files with 336 additions and 61 deletions

View File

@@ -38,6 +38,8 @@ GOLDEN_IMAGE = Path(os.environ.get("CM4_GOLDEN_IMAGE", str(BASE_DIR / "golden.im
NETWORK_DEVICES_FILE = Path(os.environ.get("CM4_NETWORK_DEVICES_FILE", str(BASE_DIR / "network_devices.json")))
BUILD_STATUS_FILE = Path(os.environ.get("CM4_BUILD_STATUS_FILE", str(BASE_DIR / "build_cloudinit_status.json")))
BUILD_REQUEST_FILE = Path(os.environ.get("CM4_BUILD_REQUEST_FILE", str(BASE_DIR / "build_cloudinit_request.json")))
SHRINK_REQUEST_FILE = Path(os.environ.get("CM4_SHRINK_REQUEST_FILE", str(BASE_DIR / "shrink_request.json")))
SHRINK_STATUS_FILE = Path(os.environ.get("CM4_SHRINK_STATUS_FILE", str(BASE_DIR / "shrink_status.json")))
CLOUDINIT_TEMPLATES_FILE = Path(os.environ.get("CM4_CLOUDINIT_TEMPLATES_FILE", str(BASE_DIR / "cloudinit_templates.json")))
# Default cloud-init user-data for Raspberry Pi OS (NoCloud on boot partition)
@@ -349,9 +351,37 @@ def api_backup_set_as_golden(name):
return jsonify({"ok": False, "error": str(e)}), 500
def _request_host_shrink(name, action="shrink", format="xz"):
"""Write shrink request for host and poll shrink_status.json. Returns (ok, message_or_error)."""
req = {"name": name, "action": action}
if action == "compress":
req["format"] = "gz" if format == "gz" else "xz"
try:
SHRINK_REQUEST_FILE.write_text(json.dumps(req))
except OSError as e:
return False, str(e)
deadline = time.monotonic() + 2100 # 35 min
while time.monotonic() < deadline:
time.sleep(5)
if not SHRINK_STATUS_FILE.exists():
continue
try:
data = json.loads(SHRINK_STATUS_FILE.read_text())
except (OSError, ValueError):
continue
if data.get("name") != name:
continue
phase = data.get("phase")
if phase == "done":
return True, data.get("message") or f"Shrunk {name}"
if phase == "error":
return False, data.get("error") or "PiShrink failed"
return False, "Shrink timed out (run on host may still be in progress)"
@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."""
"""Request PiShrink on host for a raw .img backup (shrinks in place). Dashboard polls host status."""
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"):
@@ -359,33 +389,15 @@ def api_backup_shrink(name):
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
ok, msg = _request_host_shrink(name, action="shrink")
if ok:
return jsonify({"ok": True, "message": msg})
return jsonify({"ok": False, "error": msg}), (503 if "not installed" in msg else 504 if "timed out" in msg else 500)
@app.route("/api/backups/<path:name>/compress", methods=["POST"])
def api_backup_compress(name):
"""Run PiShrink with compression (shrink + gz or xz) on a raw .img backup. Produces .img.gz or .img.xz."""
"""Request PiShrink with compression on host. Produces .img.gz or .img.xz. Dashboard polls host status."""
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"):
@@ -399,30 +411,11 @@ def api_backup_compress(name):
fmt = "xz"
if fmt == "gzip":
fmt = "gz"
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"}), 503
opts = ["-n", "-Z", "-a"] if fmt == "xz" else ["-n", "-z", "-a"]
try:
proc = subprocess.run(
[pishrink] + opts + [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
ok, msg = _request_host_shrink(name, action="compress", format=fmt)
if ok:
ext = ".xz" if fmt == "xz" else ".gz"
return jsonify({"ok": True, "message": f"Compressed to {name}{ext}"})
except subprocess.TimeoutExpired:
return jsonify({"ok": False, "error": "PiShrink timed out"}), 504
except OSError as e:
return jsonify({"ok": False, "error": str(e)}), 500
return jsonify({"ok": True, "message": msg or f"Compressed to {name}{ext}"})
return jsonify({"ok": False, "error": msg}), (503 if "not installed" in msg else 504 if "timed out" in msg else 500)
@app.route("/api/backups/<path:name>", methods=["PATCH"])
@@ -504,20 +497,26 @@ def _raspios_latest_url(variant="lite"):
"""Resolve latest Raspberry Pi OS (arm64) .img.xz URL. variant=lite|full."""
slug = "raspios_lite_arm64" if variant == "lite" else "raspios_full_arm64"
base = f"https://downloads.raspberrypi.com/{slug}/images"
headers = {"User-Agent": "Mozilla/5.0 (compatible; CM4-Provisioning/1.0)"}
try:
with urllib.request.urlopen(base + "/", timeout=15) as r:
req = urllib.request.Request(base + "/", headers=headers)
with urllib.request.urlopen(req, timeout=20) as r:
html = r.read().decode("utf-8", errors="ignore")
folders = re.findall(rf"{re.escape(slug)}-(\d{{4}}-\d{{2}}-\d{{2}})/", html)
if not folders:
return None
latest = sorted(folders)[-1]
folder_url = f"{base}/{slug}-{latest}/"
with urllib.request.urlopen(folder_url, timeout=15) as r:
req2 = urllib.request.Request(folder_url, headers=headers)
with urllib.request.urlopen(req2, timeout=20) as r:
folder_html = r.read().decode("utf-8", errors="ignore")
m = re.search(r'href="([^"]+\.img\.xz)"', folder_html)
if not m:
return None
return folder_url + m.group(1)
href = m.group(1)
if href.startswith("http://") or href.startswith("https://"):
return href
return folder_url.rstrip("/") + "/" + href.lstrip("/")
except Exception:
return None