Expand launch infrastructure and make dependencies use it

Adds support for launching MicroVMs and networking multiple machines to the launch infrastructure
and its consumers. Also makes use of our own qboot. Also converts ktests to that infra and and fixes
the issue making it succeed if the VM couldn't be started.

Test Plan: E2E tests & ktests still pass

X-Origin-Diff: phab/D571
GitOrigin-RevId: 0f317f6d8a06e4a3da343b4a7ff5c87918401426
diff --git a/core/internal/launch/BUILD.bazel b/core/internal/launch/BUILD.bazel
index 887932b..382c73b 100644
--- a/core/internal/launch/BUILD.bazel
+++ b/core/internal/launch/BUILD.bazel
@@ -6,7 +6,10 @@
     importpath = "git.monogon.dev/source/nexantic.git/core/internal/launch",
     visibility = ["//core:__subpackages__"],
     deps = [
+        "//core/api/api:go_default_library",
         "//core/internal/common:go_default_library",
+        "@com_github_golang_protobuf//proto:go_default_library",
         "@org_golang_google_grpc//:go_default_library",
+        "@org_golang_x_sys//unix:go_default_library",
     ],
 )
diff --git a/core/internal/launch/launch.go b/core/internal/launch/launch.go
index a88e46d..8aa865f 100644
--- a/core/internal/launch/launch.go
+++ b/core/internal/launch/launch.go
@@ -17,7 +17,10 @@
 package launch
 
 import (
+	"bytes"
 	"context"
+	"crypto/rand"
+	"errors"
 	"fmt"
 	"io"
 	"io/ioutil"
@@ -26,10 +29,15 @@
 	"os"
 	"os/exec"
 	"path/filepath"
+	"strconv"
 	"strings"
+	"syscall"
 
+	"github.com/golang/protobuf/proto"
+	"golang.org/x/sys/unix"
 	"google.golang.org/grpc"
 
+	apipb "git.monogon.dev/source/nexantic.git/core/generated/api"
 	"git.monogon.dev/source/nexantic.git/core/internal/common"
 )
 
@@ -50,11 +58,13 @@
 
 type qemuValue map[string][]string
 
-// qemuValueToOption encodes structured data into a QEMU option.
+// toOption encodes structured data into a QEMU option.
 // Example: "test", {"key1": {"val1"}, "key2": {"val2", "val3"}} returns "test,key1=val1,key2=val2,key2=val3"
-func qemuValueToOption(name string, value qemuValue) string {
+func (value qemuValue) toOption(name string) string {
 	var optionValues []string
-	optionValues = append(optionValues, name)
+	if name != "" {
+		optionValues = append(optionValues, name)
+	}
 	for name, values := range value {
 		if len(values) == 0 {
 			optionValues = append(optionValues, name)
@@ -116,12 +126,25 @@
 // Options contains all options that can be passed to Launch()
 type Options struct {
 	// Ports contains the port mapping where to expose the internal ports of the VM to the host. See IdentityPortMap()
-	// and ConflictFreePortMap()
+	// and ConflictFreePortMap(). Ignored when ConnectToSocket is set.
 	Ports PortMap
 
 	// If set to true, reboots are honored. Otherwise all reboots exit the Launch() command. Smalltown generally restarts
 	// on almost all errors, so unless you want to test reboot behavior this should be false.
 	AllowReboot bool
+
+	// By default the Smalltown VM is connected to the Host via SLIRP. If ConnectToSocket is set, it is instead connected
+	// to the given file descriptor/socket. If this is set, all port maps from the Ports option are ignored.
+	// Intended for networking this instance together with others for running  more complex network configurations.
+	ConnectToSocket *os.File
+
+	// 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 *os.File
+
+	// EnrolmentConfig is passed into the VM and subsequently used for bootstrapping if no enrolment config is built-in
+	EnrolmentConfig *apipb.EnrolmentConfig
 }
 
 var requiredPorts = []uint16{common.ConsensusPort, common.NodeServicePort, common.MasterServicePort,
@@ -155,6 +178,21 @@
 	return portMap, nil
 }
 
+// Gets a random EUI-48 Ethernet MAC address
+func generateRandomEthernetMAC() (*net.HardwareAddr, error) {
+	macBuf := make([]byte, 6)
+	_, err := rand.Read(macBuf)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read randomness for MAC: %v", err)
+	}
+
+	// Set U/L bit and clear I/G bit (locally administered individual MAC)
+	// Ref IEEE 802-2014 Section 8.2.2
+	macBuf[0] = (macBuf[0] | 2) & 0xfe
+	mac := net.HardwareAddr(macBuf)
+	return &mac, nil
+}
+
 // Launch launches a Smalltown instance with the given options. The instance runs mostly paravirtualized but with some
 // emulated hardware similar to how a cloud provider might set up its VMs. The disk is fully writable but is run
 // in snapshot mode meaning that changes are not kept beyond a single invocation.
@@ -165,7 +203,7 @@
 	// crashes both swtpm and qemu because we run into UNIX socket length limitations (for legacy reasons 108 chars).
 	tempDir, err := ioutil.TempDir("/tmp", "launch*")
 	if err != nil {
-		return fmt.Errorf("Failed to create temporary directory: %w", err)
+		return fmt.Errorf("failed to create temporary directory: %w", err)
 	}
 	defer os.RemoveAll(tempDir)
 
@@ -173,35 +211,51 @@
 	tpmTargetDir := filepath.Join(tempDir, "tpm")
 	tpmSrcDir := "core/tpm"
 	if err := os.Mkdir(tpmTargetDir, 0644); err != nil {
-		return fmt.Errorf("Failed to create TPM state directory: %w", err)
+		return fmt.Errorf("failed to create TPM state directory: %w", err)
 	}
 	tpmFiles, err := ioutil.ReadDir(tpmSrcDir)
 	if err != nil {
-		return fmt.Errorf("Failed to read TPM directory: %w", err)
+		return fmt.Errorf("failed to read TPM directory: %w", err)
 	}
 	for _, file := range tpmFiles {
 		name := file.Name()
 		if err := copyFile(filepath.Join(tpmSrcDir, name), filepath.Join(tpmTargetDir, name)); err != nil {
-			return fmt.Errorf("Failed to copy TPM directory: %w", err)
+			return fmt.Errorf("failed to copy TPM directory: %w", err)
 		}
 	}
 
-	qemuNetConfig := qemuValue{
-		"id":        {"net0"},
-		"net":       {"10.42.0.0/24"},
-		"dhcpstart": {"10.42.0.10"},
-		"hostfwd":   options.Ports.toQemuForwards(),
+	var qemuNetType string
+	var qemuNetConfig qemuValue
+	if options.ConnectToSocket != nil {
+		qemuNetType = "socket"
+		qemuNetConfig = qemuValue{
+			"id": {"net0"},
+			"fd": {"3"},
+		}
+	} else {
+		qemuNetType = "user"
+		qemuNetConfig = qemuValue{
+			"id":        {"net0"},
+			"net":       {"10.42.0.0/24"},
+			"dhcpstart": {"10.42.0.10"},
+			"hostfwd":   options.Ports.toQemuForwards(),
+		}
 	}
 
 	tpmSocketPath := filepath.Join(tempDir, "tpm-socket")
 
+	mac, err := generateRandomEthernetMAC()
+	if err != nil {
+		return err
+	}
+
 	qemuArgs := []string{"-machine", "q35", "-accel", "kvm", "-nographic", "-nodefaults", "-m", "2048",
 		"-cpu", "host", "-smp", "sockets=1,cpus=1,cores=2,threads=2,maxcpus=4",
 		"-drive", "if=pflash,format=raw,readonly,file=external/edk2/OVMF_CODE.fd",
 		"-drive", "if=pflash,format=raw,snapshot=on,file=external/edk2/OVMF_VARS.fd",
 		"-drive", "if=virtio,format=raw,snapshot=on,cache=unsafe,file=core/smalltown.img",
-		"-netdev", qemuValueToOption("user", qemuNetConfig),
-		"-device", "virtio-net-pci,netdev=net0",
+		"-netdev", qemuNetConfig.toOption(qemuNetType),
+		"-device", "virtio-net-pci,netdev=net0,mac=" + mac.String(),
 		"-chardev", "socket,id=chrtpm,path=" + tpmSocketPath,
 		"-tpmdev", "emulator,id=tpm0,chardev=chrtpm",
 		"-device", "tpm-tis,tpmdev=tpm0",
@@ -212,6 +266,18 @@
 		qemuArgs = append(qemuArgs, "-no-reboot")
 	}
 
+	if options.EnrolmentConfig != nil {
+		enrolmentConfigPath := filepath.Join(tempDir, "enrolment.pb")
+		enrolmentConfigRaw, err := proto.Marshal(options.EnrolmentConfig)
+		if err != nil {
+			return fmt.Errorf("failed to encode enrolment config: %w", err)
+		}
+		if err := ioutil.WriteFile(enrolmentConfigPath, enrolmentConfigRaw, 0644); err != nil {
+			return fmt.Errorf("failed to write enrolment config: %w", err)
+		}
+		qemuArgs = append(qemuArgs, "-fw_cfg", "name=com.nexantic.smalltown/enrolment.pb,file="+enrolmentConfigPath)
+	}
+
 	// Start TPM emulator as a subprocess
 	tpmCtx, tpmCancel := context.WithCancel(ctx)
 	defer tpmCancel()
@@ -227,8 +293,13 @@
 
 	// Start the main qemu binary
 	systemCmd := exec.CommandContext(ctx, "qemu-system-x86_64", qemuArgs...)
-	systemCmd.Stderr = os.Stderr
-	systemCmd.Stdout = os.Stdout
+	if options.ConnectToSocket != nil {
+		systemCmd.ExtraFiles = []*os.File{options.ConnectToSocket}
+	}
+
+	var stdErrBuf bytes.Buffer
+	systemCmd.Stderr = &stdErrBuf
+	systemCmd.Stdout = options.SerialPort
 
 	err = systemCmd.Run()
 
@@ -239,5 +310,167 @@
 	// We still need to call it to avoid creating zombies.
 	_ = tpmEmuCmd.Wait()
 
-	return nil
+	var exerr *exec.ExitError
+	if err != nil && errors.As(err, &exerr) {
+		status := exerr.ProcessState.Sys().(syscall.WaitStatus)
+		if status.Signaled() && status.Signal() == syscall.SIGKILL {
+			// Process was killed externally (most likely by our context being canceled).
+			// This is a normal exit for us, so return nil
+			return nil
+		}
+		exerr.Stderr = stdErrBuf.Bytes()
+		newErr := QEMUError(*exerr)
+		return &newErr
+	}
+	return err
+}
+
+// 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 {
+	// 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 *os.File
+
+	// 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
+
+	// 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
+}
+
+// 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.
+	baseArgs := []string{"-nodefaults", "-no-user-config", "-nographic", "-no-reboot",
+		"-accel", "kvm", "-cpu", "host",
+		// Needed until QEMU updates their bundled qboot version (needs https://github.com/bonzini/qboot/pull/28)
+		"-bios", "external/com_github_bonzini_qboot/bios.bin",
+		"-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()
+		}
+
+		baseArgs = append(baseArgs, "-netdev", qemuNetConfig.toOption(qemuNetType),
+			"-device", "virtio-net-device,netdev=usernet0,mac="+HostInterfaceMAC.String())
+	}
+
+	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...)
+
+	err := cmd.Run()
+	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/core/internal/node/main.go b/core/internal/node/main.go
index f4a138f..2cf88f4 100644
--- a/core/internal/node/main.go
+++ b/core/internal/node/main.go
@@ -29,7 +29,6 @@
 	"errors"
 	"flag"
 	"fmt"
-	"git.monogon.dev/source/nexantic.git/core/internal/containerd"
 	"io/ioutil"
 	"math/big"
 	"net"
@@ -37,21 +36,22 @@
 	"strings"
 	"time"
 
+	"github.com/cenkalti/backoff/v4"
+	"github.com/gogo/protobuf/proto"
+	"go.uber.org/zap"
+	"golang.org/x/sys/unix"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/credentials"
+
 	apipb "git.monogon.dev/source/nexantic.git/core/generated/api"
 	"git.monogon.dev/source/nexantic.git/core/internal/api"
 	"git.monogon.dev/source/nexantic.git/core/internal/common"
 	"git.monogon.dev/source/nexantic.git/core/internal/consensus"
+	"git.monogon.dev/source/nexantic.git/core/internal/containerd"
 	"git.monogon.dev/source/nexantic.git/core/internal/integrity/tpm2"
 	"git.monogon.dev/source/nexantic.git/core/internal/kubernetes"
 	"git.monogon.dev/source/nexantic.git/core/internal/network"
 	"git.monogon.dev/source/nexantic.git/core/internal/storage"
-	"golang.org/x/sys/unix"
-
-	"github.com/cenkalti/backoff/v4"
-	"github.com/gogo/protobuf/proto"
-	"go.uber.org/zap"
-	"google.golang.org/grpc"
-	"google.golang.org/grpc/credentials"
 )
 
 var (
@@ -152,6 +152,9 @@
 		s.logger.Panic("ESP configuration partition not available", zap.Error(err))
 	}
 	enrolmentConfigRaw, err := ioutil.ReadFile(enrolmentPath)
+	if os.IsNotExist(err) {
+		enrolmentConfigRaw, err = ioutil.ReadFile("/sys/firmware/qemu_fw_cfg/by_name/com.nexantic.smalltown/enrolment.pb/raw")
+	}
 	if err == nil {
 		// We have an enrolment file, let's check its contents
 		var enrolmentConfig apipb.EnrolmentConfig
diff --git a/core/tests/e2e/main_test.go b/core/tests/e2e/main_test.go
index 5de2654..3ab03df 100644
--- a/core/tests/e2e/main_test.go
+++ b/core/tests/e2e/main_test.go
@@ -25,6 +25,7 @@
 	"net/http"
 	_ "net/http"
 	_ "net/http/pprof"
+	"os"
 	"testing"
 	"time"
 
@@ -80,7 +81,7 @@
 	procExit := make(chan struct{})
 
 	go func() {
-		if err := launch.Launch(ctx, launch.Options{Ports: portMap}); err != nil {
+		if err := launch.Launch(ctx, launch.Options{Ports: portMap, SerialPort: os.Stdout}); err != nil {
 			panic(err)
 		}
 		close(procExit)
diff --git a/core/tools/ktest/BUILD b/core/tools/ktest/BUILD
index e139a18..a7f1119 100644
--- a/core/tools/ktest/BUILD
+++ b/core/tools/ktest/BUILD
@@ -6,6 +6,7 @@
     srcs = ["main.go"],
     importpath = "git.monogon.dev/source/nexantic.git/core/tools/ktest",
     visibility = ["//visibility:private"],
+    deps = ["//core/internal/launch:go_default_library"],
 )
 
 go_binary(
diff --git a/core/tools/ktest/ktest.bzl b/core/tools/ktest/ktest.bzl
index 03a7d5c..aef749b 100644
--- a/core/tools/ktest/ktest.bzl
+++ b/core/tools/ktest/ktest.bzl
@@ -57,5 +57,6 @@
             "//core/tools/ktest",
             ":test_initramfs",
             "//core/tools/ktest:linux-testing",
+            "@com_github_bonzini_qboot//:qboot-bin",
         ],
-    )
\ No newline at end of file
+    )
diff --git a/core/tools/ktest/main.go b/core/tools/ktest/main.go
index 67ad21a..3541f51 100644
--- a/core/tools/ktest/main.go
+++ b/core/tools/ktest/main.go
@@ -19,15 +19,14 @@
 package main
 
 import (
-	"crypto/rand"
+	"context"
 	"flag"
-	"fmt"
 	"io"
 	"log"
-	"net"
 	"os"
-	"os/exec"
-	"path/filepath"
+	"time"
+
+	"git.monogon.dev/source/nexantic.git/core/internal/launch"
 )
 
 var (
@@ -39,63 +38,38 @@
 func main() {
 	flag.Parse()
 
-	// Create a temporary socket for passing data (currently only exit code)
-	// TODO: Land https://patchwork.ozlabs.org/project/qemu-devel/patch/1357671226-11334-1-git-send-email-alexander_barabash@mentor.com/
-	tmpDir := os.TempDir()
-	token := make([]byte, 16)
-	if _, err := io.ReadFull(rand.Reader, token); err != nil {
-		log.Fatal(err)
-	}
-
-	socketPath := filepath.Join(tmpDir, fmt.Sprintf("qemu-io-%x", token))
-	l, err := net.Listen("unix", socketPath)
+	hostFeedbackConn, vmFeedbackConn, err := launch.NewSocketPair()
 	if err != nil {
-		log.Fatal(err)
+		log.Fatalf("Failed to create socket pair: %v", err)
 	}
-	defer l.Close()
-	defer os.Remove(socketPath)
-
-	// Start a QEMU microvm (https://github.com/qemu/qemu/blob/master/docs/microvm.rst) with only
-	// a RNG and two character devices (one for console, one for OOB communication) attached.
-	cmd := exec.Command("qemu-system-x86_64", "-nodefaults", "-no-user-config", "-nographic", "-no-reboot",
-		"-accel", "kvm", "-cpu", "host",
-		"-M", "microvm,x-option-roms=off,pic=off,pit=off,rtc=off,isa-serial=off",
-		"-kernel", *kernelPath,
-		"-append", "reboot=t console=hvc0 quiet "+*cmdline,
-		"-initrd", *initrdPath,
-		"-device", "virtio-rng-device,max-bytes=1024,period=1000",
-		"-device", "virtio-serial-device,max_ports=2",
-		"-chardev", "stdio,id=con0", "-device", "virtconsole,chardev=con0",
-		"-chardev", "socket,id=io,path="+socketPath, "-device", "virtserialport,chardev=io",
-	)
-
-	cmd.Stdout = os.Stdout
-	cmd.Stderr = os.Stderr
 
 	exitCodeChan := make(chan uint8, 1)
 
 	go func() {
-		conn, err := l.Accept()
-		if err != nil {
-			log.Fatal(err)
-		}
-		defer conn.Close()
+		defer hostFeedbackConn.Close()
 
 		returnCode := make([]byte, 1)
-		if _, err := conn.Read(returnCode); err != nil && err != io.EOF {
+		if _, err := io.ReadFull(hostFeedbackConn, returnCode); err != nil {
 			log.Fatalf("Failed to read socket: %v", err)
 		}
 		exitCodeChan <- returnCode[0]
 	}()
 
-	if err := cmd.Run(); err != nil {
-		log.Fatalf("Failed to run QEMU: %v", err)
+	if err := launch.RunMicroVM(context.Background(), &launch.MicroVMOptions{
+		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))
-	default:
-		log.Printf("Failed to get an error code back")
-		os.Exit(1)
+	case <-time.After(1 * time.Second):
+		log.Fatal("Failed to get an error code back (test runtime probably crashed)")
 	}
 }
diff --git a/core/tools/ktestinit/main.go b/core/tools/ktestinit/main.go
index 9eb2342..f6049db 100644
--- a/core/tools/ktestinit/main.go
+++ b/core/tools/ktestinit/main.go
@@ -74,7 +74,10 @@
 		} else if err != nil {
 			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)
 }