Files
MontaukOS/scripts/montauk-imager.py
T

1721 lines
69 KiB
Python
Executable File

#!/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 <file>] the graphical imager
# ./scripts/montauk-imager.py list list SD cards / USB drives
# sudo ./scripts/montauk-imager.py flash <image> <device> [--no-verify] [--expand] [--yes]
# sudo ./scripts/montauk-imager.py update <image> <device> [--keep-config] [--firmware]
# [--prune] [--expand] [--yes]
# sudo ./scripts/montauk-imager.py expand <device> [--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 <name>.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("<II", e, 8)
if ptype == 0xEE:
raise Fail("the card uses a GPT partition table, not the MBR a MontaukOS image has")
if ptype and count:
parts.append(Part(i + 1, ptype, start, count))
return parts
# ==== Images ====
class Image:
"""A raw, gzip, xz or zip image, streamed without unpacking it to disk."""
def __init__(self, path):
self.path = os.path.abspath(path)
if not os.path.isfile(self.path):
raise Fail(f"image {path} not found")
self.file_size = os.path.getsize(self.path)
with open(self.path, "rb") as f:
magic = f.read(6)
if magic[:2] == b"\x1f\x8b":
self.kind = "gzip"
elif magic == b"\xfd7zXZ\x00":
self.kind = "xz"
elif magic[:4] == b"PK\x03\x04":
self.kind = "zip"
else:
self.kind = "raw"
def _zip_member(self, zf):
infos = [i for i in zf.infolist() if not i.is_dir()]
imgs = [i for i in infos if i.filename.lower().endswith(".img")]
pick = imgs or infos
if not pick:
raise Fail("the zip file is empty")
return max(pick, key=lambda i: i.file_size)
def size(self):
"""The uncompressed size, or None where it is not cheap to find out."""
if self.kind == "raw":
return self.file_size
if self.kind == "zip":
with zipfile.ZipFile(self.path) as zf:
return self._zip_member(zf).file_size
if self.kind == "gzip" and self.file_size < (1 << 32):
with open(self.path, "rb") as f:
f.seek(-4, os.SEEK_END)
return struct.unpack("<I", f.read(4))[0] or None
return None
@contextlib.contextmanager
def open(self):
"""Yields (stream, raw) where raw.tell() / file_size is the progress."""
raw = open(self.path, "rb")
try:
if self.kind == "raw":
yield raw, raw
elif self.kind == "gzip":
with gzip.GzipFile(fileobj=raw) as s:
yield s, raw
elif self.kind == "xz":
with lzma.LZMAFile(raw) as s:
yield s, raw
else:
with zipfile.ZipFile(raw) as zf, zf.open(self._zip_member(zf)) as s:
yield s, raw
finally:
raw.close()
def materialize(self, rep, workdir):
"""A raw image file: this one, or a decompressed copy in workdir."""
if self.kind == "raw":
return self.path
out = os.path.join(workdir, "image.img")
rep.stage(f"Decompressing {os.path.basename(self.path)}")
with self.open() as (s, raw), open(out, "wb") as o:
while True:
buf = s.read(CHUNK)
if not buf:
break
o.write(buf)
rep.progress(raw.tell(), self.file_size, "Decompressing")
return out
def partitions(self, raw_path):
with open(raw_path, "rb") as f:
parts = parse_mbr(f.read(512))
if not parts:
raise Fail("the image has no MBR partition table; is it a MontaukOS Raspberry Pi image?")
return parts
# ==== Block devices ====
def disk_of(device):
"""The /sys/block name of a whole disk, or Fail."""
real = os.path.realpath(device)
name = os.path.basename(real)
if not real.startswith("/dev/") or not os.path.isdir(f"/sys/block/{name}"):
raise Fail(f"{device} is not a whole disk (give /dev/sdX or /dev/mmcblkN, not a partition)")
if not stat.S_ISBLK(os.stat(real).st_mode):
raise Fail(f"{device} is not a block device")
return name
def sysfs_int(path, default=0):
try:
with open(path) as f:
return int(f.read().strip())
except (OSError, ValueError):
return default
def disk_bytes(name):
return sysfs_int(f"/sys/block/{name}/size") * SECTOR
def sys_partitions(name):
"""{number: Part} as the kernel currently sees the disk's partitions."""
parts = {}
base = f"/sys/block/{name}"
for entry in os.listdir(base):
num = sysfs_int(f"{base}/{entry}/partition", 0)
if num:
p = Part(num, 0, sysfs_int(f"{base}/{entry}/start"), sysfs_int(f"{base}/{entry}/size"))
p.node = f"/dev/{entry}"
parts[num] = p
return parts
def holders(sysname):
"""Everything stacked on a block device (dm-crypt, LVM, md), recursively."""
out = []
for base in (f"/sys/block/{sysname}", f"/sys/class/block/{sysname}"):
hdir = os.path.join(base, "holders")
if os.path.isdir(hdir):
for h in os.listdir(hdir):
out.append(h)
out.extend(holders(h))
break
return out
def disk_mounts(name):
"""[(device node, mount point)] of the disk, its partitions and their holders."""
nodes = {f"/dev/{name}"} | {p.node for p in sys_partitions(name).values()}
for n in list(nodes):
nodes |= {f"/dev/{h}" for h in holders(os.path.basename(n))}
real = {os.path.realpath(n): n for n in nodes}
found = []
with open("/proc/self/mounts") as f:
for line in f:
dev, mnt = line.split()[:2]
mnt = mnt.replace("\\040", " ")
if dev.startswith("/dev/") and os.path.realpath(dev) in real:
found.append((dev, mnt))
with open("/proc/swaps") as f:
for line in f.readlines()[1:]:
dev = line.split()[0]
if os.path.realpath(dev) in real:
found.append((dev, "[SWAP]"))
return found
def check_target(rep, device):
"""The disk name of a device it is safe to write, or Fail."""
name = disk_of(device)
loop_ok = name.startswith("loop") and os.environ.get("MONTAUK_IMAGER_TEST_LOOP") == "1"
if name.startswith(("loop", "zram", "ram", "sr", "dm-", "md")) and not loop_ok:
raise Fail(f"{device} is not an SD card or USB drive")
for _, mnt in disk_mounts(name):
if mnt in SYSTEM_MOUNTS:
raise Fail(f"{device} holds this computer's own system ({mnt}); refusing to touch it")
if sysfs_int(f"/sys/block/{name}/ro"):
raise Fail(f"{device} is write-protected (check the lock switch on the side of the card)")
if disk_bytes(name) == 0:
raise Fail(f"{device} has no medium (is the card inserted?)")
lbs = sysfs_int(f"/sys/block/{name}/queue/logical_block_size", SECTOR)
if lbs != SECTOR:
raise Fail(f"{device} has {lbs}-byte sectors; the image's partition table assumes 512")
return name
def unmount_disk(rep, name):
for part in sys_partitions(name).values():
if holders(os.path.basename(part.node)):
raise Fail(f"{part.node} is in use by device mapper/LVM/RAID; close it first")
for attempt in range(3):
mounts = disk_mounts(name)
if not mounts:
return
for dev, mnt in sorted(mounts, key=lambda m: -len(m[1])):
if mnt == "[SWAP]":
raise Fail(f"{dev} is in use as swap")
rep.log(f"unmounting {dev} from {mnt}")
run(rep, ["umount", mnt], ok=(0, 32), quiet=True)
time.sleep(0.5)
if disk_mounts(name):
raise Fail(f"could not unmount /dev/{name}; close any program using it")
def settle():
with contextlib.suppress(FileNotFoundError, subprocess.SubprocessError):
subprocess.run(["udevadm", "settle", "--timeout=10"], env=tool_env(),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=15)
def reread_partitions(rep, name, want):
"""Make the kernel load the new table; wait for the partitions we expect."""
dev = f"/dev/{name}"
for attempt in range(10):
settle()
unmount_disk(rep, name)
fd = os.open(dev, os.O_RDONLY | os.O_CLOEXEC)
try:
fcntl.ioctl(fd, BLKRRPART)
err = None
except OSError as e:
err = e
finally:
os.close(fd)
if err is None:
break
if err.errno != errno.EBUSY:
raise Fail(f"could not reread the partition table of {dev}: {err.strerror}")
time.sleep(1)
else:
# The kernel keeps the old table while a partition is open (a desktop
# probing it); partx updates the entries one by one instead.
run(rep, ["partx", "-u", dev], ok=(0, 1), quiet=True)
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
settle()
parts = sys_partitions(name)
if all(n in parts and os.path.exists(parts[n].node) for n in want):
return parts
time.sleep(0.5)
raise Fail(f"the partitions of {dev} did not appear after writing the table")
@contextlib.contextmanager
def no_automount(rep, name):
"""Keep the desktop from mounting the card's partitions while we work.
Closing a disk opened for writing makes udev re-probe it, and a desktop
then mounts the partitions it finds (and Linux writes to an ext2 volume as
soon as it is mounted read-write). A runtime rule turns udisks' automount
off for this disk until we are done."""
rules = "/run/udev/rules.d"
path = os.path.join(rules, f"90-montauk-imager-{name}.rules")
sep = "p" if name[-1].isdigit() else ""
try:
os.makedirs(rules, exist_ok=True)
with open(path, "w") as f:
f.write("# Written by MontaukOS's montauk-imager.py while it writes this disk.\n"
f'KERNEL=="{name}", ENV{{UDISKS_AUTO}}="0"\n'
f'KERNEL=="{name}{sep}[0-9]*", ENV{{UDISKS_AUTO}}="0", ENV{{UDISKS_IGNORE}}="1"\n')
subprocess.run(["udevadm", "control", "--reload"], env=tool_env(),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except (OSError, subprocess.SubprocessError):
path = None
try:
yield
finally:
if path:
with contextlib.suppress(OSError, subprocess.SubprocessError):
os.unlink(path)
subprocess.run(["udevadm", "control", "--reload"], env=tool_env(),
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
# Let the desktop see the finished card.
subprocess.run(["udevadm", "trigger", "--action=change", f"/dev/{name}"],
env=tool_env(), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def probe(node):
"""blkid's LABEL and TYPE of a partition (low-level probe, no cache)."""
p = subprocess.run(["blkid", "-p", "-o", "export", node], stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, text=True, env=tool_env())
info = {}
for line in p.stdout.splitlines():
k, _, v = line.partition("=")
info[k] = v
return info.get("LABEL", ""), info.get("TYPE", "")
def card_layout(rep, name):
"""(esp, root) Parts of a MontaukOS card, root None for the ramdisk layout."""
with open(f"/dev/{name}", "rb") as f:
parts = parse_mbr(f.read(512))
if not parts:
raise Fail(f"/dev/{name} has no partition table; write a fresh image instead")
nodes = sys_partitions(name)
esp = root = None
for p in parts:
if p.number not in nodes:
continue
p.node = nodes[p.number].node
p.label, p.fstype = probe(p.node)
if p.label == ROOT_LABEL and p.fstype.startswith("ext"):
root = root or p
elif p.fstype == "vfat" and (p.label == ESP_LABEL or (p.type == TYPE_ESP and not esp)):
esp = p
if root and root.fstype != "ext2":
raise Fail(f"{root.node} is {root.fstype}, but MontaukOS needs ext2; write a fresh image")
if not esp:
raise Fail(f"/dev/{name} has no MontaukOS boot partition (FAT32, label {ESP_LABEL})")
return esp, root, parts
# ==== ext2 ====
def fs_info(rep, node):
p = run(rep, ["dumpe2fs", "-h", node], quiet=True)
info = {}
for line in p.stdout.splitlines():
k, _, v = line.partition(":")
info[k.strip()] = v.strip()
return (int(info.get("Block count", 0)), int(info.get("Block size", 4096)),
set(info.get("Filesystem features", "").split()))
def check_features(rep, node):
_, _, features = fs_info(rep, node)
bad = (features & EXT_INCOMPAT) - SUPPORTED_INCOMPAT
if bad:
raise Fail(f"{node} now has ext2 features MontaukOS cannot mount: {', '.join(sorted(bad))}")
def fsck(rep, node, check_only=False):
if check_only:
p = run(rep, ["e2fsck", "-f", "-n", node], ok=range(0, 256), quiet=True)
if p.returncode != 0:
for line in p.stdout.splitlines()[-20:]:
rep.log(line)
raise Fail(f"e2fsck reports problems on {node} after the update (exit {p.returncode})")
return
rep.log(f"checking {node}")
p = run(rep, ["e2fsck", "-f", "-p", node], ok=range(0, 256), quiet=True)
if p.returncode & ~3:
for line in p.stdout.splitlines()[-20:]:
rep.log(line)
raise Fail(f"{node} has errors e2fsck cannot fix by itself; run `sudo e2fsck -f {node}`")
if p.returncode:
rep.warn(f"e2fsck repaired {node} (it was probably not shut down cleanly)")
def expand_root(rep, name):
"""Grow the root partition to the end of the card, then the volume."""
unmount_disk(rep, name)
esp, root, parts = card_layout(rep, name)
if not root:
raise Fail("this card has no ext2 system partition to expand (ramdisk layout)")
if any(p.start > 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("<I", mbr, 446 + 16 * (root.number - 1) + 12, count)
os.pwrite(fd, bytes(mbr), 0)
os.fsync(fd)
finally:
os.close(fd)
rep.log(f"partition {root.number}: {human(root.size)} -> {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("<<TreeviewSelect>>", 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()