A convenient CLI tool to quickly spin up DragonflyBSD virtual machines using QEMU with sensible defaults.
at main 420 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 } 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}-amd64`, 168 "--artifact-type", 169 "application/vnd.oci.image.layer.v1.tar", 170 "--annotation", 171 "org.opencontainers.image.architecture=amd64", 172 "--annotation", 173 "org.opencontainers.image.os=dragonflybsd", 174 "--annotation", 175 "org.opencontainers.image.description=QEMU raw disk image for DragonFly BSD", 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("pull", `${formatRepository(repository)}:${tag}-amd64`); 230 231 const process = new Deno.Command("oras", { 232 args: [ 233 "pull", 234 `${formatRepository(repository)}:${tag}-amd64`, 235 ], 236 stdin: "inherit", 237 stdout: "inherit", 238 stderr: "inherit", 239 cwd: IMAGE_DIR, 240 }).spawn(); 241 242 const { code } = await process.status; 243 if (code !== 0) { 244 throw new Error(`ORAS pull failed with exit code ${code}`); 245 } 246 }, 247 catch: (error: unknown) => 248 new PullImageError({ 249 cause: error instanceof Error ? error.message : String(error), 250 }), 251 }), 252 ); 253 254export const getImageArchivePath = (image: string) => 255 Effect.tryPromise({ 256 try: async () => { 257 const repository = image.split(":")[0]; 258 const tag = image.split(":")[1] || "latest"; 259 const process = new Deno.Command("oras", { 260 args: [ 261 "manifest", 262 "fetch", 263 `${formatRepository(repository)}:${tag}-amd64`, 264 ], 265 stdout: "piped", 266 stderr: "inherit", 267 }).spawn(); 268 269 const { code, stdout } = await process.output(); 270 if (code !== 0) { 271 throw new Error(`ORAS manifest fetch failed with exit code ${code}`); 272 } 273 274 const manifest = JSON.parse(new TextDecoder().decode(stdout)); 275 const layers = manifest.layers; 276 if (!layers || layers.length === 0) { 277 throw new Error(`No layers found in manifest for image ${image}`); 278 } 279 280 if ( 281 !layers[0].annotations || 282 !layers[0].annotations["org.opencontainers.image.title"] 283 ) { 284 throw new Error( 285 `No title annotation found for layer in image ${image}`, 286 ); 287 } 288 289 const path = `${IMAGE_DIR}/${ 290 layers[0].annotations["org.opencontainers.image.title"] 291 }`; 292 293 if (!(await Deno.stat(path).catch(() => false))) { 294 throw new Error(`Image archive not found at expected path ${path}`); 295 } 296 297 return path; 298 }, 299 catch: (error: unknown) => 300 new PullImageError({ 301 cause: error instanceof Error ? error.message : String(error), 302 }), 303 }); 304 305const getImageDigest = (image: string) => 306 Effect.tryPromise({ 307 try: async () => { 308 const repository = image.split(":")[0]; 309 const tag = image.split(":")[1] || "latest"; 310 const process = new Deno.Command("oras", { 311 args: [ 312 "manifest", 313 "fetch", 314 `${formatRepository(repository)}:${tag}-amd64`, 315 ], 316 stdout: "piped", 317 stderr: "inherit", 318 }).spawn(); 319 320 const { code, stdout } = await process.output(); 321 if (code !== 0) { 322 throw new Error(`ORAS manifest fetch failed with exit code ${code}`); 323 } 324 325 const manifest = JSON.parse(new TextDecoder().decode(stdout)); 326 if (!manifest.layers[0] || !manifest.layers[0].digest) { 327 throw new Error(`No digest found in manifest for image ${image}`); 328 } 329 330 return manifest.layers[0].digest as string; 331 }, 332 catch: (error: unknown) => 333 new PullImageError({ 334 cause: error instanceof Error ? error.message : String(error), 335 }), 336 }); 337 338const extractImage = (path: string) => 339 Effect.tryPromise({ 340 try: async () => { 341 console.log("Extracting image archive..."); 342 const tarProcess = new Deno.Command("tar", { 343 args: [ 344 "-xSzf", 345 path, 346 "-C", 347 dirname(path), 348 ], 349 stdout: "inherit", 350 stderr: "inherit", 351 cwd: IMAGE_DIR, 352 }).spawn(); 353 354 const tarStatus = await tarProcess.status; 355 if (!tarStatus.success) { 356 throw new Error(`Failed to extract tar archive for image`); 357 } 358 return path.replace(/\.tar\.gz$/, ""); 359 }, 360 catch: (error: unknown) => 361 new PullImageError({ 362 cause: error instanceof Error ? error.message : String(error), 363 }), 364 }); 365 366const savePulledImage = ( 367 imagePath: string, 368 digest: string, 369 name: string, 370) => 371 Effect.gen(function* () { 372 yield* saveImage({ 373 id: createId(), 374 repository: name.split(":")[0], 375 tag: name.split(":")[1] || "latest", 376 size: yield* du(imagePath), 377 path: imagePath, 378 format: imagePath.endsWith(".qcow2") ? "qcow2" : "raw", 379 digest, 380 }); 381 return `${imagePath}.tar.gz`; 382 }); 383 384export const pushImage = (image: string) => 385 pipe( 386 getImage(image), 387 Effect.flatMap(failOnMissingImage), 388 Effect.flatMap((img) => 389 pipe( 390 archiveImage(img), 391 Effect.tap((archivedPath) => { 392 img.path = archivedPath; 393 return Effect.succeed(void 0); 394 }), 395 Effect.flatMap(() => pushToRegistry(img)), 396 Effect.flatMap(cleanup), 397 ) 398 ), 399 ); 400 401export const pullImage = (image: string) => 402 pipe( 403 Effect.all([createImageDirIfMissing, checkIfImageAlreadyPulled(image)]), 404 Effect.flatMap(() => pullFromRegistry(image)), 405 Effect.flatMap(() => getImageArchivePath(image)), 406 Effect.flatMap(extractImage), 407 Effect.flatMap((imagePath: string) => 408 Effect.all([ 409 Effect.succeed(imagePath), 410 getImageDigest(image), 411 Effect.succeed(image), 412 ]) 413 ), 414 Effect.flatMap(([imagePath, digest, image]) => 415 savePulledImage(imagePath, digest, image) 416 ), 417 Effect.flatMap(cleanup), 418 Effect.catchTag("ImageAlreadyPulledError", () => 419 Effect.sync(() => console.log(`Image ${image} is already pulled.`))), 420 );