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

Refactor code for consistency and readability by adjusting formatting and removing unnecessary line breaks in multiple files.

+107 -110
+24 -28
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 = Deno.build.arch === "aarch64" 59 - ? "qemu-system-aarch64" 60 - : "qemu-system-x86_64"; 58 + const qemu = 59 + Deno.build.arch === "aarch64" 60 + ? "qemu-system-aarch64" 61 + : "qemu-system-x86_64"; 61 62 62 63 let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); 63 64 ··· 94 95 vm.drivePath && [ 95 96 "-drive", 96 97 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 97 - ], 98 + ] 98 99 ), 99 100 ...coreosArgs, 100 101 ]); 101 102 }; 102 103 103 104 const startQemu = (vm: VirtualMachine, qemuArgs: string[]) => { 104 - const qemu = Deno.build.arch === "aarch64" 105 - ? "qemu-system-aarch64" 106 - : "qemu-system-x86_64"; 105 + const qemu = 106 + Deno.build.arch === "aarch64" 107 + ? "qemu-system-aarch64" 108 + : "qemu-system-x86_64"; 107 109 108 110 const logPath = `${LOGS_DIR}/${vm.name}.log`; 109 111 110 112 const fullCommand = vm.bridge 111 - ? `sudo ${qemu} ${ 112 - qemuArgs 113 + ? `sudo ${qemu} ${qemuArgs 113 114 .slice(1) 114 - .join(" ") 115 - } >> "${logPath}" 2>&1 & echo $!` 115 + .join(" ")} >> "${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 ${ 138 - chalk.greenBright( 139 - qemuPid, 140 - ) 141 - }.`, 137 + `${chalk.greenBright(vm.name)} restarted with PID ${chalk.greenBright( 138 + qemuPid 139 + )}.` 142 140 ); 143 141 console.log(`Logs are being written to ${chalk.blueBright(logPath)}`); 144 142 }); 145 143 146 144 const handleError = ( 147 - error: VmNotFoundError | KillQemuError | CommandError | Error, 145 + error: VmNotFoundError | KillQemuError | CommandError | Error 148 146 ) => 149 147 Effect.sync(() => { 150 148 if (error instanceof VmNotFoundError) { 151 149 console.error( 152 - `Virtual machine with name or ID ${ 153 - chalk.greenBright( 154 - error.name, 155 - ) 156 - } not found.`, 150 + `Virtual machine with name or ID ${chalk.greenBright( 151 + error.name 152 + )} not found.` 157 153 ); 158 154 } else if (error instanceof KillQemuError) { 159 155 console.error( 160 - `Failed to stop virtual machine ${chalk.greenBright(error.vmName)}.`, 156 + `Failed to stop virtual machine ${chalk.greenBright(error.vmName)}.` 161 157 ); 162 158 } else { 163 159 console.error(`An error occurred: ${error}`); ··· 183 179 pipe( 184 180 updateInstanceState(vm.id, "RUNNING", qemuPid), 185 181 Effect.flatMap(() => logSuccess(vm, qemuPid, logPath)), 186 - Effect.flatMap(() => sleep(2000)), 182 + Effect.flatMap(() => sleep(2000)) 187 183 ) 188 - ), 184 + ) 189 185 ) 190 186 ), 191 - Effect.catchAll(handleError), 187 + Effect.catchAll(handleError) 192 188 ); 193 189 194 190 export default async function (name: string) {
+44 -46
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 = Deno.build.arch === "aarch64" 48 - ? "qemu-system-aarch64" 49 - : "qemu-system-x86_64"; 47 + const qemu = 48 + Deno.build.arch === "aarch64" 49 + ? "qemu-system-aarch64" 50 + : "qemu-system-x86_64"; 50 51 51 52 let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); 52 53 ··· 83 84 vm.drivePath && [ 84 85 "-drive", 85 86 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 86 - ], 87 + ] 87 88 ), 88 89 ...coreosArgs, 89 90 ]); ··· 98 99 export const startDetachedQemu = ( 99 100 name: string, 100 101 vm: VirtualMachine, 101 - qemuArgs: string[], 102 + qemuArgs: string[] 102 103 ) => { 103 - const qemu = Deno.build.arch === "aarch64" 104 - ? "qemu-system-aarch64" 105 - : "qemu-system-x86_64"; 104 + const qemu = 105 + Deno.build.arch === "aarch64" 106 + ? "qemu-system-aarch64" 107 + : "qemu-system-x86_64"; 106 108 107 109 const logPath = `${LOGS_DIR}/${vm.name}.log`; 108 110 109 111 const fullCommand = vm.bridge 110 - ? `sudo ${qemu} ${ 111 - qemuArgs 112 + ? `sudo ${qemu} ${qemuArgs 112 113 .slice(1) 113 - .join(" ") 114 - } >> "${logPath}" 2>&1 & echo $!` 114 + .join(" ")} >> "${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 = Deno.build.arch === "aarch64" 173 - ? "qemu-system-aarch64" 174 - : "qemu-system-x86_64"; 172 + const qemu = 173 + Deno.build.arch === "aarch64" 174 + ? "qemu-system-aarch64" 175 + : "qemu-system-x86_64"; 175 176 176 177 return Effect.tryPromise({ 177 178 try: async () => { ··· 207 208 }); 208 209 209 210 export const createVolumeIfNeeded = ( 210 - vm: VirtualMachine, 211 + vm: VirtualMachine 211 212 ): Effect.Effect<[VirtualMachine, Volume?], Error, never> => 212 213 Effect.gen(function* () { 213 214 const { flags } = parseFlags(Deno.args); ··· 221 222 222 223 if (!vm.drivePath) { 223 224 throw new Error( 224 - `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`, 225 + `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.` 225 226 ); 226 227 } 227 228 ··· 263 264 drivePath: volume ? volume.path : vm.drivePath, 264 265 diskFormat: volume ? "qcow2" : vm.diskFormat, 265 266 }, 266 - firmwareArgs, 267 + firmwareArgs 267 268 ) 268 269 ), 269 270 Effect.flatMap((qemuArgs) => ··· 271 272 createLogsDir(), 272 273 Effect.flatMap(() => startDetachedQemu(name, vm, qemuArgs)), 273 274 Effect.tap(logDetachedSuccess), 274 - Effect.map(() => 0), // Exit code 0 275 + Effect.map(() => 0) // Exit code 0 275 276 ) 276 - ), 277 + ) 277 278 ) 278 279 ), 279 - Effect.catchAll(handleError), 280 + Effect.catchAll(handleError) 280 281 ); 281 282 282 283 const startInteractiveEffect = (name: string) => ··· 296 297 drivePath: volume ? volume.path : vm.drivePath, 297 298 diskFormat: volume ? "qcow2" : vm.diskFormat, 298 299 }, 299 - firmwareArgs, 300 + firmwareArgs 300 301 ) 301 302 ), 302 303 Effect.flatMap((qemuArgs) => startInteractiveQemu(name, vm, qemuArgs)), 303 - Effect.map((status) => (status.success ? 0 : status.code || 1)), 304 + Effect.map((status) => (status.success ? 0 : status.code || 1)) 304 305 ) 305 306 ), 306 - Effect.catchAll(handleError), 307 + Effect.catchAll(handleError) 307 308 ); 308 309 309 310 export default async function (name: string, detach: boolean = false) { 310 311 const exitCode = await Effect.runPromise( 311 - detach ? startDetachedEffect(name) : startInteractiveEffect(name), 312 + detach ? startDetachedEffect(name) : startInteractiveEffect(name) 312 313 ); 313 314 314 315 if (detach) { ··· 322 323 const { flags } = parseFlags(Deno.args); 323 324 return { 324 325 ...vm, 325 - memory: flags.memory || flags.m 326 - ? String(flags.memory || flags.m) 327 - : vm.memory, 326 + memory: 327 + flags.memory || flags.m ? String(flags.memory || flags.m) : 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: 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, 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, 343 341 }; 344 342 }
+39 -36
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 }); ··· 284 284 285 285 export function setupNATNetworkArgs(portForward?: string): string { 286 286 if (!portForward) { 287 - return "user,id=net0"; 287 + return "user,id=net0,model=virtio"; 288 288 } 289 289 290 290 const portForwarding = setupPortForwardingArgs(portForward); ··· 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 = Deno.build.arch === "aarch64" 331 - ? "qemu-system-aarch64" 332 - : "qemu-system-x86_64"; 330 + const qemu = 331 + Deno.build.arch === "aarch64" 332 + ? "qemu-system-aarch64" 333 + : "qemu-system-x86_64"; 333 334 334 335 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 335 - const coreosArgs: string[] = yield* setupCoreOSArgs(isoPath); 336 + const coreosArgs: string[] = yield* setupCoreOSArgs( 337 + isoPath || options.image 338 + ); 336 339 337 340 const qemuArgs = [ 338 341 ..._.compact([options.bridge && qemu]), ··· 365 368 options.image && [ 366 369 "-drive", 367 370 `file=${options.image},format=${options.diskFormat},if=virtio`, 368 - ], 371 + ] 369 372 ), 370 373 ]; 371 374 ··· 380 383 const logPath = `${LOGS_DIR}/${name}.log`; 381 384 382 385 const fullCommand = options.bridge 383 - ? `sudo ${qemu} ${ 384 - qemuArgs 386 + ? `sudo ${qemu} ${qemuArgs 385 387 .slice(1) 386 - .join(" ") 387 - } >> "${logPath}" 2>&1 & echo $!` 388 + .join(" ")} >> "${logPath}" 2>&1 & echo $!` 388 389 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 389 390 390 391 const { stdout } = yield* Effect.tryPromise({ ··· 410 411 cpus: options.cpus, 411 412 cpu: options.cpu, 412 413 diskSize: options.size || "20G", 413 - diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 414 + diskFormat: 415 + (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 414 416 options.diskFormat || 415 417 "raw", 416 418 portForward: options.portForward, ··· 429 431 }); 430 432 431 433 console.log( 432 - `Virtual machine ${name} started in background (PID: ${qemuPid})`, 434 + `Virtual machine ${name} started in background (PID: ${qemuPid})` 433 435 ); 434 436 console.log(`Logs will be written to: ${logPath}`); 435 437 ··· 452 454 cpus: options.cpus, 453 455 cpu: options.cpu, 454 456 diskSize: options.size || "20G", 455 - diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 457 + diskFormat: 458 + (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 456 459 options.diskFormat || 457 460 "raw", 458 461 portForward: options.portForward, ··· 560 563 if (pathExists) { 561 564 console.log( 562 565 chalk.yellowBright( 563 - `Drive image ${path} already exists, skipping creation.`, 564 - ), 566 + `Drive image ${path} already exists, skipping creation.` 567 + ) 565 568 ); 566 569 return; 567 570 } ··· 588 591 }); 589 592 590 593 export const fileExists = ( 591 - path: string, 594 + path: string 592 595 ): Effect.Effect<void, NoSuchFileError, never> => 593 596 Effect.try({ 594 597 try: () => Deno.statSync(path), ··· 596 599 }); 597 600 598 601 export const constructCoreOSImageURL = ( 599 - image: string, 602 + image: string 600 603 ): Effect.Effect<string, InvalidImageNameError, never> => { 601 604 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 602 605 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 604 607 if (match) { 605 608 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 606 609 return Effect.succeed( 607 - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), 610 + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) 608 611 ); 609 612 } 610 613 ··· 612 615 new InvalidImageNameError({ 613 616 image, 614 617 cause: "Image name does not match CoreOS naming conventions.", 615 - }), 618 + }) 616 619 ); 617 620 }; 618 621