87 lines
6.2 KiB
JavaScript
87 lines
6.2 KiB
JavaScript
import { readFileSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { sha256 } from "./release-bundle.mjs";
|
|
|
|
const accept = "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json";
|
|
export async function boundedFetch(url, options = {}, timeout = 30_000) {
|
|
try { return await fetch(url, { ...options, redirect: options.redirect ?? "error", signal: AbortSignal.timeout(timeout) }); }
|
|
catch { throw new Error("Release network request failed; retry with the same output directory."); }
|
|
}
|
|
export async function readLimited(response, maximum) {
|
|
const chunks = []; let size = 0;
|
|
for await (const chunk of response.body) { size += chunk.length; if (size > maximum) throw new Error("Release response exceeded its bound."); chunks.push(chunk); }
|
|
return Buffer.concat(chunks);
|
|
}
|
|
async function jsonResponse(response, description) {
|
|
if (!response.ok) throw new Error(`${description} refused (HTTP ${response.status}).`);
|
|
const bytes = await readLimited(response, 4 * 2 ** 20);
|
|
try { return { bytes, value: JSON.parse(bytes.toString()) }; }
|
|
catch { throw new Error("Release service returned invalid metadata."); }
|
|
}
|
|
export async function dockerCredentials(run) {
|
|
const config = JSON.parse(readFileSync(join(process.env.DOCKER_CONFIG || join(homedir(), ".docker"), "config.json"), "utf8"));
|
|
const server = "https://index.docker.io/v1/";
|
|
const helper = config.credHelpers?.[server] || config.credsStore;
|
|
if (!helper || !/^[A-Za-z0-9._-]+$/.test(helper)) throw new Error("Use docker login with an OS credential store before publishing.");
|
|
const auth = JSON.parse(await run(`docker-credential-${helper}`, ["get"], { input: server + "\n", quiet: true }));
|
|
if (!auth.Username || !auth.Secret) throw new Error("Docker Hub login is unavailable.");
|
|
return auth;
|
|
}
|
|
export function dockerHub(auth) {
|
|
async function token(repository, anonymous) {
|
|
const url = new URL("https://auth.docker.io/token");
|
|
url.searchParams.set("service", "registry.docker.io");
|
|
url.searchParams.set("scope", `repository:${repository}:pull`);
|
|
const response = await boundedFetch(url, { headers: anonymous ? {} : { Authorization: `Basic ${Buffer.from(`${auth.Username}:${auth.Secret}`).toString("base64")}` } });
|
|
return (await jsonResponse(response, "Registry authentication")).value.token;
|
|
}
|
|
async function registryJSON(repository, route, anonymous, allowMissing = false) {
|
|
const bearer = await token(repository, anonymous);
|
|
let response = await boundedFetch(`https://registry-1.docker.io/v2/${repository}/${route}`, { redirect: "manual", headers: { Accept: accept, Authorization: `Bearer ${bearer}` } });
|
|
if ([302, 307].includes(response.status) && route.startsWith("blobs/")) {
|
|
const target = new URL(response.headers.get("location"));
|
|
if (target.protocol !== "https:" || target.username || target.password) throw new Error("Invalid registry blob redirect.");
|
|
// Signed blob URLs are fetched without forwarding registry credentials.
|
|
response = await boundedFetch(target);
|
|
}
|
|
if (allowMissing && response.status === 404) return null;
|
|
const { bytes, value } = await jsonResponse(response, "Registry read");
|
|
const digest = `sha256:${sha256(bytes)}`;
|
|
const advertised = response.headers.get("docker-content-digest");
|
|
if (advertised && advertised !== digest) throw new Error("Registry content digest mismatch.");
|
|
return { value, digest };
|
|
}
|
|
return {
|
|
async ensurePublic(namespace, name) {
|
|
const response = await boundedFetch("https://hub.docker.com/v2/auth/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identifier: auth.Username, secret: auth.Secret }) });
|
|
const bearer = (await jsonResponse(response, "Docker Hub authentication")).value.access_token;
|
|
const headers = { Authorization: `Bearer ${bearer}`, "Content-Type": "application/json" };
|
|
const path = `https://hub.docker.com/v2/namespaces/${namespace}/repositories`;
|
|
let existing = await boundedFetch(`${path}/${name}`, { headers });
|
|
if (existing.status === 404) {
|
|
existing = await boundedFetch(path, { method: "POST", headers, body: JSON.stringify({ namespace, name, registry: "docker.io", is_private: false, description: `ThothII ${name.endsWith("core") ? "core with embedded Pi" : "standalone frontend"}` }) });
|
|
}
|
|
const data = (await jsonResponse(existing, "Public repository preparation")).value;
|
|
if (data.is_private !== false) throw new Error("Selected Docker Hub repository is private; make this release repository public before retrying.");
|
|
},
|
|
async inspect(repository, reference, platform, { anonymous = false, allowMissing = false } = {}) {
|
|
let image = await registryJSON(repository, `manifests/${reference}`, anonymous, allowMissing);
|
|
if (!image) return null;
|
|
if (reference.startsWith("sha256:") && image.digest !== reference) throw new Error("Requested image digest does not match registry content.");
|
|
if (image.value.manifests) {
|
|
const match = image.value.manifests.find((entry) => `${entry.platform?.os}/${entry.platform?.architecture}` === platform);
|
|
if (!match || !/^sha256:[a-f0-9]{64}$/.test(match.digest)) throw new Error("Image does not contain the requested platform.");
|
|
image = await registryJSON(repository, `manifests/${match.digest}`, anonymous);
|
|
if (image.digest !== match.digest) throw new Error("Image index digest mismatch.");
|
|
}
|
|
if (reference.startsWith("sha256:") && !image.value.config) throw new Error("Image metadata is incomplete.");
|
|
const configDigest = image.value.config?.digest;
|
|
if (!/^sha256:[a-f0-9]{64}$/.test(configDigest ?? "")) throw new Error("Image configuration digest is invalid.");
|
|
const config = await registryJSON(repository, `blobs/${configDigest}`, anonymous);
|
|
if (config.digest !== configDigest || `${config.value.os}/${config.value.architecture}` !== platform) throw new Error("Image configuration or platform mismatch.");
|
|
return { reference: `docker.io/${repository}@${image.digest}`, labels: config.value.config?.Labels ?? {} };
|
|
},
|
|
};
|
|
}
|