@cldmv/gh-broker 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -13
- package/config.example.json +86 -3
- package/config.sample.json +1 -1
- package/deploy/README.md +58 -18
- package/deploy/cli.mjs +38 -0
- package/deploy/gh-broker.service +9 -2
- package/deploy/install.mjs +100 -41
- package/deploy/lib/agent-cli.mjs +166 -0
- package/deploy/lib/agents.mjs +128 -0
- package/deploy/lib/cli.mjs +72 -0
- package/deploy/lib/config-merge.mjs +29 -8
- package/deploy/lib/install-args.mjs +3 -2
- package/deploy/lib/mcp-config.mjs +211 -0
- package/deploy/lib/otp-cli.mjs +136 -0
- package/deploy/lib/otp.mjs +325 -0
- package/deploy/lib/secret-store.mjs +1 -0
- package/deploy/lib/system.mjs +24 -12
- package/deploy/real-deps.mjs +102 -0
- package/dist/index.mjs +5171 -1686
- package/dist/index.mjs.map +4 -4
- package/package.json +14 -3
- package/docs/TOOLS.md +0 -538
- package/docs/agent-user-model.md +0 -89
- package/docs/changelog/v0/v0.2.0.md +0 -26
- package/docs/changelog/v0/v0.2.1.md +0 -23
- package/docs/changelog/v0/v0.2.2.md +0 -23
- package/docs/changelog/v0/v0.2.3.md +0 -33
- package/docs/changelog/v0/v0.2.4.md +0 -25
- package/docs/changelog/v0/v0.2.5.md +0 -29
- package/docs/changelog/v0/v0.3.0.md +0 -84
- package/docs/changelog/v0/v0.3.1.md +0 -30
- package/docs/changelog/v0/v0.3.2.md +0 -24
- package/docs/changelog/v0/v0.3.3.md +0 -28
- package/docs/changelog/v0/v0.4.0.md +0 -58
- package/docs/changelog/v0/v0.4.1.md +0 -33
- package/docs/changelog/v0/v0.4.2.md +0 -45
- package/docs/changelog/v0/v0.5.0.md +0 -39
- package/docs/changelog/v0/v0.5.1.md +0 -28
- package/docs/changelog/v0/v0.6.0.md +0 -52
- package/docs/changelog/v0/v0.6.1.md +0 -48
- package/docs/changelog/v0/v0.7.0.md +0 -71
- package/docs/changelog/v0/v0.8.0.md +0 -115
- package/docs/deploy-architecture.md +0 -84
- package/src/appauth.ts +0 -56
- package/src/audit.ts +0 -34
- package/src/availability.ts +0 -42
- package/src/catalog.ts +0 -182
- package/src/config.ts +0 -157
- package/src/index.ts +0 -107
- package/src/paths.ts +0 -60
- package/src/router.ts +0 -299
- package/src/secrets.ts +0 -167
- package/src/server.ts +0 -91
- package/src/types.ts +0 -128
- package/src/upstream.ts +0 -85
- package/src/verbs/ghcr/archive.ts +0 -314
- package/src/verbs/ghcr/config.ts +0 -142
- package/src/verbs/ghcr/jobs.ts +0 -141
- package/src/verbs/ghcr/push.ts +0 -192
- package/src/verbs/ghcr/registry.ts +0 -275
- package/src/verbs/ghcr/tar.ts +0 -203
- package/src/verbs/ghcr_push.ts +0 -264
- package/src/verbs/issue_labels.ts +0 -329
- package/src/verbs/link_issue.ts +0 -84
- package/src/verbs/npm-registry.ts +0 -168
- package/src/verbs/npm-tarball.ts +0 -76
- package/src/verbs/npm_scaffold.ts +0 -325
- package/src/verbs/release-analysis.ts +0 -212
- package/src/verbs/repo-tier.ts +0 -90
- package/src/verbs/report_ci_status.ts +0 -358
- package/src/verbs/report_release_prs.ts +0 -238
- package/src/verbs/report_security_alerts.ts +0 -442
- package/src/verbs/report_unlabeled.ts +0 -194
- package/src/verbs/rest-client.ts +0 -181
- package/src/verbs/review_thread.ts +0 -138
- package/tests/appauth.test.vitest.mts +0 -76
- package/tests/audit.test.vitest.mts +0 -48
- package/tests/catalog.test.vitest.mts +0 -234
- package/tests/config-merge.test.vitest.mts +0 -325
- package/tests/config.test.vitest.mts +0 -388
- package/tests/credentials.test.vitest.mts +0 -127
- package/tests/ghcr-archive.test.vitest.mts +0 -439
- package/tests/ghcr-config.test.vitest.mts +0 -96
- package/tests/ghcr-push.test.vitest.mts +0 -526
- package/tests/ghcr-registry.test.vitest.mts +0 -206
- package/tests/helpers/ghcr-fixtures.mts +0 -373
- package/tests/install-args.test.vitest.mts +0 -152
- package/tests/issue-labels.test.vitest.mts +0 -362
- package/tests/npm-scaffold.test.vitest.mts +0 -479
- package/tests/npm-setup.test.vitest.mts +0 -196
- package/tests/paths.test.vitest.mts +0 -57
- package/tests/prompt.test.vitest.mts +0 -128
- package/tests/release-prs.test.vitest.mts +0 -247
- package/tests/rest-client.test.vitest.mts +0 -115
- package/tests/router.test.vitest.mts +0 -505
- package/tests/run-vitest.mjs +0 -51
- package/tests/secret-store.test.vitest.mts +0 -167
- package/tests/secrets.test.vitest.mts +0 -208
- package/tests/security-alerts.test.vitest.mts +0 -338
- package/tests/server.test.vitest.mts +0 -200
- package/tests/smoke.test.vitest.mts +0 -177
- package/tests/system.test.vitest.mts +0 -355
- package/tests/upstream.test.vitest.mts +0 -91
- package/tests/verb-helpers.test.vitest.mts +0 -377
- package/tests/verbs.test.vitest.mts +0 -624
- package/tsconfig.json +0 -21
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
2
|
-
import { EventEmitter } from "node:events";
|
|
3
|
-
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
4
|
-
import { Readable, Writable } from "node:stream";
|
|
5
|
-
import { Registry, RegistryError } from "../src/verbs/ghcr/registry.ts";
|
|
6
|
-
|
|
7
|
-
// Real node:http, except request() can be swapped for a fake to reach states a real socket won't produce.
|
|
8
|
-
const hook = vi.hoisted(() => ({ request: null as null | ((...a: any[]) => any) }));
|
|
9
|
-
vi.mock("node:http", async (importOriginal) => {
|
|
10
|
-
const actual = await importOriginal<typeof import("node:http")>();
|
|
11
|
-
return { ...actual, request: (...a: any[]) => (hook.request ? hook.request(...a) : (actual.request as any)(...a)) };
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
type Handler = (req: IncomingMessage, res: ServerResponse, body: Buffer) => void;
|
|
15
|
-
const servers: Server[] = [];
|
|
16
|
-
|
|
17
|
-
/** A scripted registry: /token always works, everything else is up to the test. */
|
|
18
|
-
async function scripted(h: Handler): Promise<Registry> {
|
|
19
|
-
const srv = createServer((req, res) => {
|
|
20
|
-
const chunks: Buffer[] = [];
|
|
21
|
-
req.on("data", (c: Buffer) => chunks.push(c));
|
|
22
|
-
req.on("end", () => {
|
|
23
|
-
if (req.url?.startsWith("/token")) {
|
|
24
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
25
|
-
return void res.end(JSON.stringify({ token: "regtok-0001" }));
|
|
26
|
-
}
|
|
27
|
-
h(req, res, Buffer.concat(chunks));
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
servers.push(srv);
|
|
31
|
-
await new Promise<void>((r) => srv.listen(0, "127.0.0.1", r));
|
|
32
|
-
const base = `http://127.0.0.1:${(srv.address() as { port: number }).port}`;
|
|
33
|
-
return new Registry({ base, username: "u", password: "ghp_testPatValue0123456789abcdefABCDEF", repository: "o/i" });
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const json = (res: ServerResponse, status: number, obj: unknown, headers: Record<string, string> = {}) => {
|
|
37
|
-
res.writeHead(status, { "Content-Type": "application/json", ...headers });
|
|
38
|
-
res.end(JSON.stringify(obj));
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
afterEach(async () => {
|
|
42
|
-
hook.request = null;
|
|
43
|
-
await Promise.all(servers.splice(0).map((s) => new Promise((r) => s.close(() => r(null)))));
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
describe("Registry construction and errors", () => {
|
|
47
|
-
it("defaults to ghcr.io", () => {
|
|
48
|
-
expect(new Registry({ username: "u", password: "p", repository: "o/i" }).host).toBe("ghcr.io");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("tolerates registry error entries with no code or message", async () => {
|
|
52
|
-
const failing = createServer((_req, res) => json(res, 500, { errors: [{}, { code: "X" }, { message: "m" }] }));
|
|
53
|
-
servers.push(failing);
|
|
54
|
-
await new Promise<void>((r) => failing.listen(0, "127.0.0.1", r));
|
|
55
|
-
const bad = new Registry({ base: `http://127.0.0.1:${(failing.address() as { port: number }).port}`, username: "u", password: "pppp", repository: "o/i" });
|
|
56
|
-
await expect(bad.login("pull")).rejects.toThrow(/HTTP 500 \(: ; X: ; : m\)/);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("reads at most 1 MiB of a response body", async () => {
|
|
60
|
-
const big = createServer((_req, res) => {
|
|
61
|
-
res.writeHead(500);
|
|
62
|
-
const chunk = Buffer.alloc(600 * 1024, "a");
|
|
63
|
-
res.write(chunk);
|
|
64
|
-
res.write(chunk);
|
|
65
|
-
res.end(chunk);
|
|
66
|
-
});
|
|
67
|
-
servers.push(big);
|
|
68
|
-
await new Promise<void>((r) => big.listen(0, "127.0.0.1", r));
|
|
69
|
-
const reg = new Registry({ base: `http://127.0.0.1:${(big.address() as { port: number }).port}`, username: "u", password: "pppp", repository: "o/i" });
|
|
70
|
-
const err = await reg.login("pull").catch((e: Error) => e);
|
|
71
|
-
expect(err).toBeInstanceOf(RegistryError);
|
|
72
|
-
expect((err as Error).message).toMatch(/HTTP 500/);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("uses https for an https base (and reports a connection failure)", async () => {
|
|
76
|
-
const reg = new Registry({ base: "https://127.0.0.1:1", username: "u", password: "pppp", repository: "o/i" });
|
|
77
|
-
await expect(reg.login("pull")).rejects.toThrow(/ghcr: GET \/token failed/);
|
|
78
|
-
});
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
describe("Registry transport (fake http.request)", () => {
|
|
82
|
-
function fakeRequest(onEnd: (cb: (res: any) => void, req: any) => void, opts: { fireTimeout?: boolean } = {}) {
|
|
83
|
-
return (_url: unknown, _o: unknown, cb: (res: any) => void) => {
|
|
84
|
-
const req: any = new EventEmitter();
|
|
85
|
-
req.setTimeout = (_ms: number, handler: () => void) => {
|
|
86
|
-
if (opts.fireTimeout) queueMicrotask(handler);
|
|
87
|
-
};
|
|
88
|
-
req.destroy = (e: Error) => queueMicrotask(() => req.emit("error", e));
|
|
89
|
-
req.end = () => onEnd(cb, req);
|
|
90
|
-
return req;
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
const reg = () => new Registry({ base: "http://registry.invalid", username: "u", password: "pppp", repository: "o/i" });
|
|
94
|
-
|
|
95
|
-
it("destroys the request when the idle timeout fires", async () => {
|
|
96
|
-
hook.request = fakeRequest(() => {}, { fireTimeout: true });
|
|
97
|
-
await expect(reg().login("pull")).rejects.toThrow("ghcr: GET /token failed: GET timed out");
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("treats a response without a status code as status 0", async () => {
|
|
101
|
-
hook.request = fakeRequest((cb) => {
|
|
102
|
-
const res: any = new EventEmitter();
|
|
103
|
-
res.headers = {};
|
|
104
|
-
cb(res);
|
|
105
|
-
res.emit("end");
|
|
106
|
-
});
|
|
107
|
-
await expect(reg().login("pull")).rejects.toThrow("HTTP 0");
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
it("rejects when the response stream errors", async () => {
|
|
111
|
-
hook.request = fakeRequest((cb) => {
|
|
112
|
-
const res: any = new EventEmitter();
|
|
113
|
-
res.statusCode = 200;
|
|
114
|
-
res.headers = {};
|
|
115
|
-
cb(res);
|
|
116
|
-
res.emit("error", new Error("reset"));
|
|
117
|
-
});
|
|
118
|
-
await expect(reg().login("pull")).rejects.toThrow("reset");
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("streams a function body without a byte counter", async () => {
|
|
122
|
-
const sent: Buffer[] = [];
|
|
123
|
-
hook.request = (_u: unknown, _o: unknown, cb: (res: any) => void) => {
|
|
124
|
-
|
|
125
|
-
const req: any = new Writable({
|
|
126
|
-
write(chunk, _e, done) {
|
|
127
|
-
sent.push(chunk);
|
|
128
|
-
done();
|
|
129
|
-
},
|
|
130
|
-
final(done) {
|
|
131
|
-
const res: any = new EventEmitter();
|
|
132
|
-
res.statusCode = 204;
|
|
133
|
-
res.headers = {};
|
|
134
|
-
cb(res);
|
|
135
|
-
res.emit("end");
|
|
136
|
-
done();
|
|
137
|
-
}
|
|
138
|
-
});
|
|
139
|
-
req.setTimeout = () => {};
|
|
140
|
-
return req;
|
|
141
|
-
};
|
|
142
|
-
const r = reg();
|
|
143
|
-
const res = await (r as any).send("PATCH", new URL("http://registry.invalid/x"), {}, () => Readable.from([Buffer.from("abc")]));
|
|
144
|
-
expect(res.status).toBe(204);
|
|
145
|
-
expect(Buffer.concat(sent).toString()).toBe("abc");
|
|
146
|
-
});
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
describe("Registry upload and listing edge cases", () => {
|
|
150
|
-
it("completes a blob upload whose PATCH answers 204 with no Location", async () => {
|
|
151
|
-
const seen: string[] = [];
|
|
152
|
-
const reg = await scripted((req, res) => {
|
|
153
|
-
seen.push(`${req.method} ${req.url}`);
|
|
154
|
-
if (req.method === "POST") {
|
|
155
|
-
res.writeHead(202, { Location: "/v2/o/i/blobs/uploads/u1" });
|
|
156
|
-
return void res.end();
|
|
157
|
-
}
|
|
158
|
-
if (req.method === "PATCH") {
|
|
159
|
-
res.writeHead(204);
|
|
160
|
-
return void res.end();
|
|
161
|
-
}
|
|
162
|
-
res.writeHead(201);
|
|
163
|
-
res.end();
|
|
164
|
-
});
|
|
165
|
-
await reg.uploadBlob("sha256:abc", 3, () => Readable.from([Buffer.from("abc")]));
|
|
166
|
-
expect(seen).toEqual(["POST /v2/o/i/blobs/uploads/", "PATCH /v2/o/i/blobs/uploads/u1", "PUT /v2/o/i/blobs/uploads/u1?digest=sha256%3Aabc"]);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it("reports a PATCH the registry refuses", async () => {
|
|
170
|
-
const reg = await scripted((req, res) => {
|
|
171
|
-
if (req.method === "POST") {
|
|
172
|
-
res.writeHead(202, { Location: "/v2/o/i/blobs/uploads/u1" });
|
|
173
|
-
return void res.end();
|
|
174
|
-
}
|
|
175
|
-
json(res, 500, { errors: [{ code: "UNKNOWN", message: "storage down" }] });
|
|
176
|
-
});
|
|
177
|
-
await expect(reg.uploadBlob("sha256:abc", 3, () => Readable.from([Buffer.from("abc")]))).rejects.toThrow(/uploading blob sha256:abc failed: HTTP 500 \(UNKNOWN: storage down\)/);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it("aborts the upload when the source stream errors", async () => {
|
|
181
|
-
const reg = await scripted((req, res) => {
|
|
182
|
-
res.writeHead(202, { Location: "/v2/o/i/blobs/uploads/u1" });
|
|
183
|
-
res.end();
|
|
184
|
-
void req;
|
|
185
|
-
});
|
|
186
|
-
const body = () =>
|
|
187
|
-
new Readable({
|
|
188
|
-
read() {
|
|
189
|
-
this.destroy(new Error("disk gone"));
|
|
190
|
-
}
|
|
191
|
-
});
|
|
192
|
-
await expect(reg.uploadBlob("sha256:abc", 3, body)).rejects.toThrow(/disk gone/);
|
|
193
|
-
});
|
|
194
|
-
|
|
195
|
-
it("reports a tag listing the registry refuses", async () => {
|
|
196
|
-
const reg = await scripted((_req, res) => json(res, 500, { errors: [{ code: "UNAVAILABLE", message: "later" }] }));
|
|
197
|
-
await expect(reg.listTags()).rejects.toThrow(/listing tags failed: HTTP 500/);
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
it("ignores a missing tags field and non-string tag entries", async () => {
|
|
201
|
-
let call = 0;
|
|
202
|
-
const reg = await scripted((_req, res) => json(res, 200, ++call === 1 ? { name: "o/i" } : { tags: ["a", 7, null, "b"] }));
|
|
203
|
-
expect(await reg.listTags()).toEqual([]);
|
|
204
|
-
expect(await reg.listTags()).toEqual(["a", "b"]);
|
|
205
|
-
});
|
|
206
|
-
});
|
|
@@ -1,373 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Test fixtures for the GHCR verbs: a tar writer, builders for OCI-layout and
|
|
3
|
-
* docker-archive image archives, and a fake OCI distribution registry (node:http) that
|
|
4
|
-
* implements the token exchange, blob HEAD/upload, manifest HEAD/PUT and tag listing.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
8
|
-
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
9
|
-
import { mkdirSync, rmSync } from "node:fs";
|
|
10
|
-
import { join, resolve } from "node:path";
|
|
11
|
-
import { gzipSync } from "node:zlib";
|
|
12
|
-
|
|
13
|
-
export const sha = (b: Buffer | string): string => `sha256:${createHash("sha256").update(b).digest("hex")}`;
|
|
14
|
-
|
|
15
|
-
// ---------- tar ----------
|
|
16
|
-
|
|
17
|
-
export interface TarSpec {
|
|
18
|
-
name: string;
|
|
19
|
-
data?: Buffer | string;
|
|
20
|
-
type?: string; // "0" file, "1" hardlink, "2" symlink, "5" dir, "x" pax, "L" gnu long name, "g" global pax
|
|
21
|
-
linkname?: string;
|
|
22
|
-
prefix?: string;
|
|
23
|
-
/** Override the size field (to build broken archives). */
|
|
24
|
-
sizeField?: string;
|
|
25
|
-
/** Write a base-256 size field. */
|
|
26
|
-
base256?: boolean;
|
|
27
|
-
badChecksum?: boolean;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function header(spec: TarSpec, size: number): Buffer {
|
|
31
|
-
const h = Buffer.alloc(512, 0);
|
|
32
|
-
h.write(spec.name.slice(0, 100), 0, "utf8");
|
|
33
|
-
h.write("0000644\0", 100);
|
|
34
|
-
h.write("0000000\0", 108);
|
|
35
|
-
h.write("0000000\0", 116);
|
|
36
|
-
if (spec.base256) {
|
|
37
|
-
h[124] = 0x80;
|
|
38
|
-
let n = size;
|
|
39
|
-
for (let i = 135; i > 124; i--) {
|
|
40
|
-
h[i] = n % 256;
|
|
41
|
-
n = Math.floor(n / 256);
|
|
42
|
-
}
|
|
43
|
-
} else {
|
|
44
|
-
h.write((spec.sizeField ?? size.toString(8).padStart(11, "0")) + "\0", 124);
|
|
45
|
-
}
|
|
46
|
-
h.write("00000000000\0", 136);
|
|
47
|
-
h.write(" ", 148);
|
|
48
|
-
h.write(spec.type ?? "0", 156);
|
|
49
|
-
if (spec.linkname) h.write(spec.linkname.slice(0, 100), 157);
|
|
50
|
-
h.write("ustar\0", 257);
|
|
51
|
-
h.write("00", 263);
|
|
52
|
-
if (spec.prefix) h.write(spec.prefix, 345);
|
|
53
|
-
let sum = 0;
|
|
54
|
-
for (let i = 0; i < 512; i++) sum += h[i]!;
|
|
55
|
-
if (spec.badChecksum) sum += 1;
|
|
56
|
-
h.write(sum.toString(8).padStart(6, "0") + "\0 ", 148);
|
|
57
|
-
return h;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const pad = (n: number): Buffer => Buffer.alloc((512 - (n % 512)) % 512, 0);
|
|
61
|
-
|
|
62
|
-
export function tar(entries: TarSpec[], { end = true }: { end?: boolean } = {}): Buffer {
|
|
63
|
-
const parts: Buffer[] = [];
|
|
64
|
-
for (const e of entries) {
|
|
65
|
-
const data = e.data === undefined ? Buffer.alloc(0) : Buffer.isBuffer(e.data) ? e.data : Buffer.from(e.data);
|
|
66
|
-
parts.push(header(e, data.length), data, pad(data.length));
|
|
67
|
-
}
|
|
68
|
-
if (end) parts.push(Buffer.alloc(1024, 0));
|
|
69
|
-
return Buffer.concat(parts);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export function paxRecord(key: string, value: string): string {
|
|
73
|
-
const body = ` ${key}=${value}\n`;
|
|
74
|
-
let len = body.length + 1;
|
|
75
|
-
while (`${len}${body}`.length !== len) len = `${len}${body}`.length;
|
|
76
|
-
return `${len}${body}`;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// ---------- image archives ----------
|
|
80
|
-
|
|
81
|
-
export interface BuiltImage {
|
|
82
|
-
archive: Buffer;
|
|
83
|
-
config: Buffer;
|
|
84
|
-
layers: Buffer[];
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** A small, valid uncompressed layer (itself a tar). */
|
|
88
|
-
export function layerTar(content: string): Buffer {
|
|
89
|
-
return tar([{ name: "hello.txt", data: content }]);
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const imageConfig = (diffIds: string[]): Buffer =>
|
|
93
|
-
Buffer.from(JSON.stringify({ architecture: "amd64", os: "linux", rootfs: { type: "layers", diff_ids: diffIds }, config: {} }));
|
|
94
|
-
|
|
95
|
-
export interface OciOptions {
|
|
96
|
-
/** Store layers gzip-compressed (as buildx does) instead of uncompressed (as docker save does). */
|
|
97
|
-
gzip?: boolean;
|
|
98
|
-
layerMediaType?: string;
|
|
99
|
-
configMediaType?: string;
|
|
100
|
-
manifestMediaType?: string;
|
|
101
|
-
/** Wrap the manifest in a nested index (docker save on Docker 25+). */
|
|
102
|
-
nested?: boolean;
|
|
103
|
-
/** Extra entries in the top-level index (e.g. attestations, other platforms). */
|
|
104
|
-
extraIndexEntries?: unknown[];
|
|
105
|
-
annotations?: Record<string, string>;
|
|
106
|
-
layerContents?: string[];
|
|
107
|
-
/** Mutate the manifest object before it is serialized. */
|
|
108
|
-
mutateManifest?: (m: Record<string, any>) => void;
|
|
109
|
-
/** Drop these member names from the archive. */
|
|
110
|
-
omit?: string[];
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function ociArchive(opts: OciOptions = {}): BuiltImage {
|
|
114
|
-
const raw = (opts.layerContents ?? ["layer one", "layer two"]).map(layerTar);
|
|
115
|
-
const layers = opts.gzip ? raw.map((l) => gzipSync(l)) : raw;
|
|
116
|
-
const config = imageConfig(raw.map((l) => sha(l)));
|
|
117
|
-
const layerType = opts.layerMediaType ?? (opts.gzip ? "application/vnd.oci.image.layer.v1.tar+gzip" : "application/vnd.oci.image.layer.v1.tar");
|
|
118
|
-
const manifest: Record<string, any> = {
|
|
119
|
-
schemaVersion: 2,
|
|
120
|
-
mediaType: opts.manifestMediaType ?? "application/vnd.oci.image.manifest.v1+json",
|
|
121
|
-
config: { mediaType: opts.configMediaType ?? "application/vnd.oci.image.config.v1+json", digest: sha(config), size: config.length },
|
|
122
|
-
layers: layers.map((l) => ({ mediaType: layerType, digest: sha(l), size: l.length })),
|
|
123
|
-
...(opts.annotations ? { annotations: opts.annotations } : {})
|
|
124
|
-
};
|
|
125
|
-
opts.mutateManifest?.(manifest);
|
|
126
|
-
const manifestBytes = Buffer.from(JSON.stringify(manifest));
|
|
127
|
-
const mDesc = { mediaType: manifest.mediaType, digest: sha(manifestBytes), size: manifestBytes.length, platform: { os: "linux", architecture: "amd64" } };
|
|
128
|
-
const blobs: Array<[string, Buffer]> = [
|
|
129
|
-
[sha(config), config],
|
|
130
|
-
[sha(manifestBytes), manifestBytes],
|
|
131
|
-
...layers.map((l): [string, Buffer] => [sha(l), l])
|
|
132
|
-
];
|
|
133
|
-
let index: Buffer;
|
|
134
|
-
if (opts.nested) {
|
|
135
|
-
const inner = Buffer.from(JSON.stringify({ schemaVersion: 2, mediaType: "application/vnd.oci.image.index.v1+json", manifests: [mDesc, ...(opts.extraIndexEntries ?? [])] }));
|
|
136
|
-
blobs.push([sha(inner), inner]);
|
|
137
|
-
index = Buffer.from(JSON.stringify({ schemaVersion: 2, manifests: [{ mediaType: "application/vnd.oci.image.index.v1+json", digest: sha(inner), size: inner.length }] }));
|
|
138
|
-
} else {
|
|
139
|
-
index = Buffer.from(JSON.stringify({ schemaVersion: 2, manifests: [mDesc, ...(opts.extraIndexEntries ?? [])] }));
|
|
140
|
-
}
|
|
141
|
-
const omit = new Set(opts.omit ?? []);
|
|
142
|
-
const entries: TarSpec[] = [
|
|
143
|
-
{ name: "oci-layout", data: '{"imageLayoutVersion":"1.0.0"}' },
|
|
144
|
-
{ name: "index.json", data: index },
|
|
145
|
-
{ name: "blobs/", type: "5" },
|
|
146
|
-
{ name: "blobs/sha256/", type: "5" },
|
|
147
|
-
...blobs.map(([d, b]) => ({ name: `blobs/sha256/${d.slice(7)}`, data: b }))
|
|
148
|
-
].filter((e) => !omit.has(e.name));
|
|
149
|
-
return { archive: tar(entries), config, layers };
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Legacy `docker save` layout: manifest.json + <id>/layer.tar, with a symlinked duplicate layer. */
|
|
153
|
-
export function dockerArchive({ images = 1, gzipFirst = false }: { images?: number; gzipFirst?: boolean } = {}): BuiltImage {
|
|
154
|
-
const l1 = layerTar("legacy one");
|
|
155
|
-
const l2 = layerTar("legacy two");
|
|
156
|
-
const first = gzipFirst ? gzipSync(l1) : l1;
|
|
157
|
-
const config = imageConfig([sha(l1), sha(l2)]);
|
|
158
|
-
const cfgName = `${sha(config).slice(7)}.json`;
|
|
159
|
-
const entry = { Config: cfgName, RepoTags: ["hoot:latest"], Layers: ["aaa/layer.tar", "bbb/layer.tar", "ccc/layer.tar"] };
|
|
160
|
-
const manifest = Buffer.from(JSON.stringify(Array.from({ length: images }, () => entry)));
|
|
161
|
-
return {
|
|
162
|
-
archive: tar([
|
|
163
|
-
{ name: "aaa/", type: "5" },
|
|
164
|
-
{ name: "aaa/layer.tar", data: first },
|
|
165
|
-
{ name: "bbb/", type: "5" },
|
|
166
|
-
{ name: "bbb/layer.tar", data: l2 },
|
|
167
|
-
{ name: "ccc/", type: "5" },
|
|
168
|
-
{ name: "ccc/layer.tar", type: "2", linkname: "../bbb/layer.tar" },
|
|
169
|
-
{ name: cfgName, data: config },
|
|
170
|
-
{ name: "manifest.json", data: manifest }
|
|
171
|
-
]),
|
|
172
|
-
config,
|
|
173
|
-
layers: [first, l2, l2]
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// ---------- scratch dirs (inside the repo's gitignored tmp/, never /tmp) ----------
|
|
178
|
-
|
|
179
|
-
export function scratchDir(label: string): string {
|
|
180
|
-
const dir = resolve(import.meta.dirname, "..", "..", "tmp", "tests", `${label}-${randomUUID().slice(0, 8)}`);
|
|
181
|
-
mkdirSync(dir, { recursive: true });
|
|
182
|
-
return dir;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export function removeDir(dir: string): void {
|
|
186
|
-
rmSync(dir, { recursive: true, force: true });
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export { join };
|
|
190
|
-
|
|
191
|
-
// ---------- fake registry ----------
|
|
192
|
-
|
|
193
|
-
export interface FakeRegistryOptions {
|
|
194
|
-
username?: string;
|
|
195
|
-
password?: string;
|
|
196
|
-
/** Tag-list page size (to exercise Link pagination). */
|
|
197
|
-
tagPageSize?: number;
|
|
198
|
-
/** Upload Location points at another host. */
|
|
199
|
-
foreignLocation?: boolean;
|
|
200
|
-
/** Tag-list Link points at another host. */
|
|
201
|
-
foreignLink?: boolean;
|
|
202
|
-
/** Echo the Authorization header back in manifest PUT errors (a leaky registry). */
|
|
203
|
-
leakyErrors?: boolean;
|
|
204
|
-
/** Echo the bare bearer token back in manifest PUT errors, after this many padding characters. */
|
|
205
|
-
leakBareTokenAfter?: number;
|
|
206
|
-
/** Refuse every manifest PUT with this status. */
|
|
207
|
-
manifestStatus?: number;
|
|
208
|
-
/** Invalidate the current token once, after this many authenticated requests in total. */
|
|
209
|
-
expireTokenAfter?: number;
|
|
210
|
-
/** Refuse every Bearer request (a token that never works). */
|
|
211
|
-
rejectBearer?: boolean;
|
|
212
|
-
/** Token endpoint answers with this status (simulates a bad/expired PAT). */
|
|
213
|
-
tokenStatus?: number;
|
|
214
|
-
/** Token endpoint answers 200 without a token. */
|
|
215
|
-
tokenMissing?: boolean;
|
|
216
|
-
/** Return 404 for tags/list (repository not created yet). */
|
|
217
|
-
noRepo?: boolean;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
export interface Recorded {
|
|
221
|
-
method: string;
|
|
222
|
-
path: string;
|
|
223
|
-
authorization?: string;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
export class FakeRegistry {
|
|
227
|
-
readonly blobs = new Map<string, Buffer>();
|
|
228
|
-
readonly manifests = new Map<string, { mediaType: string; bytes: Buffer }>();
|
|
229
|
-
readonly requests: Recorded[] = [];
|
|
230
|
-
readonly opts: Required<Pick<FakeRegistryOptions, "username" | "password">> & FakeRegistryOptions;
|
|
231
|
-
tags: string[] = [];
|
|
232
|
-
tokensIssued = 0;
|
|
233
|
-
private server?: Server;
|
|
234
|
-
private uploads = new Map<string, Buffer[]>();
|
|
235
|
-
private validToken?: string;
|
|
236
|
-
private authedCount = 0;
|
|
237
|
-
private expired = false;
|
|
238
|
-
base = "";
|
|
239
|
-
|
|
240
|
-
constructor(opts: FakeRegistryOptions = {}) {
|
|
241
|
-
this.opts = { username: "Shinrai", password: "ghp_testPatValue0123456789abcdefABCDEF", ...opts };
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
async start(): Promise<string> {
|
|
245
|
-
this.server = createServer((req, res) => void this.handle(req, res));
|
|
246
|
-
await new Promise<void>((r) => this.server!.listen(0, "127.0.0.1", r));
|
|
247
|
-
const addr = this.server.address() as { port: number };
|
|
248
|
-
this.base = `http://127.0.0.1:${addr.port}`;
|
|
249
|
-
return this.base;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
async stop(): Promise<void> {
|
|
253
|
-
await new Promise<void>((r) => this.server?.close(() => r()) ?? r());
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
get currentToken(): string | undefined {
|
|
257
|
-
return this.validToken;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
private body(req: IncomingMessage): Promise<Buffer> {
|
|
261
|
-
return new Promise((resolve, reject) => {
|
|
262
|
-
const chunks: Buffer[] = [];
|
|
263
|
-
req.on("data", (c: Buffer) => chunks.push(c));
|
|
264
|
-
req.on("end", () => resolve(Buffer.concat(chunks)));
|
|
265
|
-
req.on("error", reject);
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
private json(res: ServerResponse, status: number, obj: unknown, headers: Record<string, string> = {}): void {
|
|
270
|
-
const b = Buffer.from(JSON.stringify(obj));
|
|
271
|
-
res.writeHead(status, { "Content-Type": "application/json", "Content-Length": String(b.length), ...headers });
|
|
272
|
-
res.end(b);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
276
|
-
const url = new URL(req.url ?? "/", this.base);
|
|
277
|
-
this.requests.push({ method: req.method ?? "", path: url.pathname + url.search, authorization: req.headers.authorization });
|
|
278
|
-
const body = await this.body(req);
|
|
279
|
-
|
|
280
|
-
if (url.pathname === "/token") {
|
|
281
|
-
const expected = `Basic ${Buffer.from(`${this.opts.username}:${this.opts.password}`).toString("base64")}`;
|
|
282
|
-
if (this.opts.tokenStatus) return this.json(res, this.opts.tokenStatus, { errors: [{ code: "DENIED", message: `denied for ${req.headers.authorization}` }] });
|
|
283
|
-
if (req.headers.authorization !== expected) return this.json(res, 401, { errors: [{ code: "UNAUTHORIZED", message: "bad credentials" }] });
|
|
284
|
-
if (this.opts.tokenMissing) return this.json(res, 200, {});
|
|
285
|
-
this.validToken = `regtok-${++this.tokensIssued}-secretvalue`;
|
|
286
|
-
return this.json(res, 200, { token: this.validToken });
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (this.opts.rejectBearer || req.headers.authorization !== `Bearer ${this.validToken}`) return this.json(res, 401, { errors: [{ code: "UNAUTHORIZED", message: "authentication required" }] });
|
|
290
|
-
if (!this.expired && this.opts.expireTokenAfter !== undefined && ++this.authedCount > this.opts.expireTokenAfter) {
|
|
291
|
-
this.expired = true;
|
|
292
|
-
this.validToken = undefined;
|
|
293
|
-
return this.json(res, 401, { errors: [{ code: "UNAUTHORIZED", message: "token expired" }] });
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
const m = /^\/v2\/(.+?)\/(blobs|manifests|tags)\/(.*)$/.exec(url.pathname);
|
|
297
|
-
if (!m) return this.json(res, 404, { errors: [{ code: "NOT_FOUND", message: "no route" }] });
|
|
298
|
-
const [, , kind, rest] = m;
|
|
299
|
-
|
|
300
|
-
if (kind === "blobs" && rest!.startsWith("uploads/")) {
|
|
301
|
-
const id = rest!.slice("uploads/".length);
|
|
302
|
-
if (req.method === "POST" && id === "") {
|
|
303
|
-
const uuid = randomUUID();
|
|
304
|
-
this.uploads.set(uuid, []);
|
|
305
|
-
const loc = this.opts.foreignLocation ? `http://evil.example/upload/${uuid}` : `${url.pathname}${uuid}`;
|
|
306
|
-
res.writeHead(202, { Location: loc, "Content-Length": "0" });
|
|
307
|
-
return void res.end();
|
|
308
|
-
}
|
|
309
|
-
const chunks = this.uploads.get(id);
|
|
310
|
-
if (!chunks) return this.json(res, 404, { errors: [{ code: "BLOB_UPLOAD_UNKNOWN", message: "unknown upload" }] });
|
|
311
|
-
if (req.method === "PATCH") {
|
|
312
|
-
if (Number(req.headers["content-length"]) !== body.length) return this.json(res, 400, { errors: [{ code: "SIZE_INVALID", message: "length mismatch" }] });
|
|
313
|
-
chunks.push(body);
|
|
314
|
-
res.writeHead(202, { Location: url.pathname, "Content-Length": "0" });
|
|
315
|
-
return void res.end();
|
|
316
|
-
}
|
|
317
|
-
if (req.method === "PUT") {
|
|
318
|
-
const digest = url.searchParams.get("digest") ?? "";
|
|
319
|
-
const all = Buffer.concat([...chunks, body]);
|
|
320
|
-
if (sha(all) !== digest) return this.json(res, 400, { errors: [{ code: "DIGEST_INVALID", message: "digest mismatch" }] });
|
|
321
|
-
this.blobs.set(digest, all);
|
|
322
|
-
this.uploads.delete(id);
|
|
323
|
-
res.writeHead(201, { "Docker-Content-Digest": digest, "Content-Length": "0" });
|
|
324
|
-
return void res.end();
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
if (kind === "blobs" && req.method === "HEAD") {
|
|
328
|
-
res.writeHead(this.blobs.has(rest!) ? 200 : 404, { "Content-Length": "0" });
|
|
329
|
-
return void res.end();
|
|
330
|
-
}
|
|
331
|
-
if (kind === "manifests" && req.method === "HEAD") {
|
|
332
|
-
res.writeHead(this.manifests.has(rest!) ? 200 : 404, { "Content-Length": "0" });
|
|
333
|
-
return void res.end();
|
|
334
|
-
}
|
|
335
|
-
if (kind === "manifests" && req.method === "PUT") {
|
|
336
|
-
if (this.opts.manifestStatus) {
|
|
337
|
-
return this.json(res, this.opts.manifestStatus, {
|
|
338
|
-
errors: [
|
|
339
|
-
{
|
|
340
|
-
code: "DENIED",
|
|
341
|
-
message:
|
|
342
|
-
this.opts.leakBareTokenAfter !== undefined
|
|
343
|
-
? `${"x".repeat(this.opts.leakBareTokenAfter)}${String(req.headers.authorization).replace(/^Bearer /, "")}`
|
|
344
|
-
: this.opts.leakyErrors
|
|
345
|
-
? `request had ${req.headers.authorization}`
|
|
346
|
-
: "denied"
|
|
347
|
-
}
|
|
348
|
-
]
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
const man = JSON.parse(body.toString("utf8")) as { config: { digest: string }; layers: Array<{ digest: string }> };
|
|
352
|
-
for (const d of [man.config.digest, ...man.layers.map((l) => l.digest)]) {
|
|
353
|
-
if (!this.blobs.has(d)) return this.json(res, 400, { errors: [{ code: "MANIFEST_BLOB_UNKNOWN", message: d }] });
|
|
354
|
-
}
|
|
355
|
-
this.manifests.set(rest!, { mediaType: String(req.headers["content-type"]), bytes: body });
|
|
356
|
-
if (!this.tags.includes(rest!)) this.tags.push(rest!);
|
|
357
|
-
res.writeHead(201, { "Docker-Content-Digest": sha(body), "Content-Length": "0" });
|
|
358
|
-
return void res.end();
|
|
359
|
-
}
|
|
360
|
-
if (kind === "tags" && rest === "list") {
|
|
361
|
-
if (this.opts.noRepo) return this.json(res, 404, { errors: [{ code: "NAME_UNKNOWN", message: "repository name not known to registry" }] });
|
|
362
|
-
const size = this.opts.tagPageSize ?? 1000;
|
|
363
|
-
const last = url.searchParams.get("last");
|
|
364
|
-
const start = last ? this.tags.indexOf(last) + 1 : 0;
|
|
365
|
-
const page = this.tags.slice(start, start + size);
|
|
366
|
-
const more = start + size < this.tags.length;
|
|
367
|
-
const linkBase = this.opts.foreignLink ? "http://evil.example" : "";
|
|
368
|
-
const headers: Record<string, string> = more ? { Link: `<${linkBase}${url.pathname}?n=${size}&last=${page[page.length - 1]}>; rel="next"` } : {};
|
|
369
|
-
return this.json(res, 200, { name: m[1], tags: page }, headers);
|
|
370
|
-
}
|
|
371
|
-
return this.json(res, 405, { errors: [{ code: "UNSUPPORTED", message: `${req.method} ${url.pathname}` }] });
|
|
372
|
-
}
|
|
373
|
-
}
|