271 lines
7.9 KiB
TypeScript
271 lines
7.9 KiB
TypeScript
import { execFile as _execFile } from "node:child_process";
|
|
import { access, mkdir, open, rename, writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { tar2squashfs } from "./tar2squashfs.js";
|
|
import { subtle } from "node:crypto";
|
|
import { Writable } from "node:stream";
|
|
|
|
type RegistryName = string;
|
|
type RegistrySecret = string;
|
|
const getToken = memoizeDownloader(_getToken);
|
|
let squashfsDownloads = new Map<string, Promise<string>>();
|
|
|
|
const cacheDir = "cache/";
|
|
// {
|
|
// const image = "gitea.nulo.in/nulo/zulip-checkin-cyborg";
|
|
// const tag = "latest";
|
|
|
|
// await downloadImage(image, tag);
|
|
// }
|
|
|
|
export const CONFIG_PATH_IN_IMAGE = "/.fireactions-image-config.json";
|
|
|
|
export function parseImageRef(ref: string): { image: string; tag: string } {
|
|
const [image, tag] = ref.split(":");
|
|
if (!image || !tag) throw new Error("Invalid image ref " + ref);
|
|
return { image, tag };
|
|
}
|
|
|
|
export async function downloadImage(image: string, tag: string) {
|
|
await mkdir(cacheDir, { recursive: true });
|
|
const manifest = await getManifest(image, tag);
|
|
|
|
// sanity check
|
|
if (
|
|
!manifest.layers.every(
|
|
(layer) =>
|
|
layer.mediaType === "application/vnd.oci.image.layer.v1.tar" ||
|
|
layer.mediaType === "application/vnd.oci.image.layer.v1.tar+gzip"
|
|
)
|
|
) {
|
|
throw new Error("Unsupported layer");
|
|
}
|
|
|
|
console.debug(manifest);
|
|
|
|
const config = await jsonBlob(image, manifest.config.digest);
|
|
const squashfsFile = await saveSquashfs(
|
|
image,
|
|
manifest,
|
|
JSON.stringify(config, null, 2)
|
|
);
|
|
|
|
return { squashfsFile };
|
|
}
|
|
|
|
// https://stackoverflow.com/a/67600346
|
|
async function hashData(data: string, algorithm = "SHA-512"): Promise<string> {
|
|
const ec = new TextEncoder();
|
|
const digest: ArrayBuffer = await subtle.digest(algorithm, ec.encode(data));
|
|
const hashArray = Array.from(new Uint8Array(digest));
|
|
const hash = hashArray
|
|
.map((item) => item.toString(16).padStart(2, "0"))
|
|
.join("");
|
|
return hash;
|
|
}
|
|
|
|
async function saveSquashfs(
|
|
image: string,
|
|
manifest: Manifest,
|
|
configJson: string
|
|
): Promise<string> {
|
|
const manifestDigest = await hashData(JSON.stringify(manifest));
|
|
const key = `${image.replaceAll("/", "%")}#${manifestDigest}.squashfs`;
|
|
|
|
let p = squashfsDownloads.get(key);
|
|
if (!p) {
|
|
p = (async () => {
|
|
const output = join(cacheDir, key);
|
|
const progressFile = output + ".downloading";
|
|
try {
|
|
await access(output);
|
|
// ya está cacheado
|
|
} catch {
|
|
if (process.env.DEBUG_WRITE_BLOBS)
|
|
await Promise.all(
|
|
manifest.layers.map(async (layer) => {
|
|
const res = await getBlob(image, layer.digest);
|
|
const file = await open(layer.digest, "w");
|
|
await res.body!.pipeTo(Writable.toWeb(file.createWriteStream()));
|
|
})
|
|
);
|
|
const layerStreams = manifest.layers.map(async (layer) => {
|
|
const res = await getBlob(image, layer.digest);
|
|
return res.body!;
|
|
});
|
|
await tar2squashfs(layerStreams, progressFile, [
|
|
{
|
|
content: configJson,
|
|
headers: {
|
|
name: CONFIG_PATH_IN_IMAGE,
|
|
uid: 0,
|
|
gid: 0,
|
|
mode: 0o400,
|
|
},
|
|
},
|
|
]);
|
|
await rename(progressFile, output);
|
|
}
|
|
return output;
|
|
})();
|
|
squashfsDownloads.set(key, p);
|
|
}
|
|
|
|
return p;
|
|
}
|
|
|
|
async function _getToken(registryUrl: string): Promise<RegistrySecret> {
|
|
const res = await fetch(`${registryUrl}/token`);
|
|
const json = await res.json();
|
|
if (
|
|
!json ||
|
|
typeof json !== "object" ||
|
|
!("token" in json) ||
|
|
typeof json.token !== "string"
|
|
)
|
|
throw new Error("Unexpected");
|
|
return json.token;
|
|
}
|
|
|
|
async function call(registryName: string, path: string, init: RequestInit) {
|
|
const registryUrl = `https://${registryName}/v2`;
|
|
const token = await getToken(registryUrl);
|
|
console.debug(path);
|
|
const res = await fetch(`${registryUrl}${path}`, {
|
|
...init,
|
|
headers: {
|
|
...init.headers,
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
});
|
|
if (!res.ok) throw new Error(res.statusText);
|
|
return res;
|
|
}
|
|
|
|
// https://github.com/opencontainers/image-spec/blob/v1.0.1/image-index.md
|
|
type ManifestIndex = {
|
|
schemaVersion: 2;
|
|
mediaType: "application/vnd.oci.image.index.v1+json";
|
|
manifests: ManifestPointer[];
|
|
};
|
|
type DockerManifestV2 = {
|
|
schemaVersion: 2;
|
|
mediaType: "application/vnd.docker.distribution.manifest.v2+json";
|
|
config: Descriptor;
|
|
layers: DockerManifestV2Layer[];
|
|
};
|
|
|
|
type ManifestPointer = {
|
|
mediaType: "application/vnd.oci.image.manifest.v1+json";
|
|
digest: string;
|
|
size: number;
|
|
platform: {
|
|
architecture: "amd64" | string;
|
|
os: string;
|
|
};
|
|
annotations?: { [k: string]: string };
|
|
};
|
|
type DockerManifestV2Layer = {
|
|
mediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip";
|
|
} & Descriptor;
|
|
|
|
function getImageParts(image: string): [string, string] {
|
|
const parts = image.split("/");
|
|
const registryName = parts[0];
|
|
const imgPath = parts.slice(1).join("/");
|
|
return [registryName, imgPath];
|
|
}
|
|
|
|
async function getManifest(image: string, tag: string): Promise<Manifest> {
|
|
const [registryName, imgPath] = getImageParts(image);
|
|
const res = await call(registryName, `/${imgPath}/manifests/${tag}`, {
|
|
headers: {
|
|
Accept: "application/vnd.oci.image.index.v1+json",
|
|
},
|
|
});
|
|
const json = (await res.json()) as object;
|
|
if (!("mediaType" in json)) throw new Error(`Received invalid manifest`);
|
|
|
|
if (
|
|
json.mediaType === "application/vnd.docker.distribution.manifest.v2+json"
|
|
) {
|
|
const manifest = json as DockerManifestV2;
|
|
return {
|
|
mediaType: "application/vnd.oci.image.manifest.v1+json",
|
|
config: {
|
|
...manifest.config,
|
|
mediaType: "application/vnd.oci.image.config.v1+json",
|
|
},
|
|
layers: manifest.layers.map((l) => ({
|
|
...l,
|
|
mediaType: "application/vnd.oci.image.layer.v1.tar+gzip",
|
|
})),
|
|
};
|
|
} else if (json.mediaType === "application/vnd.oci.image.manifest.v1+json") {
|
|
return json as Manifest;
|
|
} else if (json.mediaType === "application/vnd.oci.image.index.v1+json") {
|
|
const index = json as ManifestIndex;
|
|
// XXX: hardcoded amd64
|
|
const arch = "amd64";
|
|
const manifestPtr = chooseManifest(index, arch);
|
|
if (!manifestPtr) throw new Error(`No manifest for arch ${arch}`);
|
|
|
|
const manifest = await jsonBlob<Manifest>(image, manifestPtr.digest);
|
|
return manifest;
|
|
} else throw new Error(`Received invalid manifest`);
|
|
}
|
|
|
|
function chooseManifest(list: ManifestIndex, arch: string) {
|
|
console.debug(list);
|
|
return list.manifests.find(
|
|
(m) => m.platform.architecture === arch && m.platform.os === "linux"
|
|
);
|
|
}
|
|
|
|
async function getBlob(image: string, digest: string): Promise<Response> {
|
|
const [registryName, imgPath] = getImageParts(image);
|
|
return await call(registryName, `/${imgPath}/blobs/${digest}`, {});
|
|
}
|
|
async function jsonBlob<T>(image: string, digest: string): Promise<T> {
|
|
const res = await getBlob(image, digest);
|
|
return (await res.json()) as T;
|
|
}
|
|
|
|
function memoizeDownloader<T>(
|
|
downloader: (id: string) => Promise<T>
|
|
): (id: string) => Promise<T> {
|
|
let map = new Map<string, Promise<T>>();
|
|
|
|
return (id) => {
|
|
let p = map.get(id);
|
|
if (!p) {
|
|
p = downloader(id);
|
|
map.set(id, p);
|
|
}
|
|
return p;
|
|
};
|
|
}
|
|
|
|
// https://github.com/opencontainers/image-spec/blob/v1.0.1/descriptor.md#properties
|
|
type Descriptor = {
|
|
digest: string;
|
|
size: number;
|
|
urls?: string[];
|
|
annotations?: { [k: string]: string };
|
|
};
|
|
|
|
// https://github.com/opencontainers/image-spec/blob/v1.0.1/manifest.md
|
|
type Manifest = {
|
|
mediaType: "application/vnd.oci.image.manifest.v1+json";
|
|
config: {
|
|
mediaType: "application/vnd.oci.image.config.v1+json";
|
|
} & Descriptor;
|
|
layers: ({
|
|
mediaType:
|
|
| "application/vnd.oci.image.layer.v1.tar"
|
|
| "application/vnd.oci.image.layer.v1.tar+gzip"
|
|
| "application/vnd.oci.image.layer.nondistributable.v1.tar"
|
|
| "application/vnd.oci.image.layer.nondistributable.v1.tar+gzip";
|
|
} & Descriptor)[];
|
|
};
|