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

format

+108 -103
+28 -24
src/subcommands/restart.ts
··· 28 28 getInstanceState(name), 29 29 Effect.flatMap((vm) => 30 30 vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 31 - ) 31 + ), 32 32 ); 33 33 34 34 const killQemu = (vm: VirtualMachine) => ··· 37 37 success 38 38 ? Effect.succeed(vm) 39 39 : Effect.fail(new KillQemuError({ vmName: vm.name })) 40 - ) 40 + ), 41 41 ); 42 42 43 43 const sleep = (ms: number) => ··· 55 55 const setupFirmware = () => setupFirmwareFilesIfNeeded(); 56 56 57 57 const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 58 - const qemu = 59 - Deno.build.arch === "aarch64" 60 - ? "qemu-system-aarch64" 61 - : "qemu-system-x86_64"; 58 + const qemu = Deno.build.arch === "aarch64" 59 + ? "qemu-system-aarch64" 60 + : "qemu-system-x86_64"; 62 61 63 62 let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); 64 63 ··· 95 94 vm.drivePath && [ 96 95 "-drive", 97 96 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 98 - ] 97 + ], 99 98 ), 100 99 ...coreosArgs, 101 100 ]); 102 101 }; 103 102 104 103 const startQemu = (vm: VirtualMachine, qemuArgs: string[]) => { 105 - const qemu = 106 - Deno.build.arch === "aarch64" 107 - ? "qemu-system-aarch64" 108 - : "qemu-system-x86_64"; 104 + const qemu = Deno.build.arch === "aarch64" 105 + ? "qemu-system-aarch64" 106 + : "qemu-system-x86_64"; 109 107 110 108 const logPath = `${LOGS_DIR}/${vm.name}.log`; 111 109 112 110 const fullCommand = vm.bridge 113 - ? `sudo ${qemu} ${qemuArgs 111 + ? `sudo ${qemu} ${ 112 + qemuArgs 114 113 .slice(1) 115 - .join(" ")} >> "${logPath}" 2>&1 & echo $!` 114 + .join(" ") 115 + } >> "${logPath}" 2>&1 & echo $!` 116 116 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 117 117 118 118 return Effect.tryPromise({ ··· 134 134 const logSuccess = (vm: VirtualMachine, qemuPid: number, logPath: string) => 135 135 Effect.sync(() => { 136 136 console.log( 137 - `${chalk.greenBright(vm.name)} restarted with PID ${chalk.greenBright( 138 - qemuPid 139 - )}.` 137 + `${chalk.greenBright(vm.name)} restarted with PID ${ 138 + chalk.greenBright( 139 + qemuPid, 140 + ) 141 + }.`, 140 142 ); 141 143 console.log(`Logs are being written to ${chalk.blueBright(logPath)}`); 142 144 }); 143 145 144 146 const handleError = ( 145 - error: VmNotFoundError | KillQemuError | CommandError | Error 147 + error: VmNotFoundError | KillQemuError | CommandError | Error, 146 148 ) => 147 149 Effect.sync(() => { 148 150 if (error instanceof VmNotFoundError) { 149 151 console.error( 150 - `Virtual machine with name or ID ${chalk.greenBright( 151 - error.name 152 - )} not found.` 152 + `Virtual machine with name or ID ${ 153 + chalk.greenBright( 154 + error.name, 155 + ) 156 + } not found.`, 153 157 ); 154 158 } else if (error instanceof KillQemuError) { 155 159 console.error( 156 - `Failed to stop virtual machine ${chalk.greenBright(error.vmName)}.` 160 + `Failed to stop virtual machine ${chalk.greenBright(error.vmName)}.`, 157 161 ); 158 162 } else { 159 163 console.error(`An error occurred: ${error}`); ··· 179 183 pipe( 180 184 updateInstanceState(vm.id, "RUNNING", qemuPid), 181 185 Effect.flatMap(() => logSuccess(vm, qemuPid, logPath)), 182 - Effect.flatMap(() => sleep(2000)) 186 + Effect.flatMap(() => sleep(2000)), 183 187 ) 184 - ) 188 + ), 185 189 ) 186 190 ), 187 - Effect.catchAll(handleError) 191 + Effect.catchAll(handleError), 188 192 ); 189 193 190 194 export default async function (name: string) {
+46 -44
src/subcommands/start.ts
··· 17 17 }> {} 18 18 19 19 export class VmAlreadyRunningError extends Data.TaggedError( 20 - "VmAlreadyRunningError" 20 + "VmAlreadyRunningError", 21 21 )<{ 22 22 name: string; 23 23 }> {} ··· 31 31 getInstanceState(name), 32 32 Effect.flatMap((vm) => 33 33 vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 34 - ) 34 + ), 35 35 ); 36 36 37 37 const logStarting = (vm: VirtualMachine) => ··· 44 44 export const setupFirmware = () => setupFirmwareFilesIfNeeded(); 45 45 46 46 export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 47 - const qemu = 48 - Deno.build.arch === "aarch64" 49 - ? "qemu-system-aarch64" 50 - : "qemu-system-x86_64"; 47 + const qemu = Deno.build.arch === "aarch64" 48 + ? "qemu-system-aarch64" 49 + : "qemu-system-x86_64"; 51 50 52 51 let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); 53 52 ··· 84 83 vm.drivePath && [ 85 84 "-drive", 86 85 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 87 - ] 86 + ], 88 87 ), 89 88 ...coreosArgs, 90 89 ]); ··· 99 98 export const startDetachedQemu = ( 100 99 name: string, 101 100 vm: VirtualMachine, 102 - qemuArgs: string[] 101 + qemuArgs: string[], 103 102 ) => { 104 - const qemu = 105 - Deno.build.arch === "aarch64" 106 - ? "qemu-system-aarch64" 107 - : "qemu-system-x86_64"; 103 + const qemu = Deno.build.arch === "aarch64" 104 + ? "qemu-system-aarch64" 105 + : "qemu-system-x86_64"; 108 106 109 107 const logPath = `${LOGS_DIR}/${vm.name}.log`; 110 108 111 109 const fullCommand = vm.bridge 112 - ? `sudo ${qemu} ${qemuArgs 110 + ? `sudo ${qemu} ${ 111 + qemuArgs 113 112 .slice(1) 114 - .join(" ")} >> "${logPath}" 2>&1 & echo $!` 113 + .join(" ") 114 + } >> "${logPath}" 2>&1 & echo $!` 115 115 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 116 116 117 117 return Effect.tryPromise({ ··· 142 142 Effect.flatMap(({ qemuPid, logPath }) => 143 143 pipe( 144 144 updateInstanceState(name, "RUNNING", qemuPid), 145 - Effect.map(() => ({ vm, qemuPid, logPath })) 145 + Effect.map(() => ({ vm, qemuPid, logPath })), 146 146 ) 147 - ) 147 + ), 148 148 ); 149 149 }; 150 150 ··· 159 159 }) => 160 160 Effect.sync(() => { 161 161 console.log( 162 - `Virtual machine ${vm.name} started in background (PID: ${qemuPid})` 162 + `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`, 163 163 ); 164 164 console.log(`Logs will be written to: ${logPath}`); 165 165 }); ··· 167 167 const startInteractiveQemu = ( 168 168 name: string, 169 169 vm: VirtualMachine, 170 - qemuArgs: string[] 170 + qemuArgs: string[], 171 171 ) => { 172 - const qemu = 173 - Deno.build.arch === "aarch64" 174 - ? "qemu-system-aarch64" 175 - : "qemu-system-x86_64"; 172 + const qemu = Deno.build.arch === "aarch64" 173 + ? "qemu-system-aarch64" 174 + : "qemu-system-x86_64"; 176 175 177 176 return Effect.tryPromise({ 178 177 try: async () => { ··· 208 207 }); 209 208 210 209 export const createVolumeIfNeeded = ( 211 - vm: VirtualMachine 210 + vm: VirtualMachine, 212 211 ): Effect.Effect<[VirtualMachine, Volume?], Error, never> => 213 212 Effect.gen(function* () { 214 213 const { flags } = parseFlags(Deno.args); ··· 222 221 223 222 if (!vm.drivePath) { 224 223 throw new Error( 225 - `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.` 224 + `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`, 226 225 ); 227 226 } 228 227 ··· 264 263 drivePath: volume ? volume.path : vm.drivePath, 265 264 diskFormat: volume ? "qcow2" : vm.diskFormat, 266 265 }, 267 - firmwareArgs 266 + firmwareArgs, 268 267 ) 269 268 ), 270 269 Effect.flatMap((qemuArgs) => ··· 272 271 createLogsDir(), 273 272 Effect.flatMap(() => startDetachedQemu(name, vm, qemuArgs)), 274 273 Effect.tap(logDetachedSuccess), 275 - Effect.map(() => 0) // Exit code 0 274 + Effect.map(() => 0), // Exit code 0 276 275 ) 277 - ) 276 + ), 278 277 ) 279 278 ), 280 - Effect.catchAll(handleError) 279 + Effect.catchAll(handleError), 281 280 ); 282 281 283 282 const startInteractiveEffect = (name: string) => ··· 297 296 drivePath: volume ? volume.path : vm.drivePath, 298 297 diskFormat: volume ? "qcow2" : vm.diskFormat, 299 298 }, 300 - firmwareArgs 299 + firmwareArgs, 301 300 ) 302 301 ), 303 302 Effect.flatMap((qemuArgs) => startInteractiveQemu(name, vm, qemuArgs)), 304 - Effect.map((status) => (status.success ? 0 : status.code || 1)) 303 + Effect.map((status) => (status.success ? 0 : status.code || 1)), 305 304 ) 306 305 ), 307 - Effect.catchAll(handleError) 306 + Effect.catchAll(handleError), 308 307 ); 309 308 310 309 export default async function (name: string, detach: boolean = false) { 311 310 const exitCode = await Effect.runPromise( 312 - detach ? startDetachedEffect(name) : startInteractiveEffect(name) 311 + detach ? startDetachedEffect(name) : startInteractiveEffect(name), 313 312 ); 314 313 315 314 if (detach) { ··· 323 322 const { flags } = parseFlags(Deno.args); 324 323 return { 325 324 ...vm, 326 - memory: 327 - flags.memory || flags.m ? String(flags.memory || flags.m) : vm.memory, 325 + memory: flags.memory || flags.m 326 + ? String(flags.memory || flags.m) 327 + : vm.memory, 328 328 cpus: flags.cpus || flags.C ? Number(flags.cpus || flags.C) : vm.cpus, 329 329 cpu: flags.cpu || flags.c ? String(flags.cpu || flags.c) : vm.cpu, 330 330 diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat, 331 - portForward: 332 - flags.portForward || flags.p 333 - ? String(flags.portForward || flags.p) 334 - : vm.portForward, 335 - drivePath: 336 - flags.image || flags.i ? String(flags.image || flags.i) : vm.drivePath, 337 - bridge: 338 - flags.bridge || flags.b ? String(flags.bridge || flags.b) : vm.bridge, 339 - diskSize: 340 - flags.size || flags.s ? String(flags.size || flags.s) : vm.diskSize, 331 + portForward: flags.portForward || flags.p 332 + ? String(flags.portForward || flags.p) 333 + : vm.portForward, 334 + drivePath: flags.image || flags.i 335 + ? String(flags.image || flags.i) 336 + : vm.drivePath, 337 + bridge: flags.bridge || flags.b 338 + ? String(flags.bridge || flags.b) 339 + : vm.bridge, 340 + diskSize: flags.size || flags.s 341 + ? String(flags.size || flags.s) 342 + : vm.diskSize, 341 343 }; 342 344 }
+34 -35
src/utils.ts
··· 62 62 export const isValidISOurl = (url?: string): boolean => { 63 63 return Boolean( 64 64 (url?.startsWith("http://") || url?.startsWith("https://")) && 65 - url?.endsWith(".iso") 65 + url?.endsWith(".iso"), 66 66 ); 67 67 }; 68 68 ··· 88 88 }); 89 89 90 90 export const validateImage = ( 91 - image: string 91 + image: string, 92 92 ): Effect.Effect<string, InvalidImageNameError, never> => { 93 93 const regex = 94 94 /^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/; ··· 99 99 image, 100 100 cause: 101 101 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 102 - }) 102 + }), 103 103 ); 104 104 } 105 105 return Effect.succeed(image); ··· 108 108 export const extractTag = (name: string) => 109 109 pipe( 110 110 validateImage(name), 111 - Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) 111 + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), 112 112 ); 113 113 114 114 export const failOnMissingImage = ( 115 - image: Image | undefined 115 + image: Image | undefined, 116 116 ): Effect.Effect<Image, Error, never> => 117 117 image 118 118 ? Effect.succeed(image) 119 119 : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 120 120 121 121 export const du = ( 122 - path: string 122 + path: string, 123 123 ): Effect.Effect<number, LogCommandError, never> => 124 124 Effect.tryPromise({ 125 125 try: async () => { ··· 151 151 exists 152 152 ? Effect.succeed(true) 153 153 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 154 - ) 154 + ), 155 155 ); 156 156 157 157 export const downloadIso = (url: string, options: Options) => ··· 173 173 if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 174 174 console.log( 175 175 chalk.yellowBright( 176 - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.` 177 - ) 176 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 177 + ), 178 178 ); 179 179 return null; 180 180 } ··· 192 192 if (outputExists) { 193 193 console.log( 194 194 chalk.yellowBright( 195 - `File ${outputPath} already exists, skipping download.` 196 - ) 195 + `File ${outputPath} already exists, skipping download.`, 196 + ), 197 197 ); 198 198 return outputPath; 199 199 } ··· 242 242 if (!success) { 243 243 console.error( 244 244 chalk.redBright( 245 - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." 246 - ) 245 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 246 + ), 247 247 ); 248 248 Deno.exit(1); 249 249 } ··· 256 256 try: () => 257 257 Deno.copyFile( 258 258 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 259 - edk2VarsAarch64 259 + edk2VarsAarch64, 260 260 ), 261 261 catch: (error) => new LogCommandError({ cause: error }), 262 262 }); ··· 301 301 const configOK = yield* pipe( 302 302 fileExists("config.ign"), 303 303 Effect.flatMap(() => Effect.succeed(true)), 304 - Effect.catchAll(() => Effect.succeed(false)) 304 + Effect.catchAll(() => Effect.succeed(false)), 305 305 ); 306 306 if (!configOK) { 307 307 console.error( 308 308 chalk.redBright( 309 - "CoreOS image requires a config.ign file in the current directory." 310 - ) 309 + "CoreOS image requires a config.ign file in the current directory.", 310 + ), 311 311 ); 312 312 Deno.exit(1); 313 313 } ··· 327 327 Effect.gen(function* () { 328 328 const macAddress = yield* generateRandomMacAddress(); 329 329 330 - const qemu = 331 - Deno.build.arch === "aarch64" 332 - ? "qemu-system-aarch64" 333 - : "qemu-system-x86_64"; 330 + const qemu = Deno.build.arch === "aarch64" 331 + ? "qemu-system-aarch64" 332 + : "qemu-system-x86_64"; 334 333 335 334 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 336 335 const coreosArgs: string[] = yield* setupCoreOSArgs(isoPath); ··· 366 365 options.image && [ 367 366 "-drive", 368 367 `file=${options.image},format=${options.diskFormat},if=virtio`, 369 - ] 368 + ], 370 369 ), 371 370 ]; 372 371 ··· 381 380 const logPath = `${LOGS_DIR}/${name}.log`; 382 381 383 382 const fullCommand = options.bridge 384 - ? `sudo ${qemu} ${qemuArgs 383 + ? `sudo ${qemu} ${ 384 + qemuArgs 385 385 .slice(1) 386 - .join(" ")} >> "${logPath}" 2>&1 & echo $!` 386 + .join(" ") 387 + } >> "${logPath}" 2>&1 & echo $!` 387 388 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 388 389 389 390 const { stdout } = yield* Effect.tryPromise({ ··· 409 410 cpus: options.cpus, 410 411 cpu: options.cpu, 411 412 diskSize: options.size || "20G", 412 - diskFormat: 413 - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 413 + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 414 414 options.diskFormat || 415 415 "raw", 416 416 portForward: options.portForward, ··· 429 429 }); 430 430 431 431 console.log( 432 - `Virtual machine ${name} started in background (PID: ${qemuPid})` 432 + `Virtual machine ${name} started in background (PID: ${qemuPid})`, 433 433 ); 434 434 console.log(`Logs will be written to: ${logPath}`); 435 435 ··· 452 452 cpus: options.cpus, 453 453 cpu: options.cpu, 454 454 diskSize: options.size || "20G", 455 - diskFormat: 456 - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 455 + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 457 456 options.diskFormat || 458 457 "raw", 459 458 portForward: options.portForward, ··· 561 560 if (pathExists) { 562 561 console.log( 563 562 chalk.yellowBright( 564 - `Drive image ${path} already exists, skipping creation.` 565 - ) 563 + `Drive image ${path} already exists, skipping creation.`, 564 + ), 566 565 ); 567 566 return; 568 567 } ··· 589 588 }); 590 589 591 590 export const fileExists = ( 592 - path: string 591 + path: string, 593 592 ): Effect.Effect<void, NoSuchFileError, never> => 594 593 Effect.try({ 595 594 try: () => Deno.statSync(path), ··· 597 596 }); 598 597 599 598 export const constructCoreOSImageURL = ( 600 - image: string 599 + image: string, 601 600 ): Effect.Effect<string, InvalidImageNameError, never> => { 602 601 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 603 602 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 605 604 if (match) { 606 605 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 607 606 return Effect.succeed( 608 - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) 607 + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), 609 608 ); 610 609 } 611 610 ··· 613 612 new InvalidImageNameError({ 614 613 image, 615 614 cause: "Image name does not match CoreOS naming conventions.", 616 - }) 615 + }), 617 616 ); 618 617 }; 619 618