diff --git a/deno.json b/deno.json index 00926c9..f6ae588 100644 --- a/deno.json +++ b/deno.json @@ -29,4 +29,4 @@ "kysely": "npm:kysely@0.27.6", "moniker": "npm:moniker@^0.1.2" } -} \ No newline at end of file +} diff --git a/src/api/machines.ts b/src/api/machines.ts index 489b76c..fa75e96 100644 --- a/src/api/machines.ts +++ b/src/api/machines.ts @@ -43,10 +43,9 @@ app.get("/", (c) => Effect.flatMap((params) => listInstances(params.all === "true" || params.all === "1") ), - presentation(c) - ) - ) -); + presentation(c), + ), + )); app.post("/", (c) => Effect.runPromise( @@ -57,7 +56,7 @@ app.post("/", (c) => const image = yield* getImage(params.image); if (!image) { return yield* Effect.fail( - new ImageNotFoundError({ id: params.image }) + new ImageNotFoundError({ id: params.image }), ); } @@ -91,7 +90,7 @@ app.post("/", (c) => sshPwauth: false, }, }, - tempDir + tempDir, ); } @@ -116,7 +115,7 @@ app.post("/", (c) => seed: _.get( params, "seed", - params.users ? `${SEED_DIR}/seed-${name}.iso` : undefined + params.users ? `${SEED_DIR}/seed-${name}.iso` : undefined, ), pid: 0, }); @@ -126,20 +125,18 @@ app.post("/", (c) => }) ), presentation(c), - Effect.catchAll((error) => handleError(error, c)) - ) - ) -); + Effect.catchAll((error) => handleError(error, c)), + ), + )); app.get("/:id", (c) => Effect.runPromise( pipe( parseParams(c), Effect.flatMap(({ id }) => getInstanceState(id)), - presentation(c) - ) - ) -); + presentation(c), + ), + )); app.delete("/:id", (c) => Effect.runPromise( @@ -158,10 +155,9 @@ app.delete("/:id", (c) => }) ), presentation(c), - Effect.catchAll((error) => handleError(error, c)) - ) - ) -); + Effect.catchAll((error) => handleError(error, c)), + ), + )); app.post("/:id/start", (c) => Effect.runPromise( @@ -186,7 +182,7 @@ app.post("/:id/start", (c) => ? startRequest.portForward.join(",") : vm.portForward, }, - firmwareArgs + firmwareArgs, ); yield* createLogsDir(); yield* startDetachedQemu(vm.id, vm, qemuArgs); @@ -194,10 +190,9 @@ app.post("/:id/start", (c) => }) ), presentation(c), - Effect.catchAll((error) => handleError(error, c)) - ) - ) -); + Effect.catchAll((error) => handleError(error, c)), + ), + )); app.post("/:id/stop", (c) => Effect.runPromise( @@ -207,10 +202,9 @@ app.post("/:id/stop", (c) => Effect.flatMap(killProcess), Effect.flatMap(updateToStopped), presentation(c), - Effect.catchAll((error) => handleError(error, c)) - ) - ) -); + Effect.catchAll((error) => handleError(error, c)), + ), + )); app.post("/:id/restart", (c) => Effect.runPromise( @@ -237,7 +231,7 @@ app.post("/:id/restart", (c) => ? startRequest.portForward.join(",") : vm.portForward, }, - firmwareArgs + firmwareArgs, ); yield* createLogsDir(); yield* startDetachedQemu(vm.id, vm, qemuArgs); @@ -245,9 +239,8 @@ app.post("/:id/restart", (c) => }) ), presentation(c), - Effect.catchAll((error) => handleError(error, c)) - ) - ) -); + Effect.catchAll((error) => handleError(error, c)), + ), + )); export default app; diff --git a/src/migrations.ts b/src/migrations.ts index 4b4be07..93a145c 100644 --- a/src/migrations.ts +++ b/src/migrations.ts @@ -34,11 +34,15 @@ migrations["001"] = { .addColumn("isoPath", "varchar") .addColumn("status", "varchar", (col) => col.notNull()) .addColumn("pid", "integer") - .addColumn("createdAt", "varchar", (col) => - col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) + .addColumn( + "createdAt", + "varchar", + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), ) - .addColumn("updatedAt", "varchar", (col) => - col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) + .addColumn( + "updatedAt", + "varchar", + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), ) .execute(); }, @@ -153,8 +157,10 @@ migrations["006"] = { .addColumn("size", "integer", (col) => col.notNull()) .addColumn("path", "varchar", (col) => col.notNull()) .addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2")) - .addColumn("createdAt", "varchar", (col) => - col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) + .addColumn( + "createdAt", + "varchar", + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), ) .addUniqueConstraint("images_repository_tag_unique", [ "repository", @@ -212,12 +218,16 @@ migrations["008"] = { .createTable("volumes") .addColumn("id", "varchar", (col) => col.primaryKey()) .addColumn("name", "varchar", (col) => col.notNull().unique()) - .addColumn("baseImageId", "varchar", (col) => - col.notNull().references("images.id").onDelete("cascade") + .addColumn( + "baseImageId", + "varchar", + (col) => col.notNull().references("images.id").onDelete("cascade"), ) .addColumn("path", "varchar", (col) => col.notNull()) - .addColumn("createdAt", "varchar", (col) => - col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) + .addColumn( + "createdAt", + "varchar", + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), ) .execute(); }, @@ -233,12 +243,16 @@ migrations["009"] = { .createTable("volumes_new") .addColumn("id", "varchar", (col) => col.primaryKey()) .addColumn("name", "varchar", (col) => col.notNull().unique()) - .addColumn("baseImageId", "varchar", (col) => - col.notNull().references("images.id").onDelete("cascade") + .addColumn( + "baseImageId", + "varchar", + (col) => col.notNull().references("images.id").onDelete("cascade"), ) .addColumn("path", "varchar", (col) => col.notNull()) - .addColumn("createdAt", "varchar", (col) => - col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) + .addColumn( + "createdAt", + "varchar", + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), ) .execute(); @@ -307,11 +321,15 @@ migrations["012"] = { .addColumn("pid", "integer") .addColumn("volume", "varchar") .addColumn("seed", "varchar") - .addColumn("createdAt", "varchar", (col) => - col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) + .addColumn( + "createdAt", + "varchar", + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), ) - .addColumn("updatedAt", "varchar", (col) => - col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) + .addColumn( + "updatedAt", + "varchar", + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), ) .execute(); @@ -321,9 +339,10 @@ migrations["012"] = { `.execute(db); await db.schema.dropTable("virtual_machines").execute(); - await sql`ALTER TABLE virtual_machines_new RENAME TO virtual_machines`.execute( - db - ); + await sql`ALTER TABLE virtual_machines_new RENAME TO virtual_machines` + .execute( + db, + ); }, async down(db: Kysely): Promise { @@ -347,11 +366,15 @@ migrations["012"] = { .addColumn("pid", "integer") .addColumn("volume", "varchar") .addColumn("seed", "varchar") - .addColumn("createdAt", "varchar", (col) => - col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) + .addColumn( + "createdAt", + "varchar", + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), ) - .addColumn("updatedAt", "varchar", (col) => - col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`) + .addColumn( + "updatedAt", + "varchar", + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), ) .execute(); @@ -361,9 +384,10 @@ migrations["012"] = { `.execute(db); await db.schema.dropTable("virtual_machines").execute(); - await sql`ALTER TABLE virtual_machines_old RENAME TO virtual_machines`.execute( - db - ); + await sql`ALTER TABLE virtual_machines_old RENAME TO virtual_machines` + .execute( + db, + ); }, }; diff --git a/src/utils.ts b/src/utils.ts index c347585..d009043 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -65,7 +65,7 @@ export const getCurrentArch = (): string => { export const isValidISOurl = (url?: string): boolean => { return Boolean( (url?.startsWith("http://") || url?.startsWith("https://")) && - url?.endsWith(".iso") + url?.endsWith(".iso"), ); }; @@ -91,7 +91,7 @@ export const humanFileSize = (blocks: number) => }); export const validateImage = ( - image: string + image: string, ): Effect.Effect => { const regex = /^(?:[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,7 +102,7 @@ export const validateImage = ( image, cause: "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", - }) + }), ); } return Effect.succeed(image); @@ -111,18 +111,18 @@ export const validateImage = ( export const extractTag = (name: string) => pipe( validateImage(name), - Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), ); export const failOnMissingImage = ( - image: Image | undefined + image: Image | undefined, ): Effect.Effect => image ? Effect.succeed(image) : Effect.fail(new NoSuchImageError({ cause: "No such image" })); export const du = ( - path: string + path: string, ): Effect.Effect => Effect.tryPromise({ try: async () => { @@ -154,7 +154,7 @@ export const emptyDiskImage = (path: string) => exists ? Effect.succeed(true) : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) - ) + ), ); export const downloadIso = (url: string, options: Options) => @@ -176,8 +176,8 @@ export const downloadIso = (url: string, options: Options) => if (driveSize > EMPTY_DISK_THRESHOLD_KB) { console.log( chalk.yellowBright( - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.` - ) + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, + ), ); return null; } @@ -195,8 +195,8 @@ export const downloadIso = (url: string, options: Options) => if (outputExists) { console.log( chalk.yellowBright( - `File ${outputPath} already exists, skipping download.` - ) + `File ${outputPath} already exists, skipping download.`, + ), ); return outputPath; } @@ -207,8 +207,8 @@ export const downloadIso = (url: string, options: Options) => chalk.blueBright( `Downloading ${ url.endsWith(".iso") ? "ISO" : "image" - } from ${url}...` - ) + } from ${url}...`, + ), ); const cmd = new Deno.Command("curl", { args: ["-L", "-o", outputPath, url], @@ -251,8 +251,8 @@ export const setupFirmwareFilesIfNeeded = () => if (!success) { console.error( chalk.redBright( - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." - ) + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", + ), ); Deno.exit(1); } @@ -265,7 +265,7 @@ export const setupFirmwareFilesIfNeeded = () => try: () => Deno.copyFile( `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, - edk2VarsAarch64 + edk2VarsAarch64, ), catch: (error) => new LogCommandError({ cause: error }), }); @@ -310,13 +310,13 @@ export const setupCoreOSArgs = (imagePath?: string | null) => const configOK = yield* pipe( fileExists("config.ign"), Effect.flatMap(() => Effect.succeed(true)), - Effect.catchAll(() => Effect.succeed(false)) + Effect.catchAll(() => Effect.succeed(false)), ); if (!configOK) { console.error( chalk.redBright( - "CoreOS image requires a config.ign file in the current directory." - ) + "CoreOS image requires a config.ign file in the current directory.", + ), ); Deno.exit(1); } @@ -356,7 +356,7 @@ export const setupGentooArgs = (imagePath?: string | null, seed?: string) => imagePath && imagePath.endsWith(".qcow2") && imagePath.startsWith( - `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-` + `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`, ) ) { return [ @@ -371,7 +371,7 @@ export const setupGentooArgs = (imagePath?: string | null, seed?: string) => export const setupAlpineArgs = ( imagePath?: string | null, - seed: string = "seed.iso" + seed: string = "seed.iso", ) => Effect.sync(() => { if ( @@ -392,7 +392,7 @@ export const setupAlpineArgs = ( export const setupDebianArgs = ( imagePath?: string | null, - seed: string = "seed.iso" + seed: string = "seed.iso", ) => Effect.sync(() => { if ( @@ -413,7 +413,7 @@ export const setupDebianArgs = ( export const setupUbuntuArgs = ( imagePath?: string | null, - seed: string = "seed.iso" + seed: string = "seed.iso", ) => Effect.sync(() => { if ( @@ -434,7 +434,7 @@ export const setupUbuntuArgs = ( export const setupAlmaLinuxArgs = ( imagePath?: string | null, - seed: string = "seed.iso" + seed: string = "seed.iso", ) => Effect.sync(() => { if ( @@ -455,7 +455,7 @@ export const setupAlmaLinuxArgs = ( export const setupRockyLinuxArgs = ( imagePath?: string | null, - seed: string = "seed.iso" + seed: string = "seed.iso", ) => Effect.sync(() => { if ( @@ -478,40 +478,39 @@ export const runQemu = (isoPath: string | null, options: Options) => Effect.gen(function* () { const macAddress = yield* generateRandomMacAddress(); - const qemu = - Deno.build.arch === "aarch64" - ? "qemu-system-aarch64" - : "qemu-system-x86_64"; + const qemu = Deno.build.arch === "aarch64" + ? "qemu-system-aarch64" + : "qemu-system-x86_64"; const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image); let fedoraArgs: string[] = yield* setupFedoraArgs( isoPath || options.image, - options.seed + options.seed, ); let gentooArgs: string[] = yield* setupGentooArgs( isoPath || options.image, - options.seed + options.seed, ); let alpineArgs: string[] = yield* setupAlpineArgs( isoPath || options.image, - options.seed + options.seed, ); let debianArgs: string[] = yield* setupDebianArgs( isoPath || options.image, - options.seed + options.seed, ); let ubuntuArgs: string[] = yield* setupUbuntuArgs( isoPath || options.image, - options.seed + options.seed, ); let almalinuxArgs: string[] = yield* setupAlmaLinuxArgs( isoPath || options.image, - options.seed + options.seed, ); let rockylinuxArgs: string[] = yield* setupRockyLinuxArgs( isoPath || options.image, - options.seed + options.seed, ); if (coreosArgs.length > 0 && !isoPath) { @@ -584,7 +583,7 @@ export const runQemu = (isoPath: string | null, options: Options) => options.image && [ "-drive", `file=${options.image},format=${options.diskFormat},if=virtio`, - ] + ], ), ]; @@ -599,9 +598,11 @@ export const runQemu = (isoPath: string | null, options: Options) => const logPath = `${LOGS_DIR}/${name}.log`; const fullCommand = options.bridge - ? `sudo ${qemu} ${qemuArgs + ? `sudo ${qemu} ${ + qemuArgs .slice(1) - .join(" ")} >> "${logPath}" 2>&1 & echo $!` + .join(" ") + } >> "${logPath}" 2>&1 & echo $!` : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; const { stdout } = yield* Effect.tryPromise({ @@ -627,8 +628,7 @@ export const runQemu = (isoPath: string | null, options: Options) => cpus: options.cpus, cpu: options.cpu, diskSize: options.size || "20G", - diskFormat: - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || options.diskFormat || "raw", portForward: options.portForward, @@ -647,7 +647,7 @@ export const runQemu = (isoPath: string | null, options: Options) => }); console.log( - `Virtual machine ${name} started in background (PID: ${qemuPid})` + `Virtual machine ${name} started in background (PID: ${qemuPid})`, ); console.log(`Logs will be written to: ${logPath}`); @@ -670,8 +670,7 @@ export const runQemu = (isoPath: string | null, options: Options) => cpus: options.cpus, cpu: options.cpu, diskSize: options.size || "20G", - diskFormat: - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || options.diskFormat || "raw", portForward: options.portForward, @@ -779,8 +778,8 @@ export const createDriveImageIfNeeded = ({ if (pathExists) { console.log( chalk.yellowBright( - `Drive image ${path} already exists, skipping creation.` - ) + `Drive image ${path} already exists, skipping creation.`, + ), ); return; } @@ -807,7 +806,7 @@ export const createDriveImageIfNeeded = ({ }); export const fileExists = ( - path: string + path: string, ): Effect.Effect => Effect.try({ try: () => Deno.statSync(path), @@ -815,7 +814,7 @@ export const fileExists = ( }); export const constructCoreOSImageURL = ( - image: string + image: string, ): Effect.Effect => { // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos- or coreos or coreos- const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; @@ -823,7 +822,7 @@ export const constructCoreOSImageURL = ( if (match) { const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; return Effect.succeed( - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), ); } @@ -831,7 +830,7 @@ export const constructCoreOSImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match CoreOS naming conventions.", - }) + }), ); }; @@ -860,7 +859,7 @@ export const extractXz = (path: string | null) => }); export const constructNixOSImageURL = ( - image: string + image: string, ): Effect.Effect => { // detect with regex if image matches NixOS pattern: nixos or nixos- const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/; @@ -868,7 +867,7 @@ export const constructNixOSImageURL = ( if (match) { const version = match[3] || NIXOS_DEFAULT_VERSION; return Effect.succeed( - NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version) + NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version), ); } @@ -876,13 +875,13 @@ export const constructNixOSImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match NixOS naming conventions.", - }) + }), ); }; export const constructFedoraImageURL = ( image: string, - cloud: boolean = false + cloud: boolean = false, ): Effect.Effect => { // detect with regex if image matches Fedora pattern: fedora const fedoraRegex = /^(fedora)$/; @@ -895,12 +894,12 @@ export const constructFedoraImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match Fedora naming conventions.", - }) + }), ); }; export const constructGentooImageURL = ( - image: string + image: string, ): Effect.Effect => { // detect with regex if image matches genroo pattern: gentoo-20251116T161545Z or gentoo const gentooRegex = /^(gentoo)(-(\d{8}T\d{6}Z))?$/; @@ -909,8 +908,8 @@ export const constructGentooImageURL = ( return Effect.succeed( GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll( "20251116T233105Z", - match[3] - ) + match[3], + ), ); } @@ -922,13 +921,13 @@ export const constructGentooImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match Gentoo naming conventions.", - }) + }), ); }; export const constructDebianImageURL = ( image: string, - cloud: boolean = false + cloud: boolean = false, ): Effect.Effect => { if (cloud && image === "debian") { return Effect.succeed(DEBIAN_CLOUD_IMG_URL); @@ -939,7 +938,7 @@ export const constructDebianImageURL = ( const match = image.match(debianRegex); if (match?.[3]) { return Effect.succeed( - DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]) + DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]), ); } @@ -951,19 +950,19 @@ export const constructDebianImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match Debian naming conventions.", - }) + }), ); }; export const constructAlpineImageURL = ( - image: string + image: string, ): Effect.Effect => { // detect with regex if image matches alpine pattern: alpine- or alpine const alpineRegex = /^(alpine)(-(\d+\.\d+(\.\d+)?))?$/; const match = image.match(alpineRegex); if (match?.[3]) { return Effect.succeed( - ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]) + ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]), ); } @@ -975,13 +974,13 @@ export const constructAlpineImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match Alpine naming conventions.", - }) + }), ); }; export const constructUbuntuImageURL = ( image: string, - cloud: boolean = false + cloud: boolean = false, ): Effect.Effect => { // detect with regex if image matches ubuntu pattern: ubuntu const ubuntuRegex = /^(ubuntu)$/; @@ -997,13 +996,13 @@ export const constructUbuntuImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match Ubuntu naming conventions.", - }) + }), ); }; export const constructAlmaLinuxImageURL = ( image: string, - cloud: boolean = false + cloud: boolean = false, ): Effect.Effect => { // detect with regex if image matches almalinux pattern: almalinux, almalinux const almaLinuxRegex = /^(almalinux|alma)$/; @@ -1019,13 +1018,13 @@ export const constructAlmaLinuxImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match AlmaLinux naming conventions.", - }) + }), ); }; export const constructRockyLinuxImageURL = ( image: string, - cloud: boolean = false + cloud: boolean = false, ): Effect.Effect => { // detect with regex if image matches rockylinux pattern: rocky. rockylinux const rockyLinuxRegex = /^(rockylinux|rocky)$/; @@ -1041,6 +1040,6 @@ export const constructRockyLinuxImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match RockyLinux naming conventions.", - }) + }), ); };