core -> metropolis

Smalltown is now called Metropolis!

This is the first commit in a series of cleanup commits that prepare us
for an open source release. This one just some Bazel packages around to
follow a stricter directory layout.

All of Metropolis now lives in `//metropolis`.

All of Metropolis Node code now lives in `//metropolis/node`.

All of the main /init now lives in `//m/n/core`.

All of the Kubernetes functionality/glue now lives in `//m/n/kubernetes`.

Next steps:
     - hunt down all references to Smalltown and replace them appropriately
     - narrow down visibility rules
     - document new code organization
     - move `//build/toolchain` to `//monogon/build/toolchain`
     - do another cleanup pass between `//golibs` and
       `//monogon/node/{core,common}`.
     - remove `//delta` and `//anubis`

Fixes T799.

Test Plan: Just a very large refactor. CI should help us out here.

Bug: T799

X-Origin-Diff: phab/D667
GitOrigin-RevId: 6029b8d4edc42325d50042596b639e8b122d0ded
diff --git a/metropolis/node/build/BUILD b/metropolis/node/build/BUILD
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/metropolis/node/build/BUILD
diff --git a/metropolis/node/build/def.bzl b/metropolis/node/build/def.bzl
new file mode 100644
index 0000000..e2885e5
--- /dev/null
+++ b/metropolis/node/build/def.bzl
@@ -0,0 +1,257 @@
+#  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 _build_pure_transition_impl(settings, attr):
+    """
+    Transition that enables pure, static build of Go binaries.
+    """
+    return {
+        "@io_bazel_rules_go//go/config:pure": True,
+        "@io_bazel_rules_go//go/config:static": True,
+    }
+
+build_pure_transition = transition(
+    implementation = _build_pure_transition_impl,
+    inputs = [],
+    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:crosstool_top": "//build/toolchain/musl-host-gcc:musl_host_cc_suite",
+    }
+
+build_static_transition = transition(
+    implementation = _build_static_transition_impl,
+    inputs = [],
+    outputs = [
+        "@io_bazel_rules_go//go/config:static",
+        "//command_line_option:crosstool_top",
+    ],
+)
+
+def _smalltown_initramfs_impl(ctx):
+    """
+    Generate an lz4-compressed initramfs based on a label/file list.
+    """
+
+    # Generate config file for gen_init_cpio that describes the initramfs to build.
+    cpio_list_name = ctx.label.name + ".cpio_list"
+    cpio_list = ctx.actions.declare_file(cpio_list_name)
+
+    # Start out with some standard initramfs device files.
+    cpio_list_content = [
+        "dir /dev 0755 0 0",
+        "nod /dev/console 0600 0 0 c 5 1",
+        "nod /dev/null 0644 0 0 c 1 3",
+        "nod /dev/kmsg 0644 0 0 c 1 11",
+        "nod /dev/ptmx 0644 0 0 c 5 2",
+    ]
+
+    # Find all directories that need to be created.
+    directories_needed = []
+    for _, p in ctx.attr.files.items():
+        if not p.startswith("/"):
+            fail("file {} invalid: must begin with /".format(p))
+
+        # Get all intermediate directories on path to file
+        parts = p.split("/")[1:-1]
+        directories_needed.append(parts)
+
+    for _, p in ctx.attr.files_cc.items():
+        if not p.startswith("/"):
+            fail("file {} invalid: must begin with /".format(p))
+
+        # Get all intermediate directories on path to file
+        parts = p.split("/")[1:-1]
+        directories_needed.append(parts)
+
+    # Extend with extra directories defined by user.
+    for p in ctx.attr.extra_dirs:
+        if not p.startswith("/"):
+            fail("directory {} invalid: must begin with /".format(p))
+
+        parts = p.split("/")[1:]
+        directories_needed.append(parts)
+
+    directories = []
+    for parts in directories_needed:
+        # Turn directory parts [usr, local, bin] into successive subpaths [/usr, /usr/local, /usr/local/bin].
+        last = ""
+        for part in parts:
+            last += "/" + part
+
+            # TODO(q3k): this is slow - this should be a set instead, but starlark doesn't implement them.
+            # For the amount of files we're dealing with this doesn't matter, but all stars are pointing towards this
+            # becoming accidentally quadratic at some point in the future.
+            if last not in directories:
+                directories.append(last)
+
+    # Append instructions to create directories.
+    # Serendipitously, the directories should already be in the right order due to us not using a set to create the
+    # list. They might not be in an elegant order (ie, if files [/foo/one/one, /bar, /foo/two/two] are request, the
+    # order will be [/foo, /foo/one, /bar, /foo/two]), but that's fine.
+    for d in directories:
+        cpio_list_content.append("dir {} 0755 0 0".format(d))
+
+    # Append instructions to add files.
+    inputs = []
+    for label, p in ctx.attr.files.items():
+        # 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 = "0755" if is_executable else "0444"
+
+        cpio_list_content.append("file {} {} {} 0 0".format(p, src.path, mode))
+
+    for label, p in ctx.attr.files_cc.items():
+        # 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 = "0755" if is_executable else "0444"
+
+        cpio_list_content.append("file {} {} {} 0 0".format(p, src.path, mode))
+
+    # Write cpio_list.
+    ctx.actions.write(cpio_list, "\n".join(cpio_list_content))
+
+    gen_init_cpio = ctx.executable._gen_init_cpio
+    savestdout = ctx.executable._savestdout
+    lz4 = ctx.executable._lz4
+
+    # Generate 'raw' (uncompressed) initramfs
+    initramfs_raw_name = ctx.label.name
+    initramfs_raw = ctx.actions.declare_file(initramfs_raw_name)
+    ctx.actions.run(
+        outputs = [initramfs_raw],
+        inputs = [cpio_list] + inputs,
+        tools = [savestdout, gen_init_cpio],
+        executable = savestdout,
+        arguments = [initramfs_raw.path, gen_init_cpio.path, cpio_list.path],
+    )
+
+    # Compress raw initramfs using lz4c.
+    initramfs_name = ctx.label.name + ".lz4"
+    initramfs = ctx.actions.declare_file(initramfs_name)
+    ctx.actions.run(
+        outputs = [initramfs],
+        inputs = [initramfs_raw],
+        tools = [savestdout, lz4],
+        executable = lz4.path,
+        arguments = ["-l", initramfs_raw.path, initramfs.path],
+    )
+
+    return [DefaultInfo(files = depset([initramfs]))]
+
+smalltown_initramfs = rule(
+    implementation = _smalltown_initramfs_impl,
+    doc = """
+        Build a Smalltown 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,
+        ),
+        "extra_dirs": attr.string_list(
+            default = [],
+            doc = """
+                Extra directories to create. These will be created in addition to all the directories required to
+                contain the files specified in the `files` attribute.
+            """,
+        ),
+
+        # Tools, implicit dependencies.
+        "_gen_init_cpio": attr.label(
+            default = Label("@linux//:gen_init_cpio"),
+            executable = True,
+            cfg = "host",
+        ),
+        "_lz4": attr.label(
+            default = Label("@com_github_lz4_lz4//programs:lz4"),
+            executable = True,
+            cfg = "host",
+        ),
+        "_savestdout": attr.label(
+            default = Label("//build/savestdout"),
+            executable = True,
+            cfg = "host",
+        ),
+
+        # Allow for transitions to be attached to this rule.
+        "_whitelist_function_transition": attr.label(
+            default = "@bazel_tools//tools/whitelists/function_transition_whitelist",
+        ),
+    },
+)
diff --git a/metropolis/node/build/genosrelease/BUILD.bazel b/metropolis/node/build/genosrelease/BUILD.bazel
new file mode 100644
index 0000000..9403d72
--- /dev/null
+++ b/metropolis/node/build/genosrelease/BUILD.bazel
@@ -0,0 +1,15 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["main.go"],
+    importpath = "git.monogon.dev/source/nexantic.git/metropolis/node/build/genosrelease",
+    visibility = ["//visibility:private"],
+    deps = ["@com_github_joho_godotenv//:go_default_library"],
+)
+
+go_binary(
+    name = "genosrelease",
+    embed = [":go_default_library"],
+    visibility = ["//visibility:public"],
+)
diff --git a/metropolis/node/build/genosrelease/defs.bzl b/metropolis/node/build/genosrelease/defs.bzl
new file mode 100644
index 0000000..61ce9e4
--- /dev/null
+++ b/metropolis/node/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("//metropolis/node/build/genosrelease"),
+            cfg = "host",
+            executable = True,
+            allow_files = True,
+        ),
+    },
+    outputs = {
+        "out": "os-release",
+    },
+)
diff --git a/metropolis/node/build/genosrelease/main.go b/metropolis/node/build/genosrelease/main.go
new file mode 100644
index 0000000..2344f19
--- /dev/null
+++ b/metropolis/node/build/genosrelease/main.go
@@ -0,0 +1,78 @@
+// 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
+// (https://www.freedesktop.org/software/systemd/man/os-release.html) from arguments and stamping
+package main
+
+import (
+	"flag"
+	"fmt"
+	"io/ioutil"
+	"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 := ioutil.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]
+	}
+
+	smalltownVersion, 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":     smalltownVersion,
+		"VERSION_ID":  smalltownVersion,
+		"PRETTY_NAME": *flagName + " " + smalltownVersion,
+	}
+	osReleaseContent, err := godotenv.Marshal(osReleaseVars)
+	if err != nil {
+		fmt.Printf("Failed to encode os-release file: %v\n", err)
+		os.Exit(1)
+	}
+	if err := ioutil.WriteFile(*flagOutFile, []byte(osReleaseContent), 0644); err != nil {
+		fmt.Printf("Failed to write os-release file: %v\n", err)
+		os.Exit(1)
+	}
+}
diff --git a/metropolis/node/build/kconfig-patcher/BUILD.bazel b/metropolis/node/build/kconfig-patcher/BUILD.bazel
new file mode 100644
index 0000000..55b2b52
--- /dev/null
+++ b/metropolis/node/build/kconfig-patcher/BUILD.bazel
@@ -0,0 +1,20 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library", "go_test")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["main.go"],
+    importpath = "git.monogon.dev/source/nexantic.git/metropolis/node/build/kconfig-patcher",
+    visibility = ["//visibility:private"],
+)
+
+go_binary(
+    name = "kconfig-patcher",
+    embed = [":go_default_library"],
+    visibility = ["//visibility:public"],
+)
+
+go_test(
+    name = "go_default_test",
+    srcs = ["main_test.go"],
+    embed = [":go_default_library"],
+)
diff --git a/metropolis/node/build/kconfig-patcher/kconfig-patcher.bzl b/metropolis/node/build/kconfig-patcher/kconfig-patcher.bzl
new file mode 100644
index 0000000..337642e
--- /dev/null
+++ b/metropolis/node/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 = [
+            "//metropolis/node/build/kconfig-patcher",
+        ],
+        cmd = """
+        $(location //metropolis/node/build/kconfig-patcher) \
+            -in $< -out $@ '%s'
+        """ % struct(overrides = override_configs).to_json(),
+        **kwargs
+    )
diff --git a/metropolis/node/build/kconfig-patcher/main.go b/metropolis/node/build/kconfig-patcher/main.go
new file mode 100644
index 0000000..27c33e9
--- /dev/null
+++ b/metropolis/node/build/kconfig-patcher/main.go
@@ -0,0 +1,95 @@
+// 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() {
+		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/metropolis/node/build/kconfig-patcher/main_test.go b/metropolis/node/build/kconfig-patcher/main_test.go
new file mode 100644
index 0000000..11c7d84
--- /dev/null
+++ b/metropolis/node/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/metropolis/node/build/mkimage/BUILD.bazel b/metropolis/node/build/mkimage/BUILD.bazel
new file mode 100644
index 0000000..b489002
--- /dev/null
+++ b/metropolis/node/build/mkimage/BUILD.bazel
@@ -0,0 +1,20 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "go_default_library",
+    srcs = ["main.go"],
+    importpath = "git.monogon.dev/source/nexantic.git/metropolis/node/build/mkimage",
+    visibility = ["//visibility:private"],
+    deps = [
+        "@com_github_diskfs_go_diskfs//:go_default_library",
+        "@com_github_diskfs_go_diskfs//disk:go_default_library",
+        "@com_github_diskfs_go_diskfs//filesystem:go_default_library",
+        "@com_github_diskfs_go_diskfs//partition/gpt:go_default_library",
+    ],
+)
+
+go_binary(
+    name = "mkimage",
+    embed = [":go_default_library"],
+    visibility = ["//visibility:public"],
+)
diff --git a/metropolis/node/build/mkimage/main.go b/metropolis/node/build/mkimage/main.go
new file mode 100644
index 0000000..9f49f0a
--- /dev/null
+++ b/metropolis/node/build/mkimage/main.go
@@ -0,0 +1,141 @@
+// 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
+
+// mkimage is a tool to generate a Smalltown disk image containing the given EFI payload, and optionally, a given external
+// initramfs image and enrolment credentials.
+
+import (
+	"flag"
+	"fmt"
+	"io/ioutil"
+	"log"
+	"os"
+
+	diskfs "github.com/diskfs/go-diskfs"
+	"github.com/diskfs/go-diskfs/disk"
+	"github.com/diskfs/go-diskfs/filesystem"
+	"github.com/diskfs/go-diskfs/partition/gpt"
+)
+
+var SmalltownDataPartition gpt.Type = gpt.Type("9eeec464-6885-414a-b278-4305c51f7966")
+
+var (
+	flagEFI                  string
+	flagOut                  string
+	flagInitramfs            string
+	flagEnrolmentCredentials string
+	flagDataPartitionSize    uint64
+	flagESPPartitionSize     uint64
+)
+
+func mibToSectors(size uint64) uint64 {
+	return (size * 1024 * 1024) / 512
+}
+
+func main() {
+	flag.StringVar(&flagEFI, "efi", "", "UEFI payload")
+	flag.StringVar(&flagOut, "out", "", "Output disk image")
+	flag.StringVar(&flagInitramfs, "initramfs", "", "External initramfs [optional]")
+	flag.StringVar(&flagEnrolmentCredentials, "enrolment_credentials", "", "Enrolment credentials [optional]")
+	flag.Uint64Var(&flagDataPartitionSize, "data_partition_size", 2048, "Override the data partition size (default 2048 MiB)")
+	flag.Uint64Var(&flagESPPartitionSize, "esp_partition_size", 512, "Override the ESP partition size (default: 512MiB)")
+	flag.Parse()
+
+	if flagEFI == "" || flagOut == "" {
+		log.Fatalf("efi and initramfs must be set")
+	}
+
+	_ = os.Remove(flagOut)
+	diskImg, err := diskfs.Create(flagOut, 3*1024*1024*1024, diskfs.Raw)
+	if err != nil {
+		log.Fatalf("diskfs.Create(%q): %v", flagOut, err)
+	}
+
+	table := &gpt.Table{
+		// This is appropriate at least for virtio disks. Might need to be adjusted for real ones.
+		LogicalSectorSize:  512,
+		PhysicalSectorSize: 512,
+		ProtectiveMBR:      true,
+		Partitions: []*gpt.Partition{
+			{
+				Type:  gpt.EFISystemPartition,
+				Name:  "ESP",
+				Start: mibToSectors(1),
+				End:   mibToSectors(flagESPPartitionSize) - 1,
+			},
+			{
+				Type:  SmalltownDataPartition,
+				Name:  "SIGNOS-DATA",
+				Start: mibToSectors(flagESPPartitionSize),
+				End:   mibToSectors(flagESPPartitionSize+flagDataPartitionSize) - 1,
+			},
+		},
+	}
+	if err := diskImg.Partition(table); err != nil {
+		log.Fatalf("Failed to apply partition table: %v", err)
+	}
+
+	fs, err := diskImg.CreateFilesystem(disk.FilesystemSpec{Partition: 1, FSType: filesystem.TypeFat32, VolumeLabel: "ESP"})
+	if err != nil {
+		log.Fatalf("Failed to create filesystem: %v", err)
+	}
+
+	// Create EFI partition structure.
+	for _, dir := range []string{"/EFI", "/EFI/BOOT", "/EFI/smalltown"} {
+		if err := fs.Mkdir(dir); err != nil {
+			log.Fatalf("Mkdir(%q): %v", dir, err)
+		}
+	}
+
+	put(fs, flagEFI, "/EFI/BOOT/BOOTX64.EFI")
+
+	if flagInitramfs != "" {
+		put(fs, flagInitramfs, "/EFI/smalltown/initramfs.cpio.lz4")
+	}
+
+	if flagEnrolmentCredentials != "" {
+		put(fs, flagEnrolmentCredentials, "/EFI/smalltown/enrolment.pb")
+	}
+
+	if err := diskImg.File.Close(); err != nil {
+		log.Fatalf("Failed to finalize image: %v", err)
+	}
+	log.Printf("Success! You can now boot %v", flagOut)
+}
+
+// put copies a file from the host filesystem into the target image.
+func put(fs filesystem.FileSystem, src, dst string) {
+	target, err := fs.OpenFile(dst, os.O_CREATE|os.O_RDWR)
+	if err != nil {
+		log.Fatalf("fs.OpenFile(%q): %v", dst, err)
+	}
+	source, err := os.Open(src)
+	if err != nil {
+		log.Fatalf("os.Open(%q): %v", src, err)
+	}
+	defer source.Close()
+	// If this is streamed (e.g. using io.Copy) it exposes a bug in diskfs, so do it in one go.
+	data, err := ioutil.ReadAll(source)
+	if err != nil {
+		log.Fatalf("Reading %q: %v", src, err)
+	}
+	if _, err := target.Write(data); err != nil {
+		fmt.Printf("writing file %q: %v", dst, err)
+		os.Exit(1)
+	}
+}