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