Lorenz Brun | fc5dbc6 | 2020-05-28 12:18:07 +0200 | [diff] [blame^] | 1 | // 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 | |
| 17 | package launch |
| 18 | |
| 19 | import ( |
| 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 |
| 37 | func 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 | |
| 50 | type 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" |
| 54 | func 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 | |
| 68 | func 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. |
| 89 | type 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. |
| 93 | func (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. |
| 103 | func (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() |
| 116 | type 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 | |
| 126 | var 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. |
| 131 | func 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. |
| 143 | func 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. |
| 160 | func 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 | } |