treewide: introduce osbase package and move things around

All except localregistry moved from metropolis/pkg to osbase,
localregistry moved to metropolis/test as its only used there anyway.

Change-Id: If1a4bf377364bef0ac23169e1b90379c71b06d72
Reviewed-on: https://review.monogon.dev/c/monogon/+/3079
Tested-by: Jenkins CI
Reviewed-by: Serge Bazanski <serge@monogon.tech>
diff --git a/metropolis/test/e2e/BUILD.bazel b/metropolis/test/e2e/BUILD.bazel
index cb8645b..565223e 100644
--- a/metropolis/test/e2e/BUILD.bazel
+++ b/metropolis/test/e2e/BUILD.bazel
@@ -1,4 +1,4 @@
-load("//metropolis/pkg/localregistry:def.bzl", "localregistry_manifest")
+load("//metropolis/test/localregistry:def.bzl", "localregistry_manifest")
 
 localregistry_manifest(
     name = "testimages_manifest",
diff --git a/metropolis/test/e2e/k8s_cts/BUILD.bazel b/metropolis/test/e2e/k8s_cts/BUILD.bazel
index 8458b2d..6593a73 100644
--- a/metropolis/test/e2e/k8s_cts/BUILD.bazel
+++ b/metropolis/test/e2e/k8s_cts/BUILD.bazel
@@ -6,7 +6,7 @@
     importpath = "source.monogon.dev/metropolis/test/e2e/k8s_cts",
     visibility = ["//visibility:private"],
     deps = [
-        "//metropolis/test/launch/cluster",
+        "//metropolis/test/launch",
         "@io_k8s_api//core/v1:core",
         "@io_k8s_api//rbac/v1:rbac",
         "@io_k8s_apimachinery//pkg/apis/meta/v1:meta",
diff --git a/metropolis/test/e2e/k8s_cts/main.go b/metropolis/test/e2e/k8s_cts/main.go
index 6383cb8..6250a16 100644
--- a/metropolis/test/e2e/k8s_cts/main.go
+++ b/metropolis/test/e2e/k8s_cts/main.go
@@ -34,7 +34,7 @@
 	rbacv1 "k8s.io/api/rbac/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 
-	"source.monogon.dev/metropolis/test/launch/cluster"
+	mlaunch "source.monogon.dev/metropolis/test/launch"
 )
 
 // makeCTSPodSpec generates a spec for a standalone pod running the Kubernetes
@@ -100,7 +100,7 @@
 	}()
 
 	// TODO(q3k): bump up number of nodes after multi-node workflow gets reimplemented.
-	cl, err := cluster.LaunchCluster(ctx, cluster.ClusterOptions{NumNodes: 1})
+	cl, err := mlaunch.LaunchCluster(ctx, mlaunch.ClusterOptions{NumNodes: 1})
 	if err != nil {
 		log.Fatalf("Failed to launch cluster: %v", err)
 	}
diff --git a/metropolis/test/e2e/suites/core/BUILD.bazel b/metropolis/test/e2e/suites/core/BUILD.bazel
index 45ce438..223d7c7 100644
--- a/metropolis/test/e2e/suites/core/BUILD.bazel
+++ b/metropolis/test/e2e/suites/core/BUILD.bazel
@@ -17,12 +17,12 @@
     deps = [
         "//metropolis/node",
         "//metropolis/node/core/rpc",
-        "//metropolis/pkg/localregistry",
         "//metropolis/proto/api",
         "//metropolis/proto/common",
         "//metropolis/test/launch",
-        "//metropolis/test/launch/cluster",
+        "//metropolis/test/localregistry",
         "//metropolis/test/util",
+        "//osbase/test/launch",
         "@io_bazel_rules_go//go/runfiles:go_default_library",
         "@org_golang_google_grpc//:go_default_library",
     ],
diff --git a/metropolis/test/e2e/suites/core/run_test.go b/metropolis/test/e2e/suites/core/run_test.go
index 8bbbf52..21640a6 100644
--- a/metropolis/test/e2e/suites/core/run_test.go
+++ b/metropolis/test/e2e/suites/core/run_test.go
@@ -19,10 +19,10 @@
 
 	common "source.monogon.dev/metropolis/node"
 	"source.monogon.dev/metropolis/node/core/rpc"
-	"source.monogon.dev/metropolis/pkg/localregistry"
-	"source.monogon.dev/metropolis/test/launch"
-	"source.monogon.dev/metropolis/test/launch/cluster"
+	mlaunch "source.monogon.dev/metropolis/test/launch"
+	"source.monogon.dev/metropolis/test/localregistry"
 	"source.monogon.dev/metropolis/test/util"
+	"source.monogon.dev/osbase/test/launch"
 
 	apb "source.monogon.dev/metropolis/proto/api"
 	cpb "source.monogon.dev/metropolis/proto/common"
@@ -62,7 +62,7 @@
 		t.Fatalf("Creating test image registry failed: %v", err)
 	}
 	// Launch cluster.
-	clusterOptions := cluster.ClusterOptions{
+	clusterOptions := mlaunch.ClusterOptions{
 		NumNodes:      2,
 		LocalRegistry: lr,
 		InitialClusterConfiguration: &cpb.ClusterConfiguration{
@@ -70,7 +70,7 @@
 			StorageSecurityPolicy: cpb.ClusterConfiguration_STORAGE_SECURITY_POLICY_NEEDS_INSECURE,
 		},
 	}
-	cluster, err := cluster.LaunchCluster(ctx, clusterOptions)
+	cluster, err := mlaunch.LaunchCluster(ctx, clusterOptions)
 	if err != nil {
 		t.Fatalf("LaunchCluster failed: %v", err)
 	}
diff --git a/metropolis/test/e2e/suites/ha/BUILD.bazel b/metropolis/test/e2e/suites/ha/BUILD.bazel
index 0a89977..5a2a4dd 100644
--- a/metropolis/test/e2e/suites/ha/BUILD.bazel
+++ b/metropolis/test/e2e/suites/ha/BUILD.bazel
@@ -15,10 +15,10 @@
         "resources:ram:7000",
     ],
     deps = [
-        "//metropolis/pkg/localregistry",
         "//metropolis/test/launch",
-        "//metropolis/test/launch/cluster",
+        "//metropolis/test/localregistry",
         "//metropolis/test/util",
+        "//osbase/test/launch",
         "@io_bazel_rules_go//go/runfiles:go_default_library",
     ],
 )
diff --git a/metropolis/test/e2e/suites/ha/run_test.go b/metropolis/test/e2e/suites/ha/run_test.go
index 63a2acd..cc02df4 100644
--- a/metropolis/test/e2e/suites/ha/run_test.go
+++ b/metropolis/test/e2e/suites/ha/run_test.go
@@ -9,10 +9,10 @@
 
 	"github.com/bazelbuild/rules_go/go/runfiles"
 
-	"source.monogon.dev/metropolis/pkg/localregistry"
-	"source.monogon.dev/metropolis/test/launch"
-	"source.monogon.dev/metropolis/test/launch/cluster"
+	mlaunch "source.monogon.dev/metropolis/test/launch"
+	"source.monogon.dev/metropolis/test/localregistry"
 	"source.monogon.dev/metropolis/test/util"
+	"source.monogon.dev/osbase/test/launch"
 )
 
 const (
@@ -48,12 +48,12 @@
 		t.Fatalf("Creating test image registry failed: %v", err)
 	}
 	// Launch cluster.
-	clusterOptions := cluster.ClusterOptions{
+	clusterOptions := mlaunch.ClusterOptions{
 		NumNodes:        3,
 		LocalRegistry:   lr,
 		NodeLogsToFiles: true,
 	}
-	cluster, err := cluster.LaunchCluster(ctx, clusterOptions)
+	cluster, err := mlaunch.LaunchCluster(ctx, clusterOptions)
 	if err != nil {
 		t.Fatalf("LaunchCluster failed: %v", err)
 	}
diff --git a/metropolis/test/e2e/suites/ha_cold/BUILD.bazel b/metropolis/test/e2e/suites/ha_cold/BUILD.bazel
index d358f86..c2cfa0d 100644
--- a/metropolis/test/e2e/suites/ha_cold/BUILD.bazel
+++ b/metropolis/test/e2e/suites/ha_cold/BUILD.bazel
@@ -17,7 +17,7 @@
     deps = [
         "//metropolis/proto/common",
         "//metropolis/test/launch",
-        "//metropolis/test/launch/cluster",
         "//metropolis/test/util",
+        "//osbase/test/launch",
     ],
 )
diff --git a/metropolis/test/e2e/suites/ha_cold/run_test.go b/metropolis/test/e2e/suites/ha_cold/run_test.go
index 6670b8f..419d290 100644
--- a/metropolis/test/e2e/suites/ha_cold/run_test.go
+++ b/metropolis/test/e2e/suites/ha_cold/run_test.go
@@ -6,9 +6,9 @@
 	"testing"
 	"time"
 
-	"source.monogon.dev/metropolis/test/launch"
-	"source.monogon.dev/metropolis/test/launch/cluster"
+	mlaunch "source.monogon.dev/metropolis/test/launch"
 	"source.monogon.dev/metropolis/test/util"
+	"source.monogon.dev/osbase/test/launch"
 
 	cpb "source.monogon.dev/metropolis/proto/common"
 )
@@ -36,7 +36,7 @@
 	defer cancel()
 
 	// Launch cluster.
-	clusterOptions := cluster.ClusterOptions{
+	clusterOptions := mlaunch.ClusterOptions{
 		NumNodes:        3,
 		NodeLogsToFiles: true,
 		InitialClusterConfiguration: &cpb.ClusterConfiguration{
@@ -44,7 +44,7 @@
 			StorageSecurityPolicy: cpb.ClusterConfiguration_STORAGE_SECURITY_POLICY_NEEDS_INSECURE,
 		},
 	}
-	cluster, err := cluster.LaunchCluster(ctx, clusterOptions)
+	cluster, err := mlaunch.LaunchCluster(ctx, clusterOptions)
 	if err != nil {
 		t.Fatalf("LaunchCluster failed: %v", err)
 	}
diff --git a/metropolis/test/e2e/suites/kubernetes/BUILD.bazel b/metropolis/test/e2e/suites/kubernetes/BUILD.bazel
index 191ba35..6234e94 100644
--- a/metropolis/test/e2e/suites/kubernetes/BUILD.bazel
+++ b/metropolis/test/e2e/suites/kubernetes/BUILD.bazel
@@ -33,8 +33,8 @@
     ],
     deps = [
         "//metropolis/node",
-        "//metropolis/pkg/localregistry",
-        "//metropolis/test/launch/cluster",
+        "//metropolis/test/launch",
+        "//metropolis/test/localregistry",
         "//metropolis/test/util",
         "@io_bazel_rules_go//go/runfiles:go_default_library",
         "@io_k8s_api//core/v1:core",
diff --git a/metropolis/test/e2e/suites/kubernetes/run_test.go b/metropolis/test/e2e/suites/kubernetes/run_test.go
index ec38aa3..f15fafd 100644
--- a/metropolis/test/e2e/suites/kubernetes/run_test.go
+++ b/metropolis/test/e2e/suites/kubernetes/run_test.go
@@ -23,8 +23,8 @@
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	podv1 "k8s.io/kubernetes/pkg/api/v1/pod"
 
-	"source.monogon.dev/metropolis/pkg/localregistry"
-	"source.monogon.dev/metropolis/test/launch/cluster"
+	mlaunch "source.monogon.dev/metropolis/test/launch"
+	"source.monogon.dev/metropolis/test/localregistry"
 	"source.monogon.dev/metropolis/test/util"
 
 	common "source.monogon.dev/metropolis/node"
@@ -64,11 +64,11 @@
 	}
 
 	// Launch cluster.
-	clusterOptions := cluster.ClusterOptions{
+	clusterOptions := mlaunch.ClusterOptions{
 		NumNodes:      2,
 		LocalRegistry: lr,
 	}
-	cluster, err := cluster.LaunchCluster(ctx, clusterOptions)
+	cluster, err := mlaunch.LaunchCluster(ctx, clusterOptions)
 	if err != nil {
 		t.Fatalf("LaunchCluster failed: %v", err)
 	}
diff --git a/metropolis/test/ktest/BUILD.bazel b/metropolis/test/ktest/BUILD.bazel
deleted file mode 100644
index 952d7e8..0000000
--- a/metropolis/test/ktest/BUILD.bazel
+++ /dev/null
@@ -1,59 +0,0 @@
-load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
-load("//metropolis/node/build/kconfig-patcher:kconfig-patcher.bzl", "kconfig_patch")
-load("//third_party/linux:def.bzl", "linux_image")
-
-go_library(
-    name = "ktest_lib",
-    srcs = ["main.go"],
-    importpath = "source.monogon.dev/metropolis/test/ktest",
-    visibility = ["//visibility:private"],
-    deps = ["//metropolis/test/launch"],
-)
-
-go_binary(
-    name = "ktest",
-    embed = [":ktest_lib"],
-    pure = "on",
-    visibility = [
-        "//go/net/psample:__pkg__",
-        "//metropolis:__subpackages__",
-    ],
-)
-
-kconfig_patch(
-    name = "testing-config",
-    src = "//third_party/linux:linux-metropolis.config",
-    out = "testing.config",
-    override_configs = {
-        # Unlock command line
-        "CONFIG_CMDLINE_OVERRIDE": "n",
-        "CONFIG_CMDLINE_BOOL": "n",
-        # Shave off 1 second from boot time
-        "CONFIG_SERIO_I8042": "",
-        "CONFIG_KEYBOARD_ATKBD": "",
-        "CONFIG_RTC_DRV_CMOS": "",
-        # Shave off an additional 18ms (half of the boot time)
-        "CONFIG_DEBUG_WX": "",
-    },
-)
-
-linux_image(
-    name = "linux-testing",
-    image_format = "vmlinux",
-    kernel_config = ":testing-config",
-    # This image is directly used by the ktest macro, thus it needs a pretty
-    # wide visibility.
-    visibility = [
-        "//go/net/psample:__pkg__",
-        "//metropolis:__subpackages__",
-    ],
-)
-
-filegroup(
-    name = "test-script",
-    srcs = ["run_ktest.sh"],
-    visibility = [
-        "//go/net/psample:__pkg__",
-        "//metropolis:__subpackages__",
-    ],
-)
diff --git a/metropolis/test/ktest/init/BUILD.bazel b/metropolis/test/ktest/init/BUILD.bazel
deleted file mode 100644
index e48bcb2..0000000
--- a/metropolis/test/ktest/init/BUILD.bazel
+++ /dev/null
@@ -1,19 +0,0 @@
-load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
-
-go_library(
-    name = "init_lib",
-    srcs = ["main.go"],
-    importpath = "source.monogon.dev/metropolis/test/ktest/init",
-    visibility = ["//visibility:private"],
-    deps = ["@org_golang_x_sys//unix"],
-)
-
-go_binary(
-    name = "init",
-    embed = [":init_lib"],
-    pure = "on",
-    visibility = [
-        "//go/net/psample:__pkg__",
-        "//metropolis:__subpackages__",
-    ],
-)
diff --git a/metropolis/test/ktest/init/main.go b/metropolis/test/ktest/init/main.go
deleted file mode 100644
index 16ffa29..0000000
--- a/metropolis/test/ktest/init/main.go
+++ /dev/null
@@ -1,85 +0,0 @@
-// 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.
-
-// ktestinit is an init designed to run inside a lightweight VM for running
-// tests in there.  It performs basic platform initialization like mounting
-// kernel filesystems and launches the test executable at /tester, passes the
-// exit code back out over the control socket to ktest and then terminates the
-// default VM kernel.
-package main
-
-import (
-	"errors"
-	"fmt"
-	"os"
-	"os/exec"
-
-	"golang.org/x/sys/unix"
-)
-
-func mountInit() error {
-	for _, el := range []struct {
-		dir   string
-		fs    string
-		flags uintptr
-	}{
-		{"/sys", "sysfs", unix.MS_NOEXEC | unix.MS_NOSUID | unix.MS_NODEV},
-		{"/sys/kernel/debug", "debugfs", unix.MS_NOEXEC | unix.MS_NOSUID | unix.MS_NODEV},
-		{"/proc", "proc", unix.MS_NOEXEC | unix.MS_NOSUID | unix.MS_NODEV},
-		{"/dev", "devtmpfs", unix.MS_NOEXEC | unix.MS_NOSUID},
-		{"/dev/pts", "devpts", unix.MS_NOEXEC | unix.MS_NOSUID},
-		{"/tmp", "tmpfs", 0},
-	} {
-		if err := os.Mkdir(el.dir, 0755); err != nil && !os.IsExist(err) {
-			return fmt.Errorf("could not make %s: %w", el.dir, err)
-		}
-		if err := unix.Mount(el.fs, el.dir, el.fs, el.flags, ""); err != nil {
-			return fmt.Errorf("could not mount %s on %s: %w", el.fs, el.dir, err)
-		}
-	}
-	return nil
-}
-
-func main() {
-	if err := mountInit(); err != nil {
-		panic(err)
-	}
-
-	// First virtual serial is always stdout, second is control
-	ioConn, err := os.OpenFile("/dev/vport1p1", os.O_RDWR, 0)
-	if err != nil {
-		fmt.Printf("Failed to open communication device: %v\n", err)
-		return
-	}
-	cmd := exec.Command("/tester", "-test.v")
-	cmd.Stderr = os.Stderr
-	cmd.Stdout = os.Stdout
-	cmd.Env = append(cmd.Env, "IN_KTEST=true")
-	if err := cmd.Run(); err != nil {
-		var exerr *exec.ExitError
-		if errors.As(err, &exerr) {
-			if _, err := ioConn.Write([]byte{uint8(exerr.ExitCode())}); err != nil {
-				panic(err)
-			}
-		}
-		fmt.Printf("Failed to execute tests (tests didn't run): %v", err)
-	} else {
-		ioConn.Write([]byte{0})
-	}
-	ioConn.Close()
-
-	unix.Reboot(unix.LINUX_REBOOT_CMD_RESTART)
-}
diff --git a/metropolis/test/ktest/ktest.bzl b/metropolis/test/ktest/ktest.bzl
deleted file mode 100644
index 8cb5257..0000000
--- a/metropolis/test/ktest/ktest.bzl
+++ /dev/null
@@ -1,58 +0,0 @@
-#  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.
-
-"""
-Ktest provides a macro to run tests under a normal Metropolis node kernel
-"""
-
-load("//metropolis/node/build:def.bzl", "node_initramfs")
-
-def _dict_union(x, y):
-    z = {}
-    z.update(x)
-    z.update(y)
-    return z
-
-def ktest(tester, cmdline = "", files = {}, fsspecs = [], files_cc = {}):
-    node_initramfs(
-        name = "test_initramfs",
-        fsspecs = [
-            "//metropolis/node/build:earlydev.fsspec",
-        ] + fsspecs,
-        files = _dict_union({
-            "//metropolis/test/ktest/init": "/init",
-            tester: "/tester",
-        }, files),
-        files_cc = files_cc,
-        testonly = True,
-    )
-
-    native.sh_test(
-        name = "ktest",
-        args = [
-            "$(location //metropolis/test/ktest)",
-            "$(location :test_initramfs)",
-            "$(location //metropolis/test/ktest:linux-testing)",
-            cmdline,
-        ],
-        size = "small",
-        srcs = ["//metropolis/test/ktest:test-script"],
-        data = [
-            "//metropolis/test/ktest",
-            ":test_initramfs",
-            "//metropolis/test/ktest:linux-testing",
-        ],
-    )
diff --git a/metropolis/test/ktest/main.go b/metropolis/test/ktest/main.go
deleted file mode 100644
index 27cd919..0000000
--- a/metropolis/test/ktest/main.go
+++ /dev/null
@@ -1,76 +0,0 @@
-// 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.
-
-// ktest is a test launcher for running tests inside a custom kernel and passes
-// the results back out.
-package main
-
-import (
-	"context"
-	"flag"
-	"io"
-	"log"
-	"os"
-	"time"
-
-	"source.monogon.dev/metropolis/test/launch"
-)
-
-var (
-	kernelPath = flag.String("kernel-path", "", "Path of the Kernel ELF file")
-	initrdPath = flag.String("initrd-path", "", "Path of the initrd image")
-	cmdline    = flag.String("cmdline", "", "Additional kernel command line options")
-)
-
-func main() {
-	flag.Parse()
-
-	hostFeedbackConn, vmFeedbackConn, err := launch.NewSocketPair()
-	if err != nil {
-		log.Fatalf("Failed to create socket pair: %v", err)
-	}
-
-	exitCodeChan := make(chan uint8, 1)
-
-	go func() {
-		defer hostFeedbackConn.Close()
-
-		returnCode := make([]byte, 1)
-		if _, err := io.ReadFull(hostFeedbackConn, returnCode); err != nil {
-			log.Fatalf("Failed to read socket: %v", err)
-		}
-		exitCodeChan <- returnCode[0]
-	}()
-
-	if err := launch.RunMicroVM(context.Background(), &launch.MicroVMOptions{
-		Name:                        "ktest",
-		KernelPath:                  *kernelPath,
-		InitramfsPath:               *initrdPath,
-		Cmdline:                     *cmdline,
-		SerialPort:                  os.Stdout,
-		ExtraChardevs:               []*os.File{vmFeedbackConn},
-		DisableHostNetworkInterface: true,
-	}); err != nil {
-		log.Fatalf("Failed to run ktest VM: %v", err)
-	}
-
-	select {
-	case exitCode := <-exitCodeChan:
-		os.Exit(int(exitCode))
-	case <-time.After(1 * time.Second):
-		log.Fatal("Failed to get an error code back (test runtime probably crashed)")
-	}
-}
diff --git a/metropolis/test/ktest/run_ktest.sh b/metropolis/test/ktest/run_ktest.sh
deleted file mode 100755
index 02920a1..0000000
--- a/metropolis/test/ktest/run_ktest.sh
+++ /dev/null
@@ -1,2 +0,0 @@
-#!/bin/bash
-exec "$1" -initrd-path "$2" -kernel-path "$3" -cmdline "$4"
\ No newline at end of file
diff --git a/metropolis/test/lacp/BUILD.bazel b/metropolis/test/lacp/BUILD.bazel
index d044de2..b5a0f7a 100644
--- a/metropolis/test/lacp/BUILD.bazel
+++ b/metropolis/test/lacp/BUILD.bazel
@@ -1,5 +1,5 @@
 load("@io_bazel_rules_go//go:def.bzl", "go_test")
-load("//metropolis/test/ktest:ktest.bzl", "ktest")
+load("//osbase/test/ktest:ktest.bzl", "ktest")
 
 go_test(
     name = "lacptest_test",
diff --git a/metropolis/test/launch/BUILD.bazel b/metropolis/test/launch/BUILD.bazel
index cc5ef6c..24296d1 100644
--- a/metropolis/test/launch/BUILD.bazel
+++ b/metropolis/test/launch/BUILD.bazel
@@ -3,17 +3,50 @@
 go_library(
     name = "launch",
     srcs = [
-        "launch.go",
-        "log.go",
+        "cluster.go",
+        "insecure_key.go",
+        "metroctl.go",
+        "prefixed_stdio.go",
+        "swtpm.go",
     ],
     data = [
+        "//metropolis/node:image",
+        "//metropolis/test/nanoswitch:initramfs",
+        "//metropolis/test/swtpm/certtool",
+        "//metropolis/test/swtpm/swtpm_cert",
+        "//osbase/test/ktest:linux-testing",
+        "//third_party/edk2:firmware",
         "@com_github_bonzini_qboot//:qboot-bin",
+        "@swtpm",
+        "@swtpm//:swtpm_localca",
+        "@swtpm//:swtpm_setup",
     ],
     importpath = "source.monogon.dev/metropolis/test/launch",
-    visibility = ["//metropolis:__subpackages__"],
+    visibility = ["//visibility:public"],
     deps = [
-        "//metropolis/pkg/freeport",
+        "//go/qcow2",
+        "//metropolis/cli/metroctl/core",
+        "//metropolis/node",
+        "//metropolis/node/core/curator/proto/api",
+        "//metropolis/node/core/identity",
+        "//metropolis/node/core/rpc",
+        "//metropolis/node/core/rpc/resolver",
+        "//metropolis/proto/api",
+        "//metropolis/proto/common",
+        "//metropolis/test/localregistry",
+        "//osbase/logbuffer",
+        "//osbase/test/launch",
+        "@com_github_cenkalti_backoff_v4//:backoff",
+        "@com_github_kballard_go_shellquote//:go-shellquote",
         "@io_bazel_rules_go//go/runfiles:go_default_library",
+        "@io_k8s_client_go//kubernetes",
+        "@io_k8s_client_go//rest",
+        "@org_golang_google_grpc//:go_default_library",
+        "@org_golang_google_grpc//codes",
+        "@org_golang_google_grpc//status",
+        "@org_golang_google_protobuf//proto",
+        "@org_golang_x_net//proxy",
         "@org_golang_x_sys//unix",
+        "@org_uber_go_multierr//:multierr",
     ],
 )
diff --git a/metropolis/test/launch/cli/launch-cluster/BUILD.bazel b/metropolis/test/launch/cli/launch-cluster/BUILD.bazel
index 688228e..9e48795 100644
--- a/metropolis/test/launch/cli/launch-cluster/BUILD.bazel
+++ b/metropolis/test/launch/cli/launch-cluster/BUILD.bazel
@@ -8,7 +8,7 @@
     visibility = ["//visibility:private"],
     deps = [
         "//metropolis/cli/metroctl/core",
-        "//metropolis/test/launch/cluster",
+        "//metropolis/test/launch",
     ],
 )
 
diff --git a/metropolis/test/launch/cli/launch-cluster/main.go b/metropolis/test/launch/cli/launch-cluster/main.go
index c9b9dec..1529396 100644
--- a/metropolis/test/launch/cli/launch-cluster/main.go
+++ b/metropolis/test/launch/cli/launch-cluster/main.go
@@ -23,12 +23,12 @@
 	"os/signal"
 
 	metroctl "source.monogon.dev/metropolis/cli/metroctl/core"
-	"source.monogon.dev/metropolis/test/launch/cluster"
+	mlaunch "source.monogon.dev/metropolis/test/launch"
 )
 
 func main() {
 	ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt)
-	cl, err := cluster.LaunchCluster(ctx, cluster.ClusterOptions{
+	cl, err := mlaunch.LaunchCluster(ctx, mlaunch.ClusterOptions{
 		NumNodes:        3,
 		NodeLogsToFiles: true,
 	})
@@ -36,7 +36,7 @@
 		log.Fatalf("LaunchCluster: %v", err)
 	}
 
-	mpath, err := cluster.MetroctlRunfilePath()
+	mpath, err := mlaunch.MetroctlRunfilePath()
 	if err != nil {
 		log.Fatalf("MetroctlRunfilePath: %v", err)
 	}
diff --git a/metropolis/test/launch/cli/launch/BUILD.bazel b/metropolis/test/launch/cli/launch/BUILD.bazel
index 49df994..f0edefc 100644
--- a/metropolis/test/launch/cli/launch/BUILD.bazel
+++ b/metropolis/test/launch/cli/launch/BUILD.bazel
@@ -9,7 +9,7 @@
     deps = [
         "//metropolis/proto/api",
         "//metropolis/test/launch",
-        "//metropolis/test/launch/cluster",
+        "//osbase/test/launch",
     ],
 )
 
diff --git a/metropolis/test/launch/cli/launch/main.go b/metropolis/test/launch/cli/launch/main.go
index 2ae3a0c..71c7aa2 100644
--- a/metropolis/test/launch/cli/launch/main.go
+++ b/metropolis/test/launch/cli/launch/main.go
@@ -24,8 +24,9 @@
 	"path/filepath"
 
 	apb "source.monogon.dev/metropolis/proto/api"
-	"source.monogon.dev/metropolis/test/launch"
-	"source.monogon.dev/metropolis/test/launch/cluster"
+
+	mlaunch "source.monogon.dev/metropolis/test/launch"
+	"source.monogon.dev/osbase/test/launch"
 )
 
 func main() {
@@ -45,22 +46,22 @@
 	defer os.RemoveAll(sd)
 
 	var ports []uint16
-	for _, p := range cluster.NodePorts {
+	for _, p := range mlaunch.NodePorts {
 		ports = append(ports, uint16(p))
 	}
 	ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt)
 	doneC := make(chan error)
-	tpmf, err := cluster.NewTPMFactory(filepath.Join(ld, "tpm"))
+	tpmf, err := mlaunch.NewTPMFactory(filepath.Join(ld, "tpm"))
 	if err != nil {
 		log.Fatalf("NewTPMFactory: %v", err)
 	}
-	err = cluster.LaunchNode(ctx, ld, sd, tpmf, &cluster.NodeOptions{
+	err = mlaunch.LaunchNode(ctx, ld, sd, tpmf, &mlaunch.NodeOptions{
 		Name:       "test-node",
 		Ports:      launch.IdentityPortMap(ports),
 		SerialPort: os.Stdout,
 		NodeParameters: &apb.NodeParameters{
 			Cluster: &apb.NodeParameters_ClusterBootstrap_{
-				ClusterBootstrap: cluster.InsecureClusterBootstrap,
+				ClusterBootstrap: mlaunch.InsecureClusterBootstrap,
 			},
 		},
 	}, doneC)
diff --git a/metropolis/test/launch/cluster/cluster.go b/metropolis/test/launch/cluster.go
similarity index 99%
rename from metropolis/test/launch/cluster/cluster.go
rename to metropolis/test/launch/cluster.go
index dfeb457..7ae5f83 100644
--- a/metropolis/test/launch/cluster/cluster.go
+++ b/metropolis/test/launch/cluster.go
@@ -2,7 +2,7 @@
 // nodes and clusters in a virtualized environment using qemu. It's kept in a
 // separate package as it depends on a Metropolis node image, which might not be
 // required for some use of the launch library.
-package cluster
+package launch
 
 import (
 	"bytes"
@@ -48,8 +48,8 @@
 	"source.monogon.dev/metropolis/node/core/identity"
 	"source.monogon.dev/metropolis/node/core/rpc"
 	"source.monogon.dev/metropolis/node/core/rpc/resolver"
-	"source.monogon.dev/metropolis/pkg/localregistry"
-	"source.monogon.dev/metropolis/test/launch"
+	"source.monogon.dev/metropolis/test/localregistry"
+	"source.monogon.dev/osbase/test/launch"
 )
 
 const (
@@ -854,7 +854,7 @@
 		} else {
 			serialPort = newPrefixedStdio(99)
 		}
-		kernelPath, err := runfiles.Rlocation("_main/metropolis/test/ktest/vmlinux")
+		kernelPath, err := runfiles.Rlocation("_main/osbase/test/ktest/vmlinux")
 		if err != nil {
 			launch.Fatal("Failed to resolved nanoswitch kernel: %v", err)
 		}
diff --git a/metropolis/test/launch/cluster/BUILD.bazel b/metropolis/test/launch/cluster/BUILD.bazel
deleted file mode 100644
index efa04cb..0000000
--- a/metropolis/test/launch/cluster/BUILD.bazel
+++ /dev/null
@@ -1,52 +0,0 @@
-load("@io_bazel_rules_go//go:def.bzl", "go_library")
-
-go_library(
-    name = "cluster",
-    srcs = [
-        "cluster.go",
-        "insecure_key.go",
-        "metroctl.go",
-        "prefixed_stdio.go",
-        "swtpm.go",
-    ],
-    data = [
-        "//metropolis/node:image",
-        "//metropolis/test/ktest:linux-testing",
-        "//metropolis/test/nanoswitch:initramfs",
-        "//metropolis/test/swtpm/certtool",
-        "//metropolis/test/swtpm/swtpm_cert",
-        "//third_party/edk2:firmware",
-        "@com_github_bonzini_qboot//:qboot-bin",
-        "@swtpm",
-        "@swtpm//:swtpm_localca",
-        "@swtpm//:swtpm_setup",
-    ],
-    importpath = "source.monogon.dev/metropolis/test/launch/cluster",
-    visibility = ["//visibility:public"],
-    deps = [
-        "//go/qcow2",
-        "//metropolis/cli/metroctl/core",
-        "//metropolis/node",
-        "//metropolis/node/core/curator/proto/api",
-        "//metropolis/node/core/identity",
-        "//metropolis/node/core/rpc",
-        "//metropolis/node/core/rpc/resolver",
-        "//metropolis/pkg/localregistry",
-        "//metropolis/pkg/logbuffer",
-        "//metropolis/proto/api",
-        "//metropolis/proto/common",
-        "//metropolis/test/launch",
-        "@com_github_cenkalti_backoff_v4//:backoff",
-        "@com_github_kballard_go_shellquote//:go-shellquote",
-        "@io_bazel_rules_go//go/runfiles:go_default_library",
-        "@io_k8s_client_go//kubernetes",
-        "@io_k8s_client_go//rest",
-        "@org_golang_google_grpc//:go_default_library",
-        "@org_golang_google_grpc//codes",
-        "@org_golang_google_grpc//status",
-        "@org_golang_google_protobuf//proto",
-        "@org_golang_x_net//proxy",
-        "@org_golang_x_sys//unix",
-        "@org_uber_go_multierr//:multierr",
-    ],
-)
diff --git a/metropolis/test/launch/cluster/insecure_key.go b/metropolis/test/launch/insecure_key.go
similarity index 98%
rename from metropolis/test/launch/cluster/insecure_key.go
rename to metropolis/test/launch/insecure_key.go
index 48cd6d8..72af26f 100644
--- a/metropolis/test/launch/cluster/insecure_key.go
+++ b/metropolis/test/launch/insecure_key.go
@@ -14,7 +14,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package cluster
+package launch
 
 import (
 	"crypto/ed25519"
diff --git a/metropolis/test/launch/launch.go b/metropolis/test/launch/launch.go
deleted file mode 100644
index 953025d..0000000
--- a/metropolis/test/launch/launch.go
+++ /dev/null
@@ -1,335 +0,0 @@
-// 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.
-
-// launch implements test harnesses for running qemu VMs from tests.
-package launch
-
-import (
-	"bytes"
-	"context"
-	"errors"
-	"fmt"
-	"io"
-	"net"
-	"os"
-	"os/exec"
-	"strconv"
-	"strings"
-	"syscall"
-
-	"github.com/bazelbuild/rules_go/go/runfiles"
-	"golang.org/x/sys/unix"
-
-	"source.monogon.dev/metropolis/pkg/freeport"
-)
-
-type QemuValue map[string][]string
-
-// ToOption encodes structured data into a QEMU option. Example: "test", {"key1":
-// {"val1"}, "key2": {"val2", "val3"}} returns "test,key1=val1,key2=val2,key2=val3"
-func (value QemuValue) ToOption(name string) string {
-	var optionValues []string
-	if name != "" {
-		optionValues = append(optionValues, name)
-	}
-	for name, values := range value {
-		if len(values) == 0 {
-			optionValues = append(optionValues, name)
-		}
-		for _, val := range values {
-			optionValues = append(optionValues, fmt.Sprintf("%v=%v", name, val))
-		}
-	}
-	return strings.Join(optionValues, ",")
-}
-
-// PrettyPrintQemuArgs prints the given QEMU arguments to stderr.
-func PrettyPrintQemuArgs(name string, args []string) {
-	var argsFmt string
-	for _, arg := range args {
-		argsFmt += arg
-		if !strings.HasPrefix(arg, "-") {
-			argsFmt += "\n  "
-		} else {
-			argsFmt += " "
-		}
-	}
-	Log("Running %s:\n  %s\n", name, argsFmt)
-}
-
-// PortMap represents where VM ports are mapped to on the host. It maps from the VM
-// port number to the host port number.
-type PortMap map[uint16]uint16
-
-// ToQemuForwards generates QEMU hostfwd values (https://qemu.weilnetz.de/doc/qemu-
-// doc.html#:~:text=hostfwd=) for all mapped ports.
-func (p PortMap) ToQemuForwards() []string {
-	var hostfwdOptions []string
-	for vmPort, hostPort := range p {
-		hostfwdOptions = append(hostfwdOptions, fmt.Sprintf("tcp::%d-:%d", hostPort, vmPort))
-	}
-	return hostfwdOptions
-}
-
-// IdentityPortMap returns a port map where each given port is mapped onto itself
-// on the host. This is mainly useful for development against Metropolis. The dbg
-// command requires this mapping.
-func IdentityPortMap(ports []uint16) PortMap {
-	portMap := make(PortMap)
-	for _, port := range ports {
-		portMap[port] = port
-	}
-	return portMap
-}
-
-// ConflictFreePortMap returns a port map where each given port is mapped onto a
-// random free port on the host. This is intended for automated testing where
-// multiple instances of Metropolis nodes might be running. Please call this
-// function for each Launch command separately and as close to it as possible since
-// it cannot guarantee that the ports will remain free.
-func ConflictFreePortMap(ports []uint16) (PortMap, error) {
-	portMap := make(PortMap)
-	for _, port := range ports {
-		mappedPort, listenCloser, err := freeport.AllocateTCPPort()
-		if err != nil {
-			return portMap, fmt.Errorf("failed to get free host port: %w", err)
-		}
-		// Defer closing of the listening port until the function is done and all ports are
-		// allocated
-		defer listenCloser.Close()
-		portMap[port] = mappedPort
-	}
-	return portMap, nil
-}
-
-// GuestServiceMap maps an IP/port combination inside the virtual guest network
-// to a TCPAddr reachable by the host. If the guest connects to the virtual
-// address/port, this connection gets forwarded to the host.
-type GuestServiceMap map[*net.TCPAddr]net.TCPAddr
-
-// ToQemuForwards generates QEMU guestfwd values (https://qemu.weilnetz.de/doc/qemu-
-// doc.html#:~:text=guestfwd=) for all mapped addresses.
-func (p GuestServiceMap) ToQemuForwards() []string {
-	var guestfwdOptions []string
-	for guestAddr, hostAddr := range p {
-		guestfwdOptions = append(guestfwdOptions, fmt.Sprintf("tcp:%s-tcp:%s", guestAddr.String(), hostAddr.String()))
-	}
-	return guestfwdOptions
-}
-
-// NewSocketPair creates a new socket pair. By connecting both ends to different
-// instances you can connect them with a virtual "network cable". The ends can be
-// passed into the ConnectToSocket option.
-func NewSocketPair() (*os.File, *os.File, error) {
-	fds, err := unix.Socketpair(unix.AF_UNIX, syscall.SOCK_STREAM, 0)
-	if err != nil {
-		return nil, nil, fmt.Errorf("failed to call socketpair: %w", err)
-	}
-
-	fd1 := os.NewFile(uintptr(fds[0]), "network0")
-	fd2 := os.NewFile(uintptr(fds[1]), "network1")
-	return fd1, fd2, nil
-}
-
-// HostInterfaceMAC is the MAC address the host SLIRP network interface has if it
-// is not disabled (see DisableHostNetworkInterface in MicroVMOptions)
-var HostInterfaceMAC = net.HardwareAddr{0x02, 0x72, 0x82, 0xbf, 0xc3, 0x56}
-
-// MicroVMOptions contains all options to start a MicroVM
-type MicroVMOptions struct {
-	// Name is a human-readable identifier to be used in debug output.
-	Name string
-
-	// Path to the ELF kernel binary
-	KernelPath string
-
-	// Path to the Initramfs
-	InitramfsPath string
-
-	// Cmdline contains additional kernel commandline options
-	Cmdline string
-
-	// SerialPort is a File(descriptor) over which you can communicate with the serial
-	// port of the machine It can be set to an existing file descriptor (like
-	// os.Stdout/os.Stderr) or you can use NewSocketPair() to get one end to talk to
-	// from Go.
-	SerialPort io.Writer
-
-	// ExtraChardevs can be used similar to SerialPort, but can contain an arbitrary
-	// number of additional serial ports
-	ExtraChardevs []*os.File
-
-	// ExtraNetworkInterfaces can contain an arbitrary number of file descriptors which
-	// are mapped into the VM as virtio network interfaces. The first interface is
-	// always a SLIRP-backed interface for communicating with the host.
-	ExtraNetworkInterfaces []*os.File
-
-	// PortMap contains ports that are mapped to the host through the built-in SLIRP
-	// network interface.
-	PortMap PortMap
-
-	// GuestServiceMap contains TCP services made available in the guest virtual
-	// network which are running on the host.
-	GuestServiceMap GuestServiceMap
-
-	// DisableHostNetworkInterface disables the SLIRP-backed host network interface
-	// that is normally the first network interface. If this is set PortMap is ignored.
-	// Mostly useful for speeding up QEMU's startup time for tests.
-	DisableHostNetworkInterface bool
-
-	// PcapDump can be used to dump all network traffic to a pcap file.
-	// If unset, no dump is created.
-	PcapDump string
-}
-
-// RunMicroVM launches a tiny VM mostly intended for testing. Very quick to boot
-// (<40ms).
-func RunMicroVM(ctx context.Context, opts *MicroVMOptions) error {
-	// Generate options for all the file descriptors we'll be passing as virtio "serial
-	// ports"
-	var extraArgs []string
-	for idx := range opts.ExtraChardevs {
-		idxStr := strconv.Itoa(idx)
-		id := "extra" + idxStr
-		// That this works is pretty much a hack, but upstream QEMU doesn't have a
-		// bidirectional chardev backend not based around files/sockets on the disk which
-		// are a giant pain to work with. We're using QEMU's fdset functionality to make
-		// FDs available as pseudo-files and then "ab"using the pipe backend's fallback
-		// functionality to get a single bidirectional chardev backend backed by a passed-
-		// down RDWR fd. Ref https://lists.gnu.org/archive/html/qemu-devel/2015-
-		// 12/msg01256.html
-		addFdConf := QemuValue{
-			"set": {idxStr},
-			"fd":  {strconv.Itoa(idx + 3)},
-		}
-		chardevConf := QemuValue{
-			"id":   {id},
-			"path": {"/dev/fdset/" + idxStr},
-		}
-		deviceConf := QemuValue{
-			"chardev": {id},
-		}
-		extraArgs = append(extraArgs, "-add-fd", addFdConf.ToOption(""),
-			"-chardev", chardevConf.ToOption("pipe"), "-device", deviceConf.ToOption("virtserialport"))
-	}
-
-	for idx := range opts.ExtraNetworkInterfaces {
-		id := fmt.Sprintf("net%v", idx)
-		netdevConf := QemuValue{
-			"id": {id},
-			"fd": {strconv.Itoa(idx + 3 + len(opts.ExtraChardevs))},
-		}
-		extraArgs = append(extraArgs, "-netdev", netdevConf.ToOption("socket"), "-device", "virtio-net-device,netdev="+id)
-	}
-
-	// This sets up a minimum viable environment for our Linux kernel. It clears all
-	// standard QEMU configuration and sets up a MicroVM machine
-	// (https://github.com/qemu/qemu/blob/master/docs/microvm.rst) with all legacy
-	// emulation turned off. This means the only "hardware" the Linux kernel inside can
-	// communicate with is a single virtio-mmio region. Over that MMIO interface we run
-	// a paravirtualized RNG (since the kernel in there has nothing to gather that from
-	// and it delays booting), a single paravirtualized console and an arbitrary number
-	// of extra serial ports for talking to various things that might run inside. The
-	// kernel, initramfs and command line are mapped into VM memory at boot time and
-	// not loaded from any sort of disk. Booting and shutting off one of these VMs
-	// takes <100ms.
-	biosPath, err := runfiles.Rlocation("com_github_bonzini_qboot/bios.bin")
-	if err != nil {
-		return fmt.Errorf("while searching bios: %w", err)
-	}
-
-	baseArgs := []string{
-		"-nodefaults", "-no-user-config", "-nographic", "-no-reboot",
-		"-accel", "kvm", "-cpu", "host",
-		"-m", "1G",
-		// Needed until QEMU updates their bundled qboot version (needs
-		// https://github.com/bonzini/qboot/pull/28)
-		"-bios", biosPath,
-		"-M", "microvm,x-option-roms=off,pic=off,pit=off,rtc=off,isa-serial=off",
-		"-kernel", opts.KernelPath,
-		// We force using a triple-fault reboot strategy since otherwise the kernel first
-		// tries others (like ACPI) which are not available in this very restricted
-		// environment. Similarly we need to override the boot console since there's
-		// nothing on the ISA bus that the kernel could talk to. We also force quiet for
-		// performance reasons.
-		"-append", "reboot=t console=hvc0 quiet " + opts.Cmdline,
-		"-initrd", opts.InitramfsPath,
-		"-device", "virtio-rng-device,max-bytes=1024,period=1000",
-		"-device", "virtio-serial-device,max_ports=16",
-		"-chardev", "stdio,id=con0", "-device", "virtconsole,chardev=con0",
-	}
-
-	if !opts.DisableHostNetworkInterface {
-		qemuNetType := "user"
-		qemuNetConfig := QemuValue{
-			"id":        {"usernet0"},
-			"net":       {"10.42.0.0/24"},
-			"dhcpstart": {"10.42.0.10"},
-		}
-		if opts.PortMap != nil {
-			qemuNetConfig["hostfwd"] = opts.PortMap.ToQemuForwards()
-		}
-		if opts.GuestServiceMap != nil {
-			qemuNetConfig["guestfwd"] = opts.GuestServiceMap.ToQemuForwards()
-		}
-
-		baseArgs = append(baseArgs, "-netdev", qemuNetConfig.ToOption(qemuNetType),
-			"-device", "virtio-net-device,netdev=usernet0,mac="+HostInterfaceMAC.String())
-	}
-
-	if !opts.DisableHostNetworkInterface && opts.PcapDump != "" {
-		qemuNetDump := QemuValue{
-			"id":     {"usernet0"},
-			"netdev": {"usernet0"},
-			"file":   {opts.PcapDump},
-		}
-		extraArgs = append(extraArgs, "-object", qemuNetDump.ToOption("filter-dump"))
-	}
-
-	var stdErrBuf bytes.Buffer
-	cmd := exec.CommandContext(ctx, "qemu-system-x86_64", append(baseArgs, extraArgs...)...)
-	cmd.Stdout = opts.SerialPort
-	cmd.Stderr = &stdErrBuf
-
-	cmd.ExtraFiles = append(cmd.ExtraFiles, opts.ExtraChardevs...)
-	cmd.ExtraFiles = append(cmd.ExtraFiles, opts.ExtraNetworkInterfaces...)
-
-	PrettyPrintQemuArgs(opts.Name, cmd.Args)
-
-	err = cmd.Run()
-	// If it's a context error, just quit. There's no way to tell a
-	// killed-due-to-context vs killed-due-to-external-reason error returned by Run,
-	// so we approximate by looking at the context's status.
-	if err != nil && ctx.Err() != nil {
-		return ctx.Err()
-	}
-
-	var exerr *exec.ExitError
-	if err != nil && errors.As(err, &exerr) {
-		exerr.Stderr = stdErrBuf.Bytes()
-		newErr := QEMUError(*exerr)
-		return &newErr
-	}
-	return err
-}
-
-// QEMUError is a special type of ExitError used when QEMU fails. In addition to
-// normal ExitError features it prints stderr for debugging.
-type QEMUError exec.ExitError
-
-func (e *QEMUError) Error() string {
-	return fmt.Sprintf("%v: %v", e.String(), string(e.Stderr))
-}
diff --git a/metropolis/test/launch/log.go b/metropolis/test/launch/log.go
deleted file mode 100644
index 2637e24..0000000
--- a/metropolis/test/launch/log.go
+++ /dev/null
@@ -1,28 +0,0 @@
-package launch
-
-import (
-	"fmt"
-	"os"
-	"strings"
-)
-
-// Log is compatible with the output of ConciseString as used in the Metropolis
-// console log, making the output more readable in unified test logs.
-func Log(f string, args ...any) {
-	formatted := fmt.Sprintf(f, args...)
-	for i, line := range strings.Split(formatted, "\n") {
-		if len(line) == 0 {
-			continue
-		}
-		if i == 0 {
-			fmt.Printf("TT| %20s ! %s\n", "test launch", line)
-		} else {
-			fmt.Printf("TT| %20s | %s\n", "", line)
-		}
-	}
-}
-
-func Fatal(f string, args ...any) {
-	Log(f, args...)
-	os.Exit(1)
-}
diff --git a/metropolis/test/launch/cluster/metroctl.go b/metropolis/test/launch/metroctl.go
similarity index 99%
rename from metropolis/test/launch/cluster/metroctl.go
rename to metropolis/test/launch/metroctl.go
index e985a64..e3196a6 100644
--- a/metropolis/test/launch/cluster/metroctl.go
+++ b/metropolis/test/launch/metroctl.go
@@ -1,4 +1,4 @@
-package cluster
+package launch
 
 import (
 	"context"
diff --git a/metropolis/test/launch/cluster/prefixed_stdio.go b/metropolis/test/launch/prefixed_stdio.go
similarity index 94%
rename from metropolis/test/launch/cluster/prefixed_stdio.go
rename to metropolis/test/launch/prefixed_stdio.go
index 3ea3e18..c851c44 100644
--- a/metropolis/test/launch/cluster/prefixed_stdio.go
+++ b/metropolis/test/launch/prefixed_stdio.go
@@ -1,11 +1,11 @@
-package cluster
+package launch
 
 import (
 	"fmt"
 	"io"
 	"strings"
 
-	"source.monogon.dev/metropolis/pkg/logbuffer"
+	"source.monogon.dev/osbase/logbuffer"
 )
 
 // prefixedStdio is a io.ReadWriter which splits written bytes into lines,
diff --git a/metropolis/test/launch/cluster/swtpm.go b/metropolis/test/launch/swtpm.go
similarity index 98%
rename from metropolis/test/launch/cluster/swtpm.go
rename to metropolis/test/launch/swtpm.go
index 0f9b5c5..fa5cb78 100644
--- a/metropolis/test/launch/cluster/swtpm.go
+++ b/metropolis/test/launch/swtpm.go
@@ -1,4 +1,4 @@
-package cluster
+package launch
 
 import (
 	"context"
@@ -12,7 +12,7 @@
 
 	"github.com/bazelbuild/rules_go/go/runfiles"
 
-	"source.monogon.dev/metropolis/test/launch"
+	"source.monogon.dev/osbase/test/launch"
 )
 
 // A TPMFactory manufactures virtual TPMs using swtpm.
diff --git a/metropolis/test/localregistry/BUILD.bazel b/metropolis/test/localregistry/BUILD.bazel
new file mode 100644
index 0000000..548960a
--- /dev/null
+++ b/metropolis/test/localregistry/BUILD.bazel
@@ -0,0 +1,19 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "localregistry",
+    srcs = ["localregistry.go"],
+    importpath = "source.monogon.dev/metropolis/test/localregistry",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//metropolis/test/localregistry/spec",
+        "@com_github_docker_distribution//:distribution",
+        "@com_github_docker_distribution//manifest/manifestlist",
+        "@com_github_docker_distribution//manifest/ocischema",
+        "@com_github_docker_distribution//manifest/schema2",
+        "@com_github_docker_distribution//reference",
+        "@com_github_opencontainers_go_digest//:go-digest",
+        "@io_bazel_rules_go//go/runfiles:go_default_library",
+        "@org_golang_google_protobuf//encoding/prototext",
+    ],
+)
diff --git a/metropolis/test/localregistry/def.bzl b/metropolis/test/localregistry/def.bzl
new file mode 100644
index 0000000..c5fc560
--- /dev/null
+++ b/metropolis/test/localregistry/def.bzl
@@ -0,0 +1,35 @@
+#load("@io_bazel_rules_docker//container:providers.bzl", "ImageInfo")
+
+def _localregistry_manifest_impl(ctx):
+    manifest_out = ctx.actions.declare_file(ctx.label.name+".prototxt")
+
+    images = []
+    referenced = [manifest_out]
+    for i in ctx.attr.images:
+        image_file = i.files.to_list()[0]
+        image = struct(
+            name = i.label.package + "/" + i.label.name,
+            path = image_file.short_path,
+        )
+        referenced.append(image_file)
+        images.append(image)
+
+    ctx.actions.write(manifest_out, proto.encode_text(struct(images = images)))
+    return [DefaultInfo(runfiles = ctx.runfiles(files = referenced), files = depset([manifest_out]))]
+
+
+localregistry_manifest = rule(
+    implementation = _localregistry_manifest_impl,
+    doc = """
+        Builds a manifest for serving images directly from the build files.
+    """,
+    attrs = {
+        "images": attr.label_list(
+            mandatory = True,
+            doc = """
+                List of images to be served from the local registry.
+            """,
+           providers = [],
+        ),
+    },
+)
diff --git a/metropolis/test/localregistry/localregistry.go b/metropolis/test/localregistry/localregistry.go
new file mode 100644
index 0000000..120eb61
--- /dev/null
+++ b/metropolis/test/localregistry/localregistry.go
@@ -0,0 +1,172 @@
+// Package localregistry implements a read-only OCI Distribution / Docker
+// V2 container image registry backed by local layers.
+package localregistry
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"io"
+	"log"
+	"net/http"
+	"os"
+	"path/filepath"
+	"regexp"
+	"strconv"
+
+	"github.com/bazelbuild/rules_go/go/runfiles"
+	"github.com/docker/distribution"
+	"github.com/docker/distribution/manifest/manifestlist"
+	"github.com/docker/distribution/manifest/ocischema"
+	"github.com/docker/distribution/manifest/schema2"
+	"github.com/docker/distribution/reference"
+	"github.com/opencontainers/go-digest"
+	"google.golang.org/protobuf/encoding/prototext"
+
+	"source.monogon.dev/metropolis/test/localregistry/spec"
+)
+
+type Server struct {
+	manifests map[string][]byte
+	blobs     map[digest.Digest]blobMeta
+}
+
+type blobMeta struct {
+	filePath      string
+	mediaType     string
+	contentLength int64
+}
+
+func manifestDescriptorFromBazel(image *spec.Image) (manifestlist.ManifestDescriptor, error) {
+	indexPath, err := runfiles.Rlocation(filepath.Join("_main", image.Path, "index.json"))
+	if err != nil {
+		return manifestlist.ManifestDescriptor{}, fmt.Errorf("while locating manifest list file: %w", err)
+	}
+
+	manifestListRaw, err := os.ReadFile(indexPath)
+	if err != nil {
+		return manifestlist.ManifestDescriptor{}, fmt.Errorf("while opening manifest list file: %w", err)
+	}
+
+	var imageManifestList manifestlist.ManifestList
+	if err := json.Unmarshal(manifestListRaw, &imageManifestList); err != nil {
+		return manifestlist.ManifestDescriptor{}, fmt.Errorf("while unmarshaling manifest list for %q: %w", image.Name, err)
+	}
+
+	if len(imageManifestList.Manifests) != 1 {
+		return manifestlist.ManifestDescriptor{}, fmt.Errorf("unexpected manifest list length > 1")
+	}
+
+	return imageManifestList.Manifests[0], nil
+}
+
+func manifestFromBazel(s *Server, image *spec.Image, md manifestlist.ManifestDescriptor) (ocischema.Manifest, error) {
+	manifestPath, err := runfiles.Rlocation(filepath.Join("_main", image.Path, "blobs", md.Digest.Algorithm().String(), md.Digest.Hex()))
+	if err != nil {
+		return ocischema.Manifest{}, fmt.Errorf("while locating manifest file: %w", err)
+	}
+	manifestRaw, err := os.ReadFile(manifestPath)
+	if err != nil {
+		return ocischema.Manifest{}, fmt.Errorf("while opening manifest file: %w", err)
+	}
+
+	var imageManifest ocischema.Manifest
+	if err := json.Unmarshal(manifestRaw, &imageManifest); err != nil {
+		return ocischema.Manifest{}, fmt.Errorf("while unmarshaling manifest for %q: %w", image.Name, err)
+	}
+
+	// For Digest lookups
+	s.manifests[image.Name] = manifestRaw
+	s.manifests[md.Digest.String()] = manifestRaw
+
+	return imageManifest, nil
+}
+
+func addBazelBlobFromDescriptor(s *Server, image *spec.Image, dd distribution.Descriptor) error {
+	path, err := runfiles.Rlocation(filepath.Join("_main", image.Path, "blobs", dd.Digest.Algorithm().String(), dd.Digest.Hex()))
+	if err != nil {
+		return fmt.Errorf("while locating blob: %w", err)
+	}
+	s.blobs[dd.Digest] = blobMeta{filePath: path, mediaType: dd.MediaType, contentLength: dd.Size}
+	return nil
+}
+
+func FromBazelManifest(mb []byte) (*Server, error) {
+	var bazelManifest spec.Manifest
+	if err := prototext.Unmarshal(mb, &bazelManifest); err != nil {
+		log.Fatalf("failed to parse manifest: %v", err)
+	}
+	s := Server{
+		manifests: make(map[string][]byte),
+		blobs:     make(map[digest.Digest]blobMeta),
+	}
+	for _, i := range bazelManifest.Images {
+		md, err := manifestDescriptorFromBazel(i)
+		if err != nil {
+			return nil, err
+		}
+
+		if err := addBazelBlobFromDescriptor(&s, i, md.Descriptor); err != nil {
+			return nil, err
+		}
+
+		m, err := manifestFromBazel(&s, i, md)
+		if err != nil {
+			return nil, err
+		}
+
+		if err := addBazelBlobFromDescriptor(&s, i, m.Config); err != nil {
+			return nil, err
+		}
+		for _, l := range m.Layers {
+			if err := addBazelBlobFromDescriptor(&s, i, l); err != nil {
+				return nil, err
+			}
+		}
+	}
+	return &s, nil
+}
+
+var (
+	versionCheckEp = regexp.MustCompile(`^/v2/$`)
+	manifestEp     = regexp.MustCompile("^/v2/(" + reference.NameRegexp.String() + ")/manifests/(" + reference.TagRegexp.String() + "|" + digest.DigestRegexp.String() + ")$")
+	blobEp         = regexp.MustCompile("^/v2/(" + reference.NameRegexp.String() + ")/blobs/(" + digest.DigestRegexp.String() + ")$")
+)
+
+func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+	if req.Method != http.MethodGet && req.Method != http.MethodHead {
+		w.WriteHeader(http.StatusMethodNotAllowed)
+		fmt.Fprintf(w, "Registry is read-only, only GET and HEAD are allowed\n")
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	if versionCheckEp.MatchString(req.URL.Path) {
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, "{}")
+		return
+	} else if matches := manifestEp.FindStringSubmatch(req.URL.Path); len(matches) > 0 {
+		name := matches[1]
+		manifest, ok := s.manifests[name]
+		if !ok {
+			w.WriteHeader(http.StatusNotFound)
+			fmt.Fprintf(w, "Image not found")
+			return
+		}
+		w.Header().Set("Content-Type", schema2.MediaTypeManifest)
+		w.Header().Set("Content-Length", strconv.FormatInt(int64(len(manifest)), 10))
+		w.WriteHeader(http.StatusOK)
+		io.Copy(w, bytes.NewReader(manifest))
+	} else if matches := blobEp.FindStringSubmatch(req.URL.Path); len(matches) > 0 {
+		bm, ok := s.blobs[digest.Digest(matches[2])]
+		if !ok {
+			w.WriteHeader(http.StatusNotFound)
+			fmt.Fprintf(w, "Blob not found")
+			return
+		}
+		w.Header().Set("Content-Type", bm.mediaType)
+		w.Header().Set("Content-Length", strconv.FormatInt(bm.contentLength, 10))
+		http.ServeFile(w, req, bm.filePath)
+	} else {
+		w.WriteHeader(http.StatusNotFound)
+	}
+}
diff --git a/metropolis/test/localregistry/spec/BUILD.bazel b/metropolis/test/localregistry/spec/BUILD.bazel
new file mode 100644
index 0000000..71253d1
--- /dev/null
+++ b/metropolis/test/localregistry/spec/BUILD.bazel
@@ -0,0 +1,23 @@
+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 = ["manifest.proto"],
+    visibility = ["//visibility:public"],
+)
+
+go_proto_library(
+    name = "spec_go_proto",
+    importpath = "source.monogon.dev/metropolis/test/localregistry/spec",
+    proto = ":spec_proto",
+    visibility = ["//visibility:public"],
+)
+
+go_library(
+    name = "spec",
+    embed = [":spec_go_proto"],
+    importpath = "source.monogon.dev/metropolis/test/localregistry/spec",
+    visibility = ["//visibility:public"],
+)
diff --git a/metropolis/test/localregistry/spec/gomod-generated-placeholder.go b/metropolis/test/localregistry/spec/gomod-generated-placeholder.go
new file mode 100644
index 0000000..f09cd57
--- /dev/null
+++ b/metropolis/test/localregistry/spec/gomod-generated-placeholder.go
@@ -0,0 +1 @@
+package spec
diff --git a/metropolis/test/localregistry/spec/manifest.proto b/metropolis/test/localregistry/spec/manifest.proto
new file mode 100644
index 0000000..bb53581
--- /dev/null
+++ b/metropolis/test/localregistry/spec/manifest.proto
@@ -0,0 +1,19 @@
+syntax = "proto3";
+
+package monogon.metropolis.pkg.localregistry;
+
+option go_package = "source.monogon.dev/metropolis/test/localregistry/spec";
+
+// Single image metadata
+message Image {
+    // Name of the image (no domain or tag, just slash-separated path)
+    string name = 1;
+    // Path to the image
+    string path = 2;
+}
+
+// Main message
+message Manifest {
+    // List of images for the local registry
+    repeated Image images = 1;
+}
\ No newline at end of file
diff --git a/metropolis/test/nanoswitch/BUILD.bazel b/metropolis/test/nanoswitch/BUILD.bazel
index a3163f5..4954480 100644
--- a/metropolis/test/nanoswitch/BUILD.bazel
+++ b/metropolis/test/nanoswitch/BUILD.bazel
@@ -13,10 +13,10 @@
         "//metropolis/node",
         "//metropolis/node/core/network/dhcp4c",
         "//metropolis/node/core/network/dhcp4c/callback",
-        "//metropolis/pkg/logtree",
-        "//metropolis/pkg/socksproxy",
-        "//metropolis/pkg/supervisor",
-        "//metropolis/test/launch",
+        "//osbase/logtree",
+        "//osbase/socksproxy",
+        "//osbase/supervisor",
+        "//osbase/test/launch",
         "@com_github_google_nftables//:nftables",
         "@com_github_google_nftables//expr",
         "@com_github_insomniacslk_dhcp//dhcpv4",
diff --git a/metropolis/test/nanoswitch/nanoswitch.go b/metropolis/test/nanoswitch/nanoswitch.go
index b4ced56..ca1d77d 100644
--- a/metropolis/test/nanoswitch/nanoswitch.go
+++ b/metropolis/test/nanoswitch/nanoswitch.go
@@ -45,9 +45,9 @@
 	common "source.monogon.dev/metropolis/node"
 	"source.monogon.dev/metropolis/node/core/network/dhcp4c"
 	dhcpcb "source.monogon.dev/metropolis/node/core/network/dhcp4c/callback"
-	"source.monogon.dev/metropolis/pkg/logtree"
-	"source.monogon.dev/metropolis/pkg/supervisor"
-	"source.monogon.dev/metropolis/test/launch"
+	"source.monogon.dev/osbase/logtree"
+	"source.monogon.dev/osbase/supervisor"
+	"source.monogon.dev/osbase/test/launch"
 )
 
 var switchIP = net.IP{10, 1, 0, 1}
diff --git a/metropolis/test/nanoswitch/socks.go b/metropolis/test/nanoswitch/socks.go
index b8e7107..1230903 100644
--- a/metropolis/test/nanoswitch/socks.go
+++ b/metropolis/test/nanoswitch/socks.go
@@ -5,13 +5,13 @@
 	"fmt"
 	"net"
 
-	"source.monogon.dev/metropolis/pkg/socksproxy"
-	"source.monogon.dev/metropolis/pkg/supervisor"
+	"source.monogon.dev/osbase/socksproxy"
+	"source.monogon.dev/osbase/supervisor"
 )
 
 // SOCKSPort is the port at which nanoswitch listens for SOCKS conenctions.
 //
-// ONCHANGE(//metropolis/test/launch/cluster:cluster.go): port must be kept in sync
+// ONCHANGE(//metropolis/test/launch:cluster.go): port must be kept in sync
 const SOCKSPort uint16 = 1080
 
 // socksHandler implements a socksproxy.Handler which permits and logs
diff --git a/metropolis/test/swtpm/swtpm_cert/BUILD.bazel b/metropolis/test/swtpm/swtpm_cert/BUILD.bazel
index dce12e6..f0a8050 100644
--- a/metropolis/test/swtpm/swtpm_cert/BUILD.bazel
+++ b/metropolis/test/swtpm/swtpm_cert/BUILD.bazel
@@ -9,7 +9,7 @@
     importpath = "source.monogon.dev/metropolis/test/swtpm/swtpm_cert",
     visibility = ["//visibility:private"],
     deps = [
-        "//metropolis/pkg/pki",
+        "//osbase/pki",
         "@com_github_spf13_pflag//:pflag",
     ],
 )
diff --git a/metropolis/test/swtpm/swtpm_cert/main.go b/metropolis/test/swtpm/swtpm_cert/main.go
index 7b14f3a..8d5663b 100644
--- a/metropolis/test/swtpm/swtpm_cert/main.go
+++ b/metropolis/test/swtpm/swtpm_cert/main.go
@@ -24,7 +24,7 @@
 
 	"github.com/spf13/pflag"
 
-	"source.monogon.dev/metropolis/pkg/pki"
+	"source.monogon.dev/osbase/pki"
 )
 
 func getSignkey() *rsa.PrivateKey {
diff --git a/metropolis/test/util/BUILD.bazel b/metropolis/test/util/BUILD.bazel
index 00790a2..3603daf 100644
--- a/metropolis/test/util/BUILD.bazel
+++ b/metropolis/test/util/BUILD.bazel
@@ -12,10 +12,10 @@
     deps = [
         "//metropolis/node/core/curator/proto/api",
         "//metropolis/node/core/identity",
-        "//metropolis/pkg/event/memory",
-        "//metropolis/pkg/pki",
         "//metropolis/proto/common",
-        "//metropolis/test/launch",
+        "//osbase/event/memory",
+        "//osbase/pki",
+        "//osbase/test/launch",
         "@com_zx2c4_golang_wireguard_wgctrl//wgtypes",
         "@org_golang_google_grpc//:go_default_library",
         "@org_golang_google_grpc//credentials/insecure",
diff --git a/metropolis/test/util/curator.go b/metropolis/test/util/curator.go
index 39b2610..28fa7b1 100644
--- a/metropolis/test/util/curator.go
+++ b/metropolis/test/util/curator.go
@@ -13,7 +13,7 @@
 	apb "source.monogon.dev/metropolis/node/core/curator/proto/api"
 	cpb "source.monogon.dev/metropolis/proto/common"
 
-	"source.monogon.dev/metropolis/pkg/event/memory"
+	"source.monogon.dev/osbase/event/memory"
 )
 
 // TestCurator is a shim Curator implementation that serves pending Watch
diff --git a/metropolis/test/util/rpc.go b/metropolis/test/util/rpc.go
index e4caece..030d8d0 100644
--- a/metropolis/test/util/rpc.go
+++ b/metropolis/test/util/rpc.go
@@ -9,7 +9,7 @@
 	"testing"
 
 	"source.monogon.dev/metropolis/node/core/identity"
-	"source.monogon.dev/metropolis/pkg/pki"
+	"source.monogon.dev/osbase/pki"
 )
 
 // NewEphemeralClusterCredentials creates a set of TLS certificates for use in a
diff --git a/metropolis/test/util/runners.go b/metropolis/test/util/runners.go
index bb7aa91..95de02a 100644
--- a/metropolis/test/util/runners.go
+++ b/metropolis/test/util/runners.go
@@ -9,7 +9,7 @@
 	"testing"
 	"time"
 
-	"source.monogon.dev/metropolis/test/launch"
+	"source.monogon.dev/osbase/test/launch"
 )
 
 // TestEventual creates a new subtest looping the given function until it either