A Docker-like CLI and HTTP API for managing headless VMs
at main 383 lines 11 kB view raw
1import { createId } from "@paralleldrive/cuid2"; 2import { basename, dirname } from "@std/path"; 3import chalk from "chalk"; 4import { Effect, pipe } from "effect"; 5import { IMAGE_DIR } from "./constants.ts"; 6import { 7 ImageAlreadyPulledError, 8 PullImageError, 9 PushImageError, 10} from "./errors.ts"; 11import { getImage, saveImage } from "./images.ts"; 12import { CONFIG_DIR, failOnMissingImage } from "./mod.ts"; 13import { du, getCurrentArch } from "./utils.ts"; 14 15const DEFAULT_ORAS_VERSION = "1.3.0"; 16 17export async function setupOrasBinary(): Promise<void> { 18 Deno.env.set("PATH", `${CONFIG_DIR}/bin:${Deno.env.get("PATH")}`); 19 20 const oras = new Deno.Command("which", { 21 args: ["oras"], 22 stdout: "null", 23 stderr: "null", 24 }).spawn(); 25 26 const orasStatus = await oras.status; 27 if (orasStatus.success) { 28 return; 29 } 30 31 const version = Deno.env.get("ORAS_VERSION") || DEFAULT_ORAS_VERSION; 32 33 console.log(`Downloading ORAS version ${version}...`); 34 35 const os = Deno.build.os; 36 let arch = "amd64"; 37 38 if (Deno.build.arch === "aarch64") { 39 arch = "arm64"; 40 } 41 42 if (os !== "linux" && os !== "darwin") { 43 console.error("Unsupported OS. Please download ORAS manually."); 44 Deno.exit(1); 45 } 46 47 // https://github.com/oras-project/oras/releases/download/v1.3.0/oras_1.3.0_darwin_amd64.tar.gz 48 const downloadUrl = 49 `https://github.com/oras-project/oras/releases/download/v${version}/oras_${version}_${os}_${arch}.tar.gz`; 50 51 console.log(`Downloading ORAS from ${chalk.greenBright(downloadUrl)}`); 52 53 const downloadProcess = new Deno.Command("curl", { 54 args: ["-L", downloadUrl, "-o", `oras_${version}_${os}_${arch}.tar.gz`], 55 stdout: "inherit", 56 stderr: "inherit", 57 cwd: "/tmp", 58 }).spawn(); 59 60 const status = await downloadProcess.status; 61 if (!status.success) { 62 console.error("Failed to download ORAS binary."); 63 Deno.exit(1); 64 } 65 66 console.log("Extracting ORAS binary..."); 67 68 const extractProcess = new Deno.Command("tar", { 69 args: ["-xzf", `oras_${version}_${os}_${arch}.tar.gz`, "-C", "./"], 70 stdout: "inherit", 71 stderr: "inherit", 72 cwd: "/tmp", 73 }).spawn(); 74 75 const extractStatus = await extractProcess.status; 76 if (!extractStatus.success) { 77 console.error("Failed to extract ORAS binary."); 78 Deno.exit(1); 79 } 80 81 await Deno.remove(`/tmp/oras_${version}_${os}_${arch}.tar.gz`); 82 83 await Deno.mkdir(`${CONFIG_DIR}/bin`, { recursive: true }); 84 85 await Deno.rename(`/tmp/oras`, `${CONFIG_DIR}/bin/oras`); 86 await Deno.chmod(`${CONFIG_DIR}/bin/oras`, 0o755); 87 88 console.log( 89 `ORAS binary installed at ${chalk.greenBright(`${CONFIG_DIR}/bin/oras`)}`, 90 ); 91} 92 93const archiveImage = (img: { path: string }) => 94 Effect.tryPromise({ 95 try: async () => { 96 console.log("Archiving image for push..."); 97 const tarProcess = new Deno.Command("tar", { 98 args: [ 99 "-cSzf", 100 `${img.path}.tar.gz`, 101 "-C", 102 dirname(img.path), 103 basename(img.path), 104 ], 105 stdout: "inherit", 106 stderr: "inherit", 107 }).spawn(); 108 109 const tarStatus = await tarProcess.status; 110 if (!tarStatus.success) { 111 throw new Error(`Failed to create tar archive for image`); 112 } 113 return `${img.path}.tar.gz`; 114 }, 115 catch: (error: unknown) => 116 new PushImageError({ 117 cause: error instanceof Error ? error.message : String(error), 118 }), 119 }); 120 121// add docker.io/ if no registry is specified 122const formatRepository = (repository: string) => 123 repository.match(/^[^\/]+\.[^\/]+\/.*/i) 124 ? repository 125 : `docker.io/${repository}`; 126 127const pushToRegistry = (img: { 128 repository: string; 129 tag: string; 130 path: string; 131}) => 132 Effect.tryPromise({ 133 try: async () => { 134 console.log(`Pushing image ${formatRepository(img.repository)}...`); 135 const process = new Deno.Command("oras", { 136 args: [ 137 "push", 138 `${formatRepository(img.repository)}:${img.tag}-${getCurrentArch()}`, 139 "--artifact-type", 140 "application/vnd.oci.image.layer.v1.tar", 141 "--annotation", 142 `org.opencontainers.image.architecture=${getCurrentArch()}`, 143 "--annotation", 144 "org.opencontainers.image.os=linux", 145 "--annotation", 146 "org.opencontainers.image.description=QEMU raw disk image", 147 basename(img.path), 148 ], 149 stdout: "inherit", 150 stderr: "inherit", 151 cwd: dirname(img.path), 152 }).spawn(); 153 154 const { code } = await process.status; 155 if (code !== 0) { 156 throw new Error(`ORAS push failed with exit code ${code}`); 157 } 158 return img.path; 159 }, 160 catch: (error: unknown) => 161 new PushImageError({ 162 cause: error instanceof Error ? error.message : String(error), 163 }), 164 }); 165 166const cleanup = (path: string) => 167 Effect.tryPromise({ 168 try: () => Deno.remove(path), 169 catch: (error: unknown) => 170 new PushImageError({ 171 cause: error instanceof Error ? error.message : String(error), 172 }), 173 }); 174 175const createImageDirIfMissing = Effect.promise(() => 176 Deno.mkdir(IMAGE_DIR, { recursive: true }) 177); 178 179const checkIfImageAlreadyPulled = (image: string) => 180 pipe( 181 getImageDigest(image), 182 Effect.flatMap(getImage), 183 Effect.flatMap((img) => { 184 if (img) { 185 return Effect.fail(new ImageAlreadyPulledError({ name: image })); 186 } 187 return Effect.succeed(void 0); 188 }), 189 ); 190 191export const pullFromRegistry = (image: string) => 192 pipe( 193 Effect.tryPromise({ 194 try: async () => { 195 console.log(`Pulling image ${image}`); 196 const repository = image.split(":")[0]; 197 const tag = image.split(":")[1] || "latest"; 198 console.log( 199 "pull", 200 `${formatRepository(repository)}:${tag}-${getCurrentArch()}`, 201 ); 202 203 const process = new Deno.Command("oras", { 204 args: [ 205 "pull", 206 `${formatRepository(repository)}:${tag}-${getCurrentArch()}`, 207 ], 208 stdin: "inherit", 209 stdout: "inherit", 210 stderr: "inherit", 211 cwd: IMAGE_DIR, 212 }).spawn(); 213 214 const { code } = await process.status; 215 if (code !== 0) { 216 throw new Error(`ORAS pull failed with exit code ${code}`); 217 } 218 }, 219 catch: (error: unknown) => 220 new PullImageError({ 221 cause: error instanceof Error ? error.message : String(error), 222 }), 223 }), 224 ); 225 226export const getImageArchivePath = (image: string) => 227 Effect.tryPromise({ 228 try: async () => { 229 const repository = image.split(":")[0]; 230 const tag = image.split(":")[1] || "latest"; 231 const process = new Deno.Command("oras", { 232 args: [ 233 "manifest", 234 "fetch", 235 `${formatRepository(repository)}:${tag}-${getCurrentArch()}`, 236 ], 237 stdout: "piped", 238 stderr: "inherit", 239 }).spawn(); 240 241 const { code, stdout } = await process.output(); 242 if (code !== 0) { 243 throw new Error(`ORAS manifest fetch failed with exit code ${code}`); 244 } 245 246 const manifest = JSON.parse(new TextDecoder().decode(stdout)); 247 const layers = manifest.layers; 248 if (!layers || layers.length === 0) { 249 throw new Error(`No layers found in manifest for image ${image}`); 250 } 251 252 if ( 253 !layers[0].annotations || 254 !layers[0].annotations["org.opencontainers.image.title"] 255 ) { 256 throw new Error( 257 `No title annotation found for layer in image ${image}`, 258 ); 259 } 260 261 const path = `${IMAGE_DIR}/${ 262 layers[0].annotations["org.opencontainers.image.title"] 263 }`; 264 265 if (!(await Deno.stat(path).catch(() => false))) { 266 throw new Error(`Image archive not found at expected path ${path}`); 267 } 268 269 return path; 270 }, 271 catch: (error: unknown) => 272 new PullImageError({ 273 cause: error instanceof Error ? error.message : String(error), 274 }), 275 }); 276 277const getImageDigest = (image: string) => 278 Effect.tryPromise({ 279 try: async () => { 280 const repository = image.split(":")[0]; 281 const tag = image.split(":")[1] || "latest"; 282 const process = new Deno.Command("oras", { 283 args: [ 284 "manifest", 285 "fetch", 286 `${formatRepository(repository)}:${tag}-${getCurrentArch()}`, 287 ], 288 stdout: "piped", 289 stderr: "inherit", 290 }).spawn(); 291 292 const { code, stdout } = await process.output(); 293 if (code !== 0) { 294 throw new Error(`ORAS manifest fetch failed with exit code ${code}`); 295 } 296 297 const manifest = JSON.parse(new TextDecoder().decode(stdout)); 298 if (!manifest.layers[0] || !manifest.layers[0].digest) { 299 throw new Error(`No digest found in manifest for image ${image}`); 300 } 301 302 return manifest.layers[0].digest as string; 303 }, 304 catch: (error: unknown) => 305 new PullImageError({ 306 cause: error instanceof Error ? error.message : String(error), 307 }), 308 }); 309 310const extractImage = (path: string) => 311 Effect.tryPromise({ 312 try: async () => { 313 console.log("Extracting image archive..."); 314 const tarProcess = new Deno.Command("tar", { 315 args: ["-xSzf", path, "-C", dirname(path)], 316 stdout: "inherit", 317 stderr: "inherit", 318 cwd: IMAGE_DIR, 319 }).spawn(); 320 321 const tarStatus = await tarProcess.status; 322 if (!tarStatus.success) { 323 throw new Error(`Failed to extract tar archive for image`); 324 } 325 return path.replace(/\.tar\.gz$/, ""); 326 }, 327 catch: (error: unknown) => 328 new PullImageError({ 329 cause: error instanceof Error ? error.message : String(error), 330 }), 331 }); 332 333const savePulledImage = (imagePath: string, digest: string, name: string) => 334 Effect.gen(function* () { 335 yield* saveImage({ 336 id: createId(), 337 repository: name.split(":")[0], 338 tag: name.split(":")[1] || "latest", 339 size: yield* du(imagePath), 340 path: imagePath, 341 format: imagePath.endsWith(".qcow2") ? "qcow2" : "raw", 342 digest, 343 }); 344 return `${imagePath}.tar.gz`; 345 }); 346 347export const pushImage = (image: string) => 348 pipe( 349 getImage(image), 350 Effect.flatMap(failOnMissingImage), 351 Effect.flatMap((img) => 352 pipe( 353 archiveImage(img), 354 Effect.tap((archivedPath) => { 355 img.path = archivedPath; 356 return Effect.succeed(void 0); 357 }), 358 Effect.flatMap(() => pushToRegistry(img)), 359 Effect.flatMap(cleanup), 360 ) 361 ), 362 ); 363 364export const pullImage = (image: string) => 365 pipe( 366 Effect.all([createImageDirIfMissing, checkIfImageAlreadyPulled(image)]), 367 Effect.flatMap(() => pullFromRegistry(image)), 368 Effect.flatMap(() => getImageArchivePath(image)), 369 Effect.flatMap(extractImage), 370 Effect.flatMap((imagePath: string) => 371 Effect.all([ 372 Effect.succeed(imagePath), 373 getImageDigest(image), 374 Effect.succeed(image), 375 ]) 376 ), 377 Effect.flatMap(([imagePath, digest, image]) => 378 savePulledImage(imagePath, digest, image) 379 ), 380 Effect.flatMap(cleanup), 381 Effect.catchTag("ImageAlreadyPulledError", () => 382 Effect.sync(() => console.log(`Image ${image} is already pulled.`))), 383 );