diff --git a/src/mod.ts b/src/mod.ts index ab192e1..2870935 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -2,6 +2,7 @@ export * from "./constants.ts"; export * from "./context.ts"; export * from "./db.ts"; export * from "./network.ts"; +export * from "./oras.ts"; export * from "./state.ts"; export * from "./types.ts"; export * from "./utils.ts"; diff --git a/src/oras.ts b/src/oras.ts new file mode 100644 index 0000000..2e58784 --- /dev/null +++ b/src/oras.ts @@ -0,0 +1,15 @@ +export async function setupOrasBinary(version: string): Promise { + const process = new Deno.Command("oras", { + args: ["version"], + stdout: "piped", + stderr: "piped", + }) + .spawn(); + + const { code } = await process.output(); + if (code === 0) { + return; + } + + console.log(`Downloading ORAS version ${version}...`); +} diff --git a/src/subcommands/pull.ts b/src/subcommands/pull.ts new file mode 100644 index 0000000..084ea01 --- /dev/null +++ b/src/subcommands/pull.ts @@ -0,0 +1,2 @@ +export default async function (name: string): Promise { +} diff --git a/src/subcommands/push.ts b/src/subcommands/push.ts new file mode 100644 index 0000000..084ea01 --- /dev/null +++ b/src/subcommands/push.ts @@ -0,0 +1,2 @@ +export default async function (name: string): Promise { +} diff --git a/src/subcommands/registry/login.ts b/src/subcommands/registry/login.ts new file mode 100644 index 0000000..87618ab --- /dev/null +++ b/src/subcommands/registry/login.ts @@ -0,0 +1,5 @@ +import { setupOrasBinary } from "../../oras.ts"; + +export default async function () { + await setupOrasBinary(Deno.env.get("ORAS_VERSION") ?? "1.3.0"); +} -- 2.51.2 From 677cc374e2e4b83e7aecd258734ce4da76fe2609 Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Thu, 6 Nov 2025 00:34:34 +0300 Subject: [PATCH 2/5] work in progress --- main.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/main.ts b/main.ts index 645fd94..5780355 100755 --- a/main.ts +++ b/main.ts @@ -5,6 +5,7 @@ import { createBridgeNetworkIfNeeded } from "./src/network.ts"; import inspect from "./src/subcommands/inspect.ts"; import logs from "./src/subcommands/logs.ts"; import ps from "./src/subcommands/ps.ts"; +import pull from "./src/subcommands/pull.ts"; import restart from "./src/subcommands/restart.ts"; import rm from "./src/subcommands/rm.ts"; import start from "./src/subcommands/start.ts"; @@ -167,5 +168,13 @@ if (import.meta.main) { .action(async (_options: unknown, vmName: string) => { await restart(vmName); }) + .command( + "pull", + "Pull VM image from an OCI-compliant registry, e.g., ghcr.io, docker hub", + ) + .arguments("") + .action(async (_options: unknown, image: string) => { + await pull; + }) .parse(Deno.args); } -- 2.51.2 From 4d28f2ac9e7e7ea0e548af52541ce6ec9141bf11 Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Sun, 9 Nov 2025 11:30:11 +0300 Subject: [PATCH 3/5] Add image management commands and enhance ORAS setup - Introduced commands for managing VM images: push, pull, tag, login, logout, and rmi. - Implemented image listing functionality with a formatted table display. - Enhanced ORAS binary setup to automatically download and install the specified version. - Added database migration for images, including creation and modification of the images table. - Refactored context and database handling for improved clarity and functionality. --- deno.json | 2 + deno.lock | 46 ++++++- main.ts | 69 ++++++++++ src/context.ts | 3 +- src/db.ts | 82 ++---------- src/images.ts | 52 ++++++++ src/migrations.ts | 210 ++++++++++++++++++++++++++++++ src/oras.ts | 97 +++++++++++++- src/subcommands/images.ts | 55 ++++++++ src/subcommands/login.ts | 35 +++++ src/subcommands/logout.ts | 19 +++ src/subcommands/pull.ts | 3 + src/subcommands/push.ts | 5 +- src/subcommands/registry/login.ts | 5 - src/subcommands/rmi.ts | 6 + src/subcommands/tag.ts | 47 +++++++ src/utils.ts | 17 ++- 17 files changed, 665 insertions(+), 88 deletions(-) create mode 100644 src/images.ts create mode 100644 src/migrations.ts create mode 100644 src/subcommands/images.ts create mode 100644 src/subcommands/login.ts create mode 100644 src/subcommands/logout.ts delete mode 100644 src/subcommands/registry/login.ts create mode 100644 src/subcommands/rmi.ts create mode 100644 src/subcommands/tag.ts diff --git a/deno.json b/deno.json index 5adfd7f..a91603e 100644 --- a/deno.json +++ b/deno.json @@ -8,6 +8,7 @@ }, "imports": { "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.8", + "@cliffy/prompt": "jsr:@cliffy/prompt@^1.0.0-rc.8", "@cliffy/flags": "jsr:@cliffy/flags@^1.0.0-rc.8", "@cliffy/table": "jsr:@cliffy/table@^1.0.0-rc.8", "@db/sqlite": "jsr:@db/sqlite@^0.12.0", @@ -15,6 +16,7 @@ "@paralleldrive/cuid2": "npm:@paralleldrive/cuid2@^3.0.4", "@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", "@std/assert": "jsr:@std/assert@1", + "@std/io": "jsr:@std/io@^0.225.2", "@std/toml": "jsr:@std/toml@^1.0.11", "@zod/zod": "jsr:@zod/zod@^4.1.12", "chalk": "npm:chalk@^5.6.2", diff --git a/deno.lock b/deno.lock index 999d94c..e8a1065 100644 --- a/deno.lock +++ b/deno.lock @@ -1,10 +1,13 @@ { "version": "5", "specifiers": { + "jsr:@cliffy/ansi@1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/command@^1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/flags@1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/flags@^1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/internal@1.0.0-rc.8": "1.0.0-rc.8", + "jsr:@cliffy/keycode@1.0.0-rc.8": "1.0.0-rc.8", + "jsr:@cliffy/prompt@^1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/table@1.0.0-rc.8": "1.0.0-rc.8", "jsr:@cliffy/table@^1.0.0-rc.8": "1.0.0-rc.8", "jsr:@db/sqlite@0.12": "0.12.0", @@ -13,17 +16,22 @@ "jsr:@soapbox/kysely-deno-sqlite@^2.2.0": "2.2.0", "jsr:@std/assert@0.217": "0.217.0", "jsr:@std/assert@1": "1.0.15", + "jsr:@std/assert@~1.0.6": "1.0.15", + "jsr:@std/bytes@^1.0.5": "1.0.6", "jsr:@std/collections@^1.1.3": "1.1.3", "jsr:@std/encoding@1": "1.0.10", + "jsr:@std/encoding@~1.0.5": "1.0.10", "jsr:@std/fmt@1": "1.0.8", "jsr:@std/fmt@~1.0.2": "1.0.8", "jsr:@std/fs@1": "1.0.19", "jsr:@std/internal@^1.0.10": "1.0.12", "jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/internal@^1.0.9": "1.0.12", + "jsr:@std/io@~0.225.2": "0.225.2", "jsr:@std/path@0.217": "0.217.0", "jsr:@std/path@1": "1.1.2", "jsr:@std/path@^1.1.1": "1.1.2", + "jsr:@std/path@~1.0.6": "1.0.9", "jsr:@std/text@~1.0.7": "1.0.16", "jsr:@std/toml@^1.0.11": "1.0.11", "jsr:@zod/zod@^4.1.12": "4.1.12", @@ -37,6 +45,13 @@ "npm:moniker@~0.1.2": "0.1.2" }, "jsr": { + "@cliffy/ansi@1.0.0-rc.8": { + "integrity": "ba37f10ce55bbfbdd8ddd987f91f029b17bce88385b98ba3058870f3b007b80c", + "dependencies": [ + "jsr:@cliffy/internal", + "jsr:@std/encoding@~1.0.5" + ] + }, "@cliffy/command@1.0.0-rc.8": { "integrity": "758147790797c74a707e5294cc7285df665422a13d2a483437092ffce40b5557", "dependencies": [ @@ -56,6 +71,21 @@ "@cliffy/internal@1.0.0-rc.8": { "integrity": "34cdf2fad9b084b5aed493b138d573f52d4e988767215f7460daf0b918ff43d8" }, + "@cliffy/keycode@1.0.0-rc.8": { + "integrity": "76dbf85a67ec0aea2e29ca049b8507b6b3f62a2a971bd744d8d3fc447c177cd9" + }, + "@cliffy/prompt@1.0.0-rc.8": { + "integrity": "eba403ea1d47b9971bf2210fa35f4dc7ebd2aba87beec9540ae47552806e2f25", + "dependencies": [ + "jsr:@cliffy/ansi", + "jsr:@cliffy/internal", + "jsr:@cliffy/keycode", + "jsr:@std/assert@~1.0.6", + "jsr:@std/fmt@~1.0.2", + "jsr:@std/path@~1.0.6", + "jsr:@std/text" + ] + }, "@cliffy/table@1.0.0-rc.8": { "integrity": "8bbcdc2ba5e0061b4b13810a24e6f5c6ab19c09f0cce9eb691ccd76c7c6c9db5", "dependencies": [ @@ -72,7 +102,7 @@ "@denosaurs/plug@1.1.0": { "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044", "dependencies": [ - "jsr:@std/encoding", + "jsr:@std/encoding@1", "jsr:@std/fmt@1", "jsr:@std/fs", "jsr:@std/path@1" @@ -96,6 +126,9 @@ "jsr:@std/internal@^1.0.12" ] }, + "@std/bytes@1.0.6": { + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" + }, "@std/collections@1.1.3": { "integrity": "bf8b0818886df6a32b64c7d3b037a425111f28278d69fd0995aeb62777c986b0" }, @@ -115,12 +148,21 @@ "@std/internal@1.0.12": { "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" }, + "@std/io@0.225.2": { + "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7", + "dependencies": [ + "jsr:@std/bytes" + ] + }, "@std/path@0.217.0": { "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", "dependencies": [ "jsr:@std/assert@0.217" ] }, + "@std/path@1.0.9": { + "integrity": "260a49f11edd3db93dd38350bf9cd1b4d1366afa98e81b86167b4e3dd750129e" + }, "@std/path@1.1.2": { "integrity": "c0b13b97dfe06546d5e16bf3966b1cadf92e1cc83e56ba5476ad8b498d9e3038", "dependencies": [ @@ -204,11 +246,13 @@ "dependencies": [ "jsr:@cliffy/command@^1.0.0-rc.8", "jsr:@cliffy/flags@^1.0.0-rc.8", + "jsr:@cliffy/prompt@^1.0.0-rc.8", "jsr:@cliffy/table@^1.0.0-rc.8", "jsr:@db/sqlite@0.12", "jsr:@es-toolkit/es-toolkit@^1.41.0", "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", "jsr:@std/assert@1", + "jsr:@std/io@~0.225.2", "jsr:@std/toml@^1.0.11", "jsr:@zod/zod@^4.1.12", "npm:@paralleldrive/cuid2@^3.0.4", diff --git a/main.ts b/main.ts index e91b15e..93dd114 100755 --- a/main.ts +++ b/main.ts @@ -1,20 +1,28 @@ #!/usr/bin/env -S deno run --allow-run --allow-read --allow-env import { Command } from "@cliffy/command"; +import { Secret } from "@cliffy/prompt/secret"; +import { readAll } from "@std/io"; import chalk from "chalk"; import { Effect, pipe } from "effect"; import pkg from "./deno.json" with { type: "json" }; import { initVmFile, mergeConfig, parseVmFile } from "./src/config.ts"; import { CONFIG_FILE_NAME } from "./src/constants.ts"; import { createBridgeNetworkIfNeeded } from "./src/network.ts"; +import images from "./src/subcommands/images.ts"; import inspect from "./src/subcommands/inspect.ts"; +import login from "./src/subcommands/login.ts"; +import logout from "./src/subcommands/logout.ts"; import logs from "./src/subcommands/logs.ts"; import ps from "./src/subcommands/ps.ts"; import pull from "./src/subcommands/pull.ts"; +import push from "./src/subcommands/push.ts"; import restart from "./src/subcommands/restart.ts"; import rm from "./src/subcommands/rm.ts"; +import rmi from "./src/subcommands/rmi.ts"; import start from "./src/subcommands/start.ts"; import stop from "./src/subcommands/stop.ts"; +import tag from "./src/subcommands/tag.ts"; import { createDriveImageIfNeeded, downloadIso, @@ -251,5 +259,66 @@ if (import.meta.main) { .action(async (_options: unknown, image: string) => { await pull(image); }) + .command( + "push", + "Push VM image to an OCI-compliant registry, e.g., ghcr.io, docker hub", + ) + .arguments("") + .action(async (_options: unknown, image: string) => { + await push(image); + }) + .command( + "tag", + "Create a tag 'image' that refers to the VM image of 'vm-name'", + ) + .arguments(" ") + .action(async (_options: unknown, vmName: string, image: string) => { + console.log( + `Tagging VM image of ${chalk.greenBright(vmName)} as ${ + chalk.greenBright(image) + }...`, + ); + await tag(vmName, image); + }) + .command( + "login", + "Login to an OCI-compliant registry, e.g., ghcr.io, docker.io (docker hub), etc.", + ) + .option("-u, --username ", "Registry username") + .arguments("") + .action(async (options: unknown, registry: string) => { + const username = (options as { username: string }).username; + + let password: string | undefined; + const stdinIsTTY = Deno.stdin.isTerminal(); + + if (!stdinIsTTY) { + const buffer = await readAll(Deno.stdin); + password = new TextDecoder().decode(buffer).trim(); + } else { + password = await Secret.prompt("Registry Password: "); + } + + console.log( + `Logging in to registry ${chalk.greenBright(registry)} as ${ + chalk.greenBright(username) + }...`, + ); + await login(username, password, registry); + }) + .command("logout", "Logout from an OCI-compliant registry") + .arguments("") + .action(async (_options: unknown, registry: string) => { + await logout(registry); + }) + .command("images", "List all local VM images") + .action(async () => { + await images(); + }) + .command("rmi", "Remove a local VM image") + .arguments("") + .action(async (_options: unknown, image: string) => { + await rmi(image); + }) .parse(Deno.args); } diff --git a/src/context.ts b/src/context.ts index fcff91f..14ff1b5 100644 --- a/src/context.ts +++ b/src/context.ts @@ -1,5 +1,6 @@ import { DB_PATH } from "./constants.ts"; -import { createDb, type Database, migrateToLatest } from "./db.ts"; +import { createDb, type Database } from "./db.ts"; +import { migrateToLatest } from "./migrations.ts"; export const db: Database = createDb(DB_PATH); await migrateToLatest(db); diff --git a/src/db.ts b/src/db.ts index 50d8ae7..f4c0e33 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,12 +1,6 @@ import { Database as Sqlite } from "@db/sqlite"; import { DenoSqlite3Dialect } from "@soapbox/kysely-deno-sqlite"; -import { - Kysely, - type Migration, - type MigrationProvider, - Migrator, - sql, -} from "kysely"; +import { Kysely } from "kysely"; import { CONFIG_DIR } from "./constants.ts"; import type { STATUS } from "./types.ts"; @@ -21,6 +15,7 @@ export const createDb = (location: string): Database => { export type DatabaseSchema = { virtual_machines: VirtualMachine; + images: Image; }; export type VirtualMachine = { @@ -43,71 +38,14 @@ export type VirtualMachine = { updatedAt?: string; }; -const migrations: Record = {}; - -const migrationProvider: MigrationProvider = { - // deno-lint-ignore require-await - async getMigrations() { - return migrations; - }, -}; - -migrations["001"] = { - async up(db: Kysely): Promise { - await db.schema - .createTable("virtual_machines") - .addColumn("id", "varchar", (col) => col.primaryKey()) - .addColumn("name", "varchar", (col) => col.notNull().unique()) - .addColumn("bridge", "varchar") - .addColumn("macAddress", "varchar", (col) => col.notNull().unique()) - .addColumn("memory", "varchar", (col) => col.notNull()) - .addColumn("cpus", "integer", (col) => col.notNull()) - .addColumn("cpu", "varchar", (col) => col.notNull()) - .addColumn("diskSize", "varchar", (col) => col.notNull()) - .addColumn("drivePath", "varchar") - .addColumn("version", "varchar", (col) => col.notNull()) - .addColumn("diskFormat", "varchar") - .addColumn("isoPath", "varchar") - .addColumn("status", "varchar", (col) => col.notNull()) - .addColumn("pid", "integer") - .addColumn( - "createdAt", - "varchar", - (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), - ) - .addColumn( - "updatedAt", - "varchar", - (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), - ) - .execute(); - }, - - async down(db: Kysely): Promise { - await db.schema.dropTable("virtual_machines").execute(); - }, -}; - -migrations["002"] = { - async up(db: Kysely): Promise { - await db.schema - .alterTable("virtual_machines") - .addColumn("portForward", "varchar") - .execute(); - }, - - async down(db: Kysely): Promise { - await db.schema - .alterTable("virtual_machines") - .dropColumn("portForward") - .execute(); - }, -}; - -export const migrateToLatest = async (db: Database): Promise => { - const migrator = new Migrator({ db, provider: migrationProvider }); - const { error } = await migrator.migrateToLatest(); - if (error) throw error; +export type Image = { + id: string; + repository: string; + tag: string; + size: number; + path: string; + format: string; + createdAt?: string; }; export type Database = Kysely; diff --git a/src/images.ts b/src/images.ts new file mode 100644 index 0000000..e74c58e --- /dev/null +++ b/src/images.ts @@ -0,0 +1,52 @@ +import { Data, Effect } from "effect"; +import type { DeleteResult, InsertResult } from "kysely"; +import { ctx } from "./context.ts"; +import type { Image } from "./db.ts"; + +export class DbError extends Data.TaggedError("DatabaseError")<{ + message?: string; +}> {} + +export const listImages = (): Effect.Effect => + Effect.tryPromise({ + try: () => ctx.db.selectFrom("images").selectAll().execute(), + catch: (error) => + new DbError({ + message: error instanceof Error ? error.message : String(error), + }), + }); + +export const saveImage = ( + image: Image, +): Effect.Effect => + Effect.tryPromise({ + try: () => + ctx.db.insertInto("images") + .values(image) + .onConflict((oc) => + oc + .column("repository") + .column("tag") + .doUpdateSet({ + size: image.size, + path: image.path, + format: image.format, + }) + ) + .execute(), + catch: (error) => + new DbError({ + message: error instanceof Error ? error.message : String(error), + }), + }); + +export const deleteImage = ( + id: string, +): Effect.Effect => + Effect.tryPromise({ + try: () => ctx.db.deleteFrom("images").where("id", "=", id).execute(), + catch: (error) => + new DbError({ + message: error instanceof Error ? error.message : String(error), + }), + }); diff --git a/src/migrations.ts b/src/migrations.ts new file mode 100644 index 0000000..dae824d --- /dev/null +++ b/src/migrations.ts @@ -0,0 +1,210 @@ +import { + type Kysely, + type Migration, + type MigrationProvider, + Migrator, + sql, +} from "kysely"; +import type { Database } from "./db.ts"; + +const migrations: Record = {}; + +const migrationProvider: MigrationProvider = { + // deno-lint-ignore require-await + async getMigrations() { + return migrations; + }, +}; + +migrations["001"] = { + async up(db: Kysely): Promise { + await db.schema + .createTable("virtual_machines") + .addColumn("id", "varchar", (col) => col.primaryKey()) + .addColumn("name", "varchar", (col) => col.notNull().unique()) + .addColumn("bridge", "varchar") + .addColumn("macAddress", "varchar", (col) => col.notNull().unique()) + .addColumn("memory", "varchar", (col) => col.notNull()) + .addColumn("cpus", "integer", (col) => col.notNull()) + .addColumn("cpu", "varchar", (col) => col.notNull()) + .addColumn("diskSize", "varchar", (col) => col.notNull()) + .addColumn("drivePath", "varchar") + .addColumn("version", "varchar", (col) => col.notNull()) + .addColumn("diskFormat", "varchar") + .addColumn("isoPath", "varchar") + .addColumn("status", "varchar", (col) => col.notNull()) + .addColumn("pid", "integer") + .addColumn( + "createdAt", + "varchar", + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), + ) + .addColumn( + "updatedAt", + "varchar", + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), + ) + .execute(); + }, + + async down(db: Kysely): Promise { + await db.schema.dropTable("virtual_machines").execute(); + }, +}; + +migrations["002"] = { + async up(db: Kysely): Promise { + await db.schema + .alterTable("virtual_machines") + .addColumn("portForward", "varchar") + .execute(); + }, + + async down(db: Kysely): Promise { + await db.schema + .alterTable("virtual_machines") + .dropColumn("portForward") + .execute(); + }, +}; + +migrations["003"] = { + async up(db: Kysely): Promise { + await db.schema + .createTable("images") + .addColumn("id", "varchar", (col) => col.primaryKey()) + .addColumn("repository", "varchar", (col) => col.notNull()) + .addColumn("tag", "varchar", (col) => col.notNull()) + .addColumn("size", "integer", (col) => col.notNull()) + .addColumn("path", "varchar", (col) => col.notNull()) + .addColumn("createdAt", "varchar", (col) => col.notNull()) + .execute(); + }, + + async down(db: Kysely): Promise { + await db.schema.dropTable("images").execute(); + }, +}; + +migrations["004"] = { + async up(db: Kysely): Promise { + await db.schema + .alterTable("images") + .addColumn("format", "varchar", (col) => col.notNull().defaultTo("qcow2")) + .execute(); + }, + + async down(db: Kysely): Promise { + await db.schema + .alterTable("images") + .dropColumn("format") + .execute(); + }, +}; + +migrations["005"] = { + async up(db: Kysely): Promise { + await db.schema + .createTable("images_new") + .addColumn("id", "varchar", (col) => col.primaryKey()) + .addColumn("repository", "varchar", (col) => col.notNull()) + .addColumn("tag", "varchar", (col) => col.notNull()) + .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()) + .addUniqueConstraint("images_repository_tag_unique", [ + "repository", + "tag", + ]) + .execute(); + + await sql` + INSERT INTO images_new (id, repository, tag, size, path, format, createdAt) + SELECT id, repository, tag, size, path, format, createdAt FROM images + `.execute(db); + + await db.schema.dropTable("images").execute(); + await sql`ALTER TABLE images_new RENAME TO images`.execute(db); + }, + + async down(db: Kysely): Promise { + await db.schema + .createTable("images_old") + .addColumn("id", "varchar", (col) => col.primaryKey()) + .addColumn("repository", "varchar", (col) => col.notNull()) + .addColumn("tag", "varchar", (col) => col.notNull()) + .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()) + .execute(); + + await sql` + INSERT INTO images_old (id, repository, tag, size, path, format, createdAt) + SELECT id, repository, tag, size, path, format, createdAt FROM images + `.execute(db); + + await db.schema.dropTable("images").execute(); + await sql`ALTER TABLE images_old RENAME TO images`.execute(db); + }, +}; + +migrations["006"] = { + async up(db: Kysely): Promise { + await db.schema + .createTable("images_new") + .addColumn("id", "varchar", (col) => col.primaryKey()) + .addColumn("repository", "varchar", (col) => col.notNull()) + .addColumn("tag", "varchar", (col) => col.notNull()) + .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`), + ) + .addUniqueConstraint("images_repository_tag_unique", [ + "repository", + "tag", + ]) + .execute(); + + await sql` + INSERT INTO images_new (id, repository, tag, size, path, format, createdAt) + SELECT id, repository, tag, size, path, format, createdAt FROM images + `.execute(db); + + await db.schema.dropTable("images").execute(); + await sql`ALTER TABLE images_new RENAME TO images`.execute(db); + }, + + async down(db: Kysely): Promise { + await db.schema + .createTable("images_old") + .addColumn("id", "varchar", (col) => col.primaryKey()) + .addColumn("repository", "varchar", (col) => col.notNull()) + .addColumn("tag", "varchar", (col) => col.notNull()) + .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()) + .addUniqueConstraint("images_repository_tag_unique", [ + "repository", + "tag", + ]) + .execute(); + + await sql` + INSERT INTO images_old (id, repository, tag, size, path, format, createdAt) + SELECT id, repository, tag, size, path, format, createdAt FROM images + `.execute(db); + }, +}; + +export const migrateToLatest = async (db: Database): Promise => { + const migrator = new Migrator({ db, provider: migrationProvider }); + const { error } = await migrator.migrateToLatest(); + if (error) throw error; +}; diff --git a/src/oras.ts b/src/oras.ts index 2e58784..e8b9486 100644 --- a/src/oras.ts +++ b/src/oras.ts @@ -1,15 +1,98 @@ -export async function setupOrasBinary(version: string): Promise { - const process = new Deno.Command("oras", { - args: ["version"], - stdout: "piped", - stderr: "piped", +import chalk from "chalk"; +import { CONFIG_DIR } from "./mod.ts"; + +const DEFAULT_ORAS_VERSION = "1.3.0"; + +export async function setupOrasBinary(): Promise { + Deno.env.set( + "PATH", + `${CONFIG_DIR}/bin:${Deno.env.get("PATH")}`, + ); + + const oras = new Deno.Command("which", { + args: ["oras"], + stdout: "null", + stderr: "null", }) .spawn(); - const { code } = await process.output(); - if (code === 0) { + const orasStatus = await oras.status; + if (orasStatus.success) { return; } + const version = Deno.env.get("ORAS_VERSION") || DEFAULT_ORAS_VERSION; + console.log(`Downloading ORAS version ${version}...`); + + const os = Deno.build.os; + let arch = "amd64"; + + if (Deno.build.arch === "aarch64") { + arch = "arm64"; + } + + if (os !== "linux" && os !== "darwin") { + console.error("Unsupported OS. Please download ORAS manually."); + Deno.exit(1); + } + + // https://github.com/oras-project/oras/releases/download/v1.3.0/oras_1.3.0_darwin_amd64.tar.gz + const downloadUrl = + `https://github.com/oras-project/oras/releases/download/v${version}/oras_${version}_${os}_${arch}.tar.gz`; + + console.log(`Downloading ORAS from ${chalk.greenBright(downloadUrl)}`); + + const downloadProcess = new Deno.Command("curl", { + args: ["-L", downloadUrl, "-o", `oras_${version}_${os}_${arch}.tar.gz`], + stdout: "inherit", + stderr: "inherit", + cwd: "/tmp", + }) + .spawn(); + + const status = await downloadProcess.status; + if (!status.success) { + console.error("Failed to download ORAS binary."); + Deno.exit(1); + } + + console.log("Extracting ORAS binary..."); + + const extractProcess = new Deno.Command("tar", { + args: [ + "-xzf", + `oras_${version}_${os}_${arch}.tar.gz`, + "-C", + "./", + ], + stdout: "inherit", + stderr: "inherit", + cwd: "/tmp", + }) + .spawn(); + + const extractStatus = await extractProcess.status; + if (!extractStatus.success) { + console.error("Failed to extract ORAS binary."); + Deno.exit(1); + } + + await Deno.remove(`/tmp/oras_${version}_${os}_${arch}.tar.gz`); + + await Deno.mkdir(`${CONFIG_DIR}/bin`, { recursive: true }); + + await Deno.rename( + `/tmp/oras`, + `${CONFIG_DIR}/bin/oras`, + ); + await Deno.chmod(`${CONFIG_DIR}/bin/oras`, 0o755); + + console.log( + `ORAS binary installed at ${ + chalk.greenBright( + `${CONFIG_DIR}/bin/oras`, + ) + }`, + ); } diff --git a/src/subcommands/images.ts b/src/subcommands/images.ts new file mode 100644 index 0000000..ff46655 --- /dev/null +++ b/src/subcommands/images.ts @@ -0,0 +1,55 @@ +import { Table } from "@cliffy/table"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime.js"; +import utc from "dayjs/plugin/utc.js"; +import { Effect, pipe } from "effect"; +import type { Image } from "../db.ts"; +import { type DbError, listImages } from "../images.ts"; +import { humanFileSize } from "../utils.ts"; + +dayjs.extend(relativeTime); +dayjs.extend(utc); + +const createTable = () => + Effect.succeed( + new Table( + ["REPOSITORY", "TAG", "IMAGE ID", "CREATED", "SIZE"], + ), + ); + +const populateTable = (table: Table, images: Image[]) => + Effect.gen(function* () { + for (const image of images) { + table.push([ + image.repository, + image.tag, + image.id, + dayjs.utc(image.createdAt).local().fromNow(), + yield* humanFileSize(image.size), + ]); + } + return table; + }); + +const displayTable = (table: Table) => + Effect.sync(() => { + console.log(table.padding(2).toString()); + }); + +const handleError = (error: DbError | Error) => + Effect.sync(() => { + console.error(`Failed to fetch virtual machines: ${error}`); + Deno.exit(1); + }); + +const lsEffect = () => + pipe( + Effect.all([listImages(), createTable()]), + Effect.flatMap(([images, table]) => populateTable(table, images)), + Effect.flatMap(displayTable), + Effect.catchAll(handleError), + ); + +export default async function () { + await Effect.runPromise(lsEffect()); +} diff --git a/src/subcommands/login.ts b/src/subcommands/login.ts new file mode 100644 index 0000000..bcca7d6 --- /dev/null +++ b/src/subcommands/login.ts @@ -0,0 +1,35 @@ +import { setupOrasBinary } from "../oras.ts"; + +export default async function ( + username: string, + password: string, + reqistry: string, +) { + await setupOrasBinary(); + + const cmd = new Deno.Command("oras", { + args: [ + "login", + "--username", + username, + "--password-stdin", + reqistry, + ], + stdin: "piped", + stderr: "inherit", + stdout: "inherit", + }); + + const process = cmd.spawn(); + if (process.stdin) { + const writer = process.stdin.getWriter(); + await writer.write(new TextEncoder().encode(password + "\n")); + writer.close(); + } + + const status = await process.status; + + if (!status.success) { + Deno.exit(status.code); + } +} diff --git a/src/subcommands/logout.ts b/src/subcommands/logout.ts new file mode 100644 index 0000000..9e40e4c --- /dev/null +++ b/src/subcommands/logout.ts @@ -0,0 +1,19 @@ +import { setupOrasBinary } from "../oras.ts"; + +export default async function (registry: string) { + await setupOrasBinary(); + + const cmd = new Deno.Command("oras", { + args: ["logout", registry], + stderr: "inherit", + stdout: "inherit", + }); + + const process = cmd.spawn(); + + const status = await process.status; + + if (!status.success) { + Deno.exit(status.code); + } +} diff --git a/src/subcommands/pull.ts b/src/subcommands/pull.ts index 084ea01..f9675cb 100644 --- a/src/subcommands/pull.ts +++ b/src/subcommands/pull.ts @@ -1,2 +1,5 @@ +import { setupOrasBinary } from "../oras.ts"; + export default async function (name: string): Promise { + await setupOrasBinary(); } diff --git a/src/subcommands/push.ts b/src/subcommands/push.ts index 084ea01..dd13900 100644 --- a/src/subcommands/push.ts +++ b/src/subcommands/push.ts @@ -1,2 +1,5 @@ -export default async function (name: string): Promise { +import { setupOrasBinary } from "../oras.ts"; + +export default async function (image: string): Promise { + await setupOrasBinary(); } diff --git a/src/subcommands/registry/login.ts b/src/subcommands/registry/login.ts deleted file mode 100644 index 87618ab..0000000 --- a/src/subcommands/registry/login.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { setupOrasBinary } from "../../oras.ts"; - -export default async function () { - await setupOrasBinary(Deno.env.get("ORAS_VERSION") ?? "1.3.0"); -} diff --git a/src/subcommands/rmi.ts b/src/subcommands/rmi.ts new file mode 100644 index 0000000..2b2cf9e --- /dev/null +++ b/src/subcommands/rmi.ts @@ -0,0 +1,6 @@ +import { Effect } from "effect"; +import { deleteImage } from "../images.ts"; + +export default async function (id: string) { + await Effect.runPromise(deleteImage(id)); +} diff --git a/src/subcommands/tag.ts b/src/subcommands/tag.ts new file mode 100644 index 0000000..1185bac --- /dev/null +++ b/src/subcommands/tag.ts @@ -0,0 +1,47 @@ +import { createId } from "@paralleldrive/cuid2"; +import { Effect, pipe } from "effect"; +import { saveImage } from "../images.ts"; +import { getInstanceState, type VirtualMachine } from "../mod.ts"; +import { du } from "../utils.ts"; + +const extractTag = (name: string) => + Effect.sync(() => name.split(":")[1] || "latest"); + +const failIfNoVM = ([vm, name]: [VirtualMachine | undefined, string]) => + Effect.gen(function* () { + if (!vm) { + throw new Error(`VM with name ${name} not found`); + } + if (!vm.drivePath) { + throw new Error(`VM with name ${name} has no drive attached`); + } + + const size = yield* du(vm.drivePath); + + return [vm, name, size] as [VirtualMachine, string, number]; + }); + +export default async function (name: string, image: string) { + await Effect.runPromise( + pipe( + Effect.all([getInstanceState(name), extractTag(image)]), + Effect.flatMap(failIfNoVM), + Effect.flatMap(([vm, tag, size]) => + saveImage({ + id: createId(), + repository: image, + tag, + size, + path: vm.drivePath!, + format: vm.diskFormat, + }) + ), + Effect.catchAll((error) => + Effect.sync(() => { + console.error(`Failed to tag image: ${error}`); + Deno.exit(1); + }) + ), + ), + ); +} diff --git a/src/utils.ts b/src/utils.ts index 0aa9e43..40f30c7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -37,7 +37,22 @@ export const isValidISOurl = (url?: string): boolean => { ); }; -const du = (path: string): Effect.Effect => +export const humanFileSize = (bytes: number) => + Effect.sync(() => { + const thresh = 1024; + if (Math.abs(bytes) < thresh) return bytes + "KB"; + const units = ["MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + let u = -1; + do { + bytes /= thresh; + ++u; + } while (Math.abs(bytes) >= thresh && u < units.length - 1); + return `${bytes.toFixed(1)}${units[u]}`; + }); + +export const du = ( + path: string, +): Effect.Effect => Effect.tryPromise({ try: async () => { const cmd = new Deno.Command("du", { -- 2.51.2 From 9185f42485c8399f7035675aefd17766df082073 Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Sun, 9 Nov 2025 11:54:35 +0300 Subject: [PATCH 4/5] Refactor image handling in inspect command to include image retrieval and error handling --- src/images.ts | 16 ++++++++++++++++ src/subcommands/inspect.ts | 25 ++++++++++++++----------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/src/images.ts b/src/images.ts index e74c58e..fc33d03 100644 --- a/src/images.ts +++ b/src/images.ts @@ -16,6 +16,22 @@ export const listImages = (): Effect.Effect => }), }); +export const getImage = ( + id: string, +): Effect.Effect => + Effect.tryPromise({ + try: () => + ctx.db + .selectFrom("images") + .selectAll() + .where("id", "=", id) + .executeTakeFirst(), + catch: (error) => + new DbError({ + message: error instanceof Error ? error.message : String(error), + }), + }); + export const saveImage = ( image: Image, ): Effect.Effect => diff --git a/src/subcommands/inspect.ts b/src/subcommands/inspect.ts index 246ca0d..17495b6 100644 --- a/src/subcommands/inspect.ts +++ b/src/subcommands/inspect.ts @@ -1,27 +1,30 @@ import { Data, Effect, pipe } from "effect"; -import type { VirtualMachine } from "../db.ts"; +import type { Image, VirtualMachine } from "../db.ts"; +import { getImage } from "../images.ts"; import { getInstanceState } from "../state.ts"; -class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ +class ItemNotFoundError extends Data.TaggedError("ItemNotFoundError")<{ name: string; }> {} -const findVm = (name: string) => +const find = (name: string) => pipe( - getInstanceState(name), - Effect.flatMap((vm) => - vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) + Effect.all([getInstanceState(name), getImage(name)]), + Effect.flatMap(([vm, image]) => + vm || image + ? Effect.succeed(vm || image) + : Effect.fail(new ItemNotFoundError({ name })) ), ); -const displayVm = (vm: VirtualMachine) => +const display = (vm: VirtualMachine | Image | undefined) => Effect.sync(() => { console.log(vm); }); -const handleError = (error: VmNotFoundError | Error) => +const handleError = (error: ItemNotFoundError | Error) => Effect.sync(() => { - if (error instanceof VmNotFoundError) { + if (error instanceof ItemNotFoundError) { console.error( `Virtual machine with name or ID ${error.name} not found.`, ); @@ -33,8 +36,8 @@ const handleError = (error: VmNotFoundError | Error) => const inspectEffect = (name: string) => pipe( - findVm(name), - Effect.flatMap(displayVm), + find(name), + Effect.flatMap(display), Effect.catchAll(handleError), ); -- 2.51.2 From 9f083f411e0ebe9501db57d35c0b8c12d3e67f18 Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Sun, 9 Nov 2025 22:36:07 +0300 Subject: [PATCH 5/5] Enhance image management with new pull and run commands, add digest support, and improve error handling --- deno.json | 1 + deno.lock | 2 + main.ts | 64 ++++++++- src/constants.ts | 1 + src/db.ts | 1 + src/images.ts | 23 ++- src/migrations.ts | 15 ++ src/oras.ts | 310 +++++++++++++++++++++++++++++++++++++++- src/subcommands/pull.ts | 20 ++- src/subcommands/push.ts | 18 ++- src/subcommands/rmi.ts | 20 ++- src/subcommands/run.ts | 73 ++++++++++ src/subcommands/tag.ts | 15 +- src/utils.ts | 84 +++++++++-- 14 files changed, 610 insertions(+), 37 deletions(-) create mode 100644 src/subcommands/run.ts diff --git a/deno.json b/deno.json index a91603e..8cf9238 100644 --- a/deno.json +++ b/deno.json @@ -17,6 +17,7 @@ "@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", "@std/assert": "jsr:@std/assert@1", "@std/io": "jsr:@std/io@^0.225.2", + "@std/path": "jsr:@std/path@^1.1.2", "@std/toml": "jsr:@std/toml@^1.0.11", "@zod/zod": "jsr:@zod/zod@^4.1.12", "chalk": "npm:chalk@^5.6.2", diff --git a/deno.lock b/deno.lock index e8a1065..21faec5 100644 --- a/deno.lock +++ b/deno.lock @@ -31,6 +31,7 @@ "jsr:@std/path@0.217": "0.217.0", "jsr:@std/path@1": "1.1.2", "jsr:@std/path@^1.1.1": "1.1.2", + "jsr:@std/path@^1.1.2": "1.1.2", "jsr:@std/path@~1.0.6": "1.0.9", "jsr:@std/text@~1.0.7": "1.0.16", "jsr:@std/toml@^1.0.11": "1.0.11", @@ -253,6 +254,7 @@ "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", "jsr:@std/assert@1", "jsr:@std/io@~0.225.2", + "jsr:@std/path@^1.1.2", "jsr:@std/toml@^1.0.11", "jsr:@zod/zod@^4.1.12", "npm:@paralleldrive/cuid2@^3.0.4", diff --git a/main.ts b/main.ts index 93dd114..ae65ac3 100755 --- a/main.ts +++ b/main.ts @@ -8,7 +8,9 @@ import { Effect, pipe } from "effect"; import pkg from "./deno.json" with { type: "json" }; import { initVmFile, mergeConfig, parseVmFile } from "./src/config.ts"; import { CONFIG_FILE_NAME } from "./src/constants.ts"; +import { getImage } from "./src/images.ts"; import { createBridgeNetworkIfNeeded } from "./src/network.ts"; +import { getImageArchivePath } from "./src/oras.ts"; import images from "./src/subcommands/images.ts"; import inspect from "./src/subcommands/inspect.ts"; import login from "./src/subcommands/login.ts"; @@ -20,6 +22,7 @@ import push from "./src/subcommands/push.ts"; import restart from "./src/subcommands/restart.ts"; import rm from "./src/subcommands/rm.ts"; import rmi from "./src/subcommands/rmi.ts"; +import run from "./src/subcommands/run.ts"; import start from "./src/subcommands/start.ts"; import stop from "./src/subcommands/stop.ts"; import tag from "./src/subcommands/tag.ts"; @@ -80,6 +83,10 @@ if (import.meta.main) { "-p, --port-forward ", "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", ) + .option( + "--install", + "Persist changes to the VM disk image", + ) .example( "Default usage", "openbsd-up", @@ -96,6 +103,10 @@ if (import.meta.main) { "Download URL", "openbsd-up https://cdn.openbsd.org/pub/OpenBSD/7.8/amd64/install78.iso", ) + .example( + "From OCI Registry", + "openbsd-up ghcr.io/tsirysndr/openbsd:7.8", + ) .example( "List running VMs", "openbsd-up ps", @@ -122,6 +133,24 @@ if (import.meta.main) { ) .action(async (options: Options, input?: string) => { const program = Effect.gen(function* () { + if (input) { + const [image, archivePath] = yield* Effect.all([ + getImage(input), + pipe( + getImageArchivePath(input), + Effect.catchAll(() => Effect.succeed(null)), + ), + ]); + + if (image || archivePath) { + yield* Effect.tryPromise({ + try: () => run(input), + catch: () => {}, + }); + return; + } + } + const resolvedInput = handleInput(input); let isoPath: string | null = resolvedInput; @@ -273,16 +302,11 @@ if (import.meta.main) { ) .arguments(" ") .action(async (_options: unknown, vmName: string, image: string) => { - console.log( - `Tagging VM image of ${chalk.greenBright(vmName)} as ${ - chalk.greenBright(image) - }...`, - ); await tag(vmName, image); }) .command( "login", - "Login to an OCI-compliant registry, e.g., ghcr.io, docker.io (docker hub), etc.", + "Authenticate to an OCI-compliant registry, e.g., ghcr.io, docker.io (docker hub), etc.", ) .option("-u, --username ", "Registry username") .arguments("") @@ -300,7 +324,7 @@ if (import.meta.main) { } console.log( - `Logging in to registry ${chalk.greenBright(registry)} as ${ + `Authenticating to registry ${chalk.greenBright(registry)} as ${ chalk.greenBright(username) }...`, ); @@ -320,5 +344,31 @@ if (import.meta.main) { .action(async (_options: unknown, image: string) => { await rmi(image); }) + .command("run", "Create and run a VM from an image") + .arguments("") + .option("-c, --cpu ", "Type of CPU to emulate", { + default: "host", + }) + .option("-C, --cpus ", "Number of CPU cores", { + default: 2, + }) + .option("-m, --memory ", "Amount of memory for the VM", { + default: "2G", + }) + .option( + "-b, --bridge ", + "Name of the network bridge to use for networking (e.g., br0)", + ) + .option( + "-d, --detach", + "Run VM in the background and print VM name", + ) + .option( + "-p, --port-forward ", + "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", + ) + .action(async (_options: unknown, image: string) => { + await run(image); + }) .parse(Deno.args); } diff --git a/src/constants.ts b/src/constants.ts index 6b0fba7..dade86b 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -3,3 +3,4 @@ export const DB_PATH: string = `${CONFIG_DIR}/state.sqlite`; export const LOGS_DIR: string = `${CONFIG_DIR}/logs`; export const EMPTY_DISK_THRESHOLD_KB: number = 100; export const CONFIG_FILE_NAME: string = "vmconfig.toml"; +export const IMAGE_DIR: string = `${CONFIG_DIR}/images`; diff --git a/src/db.ts b/src/db.ts index f4c0e33..d506701 100644 --- a/src/db.ts +++ b/src/db.ts @@ -45,6 +45,7 @@ export type Image = { size: number; path: string; format: string; + digest?: string; createdAt?: string; }; diff --git a/src/images.ts b/src/images.ts index fc33d03..45dc1bc 100644 --- a/src/images.ts +++ b/src/images.ts @@ -24,7 +24,16 @@ export const getImage = ( ctx.db .selectFrom("images") .selectAll() - .where("id", "=", id) + .where((eb) => + eb.or([ + eb.and([ + eb("repository", "=", id.split(":")[0]), + eb("tag", "=", id.split(":")[1] || "latest"), + ]), + eb("id", "=", id), + eb("digest", "=", id), + ]) + ) .executeTakeFirst(), catch: (error) => new DbError({ @@ -47,6 +56,7 @@ export const saveImage = ( size: image.size, path: image.path, format: image.format, + digest: image.digest, }) ) .execute(), @@ -60,7 +70,16 @@ export const deleteImage = ( id: string, ): Effect.Effect => Effect.tryPromise({ - try: () => ctx.db.deleteFrom("images").where("id", "=", id).execute(), + try: () => + ctx.db.deleteFrom("images").where((eb) => + eb.or([ + eb.and([ + eb("repository", "=", id.split(":")[0]), + eb("tag", "=", id.split(":")[1] || "latest"), + ]), + eb("id", "=", id), + ]) + ).execute(), catch: (error) => new DbError({ message: error instanceof Error ? error.message : String(error), diff --git a/src/migrations.ts b/src/migrations.ts index dae824d..6175029 100644 --- a/src/migrations.ts +++ b/src/migrations.ts @@ -203,6 +203,21 @@ migrations["006"] = { }, }; +migrations["007"] = { + async up(db: Kysely): Promise { + await db.schema + .alterTable("images") + .addColumn("digest", "varchar") + .execute(); + }, + async down(db: Kysely): Promise { + await db.schema + .alterTable("images") + .dropColumn("digest") + .execute(); + }, +}; + export const migrateToLatest = async (db: Database): Promise => { const migrator = new Migrator({ db, provider: migrationProvider }); const { error } = await migrator.migrateToLatest(); diff --git a/src/oras.ts b/src/oras.ts index e8b9486..10b58e8 100644 --- a/src/oras.ts +++ b/src/oras.ts @@ -1,8 +1,32 @@ +import { createId } from "@paralleldrive/cuid2"; +import { basename, dirname } from "@std/path"; import chalk from "chalk"; -import { CONFIG_DIR } from "./mod.ts"; +import { Data, Effect, pipe } from "effect"; +import { IMAGE_DIR } from "./constants.ts"; +import { getImage, saveImage } from "./images.ts"; +import { CONFIG_DIR, failOnMissingImage } from "./mod.ts"; +import { du, getCurrentArch } from "./utils.ts"; const DEFAULT_ORAS_VERSION = "1.3.0"; +export class PushImageError extends Data.TaggedError("PushImageError")<{ + cause?: unknown; +}> {} + +export class PullImageError extends Data.TaggedError("PullImageError")<{ + cause?: unknown; +}> {} + +export class CreateDirectoryError + extends Data.TaggedError("CreateDirectoryError")<{ + cause?: unknown; + }> {} + +export class ImageAlreadyPulledError + extends Data.TaggedError("ImageAlreadyPulledError")<{ + name: string; + }> {} + export async function setupOrasBinary(): Promise { Deno.env.set( "PATH", @@ -96,3 +120,287 @@ export async function setupOrasBinary(): Promise { }`, ); } + +const archiveImage = (img: { path: string }) => + Effect.tryPromise({ + try: async () => { + console.log("Archiving image for push..."); + const tarProcess = new Deno.Command("tar", { + args: [ + "-cSzf", + `${img.path}.tar.gz`, + "-C", + dirname(img.path), + basename(img.path), + ], + stdout: "inherit", + stderr: "inherit", + }).spawn(); + + const tarStatus = await tarProcess.status; + if (!tarStatus.success) { + throw new Error(`Failed to create tar archive for image`); + } + return `${img.path}.tar.gz`; + }, + catch: (error: unknown) => + new PushImageError({ + cause: error instanceof Error ? error.message : String(error), + }), + }); + +const pushToRegistry = ( + img: { repository: string; tag: string; path: string }, +) => + Effect.tryPromise({ + try: async () => { + console.log(`Pushing image ${img.repository}...`); + const process = new Deno.Command("oras", { + args: [ + "push", + `${img.repository}:${img.tag}-${getCurrentArch()}`, + "--artifact-type", + "application/vnd.oci.image.layer.v1.tar", + "--annotation", + `org.opencontainers.image.architecture=${getCurrentArch()}`, + "--annotation", + "org.opencontainers.image.os=openbsd", + "--annotation", + "org.opencontainers.image.description=QEMU raw disk image", + basename(img.path), + ], + stdout: "inherit", + stderr: "inherit", + cwd: dirname(img.path), + }).spawn(); + + const { code } = await process.status; + if (code !== 0) { + throw new Error(`ORAS push failed with exit code ${code}`); + } + return img.path; + }, + catch: (error: unknown) => + new PushImageError({ + cause: error instanceof Error ? error.message : String(error), + }), + }); + +const cleanup = (path: string) => + Effect.tryPromise({ + try: () => Deno.remove(path), + catch: (error: unknown) => + new PushImageError({ + cause: error instanceof Error ? error.message : String(error), + }), + }); + +const createImageDirIfMissing = Effect.promise(() => + Deno.mkdir(IMAGE_DIR, { recursive: true }) +); + +const checkIfImageAlreadyPulled = (image: string) => + pipe( + getImageDigest(image), + Effect.flatMap(getImage), + Effect.flatMap((img) => { + if (img) { + return Effect.fail( + new ImageAlreadyPulledError({ name: image }), + ); + } + return Effect.succeed(void 0); + }), + ); + +export const pullFromRegistry = (image: string) => + pipe( + Effect.tryPromise({ + try: async () => { + console.log(`Pulling image ${image}`); + const process = new Deno.Command("oras", { + args: [ + "pull", + `${image}-${getCurrentArch()}`, + ], + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + cwd: IMAGE_DIR, + }).spawn(); + + const { code } = await process.status; + if (code !== 0) { + throw new Error(`ORAS pull failed with exit code ${code}`); + } + }, + catch: (error: unknown) => + new PullImageError({ + cause: error instanceof Error ? error.message : String(error), + }), + }), + ); + +export const getImageArchivePath = (image: string) => + Effect.tryPromise({ + try: async () => { + const process = new Deno.Command("oras", { + args: [ + "manifest", + "fetch", + `${image}-${getCurrentArch()}`, + ], + stdout: "piped", + stderr: "inherit", + }).spawn(); + + const { code, stdout } = await process.output(); + if (code !== 0) { + throw new Error(`ORAS manifest fetch failed with exit code ${code}`); + } + + const manifest = JSON.parse(new TextDecoder().decode(stdout)); + const layers = manifest.layers; + if (!layers || layers.length === 0) { + throw new Error(`No layers found in manifest for image ${image}`); + } + + if ( + !layers[0].annotations || + !layers[0].annotations["org.opencontainers.image.title"] + ) { + throw new Error( + `No title annotation found for layer in image ${image}`, + ); + } + + const path = `${IMAGE_DIR}/${ + layers[0].annotations["org.opencontainers.image.title"] + }`; + + if (!(await Deno.stat(path).catch(() => false))) { + throw new Error(`Image archive not found at expected path ${path}`); + } + + return path; + }, + catch: (error: unknown) => + new PullImageError({ + cause: error instanceof Error ? error.message : String(error), + }), + }); + +const getImageDigest = (image: string) => + Effect.tryPromise({ + try: async () => { + const process = new Deno.Command("oras", { + args: [ + "manifest", + "fetch", + `${image}-${getCurrentArch()}`, + ], + stdout: "piped", + stderr: "inherit", + }).spawn(); + + const { code, stdout } = await process.output(); + if (code !== 0) { + throw new Error(`ORAS manifest fetch failed with exit code ${code}`); + } + + const manifest = JSON.parse(new TextDecoder().decode(stdout)); + if (!manifest.layers[0] || !manifest.layers[0].digest) { + throw new Error(`No digest found in manifest for image ${image}`); + } + + return manifest.layers[0].digest as string; + }, + catch: (error: unknown) => + new PullImageError({ + cause: error instanceof Error ? error.message : String(error), + }), + }); + +const extractImage = (path: string) => + Effect.tryPromise({ + try: async () => { + console.log("Extracting image archive..."); + const tarProcess = new Deno.Command("tar", { + args: [ + "-xSzf", + path, + "-C", + dirname(path), + ], + stdout: "inherit", + stderr: "inherit", + cwd: IMAGE_DIR, + }).spawn(); + + const tarStatus = await tarProcess.status; + if (!tarStatus.success) { + throw new Error(`Failed to extract tar archive for image`); + } + return path.replace(/\.tar\.gz$/, ""); + }, + catch: (error: unknown) => + new PullImageError({ + cause: error instanceof Error ? error.message : String(error), + }), + }); + +const savePulledImage = ( + imagePath: string, + digest: string, + name: string, +) => + Effect.gen(function* () { + yield* saveImage({ + id: createId(), + repository: name.split(":")[0], + tag: name.split(":")[1] || "latest", + size: yield* du(imagePath), + path: imagePath, + format: imagePath.endsWith(".qcow2") ? "qcow2" : "raw", + digest, + }); + return `${imagePath}.tar.gz`; + }); + +export const pushImage = (image: string) => + pipe( + getImage(image), + Effect.flatMap(failOnMissingImage), + Effect.flatMap((img) => + pipe( + archiveImage(img), + Effect.tap((archivedPath) => { + img.path = archivedPath; + return Effect.succeed(void 0); + }), + Effect.flatMap(() => pushToRegistry(img)), + Effect.flatMap(cleanup), + ) + ), + ); + +export const pullImage = (image: string) => + pipe( + Effect.all([createImageDirIfMissing, checkIfImageAlreadyPulled(image)]), + Effect.flatMap(() => pullFromRegistry(image)), + Effect.flatMap(() => getImageArchivePath(image)), + Effect.flatMap(extractImage), + Effect.flatMap((imagePath: string) => + Effect.all([ + Effect.succeed(imagePath), + getImageDigest(image), + Effect.succeed(image), + ]) + ), + Effect.flatMap(([imagePath, digest, image]) => + savePulledImage(imagePath, digest, image) + ), + Effect.flatMap(cleanup), + Effect.catchTag("ImageAlreadyPulledError", () => + Effect.sync(() => console.log(`Image ${image} is already pulled.`))), + ); diff --git a/src/subcommands/pull.ts b/src/subcommands/pull.ts index f9675cb..a65b57a 100644 --- a/src/subcommands/pull.ts +++ b/src/subcommands/pull.ts @@ -1,5 +1,19 @@ -import { setupOrasBinary } from "../oras.ts"; +import { Effect, pipe } from "effect"; +import { pullImage, setupOrasBinary } from "../oras.ts"; +import { validateImage } from "../utils.ts"; -export default async function (name: string): Promise { - await setupOrasBinary(); +export default async function (image: string): Promise { + await Effect.runPromise( + pipe( + Effect.promise(() => setupOrasBinary()), + Effect.tap(() => validateImage(image)), + Effect.tap(() => pullImage(image)), + Effect.catchAll((error) => + Effect.sync(() => { + console.error(`Failed to pull image: ${error.cause}`); + Deno.exit(1); + }) + ), + ), + ); } diff --git a/src/subcommands/push.ts b/src/subcommands/push.ts index dd13900..fa495a0 100644 --- a/src/subcommands/push.ts +++ b/src/subcommands/push.ts @@ -1,5 +1,19 @@ -import { setupOrasBinary } from "../oras.ts"; +import { Effect, pipe } from "effect"; +import { pushImage, setupOrasBinary } from "../oras.ts"; +import { validateImage } from "../utils.ts"; export default async function (image: string): Promise { - await setupOrasBinary(); + await Effect.runPromise( + pipe( + Effect.promise(() => setupOrasBinary()), + Effect.tap(() => validateImage(image)), + Effect.tap(() => pushImage(image)), + Effect.catchAll((error) => + Effect.sync(() => { + console.error(`Failed to push image: ${error.cause}`); + Deno.exit(1); + }) + ), + ), + ); } diff --git a/src/subcommands/rmi.ts b/src/subcommands/rmi.ts index 2b2cf9e..78804c5 100644 --- a/src/subcommands/rmi.ts +++ b/src/subcommands/rmi.ts @@ -1,6 +1,20 @@ -import { Effect } from "effect"; -import { deleteImage } from "../images.ts"; +import { Effect, pipe } from "effect"; +import { deleteImage, getImage } from "../images.ts"; +import { failOnMissingImage } from "../utils.ts"; export default async function (id: string) { - await Effect.runPromise(deleteImage(id)); + await Effect.runPromise( + pipe( + getImage(id), + Effect.flatMap(failOnMissingImage), + Effect.tap(() => deleteImage(id)), + Effect.tap(() => console.log(`Image ${id} removed successfully.`)), + Effect.catchAll((error) => + Effect.sync(() => { + console.error(`Failed to remove image: ${error.message}`); + Deno.exit(1); + }) + ), + ), + ); } diff --git a/src/subcommands/run.ts b/src/subcommands/run.ts new file mode 100644 index 0000000..3134b87 --- /dev/null +++ b/src/subcommands/run.ts @@ -0,0 +1,73 @@ +import { parseFlags } from "@cliffy/flags"; +import { Effect, pipe } from "effect"; +import type { Image } from "../db.ts"; +import { getImage } from "../images.ts"; +import { createBridgeNetworkIfNeeded } from "../network.ts"; +import { pullImage, PullImageError, setupOrasBinary } from "../oras.ts"; +import { type Options, runQemu, validateImage } from "../utils.ts"; + +const pullImageOnMissing = ( + name: string, +): Effect.Effect => + pipe( + getImage(name), + Effect.flatMap((img) => { + if (img) { + return Effect.succeed(img); + } + console.log(`Image ${name} not found locally`); + return pipe( + pullImage(name), + Effect.flatMap(() => getImage(name)), + Effect.flatMap((pulledImg) => + pulledImg ? Effect.succeed(pulledImg) : Effect.fail( + new PullImageError({ cause: "Failed to pull image" }), + ) + ), + ); + }), + ); + +const runImage = (image: Image) => + Effect.gen(function* () { + console.log(`Running image ${image.repository}...`); + const options = mergeFlags(image); + if (options.bridge) { + yield* createBridgeNetworkIfNeeded(options.bridge); + } + yield* runQemu(null, options); + }); + +export default async function ( + image: string, +): Promise { + await Effect.runPromise( + pipe( + Effect.promise(() => setupOrasBinary()), + Effect.tap(() => validateImage(image)), + Effect.flatMap(() => pullImageOnMissing(image)), + Effect.flatMap(runImage), + Effect.catchAll((error) => + Effect.sync(() => { + console.error(`Failed to run image: ${error.cause} ${image}`); + Deno.exit(1); + }) + ), + ), + ); +} + +function mergeFlags(image: Image): Options { + const { flags } = parseFlags(Deno.args); + return { + cpu: flags.cpu ? flags.cpu : "host", + cpus: flags.cpus ? flags.cpus : 2, + memory: flags.memory ? flags.memory : "2G", + image: image.path, + bridge: flags.bridge, + portForward: flags.portForward, + detach: flags.detach, + install: false, + diskFormat: image.format, + }; +} diff --git a/src/subcommands/tag.ts b/src/subcommands/tag.ts index 1185bac..1191caf 100644 --- a/src/subcommands/tag.ts +++ b/src/subcommands/tag.ts @@ -2,12 +2,11 @@ import { createId } from "@paralleldrive/cuid2"; import { Effect, pipe } from "effect"; import { saveImage } from "../images.ts"; import { getInstanceState, type VirtualMachine } from "../mod.ts"; -import { du } from "../utils.ts"; +import { du, extractTag } from "../utils.ts"; -const extractTag = (name: string) => - Effect.sync(() => name.split(":")[1] || "latest"); - -const failIfNoVM = ([vm, name]: [VirtualMachine | undefined, string]) => +const failIfNoVM = ( + [vm, tag]: [VirtualMachine | undefined, string], +) => Effect.gen(function* () { if (!vm) { throw new Error(`VM with name ${name} not found`); @@ -18,7 +17,7 @@ const failIfNoVM = ([vm, name]: [VirtualMachine | undefined, string]) => const size = yield* du(vm.drivePath); - return [vm, name, size] as [VirtualMachine, string, number]; + return [vm, tag, size] as [VirtualMachine, string, number]; }); export default async function (name: string, image: string) { @@ -29,7 +28,7 @@ export default async function (name: string, image: string) { Effect.flatMap(([vm, tag, size]) => saveImage({ id: createId(), - repository: image, + repository: image.split(":")[0], tag, size, path: vm.drivePath!, @@ -38,7 +37,7 @@ export default async function (name: string, image: string) { ), Effect.catchAll((error) => Effect.sync(() => { - console.error(`Failed to tag image: ${error}`); + console.error(`Failed to tag image: ${error.cause}`); Deno.exit(1); }) ), diff --git a/src/utils.ts b/src/utils.ts index 40f30c7..158240f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,9 +1,10 @@ import _ from "@es-toolkit/es-toolkit/compat"; import { createId } from "@paralleldrive/cuid2"; import chalk from "chalk"; -import { Data, Effect } from "effect"; +import { Data, Effect, pipe } from "effect"; import Moniker from "moniker"; import { EMPTY_DISK_THRESHOLD_KB, LOGS_DIR } from "./constants.ts"; +import type { Image } from "./db.ts"; import { generateRandomMacAddress } from "./network.ts"; import { type DbError, @@ -19,17 +20,38 @@ export interface Options { cpus: number; memory: string; image?: string; - diskFormat: string; - size: string; + diskFormat?: string; + size?: string; bridge?: string; portForward?: string; detach?: boolean; + install?: boolean; } class LogCommandError extends Data.TaggedError("LogCommandError")<{ cause?: unknown; }> {} +class InvalidImageNameError extends Data.TaggedError("InvalidImageNameError")<{ + image: string; + cause?: unknown; +}> {} + +class NoSuchImageError extends Data.TaggedError("NoSuchImageError")<{ + cause: string; +}> {} + +export const getCurrentArch = (): string => { + switch (Deno.build.arch) { + case "x86_64": + return "amd64"; + case "aarch64": + return "arm64"; + default: + return Deno.build.arch; + } +}; + export const isValidISOurl = (url?: string): boolean => { return Boolean( (url?.startsWith("http://") || url?.startsWith("https://")) && @@ -37,19 +59,58 @@ export const isValidISOurl = (url?: string): boolean => { ); }; -export const humanFileSize = (bytes: number) => +export const humanFileSize = (blocks: number) => Effect.sync(() => { + const blockSize = 512; // bytes per block + let bytes = blocks * blockSize; const thresh = 1024; - if (Math.abs(bytes) < thresh) return bytes + "KB"; - const units = ["MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; + + if (Math.abs(bytes) < thresh) { + return `${bytes}B`; + } + + const units = ["KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; let u = -1; + do { bytes /= thresh; ++u; } while (Math.abs(bytes) >= thresh && u < units.length - 1); + return `${bytes.toFixed(1)}${units[u]}`; }); +export const validateImage = ( + 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._-]+)?$/; + + if (!regex.test(image)) { + return Effect.fail( + new InvalidImageNameError({ + image, + cause: + "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", + }), + ); + } + return Effect.succeed(image); +}; + +export const extractTag = (name: string) => + pipe( + validateImage(name), + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), + ); + +export const failOnMissingImage = ( + image: Image | undefined, +): Effect.Effect => + image + ? Effect.succeed(image) + : Effect.fail(new NoSuchImageError({ cause: "No such image" })); + export const du = ( path: string, ): Effect.Effect => @@ -268,6 +329,7 @@ export const runQemu = ( : setupNATNetworkArgs(options.portForward), "-device", `e1000,netdev=net0,mac=${macAddress}`, + ...(options.install ? [] : ["-snapshot"]), "-nographic", "-monitor", "none", @@ -322,8 +384,8 @@ export const runQemu = ( memory: options.memory, cpus: options.cpus, cpu: options.cpu, - diskSize: options.size, - diskFormat: options.diskFormat, + diskSize: options.size || "20G", + diskFormat: options.diskFormat || "raw", portForward: options.portForward, isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, drivePath: options.image ? Deno.realPathSync(options.image) : undefined, @@ -356,8 +418,8 @@ export const runQemu = ( memory: options.memory, cpus: options.cpus, cpu: options.cpu, - diskSize: options.size, - diskFormat: options.diskFormat, + diskSize: options.size || "20G", + diskFormat: options.diskFormat || "raw", portForward: options.portForward, isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, drivePath: options.image ? Deno.realPathSync(options.image) : undefined, @@ -493,7 +555,7 @@ export const createDriveImageIfNeeded = ( const status = yield* Effect.tryPromise({ try: async () => { const cmd = new Deno.Command("qemu-img", { - args: ["create", "-f", format, path!, size!], + args: ["create", "-f", format || "raw", path!, size || "20G"], stdin: "inherit", stdout: "inherit", stderr: "inherit",