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

run format

+149 -133
+1 -1
deno.json
··· 29 "kysely": "npm:kysely@0.27.6", 30 "moniker": "npm:moniker@^0.1.2" 31 } 32 - }
··· 29 "kysely": "npm:kysely@0.27.6", 30 "moniker": "npm:moniker@^0.1.2" 31 } 32 + }
+26 -33
src/api/machines.ts
··· 43 Effect.flatMap((params) => 44 listInstances(params.all === "true" || params.all === "1") 45 ), 46 - presentation(c) 47 - ) 48 - ) 49 - ); 50 51 app.post("/", (c) => 52 Effect.runPromise( ··· 57 const image = yield* getImage(params.image); 58 if (!image) { 59 return yield* Effect.fail( 60 - new ImageNotFoundError({ id: params.image }) 61 ); 62 } 63 ··· 91 sshPwauth: false, 92 }, 93 }, 94 - tempDir 95 ); 96 } 97 ··· 116 seed: _.get( 117 params, 118 "seed", 119 - params.users ? `${SEED_DIR}/seed-${name}.iso` : undefined 120 ), 121 pid: 0, 122 }); ··· 126 }) 127 ), 128 presentation(c), 129 - Effect.catchAll((error) => handleError(error, c)) 130 - ) 131 - ) 132 - ); 133 134 app.get("/:id", (c) => 135 Effect.runPromise( 136 pipe( 137 parseParams(c), 138 Effect.flatMap(({ id }) => getInstanceState(id)), 139 - presentation(c) 140 - ) 141 - ) 142 - ); 143 144 app.delete("/:id", (c) => 145 Effect.runPromise( ··· 158 }) 159 ), 160 presentation(c), 161 - Effect.catchAll((error) => handleError(error, c)) 162 - ) 163 - ) 164 - ); 165 166 app.post("/:id/start", (c) => 167 Effect.runPromise( ··· 186 ? startRequest.portForward.join(",") 187 : vm.portForward, 188 }, 189 - firmwareArgs 190 ); 191 yield* createLogsDir(); 192 yield* startDetachedQemu(vm.id, vm, qemuArgs); ··· 194 }) 195 ), 196 presentation(c), 197 - Effect.catchAll((error) => handleError(error, c)) 198 - ) 199 - ) 200 - ); 201 202 app.post("/:id/stop", (c) => 203 Effect.runPromise( ··· 207 Effect.flatMap(killProcess), 208 Effect.flatMap(updateToStopped), 209 presentation(c), 210 - Effect.catchAll((error) => handleError(error, c)) 211 - ) 212 - ) 213 - ); 214 215 app.post("/:id/restart", (c) => 216 Effect.runPromise( ··· 237 ? startRequest.portForward.join(",") 238 : vm.portForward, 239 }, 240 - firmwareArgs 241 ); 242 yield* createLogsDir(); 243 yield* startDetachedQemu(vm.id, vm, qemuArgs); ··· 245 }) 246 ), 247 presentation(c), 248 - Effect.catchAll((error) => handleError(error, c)) 249 - ) 250 - ) 251 - ); 252 253 export default app;
··· 43 Effect.flatMap((params) => 44 listInstances(params.all === "true" || params.all === "1") 45 ), 46 + presentation(c), 47 + ), 48 + )); 49 50 app.post("/", (c) => 51 Effect.runPromise( ··· 56 const image = yield* getImage(params.image); 57 if (!image) { 58 return yield* Effect.fail( 59 + new ImageNotFoundError({ id: params.image }), 60 ); 61 } 62 ··· 90 sshPwauth: false, 91 }, 92 }, 93 + tempDir, 94 ); 95 } 96 ··· 115 seed: _.get( 116 params, 117 "seed", 118 + params.users ? `${SEED_DIR}/seed-${name}.iso` : undefined, 119 ), 120 pid: 0, 121 }); ··· 125 }) 126 ), 127 presentation(c), 128 + Effect.catchAll((error) => handleError(error, c)), 129 + ), 130 + )); 131 132 app.get("/:id", (c) => 133 Effect.runPromise( 134 pipe( 135 parseParams(c), 136 Effect.flatMap(({ id }) => getInstanceState(id)), 137 + presentation(c), 138 + ), 139 + )); 140 141 app.delete("/:id", (c) => 142 Effect.runPromise( ··· 155 }) 156 ), 157 presentation(c), 158 + Effect.catchAll((error) => handleError(error, c)), 159 + ), 160 + )); 161 162 app.post("/:id/start", (c) => 163 Effect.runPromise( ··· 182 ? startRequest.portForward.join(",") 183 : vm.portForward, 184 }, 185 + firmwareArgs, 186 ); 187 yield* createLogsDir(); 188 yield* startDetachedQemu(vm.id, vm, qemuArgs); ··· 190 }) 191 ), 192 presentation(c), 193 + Effect.catchAll((error) => handleError(error, c)), 194 + ), 195 + )); 196 197 app.post("/:id/stop", (c) => 198 Effect.runPromise( ··· 202 Effect.flatMap(killProcess), 203 Effect.flatMap(updateToStopped), 204 presentation(c), 205 + Effect.catchAll((error) => handleError(error, c)), 206 + ), 207 + )); 208 209 app.post("/:id/restart", (c) => 210 Effect.runPromise( ··· 231 ? startRequest.portForward.join(",") 232 : vm.portForward, 233 }, 234 + firmwareArgs, 235 ); 236 yield* createLogsDir(); 237 yield* startDetachedQemu(vm.id, vm, qemuArgs); ··· 239 }) 240 ), 241 presentation(c), 242 + Effect.catchAll((error) => handleError(error, c)), 243 + ), 244 + )); 245 246 export default app;
+52 -28
src/migrations.ts
··· 34 .addColumn("isoPath", "varchar") 35 .addColumn("status", "varchar", (col) => col.notNull()) 36 .addColumn("pid", "integer") 37 - .addColumn("createdAt", "varchar", (col) => 38 - col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 39 ) 40 - .addColumn("updatedAt", "varchar", (col) => 41 - col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 42 ) 43 .execute(); 44 }, ··· 153 .addColumn("size", "integer", (col) => col.notNull()) 154 .addColumn("path", "varchar", (col) => col.notNull()) 155 .addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2")) 156 - .addColumn("createdAt", "varchar", (col) => 157 - col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 158 ) 159 .addUniqueConstraint("images_repository_tag_unique", [ 160 "repository", ··· 212 .createTable("volumes") 213 .addColumn("id", "varchar", (col) => col.primaryKey()) 214 .addColumn("name", "varchar", (col) => col.notNull().unique()) 215 - .addColumn("baseImageId", "varchar", (col) => 216 - col.notNull().references("images.id").onDelete("cascade") 217 ) 218 .addColumn("path", "varchar", (col) => col.notNull()) 219 - .addColumn("createdAt", "varchar", (col) => 220 - col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 221 ) 222 .execute(); 223 }, ··· 233 .createTable("volumes_new") 234 .addColumn("id", "varchar", (col) => col.primaryKey()) 235 .addColumn("name", "varchar", (col) => col.notNull().unique()) 236 - .addColumn("baseImageId", "varchar", (col) => 237 - col.notNull().references("images.id").onDelete("cascade") 238 ) 239 .addColumn("path", "varchar", (col) => col.notNull()) 240 - .addColumn("createdAt", "varchar", (col) => 241 - col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 242 ) 243 .execute(); 244 ··· 307 .addColumn("pid", "integer") 308 .addColumn("volume", "varchar") 309 .addColumn("seed", "varchar") 310 - .addColumn("createdAt", "varchar", (col) => 311 - col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 312 ) 313 - .addColumn("updatedAt", "varchar", (col) => 314 - col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 315 ) 316 .execute(); 317 ··· 321 `.execute(db); 322 323 await db.schema.dropTable("virtual_machines").execute(); 324 - await sql`ALTER TABLE virtual_machines_new RENAME TO virtual_machines`.execute( 325 - db 326 - ); 327 }, 328 329 async down(db: Kysely<unknown>): Promise<void> { ··· 347 .addColumn("pid", "integer") 348 .addColumn("volume", "varchar") 349 .addColumn("seed", "varchar") 350 - .addColumn("createdAt", "varchar", (col) => 351 - col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 352 ) 353 - .addColumn("updatedAt", "varchar", (col) => 354 - col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) 355 ) 356 .execute(); 357 ··· 361 `.execute(db); 362 363 await db.schema.dropTable("virtual_machines").execute(); 364 - await sql`ALTER TABLE virtual_machines_old RENAME TO virtual_machines`.execute( 365 - db 366 - ); 367 }, 368 }; 369
··· 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 }, ··· 157 .addColumn("size", "integer", (col) => col.notNull()) 158 .addColumn("path", "varchar", (col) => col.notNull()) 159 .addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2")) 160 + .addColumn( 161 + "createdAt", 162 + "varchar", 163 + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 164 ) 165 .addUniqueConstraint("images_repository_tag_unique", [ 166 "repository", ··· 218 .createTable("volumes") 219 .addColumn("id", "varchar", (col) => col.primaryKey()) 220 .addColumn("name", "varchar", (col) => col.notNull().unique()) 221 + .addColumn( 222 + "baseImageId", 223 + "varchar", 224 + (col) => col.notNull().references("images.id").onDelete("cascade"), 225 ) 226 .addColumn("path", "varchar", (col) => col.notNull()) 227 + .addColumn( 228 + "createdAt", 229 + "varchar", 230 + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 231 ) 232 .execute(); 233 }, ··· 243 .createTable("volumes_new") 244 .addColumn("id", "varchar", (col) => col.primaryKey()) 245 .addColumn("name", "varchar", (col) => col.notNull().unique()) 246 + .addColumn( 247 + "baseImageId", 248 + "varchar", 249 + (col) => col.notNull().references("images.id").onDelete("cascade"), 250 ) 251 .addColumn("path", "varchar", (col) => col.notNull()) 252 + .addColumn( 253 + "createdAt", 254 + "varchar", 255 + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 256 ) 257 .execute(); 258 ··· 321 .addColumn("pid", "integer") 322 .addColumn("volume", "varchar") 323 .addColumn("seed", "varchar") 324 + .addColumn( 325 + "createdAt", 326 + "varchar", 327 + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 328 ) 329 + .addColumn( 330 + "updatedAt", 331 + "varchar", 332 + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 333 ) 334 .execute(); 335 ··· 339 `.execute(db); 340 341 await db.schema.dropTable("virtual_machines").execute(); 342 + await sql`ALTER TABLE virtual_machines_new RENAME TO virtual_machines` 343 + .execute( 344 + db, 345 + ); 346 }, 347 348 async down(db: Kysely<unknown>): Promise<void> { ··· 366 .addColumn("pid", "integer") 367 .addColumn("volume", "varchar") 368 .addColumn("seed", "varchar") 369 + .addColumn( 370 + "createdAt", 371 + "varchar", 372 + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 373 ) 374 + .addColumn( 375 + "updatedAt", 376 + "varchar", 377 + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 378 ) 379 .execute(); 380 ··· 384 `.execute(db); 385 386 await db.schema.dropTable("virtual_machines").execute(); 387 + await sql`ALTER TABLE virtual_machines_old RENAME TO virtual_machines` 388 + .execute( 389 + db, 390 + ); 391 }, 392 }; 393
+70 -71
src/utils.ts
··· 65 export const isValidISOurl = (url?: string): boolean => { 66 return Boolean( 67 (url?.startsWith("http://") || url?.startsWith("https://")) && 68 - url?.endsWith(".iso") 69 ); 70 }; 71 ··· 91 }); 92 93 export const validateImage = ( 94 - image: string 95 ): Effect.Effect<string, InvalidImageNameError, never> => { 96 const regex = 97 /^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/; ··· 102 image, 103 cause: 104 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 105 - }) 106 ); 107 } 108 return Effect.succeed(image); ··· 111 export const extractTag = (name: string) => 112 pipe( 113 validateImage(name), 114 - Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) 115 ); 116 117 export const failOnMissingImage = ( 118 - image: Image | undefined 119 ): Effect.Effect<Image, Error, never> => 120 image 121 ? Effect.succeed(image) 122 : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 123 124 export const du = ( 125 - path: string 126 ): Effect.Effect<number, LogCommandError, never> => 127 Effect.tryPromise({ 128 try: async () => { ··· 154 exists 155 ? Effect.succeed(true) 156 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 157 - ) 158 ); 159 160 export const downloadIso = (url: string, options: Options) => ··· 176 if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 177 console.log( 178 chalk.yellowBright( 179 - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.` 180 - ) 181 ); 182 return null; 183 } ··· 195 if (outputExists) { 196 console.log( 197 chalk.yellowBright( 198 - `File ${outputPath} already exists, skipping download.` 199 - ) 200 ); 201 return outputPath; 202 } ··· 207 chalk.blueBright( 208 `Downloading ${ 209 url.endsWith(".iso") ? "ISO" : "image" 210 - } from ${url}...` 211 - ) 212 ); 213 const cmd = new Deno.Command("curl", { 214 args: ["-L", "-o", outputPath, url], ··· 251 if (!success) { 252 console.error( 253 chalk.redBright( 254 - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." 255 - ) 256 ); 257 Deno.exit(1); 258 } ··· 265 try: () => 266 Deno.copyFile( 267 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 268 - edk2VarsAarch64 269 ), 270 catch: (error) => new LogCommandError({ cause: error }), 271 }); ··· 310 const configOK = yield* pipe( 311 fileExists("config.ign"), 312 Effect.flatMap(() => Effect.succeed(true)), 313 - Effect.catchAll(() => Effect.succeed(false)) 314 ); 315 if (!configOK) { 316 console.error( 317 chalk.redBright( 318 - "CoreOS image requires a config.ign file in the current directory." 319 - ) 320 ); 321 Deno.exit(1); 322 } ··· 356 imagePath && 357 imagePath.endsWith(".qcow2") && 358 imagePath.startsWith( 359 - `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-` 360 ) 361 ) { 362 return [ ··· 371 372 export const setupAlpineArgs = ( 373 imagePath?: string | null, 374 - seed: string = "seed.iso" 375 ) => 376 Effect.sync(() => { 377 if ( ··· 392 393 export const setupDebianArgs = ( 394 imagePath?: string | null, 395 - seed: string = "seed.iso" 396 ) => 397 Effect.sync(() => { 398 if ( ··· 413 414 export const setupUbuntuArgs = ( 415 imagePath?: string | null, 416 - seed: string = "seed.iso" 417 ) => 418 Effect.sync(() => { 419 if ( ··· 434 435 export const setupAlmaLinuxArgs = ( 436 imagePath?: string | null, 437 - seed: string = "seed.iso" 438 ) => 439 Effect.sync(() => { 440 if ( ··· 455 456 export const setupRockyLinuxArgs = ( 457 imagePath?: string | null, 458 - seed: string = "seed.iso" 459 ) => 460 Effect.sync(() => { 461 if ( ··· 478 Effect.gen(function* () { 479 const macAddress = yield* generateRandomMacAddress(); 480 481 - const qemu = 482 - Deno.build.arch === "aarch64" 483 - ? "qemu-system-aarch64" 484 - : "qemu-system-x86_64"; 485 486 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 487 let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image); 488 let fedoraArgs: string[] = yield* setupFedoraArgs( 489 isoPath || options.image, 490 - options.seed 491 ); 492 let gentooArgs: string[] = yield* setupGentooArgs( 493 isoPath || options.image, 494 - options.seed 495 ); 496 let alpineArgs: string[] = yield* setupAlpineArgs( 497 isoPath || options.image, 498 - options.seed 499 ); 500 let debianArgs: string[] = yield* setupDebianArgs( 501 isoPath || options.image, 502 - options.seed 503 ); 504 let ubuntuArgs: string[] = yield* setupUbuntuArgs( 505 isoPath || options.image, 506 - options.seed 507 ); 508 let almalinuxArgs: string[] = yield* setupAlmaLinuxArgs( 509 isoPath || options.image, 510 - options.seed 511 ); 512 let rockylinuxArgs: string[] = yield* setupRockyLinuxArgs( 513 isoPath || options.image, 514 - options.seed 515 ); 516 517 if (coreosArgs.length > 0 && !isoPath) { ··· 584 options.image && [ 585 "-drive", 586 `file=${options.image},format=${options.diskFormat},if=virtio`, 587 - ] 588 ), 589 ]; 590 ··· 599 const logPath = `${LOGS_DIR}/${name}.log`; 600 601 const fullCommand = options.bridge 602 - ? `sudo ${qemu} ${qemuArgs 603 .slice(1) 604 - .join(" ")} >> "${logPath}" 2>&1 & echo $!` 605 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 606 607 const { stdout } = yield* Effect.tryPromise({ ··· 627 cpus: options.cpus, 628 cpu: options.cpu, 629 diskSize: options.size || "20G", 630 - diskFormat: 631 - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 632 options.diskFormat || 633 "raw", 634 portForward: options.portForward, ··· 647 }); 648 649 console.log( 650 - `Virtual machine ${name} started in background (PID: ${qemuPid})` 651 ); 652 console.log(`Logs will be written to: ${logPath}`); 653 ··· 670 cpus: options.cpus, 671 cpu: options.cpu, 672 diskSize: options.size || "20G", 673 - diskFormat: 674 - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 675 options.diskFormat || 676 "raw", 677 portForward: options.portForward, ··· 779 if (pathExists) { 780 console.log( 781 chalk.yellowBright( 782 - `Drive image ${path} already exists, skipping creation.` 783 - ) 784 ); 785 return; 786 } ··· 807 }); 808 809 export const fileExists = ( 810 - path: string 811 ): Effect.Effect<void, NoSuchFileError, never> => 812 Effect.try({ 813 try: () => Deno.statSync(path), ··· 815 }); 816 817 export const constructCoreOSImageURL = ( 818 - image: string 819 ): Effect.Effect<string, InvalidImageNameError, never> => { 820 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 821 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 823 if (match) { 824 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 825 return Effect.succeed( 826 - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) 827 ); 828 } 829 ··· 831 new InvalidImageNameError({ 832 image, 833 cause: "Image name does not match CoreOS naming conventions.", 834 - }) 835 ); 836 }; 837 ··· 860 }); 861 862 export const constructNixOSImageURL = ( 863 - image: string 864 ): Effect.Effect<string, InvalidImageNameError, never> => { 865 // detect with regex if image matches NixOS pattern: nixos or nixos-<version> 866 const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/; ··· 868 if (match) { 869 const version = match[3] || NIXOS_DEFAULT_VERSION; 870 return Effect.succeed( 871 - NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version) 872 ); 873 } 874 ··· 876 new InvalidImageNameError({ 877 image, 878 cause: "Image name does not match NixOS naming conventions.", 879 - }) 880 ); 881 }; 882 883 export const constructFedoraImageURL = ( 884 image: string, 885 - cloud: boolean = false 886 ): Effect.Effect<string, InvalidImageNameError, never> => { 887 // detect with regex if image matches Fedora pattern: fedora 888 const fedoraRegex = /^(fedora)$/; ··· 895 new InvalidImageNameError({ 896 image, 897 cause: "Image name does not match Fedora naming conventions.", 898 - }) 899 ); 900 }; 901 902 export const constructGentooImageURL = ( 903 - image: string 904 ): Effect.Effect<string, InvalidImageNameError, never> => { 905 // detect with regex if image matches genroo pattern: gentoo-20251116T161545Z or gentoo 906 const gentooRegex = /^(gentoo)(-(\d{8}T\d{6}Z))?$/; ··· 909 return Effect.succeed( 910 GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll( 911 "20251116T233105Z", 912 - match[3] 913 - ) 914 ); 915 } 916 ··· 922 new InvalidImageNameError({ 923 image, 924 cause: "Image name does not match Gentoo naming conventions.", 925 - }) 926 ); 927 }; 928 929 export const constructDebianImageURL = ( 930 image: string, 931 - cloud: boolean = false 932 ): Effect.Effect<string, InvalidImageNameError, never> => { 933 if (cloud && image === "debian") { 934 return Effect.succeed(DEBIAN_CLOUD_IMG_URL); ··· 939 const match = image.match(debianRegex); 940 if (match?.[3]) { 941 return Effect.succeed( 942 - DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]) 943 ); 944 } 945 ··· 951 new InvalidImageNameError({ 952 image, 953 cause: "Image name does not match Debian naming conventions.", 954 - }) 955 ); 956 }; 957 958 export const constructAlpineImageURL = ( 959 - image: string 960 ): Effect.Effect<string, InvalidImageNameError, never> => { 961 // detect with regex if image matches alpine pattern: alpine-<version> or alpine 962 const alpineRegex = /^(alpine)(-(\d+\.\d+(\.\d+)?))?$/; 963 const match = image.match(alpineRegex); 964 if (match?.[3]) { 965 return Effect.succeed( 966 - ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]) 967 ); 968 } 969 ··· 975 new InvalidImageNameError({ 976 image, 977 cause: "Image name does not match Alpine naming conventions.", 978 - }) 979 ); 980 }; 981 982 export const constructUbuntuImageURL = ( 983 image: string, 984 - cloud: boolean = false 985 ): Effect.Effect<string, InvalidImageNameError, never> => { 986 // detect with regex if image matches ubuntu pattern: ubuntu 987 const ubuntuRegex = /^(ubuntu)$/; ··· 997 new InvalidImageNameError({ 998 image, 999 cause: "Image name does not match Ubuntu naming conventions.", 1000 - }) 1001 ); 1002 }; 1003 1004 export const constructAlmaLinuxImageURL = ( 1005 image: string, 1006 - cloud: boolean = false 1007 ): Effect.Effect<string, InvalidImageNameError, never> => { 1008 // detect with regex if image matches almalinux pattern: almalinux, almalinux 1009 const almaLinuxRegex = /^(almalinux|alma)$/; ··· 1019 new InvalidImageNameError({ 1020 image, 1021 cause: "Image name does not match AlmaLinux naming conventions.", 1022 - }) 1023 ); 1024 }; 1025 1026 export const constructRockyLinuxImageURL = ( 1027 image: string, 1028 - cloud: boolean = false 1029 ): Effect.Effect<string, InvalidImageNameError, never> => { 1030 // detect with regex if image matches rockylinux pattern: rocky. rockylinux 1031 const rockyLinuxRegex = /^(rockylinux|rocky)$/; ··· 1041 new InvalidImageNameError({ 1042 image, 1043 cause: "Image name does not match RockyLinux naming conventions.", 1044 - }) 1045 ); 1046 };
··· 65 export const isValidISOurl = (url?: string): boolean => { 66 return Boolean( 67 (url?.startsWith("http://") || url?.startsWith("https://")) && 68 + url?.endsWith(".iso"), 69 ); 70 }; 71 ··· 91 }); 92 93 export const validateImage = ( 94 + image: string, 95 ): Effect.Effect<string, InvalidImageNameError, never> => { 96 const regex = 97 /^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/; ··· 102 image, 103 cause: 104 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 105 + }), 106 ); 107 } 108 return Effect.succeed(image); ··· 111 export const extractTag = (name: string) => 112 pipe( 113 validateImage(name), 114 + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), 115 ); 116 117 export const failOnMissingImage = ( 118 + image: Image | undefined, 119 ): Effect.Effect<Image, Error, never> => 120 image 121 ? Effect.succeed(image) 122 : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 123 124 export const du = ( 125 + path: string, 126 ): Effect.Effect<number, LogCommandError, never> => 127 Effect.tryPromise({ 128 try: async () => { ··· 154 exists 155 ? Effect.succeed(true) 156 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 157 + ), 158 ); 159 160 export const downloadIso = (url: string, options: Options) => ··· 176 if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 177 console.log( 178 chalk.yellowBright( 179 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 180 + ), 181 ); 182 return null; 183 } ··· 195 if (outputExists) { 196 console.log( 197 chalk.yellowBright( 198 + `File ${outputPath} already exists, skipping download.`, 199 + ), 200 ); 201 return outputPath; 202 } ··· 207 chalk.blueBright( 208 `Downloading ${ 209 url.endsWith(".iso") ? "ISO" : "image" 210 + } from ${url}...`, 211 + ), 212 ); 213 const cmd = new Deno.Command("curl", { 214 args: ["-L", "-o", outputPath, url], ··· 251 if (!success) { 252 console.error( 253 chalk.redBright( 254 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 255 + ), 256 ); 257 Deno.exit(1); 258 } ··· 265 try: () => 266 Deno.copyFile( 267 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 268 + edk2VarsAarch64, 269 ), 270 catch: (error) => new LogCommandError({ cause: error }), 271 }); ··· 310 const configOK = yield* pipe( 311 fileExists("config.ign"), 312 Effect.flatMap(() => Effect.succeed(true)), 313 + Effect.catchAll(() => Effect.succeed(false)), 314 ); 315 if (!configOK) { 316 console.error( 317 chalk.redBright( 318 + "CoreOS image requires a config.ign file in the current directory.", 319 + ), 320 ); 321 Deno.exit(1); 322 } ··· 356 imagePath && 357 imagePath.endsWith(".qcow2") && 358 imagePath.startsWith( 359 + `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`, 360 ) 361 ) { 362 return [ ··· 371 372 export const setupAlpineArgs = ( 373 imagePath?: string | null, 374 + seed: string = "seed.iso", 375 ) => 376 Effect.sync(() => { 377 if ( ··· 392 393 export const setupDebianArgs = ( 394 imagePath?: string | null, 395 + seed: string = "seed.iso", 396 ) => 397 Effect.sync(() => { 398 if ( ··· 413 414 export const setupUbuntuArgs = ( 415 imagePath?: string | null, 416 + seed: string = "seed.iso", 417 ) => 418 Effect.sync(() => { 419 if ( ··· 434 435 export const setupAlmaLinuxArgs = ( 436 imagePath?: string | null, 437 + seed: string = "seed.iso", 438 ) => 439 Effect.sync(() => { 440 if ( ··· 455 456 export const setupRockyLinuxArgs = ( 457 imagePath?: string | null, 458 + seed: string = "seed.iso", 459 ) => 460 Effect.sync(() => { 461 if ( ··· 478 Effect.gen(function* () { 479 const macAddress = yield* generateRandomMacAddress(); 480 481 + const qemu = Deno.build.arch === "aarch64" 482 + ? "qemu-system-aarch64" 483 + : "qemu-system-x86_64"; 484 485 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 486 let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image); 487 let fedoraArgs: string[] = yield* setupFedoraArgs( 488 isoPath || options.image, 489 + options.seed, 490 ); 491 let gentooArgs: string[] = yield* setupGentooArgs( 492 isoPath || options.image, 493 + options.seed, 494 ); 495 let alpineArgs: string[] = yield* setupAlpineArgs( 496 isoPath || options.image, 497 + options.seed, 498 ); 499 let debianArgs: string[] = yield* setupDebianArgs( 500 isoPath || options.image, 501 + options.seed, 502 ); 503 let ubuntuArgs: string[] = yield* setupUbuntuArgs( 504 isoPath || options.image, 505 + options.seed, 506 ); 507 let almalinuxArgs: string[] = yield* setupAlmaLinuxArgs( 508 isoPath || options.image, 509 + options.seed, 510 ); 511 let rockylinuxArgs: string[] = yield* setupRockyLinuxArgs( 512 isoPath || options.image, 513 + options.seed, 514 ); 515 516 if (coreosArgs.length > 0 && !isoPath) { ··· 583 options.image && [ 584 "-drive", 585 `file=${options.image},format=${options.diskFormat},if=virtio`, 586 + ], 587 ), 588 ]; 589 ··· 598 const logPath = `${LOGS_DIR}/${name}.log`; 599 600 const fullCommand = options.bridge 601 + ? `sudo ${qemu} ${ 602 + qemuArgs 603 .slice(1) 604 + .join(" ") 605 + } >> "${logPath}" 2>&1 & echo $!` 606 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 607 608 const { stdout } = yield* Effect.tryPromise({ ··· 628 cpus: options.cpus, 629 cpu: options.cpu, 630 diskSize: options.size || "20G", 631 + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 632 options.diskFormat || 633 "raw", 634 portForward: options.portForward, ··· 647 }); 648 649 console.log( 650 + `Virtual machine ${name} started in background (PID: ${qemuPid})`, 651 ); 652 console.log(`Logs will be written to: ${logPath}`); 653 ··· 670 cpus: options.cpus, 671 cpu: options.cpu, 672 diskSize: options.size || "20G", 673 + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 674 options.diskFormat || 675 "raw", 676 portForward: options.portForward, ··· 778 if (pathExists) { 779 console.log( 780 chalk.yellowBright( 781 + `Drive image ${path} already exists, skipping creation.`, 782 + ), 783 ); 784 return; 785 } ··· 806 }); 807 808 export const fileExists = ( 809 + path: string, 810 ): Effect.Effect<void, NoSuchFileError, never> => 811 Effect.try({ 812 try: () => Deno.statSync(path), ··· 814 }); 815 816 export const constructCoreOSImageURL = ( 817 + image: string, 818 ): Effect.Effect<string, InvalidImageNameError, never> => { 819 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 820 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 822 if (match) { 823 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 824 return Effect.succeed( 825 + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), 826 ); 827 } 828 ··· 830 new InvalidImageNameError({ 831 image, 832 cause: "Image name does not match CoreOS naming conventions.", 833 + }), 834 ); 835 }; 836 ··· 859 }); 860 861 export const constructNixOSImageURL = ( 862 + image: string, 863 ): Effect.Effect<string, InvalidImageNameError, never> => { 864 // detect with regex if image matches NixOS pattern: nixos or nixos-<version> 865 const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/; ··· 867 if (match) { 868 const version = match[3] || NIXOS_DEFAULT_VERSION; 869 return Effect.succeed( 870 + NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version), 871 ); 872 } 873 ··· 875 new InvalidImageNameError({ 876 image, 877 cause: "Image name does not match NixOS naming conventions.", 878 + }), 879 ); 880 }; 881 882 export const constructFedoraImageURL = ( 883 image: string, 884 + cloud: boolean = false, 885 ): Effect.Effect<string, InvalidImageNameError, never> => { 886 // detect with regex if image matches Fedora pattern: fedora 887 const fedoraRegex = /^(fedora)$/; ··· 894 new InvalidImageNameError({ 895 image, 896 cause: "Image name does not match Fedora naming conventions.", 897 + }), 898 ); 899 }; 900 901 export const constructGentooImageURL = ( 902 + image: string, 903 ): Effect.Effect<string, InvalidImageNameError, never> => { 904 // detect with regex if image matches genroo pattern: gentoo-20251116T161545Z or gentoo 905 const gentooRegex = /^(gentoo)(-(\d{8}T\d{6}Z))?$/; ··· 908 return Effect.succeed( 909 GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll( 910 "20251116T233105Z", 911 + match[3], 912 + ), 913 ); 914 } 915 ··· 921 new InvalidImageNameError({ 922 image, 923 cause: "Image name does not match Gentoo naming conventions.", 924 + }), 925 ); 926 }; 927 928 export const constructDebianImageURL = ( 929 image: string, 930 + cloud: boolean = false, 931 ): Effect.Effect<string, InvalidImageNameError, never> => { 932 if (cloud && image === "debian") { 933 return Effect.succeed(DEBIAN_CLOUD_IMG_URL); ··· 938 const match = image.match(debianRegex); 939 if (match?.[3]) { 940 return Effect.succeed( 941 + DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]), 942 ); 943 } 944 ··· 950 new InvalidImageNameError({ 951 image, 952 cause: "Image name does not match Debian naming conventions.", 953 + }), 954 ); 955 }; 956 957 export const constructAlpineImageURL = ( 958 + image: string, 959 ): Effect.Effect<string, InvalidImageNameError, never> => { 960 // detect with regex if image matches alpine pattern: alpine-<version> or alpine 961 const alpineRegex = /^(alpine)(-(\d+\.\d+(\.\d+)?))?$/; 962 const match = image.match(alpineRegex); 963 if (match?.[3]) { 964 return Effect.succeed( 965 + ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]), 966 ); 967 } 968 ··· 974 new InvalidImageNameError({ 975 image, 976 cause: "Image name does not match Alpine naming conventions.", 977 + }), 978 ); 979 }; 980 981 export const constructUbuntuImageURL = ( 982 image: string, 983 + cloud: boolean = false, 984 ): Effect.Effect<string, InvalidImageNameError, never> => { 985 // detect with regex if image matches ubuntu pattern: ubuntu 986 const ubuntuRegex = /^(ubuntu)$/; ··· 996 new InvalidImageNameError({ 997 image, 998 cause: "Image name does not match Ubuntu naming conventions.", 999 + }), 1000 ); 1001 }; 1002 1003 export const constructAlmaLinuxImageURL = ( 1004 image: string, 1005 + cloud: boolean = false, 1006 ): Effect.Effect<string, InvalidImageNameError, never> => { 1007 // detect with regex if image matches almalinux pattern: almalinux, almalinux 1008 const almaLinuxRegex = /^(almalinux|alma)$/; ··· 1018 new InvalidImageNameError({ 1019 image, 1020 cause: "Image name does not match AlmaLinux naming conventions.", 1021 + }), 1022 ); 1023 }; 1024 1025 export const constructRockyLinuxImageURL = ( 1026 image: string, 1027 + cloud: boolean = false, 1028 ): Effect.Effect<string, InvalidImageNameError, never> => { 1029 // detect with regex if image matches rockylinux pattern: rocky. rockylinux 1030 const rockyLinuxRegex = /^(rockylinux|rocky)$/; ··· 1040 new InvalidImageNameError({ 1041 image, 1042 cause: "Image name does not match RockyLinux naming conventions.", 1043 + }), 1044 ); 1045 };