| 1 | // The runner's image made in the registry, with no Docker: the base's |
| 2 | // manifest and config are read from Cloudflare's registry, one layer |
| 3 | // holding the runner binary is uploaded beside the base's layers, and a |
| 4 | // new config and manifest that name the base's layers and that one are |
| 5 | // written under the runner's tag. Nothing of the base is downloaded, so a |
| 6 | // deploy job needs no room for the 4.5 GB base, which a `docker build` |
| 7 | // pulls and unpacks on the same disk the job runs on. |
| 8 | // |
| 9 | // What it makes is what services/runner/Dockerfile builds on the same |
| 10 | // base: the binary at /usr/local/bin/g1t-runner with mode 0755 (COPY |
| 11 | // --chmod=0755), ENTRYPOINT ["g1t-runner"] with the base's CMD cleared |
| 12 | // (as a Dockerfile's ENTRYPOINT clears an inherited one), for linux/amd64. |
| 13 | // A test holds the Dockerfile to these. |
| 14 | // |
| 15 | // The calls are the OCI distribution API (docs.docker.com/registry/spec/api/), |
| 16 | // as `docker push` makes them: HEAD a blob to skip one already there; POST |
| 17 | // to start an upload, PATCH its bytes, PUT with its digest to finish; PUT |
| 18 | // the manifest by tag. |
| 19 | |
| 20 | import { createHash } from "node:crypto"; |
| 21 | import { gzipSync } from "node:zlib"; |
| 22 | |
| 23 | export const RUNNER_BINARY_PATH = "usr/local/bin/g1t-runner"; |
| 24 | export const RUNNER_BINARY_MODE = 0o755; |
| 25 | export const RUNNER_ENTRYPOINT = ["g1t-runner"]; |
| 26 | |
| 27 | export const OCI_MANIFEST = "application/vnd.oci.image.manifest.v1+json"; |
| 28 | export const DOCKER_MANIFEST = "application/vnd.docker.distribution.manifest.v2+json"; |
| 29 | const OCI_INDEX = "application/vnd.oci.image.index.v1+json"; |
| 30 | const DOCKER_LIST = "application/vnd.docker.distribution.manifest.list.v2+json"; |
| 31 | const ACCEPT = [OCI_MANIFEST, DOCKER_MANIFEST, OCI_INDEX, DOCKER_LIST].join(", "); |
| 32 | |
| 33 | /** Each manifest type's gzipped layer type. */ |
| 34 | const LAYER_TYPE = { |
| 35 | [OCI_MANIFEST]: "application/vnd.oci.image.layer.v1.tar+gzip", |
| 36 | [DOCKER_MANIFEST]: "application/vnd.docker.image.rootfs.diff.tar.gzip", |
| 37 | }; |
| 38 | |
| 39 | export const sha256 = (bytes) => `sha256:${createHash("sha256").update(bytes).digest("hex")}`; |
| 40 | |
| 41 | /** A tar header field: octal, zero-padded, ending in NUL. */ |
| 42 | function octal(value, width) { |
| 43 | return `${value.toString(8).padStart(width - 1, "0")}\0`; |
| 44 | } |
| 45 | |
| 46 | /** |
| 47 | * A tar archive (ustar) of one regular file, owned by root. Only the file: |
| 48 | * its folders are the base's, so a layer that names them would replace |
| 49 | * their owner, mode and time with its own. |
| 50 | */ |
| 51 | export function tarOf(path, data, { mode = 0o644, mtime = 0 } = {}) { |
| 52 | if (Buffer.byteLength(path) > 100) throw new Error(`a path this layer cannot hold: ${path}`); |
| 53 | const header = Buffer.alloc(512); |
| 54 | header.write(path, 0, 100, "utf8"); |
| 55 | header.write(octal(mode, 8), 100, 8, "ascii"); |
| 56 | header.write(octal(0, 8), 108, 8, "ascii"); // uid |
| 57 | header.write(octal(0, 8), 116, 8, "ascii"); // gid |
| 58 | header.write(octal(data.length, 12), 124, 12, "ascii"); |
| 59 | header.write(octal(Math.floor(mtime), 12), 136, 12, "ascii"); |
| 60 | header.write(" ", 148, 8, "ascii"); // the checksum, as spaces while it is summed |
| 61 | header.write("0", 156, 1, "ascii"); // a regular file |
| 62 | header.write("ustar\0", 257, 6, "ascii"); |
| 63 | header.write("00", 263, 2, "ascii"); |
| 64 | header.write("root", 265, 32, "ascii"); |
| 65 | header.write("root", 297, 32, "ascii"); |
| 66 | let sum = 0; |
| 67 | for (const byte of header) sum += byte; |
| 68 | header.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148, 8, "ascii"); |
| 69 | const padding = Buffer.alloc((512 - (data.length % 512)) % 512); |
| 70 | return Buffer.concat([header, data, padding, Buffer.alloc(1024)]); |
| 71 | } |
| 72 | |
| 73 | /** |
| 74 | * The runner's layer: the binary at its path and mode, gzipped. Returns |
| 75 | * the compressed bytes with their digest and size, and the uncompressed |
| 76 | * tar's digest (the config's diff_id). |
| 77 | */ |
| 78 | export function runnerLayer(binary, { mtime = 0 } = {}) { |
| 79 | const tar = tarOf(RUNNER_BINARY_PATH, binary, { mode: RUNNER_BINARY_MODE, mtime }); |
| 80 | const bytes = gzipSync(tar, { level: 9 }); |
| 81 | return { bytes, digest: sha256(bytes), size: bytes.length, diffId: sha256(tar) }; |
| 82 | } |
| 83 | |
| 84 | /** |
| 85 | * The runner image's config from the base's: one more layer, the |
| 86 | * entrypoint, and the history a Dockerfile build would add. |
| 87 | */ |
| 88 | export function runnerConfig(base, { diffId, created }) { |
| 89 | if (base.os !== "linux" || base.architecture !== "amd64") { |
| 90 | throw new Error(`the base is ${base.os}/${base.architecture}, not linux/amd64`); |
| 91 | } |
| 92 | const config = structuredClone(base); |
| 93 | config.created = created; |
| 94 | config.config = { ...(config.config ?? {}), Entrypoint: [...RUNNER_ENTRYPOINT] }; |
| 95 | // An ENTRYPOINT clears the CMD a Dockerfile inherited. |
| 96 | delete config.config.Cmd; |
| 97 | config.rootfs = { type: "layers", diff_ids: [...(base.rootfs?.diff_ids ?? []), diffId] }; |
| 98 | config.history = [ |
| 99 | ...(base.history ?? []), |
| 100 | { created, created_by: `COPY --chmod=0755 g1t-runner /${RUNNER_BINARY_PATH} # g1t deploy`, comment: "scripts/deploy/registry.mjs" }, |
| 101 | { created, created_by: `ENTRYPOINT ${JSON.stringify(RUNNER_ENTRYPOINT)}`, comment: "scripts/deploy/registry.mjs", empty_layer: true }, |
| 102 | ]; |
| 103 | return config; |
| 104 | } |
| 105 | |
| 106 | /** The runner image's manifest: the base's layers, then the runner's. */ |
| 107 | export function runnerManifest(base, { config, layer }) { |
| 108 | const type = manifestType(base); |
| 109 | if (!LAYER_TYPE[type]) throw new Error(`a manifest of a type this cannot extend: ${type}`); |
| 110 | const manifest = { |
| 111 | schemaVersion: 2, |
| 112 | mediaType: type, |
| 113 | config: { mediaType: base.config.mediaType, digest: config.digest, size: config.size }, |
| 114 | layers: [...base.layers, { mediaType: LAYER_TYPE[type], digest: layer.digest, size: layer.size }], |
| 115 | }; |
| 116 | return manifest; |
| 117 | } |
| 118 | |
| 119 | /** A manifest's type: its own, or (OCI lets it go unsaid) its config's. */ |
| 120 | export function manifestType(manifest) { |
| 121 | return manifest.mediaType ?? (manifest.config?.mediaType === "application/vnd.oci.image.config.v1+json" ? OCI_MANIFEST : DOCKER_MANIFEST); |
| 122 | } |
| 123 | |
| 124 | /** A `WWW-Authenticate: Bearer realm=...,service=...,scope=...` challenge, or null. */ |
| 125 | export function bearerChallenge(header) { |
| 126 | if (!header || !/^bearer\s/i.test(header)) return null; |
| 127 | const params = Object.fromEntries([...header.slice(7).matchAll(/(\w+)="([^"]*)"/g)].map((m) => [m[1].toLowerCase(), m[2]])); |
| 128 | return params.realm ? params : null; |
| 129 | } |
| 130 | |
| 131 | /** `host/name:tag`, `host/name@digest` or `host/name:tag@digest`, split. */ |
| 132 | export function parseRef(ref) { |
| 133 | const match = /^([^/]+)\/(.+?)(?::([^:/@]+))?(?:@(sha256:[0-9a-f]{64}))?$/.exec(ref); |
| 134 | if (!match || (!match[3] && !match[4])) throw new Error(`not an image reference with a tag or digest: ${ref}`); |
| 135 | const [, host, name, tag, digest] = match; |
| 136 | return { host, name, tag: tag ?? null, digest: digest ?? null }; |
| 137 | } |
| 138 | |
| 139 | /** |
| 140 | * A client for one repository: requests carry Basic auth from |
| 141 | * `credentials` (none when it returns null), or the bearer token a |
| 142 | * registry that asks for one gives for them; paths are under /v2/<name>/. |
| 143 | */ |
| 144 | export function repositoryClient(host, name, { credentials, fetchImpl = fetch, scheme = "https" }) { |
| 145 | const root = `${scheme}://${host}`; |
| 146 | let bearer = null; |
| 147 | const basic = async () => { |
| 148 | const auth = await credentials(); |
| 149 | return auth ? `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString("base64")}` : null; |
| 150 | }; |
| 151 | const once = async (method, url, headers, body) => { |
| 152 | const authorization = bearer ? `Bearer ${bearer}` : await basic(); |
| 153 | return fetchImpl(new URL(url, root).toString(), { method, headers: authorization ? { ...headers, authorization } : headers, body }); |
| 154 | }; |
| 155 | const send = async (method, url, { headers = {}, body } = {}) => { |
| 156 | const response = await once(method, url, headers, body); |
| 157 | const challenge = response.status === 401 ? bearerChallenge(response.headers.get("www-authenticate")) : null; |
| 158 | if (!challenge) return response; |
| 159 | // The token flow: the realm trades the credentials for a token. |
| 160 | const realm = new URL(challenge.realm); |
| 161 | if (challenge.service) realm.searchParams.set("service", challenge.service); |
| 162 | realm.searchParams.set("scope", challenge.scope ?? `repository:${name}:pull,push`); |
| 163 | const authorization = await basic(); |
| 164 | const got = await fetchImpl(realm.toString(), { method: "GET", headers: authorization ? { authorization } : {} }); |
| 165 | if (!got.ok) return response; |
| 166 | const json = await got.json(); |
| 167 | bearer = json.token ?? json.access_token ?? null; |
| 168 | return bearer ? once(method, url, headers, body) : response; |
| 169 | }; |
| 170 | const at = (path) => `${root}/v2/${name}/${path}`; |
| 171 | const fail = async (what, response) => { |
| 172 | let detail = ""; |
| 173 | try { |
| 174 | detail = (await response.text()).slice(0, 500); |
| 175 | } catch {} |
| 176 | return new Error(`${what}: the registry answered ${response.status}${detail ? ` ${detail}` : ""}`); |
| 177 | }; |
| 178 | return { |
| 179 | name, |
| 180 | async manifest(reference) { |
| 181 | const response = await send("GET", at(`manifests/${reference}`), { headers: { accept: ACCEPT } }); |
| 182 | if (!response.ok) throw await fail(`reading ${name} ${reference}`, response); |
| 183 | const bytes = Buffer.from(await response.arrayBuffer()); |
| 184 | return { bytes, digest: sha256(bytes), json: JSON.parse(bytes.toString("utf8")) }; |
| 185 | }, |
| 186 | async blob(digest) { |
| 187 | const response = await send("GET", at(`blobs/${digest}`)); |
| 188 | if (!response.ok) throw await fail(`reading blob ${digest}`, response); |
| 189 | const bytes = Buffer.from(await response.arrayBuffer()); |
| 190 | if (sha256(bytes) !== digest) throw new Error(`blob ${digest} came back as ${sha256(bytes)}`); |
| 191 | return bytes; |
| 192 | }, |
| 193 | async hasBlob(digest) { |
| 194 | const response = await send("HEAD", at(`blobs/${digest}`)); |
| 195 | if (response.status === 404) return false; |
| 196 | if (!response.ok) throw await fail(`asking for blob ${digest}`, response); |
| 197 | return true; |
| 198 | }, |
| 199 | /** Uploads `bytes` unless the repository has them. Returns whether it did. */ |
| 200 | async putBlob(bytes, digest = sha256(bytes)) { |
| 201 | if (await this.hasBlob(digest)) return false; |
| 202 | const started = await send("POST", at("blobs/uploads/")); |
| 203 | if (started.status !== 202) throw await fail(`starting an upload of ${digest}`, started); |
| 204 | const location = started.headers.get("location"); |
| 205 | if (!location) throw new Error(`the registry started an upload of ${digest} without saying where`); |
| 206 | const patched = await send("PATCH", location, { |
| 207 | headers: { "content-type": "application/octet-stream" }, |
| 208 | body: bytes, |
| 209 | }); |
| 210 | if (patched.status !== 202) throw await fail(`uploading ${digest}`, patched); |
| 211 | const done = new URL(patched.headers.get("location") ?? location, root); |
| 212 | done.searchParams.set("digest", digest); |
| 213 | const put = await send("PUT", done.toString()); |
| 214 | if (put.status !== 201) throw await fail(`finishing the upload of ${digest}`, put); |
| 215 | return true; |
| 216 | }, |
| 217 | async putManifest(tag, bytes, type) { |
| 218 | const response = await send("PUT", at(`manifests/${tag}`), { headers: { "content-type": type }, body: bytes }); |
| 219 | if (response.status !== 201) throw await fail(`writing ${name}:${tag}`, response); |
| 220 | return response.headers.get("docker-content-digest"); |
| 221 | }, |
| 222 | }; |
| 223 | } |
| 224 | |
| 225 | /** |
| 226 | * Makes the runner's image `ref` in the registry from the base `base` (by |
| 227 | * digest, as base.json records it) and the binary's bytes. Returns the new |
| 228 | * manifest's digest. The base must be in the same registry; its layers |
| 229 | * are named, not copied, so it must be in the same repository too. |
| 230 | */ |
| 231 | export async function appendRunnerImage({ |
| 232 | ref, |
| 233 | base, |
| 234 | binary, |
| 235 | credentials, |
| 236 | fetchImpl = fetch, |
| 237 | scheme = "https", |
| 238 | created = new Date().toISOString(), |
| 239 | onLine = () => {}, |
| 240 | attempts = 3, |
| 241 | wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), |
| 242 | }) { |
| 243 | const target = parseRef(ref); |
| 244 | const from = parseRef(base); |
| 245 | if (!target.tag) throw new Error(`the runner's image needs a tag: ${ref}`); |
| 246 | if (from.host !== target.host || from.name !== target.name) { |
| 247 | throw new Error(`the base ${base} is not in the runner's repository ${target.host}/${target.name}`); |
| 248 | } |
| 249 | const client = repositoryClient(target.host, target.name, { credentials, fetchImpl, scheme }); |
| 250 | |
| 251 | let manifest = await client.manifest(from.digest ?? from.tag); |
| 252 | if (from.digest && manifest.digest !== from.digest) throw new Error(`the base's manifest came back as ${manifest.digest}, not ${from.digest}`); |
| 253 | const listed = manifest.json.mediaType; |
| 254 | if (listed === OCI_INDEX || listed === DOCKER_LIST) { |
| 255 | // An index: its linux/amd64 image, as `docker build --platform linux/amd64` would pick. |
| 256 | const entry = manifest.json.manifests.find((m) => m.platform?.os === "linux" && m.platform?.architecture === "amd64"); |
| 257 | if (!entry) throw new Error(`the base ${base} has no linux/amd64 image`); |
| 258 | manifest = await client.manifest(entry.digest); |
| 259 | } |
| 260 | const baseConfig = JSON.parse((await client.blob(manifest.json.config.digest)).toString("utf8")); |
| 261 | // Every layer the new manifest names must be there. |
| 262 | for (const layer of manifest.json.layers) { |
| 263 | if (!(await client.hasBlob(layer.digest))) throw new Error(`the base's layer ${layer.digest} is not in ${target.name}`); |
| 264 | } |
| 265 | |
| 266 | const layer = runnerLayer(binary, { mtime: Date.parse(created) / 1000 }); |
| 267 | const configBytes = Buffer.from(JSON.stringify(runnerConfig(baseConfig, { diffId: layer.diffId, created }))); |
| 268 | const config = { digest: sha256(configBytes), size: configBytes.length }; |
| 269 | const type = manifestType(manifest.json); |
| 270 | const manifestBytes = Buffer.from(JSON.stringify(runnerManifest(manifest.json, { config, layer }))); |
| 271 | const digest = sha256(manifestBytes); |
| 272 | |
| 273 | onLine(`${target.tag}: the binary's layer ${layer.digest.slice(7, 19)}, ${(layer.size / 1e6).toFixed(1)} MB, on ${manifest.json.layers.length} layers of the base`); |
| 274 | for (let attempt = 1; ; attempt++) { |
| 275 | try { |
| 276 | if (await client.putBlob(layer.bytes, layer.digest)) onLine(`uploaded the binary's layer`); |
| 277 | await client.putBlob(configBytes, config.digest); |
| 278 | const answered = await client.putManifest(target.tag, manifestBytes, type); |
| 279 | if (answered && answered !== digest) throw new Error(`the registry kept ${target.tag} as ${answered}, not ${digest}`); |
| 280 | onLine(`${target.tag}: ${digest}`); |
| 281 | return digest; |
| 282 | } catch (error) { |
| 283 | // Cloudflare's registry can answer "blob unknown" for a blob it has |
| 284 | // just taken; asking again finds it there. |
| 285 | if (attempt >= attempts) throw error; |
| 286 | onLine(`making ${target.tag} in the registry did not finish (attempt ${attempt} of ${attempts}): ${error.message ?? error}; trying again`); |
| 287 | await wait(2000 * attempt); |
| 288 | } |
| 289 | } |
| 290 | } |