From 917c743f7dd96cebff8d41aa554c9d6f1a5b1762 Mon Sep 17 00:00:00 2001 From: Sonny Piers Date: Wed, 3 Jun 2026 13:15:29 +0200 Subject: [PATCH 1/5] os/image: Use systemd-repart to grow DATA on first boot --- os/image/README.md | 5 +++++ os/image/planktoscope.js | 30 ++++++++++++++++++++++++------ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/os/image/README.md b/os/image/README.md index 8af3c7182..553c0cc59 100644 --- a/os/image/README.md +++ b/os/image/README.md @@ -54,6 +54,11 @@ Congratulations, the slot is running PlanktoScope OS. ## How does it work +Files on `FIRMWARE_A` and `FIRMWARE_B` must be strictly identical. +Files on `ROOT_A` and `ROOT_B` must be strictly identitical. + +We use the Raspberry Pi firmware and bootloader to dynamically switch between A and B. + ### Partition table The partition table is as such: diff --git a/os/image/planktoscope.js b/os/image/planktoscope.js index 21988e6da..b67f81fc5 100644 --- a/os/image/planktoscope.js +++ b/os/image/planktoscope.js @@ -58,6 +58,7 @@ export async function updateMountpoints(device, rpios_partitions) { await setup_fstab(partitions) await setup_autoboot(partitions) await setup_machineid(partitions) + await setup_repart(partitions) } async function createPartitionTable(device) { @@ -93,8 +94,10 @@ async function createPartitionTable(device) { } // "DATA" + // will be expanded by systemd-repart + // see setup_repart partn++ - await $`sgdisk --new=${partn}:0:0 --typecode=${partn}:8300 -A ${partn}:set:0 -A ${partn}:set:1 -A ${partn}:set:62 -A ${partn}:set:63 --change-name=${partn}:DATA --partition-guid=${partn}:${stablePartUuid("DATA")} ${device}` + await $`sgdisk --new=${partn}:0:+256M --typecode=${partn}:8300 -A ${partn}:set:0 -A ${partn}:set:1 -A ${partn}:set:62 -A ${partn}:set:63 --change-name=${partn}:DATA --partition-guid=${partn}:${stablePartUuid("DATA")} ${device}` await $`sgdisk --verify ${device}` @@ -163,10 +166,6 @@ async function create_datafs(device, rootfs) { await $`wipefs -a ${path}` await $`mkfs.ext4 -q ${path}` - // will be grown by x-systemd.growfs, see fstab - await $`resize2fs -M ${path}` - await $`e2fsck -f -p ${path}` - await $`mount ${path} ${mountpoint}` // /data/home @@ -276,6 +275,7 @@ async function setup_config(rpios_partitions, partitions) { "utf8", ) + // See https://www.raspberrypi.com/documentation/computers/config_txt.html#boot_partition-2 let config = "[all]\n\n" for (const bootname of bootnames) { const part = partitions[`FIRMWARE_${bootname}`] @@ -355,7 +355,7 @@ async function setup_fstab(partitions) { const datafs_partuuid = partitions[`DATA`].partuuid const fstab = dedent` PARTUUID=${bootloader_partuuid} /bootloader vfat defaults,noatime,ro 0 2 - PARTUUID=${datafs_partuuid} /data ext4 defaults,noatime,x-systemd.growfs 0 2 + PARTUUID=${datafs_partuuid} /data ext4 defaults,noatime,rw 0 2 /data/home /home none bind 0 0 ` // TODO: when we go readonly @@ -371,6 +371,24 @@ async function setup_fstab(partitions) { } } +async function setup_repart(partitions) { + const datafs_partuuid = partitions[`DATA`].partuuid + const conf = dedent` + [Partition] + UUID=${datafs_partuuid} + GrowFileSystem=yes + ` + for (const bootname of bootnames) { + const path = join( + partitions[`ROOT_${bootname}`].mountpoint, + "usr/lib/repart.d/50-data.conf", + ) + await writeFile(path, conf) + } + + await writeFile(partitions[""]) +} + export async function getPartitions(device) { const devices = await getBlockDevices(device) const partitions = Object.create(null) From 22df4871621b32636c0792e82eab3e34d174011d Mon Sep 17 00:00:00 2001 From: Sonny Piers Date: Mon, 8 Jun 2026 13:18:02 +0000 Subject: [PATCH 2/5] f --- os/image/README.md | 5 ----- os/image/planktoscope.js | 8 ++++---- os/rauc/README.md | 8 ++++++++ 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/os/image/README.md b/os/image/README.md index 553c0cc59..8af3c7182 100644 --- a/os/image/README.md +++ b/os/image/README.md @@ -54,11 +54,6 @@ Congratulations, the slot is running PlanktoScope OS. ## How does it work -Files on `FIRMWARE_A` and `FIRMWARE_B` must be strictly identical. -Files on `ROOT_A` and `ROOT_B` must be strictly identitical. - -We use the Raspberry Pi firmware and bootloader to dynamically switch between A and B. - ### Partition table The partition table is as such: diff --git a/os/image/planktoscope.js b/os/image/planktoscope.js index b67f81fc5..039e07449 100644 --- a/os/image/planktoscope.js +++ b/os/image/planktoscope.js @@ -97,7 +97,7 @@ async function createPartitionTable(device) { // will be expanded by systemd-repart // see setup_repart partn++ - await $`sgdisk --new=${partn}:0:+256M --typecode=${partn}:8300 -A ${partn}:set:0 -A ${partn}:set:1 -A ${partn}:set:62 -A ${partn}:set:63 --change-name=${partn}:DATA --partition-guid=${partn}:${stablePartUuid("DATA")} ${device}` + await $`sgdisk --new=${partn}:0:+8MB --typecode=${partn}:8300 -A ${partn}:set:0 -A ${partn}:set:1 -A ${partn}:set:62 -A ${partn}:set:63 --change-name=${partn}:DATA --partition-guid=${partn}:${stablePartUuid("DATA")} ${device}` await $`sgdisk --verify ${device}` @@ -377,16 +377,16 @@ async function setup_repart(partitions) { [Partition] UUID=${datafs_partuuid} GrowFileSystem=yes + Type=linux-generic ` for (const bootname of bootnames) { const path = join( partitions[`ROOT_${bootname}`].mountpoint, "usr/lib/repart.d/50-data.conf", ) - await writeFile(path, conf) + await mkdir(path, { recursive: true }) + await writeFile(join(path, "50-data.conf"), conf) } - - await writeFile(partitions[""]) } export async function getPartitions(device) { diff --git a/os/rauc/README.md b/os/rauc/README.md index 31e96949f..a76d9a761 100644 --- a/os/rauc/README.md +++ b/os/rauc/README.md @@ -15,6 +15,14 @@ sudo ./rauc.js create-bundle /dev/device B [version] This will create a bundle from partitions `FIRMWARE_B` and `ROOT_B` on device `/dev/device`. +A bundle can be installed to either slot. So please consider: + +* Files on `FIRMWARE_A` and `FIRMWARE_B` should be considered identical. +* Files on `ROOT_A` and `ROOT_B` should be considered identical. +* A bundle must work for any slot + +We use the Raspberry Pi firmware and bootloader to dynamically switch between A and B. + ## Install a bundle ```sh From d1c47b672fd3f408377997ae71cc5483713c5bfa Mon Sep 17 00:00:00 2001 From: Sonny Piers Date: Mon, 8 Jun 2026 18:26:13 +0200 Subject: [PATCH 3/5] f --- os/image/make-image.js | 26 ++++++++++++++++++++++++++ os/image/planktoscope.js | 16 +++++++++------- os/image/repart.d/10-bootloader.conf | 5 +++++ os/image/repart.d/20-firmware_a.conf | 5 +++++ os/image/repart.d/30-firmware_b.conf | 5 +++++ os/image/repart.d/40-root_a.conf | 5 +++++ os/image/repart.d/50-root_b.conf | 5 +++++ os/image/repart.d/60-data.conf | 5 +++++ 8 files changed, 65 insertions(+), 7 deletions(-) create mode 100644 os/image/make-image.js create mode 100644 os/image/repart.d/10-bootloader.conf create mode 100644 os/image/repart.d/20-firmware_a.conf create mode 100644 os/image/repart.d/30-firmware_b.conf create mode 100644 os/image/repart.d/40-root_a.conf create mode 100644 os/image/repart.d/50-root_b.conf create mode 100644 os/image/repart.d/60-data.conf diff --git a/os/image/make-image.js b/os/image/make-image.js new file mode 100644 index 000000000..4d2bfd3d5 --- /dev/null +++ b/os/image/make-image.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +import { $ } from "execa" + +const device = `/dev/nvme0n1` +const partn = 6 +const path = device + "p" + partn + +if (import.meta.main) { + if (process.getuid() !== 0) { + throw new Error("Please run as root.") + } + + // await $`mount ${path} /mnt` + // await $`fstrim /mnt` + // await $`umount ${path}` + + // Resize is not effective, it creates a smaller partition + // await $`e2fsck -f ${path}` + // await $`resize2fs ${path} 8M` + // await $`sgdisk --delete=${partn} --new=${partn}:0:+8M --typecode=${partn}:8300 -A ${partn}:set:0 -A ${partn}:set:1 -A ${partn}:set:62 -A ${partn}:set:63 --change-name=${partn}:DATA --partition-guid=${partn}:ce528120-d0dd-52be-aea3-8225fabd8a00 ${device}` + + // systemd-repart will recreate the partition on boot + await $`sgdisk --delete=${partn}` + // await $`partprobe ${device}` +} diff --git a/os/image/planktoscope.js b/os/image/planktoscope.js index 039e07449..e03bc1b99 100644 --- a/os/image/planktoscope.js +++ b/os/image/planktoscope.js @@ -79,25 +79,23 @@ async function createPartitionTable(device) { // BOOTLOADER partn++ - await $`sgdisk --new=${partn}:0:+8M --typecode=${partn}:0700 -A ${partn}:set:0 -A ${partn}:set:1 -A ${partn}:set:62 -A ${partn}:set:63 --change-name=${partn}:BOOTLOADER --partition-guid=${partn}:${stablePartUuid("BOOTLOADER")} ${device}` + await $`sgdisk --new=${partn}:0:+8M --typecode=${partn}:0700 --change-name=${partn}:BOOTLOADER --partition-guid=${partn}:${stablePartUuid("BOOTLOADER")} ${device}` // FIRMWARE X for (const bootname of bootnames) { partn++ - await $`sgdisk --new=${partn}:0:+256M --typecode=${partn}:0700 -A ${partn}:set:0 -A ${partn}:set:1 -A ${partn}:set:62 -A ${partn}:set:63 --change-name=${partn}:${"FIRMWARE_" + bootname} --partition-guid=${partn}:${stablePartUuid("FIRMWARE_" + bootname)} ${device}` + await $`sgdisk --new=${partn}:0:+256M --typecode=${partn}:0700 --change-name=${partn}:${"FIRMWARE_" + bootname} --partition-guid=${partn}:${stablePartUuid("FIRMWARE_" + bootname)} ${device}` } // ROOT X for (const bootname of bootnames) { partn++ - await $`sgdisk --new=${partn}:0:+10G --typecode=${partn}:8300 -A ${partn}:set:0 -A ${partn}:set:1 -A ${partn}:set:62 -A ${partn}:set:63 --change-name=${partn}:${"ROOT_" + bootname} --partition-guid=${partn}:${stablePartUuid("ROOT_" + bootname)} ${device}` + await $`sgdisk --new=${partn}:0:+10G --typecode=${partn}:8300 --change-name=${partn}:${"ROOT_" + bootname} --partition-guid=${partn}:${stablePartUuid("ROOT_" + bootname)} ${device}` } // "DATA" - // will be expanded by systemd-repart - // see setup_repart partn++ - await $`sgdisk --new=${partn}:0:+8MB --typecode=${partn}:8300 -A ${partn}:set:0 -A ${partn}:set:1 -A ${partn}:set:62 -A ${partn}:set:63 --change-name=${partn}:DATA --partition-guid=${partn}:${stablePartUuid("DATA")} ${device}` + await $`sgdisk --new=${partn}:0:0 --typecode=${partn}:8300 --change-name=${partn}:DATA --partition-guid=${partn}:${stablePartUuid("DATA")} ${device}` await $`sgdisk --verify ${device}` @@ -355,7 +353,7 @@ async function setup_fstab(partitions) { const datafs_partuuid = partitions[`DATA`].partuuid const fstab = dedent` PARTUUID=${bootloader_partuuid} /bootloader vfat defaults,noatime,ro 0 2 - PARTUUID=${datafs_partuuid} /data ext4 defaults,noatime,rw 0 2 + PARTUUID=${datafs_partuuid} /data ext4 defaults,noatime 0 2 /data/home /home none bind 0 0 ` // TODO: when we go readonly @@ -371,6 +369,10 @@ async function setup_fstab(partitions) { } } +// repart will create the DATA GPT partition but won't grow the EXT4 filesystem +// we use x-systemd.growfs in fstab for that +// > Note that these definitions may only be used to create and initialize new partitions or to grow existing ones. In the latter case, it will not grow the contained files systems however; separate mechanisms, such as systemd-growfs(8) may be used to grow the file systems inside of these partitions. +// https://www.freedesktop.org/software/systemd/man/latest/repart.d.html#Description async function setup_repart(partitions) { const datafs_partuuid = partitions[`DATA`].partuuid const conf = dedent` diff --git a/os/image/repart.d/10-bootloader.conf b/os/image/repart.d/10-bootloader.conf new file mode 100644 index 000000000..f765152d8 --- /dev/null +++ b/os/image/repart.d/10-bootloader.conf @@ -0,0 +1,5 @@ +[Partition] +Label=BOOTLOADER +Type=ebd0a0a2-b9e5-4433-87c0-68b6b72699c7 +SizeMinBytes=8M +SizeMaxBytes=8M diff --git a/os/image/repart.d/20-firmware_a.conf b/os/image/repart.d/20-firmware_a.conf new file mode 100644 index 000000000..55b39b674 --- /dev/null +++ b/os/image/repart.d/20-firmware_a.conf @@ -0,0 +1,5 @@ +[Partition] +Label=FIRMWARE_A +Type=ebd0a0a2-b9e5-4433-87c0-68b6b72699c7 +SizeMinBytes=256M +SizeMaxBytes=256M diff --git a/os/image/repart.d/30-firmware_b.conf b/os/image/repart.d/30-firmware_b.conf new file mode 100644 index 000000000..9e2308a4f --- /dev/null +++ b/os/image/repart.d/30-firmware_b.conf @@ -0,0 +1,5 @@ +[Partition] +Label=FIRMWARE_B +Type=ebd0a0a2-b9e5-4433-87c0-68b6b72699c7 +SizeMinBytes=256M +SizeMaxBytes=256M diff --git a/os/image/repart.d/40-root_a.conf b/os/image/repart.d/40-root_a.conf new file mode 100644 index 000000000..1a7979c3d --- /dev/null +++ b/os/image/repart.d/40-root_a.conf @@ -0,0 +1,5 @@ +[Partition] +Label=ROOT_A +Type=linux-generic +SizeMinBytes=10G +SizeMaxBytes=10G diff --git a/os/image/repart.d/50-root_b.conf b/os/image/repart.d/50-root_b.conf new file mode 100644 index 000000000..b93d6eb91 --- /dev/null +++ b/os/image/repart.d/50-root_b.conf @@ -0,0 +1,5 @@ +[Partition] +Label=ROOT_B +Type=linux-generic +SizeMinBytes=10G +SizeMaxBytes=10G diff --git a/os/image/repart.d/60-data.conf b/os/image/repart.d/60-data.conf new file mode 100644 index 000000000..95505edc8 --- /dev/null +++ b/os/image/repart.d/60-data.conf @@ -0,0 +1,5 @@ +[Partition] +Label=DATA +UUID=ce528120-d0dd-52be-aea3-8225fabd8a00 +Type=linux-generic +GrowFileSystem=yes From 5741ef1db1bc642685f813652535385387b1a621 Mon Sep 17 00:00:00 2001 From: Sonny Piers Date: Mon, 8 Jun 2026 18:26:56 +0200 Subject: [PATCH 4/5] f --- os/image/planktoscope.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/os/image/planktoscope.js b/os/image/planktoscope.js index e03bc1b99..038b8365f 100644 --- a/os/image/planktoscope.js +++ b/os/image/planktoscope.js @@ -384,7 +384,7 @@ async function setup_repart(partitions) { for (const bootname of bootnames) { const path = join( partitions[`ROOT_${bootname}`].mountpoint, - "usr/lib/repart.d/50-data.conf", + "usr/lib/repart.d", ) await mkdir(path, { recursive: true }) await writeFile(join(path, "50-data.conf"), conf) From 49e7695206047676d98a052cc3ebac2bccf6c4bc Mon Sep 17 00:00:00 2001 From: Sonny Piers Date: Mon, 8 Jun 2026 18:39:42 +0200 Subject: [PATCH 5/5] f --- os/image/machine-id-setup.service | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/os/image/machine-id-setup.service b/os/image/machine-id-setup.service index 32c7798d9..77dd6f4eb 100644 --- a/os/image/machine-id-setup.service +++ b/os/image/machine-id-setup.service @@ -1,9 +1,9 @@ [Unit] -Description=Initialize shared machine-id on first boot +Description=Initialize shared machine-id when /data/ becomes available DefaultDependencies=no Before=sysinit.target After=data.mount -Requires=data.mount +ConditionPathExists=/data ConditionPathExists=!/data/machine-id [Service]