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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user