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())