Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

582 lines · 32.5 KBRaw History
1// End-to-end container registry: a real `wrangler dev` Worker, raw OCI distribution API
2// calls, the docker CLI (when Docker is available) and workflows whose GITHUB_TOKEN
3// pushes and pulls, run by the real runner agent.
4//
5// pnpm --filter @diffinery/runner build && pnpm --filter @diffinery/app e2e
6//
7// Covers: monolithic, chunked and streamed blob uploads, digest and range checks,
8// manifests by tag and digest, indexes, referrers, tag listing, cross-repository mounts,
9// token scopes, the packages REST API, the job token's `packages` permission, linking
10// packages to repositories, and docker login/push/pull.
11import { spawn, spawnSync, execFileSync } from "node:child_process";
12import { createHash, randomBytes } from "node:crypto";
13import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync, readFileSync } from "node:fs";
14import { tmpdir } from "node:os";
15import { join, dirname, resolve } from "node:path";
16import { fileURLToPath } from "node:url";
17import { test, before, after } from "node:test";
18import assert from "node:assert/strict";
19import { migratedState } from "./d1-state.mjs";
20import { freePort } from "./ports.mjs";
21
22const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
23const AGENT = resolve(APP, "../../containers/runner/dist/agent.mjs");
24const work = mkdtempSync(join(tmpdir(), "diffinery-registry-"));
25let base;
26let host;
27let dev;
28let devLog = "";
29let token;
30let owner;
31
32const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
33async function until(what, fn, timeoutMs = 60_000) {
34 const end = Date.now() + timeoutMs;
35 while (Date.now() < end) {
36 const v = await fn();
37 if (v) return v;
38 await sleep(500);
39 }
40 assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`);
41}
42
43const sha = (b) => `sha256:${createHash("sha256").update(b).digest("hex")}`;
44const hex = (s) => createHash("sha256").update(s).digest("hex");
45/** Tokens of the seeded "crew" organization's owner and members, and of an outside collaborator. */
46const CREW = Object.fromEntries(["olga", "mira", "nils", "otto"].map((n) => [n, `dfy_${n}_${randomBytes(12).toString("hex")}`]));
47const auth = (tok = token) => ({ authorization: `Basic ${Buffer.from(`anyone:${tok}`).toString("base64")}` });
48
49/** A registry call: path is below /v2/. */
50async function reg(method, path, { body, headers = {}, tok } = {}) {
51 const res = await fetch(`${base}/v2/${path}`, { method, body, headers: { ...auth(tok), ...headers }, redirect: "manual", duplex: body instanceof ReadableStream ? "half" : undefined });
52 const buf = Buffer.from(await res.arrayBuffer());
53 let json = null;
54 try {
55 json = buf.length ? JSON.parse(buf.toString("utf8")) : null;
56 } catch {}
57 return { status: res.status, headers: res.headers, body: buf, json };
58}
59
60async function api(method, path, body, tok = token) {
61 const res = await fetch(`${base}/api/v3${path}`, { method, headers: { ...(tok === null ? {} : { authorization: `Bearer ${tok}` }), "content-type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
62 const text = await res.text();
63 let json = null;
64 try {
65 json = text ? JSON.parse(text) : null;
66 } catch {
67 json = text;
68 }
69 return { status: res.status, json };
70}
71
72/**
73 * Claim a waiting self-hosted job as a registered runner offering `labels`, the way `diffinery runner`
74 * does (personal tokens can't claim jobs): one runner per repository and set of labels.
75 */
76const e2eRunners = new Map();
77async function runnerClaim(repoPath, labels = ["linux"]) {
78 const key = `${repoPath} ${labels.join(",")}`;
79 if (!e2eRunners.has(key)) {
80 const reg = await api("POST", `${repoPath}/actions/runners/registration-token`);
81 assert.equal(reg.status, 201, JSON.stringify(reg.json));
82 const runner = await api("POST", `${repoPath}/actions/runners/register`, { registration_token: reg.json.token, name: `e2e-runner-${e2eRunners.size + 1}`, labels });
83 assert.equal(runner.status, 201, JSON.stringify(runner.json));
84 e2eRunners.set(key, runner.json.token);
85 }
86 const r = await api("POST", `${repoPath}/actions/runners/claim`, {}, e2eRunners.get(key));
87 // A repository deleted and created again under the same name needs a new runner.
88 if (r.status !== 401) return r;
89 e2eRunners.delete(key);
90 return runnerClaim(repoPath, labels);
91}
92async function ok(method, path, body, tok) {
93 const r = await api(method, path, body, tok);
94 assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`);
95 return r.json;
96}
97
98/** Uploads a blob in one POST. */
99async function pushBlob(name, bytes, tok) {
100 const r = await reg("POST", `${name}/blobs/uploads/?digest=${sha(bytes)}`, { body: bytes, headers: { "content-type": "application/octet-stream" }, tok });
101 assert.equal(r.status, 201, JSON.stringify(r.json));
102 return { digest: sha(bytes), size: bytes.length };
103}
104
105/** Pushes a small single-layer image and returns its manifest digest. */
106async function pushImage(name, tag, { layer = randomBytes(2048), labels = {}, tok } = {}) {
107 const config = Buffer.from(JSON.stringify({ architecture: "amd64", os: "linux", config: { Labels: labels }, rootfs: { type: "layers", diff_ids: [sha(layer)] } }));
108 const c = await pushBlob(name, config, tok);
109 const l = await pushBlob(name, layer, tok);
110 const manifest = Buffer.from(
111 JSON.stringify({
112 schemaVersion: 2,
113 mediaType: "application/vnd.oci.image.manifest.v1+json",
114 config: { mediaType: "application/vnd.oci.image.config.v1+json", ...c },
115 layers: [{ mediaType: "application/vnd.oci.image.layer.v1.tar+gzip", ...l }],
116 }),
117 );
118 const r = await reg("PUT", `${name}/manifests/${tag}`, { body: manifest, headers: { "content-type": "application/vnd.oci.image.manifest.v1+json" }, tok });
119 assert.equal(r.status, 201, JSON.stringify(r.json));
120 assert.equal(r.headers.get("docker-content-digest"), sha(manifest));
121 return { digest: sha(manifest), manifest, layer, size: manifest.length };
122}
123
124before(async () => {
125 assert.ok(existsSync(AGENT), `build the runner first: pnpm --filter @diffinery/runner build (missing ${AGENT})`);
126 const port = await freePort();
127 base = `http://127.0.0.1:${port}`;
128 host = `127.0.0.1:${port}`;
129 const persist = join(work, "state");
130 migratedState(APP, persist);
131 const at = new Date().toISOString();
132 const later = new Date(Date.now() + 30 * 86400_000).toISOString();
133 const seed = [
134 `INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES ('o-crew', 'crew', 'org', 'crew', 'active', '${at}')`,
135 ...Object.entries(CREW).flatMap(([n, t]) => [
136 `INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES ('u-${n}', '${n}', 'user', '${n}', 'active', '${at}')`,
137 `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('t-${n}', 'u-${n}', 'e2e', '${hex(t)}', '${t.slice(0, 10)}', '["admin"]', '${later}', '${at}')`,
138 ]),
139 `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-crew', 'u-olga', 'owner', '${at}'), ('o-crew', 'u-mira', 'member', '${at}'), ('o-crew', 'u-nils', 'member', '${at}')`,
140 ];
141 execFileSync("pnpm", ["exec", "wrangler", "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", seed.join("; ")], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } });
142 dev = spawn("pnpm", ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--show-interactive-dev-session=false"], {
143 cwd: APP,
144 env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" },
145 stdio: ["ignore", "pipe", "pipe"],
146 detached: true,
147 });
148 dev.stdout.on("data", (d) => (devLog += d));
149 dev.stderr.on("data", (d) => (devLog += d));
150 await until("wrangler dev", async () => {
151 try {
152 return (await fetch(`${base}/healthz`)).ok;
153 } catch {
154 return false;
155 }
156 }, 120_000);
157 const res = await fetch(`${base}/api/v3/diffinery/tokens`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ name: "registry-e2e", scopes: ["admin"] }) });
158 assert.equal(res.status, 201, await res.clone().text());
159 token = (await res.json()).token;
160 owner = (await ok("GET", "/user")).login;
161});
162
163after(() => {
164 if (dev?.pid) {
165 try {
166 process.kill(-dev.pid, "SIGTERM");
167 } catch {}
168 }
169 if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true });
170});
171
172test("registry: the OCI distribution API", async () => {
173 const v2 = await reg("GET", "");
174 assert.equal(v2.status, 200);
175 assert.equal(v2.headers.get("docker-distribution-api-version"), "registry/2.0");
176 // Unknown tokens are refused with a Basic challenge so `docker login` reports it.
177 const bad = await fetch(`${base}/v2/`, { headers: auth("dfy_nope") });
178 assert.equal(bad.status, 401);
179 assert.match(bad.headers.get("www-authenticate") ?? "", /^Basic realm=/);
180
181 const name = `${owner}/tools/api`;
182 assert.equal((await reg("GET", `${owner}/bad..name/blobs/${sha("x")}`)).json.errors[0].code, "NAME_INVALID");
183 assert.equal((await reg("GET", `${name}/manifests/latest`)).status, 404);
184
185 // Monolithic upload, then HEAD and ranged GET.
186 const blob = randomBytes(5000);
187 const { digest } = await pushBlob(name, blob);
188 const head = await reg("HEAD", `${name}/blobs/${digest}`);
189 assert.equal(head.status, 200);
190 assert.equal(head.headers.get("content-length"), "5000");
191 assert.equal(head.headers.get("docker-content-digest"), digest);
192 const part = await reg("GET", `${name}/blobs/${digest}`, { headers: { range: "bytes=100-199" } });
193 assert.equal(part.status, 206);
194 assert.deepEqual(part.body, blob.subarray(100, 200));
195 assert.equal(part.headers.get("content-range"), "bytes 100-199/5000");
196
197 // Chunked upload: three PATCHes with Content-Range, then PUT with the last bytes.
198 const big = randomBytes(23 * 1024 * 1024 + 123);
199 const start = await reg("POST", `${name}/blobs/uploads/`);
200 assert.equal(start.status, 202);
201 let loc = start.headers.get("location");
202 assert.match(loc, /\/blobs\/uploads\/[0-9a-f]{32}$/);
203 const cuts = [0, 9 * 1024 * 1024, 11 * 1024 * 1024, 22 * 1024 * 1024, big.length];
204 for (let i = 0; i < 3; i++) {
205 const chunk = big.subarray(cuts[i], cuts[i + 1]);
206 const r = await fetch(`${base}${loc}`, { method: "PATCH", body: chunk, headers: { ...auth(), "content-type": "application/octet-stream", "content-range": `${cuts[i]}-${cuts[i + 1] - 1}` } });
207 assert.equal(r.status, 202, await r.text());
208 assert.equal(r.headers.get("range"), `0-${cuts[i + 1] - 1}`);
209 loc = r.headers.get("location");
210 }
211 // A chunk that doesn't start where the upload is gets 416 and the current range.
212 const wrong = await fetch(`${base}${loc}`, { method: "PATCH", body: Buffer.from("zz"), headers: { ...auth(), "content-range": "5-6" } });
213 assert.equal(wrong.status, 416);
214 assert.equal(wrong.headers.get("range"), `0-${cuts[3] - 1}`);
215 const status = await fetch(`${base}${loc}`, { headers: auth() });
216 assert.equal(status.status, 204);
217 assert.equal(status.headers.get("diffinery-upload-size"), String(cuts[3]));
218 const done = await fetch(`${base}${loc}?digest=${sha(big)}`, { method: "PUT", body: big.subarray(cuts[3]), headers: { ...auth(), "content-type": "application/octet-stream", "content-range": `${cuts[3]}-${big.length - 1}` } });
219 assert.equal(done.status, 201, await done.text());
220 const back = await reg("GET", `${name}/blobs/${sha(big)}`);
221 assert.equal(back.body.length, big.length);
222 assert.ok(back.body.equals(big), "the chunked blob comes back intact");
223
224 // A streamed single PATCH (how docker sends layers), larger than one R2 part.
225 const streamed = randomBytes(12 * 1024 * 1024 + 7);
226 const s2 = await reg("POST", `${name}/blobs/uploads/`);
227 const pieces = [streamed.subarray(0, 1_000_000), streamed.subarray(1_000_000)];
228 const stream = new ReadableStream({ pull(ctl) { const p = pieces.shift(); p ? ctl.enqueue(p) : ctl.close(); } });
229 const p2 = await fetch(`${base}${s2.headers.get("location")}`, { method: "PATCH", body: stream, duplex: "half", headers: auth() });
230 assert.equal(p2.status, 202);
231 assert.equal((await fetch(`${base}${p2.headers.get("location")}?digest=${sha(streamed)}`, { method: "PUT", headers: auth() })).status, 201);
232 assert.ok((await reg("GET", `${name}/blobs/${sha(streamed)}`)).body.equals(streamed));
233
234 // A wrong digest is refused and the upload is gone.
235 const s3 = await reg("POST", `${name}/blobs/uploads/`);
236 const badPut = await fetch(`${base}${s3.headers.get("location")}?digest=${sha("other")}`, { method: "PUT", body: Buffer.from("content"), headers: auth() });
237 assert.equal(badPut.status, 400);
238 assert.equal((await badPut.json()).errors[0].code, "DIGEST_INVALID");
239 assert.equal((await fetch(`${base}${s3.headers.get("location")}`, { headers: auth() })).status, 404);
240
241 // Manifests must only use blobs the package has.
242 const orphan = Buffer.from(JSON.stringify({ schemaVersion: 2, mediaType: "application/vnd.oci.image.manifest.v1+json", config: { mediaType: "application/vnd.oci.image.config.v1+json", digest: sha("missing"), size: 7 }, layers: [] }));
243 const refused = await reg("PUT", `${name}/manifests/broken`, { body: orphan, headers: { "content-type": "application/vnd.oci.image.manifest.v1+json" } });
244 assert.equal(refused.status, 400);
245 assert.equal(refused.json.errors[0].code, "MANIFEST_BLOB_UNKNOWN");
246
247 // Images by tag and digest, with an index on top.
248 const amd = await pushImage(name, "amd64");
249 const index = Buffer.from(
250 JSON.stringify({
251 schemaVersion: 2,
252 mediaType: "application/vnd.oci.image.index.v1+json",
253 manifests: [{ mediaType: "application/vnd.oci.image.manifest.v1+json", digest: amd.digest, size: amd.size, platform: { os: "linux", architecture: "amd64" } }],
254 }),
255 );
256 assert.equal((await reg("PUT", `${name}/manifests/v1`, { body: index, headers: { "content-type": "application/vnd.oci.image.index.v1+json" } })).status, 201);
257 for (const ref of ["v1", sha(index)]) {
258 const m = await reg("GET", `${name}/manifests/${ref}`, { headers: { accept: "application/vnd.oci.image.index.v1+json" } });
259 assert.equal(m.status, 200);
260 assert.equal(m.headers.get("content-type"), "application/vnd.oci.image.index.v1+json");
261 assert.equal(m.headers.get("docker-content-digest"), sha(index));
262 assert.ok(m.body.equals(index));
263 }
264 assert.equal((await reg("HEAD", `${name}/manifests/amd64`)).headers.get("docker-content-digest"), amd.digest);
265 // Pushing by digest checks the digest.
266 assert.equal((await reg("PUT", `${name}/manifests/${sha("nope")}`, { body: index, headers: { "content-type": "application/vnd.oci.image.index.v1+json" } })).json.errors[0].code, "DIGEST_INVALID");
267
268 // Tags, paginated.
269 await pushImage(name, "v2");
270 assert.deepEqual((await reg("GET", `${name}/tags/list`)).json, { name, tags: ["amd64", "v1", "v2"] });
271 const page1 = await reg("GET", `${name}/tags/list?n=2`);
272 assert.deepEqual(page1.json.tags, ["amd64", "v1"]);
273 assert.match(page1.headers.get("link"), /last=v1>; rel="next"/);
274 assert.deepEqual((await reg("GET", `${name}/tags/list?n=2&last=v1`)).json.tags, ["v2"]);
275
276 // Referrers: an artifact whose subject is the amd64 image.
277 const empty = await pushBlob(name, Buffer.from("{}"));
278 const sig = Buffer.from(
279 JSON.stringify({
280 schemaVersion: 2,
281 mediaType: "application/vnd.oci.image.manifest.v1+json",
282 artifactType: "application/vnd.example.signature",
283 config: { mediaType: "application/vnd.oci.empty.v1+json", ...empty },
284 layers: [],
285 subject: { mediaType: "application/vnd.oci.image.manifest.v1+json", digest: amd.digest, size: amd.size },
286 annotations: { "org.example.signed-by": "e2e" },
287 }),
288 );
289 const sigPut = await reg("PUT", `${name}/manifests/${sha(sig)}`, { body: sig, headers: { "content-type": "application/vnd.oci.image.manifest.v1+json" } });
290 assert.equal(sigPut.status, 201);
291 assert.equal(sigPut.headers.get("oci-subject"), amd.digest);
292 const refs = await reg("GET", `${name}/referrers/${amd.digest}`);
293 assert.equal(refs.json.manifests.length, 1);
294 assert.equal(refs.json.manifests[0].artifactType, "application/vnd.example.signature");
295 assert.equal((await reg("GET", `${name}/referrers/${amd.digest}?artifactType=other`)).json.manifests.length, 0);
296
297 // Cross-repository mount, and a mount that falls back to a normal upload.
298 const other = `${owner}/tools/other`;
299 const mounted = await reg("POST", `${other}/blobs/uploads/?mount=${digest}&from=${name}`);
300 assert.equal(mounted.status, 201);
301 assert.equal((await reg("HEAD", `${other}/blobs/${digest}`)).status, 200);
302 assert.equal((await reg("POST", `${other}/blobs/uploads/?mount=${sha("never")}&from=${name}`)).status, 202);
303 // The blob isn't readable through a package that doesn't hold it.
304 assert.equal((await reg("HEAD", `${other}/blobs/${sha(big)}`)).status, 404);
305
306 // Deleting a tag keeps the manifest; deleting the manifest removes it and its tags.
307 assert.equal((await reg("DELETE", `${name}/manifests/v2`)).status, 202);
308 assert.equal((await reg("GET", `${name}/manifests/v2`)).status, 404);
309 assert.equal((await reg("DELETE", `${name}/manifests/${amd.digest}`)).status, 202);
310 assert.equal((await reg("GET", `${name}/manifests/amd64`)).status, 404);
311
312 const catalog = await reg("GET", "_catalog");
313 assert.ok(catalog.json.repositories.includes(name) && catalog.json.repositories.includes(other));
314});
315
316test("registry: token scopes, the packages API and repository links", async () => {
317 const read = (await ok("POST", "/diffinery/tokens", { name: "pull only", scopes: ["read"] }, null)).token;
318 const name = `${owner}/scoped`;
319 const img = await pushImage(name, "latest");
320 assert.equal((await reg("GET", `${name}/manifests/latest`, { tok: read })).status, 200);
321 const denied = await reg("POST", `${name}/blobs/uploads/`, { tok: read });
322 assert.equal(denied.status, 403);
323 assert.equal(denied.json.errors[0].code, "DENIED");
324 assert.equal((await reg("DELETE", `${name}/manifests/latest`, { tok: read })).status, 403);
325
326 // GitHub-shaped packages API.
327 const list = await ok("GET", "/user/packages?package_type=container");
328 const pkg = list.find((p) => p.name === "scoped");
329 assert.ok(pkg, JSON.stringify(list));
330 assert.equal(pkg.visibility, "private");
331 assert.equal(pkg.image, `${host}/${owner}/scoped`);
332 const versions = await ok("GET", "/user/packages/container/scoped/versions");
333 assert.equal(versions.length, 1);
334 assert.equal(versions[0].name, img.digest);
335 assert.deepEqual(versions[0].metadata.container.tags, ["latest"]);
336 assert.equal((await ok("PATCH", "/user/packages/container/scoped", { visibility: "public" })).visibility, "public");
337 assert.equal((await api("PATCH", "/user/packages/container/scoped", { visibility: "secret" })).status, 422);
338 assert.equal((await api("DELETE", `/user/packages/container/scoped/versions/${versions[0].id}`)).status, 204);
339 assert.equal((await reg("GET", `${name}/manifests/latest`)).status, 404);
340 assert.equal((await api("DELETE", "/user/packages/container/scoped")).status, 204);
341 assert.equal((await api("GET", "/user/packages/container/scoped")).status, 404);
342
343 // diffinery.com/<owner>/<repo>/... is linked to the repository when the pusher can write to it.
344 await ok("POST", "/user/repos", { name: "linked" });
345 await pushImage(`${owner}/linked/api`, "v1");
346 assert.equal((await ok("GET", "/user/packages/container/linked%2Fapi")).repository.full_name, `${owner}/linked`);
347 const repoPkgs = await ok("GET", `/repos/${owner}/linked/packages`);
348 assert.deepEqual(repoPkgs.map((p) => p.name), ["linked/api"]);
349 // ...and so is an image whose org.opencontainers.image.source label names one.
350 await ok("POST", "/user/repos", { name: "labelled" });
351 await pushImage(`${owner}/some-image`, "v1", { labels: { "org.opencontainers.image.source": `${base}/${owner}/labelled` } });
352 assert.equal((await ok("GET", "/user/packages/container/some-image")).repository.full_name, `${owner}/labelled`);
353 // The web pages render.
354 for (const path of [`/${owner}/-/packages`, `/${owner}/-/packages/container/linked/api`, `/${owner}/linked/packages`]) {
355 const page = await fetch(`${base}${path}`);
356 assert.equal(page.status, 200, path);
357 assert.match(await page.text(), /linked\/api|some-image/);
358 }
359});
360
361test("registry: organization packages follow ghcr.io's permissions", async () => {
362 // A member creates a package and administers it, since it isn't linked to a repository.
363 const img = await pushImage("crew/mira-tool", "v1", { tok: CREW.mira });
364 assert.equal((await ok("PATCH", "/orgs/crew/packages/container/mira-tool", { visibility: "private" }, CREW.mira)).visibility, "private");
365 // Another member pulls it but can't push to it, delete it or change it.
366 assert.equal((await reg("GET", "crew/mira-tool/manifests/v1", { tok: CREW.nils })).status, 200);
367 const push = await reg("POST", "crew/mira-tool/blobs/uploads/", { tok: CREW.nils });
368 assert.equal(push.status, 403, JSON.stringify(push.json));
369 assert.equal((await reg("DELETE", `crew/mira-tool/manifests/${img.digest}`, { tok: CREW.nils })).status, 403);
370 assert.equal((await api("PATCH", "/orgs/crew/packages/container/mira-tool", { visibility: "public" }, CREW.nils)).status, 403);
371 assert.equal((await api("DELETE", "/orgs/crew/packages/container/mira-tool", undefined, CREW.nils)).status, 403);
372 // Members still create their own packages, and the organization's owner administers them all.
373 await pushImage("crew/nils-tool", "v1", { tok: CREW.nils });
374 await pushImage("crew/mira-tool", "v2", { tok: CREW.olga });
375 assert.deepEqual((await reg("GET", "crew/mira-tool/tags/list", { tok: CREW.mira })).json.tags, ["v1", "v2"]);
376
377 // A linked package follows its repository: an outside collaborator with read pulls, with write pushes.
378 await ok("POST", "/orgs/crew/repos", { name: "app" }, CREW.olga);
379 await pushImage("crew/app", "v1", { tok: CREW.olga });
380 assert.equal((await ok("GET", "/orgs/crew/packages/container/app", undefined, CREW.olga)).repository.full_name, "crew/app");
381 assert.equal((await reg("GET", "crew/app/manifests/v1", { tok: CREW.otto })).status, 404);
382 // An outside collaborator is invited, and has access once he accepts (a member like mira is added at once).
383 const invitation = await ok("PUT", "/repos/crew/app/collaborators/otto", { permission: "read" }, CREW.olga);
384 assert.equal((await reg("GET", "crew/app/manifests/v1", { tok: CREW.otto })).status, 404);
385 await ok("PATCH", `/user/repository_invitations/${invitation.id}`, undefined, CREW.otto);
386 assert.equal((await reg("GET", "crew/app/manifests/v1", { tok: CREW.otto })).status, 200);
387 assert.equal((await reg("POST", "crew/app/blobs/uploads/", { tok: CREW.otto })).status, 403);
388 await ok("PUT", "/repos/crew/app/collaborators/otto", { permission: "write" }, CREW.olga);
389 await pushImage("crew/app", "v2", { tok: CREW.otto });
390 // ...but not to the members' unlinked packages, which he can't see.
391 assert.equal((await reg("GET", "crew/mira-tool/manifests/v1", { tok: CREW.otto })).status, 404);
392 assert.deepEqual((await ok("GET", "/orgs/crew/packages", undefined, CREW.otto)).map((p) => p.name), ["app"]);
393
394 // The organization's packages pages keep its header and tab bar, with Packages selected.
395 for (const path of ["/crew/-/packages", "/crew/-/packages/container/mira-tool"]) {
396 const page = await fetch(`${base}${path}`, { headers: { authorization: `Bearer ${CREW.mira}` } });
397 const html = await page.text();
398 assert.equal(page.status, 200, path);
399 assert.match(html, /Organization · crew · you are a member/, path);
400 assert.match(html, /<a href="\/crew\/-\/packages" class="on">/, path);
401 assert.match(html, /href="\/crew\?tab=people"/, path);
402 }
403});
404
405test("registry: an organization's package linked to a private repository follows that repository's access", async () => {
406 // With the base permission at none, members only reach the repositories they're given, and so their images.
407 await ok("PATCH", "/orgs/crew", { default_repository_permission: "none" }, CREW.olga);
408 await ok("POST", "/orgs/crew/repos", { name: "secret", private: true }, CREW.olga);
409 await pushImage("crew/secret", "v1", { tok: CREW.olga });
410 assert.equal((await ok("GET", "/orgs/crew/packages/container/secret", undefined, CREW.olga)).repository.full_name, "crew/secret");
411 assert.equal((await reg("GET", "crew/secret/manifests/v1", { tok: CREW.mira })).status, 404);
412 assert.ok(!(await reg("GET", "_catalog", { tok: CREW.mira })).json.repositories.includes("crew/secret"));
413 assert.ok(!(await ok("GET", "/orgs/crew/packages", undefined, CREW.mira)).some((p) => p.name === "secret"));
414 // Unlinked packages are still the members' to pull, and a grant on the repository opens its image.
415 assert.equal((await reg("GET", "crew/nils-tool/manifests/v1", { tok: CREW.mira })).status, 200);
416 await ok("PUT", "/repos/crew/secret/collaborators/mira", { permission: "read" }, CREW.olga);
417 assert.equal((await reg("GET", "crew/secret/manifests/v1", { tok: CREW.mira })).status, 200);
418 assert.ok((await reg("GET", "_catalog", { tok: CREW.mira })).json.repositories.includes("crew/secret"));
419 await ok("PATCH", "/orgs/crew", { default_repository_permission: "read" }, CREW.olga);
420});
421
422function git(cwd, args) {
423 const a = Buffer.from(`x-token:${token}`).toString("base64");
424 return execFileSync("git", ["-c", `http.extraHeader=Authorization: Basic ${a}`, "-c", "protocol.version=2", ...args], {
425 cwd,
426 encoding: "utf8",
427 stdio: ["ignore", "pipe", "pipe"],
428 env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_AUTHOR_NAME: "E2E", GIT_AUTHOR_EMAIL: "e2e@example.com", GIT_COMMITTER_NAME: "E2E", GIT_COMMITTER_EMAIL: "e2e@example.com" },
429 });
430}
431
432function spawnAsync(cmd, args, env, timeoutMs) {
433 return new Promise((done) => {
434 const child = spawn(cmd, args, { env: { ...process.env, ...env } });
435 let out = "";
436 child.stdout.on("data", (d) => (out += d));
437 child.stderr.on("data", (d) => (out += d));
438 const t = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
439 child.on("close", (status) => {
440 clearTimeout(t);
441 done({ status, out });
442 });
443 });
444}
445
446async function runOneJob(repo) {
447 const claim = await until("a job to claim", async () => {
448 const r = await runnerClaim(`/repos/${owner}/${repo}`, ["linux"]);
449 return r.status === 200 ? r.json : null;
450 });
451 const r = await spawnAsync(process.execPath, [AGENT], { DIFFINERY_API_URL: claim.api_url, DIFFINERY_JOB_ID: claim.job_id, DIFFINERY_JOB_TOKEN: claim.token, DIFFINERY_WORK_ROOT: join(work, `job-${claim.job_id}`) }, 180_000);
452 const log = await (await fetch(`${base}/api/v3/repos/${owner}/${repo}/actions/jobs/${claim.job_id}/logs`, { headers: { authorization: `Bearer ${token}` } })).text();
453 return { name: claim.name, status: r.status, out: r.out, log };
454}
455
456test("registry: a workflow's GITHUB_TOKEN follows the packages permission", async () => {
457 await ok("POST", "/user/repos", { name: "shipyard" });
458 const src = join(work, "shipyard");
459 mkdirSync(join(src, ".github/workflows"), { recursive: true });
460 // Push a tiny image with curl, the way any registry client would.
461 writeFileSync(
462 join(src, "push.sh"),
463 [
464 'R="$GITHUB_SERVER_URL/v2/$GITHUB_REPOSITORY_OWNER/shipyard"',
465 `printf '%s' '{"architecture":"amd64","os":"linux","rootfs":{"type":"layers","diff_ids":[]}}' > cfg.json`,
466 `d="sha256:$(sha256sum cfg.json | cut -d' ' -f1)"`,
467 'curl -sS -o /dev/null -w "blob=%{http_code}\\n" -u "x:$GITHUB_TOKEN" -X POST "$R/blobs/uploads/?digest=$d" --data-binary @cfg.json',
468 `printf '{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"%s","size":%s},"layers":[]}' "$d" "$(stat -c %s cfg.json)" > m.json`,
469 'curl -sS -o /dev/null -w "$1=%{http_code}\\n" -u "x:$GITHUB_TOKEN" -X PUT "$R/manifests/$1" -H "content-type: application/vnd.oci.image.manifest.v1+json" --data-binary @m.json',
470 "",
471 ].join("\n"),
472 );
473 writeFileSync(
474 join(src, ".github/workflows/images.yml"),
475 `on: push
476env:
477 GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
478jobs:
479 publish:
480 runs-on: [self-hosted, linux]
481 permissions:
482 contents: read
483 packages: write
484 steps:
485 - uses: actions/checkout@v4
486 - run: bash push.sh ci
487 - run: |
488 curl -sS -o /dev/null -w "public=%{http_code}\\n" -X PATCH "$GITHUB_SERVER_URL/api/v3/users/$GITHUB_REPOSITORY_OWNER/packages/container/shipyard" -H "authorization: Bearer $GITHUB_TOKEN" -H "content-type: application/json" -d '{"visibility":"public"}'
489 curl -sS -o /dev/null -w "gone=%{http_code}\\n" -X DELETE "$GITHUB_SERVER_URL/api/v3/users/$GITHUB_REPOSITORY_OWNER/packages/container/shipyard" -H "authorization: Bearer $GITHUB_TOKEN"
490 reader:
491 needs: publish
492 runs-on: [self-hosted, linux]
493 permissions:
494 contents: read
495 packages: read
496 steps:
497 - uses: actions/checkout@v4
498 - run: |
499 curl -sS -o /dev/null -w "pull=%{http_code}\\n" -u "x:$GITHUB_TOKEN" "$GITHUB_SERVER_URL/v2/$GITHUB_REPOSITORY_OWNER/shipyard/manifests/ci" -H "accept: application/vnd.oci.image.manifest.v1+json"
500 bash push.sh nope
501 outsider:
502 needs: reader
503 runs-on: [self-hosted, linux]
504 permissions:
505 contents: read
506 steps:
507 - uses: actions/checkout@v4
508 - run: bash push.sh nope
509`,
510 );
511 git(src, ["init", "-q", "-b", "main"]);
512 git(src, ["add", "-A"]);
513 git(src, ["commit", "-q", "-m", "images"]);
514 git(src, ["remote", "add", "origin", `${base}/${owner}/shipyard.git`]);
515 git(src, ["push", "-q", "origin", "main"]);
516
517 const publish = await runOneJob("shipyard");
518 assert.equal(publish.name, "publish");
519 assert.match(publish.log, /blob=201/, publish.log);
520 assert.match(publish.log, /ci=201/, publish.log);
521 // Created by the workflow, so linked to its repository.
522 const pkg = await ok("GET", "/user/packages/container/shipyard");
523 assert.equal(pkg.repository.full_name, `${owner}/shipyard`);
524 assert.equal((await ok("GET", "/user/packages/container/shipyard/versions"))[0].pushed_by, "diffinery-actions[bot]");
525 // It can't make the package public or delete it: that takes someone with admin access.
526 assert.match(publish.log, /public=403/, publish.log);
527 assert.match(publish.log, /gone=403/, publish.log);
528 assert.equal((await ok("GET", "/user/packages/container/shipyard")).visibility, "private");
529
530 const reader = await runOneJob("shipyard");
531 assert.equal(reader.name, "reader");
532 assert.match(reader.log, /pull=200/, reader.log);
533 assert.match(reader.log, /blob=403/, reader.log);
534 assert.match(reader.log, /nope=403/, reader.log);
535
536 const outsider = await runOneJob("shipyard");
537 assert.equal(outsider.name, "outsider");
538 assert.match(outsider.log, /blob=404/, outsider.log);
539 assert.deepEqual((await reg("GET", `${owner}/shipyard/tags/list`)).json.tags, ["ci"]);
540});
541
542const hasDocker = spawnSync("docker", ["info"], { stdio: "ignore", timeout: 15_000 }).status === 0;
543
544test("registry: docker login, push and pull", { skip: !hasDocker && "Docker is not available" }, async () => {
545 const dir = join(work, "image");
546 mkdirSync(dir, { recursive: true });
547 const payload = randomBytes(6 * 1024 * 1024);
548 writeFileSync(join(dir, "payload.bin"), payload);
549 writeFileSync(join(dir, "Dockerfile"), 'FROM scratch\nCOPY payload.bin /payload.bin\nLABEL org.opencontainers.image.description="e2e image"\n');
550 const image = `${host}/${owner}/dockerized:v1`;
551 const config = join(work, "docker-config");
552 mkdirSync(config, { recursive: true });
553 // Run docker without blocking the event loop: undici would keep sockets the server already closed.
554 const docker = async (args, input) => {
555 const r = await new Promise((done) => {
556 const child = spawn("docker", ["--config", config, ...args], { cwd: dir });
557 let out = "";
558 let err = "";
559 child.stdout.on("data", (d) => (out += d));
560 child.stderr.on("data", (d) => (err += d));
561 child.on("close", (status) => done({ status, out, err }));
562 child.stdin.end(input ?? "");
563 });
564 assert.equal(r.status, 0, `docker ${args.join(" ")}: ${r.err}${r.out}`);
565 return r.out;
566 };
567 assert.match(await docker(["login", host, "-u", "e2e", "--password-stdin"], token), /Login Succeeded/);
568 await docker(["build", "-q", "-t", image, "."]);
569 await docker(["push", image]);
570 await docker(["rmi", image]);
571 await docker(["pull", "-q", image]);
572 const id = (await docker(["create", image, "true"])).trim();
573 try {
574 await docker(["cp", `${id}:/payload.bin`, join(work, "payload.out")]);
575 } finally {
576 await docker(["rm", id]);
577 await docker(["rmi", image]);
578 }
579 assert.ok(readFileSync(join(work, "payload.out")).equals(payload), "the pulled image holds the same bytes");
580 const pkg = await ok("GET", "/user/packages/container/dockerized");
581 assert.equal(pkg.description, "e2e image");
582});