Mercurial > code > home > repos > infra
changeset 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 | 4e424a144183 |
children | 5c5c314051c5 |
files | pi-setup/on_pi_setup.sh pi-setup/runner.py pi-setup/setup_pi.py pi-setup/tasks.py |
diffstat | 4 files changed, 424 insertions(+), 0 deletions(-) [+] |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/pi-setup/on_pi_setup.sh Sun Apr 14 20:54:35 2024 -0700 @@ -0,0 +1,41 @@ + +HOSTNAME=$1 +HEADER="🍓🍓 on_pi_setup: " + +echo ${HEADER} mount "(1)" +mount | grep mmcblk + +#echo ${HEADER} mount p1 to /boot/firmware +#mount /dev/mmcblk0p1 /boot/firmware + +echo ${HEADER} mount "(2)" +mount | grep mmcblk + +echo ${HEADER} set hostname +hostnamectl set-hostname ${HOSTNAME} +perl -pi -e 's/raspberrypi/'${HOSTNAME}'/' /etc/hosts + +echo ${HEADER} allow root@ditto +echo "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBIh/S0cInbrzh7bM9faZrp9Zc0ndn3aKHFjNngLvhVNolH/nDMu8swmvgvFtlKPv3mlxMjkaDrNpcaGbi0zlpE4= root@ditto" >> /root/.ssh/authorized_keys + +echo ${HEADER} get iscsi +apt-get install -y open-iscsi + +echo ${HEADER} final initramfs +echo "ISCSI_INITIATOR=iqn.2024-03.com.bigasterisk:${HOSTNAME}.initiator" > /etc/iscsi/iscsi.initramfs +echo "ISCSI_TARGET_NAME=iqn.2024-03.com.bigasterisk:${HOSTNAME}.target" >> /etc/iscsi/iscsi.initramfs +echo 'ISCSI_TARGET_IP=10.2.0.133' >> /etc/iscsi/iscsi.initramfs +echo "InitiatorName=iqn.2024-03.com.bigasterisk:${HOSTNAME}" > /etc/iscsi/initiatorname.iscsi +update-initramfs -v -k $(uname -r) -c + +echo ${HEADER} fdisk +fdisk -l /dev/mmcblk0 +echo ${HEADER} mount "(end)" +mount | grep mmcblk +echo ${HEADER} /boot +ls -ltr /boot +echo ${HEADER} / +ls -ltr / + +echo ${HEADER} poweroff +poweroff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/pi-setup/runner.py Sun Apr 14 20:54:35 2024 -0700 @@ -0,0 +1,108 @@ +import asyncio +import itertools +import logging +import shlex +import time +from contextlib import asynccontextmanager, contextmanager +from pathlib import Path +from typing import Any, AsyncGenerator, Generator, Sequence, cast + +import more_itertools +import psutil + +log = logging.getLogger() + +# This is the type of arg we pass to create_subprocess_exec. +RunArgType = str | Path + +# This is what you can call run or get_output with, passing sublists of args for +# clarity. +ArgType = str | Path | Sequence[str | Path] + + +def _flatten_run_args(args: tuple[ArgType, ...]) -> tuple[RunArgType]: + return tuple(more_itertools.collapse(args)) + + +async def run(program: str, *args: ArgType, _stdin: str | None = None, _stdout: int | None = None): + run_args = _flatten_run_args(args) + log.info(f'Running {program} {shlex.join(map(str,run_args))}') + + proc = await asyncio.create_subprocess_exec( + program, + *run_args, + stdin=(asyncio.subprocess.PIPE if _stdin is not None else None), + stdout=_stdout, + ) + + async def log_busy_cpu(pid, secs=3): + pr = psutil.Process(pid) + while True: + pct = pr.cpu_percent() + if pct > 5: + bar = '=' * int(pct / 400 * 50) + log.info(f"{program} cpu {pct:5.1f} {bar}") + await asyncio.sleep(secs) + + busy = asyncio.create_task(log_busy_cpu(proc.pid)) + + out, _ = await proc.communicate(_stdin.encode() if _stdin is not None else None) + busy.cancel() + if proc.returncode != 0: + raise ValueError(f'{program} returned {proc.returncode}') + return out.decode() if _stdout is not None else None + + +async def get_output(program: str, *args: ArgType, stdin: None | str = None) -> str: + out = await run(program, *args, _stdin=stdin, _stdout=asyncio.subprocess.PIPE) + log.info(f" -> returned {out!r}") + return cast(str, out) + + +_mount_count = itertools.count() + + +@contextmanager +def _new_mount_point(dir: Path) -> Generator[Path, Any, Any]: + p = dir / f'mount{next(_mount_count)}' + p.mkdir() + try: + yield p + finally: + p.rmdir() + + +@asynccontextmanager +async def mount(work_dir: Path, src: Path, src_offset: int) -> AsyncGenerator[Path, Any]: + with _new_mount_point(work_dir) as mount_point: + args = [] + if not str(src).startswith('/dev/'): + args = ['-o', f'loop,offset={src_offset}'] + await run('mount', args, src, mount_point) + try: + yield mount_point + finally: + try: + await run('umount', mount_point) + except Exception: + time.sleep(.5) + await run('umount', mount_point) + + +@asynccontextmanager +async def sshfs(work_dir: Path, ssh_path: str) -> AsyncGenerator[Path, Any]: + with _new_mount_point(work_dir) as mount_point: + await run('sshfs', ssh_path, mount_point) + try: + yield mount_point + finally: + await run('umount', mount_point) + + +@asynccontextmanager +async def iscsi_login(*args): + await run('iscsiadm', *(list(args) + ['--login'])) + try: + yield + finally: + await run('iscsiadm', *(list(args) + ['--logout']))
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/pi-setup/setup_pi.py Sun Apr 14 20:54:35 2024 -0700 @@ -0,0 +1,260 @@ +import asyncio +import logging +import re +import sys +import time +from pathlib import Path +from functools import wraps +from runner import get_output, iscsi_login, mount, run, sshfs + +logging.basicConfig(level=logging.INFO, format='%(asctime)s.%(msecs)03d %(levelname)s %(filename) 12s:%(lineno)d %(message)s', datefmt='%H:%M:%S') +log = logging.getLogger() + +WORK = Path('/tmp/pi-setup') +LITE_PREFIX = '2024-03-15-raspios-bookworm' + +# These come from fdisk -l on the img: +IMG_BOOT_OFFSET = 512 * 8192 +IMG_ROOT_OFFSET = 512 * 1056768 + +TFTP_SPEC = 'root@pipe:/opt/dnsmasq/tftp/' + + +def step(func): + name = func.__name__ + + @wraps(func) + async def wrapper(*a, **kw): + print("", file=sys.stderr) + log.info(f'👣 step {name}') + t1 = time.time() + ret = await func(*a, **kw) + dt = time.time() - t1 + log.info(f' -> step {name} took {dt:3} seconds') + return ret + + return wrapper + + +@step +async def init_work_dir(): + WORK.mkdir(exist_ok=True) + await run( + 'wget', '--continue', '--no-verbose', '-O', WORK / 'raspios.img.xz', + f'https://downloads.raspberrypi.org/raspios_lite_arm64/images/raspios_lite_arm64-2024-03-15/{LITE_PREFIX}-arm64-lite.img.xz' + ) + + +@step +async def unpack_fresh_img_copy(): + await run('xz', '-dkf', WORK / 'raspios.img.xz') + await run('qemu-img', 'resize', ['-f', 'raw'], WORK / 'raspios.img', '4G') + + +@step +async def extract_boot_files(): + async with mount(WORK, WORK / 'raspios.img', IMG_BOOT_OFFSET) as img_boot: + await run('cp', img_boot / 'bcm2710-rpi-3-b-plus.dtb', WORK) + await run('cp', img_boot / 'kernel8.img', WORK) + + +@step +async def setup_ssh_login(): + async with mount(WORK, WORK / 'raspios.img', IMG_ROOT_OFFSET) as img_root: + root_pub_key = Path('/root/.ssh/id_ecdsa.pub').read_text() + (img_root / 'root/.ssh/authorized_keys').write_text(root_pub_key) + # (img_root / 'etc/iscsi/initiatorname.iscsi').write_text(f"InitiatorName=iqn.2024-03.com.bigasterisk:{PI_HOSTNAME}\n") + + async with mount(WORK, WORK / 'raspios.img', IMG_BOOT_OFFSET) as img_boot: + await run('touch', img_boot / 'ssh') + + +@step +async def qemu(): + await run( + 'qemu-system-aarch64', + ['-machine', 'raspi3b'], + ['-cpu', 'cortex-a72'], + ['-nographic'], + ['-dtb', WORK / 'bcm2710-rpi-3-b-plus.dtb'], + ['-m', '1G'], + ['-smp', '4'], + ['-kernel', WORK / 'kernel8.img'], + ['-sd', WORK / 'raspios.img'], + ['-append', "rw earlyprintk loglevel=8 console=ttyAMA0,115200 dwc_otg.lpm_enable=0 root=/dev/mmcblk0p2 rootdelay=1"], + ['-device', 'usb-net,netdev=net0'], + ['-netdev', 'user,id=net0,hostfwd=tcp::2222-:22'], + ) + + +@step +async def setup_pi_in_emulator(): + src_path = Path(__file__).parent / 'on_pi_setup.sh' + path_on_pi = '/tmp/on_pi_setup.sh' + + ssh_opts = [ + '-oPort=2222', + '-oConnectTimeout=2', + '-oBatchMode=yes', + '-oNoHostAuthenticationForLocalHost=yes', + ] + give_up = time.time() + 60 + final_err = ValueError("timed out") + while time.time() < give_up: + try: + await run('scp', ssh_opts, src_path, f'root@localhost:{path_on_pi}') + except ValueError as err: + final_err = err + log.info('waiting for qemu to boot...') + await asyncio.sleep(8) + continue + break + else: + raise final_err + + cmd_on_pi = ['sh', path_on_pi, PI_HOSTNAME] + await run('ssh', ssh_opts, 'root@localhost', *cmd_on_pi) + + +async def _get_iscsi_device(better_be_the_iscsi_device='sde') -> Path: + # don't screw up- this device is about to get formatted! + dev_path = Path(f'/dev/{better_be_the_iscsi_device}') + + iscsi = await get_output('iscsiadm', '-m', 'session', '-P3') + for m in re.findall(r'Attached scsi disk (\S+)', iscsi): + if f'/dev/{m}' != str(dev_path): + raise ValueError(f'surprised by attached iscsi disk {m!r} (try `iscsiadm -m node --logoutall=all`)') + + fdisk = await get_output('fdisk', '-l', dev_path) + for m in re.findall(r'Disk model: (\S+)', fdisk): + if m != 'VIRTUAL-DISK': + raise ValueError(f'surprised that {dev_path} is model {m!r} instead of "VIRTUAL-DISK"') + + return dev_path + + +@step +async def fs_to_iscsi(): + async with mount(WORK, WORK / 'raspios.img', IMG_ROOT_OFFSET) as img_root: + async with iscsi_login('-m', 'node', '-T', f'iqn.2024-03.com.bigasterisk:{PI_HOSTNAME}.target', '-p', '10.2.0.133'): + dev = await _get_iscsi_device() + await run('mkfs.ext4', '-F', dev) + async with mount(WORK, dev, 0) as iscsi_root: + await run( + 'rsync', + '-r', # recurs + '-l', # symlinks as symlinks + '-p', # perms + '-t', # mtimes + '-g', # group + '-o', # owner + '-D', # devices + specials + '-H', # hard links + ['--exclude', '/boot'], + ['--exclude', '/dev'], + ['--exclude', '/mnt'], + ['--exclude', '/proc'], + ['--exclude', '/sys'], + str(img_root) + '/', + str(iscsi_root) + '/', + ) + for d in [ + 'boot', + 'dev', + 'mnt', + 'proc', + 'sys', + ]: + (iscsi_root / d).mkdir() + (iscsi_root / 'etc/fstab').write_text(''' +proc /proc proc defaults 0 0 +/dev/sda / ext4 defaults,noatime 0 1 +''') + # don't open cursesui for making first user + (iscsi_root / 'etc/systemd/system/multi-user.target.wants/userconfig.service').unlink() + log.info("there may be a delay here for flushing all the new files") + + +@step +async def setup_tftp(): + async with sshfs(WORK, TFTP_SPEC) as tftp: + tftp_host_dir = tftp / f'{PI_HOSTNAME}-boot' + async with mount(WORK, WORK / 'raspios.img', IMG_ROOT_OFFSET) as img_root: + await run( + 'rsync', + '-r', # recurs + '-l', # symlinks as symlinks + '-p', # perms + #'-t', # mtimes + '-g', # group + '-o', # owner + '-D', # devices + specials + #'-H', # hard links + '--delete', + str(img_root) + '/boot/', + str(tftp_host_dir) + '/', + ) + async with mount(WORK, WORK / 'raspios.img', IMG_BOOT_OFFSET) as img_boot: + await run( + 'rsync', + '-r', # recurs + '-l', # symlinks as symlinks + '-p', # perms + '-t', # mtimes + '-g', # group + '-o', # owner + '-D', # devices + specials + '-H', # hard links + # (no delete) + str(img_boot) + '/', + str(tftp_host_dir) + '/firmware/', + ) + await run('ln', '-sf', f'{PI_HOSTNAME}-boot/firmware/', str(tftp / PI_SERIAL)) + kernel_cmdline = [ + "console=serial0,115200", + "console=tty1", + f"ip=::::{PI_HOSTNAME}:eth0:dhcp", + "root=/dev/sda", + f"ISCSI_INITIATOR=iqn.2024-03.com.bigasterisk:{PI_HOSTNAME}.initiator", + f"ISCSI_TARGET_NAME=iqn.2024-03.com.bigasterisk:{PI_HOSTNAME}.target", + "ISCSI_TARGET_IP=10.2.0.133", + "ISCSI_TARGET_PORT=3260", + "rw", + "rootfstype=ext4", + "fsck.repair=yes", + "rootwait", + "cgroup_enable=cpuset", + "cgroup_memory=1", + "cgroup_enable=memory", + ] + (tftp_host_dir/'firmware/cmdline.txt').write_text(' '.join(kernel_cmdline)) + + +async def main(): + global PI_HOSTNAME, PI_SERIAL + + if sys.argv[1:] == ['--init']: + await init_work_dir() + else: + PI_HOSTNAME, PI_SERIAL = sys.argv[1:] + # todo: dd and add iscsi volume (handled by pyinfra) + # dd if=/dev/zero of={out} count=0 bs=1 seek=5G conv=excl || true + # edit /etc/tgt/conf.d/{pi_hostname}.conf etc + + await unpack_fresh_img_copy() + await extract_boot_files() + await setup_ssh_login() + + qemu_task = asyncio.create_task(qemu()) + await asyncio.sleep(20) # inital qemu startup delay + await setup_pi_in_emulator() # finishes with a poweroff + log.info('waiting for qemu to exit') + await qemu_task + + await fs_to_iscsi() + await setup_tftp() + + log.info(f'🎉 {PI_HOSTNAME} is ready for net boot') + + +asyncio.run(main())
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/pi-setup/tasks.py Sun Apr 14 20:54:35 2024 -0700 @@ -0,0 +1,15 @@ +from invoke import task + +cmd = ''' +HOME=/root +export HOME +eval `keychain --quiet --eval id_ecdsa` +''' + +@task +def init_workspace(ctx): + ctx.run(cmd + 'pdm run -p .. setup_pi.py --init', pty=True) + +@task +def setup_pi(ctx, hostname, serial): + ctx.run(cmd + f'pdm run -p .. setup_pi.py {hostname} {serial}', pty=True) \ No newline at end of file