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

format

+126 -121
+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) {
+13 -13
src/subcommands/run.ts
··· 22 22 pulledImg 23 23 ? Effect.succeed(pulledImg) 24 24 : Effect.fail(new PullImageError({ cause: "Failed to pull image" })) 25 - ) 25 + ), 26 26 ); 27 - }) 27 + }), 28 28 ); 29 29 30 30 const createVolumeIfNeeded = ( 31 - image: Image 31 + image: Image, 32 32 ): Effect.Effect<[Image, Volume?], Error, never> => 33 33 parseFlags(Deno.args).flags.volume 34 34 ? Effect.gen(function* () { 35 - const volumeName = parseFlags(Deno.args).flags.volume as string; 36 - const volume = yield* getVolume(volumeName); 37 - if (volume) { 38 - return [image, volume]; 39 - } 40 - const newVolume = yield* createVolume(volumeName, image); 41 - return [image, newVolume]; 42 - }) 35 + const volumeName = parseFlags(Deno.args).flags.volume as string; 36 + const volume = yield* getVolume(volumeName); 37 + if (volume) { 38 + return [image, volume]; 39 + } 40 + const newVolume = yield* createVolume(volumeName, image); 41 + return [image, newVolume]; 42 + }) 43 43 : Effect.succeed([image]); 44 44 45 45 const runImage = ([image, volume]: [Image, Volume?]) => ··· 73 73 console.error(`Failed to run image: ${error.cause} ${image}`); 74 74 Deno.exit(1); 75 75 }) 76 - ) 77 - ) 76 + ), 77 + ), 78 78 ); 79 79 } 80 80
+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 let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image); ··· 370 369 options.image && [ 371 370 "-drive", 372 371 `file=${options.image},format=${options.diskFormat},if=virtio`, 373 - ] 372 + ], 374 373 ), 375 374 ]; 376 375 ··· 385 384 const logPath = `${LOGS_DIR}/${name}.log`; 386 385 387 386 const fullCommand = options.bridge 388 - ? `sudo ${qemu} ${qemuArgs 387 + ? `sudo ${qemu} ${ 388 + qemuArgs 389 389 .slice(1) 390 - .join(" ")} >> "${logPath}" 2>&1 & echo $!` 390 + .join(" ") 391 + } >> "${logPath}" 2>&1 & echo $!` 391 392 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 392 393 393 394 const { stdout } = yield* Effect.tryPromise({ ··· 413 414 cpus: options.cpus, 414 415 cpu: options.cpu, 415 416 diskSize: options.size || "20G", 416 - diskFormat: 417 - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 417 + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 418 418 options.diskFormat || 419 419 "raw", 420 420 portForward: options.portForward, ··· 433 433 }); 434 434 435 435 console.log( 436 - `Virtual machine ${name} started in background (PID: ${qemuPid})` 436 + `Virtual machine ${name} started in background (PID: ${qemuPid})`, 437 437 ); 438 438 console.log(`Logs will be written to: ${logPath}`); 439 439 ··· 456 456 cpus: options.cpus, 457 457 cpu: options.cpu, 458 458 diskSize: options.size || "20G", 459 - diskFormat: 460 - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 459 + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 461 460 options.diskFormat || 462 461 "raw", 463 462 portForward: options.portForward, ··· 565 564 if (pathExists) { 566 565 console.log( 567 566 chalk.yellowBright( 568 - `Drive image ${path} already exists, skipping creation.` 569 - ) 567 + `Drive image ${path} already exists, skipping creation.`, 568 + ), 570 569 ); 571 570 return; 572 571 } ··· 593 592 }); 594 593 595 594 export const fileExists = ( 596 - path: string 595 + path: string, 597 596 ): Effect.Effect<void, NoSuchFileError, never> => 598 597 Effect.try({ 599 598 try: () => Deno.statSync(path), ··· 601 600 }); 602 601 603 602 export const constructCoreOSImageURL = ( 604 - image: string 603 + image: string, 605 604 ): Effect.Effect<string, InvalidImageNameError, never> => { 606 605 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 607 606 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 609 608 if (match) { 610 609 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 611 610 return Effect.succeed( 612 - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) 611 + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), 613 612 ); 614 613 } 615 614 ··· 617 616 new InvalidImageNameError({ 618 617 image, 619 618 cause: "Image name does not match CoreOS naming conventions.", 620 - }) 619 + }), 621 620 ); 622 621 }; 623 622
+5 -5
src/volumes.ts
··· 19 19 }); 20 20 21 21 export const getVolume = ( 22 - id: string 22 + id: string, 23 23 ): Effect.Effect<Volume | undefined, VolumeError, never> => 24 24 Effect.tryPromise({ 25 25 try: () => ··· 37 37 }); 38 38 39 39 export const saveVolume = ( 40 - volume: Volume 40 + volume: Volume, 41 41 ): Effect.Effect<InsertResult[], VolumeError, never> => 42 42 Effect.tryPromise({ 43 43 try: () => ctx.db.insertInto("volumes").values(volume).execute(), ··· 48 48 }); 49 49 50 50 export const deleteVolume = ( 51 - id: string 51 + id: string, 52 52 ): Effect.Effect<DeleteResult[], VolumeError, never> => 53 53 Effect.tryPromise({ 54 54 try: () => ··· 65 65 export const createVolume = ( 66 66 name: string, 67 67 baseImage: Image, 68 - size?: string 68 + size?: string, 69 69 ): Effect.Effect<Volume, VolumeError, never> => 70 70 Effect.tryPromise({ 71 71 try: async () => { ··· 91 91 const status = await qemu.status; 92 92 if (!status.success) { 93 93 throw new Error( 94 - `Failed to create volume: qemu-img exited with code ${status.code}` 94 + `Failed to create volume: qemu-img exited with code ${status.code}`, 95 95 ); 96 96 } 97 97 }