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

run format

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