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 +49 -0
- pi_bake/alpine.py +375 -0
- pi_bake/bake.py +74 -0
- pi_bake/boards.py +77 -0
- pi_bake/cli.py +243 -0
- pi_bake/config.py +149 -0
- pi_bake/download.py +107 -0
- pi_bake/oses.py +167 -0
- pi_bake/raspbian.py +49 -0
- py_pi_bake-0.0.4.dist-info/METADATA +177 -0
- py_pi_bake-0.0.4.dist-info/RECORD +15 -0
- py_pi_bake-0.0.4.dist-info/WHEEL +5 -0
- py_pi_bake-0.0.4.dist-info/entry_points.txt +2 -0
- py_pi_bake-0.0.4.dist-info/licenses/LICENSE +21 -0
- py_pi_bake-0.0.4.dist-info/top_level.txt +1 -0
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
|
+
)
|