A simple, zero-configuration script to quickly boot FreeBSD ISO images using QEMU

feat: add image management functionality with OCI registry support

- Introduced new constants for image directory.
- Refactored database context to include migration handling.
- Created a new database schema for images with relevant fields.
- Implemented image CRUD operations in a new images module.
- Added migration scripts for creating and altering the images table.
- Developed ORAS commands for pushing and pulling images.
- Implemented subcommands for image management (list, tag, push, pull, remove).
- Enhanced utility functions for image validation and size formatting.
- Updated existing modules to integrate new image management features.

+3
deno.json
··· 9 9 "imports": { 10 10 "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.8", 11 11 "@cliffy/flags": "jsr:@cliffy/flags@^1.0.0-rc.8", 12 + "@cliffy/prompt": "jsr:@cliffy/prompt@^1.0.0-rc.8", 12 13 "@cliffy/table": "jsr:@cliffy/table@^1.0.0-rc.8", 13 14 "@db/sqlite": "jsr:@db/sqlite@^0.12.0", 14 15 "@es-toolkit/es-toolkit": "jsr:@es-toolkit/es-toolkit@^1.41.0", 15 16 "@paralleldrive/cuid2": "npm:@paralleldrive/cuid2@^3.0.4", 16 17 "@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", 17 18 "@std/assert": "jsr:@std/assert@1", 19 + "@std/io": "jsr:@std/io@^0.225.2", 20 + "@std/path": "jsr:@std/path@^1.1.2", 18 21 "@std/toml": "jsr:@std/toml@^1.0.11", 19 22 "@zod/zod": "jsr:@zod/zod@^4.1.12", 20 23 "chalk": "npm:chalk@^5.6.2",
+47 -1
deno.lock
··· 1 1 { 2 2 "version": "5", 3 3 "specifiers": { 4 + "jsr:@cliffy/ansi@1.0.0-rc.8": "1.0.0-rc.8", 4 5 "jsr:@cliffy/command@^1.0.0-rc.8": "1.0.0-rc.8", 5 6 "jsr:@cliffy/flags@1.0.0-rc.8": "1.0.0-rc.8", 6 7 "jsr:@cliffy/flags@^1.0.0-rc.8": "1.0.0-rc.8", 7 8 "jsr:@cliffy/internal@1.0.0-rc.8": "1.0.0-rc.8", 9 + "jsr:@cliffy/keycode@1.0.0-rc.8": "1.0.0-rc.8", 10 + "jsr:@cliffy/prompt@^1.0.0-rc.8": "1.0.0-rc.8", 8 11 "jsr:@cliffy/table@1.0.0-rc.8": "1.0.0-rc.8", 9 12 "jsr:@cliffy/table@^1.0.0-rc.8": "1.0.0-rc.8", 10 13 "jsr:@db/sqlite@0.12": "0.12.0", ··· 13 16 "jsr:@soapbox/kysely-deno-sqlite@^2.2.0": "2.2.0", 14 17 "jsr:@std/assert@0.217": "0.217.0", 15 18 "jsr:@std/assert@1": "1.0.15", 19 + "jsr:@std/assert@~1.0.6": "1.0.15", 20 + "jsr:@std/bytes@^1.0.5": "1.0.6", 16 21 "jsr:@std/collections@^1.1.3": "1.1.3", 17 22 "jsr:@std/encoding@1": "1.0.10", 23 + "jsr:@std/encoding@~1.0.5": "1.0.10", 18 24 "jsr:@std/fmt@1": "1.0.8", 19 25 "jsr:@std/fmt@~1.0.2": "1.0.8", 20 26 "jsr:@std/fs@1": "1.0.19", 21 27 "jsr:@std/internal@^1.0.10": "1.0.12", 22 28 "jsr:@std/internal@^1.0.12": "1.0.12", 23 29 "jsr:@std/internal@^1.0.9": "1.0.12", 30 + "jsr:@std/io@~0.225.2": "0.225.2", 24 31 "jsr:@std/path@0.217": "0.217.0", 25 32 "jsr:@std/path@1": "1.1.2", 26 33 "jsr:@std/path@^1.1.1": "1.1.2", 34 + "jsr:@std/path@^1.1.2": "1.1.2", 35 + "jsr:@std/path@~1.0.6": "1.0.9", 27 36 "jsr:@std/text@~1.0.7": "1.0.16", 28 37 "jsr:@std/toml@*": "1.0.11", 29 38 "jsr:@std/toml@^1.0.11": "1.0.11", ··· 39 48 "npm:moniker@~0.1.2": "0.1.2" 40 49 }, 41 50 "jsr": { 51 + "@cliffy/ansi@1.0.0-rc.8": { 52 + "integrity": "ba37f10ce55bbfbdd8ddd987f91f029b17bce88385b98ba3058870f3b007b80c", 53 + "dependencies": [ 54 + "jsr:@cliffy/internal", 55 + "jsr:@std/encoding@~1.0.5" 56 + ] 57 + }, 42 58 "@cliffy/command@1.0.0-rc.8": { 43 59 "integrity": "758147790797c74a707e5294cc7285df665422a13d2a483437092ffce40b5557", 44 60 "dependencies": [ ··· 58 74 "@cliffy/internal@1.0.0-rc.8": { 59 75 "integrity": "34cdf2fad9b084b5aed493b138d573f52d4e988767215f7460daf0b918ff43d8" 60 76 }, 77 + "@cliffy/keycode@1.0.0-rc.8": { 78 + "integrity": "76dbf85a67ec0aea2e29ca049b8507b6b3f62a2a971bd744d8d3fc447c177cd9" 79 + }, 80 + "@cliffy/prompt@1.0.0-rc.8": { 81 + "integrity": "eba403ea1d47b9971bf2210fa35f4dc7ebd2aba87beec9540ae47552806e2f25", 82 + "dependencies": [ 83 + "jsr:@cliffy/ansi", 84 + "jsr:@cliffy/internal", 85 + "jsr:@cliffy/keycode", 86 + "jsr:@std/assert@~1.0.6", 87 + "jsr:@std/fmt@~1.0.2", 88 + "jsr:@std/path@~1.0.6", 89 + "jsr:@std/text" 90 + ] 91 + }, 61 92 "@cliffy/table@1.0.0-rc.8": { 62 93 "integrity": "8bbcdc2ba5e0061b4b13810a24e6f5c6ab19c09f0cce9eb691ccd76c7c6c9db5", 63 94 "dependencies": [ ··· 74 105 "@denosaurs/plug@1.1.0": { 75 106 "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044", 76 107 "dependencies": [ 77 - "jsr:@std/encoding", 108 + "jsr:@std/encoding@1", 78 109 "jsr:@std/fmt@1", 79 110 "jsr:@std/fs", 80 111 "jsr:@std/path@1" ··· 97 128 "dependencies": [ 98 129 "jsr:@std/internal@^1.0.12" 99 130 ] 131 + }, 132 + "@std/bytes@1.0.6": { 133 + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" 100 134 }, 101 135 "@std/collections@1.1.3": { 102 136 "integrity": "bf8b0818886df6a32b64c7d3b037a425111f28278d69fd0995aeb62777c986b0" ··· 117 151 "@std/internal@1.0.12": { 118 152 "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" 119 153 }, 154 + "@std/io@0.225.2": { 155 + "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7", 156 + "dependencies": [ 157 + "jsr:@std/bytes" 158 + ] 159 + }, 120 160 "@std/path@0.217.0": { 121 161 "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", 122 162 "dependencies": [ 123 163 "jsr:@std/assert@0.217" 124 164 ] 165 + }, 166 + "@std/path@1.0.9": { 167 + "integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e" 125 168 }, 126 169 "@std/path@1.1.2": { 127 170 "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038", ··· 209 252 "dependencies": [ 210 253 "jsr:@cliffy/command@^1.0.0-rc.8", 211 254 "jsr:@cliffy/flags@^1.0.0-rc.8", 255 + "jsr:@cliffy/prompt@^1.0.0-rc.8", 212 256 "jsr:@cliffy/table@^1.0.0-rc.8", 213 257 "jsr:@db/sqlite@0.12", 214 258 "jsr:@es-toolkit/es-toolkit@^1.41.0", 215 259 "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", 216 260 "jsr:@std/assert@1", 261 + "jsr:@std/io@~0.225.2", 262 + "jsr:@std/path@^1.1.2", 217 263 "jsr:@std/toml@^1.0.11", 218 264 "jsr:@zod/zod@^4.1.12", 219 265 "npm:@paralleldrive/cuid2@^3.0.4",
+128
main.ts
··· 1 1 #!/usr/bin/env -S deno run --allow-run --allow-read --allow-env 2 2 3 3 import { Command } from "@cliffy/command"; 4 + import { Secret } from "@cliffy/prompt"; 5 + import { readAll } from "@std/io"; 4 6 import chalk from "chalk"; 5 7 import { Effect, pipe } from "effect"; 6 8 import pkg from "./deno.json" with { type: "json" }; 7 9 import { initVmFile, mergeConfig, parseVmFile } from "./src/config.ts"; 8 10 import { CONFIG_FILE_NAME } from "./src/constants.ts"; 11 + import { getImage } from "./src/images.ts"; 9 12 import { createBridgeNetworkIfNeeded } from "./src/network.ts"; 13 + import { getImageArchivePath } from "./src/oras.ts"; 14 + import images from "./src/subcommands/images.ts"; 10 15 import inspect from "./src/subcommands/inspect.ts"; 16 + import login from "./src/subcommands/login.ts"; 17 + import logout from "./src/subcommands/logout.ts"; 11 18 import logs from "./src/subcommands/logs.ts"; 12 19 import ps from "./src/subcommands/ps.ts"; 20 + import pull from "./src/subcommands/pull.ts"; 21 + import push from "./src/subcommands/push.ts"; 13 22 import restart from "./src/subcommands/restart.ts"; 14 23 import rm from "./src/subcommands/rm.ts"; 24 + import rmi from "./src/subcommands/rmi.ts"; 25 + import run from "./src/subcommands/run.ts"; 15 26 import start from "./src/subcommands/start.ts"; 16 27 import stop from "./src/subcommands/stop.ts"; 28 + import tag from "./src/subcommands/tag.ts"; 17 29 import { 18 30 createDriveImageIfNeeded, 19 31 downloadIso, ··· 70 82 .option( 71 83 "-p, --port-forward <mappings:string>", 72 84 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 85 + ) 86 + .option( 87 + "--install", 88 + "Persist changes to the VM disk image", 73 89 ) 74 90 .example( 75 91 "Create a default VM configuration file", ··· 92 108 "freebsd-up https://download.freebsd.org/ftp/releases/ISO-IMAGES/14.3/FreeBSD-14.3-RELEASE-amd64-disc1.iso", 93 109 ) 94 110 .example( 111 + "From OCI Registry", 112 + "freebsd-up ghcr.io/tsirysndr/freebsd:15.0-BETA4", 113 + ) 114 + .example( 95 115 "List running VMs", 96 116 "freebsd-up ps", 97 117 ) ··· 113 133 ) 114 134 .action(async (options: Options, input?: string) => { 115 135 const program = Effect.gen(function* () { 136 + if (input) { 137 + const [image, archivePath] = yield* Effect.all([ 138 + getImage(input), 139 + pipe( 140 + getImageArchivePath(input), 141 + Effect.catchAll(() => Effect.succeed(null)), 142 + ), 143 + ]); 144 + 145 + if (image || archivePath) { 146 + yield* Effect.tryPromise({ 147 + try: () => run(input), 148 + catch: () => {}, 149 + }); 150 + return; 151 + } 152 + } 153 + 116 154 const resolvedInput = handleInput(input); 117 155 let isoPath: string | null = resolvedInput; 118 156 ··· 241 279 `You can edit this file to customize your VM settings and then start the VM with:`, 242 280 ); 243 281 console.log(` ${chalk.greenBright(`freebsd-up`)}`); 282 + }) 283 + .command( 284 + "pull", 285 + "Pull VM image from an OCI-compliant registry, e.g., ghcr.io, docker hub", 286 + ) 287 + .arguments("<image:string>") 288 + .action(async (_options: unknown, image: string) => { 289 + await pull(image); 290 + }) 291 + .command( 292 + "push", 293 + "Push VM image to an OCI-compliant registry, e.g., ghcr.io, docker hub", 294 + ) 295 + .arguments("<image:string>") 296 + .action(async (_options: unknown, image: string) => { 297 + await push(image); 298 + }) 299 + .command( 300 + "tag", 301 + "Create a tag 'image' that refers to the VM image of 'vm-name'", 302 + ) 303 + .arguments("<vm-name:string> <image:string>") 304 + .action(async (_options: unknown, vmName: string, image: string) => { 305 + await tag(vmName, image); 306 + }) 307 + .command( 308 + "login", 309 + "Authenticate to an OCI-compliant registry, e.g., ghcr.io, docker.io (docker hub), etc.", 310 + ) 311 + .option("-u, --username <username:string>", "Registry username") 312 + .arguments("<registry:string>") 313 + .action(async (options: unknown, registry: string) => { 314 + const username = (options as { username: string }).username; 315 + 316 + let password: string | undefined; 317 + const stdinIsTTY = Deno.stdin.isTerminal(); 318 + 319 + if (!stdinIsTTY) { 320 + const buffer = await readAll(Deno.stdin); 321 + password = new TextDecoder().decode(buffer).trim(); 322 + } else { 323 + password = await Secret.prompt("Registry Password: "); 324 + } 325 + 326 + console.log( 327 + `Authenticating to registry ${chalk.greenBright(registry)} as ${ 328 + chalk.greenBright(username) 329 + }...`, 330 + ); 331 + await login(username, password, registry); 332 + }) 333 + .command("logout", "Logout from an OCI-compliant registry") 334 + .arguments("<registry:string>") 335 + .action(async (_options: unknown, registry: string) => { 336 + await logout(registry); 337 + }) 338 + .command("images", "List all local VM images") 339 + .action(async () => { 340 + await images(); 341 + }) 342 + .command("rmi", "Remove a local VM image") 343 + .arguments("<image:string>") 344 + .action(async (_options: unknown, image: string) => { 345 + await rmi(image); 346 + }) 347 + .command("run", "Create and run a VM from an image") 348 + .arguments("<image:string>") 349 + .option("-c, --cpu <type:string>", "Type of CPU to emulate", { 350 + default: "host", 351 + }) 352 + .option("-C, --cpus <number:number>", "Number of CPU cores", { 353 + default: 2, 354 + }) 355 + .option("-m, --memory <size:string>", "Amount of memory for the VM", { 356 + default: "2G", 357 + }) 358 + .option( 359 + "-b, --bridge <name:string>", 360 + "Name of the network bridge to use for networking (e.g., br0)", 361 + ) 362 + .option( 363 + "-d, --detach", 364 + "Run VM in the background and print VM name", 365 + ) 366 + .option( 367 + "-p, --port-forward <mappings:string>", 368 + "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 369 + ) 370 + .action(async (_options: unknown, image: string) => { 371 + await run(image); 244 372 }) 245 373 .parse(Deno.args); 246 374 }
+1
src/constants.ts
··· 3 3 export const LOGS_DIR: string = `${CONFIG_DIR}/logs`; 4 4 export const EMPTY_DISK_THRESHOLD_KB: number = 100; 5 5 export const CONFIG_FILE_NAME: string = "vmconfig.toml"; 6 + export const IMAGE_DIR: string = `${CONFIG_DIR}/images`;
+2 -1
src/context.ts
··· 1 1 import { DB_PATH } from "./constants.ts"; 2 - import { createDb, type Database, migrateToLatest } from "./db.ts"; 2 + import { createDb, type Database } from "./db.ts"; 3 + import { migrateToLatest } from "./migrations.ts"; 3 4 4 5 export const db: Database = createDb(DB_PATH); 5 6 await migrateToLatest(db);
+11 -72
src/db.ts
··· 1 1 import { Database as Sqlite } from "@db/sqlite"; 2 2 import { DenoSqlite3Dialect } from "@soapbox/kysely-deno-sqlite"; 3 - import { 4 - Kysely, 5 - type Migration, 6 - type MigrationProvider, 7 - Migrator, 8 - sql, 9 - } from "kysely"; 3 + import { Kysely } from "kysely"; 10 4 import { CONFIG_DIR } from "./constants.ts"; 11 5 import type { STATUS } from "./types.ts"; 12 6 ··· 21 15 22 16 export type DatabaseSchema = { 23 17 virtual_machines: VirtualMachine; 18 + images: Image; 24 19 }; 25 20 26 21 export type VirtualMachine = { ··· 43 38 updatedAt?: string; 44 39 }; 45 40 46 - const migrations: Record<string, Migration> = {}; 47 - 48 - const migrationProvider: MigrationProvider = { 49 - // deno-lint-ignore require-await 50 - async getMigrations() { 51 - return migrations; 52 - }, 53 - }; 54 - 55 - migrations["001"] = { 56 - async up(db: Kysely<unknown>): Promise<void> { 57 - await db.schema 58 - .createTable("virtual_machines") 59 - .addColumn("id", "varchar", (col) => col.primaryKey()) 60 - .addColumn("name", "varchar", (col) => col.notNull().unique()) 61 - .addColumn("bridge", "varchar") 62 - .addColumn("macAddress", "varchar", (col) => col.notNull().unique()) 63 - .addColumn("memory", "varchar", (col) => col.notNull()) 64 - .addColumn("cpus", "integer", (col) => col.notNull()) 65 - .addColumn("cpu", "varchar", (col) => col.notNull()) 66 - .addColumn("diskSize", "varchar", (col) => col.notNull()) 67 - .addColumn("drivePath", "varchar") 68 - .addColumn("version", "varchar", (col) => col.notNull()) 69 - .addColumn("diskFormat", "varchar") 70 - .addColumn("isoPath", "varchar") 71 - .addColumn("status", "varchar", (col) => col.notNull()) 72 - .addColumn("pid", "integer") 73 - .addColumn( 74 - "createdAt", 75 - "varchar", 76 - (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 77 - ) 78 - .addColumn( 79 - "updatedAt", 80 - "varchar", 81 - (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 82 - ) 83 - .execute(); 84 - }, 85 - 86 - async down(db: Kysely<unknown>): Promise<void> { 87 - await db.schema.dropTable("virtual_machines").execute(); 88 - }, 89 - }; 90 - 91 - migrations["002"] = { 92 - async up(db: Kysely<unknown>): Promise<void> { 93 - await db.schema 94 - .alterTable("virtual_machines") 95 - .addColumn("portForward", "varchar") 96 - .execute(); 97 - }, 98 - 99 - async down(db: Kysely<unknown>): Promise<void> { 100 - await db.schema 101 - .alterTable("virtual_machines") 102 - .dropColumn("portForward") 103 - .execute(); 104 - }, 105 - }; 106 - 107 - export const migrateToLatest = async (db: Database): Promise<void> => { 108 - const migrator = new Migrator({ db, provider: migrationProvider }); 109 - const { error } = await migrator.migrateToLatest(); 110 - if (error) throw error; 41 + export type Image = { 42 + id: string; 43 + repository: string; 44 + tag: string; 45 + size: number; 46 + path: string; 47 + format: string; 48 + digest?: string; 49 + createdAt?: string; 111 50 }; 112 51 113 52 export type Database = Kysely<DatabaseSchema>;
+87
src/images.ts
··· 1 + import { Data, Effect } from "effect"; 2 + import type { DeleteResult, InsertResult } from "kysely"; 3 + import { ctx } from "./context.ts"; 4 + import type { Image } from "./db.ts"; 5 + 6 + export class DbError extends Data.TaggedError("DatabaseError")<{ 7 + message?: string; 8 + }> {} 9 + 10 + export const listImages = (): Effect.Effect<Image[], DbError, never> => 11 + Effect.tryPromise({ 12 + try: () => ctx.db.selectFrom("images").selectAll().execute(), 13 + catch: (error) => 14 + new DbError({ 15 + message: error instanceof Error ? error.message : String(error), 16 + }), 17 + }); 18 + 19 + export const getImage = ( 20 + id: string, 21 + ): Effect.Effect<Image | undefined, DbError, never> => 22 + Effect.tryPromise({ 23 + try: () => 24 + ctx.db 25 + .selectFrom("images") 26 + .selectAll() 27 + .where((eb) => 28 + eb.or([ 29 + eb.and([ 30 + eb("repository", "=", id.split(":")[0]), 31 + eb("tag", "=", id.split(":")[1] || "latest"), 32 + ]), 33 + eb("id", "=", id), 34 + eb("digest", "=", id), 35 + ]) 36 + ) 37 + .executeTakeFirst(), 38 + catch: (error) => 39 + new DbError({ 40 + message: error instanceof Error ? error.message : String(error), 41 + }), 42 + }); 43 + 44 + export const saveImage = ( 45 + image: Image, 46 + ): Effect.Effect<InsertResult[], DbError, never> => 47 + Effect.tryPromise({ 48 + try: () => 49 + ctx.db.insertInto("images") 50 + .values(image) 51 + .onConflict((oc) => 52 + oc 53 + .column("repository") 54 + .column("tag") 55 + .doUpdateSet({ 56 + size: image.size, 57 + path: image.path, 58 + format: image.format, 59 + digest: image.digest, 60 + }) 61 + ) 62 + .execute(), 63 + catch: (error) => 64 + new DbError({ 65 + message: error instanceof Error ? error.message : String(error), 66 + }), 67 + }); 68 + 69 + export const deleteImage = ( 70 + id: string, 71 + ): Effect.Effect<DeleteResult[], DbError, never> => 72 + Effect.tryPromise({ 73 + try: () => 74 + ctx.db.deleteFrom("images").where((eb) => 75 + eb.or([ 76 + eb.and([ 77 + eb("repository", "=", id.split(":")[0]), 78 + eb("tag", "=", id.split(":")[1] || "latest"), 79 + ]), 80 + eb("id", "=", id), 81 + ]) 82 + ).execute(), 83 + catch: (error) => 84 + new DbError({ 85 + message: error instanceof Error ? error.message : String(error), 86 + }), 87 + });
+225
src/migrations.ts
··· 1 + import { 2 + type Kysely, 3 + type Migration, 4 + type MigrationProvider, 5 + Migrator, 6 + sql, 7 + } from "kysely"; 8 + import type { Database } from "./db.ts"; 9 + 10 + const migrations: Record<string, Migration> = {}; 11 + 12 + const migrationProvider: MigrationProvider = { 13 + // deno-lint-ignore require-await 14 + async getMigrations() { 15 + return migrations; 16 + }, 17 + }; 18 + 19 + migrations["001"] = { 20 + async up(db: Kysely<unknown>): Promise<void> { 21 + await db.schema 22 + .createTable("virtual_machines") 23 + .addColumn("id", "varchar", (col) => col.primaryKey()) 24 + .addColumn("name", "varchar", (col) => col.notNull().unique()) 25 + .addColumn("bridge", "varchar") 26 + .addColumn("macAddress", "varchar", (col) => col.notNull().unique()) 27 + .addColumn("memory", "varchar", (col) => col.notNull()) 28 + .addColumn("cpus", "integer", (col) => col.notNull()) 29 + .addColumn("cpu", "varchar", (col) => col.notNull()) 30 + .addColumn("diskSize", "varchar", (col) => col.notNull()) 31 + .addColumn("drivePath", "varchar") 32 + .addColumn("version", "varchar", (col) => col.notNull()) 33 + .addColumn("diskFormat", "varchar") 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 + }, 49 + 50 + async down(db: Kysely<unknown>): Promise<void> { 51 + await db.schema.dropTable("virtual_machines").execute(); 52 + }, 53 + }; 54 + 55 + migrations["002"] = { 56 + async up(db: Kysely<unknown>): Promise<void> { 57 + await db.schema 58 + .alterTable("virtual_machines") 59 + .addColumn("portForward", "varchar") 60 + .execute(); 61 + }, 62 + 63 + async down(db: Kysely<unknown>): Promise<void> { 64 + await db.schema 65 + .alterTable("virtual_machines") 66 + .dropColumn("portForward") 67 + .execute(); 68 + }, 69 + }; 70 + 71 + migrations["003"] = { 72 + async up(db: Kysely<unknown>): Promise<void> { 73 + await db.schema 74 + .createTable("images") 75 + .addColumn("id", "varchar", (col) => col.primaryKey()) 76 + .addColumn("repository", "varchar", (col) => col.notNull()) 77 + .addColumn("tag", "varchar", (col) => col.notNull()) 78 + .addColumn("size", "integer", (col) => col.notNull()) 79 + .addColumn("path", "varchar", (col) => col.notNull()) 80 + .addColumn("createdAt", "varchar", (col) => col.notNull()) 81 + .execute(); 82 + }, 83 + 84 + async down(db: Kysely<unknown>): Promise<void> { 85 + await db.schema.dropTable("images").execute(); 86 + }, 87 + }; 88 + 89 + migrations["004"] = { 90 + async up(db: Kysely<unknown>): Promise<void> { 91 + await db.schema 92 + .alterTable("images") 93 + .addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2")) 94 + .execute(); 95 + }, 96 + 97 + async down(db: Kysely<unknown>): Promise<void> { 98 + await db.schema 99 + .alterTable("images") 100 + .dropColumn("format") 101 + .execute(); 102 + }, 103 + }; 104 + 105 + migrations["005"] = { 106 + async up(db: Kysely<unknown>): Promise<void> { 107 + await db.schema 108 + .createTable("images_new") 109 + .addColumn("id", "varchar", (col) => col.primaryKey()) 110 + .addColumn("repository", "varchar", (col) => col.notNull()) 111 + .addColumn("tag", "varchar", (col) => col.notNull()) 112 + .addColumn("size", "integer", (col) => col.notNull()) 113 + .addColumn("path", "varchar", (col) => col.notNull()) 114 + .addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2")) 115 + .addColumn("createdAt", "varchar", (col) => col.notNull()) 116 + .addUniqueConstraint("images_repository_tag_unique", [ 117 + "repository", 118 + "tag", 119 + ]) 120 + .execute(); 121 + 122 + await sql` 123 + INSERT INTO images_new (id, repository, tag, size, path, format, createdAt) 124 + SELECT id, repository, tag, size, path, format, createdAt FROM images 125 + `.execute(db); 126 + 127 + await db.schema.dropTable("images").execute(); 128 + await sql`ALTER TABLE images_new RENAME TO images`.execute(db); 129 + }, 130 + 131 + async down(db: Kysely<unknown>): Promise<void> { 132 + await db.schema 133 + .createTable("images_old") 134 + .addColumn("id", "varchar", (col) => col.primaryKey()) 135 + .addColumn("repository", "varchar", (col) => col.notNull()) 136 + .addColumn("tag", "varchar", (col) => col.notNull()) 137 + .addColumn("size", "integer", (col) => col.notNull()) 138 + .addColumn("path", "varchar", (col) => col.notNull()) 139 + .addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2")) 140 + .addColumn("createdAt", "varchar", (col) => col.notNull()) 141 + .execute(); 142 + 143 + await sql` 144 + INSERT INTO images_old (id, repository, tag, size, path, format, createdAt) 145 + SELECT id, repository, tag, size, path, format, createdAt FROM images 146 + `.execute(db); 147 + 148 + await db.schema.dropTable("images").execute(); 149 + await sql`ALTER TABLE images_old RENAME TO images`.execute(db); 150 + }, 151 + }; 152 + 153 + migrations["006"] = { 154 + async up(db: Kysely<unknown>): Promise<void> { 155 + await db.schema 156 + .createTable("images_new") 157 + .addColumn("id", "varchar", (col) => col.primaryKey()) 158 + .addColumn("repository", "varchar", (col) => col.notNull()) 159 + .addColumn("tag", "varchar", (col) => col.notNull()) 160 + .addColumn("size", "integer", (col) => col.notNull()) 161 + .addColumn("path", "varchar", (col) => col.notNull()) 162 + .addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2")) 163 + .addColumn( 164 + "createdAt", 165 + "varchar", 166 + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 167 + ) 168 + .addUniqueConstraint("images_repository_tag_unique", [ 169 + "repository", 170 + "tag", 171 + ]) 172 + .execute(); 173 + 174 + await sql` 175 + INSERT INTO images_new (id, repository, tag, size, path, format, createdAt) 176 + SELECT id, repository, tag, size, path, format, createdAt FROM images 177 + `.execute(db); 178 + 179 + await db.schema.dropTable("images").execute(); 180 + await sql`ALTER TABLE images_new RENAME TO images`.execute(db); 181 + }, 182 + 183 + async down(db: Kysely<unknown>): Promise<void> { 184 + await db.schema 185 + .createTable("images_old") 186 + .addColumn("id", "varchar", (col) => col.primaryKey()) 187 + .addColumn("repository", "varchar", (col) => col.notNull()) 188 + .addColumn("tag", "varchar", (col) => col.notNull()) 189 + .addColumn("size", "integer", (col) => col.notNull()) 190 + .addColumn("path", "varchar", (col) => col.notNull()) 191 + .addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2")) 192 + .addColumn("createdAt", "varchar", (col) => col.notNull()) 193 + .addUniqueConstraint("images_repository_tag_unique", [ 194 + "repository", 195 + "tag", 196 + ]) 197 + .execute(); 198 + 199 + await sql` 200 + INSERT INTO images_old (id, repository, tag, size, path, format, createdAt) 201 + SELECT id, repository, tag, size, path, format, createdAt FROM images 202 + `.execute(db); 203 + }, 204 + }; 205 + 206 + migrations["007"] = { 207 + async up(db: Kysely<unknown>): Promise<void> { 208 + await db.schema 209 + .alterTable("images") 210 + .addColumn("digest", "varchar") 211 + .execute(); 212 + }, 213 + async down(db: Kysely<unknown>): Promise<void> { 214 + await db.schema 215 + .alterTable("images") 216 + .dropColumn("digest") 217 + .execute(); 218 + }, 219 + }; 220 + 221 + export const migrateToLatest = async (db: Database): Promise<void> => { 222 + const migrator = new Migrator({ db, provider: migrationProvider }); 223 + const { error } = await migrator.migrateToLatest(); 224 + if (error) throw error; 225 + };
+1
src/mod.ts
··· 3 3 export * from "./context.ts"; 4 4 export * from "./db.ts"; 5 5 export * from "./network.ts"; 6 + export * from "./oras.ts"; 6 7 export * from "./state.ts"; 7 8 export * from "./types.ts"; 8 9 export * from "./utils.ts";
+406
src/oras.ts
··· 1 + import { createId } from "@paralleldrive/cuid2"; 2 + import { basename, dirname } from "@std/path"; 3 + import chalk from "chalk"; 4 + import { Data, Effect, pipe } from "effect"; 5 + import { IMAGE_DIR } from "./constants.ts"; 6 + import { getImage, saveImage } from "./images.ts"; 7 + import { CONFIG_DIR, failOnMissingImage } from "./mod.ts"; 8 + import { du, getCurrentArch } from "./utils.ts"; 9 + 10 + const DEFAULT_ORAS_VERSION = "1.3.0"; 11 + 12 + export class PushImageError extends Data.TaggedError("PushImageError")<{ 13 + cause?: unknown; 14 + }> {} 15 + 16 + export class PullImageError extends Data.TaggedError("PullImageError")<{ 17 + cause?: unknown; 18 + }> {} 19 + 20 + export class CreateDirectoryError 21 + extends Data.TaggedError("CreateDirectoryError")<{ 22 + cause?: unknown; 23 + }> {} 24 + 25 + export class ImageAlreadyPulledError 26 + extends Data.TaggedError("ImageAlreadyPulledError")<{ 27 + name: string; 28 + }> {} 29 + 30 + export async function setupOrasBinary(): Promise<void> { 31 + Deno.env.set( 32 + "PATH", 33 + `${CONFIG_DIR}/bin:${Deno.env.get("PATH")}`, 34 + ); 35 + 36 + const oras = new Deno.Command("which", { 37 + args: ["oras"], 38 + stdout: "null", 39 + stderr: "null", 40 + }) 41 + .spawn(); 42 + 43 + const orasStatus = await oras.status; 44 + if (orasStatus.success) { 45 + return; 46 + } 47 + 48 + const version = Deno.env.get("ORAS_VERSION") || DEFAULT_ORAS_VERSION; 49 + 50 + console.log(`Downloading ORAS version ${version}...`); 51 + 52 + const os = Deno.build.os; 53 + let arch = "amd64"; 54 + 55 + if (Deno.build.arch === "aarch64") { 56 + arch = "arm64"; 57 + } 58 + 59 + if (os !== "linux" && os !== "darwin") { 60 + console.error("Unsupported OS. Please download ORAS manually."); 61 + Deno.exit(1); 62 + } 63 + 64 + // https://github.com/oras-project/oras/releases/download/v1.3.0/oras_1.3.0_darwin_amd64.tar.gz 65 + const downloadUrl = 66 + `https://github.com/oras-project/oras/releases/download/v${version}/oras_${version}_${os}_${arch}.tar.gz`; 67 + 68 + console.log(`Downloading ORAS from ${chalk.greenBright(downloadUrl)}`); 69 + 70 + const downloadProcess = new Deno.Command("curl", { 71 + args: ["-L", downloadUrl, "-o", `oras_${version}_${os}_${arch}.tar.gz`], 72 + stdout: "inherit", 73 + stderr: "inherit", 74 + cwd: "/tmp", 75 + }) 76 + .spawn(); 77 + 78 + const status = await downloadProcess.status; 79 + if (!status.success) { 80 + console.error("Failed to download ORAS binary."); 81 + Deno.exit(1); 82 + } 83 + 84 + console.log("Extracting ORAS binary..."); 85 + 86 + const extractProcess = new Deno.Command("tar", { 87 + args: [ 88 + "-xzf", 89 + `oras_${version}_${os}_${arch}.tar.gz`, 90 + "-C", 91 + "./", 92 + ], 93 + stdout: "inherit", 94 + stderr: "inherit", 95 + cwd: "/tmp", 96 + }) 97 + .spawn(); 98 + 99 + const extractStatus = await extractProcess.status; 100 + if (!extractStatus.success) { 101 + console.error("Failed to extract ORAS binary."); 102 + Deno.exit(1); 103 + } 104 + 105 + await Deno.remove(`/tmp/oras_${version}_${os}_${arch}.tar.gz`); 106 + 107 + await Deno.mkdir(`${CONFIG_DIR}/bin`, { recursive: true }); 108 + 109 + await Deno.rename( 110 + `/tmp/oras`, 111 + `${CONFIG_DIR}/bin/oras`, 112 + ); 113 + await Deno.chmod(`${CONFIG_DIR}/bin/oras`, 0o755); 114 + 115 + console.log( 116 + `ORAS binary installed at ${ 117 + chalk.greenBright( 118 + `${CONFIG_DIR}/bin/oras`, 119 + ) 120 + }`, 121 + ); 122 + } 123 + 124 + const archiveImage = (img: { path: string }) => 125 + Effect.tryPromise({ 126 + try: async () => { 127 + console.log("Archiving image for push..."); 128 + const tarProcess = new Deno.Command("tar", { 129 + args: [ 130 + "-cSzf", 131 + `${img.path}.tar.gz`, 132 + "-C", 133 + dirname(img.path), 134 + basename(img.path), 135 + ], 136 + stdout: "inherit", 137 + stderr: "inherit", 138 + }).spawn(); 139 + 140 + const tarStatus = await tarProcess.status; 141 + if (!tarStatus.success) { 142 + throw new Error(`Failed to create tar archive for image`); 143 + } 144 + return `${img.path}.tar.gz`; 145 + }, 146 + catch: (error: unknown) => 147 + new PushImageError({ 148 + cause: error instanceof Error ? error.message : String(error), 149 + }), 150 + }); 151 + 152 + const pushToRegistry = ( 153 + img: { repository: string; tag: string; path: string }, 154 + ) => 155 + Effect.tryPromise({ 156 + try: async () => { 157 + console.log(`Pushing image ${img.repository}...`); 158 + const process = new Deno.Command("oras", { 159 + args: [ 160 + "push", 161 + `${img.repository}:${img.tag}-${getCurrentArch()}`, 162 + "--artifact-type", 163 + "application/vnd.oci.image.layer.v1.tar", 164 + "--annotation", 165 + `org.opencontainers.image.architecture=${getCurrentArch()}`, 166 + "--annotation", 167 + "org.opencontainers.image.os=freebsd", 168 + "--annotation", 169 + "org.opencontainers.image.description=QEMU raw disk image", 170 + basename(img.path), 171 + ], 172 + stdout: "inherit", 173 + stderr: "inherit", 174 + cwd: dirname(img.path), 175 + }).spawn(); 176 + 177 + const { code } = await process.status; 178 + if (code !== 0) { 179 + throw new Error(`ORAS push failed with exit code ${code}`); 180 + } 181 + return img.path; 182 + }, 183 + catch: (error: unknown) => 184 + new PushImageError({ 185 + cause: error instanceof Error ? error.message : String(error), 186 + }), 187 + }); 188 + 189 + const cleanup = (path: string) => 190 + Effect.tryPromise({ 191 + try: () => Deno.remove(path), 192 + catch: (error: unknown) => 193 + new PushImageError({ 194 + cause: error instanceof Error ? error.message : String(error), 195 + }), 196 + }); 197 + 198 + const createImageDirIfMissing = Effect.promise(() => 199 + Deno.mkdir(IMAGE_DIR, { recursive: true }) 200 + ); 201 + 202 + const checkIfImageAlreadyPulled = (image: string) => 203 + pipe( 204 + getImageDigest(image), 205 + Effect.flatMap(getImage), 206 + Effect.flatMap((img) => { 207 + if (img) { 208 + return Effect.fail( 209 + new ImageAlreadyPulledError({ name: image }), 210 + ); 211 + } 212 + return Effect.succeed(void 0); 213 + }), 214 + ); 215 + 216 + export const pullFromRegistry = (image: string) => 217 + pipe( 218 + Effect.tryPromise({ 219 + try: async () => { 220 + console.log(`Pulling image ${image}`); 221 + const process = new Deno.Command("oras", { 222 + args: [ 223 + "pull", 224 + `${image}-${getCurrentArch()}`, 225 + ], 226 + stdin: "inherit", 227 + stdout: "inherit", 228 + stderr: "inherit", 229 + cwd: IMAGE_DIR, 230 + }).spawn(); 231 + 232 + const { code } = await process.status; 233 + if (code !== 0) { 234 + throw new Error(`ORAS pull failed with exit code ${code}`); 235 + } 236 + }, 237 + catch: (error: unknown) => 238 + new PullImageError({ 239 + cause: error instanceof Error ? error.message : String(error), 240 + }), 241 + }), 242 + ); 243 + 244 + export const getImageArchivePath = (image: string) => 245 + Effect.tryPromise({ 246 + try: async () => { 247 + const process = new Deno.Command("oras", { 248 + args: [ 249 + "manifest", 250 + "fetch", 251 + `${image}-${getCurrentArch()}`, 252 + ], 253 + stdout: "piped", 254 + stderr: "inherit", 255 + }).spawn(); 256 + 257 + const { code, stdout } = await process.output(); 258 + if (code !== 0) { 259 + throw new Error(`ORAS manifest fetch failed with exit code ${code}`); 260 + } 261 + 262 + const manifest = JSON.parse(new TextDecoder().decode(stdout)); 263 + const layers = manifest.layers; 264 + if (!layers || layers.length === 0) { 265 + throw new Error(`No layers found in manifest for image ${image}`); 266 + } 267 + 268 + if ( 269 + !layers[0].annotations || 270 + !layers[0].annotations["org.opencontainers.image.title"] 271 + ) { 272 + throw new Error( 273 + `No title annotation found for layer in image ${image}`, 274 + ); 275 + } 276 + 277 + const path = `${IMAGE_DIR}/${ 278 + layers[0].annotations["org.opencontainers.image.title"] 279 + }`; 280 + 281 + if (!(await Deno.stat(path).catch(() => false))) { 282 + throw new Error(`Image archive not found at expected path ${path}`); 283 + } 284 + 285 + return path; 286 + }, 287 + catch: (error: unknown) => 288 + new PullImageError({ 289 + cause: error instanceof Error ? error.message : String(error), 290 + }), 291 + }); 292 + 293 + const getImageDigest = (image: string) => 294 + Effect.tryPromise({ 295 + try: async () => { 296 + const process = new Deno.Command("oras", { 297 + args: [ 298 + "manifest", 299 + "fetch", 300 + `${image}-${getCurrentArch()}`, 301 + ], 302 + stdout: "piped", 303 + stderr: "inherit", 304 + }).spawn(); 305 + 306 + const { code, stdout } = await process.output(); 307 + if (code !== 0) { 308 + throw new Error(`ORAS manifest fetch failed with exit code ${code}`); 309 + } 310 + 311 + const manifest = JSON.parse(new TextDecoder().decode(stdout)); 312 + if (!manifest.layers[0] || !manifest.layers[0].digest) { 313 + throw new Error(`No digest found in manifest for image ${image}`); 314 + } 315 + 316 + return manifest.layers[0].digest as string; 317 + }, 318 + catch: (error: unknown) => 319 + new PullImageError({ 320 + cause: error instanceof Error ? error.message : String(error), 321 + }), 322 + }); 323 + 324 + const extractImage = (path: string) => 325 + Effect.tryPromise({ 326 + try: async () => { 327 + console.log("Extracting image archive..."); 328 + const tarProcess = new Deno.Command("tar", { 329 + args: [ 330 + "-xSzf", 331 + path, 332 + "-C", 333 + dirname(path), 334 + ], 335 + stdout: "inherit", 336 + stderr: "inherit", 337 + cwd: IMAGE_DIR, 338 + }).spawn(); 339 + 340 + const tarStatus = await tarProcess.status; 341 + if (!tarStatus.success) { 342 + throw new Error(`Failed to extract tar archive for image`); 343 + } 344 + return path.replace(/\.tar\.gz$/, ""); 345 + }, 346 + catch: (error: unknown) => 347 + new PullImageError({ 348 + cause: error instanceof Error ? error.message : String(error), 349 + }), 350 + }); 351 + 352 + const savePulledImage = ( 353 + imagePath: string, 354 + digest: string, 355 + name: string, 356 + ) => 357 + Effect.gen(function* () { 358 + yield* saveImage({ 359 + id: createId(), 360 + repository: name.split(":")[0], 361 + tag: name.split(":")[1] || "latest", 362 + size: yield* du(imagePath), 363 + path: imagePath, 364 + format: imagePath.endsWith(".qcow2") ? "qcow2" : "raw", 365 + digest, 366 + }); 367 + return `${imagePath}.tar.gz`; 368 + }); 369 + 370 + export const pushImage = (image: string) => 371 + pipe( 372 + getImage(image), 373 + Effect.flatMap(failOnMissingImage), 374 + Effect.flatMap((img) => 375 + pipe( 376 + archiveImage(img), 377 + Effect.tap((archivedPath) => { 378 + img.path = archivedPath; 379 + return Effect.succeed(void 0); 380 + }), 381 + Effect.flatMap(() => pushToRegistry(img)), 382 + Effect.flatMap(cleanup), 383 + ) 384 + ), 385 + ); 386 + 387 + export const pullImage = (image: string) => 388 + pipe( 389 + Effect.all([createImageDirIfMissing, checkIfImageAlreadyPulled(image)]), 390 + Effect.flatMap(() => pullFromRegistry(image)), 391 + Effect.flatMap(() => getImageArchivePath(image)), 392 + Effect.flatMap(extractImage), 393 + Effect.flatMap((imagePath: string) => 394 + Effect.all([ 395 + Effect.succeed(imagePath), 396 + getImageDigest(image), 397 + Effect.succeed(image), 398 + ]) 399 + ), 400 + Effect.flatMap(([imagePath, digest, image]) => 401 + savePulledImage(imagePath, digest, image) 402 + ), 403 + Effect.flatMap(cleanup), 404 + Effect.catchTag("ImageAlreadyPulledError", () => 405 + Effect.sync(() => console.log(`Image ${image} is already pulled.`))), 406 + );
+55
src/subcommands/images.ts
··· 1 + import { Table } from "@cliffy/table"; 2 + import dayjs from "dayjs"; 3 + import relativeTime from "dayjs/plugin/relativeTime.js"; 4 + import utc from "dayjs/plugin/utc.js"; 5 + import { Effect, pipe } from "effect"; 6 + import type { Image } from "../db.ts"; 7 + import { type DbError, listImages } from "../images.ts"; 8 + import { humanFileSize } from "../utils.ts"; 9 + 10 + dayjs.extend(relativeTime); 11 + dayjs.extend(utc); 12 + 13 + const createTable = () => 14 + Effect.succeed( 15 + new Table( 16 + ["REPOSITORY", "TAG", "IMAGE ID", "CREATED", "SIZE"], 17 + ), 18 + ); 19 + 20 + const populateTable = (table: Table, images: Image[]) => 21 + Effect.gen(function* () { 22 + for (const image of images) { 23 + table.push([ 24 + image.repository, 25 + image.tag, 26 + image.id, 27 + dayjs.utc(image.createdAt).local().fromNow(), 28 + yield* humanFileSize(image.size), 29 + ]); 30 + } 31 + return table; 32 + }); 33 + 34 + const displayTable = (table: Table) => 35 + Effect.sync(() => { 36 + console.log(table.padding(2).toString()); 37 + }); 38 + 39 + const handleError = (error: DbError | Error) => 40 + Effect.sync(() => { 41 + console.error(`Failed to fetch virtual machines: ${error}`); 42 + Deno.exit(1); 43 + }); 44 + 45 + const lsEffect = () => 46 + pipe( 47 + Effect.all([listImages(), createTable()]), 48 + Effect.flatMap(([images, table]) => populateTable(table, images)), 49 + Effect.flatMap(displayTable), 50 + Effect.catchAll(handleError), 51 + ); 52 + 53 + export default async function () { 54 + await Effect.runPromise(lsEffect()); 55 + }
+35
src/subcommands/login.ts
··· 1 + import { setupOrasBinary } from "../oras.ts"; 2 + 3 + export default async function ( 4 + username: string, 5 + password: string, 6 + reqistry: string, 7 + ) { 8 + await setupOrasBinary(); 9 + 10 + const cmd = new Deno.Command("oras", { 11 + args: [ 12 + "login", 13 + "--username", 14 + username, 15 + "--password-stdin", 16 + reqistry, 17 + ], 18 + stdin: "piped", 19 + stderr: "inherit", 20 + stdout: "inherit", 21 + }); 22 + 23 + const process = cmd.spawn(); 24 + if (process.stdin) { 25 + const writer = process.stdin.getWriter(); 26 + await writer.write(new TextEncoder().encode(password + "\n")); 27 + writer.close(); 28 + } 29 + 30 + const status = await process.status; 31 + 32 + if (!status.success) { 33 + Deno.exit(status.code); 34 + } 35 + }
+19
src/subcommands/logout.ts
··· 1 + import { setupOrasBinary } from "../oras.ts"; 2 + 3 + export default async function (registry: string) { 4 + await setupOrasBinary(); 5 + 6 + const cmd = new Deno.Command("oras", { 7 + args: ["logout", registry], 8 + stderr: "inherit", 9 + stdout: "inherit", 10 + }); 11 + 12 + const process = cmd.spawn(); 13 + 14 + const status = await process.status; 15 + 16 + if (!status.success) { 17 + Deno.exit(status.code); 18 + } 19 + }
+19
src/subcommands/pull.ts
··· 1 + import { Effect, pipe } from "effect"; 2 + import { pullImage, setupOrasBinary } from "../oras.ts"; 3 + import { validateImage } from "../utils.ts"; 4 + 5 + export default async function (image: string): Promise<void> { 6 + await Effect.runPromise( 7 + pipe( 8 + Effect.promise(() => setupOrasBinary()), 9 + Effect.tap(() => validateImage(image)), 10 + Effect.tap(() => pullImage(image)), 11 + Effect.catchAll((error) => 12 + Effect.sync(() => { 13 + console.error(`Failed to pull image: ${error.cause}`); 14 + Deno.exit(1); 15 + }) 16 + ), 17 + ), 18 + ); 19 + }
+19
src/subcommands/push.ts
··· 1 + import { Effect, pipe } from "effect"; 2 + import { pushImage, setupOrasBinary } from "../oras.ts"; 3 + import { validateImage } from "../utils.ts"; 4 + 5 + export default async function (image: string): Promise<void> { 6 + await Effect.runPromise( 7 + pipe( 8 + Effect.promise(() => setupOrasBinary()), 9 + Effect.tap(() => validateImage(image)), 10 + Effect.tap(() => pushImage(image)), 11 + Effect.catchAll((error) => 12 + Effect.sync(() => { 13 + console.error(`Failed to push image: ${error.cause}`); 14 + Deno.exit(1); 15 + }) 16 + ), 17 + ), 18 + ); 19 + }
+20
src/subcommands/rmi.ts
··· 1 + import { Effect, pipe } from "effect"; 2 + import { deleteImage, getImage } from "../images.ts"; 3 + import { failOnMissingImage } from "../utils.ts"; 4 + 5 + export default async function (id: string) { 6 + await Effect.runPromise( 7 + pipe( 8 + getImage(id), 9 + Effect.flatMap(failOnMissingImage), 10 + Effect.tap(() => deleteImage(id)), 11 + Effect.tap(() => console.log(`Image ${id} removed successfully.`)), 12 + Effect.catchAll((error) => 13 + Effect.sync(() => { 14 + console.error(`Failed to remove image: ${error.message}`); 15 + Deno.exit(1); 16 + }) 17 + ), 18 + ), 19 + ); 20 + }
+73
src/subcommands/run.ts
··· 1 + import { parseFlags } from "@cliffy/flags"; 2 + import { Effect, pipe } from "effect"; 3 + import type { Image } from "../db.ts"; 4 + import { getImage } from "../images.ts"; 5 + import { createBridgeNetworkIfNeeded } from "../network.ts"; 6 + import { pullImage, PullImageError, setupOrasBinary } from "../oras.ts"; 7 + import { type Options, runQemu, validateImage } from "../utils.ts"; 8 + 9 + const pullImageOnMissing = ( 10 + name: string, 11 + ): Effect.Effect<Image, Error, never> => 12 + pipe( 13 + getImage(name), 14 + Effect.flatMap((img) => { 15 + if (img) { 16 + return Effect.succeed(img); 17 + } 18 + console.log(`Image ${name} not found locally`); 19 + return pipe( 20 + pullImage(name), 21 + Effect.flatMap(() => getImage(name)), 22 + Effect.flatMap((pulledImg) => 23 + pulledImg ? Effect.succeed(pulledImg) : Effect.fail( 24 + new PullImageError({ cause: "Failed to pull image" }), 25 + ) 26 + ), 27 + ); 28 + }), 29 + ); 30 + 31 + const runImage = (image: Image) => 32 + Effect.gen(function* () { 33 + console.log(`Running image ${image.repository}...`); 34 + const options = mergeFlags(image); 35 + if (options.bridge) { 36 + yield* createBridgeNetworkIfNeeded(options.bridge); 37 + } 38 + yield* runQemu(null, options); 39 + }); 40 + 41 + export default async function ( 42 + image: string, 43 + ): Promise<void> { 44 + await Effect.runPromise( 45 + pipe( 46 + Effect.promise(() => setupOrasBinary()), 47 + Effect.tap(() => validateImage(image)), 48 + Effect.flatMap(() => pullImageOnMissing(image)), 49 + Effect.flatMap(runImage), 50 + Effect.catchAll((error) => 51 + Effect.sync(() => { 52 + console.error(`Failed to run image: ${error.cause} ${image}`); 53 + Deno.exit(1); 54 + }) 55 + ), 56 + ), 57 + ); 58 + } 59 + 60 + function mergeFlags(image: Image): Options { 61 + const { flags } = parseFlags(Deno.args); 62 + return { 63 + cpu: flags.cpu ? flags.cpu : "host", 64 + cpus: flags.cpus ? flags.cpus : 2, 65 + memory: flags.memory ? flags.memory : "2G", 66 + image: image.path, 67 + bridge: flags.bridge, 68 + portForward: flags.portForward, 69 + detach: flags.detach, 70 + install: false, 71 + diskFormat: image.format, 72 + }; 73 + }
+46
src/subcommands/tag.ts
··· 1 + import { createId } from "@paralleldrive/cuid2"; 2 + import { Effect, pipe } from "effect"; 3 + import { saveImage } from "../images.ts"; 4 + import { getInstanceState, type VirtualMachine } from "../mod.ts"; 5 + import { du, extractTag } from "../utils.ts"; 6 + 7 + const failIfNoVM = ( 8 + [vm, tag]: [VirtualMachine | undefined, string], 9 + ) => 10 + Effect.gen(function* () { 11 + if (!vm) { 12 + throw new Error(`VM with name ${name} not found`); 13 + } 14 + if (!vm.drivePath) { 15 + throw new Error(`VM with name ${name} has no drive attached`); 16 + } 17 + 18 + const size = yield* du(vm.drivePath); 19 + 20 + return [vm, tag, size] as [VirtualMachine, string, number]; 21 + }); 22 + 23 + export default async function (name: string, image: string) { 24 + await Effect.runPromise( 25 + pipe( 26 + Effect.all([getInstanceState(name), extractTag(image)]), 27 + Effect.flatMap(failIfNoVM), 28 + Effect.flatMap(([vm, tag, size]) => 29 + saveImage({ 30 + id: createId(), 31 + repository: image.split(":")[0], 32 + tag, 33 + size, 34 + path: vm.drivePath!, 35 + format: vm.diskFormat, 36 + }) 37 + ), 38 + Effect.catchAll((error) => 39 + Effect.sync(() => { 40 + console.error(`Failed to tag image: ${error.cause}`); 41 + Deno.exit(1); 42 + }) 43 + ), 44 + ), 45 + ); 46 + }
+86 -9
src/utils.ts
··· 1 1 import _ from "@es-toolkit/es-toolkit/compat"; 2 2 import { createId } from "@paralleldrive/cuid2"; 3 3 import chalk from "chalk"; 4 - import { Data, Effect } from "effect"; 4 + import { Data, Effect, pipe } from "effect"; 5 5 import Moniker from "moniker"; 6 6 import { EMPTY_DISK_THRESHOLD_KB, LOGS_DIR } from "./constants.ts"; 7 + import type { Image } from "./db.ts"; 7 8 import { generateRandomMacAddress } from "./network.ts"; 8 9 import { saveInstanceState, updateInstanceState } from "./state.ts"; 9 10 ··· 15 16 cpus: number; 16 17 memory: string; 17 18 image?: string; 18 - diskFormat: string; 19 - size: string; 19 + diskFormat?: string; 20 + size?: string; 20 21 bridge?: string; 21 22 portForward?: string; 22 23 detach?: boolean; 24 + install?: boolean; 23 25 } 24 26 25 27 class LogCommandError extends Data.TaggedError("LogCommandError")<{ 26 28 cause?: unknown; 27 29 }> {} 28 30 31 + class InvalidImageNameError extends Data.TaggedError("InvalidImageNameError")<{ 32 + image: string; 33 + cause?: unknown; 34 + }> {} 35 + 36 + class NoSuchImageError extends Data.TaggedError("NoSuchImageError")<{ 37 + cause: string; 38 + }> {} 39 + 40 + export const getCurrentArch = (): string => { 41 + switch (Deno.build.arch) { 42 + case "x86_64": 43 + return "amd64"; 44 + case "aarch64": 45 + return "arm64"; 46 + default: 47 + return Deno.build.arch; 48 + } 49 + }; 50 + 29 51 export const isValidISOurl = (url?: string): boolean => { 30 52 return Boolean( 31 53 (url?.startsWith("http://") || url?.startsWith("https://")) && ··· 33 55 ); 34 56 }; 35 57 36 - const du = (path: string) => 58 + export const humanFileSize = (blocks: number) => 59 + Effect.sync(() => { 60 + const blockSize = 512; // bytes per block 61 + let bytes = blocks * blockSize; 62 + const thresh = 1024; 63 + 64 + if (Math.abs(bytes) < thresh) { 65 + return `${bytes}B`; 66 + } 67 + 68 + const units = ["KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; 69 + let u = -1; 70 + 71 + do { 72 + bytes /= thresh; 73 + ++u; 74 + } while (Math.abs(bytes) >= thresh && u < units.length - 1); 75 + 76 + return `${bytes.toFixed(1)}${units[u]}`; 77 + }); 78 + 79 + export const validateImage = ( 80 + image: string, 81 + ): Effect.Effect<string, InvalidImageNameError, never> => { 82 + const regex = 83 + /^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/; 84 + 85 + if (!regex.test(image)) { 86 + return Effect.fail( 87 + new InvalidImageNameError({ 88 + image, 89 + cause: 90 + "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 91 + }), 92 + ); 93 + } 94 + return Effect.succeed(image); 95 + }; 96 + 97 + export const extractTag = (name: string) => 98 + pipe( 99 + validateImage(name), 100 + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), 101 + ); 102 + 103 + export const failOnMissingImage = ( 104 + image: Image | undefined, 105 + ): Effect.Effect<Image, Error, never> => 106 + image 107 + ? Effect.succeed(image) 108 + : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 109 + 110 + export const du = ( 111 + path: string, 112 + ): Effect.Effect<number, LogCommandError, never> => 37 113 Effect.tryPromise({ 38 114 try: async () => { 39 115 const cmd = new Deno.Command("du", { ··· 244 320 : setupNATNetworkArgs(options.portForward), 245 321 "-device", 246 322 `e1000,netdev=net0,mac=${macAddress}`, 323 + ...(options.install ? [] : ["-snapshot"]), 247 324 "-nographic", 248 325 "-monitor", 249 326 "none", ··· 298 375 memory: options.memory, 299 376 cpus: options.cpus, 300 377 cpu: options.cpu, 301 - diskSize: options.size, 302 - diskFormat: options.diskFormat, 378 + diskSize: options.size || "20G", 379 + diskFormat: options.diskFormat || "raw", 303 380 portForward: options.portForward, 304 381 isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 305 382 drivePath: options.image ? Deno.realPathSync(options.image) : undefined, ··· 332 409 memory: options.memory, 333 410 cpus: options.cpus, 334 411 cpu: options.cpu, 335 - diskSize: options.size, 336 - diskFormat: options.diskFormat, 412 + diskSize: options.size || "20G", 413 + diskFormat: options.diskFormat || "raw", 337 414 portForward: options.portForward, 338 415 isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 339 416 drivePath: options.image ? Deno.realPathSync(options.image) : undefined, ··· 469 546 const status = yield* Effect.tryPromise({ 470 547 try: async () => { 471 548 const cmd = new Deno.Command("qemu-img", { 472 - args: ["create", "-f", format, path!, size!], 549 + args: ["create", "-f", format || "raw", path!, size!], 473 550 stdin: "inherit", 474 551 stdout: "inherit", 475 552 stderr: "inherit",