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