Skip to content

engineering: make Azure Container Linux images installable (inline dm-verity, ESP staging) - #754

Draft
Paco Huelsz (frhuelsz) wants to merge 9 commits into
mainfrom
user/frhuelsz/inline-verity
Draft

engineering: make Azure Container Linux images installable (inline dm-verity, ESP staging)#754
Paco Huelsz (frhuelsz) wants to merge 9 commits into
mainfrom
user/frhuelsz/inline-verity

Conversation

@frhuelsz

@frhuelsz Paco Huelsz (frhuelsz) commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

🔍 Description

engineering + bug. Makes Azure Container Linux (ACL) images installable. Two defects, both found installing the same image:

  1. Inline dm-verity is not supported when deriving a Host Configuration from COSI, so trident grpc-client stream-disk cannot install ACL. Inline verity stores the hash tree inside the same partition as the data, at a byte offset, rather than in a dedicated hash partition.
  2. File-based ESP deployment assumes <newroot>/var/tmp exists, so trident install cannot install ACL either — failing after the disk has already been repartitioned.
  3. The verity-addon guard keys off a shared directory, so trident install rejects every currently published ACL image.
  4. fstab is written for UKI usr-verity images, which both fails on ACL and would be wrong if it succeeded.

They are separate faults, uncovered one behind the other as each was fixed. stream-disk only hits (1); install hits all three. None alone makes ACL installable.

🤔 Rationale

Streaming a real ACL image failed during derivation:

Derived Host Configuration is invalid: Referrer 'verity-1' of kind
'verity-device' references target 'partition-2' more than once

Derivation resolved the verity hash partition purely by image path. With inline verity the verity entry points at the same image as the filesystem, so it emitted a VerityDevice whose data and hash devices were the same partition, which the storage graph rejected. Trident also had nowhere to record where the hash tree began, and never passed an offset to veritysetup open.

This was not a regression — inline verity was never supported. git log --all --grep=inline is empty and nothing referenced hashOffset. #673 made ACL work for A/B update, but that path never derives a Host Configuration; it reads the root hash from usrhash= on the UKI cmdline and mounts /usr directly, so it never had to model the layout.

Inline dm-verity (1)

  • Read hashOffset from COSI verity metadata. Image Customizer already emits this field for inline layouts — it is declared in its cosiapi package and was added upstream by azure-linux-image-tools#702 ("Add support for inline verity"), gated so it appears only when inline, and covered by an IC test. The encoding is intentional and stable on the producer side, confirmed against IC's source: HashOffset is set only when the hash tree is inline (cosicommon.go:199-209), and the separate-hash layout instead yields a distinct image path with the field omitted. So the two shapes are cleanly discriminable, and verity.image.path == image.path together with a present hashOffset is precisely how IC expresses inline verity. It has behaved this way in every release since v1.4.0 (April 2026) with no COSI version bump, so upgrading IC does not avoid this — a consumer that wants to install inline-verity images has to read the field.

This change consumes what the producer already declares, and brings Trident's COSI reference up to date with it: hashOffset is now documented in Composable-OS-Image.md, the v1.2 JSON Schema, and the changelog. The addition is descriptive and backward compatible — the field is optional and absent for the ordinary separate-hash-partition layout, so every existing COSI stays valid.

  • Carry the offset on the OS image, not the Host Configuration. Like the root hash, the offset is a property of the image, so it is read from image metadata at servicing time. It does not appear in the Host Configuration or Host Status.
  • Allow a verity device to name the same partition as both data and hash device. hashDeviceId stays required, so the Host Configuration schema is completely unchangedhost-config-schema.json and the generated VerityDevice.md are byte-identical to main. The graph models the inline case as the single device it is rather than as a second reference to the same node, which leaves the duplicate-target and referrer-sharing invariants untouched. Verity referrer cardinality becomes 1..=2, and the hash partition-type cross-check is skipped when there is no distinct hash partition.
  • Pass --hash-offset to veritysetup open, so an inline-verity device is actually opened and verified during servicing rather than skipped.

Two documentation sets are treated differently on purpose. The COSI reference describes the format Trident consumes, so it documents hashOffset as a factual property of images IC already produces. The Host Configuration reference describes what users may configure, and inline dm-verity is not an officially supported configuration, so it is deliberately not documented there — host-config-schema.json and the generated VerityDevice.md are byte-identical to main. The only generated-doc change is the verity-device row of the referrer cardinality table in Storage-Rules.md (2..21..2), which states a reference count without mentioning inline verity.

Derivation fails with a clear error naming hashOffset if an image uses inline verity but does not say where the hash tree starts, instead of producing a device that cannot be opened.

ESP staging directory (2)

deploy_esp stages the ESP image through <newroot>/var/tmp, on the comment's reasoning that the location "is generally guaranteed to be writable and backed by a real block device" — deliberately avoiding holding a potentially large image in RAM. That assumption does not hold for an immutable image. ACL's root filesystem ships only usr, oem, boot and lost+found plus symlinks, with no /var at all, leaving systemd-tmpfiles to populate it on first boot:

$ debugfs -R "ls -l /" root.raw     # root partition, straight out of the COSI
      2   40755 .          11   40700 lost+found     14   40755 usr
     13   40755 oem        15   40755 boot           37  120777 lib -> usr/lib

NamedTempFile::new_in then has nothing to create into and servicing fails in Provision, after repartitioning.

The directory is now created when missing, with the sticky, world-writable mode a conventional /var/tmp has, so systemd-tmpfiles finds what it expects on first boot. An existing directory is left untouched, so images that do ship one keep whatever mode they chose.

The neighbouring space check is also reworded: it reported Failed to check if there is enough space available whenever the backing device size was unknown, which reads like a malfunction. The usual cause is a partition configured to grow, whose size is not knowable before servicing — including on plain Azure Linux. It now says so, and makes clear the check was skipped rather than passed.

Verity addon guard (3)

activate_verity_addon_for_target_volume decided whether an image uses PARTUUID-based verity addons by testing whether acl/uki-addons/ exists, then hard-errored if the selected slot's template was missing. That directory is shared — its own doc comment says it holds "per-slot verity addons (verity-a/verity-b) and the first-boot addon" — and published ACL images ship first-boot, fips and kdump there while shipping no verity templates:

$ mdir -i esp.img -/ ::/acl/uki-addons     # ESP straight out of the ACL COSI
 FIRSTB~1 EFI     81784   firstboot.addon.efi
 FIPSAD~1 EFI     81784   fips.addon.efi
 KDUMPA~1 EFI     81784   kdump.addon.efi

So the guard passed and the function demanded a template the image never had, failing installation of every published ACL image. Per-slot verity addons are ACL base work that has not landed yet.

The decision now keys on whether the image's verity is inline. A per-slot addon exists to carry its slot's verity data/hash PARTUUID pair; inline verity has no such pair, since the hash tree lives in the data partition and both refer to one device, so activation has nothing to select and is skipped.

Gating on inline-ness rather than on "no templates present" matters: the latter infers intent from a missing file, so an image that should ship per-slot addons but shipped none by mistake would be indistinguishable from one legitimately exempt, and would be activated silently instead of rejected. Inline-ness is a property that can be established positively.

With a separate hash partition the check therefore stays strict — including when no templates are shipped at all, which is a packaging fault rather than an exemption. That is the direction ACL is heading, and where the error should keep firing. The pre-existing safety test (ships verity-a, installs to slot B → error) is unchanged and still passes.

Inline-ness comes from VerityDevice::is_inline(), the same predicate defect (1) keys on, so the two features stay consistent by construction.

fstab on UKI usr-verity images (4)

Trident already skips storage configuration for UKI images using root-verity, on the reasoning that such an image takes its mount topology from the signed kernel command line rather than from fstab. ACL is that same case with the verity on /usr, but the guard tested only root-verity, so it did not apply and generate_fstab ran — failing, because ACL's ROOT image ships no /etc.

Creating the directory would have been the wrong fix. ACL assembles /etc as an overlay — factory /usr/share/distro/etc as lower, the ROOT partition's /etc as upper — so anything written there wins over the image's own. Confirmed against the shipping image:

  • the factory /usr/share/distro/etc/fstab is deliberately empty (0 bytes);
  • every mount ACL needs already comes from the command line (/usr, /) or a shipped unit (/boot, /oem);
  • systemd-fstab-generator is present, and emits into /run/systemd/generator, which takes precedence over /usr/lib/systemd/system.

A generated fstab would therefore have shadowed the units the image ships, not merely duplicated them. The absence of /etc is likewise expected rather than a fault: ACL's initrd creates it immediately before mounting the overlay, in dracut module 99setup-root.

Only fstab is skipped, not the whole step as in the root-verity case: a usr-verity image still has a writable root, so encryption and RAID configuration remain applicable.

📝 Checks

Both defects were found and re-validated on real ACL images. Manually validated end to end on a real ACL image (acl-base-3.0.20260706), installed via stream-disk from an Azure Linux installer ISO built with RPMs from this branch, in QEMU/KVM:

  • derivation succeeds; storage graph builds; full HostConfiguration::validate() passes
  • all five partitions streamed; Servicing completed successfully; reboot
  • verity activated during servicing and reported verified
  • installed disk boots with no install media attached: UEFI → GRUB → ACL kernel 6.6.143.1, with systemd activating dm-verity on /usr from the UKI cmdline (sysusr-usr.mount, /dev/mapper/usr, zero mount failures), 38 targets reached

First boot then stops at emergency.target because Ignition cannot reach Azure IMDS (169.254.169.254) — expected off-Azure for an image carrying flatcar.oem.id=azure, and downstream of everything this PR touches (verity was already mounted and verified before Ignition ran).

Before writing any code, the underlying veritysetup invocation was confirmed against the real disk image:

veritysetup open /dev/loopXp2 <name> /dev/loopXp2 270ed371... --hash-offset=1065345024
→ status: verified

Automated: 22 test suites pass workspace-wide, cargo fmt --check clean, cargo clippy introduces no new warnings. Adds test_derive_host_configuration_inner_inline_verity (pinning ACL's real usr partition type GUID, which exercises partition-type rules a generic Linux GUID would mask) and ..._inline_verity_missing_offset.

📌 Follow-ups

TODO:

  • The v1.2 schema requires osPackages to be an array, but real ACL COSI files set it to null and Trident already tolerates that (acl COSI files do not contain ospackages #707). Validating an ACL image's metadata against the checked-in schema fails on osPackages alone — a pre-existing divergence, unrelated to this PR and left alone here.

🗒️ Notes

Opened as a draft: functionally validated end to end, but wanted review on the graph modelling choice before marking ready.

The main design question is how inline verity is represented. Two alternatives were built and discarded:

  1. Drop the verity device entirely, mounting /usr read-only straight off the partition and leaving verity to systemd at boot. Smallest change and it validates, but Trident never verifies the image it just wrote. It also has a trap: the non-verity path drops the ro mount option, and resize is skipped only when is_read_only() || !fs_type.is_ext(). ACL survives on the btrfs clause alone; an ext4 inline-verity image would be resized and have its appended hash tree silently destroyed.
  2. hash_device_id: Option<BlockDeviceId>. Cleaner conceptually, but it is a Host Status wire-format change that rippled through ~30 files.

The approach here keeps the Host Configuration and Host Status untouched while still verifying at install time.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).
There may be pipelines that require an authorized user to comment /azp run to run.

@frhuelsz Paco Huelsz (frhuelsz) changed the title engineering: support inline dm-verity in COSI-derived Host Configurations engineering: make Azure Container Linux images installable (inline dm-verity, ESP staging) Aug 31, 2026
…ions

Azure Container Linux images use *inline* dm-verity: the hash tree lives
inside the same partition as the data, at a byte offset, rather than in a
dedicated hash partition. `trident grpc-client stream-disk` could not
install such an image.

Derivation resolved the verity hash partition purely by image path. With
inline verity the verity entry points at the same image as the filesystem,
so it produced a VerityDevice whose data and hash devices were the same
partition, which the storage graph then rejected:

    Derived Host Configuration is invalid: Referrer 'verity-1' of kind
    'verity-device' references target 'partition-2' more than once

Trident also had nowhere to record where the hash tree began, and never
passed an offset to `veritysetup open`.

Changes:

- Read `hashOffset` from COSI verity metadata. Image Customizer already
  emits this field for inline layouts (see its `cosiapi` package), so this
  only consumes what the producer declares; the COSI spec and schema are
  unchanged.
- Carry the offset on the OS image next to the root hash. Like the root
  hash, it is a property of the image rather than of the Host
  Configuration, so it is read from image metadata at servicing time and
  does not appear in the Host Configuration or Host Status.
- Allow a verity device to name the same partition as both its data and
  hash device. `hashDeviceId` remains required, so the Host Configuration
  schema is unchanged apart from documentation. The storage graph models
  the inline case as the single device it is, rather than as a second
  reference to the same node, which leaves the duplicate-target and
  referrer-sharing invariants untouched. Verity referrer cardinality
  becomes 1..=2, and the hash partition type cross-check is skipped when
  there is no distinct hash partition.
- Pass `--hash-offset` to `veritysetup open`, so an inline-verity device is
  actually opened and verified during servicing.

Derivation fails with a clear error naming `hashOffset` when an image uses
inline verity but does not say where the hash tree starts, rather than
producing a device that cannot be opened.

Tested against a real ACL image end to end: derivation, graph construction
and full Host Configuration validation pass; `stream-disk` writes all five
partitions; verity activates and reports `verified`; and the installed
system boots with systemd activating dm-verity on /usr from the UKI command
line.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Inline dm-verity is not an officially supported configuration, so the
public Host Configuration reference should not describe it.

Reverts the `hashDeviceId` doc comment to its original wording. The
generated `VerityDevice.md` and `host-config-schema.json` are now
byte-identical to main, so this change makes no alteration to Trident's
documented public API surface.

Support itself is unaffected: inline verity is still derived, opened with
`--hash-offset`, and verified. The behaviour is simply not advertised.

The only remaining generated-doc difference is the verity-device row of the
referrer cardinality table in Storage-Rules.md (2..2 becomes 1..2). That
table is generated from the cardinality rule itself and states a reference
count without mentioning inline verity; suppressing it would mean reverting
the rule and reintroducing the graph invariant exemptions this design
deliberately avoids.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Image Customizer has emitted `hashOffset` since azure-linux-image-tools#702
("Add support for inline verity"), where it is declared in its `cosiapi`
package and written only for images whose verity hash tree is stored inside
the data partition. COSI 1.2 documents describe the field nowhere, so the
spec has been behind its producer.

Document it: add `hashOffset` to the `VerityConfig` table, note that the
`image` field refers to the same image as the filesystem's own for inline
verity, add a sample, and record the field in the 1.2 changelog.

The addition is descriptive rather than a format change. `hashOffset` is
optional and absent for the ordinary separate-hash-partition layout, so
every existing COSI remains valid and consumers that ignore the field remain
correct.

Extends the schema gate with an inline-verity sample under
tests/cosi/metadata_samples/v1.2/valid/, plus a negative sample asserting
the offset cannot be negative. Verified with check-jsonschema, as the CI
workflow does; also confirmed the metadata of a real inline-verity image
validates against the updated schema.

Note this documents the COSI format Trident consumes. It does not present
inline verity as a supported Host Configuration: the Host Configuration
reference and its schema are unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`trident install` could not install an Azure Container Linux image. After
partitioning the disk and writing the usr, oem and root filesystems, it
failed in Provision:

    Failed to perform file-based deployment of ESP images
    Failed to create a temporary file

File-based ESP deployment stages the image through
`<newroot>/var/tmp`, whose comment claims the location "is generally
guaranteed to be writable and backed by a real block device". That does not
hold for an immutable image: ACL's root filesystem ships only `usr`, `oem`,
`boot` and `lost+found` plus a few symlinks, with no `/var` at all, and
leaves systemd-tmpfiles to populate it on first boot. `NamedTempFile` then
has no directory to create into, and servicing fails after the disk has
already been repartitioned.

Create the directory when it is missing, with the sticky, world-writable
mode a conventional `/var/tmp` has, so systemd-tmpfiles finds what it
expects on first boot. An existing directory is left untouched, so images
that do ship one keep whatever mode they chose. The path computation and
creation move into `ensure_esp_extraction_dir` so both cases can be tested.

`stream-disk` was unaffected, as it writes the ESP partition image directly
rather than staging it through a file.

Also reword the neighbouring space check. It reported "Failed to check if
there is enough space available" whenever the backing device size was
unknown, which reads like a malfunction; the usual cause is simply a
partition configured to grow, whose size is not known before servicing. Say
that instead, and note the check was skipped rather than passed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Installing any currently published Azure Container Linux image failed
during Provision:

    Verity addon template 'verity-a.addon.efi' not found in
    '.../acl/uki-addons' — cannot activate VolumeA

`activate_verity_addon_for_target_volume` decided whether an image uses
PARTUUID-based verity addons by testing whether `acl/uki-addons/` exists.
That directory is shared, though: it also holds the first boot, fips and
kdump addons, as its own doc comment says. Published ACL images ship those
three and no verity templates, so the check passed and the function went on
to demand a template the image never had.

Key the decision on the templates themselves instead. If the selected slot's
template is absent, the image only fails when the *other* slot's template is
present, since that is what shows the image really does use per-slot verity
addons and that booting would otherwise pick up the wrong slot's PARTUUIDs.
When neither is present the image simply does not use them and there is
nothing to activate, which is the behaviour the doc comment already
described.

The safety property is unchanged, and its test with it: shipping verity-a
while installing to slot B is still an error.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Follow-up to 8da7273, which keyed the no-op on neither per-slot verity
addon template being present. That test is a proxy: it infers an image's
intent from a missing file, so an image that should ship per-slot addons but
shipped none by mistake looks exactly like one that legitimately does not
use them, and would be activated silently rather than rejected.

Gate on the structural reason the exemption is safe instead. A per-slot
addon exists to carry its slot's verity data/hash PARTUUID pair; an image
whose verity is inline has no such pair, since the hash tree lives in the
data partition and both refer to one device. There is nothing for an addon
to select, so activation does not apply. That is a property of the image
that can be established positively rather than read out of an absence.

With a separate hash partition the check stays strict, including when the
image ships no templates at all: such an image is meant to carry per-slot
PARTUUIDs, and shipping none is a packaging fault rather than an exemption.
That is the case ACL is moving towards, and where the error should keep
firing.

Inline-ness comes from `VerityDevice::is_inline`, the same predicate the
rest of the inline verity support keys on, so the two stay consistent. An
image that both uses inline verity and ships per-slot templates is
contradictory; it warns and continues, since there is still nothing to
activate.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Installing an Azure Container Linux image failed in Configure:

    Failed to generate fstab at path '/etc/fstab'

Trident already skips storage configuration for UKI images using
root-verity, on the reasoning that such an image takes its mount topology
from the signed kernel command line rather than from fstab. ACL is the same
case with the verity on /usr instead of /, but the guard tested only
root-verity, so it did not apply.

Writing the fstab would have been wrong even where it succeeded. ACL
assembles /etc as an overlay, with the factory /usr/share/distro/etc as the
lower layer and the ROOT partition's /etc as the upper, so a file written
there wins over the image's own. The factory fstab is deliberately empty,
every mount ACL needs already comes from the command line (/usr and /) or a
shipped unit (/boot and /oem), and systemd-fstab-generator writes into
/run/systemd/generator, which takes precedence over /usr/lib/systemd/system.
A generated fstab would therefore have shadowed the units the image ships
rather than merely duplicating them.

Skip fstab generation for these images. Only fstab is skipped, not the whole
step as in the root-verity case: a usr-verity image still has a writable
root, so encryption and RAID configuration remain applicable.

The mechanism was confirmed against the shipping ACL image, whose initrd
mounts the overlay in dracut module 99setup-root and creates the ROOT /etc
and workdir immediately beforehand. The absence of /etc in the image is
therefore expected rather than a packaging fault, and not something for
Trident to repair.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
A hash offset only means anything when the hash tree shares the data image.
Producers emit the two together — Image Customizer sets `hashOffset` only for
inline verity, and gives a distinct image path otherwise — so an offset
accompanying a distinct hash image is contradictory.

Derivation ignored the offset in that case, which would have set the device
up as though it had never been given, and failed later at `veritysetup open`
with nothing pointing at the cause. Warn instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
An Azure Container Linux system installed with `trident install` did not
boot. Its initrd waits for `/dev/disk/by-label/EFI-SYSTEM`, which never
appears, and the boot ends in emergency mode.

Every other filesystem from the OS image is written to its partition
verbatim, so its label arrives with the rest of the bits. The ESP is the
exception: it is deployed file by file onto a filesystem Trident creates
itself, and `mkfs` gives that filesystem no label, so the image's label is
lost. `stream-disk` is unaffected, as it writes the ESP partition image
whole.

Copy the label from the extracted ESP image onto the filesystem, which
leaves the ESP as it would have been had it been written wholesale. The
label is read from the image itself rather than from COSI metadata, which
carries a filesystem UUID but no label. An image whose ESP is unlabelled
leaves the new filesystem unlabelled too, which is equally faithful.

`mkfs.vfat` can only set a label at creation time, and the label is not known
then: the ESP image is not extracted until deployment, well after
filesystems are created. So it is applied afterwards with `fatlabel`, which
writes the boot sector and may be used on a mounted filesystem, the label
surviving the unmount.

Adds `fatlabel` to the dependency registry, an `osutils::fatlabel` wrapper
that also enforces FAT's 11 character limit, and a filesystem-label reader
alongside the existing partition-label one in `osutils::blkid`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant