@cldmv/gh-broker 0.8.0 → 0.9.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 +40 -10
- package/config.example.json +17 -1
- package/deploy/README.md +3 -2
- package/deploy/install.mjs +5 -3
- package/dist/index.mjs +4261 -1642
- package/dist/index.mjs.map +4 -4
- package/package.json +12 -2
- 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
package/src/upstream.ts
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
* @Project: @cldmv/gh-broker
|
|
4
|
-
* @Filename: /src/upstream.ts
|
|
5
|
-
* @Date: 2026-07-07T00:24:51-07:00 (1783409091)
|
|
6
|
-
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
-
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
-
* -----
|
|
9
|
-
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
-
* @Last modified time: 2026-10-02T11:30:58-07:00 (1790965858)
|
|
11
|
-
* -----
|
|
12
|
-
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
-
*
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
17
|
-
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
18
|
-
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
19
|
-
import type { Config } from "./types.ts";
|
|
20
|
-
import type { Upstream } from "./router.ts";
|
|
21
|
-
|
|
22
|
-
const CLIENT_INFO = { name: "cldmv-gh-broker", version: "0.1.0" };
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Pool of MCP client connections to the hosted GitHub MCP — one per bearer token,
|
|
26
|
-
* so each org's PAT drives its own upstream session. Connections are created lazily
|
|
27
|
-
* and reused; the PAT is only ever set as an `Authorization: Bearer` header and is
|
|
28
|
-
* never logged.
|
|
29
|
-
*/
|
|
30
|
-
export class UpstreamPool implements Upstream {
|
|
31
|
-
private readonly url: URL;
|
|
32
|
-
private readonly clients = new Map<string, Promise<Client>>();
|
|
33
|
-
|
|
34
|
-
constructor(config: Config) {
|
|
35
|
-
this.url = new URL(config.upstream);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
private connect(token: string): Promise<Client> {
|
|
39
|
-
const existing = this.clients.get(token);
|
|
40
|
-
if (existing) return existing;
|
|
41
|
-
const pending = (async (): Promise<Client> => {
|
|
42
|
-
const client = new Client(CLIENT_INFO);
|
|
43
|
-
const transport = new StreamableHTTPClientTransport(this.url, {
|
|
44
|
-
requestInit: { headers: { Authorization: `Bearer ${token}` } }
|
|
45
|
-
});
|
|
46
|
-
await client.connect(transport);
|
|
47
|
-
return client;
|
|
48
|
-
})();
|
|
49
|
-
// Don't cache a failed connect — let the next call retry.
|
|
50
|
-
pending.catch(() => this.clients.delete(token));
|
|
51
|
-
this.clients.set(token, pending);
|
|
52
|
-
return pending;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async forward(tool: string, args: Record<string, unknown>, token: string): Promise<CallToolResult> {
|
|
56
|
-
return this.withReconnect(token, (client) => client.callTool({ name: tool, arguments: args }) as Promise<CallToolResult>);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Fetch upstream tool schemas (owner-independent) to build the advertised catalog. */
|
|
60
|
-
async listToolsFor(token: string): Promise<Tool[]> {
|
|
61
|
-
return this.withReconnect(token, async (client) => (await client.listTools()).tools);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Run an upstream op with a single transparent reconnect. The hosted MCP's
|
|
66
|
-
* streamable-HTTP session can die server-side (idle-session expiry, upstream
|
|
67
|
-
* restart) even though our cached client still looks connected — the next call
|
|
68
|
-
* then fails with "invalid session". Rather than surface that first failure to
|
|
69
|
-
* the agent (and leave it stuck until the whole broker restarts), evict the dead
|
|
70
|
-
* client and retry ONCE with a fresh connection. A genuinely broken call (bad
|
|
71
|
-
* token, real upstream error) fails the retry too and propagates.
|
|
72
|
-
*/
|
|
73
|
-
private async withReconnect<T>(token: string, op: (client: Client) => Promise<T>): Promise<T> {
|
|
74
|
-
for (let attempt = 0; ; attempt++) {
|
|
75
|
-
const client = await this.connect(token);
|
|
76
|
-
try {
|
|
77
|
-
return await op(client);
|
|
78
|
-
} catch (err) {
|
|
79
|
-
// Evict the (possibly stale) session so this retry / the next call reconnects.
|
|
80
|
-
this.clients.delete(token);
|
|
81
|
-
if (attempt >= 1) throw err;
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
* @Project: @cldmv/gh-broker
|
|
4
|
-
* @Filename: /src/verbs/ghcr/archive.ts
|
|
5
|
-
* @Date: 2026-10-31T10:59:13-07:00 (1793469553)
|
|
6
|
-
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
-
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
-
* -----
|
|
9
|
-
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
-
* @Last modified time: 2026-10-31T23:26:33-07:00 (1793514393)
|
|
11
|
-
* -----
|
|
12
|
-
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
-
*
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Opens an image archive from the GHCR drop directory and works out what to push.
|
|
18
|
-
*
|
|
19
|
-
* The archive path comes from the agent, and the broker can read files the agent can't
|
|
20
|
-
* (its own credentials among them). A path that resolves outside the drop directory would
|
|
21
|
-
* let the agent have the broker upload a secret into a registry the agent can then pull
|
|
22
|
-
* from. So the file is opened exactly once, by descriptor, and every check runs against
|
|
23
|
-
* that descriptor rather than the path:
|
|
24
|
-
*
|
|
25
|
-
* 1. lexical: the normalized path must sit under `dropDir`;
|
|
26
|
-
* 2. the real (symlink-resolved) parent directory must be the real `dropDir` or under it,
|
|
27
|
-
* so a symlinked subdirectory pointing elsewhere is refused before anything is opened;
|
|
28
|
-
* steps 1 and 2 give the same answer for every refusal, so a path elsewhere reveals
|
|
29
|
-
* nothing about what exists there;
|
|
30
|
-
* 3. `O_NOFOLLOW | O_NONBLOCK`: the last component can't be a symlink, and a FIFO can't
|
|
31
|
-
* hang the open;
|
|
32
|
-
* 4. `/proc/self/fd/<fd>`: the file actually opened lives under the real `dropDir`. This
|
|
33
|
-
* runs before any check that describes the file, and catches a parent directory swapped
|
|
34
|
-
* between step 2 and the open;
|
|
35
|
-
* 5. `fstat`: a regular file, exactly one link (no hard link to a file elsewhere), not
|
|
36
|
-
* empty, not over `maxArchiveBytes`.
|
|
37
|
-
*
|
|
38
|
-
* All later reads use positional reads on that descriptor; the path is never reopened.
|
|
39
|
-
*
|
|
40
|
-
* Two archive formats are read:
|
|
41
|
-
* - OCI image layout (`index.json` + `blobs/sha256/…`): `docker save` on Docker 25+,
|
|
42
|
-
* `docker buildx build --output type=oci`, `skopeo copy … oci-archive:`.
|
|
43
|
-
* - docker-archive (`manifest.json` only): `docker save` on older Docker.
|
|
44
|
-
*
|
|
45
|
-
* Layers that are already gzip/zstd-compressed are pushed byte-for-byte. Uncompressed
|
|
46
|
-
* layers (what `docker save` writes for the classic image store) are gzipped on the fly at
|
|
47
|
-
* push time, and the manifest is then rebuilt as an OCI image manifest.
|
|
48
|
-
*/
|
|
49
|
-
|
|
50
|
-
import { closeSync, constants, fstatSync, openSync, readlinkSync, realpathSync } from "node:fs";
|
|
51
|
-
import { createHash } from "node:crypto";
|
|
52
|
-
import { dirname, isAbsolute, join, normalize } from "node:path";
|
|
53
|
-
import { indexTar, peekMember, readMember, type TarIndex, type TarMember } from "./tar.ts";
|
|
54
|
-
|
|
55
|
-
export const MEDIA = {
|
|
56
|
-
ociIndex: "application/vnd.oci.image.index.v1+json",
|
|
57
|
-
ociManifest: "application/vnd.oci.image.manifest.v1+json",
|
|
58
|
-
ociConfig: "application/vnd.oci.image.config.v1+json",
|
|
59
|
-
ociLayer: "application/vnd.oci.image.layer.v1.tar",
|
|
60
|
-
ociLayerGzip: "application/vnd.oci.image.layer.v1.tar+gzip",
|
|
61
|
-
ociLayerZstd: "application/vnd.oci.image.layer.v1.tar+zstd",
|
|
62
|
-
dockerList: "application/vnd.docker.distribution.manifest.list.v2+json",
|
|
63
|
-
dockerManifest: "application/vnd.docker.distribution.manifest.v2+json",
|
|
64
|
-
dockerConfig: "application/vnd.docker.container.image.v1+json",
|
|
65
|
-
dockerLayer: "application/vnd.docker.image.rootfs.diff.tar",
|
|
66
|
-
dockerLayerGzip: "application/vnd.docker.image.rootfs.diff.tar.gzip"
|
|
67
|
-
} as const;
|
|
68
|
-
|
|
69
|
-
const MANIFEST_TYPES = new Set<string>([MEDIA.ociManifest, MEDIA.dockerManifest]);
|
|
70
|
-
const INDEX_TYPES = new Set<string>([MEDIA.ociIndex, MEDIA.dockerList]);
|
|
71
|
-
const LAYER_TYPES = new Set<string>([MEDIA.ociLayer, MEDIA.ociLayerGzip, MEDIA.ociLayerZstd, MEDIA.dockerLayer, MEDIA.dockerLayerGzip]);
|
|
72
|
-
const CONFIG_TYPES = new Set<string>([MEDIA.ociConfig, MEDIA.dockerConfig]);
|
|
73
|
-
const DIGEST = /^sha256:[a-f0-9]{64}$/;
|
|
74
|
-
const MAX_JSON = 4 * 1024 * 1024;
|
|
75
|
-
export const MAX_LAYERS = 256;
|
|
76
|
-
|
|
77
|
-
export class ArchiveError extends Error {}
|
|
78
|
-
|
|
79
|
-
const fail = (msg: string): never => {
|
|
80
|
-
throw new ArchiveError(`archive: ${msg}`);
|
|
81
|
-
};
|
|
82
|
-
|
|
83
|
-
export interface OpenedArchive {
|
|
84
|
-
fd: number;
|
|
85
|
-
/** The real path of the opened file (inside dropDir). */
|
|
86
|
-
path: string;
|
|
87
|
-
size: number;
|
|
88
|
-
close(): void;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Securely open `input` (absolute, or relative to dropDir) — see the file comment for the checks. */
|
|
92
|
-
export function openArchive(input: unknown, dropDir: string, maxBytes: number): OpenedArchive {
|
|
93
|
-
if (typeof input !== "string" || !input || input.length > 4096 || input.includes("\0")) fail('"archive" must be a file path inside the drop directory');
|
|
94
|
-
const candidate = normalize(isAbsolute(input as string) ? (input as string) : join(dropDir, input as string));
|
|
95
|
-
// One answer for every path that can't be confirmed to be in the drop dir (outside it,
|
|
96
|
-
// behind a symlinked or missing subdirectory), so none of them reveals anything.
|
|
97
|
-
const outside = `"archive" must be an existing file inside the drop directory ${dropDir}`;
|
|
98
|
-
if (!candidate.startsWith(`${dropDir}/`)) fail(outside);
|
|
99
|
-
|
|
100
|
-
let realDrop: string;
|
|
101
|
-
try {
|
|
102
|
-
realDrop = realpathSync(dropDir);
|
|
103
|
-
} catch {
|
|
104
|
-
return fail(`drop directory ${dropDir} does not exist — the installer creates it`);
|
|
105
|
-
}
|
|
106
|
-
// A symlinked subdirectory would make the open below follow it out of the drop dir.
|
|
107
|
-
let realParent: string;
|
|
108
|
-
try {
|
|
109
|
-
realParent = realpathSync(dirname(candidate));
|
|
110
|
-
} catch {
|
|
111
|
-
return fail(outside);
|
|
112
|
-
}
|
|
113
|
-
if (realParent !== realDrop && !realParent.startsWith(`${realDrop}/`)) fail(outside);
|
|
114
|
-
|
|
115
|
-
let fd: number;
|
|
116
|
-
try {
|
|
117
|
-
fd = openSync(candidate, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
118
|
-
} catch (err) {
|
|
119
|
-
const code = (err as NodeJS.ErrnoException).code;
|
|
120
|
-
if (code === "ELOOP") return fail(`${candidate} is a symlink — put the archive file itself in the drop directory`);
|
|
121
|
-
if (code === "ENOENT" || code === "ENOTDIR") return fail(`${candidate} not found`);
|
|
122
|
-
if (code === "EACCES") return fail(`${candidate} is not readable by the broker — make it group-readable (chmod g+r)`);
|
|
123
|
-
// openSync errors always carry an errno code, so the "error" fallback is unreachable; it only satisfies the type.
|
|
124
|
-
/* v8 ignore next */
|
|
125
|
-
return fail(`cannot open ${candidate} (${code ?? "error"})`);
|
|
126
|
-
}
|
|
127
|
-
try {
|
|
128
|
-
// Containment first: nothing about a file outside the drop dir is described.
|
|
129
|
-
const real = readlinkSync(`/proc/self/fd/${fd}`);
|
|
130
|
-
// Reachable when a parent directory is swapped for a symlink between the realpath check
|
|
131
|
-
// above and the open (a race the suite cannot drive deterministically); guarded because
|
|
132
|
-
// that race is exactly how a broker-only file could otherwise be read.
|
|
133
|
-
/* v8 ignore next */
|
|
134
|
-
if (!real.startsWith(`${realDrop}/`)) fail(outside);
|
|
135
|
-
const st = fstatSync(fd);
|
|
136
|
-
if (!st.isFile()) fail(`${candidate} is not a regular file`);
|
|
137
|
-
if (st.nlink !== 1) fail(`${candidate} has ${st.nlink} hard links — copy the archive instead of linking it`);
|
|
138
|
-
if (st.size === 0) fail(`${candidate} is empty`);
|
|
139
|
-
if (st.size > maxBytes) fail(`${candidate} is ${st.size} bytes — over the ${maxBytes}-byte limit (ghcr.maxArchiveBytes)`);
|
|
140
|
-
let closed = false;
|
|
141
|
-
return {
|
|
142
|
-
fd,
|
|
143
|
-
path: real,
|
|
144
|
-
size: st.size,
|
|
145
|
-
close() {
|
|
146
|
-
if (closed) return;
|
|
147
|
-
closed = true;
|
|
148
|
-
closeSync(fd);
|
|
149
|
-
}
|
|
150
|
-
};
|
|
151
|
-
} catch (err) {
|
|
152
|
-
closeSync(fd);
|
|
153
|
-
throw err;
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/** One blob to push. `digest`/`size` are what the registry will hold (known up front unless `compress`). */
|
|
158
|
-
export interface BlobPlan {
|
|
159
|
-
member: TarMember;
|
|
160
|
-
mediaType: string;
|
|
161
|
-
/** Digest + size in the archive (for a compressed-on-push layer: of the uncompressed tar). */
|
|
162
|
-
sourceDigest?: string;
|
|
163
|
-
sourceSize: number;
|
|
164
|
-
/** Gzip this layer while pushing it (archive holds it uncompressed). */
|
|
165
|
-
compress: boolean;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export interface ImagePlan {
|
|
169
|
-
format: "oci-layout" | "docker-archive";
|
|
170
|
-
config: BlobPlan & { digest: string };
|
|
171
|
-
layers: BlobPlan[];
|
|
172
|
-
/** Set when the archive's own manifest can be pushed unchanged (nothing recompressed or retyped). */
|
|
173
|
-
verbatimManifest?: { bytes: Buffer; mediaType: string; digest: string };
|
|
174
|
-
annotations?: Record<string, string>;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
const sha256 = (b: Buffer): string => `sha256:${createHash("sha256").update(b).digest("hex")}`;
|
|
178
|
-
|
|
179
|
-
function parseJson(buf: Buffer, what: string): unknown {
|
|
180
|
-
try {
|
|
181
|
-
return JSON.parse(buf.toString("utf8"));
|
|
182
|
-
} catch {
|
|
183
|
-
return fail(`${what} is not valid JSON`);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
interface Descriptor {
|
|
188
|
-
mediaType?: string;
|
|
189
|
-
digest?: string;
|
|
190
|
-
size?: number;
|
|
191
|
-
annotations?: Record<string, string>;
|
|
192
|
-
platform?: { os?: string; architecture?: string };
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function blobMember(index: TarIndex, digest: unknown, what: string): TarMember {
|
|
196
|
-
if (typeof digest !== "string" || !DIGEST.test(digest)) fail(`${what} has an unsupported digest (only sha256 is accepted)`);
|
|
197
|
-
const m = index.files.get(`blobs/sha256/${(digest as string).slice("sha256:".length)}`);
|
|
198
|
-
if (!m) fail(`${what} ${digest} is missing from the archive`);
|
|
199
|
-
return m as TarMember;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function readBlobJson(fd: number, index: TarIndex, d: Descriptor, what: string): { bytes: Buffer; json: unknown } {
|
|
203
|
-
const m = blobMember(index, d.digest, what);
|
|
204
|
-
const bytes = readMember(fd, m, MAX_JSON, what);
|
|
205
|
-
if (sha256(bytes) !== d.digest) fail(`${what} does not match its digest`);
|
|
206
|
-
return { bytes, json: parseJson(bytes, what) };
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* The image entries of an index: buildx attestation manifests and unknown/unknown platform
|
|
211
|
-
* entries are not images. When several remain, keep only those whose manifest blob is in
|
|
212
|
-
* the archive: with the containerd image store, `docker save` writes the image's whole
|
|
213
|
-
* multi-platform index but only the blobs of the platform that was pulled.
|
|
214
|
-
*/
|
|
215
|
-
function images(manifests: unknown, index: TarIndex): Descriptor[] {
|
|
216
|
-
if (!Array.isArray(manifests)) return fail("index has no manifests list");
|
|
217
|
-
const list = (manifests as Descriptor[]).filter(
|
|
218
|
-
(d) => d?.annotations?.["vnd.docker.reference.type"] !== "attestation-manifest" && !(d?.platform?.os === "unknown" && d?.platform?.architecture === "unknown")
|
|
219
|
-
);
|
|
220
|
-
if (list.length <= 1) return list;
|
|
221
|
-
return list.filter((d) => typeof d?.digest === "string" && DIGEST.test(d.digest) && index.files.has(`blobs/sha256/${d.digest.slice("sha256:".length)}`));
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
function sniff(fd: number, m: TarMember): "gzip" | "zstd" | "none" {
|
|
225
|
-
const head = peekMember(fd, m, 4);
|
|
226
|
-
if (head.length >= 2 && head[0] === 0x1f && head[1] === 0x8b) return "gzip";
|
|
227
|
-
if (head.length >= 4 && head.readUInt32LE(0) === 0xfd2fb528) return "zstd";
|
|
228
|
-
return "none";
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
/** Decide how a layer is pushed: verbatim when already compressed, gzipped on the fly otherwise. */
|
|
232
|
-
function layerPlan(fd: number, m: TarMember, declared: string | undefined, sourceDigest: string | undefined): BlobPlan {
|
|
233
|
-
if (declared !== undefined && !LAYER_TYPES.has(declared)) fail(`layer media type "${declared}" is not supported (non-distributable and foreign layers are refused)`);
|
|
234
|
-
const kind = sniff(fd, m);
|
|
235
|
-
if (kind === "gzip") {
|
|
236
|
-
const mediaType = declared === MEDIA.ociLayerGzip || declared === MEDIA.dockerLayerGzip ? declared : MEDIA.ociLayerGzip;
|
|
237
|
-
return { member: m, mediaType, sourceDigest, sourceSize: m.size, compress: false };
|
|
238
|
-
}
|
|
239
|
-
if (kind === "zstd") return { member: m, mediaType: MEDIA.ociLayerZstd, sourceDigest, sourceSize: m.size, compress: false };
|
|
240
|
-
return { member: m, mediaType: MEDIA.ociLayerGzip, sourceDigest, sourceSize: m.size, compress: true };
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Read the archive's index/manifest and plan the push. Reads only small JSON members and 4-byte layer heads. */
|
|
244
|
-
export function planImage(fd: number, size: number): ImagePlan {
|
|
245
|
-
const index = indexTar(fd, size);
|
|
246
|
-
const top = index.files.get("index.json");
|
|
247
|
-
if (top) return planOciLayout(fd, index, top);
|
|
248
|
-
const legacy = index.files.get("manifest.json");
|
|
249
|
-
if (legacy) return planDockerArchive(fd, index, legacy);
|
|
250
|
-
return fail("not an image archive (no index.json or manifest.json) — create it with `docker save <image> -o <file>`");
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function planOciLayout(fd: number, index: TarIndex, top: TarMember): ImagePlan {
|
|
254
|
-
const root = parseJson(readMember(fd, top, MAX_JSON, "index.json"), "index.json") as { manifests?: unknown };
|
|
255
|
-
let list = images(root.manifests, index);
|
|
256
|
-
let desc: Descriptor | undefined = list.length === 1 ? list[0] : undefined;
|
|
257
|
-
// docker save / buildx often wrap the image manifest in one more index level.
|
|
258
|
-
if (desc && INDEX_TYPES.has(desc.mediaType ?? "")) {
|
|
259
|
-
const nested = readBlobJson(fd, index, desc, "nested index").json as { manifests?: unknown };
|
|
260
|
-
list = images(nested.manifests, index);
|
|
261
|
-
desc = list.length === 1 ? list[0] : undefined;
|
|
262
|
-
}
|
|
263
|
-
if (!desc) fail(`archive holds ${list.length} images — save exactly one single-platform image per archive`);
|
|
264
|
-
const d = desc as Descriptor;
|
|
265
|
-
if (!MANIFEST_TYPES.has(d.mediaType ?? "")) fail(`manifest media type "${d.mediaType}" is not supported`);
|
|
266
|
-
const { bytes, json } = readBlobJson(fd, index, d, "image manifest");
|
|
267
|
-
const man = json as { config?: Descriptor; layers?: Descriptor[]; annotations?: Record<string, string> };
|
|
268
|
-
if (!man.config || !Array.isArray(man.layers)) fail("image manifest has no config or layers");
|
|
269
|
-
if (man.layers!.length > MAX_LAYERS) fail(`image has ${man.layers!.length} layers — at most ${MAX_LAYERS}`);
|
|
270
|
-
|
|
271
|
-
const cfg = man.config as Descriptor;
|
|
272
|
-
if (!CONFIG_TYPES.has(cfg.mediaType ?? "")) fail(`config media type "${cfg.mediaType}" is not supported`);
|
|
273
|
-
const cfgMember = blobMember(index, cfg.digest, "image config");
|
|
274
|
-
if (cfgMember.size !== cfg.size) fail("image config size does not match its descriptor");
|
|
275
|
-
|
|
276
|
-
let changed = false;
|
|
277
|
-
const layers = man.layers!.map((l, i) => {
|
|
278
|
-
const m = blobMember(index, l.digest, `layer ${i + 1}`);
|
|
279
|
-
if (m.size !== l.size) fail(`layer ${i + 1} size does not match its descriptor`);
|
|
280
|
-
const plan = layerPlan(fd, m, l.mediaType, l.digest);
|
|
281
|
-
if (plan.compress || plan.mediaType !== l.mediaType) changed = true;
|
|
282
|
-
return plan;
|
|
283
|
-
});
|
|
284
|
-
return {
|
|
285
|
-
format: "oci-layout",
|
|
286
|
-
config: { member: cfgMember, mediaType: cfg.mediaType!, digest: cfg.digest!, sourceDigest: cfg.digest, sourceSize: cfgMember.size, compress: false },
|
|
287
|
-
layers,
|
|
288
|
-
...(changed ? {} : { verbatimManifest: { bytes, mediaType: d.mediaType!, digest: d.digest! } }),
|
|
289
|
-
...(man.annotations ? { annotations: man.annotations } : {})
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
function planDockerArchive(fd: number, index: TarIndex, legacy: TarMember): ImagePlan {
|
|
294
|
-
const list = parseJson(readMember(fd, legacy, MAX_JSON, "manifest.json"), "manifest.json");
|
|
295
|
-
if (!Array.isArray(list) || list.length !== 1) fail(`archive holds ${Array.isArray(list) ? list.length : 0} images — save exactly one image per archive`);
|
|
296
|
-
const entry = (list as Array<{ Config?: unknown; Layers?: unknown }>)[0]!;
|
|
297
|
-
if (typeof entry.Config !== "string" || !Array.isArray(entry.Layers)) fail("manifest.json entry has no Config or Layers");
|
|
298
|
-
if ((entry.Layers as unknown[]).length > MAX_LAYERS) fail(`image has ${(entry.Layers as unknown[]).length} layers — at most ${MAX_LAYERS}`);
|
|
299
|
-
|
|
300
|
-
const member = (name: unknown, what: string): TarMember => {
|
|
301
|
-
const m = typeof name === "string" ? index.files.get(name.replace(/^\.?\//, "")) : undefined;
|
|
302
|
-
if (!m) fail(`${what} "${String(name)}" is missing from the archive`);
|
|
303
|
-
return m as TarMember;
|
|
304
|
-
};
|
|
305
|
-
const cfgMember = member(entry.Config, "image config");
|
|
306
|
-
const cfgBytes = readMember(fd, cfgMember, MAX_JSON, "image config");
|
|
307
|
-
parseJson(cfgBytes, "image config");
|
|
308
|
-
const layers = (entry.Layers as unknown[]).map((name, i) => layerPlan(fd, member(name, `layer ${i + 1}`), undefined, undefined));
|
|
309
|
-
return {
|
|
310
|
-
format: "docker-archive",
|
|
311
|
-
config: { member: cfgMember, mediaType: MEDIA.ociConfig, digest: sha256(cfgBytes), sourceSize: cfgMember.size, compress: false },
|
|
312
|
-
layers
|
|
313
|
-
};
|
|
314
|
-
}
|
package/src/verbs/ghcr/config.ts
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
* @Project: @cldmv/gh-broker
|
|
4
|
-
* @Filename: /src/verbs/ghcr/config.ts
|
|
5
|
-
* @Date: 2026-10-31T10:57:30-07:00 (1793469450)
|
|
6
|
-
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
-
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
-
* -----
|
|
9
|
-
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
-
* @Last modified time: 2026-11-01T00:40:55-07:00 (1793518855)
|
|
11
|
-
* -----
|
|
12
|
-
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
-
*
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Config block for the GHCR verbs (`ghcr_push`, `ghcr_push_status`, `ghcr_tags`).
|
|
18
|
-
*
|
|
19
|
-
* GHCR is a separate credential and a separate policy from the GitHub API routing in
|
|
20
|
-
* `config.owners`: the registry does not accept fine-grained PATs, so it needs a classic
|
|
21
|
-
* PAT with `write:packages`, and that token must only ever reach `ghcr.io`. Keeping it in
|
|
22
|
-
* its own block (rather than a third ref on `owners.<o>`) keeps the existing owner
|
|
23
|
-
* credentials untouched and lets the installer treat the whole block as operator data, set with
|
|
24
|
-
* `--ghcr-*` flags or a `--config` file (see deploy/lib/config-merge.mjs).
|
|
25
|
-
*
|
|
26
|
-
* "ghcr": {
|
|
27
|
-
* "dropDir": "/srv/gh-broker/ghcr-drop",
|
|
28
|
-
* "protectReleaseTags": true,
|
|
29
|
-
* "owners": {
|
|
30
|
-
* "your-org": { "username": "your-login", "credential": "cred:your-org-packages", "images": ["your-image", "your-image-worker-*"] }
|
|
31
|
-
* }
|
|
32
|
-
* }
|
|
33
|
-
*
|
|
34
|
-
* Every key is checked strictly (unknown keys are refused) because this is a security
|
|
35
|
-
* policy: a typo such as `image` for `images` must fail loudly, not silently allow nothing
|
|
36
|
-
* or everything.
|
|
37
|
-
*/
|
|
38
|
-
|
|
39
|
-
import { isAbsolute, normalize } from "node:path";
|
|
40
|
-
|
|
41
|
-
export interface GhcrOwnerConfig {
|
|
42
|
-
/** GitHub login the classic PAT belongs to; sent as the Basic-auth user to ghcr.io/token. */
|
|
43
|
-
username: string;
|
|
44
|
-
/** "cred:<name>" ref for the classic PAT (write:packages). */
|
|
45
|
-
credential: string;
|
|
46
|
-
/** Image names under ghcr.io/<owner>/ that may be pushed or listed. `*` matches within one path segment. */
|
|
47
|
-
images: string[];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export interface GhcrConfig {
|
|
51
|
-
/** The only directory archives are read from. Absolute; the installer creates it. */
|
|
52
|
-
dropDir: string;
|
|
53
|
-
/** Refuse to overwrite an existing tag shaped like a release (`1.2.3` / `v1.2.3`). */
|
|
54
|
-
protectReleaseTags: boolean;
|
|
55
|
-
/** Largest archive accepted, in bytes. */
|
|
56
|
-
maxArchiveBytes: number;
|
|
57
|
-
owners: Record<string, GhcrOwnerConfig>;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export const DEFAULT_MAX_ARCHIVE_BYTES = 16 * 1024 ** 3;
|
|
61
|
-
const TOP_KEYS = new Set(["dropDir", "protectReleaseTags", "maxArchiveBytes", "owners"]);
|
|
62
|
-
const OWNER_KEYS = new Set(["username", "credential", "images"]);
|
|
63
|
-
/** Directories that must never be a drop dir: system state, other users' homes, secrets. */
|
|
64
|
-
const FORBIDDEN_ROOTS = ["/etc", "/proc", "/sys", "/dev", "/run", "/boot", "/root", "/home", "/tmp", "/usr", "/bin", "/sbin", "/lib", "/lib64", "/var/tmp"];
|
|
65
|
-
/** One image-name glob: lowercase OCI path components, `*` allowed, `/` separates segments. */
|
|
66
|
-
const IMAGE_GLOB = /^[a-z0-9*](?:[a-z0-9._*-]*[a-z0-9*])?(?:\/[a-z0-9*](?:[a-z0-9._*-]*[a-z0-9*])?)*$/;
|
|
67
|
-
const OWNER_NAME = /^[a-z0-9](?:[a-z0-9-]{0,38})$/;
|
|
68
|
-
|
|
69
|
-
const fail = (msg: string): never => {
|
|
70
|
-
throw new Error(`config.ghcr${msg}`);
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
function strictKeys(o: Record<string, unknown>, allowed: Set<string>, where: string): void {
|
|
74
|
-
const extra = Object.keys(o).filter((k) => !allowed.has(k));
|
|
75
|
-
if (extra.length) fail(`${where}: unknown key(s) ${extra.join(", ")} — expected ${[...allowed].join(", ")}`);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
79
|
-
|
|
80
|
-
export function parseGhcrConfig(raw: unknown): GhcrConfig {
|
|
81
|
-
if (!isObject(raw)) return fail(": must be an object");
|
|
82
|
-
strictKeys(raw, TOP_KEYS, "");
|
|
83
|
-
|
|
84
|
-
const dropDir = raw.dropDir;
|
|
85
|
-
if (typeof dropDir !== "string" || !isAbsolute(dropDir)) fail('.dropDir: must be an absolute path, e.g. "/srv/gh-broker/ghcr-drop"');
|
|
86
|
-
const dir = normalize(dropDir as string).replace(/\/+$/, "");
|
|
87
|
-
if (!dir || dir !== (dropDir as string).replace(/\/+$/, "")) fail(".dropDir: must be a normalized path (no ., .. or doubled slashes)");
|
|
88
|
-
if (dir.split("/").filter(Boolean).length < 2) fail(".dropDir: must be at least two levels deep, e.g. /srv/gh-broker/ghcr-drop");
|
|
89
|
-
for (const root of FORBIDDEN_ROOTS) {
|
|
90
|
-
if (dir === root || dir.startsWith(`${root}/`)) fail(`.dropDir: may not be under ${root}`);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
if (raw.protectReleaseTags !== undefined && typeof raw.protectReleaseTags !== "boolean") fail(".protectReleaseTags: must be a boolean");
|
|
94
|
-
let maxArchiveBytes = DEFAULT_MAX_ARCHIVE_BYTES;
|
|
95
|
-
if (raw.maxArchiveBytes !== undefined) {
|
|
96
|
-
const m = raw.maxArchiveBytes;
|
|
97
|
-
if (typeof m !== "number" || !Number.isSafeInteger(m) || m <= 0) fail(".maxArchiveBytes: must be a positive integer");
|
|
98
|
-
maxArchiveBytes = m as number;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
if (!isObject(raw.owners) || Object.keys(raw.owners).length === 0) fail(".owners: must be an object with at least one owner");
|
|
102
|
-
const owners: Record<string, GhcrOwnerConfig> = {};
|
|
103
|
-
for (const [name, v] of Object.entries(raw.owners as Record<string, unknown>)) {
|
|
104
|
-
const owner = name.toLowerCase();
|
|
105
|
-
if (!OWNER_NAME.test(owner)) fail(`.owners: "${name}" is not a valid GitHub owner name`);
|
|
106
|
-
if (!isObject(v)) fail(`.owners.${name}: must be an object`);
|
|
107
|
-
const o = v as Record<string, unknown>;
|
|
108
|
-
strictKeys(o, OWNER_KEYS, `.owners.${name}`);
|
|
109
|
-
if (typeof o.username !== "string" || !/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/.test(o.username)) fail(`.owners.${name}.username: must be the GitHub login that owns the PAT`);
|
|
110
|
-
if (typeof o.credential !== "string" || !/^cred:[A-Za-z0-9._-]+$/.test(o.credential) || /^cred:\.\.?$/.test(o.credential)) {
|
|
111
|
-
fail(`.owners.${name}.credential: must be a "cred:<name>" ref`);
|
|
112
|
-
}
|
|
113
|
-
if (!Array.isArray(o.images) || o.images.length === 0) fail(`.owners.${name}.images: must be a non-empty list of image names or globs`);
|
|
114
|
-
for (const img of o.images as unknown[]) {
|
|
115
|
-
if (typeof img !== "string" || img.length > 255 || !IMAGE_GLOB.test(img)) {
|
|
116
|
-
fail(`.owners.${name}.images: "${String(img)}" is not a valid image name or glob (lowercase, [a-z0-9._-], "/" between segments, "*" within a segment)`);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
owners[owner] = { username: o.username as string, credential: o.credential as string, images: [...(o.images as string[])] };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
return { dropDir: dir, protectReleaseTags: raw.protectReleaseTags !== false, maxArchiveBytes, owners };
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** Every "cred:<name>" ref the ghcr block references, for eager validation at startup. */
|
|
126
|
-
export function ghcrCredRefs(cfg: GhcrConfig | undefined): string[] {
|
|
127
|
-
return cfg ? Object.values(cfg.owners).map((o) => o.credential) : [];
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/** Compile an image glob to an anchored regex: `*` matches `[a-z0-9._-]*` (never `/`). */
|
|
131
|
-
export function imageGlobToRegExp(glob: string): RegExp {
|
|
132
|
-
const body = glob
|
|
133
|
-
.split("*")
|
|
134
|
-
.map((part) => part.replace(/[.\\^$+?()[\]{}|-]/g, "\\$&"))
|
|
135
|
-
.join("[a-z0-9._-]*");
|
|
136
|
-
return new RegExp(`^${body}$`);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/** True when `image` (a name under the owner, e.g. "hoot-worker-a") is on the owner's allowlist. */
|
|
140
|
-
export function imageAllowed(owner: GhcrOwnerConfig, image: string): boolean {
|
|
141
|
-
return owner.images.some((g) => imageGlobToRegExp(g).test(image));
|
|
142
|
-
}
|
package/src/verbs/ghcr/jobs.ts
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
*
|
|
3
|
-
* @Project: @cldmv/gh-broker
|
|
4
|
-
* @Filename: /src/verbs/ghcr/jobs.ts
|
|
5
|
-
* @Date: 2026-10-31T11:08:29-07:00 (1793470109)
|
|
6
|
-
* @Author: Nate Corcoran <CLDMV>
|
|
7
|
-
* @Email: <Shinrai@users.noreply.github.com>
|
|
8
|
-
* -----
|
|
9
|
-
* @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
|
|
10
|
-
* @Last modified time: 2026-10-31T11:13:29-07:00 (1793470409)
|
|
11
|
-
* -----
|
|
12
|
-
* @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
|
|
13
|
-
*
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* In-memory queue for GHCR push jobs.
|
|
18
|
-
*
|
|
19
|
-
* Images are 1–3 GB, far longer than one MCP call should block, so `ghcr_push` enqueues a
|
|
20
|
-
* job and returns its id; `ghcr_push_status` reads it back. Jobs run one at a time (a
|
|
21
|
-
* push saturates the uplink; two at once only make both slower) in submission order.
|
|
22
|
-
*
|
|
23
|
-
* Bounded on purpose: at most `maxPending` queued-or-running jobs (each holds an open
|
|
24
|
-
* archive descriptor), and only the newest `keepFinished` finished jobs are remembered.
|
|
25
|
-
* State lives in memory only, so a broker restart forgets every job; a push that was
|
|
26
|
-
* running is simply not completed (the registry discards an unfinished upload, and no
|
|
27
|
-
* tag moves until the final manifest PUT).
|
|
28
|
-
*/
|
|
29
|
-
|
|
30
|
-
import { randomUUID } from "node:crypto";
|
|
31
|
-
import type { PushProgress, PushResult } from "./push.ts";
|
|
32
|
-
|
|
33
|
-
export type JobState = "queued" | "running" | "succeeded" | "failed";
|
|
34
|
-
|
|
35
|
-
export interface JobMeta {
|
|
36
|
-
owner: string;
|
|
37
|
-
/** e.g. "ghcr.io/cldmv/hoot" */
|
|
38
|
-
image: string;
|
|
39
|
-
tag: string;
|
|
40
|
-
archive: string;
|
|
41
|
-
archiveBytes: number;
|
|
42
|
-
format: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export interface Job extends JobMeta {
|
|
46
|
-
job: string;
|
|
47
|
-
state: JobState;
|
|
48
|
-
progress?: PushProgress;
|
|
49
|
-
result?: PushResult;
|
|
50
|
-
error?: string;
|
|
51
|
-
createdAt: string;
|
|
52
|
-
startedAt?: string;
|
|
53
|
-
finishedAt?: string;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export interface JobHooks {
|
|
57
|
-
/** Does the work. Report progress through `progress`; resolve with the result or throw. */
|
|
58
|
-
run(progress: (p: PushProgress) => void): Promise<PushResult>;
|
|
59
|
-
/** Always called once the job ends (success, failure, or never started). */
|
|
60
|
-
finally?(job: Job): void;
|
|
61
|
-
/** Turns a thrown error into the message stored on the job (must already be secret-free). */
|
|
62
|
-
describeError?(err: unknown): string;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export class QueueFull extends Error {}
|
|
66
|
-
|
|
67
|
-
export class JobQueue {
|
|
68
|
-
private readonly jobs = new Map<string, Job>();
|
|
69
|
-
private readonly pending: Array<{ job: Job; hooks: JobHooks }> = [];
|
|
70
|
-
private running = false;
|
|
71
|
-
private readonly maxPending: number;
|
|
72
|
-
private readonly keepFinished: number;
|
|
73
|
-
|
|
74
|
-
constructor({ maxPending = 8, keepFinished = 50 }: { maxPending?: number; keepFinished?: number } = {}) {
|
|
75
|
-
this.maxPending = maxPending;
|
|
76
|
-
this.keepFinished = keepFinished;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Queued + running jobs. */
|
|
80
|
-
activeCount(): number {
|
|
81
|
-
return this.pending.length + (this.running ? 1 : 0);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
submit(meta: JobMeta, hooks: JobHooks): Job {
|
|
85
|
-
if (this.activeCount() >= this.maxPending) throw new QueueFull(`ghcr_push: ${this.activeCount()} pushes are already queued or running — wait for one to finish (ghcr_push_status)`);
|
|
86
|
-
const job: Job = { ...meta, job: randomUUID(), state: "queued", createdAt: new Date().toISOString() };
|
|
87
|
-
this.jobs.set(job.job, job);
|
|
88
|
-
this.pending.push({ job, hooks });
|
|
89
|
-
const snapshot = { ...job }; // as submitted, before pump() may flip it to running
|
|
90
|
-
void this.pump();
|
|
91
|
-
return snapshot;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
get(id: string): Job | undefined {
|
|
95
|
-
const j = this.jobs.get(id);
|
|
96
|
-
return j ? structuredClone(j) : undefined;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
list(owner: string): Job[] {
|
|
100
|
-
return [...this.jobs.values()].filter((j) => j.owner === owner).map((j) => structuredClone(j));
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Resolves once nothing is queued or running (for tests and orderly shutdown). */
|
|
104
|
-
async idle(): Promise<void> {
|
|
105
|
-
while (this.activeCount() > 0) await new Promise((r) => setTimeout(r, 5));
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
private async pump(): Promise<void> {
|
|
109
|
-
if (this.running) return;
|
|
110
|
-
const next = this.pending.shift();
|
|
111
|
-
if (!next) return;
|
|
112
|
-
this.running = true;
|
|
113
|
-
const { job, hooks } = next;
|
|
114
|
-
job.state = "running";
|
|
115
|
-
job.startedAt = new Date().toISOString();
|
|
116
|
-
try {
|
|
117
|
-
job.result = await hooks.run((p) => {
|
|
118
|
-
job.progress = p;
|
|
119
|
-
});
|
|
120
|
-
job.state = "succeeded";
|
|
121
|
-
} catch (err) {
|
|
122
|
-
job.state = "failed";
|
|
123
|
-
job.error = hooks.describeError ? hooks.describeError(err) : "push failed";
|
|
124
|
-
} finally {
|
|
125
|
-
job.finishedAt = new Date().toISOString();
|
|
126
|
-
try {
|
|
127
|
-
hooks.finally?.(structuredClone(job));
|
|
128
|
-
} catch {
|
|
129
|
-
/* a cleanup/audit hook must not wedge the queue */
|
|
130
|
-
}
|
|
131
|
-
this.running = false;
|
|
132
|
-
this.prune();
|
|
133
|
-
void this.pump();
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
private prune(): void {
|
|
138
|
-
const finished = [...this.jobs.values()].filter((j) => j.state === "succeeded" || j.state === "failed");
|
|
139
|
-
for (const j of finished.slice(0, Math.max(0, finished.length - this.keepFinished))) this.jobs.delete(j.job);
|
|
140
|
-
}
|
|
141
|
-
}
|