From 262bc3e515eacc445eaf2f911fcf7a8979b15f35 Mon Sep 17 00:00:00 2001 From: nearxos Date: Wed, 18 Feb 2026 23:06:36 +0200 Subject: [PATCH] Enhance eMMC provisioning dashboard: implement cloud-init template management with options to load, save, and delete templates. Update UI to support selecting Raspberry Pi OS variant for image building and improve user instructions for cloud-init image creation. Add new API endpoints for managing cloud-init templates and fetching the latest Raspberry Pi OS URLs. --- .../emmc-provisioning/dashboard/app.py | 232 +++++++++--------- .../dashboard/templates/index.html | 111 ++++++++- .../host/build-cloudinit-image.sh | 90 +++++++ .../host/cm4-build-cloudinit.path | 9 + .../host/cm4-build-cloudinit.service | 14 ++ .../scripts/deploy-to-proxmox.sh | 5 + 6 files changed, 332 insertions(+), 129 deletions(-) create mode 100644 chromium-setup/emmc-provisioning/host/build-cloudinit-image.sh create mode 100644 chromium-setup/emmc-provisioning/host/cm4-build-cloudinit.path create mode 100644 chromium-setup/emmc-provisioning/host/cm4-build-cloudinit.service diff --git a/chromium-setup/emmc-provisioning/dashboard/app.py b/chromium-setup/emmc-provisioning/dashboard/app.py index e5de91f..7ac74b4 100644 --- a/chromium-setup/emmc-provisioning/dashboard/app.py +++ b/chromium-setup/emmc-provisioning/dashboard/app.py @@ -10,8 +10,6 @@ import os import re import shutil import subprocess -import tempfile -import threading import time import urllib.request from pathlib import Path @@ -39,6 +37,8 @@ BACKUPS_DIR = Path(os.environ.get("CM4_BACKUPS_DIR", str(BASE_DIR / "backups"))) GOLDEN_IMAGE = Path(os.environ.get("CM4_GOLDEN_IMAGE", str(BASE_DIR / "golden.img"))) 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"))) +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) DEFAULT_USER_DATA = """#cloud-config @@ -500,21 +500,20 @@ def _build_status_write(phase, message, output_name=None, error=None): pass -def _raspios_latest_lite_url(): - """Resolve latest Raspberry Pi OS Lite (arm64) .img.xz URL from official index.""" - base = "https://downloads.raspberrypi.com/raspios_lite_arm64/images" +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" try: with urllib.request.urlopen(base + "/", timeout=15) as r: html = r.read().decode("utf-8", errors="ignore") - # Match raspios_lite_arm64-YYYY-MM-DD/ - folders = re.findall(r"raspios_lite_arm64-(\d{4}-\d{2}-\d{2})/", html) + 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}/raspios_lite_arm64-{latest}/" + folder_url = f"{base}/{slug}-{latest}/" with urllib.request.urlopen(folder_url, timeout=15) as r: folder_html = r.read().decode("utf-8", errors="ignore") - # Match .img.xz link (skip .sha256, .torrent, etc.) m = re.search(r'href="([^"]+\.img\.xz)"', folder_html) if not m: return None @@ -523,96 +522,24 @@ def _raspios_latest_lite_url(): return None -def _build_cloudinit_worker(variant, user_data, meta_data, network_config): - """Background: download Raspios, inject cloud-init, save to BACKUPS_DIR.""" - out_name = f"raspios-{variant}-cloudinit-{time.strftime('%Y%m%d-%H%M%S')}.img" - out_path = BACKUPS_DIR / out_name - mount_point = None - loop_dev = None - temp_dir = None - img_path = None - xz_path = None +def _load_cloudinit_templates(): try: - _build_status_write("resolving", "Resolving latest Raspberry Pi OS image URL…") - url = _raspios_latest_lite_url() - if not url: - _build_status_write("error", "", output_name=None, error="Could not resolve latest Raspios Lite URL") - return - _build_status_write("downloading", f"Downloading {url.split('/')[-1]}… (this may take several minutes)") - temp_dir = Path(tempfile.mkdtemp(prefix="cloudinit-build-", dir=str(BACKUPS_DIR))) - xz_path = temp_dir / "image.img.xz" - with urllib.request.urlopen(url, timeout=3600) as resp: - total = int(resp.headers.get("Content-Length", 0)) or 0 - chunk_size = 1024 * 1024 - with open(xz_path, "wb") as f: - done = 0 - while True: - chunk = resp.read(chunk_size) - if not chunk: - break - f.write(chunk) - done += len(chunk) - if total and done % (50 * 1024 * 1024) < chunk_size: - _build_status_write("downloading", f"Downloaded {done // (1024*1024)} MB…") - _build_status_write("decompressing", "Decompressing image…") - img_path = temp_dir / "image.img" - subprocess.run(["xz", "-d", "-k", "-f", str(xz_path)], check=True, capture_output=True, timeout=1800, cwd=str(temp_dir)) - if not img_path.exists(): - _build_status_write("error", "", output_name=None, error="Decompress failed: image.img not found") - return - _build_status_write("injecting", "Mounting boot partition and injecting cloud-init…") - loop_out = subprocess.run(["losetup", "-f", "--show", "-P", str(img_path)], capture_output=True, text=True, timeout=10) - if loop_out.returncode != 0: - _build_status_write("error", "", output_name=None, error="losetup failed (loop device may not be available in this container)") - return - loop_dev = loop_out.stdout.strip() - # First partition is usually boot (FAT32) - boot_part = f"{loop_dev}p1" - if not Path(boot_part).exists(): - boot_part = f"{loop_dev}p2" - if not Path(boot_part).exists(): - _build_status_write("error", "", output_name=None, error="Boot partition not found") - return - mount_point = temp_dir / "mnt" - mount_point.mkdir(exist_ok=True) - subprocess.run(["mount", boot_part, str(mount_point)], check=True, capture_output=True, timeout=10) - try: - (mount_point / "user-data").write_text(user_data or DEFAULT_USER_DATA) - (mount_point / "meta-data").write_text(meta_data or DEFAULT_META_DATA) - (mount_point / "network-config").write_text(network_config or DEFAULT_NETWORK_CONFIG) - finally: - subprocess.run(["umount", str(mount_point)], capture_output=True, timeout=10) - subprocess.run(["losetup", "-d", loop_dev], capture_output=True, timeout=10) - loop_dev = None - _build_status_write("finalizing", "Copying image to backups…") - shutil.copy2(str(img_path), str(out_path)) - _build_status_write("done", f"Built {out_name}", output_name=out_name) - except subprocess.TimeoutExpired as e: - _build_status_write("error", "", output_name=None, error=f"Timeout: {e}") - except subprocess.CalledProcessError as e: - _build_status_write("error", "", output_name=None, error=f"Command failed: {e.stderr or e}") - except Exception as e: - _build_status_write("error", "", output_name=None, error=str(e)) - finally: - if loop_dev: - try: - subprocess.run(["losetup", "-d", loop_dev], capture_output=True, timeout=5) - except Exception: - pass - if mount_point and mount_point.exists(): - try: - subprocess.run(["umount", str(mount_point)], capture_output=True, timeout=5) - except Exception: - pass - if temp_dir and temp_dir.exists(): - try: - shutil.rmtree(temp_dir, ignore_errors=True) - except Exception: - pass + if CLOUDINIT_TEMPLATES_FILE.is_file(): + with open(CLOUDINIT_TEMPLATES_FILE, "r") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + pass + return {"templates": []} -_build_lock = threading.Lock() -_build_thread = None +def _save_cloudinit_templates(data): + try: + CLOUDINIT_TEMPLATES_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(CLOUDINIT_TEMPLATES_FILE, "w") as f: + json.dump(data, f, indent=2) + return True + except OSError: + return False @app.route("/api/build-cloudinit-status") @@ -623,34 +550,103 @@ def api_build_cloudinit_status(): @app.route("/api/build-cloudinit", methods=["POST"]) def api_build_cloudinit(): - """Start building a cloud-init ready Raspberry Pi OS image (download latest Lite, inject NoCloud). Runs in background.""" - global _build_thread - with _build_lock: - st = _build_status_read() - if st.get("phase") in ("downloading", "decompressing", "injecting", "finalizing", "resolving"): - return jsonify({"ok": False, "error": "A build is already in progress"}), 409 - _build_thread = threading.Thread( - target=_build_cloudinit_worker, - kwargs={ - "variant": "lite", - "user_data": (request.get_json(silent=True) or {}).get("user_data") or DEFAULT_USER_DATA, - "meta_data": (request.get_json(silent=True) or {}).get("meta_data") or DEFAULT_META_DATA, - "network_config": (request.get_json(silent=True) or {}).get("network_config") or DEFAULT_NETWORK_CONFIG, - }, - daemon=True, - ) - _build_thread.start() - _build_status_write("resolving", "Starting…") - return jsonify({"ok": True, "message": "Build started. Download and inject may take 15–30 min. Poll /api/build-cloudinit-status or refresh the page."}), 202 + """Start building a cloud-init image: write request file; host runs build (has loop devices).""" + st = _build_status_read() + if st.get("phase") in ("downloading", "decompressing", "injecting", "finalizing", "resolving"): + return jsonify({"ok": False, "error": "A build is already in progress"}), 409 + body = request.get_json(silent=True) or {} + variant = (body.get("variant") or "lite").strip().lower() + if variant not in ("lite", "full"): + variant = "lite" + _build_status_write("resolving", "Resolving latest Raspberry Pi OS image URL…") + url = _raspios_latest_url(variant) + if not url: + _build_status_write("idle", "", error="Could not resolve latest Raspios URL") + return jsonify({"ok": False, "error": "Could not resolve latest image URL"}), 503 + user_data = body.get("user_data") or DEFAULT_USER_DATA + meta_data = body.get("meta_data") or DEFAULT_META_DATA + network_config = body.get("network_config") or DEFAULT_NETWORK_CONFIG + set_as_golden_after = bool(body.get("set_as_golden_after")) + try: + BUILD_REQUEST_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(BUILD_REQUEST_FILE, "w") as f: + json.dump({ + "url": url, + "variant": variant, + "user_data": user_data, + "meta_data": meta_data, + "network_config": network_config, + "set_as_golden_after": set_as_golden_after, + }, f, indent=2) + except OSError as e: + return jsonify({"ok": False, "error": str(e)}), 500 + _build_status_write("resolving", "Build requested; host will run download and inject (check status).") + return jsonify({"ok": True, "message": "Build started on host. Download and inject may take 15–45 min. Poll status or refresh the page."}), 202 @app.route("/api/raspios-latest-url") def api_raspios_latest_url(): - """Return the URL of the latest Raspberry Pi OS Lite arm64 image (for display only).""" - url = _raspios_latest_lite_url() + """Return the URL of the latest Raspberry Pi OS (arm64) image. Query: variant=lite|full.""" + variant = (request.args.get("variant") or "lite").strip().lower() + if variant not in ("lite", "full"): + variant = "lite" + url = _raspios_latest_url(variant) if not url: return jsonify({"ok": False, "url": None, "error": "Could not resolve latest image URL"}), 503 - return jsonify({"ok": True, "url": url, "filename": url.split("/")[-1]}) + return jsonify({"ok": True, "url": url, "filename": url.split("/")[-1], "variant": variant}) + + +@app.route("/api/cloudinit-templates", methods=["GET"]) +def api_cloudinit_templates_list(): + """List saved cloud-init templates.""" + data = _load_cloudinit_templates() + return jsonify({"templates": data.get("templates", [])}) + + +@app.route("/api/cloudinit-templates", methods=["POST"]) +def api_cloudinit_templates_create(): + """Save a new cloud-init template.""" + body = request.get_json(force=True, silent=True) or {} + name = (body.get("name") or "").strip() + if not name: + return jsonify({"ok": False, "error": "name required"}), 400 + data = _load_cloudinit_templates() + templates = data.get("templates", []) + tid = str(int(time.time() * 1000)) + templates.append({ + "id": tid, + "name": name, + "user_data": body.get("user_data", ""), + "meta_data": body.get("meta_data", ""), + "network_config": body.get("network_config", ""), + }) + data["templates"] = templates + if not _save_cloudinit_templates(data): + return jsonify({"ok": False, "error": "Failed to save"}), 500 + return jsonify({"ok": True, "id": tid, "name": name}) + + +@app.route("/api/cloudinit-templates/") +def api_cloudinit_templates_get(tid): + """Get one template by id.""" + data = _load_cloudinit_templates() + for t in data.get("templates", []): + if t.get("id") == tid: + return jsonify(t) + return jsonify({"error": "not found"}), 404 + + +@app.route("/api/cloudinit-templates/", methods=["DELETE"]) +def api_cloudinit_templates_delete(tid): + """Delete a template.""" + data = _load_cloudinit_templates() + templates = [t for t in data.get("templates", []) if t.get("id") != tid] + if len(templates) == len(data.get("templates", [])): + return jsonify({"ok": False, "error": "not found"}), 404 + data["templates"] = templates + if not _save_cloudinit_templates(data): + return jsonify({"ok": False, "error": "Failed to save"}), 500 + return jsonify({"ok": True}) @app.route("/api/golden-info") diff --git a/chromium-setup/emmc-provisioning/dashboard/templates/index.html b/chromium-setup/emmc-provisioning/dashboard/templates/index.html index 2aada0d..19892a2 100644 --- a/chromium-setup/emmc-provisioning/dashboard/templates/index.html +++ b/chromium-setup/emmc-provisioning/dashboard/templates/index.html @@ -419,15 +419,30 @@

Build cloud-init image

-

Download the latest Raspberry Pi OS Lite (arm64) from the official repository and inject cloud-init NoCloud files so the image is ready for first-boot configuration.

-

+

Download the latest Raspberry Pi OS (arm64) from the official repository and inject cloud-init NoCloud files. The built image appears in Saved backups; you can then choose it and click Set as golden for deployment.

+
+ + + +
+
+ + (use for Deploy without clicking manually) +
- +
- Customize cloud-init (optional) + Cloud-init templates & customize
+

Templates: + +

+
    @@ -745,12 +760,74 @@ fetch('/api/backups').then(function(r) { return r.json(); }).then(function(d) { renderBackups(d.backups || []); }).catch(function() {}); } + function getBuildVariant() { + var sel = document.getElementById('buildVariant'); + return (sel && sel.value) ? sel.value : 'lite'; + } function fetchRaspiosUrl() { - fetch('/api/raspios-latest-url').then(function(r) { return r.json(); }).then(function(d) { + var variant = getBuildVariant(); + fetch('/api/raspios-latest-url?variant=' + encodeURIComponent(variant)).then(function(r) { return r.json(); }).then(function(d) { var el = document.getElementById('buildRaspiosUrl'); if (el) el.textContent = d.ok && d.filename ? ('Latest: ' + d.filename) : (d.error || 'Could not resolve latest image URL'); }).catch(function() {}); } + function fetchCloudInitTemplates() { + fetch('/api/cloudinit-templates').then(function(r) { return r.json(); }).then(function(d) { + var list = d.templates || []; + var sel = document.getElementById('buildTemplateSelect'); + var listEl = document.getElementById('buildTemplateList'); + if (sel) { + sel.innerHTML = ''; + list.forEach(function(t) { + var opt = document.createElement('option'); + opt.value = t.id; + opt.textContent = t.name; + sel.appendChild(opt); + }); + } + if (listEl) { + listEl.innerHTML = list.map(function(t) { + return '
  • ' + escapeHtml(t.name) + '
  • '; + }).join('') || '
  • No templates saved.
  • '; + listEl.querySelectorAll('.template-load-btn').forEach(function(btn) { + btn.onclick = function() { loadTemplate(btn.getAttribute('data-id')); }; + }); + listEl.querySelectorAll('.template-del-btn').forEach(function(btn) { + btn.onclick = function() { + if (!confirm('Delete this template?')) return; + fetch('/api/cloudinit-templates/' + encodeURIComponent(btn.getAttribute('data-id')), { method: 'DELETE' }) + .then(function(r) { return r.json(); }).then(function(data) { if (data.ok) fetchCloudInitTemplates(); }); + }; + }); + } + }).catch(function() {}); + } + function loadTemplate(id) { + fetch('/api/cloudinit-templates/' + encodeURIComponent(id)).then(function(r) { return r.json(); }).then(function(t) { + var ud = document.getElementById('buildUserData'); + var md = document.getElementById('buildMetaData'); + var nc = document.getElementById('buildNetworkConfig'); + if (ud) ud.value = t.user_data || ''; + if (md) md.value = t.meta_data || ''; + if (nc) nc.value = t.network_config || ''; + }).catch(function() {}); + } + function saveTemplate() { + var name = prompt('Template name'); + if (!name || !name.trim()) return; + var ud = document.getElementById('buildUserData'); + var md = document.getElementById('buildMetaData'); + var nc = document.getElementById('buildNetworkConfig'); + fetch('/api/cloudinit-templates', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + name: name.trim(), + user_data: ud ? ud.value : '', + meta_data: md ? md.value : '', + network_config: nc ? nc.value : '' + })}).then(function(r) { return r.json(); }).then(function(d) { + if (d.ok) { fetchCloudInitTemplates(); alert('Saved as "' + name + '"'); } + else alert(d.error || 'Failed'); + }).catch(function() { alert('Failed'); }); + } function fetchBuildStatus() { fetch('/api/build-cloudinit-status').then(function(r) { return r.json(); }).then(function(d) { @@ -762,8 +839,9 @@ if (d.phase === 'idle' && !d.message) { el.textContent = ''; } else if (d.phase === 'done') { - el.textContent = 'Done: ' + (d.output_name || '') + ' — refresh the backups list below.'; + el.textContent = 'Done: ' + (d.output_name || '') + ' — see Saved backups below. Set as golden to use for Deploy.'; fetchBackups(); + fetchGoldenInfo(); } else if (d.phase === 'error') { el.textContent = 'Error: ' + (d.error || d.message || 'Unknown'); } else { @@ -776,18 +854,22 @@ function startBuildCloudInit() { var btn = document.getElementById('buildCloudInitBtn'); if (btn) btn.disabled = true; - var body = {}; var ud = document.getElementById('buildUserData'); var md = document.getElementById('buildMetaData'); var nc = document.getElementById('buildNetworkConfig'); - if (ud && ud.value.trim()) body.user_data = ud.value.trim(); - if (md && md.value.trim()) body.meta_data = md.value.trim(); - if (nc && nc.value.trim()) body.network_config = nc.value.trim(); + var setGolden = document.getElementById('buildSetGolden'); + var body = { + variant: getBuildVariant(), + set_as_golden_after: setGolden && setGolden.checked, + user_data: (ud && ud.value.trim()) ? ud.value.trim() : undefined, + meta_data: (md && md.value.trim()) ? md.value.trim() : undefined, + network_config: (nc && nc.value.trim()) ? nc.value.trim() : undefined + }; fetch('/api/build-cloudinit', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) .then(function(r) { return r.json(); }) .then(function(data) { if (data.ok) { - document.getElementById('buildCloudInitStatus').textContent = 'Build started. Waiting…'; + document.getElementById('buildCloudInitStatus').textContent = 'Build started on host. Waiting…'; setTimeout(fetchBuildStatus, 2000); } else { alert(data.error || 'Failed to start build'); @@ -820,8 +902,15 @@ fetchGoldenInfo(); fetchRaspiosUrl(); fetchBuildStatus(); + fetchCloudInitTemplates(); var buildBtn = document.getElementById('buildCloudInitBtn'); if (buildBtn) buildBtn.onclick = startBuildCloudInit; + var variantSel = document.getElementById('buildVariant'); + if (variantSel) variantSel.onchange = fetchRaspiosUrl; + var templateLoadBtn = document.getElementById('buildTemplateLoad'); + if (templateLoadBtn) templateLoadBtn.onclick = function() { var s = document.getElementById('buildTemplateSelect'); if (s && s.value) loadTemplate(s.value); }; + var templateSaveBtn = document.getElementById('buildTemplateSave'); + if (templateSaveBtn) templateSaveBtn.onclick = saveTemplate; setInterval(fetchStatus, 2000); setInterval(fetchLog, 4000); setInterval(fetchPending, 2000); diff --git a/chromium-setup/emmc-provisioning/host/build-cloudinit-image.sh b/chromium-setup/emmc-provisioning/host/build-cloudinit-image.sh new file mode 100644 index 0000000..ddccf8c --- /dev/null +++ b/chromium-setup/emmc-provisioning/host/build-cloudinit-image.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Run on the Proxmox host when build_cloudinit_request.json appears in the provisioning dir. +# Downloads Raspberry Pi OS image from URL, injects cloud-init NoCloud files, saves to BACKUPS_DIR. +# Uses loop devices and mount (available on host, not in unprivileged LXC). +# Triggered by systemd path unit cm4-build-cloudinit.path. + +set -e +PROV_DIR="${CM4_PROVISIONING_DIR:-/var/lib/cm4-provisioning}" +REQUEST_FILE="$PROV_DIR/build_cloudinit_request.json" +STATUS_FILE="$PROV_DIR/build_cloudinit_status.json" +[[ -f /opt/cm4-provisioning/env ]] && source /opt/cm4-provisioning/env +BACKUPS_DIR="${BACKUPS_DIR:-$PROV_DIR/backups}" +GOLDEN_IMAGE="${GOLDEN_IMAGE:-$PROV_DIR/golden.img}" + +write_status() { + local phase="$1" message="$2" output_name="$3" error="$4" + printf '{"phase":"%s","message":"%s","output_name":%s,"error":%s,"updated":%s}\n' \ + "$phase" "$message" \ + "$([ -n "$output_name" ] && echo "\"$output_name\"" || echo "null")" \ + "$([ -n "$error" ] && echo "\"${error//\"/\\\"}\"" || echo "null")" \ + "$(date +%s)" > "$STATUS_FILE" +} + +[[ -f "$REQUEST_FILE" ]] || { echo "No request file"; exit 0; } + +TEMP_DIR=$(mktemp -d -p "$BACKUPS_DIR" cloudinit-build.XXXXXX) +trap 'rm -rf "$TEMP_DIR"; rm -f "$REQUEST_FILE"' EXIT + +# Extract fields from JSON into temp files (handles multi-line content) +python3 - "$REQUEST_FILE" "$TEMP_DIR" << 'PY' +import json, sys +with open(sys.argv[1]) as f: + d = json.load(f) +out = sys.argv[2] +for name, key in [("user-data", "user_data"), ("meta-data", "meta_data"), ("network-config", "network_config")]: + with open(f"{out}/{name}", "w") as f: + f.write(d.get(key, "")) +with open(f"{out}/variant", "w") as f: + f.write(d.get("variant", "lite")) +with open(f"{out}/url", "w") as f: + f.write(d.get("url", "")) +with open(f"{out}/set_golden", "w") as f: + f.write("1" if d.get("set_as_golden_after") else "0") +PY + +URL=$(cat "$TEMP_DIR/url") +VARIANT=$(cat "$TEMP_DIR/variant") +SET_AS_GOLDEN=$(cat "$TEMP_DIR/set_golden") +[[ -n "$URL" ]] || { write_status "error" "" "" "Missing url in request"; exit 1; } + +OUT_NAME="raspios-${VARIANT}-cloudinit-$(date +%Y%m%d-%H%M%S).img" +OUT_PATH="$BACKUPS_DIR/$OUT_NAME" + +write_status "downloading" "Downloading $(basename "$URL")…" "" "" +XZ_FILE="$TEMP_DIR/image.img.xz" +if ! curl -f -sS -L -o "$XZ_FILE" "$URL"; then + write_status "error" "" "" "Download failed" + exit 1 +fi + +write_status "decompressing" "Decompressing image…" "" "" +IMG_FILE="$TEMP_DIR/image.img" +xz -d -k -f "$XZ_FILE" || { write_status "error" "" "" "Decompress failed"; exit 1; } +[[ -f "$IMG_FILE" ]] || { write_status "error" "" "" "image.img not found after decompress"; exit 1; } + +write_status "injecting" "Mounting boot partition and injecting cloud-init…" "" "" +LOOP=$(losetup -f --show -P "$IMG_FILE") +boot_part="${LOOP}p1" +[[ -b "$boot_part" ]] || boot_part="${LOOP}p2" +[[ -b "$boot_part" ]] || { write_status "error" "" "" "Boot partition not found"; losetup -d "$LOOP"; exit 1; } + +MNT="$TEMP_DIR/mnt" +mkdir -p "$MNT" +mount "$boot_part" "$MNT" +cp "$TEMP_DIR/user-data" "$MNT/user-data" +cp "$TEMP_DIR/meta-data" "$MNT/meta-data" +cp "$TEMP_DIR/network-config" "$MNT/network-config" +umount "$MNT" +losetup -d "$LOOP" + +write_status "finalizing" "Copying image to backups…" "" "" +mkdir -p "$BACKUPS_DIR" +cp "$IMG_FILE" "$OUT_PATH" + +if [[ "$SET_AS_GOLDEN" == "1" ]]; then + cp "$OUT_PATH" "$GOLDEN_IMAGE" + write_status "done" "Built $OUT_NAME and set as golden image." "$OUT_NAME" "" +else + write_status "done" "Built $OUT_NAME" "$OUT_NAME" "" +fi diff --git a/chromium-setup/emmc-provisioning/host/cm4-build-cloudinit.path b/chromium-setup/emmc-provisioning/host/cm4-build-cloudinit.path new file mode 100644 index 0000000..a249c24 --- /dev/null +++ b/chromium-setup/emmc-provisioning/host/cm4-build-cloudinit.path @@ -0,0 +1,9 @@ +[Unit] +Description=CM4 build cloud-init image (when request file appears) +After=local-fs.target + +[Path] +PathExists=/var/lib/cm4-provisioning/build_cloudinit_request.json + +[Install] +WantedBy=multi-user.target diff --git a/chromium-setup/emmc-provisioning/host/cm4-build-cloudinit.service b/chromium-setup/emmc-provisioning/host/cm4-build-cloudinit.service new file mode 100644 index 0000000..4a4dbfa --- /dev/null +++ b/chromium-setup/emmc-provisioning/host/cm4-build-cloudinit.service @@ -0,0 +1,14 @@ +[Unit] +Description=CM4 build cloud-init image (download Raspios, inject NoCloud) +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +Environment=CM4_PROVISIONING_DIR=/var/lib/cm4-provisioning +ExecStart=/opt/cm4-provisioning/build-cloudinit-image.sh +# Run as root; script uses losetup, mount +User=root +StandardOutput=journal +StandardError=journal +TimeoutStartSec=7200 diff --git a/chromium-setup/emmc-provisioning/scripts/deploy-to-proxmox.sh b/chromium-setup/emmc-provisioning/scripts/deploy-to-proxmox.sh index 926a375..26847bd 100755 --- a/chromium-setup/emmc-provisioning/scripts/deploy-to-proxmox.sh +++ b/chromium-setup/emmc-provisioning/scripts/deploy-to-proxmox.sh @@ -73,10 +73,15 @@ log "Host: installing scripts and udev ..." mkdir -p /opt/cm4-provisioning /etc/cm4-provisioning cp "$DEPLOY/host/flash-emmc-on-connect.sh" /opt/cm4-provisioning/ chmod +x /opt/cm4-provisioning/flash-emmc-on-connect.sh +cp "$DEPLOY/host/build-cloudinit-image.sh" /opt/cm4-provisioning/ +chmod +x /opt/cm4-provisioning/build-cloudinit-image.sh cp "$DEPLOY/host/cm4-flash-trigger.sh" /usr/local/bin/ chmod +x /usr/local/bin/cm4-flash-trigger.sh cp "$DEPLOY/host/cm4-flash.service" /etc/systemd/system/ +cp "$DEPLOY/host/cm4-build-cloudinit.path" /etc/systemd/system/ +cp "$DEPLOY/host/cm4-build-cloudinit.service" /etc/systemd/system/ systemctl daemon-reload +systemctl enable --now cm4-build-cloudinit.path 2>/dev/null || true cp "$DEPLOY/host/89-cm4-boot-mode-permissions.rules" /etc/udev/rules.d/ 2>/dev/null || true cp "$DEPLOY/host/90-cm4-boot-mode.rules" /etc/udev/rules.d/ udevadm control --reload-rules