A convenient CLI tool to quickly spin up DragonflyBSD virtual machines using QEMU with sensible defaults.
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 );