blob: a88e46d1ea617f4accc320ed3be5ec816d6628da [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 (
20 "context"
21 "fmt"
22 "io"
23 "io/ioutil"
Leopold Schabela013ffa2020-06-03 15:09:32 +020024 "log"
Lorenz Brunfc5dbc62020-05-28 12:18:07 +020025 "net"
26 "os"
27 "os/exec"
28 "path/filepath"
29 "strings"
30
31 "google.golang.org/grpc"
32
33 "git.monogon.dev/source/nexantic.git/core/internal/common"
34)
35
36// This is more of a best-effort solution and not guaranteed to give us unused ports (since we're not immediately using
37// them), but AFAIK qemu cannot dynamically select hostfwd ports
38func getFreePort() (uint16, io.Closer, error) {
39 addr, err := net.ResolveTCPAddr("tcp", "localhost:0")
40 if err != nil {
41 return 0, nil, err
42 }
43
44 l, err := net.ListenTCP("tcp", addr)
45 if err != nil {
46 return 0, nil, err
47 }
48 return uint16(l.Addr().(*net.TCPAddr).Port), l, nil
49}
50
51type qemuValue map[string][]string
52
53// qemuValueToOption encodes structured data into a QEMU option.
54// Example: "test", {"key1": {"val1"}, "key2": {"val2", "val3"}} returns "test,key1=val1,key2=val2,key2=val3"
55func qemuValueToOption(name string, value qemuValue) string {
56 var optionValues []string
57 optionValues = append(optionValues, name)
58 for name, values := range value {
59 if len(values) == 0 {
60 optionValues = append(optionValues, name)
61 }
62 for _, val := range values {
63 optionValues = append(optionValues, fmt.Sprintf("%v=%v", name, val))
64 }
65 }
66 return strings.Join(optionValues, ",")
67}
68
69func copyFile(src, dst string) error {
70 in, err := os.Open(src)
71 if err != nil {
72 return err
73 }
74 defer in.Close()
75
76 out, err := os.Create(dst)
77 if err != nil {
78 return err
79 }
80 defer out.Close()
81
82 _, err = io.Copy(out, in)
83 if err != nil {
84 return err
85 }
86 return out.Close()
87}
88
89// PortMap represents where VM ports are mapped to on the host. It maps from the VM port number to the host port number.
90type PortMap map[uint16]uint16
91
92// toQemuForwards generates QEMU hostfwd values (https://qemu.weilnetz.de/doc/qemu-doc.html#:~:text=hostfwd=) for all
93// mapped ports.
94func (p PortMap) toQemuForwards() []string {
95 var hostfwdOptions []string
96 for vmPort, hostPort := range p {
97 hostfwdOptions = append(hostfwdOptions, fmt.Sprintf("tcp::%v-:%v", hostPort, vmPort))
98 }
99 return hostfwdOptions
100}
101
102// DialGRPC creates a gRPC client for a VM port that's forwarded/mapped to the host. The given port is automatically
103// resolved to the host-mapped port.
104func (p PortMap) DialGRPC(port uint16, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
105 mappedPort, ok := p[port]
106 if !ok {
107 return nil, fmt.Errorf("cannot dial port: port %v is not mapped/forwarded", port)
108 }
109 grpcClient, err := grpc.Dial(fmt.Sprintf("localhost:%v", mappedPort), opts...)
110 if err != nil {
111 return nil, fmt.Errorf("failed to dial port %v: %w", port, err)
112 }
113 return grpcClient, nil
114}
115
116// Options contains all options that can be passed to Launch()
117type Options struct {
118 // Ports contains the port mapping where to expose the internal ports of the VM to the host. See IdentityPortMap()
119 // and ConflictFreePortMap()
120 Ports PortMap
121
122 // If set to true, reboots are honored. Otherwise all reboots exit the Launch() command. Smalltown generally restarts
123 // on almost all errors, so unless you want to test reboot behavior this should be false.
124 AllowReboot bool
125}
126
127var requiredPorts = []uint16{common.ConsensusPort, common.NodeServicePort, common.MasterServicePort,
128 common.ExternalServicePort, common.DebugServicePort, common.KubernetesAPIPort}
129
130// IdentityPortMap returns a port map where each VM port is mapped onto itself on the host. This is mainly useful
131// for development against Smalltown. The dbg command requires this mapping.
132func IdentityPortMap() PortMap {
133 portMap := make(PortMap)
134 for _, port := range requiredPorts {
135 portMap[port] = port
136 }
137 return portMap
138}
139
140// ConflictFreePortMap returns a port map where each VM port is mapped onto a random free port on the host. This is
141// intended for automated testing where multiple instances of Smalltown might be running. Please call this function for
142// each Launch command separately and as close to it as possible since it cannot guarantee that the ports will remain
143// free.
144func ConflictFreePortMap() (PortMap, error) {
145 portMap := make(PortMap)
146 for _, port := range requiredPorts {
147 mappedPort, listenCloser, err := getFreePort()
148 if err != nil {
149 return portMap, fmt.Errorf("failed to get free host port: %w", err)
150 }
151 // Defer closing of the listening port until the function is done and all ports are allocated
152 defer listenCloser.Close()
153 portMap[port] = mappedPort
154 }
155 return portMap, nil
156}
157
158// Launch launches a Smalltown instance with the given options. The instance runs mostly paravirtualized but with some
159// emulated hardware similar to how a cloud provider might set up its VMs. The disk is fully writable but is run
160// in snapshot mode meaning that changes are not kept beyond a single invocation.
161func Launch(ctx context.Context, options Options) error {
162 // Pin temp directory to /tmp until we can use abstract socket namespace in QEMU (next release after 5.0,
163 // https://github.com/qemu/qemu/commit/776b97d3605ed0fc94443048fdf988c7725e38a9). swtpm accepts already-open FDs
164 // so we can pass in an abstract socket namespace FD that we open and pass the name of it to QEMU. Not pinning this
165 // crashes both swtpm and qemu because we run into UNIX socket length limitations (for legacy reasons 108 chars).
166 tempDir, err := ioutil.TempDir("/tmp", "launch*")
167 if err != nil {
168 return fmt.Errorf("Failed to create temporary directory: %w", err)
169 }
170 defer os.RemoveAll(tempDir)
171
172 // Copy TPM state into a temporary directory since it's being modified by the emulator
173 tpmTargetDir := filepath.Join(tempDir, "tpm")
174 tpmSrcDir := "core/tpm"
175 if err := os.Mkdir(tpmTargetDir, 0644); err != nil {
176 return fmt.Errorf("Failed to create TPM state directory: %w", err)
177 }
178 tpmFiles, err := ioutil.ReadDir(tpmSrcDir)
179 if err != nil {
180 return fmt.Errorf("Failed to read TPM directory: %w", err)
181 }
182 for _, file := range tpmFiles {
183 name := file.Name()
184 if err := copyFile(filepath.Join(tpmSrcDir, name), filepath.Join(tpmTargetDir, name)); err != nil {
185 return fmt.Errorf("Failed to copy TPM directory: %w", err)
186 }
187 }
188
189 qemuNetConfig := qemuValue{
190 "id": {"net0"},
191 "net": {"10.42.0.0/24"},
192 "dhcpstart": {"10.42.0.10"},
193 "hostfwd": options.Ports.toQemuForwards(),
194 }
195
196 tpmSocketPath := filepath.Join(tempDir, "tpm-socket")
197
198 qemuArgs := []string{"-machine", "q35", "-accel", "kvm", "-nographic", "-nodefaults", "-m", "2048",
199 "-cpu", "host", "-smp", "sockets=1,cpus=1,cores=2,threads=2,maxcpus=4",
200 "-drive", "if=pflash,format=raw,readonly,file=external/edk2/OVMF_CODE.fd",
201 "-drive", "if=pflash,format=raw,snapshot=on,file=external/edk2/OVMF_VARS.fd",
202 "-drive", "if=virtio,format=raw,snapshot=on,cache=unsafe,file=core/smalltown.img",
203 "-netdev", qemuValueToOption("user", qemuNetConfig),
204 "-device", "virtio-net-pci,netdev=net0",
205 "-chardev", "socket,id=chrtpm,path=" + tpmSocketPath,
206 "-tpmdev", "emulator,id=tpm0,chardev=chrtpm",
207 "-device", "tpm-tis,tpmdev=tpm0",
208 "-device", "virtio-rng-pci",
209 "-serial", "stdio"}
210
211 if !options.AllowReboot {
212 qemuArgs = append(qemuArgs, "-no-reboot")
213 }
214
Leopold Schabela013ffa2020-06-03 15:09:32 +0200215 // Start TPM emulator as a subprocess
216 tpmCtx, tpmCancel := context.WithCancel(ctx)
217 defer tpmCancel()
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200218
Leopold Schabela013ffa2020-06-03 15:09:32 +0200219 tpmEmuCmd := exec.CommandContext(tpmCtx, "swtpm", "socket", "--tpm2", "--tpmstate", "dir="+tpmTargetDir, "--ctrl", "type=unixio,path="+tpmSocketPath)
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200220 tpmEmuCmd.Stderr = os.Stderr
221 tpmEmuCmd.Stdout = os.Stdout
Leopold Schabela013ffa2020-06-03 15:09:32 +0200222
223 err = tpmEmuCmd.Start()
224 if err != nil {
225 return fmt.Errorf("failed to start TPM emulator: %w", err)
226 }
227
228 // Start the main qemu binary
229 systemCmd := exec.CommandContext(ctx, "qemu-system-x86_64", qemuArgs...)
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200230 systemCmd.Stderr = os.Stderr
231 systemCmd.Stdout = os.Stdout
Leopold Schabela013ffa2020-06-03 15:09:32 +0200232
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200233 err = systemCmd.Run()
Leopold Schabela013ffa2020-06-03 15:09:32 +0200234
235 // Stop TPM emulator and wait for it to exit to properly reap the child process
236 tpmCancel()
237 log.Print("Waiting for TPM emulator to exit")
238 // Wait returns a SIGKILL error because we just cancelled its context.
239 // We still need to call it to avoid creating zombies.
240 _ = tpmEmuCmd.Wait()
241
242 return nil
Lorenz Brunfc5dbc62020-05-28 12:18:07 +0200243}