treewide: move build helper to more fitting places

Change-Id: I3d0cfe9283222d403ae369ec9db09201ad511e15
Reviewed-on: https://review.monogon.dev/c/monogon/+/3327
Reviewed-by: Serge Bazanski <serge@monogon.tech>
Tested-by: Jenkins CI
diff --git a/osbase/build/BUILD.bazel b/osbase/build/BUILD.bazel
new file mode 100644
index 0000000..8eafa9d
--- /dev/null
+++ b/osbase/build/BUILD.bazel
@@ -0,0 +1 @@
+exports_files(["earlydev.fsspec"])
diff --git a/osbase/build/def.bzl b/osbase/build/def.bzl
new file mode 100644
index 0000000..8e9fcd9
--- /dev/null
+++ b/osbase/build/def.bzl
@@ -0,0 +1,403 @@
+#  Copyright 2020 The Monogon Project Authors.
+#
+#  SPDX-License-Identifier: Apache-2.0
+#
+#  Licensed under the Apache License, Version 2.0 (the "License");
+#  you may not use this file except in compliance with the License.
+#  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+load("@bazel_skylib//lib:paths.bzl", "paths")
+
+def _build_pure_transition_impl(settings, attr):
+    """
+    Transition that enables pure, static build of Go binaries.
+    """
+    race = settings['@io_bazel_rules_go//go/config:race']
+    pure = not race
+
+    return {
+        "@io_bazel_rules_go//go/config:pure": pure,
+        "@io_bazel_rules_go//go/config:static": True,
+    }
+
+build_pure_transition = transition(
+    implementation = _build_pure_transition_impl,
+    inputs = [
+        "@io_bazel_rules_go//go/config:race",
+    ],
+    outputs = [
+        "@io_bazel_rules_go//go/config:pure",
+        "@io_bazel_rules_go//go/config:static",
+    ],
+)
+
+def _build_static_transition_impl(settings, attr):
+    """
+    Transition that enables static builds with CGo and musl for Go binaries.
+    """
+    return {
+        "@io_bazel_rules_go//go/config:static": True,
+        "//command_line_option:platforms": "//build/platforms:linux_amd64_static",
+    }
+
+build_static_transition = transition(
+    implementation = _build_static_transition_impl,
+    inputs = [],
+    outputs = [
+        "@io_bazel_rules_go//go/config:static",
+        "//command_line_option:platforms",
+    ],
+)
+
+FSSpecInfo = provider(
+    "Provides parts of an FSSpec used to assemble filesystem images",
+    fields = {
+        "spec": "File containing the partial FSSpec as prototext",
+        "referenced": "Files (potentially) referenced by the spec",
+    },
+)
+
+def _fsspec_core_impl(ctx, tool, output_file):
+    """
+    _fsspec_core_impl implements the core of an fsspec-based rule. It takes
+    input from the `files`,`files_cc`, `symlinks` and `fsspecs` attributes
+    and calls `tool` with the `-out` parameter pointing to `output_file`
+    and paths to all fsspecs as positional arguments.
+    """
+    fs_spec_name = ctx.label.name + ".prototxt"
+    fs_spec = ctx.actions.declare_file(fs_spec_name)
+
+    fs_files = []
+    inputs = []
+    for label, p in ctx.attr.files.items() + ctx.attr.files_cc.items():
+        if not p.startswith("/"):
+            fail("file {} invalid: must begin with /".format(p))
+
+        # Figure out if this is an executable.
+        is_executable = True
+
+        di = label[DefaultInfo]
+        if di.files_to_run.executable == None:
+            # Generated non-executable files will have DefaultInfo.files_to_run.executable == None
+            is_executable = False
+        elif di.files_to_run.executable.is_source:
+            # Source files will have executable.is_source == True
+            is_executable = False
+
+        # Ensure only single output is declared.
+        # If you hit this error, figure out a better logic to find what file you need, maybe looking at providers other
+        # than DefaultInfo.
+        files = di.files.to_list()
+        if len(files) > 1:
+            fail("file {} has more than one output: {}", p, files)
+        src = files[0]
+        inputs.append(src)
+
+        mode = 0o555 if is_executable else 0o444
+        fs_files.append(struct(path = p, source_path = src.path, mode = mode, uid = 0, gid = 0))
+
+    fs_symlinks = []
+    for target, p in ctx.attr.symlinks.items():
+        fs_symlinks.append(struct(path = p, target_path = target))
+
+    fs_spec_content = struct(file = fs_files, directory = [], symbolic_link = fs_symlinks)
+    ctx.actions.write(fs_spec, proto.encode_text(fs_spec_content))
+
+    extra_specs = []
+
+    for fsspec in ctx.attr.fsspecs:
+        if FSSpecInfo in fsspec:
+            fsspecInfo = fsspec[FSSpecInfo]
+            extra_specs.append(fsspecInfo.spec)
+            for f in fsspecInfo.referenced:
+                inputs.append(f)
+        else:
+            # Raw .fsspec prototext. No referenced data allowed.
+            di = fsspec[DefaultInfo]
+            extra_specs += di.files.to_list()
+
+    ctx.actions.run(
+        outputs = [output_file],
+        inputs = [fs_spec] + inputs + extra_specs,
+        tools = [tool],
+        executable = tool,
+        arguments = ["-out", output_file.path, fs_spec.path] + [s.path for s in extra_specs],
+    )
+    return
+
+def _node_initramfs_impl(ctx):
+    initramfs_name = ctx.label.name + ".cpio.zst"
+    initramfs = ctx.actions.declare_file(initramfs_name)
+
+    _fsspec_core_impl(ctx, ctx.executable._mkcpio, initramfs)
+
+    # TODO(q3k): Document why this is needed
+    return [DefaultInfo(runfiles = ctx.runfiles(files = [initramfs]), files = depset([initramfs]))]
+
+node_initramfs = rule(
+    implementation = _node_initramfs_impl,
+    doc = """
+        Build a node initramfs. The initramfs will contain a basic /dev directory and all the files specified by the
+        `files` attribute. Executable files will have their permissions set to 0755, non-executable files will have
+        their permissions set to 0444. All parent directories will be created with 0755 permissions.
+    """,
+    attrs = {
+        "files": attr.label_keyed_string_dict(
+            mandatory = True,
+            allow_files = True,
+            doc = """
+                Dictionary of Labels to String, placing a given Label's output file in the initramfs at the location
+                specified by the String value. The specified labels must only have a single output.
+            """,
+            # Attach pure transition to ensure all binaries added to the initramfs are pure/static binaries.
+            cfg = build_pure_transition,
+        ),
+        "files_cc": attr.label_keyed_string_dict(
+            allow_files = True,
+            doc = """
+                 Special case of 'files' for compilation targets that need to be built with the musl toolchain like
+                 go_binary targets which need cgo or cc_binary targets.
+            """,
+            # Attach static transition to all files_cc inputs to ensure they are built with musl and static.
+            cfg = build_static_transition,
+        ),
+        "symlinks": attr.string_dict(
+            default = {},
+            doc = """
+                Symbolic links to create. Similar format as in files and files_cc, so the target of the symlink is the
+                key and the value of it is the location of the symlink itself. Only raw strings are allowed as targets,
+                labels are not permitted. Include the file using files or files_cc, then symlink to its location.
+            """,
+        ),
+        "fsspecs": attr.label_list(
+            default = [],
+            doc = """
+                List of file system specs (osbase.build.fsspec.FSSpec) to also include in the resulting image.
+                These will be merged with all other given attributes.
+            """,
+            providers = [FSSpecInfo],
+            allow_files = True,
+        ),
+
+        # Tool
+        "_mkcpio": attr.label(
+            default = Label("//osbase/build/mkcpio"),
+            executable = True,
+            cfg = "exec",
+        ),
+    },
+)
+
+def _erofs_image_impl(ctx):
+    fs_name = ctx.label.name + ".img"
+    fs_out = ctx.actions.declare_file(fs_name)
+
+    _fsspec_core_impl(ctx, ctx.executable._mkerofs, fs_out)
+
+    return [DefaultInfo(files = depset([fs_out]))]
+
+erofs_image = rule(
+    implementation = _erofs_image_impl,
+    doc = """
+        Build an EROFS. All files specified in files, files_cc and all specified symlinks will be contained.
+        Executable files will have their permissions set to 0555, non-executable files will have
+        their permissions set to 0444. All parent directories will be created with 0555 permissions.
+    """,
+    attrs = {
+        "files": attr.label_keyed_string_dict(
+            mandatory = True,
+            allow_files = True,
+            doc = """
+                Dictionary of Labels to String, placing a given Label's output file in the EROFS at the location
+                specified by the String value. The specified labels must only have a single output.
+            """,
+            # Attach pure transition to ensure all binaries added to the initramfs are pure/static binaries.
+            cfg = build_pure_transition,
+        ),
+        "files_cc": attr.label_keyed_string_dict(
+            allow_files = True,
+            doc = """
+                 Special case of 'files' for compilation targets that need to be built with the musl toolchain like
+                 go_binary targets which need cgo or cc_binary targets.
+            """,
+            # Attach static transition to all files_cc inputs to ensure they are built with musl and static.
+            cfg = build_static_transition,
+        ),
+        "symlinks": attr.string_dict(
+            default = {},
+            doc = """
+                Symbolic links to create. Similar format as in files and files_cc, so the target of the symlink is the
+                key and the value of it is the location of the symlink itself. Only raw strings are allowed as targets,
+                labels are not permitted. Include the file using files or files_cc, then symlink to its location.
+          """,
+        ),
+        "fsspecs": attr.label_list(
+            default = [],
+            doc = """
+                List of file system specs (osbase.build.fsspec.FSSpec) to also include in the resulting image.
+                These will be merged with all other given attributes.
+            """,
+            providers = [FSSpecInfo],
+            allow_files = True,
+        ),
+
+        # Tools, implicit dependencies.
+        "_mkerofs": attr.label(
+            default = Label("//osbase/build/mkerofs"),
+            executable = True,
+            cfg = "host",
+        ),
+    },
+)
+
+# VerityConfig is emitted by verity_image, and contains a file enclosing a
+# singular dm-verity target table.
+VerityConfig = provider(
+    "Configuration necessary to mount a single dm-verity target.",
+    fields = {
+        "table": "A file containing the dm-verity target table. See: https://www.kernel.org/doc/html/latest/admin-guide/device-mapper/verity.html",
+    },
+)
+
+def _verity_image_impl(ctx):
+    """
+    Create a new file containing the source image data together with the Verity
+    metadata appended to it, and provide an associated DeviceMapper Verity target
+    table in a separate file, through VerityConfig provider.
+    """
+
+    # Run mkverity.
+    image = ctx.actions.declare_file(ctx.attr.name + ".img")
+    table = ctx.actions.declare_file(ctx.attr.name + ".dmt")
+    ctx.actions.run(
+        mnemonic = "GenVerityImage",
+        progress_message = "Generating a dm-verity image",
+        inputs = [ctx.file.source],
+        outputs = [
+            image,
+            table,
+        ],
+        executable = ctx.file._mkverity,
+        arguments = [
+            "-input=" + ctx.file.source.path,
+            "-output=" + image.path,
+            "-table=" + table.path,
+            "-data_alias=" + ctx.attr.rootfs_partlabel,
+            "-hash_alias=" + ctx.attr.rootfs_partlabel,
+        ],
+    )
+
+    return [
+        DefaultInfo(
+            files = depset([image]),
+            runfiles = ctx.runfiles(files = [image]),
+        ),
+        VerityConfig(
+            table = table,
+        ),
+    ]
+
+verity_image = rule(
+    implementation = _verity_image_impl,
+    doc = """
+      Build a dm-verity target image by appending Verity metadata to the source
+      image. A corresponding dm-verity target table will be made available
+      through VerityConfig provider.
+  """,
+    attrs = {
+        "source": attr.label(
+            doc = "A source image.",
+            allow_single_file = True,
+        ),
+        "rootfs_partlabel": attr.string(
+            doc = "GPT partition label of the rootfs to be used with dm-mod.create.",
+            default = "PARTLABEL=METROPOLIS-SYSTEM-X",
+        ),
+        "_mkverity": attr.label(
+            doc = "The mkverity executable needed to generate the image.",
+            default = "//osbase/build/mkverity",
+            allow_single_file = True,
+            executable = True,
+            cfg = "host",
+        ),
+    },
+)
+
+# From Aspect's bazel-lib under Apache 2.0
+def _transition_platform_impl(_, attr):
+    return {"//command_line_option:platforms": str(attr.target_platform)}
+
+# Transition from any input configuration to one that includes the
+# --platforms command-line flag.
+_transition_platform = transition(
+    implementation = _transition_platform_impl,
+    inputs = [],
+    outputs = ["//command_line_option:platforms"],
+)
+
+
+def _platform_transition_binary_impl(ctx):
+    # We need to forward the DefaultInfo provider from the underlying rule.
+    # Unfortunately, we can't do this directly, because Bazel requires that the executable to run
+    # is actually generated by this rule, so we need to symlink to it, and generate a synthetic
+    # forwarding DefaultInfo.
+
+    result = []
+    binary = ctx.attr.binary[0]
+
+    default_info = binary[DefaultInfo]
+    files = default_info.files
+    new_executable = None
+    original_executable = default_info.files_to_run.executable
+    runfiles = default_info.default_runfiles
+
+    if not original_executable:
+        fail("Cannot transition a 'binary' that is not executable")
+
+    new_executable_name = ctx.attr.basename if ctx.attr.basename else original_executable.basename
+
+    # In order for the symlink to have the same basename as the original
+    # executable (important in the case of proto plugins), put it in a
+    # subdirectory named after the label to prevent collisions.
+    new_executable = ctx.actions.declare_file(paths.join(ctx.label.name, new_executable_name))
+    ctx.actions.symlink(
+        output = new_executable,
+        target_file = original_executable,
+        is_executable = True,
+    )
+    files = depset(direct = [new_executable], transitive = [files])
+    runfiles = runfiles.merge(ctx.runfiles([new_executable]))
+
+    result.append(
+        DefaultInfo(
+            files = files,
+            runfiles = runfiles,
+            executable = new_executable,
+        ),
+    )
+
+    return result
+
+platform_transition_binary = rule(
+    implementation = _platform_transition_binary_impl,
+    attrs = {
+        "basename": attr.string(),
+        "binary": attr.label(allow_files = True, cfg = _transition_platform),
+        "target_platform": attr.label(
+            doc = "The target platform to transition the binary.",
+            mandatory = True,
+        ),
+        "_allowlist_function_transition": attr.label(
+            default = "@bazel_tools//tools/allowlists/function_transition_allowlist",
+        ),
+    },
+    executable = True,
+    doc = "Transitions the binary to use the provided platform.",
+)
\ No newline at end of file
diff --git a/osbase/build/earlydev.fsspec b/osbase/build/earlydev.fsspec
new file mode 100644
index 0000000..a7d2ea4
--- /dev/null
+++ b/osbase/build/earlydev.fsspec
@@ -0,0 +1,54 @@
+# Critical /dev files which should be present as early as possible, ie. be baked
+# into filesystem images.
+
+# At least /dev/console and /dev/null are required to exist for Linux
+# to properly boot an init. Here we additionally include important device nodes
+# like /dev/kmsg and /dev/ptmx which might need to be available before a proper
+# device manager (ie. devtmpfs) is launched.
+special_file <
+    path: "/dev/console"
+    type: CHARACTER_DEV
+    major: 5 minor: 1
+    mode: 0600 uid: 0 gid: 0
+>
+special_file <
+    path: "/dev/ptmx"
+    type: CHARACTER_DEV
+    major: 5 minor: 2
+    mode: 0644 uid: 0 gid: 0
+>
+special_file <
+    path: "/dev/null"
+    type: CHARACTER_DEV
+    major: 1 minor: 3
+    mode: 0644 uid: 0 gid: 0
+>
+special_file <
+    path: "/dev/kmsg"
+    type: CHARACTER_DEV
+    major: 1 minor: 11
+    mode: 0644 uid: 0 gid: 0
+>
+
+
+# Metropolis core logs to /dev/ttyS{0,1} and /dev/tty0 by default, we want
+# these to also be present before devtmpfs is mounted so that minit can
+# log there, too.
+special_file <
+    path: "/dev/tty0"
+    type: CHARACTER_DEV
+    major: 4 minor: 0
+    mode: 0600 uid: 0 gid: 0
+>
+special_file <
+    path: "/dev/ttyS0"
+    type: CHARACTER_DEV
+    major: 4 minor: 64
+    mode: 0660 uid: 0 gid: 0
+>
+special_file <
+    path: "/dev/ttyS1"
+    type: CHARACTER_DEV
+    major: 4 minor: 65
+    mode: 0660 uid: 0 gid: 0
+>
\ No newline at end of file
diff --git a/osbase/build/efi.bzl b/osbase/build/efi.bzl
new file mode 100644
index 0000000..2f5f363
--- /dev/null
+++ b/osbase/build/efi.bzl
@@ -0,0 +1,135 @@
+"""Rules for generating EFI unified kernel images. These are EFI-bootable PE/COFF files containing a stub loader,
+a kernel, and optional commandline and initramfs in one file.
+See https://systemd.io/BOOT_LOADER_SPECIFICATION/#type-2-efi-unified-kernel-images for more information.
+"""
+
+load("//build/toolchain/llvm-efi:transition.bzl", "build_efi_transition")
+load("//osbase/build:def.bzl", "VerityConfig")
+
+def _efi_unified_kernel_image_impl(ctx):
+    # Find the dependency paths to be passed to mkpayload.
+    deps = {
+        "linux": ctx.file.kernel,
+        "osrel": ctx.file.os_release,
+        "splash": ctx.file.splash,
+        "stub": ctx.file.stub,
+    }
+
+    # Since cmdline is a string attribute, put it into a file, then append
+    # that file to deps.
+    if ctx.attr.cmdline and ctx.attr.cmdline != "":
+        cmdline = ctx.actions.declare_file("cmdline")
+        ctx.actions.write(
+            output = cmdline,
+            content = ctx.attr.cmdline,
+        )
+        deps["cmdline"] = cmdline
+
+    # Get the dm-verity target table from VerityConfig provider.
+    if ctx.attr.verity:
+        deps["rootfs_dm_table"] = ctx.attr.verity[VerityConfig].table
+
+    # Format deps into command line arguments while keeping track of mkpayload
+    # runtime inputs.
+    args = []
+    inputs = []
+    for name, file in deps.items():
+        if file:
+            args.append("-{}={}".format(name, file.path))
+            inputs.append(file)
+
+    for file in ctx.files.initrd:
+        args.append("-initrd={}".format(file.path))
+        inputs.append(file)
+
+    # Append the output parameter separately, as it doesn't belong with the
+    # runtime inputs.
+    image = ctx.actions.declare_file(ctx.attr.name + ".efi")
+    args.append("-output={}".format(image.path))
+
+    # Append the objcopy parameter separately, as it's not of File type, and
+    # it does not constitute an input, since it's part of the toolchain.
+    objcopy = ctx.toolchains["@bazel_tools//tools/cpp:toolchain_type"].cc.objcopy_executable
+    args.append("-objcopy={}".format(objcopy))
+
+    # Run mkpayload.
+    ctx.actions.run(
+        mnemonic = "GenEFIKernelImage",
+        progress_message = "Generating EFI unified kernel image",
+        inputs = inputs,
+        outputs = [image],
+        executable = ctx.file._mkpayload,
+        arguments = args,
+    )
+
+    # Return the unified kernel image file.
+    return [DefaultInfo(files = depset([image]), runfiles = ctx.runfiles(files = [image]))]
+
+efi_unified_kernel_image = rule(
+    implementation = _efi_unified_kernel_image_impl,
+    attrs = {
+        "kernel": attr.label(
+            doc = "The Linux kernel executable bzImage. Needs to have EFI handover and EFI stub enabled.",
+            mandatory = True,
+            allow_single_file = True,
+        ),
+        "cmdline": attr.string(
+            doc = "The kernel commandline to be embedded.",
+        ),
+        "initrd": attr.label_list(
+            doc = """
+                List of payloads to concatenate and supply as the initrd parameter to Linux when it boots.
+                The name stems from the time Linux booted from an initial ram disk (initrd), but it's now
+                a catch-all for a bunch of different larger payload for early Linux initialization.
+
+                In Linux 5.15 this can first contain an arbitrary amount of uncompressed cpio archives
+                with directories being optional which is accessed by earlycpio. This is used for both
+                early microcode loading and ACPI table overrides. This can then be followed by an arbitrary
+                amount of compressed cpio archives (even with different compression methods) which will
+                together make up the initramfs. The initramfs is only booted into if it contains either
+                /init or whatever file is specified as init= in cmdline. Technically depending on kernel
+                flags you might be able to supply an actual initrd, i.e. an image of a disk loaded into
+                RAM, but that has been deprecated for nearly 2 decades and should really not be used.
+
+                For kernels designed to run on physical machines this should at least contain microcode,
+                optionally followed by a compressed initramfs. For kernels only used in virtualized
+                setups the microcode can be left out and if no initramfs is needed this option can
+                be omitted completely.
+                """,
+            allow_files = True,
+        ),
+        "os_release": attr.label(
+            doc = """
+                The os-release file identifying the operating system.
+                See https://www.freedesktop.org/software/systemd/man/os-release.html for format.
+            """,
+            allow_single_file = True,
+        ),
+        "splash": attr.label(
+            doc = "An image in BMP format which will be displayed as a splash screen until the kernel takes over.",
+            allow_single_file = True,
+        ),
+        "stub": attr.label(
+            doc = "The stub executable itself as a PE/COFF executable.",
+            default = "@efistub//:efistub",
+            allow_single_file = True,
+            executable = True,
+            cfg = build_efi_transition,
+        ),
+        "verity": attr.label(
+            doc = "The DeviceMapper Verity rootfs target table.",
+            allow_single_file = True,
+            providers = [DefaultInfo, VerityConfig],
+        ),
+        "_mkpayload": attr.label(
+            doc = "The mkpayload executable.",
+            default = "//osbase/build/mkpayload",
+            allow_single_file = True,
+            executable = True,
+            cfg = "exec",
+        ),
+    },
+    toolchains = [
+        "@bazel_tools//tools/cpp:toolchain_type"
+    ],
+)
diff --git a/osbase/build/fsspec/BUILD.bazel b/osbase/build/fsspec/BUILD.bazel
new file mode 100644
index 0000000..b719522
--- /dev/null
+++ b/osbase/build/fsspec/BUILD.bazel
@@ -0,0 +1,25 @@
+load("@rules_proto//proto:defs.bzl", "proto_library")
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
+
+proto_library(
+    name = "spec_proto",
+    srcs = ["spec.proto"],
+    visibility = ["//visibility:public"],
+)
+
+go_library(
+    name = "fsspec",
+    srcs = ["utils.go"],
+    embed = [":fsspec_go_proto"],
+    importpath = "source.monogon.dev/osbase/build/fsspec",
+    visibility = ["//visibility:public"],
+    deps = ["@org_golang_google_protobuf//encoding/prototext"],
+)
+
+go_proto_library(
+    name = "fsspec_go_proto",
+    importpath = "source.monogon.dev/osbase/build/fsspec",
+    proto = ":spec_proto",
+    visibility = ["//visibility:public"],
+)
diff --git a/osbase/build/fsspec/spec.proto b/osbase/build/fsspec/spec.proto
new file mode 100644
index 0000000..487eebf
--- /dev/null
+++ b/osbase/build/fsspec/spec.proto
@@ -0,0 +1,98 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package osbase.node.build.fsspec;
+option go_package = "source.monogon.dev/osbase/build/fsspec";
+
+// FSSpec is the spec from which a filesystem is generated. It consists of files, directories and symbolic
+// links. Directories are also automatically inferred when required for the placement of files or symbolic
+// links. Inferred directories always have uid 0, gid 0 and permissions 0555. This can be overridden by
+// explicitly specifying a directory at a given path.
+message FSSpec {
+  repeated File file = 1;
+  repeated Directory directory = 2;
+  repeated SymbolicLink symbolic_link = 3;
+  repeated SpecialFile special_file = 4;
+}
+
+// For internal use only. Represents all supported inodes in a oneof.
+message Inode {
+  oneof type {
+    File file = 1;
+    Directory directory = 2;
+    SymbolicLink symbolic_link = 3;
+    SpecialFile special_file = 4;
+  }
+}
+
+message File {
+  // The path where the file ends up in the filesystem.
+  string path = 1;
+  // The path on the host filesystem where the file contents should be taken from.
+  string source_path = 2;
+  // Unix permission bits
+  uint32 mode = 3;
+  // Owner uid
+  uint32 uid = 4;
+  // Owner gid
+  uint32 gid = 5;
+}
+
+message Directory {
+  // The path where the directory ends up in the filesystem.
+  string path = 1;
+  // Unix permission bits
+  uint32 mode = 2;
+  // Owner uid
+  uint32 uid = 3;
+  // Owner gid
+  uint32 gid = 4;
+}
+
+message SymbolicLink {
+  // The path where the symbolic link ends up in the filesystem.
+  string path = 1;
+  // The path to which the symbolic link resolves to.
+  string target_path = 2;
+}
+
+message SpecialFile {
+  // The path where the special file ends up in the filesystem.
+  string path = 1;
+
+  enum Type {
+    CHARACTER_DEV = 0;
+    BLOCK_DEV = 1;
+    FIFO = 2;
+  }
+
+  // Type of special file.
+  Type type = 2;
+
+  // The major device number of the special file.
+  uint32 major = 3;
+  // The minor number of the special file. Ignored for FIFO-type special files.
+  uint32 minor = 4;
+
+  // Unix permission bits
+  uint32 mode = 5;
+  // Owner uid
+  uint32 uid = 6;
+  // Owner gid
+  uint32 gid = 7;
+}
\ No newline at end of file
diff --git a/osbase/build/fsspec/utils.go b/osbase/build/fsspec/utils.go
new file mode 100644
index 0000000..f5a45e3
--- /dev/null
+++ b/osbase/build/fsspec/utils.go
@@ -0,0 +1,30 @@
+package fsspec
+
+import (
+	"fmt"
+	"os"
+
+	"google.golang.org/protobuf/encoding/prototext"
+)
+
+// ReadMergeSpecs reads FSSpecs from all files in paths and merges them into
+// a single FSSpec.
+func ReadMergeSpecs(paths []string) (*FSSpec, error) {
+	var mergedSpec FSSpec
+	for _, p := range paths {
+		specRaw, err := os.ReadFile(p)
+		if err != nil {
+			return nil, fmt.Errorf("failed to open spec: %w", err)
+		}
+
+		var spec FSSpec
+		if err := prototext.Unmarshal(specRaw, &spec); err != nil {
+			return nil, fmt.Errorf("failed to parse spec %q: %w", p, err)
+		}
+		mergedSpec.File = append(mergedSpec.File, spec.File...)
+		mergedSpec.Directory = append(mergedSpec.Directory, spec.Directory...)
+		mergedSpec.SymbolicLink = append(mergedSpec.SymbolicLink, spec.SymbolicLink...)
+		mergedSpec.SpecialFile = append(mergedSpec.SpecialFile, spec.SpecialFile...)
+	}
+	return &mergedSpec, nil
+}
diff --git a/osbase/build/fwprune/BUILD.bazel b/osbase/build/fwprune/BUILD.bazel
new file mode 100644
index 0000000..d9b4718
--- /dev/null
+++ b/osbase/build/fwprune/BUILD.bazel
@@ -0,0 +1,20 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "fwprune_lib",
+    srcs = ["main.go"],
+    importpath = "source.monogon.dev/osbase/build/fwprune",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//osbase/build/fsspec",
+        "//osbase/kmod",
+        "@org_golang_google_protobuf//encoding/prototext",
+        "@org_golang_google_protobuf//proto",
+    ],
+)
+
+go_binary(
+    name = "fwprune",
+    embed = [":fwprune_lib"],
+    visibility = ["//visibility:public"],
+)
diff --git a/osbase/build/fwprune/def.bzl b/osbase/build/fwprune/def.bzl
new file mode 100644
index 0000000..4633c93
--- /dev/null
+++ b/osbase/build/fwprune/def.bzl
@@ -0,0 +1,76 @@
+load("//osbase/build:def.bzl", "FSSpecInfo")
+
+def _fsspec_linux_firmware(ctx):
+    fsspec_out = ctx.actions.declare_file(ctx.label.name + ".prototxt")
+
+    fwlist = ctx.actions.declare_file(ctx.label.name + "-fwlist.txt")
+    ctx.actions.write(
+        output = fwlist,
+        content = "\n".join([f.path for f in ctx.files.firmware_files]),
+    )
+
+    modinfo = ctx.attr.kernel[OutputGroupInfo].modinfo.to_list()[0]
+    modules = ctx.attr.kernel[OutputGroupInfo].modules.to_list()[0]
+
+    meta_out = ctx.actions.declare_file(ctx.label.name + "-meta.pb")
+
+    ctx.actions.run(
+        outputs = [fsspec_out, meta_out],
+        inputs = [fwlist, modinfo, modules, ctx.file.metadata] + ctx.files.firmware_files,
+        tools = [ctx.executable._fwprune],
+        executable = ctx.executable._fwprune,
+        arguments = [
+            "-modinfo",
+            modinfo.path,
+            "-modules",
+            modules.path,
+            "-firmware-file-list",
+            fwlist.path,
+            "-firmware-whence",
+            ctx.file.metadata.path,
+            "-out-meta",
+            meta_out.path,
+            "-out-fsspec",
+            fsspec_out.path,
+        ],
+    )
+
+    return [DefaultInfo(files = depset([fsspec_out])), FSSpecInfo(spec = fsspec_out, referenced = ctx.files.firmware_files + [modules, meta_out])]
+
+fsspec_linux_firmware = rule(
+    implementation = _fsspec_linux_firmware,
+    doc = """
+         Generates a partial filesystem spec containing all firmware files required by a given kernel at the
+         default firmware load path (/lib/firmware).
+    """,
+    attrs = {
+        "firmware_files": attr.label_list(
+            mandatory = True,
+            allow_files = True,
+            doc = """
+               List of firmware files. Generally at least a filegroup of the linux-firmware repository should
+               be in here.
+            """,
+        ),
+        "metadata": attr.label(
+            mandatory = True,
+            allow_single_file = True,
+            doc = """
+                The metadata file for the Linux firmware. Currently this is the WHENCE file at the root of the
+                linux-firmware repository. Used for resolving additional links.
+            """,
+        ),
+        "kernel": attr.label(
+            doc = """
+                Kernel for which firmware should be selected. Needs to have a modinfo OutputGroup.
+            """,
+        ),
+
+        # Tool
+        "_fwprune": attr.label(
+            default = Label("//osbase/build/fwprune"),
+            executable = True,
+            cfg = "exec",
+        ),
+    },
+)
diff --git a/osbase/build/fwprune/main.go b/osbase/build/fwprune/main.go
new file mode 100644
index 0000000..0bedcfd
--- /dev/null
+++ b/osbase/build/fwprune/main.go
@@ -0,0 +1,218 @@
+// fwprune is a buildsystem utility that filters linux-firmware repository
+// contents to include only files required by the built-in kernel modules,
+// that are specified in modules.builtin.modinfo.
+// (see: https://www.kernel.org/doc/Documentation/kbuild/kbuild.txt)
+package main
+
+import (
+	"debug/elf"
+	"flag"
+	"io/fs"
+	"log"
+	"os"
+	"path"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strings"
+
+	"google.golang.org/protobuf/encoding/prototext"
+	"google.golang.org/protobuf/proto"
+
+	"source.monogon.dev/osbase/build/fsspec"
+	"source.monogon.dev/osbase/kmod"
+)
+
+// linkRegexp parses the Link: lines in the WHENCE file. This does not have
+// an official grammar, the regexp has been written in an approximation of
+// the original parsing algorithm at @linux-firmware//:copy_firmware.sh.
+var linkRegexp = regexp.MustCompile(`(?m:^Link:\s*([^\s]+)\s+->\s+([^\s]+)\s*$)`)
+
+var (
+	modinfoPath      = flag.String("modinfo", "", "Path to the modules.builtin.modinfo file built with the kernel")
+	modulesPath      = flag.String("modules", "", "Path to the directory containing the dynamically loaded kernel modules (.ko files)")
+	firmwareListPath = flag.String("firmware-file-list", "", "Path to a file containing a newline-separated list of paths to firmware files")
+	whenceFilePath   = flag.String("firmware-whence", "", "Path to the linux-firmware WHENCE file containing aliases for firmware files")
+	outMetaPath      = flag.String("out-meta", "", "Path where the resulting module metadata protobuf file should be created")
+	outFSSpecPath    = flag.String("out-fsspec", "", "Path where the resulting fsspec should be created")
+)
+
+func main() {
+	flag.Parse()
+	if *modinfoPath == "" || *modulesPath == "" || *firmwareListPath == "" ||
+		*whenceFilePath == "" || *outMetaPath == "" || *outFSSpecPath == "" {
+		log.Fatal("all flags are required and need to be provided")
+	}
+
+	allFirmwareData, err := os.ReadFile(*firmwareListPath)
+	if err != nil {
+		log.Fatalf("Failed to read firmware source list: %v", err)
+	}
+	allFirmwarePaths := strings.Split(string(allFirmwareData), "\n")
+
+	// Create a look-up table of all possible suffixes to their full paths as
+	// this is much faster at O(n) than calling strings.HasSuffix for every
+	// possible combination which is O(n^2).
+	// For example a build output at out/a/b/c.bin will be entered into
+	// the suffix LUT as build as out/a/b/c.bin, a/b/c.bin, b/c.bin and c.bin.
+	// If the firmware then requests b/c.bin, the output path is contained in
+	// the suffix LUT.
+	suffixLUT := make(map[string]string)
+	for _, firmwarePath := range allFirmwarePaths {
+		pathParts := strings.Split(firmwarePath, string(os.PathSeparator))
+		for i := range pathParts {
+			suffixLUT[path.Join(pathParts[i:]...)] = firmwarePath
+		}
+	}
+
+	// The linux-firmware repo contains a WHENCE file which contains (among
+	// other information) aliases for firmware which should be symlinked.
+	// Open this file and create a map of aliases in it.
+	linkMap := make(map[string]string)
+	metadata, err := os.ReadFile(*whenceFilePath)
+	if err != nil {
+		log.Fatalf("Failed to read metadata file: %v", err)
+	}
+	linksRaw := linkRegexp.FindAllStringSubmatch(string(metadata), -1)
+	for _, link := range linksRaw {
+		// For links we know the exact path referenced by kernel drives so
+		// a suffix LUT is unnecessary.
+		linkMap[link[1]] = link[2]
+	}
+
+	// Collect module metadata (modinfo) from both built-in modules via the
+	// kbuild-generated metadata file as well as from the loadable modules by
+	// walking them.
+	var files []*fsspec.File
+	var symlinks []*fsspec.SymbolicLink
+
+	mi, err := os.Open(*modinfoPath)
+	if err != nil {
+		log.Fatalf("While reading modinfo: %v", err)
+	}
+	modMeta, err := kmod.GetBuiltinModulesInfo(mi)
+	if err != nil {
+		log.Fatalf("Failed to read modules modinfo data: %v", err)
+	}
+
+	err = filepath.WalkDir(*modulesPath, func(p string, d fs.DirEntry, err error) error {
+		if err != nil {
+			log.Fatal(err)
+		}
+		if d.IsDir() {
+			return nil
+		}
+		mod, err := elf.Open(p)
+		if err != nil {
+			log.Fatal(err)
+		}
+		defer mod.Close()
+		out, err := kmod.GetModuleInfo(mod)
+		if err != nil {
+			log.Fatal(err)
+		}
+		relPath, err := filepath.Rel(*modulesPath, p)
+		if err != nil {
+			return err
+		}
+		// Add path information for MakeMetaFromModuleInfo.
+		out["path"] = []string{relPath}
+		modMeta = append(modMeta, out)
+		files = append(files, &fsspec.File{
+			Path:       path.Join("/lib/modules", relPath),
+			SourcePath: filepath.Join(*modulesPath, relPath),
+			Mode:       0555,
+		})
+		return nil
+	})
+	if err != nil {
+		log.Fatalf("Error walking modules: %v", err)
+	}
+
+	// Generate loading metadata from all known modules.
+	meta, err := kmod.MakeMetaFromModuleInfo(modMeta)
+	if err != nil {
+		log.Fatal(err)
+	}
+	metaRaw, err := proto.Marshal(meta)
+	if err != nil {
+		log.Fatal(err)
+	}
+	if err := os.WriteFile(*outMetaPath, metaRaw, 0640); err != nil {
+		log.Fatal(err)
+	}
+	files = append(files, &fsspec.File{
+		Path:       "/lib/modules/meta.pb",
+		SourcePath: *outMetaPath,
+		Mode:       0444,
+	})
+
+	// Create set of all firmware paths required by modules
+	fwset := make(map[string]bool)
+	for _, m := range modMeta {
+		if len(m["path"]) == 0 && len(m.Firmware()) > 0 {
+			log.Fatalf("Module %v is built-in, but requires firmware. Linux does not support this in all configurations.", m.Name())
+		}
+		for _, fw := range m.Firmware() {
+			fwset[fw] = true
+		}
+	}
+
+	// Convert set to list and sort for determinism
+	fwp := make([]string, 0, len(fwset))
+	for p := range fwset {
+		fwp = append(fwp, p)
+	}
+	sort.Strings(fwp)
+
+	// This function is called for every requested firmware file and adds and
+	// resolves symlinks until it finds the target file and adds that too.
+	populatedPaths := make(map[string]bool)
+	var chaseReference func(string)
+	chaseReference = func(p string) {
+		if populatedPaths[p] {
+			// Bail if path is already populated. Because of the DAG-like
+			// property of links in filesystems everything transitively pointed
+			// to by anything at this path has already been included.
+			return
+		}
+		placedPath := path.Join("/lib/firmware", p)
+		if linkTarget := linkMap[p]; linkTarget != "" {
+			symlinks = append(symlinks, &fsspec.SymbolicLink{
+				Path:       placedPath,
+				TargetPath: linkTarget,
+			})
+			populatedPaths[p] = true
+			// Symlinks are relative to their place, resolve them to be relative
+			// to the firmware root directory.
+			chaseReference(path.Join(path.Dir(p), linkTarget))
+			return
+		}
+		sourcePath := suffixLUT[p]
+		if sourcePath == "" {
+			// This should not be fatal as sometimes linux-firmware cannot
+			// ship all firmware usable by the kernel for mostly legal reasons.
+			log.Printf("WARNING: Requested firmware %q not found", p)
+			return
+		}
+		files = append(files, &fsspec.File{
+			Path:       path.Join("/lib/firmware", p),
+			Mode:       0444,
+			SourcePath: sourcePath,
+		})
+		populatedPaths[p] = true
+	}
+
+	for _, p := range fwp {
+		chaseReference(p)
+	}
+	// Format output in a both human- and machine-readable form
+	marshalOpts := prototext.MarshalOptions{Multiline: true, Indent: "  "}
+	fsspecRaw, err := marshalOpts.Marshal(&fsspec.FSSpec{File: files, SymbolicLink: symlinks})
+	if err != nil {
+		log.Fatalf("failed to marshal fsspec: %v", err)
+	}
+	if err := os.WriteFile(*outFSSpecPath, fsspecRaw, 0644); err != nil {
+		log.Fatalf("failed writing output: %v", err)
+	}
+}
diff --git a/osbase/build/genosrelease/BUILD.bazel b/osbase/build/genosrelease/BUILD.bazel
new file mode 100644
index 0000000..f845cae
--- /dev/null
+++ b/osbase/build/genosrelease/BUILD.bazel
@@ -0,0 +1,18 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "genosrelease_lib",
+    srcs = ["main.go"],
+    importpath = "source.monogon.dev/osbase/build/genosrelease",
+    visibility = ["//visibility:private"],
+    deps = ["@com_github_joho_godotenv//:godotenv"],
+)
+
+go_binary(
+    name = "genosrelease",
+    embed = [":genosrelease_lib"],
+    visibility = [
+        "//metropolis/installer:__subpackages__",
+        "//metropolis/node:__subpackages__",
+    ],
+)
diff --git a/osbase/build/genosrelease/defs.bzl b/osbase/build/genosrelease/defs.bzl
new file mode 100644
index 0000000..6fed483
--- /dev/null
+++ b/osbase/build/genosrelease/defs.bzl
@@ -0,0 +1,54 @@
+#  Copyright 2020 The Monogon Project Authors.
+#
+#  SPDX-License-Identifier: Apache-2.0
+#
+#  Licensed under the Apache License, Version 2.0 (the "License");
+#  you may not use this file except in compliance with the License.
+#  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+def _os_release_impl(ctx):
+    ctx.actions.run(
+        mnemonic = "GenOSRelease",
+        progress_message = "Generating os-release",
+        inputs = [ctx.info_file],
+        outputs = [ctx.outputs.out],
+        executable = ctx.executable._genosrelease,
+        arguments = [
+            "-status_file",
+            ctx.info_file.path,
+            "-out_file",
+            ctx.outputs.out.path,
+            "-stamp_var",
+            ctx.attr.stamp_var,
+            "-name",
+            ctx.attr.os_name,
+            "-id",
+            ctx.attr.os_id,
+        ],
+    )
+
+os_release = rule(
+    implementation = _os_release_impl,
+    attrs = {
+        "os_name": attr.string(mandatory = True),
+        "os_id": attr.string(mandatory = True),
+        "stamp_var": attr.string(mandatory = True),
+        "_genosrelease": attr.label(
+            default = Label("//osbase/build/genosrelease"),
+            cfg = "host",
+            executable = True,
+            allow_files = True,
+        ),
+    },
+    outputs = {
+        "out": "os-release",
+    },
+)
diff --git a/osbase/build/genosrelease/main.go b/osbase/build/genosrelease/main.go
new file mode 100644
index 0000000..adb8202
--- /dev/null
+++ b/osbase/build/genosrelease/main.go
@@ -0,0 +1,79 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// genosrelease provides rudimentary support to generate os-release files
+// following the freedesktop spec from arguments and stamping
+//
+// https://www.freedesktop.org/software/systemd/man/os-release.html
+package main
+
+import (
+	"flag"
+	"fmt"
+	"os"
+	"strings"
+
+	"github.com/joho/godotenv"
+)
+
+var (
+	flagStatusFile = flag.String("status_file", "", "path to bazel workspace status file")
+	flagOutFile    = flag.String("out_file", "os-release", "path to os-release output file")
+	flagStampVar   = flag.String("stamp_var", "", "variable to use as version from the workspace status file")
+	flagName       = flag.String("name", "", "name parameter (see freedesktop spec)")
+	flagID         = flag.String("id", "", "id parameter (see freedesktop spec)")
+)
+
+func main() {
+	flag.Parse()
+	statusFileContent, err := os.ReadFile(*flagStatusFile)
+	if err != nil {
+		fmt.Printf("Failed to open bazel workspace status file: %v\n", err)
+		os.Exit(1)
+	}
+	statusVars := make(map[string]string)
+	for _, line := range strings.Split(string(statusFileContent), "\n") {
+		line = strings.TrimSpace(line)
+		parts := strings.Fields(line)
+		if len(parts) != 2 {
+			continue
+		}
+		statusVars[parts[0]] = parts[1]
+	}
+
+	version, ok := statusVars[*flagStampVar]
+	if !ok {
+		fmt.Printf("%v key not set in bazel workspace status file\n", *flagStampVar)
+		os.Exit(1)
+	}
+	// As specified by https://www.freedesktop.org/software/systemd/man/os-release.html
+	osReleaseVars := map[string]string{
+		"NAME":        *flagName,
+		"ID":          *flagID,
+		"VERSION":     version,
+		"VERSION_ID":  version,
+		"PRETTY_NAME": *flagName + " " + version,
+	}
+	osReleaseContent, err := godotenv.Marshal(osReleaseVars)
+	if err != nil {
+		fmt.Printf("Failed to encode os-release file: %v\n", err)
+		os.Exit(1)
+	}
+	if err := os.WriteFile(*flagOutFile, []byte(osReleaseContent), 0644); err != nil {
+		fmt.Printf("Failed to write os-release file: %v\n", err)
+		os.Exit(1)
+	}
+}
diff --git a/osbase/build/kconfig-patcher/BUILD.bazel b/osbase/build/kconfig-patcher/BUILD.bazel
new file mode 100644
index 0000000..59ee391
--- /dev/null
+++ b/osbase/build/kconfig-patcher/BUILD.bazel
@@ -0,0 +1,23 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library", "go_test")
+
+go_library(
+    name = "kconfig-patcher_lib",
+    srcs = ["main.go"],
+    importpath = "source.monogon.dev/osbase/build/kconfig-patcher",
+    visibility = ["//visibility:private"],
+)
+
+go_binary(
+    name = "kconfig-patcher",
+    embed = [":kconfig-patcher_lib"],
+    visibility = [
+        "//metropolis/node:__pkg__",
+        "//osbase/test/ktest:__pkg__",
+    ],
+)
+
+go_test(
+    name = "kconfig-patcher_test",
+    srcs = ["main_test.go"],
+    embed = [":kconfig-patcher_lib"],
+)
diff --git a/osbase/build/kconfig-patcher/kconfig-patcher.bzl b/osbase/build/kconfig-patcher/kconfig-patcher.bzl
new file mode 100644
index 0000000..39e786e
--- /dev/null
+++ b/osbase/build/kconfig-patcher/kconfig-patcher.bzl
@@ -0,0 +1,33 @@
+#  Copyright 2020 The Monogon Project Authors.
+#
+#  SPDX-License-Identifier: Apache-2.0
+#
+#  Licensed under the Apache License, Version 2.0 (the "License");
+#  you may not use this file except in compliance with the License.
+#  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+#  limitations under the License.
+
+"""Override configs in a Linux kernel Kconfig
+"""
+
+def kconfig_patch(name, src, out, override_configs, **kwargs):
+    native.genrule(
+        name = name,
+        srcs = [src],
+        outs = [out],
+        tools = [
+            "//osbase/build/kconfig-patcher",
+        ],
+        cmd = """
+        $(location //osbase/build/kconfig-patcher) \
+            -in $< -out $@ '%s'
+        """ % struct(overrides = override_configs).to_json(),
+        **kwargs
+    )
diff --git a/osbase/build/kconfig-patcher/main.go b/osbase/build/kconfig-patcher/main.go
new file mode 100644
index 0000000..1b5f24f
--- /dev/null
+++ b/osbase/build/kconfig-patcher/main.go
@@ -0,0 +1,98 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+	"bufio"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"io"
+	"os"
+	"strings"
+)
+
+var (
+	inPath  = flag.String("in", "", "Path to input Kconfig")
+	outPath = flag.String("out", "", "Path to output Kconfig")
+)
+
+func main() {
+	flag.Parse()
+	if *inPath == "" || *outPath == "" {
+		flag.PrintDefaults()
+		os.Exit(2)
+	}
+	inFile, err := os.Open(*inPath)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Failed to open input Kconfig: %v\n", err)
+		os.Exit(1)
+	}
+	outFile, err := os.Create(*outPath)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Failed to create output Kconfig: %v\n", err)
+		os.Exit(1)
+	}
+	var config struct {
+		Overrides map[string]string `json:"overrides"`
+	}
+	if err := json.Unmarshal([]byte(flag.Arg(0)), &config); err != nil {
+		fmt.Fprintf(os.Stderr, "Failed to parse overrides: %v\n", err)
+		os.Exit(1)
+	}
+	err = patchKconfig(inFile, outFile, config.Overrides)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "Failed to patch: %v\n", err)
+		os.Exit(1)
+	}
+}
+
+func patchKconfig(inFile io.Reader, outFile io.Writer, overrides map[string]string) error {
+	scanner := bufio.NewScanner(inFile)
+	for scanner.Scan() {
+		if scanner.Err() != nil {
+			return scanner.Err()
+		}
+		line := scanner.Text()
+		cleanLine := strings.TrimSpace(line)
+		if strings.HasPrefix(cleanLine, "#") || cleanLine == "" {
+			// Pass through comments and empty lines
+			fmt.Fprintln(outFile, line)
+		} else {
+			// Line contains a configuration option
+			parts := strings.SplitN(line, "=", 2)
+			keyName := parts[0]
+			if overrideVal, ok := overrides[strings.TrimSpace(keyName)]; ok {
+				// Override it
+				if overrideVal == "" {
+					fmt.Fprintf(outFile, "# %v is not set\n", keyName)
+				} else {
+					fmt.Fprintf(outFile, "%v=%v\n", keyName, overrideVal)
+				}
+				delete(overrides, keyName)
+			} else {
+				// Pass through unchanged
+				fmt.Fprintln(outFile, line)
+			}
+		}
+	}
+	// Process left over overrides
+	for key, val := range overrides {
+		fmt.Fprintf(outFile, "%v=%v\n", key, val)
+	}
+	return nil
+}
diff --git a/osbase/build/kconfig-patcher/main_test.go b/osbase/build/kconfig-patcher/main_test.go
new file mode 100644
index 0000000..11c7d84
--- /dev/null
+++ b/osbase/build/kconfig-patcher/main_test.go
@@ -0,0 +1,61 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+	"bytes"
+	"strings"
+	"testing"
+)
+
+func Test_patchKconfig(t *testing.T) {
+	type args struct {
+		inFile    string
+		overrides map[string]string
+	}
+	tests := []struct {
+		name        string
+		args        args
+		wantOutFile string
+		wantErr     bool
+	}{
+		{
+			"passthroughExtend",
+			args{inFile: "# TEST=y\n\n", overrides: map[string]string{"TEST": "n"}},
+			"# TEST=y\n\nTEST=n\n",
+			false,
+		},
+		{
+			"patch",
+			args{inFile: "TEST=y\nTEST_NO=n\n", overrides: map[string]string{"TEST": "n"}},
+			"TEST=n\nTEST_NO=n\n",
+			false,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			outFile := &bytes.Buffer{}
+			if err := patchKconfig(strings.NewReader(tt.args.inFile), outFile, tt.args.overrides); (err != nil) != tt.wantErr {
+				t.Errorf("patchKconfig() error = %v, wantErr %v", err, tt.wantErr)
+				return
+			}
+			if gotOutFile := outFile.String(); gotOutFile != tt.wantOutFile {
+				t.Errorf("patchKconfig() = %v, want %v", gotOutFile, tt.wantOutFile)
+			}
+		})
+	}
+}
diff --git a/osbase/build/mkcpio/BUILD.bazel b/osbase/build/mkcpio/BUILD.bazel
new file mode 100644
index 0000000..d281f6d
--- /dev/null
+++ b/osbase/build/mkcpio/BUILD.bazel
@@ -0,0 +1,20 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "mkcpio_lib",
+    srcs = ["main.go"],
+    importpath = "source.monogon.dev/osbase/build/mkcpio",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//osbase/build/fsspec",
+        "@com_github_cavaliergopher_cpio//:cpio",
+        "@com_github_klauspost_compress//zstd",
+        "@org_golang_x_sys//unix",
+    ],
+)
+
+go_binary(
+    name = "mkcpio",
+    embed = [":mkcpio_lib"],
+    visibility = ["//visibility:public"],
+)
diff --git a/osbase/build/mkcpio/main.go b/osbase/build/mkcpio/main.go
new file mode 100644
index 0000000..4886c3c
--- /dev/null
+++ b/osbase/build/mkcpio/main.go
@@ -0,0 +1,218 @@
+package main
+
+import (
+	"flag"
+	"io"
+	"log"
+	"os"
+	"path"
+	"sort"
+	"strings"
+
+	"github.com/cavaliergopher/cpio"
+	"github.com/klauspost/compress/zstd"
+	"golang.org/x/sys/unix"
+
+	"source.monogon.dev/osbase/build/fsspec"
+)
+
+var (
+	outPath = flag.String("out", "", "Output file path")
+)
+
+type placeEnum int
+
+const (
+	// placeNone implies that currently nothing is placed at that path.
+	// Can be overridden by everything.
+	placeNone placeEnum = 0
+	// placeDirImplicit means that there is currently a implied directory
+	// at the given path. It can be overridden by (and only by) an explicit
+	// directory.
+	placeDirImplicit placeEnum = 1
+	// placeDirExplicit means that there is an explicit (i.e. specified by
+	// the FSSpec) directory at the given path. Nothing else can override
+	// this.
+	placeDirExplicit placeEnum = 2
+	// placeNonDir means that there is a file-type resource (i.e a file, symlink
+	// or special_file) at the given path. Nothing else can override this.
+	placeNonDir placeEnum = 3
+)
+
+// place represents the state a given canonical path is in during metadata
+// construction. Its zero value is { State: placeNone, Inode: nil }.
+type place struct {
+	State placeEnum
+	// Inode contains one of the types inside an FSSpec (e.g. *fsspec.File)
+	Inode interface{}
+}
+
+// Usage: -out <out-path.cpio.zst> fsspec-path...
+func main() {
+	flag.Parse()
+	outFile, err := os.Create(*outPath)
+	if err != nil {
+		log.Fatalf("Failed to open CPIO output file: %v", err)
+	}
+	defer outFile.Close()
+	compressedOut, err := zstd.NewWriter(outFile)
+	if err != nil {
+		log.Fatalf("While initializing zstd writer: %v", err)
+	}
+	defer compressedOut.Close()
+	cpioWriter := cpio.NewWriter(compressedOut)
+	defer cpioWriter.Close()
+
+	spec, err := fsspec.ReadMergeSpecs(flag.Args())
+	if err != nil {
+		log.Fatalf("failed to load specs: %v", err)
+	}
+
+	// Map of paths to metadata for validation & implicit directory injection
+	places := make(map[string]place)
+
+	// The idea behind this machinery is that we try to place all files and
+	// directories into a map while creating the required parent directories
+	// on-the-fly as implicit directories. Overriding an implicit directory
+	// with an explicit one is allowed thus the actual order in which this
+	// structure is created does not matter. All non-directories cannot be
+	// overridden anyways so their insertion order does not matter.
+	// This also has the job of validating the FSSpec structure, ensuring that
+	// there are no duplicate paths and that there is nothing placed below a
+	// non-directory.
+	var placeInode func(p string, isDir bool, inode interface{})
+	placeInode = func(p string, isDir bool, inode interface{}) {
+		cleanPath := path.Clean(p)
+		if !isDir {
+			if places[cleanPath].State != placeNone {
+				log.Fatalf("Invalid FSSpec: Duplicate Inode at %q", cleanPath)
+			}
+			places[cleanPath] = place{
+				State: placeNonDir,
+				Inode: inode,
+			}
+		} else {
+			switch places[cleanPath].State {
+			case placeNone:
+				if inode != nil {
+					places[cleanPath] = place{
+						State: placeDirExplicit,
+						Inode: inode,
+					}
+				} else {
+					places[cleanPath] = place{
+						State: placeDirImplicit,
+						Inode: &fsspec.Directory{Path: cleanPath, Mode: 0555},
+					}
+				}
+			case placeDirImplicit:
+				if inode != nil {
+					places[cleanPath] = place{
+						State: placeDirExplicit,
+						Inode: inode,
+					}
+				}
+			case placeDirExplicit:
+				if inode != nil {
+					log.Fatalf("Invalid FSSpec: Conflicting explicit directories at %v", cleanPath)
+				}
+			case placeNonDir:
+				log.Fatalf("Invalid FSSpec: Trying to place inode below non-directory at #{cleanPath}")
+			default:
+				panic("unhandled placeEnum value")
+			}
+		}
+		parentPath, _ := path.Split(p)
+		parentPath = path.Clean(parentPath)
+		if parentPath == "/" || parentPath == p {
+			return
+		}
+		placeInode(parentPath, true, nil)
+	}
+	for _, d := range spec.Directory {
+		placeInode(d.Path, true, d)
+	}
+	for _, f := range spec.File {
+		placeInode(f.Path, false, f)
+	}
+	for _, s := range spec.SymbolicLink {
+		placeInode(s.Path, false, s)
+	}
+	for _, s := range spec.SpecialFile {
+		placeInode(s.Path, false, s)
+	}
+
+	var writeOrder []string
+	for path := range places {
+		writeOrder = append(writeOrder, path)
+	}
+	// Sorting a list of normalized paths representing a tree gives us Depth-
+	// first search (DFS) order which is the correct order for writing archives.
+	// This also makes the output reproducible.
+	sort.Strings(writeOrder)
+
+	for _, path := range writeOrder {
+		place := places[path]
+		switch i := place.Inode.(type) {
+		case *fsspec.File:
+			inF, err := os.Open(i.SourcePath)
+			if err != nil {
+				log.Fatalf("Failed to open source path for file %q: %v", i.Path, err)
+			}
+			inFStat, err := inF.Stat()
+			if err != nil {
+				log.Fatalf("Failed to stat source path for file %q: %v", i.Path, err)
+			}
+			if err := cpioWriter.WriteHeader(&cpio.Header{
+				Mode: cpio.FileMode(i.Mode),
+				Name: strings.TrimPrefix(i.Path, "/"),
+				Size: inFStat.Size(),
+			}); err != nil {
+				log.Fatalf("Failed to write cpio header for file %q: %v", i.Path, err)
+			}
+			if n, err := io.Copy(cpioWriter, inF); err != nil || n != inFStat.Size() {
+				log.Fatalf("Failed to copy file %q into cpio: %v", i.SourcePath, err)
+			}
+			inF.Close()
+		case *fsspec.Directory:
+			if err := cpioWriter.WriteHeader(&cpio.Header{
+				Mode: cpio.FileMode(i.Mode) | cpio.TypeDir,
+				Name: strings.TrimPrefix(i.Path, "/"),
+			}); err != nil {
+				log.Fatalf("Failed to write cpio header for directory %q: %v", i.Path, err)
+			}
+		case *fsspec.SymbolicLink:
+			if err := cpioWriter.WriteHeader(&cpio.Header{
+				// Symlinks are 0777 by definition (from man 7 symlink on Linux)
+				Mode: 0777 | cpio.TypeSymlink,
+				Name: strings.TrimPrefix(i.Path, "/"),
+				Size: int64(len(i.TargetPath)),
+			}); err != nil {
+				log.Fatalf("Failed to write cpio header for symlink %q: %v", i.Path, err)
+			}
+			if _, err := cpioWriter.Write([]byte(i.TargetPath)); err != nil {
+				log.Fatalf("Failed to write cpio symlink %q: %v", i.Path, err)
+			}
+		case *fsspec.SpecialFile:
+			mode := cpio.FileMode(i.Mode)
+			switch i.Type {
+			case fsspec.SpecialFile_CHARACTER_DEV:
+				mode |= cpio.TypeChar
+			case fsspec.SpecialFile_BLOCK_DEV:
+				mode |= cpio.TypeBlock
+			case fsspec.SpecialFile_FIFO:
+				mode |= cpio.TypeFifo
+			}
+
+			if err := cpioWriter.WriteHeader(&cpio.Header{
+				Mode:     mode,
+				Name:     strings.TrimPrefix(i.Path, "/"),
+				DeviceID: int(unix.Mkdev(i.Major, i.Minor)),
+			}); err != nil {
+				log.Fatalf("Failed to write CPIO header for special file %q: %v", i.Path, err)
+			}
+		default:
+			panic("inode type not handled")
+		}
+	}
+}
diff --git a/osbase/build/mkerofs/BUILD.bazel b/osbase/build/mkerofs/BUILD.bazel
new file mode 100644
index 0000000..ce648b9
--- /dev/null
+++ b/osbase/build/mkerofs/BUILD.bazel
@@ -0,0 +1,18 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "mkerofs_lib",
+    srcs = ["main.go"],
+    importpath = "source.monogon.dev/osbase/build/mkerofs",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//osbase/build/fsspec",
+        "//osbase/erofs",
+    ],
+)
+
+go_binary(
+    name = "mkerofs",
+    embed = [":mkerofs_lib"],
+    visibility = ["//visibility:public"],
+)
diff --git a/osbase/build/mkerofs/main.go b/osbase/build/mkerofs/main.go
new file mode 100644
index 0000000..edcfdb9
--- /dev/null
+++ b/osbase/build/mkerofs/main.go
@@ -0,0 +1,208 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// mkerofs takes a specification in the form of a prototext file (see fsspec
+// next to this) and assembles an EROFS filesystem according to it. The output
+// is fully reproducible.
+package main
+
+import (
+	"flag"
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"path"
+	"sort"
+	"strings"
+
+	"source.monogon.dev/osbase/build/fsspec"
+	"source.monogon.dev/osbase/erofs"
+)
+
+func (spec *entrySpec) writeRecursive(w *erofs.Writer, pathname string) {
+	switch inode := spec.data.Type.(type) {
+	case *fsspec.Inode_Directory:
+		// Sort children for reproducibility
+		var sortedChildren []string
+		for name := range spec.children {
+			sortedChildren = append(sortedChildren, name)
+		}
+		sort.Strings(sortedChildren)
+
+		err := w.Create(pathname, &erofs.Directory{
+			Base: erofs.Base{
+				Permissions: uint16(inode.Directory.Mode),
+				UID:         uint16(inode.Directory.Uid),
+				GID:         uint16(inode.Directory.Gid),
+			},
+			Children: sortedChildren,
+		})
+		if err != nil {
+			log.Fatalf("failed to write directory: %s", err)
+		}
+		for _, name := range sortedChildren {
+			spec.children[name].writeRecursive(w, path.Join(pathname, name))
+		}
+	case *fsspec.Inode_File:
+		iw := w.CreateFile(pathname, &erofs.FileMeta{
+			Base: erofs.Base{
+				Permissions: uint16(inode.File.Mode),
+				UID:         uint16(inode.File.Uid),
+				GID:         uint16(inode.File.Gid),
+			},
+		})
+
+		sourceFile, err := os.Open(inode.File.SourcePath)
+		if err != nil {
+			log.Fatalf("failed to open source file %s: %s", inode.File.SourcePath, err)
+		}
+
+		_, err = io.Copy(iw, sourceFile)
+		if err != nil {
+			log.Fatalf("failed to copy file into filesystem: %s", err)
+		}
+		sourceFile.Close()
+		if err := iw.Close(); err != nil {
+			log.Fatalf("failed to close target file: %s", err)
+		}
+	case *fsspec.Inode_SymbolicLink:
+		err := w.Create(pathname, &erofs.SymbolicLink{
+			Base: erofs.Base{
+				Permissions: 0777, // Nominal, Linux forces that mode anyways, see symlink(7)
+			},
+			Target: inode.SymbolicLink.TargetPath,
+		})
+		if err != nil {
+			log.Fatalf("failed to create symbolic link: %s", err)
+		}
+	case *fsspec.Inode_SpecialFile:
+		err := fmt.Errorf("unimplemented special file type %s", inode.SpecialFile.Type)
+		base := erofs.Base{
+			Permissions: uint16(inode.SpecialFile.Mode),
+			UID:         uint16(inode.SpecialFile.Uid),
+			GID:         uint16(inode.SpecialFile.Gid),
+		}
+		switch inode.SpecialFile.Type {
+		case fsspec.SpecialFile_FIFO:
+			err = w.Create(pathname, &erofs.FIFO{
+				Base: base,
+			})
+		case fsspec.SpecialFile_CHARACTER_DEV:
+			err = w.Create(pathname, &erofs.CharacterDevice{
+				Base:  base,
+				Major: inode.SpecialFile.Major,
+				Minor: inode.SpecialFile.Minor,
+			})
+		case fsspec.SpecialFile_BLOCK_DEV:
+			err = w.Create(pathname, &erofs.BlockDevice{
+				Base:  base,
+				Major: inode.SpecialFile.Major,
+				Minor: inode.SpecialFile.Minor,
+			})
+		}
+		if err != nil {
+			log.Fatalf("failed to make special file: %v", err)
+		}
+	}
+}
+
+// entrySpec is a recursive structure representing the filesystem tree
+type entrySpec struct {
+	data     fsspec.Inode
+	children map[string]*entrySpec
+}
+
+// pathRef gets the entrySpec at the leaf of the given path, inferring
+// directories if necessary
+func (spec *entrySpec) pathRef(p string) *entrySpec {
+	// This block gets a path array starting at the root of the filesystem. The
+	// root folder is the zero-length array.
+	pathParts := strings.Split(path.Clean("./"+p), "/")
+	if pathParts[0] == "." {
+		pathParts = pathParts[1:]
+	}
+
+	entryRef := spec
+	for _, part := range pathParts {
+		childRef, ok := entryRef.children[part]
+		if !ok {
+			childRef = &entrySpec{
+				data:     fsspec.Inode{Type: &fsspec.Inode_Directory{Directory: &fsspec.Directory{Mode: 0555}}},
+				children: make(map[string]*entrySpec),
+			}
+			entryRef.children[part] = childRef
+		}
+		entryRef = childRef
+	}
+	return entryRef
+}
+
+var (
+	outPath = flag.String("out", "", "Output file path")
+)
+
+func main() {
+	flag.Parse()
+
+	spec, err := fsspec.ReadMergeSpecs(flag.Args())
+	if err != nil {
+		log.Fatalf("failed to load specs: %v", err)
+	}
+
+	var fsRoot = &entrySpec{
+		data:     fsspec.Inode{Type: &fsspec.Inode_Directory{Directory: &fsspec.Directory{Mode: 0555}}},
+		children: make(map[string]*entrySpec),
+	}
+
+	for _, dir := range spec.Directory {
+		entryRef := fsRoot.pathRef(dir.Path)
+		entryRef.data.Type = &fsspec.Inode_Directory{Directory: dir}
+	}
+
+	for _, file := range spec.File {
+		entryRef := fsRoot.pathRef(file.Path)
+		entryRef.data.Type = &fsspec.Inode_File{File: file}
+	}
+
+	for _, symlink := range spec.SymbolicLink {
+		entryRef := fsRoot.pathRef(symlink.Path)
+		entryRef.data.Type = &fsspec.Inode_SymbolicLink{SymbolicLink: symlink}
+	}
+
+	for _, specialFile := range spec.SpecialFile {
+		entryRef := fsRoot.pathRef(specialFile.Path)
+		entryRef.data.Type = &fsspec.Inode_SpecialFile{SpecialFile: specialFile}
+	}
+
+	fs, err := os.Create(*outPath)
+	if err != nil {
+		log.Fatalf("failed to open output file: %v", err)
+	}
+	writer, err := erofs.NewWriter(fs)
+	if err != nil {
+		log.Fatalf("failed to initialize EROFS writer: %v", err)
+	}
+
+	fsRoot.writeRecursive(writer, ".")
+
+	if err := writer.Close(); err != nil {
+		panic(err)
+	}
+	if err := fs.Close(); err != nil {
+		panic(err)
+	}
+}
diff --git a/osbase/build/mkimage/BUILD.bazel b/osbase/build/mkimage/BUILD.bazel
new file mode 100644
index 0000000..30ba81f
--- /dev/null
+++ b/osbase/build/mkimage/BUILD.bazel
@@ -0,0 +1,22 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "mkimage_lib",
+    srcs = ["main.go"],
+    embedsrcs = [
+        "//metropolis/node/core/abloader",  #keep
+    ],
+    importpath = "source.monogon.dev/osbase/build/mkimage",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//osbase/blkio",
+        "//osbase/blockdev",
+        "//osbase/build/mkimage/osimage",
+    ],
+)
+
+go_binary(
+    name = "mkimage",
+    embed = [":mkimage_lib"],
+    visibility = ["//metropolis/node:__pkg__"],
+)
diff --git a/osbase/build/mkimage/def.bzl b/osbase/build/mkimage/def.bzl
new file mode 100644
index 0000000..1663e3d
--- /dev/null
+++ b/osbase/build/mkimage/def.bzl
@@ -0,0 +1,48 @@
+def _node_image_impl(ctx):
+    img_file = ctx.actions.declare_file(ctx.label.name + ".img")
+    ctx.actions.run(
+        mnemonic = "MkImage",
+        executable = ctx.executable._mkimage,
+        arguments = [
+            "-efi",
+            ctx.file.kernel.path,
+            "-system",
+            ctx.file.system.path,
+            "-out",
+            img_file.path,
+        ],
+        inputs = [
+            ctx.file.kernel,
+            ctx.file.system,
+        ],
+        outputs = [img_file],
+    )
+
+    return [DefaultInfo(files = depset([img_file]), runfiles = ctx.runfiles(files = [img_file]))]
+
+node_image = rule(
+    implementation = _node_image_impl,
+    doc = """
+        Build a disk image from an EFI kernel payload and system partition
+        contents. See //osbase/build/mkimage for more information.
+    """,
+    attrs = {
+        "kernel": attr.label(
+            doc = "EFI binary containing a kernel.",
+            mandatory = True,
+            allow_single_file = True,
+        ),
+        "system": attr.label(
+            doc = "Contents of the system partition.",
+            mandatory = True,
+            allow_single_file = True,
+        ),
+        "_mkimage": attr.label(
+            doc = "The mkimage executable.",
+            default = "//osbase/build/mkimage",
+            allow_single_file = True,
+            executable = True,
+            cfg = "exec",
+        ),
+    },
+)
diff --git a/osbase/build/mkimage/main.go b/osbase/build/mkimage/main.go
new file mode 100644
index 0000000..d83a03d
--- /dev/null
+++ b/osbase/build/mkimage/main.go
@@ -0,0 +1,106 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// mkimage is a tool to generate node disk images.
+// It can be used both to initialize block devices and to create image
+// files.
+//
+// The tool takes a path to an EFI payload (--efi), and a path to a
+// system image (--system) as its only required inputs. In
+// addition, an output path must be supplied (--out).
+// Node parameters file path (--node_parameters) may also be supplied, in
+// which case the file will be copied to the EFI system partition.
+// Partition sizes are fixed and may be overridden by command line flags.
+package main
+
+import (
+	"bytes"
+	_ "embed"
+	"flag"
+	"log"
+	"os"
+
+	"source.monogon.dev/osbase/blkio"
+	"source.monogon.dev/osbase/blockdev"
+	"source.monogon.dev/osbase/build/mkimage/osimage"
+)
+
+//go:embed metropolis/node/core/abloader/abloader_bin.efi
+var abloader []byte
+
+func main() {
+	// Fill in the image parameters based on flags.
+	var (
+		efiPayload  string
+		systemImage string
+		nodeParams  string
+		outputPath  string
+		diskUUID    string
+		cfg         osimage.Params
+	)
+	flag.StringVar(&efiPayload, "efi", "", "Path to the UEFI payload used")
+	flag.StringVar(&systemImage, "system", "", "Path to the system partition image used")
+	flag.StringVar(&nodeParams, "node_parameters", "", "Path to Node Parameters to be written to the ESP (default: don't write Node Parameters)")
+	flag.StringVar(&outputPath, "out", "", "Path to the resulting disk image or block device")
+	flag.Int64Var(&cfg.PartitionSize.Data, "data_partition_size", 2048, "Override the data partition size (default 2048 MiB). Used only when generating image files.")
+	flag.Int64Var(&cfg.PartitionSize.ESP, "esp_partition_size", 128, "Override the ESP partition size (default: 128MiB)")
+	flag.Int64Var(&cfg.PartitionSize.System, "system_partition_size", 1024, "Override the System partition size (default: 1024MiB)")
+	flag.StringVar(&diskUUID, "GUID", "", "Disk GUID marked in the resulting image's partition table (default: randomly generated)")
+	flag.Parse()
+
+	// Open the input files for osimage.Create, fill in reader objects and
+	// metadata in osimage.Params.
+	// Start with the EFI Payload the OS will boot from.
+	p, err := blkio.NewFileReader(efiPayload)
+	if err != nil {
+		log.Fatalf("while opening the EFI payload at %q: %v", efiPayload, err)
+	}
+	cfg.EFIPayload = p
+
+	// Attempt to open the system image if its path is set. In case the path
+	// isn't set, the system partition will still be created, but no
+	// contents will be written into it.
+	if systemImage != "" {
+		img, err := os.Open(systemImage)
+		if err != nil {
+			log.Fatalf("while opening the system image at %q: %v", systemImage, err)
+		}
+		defer img.Close()
+		cfg.SystemImage = img
+	}
+
+	// Attempt to open the node parameters file if its path is set.
+	if nodeParams != "" {
+		np, err := blkio.NewFileReader(nodeParams)
+		if err != nil {
+			log.Fatalf("while opening node parameters at %q: %v", nodeParams, err)
+		}
+		cfg.NodeParameters = np
+	}
+
+	// TODO(#254): Build and use dynamically-grown block devices
+	cfg.Output, err = blockdev.CreateFile(outputPath, 512, 10*1024*1024)
+	if err != nil {
+		panic(err)
+	}
+
+	cfg.ABLoader = bytes.NewReader(abloader)
+
+	// Write the parametrized OS image.
+	if _, err := osimage.Write(&cfg); err != nil {
+		log.Fatalf("while creating a Metropolis OS image: %v", err)
+	}
+}
diff --git a/osbase/build/mkimage/osimage/BUILD.bazel b/osbase/build/mkimage/osimage/BUILD.bazel
new file mode 100644
index 0000000..cfcf096
--- /dev/null
+++ b/osbase/build/mkimage/osimage/BUILD.bazel
@@ -0,0 +1,15 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "osimage",
+    srcs = ["osimage.go"],
+    importpath = "source.monogon.dev/osbase/build/mkimage/osimage",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//osbase/blockdev",
+        "//osbase/efivarfs",
+        "//osbase/fat32",
+        "//osbase/gpt",
+        "@com_github_google_uuid//:uuid",
+    ],
+)
diff --git a/osbase/build/mkimage/osimage/osimage.go b/osbase/build/mkimage/osimage/osimage.go
new file mode 100644
index 0000000..d139d6b
--- /dev/null
+++ b/osbase/build/mkimage/osimage/osimage.go
@@ -0,0 +1,237 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// This package provides self-contained implementation used to generate
+// Metropolis disk images.
+package osimage
+
+import (
+	"fmt"
+	"io"
+	"strings"
+
+	"github.com/google/uuid"
+
+	"source.monogon.dev/osbase/blockdev"
+	"source.monogon.dev/osbase/efivarfs"
+	"source.monogon.dev/osbase/fat32"
+	"source.monogon.dev/osbase/gpt"
+)
+
+var (
+	SystemAType = uuid.MustParse("ee96054b-f6d0-4267-aaaa-724b2afea74c")
+	SystemBType = uuid.MustParse("ee96054b-f6d0-4267-bbbb-724b2afea74c")
+
+	DataType = uuid.MustParse("9eeec464-6885-414a-b278-4305c51f7966")
+)
+
+const (
+	SystemALabel = "METROPOLIS-SYSTEM-A"
+	SystemBLabel = "METROPOLIS-SYSTEM-B"
+	DataLabel    = "METROPOLIS-NODE-DATA"
+	ESPLabel     = "ESP"
+
+	EFIPayloadPath = "/EFI/BOOT/BOOTx64.EFI"
+	EFIBootAPath   = "/EFI/metropolis/boot-a.efi"
+	EFIBootBPath   = "/EFI/metropolis/boot-b.efi"
+	nodeParamsPath = "metropolis/parameters.pb"
+)
+
+// PartitionSizeInfo contains parameters used during partition table
+// initialization and, in case of image files, space allocation.
+type PartitionSizeInfo struct {
+	// Size of the EFI System Partition (ESP), in mebibytes. The size must
+	// not be zero.
+	ESP int64
+	// Size of the Metropolis system partition, in mebibytes. The partition
+	// won't be created if the size is zero.
+	System int64
+	// Size of the Metropolis data partition, in mebibytes. The partition
+	// won't be created if the size is zero. If the image is output to a
+	// block device, the partition will be extended to fill the remaining
+	// space.
+	Data int64
+}
+
+// Params contains parameters used by Plan or Write to build a Metropolis OS
+// image.
+type Params struct {
+	// Output is the block device to which the OS image is written.
+	Output blockdev.BlockDev
+	// ABLoader provides the A/B loader which then loads the EFI loader for the
+	// correct slot.
+	ABLoader fat32.SizedReader
+	// EFIPayload provides contents of the EFI payload file. It must not be
+	// nil. This gets put into boot slot A.
+	EFIPayload fat32.SizedReader
+	// SystemImage provides contents of the Metropolis system partition.
+	// If nil, no contents will be copied into the partition.
+	SystemImage io.Reader
+	// NodeParameters provides contents of the node parameters file. If nil,
+	// the node parameters file won't be created in the target ESP
+	// filesystem.
+	NodeParameters fat32.SizedReader
+	// DiskGUID is a unique identifier of the image and a part of Table
+	// header. It's optional and can be left blank if the identifier is
+	// to be randomly generated. Setting it to a predetermined value can
+	// help in implementing reproducible builds.
+	DiskGUID uuid.UUID
+	// PartitionSize specifies a size for the ESP, Metropolis System and
+	// Metropolis data partition.
+	PartitionSize PartitionSizeInfo
+}
+
+type plan struct {
+	*Params
+	rootInode        fat32.Inode
+	tbl              *gpt.Table
+	efiPartition     *gpt.Partition
+	systemPartitionA *gpt.Partition
+	systemPartitionB *gpt.Partition
+	dataPartition    *gpt.Partition
+}
+
+// Apply actually writes the planned installation to the blockdevice.
+func (i *plan) Apply() (*efivarfs.LoadOption, error) {
+	// Discard the entire device, we're going to write new data over it.
+	// Ignore errors, this is only advisory.
+	i.Output.Discard(0, i.Output.BlockCount()*i.Output.BlockSize())
+
+	if err := fat32.WriteFS(blockdev.NewRWS(i.efiPartition), i.rootInode, fat32.Options{
+		BlockSize:  uint16(i.efiPartition.BlockSize()),
+		BlockCount: uint32(i.efiPartition.BlockCount()),
+		Label:      "MNGN_BOOT",
+	}); err != nil {
+		return nil, fmt.Errorf("failed to write FAT32: %w", err)
+	}
+
+	if _, err := io.Copy(blockdev.NewRWS(i.systemPartitionA), i.SystemImage); err != nil {
+		return nil, fmt.Errorf("failed to write system partition A: %w", err)
+	}
+
+	if err := i.tbl.Write(); err != nil {
+		return nil, fmt.Errorf("failed to write Table: %w", err)
+	}
+
+	// Build an EFI boot entry pointing to the image's ESP.
+	return &efivarfs.LoadOption{
+		Description: "Metropolis",
+		FilePath: efivarfs.DevicePath{
+			&efivarfs.HardDrivePath{
+				PartitionNumber:     1,
+				PartitionStartBlock: i.efiPartition.FirstBlock,
+				PartitionSizeBlocks: i.efiPartition.SizeBlocks(),
+				PartitionMatch: efivarfs.PartitionGPT{
+					PartitionUUID: i.efiPartition.ID,
+				},
+			},
+			efivarfs.FilePath(EFIPayloadPath),
+		},
+	}, nil
+}
+
+// Plan allows to prepare an installation without modifying any data on the
+// system. To apply the planned installation, call Apply on the returned plan.
+func Plan(p *Params) (*plan, error) {
+	params := &plan{Params: p}
+
+	var err error
+	params.tbl, err = gpt.New(params.Output)
+	if err != nil {
+		return nil, fmt.Errorf("invalid block device: %w", err)
+	}
+
+	params.tbl.ID = params.DiskGUID
+	params.efiPartition = &gpt.Partition{
+		Type: gpt.PartitionTypeEFISystem,
+		Name: ESPLabel,
+	}
+
+	if err := params.tbl.AddPartition(params.efiPartition, params.PartitionSize.ESP*Mi); err != nil {
+		return nil, fmt.Errorf("failed to allocate ESP: %w", err)
+	}
+
+	params.rootInode = fat32.Inode{
+		Attrs: fat32.AttrDirectory,
+	}
+	if err := params.rootInode.PlaceFile(strings.TrimPrefix(EFIBootAPath, "/"), params.EFIPayload); err != nil {
+		return nil, err
+	}
+	// Place the A/B loader at the EFI bootloader autodiscovery path.
+	if err := params.rootInode.PlaceFile(strings.TrimPrefix(EFIPayloadPath, "/"), params.ABLoader); err != nil {
+		return nil, err
+	}
+	if params.NodeParameters != nil {
+		if err := params.rootInode.PlaceFile(nodeParamsPath, params.NodeParameters); err != nil {
+			return nil, err
+		}
+	}
+
+	// Try to layout the fat32 partition. If it detects that the disk is too
+	// small, an error will be returned.
+	if _, err := fat32.SizeFS(params.rootInode, fat32.Options{
+		BlockSize:  uint16(params.efiPartition.BlockSize()),
+		BlockCount: uint32(params.efiPartition.BlockCount()),
+		Label:      "MNGN_BOOT",
+	}); err != nil {
+		return nil, fmt.Errorf("failed to calculate size of FAT32: %w", err)
+	}
+
+	// Create the system partition only if its size is specified.
+	if params.PartitionSize.System != 0 && params.SystemImage != nil {
+		params.systemPartitionA = &gpt.Partition{
+			Type: SystemAType,
+			Name: SystemALabel,
+		}
+		if err := params.tbl.AddPartition(params.systemPartitionA, params.PartitionSize.System*Mi); err != nil {
+			return nil, fmt.Errorf("failed to allocate system partition A: %w", err)
+		}
+		params.systemPartitionB = &gpt.Partition{
+			Type: SystemBType,
+			Name: SystemBLabel,
+		}
+		if err := params.tbl.AddPartition(params.systemPartitionB, params.PartitionSize.System*Mi); err != nil {
+			return nil, fmt.Errorf("failed to allocate system partition B: %w", err)
+		}
+	} else if params.PartitionSize.System == 0 && params.SystemImage != nil {
+		// Safeguard against contradicting parameters.
+		return nil, fmt.Errorf("the system image parameter was passed while the associated partition size is zero")
+	}
+	// Create the data partition only if its size is specified.
+	if params.PartitionSize.Data != 0 {
+		params.dataPartition = &gpt.Partition{
+			Type: DataType,
+			Name: DataLabel,
+		}
+		if err := params.tbl.AddPartition(params.dataPartition, -1); err != nil {
+			return nil, fmt.Errorf("failed to allocate data partition: %w", err)
+		}
+	}
+
+	return params, nil
+}
+
+const Mi = 1024 * 1024
+
+// Write writes a Metropolis OS image to a block device.
+func Write(params *Params) (*efivarfs.LoadOption, error) {
+	p, err := Plan(params)
+	if err != nil {
+		return nil, err
+	}
+
+	return p.Apply()
+}
diff --git a/osbase/build/mkpayload/BUILD.bazel b/osbase/build/mkpayload/BUILD.bazel
new file mode 100644
index 0000000..7e49388
--- /dev/null
+++ b/osbase/build/mkpayload/BUILD.bazel
@@ -0,0 +1,14 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_binary(
+    name = "mkpayload",
+    embed = [":mkpayload_lib"],
+    visibility = ["//visibility:public"],
+)
+
+go_library(
+    name = "mkpayload_lib",
+    srcs = ["mkpayload.go"],
+    importpath = "source.monogon.dev/osbase/build/mkpayload",
+    visibility = ["//visibility:private"],
+)
diff --git a/osbase/build/mkpayload/mkpayload.go b/osbase/build/mkpayload/mkpayload.go
new file mode 100644
index 0000000..a66d458
--- /dev/null
+++ b/osbase/build/mkpayload/mkpayload.go
@@ -0,0 +1,188 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// mkpayload is an objcopy wrapper that builds EFI unified kernel images. It
+// performs actions that can't be realized by either objcopy or the
+// buildsystem.
+package main
+
+import (
+	"errors"
+	"flag"
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"os/exec"
+	"strings"
+)
+
+type stringList []string
+
+func (l *stringList) String() string {
+	if l == nil {
+		return ""
+	}
+	return strings.Join(*l, ", ")
+}
+
+func (l *stringList) Set(value string) error {
+	*l = append(*l, value)
+	return nil
+}
+
+var (
+	// sections contains VMAs and source files of the payload PE sections. The
+	// file path pointers will be filled in when the flags are parsed. It's used
+	// to generate objcopy command line arguments. Entries that are "required"
+	// will cause the program to stop and print usage information if not provided
+	// as command line parameters.
+	sections = map[string]struct {
+		descr    string
+		vma      string
+		required bool
+		file     *string
+	}{
+		"linux":   {"Linux kernel image", "0x2000000", true, nil},
+		"initrd":  {"initramfs", "0x5000000", false, nil},
+		"osrel":   {"OS release file in text format", "0x20000", false, nil},
+		"cmdline": {"a file containting additional kernel command line parameters", "0x30000", false, nil},
+		"splash":  {"a splash screen image in BMP format", "0x40000", false, nil},
+	}
+	initrdList      stringList
+	objcopy         = flag.String("objcopy", "", "objcopy executable")
+	stub            = flag.String("stub", "", "the EFI stub executable")
+	output          = flag.String("output", "", "objcopy output")
+	rootfs_dm_table = flag.String("rootfs_dm_table", "", "a text file containing the DeviceMapper rootfs target table")
+)
+
+func main() {
+	flag.Var(&initrdList, "initrd", "Path to initramfs, can be given multiple times")
+	// Register parameters related to the EFI payload sections, then parse the flags.
+	for k, v := range sections {
+		if k == "initrd" { // initrd is special because it accepts multiple payloads
+			continue
+		}
+		v.file = flag.String(k, "", v.descr)
+		sections[k] = v
+	}
+	flag.Parse()
+
+	// Ensure all the required parameters are filled in.
+	for n, s := range sections {
+		if s.required && *s.file == "" {
+			log.Fatalf("-%s parameter is missing.", n)
+		}
+	}
+	if *objcopy == "" {
+		log.Fatalf("-objcopy parameter is missing.")
+	}
+	if *stub == "" {
+		log.Fatalf("-stub parameter is missing.")
+	}
+	if *output == "" {
+		log.Fatalf("-output parameter is missing.")
+	}
+
+	// If a DeviceMapper table was passed, configure the kernel to boot from the
+	// device described by it, while keeping any other kernel command line
+	// parameters that might have been passed through "-cmdline".
+	if *rootfs_dm_table != "" {
+		var cmdline string
+		p := *sections["cmdline"].file
+		if p != "" {
+			c, err := os.ReadFile(p)
+			if err != nil {
+				log.Fatalf("%v", err)
+			}
+			cmdline = string(c[:])
+
+			if strings.Contains(cmdline, "root=") {
+				log.Fatalf("A DeviceMapper table was passed, however the kernel command line already contains a \"root=\" statement.")
+			}
+		}
+
+		vt, err := os.ReadFile(*rootfs_dm_table)
+		if err != nil {
+			log.Fatalf("%v", err)
+		}
+		cmdline += fmt.Sprintf(" dm-mod.create=\"rootfs,,,ro,%s\" root=/dev/dm-0", vt)
+
+		out, err := os.CreateTemp(".", "cmdline")
+		if err != nil {
+			log.Fatalf("%v", err)
+		}
+		defer os.Remove(out.Name())
+		if _, err = out.Write([]byte(cmdline[:])); err != nil {
+			log.Fatalf("%v", err)
+		}
+		out.Close()
+
+		*sections["cmdline"].file = out.Name()
+	}
+
+	var initrdPath string
+	if len(initrdList) > 0 {
+		initrd, err := os.CreateTemp(".", "initrd")
+		if err != nil {
+			log.Fatalf("Failed to create temporary initrd: %v", err)
+		}
+		defer os.Remove(initrd.Name())
+		for _, initrdPath := range initrdList {
+			initrdSrc, err := os.Open(initrdPath)
+			if err != nil {
+				log.Fatalf("Failed to open initrd file: %v", err)
+			}
+			if _, err := io.Copy(initrd, initrdSrc); err != nil {
+				initrdSrc.Close()
+				log.Fatalf("Failed concatinating initrd: %v", err)
+			}
+			initrdSrc.Close()
+		}
+		initrdPath = initrd.Name()
+	}
+	sec := sections["initrd"]
+	sec.file = &initrdPath
+	sections["initrd"] = sec
+
+	// Execute objcopy
+	var args []string
+	for name, c := range sections {
+		if *c.file != "" {
+			args = append(args, []string{
+				"--add-section", fmt.Sprintf(".%s=%s", name, *c.file),
+				"--change-section-vma", fmt.Sprintf(".%s=%s", name, c.vma),
+			}...)
+		}
+	}
+	args = append(args, []string{
+		*stub,
+		*output,
+	}...)
+	cmd := exec.Command(*objcopy, args...)
+	cmd.Stderr = os.Stderr
+	cmd.Stdout = os.Stdout
+	err := cmd.Run()
+	if err == nil {
+		return
+	}
+	// Exit with objcopy's return code.
+	var e *exec.ExitError
+	if errors.As(err, &e) {
+		os.Exit(e.ExitCode())
+	}
+	log.Fatalf("Could not start command: %v", err)
+}
diff --git a/osbase/build/mkucode/BUILD.bazel b/osbase/build/mkucode/BUILD.bazel
new file mode 100644
index 0000000..9f56e10
--- /dev/null
+++ b/osbase/build/mkucode/BUILD.bazel
@@ -0,0 +1,19 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "mkucode_lib",
+    srcs = ["main.go"],
+    importpath = "source.monogon.dev/osbase/build/mkucode",
+    visibility = ["//visibility:private"],
+    deps = [
+        "//osbase/build/mkucode/spec",
+        "@com_github_cavaliergopher_cpio//:cpio",
+        "@org_golang_google_protobuf//encoding/prototext",
+    ],
+)
+
+go_binary(
+    name = "mkucode",
+    embed = [":mkucode_lib"],
+    visibility = ["//visibility:public"],
+)
diff --git a/osbase/build/mkucode/def.bzl b/osbase/build/mkucode/def.bzl
new file mode 100644
index 0000000..04d83a2
--- /dev/null
+++ b/osbase/build/mkucode/def.bzl
@@ -0,0 +1,45 @@
+def _cpio_ucode_impl(ctx):
+    ucode_spec = ctx.actions.declare_file(ctx.label.name + "_spec.prototxt")
+
+    vendors = []
+    inputs = []
+    for label, vendor in ctx.attr.ucode.items():
+        files = label[DefaultInfo].files.to_list()
+        inputs += files
+        vendors.append(struct(id = vendor, file = [f.path for f in files]))
+
+    ctx.actions.write(ucode_spec, proto.encode_text(struct(vendor = vendors)))
+
+    output_file = ctx.actions.declare_file(ctx.label.name + ".cpio")
+    ctx.actions.run(
+        outputs = [output_file],
+        inputs = [ucode_spec] + inputs,
+        tools = [ctx.executable._mkucode],
+        executable = ctx.executable._mkucode,
+        arguments = ["-out", output_file.path, "-spec", ucode_spec.path],
+    )
+    return [DefaultInfo(files = depset([output_file]))]
+
+cpio_ucode = rule(
+    implementation = _cpio_ucode_impl,
+    doc = """
+        Builds a cpio archive with microcode for the Linux early microcode loader.
+    """,
+    attrs = {
+        "ucode": attr.label_keyed_string_dict(
+            mandatory = True,
+            allow_files = True,
+            doc = """
+                Dictionary of Labels to String. Each label is a list of microcode files and the string label
+                is the vendor ID corresponding to that microcode.
+            """,
+        ),
+
+        # Tool
+        "_mkucode": attr.label(
+            default = Label("//osbase/build/mkucode"),
+            executable = True,
+            cfg = "exec",
+        ),
+    },
+)
diff --git a/osbase/build/mkucode/main.go b/osbase/build/mkucode/main.go
new file mode 100644
index 0000000..58561e3
--- /dev/null
+++ b/osbase/build/mkucode/main.go
@@ -0,0 +1,71 @@
+// This assembles standalone microcode files into the format expected by the
+// Linux microcode loader. See
+// https://www.kernel.org/doc/html/latest/x86/microcode.html for further
+// information.
+package main
+
+import (
+	"flag"
+	"io"
+	"log"
+	"os"
+
+	"github.com/cavaliergopher/cpio"
+	"google.golang.org/protobuf/encoding/prototext"
+
+	"source.monogon.dev/osbase/build/mkucode/spec"
+)
+
+var (
+	specPath = flag.String("spec", "", "Path to prototext specification (osbase.build.mkucode.UCode)")
+	outPath  = flag.String("out", "", "Output path for cpio to be prepend to initrd")
+)
+
+// Usage: -spec <ucode.prototxt> -out <ucode.cpio>
+func main() {
+	flag.Parse()
+	specRaw, err := os.ReadFile(*specPath)
+	if err != nil {
+		log.Fatalf("Failed to read spec: %v", err)
+	}
+	var ucodeSpec spec.UCode
+	if err := prototext.Unmarshal(specRaw, &ucodeSpec); err != nil {
+		log.Fatalf("Failed unmarshaling ucode spec: %v", err)
+	}
+	out, err := os.Create(*outPath)
+	if err != nil {
+		log.Fatalf("Failed to create cpio: %v", err)
+	}
+	defer out.Close()
+	cpioWriter := cpio.NewWriter(out)
+	for _, vendor := range ucodeSpec.Vendor {
+		var totalSize int64
+		for _, file := range vendor.File {
+			data, err := os.Stat(file)
+			if err != nil {
+				log.Fatalf("Failed to stat file for vendor %q: %v", vendor.Id, err)
+			}
+			totalSize += data.Size()
+		}
+		if err := cpioWriter.WriteHeader(&cpio.Header{
+			Mode: 0444,
+			Name: "kernel/x86/microcode/" + vendor.Id + ".bin",
+			Size: totalSize,
+		}); err != nil {
+			log.Fatalf("Failed to write cpio header for vendor %q: %v", vendor.Id, err)
+		}
+		for _, file := range vendor.File {
+			f, err := os.Open(file)
+			if err != nil {
+				log.Fatalf("Failed to open file for vendor %q: %v", vendor.Id, err)
+			}
+			if _, err := io.Copy(cpioWriter, f); err != nil {
+				log.Fatalf("Failed to copy data for file %q: %v", file, err)
+			}
+			f.Close()
+		}
+	}
+	if err := cpioWriter.Close(); err != nil {
+		log.Fatalf("Failed writing cpio: %v", err)
+	}
+}
diff --git a/osbase/build/mkucode/spec/BUILD.bazel b/osbase/build/mkucode/spec/BUILD.bazel
new file mode 100644
index 0000000..0210a4b
--- /dev/null
+++ b/osbase/build/mkucode/spec/BUILD.bazel
@@ -0,0 +1,30 @@
+load("@rules_proto//proto:defs.bzl", "proto_library")
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
+
+proto_library(
+    name = "mkucode_proto",
+    srcs = ["spec.proto"],
+    visibility = ["//visibility:public"],
+)
+
+go_proto_library(
+    name = "mkucode_go_proto",
+    importpath = "source.monogon.dev/osbase/build/mkucode",
+    proto = ":mkucode_proto",
+    visibility = ["//visibility:public"],
+)
+
+go_library(
+    name = "spec",
+    embed = [":spec_go_proto"],
+    importpath = "source.monogon.dev/osbase/build/mkucode/spec",
+    visibility = ["//visibility:public"],
+)
+
+go_proto_library(
+    name = "spec_go_proto",
+    importpath = "source.monogon.dev/osbase/build/mkucode/spec",
+    proto = ":mkucode_proto",
+    visibility = ["//visibility:public"],
+)
diff --git a/osbase/build/mkucode/spec/gomod-generated-placeholder.go b/osbase/build/mkucode/spec/gomod-generated-placeholder.go
new file mode 100644
index 0000000..f09cd57
--- /dev/null
+++ b/osbase/build/mkucode/spec/gomod-generated-placeholder.go
@@ -0,0 +1 @@
+package spec
diff --git a/osbase/build/mkucode/spec/spec.proto b/osbase/build/mkucode/spec/spec.proto
new file mode 100644
index 0000000..36fcc4c
--- /dev/null
+++ b/osbase/build/mkucode/spec/spec.proto
@@ -0,0 +1,17 @@
+syntax = "proto3";
+
+package osbase.build.mkucode;
+option go_package = "source.monogon.dev/osbase/build/mkucode/spec";
+
+message UCode {
+  repeated UCodeVendor vendor = 1;
+}
+
+message UCodeVendor {
+  // The vendor id (as given in cpuid) of the CPU the microcode is for, like
+  // GenuineIntel or AuthenticAMD.
+  string id = 1;
+
+  // List of paths to microcode files from for CPUs from the vendor.
+  repeated string file = 2;
+}
\ No newline at end of file
diff --git a/osbase/build/mkverity/BUILD.bazel b/osbase/build/mkverity/BUILD.bazel
new file mode 100644
index 0000000..04a8a54
--- /dev/null
+++ b/osbase/build/mkverity/BUILD.bazel
@@ -0,0 +1,19 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_binary(
+    name = "mkverity",
+    embed = [":mkverity_lib"],
+    visibility = [
+        "//metropolis/installer/test/testos:__pkg__",
+        "//metropolis/node:__pkg__",
+        "//metropolis/node/core/update/e2e/testos:__pkg__",
+    ],
+)
+
+go_library(
+    name = "mkverity_lib",
+    srcs = ["mkverity.go"],
+    importpath = "source.monogon.dev/osbase/build/mkverity",
+    visibility = ["//visibility:private"],
+    deps = ["//osbase/verity"],
+)
diff --git a/osbase/build/mkverity/mkverity.go b/osbase/build/mkverity/mkverity.go
new file mode 100644
index 0000000..ff2807b
--- /dev/null
+++ b/osbase/build/mkverity/mkverity.go
@@ -0,0 +1,152 @@
+// Copyright 2020 The Monogon Project Authors.
+//
+// SPDX-License-Identifier: Apache-2.0
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// This package implements a command line tool that creates dm-verity hash
+// images at a selected path, given an existing data image. The tool
+// outputs a Verity mapping table on success.
+//
+// For more information, see:
+// - source.monogon.dev/osbase/verity
+// - https://gitlab.com/cryptsetup/cryptsetup/wikis/DMVerity
+package main
+
+import (
+	"flag"
+	"fmt"
+	"io"
+	"log"
+	"os"
+
+	"source.monogon.dev/osbase/verity"
+)
+
+// createImage creates a dm-verity target image by combining the input image
+// with Verity metadata. Contents of the data image are copied to the output
+// image. Then, the same contents are verity-encoded and appended to the
+// output image. The verity superblock is written only if wsb is true. It
+// returns either a dm-verity target table, or an error.
+func createImage(dataImagePath, outputImagePath string, wsb bool) (*verity.MappingTable, error) {
+	// Hardcode both the data block size and the hash block size as 4096 bytes.
+	bs := uint32(4096)
+
+	// Open the data image for reading.
+	dataImage, err := os.Open(dataImagePath)
+	if err != nil {
+		return nil, fmt.Errorf("while opening the data image: %w", err)
+	}
+	defer dataImage.Close()
+
+	// Check that the data image is well-formed.
+	ds, err := dataImage.Stat()
+	if err != nil {
+		return nil, fmt.Errorf("while stat-ing the data image: %w", err)
+	}
+	if !ds.Mode().IsRegular() {
+		return nil, fmt.Errorf("the data image must be a regular file")
+	}
+	if ds.Size()%int64(bs) != 0 {
+		return nil, fmt.Errorf("the data image must end on a %d-byte block boundary", bs)
+	}
+
+	// Create an empty hash image file.
+	outputImage, err := os.OpenFile(outputImagePath, os.O_RDWR|os.O_CREATE|os.O_EXCL, 0644)
+	if err != nil {
+		return nil, fmt.Errorf("while opening the hash image for writing: %w", err)
+	}
+	defer outputImage.Close()
+
+	// Copy the input data into the output file, then rewind dataImage to be read
+	// again by the Verity encoder.
+	_, err = io.Copy(outputImage, dataImage)
+	if err != nil {
+		return nil, err
+	}
+	_, err = dataImage.Seek(0, os.SEEK_SET)
+	if err != nil {
+		return nil, err
+	}
+
+	// Write outputImage contents. Start with initializing a verity encoder,
+	// seting outputImage as its output.
+	v, err := verity.NewEncoder(outputImage, bs, bs, wsb)
+	if err != nil {
+		return nil, fmt.Errorf("while initializing a verity encoder: %w", err)
+	}
+	// Hash the contents of dataImage, block by block.
+	_, err = io.Copy(v, dataImage)
+	if err != nil {
+		return nil, fmt.Errorf("while reading the data image: %w", err)
+	}
+	// The resulting hash tree won't be written until Close is called.
+	err = v.Close()
+	if err != nil {
+		return nil, fmt.Errorf("while writing the hash image: %w", err)
+	}
+
+	// Return an encoder-generated verity mapping table, containing the salt and
+	// the root hash. First, calculate the starting hash block by dividing the
+	// data image size by the encoder data block size.
+	hashStart := ds.Size() / int64(bs)
+	mt, err := v.MappingTable(dataImagePath, outputImagePath, hashStart)
+	if err != nil {
+		return nil, fmt.Errorf("while querying for the mapping table: %w", err)
+	}
+	return mt, nil
+}
+
+var (
+	input           = flag.String("input", "", "input disk image (required)")
+	output          = flag.String("output", "", "output disk image with Verity metadata appended (required)")
+	dataDeviceAlias = flag.String("data_alias", "", "data device alias used in the mapping table")
+	hashDeviceAlias = flag.String("hash_alias", "", "hash device alias used in the mapping table")
+	table           = flag.String("table", "", "a file the mapping table will be saved to; disables stdout")
+)
+
+func main() {
+	flag.Parse()
+
+	// Ensure that required parameters were provided before continuing.
+	if *input == "" {
+		log.Fatalf("-input must be set.")
+	}
+	if *output == "" {
+		log.Fatalf("-output must be set.")
+	}
+
+	// Build the image.
+	mt, err := createImage(*input, *output, false)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	// Patch the device names, if alternatives were provided.
+	if *dataDeviceAlias != "" {
+		mt.DataDevicePath = *dataDeviceAlias
+	}
+	if *hashDeviceAlias != "" {
+		mt.HashDevicePath = *hashDeviceAlias
+	}
+
+	// Print a DeviceMapper target table, or save it to a file, if the table
+	// parameter was specified.
+	if *table != "" {
+		if err := os.WriteFile(*table, []byte(mt.String()), 0644); err != nil {
+			log.Fatal(err)
+		}
+	} else {
+		fmt.Println(mt)
+	}
+}
diff --git a/osbase/kmod/BUILD.bazel b/osbase/kmod/BUILD.bazel
index a8ade05..4bf0fb5 100644
--- a/osbase/kmod/BUILD.bazel
+++ b/osbase/kmod/BUILD.bazel
@@ -1,5 +1,5 @@
 load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
-load("//metropolis/node/build/fwprune:def.bzl", "fsspec_linux_firmware")
+load("//osbase/build/fwprune:def.bzl", "fsspec_linux_firmware")
 load("//osbase/test/ktest:ktest.bzl", "ktest")
 
 go_library(
diff --git a/osbase/logtree/unraw/unraw.go b/osbase/logtree/unraw/unraw.go
index 5c4e8e9..a1f2624 100644
--- a/osbase/logtree/unraw/unraw.go
+++ b/osbase/logtree/unraw/unraw.go
@@ -1,5 +1,5 @@
-// unraw implements a facility to convert raw logs from external sources into
-// leveled logs.
+// Package unraw implements a facility to convert raw logs from external sources
+// into leveled logs.
 //
 // This is not the same as raw logging inside the logtree, which exists to
 // ingest logs that are either fully arbitrary or do not map cleanly to the
diff --git a/osbase/socksproxy/socksproxy.go b/osbase/socksproxy/socksproxy.go
index 808ae1f..2dd0c35 100644
--- a/osbase/socksproxy/socksproxy.go
+++ b/osbase/socksproxy/socksproxy.go
@@ -1,4 +1,4 @@
-// package socksproxy implements a limited subset of the SOCKS 5 (RFC1928)
+// Package socksproxy implements a limited subset of the SOCKS 5 (RFC1928)
 // protocol in the form of a pluggable Proxy object. However, this
 // implementation is _not_ RFC1928 compliant, as it does not implement GSSAPI
 // (which is mandated by the spec). It currently only implements CONNECT
diff --git a/osbase/test/ktest/BUILD.bazel b/osbase/test/ktest/BUILD.bazel
index 16612e5..733d695 100644
--- a/osbase/test/ktest/BUILD.bazel
+++ b/osbase/test/ktest/BUILD.bazel
@@ -1,5 +1,5 @@
 load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
-load("//metropolis/node/build/kconfig-patcher:kconfig-patcher.bzl", "kconfig_patch")
+load("//osbase/build/kconfig-patcher:kconfig-patcher.bzl", "kconfig_patch")
 load("//third_party/linux:def.bzl", "linux_image")
 
 go_library(
diff --git a/osbase/test/ktest/ktest.bzl b/osbase/test/ktest/ktest.bzl
index a9f5fdd..614832d 100644
--- a/osbase/test/ktest/ktest.bzl
+++ b/osbase/test/ktest/ktest.bzl
@@ -18,7 +18,7 @@
 Ktest provides a macro to run tests under a normal Metropolis node kernel
 """
 
-load("//metropolis/node/build:def.bzl", "node_initramfs")
+load("//osbase/build:def.bzl", "node_initramfs")
 
 def _dict_union(x, y):
     z = {}
@@ -30,7 +30,7 @@
     node_initramfs(
         name = "test_initramfs",
         fsspecs = [
-            "//metropolis/node/build:earlydev.fsspec",
+            "//osbase/build:earlydev.fsspec",
         ] + fsspecs,
         files = _dict_union({
             "//osbase/test/ktest/init": "/init",
diff --git a/osbase/tpm/proto/tpm.proto b/osbase/tpm/proto/tpm.proto
index e5455d2..fc70223 100644
--- a/osbase/tpm/proto/tpm.proto
+++ b/osbase/tpm/proto/tpm.proto
@@ -1,6 +1,6 @@
 syntax = "proto3";
 option go_package = "source.monogon.dev/osbase/tpm/proto";
-package metropolis.pkg.tpm;
+package osbase.pkg.tpm;
 
 import "proto/tpm/tpm.proto";