├── .github └── workflows │ └── build.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── machine │ ├── cmd │ │ ├── console.go │ │ ├── delete.go │ │ ├── edit.go │ │ ├── gui.go │ │ ├── info.go │ │ ├── init.go │ │ ├── list.go │ │ ├── main.go │ │ ├── root.go │ │ ├── run.go │ │ ├── start.go │ │ └── stop.go │ └── examples.md └── machined │ ├── README.md │ └── cmd │ ├── install.go │ ├── main.go │ ├── remove.go │ └── root.go ├── doc ├── examples │ ├── 01-secureboot-ubuntu-livecd.yaml │ ├── 02-provision-with-heimdall.yaml │ ├── 03-install.yaml │ ├── vm-network-ports.yaml │ └── vm-with-cloud-init.yaml ├── min.yaml └── test-qcli.yaml ├── go.mod ├── go.sum ├── pkg ├── api │ ├── cloudinit.go │ ├── cloudinit_test.go │ ├── config.go │ ├── controller.go │ ├── disk.go │ ├── machine.go │ ├── network.go │ ├── ports.go │ ├── qconfig.go │ ├── rest.go │ ├── routes.go │ ├── socket.go │ ├── swtpm.go │ ├── util.go │ └── vm.go └── client │ └── machine.go └── systemd ├── machined.service.tpl └── machined.socket.tpl /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: 3 | push: 4 | pull_request: 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | include: 11 | - os: ubuntu-22.04 12 | arch: amd64 13 | build: true 14 | - os: ubuntu-22.04-arm 15 | arch: arm64 16 | build: true 17 | steps: 18 | - name: Checkout source 19 | uses: actions/checkout@v4 20 | - name: Set up golang 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version: 1.23 24 | - name: Install build dependencies 25 | run: | 26 | sudo apt-get update 27 | sudo apt-get install golang make 28 | - name: Make machine binaries 29 | run: | 30 | make 31 | mv bin/machine bin/machine-linux-${{matrix.arch}} 32 | mv bin/machined bin/machined-linux-${{matrix.arch}} 33 | ls -al bin/ 34 | - name: Test machine unittests 35 | run: | 36 | make test 37 | - name: Upload artifacts 38 | uses: actions/upload-artifact@v4 39 | with: 40 | name: machine-linux-${{matrix.arch}} 41 | path: bin/machine*-linux-${{matrix.arch}} 42 | if-no-files-found: error 43 | - name: Release 44 | uses: softprops/action-gh-release@v2 45 | if: startsWith(github.ref, 'refs/tags/') 46 | with: 47 | name: machine-linux-${{matrix.arch}} 48 | path: bin/machine*-linux-${{matrix.arch}} 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/* 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2022, Cisco Systems, All rights reserved. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BINS := bin/machine bin/machined 2 | 3 | .PHONY: all clean 4 | all: $(BINS) 5 | 6 | .PHONY: test 7 | test: test-api 8 | 9 | clean: 10 | rm -f -v $(BINS) 11 | 12 | bin/machine: cmd/machine/cmd/*.go pkg/*/*.go 13 | go build -o $@ cmd/machine/cmd/*.go 14 | 15 | bin/machined: cmd/machined/cmd/*.go pkg/*/*.go 16 | go build -o $@ cmd/machined/cmd/*.go 17 | 18 | test-api: 19 | go test pkg/api/*.go 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Machine 2 | 3 | ## Introduction 4 | 5 | Machine is a tool(set) to create, install, and run your container 6 | images in a secure manner. 7 | 8 | ## Status 9 | 10 | Currently all machine does is run kvm vms. However, it does so 11 | easily, driven by yaml specs, using secureboot and a UEFI db 12 | configured by yourself. 13 | 14 | ## Install Prerequisites 15 | 16 | ``` 17 | sudo add-apt-repository -y ppa:puzzleos/dev 18 | sudo apt install golang-go || sudo snap install --classic go 19 | sudo apt install -y build-essential qemu-system-x86 qemu-utils spice-client-gtk socat swtpm 20 | sudo usermod --append --groups kvm $USER 21 | newgrp kvm # or logout and login, run 'groups' command to confirm 22 | ``` 23 | 24 | ## Build machine 25 | 26 | Find the latest release here: https://github.com/project-machine/machine/releases/latest 27 | And select the tar.gz link, for example: 28 | 29 | ```shell 30 | LATEST="https://github.com/project-machine/machine/archive/refs/tags/v0.0.4.tar.gz" 31 | wget "$LATEST" 32 | tar xzf v0.0.4 33 | cd machine-0.0.4 34 | make 35 | ``` 36 | 37 | ## Run machined 38 | 39 | ### Debugging/Testing 40 | 41 | In a second shell/terminal 42 | 43 | ```shell 44 | newgrp kvm 45 | ./bin/machined 46 | ``` 47 | 48 | When done, control-c to stop daemon. 49 | 50 | ### For hosting/running 51 | 52 | In a second shell/terminal, use `machined install` to setup systemd units to run 53 | machined via socket activation. 54 | 55 | ```shell 56 | groups | grep kvm || newgrp kvm 57 | ./bin/machined install 58 | systemctl --user status machined.service 59 | journalctl --user --follow -u machined.service 60 | ``` 61 | 62 | If you make changes to machined (most changes under pkg/api) then you can stop 63 | the service with `systemctl stop --user machined.service` and then any new 64 | invocation of `machine` will start up the service again with the newer binary 65 | 66 | If you would like to remove the systemd units, do so with `machined remove`. 67 | If for any reason machined fails, you can clean up the unit with `systemctl --user reset-failed machined.service`. 68 | Then re-run the `machined remove` command to remove the units. 69 | 70 | 71 | Note: on some systems, systemd-run --user prevents access to /dev/kvm via groups 72 | The current workaround is to `sudo chmod 0666 /dev/kvm` 73 | 74 | ## Run machine client 75 | 76 | ``` 77 | ./bin machine list 78 | ``` 79 | 80 | ## Starting your first VM 81 | 82 | Download a live iso, like Ubuntu 22.04 83 | 84 | https://releases.ubuntu.com/22.04.2/ubuntu-22.04.2-desktop-amd64.iso 85 | 86 | ``` 87 | $ cat >vm1.yaml < ] 107 | args := []string{"stdin,echo=0,raw,escape=0x1d", fmt.Sprintf("unix-connect:%s", consoleInfo.Path)} 108 | cmd := exec.Command("socat", args...) 109 | cmd.Stdin = os.Stdin 110 | cmd.Stdout = os.Stdout 111 | cmd.Stderr = os.Stderr 112 | log.Infof("Running command: %s", cmd.Args) 113 | fmt.Printf("Attaching to %s serial console, use 'Control-]' to detatch from console\n", machineName) 114 | return cmd.Run() 115 | } 116 | 117 | func doVGAAttach(machineName string, consoleInfo api.ConsoleInfo) error { 118 | 119 | args := []string{fmt.Sprintf("--host=%s", consoleInfo.Addr)} 120 | if consoleInfo.Secure { 121 | args = append(args, fmt.Sprintf("--secure-port=%s", consoleInfo.Port)) 122 | } else { 123 | args = append(args, fmt.Sprintf("--port=%s", consoleInfo.Port)) 124 | } 125 | args = append(args, fmt.Sprintf("--title='machine %s'", machineName)) 126 | 127 | cmd := exec.Command("spicy", args...) 128 | fmt.Printf("Attaching to %s vga console\n", machineName) 129 | return cmd.Run() 130 | } 131 | 132 | func DoConsoleAttach(machineName string, consoleInfo api.ConsoleInfo) error { 133 | switch consoleInfo.Type { 134 | case api.SerialConsole: 135 | return doConsoleAttach(machineName, consoleInfo) 136 | case api.VGAConsole: 137 | return doVGAAttach(machineName, consoleInfo) 138 | default: 139 | return fmt.Errorf("Cannot attach to unknown console type '%s'", consoleInfo.Type) 140 | } 141 | 142 | return nil 143 | } 144 | -------------------------------------------------------------------------------- /cmd/machine/cmd/delete.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | 20 | "github.com/spf13/cobra" 21 | "github.com/project-machine/machine/pkg/api" 22 | ) 23 | 24 | // deleteCmd represents the list command 25 | var deleteCmd = &cobra.Command{ 26 | Use: "delete ", 27 | Args: cobra.MinimumNArgs(1), 28 | ArgAliases: []string{"machineName"}, 29 | Short: "delete the specified machine", 30 | Long: `delete the specified machine if it exists`, 31 | Run: doDelete, 32 | } 33 | 34 | func doDelete(cmd *cobra.Command, args []string) { 35 | machineName := args[0] 36 | endpoint := fmt.Sprintf("machines/%s", machineName) 37 | deleteURL := api.GetAPIURL(endpoint) 38 | if len(deleteURL) == 0 { 39 | panic("Failed to get DELETE API URL for 'machines' endpoint") 40 | } 41 | resp, err := rootclient.R().EnableTrace().Delete(deleteURL) 42 | if err != nil { 43 | fmt.Printf("Failed to delete machine '%s': %s\n", machineName, err) 44 | panic(err) 45 | } 46 | fmt.Println(resp.Status()) 47 | } 48 | 49 | func init() { 50 | rootCmd.AddCommand(deleteCmd) 51 | } 52 | -------------------------------------------------------------------------------- /cmd/machine/cmd/edit.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | "os" 20 | 21 | "github.com/lxc/lxd/shared" 22 | "github.com/lxc/lxd/shared/termios" 23 | "github.com/project-machine/machine/pkg/api" 24 | "github.com/spf13/cobra" 25 | "golang.org/x/sys/unix" 26 | "gopkg.in/yaml.v2" 27 | ) 28 | 29 | // editCmd represents the edit command 30 | var editCmd = &cobra.Command{ 31 | Use: "edit ", 32 | Args: cobra.MinimumNArgs(1), 33 | ArgAliases: []string{"machineName"}, 34 | Short: "edit a machine's configuration file", 35 | Long: `Read the machine configuration into an editor for modification`, 36 | Run: doEdit, 37 | } 38 | 39 | // edit requires one to: 40 | // - GET the machine configuration from REST API 41 | // - render this to a temp file 42 | // - invoke $EDITOR to allow user to make changes 43 | // 44 | // Option 1: 45 | // - (optionally) before posting, run JSON validator on the new file? 46 | // - PATCH/UPDATE the machine configuration back to API 47 | // (and symantically what does that mean if the instance is running) 48 | // 49 | // Option 2: 50 | // - write out changes to config file on disk and not modifying in-memory state 51 | // via PATCH/UPDATE operations. 52 | // 53 | func doEdit(cmd *cobra.Command, args []string) { 54 | machineName := args[0] 55 | machines, err := getMachines() 56 | if err != nil { 57 | panic(err) 58 | } 59 | 60 | var machineBytes []byte 61 | onTerm := termios.IsTerminal(unix.Stdin) 62 | editMachine := &api.Machine{} 63 | 64 | for _, machine := range machines { 65 | if machine.Name == machineName { 66 | editMachine = &machine 67 | break 68 | } 69 | } 70 | if editMachine.Name == "" { 71 | panic(fmt.Sprintf("Failed to find machine '%s'", machineName)) 72 | } 73 | 74 | machineBytes, err = yaml.Marshal(editMachine) 75 | if err != nil { 76 | panic(fmt.Sprintf("Error marshalling machine '%s'", machineName)) 77 | } 78 | 79 | machineBytes, err = shared.TextEditor("", machineBytes) 80 | if err != nil { 81 | panic("Error calling editor") 82 | } 83 | 84 | newMachine := api.Machine{Name: machineName} 85 | for { 86 | err = yaml.Unmarshal(machineBytes, &newMachine) 87 | if err == nil { 88 | pErr := checkMachineFilePaths(&newMachine) 89 | if pErr == nil { 90 | break 91 | } 92 | fmt.Printf("Error checking paths in config: %s\n", pErr) 93 | } 94 | if !onTerm { 95 | panic(fmt.Sprintf("Error parsing configuration: %s", err)) 96 | } 97 | fmt.Printf("Error parsing yaml: %v\n", err) 98 | fmt.Println("Press enter to re-open editor, or ctrl-c to abort") 99 | _, err := os.Stdin.Read(make([]byte, 1)) 100 | if err != nil { 101 | panic(fmt.Sprintf("Error reading reply: %s", err)) 102 | } 103 | machineBytes, err = shared.TextEditor("", machineBytes) 104 | if err != nil { 105 | panic(fmt.Sprintf("Error calling editor: %s", err)) 106 | } 107 | 108 | } 109 | // persist config if not ephemeral 110 | 111 | err = putMachine(newMachine) 112 | if err != nil { 113 | panic(err.Error()) 114 | } 115 | } 116 | 117 | func init() { 118 | rootCmd.AddCommand(editCmd) 119 | } 120 | -------------------------------------------------------------------------------- /cmd/machine/cmd/gui.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | package main 16 | 17 | import ( 18 | "github.com/spf13/cobra" 19 | 20 | "github.com/project-machine/machine/pkg/api" 21 | ) 22 | 23 | // guiCmd represents the gui command 24 | var guiCmd = &cobra.Command{ 25 | Use: "gui", 26 | Short: "launch a gui client attaching to a specified machine", 27 | Run: doGui, 28 | } 29 | 30 | func doGui(cmd *cobra.Command, args []string) { 31 | if len(args) < 1 { 32 | panic("Missing required machine name") 33 | } 34 | machineName := args[0] 35 | 36 | consoleInfo, err := GetMachineConsoleInfo(machineName, api.VGAConsole) 37 | if err != nil { 38 | panic(err) 39 | } 40 | 41 | err = DoConsoleAttach(machineName, consoleInfo) 42 | if err != nil { 43 | panic(err) 44 | } 45 | } 46 | 47 | func init() { 48 | rootCmd.AddCommand(guiCmd) 49 | } 50 | -------------------------------------------------------------------------------- /cmd/machine/cmd/info.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | "net/http" 20 | "strings" 21 | 22 | table "github.com/rodaine/table" 23 | "github.com/spf13/cobra" 24 | "gopkg.in/yaml.v2" 25 | ) 26 | 27 | // infoCmd represents the info command 28 | var infoCmd = &cobra.Command{ 29 | Use: "info", 30 | Short: "info about the specified machine", 31 | Long: `info about the specified machine`, 32 | RunE: doInfo, 33 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 34 | cmd.SilenceUsage = true 35 | }, 36 | } 37 | 38 | func doInfo(cmd *cobra.Command, args []string) error { 39 | machineName := args[0] 40 | machine, status, err := getMachine(machineName) 41 | if err != nil { 42 | return fmt.Errorf("Error getting machine '%s': %s", machineName, err) 43 | } 44 | if status != http.StatusOK { 45 | if status == http.StatusNotFound { 46 | // fmt.Printf("No such machine '%s'\n", machineName) 47 | return fmt.Errorf("No such machine '%s'", machineName) 48 | } 49 | return fmt.Errorf("Error: %d %v\n", status, err) 50 | } else { 51 | machineBytes, err := yaml.Marshal(machine) 52 | if err != nil { 53 | return fmt.Errorf("Failed to marshal response: %v", err) 54 | } 55 | fmt.Printf("%s", machineBytes) 56 | } 57 | return nil 58 | } 59 | 60 | func init() { 61 | rootCmd.AddCommand(infoCmd) 62 | table.DefaultHeaderFormatter = func(format string, vals ...interface{}) string { 63 | return strings.ToUpper(fmt.Sprintf(format, vals...)) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /cmd/machine/cmd/init.go: -------------------------------------------------------------------------------- 1 | /* 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | */ 14 | package main 15 | 16 | import ( 17 | "fmt" 18 | "io/ioutil" 19 | "os" 20 | "path/filepath" 21 | "sort" 22 | "strings" 23 | 24 | petname "github.com/dustinkirkland/golang-petname" 25 | "github.com/lxc/lxd/shared" 26 | "github.com/lxc/lxd/shared/termios" 27 | homedir "github.com/mitchellh/go-homedir" 28 | "github.com/project-machine/machine/pkg/api" 29 | log "github.com/sirupsen/logrus" 30 | "github.com/spf13/cobra" 31 | "golang.org/x/sys/unix" 32 | "gopkg.in/yaml.v2" 33 | ) 34 | 35 | const machineTypeInsecureVerson1 = ` 36 | type: kvm 37 | ephemeral: false 38 | config: 39 | cpus: 2 40 | memory: 2048 41 | uefi: true 42 | tpm: true 43 | tpm-version: 2.0 44 | secure-boot: false 45 | gui: true 46 | disks: 47 | - file: rootdisk.qcow2 48 | format: qcow2 49 | type: ssd 50 | attach: virtio 51 | bootindex: 0 52 | size: 50GiB 53 | nics: 54 | - id: nic0 55 | device: virtio-net 56 | network: user 57 | ` 58 | 59 | const machineTypeVersion1 = ` 60 | type: kvm 61 | ephemeral: false 62 | config: 63 | cpus: 2 64 | memory: 2048 65 | uefi: true 66 | tpm: true 67 | tpm-version: 2.0 68 | secure-boot: true 69 | gui: true 70 | disks: 71 | - file: rootdisk.qcow2 72 | format: qcow2 73 | type: ssd 74 | attach: virtio 75 | bootindex: 0 76 | size: 50GiB 77 | ` 78 | 79 | const defaultMachineType = "1.0" 80 | 81 | var machineTypes = map[string]string{ 82 | defaultMachineType: machineTypeVersion1, 83 | "1.0-insecure": machineTypeInsecureVerson1, 84 | } 85 | 86 | func getMachineTypes() []string { 87 | var mTypes []string 88 | for key := range machineTypes { 89 | mTypes = append(mTypes, key) 90 | } 91 | sort.Strings(mTypes) 92 | return mTypes 93 | } 94 | 95 | func getMachineTypeYaml(mType string) (string, error) { 96 | machine, ok := machineTypes[mType] 97 | if !ok { 98 | return "", fmt.Errorf("Unknown machine type '%s'", mType) 99 | } 100 | return machine, nil 101 | } 102 | 103 | func dataOnStdin() bool { 104 | stat, _ := os.Stdin.Stat() 105 | if (stat.Mode() & os.ModeCharDevice) == 0 { 106 | return true 107 | } 108 | return false 109 | } 110 | 111 | func DoCreateMachine(machineName, machineType, fileName string, editFile bool) error { 112 | log.Debugf("DoCreateMachine Name:%s Type:%s File:%s Edit:%v", machineName, machineType, fileName, editFile) 113 | var err error 114 | onTerm := termios.IsTerminal(unix.Stdin) 115 | machine, err := getMachineTypeYaml(machineType) 116 | if err != nil { 117 | return fmt.Errorf("Failed to machine type '%s' template: %s", machineType, err) 118 | } 119 | machineBytes := []byte(machine) 120 | newMachine := api.Machine{} 121 | 122 | err = yaml.Unmarshal(machineBytes, &newMachine) 123 | if err != nil { 124 | return fmt.Errorf("Failed to unmarshal default machine config: %s", err) 125 | } 126 | newMachine.Name = machineName 127 | newMachine.Config.Name = machineName 128 | for idx, nic := range newMachine.Config.Nics { 129 | newMac, err := api.RandomQemuMAC() 130 | if err != nil { 131 | return fmt.Errorf("Failed to generate a random QEMU MAC address: %s", err) 132 | } 133 | nic.Mac = newMac 134 | newMachine.Config.Nics[idx] = nic 135 | } 136 | 137 | log.Infof("Creating machine...") 138 | 139 | // check if edit is set whether we're a terminal or not 140 | // if file, read contents, else read from stdin 141 | // launch editor with contents 142 | // post-edit attempt to marshal contents into Machine definition, retry on failure 143 | // If machine.Persistent is set, then write contents to config dir, else call api.AddMachine() 144 | 145 | if editFile && !onTerm { 146 | return fmt.Errorf("Aborting edit since stdin is not a terminal") 147 | } 148 | 149 | if fileName == "-" || dataOnStdin() { 150 | log.Info("Reading machine config from stdin...") 151 | machineBytes, err = ioutil.ReadAll(os.Stdin) 152 | if err != nil { 153 | return fmt.Errorf("Error reading machine definition from stdin: %s", err) 154 | } 155 | } else { 156 | if len(fileName) > 0 { 157 | log.Infof("Reading machine config from %q", fileName) 158 | machineBytes, err = os.ReadFile(fileName) 159 | if err != nil { 160 | return fmt.Errorf("Error reading definition from %s: %s", fileName, err) 161 | } 162 | } else { 163 | log.Infof("No machine config specified. Using defaults from machine type '%s' ...\n", machineType) 164 | machineBytes, err = yaml.Marshal(newMachine) 165 | if err != nil { 166 | return fmt.Errorf("Failed reading empty machine config: %s", err) 167 | } 168 | } 169 | } 170 | 171 | if editFile { 172 | machineBytes, err = shared.TextEditor("", machineBytes) 173 | if err != nil { 174 | return fmt.Errorf("Error calling editor: %s", err) 175 | } 176 | } 177 | log.Debugf("Got config:\n%s", string(machineBytes)) 178 | 179 | for { 180 | if err = yaml.Unmarshal(machineBytes, &newMachine); err == nil { 181 | break 182 | } 183 | if !onTerm { 184 | return fmt.Errorf("Error parsing configuration: %s", err) 185 | } 186 | fmt.Printf("Error parsing yaml: %v\n", err) 187 | fmt.Println("Press enter to re-open editor, or ctrl-c to abort") 188 | _, err := os.Stdin.Read(make([]byte, 1)) 189 | if err != nil { 190 | return fmt.Errorf("Error reading reply: %s", err) 191 | } 192 | machineBytes, err = shared.TextEditor("", machineBytes) 193 | if err != nil { 194 | fmt.Errorf("Error calling editor: %s", err) 195 | } 196 | } 197 | 198 | if err := checkMachineFilePaths(&newMachine); err != nil { 199 | return fmt.Errorf("Error while checking machine file paths: %s", err) 200 | } 201 | 202 | // persist config if not ephemeral 203 | err = postMachine(newMachine) 204 | if err != nil { 205 | return fmt.Errorf("Error while POST'ing new machine config: %s", err) 206 | } 207 | return nil 208 | } 209 | 210 | func verifyPath(base, path string) (string, error) { 211 | fullPath := path 212 | if strings.HasPrefix(path, "/") { 213 | fullPath = path 214 | } else if strings.HasPrefix(fullPath, "~/") { 215 | ePath, err := homedir.Expand(fullPath) 216 | if err != nil { 217 | return "", fmt.Errorf("Failed to expand '~/' in path string %q: %s", fullPath, err) 218 | } 219 | log.Infof("Expanded %s to %q", fullPath, ePath) 220 | fullPath = ePath 221 | } else { 222 | fullPath = filepath.Join(base, path) 223 | } 224 | 225 | if !api.PathExists(fullPath) { 226 | return "", fmt.Errorf("Failed to find specified file '%s'", fullPath) 227 | } 228 | 229 | return fullPath, nil 230 | } 231 | 232 | func checkMachineFilePaths(newMachine *api.Machine) error { 233 | log.Infof("Checking machine definition for local file paths...") 234 | cwd, err := os.Getwd() 235 | if err != nil { 236 | return fmt.Errorf("Failed to get current working dir: %s", err) 237 | } 238 | for idx := range newMachine.Config.Disks { 239 | disk := newMachine.Config.Disks[idx] 240 | // skip disks to be created (file does not exist but size > 0) 241 | if disk.File != "" && disk.Size == 0 { 242 | newPath, err := verifyPath(cwd, disk.File) 243 | if err != nil { 244 | return fmt.Errorf("Failed to verify path to disk %q: %w", disk.File, err) 245 | } 246 | if newPath != disk.File { 247 | log.Infof("Fully qualified disk path %s", newPath) 248 | disk.File = newPath 249 | newMachine.Config.Disks[idx] = disk 250 | } 251 | } 252 | } 253 | if newMachine.Config.Cdrom != "" { 254 | newPath, err := verifyPath(cwd, newMachine.Config.Cdrom) 255 | if err != nil { 256 | return fmt.Errorf("Failed to verify path to cdrom %q: %w", newMachine.Config.Cdrom, err) 257 | } 258 | log.Infof("Fully qualified cdrom path %s", newPath) 259 | newMachine.Config.Cdrom = newPath 260 | } 261 | if newMachine.Config.UEFIVars != "" { 262 | newPath, err := verifyPath(cwd, newMachine.Config.UEFIVars) 263 | if err != nil { 264 | return fmt.Errorf("Failed to verify path to uefi-vars: %q: %s", newMachine.Config.UEFIVars, err) 265 | } 266 | log.Infof("Fully qualified uefi-vars path %s", newPath) 267 | newMachine.Config.UEFIVars = newPath 268 | } 269 | if newMachine.Config.UEFICode != "" { 270 | newPath, err := verifyPath(cwd, newMachine.Config.UEFICode) 271 | if err != nil { 272 | return fmt.Errorf("Failed to verify path to uefi-code: %q: %s", newMachine.Config.UEFICode, err) 273 | } 274 | log.Infof("Fully qualified uefi-code path %s", newPath) 275 | newMachine.Config.UEFICode = newPath 276 | } 277 | return nil 278 | } 279 | 280 | // initCmd represents the init command 281 | var initCmd = &cobra.Command{ 282 | Use: "init ", 283 | Short: "Initialize a new machine from yaml", 284 | Long: `Initilize a new machine by specifying a machine yaml configuring.`, 285 | RunE: doInit, 286 | PersistentPreRunE: doInitArgsValidate, 287 | } 288 | 289 | func doInit(cmd *cobra.Command, args []string) error { 290 | fileName := cmd.Flag("file").Value.String() 291 | // Hi cobra, this is awkward... why isn't there .Value.Bool()? 292 | editFile, _ := cmd.Flags().GetBool("edit") 293 | var machineName string 294 | if len(args) > 0 { 295 | machineName = args[0] 296 | } else { 297 | machineName = petname.Generate(petNameWords, petNameSep) 298 | } 299 | machineType := cmd.Flag("machine-type").Value.String() 300 | 301 | if err := DoCreateMachine(machineName, machineType, fileName, editFile); err != nil { 302 | return fmt.Errorf("Failed to create machine with type '%s': %s", machineType, err) 303 | } 304 | return nil 305 | } 306 | 307 | func doInitArgsValidate(cmd *cobra.Command, args []string) error { 308 | mType := cmd.Flag("machine-type").Value.String() 309 | if _, ok := machineTypes[mType]; !ok { 310 | mTypes := getMachineTypes() 311 | cmd.SilenceUsage = true 312 | return fmt.Errorf("Invalid machine-type '%s', must be one of: %s", mType, strings.Join(mTypes, ", ")) 313 | } 314 | debug, _ := cmd.Flags().GetBool("debug") 315 | if debug { 316 | log.SetLevel(log.DebugLevel) 317 | } 318 | return nil 319 | } 320 | 321 | func init() { 322 | mTypes := getMachineTypes() 323 | rootCmd.AddCommand(initCmd) 324 | initCmd.PersistentFlags().StringP("file", "f", "", "yaml file to import. If unspecified, use stdin") 325 | initCmd.PersistentFlags().BoolP("edit", "e", false, "edit the yaml file inline") 326 | initCmd.PersistentFlags().BoolP("debug", "D", false, "enable debug logging") 327 | initCmd.PersistentFlags().StringP("machine-type", "m", defaultMachineType, fmt.Sprintf("specify the machine type, one of [%s]", strings.Join(mTypes, ", "))) 328 | } 329 | -------------------------------------------------------------------------------- /cmd/machine/cmd/list.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | "strings" 20 | 21 | table "github.com/rodaine/table" 22 | "github.com/spf13/cobra" 23 | ) 24 | 25 | // listCmd represents the list command 26 | var listCmd = &cobra.Command{ 27 | Use: "list", 28 | Short: "list all of the defined machines", 29 | Long: `list all of the defined machines`, 30 | Run: doList, 31 | } 32 | 33 | func doList(cmd *cobra.Command, args []string) { 34 | machines, err := getMachines() 35 | if err != nil { 36 | panic(err) 37 | } 38 | tbl := table.New("Name", "Status", "Description") 39 | tbl.AddRow("----", "------", "-----------") 40 | for _, machine := range machines { 41 | tbl.AddRow(machine.Name, machine.Status, machine.Description) 42 | } 43 | tbl.Print() 44 | } 45 | 46 | func init() { 47 | rootCmd.AddCommand(listCmd) 48 | table.DefaultHeaderFormatter = func(format string, vals ...interface{}) string { 49 | return strings.ToUpper(fmt.Sprintf(format, vals...)) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /cmd/machine/cmd/main.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | package main 16 | 17 | func main() { 18 | Execute() 19 | } 20 | -------------------------------------------------------------------------------- /cmd/machine/cmd/root.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | package main 16 | 17 | import ( 18 | "context" 19 | "encoding/json" 20 | "fmt" 21 | "math/rand" 22 | "net" 23 | "net/http" 24 | "os" 25 | "path/filepath" 26 | "time" 27 | 28 | "github.com/go-resty/resty/v2" 29 | "github.com/project-machine/machine/pkg/api" 30 | "github.com/spf13/cobra" 31 | "github.com/spf13/viper" 32 | ) 33 | 34 | var cfgFile string 35 | var rootclient *resty.Client 36 | 37 | const ( 38 | petNameWords = 2 39 | petNameSep = "-" 40 | ) 41 | 42 | // rootCmd represents the base command when called without any subcommands 43 | var rootCmd = &cobra.Command{ 44 | Use: "machine", 45 | Short: "The machine client is used to run and manage machine machines", 46 | } 47 | 48 | // Execute adds all child commands to the root command and sets flags appropriately. 49 | // This is called by main.main(). It only needs to happen once to the rootCmd. 50 | func Execute() { 51 | err := rootCmd.Execute() 52 | if err != nil { 53 | os.Exit(1) 54 | } 55 | } 56 | 57 | func init() { 58 | rand.Seed(time.Now().UTC().UnixNano()) 59 | cobra.OnInitialize(initConfig) 60 | 61 | // Here you will define your flags and configuration settings. 62 | // Cobra supports persistent flags, which, if defined here, 63 | // will be global for your application. 64 | 65 | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.client.yaml)") 66 | 67 | // configure the http client to point to the unix socket 68 | apiSocket := api.APISocketPath() 69 | if len(apiSocket) == 0 { 70 | panic("Failed to get API socket path") 71 | } 72 | 73 | unixDial := func(_ context.Context, network, addr string) (net.Conn, error) { 74 | raddr, err := net.ResolveUnixAddr("unix", apiSocket) 75 | if err != nil { 76 | return nil, err 77 | } 78 | 79 | return net.DialUnix("unix", nil, raddr) 80 | } 81 | 82 | transport := http.Transport{ 83 | DialContext: unixDial, 84 | DisableKeepAlives: true, 85 | ExpectContinueTimeout: time.Second * 30, 86 | ResponseHeaderTimeout: time.Second * 3600, 87 | TLSHandshakeTimeout: time.Second * 5, 88 | } 89 | 90 | rootclient = resty.New() 91 | rootclient.SetTransport(&transport).SetScheme("http").SetBaseURL(apiSocket) 92 | } 93 | 94 | // initConfig reads in config file and ENV variables if set. 95 | func initConfig() { 96 | if cfgFile != "" { 97 | // Use config file from the flag. 98 | viper.SetConfigFile(cfgFile) 99 | } else { 100 | // Find home directory. 101 | home, err := os.UserHomeDir() 102 | cobra.CheckErr(err) 103 | 104 | // Search config in home directory with name ".client" (without extension). 105 | viper.AddConfigPath(home) 106 | viper.SetConfigType("yaml") 107 | viper.SetConfigName(".client") 108 | } 109 | 110 | viper.AutomaticEnv() // read in environment variables that match 111 | 112 | // If a config file is found, read it in. 113 | if err := viper.ReadInConfig(); err == nil { 114 | fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) 115 | } 116 | } 117 | 118 | // common for all commands 119 | func getMachines() ([]api.Machine, error) { 120 | machines := []api.Machine{} 121 | listURL := api.GetAPIURL("machines") 122 | if len(listURL) == 0 { 123 | return machines, fmt.Errorf("Failed to get API URL for 'machines' endpoint") 124 | } 125 | resp, _ := rootclient.R().EnableTrace().Get(listURL) 126 | err := json.Unmarshal(resp.Body(), &machines) 127 | if err != nil { 128 | return machines, fmt.Errorf("Failed to unmarshal GET on /machines") 129 | } 130 | return machines, nil 131 | } 132 | 133 | func getMachine(machineName string) (api.Machine, int, error) { 134 | machine := api.Machine{} 135 | getURL := api.GetAPIURL(filepath.Join("machines", machineName)) 136 | if len(getURL) == 0 { 137 | return machine, http.StatusBadRequest, fmt.Errorf("Failed to get API URL for 'machines/%s' endpoint", machineName) 138 | } 139 | resp, _ := rootclient.R().EnableTrace().Get(getURL) 140 | err := json.Unmarshal(resp.Body(), &machine) 141 | if err != nil { 142 | return machine, resp.StatusCode(), fmt.Errorf("%d: Failed to unmarshal GET on /machines/%s", resp.StatusCode(), machineName) 143 | } 144 | return machine, resp.StatusCode(), nil 145 | } 146 | 147 | func postMachine(newMachine api.Machine) error { 148 | postURL := api.GetAPIURL("machines") 149 | if len(postURL) == 0 { 150 | return fmt.Errorf("Failed to get API URL for 'machines' endpoint") 151 | } 152 | resp, err := rootclient.R().EnableTrace().SetBody(newMachine).Post(postURL) 153 | if err != nil { 154 | return fmt.Errorf("Failed POST to 'machines' endpoint: %s", err) 155 | } 156 | fmt.Printf("%s %s\n", resp, resp.Status()) 157 | return nil 158 | } 159 | 160 | func putMachine(newMachine api.Machine) error { 161 | endpoint := fmt.Sprintf("machines/%s", newMachine.Name) 162 | putURL := api.GetAPIURL(endpoint) 163 | if len(putURL) == 0 { 164 | return fmt.Errorf("Failed to get API PUT URL for 'machines' endpoint") 165 | } 166 | resp, err := rootclient.R().EnableTrace().SetBody(newMachine).Put(putURL) 167 | if err != nil { 168 | return fmt.Errorf("Failed PUT to machine '%s' endpoint: %s", newMachine.Name, err) 169 | } 170 | fmt.Printf("%s %s\n", resp, resp.Status()) 171 | return nil 172 | } 173 | -------------------------------------------------------------------------------- /cmd/machine/cmd/run.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | 20 | "github.com/spf13/cobra" 21 | ) 22 | 23 | // runCmd represents the run command 24 | var runCmd = &cobra.Command{ 25 | Use: "run ", 26 | Args: cobra.MinimumNArgs(2), 27 | ArgAliases: []string{"machineName"}, 28 | Short: "create and start a new machine", 29 | Long: `create a new machine from config and start the machine.`, 30 | Run: doRun, 31 | } 32 | 33 | // Initialize a new machine from config file and then start it up 34 | func doRun(cmd *cobra.Command, args []string) { 35 | machineName := args[0] 36 | machineConfig := args[1] 37 | editMachine := false 38 | 39 | // FIXME: handle mismatch between name in arg and value in config file 40 | if err := DoCreateMachine(machineName, defaultMachineType, machineConfig, editMachine); err != nil { 41 | panic(fmt.Sprintf("Failed to create machine '%s' from config '%s': %s", machineName, machineConfig, err)) 42 | } 43 | 44 | if err := DoStartMachine(machineName); err != nil { 45 | panic(fmt.Sprintf("Failed to start machine '%s' from config '%s': %s", machineName, machineConfig, err)) 46 | } 47 | } 48 | 49 | func init() { 50 | rootCmd.AddCommand(runCmd) 51 | } 52 | -------------------------------------------------------------------------------- /cmd/machine/cmd/start.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | 20 | "github.com/project-machine/machine/pkg/api" 21 | "github.com/spf13/cobra" 22 | ) 23 | 24 | // startCmd represents the start command 25 | var startCmd = &cobra.Command{ 26 | Use: "start ", 27 | Args: cobra.MinimumNArgs(1), 28 | ArgAliases: []string{"machineName"}, 29 | Short: "start the specified machine", 30 | Long: `start the specified machine if it exists`, 31 | Run: doStart, 32 | } 33 | 34 | // Ideally: 35 | // starting a machine requires POST'ing an update to the machine state 36 | // which toggles from the 'stopped' state to the 'running' state. 37 | // Asynchronously the machine will start, in a separate goroutine spawned by 38 | // machined, and depending on client flags (blocking/non-blocking) the server 39 | // will return back an new URL for status on the machine instance 40 | // 41 | // TBD, the affecting the machines in each machine 42 | // 43 | // Currently we now post a request with {'status': 'running'} to start a machine 44 | 45 | func doStart(cmd *cobra.Command, args []string) { 46 | machineName := args[0] 47 | if err := DoStartMachine(machineName); err != nil { 48 | panic(fmt.Sprintf("Failed to start machines '%s': %s", machineName)) 49 | } 50 | } 51 | 52 | func DoStartMachine(machineName string) error { 53 | fmt.Printf("Starting machine %s\n", machineName) 54 | var request struct { 55 | Status string `json:"status"` 56 | } 57 | request.Status = "running" 58 | endpoint := fmt.Sprintf("machines/%s/start", machineName) 59 | startURL := api.GetAPIURL(endpoint) 60 | if len(startURL) == 0 { 61 | return fmt.Errorf("Failed to get API URL for 'machines/%s/start' endpoint", machineName) 62 | } 63 | resp, err := rootclient.R().EnableTrace().SetBody(request).Post(startURL) 64 | if err != nil { 65 | return fmt.Errorf("Failed POST to 'machines/%s/start' endpoint: %s", machineName, err) 66 | } 67 | fmt.Printf("%s %s\n", resp, resp.Status()) 68 | return nil 69 | } 70 | 71 | func init() { 72 | rootCmd.AddCommand(startCmd) 73 | } 74 | -------------------------------------------------------------------------------- /cmd/machine/cmd/stop.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | package main 16 | 17 | import ( 18 | "fmt" 19 | 20 | "github.com/project-machine/machine/pkg/api" 21 | "github.com/spf13/cobra" 22 | ) 23 | 24 | // stopCmd represents the stop command 25 | var stopCmd = &cobra.Command{ 26 | Use: "stop ", 27 | Args: cobra.MinimumNArgs(1), 28 | ArgAliases: []string{"machineName"}, 29 | Short: "stop the specified machine", 30 | Long: `stop the specified machine if it exists`, 31 | Run: doStop, 32 | } 33 | 34 | // need to see about stopping single machine under machine and whole machine 35 | func doStop(cmd *cobra.Command, args []string) { 36 | machineName := args[0] 37 | // Hi cobra, this is awkward... why isn't there .Value.Bool()? 38 | forceStop, _ := cmd.Flags().GetBool("force") 39 | var request struct { 40 | Status string `json:"status"` 41 | Force bool `json:"force"` 42 | } 43 | request.Status = "stopped" 44 | request.Force = forceStop 45 | 46 | endpoint := fmt.Sprintf("machines/%s/stop", machineName) 47 | stopURL := api.GetAPIURL(endpoint) 48 | if len(stopURL) == 0 { 49 | panic(fmt.Sprintf("Failed to get API URL for 'machines/%s/stop' endpoint", machineName)) 50 | } 51 | resp, err := rootclient.R().EnableTrace().SetBody(request).Post(stopURL) 52 | if err != nil { 53 | panic(fmt.Sprintf("Failed POST to 'machines/%s/stop' endpoint: %s", machineName, err)) 54 | } 55 | fmt.Printf("%s %s\n", resp, resp.Status()) 56 | } 57 | 58 | func init() { 59 | rootCmd.AddCommand(stopCmd) 60 | stopCmd.PersistentFlags().BoolP("force", "f", false, "shutdown the machine forcefully") 61 | } 62 | -------------------------------------------------------------------------------- /cmd/machine/examples.md: -------------------------------------------------------------------------------- 1 | # Example machine cli commands 2 | 3 | 4 | ## launch (init + run) 5 | ``` 6 | machine launch images:ubuntu/22.04 vm1 7 | ``` 8 | 9 | Expanded 10 | 11 | machine init \ 12 | --name=vm1 \ 13 | --root-disk \ # source:dest:size:driver:block_size:devopts 14 | http://c.i.u/ubuntu-server/daily/current/22.04:\ 15 | ~/.local/share/machine/vm1/disks/root-disk.img:\ 16 | 100G:\ 17 | virtio-blk-pci:\ 18 | 512:\ 19 | serial=srcfile-size-format,bootindex=0 \ 20 | --extra-disk \ # 21 | --memory 1024M \ 22 | --smp 2 \ 23 | --uefi \ 24 | --enable-tmp \ 25 | --tpm-version 2.0 26 | --network \ # alias | name:type:id(empty autoallocate),netopts 27 | default:user:net0:hostfwd=tcp::-:22 \ 28 | --nic \ # driver:network:devopts 29 | virtio-net-pci::bootindex \ 30 | --save-config ~/.config/machine/vm1/machine.yaml 31 | --run 32 | 33 | 34 | Create a default machine: 35 | 36 | - smp 2 37 | - mem 1024 38 | - serial-console on pty, machine console vm1 39 | - virtio-net nic on -net user 40 | - acquire root-disk, and boot from it 41 | - boot via uefi with tmp 2.0 and secure-boot 42 | - write config to CONFIG path for the machine 43 | - headless by default (enable vnc/spice) 44 | - enable ssh forwarding, machine ssh vm1 45 | 46 | 47 | ## machine networking 48 | 49 | `network --type user citra` 50 | 51 | default, guest to host, no-guest-to-gest 52 | 53 | `network --type host-bridge --interface br1 cascade` 54 | 55 | Attach nics using this network to an existing host bridge, this requires 56 | sudo privs when launching. 57 | 58 | `network --type user-bridge fuggle` 59 | 60 | create a network-namespace called XX, in which a bridge is created, 61 | spawn pasta on host connecting to this namespace to allow network traffic 62 | to flow to the host network (and off box) 63 | 64 | if no VMs in bridge XX are running, then pasta is stopped and NS is removed. 65 | when any VM in bridge XX then a new NS is created and pasta launched. 66 | 67 | 68 | # PXE/ZOT Client scenario 69 | 70 | ``` 71 | machine create network --type user-bridge fuggle 72 | machine launch images:pxeserver:v1.2 pxe-server --network fuggle --cloud-cfg pxe.cfg 73 | machine launch images:zot:v1.0 z1 --extra-disk 500G --network fuggle --cloud-cfg zot.cfg 74 | machine launch --empty-disk 100G --network-fuggle 75 | ``` 76 | 77 | ``` 78 | machine launch img foobar --nic device=virtio-net network=foo --nic device=e1000 network=bar 79 | ``` 80 | 81 | 82 | ``` 83 | $ cat .config/machine/pxe-server/machine.yaml 84 | type: kvm 85 | ephemeral: false 86 | description: pxe-server for booting other machiens 87 | name: pxe-server 88 | config: 89 | cpus: 2 90 | memory: 2048M 91 | uefi: true 92 | secureboot: true 93 | tpm: true 94 | tpm-version: 2.0 95 | truststore: $XDG_DATA_DIR/machine/trust/project1 96 | disks: 97 | - file: $XDG_DATA_DIR/machine/pxe-server/root-disk.qcow2 98 | type: ssd 99 | attach: virtio 100 | bootindex: 0 101 | nics: 102 | - device: virtio-net 103 | network: fuggle 104 | mac: "random" 105 | id: nic0 106 | bootindex: 1 107 | config: | 108 | #cloud-config 109 | ... 110 | 111 | $ cat .config/machine/zot/machine.yaml 112 | type: kvm 113 | ephemeral: false 114 | description: zot oci service image 115 | name: zot 116 | config 117 | cpus: 2 118 | memory: 2048M 119 | uefi: true 120 | secureboot: true 121 | tpm: true 122 | tpm-version: 2.0 123 | truststore: $XDG_DATA_DIR/machine/trust/project1 124 | disks: 125 | - file: $XDG_DATA_DIR/machine/zot/01-root-disk.qcow2 126 | type: ssd 127 | attach: virtio 128 | bootindex: 0 129 | - file: $XDG_DATA_DIR/machine/zot/02-extra-disk.qcow2 130 | type: ssd 131 | attach: virtio 132 | bootindex: 1 133 | size: 500G 134 | cache: none 135 | nics: 136 | - device: virtio-net 137 | mac: "random" 138 | id: nic0 139 | bootindex: 2 140 | network: fuggle 141 | config: | 142 | #cloud-config 143 | ... 144 | 145 | ``` 146 | -------------------------------------------------------------------------------- /cmd/machined/README.md: -------------------------------------------------------------------------------- 1 | # machined 2 | 3 | 4 | machined install 5 | - if systemd system: 6 | - write machined.socket/machined.service systemd units to user path 7 | $XDG_CONFIG_HOME/systemd/user/ 8 | - calls systemctl --user daemon-reload 9 | - calls systemctl --user enable machined.socket 10 | - calls systemctl --user enable machined.server 11 | 12 | # install to user systemd config path 13 | machined install 14 | 15 | # overwite systemd units 16 | machined install --force 17 | 18 | # installs to host systemd path 19 | machined install --host 20 | 21 | # remove installed units 22 | machined remove 23 | 24 | # remove installed units from host systemd path 25 | sudo machined remove --host 26 | -------------------------------------------------------------------------------- /cmd/machined/cmd/install.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | 9 | "github.com/project-machine/machine/pkg/api" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // installCmd represents the install command 15 | var installCmd = &cobra.Command{ 16 | Use: "install", 17 | Short: "Install systemd --user unit files", 18 | Long: `Install systemd unit files for machined service with socket activation.`, 19 | RunE: doInstall, 20 | } 21 | 22 | func doInstall(cmd *cobra.Command, args []string) error { 23 | hostMode, _ := cmd.Flags().GetBool("host") 24 | unitPath, err := getSystemdUnitPath(hostMode) 25 | if err != nil { 26 | return fmt.Errorf("Failed to get Systemd Unit Path: %s", err) 27 | } 28 | if !api.PathExists(unitPath) { 29 | if err := api.EnsureDir(unitPath); err != nil { 30 | return fmt.Errorf("Failed to create Systemd Unit path %q: %s", unitPath, err) 31 | } 32 | } 33 | serviceUnit := filepath.Join(unitPath, MachinedServiceUnit) 34 | socketUnit := filepath.Join(unitPath, MachinedSocketUnit) 35 | customService := cmd.Flag("service-template").Value.String() 36 | serviceTemplate := getTemplate(customService, MachinedServiceTemplate) 37 | customSocket := cmd.Flag("socket-template").Value.String() 38 | socketTemplate := getTemplate(customSocket, MachinedSocketTemplate) 39 | 40 | // check if files exist and exit asking for --force flag 41 | overwrite, _ := cmd.Flags().GetBool("force") 42 | if api.PathExists(serviceUnit) && api.PathExists(socketUnit) { 43 | log.Infof("machined service and socket units already exist: %q, %q", serviceUnit, socketUnit) 44 | if !overwrite { 45 | return nil 46 | } 47 | log.Infof("--force specified, overwriting files") 48 | } 49 | log.Infof("machined missing service and/or socket unit(s), installing..") 50 | if !api.PathExists(serviceUnit) { 51 | if err := installTemplate(serviceTemplate, serviceUnit); err != nil { 52 | return fmt.Errorf("Failed to render template to %q: %s", serviceUnit, err) 53 | } 54 | } 55 | if !api.PathExists(socketUnit) { 56 | if err := installTemplate(socketTemplate, socketUnit); err != nil { 57 | return fmt.Errorf("Failed to render service to %q: %s", socketUnit, err) 58 | } 59 | } 60 | 61 | runCmd := exec.Command("systemctl", "--user", "daemon-reload") 62 | out, err := runCmd.CombinedOutput() 63 | if err != nil { 64 | return fmt.Errorf("Failed to 'daemon-reload' systemd --user: %s: %s", string(out), err) 65 | } 66 | 67 | runCmd = exec.Command("systemctl", "--user", "start", MachinedSocketUnit) 68 | out, err = runCmd.CombinedOutput() 69 | if err != nil { 70 | return fmt.Errorf("Failed to start unit %s: %s: %s", MachinedSocketUnit, string(out), err) 71 | } 72 | 73 | log.Infof("Checking machined.socket status...") 74 | runCmd = exec.Command("systemctl", "--no-pager", "--user", "status", MachinedSocketUnit) 75 | runCmd.Stdout = os.Stdout 76 | err = runCmd.Run() 77 | if err != nil { 78 | return fmt.Errorf("Failed to start unit %s: %s: %s", MachinedSocketUnit, string(out), err) 79 | } 80 | log.Infof("Useful systemctl commands:") 81 | log.Infof("") 82 | log.Infof(" systemctl --user status %s", MachinedSocketUnit) 83 | log.Infof(" systemctl --user status %s", MachinedServiceUnit) 84 | log.Infof("") 85 | log.Infof("To run an updated machined binary, run:") 86 | log.Infof("") 87 | log.Infof(" systemctl --user stop %s", MachinedServiceUnit) 88 | return nil 89 | } 90 | 91 | func init() { 92 | rootCmd.AddCommand(installCmd) 93 | installCmd.PersistentFlags().BoolP("host", "H", false, "install systemd units to /etc/systemd/system instead of systemd --user path") 94 | installCmd.PersistentFlags().BoolP("force", "f", false, "allow overwriting existing unit files when installing") 95 | installCmd.PersistentFlags().StringP("service-template", "s", "", "specify path to custom machined service template") 96 | installCmd.PersistentFlags().StringP("socket-template", "S", "", "specify path to custom machined socket template") 97 | } 98 | -------------------------------------------------------------------------------- /cmd/machined/cmd/main.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | package main 16 | 17 | func main() { 18 | Execute() 19 | } 20 | -------------------------------------------------------------------------------- /cmd/machined/cmd/remove.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | 9 | "github.com/project-machine/machine/pkg/api" 10 | log "github.com/sirupsen/logrus" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // removeCmd represents the remove command 15 | var removeCmd = &cobra.Command{ 16 | Use: "remove", 17 | Short: "Remove systemd --user unit files", 18 | Long: `Remove systemd unit files for machined service with socket activation.`, 19 | RunE: doRemove, 20 | } 21 | 22 | func doRemove(cmd *cobra.Command, args []string) error { 23 | hostMode, _ := cmd.Flags().GetBool("host") 24 | unitPath, err := getSystemdUnitPath(hostMode) 25 | if err != nil { 26 | return fmt.Errorf("Failed to get Systemd Unit Path: %s", err) 27 | } 28 | serviceUnit := filepath.Join(unitPath, MachinedServiceUnit) 29 | socketUnit := filepath.Join(unitPath, MachinedSocketUnit) 30 | removed := false 31 | if api.PathExists(serviceUnit) { 32 | log.Infof("Removing unit %s", serviceUnit) 33 | if err := os.Remove(serviceUnit); err != nil { 34 | return fmt.Errorf("Failed to remove %q: %s", serviceUnit, err) 35 | } 36 | removed = true 37 | runCmd := exec.Command("systemctl", "--user", "stop", MachinedServiceUnit) 38 | out, err := runCmd.CombinedOutput() 39 | if err != nil { 40 | return fmt.Errorf("Failed to stop unit %s: %s: %s", MachinedServiceUnit, string(out), err) 41 | } 42 | } 43 | if api.PathExists(socketUnit) { 44 | log.Infof("Removing unit %s", socketUnit) 45 | if err := os.Remove(socketUnit); err != nil { 46 | return fmt.Errorf("Failed to remove %q: %s", socketUnit, err) 47 | } 48 | removed = true 49 | log.Infof("Stopping unit %s", socketUnit) 50 | runCmd := exec.Command("systemctl", "--user", "stop", MachinedSocketUnit) 51 | out, err := runCmd.CombinedOutput() 52 | if err != nil { 53 | return fmt.Errorf("Failed to stop unit %s: %s: %s", MachinedSocketUnit, string(out), err) 54 | } 55 | } 56 | if removed { 57 | args := []string{"daemon-reload"} 58 | if !hostMode { 59 | args = append(args, "--user") 60 | } 61 | log.Infof("Reloading systemd units") 62 | runCmd := exec.Command("systemctl", args...) 63 | _, err := runCmd.CombinedOutput() 64 | if err != nil { 65 | return fmt.Errorf("Failed to reload units: %s", err) 66 | } 67 | } 68 | return nil 69 | } 70 | 71 | func init() { 72 | rootCmd.AddCommand(removeCmd) 73 | removeCmd.PersistentFlags().BoolP("host", "H", false, "remove systemd units in /etc/systemd/system instead of systemd --user path") 74 | } 75 | -------------------------------------------------------------------------------- /cmd/machined/cmd/root.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "math/rand" 8 | "net/http" 9 | "os" 10 | "os/signal" 11 | "path/filepath" 12 | "syscall" 13 | "text/template" 14 | "time" 15 | 16 | "github.com/project-machine/machine/pkg/api" 17 | log "github.com/sirupsen/logrus" 18 | "github.com/spf13/cobra" 19 | "github.com/spf13/viper" 20 | ) 21 | 22 | var cfgFile string 23 | 24 | // rootCmd represents the base command when called without any subcommands 25 | var rootCmd = &cobra.Command{ 26 | Use: "machined", 27 | Short: "A daemon to handle the lifecycle of machine machines", 28 | Long: `The daemon runs a RESTful service to interact with and manage 29 | machine machines.`, 30 | Run: doServerRun, 31 | } 32 | 33 | // common bits for install/remove commands 34 | const ( 35 | HostSystemdUnitPath = "/etc/systemd/system" 36 | MachinedServiceUnit = "machined.service" 37 | MachinedSocketUnit = "machined.socket" 38 | ) 39 | 40 | const MachinedServiceTemplate = `[Unit] 41 | Description=Machined Service 42 | Requires=machined.socket 43 | After=machined.socket 44 | StartLimitIntervalSec=0 45 | 46 | [Service] 47 | Delegate=true 48 | Type=exec 49 | KillMode=process 50 | ExecStart={{.MachinedBinaryPath}} 51 | 52 | [Install] 53 | WantedBy=default.target 54 | ` 55 | 56 | const MachinedSocketTemplate = ` 57 | [Unit] 58 | Description=Machined Socket 59 | 60 | [Socket] 61 | ListenStream=%t/machined/machined.socket 62 | SocketMode=0660 63 | 64 | [Install] 65 | WantedBy=sockets.target 66 | ` 67 | 68 | func getSystemdUnitPath(hostMode bool) (string, error) { 69 | if hostMode { 70 | return HostSystemdUnitPath, nil 71 | } 72 | ucd, err := api.UserConfigDir() 73 | if err != nil { 74 | return "", fmt.Errorf("Failed to get UserConfigDir via API: %s", err) 75 | } 76 | return filepath.Join(ucd, "systemd/user"), nil 77 | } 78 | 79 | // GetTemplate returns a template string, either from a specified file or default template 80 | func getTemplate(cliTemplateFile, defaultTemplate string) string { 81 | if cliTemplateFile != "" { 82 | content, err := ioutil.ReadFile(cliTemplateFile) 83 | if err == nil { 84 | return string(content) 85 | } 86 | } 87 | return defaultTemplate 88 | } 89 | 90 | func getMachinedBinaryPath() (string, error) { 91 | path, err := os.Executable() 92 | if err != nil { 93 | return "", fmt.Errorf("Failed to determine machined full path: %s", err) 94 | } 95 | return path, nil 96 | } 97 | 98 | func installTemplate(templateSource, target string) error { 99 | machinedPath, err := getMachinedBinaryPath() 100 | if err != nil { 101 | return fmt.Errorf("Failed to get path to machined binary: %s", err) 102 | } 103 | 104 | binpath := struct { 105 | MachinedBinaryPath string 106 | }{ 107 | MachinedBinaryPath: machinedPath, 108 | } 109 | 110 | tpl := template.New("systemd-unit") 111 | tpl, err = tpl.Parse(templateSource) 112 | if err != nil { 113 | return fmt.Errorf("Failed to parse provided template for target %q", target) 114 | } 115 | 116 | fh, err := os.Create(target) 117 | if err != nil { 118 | return fmt.Errorf("Failed to create target file %q: %s", target, err) 119 | } 120 | log.Infof("Installing %s", target) 121 | return tpl.Execute(fh, binpath) 122 | } 123 | 124 | func doServerRun(cmd *cobra.Command, args []string) { 125 | conf := api.DefaultMachineDaemonConfig() 126 | ctrl := api.NewController(conf) 127 | 128 | cwd, err := os.Getwd() 129 | if err != nil { 130 | panic(err) 131 | } 132 | log.Infof("machined starting up in %s", cwd) 133 | ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) 134 | defer stop() 135 | 136 | go func() { 137 | if err := ctrl.Run(ctx); err != nil && err != http.ErrServerClosed { 138 | panic(err) 139 | } 140 | }() 141 | <-ctx.Done() 142 | log.Infof("machined shutting down gracefully, press Ctrl+C again to force") 143 | log.Infof("machined notifying all machines to shutdown... (FIXME)") 144 | log.Infof("machined waiting up to %s seconds\n", "30") 145 | if err := ctrl.MachineController.StopMachines(); err != nil { 146 | log.Errorf("Failure during machine shutdown: %s\n", err) 147 | } 148 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) 149 | defer cancel() 150 | ctrl.Shutdown(ctx) 151 | log.Infof("machined exiting") 152 | } 153 | 154 | // Execute adds all child commands to the root command and sets flags appropriately. 155 | // This is called by main.main(). It only needs to happen once to the rootCmd. 156 | func Execute() { 157 | err := rootCmd.Execute() 158 | if err != nil { 159 | os.Exit(1) 160 | } 161 | } 162 | 163 | func init() { 164 | // init our rng 165 | rand.Seed(time.Now().UTC().UnixNano()) 166 | 167 | cobra.OnInitialize(initConfig) 168 | rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.server.yaml)") 169 | } 170 | 171 | // initConfig reads in config file and ENV variables if set. 172 | func initConfig() { 173 | if cfgFile != "" { 174 | // Use config file from the flag. 175 | viper.SetConfigFile(cfgFile) 176 | } else { 177 | // Find home directory. 178 | home, err := os.UserHomeDir() 179 | cobra.CheckErr(err) 180 | 181 | // Search config in home directory with name ".server" (without extension). 182 | viper.AddConfigPath(home) 183 | viper.SetConfigType("yaml") 184 | viper.SetConfigName(".server") 185 | } 186 | 187 | viper.AutomaticEnv() // read in environment variables that match 188 | 189 | // If a config file is found, read it in. 190 | if err := viper.ReadInConfig(); err == nil { 191 | fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) 192 | } 193 | 194 | // TODO: 195 | // parse the config 196 | // for each on-disk machine, read in the yaml and post the struct 197 | } 198 | -------------------------------------------------------------------------------- /doc/examples/01-secureboot-ubuntu-livecd.yaml: -------------------------------------------------------------------------------- 1 | name: 01-secure-boot-server 2 | type: kvm 3 | ephemeral: false 4 | description: A fresh VM booting Ubuntu Server LiveCD in SecureBoot mode with TPM 5 | config: 6 | name: 01-secure-boot-server 7 | boot: cdrom 8 | uefi: true 9 | tpm: true 10 | tpm-version: 2.0 11 | secure-boot: true 12 | # cdrom: import/ubuntu-22.04.2-desktop-amd64.iso 13 | cdrom: import/jammy-live-server-amd64.iso 14 | disks: 15 | - file: root-disk.qcow 16 | type: ssd 17 | size: 50GiB 18 | -------------------------------------------------------------------------------- /doc/examples/02-provision-with-heimdall.yaml: -------------------------------------------------------------------------------- 1 | name: vm2 2 | type: kvm 3 | ephemeral: false 4 | description: provision disk/tpm from heimdall 5 | config: 6 | name: vm2 7 | boot: cdrom 8 | uefi: true 9 | tpm: true 10 | tpm-version: 2.0 11 | secure-boot: true 12 | uefi-vars: import/ovmf_vars-snakeoil.fd 13 | cdrom: import/heimdall-0.0.12-snakeoil.iso 14 | disks: 15 | - file: root-disk.qcow 16 | type: ssd 17 | size: 100GiB 18 | -------------------------------------------------------------------------------- /doc/examples/03-install.yaml: -------------------------------------------------------------------------------- 1 | name: vm3 2 | type: kvm 3 | ephemeral: false 4 | description: install machine-os to system 5 | config: 6 | name: vm3 7 | boot: cdrom 8 | uefi: true 9 | tpm: true 10 | tpm-version: 2.0 11 | secure-boot: true 12 | uefi-vars: import/ovmf_vars-snakeoil.fd 13 | cdrom: import/machineos-lvm.iso 14 | disks: 15 | - file: root-disk.qcow 16 | type: ssd 17 | size: 100GiB 18 | - file: extra-disk.qcow 19 | type: hdd 20 | size: 650GiB 21 | -------------------------------------------------------------------------------- /doc/examples/vm-network-ports.yaml: -------------------------------------------------------------------------------- 1 | type: kvm 2 | description: example vm showing multiple host/guest port forward syntax 3 | ephemeral: false 4 | name: slick-seal 5 | config: 6 | name: slick-seal 7 | cpus: 2 8 | memory: 2048 9 | serial: "true" 10 | nics: 11 | - device: virtio-net 12 | id: nic0 13 | mac: 52:54:00:81:91:9a 14 | network: user 15 | romfile: "/usr/share/qemu/pxe-virtio.rom" # optionally specify rom instead of built-in one 16 | ports: 17 | - protocol: tcp 18 | host: 19 | address: "" 20 | port: 22222 21 | guest: 22 | address: "" 23 | port: 22 24 | - protocol: tcp 25 | host: 26 | address: "" 27 | port: 8080 28 | guest: 29 | address: "" 30 | port: 80 31 | bootindex: "0" 32 | - device: virtio-net 33 | id: nic1 34 | mac: 52:54:00:73:28:1a 35 | network: user 36 | romfile: "off" # disable built-in rom per qcli.DisabledNetDeviceROMFile 37 | bootindex: "off" # prevents qemu from including this device in OVMF Boot list 38 | disks: 39 | - file: root.img 40 | format: raw 41 | size: 0 42 | attach: virtio 43 | type: ssd 44 | bootindex: "1" 45 | boot: "" 46 | cdrom: "" 47 | uefi-vars: /tmp/uefi_nvram-efi-shell.fd 48 | tpm: true 49 | tpm-version: "2.0" 50 | secure-boot: false 51 | gui: true 52 | -------------------------------------------------------------------------------- /doc/examples/vm-with-cloud-init.yaml: -------------------------------------------------------------------------------- 1 | name: f40-vm1 2 | type: kvm 3 | ephemeral: false 4 | description: Fedora 40 Beta with UKI 5 | config: 6 | name: f40-vm1 7 | uefi: true 8 | tpm: true 9 | gui: false 10 | tpm-version: 2.0 11 | secure-boot: false 12 | uefi-code: /usr/share/OVMF/OVMF_CODE.fd 13 | disks: 14 | - file: import/Fedora-Cloud-Base-UEFI-UKI.x86_64-40-1.10.qcow2 15 | type: ssd 16 | format: qcow2 17 | cloud-init: 18 | user-data: | 19 | #cloud-config 20 | password: 21 | chpasswd: { expire: False } 22 | ssh_pwauth: True 23 | ssh-authorized-keys: 24 | - | 25 | ssh-ed25519 xxxxx 26 | -------------------------------------------------------------------------------- /doc/min.yaml: -------------------------------------------------------------------------------- 1 | name: c1 2 | type: kvm 3 | ephemeral: false 4 | description: the minimal amount of config to define a machine 5 | name: vm1-minimal 6 | config: 7 | disks: 8 | - file: home/vm1-disk.qcow2 9 | type: ssd 10 | -------------------------------------------------------------------------------- /doc/test-qcli.yaml: -------------------------------------------------------------------------------- 1 | type: kvm 2 | description: Test machine for qcli VM config 3 | ephemeral: false 4 | name: test-qcli 5 | config: 6 | cpus: 16 7 | memory: 16384 8 | uefi: true 9 | secureboot: false 10 | tpm: true 11 | tpm-version: 2.0 12 | disks: 13 | - file: import/barehost-lvm-uefi.qcow2 14 | format: qcow2 15 | type: ssd 16 | attach: virtio 17 | bootindex: 0 18 | - file: vm1-data.raw 19 | size: 100G 20 | format: raw 21 | attach: virtio 22 | type: ssd 23 | nics: 24 | - device: e1000 25 | addr: 3 26 | mac: "aa:bb:cc:dd:ee:ff" 27 | id: mgmt0 28 | network: user 29 | - device: virtio-net 30 | addr: 4 31 | mac: "ff:ee:dd:cc:bb:aa" 32 | id: fabric0 33 | network: user 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/project-machine/machine 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf 9 | github.com/dustin/go-humanize v1.0.0 10 | github.com/dustinkirkland/golang-petname v0.0.0-20191129215211-8e5a1ed0cff0 11 | github.com/gin-gonic/gin v1.9.1 12 | github.com/go-resty/resty/v2 v2.7.0 13 | github.com/google/uuid v1.3.0 14 | github.com/lxc/lxd v0.0.0-20221130220346-2c77027b7a5e 15 | github.com/mitchellh/go-homedir v1.1.0 16 | github.com/msoap/byline v1.1.1 17 | github.com/pkg/errors v0.9.1 18 | github.com/project-machine/qcli v0.3.1 19 | github.com/rodaine/table v1.1.0 20 | github.com/sirupsen/logrus v1.9.0 21 | github.com/spf13/cobra v1.6.1 22 | github.com/spf13/viper v1.14.0 23 | golang.org/x/sys v0.31.0 24 | gopkg.in/yaml.v2 v2.4.0 25 | ) 26 | 27 | require ( 28 | github.com/bytedance/sonic v1.9.1 // indirect 29 | github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect 30 | github.com/flosch/pongo2 v0.0.0-20200913210552-0d938eb266f3 // indirect 31 | github.com/fsnotify/fsnotify v1.6.0 // indirect 32 | github.com/gabriel-vasile/mimetype v1.4.2 // indirect 33 | github.com/gin-contrib/sse v0.1.0 // indirect 34 | github.com/go-playground/locales v0.14.1 // indirect 35 | github.com/go-playground/universal-translator v0.18.1 // indirect 36 | github.com/go-playground/validator/v10 v10.14.0 // indirect 37 | github.com/goccy/go-json v0.10.2 // indirect 38 | github.com/gorilla/websocket v1.5.0 // indirect 39 | github.com/hashicorp/hcl v1.0.0 // indirect 40 | github.com/inconshreveable/mousetrap v1.0.1 // indirect 41 | github.com/json-iterator/go v1.1.12 // indirect 42 | github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect 43 | github.com/klauspost/cpuid/v2 v2.2.4 // indirect 44 | github.com/leodido/go-urn v1.2.4 // indirect 45 | github.com/magiconair/properties v1.8.6 // indirect 46 | github.com/mattn/go-isatty v0.0.19 // indirect 47 | github.com/mitchellh/mapstructure v1.5.0 // indirect 48 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 49 | github.com/modern-go/reflect2 v1.0.2 // indirect 50 | github.com/pborman/uuid v1.2.1 // indirect 51 | github.com/pelletier/go-toml v1.9.5 // indirect 52 | github.com/pelletier/go-toml/v2 v2.0.8 // indirect 53 | github.com/pkg/xattr v0.4.9 // indirect 54 | github.com/robfig/cron/v3 v3.0.1 // indirect 55 | github.com/spf13/afero v1.9.3 // indirect 56 | github.com/spf13/cast v1.5.0 // indirect 57 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 58 | github.com/spf13/pflag v1.0.5 // indirect 59 | github.com/subosito/gotenv v1.4.1 // indirect 60 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 61 | github.com/ugorji/go/codec v1.2.11 // indirect 62 | github.com/yourbasic/bit v0.0.0-20180313074424-45a4409f4082 // indirect 63 | golang.org/x/arch v0.3.0 // indirect 64 | golang.org/x/crypto v0.36.0 // indirect 65 | golang.org/x/net v0.36.0 // indirect 66 | golang.org/x/term v0.30.0 // indirect 67 | golang.org/x/text v0.23.0 // indirect 68 | google.golang.org/protobuf v1.33.0 // indirect 69 | gopkg.in/ini.v1 v1.67.0 // indirect 70 | gopkg.in/yaml.v3 v3.0.1 // indirect 71 | ) 72 | -------------------------------------------------------------------------------- /pkg/api/cloudinit.go: -------------------------------------------------------------------------------- 1 | /* 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | */ 14 | package api 15 | 16 | import ( 17 | "fmt" 18 | "os" 19 | "path/filepath" 20 | 21 | "github.com/google/uuid" 22 | log "github.com/sirupsen/logrus" 23 | "gopkg.in/yaml.v2" 24 | ) 25 | 26 | const ( 27 | NoCloudFSLabel = "cidata" 28 | ) 29 | 30 | /* 31 | type: kvm 32 | config: 33 | 34 | name: slick-seal 35 | ... 36 | cloud-init: 37 | user-data:| 38 | #cloud-config 39 | runcmd: 40 | - cat /etc/os-release 41 | network-config:| 42 | version: 2 43 | ethernets: 44 | nic0: 45 | match: 46 | name: en* 47 | dhcp4: true 48 | meta-data:| 49 | instance-id: 08b2083d-2935-4d50-a442-d1da8920de20 50 | local-hostname: slick-seal 51 | */ 52 | 53 | type CloudInitConfig struct { 54 | NetworkConfig string `yaml:"network-config"` 55 | UserData string `yaml:"user-data"` 56 | MetaData string `yaml:"meta-data"` 57 | } 58 | 59 | type MetaData struct { 60 | InstanceId string `yaml:"instance-id"` 61 | LocalHostname string `yaml:"local-hostname"` 62 | } 63 | 64 | func HasCloudConfig(config CloudInitConfig) bool { 65 | 66 | if config.MetaData != "" { 67 | return true 68 | } 69 | if config.UserData != "" { 70 | return true 71 | } 72 | if config.NetworkConfig != "" { 73 | return true 74 | } 75 | return false 76 | } 77 | 78 | func PrepareMetadata(config *CloudInitConfig, hostname string) error { 79 | // update MetaData with local-hostname and instance-id if not set 80 | 81 | if config.MetaData != "" { 82 | return fmt.Errorf("cloud-init config has existing metadata") 83 | } 84 | 85 | iid := uuid.New() 86 | 87 | md := MetaData{ 88 | InstanceId: iid.String(), 89 | LocalHostname: hostname, 90 | } 91 | 92 | content, err := yaml.Marshal(&md) 93 | if err != nil { 94 | return fmt.Errorf("failed to marshal metadata: %s", err) 95 | } 96 | 97 | config.MetaData = string(content) 98 | 99 | return nil 100 | } 101 | 102 | func RenderCloudInitConfig(config CloudInitConfig, outputPath string) error { 103 | 104 | renderedFiles := 0 105 | for _, d := range []struct { 106 | confFile string 107 | confData string 108 | }{ 109 | { 110 | confFile: "network-config", 111 | confData: config.NetworkConfig, 112 | }, 113 | { 114 | confFile: "user-data", 115 | confData: config.UserData, 116 | }, 117 | { 118 | confFile: "meta-data", 119 | confData: config.MetaData, 120 | }, 121 | } { 122 | if len(d.confData) > 0 { 123 | configFile := filepath.Join(outputPath, d.confFile) 124 | tempFile, err := os.CreateTemp("", "tmp-cloudinit-") 125 | if err != nil { 126 | return fmt.Errorf("failed to create a temp file for writing cloud-init %s file: %s", d.confFile, err) 127 | } 128 | defer tempFile.Close() 129 | defer os.Remove(tempFile.Name()) 130 | if err := os.WriteFile(tempFile.Name(), []byte(d.confData), 0666); err != nil { 131 | return fmt.Errorf("failed to write cloud-init %s file %q: %s", d.confFile, tempFile.Name(), err) 132 | } 133 | if err := os.Rename(tempFile.Name(), configFile); err != nil { 134 | return fmt.Errorf("failed to rename temp file %q to %q: %s", tempFile.Name(), configFile, err) 135 | } 136 | renderedFiles++ 137 | } 138 | } 139 | if renderedFiles == 0 { 140 | return fmt.Errorf("failed to render any cloud-init config files; maybe empty cloud-init config?") 141 | } 142 | return nil 143 | } 144 | 145 | func verifyCloudInitConfig(cfg CloudInitConfig, contentsDir string) error { 146 | 147 | // read the extracted directory and validate CloudInitConfig files 148 | err := filepath.Walk(contentsDir, func(path string, info os.FileInfo, err error) error { 149 | if err != nil { 150 | return err 151 | } 152 | 153 | if !info.IsDir() { 154 | contents, err := os.ReadFile(path) 155 | if err != nil { 156 | return err 157 | } 158 | log.Infof("verifyCloudInitCfg: path:%s name:%s contents:%s", path, info.Name(), contents) 159 | switch info.Name() { 160 | case "network-config": 161 | if cfg.NetworkConfig != string(contents) { 162 | return fmt.Errorf("network-config: expected contents %q, got %q", cfg.NetworkConfig, string(contents)) 163 | } 164 | case "user-data": 165 | if cfg.UserData != string(contents) { 166 | return fmt.Errorf("user-data: expected contents %q, got %q", cfg.UserData, string(contents)) 167 | } 168 | case "meta-data": 169 | if cfg.MetaData != string(contents) { 170 | return fmt.Errorf("meta-data: expected contents %q, got %q", cfg.MetaData, string(contents)) 171 | } 172 | default: 173 | return fmt.Errorf("Unexpected file %q in cloud-init rendered directory", info.Name()) 174 | } 175 | } else { 176 | if info.Name() != filepath.Base(path) { 177 | return fmt.Errorf("Unexpected directory %q in cloud-init rendered directory", info.Name()) 178 | } 179 | } 180 | 181 | return nil 182 | }) 183 | 184 | return err 185 | } 186 | 187 | func CreateLocalDataSource(cfg CloudInitConfig, directory string) error { 188 | 189 | if err := EnsureDir(directory); err != nil { 190 | return fmt.Errorf("failed to create cloud-init data source directory %q: %s", directory, err) 191 | } 192 | 193 | if err := RenderCloudInitConfig(cfg, directory); err != nil { 194 | return fmt.Errorf("failed to render cloud-init config to directory %q: %s", directory, err) 195 | } 196 | 197 | if err := verifyCloudInitConfig(cfg, directory); err != nil { 198 | return fmt.Errorf("failed to verify cloud-init config content in directory %q: %s", directory, err) 199 | } 200 | 201 | return nil 202 | } 203 | -------------------------------------------------------------------------------- /pkg/api/cloudinit_test.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestCloudInitRendersConfig(t *testing.T) { 9 | 10 | cfg := CloudInitConfig{ 11 | NetworkConfig: "network-config", 12 | UserData: "user-data", 13 | MetaData: "meta-data", 14 | } 15 | 16 | tmpDir, err := os.MkdirTemp("", "test-ci-render-config") 17 | if err != nil { 18 | t.Fatalf("failed to create a tempdir for test") 19 | } 20 | defer os.RemoveAll(tmpDir) 21 | 22 | err = RenderCloudInitConfig(cfg, tmpDir) 23 | if err != nil { 24 | t.Fatalf("unexpected error when rendering cloud-init config: %s", err) 25 | } 26 | 27 | err = verifyCloudInitConfig(cfg, tmpDir) 28 | if err != nil { 29 | t.Fatalf("failed to verify rendered contents: %s", err) 30 | } 31 | } 32 | 33 | func TestCloudInitRenderConfigFailsOnEmpty(t *testing.T) { 34 | 35 | cfg := CloudInitConfig{ 36 | NetworkConfig: "", 37 | UserData: "", 38 | MetaData: "", 39 | } 40 | 41 | tmpDir, err := os.MkdirTemp("", "test-ci-render-config") 42 | if err != nil { 43 | t.Fatalf("failed to create a tempdir for test") 44 | } 45 | defer os.RemoveAll(tmpDir) 46 | 47 | err = RenderCloudInitConfig(cfg, tmpDir) 48 | if err == nil { 49 | t.Fatalf("expected empty config to return an error, got nil instead") 50 | } 51 | } 52 | 53 | func TestPrepareMetadataUpdatesConfig(t *testing.T) { 54 | vmCfg := VMDef{ 55 | Name: "myVM1", 56 | CloudInit: CloudInitConfig{ 57 | NetworkConfig: "network-config", 58 | UserData: "user-data", 59 | MetaData: "", 60 | }, 61 | } 62 | 63 | err := PrepareMetadata(&vmCfg.CloudInit, vmCfg.Name) 64 | if err != nil { 65 | t.Fatalf("failed to prepare metadata: %s", err) 66 | } 67 | 68 | // log.Infof("vmCfg: %+v", vmCfg) 69 | if vmCfg.CloudInit.MetaData == "" { 70 | t.Fatalf("failed to update metadata, it's empty") 71 | } 72 | 73 | tmpDir, err := os.MkdirTemp("", "test-ci-render-config") 74 | if err != nil { 75 | t.Fatalf("failed to create a tempdir for test") 76 | } 77 | defer os.RemoveAll(tmpDir) 78 | 79 | err = RenderCloudInitConfig(vmCfg.CloudInit, tmpDir) 80 | if err != nil { 81 | t.Fatalf("unexpected error when rendering cloud-init config: %s", err) 82 | } 83 | } 84 | 85 | func TestCloudInitCreatesDataSource(t *testing.T) { 86 | 87 | cfg := CloudInitConfig{ 88 | NetworkConfig: "network-config", 89 | UserData: "user-data", 90 | MetaData: "meta-data", 91 | } 92 | 93 | seedDir, err := os.MkdirTemp("", "test-ci-seed") 94 | if err != nil { 95 | t.Fatalf("failed to create a tempdir for test") 96 | } 97 | defer os.RemoveAll(seedDir) 98 | 99 | err = CreateLocalDataSource(cfg, seedDir) 100 | if err != nil { 101 | t.Fatalf("failed to cloud-init datasource: %s", err) 102 | } 103 | 104 | } 105 | 106 | func TestPrepareMetadataUpdatesPresentInDataSource(t *testing.T) { 107 | vmCfg := VMDef{ 108 | Name: "myVM1", 109 | CloudInit: CloudInitConfig{ 110 | NetworkConfig: "network-config", 111 | UserData: "user-data", 112 | MetaData: "", 113 | }, 114 | } 115 | 116 | err := PrepareMetadata(&vmCfg.CloudInit, vmCfg.Name) 117 | if err != nil { 118 | t.Fatalf("failed to prepare metadata: %s", err) 119 | } 120 | 121 | seedDir, err := os.MkdirTemp("", "test-ci-seed") 122 | if err != nil { 123 | t.Fatalf("failed to create a tempdir for test") 124 | } 125 | defer os.RemoveAll(seedDir) 126 | 127 | // log.Infof("vmCfg: %+v", vmCfg) 128 | if vmCfg.CloudInit.MetaData == "" { 129 | t.Fatalf("failed to update metadata, it's empty") 130 | } 131 | 132 | err = CreateLocalDataSource(vmCfg.CloudInit, seedDir) 133 | if err != nil { 134 | t.Fatalf("failed to create data source: %s", err) 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /pkg/api/config.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | package api 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "os" 21 | "path/filepath" 22 | ) 23 | 24 | type MachineDaemonConfig struct { 25 | ConfigDirectory string 26 | DataDirectory string 27 | StateDirectory string 28 | } 29 | 30 | var ( 31 | mdcCtx = "mdc-context" 32 | mdcCtxConfDir = mdcCtx + "-confdir" 33 | mdcCtxDataDir = mdcCtx + "-datadir" 34 | mdcCtxStateDir = mdcCtx + "-statedir" 35 | ) 36 | 37 | func DefaultMachineDaemonConfig() *MachineDaemonConfig { 38 | cfg := MachineDaemonConfig{} 39 | udd, err := UserDataDir() 40 | if err != nil { 41 | panic(fmt.Sprintf("Error getting user data dir: %s", err)) 42 | } 43 | ucd, err := UserConfigDir() 44 | if err != nil { 45 | panic(fmt.Sprintf("Error getting user config dir: %s", err)) 46 | } 47 | usd, err := UserStateDir() 48 | if err != nil { 49 | panic(fmt.Sprintf("Error getting user state dir: %s", err)) 50 | } 51 | cfg.ConfigDirectory = filepath.Join(ucd, "machine") 52 | cfg.DataDirectory = filepath.Join(udd, "machine") 53 | cfg.StateDirectory = filepath.Join(usd, "machine") 54 | return &cfg 55 | } 56 | 57 | func (c *MachineDaemonConfig) GetConfigContext() context.Context { 58 | ctx := context.Background() 59 | ctx = context.WithValue(ctx, mdcCtxConfDir, c.ConfigDirectory) 60 | ctx = context.WithValue(ctx, mdcCtxDataDir, c.DataDirectory) 61 | ctx = context.WithValue(ctx, mdcCtxStateDir, c.StateDirectory) 62 | return ctx 63 | } 64 | 65 | // XDG_RUNTIME_DIR 66 | func UserRuntimeDir() (string, error) { 67 | env := "XDG_RUNTIME_DIR" 68 | if v := os.Getenv(env); v != "" { 69 | return v, nil 70 | } 71 | uid := os.Getuid() 72 | return fmt.Sprintf("/run/user/%d", uid), nil 73 | } 74 | 75 | // XDG_DATA_HOME 76 | func UserDataDir() (string, error) { 77 | env := "XDG_DATA_HOME" 78 | if v := os.Getenv(env); v != "" { 79 | return v, nil 80 | } 81 | p, err := os.UserHomeDir() 82 | if err != nil { 83 | return "", err 84 | } 85 | return filepath.Join(p, ".local", "share"), nil 86 | } 87 | 88 | // XDG_CONFIG_HOME 89 | func UserConfigDir() (string, error) { 90 | env := "XDG_CONFIG_HOME" 91 | if v := os.Getenv(env); v != "" { 92 | return v, nil 93 | } 94 | p, err := os.UserHomeDir() 95 | if err != nil { 96 | return "", err 97 | } 98 | return filepath.Join(p, ".config"), nil 99 | } 100 | 101 | // XDG_STATE_HOME 102 | func UserStateDir() (string, error) { 103 | env := "XDG_STATE_HOME" 104 | if v := os.Getenv(env); v != "" { 105 | return v, nil 106 | } 107 | p, err := os.UserHomeDir() 108 | if err != nil { 109 | return "", err 110 | } 111 | return filepath.Join(p, ".local", "state"), nil 112 | } 113 | -------------------------------------------------------------------------------- /pkg/api/controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | */ 14 | package api 15 | 16 | import ( 17 | "context" 18 | "fmt" 19 | "net" 20 | "net/http" 21 | "os" 22 | "path/filepath" 23 | "sync" 24 | 25 | "github.com/coreos/go-systemd/activation" 26 | "github.com/gin-gonic/gin" 27 | log "github.com/sirupsen/logrus" 28 | ) 29 | 30 | type Controller struct { 31 | Config *MachineDaemonConfig 32 | Router *gin.Engine 33 | MachineController MachineController 34 | Server *http.Server 35 | wgShutDown *sync.WaitGroup 36 | portNumber int 37 | } 38 | 39 | func NewController(config *MachineDaemonConfig) *Controller { 40 | var controller Controller 41 | 42 | controller.Config = config 43 | controller.wgShutDown = new(sync.WaitGroup) 44 | 45 | return &controller 46 | } 47 | 48 | func (c *Controller) Run(ctx context.Context) error { 49 | // load existing machines 50 | machineDir := filepath.Join(c.Config.ConfigDirectory, "machines") 51 | if PathExists(machineDir) { 52 | log.Infof("Loading saved machine configs...") 53 | err := filepath.Walk(machineDir, func(path string, info os.FileInfo, err error) error { 54 | if err != nil { 55 | return err 56 | } 57 | if info.IsDir() { 58 | machineConf := filepath.Join(path, "machine.yaml") 59 | if PathExists(machineConf) { 60 | newMachine, err := LoadConfig(machineConf) 61 | if err != nil { 62 | return err 63 | } 64 | newMachine.ctx = c.Config.GetConfigContext() 65 | log.Infof(" loaded machine %s", newMachine.Name) 66 | c.MachineController.Machines = append(c.MachineController.Machines, newMachine) 67 | } 68 | } 69 | return nil 70 | }) 71 | if err != nil { 72 | return err 73 | } 74 | } 75 | 76 | unixSocket := APISocketPath() 77 | if len(unixSocket) == 0 { 78 | panic("Failed to get an API Socket path") 79 | } 80 | log.Infof("Using machined API socket: %s", unixSocket) 81 | 82 | // mkdir -p on dirname(unixSocet) 83 | err := os.MkdirAll(filepath.Dir(unixSocket), 0755) 84 | if err != nil { 85 | panic(fmt.Sprintf("Failed to create directory path to: %s", unixSocket)) 86 | } 87 | 88 | // handle systemd socket activation 89 | listeners, err := activation.Listeners() 90 | if err != nil { 91 | panic(err) 92 | } 93 | 94 | // configure engine, router, and server 95 | engine := gin.Default() 96 | c.Router = engine 97 | _ = NewRouteHandler(c) 98 | c.Server = &http.Server{Handler: c.Router.Handler()} 99 | 100 | // either systemd socket unit isn't started or we're not using systemd 101 | if len(listeners) > 0 { 102 | for _, listener := range listeners { 103 | if listener != nil { 104 | log.Infof("machined service starting service via socket activation") 105 | return c.Server.Serve(listeners[0]) 106 | } 107 | } 108 | } 109 | log.Infof("No systemd socket activation, falling back on direct listen") 110 | 111 | // FIXME to check if another machined is running/pidfile?, flock? 112 | if PathExists(unixSocket) { 113 | os.Remove(unixSocket) 114 | } 115 | defer os.Remove(unixSocket) 116 | 117 | // re-implement gin.Engine.RunUnix() so we can set the context ourselves 118 | listener, err := net.Listen("unix", unixSocket) 119 | if err != nil { 120 | panic("Failed to create a unix socket listener") 121 | } 122 | defer listener.Close() 123 | 124 | return c.Server.Serve(listener) 125 | } 126 | 127 | func (c *Controller) InitMachineController(ctx context.Context) error { 128 | c.MachineController = MachineController{} 129 | 130 | // TODO 131 | // look for serialized Machine configuration files in data dir 132 | // for each one, read them in and add to the Controller 133 | return nil 134 | } 135 | 136 | func (c *Controller) Shutdown(ctx context.Context) error { 137 | c.wgShutDown.Wait() 138 | if err := c.Server.Shutdown(ctx); err != nil && err != http.ErrServerClosed { 139 | return err 140 | } 141 | return nil 142 | } 143 | -------------------------------------------------------------------------------- /pkg/api/disk.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | package api 16 | 17 | import ( 18 | "fmt" 19 | "path" 20 | "path/filepath" 21 | "strings" 22 | 23 | humanize "github.com/dustin/go-humanize" 24 | log "github.com/sirupsen/logrus" 25 | ) 26 | 27 | type DiskSize int64 28 | 29 | func (s *DiskSize) UnmarshalYAML(unmarshal func(interface{}) error) error { 30 | var strVal string 31 | if err := unmarshal(&strVal); err != nil { 32 | return err 33 | } 34 | ut, err := humanize.ParseBytes(strVal) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | *s = DiskSize(ut) 40 | return nil 41 | } 42 | 43 | type QemuDisk struct { 44 | File string `yaml:"file,omitempty"` 45 | Format string `yaml:"format,omitempty"` 46 | Size DiskSize `yaml:"size"` 47 | Attach string `yaml:"attach,omitempty"` 48 | Type string `yaml:"type"` 49 | BlockSize int `yaml:"blocksize,omitempty"` 50 | BusAddr string `yaml:"addr,omitempty"` 51 | BootIndex string `yaml:"bootindex,omitempty"` 52 | ReadOnly bool `yaml:"read-only,omitempty"` 53 | } 54 | 55 | func (q *QemuDisk) Sanitize(basedir string) error { 56 | validate := func(name string, found string, valid ...string) string { 57 | for _, i := range valid { 58 | if found == i { 59 | return "" 60 | } 61 | } 62 | return fmt.Sprintf("invalid %s: found %s expected %v", name, found, valid) 63 | } 64 | 65 | errors := []string{} 66 | 67 | if q.Format == "" { 68 | q.Format = "qcow2" 69 | } 70 | 71 | if q.Type == "" { 72 | q.Type = "ssd" 73 | } 74 | 75 | if q.Attach == "" { 76 | q.Attach = "scsi" 77 | } 78 | 79 | if q.File == "" { 80 | errors = append(errors, "empty File") 81 | } 82 | 83 | if !strings.Contains(q.File, "/") { 84 | q.File = path.Join(basedir, q.File) 85 | } 86 | 87 | if msg := validate("format", q.Format, "qcow2", "raw"); msg != "" { 88 | errors = append(errors, msg) 89 | } 90 | 91 | if msg := validate("attach", q.Attach, "scsi", "nvme", "virtio", "ide", "usb"); msg != "" { 92 | errors = append(errors, msg) 93 | } 94 | 95 | if msg := validate("type", q.Type, "hdd", "ssd", "cdrom"); msg != "" { 96 | errors = append(errors, msg) 97 | } 98 | 99 | if len(errors) != 0 { 100 | return fmt.Errorf("bad disk %#v: %s", q, strings.Join(errors, "\n")) 101 | } 102 | 103 | return nil 104 | } 105 | 106 | // Create - create the qemu disk at fpath or its File if it does not exist. 107 | func (q *QemuDisk) Create() error { 108 | if q.Type == "cdrom" { 109 | log.Debugf("Ignoring Create on QemuDisk.Name:%s wth Type 'cdrom'", q.File) 110 | return nil 111 | } else if q.Size == 0 { 112 | log.Debugf("Ignoring Create on QemuDisk.Name:%s with Size '0'", q.File) 113 | return nil 114 | } 115 | log.Infof("Creating %s type %s size %d attach %s", q.File, q.Format, q.Size, q.Attach) 116 | cmd := []string{"qemu-img", "create", "-f", q.Format, q.File, fmt.Sprintf("%d", q.Size)} 117 | out, err, rc := RunCommandWithOutputErrorRc(cmd...) 118 | if rc != 0 { 119 | return fmt.Errorf("qemu-img create failed: %v\n rc: %d\n out: %s\n, err: %s", 120 | cmd, rc, out, err) 121 | } 122 | return nil 123 | } 124 | 125 | func (q *QemuDisk) serial() string { 126 | // serial gets basename without extension 127 | ext := filepath.Ext(q.File) 128 | s := path.Base(q.File[0 : len(q.File)-len(ext)]) 129 | if q.Type == "ssd" && q.Attach == "virtio" && !strings.HasPrefix("ssd-", s) { 130 | // virtio-blk does not support rotation_rate. Some places (partition-helpers and disko) 131 | // determine that a disk is an ssd if it's serial starts with 'ssd-' 132 | s = "ssd-" + s 133 | } 134 | return s 135 | } 136 | -------------------------------------------------------------------------------- /pkg/api/machine.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | package api 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "io/ioutil" 21 | "os" 22 | "path/filepath" 23 | "sync" 24 | 25 | log "github.com/sirupsen/logrus" 26 | "gopkg.in/yaml.v2" 27 | ) 28 | 29 | const ( 30 | MachineStatusInitialized string = "initialized" 31 | MachineStatusStopped string = "stopped" 32 | MachineStatusStarting string = "starting" 33 | MachineStatusRunning string = "running" 34 | MachineStatusStopping string = "stopping" 35 | MachineStatusFailed string = "failed" 36 | SerialConsole string = "console" 37 | VGAConsole string = "vga" 38 | ) 39 | 40 | type StopChannel chan struct{} 41 | 42 | type MachineController struct { 43 | Machines []Machine 44 | } 45 | 46 | type Machine struct { 47 | ctx context.Context 48 | Type string `yaml:"type"` 49 | Config VMDef `yaml:"config"` 50 | Description string `yaml:"description"` 51 | Ephemeral bool `yaml:"ephemeral"` 52 | Name string `yaml:"name"` 53 | Status string 54 | statusCode int64 55 | vmCount sync.WaitGroup 56 | instance *VM 57 | } 58 | 59 | func (ctl *MachineController) GetMachineByName(machineName string) (*Machine, error) { 60 | for id := range ctl.Machines { 61 | machine := ctl.Machines[id] 62 | if machine.Name == machineName { 63 | machine.GetStatus() 64 | ctl.Machines[id] = machine 65 | return &machine, nil 66 | } 67 | } 68 | return &Machine{}, fmt.Errorf("Failed to find machine with Name: %s", machineName) 69 | } 70 | 71 | func (ctl *MachineController) GetMachines() []Machine { 72 | for id := range ctl.Machines { 73 | machine := ctl.Machines[id] 74 | machine.GetStatus() 75 | ctl.Machines[id] = machine 76 | } 77 | 78 | return ctl.Machines 79 | } 80 | 81 | func (ctl *MachineController) GetMachine(machineName string) (Machine, error) { 82 | for id := range ctl.Machines { 83 | machine := ctl.Machines[id] 84 | if machine.Name == machineName { 85 | machine.GetStatus() 86 | ctl.Machines[id] = machine 87 | return machine, nil 88 | } 89 | } 90 | return Machine{}, fmt.Errorf("Failed to find machine with Name: %s", machineName) 91 | } 92 | 93 | func (ctl *MachineController) AddMachine(newMachine Machine, cfg *MachineDaemonConfig) error { 94 | if _, err := ctl.GetMachineByName(newMachine.Name); err == nil { 95 | return fmt.Errorf("Machine '%s' is already defined", newMachine.Name) 96 | } 97 | newMachine.Status = MachineStatusStopped 98 | newMachine.ctx = cfg.GetConfigContext() 99 | if !newMachine.Ephemeral { 100 | if err := newMachine.SaveConfig(); err != nil { 101 | return fmt.Errorf("Could not save '%s' machine to %q: %s", newMachine.Name, newMachine.ConfigFile(), err) 102 | } 103 | } 104 | ctl.Machines = append(ctl.Machines, newMachine) 105 | return nil 106 | } 107 | 108 | func (ctl *MachineController) StopMachines() error { 109 | for idx, _ := range ctl.Machines { 110 | machine := ctl.Machines[idx] 111 | if machine.IsRunning() { 112 | if err := machine.Stop(false); err != nil { 113 | log.Infof("Error while stopping machine '%s': %s", machine.Name, err) 114 | } 115 | } 116 | } 117 | return nil 118 | } 119 | 120 | func (ctl *MachineController) DeleteMachine(machineName string, cfg *MachineDaemonConfig) error { 121 | machines := []Machine{} 122 | for idx, _ := range ctl.Machines { 123 | machine := ctl.Machines[idx] 124 | if machine.Name != machineName { 125 | machines = append(machines, machine) 126 | } else { 127 | err := machine.Delete() 128 | if err != nil { 129 | return fmt.Errorf("Machine:%s delete failed: %s", machine.Name, err) 130 | } 131 | log.Infof("Deleted machine: %s", machine.Name) 132 | } 133 | } 134 | ctl.Machines = machines 135 | return nil 136 | } 137 | 138 | func (ctl *MachineController) UpdateMachine(updateMachine Machine, cfg *MachineDaemonConfig) error { 139 | // FIXME: decide if update will modify the in-memory state (I think yes, but 140 | // maybe only the on-disk format if it's running? but what does subsequent 141 | // GET return (on-disk or in-memory?) 142 | 143 | for idx, machine := range ctl.Machines { 144 | if machine.Name == updateMachine.Name { 145 | updateMachine.ctx = cfg.GetConfigContext() 146 | ctl.Machines[idx] = updateMachine 147 | if !updateMachine.Ephemeral { 148 | if err := updateMachine.SaveConfig(); err != nil { 149 | return fmt.Errorf("Could not save '%s' machine to %q: %s", updateMachine.Name, updateMachine.ConfigFile(), err) 150 | } 151 | } 152 | log.Infof("Updated machine '%s'", updateMachine.Name) 153 | break 154 | } 155 | } 156 | return nil 157 | } 158 | 159 | func (ctl *MachineController) StartMachine(machineName string) error { 160 | for idx, machine := range ctl.Machines { 161 | if machine.Name == machineName { 162 | err := ctl.Machines[idx].Start() 163 | if err != nil { 164 | return fmt.Errorf("Could not start '%s' machine: %s", machineName, err) 165 | } 166 | return nil 167 | } 168 | } 169 | return fmt.Errorf("Failed to find machine '%s', cannot start unknown machine", machineName) 170 | } 171 | 172 | func (ctl *MachineController) StopMachine(machineName string, force bool) error { 173 | for idx, machine := range ctl.Machines { 174 | if machine.Name == machineName { 175 | err := ctl.Machines[idx].Stop(force) 176 | if err != nil { 177 | return fmt.Errorf("Could not stop '%s' machine: %s", machineName, err) 178 | } 179 | return nil 180 | } 181 | } 182 | return fmt.Errorf("Failed to find machine '%s', cannot stop unknown machine", machineName) 183 | } 184 | 185 | type ConsoleInfo struct { 186 | Type string `json:"type"` 187 | Path string `json:"path"` 188 | Addr string `json:"addr"` 189 | Port string `json:"port"` 190 | Secure bool `json:"secure"` 191 | } 192 | 193 | func (ctl *MachineController) GetMachineConsole(machineName string, consoleType string) (ConsoleInfo, error) { 194 | consoleInfo := ConsoleInfo{Type: consoleType} 195 | for _, machine := range ctl.Machines { 196 | if machine.Name == machineName { 197 | if consoleType == SerialConsole { 198 | path, err := machine.SerialSocket() 199 | if err != nil { 200 | return consoleInfo, fmt.Errorf("Failed to get serial socket info: %s", err) 201 | } 202 | consoleInfo.Path = path 203 | return consoleInfo, nil 204 | } 205 | if consoleType == VGAConsole { 206 | spiceInfo, err := machine.SpiceConnection() 207 | if err != nil { 208 | return consoleInfo, fmt.Errorf("Failed to get spice connection info: %s", err) 209 | } 210 | consoleInfo.Addr = spiceInfo.HostAddress 211 | consoleInfo.Port = spiceInfo.Port 212 | if spiceInfo.TLSPort != "" { 213 | consoleInfo.Port = spiceInfo.TLSPort 214 | consoleInfo.Secure = true 215 | } 216 | return consoleInfo, nil 217 | } 218 | return consoleInfo, fmt.Errorf("Unknown console type '%s'", consoleType) 219 | } 220 | } 221 | return consoleInfo, fmt.Errorf("Failed to find machine '%s', cannot connect console to unknown machine", machineName) 222 | } 223 | 224 | // 225 | // Machine Functions Below 226 | // 227 | func (cls *Machine) ConfigDir() string { 228 | return filepath.Join(cls.ctx.Value(mdcCtxConfDir).(string), "machines", cls.Name) 229 | } 230 | 231 | func (cls *Machine) DataDir() string { 232 | return filepath.Join(cls.ctx.Value(mdcCtxDataDir).(string), "machines", cls.Name) 233 | } 234 | 235 | func (cls *Machine) StateDir() string { 236 | return filepath.Join(cls.ctx.Value(mdcCtxStateDir).(string), "machines", cls.Name) 237 | } 238 | 239 | var ( 240 | clsCtx = "machine-ctx" 241 | clsCtxConfDir = mdcCtx + "-confdir" 242 | clsCtxDataDir = mdcCtx + "-datadir" 243 | clsCtxStateDir = mdcCtx + "-statedir" 244 | ) 245 | 246 | func (cls *Machine) Context() context.Context { 247 | ctx := context.Background() 248 | ctx = context.WithValue(ctx, clsCtxConfDir, cls.ConfigDir()) 249 | ctx = context.WithValue(ctx, clsCtxDataDir, cls.DataDir()) 250 | ctx = context.WithValue(ctx, clsCtxStateDir, cls.StateDir()) 251 | return ctx 252 | } 253 | 254 | func (cls *Machine) ConfigFile() string { 255 | // FIXME: need to decide on the name of this yaml file 256 | return filepath.Join(cls.ConfigDir(), "machine.yaml") 257 | } 258 | 259 | func (cls *Machine) SaveConfig() error { 260 | configFile := cls.ConfigFile() 261 | machinesDir := filepath.Dir(configFile) 262 | log.Debugf("machinesDir: %q configFile: %q", machinesDir, configFile) 263 | if !PathExists(machinesDir) { 264 | if err := os.MkdirAll(machinesDir, 0755); err != nil { 265 | return fmt.Errorf("Failed to create machinesDir %q: %s", machinesDir, err) 266 | } 267 | } 268 | contents, err := yaml.Marshal(cls) 269 | if err != nil { 270 | return fmt.Errorf("Failed to marshal machine config: %s", err) 271 | } 272 | if err := ioutil.WriteFile(configFile, contents, 0644); err != nil { 273 | return fmt.Errorf("Failed write machine config to '%q': %s", configFile, err) 274 | } 275 | return nil 276 | } 277 | 278 | func LoadConfig(configFile string) (Machine, error) { 279 | var newMachine Machine 280 | machineBytes, err := ioutil.ReadFile(configFile) 281 | if err != nil { 282 | return newMachine, fmt.Errorf("Error reading machine config file '%q': %s", configFile, err) 283 | } 284 | if err := yaml.Unmarshal(machineBytes, &newMachine); err != nil { 285 | return newMachine, fmt.Errorf("Error unmarshaling machine config file %q: %s", configFile, err) 286 | } 287 | return newMachine, nil 288 | } 289 | 290 | func (m *Machine) GetStatus() string { 291 | if m.instance == nil { 292 | m.Status = MachineStatusStopped 293 | } else { 294 | status := m.instance.Status() 295 | log.Debugf("VM:%s instance status: %s", m.instance.Name(), status.String()) 296 | // VMInit, VMStarted, VMStopped, VMFailed 297 | switch status { 298 | case VMInit: 299 | m.Status = MachineStatusInitialized 300 | case VMStarted: 301 | m.Status = MachineStatusRunning 302 | case VMStopped: 303 | m.Status = MachineStatusStopped 304 | case VMFailed: 305 | m.Status = MachineStatusFailed 306 | } 307 | } 308 | return m.Status 309 | } 310 | 311 | func (m *Machine) Start() error { 312 | 313 | // check if machine is running, if so return 314 | if m.IsRunning() { 315 | return fmt.Errorf("Machine is already running") 316 | } 317 | 318 | vmCtx := m.Context() 319 | vm, err := newVM(vmCtx, m.Name, m.Config) 320 | if err != nil { 321 | return fmt.Errorf("Failed to create new VM '%s': %s", m.Name, err) 322 | } 323 | m.instance = vm 324 | log.Infof("machine.Start()") 325 | 326 | err = vm.Start() 327 | if err != nil { 328 | forceStop := true 329 | vm.Stop(forceStop) 330 | return fmt.Errorf("Failed to start VM '%s.%s': %s", m.Name, vm.Config.Name, err) 331 | } 332 | 333 | m.vmCount.Add(1) 334 | return nil 335 | } 336 | 337 | func (m *Machine) Stop(force bool) error { 338 | 339 | log.Infof("Machine.Stop called on machine %s, status: %s, force: %v", m.Name, m.GetStatus(), force) 340 | // check if machine is stopped, if so return 341 | if !m.IsRunning() { 342 | return fmt.Errorf("Machine is already stopped") 343 | } 344 | 345 | if m.instance != nil { 346 | log.Infof("Machine.Stop, VM instance: %s, calling stop", m.Name) 347 | err := m.instance.Stop(force) 348 | if err != nil { 349 | return fmt.Errorf("Failed to stop VM '%s': %s", m.Name, err) 350 | } 351 | m.vmCount.Done() 352 | } else { 353 | log.Debugf("Machine instanace was nil, marking stop") 354 | } 355 | m.Status = MachineStatusStopped 356 | return nil 357 | } 358 | 359 | func (m *Machine) Delete() error { 360 | // Stop machine, if running 361 | // Delete VM (stop and remove state) 362 | // Remove Machine Config 363 | 364 | log.Infof("Machine.Delete called on machine %s, status: %s", m.Name, m.GetStatus()) 365 | 366 | if m.instance != nil { 367 | log.Infof("Machine.Delete, VM instance: %s, calling delete", m.Name) 368 | err := m.instance.Delete() 369 | if err != nil { 370 | return fmt.Errorf("Failed to delete VM '%s': %s", m.Name, err) 371 | } 372 | } 373 | 374 | dirs := []string{m.ConfigDir(), m.DataDir(), m.StateDir()} 375 | for _, dir := range dirs { 376 | if PathExists(dir) { 377 | log.Infof("Removing machine dir %q", dir) 378 | err := os.RemoveAll(dir) 379 | if err != nil { 380 | return fmt.Errorf("Failed to remove machine %s dir %q", m.Name, dir) 381 | } 382 | } 383 | } 384 | 385 | m.instance = nil 386 | 387 | return nil 388 | } 389 | 390 | func (m *Machine) IsRunning() bool { 391 | return m.GetStatus() == MachineStatusRunning 392 | } 393 | 394 | func (m *Machine) SerialSocket() (string, error) { 395 | return m.instance.SerialSocket() 396 | } 397 | 398 | type SpiceConnection struct { 399 | HostAddress string 400 | Port string 401 | TLSPort string 402 | // Password string 403 | } 404 | 405 | func (m *Machine) SpiceConnection() (SpiceConnection, error) { 406 | spiceCon := SpiceConnection{} 407 | 408 | spiceDev, err := m.instance.SpiceDevice() 409 | if err != nil { 410 | return SpiceConnection{}, err 411 | } 412 | spiceCon.HostAddress = spiceDev.HostAddress 413 | spiceCon.Port = spiceDev.Port 414 | spiceCon.TLSPort = spiceDev.TLSPort 415 | // spiceCon.Password = spiceDev.Password 416 | 417 | return spiceCon, nil 418 | } 419 | -------------------------------------------------------------------------------- /pkg/api/network.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | package api 16 | 17 | import ( 18 | "fmt" 19 | "math/rand" 20 | "time" 21 | ) 22 | 23 | type NetworkDef struct { 24 | Name string `yaml:"name"` 25 | Address string `yaml:"address",omitempty` 26 | Type string `yaml:"type"` 27 | IFName string `yaml:"interface",omitempty` 28 | } 29 | 30 | type NicDef struct { 31 | BusAddr string `yaml:"addr,omitempty"` 32 | Device string `yaml:"device"` 33 | ID string `yaml:"id",omitempty` 34 | Mac string `yaml:"mac",omitempty` 35 | ifname string `yaml:"ifname",omitempty` 36 | Network string `yaml:"network",omitempty` 37 | Ports []PortRule `yaml:"ports",omitempty` 38 | BootIndex string `yaml:"bootindex,omitempty` 39 | ROMFile string `yaml:"romfile,omitempty` 40 | } 41 | 42 | type VMNic struct { 43 | BusAddr string 44 | DeviceType string 45 | HWAddr string 46 | ID string 47 | IFName string 48 | NetIFName string 49 | NetType string 50 | NetAddr string 51 | BootIndex string 52 | Ports []PortRule 53 | } 54 | 55 | // Ports are a list of PortRules 56 | // nics: 57 | // - id: nic1 58 | // ports: 59 | // - protocol: tcp 60 | // host: 61 | // address: "" // address must be an IP, not hostname 62 | // port: 22222 63 | // guest: 64 | // address: "" 65 | // port: 22 66 | // - host: 67 | // address: "" 68 | // port: 1234 69 | // - guest: 70 | // address: "" 71 | // port: 23 72 | // - host: 73 | // address: "" 74 | // port: 8080 75 | // - guest: 76 | // address: "" 77 | // port: 80 78 | 79 | // A PortRule is a single entry map where the key and value represent 80 | // the host and guest mapping respectively. The Host and Guest value 81 | 82 | type PortRule struct { 83 | Protocol string 84 | Host Port 85 | Guest Port 86 | } 87 | 88 | type Port struct { 89 | Address string 90 | Port int 91 | } 92 | 93 | func (p *PortRule) String() string { 94 | return fmt.Sprintf("%s:%s:%d-%s:%d", p.Protocol, 95 | p.Host.Address, p.Host.Port, p.Guest.Address, p.Guest.Port) 96 | } 97 | 98 | // https://stackoverflow.com/questions/21018729/generate-mac-address-in-go 99 | func RandomMAC() (string, error) { 100 | r := rand.New(rand.NewSource(time.Now().UnixNano())) 101 | buf := make([]byte, 6) 102 | _, err := r.Read(buf) 103 | if err != nil { 104 | return "", fmt.Errorf("Failed reading random bytes") 105 | } 106 | 107 | // Set local bit, ensure unicast address 108 | buf[0] = (buf[0] | 2) & 0xfe 109 | return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", buf[0], buf[1], buf[2], buf[3], buf[4], buf[5]), nil 110 | } 111 | 112 | func RandomQemuMAC() (string, error) { 113 | r := rand.New(rand.NewSource(time.Now().UnixNano())) 114 | 115 | buf := make([]byte, 6) 116 | suf := make([]byte, 3) 117 | _, err := r.Read(suf) 118 | if err != nil { 119 | return "", fmt.Errorf("Failed reading random bytes") 120 | } 121 | // QEMU OUI prefix 52:54:00 122 | buf[0] = 0x52 123 | buf[1] = 0x54 124 | buf[2] = 0x00 125 | buf[3] = suf[0] 126 | buf[4] = suf[1] 127 | buf[5] = suf[2] 128 | return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", buf[0], buf[1], buf[2], buf[3], buf[4], buf[5]), nil 129 | } 130 | -------------------------------------------------------------------------------- /pkg/api/ports.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | ) 7 | 8 | func portAvail(p int) bool { 9 | 10 | ln, err := net.Listen("tcp", fmt.Sprintf(":%d", p)) 11 | if err != nil { 12 | return false 13 | } 14 | _ = ln.Close() 15 | return true 16 | } 17 | 18 | func NextFreePort(first int) int { 19 | for p := first; ; p++ { 20 | if portAvail(p) { 21 | return p 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pkg/api/qconfig.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "path/filepath" 8 | "runtime" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/project-machine/qcli" 13 | 14 | log "github.com/sirupsen/logrus" 15 | ) 16 | 17 | func GetKvmPath() (string, error) { 18 | // prefer qemu-kvm, qemu-system-x86_64, kvm for x86 platform, 19 | // qemu-system-aarch64 when on arm64 platform 20 | var emulators []string 21 | paths := []string{"/usr/libexec", "/usr/bin"} 22 | switch runtime.GOARCH { 23 | case "amd64", "x86_64": 24 | emulators = []string{"qemu-kvm", "qemu-system-x86_64", "kvm"} 25 | case "aarch64", "arm64": 26 | emulators = []string{"qemu-system-aarch64"} 27 | } 28 | for _, emulator := range emulators { 29 | for _, prefix := range paths { 30 | kvmPath := path.Join(prefix, emulator) 31 | if _, err := os.Stat(kvmPath); err == nil { 32 | return kvmPath, nil 33 | } 34 | } 35 | } 36 | return "", fmt.Errorf("Failed to find QEMU/KVM binary [%s] in paths [%s]\n", emulators, paths) 37 | } 38 | 39 | func NewDefaultX86Config(name string, numCpus, numMemMB uint32, sockDir string) (*qcli.Config, error) { 40 | smp := qcli.SMP{CPUs: numCpus} 41 | if numCpus < 1 { 42 | smp.CPUs = 4 43 | } 44 | 45 | mem := qcli.Memory{ 46 | Size: fmt.Sprintf("%dm", numMemMB), 47 | } 48 | if numMemMB < 1 { 49 | mem.Size = "4096m" 50 | } 51 | 52 | path, err := GetKvmPath() 53 | if err != nil { 54 | return &qcli.Config{}, fmt.Errorf("Failed creating new default config: %s", err) 55 | } 56 | 57 | c := &qcli.Config{ 58 | Name: name, 59 | Path: path, 60 | Machine: qcli.Machine{ 61 | Type: qcli.MachineTypePC35, 62 | Acceleration: qcli.MachineAccelerationKVM, 63 | SMM: "on", 64 | }, 65 | CPUModel: "qemu64", 66 | CPUModelFlags: []string{"+x2apic"}, 67 | SMP: smp, 68 | Memory: mem, 69 | RngDevices: []qcli.RngDevice{ 70 | { 71 | Driver: qcli.VirtioRng, 72 | ID: "rng0", 73 | Bus: "pcie.0", 74 | Transport: qcli.TransportPCI, 75 | Filename: qcli.RngDevUrandom, 76 | }, 77 | }, 78 | CharDevices: []qcli.CharDevice{ 79 | { 80 | Driver: qcli.LegacySerial, 81 | Backend: qcli.Socket, 82 | ID: "serial0", 83 | Path: filepath.Join(sockDir, "console.sock"), 84 | }, 85 | { 86 | Driver: qcli.LegacySerial, 87 | Backend: qcli.Socket, 88 | ID: "monitor0", 89 | Path: filepath.Join(sockDir, "monitor.sock"), 90 | }, 91 | }, 92 | LegacySerialDevices: []qcli.LegacySerialDevice{ 93 | { 94 | ChardevID: "serial0", 95 | }, 96 | }, 97 | MonitorDevices: []qcli.MonitorDevice{ 98 | { 99 | ChardevID: "monitor0", 100 | }, 101 | }, 102 | QMPSockets: []qcli.QMPSocket{ 103 | { 104 | Type: "unix", 105 | Server: true, 106 | NoWait: true, 107 | Name: filepath.Join(sockDir, "qmp.sock"), 108 | }, 109 | }, 110 | PCIeRootPortDevices: []qcli.PCIeRootPortDevice{ 111 | { 112 | ID: "root-port.0x4.0", 113 | Bus: "pcie.0", 114 | Chassis: "0x0", 115 | Slot: "0x00", 116 | Port: "0x0", 117 | Addr: "0x5", 118 | Multifunction: true, 119 | }, 120 | { 121 | ID: "root-port.0x4.1", 122 | Bus: "pcie.0", 123 | Chassis: "0x1", 124 | Slot: "0x00", 125 | Port: "0x1", 126 | Addr: "0x5.0x1", 127 | Multifunction: false, 128 | }, 129 | }, 130 | VGA: "qxl", 131 | SpiceDevice: qcli.SpiceDevice{ 132 | HostAddress: "127.0.0.1", 133 | Port: fmt.Sprintf("%d", NextFreePort(qcli.RemoteDisplayPortBase)), 134 | DisableTicketing: true, 135 | }, 136 | GlobalParams: []string{ 137 | "ICH9-LPC.disable_s3=1", 138 | "driver=cfi.pflash01,property=secure,value=on", 139 | }, 140 | Knobs: qcli.Knobs{ 141 | NoHPET: true, 142 | NoGraphic: true, 143 | }, 144 | } 145 | 146 | return c, nil 147 | } 148 | 149 | func NewDefaultAarch64Config(name string, numCpus uint32, numMemMB uint32, sockDir string) (*qcli.Config, error) { 150 | smp := qcli.SMP{CPUs: numCpus} 151 | if numCpus < 1 { 152 | smp.CPUs = 4 153 | } 154 | 155 | mem := qcli.Memory{ 156 | Size: fmt.Sprintf("%dm", numMemMB), 157 | } 158 | if numMemMB < 1 { 159 | mem.Size = "1G" 160 | } 161 | path, err := GetKvmPath() 162 | if err != nil { 163 | return &qcli.Config{}, fmt.Errorf("Failed creating new default config: %s", err) 164 | } 165 | c := &qcli.Config{ 166 | Name: name, 167 | Path: path, 168 | Machine: qcli.Machine{ 169 | Type: qcli.MachineTypeVirt, 170 | Acceleration: qcli.MachineAccelerationKVM, 171 | }, 172 | CPUModel: "host", 173 | Memory: mem, 174 | CharDevices: []qcli.CharDevice{ 175 | { 176 | Driver: qcli.PCISerialDevice, 177 | Backend: qcli.Socket, 178 | ID: "serial0", 179 | Path: "/tmp/console.sock", 180 | }, 181 | { 182 | Driver: qcli.LegacySerial, 183 | Backend: qcli.Socket, 184 | ID: "monitor0", 185 | Path: filepath.Join(sockDir, "monitor.sock"), 186 | }, 187 | }, 188 | SerialDevices: []qcli.SerialDevice{ 189 | { 190 | Driver: qcli.PCISerialDevice, 191 | ID: "pciser0", 192 | ChardevIDs: []string{"serial0"}, 193 | MaxPorts: 1, 194 | }, 195 | }, 196 | MonitorDevices: []qcli.MonitorDevice{ 197 | { 198 | ChardevID: "monitor0", 199 | }, 200 | }, 201 | QMPSockets: []qcli.QMPSocket{ 202 | { 203 | Type: "unix", 204 | Server: true, 205 | NoWait: true, 206 | Name: filepath.Join(sockDir, "qmp.sock"), 207 | }, 208 | }, 209 | Knobs: qcli.Knobs{ 210 | NoGraphic: true, 211 | }, 212 | } 213 | return c, nil 214 | } 215 | 216 | // FIXME: what to do with remote client/server ? push to zot and use zot URLs? 217 | // ImportDiskImage will copy/create a source image to server image 218 | func (qd *QemuDisk) ImportDiskImage(imageDir string) error { 219 | // What to do about sparse? use reflink and sparse=auto for now. 220 | if qd.Size > 0 { 221 | if PathExists(qd.File) { 222 | log.Infof("Skipping creation of existing disk: %s", qd.File) 223 | return nil 224 | } 225 | return qd.Create() 226 | } 227 | 228 | if !PathExists(qd.File) { 229 | return fmt.Errorf("Disk File %q does not exist", qd.File) 230 | } 231 | 232 | if qd.Type == "cdrom" { 233 | log.Infof("Skipping import of cdrom: %s", qd.File) 234 | } 235 | 236 | srcFilePath := qd.File 237 | destFilePath := filepath.Join(imageDir, filepath.Base(srcFilePath)) 238 | qd.File = destFilePath 239 | 240 | if srcFilePath != destFilePath || !PathExists(destFilePath) { 241 | log.Infof("Importing VM disk '%s' -> '%s'", srcFilePath, destFilePath) 242 | err := CopyFileRefSparse(srcFilePath, destFilePath) 243 | if err != nil { 244 | return fmt.Errorf("Error copying VM disk '%s' -> '%s': %s", srcFilePath, destFilePath, err) 245 | } 246 | } else { 247 | log.Infof("VM disk imported %q", filepath.Base(srcFilePath)) 248 | } 249 | 250 | return nil 251 | } 252 | 253 | func (qd *QemuDisk) QBlockDevice(qti *qcli.QemuTypeIndex) (qcli.BlockDevice, error) { 254 | log.Debugf("QemuDisk -> QBlockDevice() %+v", qd) 255 | blk := qcli.BlockDevice{ 256 | ID: fmt.Sprintf("drive%d", qti.NextDriveIndex()), 257 | File: qd.File, 258 | Interface: qcli.NoInterface, 259 | AIO: qcli.Threads, 260 | BusAddr: qd.BusAddr, 261 | ReadOnly: qd.ReadOnly, 262 | Cache: qcli.CacheModeUnsafe, 263 | Discard: qcli.DiscardUnmap, 264 | DetectZeroes: qcli.DetectZeroesUnmap, 265 | Serial: qd.serial(), 266 | } 267 | if blk.BlockSize == 0 { 268 | blk.BlockSize = 512 269 | } 270 | if qd.BootIndex != "" && qd.BootIndex != "off" { 271 | bootindex, err := strconv.Atoi(qd.BootIndex) 272 | if err != nil { 273 | return blk, fmt.Errorf("Failed parsing disk %s BootIndex '%s': %s", qd.File, qd.BootIndex, err) 274 | } 275 | blk.BootIndex = fmt.Sprintf("%d", bootindex) 276 | } 277 | 278 | if qd.Format != "" { 279 | switch qd.Format { 280 | case "raw": 281 | blk.Format = qcli.RAW 282 | case "qcow2": 283 | blk.Format = qcli.QCOW2 284 | } 285 | } else { 286 | blk.Format = qcli.QCOW2 287 | } 288 | 289 | if qd.Attach == "" { 290 | qd.Attach = "virtio" 291 | } 292 | 293 | switch qd.Attach { 294 | case "scsi": 295 | blk.Driver = qcli.SCSIHD 296 | blk.SCSI = true 297 | // FIXME: we should scan disks for buses, create buses, then 298 | // walk disks a second time to configure bus= for each device 299 | blk.Bus = "scsi0.0" // this is the default scsi bus 300 | case "nvme": 301 | blk.Driver = qcli.NVME 302 | case "virtio": 303 | blk.Driver = qcli.VirtioBlock 304 | blk.Bus = "pcie.0" 305 | if qd.Type == "cdrom" { 306 | blk.Media = "cdrom" 307 | } 308 | case "ide": 309 | if qd.Type == "cdrom" { 310 | blk.Driver = qcli.IDECDROM 311 | blk.Media = "cdrom" 312 | } else { 313 | blk.Driver = qcli.IDEHardDisk 314 | } 315 | blk.Bus = "ide.0" 316 | case "usb": 317 | blk.Driver = qcli.USBStorage 318 | default: 319 | return blk, fmt.Errorf("Unknown Disk Attach type: %s", qd.Attach) 320 | } 321 | 322 | return blk, nil 323 | } 324 | 325 | func (nd NicDef) QNetDevice(qti *qcli.QemuTypeIndex) (qcli.NetDevice, error) { 326 | //FIXME: how do we do bridge or socket/mcast types? 327 | ndev := qcli.NetDevice{ 328 | Type: qcli.USER, 329 | ID: fmt.Sprintf("net%d", qti.NextNetIndex()), 330 | Addr: nd.BusAddr, 331 | MACAddress: nd.Mac, 332 | ROMFile: nd.ROMFile, 333 | User: qcli.NetDeviceUser{ 334 | IPV4: true, 335 | }, 336 | Driver: qcli.DeviceDriver(nd.Device), 337 | } 338 | if len(nd.Ports) > 0 { 339 | for _, portRule := range nd.Ports { 340 | rule := qcli.PortRule{} 341 | rule.Protocol = portRule.Protocol 342 | rule.Host.Address = portRule.Host.Address 343 | rule.Host.Port = portRule.Host.Port 344 | rule.Guest.Address = portRule.Guest.Address 345 | rule.Guest.Port = portRule.Guest.Port 346 | ndev.User.HostForward = append(ndev.User.HostForward, rule) 347 | } 348 | } 349 | if ndev.MACAddress == "" { 350 | mac, err := RandomQemuMAC() 351 | if err != nil { 352 | return qcli.NetDevice{}, fmt.Errorf("Failed to generate a random QEMU mac: %s", err) 353 | } 354 | ndev.MACAddress = mac 355 | } 356 | if nd.BootIndex != "" && nd.BootIndex != "off" { 357 | bootindex, err := strconv.Atoi(nd.BootIndex) 358 | if err != nil { 359 | return qcli.NetDevice{}, fmt.Errorf("Failed parsing nic %s BootIndex '%s': %s", nd.Device, nd.BootIndex, err) 360 | } 361 | ndev.BootIndex = fmt.Sprintf("%d", bootindex) 362 | } 363 | 364 | return ndev, nil 365 | } 366 | 367 | func ConfigureUEFIVars(c *qcli.Config, srcCode, srcVars, runDir string, secureBoot bool) error { 368 | uefiDev, err := qcli.NewSystemUEFIFirmwareDevice(secureBoot) 369 | if err != nil { 370 | return fmt.Errorf("failed to create a UEFI Firmware Device: %s", err) 371 | } 372 | // Import source UEFI Code (if provided) 373 | src := uefiDev.Code 374 | if len(srcCode) > 0 { 375 | src = srcCode 376 | } 377 | // FIXME: create a qcli.UEFICodeFileName 378 | dest := filepath.Join(runDir, "uefi-code.fd") 379 | log.Infof("Importing UEFI Code from '%s' to '%q'", src, dest) 380 | if err := CopyFileBits(src, dest); err != nil { 381 | return fmt.Errorf("Failed to import UEFI Code from '%s' to '%q': %s", src, dest, err) 382 | } 383 | uefiDev.Code = dest 384 | 385 | // Import source UEFI Vxrs (if provided) 386 | src = uefiDev.Vars 387 | if len(srcVars) > 0 { 388 | src = srcVars 389 | } 390 | dest = filepath.Join(runDir, qcli.UEFIVarsFileName) 391 | log.Infof("Importing UEFI Vars from '%s' to '%q'", src, dest) 392 | if !PathExists(dest) { 393 | if err := CopyFileBits(src, dest); err != nil { 394 | return fmt.Errorf("Failed to import UEFI Vars from '%s' to '%q': %s", src, dest, err) 395 | } 396 | } else { 397 | log.Infof("Already imported UEFI Vars file %q to %q. Not overwriting.", src, dest) 398 | } 399 | uefiDev.Vars = dest 400 | 401 | c.UEFIFirmwareDevices = []qcli.UEFIFirmwareDevice{*uefiDev} 402 | return nil 403 | } 404 | 405 | func NewVVFATBlockDev(id, directory, label string) (qcli.BlockDevice, error) { 406 | blkdev := qcli.BlockDevice{ 407 | Driver: qcli.VVFAT, 408 | ID: id, 409 | VVFATDev: qcli.VVFATDev{ 410 | Driver: qcli.VirtioBlock, 411 | Directory: directory, 412 | Label: label, 413 | FATMode: qcli.FATMode16, 414 | }, 415 | } 416 | return blkdev, nil 417 | } 418 | 419 | func GenerateQConfig(runDir, sockDir string, v VMDef) (*qcli.Config, error) { 420 | var c *qcli.Config 421 | var err error 422 | switch runtime.GOARCH { 423 | case "amd64", "x86_64": 424 | c, err = NewDefaultX86Config(v.Name, v.Cpus, v.Memory, sockDir) 425 | case "aarch64", "arm64": 426 | c, err = NewDefaultAarch64Config(v.Name, v.Cpus, v.Memory, sockDir) 427 | } 428 | 429 | if err != nil { 430 | return c, err 431 | } 432 | 433 | err = ConfigureUEFIVars(c, v.UEFICode, v.UEFIVars, runDir, v.SecureBoot) 434 | if err != nil { 435 | return c, fmt.Errorf("Error configuring UEFI Vars: %s", err) 436 | } 437 | 438 | cdromPath := v.Cdrom 439 | if !strings.HasPrefix(v.Cdrom, "/") { 440 | cwd, err := os.Getwd() 441 | if err != nil { 442 | return c, fmt.Errorf("Failed to get current working dir: %s", err) 443 | } 444 | cdromPath = filepath.Join(cwd, v.Cdrom) 445 | } 446 | 447 | qti := qcli.NewQemuTypeIndex() 448 | 449 | if v.Cdrom != "" { 450 | qd := QemuDisk{ 451 | File: cdromPath, 452 | Format: "raw", 453 | Attach: "ide", 454 | Type: "cdrom", 455 | ReadOnly: true, 456 | } 457 | if v.Boot == "cdrom" { 458 | qd.BootIndex = "0" 459 | log.Infof("Boot from cdrom requested: bootindex=%s", qd.BootIndex) 460 | } 461 | v.Disks = append(v.Disks, qd) 462 | } 463 | 464 | if err := v.AdjustBootIndicies(qti); err != nil { 465 | return c, err 466 | } 467 | 468 | busses := make(map[string]bool) 469 | for i := range v.Disks { 470 | var disk *QemuDisk 471 | disk = &v.Disks[i] 472 | 473 | if err := disk.Sanitize(runDir); err != nil { 474 | return c, err 475 | } 476 | 477 | // import/create files into stateDir/images/basename(File) 478 | if err := disk.ImportDiskImage(runDir); err != nil { 479 | return c, err 480 | } 481 | 482 | qblk, err := disk.QBlockDevice(qti) 483 | if err != nil { 484 | return c, err 485 | } 486 | c.BlkDevices = append(c.BlkDevices, qblk) 487 | 488 | _, ok := busses[disk.Attach] 489 | // we only need one controller per attach 490 | if !ok { 491 | if disk.Attach == "scsi" { 492 | scsiCon := qcli.SCSIControllerDevice{ 493 | ID: fmt.Sprintf("scsi%d", qti.Next("scsi")), 494 | IOThread: fmt.Sprintf("iothread%d", qti.Next("iothread")), 495 | } 496 | c.SCSIControllerDevices = append(c.SCSIControllerDevices, scsiCon) 497 | } 498 | if disk.Attach == "ide" { 499 | ideCon := qcli.IDEControllerDevice{ 500 | Driver: qcli.ICH9AHCIController, 501 | ID: fmt.Sprintf("ide%d", qti.Next("ide")), 502 | } 503 | c.IDEControllerDevices = append(c.IDEControllerDevices, ideCon) 504 | } 505 | } 506 | } 507 | 508 | for _, nic := range v.Nics { 509 | qnet, err := nic.QNetDevice(qti) 510 | if err != nil { 511 | return c, err 512 | } 513 | c.NetDevices = append(c.NetDevices, qnet) 514 | } 515 | 516 | if v.TPM { 517 | c.TPM = qcli.TPMDevice{ 518 | ID: "tpm0", 519 | Driver: qcli.TPMTISDevice, 520 | Path: filepath.Join(runDir, "tpm0.sock"), 521 | Type: qcli.TPMEmulatorDevice, 522 | } 523 | } 524 | 525 | return c, nil 526 | } 527 | 528 | type QMPMachineLogger struct{} 529 | 530 | func (l QMPMachineLogger) V(level int32) bool { 531 | return true 532 | } 533 | 534 | func (l QMPMachineLogger) Infof(format string, v ...interface{}) { 535 | log.Infof(format, v...) 536 | } 537 | 538 | func (l QMPMachineLogger) Warningf(format string, v ...interface{}) { 539 | log.Warnf(format, v...) 540 | } 541 | 542 | func (l QMPMachineLogger) Errorf(format string, v ...interface{}) { 543 | log.Errorf(format, v...) 544 | } 545 | -------------------------------------------------------------------------------- /pkg/api/rest.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | package api 16 | 17 | import ( 18 | "fmt" 19 | ) 20 | 21 | func GetAPIURL(endpoint string) string { 22 | return fmt.Sprintf("http://machined/%s", endpoint) 23 | } 24 | -------------------------------------------------------------------------------- /pkg/api/routes.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | package api 16 | 17 | import ( 18 | "fmt" 19 | "net/http" 20 | 21 | "github.com/gin-gonic/gin" 22 | log "github.com/sirupsen/logrus" 23 | ) 24 | 25 | type RouteHandler struct { 26 | c *Controller 27 | } 28 | 29 | func NewRouteHandler(c *Controller) *RouteHandler { 30 | routeHandler := &RouteHandler{c: c} 31 | routeHandler.SetupRoutes() 32 | 33 | return routeHandler 34 | } 35 | 36 | func (rh *RouteHandler) SetupRoutes() { 37 | rh.c.Router.GET("/machines", rh.GetMachines) 38 | rh.c.Router.POST("/machines", rh.PostMachine) 39 | rh.c.Router.GET("/machines/:machinename", rh.GetMachine) 40 | rh.c.Router.PUT("/machines/:machinename", rh.UpdateMachine) 41 | rh.c.Router.DELETE("/machines/:machinename", rh.DeleteMachine) 42 | rh.c.Router.POST("/machines/:machinename/start", rh.StartMachine) 43 | rh.c.Router.POST("/machines/:machinename/stop", rh.StopMachine) 44 | rh.c.Router.POST("/machines/:machinename/console", rh.GetMachineConsole) 45 | } 46 | 47 | func (rh *RouteHandler) GetMachines(ctx *gin.Context) { 48 | ctx.IndentedJSON(http.StatusOK, rh.c.MachineController.GetMachines()) 49 | } 50 | 51 | func (rh *RouteHandler) GetMachine(ctx *gin.Context) { 52 | machineName := ctx.Param("machinename") 53 | machine, err := rh.c.MachineController.GetMachine(machineName) 54 | if err != nil { 55 | log.Errorf("Failed to get machine '%s': %s\n", machineName, err) 56 | ctx.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) 57 | return 58 | } 59 | ctx.IndentedJSON(http.StatusOK, machine) 60 | } 61 | 62 | func (rh *RouteHandler) PostMachine(ctx *gin.Context) { 63 | var newMachine Machine 64 | if err := ctx.BindJSON(&newMachine); err != nil { 65 | return 66 | } 67 | cfg := rh.c.Config 68 | if err := rh.c.MachineController.AddMachine(newMachine, cfg); err != nil { 69 | ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 70 | } 71 | } 72 | 73 | func (rh *RouteHandler) DeleteMachine(ctx *gin.Context) { 74 | machineName := ctx.Param("machinename") 75 | cfg := rh.c.Config 76 | // TODO refuse if machine status is running, handle --force param 77 | err := rh.c.MachineController.DeleteMachine(machineName, cfg) 78 | if err != nil { 79 | log.Errorf("Failed to delete machine '%s': %s\n", machineName, err) 80 | } 81 | } 82 | 83 | func (rh *RouteHandler) UpdateMachine(ctx *gin.Context) { 84 | var newMachine Machine 85 | if err := ctx.ShouldBindJSON(&newMachine); err != nil { 86 | ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 87 | return 88 | } 89 | cfg := rh.c.Config 90 | if err := rh.c.MachineController.UpdateMachine(newMachine, cfg); err != nil { 91 | ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 92 | } 93 | } 94 | 95 | func (rh *RouteHandler) StartMachine(ctx *gin.Context) { 96 | machineName := ctx.Param("machinename") 97 | var request struct { 98 | Status string `json:"status"` 99 | } 100 | if err := ctx.ShouldBindJSON(&request); err != nil { 101 | ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 102 | return 103 | } 104 | if request.Status == "running" { 105 | if err := rh.c.MachineController.StartMachine(machineName); err != nil { 106 | ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 107 | } 108 | } else { 109 | err := fmt.Errorf("Invalid Start request: '%v;", request) 110 | ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 111 | return 112 | } 113 | } 114 | 115 | func (rh *RouteHandler) StopMachine(ctx *gin.Context) { 116 | machineName := ctx.Param("machinename") 117 | var request struct { 118 | Status string `json:"status"` 119 | Force bool `json:"force"` 120 | } 121 | if err := ctx.ShouldBindJSON(&request); err != nil { 122 | ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 123 | return 124 | } 125 | if request.Status == "stopped" { 126 | if err := rh.c.MachineController.StopMachine(machineName, request.Force); err != nil { 127 | ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 128 | } 129 | } else { 130 | err := fmt.Errorf("Invalid Stop request: '%v;", request) 131 | ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 132 | return 133 | } 134 | } 135 | 136 | type MachineConsoleRequest struct { 137 | ConsoleType string `json:"type"` 138 | } 139 | 140 | func (rh *RouteHandler) GetMachineConsole(ctx *gin.Context) { 141 | machineName := ctx.Param("machinename") 142 | var request MachineConsoleRequest 143 | if err := ctx.ShouldBindJSON(&request); err != nil { 144 | ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 145 | return 146 | } 147 | if request.ConsoleType == SerialConsole { 148 | consoleInfo, err := rh.c.MachineController.GetMachineConsole(machineName, SerialConsole) 149 | if err != nil { 150 | ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 151 | } 152 | ctx.IndentedJSON(http.StatusOK, consoleInfo) 153 | } else if request.ConsoleType == VGAConsole { 154 | consoleInfo, err := rh.c.MachineController.GetMachineConsole(machineName, VGAConsole) 155 | if err != nil { 156 | ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 157 | } 158 | ctx.IndentedJSON(http.StatusOK, consoleInfo) 159 | } else { 160 | err := fmt.Errorf("Invalid console request: '%v;", request) 161 | ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) 162 | return 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /pkg/api/socket.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | */ 15 | package api 16 | 17 | import ( 18 | "path/filepath" 19 | ) 20 | 21 | const MachineUnixSocketName = "machined.socket" 22 | 23 | func APISocketPath() string { 24 | udd, err := UserRuntimeDir() 25 | if err != nil { 26 | return "" 27 | } 28 | return filepath.Join(udd, "machined", MachineUnixSocketName) 29 | } 30 | -------------------------------------------------------------------------------- /pkg/api/swtpm.go: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | package api 17 | 18 | import ( 19 | "bytes" 20 | "fmt" 21 | "html/template" 22 | "io/ioutil" 23 | "os" 24 | "os/exec" 25 | "path" 26 | "path/filepath" 27 | "strings" 28 | "syscall" 29 | "time" 30 | 31 | log "github.com/sirupsen/logrus" 32 | ) 33 | 34 | type SwTPM struct { 35 | StateDir string 36 | Socket string 37 | Version string 38 | cmd *exec.Cmd 39 | finished chan error 40 | } 41 | 42 | // ${StateDir}/swtpm-localca.conf 43 | const swTPMLocalCaConf = "swtpm-localca.conf" 44 | const swTPMLocalCaConfTpl = ` 45 | statedir = {{.StateDir}} 46 | signingkey = {{.StateDir}}/signingkey.pem 47 | issuercert = {{.StateDir}}/issuercert.pem 48 | certserial = {{.StateDir}}/certserial 49 | ` 50 | 51 | // ${StateDir}/swtpm-localca.options 52 | const swTPMLocalCaOptions = "swtpm-localca.options" 53 | const swTPMLocalCaOptionsTpl = ` 54 | --tpm-manufacturer IBM 55 | --tpm-model swtpm-libtpms 56 | --tpm-version {{.Version}} 57 | --platform-manufacturer MachineOS 58 | --platform-version 2.1 59 | --platform-model QEMU 60 | ` 61 | 62 | // ${StateDir}/swtpm_setup.conf 63 | const swTPMSetupConf = "swtpm_setup.conf" 64 | const swTPMSetupConfTpl = ` 65 | create_certs_tool={{.CertsTool}} 66 | create_certs_tool_config={{.StateDir}}/swtpm-localca.conf 67 | create_certs_tool_options={{.StateDir}}/swtpm-localca.options 68 | ` 69 | 70 | var swTPMSetupTemplates = map[string]string{ 71 | swTPMLocalCaConf: swTPMLocalCaConfTpl, 72 | swTPMLocalCaOptions: swTPMLocalCaOptionsTpl, 73 | swTPMSetupConf: swTPMSetupConfTpl, 74 | } 75 | 76 | func renderSwTPMTemplate(templateSource, filename string, data interface{}) error { 77 | log.Debugf("SwTPM: rendering template for %s", filename) 78 | tmpl, err := template.New(filename).Parse(templateSource) 79 | if err != nil { 80 | return fmt.Errorf("Failed to read template for %s: %s", filename, err) 81 | } 82 | 83 | var tmplBuffer bytes.Buffer 84 | err = tmpl.Execute(&tmplBuffer, data) 85 | if err != nil { 86 | return fmt.Errorf("Failed to render template for %s: %s", filename, err) 87 | } 88 | 89 | err = ioutil.WriteFile(filename, tmplBuffer.Bytes(), 0644) 90 | if err != nil { 91 | return fmt.Errorf("Failed to write template to file: %s: %s", filename, err) 92 | } 93 | return nil 94 | } 95 | 96 | func (s *SwTPM) Setup() error { 97 | if err := os.MkdirAll(s.StateDir, 0755); err != nil { 98 | return fmt.Errorf("SwTPM Setup failed to create statedir: %s: %s", s.StateDir, err) 99 | } 100 | 101 | // check if we've already setup a tpm before 102 | if PathExists(filepath.Join(s.StateDir, "tpm-00.permall")) { 103 | log.Debugf("SwTPM already configured, skipping setup") 104 | return nil 105 | } 106 | 107 | if Which("swtpm_setup") == "" { 108 | return fmt.Errorf("no 'swtpm_setup' command found in PATH.") 109 | } 110 | 111 | log.Infof("Checking swtpm_setup version ...") 112 | stdout, stderr, rc := RunCommandWithOutputErrorRc("swtpm_setup", "--version") 113 | // swtpm_setup returns 1 on --version flag ... *sigh* 114 | if rc != 1 { 115 | return fmt.Errorf("failed to run 'swtpm_setup --version', rc:%d stdout: %s, stderr: %s", rc, string(stdout), string(stderr)) 116 | } 117 | 118 | // expected output from --version: TPM emulator setup tool version 0.7.1 119 | toks := strings.Split(strings.TrimSpace(string(stdout)), " ") 120 | swtpmVersion := toks[len(toks)-1] 121 | var major, minor, micro int 122 | numParsed, err := fmt.Sscanf(swtpmVersion, "%d.%d.%d", &major, &minor, µ) 123 | if err != nil || numParsed != 3 { 124 | return fmt.Errorf("Failed to parse swtpm_setup version string '%s': %s", swtpmVersion, err) 125 | } 126 | log.Infof("Found swtpm_setup version string:%s major:%d minor:%d micro:%d", swtpmVersion, major, minor, micro) 127 | 128 | // For SecureBoot TPM 2.0 mode we skip swtpm_setup if version is older than 0.7.3 as it does not work 129 | if strings.HasPrefix(s.Version, "2") { 130 | if major == 0 && minor <= 7 && micro < 3 { 131 | log.Infof("Skipping swtpm_setup for TPM Version 2, SwTPM version %s is not >= 0.7.3", swtpmVersion) 132 | return nil 133 | } 134 | } 135 | 136 | certsTool := Which("swtpm_localca") 137 | if certsTool == "" { 138 | return fmt.Errorf("no 'swtpm_localca' command found in PATH.") 139 | } 140 | 141 | data := make(map[string]interface{}) 142 | data["StateDir"] = s.StateDir 143 | data["Version"] = s.Version 144 | data["CertsTool"] = certsTool 145 | 146 | for fname, tpl := range swTPMSetupTemplates { 147 | if err := renderSwTPMTemplate(tpl, filepath.Join(s.StateDir, fname), data); err != nil { 148 | return fmt.Errorf("failed to render template for %s with data: %+v", fname, data) 149 | } 150 | } 151 | 152 | // swtpm_setup --help shows it accepts either '--tpmstate', or 153 | // '--tpm-state' but does not support '--tpm-state=dir://' 154 | // '--log=' works, but not with level= or file= values 155 | args := []string{ 156 | "swtpm_setup", 157 | "--tpm-state", "dir://" + s.StateDir, 158 | "--config=" + filepath.Join(s.StateDir, swTPMSetupConf), 159 | "--log=" + path.Join(s.StateDir, "log"), 160 | "--createek", // not a a typo 'create ek' 161 | "--create-ek-cert", 162 | "--create-platform-cert", 163 | "--lock-nvram", 164 | "--not-overwrite", 165 | "--write-ek-cert-files=" + filepath.Join(s.StateDir), 166 | } 167 | 168 | // tpm1 mode requires well-known values set; these flags break tpm 2.0 secureboot mode 169 | if strings.HasPrefix(s.Version, "1") { 170 | args = append(args, "--srk-well-known", "--owner-well-known") 171 | } 172 | 173 | log.Infof("swtpm_setup args: %s", strings.Join(args, " ")) 174 | stdout, stderr, rc = RunCommandWithOutputErrorRc(args...) 175 | if rc != 0 { 176 | return fmt.Errorf("failed to run 'swtpm_setup' rc:%d stdout:%s stderr:%s", rc, string(stdout), string(stderr)) 177 | } 178 | return nil 179 | } 180 | 181 | func (s *SwTPM) Start() error { 182 | if Which("swtpm") == "" { 183 | return fmt.Errorf("no 'swtpm' command found in PATH.") 184 | } 185 | 186 | err := s.Setup() 187 | if err != nil { 188 | // swtpm_setup is mandatory for 1.2 tpms to function, 2.0 can proceed 189 | if strings.HasPrefix(s.Version, "1") { 190 | return fmt.Errorf("Cannot start SwTPM, required setup for TPM 1.x failed") 191 | } 192 | log.Warnf("SwTPM Setup() failed. Some TPM features may not function. Please update swtpm to 0.7.1 or newer") 193 | } 194 | 195 | // swtpm socket --help shows it accepts only '--tpmstate', not '--tpm-state' 196 | // note that --tpmstate does NOT accept dir:// like swtpm_setup does 197 | args := []string{ 198 | "swtpm", "socket", 199 | "--tpmstate=dir=" + s.StateDir, 200 | "--ctrl=type=unixio,path=" + s.Socket, 201 | "--log=level=20,file=" + path.Join(s.StateDir, "log"), 202 | "--pid=file=" + path.Join(s.StateDir, "pid"), 203 | } 204 | 205 | if strings.HasPrefix(s.Version, "2") { 206 | args = append(args, "--tpm2") 207 | } else { 208 | // no args needed for tpm 1.2, it is the default chip version 209 | if !strings.HasPrefix(s.Version, "1") { 210 | return fmt.Errorf("Invalid SwTPM Version: '%s', must be 1.2 or 2.0", s.Version) 211 | } 212 | } 213 | 214 | cmd := exec.Command(args[0], args[1:]...) 215 | log.Infof("swtpm args: %s", cmd.String()) 216 | if err := cmd.Start(); err != nil { 217 | return err 218 | } 219 | 220 | // wait up to 10 seconds for the SwTPM socket to appear 221 | if !WaitForPath(s.Socket, 10, 1) { 222 | return fmt.Errorf("SwTPM start failed, socket %s does not exist after 10 seconds", s.Socket) 223 | } 224 | 225 | log.Infof("swtpm TPM Version %s started with pid %d", s.Version, cmd.Process.Pid) 226 | s.cmd = cmd 227 | 228 | go func() { 229 | s.finished <- s.cmd.Wait() 230 | }() 231 | 232 | return nil 233 | } 234 | 235 | func (s *SwTPM) Stop() error { 236 | // never started. 237 | if s.cmd == nil { 238 | return nil 239 | } 240 | 241 | pid := s.cmd.Process.Pid 242 | if err := s.cmd.Process.Signal(syscall.SIGTERM); err != nil { 243 | if err == os.ErrProcessDone { 244 | return nil 245 | } 246 | log.Warnf("Failed to kill %d: %v", pid, err) 247 | return err 248 | } 249 | 250 | timeout := time.Duration(2) * time.Second 251 | select { 252 | case <-s.finished: 253 | log.Infof("swtpm pid %d exited after sigterm", pid) 254 | case <-time.After(timeout): 255 | log.Infof("SwTPM pid %d didn't die right away, killing.", pid) 256 | if err := s.cmd.Process.Kill(); err != nil { 257 | return err 258 | } 259 | } 260 | return nil 261 | } 262 | -------------------------------------------------------------------------------- /pkg/api/util.go: -------------------------------------------------------------------------------- 1 | /* 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | */ 14 | package api 15 | 16 | import ( 17 | "bytes" 18 | "fmt" 19 | "io" 20 | "io/ioutil" 21 | "os" 22 | "os/exec" 23 | "path/filepath" 24 | "strings" 25 | "sync" 26 | "syscall" 27 | "time" 28 | 29 | "github.com/msoap/byline" 30 | "github.com/pkg/errors" 31 | log "github.com/sirupsen/logrus" 32 | ) 33 | 34 | func PathExists(d string) bool { 35 | _, err := os.Stat(d) 36 | if err != nil && os.IsNotExist(err) { 37 | return false 38 | } 39 | return true 40 | } 41 | 42 | func WaitForPath(path string, retries, sleepSeconds int) bool { 43 | var numRetries int 44 | if retries == 0 { 45 | numRetries = 1 46 | } else { 47 | numRetries = retries 48 | } 49 | for i := 0; i < numRetries; i++ { 50 | if PathExists(path) { 51 | return true 52 | } 53 | time.Sleep(time.Duration(sleepSeconds) * time.Second) 54 | } 55 | return PathExists(path) 56 | } 57 | 58 | func EnsureDir(dir string) error { 59 | if err := os.MkdirAll(dir, 0755); err != nil { 60 | return fmt.Errorf("couldn't make dirs: %s", err) 61 | } 62 | return nil 63 | } 64 | 65 | func Which(commandName string) string { 66 | return WhichInRoot(commandName, "") 67 | } 68 | 69 | func WhichInRoot(commandName string, root string) string { 70 | cmd := []string{"sh", "-c", "command -v \"$0\"", commandName} 71 | if root != "" && root != "/" { 72 | cmd = append([]string{"chroot", root}, cmd...) 73 | } 74 | out, rc := RunCommandWithRc(cmd...) 75 | if rc == 0 { 76 | return strings.TrimSuffix(string(out), "\n") 77 | } 78 | if rc != 127 { 79 | log.Warnf("checking for %s exited unexpected value %d\n", commandName, rc) 80 | } 81 | return "" 82 | } 83 | 84 | func LogCommand(args ...string) error { 85 | return LogCommandWithFunc(log.Infof, args...) 86 | } 87 | 88 | func LogCommandDebug(args ...string) error { 89 | return LogCommandWithFunc(log.Debugf, args...) 90 | } 91 | 92 | func LogCommandWithFunc(logf func(string, ...interface{}), args ...string) error { 93 | cmd := exec.Command(args[0], args[1:]...) 94 | stdoutPipe, err := cmd.StdoutPipe() 95 | if err != nil { 96 | logf("%s-fail | %s", err) 97 | return err 98 | } 99 | cmd.Stderr = cmd.Stdout 100 | err = cmd.Start() 101 | if err != nil { 102 | logf("%s-fail | %s", args[0], err) 103 | return err 104 | } 105 | pid := cmd.Process.Pid 106 | logf("|%d-start| %q", pid, args) 107 | 108 | var wg sync.WaitGroup 109 | wg.Add(1) 110 | go func() { 111 | err := byline.NewReader(stdoutPipe).Each( 112 | func(line []byte) { 113 | logf("|%d-out | %s", pid, line[:len(line)-1]) 114 | }).Discard() 115 | if err != nil { 116 | log.Fatalf("Unexpected %s", err) 117 | } 118 | wg.Done() 119 | }() 120 | 121 | wg.Wait() 122 | err = cmd.Wait() 123 | 124 | logf("|%d-exit | rc=%d", pid, GetCommandErrorRC(err)) 125 | return err 126 | } 127 | 128 | // CopyFileBits - copy file content from a to b 129 | // differs from CopyFile in: 130 | // - does not do permissions - new files created with 0644 131 | // - if src is a symlink, copies content, not link. 132 | // - does not invoke sh. 133 | func CopyFileBits(src, dest string) error { 134 | if len(src) == 0 { 135 | return fmt.Errorf("Source file is empty string") 136 | } 137 | if len(dest) == 0 { 138 | return fmt.Errorf("Destination file is empty string") 139 | } 140 | in, err := os.Open(src) 141 | if err != nil { 142 | return fmt.Errorf("Failed to open source file %q: %s", src, err) 143 | } 144 | defer in.Close() 145 | 146 | out, err := os.OpenFile(dest, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) 147 | if err != nil { 148 | return fmt.Errorf("Failed to open destination file %q: %s", dest, err) 149 | } 150 | defer out.Close() 151 | 152 | _, err = io.Copy(out, in) 153 | if err != nil { 154 | return fmt.Errorf("Failed while copying %q -> %q: %s", src, dest, err) 155 | } 156 | return out.Close() 157 | } 158 | 159 | // Copy one file to a new path, i.e. cp a b 160 | func CopyFileRefSparse(src, dest string) error { 161 | if err := EnsureDir(filepath.Dir(src)); err != nil { 162 | return err 163 | } 164 | if err := EnsureDir(filepath.Dir(dest)); err != nil { 165 | return err 166 | } 167 | cmdtxt := fmt.Sprintf("cp --force --reflink=auto --sparse=auto %s %s", src, dest) 168 | return RunCommand("sh", "-c", cmdtxt) 169 | } 170 | 171 | func RsyncDirWithErrorQuiet(src, dest string) error { 172 | err := LogCommand("rsync", "--quiet", "--archive", src+"/", dest+"/") 173 | if err != nil { 174 | return errors.Wrapf(err, "Failed copying %s to %s\n", src, dest) 175 | } 176 | return nil 177 | } 178 | 179 | func RunCommand(args ...string) error { 180 | cmd := exec.Command(args[0], args[1:]...) 181 | output, err := cmd.CombinedOutput() 182 | if err != nil { 183 | return fmt.Errorf("%s: %s: %s", strings.Join(args, " "), err, string(output)) 184 | } 185 | return nil 186 | } 187 | 188 | func RunCommandWithRc(args ...string) ([]byte, int) { 189 | out, err := exec.Command(args[0], args[1:]...).CombinedOutput() 190 | return out, GetCommandErrorRC(err) 191 | } 192 | 193 | func RunCommandWithOutputErrorRc(args ...string) ([]byte, []byte, int) { 194 | cmd := exec.Command(args[0], args[1:]...) 195 | var stdout, stderr bytes.Buffer 196 | cmd.Stdout = &stdout 197 | cmd.Stderr = &stderr 198 | err := cmd.Run() 199 | return stdout.Bytes(), stderr.Bytes(), GetCommandErrorRC(err) 200 | } 201 | 202 | func GetCommandErrorRCDefault(err error, rcError int) int { 203 | if err == nil { 204 | return 0 205 | } 206 | exitError, ok := err.(*exec.ExitError) 207 | if ok { 208 | if status, ok := exitError.Sys().(syscall.WaitStatus); ok { 209 | return status.ExitStatus() 210 | } 211 | } 212 | log.Debugf("Unavailable return code for %s. returning %d", err, rcError) 213 | return rcError 214 | } 215 | 216 | func GetCommandErrorRC(err error) int { 217 | return GetCommandErrorRCDefault(err, 127) 218 | } 219 | 220 | func GetTempSocketDir() (string, error) { 221 | d, err := ioutil.TempDir("/tmp", "msockets-*") 222 | if err != nil { 223 | return "", nil 224 | } 225 | if err := checkSocketDir(d); err != nil { 226 | os.RemoveAll(d) 227 | return "", err 228 | } 229 | return d, nil 230 | } 231 | 232 | // LinuxUnixSocketMaxLen - 108 chars max for a unix socket path (including null byte). 233 | const LinuxUnixSocketMaxLen int = 108 234 | 235 | func checkSocketDir(sdir string) error { 236 | // just use this as a filename that might go there. 237 | fname := "monitor.socket" 238 | if len(sdir)+len(fname) >= LinuxUnixSocketMaxLen { 239 | return fmt.Errorf("dir %s is too long (%d) to hold a unix socket", sdir, len(sdir)) 240 | } 241 | return nil 242 | } 243 | 244 | func ForceLink(oldname, newname string) error { 245 | if oldname == "" { 246 | return fmt.Errorf("empty string for parameter 'oldname'") 247 | } 248 | if newname == "" { 249 | return fmt.Errorf("empty string for parameter 'newname'") 250 | } 251 | if !PathExists(oldname) { 252 | return fmt.Errorf("Source file %s does not exist", oldname) 253 | } 254 | log.Debugf("forceLink oldname=%s newname=%s", oldname, newname) 255 | if err := os.Remove(newname); err != nil && !os.IsNotExist(err) { 256 | return fmt.Errorf("Failed removing %s before linking to %s: %s", newname, oldname, err) 257 | } 258 | if err := os.Symlink(oldname, newname); err != nil { 259 | return fmt.Errorf("Failed linking %s -> %s: %s", oldname, newname, err) 260 | } 261 | if !PathExists(newname) { 262 | return fmt.Errorf("Failed to symlink %s -> %s", newname, oldname) 263 | } 264 | return nil 265 | } 266 | -------------------------------------------------------------------------------- /pkg/api/vm.go: -------------------------------------------------------------------------------- 1 | /* 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | */ 14 | package api 15 | 16 | import ( 17 | "bytes" 18 | "context" 19 | "fmt" 20 | "os" 21 | "os/exec" 22 | "path/filepath" 23 | "strconv" 24 | "sync" 25 | "time" 26 | 27 | "github.com/project-machine/qcli" 28 | 29 | log "github.com/sirupsen/logrus" 30 | ) 31 | 32 | type VMState int 33 | 34 | const ( 35 | VMInit VMState = iota 36 | VMStarted 37 | VMStopped 38 | VMFailed 39 | VMCleaned 40 | ) 41 | 42 | func (v VMState) String() string { 43 | switch v { 44 | case VMInit: 45 | return "initialized" 46 | case VMStarted: 47 | return "started" 48 | case VMStopped: 49 | return "stopped" 50 | case VMFailed: 51 | return "failed" 52 | case VMCleaned: 53 | return "cleaned" 54 | default: 55 | return fmt.Sprintf("unknown VMState %d", v) 56 | } 57 | } 58 | 59 | type VMDef struct { 60 | Name string `yaml:"name"` 61 | Cpus uint32 `yaml:"cpus" default:1` 62 | Memory uint32 `yaml:"memory"` 63 | Serial string `yaml:"serial"` 64 | Nics []NicDef `yaml:"nics"` 65 | Disks []QemuDisk `yaml:"disks"` 66 | Boot string `yaml:"boot"` 67 | Cdrom string `yaml:"cdrom"` 68 | UEFICode string `yaml:"uefi-code"` 69 | UEFIVars string `yaml:"uefi-vars"` 70 | TPM bool `yaml:"tpm"` 71 | TPMVersion string `yaml:"tpm-version"` 72 | SecureBoot bool `yaml:"secure-boot"` 73 | Gui bool `yaml:"gui"` 74 | CloudInit CloudInitConfig `yaml:"cloud-init"` 75 | } 76 | 77 | func (v *VMDef) adjustDiskBootIdx(qti *qcli.QemuTypeIndex) ([]string, error) { 78 | allocated := []string{} 79 | // do this in two loops, first, if BootIndex is set in the disk, allocate 80 | // the bit in qti, next, for any disk without BootIndex set, allocate new 81 | // bootindex from qti 82 | 83 | // mark any configured 84 | for n := range v.Disks { 85 | disk := v.Disks[n] 86 | if disk.BootIndex != "" && disk.BootIndex != "off" { 87 | log.Infof("disk: setting configured index %s on %s", disk.BootIndex, disk.File) 88 | bootindex, err := strconv.Atoi(disk.BootIndex) 89 | if err != nil { 90 | return allocated, fmt.Errorf("Failed to parse disk %s BootIndex '%s' as integer: %s", disk.File, disk.BootIndex, err) 91 | } 92 | if err := qti.SetBootIndex(bootindex); err != nil { 93 | return allocated, fmt.Errorf("Failed to set BootIndex %s on disk %s: %s", disk.BootIndex, disk.File, err) 94 | } 95 | allocated = append(allocated, disk.BootIndex) 96 | } 97 | } 98 | 99 | // for any disks without a BootIndex, allocate one 100 | for n := range v.Disks { 101 | disk := v.Disks[n] 102 | if disk.BootIndex == "" { 103 | idx := qti.NextBootIndex() 104 | disk.BootIndex = fmt.Sprintf("%d", idx) 105 | log.Infof("disk: allocating new index %d on %s", idx, disk.File) 106 | allocated = append(allocated, disk.BootIndex) 107 | v.Disks[n] = disk 108 | } 109 | } 110 | 111 | return allocated, nil 112 | } 113 | 114 | func (v *VMDef) adjustNetBootIdx(qti *qcli.QemuTypeIndex) ([]string, error) { 115 | allocated := []string{} 116 | 117 | // do this in two loops, first, if BootIndex is set in the nic, allocate 118 | // the bit in qti, next, for any nic without BootIndex set, allocate new 119 | // bootindex from qti 120 | 121 | // mark any configured 122 | for n := range v.Nics { 123 | nic := v.Nics[n] 124 | if nic.BootIndex != "" && nic.BootIndex != "off" { 125 | log.Infof("nic: setting configured index %s on %s", nic.BootIndex, nic.ID) 126 | bootindex, err := strconv.Atoi(nic.BootIndex) 127 | if err != nil { 128 | return allocated, fmt.Errorf("Failed to parse nic %s BootIndex '%s' as integer: %s", nic.ID, nic.BootIndex, err) 129 | } 130 | if err := qti.SetBootIndex(bootindex); err != nil { 131 | return allocated, fmt.Errorf("Failed to set BootIndex %s on nic %s: %s", nic.BootIndex, nic.ID, err) 132 | } 133 | allocated = append(allocated, nic.BootIndex) 134 | } 135 | } 136 | 137 | // for any nics without a BootIndex, allocate one 138 | for n := range v.Nics { 139 | nic := v.Nics[n] 140 | if nic.BootIndex == "" { 141 | idx := qti.NextBootIndex() 142 | nic.BootIndex = fmt.Sprintf("%d", idx) 143 | log.Infof("nic: allocating new index %d on %s", idx, nic.ID) 144 | allocated = append(allocated, nic.BootIndex) 145 | v.Nics[n] = nic 146 | } 147 | } 148 | 149 | return allocated, nil 150 | } 151 | 152 | func (v *VMDef) AdjustBootIndicies(qti *qcli.QemuTypeIndex) error { 153 | 154 | _, err := v.adjustDiskBootIdx(qti) 155 | if err != nil { 156 | return fmt.Errorf("Error setting disk bootindex values: %s", err) 157 | } 158 | 159 | _, err = v.adjustNetBootIdx(qti) 160 | if err != nil { 161 | return fmt.Errorf("Error setting nic bootindex values: %s", err) 162 | } 163 | 164 | return nil 165 | } 166 | 167 | // TODO: Rename fields 168 | type VM struct { 169 | Ctx context.Context 170 | Cancel context.CancelFunc 171 | Config VMDef 172 | State VMState 173 | RunDir string 174 | sockDir string 175 | Cmd *exec.Cmd 176 | SwTPM *SwTPM 177 | qcli *qcli.Config 178 | qmp *qcli.QMP 179 | qmpCh chan struct{} 180 | wg sync.WaitGroup 181 | } 182 | 183 | // note VM.sockDir is the path to the real sockets and runDir/sockets is a symlink to the socket 184 | func (v *VM) SocketDir() string { 185 | return filepath.Join(v.RunDir, "sockets") 186 | } 187 | 188 | func (v *VM) findCharDeviceByID(deviceID string) (qcli.CharDevice, error) { 189 | for _, chardev := range v.qcli.CharDevices { 190 | if chardev.ID == deviceID { 191 | return chardev, nil 192 | } 193 | } 194 | return qcli.CharDevice{}, fmt.Errorf("Failed to find a char device with id:%s", deviceID) 195 | } 196 | 197 | func (v *VM) MonitorSocket() (string, error) { 198 | devID := "monitor0" 199 | cdev, err := v.findCharDeviceByID(devID) 200 | if err != nil { 201 | return "", fmt.Errorf("Failed to find a monitor device with id=%s: %s", devID, err) 202 | } 203 | return cdev.Path, nil 204 | } 205 | 206 | func (v *VM) SerialSocket() (string, error) { 207 | log.Infof("VM.SerialSocket") 208 | devID := "serial0" 209 | cdev, err := v.findCharDeviceByID(devID) 210 | if err != nil { 211 | return "", fmt.Errorf("Failed to find a serial device with id=%s: %s", devID, err) 212 | } 213 | return cdev.Path, nil 214 | } 215 | 216 | func (v *VM) SpiceDevice() (qcli.SpiceDevice, error) { 217 | return v.qcli.SpiceDevice, nil 218 | } 219 | 220 | func (v *VM) TPMSocket() (string, error) { 221 | return v.qcli.TPM.Path, nil 222 | } 223 | 224 | func newVM(ctx context.Context, clusterName string, vmConfig VMDef) (*VM, error) { 225 | ctx, cancelFn := context.WithCancel(ctx) 226 | runDir := filepath.Join(ctx.Value(clsCtxStateDir).(string), vmConfig.Name) 227 | 228 | if !PathExists(runDir) { 229 | err := EnsureDir(runDir) 230 | if err != nil { 231 | return &VM{}, fmt.Errorf("Error creating VM run dir '%s': %s", runDir, err) 232 | } 233 | } 234 | 235 | // UNIX sockets cannot have a long path so: 236 | // 1. create a dir under /tmp to hold the real sockets, the VM will 237 | // reference this path 238 | // 2. create a symlink, $runDir/sockets which points to the tmp dir 239 | // 3. the VM will use the tmp path, and the Machine will return the statedir 240 | // path to client 241 | tmpSockDir, err := GetTempSocketDir() 242 | if err != nil { 243 | return &VM{}, fmt.Errorf("Failed to create temp socket dir: %s", err) 244 | } 245 | 246 | sockLink := filepath.Join(runDir, "sockets") 247 | if err := ForceLink(tmpSockDir, sockLink); err != nil { 248 | return &VM{}, fmt.Errorf("Failed to link socket dir: %s", err) 249 | } 250 | 251 | log.Infof("newVM: Generating QEMU Config") 252 | qcfg, err := GenerateQConfig(runDir, tmpSockDir, vmConfig) 253 | if err != nil { 254 | return &VM{}, fmt.Errorf("Failed to generate qcli Config from VM definition: %s", err) 255 | } 256 | 257 | // generate cloud-init seed dir if config is present 258 | if HasCloudConfig(vmConfig.CloudInit) { 259 | log.Infof("newVM: vm has cloud-init config, generating ci data") 260 | log.Infof("newVM: network-config: %s", vmConfig.CloudInit.NetworkConfig) 261 | log.Infof("newVM: user-data: %s", vmConfig.CloudInit.UserData) 262 | log.Infof("newVM: meta-data: %s", vmConfig.CloudInit.MetaData) 263 | 264 | // insert MetaData if needed 265 | if vmConfig.CloudInit.MetaData == "" { 266 | log.Infof("newVM: preparing metadata, none provided") 267 | if err := PrepareMetadata(&vmConfig.CloudInit, vmConfig.Name); err != nil { 268 | return &VM{}, fmt.Errorf("failed to prepare cloud-init metadata: %s", err) 269 | } 270 | log.Infof("newVM:updated CloudInit data after prepare") 271 | log.Infof("newVM: network-config: %s", vmConfig.CloudInit.NetworkConfig) 272 | log.Infof("newVM: user-data: %s", vmConfig.CloudInit.UserData) 273 | log.Infof("newVM: meta-data: %s", vmConfig.CloudInit.MetaData) 274 | } 275 | 276 | // render CloudConfig to VM state dir if needed 277 | seedDir := filepath.Join(runDir, "seed") 278 | if !PathExists(seedDir) { 279 | if err := CreateLocalDataSource(vmConfig.CloudInit, seedDir); err != nil { 280 | // FIXME: if this create fails we might want to keep the image around? 281 | os.RemoveAll(seedDir) 282 | return &VM{}, fmt.Errorf("failed to create cloud-init datasource: %s", err) 283 | } 284 | } 285 | 286 | // create a vvfat block device (id, directory, fslabel) 287 | seedBlkID := fmt.Sprintf("%s-cloudcfg", vmConfig.Name) 288 | seedBlockDev, err := NewVVFATBlockDev(seedBlkID, seedDir, NoCloudFSLabel) 289 | if err != nil { 290 | return &VM{}, fmt.Errorf("failed to create a vvfat block device: %s", err) 291 | } 292 | 293 | // insert vvfat blkdev if not already present 294 | found := false 295 | for n := range qcfg.BlkDevices { 296 | if qcfg.BlkDevices[n].ID == seedBlkID { 297 | found = true 298 | } 299 | } 300 | 301 | if !found { 302 | qcfg.BlkDevices = append(qcfg.BlkDevices, seedBlockDev) 303 | } 304 | } 305 | 306 | cmdParams, err := qcli.ConfigureParams(qcfg, nil) 307 | if err != nil { 308 | return &VM{}, fmt.Errorf("Failed to generate new VM command parameters: %s", err) 309 | } 310 | log.Infof("newVM: generated qcli config parameters: %s", cmdParams) 311 | 312 | return &VM{ 313 | Config: vmConfig, 314 | Ctx: ctx, 315 | Cancel: cancelFn, 316 | State: VMInit, 317 | Cmd: exec.CommandContext(ctx, qcfg.Path, cmdParams...), 318 | qcli: qcfg, 319 | RunDir: runDir, 320 | sockDir: tmpSockDir, // this must point to the /tmp path to remain short 321 | }, nil 322 | } 323 | 324 | func (v *VM) Name() string { 325 | return v.Config.Name 326 | } 327 | 328 | func (v *VM) runVM() error { 329 | // add to waitgroup and spawn goroutine to run the command 330 | errCh := make(chan error, 1) 331 | 332 | v.wg.Add(1) 333 | go func() { 334 | var stderr bytes.Buffer 335 | defer func() { 336 | v.wg.Done() 337 | if v.State != VMFailed { 338 | v.State = VMStopped 339 | } 340 | }() 341 | 342 | if v.Config.TPM { 343 | tpmDir := filepath.Join(v.RunDir, "tpm") 344 | if err := EnsureDir(tpmDir); err != nil { 345 | errCh <- fmt.Errorf("Failed to create tpm state dir: %s", err) 346 | return 347 | } 348 | tpmSocket, err := v.TPMSocket() 349 | if err != nil { 350 | errCh <- fmt.Errorf("Failed to get TPM Socket path: %s", err) 351 | return 352 | } 353 | v.SwTPM = &SwTPM{ 354 | StateDir: tpmDir, 355 | Socket: tpmSocket, 356 | Version: v.Config.TPMVersion, 357 | } 358 | if err := v.SwTPM.Start(); err != nil { 359 | errCh <- fmt.Errorf("Failed to start SwTPM: %s", err) 360 | return 361 | } 362 | } 363 | 364 | log.Infof("VM:%s starting QEMU process", v.Name()) 365 | v.Cmd.Stderr = &stderr 366 | err := v.Cmd.Start() 367 | if err != nil { 368 | errCh <- fmt.Errorf("VM:%s failed with: %s", v.Name(), stderr.String()) 369 | return 370 | } 371 | 372 | v.State = VMStarted 373 | log.Infof("VM:%s waiting for QEMU process to exit...", v.Name()) 374 | err = v.Cmd.Wait() 375 | if err != nil { 376 | errCh <- fmt.Errorf("VM:%s wait failed with: %s", v.Name(), stderr.String()) 377 | return 378 | } 379 | log.Infof("VM:%s QEMU process exited", v.Name()) 380 | errCh <- nil 381 | }() 382 | 383 | select { 384 | case err := <-errCh: 385 | if err != nil { 386 | log.Errorf("runVM failed: %s", err) 387 | v.State = VMFailed 388 | return err 389 | } 390 | } 391 | 392 | return nil 393 | } 394 | 395 | func (v *VM) StartQMP() error { 396 | var wg sync.WaitGroup 397 | errCh := make(chan error, 1) 398 | 399 | // FIXME: are there more than one qmp sockets allowed? 400 | numQMP := len(v.qcli.QMPSockets) 401 | if numQMP != 1 { 402 | return fmt.Errorf("StartQMP failed, expected 1 QMP socket, found: %d", numQMP) 403 | } 404 | 405 | // start qmp goroutine 406 | wg.Add(1) 407 | go func() { 408 | defer wg.Done() 409 | // watch for qmp/monitor/serial sockets 410 | waitOn, err := qcli.GetSocketPaths(v.qcli) 411 | if err != nil { 412 | errCh <- fmt.Errorf("StartQMP failed to fetch VM socket paths: %s", err) 413 | return 414 | } 415 | 416 | // wait up to for 10 seconds for each. 417 | for _, sock := range waitOn { 418 | if !WaitForPath(sock, 10, 1) { 419 | errCh <- fmt.Errorf("VM:%s socket %s does not exist", v.Name(), sock) 420 | return 421 | } 422 | } 423 | 424 | qmpCfg := qcli.QMPConfig{ 425 | Logger: QMPMachineLogger{}, 426 | } 427 | 428 | qmpSocketFile := v.qcli.QMPSockets[0].Name 429 | attempt := 0 430 | for { 431 | qmpCh := make(chan struct{}) 432 | attempt = attempt + 1 433 | log.Infof("VM:%s connecting to QMP socket %s attempt %d", v.Name(), qmpSocketFile, attempt) 434 | q, qver, err := qcli.QMPStart(v.Ctx, qmpSocketFile, qmpCfg, qmpCh) 435 | if err != nil { 436 | errCh <- fmt.Errorf("Failed to connect to qmp socket: %s, retrying...", err.Error()) 437 | time.Sleep(time.Second * 1) 438 | continue 439 | } 440 | log.Infof("VM:%s QMP:%v QMPVersion:%v", v.Name(), q, qver) 441 | 442 | // This has to be the first command executed in a QMP session. 443 | err = q.ExecuteQMPCapabilities(v.Ctx) 444 | if err != nil { 445 | errCh <- err 446 | time.Sleep(time.Second * 1) 447 | continue 448 | } 449 | log.Infof("VM:%s QMP ready", v.Name()) 450 | v.qmp = q 451 | v.qmpCh = qmpCh 452 | break 453 | } 454 | errCh <- nil 455 | }() 456 | 457 | // wait until qmp setup is complete (or failed) 458 | wg.Wait() 459 | 460 | select { 461 | case err := <-errCh: 462 | if err != nil { 463 | log.Errorf("StartQMP failed: %s", err) 464 | return err 465 | } 466 | } 467 | 468 | return nil 469 | } 470 | 471 | func (v *VM) BackgroundRun() error { 472 | 473 | // start vm command in background goroutine 474 | go func() { 475 | log.Infof("VM:%s backgrounding runVM()", v.Name()) 476 | err := v.runVM() 477 | if err != nil { 478 | log.Errorf("runVM error: %s", err) 479 | return 480 | } 481 | }() 482 | 483 | go func() { 484 | log.Infof("VM:%s backgrounding StartQMP()", v.Name()) 485 | err := v.StartQMP() 486 | if err != nil { 487 | log.Errorf("StartQMP error: %s", err) 488 | return 489 | } 490 | }() 491 | 492 | return nil 493 | } 494 | 495 | func (v *VM) QMPStatus() qcli.RunState { 496 | if v.qmp != nil { 497 | vmName := v.Name() 498 | log.Infof("VM:%s querying CPUInfo via QMP...", vmName) 499 | cpuInfo, err := v.qmp.ExecQueryCpus(context.TODO()) 500 | if err != nil { 501 | return qcli.RunStateUnknown 502 | } 503 | log.Infof("VM:%s has %d CPUs", vmName, len(cpuInfo)) 504 | 505 | log.Infof("VM:%s querying VM Status via QMP...", vmName) 506 | status, err := v.qmp.ExecuteQueryStatus(context.TODO()) 507 | if err != nil { 508 | return qcli.RunStateUnknown 509 | } 510 | log.Infof("VM:%s Status:%s Running:%v", vmName, status.Status, status.Running) 511 | return qcli.ToRunState(status.Status) 512 | } 513 | log.Infof("VM:%s qmp socket is not ready yet", v.Name()) 514 | return qcli.RunStateUnknown 515 | } 516 | 517 | func (v *VM) Status() VMState { 518 | return v.State 519 | } 520 | 521 | func (v *VM) Start() error { 522 | log.Infof("VM:%s starting...", v.Name()) 523 | err := v.BackgroundRun() 524 | if err != nil { 525 | log.Errorf("VM:%s failed to start VM: %s", v.Name(), err) 526 | v.Stop(true) 527 | return err 528 | } 529 | v.QMPStatus() 530 | return nil 531 | } 532 | 533 | func (v *VM) Stop(force bool) error { 534 | pid := v.Cmd.Process.Pid 535 | status := v.Cmd.ProcessState.String() 536 | log.Infof("VM:%s PID:%d Status:%s Force:%v stopping...\n", v.Name(), pid, status, force) 537 | 538 | v.Status() 539 | 540 | if v.qmp != nil { 541 | log.Infof("VM:%s PID:%d qmp is not nill, sending qmp command", v.Name(), pid) 542 | // FIXME: configurable? 543 | // Try shutdown via QMP, wait up to 10 seconds before force shutting down 544 | timeout := time.Second * 10 545 | 546 | if force { 547 | // Let's force quit 548 | // send a quit message. 549 | log.Infof("VM:%s forcefully stopping vm via quit (%s timeout before cancelling)..", v.Name(), timeout.String()) 550 | err := v.qmp.ExecuteQuit(v.Ctx) 551 | if err != nil { 552 | log.Errorf("VM:%s error:%s", v.Name(), err.Error()) 553 | } 554 | } else { 555 | // Let's try to shutdown the VM. If it hasn't shutdown in 10 seconds we'll 556 | // send a poweroff message. 557 | log.Infof("VM:%s trying graceful shutdown via system_powerdown (%s timeout before cancelling)..", v.Name(), timeout.String()) 558 | err := v.qmp.ExecuteSystemPowerdown(v.Ctx) 559 | if err != nil { 560 | log.Errorf("VM:%s error:%s", v.Name(), err.Error()) 561 | } 562 | } 563 | 564 | log.Infof("waiting on Ctx.Done() or time.After(timeout)") 565 | select { 566 | case <-v.qmpCh: 567 | log.Infof("VM:%s qmpCh.exited: has exited without cancel", v.Name()) 568 | case <-v.Ctx.Done(): 569 | log.Infof("VM:%s Ctx.Done(): has exited without cancel", v.Name()) 570 | case <-time.After(timeout): 571 | log.Warnf("VM:%s timed out, killing via cancel context...", v.Name()) 572 | v.Cancel() 573 | log.Warnf("VM:%s cancel() complete", v.Name()) 574 | } 575 | v.wg.Wait() 576 | } else { 577 | log.Infof("VM:%s PID:%d qmp is not set, killing pid...", v.Name(), pid) 578 | if err := v.Cmd.Process.Kill(); err != nil { 579 | log.Errorf("Error killing VM:%s PID:%d Error:%v", v.Name(), pid, err) 580 | } 581 | } 582 | 583 | if v.Config.TPM { 584 | v.SwTPM.Stop() 585 | } 586 | 587 | // when runVM goroutine exits, it marks v.State = VMStopped 588 | return nil 589 | } 590 | 591 | func (v *VM) IsRunning() bool { 592 | if v.State == VMStarted { 593 | return true 594 | } 595 | return false 596 | } 597 | 598 | func (v *VM) Delete() error { 599 | log.Infof("VM:%s deleting self...", v.Name()) 600 | if v.IsRunning() { 601 | err := v.Stop(true) 602 | if err != nil { 603 | return fmt.Errorf("Failed to delete VM:%s :%s", v.Name(), err) 604 | } 605 | } 606 | 607 | if PathExists(v.RunDir) { 608 | log.Infof("VM:%s removing state dir: %q", v.Name(), v.RunDir) 609 | return os.RemoveAll(v.RunDir) 610 | } 611 | 612 | return nil 613 | } 614 | -------------------------------------------------------------------------------- /pkg/client/machine.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "math/rand" 8 | "path/filepath" 9 | "time" 10 | "net" 11 | "net/http" 12 | 13 | "github.com/go-resty/resty/v2" 14 | 15 | "github.com/project-machine/machine/pkg/api" 16 | ) 17 | 18 | var rootclient *resty.Client 19 | 20 | func init() { 21 | rand.Seed(time.Now().UTC().UnixNano()) 22 | 23 | // configure the http client to point to the unix socket 24 | apiSocket := api.APISocketPath() 25 | if len(apiSocket) == 0 { 26 | panic("Failed to get API socket path") 27 | } 28 | 29 | unixDial := func(_ context.Context, network, addr string) (net.Conn, error) { 30 | raddr, err := net.ResolveUnixAddr("unix", apiSocket) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | return net.DialUnix("unix", nil, raddr) 36 | } 37 | 38 | transport := http.Transport{ 39 | DialContext: unixDial, 40 | DisableKeepAlives: true, 41 | ExpectContinueTimeout: time.Second * 30, 42 | ResponseHeaderTimeout: time.Second * 3600, 43 | TLSHandshakeTimeout: time.Second * 5, 44 | } 45 | 46 | rootclient = resty.New() 47 | rootclient.SetTransport(&transport).SetScheme("http").SetBaseURL(apiSocket) 48 | } 49 | 50 | func GetMachines() ([]api.Machine, error) { 51 | machines := []api.Machine{} 52 | listURL := api.GetAPIURL("machines") 53 | if len(listURL) == 0 { 54 | return machines, fmt.Errorf("Failed to get API URL for 'machines' endpoint") 55 | } 56 | resp, _ := rootclient.R().EnableTrace().Get(listURL) 57 | err := json.Unmarshal(resp.Body(), &machines) 58 | if err != nil { 59 | return machines, fmt.Errorf("Failed to unmarshal GET on /machines") 60 | } 61 | return machines, nil 62 | } 63 | 64 | func GetMachine(machineName string) (api.Machine, int, error) { 65 | machine := api.Machine{} 66 | getURL := api.GetAPIURL(filepath.Join("machines", machineName)) 67 | if len(getURL) == 0 { 68 | return machine, http.StatusBadRequest, fmt.Errorf("Failed to get API URL for 'machines/%s' endpoint", machineName) 69 | } 70 | resp, _ := rootclient.R().EnableTrace().Get(getURL) 71 | err := json.Unmarshal(resp.Body(), &machine) 72 | if err != nil { 73 | return machine, resp.StatusCode(), fmt.Errorf("%d: Failed to unmarshal GET on /machines/%s", resp.StatusCode(), machineName) 74 | } 75 | return machine, resp.StatusCode(), nil 76 | } 77 | 78 | func PutMachine(newMachine api.Machine) error { 79 | endpoint := fmt.Sprintf("machines/%s", newMachine.Name) 80 | putURL := api.GetAPIURL(endpoint) 81 | if len(putURL) == 0 { 82 | return fmt.Errorf("Failed to get API PUT URL for 'machines' endpoint") 83 | } 84 | resp, err := rootclient.R().EnableTrace().SetBody(newMachine).Put(putURL) 85 | if err != nil { 86 | return fmt.Errorf("Failed PUT to machine '%s' endpoint: %s", newMachine.Name, err) 87 | } 88 | fmt.Printf("%s %s\n", resp, resp.Status()) 89 | return nil 90 | } 91 | -------------------------------------------------------------------------------- /systemd/machined.service.tpl: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Machined Service 3 | Requires=machined.socket 4 | After=machined.socket 5 | StartLimitIntervalSec=0 6 | 7 | [Service] 8 | Delegate=true 9 | Type=exec 10 | KillMode=process 11 | ExecStart={{.MachinedBinaryPath}} 12 | 13 | [Install] 14 | WantedBy=default.target 15 | -------------------------------------------------------------------------------- /systemd/machined.socket.tpl: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Machined Socket 3 | 4 | [Socket] 5 | ListenStream=%t/machined/machined.socket 6 | SocketMode=0660 7 | 8 | [Install] 9 | WantedBy=sockets.target 10 | --------------------------------------------------------------------------------