@cldmv/gh-broker 0.7.0 → 0.8.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 +134 -40
- package/config.example.json +21 -8
- package/config.sample.json +45 -0
- package/deploy/README.md +73 -15
- package/deploy/install.mjs +166 -101
- package/deploy/install.sh +0 -0
- package/deploy/lib/config-merge.mjs +174 -16
- package/deploy/lib/credentials.mjs +143 -0
- package/deploy/lib/install-args.mjs +144 -0
- package/deploy/lib/npm-setup.mjs +164 -0
- package/deploy/lib/secret-store.mjs +20 -2
- package/deploy/lib/system.mjs +26 -2
- package/dist/index.mjs +1638 -202
- package/dist/index.mjs.map +4 -4
- package/docs/TOOLS.md +192 -1
- package/docs/changelog/v0/v0.8.0.md +115 -0
- package/docs/deploy-architecture.md +13 -11
- package/package.json +7 -7
- package/src/availability.ts +42 -0
- package/src/catalog.ts +18 -1
- package/src/config.ts +29 -2
- package/src/router.ts +43 -2
- package/src/secrets.ts +34 -4
- package/src/types.ts +25 -1
- package/src/verbs/ghcr/archive.ts +314 -0
- package/src/verbs/ghcr/config.ts +142 -0
- package/src/verbs/ghcr/jobs.ts +141 -0
- package/src/verbs/ghcr/push.ts +192 -0
- package/src/verbs/ghcr/registry.ts +275 -0
- package/src/verbs/ghcr/tar.ts +203 -0
- package/src/verbs/ghcr_push.ts +264 -0
- package/src/verbs/npm-registry.ts +168 -0
- package/src/verbs/npm-tarball.ts +76 -0
- package/src/verbs/npm_scaffold.ts +325 -0
- package/src/verbs/release-analysis.ts +3 -1
- package/tests/catalog.test.vitest.mts +55 -1
- package/tests/config-merge.test.vitest.mts +275 -8
- package/tests/config.test.vitest.mts +75 -1
- package/tests/credentials.test.vitest.mts +127 -0
- package/tests/ghcr-archive.test.vitest.mts +439 -0
- package/tests/ghcr-config.test.vitest.mts +96 -0
- package/tests/ghcr-push.test.vitest.mts +526 -0
- package/tests/ghcr-registry.test.vitest.mts +206 -0
- package/tests/helpers/ghcr-fixtures.mts +373 -0
- package/tests/install-args.test.vitest.mts +152 -0
- package/tests/issue-labels.test.vitest.mts +15 -0
- package/tests/npm-scaffold.test.vitest.mts +479 -0
- package/tests/npm-setup.test.vitest.mts +196 -0
- package/tests/paths.test.vitest.mts +5 -0
- package/tests/prompt.test.vitest.mts +128 -0
- package/tests/release-prs.test.vitest.mts +10 -0
- package/tests/router.test.vitest.mts +108 -2
- package/tests/secret-store.test.vitest.mts +64 -3
- package/tests/secrets.test.vitest.mts +53 -0
- package/tests/security-alerts.test.vitest.mts +10 -0
- package/tests/server.test.vitest.mts +187 -7
- package/tests/smoke.test.vitest.mts +177 -0
- package/tests/system.test.vitest.mts +355 -0
- package/tests/verb-helpers.test.vitest.mts +46 -0
- package/.configs/fix-headers.json +0 -3
- package/.configs/vitest.config.mjs +0 -44
- package/.github/dependabot.yml +0 -111
- package/.github/workflows/branch-retention.yml +0 -44
- package/.github/workflows/bundle-size.yml +0 -52
- package/.github/workflows/ci.yml +0 -389
- package/.github/workflows/cla.yml +0 -66
- package/.github/workflows/codeql.yml +0 -87
- package/.github/workflows/dependabot-auto-merge.yml +0 -62
- package/.github/workflows/dependabot-recreate.yml +0 -67
- package/.github/workflows/dependency-review.yml +0 -46
- package/.github/workflows/feature-pr.yml +0 -75
- package/.github/workflows/hotfix-redirector.yml +0 -67
- package/.github/workflows/hotfixes-release.yml +0 -70
- package/.github/workflows/labeler.yml +0 -48
- package/.github/workflows/master-commit-audit.yml +0 -56
- package/.github/workflows/member-auto-merge.yml +0 -75
- package/.github/workflows/next-release.yml +0 -76
- package/.github/workflows/next-reset.yml +0 -50
- package/.github/workflows/pr-notify.yml +0 -44
- package/.github/workflows/pr-title-normalizer.yml +0 -52
- package/.github/workflows/provenance.yml +0 -51
- package/.github/workflows/publish.yml +0 -146
- package/.github/workflows/release-merge.yml +0 -104
- package/.github/workflows/release-notify.yml +0 -41
- package/.github/workflows/scorecard.yml +0 -64
- package/.github/workflows/stale.yml +0 -50
- package/.github/workflows/tag-health.yml +0 -70
- package/.github/workflows/update-major-version-tags.yml +0 -94
- package/.github/workflows/v4-bootstrap.yml +0 -115
- package/.github/workflows/welcome.yml +0 -42
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { mkdirSync, openSync, closeSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { gunzipSync } from "node:zlib";
|
|
4
|
+
import { Readable } from "node:stream";
|
|
5
|
+
import { createServer } from "node:http";
|
|
6
|
+
import { GhcrService, GHCR_CATALOG } from "../src/verbs/ghcr_push.ts";
|
|
7
|
+
import { JobQueue, QueueFull } from "../src/verbs/ghcr/jobs.ts";
|
|
8
|
+
import { Registry, RegistryError } from "../src/verbs/ghcr/registry.ts";
|
|
9
|
+
import { pushImage } from "../src/verbs/ghcr/push.ts";
|
|
10
|
+
import { planImage, MEDIA } from "../src/verbs/ghcr/archive.ts";
|
|
11
|
+
import { DEFAULT_MAX_ARCHIVE_BYTES, type GhcrConfig } from "../src/verbs/ghcr/config.ts";
|
|
12
|
+
import { FakeRegistry, tar, ociArchive, dockerArchive, scratchDir, removeDir, join, sha, type FakeRegistryOptions } from "./helpers/ghcr-fixtures.mts";
|
|
13
|
+
|
|
14
|
+
/** A docker-archive whose single layer member is empty (0 bytes). */
|
|
15
|
+
function dockerArchiveFromParts(config: Buffer, manifest: Buffer): Buffer {
|
|
16
|
+
return tar([
|
|
17
|
+
{ name: "e/", type: "5" },
|
|
18
|
+
{ name: "e/layer.tar" },
|
|
19
|
+
{ name: "cfg.json", data: config },
|
|
20
|
+
{ name: "manifest.json", data: manifest }
|
|
21
|
+
]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const PAT = "ghp_testPatValue0123456789abcdefABCDEF";
|
|
25
|
+
|
|
26
|
+
let root: string;
|
|
27
|
+
let drop: string;
|
|
28
|
+
let reg: FakeRegistry;
|
|
29
|
+
let logs: string[];
|
|
30
|
+
|
|
31
|
+
beforeAll(() => {
|
|
32
|
+
root = scratchDir("ghcr-push");
|
|
33
|
+
drop = join(root, "drop");
|
|
34
|
+
mkdirSync(drop);
|
|
35
|
+
});
|
|
36
|
+
afterAll(() => removeDir(root));
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
logs = [];
|
|
40
|
+
vi.spyOn(console, "log").mockImplementation((...a: unknown[]) => void logs.push(a.join(" ")));
|
|
41
|
+
});
|
|
42
|
+
afterEach(async () => {
|
|
43
|
+
vi.restoreAllMocks();
|
|
44
|
+
await reg?.stop();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
let n = 0;
|
|
48
|
+
function put(buf: Buffer): string {
|
|
49
|
+
const name = `img${++n}.tar`;
|
|
50
|
+
writeFileSync(join(drop, name), buf);
|
|
51
|
+
return name;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function service(opts: FakeRegistryOptions = {}, cfg: Record<string, unknown> = {}, queue?: JobQueue) {
|
|
55
|
+
reg = new FakeRegistry(opts);
|
|
56
|
+
const base = await reg.start();
|
|
57
|
+
// Built directly, not via parseGhcrConfig: the scratch dir lives in the checkout, which in CI is
|
|
58
|
+
// under /home — a path the parser rightly refuses for a real drop dir (see ghcr-config tests).
|
|
59
|
+
const ghcr: GhcrConfig = {
|
|
60
|
+
dropDir: drop,
|
|
61
|
+
protectReleaseTags: true,
|
|
62
|
+
maxArchiveBytes: DEFAULT_MAX_ARCHIVE_BYTES,
|
|
63
|
+
owners: { cldmv: { username: "Shinrai", credential: "cred:cldmv-packages", images: ["hoot", "hoot-worker-*"] }, other: { username: "o", credential: "cred:other", images: ["x"] } },
|
|
64
|
+
...cfg
|
|
65
|
+
};
|
|
66
|
+
const secrets = { resolve: vi.fn((ref: string) => (ref === "cred:cldmv-packages" ? PAT : "ghp_otherPat000000000000000000000000")), has: vi.fn(() => true) };
|
|
67
|
+
return { svc: new GhcrService(ghcr, secrets, { base, ...(queue ? { queue } : {}) }), secrets };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** No secret value may appear in anything the agent or the journal can see. */
|
|
71
|
+
function expectNoSecrets(...texts: string[]) {
|
|
72
|
+
for (const t of [...texts, ...logs]) {
|
|
73
|
+
expect(t).not.toContain(PAT);
|
|
74
|
+
if (reg?.currentToken) expect(t).not.toContain(reg.currentToken);
|
|
75
|
+
expect(t).not.toMatch(/regtok-\d+-secretvalue/);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
describe("ghcr_push (end to end against a fake registry)", () => {
|
|
80
|
+
it("pushes an uncompressed docker save archive: gzips layers, writes an OCI manifest, audits the result", async () => {
|
|
81
|
+
const { svc } = await service();
|
|
82
|
+
const img = ociArchive();
|
|
83
|
+
const job = await svc.push({ owner: "CLDMV", image: "hoot", tag: "1.0.0", archive: put(img.archive) });
|
|
84
|
+
expect(job).toMatchObject({ state: "queued", owner: "cldmv", image: `${new URL(reg.base).host}/cldmv/hoot`, tag: "1.0.0", format: "oci-layout" });
|
|
85
|
+
await svc.queue.idle();
|
|
86
|
+
const done = svc.status({ owner: "cldmv", job: job.job }) as any;
|
|
87
|
+
expect(done.state).toBe("succeeded");
|
|
88
|
+
expect(done.progress.phase).toBe("done");
|
|
89
|
+
expect(done.result.blobsUploaded).toBe(3);
|
|
90
|
+
|
|
91
|
+
const man = reg.manifests.get("1.0.0")!;
|
|
92
|
+
expect(man.mediaType).toBe(MEDIA.ociManifest);
|
|
93
|
+
expect(sha(man.bytes)).toBe(done.result.digest);
|
|
94
|
+
const parsed = JSON.parse(man.bytes.toString());
|
|
95
|
+
expect(parsed.layers.map((l: any) => l.mediaType)).toEqual([MEDIA.ociLayerGzip, MEDIA.ociLayerGzip]);
|
|
96
|
+
// The pushed blobs really are the gzip of the archive's layers.
|
|
97
|
+
expect(parsed.layers.map((l: any) => gunzipSync(reg.blobs.get(l.digest)!).toString("hex"))).toEqual(img.layers.map((l) => l.toString("hex")));
|
|
98
|
+
expect(reg.blobs.get(sha(img.config))).toEqual(img.config);
|
|
99
|
+
|
|
100
|
+
const auditLines = logs.filter((l) => l.includes('"tool":"ghcr_push"'));
|
|
101
|
+
expect(auditLines).toHaveLength(2);
|
|
102
|
+
expect(auditLines[1]).toContain(done.result.digest);
|
|
103
|
+
expect(auditLines[1]).toContain(join(drop, `img${n}.tar`));
|
|
104
|
+
expectNoSecrets(JSON.stringify(job), JSON.stringify(done));
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("skips blobs the registry already has, so a re-push uploads nothing", async () => {
|
|
108
|
+
const { svc } = await service();
|
|
109
|
+
const archive = put(ociArchive().archive);
|
|
110
|
+
await svc.push({ owner: "cldmv", image: "hoot", tag: "a", archive });
|
|
111
|
+
await svc.queue.idle();
|
|
112
|
+
const uploadsBefore = reg.requests.filter((r) => r.method === "PATCH").length;
|
|
113
|
+
const again = await svc.push({ owner: "cldmv", image: "hoot", tag: "b", archive });
|
|
114
|
+
await svc.queue.idle();
|
|
115
|
+
const res = (svc.status({ owner: "cldmv", job: again.job }) as any).result;
|
|
116
|
+
expect(res).toMatchObject({ blobsUploaded: 0, blobsSkipped: 3 });
|
|
117
|
+
expect(reg.requests.filter((r) => r.method === "PATCH").length).toBe(uploadsBefore);
|
|
118
|
+
expect(reg.manifests.get("a")!.bytes).toEqual(reg.manifests.get("b")!.bytes);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("pushes a compressed OCI layout byte-for-byte, manifest included", async () => {
|
|
122
|
+
const { svc } = await service();
|
|
123
|
+
const img = ociArchive({ gzip: true });
|
|
124
|
+
const job = await svc.push({ owner: "cldmv", image: "hoot-worker-render", tag: "latest", archive: put(img.archive) });
|
|
125
|
+
await svc.queue.idle();
|
|
126
|
+
const res = (svc.status({ owner: "cldmv", job: job.job }) as any).result;
|
|
127
|
+
for (const l of img.layers) expect(reg.blobs.get(sha(l))).toEqual(l);
|
|
128
|
+
const idx = reg.manifests.get("latest")!;
|
|
129
|
+
expect(sha(idx.bytes)).toBe(res.digest);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("pushes a legacy docker-archive and uploads a duplicated layer once", async () => {
|
|
133
|
+
const { svc } = await service();
|
|
134
|
+
const job = await svc.push({ owner: "cldmv", image: "hoot", tag: "legacy", archive: put(dockerArchive({ gzipFirst: true }).archive) });
|
|
135
|
+
await svc.queue.idle();
|
|
136
|
+
const res = (svc.status({ owner: "cldmv", job: job.job }) as any).result;
|
|
137
|
+
expect(res).toMatchObject({ blobsUploaded: 3, blobsSkipped: 1 });
|
|
138
|
+
const man = JSON.parse(reg.manifests.get("legacy")!.bytes.toString());
|
|
139
|
+
expect(man.config.mediaType).toBe(MEDIA.ociConfig);
|
|
140
|
+
expect(man.layers).toHaveLength(3);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("maps docker media types to OCI ones when the manifest has to be rebuilt", async () => {
|
|
144
|
+
const { svc } = await service();
|
|
145
|
+
const img = ociArchive({ configMediaType: MEDIA.dockerConfig, manifestMediaType: MEDIA.dockerManifest, layerMediaType: MEDIA.dockerLayer, annotations: { k: "v" } });
|
|
146
|
+
const job = await svc.push({ owner: "cldmv", image: "hoot", tag: "dm", archive: put(img.archive) });
|
|
147
|
+
await svc.queue.idle();
|
|
148
|
+
expect((svc.status({ owner: "cldmv", job: job.job }) as any).state).toBe("succeeded");
|
|
149
|
+
const man = JSON.parse(reg.manifests.get("dm")!.bytes.toString());
|
|
150
|
+
expect(man.config.mediaType).toBe(MEDIA.ociConfig);
|
|
151
|
+
expect(man.annotations).toEqual({ k: "v" });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("re-runs the token exchange when the registry token expires mid-push", async () => {
|
|
155
|
+
const { svc } = await service({ expireTokenAfter: 3 });
|
|
156
|
+
const job = await svc.push({ owner: "cldmv", image: "hoot", tag: "exp", archive: put(ociArchive().archive) });
|
|
157
|
+
await svc.queue.idle();
|
|
158
|
+
expect((svc.status({ owner: "cldmv", job: job.job }) as any).state).toBe("succeeded");
|
|
159
|
+
expect(reg.tokensIssued).toBeGreaterThan(1);
|
|
160
|
+
expectNoSecrets();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("refuses to overwrite an existing release tag, at submit time and again before the manifest PUT", async () => {
|
|
164
|
+
const { svc } = await service();
|
|
165
|
+
const archive = put(ociArchive().archive);
|
|
166
|
+
await svc.push({ owner: "cldmv", image: "hoot", tag: "v2.0.0", archive });
|
|
167
|
+
await svc.queue.idle();
|
|
168
|
+
await expect(svc.push({ owner: "cldmv", image: "hoot", tag: "v2.0.0", archive })).rejects.toThrow(/tag v2.0.0 already exists .* release tags are protected/);
|
|
169
|
+
|
|
170
|
+
// Race: two queued pushes of the same new release tag — the second must fail at manifest time.
|
|
171
|
+
const one = await svc.push({ owner: "cldmv", image: "hoot", tag: "3.0.0", archive });
|
|
172
|
+
const two = await svc.push({ owner: "cldmv", image: "hoot", tag: "3.0.0", archive });
|
|
173
|
+
await svc.queue.idle();
|
|
174
|
+
expect((svc.status({ owner: "cldmv", job: one.job }) as any).state).toBe("succeeded");
|
|
175
|
+
const failed = svc.status({ owner: "cldmv", job: two.job }) as any;
|
|
176
|
+
expect(failed.state).toBe("failed");
|
|
177
|
+
expect(failed.error).toMatch(/release tags are protected/);
|
|
178
|
+
expect(logs.some((l) => l.includes('"ok":false') && l.includes(two.job))).toBe(true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("lets non-release tags and unprotected configs overwrite", async () => {
|
|
182
|
+
const { svc } = await service({}, { protectReleaseTags: false });
|
|
183
|
+
const archive = put(ociArchive().archive);
|
|
184
|
+
for (let i = 0; i < 2; i++) {
|
|
185
|
+
await svc.push({ owner: "cldmv", image: "hoot", tag: "1.2.3", archive });
|
|
186
|
+
await svc.queue.idle();
|
|
187
|
+
}
|
|
188
|
+
const all = svc.status({ owner: "cldmv" }) as any;
|
|
189
|
+
expect(all.jobs.map((j: any) => j.state)).toEqual(["succeeded", "succeeded"]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("surfaces a bad PAT at submit without echoing it", async () => {
|
|
193
|
+
const { svc } = await service({ tokenStatus: 403 });
|
|
194
|
+
const err = await svc.push({ owner: "cldmv", image: "hoot", tag: "x", archive: put(ociArchive().archive) }).catch((e: Error) => e);
|
|
195
|
+
expect((err as Error).message).toMatch(/token exchange failed: HTTP 403 .*classic PAT with write:packages/);
|
|
196
|
+
expectNoSecrets((err as Error).message);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("scrubs a registry error that echoes the Authorization header", async () => {
|
|
200
|
+
const { svc } = await service({ manifestStatus: 403, leakyErrors: true });
|
|
201
|
+
const job = await svc.push({ owner: "cldmv", image: "hoot", tag: "leak", archive: put(ociArchive().archive) });
|
|
202
|
+
await svc.queue.idle();
|
|
203
|
+
const failed = svc.status({ owner: "cldmv", job: job.job }) as any;
|
|
204
|
+
expect(failed.state).toBe("failed");
|
|
205
|
+
expect(failed.error).toMatch(/pushing manifest for tag leak failed: HTTP 403 .*Bearer \[redacted\]/);
|
|
206
|
+
expectNoSecrets(JSON.stringify(failed));
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("scrubs a secret before cutting a long registry error, so no fragment survives the cut", async () => {
|
|
210
|
+
// "DENIED: " is 8 characters, so 285 characters of padding puts the cut inside the token.
|
|
211
|
+
const { svc } = await service({ manifestStatus: 403, leakBareTokenAfter: 285 });
|
|
212
|
+
const job = await svc.push({ owner: "cldmv", image: "hoot", tag: "cut", archive: put(ociArchive().archive) });
|
|
213
|
+
await svc.queue.idle();
|
|
214
|
+
const failed = svc.status({ owner: "cldmv", job: job.job }) as any;
|
|
215
|
+
expect(failed.state).toBe("failed");
|
|
216
|
+
expect(failed.error).not.toMatch(/regtok-/);
|
|
217
|
+
expectNoSecrets(JSON.stringify(failed));
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("refuses an upload Location on another host (the token never leaves the registry)", async () => {
|
|
221
|
+
const { svc } = await service({ foreignLocation: true });
|
|
222
|
+
const job = await svc.push({ owner: "cldmv", image: "hoot", tag: "f", archive: put(ociArchive().archive) });
|
|
223
|
+
await svc.queue.idle();
|
|
224
|
+
expect((svc.status({ owner: "cldmv", job: job.job }) as any).error).toMatch(/pointed at another host \(evil.example\)/);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("refusals before any network call", () => {
|
|
228
|
+
it.each([
|
|
229
|
+
[{ owner: "cldmv", image: "hoot", tag: "t" }, /archive: "archive" must be a file path/],
|
|
230
|
+
[{ owner: "cldmv", image: "hoot", tag: "t", archive: "a", extra: 1 }, /unknown argument\(s\): extra/],
|
|
231
|
+
[{ image: "hoot", tag: "t", archive: "a" }, /"owner" must be a non-empty string/],
|
|
232
|
+
[{ owner: "nobody", image: "hoot", tag: "t", archive: "a" }, /owner "nobody" has no GHCR configuration/],
|
|
233
|
+
[{ owner: "cldmv", image: "secret-thing", tag: "t", archive: "a" }, /ghcr.io\/cldmv\/secret-thing is not on the allowlist/],
|
|
234
|
+
[{ owner: "cldmv", image: "hoot-worker-a/b", tag: "t", archive: "a" }, /not on the allowlist/],
|
|
235
|
+
[{ owner: "cldmv", image: "Hoot", tag: "t", archive: "a" }, /not a valid image name/],
|
|
236
|
+
[{ owner: "cldmv", image: "cldmv/hoot", tag: "t", archive: "a" }, /pass e.g. "hoot"/],
|
|
237
|
+
[{ owner: "cldmv", image: "ghcr.io/cldmv/hoot", tag: "t", archive: "a" }, /pass e.g. "hoot"/],
|
|
238
|
+
[{ owner: "cldmv", image: "", tag: "t", archive: "a" }, /"image" must be a non-empty string/],
|
|
239
|
+
[{ owner: "cldmv", image: "hoot", tag: "-bad", archive: "a" }, /"tag" must be a valid tag/],
|
|
240
|
+
[{ owner: "cldmv", image: "hoot", tag: 5, archive: "a" }, /"tag" must be a valid tag/],
|
|
241
|
+
[{ owner: "cldmv", image: "hoot", tag: "t", archive: "/etc/gh-broker/secrets.json" }, /must be an existing file inside the drop directory/],
|
|
242
|
+
[{ owner: "cldmv", image: "hoot", tag: "t", archive: "../../x.tar" }, /must be an existing file inside the drop directory/]
|
|
243
|
+
])("%j", async (args, re) => {
|
|
244
|
+
const { svc } = await service();
|
|
245
|
+
await expect(svc.push(args)).rejects.toThrow(re);
|
|
246
|
+
expect(reg.requests).toHaveLength(0);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("refuses a non-object argument list", async () => {
|
|
250
|
+
const { svc } = await service();
|
|
251
|
+
await expect(svc.push([])).rejects.toThrow(/arguments must be an object/);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("refuses a non-image archive before touching the registry, and closes it", async () => {
|
|
255
|
+
const { svc } = await service();
|
|
256
|
+
await expect(svc.push({ owner: "cldmv", image: "hoot", tag: "t", archive: put(Buffer.alloc(1024)) })).rejects.toThrow(/not an image archive/);
|
|
257
|
+
expect(reg.requests).toHaveLength(0);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("refuses every verb when GHCR is not configured", async () => {
|
|
261
|
+
const svc = new GhcrService(undefined, { resolve: () => "x" });
|
|
262
|
+
await expect(svc.push({ owner: "cldmv", image: "hoot", tag: "t", archive: "a" })).rejects.toThrow(/GHCR is not configured/);
|
|
263
|
+
expect(() => svc.status({ owner: "cldmv" })).toThrow(/GHCR is not configured/);
|
|
264
|
+
await expect(svc.tags({ owner: "cldmv", image: "hoot" })).rejects.toThrow(/GHCR is not configured/);
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("refuses new pushes once the queue is full", async () => {
|
|
269
|
+
const { svc } = await service({}, {}, new JobQueue({ maxPending: 1 }));
|
|
270
|
+
const archive = put(ociArchive().archive);
|
|
271
|
+
await svc.push({ owner: "cldmv", image: "hoot", tag: "q1", archive });
|
|
272
|
+
await expect(svc.push({ owner: "cldmv", image: "hoot", tag: "q2", archive })).rejects.toThrow(/already queued or running/);
|
|
273
|
+
await svc.queue.idle();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("ghcr_push_status", () => {
|
|
278
|
+
it("validates the job id and never shows another owner's job", async () => {
|
|
279
|
+
const { svc } = await service();
|
|
280
|
+
const job = await svc.push({ owner: "cldmv", image: "hoot", tag: "s", archive: put(ociArchive().archive) });
|
|
281
|
+
await svc.queue.idle();
|
|
282
|
+
expect(() => svc.status({ owner: "cldmv", job: "nope" })).toThrow(/must be a job id/);
|
|
283
|
+
expect(() => svc.status({ owner: "cldmv", job: "00000000-0000-0000-0000-000000000000" })).toThrow(/no job .* for owner cldmv/);
|
|
284
|
+
expect(() => svc.status({ owner: "other", job: job.job })).toThrow(/no job/);
|
|
285
|
+
expect((svc.status({ owner: "other" }) as any).jobs).toEqual([]);
|
|
286
|
+
expect(() => svc.status({ owner: "cldmv", job: job.job, x: 1 })).toThrow(/unknown argument/);
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe("ghcr_tags", () => {
|
|
291
|
+
it("lists tags across pages", async () => {
|
|
292
|
+
const { svc } = await service({ tagPageSize: 2 });
|
|
293
|
+
reg.tags = ["a", "b", "c", "d", "e"];
|
|
294
|
+
const out = await svc.tags({ owner: "cldmv", image: "hoot" });
|
|
295
|
+
expect(out).toEqual({ image: `${new URL(reg.base).host}/cldmv/hoot`, exists: true, tags: ["a", "b", "c", "d", "e"] });
|
|
296
|
+
expect(reg.requests.find((r) => r.path.startsWith("/token"))!.path).toContain("scope=repository%3Acldmv%2Fhoot%3Apull");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("reports a repository that doesn't exist yet", async () => {
|
|
300
|
+
const { svc } = await service({ noRepo: true });
|
|
301
|
+
expect(await svc.tags({ owner: "cldmv", image: "hoot" })).toMatchObject({ exists: false, tags: [] });
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("refuses a non-allowlisted image and a foreign pagination link, and scrubs errors", async () => {
|
|
305
|
+
const { svc } = await service({ tagPageSize: 1, foreignLink: true });
|
|
306
|
+
reg.tags = ["a", "b"];
|
|
307
|
+
await expect(svc.tags({ owner: "cldmv", image: "private" })).rejects.toThrow(/not on the allowlist/);
|
|
308
|
+
await expect(svc.tags({ owner: "cldmv", image: "hoot" })).rejects.toThrow(/another host/);
|
|
309
|
+
const bad = await service({ tokenStatus: 401 });
|
|
310
|
+
const err = await bad.svc.tags({ owner: "cldmv", image: "hoot" }).catch((e: Error) => e);
|
|
311
|
+
expect((err as Error).message).toMatch(/HTTP 401/);
|
|
312
|
+
expectNoSecrets((err as Error).message);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe("Registry edge cases", () => {
|
|
317
|
+
beforeEach(async () => {
|
|
318
|
+
reg = new FakeRegistry();
|
|
319
|
+
await reg.start();
|
|
320
|
+
});
|
|
321
|
+
const client = (over: Partial<ConstructorParameters<typeof Registry>[0]> = {}) => new Registry({ base: reg.base, username: "Shinrai", password: PAT, repository: "cldmv/hoot", ...over });
|
|
322
|
+
|
|
323
|
+
it("clean() redacts both secrets and token-shaped strings", async () => {
|
|
324
|
+
const r = client();
|
|
325
|
+
await r.login("pull");
|
|
326
|
+
const tok = reg.currentToken!;
|
|
327
|
+
expect(r.clean(`a ${PAT} b ${tok} c Basic abc== d ghs_${"x".repeat(30)} e github_pat_${"y".repeat(30)}`)).toBe(
|
|
328
|
+
"a [redacted] b [redacted] c Basic [redacted] d [redacted] e [redacted]"
|
|
329
|
+
);
|
|
330
|
+
expect(r.host).toBe(new URL(reg.base).host);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("fails cleanly when the token response has no token, or the credential is wrong", async () => {
|
|
334
|
+
await expect(client({ password: "wrong-password" }).login("pull")).rejects.toThrow(/HTTP 401 \(UNAUTHORIZED: bad credentials\)/);
|
|
335
|
+
await reg.stop();
|
|
336
|
+
reg = new FakeRegistry({ tokenMissing: true });
|
|
337
|
+
await reg.start();
|
|
338
|
+
await expect(client().login("pull")).rejects.toThrow(/returned no token/);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("logs in on demand, and reports unexpected statuses with the registry's error code", async () => {
|
|
342
|
+
const r = client();
|
|
343
|
+
expect(await r.blobExists(sha("nothing"))).toBe(false); // first call triggers a pull login
|
|
344
|
+
await expect(r.putManifest("t", MEDIA.ociManifest, Buffer.from('{"config":{"digest":"sha256:00"},"layers":[]}'))).rejects.toThrow(/MANIFEST_BLOB_UNKNOWN/);
|
|
345
|
+
expect(await r.manifestExists("t")).toBe(false);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
it("surfaces the registry's digest check when the bytes don't match", async () => {
|
|
349
|
+
const r = client();
|
|
350
|
+
await r.login("push,pull");
|
|
351
|
+
const body = Buffer.from("real bytes");
|
|
352
|
+
await expect(r.uploadBlob(sha("other bytes"), body.length, () => Readable.from([body]))).rejects.toThrow(/finishing blob .*HTTP 400 \(DIGEST_INVALID/);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("rolls progress back and retries a blob once when the token expires mid-upload", async () => {
|
|
356
|
+
await reg.stop();
|
|
357
|
+
reg = new FakeRegistry({ expireTokenAfter: 1 }); // POST succeeds, PATCH gets 401
|
|
358
|
+
await reg.start();
|
|
359
|
+
const r = client();
|
|
360
|
+
await r.login("push,pull");
|
|
361
|
+
const body = Buffer.from("retry me");
|
|
362
|
+
let progress = 0;
|
|
363
|
+
await r.uploadBlob(sha(body), body.length, () => Readable.from([body]), (d) => (progress += d));
|
|
364
|
+
expect(progress).toBe(body.length);
|
|
365
|
+
expect(reg.blobs.get(sha(body))).toEqual(body);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("gives up after the retry when the token keeps expiring", async () => {
|
|
369
|
+
await reg.stop();
|
|
370
|
+
reg = new FakeRegistry({ rejectBearer: true });
|
|
371
|
+
await reg.start();
|
|
372
|
+
const r = client();
|
|
373
|
+
await r.login("push,pull");
|
|
374
|
+
await expect(r.uploadBlob(sha("x"), 1, () => Readable.from([Buffer.from("x")]))).rejects.toThrow(/HTTP 401/);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it("reports a network failure without secrets", async () => {
|
|
378
|
+
const r = client({ base: "http://127.0.0.1:1" });
|
|
379
|
+
const err = await r.login("pull").catch((e: Error) => e);
|
|
380
|
+
expect(err).toBeInstanceOf(RegistryError);
|
|
381
|
+
expect((err as Error).message).toMatch(/GET \/token failed/);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
it("reports a misbehaving registry (plain-text errors, bad JSON, unexpected statuses)", async () => {
|
|
385
|
+
const server = createServer((req, res) => {
|
|
386
|
+
if (req.url?.startsWith("/token")) return void res.end(JSON.stringify({ token: "t0ken-value" }));
|
|
387
|
+
if (req.url?.includes("/tags/list")) return void res.end("not json");
|
|
388
|
+
res.writeHead(500, { "Content-Type": "text/plain" });
|
|
389
|
+
res.end(" upstream\n exploded ");
|
|
390
|
+
});
|
|
391
|
+
await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
|
|
392
|
+
const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
|
393
|
+
try {
|
|
394
|
+
const r = client({ base });
|
|
395
|
+
await r.login("push,pull");
|
|
396
|
+
await expect(r.manifestExists("t")).rejects.toThrow(/checking tag t failed: HTTP 500$/); // HEAD: no body
|
|
397
|
+
await expect(r.blobExists(sha("x"))).rejects.toThrow(/HTTP 500/);
|
|
398
|
+
await expect(r.uploadBlob(sha("x"), 1, () => Readable.from([Buffer.from("x")]))).rejects.toThrow(/starting a blob upload failed: HTTP 500 \(upstream exploded\)/);
|
|
399
|
+
await expect(r.listTags()).rejects.toThrow(/invalid JSON/);
|
|
400
|
+
} finally {
|
|
401
|
+
await new Promise<void>((r) => server.close(() => r()));
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
it("refuses an unparseable Location", async () => {
|
|
406
|
+
const r = client();
|
|
407
|
+
expect(() => (r as any).sameOrigin("http://[", "x")).toThrow(/unparseable URL/);
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
describe("pushImage edge cases", () => {
|
|
412
|
+
it("pushes an image with an empty layer, and rewrites docker gzip layer types in a rebuilt manifest", async () => {
|
|
413
|
+
const { svc } = await service();
|
|
414
|
+
const config = Buffer.from(JSON.stringify({ architecture: "amd64", os: "linux", rootfs: { type: "layers", diff_ids: [] } }));
|
|
415
|
+
const manifest = Buffer.from(JSON.stringify([{ Config: "cfg.json", Layers: ["e/layer.tar"] }]));
|
|
416
|
+
const empty = dockerArchiveFromParts(config, manifest);
|
|
417
|
+
const job = await svc.push({ owner: "cldmv", image: "hoot", tag: "empty", archive: put(empty) });
|
|
418
|
+
await svc.queue.idle();
|
|
419
|
+
expect((svc.status({ owner: "cldmv", job: job.job }) as any).state).toBe("succeeded");
|
|
420
|
+
|
|
421
|
+
const mixed = ociArchive({
|
|
422
|
+
gzip: true,
|
|
423
|
+
mutateManifest: (m) => {
|
|
424
|
+
m.layers[0].mediaType = MEDIA.dockerLayerGzip;
|
|
425
|
+
m.layers[1].mediaType = MEDIA.ociLayer;
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
const job2 = await svc.push({ owner: "cldmv", image: "hoot", tag: "mixed", archive: put(mixed.archive) });
|
|
429
|
+
await svc.queue.idle();
|
|
430
|
+
expect((svc.status({ owner: "cldmv", job: job2.job }) as any).state).toBe("succeeded");
|
|
431
|
+
const man = JSON.parse(reg.manifests.get("mixed")!.bytes.toString());
|
|
432
|
+
expect(man.layers.map((l: any) => l.mediaType)).toEqual([MEDIA.ociLayerGzip, MEDIA.ociLayerGzip]);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("fails the push when the archive cannot be read while gzipping a layer", async () => {
|
|
436
|
+
reg = new FakeRegistry();
|
|
437
|
+
await reg.start();
|
|
438
|
+
const r = new Registry({ base: reg.base, username: "Shinrai", password: PAT, repository: "cldmv/hoot" });
|
|
439
|
+
const buf = ociArchive().archive;
|
|
440
|
+
const p = join(drop, "closed-fd.tar");
|
|
441
|
+
writeFileSync(p, buf);
|
|
442
|
+
const fd = openSync(p, "r");
|
|
443
|
+
const plan = planImage(fd, buf.length);
|
|
444
|
+
closeSync(fd); // the stream over a closed descriptor fails with EBADF
|
|
445
|
+
await expect(pushImage(r, fd, plan, "t", { protectReleaseTags: true })).rejects.toThrow(/EBADF/);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
describe("errors that are not Error objects", () => {
|
|
450
|
+
it("scrubs a non-Error thrown before hand-off, from the tag listing, and from a queued job", async () => {
|
|
451
|
+
const { svc } = await service();
|
|
452
|
+
const login = vi.spyOn(Registry.prototype, "login").mockRejectedValue("plain failure");
|
|
453
|
+
await expect(svc.push({ owner: "cldmv", image: "hoot", tag: "x", archive: put(ociArchive().archive) })).rejects.toThrow("plain failure");
|
|
454
|
+
await expect(svc.tags({ owner: "cldmv", image: "hoot" })).rejects.toThrow("plain failure");
|
|
455
|
+
login.mockRestore();
|
|
456
|
+
|
|
457
|
+
vi.spyOn(Registry.prototype, "blobExists").mockRejectedValue("job failure");
|
|
458
|
+
const job = await svc.push({ owner: "cldmv", image: "hoot", tag: "y", archive: put(ociArchive().archive) });
|
|
459
|
+
await svc.queue.idle();
|
|
460
|
+
expect((svc.status({ owner: "cldmv", job: job.job }) as any)).toMatchObject({ state: "failed", error: "job failure" });
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe("pushImage progress", () => {
|
|
465
|
+
it("reports every phase in order and the final byte count", async () => {
|
|
466
|
+
reg = new FakeRegistry();
|
|
467
|
+
await reg.start();
|
|
468
|
+
const r = new Registry({ base: reg.base, username: "Shinrai", password: PAT, repository: "cldmv/hoot" });
|
|
469
|
+
await r.login("push,pull");
|
|
470
|
+
const buf = ociArchive({ gzip: true }).archive;
|
|
471
|
+
const p = join(drop, "progress.tar");
|
|
472
|
+
writeFileSync(p, buf);
|
|
473
|
+
const fd = openSync(p, "r");
|
|
474
|
+
const phases: string[] = [];
|
|
475
|
+
try {
|
|
476
|
+
const res = await pushImage(r, fd, planImage(fd, buf.length), "p", { protectReleaseTags: true, onProgress: (x) => phases.push(x.phase) });
|
|
477
|
+
expect(res.size).toBeGreaterThan(0);
|
|
478
|
+
} finally {
|
|
479
|
+
closeSync(fd);
|
|
480
|
+
}
|
|
481
|
+
expect([...new Set(phases)]).toEqual(["preparing", "uploading", "manifest", "done"]);
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
describe("JobQueue", () => {
|
|
486
|
+
it("runs jobs one at a time, records failures, survives a throwing hook, and prunes old jobs", async () => {
|
|
487
|
+
const q = new JobQueue({ keepFinished: 2 });
|
|
488
|
+
const order: string[] = [];
|
|
489
|
+
const meta = { owner: "o", image: "i", tag: "t", archive: "a", archiveBytes: 1, format: "f" };
|
|
490
|
+
const ok = (id: string) => ({
|
|
491
|
+
run: async () => {
|
|
492
|
+
order.push(`start ${id}`);
|
|
493
|
+
await new Promise((r) => setTimeout(r, 5));
|
|
494
|
+
order.push(`end ${id}`);
|
|
495
|
+
return { digest: "d", mediaType: "m", size: 1, blobsUploaded: 1, blobsSkipped: 0 };
|
|
496
|
+
},
|
|
497
|
+
finally: () => {
|
|
498
|
+
throw new Error("hook bug");
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
const a = q.submit(meta, ok("a"));
|
|
502
|
+
q.submit(meta, ok("b"));
|
|
503
|
+
const failing = q.submit(meta, { run: async () => Promise.reject(new Error("boom")) });
|
|
504
|
+
const noDescribe = q.submit(meta, { run: async () => Promise.reject(new Error("hidden")) });
|
|
505
|
+
await q.idle();
|
|
506
|
+
expect(order).toEqual(["start a", "end a", "start b", "end b"]);
|
|
507
|
+
expect(q.get(a.job)).toBeUndefined(); // pruned: only the newest 2 finished jobs are kept
|
|
508
|
+
expect(q.get(failing.job)).toMatchObject({ state: "failed", error: "push failed" });
|
|
509
|
+
expect(q.get(noDescribe.job)?.state).toBe("failed");
|
|
510
|
+
expect(q.list("o")).toHaveLength(2);
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("throws QueueFull past maxPending", () => {
|
|
514
|
+
const q = new JobQueue({ maxPending: 1 });
|
|
515
|
+
const meta = { owner: "o", image: "i", tag: "t", archive: "a", archiveBytes: 1, format: "f" };
|
|
516
|
+
q.submit(meta, { run: () => new Promise(() => {}) });
|
|
517
|
+
expect(() => q.submit(meta, { run: () => new Promise(() => {}) })).toThrow(QueueFull);
|
|
518
|
+
});
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
describe("GHCR_CATALOG", () => {
|
|
522
|
+
it("describes all three verbs with strict schemas", () => {
|
|
523
|
+
expect(Object.keys(GHCR_CATALOG)).toEqual(["ghcr_push", "ghcr_push_status", "ghcr_tags"]);
|
|
524
|
+
for (const v of Object.values(GHCR_CATALOG)) expect(v.inputSchema.additionalProperties).toBe(false);
|
|
525
|
+
});
|
|
526
|
+
});
|