├── .dockerignore ├── .github └── workflows │ └── cubic.yml ├── .gitignore ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── docs ├── install │ ├── snap.md │ └── source.md └── usage │ ├── add_delete.md │ ├── configure.md │ ├── copy_mount.md │ ├── hostfwd.md │ ├── list.md │ ├── rename_clone.md │ ├── sh.md │ └── start_stop.md ├── snapcraft.yaml ├── src ├── arch.rs ├── commands.rs ├── commands │ ├── command_dispatcher.rs │ ├── console.rs │ ├── image.rs │ ├── instance.rs │ ├── instance_add_command.rs │ ├── instance_clone_command.rs │ ├── instance_config_command.rs │ ├── instance_info_command.rs │ ├── instance_list_command.rs │ ├── instance_remove_command.rs │ ├── instance_rename_command.rs │ ├── mount.rs │ ├── net.rs │ ├── net │ │ └── hostfwd.rs │ ├── restart.rs │ ├── run.rs │ ├── scp.rs │ ├── sh.rs │ ├── ssh.rs │ ├── start.rs │ ├── stop.rs │ └── verbosity.rs ├── emulator.rs ├── error.rs ├── fs.rs ├── image.rs ├── image │ ├── image_dao.rs │ ├── image_factory.rs │ └── image_fetcher.rs ├── instance.rs ├── instance │ ├── instance_dao.rs │ ├── instance_state.rs │ ├── instance_store.rs │ └── instance_store_mock.rs ├── main.rs ├── qemu.rs ├── qemu │ ├── guest_agent.rs │ ├── monitor.rs │ ├── qmp.rs │ └── qmp_message.rs ├── ssh_cmd.rs ├── ssh_cmd │ ├── port_checker.rs │ ├── scp.rs │ └── ssh.rs ├── util.rs ├── util │ ├── env.rs │ ├── generate_random_ssh_port.rs │ ├── input.rs │ ├── migration.rs │ ├── process.rs │ ├── qemu.rs │ └── terminal.rs ├── view.rs ├── view │ ├── console.rs │ ├── console_mock.rs │ ├── map_view.rs │ ├── spinner_view.rs │ ├── stdio.rs │ ├── table_view.rs │ ├── timer_view.rs │ └── transfer_view.rs ├── web.rs └── web │ └── web_client.rs └── taskfile.yml /.dockerignore: -------------------------------------------------------------------------------- 1 | .* 2 | -------------------------------------------------------------------------------- /.github/workflows/cubic.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | name: Cubic 4 | permissions: 5 | contents: read 6 | pull-requests: read 7 | 8 | jobs: 9 | check: 10 | name: Check 11 | runs-on: ubuntu-24.04 12 | steps: 13 | - name: Install task 14 | run: sudo snap install task --classic 15 | 16 | - name: Checkout Code 17 | uses: actions/checkout@v4 18 | 19 | - name: Build image 20 | run: task build-image 21 | 22 | - name: Check 23 | run: task check 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribute to Cubic 2 | 3 | Contributions are very welcome. 4 | Thank you for taking the time to contribute to Cubic! 5 | 6 | ## How to contribute? 7 | 8 | You can contribute by creating a pull request (PR) on the official Github repository: 9 | https://github.com/cubic-vm/cubic/pulls 10 | 11 | ## What license does Cubic use? 12 | 13 | Cubic is licensed under GPL-2 and any contribution must be released under the same license. 14 | 15 | ## How to create a good pull request? 16 | 17 | High quality pull requests are easier to review and thus take less of your and our time. 18 | 19 | General guideline: 20 | - Each pull request must have exactly one intend (fix a bug, update doc, etc.). 21 | - Each pull request should have one Git commit (not mandatory, but recommend). 22 | - Each Git commit must have a descriptive message that explains the changes. 23 | - Each Git commit must have a sign off (git commit --signoff). 24 | - Each Git commit message must start with either: 25 | - `feat: ...` for features 26 | - `fix: ...` for bug and security fixes 27 | - `refactor: ...` for code refactorings 28 | - `docs: ...` for documentation changes 29 | - `chore: ...` for changes not related to source code 30 | - `revert: ...` for reverting a previous commit 31 | 32 | Mandatory check before creating a pull request: `task check` 33 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "cubic" 3 | version = "0.10.0" 4 | authors = ["Roger Knecht "] 5 | license = "GPL-2.0-only" 6 | description = """\ 7 | Cubic is a lightweight command line manager for virtual machines. It has a 8 | simple, daemon-less and rootless design. All Cubic virtual machines run 9 | isolated in the user context. Cubic is built on top of QEMU, KVM and cloud-init. 10 | 11 | Show all supported images: 12 | $ cubic image ls 13 | 14 | Create a new virtual machine instance: 15 | $ cubic add mymachine --image ubuntu:noble 16 | 17 | List all virtual machine instances: 18 | $ cubic ls 19 | 20 | Start an instance: 21 | $ cubic start 22 | 23 | Stop an instance: 24 | $ cubic stop 25 | 26 | Open a shell in the instance: 27 | $ cubic ssh 28 | 29 | Copy a file from the host to the instance: 30 | $ cubic scp : 31 | 32 | Copy a file from the instance to the hots: 33 | $ cubic scp : 34 | """ 35 | readme = "README.md" 36 | homepage = "https://github.com/cubic-vm/cubic" 37 | repository = "https://github.com/cubic-vm/cubic" 38 | keywords = ["cli", "vm"] 39 | categories = ["command-line-utilities"] 40 | edition = "2021" 41 | 42 | [features] 43 | qemu-sandbox = [] 44 | 45 | [dependencies] 46 | clap = { version = "^4", features = ["derive"] } 47 | reqwest = { version = "^0", default-features = false, features = ["rustls-tls", "blocking", "gzip", "brotli"] } 48 | serde = { version = "^1", features = ["derive"] } 49 | serde_json = "^1" 50 | serde_yaml = "^0" 51 | libc = "^0" 52 | regex = "^1" 53 | 54 | [profile.release] 55 | opt-level = 'z' 56 | lto = true 57 | codegen-units = 1 58 | panic = 'abort' 59 | strip = true 60 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM rust:1.86.0 2 | WORKDIR /usr/local/app 3 | 4 | COPY . . 5 | 6 | ENV DEBIAN_FRONTEND=noninteractive 7 | ENV XDG_RUNTIME_DIR=/tmp 8 | RUN apt update && \ 9 | apt install -y qemu-utils genisoimage qemu-system-x86 qemu-system-arm 10 | RUN rustup component add clippy rustfmt && \ 11 | cargo install --locked cargo-audit@0.21.1 &&\ 12 | cargo fetch 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cubic 2 | 3 | Cubic is a lightweight command line manager for virtual machines with focus on simplicity and security. 4 | 5 | It has a simple, daemon-less and rootless design. All Cubic virtual machines run isolated in the user context. 6 | Cubic is built on top of `QEMU`, `KVM` and `cloud-init`. 7 | 8 | **Official website**: https://github.com/cubic-vm/cubic 9 | 10 | ## Features 11 | 12 | - Simple command line interface 13 | - Daemon-less design 14 | - Works without root rights 15 | - Supports KVM acceleration 16 | - Supports ArchLinux, Debian, Fedora, OpenSUSE and Ubuntu guest images 17 | - Supports file transfers between host and guest 18 | - Supports directory mounting between host and guest 19 | - Written in Rust 20 | 21 | ## Quick Start 22 | 23 | A virtual machine instance can be created with a single command: 24 | ``` 25 | $ cubic run --name quickstart --image ubuntu:noble 26 | Welcome to Ubuntu 24.04 LTS (GNU/Linux 6.8.0-35-generic x86_64) 27 | 28 | * Documentation: https://help.ubuntu.com 29 | * Management: https://landscape.canonical.com 30 | * Support: https://ubuntu.com/pro 31 | 32 | System information as of Sun Jul 14 13:58:15 UTC 2024 33 | 34 | System load: 0.15 35 | Usage of /: 60.7% of 2.35GB 36 | Memory usage: 29% 37 | Swap usage: 0% 38 | Processes: 150 39 | Users logged in: 0 40 | IPv4 address for ens13: 10.0.2.15 41 | IPv6 address for ens13: fec0::5054:ff:fe12:3456 42 | 43 | Expanded Security Maintenance for Applications is not enabled. 44 | 45 | 0 updates can be applied immediately. 46 | 47 | Enable ESM Apps to receive additional future security updates. 48 | See https://ubuntu.com/esm or run: sudo pro status 49 | 50 | 51 | The list of available updates is more than a week old. 52 | To check for new updates run: sudo apt update 53 | 54 | 55 | The programs included with the Ubuntu system are free software; 56 | the exact distribution terms for each program are described in the 57 | individual files in /usr/share/doc/*/copyright. 58 | 59 | Ubuntu comes with ABSOLUTELY NO WARRANTY, to the extent permitted by 60 | applicable law. 61 | 62 | cubic@quickstart:~$ 63 | ``` 64 | 65 | The supported images can be listed with: 66 | ``` 67 | $ cubic image ls 68 | Vendor Version Name Arch Size 69 | archlinux latest latest amd64 70 | debian 9 stretch amd64 71 | debian 10 buster amd64 72 | debian 11 bullseye amd64 73 | debian 12 bookworm amd64 74 | debian 13 trixie amd64 75 | debian 14 forky amd64 76 | fedora 39 39 amd64 77 | fedora 40 40 amd64 78 | fedora 41 41 amd64 79 | ubuntu 18.04 bionic amd64 80 | ubuntu 18.10 cosmic amd64 81 | ubuntu 19.04 disco amd64 82 | ubuntu 19.10 eoan amd64 83 | ubuntu 20.04 focal amd64 84 | ubuntu 20.10 groovy amd64 85 | ubuntu 21.04 hirsute amd64 86 | ubuntu 21.10 impish amd64 87 | ubuntu 22.04 jammy amd64 284.6 MiB 88 | ubuntu 22.10 kinetic amd64 89 | ubuntu 23.04 lunar amd64 90 | ubuntu 23.10 mantic amd64 91 | ubuntu 24.04 noble amd64 92 | ubuntu 24.10 oracular amd64 93 | ``` 94 | 95 | A virtual machine instance can be started, stopped and restarted by: 96 | - `cubic start ` 97 | - `cubic stop ` 98 | - `cubic restart ` 99 | 100 | In order to open a shell in the virtual machine instance use: 101 | ```cubic ssh ``` 102 | 103 | ## How to install Cubic? 104 | - [Install Cubic as Snap](docs/install/snap.md) 105 | - [Install Cubic from source](docs/install/source.md) 106 | 107 | ## How to use Cubic? 108 | 109 | Cubic has a simple CLI: 110 | ``` 111 | $ cubic --help 112 | Cubic is a lightweight command line manager for virtual machines. It has a 113 | simple, daemon-less and rootless design. All Cubic virtual machines run 114 | isolated in the user context. Cubic is built on top of QEMU, KVM and cloud-init. 115 | 116 | Show all supported images: 117 | $ cubic image ls 118 | 119 | Create a new virtual machine instance: 120 | $ cubic add mymachine --image ubuntu:noble 121 | 122 | List all virtual machine instances: 123 | $ cubic ls 124 | 125 | Start an instance: 126 | $ cubic start 127 | 128 | Stop an instance: 129 | $ cubic stop 130 | 131 | Open a shell in the instance: 132 | $ cubic ssh 133 | 134 | Copy a file from the host to the instance: 135 | $ cubic scp : 136 | 137 | Copy a file from the instance to the hots: 138 | $ cubic scp : 139 | 140 | 141 | Usage: cubic [COMMAND] 142 | 143 | Commands: 144 | run Setup and run a new instance 145 | list List instances 146 | info Get information about an instance 147 | sh Open a shell in an instance 148 | ssh Connect to an instance with SSH 149 | scp Copy a file from or to an instance with SCP 150 | start Start instances 151 | stop Stop instances 152 | restart Restart instances 153 | instance Instance commands 154 | image Image commands 155 | mount Mount commands 156 | help Print this message or the help of the given subcommand(s) 157 | 158 | Options: 159 | -h, --help Print help 160 | -V, --version Print version 161 | ``` 162 | 163 | ## Usage: 164 | - [Add and Delete Virtual Machines](docs/usage/add_delete.md) 165 | - [Start, Stop and Restart Virtual Machines](docs/usage/start_stop.md) 166 | - [List Images and Virtual Machines](docs/usage/list.md) 167 | - [Open Shell in Virtual Machines](docs/usage/sh.md) 168 | - [Transfer Directories and Files](docs/usage/copy_mount.md) 169 | - [Configure Virtual Machines](docs/usage/configure.md) 170 | - [Rename and Clone Virtual Machines](docs/usage/rename_clone.md) 171 | - [Guest to Host Port Forwarding](docs/usage/hostfwd.md) 172 | 173 | ## How to contribute to Cubic? 174 | 175 | See: [Contribute to Cubic](CONTRIBUTING.md) 176 | -------------------------------------------------------------------------------- /docs/install/snap.md: -------------------------------------------------------------------------------- 1 | # Install Cubic as Snap 2 | 3 | Cubic can be installed from the Snap Store: 4 | ``` 5 | $ sudo snap install cubic 6 | $ sudo snap connect cubic:kvm 7 | $ sudo snap connect cubic:ssh-keys 8 | ``` 9 | 10 | ## How to enable Kernel Virtual Machine (KVM) acceleration? 11 | 12 | Virtual machines perform a lot better with KVM support. 13 | It is recommend to allow KVM access to the Cubic snap. 14 | 15 | Steps: 16 | 1. Make sure your current user is in the `kvm` group (show groups of your user: `groups`) 17 | 1. You can add your user to the `kvm` group by: `sudo usermod -a -G kvm $USER` and then reboot. 18 | 2. Permit access to the kernel virtual machine (KVM) for hardware acceleration: 19 | `sudo snap connect cubic:kvm` 20 | 21 | 22 | -------------------------------------------------------------------------------- /docs/install/source.md: -------------------------------------------------------------------------------- 1 | # Install Cubic from Source 2 | 3 | Cubic requires the following dependencies: 4 | - Cargo 5 | - QEMU 6 | - OpenSSH Client 7 | - mkisofs 8 | 9 | The dependencies can be installed for Debian and Ubuntu with the following command: 10 | ``` 11 | $ sudo apt install cargo qemu-system-x86 qemu-system-arm qemu-utils genisoimage openssh-client 12 | ``` 13 | 14 | Build the Rust project with the Cargo package manager: 15 | ``` 16 | $ cargo build 17 | ``` 18 | 19 | Install the binaries: 20 | ``` 21 | $ cargo install --path . 22 | $ export PATH="$PATH:$HOME/.cargo/bin" 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/usage/add_delete.md: -------------------------------------------------------------------------------- 1 | # Add and Delete Virtual Machine Instances 2 | 3 | ## Instance Add Command 4 | Create a virtual machine instance: 5 | ``` 6 | $ cubic add --help 7 | Add a virtual machine instance 8 | 9 | Usage: cubic add [OPTIONS] --image [INSTANCE_NAME] 10 | 11 | Arguments: 12 | [INSTANCE_NAME] Name of the virtual machine instance 13 | 14 | Options: 15 | -i, --image Name of the virtual machine image 16 | -c, --cpus Number of CPUs for the virtual machine instance 17 | -m, --mem Memory size of the virtual machine instance (e.g. 1G for 1 gigabyte) 18 | -d, --disk Disk size of the virtual machine instance (e.g. 10G for 10 gigabytes) 19 | -h, --help Print help 20 | ``` 21 | **Example**: 22 | ``` 23 | $ cubic add example --image ubuntu:noble:amd64 --cpus 4 --mem 4G --disk 5G 24 | ``` 25 | 26 | ## Instance Delete Command 27 | 28 | Delete a virtual machine instance: 29 | ``` 30 | $ cubic rm --help 31 | Delete virtual machine instances 32 | 33 | Usage: cubic rm [OPTIONS] [INSTANCES]... 34 | 35 | Arguments: 36 | [INSTANCES]... Name of the virtual machine instances to delete 37 | 38 | Options: 39 | -v, --verbose Enable verbose logging 40 | -q, --quiet Reduce logging output 41 | -f, --force Delete the virtual machine instances without confirmation 42 | -h, --help Print help 43 | ``` 44 | **Example**: 45 | ``` 46 | $ cubic rm example 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/usage/configure.md: -------------------------------------------------------------------------------- 1 | # Configure Virtual Machines 2 | 3 | ## Instance Config Command 4 | ``` 5 | $ cubic config --help 6 | Read and write virtual machine instance configuration parameters 7 | 8 | Usage: cubic config [OPTIONS] 9 | 10 | Arguments: 11 | Name of the virtual machine instance 12 | 13 | Options: 14 | -c, --cpus Number of CPUs for the virtual machine instance 15 | -m, --mem Memory size of the virtual machine instance (e.g. 1G for 1 gigabyte) 16 | -d, --disk Disk size of the virtual machine instance (e.g. 10G for 10 gigabytes) 17 | -h, --help Print help 18 | ``` 19 | **Example:** 20 | Change a virtual machine instance config: 21 | ``` 22 | $ cubic config --cpus 5 --mem 5G --disk 5G example 23 | ``` 24 | 25 | ## Instance Info Command 26 | ``` 27 | Get information about an virtual machine instance 28 | 29 | Usage: cubic info 30 | 31 | Arguments: 32 | Name of the virtual machine instance 33 | 34 | Options: 35 | -h, --help Print help 36 | ``` 37 | 38 | Show a virtual machine instance configuration: 39 | ``` 40 | $ cubic info example 41 | cpus: 4 42 | mem: 4.0 GiB 43 | disk: 2.2 GiB 44 | ``` 45 | -------------------------------------------------------------------------------- /docs/usage/copy_mount.md: -------------------------------------------------------------------------------- 1 | # Transfer Directories and Files 2 | 3 | ## SCP Command 4 | ``` 5 | $ cubic scp --help 6 | Copy a file from or to a virtual machine instance with SCP 7 | 8 | Usage: cubic scp [OPTIONS] 9 | 10 | Arguments: 11 | Source of the data to copy 12 | Target of the data to copy 13 | 14 | Options: 15 | -v, --verbose Enable verbose logging 16 | -q, --quiet Reduce logging output 17 | --scp-args Pass additional SCP arguments 18 | -h, --help Print help 19 | ``` 20 | 21 | **Example:** 22 | Copy a file from the host to an virtual machine instance: 23 | ``` 24 | $ touch test 25 | $ cubic scp test example:~/ 26 | ``` 27 | 28 | Copy a directory from the virtual machine instance to the host: 29 | ``` 30 | $ cubic scp example:~/Documents/ . 31 | ``` 32 | 33 | ## Mount Command 34 | ``` 35 | $ cubic mount --help 36 | Mount commands 37 | 38 | Usage: cubic mount 39 | 40 | Commands: 41 | list List mount mounts 42 | add Add a directory mount 43 | del Delete a directory mount 44 | help Print this message or the help of the given subcommand(s) 45 | 46 | Options: 47 | -h, --help Print help 48 | ``` 49 | 50 | **Example:** 51 | Mount a host directory to the virtual machine instance: 52 | ``` 53 | $ cubic mount add example /home/tux/Documennts /home/cubic/Documents 54 | ``` 55 | 56 | List mounts of virtual machine instance: 57 | ``` 58 | $ cubic mount list example 59 | HOST GUEST 60 | /home/tux/Documennts /home/cubic/Documents 61 | ``` 62 | 63 | Unmount a directory: 64 | ``` 65 | $ cubic mount del example /home/cubic/Documents 66 | ``` 67 | -------------------------------------------------------------------------------- /docs/usage/hostfwd.md: -------------------------------------------------------------------------------- 1 | # Guest to Host Port Forwarding 2 | 3 | ``` 4 | Guest to host port forwarding commands 5 | 6 | List forwarded ports for all instances: 7 | $ cubic net hostfwd list 8 | 9 | Forward guest SSH port (TCP port 22) to host on port 8000: 10 | $ cubic net hostfwd add myinstance tcp:127.0.0.1:8000-:22 11 | 12 | Remove port forwarding: 13 | $ cubic net hostfwd del myinstance tcp:127.0.0.1:8000-:22 14 | 15 | Usage: cubic net hostfwd 16 | 17 | Commands: 18 | list List forwarded host ports 19 | add Add host port forwarding rule 20 | del Delete host port forwarding rule 21 | help Print this message or the help of the given subcommand(s) 22 | 23 | Options: 24 | -h, --help 25 | Print help (see a summary with '-h') 26 | ``` 27 | -------------------------------------------------------------------------------- /docs/usage/list.md: -------------------------------------------------------------------------------- 1 | # List Images and Virtual Machines 2 | 3 | ## List Virtual Machine Instances 4 | 5 | List virtual machine instancess: 6 | ``` 7 | $ cubic ls 8 | Name CPUs Memory Disk State 9 | noble 1 1.0 GiB 2.0 GiB STOPPED 10 | ``` 11 | 12 | ## List Virtual Machine Images 13 | 14 | List all virtual machine images: 15 | ``` 16 | $ cubic image ls 17 | Name Arch Size 18 | archlinux:latest amd64 516.0 MiB 19 | debian:12 amd64 424.2 MiB 20 | debian:bookworm amd64 424.2 MiB 21 | debian:11 amd64 345.1 MiB 22 | debian:bullseye amd64 345.1 MiB 23 | debian:10 amd64 301.7 MiB 24 | debian:buster amd64 301.7 MiB 25 | fedora:41 amd64 468.9 MiB 26 | fedora:42 amd64 507.6 MiB 27 | ubuntu:18.04 amd64 206.0 MiB 28 | ubuntu:bionic amd64 206.0 MiB 29 | ubuntu:18.10 amd64 289.7 MiB 30 | ubuntu:cosmic amd64 289.7 MiB 31 | ubuntu:19.04 amd64 153.3 MiB 32 | ubuntu:disco amd64 153.3 MiB 33 | ubuntu:19.10 amd64 193.1 MiB 34 | ... 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/usage/rename_clone.md: -------------------------------------------------------------------------------- 1 | # Rename and Clone Virtual Machines 2 | 3 | ## Instance Rename Command 4 | Rename a virtual machine instance: 5 | ``` 6 | $ cubic rename --help 7 | Rename a virtual machine instance 8 | 9 | Usage: cubic rename 10 | 11 | Arguments: 12 | Name of the virtual machine instance to rename 13 | New name of the virutal machine instance 14 | 15 | Options: 16 | -h, --help Print help 17 | ``` 18 | 19 | **Example:** 20 | ``` 21 | $ cubic rename example example_new 22 | ``` 23 | 24 | ## Instance Clone Command 25 | Clone a virtual machine instance: 26 | ``` 27 | $ cubic clone --help 28 | Clone a virtual machine instance 29 | 30 | Usage: cubic clone 31 | 32 | Arguments: 33 | Name of the virtual machine instance to clone 34 | Name of the copy 35 | 36 | Options: 37 | -h, --help Print help 38 | ``` 39 | 40 | **Example:** 41 | ``` 42 | $ cubic clone example example2 43 | ``` 44 | -------------------------------------------------------------------------------- /docs/usage/sh.md: -------------------------------------------------------------------------------- 1 | # SH Command 2 | Open shell over a serial connection on the virtual machine instance. 3 | ``` 4 | $ cubic sh --help 5 | Open a shell in a virtual machine instance 6 | 7 | Usage: cubic sh [OPTIONS] 8 | 9 | Arguments: 10 | Name of the virtual machine instance 11 | 12 | Options: 13 | -v, --verbose Enable verbose logging 14 | -q, --quiet Reduce logging output 15 | -h, --help Print help 16 | ``` 17 | **Example**: 18 | ``` 19 | $ cubic sh example 20 | ``` 21 | 22 | # SSH Command 23 | Connect with SSH to a virtual machine instance: 24 | ``` 25 | Usage: cubic ssh [OPTIONS] [CMD] 26 | 27 | Arguments: 28 | Name of the virtual machine instance 29 | [CMD] Execute a command in the virtual machine 30 | 31 | Options: 32 | -X Forward X over SSH 33 | -v, --verbose Enable verbose logging 34 | -q, --quiet Reduce logging output 35 | --ssh-args Pass additional SSH arguments 36 | -h, --help Print help 37 | ``` 38 | 39 | **Example**: 40 | ``` 41 | $ cubic ssh example 42 | ``` 43 | -------------------------------------------------------------------------------- /docs/usage/start_stop.md: -------------------------------------------------------------------------------- 1 | # Start, Stop and Restart Virtual Machines 2 | 3 | ## Start Command 4 | ``` 5 | $ cubic start --help 6 | Start virtual machine instances 7 | 8 | Usage: cubic start [OPTIONS] [INSTANCES]... 9 | 10 | Arguments: 11 | [INSTANCES]... Name of the virtual machine instances to start 12 | 13 | Options: 14 | --qemu-args Pass additional QEMU arguments 15 | -v, --verbose Enable verbose logging 16 | -q, --quiet Reduce logging output 17 | -h, --help Print help 18 | ``` 19 | 20 | ## Stop Command 21 | ``` 22 | $ cubic stop --help 23 | Stop virtual machine instances 24 | 25 | Usage: cubic stop [OPTIONS] [INSTANCES]... 26 | 27 | Arguments: 28 | [INSTANCES]... Name of the virtual machine instances to stop 29 | 30 | Options: 31 | -a, --all Stop all virtual machine instances 32 | -v, --verbose Enable verbose logging 33 | -q, --quiet Reduce logging output 34 | -h, --help Print help 35 | ``` 36 | 37 | ## Restart Command 38 | ``` 39 | $ cubic restart --help 40 | Restart virtual machine instances 41 | 42 | Usage: cubic restart [OPTIONS] [INSTANCES]... 43 | 44 | Arguments: 45 | [INSTANCES]... Name of the virtual machine instances to restart 46 | 47 | Options: 48 | -v, --verbose Enable verbose logging 49 | -q, --quiet Reduce logging output 50 | -h, --help Print help 51 | ``` 52 | -------------------------------------------------------------------------------- /snapcraft.yaml: -------------------------------------------------------------------------------- 1 | name: cubic 2 | version: '0.10.0' 3 | license: GPL-2.0-only 4 | website: https://github.com/cubic-vm/cubic 5 | source-code: https://github.com/cubic-vm/cubic 6 | issues: https://github.com/cubic-vm/cubic/issues 7 | summary: Cubic is a lightweight command line manager for virtual machines. 8 | description: | 9 | Cubic is a lightweight command line manager for virtual machines with focus on simplicity and security. 10 | 11 | It has a daemon-less and rootless design. All Cubic virtual machines run unprivileged in the user context. Cubic is built on top of QEMU, KVM and cloud-init. 12 | base: core24 13 | platforms: 14 | amd64: 15 | arm64: 16 | confinement: strict 17 | parts: 18 | cubic: 19 | plugin: rust 20 | source: . 21 | rust-cargo-parameters: ["--features", "qemu-sandbox"] 22 | runtime-dependencies: 23 | plugin: nil 24 | stage-packages: 25 | - genisoimage 26 | - openssh-client 27 | - qemu-utils 28 | - qemu-system-x86 29 | - qemu-system-arm 30 | - qemu-system-gui 31 | - qemu-system-modules-spice 32 | - qemu-efi-aarch64 33 | - seabios 34 | - libvirglrenderer1 35 | override-build: | 36 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/share/qemu/openbios-ppc 37 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/share/qemu/openbios-sparc32 38 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/share/qemu/openbios-sparc64 39 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/cmake* 40 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/dri* 41 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/gdk* 42 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/gio* 43 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/gli* 44 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/gstreamer* 45 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/gtk* 46 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/icu* 47 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libEGL* 48 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libGL* 49 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libLLVM* 50 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libOpenGL* 51 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libSDL* 52 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libX* 53 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libasound* 54 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libavahi* 55 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libavahi* 56 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libbmp* 57 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libdrm* 58 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libgdk* 59 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libgst* 60 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libgtk* 61 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libicudata* 62 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libjpeg* 63 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libnss* 64 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libogg* 65 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libopus* 66 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libpipewire* 67 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libpulse* 68 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libsensors* 69 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libsharp* 70 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libsnd* 71 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libtiff* 72 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libunwind* 73 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libvorbis* 74 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libvulkan* 75 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libwayland* 76 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libwebp* 77 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libxcb* 78 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libxml* 79 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/libxshm* 80 | rm -rf $SNAPCRAFT_PART_INSTALL/usr/lib/*/spa* 81 | apps: 82 | cubic: 83 | extensions: [gnome] 84 | command: bin/cubic 85 | plugs: 86 | - kvm 87 | - network 88 | - network-bind 89 | - home 90 | - ssh-keys 91 | environment: 92 | LD_LIBRARY_PATH: $LD_LIBRARY_PATH:$SNAP/usr/lib/$CRAFT_ARCH_TRIPLET_BUILD_FOR/libproxy 93 | -------------------------------------------------------------------------------- /src/arch.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use serde::{Deserialize, Serialize}; 3 | use std::fmt; 4 | 5 | #[derive(Eq, Hash, PartialEq, Default, Debug, Clone, Copy, Serialize, Deserialize)] 6 | pub enum Arch { 7 | #[default] 8 | AMD64, 9 | ARM64, 10 | } 11 | 12 | impl Arch { 13 | pub fn from_str(arch: &str) -> Result { 14 | match arch { 15 | "amd64" => Ok(Arch::AMD64), 16 | "arm64" => Ok(Arch::ARM64), 17 | _ => Result::Err(Error::UnknownArch(arch.to_string())), 18 | } 19 | } 20 | } 21 | 22 | impl fmt::Display for Arch { 23 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 24 | match &self { 25 | Arch::AMD64 => write!(f, "amd64"), 26 | Arch::ARM64 => write!(f, "arm64"), 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/commands.rs: -------------------------------------------------------------------------------- 1 | pub mod command_dispatcher; 2 | pub mod console; 3 | pub mod image; 4 | pub mod instance; 5 | pub mod instance_add_command; 6 | pub mod instance_clone_command; 7 | pub mod instance_config_command; 8 | pub mod instance_info_command; 9 | pub mod instance_list_command; 10 | pub mod instance_remove_command; 11 | pub mod instance_rename_command; 12 | pub mod mount; 13 | pub mod net; 14 | pub mod restart; 15 | pub mod run; 16 | pub mod scp; 17 | pub mod sh; 18 | pub mod ssh; 19 | pub mod start; 20 | pub mod stop; 21 | pub mod verbosity; 22 | 23 | pub use command_dispatcher::*; 24 | pub use console::*; 25 | pub use image::*; 26 | pub use instance::*; 27 | pub use instance_add_command::*; 28 | pub use instance_clone_command::*; 29 | pub use instance_config_command::*; 30 | pub use instance_info_command::*; 31 | pub use instance_list_command::*; 32 | pub use instance_remove_command::*; 33 | pub use instance_rename_command::*; 34 | pub use mount::*; 35 | pub use net::*; 36 | pub use restart::*; 37 | pub use run::*; 38 | pub use scp::*; 39 | pub use sh::*; 40 | pub use ssh::*; 41 | pub use start::*; 42 | pub use stop::*; 43 | pub use verbosity::*; 44 | -------------------------------------------------------------------------------- /src/commands/command_dispatcher.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::{ 2 | self, InstanceAddCommand, InstanceCloneCommand, InstanceConfigCommand, InstanceInfoCommand, 3 | InstanceListCommand, InstanceRemoveCommand, InstanceRenameCommand, Verbosity, 4 | }; 5 | use crate::error::Error; 6 | use crate::image::ImageDao; 7 | use crate::instance::InstanceDao; 8 | use crate::view::{Console, Stdio}; 9 | use clap::{Parser, Subcommand}; 10 | 11 | #[derive(Subcommand)] 12 | pub enum Commands { 13 | /// Setup and run a new instance 14 | Run { 15 | /// Name of the virtual machine image 16 | #[clap(short, long)] 17 | image: String, 18 | /// Name of the virtual machine instance 19 | #[clap(short, long)] 20 | name: String, 21 | /// Number of CPUs for the virtual machine instance 22 | #[clap(short, long)] 23 | cpus: Option, 24 | /// Memory size of the virtual machine instance (e.g. 1G for 1 gigabyte) 25 | #[clap(short, long)] 26 | mem: Option, 27 | /// Disk size of the virtual machine instance (e.g. 10G for 10 gigabytes) 28 | #[clap(short, long)] 29 | disk: Option, 30 | /// Enable verbose logging 31 | #[clap(short, long, default_value_t = false)] 32 | verbose: bool, 33 | /// Reduce logging output 34 | #[clap(short, long, default_value_t = false)] 35 | quiet: bool, 36 | }, 37 | 38 | /// List virtual machine instances 39 | #[clap(alias = "list")] 40 | Ls, 41 | 42 | /// Add a virtual machine instance 43 | Add { 44 | /// Name of the virtual machine instance 45 | #[clap(conflicts_with = "name")] 46 | instance_name: Option, 47 | /// Name of the virtual machine image 48 | #[clap(short, long)] 49 | image: String, 50 | /// Name of the virtual machine instance 51 | #[clap(short, long, conflicts_with = "instance_name", hide = true)] 52 | name: Option, 53 | /// Number of CPUs for the virtual machine instance 54 | #[clap(short, long)] 55 | cpus: Option, 56 | /// Memory size of the virtual machine instance (e.g. 1G for 1 gigabyte) 57 | #[clap(short, long)] 58 | mem: Option, 59 | /// Disk size of the virtual machine instance (e.g. 10G for 10 gigabytes) 60 | #[clap(short, long)] 61 | disk: Option, 62 | }, 63 | 64 | /// Delete virtual machine instances 65 | #[clap(alias = "del")] 66 | Rm { 67 | /// Enable verbose logging 68 | #[clap(short, long, default_value_t = false)] 69 | verbose: bool, 70 | /// Reduce logging output 71 | #[clap(short, long, default_value_t = false)] 72 | quiet: bool, 73 | /// Delete the virtual machine instances without confirmation 74 | #[clap(short, long, default_value_t = false)] 75 | force: bool, 76 | /// Name of the virtual machine instances to delete 77 | instances: Vec, 78 | }, 79 | 80 | /// Clone a virtual machine instance 81 | Clone { 82 | /// Name of the virtual machine instance to clone 83 | name: String, 84 | /// Name of the copy 85 | new_name: String, 86 | }, 87 | 88 | /// Rename a virtual machine instance 89 | Rename { 90 | /// Name of the virtual machine instance to rename 91 | old_name: String, 92 | /// New name of the virutal machine instance 93 | new_name: String, 94 | }, 95 | 96 | /// Get information about an virtual machine instance 97 | Info { 98 | /// Name of the virtual machine instance 99 | instance: String, 100 | }, 101 | 102 | /// Read and write virtual machine instance configuration parameters 103 | Config { 104 | /// Name of the virtual machine instance 105 | instance: String, 106 | /// Number of CPUs for the virtual machine instance 107 | #[clap(short, long)] 108 | cpus: Option, 109 | /// Memory size of the virtual machine instance (e.g. 1G for 1 gigabyte) 110 | #[clap(short, long)] 111 | mem: Option, 112 | /// Disk size of the virtual machine instance (e.g. 10G for 10 gigabytes) 113 | #[clap(short, long)] 114 | disk: Option, 115 | }, 116 | 117 | /// Open the console of an virtual machine instance 118 | Console { 119 | /// Name of the virtual machine instance 120 | instance: String, 121 | }, 122 | 123 | /// Open a shell in a virtual machine instance 124 | Sh { 125 | /// Enable verbose logging 126 | #[clap(short, long, default_value_t = false)] 127 | verbose: bool, 128 | /// Reduce logging output 129 | #[clap(short, long, default_value_t = false)] 130 | quiet: bool, 131 | /// Name of the virtual machine instance 132 | instance: String, 133 | }, 134 | 135 | /// Connect to a virtual machine instance with SSH 136 | Ssh { 137 | /// Name of the virtual machine instance 138 | instance: String, 139 | /// Forward X over SSH 140 | #[clap(short = 'X', default_value_t = false)] 141 | xforward: bool, 142 | /// Enable verbose logging 143 | #[clap(short, long, default_value_t = false)] 144 | verbose: bool, 145 | /// Reduce logging output 146 | #[clap(short, long, default_value_t = false)] 147 | quiet: bool, 148 | /// Pass additional SSH arguments 149 | #[clap(long)] 150 | ssh_args: Option, 151 | /// Execute a command in the virtual machine 152 | cmd: Option, 153 | }, 154 | 155 | /// Copy a file from or to a virtual machine instance with SCP 156 | Scp { 157 | /// Source of the data to copy 158 | from: String, 159 | /// Target of the data to copy 160 | to: String, 161 | /// Enable verbose logging 162 | #[clap(short, long, default_value_t = false)] 163 | verbose: bool, 164 | /// Reduce logging output 165 | #[clap(short, long, default_value_t = false)] 166 | quiet: bool, 167 | /// Pass additional SCP arguments 168 | #[clap(long)] 169 | scp_args: Option, 170 | }, 171 | 172 | /// Start virtual machine instances 173 | Start { 174 | /// Pass additional QEMU arguments 175 | #[clap(long)] 176 | qemu_args: Option, 177 | /// Enable verbose logging 178 | #[clap(short, long, default_value_t = false)] 179 | verbose: bool, 180 | /// Reduce logging output 181 | #[clap(short, long, default_value_t = false)] 182 | quiet: bool, 183 | /// Name of the virtual machine instances to start 184 | instances: Vec, 185 | }, 186 | 187 | /// Stop virtual machine instances 188 | Stop { 189 | /// Stop all virtual machine instances 190 | #[clap(short, long, default_value_t = false)] 191 | all: bool, 192 | /// Enable verbose logging 193 | #[clap(short, long, default_value_t = false)] 194 | verbose: bool, 195 | /// Reduce logging output 196 | #[clap(short, long, default_value_t = false)] 197 | quiet: bool, 198 | /// Name of the virtual machine instances to stop 199 | instances: Vec, 200 | }, 201 | 202 | /// Restart virtual machine instances 203 | Restart { 204 | /// Enable verbose logging 205 | #[clap(short, long, default_value_t = false)] 206 | verbose: bool, 207 | /// Reduce logging output 208 | #[clap(short, long, default_value_t = false)] 209 | quiet: bool, 210 | /// Name of the virtual machine instances to restart 211 | instances: Vec, 212 | }, 213 | 214 | /// Instance subcommands (Deprecated) 215 | #[command(subcommand, hide = true)] 216 | Instance(commands::InstanceCommands), 217 | 218 | /// Image subcommands 219 | #[command(subcommand)] 220 | Image(commands::ImageCommands), 221 | 222 | /// Mount subcommands 223 | #[command(subcommand)] 224 | Mount(commands::MountCommands), 225 | 226 | /// Network subcommands 227 | #[command(subcommand)] 228 | Net(commands::NetworkCommands), 229 | } 230 | 231 | #[derive(Default, Parser)] 232 | #[command(author, version, about, long_about = None, arg_required_else_help = true)] 233 | pub struct CommandDispatcher { 234 | #[command(subcommand)] 235 | pub command: Option, 236 | } 237 | 238 | impl CommandDispatcher { 239 | pub fn new() -> Self { 240 | CommandDispatcher::default() 241 | } 242 | 243 | pub fn dispatch(self) -> Result<(), Error> { 244 | let command = Self::parse().command.ok_or(Error::UnknownCommand)?; 245 | 246 | let console: &mut dyn Console = &mut Stdio::new(); 247 | let image_dao = ImageDao::new()?; 248 | let instance_dao = InstanceDao::new()?; 249 | 250 | match &command { 251 | Commands::Run { 252 | image, 253 | name, 254 | cpus, 255 | mem, 256 | disk, 257 | verbose, 258 | quiet, 259 | } => commands::run( 260 | &image_dao, 261 | &instance_dao, 262 | image, 263 | name, 264 | cpus, 265 | mem, 266 | disk, 267 | Verbosity::new(*verbose, *quiet), 268 | ), 269 | Commands::Ls => InstanceListCommand::new().run(console, &instance_dao), 270 | Commands::Add { 271 | instance_name, 272 | image, 273 | name, 274 | cpus, 275 | mem, 276 | disk, 277 | } => InstanceAddCommand::new( 278 | image.to_string(), 279 | instance_name 280 | .as_ref() 281 | .or(name.as_ref()) 282 | .ok_or(Error::InvalidArgument("Missing instance name".to_string()))? 283 | .to_string(), 284 | cpus.as_ref().cloned(), 285 | mem.as_ref().cloned(), 286 | disk.as_ref().cloned(), 287 | ) 288 | .run(&image_dao, &instance_dao), 289 | Commands::Rm { 290 | verbose, 291 | quiet, 292 | force, 293 | instances, 294 | } => InstanceRemoveCommand::new(Verbosity::new(*verbose, *quiet), *force, instances) 295 | .run(&instance_dao), 296 | 297 | Commands::Clone { name, new_name } => { 298 | InstanceCloneCommand::new(name, new_name).run(&instance_dao) 299 | } 300 | Commands::Rename { old_name, new_name } => { 301 | InstanceRenameCommand::new(old_name, new_name).run(&instance_dao) 302 | } 303 | Commands::Info { instance } => { 304 | InstanceInfoCommand::new().run(console, &instance_dao, instance) 305 | } 306 | Commands::Config { 307 | instance, 308 | cpus, 309 | mem, 310 | disk, 311 | } => InstanceConfigCommand::new(instance, cpus, mem, disk).run(&instance_dao), 312 | Commands::Start { 313 | qemu_args, 314 | verbose, 315 | quiet, 316 | instances, 317 | } => commands::start( 318 | &instance_dao, 319 | qemu_args, 320 | Verbosity::new(*verbose, *quiet), 321 | instances, 322 | ), 323 | Commands::Stop { 324 | instances, 325 | verbose, 326 | quiet, 327 | all, 328 | } => commands::stop( 329 | &instance_dao, 330 | *all, 331 | Verbosity::new(*verbose, *quiet), 332 | instances, 333 | ), 334 | Commands::Restart { 335 | verbose, 336 | quiet, 337 | instances, 338 | } => commands::restart(&instance_dao, Verbosity::new(*verbose, *quiet), instances), 339 | Commands::Console { instance } => commands::console(&instance_dao, instance), 340 | Commands::Sh { 341 | verbose, 342 | quiet, 343 | instance, 344 | } => commands::sh(&instance_dao, Verbosity::new(*verbose, *quiet), instance), 345 | Commands::Ssh { 346 | instance, 347 | xforward, 348 | verbose, 349 | quiet, 350 | ssh_args, 351 | cmd, 352 | } => commands::ssh( 353 | &instance_dao, 354 | instance, 355 | *xforward, 356 | Verbosity::new(*verbose, *quiet), 357 | ssh_args, 358 | cmd, 359 | ), 360 | Commands::Scp { 361 | from, 362 | to, 363 | verbose, 364 | quiet, 365 | scp_args, 366 | } => commands::scp( 367 | &instance_dao, 368 | from, 369 | to, 370 | Verbosity::new(*verbose, *quiet), 371 | scp_args, 372 | ), 373 | Commands::Instance(command) => command.dispatch(&image_dao, &instance_dao), 374 | Commands::Image(command) => command.dispatch(&image_dao), 375 | Commands::Mount(command) => command.dispatch(&instance_dao), 376 | Commands::Net(command) => command.dispatch(&instance_dao), 377 | } 378 | } 379 | } 380 | -------------------------------------------------------------------------------- /src/commands/console.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::{self, Verbosity}; 2 | use crate::error::Error; 3 | use crate::instance::{InstanceDao, InstanceStore}; 4 | use crate::util::Terminal; 5 | 6 | use std::path::Path; 7 | use std::str; 8 | use std::thread; 9 | use std::time::Duration; 10 | 11 | pub fn console(instance_dao: &InstanceDao, name: &str) -> Result<(), Error> { 12 | let instance = instance_dao.load(name)?; 13 | 14 | if !instance_dao.is_running(&instance) { 15 | commands::start( 16 | instance_dao, 17 | &None, 18 | Verbosity::Quiet, 19 | &vec![name.to_string()], 20 | )?; 21 | } 22 | 23 | let console_path = format!("{}/{}/console", instance_dao.cache_dir, name); 24 | while !Path::new(&console_path).exists() { 25 | thread::sleep(Duration::new(1, 0)); 26 | } 27 | 28 | if let Ok(mut term) = Terminal::open(&console_path) { 29 | term.wait(); 30 | } else { 31 | println!("Cannot open shell"); 32 | } 33 | 34 | Ok(()) 35 | } 36 | -------------------------------------------------------------------------------- /src/commands/image.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::fs::FS; 3 | use crate::image::{Image, ImageDao, ImageFactory, ImageFetcher}; 4 | use crate::util; 5 | use crate::view::SpinnerView; 6 | use crate::view::{Alignment, TableView}; 7 | use crate::view::{MapView, Stdio}; 8 | use clap::Subcommand; 9 | 10 | #[derive(Subcommand)] 11 | pub enum ImageCommands { 12 | /// List images 13 | #[clap(alias = "list")] 14 | Ls { 15 | /// List all images 16 | #[clap(short, long, hide = true)] 17 | all: bool, 18 | }, 19 | 20 | /// Fetch an image 21 | Fetch { 22 | /// Name of the virtual machine image 23 | image: String, 24 | }, 25 | 26 | /// Show image information 27 | Info { 28 | /// Name of the virtual machine image 29 | name: String, 30 | }, 31 | 32 | /// Clear local image cache 33 | Prune, 34 | 35 | /// Delete images (Deprecated) 36 | #[clap(alias = "del", hide = true)] 37 | Rm { 38 | /// List of images to delete 39 | images: Vec, 40 | #[clap(short, long, default_value_t = false)] 41 | /// Delete all images 42 | all: bool, 43 | /// Force delete images without asking for confirmation 44 | #[clap(short, long, default_value_t = false)] 45 | force: bool, 46 | /// Silence command output 47 | #[clap(short, long, default_value_t = false)] 48 | quiet: bool, 49 | }, 50 | } 51 | 52 | impl ImageCommands { 53 | pub fn dispatch(&self, image_dao: &ImageDao) -> Result<(), Error> { 54 | let console = &mut Stdio::new(); 55 | 56 | match self { 57 | ImageCommands::Ls { .. } => { 58 | let images: Vec = SpinnerView::new("Fetching image list") 59 | .run(ImageFactory::create_images) 60 | .and_then(|v| v.ok()) 61 | .unwrap_or_default(); 62 | 63 | let mut view = TableView::new(); 64 | view.add_row() 65 | .add("Name", Alignment::Left) 66 | .add("Arch", Alignment::Left) 67 | .add("Size", Alignment::Right); 68 | 69 | for image in images { 70 | let size = image 71 | .size 72 | .map(util::bytes_to_human_readable) 73 | .unwrap_or_default(); 74 | 75 | view.add_row() 76 | .add( 77 | &format!("{}:{}", image.vendor, image.version), 78 | Alignment::Left, 79 | ) 80 | .add(&image.arch.to_string(), Alignment::Left) 81 | .add(&size, Alignment::Right); 82 | 83 | if image.version != image.codename { 84 | view.add_row() 85 | .add( 86 | &format!("{}:{}", image.vendor, image.codename), 87 | Alignment::Left, 88 | ) 89 | .add(&image.arch.to_string(), Alignment::Left) 90 | .add(&size, Alignment::Right); 91 | } 92 | } 93 | view.print(console); 94 | Ok(()) 95 | } 96 | 97 | ImageCommands::Info { name } => { 98 | let image = image_dao.get(name)?; 99 | let mut view = MapView::new(); 100 | view.add("Vendor", &image.vendor); 101 | view.add("Codename", &image.codename); 102 | view.add("Version", &image.version); 103 | view.add("URL", &image.url); 104 | view.print(console); 105 | Ok(()) 106 | } 107 | 108 | ImageCommands::Fetch { image } => { 109 | let image = &image_dao.get(image)?; 110 | 111 | if !image_dao.exists(image) { 112 | FS::new().create_dir(&image_dao.image_dir)?; 113 | ImageFetcher::new().fetch( 114 | image, 115 | &format!("{}/{}", image_dao.image_dir, image.to_file_name()), 116 | )?; 117 | } 118 | 119 | Ok(()) 120 | } 121 | 122 | ImageCommands::Rm { 123 | images, 124 | all, 125 | force, 126 | quiet, 127 | } => { 128 | let selected_images = if *all { 129 | ImageFactory::create_images()?.clone() 130 | } else { 131 | images 132 | .iter() 133 | .map(|name| image_dao.get(name)) 134 | .collect::, Error>>()? 135 | }; 136 | 137 | for image in &selected_images { 138 | let name = image.to_id(); 139 | 140 | if !image_dao.exists(image) { 141 | if !*all && !*quiet { 142 | println!("Image '{name}' does not exists"); 143 | } 144 | continue; 145 | } 146 | 147 | if *force 148 | || util::confirm(&format!( 149 | "Do you really want delete the image '{name}'? [y/n]: " 150 | )) 151 | { 152 | image_dao.delete(image)?; 153 | if !*quiet { 154 | println!("Deleted image {name}"); 155 | } 156 | } 157 | } 158 | 159 | Ok(()) 160 | } 161 | 162 | ImageCommands::Prune => image_dao.prune(), 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/commands/instance.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::{ 2 | InstanceAddCommand, InstanceCloneCommand, InstanceConfigCommand, InstanceListCommand, 3 | InstanceRemoveCommand, InstanceRenameCommand, Verbosity, 4 | }; 5 | use crate::error::Error; 6 | use crate::image::ImageDao; 7 | use crate::instance::InstanceDao; 8 | use crate::view::Stdio; 9 | use clap::Subcommand; 10 | 11 | #[derive(Subcommand)] 12 | pub enum InstanceCommands { 13 | /// List instances (Deprecated) 14 | #[clap(alias = "list")] 15 | Ls, 16 | 17 | /// Add a virtual machine instance (Deprecated) 18 | Add { 19 | /// Name of the virtual machine image 20 | #[clap(short, long)] 21 | image: String, 22 | /// Name of the virtual machine instance 23 | #[clap(short, long)] 24 | name: String, 25 | /// Number of CPUs for the virtual machine instance 26 | #[clap(short, long)] 27 | cpus: Option, 28 | /// Memory size of the virtual machine instance (e.g. 1G for 1 gigabyte) 29 | #[clap(short, long)] 30 | mem: Option, 31 | /// Disk size of the virtual machine instance (e.g. 10G for 10 gigabytes) 32 | #[clap(short, long)] 33 | disk: Option, 34 | }, 35 | 36 | /// Delete instances (Deprecated) 37 | #[clap(alias = "del")] 38 | Rm { 39 | /// Enable verbose logging 40 | #[clap(short, long, default_value_t = false)] 41 | verbose: bool, 42 | /// Reduce logging output 43 | #[clap(short, long, default_value_t = false)] 44 | quiet: bool, 45 | /// Delete the virtual machine instances without confirmation 46 | #[clap(short, long, default_value_t = false)] 47 | force: bool, 48 | /// Name of the virtual machine instances to delete 49 | instances: Vec, 50 | }, 51 | 52 | /// Read and write configuration parameters (Deprecated) 53 | Config { 54 | /// Name of the virtual machine instance 55 | instance: String, 56 | /// Number of CPUs for the virtual machine instance 57 | #[clap(short, long)] 58 | cpus: Option, 59 | /// Memory size of the virtual machine instance (e.g. 1G for 1 gigabyte) 60 | #[clap(short, long)] 61 | mem: Option, 62 | /// Disk size of the virtual machine instance (e.g. 10G for 10 gigabytes) 63 | #[clap(short, long)] 64 | disk: Option, 65 | }, 66 | 67 | /// Clone a virtual machine instance (Deprecated) 68 | Clone { 69 | /// Name of the virtual machine instance to clone 70 | name: String, 71 | /// Name of the copy 72 | new_name: String, 73 | }, 74 | 75 | /// Rename an instance (Deprecated) 76 | Rename { 77 | /// Name of the virtual machine instance to rename 78 | old_name: String, 79 | /// New name of the virutal machine instance 80 | new_name: String, 81 | }, 82 | } 83 | 84 | impl InstanceCommands { 85 | pub fn dispatch(&self, image_dao: &ImageDao, instance_dao: &InstanceDao) -> Result<(), Error> { 86 | let console = &mut Stdio::new(); 87 | match self { 88 | InstanceCommands::Ls => InstanceListCommand::new().run(console, instance_dao), 89 | InstanceCommands::Add { 90 | image, 91 | name, 92 | cpus, 93 | mem, 94 | disk, 95 | } => InstanceAddCommand::new( 96 | image.to_string(), 97 | name.to_string(), 98 | cpus.as_ref().cloned(), 99 | mem.as_ref().cloned(), 100 | disk.as_ref().cloned(), 101 | ) 102 | .run(image_dao, instance_dao), 103 | InstanceCommands::Rm { 104 | verbose, 105 | quiet, 106 | force, 107 | instances, 108 | } => InstanceRemoveCommand::new(Verbosity::new(*verbose, *quiet), *force, instances) 109 | .run(instance_dao), 110 | InstanceCommands::Config { 111 | instance, 112 | cpus, 113 | mem, 114 | disk, 115 | } => InstanceConfigCommand::new(instance, cpus, mem, disk).run(instance_dao), 116 | 117 | InstanceCommands::Clone { name, new_name } => { 118 | InstanceCloneCommand::new(name, new_name).run(instance_dao) 119 | } 120 | 121 | InstanceCommands::Rename { old_name, new_name } => { 122 | InstanceRenameCommand::new(old_name, new_name).run(instance_dao) 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/commands/instance_add_command.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::image::ImageCommands; 2 | use crate::error::Error; 3 | use crate::image::ImageDao; 4 | use crate::instance::{Instance, InstanceDao, InstanceStore, USER}; 5 | use crate::util; 6 | 7 | pub struct InstanceAddCommand { 8 | image: String, 9 | name: String, 10 | cpus: Option, 11 | mem: Option, 12 | disk: Option, 13 | } 14 | 15 | impl InstanceAddCommand { 16 | pub fn new( 17 | image: String, 18 | name: String, 19 | cpus: Option, 20 | mem: Option, 21 | disk: Option, 22 | ) -> Self { 23 | InstanceAddCommand { 24 | image, 25 | name, 26 | cpus, 27 | mem, 28 | disk, 29 | } 30 | } 31 | 32 | pub fn run(self, image_dao: &ImageDao, instance_dao: &InstanceDao) -> Result<(), Error> { 33 | let image = image_dao.get(&self.image)?; 34 | ImageCommands::Fetch { 35 | image: self.image.to_string(), 36 | } 37 | .dispatch(image_dao)?; 38 | 39 | let instance_dir = format!("{}/{}", instance_dao.instance_dir, &self.name); 40 | 41 | if instance_dao.exists(&self.name) { 42 | return Result::Err(Error::InstanceAlreadyExists(self.name.to_string())); 43 | } 44 | 45 | let image_size = image_dao.get_disk_capacity(&image)?; 46 | let disk_capacity = self 47 | .disk 48 | .as_ref() 49 | .map(|size| util::human_readable_to_bytes(size)) 50 | .unwrap_or(Result::Ok(image_size))?; 51 | 52 | image_dao.copy_image(&image, &instance_dir, "machine.img")?; 53 | 54 | let ssh_port = util::generate_random_ssh_port(); 55 | 56 | let mut instance = Instance { 57 | name: self.name.clone(), 58 | arch: image.arch, 59 | user: USER.to_string(), 60 | cpus: self.cpus.unwrap_or(1), 61 | mem: util::human_readable_to_bytes(self.mem.as_deref().unwrap_or("1G"))?, 62 | disk_capacity, 63 | ssh_port, 64 | ..Instance::default() 65 | }; 66 | instance_dao.store(&instance)?; 67 | if self.disk.is_some() { 68 | instance_dao.resize(&mut instance, disk_capacity)?; 69 | } 70 | 71 | Result::Ok(()) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/commands/instance_clone_command.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::instance::{InstanceDao, InstanceStore}; 3 | use crate::util; 4 | 5 | pub struct InstanceCloneCommand { 6 | name: String, 7 | new_name: String, 8 | } 9 | 10 | impl InstanceCloneCommand { 11 | pub fn new(name: &str, new_name: &str) -> Self { 12 | Self { 13 | name: name.to_string(), 14 | new_name: new_name.to_string(), 15 | } 16 | } 17 | 18 | pub fn run(&self, instance_dao: &InstanceDao) -> Result<(), Error> { 19 | instance_dao.clone(&instance_dao.load(&self.name)?, &self.new_name)?; 20 | 21 | let mut new_instance = instance_dao.load(&self.new_name)?; 22 | new_instance.ssh_port = util::generate_random_ssh_port(); 23 | instance_dao.store(&new_instance) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/commands/instance_config_command.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::instance::{InstanceDao, InstanceStore}; 3 | use crate::util; 4 | 5 | pub struct InstanceConfigCommand { 6 | instance: String, 7 | cpus: Option, 8 | mem: Option, 9 | disk: Option, 10 | } 11 | 12 | impl InstanceConfigCommand { 13 | pub fn new( 14 | instance: &str, 15 | cpus: &Option, 16 | mem: &Option, 17 | disk: &Option, 18 | ) -> Self { 19 | Self { 20 | instance: instance.to_string(), 21 | cpus: cpus.as_ref().cloned(), 22 | mem: mem.as_ref().cloned(), 23 | disk: disk.as_ref().cloned(), 24 | } 25 | } 26 | 27 | pub fn run(&self, instance_dao: &InstanceDao) -> Result<(), Error> { 28 | let mut instance = instance_dao.load(&self.instance)?; 29 | 30 | if let Some(cpus) = &self.cpus { 31 | instance.cpus = *cpus; 32 | } 33 | 34 | if let Some(mem) = &self.mem { 35 | instance.mem = util::human_readable_to_bytes(mem)?; 36 | } 37 | 38 | if let Some(disk) = &self.disk { 39 | instance_dao.resize(&mut instance, util::human_readable_to_bytes(disk)?)?; 40 | } 41 | 42 | instance_dao.store(&instance)?; 43 | Result::Ok(()) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/commands/instance_info_command.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::instance::InstanceStore; 3 | use crate::util; 4 | use crate::view::{Console, MapView}; 5 | 6 | pub struct InstanceInfoCommand; 7 | 8 | impl InstanceInfoCommand { 9 | pub fn new() -> Self { 10 | Self {} 11 | } 12 | 13 | pub fn run( 14 | &self, 15 | console: &mut dyn Console, 16 | instance_store: &dyn InstanceStore, 17 | instance: &str, 18 | ) -> Result<(), Error> { 19 | if !instance_store.exists(instance) { 20 | return Result::Err(Error::UnknownInstance(instance.to_string())); 21 | } 22 | 23 | let instance = instance_store.load(instance)?; 24 | 25 | let mut view = MapView::new(); 26 | view.add("Arch", &instance.arch.to_string()); 27 | view.add("CPUs", &instance.cpus.to_string()); 28 | view.add("Memory", &util::bytes_to_human_readable(instance.mem)); 29 | view.add( 30 | "Disk", 31 | &util::bytes_to_human_readable(instance.disk_capacity), 32 | ); 33 | view.add("User", &instance.user); 34 | view.add("Display", &instance.display.to_string()); 35 | view.add("GPU", &instance.gpu.to_string()); 36 | view.add("SSH Port", &instance.ssh_port.to_string()); 37 | 38 | for (index, mount) in instance.mounts.iter().enumerate() { 39 | let key = if index == 0 { "Mounts" } else { "" }; 40 | view.add(key, &format!("{} => {}", mount.host, mount.guest)); 41 | } 42 | 43 | for (index, rule) in instance.hostfwd.iter().enumerate() { 44 | let key = if index == 0 { "Forward" } else { "" }; 45 | view.add(key, rule); 46 | } 47 | 48 | view.print(console); 49 | 50 | Ok(()) 51 | } 52 | } 53 | 54 | #[cfg(test)] 55 | mod tests { 56 | use super::*; 57 | use crate::arch::Arch; 58 | use crate::instance::instance_store_mock::tests::InstanceStoreMock; 59 | use crate::instance::{Instance, MountPoint}; 60 | use crate::view::console_mock::tests::ConsoleMock; 61 | 62 | #[test] 63 | fn test_info_command() { 64 | let console = &mut ConsoleMock::new(); 65 | let instance_store = &InstanceStoreMock::new(vec![Instance { 66 | name: "test".to_string(), 67 | arch: Arch::AMD64, 68 | user: "cubic".to_string(), 69 | cpus: 1, 70 | mem: 1024, 71 | disk_capacity: 1048576, 72 | ssh_port: 9000, 73 | display: false, 74 | gpu: false, 75 | mounts: vec![ 76 | MountPoint { 77 | host: "/host/path".to_string(), 78 | guest: "/guest/path".to_string(), 79 | }, 80 | MountPoint { 81 | host: "/host/path2".to_string(), 82 | guest: "/guest/path2".to_string(), 83 | }, 84 | ], 85 | hostfwd: Vec::new(), 86 | }]); 87 | 88 | InstanceInfoCommand::new() 89 | .run(console, instance_store, "test") 90 | .unwrap(); 91 | 92 | assert_eq!( 93 | console.get_output(), 94 | "\ 95 | Arch: amd64 96 | CPUs: 1 97 | Memory: 1.0 KiB 98 | Disk: 1.0 MiB 99 | User: cubic 100 | Display: false 101 | GPU: false 102 | SSH Port: 9000 103 | Mounts: /host/path => /guest/path 104 | /host/path2 => /guest/path2 105 | " 106 | ); 107 | } 108 | 109 | #[test] 110 | fn test_info_command_failed() { 111 | let console = &mut ConsoleMock::new(); 112 | let instance_store = &InstanceStoreMock::new(Vec::new()); 113 | 114 | assert!(matches!( 115 | InstanceInfoCommand::new().run(console, instance_store, "test"), 116 | Result::Err(Error::UnknownInstance(_)) 117 | )); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/commands/instance_list_command.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::instance::{InstanceState, InstanceStore}; 3 | use crate::util; 4 | use crate::view::{Alignment, Console, TableView}; 5 | 6 | pub struct InstanceListCommand; 7 | 8 | impl InstanceListCommand { 9 | pub fn new() -> Self { 10 | Self {} 11 | } 12 | 13 | pub fn run( 14 | &self, 15 | console: &mut dyn Console, 16 | instance_store: &dyn InstanceStore, 17 | ) -> Result<(), Error> { 18 | let instance_names = instance_store.get_instances(); 19 | 20 | let mut view = TableView::new(); 21 | view.add_row() 22 | .add("PID", Alignment::Left) 23 | .add("Name", Alignment::Left) 24 | .add("Arch", Alignment::Left) 25 | .add("CPUs", Alignment::Right) 26 | .add("Memory", Alignment::Right) 27 | .add("Disk", Alignment::Right) 28 | .add("State", Alignment::Left); 29 | 30 | for instance_name in &instance_names { 31 | let instance = instance_store.load(instance_name)?; 32 | let pid = instance_store 33 | .get_pid(&instance) 34 | .map(|pid| pid.to_string()) 35 | .unwrap_or_default(); 36 | 37 | view.add_row() 38 | .add(&pid, Alignment::Left) 39 | .add(instance_name, Alignment::Left) 40 | .add(&instance.arch.to_string(), Alignment::Left) 41 | .add(&instance.cpus.to_string(), Alignment::Right) 42 | .add( 43 | &util::bytes_to_human_readable(instance.mem), 44 | Alignment::Right, 45 | ) 46 | .add( 47 | &util::bytes_to_human_readable(instance.disk_capacity), 48 | Alignment::Right, 49 | ) 50 | .add( 51 | match instance_store.get_state(&instance) { 52 | InstanceState::Stopped => "STOPPED", 53 | InstanceState::Starting => "STARTING", 54 | InstanceState::Running => "RUNNING", 55 | }, 56 | Alignment::Left, 57 | ); 58 | } 59 | view.print(console); 60 | Result::Ok(()) 61 | } 62 | } 63 | 64 | #[cfg(test)] 65 | mod tests { 66 | use super::*; 67 | use crate::arch::Arch; 68 | use crate::instance::instance_store_mock::tests::InstanceStoreMock; 69 | use crate::instance::Instance; 70 | use crate::view::console_mock::tests::ConsoleMock; 71 | 72 | #[test] 73 | fn test_instance_list_command() { 74 | let console = &mut ConsoleMock::new(); 75 | let instance_store = &InstanceStoreMock::new(vec![ 76 | Instance { 77 | name: "test".to_string(), 78 | arch: Arch::AMD64, 79 | user: "cubic".to_string(), 80 | cpus: 1, 81 | mem: 1024, 82 | disk_capacity: 1048576, 83 | ssh_port: 9000, 84 | display: false, 85 | gpu: false, 86 | mounts: Vec::new(), 87 | hostfwd: Vec::new(), 88 | }, 89 | Instance { 90 | name: "test2".to_string(), 91 | arch: Arch::AMD64, 92 | user: "cubic".to_string(), 93 | cpus: 5, 94 | mem: 0, 95 | disk_capacity: 5000, 96 | ssh_port: 9000, 97 | display: false, 98 | gpu: false, 99 | mounts: Vec::new(), 100 | hostfwd: Vec::new(), 101 | }, 102 | ]); 103 | 104 | InstanceListCommand::new() 105 | .run(console, instance_store) 106 | .unwrap(); 107 | 108 | assert_eq!( 109 | console.get_output(), 110 | "\ 111 | PID Name Arch CPUs Memory Disk State 112 | test amd64 1 1.0 KiB 1.0 MiB STOPPED 113 | test2 amd64 5 0.0 B 4.9 KiB STOPPED 114 | " 115 | ); 116 | } 117 | 118 | #[test] 119 | fn test_instance_list_command_empty() { 120 | let console = &mut ConsoleMock::new(); 121 | let instance_store = &InstanceStoreMock::new(Vec::new()); 122 | 123 | InstanceListCommand::new() 124 | .run(console, instance_store) 125 | .unwrap(); 126 | 127 | assert_eq!( 128 | console.get_output(), 129 | "PID Name Arch CPUs Memory Disk State\n" 130 | ); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/commands/instance_remove_command.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::{self, Verbosity}; 2 | use crate::error::Error; 3 | use crate::instance::{InstanceDao, InstanceStore}; 4 | use crate::util; 5 | 6 | pub struct InstanceRemoveCommand { 7 | verbosity: Verbosity, 8 | force: bool, 9 | instances: Vec, 10 | } 11 | 12 | impl InstanceRemoveCommand { 13 | pub fn new(verbosity: Verbosity, force: bool, instances: &[String]) -> Self { 14 | Self { 15 | verbosity, 16 | force, 17 | instances: instances.to_vec(), 18 | } 19 | } 20 | 21 | pub fn run(&self, instance_dao: &InstanceDao) -> Result<(), Error> { 22 | if self.force { 23 | commands::stop(instance_dao, false, self.verbosity, &self.instances)?; 24 | } 25 | 26 | for instance in &self.instances { 27 | if !instance_dao.exists(instance) { 28 | return Result::Err(Error::UnknownInstance(instance.clone())); 29 | } 30 | 31 | if instance_dao.is_running(&instance_dao.load(instance)?) { 32 | return Result::Err(Error::InstanceNotStopped(instance.to_string())); 33 | } 34 | } 35 | 36 | for instance in &self.instances { 37 | if util::confirm(&format!( 38 | "Do you really want delete the instance '{instance}'? [y/n]: " 39 | )) { 40 | instance_dao.delete(&instance_dao.load(instance)?)?; 41 | println!("Deleted instance {instance}"); 42 | } 43 | } 44 | 45 | Result::Ok(()) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/commands/instance_rename_command.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::instance::{InstanceDao, InstanceStore}; 3 | 4 | pub struct InstanceRenameCommand { 5 | old_name: String, 6 | new_name: String, 7 | } 8 | 9 | impl InstanceRenameCommand { 10 | pub fn new(old_name: &str, new_name: &str) -> Self { 11 | Self { 12 | old_name: old_name.to_string(), 13 | new_name: new_name.to_string(), 14 | } 15 | } 16 | 17 | pub fn run(&self, instance_dao: &InstanceDao) -> Result<(), Error> { 18 | instance_dao.rename(&mut instance_dao.load(&self.old_name)?, &self.new_name) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/commands/mount.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::instance::{InstanceDao, InstanceStore, MountPoint}; 3 | use crate::util; 4 | use crate::view::{Alignment, Stdio, TableView}; 5 | use clap::Subcommand; 6 | use std::path::Path; 7 | 8 | #[derive(Subcommand)] 9 | pub enum MountCommands { 10 | /// List mount mounts 11 | #[clap(alias = "ls")] 12 | List { 13 | /// Name of the virtual machine instance 14 | name: String, 15 | }, 16 | 17 | /// Add a directory mount 18 | Add { 19 | /// Name of the virtual machine instance 20 | name: String, 21 | /// Path on the host filesystem 22 | host: String, 23 | /// Path on guest filesystem 24 | guest: String, 25 | }, 26 | 27 | /// Delete a directory mount 28 | #[clap(alias = "rm")] 29 | Del { 30 | /// Name of the virtual machine instance 31 | name: String, 32 | /// Path on guest filesystem 33 | guest: String, 34 | }, 35 | } 36 | 37 | impl MountCommands { 38 | pub fn dispatch(&self, instance_dao: &InstanceDao) -> Result<(), Error> { 39 | let console = &mut Stdio::new(); 40 | match self { 41 | MountCommands::List { name } => { 42 | let instance = instance_dao.load(name)?; 43 | let mut view = TableView::new(); 44 | view.add_row() 45 | .add("Host", Alignment::Left) 46 | .add("Guest", Alignment::Left); 47 | 48 | for mount in instance.mounts { 49 | view.add_row() 50 | .add(&mount.host, Alignment::Left) 51 | .add(&mount.guest, Alignment::Left); 52 | } 53 | view.print(console); 54 | Ok(()) 55 | } 56 | 57 | MountCommands::Add { name, host, guest } => { 58 | if !Path::new(host).exists() { 59 | return Err(Error::CannotAccessDir(host.clone())); 60 | } 61 | 62 | let mut instance = instance_dao.load(name)?; 63 | instance.mounts.push(MountPoint { 64 | host: host.to_string(), 65 | guest: guest.to_string(), 66 | }); 67 | instance_dao.store(&instance)?; 68 | util::setup_cloud_init(&instance, &instance_dao.cache_dir, true) 69 | } 70 | 71 | MountCommands::Del { 72 | ref name, 73 | ref guest, 74 | } => { 75 | let mut instance = instance_dao.load(name)?; 76 | instance.mounts.retain(|mount| mount.guest != *guest); 77 | instance_dao.store(&instance)?; 78 | util::setup_cloud_init(&instance, &instance_dao.cache_dir, true) 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/commands/net.rs: -------------------------------------------------------------------------------- 1 | mod hostfwd; 2 | 3 | use crate::error::Error; 4 | use crate::instance::InstanceDao; 5 | 6 | use clap::Subcommand; 7 | use hostfwd::HostfwdCommands; 8 | 9 | #[derive(Subcommand)] 10 | pub enum NetworkCommands { 11 | /// Guest to host port forwarding commands 12 | /// 13 | /// List forwarded ports for all instances: 14 | /// $ cubic net hostfwd list 15 | /// 16 | /// Forward guest SSH port (TCP port 22) to host on port 8000: 17 | /// $ cubic net hostfwd add myinstance tcp:127.0.0.1:8000-:22 18 | /// 19 | /// Remove port forwarding: 20 | /// $ cubic net hostfwd del myinstance tcp:127.0.0.1:8000-:22 21 | #[command(subcommand, verbatim_doc_comment)] 22 | Hostfwd(HostfwdCommands), 23 | } 24 | 25 | impl NetworkCommands { 26 | pub fn dispatch(&self, instance_dao: &InstanceDao) -> Result<(), Error> { 27 | match self { 28 | NetworkCommands::Hostfwd(command) => command.dispatch(instance_dao), 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/commands/net/hostfwd.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::instance::{InstanceDao, InstanceStore}; 3 | use crate::view::{Alignment, Stdio, TableView}; 4 | use clap::Subcommand; 5 | use regex::Regex; 6 | 7 | #[derive(Subcommand)] 8 | pub enum HostfwdCommands { 9 | /// List forwarded host ports 10 | /// 11 | /// List forwarded ports for all instances: 12 | /// $ cubic net hostfwd list 13 | #[clap(verbatim_doc_comment, alias = "ls")] 14 | List, 15 | 16 | /// Add host port forwarding rule 17 | /// 18 | /// Forward guest SSH port (TCP port 22) to host on port 8000: 19 | /// $ cubic net hostfwd add myinstance tcp:127.0.0.1:8000-:22 20 | #[clap(verbatim_doc_comment)] 21 | Add { 22 | /// Virtual machine instance 23 | instance: String, 24 | /// Port forwarding rule 25 | rule: String, 26 | }, 27 | 28 | /// Delete host port forwarding rule 29 | /// 30 | /// Remove port forwarding: 31 | /// $ cubic net hostfwd del myinstance tcp:127.0.0.1:8000-:22 32 | #[clap(verbatim_doc_comment, alias = "rm")] 33 | Del { 34 | /// Virtual machine instance 35 | instance: String, 36 | /// Port forwarding rule 37 | rule: String, 38 | }, 39 | } 40 | 41 | impl HostfwdCommands { 42 | pub fn dispatch(&self, instance_dao: &InstanceDao) -> Result<(), Error> { 43 | let console = &mut Stdio::new(); 44 | match self { 45 | HostfwdCommands::List => { 46 | let instance_names = instance_dao.get_instances(); 47 | 48 | let mut view = TableView::new(); 49 | view.add_row() 50 | .add("INSTANCE", Alignment::Left) 51 | .add("RULE", Alignment::Left); 52 | 53 | for instance_name in instance_names { 54 | for hostfwd in instance_dao.load(&instance_name)?.hostfwd { 55 | view.add_row() 56 | .add(&instance_name, Alignment::Left) 57 | .add(&hostfwd, Alignment::Left); 58 | } 59 | } 60 | view.print(console); 61 | Ok(()) 62 | } 63 | HostfwdCommands::Add { instance, rule } => { 64 | if !Regex::new( 65 | r"^(udp|tcp):([0-9]+\.){3}[0-9]+:[0-9]{1,5}\-([0-9]+.[0-9])?:[0-9]{1,5}$", 66 | ) 67 | .unwrap() 68 | .is_match(rule) 69 | { 70 | return Err(Error::HostFwdRuleMalformed(rule.to_string())); 71 | } 72 | let mut instance = instance_dao.load(instance)?; 73 | instance.hostfwd.push(rule.to_string()); 74 | instance_dao.store(&instance) 75 | } 76 | HostfwdCommands::Del { instance, rule } => { 77 | let mut instance = instance_dao.load(instance)?; 78 | instance.hostfwd.retain(|item| item != rule); 79 | instance_dao.store(&instance) 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/commands/restart.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::{self, Verbosity}; 2 | use crate::error::Error; 3 | use crate::instance::InstanceDao; 4 | 5 | pub fn restart( 6 | instance_dao: &InstanceDao, 7 | verbosity: Verbosity, 8 | instances: &Vec, 9 | ) -> Result<(), Error> { 10 | commands::stop(instance_dao, false, verbosity, instances)?; 11 | commands::start(instance_dao, &None, verbosity, instances) 12 | } 13 | -------------------------------------------------------------------------------- /src/commands/run.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::instance_add_command::InstanceAddCommand; 2 | use crate::commands::{self, Verbosity}; 3 | use crate::error::Error; 4 | use crate::image::ImageDao; 5 | use crate::instance::InstanceDao; 6 | 7 | #[allow(clippy::too_many_arguments)] 8 | pub fn run( 9 | image_dao: &ImageDao, 10 | instance_dao: &InstanceDao, 11 | image_name: &str, 12 | name: &String, 13 | cpus: &Option, 14 | mem: &Option, 15 | disk: &Option, 16 | verbosity: Verbosity, 17 | ) -> Result<(), Error> { 18 | InstanceAddCommand::new( 19 | image_name.to_string(), 20 | name.to_string(), 21 | cpus.as_ref().cloned(), 22 | mem.as_ref().cloned(), 23 | disk.as_ref().cloned(), 24 | ) 25 | .run(image_dao, instance_dao)?; 26 | commands::sh(instance_dao, verbosity, name) 27 | } 28 | -------------------------------------------------------------------------------- /src/commands/scp.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::Verbosity; 2 | use crate::error::Error; 3 | use crate::instance::{InstanceDao, InstanceStore}; 4 | use crate::ssh_cmd::{get_ssh_private_key_names, Scp}; 5 | use std::env; 6 | use std::os::unix::process::CommandExt; 7 | 8 | fn get_scp_address(instance_dao: &InstanceDao, location: &str) -> Result { 9 | Ok(if location.contains(':') { 10 | let mut location_token = location.split(':'); 11 | let name = location_token.next().unwrap(); 12 | let path = location_token.next().unwrap(); 13 | let instance = instance_dao.load(name)?; 14 | let port = instance.ssh_port; 15 | let user = instance.user; 16 | format!("scp://{user}@127.0.0.1:{port}/{path}") 17 | } else { 18 | location.to_string() 19 | }) 20 | } 21 | 22 | pub fn scp( 23 | instance_dao: &InstanceDao, 24 | from: &str, 25 | to: &str, 26 | verbosity: Verbosity, 27 | scp_args: &Option, 28 | ) -> Result<(), Error> { 29 | let from = &get_scp_address(instance_dao, from)?; 30 | let to = &get_scp_address(instance_dao, to)?; 31 | 32 | Err(Error::Io( 33 | Scp::new() 34 | .set_root_dir(env::var("SNAP").unwrap_or_default().as_str()) 35 | .set_verbose(verbosity.is_verbose()) 36 | .set_known_hosts_file( 37 | env::var("HOME") 38 | .map(|dir| format!("{dir}/.ssh/known_hosts")) 39 | .ok(), 40 | ) 41 | .set_private_keys(get_ssh_private_key_names()?) 42 | .set_args(scp_args.as_ref().unwrap_or(&String::new())) 43 | .copy(from, to) 44 | .exec(), 45 | )) 46 | } 47 | -------------------------------------------------------------------------------- /src/commands/sh.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::{self, Verbosity}; 2 | use crate::error::Error; 3 | use crate::instance::{InstanceDao, InstanceStore}; 4 | use crate::util::Terminal; 5 | use crate::view::TimerView; 6 | 7 | use std::str; 8 | use std::thread; 9 | use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; 10 | 11 | const QEMU_GA_TIMEOUT_SECS: u64 = 300; 12 | 13 | pub fn sh(instance_dao: &InstanceDao, verbosity: Verbosity, name: &str) -> Result<(), Error> { 14 | let instance = instance_dao.load(name)?; 15 | 16 | if !instance_dao.is_running(&instance) { 17 | commands::start(instance_dao, &None, verbosity, &vec![name.to_string()])?; 18 | } 19 | 20 | // Check if QEMU guest agent is present 21 | let ga_start = Instant::now(); 22 | if let Ok(mut ga) = instance_dao.get_guest_agent(&instance) { 23 | TimerView::new("Connecting to guest").run(&mut || { 24 | ga.ping().is_ok() || ga_start.elapsed() > Duration::from_secs(QEMU_GA_TIMEOUT_SECS) 25 | }); 26 | } 27 | 28 | if ga_start.elapsed() > Duration::from_secs(QEMU_GA_TIMEOUT_SECS) { 29 | return Err(Error::MissingQemuGA); 30 | } 31 | 32 | let user = &instance.user; 33 | let sh = format!( 34 | "sh{}", 35 | SystemTime::now() 36 | .duration_since(UNIX_EPOCH) 37 | .unwrap() 38 | .as_millis() 39 | ); 40 | let chardev = &format!("{sh}_chardev"); 41 | let device = &format!("{sh}_dev"); 42 | 43 | { 44 | let mut monitor = instance_dao.get_monitor(&instance)?; 45 | monitor.add_unix_socket_chardev(chardev)?; 46 | monitor.add_virtserialport_device(device, chardev)?; 47 | } 48 | 49 | let console_path = format!("{}/{name}/{chardev}.socket", instance_dao.cache_dir); 50 | if let Ok(mut term) = Terminal::open(&console_path) { 51 | let pid; 52 | 53 | { 54 | let mut ga = instance_dao.get_guest_agent(&instance)?; 55 | ga.sync()?; 56 | 57 | pid = ga.exec( 58 | "sh", 59 | &[ 60 | "-c".to_string(), 61 | format!("until [ -c /dev/virtio-ports/{device} ]; do sleep 1; done; socat /dev/virtio-ports/{device} exec:'su - {user}',raw,pty,stderr,setsid,sigint,sane,ctty"), 62 | ], 63 | &["TERM=linux".to_string()], 64 | )?; 65 | } 66 | 67 | while term.is_running() { 68 | { 69 | let mut ga = instance_dao.get_guest_agent(&instance)?; 70 | ga.sync()?; 71 | 72 | // update terminal geometry 73 | if let Some(termsize) = term.get_term_size() { 74 | let (cols, rows) = termsize; 75 | ga.exec( 76 | "sh", 77 | &[ 78 | "-c".to_string(), 79 | format!( 80 | "export CHILD=$(cat /proc/{pid}/task/{pid}/children | xargs); 81 | export GRAND_CHILD=$(cat /proc/$CHILD/task/$CHILD/children | xargs); 82 | stty -F /proc/$GRAND_CHILD/fd/0 cols {cols} rows {rows}" 83 | ), 84 | ], 85 | &[], 86 | ) 87 | .ok(); 88 | } 89 | 90 | // check program status 91 | if let Ok(true) = ga.get_exec_status(pid) { 92 | term.stop(); 93 | } 94 | } 95 | 96 | thread::sleep(Duration::from_millis(100)); 97 | } 98 | 99 | let mut ga = instance_dao.get_guest_agent(&instance)?; 100 | ga.sync()?; 101 | ga.exec("sh", &["-c".to_string(), format!("kill -9 {pid}")], &[])?; 102 | 103 | let mut monitor = instance_dao.get_monitor(&instance)?; 104 | monitor.delete_device(device)?; 105 | monitor.delete_chardev(chardev)?; 106 | } 107 | 108 | Ok(()) 109 | } 110 | -------------------------------------------------------------------------------- /src/commands/ssh.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::{self, Verbosity}; 2 | use crate::error::Error; 3 | use crate::instance::{InstanceDao, InstanceStore}; 4 | use crate::ssh_cmd::{get_ssh_private_key_names, Ssh}; 5 | 6 | use std::env; 7 | use std::os::unix::process::CommandExt; 8 | use std::thread::sleep; 9 | use std::time::Duration; 10 | 11 | fn get_instance_name(target: &str) -> Result { 12 | if target.contains('@') { 13 | target 14 | .split('@') 15 | .nth(1) 16 | .map(|instance| instance.to_string()) 17 | .ok_or(Error::InvalidSshTarget(target.to_string())) 18 | } else { 19 | Ok(target.to_string()) 20 | } 21 | } 22 | 23 | fn get_user_name(target: &str) -> Result, Error> { 24 | if target.contains('@') { 25 | target 26 | .split('@') 27 | .next() 28 | .map(|instance| Some(instance.to_string())) 29 | .ok_or(Error::InvalidSshTarget(target.to_string())) 30 | } else { 31 | Ok(None) 32 | } 33 | } 34 | 35 | pub fn ssh( 36 | instance_dao: &InstanceDao, 37 | target: &str, 38 | xforward: bool, 39 | verbosity: Verbosity, 40 | ssh_args: &Option, 41 | cmd: &Option, 42 | ) -> Result<(), Error> { 43 | let name = get_instance_name(target)?; 44 | let instance = instance_dao.load(&name)?; 45 | let user = get_user_name(target)?.unwrap_or(instance.user.to_string()); 46 | let ssh_port = instance.ssh_port; 47 | 48 | if !instance_dao.is_running(&instance) { 49 | commands::start(instance_dao, &None, verbosity, &vec![name.to_string()])?; 50 | sleep(Duration::from_millis(3000)); 51 | } 52 | 53 | Err(Error::Io( 54 | Ssh::new() 55 | .set_known_hosts_file( 56 | env::var("HOME") 57 | .map(|dir| format!("{dir}/.ssh/known_hosts")) 58 | .ok(), 59 | ) 60 | .set_private_keys(get_ssh_private_key_names()?) 61 | .set_port(Some(ssh_port)) 62 | .set_xforward(xforward) 63 | .set_args(ssh_args.clone().unwrap_or_default()) 64 | .set_user(user.clone()) 65 | .set_cmd(cmd.clone()) 66 | .set_verbose(verbosity.is_verbose()) 67 | .connect() 68 | .exec(), 69 | )) 70 | } 71 | -------------------------------------------------------------------------------- /src/commands/start.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::Verbosity; 2 | use crate::error::Error; 3 | use crate::instance::{InstanceDao, InstanceState, InstanceStore}; 4 | use crate::view::TimerView; 5 | use std::io::Read; 6 | 7 | pub fn start( 8 | instance_dao: &InstanceDao, 9 | qemu_args: &Option, 10 | verbosity: Verbosity, 11 | instance_names: &Vec, 12 | ) -> Result<(), Error> { 13 | for name in instance_names { 14 | if !instance_dao.exists(name) { 15 | return Result::Err(Error::UnknownInstance(name.clone())); 16 | } 17 | } 18 | 19 | let mut instances = Vec::new(); 20 | let mut children = Vec::new(); 21 | for name in instance_names { 22 | let instance = instance_dao.load(name)?; 23 | if !instance_dao.is_running(&instance) { 24 | let child = instance_dao.start(&instance, qemu_args, verbosity.is_verbose())?; 25 | children.push(child); 26 | } 27 | instances.push(instance); 28 | } 29 | 30 | if !verbosity.is_quiet() { 31 | TimerView::new("Starting instance(s)").run(&mut || { 32 | let all_running = instances 33 | .iter() 34 | .all(|instance| instance_dao.get_state(instance) == InstanceState::Running); 35 | let any_fails = children.iter_mut().any(|child| { 36 | child 37 | .try_wait() 38 | .ok() 39 | .and_then(|result| result) 40 | .and_then(|exit| exit.code()) 41 | .map(|exit_code| exit_code != 0) 42 | .unwrap_or_default() 43 | }); 44 | 45 | all_running || any_fails 46 | }); 47 | 48 | for mut child in children { 49 | let exit_code = child 50 | .try_wait() 51 | .ok() 52 | .and_then(|result| result) 53 | .and_then(|exit| exit.code()); 54 | 55 | if let Some(exit_code) = exit_code { 56 | if exit_code != 0 { 57 | let mut stderr = String::new(); 58 | if let Some(mut err) = child.stderr.take() { 59 | err.read_to_string(&mut stderr).ok(); 60 | } 61 | 62 | let message = format!("QEMU failed with exit code {exit_code}:\n{stderr}"); 63 | return Err(Error::CommandFailed(message)); 64 | } 65 | } 66 | } 67 | } 68 | 69 | Result::Ok(()) 70 | } 71 | -------------------------------------------------------------------------------- /src/commands/stop.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::Verbosity; 2 | use crate::error::Error; 3 | use crate::instance::{InstanceDao, InstanceState, InstanceStore}; 4 | use crate::view::TimerView; 5 | 6 | pub fn stop( 7 | instance_dao: &InstanceDao, 8 | all: bool, 9 | verbosity: Verbosity, 10 | instances: &Vec, 11 | ) -> Result<(), Error> { 12 | for instance in instances { 13 | if !instance_dao.exists(instance) { 14 | return Result::Err(Error::UnknownInstance(instance.clone())); 15 | } 16 | } 17 | 18 | let stop_instances = if all { 19 | instance_dao.get_instances() 20 | } else { 21 | instances.clone() 22 | }; 23 | 24 | let mut instances = Vec::new(); 25 | for instance in stop_instances { 26 | let instance = instance_dao.load(&instance)?; 27 | instance_dao.stop(&instance)?; 28 | instances.push(instance); 29 | } 30 | 31 | if !verbosity.is_quiet() { 32 | TimerView::new("Stopping instance(s)").run(&mut || { 33 | instances 34 | .iter() 35 | .all(|instance| instance_dao.get_state(instance) == InstanceState::Stopped) 36 | }); 37 | } 38 | 39 | Result::Ok(()) 40 | } 41 | -------------------------------------------------------------------------------- /src/commands/verbosity.rs: -------------------------------------------------------------------------------- 1 | #[derive(PartialEq, Clone, Copy)] 2 | pub enum Verbosity { 3 | Verbose, 4 | Normal, 5 | Quiet, 6 | } 7 | 8 | impl Verbosity { 9 | pub fn new(verbose: bool, quiet: bool) -> Self { 10 | if quiet { 11 | Verbosity::Quiet 12 | } else if verbose { 13 | Verbosity::Verbose 14 | } else { 15 | Verbosity::Normal 16 | } 17 | } 18 | 19 | pub fn is_verbose(&self) -> bool { 20 | *self == Verbosity::Verbose 21 | } 22 | 23 | pub fn is_quiet(&self) -> bool { 24 | *self == Verbosity::Quiet 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/emulator.rs: -------------------------------------------------------------------------------- 1 | use crate::arch::Arch; 2 | use crate::error::Error; 3 | use crate::util; 4 | 5 | use std::process::{Child, Command, Stdio}; 6 | 7 | pub struct Emulator { 8 | name: String, 9 | command: Command, 10 | verbose: bool, 11 | } 12 | 13 | impl Emulator { 14 | pub fn from(name: String, arch: Arch) -> Result { 15 | let mut command = match arch { 16 | Arch::AMD64 => { 17 | let mut command = Command::new("qemu-system-x86_64"); 18 | // Set machine type 19 | command.arg("-machine").arg("q35"); 20 | command 21 | } 22 | Arch::ARM64 => { 23 | let mut command = Command::new("qemu-system-aarch64"); 24 | // Set machine type 25 | command.arg("-machine").arg("virt"); 26 | command.arg("-bios").arg("QEMU_EFI.fd"); 27 | command 28 | } 29 | }; 30 | 31 | // Set CPU type 32 | command.arg("-cpu").arg("max"); 33 | // Enable accelerators 34 | command 35 | .arg("-accel") 36 | .arg("kvm") 37 | .arg("-accel") 38 | .arg("xen") 39 | .arg("-accel") 40 | .arg("hvf") 41 | .arg("-accel") 42 | .arg("nvmm") 43 | .arg("-accel") 44 | .arg("whpx") 45 | .arg("-accel") 46 | .arg("tcg"); 47 | // Only boot disk 48 | command.arg("-boot").arg("c"); 49 | 50 | // Sandbox 51 | #[cfg(feature = "qemu-sandbox")] 52 | command.arg("-sandbox").arg("on"); 53 | 54 | Ok(Emulator { 55 | name, 56 | command, 57 | verbose: false, 58 | }) 59 | } 60 | 61 | pub fn set_verbose(&mut self, flag: bool) { 62 | self.verbose = flag; 63 | } 64 | 65 | pub fn add_env(&mut self, name: &str, value: &str) { 66 | self.command.env(name, value); 67 | } 68 | 69 | pub fn set_cpus(&mut self, cpus: u16) { 70 | self.command.arg("-smp").arg(cpus.to_string()); 71 | } 72 | 73 | pub fn set_memory(&mut self, memory: u64) { 74 | self.command.arg("-m").arg(format!("{}B", memory)); 75 | } 76 | 77 | pub fn add_virtio_serial(&mut self, name: &str) { 78 | self.command 79 | .arg("-device") 80 | .arg(format!("virtio-serial,id={name},max_ports=32")); 81 | } 82 | 83 | pub fn add_qmp(&mut self, name: &str, path: &str) { 84 | self.command 85 | .args([ 86 | "-chardev", 87 | &format!("socket,id={name},path={path},server=on,wait=off"), 88 | ]) 89 | .args(["-mon", &format!("chardev={name},mode=control,pretty=off")]); 90 | } 91 | 92 | pub fn add_guest_agent(&mut self, name: &str, path: &str) { 93 | self.command 94 | .args([ 95 | "-chardev", 96 | &format!("socket,id={name},path={path},server=on,wait=off"), 97 | ]) 98 | .args(["-device", "virtio-serial"]) 99 | .args([ 100 | "-device", 101 | &format!("virtserialport,chardev={name},name=org.qemu.guest_agent.0"), 102 | ]); 103 | } 104 | 105 | pub fn set_console(&mut self, path: &str) { 106 | self.command 107 | .arg("-chardev") 108 | .arg(format!("socket,path={path},server=on,wait=off,id=console")) 109 | .arg("-serial") 110 | .arg("chardev:console"); 111 | } 112 | 113 | pub fn set_network(&mut self, hostfwd: &[String], ssh_port: u16) { 114 | let mut hostfwd_options = String::new(); 115 | for fwd in hostfwd { 116 | hostfwd_options.push_str(",hostfwd="); 117 | hostfwd_options.push_str(fwd); 118 | } 119 | 120 | self.command 121 | .arg("-device") 122 | .arg("virtio-net-pci,netdev=net0") 123 | .arg("-netdev") 124 | .arg(format!( 125 | "user,id=net0,hostfwd=tcp:127.0.0.1:{ssh_port}-:22{hostfwd_options}" 126 | )); 127 | } 128 | 129 | pub fn add_drive(&mut self, path: &str, format: &str) { 130 | self.command 131 | .arg("-drive") 132 | .arg(format!("if=virtio,format={format},file={path}")); 133 | } 134 | 135 | pub fn add_mount(&mut self, name: &str, path: &str) { 136 | self.command 137 | .arg("-fsdev") 138 | .arg(format!( 139 | "local,security_model=mapped,id={name}_dev,multidevs=remap,path={path}" 140 | )) 141 | .arg("-device") 142 | .arg(format!( 143 | "virtio-9p-pci,id={name},fsdev={name}_dev,mount_tag=cubic{name}" 144 | )); 145 | } 146 | 147 | pub fn set_display(&mut self, display: bool, gpu: bool) { 148 | self.command.arg("-display"); 149 | if display { 150 | self.command.arg("gtk,gl=on"); 151 | self.command.arg("-device"); 152 | if gpu { 153 | self.command.arg("virtio-gpu-gl"); 154 | } else { 155 | self.command.arg("virtio-gpu"); 156 | } 157 | } else { 158 | self.command.arg("none"); 159 | } 160 | } 161 | 162 | pub fn set_qemu_args(&mut self, args: &str) { 163 | for arg in args.split(' ') { 164 | self.command.arg(arg); 165 | } 166 | } 167 | 168 | pub fn add_search_path(&mut self, path: &str) { 169 | self.command.arg("-L").arg(path); 170 | } 171 | 172 | pub fn set_pid_file(&mut self, path: &str) { 173 | self.command.arg("-pidfile").arg(path); 174 | } 175 | 176 | pub fn run(&mut self) -> Result { 177 | self.command 178 | .arg("-daemonize") 179 | .stdin(Stdio::null()) 180 | .stderr(Stdio::piped()); 181 | 182 | if self.verbose { 183 | util::print_command(&self.command); 184 | } else { 185 | self.command.stdout(Stdio::null()); 186 | } 187 | 188 | self.command 189 | .spawn() 190 | .map_err(|_| Error::Start(self.name.clone())) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | #[derive(Debug)] 4 | pub enum Error { 5 | UnknownCommand, 6 | InvalidArgument(String), 7 | UnknownArch(String), 8 | UnknownInstance(String), 9 | InstanceIsRunning(String), 10 | InstanceNotStopped(String), 11 | Start(String), 12 | InstanceAlreadyExists(String), 13 | Io(io::Error), 14 | FS(String), 15 | UnknownImage(String), 16 | MissingSshKey, 17 | InvalidImageName(String), 18 | UnsetEnvVar(String), 19 | CannotAccessDir(String), 20 | CannotParseFile(String), 21 | InvalidSshTarget(String), 22 | UserDataCreationFailed(String), 23 | CannotParseSize(String), 24 | CannotShrinkDisk(String), 25 | GetCapacityFailed(String), 26 | CannotOpenTerminal(String), 27 | HostFwdRuleMalformed(String), 28 | CommandFailed(String), 29 | Web(reqwest::Error), 30 | SerdeJson(serde_json::Error), 31 | SerdeYaml(serde_yaml::Error), 32 | MissingQemuGA, 33 | ExecFailed, 34 | } 35 | 36 | pub fn print_error(error: Error) { 37 | print!("ERROR: "); 38 | match error { 39 | Error::UnknownCommand => println!("Unknown command"), 40 | Error::InvalidArgument(err) => println!("Argument error: {err}"), 41 | Error::UnknownArch(name) => println!("Unknown architecture: '{name}'"), 42 | Error::UnknownInstance(instance) => println!("Unknown instance '{instance}'"), 43 | Error::InstanceIsRunning(name) => println!("Instance '{name}' is already runing"), 44 | Error::InstanceNotStopped(name) => println!("Instance '{name}' is not stopped"), 45 | Error::Start(instance) => println!("Failed to start instance '{instance}'"), 46 | Error::InstanceAlreadyExists(id) => println!("Instance with name '{id}' already exists"), 47 | Error::Io(e) => println!("{}", e), 48 | Error::FS(e) => println!("{}", e), 49 | Error::UnknownImage(name) => println!("Unknown image name {name}"), 50 | Error::MissingSshKey => print!( 51 | "No SSH keys found. Please try the following:\n\ 52 | - Check if cubic has read access to $HOME/.ssh 53 | - Snap users must grant access with: `sudo snap connect cubic:ssh-keys`\n\ 54 | - Check if you have a ssh key in $HOME/.ssh 55 | - You can generate one with `ssh-keygen`\n\ 56 | - Use `cubic sh myinstance` instead\n" 57 | ), 58 | Error::InvalidImageName(name) => println!("Invalid image name: {name}"), 59 | Error::UnsetEnvVar(var) => println!("Environment variable '{var}' is not set"), 60 | Error::CannotAccessDir(path) => println!("Cannot access directory '{path}'"), 61 | Error::CannotParseFile(path) => println!("Cannot parse file '{path}'"), 62 | Error::InvalidSshTarget(name) => println!("Invalid SSH target '{name}'"), 63 | Error::UserDataCreationFailed(name) => { 64 | println!("Failed to create user data for instance '{name}'") 65 | } 66 | Error::CannotParseSize(size) => println!("Invalid data size format '{size}'"), 67 | Error::CannotShrinkDisk(name) => { 68 | println!("Cannot shrink the disk of the instance '{name}'") 69 | } 70 | Error::GetCapacityFailed(path) => println!("Failed to get capacity from image: '{path}'"), 71 | Error::CannotOpenTerminal(path) => println!("Failed to open terminal from path: '{path}'"), 72 | Error::HostFwdRuleMalformed(rule) => println!("Host forwarding rule is malformed: {rule}"), 73 | Error::CommandFailed(message) => println!("{message}"), 74 | Error::SerdeJson(err) => println!("[JSON] {err}"), 75 | Error::SerdeYaml(err) => println!("[YAML] {err}"), 76 | Error::MissingQemuGA => println!("Cannot access QEMU guest agent. Please install qemu-guest-agent in the virtual machine instance."), 77 | Error::ExecFailed => println!("Failed to execute command in virtual machine instance."), 78 | Error::Web(e) => println!("{e}"), 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/fs.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use std::fs; 3 | use std::io::prelude::*; 4 | use std::path::Path; 5 | use std::process::{Command, Stdio}; 6 | 7 | pub struct FS; 8 | 9 | impl FS { 10 | pub fn new() -> Self { 11 | Self {} 12 | } 13 | 14 | pub fn create_dir(&self, path: &str) -> Result<(), Error> { 15 | if !Path::new(path).exists() { 16 | return fs::create_dir_all(path) 17 | .map_err(|e| Error::FS(format!("Cannot create directory '{path}' ({e})"))); 18 | } 19 | 20 | Result::Ok(()) 21 | } 22 | 23 | pub fn copy_dir(&self, from: &str, to: &str) -> Result<(), Error> { 24 | Command::new("cp") 25 | .arg("--recursive") 26 | .arg(from) 27 | .arg(to) 28 | .stdout(Stdio::null()) 29 | .stderr(Stdio::null()) 30 | .spawn() 31 | .map_err(Error::Io)? 32 | .wait() 33 | .map(|_| ()) 34 | .map_err(|e| { 35 | Error::FS(format!( 36 | "Cannot copy directory from '{from}' to '{to}' ({e})" 37 | )) 38 | }) 39 | } 40 | 41 | pub fn move_dir(&self, from: &str, to: &str) -> Result<(), Error> { 42 | Command::new("mv") 43 | .arg(from) 44 | .arg(to) 45 | .stdout(Stdio::null()) 46 | .stderr(Stdio::null()) 47 | .spawn() 48 | .map_err(Error::Io)? 49 | .wait() 50 | .map(|_| ()) 51 | .map_err(|e| { 52 | Error::FS(format!( 53 | "Cannot move directory from '{from}' to '{to}' ({e})" 54 | )) 55 | }) 56 | } 57 | 58 | pub fn read_dir(&self, path: &str) -> Result { 59 | fs::read_dir(path).map_err(|e| Error::FS(format!("Cannot read directory '{path}' ({e})"))) 60 | } 61 | 62 | pub fn remove_dir(&self, path: &str) -> Result<(), Error> { 63 | fs::remove_dir_all(path) 64 | .map_err(|e| Error::FS(format!("Cannot remove directory '{path}' ({e})"))) 65 | } 66 | 67 | pub fn setup_directory_access(&self, path: &str) -> Result<(), Error> { 68 | self.create_dir(path)?; 69 | 70 | let permission = fs::metadata(path) 71 | .map_err(|e| Error::FS(format!("Cannot read directory metadata '{path}' ({e})")))? 72 | .permissions(); 73 | 74 | if permission.readonly() { 75 | return Err(Error::FS(format!("Cannot write directory '{path}'"))); 76 | } 77 | 78 | Result::Ok(()) 79 | } 80 | 81 | pub fn create_file(&self, path: &str) -> Result { 82 | std::fs::OpenOptions::new() 83 | .write(true) 84 | .create(true) 85 | .truncate(true) 86 | .open(path) 87 | .map_err(|e| Error::FS(format!("Cannot create file '{path}' ({e})"))) 88 | } 89 | 90 | pub fn open_file(&self, path: &str) -> Result { 91 | fs::File::open(path).map_err(|e| Error::FS(format!("Cannot open file '{path}' ({e})"))) 92 | } 93 | 94 | pub fn copy_file(&self, from: &str, to: &str) -> Result<(), Error> { 95 | fs::copy(from, to) 96 | .map(|_| ()) 97 | .map_err(|e| Error::FS(format!("Cannot copy file from '{from}' to '{to}' ({e})"))) 98 | } 99 | 100 | pub fn write_file(&self, path: &str, data: &[u8]) -> Result<(), Error> { 101 | self.create_file(path)? 102 | .write_all(data) 103 | .map_err(|e| Error::FS(format!("Cannot write file '{path}' ({e})"))) 104 | } 105 | 106 | pub fn read_file_to_string(&self, path: &str) -> Result { 107 | fs::read_to_string(path).map_err(|e| Error::FS(format!("Cannot read file '{path}' ({e})"))) 108 | } 109 | 110 | pub fn rename_file(&self, from: &str, to: &str) -> Result<(), Error> { 111 | fs::rename(from, to) 112 | .map_err(|e| Error::FS(format!("Cannot rename file from '{from}' to '{to}' ({e})"))) 113 | } 114 | 115 | pub fn remove_file(&self, path: &str) -> Result<(), Error> { 116 | fs::remove_file(path).map_err(|e| Error::FS(format!("Cannot delete file '{path}' ({e})"))) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/image.rs: -------------------------------------------------------------------------------- 1 | pub mod image_dao; 2 | pub mod image_factory; 3 | pub mod image_fetcher; 4 | 5 | use crate::arch::Arch; 6 | use crate::error::Error; 7 | pub use image_dao::*; 8 | pub use image_factory::*; 9 | pub use image_fetcher::*; 10 | use serde::{Deserialize, Serialize}; 11 | use std::io::{Read, Write}; 12 | 13 | #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] 14 | pub struct Image { 15 | pub vendor: String, 16 | pub codename: String, 17 | pub version: String, 18 | pub arch: Arch, 19 | pub url: String, 20 | pub size: Option, 21 | } 22 | 23 | impl Image { 24 | pub fn to_file_name(&self) -> String { 25 | format!("{}_{}_{}", self.vendor, self.codename, self.arch) 26 | } 27 | 28 | pub fn to_id(&self) -> String { 29 | format!("{}:{}", self.vendor, self.codename) 30 | } 31 | } 32 | 33 | #[derive(Debug, PartialEq, Clone, Default, Serialize, Deserialize)] 34 | pub struct ImageCache { 35 | images: Vec, 36 | timestamp: u64, 37 | } 38 | 39 | impl ImageCache { 40 | pub fn deserialize(reader: &mut dyn Read) -> Result { 41 | serde_yaml::from_reader(reader).map_err(Error::SerdeYaml) 42 | } 43 | 44 | pub fn serialize(&self, writer: &mut dyn Write) -> Result<(), Error> { 45 | serde_yaml::to_writer(writer, self).map_err(Error::SerdeYaml) 46 | } 47 | } 48 | 49 | #[cfg(test)] 50 | mod tests { 51 | use super::*; 52 | 53 | use std::io::BufReader; 54 | 55 | #[test] 56 | fn test_deserialize_invalid() { 57 | let reader = &mut BufReader::new("".as_bytes()); 58 | let cache = ImageCache::deserialize(reader); 59 | assert!(cache.is_err()); 60 | } 61 | 62 | #[test] 63 | fn test_deserialize_empty() { 64 | let reader = &mut BufReader::new("images: []\ntimestamp: 0".as_bytes()); 65 | let cache = ImageCache::deserialize(reader); 66 | assert_eq!(cache.unwrap(), ImageCache::default()); 67 | } 68 | 69 | #[test] 70 | fn test_serialize_empty() { 71 | let mut writer = Vec::new(); 72 | 73 | ImageCache::default().serialize(&mut writer).unwrap(); 74 | 75 | assert_eq!( 76 | String::from_utf8(writer).unwrap(), 77 | "images: []\ntimestamp: 0\n" 78 | ); 79 | } 80 | 81 | #[test] 82 | fn test_deserialize_single() { 83 | let reader = &mut BufReader::new("images: []\ntimestamp: 0".as_bytes()); 84 | let cache = ImageCache::deserialize(reader); 85 | assert_eq!(cache.unwrap(), ImageCache::default()); 86 | } 87 | 88 | #[test] 89 | fn test_serialize_single() { 90 | let mut writer = Vec::new(); 91 | 92 | ImageCache { 93 | images: vec![Image { 94 | vendor: "testvendor".to_string(), 95 | codename: "testcodename".to_string(), 96 | version: "testversion".to_string(), 97 | arch: Arch::AMD64, 98 | url: "testurl".to_string(), 99 | size: None, 100 | }], 101 | timestamp: 1000, 102 | } 103 | .serialize(&mut writer) 104 | .unwrap(); 105 | 106 | assert_eq!( 107 | String::from_utf8(writer).unwrap(), 108 | r#"images: 109 | - vendor: testvendor 110 | codename: testcodename 111 | version: testversion 112 | arch: AMD64 113 | url: testurl 114 | size: null 115 | timestamp: 1000 116 | "# 117 | ); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /src/image/image_dao.rs: -------------------------------------------------------------------------------- 1 | use crate::arch::Arch; 2 | use crate::error::Error; 3 | use crate::fs::FS; 4 | use crate::image::{Image, ImageFactory}; 5 | use crate::util; 6 | use std::path::Path; 7 | use std::str; 8 | 9 | pub struct ImageDao { 10 | fs: FS, 11 | pub image_dir: String, 12 | } 13 | 14 | #[cfg(target_arch = "aarch64")] 15 | fn get_default_arch() -> Arch { 16 | Arch::ARM64 17 | } 18 | 19 | #[cfg(not(target_arch = "aarch64"))] 20 | fn get_default_arch() -> Arch { 21 | Arch::AMD64 22 | } 23 | 24 | impl ImageDao { 25 | pub fn new() -> Result { 26 | let fs = FS::new(); 27 | let image_dir = util::get_image_data_dir()?; 28 | fs.setup_directory_access(&image_dir)?; 29 | 30 | Result::Ok(ImageDao { fs, image_dir }) 31 | } 32 | 33 | pub fn get(&self, id: &str) -> Result { 34 | let mut tokens = id.split(':'); 35 | let vendor = tokens 36 | .next() 37 | .ok_or(Error::InvalidImageName(id.to_string()))? 38 | .to_string(); 39 | let name = tokens 40 | .next() 41 | .ok_or(Error::InvalidImageName(id.to_string()))? 42 | .to_string(); 43 | let arch = tokens 44 | .next() 45 | .map(Arch::from_str) 46 | .unwrap_or(Ok(get_default_arch()))?; 47 | 48 | ImageFactory::create_images_for_distro(&vendor)? 49 | .iter() 50 | .find(|image| (image.arch == arch) && (image.codename == name || image.version == name)) 51 | .cloned() 52 | .ok_or(Error::UnknownImage(id.to_string())) 53 | } 54 | 55 | pub fn get_disk_capacity(&self, image: &Image) -> Result { 56 | let path = format!("{}/{}", self.image_dir, image.to_file_name()); 57 | util::get_disk_capacity(&path) 58 | } 59 | 60 | pub fn copy_image(&self, image: &Image, dir: &str, name: &str) -> Result<(), Error> { 61 | let path = format!("{}/{}", self.image_dir, image.to_file_name()); 62 | self.fs.create_dir(dir)?; 63 | self.fs.copy_file(&path, &format!("{dir}/{name}")) 64 | } 65 | 66 | pub fn exists(&self, image: &Image) -> bool { 67 | Path::new(&format!("{}/{}", self.image_dir, image.to_file_name())).exists() 68 | } 69 | 70 | pub fn delete(&self, image: &Image) -> Result<(), Error> { 71 | self.fs 72 | .remove_file(&format!("{}/{}", self.image_dir, image.to_file_name())) 73 | } 74 | 75 | pub fn prune(&self) -> Result<(), Error> { 76 | self.fs.remove_dir(&self.image_dir) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/image/image_factory.rs: -------------------------------------------------------------------------------- 1 | use crate::arch::Arch; 2 | use crate::error::Error; 3 | use crate::image::Image; 4 | use crate::image::ImageCache; 5 | use crate::util; 6 | use crate::web::WebClient; 7 | use regex::Regex; 8 | use std::collections::HashMap; 9 | use std::fs::File; 10 | use std::sync::LazyLock; 11 | use std::time::{SystemTime, UNIX_EPOCH}; 12 | 13 | const IMAGE_CACHE_LIFETIME_SEC: u64 = 24 * 60 * 60; // = 1 day 14 | 15 | struct ImageLocation { 16 | url: &'static str, 17 | pattern: LazyLock, 18 | download_url: &'static str, 19 | } 20 | 21 | struct Distro { 22 | vendor: &'static str, 23 | name_pattern: &'static str, 24 | version_pattern: &'static str, 25 | overview_url: &'static str, 26 | overview_pattern: LazyLock, 27 | images: HashMap, 28 | } 29 | 30 | static DISTROS: LazyLock> = LazyLock::new(|| { 31 | vec![ 32 | Distro { 33 | vendor: "archlinux", 34 | name_pattern: "(name)", 35 | version_pattern: "(name)", 36 | overview_url: "https://geo.mirror.pkgbuild.com/images/", 37 | overview_pattern: LazyLock::new(|| Regex::new(r">([a-z]+)/<").unwrap()), 38 | images: HashMap::from([ 39 | (Arch::AMD64, ImageLocation { 40 | url: "https://geo.mirror.pkgbuild.com/images/latest/", 41 | pattern: LazyLock::new(|| Regex::new(r">(Arch-Linux-x86_64-cloudimg.qcow2)<").unwrap()), 42 | download_url: "https://geo.mirror.pkgbuild.com/images/(name)/Arch-Linux-x86_64-cloudimg.qcow2", 43 | }), 44 | (Arch::ARM64, ImageLocation { 45 | url: "https://geo.mirror.pkgbuild.com/images/latest/", 46 | pattern: LazyLock::new(|| Regex::new(r">(Arch-Linux-arm64-cloudimg.qcow2)<").unwrap()), 47 | download_url: "https://geo.mirror.pkgbuild.com/images/(name)/Arch-Linux-arm64-cloudimg.qcow2", 48 | }) 49 | ]), 50 | }, 51 | 52 | 53 | Distro { 54 | vendor: "debian", 55 | name_pattern: "(name)", 56 | version_pattern: "(version)", 57 | overview_url: "https://cloud.debian.org/images/cloud/", 58 | overview_pattern: LazyLock::new(|| Regex::new(r">([a-z]+)/<").unwrap()), 59 | images: HashMap::from([ 60 | (Arch::AMD64, ImageLocation { 61 | url: "https://cloud.debian.org/images/cloud/(name)/latest/", 62 | pattern: LazyLock::new(|| Regex::new(r">debian-([0-9]+)-generic-amd64.qcow2<").unwrap()), 63 | download_url: "https://cloud.debian.org/images/cloud/(name)/latest/debian-(version)-generic-amd64.qcow2", 64 | }), 65 | (Arch::ARM64, ImageLocation { 66 | url: "https://cloud.debian.org/images/cloud/(name)/latest/", 67 | pattern: LazyLock::new(|| Regex::new(r">debian-([0-9]+)-generic-arm64.qcow2<").unwrap()), 68 | download_url: "https://cloud.debian.org/images/cloud/(name)/latest/debian-(version)-generic-arm64.qcow2", 69 | }) 70 | ]), 71 | }, 72 | 73 | Distro { 74 | vendor: "fedora", 75 | name_pattern: "(name)", 76 | version_pattern: "(name)", 77 | overview_url: "https://download.fedoraproject.org/pub/fedora/linux/releases/", 78 | overview_pattern: LazyLock::new(|| Regex::new(r">([4-9][0-9]+)/<").unwrap()), 79 | images: HashMap::from([ 80 | (Arch::AMD64, ImageLocation { 81 | url: "https://download.fedoraproject.org/pub/fedora/linux/releases/(name)/Cloud/x86_64/images/", 82 | pattern: LazyLock::new(|| Regex::new(r"Fedora-Cloud-Base-Generic-([0-9]+-[0-9]+.[0-9]+).x86_64.qcow2").unwrap()), 83 | download_url: "https://download.fedoraproject.org/pub/fedora/linux/releases/(name)/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-(version).x86_64.qcow2", 84 | }), 85 | (Arch::ARM64, ImageLocation { 86 | url: "https://download.fedoraproject.org/pub/fedora/linux/releases/(name)/Cloud/aarch64/images/", 87 | pattern: LazyLock::new(|| Regex::new(r"Fedora-Cloud-Base-Generic-([0-9]+-[0-9]+.[0-9]+).aarch64.qcow2").unwrap()), 88 | download_url: "https://download.fedoraproject.org/pub/fedora/linux/releases/(name)/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-(version).aarch64.qcow2", 89 | }) 90 | ]), 91 | }, 92 | 93 | Distro { 94 | vendor: "opensuse", 95 | name_pattern: "(name)", 96 | version_pattern: "(name)", 97 | overview_url: "https://download.opensuse.org/repositories/Cloud:/Images:/", 98 | overview_pattern: LazyLock::new(|| Regex::new(r">Leap_([0-9]+\.[0-9]+)/<").unwrap()), 99 | images: HashMap::from([ 100 | (Arch::AMD64, ImageLocation { 101 | url: "https://download.opensuse.org/repositories/Cloud:/Images:/Leap_15.6/images/", 102 | pattern: LazyLock::new(|| Regex::new(r">(openSUSE-Leap-[0-9]+.[0-9]+.x86_64-NoCloud.qcow2)<").unwrap()), 103 | download_url: "https://download.opensuse.org/repositories/Cloud:/Images:/Leap_15.6/images/(version)", 104 | }), 105 | (Arch::ARM64, ImageLocation { 106 | url: "https://download.opensuse.org/repositories/Cloud:/Images:/Leap_15.6/images/", 107 | pattern: LazyLock::new(|| Regex::new(r">(openSUSE-Leap-[0-9]+.[0-9]+.aarch64-NoCloud.qcow2)<").unwrap()), 108 | download_url: "https://download.opensuse.org/repositories/Cloud:/Images:/Leap_15.6/images/(version)", 109 | }) 110 | ]), 111 | }, 112 | 113 | Distro { 114 | vendor: "ubuntu", 115 | name_pattern: "(name)", 116 | version_pattern: "(version)", 117 | overview_url: "https://cloud-images.ubuntu.com/minimal/releases/", 118 | overview_pattern: LazyLock::new(|| Regex::new(r">([a-z]+)/<").unwrap()), 119 | images: HashMap::from([ 120 | (Arch::AMD64, ImageLocation { 121 | url: "https://cloud-images.ubuntu.com/minimal/releases/(name)/release/", 122 | pattern: LazyLock::new(|| Regex::new(r">ubuntu-([0-9]+\.[0-9]+)-minimal-cloudimg-amd64.img<").unwrap()), 123 | download_url: "https://cloud-images.ubuntu.com/minimal/releases/(name)/release/ubuntu-(version)-minimal-cloudimg-amd64.img", 124 | }), 125 | (Arch::ARM64, ImageLocation { 126 | url: "https://cloud-images.ubuntu.com/minimal/releases/(name)/release/", 127 | pattern: LazyLock::new(|| Regex::new(r">ubuntu-([0-9]+\.[0-9]+)-minimal-cloudimg-arm64.img<").unwrap()), 128 | download_url: "https://cloud-images.ubuntu.com/minimal/releases/(name)/release/ubuntu-(version)-minimal-cloudimg-arm64.img", 129 | }) 130 | ]), 131 | }, 132 | ] 133 | }); 134 | 135 | pub struct ImageFactory; 136 | 137 | impl ImageFactory { 138 | fn match_content(web: &mut WebClient, url: &str, pattern: &LazyLock) -> Vec { 139 | web.download_content(url) 140 | .map(|content| { 141 | pattern 142 | .captures_iter(&content) 143 | .map(|content| content.extract::<1>()) 144 | .map(|(_, values)| values[0].to_string()) 145 | .collect() 146 | }) 147 | .unwrap_or_default() 148 | } 149 | 150 | fn replace_vars(text: &str, name: &str, version: &str) -> String { 151 | text.replace("(name)", name).replace("(version)", version) 152 | } 153 | 154 | fn add_images(web: &mut WebClient, distro: &Distro) -> Vec { 155 | let mut images = Vec::new(); 156 | for name in Self::match_content(web, distro.overview_url, &distro.overview_pattern) { 157 | for (arch, loc) in &distro.images { 158 | for version in 159 | Self::match_content(web, &loc.url.replace("(name)", &name), &loc.pattern) 160 | { 161 | let url = Self::replace_vars(loc.download_url, &name, &version); 162 | if let Ok(size) = web.get_file_size(&url) { 163 | images.push(Image { 164 | vendor: distro.vendor.to_string(), 165 | codename: Self::replace_vars(distro.name_pattern, &name, &version), 166 | version: Self::replace_vars(distro.version_pattern, &name, &version), 167 | arch: *arch, 168 | url, 169 | size, 170 | }) 171 | } 172 | } 173 | } 174 | } 175 | 176 | images 177 | } 178 | 179 | fn read_image_cache() -> Option> { 180 | let now = SystemTime::now() 181 | .duration_since(UNIX_EPOCH) 182 | .map(|time| time.as_secs()) 183 | .unwrap_or_default(); 184 | 185 | util::get_image_cache_file() 186 | .and_then(|path| File::open(path).map_err(Error::Io)) 187 | .and_then(|ref mut reader| ImageCache::deserialize(reader)) 188 | .ok() 189 | .and_then(|cache| { 190 | if now - cache.timestamp < IMAGE_CACHE_LIFETIME_SEC { 191 | Some(cache.images) 192 | } else { 193 | None 194 | } 195 | }) 196 | } 197 | 198 | fn write_image_cache(images: &[Image]) { 199 | let cache = ImageCache { 200 | images: images.to_vec(), 201 | timestamp: SystemTime::now() 202 | .duration_since(UNIX_EPOCH) 203 | .map(|time| time.as_secs()) 204 | .unwrap_or_default(), 205 | }; 206 | 207 | // Write cache 208 | util::get_image_cache_file() 209 | .and_then(|path| File::create(path).map_err(Error::Io)) 210 | .and_then(|mut file| cache.serialize(&mut file)) 211 | .ok(); 212 | } 213 | 214 | pub fn create_images() -> Result, Error> { 215 | // Read cache 216 | if let Some(images) = Self::read_image_cache() { 217 | return Ok(images); 218 | } 219 | 220 | let web = &mut WebClient::new()?; 221 | let images: Vec = DISTROS 222 | .iter() 223 | .flat_map(|distro| Self::add_images(web, distro)) 224 | .collect(); 225 | 226 | // Write cache 227 | Self::write_image_cache(&images); 228 | 229 | Ok(images) 230 | } 231 | 232 | pub fn create_images_for_distro(name: &str) -> Result, Error> { 233 | let web = &mut WebClient::new()?; 234 | 235 | // Read cache 236 | if let Some(images) = Self::read_image_cache() { 237 | return Ok(images 238 | .into_iter() 239 | .filter(|image| image.vendor == name) 240 | .collect()); 241 | } 242 | 243 | let images: Vec = DISTROS 244 | .iter() 245 | .filter(|distro| distro.vendor == name) 246 | .flat_map(|distro| Self::add_images(web, distro)) 247 | .collect(); 248 | 249 | // Write cache 250 | Self::write_image_cache(&images); 251 | 252 | Ok(images) 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /src/image/image_fetcher.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::image::Image; 3 | use crate::view::TransferView; 4 | use crate::web::WebClient; 5 | 6 | pub struct ImageFetcher {} 7 | 8 | impl ImageFetcher { 9 | pub fn new() -> Self { 10 | ImageFetcher {} 11 | } 12 | 13 | pub fn fetch(&self, image: &Image, target_file: &str) -> Result<(), Error> { 14 | WebClient::new()?.download_file( 15 | &image.url, 16 | target_file, 17 | TransferView::new("Downloading image"), 18 | ) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/instance.rs: -------------------------------------------------------------------------------- 1 | pub mod instance_dao; 2 | pub mod instance_state; 3 | pub mod instance_store; 4 | pub mod instance_store_mock; 5 | 6 | use crate::arch::Arch; 7 | pub use crate::error::Error; 8 | pub use instance_dao::*; 9 | pub use instance_state::*; 10 | pub use instance_store::*; 11 | use serde::{Deserialize, Serialize}; 12 | use std::io::{Read, Write}; 13 | 14 | fn default_user() -> String { 15 | USER.to_string() 16 | } 17 | 18 | #[derive(PartialEq, Default, Debug, Clone, Serialize, Deserialize)] 19 | pub struct MountPoint { 20 | pub host: String, 21 | pub guest: String, 22 | } 23 | 24 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 25 | pub struct Instance { 26 | #[serde(skip)] 27 | pub name: String, 28 | #[serde(default)] 29 | pub arch: Arch, 30 | #[serde(default = "default_user")] 31 | pub user: String, 32 | pub cpus: u16, 33 | pub mem: u64, 34 | pub disk_capacity: u64, 35 | pub ssh_port: u16, 36 | #[serde(default)] 37 | pub display: bool, 38 | #[serde(default)] 39 | pub gpu: bool, 40 | #[serde(default)] 41 | pub mounts: Vec, 42 | #[serde(default)] 43 | pub hostfwd: Vec, 44 | } 45 | 46 | impl Instance { 47 | pub fn deserialize(name: &str, reader: &mut dyn Read) -> Result { 48 | serde_yaml::from_reader(reader) 49 | .map(|config: Config| config.machine) 50 | .map(|mut instance: Instance| { 51 | instance.name = name.to_string(); 52 | instance 53 | }) 54 | .map_err(|_| Error::CannotParseFile(String::new())) 55 | } 56 | 57 | pub fn serialize(&self, writer: &mut dyn Write) -> Result<(), Error> { 58 | serde_yaml::to_writer( 59 | writer, 60 | &Config { 61 | machine: self.clone(), 62 | }, 63 | ) 64 | .map_err(Error::SerdeYaml) 65 | } 66 | } 67 | 68 | #[cfg(test)] 69 | mod tests { 70 | use super::*; 71 | 72 | use std::io::BufReader; 73 | 74 | #[test] 75 | fn test_deserialize_empty_file() { 76 | let reader = &mut BufReader::new("".as_bytes()); 77 | let instance = Instance::deserialize("test", reader); 78 | assert!(instance.is_err()); 79 | } 80 | 81 | #[test] 82 | fn test_deserialize_minimal_config() { 83 | let reader = &mut BufReader::new( 84 | r#" 85 | machine: 86 | cpus: 1 87 | mem: 1073741824 88 | disk_capacity: 2361393152 89 | ssh_port: 14357 90 | "# 91 | .as_bytes(), 92 | ); 93 | 94 | let instance = Instance::deserialize("test", reader).expect("Cannot parser config"); 95 | assert_eq!(instance.name, "test"); 96 | assert_eq!(instance.user, "cubic"); 97 | assert_eq!(instance.cpus, 1); 98 | assert_eq!(instance.mem, 1073741824); 99 | assert_eq!(instance.disk_capacity, 2361393152); 100 | assert_eq!(instance.ssh_port, 14357); 101 | assert!(!instance.display); 102 | assert!(!instance.gpu); 103 | assert!(instance.mounts.is_empty()); 104 | assert!(instance.hostfwd.is_empty()); 105 | } 106 | 107 | #[test] 108 | fn test_deserialize_full_config() { 109 | let reader = &mut BufReader::new( 110 | r#" 111 | machine: 112 | user: tux 113 | cpus: 1 114 | mem: 1073741824 115 | disk_capacity: 2361393152 116 | ssh_port: 14357 117 | display: false 118 | gpu: false 119 | mounts: 120 | - host: /home/tux/guest 121 | guest: /home/tux 122 | hostfwd: ["tcp:127.0.0.1:8000-:8000", "tcp:127.0.0.1:9000-:10000"] 123 | "# 124 | .as_bytes(), 125 | ); 126 | 127 | let instance = Instance::deserialize("test", reader).expect("Cannot parser config"); 128 | assert_eq!(instance.name, "test"); 129 | assert_eq!(instance.user, "tux"); 130 | assert_eq!(instance.cpus, 1); 131 | assert_eq!(instance.mem, 1073741824); 132 | assert_eq!(instance.disk_capacity, 2361393152); 133 | assert_eq!(instance.ssh_port, 14357); 134 | assert!(!instance.display); 135 | assert!(!instance.gpu); 136 | assert_eq!( 137 | instance.mounts, 138 | [MountPoint { 139 | host: "/home/tux/guest".to_string(), 140 | guest: "/home/tux".to_string() 141 | }] 142 | ); 143 | assert_eq!( 144 | instance.hostfwd, 145 | ["tcp:127.0.0.1:8000-:8000", "tcp:127.0.0.1:9000-:10000"] 146 | ); 147 | } 148 | 149 | #[test] 150 | fn test_deserialize_desktop_config() { 151 | let reader = &mut BufReader::new( 152 | r#" 153 | machine: 154 | user: tux 155 | cpus: 1 156 | mem: 1073741824 157 | disk_capacity: 2361393152 158 | ssh_port: 14357 159 | display: true 160 | gpu: true 161 | mounts: 162 | hostfwd: 163 | "# 164 | .as_bytes(), 165 | ); 166 | 167 | let instance = Instance::deserialize("test", reader).expect("Cannot parser config"); 168 | assert_eq!(instance.name, "test"); 169 | assert_eq!(instance.user, "tux"); 170 | assert_eq!(instance.cpus, 1); 171 | assert_eq!(instance.mem, 1073741824); 172 | assert_eq!(instance.disk_capacity, 2361393152); 173 | assert_eq!(instance.ssh_port, 14357); 174 | assert!(instance.display); 175 | assert!(instance.gpu); 176 | assert!(instance.mounts.is_empty()); 177 | assert!(instance.hostfwd.is_empty()); 178 | } 179 | 180 | #[test] 181 | fn test_serialize_minimal_config() { 182 | let mut writer = Vec::new(); 183 | 184 | Instance { 185 | name: "test".to_string(), 186 | arch: Arch::AMD64, 187 | user: "tux".to_string(), 188 | cpus: 1, 189 | mem: 1000, 190 | disk_capacity: 1000, 191 | ssh_port: 10000, 192 | display: false, 193 | gpu: false, 194 | mounts: Vec::new(), 195 | hostfwd: Vec::new(), 196 | } 197 | .serialize(&mut writer) 198 | .expect("Cannot parser config"); 199 | let config = String::from_utf8(writer).unwrap(); 200 | 201 | assert_eq!( 202 | config, 203 | r#"machine: 204 | arch: AMD64 205 | user: tux 206 | cpus: 1 207 | mem: 1000 208 | disk_capacity: 1000 209 | ssh_port: 10000 210 | display: false 211 | gpu: false 212 | mounts: [] 213 | hostfwd: [] 214 | "# 215 | ); 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/instance/instance_dao.rs: -------------------------------------------------------------------------------- 1 | use crate::emulator::Emulator; 2 | use crate::error::Error; 3 | use crate::fs::FS; 4 | use crate::instance::{Instance, InstanceState, InstanceStore, MountPoint}; 5 | use crate::qemu::{GuestAgent, Monitor}; 6 | use crate::ssh_cmd::PortChecker; 7 | use crate::util; 8 | use serde::{Deserialize, Serialize}; 9 | use std::fs::DirEntry; 10 | use std::path::Path; 11 | use std::process::{Child, Command, Stdio}; 12 | use std::str; 13 | 14 | pub const USER: &str = "cubic"; 15 | 16 | #[derive(Debug, PartialEq, Serialize, Deserialize)] 17 | pub struct Config { 18 | pub machine: Instance, 19 | } 20 | 21 | pub struct InstanceDao { 22 | fs: FS, 23 | pub instance_dir: String, 24 | pub cache_dir: String, 25 | } 26 | 27 | impl InstanceDao { 28 | pub fn new() -> Result { 29 | let fs = FS::new(); 30 | let instance_dir = util::get_instance_data_dir()?; 31 | let xdg_runtime_dir = util::get_xdg_runtime_dir()?; 32 | let cache_dir = format!("{xdg_runtime_dir}/cubic/instances"); 33 | fs.setup_directory_access(&instance_dir)?; 34 | fs.setup_directory_access(&cache_dir)?; 35 | 36 | Result::Ok(InstanceDao { 37 | fs, 38 | instance_dir, 39 | cache_dir, 40 | }) 41 | } 42 | } 43 | 44 | impl InstanceStore for InstanceDao { 45 | fn get_instances(&self) -> Vec { 46 | self.fs 47 | .read_dir(&self.instance_dir) 48 | .map_err(|_| ()) 49 | .and_then(|entries| { 50 | entries 51 | .collect::, _>>() 52 | .map_err(|_| ()) 53 | }) 54 | .and_then(|entries| { 55 | entries 56 | .iter() 57 | .map(|entry| entry.file_name().to_str().map(|x| x.to_string()).ok_or(())) 58 | .collect() 59 | }) 60 | .unwrap_or_default() 61 | } 62 | 63 | fn exists(&self, name: &str) -> bool { 64 | Path::new(&format!("{}/{name}", self.instance_dir)).exists() 65 | } 66 | 67 | fn load(&self, name: &str) -> Result { 68 | if !self.exists(name) { 69 | return Result::Err(Error::UnknownInstance(name.to_string())); 70 | } 71 | 72 | self.fs 73 | .open_file(&format!("{}/{name}/machine.yaml", self.instance_dir)) 74 | .and_then(|mut file| Instance::deserialize(name, &mut file)) 75 | .or(Ok(Instance { 76 | name: name.to_string(), 77 | user: USER.to_string(), 78 | cpus: 1, 79 | mem: util::human_readable_to_bytes("1G").unwrap(), 80 | disk_capacity: util::human_readable_to_bytes("1G").unwrap(), 81 | ssh_port: util::generate_random_ssh_port(), 82 | ..Instance::default() 83 | })) 84 | } 85 | 86 | fn store(&self, instance: &Instance) -> Result<(), Error> { 87 | let path = format!("{}/{}", self.instance_dir, &instance.name); 88 | let file_name = format!("{path}/machine.yaml"); 89 | let temp_file_name = format!("{file_name}.tmp"); 90 | 91 | let mut file = self.fs.create_file(&temp_file_name)?; 92 | instance.serialize(&mut file)?; 93 | self.fs.rename_file(&temp_file_name, &file_name) 94 | } 95 | 96 | fn clone(&self, instance: &Instance, new_name: &str) -> Result<(), Error> { 97 | if self.exists(new_name) { 98 | Result::Err(Error::InstanceAlreadyExists(new_name.to_string())) 99 | } else if self.is_running(instance) { 100 | Result::Err(Error::InstanceNotStopped(instance.name.to_string())) 101 | } else { 102 | self.fs.copy_dir( 103 | &format!("{}/{}", self.instance_dir, instance.name), 104 | &format!("{}/{new_name}", self.instance_dir), 105 | ) 106 | } 107 | } 108 | 109 | fn rename(&self, instance: &mut Instance, new_name: &str) -> Result<(), Error> { 110 | if self.exists(new_name) { 111 | Result::Err(Error::InstanceAlreadyExists(new_name.to_string())) 112 | } else if self.is_running(instance) { 113 | Result::Err(Error::InstanceNotStopped(instance.name.to_string())) 114 | } else { 115 | self.fs.rename_file( 116 | &format!("{}/{}", self.instance_dir, instance.name), 117 | &format!("{}/{new_name}", self.instance_dir), 118 | )?; 119 | instance.name = new_name.to_string(); 120 | Result::Ok(()) 121 | } 122 | } 123 | 124 | fn resize(&self, instance: &mut Instance, size: u64) -> Result<(), Error> { 125 | if self.is_running(instance) { 126 | Result::Err(Error::InstanceNotStopped(instance.name.to_string())) 127 | } else if instance.disk_capacity >= size { 128 | Result::Err(Error::CannotShrinkDisk(instance.name.to_string())) 129 | } else { 130 | Command::new("qemu-img") 131 | .arg("resize") 132 | .arg(format!( 133 | "{}/{}/machine.img", 134 | self.instance_dir, instance.name 135 | )) 136 | .arg(size.to_string()) 137 | .stdin(Stdio::null()) 138 | .stdout(Stdio::null()) 139 | .stderr(Stdio::null()) 140 | .spawn() 141 | .map_err(Error::Io)? 142 | .wait() 143 | .map(|_| ()) 144 | .map_err(Error::Io)?; 145 | instance.disk_capacity = size; 146 | Result::Ok(()) 147 | } 148 | } 149 | 150 | fn delete(&self, instance: &Instance) -> Result<(), Error> { 151 | if self.is_running(instance) { 152 | Result::Err(Error::InstanceNotStopped(instance.name.to_string())) 153 | } else { 154 | self.fs 155 | .remove_dir(&format!("{}/{}", self.cache_dir, instance.name)) 156 | .ok(); 157 | self.fs 158 | .remove_dir(&format!("{}/{}", self.instance_dir, instance.name)) 159 | .ok(); 160 | Ok(()) 161 | } 162 | } 163 | 164 | fn start( 165 | &self, 166 | instance: &Instance, 167 | qemu_args: &Option, 168 | verbose: bool, 169 | ) -> Result { 170 | if self.is_running(instance) { 171 | return Result::Err(Error::InstanceIsRunning(instance.name.to_string())); 172 | } 173 | 174 | let instance_dir = format!("{}/{}", &self.instance_dir, &instance.name); 175 | let cache_dir = format!("{}/{}", &self.cache_dir, &instance.name); 176 | util::setup_cloud_init(instance, &cache_dir, false)?; 177 | 178 | let mut emulator = Emulator::from(instance.name.clone(), instance.arch)?; 179 | emulator.set_cpus(instance.cpus); 180 | emulator.set_memory(instance.mem); 181 | emulator.set_console(&format!("{cache_dir}/console")); 182 | emulator.add_drive(&format!("{instance_dir}/machine.img"), "qcow2"); 183 | emulator.add_drive(&format!("{cache_dir}/user-data.img"), "raw"); 184 | emulator.set_network(&instance.hostfwd, instance.ssh_port); 185 | for (index, MountPoint { ref host, .. }) in instance.mounts.iter().enumerate() { 186 | emulator.add_mount(&format!("cubicdev{index}"), host); 187 | } 188 | emulator.set_display(instance.display, instance.gpu); 189 | if let Some(ref args) = qemu_args { 190 | emulator.set_qemu_args(args); 191 | } 192 | emulator.set_verbose(verbose); 193 | emulator.set_pid_file(&format!("{cache_dir}/qemu.pid")); 194 | 195 | if let Ok(qemu_root) = std::env::var("SNAP") { 196 | emulator.add_env( 197 | "QEMU_MODULE_DIR", 198 | "/snap/cubic/current/usr/lib/x86_64-linux-gnu/qemu", 199 | ); 200 | emulator.add_search_path(&format!("{qemu_root}/usr/share/qemu")); 201 | emulator.add_search_path(&format!("{qemu_root}/usr/share/qemu-efi-aarch64")); 202 | emulator.add_search_path(&format!("{qemu_root}/usr/share/seabios")); 203 | emulator.add_search_path(&format!("{qemu_root}/usr/lib/ipxe/qemu")); 204 | } 205 | 206 | emulator.add_qmp("qmp", &format!("{cache_dir}/monitor.socket")); 207 | emulator.add_guest_agent("guest-agent", &format!("{cache_dir}/guest-agent.socket")); 208 | 209 | emulator.add_virtio_serial("sh_serial"); 210 | emulator.run() 211 | } 212 | 213 | fn stop(&self, instance: &Instance) -> Result<(), Error> { 214 | if !self.is_running(instance) { 215 | return Result::Ok(()); 216 | } 217 | 218 | self.get_monitor(instance)?.shutdown() 219 | } 220 | 221 | fn get_state(&self, instance: &Instance) -> InstanceState { 222 | if self.is_running(instance) { 223 | let ga = self.get_guest_agent(instance); 224 | if ga.and_then(|mut ga| ga.ping()).is_ok() 225 | || PortChecker::new(instance.ssh_port).try_connect() 226 | { 227 | InstanceState::Running 228 | } else { 229 | InstanceState::Starting 230 | } 231 | } else { 232 | InstanceState::Stopped 233 | } 234 | } 235 | 236 | fn is_running(&self, instance: &Instance) -> bool { 237 | self.get_pid(instance) 238 | .map(|pid| Path::new(&format!("/proc/{pid}")).exists()) 239 | .unwrap_or(false) 240 | } 241 | 242 | fn get_pid(&self, instance: &Instance) -> Result { 243 | let pid = self 244 | .fs 245 | .read_file_to_string(&format!("{}/{}/qemu.pid", self.cache_dir, instance.name)) 246 | .map_err(|_| ())?; 247 | 248 | pid.trim().parse::().map_err(|_| ()) 249 | } 250 | 251 | fn get_monitor(&self, instance: &Instance) -> Result { 252 | Monitor::new(&format!("{}/{}", self.cache_dir, &instance.name)) 253 | } 254 | 255 | fn get_guest_agent(&self, instance: &Instance) -> Result { 256 | GuestAgent::new(&format!( 257 | "{}/{}/guest-agent.socket", 258 | self.cache_dir, &instance.name 259 | )) 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /src/instance/instance_state.rs: -------------------------------------------------------------------------------- 1 | #[derive(PartialEq)] 2 | pub enum InstanceState { 3 | Stopped, 4 | Starting, 5 | Running, 6 | } 7 | -------------------------------------------------------------------------------- /src/instance/instance_store.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::instance::{Instance, InstanceState}; 3 | use crate::qemu::{GuestAgent, Monitor}; 4 | use std::process::Child; 5 | use std::str; 6 | 7 | pub trait InstanceStore { 8 | fn get_instances(&self) -> Vec; 9 | fn exists(&self, name: &str) -> bool; 10 | fn load(&self, name: &str) -> Result; 11 | fn store(&self, instance: &Instance) -> Result<(), Error>; 12 | 13 | fn clone(&self, instance: &Instance, new_name: &str) -> Result<(), Error>; 14 | fn rename(&self, instance: &mut Instance, new_name: &str) -> Result<(), Error>; 15 | fn resize(&self, instance: &mut Instance, size: u64) -> Result<(), Error>; 16 | fn delete(&self, instance: &Instance) -> Result<(), Error>; 17 | 18 | fn start( 19 | &self, 20 | instance: &Instance, 21 | qemu_args: &Option, 22 | verbose: bool, 23 | ) -> Result; 24 | fn stop(&self, instance: &Instance) -> Result<(), Error>; 25 | fn get_state(&self, instance: &Instance) -> InstanceState; 26 | fn is_running(&self, instance: &Instance) -> bool; 27 | fn get_pid(&self, instance: &Instance) -> Result; 28 | fn get_monitor(&self, instance: &Instance) -> Result; 29 | fn get_guest_agent(&self, instance: &Instance) -> Result; 30 | } 31 | -------------------------------------------------------------------------------- /src/instance/instance_store_mock.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | pub mod tests { 3 | 4 | use crate::error::Error; 5 | use crate::instance::{Instance, InstanceState, InstanceStore}; 6 | use crate::qemu::{GuestAgent, Monitor}; 7 | use std::process::Child; 8 | 9 | pub struct InstanceStoreMock { 10 | instances: Vec, 11 | } 12 | 13 | impl InstanceStoreMock { 14 | pub fn new(instances: Vec) -> Self { 15 | Self { instances } 16 | } 17 | } 18 | 19 | impl InstanceStore for InstanceStoreMock { 20 | fn get_instances(&self) -> Vec { 21 | self.instances.iter().map(|i| i.name.clone()).collect() 22 | } 23 | 24 | fn exists(&self, name: &str) -> bool { 25 | self.instances.iter().any(|i| i.name == name) 26 | } 27 | 28 | fn load(&self, name: &str) -> Result { 29 | self.instances 30 | .iter() 31 | .find(|i| i.name == name) 32 | .cloned() 33 | .ok_or(Error::UnknownInstance(name.to_string())) 34 | } 35 | 36 | fn store(&self, _instance: &Instance) -> Result<(), Error> { 37 | Result::Err(Error::UnknownCommand) 38 | } 39 | 40 | fn clone(&self, _instance: &Instance, _new_name: &str) -> Result<(), Error> { 41 | Result::Err(Error::UnknownCommand) 42 | } 43 | 44 | fn rename(&self, _instance: &mut Instance, _new_name: &str) -> Result<(), Error> { 45 | Result::Err(Error::UnknownCommand) 46 | } 47 | 48 | fn resize(&self, _instance: &mut Instance, _size: u64) -> Result<(), Error> { 49 | Result::Err(Error::UnknownCommand) 50 | } 51 | 52 | fn delete(&self, _instance: &Instance) -> Result<(), Error> { 53 | Result::Err(Error::UnknownCommand) 54 | } 55 | 56 | fn start( 57 | &self, 58 | _instance: &Instance, 59 | _qemu_args: &Option, 60 | _verbose: bool, 61 | ) -> Result { 62 | Result::Err(Error::UnknownCommand) 63 | } 64 | 65 | fn stop(&self, _instance: &Instance) -> Result<(), Error> { 66 | Result::Err(Error::UnknownCommand) 67 | } 68 | 69 | fn get_state(&self, _instance: &Instance) -> InstanceState { 70 | InstanceState::Stopped 71 | } 72 | 73 | fn is_running(&self, _instance: &Instance) -> bool { 74 | false 75 | } 76 | 77 | fn get_pid(&self, _instance: &Instance) -> Result { 78 | Result::Err(()) 79 | } 80 | 81 | fn get_monitor(&self, _instance: &Instance) -> Result { 82 | Result::Err(Error::UnknownCommand) 83 | } 84 | 85 | fn get_guest_agent(&self, _instance: &Instance) -> Result { 86 | Result::Err(Error::UnknownCommand) 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod arch; 2 | mod commands; 3 | mod emulator; 4 | mod error; 5 | mod fs; 6 | mod image; 7 | mod instance; 8 | mod qemu; 9 | mod ssh_cmd; 10 | mod util; 11 | mod view; 12 | mod web; 13 | 14 | use crate::commands::CommandDispatcher; 15 | 16 | fn main() { 17 | util::migrate(); 18 | 19 | CommandDispatcher::new() 20 | .dispatch() 21 | .map_err(error::print_error) 22 | .ok(); 23 | } 24 | -------------------------------------------------------------------------------- /src/qemu.rs: -------------------------------------------------------------------------------- 1 | pub mod guest_agent; 2 | pub mod monitor; 3 | pub mod qmp; 4 | pub mod qmp_message; 5 | 6 | pub use guest_agent::*; 7 | pub use monitor::*; 8 | pub use qmp::*; 9 | pub use qmp_message::*; 10 | -------------------------------------------------------------------------------- /src/qemu/guest_agent.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::Verbosity; 2 | use crate::error::Error; 3 | use crate::qemu::{Qmp, QmpMessage}; 4 | use std::thread; 5 | use std::time::Duration; 6 | 7 | pub struct GuestAgent { 8 | qmp: Qmp, 9 | } 10 | 11 | impl GuestAgent { 12 | pub fn new(path: &str) -> Result { 13 | Ok(GuestAgent { 14 | qmp: Qmp::new(path, Verbosity::Normal)?, 15 | }) 16 | } 17 | 18 | pub fn sync(&mut self) -> Result<(), Error> { 19 | let arg: serde_json::Value = serde_json::json!({"id": 1}); 20 | 21 | while self 22 | .qmp 23 | .execute_with_args("guest-sync", arg.clone()) 24 | .is_err() 25 | { 26 | thread::sleep(Duration::from_millis(100)); 27 | } 28 | 29 | Ok(()) 30 | } 31 | 32 | pub fn ping(&mut self) -> Result<(), Error> { 33 | self.qmp.execute("guest-ping").map(|_| ()) 34 | } 35 | 36 | pub fn exec(&mut self, program: &str, args: &[String], env: &[String]) -> Result { 37 | let arg = serde_json::json!({ 38 | "path": program, 39 | "arg": args.to_vec(), 40 | "env": env.to_vec() 41 | }); 42 | let response = self.qmp.execute_with_args("guest-exec", arg); 43 | if let Ok(QmpMessage::Success { 44 | ret: serde_json::Value::Object(fields), 45 | .. 46 | }) = response 47 | { 48 | if let Some(serde_json::Value::Number(pid)) = fields.get("pid") { 49 | return Ok(pid.as_u64().unwrap()); 50 | } 51 | } 52 | 53 | Err(Error::ExecFailed) 54 | } 55 | 56 | pub fn get_exec_status(&mut self, pid: u64) -> Result { 57 | let arg = serde_json::json!({"pid": pid}); 58 | let response = self.qmp.execute_with_args("guest-exec-status", arg); 59 | if let Ok(QmpMessage::Success { 60 | ret: serde_json::Value::Object(fields), 61 | .. 62 | }) = response 63 | { 64 | if let Some(serde_json::Value::Bool(value)) = fields.get("exited") { 65 | return Ok(*value); 66 | } 67 | } 68 | 69 | Ok(false) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/qemu/monitor.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::Verbosity; 2 | use crate::error::Error; 3 | use crate::qemu::{Qmp, QmpMessage}; 4 | use std::thread; 5 | use std::time::Duration; 6 | 7 | pub struct Monitor { 8 | path: String, 9 | qmp: Qmp, 10 | } 11 | 12 | impl Monitor { 13 | pub fn new(path: &str) -> Result { 14 | let mut monitor = Monitor { 15 | path: path.to_string(), 16 | qmp: Qmp::new(&format!("{path}/monitor.socket"), Verbosity::Normal)?, 17 | }; 18 | monitor.init()?; 19 | Ok(monitor) 20 | } 21 | 22 | pub fn init(&mut self) -> Result<(), Error> { 23 | self.qmp.recv().map(|_| ())?; 24 | self.qmp.execute("qmp_capabilities").map(|_| ()) 25 | } 26 | 27 | pub fn add_unix_socket_chardev(&mut self, id: &str) -> Result<(), Error> { 28 | self.qmp 29 | .execute_with_args( 30 | "chardev-add", 31 | serde_json::json!({ 32 | "id": id, 33 | "backend": { 34 | "type": "socket", 35 | "data": { 36 | "addr" : { 37 | "type" : "unix", 38 | "data" : { "path": format!("{}/{id}.socket", self.path) } 39 | }, 40 | "server": true, 41 | "wait": false 42 | } 43 | } 44 | }), 45 | ) 46 | .map(|_| ()) 47 | } 48 | 49 | pub fn delete_chardev(&mut self, id: &str) -> Result<(), Error> { 50 | for _ in 0..10 { 51 | let result = self 52 | .qmp 53 | .execute_with_args("chardev-remove", serde_json::json!({"id": id })); 54 | 55 | if let Result::Ok(QmpMessage::Success { .. }) = result { 56 | break; 57 | } 58 | 59 | thread::sleep(Duration::from_millis(100)); 60 | } 61 | 62 | Ok(()) 63 | } 64 | 65 | pub fn add_virtserialport_device(&mut self, name: &str, chardev_id: &str) -> Result<(), Error> { 66 | self.qmp 67 | .execute_with_args( 68 | "device_add", 69 | serde_json::json!({ 70 | "id": name, 71 | "driver": "virtserialport", 72 | "bus": "sh_serial.0", 73 | "chardev": chardev_id, 74 | "name": name 75 | }), 76 | ) 77 | .map(|_| ()) 78 | } 79 | 80 | pub fn delete_device(&mut self, id: &str) -> Result<(), Error> { 81 | self.qmp 82 | .execute_with_args("device_del", serde_json::json!({"id": id })) 83 | .map(|_| ()) 84 | } 85 | 86 | pub fn shutdown(&mut self) -> Result<(), Error> { 87 | self.qmp.execute("system_powerdown") 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/qemu/qmp.rs: -------------------------------------------------------------------------------- 1 | use crate::commands::Verbosity; 2 | use crate::error::Error; 3 | use crate::qemu; 4 | use serde_json::Value; 5 | use std::io::{prelude::*, BufReader, BufWriter, Read, Write}; 6 | use std::os::unix::net::UnixStream; 7 | use std::time::Duration; 8 | 9 | const QMP_TIMEOUT_MS: u64 = 100; 10 | 11 | pub struct Qmp { 12 | counter: u64, 13 | verbosity: Verbosity, 14 | reader: BufReader>, 15 | writer: BufWriter>, 16 | } 17 | 18 | impl Qmp { 19 | pub fn new(path: &str, verbosity: Verbosity) -> Result { 20 | let socket = UnixStream::connect(path).map_err(Error::Io)?; 21 | 22 | let get_timeout = || Some(Duration::from_millis(QMP_TIMEOUT_MS)); 23 | socket.set_read_timeout(get_timeout()).map_err(Error::Io)?; 24 | socket.set_write_timeout(get_timeout()).map_err(Error::Io)?; 25 | 26 | Ok(Qmp { 27 | counter: 0, 28 | verbosity, 29 | reader: BufReader::new(Box::new(socket.try_clone().map_err(Error::Io)?)), 30 | writer: BufWriter::new(Box::new(socket.try_clone().map_err(Error::Io)?)), 31 | }) 32 | } 33 | 34 | pub fn send(&mut self, message: &qemu::QmpMessage) -> Result<(), Error> { 35 | let request = serde_json::to_string(message).map_err(Error::SerdeJson)?; 36 | 37 | if self.verbosity.is_verbose() { 38 | println!("QMP send: {request}"); 39 | } 40 | self.writer.write(request.as_bytes()).map_err(Error::Io)?; 41 | self.writer.flush().map_err(Error::Io) 42 | } 43 | 44 | pub fn recv(&mut self) -> Result { 45 | let mut response = String::new(); 46 | self.reader.read_line(&mut response).map_err(Error::Io)?; 47 | 48 | if self.verbosity.is_verbose() { 49 | println!("QMP recv: {response}"); 50 | } 51 | 52 | serde_json::from_str(&response).map_err(Error::SerdeJson) 53 | } 54 | 55 | pub fn execute_with_args( 56 | &mut self, 57 | cmd: &str, 58 | arguments: Value, 59 | ) -> Result { 60 | let request_id = Some(self.counter.to_string()); 61 | self.counter += 1; 62 | 63 | self.send(&qemu::QmpMessage::Command { 64 | id: request_id.clone(), 65 | execute: cmd.to_string(), 66 | arguments, 67 | })?; 68 | 69 | loop { 70 | let response = self.recv()?; 71 | match &response { 72 | qemu::QmpMessage::Success { id, .. } | qemu::QmpMessage::Error { id, .. } 73 | if *id == request_id => 74 | { 75 | return Ok(response) 76 | } 77 | _ => {} 78 | } 79 | } 80 | } 81 | 82 | pub fn execute(&mut self, cmd: &str) -> Result<(), Error> { 83 | self.execute_with_args(cmd, Value::Null).map(|_| ()) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/qemu/qmp_message.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | use serde_json::Value; 3 | 4 | #[derive(Debug, Serialize, Deserialize)] 5 | pub struct QmpTimestamp { 6 | seconds: i64, 7 | microseconds: i64, 8 | } 9 | 10 | #[derive(Debug, Serialize, Deserialize)] 11 | pub struct QmpError { 12 | class: String, 13 | desc: String, 14 | } 15 | 16 | #[derive(Debug, Serialize, Deserialize)] 17 | #[serde(untagged)] 18 | pub enum QmpMessage { 19 | Greeting { 20 | #[serde(alias = "QMP")] 21 | qmp: Value, 22 | }, 23 | 24 | Command { 25 | #[serde(skip_serializing_if = "Option::is_none")] 26 | id: Option, 27 | execute: String, 28 | #[serde(skip_serializing_if = "Value::is_null")] 29 | arguments: Value, 30 | }, 31 | 32 | Event { 33 | event: String, 34 | #[serde(skip_serializing_if = "Option::is_none")] 35 | data: Option, 36 | timestamp: QmpTimestamp, 37 | }, 38 | 39 | Success { 40 | #[serde(skip_serializing_if = "Option::is_none")] 41 | id: Option, 42 | #[serde(alias = "return")] 43 | ret: Value, 44 | }, 45 | 46 | Error { 47 | #[serde(skip_serializing_if = "Option::is_none")] 48 | id: Option, 49 | error: QmpError, 50 | }, 51 | } 52 | -------------------------------------------------------------------------------- /src/ssh_cmd.rs: -------------------------------------------------------------------------------- 1 | mod port_checker; 2 | mod scp; 3 | mod ssh; 4 | 5 | use crate::error::Error; 6 | use crate::fs::FS; 7 | pub use port_checker::PortChecker; 8 | pub use scp::Scp; 9 | pub use ssh::Ssh; 10 | use std::env; 11 | use std::fs::DirEntry; 12 | 13 | fn get_ssh_key_dirs() -> Vec { 14 | ["SNAP_REAL_HOME", "HOME"] 15 | .iter() 16 | .filter_map(|var| env::var(var).ok()) 17 | .map(|dir| format!("{dir}/.ssh")) 18 | .collect() 19 | } 20 | 21 | fn get_ssh_keys() -> Vec { 22 | get_ssh_key_dirs() 23 | .iter() 24 | .filter_map(|dir| FS::new().read_dir(dir).ok()) 25 | .flatten() 26 | .filter_map(|item| item.ok()) 27 | .filter(|item| { 28 | item.file_name() 29 | .to_str() 30 | .map(|name| name.starts_with("id_")) 31 | .unwrap_or_default() 32 | }) 33 | .collect() 34 | } 35 | 36 | pub fn get_ssh_private_key_names() -> Result, Error> { 37 | let mut keys = Vec::new(); 38 | 39 | for entry in get_ssh_keys() { 40 | let path = entry.path(); 41 | let extension = path 42 | .extension() 43 | .unwrap_or_default() 44 | .to_str() 45 | .unwrap_or_default(); 46 | if extension.is_empty() { 47 | keys.push(path.as_os_str().to_str().unwrap().to_string()); 48 | } 49 | } 50 | 51 | if keys.is_empty() { 52 | return Result::Err(Error::MissingSshKey); 53 | } 54 | 55 | Ok(keys) 56 | } 57 | 58 | pub fn get_ssh_pub_keys() -> Result, Error> { 59 | get_ssh_private_key_names().map(|key| { 60 | key.iter() 61 | .filter_map(|path| FS::new().read_file_to_string(&format!("{path}.pub")).ok()) 62 | .map(|content| content.trim().to_string()) 63 | .collect() 64 | }) 65 | } 66 | -------------------------------------------------------------------------------- /src/ssh_cmd/port_checker.rs: -------------------------------------------------------------------------------- 1 | use std::net::TcpStream; 2 | use std::time::Duration; 3 | 4 | pub struct PortChecker { 5 | pub port: u16, 6 | } 7 | 8 | impl PortChecker { 9 | pub fn new(port: u16) -> Self { 10 | PortChecker { port } 11 | } 12 | 13 | pub fn try_connect(&self) -> bool { 14 | let mut buf = [0]; 15 | TcpStream::connect(format!("127.0.0.1:{}", &self.port)) 16 | .and_then(|stream| { 17 | stream.set_read_timeout(Some(Duration::new(0, 100000000)))?; 18 | stream.peek(&mut buf) 19 | }) 20 | .is_ok() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/ssh_cmd/scp.rs: -------------------------------------------------------------------------------- 1 | use crate::fs::FS; 2 | use crate::util; 3 | use std::path::Path; 4 | use std::process::Command; 5 | 6 | #[derive(Default)] 7 | pub struct Scp { 8 | root_dir: String, 9 | known_hosts_file: Option, 10 | private_keys: Vec, 11 | args: String, 12 | verbose: bool, 13 | } 14 | 15 | impl Scp { 16 | pub fn new() -> Self { 17 | Scp::default() 18 | } 19 | 20 | pub fn set_root_dir(&mut self, root_dir: &str) -> &mut Self { 21 | self.root_dir = root_dir.to_string(); 22 | self 23 | } 24 | 25 | pub fn set_known_hosts_file(&mut self, path: Option) -> &mut Self { 26 | self.known_hosts_file = path; 27 | self 28 | } 29 | 30 | pub fn set_private_keys(&mut self, private_keys: Vec) -> &mut Self { 31 | self.private_keys = private_keys; 32 | self 33 | } 34 | 35 | pub fn set_args(&mut self, args: &str) -> &mut Self { 36 | self.args = args.to_string(); 37 | self 38 | } 39 | 40 | pub fn set_verbose(&mut self, verbose: bool) -> &mut Self { 41 | self.verbose = verbose; 42 | self 43 | } 44 | 45 | pub fn copy(&self, from: &str, to: &str) -> Command { 46 | let mut command = Command::new(format!("{}/usr/bin/scp", self.root_dir)); 47 | 48 | if let Some(ref known_hosts_file) = self.known_hosts_file { 49 | Path::new(known_hosts_file) 50 | .parent() 51 | .and_then(|dir| dir.to_str()) 52 | .map(|dir| FS::new().create_dir(dir)); 53 | 54 | command.arg(format!("-oUserKnownHostsFile={known_hosts_file}")); 55 | } 56 | 57 | command 58 | .arg("-3") 59 | .arg("-r") 60 | .arg(format!("-S{}/usr/bin/ssh", self.root_dir)) 61 | .args( 62 | self.private_keys 63 | .iter() 64 | .map(|key| format!("-i{key}")) 65 | .collect::>(), 66 | ) 67 | .args(self.args.split(' ').filter(|item| !item.is_empty())) 68 | .arg(from) 69 | .arg(to); 70 | 71 | if self.verbose { 72 | util::print_command(&command); 73 | } 74 | 75 | command 76 | } 77 | } 78 | 79 | #[cfg(test)] 80 | mod tests { 81 | use super::*; 82 | use std::ffi::OsStr; 83 | 84 | #[test] 85 | fn test_scp_minimal() { 86 | let cmd = Scp::new().copy("/from/file", "/to/file"); 87 | let args: Vec<&OsStr> = cmd.get_args().collect(); 88 | 89 | assert_eq!(cmd.get_program(), "/usr/bin/scp"); 90 | assert_eq!( 91 | args, 92 | &["-3", "-r", "-S/usr/bin/ssh", "/from/file", "/to/file"] 93 | ); 94 | } 95 | 96 | #[test] 97 | fn test_scp_minimal_snap() { 98 | let cmd = Scp::new() 99 | .set_root_dir("/snap/cubic/current") 100 | .copy("/from/file", "/to/file"); 101 | let args: Vec<&OsStr> = cmd.get_args().collect(); 102 | 103 | assert_eq!(cmd.get_program(), "/snap/cubic/current/usr/bin/scp"); 104 | assert_eq!( 105 | args, 106 | &[ 107 | "-3", 108 | "-r", 109 | "-S/snap/cubic/current/usr/bin/ssh", 110 | "/from/file", 111 | "/to/file" 112 | ] 113 | ); 114 | } 115 | 116 | #[test] 117 | fn test_scp_advanced() { 118 | let cmd = Scp::new() 119 | .set_root_dir("/snap/cubic/current") 120 | .set_verbose(true) 121 | .set_known_hosts_file(Some("/home/test/.ssh/known_hosts".to_string())) 122 | .set_private_keys(vec![ 123 | "/home/cubic/.ssh/id_rsa".to_string(), 124 | "/home/cubic/.ssh/id_ed25519".to_string(), 125 | ]) 126 | .set_args("-myarg1 -myarg2 -myarg3") 127 | .copy("/from/file", "/to/file"); 128 | let args: Vec<&OsStr> = cmd.get_args().collect(); 129 | 130 | assert_eq!(cmd.get_program(), "/snap/cubic/current/usr/bin/scp"); 131 | assert_eq!( 132 | args, 133 | &[ 134 | "-oUserKnownHostsFile=/home/test/.ssh/known_hosts", 135 | "-3", 136 | "-r", 137 | "-S/snap/cubic/current/usr/bin/ssh", 138 | "-i/home/cubic/.ssh/id_rsa", 139 | "-i/home/cubic/.ssh/id_ed25519", 140 | "-myarg1", 141 | "-myarg2", 142 | "-myarg3", 143 | "/from/file", 144 | "/to/file" 145 | ] 146 | ); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/ssh_cmd/ssh.rs: -------------------------------------------------------------------------------- 1 | use crate::fs::FS; 2 | use crate::util; 3 | use std::path::Path; 4 | use std::process::Command; 5 | 6 | #[derive(Default)] 7 | pub struct Ssh { 8 | known_hosts_file: Option, 9 | private_keys: Vec, 10 | user: String, 11 | port: Option, 12 | args: String, 13 | verbose: bool, 14 | xforward: bool, 15 | cmd: Option, 16 | } 17 | 18 | impl Ssh { 19 | pub fn new() -> Self { 20 | Ssh::default() 21 | } 22 | 23 | pub fn set_known_hosts_file(&mut self, path: Option) -> &mut Self { 24 | self.known_hosts_file = path; 25 | self 26 | } 27 | 28 | pub fn set_private_keys(&mut self, private_keys: Vec) -> &mut Self { 29 | self.private_keys = private_keys; 30 | self 31 | } 32 | 33 | pub fn set_user(&mut self, user: String) -> &mut Self { 34 | self.user = user; 35 | self 36 | } 37 | 38 | pub fn set_port(&mut self, port: Option) -> &mut Self { 39 | self.port = port; 40 | self 41 | } 42 | 43 | pub fn set_args(&mut self, args: String) -> &mut Self { 44 | self.args = args; 45 | self 46 | } 47 | 48 | pub fn set_verbose(&mut self, verbose: bool) -> &mut Self { 49 | self.verbose = verbose; 50 | self 51 | } 52 | 53 | pub fn set_xforward(&mut self, xforward: bool) -> &mut Self { 54 | self.xforward = xforward; 55 | self 56 | } 57 | 58 | pub fn set_cmd(&mut self, cmd: Option) -> &mut Self { 59 | self.cmd = cmd; 60 | self 61 | } 62 | 63 | pub fn connect(&self) -> Command { 64 | let mut command = Command::new("ssh"); 65 | 66 | if let Some(ref known_hosts_file) = self.known_hosts_file { 67 | Path::new(known_hosts_file) 68 | .parent() 69 | .and_then(|dir| dir.to_str()) 70 | .map(|dir| FS::new().create_dir(dir)); 71 | 72 | command.arg(format!("-oUserKnownHostsFile={known_hosts_file}")); 73 | } 74 | 75 | command 76 | .args(self.port.map(|port| format!("-p{port}")).as_slice()) 77 | .args( 78 | self.private_keys 79 | .iter() 80 | .map(|key| format!("-i{key}")) 81 | .collect::>(), 82 | ) 83 | .args(self.xforward.then_some("-X").as_slice()) 84 | .args(self.args.split(' ').filter(|item| !item.is_empty())) 85 | .arg(format!("{}@127.0.0.1", self.user)) 86 | .args(self.cmd.as_slice()); 87 | 88 | if self.verbose { 89 | util::print_command(&command); 90 | } 91 | 92 | command 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/util.rs: -------------------------------------------------------------------------------- 1 | pub mod env; 2 | pub mod generate_random_ssh_port; 3 | pub mod input; 4 | pub mod migration; 5 | pub mod process; 6 | pub mod qemu; 7 | pub mod terminal; 8 | 9 | pub use env::*; 10 | pub use generate_random_ssh_port::*; 11 | pub use input::*; 12 | pub use migration::*; 13 | pub use process::*; 14 | pub use qemu::*; 15 | pub use terminal::*; 16 | -------------------------------------------------------------------------------- /src/util/env.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use std::env; 3 | 4 | const HOME_ENV: &str = "HOME"; 5 | const SNAP_COMMON_ENV: &str = "SNAP_USER_COMMON"; 6 | const XDG_RUNTIME_DIR_ENV: &str = "XDG_RUNTIME_DIR"; 7 | 8 | pub fn get_data_dir() -> Result { 9 | env::var(SNAP_COMMON_ENV).or(env::var(HOME_ENV) 10 | .map(|home_dir| format!("{home_dir}/.local/share")) 11 | .map_err(|_| Error::UnsetEnvVar(HOME_ENV.to_string()))) 12 | } 13 | 14 | pub fn get_instance_data_dir() -> Result { 15 | get_data_dir().map(|dir| format!("{dir}/cubic/machines")) 16 | } 17 | 18 | pub fn get_image_data_dir() -> Result { 19 | get_data_dir().map(|dir| format!("{dir}/cubic/images")) 20 | } 21 | 22 | pub fn get_xdg_runtime_dir() -> Result { 23 | env::var(XDG_RUNTIME_DIR_ENV).map_err(|_| Error::UnsetEnvVar(XDG_RUNTIME_DIR_ENV.to_string())) 24 | } 25 | 26 | pub fn get_image_cache_file() -> Result { 27 | get_xdg_runtime_dir().map(|dir| format!("{dir}/cubic/images.cache")) 28 | } 29 | -------------------------------------------------------------------------------- /src/util/generate_random_ssh_port.rs: -------------------------------------------------------------------------------- 1 | use std::time::{SystemTime, UNIX_EPOCH}; 2 | 3 | const PORT_MIN: u128 = 1024; 4 | const PORT_MAX: u128 = 65535; 5 | 6 | pub fn generate_random_ssh_port() -> u16 { 7 | (SystemTime::now() 8 | .duration_since(UNIX_EPOCH) 9 | .unwrap() 10 | .as_millis() 11 | % (PORT_MAX - PORT_MIN) 12 | + PORT_MIN) as u16 13 | } 14 | -------------------------------------------------------------------------------- /src/util/input.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, Write}; 2 | 3 | pub fn confirm(text: &str) -> bool { 4 | let mut reply = String::new(); 5 | 6 | print!("{text}"); 7 | std::io::stdout().flush().unwrap(); 8 | io::stdin().read_line(&mut reply).unwrap(); 9 | reply.trim() == "y" 10 | } 11 | -------------------------------------------------------------------------------- /src/util/migration.rs: -------------------------------------------------------------------------------- 1 | use crate::fs::FS; 2 | use std::env; 3 | use std::path::Path; 4 | 5 | pub fn migrate() { 6 | // Move data from SNAP_USER_DATA to SNAP_COMMON_DATA 7 | if let Ok(user_data) = env::var("SNAP_USER_DATA") { 8 | if let Ok(user_common) = env::var("SNAP_USER_COMMON") { 9 | let from = format!("{user_data}/.local/share/cubic"); 10 | let to = format!("{user_common}/cubic"); 11 | if Path::new(&from).exists() && !Path::new(&to).exists() { 12 | FS::new().move_dir(&from, &to).ok(); 13 | } 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/util/process.rs: -------------------------------------------------------------------------------- 1 | use std::process::Command; 2 | 3 | pub fn print_command(command: &Command) { 4 | print!("{}", command.get_program().to_str().unwrap_or("n/a")); 5 | for arg in command.get_args() { 6 | print!(" {}", arg.to_str().unwrap_or("n/a")); 7 | } 8 | println!(); 9 | } 10 | -------------------------------------------------------------------------------- /src/util/qemu.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::fs::FS; 3 | use crate::instance::{Instance, MountPoint}; 4 | use crate::ssh_cmd::get_ssh_pub_keys; 5 | use serde_json::Value::{self, Number}; 6 | use std::path::Path; 7 | use std::process::{Command, Stdio}; 8 | use std::str; 9 | 10 | fn run_qemu_info(path: &str) -> Result { 11 | let out = Command::new("qemu-img") 12 | .arg("info") 13 | .arg("--output=json") 14 | .arg(path) 15 | .stdout(Stdio::piped()) 16 | .stderr(Stdio::null()) 17 | .output() 18 | .map_err(|_| Error::GetCapacityFailed(path.to_string()))? 19 | .stdout; 20 | 21 | let out_str = str::from_utf8(&out).map_err(|_| Error::GetCapacityFailed(path.to_string()))?; 22 | 23 | serde_json::from_str(out_str).map_err(|_| Error::GetCapacityFailed(path.to_string())) 24 | } 25 | 26 | pub fn get_disk_capacity(path: &str) -> Result { 27 | let json: Value = run_qemu_info(path)?; 28 | 29 | match &json["virtual-size"] { 30 | Number(number) => number 31 | .as_u64() 32 | .ok_or(Error::GetCapacityFailed(path.to_string())), 33 | _ => Result::Err(Error::GetCapacityFailed(path.to_string())), 34 | } 35 | } 36 | 37 | pub fn bytes_to_human_readable(bytes: u64) -> String { 38 | match bytes.checked_ilog(1024) { 39 | Some(1) => format!("{:3.1} KiB", bytes as f64 / 1024_f64.powf(1_f64)), 40 | Some(2) => format!("{:3.1} MiB", bytes as f64 / 1024_f64.powf(2_f64)), 41 | Some(3) => format!("{:3.1} GiB", bytes as f64 / 1024_f64.powf(3_f64)), 42 | Some(4) => format!("{:3.1} TiB", bytes as f64 / 1024_f64.powf(4_f64)), 43 | _ => format!("{:3.1} B", bytes as f64), 44 | } 45 | } 46 | 47 | pub fn human_readable_to_bytes(size: &str) -> Result { 48 | if size.is_empty() { 49 | return Result::Err(Error::CannotParseSize(size.to_string())); 50 | } 51 | 52 | let suffix: char = size.bytes().last().unwrap() as char; 53 | let size = &size[..size.len() - 1]; 54 | let power = match suffix { 55 | 'B' => 0, 56 | 'K' => 1, 57 | 'M' => 2, 58 | 'G' => 3, 59 | 'T' => 4, 60 | _ => return Result::Err(Error::CannotParseSize(size.to_string())), 61 | }; 62 | 63 | size.parse() 64 | .map(|size: u64| size * 1024_u64.pow(power)) 65 | .map_err(|_| Error::CannotParseSize(size.to_string())) 66 | } 67 | 68 | pub fn setup_cloud_init(instance: &Instance, dir: &str, force: bool) -> Result<(), Error> { 69 | let fs = FS::new(); 70 | let name = &instance.name; 71 | let user = &instance.user; 72 | 73 | let user_data_img_path = format!("{dir}/user-data.img"); 74 | 75 | if force || !Path::new(&user_data_img_path).exists() { 76 | let meta_data_path = format!("{dir}/meta-data"); 77 | let user_data_path = format!("{dir}/user-data"); 78 | 79 | fs.create_dir(dir)?; 80 | 81 | if force || !Path::new(&meta_data_path).exists() { 82 | fs.write_file( 83 | &meta_data_path, 84 | format!("instance-id: {name}\nlocal-hostname: {name}\n").as_bytes(), 85 | )?; 86 | } 87 | 88 | let mut bootcmds = String::new(); 89 | if !instance.mounts.is_empty() { 90 | bootcmds += "bootcmd:\n"; 91 | for (index, MountPoint { guest, .. }) in instance.mounts.iter().enumerate() { 92 | bootcmds += &format!(" - mount -t 9p cubic{index} {guest}\n"); 93 | } 94 | } 95 | 96 | if force || !Path::new(&user_data_path).exists() { 97 | let ssh_pk = if let Ok(ssh_keys) = get_ssh_pub_keys() { 98 | format!( 99 | "\u{20}\u{20}\u{20}\u{20}ssh-authorized-keys:\n{}", 100 | ssh_keys 101 | .iter() 102 | .map(|key| format!("\u{20}\u{20}\u{20}\u{20}\u{20}\u{20}- {key}")) 103 | .collect::>() 104 | .join("\n") 105 | ) 106 | } else { 107 | String::new() 108 | }; 109 | 110 | fs.write_file( 111 | &user_data_path, 112 | format!( 113 | "\ 114 | #cloud-config\n\ 115 | users:\n\ 116 | \u{20}\u{20}- name: {user}\n\ 117 | \u{20}\u{20} lock_passwd: false\n\ 118 | \u{20}\u{20} hashed_passwd: $y$j9T$wifmOLBedd7NSaH2IqG4L.$2J.8E.qE57lxapsWosOFod37djHePHg7Go03iDNsRe4\n\ 119 | {ssh_pk}\n\ 120 | \u{20}\u{20}\u{20}\u{20}shell: /bin/bash\n\ 121 | \u{20}\u{20}\u{20}\u{20}sudo: ALL=(ALL) NOPASSWD:ALL\n\ 122 | packages:\n\ 123 | \u{20}\u{20}- openssh\n\ 124 | {bootcmds}\n\ 125 | runcmd:\n\ 126 | \u{20}\u{20}- \ 127 | apt update; apt install -y qemu-guest-agent socat; \ 128 | dnf install -y qemu-guest-agent socat; \ 129 | yes | pacman -S qemu-guest-agent socat; \ 130 | systemctl enable --now qemu-guest-agent\n\ 131 | " 132 | ) 133 | .as_bytes(), 134 | )?; 135 | } 136 | 137 | Command::new("mkisofs") 138 | .arg("-RJ") 139 | .arg("-V") 140 | .arg("cidata") 141 | .arg("-o") 142 | .arg(&user_data_img_path) 143 | .arg("-graft-points") 144 | .arg(format!("/={user_data_path}")) 145 | .arg(format!("/={meta_data_path}")) 146 | .stdout(Stdio::null()) 147 | .stderr(Stdio::null()) 148 | .spawn() 149 | .map_err(|_| Error::UserDataCreationFailed(name.to_string()))? 150 | .wait() 151 | .map(|_| ()) 152 | .map_err(|_| Error::UserDataCreationFailed(name.to_string()))?; 153 | } 154 | 155 | Result::Ok(()) 156 | } 157 | -------------------------------------------------------------------------------- /src/util/terminal.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | 3 | use libc; 4 | 5 | use std::io::{self, prelude::*}; 6 | use std::mem; 7 | use std::net::Shutdown; 8 | use std::os::unix::net::UnixStream; 9 | 10 | use std::sync::{ 11 | atomic::{AtomicBool, Ordering}, 12 | mpsc::{self, Receiver, Sender}, 13 | Arc, 14 | }; 15 | use std::thread; 16 | use std::time::Duration; 17 | 18 | const TIOCGWINSZ: libc::c_ulong = 0x5413; 19 | 20 | pub struct Terminal { 21 | threads: Vec>, 22 | running: Arc, 23 | } 24 | 25 | fn spawn_stdin_thread(sender: Sender, running: Arc) -> thread::JoinHandle<()> { 26 | thread::spawn(move || { 27 | let mut buffer = [0u8]; 28 | while running.load(Ordering::Relaxed) { 29 | if io::stdin().read(&mut buffer).is_ok() { 30 | running.store(buffer[0] != 0x17, Ordering::Relaxed); 31 | sender.send(buffer[0]).ok(); 32 | } 33 | } 34 | }) 35 | } 36 | 37 | fn spawn_stream_thread( 38 | mut stream: UnixStream, 39 | receiver: Receiver, 40 | running: Arc, 41 | ) -> thread::JoinHandle<()> { 42 | thread::spawn(move || { 43 | let mut termios_original: libc::termios; 44 | unsafe { 45 | termios_original = mem::zeroed(); 46 | libc::tcgetattr(0, &mut termios_original); 47 | let mut termios = mem::zeroed(); 48 | libc::tcgetattr(0, &mut termios); 49 | termios.c_lflag &= !libc::ICANON; 50 | termios.c_lflag &= !libc::ECHO; 51 | termios.c_lflag &= !libc::ISIG; 52 | termios.c_cc[libc::VMIN] = 1; 53 | termios.c_cc[libc::VTIME] = 0; 54 | libc::tcsetattr(0, libc::TCSANOW, &termios); 55 | } 56 | 57 | let buf = &mut [0u8; 10]; 58 | let mut out = std::io::stdout(); 59 | 60 | while running.load(Ordering::Relaxed) { 61 | while let Ok(input) = receiver.try_recv() { 62 | stream.write_all(&[input]).ok(); 63 | } 64 | stream.flush().ok(); 65 | 66 | while let Ok(size) = stream.read(buf) { 67 | out.write_all(&buf[..size]).ok(); 68 | } 69 | out.flush().ok(); 70 | 71 | thread::sleep(Duration::from_millis(10)); 72 | } 73 | 74 | stream.shutdown(Shutdown::Both).ok(); 75 | out.write_all("\n".as_bytes()).ok(); 76 | out.flush().ok(); 77 | 78 | unsafe { 79 | libc::tcsetattr(0, libc::TCSANOW, &termios_original); 80 | } 81 | }) 82 | } 83 | 84 | impl Terminal { 85 | pub fn open(path: &str) -> Result { 86 | UnixStream::connect(path) 87 | .map(|stream| { 88 | stream.set_nonblocking(true).ok(); 89 | stream 90 | .set_read_timeout(Some(Duration::from_millis(10))) 91 | .ok(); 92 | let running = Arc::new(AtomicBool::new(true)); 93 | let (tx, rx) = mpsc::channel::(); 94 | Terminal { 95 | threads: vec![ 96 | spawn_stdin_thread(tx, running.clone()), 97 | spawn_stream_thread(stream, rx, running.clone()), 98 | ], 99 | running, 100 | } 101 | }) 102 | .map_err(|_| Error::CannotOpenTerminal(path.to_string())) 103 | } 104 | 105 | pub fn is_running(&mut self) -> bool { 106 | self.running.load(Ordering::Relaxed) 107 | } 108 | 109 | pub fn stop(&mut self) { 110 | self.running.store(false, Ordering::SeqCst) 111 | } 112 | 113 | pub fn get_term_size(&self) -> Option<(isize, isize)> { 114 | let winsize = libc::winsize { 115 | ws_row: 0, 116 | ws_col: 0, 117 | ws_xpixel: 0, 118 | ws_ypixel: 0, 119 | }; 120 | 121 | unsafe { 122 | if libc::ioctl(libc::STDOUT_FILENO, TIOCGWINSZ, &winsize) == 0 { 123 | Some((winsize.ws_col as isize, winsize.ws_row as isize)) 124 | } else { 125 | None 126 | } 127 | } 128 | } 129 | 130 | pub fn wait(&mut self) { 131 | while let Some(thread) = self.threads.pop() { 132 | thread.join().ok(); 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/view.rs: -------------------------------------------------------------------------------- 1 | pub mod console; 2 | pub mod console_mock; 3 | pub mod map_view; 4 | pub mod spinner_view; 5 | pub mod stdio; 6 | pub mod table_view; 7 | pub mod timer_view; 8 | pub mod transfer_view; 9 | 10 | pub use console::*; 11 | pub use map_view::*; 12 | pub use spinner_view::*; 13 | pub use stdio::*; 14 | pub use table_view::*; 15 | pub use timer_view::*; 16 | pub use transfer_view::*; 17 | -------------------------------------------------------------------------------- /src/view/console.rs: -------------------------------------------------------------------------------- 1 | pub trait Console { 2 | fn info(&mut self, msg: &str); 3 | } 4 | -------------------------------------------------------------------------------- /src/view/console_mock.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | pub mod tests { 3 | 4 | use crate::view::Console; 5 | 6 | #[derive(Default)] 7 | pub struct ConsoleMock { 8 | output: String, 9 | } 10 | 11 | impl ConsoleMock { 12 | pub fn new() -> Self { 13 | ConsoleMock::default() 14 | } 15 | 16 | pub fn get_output(&self) -> String { 17 | self.output.clone() 18 | } 19 | } 20 | 21 | impl Console for ConsoleMock { 22 | fn info(&mut self, msg: &str) { 23 | self.output = format!("{}{msg}\n", self.output); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/view/map_view.rs: -------------------------------------------------------------------------------- 1 | use crate::view::Console; 2 | 3 | #[derive(Default)] 4 | pub struct MapView { 5 | items: Vec<(String, String)>, 6 | } 7 | 8 | impl MapView { 9 | pub fn new() -> Self { 10 | MapView::default() 11 | } 12 | 13 | pub fn add(&mut self, key: &str, value: &str) { 14 | self.items.push((key.to_string(), value.to_string())); 15 | } 16 | 17 | pub fn print(self, console: &mut dyn Console) { 18 | let max_key_length = self 19 | .items 20 | .iter() 21 | .map(|(key, _)| key.len() + 1) 22 | .max() 23 | .unwrap_or(0); 24 | 25 | self.items.iter().for_each(|(key, value)| { 26 | let mut key = key.clone(); 27 | if !key.is_empty() { 28 | key += ":"; 29 | } 30 | console.info(&format!("{key:max_key_length$} {value}")); 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/view/spinner_view.rs: -------------------------------------------------------------------------------- 1 | use std::io::{stdout, Write}; 2 | use std::thread; 3 | use std::time; 4 | 5 | const SPINNER_VIEW_CHARS: &[char] = &['-', '\\', '|', '/']; 6 | 7 | pub struct SpinnerView { 8 | text: String, 9 | } 10 | 11 | impl SpinnerView { 12 | pub fn new(text: &str) -> Self { 13 | SpinnerView { 14 | text: text.to_string(), 15 | } 16 | } 17 | 18 | pub fn run(&mut self, f: fn() -> T) -> Option { 19 | let thread = thread::spawn(f); 20 | let mut index = 0; 21 | 22 | while !thread.is_finished() { 23 | print!( 24 | "\r{}.. {}", 25 | &self.text, 26 | SPINNER_VIEW_CHARS[index % SPINNER_VIEW_CHARS.len()] 27 | ); 28 | stdout().flush().ok(); 29 | thread::sleep(time::Duration::from_millis(100)); 30 | index += 1; 31 | } 32 | 33 | print!("\r"); 34 | thread.join().ok() 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/view/stdio.rs: -------------------------------------------------------------------------------- 1 | use crate::view::Console; 2 | 3 | pub struct Stdio; 4 | 5 | impl Stdio { 6 | pub fn new() -> Self { 7 | Stdio {} 8 | } 9 | } 10 | 11 | impl Console for Stdio { 12 | fn info(&mut self, msg: &str) { 13 | println!("{msg}"); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/view/table_view.rs: -------------------------------------------------------------------------------- 1 | use crate::view::Console; 2 | 3 | pub enum Alignment { 4 | Left, 5 | Right, 6 | } 7 | 8 | #[derive(Default)] 9 | pub struct Row { 10 | pub entries: Vec<(String, Alignment)>, 11 | } 12 | 13 | impl Row { 14 | pub fn add(&mut self, entry: &str, alignment: Alignment) -> &mut Self { 15 | self.entries.push((entry.to_string(), alignment)); 16 | self 17 | } 18 | } 19 | 20 | #[derive(Default)] 21 | pub struct TableView { 22 | rows: Vec, 23 | } 24 | 25 | impl TableView { 26 | pub fn new() -> Self { 27 | TableView::default() 28 | } 29 | 30 | pub fn add_row(&mut self) -> &mut Row { 31 | let row = Row::default(); 32 | self.rows.push(row); 33 | self.rows.last_mut().unwrap() 34 | } 35 | 36 | pub fn print(&self, console: &mut dyn Console) { 37 | let mut column_size = Vec::new(); 38 | for row in &self.rows { 39 | for (index, (entry, _)) in row.entries.iter().enumerate() { 40 | while index >= column_size.len() { 41 | column_size.push(0); 42 | } 43 | 44 | column_size[index] = column_size[index].max(entry.len()); 45 | } 46 | } 47 | 48 | for row in &self.rows { 49 | let line = row 50 | .entries 51 | .iter() 52 | .enumerate() 53 | .map(|(index, (entry, alignment))| match alignment { 54 | Alignment::Left => format!("{entry: format!("{entry:>width$}", width = column_size[index]), 56 | }) 57 | .collect::>() 58 | .join(" "); 59 | console.info(line.trim_end()); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/view/timer_view.rs: -------------------------------------------------------------------------------- 1 | use std::io::stdout; 2 | use std::io::Write; 3 | use std::thread::sleep; 4 | use std::time::{Duration, Instant}; 5 | 6 | pub struct TimerView { 7 | message: String, 8 | start: Option, 9 | } 10 | 11 | impl TimerView { 12 | pub fn new(message: &str) -> Self { 13 | TimerView { 14 | message: message.to_string(), 15 | start: Some(Instant::now()), 16 | } 17 | } 18 | 19 | pub fn run(&self, is_done: &mut impl FnMut() -> bool) { 20 | let mut stdout = stdout(); 21 | 22 | while !is_done() { 23 | print!( 24 | "\r{} {}", 25 | self.message, 26 | self.start 27 | .map(|start| format!("({:.1?}s)", start.elapsed().as_secs_f32())) 28 | .unwrap_or_default() 29 | ); 30 | stdout.flush().ok(); 31 | sleep(Duration::from_millis(10)); 32 | } 33 | println!(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/view/transfer_view.rs: -------------------------------------------------------------------------------- 1 | use crate::util; 2 | use std::io; 3 | use std::io::Write; 4 | use std::time::Instant; 5 | 6 | pub struct TransferView { 7 | message: String, 8 | start_time: Instant, 9 | bytes_per_second: u64, 10 | } 11 | 12 | impl TransferView { 13 | pub fn new(message: &str) -> Self { 14 | TransferView { 15 | message: message.to_string(), 16 | start_time: Instant::now(), 17 | bytes_per_second: 0, 18 | } 19 | } 20 | 21 | pub fn update(&mut self, transfered_bytes: u64, total_bytes: Option) { 22 | print!( 23 | "\r{}: {:>10}", 24 | self.message, 25 | util::bytes_to_human_readable(transfered_bytes) 26 | ); 27 | 28 | if let Some(total_bytes) = total_bytes { 29 | print!( 30 | " / {:>10} [{:>3.0}%]", 31 | util::bytes_to_human_readable(total_bytes), 32 | transfered_bytes as f64 / total_bytes as f64 * 100_f64 33 | ); 34 | } 35 | 36 | let transfer_time_sec = self.start_time.elapsed().as_secs(); 37 | if transfer_time_sec != 0 { 38 | self.bytes_per_second += transfered_bytes / transfer_time_sec; 39 | self.bytes_per_second /= 2; 40 | print!( 41 | " {:>10}/s", 42 | util::bytes_to_human_readable(self.bytes_per_second) 43 | ); 44 | } 45 | 46 | if total_bytes 47 | .map(|total| transfered_bytes >= total) 48 | .unwrap_or(false) 49 | { 50 | println!(); 51 | } 52 | 53 | io::stdout().flush().ok(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/web.rs: -------------------------------------------------------------------------------- 1 | pub mod web_client; 2 | 3 | pub use web_client::*; 4 | -------------------------------------------------------------------------------- /src/web/web_client.rs: -------------------------------------------------------------------------------- 1 | use crate::error::Error; 2 | use crate::fs::FS; 3 | use crate::view::TransferView; 4 | use reqwest::blocking::Client; 5 | use std::fs::File; 6 | use std::io; 7 | use std::path::Path; 8 | use std::time::Duration; 9 | 10 | struct ProgressWriter { 11 | file: File, 12 | size: Option, 13 | written: u64, 14 | view: TransferView, 15 | } 16 | 17 | impl io::Write for ProgressWriter { 18 | fn write(&mut self, buf: &[u8]) -> io::Result { 19 | self.written += buf.len() as u64; 20 | self.view.update(self.written, self.size); 21 | self.file.write(buf) 22 | } 23 | 24 | fn flush(&mut self) -> io::Result<()> { 25 | self.file.flush() 26 | } 27 | } 28 | 29 | pub struct WebClient { 30 | client: Client, 31 | } 32 | 33 | impl WebClient { 34 | pub fn new() -> Result { 35 | Ok(WebClient { 36 | client: reqwest::blocking::Client::builder() 37 | .timeout(Duration::from_secs(5)) 38 | .gzip(true) 39 | .brotli(true) 40 | .build() 41 | .map_err(Error::Web)?, 42 | }) 43 | } 44 | 45 | pub fn get_file_size(&mut self, url: &str) -> Result, Error> { 46 | Ok(self 47 | .client 48 | .head(url) 49 | .send() 50 | .map_err(Error::Web)? 51 | .headers() 52 | .get("Content-Length") 53 | .and_then(|value| value.to_str().ok()) 54 | .and_then(|value| value.parse().ok())) 55 | } 56 | 57 | pub fn download_file( 58 | self, 59 | url: &str, 60 | file_path: &str, 61 | view: TransferView, 62 | ) -> Result<(), Error> { 63 | let fs = FS::new(); 64 | 65 | let temp_file = format!("{file_path}.tmp"); 66 | if Path::new(&temp_file).exists() { 67 | fs.remove_file(&temp_file)?; 68 | } 69 | 70 | if Path::new(&file_path).exists() { 71 | return Result::Ok(()); 72 | } 73 | 74 | let mut resp = reqwest::blocking::get(url).map_err(Error::Web)?; 75 | 76 | let mut writer = ProgressWriter { 77 | file: fs.create_file(&temp_file)?, 78 | size: resp.content_length(), 79 | written: 0, 80 | view, 81 | }; 82 | resp.copy_to(&mut writer).map_err(Error::Web)?; 83 | 84 | fs.rename_file(&temp_file, file_path) 85 | } 86 | 87 | pub fn download_content(&mut self, url: &str) -> Result { 88 | self.client 89 | .get(url) 90 | .send() 91 | .map_err(Error::Web)? 92 | .text() 93 | .map_err(Error::Web) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | env: 4 | DOCKER_CMD: docker run --rm -v .:/usr/local/app 5 | IMAGE: cubic:latest 6 | 7 | tasks: 8 | build-image: 9 | cmds: 10 | - if [ -z "$(docker images -q $IMAGE)" ]; then docker build -t $IMAGE .; fi 11 | 12 | clean: 13 | deps: [build-image] 14 | cmds: 15 | - $DOCKER_CMD $IMAGE cargo clean 16 | 17 | cleanall: 18 | deps: [build-image] 19 | cmds: 20 | - docker image rm -f $IMAGE 21 | 22 | format: 23 | deps: [build-image] 24 | cmds: 25 | - $DOCKER_CMD $IMAGE cargo fmt --check 26 | 27 | fix-format: 28 | deps: [build-image] 29 | cmds: 30 | - $DOCKER_CMD $IMAGE cargo fmt 31 | 32 | lint: 33 | deps: [build-image] 34 | cmds: 35 | - $DOCKER_CMD $IMAGE cargo clippy -- -D warnings 36 | 37 | fix-lint: 38 | deps: [build-image] 39 | cmds: 40 | - $DOCKER_CMD $IMAGE cargo clippy --fix --allow-dirty --allow-staged 41 | 42 | test: 43 | deps: [build-image] 44 | cmds: 45 | - $DOCKER_CMD $IMAGE cargo test 46 | 47 | audit: 48 | deps: [build-image] 49 | cmds: 50 | - $DOCKER_CMD $IMAGE cargo audit 51 | 52 | update: 53 | deps: [build-image] 54 | cmds: 55 | - $DOCKER_CMD $IMAGE cargo update 56 | 57 | sh: 58 | deps: [build-image] 59 | cmds: 60 | - $DOCKER_CMD -it $IMAGE bash 61 | 62 | check: 63 | deps: [format, lint, test, audit] 64 | 65 | fix: 66 | deps: [fix-format, fix-lint] 67 | 68 | build: 69 | deps: [build-image] 70 | cmds: 71 | - $DOCKER_CMD $IMAGE cargo build 72 | 73 | release: 74 | deps: [build-image] 75 | cmds: 76 | - sed 's/^\(version =\).*$/\1 "{{.version}}"/g' -i Cargo.toml 77 | - "sed \"s/^\\(version:\\).*$/\\1 '{{.version}}'/g\" -i snapcraft.yaml" 78 | - task: build 79 | --------------------------------------------------------------------------------