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:
@@ -113,6 +113,16 @@ ps aux | grep -i plasma
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Cloud-Init (Automated Install)
|
||||||
|
|
||||||
|
For EMMC provisioning you can install KDE, set it as the default session, and enable touch options automatically using cloud-init. Use the example that includes KDE and touch:
|
||||||
|
|
||||||
|
- **File:** `emmc-provisioning/cloud-init/user-data-kiosk-username-ssh.example`
|
||||||
|
|
||||||
|
It installs `kde-plasma-desktop`, `maliit-keyboard`, and `xinput-calibrator`; sets LightDM default session to Plasma (X11); writes KDE config for touch-friendly scaling and touch-point feedback; and autostarts the on-screen keyboard. Chromium kiosk and SSH are configured in the same example. Replace the password hash and use it as your cloud-init `user-data` when building the image.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Reverting to LXDE
|
## Reverting to LXDE
|
||||||
|
|
||||||
### Method 1: Switch at Login Screen (Easiest)
|
### Method 1: Switch at Login Screen (Easiest)
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
#cloud-config
|
||||||
|
# Example: create user (pi) with password, enable SSH, install KDE Plasma with touch options,
|
||||||
|
# set KDE as default GUI, and deploy Chromium kiosk autostart.
|
||||||
|
# Uses start-chromium.sh and chromium-kiosk.desktop from this project (chromium-setup/).
|
||||||
|
#
|
||||||
|
# 1. Generate a password hash on a Linux host:
|
||||||
|
# mkpasswd -m sha-512 'YourPassword'
|
||||||
|
# or: openssl passwd -6 'YourPassword'
|
||||||
|
# Paste the full output (e.g. $6$...) into the passwd: line below.
|
||||||
|
# 2. To use a different username than "pi", replace every "pi" in this file.
|
||||||
|
# 3. To change the kiosk URL, edit the --app=... line in the start-chromium.sh content below.
|
||||||
|
|
||||||
|
package_update: true
|
||||||
|
package_upgrade: false
|
||||||
|
|
||||||
|
packages:
|
||||||
|
- chromium-browser
|
||||||
|
- wmctrl
|
||||||
|
- openssh-server
|
||||||
|
# KDE Plasma + touchscreen
|
||||||
|
- kde-plasma-desktop
|
||||||
|
- maliit-keyboard
|
||||||
|
- xinput-calibrator
|
||||||
|
|
||||||
|
# Create user and set password (use hash from mkpasswd -m sha-512 or openssl passwd -6)
|
||||||
|
users:
|
||||||
|
- name: pi
|
||||||
|
groups: [adm, sudo, video]
|
||||||
|
lock_passwd: false
|
||||||
|
passwd: "$6$7xWGhGc6d1lJx1dU$4E8r1mkzVj51bjEbfzdP8wPxso..C36LbXkqU/X4oBGq94aGFMSrZb0PVI8zs/Om1Jm97/D..Apy2HTdCn3FV1"
|
||||||
|
shell: /bin/bash
|
||||||
|
|
||||||
|
# Enable SSH (allow password auth so you can log in with the user above)
|
||||||
|
write_files:
|
||||||
|
- path: /etc/ssh/sshd_config.d/99-cloud-init.conf
|
||||||
|
content: |
|
||||||
|
PasswordAuthentication yes
|
||||||
|
PermitRootLogin no
|
||||||
|
- path: /home/pi/start-chromium.sh
|
||||||
|
content: |
|
||||||
|
#!/bin/bash
|
||||||
|
export GNOME_KEYRING_CONTROL=""
|
||||||
|
export DISPLAY=:0
|
||||||
|
export GDK_BACKEND=x11
|
||||||
|
unset WAYLAND_DISPLAY
|
||||||
|
for i in {1..60}; do
|
||||||
|
if xset q >/dev/null 2>&1 || [ -n "$DISPLAY" ]; then
|
||||||
|
if pgrep -x plasma_session >/dev/null 2>&1 || pgrep -x kwin_x11 >/dev/null 2>&1 || pgrep -x pcmanfm >/dev/null 2>&1 || pgrep -x lxsession >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
sleep 5
|
||||||
|
/usr/bin/chromium --start-fullscreen --noerrdialogs --disable-infobars --disable-session-crashed-bubble --disable-restore-session-state --no-first-run --password-store=basic --use-mock-keychain --ozone-platform=x11 --disable-features=UseChromeOSDirectVideoDecoder --app=http://127.0.0.1:8080 &
|
||||||
|
sleep 3
|
||||||
|
for i in {1..10}; do
|
||||||
|
WINDOW_ID=$(wmctrl -l 2>/dev/null | grep -i chromium | head -1 | awk '{print $1}')
|
||||||
|
if [ -n "$WINDOW_ID" ]; then
|
||||||
|
wmctrl -i -r "$WINDOW_ID" -b add,fullscreen 2>/dev/null
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
wait
|
||||||
|
owner: pi:pi
|
||||||
|
permissions: "0755"
|
||||||
|
- path: /home/pi/.config/autostart/chromium-kiosk.desktop
|
||||||
|
content: |
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Chromium Fullscreen
|
||||||
|
Exec=/home/pi/start-chromium.sh
|
||||||
|
Hidden=false
|
||||||
|
NoDisplay=false
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
|
owner: pi:pi
|
||||||
|
permissions: "0644"
|
||||||
|
# KDE Plasma: switch to KDE as default session (X11 for Chromium compatibility)
|
||||||
|
- path: /etc/lightdm/lightdm.conf.d/99-default-session.conf
|
||||||
|
content: |
|
||||||
|
[Seat:*]
|
||||||
|
user-session=plasmax11
|
||||||
|
permissions: "0644"
|
||||||
|
# KDE touch-friendly: UI scale and input (for pi after first login)
|
||||||
|
- path: /home/pi/.config/kdeglobals
|
||||||
|
content: |
|
||||||
|
[General]
|
||||||
|
ForceFontDPI=120
|
||||||
|
owner: pi:pi
|
||||||
|
permissions: "0644"
|
||||||
|
- path: /home/pi/.config/kwinrc
|
||||||
|
content: |
|
||||||
|
[Windows]
|
||||||
|
BorderlessMaximizedWindows=true
|
||||||
|
[Plugins]
|
||||||
|
touchpointsEnabled=true
|
||||||
|
owner: pi:pi
|
||||||
|
permissions: "0644"
|
||||||
|
# Start on-screen keyboard (maliit) with KDE for touch input
|
||||||
|
- path: /home/pi/.config/autostart/maliit-keyboard.desktop
|
||||||
|
content: |
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Maliit Keyboard
|
||||||
|
Exec=maliit-keyboard -r
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
|
owner: pi:pi
|
||||||
|
permissions: "0644"
|
||||||
|
|
||||||
|
runcmd:
|
||||||
|
- mkdir -p /home/pi/.config/autostart
|
||||||
|
- chown -R pi:pi /home/pi/.config
|
||||||
|
# Set KDE Plasma (X11) as default session so next boot uses KDE
|
||||||
|
- update-alternatives --set x-session-manager /usr/bin/startplasma-x11 2>/dev/null || true
|
||||||
|
- systemctl enable ssh
|
||||||
|
- systemctl start ssh
|
||||||
|
- cloud-init single --name cc_final_message
|
||||||
@@ -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")))
|
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_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")))
|
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")))
|
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 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
|
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"])
|
@app.route("/api/backups/<path:name>/shrink", methods=["POST"])
|
||||||
def api_backup_shrink(name):
|
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):
|
if not _safe_backup_name(name):
|
||||||
return jsonify({"ok": False, "error": "invalid backup name"}), 400
|
return jsonify({"ok": False, "error": "invalid backup name"}), 400
|
||||||
if not name.endswith(".img") or name.endswith(".img.gz") or name.endswith(".img.xz"):
|
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
|
path = BACKUPS_DIR / name
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return jsonify({"ok": False, "error": "backup not found"}), 404
|
return jsonify({"ok": False, "error": "backup not found"}), 404
|
||||||
pishrink = shutil.which("pishrink.sh") or "/usr/local/bin/pishrink.sh"
|
ok, msg = _request_host_shrink(name, action="shrink")
|
||||||
if not pishrink or not os.path.isfile(pishrink):
|
if ok:
|
||||||
return jsonify({"ok": False, "error": "PiShrink not installed. Install on the host/LXC (e.g. scripts/install-pishrink-on-host.sh)"}), 503
|
return jsonify({"ok": True, "message": msg})
|
||||||
try:
|
return jsonify({"ok": False, "error": msg}), (503 if "not installed" in msg else 504 if "timed out" in msg else 500)
|
||||||
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>/compress", methods=["POST"])
|
@app.route("/api/backups/<path:name>/compress", methods=["POST"])
|
||||||
def api_backup_compress(name):
|
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):
|
if not _safe_backup_name(name):
|
||||||
return jsonify({"ok": False, "error": "invalid backup name"}), 400
|
return jsonify({"ok": False, "error": "invalid backup name"}), 400
|
||||||
if not name.endswith(".img") or name.endswith(".img.gz") or name.endswith(".img.xz"):
|
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"
|
fmt = "xz"
|
||||||
if fmt == "gzip":
|
if fmt == "gzip":
|
||||||
fmt = "gz"
|
fmt = "gz"
|
||||||
pishrink = shutil.which("pishrink.sh") or "/usr/local/bin/pishrink.sh"
|
ok, msg = _request_host_shrink(name, action="compress", format=fmt)
|
||||||
if not pishrink or not os.path.isfile(pishrink):
|
if ok:
|
||||||
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
|
|
||||||
ext = ".xz" if fmt == "xz" else ".gz"
|
ext = ".xz" if fmt == "xz" else ".gz"
|
||||||
return jsonify({"ok": True, "message": f"Compressed to {name}{ext}"})
|
return jsonify({"ok": True, "message": msg or f"Compressed to {name}{ext}"})
|
||||||
except subprocess.TimeoutExpired:
|
return jsonify({"ok": False, "error": msg}), (503 if "not installed" in msg else 504 if "timed out" in msg else 500)
|
||||||
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"])
|
||||||
@@ -504,20 +497,26 @@ def _raspios_latest_url(variant="lite"):
|
|||||||
"""Resolve latest Raspberry Pi OS (arm64) .img.xz URL. variant=lite|full."""
|
"""Resolve latest Raspberry Pi OS (arm64) .img.xz URL. variant=lite|full."""
|
||||||
slug = "raspios_lite_arm64" if variant == "lite" else "raspios_full_arm64"
|
slug = "raspios_lite_arm64" if variant == "lite" else "raspios_full_arm64"
|
||||||
base = f"https://downloads.raspberrypi.com/{slug}/images"
|
base = f"https://downloads.raspberrypi.com/{slug}/images"
|
||||||
|
headers = {"User-Agent": "Mozilla/5.0 (compatible; CM4-Provisioning/1.0)"}
|
||||||
try:
|
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")
|
html = r.read().decode("utf-8", errors="ignore")
|
||||||
folders = re.findall(rf"{re.escape(slug)}-(\d{{4}}-\d{{2}}-\d{{2}})/", html)
|
folders = re.findall(rf"{re.escape(slug)}-(\d{{4}}-\d{{2}}-\d{{2}})/", html)
|
||||||
if not folders:
|
if not folders:
|
||||||
return None
|
return None
|
||||||
latest = sorted(folders)[-1]
|
latest = sorted(folders)[-1]
|
||||||
folder_url = f"{base}/{slug}-{latest}/"
|
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")
|
folder_html = r.read().decode("utf-8", errors="ignore")
|
||||||
m = re.search(r'href="([^"]+\.img\.xz)"', folder_html)
|
m = re.search(r'href="([^"]+\.img\.xz)"', folder_html)
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
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:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ Raw full-disk backups and golden images are the full size of the eMMC (e.g. 32
|
|||||||
|
|
||||||
- PiShrink only shrinks the **last** partition; it must be ext2/3/4 (standard Raspberry Pi OS root is ext4).
|
- 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`).
|
- 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`).
|
||||||
|
- **Dashboard "Shrink" / "Compress"** buttons run PiShrink **on the host** (not in the LXC). The dashboard writes a request file; the host runs `run-shrink-on-host.sh` when `cm4-shrink.path` sees it. Ensure PiShrink is installed on the host (see above) and that deploy has installed `run-shrink-on-host.sh` and enabled `cm4-shrink.path`.
|
||||||
|
|
||||||
### Cloud-init file locations on the Pi
|
### Cloud-init file locations on the Pi
|
||||||
|
|
||||||
|
|||||||
@@ -195,9 +195,13 @@ 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. **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.
|
5. **"PiShrink not installed" when clicking Shrink/Compress** – Shrink and Compress run **on the host**, not in the LXC. Install PiShrink on the host: `ssh root@HOST 'bash -s' < chromium-setup/emmc-provisioning/scripts/install-pishrink-on-host.sh`. Ensure deploy has been run so the host has `run-shrink-on-host.sh` and `cm4-shrink.path` enabled (`systemctl status cm4-shrink.path`).
|
||||||
|
|
||||||
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.
|
6. **"Download failed" when building cloud-init image** – The download runs on the **host** (not the LXC). Check: (1) Host can reach the internet: `ssh root@HOST 'curl -sI https://downloads.raspberrypi.com/'`. (2) Build status now shows curl’s error (e.g. "Could not resolve host", "Connection timed out"); check the dashboard error text. (3) If you use a proxy or custom CA, set in `/opt/cm4-provisioning/env`: `CURL_INSECURE=1` to skip SSL verify (only if you understand the risk), then rerun the build.
|
||||||
|
|
||||||
|
7. **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.
|
||||||
|
|
||||||
|
8. **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -53,8 +53,12 @@ OUT_PATH="$BACKUPS_DIR/$OUT_NAME"
|
|||||||
|
|
||||||
write_status "downloading" "Downloading $(basename "$URL")…" "" ""
|
write_status "downloading" "Downloading $(basename "$URL")…" "" ""
|
||||||
XZ_FILE="$TEMP_DIR/image.img.xz"
|
XZ_FILE="$TEMP_DIR/image.img.xz"
|
||||||
if ! curl -f -sS -L -o "$XZ_FILE" "$URL"; then
|
CURL_ERR="$TEMP_DIR/curl_err.txt"
|
||||||
write_status "error" "" "" "Download failed"
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
9
chromium-setup/emmc-provisioning/host/cm4-shrink.path
Normal file
9
chromium-setup/emmc-provisioning/host/cm4-shrink.path
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=CM4 shrink/compress backup (when request file appears)
|
||||||
|
After=local-fs.target
|
||||||
|
|
||||||
|
[Path]
|
||||||
|
PathExists=/var/lib/cm4-provisioning/shrink_request.json
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
12
chromium-setup/emmc-provisioning/host/cm4-shrink.service
Normal file
12
chromium-setup/emmc-provisioning/host/cm4-shrink.service
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=CM4 run PiShrink on requested backup image
|
||||||
|
After=local-fs.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
Environment=CM4_PROVISIONING_DIR=/var/lib/cm4-provisioning
|
||||||
|
ExecStart=/opt/cm4-provisioning/run-shrink-on-host.sh
|
||||||
|
User=root
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
TimeoutStartSec=3600
|
||||||
77
chromium-setup/emmc-provisioning/host/run-shrink-on-host.sh
Normal file
77
chromium-setup/emmc-provisioning/host/run-shrink-on-host.sh
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Run on the Proxmox host when shrink_request.json appears in the provisioning dir.
|
||||||
|
# Runs PiShrink on the requested backup image (shrink or shrink+compress).
|
||||||
|
# Requires PiShrink on host (scripts/install-pishrink-on-host.sh). Triggered by cm4-shrink.path.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
PROV_DIR="${CM4_PROVISIONING_DIR:-/var/lib/cm4-provisioning}"
|
||||||
|
REQUEST_FILE="$PROV_DIR/shrink_request.json"
|
||||||
|
STATUS_FILE="$PROV_DIR/shrink_status.json"
|
||||||
|
[[ -f /opt/cm4-provisioning/env ]] && source /opt/cm4-provisioning/env
|
||||||
|
BACKUPS_DIR="${BACKUPS_DIR:-$PROV_DIR/backups}"
|
||||||
|
PISHRINK="${PISHRINK_SCRIPT:-/usr/local/bin/pishrink.sh}"
|
||||||
|
SHRINK_TIMEOUT="${SHRINK_TIMEOUT:-2100}"
|
||||||
|
|
||||||
|
write_status() {
|
||||||
|
local phase="$1" name="$2" message="$3" error="$4"
|
||||||
|
printf '{"phase":"%s","name":"%s","message":"%s","error":%s,"updated":%s}\n' \
|
||||||
|
"$phase" "$name" "$message" \
|
||||||
|
"$([ -n "$error" ] && echo "\"${error//\"/\\\"}\"" || echo "null")" \
|
||||||
|
"$(date +%s)" > "$STATUS_FILE"
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -f "$REQUEST_FILE" ]] || { echo "No request file"; exit 0; }
|
||||||
|
|
||||||
|
# Parse request
|
||||||
|
NAME=$(python3 -c "import json; print(json.load(open('$REQUEST_FILE')).get('name',''))")
|
||||||
|
ACTION=$(python3 -c "import json; print(json.load(open('$REQUEST_FILE')).get('action','shrink'))")
|
||||||
|
FORMAT=$(python3 -c "import json; print(json.load(open('$REQUEST_FILE')).get('format','xz'))")
|
||||||
|
rm -f "$REQUEST_FILE"
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
if [[ -z "$NAME" ]]; then
|
||||||
|
write_status "error" "" "" "Missing name in request"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "$NAME" != "${NAME##*/}" ]] || [[ "$NAME" == *..* ]]; then
|
||||||
|
write_status "error" "$NAME" "" "Invalid name"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ ! "$NAME" =~ \.img$ ]]; then
|
||||||
|
write_status "error" "$NAME" "" "Only .img files can be shrunk"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
PATH_FILE="$BACKUPS_DIR/$NAME"
|
||||||
|
if [[ ! -f "$PATH_FILE" ]]; then
|
||||||
|
write_status "error" "$NAME" "" "File not found"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -x "$PISHRINK" ]]; then
|
||||||
|
write_status "error" "$NAME" "" "PiShrink not installed on host. Run scripts/install-pishrink-on-host.sh"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_status "running" "$NAME" "Shrinking…" ""
|
||||||
|
OPTS=(-n)
|
||||||
|
if [[ "$ACTION" == "compress" ]]; then
|
||||||
|
if [[ "$FORMAT" == "gz" ]] || [[ "$FORMAT" == "gzip" ]]; then
|
||||||
|
OPTS+=(-z -a)
|
||||||
|
else
|
||||||
|
OPTS+=(-Z -a)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$BACKUPS_DIR" || exit 1
|
||||||
|
if timeout "$SHRINK_TIMEOUT" "$PISHRINK" "${OPTS[@]}" "$NAME" 2>&1; then
|
||||||
|
if [[ "$ACTION" == "compress" ]]; then
|
||||||
|
ext=".xz"
|
||||||
|
[[ "$FORMAT" == "gz" ]] || [[ "$FORMAT" == "gzip" ]] && ext=".gz"
|
||||||
|
write_status "done" "$NAME" "Compressed to ${NAME}${ext}" ""
|
||||||
|
else
|
||||||
|
write_status "done" "$NAME" "Shrunk $NAME" ""
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
rc=$?
|
||||||
|
write_status "error" "$NAME" "" "PiShrink failed (exit $rc)"
|
||||||
|
fi
|
||||||
@@ -75,13 +75,18 @@ cp "$DEPLOY/host/flash-emmc-on-connect.sh" /opt/cm4-provisioning/
|
|||||||
chmod +x /opt/cm4-provisioning/flash-emmc-on-connect.sh
|
chmod +x /opt/cm4-provisioning/flash-emmc-on-connect.sh
|
||||||
cp "$DEPLOY/host/build-cloudinit-image.sh" /opt/cm4-provisioning/
|
cp "$DEPLOY/host/build-cloudinit-image.sh" /opt/cm4-provisioning/
|
||||||
chmod +x /opt/cm4-provisioning/build-cloudinit-image.sh
|
chmod +x /opt/cm4-provisioning/build-cloudinit-image.sh
|
||||||
|
cp "$DEPLOY/host/run-shrink-on-host.sh" /opt/cm4-provisioning/
|
||||||
|
chmod +x /opt/cm4-provisioning/run-shrink-on-host.sh
|
||||||
cp "$DEPLOY/host/cm4-flash-trigger.sh" /usr/local/bin/
|
cp "$DEPLOY/host/cm4-flash-trigger.sh" /usr/local/bin/
|
||||||
chmod +x /usr/local/bin/cm4-flash-trigger.sh
|
chmod +x /usr/local/bin/cm4-flash-trigger.sh
|
||||||
cp "$DEPLOY/host/cm4-flash.service" /etc/systemd/system/
|
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.path" /etc/systemd/system/
|
||||||
cp "$DEPLOY/host/cm4-build-cloudinit.service" /etc/systemd/system/
|
cp "$DEPLOY/host/cm4-build-cloudinit.service" /etc/systemd/system/
|
||||||
|
cp "$DEPLOY/host/cm4-shrink.path" /etc/systemd/system/
|
||||||
|
cp "$DEPLOY/host/cm4-shrink.service" /etc/systemd/system/
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now cm4-build-cloudinit.path 2>/dev/null || true
|
systemctl enable --now cm4-build-cloudinit.path 2>/dev/null || true
|
||||||
|
systemctl enable --now cm4-shrink.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/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/
|
cp "$DEPLOY/host/90-cm4-boot-mode.rules" /etc/udev/rules.d/
|
||||||
udevadm control --reload-rules
|
udevadm control --reload-rules
|
||||||
|
|||||||
@@ -2,19 +2,53 @@
|
|||||||
# Run on the provisioning HOST (root) to install PiShrink and dependencies.
|
# Run on the provisioning HOST (root) to install PiShrink and dependencies.
|
||||||
# Enables shrinking backups in flash-emmc-on-connect.sh when SHRINK_BACKUP=1.
|
# Enables shrinking backups in flash-emmc-on-connect.sh when SHRINK_BACKUP=1.
|
||||||
# PiShrink: https://github.com/Drewsif/PiShrink
|
# PiShrink: https://github.com/Drewsif/PiShrink
|
||||||
|
#
|
||||||
|
# Offline install (when host has no internet):
|
||||||
|
# 1. On a machine with internet: curl -Lo pishrink.sh https://raw.githubusercontent.com/Drewsif/PiShrink/master/pishrink.sh
|
||||||
|
# 2. scp pishrink.sh root@HOST:/tmp/
|
||||||
|
# 3. On host: bash install-pishrink-on-host.sh /tmp/pishrink.sh
|
||||||
|
#
|
||||||
|
# Optional: PISHRINK_URL=... or pass path to local pishrink.sh as first argument.
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
PISHRINK_URL="${PISHRINK_URL:-https://raw.githubusercontent.com/Drewsif/PiShrink/master/pishrink.sh}"
|
PISHRINK_URL="${PISHRINK_URL:-https://raw.githubusercontent.com/Drewsif/PiShrink/master/pishrink.sh}"
|
||||||
|
LOCAL_PISHRINK="$1"
|
||||||
|
|
||||||
echo "Installing PiShrink dependencies..."
|
echo "Installing PiShrink dependencies (parted, e2fsprogs, xz-utils, ...)..."
|
||||||
apt-get update
|
if apt-get update 2>/dev/null && apt-get install -y wget parted gzip pigz xz-utils udev e2fsprogs 2>/dev/null; then
|
||||||
apt-get install -y wget parted gzip pigz xz-utils udev e2fsprogs
|
echo "Dependencies installed."
|
||||||
|
else
|
||||||
|
echo "Warning: apt install failed (e.g. host has no internet). Skipping packages."
|
||||||
|
echo "PiShrink needs: parted, e2fsprogs; optional: gzip/pigz, xz-utils. Install them manually if missing."
|
||||||
|
for cmd in parted resize2fs; do
|
||||||
|
if ! command -v "$cmd" &>/dev/null; then
|
||||||
|
echo " Missing: $cmd — install with apt when the host has network, or use a local mirror."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
echo "Downloading PiShrink..."
|
if [[ -n "$LOCAL_PISHRINK" && -f "$LOCAL_PISHRINK" ]]; then
|
||||||
wget -q -O /usr/local/bin/pishrink.sh "$PISHRINK_URL"
|
echo "Using local PiShrink script: $LOCAL_PISHRINK"
|
||||||
|
cp "$LOCAL_PISHRINK" /usr/local/bin/pishrink.sh
|
||||||
chmod +x /usr/local/bin/pishrink.sh
|
chmod +x /usr/local/bin/pishrink.sh
|
||||||
|
|
||||||
echo "PiShrink installed at /usr/local/bin/pishrink.sh"
|
echo "PiShrink installed at /usr/local/bin/pishrink.sh"
|
||||||
|
elif command -v wget &>/dev/null && wget -q -O /usr/local/bin/pishrink.sh "$PISHRINK_URL" 2>/dev/null; then
|
||||||
|
chmod +x /usr/local/bin/pishrink.sh
|
||||||
|
echo "PiShrink installed at /usr/local/bin/pishrink.sh"
|
||||||
|
else
|
||||||
|
echo "Could not download PiShrink (no wget or no network)."
|
||||||
|
echo ""
|
||||||
|
echo "Offline install:"
|
||||||
|
echo " 1. On a machine WITH internet run:"
|
||||||
|
echo " curl -Lo pishrink.sh $PISHRINK_URL"
|
||||||
|
echo " 2. Copy to host:"
|
||||||
|
echo " scp pishrink.sh root@YOUR_HOST:/tmp/"
|
||||||
|
echo " 3. On the host run:"
|
||||||
|
echo " bash install-pishrink-on-host.sh /tmp/pishrink.sh"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
echo "To shrink backups automatically, add to /opt/cm4-provisioning/env:"
|
echo "To shrink backups automatically, add to /opt/cm4-provisioning/env:"
|
||||||
echo " SHRINK_BACKUP=1"
|
echo " SHRINK_BACKUP=1"
|
||||||
echo " # optional: PISHRINK_COMPRESS=gz or PISHRINK_COMPRESS=xz"
|
echo " # optional: PISHRINK_COMPRESS=gz or PISHRINK_COMPRESS=xz"
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ for i in {1..10}; do
|
|||||||
sleep 0.5
|
sleep 0.5
|
||||||
done
|
done
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Keep script running
|
# Keep script running
|
||||||
wait
|
wait
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user