Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions docs/user/images.md
Original file line number Diff line number Diff line change
Expand Up @@ -412,8 +412,9 @@ 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;
- `image.vm` runs the image's qcow2 ephemerally with the `systemd-vmspawn`, QEMU and OVMF stack of the tools box
and `[initrd]` subtargets, and `[cmdline]`, the base command line of the UKI, for a direct kernel boot;
- `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
Expand Down Expand Up @@ -697,6 +698,7 @@ views and supply-chain artifacts are lazy subtargets:
├── [boot]
│ ├── [kernel]
│ ├── [initrd]
│ ├── [cmdline]
│ └── [uki]
├── [roothash]
└── [partitions]
Expand Down Expand Up @@ -1189,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
Expand Down
27 changes: 26 additions & 1 deletion image/artifacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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__":
Expand Down
11 changes: 8 additions & 3 deletions image/boot.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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"],
Expand All @@ -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)],
Expand Down
4 changes: 4 additions & 0 deletions image/boot.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ class Selection:
uki: Source | None
kernel: Source
initrd: Source
cmdline: Source | None


@dataclass(frozen=True)
Expand Down Expand Up @@ -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)
Expand All @@ -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,
)


Expand All @@ -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,
Expand Down
87 changes: 75 additions & 12 deletions image/vm.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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 = [],
Expand All @@ -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,
Expand Down
Loading