diff --git a/GNUmakefile b/GNUmakefile index 0d3b5c4..ea13dbe 100644 --- a/GNUmakefile +++ b/GNUmakefile @@ -429,6 +429,12 @@ ifneq ($(ARCH),aarch64) endif ./scripts/mk-rpi4-sd.sh kernel/bin-$(ARCH)/kernel ramdisk.tar $@ $(ROOTFS) $(PROGRAMS_BIN)-rpi +# Graphical imager: write montauk-rpi4.img to an SD card, or update the +# installation on one in place (scripts/montauk-imager.py, docs/raspberry-pi.md). +.PHONY: rpi4-imager +rpi4-imager: + ./scripts/montauk-imager.py + $(IMAGE_NAME).hdd: limine/limine kernel ramdisk rm -f $(IMAGE_NAME).hdd dd if=/dev/zero bs=1M count=0 seek=64 of=$(IMAGE_NAME).hdd diff --git a/docs/raspberry-pi.md b/docs/raspberry-pi.md index 958fad3..e0496fd 100644 --- a/docs/raspberry-pi.md +++ b/docs/raspberry-pi.md @@ -12,6 +12,50 @@ make ARCH=aarch64 rpi4-sd # -> montauk-rpi4.img sudo dd if=montauk-rpi4.img of=/dev/sdX bs=4M conv=fsync ``` +Or use the imager, which also updates a card in place: + +```bash +make rpi4-imager # = ./scripts/montauk-imager.py +``` + +### The imager + +`scripts/montauk-imager.py` is a Tk window (`python3-tk`) that lists the SD +cards and USB drives attached (never the host's own disks) and what is on +them, and either writes a fresh image or updates the MontaukOS on a card. It +runs as the user and does the writing by starting itself again through +`pkexec` (it asks for the password each time). The same operations work from +a terminal: `list`, and as root `flash`, `update` and `expand` +(`--help` for the options). Images can be raw or `.gz`, `.xz` or `.zip` +compressed; **Build** runs `make ARCH=aarch64 rpi4-sd`. + +- **Write a fresh image** erases the card, writes the image, clears the last + MiB of the card (a backup GPT from an earlier OS) and reads the card back to + verify it. *Expand* grows the root to fill the card right away instead of + leaving it to `firstboot.elf`. +- **Update** leaves the partition table alone and finds both partitions by + label, so a root that has grown to fill the card (or was resized by hand) + stays as it is. On the ESP every file is written except `RPI_EFI.fd`, which + holds the firmware's own settings (*Update the UEFI firmware* replaces it); + a changed `config.txt` is kept as `config.txt.bak`. On the root, `os/`, + `apps/`, `sdk/`, `boot/` get the image's new and changed files; `config/` + and `www/` only get files the card lacks, `users/` and `state/` are not + touched. `init.toml` becomes the image's, keeping each service's `enabled` + from the card and appending services only the card has; `capabilities.toml` + becomes the image's plus grants only the card has (old copies: `.old`). + `os/firstboot.elf` is only written if the card still has it, so a grown + card does not grow again. *Remove system files the new image no longer + ships* prunes `os/`, `apps/`, `sdk/` and `boot/` (keeping `os/btkeys.bin`, + the Bluetooth bonds). Unchanged files are skipped and each file is + replaced by a rename; the volume is checked with `e2fsck` before and after, + and its features against what `Fs/Ext2.cpp` mounts. +- **Expand** (a host-side first boot) grows the root partition to the end of + the card, at most 1 TiB, and the volume with `resize2fs`; `firstboot.elf` + then finds nothing to do and deletes itself. + +While it works, a runtime udev rule keeps the desktop from automounting the +card's partitions (Linux writes to an ext2 volume as soon as it mounts it). + `scripts/mk-rpi4-sd.sh` downloads the firmware release once into `rpi4-firmware/` (`PFTF_VERSION=v1.53` by default) and builds an MBR image: diff --git a/scripts/__pycache__/montauk-imager.cpython-314.pyc b/scripts/__pycache__/montauk-imager.cpython-314.pyc new file mode 100644 index 0000000..e229cc5 Binary files /dev/null and b/scripts/__pycache__/montauk-imager.cpython-314.pyc differ diff --git a/scripts/montauk-imager.py b/scripts/montauk-imager.py new file mode 100755 index 0000000..a7fdd50 --- /dev/null +++ b/scripts/montauk-imager.py @@ -0,0 +1,1720 @@ +#!/usr/bin/env python3 +# montauk-imager.py - write and update MontaukOS SD cards for the Raspberry Pi 4/400 +# +# Usage: ./scripts/montauk-imager.py [--image ] the graphical imager +# ./scripts/montauk-imager.py list list SD cards / USB drives +# sudo ./scripts/montauk-imager.py flash [--no-verify] [--expand] [--yes] +# sudo ./scripts/montauk-imager.py update [--keep-config] [--firmware] +# [--prune] [--expand] [--yes] +# sudo ./scripts/montauk-imager.py expand [--yes] +# +# The image is what `make ARCH=aarch64 rpi4-sd` builds (montauk-rpi4.img, see +# scripts/mk-rpi4-sd.sh and docs/raspberry-pi.md), raw or compressed (.gz, .xz, +# .zip). The GUI runs as the user and does the work by starting this script +# again through pkexec with --machine, reading its progress from stdout. +# +# flash Writes the image over the whole card (cancellable), wipes the end of +# the card (a stale backup GPT from an earlier OS), reads it back to +# verify, and rereads the partition table. +# +# update Refreshes the installation on a card already written from an image, +# in place: the card's partition table is never touched, so a system +# partition that has grown to fill the card (first boot, or --expand) +# stays as it is. Both partitions are found by their labels. +# ESP (FAT32, MONTAUK) every file of the image's ESP is written; +# config.txt is backed up to config.txt.bak +# if it differs, and the UEFI firmware +# RPI_EFI.fd (which holds the firmware's own +# settings) is only replaced with --firmware. +# root (ext2, MONTAUKROOT) +# os/, apps/, sdk/, boot/ and any other system +# directory: new and changed files written. +# config/ and www/: only files the card lacks +# are added, except that the image's init.toml +# and capabilities.toml are merged in (below). +# users/ and state/ are never touched. +# init.toml becomes the image's, with each service's `enabled` taken +# from the card's copy and services only the card has appended; +# capabilities.toml becomes the image's with grants only the card has +# appended. The card's old copy is kept as .old. --keep-config +# leaves both alone. os/firstboot.elf is only written if the card still +# has it: it deletes itself once the volume has grown. --prune deletes +# files of the system directories the image no longer ships (except +# os/btkeys.bin, the Bluetooth bonds the kernel keeps there). +# Unchanged files are not rewritten; each file is written to a temporary +# name, flushed and renamed over the old one. +# +# expand Grows the system partition to the end of the card (on a 1 MiB +# boundary, at most 1 TiB, the size mk-rpi4-sd.sh's resize_inode +# reserves for) and the ext2 volume with resize2fs, which is what +# 0:/os/firstboot.elf otherwise does on the Pi. firstboot.elf then +# finds nothing to grow and deletes itself. flash and update take +# --expand to do this as a last (flash) or first (update) step. +# +# The ext2 volume is checked with e2fsck before it is changed and afterwards, +# and its feature flags are checked against what Fs/Ext2.cpp mounts (no +# incompat feature but filetype). + +import argparse +import contextlib +import errno +import fcntl +import filecmp +import gzip +import hashlib +import json +import lzma +import os +import queue +import re +import shutil +import signal +import stat +import struct +import subprocess +import sys +import tempfile +import threading +import time +import zipfile + +ROOT_LABEL = "MONTAUKROOT" # RootLabel in kernel/src/Fs/Boot.cpp +ESP_LABEL = "MONTAUK" # mformat -v in scripts/mk-rpi4-sd.sh +SECTOR = 512 +MIB = 1 << 20 +ALIGN = MIB // SECTOR # partitions start and end on 1 MiB boundaries +MAX_ROOT_BYTES = (1 << 28) * 4096 # mke2fs -E resize=2^28 (4 KiB blocks); the kernel's grow cap too +CHUNK = 4 * MIB + +FAT_TYPES = {0x01, 0x04, 0x06, 0x0B, 0x0C, 0x0E, 0xEF} +TYPE_ESP = 0xEF +TYPE_LINUX = 0x83 + +# ext2/3/4 incompat features (dumpe2fs names). Fs/Ext2.cpp refuses all but filetype. +EXT_INCOMPAT = { + "compression", "filetype", "needs_recovery", "journal_dev", "meta_bg", + "extent", "extents", "64bit", "mmp", "flex_bg", "ea_inode", "dirdata", + "metadata_csum_seed", "large_dir", "inline_data", "encrypt", "casefold", +} +SUPPORTED_INCOMPAT = {"filetype"} + +# Mount points that mark a disk as the host's own: never offered, never written. +SYSTEM_MOUNTS = {"/", "/boot", "/boot/efi", "/efi", "/usr", "/var", "/home", "[SWAP]"} + +# Update policy for the root volume (see the header). +MERGE_DIRS = {"config", "www"} +STATE_DIRS = {"users", "state", "lost+found"} +PRUNE_DIRS = {"os", "apps", "sdk", "boot"} +PRESERVE = {"os/btkeys.bin", "os/firstboot.elf"} +FIRSTBOOT = "os/firstboot.elf" +INIT_TOML = "config/init.toml" +CAPS_TOML = "config/capabilities.toml" +FIRMWARE_FILE = "RPI_EFI.fd" +ESP_BACKUP = {"config.txt"} + +BLKRRPART = 0x125F +BLKFLSBUF = 0x1261 + +SCRIPT = os.path.realpath(__file__) +REPO = os.path.dirname(os.path.dirname(SCRIPT)) +if not os.path.isfile(os.path.join(REPO, "scripts", "mk-rpi4-sd.sh")): + REPO = None +DEFAULT_IMAGE = os.path.join(REPO, "montauk-rpi4.img") if REPO else "" + + +class Fail(Exception): + pass + + +def human(n): + """Decimal units, as SD cards are labelled.""" + n = float(n) + for unit in ("B", "kB", "MB", "GB", "TB"): + if n < 1000 or unit == "TB": + return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}" + n /= 1000 + + +def tool_env(): + env = dict(os.environ) + env["PATH"] = env.get("PATH", "/usr/bin:/bin") + ":/usr/sbin:/sbin" + env["LC_ALL"] = "C" + return env + + +# ==== Output ==== + +class Reporter: + """Progress for the GUI (--machine: one '@tag text' line each) or a terminal.""" + + def __init__(self, machine): + self.machine = machine + self.cancel = threading.Event() + self.cancellable = False + self._last = 0.0 + self._inline = False + self._tty = sys.stdout.isatty() + + def _write(self, line, end="\n"): + try: + sys.stdout.write(line + end) + sys.stdout.flush() + except (BrokenPipeError, OSError): + # The GUI went away. Finish the job anyway: an update stopped + # halfway is worse than one nobody watches. + devnull = os.open(os.devnull, os.O_WRONLY) + os.dup2(devnull, sys.stdout.fileno()) + + def _emit(self, tag, text): + text = " ".join(str(text).splitlines()) if tag != "log" else str(text).rstrip("\n") + if self.machine: + for part in text.splitlines() or [""]: + self._write(f"@{tag} {part}") + return + if self._inline: + self._write("") + self._inline = False + prefix = {"stage": "==> ", "log": " ", "warn": "warning: ", + "error": "error: ", "done": "==> "}[tag] + self._write(prefix + text) + + def stage(self, text): self._emit("stage", text) + def log(self, text): self._emit("log", text) + def warn(self, text): self._emit("warn", text) + def error(self, text): self._emit("error", text) + def done(self, text): self._emit("done", text) + + def progress(self, done, total, text, force=False): + now = time.monotonic() + if not force and done < total and now - self._last < 0.2: + return + self._last = now + if self.machine: + self._write(f"@progress {int(done)} {int(max(total, 1))} {text}") + elif self._tty: + pct = 100 * done / max(total, 1) + self._write(f"\r[{pct:5.1f}%] {text}".ljust(78), end="") + self._inline = True + + def check_cancel(self): + if self.cancellable and self.cancel.is_set(): + raise Fail("cancelled") + + +def watch_stdin(rep): + """The GUI cancels by writing 'cancel' (or by closing the pipe).""" + def run(): + try: + for line in sys.stdin: + if line.strip() == "cancel": + rep.cancel.set() + except (OSError, ValueError): + pass + rep.cancel.set() + threading.Thread(target=run, daemon=True).start() + + +def run(rep, cmd, ok=(0,), quiet=False): + try: + p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + stdin=subprocess.DEVNULL, text=True, errors="replace", env=tool_env()) + except FileNotFoundError: + raise Fail(f"{cmd[0]} is not installed") + if not quiet or p.returncode not in ok: + for line in p.stdout.splitlines(): + if line.strip(): + rep.log(line) + if p.returncode not in ok: + raise Fail(f"{os.path.basename(cmd[0])} failed (exit {p.returncode})") + return p + + +# ==== Partition tables ==== + +class Part: + def __init__(self, number, ptype, start, sectors): + self.number, self.type, self.start, self.sectors = number, ptype, start, sectors + self.node = None + self.label = "" + self.fstype = "" + + @property + def offset(self): return self.start * SECTOR + + @property + def size(self): return self.sectors * SECTOR + + @property + def end(self): return self.start + self.sectors + + +def parse_mbr(sector0): + """The four primary entries of an MBR, or None. Raises on a GPT disk.""" + if len(sector0) < 512 or sector0[510:512] != b"\x55\xaa": + return None + parts = [] + for i in range(4): + e = sector0[446 + 16 * i: 462 + 16 * i] + ptype = e[4] + start, count = struct.unpack_from(" root.start for p in parts): + raise Fail(f"partition {root.number} is not the last one; cannot expand it") + rep.stage("Expanding the system partition") + total = disk_bytes(name) // SECTOR + end = total // ALIGN * ALIGN + count = min(end - root.start, MAX_ROOT_BYTES // SECTOR, 0xFFFFFFFF // ALIGN * ALIGN) + if count > root.sectors: + dev = f"/dev/{name}" + fd = os.open(dev, os.O_RDWR | os.O_EXCL | os.O_CLOEXEC) + try: + mbr = bytearray(os.pread(fd, SECTOR, 0)) + struct.pack_into(" {human(count * SECTOR)}") + reread_partitions(rep, name, [p.number for p in parts]) + root.sectors = count + else: + rep.log(f"partition {root.number} already fills the card ({human(root.size)})") + fsck(rep, root.node) + blocks, bs, _ = fs_info(rep, root.node) + target = min(root.size, MAX_ROOT_BYTES) // bs + if target > blocks: + rep.log(f"growing the volume: {human(blocks * bs)} -> {human(target * bs)}") + rep.progress(0, 1, "Growing the volume (resize2fs)", force=True) + run(rep, ["resize2fs", root.node, str(target)]) + check_features(rep, root.node) + fsck(rep, root.node, check_only=True) + else: + rep.log(f"the volume already fills the partition ({human(blocks * bs)})") + + +# ==== Flash ==== + +def cmd_flash(rep, args): + img = Image(args.image) + name = check_target(rep, args.device) + dev = f"/dev/{name}" + card = disk_bytes(name) + known = img.size() + if known and known > card: + raise Fail(f"the image ({human(known)}) does not fit on the card ({human(card)})") + confirm(args, f"Everything on {dev} ({human(card)}) will be erased.") + + rep.stage(f"Preparing {dev}") + with no_automount(rep, name): + unmount_disk(rep, name) + try: + fd = os.open(dev, os.O_RDWR | os.O_EXCL | os.O_CLOEXEC) + except OSError as e: + raise Fail(f"cannot open {dev}: {e.strerror} (is something still using it?)") + # One descriptor from the first write to the reread: udev re-probes + # the disk when a writer closes it, and a volume mounted before the + # read-back would be changed under the verification. + try: + written, digest = write_image(rep, img, fd, card, dev) + if not args.no_verify: + verify_image(rep, fd, written, digest) + parts = parse_mbr(os.pread(fd, SECTOR, 0)) or [] + with contextlib.suppress(OSError): + fcntl.ioctl(fd, BLKRRPART) + finally: + os.close(fd) + reread_partitions(rep, name, [p.number for p in parts]) + if args.expand: + expand_root(rep, name) + settle() + rep.done(f"MontaukOS is on {dev}. You can remove the card.") + + +def write_image(rep, img, fd, card, dev): + digest = hashlib.sha256() + written = 0 + rep.stage(f"Writing {os.path.basename(img.path)} to {dev}") + rep.cancellable = True + try: + start = time.monotonic() + synced = 0 + with img.open() as (stream, raw): + while True: + rep.check_cancel() + buf = stream.read(CHUNK) + if not buf: + break + if written == 0: + try: + ok = parse_mbr(buf[:SECTOR]) is not None + except Fail: + ok = False + if not ok: + raise Fail(f"{os.path.basename(img.path)} is not a MontaukOS Raspberry Pi image " + "(no MBR partition table); nothing was written") + if written + len(buf) > card: + raise Fail(f"the image does not fit on the card ({human(card)})") + view = memoryview(buf) + while view: + n = os.write(fd, view) + view = view[n:] + digest.update(buf) + written += len(buf) + if written - synced >= 32 * MIB: + os.fsync(fd) # keep the progress honest: page cache is not the card + synced = written + rate = written / max(time.monotonic() - start, 0.001) + rep.progress(raw.tell(), img.file_size, + f"Writing: {written // MIB} MiB ({rate / 1e6:.1f} MB/s)") + rep.progress(1, 1, "Flushing to the card", force=True) + os.fsync(fd) + except Fail as e: + if str(e) == "cancelled": + os.fsync(fd) + raise Fail("cancelled; the card is incomplete now, write it again before using it") + raise + finally: + rep.cancellable = False + # Clear the last MiB: a backup GPT left by an earlier OS would otherwise + # outlive the MBR this image brings. + if card - written > 2 * MIB: + os.pwrite(fd, bytes(MIB), card - MIB) + os.fsync(fd) + rep.log(f"wrote {human(written)}") + return written, digest.digest() + + +def verify_image(rep, fd, written, digest): + rep.stage("Verifying") + fcntl.ioctl(fd, BLKFLSBUF) # read the card, not the page cache + os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED) + check = hashlib.sha256() + done = 0 + start = time.monotonic() + while done < written: + if rep.cancel.is_set(): + rep.warn("verification cancelled; the card was written completely but not checked") + return + buf = os.pread(fd, min(CHUNK, written - done), done) + if not buf: + break + check.update(buf) + done += len(buf) + rate = done / max(time.monotonic() - start, 0.001) + rep.progress(done, written, f"Verifying: {done // MIB} MiB ({rate / 1e6:.1f} MB/s)") + if done != written or check.digest() != digest: + raise Fail("verification failed: the card does not read back what was written " + "(a failing or counterfeit card?)") + rep.log("the card reads back correctly") + + +# ==== Update ==== + +def toml_sections(text): + """[(table name or None, [lines])]: each table with the lines up to the next.""" + header = re.compile(r"^\[([A-Za-z0-9_.\-\"]+)\]\s*(#.*)?$") + secs = [(None, [])] + for line in text.splitlines(): + m = header.match(line) + if m: + secs.append((m.group(1), [line])) + else: + secs[-1][1].append(line) + return secs + + +def merge_init(new, old): + """The image's init.toml, keeping the card's on/off choices and own services.""" + enabled = re.compile(r"^\s*enabled\s*=\s*(true|false)\b") + old_secs = {n: l for n, l in toml_sections(old) if n} + new_secs = toml_sections(new) + names = {n for n, _ in new_secs} + out = [] + for n, lines in new_secs: + lines = list(lines) + if n and n.startswith("services.") and n in old_secs: + choice = next((m.group(1) for m in map(enabled.match, old_secs[n]) if m), None) + if choice is not None: + idx = [i for i, l in enumerate(lines) if enabled.match(l)] + if idx: + lines[idx[0]] = f"enabled = {choice}" + else: + lines.insert(1, f"enabled = {choice}") + out.extend(lines) + extra = [(n, l) for n, l in toml_sections(old) + if n and n.startswith("services.") and n not in names] + if extra: + out += ["", "# Services from the previous installation."] + for _, lines in extra: + out.extend(lines) + return "\n".join(out).rstrip("\n") + "\n" + + +def merge_caps(new, old): + """The image's capabilities.toml plus the grants only the card has.""" + names = {n for n, _ in toml_sections(new)} + extra = [(n, l) for n, l in toml_sections(old) + if n and n.startswith("grant.") and n not in names] + out = new.rstrip("\n").splitlines() + if extra: + out += ["", "# ==== Grants from the previous installation ====", ""] + for _, lines in extra: + out.extend(lines) + return "\n".join(out).rstrip("\n") + "\n" + + +class Op: + def __init__(self, kind, rel, dst, src=None, data=None, size=0, replaced=0): + self.kind, self.rel, self.dst, self.src, self.data = kind, rel, dst, src, data + self.size, self.replaced = size, replaced + + +def alloc(size, bs, ext): + blocks = -(-size // bs) + if ext and blocks > 12: + blocks += -(-blocks // (bs // 4)) # indirect blocks + return blocks * bs + + +class Plan: + def __init__(self, what, dst_root, ext): + self.what, self.dst_root, self.ext = what, dst_root, ext + self.ops = [] + self.unchanged = 0 + st = os.statvfs(dst_root) + self.bs = st.f_frsize + self.free = st.f_bavail * st.f_frsize + + def write(self, rel, src=None, data=None): + dst = os.path.join(self.dst_root, rel) + size = len(data) if data is not None else os.path.getsize(src) + replaced = os.path.getsize(dst) if os.path.isfile(dst) else 0 + self.ops.append(Op("write", rel, dst, src, data, size, replaced)) + + def needed(self): + a = lambda n: alloc(n, self.bs, self.ext) + net = 0 + biggest = 0 + for op in self.ops: + if op.kind == "write": + net += a(op.size) - (a(op.replaced) if op.replaced else 0) + biggest = max(biggest, a(op.size)) + elif op.kind == "backup": + net += a(op.size) + elif op.kind == "delete": + net -= a(op.size) + return net + biggest + MIB + + def count(self, kind): + return sum(1 for op in self.ops if op.kind == kind) + + +def same_file(a, b): + try: + return os.path.getsize(a) == os.path.getsize(b) and filecmp.cmp(a, b, shallow=False) + except OSError: + return False + + +def plan_tree(rep, plan, src_root, decide, prune_dirs=(), preserve=()): + shipped = set() + files = [] + for dirpath, dirnames, filenames in os.walk(src_root): + rel_dir = os.path.relpath(dirpath, src_root) + rel_dir = "" if rel_dir == "." else rel_dir + dirnames[:] = sorted(d for d in dirnames + if decide(os.path.join(rel_dir, d), True) != "skip") + for d in dirnames: + shipped.add(os.path.join(rel_dir, d)) + for fn in sorted(filenames): + files.append(os.path.join(rel_dir, fn)) + shipped.add(files[-1]) + + for i, rel in enumerate(files): + rep.progress(i, len(files), f"Comparing {plan.what} ({i}/{len(files)})") + src = os.path.join(src_root, rel) + dst = os.path.join(plan.dst_root, rel) + policy = decide(rel, False) + exists = os.path.lexists(dst) + if exists and os.path.isdir(dst): + raise Fail(f"{rel} is a directory on the card but a file in the image") + if policy == "skip" or (policy == "add" and exists): + continue + if policy == "firstboot" and not exists: + rep.log("not adding os/firstboot.elf: the card's system volume has already grown") + continue + if policy in ("merge-init", "merge-caps") and exists: + with open(src, encoding="utf-8", errors="replace") as f: + new = f.read() + with open(dst, encoding="utf-8", errors="replace") as f: + old = f.read() + merged = (merge_init if policy == "merge-init" else merge_caps)(new, old) + if merged == old: + plan.unchanged += 1 + continue + plan.ops.append(Op("backup", rel, dst, size=len(old.encode()))) + plan.write(rel, data=merged.encode()) + continue + if exists and same_file(src, dst): + plan.unchanged += 1 + continue + if policy == "backup" and exists: + plan.ops.append(Op("backup", rel, dst, size=os.path.getsize(dst))) + plan.write(rel, src=src) + + for top in prune_dirs: + base = os.path.join(plan.dst_root, top) + if not os.path.isdir(base) or top not in shipped: + continue + for dirpath, dirnames, filenames in os.walk(base, topdown=False): + rel_dir = os.path.relpath(dirpath, plan.dst_root) + for fn in filenames: + rel = os.path.join(rel_dir, fn) + if rel not in shipped and rel not in preserve: + path = os.path.join(dirpath, fn) + plan.ops.append(Op("delete", rel, path, size=os.path.getsize(path))) + if rel_dir not in shipped: + plan.ops.append(Op("rmdir", rel_dir, dirpath)) + + +def execute(rep, plan, done, total): + for op in plan.ops: + if op.kind == "delete": + rep.log(f"removing {op.rel}") + os.unlink(op.dst) + for op in plan.ops: + if op.kind == "rmdir": + with contextlib.suppress(OSError): + os.rmdir(op.dst) # only if it ended up empty + for op in plan.ops: + if op.kind == "backup": + suffix = ".bak" if op.rel in ESP_BACKUP else ".old" + rep.log(f"keeping the card's {op.rel} as {os.path.basename(op.rel)}{suffix}") + shutil.copyfile(op.dst, op.dst + suffix) + for op in plan.ops: + if op.kind != "write": + continue + os.makedirs(os.path.dirname(op.dst), exist_ok=True) + tmp = os.path.join(os.path.dirname(op.dst), ".imager-" + os.path.basename(op.dst)) + with open(tmp, "wb") as out: + if op.data is not None: + out.write(op.data) + done += len(op.data) + else: + with open(op.src, "rb") as src: + while True: + buf = src.read(CHUNK) + if not buf: + break + out.write(buf) + done += len(buf) + rep.progress(done, total, f"Writing {op.rel}") + out.flush() + os.fsync(out.fileno()) + if op.src: + with contextlib.suppress(OSError): # FAT has no modes + os.chmod(tmp, os.stat(op.src).st_mode & 0o777) + os.replace(tmp, op.dst) + rep.progress(done, total, f"Writing {op.rel}") + return done + + +@contextlib.contextmanager +def mounted(rep, workdir, label, source, fstype, opts): + mnt = os.path.join(workdir, label) + os.makedirs(mnt, exist_ok=True) + run(rep, ["mount", "-t", fstype, "-o", opts, source, mnt], quiet=True) + try: + yield mnt + finally: + os.sync() + p = subprocess.run(["umount", mnt], env=tool_env(), + stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) + if p.returncode: + time.sleep(1) + subprocess.run(["umount", "-l", mnt], env=tool_env(), + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def cmd_update(rep, args): + img = Image(args.image) + name = check_target(rep, args.device) + with no_automount(rep, name): + update(rep, args, img, name) + + +def update(rep, args, img, name): + dev = f"/dev/{name}" + workdir = tempfile.mkdtemp(prefix="montauk-imager-") + try: + raw = img.materialize(rep, workdir) + iparts = img.partitions(raw) + iesp = next((p for p in iparts if p.type in FAT_TYPES), None) + iroot = next((p for p in iparts if p.type == TYPE_LINUX), None) + if not iesp: + raise Fail("the image has no boot partition; is it a MontaukOS Raspberry Pi image?") + + rep.stage(f"Inspecting {dev}") + unmount_disk(rep, name) + esp, root, _ = card_layout(rep, name) + if bool(iroot) != bool(root): + have = "an ext2 system partition" if root else "the single-partition (ramdisk) layout" + want = "an ext2 system partition" if iroot else "the single-partition (ramdisk) layout" + raise Fail(f"the card has {have} but the image uses {want}; write a fresh image instead") + rep.log(f"boot partition {esp.node} ({human(esp.size)})") + if root: + rep.log(f"system partition {root.node} ({human(root.size)}, " + f"{'fills the card' if root.end + ALIGN >= disk_bytes(name) // SECTOR else 'not expanded'})") + confirm(args, f"MontaukOS on {dev} will be updated; users, files and settings are kept.") + + signal.signal(signal.SIGHUP, signal.SIG_IGN) # an update must not stop halfway + signal.signal(signal.SIGINT, signal.SIG_IGN) + if root: + rep.stage("Checking the system volume") + fsck(rep, root.node) + if args.expand: + expand_root(rep, name) + esp, root, _ = card_layout(rep, name) + + with contextlib.ExitStack() as stack: + rep.stage("Mounting") + loop = lambda p: f"ro,loop,offset={p.offset},sizelimit={p.size}" + src_esp = stack.enter_context(mounted(rep, workdir, "image-esp", raw, "vfat", loop(iesp))) + dst_esp = stack.enter_context(mounted(rep, workdir, "card-esp", esp.node, "vfat", "rw,noatime")) + plans = [] + + def esp_rule(rel, is_dir): + if is_dir: + return "system" + if rel == FIRMWARE_FILE and not args.firmware: + return "add" + return "backup" if rel in ESP_BACKUP else "system" + + rep.stage("Comparing the card with the image") + plans.append((Plan("the boot partition", dst_esp, False), src_esp, esp_rule, (), ())) + + if root: + src_root = stack.enter_context(mounted(rep, workdir, "image-root", raw, "ext2", loop(iroot))) + dst_root = stack.enter_context(mounted(rep, workdir, "card-root", root.node, "ext2", "rw,noatime")) + + def root_rule(rel, is_dir): + top = rel.split("/")[0] + if top in STATE_DIRS: + return "skip" + if is_dir: + return "system" + if rel == FIRSTBOOT: + return "firstboot" + if rel in (INIT_TOML, CAPS_TOML): + if args.keep_config: + return "add" + return "merge-init" if rel == INIT_TOML else "merge-caps" + return "add" if top in MERGE_DIRS else "system" + + plans.append((Plan("the system partition", dst_root, True), src_root, root_rule, + PRUNE_DIRS if args.prune else (), PRESERVE)) + + for plan, src, rule, prune, keep in plans: + plan_tree(rep, plan, src, rule, prune, keep) + need = plan.needed() + rep.log(f"{plan.what}: {plan.count('write')} to write, {plan.unchanged} unchanged" + + (f", {plan.count('delete')} to remove" if plan.count("delete") else "")) + if need > plan.free: + hint = ("enable Expand (--expand) or write a fresh image" + if plan.ext else "write a fresh image") + raise Fail(f"not enough space on {plan.what}: needs {human(need)}, " + f"{human(plan.free)} free; {hint}") + + total = sum(op.size for plan, *_ in plans for op in plan.ops if op.kind == "write") + if total == 0 and not any(plan.ops for plan, *_ in plans): + rep.log("the card already matches the image") + rep.stage("Updating") + done = 0 + for plan, *_ in plans: + done = execute(rep, plan, done, total) + rep.progress(1, 1, "Flushing to the card", force=True) + os.sync() + rep.stage("Unmounting") + + if root: + rep.stage("Checking the system volume") + fsck(rep, root.node, check_only=True) + check_features(rep, root.node) + settle() + rep.done(f"MontaukOS on {dev} is up to date. You can remove the card.") + finally: + remove_workdir(rep, workdir) + + +def remove_workdir(rep, workdir): + """rmtree crosses mount points: never let it into a card left mounted.""" + for entry in os.listdir(workdir): + path = os.path.join(workdir, entry) + if os.path.ismount(path): + rep.warn(f"{path} is still mounted; unmount it by hand (sudo umount {path})") + elif os.path.isdir(path): + with contextlib.suppress(OSError): + os.rmdir(path) + else: + with contextlib.suppress(OSError): + os.unlink(path) + with contextlib.suppress(OSError): + os.rmdir(workdir) + + +def cmd_expand(rep, args): + name = check_target(rep, args.device) + confirm(args, f"The system partition on /dev/{name} will be grown to fill the card.") + signal.signal(signal.SIGINT, signal.SIG_IGN) + with no_automount(rep, name): + expand_root(rep, name) + settle() + rep.done(f"The system partition on /dev/{name} fills the card. You can remove the card.") + + +def confirm(args, what): + if args.yes: + return + if not sys.stdin.isatty(): + raise Fail(f"{what} Pass --yes to go ahead.") + answer = input(f"{what} Type 'yes' to continue: ") + if answer.strip().lower() != "yes": + raise Fail("aborted") + + +# ==== Card discovery (unprivileged) ==== + +def truthy(v): + return v in (True, 1, "1", "true") + + +def list_cards(show_all=False): + """Removable disks (or all non-system disks) as dicts, for the GUI and `list`.""" + cols = "NAME,PATH,SIZE,TYPE,RM,HOTPLUG,TRAN,MODEL,VENDOR,FSTYPE,LABEL,MOUNTPOINT,RO" + try: + p = subprocess.run(["lsblk", "-J", "-b", "-o", cols], stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, text=True, env=tool_env(), timeout=10) + devices = json.loads(p.stdout or "{}").get("blockdevices", []) + except (OSError, ValueError, subprocess.SubprocessError): + return [] + + def mounts(d): + out = [d.get("mountpoint")] if d.get("mountpoint") else [] + for c in d.get("children") or []: + out += mounts(c) + return out + + cards = [] + for d in devices: + name = d.get("name", "") + size = int(d.get("size") or 0) + if d.get("type") != "disk" or size == 0 or name.startswith(("zram", "loop", "ram")): + continue + if any(m in SYSTEM_MOUNTS for m in mounts(d)): + continue + removable = (truthy(d.get("rm")) or truthy(d.get("hotplug")) + or d.get("tran") in ("usb", "mmc", "sdio") or name.startswith("mmcblk")) + if not removable and not show_all: + continue + parts = [] + for c in d.get("children") or []: + if c.get("type") != "part": + continue + cname = c.get("name", "") + parts.append({ + "path": c.get("path") or f"/dev/{cname}", + "size": int(c.get("size") or 0), + "start": sysfs_int(f"/sys/block/{name}/{cname}/start") * SECTOR, + "label": c.get("label") or "", + "fstype": c.get("fstype") or "", + "mount": c.get("mountpoint") or "", + }) + model = " ".join(x.strip() for x in (d.get("vendor") or "", d.get("model") or "") if x and x.strip()) + card = {"path": d.get("path") or f"/dev/{name}", "name": name, "size": size, + "model": model or ("SD card" if name.startswith("mmcblk") else "Drive"), + "ro": truthy(d.get("ro")), "removable": removable, "parts": parts} + card.update(describe(card)) + cards.append(card) + return cards + + +def describe(card): + """What is on a card: {'kind': 'ext2'|'ramdisk'|'other'|'empty', 'contents', 'fills'}.""" + parts = card["parts"] + root = next((p for p in parts if p["label"] == ROOT_LABEL), None) + esp = next((p for p in parts if p["label"] == ESP_LABEL and p["fstype"] == "vfat"), None) + if root: + fills = (root["start"] + root["size"] + 2 * MIB >= card["size"] + or root["size"] >= MAX_ROOT_BYTES - MIB) + text = f"MontaukOS, system partition {human(root['size'])}" + text += ", fills the card" if fills else ", not expanded yet" + return {"kind": "ext2", "contents": text, "fills": fills} + if esp: + return {"kind": "ramdisk", "contents": "MontaukOS (single partition, ramdisk)", "fills": True} + if parts: + names = [p["label"] or p["fstype"] or "unknown" for p in parts] + return {"kind": "other", "contents": ", ".join(names), "fills": False} + return {"kind": "empty", "contents": "empty", "fills": False} + + +def cmd_list(rep, args): + cards = list_cards(args.all) + if not cards: + print("No SD cards or USB drives found." + ("" if args.all else " (--all shows fixed disks too)")) + for c in cards: + print(f"{c['path']:<16} {human(c['size']):>9} {c['model']:<28} {c['contents']}" + + (" [write-protected]" if c["ro"] else "")) + + +def image_summary(path): + """One line about an image file for the GUI, and its layout if it can tell.""" + try: + img = Image(path) + except Fail as e: + return str(e), None + when = time.strftime("%Y-%m-%d %H:%M", time.localtime(os.path.getmtime(img.path))) + if img.kind != "raw": + return f"{img.kind} compressed, {human(img.file_size)}, {when}", None + try: + parts = img.partitions(img.path) + except Fail as e: + return str(e), None + layout = "ext2" if any(p.type == TYPE_LINUX for p in parts) else "ramdisk" + desc = "boot + ext2 system partition" if layout == "ext2" else "single partition (ramdisk)" + return f"{human(img.file_size)}, {desc}, built {when}", layout + + +# ==== GUI ==== + +def gui_main(initial): + try: + import tkinter as tk + from tkinter import ttk, filedialog, messagebox + except ImportError: + sys.exit("montauk-imager: the GUI needs Tkinter (sudo apt install python3-tk)") + try: + root = tk.Tk() + except tk.TclError as e: + sys.exit(f"montauk-imager: cannot open a window ({e}); use the list/flash/update commands") + + root.title("MontaukOS Imager") + root.minsize(680, 600) + style = ttk.Style(root) + if "clam" in style.theme_names(): + style.theme_use("clam") + style.configure("Title.TLabel", font=("TkDefaultFont", 15, "bold")) + style.configure("Dim.TLabel", foreground="#666666") + style.configure("Accent.TButton", font=("TkDefaultFont", 10, "bold")) + + S = {"busy": None, "proc": None, "cards": [], "sig": None, "layout": None} + events = queue.Queue() + + image_var = tk.StringVar(value=initial or (DEFAULT_IMAGE if os.path.isfile(DEFAULT_IMAGE) else "")) + image_info = tk.StringVar() + show_all = tk.BooleanVar(value=False) + action = tk.StringVar(value="flash") + verify = tk.BooleanVar(value=True) + flash_expand = tk.BooleanVar(value=False) + merge_cfg = tk.BooleanVar(value=True) + firmware = tk.BooleanVar(value=False) + prune = tk.BooleanVar(value=False) + upd_expand = tk.BooleanVar(value=False) + status = tk.StringVar(value="Choose an image and an SD card.") + card_info = tk.StringVar() + + main = ttk.Frame(root, padding=16) + main.pack(fill="both", expand=True) + main.columnconfigure(0, weight=1) + + head = ttk.Frame(main) + head.grid(row=0, column=0, sticky="ew") + ttk.Label(head, text="MontaukOS Imager", style="Title.TLabel").pack(side="left") + ttk.Label(head, text="Raspberry Pi 4 / 400", style="Dim.TLabel").pack(side="left", padx=10, pady=(5, 0)) + + # Image + imgf = ttk.LabelFrame(main, text="Image", padding=10) + imgf.grid(row=1, column=0, sticky="ew", pady=(12, 0)) + imgf.columnconfigure(0, weight=1) + img_entry = ttk.Entry(imgf, textvariable=image_var) + img_entry.grid(row=0, column=0, sticky="ew") + browse_btn = ttk.Button(imgf, text="Browse...", command=lambda: browse()) + browse_btn.grid(row=0, column=1, padx=(6, 0)) + build_btn = ttk.Button(imgf, text="Build", command=lambda: build()) + if REPO: + build_btn.grid(row=0, column=2, padx=(6, 0)) + ttk.Label(imgf, textvariable=image_info, style="Dim.TLabel").grid(row=1, column=0, columnspan=3, sticky="w", pady=(4, 0)) + + # Card + cardf = ttk.LabelFrame(main, text="SD card", padding=10) + cardf.grid(row=2, column=0, sticky="nsew", pady=(10, 0)) + main.rowconfigure(2, weight=1) + cardf.columnconfigure(0, weight=1) + cardf.rowconfigure(0, weight=1) + tree = ttk.Treeview(cardf, columns=("device", "size", "model", "contents"), show="headings", + height=4, selectmode="browse") + for col, text, width, anchor in (("device", "Device", 110, "w"), ("size", "Size", 80, "e"), + ("model", "Name", 170, "w"), ("contents", "Contents", 300, "w")): + tree.heading(col, text=text, anchor=anchor) + tree.column(col, width=width, anchor=anchor, stretch=(col == "contents")) + tree.grid(row=0, column=0, columnspan=3, sticky="nsew") + ttk.Label(cardf, textvariable=card_info, style="Dim.TLabel").grid(row=1, column=0, sticky="w", pady=(4, 0)) + ttk.Checkbutton(cardf, text="Show all drives", variable=show_all, + command=lambda: refresh(force=True)).grid(row=1, column=1, padx=6, pady=(4, 0)) + ttk.Button(cardf, text="Refresh", command=lambda: refresh(force=True)).grid(row=1, column=2, pady=(4, 0)) + + # Action + actf = ttk.LabelFrame(main, text="Action", padding=10) + actf.grid(row=3, column=0, sticky="ew", pady=(10, 0)) + actf.columnconfigure(1, weight=1) + rb_flash = ttk.Radiobutton(actf, text="Write a fresh image (erases the card)", value="flash", + variable=action, command=lambda: update_ui()) + rb_update = ttk.Radiobutton(actf, text="Update the installation on the card (keeps users, files and settings)", + value="update", variable=action, command=lambda: update_ui()) + rb_flash.grid(row=0, column=0, columnspan=2, sticky="w") + rb_update.grid(row=1, column=0, columnspan=2, sticky="w") + + optf = ttk.Frame(actf, padding=(22, 4, 0, 0)) + optf.grid(row=2, column=0, columnspan=2, sticky="ew") + flash_opts = [ + ttk.Checkbutton(optf, text="Verify the card after writing", variable=verify), + ttk.Checkbutton(optf, text="Expand the system partition to fill the card now " + "(otherwise MontaukOS does it on first boot)", variable=flash_expand), + ] + update_opts = [ + ttk.Checkbutton(optf, text="Merge new services and capability grants into the card's configuration", + variable=merge_cfg), + ttk.Checkbutton(optf, text="Update the UEFI firmware (RPI_EFI.fd; resets the firmware's settings)", + variable=firmware), + ttk.Checkbutton(optf, text="Remove system files the new image no longer ships", variable=prune), + ttk.Checkbutton(optf, text="Expand the system partition to fill the card", variable=upd_expand), + ] + + # Progress + progf = ttk.Frame(main) + progf.grid(row=4, column=0, sticky="ew", pady=(12, 0)) + progf.columnconfigure(0, weight=1) + bar = ttk.Progressbar(progf, mode="determinate", maximum=1000) + bar.grid(row=0, column=0, columnspan=3, sticky="ew") + ttk.Label(progf, textvariable=status).grid(row=1, column=0, sticky="w", pady=(4, 0)) + log_btn = ttk.Button(progf, text="Show log", command=lambda: toggle_log()) + log_btn.grid(row=1, column=1, padx=6, pady=(4, 0)) + logf = ttk.Frame(main) + logf.columnconfigure(0, weight=1) + logf.rowconfigure(0, weight=1) + log = tk.Text(logf, height=10, wrap="word", state="disabled", font=("TkFixedFont", 9), + relief="flat", borderwidth=1) + scroll = ttk.Scrollbar(logf, orient="vertical", command=log.yview) + log.configure(yscrollcommand=scroll.set) + log.grid(row=0, column=0, sticky="nsew") + scroll.grid(row=0, column=1, sticky="ns") + log.tag_configure("stage", font=("TkFixedFont", 9, "bold")) + log.tag_configure("warn", foreground="#a05a00") + log.tag_configure("error", foreground="#b00020", font=("TkFixedFont", 9, "bold")) + log.tag_configure("done", foreground="#0a7a2f", font=("TkFixedFont", 9, "bold")) + + btns = ttk.Frame(main) + btns.grid(row=6, column=0, sticky="e", pady=(12, 0)) + cancel_btn = ttk.Button(btns, text="Cancel", command=lambda: cancel()) + start_btn = ttk.Button(btns, text="Write", style="Accent.TButton", command=lambda: start()) + cancel_btn.pack(side="left", padx=(0, 6)) + start_btn.pack(side="left") + + def toggle_log(show=None): + visible = bool(logf.winfo_ismapped()) + show = (not visible) if show is None else show + if show and not visible: + logf.grid(row=5, column=0, sticky="nsew", pady=(8, 0)) + main.rowconfigure(5, weight=2) + log_btn.configure(text="Hide log") + elif not show and visible: + logf.grid_remove() + main.rowconfigure(5, weight=0) + log_btn.configure(text="Show log") + + def append(text, tag=None): + log.configure(state="normal") + log.insert("end", text + "\n", (tag,) if tag else ()) + log.see("end") + log.configure(state="disabled") + + def selected(): + sel = tree.selection() + return next((c for c in S["cards"] if c["path"] == (sel[0] if sel else None)), None) + + def refresh(force=False): + if S["busy"] in ("flash", "update", "expand") and not force: + return + cards = list_cards(show_all.get()) + sig = json.dumps(cards, sort_keys=True) + if sig == S["sig"] and not force: + return + S["sig"] = sig + keep = selected() + S["cards"] = cards + tree.delete(*tree.get_children()) + for c in cards: + contents = c["contents"] + (" [write-protected]" if c["ro"] else "") + tree.insert("", "end", iid=c["path"], + values=(c["path"], human(c["size"]), c["model"], contents)) + if keep and tree.exists(keep["path"]): + tree.selection_set(keep["path"]) + elif len(cards) == 1: + tree.selection_set(cards[0]["path"]) + update_ui() + + def poll_cards(): + if not S["busy"]: + refresh() + root.after(2000, poll_cards) + + def refresh_image(*_): + path = image_var.get().strip() + if not path: + image_info.set("No image chosen." + (" Build one, or browse for montauk-rpi4.img." if REPO else "")) + S["layout"] = None + else: + info, S["layout"] = image_summary(path) + image_info.set(info) + update_ui() + + def update_ui(*_): + card = selected() + installed = bool(card and card["kind"] in ("ext2", "ramdisk")) + rb_update.configure(state="normal" if installed and not S["busy"] else "disabled") + if action.get() == "update" and not installed: + action.set("flash") + for w in flash_opts + update_opts: + w.pack_forget() + opts = flash_opts if action.get() == "flash" else update_opts + for w in opts: + w.pack(anchor="w") + if card: + if card["kind"] == "ext2" and card["fills"]: + upd_expand.set(False) + card_info.set(f"{card['path']}: {card['model']}, {human(card['size'])}, {card['contents']}") + else: + card_info.set("Insert an SD card (directly or in a USB reader)." if not S["cards"] + else "Select the card to write.") + state = "disabled" if S["busy"] else "normal" + for w in opts + [img_entry, browse_btn, build_btn, rb_flash]: + w.configure(state=state) + if card and card["kind"] == "ext2" and card["fills"] and not S["busy"]: + update_opts[3].configure(state="disabled") + if card and card["kind"] == "ramdisk": + update_opts[3].configure(state="disabled") + start_btn.configure(text="Write" if action.get() == "flash" else "Update") + ready = card and image_var.get().strip() and not S["busy"] and not card["ro"] + start_btn.configure(state="normal" if ready else "disabled") + cancel_btn.configure(state="normal" if S["busy"] in ("flash", "build") else "disabled") + + def browse(): + start_dir = os.path.dirname(image_var.get()) or REPO or os.path.expanduser("~") + path = filedialog.askopenfilename( + parent=root, title="Choose a MontaukOS image", initialdir=start_dir, + filetypes=[("Disk images", "*.img *.img.gz *.img.xz *.gz *.xz *.zip"), ("All files", "*")]) + if path: + image_var.set(path) + + def launch(kind, cmd, cwd=None): + S["busy"] = kind + bar.configure(mode="determinate", value=0) + append("$ " + " ".join(cmd), "stage") + try: + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, text=True, bufsize=1, + errors="replace", cwd=cwd) + except OSError as e: + S["busy"] = None + fail(f"Could not start {cmd[0]}: {e}") + return + S["proc"] = proc + + def reader(): + for line in proc.stdout: + events.put(("line", line.rstrip("\n"))) + events.put(("exit", proc.wait())) + threading.Thread(target=reader, daemon=True).start() + update_ui() + + def fail(msg): + status.set(msg) + append(msg, "error") + toggle_log(True) + messagebox.showerror("MontaukOS Imager", msg, parent=root) + + def pump(): + try: + while True: + kind, val = events.get_nowait() + if kind == "line": + handle_line(val) + else: + finished(val) + except queue.Empty: + pass + root.after(50, pump) + + def handle_line(line): + if S["busy"] == "build" or not line.startswith("@"): + append(line) + if S["busy"] == "build" and line.strip(): + status.set(line.strip()[:90]) + return + tag, _, text = line[1:].partition(" ") + if tag == "progress": + done, total, text = (text.split(" ", 2) + ["", ""])[:3] + bar.configure(mode="determinate", value=1000 * int(done) / max(int(total), 1)) + status.set(text) + elif tag in ("stage", "log", "warn", "error", "done"): + append(("==> " + text) if tag == "stage" else text, tag if tag != "log" else None) + if tag in ("stage", "done"): + status.set(text) + if tag == "error": + S["error"] = text + if tag == "warn": + toggle_log(True) + + def finished(rc): + kind = S["busy"] + S["busy"], S["proc"] = None, None + if kind == "build": + bar.stop() + bar.configure(mode="determinate", value=0) + if rc == 0 and os.path.isfile(DEFAULT_IMAGE): + status.set("Image built.") + image_var.set(DEFAULT_IMAGE) + refresh_image() + else: + fail(f"The build failed (make exit {rc}); see the log.") + elif rc == 0: + bar.configure(value=1000) + messagebox.showinfo("MontaukOS Imager", status.get(), parent=root) + else: + err = S.pop("error", None) + if rc == 126 and not err: + status.set("Authorization was cancelled.") + elif rc == 127 and not err: + fail("Could not get administrator rights through pkexec. " + "Run the imager with sudo instead.") + else: + fail(err or f"Failed (exit {rc}); see the log.") + S["sig"] = None + refresh(force=True) + + def privileged(args): + base = [sys.executable, "-I", SCRIPT, "--machine"] + args + ["--yes"] + if os.geteuid() == 0: + return base + if not shutil.which("pkexec"): + return None + return ["pkexec"] + base + + def start(): + card = selected() + path = os.path.abspath(image_var.get().strip()) + if not card: + return + if not os.path.isfile(path): + fail(f"The image {path} does not exist.") + return + what = f"{card['model']} ({card['path']}, {human(card['size'])})" + if action.get() == "flash": + if card["kind"] != "empty": + detail = f"It currently holds: {card['contents']}.\n\n" + else: + detail = "" + if not messagebox.askyesno( + "Erase the card?", + f"Everything on {what} will be erased and replaced with MontaukOS.\n\n{detail}" + "Continue?", icon="warning", default="no", parent=root): + return + args = ["flash", path, card["path"]] + if not verify.get(): + args.append("--no-verify") + if flash_expand.get(): + args.append("--expand") + else: + if S["layout"] and card["kind"] != S["layout"]: + fail("The card and the image use different layouts " + "(ext2 system partition vs single ramdisk partition). Write a fresh image instead.") + return + if not messagebox.askyesno( + "Update MontaukOS?", + f"MontaukOS on {what} will be updated from\n{os.path.basename(path)}.\n\n" + "Users, their files and settings are kept. Do not remove the card " + "until the update has finished.\n\nContinue?", parent=root): + return + args = ["update", path, card["path"]] + if not merge_cfg.get(): + args.append("--keep-config") + if firmware.get(): + args.append("--firmware") + if prune.get(): + args.append("--prune") + if upd_expand.get(): + args.append("--expand") + cmd = privileged(args) + if not cmd: + fail("Writing a card needs administrator rights, but pkexec is not installed. " + "Run the imager with sudo.") + return + S.pop("error", None) + status.set("Waiting for authorization..." if cmd[0] == "pkexec" else "Starting...") + launch(args[0], cmd) + + def build(): + if not REPO: + return + status.set("Building the image (make ARCH=aarch64 rpi4-sd)...") + toggle_log(True) + launch("build", ["make", "ARCH=aarch64", "rpi4-sd"], cwd=REPO) + bar.configure(mode="indeterminate") + bar.start(15) + + def cancel(): + proc = S["proc"] + if not proc: + return + if S["busy"] == "build": + proc.terminate() + bar.stop() + return + if S["busy"] == "flash" and messagebox.askyesno( + "Cancel writing?", "Stop writing the card? It will not boot until it is written again.", + parent=root): + with contextlib.suppress(OSError): + proc.stdin.write("cancel\n") + proc.stdin.flush() + status.set("Cancelling...") + + def on_close(): + if S["busy"] == "update" or S["busy"] == "expand": + messagebox.showwarning("MontaukOS Imager", + "The card is being updated. Wait until it has finished.", parent=root) + return + if S["busy"] == "flash": + if not messagebox.askyesno("Quit?", "The card is still being written. Stop and quit?", + parent=root): + return + with contextlib.suppress(OSError): + S["proc"].stdin.write("cancel\n") + S["proc"].stdin.flush() + if S["busy"] == "build": + S["proc"].terminate() + root.destroy() + + tree.bind("<>", update_ui) + image_var.trace_add("write", refresh_image) + root.protocol("WM_DELETE_WINDOW", on_close) + refresh_image() + refresh(force=True) + root.after(50, pump) + root.after(2000, poll_cards) + root.mainloop() + + +# ==== Entry ==== + +def main(): + ap = argparse.ArgumentParser(description="Write and update MontaukOS SD cards for the Raspberry Pi 4/400.") + ap.add_argument("--machine", action="store_true", help=argparse.SUPPRESS) + ap.add_argument("--image", help="image to preselect in the GUI") + sub = ap.add_subparsers(dest="cmd") + p = sub.add_parser("list", help="list SD cards and USB drives") + p.add_argument("--all", action="store_true", help="include fixed disks") + p = sub.add_parser("flash", help="write an image over the whole card") + p.add_argument("image") + p.add_argument("device") + p.add_argument("--no-verify", action="store_true", help="do not read the card back") + p.add_argument("--expand", action="store_true", help="grow the system partition to fill the card") + p.add_argument("--yes", action="store_true", help="do not ask for confirmation") + p = sub.add_parser("update", help="update the installation on a card, keeping user data") + p.add_argument("image") + p.add_argument("device") + p.add_argument("--keep-config", action="store_true", help="leave init.toml and capabilities.toml alone") + p.add_argument("--firmware", action="store_true", help="also replace RPI_EFI.fd (resets UEFI settings)") + p.add_argument("--prune", action="store_true", help="delete system files the image no longer ships") + p.add_argument("--expand", action="store_true", help="grow the system partition to fill the card first") + p.add_argument("--yes", action="store_true", help="do not ask for confirmation") + p = sub.add_parser("expand", help="grow the system partition to fill the card") + p.add_argument("device") + p.add_argument("--yes", action="store_true", help="do not ask for confirmation") + args = ap.parse_args() + + if args.cmd is None: + gui_main(args.image) + return + rep = Reporter(args.machine) + if args.cmd == "list": + cmd_list(rep, args) + return + if os.geteuid() != 0: + rep.error("writing a card needs root: run with sudo (the GUI asks through pkexec)") + sys.exit(1) + if args.machine: + watch_stdin(rep) + try: + {"flash": cmd_flash, "update": cmd_update, "expand": cmd_expand}[args.cmd](rep, args) + except Fail as e: + rep.error(str(e)) + sys.exit(1) + except KeyboardInterrupt: + rep.error("interrupted") + sys.exit(130) + except OSError as e: + rep.error(f"{e.filename + ': ' if e.filename else ''}{e.strerror or e}") + sys.exit(1) + + +if __name__ == "__main__": + main()