From 607183953aae63c1cd9853c242f6266d301f1b6f Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Thu, 5 Nov 2026 12:37:19 +0000 Subject: [PATCH 1/6] image: share image.vm's checks of extensions and portables We will need the same checks in another place in the next commit. Signed-off-by: Martin Pitt --- image/extension.bzl | 21 +++++++++++++++++++++ image/vm.bzl | 16 +++++----------- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/image/extension.bzl b/image/extension.bzl index 17dfcb8e..146995c5 100644 --- a/image/extension.bzl +++ b/image/extension.bzl @@ -38,6 +38,27 @@ PortableInfo = provider( fields = {}, ) +def system_extension(rule: str, dep: Dependency) -> ExtensionInfo: + """Return the ExtensionInfo of `dep`, which `rule` hands to a host to merge.""" + extension = dep[ExtensionInfo] + if "system" not in extension.scope: + fail("{}: {} is not an extension with the system scope".format(rule, dep.label)) + return extension + +def check_portable(rule: str, dep: Dependency) -> None: + """Fail unless `dep`, which `rule` hands to portabled, is a portable image or an extension of one.""" + extension = dep.get(ExtensionInfo) + if dep.get(PortableInfo) == None and (extension == None or "portable" not in extension.scope): + fail("{}: {} is neither a portable image nor an extension with the portable scope".format(rule, dep.label)) + +def ddi_image_name(disk: Artifact) -> str: + """The image name systemd takes from the file name of `disk`: everything up to the first "_". + + systemd looks up an extension's release file by it, and portablectl attaches the units of a + portable image by it. + """ + return disk.basename.split("_")[0] + # _CLASSES is a list and not a dict, because its order is the order of the CopyFiles= lines of the # root partition. _CLASSES = [ diff --git a/image/vm.bzl b/image/vm.bzl index 0e9b1e01..0c5eecbb 100644 --- a/image/vm.bzl +++ b/image/vm.bzl @@ -7,7 +7,7 @@ load("@prelude//platforms:defs.bzl", "host_configuration") load("//box:defs.bzl", "box") load("//distribution:defs.bzl", "distribution") load(":ddi.bzl", "DdiInfo", "DiskConversionInfo", "SIZE_PATTERN") -load(":extension.bzl", "ExtensionInfo", "PortableInfo") +load(":extension.bzl", "ExtensionInfo", "check_portable", "ddi_image_name", "system_extension") def _image_vm_impl(ctx: AnalysisContext) -> list[Provider]: # The qcow is the intended VM format, and it has virtual_size; the raw doesn't. @@ -59,25 +59,19 @@ def _image_vm_impl(ctx: AnalysisContext) -> list[Provider]: directories = {} for dep in ctx.attrs.extensions: - extension = dep[ExtensionInfo] - if "system" not in extension.scope: - fail("image_vm: {} is not an extension with the system scope".format(dep.label)) + extension = system_extension("image_vm", dep) for class_name, directory in (("sysext", "extensions"), ("confext", "confexts")): if class_name in extension.classes: directories.setdefault(directory, []).append(dep[DdiInfo].disk) for dep in ctx.attrs.portables: - extension = dep.get(ExtensionInfo) - if dep.get(PortableInfo) == None and (extension == None or "portable" not in extension.scope): - fail("image_vm: {} is neither a portable image nor an extension with the portable scope".format(dep.label)) + check_portable("image_vm", dep) directories.setdefault("portables", []).append(dep[DdiInfo].disk) for directory in sorted(directories): files = {} for disk in directories[directory]: - # systemd takes the image name from the file name, up to the first "_", and looks up an - # extension's release file by it. portablectl attaches the units of a portable image - # by it too, so the guest gets each DDI as .raw. - name = disk.basename.split("_")[0] + ".raw" + # The guest gets each DDI as .raw, so that systemd finds it by that name. + name = ddi_image_name(disk) + ".raw" if name in files: fail("image_vm: duplicate {} DDI {!r}".format(directory, name)) files[name] = disk From 54d67e7f713b672b2097c3cf5bc8c64bddb6148a Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Thu, 5 Nov 2026 13:57:30 +0000 Subject: [PATCH 2/6] image: provide disk conversion as driver API function The next commit adds a driver that writes a disk and converts it within the same action. Make the conversion a function it can call. Signed-off-by: Martin Pitt --- image/BUCK | 8 ++++++++ image/convert.py | 16 ++++++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/image/BUCK b/image/BUCK index bad7e0d7..61b2a040 100644 --- a/image/BUCK +++ b/image/BUCK @@ -71,6 +71,14 @@ python.binary( visibility = ["PUBLIC"], ) +# A driver that writes a disk converts it in its own action, rather than handing Buck a raw for the +# conversion to read. +python.library( + name = "convert-lib", + srcs = ["convert.py"], + deps = ["//:specs", "//:util"], +) + python.binary( name = "uki", main = "uki.py", diff --git a/image/convert.py b/image/convert.py index 4a448fc7..3a0a3579 100755 --- a/image/convert.py +++ b/image/convert.py @@ -22,12 +22,11 @@ class Spec(TypedDict): virtual_size: str | None -def main(argv: list[str] | None = None) -> None: - spec = specs.parse(Spec, "convert", argv) +def convert(source: Path, out: Path, fmt: str, virtual_size: str | None) -> None: + """Write the raw disk `source` to `out` in the format `fmt`, one of ddi.bzl's DISK_FORMATS. - source = Path(spec["input"]) - out = Path(spec["out"]) - fmt = spec["format"] + `virtual_size` sizes the disk of a qcow2 or vmdk, which otherwise is as large as `source`. + """ if fmt == "raw.zst": cmd = [ "zstd", "-q", "-f", @@ -52,7 +51,7 @@ def main(argv: list[str] | None = None) -> None: fail(f"convert: unknown format {fmt!r}") with tempfile.TemporaryDirectory() as tmp: - if size := spec["virtual_size"]: + if size := virtual_size: # A VMDK cannot be resized after the fact, and a streamOptimized one cannot be converted # into at a larger size either, as the raw ends within a grain. So convert from a qcow2 # overlay of that size instead, which reads the raw and zeros past its end, and costs no @@ -80,5 +79,10 @@ def main(argv: list[str] | None = None) -> None: ) +def main(argv: list[str] | None = None) -> None: + spec = specs.parse(Spec, "convert", argv) + convert(Path(spec["input"]), Path(spec["out"]), spec["format"], spec["virtual_size"]) + + if __name__ == "__main__": main() From aba5d929d0948eedea74adf5637e30aa9c3d4db2 Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Fri, 6 Nov 2026 09:43:00 +0000 Subject: [PATCH 3/6] image: add DdiInfo.virtual_size This supports creating disks derived from a base one. That needs to know the base's `virtual_size`. Signed-off-by: Martin Pitt --- image/compose.bzl | 2 ++ image/ddi.bzl | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/image/compose.bzl b/image/compose.bzl index e4053d7c..b1cfbb09 100644 --- a/image/compose.bzl +++ b/image/compose.bzl @@ -462,6 +462,7 @@ def _disk_impl(ctx: AnalysisContext) -> list[Provider]: imported = [system.info], mkfs_options = ctx.attrs.mkfs_options, seed = ctx.attrs.disk_seed, + virtual_size = ctx.attrs.virtual_size, ) # repart lists the files of the system partitions in the run that writes them. The disk @@ -509,6 +510,7 @@ def _disk_impl(ctx: AnalysisContext) -> list[Provider]: split = True, strip_pkgdb = ctx.attrs.strip_pkgdb, verity_key = resolve_signing_key(ctx.attrs.verity_key), + virtual_size = ctx.attrs.virtual_size, ) manifests = composed.info.manifests boot_sub_targets = {} diff --git a/image/ddi.bzl b/image/ddi.bzl index 640de118..f9bcbd41 100644 --- a/image/ddi.bzl +++ b/image/ddi.bzl @@ -218,6 +218,9 @@ DdiInfo = provider( "manifests": provider_field(dict[str, Artifact], default = {}), "partitions": provider_field(list[PartitionInfo]), "root_hash": provider_field(RootHashInfo | None, default = None), + # The size of a virtual disk of `disk`, with room past the partitions for first boot to claim, as + # image.disk's virtual_size. A disk built on this one needs the same. + "virtual_size": provider_field(str | None, default = None), }, ) @@ -285,6 +288,7 @@ def declare_ddi( extension: dict[str, typing.Any] | None = None, tmpfiles: list[str] | None = None, exclude: list[str] = [], + virtual_size: str | None = None, ) -> DdiOutput: """Declare the systemd-repart action that builds a DDI from an image and partition definitions. @@ -292,7 +296,8 @@ def declare_ddi( keeps it, for a system nothing ever resolves packages in. The image's `[pkgdb]` and `[sbom]` subtargets still capture it from the tree itself. `extension` makes the driver package the tree as an extension (see extension.bzl). `tmpfiles` replaces the tmpfiles lines of `image`. The - partitions leave out the contents of the directories in `exclude`. + partitions leave out the contents of the directories in `exclude`. `virtual_size` goes into the + provider, see DdiInfo. """ _check_definitions( definitions, @@ -427,6 +432,7 @@ def declare_ddi( partitions = available_partitions, manifests = manifests, root_hash = root_hash, + virtual_size = virtual_size, ) sub_targets = manifest_sub_targets(manifests) if manifest != None: From 76c02f52961e773bccea597eb6255625aac02f0d Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Thu, 5 Nov 2026 14:09:08 +0000 Subject: [PATCH 4/6] image: add image.ddi_preloaded_disk This copies the DDIs onto the ESP of a built disk (where systemd-sysupdate can reach them), and leaves the rest of the disk alone. This allows a project that builds extensions or portable services to ship them bundled with a base OS that is built elsewhere, and keeping all of its signatures. Signed-off-by: Martin Pitt --- docs/user/images.md | 38 +++++++++++- examples/image/BUCK | 43 +++++++++++++ examples/image/expected.json | 6 ++ image/BUCK | 11 ++++ image/defs.bzl | 2 + image/image.bzl | 2 + image/preload.bzl | 116 +++++++++++++++++++++++++++++++++++ image/preload.py | 102 ++++++++++++++++++++++++++++++ image/preload_test.py | 36 +++++++++++ 9 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 image/preload.bzl create mode 100755 image/preload.py create mode 100644 image/preload_test.py diff --git a/docs/user/images.md b/docs/user/images.md index c9df9179..220025ad 100644 --- a/docs/user/images.md +++ b/docs/user/images.md @@ -870,6 +870,40 @@ boot is not exposed the way the same package in the running system is. The union everything in the UKI, provided the image keeps the kernel package installed in its own tree, which is where the UKI's kernel and modules come from. +### image.ddi_preloaded_disk + +`image.ddi_preloaded_disk()` adds extension and portable service DDIs to the ESP of an already built +disk. It keeps all existing signatures intact, and fails if any file on the ESP already exists. + +```Starlark +image.ddi_preloaded_disk( + name = "appliance", + base = ":base-os", + extensions = [":debug-tools", ":site-config"], + portables = [":webapp", ":webapp-config"], +) +``` + +- `base` (target providing `image.DdiInfo`): The disk (raw or zstd-compressed), such as an + `image.disk()` or another `image.ddi_preloaded_disk()`. It needs exactly one ESP partition, with + room for the DDIs. +- `extensions` (list of extension targets): Each must have the `system` scope. A DDI goes to + `/extensions/.raw.v/__.raw` on the ESP, regardless of its classes. +- `portables` (list of portable images and extensions with the `portable` scope): A DDI goes to + `/portables/.raw.v/__.raw` on the ESP. +- `virtual_size` (size string such as `"20G"`): Same as for `image.disk()`. It defaults to the base's. + +These paths are compatible with `systemd-sysupdate` transfer files and `systemd-vpick` versioning, +so that updates replace the preloaded extensions. + +Nothing currently merges or attaches the DDIs at boot: systemd does not look for extensions and +portable images in the ESP. Booting/instantiation needs to do that with creating symlinks to `/run` +or `/var`, followed by `systemd-{conf,sys}ext refresh` (as `systemd-tmpfiles-setup.service` runs +after the initial confext/sysext merge). For portable services, call `portablectl attach` as usual. + +The target defaults to the disk as `.raw.zst`, and offers `[qcow2]` and `[vmdk]` subtargets. +It can be booted by `image.vm`. + ### Kernel modules in the UKI A UKI carries the kernel, the initrds it was built from, and one further initrd the `image.uki` rule @@ -1137,7 +1171,9 @@ versioned UKI creation, semantic boot-artifact extraction, ESP-layer assembly, a `demo-portable` is a portable service image, and `demo-portable-ext` an extension for it. Running `boot-demo-vm` validates the interactive VM runner. `boot-demo-vm-smoke` boots `boot-demo`, merges `demo-ext` and `demo-confext` at boot, and attaches the two portable DDIs with -`portablectl attach --extension demo-portable-ext demo-portable`. +`portablectl attach --extension demo-portable-ext demo-portable`. `boot-demo-preloaded` is `boot-demo` +with these four DDIs on its ESP. `boot-demo-preloaded-vm-smoke` boots it, activates the DDIs from the ESP, +and checks that they merge and attach. A host that merges a confext or attaches a portable image needs two things, which `boot-demo` provides: diff --git a/examples/image/BUCK b/examples/image/BUCK index 18782481..cacb865d 100644 --- a/examples/image/BUCK +++ b/examples/image/BUCK @@ -385,6 +385,49 @@ image.extension( ], ) +# boot-demo with the demo DDIs on its ESP, where systemd-sysupdate would update them. Nothing merges or +# attaches them at boot yet: whoever instantiates the disk does that. +image.ddi_preloaded_disk( + name = "boot-demo-preloaded", + base = ":boot-demo", + extensions = [":demo-confext", ":demo-ext"], + portables = [":demo-portable", ":demo-portable-ext"], +) + +image.vm( + name = "boot-demo-preloaded-vm", + autologin = "root", + credentials = { + "firstboot.locale": "C.UTF-8", + "firstboot.timezone": "UTC", + }, + image = ":boot-demo-preloaded", +) + +# The checks activate the DDIs on the ESP the way an instance would, see "image.ddi_preloaded_disk" in images.md. +# The ESP carries one version of each, so the links can name the .raw.v directories. +tests.vm_smoke( + name = "boot-demo-preloaded-vm-smoke", + vm = ":boot-demo-preloaded-vm", + checks = [ + # The disk has boot-demo's virtual_size + 'test "$(lsblk --nodeps --noheadings --bytes --output SIZE /dev/vda)" = 2147483648', + # The ESP is where systemd-gpt-auto-generator mounts it, which is /boot on this image. + 'test -d "$(bootctl --print-esp-path)/extensions"', + "mkdir -p /run/extensions /run/confexts", + 'ln -s "$(bootctl --print-esp-path)/extensions/demo-ext.raw.v" /run/extensions/', + 'ln -s "$(bootctl --print-esp-path)/extensions/demo-confext.raw.v" /run/confexts/', + "systemd-sysext refresh", + "systemd-confext refresh", + # systemd-vpick picked each newest version out of its directory, and the extension-release of + # each matches boot-demo. + "test -x /usr/bin/zsh", + "test -f /etc/tine/from-confext", + 'portablectl attach --now --runtime --extension "$(bootctl --print-esp-path)/portables/demo-portable-ext.raw.v" "$(bootctl --print-esp-path)/portables/demo-portable.raw.v"', + "systemctl is-active demo-portable.service", + ], +) + # A file tree that differs per distribution is one copy whose source is a select, rather than a # target per distribution and an alias choosing between them. A distribution the select names no # tree for gets `None`, which copies nothing; only arch ships one so every other exercises that. diff --git a/examples/image/expected.json b/examples/image/expected.json index 7713ecc0..92e5a631 100644 --- a/examples/image/expected.json +++ b/examples/image/expected.json @@ -1,5 +1,8 @@ { "arm64": { + "boot-demo-preloaded.fedora": { + "sha256": "eb0291105e6dda37fbe5b77bf603ece5c46ef75077238949aa0faa1191a4dacf" + }, "boot-demo.fedora": { "sha256": "e3377bc790d7f40a25bd0126b5d18904d29bcc9cbb287047113019845d971b1f" }, @@ -35,6 +38,9 @@ } }, "x86_64": { + "boot-demo-preloaded.fedora": { + "sha256": "5639ec39438ad8de3facbc193b55163785b36956abec603061c95dce4229a8d2" + }, "boot-demo.fedora": { "sha256": "810f4d8cd0ae75dd5d58c09784615409a4c0c5983f44b9a7e68b3e2b758aaab0" }, diff --git a/image/BUCK b/image/BUCK index 61b2a040..9e6a2c58 100644 --- a/image/BUCK +++ b/image/BUCK @@ -17,6 +17,7 @@ image_tools( ddi = ":ddi", layer = ":layer", manifest = ":manifest", + preload = ":preload", python = "//tools:python3", sbom = ":sbom", syft = "//tools:syft", @@ -79,6 +80,13 @@ python.library( deps = ["//:specs", "//:util"], ) +python.binary( + name = "preload", + main = "preload.py", + deps = [":convert-lib", "//:specs", "//:util"], + visibility = ["PUBLIC"], +) + python.binary( name = "uki", main = "uki.py", @@ -118,6 +126,8 @@ python.test( "layer_test.py", "manifest.py", "manifest_test.py", + "preload.py", + "preload_test.py", "substitute.py", "substitute_test.py", "uki.py", @@ -125,6 +135,7 @@ python.test( "version_test.py", ], deps = [ + ":convert-lib", ":finalize", ":version", "//:specs", diff --git a/image/defs.bzl b/image/defs.bzl index bec198f6..5acd3c1e 100644 --- a/image/defs.bzl +++ b/image/defs.bzl @@ -48,6 +48,7 @@ load( "layer", "ops", ) +load(":preload.bzl", "ddi_preloaded_disk") load(":publish.bzl", "PublishedFile", "PublishedInfo", "image_artifacts") load( ":sign.bzl", @@ -85,6 +86,7 @@ image = struct( extension = extension, portable = portable, disk = disk, + ddi_preloaded_disk = ddi_preloaded_disk, artifacts = image_artifacts, initrd = initrd_image, # The core kernel modules a UKI carries unless a target says otherwise; extend it with `+`. diff --git a/image/image.bzl b/image/image.bzl index c39a59f1..d625d4e5 100644 --- a/image/image.bzl +++ b/image/image.bzl @@ -22,6 +22,7 @@ ImageToolsInfo = provider( "ddi": provider_field(Dependency), "layer": provider_field(Dependency), "manifest": provider_field(Dependency), + "preload": provider_field(Dependency), "python": provider_field(Dependency), "sbom": provider_field(Dependency), "syft": provider_field(Dependency), @@ -37,6 +38,7 @@ _TOOLS = [ "ddi", "layer", "manifest", + "preload", "python", "sbom", "syft", diff --git a/image/preload.bzl b/image/preload.bzl new file mode 100644 index 00000000..ced5adeb --- /dev/null +++ b/image/preload.bzl @@ -0,0 +1,116 @@ +# SPDX-FileCopyrightText: Amutable GmbH +# SPDX-License-Identifier: MPL-2.0 + +"""A built disk with preloaded extensions and portable images.""" + +load("//:specs.bzl", "specs") +load("//box:defs.bzl", "box") +load("//distribution:defs.bzl", "distribution") +load(":ddi.bzl", "DISK_FORMATS", "DdiInfo", "DiskConversionInfo", "SIZE_PATTERN", "VIRTUAL_SIZE_FORMATS") +load(":extension.bzl", "ExtensionInfo", "check_portable", "ddi_image_name", "system_extension") +load(":image.bzl", "IMAGE_TOOLS_ATTR", "ImageToolsInfo", "spec_path") + +def _esp_path(directory: str, disk: Artifact) -> str: + """Where `disk` goes on the ESP: __.raw in .raw.v below `directory`. + + That is how systemd-sysupdate and systemd-vpick lay out the versions of one image. vpick reads the + version and architecture from what sits between the image name and ".raw", so drop the DDI's class + suffix. The directory gives systemd the image name. + """ + stem = disk.basename.removesuffix(".raw").removesuffix(".sysext").removesuffix(".confext") + return "/{}/{}.raw.v/{}.raw".format(directory, ddi_image_name(disk), stem) + +def _ddi_preloaded_disk_impl(ctx: AnalysisContext) -> list[Provider]: + base_info = ctx.attrs.base[DdiInfo] + base = base_info.disk + if base == None: + fail("ddi_preloaded_disk: {} has no composed disk".format(ctx.attrs.base.label)) + if ctx.attrs.virtual_size != None and not regex_match(SIZE_PATTERN, ctx.attrs.virtual_size): + fail("ddi_preloaded_disk: invalid virtual_size {!r}".format(ctx.attrs.virtual_size)) + + # First boot of the base claims the room past its partitions, so the preloaded disk needs as much. + virtual_size = ctx.attrs.virtual_size or base_info.virtual_size + + sources = [] + for dep in ctx.attrs.extensions: + system_extension("ddi_preloaded_disk", dep) + sources.append(("extensions", dep[DdiInfo].disk)) + for dep in ctx.attrs.portables: + check_portable("ddi_preloaded_disk", dep) + sources.append(("portables", dep[DdiInfo].disk)) + files = {} + for directory, disk in sources: + path = _esp_path(directory, disk) + if path in files: + fail("ddi_preloaded_disk: two DDIs go to {}".format(path)) + files[path] = disk + + # Unlike image.disk, this has no raw, and each format is a run of the driver of its own, which edits + # the ESP and converts in one action. A base can be a disk with gigabytes of holes, such as an + # imported release, and Buck would hash and cache a raw of it in full. + disks = {} + for format in DISK_FORMATS: + out = ctx.actions.declare_output("{}.{}".format(ctx.label.name, format)) + ctx.actions.run( + cmd_args( + box.run(box = ctx.attrs._box[box.Info], exe = ctx.attrs._tools[ImageToolsInfo].preload), + specs.args( + ctx.actions, + spec_path(format, "preload"), + { + "base": base, + "files": files, + "format": format, + "out": out.as_output(), + "virtual_size": virtual_size if format in VIRTUAL_SIZE_FORMATS else None, + }, + ), + ), + category = "preload", + identifier = format, + ) + disks[format] = out + return [ + DefaultInfo( + default_output = disks["raw.zst"], + sub_targets = {format: [DefaultInfo(default_output = out), DiskConversionInfo(format = format, image = out)] for format, out in disks.items()}, + ), + DdiInfo(disk = disks["raw.zst"], partitions = [], virtual_size = virtual_size), + ] + +_ddi_preloaded_disk = rule( + impl = _ddi_preloaded_disk_impl, + attrs = { + "base": attrs.dep(providers = [DdiInfo], doc = "the disk, raw or zstd-compressed, whose ESP gets the DDIs"), + "extensions": attrs.list( + attrs.dep(providers = [DdiInfo, ExtensionInfo]), + default = [], + doc = "system extensions, put on the ESP below /extensions", + ), + "portables": attrs.list( + attrs.dep(providers = [DdiInfo]), + default = [], + doc = "portable service images and their extensions, put on the ESP below /portables", + ), + "virtual_size": attrs.option( + attrs.string(), + default = None, + doc = 'size of the disk a qcow2 or vmdk describes, e.g. "20G", instead of the base\'s', + ), + # The tools box of the release has the tools that read the partition table, write the FAT and + # convert the disk. + "_box": distribution.TOOLS_BOX_ATTR, + } + | IMAGE_TOOLS_ATTR, + supports_incoming_transition = True, +) + +def ddi_preloaded_disk( + name: str, + distro: str | None = None, + visibility: list[str] | None = None, + target_compatible_with: list[str] | Select | None = None, + **kwargs, +) -> None: + """Declare one, compatible with the distributions its package serves.""" + _ddi_preloaded_disk(name = name, **(distribution.distributed(name, distro, visibility, target_compatible_with) | kwargs)) diff --git a/image/preload.py b/image/preload.py new file mode 100755 index 00000000..c0a82337 --- /dev/null +++ b/image/preload.py @@ -0,0 +1,102 @@ +#!/usr/bin/python3 + +# SPDX-FileCopyrightText: Amutable GmbH +# SPDX-License-Identifier: MPL-2.0 + +"""Add files to the ESP of a disk, and leave every other byte of the disk alone.""" + +import json +import os +import subprocess +import tempfile +from collections.abc import Iterable +from pathlib import Path, PurePosixPath +from typing import TypedDict + +import specs +from util import fail + +from convert import convert + + +class Spec(TypedDict): + base: str + # each absolute path on the ESP -> the file to put there + files: dict[str, str] + # one of ddi.bzl's DISK_FORMATS + format: str + out: str + virtual_size: str | None + + +def esp_offset(disk: Path) -> int: + """Return the byte offset of the one ESP partition of `disk`.""" + with tempfile.TemporaryDirectory() as empty: + listing = subprocess.run( + [ + "systemd-repart", + "--dry-run=yes", + f"--definitions={empty}", + "--json=short", + "--no-pager", + str(disk), + ], + check=True, + stdout=subprocess.PIPE, + env=os.environ | {"SYSTEMD_LOG_LEVEL": "warning"}, + ) + offsets = [partition["offset"] for partition in json.loads(listing.stdout) if partition["type"] == "esp"] + if len(offsets) != 1: + fail(f"preload: the base disk needs exactly one ESP partition, it has {len(offsets)}") + return int(offsets[0]) + + +def directories(present: Iterable[str], targets: Iterable[str]) -> list[str]: + """Return the directories to create for `targets`, parents first, on a FAT with `present` paths. + + FAT compares names regardless of case. A target that is present already fails, as the base + carries a file of that name. + """ + known = {path.casefold() for path in present} + missing = [] + for target in sorted(targets): + if target.casefold() in known: + fail(f"preload: the base disk's ESP already contains {target}") + for parent in reversed(PurePosixPath(target).parents[:-1]): + if str(parent).casefold() not in known: + known.add(str(parent).casefold()) + missing.append(str(parent)) + return missing + + +def add(image: str, files: dict[str, str]) -> None: + """Copy `files` onto the FAT that the mtools drive `image` names, creating their directories.""" + # mdir lists a directory with a trailing "/", below the drive prefix "::". + listing = subprocess.run( + ["mdir", "-i", image, "-/", "-b", "::/"], check=True, stdout=subprocess.PIPE, text=True + ) + present = [line.removeprefix("::").rstrip("/") for line in listing.stdout.splitlines()] + # mmd and mcopy refuse an existing name with exit status 1 and no message, so directories() checks first. + if missing := directories(present, files): + subprocess.run(["mmd", "-i", image, *(f"::{directory}" for directory in missing)], check=True) + for target in sorted(files): + subprocess.run(["mcopy", "-i", image, files[target], f"::{target}"], check=True) + + +def main(argv: list[str] | None = None) -> None: + spec = specs.parse(Spec, "preload", argv) + base = Path(spec["base"]) + with tempfile.TemporaryDirectory() as tmp: + disk = Path(tmp) / "disk.raw" + # Both keep the holes of a sparse disk. Without -f, zstd skips an input that is a symlink, + # which Buck may materialize an artifact as. + if base.name.endswith(".zst"): + subprocess.run(["zstd", "-q", "-f", "-d", "-o", str(disk), str(base)], check=True) + else: + subprocess.run(["cp", "--sparse=always", str(base), str(disk)], check=True) + add(f"{disk}@@{esp_offset(disk)}", spec["files"]) + convert(disk, Path(spec["out"]), spec["format"], spec["virtual_size"]) + + +if __name__ == "__main__": + main() diff --git a/image/preload_test.py b/image/preload_test.py new file mode 100644 index 00000000..5dd103fb --- /dev/null +++ b/image/preload_test.py @@ -0,0 +1,36 @@ +# SPDX-FileCopyrightText: Amutable GmbH +# SPDX-License-Identifier: MPL-2.0 + +"""Tests for the directories that preload creates on an ESP. + +buck test tine//image:test +""" + +import unittest + +from preload import directories + +PRESENT = ["/EFI", "/EFI/BOOT", "/EFI/BOOT/BOOTX64.EFI", "/extensions"] + + +class DirectoriesTest(unittest.TestCase): + def test_creates_missing_parents_first(self) -> None: + targets = ["/portables/p.raw.v/p_1_x86-64.raw", "/extensions/e.raw.v/e_1_x86-64.raw"] + self.assertEqual( + directories(PRESENT, targets), ["/extensions/e.raw.v", "/portables", "/portables/p.raw.v"] + ) + + def test_creates_a_shared_parent_once(self) -> None: + targets = ["/extensions/e.raw.v/e_1_x86-64.raw", "/extensions/e.raw.v/e_2_x86-64.raw"] + self.assertEqual(directories(PRESENT, targets), ["/extensions/e.raw.v"]) + + def test_matches_regardless_of_case(self) -> None: + self.assertEqual(directories(PRESENT, ["/efi/linux/a.efi"]), ["/efi/linux"]) + + def test_refuses_a_present_target(self) -> None: + with self.assertRaisesRegex(SystemExit, "already contains /efi/boot/bootx64.efi"): + directories(PRESENT, ["/efi/boot/bootx64.efi"]) + + +if __name__ == "__main__": + unittest.main() From 27b5f5cbd6efd75d7b3df771aedf60708c61e788 Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Fri, 6 Nov 2026 07:53:09 +0000 Subject: [PATCH 5/6] image: give a preloaded disk its base's AMI parameters Take the base's registration parameters and give them the preloaded disk's name. This makes uploading a preload disk AMI work naturally. Signed-off-by: Martin Pitt --- examples/image/BUCK | 18 ++++++++++++ image/ami.bzl | 68 ++++++++++++++++++++++++++++++++++++++++++++- image/compose.bzl | 29 ++++++++++--------- image/preload.bzl | 22 +++++++++++---- 4 files changed, 118 insertions(+), 19 deletions(-) diff --git a/examples/image/BUCK b/examples/image/BUCK index cacb865d..3ef41a23 100644 --- a/examples/image/BUCK +++ b/examples/image/BUCK @@ -428,6 +428,24 @@ tests.vm_smoke( ], ) +# The preloaded disk registers with the AMI parameters of boot-demo, under its own name. +box.sh_test( + name = "preloaded-ami-test", + labels = ["image"], + box = "@tine//tools:dev.box", + script = """ +python3 -c ' +import json, sys +base, preloaded = (json.load(open(path)) for path in sys.argv[1:]) +assert preloaded["Name"] == preloaded["Description"] == "boot-demo-preloaded", preloaded +names = ("Name", "Description") +assert {k: v for k, v in preloaded.items() if k not in names} == {k: v for k, v in base.items() if k not in names}, (base, preloaded) +' "$@" +""", + args = ["$(location :boot-demo.fedora[ami])", "$(location :boot-demo-preloaded.fedora[ami])"], + distro = _FEDORA, +) + # A file tree that differs per distribution is one copy whose source is a select, rather than a # target per distribution and an alias choosing between them. A distribution the select names no # tree for gets `None`, which copies nothing; only arch ships one so every other exercises that. diff --git a/image/ami.bzl b/image/ami.bzl index acbacdcc..400642ad 100644 --- a/image/ami.bzl +++ b/image/ami.bzl @@ -86,14 +86,80 @@ def declare_ami( uefi_data = _uefi_data(actions, box_info, secure_boot_certificate), ), ) + return _ami_providers(basename = basename, disk = disk, importer = importer, parameters = parameters, version = version) + +def declare_ami_from_base( + actions: AnalysisActions, + *, + base_parameters: Artifact, + basename: str, + disk: Artifact, + importer: RunInfo, + virtual_size: str | None, +) -> list[Provider]: + """Declare the AMI RegisterImage parameters of a disk built on another, for its `[ami]` subtarget. + + The disk registers with the `base_parameters` of the disk it builds on, under its own name, so that an + instance gets the base's UEFI variable store, which this build has no certificate to write. + `virtual_size` replaces the base's root volume size. + """ + parameters = actions.declare_output("{}.ami.json".format(basename)) + actions.dynamic_output_new( + _from_base( + base = base_parameters, + name = basename, + out = parameters.as_output(), + volume_size = _gib(virtual_size) if virtual_size != None else None, + ), + ) + return _ami_providers(basename = basename, disk = disk, importer = importer, parameters = parameters, version = None) + +def _from_base_impl( + actions: AnalysisActions, + base: ArtifactValue, + name: str, + out: OutputArtifact, + volume_size: int | None, +) -> list[Provider]: + register = base.read_json() | {"Description": name, "Name": _ami_name(name)} + if volume_size != None: + mappings = [dict(mapping) for mapping in register["BlockDeviceMappings"]] + mappings[0]["Ebs"] = dict(mappings[0]["Ebs"]) | {"VolumeSize": volume_size} + register["BlockDeviceMappings"] = mappings + actions.write_json(out, register) + return [] + +_from_base = dynamic_actions( + impl = _from_base_impl, + attrs = { + "base": dynattrs.artifact_value(), + "name": dynattrs.value(str), + "out": dynattrs.output(), + "volume_size": dynattrs.value(int | None), + }, +) + +def _ami_providers( + *, + basename: str, + disk: Artifact, + importer: RunInfo, + parameters: Artifact, + version: str | None, +) -> list[Provider]: return [ # Building an AMI builds the disk, as for any other format; only the parameters are published, # as a release carries the disk already. DefaultInfo(default_output = parameters, other_outputs = [disk]), - PublishedInfo(artifacts = {name: PublishedFile(artifact = parameters, kind = "ami")}, image = basename, version = version), + PublishedInfo(artifacts = {parameters.basename: PublishedFile(artifact = parameters, kind = "ami")}, image = basename, version = version), RunInfo(args = cmd_args(importer, disk, parameters)), ] +# The importer that running an `[ami]` subtarget runs. +AMI_IMPORT_ATTR = { + "_ami_import": attrs.dep(providers = [RunInfo], default = "tine//image:ami-import"), +} + def _with_uefi_data_impl( actions: AnalysisActions, out: OutputArtifact, diff --git a/image/compose.bzl b/image/compose.bzl index b1cfbb09..1a31398c 100644 --- a/image/compose.bzl +++ b/image/compose.bzl @@ -7,7 +7,7 @@ load("//box:defs.bzl", "box") load("//distribution:defs.bzl", "distribution") load("//package:manager.bzl", "PackageManagerInfo") load("//platform:architecture.bzl", "architecture") -load(":ami.bzl", "declare_ami") +load(":ami.bzl", "AMI_IMPORT_ATTR", "declare_ami") load( ":archive.bzl", "ARCHIVE_ATTRS", @@ -613,18 +613,21 @@ _DISK_ATTRS = ( # Only a bootable disk has the attributes in _BOOTABLE_ATTRS. Buck refuses them on a disk that # doesn't boot. -_BOOTABLE_ATTRS = UKI_ATTRS | { - "cmdline": attrs.list( - attrs.string(), - default = ["root=tmpfs", "mount.usr=dissect", "rw"], - ), - "initrd": attrs.dep( - providers = [InitrdInfo], - doc = "the initrd to boot; the declaration macro declares a conventional one when given none", - ), - "_ami_box": attrs.dep(providers = [box.Info], default = "tine//tools:aws.box"), - "_ami_import": attrs.dep(providers = [RunInfo], default = "tine//image:ami-import"), -} +_BOOTABLE_ATTRS = ( + UKI_ATTRS + | { + "cmdline": attrs.list( + attrs.string(), + default = ["root=tmpfs", "mount.usr=dissect", "rw"], + ), + "initrd": attrs.dep( + providers = [InitrdInfo], + doc = "the initrd to boot; the declaration macro declares a conventional one when given none", + ), + "_ami_box": attrs.dep(providers = [box.Info], default = "tine//tools:aws.box"), + } + | AMI_IMPORT_ATTR +) _disk = rule( impl = _disk_impl, diff --git a/image/preload.bzl b/image/preload.bzl index ced5adeb..9935845f 100644 --- a/image/preload.bzl +++ b/image/preload.bzl @@ -6,6 +6,7 @@ load("//:specs.bzl", "specs") load("//box:defs.bzl", "box") load("//distribution:defs.bzl", "distribution") +load(":ami.bzl", "AMI_IMPORT_ATTR", "declare_ami_from_base") load(":ddi.bzl", "DISK_FORMATS", "DdiInfo", "DiskConversionInfo", "SIZE_PATTERN", "VIRTUAL_SIZE_FORMATS") load(":extension.bzl", "ExtensionInfo", "check_portable", "ddi_image_name", "system_extension") load(":image.bzl", "IMAGE_TOOLS_ATTR", "ImageToolsInfo", "spec_path") @@ -70,11 +71,21 @@ def _ddi_preloaded_disk_impl(ctx: AnalysisContext) -> list[Provider]: identifier = format, ) disks[format] = out + sub_targets = {format: [DefaultInfo(default_output = out), DiskConversionInfo(format = format, image = out)] for format, out in disks.items()} + + # A base that registers as an AMI has `[ami]`, as a bootable image.disk does. + base_ami = ctx.attrs.base[DefaultInfo].sub_targets.get("ami") + if base_ami != None: + sub_targets["ami"] = declare_ami_from_base( + ctx.actions, + base_parameters = base_ami[DefaultInfo].default_outputs[0], + basename = ctx.label.name, + disk = disks["raw.zst"], + importer = ctx.attrs._ami_import[RunInfo], + virtual_size = ctx.attrs.virtual_size, + ) return [ - DefaultInfo( - default_output = disks["raw.zst"], - sub_targets = {format: [DefaultInfo(default_output = out), DiskConversionInfo(format = format, image = out)] for format, out in disks.items()}, - ), + DefaultInfo(default_output = disks["raw.zst"], sub_targets = sub_targets), DdiInfo(disk = disks["raw.zst"], partitions = [], virtual_size = virtual_size), ] @@ -95,12 +106,13 @@ _ddi_preloaded_disk = rule( "virtual_size": attrs.option( attrs.string(), default = None, - doc = 'size of the disk a qcow2 or vmdk describes, e.g. "20G", instead of the base\'s', + doc = "size of the disk a qcow2 or vmdk describes, and of the AMI's root volume, e.g. \"20G\", instead of the base's", ), # The tools box of the release has the tools that read the partition table, write the FAT and # convert the disk. "_box": distribution.TOOLS_BOX_ATTR, } + | AMI_IMPORT_ATTR | IMAGE_TOOLS_ATTR, supports_incoming_transition = True, ) From 91c63dfd9a56542bc1c499f38376f5991ed2794a Mon Sep 17 00:00:00 2001 From: Martin Pitt Date: Fri, 6 Nov 2026 10:21:45 +0000 Subject: [PATCH 6/6] image: add image.imported_disk This allows a project to build a preloaded disk based on a published release of an OS, rather than on a disk it builds itself. Add a rule that turns the release's disk and AMI parameters (usually `http.file` downloads) into a disk `DdiInfo` which `image.ddi_preloaded_disk` can take as base image. Signed-off-by: Martin Pitt --- docs/user/images.md | 55 +++++++++++++++++++++++++++++++++++++--- examples/image/BUCK | 62 +++++++++++++++++++++++++++++++++++++++++++-- image/defs.bzl | 3 ++- image/preload.bzl | 45 ++++++++++++++++++++++++++++++++ 4 files changed, 159 insertions(+), 6 deletions(-) diff --git a/docs/user/images.md b/docs/user/images.md index 220025ad..574318c7 100644 --- a/docs/user/images.md +++ b/docs/user/images.md @@ -885,8 +885,8 @@ image.ddi_preloaded_disk( ``` - `base` (target providing `image.DdiInfo`): The disk (raw or zstd-compressed), such as an - `image.disk()` or another `image.ddi_preloaded_disk()`. It needs exactly one ESP partition, with - room for the DDIs. + `image.disk()`, another `image.ddi_preloaded_disk()`, or an `image.imported_disk()` (see below). + It needs exactly one ESP partition, with room for the DDIs. - `extensions` (list of extension targets): Each must have the `system` scope. A DDI goes to `/extensions/.raw.v/__.raw` on the ESP, regardless of its classes. - `portables` (list of portable images and extensions with the `portable` scope): A DDI goes to @@ -904,6 +904,53 @@ after the initial confext/sysext merge). For portable services, call `portablect The target defaults to the disk as `.raw.zst`, and offers `[qcow2]` and `[vmdk]` subtargets. It can be booted by `image.vm`. +### External disks as a base + +`image.imported_disk()` declares a base disk for preloading from a disk that was built elsewhere, +such as a published release of an OS. `disk` is its `.raw` or `.raw.zst`, and the optional `ami` its +`.ami.json`, which the target provides as `[ami]`, plus optionally a `virtual_size`. Pin each file in the +project's `tine.lock.json`, and download it with [`http.file()`](http.md#declaring-a-download): + +```json +{ + "os-disk": { + "url": "https://releases.example.com", + "release": "1.2", + "platforms": { + "x86_64": {"artifact": "os_1.2_x86-64.raw.zst", "sha256": "…", "size": 382105381} + } + }, + "os-ami": { + "url": "https://releases.example.com", + "release": "1.2", + "platforms": { + "x86_64": {"artifact": "os_1.2_x86-64.ami.json", "sha256": "…", "size": 1414} + } + } +} +``` + +`tine bump` fills in `sha256` and `size`, and with a `releases` index moves the pins to the newest +release, see [The pin](http.md#the-pin). + +```Starlark +load("//:tine.lock.json", pins = "value") +load("@tine//http:defs.bzl", "http") + +http.file(name = "os.raw.zst", spec = pins["os-disk"]) + +http.file(name = "os.ami.json", spec = pins["os-ami"]) + +image.imported_disk( + name = "os", + disk = ":os.raw.zst", + ami = ":os.ami.json", + virtual_size = "20G", +) +``` + +Then `image.ddi_preloaded_disk()` takes `:os` as a `base =`, see above. + ### Kernel modules in the UKI A UKI carries the kernel, the initrds it was built from, and one further initrd the `image.uki` rule @@ -1173,7 +1220,9 @@ versioned UKI creation, semantic boot-artifact extraction, ESP-layer assembly, a `demo-ext` and `demo-confext` at boot, and attaches the two portable DDIs with `portablectl attach --extension demo-portable-ext demo-portable`. `boot-demo-preloaded` is `boot-demo` with these four DDIs on its ESP. `boot-demo-preloaded-vm-smoke` boots it, activates the DDIs from the ESP, -and checks that they merge and attach. +and checks that they merge and attach. `boot-demo-imported` imports the `[raw.zst]` and `[ami]` of +`boot-demo` the way a project that builds on a release would, and `imported-disk-test` checks that +preloading it gives the same disk. A host that merges a confext or attaches a portable image needs two things, which `boot-demo` provides: diff --git a/examples/image/BUCK b/examples/image/BUCK index 3ef41a23..71d8a8c0 100644 --- a/examples/image/BUCK +++ b/examples/image/BUCK @@ -385,13 +385,44 @@ image.extension( ], ) +_PRELOADED_EXTENSIONS = [":demo-confext", ":demo-ext"] + +_PRELOADED_PORTABLES = [":demo-portable", ":demo-portable-ext"] + # boot-demo with the demo DDIs on its ESP, where systemd-sysupdate would update them. Nothing merges or # attaches them at boot yet: whoever instantiates the disk does that. image.ddi_preloaded_disk( name = "boot-demo-preloaded", base = ":boot-demo", - extensions = [":demo-confext", ":demo-ext"], - portables = [":demo-portable", ":demo-portable-ext"], + extensions = _PRELOADED_EXTENSIONS, + portables = _PRELOADED_PORTABLES, +) + +# boot-demo's published raw.zst and AMI parameters, the way a project that builds on a release of it +# imports them, and the same DDIs on its ESP. +image.imported_disk( + name = "boot-demo-imported", + ami = ":boot-demo[ami]", + disk = ":boot-demo[raw.zst]", + # As boot-demo's own, which a release would publish beside the disk. + virtual_size = "2G", +) + +image.ddi_preloaded_disk( + name = "boot-demo-imported-preloaded", + base = ":boot-demo-imported", + extensions = _PRELOADED_EXTENSIONS, + portables = _PRELOADED_PORTABLES, +) + +# The preloaded disk defaults to the virtual_size of the imported one. +box.sh_test( + name = "imported-virtual-size-test", + labels = ["image"], + box = "@tine//tools:dev.box", + script = """test "$(qemu-img info --output=json "$1" | jq '."virtual-size"')" = 2147483648""", + args = ["$(location :boot-demo-imported-preloaded.fedora[qcow2])"], + distro = _FEDORA, ) image.vm( @@ -446,6 +477,33 @@ assert {k: v for k, v in preloaded.items() if k not in names} == {k: v for k, v distro = _FEDORA, ) +# Preloading the imported raw.zst writes the same disk as preloading boot-demo itself, and registers it +# with the same AMI parameters, under its own name. +box.sh_test( + name = "imported-disk-test", + labels = ["image"], + box = "@tine//tools:dev.box", + script = """ +python3 -c ' +import hashlib, json, sys +disk, imported_disk, ami, imported_ami = sys.argv[1:] +digests = [hashlib.file_digest(open(path, "rb"), "sha256").hexdigest() for path in (disk, imported_disk)] +assert digests[0] == digests[1], digests +ami, imported_ami = json.load(open(ami)), json.load(open(imported_ami)) +assert imported_ami["Name"] == "boot-demo-imported-preloaded", imported_ami +names = ("Name", "Description") +assert {k: v for k, v in ami.items() if k not in names} == {k: v for k, v in imported_ami.items() if k not in names} +' "$@" +""", + args = [ + "$(location :boot-demo-preloaded.fedora)", + "$(location :boot-demo-imported-preloaded.fedora)", + "$(location :boot-demo-preloaded.fedora[ami])", + "$(location :boot-demo-imported-preloaded.fedora[ami])", + ], + distro = _FEDORA, +) + # A file tree that differs per distribution is one copy whose source is a select, rather than a # target per distribution and an alias choosing between them. A distribution the select names no # tree for gets `None`, which copies nothing; only arch ships one so every other exercises that. diff --git a/image/defs.bzl b/image/defs.bzl index 5acd3c1e..931b57a1 100644 --- a/image/defs.bzl +++ b/image/defs.bzl @@ -48,7 +48,7 @@ load( "layer", "ops", ) -load(":preload.bzl", "ddi_preloaded_disk") +load(":preload.bzl", "ddi_preloaded_disk", "imported_disk") load(":publish.bzl", "PublishedFile", "PublishedInfo", "image_artifacts") load( ":sign.bzl", @@ -87,6 +87,7 @@ image = struct( portable = portable, disk = disk, ddi_preloaded_disk = ddi_preloaded_disk, + imported_disk = imported_disk, artifacts = image_artifacts, initrd = initrd_image, # The core kernel modules a UKI carries unless a target says otherwise; extend it with `+`. diff --git a/image/preload.bzl b/image/preload.bzl index 9935845f..7ece8dd0 100644 --- a/image/preload.bzl +++ b/image/preload.bzl @@ -126,3 +126,48 @@ def ddi_preloaded_disk( ) -> None: """Declare one, compatible with the distributions its package serves.""" _ddi_preloaded_disk(name = name, **(distribution.distributed(name, distro, visibility, target_compatible_with) | kwargs)) + +def _imported_disk_impl(ctx: AnalysisContext) -> list[Provider]: + disk = ctx.attrs.disk + + # The preload driver tells the two apart by name. + if not (disk.basename.endswith(".raw") or disk.basename.endswith(".raw.zst")): + fail("imported_disk: {} is neither a .raw nor a .raw.zst".format(disk.basename)) + if ctx.attrs.virtual_size != None and not regex_match(SIZE_PATTERN, ctx.attrs.virtual_size): + fail("imported_disk: invalid virtual_size {!r}".format(ctx.attrs.virtual_size)) + sub_targets = {} + if ctx.attrs.ami != None: + # As the `[ami]` of an image.disk, which image.ddi_preloaded_disk takes the parameters from. + sub_targets["ami"] = [DefaultInfo(default_output = ctx.attrs.ami, other_outputs = [disk])] + return [ + DefaultInfo(default_output = disk, sub_targets = sub_targets), + DdiInfo(disk = disk, partitions = [], virtual_size = ctx.attrs.virtual_size), + ] + +_imported_disk = rule( + impl = _imported_disk_impl, + attrs = { + "ami": attrs.option( + attrs.source(), + default = None, + doc = "the disk's AMI RegisterImage parameters (.ami.json), which an [ami] subtarget provides", + ), + "disk": attrs.source(doc = "the disk, a .raw or .raw.zst"), + "virtual_size": attrs.option( + attrs.string(), + default = None, + doc = 'size of the virtual disk the release boots from, e.g. "20G", which image.ddi_preloaded_disk keeps', + ), + }, + supports_incoming_transition = True, +) + +def imported_disk( + name: str, + distro: str | None = None, + visibility: list[str] | None = None, + target_compatible_with: list[str] | Select | None = None, + **kwargs, +) -> None: + """Declare a disk built elsewhere, such as a published release, as a base for image.ddi_preloaded_disk.""" + _imported_disk(name = name, **(distribution.distributed(name, distro, visibility, target_compatible_with) | kwargs))