A simple command-line tool to start NetBSD virtual machines using QEMU with sensible defaults.
at main 423 lines 12 kB view raw
1import { createId } from "@paralleldrive/cuid2"; 2import { basename, dirname } from "@std/path"; 3import chalk from "chalk"; 4import { Data, Effect, pipe } from "effect"; 5import { IMAGE_DIR } from "./constants.ts"; 6import { getImage, saveImage } from "./images.ts"; 7import { CONFIG_DIR, failOnMissingImage } from "./mod.ts"; 8import { du, getCurrentArch } from "./utils.ts"; 9 10const DEFAULT_ORAS_VERSION = "1.3.0"; 11 12export class PushImageError extends Data.TaggedError("PushImageError")<{ 13 cause?: unknown; 14}> {} 15 16export class PullImageError extends Data.TaggedError("PullImageError")<{ 17 cause?: unknown; 18}> {} 19 20export class CreateDirectoryError 21 extends Data.TaggedError("CreateDirectoryError")<{ 22 cause?: unknown; 23 }> {} 24 25export class ImageAlreadyPulledError 26 extends Data.TaggedError("ImageAlreadyPulledError")<{ 27 name: string; 28 }> {} 29 30export 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 124const 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// add docker.io/ if no registry is specified 153const formatRepository = (repository: string) => 154 repository.match(/^[^\/]+\.[^\/]+\/.*/i) 155 ? repository 156 : `docker.io/${repository}`; 157 158const pushToRegistry = ( 159 img: { repository: string; tag: string; path: string }, 160) => 161 Effect.tryPromise({ 162 try: async () => { 163 console.log(`Pushing image ${formatRepository(img.repository)}...`); 164 const process = new Deno.Command("oras", { 165 args: [ 166 "push", 167 `${formatRepository(img.repository)}:${img.tag}-${getCurrentArch()}`, 168 "--artifact-type", 169 "application/vnd.oci.image.layer.v1.tar", 170 "--annotation", 171 `org.opencontainers.image.architecture=${getCurrentArch()}`, 172 "--annotation", 173 "org.opencontainers.image.os=netbsd", 174 "--annotation", 175 "org.opencontainers.image.description=QEMU raw disk image of NetBSD", 176 basename(img.path), 177 ], 178 stdout: "inherit", 179 stderr: "inherit", 180 cwd: dirname(img.path), 181 }).spawn(); 182 183 const { code } = await process.status; 184 if (code !== 0) { 185 throw new Error(`ORAS push failed with exit code ${code}`); 186 } 187 return img.path; 188 }, 189 catch: (error: unknown) => 190 new PushImageError({ 191 cause: error instanceof Error ? error.message : String(error), 192 }), 193 }); 194 195const cleanup = (path: string) => 196 Effect.tryPromise({ 197 try: () => Deno.remove(path), 198 catch: (error: unknown) => 199 new PushImageError({ 200 cause: error instanceof Error ? error.message : String(error), 201 }), 202 }); 203 204const createImageDirIfMissing = Effect.promise(() => 205 Deno.mkdir(IMAGE_DIR, { recursive: true }) 206); 207 208const checkIfImageAlreadyPulled = (image: string) => 209 pipe( 210 getImageDigest(image), 211 Effect.flatMap(getImage), 212 Effect.flatMap((img) => { 213 if (img) { 214 return Effect.fail( 215 new ImageAlreadyPulledError({ name: image }), 216 ); 217 } 218 return Effect.succeed(void 0); 219 }), 220 ); 221 222export const pullFromRegistry = (image: string) => 223 pipe( 224 Effect.tryPromise({ 225 try: async () => { 226 console.log(`Pulling image ${image}`); 227 const repository = image.split(":")[0]; 228 const tag = image.split(":")[1] || "latest"; 229 console.log( 230 "pull", 231 `${formatRepository(repository)}:${tag}-${getCurrentArch()}`, 232 ); 233 234 const process = new Deno.Command("oras", { 235 args: [ 236 "pull", 237 `${formatRepository(repository)}:${tag}-${getCurrentArch()}`, 238 ], 239 stdin: "inherit", 240 stdout: "inherit", 241 stderr: "inherit", 242 cwd: IMAGE_DIR, 243 }).spawn(); 244 245 const { code } = await process.status; 246 if (code !== 0) { 247 throw new Error(`ORAS pull failed with exit code ${code}`); 248 } 249 }, 250 catch: (error: unknown) => 251 new PullImageError({ 252 cause: error instanceof Error ? error.message : String(error), 253 }), 254 }), 255 ); 256 257export const getImageArchivePath = (image: string) => 258 Effect.tryPromise({ 259 try: async () => { 260 const repository = image.split(":")[0]; 261 const tag = image.split(":")[1] || "latest"; 262 const process = new Deno.Command("oras", { 263 args: [ 264 "manifest", 265 "fetch", 266 `${formatRepository(repository)}:${tag}-${getCurrentArch()}`, 267 ], 268 stdout: "piped", 269 stderr: "inherit", 270 }).spawn(); 271 272 const { code, stdout } = await process.output(); 273 if (code !== 0) { 274 throw new Error(`ORAS manifest fetch failed with exit code ${code}`); 275 } 276 277 const manifest = JSON.parse(new TextDecoder().decode(stdout)); 278 const layers = manifest.layers; 279 if (!layers || layers.length === 0) { 280 throw new Error(`No layers found in manifest for image ${image}`); 281 } 282 283 if ( 284 !layers[0].annotations || 285 !layers[0].annotations["org.opencontainers.image.title"] 286 ) { 287 throw new Error( 288 `No title annotation found for layer in image ${image}`, 289 ); 290 } 291 292 const path = `${IMAGE_DIR}/${ 293 layers[0].annotations["org.opencontainers.image.title"] 294 }`; 295 296 if (!(await Deno.stat(path).catch(() => false))) { 297 throw new Error(`Image archive not found at expected path ${path}`); 298 } 299 300 return path; 301 }, 302 catch: (error: unknown) => 303 new PullImageError({ 304 cause: error instanceof Error ? error.message : String(error), 305 }), 306 }); 307 308const getImageDigest = (image: string) => 309 Effect.tryPromise({ 310 try: async () => { 311 const repository = image.split(":")[0]; 312 const tag = image.split(":")[1] || "latest"; 313 const process = new Deno.Command("oras", { 314 args: [ 315 "manifest", 316 "fetch", 317 `${formatRepository(repository)}:${tag}-${getCurrentArch()}`, 318 ], 319 stdout: "piped", 320 stderr: "inherit", 321 }).spawn(); 322 323 const { code, stdout } = await process.output(); 324 if (code !== 0) { 325 throw new Error(`ORAS manifest fetch failed with exit code ${code}`); 326 } 327 328 const manifest = JSON.parse(new TextDecoder().decode(stdout)); 329 if (!manifest.layers[0] || !manifest.layers[0].digest) { 330 throw new Error(`No digest found in manifest for image ${image}`); 331 } 332 333 return manifest.layers[0].digest as string; 334 }, 335 catch: (error: unknown) => 336 new PullImageError({ 337 cause: error instanceof Error ? error.message : String(error), 338 }), 339 }); 340 341const extractImage = (path: string) => 342 Effect.tryPromise({ 343 try: async () => { 344 console.log("Extracting image archive..."); 345 const tarProcess = new Deno.Command("tar", { 346 args: [ 347 "-xSzf", 348 path, 349 "-C", 350 dirname(path), 351 ], 352 stdout: "inherit", 353 stderr: "inherit", 354 cwd: IMAGE_DIR, 355 }).spawn(); 356 357 const tarStatus = await tarProcess.status; 358 if (!tarStatus.success) { 359 throw new Error(`Failed to extract tar archive for image`); 360 } 361 return path.replace(/\.tar\.gz$/, ""); 362 }, 363 catch: (error: unknown) => 364 new PullImageError({ 365 cause: error instanceof Error ? error.message : String(error), 366 }), 367 }); 368 369const savePulledImage = ( 370 imagePath: string, 371 digest: string, 372 name: string, 373) => 374 Effect.gen(function* () { 375 yield* saveImage({ 376 id: createId(), 377 repository: name.split(":")[0], 378 tag: name.split(":")[1] || "latest", 379 size: yield* du(imagePath), 380 path: imagePath, 381 format: imagePath.endsWith(".qcow2") ? "qcow2" : "raw", 382 digest, 383 }); 384 return `${imagePath}.tar.gz`; 385 }); 386 387export const pushImage = (image: string) => 388 pipe( 389 getImage(image), 390 Effect.flatMap(failOnMissingImage), 391 Effect.flatMap((img) => 392 pipe( 393 archiveImage(img), 394 Effect.tap((archivedPath) => { 395 img.path = archivedPath; 396 return Effect.succeed(void 0); 397 }), 398 Effect.flatMap(() => pushToRegistry(img)), 399 Effect.flatMap(cleanup), 400 ) 401 ), 402 ); 403 404export const pullImage = (image: string) => 405 pipe( 406 Effect.all([createImageDirIfMissing, checkIfImageAlreadyPulled(image)]), 407 Effect.flatMap(() => pullFromRegistry(image)), 408 Effect.flatMap(() => getImageArchivePath(image)), 409 Effect.flatMap(extractImage), 410 Effect.flatMap((imagePath: string) => 411 Effect.all([ 412 Effect.succeed(imagePath), 413 getImageDigest(image), 414 Effect.succeed(image), 415 ]) 416 ), 417 Effect.flatMap(([imagePath, digest, image]) => 418 savePulledImage(imagePath, digest, image) 419 ), 420 Effect.flatMap(cleanup), 421 Effect.catchTag("ImageAlreadyPulledError", () => 422 Effect.sync(() => console.log(`Image ${image} is already pulled.`))), 423 );