A Docker-like CLI and HTTP API for managing headless VMs
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 );