A Docker-like CLI and HTTP API for managing headless VMs

Initial Commit

rrovarandria 448f380a

+4868
+5
.gitignore
···
··· 1 + *.iso 2 + *.img 3 + vmconfig.toml 4 + .env 5 + *.fd
+364
LICENSE
···
··· 1 + Copyright (c) 2025 Tsiry Sandratraina <tsiry.sndr@rocksky.app> 2 + 3 + Mozilla Public License, version 2.0 4 + 5 + 1. Definitions 6 + 7 + 1.1. "Contributor" 8 + 9 + means each individual or legal entity that creates, contributes to the 10 + creation of, or owns Covered Software. 11 + 12 + 1.2. "Contributor Version" 13 + 14 + means the combination of the Contributions of others (if any) used by a 15 + Contributor and that particular Contributor's Contribution. 16 + 17 + 1.3. "Contribution" 18 + 19 + means Covered Software of a particular Contributor. 20 + 21 + 1.4. "Covered Software" 22 + 23 + means Source Code Form to which the initial Contributor has attached the 24 + notice in Exhibit A, the Executable Form of such Source Code Form, and 25 + Modifications of such Source Code Form, in each case including portions 26 + thereof. 27 + 28 + 1.5. "Incompatible With Secondary Licenses" 29 + means 30 + 31 + a. that the initial Contributor has attached the notice described in 32 + Exhibit B to the Covered Software; or 33 + 34 + b. that the Covered Software was made available under the terms of 35 + version 1.1 or earlier of the License, but not also under the terms of 36 + a Secondary License. 37 + 38 + 1.6. "Executable Form" 39 + 40 + means any form of the work other than Source Code Form. 41 + 42 + 1.7. "Larger Work" 43 + 44 + means a work that combines Covered Software with other material, in a 45 + separate file or files, that is not Covered Software. 46 + 47 + 1.8. "License" 48 + 49 + means this document. 50 + 51 + 1.9. "Licensable" 52 + 53 + means having the right to grant, to the maximum extent possible, whether 54 + at the time of the initial grant or subsequently, any and all of the 55 + rights conveyed by this License. 56 + 57 + 1.10. "Modifications" 58 + 59 + means any of the following: 60 + 61 + a. any file in Source Code Form that results from an addition to, 62 + deletion from, or modification of the contents of Covered Software; or 63 + 64 + b. any new file in Source Code Form that contains any Covered Software. 65 + 66 + 1.11. "Patent Claims" of a Contributor 67 + 68 + means any patent claim(s), including without limitation, method, 69 + process, and apparatus claims, in any patent Licensable by such 70 + Contributor that would be infringed, but for the grant of the License, 71 + by the making, using, selling, offering for sale, having made, import, 72 + or transfer of either its Contributions or its Contributor Version. 73 + 74 + 1.12. "Secondary License" 75 + 76 + means either the GNU General Public License, Version 2.0, the GNU Lesser 77 + General Public License, Version 2.1, the GNU Affero General Public 78 + License, Version 3.0, or any later versions of those licenses. 79 + 80 + 1.13. "Source Code Form" 81 + 82 + means the form of the work preferred for making modifications. 83 + 84 + 1.14. "You" (or "Your") 85 + 86 + means an individual or a legal entity exercising rights under this 87 + License. For legal entities, "You" includes any entity that controls, is 88 + controlled by, or is under common control with You. For purposes of this 89 + definition, "control" means (a) the power, direct or indirect, to cause 90 + the direction or management of such entity, whether by contract or 91 + otherwise, or (b) ownership of more than fifty percent (50%) of the 92 + outstanding shares or beneficial ownership of such entity. 93 + 94 + 95 + 2. License Grants and Conditions 96 + 97 + 2.1. Grants 98 + 99 + Each Contributor hereby grants You a world-wide, royalty-free, 100 + non-exclusive license: 101 + 102 + a. under intellectual property rights (other than patent or trademark) 103 + Licensable by such Contributor to use, reproduce, make available, 104 + modify, display, perform, distribute, and otherwise exploit its 105 + Contributions, either on an unmodified basis, with Modifications, or 106 + as part of a Larger Work; and 107 + 108 + b. under Patent Claims of such Contributor to make, use, sell, offer for 109 + sale, have made, import, and otherwise transfer either its 110 + Contributions or its Contributor Version. 111 + 112 + 2.2. Effective Date 113 + 114 + The licenses granted in Section 2.1 with respect to any Contribution 115 + become effective for each Contribution on the date the Contributor first 116 + distributes such Contribution. 117 + 118 + 2.3. Limitations on Grant Scope 119 + 120 + The licenses granted in this Section 2 are the only rights granted under 121 + this License. No additional rights or licenses will be implied from the 122 + distribution or licensing of Covered Software under this License. 123 + Notwithstanding Section 2.1(b) above, no patent license is granted by a 124 + Contributor: 125 + 126 + a. for any code that a Contributor has removed from Covered Software; or 127 + 128 + b. for infringements caused by: (i) Your and any other third party's 129 + modifications of Covered Software, or (ii) the combination of its 130 + Contributions with other software (except as part of its Contributor 131 + Version); or 132 + 133 + c. under Patent Claims infringed by Covered Software in the absence of 134 + its Contributions. 135 + 136 + This License does not grant any rights in the trademarks, service marks, 137 + or logos of any Contributor (except as may be necessary to comply with 138 + the notice requirements in Section 3.4). 139 + 140 + 2.4. Subsequent Licenses 141 + 142 + No Contributor makes additional grants as a result of Your choice to 143 + distribute the Covered Software under a subsequent version of this 144 + License (see Section 10.2) or under the terms of a Secondary License (if 145 + permitted under the terms of Section 3.3). 146 + 147 + 2.5. Representation 148 + 149 + Each Contributor represents that the Contributor believes its 150 + Contributions are its original creation(s) or it has sufficient rights to 151 + grant the rights to its Contributions conveyed by this License. 152 + 153 + 2.6. Fair Use 154 + 155 + This License is not intended to limit any rights You have under 156 + applicable copyright doctrines of fair use, fair dealing, or other 157 + equivalents. 158 + 159 + 2.7. Conditions 160 + 161 + Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted in 162 + Section 2.1. 163 + 164 + 165 + 3. Responsibilities 166 + 167 + 3.1. Distribution of Source Form 168 + 169 + All distribution of Covered Software in Source Code Form, including any 170 + Modifications that You create or to which You contribute, must be under 171 + the terms of this License. You must inform recipients that the Source 172 + Code Form of the Covered Software is governed by the terms of this 173 + License, and how they can obtain a copy of this License. You may not 174 + attempt to alter or restrict the recipients' rights in the Source Code 175 + Form. 176 + 177 + 3.2. Distribution of Executable Form 178 + 179 + If You distribute Covered Software in Executable Form then: 180 + 181 + a. such Covered Software must also be made available in Source Code Form, 182 + as described in Section 3.1, and You must inform recipients of the 183 + Executable Form how they can obtain a copy of such Source Code Form by 184 + reasonable means in a timely manner, at a charge no more than the cost 185 + of distribution to the recipient; and 186 + 187 + b. You may distribute such Executable Form under the terms of this 188 + License, or sublicense it under different terms, provided that the 189 + license for the Executable Form does not attempt to limit or alter the 190 + recipients' rights in the Source Code Form under this License. 191 + 192 + 3.3. Distribution of a Larger Work 193 + 194 + You may create and distribute a Larger Work under terms of Your choice, 195 + provided that You also comply with the requirements of this License for 196 + the Covered Software. If the Larger Work is a combination of Covered 197 + Software with a work governed by one or more Secondary Licenses, and the 198 + Covered Software is not Incompatible With Secondary Licenses, this 199 + License permits You to additionally distribute such Covered Software 200 + under the terms of such Secondary License(s), so that the recipient of 201 + the Larger Work may, at their option, further distribute the Covered 202 + Software under the terms of either this License or such Secondary 203 + License(s). 204 + 205 + 3.4. Notices 206 + 207 + You may not remove or alter the substance of any license notices 208 + (including copyright notices, patent notices, disclaimers of warranty, or 209 + limitations of liability) contained within the Source Code Form of the 210 + Covered Software, except that You may alter any license notices to the 211 + extent required to remedy known factual inaccuracies. 212 + 213 + 3.5. Application of Additional Terms 214 + 215 + You may choose to offer, and to charge a fee for, warranty, support, 216 + indemnity or liability obligations to one or more recipients of Covered 217 + Software. However, You may do so only on Your own behalf, and not on 218 + behalf of any Contributor. You must make it absolutely clear that any 219 + such warranty, support, indemnity, or liability obligation is offered by 220 + You alone, and You hereby agree to indemnify every Contributor for any 221 + liability incurred by such Contributor as a result of warranty, support, 222 + indemnity or liability terms You offer. You may include additional 223 + disclaimers of warranty and limitations of liability specific to any 224 + jurisdiction. 225 + 226 + 4. Inability to Comply Due to Statute or Regulation 227 + 228 + If it is impossible for You to comply with any of the terms of this License 229 + with respect to some or all of the Covered Software due to statute, 230 + judicial order, or regulation then You must: (a) comply with the terms of 231 + this License to the maximum extent possible; and (b) describe the 232 + limitations and the code they affect. Such description must be placed in a 233 + text file included with all distributions of the Covered Software under 234 + this License. Except to the extent prohibited by statute or regulation, 235 + such description must be sufficiently detailed for a recipient of ordinary 236 + skill to be able to understand it. 237 + 238 + 5. Termination 239 + 240 + 5.1. The rights granted under this License will terminate automatically if You 241 + fail to comply with any of its terms. However, if You become compliant, 242 + then the rights granted under this License from a particular Contributor 243 + are reinstated (a) provisionally, unless and until such Contributor 244 + explicitly and finally terminates Your grants, and (b) on an ongoing 245 + basis, if such Contributor fails to notify You of the non-compliance by 246 + some reasonable means prior to 60 days after You have come back into 247 + compliance. Moreover, Your grants from a particular Contributor are 248 + reinstated on an ongoing basis if such Contributor notifies You of the 249 + non-compliance by some reasonable means, this is the first time You have 250 + received notice of non-compliance with this License from such 251 + Contributor, and You become compliant prior to 30 days after Your receipt 252 + of the notice. 253 + 254 + 5.2. If You initiate litigation against any entity by asserting a patent 255 + infringement claim (excluding declaratory judgment actions, 256 + counter-claims, and cross-claims) alleging that a Contributor Version 257 + directly or indirectly infringes any patent, then the rights granted to 258 + You by any and all Contributors for the Covered Software under Section 259 + 2.1 of this License shall terminate. 260 + 261 + 5.3. In the event of termination under Sections 5.1 or 5.2 above, all end user 262 + license agreements (excluding distributors and resellers) which have been 263 + validly granted by You or Your distributors under this License prior to 264 + termination shall survive termination. 265 + 266 + 6. Disclaimer of Warranty 267 + 268 + Covered Software is provided under this License on an "as is" basis, 269 + without warranty of any kind, either expressed, implied, or statutory, 270 + including, without limitation, warranties that the Covered Software is free 271 + of defects, merchantable, fit for a particular purpose or non-infringing. 272 + The entire risk as to the quality and performance of the Covered Software 273 + is with You. Should any Covered Software prove defective in any respect, 274 + You (not any Contributor) assume the cost of any necessary servicing, 275 + repair, or correction. This disclaimer of warranty constitutes an essential 276 + part of this License. No use of any Covered Software is authorized under 277 + this License except under this disclaimer. 278 + 279 + 7. Limitation of Liability 280 + 281 + Under no circumstances and under no legal theory, whether tort (including 282 + negligence), contract, or otherwise, shall any Contributor, or anyone who 283 + distributes Covered Software as permitted above, be liable to You for any 284 + direct, indirect, special, incidental, or consequential damages of any 285 + character including, without limitation, damages for lost profits, loss of 286 + goodwill, work stoppage, computer failure or malfunction, or any and all 287 + other commercial damages or losses, even if such party shall have been 288 + informed of the possibility of such damages. This limitation of liability 289 + shall not apply to liability for death or personal injury resulting from 290 + such party's negligence to the extent applicable law prohibits such 291 + limitation. Some jurisdictions do not allow the exclusion or limitation of 292 + incidental or consequential damages, so this exclusion and limitation may 293 + not apply to You. 294 + 295 + 8. Litigation 296 + 297 + Any litigation relating to this License may be brought only in the courts 298 + of a jurisdiction where the defendant maintains its principal place of 299 + business and such litigation shall be governed by laws of that 300 + jurisdiction, without reference to its conflict-of-law provisions. Nothing 301 + in this Section shall prevent a party's ability to bring cross-claims or 302 + counter-claims. 303 + 304 + 9. Miscellaneous 305 + 306 + This License represents the complete agreement concerning the subject 307 + matter hereof. If any provision of this License is held to be 308 + unenforceable, such provision shall be reformed only to the extent 309 + necessary to make it enforceable. Any law or regulation which provides that 310 + the language of a contract shall be construed against the drafter shall not 311 + be used to construe this License against a Contributor. 312 + 313 + 314 + 10. Versions of the License 315 + 316 + 10.1. New Versions 317 + 318 + Mozilla Foundation is the license steward. Except as provided in Section 319 + 10.3, no one other than the license steward has the right to modify or 320 + publish new versions of this License. Each version will be given a 321 + distinguishing version number. 322 + 323 + 10.2. Effect of New Versions 324 + 325 + You may distribute the Covered Software under the terms of the version 326 + of the License under which You originally received the Covered Software, 327 + or under the terms of any subsequent version published by the license 328 + steward. 329 + 330 + 10.3. Modified Versions 331 + 332 + If you create software not governed by this License, and you want to 333 + create a new license for such software, you may create and use a 334 + modified version of this License if you rename the license and remove 335 + any references to the name of the license steward (except to note that 336 + such modified license differs from this License). 337 + 338 + 10.4. Distributing Source Code Form that is Incompatible With Secondary 339 + Licenses If You choose to distribute Source Code Form that is 340 + Incompatible With Secondary Licenses under the terms of this version of 341 + the License, the notice described in Exhibit B of this License must be 342 + attached. 343 + 344 + Exhibit A - Source Code Form License Notice 345 + 346 + This Source Code Form is subject to the 347 + terms of the Mozilla Public License, v. 348 + 2.0. If a copy of the MPL was not 349 + distributed with this file, You can 350 + obtain one at 351 + http://mozilla.org/MPL/2.0/. 352 + 353 + If it is not possible or desirable to put the notice in a particular file, 354 + then You may include the notice in a location (such as a LICENSE file in a 355 + relevant directory) where a recipient would be likely to look for such a 356 + notice. 357 + 358 + You may add additional accurate notices of copyright ownership. 359 + 360 + Exhibit B - "Incompatible With Secondary Licenses" Notice 361 + 362 + This Source Code Form is "Incompatible 363 + With Secondary Licenses", as defined by 364 + the Mozilla Public License, v. 2.0.
+31
deno.json
···
··· 1 + { 2 + "name": "@tsiry/vmx", 3 + "version": "0.1.0", 4 + "exports": "./main.ts", 5 + "license": "MPL-2.0", 6 + "tasks": { 7 + "dev": "deno run --env-file=.env -A --watch main.ts" 8 + }, 9 + "imports": { 10 + "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.8", 11 + "@cliffy/flags": "jsr:@cliffy/flags@^1.0.0-rc.8", 12 + "@cliffy/prompt": "jsr:@cliffy/prompt@^1.0.0-rc.8", 13 + "@cliffy/table": "jsr:@cliffy/table@^1.0.0-rc.8", 14 + "@db/sqlite": "jsr:@db/sqlite@^0.12.0", 15 + "@es-toolkit/es-toolkit": "jsr:@es-toolkit/es-toolkit@^1.41.0", 16 + "@hono/swagger-ui": "npm:@hono/swagger-ui@^0.5.2", 17 + "@paralleldrive/cuid2": "npm:@paralleldrive/cuid2@^3.0.4", 18 + "@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", 19 + "@std/assert": "jsr:@std/assert@1", 20 + "@std/io": "jsr:@std/io@^0.225.2", 21 + "@std/path": "jsr:@std/path@^1.1.2", 22 + "@std/toml": "jsr:@std/toml@^1.0.11", 23 + "@zod/zod": "jsr:@zod/zod@^4.1.12", 24 + "chalk": "npm:chalk@^5.6.2", 25 + "dayjs": "npm:dayjs@^1.11.19", 26 + "effect": "npm:effect@^3.19.2", 27 + "hono": "npm:hono@^4.10.6", 28 + "kysely": "npm:kysely@0.27.6", 29 + "moniker": "npm:moniker@^0.1.2" 30 + } 31 + }
+266
deno.lock
···
··· 1 + { 2 + "version": "5", 3 + "specifiers": { 4 + "jsr:@cliffy/ansi@1.0.0-rc.8": "1.0.0-rc.8", 5 + "jsr:@cliffy/command@^1.0.0-rc.8": "1.0.0-rc.8", 6 + "jsr:@cliffy/flags@1.0.0-rc.8": "1.0.0-rc.8", 7 + "jsr:@cliffy/flags@^1.0.0-rc.8": "1.0.0-rc.8", 8 + "jsr:@cliffy/internal@1.0.0-rc.8": "1.0.0-rc.8", 9 + "jsr:@cliffy/keycode@1.0.0-rc.8": "1.0.0-rc.8", 10 + "jsr:@cliffy/prompt@^1.0.0-rc.8": "1.0.0-rc.8", 11 + "jsr:@cliffy/table@1.0.0-rc.8": "1.0.0-rc.8", 12 + "jsr:@cliffy/table@^1.0.0-rc.8": "1.0.0-rc.8", 13 + "jsr:@db/sqlite@0.12": "0.12.0", 14 + "jsr:@denosaurs/plug@1": "1.1.0", 15 + "jsr:@es-toolkit/es-toolkit@^1.41.0": "1.41.0", 16 + "jsr:@soapbox/kysely-deno-sqlite@^2.2.0": "2.2.0", 17 + "jsr:@std/assert@0.217": "0.217.0", 18 + "jsr:@std/assert@~1.0.6": "1.0.15", 19 + "jsr:@std/bytes@^1.0.5": "1.0.6", 20 + "jsr:@std/collections@^1.1.3": "1.1.3", 21 + "jsr:@std/encoding@1": "1.0.10", 22 + "jsr:@std/encoding@~1.0.5": "1.0.10", 23 + "jsr:@std/fmt@1": "1.0.8", 24 + "jsr:@std/fmt@~1.0.2": "1.0.8", 25 + "jsr:@std/fs@1": "1.0.19", 26 + "jsr:@std/internal@^1.0.10": "1.0.12", 27 + "jsr:@std/internal@^1.0.9": "1.0.12", 28 + "jsr:@std/io@~0.225.2": "0.225.2", 29 + "jsr:@std/path@0.217": "0.217.0", 30 + "jsr:@std/path@1": "1.1.2", 31 + "jsr:@std/path@^1.1.1": "1.1.2", 32 + "jsr:@std/path@^1.1.2": "1.1.2", 33 + "jsr:@std/path@~1.0.6": "1.0.9", 34 + "jsr:@std/text@~1.0.7": "1.0.16", 35 + "jsr:@std/toml@^1.0.11": "1.0.11", 36 + "jsr:@zod/zod@^4.1.12": "4.1.12", 37 + "npm:@hono/swagger-ui@~0.5.2": "0.5.2_hono@4.10.6", 38 + "npm:@paralleldrive/cuid2@^3.0.4": "3.0.4", 39 + "npm:chalk@^5.6.2": "5.6.2", 40 + "npm:dayjs@^1.11.19": "1.11.19", 41 + "npm:effect@^3.19.2": "3.19.3", 42 + "npm:hono@^4.10.6": "4.10.6", 43 + "npm:kysely@0.27.6": "0.27.6", 44 + "npm:kysely@~0.27.2": "0.27.6", 45 + "npm:moniker@~0.1.2": "0.1.2" 46 + }, 47 + "jsr": { 48 + "@cliffy/ansi@1.0.0-rc.8": { 49 + "integrity": "ba37f10ce55bbfbdd8ddd987f91f029b17bce88385b98ba3058870f3b007b80c", 50 + "dependencies": [ 51 + "jsr:@cliffy/internal", 52 + "jsr:@std/encoding@~1.0.5" 53 + ] 54 + }, 55 + "@cliffy/command@1.0.0-rc.8": { 56 + "integrity": "758147790797c74a707e5294cc7285df665422a13d2a483437092ffce40b5557", 57 + "dependencies": [ 58 + "jsr:@cliffy/flags@1.0.0-rc.8", 59 + "jsr:@cliffy/internal", 60 + "jsr:@cliffy/table@1.0.0-rc.8", 61 + "jsr:@std/fmt@~1.0.2", 62 + "jsr:@std/text" 63 + ] 64 + }, 65 + "@cliffy/flags@1.0.0-rc.8": { 66 + "integrity": "0f1043ce6ef037ba1cb5fe6b1bcecb25dc2f29371a1c17f278ab0f45e4b6f46c", 67 + "dependencies": [ 68 + "jsr:@std/text" 69 + ] 70 + }, 71 + "@cliffy/internal@1.0.0-rc.8": { 72 + "integrity": "34cdf2fad9b084b5aed493b138d573f52d4e988767215f7460daf0b918ff43d8" 73 + }, 74 + "@cliffy/keycode@1.0.0-rc.8": { 75 + "integrity": "76dbf85a67ec0aea2e29ca049b8507b6b3f62a2a971bd744d8d3fc447c177cd9" 76 + }, 77 + "@cliffy/prompt@1.0.0-rc.8": { 78 + "integrity": "eba403ea1d47b9971bf2210fa35f4dc7ebd2aba87beec9540ae47552806e2f25", 79 + "dependencies": [ 80 + "jsr:@cliffy/ansi", 81 + "jsr:@cliffy/internal", 82 + "jsr:@cliffy/keycode", 83 + "jsr:@std/assert@~1.0.6", 84 + "jsr:@std/fmt@~1.0.2", 85 + "jsr:@std/path@~1.0.6", 86 + "jsr:@std/text" 87 + ] 88 + }, 89 + "@cliffy/table@1.0.0-rc.8": { 90 + "integrity": "8bbcdc2ba5e0061b4b13810a24e6f5c6ab19c09f0cce9eb691ccd76c7c6c9db5", 91 + "dependencies": [ 92 + "jsr:@std/fmt@~1.0.2" 93 + ] 94 + }, 95 + "@db/sqlite@0.12.0": { 96 + "integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f", 97 + "dependencies": [ 98 + "jsr:@denosaurs/plug", 99 + "jsr:@std/path@0.217" 100 + ] 101 + }, 102 + "@denosaurs/plug@1.1.0": { 103 + "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044", 104 + "dependencies": [ 105 + "jsr:@std/encoding@1", 106 + "jsr:@std/fmt@1", 107 + "jsr:@std/fs", 108 + "jsr:@std/path@1" 109 + ] 110 + }, 111 + "@es-toolkit/es-toolkit@1.41.0": { 112 + "integrity": "4df54a18e80b869880cee8a8a9ff7a5e1c424a9fd0916dccd38d34686f110071" 113 + }, 114 + "@soapbox/kysely-deno-sqlite@2.2.0": { 115 + "integrity": "668ec94600bc4b4d7bd618dd7ca65d4ef30ee61c46ffcb379b6f45203c08517a", 116 + "dependencies": [ 117 + "npm:kysely@~0.27.2" 118 + ] 119 + }, 120 + "@std/assert@0.217.0": { 121 + "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" 122 + }, 123 + "@std/assert@1.0.15": { 124 + "integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b" 125 + }, 126 + "@std/bytes@1.0.6": { 127 + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" 128 + }, 129 + "@std/collections@1.1.3": { 130 + "integrity": "bf8b0818886df6a32b64c7d3b037a425111f28278d69fd0995aeb62777c986b0" 131 + }, 132 + "@std/encoding@1.0.10": { 133 + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" 134 + }, 135 + "@std/fmt@1.0.8": { 136 + "integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7" 137 + }, 138 + "@std/fs@1.0.19": { 139 + "integrity": "051968c2b1eae4d2ea9f79a08a3845740ef6af10356aff43d3e2ef11ed09fb06", 140 + "dependencies": [ 141 + "jsr:@std/internal@^1.0.9", 142 + "jsr:@std/path@^1.1.1" 143 + ] 144 + }, 145 + "@std/internal@1.0.12": { 146 + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 147 + }, 148 + "@std/io@0.225.2": { 149 + "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7", 150 + "dependencies": [ 151 + "jsr:@std/bytes" 152 + ] 153 + }, 154 + "@std/path@0.217.0": { 155 + "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", 156 + "dependencies": [ 157 + "jsr:@std/assert@0.217" 158 + ] 159 + }, 160 + "@std/path@1.0.9": { 161 + "integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e" 162 + }, 163 + "@std/path@1.1.2": { 164 + "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038", 165 + "dependencies": [ 166 + "jsr:@std/internal@^1.0.10" 167 + ] 168 + }, 169 + "@std/text@1.0.16": { 170 + "integrity": "ddb9853b75119a2473857d691cf1ec02ad90793a2e8b4a4ac49d7354281a0cf8" 171 + }, 172 + "@std/toml@1.0.11": { 173 + "integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715", 174 + "dependencies": [ 175 + "jsr:@std/collections" 176 + ] 177 + }, 178 + "@zod/zod@4.1.12": { 179 + "integrity": "5876ed4c6d44673faf5120f0a461a2ada2eb6c735329d3ebaf5ba1fc08387695" 180 + } 181 + }, 182 + "npm": { 183 + "@hono/swagger-ui@0.5.2_hono@4.10.6": { 184 + "integrity": "sha512-7wxLKdb8h7JTdZ+K8DJNE3KXQMIpJejkBTQjrYlUWF28Z1PGOKw6kUykARe5NTfueIN37jbyG/sBYsbzXzG53A==", 185 + "dependencies": [ 186 + "hono" 187 + ] 188 + }, 189 + "@noble/hashes@2.0.1": { 190 + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==" 191 + }, 192 + "@paralleldrive/cuid2@3.0.4": { 193 + "integrity": "sha512-sM6M2PWrByOEpN2QYAdulhEbSZmChwj0e52u4hpwB7u4PznFiNAavtE6m7O8tWUlzX+jT2eKKtc5/ZgX+IHrtg==", 194 + "dependencies": [ 195 + "@noble/hashes", 196 + "bignumber.js", 197 + "error-causes" 198 + ], 199 + "bin": true 200 + }, 201 + "@standard-schema/spec@1.0.0": { 202 + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" 203 + }, 204 + "bignumber.js@9.3.1": { 205 + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==" 206 + }, 207 + "chalk@5.6.2": { 208 + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==" 209 + }, 210 + "dayjs@1.11.19": { 211 + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==" 212 + }, 213 + "effect@3.19.3": { 214 + "integrity": "sha512-LodiPXiyUJWQ5LoMhUGbu0acD2ff5A5teJtUlLKDPVfoeWEBcZLlzK8BeVXpVa0f30UsdHouVCf0C/E0TxYMrA==", 215 + "dependencies": [ 216 + "@standard-schema/spec", 217 + "fast-check" 218 + ] 219 + }, 220 + "error-causes@3.0.2": { 221 + "integrity": "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw==" 222 + }, 223 + "fast-check@3.23.2": { 224 + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", 225 + "dependencies": [ 226 + "pure-rand" 227 + ] 228 + }, 229 + "hono@4.10.6": { 230 + "integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==" 231 + }, 232 + "kysely@0.27.6": { 233 + "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==" 234 + }, 235 + "moniker@0.1.2": { 236 + "integrity": "sha512-Uj9iV0QYr6281G+o0TvqhKwHHWB2Q/qUTT4LPQ3qDGc0r8cbMuqQjRXPZuVZ+gcL7APx+iQgE8lcfWPrj1LsLA==" 237 + }, 238 + "pure-rand@6.1.0": { 239 + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==" 240 + } 241 + }, 242 + "workspace": { 243 + "dependencies": [ 244 + "jsr:@cliffy/command@^1.0.0-rc.8", 245 + "jsr:@cliffy/flags@^1.0.0-rc.8", 246 + "jsr:@cliffy/prompt@^1.0.0-rc.8", 247 + "jsr:@cliffy/table@^1.0.0-rc.8", 248 + "jsr:@db/sqlite@0.12", 249 + "jsr:@es-toolkit/es-toolkit@^1.41.0", 250 + "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", 251 + "jsr:@std/assert@1", 252 + "jsr:@std/io@~0.225.2", 253 + "jsr:@std/path@^1.1.2", 254 + "jsr:@std/toml@^1.0.11", 255 + "jsr:@zod/zod@^4.1.12", 256 + "npm:@hono/swagger-ui@~0.5.2", 257 + "npm:@paralleldrive/cuid2@^3.0.4", 258 + "npm:chalk@^5.6.2", 259 + "npm:dayjs@^1.11.19", 260 + "npm:effect@^3.19.2", 261 + "npm:hono@^4.10.6", 262 + "npm:kysely@0.27.6", 263 + "npm:moniker@~0.1.2" 264 + ] 265 + } 266 + }
+406
main.ts
···
··· 1 + #!/usr/bin/env -S deno run --allow-run --allow-read --allow-env 2 + 3 + import { Command } from "@cliffy/command"; 4 + import { Secret } from "@cliffy/prompt"; 5 + import { readAll } from "@std/io"; 6 + import chalk from "chalk"; 7 + import { Effect, pipe } from "effect"; 8 + import pkg from "./deno.json" with { type: "json" }; 9 + import { initVmFile, mergeConfig, parseVmFile } from "./src/config.ts"; 10 + import { CONFIG_FILE_NAME } from "./src/constants.ts"; 11 + import { getImage } from "./src/images.ts"; 12 + import { createBridgeNetworkIfNeeded } from "./src/network.ts"; 13 + import { getImageArchivePath } from "./src/oras.ts"; 14 + import images from "./src/subcommands/images.ts"; 15 + import inspect from "./src/subcommands/inspect.ts"; 16 + import login from "./src/subcommands/login.ts"; 17 + import logout from "./src/subcommands/logout.ts"; 18 + import logs from "./src/subcommands/logs.ts"; 19 + import ps from "./src/subcommands/ps.ts"; 20 + import pull from "./src/subcommands/pull.ts"; 21 + import push from "./src/subcommands/push.ts"; 22 + import restart from "./src/subcommands/restart.ts"; 23 + import rm from "./src/subcommands/rm.ts"; 24 + import rmi from "./src/subcommands/rmi.ts"; 25 + import run from "./src/subcommands/run.ts"; 26 + import start from "./src/subcommands/start.ts"; 27 + import stop from "./src/subcommands/stop.ts"; 28 + import tag from "./src/subcommands/tag.ts"; 29 + import * as volumes from "./src/subcommands/volume.ts"; 30 + import { 31 + createDriveImageIfNeeded, 32 + downloadIso, 33 + emptyDiskImage, 34 + fileExists, 35 + isValidISOurl, 36 + NoSuchFileError, 37 + type Options, 38 + runQemu, 39 + } from "./src/utils.ts"; 40 + import serve from "./src/subcommands/serve.ts"; 41 + 42 + export * from "./src/mod.ts"; 43 + 44 + if (import.meta.main) { 45 + await new Command() 46 + .name("vmx") 47 + .version(pkg.version) 48 + .description("Manage and run headless VMs using QEMU") 49 + .arguments( 50 + "[path-or-url-to-iso:string]", 51 + ) 52 + .option("-o, --output <path:string>", "Output path for downloaded ISO") 53 + .option("-c, --cpu <type:string>", "Type of CPU to emulate", { 54 + default: "host", 55 + }) 56 + .option("-C, --cpus <number:number>", "Number of CPU cores", { 57 + default: 2, 58 + }) 59 + .option("-m, --memory <size:string>", "Amount of memory for the VM", { 60 + default: "2G", 61 + }) 62 + .option("-i, --image <path:string>", "Path to VM disk image") 63 + .option( 64 + "--disk-format <format:string>", 65 + "Disk image format (e.g., qcow2, raw)", 66 + { 67 + default: "raw", 68 + }, 69 + ) 70 + .option( 71 + "-s, --size <size:string>", 72 + "Size of the disk image to create if it doesn't exist (e.g., 20G)", 73 + { 74 + default: "20G", 75 + }, 76 + ) 77 + .option( 78 + "-b, --bridge <name:string>", 79 + "Name of the network bridge to use for networking (e.g., br0)", 80 + ) 81 + .option( 82 + "-d, --detach", 83 + "Run VM in the background and print VM name", 84 + ) 85 + .option( 86 + "-p, --port-forward <mappings:string>", 87 + "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 88 + ) 89 + .option( 90 + "--install", 91 + "Persist changes to the VM disk image", 92 + ) 93 + .example( 94 + "Create a default VM configuration file", 95 + "vmx init", 96 + ) 97 + .example( 98 + "Local ISO file", 99 + "vmx /path/to/freebsd.iso", 100 + ) 101 + .example( 102 + "Download URL", 103 + "vmx https://download.freebsd.org/ftp/releases/ISO-IMAGES/14.3/FreeBSD-14.3-RELEASE-amd64-disc1.iso", 104 + ) 105 + .example( 106 + "From OCI Registry", 107 + "vmx ghcr.io/tsirysndr/freebsd:15.0-BETA4", 108 + ) 109 + .example( 110 + "List running VMs", 111 + "vmx ps", 112 + ) 113 + .example( 114 + "List all VMs", 115 + "vmx ps --all", 116 + ) 117 + .example( 118 + "Start a VM", 119 + "vmx start my-vm", 120 + ) 121 + .example( 122 + "Stop a VM", 123 + "vmx stop my-vm", 124 + ) 125 + .example( 126 + "Inspect a VM", 127 + "vmx inspect my-vm", 128 + ) 129 + .action(async (options: Options, input?: string) => { 130 + const program = Effect.gen(function* () { 131 + if (input) { 132 + const [image, archivePath] = yield* Effect.all([ 133 + getImage(input), 134 + pipe( 135 + getImageArchivePath(input), 136 + Effect.catchAll(() => Effect.succeed(null)), 137 + ), 138 + ]); 139 + 140 + if (image || archivePath) { 141 + yield* Effect.tryPromise({ 142 + try: () => run(input), 143 + catch: () => {}, 144 + }); 145 + return; 146 + } 147 + } 148 + let isoPath: string | null = null; 149 + 150 + 151 + const config = yield* pipe( 152 + fileExists(CONFIG_FILE_NAME), 153 + Effect.flatMap(() => parseVmFile(CONFIG_FILE_NAME)), 154 + Effect.tap(() => Effect.log("Parsed VM configuration file.")), 155 + Effect.catchAll((error) => { 156 + if (error instanceof NoSuchFileError) { 157 + console.log( 158 + chalk.yellowBright(`No vmconfig.toml file found, please run:`), 159 + chalk.greenBright("vmx init"), 160 + ); 161 + Deno.exit(1); 162 + } 163 + return Effect.fail(error); 164 + }), 165 + ); 166 + 167 + if (!input && (isValidISOurl(config?.vm?.iso))) { 168 + isoPath = yield* downloadIso(config!.vm!.iso!, options); 169 + } 170 + 171 + options = yield* mergeConfig(config, options); 172 + 173 + if (options.image) { 174 + yield* createDriveImageIfNeeded(options); 175 + } 176 + 177 + if (!input && options.image) { 178 + const isEmpty = yield* emptyDiskImage(options.image); 179 + if (!isEmpty) { 180 + isoPath = null; 181 + } 182 + } 183 + 184 + if (options.bridge) { 185 + yield* createBridgeNetworkIfNeeded(options.bridge); 186 + } 187 + 188 + if (!input && !config?.vm?.iso && isValidISOurl(isoPath!)) { 189 + isoPath = null; 190 + } 191 + 192 + yield* runQemu(isoPath, options); 193 + }); 194 + 195 + await Effect.runPromise(program); 196 + }) 197 + .command("ps", "List all virtual machines") 198 + .option("--all, -a", "Show all virtual machines, including stopped ones") 199 + .action(async (options: { all?: unknown }) => { 200 + await ps(Boolean(options.all)); 201 + }) 202 + .command("start", "Start a virtual machine") 203 + .arguments("<vm-name:string>") 204 + .option("-c, --cpu <type:string>", "Type of CPU to emulate", { 205 + default: "host", 206 + }) 207 + .option("-C, --cpus <number:number>", "Number of CPU cores", { 208 + default: 2, 209 + }) 210 + .option("-m, --memory <size:string>", "Amount of memory for the VM", { 211 + default: "2G", 212 + }) 213 + .option("-i, --image <path:string>", "Path to VM disk image") 214 + .option( 215 + "--disk-format <format:string>", 216 + "Disk image format (e.g., qcow2, raw)", 217 + { 218 + default: "raw", 219 + }, 220 + ) 221 + .option( 222 + "--size <size:string>", 223 + "Size of the VM disk image to create if it doesn't exist (e.g., 20G)", 224 + { 225 + default: "20G", 226 + }, 227 + ) 228 + .option( 229 + "-b, --bridge <name:string>", 230 + "Name of the network bridge to use for networking (e.g., br0)", 231 + ) 232 + .option( 233 + "-d, --detach", 234 + "Run VM in the background and print VM name", 235 + ) 236 + .option( 237 + "-p, --port-forward <mappings:string>", 238 + "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 239 + ) 240 + .option( 241 + "-v, --volume <name:string>", 242 + "Name of the volume to attach to the VM, will be created if it doesn't exist", 243 + ) 244 + .action(async (options: unknown, vmName: string) => { 245 + await start(vmName, Boolean((options as { detach: boolean }).detach)); 246 + }) 247 + .command("stop", "Stop a virtual machine") 248 + .arguments("<vm-name:string>") 249 + .action(async (_options: unknown, vmName: string) => { 250 + await stop(vmName); 251 + }) 252 + .command("inspect", "Inspect a virtual machine") 253 + .arguments("<vm-name:string>") 254 + .action(async (_options: unknown, vmName: string) => { 255 + await inspect(vmName); 256 + }) 257 + .command("rm", "Remove a virtual machine") 258 + .arguments("<vm-name:string>") 259 + .action(async (_options: unknown, vmName: string) => { 260 + await rm(vmName); 261 + }) 262 + .command("logs", "View logs of a virtual machine") 263 + .option("--follow, -f", "Follow log output") 264 + .arguments("<vm-name:string>") 265 + .action(async (options: unknown, vmName: string) => { 266 + await logs(vmName, Boolean((options as { follow: boolean }).follow)); 267 + }) 268 + .command("restart", "Restart a virtual machine") 269 + .arguments("<vm-name:string>") 270 + .action(async (_options: unknown, vmName: string) => { 271 + await restart(vmName); 272 + }) 273 + .command("init", "Initialize a default VM configuration file") 274 + .action(async () => { 275 + await Effect.runPromise(initVmFile(CONFIG_FILE_NAME)); 276 + console.log( 277 + `New VM configuration file created at ${ 278 + chalk.greenBright("./") + 279 + chalk.greenBright(CONFIG_FILE_NAME) 280 + }`, 281 + ); 282 + console.log( 283 + `You can edit this file to customize your VM settings and then start the VM with:`, 284 + ); 285 + console.log(` ${chalk.greenBright(`vmx`)}`); 286 + }) 287 + .command( 288 + "pull", 289 + "Pull VM image from an OCI-compliant registry, e.g., ghcr.io, docker hub", 290 + ) 291 + .arguments("<image:string>") 292 + .action(async (_options: unknown, image: string) => { 293 + await pull(image); 294 + }) 295 + .command( 296 + "push", 297 + "Push VM image to an OCI-compliant registry, e.g., ghcr.io, docker hub", 298 + ) 299 + .arguments("<image:string>") 300 + .action(async (_options: unknown, image: string) => { 301 + await push(image); 302 + }) 303 + .command( 304 + "tag", 305 + "Create a tag 'image' that refers to the VM image of 'vm-name'", 306 + ) 307 + .arguments("<vm-name:string> <image:string>") 308 + .action(async (_options: unknown, vmName: string, image: string) => { 309 + await tag(vmName, image); 310 + }) 311 + .command( 312 + "login", 313 + "Authenticate to an OCI-compliant registry, e.g., ghcr.io, docker.io (docker hub), etc.", 314 + ) 315 + .option("-u, --username <username:string>", "Registry username") 316 + .arguments("<registry:string>") 317 + .action(async (options: unknown, registry: string) => { 318 + const username = (options as { username: string }).username; 319 + 320 + let password: string | undefined; 321 + const stdinIsTTY = Deno.stdin.isTerminal(); 322 + 323 + if (!stdinIsTTY) { 324 + const buffer = await readAll(Deno.stdin); 325 + password = new TextDecoder().decode(buffer).trim(); 326 + } else { 327 + password = await Secret.prompt("Registry Password: "); 328 + } 329 + 330 + console.log( 331 + `Authenticating to registry ${chalk.greenBright(registry)} as ${ 332 + chalk.greenBright(username) 333 + }...`, 334 + ); 335 + await login(username, password, registry); 336 + }) 337 + .command("logout", "Logout from an OCI-compliant registry") 338 + .arguments("<registry:string>") 339 + .action(async (_options: unknown, registry: string) => { 340 + await logout(registry); 341 + }) 342 + .command("images", "List all local VM images") 343 + .action(async () => { 344 + await images(); 345 + }) 346 + .command("rmi", "Remove a local VM image") 347 + .arguments("<image:string>") 348 + .action(async (_options: unknown, image: string) => { 349 + await rmi(image); 350 + }) 351 + .command("run", "Create and run a VM from an image") 352 + .arguments("<image:string>") 353 + .option("-c, --cpu <type:string>", "Type of CPU to emulate", { 354 + default: "host", 355 + }) 356 + .option("-C, --cpus <number:number>", "Number of CPU cores", { 357 + default: 2, 358 + }) 359 + .option("-m, --memory <size:string>", "Amount of memory for the VM", { 360 + default: "2G", 361 + }) 362 + .option( 363 + "-b, --bridge <name:string>", 364 + "Name of the network bridge to use for networking (e.g., br0)", 365 + ) 366 + .option( 367 + "-d, --detach", 368 + "Run VM in the background and print VM name", 369 + ) 370 + .option( 371 + "-p, --port-forward <mappings:string>", 372 + "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 373 + ) 374 + .option( 375 + "-v, --volume <name:string>", 376 + "Name of the volume to attach to the VM, will be created if it doesn't exist", 377 + ) 378 + .action(async (_options: unknown, image: string) => { 379 + await run(image); 380 + }) 381 + .command("volumes", "List all volumes") 382 + .action(async () => { 383 + await volumes.list(); 384 + }) 385 + .command( 386 + "volume", 387 + new Command() 388 + .command("rm", "Remove a volume") 389 + .arguments("<volume-name:string>") 390 + .action(async (_options: unknown, volumeName: string) => { 391 + await volumes.remove(volumeName); 392 + }) 393 + .command("inspect", "Inspect a volume") 394 + .arguments("<volume-name:string>") 395 + .action(async (_options: unknown, volumeName: string) => { 396 + await volumes.inspect(volumeName); 397 + }), 398 + ) 399 + .description("Manage volumes") 400 + .command("serve", "Start the HTTP API server") 401 + .option("-p, --port <port:number>", "Port to listen on", { default: 8889 }) 402 + .action(() => { 403 + serve(); 404 + }) 405 + .parse(Deno.args); 406 + }
+34
src/api/images.ts
···
··· 1 + import { Hono } from "hono"; 2 + import { Effect, pipe } from "effect"; 3 + import { parseParams, presentation } from "./utils.ts"; 4 + import { getImage, listImages } from "../images.ts"; 5 + 6 + const app = new Hono(); 7 + 8 + app.get("/", (c) => 9 + Effect.runPromise( 10 + pipe( 11 + listImages(), 12 + presentation(c), 13 + ), 14 + )); 15 + 16 + app.get("/:id", (c) => 17 + Effect.runPromise( 18 + pipe( 19 + parseParams(c), 20 + Effect.flatMap(({ id }) => getImage(id)), 21 + presentation(c), 22 + ), 23 + )); 24 + 25 + app.post("/", (c) => { 26 + return c.json({ message: "New image created" }); 27 + }); 28 + 29 + app.delete("/:id", (c) => { 30 + const { id } = c.req.param(); 31 + return c.json({ message: `Image with ID ${id} deleted` }); 32 + }); 33 + 34 + export default app;
+208
src/api/machines.ts
···
··· 1 + import { Hono } from "hono"; 2 + import { Data, Effect, pipe } from "effect"; 3 + import { 4 + createVolumeIfNeeded, 5 + handleError, 6 + parseCreateMachineRequest, 7 + parseParams, 8 + parseQueryParams, 9 + parseStartRequest, 10 + presentation, 11 + } from "./utils.ts"; 12 + import { DEFAULT_VERSION, getInstanceState } from "../mod.ts"; 13 + import { 14 + listInstances, 15 + removeInstanceState, 16 + saveInstanceState, 17 + } from "../state.ts"; 18 + import { findVm, killProcess, updateToStopped } from "../subcommands/stop.ts"; 19 + import { 20 + buildQemuArgs, 21 + createLogsDir, 22 + failIfVMRunning, 23 + setupFirmware, 24 + startDetachedQemu, 25 + } from "../subcommands/start.ts"; 26 + import type { NewMachine } from "../types.ts"; 27 + import { createId } from "@paralleldrive/cuid2"; 28 + import { generateRandomMacAddress } from "../network.ts"; 29 + import Moniker from "moniker"; 30 + import { getImage } from "../images.ts"; 31 + 32 + export class ImageNotFoundError extends Data.TaggedError("ImageNotFoundError")<{ 33 + id: string; 34 + }> {} 35 + 36 + export class RemoveRunningVmError extends Data.TaggedError( 37 + "RemoveRunningVmError", 38 + )<{ 39 + id: string; 40 + }> {} 41 + 42 + const app = new Hono(); 43 + 44 + app.get("/", (c) => 45 + Effect.runPromise( 46 + pipe( 47 + parseQueryParams(c), 48 + Effect.flatMap((params) => 49 + listInstances( 50 + params.all === "true" || params.all === "1", 51 + ) 52 + ), 53 + presentation(c), 54 + ), 55 + )); 56 + 57 + app.post("/", (c) => 58 + Effect.runPromise( 59 + pipe( 60 + parseCreateMachineRequest(c), 61 + Effect.flatMap((params: NewMachine) => 62 + Effect.gen(function* () { 63 + const image = yield* getImage(params.image); 64 + if (!image) { 65 + return yield* Effect.fail( 66 + new ImageNotFoundError({ id: params.image }), 67 + ); 68 + } 69 + 70 + const volume = params.volume 71 + ? yield* createVolumeIfNeeded(image, params.volume) 72 + : undefined; 73 + 74 + const macAddress = yield* generateRandomMacAddress(); 75 + const id = createId(); 76 + yield* saveInstanceState({ 77 + id, 78 + name: Moniker.choose(), 79 + bridge: params.bridge, 80 + macAddress, 81 + memory: params.memory || "2G", 82 + cpus: params.cpus || 8, 83 + cpu: params.cpu || "host", 84 + diskSize: "20G", 85 + diskFormat: volume ? "qcow2" : "raw", 86 + portForward: params.portForward 87 + ? params.portForward.join(",") 88 + : undefined, 89 + drivePath: volume ? volume.path : image.path, 90 + version: image.tag ?? DEFAULT_VERSION, 91 + status: "STOPPED", 92 + pid: 0, 93 + }); 94 + 95 + const createdVm = yield* findVm(id); 96 + return createdVm; 97 + }) 98 + ), 99 + presentation(c), 100 + Effect.catchAll((error) => handleError(error, c)), 101 + ), 102 + )); 103 + 104 + app.get("/:id", (c) => 105 + Effect.runPromise( 106 + pipe( 107 + parseParams(c), 108 + Effect.flatMap(({ id }) => getInstanceState(id)), 109 + presentation(c), 110 + ), 111 + )); 112 + 113 + app.delete("/:id", (c) => 114 + Effect.runPromise( 115 + pipe( 116 + parseParams(c), 117 + Effect.flatMap(({ id }) => findVm(id)), 118 + Effect.flatMap((vm) => 119 + vm.status === "RUNNING" 120 + ? Effect.fail(new RemoveRunningVmError({ id: vm.id })) 121 + : Effect.succeed(vm) 122 + ), 123 + Effect.flatMap((vm) => 124 + Effect.gen(function* () { 125 + yield* removeInstanceState(vm.id); 126 + return vm; 127 + }) 128 + ), 129 + presentation(c), 130 + Effect.catchAll((error) => handleError(error, c)), 131 + ), 132 + )); 133 + 134 + app.post("/:id/start", (c) => 135 + Effect.runPromise( 136 + pipe( 137 + Effect.all([parseParams(c), parseStartRequest(c)]), 138 + Effect.flatMap(( 139 + [{ id }, startRequest], 140 + ) => Effect.all([findVm(id), Effect.succeed(startRequest)])), 141 + Effect.flatMap(([vm, startRequest]) => 142 + Effect.gen(function* () { 143 + yield* failIfVMRunning(vm); 144 + const firmwareArgs = yield* setupFirmware(); 145 + const qemuArgs = yield* buildQemuArgs({ 146 + ...vm, 147 + cpu: String(startRequest.cpu ?? vm.cpu), 148 + cpus: startRequest.cpus ?? vm.cpus, 149 + memory: startRequest.memory ?? vm.memory, 150 + portForward: startRequest.portForward 151 + ? startRequest.portForward.join(",") 152 + : vm.portForward, 153 + }, firmwareArgs); 154 + yield* createLogsDir(); 155 + yield* startDetachedQemu(vm.id, vm, qemuArgs); 156 + return { ...vm, status: "RUNNING" }; 157 + }) 158 + ), 159 + presentation(c), 160 + Effect.catchAll((error) => handleError(error, c)), 161 + ), 162 + )); 163 + 164 + app.post("/:id/stop", (c) => 165 + Effect.runPromise( 166 + pipe( 167 + parseParams(c), 168 + Effect.flatMap(({ id }) => findVm(id)), 169 + Effect.flatMap(killProcess), 170 + Effect.flatMap(updateToStopped), 171 + presentation(c), 172 + Effect.catchAll((error) => handleError(error, c)), 173 + ), 174 + )); 175 + 176 + app.post("/:id/restart", (c) => 177 + Effect.runPromise( 178 + pipe( 179 + parseParams(c), 180 + Effect.flatMap(({ id }) => findVm(id)), 181 + Effect.flatMap(killProcess), 182 + Effect.flatMap(updateToStopped), 183 + Effect.flatMap(() => Effect.all([parseParams(c), parseStartRequest(c)])), 184 + Effect.flatMap(( 185 + [{ id }, startRequest], 186 + ) => Effect.all([findVm(id), Effect.succeed(startRequest)])), 187 + Effect.flatMap(([vm, startRequest]) => 188 + Effect.gen(function* () { 189 + const firmwareArgs = yield* setupFirmware(); 190 + const qemuArgs = yield* buildQemuArgs({ 191 + ...vm, 192 + cpu: String(startRequest.cpus ?? vm.cpu), 193 + memory: startRequest.memory ?? vm.memory, 194 + portForward: startRequest.portForward 195 + ? startRequest.portForward.join(",") 196 + : vm.portForward, 197 + }, firmwareArgs); 198 + yield* createLogsDir(); 199 + yield* startDetachedQemu(vm.id, vm, qemuArgs); 200 + return { ...vm, status: "RUNNING" }; 201 + }) 202 + ), 203 + presentation(c), 204 + Effect.catchAll((error) => handleError(error, c)), 205 + ), 206 + )); 207 + 208 + export default app;
+46
src/api/mod.ts
···
··· 1 + import machines from "./machines.ts"; 2 + import images from "./images.ts"; 3 + import volumes from "./volumes.ts"; 4 + import { Hono } from "hono"; 5 + import { logger } from "hono/logger"; 6 + import { cors } from "hono/cors"; 7 + import { bearerAuth } from "hono/bearer-auth"; 8 + import { parseFlags } from "@cliffy/flags"; 9 + 10 + export { images, machines, volumes }; 11 + 12 + export default function () { 13 + const token = Deno.env.get("FREEBSD_UP_API_TOKEN") || 14 + crypto.randomUUID(); 15 + const { flags } = parseFlags(Deno.args); 16 + 17 + if (!Deno.env.get("FREEBSD_UP_API_TOKEN")) { 18 + console.log(`Using API token: ${token}`); 19 + } else { 20 + console.log( 21 + `Using provided API token from environment variable FREEBSD_UP_API_TOKEN`, 22 + ); 23 + } 24 + 25 + const app = new Hono(); 26 + 27 + app.use(logger()); 28 + app.use(cors()); 29 + 30 + app.use("/images/*", bearerAuth({ token })); 31 + app.use("/machines/*", bearerAuth({ token })); 32 + app.use("/volumes/*", bearerAuth({ token })); 33 + 34 + app.route("/images", images); 35 + app.route("/machines", machines); 36 + app.route("/volumes", volumes); 37 + 38 + const port = Number( 39 + flags.port || flags.p || 40 + (Deno.env.get("FREEBSD_UP_PORT") 41 + ? Number(Deno.env.get("FREEBSD_UP_PORT")) 42 + : 8890), 43 + ); 44 + 45 + Deno.serve({ port }, app.fetch); 46 + }
+158
src/api/utils.ts
···
··· 1 + import { Data, Effect } from "effect"; 2 + import type { Context } from "hono"; 3 + import { 4 + type CommandError, 5 + StopCommandError, 6 + VmNotFoundError, 7 + } from "../subcommands/stop.ts"; 8 + import { VmAlreadyRunningError } from "../subcommands/start.ts"; 9 + import { 10 + MachineParamsSchema, 11 + NewMachineSchema, 12 + NewVolumeSchema, 13 + } from "../types.ts"; 14 + import type { Image, Volume } from "../db.ts"; 15 + import { createVolume, getVolume } from "../volumes.ts"; 16 + import { ImageNotFoundError, RemoveRunningVmError } from "./machines.ts"; 17 + 18 + export const parseQueryParams = (c: Context) => Effect.succeed(c.req.query()); 19 + 20 + export const parseParams = (c: Context) => Effect.succeed(c.req.param()); 21 + 22 + export const presentation = (c: Context) => 23 + Effect.flatMap((data) => Effect.succeed(c.json(data))); 24 + 25 + export class ParseRequestError extends Data.TaggedError("ParseRequestError")<{ 26 + cause?: unknown; 27 + message: string; 28 + }> {} 29 + 30 + export const handleError = ( 31 + error: 32 + | VmNotFoundError 33 + | StopCommandError 34 + | CommandError 35 + | ParseRequestError 36 + | VmAlreadyRunningError 37 + | ImageNotFoundError 38 + | RemoveRunningVmError 39 + | Error, 40 + c: Context, 41 + ) => 42 + Effect.sync(() => { 43 + if (error instanceof VmNotFoundError) { 44 + return c.json( 45 + { message: "VM not found", code: "VM_NOT_FOUND" }, 46 + 404, 47 + ); 48 + } 49 + if (error instanceof StopCommandError) { 50 + return c.json( 51 + { 52 + message: error.message || 53 + `Failed to stop VM ${error.vmName}`, 54 + code: "STOP_COMMAND_ERROR", 55 + }, 56 + 500, 57 + ); 58 + } 59 + 60 + if (error instanceof ParseRequestError) { 61 + return c.json( 62 + { 63 + message: error.message || "Failed to parse request body", 64 + code: "PARSE_BODY_ERROR", 65 + }, 66 + 400, 67 + ); 68 + } 69 + 70 + if (error instanceof VmAlreadyRunningError) { 71 + return c.json( 72 + { 73 + message: `VM ${error.name} is already running`, 74 + code: "VM_ALREADY_RUNNING", 75 + }, 76 + 400, 77 + ); 78 + } 79 + 80 + if (error instanceof ImageNotFoundError) { 81 + return c.json( 82 + { 83 + message: `Image ${error.id} not found`, 84 + code: "IMAGE_NOT_FOUND", 85 + }, 86 + 404, 87 + ); 88 + } 89 + 90 + if (error instanceof RemoveRunningVmError) { 91 + return c.json( 92 + { 93 + message: 94 + `Cannot remove running VM with ID ${error.id}. Please stop it first.`, 95 + code: "REMOVE_RUNNING_VM_ERROR", 96 + }, 97 + 400, 98 + ); 99 + } 100 + 101 + return c.json( 102 + { message: error instanceof Error ? error.message : String(error) }, 103 + 500, 104 + ); 105 + }); 106 + 107 + export const parseStartRequest = (c: Context) => 108 + Effect.tryPromise({ 109 + try: async () => { 110 + const body = await c.req.json(); 111 + return MachineParamsSchema.parse(body); 112 + }, 113 + catch: (error) => 114 + new ParseRequestError({ 115 + cause: error, 116 + message: error instanceof Error ? error.message : String(error), 117 + }), 118 + }); 119 + 120 + export const parseCreateMachineRequest = (c: Context) => 121 + Effect.tryPromise({ 122 + try: async () => { 123 + const body = await c.req.json(); 124 + return NewMachineSchema.parse(body); 125 + }, 126 + catch: (error) => 127 + new ParseRequestError({ 128 + cause: error, 129 + message: error instanceof Error ? error.message : String(error), 130 + }), 131 + }); 132 + 133 + export const createVolumeIfNeeded = ( 134 + image: Image, 135 + volumeName: string, 136 + size?: string, 137 + ): Effect.Effect<Volume, Error, never> => 138 + Effect.gen(function* () { 139 + const volume = yield* getVolume(volumeName); 140 + if (volume) { 141 + return volume; 142 + } 143 + 144 + return yield* createVolume(volumeName, image, size); 145 + }); 146 + 147 + export const parseCreateVolumeRequest = (c: Context) => 148 + Effect.tryPromise({ 149 + try: async () => { 150 + const body = await c.req.json(); 151 + return NewVolumeSchema.parse(body); 152 + }, 153 + catch: (error) => 154 + new ParseRequestError({ 155 + cause: error, 156 + message: error instanceof Error ? error.message : String(error), 157 + }), 158 + });
+71
src/api/volumes.ts
···
··· 1 + import { Hono } from "hono"; 2 + import { Effect, pipe } from "effect"; 3 + import { 4 + createVolumeIfNeeded, 5 + handleError, 6 + parseCreateVolumeRequest, 7 + parseParams, 8 + presentation, 9 + } from "./utils.ts"; 10 + import { listVolumes } from "../mod.ts"; 11 + import { deleteVolume, getVolume } from "../volumes.ts"; 12 + import type { NewVolume } from "../types.ts"; 13 + import { getImage } from "../images.ts"; 14 + import { ImageNotFoundError } from "./machines.ts"; 15 + 16 + const app = new Hono(); 17 + 18 + app.get("/", (c) => 19 + Effect.runPromise( 20 + pipe( 21 + listVolumes(), 22 + presentation(c), 23 + ), 24 + )); 25 + 26 + app.get("/:id", (c) => 27 + Effect.runPromise( 28 + pipe( 29 + parseParams(c), 30 + Effect.flatMap(({ id }) => getVolume(id)), 31 + presentation(c), 32 + ), 33 + )); 34 + 35 + app.delete("/:id", (c) => 36 + Effect.runPromise( 37 + pipe( 38 + parseParams(c), 39 + Effect.flatMap(({ id }) => 40 + Effect.gen(function* () { 41 + const volume = yield* getVolume(id); 42 + yield* deleteVolume(id); 43 + return volume; 44 + }) 45 + ), 46 + presentation(c), 47 + ), 48 + )); 49 + 50 + app.post("/", (c) => 51 + Effect.runPromise( 52 + pipe( 53 + parseCreateVolumeRequest(c), 54 + Effect.flatMap((params: NewVolume) => 55 + Effect.gen(function* () { 56 + const image = yield* getImage(params.baseImage); 57 + if (!image) { 58 + return yield* Effect.fail( 59 + new ImageNotFoundError({ id: params.baseImage }), 60 + ); 61 + } 62 + 63 + return yield* createVolumeIfNeeded(image, params.name, params.size); 64 + }) 65 + ), 66 + presentation(c), 67 + Effect.catchAll((error) => handleError(error, c)), 68 + ), 69 + )); 70 + 71 + export default app;
+128
src/config.ts
···
··· 1 + import { parseFlags } from "@cliffy/flags"; 2 + import _ from "@es-toolkit/es-toolkit/compat"; 3 + import * as toml from "@std/toml"; 4 + import z from "@zod/zod"; 5 + import { Data, Effect } from "effect"; 6 + import type { Options } from "./utils.ts"; 7 + import { UBUNTU_ISO_URL } from "./constants.ts"; 8 + 9 + export const VmConfigSchema = z.object({ 10 + vm: z 11 + .object({ 12 + iso: z.string(), 13 + output: z.string(), 14 + cpu: z.string(), 15 + cpus: z.number(), 16 + memory: z.string(), 17 + image: z.string(), 18 + disk_format: z.enum(["qcow2", "raw"]), 19 + size: z.string(), 20 + }) 21 + .partial(), 22 + network: z 23 + .object({ 24 + bridge: z.string(), 25 + port_forward: z.string(), 26 + }) 27 + .partial(), 28 + options: z 29 + .object({ 30 + detach: z.boolean(), 31 + }) 32 + .partial(), 33 + }); 34 + 35 + export type VmConfig = z.infer<typeof VmConfigSchema>; 36 + 37 + class VmConfigError extends Data.TaggedError("VmConfigError")<{ 38 + cause?: string; 39 + }> {} 40 + 41 + export const initVmFile = ( 42 + path: string 43 + ): Effect.Effect<void, VmConfigError, never> => 44 + Effect.tryPromise({ 45 + try: async () => { 46 + const defaultConfig: VmConfig = { 47 + vm: { 48 + iso: UBUNTU_ISO_URL, 49 + cpu: "host", 50 + cpus: 2, 51 + memory: "2G", 52 + }, 53 + network: { 54 + port_forward: "2222:22", 55 + }, 56 + options: { 57 + detach: false, 58 + }, 59 + }; 60 + const tomlString = toml.stringify(defaultConfig); 61 + await Deno.writeTextFile(path, tomlString); 62 + }, 63 + catch: (error) => new VmConfigError({ cause: String(error) }), 64 + }); 65 + 66 + export const parseVmFile = ( 67 + path: string 68 + ): Effect.Effect<VmConfig, VmConfigError, never> => 69 + Effect.tryPromise({ 70 + try: async () => { 71 + const fileContent = await Deno.readTextFile(path); 72 + const parsedToml = toml.parse(fileContent); 73 + return VmConfigSchema.parse(parsedToml); 74 + }, 75 + catch: (error) => new VmConfigError({ cause: String(error) }), 76 + }); 77 + 78 + export const mergeConfig = ( 79 + config: VmConfig | null, 80 + options: Options 81 + ): Effect.Effect<Options, never, never> => { 82 + const { flags } = parseFlags(Deno.args); 83 + flags.image = flags.i || flags.image; 84 + flags.memory = flags.m || flags.memory; 85 + flags.cpus = flags.C || flags.cpus; 86 + flags.cpu = flags.c || flags.cpu; 87 + flags.portForward = flags.p || flags.portForward; 88 + flags.bridge = flags.b || flags.bridge; 89 + flags.size = flags.s || flags.size; 90 + 91 + const defaultConfig: VmConfig = { 92 + vm: { 93 + iso: _.get(config, "vm.iso"), 94 + cpu: _.get(config, "vm.cpu", "host"), 95 + cpus: _.get(config, "vm.cpus", 2), 96 + memory: _.get(config, "vm.memory", "2G"), 97 + image: _.get(config, "vm.image", options.image), 98 + disk_format: _.get(config, "vm.disk_format", "raw"), 99 + size: _.get(config, "vm.size", "20G"), 100 + }, 101 + network: { 102 + bridge: _.get(config, "network.bridge"), 103 + port_forward: _.get(config, "network.port_forward", "2222:22"), 104 + }, 105 + options: { 106 + detach: _.get(config, "options.detach", false), 107 + }, 108 + }; 109 + return Effect.succeed({ 110 + memory: _.get(flags, "memory", defaultConfig.vm.memory!) as string, 111 + cpus: _.get(flags, "cpus", defaultConfig.vm.cpus!) as number, 112 + cpu: _.get(flags, "cpu", defaultConfig.vm.cpu!) as string, 113 + diskFormat: _.get( 114 + flags, 115 + "diskFormat", 116 + defaultConfig.vm.disk_format! 117 + ) as string, 118 + portForward: _.get( 119 + flags, 120 + "portForward", 121 + defaultConfig.network.port_forward! 122 + ) as string, 123 + image: _.get(flags, "image", defaultConfig.vm.image!) as string, 124 + bridge: _.get(flags, "bridge", defaultConfig.network.bridge!) as string, 125 + size: _.get(flags, "size", defaultConfig.vm.size!) as string, 126 + install: flags.install, 127 + }); 128 + };
+19
src/constants.ts
···
··· 1 + const getCurrentArch = (): string => { 2 + switch (Deno.build.arch) { 3 + case "x86_64": 4 + return "amd64"; 5 + case "aarch64": 6 + return "arm64"; 7 + default: 8 + return Deno.build.arch; 9 + } 10 + }; 11 + 12 + export const CONFIG_DIR: string = `${Deno.env.get("HOME")}/.vmx`; 13 + export const DB_PATH: string = `${CONFIG_DIR}/state.sqlite`; 14 + export const LOGS_DIR: string = `${CONFIG_DIR}/logs`; 15 + export const EMPTY_DISK_THRESHOLD_KB: number = 100; 16 + export const CONFIG_FILE_NAME: string = "vmconfig.toml"; 17 + export const IMAGE_DIR: string = `${CONFIG_DIR}/images`; 18 + export const VOLUME_DIR: string = `${CONFIG_DIR}/volumes`; 19 + export const UBUNTU_ISO_URL: string = `https://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04.3-live-server-${getCurrentArch()}.iso`;
+12
src/context.ts
···
··· 1 + import { DB_PATH } from "./constants.ts"; 2 + import { createDb, type Database } from "./db.ts"; 3 + import { migrateToLatest } from "./migrations.ts"; 4 + 5 + export const db: Database = createDb(DB_PATH); 6 + await migrateToLatest(db); 7 + 8 + export const ctx = { 9 + db, 10 + }; 11 + 12 + export type Context = typeof ctx;
+63
src/db.ts
···
··· 1 + import { Database as Sqlite } from "@db/sqlite"; 2 + import { DenoSqlite3Dialect } from "@soapbox/kysely-deno-sqlite"; 3 + import { Kysely } from "kysely"; 4 + import { CONFIG_DIR } from "./constants.ts"; 5 + import type { STATUS } from "./types.ts"; 6 + 7 + export const createDb = (location: string): Database => { 8 + Deno.mkdirSync(CONFIG_DIR, { recursive: true }); 9 + return new Kysely<DatabaseSchema>({ 10 + dialect: new DenoSqlite3Dialect({ 11 + database: new Sqlite(location), 12 + }), 13 + }); 14 + }; 15 + 16 + export type DatabaseSchema = { 17 + virtual_machines: VirtualMachine; 18 + images: Image; 19 + volumes: Volume; 20 + }; 21 + 22 + export type VirtualMachine = { 23 + id: string; 24 + name: string; 25 + bridge?: string; 26 + macAddress: string; 27 + memory: string; 28 + cpus: number; 29 + cpu: string; 30 + diskSize: string; 31 + drivePath?: string; 32 + diskFormat: string; 33 + isoPath?: string; 34 + portForward?: string; 35 + version: string; 36 + status: STATUS; 37 + pid: number; 38 + volume?: string; 39 + createdAt?: string; 40 + updatedAt?: string; 41 + }; 42 + 43 + export type Image = { 44 + id: string; 45 + repository: string; 46 + tag: string; 47 + size: number; 48 + path: string; 49 + format: string; 50 + digest?: string; 51 + createdAt?: string; 52 + }; 53 + 54 + export type Volume = { 55 + id: string; 56 + name: string; 57 + baseImageId: string; 58 + path: string; 59 + size?: string; 60 + createdAt?: string; 61 + }; 62 + 63 + export type Database = Kysely<DatabaseSchema>;
+87
src/images.ts
···
··· 1 + import { Data, Effect } from "effect"; 2 + import type { DeleteResult, InsertResult } from "kysely"; 3 + import { ctx } from "./context.ts"; 4 + import type { Image } from "./db.ts"; 5 + 6 + export class DbError extends Data.TaggedError("DatabaseError")<{ 7 + message?: string; 8 + }> {} 9 + 10 + export const listImages = (): Effect.Effect<Image[], DbError, never> => 11 + Effect.tryPromise({ 12 + try: () => ctx.db.selectFrom("images").selectAll().execute(), 13 + catch: (error) => 14 + new DbError({ 15 + message: error instanceof Error ? error.message : String(error), 16 + }), 17 + }); 18 + 19 + export const getImage = ( 20 + id: string, 21 + ): Effect.Effect<Image | undefined, DbError, never> => 22 + Effect.tryPromise({ 23 + try: () => 24 + ctx.db 25 + .selectFrom("images") 26 + .selectAll() 27 + .where((eb) => 28 + eb.or([ 29 + eb.and([ 30 + eb("repository", "=", id.split(":")[0]), 31 + eb("tag", "=", id.split(":")[1] || "latest"), 32 + ]), 33 + eb("id", "=", id), 34 + eb("digest", "=", id), 35 + ]) 36 + ) 37 + .executeTakeFirst(), 38 + catch: (error) => 39 + new DbError({ 40 + message: error instanceof Error ? error.message : String(error), 41 + }), 42 + }); 43 + 44 + export const saveImage = ( 45 + image: Image, 46 + ): Effect.Effect<InsertResult[], DbError, never> => 47 + Effect.tryPromise({ 48 + try: () => 49 + ctx.db.insertInto("images") 50 + .values(image) 51 + .onConflict((oc) => 52 + oc 53 + .column("repository") 54 + .column("tag") 55 + .doUpdateSet({ 56 + size: image.size, 57 + path: image.path, 58 + format: image.format, 59 + digest: image.digest, 60 + }) 61 + ) 62 + .execute(), 63 + catch: (error) => 64 + new DbError({ 65 + message: error instanceof Error ? error.message : String(error), 66 + }), 67 + }); 68 + 69 + export const deleteImage = ( 70 + id: string, 71 + ): Effect.Effect<DeleteResult[], DbError, never> => 72 + Effect.tryPromise({ 73 + try: () => 74 + ctx.db.deleteFrom("images").where((eb) => 75 + eb.or([ 76 + eb.and([ 77 + eb("repository", "=", id.split(":")[0]), 78 + eb("tag", "=", id.split(":")[1] || "latest"), 79 + ]), 80 + eb("id", "=", id), 81 + ]) 82 + ).execute(), 83 + catch: (error) => 84 + new DbError({ 85 + message: error instanceof Error ? error.message : String(error), 86 + }), 87 + });
+299
src/migrations.ts
···
··· 1 + import { 2 + type Kysely, 3 + type Migration, 4 + type MigrationProvider, 5 + Migrator, 6 + sql, 7 + } from "kysely"; 8 + import type { Database } from "./db.ts"; 9 + 10 + const migrations: Record<string, Migration> = {}; 11 + 12 + const migrationProvider: MigrationProvider = { 13 + // deno-lint-ignore require-await 14 + async getMigrations() { 15 + return migrations; 16 + }, 17 + }; 18 + 19 + migrations["001"] = { 20 + async up(db: Kysely<unknown>): Promise<void> { 21 + await db.schema 22 + .createTable("virtual_machines") 23 + .addColumn("id", "varchar", (col) => col.primaryKey()) 24 + .addColumn("name", "varchar", (col) => col.notNull().unique()) 25 + .addColumn("bridge", "varchar") 26 + .addColumn("macAddress", "varchar", (col) => col.notNull().unique()) 27 + .addColumn("memory", "varchar", (col) => col.notNull()) 28 + .addColumn("cpus", "integer", (col) => col.notNull()) 29 + .addColumn("cpu", "varchar", (col) => col.notNull()) 30 + .addColumn("diskSize", "varchar", (col) => col.notNull()) 31 + .addColumn("drivePath", "varchar") 32 + .addColumn("version", "varchar", (col) => col.notNull()) 33 + .addColumn("diskFormat", "varchar") 34 + .addColumn("isoPath", "varchar") 35 + .addColumn("status", "varchar", (col) => col.notNull()) 36 + .addColumn("pid", "integer") 37 + .addColumn( 38 + "createdAt", 39 + "varchar", 40 + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 41 + ) 42 + .addColumn( 43 + "updatedAt", 44 + "varchar", 45 + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 46 + ) 47 + .execute(); 48 + }, 49 + 50 + async down(db: Kysely<unknown>): Promise<void> { 51 + await db.schema.dropTable("virtual_machines").execute(); 52 + }, 53 + }; 54 + 55 + migrations["002"] = { 56 + async up(db: Kysely<unknown>): Promise<void> { 57 + await db.schema 58 + .alterTable("virtual_machines") 59 + .addColumn("portForward", "varchar") 60 + .execute(); 61 + }, 62 + 63 + async down(db: Kysely<unknown>): Promise<void> { 64 + await db.schema 65 + .alterTable("virtual_machines") 66 + .dropColumn("portForward") 67 + .execute(); 68 + }, 69 + }; 70 + 71 + migrations["003"] = { 72 + async up(db: Kysely<unknown>): Promise<void> { 73 + await db.schema 74 + .createTable("images") 75 + .addColumn("id", "varchar", (col) => col.primaryKey()) 76 + .addColumn("repository", "varchar", (col) => col.notNull()) 77 + .addColumn("tag", "varchar", (col) => col.notNull()) 78 + .addColumn("size", "integer", (col) => col.notNull()) 79 + .addColumn("path", "varchar", (col) => col.notNull()) 80 + .addColumn("createdAt", "varchar", (col) => col.notNull()) 81 + .execute(); 82 + }, 83 + 84 + async down(db: Kysely<unknown>): Promise<void> { 85 + await db.schema.dropTable("images").execute(); 86 + }, 87 + }; 88 + 89 + migrations["004"] = { 90 + async up(db: Kysely<unknown>): Promise<void> { 91 + await db.schema 92 + .alterTable("images") 93 + .addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2")) 94 + .execute(); 95 + }, 96 + 97 + async down(db: Kysely<unknown>): Promise<void> { 98 + await db.schema 99 + .alterTable("images") 100 + .dropColumn("format") 101 + .execute(); 102 + }, 103 + }; 104 + 105 + migrations["005"] = { 106 + async up(db: Kysely<unknown>): Promise<void> { 107 + await db.schema 108 + .createTable("images_new") 109 + .addColumn("id", "varchar", (col) => col.primaryKey()) 110 + .addColumn("repository", "varchar", (col) => col.notNull()) 111 + .addColumn("tag", "varchar", (col) => col.notNull()) 112 + .addColumn("size", "integer", (col) => col.notNull()) 113 + .addColumn("path", "varchar", (col) => col.notNull()) 114 + .addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2")) 115 + .addColumn("createdAt", "varchar", (col) => col.notNull()) 116 + .addUniqueConstraint("images_repository_tag_unique", [ 117 + "repository", 118 + "tag", 119 + ]) 120 + .execute(); 121 + 122 + await sql` 123 + INSERT INTO images_new (id, repository, tag, size, path, format, createdAt) 124 + SELECT id, repository, tag, size, path, format, createdAt FROM images 125 + `.execute(db); 126 + 127 + await db.schema.dropTable("images").execute(); 128 + await sql`ALTER TABLE images_new RENAME TO images`.execute(db); 129 + }, 130 + 131 + async down(db: Kysely<unknown>): Promise<void> { 132 + await db.schema 133 + .createTable("images_old") 134 + .addColumn("id", "varchar", (col) => col.primaryKey()) 135 + .addColumn("repository", "varchar", (col) => col.notNull()) 136 + .addColumn("tag", "varchar", (col) => col.notNull()) 137 + .addColumn("size", "integer", (col) => col.notNull()) 138 + .addColumn("path", "varchar", (col) => col.notNull()) 139 + .addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2")) 140 + .addColumn("createdAt", "varchar", (col) => col.notNull()) 141 + .execute(); 142 + 143 + await sql` 144 + INSERT INTO images_old (id, repository, tag, size, path, format, createdAt) 145 + SELECT id, repository, tag, size, path, format, createdAt FROM images 146 + `.execute(db); 147 + 148 + await db.schema.dropTable("images").execute(); 149 + await sql`ALTER TABLE images_old RENAME TO images`.execute(db); 150 + }, 151 + }; 152 + 153 + migrations["006"] = { 154 + async up(db: Kysely<unknown>): Promise<void> { 155 + await db.schema 156 + .createTable("images_new") 157 + .addColumn("id", "varchar", (col) => col.primaryKey()) 158 + .addColumn("repository", "varchar", (col) => col.notNull()) 159 + .addColumn("tag", "varchar", (col) => col.notNull()) 160 + .addColumn("size", "integer", (col) => col.notNull()) 161 + .addColumn("path", "varchar", (col) => col.notNull()) 162 + .addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2")) 163 + .addColumn( 164 + "createdAt", 165 + "varchar", 166 + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 167 + ) 168 + .addUniqueConstraint("images_repository_tag_unique", [ 169 + "repository", 170 + "tag", 171 + ]) 172 + .execute(); 173 + 174 + await sql` 175 + INSERT INTO images_new (id, repository, tag, size, path, format, createdAt) 176 + SELECT id, repository, tag, size, path, format, createdAt FROM images 177 + `.execute(db); 178 + 179 + await db.schema.dropTable("images").execute(); 180 + await sql`ALTER TABLE images_new RENAME TO images`.execute(db); 181 + }, 182 + 183 + async down(db: Kysely<unknown>): Promise<void> { 184 + await db.schema 185 + .createTable("images_old") 186 + .addColumn("id", "varchar", (col) => col.primaryKey()) 187 + .addColumn("repository", "varchar", (col) => col.notNull()) 188 + .addColumn("tag", "varchar", (col) => col.notNull()) 189 + .addColumn("size", "integer", (col) => col.notNull()) 190 + .addColumn("path", "varchar", (col) => col.notNull()) 191 + .addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2")) 192 + .addColumn("createdAt", "varchar", (col) => col.notNull()) 193 + .addUniqueConstraint("images_repository_tag_unique", [ 194 + "repository", 195 + "tag", 196 + ]) 197 + .execute(); 198 + 199 + await sql` 200 + INSERT INTO images_old (id, repository, tag, size, path, format, createdAt) 201 + SELECT id, repository, tag, size, path, format, createdAt FROM images 202 + `.execute(db); 203 + }, 204 + }; 205 + 206 + migrations["007"] = { 207 + async up(db: Kysely<unknown>): Promise<void> { 208 + await db.schema 209 + .alterTable("images") 210 + .addColumn("digest", "varchar") 211 + .execute(); 212 + }, 213 + async down(db: Kysely<unknown>): Promise<void> { 214 + await db.schema 215 + .alterTable("images") 216 + .dropColumn("digest") 217 + .execute(); 218 + }, 219 + }; 220 + 221 + migrations["008"] = { 222 + async up(db: Kysely<unknown>): Promise<void> { 223 + await db.schema 224 + .createTable("volumes") 225 + .addColumn("id", "varchar", (col) => col.primaryKey()) 226 + .addColumn("name", "varchar", (col) => col.notNull().unique()) 227 + .addColumn( 228 + "baseImageId", 229 + "varchar", 230 + (col) => col.notNull().references("images.id").onDelete("cascade"), 231 + ) 232 + .addColumn("path", "varchar", (col) => col.notNull()) 233 + .addColumn( 234 + "createdAt", 235 + "varchar", 236 + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 237 + ) 238 + .execute(); 239 + }, 240 + 241 + async down(db: Kysely<unknown>): Promise<void> { 242 + await db.schema.dropTable("volumes").execute(); 243 + }, 244 + }; 245 + 246 + migrations["009"] = { 247 + async up(db: Kysely<unknown>): Promise<void> { 248 + await db.schema 249 + .createTable("volumes_new") 250 + .addColumn("id", "varchar", (col) => col.primaryKey()) 251 + .addColumn("name", "varchar", (col) => col.notNull().unique()) 252 + .addColumn( 253 + "baseImageId", 254 + "varchar", 255 + (col) => col.notNull().references("images.id").onDelete("cascade"), 256 + ) 257 + .addColumn("path", "varchar", (col) => col.notNull()) 258 + .addColumn( 259 + "createdAt", 260 + "varchar", 261 + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 262 + ) 263 + .execute(); 264 + 265 + await sql` 266 + INSERT INTO volumes_new (id, name, baseImageId, path, createdAt) 267 + SELECT id, name, baseImageId, path, createdAt FROM volumes 268 + `.execute(db); 269 + 270 + await db.schema.dropTable("volumes").execute(); 271 + await sql`ALTER TABLE volumes_new RENAME TO volumes`.execute(db); 272 + }, 273 + 274 + async down(db: Kysely<unknown>): Promise<void> { 275 + await db.schema.dropTable("volumes").execute(); 276 + }, 277 + }; 278 + 279 + migrations["010"] = { 280 + async up(db: Kysely<unknown>): Promise<void> { 281 + await db.schema 282 + .alterTable("virtual_machines") 283 + .addColumn("volume", "varchar") 284 + .execute(); 285 + }, 286 + 287 + async down(db: Kysely<unknown>): Promise<void> { 288 + await db.schema 289 + .alterTable("virtual_machines") 290 + .dropColumn("volume") 291 + .execute(); 292 + }, 293 + }; 294 + 295 + export const migrateToLatest = async (db: Database): Promise<void> => { 296 + const migrator = new Migrator({ db, provider: migrationProvider }); 297 + const { error } = await migrator.migrateToLatest(); 298 + if (error) throw error; 299 + };
+11
src/mod.ts
···
··· 1 + export * from "./config.ts"; 2 + export * from "./constants.ts"; 3 + export * from "./context.ts"; 4 + export * from "./db.ts"; 5 + export * from "./network.ts"; 6 + export * from "./oras.ts"; 7 + export * from "./state.ts"; 8 + export * from "./types.ts"; 9 + export * from "./utils.ts"; 10 + export * from "./volumes.ts"; 11 + export * from "./api/mod.ts";
+138
src/network.ts
···
··· 1 + import chalk from "chalk"; 2 + import { Data, Effect } from "effect"; 3 + 4 + export class NetworkError extends Data.TaggedError("NetworkError")<{ 5 + cause?: unknown; 6 + }> {} 7 + 8 + export class BridgeSetupError extends Data.TaggedError("BridgeSetupError")<{ 9 + cause?: unknown; 10 + }> {} 11 + 12 + export const setupQemuBridge = (bridgeName: string) => 13 + Effect.tryPromise({ 14 + try: async () => { 15 + const bridgeConfPath = "/etc/qemu/bridge.conf"; 16 + const bridgeConfContent = await Deno.readTextFile(bridgeConfPath).catch( 17 + () => "", 18 + ); 19 + if (bridgeConfContent.includes(`allow ${bridgeName}`)) { 20 + console.log( 21 + chalk.greenBright( 22 + `QEMU bridge configuration for ${bridgeName} already exists.`, 23 + ), 24 + ); 25 + return; 26 + } 27 + 28 + console.log( 29 + chalk.blueBright( 30 + `Adding QEMU bridge configuration for ${bridgeName}...`, 31 + ), 32 + ); 33 + 34 + const cmd = new Deno.Command("sudo", { 35 + args: [ 36 + "sh", 37 + "-c", 38 + `mkdir -p /etc/qemu && echo "allow ${bridgeName}" >> ${bridgeConfPath}`, 39 + ], 40 + stdin: "inherit", 41 + stdout: "inherit", 42 + stderr: "inherit", 43 + }); 44 + const status = await cmd.spawn().status; 45 + 46 + if (!status.success) { 47 + console.error( 48 + chalk.redBright( 49 + `Failed to add QEMU bridge configuration for ${bridgeName}.`, 50 + ), 51 + ); 52 + Deno.exit(status.code); 53 + } 54 + 55 + console.log( 56 + chalk.greenBright( 57 + `QEMU bridge configuration for ${bridgeName} added successfully.`, 58 + ), 59 + ); 60 + }, 61 + catch: (error) => new BridgeSetupError({ cause: error }), 62 + }); 63 + 64 + export const createBridgeNetworkIfNeeded = ( 65 + bridgeName: string, 66 + ) => 67 + Effect.tryPromise({ 68 + try: async () => { 69 + const bridgeExistsCmd = new Deno.Command("ip", { 70 + args: ["link", "show", bridgeName], 71 + stdout: "null", 72 + stderr: "null", 73 + }); 74 + 75 + const bridgeExistsStatus = await bridgeExistsCmd.spawn().status; 76 + if (bridgeExistsStatus.success) { 77 + console.log( 78 + chalk.greenBright(`Network bridge ${bridgeName} already exists.`), 79 + ); 80 + await setupQemuBridge(bridgeName); 81 + return; 82 + } 83 + 84 + console.log(chalk.blueBright(`Creating network bridge ${bridgeName}...`)); 85 + const createBridgeCmd = new Deno.Command("sudo", { 86 + args: ["ip", "link", "add", bridgeName, "type", "bridge"], 87 + stdin: "inherit", 88 + stdout: "inherit", 89 + stderr: "inherit", 90 + }); 91 + 92 + let status = await createBridgeCmd.spawn().status; 93 + if (!status.success) { 94 + console.error( 95 + chalk.redBright(`Failed to create network bridge ${bridgeName}.`), 96 + ); 97 + Deno.exit(status.code); 98 + } 99 + 100 + const bringUpBridgeCmd = new Deno.Command("sudo", { 101 + args: ["ip", "link", "set", "dev", bridgeName, "up"], 102 + stdin: "inherit", 103 + stdout: "inherit", 104 + stderr: "inherit", 105 + }); 106 + status = await bringUpBridgeCmd.spawn().status; 107 + if (!status.success) { 108 + console.error( 109 + chalk.redBright(`Failed to bring up network bridge ${bridgeName}.`), 110 + ); 111 + Deno.exit(status.code); 112 + } 113 + 114 + console.log( 115 + chalk.greenBright(`Network bridge ${bridgeName} created and up.`), 116 + ); 117 + 118 + await setupQemuBridge(bridgeName); 119 + }, 120 + catch: (error) => new NetworkError({ cause: error }), 121 + }); 122 + 123 + export const generateRandomMacAddress = () => 124 + Effect.sync(() => { 125 + const hexDigits = "0123456789ABCDEF"; 126 + let macAddress = "52:54:00"; 127 + 128 + for (let i = 0; i < 3; i++) { 129 + macAddress += ":"; 130 + for (let j = 0; j < 2; j++) { 131 + macAddress += hexDigits.charAt( 132 + Math.floor(Math.random() * hexDigits.length), 133 + ); 134 + } 135 + } 136 + 137 + return macAddress; 138 + });
+423
src/oras.ts
···
··· 1 + import { createId } from "@paralleldrive/cuid2"; 2 + import { basename, dirname } from "@std/path"; 3 + import chalk from "chalk"; 4 + import { Data, Effect, pipe } from "effect"; 5 + import { IMAGE_DIR } from "./constants.ts"; 6 + import { getImage, saveImage } from "./images.ts"; 7 + import { CONFIG_DIR, failOnMissingImage } from "./mod.ts"; 8 + import { du, getCurrentArch } from "./utils.ts"; 9 + 10 + const DEFAULT_ORAS_VERSION = "1.3.0"; 11 + 12 + export class PushImageError extends Data.TaggedError("PushImageError")<{ 13 + cause?: unknown; 14 + }> {} 15 + 16 + export class PullImageError extends Data.TaggedError("PullImageError")<{ 17 + cause?: unknown; 18 + }> {} 19 + 20 + export class CreateDirectoryError 21 + extends Data.TaggedError("CreateDirectoryError")<{ 22 + cause?: unknown; 23 + }> {} 24 + 25 + export class ImageAlreadyPulledError 26 + extends Data.TaggedError("ImageAlreadyPulledError")<{ 27 + name: string; 28 + }> {} 29 + 30 + export async function setupOrasBinary(): Promise<void> { 31 + Deno.env.set( 32 + "PATH", 33 + `${CONFIG_DIR}/bin:${Deno.env.get("PATH")}`, 34 + ); 35 + 36 + const oras = new Deno.Command("which", { 37 + args: ["oras"], 38 + stdout: "null", 39 + stderr: "null", 40 + }) 41 + .spawn(); 42 + 43 + const orasStatus = await oras.status; 44 + if (orasStatus.success) { 45 + return; 46 + } 47 + 48 + const version = Deno.env.get("ORAS_VERSION") || DEFAULT_ORAS_VERSION; 49 + 50 + console.log(`Downloading ORAS version ${version}...`); 51 + 52 + const os = Deno.build.os; 53 + let arch = "amd64"; 54 + 55 + if (Deno.build.arch === "aarch64") { 56 + arch = "arm64"; 57 + } 58 + 59 + if (os !== "linux" && os !== "darwin") { 60 + console.error("Unsupported OS. Please download ORAS manually."); 61 + Deno.exit(1); 62 + } 63 + 64 + // https://github.com/oras-project/oras/releases/download/v1.3.0/oras_1.3.0_darwin_amd64.tar.gz 65 + const downloadUrl = 66 + `https://github.com/oras-project/oras/releases/download/v${version}/oras_${version}_${os}_${arch}.tar.gz`; 67 + 68 + console.log(`Downloading ORAS from ${chalk.greenBright(downloadUrl)}`); 69 + 70 + const downloadProcess = new Deno.Command("curl", { 71 + args: ["-L", downloadUrl, "-o", `oras_${version}_${os}_${arch}.tar.gz`], 72 + stdout: "inherit", 73 + stderr: "inherit", 74 + cwd: "/tmp", 75 + }) 76 + .spawn(); 77 + 78 + const status = await downloadProcess.status; 79 + if (!status.success) { 80 + console.error("Failed to download ORAS binary."); 81 + Deno.exit(1); 82 + } 83 + 84 + console.log("Extracting ORAS binary..."); 85 + 86 + const extractProcess = new Deno.Command("tar", { 87 + args: [ 88 + "-xzf", 89 + `oras_${version}_${os}_${arch}.tar.gz`, 90 + "-C", 91 + "./", 92 + ], 93 + stdout: "inherit", 94 + stderr: "inherit", 95 + cwd: "/tmp", 96 + }) 97 + .spawn(); 98 + 99 + const extractStatus = await extractProcess.status; 100 + if (!extractStatus.success) { 101 + console.error("Failed to extract ORAS binary."); 102 + Deno.exit(1); 103 + } 104 + 105 + await Deno.remove(`/tmp/oras_${version}_${os}_${arch}.tar.gz`); 106 + 107 + await Deno.mkdir(`${CONFIG_DIR}/bin`, { recursive: true }); 108 + 109 + await Deno.rename( 110 + `/tmp/oras`, 111 + `${CONFIG_DIR}/bin/oras`, 112 + ); 113 + await Deno.chmod(`${CONFIG_DIR}/bin/oras`, 0o755); 114 + 115 + console.log( 116 + `ORAS binary installed at ${ 117 + chalk.greenBright( 118 + `${CONFIG_DIR}/bin/oras`, 119 + ) 120 + }`, 121 + ); 122 + } 123 + 124 + const archiveImage = (img: { path: string }) => 125 + Effect.tryPromise({ 126 + try: async () => { 127 + console.log("Archiving image for push..."); 128 + const tarProcess = new Deno.Command("tar", { 129 + args: [ 130 + "-cSzf", 131 + `${img.path}.tar.gz`, 132 + "-C", 133 + dirname(img.path), 134 + basename(img.path), 135 + ], 136 + stdout: "inherit", 137 + stderr: "inherit", 138 + }).spawn(); 139 + 140 + const tarStatus = await tarProcess.status; 141 + if (!tarStatus.success) { 142 + throw new Error(`Failed to create tar archive for image`); 143 + } 144 + return `${img.path}.tar.gz`; 145 + }, 146 + catch: (error: unknown) => 147 + new PushImageError({ 148 + cause: error instanceof Error ? error.message : String(error), 149 + }), 150 + }); 151 + 152 + // add docker.io/ if no registry is specified 153 + const formatRepository = (repository: string) => 154 + repository.match(/^[^\/]+\.[^\/]+\/.*/i) 155 + ? repository 156 + : `docker.io/${repository}`; 157 + 158 + const pushToRegistry = ( 159 + img: { repository: string; tag: string; path: string }, 160 + ) => 161 + Effect.tryPromise({ 162 + try: async () => { 163 + console.log(`Pushing image ${formatRepository(img.repository)}...`); 164 + const process = new Deno.Command("oras", { 165 + args: [ 166 + "push", 167 + `${formatRepository(img.repository)}:${img.tag}-${getCurrentArch()}`, 168 + "--artifact-type", 169 + "application/vnd.oci.image.layer.v1.tar", 170 + "--annotation", 171 + `org.opencontainers.image.architecture=${getCurrentArch()}`, 172 + "--annotation", 173 + "org.opencontainers.image.os=freebsd", 174 + "--annotation", 175 + "org.opencontainers.image.description=QEMU raw disk image of FreeBSD", 176 + basename(img.path), 177 + ], 178 + stdout: "inherit", 179 + stderr: "inherit", 180 + cwd: dirname(img.path), 181 + }).spawn(); 182 + 183 + const { code } = await process.status; 184 + if (code !== 0) { 185 + throw new Error(`ORAS push failed with exit code ${code}`); 186 + } 187 + return img.path; 188 + }, 189 + catch: (error: unknown) => 190 + new PushImageError({ 191 + cause: error instanceof Error ? error.message : String(error), 192 + }), 193 + }); 194 + 195 + const cleanup = (path: string) => 196 + Effect.tryPromise({ 197 + try: () => Deno.remove(path), 198 + catch: (error: unknown) => 199 + new PushImageError({ 200 + cause: error instanceof Error ? error.message : String(error), 201 + }), 202 + }); 203 + 204 + const createImageDirIfMissing = Effect.promise(() => 205 + Deno.mkdir(IMAGE_DIR, { recursive: true }) 206 + ); 207 + 208 + const checkIfImageAlreadyPulled = (image: string) => 209 + pipe( 210 + getImageDigest(image), 211 + Effect.flatMap(getImage), 212 + Effect.flatMap((img) => { 213 + if (img) { 214 + return Effect.fail( 215 + new ImageAlreadyPulledError({ name: image }), 216 + ); 217 + } 218 + return Effect.succeed(void 0); 219 + }), 220 + ); 221 + 222 + export const pullFromRegistry = (image: string) => 223 + pipe( 224 + Effect.tryPromise({ 225 + try: async () => { 226 + console.log(`Pulling image ${image}`); 227 + const repository = image.split(":")[0]; 228 + const tag = image.split(":")[1] || "latest"; 229 + console.log( 230 + "pull", 231 + `${formatRepository(repository)}:${tag}-${getCurrentArch()}`, 232 + ); 233 + 234 + const process = new Deno.Command("oras", { 235 + args: [ 236 + "pull", 237 + `${formatRepository(repository)}:${tag}-${getCurrentArch()}`, 238 + ], 239 + stdin: "inherit", 240 + stdout: "inherit", 241 + stderr: "inherit", 242 + cwd: IMAGE_DIR, 243 + }).spawn(); 244 + 245 + const { code } = await process.status; 246 + if (code !== 0) { 247 + throw new Error(`ORAS pull failed with exit code ${code}`); 248 + } 249 + }, 250 + catch: (error: unknown) => 251 + new PullImageError({ 252 + cause: error instanceof Error ? error.message : String(error), 253 + }), 254 + }), 255 + ); 256 + 257 + export const getImageArchivePath = (image: string) => 258 + Effect.tryPromise({ 259 + try: async () => { 260 + const repository = image.split(":")[0]; 261 + const tag = image.split(":")[1] || "latest"; 262 + const process = new Deno.Command("oras", { 263 + args: [ 264 + "manifest", 265 + "fetch", 266 + `${formatRepository(repository)}:${tag}-${getCurrentArch()}`, 267 + ], 268 + stdout: "piped", 269 + stderr: "inherit", 270 + }).spawn(); 271 + 272 + const { code, stdout } = await process.output(); 273 + if (code !== 0) { 274 + throw new Error(`ORAS manifest fetch failed with exit code ${code}`); 275 + } 276 + 277 + const manifest = JSON.parse(new TextDecoder().decode(stdout)); 278 + const layers = manifest.layers; 279 + if (!layers || layers.length === 0) { 280 + throw new Error(`No layers found in manifest for image ${image}`); 281 + } 282 + 283 + if ( 284 + !layers[0].annotations || 285 + !layers[0].annotations["org.opencontainers.image.title"] 286 + ) { 287 + throw new Error( 288 + `No title annotation found for layer in image ${image}`, 289 + ); 290 + } 291 + 292 + const path = `${IMAGE_DIR}/${ 293 + layers[0].annotations["org.opencontainers.image.title"] 294 + }`; 295 + 296 + if (!(await Deno.stat(path).catch(() => false))) { 297 + throw new Error(`Image archive not found at expected path ${path}`); 298 + } 299 + 300 + return path; 301 + }, 302 + catch: (error: unknown) => 303 + new PullImageError({ 304 + cause: error instanceof Error ? error.message : String(error), 305 + }), 306 + }); 307 + 308 + const getImageDigest = (image: string) => 309 + Effect.tryPromise({ 310 + try: async () => { 311 + const repository = image.split(":")[0]; 312 + const tag = image.split(":")[1] || "latest"; 313 + const process = new Deno.Command("oras", { 314 + args: [ 315 + "manifest", 316 + "fetch", 317 + `${formatRepository(repository)}:${tag}-${getCurrentArch()}`, 318 + ], 319 + stdout: "piped", 320 + stderr: "inherit", 321 + }).spawn(); 322 + 323 + const { code, stdout } = await process.output(); 324 + if (code !== 0) { 325 + throw new Error(`ORAS manifest fetch failed with exit code ${code}`); 326 + } 327 + 328 + const manifest = JSON.parse(new TextDecoder().decode(stdout)); 329 + if (!manifest.layers[0] || !manifest.layers[0].digest) { 330 + throw new Error(`No digest found in manifest for image ${image}`); 331 + } 332 + 333 + return manifest.layers[0].digest as string; 334 + }, 335 + catch: (error: unknown) => 336 + new PullImageError({ 337 + cause: error instanceof Error ? error.message : String(error), 338 + }), 339 + }); 340 + 341 + const extractImage = (path: string) => 342 + Effect.tryPromise({ 343 + try: async () => { 344 + console.log("Extracting image archive..."); 345 + const tarProcess = new Deno.Command("tar", { 346 + args: [ 347 + "-xSzf", 348 + path, 349 + "-C", 350 + dirname(path), 351 + ], 352 + stdout: "inherit", 353 + stderr: "inherit", 354 + cwd: IMAGE_DIR, 355 + }).spawn(); 356 + 357 + const tarStatus = await tarProcess.status; 358 + if (!tarStatus.success) { 359 + throw new Error(`Failed to extract tar archive for image`); 360 + } 361 + return path.replace(/\.tar\.gz$/, ""); 362 + }, 363 + catch: (error: unknown) => 364 + new PullImageError({ 365 + cause: error instanceof Error ? error.message : String(error), 366 + }), 367 + }); 368 + 369 + const savePulledImage = ( 370 + imagePath: string, 371 + digest: string, 372 + name: string, 373 + ) => 374 + Effect.gen(function* () { 375 + yield* saveImage({ 376 + id: createId(), 377 + repository: name.split(":")[0], 378 + tag: name.split(":")[1] || "latest", 379 + size: yield* du(imagePath), 380 + path: imagePath, 381 + format: imagePath.endsWith(".qcow2") ? "qcow2" : "raw", 382 + digest, 383 + }); 384 + return `${imagePath}.tar.gz`; 385 + }); 386 + 387 + export const pushImage = (image: string) => 388 + pipe( 389 + getImage(image), 390 + Effect.flatMap(failOnMissingImage), 391 + Effect.flatMap((img) => 392 + pipe( 393 + archiveImage(img), 394 + Effect.tap((archivedPath) => { 395 + img.path = archivedPath; 396 + return Effect.succeed(void 0); 397 + }), 398 + Effect.flatMap(() => pushToRegistry(img)), 399 + Effect.flatMap(cleanup), 400 + ) 401 + ), 402 + ); 403 + 404 + export const pullImage = (image: string) => 405 + pipe( 406 + Effect.all([createImageDirIfMissing, checkIfImageAlreadyPulled(image)]), 407 + Effect.flatMap(() => pullFromRegistry(image)), 408 + Effect.flatMap(() => getImageArchivePath(image)), 409 + Effect.flatMap(extractImage), 410 + Effect.flatMap((imagePath: string) => 411 + Effect.all([ 412 + Effect.succeed(imagePath), 413 + getImageDigest(image), 414 + Effect.succeed(image), 415 + ]) 416 + ), 417 + Effect.flatMap(([imagePath, digest, image]) => 418 + savePulledImage(imagePath, digest, image) 419 + ), 420 + Effect.flatMap(cleanup), 421 + Effect.catchTag("ImageAlreadyPulledError", () => 422 + Effect.sync(() => console.log(`Image ${image} is already pulled.`))), 423 + );
+92
src/state.ts
···
··· 1 + import { Data, Effect } from "effect"; 2 + import { ctx } from "./context.ts"; 3 + import type { VirtualMachine } from "./db.ts"; 4 + import type { STATUS } from "./types.ts"; 5 + 6 + export class DbError extends Data.TaggedError("DatabaseError")<{ 7 + cause?: unknown; 8 + }> {} 9 + 10 + export const saveInstanceState = ( 11 + vm: VirtualMachine, 12 + ) => 13 + Effect.tryPromise({ 14 + try: () => 15 + ctx.db.insertInto("virtual_machines") 16 + .values(vm) 17 + .execute(), 18 + catch: (error) => new DbError({ cause: error }), 19 + }); 20 + 21 + export const updateInstanceState = ( 22 + name: string, 23 + status: STATUS, 24 + pid?: number, 25 + ) => 26 + Effect.tryPromise({ 27 + try: () => 28 + ctx.db.updateTable("virtual_machines") 29 + .set({ 30 + status, 31 + pid, 32 + updatedAt: new Date().toISOString(), 33 + }) 34 + .where((eb) => 35 + eb.or([ 36 + eb("name", "=", name), 37 + eb("id", "=", name), 38 + ]) 39 + ) 40 + .execute(), 41 + catch: (error) => new DbError({ cause: error }), 42 + }); 43 + 44 + export const removeInstanceState = ( 45 + name: string, 46 + ) => 47 + Effect.tryPromise({ 48 + try: () => 49 + ctx.db.deleteFrom("virtual_machines") 50 + .where((eb) => 51 + eb.or([ 52 + eb("name", "=", name), 53 + eb("id", "=", name), 54 + ]) 55 + ) 56 + .execute(), 57 + catch: (error) => new DbError({ cause: error }), 58 + }); 59 + 60 + export const getInstanceState = ( 61 + name: string, 62 + ): Effect.Effect<VirtualMachine | undefined, DbError, never> => 63 + Effect.tryPromise({ 64 + try: () => 65 + ctx.db.selectFrom("virtual_machines") 66 + .selectAll() 67 + .where((eb) => 68 + eb.or([ 69 + eb("name", "=", name), 70 + eb("id", "=", name), 71 + ]) 72 + ) 73 + .executeTakeFirst(), 74 + catch: (error) => new DbError({ cause: error }), 75 + }); 76 + 77 + export const listInstances = ( 78 + all: boolean, 79 + ): Effect.Effect<VirtualMachine[], DbError, never> => 80 + Effect.tryPromise({ 81 + try: () => 82 + ctx.db.selectFrom("virtual_machines") 83 + .selectAll() 84 + .where((eb) => { 85 + if (all) { 86 + return eb("id", "!=", ""); 87 + } 88 + return eb("status", "=", "RUNNING"); 89 + }) 90 + .execute(), 91 + catch: (error) => new DbError({ cause: error }), 92 + });
+55
src/subcommands/images.ts
···
··· 1 + import { Table } from "@cliffy/table"; 2 + import dayjs from "dayjs"; 3 + import relativeTime from "dayjs/plugin/relativeTime.js"; 4 + import utc from "dayjs/plugin/utc.js"; 5 + import { Effect, pipe } from "effect"; 6 + import type { Image } from "../db.ts"; 7 + import { type DbError, listImages } from "../images.ts"; 8 + import { humanFileSize } from "../utils.ts"; 9 + 10 + dayjs.extend(relativeTime); 11 + dayjs.extend(utc); 12 + 13 + const createTable = () => 14 + Effect.succeed( 15 + new Table( 16 + ["REPOSITORY", "TAG", "IMAGE ID", "CREATED", "SIZE"], 17 + ), 18 + ); 19 + 20 + const populateTable = (table: Table, images: Image[]) => 21 + Effect.gen(function* () { 22 + for (const image of images) { 23 + table.push([ 24 + image.repository, 25 + image.tag, 26 + image.id, 27 + dayjs.utc(image.createdAt).local().fromNow(), 28 + yield* humanFileSize(image.size), 29 + ]); 30 + } 31 + return table; 32 + }); 33 + 34 + const displayTable = (table: Table) => 35 + Effect.sync(() => { 36 + console.log(table.padding(2).toString()); 37 + }); 38 + 39 + const handleError = (error: DbError | Error) => 40 + Effect.sync(() => { 41 + console.error(`Failed to fetch virtual machines: ${error}`); 42 + Deno.exit(1); 43 + }); 44 + 45 + const lsEffect = () => 46 + pipe( 47 + Effect.all([listImages(), createTable()]), 48 + Effect.flatMap(([images, table]) => populateTable(table, images)), 49 + Effect.flatMap(displayTable), 50 + Effect.catchAll(handleError), 51 + ); 52 + 53 + export default async function () { 54 + await Effect.runPromise(lsEffect()); 55 + }
+43
src/subcommands/inspect.ts
···
··· 1 + import { Data, Effect, pipe } from "effect"; 2 + import type { VirtualMachine } from "../db.ts"; 3 + import { getInstanceState } from "../state.ts"; 4 + 5 + class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 6 + name: string; 7 + }> {} 8 + 9 + const findVm = (name: string) => 10 + pipe( 11 + getInstanceState(name), 12 + Effect.flatMap((vm) => 13 + vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 14 + ), 15 + ); 16 + 17 + const displayVm = (vm: VirtualMachine) => 18 + Effect.sync(() => { 19 + console.log(vm); 20 + }); 21 + 22 + const handleError = (error: VmNotFoundError | Error) => 23 + Effect.sync(() => { 24 + if (error instanceof VmNotFoundError) { 25 + console.error( 26 + `Virtual machine with name or ID ${error.name} not found.`, 27 + ); 28 + } else { 29 + console.error(`An error occurred: ${error}`); 30 + } 31 + Deno.exit(1); 32 + }); 33 + 34 + const inspectEffect = (name: string) => 35 + pipe( 36 + findVm(name), 37 + Effect.flatMap(displayVm), 38 + Effect.catchAll(handleError), 39 + ); 40 + 41 + export default async function (name: string) { 42 + await Effect.runPromise(inspectEffect(name)); 43 + }
+35
src/subcommands/login.ts
···
··· 1 + import { setupOrasBinary } from "../oras.ts"; 2 + 3 + export default async function ( 4 + username: string, 5 + password: string, 6 + reqistry: string, 7 + ) { 8 + await setupOrasBinary(); 9 + 10 + const cmd = new Deno.Command("oras", { 11 + args: [ 12 + "login", 13 + "--username", 14 + username, 15 + "--password-stdin", 16 + reqistry, 17 + ], 18 + stdin: "piped", 19 + stderr: "inherit", 20 + stdout: "inherit", 21 + }); 22 + 23 + const process = cmd.spawn(); 24 + if (process.stdin) { 25 + const writer = process.stdin.getWriter(); 26 + await writer.write(new TextEncoder().encode(password + "\n")); 27 + writer.close(); 28 + } 29 + 30 + const status = await process.status; 31 + 32 + if (!status.success) { 33 + Deno.exit(status.code); 34 + } 35 + }
+19
src/subcommands/logout.ts
···
··· 1 + import { setupOrasBinary } from "../oras.ts"; 2 + 3 + export default async function (registry: string) { 4 + await setupOrasBinary(); 5 + 6 + const cmd = new Deno.Command("oras", { 7 + args: ["logout", registry], 8 + stderr: "inherit", 9 + stdout: "inherit", 10 + }); 11 + 12 + const process = cmd.spawn(); 13 + 14 + const status = await process.status; 15 + 16 + if (!status.success) { 17 + Deno.exit(status.code); 18 + } 19 + }
+71
src/subcommands/logs.ts
···
··· 1 + import { Data, Effect, pipe } from "effect"; 2 + import { LOGS_DIR } from "../constants.ts"; 3 + 4 + class LogCommandError extends Data.TaggedError("LogCommandError")<{ 5 + vmName: string; 6 + exitCode: number; 7 + }> {} 8 + 9 + class CommandError extends Data.TaggedError("CommandError")<{ 10 + cause?: unknown; 11 + }> {} 12 + 13 + const createLogsDir = () => 14 + Effect.tryPromise({ 15 + try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 16 + catch: (error) => new CommandError({ cause: error }), 17 + }); 18 + 19 + const buildLogPath = (name: string) => 20 + Effect.succeed(`${LOGS_DIR}/${name}.log`); 21 + 22 + const viewLogs = (name: string, follow: boolean, logPath: string) => 23 + Effect.tryPromise({ 24 + try: async () => { 25 + const cmd = new Deno.Command(follow ? "tail" : "cat", { 26 + args: [ 27 + ...(follow ? ["-n", "100", "-f"] : []), 28 + logPath, 29 + ], 30 + stdin: "inherit", 31 + stdout: "inherit", 32 + stderr: "inherit", 33 + }); 34 + 35 + const status = await cmd.spawn().status; 36 + return { name, status }; 37 + }, 38 + catch: (error) => new CommandError({ cause: error }), 39 + }).pipe( 40 + Effect.flatMap(({ name, status }) => 41 + status.success ? Effect.succeed(undefined) : Effect.fail( 42 + new LogCommandError({ 43 + vmName: name, 44 + exitCode: status.code || 1, 45 + }), 46 + ) 47 + ), 48 + ); 49 + 50 + const handleError = (error: LogCommandError | CommandError | Error) => 51 + Effect.sync(() => { 52 + if (error instanceof LogCommandError) { 53 + console.error(`Failed to view logs for virtual machine ${error.vmName}.`); 54 + Deno.exit(error.exitCode); 55 + } else { 56 + console.error(`An error occurred: ${error}`); 57 + Deno.exit(1); 58 + } 59 + }); 60 + 61 + const logsEffect = (name: string, follow: boolean) => 62 + pipe( 63 + createLogsDir(), 64 + Effect.flatMap(() => buildLogPath(name)), 65 + Effect.flatMap((logPath) => viewLogs(name, follow, logPath)), 66 + Effect.catchAll(handleError), 67 + ); 68 + 69 + export default async function (name: string, follow: boolean) { 70 + await Effect.runPromise(logsEffect(name, follow)); 71 + }
+101
src/subcommands/ps.ts
···
··· 1 + import { Table } from "@cliffy/table"; 2 + import dayjs from "dayjs"; 3 + import relativeTime from "dayjs/plugin/relativeTime.js"; 4 + import utc from "dayjs/plugin/utc.js"; 5 + import { Data, Effect, pipe } from "effect"; 6 + import { ctx } from "../context.ts"; 7 + import type { VirtualMachine } from "../db.ts"; 8 + 9 + dayjs.extend(relativeTime); 10 + dayjs.extend(utc); 11 + 12 + class DbQueryError extends Data.TaggedError("DbQueryError")<{ 13 + cause?: unknown; 14 + }> {} 15 + 16 + const fetchVMs = (all: boolean) => 17 + Effect.tryPromise({ 18 + try: () => 19 + ctx.db.selectFrom("virtual_machines") 20 + .selectAll() 21 + .where((eb) => { 22 + if (all) { 23 + return eb("id", "!=", ""); 24 + } 25 + return eb("status", "=", "RUNNING"); 26 + }) 27 + .execute(), 28 + catch: (error) => new DbQueryError({ cause: error }), 29 + }); 30 + 31 + const createTable = () => 32 + Effect.succeed( 33 + new Table( 34 + ["NAME", "VCPU", "MEMORY", "STATUS", "PID", "BRIDGE", "PORTS", "CREATED"], 35 + ), 36 + ); 37 + 38 + const populateTable = (table: Table, vms: VirtualMachine[]) => 39 + Effect.sync(() => { 40 + for (const vm of vms) { 41 + table.push([ 42 + vm.name, 43 + vm.cpus.toString(), 44 + vm.memory, 45 + formatStatus(vm), 46 + vm.pid?.toString() ?? "-", 47 + vm.bridge ?? "-", 48 + formatPorts(vm.portForward), 49 + dayjs.utc(vm.createdAt).local().fromNow(), 50 + ]); 51 + } 52 + return table; 53 + }); 54 + 55 + const displayTable = (table: Table) => 56 + Effect.sync(() => { 57 + console.log(table.padding(2).toString()); 58 + }); 59 + 60 + const handleError = (error: DbQueryError | Error) => 61 + Effect.sync(() => { 62 + console.error(`Failed to fetch virtual machines: ${error}`); 63 + Deno.exit(1); 64 + }); 65 + 66 + const psEffect = (all: boolean) => 67 + pipe( 68 + Effect.all([fetchVMs(all), createTable()]), 69 + Effect.flatMap(([vms, table]) => populateTable(table, vms)), 70 + Effect.flatMap(displayTable), 71 + Effect.catchAll(handleError), 72 + ); 73 + 74 + export default async function (all: boolean) { 75 + await Effect.runPromise(psEffect(all)); 76 + } 77 + 78 + function formatStatus(vm: VirtualMachine) { 79 + switch (vm.status) { 80 + case "RUNNING": 81 + return `Up ${ 82 + dayjs.utc(vm.updatedAt).local().fromNow().replace("ago", "") 83 + }`; 84 + case "STOPPED": 85 + return `Exited ${dayjs.utc(vm.updatedAt).local().fromNow()}`; 86 + default: 87 + return vm.status; 88 + } 89 + } 90 + 91 + function formatPorts(portForward?: string) { 92 + if (!portForward) { 93 + return "-"; 94 + } 95 + 96 + const mappings = portForward.split(","); 97 + return mappings.map((mapping) => { 98 + const [hostPort, guestPort] = mapping.split(":"); 99 + return `${hostPort}->${guestPort}`; 100 + }).join(", "); 101 + }
+19
src/subcommands/pull.ts
···
··· 1 + import { Effect, pipe } from "effect"; 2 + import { pullImage, setupOrasBinary } from "../oras.ts"; 3 + import { validateImage } from "../utils.ts"; 4 + 5 + export default async function (image: string): Promise<void> { 6 + await Effect.runPromise( 7 + pipe( 8 + Effect.promise(() => setupOrasBinary()), 9 + Effect.tap(() => validateImage(image)), 10 + Effect.tap(() => pullImage(image)), 11 + Effect.catchAll((error) => 12 + Effect.sync(() => { 13 + console.error(`Failed to pull image: ${error.cause}`); 14 + Deno.exit(1); 15 + }) 16 + ), 17 + ), 18 + ); 19 + }
+19
src/subcommands/push.ts
···
··· 1 + import { Effect, pipe } from "effect"; 2 + import { pushImage, setupOrasBinary } from "../oras.ts"; 3 + import { validateImage } from "../utils.ts"; 4 + 5 + export default async function (image: string): Promise<void> { 6 + await Effect.runPromise( 7 + pipe( 8 + Effect.promise(() => setupOrasBinary()), 9 + Effect.tap(() => validateImage(image)), 10 + Effect.tap(() => pushImage(image)), 11 + Effect.catchAll((error) => 12 + Effect.sync(() => { 13 + console.error(`Failed to push image: ${error.cause}`); 14 + Deno.exit(1); 15 + }) 16 + ), 17 + ), 18 + ); 19 + }
+185
src/subcommands/restart.ts
···
··· 1 + import _ from "@es-toolkit/es-toolkit/compat"; 2 + import chalk from "chalk"; 3 + import { Data, Effect, pipe } from "effect"; 4 + import { LOGS_DIR } from "../constants.ts"; 5 + import type { VirtualMachine } from "../db.ts"; 6 + import { getInstanceState, updateInstanceState } from "../state.ts"; 7 + import { 8 + safeKillQemu, 9 + setupFirmwareFilesIfNeeded, 10 + setupNATNetworkArgs, 11 + } from "../utils.ts"; 12 + 13 + class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 14 + name: string; 15 + }> {} 16 + 17 + class KillQemuError extends Data.TaggedError("KillQemuError")<{ 18 + vmName: string; 19 + }> {} 20 + 21 + class CommandError extends Data.TaggedError("CommandError")<{ 22 + cause?: unknown; 23 + }> {} 24 + 25 + const findVm = (name: string) => 26 + pipe( 27 + getInstanceState(name), 28 + Effect.flatMap((vm) => 29 + vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 30 + ), 31 + ); 32 + 33 + const killQemu = (vm: VirtualMachine) => 34 + safeKillQemu(vm.pid, Boolean(vm.bridge)).pipe( 35 + Effect.flatMap((success) => 36 + success 37 + ? Effect.succeed(vm) 38 + : Effect.fail(new KillQemuError({ vmName: vm.name })) 39 + ), 40 + ); 41 + 42 + const sleep = (ms: number) => 43 + Effect.tryPromise({ 44 + try: () => new Promise((resolve) => setTimeout(resolve, ms)), 45 + catch: (error) => new CommandError({ cause: error }), 46 + }); 47 + 48 + const createLogsDir = () => 49 + Effect.tryPromise({ 50 + try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 51 + catch: (error) => new CommandError({ cause: error }), 52 + }); 53 + 54 + const setupFirmware = () => setupFirmwareFilesIfNeeded(); 55 + 56 + const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 57 + const qemu = Deno.build.arch === "aarch64" 58 + ? "qemu-system-aarch64" 59 + : "qemu-system-x86_64"; 60 + 61 + return Effect.succeed([ 62 + ..._.compact([vm.bridge && qemu]), 63 + ...Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"], 64 + ...Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : [], 65 + "-cpu", 66 + vm.cpu, 67 + "-m", 68 + vm.memory, 69 + "-smp", 70 + vm.cpus.toString(), 71 + ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]), 72 + "-netdev", 73 + vm.bridge 74 + ? `bridge,id=net0,br=${vm.bridge}` 75 + : setupNATNetworkArgs(vm.portForward), 76 + "-device", 77 + `e1000,netdev=net0,mac=${vm.macAddress}`, 78 + "-nographic", 79 + "-monitor", 80 + "none", 81 + "-chardev", 82 + "stdio,id=con0,signal=off", 83 + "-serial", 84 + "chardev:con0", 85 + ...firmwareArgs, 86 + ..._.compact( 87 + vm.drivePath && [ 88 + "-drive", 89 + `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 90 + ], 91 + ), 92 + ]); 93 + }; 94 + 95 + const startQemu = (vm: VirtualMachine, qemuArgs: string[]) => { 96 + const qemu = Deno.build.arch === "aarch64" 97 + ? "qemu-system-aarch64" 98 + : "qemu-system-x86_64"; 99 + 100 + const logPath = `${LOGS_DIR}/${vm.name}.log`; 101 + 102 + const fullCommand = vm.bridge 103 + ? `sudo ${qemu} ${ 104 + qemuArgs.slice(1).join(" ") 105 + } >> "${logPath}" 2>&1 & echo $!` 106 + : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 107 + 108 + return Effect.tryPromise({ 109 + try: async () => { 110 + const cmd = new Deno.Command("sh", { 111 + args: ["-c", fullCommand], 112 + stdin: "null", 113 + stdout: "piped", 114 + }); 115 + 116 + const { stdout } = await cmd.spawn().output(); 117 + const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 118 + return { qemuPid, logPath }; 119 + }, 120 + catch: (error) => new CommandError({ cause: error }), 121 + }); 122 + }; 123 + 124 + const logSuccess = (vm: VirtualMachine, qemuPid: number, logPath: string) => 125 + Effect.sync(() => { 126 + console.log( 127 + `${chalk.greenBright(vm.name)} restarted with PID ${ 128 + chalk.greenBright(qemuPid) 129 + }.`, 130 + ); 131 + console.log( 132 + `Logs are being written to ${chalk.blueBright(logPath)}`, 133 + ); 134 + }); 135 + 136 + const handleError = ( 137 + error: VmNotFoundError | KillQemuError | CommandError | Error, 138 + ) => 139 + Effect.sync(() => { 140 + if (error instanceof VmNotFoundError) { 141 + console.error( 142 + `Virtual machine with name or ID ${ 143 + chalk.greenBright(error.name) 144 + } not found.`, 145 + ); 146 + } else if (error instanceof KillQemuError) { 147 + console.error( 148 + `Failed to stop virtual machine ${chalk.greenBright(error.vmName)}.`, 149 + ); 150 + } else { 151 + console.error(`An error occurred: ${error}`); 152 + } 153 + Deno.exit(1); 154 + }); 155 + 156 + const restartEffect = (name: string) => 157 + pipe( 158 + findVm(name), 159 + Effect.tap((vm) => Effect.log(`Found VM: ${vm.name}`)), 160 + Effect.flatMap(killQemu), 161 + Effect.tap((vm) => updateInstanceState(vm.id, "STOPPED")), 162 + Effect.flatMap((vm) => 163 + pipe( 164 + sleep(2000), 165 + Effect.flatMap(() => createLogsDir()), 166 + Effect.flatMap(() => setupFirmware()), 167 + Effect.flatMap((firmwareArgs) => buildQemuArgs(vm, firmwareArgs)), 168 + Effect.flatMap((qemuArgs) => startQemu(vm, qemuArgs)), 169 + Effect.tap(() => sleep(2000)), 170 + Effect.flatMap(({ qemuPid, logPath }) => 171 + pipe( 172 + updateInstanceState(vm.id, "RUNNING", qemuPid), 173 + Effect.flatMap(() => logSuccess(vm, qemuPid, logPath)), 174 + Effect.flatMap(() => sleep(2000)), 175 + ) 176 + ), 177 + ) 178 + ), 179 + Effect.catchAll(handleError), 180 + ); 181 + 182 + export default async function (name: string) { 183 + await Effect.runPromise(restartEffect(name)); 184 + Deno.exit(0); 185 + }
+50
src/subcommands/rm.ts
···
··· 1 + import { Data, Effect, pipe } from "effect"; 2 + import type { VirtualMachine } from "../db.ts"; 3 + import { getInstanceState, removeInstanceState } from "../state.ts"; 4 + 5 + class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 6 + name: string; 7 + }> {} 8 + 9 + const findVm = (name: string) => 10 + pipe( 11 + getInstanceState(name), 12 + Effect.flatMap((vm) => 13 + vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 14 + ), 15 + ); 16 + 17 + const logRemoving = (vm: VirtualMachine) => 18 + Effect.sync(() => { 19 + console.log(`Removing virtual machine ${vm.name} (ID: ${vm.id})...`); 20 + }); 21 + 22 + const removeVm = (name: string, vm: VirtualMachine) => 23 + pipe( 24 + removeInstanceState(name), 25 + Effect.map(() => vm), 26 + ); 27 + 28 + const handleError = (error: VmNotFoundError | Error) => 29 + Effect.sync(() => { 30 + if (error instanceof VmNotFoundError) { 31 + console.error( 32 + `Virtual machine with name or ID ${error.name} not found.`, 33 + ); 34 + } else { 35 + console.error(`An error occurred: ${error}`); 36 + } 37 + Deno.exit(1); 38 + }); 39 + 40 + const removeEffect = (name: string) => 41 + pipe( 42 + findVm(name), 43 + Effect.tap(logRemoving), 44 + Effect.flatMap((vm) => removeVm(name, vm)), 45 + Effect.catchAll(handleError), 46 + ); 47 + 48 + export default async function (name: string) { 49 + await Effect.runPromise(removeEffect(name)); 50 + }
+20
src/subcommands/rmi.ts
···
··· 1 + import { Effect, pipe } from "effect"; 2 + import { deleteImage, getImage } from "../images.ts"; 3 + import { failOnMissingImage } from "../utils.ts"; 4 + 5 + export default async function (id: string) { 6 + await Effect.runPromise( 7 + pipe( 8 + getImage(id), 9 + Effect.flatMap(failOnMissingImage), 10 + Effect.tap(() => deleteImage(id)), 11 + Effect.tap(() => console.log(`Image ${id} removed successfully.`)), 12 + Effect.catchAll((error) => 13 + Effect.sync(() => { 14 + console.error(`Failed to remove image: ${error.message}`); 15 + Deno.exit(1); 16 + }) 17 + ), 18 + ), 19 + ); 20 + }
+98
src/subcommands/run.ts
···
··· 1 + import { parseFlags } from "@cliffy/flags"; 2 + import { Effect, pipe } from "effect"; 3 + import type { Image, Volume } from "../db.ts"; 4 + import { getImage } from "../images.ts"; 5 + import { createBridgeNetworkIfNeeded } from "../network.ts"; 6 + import { pullImage, PullImageError, setupOrasBinary } from "../oras.ts"; 7 + import { type Options, runQemu, validateImage } from "../utils.ts"; 8 + import { createVolume, getVolume } from "../volumes.ts"; 9 + 10 + const pullImageOnMissing = ( 11 + name: string, 12 + ): Effect.Effect<Image, Error, never> => 13 + pipe( 14 + getImage(name), 15 + Effect.flatMap((img) => { 16 + if (img) { 17 + return Effect.succeed(img); 18 + } 19 + console.log(`Image ${name} not found locally`); 20 + return pipe( 21 + pullImage(name), 22 + Effect.flatMap(() => getImage(name)), 23 + Effect.flatMap((pulledImg) => 24 + pulledImg ? Effect.succeed(pulledImg) : Effect.fail( 25 + new PullImageError({ cause: "Failed to pull image" }), 26 + ) 27 + ), 28 + ); 29 + }), 30 + ); 31 + 32 + const createVolumeIfNeeded = ( 33 + image: Image, 34 + ): Effect.Effect<[Image, Volume?], Error, never> => 35 + parseFlags(Deno.args).flags.volume 36 + ? Effect.gen(function* () { 37 + const volumeName = parseFlags(Deno.args).flags.volume as string; 38 + const volume = yield* getVolume(volumeName); 39 + if (volume) { 40 + return [image, volume]; 41 + } 42 + const newVolume = yield* createVolume(volumeName, image); 43 + return [image, newVolume]; 44 + }) 45 + : Effect.succeed([image]); 46 + 47 + const runImage = ([image, volume]: [Image, Volume?]) => 48 + Effect.gen(function* () { 49 + console.log(`Running image ${image.repository}...`); 50 + const options = mergeFlags(image); 51 + if (options.bridge) { 52 + yield* createBridgeNetworkIfNeeded(options.bridge); 53 + } 54 + 55 + if (volume) { 56 + options.image = volume.path; 57 + options.install = true; 58 + options.diskFormat = "qcow2"; 59 + } 60 + 61 + yield* runQemu(null, options); 62 + }); 63 + 64 + export default async function ( 65 + image: string, 66 + ): Promise<void> { 67 + await Effect.runPromise( 68 + pipe( 69 + Effect.promise(() => setupOrasBinary()), 70 + Effect.tap(() => validateImage(image)), 71 + Effect.flatMap(() => pullImageOnMissing(image)), 72 + Effect.flatMap(createVolumeIfNeeded), 73 + Effect.flatMap(runImage), 74 + Effect.catchAll((error) => 75 + Effect.sync(() => { 76 + console.error(`Failed to run image: ${error.cause} ${image}`); 77 + Deno.exit(1); 78 + }) 79 + ), 80 + ), 81 + ); 82 + } 83 + 84 + function mergeFlags(image: Image): Options { 85 + const { flags } = parseFlags(Deno.args); 86 + return { 87 + cpu: (flags.cpu || flags.c) ? (flags.cpu || flags.c) : "host", 88 + cpus: (flags.cpus || flags.C) ? (flags.cpus || flags.C) : 2, 89 + memory: (flags.memory || flags.m) ? (flags.memory || flags.m) : "2G", 90 + image: image.path, 91 + bridge: flags.bridge || flags.b, 92 + portForward: flags.portForward || flags.p, 93 + detach: flags.detach || flags.d, 94 + install: false, 95 + diskFormat: image.format, 96 + volume: flags.volume || flags.v, 97 + }; 98 + }
+5
src/subcommands/serve.ts
···
··· 1 + import api from "../api/mod.ts"; 2 + 3 + export default function () { 4 + api(); 5 + }
+327
src/subcommands/start.ts
···
··· 1 + import { parseFlags } from "@cliffy/flags"; 2 + import _ from "@es-toolkit/es-toolkit/compat"; 3 + import { Data, Effect, pipe } from "effect"; 4 + import { LOGS_DIR } from "../constants.ts"; 5 + import type { VirtualMachine, Volume } from "../db.ts"; 6 + import { getImage } from "../images.ts"; 7 + import { getInstanceState, updateInstanceState } from "../state.ts"; 8 + import { setupFirmwareFilesIfNeeded, setupNATNetworkArgs } from "../utils.ts"; 9 + import { createVolume, getVolume } from "../volumes.ts"; 10 + 11 + export class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 12 + name: string; 13 + }> {} 14 + 15 + export class VmAlreadyRunningError 16 + extends Data.TaggedError("VmAlreadyRunningError")<{ 17 + name: string; 18 + }> {} 19 + 20 + export class CommandError extends Data.TaggedError("CommandError")<{ 21 + cause?: unknown; 22 + }> {} 23 + 24 + const findVm = (name: string) => 25 + pipe( 26 + getInstanceState(name), 27 + Effect.flatMap((vm) => 28 + vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 29 + ), 30 + ); 31 + 32 + const logStarting = (vm: VirtualMachine) => 33 + Effect.sync(() => { 34 + console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`); 35 + }); 36 + 37 + const applyFlags = (vm: VirtualMachine) => Effect.succeed(mergeFlags(vm)); 38 + 39 + export const setupFirmware = () => setupFirmwareFilesIfNeeded(); 40 + 41 + export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 42 + const qemu = Deno.build.arch === "aarch64" 43 + ? "qemu-system-aarch64" 44 + : "qemu-system-x86_64"; 45 + 46 + return Effect.succeed([ 47 + ..._.compact([vm.bridge && qemu]), 48 + ...Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"], 49 + ...Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : [], 50 + "-cpu", 51 + vm.cpu, 52 + "-m", 53 + vm.memory, 54 + "-smp", 55 + vm.cpus.toString(), 56 + ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]), 57 + "-netdev", 58 + vm.bridge 59 + ? `bridge,id=net0,br=${vm.bridge}` 60 + : setupNATNetworkArgs(vm.portForward), 61 + "-device", 62 + `e1000,netdev=net0,mac=${vm.macAddress}`, 63 + "-nographic", 64 + "-monitor", 65 + "none", 66 + "-chardev", 67 + "stdio,id=con0,signal=off", 68 + "-serial", 69 + "chardev:con0", 70 + ...firmwareArgs, 71 + ..._.compact( 72 + vm.drivePath && [ 73 + "-drive", 74 + `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 75 + ], 76 + ), 77 + ]); 78 + }; 79 + 80 + export const createLogsDir = () => 81 + Effect.tryPromise({ 82 + try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 83 + catch: (error) => new CommandError({ cause: error }), 84 + }); 85 + 86 + export const startDetachedQemu = ( 87 + name: string, 88 + vm: VirtualMachine, 89 + qemuArgs: string[], 90 + ) => { 91 + const qemu = Deno.build.arch === "aarch64" 92 + ? "qemu-system-aarch64" 93 + : "qemu-system-x86_64"; 94 + 95 + const logPath = `${LOGS_DIR}/${vm.name}.log`; 96 + 97 + const fullCommand = vm.bridge 98 + ? `sudo ${qemu} ${ 99 + qemuArgs.slice(1).join(" ") 100 + } >> "${logPath}" 2>&1 & echo $!` 101 + : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 102 + 103 + return Effect.tryPromise({ 104 + try: async () => { 105 + const cmd = new Deno.Command("sh", { 106 + args: ["-c", fullCommand], 107 + stdin: "piped", 108 + stdout: "piped", 109 + }) 110 + .spawn(); 111 + 112 + // Wait 2 seconds and send "1" to boot normally 113 + setTimeout(async () => { 114 + try { 115 + const writer = cmd.stdin.getWriter(); 116 + await writer.write(new TextEncoder().encode("1\n")); 117 + await writer.close(); 118 + } catch { 119 + // Ignore errors if stdin is already closed 120 + } 121 + }, 2000); 122 + 123 + const { stdout } = await cmd.output(); 124 + const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 125 + return { qemuPid, logPath }; 126 + }, 127 + catch: (error) => new CommandError({ cause: error }), 128 + }).pipe( 129 + Effect.flatMap(({ qemuPid, logPath }) => 130 + pipe( 131 + updateInstanceState(name, "RUNNING", qemuPid), 132 + Effect.map(() => ({ vm, qemuPid, logPath })), 133 + ) 134 + ), 135 + ); 136 + }; 137 + 138 + const logDetachedSuccess = ( 139 + { vm, qemuPid, logPath }: { 140 + vm: VirtualMachine; 141 + qemuPid: number; 142 + logPath: string; 143 + }, 144 + ) => 145 + Effect.sync(() => { 146 + console.log( 147 + `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`, 148 + ); 149 + console.log(`Logs will be written to: ${logPath}`); 150 + }); 151 + 152 + const startInteractiveQemu = ( 153 + name: string, 154 + vm: VirtualMachine, 155 + qemuArgs: string[], 156 + ) => { 157 + const qemu = Deno.build.arch === "aarch64" 158 + ? "qemu-system-aarch64" 159 + : "qemu-system-x86_64"; 160 + 161 + return Effect.tryPromise({ 162 + try: async () => { 163 + const cmd = new Deno.Command(vm.bridge ? "sudo" : qemu, { 164 + args: qemuArgs, 165 + stdin: "inherit", 166 + stdout: "inherit", 167 + stderr: "inherit", 168 + }); 169 + 170 + const child = cmd.spawn(); 171 + 172 + await Effect.runPromise(updateInstanceState(name, "RUNNING", child.pid)); 173 + 174 + const status = await child.status; 175 + 176 + await Effect.runPromise(updateInstanceState(name, "STOPPED", child.pid)); 177 + 178 + return status; 179 + }, 180 + catch: (error) => new CommandError({ cause: error }), 181 + }); 182 + }; 183 + 184 + const handleError = (error: VmNotFoundError | CommandError | Error) => 185 + Effect.sync(() => { 186 + if (error instanceof VmNotFoundError) { 187 + console.error( 188 + `Virtual machine with name or ID ${error.name} not found.`, 189 + ); 190 + } else { 191 + console.error(`An error occurred: ${error}`); 192 + } 193 + Deno.exit(1); 194 + }); 195 + 196 + export const createVolumeIfNeeded = ( 197 + vm: VirtualMachine, 198 + ): Effect.Effect<[VirtualMachine, Volume?], Error, never> => 199 + Effect.gen(function* () { 200 + const { flags } = parseFlags(Deno.args); 201 + if (!flags.volume) { 202 + return [vm]; 203 + } 204 + const volume = yield* getVolume(flags.volume as string); 205 + if (volume) { 206 + return [vm, volume]; 207 + } 208 + 209 + if (!vm.drivePath) { 210 + throw new Error( 211 + `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`, 212 + ); 213 + } 214 + 215 + let image = yield* getImage(vm.drivePath); 216 + 217 + if (!image) { 218 + const volume = yield* getVolume(vm.drivePath); 219 + if (volume) { 220 + image = yield* getImage(volume.baseImageId); 221 + } 222 + } 223 + 224 + const newVolume = yield* createVolume(flags.volume as string, image!); 225 + return [vm, newVolume]; 226 + }); 227 + 228 + export const failIfVMRunning = (vm: VirtualMachine) => 229 + Effect.gen(function* () { 230 + if (vm.status === "RUNNING") { 231 + return yield* Effect.fail( 232 + new VmAlreadyRunningError({ name: vm.name }), 233 + ); 234 + } 235 + return vm; 236 + }); 237 + 238 + const startDetachedEffect = (name: string) => 239 + pipe( 240 + findVm(name), 241 + Effect.flatMap(failIfVMRunning), 242 + Effect.tap(logStarting), 243 + Effect.flatMap(applyFlags), 244 + Effect.flatMap(createVolumeIfNeeded), 245 + Effect.flatMap(([vm, volume]) => 246 + pipe( 247 + setupFirmware(), 248 + Effect.flatMap((firmwareArgs) => 249 + buildQemuArgs({ 250 + ...vm, 251 + drivePath: volume ? volume.path : vm.drivePath, 252 + diskFormat: volume ? "qcow2" : vm.diskFormat, 253 + }, firmwareArgs) 254 + ), 255 + Effect.flatMap((qemuArgs) => 256 + pipe( 257 + createLogsDir(), 258 + Effect.flatMap(() => startDetachedQemu(name, vm, qemuArgs)), 259 + Effect.tap(logDetachedSuccess), 260 + Effect.map(() => 0), // Exit code 0 261 + ) 262 + ), 263 + ) 264 + ), 265 + Effect.catchAll(handleError), 266 + ); 267 + 268 + const startInteractiveEffect = (name: string) => 269 + pipe( 270 + findVm(name), 271 + Effect.flatMap(failIfVMRunning), 272 + Effect.tap(logStarting), 273 + Effect.flatMap(applyFlags), 274 + Effect.flatMap(createVolumeIfNeeded), 275 + Effect.flatMap(([vm, volume]) => 276 + pipe( 277 + setupFirmware(), 278 + Effect.flatMap((firmwareArgs) => 279 + buildQemuArgs({ 280 + ...vm, 281 + drivePath: volume ? volume.path : vm.drivePath, 282 + diskFormat: volume ? "qcow2" : vm.diskFormat, 283 + }, firmwareArgs) 284 + ), 285 + Effect.flatMap((qemuArgs) => startInteractiveQemu(name, vm, qemuArgs)), 286 + Effect.map((status) => status.success ? 0 : (status.code || 1)), 287 + ) 288 + ), 289 + Effect.catchAll(handleError), 290 + ); 291 + 292 + export default async function (name: string, detach: boolean = false) { 293 + const exitCode = await Effect.runPromise( 294 + detach ? startDetachedEffect(name) : startInteractiveEffect(name), 295 + ); 296 + 297 + if (detach) { 298 + Deno.exit(exitCode); 299 + } else if (exitCode !== 0) { 300 + Deno.exit(exitCode); 301 + } 302 + } 303 + 304 + function mergeFlags(vm: VirtualMachine): VirtualMachine { 305 + const { flags } = parseFlags(Deno.args); 306 + return { 307 + ...vm, 308 + memory: (flags.memory || flags.m) 309 + ? String(flags.memory || flags.m) 310 + : vm.memory, 311 + cpus: (flags.cpus || flags.C) ? Number(flags.cpus || flags.C) : vm.cpus, 312 + cpu: (flags.cpu || flags.c) ? String(flags.cpu || flags.c) : vm.cpu, 313 + diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat, 314 + portForward: (flags.portForward || flags.p) 315 + ? String(flags.portForward || flags.p) 316 + : vm.portForward, 317 + drivePath: (flags.image || flags.i) 318 + ? String(flags.image || flags.i) 319 + : vm.drivePath, 320 + bridge: (flags.bridge || flags.b) 321 + ? String(flags.bridge || flags.b) 322 + : vm.bridge, 323 + diskSize: (flags.size || flags.s) 324 + ? String(flags.size || flags.s) 325 + : vm.diskSize, 326 + }; 327 + }
+114
src/subcommands/stop.ts
···
··· 1 + import _ from "@es-toolkit/es-toolkit/compat"; 2 + import chalk from "chalk"; 3 + import { Data, Effect, pipe } from "effect"; 4 + import type { VirtualMachine } from "../db.ts"; 5 + import { getInstanceState, updateInstanceState } from "../state.ts"; 6 + 7 + export class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 8 + name: string; 9 + }> {} 10 + 11 + export class StopCommandError extends Data.TaggedError("StopCommandError")<{ 12 + vmName: string; 13 + exitCode: number; 14 + message?: string; 15 + }> {} 16 + 17 + export class CommandError extends Data.TaggedError("CommandError")<{ 18 + cause?: unknown; 19 + }> {} 20 + 21 + export const findVm = (name: string) => 22 + pipe( 23 + getInstanceState(name), 24 + Effect.flatMap((vm) => 25 + vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 26 + ), 27 + ); 28 + 29 + export const logStopping = (vm: VirtualMachine) => 30 + Effect.sync(() => { 31 + console.log( 32 + `Stopping virtual machine ${chalk.greenBright(vm.name)} (ID: ${ 33 + chalk.greenBright(vm.id) 34 + })...`, 35 + ); 36 + }); 37 + 38 + export const killProcess = (vm: VirtualMachine) => 39 + Effect.tryPromise({ 40 + try: async () => { 41 + const cmd = new Deno.Command(vm.bridge ? "sudo" : "kill", { 42 + args: [ 43 + ..._.compact([vm.bridge && "kill"]), 44 + "-TERM", 45 + vm.pid.toString(), 46 + ], 47 + stdin: "inherit", 48 + stdout: "inherit", 49 + stderr: "inherit", 50 + }); 51 + 52 + const status = await cmd.spawn().status; 53 + return { vm, status }; 54 + }, 55 + catch: (error) => new CommandError({ cause: error }), 56 + }).pipe( 57 + Effect.flatMap(({ vm, status }) => 58 + status.success ? Effect.succeed(vm) : Effect.fail( 59 + new StopCommandError({ 60 + vmName: vm.name, 61 + exitCode: status.code || 1, 62 + message: 63 + `Failed to stop VM ${vm.name}, exited with code ${status.code}`, 64 + }), 65 + ) 66 + ), 67 + ); 68 + 69 + export const updateToStopped = (vm: VirtualMachine) => 70 + pipe( 71 + updateInstanceState(vm.name, "STOPPED"), 72 + Effect.map(() => ({ ...vm, status: "STOPPED" } as VirtualMachine)), 73 + ); 74 + 75 + export const logSuccess = (vm: VirtualMachine) => 76 + Effect.sync(() => { 77 + console.log(`Virtual machine ${chalk.greenBright(vm.name)} stopped.`); 78 + }); 79 + 80 + const handleError = ( 81 + error: VmNotFoundError | StopCommandError | CommandError | Error, 82 + ) => 83 + Effect.sync(() => { 84 + if (error instanceof VmNotFoundError) { 85 + console.error( 86 + `Virtual machine with name or ID ${ 87 + chalk.greenBright(error.name) 88 + } not found.`, 89 + ); 90 + Deno.exit(1); 91 + } else if (error instanceof StopCommandError) { 92 + console.error( 93 + `Failed to stop virtual machine ${chalk.greenBright(error.vmName)}.`, 94 + ); 95 + Deno.exit(error.exitCode); 96 + } else { 97 + console.error(`An error occurred: ${error}`); 98 + Deno.exit(1); 99 + } 100 + }); 101 + 102 + const stopEffect = (name: string) => 103 + pipe( 104 + findVm(name), 105 + Effect.tap(logStopping), 106 + Effect.flatMap(killProcess), 107 + Effect.flatMap(updateToStopped), 108 + Effect.tap(logSuccess), 109 + Effect.catchAll(handleError), 110 + ); 111 + 112 + export default async function (name: string) { 113 + await Effect.runPromise(stopEffect(name)); 114 + }
+46
src/subcommands/tag.ts
···
··· 1 + import { createId } from "@paralleldrive/cuid2"; 2 + import { Effect, pipe } from "effect"; 3 + import { saveImage } from "../images.ts"; 4 + import { getInstanceState, type VirtualMachine } from "../mod.ts"; 5 + import { du, extractTag } from "../utils.ts"; 6 + 7 + const failIfNoVM = ( 8 + [vm, tag]: [VirtualMachine | undefined, string], 9 + ) => 10 + Effect.gen(function* () { 11 + if (!vm) { 12 + throw new Error(`VM with name ${name} not found`); 13 + } 14 + if (!vm.drivePath) { 15 + throw new Error(`VM with name ${name} has no drive attached`); 16 + } 17 + 18 + const size = yield* du(vm.drivePath); 19 + 20 + return [vm, tag, size] as [VirtualMachine, string, number]; 21 + }); 22 + 23 + export default async function (name: string, image: string) { 24 + await Effect.runPromise( 25 + pipe( 26 + Effect.all([getInstanceState(name), extractTag(image)]), 27 + Effect.flatMap(failIfNoVM), 28 + Effect.flatMap(([vm, tag, size]) => 29 + saveImage({ 30 + id: createId(), 31 + repository: image.split(":")[0], 32 + tag, 33 + size, 34 + path: vm.drivePath!, 35 + format: vm.diskFormat, 36 + }) 37 + ), 38 + Effect.catchAll((error) => 39 + Effect.sync(() => { 40 + console.error(`Failed to tag image: ${error.cause}`); 41 + Deno.exit(1); 42 + }) 43 + ), 44 + ), 45 + ); 46 + }
+98
src/subcommands/volume.ts
···
··· 1 + import { Table } from "@cliffy/table"; 2 + import dayjs from "dayjs"; 3 + import relativeTime from "dayjs/plugin/relativeTime.js"; 4 + import utc from "dayjs/plugin/utc.js"; 5 + import { Effect, pipe } from "effect"; 6 + import type { Volume } from "../db.ts"; 7 + import type { DbError } from "../mod.ts"; 8 + import { deleteVolume, getVolume, listVolumes } from "../volumes.ts"; 9 + 10 + dayjs.extend(relativeTime); 11 + dayjs.extend(utc); 12 + 13 + const createTable = () => 14 + Effect.succeed( 15 + new Table( 16 + ["NAME", "VOLUME ID", "CREATED"], 17 + ), 18 + ); 19 + 20 + const populateTable = (table: Table, volumes: Volume[]) => 21 + Effect.sync(() => { 22 + for (const volume of volumes) { 23 + table.push([ 24 + volume.name, 25 + volume.id, 26 + dayjs.utc(volume.createdAt).local().fromNow(), 27 + ]); 28 + } 29 + return table; 30 + }); 31 + 32 + const displayTable = (table: Table) => 33 + Effect.sync(() => { 34 + console.log(table.padding(2).toString()); 35 + }); 36 + 37 + const handleError = (error: DbError | Error) => 38 + Effect.sync(() => { 39 + console.error(`Failed to fetch volumes: ${error}`); 40 + Deno.exit(1); 41 + }); 42 + 43 + const lsEffect = () => 44 + pipe( 45 + Effect.all([listVolumes(), createTable()]), 46 + Effect.flatMap(([volumes, table]) => populateTable(table, volumes)), 47 + Effect.flatMap(displayTable), 48 + Effect.catchAll(handleError), 49 + ); 50 + 51 + export async function list() { 52 + await Effect.runPromise(lsEffect()); 53 + } 54 + 55 + export async function remove(name: string) { 56 + await Effect.runPromise( 57 + pipe( 58 + getVolume(name), 59 + Effect.flatMap((volume) => 60 + volume 61 + ? deleteVolume(volume.id) 62 + : Effect.fail(new Error(`Volume with name or ID ${name} not found.`)) 63 + ), 64 + Effect.tap(() => 65 + Effect.sync(() => { 66 + console.log(`Volume ${name} deleted successfully.`); 67 + }) 68 + ), 69 + Effect.catchAll((error) => 70 + Effect.sync(() => { 71 + console.error(`An error occurred: ${error}`); 72 + Deno.exit(1); 73 + }) 74 + ), 75 + ), 76 + ); 77 + } 78 + 79 + export async function inspect(name: string) { 80 + await Effect.runPromise( 81 + pipe( 82 + getVolume(name), 83 + Effect.flatMap((volume) => 84 + volume 85 + ? Effect.sync(() => { 86 + console.log(volume); 87 + }) 88 + : Effect.fail(new Error(`Volume with name or ID ${name} not found.`)) 89 + ), 90 + Effect.catchAll((error) => 91 + Effect.sync(() => { 92 + console.error(`An error occurred: ${error}`); 93 + Deno.exit(1); 94 + }) 95 + ), 96 + ), 97 + ); 98 + }
+36
src/types.ts
···
··· 1 + import z from "@zod/zod"; 2 + 3 + export type STATUS = "RUNNING" | "STOPPED"; 4 + 5 + export const MachineParamsSchema = z.object({ 6 + portForward: z.array(z.string().regex(/^\d+:\d+$/)).optional(), 7 + cpu: z.string().optional(), 8 + cpus: z.number().min(1).optional(), 9 + memory: z.string().regex(/^\d+(M|G)$/).optional(), 10 + }); 11 + 12 + export type MachineParams = z.infer<typeof MachineParamsSchema>; 13 + 14 + export const NewMachineSchema = MachineParamsSchema.extend({ 15 + portForward: z.array(z.string().regex(/^\d+:\d+$/)).optional(), 16 + cpu: z.string().default("host").optional(), 17 + cpus: z.number().min(1).default(8).optional(), 18 + memory: z.string().regex(/^\d+(M|G)$/).default("2G").optional(), 19 + image: z.string().regex( 20 + /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, 21 + ), 22 + volume: z.string().optional(), 23 + bridge: z.string().optional(), 24 + }); 25 + 26 + export type NewMachine = z.infer<typeof NewMachineSchema>; 27 + 28 + export const NewVolumeSchema = z.object({ 29 + name: z.string(), 30 + baseImage: z.string().regex( 31 + /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, 32 + ), 33 + size: z.string().regex(/^\d+(M|G|T)$/).optional(), 34 + }); 35 + 36 + export type NewVolume = z.infer<typeof NewVolumeSchema>;
+537
src/utils.ts
···
··· 1 + import _ from "@es-toolkit/es-toolkit/compat"; 2 + import { createId } from "@paralleldrive/cuid2"; 3 + import chalk from "chalk"; 4 + import { Data, Effect, pipe } from "effect"; 5 + import Moniker from "moniker"; 6 + import { EMPTY_DISK_THRESHOLD_KB, LOGS_DIR } from "./constants.ts"; 7 + import type { Image } from "./db.ts"; 8 + import { generateRandomMacAddress } from "./network.ts"; 9 + import { saveInstanceState, updateInstanceState } from "./state.ts"; 10 + 11 + export const DEFAULT_VERSION = "14.3-RELEASE"; 12 + 13 + export interface Options { 14 + output?: string; 15 + cpu: string; 16 + cpus: number; 17 + memory: string; 18 + image?: string; 19 + diskFormat?: string; 20 + size?: string; 21 + bridge?: string; 22 + portForward?: string; 23 + detach?: boolean; 24 + install?: boolean; 25 + volume?: string; 26 + } 27 + 28 + class LogCommandError extends Data.TaggedError("LogCommandError")<{ 29 + cause?: unknown; 30 + }> {} 31 + 32 + class InvalidImageNameError extends Data.TaggedError("InvalidImageNameError")<{ 33 + image: string; 34 + cause?: unknown; 35 + }> {} 36 + 37 + class NoSuchImageError extends Data.TaggedError("NoSuchImageError")<{ 38 + cause: string; 39 + }> {} 40 + 41 + export class NoSuchFileError extends Data.TaggedError("NoSuchFileError")<{ 42 + cause: string; 43 + }> {} 44 + 45 + export const getCurrentArch = (): string => { 46 + switch (Deno.build.arch) { 47 + case "x86_64": 48 + return "amd64"; 49 + case "aarch64": 50 + return "arm64"; 51 + default: 52 + return Deno.build.arch; 53 + } 54 + }; 55 + 56 + export const isValidISOurl = (url?: string): boolean => { 57 + return Boolean( 58 + (url?.startsWith("http://") || url?.startsWith("https://")) && 59 + url?.endsWith(".iso") 60 + ); 61 + }; 62 + 63 + export const humanFileSize = (blocks: number) => 64 + Effect.sync(() => { 65 + const blockSize = 512; // bytes per block 66 + let bytes = blocks * blockSize; 67 + const thresh = 1024; 68 + 69 + if (Math.abs(bytes) < thresh) { 70 + return `${bytes}B`; 71 + } 72 + 73 + const units = ["KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; 74 + let u = -1; 75 + 76 + do { 77 + bytes /= thresh; 78 + ++u; 79 + } while (Math.abs(bytes) >= thresh && u < units.length - 1); 80 + 81 + return `${bytes.toFixed(1)}${units[u]}`; 82 + }); 83 + 84 + export const validateImage = ( 85 + image: string 86 + ): Effect.Effect<string, InvalidImageNameError, never> => { 87 + const regex = 88 + /^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/; 89 + 90 + if (!regex.test(image)) { 91 + return Effect.fail( 92 + new InvalidImageNameError({ 93 + image, 94 + cause: 95 + "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 96 + }) 97 + ); 98 + } 99 + return Effect.succeed(image); 100 + }; 101 + 102 + export const extractTag = (name: string) => 103 + pipe( 104 + validateImage(name), 105 + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) 106 + ); 107 + 108 + export const failOnMissingImage = ( 109 + image: Image | undefined 110 + ): Effect.Effect<Image, Error, never> => 111 + image 112 + ? Effect.succeed(image) 113 + : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 114 + 115 + export const du = ( 116 + path: string 117 + ): Effect.Effect<number, LogCommandError, never> => 118 + Effect.tryPromise({ 119 + try: async () => { 120 + const cmd = new Deno.Command("du", { 121 + args: [path], 122 + stdout: "piped", 123 + stderr: "inherit", 124 + }); 125 + 126 + const { stdout } = await cmd.spawn().output(); 127 + const output = new TextDecoder().decode(stdout).trim(); 128 + const size = parseInt(output.split("\t")[0], 10); 129 + return size; 130 + }, 131 + catch: (error) => new LogCommandError({ cause: error }), 132 + }); 133 + 134 + export const emptyDiskImage = (path: string) => 135 + Effect.tryPromise({ 136 + try: async () => { 137 + if (!(await Deno.stat(path).catch(() => false))) { 138 + return true; 139 + } 140 + return false; 141 + }, 142 + catch: (error) => new LogCommandError({ cause: error }), 143 + }).pipe( 144 + Effect.flatMap((exists) => 145 + exists 146 + ? Effect.succeed(true) 147 + : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 148 + ) 149 + ); 150 + 151 + export const downloadIso = (url: string, options: Options) => 152 + Effect.gen(function* () { 153 + const filename = url.split("/").pop()!; 154 + const outputPath = options.output ?? filename; 155 + 156 + if (options.image) { 157 + const imageExists = yield* Effect.tryPromise({ 158 + try: () => 159 + Deno.stat(options.image!) 160 + .then(() => true) 161 + .catch(() => false), 162 + catch: (error) => new LogCommandError({ cause: error }), 163 + }); 164 + 165 + if (imageExists) { 166 + const driveSize = yield* du(options.image); 167 + if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 168 + console.log( 169 + chalk.yellowBright( 170 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.` 171 + ) 172 + ); 173 + return null; 174 + } 175 + } 176 + } 177 + 178 + const outputExists = yield* Effect.tryPromise({ 179 + try: () => 180 + Deno.stat(outputPath) 181 + .then(() => true) 182 + .catch(() => false), 183 + catch: (error) => new LogCommandError({ cause: error }), 184 + }); 185 + 186 + if (outputExists) { 187 + console.log( 188 + chalk.yellowBright( 189 + `File ${outputPath} already exists, skipping download.` 190 + ) 191 + ); 192 + return outputPath; 193 + } 194 + 195 + yield* Effect.tryPromise({ 196 + try: async () => { 197 + console.log(chalk.blueBright(`Downloading ISO from ${url}...`)); 198 + const cmd = new Deno.Command("curl", { 199 + args: ["-L", "-o", outputPath, url], 200 + stdin: "inherit", 201 + stdout: "inherit", 202 + stderr: "inherit", 203 + }); 204 + 205 + const status = await cmd.spawn().status; 206 + if (!status.success) { 207 + console.error(chalk.redBright("Failed to download ISO image.")); 208 + Deno.exit(status.code); 209 + } 210 + }, 211 + catch: (error) => new LogCommandError({ cause: error }), 212 + }); 213 + 214 + console.log(chalk.greenBright(`Downloaded ISO to ${outputPath}`)); 215 + return outputPath; 216 + }); 217 + 218 + export const setupFirmwareFilesIfNeeded = () => 219 + Effect.gen(function* () { 220 + if (Deno.build.arch !== "aarch64") { 221 + return []; 222 + } 223 + 224 + const { stdout, success } = yield* Effect.tryPromise({ 225 + try: async () => { 226 + const brewCmd = new Deno.Command("brew", { 227 + args: ["--prefix", "qemu"], 228 + stdout: "piped", 229 + stderr: "inherit", 230 + }); 231 + return await brewCmd.spawn().output(); 232 + }, 233 + catch: (error) => new LogCommandError({ cause: error }), 234 + }); 235 + 236 + if (!success) { 237 + console.error( 238 + chalk.redBright( 239 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." 240 + ) 241 + ); 242 + Deno.exit(1); 243 + } 244 + 245 + const brewPrefix = new TextDecoder().decode(stdout).trim(); 246 + const edk2Aarch64 = `${brewPrefix}/share/qemu/edk2-aarch64-code.fd`; 247 + const edk2VarsAarch64 = "./edk2-arm-vars.fd"; 248 + 249 + yield* Effect.tryPromise({ 250 + try: () => 251 + Deno.copyFile( 252 + `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 253 + edk2VarsAarch64 254 + ), 255 + catch: (error) => new LogCommandError({ cause: error }), 256 + }); 257 + 258 + return [ 259 + "-drive", 260 + `if=pflash,format=raw,file=${edk2Aarch64},readonly=on`, 261 + "-drive", 262 + `if=pflash,format=raw,file=${edk2VarsAarch64}`, 263 + ]; 264 + }); 265 + 266 + export function setupPortForwardingArgs(portForward?: string): string { 267 + if (!portForward) { 268 + return ""; 269 + } 270 + 271 + const forwards = portForward.split(",").map((pair) => { 272 + const [hostPort, guestPort] = pair.split(":"); 273 + return `hostfwd=tcp::${hostPort}-:${guestPort}`; 274 + }); 275 + 276 + return forwards.join(","); 277 + } 278 + 279 + export function setupNATNetworkArgs(portForward?: string): string { 280 + if (!portForward) { 281 + return "user,id=net0"; 282 + } 283 + 284 + const portForwarding = setupPortForwardingArgs(portForward); 285 + return `user,id=net0,${portForwarding}`; 286 + } 287 + 288 + export const runQemu = (isoPath: string | null, options: Options) => 289 + Effect.gen(function* () { 290 + const macAddress = yield* generateRandomMacAddress(); 291 + 292 + const qemu = 293 + Deno.build.arch === "aarch64" 294 + ? "qemu-system-aarch64" 295 + : "qemu-system-x86_64"; 296 + 297 + const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 298 + 299 + const qemuArgs = [ 300 + ..._.compact([options.bridge && qemu]), 301 + ...(Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"]), 302 + ...(Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : []), 303 + "-cpu", 304 + options.cpu, 305 + "-m", 306 + options.memory, 307 + "-smp", 308 + options.cpus.toString(), 309 + ..._.compact([isoPath && "-cdrom", isoPath]), 310 + "-netdev", 311 + options.bridge 312 + ? `bridge,id=net0,br=${options.bridge}` 313 + : setupNATNetworkArgs(options.portForward), 314 + "-device", 315 + `e1000,netdev=net0,mac=${macAddress}`, 316 + ...(options.install ? [] : ["-snapshot"]), 317 + "-nographic", 318 + "-monitor", 319 + "none", 320 + "-chardev", 321 + "stdio,id=con0,signal=off", 322 + "-serial", 323 + "chardev:con0", 324 + ...firmwareFiles, 325 + ..._.compact( 326 + options.image && [ 327 + "-drive", 328 + `file=${options.image},format=${options.diskFormat},if=virtio`, 329 + ] 330 + ), 331 + ]; 332 + 333 + const name = Moniker.choose(); 334 + 335 + if (options.detach) { 336 + yield* Effect.tryPromise({ 337 + try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 338 + catch: (error) => new LogCommandError({ cause: error }), 339 + }); 340 + 341 + const logPath = `${LOGS_DIR}/${name}.log`; 342 + 343 + const fullCommand = options.bridge 344 + ? `sudo ${qemu} ${qemuArgs 345 + .slice(1) 346 + .join(" ")} >> "${logPath}" 2>&1 & echo $!` 347 + : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 348 + 349 + const { stdout } = yield* Effect.tryPromise({ 350 + try: async () => { 351 + const cmd = new Deno.Command("sh", { 352 + args: ["-c", fullCommand], 353 + stdin: "null", 354 + stdout: "piped", 355 + }); 356 + return await cmd.spawn().output(); 357 + }, 358 + catch: (error) => new LogCommandError({ cause: error }), 359 + }); 360 + 361 + const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 362 + 363 + yield* saveInstanceState({ 364 + id: createId(), 365 + name, 366 + bridge: options.bridge, 367 + macAddress, 368 + memory: options.memory, 369 + cpus: options.cpus, 370 + cpu: options.cpu, 371 + diskSize: options.size || "20G", 372 + diskFormat: options.diskFormat || "raw", 373 + portForward: options.portForward, 374 + isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 375 + drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 376 + version: DEFAULT_VERSION, 377 + status: "RUNNING", 378 + pid: qemuPid, 379 + }); 380 + 381 + console.log( 382 + `Virtual machine ${name} started in background (PID: ${qemuPid})` 383 + ); 384 + console.log(`Logs will be written to: ${logPath}`); 385 + 386 + // Exit successfully while keeping VM running in background 387 + Deno.exit(0); 388 + } else { 389 + const cmd = new Deno.Command(options.bridge ? "sudo" : qemu, { 390 + args: qemuArgs, 391 + stdin: "inherit", 392 + stdout: "inherit", 393 + stderr: "inherit", 394 + }).spawn(); 395 + 396 + yield* saveInstanceState({ 397 + id: createId(), 398 + name, 399 + bridge: options.bridge, 400 + macAddress, 401 + memory: options.memory, 402 + cpus: options.cpus, 403 + cpu: options.cpu, 404 + diskSize: options.size || "20G", 405 + diskFormat: options.diskFormat || "raw", 406 + portForward: options.portForward, 407 + isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 408 + drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 409 + version: DEFAULT_VERSION, 410 + status: "RUNNING", 411 + pid: cmd.pid, 412 + }); 413 + 414 + const status = yield* Effect.tryPromise({ 415 + try: () => cmd.status, 416 + catch: (error) => new LogCommandError({ cause: error }), 417 + }); 418 + 419 + yield* updateInstanceState(name, "STOPPED"); 420 + 421 + if (!status.success) { 422 + Deno.exit(status.code); 423 + } 424 + } 425 + }); 426 + 427 + export const safeKillQemu = (pid: number, useSudo: boolean = false) => 428 + Effect.gen(function* () { 429 + const killArgs = useSudo 430 + ? ["sudo", "kill", "-TERM", pid.toString()] 431 + : ["kill", "-TERM", pid.toString()]; 432 + 433 + const termStatus = yield* Effect.tryPromise({ 434 + try: async () => { 435 + const termCmd = new Deno.Command(killArgs[0], { 436 + args: killArgs.slice(1), 437 + stdout: "null", 438 + stderr: "null", 439 + }); 440 + return await termCmd.spawn().status; 441 + }, 442 + catch: (error) => new LogCommandError({ cause: error }), 443 + }); 444 + 445 + if (termStatus.success) { 446 + yield* Effect.tryPromise({ 447 + try: () => new Promise((resolve) => setTimeout(resolve, 3000)), 448 + catch: (error) => new LogCommandError({ cause: error }), 449 + }); 450 + 451 + const checkStatus = yield* Effect.tryPromise({ 452 + try: async () => { 453 + const checkCmd = new Deno.Command("kill", { 454 + args: ["-0", pid.toString()], 455 + stdout: "null", 456 + stderr: "null", 457 + }); 458 + return await checkCmd.spawn().status; 459 + }, 460 + catch: (error) => new LogCommandError({ cause: error }), 461 + }); 462 + 463 + if (!checkStatus.success) { 464 + return true; 465 + } 466 + } 467 + 468 + const killKillArgs = useSudo 469 + ? ["sudo", "kill", "-KILL", pid.toString()] 470 + : ["kill", "-KILL", pid.toString()]; 471 + 472 + const killStatus = yield* Effect.tryPromise({ 473 + try: async () => { 474 + const killCmd = new Deno.Command(killKillArgs[0], { 475 + args: killKillArgs.slice(1), 476 + stdout: "null", 477 + stderr: "null", 478 + }); 479 + return await killCmd.spawn().status; 480 + }, 481 + catch: (error) => new LogCommandError({ cause: error }), 482 + }); 483 + 484 + return killStatus.success; 485 + }); 486 + 487 + export const createDriveImageIfNeeded = ({ 488 + image: path, 489 + diskFormat: format, 490 + size, 491 + }: Options) => 492 + Effect.gen(function* () { 493 + const pathExists = yield* Effect.tryPromise({ 494 + try: () => 495 + Deno.stat(path!) 496 + .then(() => true) 497 + .catch(() => false), 498 + catch: (error) => new LogCommandError({ cause: error }), 499 + }); 500 + 501 + if (pathExists) { 502 + console.log( 503 + chalk.yellowBright( 504 + `Drive image ${path} already exists, skipping creation.` 505 + ) 506 + ); 507 + return; 508 + } 509 + 510 + const status = yield* Effect.tryPromise({ 511 + try: async () => { 512 + const cmd = new Deno.Command("qemu-img", { 513 + args: ["create", "-f", format || "raw", path!, size!], 514 + stdin: "inherit", 515 + stdout: "inherit", 516 + stderr: "inherit", 517 + }); 518 + return await cmd.spawn().status; 519 + }, 520 + catch: (error) => new LogCommandError({ cause: error }), 521 + }); 522 + 523 + if (!status.success) { 524 + console.error(chalk.redBright("Failed to create drive image.")); 525 + Deno.exit(status.code); 526 + } 527 + 528 + console.log(chalk.greenBright(`Created drive image at ${path}`)); 529 + }); 530 + 531 + export const fileExists = ( 532 + path: string 533 + ): Effect.Effect<void, NoSuchFileError, never> => 534 + Effect.tryPromise({ 535 + try: () => Deno.stat(path), 536 + catch: (error) => new NoSuchFileError({ cause: String(error) }), 537 + });
+129
src/volumes.ts
···
··· 1 + import { createId } from "@paralleldrive/cuid2"; 2 + import { Data, Effect } from "effect"; 3 + import type { DeleteResult, InsertResult } from "kysely"; 4 + import { VOLUME_DIR } from "./constants.ts"; 5 + import { ctx } from "./context.ts"; 6 + import type { Image, Volume } from "./db.ts"; 7 + 8 + export class VolumeError extends Data.TaggedError("VolumeError")<{ 9 + message?: unknown; 10 + }> {} 11 + 12 + export const listVolumes = () => 13 + Effect.tryPromise({ 14 + try: () => ctx.db.selectFrom("volumes").selectAll().execute(), 15 + catch: (error) => 16 + new VolumeError({ 17 + message: error instanceof Error ? error.message : String(error), 18 + }), 19 + }); 20 + 21 + export const getVolume = ( 22 + id: string, 23 + ): Effect.Effect<Volume | undefined, VolumeError, never> => 24 + Effect.tryPromise({ 25 + try: () => 26 + ctx.db 27 + .selectFrom("volumes") 28 + .selectAll() 29 + .where((eb) => 30 + eb.or([ 31 + eb("name", "=", id), 32 + eb("id", "=", id), 33 + eb("path", "=", id), 34 + ]) 35 + ) 36 + .executeTakeFirst(), 37 + catch: (error) => 38 + new VolumeError({ 39 + message: error instanceof Error ? error.message : String(error), 40 + }), 41 + }); 42 + 43 + export const saveVolume = ( 44 + volume: Volume, 45 + ): Effect.Effect<InsertResult[], VolumeError, never> => 46 + Effect.tryPromise({ 47 + try: () => 48 + ctx.db.insertInto("volumes") 49 + .values(volume) 50 + .execute(), 51 + catch: (error) => 52 + new VolumeError({ 53 + message: error instanceof Error ? error.message : String(error), 54 + }), 55 + }); 56 + 57 + export const deleteVolume = ( 58 + id: string, 59 + ): Effect.Effect<DeleteResult[], VolumeError, never> => 60 + Effect.tryPromise({ 61 + try: () => 62 + ctx.db.deleteFrom("volumes").where((eb) => 63 + eb.or([ 64 + eb("name", "=", id), 65 + eb("id", "=", id), 66 + ]) 67 + ).execute(), 68 + catch: (error) => 69 + new VolumeError({ 70 + message: error instanceof Error ? error.message : String(error), 71 + }), 72 + }); 73 + 74 + export const createVolume = ( 75 + name: string, 76 + baseImage: Image, 77 + size?: string, 78 + ): Effect.Effect<Volume, VolumeError, never> => 79 + Effect.tryPromise({ 80 + try: async () => { 81 + const path = `${VOLUME_DIR}/${name}.qcow2`; 82 + 83 + if (!(await Deno.stat(path).catch(() => false))) { 84 + await Deno.mkdir(VOLUME_DIR, { recursive: true }); 85 + const qemu = new Deno.Command("qemu-img", { 86 + args: [ 87 + "create", 88 + "-F", 89 + "raw", 90 + "-f", 91 + "qcow2", 92 + "-b", 93 + baseImage.path, 94 + path, 95 + ...(size ? [size] : []), 96 + ], 97 + stdout: "inherit", 98 + stderr: "inherit", 99 + }) 100 + .spawn(); 101 + const status = await qemu.status; 102 + if (!status.success) { 103 + throw new Error( 104 + `Failed to create volume: qemu-img exited with code ${status.code}`, 105 + ); 106 + } 107 + } 108 + 109 + ctx.db.insertInto("volumes").values({ 110 + id: createId(), 111 + name, 112 + path, 113 + baseImageId: baseImage.id, 114 + }).execute(); 115 + const volume = await ctx.db 116 + .selectFrom("volumes") 117 + .selectAll() 118 + .where("name", "=", name) 119 + .executeTakeFirst(); 120 + if (!volume) { 121 + throw new Error("Failed to create volume"); 122 + } 123 + return volume; 124 + }, 125 + catch: (error) => 126 + new VolumeError({ 127 + message: error instanceof Error ? error.message : String(error), 128 + }), 129 + });