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.

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