diff --git a/chromium-setup/emmc-provisioning/dashboard/app.py b/chromium-setup/emmc-provisioning/dashboard/app.py index ab2c497..386f2fc 100644 --- a/chromium-setup/emmc-provisioning/dashboard/app.py +++ b/chromium-setup/emmc-provisioning/dashboard/app.py @@ -335,9 +335,44 @@ def api_backups(): return jsonify({"backups": list_backups(), "backups_dir": str(BACKUPS_DIR)}) +@app.route("/api/backups/upload", methods=["POST"]) +def api_backups_upload(): + """Upload an image file from the dashboard (multipart form).""" + if "file" not in request.files and "image" not in request.files: + return jsonify({"ok": False, "error": "no file in request (use field 'file' or 'image')"}), 400 + f = request.files.get("file") or request.files.get("image") + if not f or not f.filename: + return jsonify({"ok": False, "error": "no file selected"}), 400 + base = (f.filename.rsplit(".", 1)[0] if "." in f.filename else f.filename).strip() or "upload" + safe_base = re.sub(r"[^\w\-.]", "_", base)[:80] + if not safe_base: + safe_base = "upload" + ext = "" + if f.filename.lower().endswith(".img.xz"): + ext = ".img.xz" + elif f.filename.lower().endswith(".img.gz"): + ext = ".img.gz" + elif f.filename.lower().endswith(".img"): + ext = ".img" + else: + ext = ".img" + name = f"{safe_base}-{int(time.time())}{ext}" + if not _safe_backup_name(name): + name = f"upload-{int(time.time())}.img" + path = BACKUPS_DIR / name + try: + BACKUPS_DIR.mkdir(parents=True, exist_ok=True) + f.save(str(path)) + return jsonify({"ok": True, "name": name, "message": f"Uploaded {name}"}) + except (OSError, IOError) as e: + if path.exists(): + path.unlink(missing_ok=True) + return jsonify({"ok": False, "error": str(e)}), 500 + + @app.route("/api/backups//set-as-golden", methods=["POST"]) def api_backup_set_as_golden(name): - """Copy this backup to golden.img so it becomes the image used for Deploy.""" + """Use this backup as the golden image (symlink when in backups dir to avoid copying).""" if not _safe_backup_name(name): return jsonify({"ok": False, "error": "invalid backup name"}), 400 path = BACKUPS_DIR / name @@ -345,7 +380,17 @@ def api_backup_set_as_golden(name): return jsonify({"ok": False, "error": "backup not found"}), 404 try: BACKUPS_DIR.mkdir(parents=True, exist_ok=True) - shutil.copy2(path, GOLDEN_IMAGE) + GOLDEN_IMAGE.parent.mkdir(parents=True, exist_ok=True) + if GOLDEN_IMAGE.exists(): + GOLDEN_IMAGE.unlink() + # Symlink to the backup so we use the same file instead of duplicating + path_resolved = path.resolve() + try: + path_resolved.relative_to(BACKUPS_DIR.resolve()) + os.symlink(path_resolved, GOLDEN_IMAGE) + except ValueError: + # Backup not under BACKUPS_DIR (shouldn't happen for list); fall back to copy + shutil.copy2(path, GOLDEN_IMAGE) return jsonify({"ok": True, "message": f"Golden image set from {name}"}) except (OSError, IOError) as e: return jsonify({"ok": False, "error": str(e)}), 500 @@ -458,6 +503,30 @@ def api_backup_update(name): return jsonify({"ok": True, "name": name}) +@app.route("/api/backups/", methods=["DELETE"]) +def api_backup_delete(name): + """Delete a backup file. If it is the current golden image (symlink), the golden link is removed.""" + if not _safe_backup_name(name): + return jsonify({"ok": False, "error": "invalid backup name"}), 400 + path = BACKUPS_DIR / name + if not path.is_file(): + return jsonify({"ok": False, "error": "backup not found"}), 404 + try: + if GOLDEN_IMAGE.exists() and GOLDEN_IMAGE.is_symlink(): + try: + if (GOLDEN_IMAGE.resolve() == path.resolve()): + GOLDEN_IMAGE.unlink() + except OSError: + pass + path.unlink() + meta = _load_backups_meta() + meta.pop(name, None) + _save_backups_meta(meta) + return jsonify({"ok": True, "message": f"Deleted {name}"}) + except (OSError, IOError) as e: + return jsonify({"ok": False, "error": str(e)}), 500 + + @app.route("/api/backups/", methods=["GET"]) def api_backup_download(name): if not _safe_backup_name(name): diff --git a/chromium-setup/emmc-provisioning/dashboard/templates/index.html b/chromium-setup/emmc-provisioning/dashboard/templates/index.html index c3e0bfc..831c176 100644 --- a/chromium-setup/emmc-provisioning/dashboard/templates/index.html +++ b/chromium-setup/emmc-provisioning/dashboard/templates/index.html @@ -399,7 +399,11 @@
-

Saved backups

+

Saved backups + + + +

@@ -617,7 +621,8 @@ compressBtn + ' ' + ' ' + - 'Download' + + 'Download ' + + '' + ''; tbody.appendChild(tr); }); @@ -626,6 +631,25 @@ bindRenameFile(); bindShrink(); bindCompress(); + bindDeleteBackup(); + } + + function bindDeleteBackup() { + document.querySelectorAll('.delete-backup-btn').forEach(function(btn) { + btn.onclick = function() { + const name = btn.getAttribute('data-name'); + if (!confirm('Delete this backup? This cannot be undone.\n\n' + name)) return; + btn.disabled = true; + fetch('/api/backups/' + encodeURIComponent(name), { method: 'DELETE' }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.ok) { fetchBackups(); fetchGoldenInfo(); } + else alert(data.error || 'Failed'); + }) + .catch(function() { alert('Request failed'); }) + .finally(function() { btn.disabled = false; }); + }; + }); } function bindRenameFile() { @@ -901,6 +925,26 @@ }); var refreshBackupsBtn = document.getElementById('refreshBackupsBtn'); if (refreshBackupsBtn) refreshBackupsBtn.onclick = function() { fetchBackups(); fetchGoldenInfo(); }; + var uploadImageBtn = document.getElementById('uploadImageBtn'); + var uploadImageInput = document.getElementById('uploadImageInput'); + if (uploadImageBtn && uploadImageInput) { + uploadImageBtn.onclick = function() { uploadImageInput.click(); }; + uploadImageInput.onchange = function() { + var file = uploadImageInput.files && uploadImageInput.files[0]; + if (!file) return; + var fd = new FormData(); + fd.append('file', file); + uploadImageBtn.disabled = true; + fetch('/api/backups/upload', { method: 'POST', body: fd }) + .then(function(r) { return r.json(); }) + .then(function(d) { + if (d.ok) { fetchBackups(); fetchGoldenInfo(); alert('Uploaded: ' + (d.name || file.name)); } + else alert(d.error || 'Upload failed'); + }) + .catch(function() { alert('Upload failed'); }) + .finally(function() { uploadImageBtn.disabled = false; uploadImageInput.value = ''; }); + }; + } fetchStatus(); fetchLog(); diff --git a/chromium-setup/emmc-provisioning/host/build-cloudinit-image.sh b/chromium-setup/emmc-provisioning/host/build-cloudinit-image.sh index 26cf8da..806a815 100644 --- a/chromium-setup/emmc-provisioning/host/build-cloudinit-image.sh +++ b/chromium-setup/emmc-provisioning/host/build-cloudinit-image.sh @@ -87,7 +87,8 @@ mkdir -p "$BACKUPS_DIR" cp "$IMG_FILE" "$OUT_PATH" if [[ "$SET_AS_GOLDEN" == "1" ]]; then - cp "$OUT_PATH" "$GOLDEN_IMAGE" + 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" "" diff --git a/chromium-setup/emmc-provisioning/scripts/deploy-to-proxmox.sh b/chromium-setup/emmc-provisioning/scripts/deploy-to-proxmox.sh index b250d2f..f24b092 100755 --- a/chromium-setup/emmc-provisioning/scripts/deploy-to-proxmox.sh +++ b/chromium-setup/emmc-provisioning/scripts/deploy-to-proxmox.sh @@ -20,7 +20,7 @@ set -e PROXMOX="${1:-root@10.130.60.224}" -ROOTFS_STORAGE="${DEPLOY_ROOTFS_STORAGE:-local-lvm}" +# ROOTFS_STORAGE set later: from DEPLOY_ROOTFS_STORAGE (if in host list) or from interactive choice SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_DIR="$(dirname "$SCRIPT_DIR")" LOG_FILE="" @@ -33,10 +33,16 @@ fi log() { echo "[$(date -Iseconds)] $*"; } # Optional: gather SSH key and LXC root password for setup inside deploy +# Default LXC root password (base64); override with DEPLOY_LXC_ROOT_PASSWORD. Not secure if repo is shared. +DEFAULT_LXC_PWD_B64="c2gxcGIweDE=" DEPLOY_SSH_KEY_B64="" DEPLOY_LXC_PWD_B64="" if [[ -n "${DEPLOY_LXC_ROOT_PASSWORD:-}" ]]; then DEPLOY_LXC_PWD_B64=$(echo -n "$DEPLOY_LXC_ROOT_PASSWORD" | base64 -w 0 2>/dev/null || base64 2>/dev/null | tr -d '\n') + log "Will set LXC root password (from DEPLOY_LXC_ROOT_PASSWORD)." +else + DEPLOY_LXC_PWD_B64="$DEFAULT_LXC_PWD_B64" + log "Will set LXC root password (default)." fi KEY_FILE="${DEPLOY_LXC_SSH_KEY:-}" if [[ -z "$KEY_FILE" ]]; then @@ -48,7 +54,6 @@ if [[ -n "$KEY_FILE" && -f "$KEY_FILE" ]]; then DEPLOY_SSH_KEY_B64=$(base64 -w 0 < "$KEY_FILE" 2>/dev/null || base64 < "$KEY_FILE" 2>/dev/null | tr -d '\n') log "Will copy SSH key to LXC: $KEY_FILE" fi -[[ -n "$DEPLOY_LXC_ROOT_PASSWORD" ]] && log "Will set LXC root password (DEPLOY_LXC_ROOT_PASSWORD)." log "Deploying to $PROXMOX ..." log "[1/5] Cleaning remote staging dir ..." @@ -57,7 +62,8 @@ log "[2/5] Rsync repo to $PROXMOX ..." rsync -a "$REPO_DIR/" "$PROXMOX:/tmp/emmc-provisioning-deploy/" --exclude='.git' --exclude='scripts/deploy-to-proxmox.sh' --exclude='scripts/deploy-*.log' # --- Ask user to select LXC rootfs storage (from host; PBS excluded) --- -STORAGE_LIST=$(ssh "$PROXMOX" "pvesm status 2>/dev/null | awk 'NR>1 && \$2!=\"pbs\" && \$3~/active/ {print \$1}'" 2>/dev/null || true) +# Normalize: trim leading spaces so first column is storage name (pvesm can align columns) +STORAGE_LIST=$(ssh "$PROXMOX" "pvesm status 2>/dev/null | awk 'NR>1 { sub(/^[ \t]+/, \"\"); if (\$2!=\"pbs\" && \$3~/active/) print \$1 }'" 2>/dev/null || true) storages=() while IFS= read -r line; do [[ -n "$line" ]] && storages+=("$line"); done <<< "$STORAGE_LIST" if [[ ${#storages[@]} -eq 0 ]]; then @@ -109,8 +115,8 @@ BACKUPS_HOST_PATH="${CM4_BACKUPS_HOST_PATH:-}" LXC_HOSTNAME="cm4-provisioning" log() { echo "[$(date -Iseconds)] $*"; } -# Storage already chosen interactively; validate it exists on host -if ! pvesm status 2>/dev/null | awk -v s="$ROOTFS_STORAGE" 'NR>1 && $1==s && $2!="pbs" && $3~/active/ {exit(0)} END{exit(1)}'; then +# Storage already chosen interactively; validate it exists on host (match by name + active, robust to column alignment) +if ! pvesm status 2>/dev/null | grep -w "$ROOTFS_STORAGE" | grep -q active; then log "Error: storage $ROOTFS_STORAGE not found or not valid on host" exit 1 fi @@ -253,26 +259,34 @@ if [[ -n "${DEPLOY_SSH_KEY_B64:-}" ]] || [[ -n "${DEPLOY_LXC_PWD_B64:-}" ]]; the echo "$DEPLOY_SSH_KEY_B64" | base64 -d 2>/dev/null | pct exec "$CTID" -- bash -c "mkdir -p /root/.ssh; chmod 700 /root/.ssh; cat >> /root/.ssh/authorized_keys; chmod 600 /root/.ssh/authorized_keys" 2>/dev/null && log "LXC: SSH key added to /root/.ssh/authorized_keys." || true fi LXC_IP=$(pct exec "$CTID" -- hostname -I 2>/dev/null | awk '{print $1}') - [[ -n "$LXC_IP" ]] && echo "LXC SSH: ssh root@$LXC_IP" + [[ -n "$LXC_IP" ]] && log "LXC SSH: ssh root@$LXC_IP" fi +# Always capture LXC IP for final summary (write so local script can read it) +LXC_IP=$(pct exec "$CTID" -- hostname -I 2>/dev/null | awk '{print $1}') +echo "${LXC_IP:-}" > "$DEPLOY/lxc_ip.txt" -log "Deploy done (remote). LXC ID: $CTID" +log "Deploy done on remote. LXC ID: $CTID" REMOTE +# Read LXC IP written by remote (container hostname -I) +LXC_IP=$(ssh "$PROXMOX" "cat /tmp/emmc-provisioning-deploy/lxc_ip.txt 2>/dev/null" | tr -d '\n\r') + log "[5/5] Deploy finished." echo "" echo "=== Deploy complete ===" echo "Host and LXC are fully set up: usbboot (rpiboot), PiShrink, dashboard, systemd, udev." +[[ -n "$LXC_IP" ]] && echo " LXC IP: $LXC_IP" echo "" echo "--- Only remaining step (manual) ---" echo " Add a golden image for Deploy (writing image to device):" -echo " • Dashboard: open http://:5000 → Build cloud-init image → then Set as golden" +echo " • Dashboard: open http://${LXC_IP:-}:5000 → Build cloud-init image → then Set as golden" echo " • Or copy your image: scp your-image.img $PROXMOX:/var/lib/cm4-provisioning/golden.img" echo " Backup (read from device) works without golden.img." echo "" echo "--- You have ---" -echo " - Dashboard: http://:5000 (LXC IP: from Proxmox UI, or on host: pct exec -- hostname -I)" -[[ -n "${DEPLOY_LXC_ROOT_PASSWORD:-}" || -n "${DEPLOY_SSH_KEY_B64:-}" ]] && echo " - LXC SSH: ssh root@ (password and/or key were set)" +echo " - Dashboard: http://${LXC_IP:-}:5000" +[[ -n "${DEPLOY_LXC_ROOT_PASSWORD:-}" || -n "${DEPLOY_SSH_KEY_B64:-}" ]] && [[ -n "$LXC_IP" ]] && echo " - LXC SSH: ssh root@$LXC_IP (password and/or key were set)" +[[ -n "${DEPLOY_LXC_ROOT_PASSWORD:-}" || -n "${DEPLOY_SSH_KEY_B64:-}" ]] && [[ -z "$LXC_IP" ]] && echo " - LXC SSH: ssh root@ (password and/or key were set)" [[ -n "${CM4_BACKUPS_HOST_PATH:-}" ]] && echo " - Backups on host: $CM4_BACKUPS_HOST_PATH" if [[ -n "$LOG_FILE" ]]; then echo " - Log: $LOG_FILE"