#!/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}" CLOUDINIT_IMAGES_DIR="${CLOUDINIT_IMAGES_DIR:-$PROV_DIR/cloudinit-images}" # Write built cloud-init images to CLOUDINIT_IMAGES_DIR (separate from backups) OUTPUT_DIR="${CLOUDINIT_IMAGES_DIR}" 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) 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="$OUTPUT_DIR/$OUT_NAME" write_status "downloading" "Downloading $(basename "$URL")…" "" "" XZ_FILE="$TEMP_DIR/image.img.xz" CURL_ERR="$TEMP_DIR/curl_err.txt" CURL_OPTS=(-f -L -o "$XZ_FILE" --connect-timeout 30 --max-time 7200) [[ "${CURL_INSECURE:-}" == "1" ]] && CURL_OPTS+=(-k) if ! curl "${CURL_OPTS[@]}" "$URL" 2>"$CURL_ERR"; then err_detail=$(head -c 200 "$CURL_ERR" | tr '\n' ' ' | sed 's/"/\\"/g') write_status "error" "" "" "Download failed: ${err_detail:-curl exited with error}" exit 1 fi write_status "decompressing" "Decompressing image…" "" "" # Check we have a real xz file (not HTML error page) if ! command -v xz >/dev/null 2>&1; then write_status "error" "" "" "Decompress failed: xz not installed. Install xz-utils (apt install xz-utils)" exit 1 fi if [[ ! -s "$XZ_FILE" ]]; then write_status "error" "" "" "Decompress failed: downloaded file is empty" exit 1 fi FILE_TYPE=$(file -b "$XZ_FILE" 2>/dev/null || true) if [[ "$FILE_TYPE" == *"HTML"* ]] || [[ "$FILE_TYPE" == *"text"* ]] && [[ "$FILE_TYPE" != *"XZ"* ]]; then write_status "error" "" "" "Decompress failed: download is not an image (got: ${FILE_TYPE:0:80})" exit 1 fi IMG_FILE="$TEMP_DIR/image.img" XZ_ERR="$TEMP_DIR/xz_err.txt" if ! xz -d -k -f "$XZ_FILE" 2>"$XZ_ERR"; then err_detail=$(head -c 300 "$XZ_ERR" 2>/dev/null | tr '\n' ' ' | sed 's/"/\\"/g') write_status "error" "" "" "Decompress failed: ${err_detail:-xz exited with error}" exit 1 fi [[ -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 cloud-init images…" "" "" mkdir -p "$OUTPUT_DIR" cp "$IMG_FILE" "$OUT_PATH" if [[ "$SET_AS_GOLDEN" == "1" ]]; then rm -f "$GOLDEN_IMAGE" ln -sf "$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