Add backup shrinking functionality to eMMC provisioning dashboard: implement API for shrinking raw .img backups using PiShrink, update UI to support shrink option after backup, and enhance documentation for backup image handling and storage options on Proxmox host.

This commit is contained in:
nearxos
2026-02-18 18:55:32 +02:00
parent f93d224e8b
commit 5ff46e67d8
9 changed files with 302 additions and 20 deletions

View File

@@ -0,0 +1,21 @@
#cloud-config
# Example user-data that downloads and runs a script from a file server.
# Use this so you can change first-boot behaviour without rebuilding the golden image.
# Copy to boot partition as 'user-data' and set BOOTSTRAP_URL to your script URL.
package_update: true
package_upgrade: false
packages:
- curl # or wget if you prefer
# Download script from file server and run it (runcmd runs after network is up)
runcmd:
- curl -fsSL "http://YOUR_FILE_SERVER/provisioning/bootstrap.sh" -o /tmp/bootstrap.sh
- chmod +x /tmp/bootstrap.sh
- /tmp/bootstrap.sh
# Optional: remove after run
# - rm -f /tmp/bootstrap.sh
# Optional: finish cloud-init
# - cloud-init single --name cc_final_message

View File

@@ -8,6 +8,7 @@ Supports USB boot mode (ask Backup/Deploy) and network-booted devices (register,
import json import json
import os import os
import shutil import shutil
import subprocess
import time import time
from pathlib import Path from pathlib import Path
@@ -114,7 +115,7 @@ def _safe_backup_name(name):
"""Reject path traversal and ensure it's a backup filename we manage.""" """Reject path traversal and ensure it's a backup filename we manage."""
if not name or ".." in name or "/" in name or "\\" in name: if not name or ".." in name or "/" in name or "\\" in name:
return False return False
if not (name.endswith(".img") or name.endswith(".img.gz")): if not name.endswith((".img", ".img.gz", ".img.xz")):
return False return False
return True return True
@@ -125,7 +126,7 @@ def list_backups():
meta = _load_backups_meta() meta = _load_backups_meta()
out = [] out = []
for p in sorted(BACKUPS_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True): for p in sorted(BACKUPS_DIR.iterdir(), key=lambda x: x.stat().st_mtime, reverse=True):
if p.is_file() and p.suffix in (".img", ".img.gz") and p.name != "backups_meta.json": if p.is_file() and p.name != "backups_meta.json" and p.name.endswith((".img", ".img.gz", ".img.xz")):
try: try:
st = p.stat() st = p.stat()
m = meta.get(p.name, {}) m = meta.get(p.name, {})
@@ -195,6 +196,12 @@ def api_device_action():
if source == "usb": if source == "usb":
try: try:
os.makedirs(os.path.dirname(ACTION_REQUEST_FILE) or ".", exist_ok=True) os.makedirs(os.path.dirname(ACTION_REQUEST_FILE) or ".", exist_ok=True)
# If user requested "shrink after backup", create flag so host runs PiShrink after dd
if action == "backup" and body.get("shrink"):
try:
(BASE_DIR / "shrink_next_backup").write_text("1")
except (PermissionError, OSError):
pass # host may still have SHRINK_BACKUP=1
with open(ACTION_REQUEST_FILE, "w") as f: with open(ACTION_REQUEST_FILE, "w") as f:
f.write(action) f.write(action)
return jsonify({"ok": True}) return jsonify({"ok": True})
@@ -314,6 +321,40 @@ def api_backup_set_as_golden(name):
return jsonify({"ok": False, "error": str(e)}), 500 return jsonify({"ok": False, "error": str(e)}), 500
@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."""
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"):
return jsonify({"ok": False, "error": "only raw .img files can be shrunk (not .img.gz / .img.xz)"}), 400
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
@app.route("/api/backups/<path:name>", methods=["PATCH"]) @app.route("/api/backups/<path:name>", methods=["PATCH"])
def api_backup_update(name): def api_backup_update(name):
"""Update backup metadata (display name, description) or rename the file.""" """Update backup metadata (display name, description) or rename the file."""

View File

@@ -382,6 +382,9 @@
<section class="section"> <section class="section">
<h2 class="section-title">Capture image or deploy</h2> <h2 class="section-title">Capture image or deploy</h2>
<p class="backup-deploy-hint">To <strong>capture (backup)</strong> an image from a device: connect it in USB boot mode or register over network. When the device appears below, click <strong>Backup</strong> to save its eMMC to a file. To write an image to a device, click <strong>Deploy</strong> (requires a golden image).</p> <p class="backup-deploy-hint">To <strong>capture (backup)</strong> an image from a device: connect it in USB boot mode or register over network. When the device appears below, click <strong>Backup</strong> to save its eMMC to a file. To write an image to a device, click <strong>Deploy</strong> (requires a golden image).</p>
<p id="shrinkOptionWrap" class="backup-deploy-hint" style="display:none; margin-top:0.5rem;">
<label><input type="checkbox" id="shrinkAfterBackup" /> Shrink after backup</label> <span class="backups-mono" style="font-size:0.8rem;">(reduces size; requires PiShrink on host)</span>
</p>
<div id="pendingDevices"></div> <div id="pendingDevices"></div>
<div id="noPendingPlaceholder" class="placeholder-actions" style="display:none;"> <div id="noPendingPlaceholder" class="placeholder-actions" style="display:none;">
<span>Connect a device to see:</span> <span>Connect a device to see:</span>
@@ -506,12 +509,16 @@
container.innerHTML = ''; container.innerHTML = '';
let hasAny = false; let hasAny = false;
const shrinkWrap = document.getElementById('shrinkOptionWrap');
if (usb) { if (usb) {
hasAny = true; hasAny = true;
if (shrinkWrap) shrinkWrap.style.display = 'block';
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'device-item'; el.className = 'device-item';
el.innerHTML = '<div class="device-info"><div class="device-type">USB boot</div><div class="device-desc">Device connected — choose Backup or Deploy</div></div><div class="device-actions"><button type="button" class="btn btn-outline" data-source="usb" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="usb" data-action="deploy">Deploy</button></div>'; el.innerHTML = '<div class="device-info"><div class="device-type">USB boot</div><div class="device-desc">Device connected — choose Backup or Deploy</div></div><div class="device-actions"><button type="button" class="btn btn-outline" data-source="usb" data-action="backup">Backup</button><button type="button" class="btn btn-primary" data-source="usb" data-action="deploy">Deploy</button></div>';
container.appendChild(el); container.appendChild(el);
} else {
if (shrinkWrap) shrinkWrap.style.display = 'none';
} }
(network || []).forEach(function(d) { (network || []).forEach(function(d) {
hasAny = true; hasAny = true;
@@ -532,6 +539,8 @@
const mac = btn.getAttribute('data-mac'); const mac = btn.getAttribute('data-mac');
const body = { source: source, action: action }; const body = { source: source, action: action };
if (mac) body.mac = mac; if (mac) body.mac = mac;
const shrinkCb = document.getElementById('shrinkAfterBackup');
if (action === 'backup' && shrinkCb && shrinkCb.checked) body.shrink = true;
fetch('/api/device-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }) fetch('/api/device-action', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
@@ -557,12 +566,15 @@
tr.dataset.name = b.name; tr.dataset.name = b.name;
const displayName = (b.display_name || b.name); const displayName = (b.display_name || b.name);
const desc = (b.description || ''); const desc = (b.description || '');
const isRawImg = b.name.endsWith('.img') && !b.name.endsWith('.img.gz') && !b.name.endsWith('.img.xz');
const shrinkBtn = isRawImg ? '<button type="button" class="btn btn-outline btn-sm shrink-btn" data-name="' + escapeHtml(b.name) + '" title="Shrink image (PiShrink)">Shrink</button> ' : '';
tr.innerHTML = tr.innerHTML =
'<td class="backup-name-edit" data-field="name" title="Click to rename">' + escapeHtml(displayName) + '</td>' + '<td class="backup-name-edit" data-field="name" title="Click to rename">' + escapeHtml(displayName) + '</td>' +
'<td class="backup-desc-edit" data-field="description" title="Click to add description">' + escapeHtml(desc) + '</td>' + '<td class="backup-desc-edit" data-field="description" title="Click to add description">' + escapeHtml(desc) + '</td>' +
'<td class="backups-mono">' + fmtSize(b.size) + '</td>' + '<td class="backups-mono">' + fmtSize(b.size) + '</td>' +
'<td class="backups-mono">' + fmtDate(b.mtime) + '</td>' + '<td class="backups-mono">' + fmtDate(b.mtime) + '</td>' +
'<td class="actions-cell">' + '<td class="actions-cell">' +
shrinkBtn +
'<button type="button" class="btn btn-primary btn-sm set-golden-btn" data-name="' + escapeHtml(b.name) + '">Set as golden</button> ' + '<button type="button" class="btn btn-primary btn-sm set-golden-btn" data-name="' + escapeHtml(b.name) + '">Set as golden</button> ' +
'<button type="button" class="btn btn-outline btn-sm rename-file-btn" data-name="' + escapeHtml(b.name) + '" title="Rename file">Rename file</button> ' + '<button type="button" class="btn btn-outline btn-sm rename-file-btn" data-name="' + escapeHtml(b.name) + '" title="Rename file">Rename file</button> ' +
'<a href="/api/backups/' + encodeURIComponent(b.name) + '" download class="btn btn-outline btn-sm download-link">Download</a>' + '<a href="/api/backups/' + encodeURIComponent(b.name) + '" download class="btn btn-outline btn-sm download-link">Download</a>' +
@@ -572,6 +584,7 @@
bindBackupEdits(); bindBackupEdits();
bindSetGolden(); bindSetGolden();
bindRenameFile(); bindRenameFile();
bindShrink();
} }
function bindRenameFile() { function bindRenameFile() {
@@ -648,6 +661,25 @@
}); });
} }
function bindShrink() {
document.querySelectorAll('.shrink-btn').forEach(function(btn) {
btn.onclick = function() {
const name = btn.getAttribute('data-name');
if (!confirm('Shrink this image with PiShrink? This reduces file size and may take a few minutes.\n\n' + name)) return;
btn.disabled = true;
btn.textContent = 'Shrinking…';
fetch('/api/backups/' + encodeURIComponent(name) + '/shrink', { method: 'POST' })
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.ok) { fetchBackups(); }
else alert(data.error || data.detail || 'Failed');
})
.catch(function() { alert('Request failed'); })
.finally(function() { btn.disabled = false; btn.textContent = 'Shrink'; });
};
});
}
function fetchGoldenInfo() { function fetchGoldenInfo() {
fetch('/api/golden-info').then(function(r) { return r.json(); }).then(function(d) { fetch('/api/golden-info').then(function(r) { return r.json(); }).then(function(d) {
const el = document.getElementById('goldenHint'); const el = document.getElementById('goldenHint');

View File

@@ -43,7 +43,7 @@ sudo cp rpiboot /opt/usbboot/
sudo cp /path/to/your/golden-reterminal.img /var/lib/cm4-provisioning/golden.img sudo cp /path/to/your/golden-reterminal.img /var/lib/cm4-provisioning/golden.img
``` ```
- Or use a different path and set `GOLDEN_IMAGE` when installing the script (see below). - Or use a different path and set `GOLDEN_IMAGE` when installing the script (see below). On Proxmox with LXC, you can store backup images on a host directory by setting `CM4_BACKUPS_HOST_PATH` at deploy so that folder is bind-mounted into the LXC — see **PROXMOX-LXC-DEPLOYMENT.md** § Store backup images on a host directory.
#### 3. Install the provisioning script and trigger #### 3. Install the provisioning script and trigger
@@ -59,6 +59,9 @@ sudo chmod +x /opt/cm4-provisioning/flash-emmc-on-connect.sh
echo 'GOLDEN_IMAGE=/var/lib/cm4-provisioning/golden.img' | sudo tee /opt/cm4-provisioning/env echo 'GOLDEN_IMAGE=/var/lib/cm4-provisioning/golden.img' | sudo tee /opt/cm4-provisioning/env
echo 'RPIBOOT_DIR=/opt/usbboot' | sudo tee -a /opt/cm4-provisioning/env echo 'RPIBOOT_DIR=/opt/usbboot' | sudo tee -a /opt/cm4-provisioning/env
echo 'EMMC_SIZE_BYTES=8589934592' | sudo tee -a /opt/cm4-provisioning/env # 8GB; use 17179869184 for 16GB echo 'EMMC_SIZE_BYTES=8589934592' | sudo tee -a /opt/cm4-provisioning/env # 8GB; use 17179869184 for 16GB
# Optional: shrink backups after dd (requires PiShrink; see "Shrinking backup and golden images" below)
# echo 'SHRINK_BACKUP=1' | sudo tee -a /opt/cm4-provisioning/env
# echo 'PISHRINK_COMPRESS=gz' | sudo tee -a /opt/cm4-provisioning/env # or xz
sudo cp cm4-flash-trigger.sh /usr/local/bin/ sudo cp cm4-flash-trigger.sh /usr/local/bin/
sudo chmod +x /usr/local/bin/cm4-flash-trigger.sh sudo chmod +x /usr/local/bin/cm4-flash-trigger.sh
@@ -116,6 +119,53 @@ Or keep it simple and edit the defaults inside `flash-emmc-on-connect.sh` (e.g.
Raspberry Pi OS (recent versions) supports **cloud-init** using the **NoCloud** datasource: it reads `user-data`, `meta-data`, and optionally `network-config` from the **boot** (FAT32) partition. Raspberry Pi OS (recent versions) supports **cloud-init** using the **NoCloud** datasource: it reads `user-data`, `meta-data`, and optionally `network-config` from the **boot** (FAT32) partition.
### Steps to prepare a cloud-init image for Raspberry Pi OS
1. **Obtain Raspberry Pi OS**
- Download from [raspberrypi.com/software](https://www.raspberrypi.com/software/) (desktop or Lite), or use **Raspberry Pi Imager** with "Edit settings" for locale/SSH. Recent Raspberry Pi OS has cloud-init built in.
2. **Flash the image**
- Flash to a spare SD card, or to a loop file for building without physical media: `cp raspios.img golden-work.img`
3. **Mount the boot partition**
- **From an image file**: `sudo losetup -fP golden-work.img` then e.g. `sudo mount /dev/loop0p1 /mnt/boot`
- **From SD**: mount the first (FAT32) partition at `/mnt/boot`. On a running Pi, boot is often `/boot/firmware`.
4. **Add NoCloud files on the boot partition** (root of FAT32, same level as `config.txt`):
- `user-data`, `meta-data`, `network-config`
- From this repo: `cp emmc-provisioning/cloud-init/{user-data,meta-data,network-config} /mnt/boot/`
5. **Customise** `user-data` and `network-config`. Use the **remote bootstrap script** pattern (below) to avoid rebuilding the image when you change first-boot commands.
6. **Unmount and create golden image**: `sudo umount /mnt/boot`, then copy the image to your `GOLDEN_IMAGE` path (e.g. `/var/lib/cm4-provisioning/golden.img`).
### Remote bootstrap script (no image rebuild for script changes)
You can keep the golden image **fixed** and have cloud-init **download a script from a file server** and run it on first boot. When you change the script on the server, the next device gets the new commands without rebuilding the image.
1. **Host the script** on an HTTP/HTTPS file server (e.g. nginx, or `python3 -m http.server`) at a URL the Pi can reach, e.g. `http://192.168.1.10/provisioning/bootstrap.sh`.
2. **In `user-data`, use `runcmd` to download and run it** (runcmd runs after packages and network are up):
```yaml
#cloud-config
package_update: true
package_upgrade: false
packages:
- curl # or wget
runcmd:
- curl -fsSL "http://YOUR_FILE_SERVER/provisioning/bootstrap.sh" -o /tmp/bootstrap.sh
- chmod +x /tmp/bootstrap.sh
- /tmp/bootstrap.sh
```
With **wget**: `wget -q -O /tmp/bootstrap.sh "http://.../bootstrap.sh"` then `chmod +x /tmp/bootstrap.sh` and `/tmp/bootstrap.sh`.
3. **Bootstrap script**: use a normal shell script (e.g. `#!/bin/bash` and `set -e`). It can install packages, configure kiosk, set hostname, register with a dashboard, etc.
4. **Notes**: Ensure `network-config` (or DHCP) gives the Pi an IP before runcmd. For HTTPS, add `ca-certificates` to `packages` if needed. You can use one script for all devices or have the script call your server with serial/MAC for device-specific config.
### Creating the golden image ### Creating the golden image
1. **Flash Raspberry Pi OS** (or your base image) to a spare SD card or a loop file. 1. **Flash Raspberry Pi OS** (or your base image) to a spare SD card or a loop file.
@@ -135,9 +185,47 @@ cp emmc-provisioning/cloud-init/meta-data /mnt/boot/
cp emmc-provisioning/cloud-init/network-config /mnt/boot/ cp emmc-provisioning/cloud-init/network-config /mnt/boot/
``` ```
4. **Customise** `user-data` and `network-config` (hostname, WiFi, packages, Chromium kiosk, etc.). 4. **Customise** `user-data` and `network-config` (hostname, WiFi, packages, Chromium kiosk, or a single runcmd that downloads and runs a remote script—see "Remote bootstrap script" above).
5. **Copy your kiosk/Chromium scripts** into the image rootfs if needed (e.g. under `/home/pi/` or `/opt/`) and reference them from `user-data` `runcmd` or a systemd unit. 5. **Copy your kiosk/Chromium scripts** into the image rootfs only if you are not using a remote script; otherwise the remote script can pull what it needs.
6. **Unmount**, then create a **golden image** from the SD or loop device (e.g. `dd` or `dd` of the whole block device). Use that as `golden.img` on the provisioning host. 6. **Unmount**, then create a **golden image** from the SD or loop device (e.g. `dd` or copy of the whole block device). Use that as `golden.img` on the provisioning host. Optionally shrink it with PiShrink (see below) to save space and speed up deploy.
### Shrinking backup and golden images (PiShrink)
Raw full-disk backups and golden images are the full size of the eMMC (e.g. 32GB). [PiShrink](https://github.com/Drewsif/PiShrink) shrinks the **last partition** (must be ext2/3/4) to its minimum size and truncates the image file; on first boot the rootfs can expand back to fill the device. This reduces backup/golden image size (often to a few GB) and improves transfer times.
**On the provisioning host:**
1. **Install PiShrink and dependencies** (run as root on the host, or via `ssh root@HOST 'bash -s' < scripts/install-pishrink-on-host.sh`):
```bash
# From this repo (on the host or via ssh)
bash chromium-setup/emmc-provisioning/scripts/install-pishrink-on-host.sh
```
This installs `parted`, `e2fsprogs`, `gzip`, `pigz`, `xz-utils` and downloads `pishrink.sh` to `/usr/local/bin/pishrink.sh`.
2. **Shrink backups automatically** after each Backup in the dashboard: add to `/opt/cm4-provisioning/env` and ensure the trigger sources it:
```bash
echo 'SHRINK_BACKUP=1' | sudo tee -a /opt/cm4-provisioning/env
# Optional: compress after shrinking (smaller file, but must decompress before using as golden image)
# echo 'PISHRINK_COMPRESS=gz' | sudo tee -a /opt/cm4-provisioning/env # or xz
```
With `SHRINK_BACKUP=1`, once a backup finishes, the script runs PiShrink on the `.img` file. Use `PISHRINK_COMPRESS=gz` or `xz` for maximum size reduction; the file becomes `.img.gz` or `.img.xz` and must be decompressed before deploy (e.g. `gunzip -c backup.img.gz > golden.img`).
3. **Shrink a golden image manually** (e.g. after building from Raspberry Pi OS):
```bash
sudo pishrink.sh -n /path/to/large.img /var/lib/cm4-provisioning/golden.img
```
`-n` disables update check. Omit the second argument to shrink in place. The shrunk image will expand the rootfs on first boot when deployed to eMMC.
**Notes:**
- PiShrink only shrinks the **last** partition; it must be ext2/3/4 (standard Raspberry Pi OS root is ext4).
- Compressed backups (`.img.gz` / `.img.xz`) are for archival; to use as golden image, decompress first (e.g. `gunzip -k backup.img.gz` then copy to `golden.img`).
### Cloud-init file locations on the Pi ### Cloud-init file locations on the Pi

View File

@@ -77,7 +77,19 @@ No root password is set by default. To log in as root over SSH:
- **Option B Manual:** - **Option B Manual:**
`ssh root@10.130.60.224` then `pct exec 201 -- bash` to get a shell in the container. Run `apt-get install -y openssh-server`, edit `/etc/ssh/sshd_config` to set `PermitRootLogin yes`, run `passwd` to set root password, add your key to `/root/.ssh/authorized_keys`, and restart `ssh`. `ssh root@10.130.60.224` then `pct exec 201 -- bash` to get a shell in the container. Run `apt-get install -y openssh-server`, edit `/etc/ssh/sshd_config` to set `PermitRootLogin yes`, run `passwd` to set root password, add your key to `/root/.ssh/authorized_keys`, and restart `ssh`.
### 3. Put the golden image on the host (or in the LXC) ### 3. (Optional) Store backup images on a host directory
To keep backup images on a specific host path (e.g. a large disk or NFS mount) instead of under `/var/lib/cm4-provisioning/backups`, deploy with **`CM4_BACKUPS_HOST_PATH`** set. That directory is created on the host, bind-mounted into the LXC at `/var/lib/cm4-provisioning/backups`, and the host flash script is configured to write backups there. The dashboard in the LXC then lists and serves those same files.
**Deploy with a host backup path:**
```bash
CM4_BACKUPS_HOST_PATH=/mnt/storage/cm4-backups ./chromium-setup/emmc-provisioning/scripts/deploy-to-proxmox.sh root@10.130.60.224
```
Create `/mnt/storage/cm4-backups` (or your path) on the host first if it doesnt exist; the deploy script will create it if possible. To add or change the backup mount on an already-deployed host, set `CM4_BACKUPS_HOST_PATH` and run the deploy script again, then on the host add `BACKUPS_DIR=<path>` to `/opt/cm4-provisioning/env` and add the bind mount (see deploy script for the `pct set 201 -mp1 ...` step).
### 4. Put the golden image on the host (or in the LXC)
The image must be at **`/var/lib/cm4-provisioning/golden.img`** on the **host**. Because that directory is bind-mounted into the LXC, you can use either: The image must be at **`/var/lib/cm4-provisioning/golden.img`** on the **host**. Because that directory is bind-mounted into the LXC, you can use either:
@@ -92,7 +104,7 @@ The image must be at **`/var/lib/cm4-provisioning/golden.img`** on the **host**.
# Copy to that path inside the container; it's the same as the host path. # Copy to that path inside the container; it's the same as the host path.
``` ```
### 4. Run the provisioning dashboard (optional) ### 5. Run the provisioning dashboard (optional)
The dashboard shows **connection steps** and **live deployment status** (idle / connecting / flashing / done / error) and a recent flash log. It reads the same `status.json` and `flash.log` that the hosts flash script writes (via the bind-mounted `/var/lib/cm4-provisioning`). The dashboard shows **connection steps** and **live deployment status** (idle / connecting / flashing / done / error) and a recent flash log. It reads the same `status.json` and `flash.log` that the hosts flash script writes (via the bind-mounted `/var/lib/cm4-provisioning`).
@@ -116,7 +128,7 @@ systemctl enable --now cm4-dashboard
Then open **http://&lt;LXC-201-IP&gt;:5000** (get the IP with `pct exec 201 -- hostname -I`). If the LXC is on a private network, set up port forwarding on the Proxmox host or use a reverse proxy so you can reach the dashboard from your browser. Then open **http://&lt;LXC-201-IP&gt;:5000** (get the IP with `pct exec 201 -- hostname -I`). If the LXC is on a private network, set up port forwarding on the Proxmox host or use a reverse proxy so you can reach the dashboard from your browser.
### 5. Optional: disable or enable auto-flash ### 6. Optional: disable or enable auto-flash
- **Disable:** - **Disable:**
`ssh root@10.130.60.224 "rm /etc/cm4-provisioning/enabled"` `ssh root@10.130.60.224 "rm /etc/cm4-provisioning/enabled"`
@@ -153,7 +165,7 @@ From the **Proxmox host** you can monitor:
| **Flash job** | `systemctl status cm4-flash-once` — whether the udev-triggered job is running/failed | | **Flash job** | `systemctl status cm4-flash-once` — whether the udev-triggered job is running/failed |
| **Journal** | `journalctl -u cm4-flash-once -f` or `journalctl -t cm4-flash -f` — systemd/log output | | **Journal** | `journalctl -u cm4-flash-once -f` or `journalctl -t cm4-flash -f` — systemd/log output |
| **Block devices** | `lsblk` — after rpiboot, the eMMC appears as a new disk (e.g. `/dev/sdb`) | | **Block devices** | `lsblk` — after rpiboot, the eMMC appears as a new disk (e.g. `/dev/sdb`) |
| **Backups** | `ls /var/lib/cm4-provisioning/backups/` — backup images created from the dashboard | | **Backups** | `ls /var/lib/cm4-provisioning/backups/` — backup images (on host; if you used `CM4_BACKUPS_HOST_PATH` they are under that path on the host, bind-mounted into the LXC). To shrink automatically, set `SHRINK_BACKUP=1` in `/opt/cm4-provisioning/env` — see **EMMC-PROVISIONING-GUIDE.md** § Shrinking backup and golden images. |
| **Config** | `cat /opt/cm4-provisioning/env` — GOLDEN_IMAGE, RPIBOOT_DIR, EMMC_SIZE_BYTES | | **Config** | `cat /opt/cm4-provisioning/env` — GOLDEN_IMAGE, RPIBOOT_DIR, EMMC_SIZE_BYTES |
**One-command snapshot:** **One-command snapshot:**
@@ -183,7 +195,9 @@ Or copy `scripts/monitor-from-host.sh` to the host and run `./monitor-from-host.
4. **Clear stuck error in portal** If the portal shows an old error (e.g. "Golden image not found" or "rpiboot failed"), click **Clear message** in the dashboard, or: `ssh root@10.130.60.224 "echo '{\"phase\":\"idle\",\"message\":\"Waiting for reTerminal in boot mode or network.\",\"progress\":null}' > /var/lib/cm4-provisioning/status.json"`. Then unplug/replug the device. 4. **Clear stuck error in portal** If the portal shows an old error (e.g. "Golden image not found" or "rpiboot failed"), click **Clear message** in the dashboard, or: `ssh root@10.130.60.224 "echo '{\"phase\":\"idle\",\"message\":\"Waiting for reTerminal in boot mode or network.\",\"progress\":null}' > /var/lib/cm4-provisioning/status.json"`. Then unplug/replug the device.
5. **Trigger now runs the flash script in the background** (not via systemd-run) so it can access the USB device; a 2s delay gives the device time to enumerate before rpiboot runs. 5. **Backup stops before finishing** If backup or shrink appears to stop partway (e.g. dashboard stuck on "Creating backup…" or "Shrinking…"), the service may have been killed by systemd. The `cm4-flash.service` unit uses `TimeoutStartSec=7200` (2 hours); if you deployed an older version with 15 minutes, redeploy so the host gets the updated unit, then on the host run `systemctl daemon-reload` so the next backup has enough time to complete.
6. **Trigger now runs the flash script in the background** (not via systemd-run) so it can access the USB device; a 2s delay gives the device time to enumerate before rpiboot runs.
--- ---
@@ -208,5 +222,5 @@ That script syncs the repo to the host and reinstalls scripts on both the host a
| Flash runs on | Proxmox **host** (udev + rpiboot + dd) | | Flash runs on | Proxmox **host** (udev + rpiboot + dd) |
| Build rpiboot on host | Run `scripts/install-usbboot-on-host.sh` on the host when it has internet | | Build rpiboot on host | Run `scripts/install-usbboot-on-host.sh` on the host when it has internet |
| Dashboard | Flask app in LXC at `http://<LXC-IP>:5000`; switch Flash/Backup mode, list and download backups; see **dashboard/README.md** and section 3 above | | Dashboard | Flask app in LXC at `http://<LXC-IP>:5000`; switch Flash/Backup mode, list and download backups; see **dashboard/README.md** and section 3 above |
| Backups | Saved under `/var/lib/cm4-provisioning/backups/`. When a device is detected (USB or network), choose **Backup** or **Deploy** in the dashboard. | | Backups | Saved under `/var/lib/cm4-provisioning/backups/` (optionally a host path bind-mounted into the LXC — set `CM4_BACKUPS_HOST_PATH` at deploy). When a device is detected, choose **Backup** or **Deploy** in the dashboard. |
| Network deploy/backup | Network-booted devices run **network-client/provisioning-client.sh** and register with the dashboard; they then appear under "Device detected (Network)" and you choose Backup or Deploy. See **network-client/README.md**. | | Network deploy/backup | Network-booted devices run **network-client/provisioning-client.sh** and register with the dashboard; they then appear under "Device detected (Network)" and you choose Backup or Deploy. See **network-client/README.md**. |

View File

@@ -13,8 +13,8 @@ ExecStart=/opt/cm4-provisioning/flash-emmc-on-connect.sh
User=root User=root
StandardOutput=journal StandardOutput=journal
StandardError=journal StandardError=journal
# Allow long run (rpiboot + device scan + wait for user choice) # Allow long run: rpiboot + wait for choice + dd 32GB (~2035 min) + optional PiShrink (~1530 min)
TimeoutStartSec=900 TimeoutStartSec=7200
[Install] [Install]
# Only started by udev trigger, not at boot # Only started by udev trigger, not at boot

View File

@@ -57,6 +57,10 @@ fi
# When a device is detected we ask the user (dashboard): Backup or Deploy? # When a device is detected we ask the user (dashboard): Backup or Deploy?
# These files are used to wait for the user's choice (written by dashboard, read by this script). # These files are used to wait for the user's choice (written by dashboard, read by this script).
BACKUPS_DIR="${BACKUPS_DIR:-/var/lib/cm4-provisioning/backups}" BACKUPS_DIR="${BACKUPS_DIR:-/var/lib/cm4-provisioning/backups}"
# Optional: shrink backup with PiShrink (requires pishrink + parted, e2fsprogs). SHRINK_BACKUP=1 to enable.
SHRINK_BACKUP="${SHRINK_BACKUP:-0}"
# If SHRINK_BACKUP=1, optionally compress: PISHRINK_COMPRESS=gz or xz (uses parallel when available). Uncompressed .img can be dd'd directly for deploy.
PISHRINK_COMPRESS="${PISHRINK_COMPRESS:-}"
ACTION_REQUEST_FILE="${ACTION_REQUEST_FILE:-/var/lib/cm4-provisioning/action_request}" ACTION_REQUEST_FILE="${ACTION_REQUEST_FILE:-/var/lib/cm4-provisioning/action_request}"
CURRENT_DEVICE_FILE="${CURRENT_DEVICE_FILE:-/var/lib/cm4-provisioning/current_device}" CURRENT_DEVICE_FILE="${CURRENT_DEVICE_FILE:-/var/lib/cm4-provisioning/current_device}"
DEVICE_SOURCE_FILE="${DEVICE_SOURCE_FILE:-/var/lib/cm4-provisioning/device_source}" DEVICE_SOURCE_FILE="${DEVICE_SOURCE_FILE:-/var/lib/cm4-provisioning/device_source}"
@@ -163,8 +167,44 @@ for (( i = 0; i < WAIT_TIMEOUT; i += 2 )); do
write_status "backup" "Creating backup…" "null" write_status "backup" "Creating backup…" "null"
log "Backing up $target_dev to $backup_path..." log "Backing up $target_dev to $backup_path..."
if dd if="$target_dev" of="$backup_path" bs=4M status=progress conv=fsync; then if dd if="$target_dev" of="$backup_path" bs=4M status=progress conv=fsync; then
log "Backup complete: $backup_path" final_name="$backup_name"
write_status "done" "Backup complete: $backup_name" "100" shrink_requested=false
[[ "$SHRINK_BACKUP" == "1" || "$SHRINK_BACKUP" == "true" ]] && shrink_requested=true
[[ -f "$(dirname "$STATUS_FILE")/shrink_next_backup" ]] && shrink_requested=true
rm -f "$(dirname "$STATUS_FILE")/shrink_next_backup" 2>/dev/null || true
if [[ "$shrink_requested" == "true" ]]; then
pishrink_cmd=""
for p in /usr/local/bin/pishrink.sh /usr/local/bin/pishrink; do
[[ -x "$p" ]] && pishrink_cmd="$p" && break
done
if [[ -n "$pishrink_cmd" ]]; then
write_status "backup" "Shrinking backup image…" "null"
log "Shrinking backup with PiShrink (timeout 30 min)..."
pishrink_opts="-n"
[[ "$PISHRINK_COMPRESS" == "gz" || "$PISHRINK_COMPRESS" == "gzip" ]] && pishrink_opts="$pishrink_opts -z -a"
[[ "$PISHRINK_COMPRESS" == "xz" ]] && pishrink_opts="$pishrink_opts -Z -a"
PISHRINK_TIMEOUT="${PISHRINK_TIMEOUT:-1800}"
if timeout "$PISHRINK_TIMEOUT" $pishrink_cmd $pishrink_opts "$backup_path" 2>&1; then
if [[ "$PISHRINK_COMPRESS" == "gz" || "$PISHRINK_COMPRESS" == "gzip" ]]; then
final_name="${backup_name}.gz"
elif [[ "$PISHRINK_COMPRESS" == "xz" ]]; then
final_name="${backup_name}.xz"
fi
log "Shrunk backup: $BACKUPS_DIR/$final_name"
else
rc=$?
if [[ "$rc" -eq 124 ]]; then
log "PiShrink timed out after ${PISHRINK_TIMEOUT}s; keeping full backup $backup_path"
else
log "PiShrink failed (exit $rc); keeping full backup $backup_path"
fi
fi
else
log "SHRINK_BACKUP=1 but pishrink not found; keeping full backup. Install with scripts/install-pishrink-on-host.sh"
fi
fi
log "Backup complete: $BACKUPS_DIR/$final_name"
write_status "done" "Backup complete: $final_name" "100"
else else
write_status "error" "Backup failed" "null" "dd failed" write_status "error" "Backup failed" "null" "dd failed"
fi fi

View File

@@ -2,11 +2,14 @@
# Deploy CM4 eMMC provisioning to a Proxmox host (creates LXC 201, installs scripts on host and in LXC). # Deploy CM4 eMMC provisioning to a Proxmox host (creates LXC 201, installs scripts on host and in LXC).
# Usage: ./deploy-to-proxmox.sh [proxmox_host] # Usage: ./deploy-to-proxmox.sh [proxmox_host]
# Example: ./deploy-to-proxmox.sh root@10.130.60.224 # Example: ./deploy-to-proxmox.sh root@10.130.60.224
# Optional: DEPLOY_ROOTFS_STORAGE=local-lvm (or local-zfs, etc.) — storage for LXC rootfs
# Optional: CM4_BACKUPS_HOST_PATH=/mnt/storage/cm4-backups — host dir for backup images; bind-mounted into LXC so images are stored on the host
# Requires: ssh key access to root@<host> # Requires: ssh key access to root@<host>
# Logging: set DEPLOY_LOG=1 to also write to deploy-YYYYMMDD-HHMMSS.log in the script dir. # Logging: set DEPLOY_LOG=1 to also write to deploy-YYYYMMDD-HHMMSS.log in the script dir.
set -e set -e
PROXMOX="${1:-root@10.130.60.224}" PROXMOX="${1:-root@10.130.60.224}"
ROOTFS_STORAGE="${DEPLOY_ROOTFS_STORAGE:-local-lvm}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(dirname "$SCRIPT_DIR")" REPO_DIR="$(dirname "$SCRIPT_DIR")"
LOG_FILE="" LOG_FILE=""
@@ -26,17 +29,28 @@ log "[2/4] Rsync repo to $PROXMOX ..."
rsync -a "$REPO_DIR/" "$PROXMOX:/tmp/emmc-provisioning-deploy/" --exclude='.git' --exclude='scripts/deploy-to-proxmox.sh' rsync -a "$REPO_DIR/" "$PROXMOX:/tmp/emmc-provisioning-deploy/" --exclude='.git' --exclude='scripts/deploy-to-proxmox.sh'
log "[3/4] Running remote install (host + LXC) ..." log "[3/4] Running remote install (host + LXC) ..."
ssh "$PROXMOX" bash -s << 'REMOTE' ssh "$PROXMOX" "ROOTFS_STORAGE='$ROOTFS_STORAGE' CM4_BACKUPS_HOST_PATH='${CM4_BACKUPS_HOST_PATH:-}'" bash -s << 'REMOTE'
set -e set -e
DEPLOY=/tmp/emmc-provisioning-deploy DEPLOY=/tmp/emmc-provisioning-deploy
ROOTFS_STORAGE="${ROOTFS_STORAGE:-local-lvm}"
BACKUPS_HOST_PATH="${CM4_BACKUPS_HOST_PATH:-}"
log() { echo "[$(date -Iseconds)] $*"; } log() { echo "[$(date -Iseconds)] $*"; }
# Ensure LXC 201 exists (create if not) # Ensure LXC 201 exists (create if not)
if ! pct status 201 &>/dev/null; then if ! pct status 201 &>/dev/null; then
log "Creating LXC 201 (cm4-provisioning)..." # Use Debian 12 template: prefer one in cache, else download latest
pct create 201 local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst \ VZTMPL_DIR=/var/lib/vz/template/cache
DEBIAN12_TMPL=$(ls "$VZTMPL_DIR"/debian-12-standard_*.tar.zst 2>/dev/null | head -1)
if [[ -z "$DEBIAN12_TMPL" ]]; then
log "Downloading Debian 12 LXC template..."
pveam download local debian-12-standard_12.12-1_amd64.tar.zst || pveam download local debian-12-standard_12.7-1_amd64.tar.zst
DEBIAN12_TMPL=$(ls "$VZTMPL_DIR"/debian-12-standard_*.tar.zst 2>/dev/null | head -1)
fi
TMPL_NAME=$(basename "$DEBIAN12_TMPL")
log "Creating LXC 201 (cm4-provisioning) (rootfs on ${ROOTFS_STORAGE}, template ${TMPL_NAME})..."
pct create 201 "local:vztmpl/${TMPL_NAME}" \
--hostname cm4-provisioning --memory 1024 --swap 0 --cores 1 \ --hostname cm4-provisioning --memory 1024 --swap 0 --cores 1 \
--rootfs local-zfs:8 --net0 name=eth0,bridge=vmbr0,ip=dhcp \ --rootfs "${ROOTFS_STORAGE}:8" --net0 name=eth0,bridge=vmbr0,ip=dhcp \
--unprivileged 0 --features nesting=1 -tag cm4-provisioning --unprivileged 0 --features nesting=1 -tag cm4-provisioning
mkdir -p /var/lib/cm4-provisioning mkdir -p /var/lib/cm4-provisioning
pct set 201 -mp0 /var/lib/cm4-provisioning,mp=/var/lib/cm4-provisioning pct set 201 -mp0 /var/lib/cm4-provisioning,mp=/var/lib/cm4-provisioning
@@ -45,6 +59,15 @@ else
log "LXC 201 already exists." log "LXC 201 already exists."
fi fi
# Optional: bind-mount a host directory for backup images (so they are stored on the host, not LXC rootfs)
if [[ -n "$BACKUPS_HOST_PATH" ]]; then
mkdir -p "$BACKUPS_HOST_PATH"
pct stop 201 2>/dev/null || true
pct set 201 -mp1 "$BACKUPS_HOST_PATH",mp=/var/lib/cm4-provisioning/backups
pct start 201 2>/dev/null || true
log "Backups mount: host $BACKUPS_HOST_PATH -> LXC /var/lib/cm4-provisioning/backups"
fi
# Host: install scripts and udev (from host/) # Host: install scripts and udev (from host/)
log "Host: installing scripts and udev ..." log "Host: installing scripts and udev ..."
mkdir -p /opt/cm4-provisioning /etc/cm4-provisioning mkdir -p /opt/cm4-provisioning /etc/cm4-provisioning
@@ -63,8 +86,10 @@ GOLDEN_IMAGE=/var/lib/cm4-provisioning/golden.img
RPIBOOT_DIR=/opt/usbboot RPIBOOT_DIR=/opt/usbboot
EMMC_SIZE_BYTES=8589934592 EMMC_SIZE_BYTES=8589934592
ENV ENV
[[ -n "$BACKUPS_HOST_PATH" ]] && echo "BACKUPS_DIR=$BACKUPS_HOST_PATH" >> /opt/cm4-provisioning/env
touch /etc/cm4-provisioning/enabled touch /etc/cm4-provisioning/enabled
mkdir -p /var/lib/cm4-provisioning/backups mkdir -p /var/lib/cm4-provisioning/backups
[[ -n "$BACKUPS_HOST_PATH" ]] && mkdir -p "$BACKUPS_HOST_PATH"
# Start LXC if stopped # Start LXC if stopped
log "Starting LXC 201 if stopped ..." log "Starting LXC 201 if stopped ..."
@@ -101,6 +126,7 @@ REMOTE
log "[4/4] Deploy finished." log "[4/4] Deploy finished."
echo "" echo ""
echo "Done. Put golden.img in /var/lib/cm4-provisioning/ on the host (or scp to LXC 201 at /var/lib/cm4-provisioning/)." echo "Done. Put golden.img in /var/lib/cm4-provisioning/ on the host (or scp to LXC 201 at /var/lib/cm4-provisioning/)."
[[ -n "${CM4_BACKUPS_HOST_PATH:-}" ]] && echo "Backup images are stored on the host at: $CM4_BACKUPS_HOST_PATH (bind-mounted into LXC at /var/lib/cm4-provisioning/backups)."
echo "When the host has internet, run on the host: bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh" echo "When the host has internet, run on the host: bash /tmp/emmc-provisioning-deploy/scripts/install-usbboot-on-host.sh"
echo "Dashboard: install flask in LXC 201 and enable cm4-dashboard.service (see docs/PROXMOX-LXC-DEPLOYMENT.md)." echo "Dashboard: install flask in LXC 201 and enable cm4-dashboard.service (see docs/PROXMOX-LXC-DEPLOYMENT.md)."
if [[ -n "$LOG_FILE" ]]; then if [[ -n "$LOG_FILE" ]]; then

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Run on the provisioning HOST (root) to install PiShrink and dependencies.
# Enables shrinking backups in flash-emmc-on-connect.sh when SHRINK_BACKUP=1.
# PiShrink: https://github.com/Drewsif/PiShrink
set -e
PISHRINK_URL="${PISHRINK_URL:-https://raw.githubusercontent.com/Drewsif/PiShrink/master/pishrink.sh}"
echo "Installing PiShrink dependencies..."
apt-get update
apt-get install -y wget parted gzip pigz xz-utils udev e2fsprogs
echo "Downloading PiShrink..."
wget -q -O /usr/local/bin/pishrink.sh "$PISHRINK_URL"
chmod +x /usr/local/bin/pishrink.sh
echo "PiShrink installed at /usr/local/bin/pishrink.sh"
echo "To shrink backups automatically, add to /opt/cm4-provisioning/env:"
echo " SHRINK_BACKUP=1"
echo " # optional: PISHRINK_COMPRESS=gz or PISHRINK_COMPRESS=xz"