199 lines
5.7 KiB
TypeScript
199 lines
5.7 KiB
TypeScript
import { execFile as _execFile } from "node:child_process";
|
|
import { mkdtemp, open, rm } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import { Writable } from "node:stream";
|
|
import { promisify } from "node:util";
|
|
import { tar2squashfs } from "./tar2squashfs.js";
|
|
const execFile = promisify(_execFile);
|
|
|
|
type RegistryName = string;
|
|
type RegistrySecret = string;
|
|
let tokenCache = new Map<RegistryName, Promise<RegistrySecret>>();
|
|
|
|
const tmp = await mkdtemp("sexy-chambelan-");
|
|
try {
|
|
console.debug(tmp);
|
|
|
|
const image = "gitea.nulo.in/nulo/zulip-checkin-cyborg";
|
|
const tag = "latest";
|
|
const arch = "amd64";
|
|
|
|
const manifest = await getContainerManifest(image, tag);
|
|
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 layers = await Promise.all(
|
|
manifest.layers.map(async (layer) => {
|
|
const path = join(tmp, layer.digest);
|
|
await downloadBlob(image, layer.digest, path);
|
|
return { layer, path };
|
|
})
|
|
);
|
|
const squashfs = `${image.replaceAll("/", "%")}.squashfs`;
|
|
await rm(squashfs, { force: true });
|
|
await tar2squashfs(
|
|
layers.map(async ({ path }) => {
|
|
const f = await open(path);
|
|
return f.createReadStream();
|
|
}),
|
|
squashfs
|
|
);
|
|
await downloadBlob(
|
|
image,
|
|
manifest.config.digest,
|
|
`${image.replaceAll("/", "%")}.config.json`
|
|
);
|
|
process.exit(1);
|
|
} finally {
|
|
await rm(tmp, { recursive: true });
|
|
}
|
|
|
|
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`;
|
|
let tokenPromise = tokenCache.get(registryName);
|
|
if (!tokenPromise) {
|
|
tokenPromise = _getToken(registryUrl);
|
|
tokenCache.set(registryName, tokenPromise);
|
|
}
|
|
const token = await tokenPromise;
|
|
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;
|
|
}
|
|
|
|
type ManifestPointer = {
|
|
mediaType: "application/vnd.oci.image.manifest.v1+json";
|
|
digest: string;
|
|
size: number;
|
|
platform: {
|
|
architecture: "amd64" | string;
|
|
os: string;
|
|
};
|
|
annotations?: { [k: string]: string };
|
|
};
|
|
|
|
// https://github.com/opencontainers/image-spec/blob/v1.0.1/image-index.md
|
|
type ManifestIndex = {
|
|
mediaType: "application/vnd.oci.image.index.v1+json";
|
|
schemaVersion: 2;
|
|
manifests: ManifestPointer[];
|
|
};
|
|
|
|
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 getContainerManifest(image: string, tag: string) {
|
|
const index = await getManifestIndex(image, tag);
|
|
// badly behaved registries will return whatever they want
|
|
if (
|
|
(index.mediaType as string) === "application/vnd.oci.image.manifest.v1+json"
|
|
) {
|
|
return index as unknown as Manifest;
|
|
}
|
|
const arch = "amd64";
|
|
const ptr = chooseManifest(index, arch);
|
|
if (!ptr)
|
|
throw new Error(`Image ${image}:${tag} doesn't exist for arch ${arch}`);
|
|
const manifest = await getManifest(image, ptr);
|
|
return manifest;
|
|
}
|
|
|
|
async function getManifestIndex(
|
|
image: string,
|
|
tag: string
|
|
): Promise<ManifestIndex> {
|
|
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();
|
|
return json as ManifestIndex;
|
|
}
|
|
|
|
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;
|
|
}
|
|
async function downloadBlob(
|
|
image: string,
|
|
digest: string,
|
|
outputPath: string
|
|
): Promise<void> {
|
|
const res = await getBlob(image, digest);
|
|
const file = await open(outputPath, "w");
|
|
await res.body!.pipeTo(Writable.toWeb(file.createWriteStream()));
|
|
return;
|
|
}
|
|
|
|
// 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)[];
|
|
};
|
|
|
|
async function getManifest(image: string, ptr: ManifestPointer) {
|
|
return await jsonBlob<Manifest>(image, ptr.digest);
|
|
}
|