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/osbase/test/ktest/BUILD.bazel b/osbase/test/ktest/BUILD.bazel
new file mode 100644
index 0000000..16612e5
--- /dev/null
+++ b/osbase/test/ktest/BUILD.bazel
@@ -0,0 +1,62 @@
+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/osbase/test/ktest",
+    visibility = ["//visibility:private"],
+    deps = ["//osbase/test/launch"],
+)
+
+go_binary(
+    name = "ktest",
+    embed = [":ktest_lib"],
+    pure = "on",
+    visibility = [
+        "//go/net/psample:__pkg__",
+        "//metropolis:__subpackages__",
+        "//osbase:__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__",
+        "//osbase:__subpackages__",
+    ],
+)
+
+filegroup(
+    name = "test-script",
+    srcs = ["run_ktest.sh"],
+    visibility = [
+        "//go/net/psample:__pkg__",
+        "//metropolis:__subpackages__",
+        "//osbase:__subpackages__",
+    ],
+)
diff --git a/osbase/test/ktest/init/BUILD.bazel b/osbase/test/ktest/init/BUILD.bazel
new file mode 100644
index 0000000..56b262c
--- /dev/null
+++ b/osbase/test/ktest/init/BUILD.bazel
@@ -0,0 +1,18 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
+
+go_library(
+    name = "init_lib",
+    srcs = ["main.go"],
+    importpath = "source.monogon.dev/osbase/test/ktest/init",
+    visibility = ["//visibility:private"],
+    deps = ["@org_golang_x_sys//unix"],
+)
+
+go_binary(
+    name = "init",
+    embed = [":init_lib"],
+    pure = "on",
+    visibility = [
+        "//visibility:public",
+    ],
+)
diff --git a/osbase/test/ktest/init/main.go b/osbase/test/ktest/init/main.go
new file mode 100644
index 0000000..16ffa29
--- /dev/null
+++ b/osbase/test/ktest/init/main.go
@@ -0,0 +1,85 @@
+// 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/osbase/test/ktest/ktest.bzl b/osbase/test/ktest/ktest.bzl
new file mode 100644
index 0000000..a9f5fdd
--- /dev/null
+++ b/osbase/test/ktest/ktest.bzl
@@ -0,0 +1,58 @@
+#  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({
+            "//osbase/test/ktest/init": "/init",
+            tester: "/tester",
+        }, files),
+        files_cc = files_cc,
+        testonly = True,
+    )
+
+    native.sh_test(
+        name = "ktest",
+        args = [
+            "$(location //osbase/test/ktest)",
+            "$(location :test_initramfs)",
+            "$(location //osbase/test/ktest:linux-testing)",
+            cmdline,
+        ],
+        size = "small",
+        srcs = ["//osbase/test/ktest:test-script"],
+        data = [
+            "//osbase/test/ktest",
+            ":test_initramfs",
+            "//osbase/test/ktest:linux-testing",
+        ],
+    )
diff --git a/osbase/test/ktest/main.go b/osbase/test/ktest/main.go
new file mode 100644
index 0000000..7d76bc5
--- /dev/null
+++ b/osbase/test/ktest/main.go
@@ -0,0 +1,76 @@
+// 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/osbase/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/osbase/test/ktest/run_ktest.sh b/osbase/test/ktest/run_ktest.sh
new file mode 100755
index 0000000..02920a1
--- /dev/null
+++ b/osbase/test/ktest/run_ktest.sh
@@ -0,0 +1,2 @@
+#!/bin/bash
+exec "$1" -initrd-path "$2" -kernel-path "$3" -cmdline "$4"
\ No newline at end of file
diff --git a/osbase/test/launch/BUILD.bazel b/osbase/test/launch/BUILD.bazel
new file mode 100644
index 0000000..10be93d
--- /dev/null
+++ b/osbase/test/launch/BUILD.bazel
@@ -0,0 +1,22 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "launch",
+    srcs = [
+        "launch.go",
+        "log.go",
+    ],
+    data = [
+        "@com_github_bonzini_qboot//:qboot-bin",
+    ],
+    importpath = "source.monogon.dev/osbase/test/launch",
+    visibility = [
+        "//metropolis:__subpackages__",
+        "//osbase:__subpackages__",
+    ],
+    deps = [
+        "//osbase/freeport",
+        "@io_bazel_rules_go//go/runfiles:go_default_library",
+        "@org_golang_x_sys//unix",
+    ],
+)
diff --git a/osbase/test/launch/launch.go b/osbase/test/launch/launch.go
new file mode 100644
index 0000000..d030b22
--- /dev/null
+++ b/osbase/test/launch/launch.go
@@ -0,0 +1,335 @@
+// 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/osbase/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/osbase/test/launch/log.go b/osbase/test/launch/log.go
new file mode 100644
index 0000000..2637e24
--- /dev/null
+++ b/osbase/test/launch/log.go
@@ -0,0 +1,28 @@
+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)
+}