Mercurial > code > home > repos > infra
comparison pi-setup/setup_pi.py @ 279:1cb4aeec8fc6
pi_setup code to prepare a pi for netboot
author | drewp@bigasterisk.com |
---|---|
date | Sun, 14 Apr 2024 20:54:35 -0700 |
parents | |
children | 957eb07e06e6 |
comparison
equal
deleted
inserted
replaced
278:4e424a144183 | 279:1cb4aeec8fc6 |
---|---|
1 import asyncio | |
2 import logging | |
3 import re | |
4 import sys | |
5 import time | |
6 from pathlib import Path | |
7 from functools import wraps | |
8 from runner import get_output, iscsi_login, mount, run, sshfs | |
9 | |
10 logging.basicConfig(level=logging.INFO, format='%(asctime)s.%(msecs)03d %(levelname)s %(filename) 12s:%(lineno)d %(message)s', datefmt='%H:%M:%S') | |
11 log = logging.getLogger() | |
12 | |
13 WORK = Path('/tmp/pi-setup') | |
14 LITE_PREFIX = '2024-03-15-raspios-bookworm' | |
15 | |
16 # These come from fdisk -l on the img: | |
17 IMG_BOOT_OFFSET = 512 * 8192 | |
18 IMG_ROOT_OFFSET = 512 * 1056768 | |
19 | |
20 TFTP_SPEC = 'root@pipe:/opt/dnsmasq/tftp/' | |
21 | |
22 | |
23 def step(func): | |
24 name = func.__name__ | |
25 | |
26 @wraps(func) | |
27 async def wrapper(*a, **kw): | |
28 print("", file=sys.stderr) | |
29 log.info(f'👣 step {name}') | |
30 t1 = time.time() | |
31 ret = await func(*a, **kw) | |
32 dt = time.time() - t1 | |
33 log.info(f' -> step {name} took {dt:3} seconds') | |
34 return ret | |
35 | |
36 return wrapper | |
37 | |
38 | |
39 @step | |
40 async def init_work_dir(): | |
41 WORK.mkdir(exist_ok=True) | |
42 await run( | |
43 'wget', '--continue', '--no-verbose', '-O', WORK / 'raspios.img.xz', | |
44 f'https://downloads.raspberrypi.org/raspios_lite_arm64/images/raspios_lite_arm64-2024-03-15/{LITE_PREFIX}-arm64-lite.img.xz' | |
45 ) | |
46 | |
47 | |
48 @step | |
49 async def unpack_fresh_img_copy(): | |
50 await run('xz', '-dkf', WORK / 'raspios.img.xz') | |
51 await run('qemu-img', 'resize', ['-f', 'raw'], WORK / 'raspios.img', '4G') | |
52 | |
53 | |
54 @step | |
55 async def extract_boot_files(): | |
56 async with mount(WORK, WORK / 'raspios.img', IMG_BOOT_OFFSET) as img_boot: | |
57 await run('cp', img_boot / 'bcm2710-rpi-3-b-plus.dtb', WORK) | |
58 await run('cp', img_boot / 'kernel8.img', WORK) | |
59 | |
60 | |
61 @step | |
62 async def setup_ssh_login(): | |
63 async with mount(WORK, WORK / 'raspios.img', IMG_ROOT_OFFSET) as img_root: | |
64 root_pub_key = Path('/root/.ssh/id_ecdsa.pub').read_text() | |
65 (img_root / 'root/.ssh/authorized_keys').write_text(root_pub_key) | |
66 # (img_root / 'etc/iscsi/initiatorname.iscsi').write_text(f"InitiatorName=iqn.2024-03.com.bigasterisk:{PI_HOSTNAME}\n") | |
67 | |
68 async with mount(WORK, WORK / 'raspios.img', IMG_BOOT_OFFSET) as img_boot: | |
69 await run('touch', img_boot / 'ssh') | |
70 | |
71 | |
72 @step | |
73 async def qemu(): | |
74 await run( | |
75 'qemu-system-aarch64', | |
76 ['-machine', 'raspi3b'], | |
77 ['-cpu', 'cortex-a72'], | |
78 ['-nographic'], | |
79 ['-dtb', WORK / 'bcm2710-rpi-3-b-plus.dtb'], | |
80 ['-m', '1G'], | |
81 ['-smp', '4'], | |
82 ['-kernel', WORK / 'kernel8.img'], | |
83 ['-sd', WORK / 'raspios.img'], | |
84 ['-append', "rw earlyprintk loglevel=8 console=ttyAMA0,115200 dwc_otg.lpm_enable=0 root=/dev/mmcblk0p2 rootdelay=1"], | |
85 ['-device', 'usb-net,netdev=net0'], | |
86 ['-netdev', 'user,id=net0,hostfwd=tcp::2222-:22'], | |
87 ) | |
88 | |
89 | |
90 @step | |
91 async def setup_pi_in_emulator(): | |
92 src_path = Path(__file__).parent / 'on_pi_setup.sh' | |
93 path_on_pi = '/tmp/on_pi_setup.sh' | |
94 | |
95 ssh_opts = [ | |
96 '-oPort=2222', | |
97 '-oConnectTimeout=2', | |
98 '-oBatchMode=yes', | |
99 '-oNoHostAuthenticationForLocalHost=yes', | |
100 ] | |
101 give_up = time.time() + 60 | |
102 final_err = ValueError("timed out") | |
103 while time.time() < give_up: | |
104 try: | |
105 await run('scp', ssh_opts, src_path, f'root@localhost:{path_on_pi}') | |
106 except ValueError as err: | |
107 final_err = err | |
108 log.info('waiting for qemu to boot...') | |
109 await asyncio.sleep(8) | |
110 continue | |
111 break | |
112 else: | |
113 raise final_err | |
114 | |
115 cmd_on_pi = ['sh', path_on_pi, PI_HOSTNAME] | |
116 await run('ssh', ssh_opts, 'root@localhost', *cmd_on_pi) | |
117 | |
118 | |
119 async def _get_iscsi_device(better_be_the_iscsi_device='sde') -> Path: | |
120 # don't screw up- this device is about to get formatted! | |
121 dev_path = Path(f'/dev/{better_be_the_iscsi_device}') | |
122 | |
123 iscsi = await get_output('iscsiadm', '-m', 'session', '-P3') | |
124 for m in re.findall(r'Attached scsi disk (\S+)', iscsi): | |
125 if f'/dev/{m}' != str(dev_path): | |
126 raise ValueError(f'surprised by attached iscsi disk {m!r} (try `iscsiadm -m node --logoutall=all`)') | |
127 | |
128 fdisk = await get_output('fdisk', '-l', dev_path) | |
129 for m in re.findall(r'Disk model: (\S+)', fdisk): | |
130 if m != 'VIRTUAL-DISK': | |
131 raise ValueError(f'surprised that {dev_path} is model {m!r} instead of "VIRTUAL-DISK"') | |
132 | |
133 return dev_path | |
134 | |
135 | |
136 @step | |
137 async def fs_to_iscsi(): | |
138 async with mount(WORK, WORK / 'raspios.img', IMG_ROOT_OFFSET) as img_root: | |
139 async with iscsi_login('-m', 'node', '-T', f'iqn.2024-03.com.bigasterisk:{PI_HOSTNAME}.target', '-p', '10.2.0.133'): | |
140 dev = await _get_iscsi_device() | |
141 await run('mkfs.ext4', '-F', dev) | |
142 async with mount(WORK, dev, 0) as iscsi_root: | |
143 await run( | |
144 'rsync', | |
145 '-r', # recurs | |
146 '-l', # symlinks as symlinks | |
147 '-p', # perms | |
148 '-t', # mtimes | |
149 '-g', # group | |
150 '-o', # owner | |
151 '-D', # devices + specials | |
152 '-H', # hard links | |
153 ['--exclude', '/boot'], | |
154 ['--exclude', '/dev'], | |
155 ['--exclude', '/mnt'], | |
156 ['--exclude', '/proc'], | |
157 ['--exclude', '/sys'], | |
158 str(img_root) + '/', | |
159 str(iscsi_root) + '/', | |
160 ) | |
161 for d in [ | |
162 'boot', | |
163 'dev', | |
164 'mnt', | |
165 'proc', | |
166 'sys', | |
167 ]: | |
168 (iscsi_root / d).mkdir() | |
169 (iscsi_root / 'etc/fstab').write_text(''' | |
170 proc /proc proc defaults 0 0 | |
171 /dev/sda / ext4 defaults,noatime 0 1 | |
172 ''') | |
173 # don't open cursesui for making first user | |
174 (iscsi_root / 'etc/systemd/system/multi-user.target.wants/userconfig.service').unlink() | |
175 log.info("there may be a delay here for flushing all the new files") | |
176 | |
177 | |
178 @step | |
179 async def setup_tftp(): | |
180 async with sshfs(WORK, TFTP_SPEC) as tftp: | |
181 tftp_host_dir = tftp / f'{PI_HOSTNAME}-boot' | |
182 async with mount(WORK, WORK / 'raspios.img', IMG_ROOT_OFFSET) as img_root: | |
183 await run( | |
184 'rsync', | |
185 '-r', # recurs | |
186 '-l', # symlinks as symlinks | |
187 '-p', # perms | |
188 #'-t', # mtimes | |
189 '-g', # group | |
190 '-o', # owner | |
191 '-D', # devices + specials | |
192 #'-H', # hard links | |
193 '--delete', | |
194 str(img_root) + '/boot/', | |
195 str(tftp_host_dir) + '/', | |
196 ) | |
197 async with mount(WORK, WORK / 'raspios.img', IMG_BOOT_OFFSET) as img_boot: | |
198 await run( | |
199 'rsync', | |
200 '-r', # recurs | |
201 '-l', # symlinks as symlinks | |
202 '-p', # perms | |
203 '-t', # mtimes | |
204 '-g', # group | |
205 '-o', # owner | |
206 '-D', # devices + specials | |
207 '-H', # hard links | |
208 # (no delete) | |
209 str(img_boot) + '/', | |
210 str(tftp_host_dir) + '/firmware/', | |
211 ) | |
212 await run('ln', '-sf', f'{PI_HOSTNAME}-boot/firmware/', str(tftp / PI_SERIAL)) | |
213 kernel_cmdline = [ | |
214 "console=serial0,115200", | |
215 "console=tty1", | |
216 f"ip=::::{PI_HOSTNAME}:eth0:dhcp", | |
217 "root=/dev/sda", | |
218 f"ISCSI_INITIATOR=iqn.2024-03.com.bigasterisk:{PI_HOSTNAME}.initiator", | |
219 f"ISCSI_TARGET_NAME=iqn.2024-03.com.bigasterisk:{PI_HOSTNAME}.target", | |
220 "ISCSI_TARGET_IP=10.2.0.133", | |
221 "ISCSI_TARGET_PORT=3260", | |
222 "rw", | |
223 "rootfstype=ext4", | |
224 "fsck.repair=yes", | |
225 "rootwait", | |
226 "cgroup_enable=cpuset", | |
227 "cgroup_memory=1", | |
228 "cgroup_enable=memory", | |
229 ] | |
230 (tftp_host_dir/'firmware/cmdline.txt').write_text(' '.join(kernel_cmdline)) | |
231 | |
232 | |
233 async def main(): | |
234 global PI_HOSTNAME, PI_SERIAL | |
235 | |
236 if sys.argv[1:] == ['--init']: | |
237 await init_work_dir() | |
238 else: | |
239 PI_HOSTNAME, PI_SERIAL = sys.argv[1:] | |
240 # todo: dd and add iscsi volume (handled by pyinfra) | |
241 # dd if=/dev/zero of={out} count=0 bs=1 seek=5G conv=excl || true | |
242 # edit /etc/tgt/conf.d/{pi_hostname}.conf etc | |
243 | |
244 await unpack_fresh_img_copy() | |
245 await extract_boot_files() | |
246 await setup_ssh_login() | |
247 | |
248 qemu_task = asyncio.create_task(qemu()) | |
249 await asyncio.sleep(20) # inital qemu startup delay | |
250 await setup_pi_in_emulator() # finishes with a poweroff | |
251 log.info('waiting for qemu to exit') | |
252 await qemu_task | |
253 | |
254 await fs_to_iscsi() | |
255 await setup_tftp() | |
256 | |
257 log.info(f'🎉 {PI_HOSTNAME} is ready for net boot') | |
258 | |
259 | |
260 asyncio.run(main()) |