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

feat: add support for Alpine images and enhance related functionality

+276 -135
+2 -1
.gitignore
··· 8 8 *.qcow2 9 9 *.xz 10 10 *.bu 11 - *.ign 11 + *.ign 12 + seed
+24
main.ts
··· 31 31 import tag from "./src/subcommands/tag.ts"; 32 32 import * as volumes from "./src/subcommands/volume.ts"; 33 33 import { 34 + constructAlpineImageURL, 34 35 constructDebianImageURL, 35 36 constructFedoraImageURL, 36 37 constructGentooImageURL, ··· 267 268 isoPath = basename(debianImageURL); 268 269 } 269 270 } 271 + 272 + const alpineImageURL = yield* pipe( 273 + constructAlpineImageURL(input), 274 + Effect.catchAll(() => Effect.succeed(null)), 275 + ); 276 + 277 + if (alpineImageURL) { 278 + const cached = yield* pipe( 279 + basename(alpineImageURL), 280 + fileExists, 281 + Effect.flatMap(() => Effect.succeed(true)), 282 + Effect.catchAll(() => Effect.succeed(false)), 283 + ); 284 + if (!cached) { 285 + isoPath = yield* downloadIso(alpineImageURL, options); 286 + } else { 287 + isoPath = basename(alpineImageURL); 288 + } 289 + } 270 290 } 271 291 272 292 const config = yield* pipe( ··· 517 537 .option( 518 538 "-v, --volume <name:string>", 519 539 "Name of the volume to attach to the VM, will be created if it doesn't exist", 540 + ) 541 + .option( 542 + "-s, --size <size:string>", 543 + "Size of the volume to create if it doesn't exist (e.g., 20G)", 520 544 ) 521 545 .action(async (_options: unknown, image: string) => { 522 546 await run(image);
+18
src/constants.ts
··· 30 30 export const DEBIAN_ISO_URL: string = Deno.build.arch === "aarch64" 31 31 ? `https://cdimage.debian.org/debian-cd/current/arm64/iso-cd/debian-${DEBIAN_DEFAULT_VERSION}-arm64-netinst.iso` 32 32 : `https://cdimage.debian.org/debian-cd/current/amd64/iso-cd/debian-${DEBIAN_DEFAULT_VERSION}-amd64-netinst.iso`; 33 + 34 + export const ALPINE_DEFAULT_VERSION: string = "3.22.2"; 35 + 36 + export const ALPINE_ISO_URL: string = Deno.build.arch === "aarch64" 37 + ? `https://dl-cdn.alpinelinux.org/alpine/v${ 38 + ALPINE_DEFAULT_VERSION.split( 39 + ".", 40 + ) 41 + .slice(0, 2) 42 + .join(".") 43 + }/releases/cloud/generic_alpine-${ALPINE_DEFAULT_VERSION}-${Deno.build.arch}-uefi-tiny-r0.qcow2` 44 + : `https://dl-cdn.alpinelinux.org/alpine/v${ 45 + ALPINE_DEFAULT_VERSION.split( 46 + ".", 47 + ) 48 + .slice(0, 2) 49 + .join(".") 50 + }/releases/cloud/generic_alpine-${ALPINE_DEFAULT_VERSION}-${Deno.build.arch}-uefi-tiny-r0.qcow2`;
+31 -28
src/subcommands/restart.ts
··· 6 6 import { getInstanceState, updateInstanceState } from "../state.ts"; 7 7 import { 8 8 safeKillQemu, 9 + setupAlpineArgs, 9 10 setupCoreOSArgs, 10 11 setupFirmwareFilesIfNeeded, 11 12 setupNATNetworkArgs, ··· 28 29 getInstanceState(name), 29 30 Effect.flatMap((vm) => 30 31 vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 31 - ), 32 + ) 32 33 ); 33 34 34 35 const killQemu = (vm: VirtualMachine) => ··· 37 38 success 38 39 ? Effect.succeed(vm) 39 40 : Effect.fail(new KillQemuError({ vmName: vm.name })) 40 - ), 41 + ) 41 42 ); 42 43 43 44 const sleep = (ms: number) => ··· 55 56 const setupFirmware = () => setupFirmwareFilesIfNeeded(); 56 57 57 58 const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 58 - const qemu = Deno.build.arch === "aarch64" 59 - ? "qemu-system-aarch64" 60 - : "qemu-system-x86_64"; 59 + const qemu = 60 + Deno.build.arch === "aarch64" 61 + ? "qemu-system-aarch64" 62 + : "qemu-system-x86_64"; 61 63 62 64 let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); 65 + let alpineArgs: string[] = Effect.runSync(setupAlpineArgs(vm.isoPath)); 63 66 64 67 if (coreosArgs.length > 0) { 65 68 coreosArgs = coreosArgs.slice(2); 66 69 } 67 70 71 + if (alpineArgs.length > 0) { 72 + alpineArgs = alpineArgs.slice(2); 73 + } 74 + 68 75 return Effect.succeed([ 69 76 ..._.compact([vm.bridge && qemu]), 70 77 ...(Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"]), ··· 94 101 vm.drivePath && [ 95 102 "-drive", 96 103 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 97 - ], 104 + ] 98 105 ), 99 106 ...coreosArgs, 107 + ...alpineArgs, 100 108 ]); 101 109 }; 102 110 103 111 const startQemu = (vm: VirtualMachine, qemuArgs: string[]) => { 104 - const qemu = Deno.build.arch === "aarch64" 105 - ? "qemu-system-aarch64" 106 - : "qemu-system-x86_64"; 112 + const qemu = 113 + Deno.build.arch === "aarch64" 114 + ? "qemu-system-aarch64" 115 + : "qemu-system-x86_64"; 107 116 108 117 const logPath = `${LOGS_DIR}/${vm.name}.log`; 109 118 110 119 const fullCommand = vm.bridge 111 - ? `sudo ${qemu} ${ 112 - qemuArgs 120 + ? `sudo ${qemu} ${qemuArgs 113 121 .slice(1) 114 - .join(" ") 115 - } >> "${logPath}" 2>&1 & echo $!` 122 + .join(" ")} >> "${logPath}" 2>&1 & echo $!` 116 123 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 117 124 118 125 return Effect.tryPromise({ ··· 134 141 const logSuccess = (vm: VirtualMachine, qemuPid: number, logPath: string) => 135 142 Effect.sync(() => { 136 143 console.log( 137 - `${chalk.greenBright(vm.name)} restarted with PID ${ 138 - chalk.greenBright( 139 - qemuPid, 140 - ) 141 - }.`, 144 + `${chalk.greenBright(vm.name)} restarted with PID ${chalk.greenBright( 145 + qemuPid 146 + )}.` 142 147 ); 143 148 console.log(`Logs are being written to ${chalk.blueBright(logPath)}`); 144 149 }); 145 150 146 151 const handleError = ( 147 - error: VmNotFoundError | KillQemuError | CommandError | Error, 152 + error: VmNotFoundError | KillQemuError | CommandError | Error 148 153 ) => 149 154 Effect.sync(() => { 150 155 if (error instanceof VmNotFoundError) { 151 156 console.error( 152 - `Virtual machine with name or ID ${ 153 - chalk.greenBright( 154 - error.name, 155 - ) 156 - } not found.`, 157 + `Virtual machine with name or ID ${chalk.greenBright( 158 + error.name 159 + )} not found.` 157 160 ); 158 161 } else if (error instanceof KillQemuError) { 159 162 console.error( 160 - `Failed to stop virtual machine ${chalk.greenBright(error.vmName)}.`, 163 + `Failed to stop virtual machine ${chalk.greenBright(error.vmName)}.` 161 164 ); 162 165 } else { 163 166 console.error(`An error occurred: ${error}`); ··· 183 186 pipe( 184 187 updateInstanceState(vm.id, "RUNNING", qemuPid), 185 188 Effect.flatMap(() => logSuccess(vm, qemuPid, logPath)), 186 - Effect.flatMap(() => sleep(2000)), 189 + Effect.flatMap(() => sleep(2000)) 187 190 ) 188 - ), 191 + ) 189 192 ) 190 193 ), 191 - Effect.catchAll(handleError), 194 + Effect.catchAll(handleError) 192 195 ); 193 196 194 197 export default async function (name: string) {
+14 -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 size = parseFlags(Deno.args).flags.size as string | undefined; 36 + const volumeName = parseFlags(Deno.args).flags.volume as string; 37 + const volume = yield* getVolume(volumeName); 38 + if (volume) { 39 + return [image, volume]; 40 + } 41 + const newVolume = yield* createVolume(volumeName, image, size); 42 + return [image, newVolume]; 43 + }) 43 44 : Effect.succeed([image]); 44 45 45 46 const runImage = ([image, volume]: [Image, Volume?]) => ··· 73 74 console.error(`Failed to run image: ${error.cause} ${image}`); 74 75 Deno.exit(1); 75 76 }) 76 - ), 77 - ), 77 + ) 78 + ) 78 79 ); 79 80 } 80 81
+51 -46
src/subcommands/start.ts
··· 6 6 import { getImage } from "../images.ts"; 7 7 import { getInstanceState, updateInstanceState } from "../state.ts"; 8 8 import { 9 + setupAlpineArgs, 9 10 setupCoreOSArgs, 10 11 setupFirmwareFilesIfNeeded, 11 12 setupNATNetworkArgs, ··· 17 18 }> {} 18 19 19 20 export class VmAlreadyRunningError extends Data.TaggedError( 20 - "VmAlreadyRunningError", 21 + "VmAlreadyRunningError" 21 22 )<{ 22 23 name: string; 23 24 }> {} ··· 31 32 getInstanceState(name), 32 33 Effect.flatMap((vm) => 33 34 vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 34 - ), 35 + ) 35 36 ); 36 37 37 38 const logStarting = (vm: VirtualMachine) => ··· 44 45 export const setupFirmware = () => setupFirmwareFilesIfNeeded(); 45 46 46 47 export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 47 - const qemu = Deno.build.arch === "aarch64" 48 - ? "qemu-system-aarch64" 49 - : "qemu-system-x86_64"; 48 + const qemu = 49 + Deno.build.arch === "aarch64" 50 + ? "qemu-system-aarch64" 51 + : "qemu-system-x86_64"; 50 52 51 53 let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); 54 + let alpineArgs: string[] = Effect.runSync(setupAlpineArgs(vm.isoPath)); 52 55 53 56 if (coreosArgs.length > 0) { 54 57 coreosArgs = coreosArgs.slice(2); 55 58 } 56 59 60 + if (alpineArgs.length > 0) { 61 + alpineArgs = alpineArgs.slice(2); 62 + } 63 + 57 64 return Effect.succeed([ 58 65 ..._.compact([vm.bridge && qemu]), 59 66 ...(Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"]), ··· 83 90 vm.drivePath && [ 84 91 "-drive", 85 92 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 86 - ], 93 + ] 87 94 ), 88 95 ...coreosArgs, 96 + ...alpineArgs, 89 97 ...(vm.volume ? [] : ["-snapshot"]), 90 98 ]); 91 99 }; ··· 99 107 export const startDetachedQemu = ( 100 108 name: string, 101 109 vm: VirtualMachine, 102 - qemuArgs: string[], 110 + qemuArgs: string[] 103 111 ) => { 104 - const qemu = Deno.build.arch === "aarch64" 105 - ? "qemu-system-aarch64" 106 - : "qemu-system-x86_64"; 112 + const qemu = 113 + Deno.build.arch === "aarch64" 114 + ? "qemu-system-aarch64" 115 + : "qemu-system-x86_64"; 107 116 108 117 const logPath = `${LOGS_DIR}/${vm.name}.log`; 109 118 110 119 const fullCommand = vm.bridge 111 - ? `sudo ${qemu} ${ 112 - qemuArgs 120 + ? `sudo ${qemu} ${qemuArgs 113 121 .slice(1) 114 - .join(" ") 115 - } >> "${logPath}" 2>&1 & echo $!` 122 + .join(" ")} >> "${logPath}" 2>&1 & echo $!` 116 123 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 117 124 118 125 return Effect.tryPromise({ ··· 143 150 Effect.flatMap(({ qemuPid, logPath }) => 144 151 pipe( 145 152 updateInstanceState(name, "RUNNING", qemuPid), 146 - Effect.map(() => ({ vm, qemuPid, logPath })), 153 + Effect.map(() => ({ vm, qemuPid, logPath })) 147 154 ) 148 - ), 155 + ) 149 156 ); 150 157 }; 151 158 ··· 160 167 }) => 161 168 Effect.sync(() => { 162 169 console.log( 163 - `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`, 170 + `Virtual machine ${vm.name} started in background (PID: ${qemuPid})` 164 171 ); 165 172 console.log(`Logs will be written to: ${logPath}`); 166 173 }); ··· 168 175 const startInteractiveQemu = ( 169 176 name: string, 170 177 vm: VirtualMachine, 171 - qemuArgs: string[], 178 + qemuArgs: string[] 172 179 ) => { 173 - const qemu = Deno.build.arch === "aarch64" 174 - ? "qemu-system-aarch64" 175 - : "qemu-system-x86_64"; 180 + const qemu = 181 + Deno.build.arch === "aarch64" 182 + ? "qemu-system-aarch64" 183 + : "qemu-system-x86_64"; 176 184 177 185 return Effect.tryPromise({ 178 186 try: async () => { ··· 208 216 }); 209 217 210 218 export const createVolumeIfNeeded = ( 211 - vm: VirtualMachine, 219 + vm: VirtualMachine 212 220 ): Effect.Effect<[VirtualMachine, Volume?], Error, never> => 213 221 Effect.gen(function* () { 214 222 const { flags } = parseFlags(Deno.args); ··· 223 231 224 232 if (!vm.drivePath) { 225 233 throw new Error( 226 - `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`, 234 + `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.` 227 235 ); 228 236 } 229 237 ··· 266 274 diskFormat: volume ? "qcow2" : vm.diskFormat, 267 275 volume: volume?.path, 268 276 }, 269 - firmwareArgs, 277 + firmwareArgs 270 278 ) 271 279 ), 272 280 Effect.flatMap((qemuArgs) => ··· 276 284 startDetachedQemu(name, { ...vm, volume: volume?.path }, qemuArgs) 277 285 ), 278 286 Effect.tap(logDetachedSuccess), 279 - Effect.map(() => 0), // Exit code 0 287 + Effect.map(() => 0) // Exit code 0 280 288 ) 281 - ), 289 + ) 282 290 ) 283 291 ), 284 - Effect.catchAll(handleError), 292 + Effect.catchAll(handleError) 285 293 ); 286 294 287 295 const startInteractiveEffect = (name: string) => ··· 302 310 diskFormat: volume ? "qcow2" : vm.diskFormat, 303 311 volume: volume?.path, 304 312 }, 305 - firmwareArgs, 313 + firmwareArgs 306 314 ) 307 315 ), 308 316 Effect.flatMap((qemuArgs) => 309 317 startInteractiveQemu(name, { ...vm, volume: volume?.path }, qemuArgs) 310 318 ), 311 - Effect.map((status) => (status.success ? 0 : status.code || 1)), 319 + Effect.map((status) => (status.success ? 0 : status.code || 1)) 312 320 ) 313 321 ), 314 - Effect.catchAll(handleError), 322 + Effect.catchAll(handleError) 315 323 ); 316 324 317 325 export default async function (name: string, detach: boolean = false) { 318 326 const exitCode = await Effect.runPromise( 319 - detach ? startDetachedEffect(name) : startInteractiveEffect(name), 327 + detach ? startDetachedEffect(name) : startInteractiveEffect(name) 320 328 ); 321 329 322 330 if (detach) { ··· 330 338 const { flags } = parseFlags(Deno.args); 331 339 return { 332 340 ...vm, 333 - memory: flags.memory || flags.m 334 - ? String(flags.memory || flags.m) 335 - : vm.memory, 341 + memory: 342 + flags.memory || flags.m ? String(flags.memory || flags.m) : vm.memory, 336 343 cpus: flags.cpus || flags.C ? Number(flags.cpus || flags.C) : vm.cpus, 337 344 cpu: flags.cpu || flags.c ? String(flags.cpu || flags.c) : vm.cpu, 338 345 diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat, 339 - portForward: flags.portForward || flags.p 340 - ? String(flags.portForward || flags.p) 341 - : vm.portForward, 342 - drivePath: flags.image || flags.i 343 - ? String(flags.image || flags.i) 344 - : vm.drivePath, 345 - bridge: flags.bridge || flags.b 346 - ? String(flags.bridge || flags.b) 347 - : vm.bridge, 348 - diskSize: flags.size || flags.s 349 - ? String(flags.size || flags.s) 350 - : vm.diskSize, 346 + portForward: 347 + flags.portForward || flags.p 348 + ? String(flags.portForward || flags.p) 349 + : vm.portForward, 350 + drivePath: 351 + flags.image || flags.i ? String(flags.image || flags.i) : vm.drivePath, 352 + bridge: 353 + flags.bridge || flags.b ? String(flags.bridge || flags.b) : vm.bridge, 354 + diskSize: 355 + flags.size || flags.s ? String(flags.size || flags.s) : vm.diskSize, 351 356 }; 352 357 }
+98 -47
src/utils.ts
··· 5 5 import { Data, Effect, pipe } from "effect"; 6 6 import Moniker from "moniker"; 7 7 import { 8 + ALPINE_DEFAULT_VERSION, 9 + ALPINE_ISO_URL, 8 10 DEBIAN_DEFAULT_VERSION, 9 11 DEBIAN_ISO_URL, 10 12 EMPTY_DISK_THRESHOLD_KB, ··· 68 70 export const isValidISOurl = (url?: string): boolean => { 69 71 return Boolean( 70 72 (url?.startsWith("http://") || url?.startsWith("https://")) && 71 - url?.endsWith(".iso"), 73 + url?.endsWith(".iso") 72 74 ); 73 75 }; 74 76 ··· 94 96 }); 95 97 96 98 export const validateImage = ( 97 - image: string, 99 + image: string 98 100 ): Effect.Effect<string, InvalidImageNameError, never> => { 99 101 const regex = 100 102 /^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/; ··· 105 107 image, 106 108 cause: 107 109 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 108 - }), 110 + }) 109 111 ); 110 112 } 111 113 return Effect.succeed(image); ··· 114 116 export const extractTag = (name: string) => 115 117 pipe( 116 118 validateImage(name), 117 - Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), 119 + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) 118 120 ); 119 121 120 122 export const failOnMissingImage = ( 121 - image: Image | undefined, 123 + image: Image | undefined 122 124 ): Effect.Effect<Image, Error, never> => 123 125 image 124 126 ? Effect.succeed(image) 125 127 : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 126 128 127 129 export const du = ( 128 - path: string, 130 + path: string 129 131 ): Effect.Effect<number, LogCommandError, never> => 130 132 Effect.tryPromise({ 131 133 try: async () => { ··· 157 159 exists 158 160 ? Effect.succeed(true) 159 161 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 160 - ), 162 + ) 161 163 ); 162 164 163 165 export const downloadIso = (url: string, options: Options) => ··· 179 181 if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 180 182 console.log( 181 183 chalk.yellowBright( 182 - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 183 - ), 184 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.` 185 + ) 184 186 ); 185 187 return null; 186 188 } ··· 198 200 if (outputExists) { 199 201 console.log( 200 202 chalk.yellowBright( 201 - `File ${outputPath} already exists, skipping download.`, 202 - ), 203 + `File ${outputPath} already exists, skipping download.` 204 + ) 203 205 ); 204 206 return outputPath; 205 207 } ··· 248 250 if (!success) { 249 251 console.error( 250 252 chalk.redBright( 251 - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 252 - ), 253 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." 254 + ) 253 255 ); 254 256 Deno.exit(1); 255 257 } ··· 262 264 try: () => 263 265 Deno.copyFile( 264 266 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 265 - edk2VarsAarch64, 267 + edk2VarsAarch64 266 268 ), 267 269 catch: (error) => new LogCommandError({ cause: error }), 268 270 }); ··· 307 309 const configOK = yield* pipe( 308 310 fileExists("config.ign"), 309 311 Effect.flatMap(() => Effect.succeed(true)), 310 - Effect.catchAll(() => Effect.succeed(false)), 312 + Effect.catchAll(() => Effect.succeed(false)) 311 313 ); 312 314 if (!configOK) { 313 315 console.error( 314 316 chalk.redBright( 315 - "CoreOS image requires a config.ign file in the current directory.", 316 - ), 317 + "CoreOS image requires a config.ign file in the current directory." 318 + ) 317 319 ); 318 320 Deno.exit(1); 319 321 } ··· 348 350 imagePath && 349 351 imagePath.endsWith(".qcow2") && 350 352 imagePath.startsWith( 351 - `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`, 353 + `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-` 352 354 ) 353 355 ) { 354 356 return ["-drive", `file=${imagePath},format=qcow2,if=virtio`]; ··· 357 359 return []; 358 360 }); 359 361 362 + export const setupAlpineArgs = (imagePath?: string | null) => 363 + Effect.sync(() => { 364 + if ( 365 + imagePath && 366 + imagePath.endsWith(".qcow2") && 367 + imagePath.includes("alpine") 368 + ) { 369 + return [ 370 + "-drive", 371 + `file=${imagePath},format=qcow2,if=virtio`, 372 + "-drive", 373 + "if=virtio,file=seed.iso,media=cdrom", 374 + ]; 375 + } 376 + 377 + return []; 378 + }); 379 + 360 380 export const runQemu = (isoPath: string | null, options: Options) => 361 381 Effect.gen(function* () { 362 382 const macAddress = yield* generateRandomMacAddress(); 363 383 364 - const qemu = Deno.build.arch === "aarch64" 365 - ? "qemu-system-aarch64" 366 - : "qemu-system-x86_64"; 384 + const qemu = 385 + Deno.build.arch === "aarch64" 386 + ? "qemu-system-aarch64" 387 + : "qemu-system-x86_64"; 367 388 368 389 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 369 390 let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image); 370 391 let fedoraArgs: string[] = yield* setupFedoraArgs(isoPath || options.image); 371 392 let gentooArgs: string[] = yield* setupGentooArgs(isoPath || options.image); 393 + let alpineArgs: string[] = yield* setupAlpineArgs(isoPath || options.image); 372 394 373 395 if (coreosArgs.length > 0 && !isoPath) { 374 396 coreosArgs = coreosArgs.slice(2); ··· 382 404 gentooArgs = []; 383 405 } 384 406 407 + if (alpineArgs.length > 0 && !isoPath) { 408 + alpineArgs = alpineArgs.slice(2); 409 + } 410 + 385 411 const qemuArgs = [ 386 412 ..._.compact([options.bridge && qemu]), 387 413 ...(Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"]), ··· 411 437 ...coreosArgs, 412 438 ...fedoraArgs, 413 439 ...gentooArgs, 440 + ...alpineArgs, 414 441 ..._.compact( 415 442 options.image && [ 416 443 "-drive", 417 444 `file=${options.image},format=${options.diskFormat},if=virtio`, 418 - ], 445 + ] 419 446 ), 420 447 ]; 421 448 ··· 430 457 const logPath = `${LOGS_DIR}/${name}.log`; 431 458 432 459 const fullCommand = options.bridge 433 - ? `sudo ${qemu} ${ 434 - qemuArgs 460 + ? `sudo ${qemu} ${qemuArgs 435 461 .slice(1) 436 - .join(" ") 437 - } >> "${logPath}" 2>&1 & echo $!` 462 + .join(" ")} >> "${logPath}" 2>&1 & echo $!` 438 463 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 439 464 440 465 const { stdout } = yield* Effect.tryPromise({ ··· 460 485 cpus: options.cpus, 461 486 cpu: options.cpu, 462 487 diskSize: options.size || "20G", 463 - diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 488 + diskFormat: 489 + (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 464 490 options.diskFormat || 465 491 "raw", 466 492 portForward: options.portForward, ··· 479 505 }); 480 506 481 507 console.log( 482 - `Virtual machine ${name} started in background (PID: ${qemuPid})`, 508 + `Virtual machine ${name} started in background (PID: ${qemuPid})` 483 509 ); 484 510 console.log(`Logs will be written to: ${logPath}`); 485 511 ··· 502 528 cpus: options.cpus, 503 529 cpu: options.cpu, 504 530 diskSize: options.size || "20G", 505 - diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 531 + diskFormat: 532 + (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 506 533 options.diskFormat || 507 534 "raw", 508 535 portForward: options.portForward, ··· 610 637 if (pathExists) { 611 638 console.log( 612 639 chalk.yellowBright( 613 - `Drive image ${path} already exists, skipping creation.`, 614 - ), 640 + `Drive image ${path} already exists, skipping creation.` 641 + ) 615 642 ); 616 643 return; 617 644 } ··· 638 665 }); 639 666 640 667 export const fileExists = ( 641 - path: string, 668 + path: string 642 669 ): Effect.Effect<void, NoSuchFileError, never> => 643 670 Effect.try({ 644 671 try: () => Deno.statSync(path), ··· 646 673 }); 647 674 648 675 export const constructCoreOSImageURL = ( 649 - image: string, 676 + image: string 650 677 ): Effect.Effect<string, InvalidImageNameError, never> => { 651 678 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 652 679 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 654 681 if (match) { 655 682 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 656 683 return Effect.succeed( 657 - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), 684 + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) 658 685 ); 659 686 } 660 687 ··· 662 689 new InvalidImageNameError({ 663 690 image, 664 691 cause: "Image name does not match CoreOS naming conventions.", 665 - }), 692 + }) 666 693 ); 667 694 }; 668 695 ··· 691 718 }); 692 719 693 720 export const constructNixOSImageURL = ( 694 - image: string, 721 + image: string 695 722 ): Effect.Effect<string, InvalidImageNameError, never> => { 696 723 // detect with regex if image matches NixOS pattern: nixos or nixos-<version> 697 724 const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/; ··· 699 726 if (match) { 700 727 const version = match[3] || NIXOS_DEFAULT_VERSION; 701 728 return Effect.succeed( 702 - NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version), 729 + NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version) 703 730 ); 704 731 } 705 732 ··· 707 734 new InvalidImageNameError({ 708 735 image, 709 736 cause: "Image name does not match NixOS naming conventions.", 710 - }), 737 + }) 711 738 ); 712 739 }; 713 740 714 741 export const constructFedoraImageURL = ( 715 - image: string, 742 + image: string 716 743 ): Effect.Effect<string, InvalidImageNameError, never> => { 717 744 // detect with regex if image matches Fedora pattern: fedora 718 745 const fedoraRegex = /^(fedora)$/; ··· 725 752 new InvalidImageNameError({ 726 753 image, 727 754 cause: "Image name does not match Fedora naming conventions.", 728 - }), 755 + }) 729 756 ); 730 757 }; 731 758 732 759 export const constructGentooImageURL = ( 733 - image: string, 760 + image: string 734 761 ): Effect.Effect<string, InvalidImageNameError, never> => { 735 762 // detect with regex if image matches genroo pattern: gentoo-20251116T161545Z or gentoo 736 763 const gentooRegex = /^(gentoo)(-(\d{8}T\d{6}Z))?$/; ··· 739 766 return Effect.succeed( 740 767 GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll( 741 768 "20251116T233105Z", 742 - match[3], 743 - ), 769 + match[3] 770 + ) 744 771 ); 745 772 } 746 773 ··· 752 779 new InvalidImageNameError({ 753 780 image, 754 781 cause: "Image name does not match Gentoo naming conventions.", 755 - }), 782 + }) 756 783 ); 757 784 }; 758 785 759 786 export const constructDebianImageURL = ( 760 - image: string, 787 + image: string 761 788 ): Effect.Effect<string, InvalidImageNameError, never> => { 762 789 // detect with regex if image matches debian pattern: debian-<version> or debian 763 790 const debianRegex = /^(debian)(-(\d+\.\d+\.\d+))?$/; 764 791 const match = image.match(debianRegex); 765 792 if (match?.[3]) { 766 793 return Effect.succeed( 767 - DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]), 794 + DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]) 768 795 ); 769 796 } 770 797 ··· 776 803 new InvalidImageNameError({ 777 804 image, 778 805 cause: "Image name does not match Debian naming conventions.", 779 - }), 806 + }) 807 + ); 808 + }; 809 + 810 + export const constructAlpineImageURL = ( 811 + image: string 812 + ): Effect.Effect<string, InvalidImageNameError, never> => { 813 + // detect with regex if image matches alpine pattern: alpine-<version> or alpine 814 + const alpineRegex = /^(alpine)(-(\d+\.\d+(\.\d+)?))?$/; 815 + const match = image.match(alpineRegex); 816 + if (match?.[3]) { 817 + return Effect.succeed( 818 + ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]) 819 + ); 820 + } 821 + 822 + if (match) { 823 + return Effect.succeed(ALPINE_ISO_URL); 824 + } 825 + 826 + return Effect.fail( 827 + new InvalidImageNameError({ 828 + image, 829 + cause: "Image name does not match Alpine naming conventions.", 830 + }) 780 831 ); 781 832 };
+38
src/utils_test.ts
··· 1 1 import { assertEquals } from "@std/assert"; 2 2 import { Effect, pipe } from "effect"; 3 3 import { 4 + ALPINE_ISO_URL, 4 5 DEBIAN_ISO_URL, 5 6 FEDORA_COREOS_IMG_URL, 6 7 GENTOO_IMG_URL, 7 8 NIXOS_ISO_URL, 8 9 } from "./constants.ts"; 9 10 import { 11 + constructAlpineImageURL, 10 12 constructCoreOSImageURL, 11 13 constructDebianImageURL, 12 14 constructGentooImageURL, ··· 171 173 172 174 assertEquals(url, null); 173 175 }); 176 + 177 + Deno.test("Test valid Alpine Image Name", () => { 178 + const url = Effect.runSync( 179 + pipe( 180 + constructAlpineImageURL("alpine-3.22.2"), 181 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 182 + ), 183 + ); 184 + 185 + assertEquals( 186 + url, 187 + `https://dl-cdn.alpinelinux.org/alpine/v3.22/releases/cloud/generic_alpine-3.22.2-${Deno.build.arch}-uefi-tiny-r0.qcow2`, 188 + ); 189 + }); 190 + 191 + Deno.test("Test valid Alpine Image Name", () => { 192 + const url = Effect.runSync( 193 + pipe( 194 + constructAlpineImageURL("alpine"), 195 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 196 + ), 197 + ); 198 + 199 + assertEquals(url, ALPINE_ISO_URL); 200 + }); 201 + 202 + Deno.test("Test invalid Alpine Image Name", () => { 203 + const url = Effect.runSync( 204 + pipe( 205 + constructAlpineImageURL("alpine-latest"), 206 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 207 + ), 208 + ); 209 + 210 + assertEquals(url, null); 211 + });