py-pi-bake 0.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pi_bake/__init__.py ADDED
@@ -0,0 +1,49 @@
1
+ """pi-bake — generate flashable, headless Raspberry Pi images.
2
+
3
+ Bake `(board, os, version, hostname, ssh_pubkey, [wifi])` into a
4
+ single `.img.gz` operator dd's to an SD card. Boot the Pi → it
5
+ joins the network → operator can SSH in. No keyboard, no monitor,
6
+ no `setup-alpine`.
7
+
8
+ Public API (also surfaced via the `pi-bake` CLI):
9
+
10
+ from pi_bake import NodeConfig, build, list_boards, list_oses
11
+ out = build(
12
+ board="pi-zero-2-w",
13
+ os_name="alpine",
14
+ version="3.21",
15
+ node=NodeConfig(
16
+ hostname="pi-radio-1",
17
+ ssh_pubkey="ssh-ed25519 AAAA...",
18
+ wifi_ssid="totaldns-lab",
19
+ wifi_psk="secret",
20
+ ),
21
+ out_path="~/sdcards/pi-radio-1.img.gz",
22
+ )
23
+
24
+ Designed to be agnostic of any specific downstream — totaldns,
25
+ home-server projects, anything that wants a flash-and-boot Pi.
26
+ """
27
+ from importlib.metadata import PackageNotFoundError, version as _pkg_version
28
+
29
+ from pi_bake.boards import Board, BOARDS, list_boards
30
+ from pi_bake.config import NodeConfig
31
+ from pi_bake.oses import OSImage, OSES, list_oses, resolve_image
32
+ from pi_bake.bake import build, supports
33
+
34
+ try:
35
+ # Distribution name on PyPI is `py-pi-bake`; importlib.metadata
36
+ # reads it from the installed dist-info, which setuptools_scm
37
+ # populated from the git tag at build time.
38
+ __version__ = _pkg_version("py-pi-bake")
39
+ except PackageNotFoundError:
40
+ # Running from a checkout without `pip install -e .` — fine for
41
+ # one-off tests, just don't claim a real version.
42
+ __version__ = "0.0.0+unknown"
43
+
44
+ __all__ = [
45
+ "Board", "BOARDS", "list_boards",
46
+ "OSImage", "OSES", "list_oses", "resolve_image",
47
+ "NodeConfig",
48
+ "build", "supports",
49
+ ]
pi_bake/alpine.py ADDED
@@ -0,0 +1,375 @@
1
+ """Alpine RPi image baker — no-root, mtools-based.
2
+
3
+ Alpine RPi ships as a tarball you extract onto a FAT32 partition.
4
+ On boot, an apkovl tarball (per-host state overlay) is restored
5
+ into the live filesystem; that's where `/etc/hostname`,
6
+ `/etc/ssh/sshd_config`, etc. come from on subsequent boots.
7
+
8
+ To produce a single flashable `.img.gz`:
9
+ 1. Create an empty FAT32 image of fixed size with `mformat`.
10
+ 2. Extract the upstream Alpine RPi tarball.
11
+ 3. `mcopy` the extracted tree into the image.
12
+ 4. Generate a per-node apkovl.tar.gz (etc/, root/, runlevels).
13
+ 5. `mcopy` the apkovl in.
14
+ 6. gzip → final `.img.gz`.
15
+
16
+ No `losetup`, no root. Requires `mtools` + `dosfstools` on PATH.
17
+
18
+ Sizing: ~400 MB image is enough for the standard Alpine RPi
19
+ tarball (~150 MB extracted) + apkovl + future apk-cache headroom.
20
+ Operator-overridable via `image_size_mb`.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import gzip
25
+ import io
26
+ import logging
27
+ import os
28
+ import shutil
29
+ import stat
30
+ import subprocess
31
+ import tarfile
32
+ import tempfile
33
+ from pathlib import Path
34
+
35
+ from pi_bake.config import NodeConfig
36
+ from pi_bake.download import fetch
37
+
38
+ LOG = logging.getLogger("pi_bake.alpine")
39
+
40
+ DEFAULT_IMAGE_SIZE_MB = 400
41
+
42
+
43
+ def bake(
44
+ *, url: str, node: NodeConfig, out_path: Path,
45
+ image_size_mb: int = DEFAULT_IMAGE_SIZE_MB,
46
+ ) -> Path:
47
+ """Build an Alpine RPi `.img.gz` for `node`. Returns out_path.
48
+
49
+ Steps the operator might want to inspect: each one logs at INFO.
50
+ """
51
+ _require_tools("mformat", "mcopy", "mmd")
52
+
53
+ out_path = Path(out_path).expanduser().resolve()
54
+ out_path.parent.mkdir(parents=True, exist_ok=True)
55
+
56
+ tarball = fetch(url)
57
+
58
+ with tempfile.TemporaryDirectory(prefix="pi-bake-alpine-") as td:
59
+ td = Path(td)
60
+ # 1. Empty FAT32 image.
61
+ img = td / "image.img"
62
+ LOG.info("creating %d MB FAT32 image at %s", image_size_mb, img)
63
+ _create_fat32_image(img, image_size_mb)
64
+
65
+ # 2. Extract the upstream tarball into a tree we can mcopy.
66
+ extracted = td / "extracted"
67
+ extracted.mkdir()
68
+ LOG.info("extracting %s", tarball.name)
69
+ with tarfile.open(tarball, "r:*") as tf:
70
+ # `filter="data"` is Python 3.12+ and applies path-traversal
71
+ # sanitization. Pre-3.12 lacks the kwarg; the official Alpine
72
+ # tarball is trusted upstream content so the older permissive
73
+ # behavior is fine. Try the safer call first, fall back.
74
+ try:
75
+ tf.extractall(extracted, filter="data")
76
+ except TypeError:
77
+ tf.extractall(extracted)
78
+
79
+ # 3. Pour the tree into the FAT32 image.
80
+ LOG.info("mcopy: tarball → image")
81
+ for child in sorted(extracted.iterdir()):
82
+ _mcopy_into(img, child, "/")
83
+
84
+ # 4. Per-node apkovl.tar.gz.
85
+ apkovl_path = td / f"{node.hostname}.apkovl.tar.gz"
86
+ LOG.info("generating apkovl: %s", apkovl_path.name)
87
+ _write_apkovl(apkovl_path, node)
88
+ _mcopy_into(img, apkovl_path, "/")
89
+
90
+ # 5. gzip → out_path.
91
+ LOG.info("compressing → %s", out_path)
92
+ with open(img, "rb") as src, gzip.open(out_path, "wb", compresslevel=6) as dst:
93
+ shutil.copyfileobj(src, dst, length=1 << 20)
94
+
95
+ LOG.info("DONE: %s (%d MB)", out_path, out_path.stat().st_size >> 20)
96
+ return out_path
97
+
98
+
99
+ # --------------------------------------------------------------------------- #
100
+ # FAT32 image helpers (mtools) #
101
+ # --------------------------------------------------------------------------- #
102
+
103
+ def _create_fat32_image(path: Path, size_mb: int) -> None:
104
+ """Create an empty FAT32 image at `path` of exactly `size_mb`
105
+ megabytes. Uses `truncate` + `mformat` (no root)."""
106
+ size_bytes = size_mb * 1024 * 1024
107
+ with open(path, "wb") as f:
108
+ f.truncate(size_bytes)
109
+ # -i for image file, -F for FAT32, -v for volume label.
110
+ subprocess.run(
111
+ ["mformat", "-i", str(path), "-F", "-v", "PI-BAKE", "::"],
112
+ check=True, capture_output=True, text=True,
113
+ )
114
+
115
+
116
+ def _mcopy_into(img: Path, src: Path, dest: str = "/") -> None:
117
+ """`mcopy -s -i <img> <src> ::<dest>`.
118
+
119
+ `dest` is the path inside the FAT image — normalized to start with
120
+ `/`, then prefixed with `::` to form mtools' "image-root-relative"
121
+ syntax. `::/` puts files at the FAT root; `::/apkovl/` inside an
122
+ apkovl subdir, etc. Recursive (`-s`) handles directories.
123
+ """
124
+ if not dest.startswith("/"):
125
+ dest = "/" + dest
126
+ target = f"::{dest}"
127
+ cmd = ["mcopy", "-Q", "-s", "-i", str(img), str(src), target]
128
+ r = subprocess.run(cmd, capture_output=True, text=True)
129
+ if r.returncode != 0:
130
+ raise RuntimeError(
131
+ f"mcopy failed: {' '.join(cmd)}\n--- stderr ---\n{r.stderr}"
132
+ )
133
+
134
+
135
+ # --------------------------------------------------------------------------- #
136
+ # apkovl generation #
137
+ # --------------------------------------------------------------------------- #
138
+
139
+ def _write_apkovl(out: Path, node: NodeConfig) -> None:
140
+ """Build an Alpine apkovl.tar.gz for `node`.
141
+
142
+ Strategy: rely on Alpine RPi's diskless init, which on first boot
143
+ runs `apk add --root $sysroot --no-network` reading packages from
144
+ the overlay's `/etc/apk/world` and pulling apks from the local
145
+ `/media/mmcblk0/apks/` cache that ships in the tarball. So as long
146
+ as everything we want is in the stock cache, no network is needed
147
+ to come up with sshd + DHCP + NTP wired up. No first-boot script,
148
+ no over-the-network apk fetch dance — the package set just IS at
149
+ the end of the first boot.
150
+
151
+ Stock Alpine RPi tarball (verified) ships: openssh-server,
152
+ openssh-server-common-openrc, dhcpcd, dhcpcd-openrc, chrony,
153
+ chrony-openrc, wpa_supplicant, wpa_supplicant-openrc, iw,
154
+ ifupdown-ng-wifi, ca-certificates-bundle. NOT shipped: avahi,
155
+ dbus, linux-firmware-brcm, linux-firmware-intel — those need
156
+ a bake-time fetch (future v0.2 work, see ROADMAP.md).
157
+
158
+ DHCP choice: dhcpcd, not busybox udhcpc. udhcpc 1.37 + Alpine 3.21
159
+ + Pi 5's macb driver hangs with "address family not supported".
160
+ dhcpcd is more reliable across kernels and is what setup-alpine
161
+ selects by default in recent releases.
162
+
163
+ Files we lay down (paths relative to /):
164
+ etc/hostname — node.hostname
165
+ etc/hosts — localhost + hostname entry
166
+ etc/timezone — node.timezone
167
+ etc/ssh/sshd_config — root-by-key only, no passwords
168
+ root/.ssh/authorized_keys — node.all_pubkeys
169
+ etc/network/interfaces — lo only (+ eth0 static path)
170
+ etc/apk/world — packages init will install
171
+ etc/apk/repositories — local cache + upstream main/community
172
+ etc/runlevels/default/sshd — symlink, started at boot
173
+ etc/runlevels/default/dhcpcd — symlink (skipped on static-IP)
174
+ etc/runlevels/default/chronyd — symlink
175
+ etc/runlevels/default/networking — symlink (brings up lo + static eth0)
176
+ etc/wpa_supplicant/wpa_supplicant.conf + runlevel — only when wifi
177
+ """
178
+ members: list[tuple[str, bytes, int, bool]] = []
179
+ # (path, content, mode, is_symlink)
180
+
181
+ # The whole reason the rest of this works. Alpine RPi's /init,
182
+ # when it finds an apkovl, SKIPS the "add default boot services"
183
+ # block (rc_add modloop sysinit, rc_add modules boot, …) UNLESS
184
+ # this marker file is present. Without modloop in sysinit, the
185
+ # squashfs of kernel modules never mounts on /lib/modules, so
186
+ # af_packet, ipv6, almost every needed network driver is absent
187
+ # — and every DHCP client fails with "Address family not
188
+ # supported by protocol" (seen on Pi 5 with both busybox udhcpc
189
+ # and dhcpcd 10.x). The init script deletes this marker after
190
+ # consuming it, so it's truly one-shot.
191
+ members.append(("etc/.default_boot_services", b"", 0o644, False))
192
+
193
+ # lbu (Alpine "local backup") writes a fresh apkovl onto the FAT
194
+ # so the operator's post-boot changes survive reboot. Without
195
+ # LBU_MEDIA set, `lbu commit` and even `lbu status` just print
196
+ # usage. mmcblk0 is the SD card's FAT partition, mounted at
197
+ # /media/mmcblk0 by Alpine RPi init.
198
+ members.append((
199
+ "etc/lbu/lbu.conf",
200
+ b'LBU_MEDIA="mmcblk0"\n',
201
+ 0o644, False,
202
+ ))
203
+
204
+ members.append(("etc/hostname", f"{node.hostname}\n".encode(), 0o644, False))
205
+ members.append((
206
+ "etc/hosts",
207
+ (
208
+ "127.0.0.1 localhost\n"
209
+ f"127.0.1.1 {node.hostname}\n"
210
+ "::1 localhost ip6-localhost ip6-loopback\n"
211
+ "ff02::1 ip6-allnodes\nff02::2 ip6-allrouters\n"
212
+ ).encode(),
213
+ 0o644, False,
214
+ ))
215
+ members.append(("etc/timezone", f"{node.timezone}\n".encode(), 0o644, False))
216
+
217
+ # sshd_config: enable, no passwords, root login by key.
218
+ # Alpine's openssh is built WITHOUT PAM — `UsePAM yes` makes sshd
219
+ # refuse to start ("Bad configuration option: UsePAM"). Don't add
220
+ # it back unless you also switch to a PAM-enabled openssh build.
221
+ # `ChallengeResponseAuthentication` was renamed to
222
+ # `KbdInteractiveAuthentication` in openssh 8.7; 9.9 still parses
223
+ # it but it's redundant when PasswordAuthentication=no.
224
+ sshd_cfg = (
225
+ "PermitRootLogin prohibit-password\n"
226
+ "PasswordAuthentication no\n"
227
+ "KbdInteractiveAuthentication no\n"
228
+ "PrintMotd no\n"
229
+ "AcceptEnv LANG LC_*\n"
230
+ "Subsystem sftp /usr/lib/ssh/sftp-server\n"
231
+ )
232
+ members.append(("etc/ssh/sshd_config", sshd_cfg.encode(), 0o600, False))
233
+
234
+ members.append(("root/.ssh/authorized_keys",
235
+ node.authorized_keys_text().encode(), 0o600, False))
236
+
237
+ # /etc/network/interfaces — lo is always managed by `networking`.
238
+ # For DHCP nodes, eth0 + wlan0 are NOT listed here: dhcpcd runs as
239
+ # a daemon and watches all interfaces, so listing them under
240
+ # `networking` would race with dhcpcd. For static-IP nodes, eth0
241
+ # IS listed (and dhcpcd is dropped from the runlevel entirely —
242
+ # see further down).
243
+ interfaces = "auto lo\niface lo inet loopback\n"
244
+ if node.has_static_ip:
245
+ interfaces += (
246
+ f"\nauto eth0\n"
247
+ f"iface eth0 inet static\n"
248
+ f" address {node.static_address_only}\n"
249
+ f" netmask {node.static_netmask}\n"
250
+ f" gateway {node.gateway_ipv4}\n"
251
+ )
252
+ members.append(("etc/network/interfaces", interfaces.encode(), 0o644, False))
253
+
254
+ # /etc/resolv.conf for static-IP nodes (DHCP fills this via dhcpcd
255
+ # but static doesn't). Default to Cloudflare + Google — operator
256
+ # can replace post-boot if they want their own resolver.
257
+ if node.has_static_ip:
258
+ members.append((
259
+ "etc/resolv.conf",
260
+ b"nameserver 1.1.1.1\nnameserver 8.8.8.8\n",
261
+ 0o644, False,
262
+ ))
263
+
264
+ # /etc/apk/repositories — local FAT cache first (init's `--no-network`
265
+ # path resolves from here), then upstream so post-boot `apk add`
266
+ # works for anything not in the cache. Track the matching version
267
+ # to avoid mixed-release ABI surprises.
268
+ members.append((
269
+ "etc/apk/repositories",
270
+ (
271
+ "/media/mmcblk0/apks\n"
272
+ "http://dl-cdn.alpinelinux.org/alpine/v3.21/main\n"
273
+ "http://dl-cdn.alpinelinux.org/alpine/v3.21/community\n"
274
+ ).encode(),
275
+ 0o644, False,
276
+ ))
277
+
278
+ # /etc/apk/world — the package set Alpine init installs on first
279
+ # boot from the local /media/mmcblk0/apks cache. All listed
280
+ # packages MUST exist in the stock RPi tarball; anything else
281
+ # would need bake-time fetch (deferred — see module docstring).
282
+ world_pkgs = [
283
+ "alpine-base",
284
+ "openssh-server",
285
+ "openssh-server-common-openrc",
286
+ # openssh-server alone has no sftp-server binary, so modern
287
+ # scp (openssh 9.0+ defaults to SFTP protocol) fails with
288
+ # "subsystem request failed". pyinfra also leans on SFTP for
289
+ # file pushes. Ships in stock RPi tarball — free to include.
290
+ "openssh-sftp-server",
291
+ "chrony",
292
+ "chrony-openrc",
293
+ ]
294
+ if not node.has_static_ip:
295
+ world_pkgs += ["dhcpcd", "dhcpcd-openrc"]
296
+ if node.has_wifi:
297
+ world_pkgs += [
298
+ "wpa_supplicant",
299
+ "wpa_supplicant-openrc",
300
+ "iw",
301
+ "ifupdown-ng-wifi",
302
+ ]
303
+ members.append((
304
+ "etc/apk/world",
305
+ ("\n".join(world_pkgs) + "\n").encode(),
306
+ 0o644, False,
307
+ ))
308
+
309
+ if node.has_wifi:
310
+ members.append((
311
+ "etc/wpa_supplicant/wpa_supplicant.conf",
312
+ node.wpa_supplicant_conf().encode(),
313
+ 0o600, False,
314
+ ))
315
+ # Tell wpa_supplicant-openrc which interface to drive. Without
316
+ # this it boots in no-interface mode and never associates.
317
+ members.append((
318
+ "etc/conf.d/wpa_supplicant",
319
+ b'wpa_supplicant_args="-iwlan0"\n',
320
+ 0o644, False,
321
+ ))
322
+
323
+ # /etc/runlevels/default/* — symlinks into /etc/init.d/ enable
324
+ # services at boot. The target init scripts don't exist yet when
325
+ # the apkovl is extracted; they appear when init's `apk add`
326
+ # installs the corresponding `*-openrc` packages from world (a few
327
+ # lines below). By the time the `default` runlevel actually starts,
328
+ # both symlink and target exist.
329
+ runlevel_svcs = ["networking", "sshd", "chronyd"]
330
+ if not node.has_static_ip:
331
+ runlevel_svcs.append("dhcpcd")
332
+ if node.has_wifi:
333
+ runlevel_svcs.append("wpa_supplicant")
334
+ for svc in runlevel_svcs:
335
+ members.append((
336
+ f"etc/runlevels/default/{svc}",
337
+ f"/etc/init.d/{svc}".encode(),
338
+ 0o777, True,
339
+ ))
340
+
341
+ # Pack as tar.gz.
342
+ buf = io.BytesIO()
343
+ with tarfile.open(fileobj=buf, mode="w:gz") as tf:
344
+ for path, content, mode, is_symlink in members:
345
+ ti = tarfile.TarInfo(name=path)
346
+ if is_symlink:
347
+ ti.type = tarfile.SYMTYPE
348
+ ti.linkname = content.decode()
349
+ ti.size = 0
350
+ else:
351
+ ti.size = len(content)
352
+ ti.mode = mode
353
+ ti.uid = 0
354
+ ti.gid = 0
355
+ ti.mtime = 0
356
+ if is_symlink:
357
+ tf.addfile(ti)
358
+ else:
359
+ tf.addfile(ti, io.BytesIO(content))
360
+
361
+ out.write_bytes(buf.getvalue())
362
+
363
+
364
+ # --------------------------------------------------------------------------- #
365
+ # Helpers #
366
+ # --------------------------------------------------------------------------- #
367
+
368
+ def _require_tools(*names: str) -> None:
369
+ missing = [n for n in names if shutil.which(n) is None]
370
+ if missing:
371
+ raise RuntimeError(
372
+ f"missing required tool(s) on PATH: {missing}. "
373
+ f"On Fedora: sudo dnf install mtools dosfstools. "
374
+ f"On Debian/Ubuntu: sudo apt install mtools dosfstools."
375
+ )
pi_bake/bake.py ADDED
@@ -0,0 +1,74 @@
1
+ """Top-level bake dispatcher.
2
+
3
+ Routes `(board, os)` to the right backend module + verifies the
4
+ combo is on the supported edge list before going anywhere near
5
+ the network. The CLI calls this; the Python API also exports it
6
+ via `pi_bake.build()`.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from pathlib import Path
12
+
13
+ from pi_bake.boards import get_board
14
+ from pi_bake.config import NodeConfig
15
+ from pi_bake.oses import get_os, resolve_image
16
+
17
+ LOG = logging.getLogger("pi_bake.bake")
18
+
19
+
20
+ def supports(board: str, os_name: str) -> bool:
21
+ """True iff `os_name` is supported on `board` per the catalog."""
22
+ try:
23
+ b = get_board(board)
24
+ o = get_os(os_name)
25
+ except KeyError:
26
+ return False
27
+ return b.name in o.supports_boards
28
+
29
+
30
+ def build(
31
+ *, board: str, os_name: str, version: str | None,
32
+ node: NodeConfig, out_path: str | Path,
33
+ image_size_mb: int | None = None,
34
+ ) -> Path:
35
+ """Build an `.img.gz` for `(board, os, version, node)`.
36
+
37
+ `version=None` → use the OS's latest known-good.
38
+ `image_size_mb=None` → backend's default.
39
+
40
+ Raises:
41
+ - KeyError on unknown board/os.
42
+ - ValueError if the (board, os) combo isn't supported.
43
+ - NotImplementedError for backends not in v0.1.
44
+ """
45
+ b = get_board(board)
46
+ o = get_os(os_name)
47
+ if b.name not in o.supports_boards:
48
+ raise ValueError(
49
+ f"{o.pretty} ({o.name}) doesn't support {b.pretty} ({b.name}). "
50
+ f"Supported boards for {o.name}: {sorted(o.supports_boards)}"
51
+ )
52
+
53
+ o, resolved_version, url = resolve_image(o.name, version, b.arch)
54
+
55
+ LOG.info(
56
+ "baking %s on %s using %s %s (url=%s)",
57
+ node.hostname, b.name, o.name, resolved_version, url,
58
+ )
59
+
60
+ backend = o.bake_backend
61
+ if backend == "alpine":
62
+ from pi_bake import alpine
63
+ kwargs = {"url": url, "node": node, "out_path": Path(out_path)}
64
+ if image_size_mb is not None:
65
+ kwargs["image_size_mb"] = image_size_mb
66
+ return alpine.bake(**kwargs)
67
+ elif backend == "raspbian":
68
+ from pi_bake import raspbian
69
+ return raspbian.bake(
70
+ url=url, node=node, out_path=Path(out_path),
71
+ image_size_mb=image_size_mb or 0,
72
+ )
73
+ else:
74
+ raise RuntimeError(f"unknown bake backend {backend!r} in OS catalog")
pi_bake/boards.py ADDED
@@ -0,0 +1,77 @@
1
+ """Raspberry Pi board catalog.
2
+
3
+ One `Board` per officially-supported model. Keep this list short +
4
+ honest — a board belongs here when at least one OS we know how to
5
+ bake images for ACTUALLY runs on it. "Supported" lives on the
6
+ (board, os) edges in `oses.py`, not on the board itself.
7
+
8
+ `arch` matches what the upstream OS image archives use, so the
9
+ download URL templates can interpolate it directly.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Board:
18
+ name: str # short slug, e.g. "pi-5"
19
+ pretty: str # human label, e.g. "Raspberry Pi 5"
20
+ arch: str # "aarch64" | "armhf" — what the OS image label uses
21
+ notes: str = "" # quirks worth flagging to the operator
22
+
23
+ def __str__(self) -> str:
24
+ return f"{self.name} — {self.pretty} ({self.arch})"
25
+
26
+
27
+ # Order: most-current first. CLI `list-boards` renders in this order.
28
+ BOARDS: tuple[Board, ...] = (
29
+ Board(
30
+ "pi-5", "Raspberry Pi 5", "aarch64",
31
+ notes="64-bit only. Alpine 3.21+ has experimental support; "
32
+ "Raspberry Pi OS Lite (Debian Bookworm) is the safe default.",
33
+ ),
34
+ Board(
35
+ "pi-4", "Raspberry Pi 4 Model B", "aarch64",
36
+ notes="64-bit recommended; 32-bit still works for the ≤2 GB models.",
37
+ ),
38
+ Board(
39
+ "pi-zero-2-w", "Raspberry Pi Zero 2 W", "aarch64",
40
+ notes="Quad-core Cortex-A53; the snappy small WiFi-only Pi. "
41
+ "Alpine aarch64 is the typical choice for IoT / station roles.",
42
+ ),
43
+ Board(
44
+ "pi-3", "Raspberry Pi 3 Model B+", "aarch64",
45
+ notes="Mostly legacy; Pi 4 / Pi 5 are the buy-it-today choices.",
46
+ ),
47
+ Board(
48
+ "pi-zero-w", "Raspberry Pi Zero W (original)", "armhf",
49
+ notes="32-bit ARMv6 ONLY. Alpine `armhf` image. Pi Zero 2 W is the "
50
+ "successor for new deployments.",
51
+ ),
52
+ )
53
+
54
+ _BY_NAME: dict[str, Board] = {b.name: b for b in BOARDS}
55
+
56
+
57
+ def list_boards() -> list[Board]:
58
+ """Return every supported board, in display order."""
59
+ return list(BOARDS)
60
+
61
+
62
+ def get_board(name: str) -> Board:
63
+ """Look up a board by slug. Raises KeyError on miss.
64
+
65
+ Aliases: `pi5` / `pi-5` / `rpi5` all resolve to `pi-5`.
66
+ """
67
+ norm = name.strip().lower().replace("rpi", "pi").replace(" ", "-")
68
+ # Common shorthand: "pi5" → "pi-5".
69
+ if norm not in _BY_NAME and "-" not in norm and norm.startswith("pi"):
70
+ try_dashed = f"pi-{norm[2:]}"
71
+ if try_dashed in _BY_NAME:
72
+ return _BY_NAME[try_dashed]
73
+ if norm in _BY_NAME:
74
+ return _BY_NAME[norm]
75
+ raise KeyError(
76
+ f"unknown board {name!r}; known: {sorted(_BY_NAME)}"
77
+ )