The idea of shipping a server image small and letting it grow in place is not new; my own server booted that way. On 11 May, installation day, cloud-init's growpart module pulled the root partition from the image's 2.5 GiB to 699 GiB and resize2fs grew the file system after it; two lines in the log. systemd has its own tool for the same job, and has had it since 2020, version 245: systemd-repart. The description is ambitious: it reads the partition table against a few .conf files, adds missing partitions, grows the ones that can grow, deletes nothing (except what FactoryReset= explicitly asks for), moves and shrinks nothing; it is designed to run on every boot and does not touch the table once it finds it matching the definitions. On top of that, image building: a disk image from scratch with the same definitions, files copied in, a dm-verity hash partition and its signature.
I ran the tool on my server, with Ubuntu 24.04's systemd 255, in three scenarios: growing an image, asking what it would do to the live disk, building a signed verity image. It did what I expected; four things I did not expect also came out.
The matching rule
One rule is enough to understand the tool's behaviour: existing partitions are matched to definition files by GPT partition type UUID. The first partition of a type goes to the first file declaring that type, the second to the second; file name order is binding. Partitions with no matching file are "foreign" and left as they are; a file with no partition is a "create a partition" request. The definitions are the same when building an image from scratch; the difference is that there is nothing in a table to match.
This rule has a consequence, and it happened to me in the second scenario: if the type is wrong the tool does not recognise the partition, counts it as "missing" and sets out to create a new one.
One: dry-run is the default
On a 4 GiB empty file I created a single 1 GiB linux-generic partition with sfdisk, put ext4 and a file inside. The definition is two lines:
[Partition]
Type=linux-generic
# systemd-repart --definitions=grow.d grow.raw
TYPE LABEL UUID PARTNO FILE RAW SIZE SIZE PADDING
linux-generic root d283755f-1723-4cb2-9117-6064d639dd7d 0 10-root.conf 4293898240 1.0G → 3.9G 2.9G → 0B
Refusing to repartition, please re-run with --dry-run=no.
Exit code zero, table unchanged. --dry-run=yes is the default; whoever runs it without the flag cannot change anything, and it does not treat that as an error. I find this safe, but a step in a set -e script that is waved through as "ran, returned zero" and did nothing at all is exactly this. With --dry-run=no:
Growing existing partition 0.
Writing new partition table.
All done.
The partition went from 2,097,152 sectors to 8,386,520; the second run said "No changes." The file system, however, stayed put: after mounting, df still shows 974 MiB. systemd-repart grows the partition, not the file system; that is the job of resize2fs, or systemd-growfs at boot. For that it stamps bit 59, the "grow file system" flag, on partitions it creates; you will see it as attrs="GUID:59" in the third scenario. The systemd counterpart of the growpart + resizefs pair is two pieces as well.
Two: calling the live disk "root"
Since I could ask without touching the live disk, I asked: what would it do to /dev/sda with the same linux-generic definition?
TYPE LABEL UUID PARTNO FILE RAW SIZE SIZE
linux-generic linux-generic 33145b69-de24-47b8-9114-e691b1972e92 0 10-root.conf 750544469504 698.9G
21686148-... - c0485e87-... 13 4194304 4.0M
esp esp c2d8823d-... 14 111149056 106.0M
xbootldr xbootldr 251d947f-... 15 957350400 913.0M
No changes.
The BIOS boot partition is not even recognised by type name (listed as a raw UUID), ESP and XBOOTLDR are foreign, the root partition matched and there is no room to grow. Then I changed the definition to Type=root, as the Discoverable Partitions Specification recommends:
Can't fit requested partitions into available free space (1020.0K), refusing.
Ubuntu's cloud image creates the root partition with the generic linux-generic type (0FC63DAF-…), not root-x86-64. With Type=root the tool does not see sda1, says "no root partition" and tries to create one; the disk is full, so it refuses. Had there been 10 GB free at the end of the disk it would not have refused; it would have opened a second root partition in the first free slot, sda2, and in a boot service running with --dry-run=no this happens silently. When carrying repart definitions to another distribution's image, the first job is to read the type column in sfdisk --dump; the definitions come after.
On this server systemd-repart.service is installed and static; it has never run at boot because its condition is never met (ConditionDirectoryNotEmpty=|/usr/lib/repart.d and its siblings, all empty), and it is not in the initramfs either. It was cloud-init that grew the root partition; repart's only job here would be to open /srv if a second disk were added.
Three: a signed verity image in one second
The part I was really curious about. Three definition files:
# 10-root.conf
[Partition]
Type=root
Format=squashfs
CopyFiles=/tmp/repart-lab/tree:/
Verity=data
VerityMatchKey=root
Minimize=best
# 20-root-verity.conf
[Partition]
Type=root-verity
Verity=hash
VerityMatchKey=root
# 30-root-verity-sig.conf
[Partition]
Type=root-verity-sig
Verity=signature
VerityMatchKey=root
In the tree an os-release, two binaries and 32 MiB of random data. With a certificate and key I signed myself:
# systemd-repart --definitions=img.d --empty=create --size=auto --dry-run=no \
--seed=11111111-2222-3333-4444-555555555555 \
--private-key=verity-key.pem --certificate=verity-cert.pem --json=short img.raw
build: 1.04 s
img.raw1 size=65688 type=4F68BCE3-… uuid=07A9C045-172A-1C49-945F-B8288A70E82B name="root-x86-64"
img.raw2 size=20480 type=2C7357ED-… uuid=7392235F-1248-FA18-7713-DDF807421E7E name="root-x86-64-verity"
img.raw3 size=32 type=41092B05-… uuid=1D00C5DE-82E6-410F-8AC9-C08E54925652 name="root-x86-64-verity-sig"
roothash: 07a9c045172a1c49945fb8288a70e82b7392235f1248fa187713ddf807421e7e
One second. With --empty=create, --dry-run=no is already implied (a comment in the source: we cannot break anyone's partition table in a file we created from scratch). Look at the root hash: its first 16 bytes are 07a9c045-172a-1c49-945f-b8288a70e82b, the data partition's UUID; its last 16 bytes are 7392235f-1248-fa18-7713-ddf807421e7e, the hash partition's UUID. This is how systemd-veritysetup-generator finds the partitions from the roothash= parameter, and because both sides use the same rule, no partition path needs to be written into the image. I attached it to a loop device, opened it with veritysetup open, the squashfs mounted read-only, os-release in place.
The third partition is 16 KiB and holds JSON: rootHash, certificateFingerprint, signature. The signature is base64 PKCS#7. I read what gets signed in repart.c: the hex text produced by hexmem(), not the raw 32 bytes (PKCS7_DETACHED|PKCS7_NOATTR|PKCS7_BINARY). Running openssl smime -verify against the raw bytes gave "signature failure"; against the hex string, "Verification successful". The kernel expects the same thing: dm-verity-target.c verifies the signature over the hex string in argv[8], with its strlen. The two ends agree; but when signing by hand you have to sign the hex text, as in the jq --raw-output0 example.
I tried the kernel path too: veritysetup open --root-hash-signature=sig.der returned -ENOKEY on this virtual machine; my certificate is not in the keyrings the kernel trusts, the limit I described in yesterday's post applies unchanged. Completing the chain needs Secure Boot and MOK; the repart side is ready.
Four: same seed, different hash
I expected a reproducible image; --seed exists for that, partition UUIDs are derived from the seed. I built a second time with the same seed:
roothash same seed: DIFFERENT
image bytes DIFFER
The verity salt comes from the seed (derive_salt(context->seed, "verity-salt", …), in 255 as well), so the difference is not from there. unsquashfs -s showed it: "Creation or last append time" differs by a minute between the two images. mksquashfs writes the clock into the superblock, one byte changes so the root hash changes, and when the root hash changes the two partitions' UUIDs change. The current man page covers this under $SOURCE_DATE_EPOCH; repart reading the variable itself arrived with 262 (NEWS: "honors $SOURCE_DATE_EPOCH while populating images"), and the name of the variable does not appear anywhere in 255's repart.c. Still, once set:
014ffbc4ace32b43ceadc5122f129dac83ddcdc95d45387872db5256e49a1af6
014ffbc4ace32b43ceadc5122f129dac83ddcdc95d45387872db5256e49a1af6
IMAGE BYTES IDENTICAL
Byte-for-byte identical images. The reason it worked on 255 is not repart but mksquashfs: squashfs-tools has read the variable itself since 4.4, and 4.6.1 clamps both the superblock time and the file times to it; repart merely passed the environment variable on to the child process. With Format=ext4 or vfat the same trick does not work on 255; those file systems are populated through the kernel, and the documentation requires --offline=yes mode for byte-for-byte reproducibility (the mode exists in 255 too; the repart that reads the variable is 262). With --offline=yes the result here was the same, since squashfs is built in user space anyway. In a system where the root hash is the image's identity, the timestamp is part of the identity too; it has to be pinned.
The other side of the seed: if none is given, the machine's /etc/machine-id becomes the seed. On the same server I built two empty images in a row without --seed; both had disk UUID A8DBCA6F-… and partition UUID 0D27CCB9-…. The documentation says so plainly ("the machine ID reproducibly determines the UUIDs") and the intent is consistency at boot: the same partition gets the same UUID on every boot. In an image factory, though, every image leaving the same build machine comes with the same UUIDs; you need --seed=random or a fixed seed per image.
The B partition on first boot
The tool's real design: the image ships with a single root partition, the B partition is opened on the disk at first boot, updates alternate between A and B. I added 15-root-b.conf (Type=root, SizeMinBytes=32M, SizeMaxBytes=64M) and 25-root-verity-b.conf to the definitions and moved the image to a "bigger disk" with --size=200M:
Adding new partition 3 to partition table.
Adding new partition 4 to partition table.
Writing new partition table.
Because 15-root-b.conf is the second file of the same type, it went to free space rather than to the existing root partition; 64 MiB was opened for B and 10 MiB for its hash, and the signature partition, now without a definition, was counted foreign and left alone. The second run: "No changes." The file grew to 200 MB but takes 35 MB on disk; the new partitions' space was left as holes by --discard.
The surprise here came in dry-run. When I ran the same command without --dry-run=no, that is in the default dry mode, the file still grew to 200 MB and the GPT backup header moved to the new end of the disk: in sfdisk --dump, last-lba went from 88254 to 409566, the partition count stayed at three. The source confirms it: when --size= is given, resize_backing_fd() and the resize_pt() inside it (fdisk_write_disklabel) run before the dry-run check, on master and in 255 alike. The documentation says the file is grown "before any change is made to the partition table", and that sentence is true; but the sentence "dry-run touches nothing" is not true together with --size=. On block devices --size= is refused anyway, so the issue is only with file images and only in the growing direction; still, watching a step you trusted as dry inflate your image file in a script is not pleasant.
The chain at boot
Where the tool was designed to live, the initrd, the order is this: systemd-repart.service runs early with DefaultDependencies=no, matches the root disk's table against the definitions before /sysroot is mounted and applies with --dry-run=no; if the root partition is dm-verity, systemd-veritysetup-generator finds the two partitions by UUID from roothash= on the kernel command line; on writable partitions with bit 59, systemd-growfs stretches the file system to the end of the partition. The attrs="GUID:60" in the third scenario's output is the other flag of this chain: Verity=data and Verity=hash partitions are marked read-only (bit 60), while the root partition opened for B carries GUID:59 as a file system to grow. I did not write the flags; they were derived from the Type= and Verity= lines (in the 255 code the hash partition's read-only flag comes from the root-verity type, the data partition's from Verity=data). The man page also covers the two colliding: if bit 60 is set, 59 has no effect.
In Ubuntu's cloud image the first link of the chain is absent: I could not find a single file with repart in the initramfs, the service on the main system is static and its condition has never been met, and growing is done in user space by cloud-init's growpart. The two routes reach the same result; the difference is that cloud-init only grows the existing partition, while repart can add partitions to the table and derives the identity of what it adds from the seed. For growing a single root partition repart is too much; for an image that wants A/B or a separate /var, cloud-init has no answer.
Version notes
Ubuntu 24.04's 255 did everything in this post: squashfs formatting, Minimize=best, the verity trio, --json, --offline, even --make-ddi. What it lacks: the short form -n of --dry-run=yes (262), passing - instead of a device to ask "how many bytes is the smallest image for these definitions" (259; in 255: "No such file or directory"), and repart reading $SOURCE_DATE_EPOCH itself (262). Also systemd-dissect is not on this installation (on Ubuntu it lives in the systemd-container package); losetup -P and veritysetup were enough to inspect the image.
I will not put repart definitions on my server; cloud-init grew the root partition, there is no second disk, and the A/B design does not fit a machine that lives on apt upgrade. The tool's place is the image-based machines I listed at the end of yesterday's post: there, one tool and three .conf files close all four links of the chain "build the image, sign it, open B on first boot, do nothing on later boots". All four surprises are documented behaviour or logic readable in the source; they were surprises because I did not read the documentation to the end first.
Versions: Ubuntu 24.04, systemd 255.4-1ubuntu8.17, squashfs-tools 4.6.1, cryptsetup 2.7.0, kernel 6.8.0-139. Partition type UUIDs per the Discoverable Partitions Specification; the cloud-init module that grew the root partition is cc_growpart.py. Source reading from systemd main and the v255 tag; in 255 the file is src/partition/repart.c, on master src/repart/repart.c.
Official Sources
- systemd-repart(8) — algorithm, --dry-run, --size, --seed, --empty, --offline, $SOURCE_DATE_EPOCH
- repart.d(5) — Type=, Verity=, VerityMatchKey=, Minimize=, CopyFiles=, UUID derivation
- repart.c (main) — derive_salt, sign_verity_roothash, resize_backing_fd/resize_pt
- repart.c (v255) — same logic, under src/partition
- systemd-repart.service — ConditionDirectoryNotEmpty conditions
- systemd-veritysetup-generator — finding partitions from roothash=
- dm-verity-target.c — signature verified over the hex root hash string
- cloud-init cc_growpart.py — the module that grows the root partition in cloud images
- dm-verity — kernel admin guide: root_hash_sig_key_desc and table format
- veritysetup(8) — open, --root-hash-signature, dump
Top comments (0)