blob: 8aa865fd326e375bea3a83466b182e704d71b632 [file] [log] [blame]
Lorenz Brunfc5dbc62020-05-28 12:18:07 +02001// Copyright 2020 The Monogon Project Authors.
2//
3// SPDX-License-Identifier: Apache-2.0
4//
5// Licensed under the Apache License, Version 2.0 (the "License");
6// you may not use this file except in compliance with the License.
7// You may obtain a copy of the License at
8//
9// http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing, software
12// distributed under the License is distributed on an "AS IS" BASIS,
13// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14// See the License for the specific language governing permissions and
15// limitations under the License.
16
17package launch
18
19import (
Lorenz Brun3ff5af32020-06-24 16:34:11 +020020 "bytes"
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020021 "context"
Lorenz Brun3ff5af32020-06-24 16:34:11 +020022 "crypto/rand"
23 "errors"
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020024 "fmt"
25 "io"
26 "io/ioutil"
Leopold Schabela013ffa2020-06-03 15:09:32 +020027 "log"
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020028 "net"
29 "os"
30 "os/exec"
31 "path/filepath"
Lorenz Brun3ff5af32020-06-24 16:34:11 +020032 "strconv"
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020033 "strings"
Lorenz Brun3ff5af32020-06-24 16:34:11 +020034 "syscall"
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020035
Lorenz Brun3ff5af32020-06-24 16:34:11 +020036 "github.com/golang/protobuf/proto"
37 "golang.org/x/sys/unix"
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020038 "google.golang.org/grpc"
39
Lorenz Brun3ff5af32020-06-24 16:34:11 +020040 apipb "git.monogon.dev/source/nexantic.git/core/generated/api"
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020041 "git.monogon.dev/source/nexantic.git/core/internal/common"
42)
43
44// This is more of a best-effort solution and not guaranteed to give us unused ports (since we're not immediately using
45// them), but AFAIK qemu cannot dynamically select hostfwd ports
46func getFreePort() (uint16, io.Closer, error) {
47 addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
48 if err != nil {
49 return 0, nil, err
50 }
51
52 l, err := net.ListenTCP("tcp", addr)
53 if err != nil {
54 return 0, nil, err
55 }
56 return uint16(l.Addr().(*net.TCPAddr).Port), l, nil
57}
58
59type qemuValue map[string][]string
60
Lorenz Brun3ff5af32020-06-24 16:34:11 +020061// toOption encodes structured data into a QEMU option.
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020062// Example: "test", {"key1": {"val1"}, "key2": {"val2", "val3"}} returns "test,key1=val1,key2=val2,key2=val3"
Lorenz Brun3ff5af32020-06-24 16:34:11 +020063func (value qemuValue) toOption(name string) string {
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020064 var optionValues []string
Lorenz Brun3ff5af32020-06-24 16:34:11 +020065 if name != "" {
66 optionValues = append(optionValues, name)
67 }
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020068 for name, values := range value {
69 if len(values) == 0 {
70 optionValues = append(optionValues, name)
71 }
72 for _, val := range values {
73 optionValues = append(optionValues, fmt.Sprintf("%v=%v", name, val))
74 }
75 }
76 return strings.Join(optionValues, ",")
77}
78
79func copyFile(src, dst string) error {
80 in, err := os.Open(src)
81 if err != nil {
82 return err
83 }
84 defer in.Close()
85
86 out, err := os.Create(dst)
87 if err != nil {
88 return err
89 }
90 defer out.Close()
91
92 _, err = io.Copy(out, in)
93 if err != nil {
94 return err
95 }
96 return out.Close()
97}
98
99// PortMap represents where VM ports are mapped to on the host. It maps from the VM port number to the host port number.
100type PortMap map[uint16]uint16
101
102// toQemuForwards generates QEMU hostfwd values (https://qemu.weilnetz.de/doc/qemu-doc.html#:~:text=hostfwd=) for all
103// mapped ports.
104func (p PortMap) toQemuForwards() []string {
105 var hostfwdOptions []string
106 for vmPort, hostPort := range p {
107 hostfwdOptions = append(hostfwdOptions, fmt.Sprintf("tcp::%v-:%v", hostPort, vmPort))
108 }
109 return hostfwdOptions
110}
111
112// DialGRPC creates a gRPC client for a VM port that's forwarded/mapped to the host. The given port is automatically
113// resolved to the host-mapped port.
114func (p PortMap) DialGRPC(port uint16, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
115 mappedPort, ok := p[port]
116 if !ok {
117 return nil, fmt.Errorf("cannot dial port: port %v is not mapped/forwarded", port)
118 }
119 grpcClient, err := grpc.Dial(fmt.Sprintf("localhost:%v", mappedPort), opts...)
120 if err != nil {
121 return nil, fmt.Errorf("failed to dial port %v: %w", port, err)
122 }
123 return grpcClient, nil
124}
125
126// Options contains all options that can be passed to Launch()
127type Options struct {
128 // Ports contains the port mapping where to expose the internal ports of the VM to the host. See IdentityPortMap()
Lorenz Brun3ff5af32020-06-24 16:34:11 +0200129 // and ConflictFreePortMap(). Ignored when ConnectToSocket is set.
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200130 Ports PortMap
131
132 // If set to true, reboots are honored. Otherwise all reboots exit the Launch() command. Smalltown generally restarts
133 // on almost all errors, so unless you want to test reboot behavior this should be false.
134 AllowReboot bool
Lorenz Brun3ff5af32020-06-24 16:34:11 +0200135
136 // By default the Smalltown VM is connected to the Host via SLIRP. If ConnectToSocket is set, it is instead connected
137 // to the given file descriptor/socket. If this is set, all port maps from the Ports option are ignored.
138 // Intended for networking this instance together with others for running more complex network configurations.
139 ConnectToSocket *os.File
140
141 // SerialPort is a File(descriptor) over which you can communicate with the serial port of the machine
142 // It can be set to an existing file descriptor (like os.Stdout/os.Stderr) or you can use NewSocketPair() to get one
143 // end to talk to from Go.
144 SerialPort *os.File
145
146 // EnrolmentConfig is passed into the VM and subsequently used for bootstrapping if no enrolment config is built-in
147 EnrolmentConfig *apipb.EnrolmentConfig
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200148}
149
150var requiredPorts = []uint16{common.ConsensusPort, common.NodeServicePort, common.MasterServicePort,
151 common.ExternalServicePort, common.DebugServicePort, common.KubernetesAPIPort}
152
153// IdentityPortMap returns a port map where each VM port is mapped onto itself on the host. This is mainly useful
154// for development against Smalltown. The dbg command requires this mapping.
155func IdentityPortMap() PortMap {
156 portMap := make(PortMap)
157 for _, port := range requiredPorts {
158 portMap[port] = port
159 }
160 return portMap
161}
162
163// ConflictFreePortMap returns a port map where each VM port is mapped onto a random free port on the host. This is
164// intended for automated testing where multiple instances of Smalltown might be running. Please call this function for
165// each Launch command separately and as close to it as possible since it cannot guarantee that the ports will remain
166// free.
167func ConflictFreePortMap() (PortMap, error) {
168 portMap := make(PortMap)
169 for _, port := range requiredPorts {
170 mappedPort, listenCloser, err := getFreePort()
171 if err != nil {
172 return portMap, fmt.Errorf("failed to get free host port: %w", err)
173 }
174 // Defer closing of the listening port until the function is done and all ports are allocated
175 defer listenCloser.Close()
176 portMap[port] = mappedPort
177 }
178 return portMap, nil
179}
180
Lorenz Brun3ff5af32020-06-24 16:34:11 +0200181// Gets a random EUI-48 Ethernet MAC address
182func generateRandomEthernetMAC() (*net.HardwareAddr, error) {
183 macBuf := make([]byte, 6)
184 _, err := rand.Read(macBuf)
185 if err != nil {
186 return nil, fmt.Errorf("failed to read randomness for MAC: %v", err)
187 }
188
189 // Set U/L bit and clear I/G bit (locally administered individual MAC)
190 // Ref IEEE 802-2014 Section 8.2.2
191 macBuf[0] = (macBuf[0] | 2) & 0xfe
192 mac := net.HardwareAddr(macBuf)
193 return &mac, nil
194}
195
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200196// Launch launches a Smalltown instance with the given options. The instance runs mostly paravirtualized but with some
197// emulated hardware similar to how a cloud provider might set up its VMs. The disk is fully writable but is run
198// in snapshot mode meaning that changes are not kept beyond a single invocation.
199func Launch(ctx context.Context, options Options) error {
200 // Pin temp directory to /tmp until we can use abstract socket namespace in QEMU (next release after 5.0,
201 // https://github.com/qemu/qemu/commit/776b97d3605ed0fc94443048fdf988c7725e38a9). swtpm accepts already-open FDs
202 // so we can pass in an abstract socket namespace FD that we open and pass the name of it to QEMU. Not pinning this
203 // crashes both swtpm and qemu because we run into UNIX socket length limitations (for legacy reasons 108 chars).
204 tempDir, err := ioutil.TempDir("/tmp", "launch*")
205 if err != nil {
Lorenz Brun3ff5af32020-06-24 16:34:11 +0200206 return fmt.Errorf("failed to create temporary directory: %w", err)
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200207 }
208 defer os.RemoveAll(tempDir)
209
210 // Copy TPM state into a temporary directory since it's being modified by the emulator
211 tpmTargetDir := filepath.Join(tempDir, "tpm")
212 tpmSrcDir := "core/tpm"
213 if err := os.Mkdir(tpmTargetDir, 0644); err != nil {
Lorenz Brun3ff5af32020-06-24 16:34:11 +0200214 return fmt.Errorf("failed to create TPM state directory: %w", err)
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200215 }
216 tpmFiles, err := ioutil.ReadDir(tpmSrcDir)
217 if err != nil {
Lorenz Brun3ff5af32020-06-24 16:34:11 +0200218 return fmt.Errorf("failed to read TPM directory: %w", err)
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200219 }
220 for _, file := range tpmFiles {
221 name := file.Name()
222 if err := copyFile(filepath.Join(tpmSrcDir, name), filepath.Join(tpmTargetDir, name)); err != nil {
Lorenz Brun3ff5af32020-06-24 16:34:11 +0200223 return fmt.Errorf("failed to copy TPM directory: %w", err)
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200224 }
225 }
226
Lorenz Brun3ff5af32020-06-24 16:34:11 +0200227 var qemuNetType string
228 var qemuNetConfig qemuValue
229 if options.ConnectToSocket != nil {
230 qemuNetType = "socket"
231 qemuNetConfig = qemuValue{
232 "id": {"net0"},
233 "fd": {"3"},
234 }
235 } else {
236 qemuNetType = "user"
237 qemuNetConfig = qemuValue{
238 "id": {"net0"},
239 "net": {"10.42.0.0/24"},
240 "dhcpstart": {"10.42.0.10"},
241 "hostfwd": options.Ports.toQemuForwards(),
242 }
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200243 }
244
245 tpmSocketPath := filepath.Join(tempDir, "tpm-socket")
246
Lorenz Brun3ff5af32020-06-24 16:34:11 +0200247 mac, err := generateRandomEthernetMAC()
248 if err != nil {
249 return err
250 }
251
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200252 qemuArgs := []string{"-machine", "q35", "-accel", "kvm", "-nographic", "-nodefaults", "-m", "2048",
253 "-cpu", "host", "-smp", "sockets=1,cpus=1,cores=2,threads=2,maxcpus=4",
254 "-drive", "if=pflash,format=raw,readonly,file=external/edk2/OVMF_CODE.fd",
255 "-drive", "if=pflash,format=raw,snapshot=on,file=external/edk2/OVMF_VARS.fd",
256 "-drive", "if=virtio,format=raw,snapshot=on,cache=unsafe,file=core/smalltown.img",
Lorenz Brun3ff5af32020-06-24 16:34:11 +0200257 "-netdev", qemuNetConfig.toOption(qemuNetType),
258 "-device", "virtio-net-pci,netdev=net0,mac=" + mac.String(),
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200259 "-chardev", "socket,id=chrtpm,path=" + tpmSocketPath,
260 "-tpmdev", "emulator,id=tpm0,chardev=chrtpm",
261 "-device", "tpm-tis,tpmdev=tpm0",
262 "-device", "virtio-rng-pci",
263 "-serial", "stdio"}
264
265 if !options.AllowReboot {
266 qemuArgs = append(qemuArgs, "-no-reboot")
267 }
268
Lorenz Brun3ff5af32020-06-24 16:34:11 +0200269 if options.EnrolmentConfig != nil {
270 enrolmentConfigPath := filepath.Join(tempDir, "enrolment.pb")
271 enrolmentConfigRaw, err := proto.Marshal(options.EnrolmentConfig)
272 if err != nil {
273 return fmt.Errorf("failed to encode enrolment config: %w", err)
274 }
275 if err := ioutil.WriteFile(enrolmentConfigPath, enrolmentConfigRaw, 0644); err != nil {
276 return fmt.Errorf("failed to write enrolment config: %w", err)
277 }
278 qemuArgs = append(qemuArgs, "-fw_cfg", "name=com.nexantic.smalltown/enrolment.pb,file="+enrolmentConfigPath)
279 }
280
Leopold Schabela013ffa2020-06-03 15:09:32 +0200281 // Start TPM emulator as a subprocess
282 tpmCtx, tpmCancel := context.WithCancel(ctx)
283 defer tpmCancel()
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200284
Leopold Schabela013ffa2020-06-03 15:09:32 +0200285 tpmEmuCmd := exec.CommandContext(tpmCtx, "swtpm", "socket", "--tpm2", "--tpmstate", "dir="+tpmTargetDir, "--ctrl", "type=unixio,path="+tpmSocketPath)
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200286 tpmEmuCmd.Stderr = os.Stderr
287 tpmEmuCmd.Stdout = os.Stdout
Leopold Schabela013ffa2020-06-03 15:09:32 +0200288
289 err = tpmEmuCmd.Start()
290 if err != nil {
291 return fmt.Errorf("failed to start TPM emulator: %w", err)
292 }
293
294 // Start the main qemu binary
295 systemCmd := exec.CommandContext(ctx, "qemu-system-x86_64", qemuArgs...)
Lorenz Brun3ff5af32020-06-24 16:34:11 +0200296 if options.ConnectToSocket != nil {
297 systemCmd.ExtraFiles = []*os.File{options.ConnectToSocket}
298 }
299
300 var stdErrBuf bytes.Buffer
301 systemCmd.Stderr = &stdErrBuf
302 systemCmd.Stdout = options.SerialPort
Leopold Schabela013ffa2020-06-03 15:09:32 +0200303
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200304 err = systemCmd.Run()
Leopold Schabela013ffa2020-06-03 15:09:32 +0200305
306 // Stop TPM emulator and wait for it to exit to properly reap the child process
307 tpmCancel()
308 log.Print("Waiting for TPM emulator to exit")
309 // Wait returns a SIGKILL error because we just cancelled its context.
310 // We still need to call it to avoid creating zombies.
311 _ = tpmEmuCmd.Wait()
312
Lorenz Brun3ff5af32020-06-24 16:34:11 +0200313 var exerr *exec.ExitError
314 if err != nil && errors.As(err, &exerr) {
315 status := exerr.ProcessState.Sys().(syscall.WaitStatus)
316 if status.Signaled() && status.Signal() == syscall.SIGKILL {
317 // Process was killed externally (most likely by our context being canceled).
318 // This is a normal exit for us, so return nil
319 return nil
320 }
321 exerr.Stderr = stdErrBuf.Bytes()
322 newErr := QEMUError(*exerr)
323 return &newErr
324 }
325 return err
326}
327
328// NewSocketPair creates a new socket pair. By connecting both ends to different instances you can connect them
329// with a virtual "network cable". The ends can be passed into the ConnectToSocket option.
330func NewSocketPair() (*os.File, *os.File, error) {
331 fds, err := unix.Socketpair(unix.AF_UNIX, syscall.SOCK_STREAM, 0)
332 if err != nil {
333 return nil, nil, fmt.Errorf("failed to call socketpair: %w", err)
334 }
335
336 fd1 := os.NewFile(uintptr(fds[0]), "network0")
337 fd2 := os.NewFile(uintptr(fds[1]), "network1")
338 return fd1, fd2, nil
339}
340
341// HostInterfaceMAC is the MAC address the host SLIRP network interface has if it is not disabled (see
342// DisableHostNetworkInterface in MicroVMOptions)
343var HostInterfaceMAC = net.HardwareAddr{0x02, 0x72, 0x82, 0xbf, 0xc3, 0x56}
344
345// MicroVMOptions contains all options to start a MicroVM
346type MicroVMOptions struct {
347 // Path to the ELF kernel binary
348 KernelPath string
349
350 // Path to the Initramfs
351 InitramfsPath string
352
353 // Cmdline contains additional kernel commandline options
354 Cmdline string
355
356 // SerialPort is a File(descriptor) over which you can communicate with the serial port of the machine
357 // It can be set to an existing file descriptor (like os.Stdout/os.Stderr) or you can use NewSocketPair() to get one
358 // end to talk to from Go.
359 SerialPort *os.File
360
361 // ExtraChardevs can be used similar to SerialPort, but can contain an arbitrary number of additional serial ports
362 ExtraChardevs []*os.File
363
364 // ExtraNetworkInterfaces can contain an arbitrary number of file descriptors which are mapped into the VM as virtio
365 // network interfaces. The first interface is always a SLIRP-backed interface for communicating with the host.
366 ExtraNetworkInterfaces []*os.File
367
368 // PortMap contains ports that are mapped to the host through the built-in SLIRP network interface.
369 PortMap PortMap
370
371 // DisableHostNetworkInterface disables the SLIRP-backed host network interface that is normally the first network
372 // interface. If this is set PortMap is ignored. Mostly useful for speeding up QEMU's startup time for tests.
373 DisableHostNetworkInterface bool
374}
375
376// RunMicroVM launches a tiny VM mostly intended for testing. Very quick to boot (<40ms).
377func RunMicroVM(ctx context.Context, opts *MicroVMOptions) error {
378 // Generate options for all the file descriptors we'll be passing as virtio "serial ports"
379 var extraArgs []string
380 for idx, _ := range opts.ExtraChardevs {
381 idxStr := strconv.Itoa(idx)
382 id := "extra" + idxStr
383 // That this works is pretty much a hack, but upstream QEMU doesn't have a bidirectional chardev backend not
384 // based around files/sockets on the disk which are a giant pain to work with.
385 // We're using QEMU's fdset functionality to make FDs available as pseudo-files and then "ab"using the pipe
386 // backend's fallback functionality to get a single bidirectional chardev backend backed by a passed-down
387 // RDWR fd.
388 // Ref https://lists.gnu.org/archive/html/qemu-devel/2015-12/msg01256.html
389 addFdConf := qemuValue{
390 "set": {idxStr},
391 "fd": {strconv.Itoa(idx + 3)},
392 }
393 chardevConf := qemuValue{
394 "id": {id},
395 "path": {"/dev/fdset/" + idxStr},
396 }
397 deviceConf := qemuValue{
398 "chardev": {id},
399 }
400 extraArgs = append(extraArgs, "-add-fd", addFdConf.toOption(""),
401 "-chardev", chardevConf.toOption("pipe"), "-device", deviceConf.toOption("virtserialport"))
402 }
403
404 for idx, _ := range opts.ExtraNetworkInterfaces {
405 id := fmt.Sprintf("net%v", idx)
406 netdevConf := qemuValue{
407 "id": {id},
408 "fd": {strconv.Itoa(idx + 3 + len(opts.ExtraChardevs))},
409 }
410 extraArgs = append(extraArgs, "-netdev", netdevConf.toOption("socket"), "-device", "virtio-net-device,netdev="+id)
411 }
412
413 // This sets up a minimum viable environment for our Linux kernel.
414 // It clears all standard QEMU configuration and sets up a MicroVM machine
415 // (https://github.com/qemu/qemu/blob/master/docs/microvm.rst) with all legacy emulation turned off. This means
416 // the only "hardware" the Linux kernel inside can communicate with is a single virtio-mmio region. Over that MMIO
417 // interface we run a paravirtualized RNG (since the kernel in there has nothing to gather that from and it
418 // delays booting), a single paravirtualized console and an arbitrary number of extra serial ports for talking to
419 // various things that might run inside. The kernel, initramfs and command line are mapped into VM memory at boot
420 // time and not loaded from any sort of disk. Booting and shutting off one of these VMs takes <100ms.
421 baseArgs := []string{"-nodefaults", "-no-user-config", "-nographic", "-no-reboot",
422 "-accel", "kvm", "-cpu", "host",
423 // Needed until QEMU updates their bundled qboot version (needs https://github.com/bonzini/qboot/pull/28)
424 "-bios", "external/com_github_bonzini_qboot/bios.bin",
425 "-M", "microvm,x-option-roms=off,pic=off,pit=off,rtc=off,isa-serial=off",
426 "-kernel", opts.KernelPath,
427 // We force using a triple-fault reboot strategy since otherwise the kernel first tries others (like ACPI) which
428 // are not available in this very restricted environment. Similarly we need to override the boot console since
429 // there's nothing on the ISA bus that the kernel could talk to. We also force quiet for performance reasons.
430 "-append", "reboot=t console=hvc0 quiet " + opts.Cmdline,
431 "-initrd", opts.InitramfsPath,
432 "-device", "virtio-rng-device,max-bytes=1024,period=1000",
433 "-device", "virtio-serial-device,max_ports=16",
434 "-chardev", "stdio,id=con0", "-device", "virtconsole,chardev=con0",
435 }
436
437 if !opts.DisableHostNetworkInterface {
438 qemuNetType := "user"
439 qemuNetConfig := qemuValue{
440 "id": {"usernet0"},
441 "net": {"10.42.0.0/24"},
442 "dhcpstart": {"10.42.0.10"},
443 }
444 if opts.PortMap != nil {
445 qemuNetConfig["hostfwd"] = opts.PortMap.toQemuForwards()
446 }
447
448 baseArgs = append(baseArgs, "-netdev", qemuNetConfig.toOption(qemuNetType),
449 "-device", "virtio-net-device,netdev=usernet0,mac="+HostInterfaceMAC.String())
450 }
451
452 var stdErrBuf bytes.Buffer
453 cmd := exec.CommandContext(ctx, "qemu-system-x86_64", append(baseArgs, extraArgs...)...)
454 cmd.Stdout = opts.SerialPort
455 cmd.Stderr = &stdErrBuf
456
457 cmd.ExtraFiles = append(cmd.ExtraFiles, opts.ExtraChardevs...)
458 cmd.ExtraFiles = append(cmd.ExtraFiles, opts.ExtraNetworkInterfaces...)
459
460 err := cmd.Run()
461 var exerr *exec.ExitError
462 if err != nil && errors.As(err, &exerr) {
463 exerr.Stderr = stdErrBuf.Bytes()
464 newErr := QEMUError(*exerr)
465 return &newErr
466 }
467 return err
468}
469
470// QEMUError is a special type of ExitError used when QEMU fails. In addition to normal ExitError features it
471// prints stderr for debugging.
472type QEMUError exec.ExitError
473
474func (e *QEMUError) Error() string {
475 return fmt.Sprintf("%v: %v", e.String(), string(e.Stderr))
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200476}