From 60d4df9e959f831035419f6f617602650f48eadf Mon Sep 17 00:00:00 2001 From: Paul Meyer Date: Fri, 13 Nov 2026 11:38:54 +0100 Subject: [PATCH 1/2] image: expose a UKI's base command line as [boot][cmdline] A direct kernel boot of the UKI's kernel has to pass the command line itself, usrhash= included, which is only known once the image is built. pe_sections now stops at the first .profile, so it returns the base sections rather than the last profile's. Signed-off-by: Paul Meyer --- docs/user/images.md | 3 ++- image/artifacts.py | 27 ++++++++++++++++++++++++++- image/boot.bzl | 11 ++++++++--- image/boot.py | 4 ++++ 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/docs/user/images.md b/docs/user/images.md index 574318c7..8684c1a2 100644 --- a/docs/user/images.md +++ b/docs/user/images.md @@ -412,7 +412,7 @@ when needed: for Secure Boot. With a `sign_expected_pcr_key`, a signed expected-PCR 11 policy covers each profile; set its `sign_expected_pcr` to false to opt out; - `image.bootable` selects a kernel and matching initrd from a logical image, exposed as `[uki]`, `[kernel]`, - and `[initrd]` subtargets; + and `[initrd]` subtargets, and `[cmdline]`, the base command line of the UKI, for a direct kernel boot; - `image.vm` runs the image's qcow2 ephemerally with the `systemd-vmspawn`, QEMU and OVMF stack of the tools box of the image's release, and binds the DDIs in `extensions` into the guest, where systemd merges them at boot: a sysext from `/var/lib/extensions`, a confext from `/var/lib/confexts`, and a DDI of both classes @@ -697,6 +697,7 @@ views and supply-chain artifacts are lazy subtargets: ├── [boot] │ ├── [kernel] │ ├── [initrd] +│ ├── [cmdline] │ └── [uki] ├── [roothash] └── [partitions] diff --git a/image/artifacts.py b/image/artifacts.py index d840e6c7..8b977e9b 100755 --- a/image/artifacts.py +++ b/image/artifacts.py @@ -36,12 +36,20 @@ class Source: def pe_sections(path: Path) -> dict[str, Section]: + """Return the base sections of a PE binary, by name. + + A UKI with profiles repeats sections such as `.cmdline` after each `.profile`, and systemd-stub + applies a profile's on top of the base. What precedes the first `.profile` is what boots without + one, so that is the only set read here. + """ try: with pefile.PE(str(path), fast_load=True) as image: sections: dict[str, Section] = {} file_size = path.stat().st_size for pe_section in image.sections: name = pe_section.Name.partition(b"\0")[0].decode("ascii", "strict") + if name == ".profile": + break virtual_size = pe_section.Misc_VirtualSize raw_size = pe_section.SizeOfRawData offset = pe_section.PointerToRawData @@ -118,12 +126,29 @@ def _extract(tree: Path, source: Source, out: Path) -> None: _copy_section(path, section, out) +def _extract_text(tree: Path, source: Source, out: Path) -> None: + path = _image_path(tree, source.path) + section = pe_sections(path).get(source.section) if source.section is not None else None + if section is None: + util.fail(f"artifacts: {path} has no {source.section} section") + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(section_text(path, section) + "\n", encoding="utf-8") + + +# Artifacts that are text, written without the NUL terminator the PE section carries, so a consumer +# can read them as they are. +_TEXT_ARTIFACTS = {"cmdline"} + + def main(argv: list[str] | None = None) -> None: spec = specs.parse(Spec, "artifacts", argv) source = _read_source(Path(spec["manifest"]), spec["artifact"]) with finalize.image(spec, program="artifacts") as tree: - _extract(tree, source, Path(spec["out"])) + if spec["artifact"] in _TEXT_ARTIFACTS: + _extract_text(tree, source, Path(spec["out"])) + else: + _extract(tree, source, Path(spec["out"])) if __name__ == "__main__": diff --git a/image/boot.bzl b/image/boot.bzl index 424682d1..8771600a 100644 --- a/image/boot.bzl +++ b/image/boot.bzl @@ -252,12 +252,14 @@ def uki(name: str, profiles: list[UkiProfile] = [], **kwargs) -> None: _uki(name = name, profiles = encode_profiles(profiles), **kwargs) _ARTIFACTS = { + "cmdline": "cmdline", "initrd": "initrd", "kernel": "vmlinuz", "uki": "image.efi", } BootArtifactsInfo = record( + cmdline = Artifact, initrd = Artifact, kernel = Artifact, uki = Artifact, @@ -270,10 +272,10 @@ def declare_boot_artifacts( image: ImageInfo, identifier: str | None = None, ) -> BootArtifactsInfo: - """Declare extraction of what a completed image boots: its kernel, initrd and UKI. + """Declare extraction of what a completed image boots: its kernel, initrd, command line and UKI. - The driver picks the newest kernel the image carries and reads the initrd and kernel out of - the UKI's PE sections when it boots one, so the results are what a direct kernel boot needs. + The driver picks the newest kernel the image carries and reads the initrd, kernel and command line + out of the UKI's PE sections when it boots one, so the results are what a direct kernel boot needs. """ selection = declare_out(ctx, identifier, "boot-artifacts.json") select = terminal_image_command( @@ -307,6 +309,7 @@ def declare_boot_artifacts( ctx.actions.run(extract, category = "boot_artifact_" + kind, identifier = identifier) artifacts[kind] = out return BootArtifactsInfo( + cmdline = artifacts["cmdline"], initrd = artifacts["initrd"], kernel = artifacts["kernel"], uki = artifacts["uki"], @@ -315,6 +318,8 @@ def declare_boot_artifacts( def boot_subtargets(artifacts: BootArtifactsInfo) -> dict[str, list[Provider]]: """The extracted artifacts, exposed wherever the image they come from is.""" return { + # Only a UKI carries one, so like [uki] it extracts on demand and fails for a plain kernel. + "cmdline": [DefaultInfo(default_output = artifacts.cmdline)], "initrd": [DefaultInfo(default_output = artifacts.initrd)], "kernel": [DefaultInfo(default_output = artifacts.kernel)], "uki": [DefaultInfo(default_output = artifacts.uki)], diff --git a/image/boot.py b/image/boot.py index ac05e99a..f92f9b5e 100755 --- a/image/boot.py +++ b/image/boot.py @@ -35,6 +35,7 @@ class Selection: uki: Source | None kernel: Source initrd: Source + cmdline: Source | None @dataclass(frozen=True) @@ -111,6 +112,7 @@ def _select(tree: Path) -> Selection: uki=source, kernel=Source(source.path, ".linux"), initrd=initrd, + cmdline=Source(source.path, ".cmdline") if ".cmdline" in uki.sections else None, ) kernels = _kernels(tree) @@ -125,6 +127,7 @@ def _select(tree: Path) -> Selection: uki=None, kernel=Source(_image_name(tree, kernel.path)), initrd=Source(_image_name(tree, initrd)), + cmdline=None, ) @@ -141,6 +144,7 @@ def _write_selection(selection: Selection, out: Path) -> None: "uki": _source_json(selection.uki) if selection.uki is not None else None, "kernel": _source_json(selection.kernel), "initrd": _source_json(selection.initrd), + "cmdline": _source_json(selection.cmdline) if selection.cmdline is not None else None, }, indent=2, sort_keys=True, From af75e24ca001577f232b49fd528c1189ac14d6e7 Mon Sep 17 00:00:00 2001 From: Paul Meyer Date: Fri, 13 Nov 2026 11:39:21 +0100 Subject: [PATCH 2/2] image: make image.vm's vmspawn options configurable Add image_format, linux, initrd, cmdline_file and coco after vmspawn's own options, and vmspawn_args for any other option. Those follow tine's, so options vmspawn takes the last value of override them. Defaults stay. Signed-off-by: Paul Meyer --- docs/user/images.md | 29 +++++++++++++-- image/vm.bzl | 87 ++++++++++++++++++++++++++++++++++++++------- 2 files changed, 102 insertions(+), 14 deletions(-) diff --git a/docs/user/images.md b/docs/user/images.md index 8684c1a2..aa91d9f3 100644 --- a/docs/user/images.md +++ b/docs/user/images.md @@ -413,7 +413,8 @@ when needed: set its `sign_expected_pcr` to false to opt out; - `image.bootable` selects a kernel and matching initrd from a logical image, exposed as `[uki]`, `[kernel]`, and `[initrd]` subtargets, and `[cmdline]`, the base command line of the UKI, for a direct kernel boot; -- `image.vm` runs the image's qcow2 ephemerally with the `systemd-vmspawn`, QEMU and OVMF stack of the tools box +- `image.vm` runs the image's qcow2 (or raw) ephemerally, from its ESP or by a direct kernel boot, with the + `systemd-vmspawn`, QEMU and OVMF stack of the tools box of the image's release, and binds the DDIs in `extensions` into the guest, where systemd merges them at boot: a sysext from `/var/lib/extensions`, a confext from `/var/lib/confexts`, and a DDI of both classes from both. Each must have the `system` scope. It binds the DDIs in `portables`, portable images and @@ -1190,7 +1191,31 @@ and the boot loader read (`io.systemd.stub.kernel-cmdline-extra` and its `boot` The guest boots the image's `[qcow2]`, so it sees a disk of the image's `virtual_size` and claims the room past the built partitions itself, for example with `systemd-repart`. `grow` gives it a larger disk than that (vmspawn's `--grow-image`): vmspawn creates the ephemeral overlay at that size, and the qcow2 stays -untouched. +untouched. `image_format = "raw"` hands it the raw disk instead, which has no room past the partitions. + +By default the firmware boots the image from its ESP. `linux` and `initrd` boot a kernel directly instead, +vmspawn's `--linux` and `--initrd`, and take any file; a bootable disk's `[boot][kernel]` and +`[boot][initrd]` are the ones its UKI carries. Unlike the UKI, a bare kernel brings no command line of its +own, so `cmdline_file` names the file to read it from when the VM starts, usually the disk's +`[boot][cmdline]`: it carries the root hash, which is only known once the image is built. `cmdline_extra` +follows it. + +```python +image.vm( + name = "os-direct-vm", + image = ":os", + linux = ":os[boot][kernel]", + initrd = ":os[boot][initrd]", + cmdline_file = ":os[boot][cmdline]", +) +``` + +`coco` runs the guest as a confidential VM, vmspawn's `--coco=`, for instance `"sev-snp"` or `"tdx"`; vmspawn +checks what that needs of the host and of the rest of the command line, such as a direct kernel boot for +SEV-SNP. `vmspawn_args` passes any other vmspawn option. They follow everything tine passes, so one that +vmspawn takes the last value of, such as `--console=` or `--tpm=`, overrides tine's. The only options tine +always passes are `--image`, `--image-format`, `--ephemeral`, which keeps the guest from writing the build +output, and the defaults `--console=native`, `--kvm=yes` and `--network-user-mode`. ```sh tine buck run //examples/image:boot-demo-vm.fedora diff --git a/image/vm.bzl b/image/vm.bzl index 0c5eecbb..29875d70 100644 --- a/image/vm.bzl +++ b/image/vm.bzl @@ -9,26 +9,53 @@ load("//distribution:defs.bzl", "distribution") load(":ddi.bzl", "DdiInfo", "DiskConversionInfo", "SIZE_PATTERN") load(":extension.bzl", "ExtensionInfo", "check_portable", "ddi_image_name", "system_extension") -def _image_vm_impl(ctx: AnalysisContext) -> list[Provider]: +# vmspawn takes the kernel command line as its own trailing arguments, and only the build knows the +# one a direct kernel boot needs, as the UKI's carries the root hash. So the runner reads it from the +# file at run time. Word splitting is harmless: vmspawn joins the arguments with spaces again. Globbing +# is not, as a command line can carry patterns such as systemd.image_filter=usr=AOS_*. +_CMDLINE_FILE_RUNNER = 'set -f; cmdline=$(cat "$1"); shift; exec systemd-vmspawn $cmdline "$@"' + +def _image(image: Dependency, image_format: str) -> Artifact: + if image_format == "raw": + disk = image[DdiInfo].disk + if disk == None: + fail("image_vm: {} is not a disk".format(image.label)) + return disk + # The qcow is the intended VM format, and it has virtual_size; the raw doesn't. - qcow2 = ctx.attrs.image[DefaultInfo].sub_targets.get("qcow2") + qcow2 = image[DefaultInfo].sub_targets.get("qcow2") if qcow2 == None: - fail("image_vm: {} is not a disk with a qcow2".format(ctx.attrs.image.label)) - run = cmd_args( - box.run( - box = ctx.attrs._box[box.Info], - exe = "systemd-vmspawn", - relaxed = True, - ), + fail("image_vm: {} is not a disk with a qcow2".format(image.label)) + return qcow2[DiskConversionInfo].image + +def _image_vm_impl(ctx: AnalysisContext) -> list[Provider]: + if ctx.attrs.cmdline_file != None: + run = cmd_args( + box.run(box = ctx.attrs._box[box.Info], exe = "sh", relaxed = True), + "-c", + _CMDLINE_FILE_RUNNER, + "sh", + ctx.attrs.cmdline_file, + ) + else: + run = cmd_args(box.run(box = ctx.attrs._box[box.Info], exe = "systemd-vmspawn", relaxed = True)) + + run.add( "--image", - qcow2[DiskConversionInfo].image, - "--image-format=qcow2", + _image(ctx.attrs.image, ctx.attrs.image_format), + "--image-format={}".format(ctx.attrs.image_format), "--console=native", + # Necessary rather than a default: the image is a build output, which the guest must not write. "--ephemeral", "--kvm=yes", "--network-user-mode", ) + if ctx.attrs.linux != None: + run.add(cmd_args(ctx.attrs.linux, format = "--linux={}")) + if ctx.attrs.initrd != None: + run.add(cmd_args(ctx.attrs.initrd, format = "--initrd={}")) + if ctx.attrs.grow != None: if not regex_match(SIZE_PATTERN, ctx.attrs.grow): fail("image_vm: invalid grow size {!r}".format(ctx.attrs.grow)) @@ -57,6 +84,9 @@ def _image_vm_impl(ctx: AnalysisContext) -> list[Provider]: if ctx.attrs.secure_boot or ctx.attrs.tpm: run.add("--tpm=yes") + if ctx.attrs.coco != None: + run.add("--coco={}".format(ctx.attrs.coco)) + directories = {} for dep in ctx.attrs.extensions: extension = system_extension("image_vm", dep) @@ -102,6 +132,9 @@ def _image_vm_impl(ctx: AnalysisContext) -> list[Provider]: fail("image_vm: invalid credential name {!r}".format(name)) run.add("--set-credential={}:{}".format(name, ctx.attrs.credentials[name])) + # After everything tine passes, so that an option vmspawn takes the last value of overrides it. + run.add(ctx.attrs.vmspawn_args) + # vmspawn takes extra kernel command line arguments as its own trailing arguments, and picks how # to deliver them from what it is booting: an SMBIOS OEM string each for the stub and the boot # loader for a UKI, `-append` for a bare kernel image. Handing it the arguments rather than the @@ -125,6 +158,16 @@ _image_vm = rule( default = [], doc = "kernel command line arguments appended to the ones the image boots with", ), + "cmdline_file": attrs.option( + attrs.source(), + default = None, + doc = "file holding the kernel command line to boot `linux` with, such as a disk's `[boot][cmdline]`; `cmdline_extra` follows it", + ), + "coco": attrs.option( + attrs.string(), + default = None, + doc = 'confidential computing technology to run the guest with, as vmspawn\'s --coco=, e.g. "sev-snp" or "tdx"', + ), "cpus": attrs.option( attrs.int(), default = None, @@ -146,7 +189,22 @@ _image_vm = rule( default = None, doc = 'size of the disk the guest sees, e.g. "8G", for more room than the virtual_size of the image', ), - "image": attrs.dep(providers = [DdiInfo], doc = "the disk image whose qcow2 boots ephemerally"), + "image": attrs.dep(providers = [DdiInfo], doc = "the disk image the guest boots from, ephemerally"), + "image_format": attrs.enum( + ["qcow2", "raw"], + default = "qcow2", + doc = "which encoding of `image` the guest gets, as vmspawn's --image-format=", + ), + "initrd": attrs.option( + attrs.source(), + default = None, + doc = "initrd for a direct kernel boot of `linux`, such as a disk's `[boot][initrd]`", + ), + "linux": attrs.option( + attrs.source(), + default = None, + doc = "kernel to boot directly rather than through the image's ESP, such as a disk's `[boot][kernel]`", + ), "portables": attrs.list( attrs.dep(providers = [DdiInfo]), default = [], @@ -165,6 +223,11 @@ _image_vm = rule( default = False, doc = "attach a software TPM, which Secure Boot implies", ), + "vmspawn_args": attrs.list( + attrs.arg(), + default = [], + doc = "further systemd-vmspawn options, after the ones tine passes, so they can override those vmspawn takes the last value of", + ), # The tools box of the image's release built the image and contains the VM stack. Booting the image # in that box builds no other box. "_box": distribution.TOOLS_BOX_ATTR,