@telorun/runner-core 0.5.2
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/LICENSE +17 -0
- package/README.md +38 -0
- package/dist/backend.d.ts +71 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +2 -0
- package/dist/backend.js.map +1 -0
- package/dist/base-image-catalog.d.ts +110 -0
- package/dist/base-image-catalog.d.ts.map +1 -0
- package/dist/base-image-catalog.js +245 -0
- package/dist/base-image-catalog.js.map +1 -0
- package/dist/capabilities-schema.d.ts +33 -0
- package/dist/capabilities-schema.d.ts.map +1 -0
- package/dist/capabilities-schema.js +44 -0
- package/dist/capabilities-schema.js.map +1 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +93 -0
- package/dist/config.js.map +1 -0
- package/dist/contract.d.ts +170 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +24 -0
- package/dist/contract.js.map +1 -0
- package/dist/debug/relay.d.ts +25 -0
- package/dist/debug/relay.d.ts.map +1 -0
- package/dist/debug/relay.js +89 -0
- package/dist/debug/relay.js.map +1 -0
- package/dist/dependency-key.d.ts +38 -0
- package/dist/dependency-key.d.ts.map +1 -0
- package/dist/dependency-key.js +68 -0
- package/dist/dependency-key.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/routes/capabilities.d.ts +12 -0
- package/dist/routes/capabilities.d.ts.map +1 -0
- package/dist/routes/capabilities.js +15 -0
- package/dist/routes/capabilities.js.map +1 -0
- package/dist/routes/health.d.ts +5 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +8 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/io.d.ts +8 -0
- package/dist/routes/io.d.ts.map +1 -0
- package/dist/routes/io.js +239 -0
- package/dist/routes/io.js.map +1 -0
- package/dist/routes/probe.d.ts +7 -0
- package/dist/routes/probe.d.ts.map +1 -0
- package/dist/routes/probe.js +22 -0
- package/dist/routes/probe.js.map +1 -0
- package/dist/routes/sessions.d.ts +22 -0
- package/dist/routes/sessions.d.ts.map +1 -0
- package/dist/routes/sessions.js +223 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/server.d.ts +34 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +67 -0
- package/dist/server.js.map +1 -0
- package/dist/session/bundle-path.d.ts +12 -0
- package/dist/session/bundle-path.d.ts.map +1 -0
- package/dist/session/bundle-path.js +27 -0
- package/dist/session/bundle-path.js.map +1 -0
- package/dist/session/byte-ring-buffer.d.ts +29 -0
- package/dist/session/byte-ring-buffer.d.ts.map +1 -0
- package/dist/session/byte-ring-buffer.js +54 -0
- package/dist/session/byte-ring-buffer.js.map +1 -0
- package/dist/session/registry.d.ts +62 -0
- package/dist/session/registry.d.ts.map +1 -0
- package/dist/session/registry.js +156 -0
- package/dist/session/registry.js.map +1 -0
- package/dist/session/ring-buffer.d.ts +37 -0
- package/dist/session/ring-buffer.d.ts.map +1 -0
- package/dist/session/ring-buffer.js +64 -0
- package/dist/session/ring-buffer.js.map +1 -0
- package/dist/session/session-id.d.ts +3 -0
- package/dist/session/session-id.d.ts.map +1 -0
- package/dist/session/session-id.js +19 -0
- package/dist/session/session-id.js.map +1 -0
- package/dist/sse/channel.d.ts +11 -0
- package/dist/sse/channel.d.ts.map +1 -0
- package/dist/sse/channel.js +129 -0
- package/dist/sse/channel.js.map +1 -0
- package/package.json +48 -0
- package/src/backend.ts +88 -0
- package/src/base-image-catalog.test.ts +209 -0
- package/src/base-image-catalog.ts +320 -0
- package/src/capabilities-schema.test.ts +54 -0
- package/src/capabilities-schema.ts +71 -0
- package/src/config.ts +122 -0
- package/src/contract.ts +170 -0
- package/src/debug/relay.ts +104 -0
- package/src/dependency-key.test.ts +64 -0
- package/src/dependency-key.ts +105 -0
- package/src/index.ts +33 -0
- package/src/routes/capabilities.ts +20 -0
- package/src/routes/health.ts +9 -0
- package/src/routes/io.ts +265 -0
- package/src/routes/probe.ts +35 -0
- package/src/routes/sessions.ts +270 -0
- package/src/server.ts +108 -0
- package/src/session/bundle-path.ts +27 -0
- package/src/session/byte-ring-buffer.ts +62 -0
- package/src/session/registry.test.ts +34 -0
- package/src/session/registry.ts +185 -0
- package/src/session/ring-buffer.test.ts +54 -0
- package/src/session/ring-buffer.ts +75 -0
- package/src/session/session-id.test.ts +17 -0
- package/src/session/session-id.ts +20 -0
- package/src/sse/channel.ts +154 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A base-image catalog backed by the Docker Hub repository-tags API. A runner
|
|
3
|
+
* advertises the resolved tag list as an `enum` on its session `image` field so
|
|
4
|
+
* the editor renders a base-image picker, and validates a chosen image against
|
|
5
|
+
* the same list server-side — the runner is the source of truth, so a non-editor
|
|
6
|
+
* client can't widen the allowed set.
|
|
7
|
+
*
|
|
8
|
+
* The list is fetched at startup and on a refresh interval, cached in memory,
|
|
9
|
+
* and degrades gracefully: when Docker Hub is unreachable the catalog still
|
|
10
|
+
* serves the configured default (always present), so the runner boots and a
|
|
11
|
+
* session can still start on the default image.
|
|
12
|
+
*
|
|
13
|
+
* Free of runner specifics — it takes only a repository, a default ref, and a
|
|
14
|
+
* tag filter — but the tag source is specifically Docker Hub's registry API
|
|
15
|
+
* (`hub.docker.com/v2`); a base on another registry (GHCR, a private registry)
|
|
16
|
+
* can't be enumerated or digest-resolved here. The Docker registry the kubelet
|
|
17
|
+
* pulls from is a separate concern; this resolves the *menu* of base images, not
|
|
18
|
+
* pull auth.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** A composable predicate set narrowing a repository's tag list to the offered
|
|
22
|
+
* menu. Named flags cover the common cases; `include`/`exclude` are the regex
|
|
23
|
+
* escape hatch for everything else. */
|
|
24
|
+
export interface TagFilter {
|
|
25
|
+
/** Keep only `MAJOR.MINOR.PATCH[-variant]` tags — drops moving tags (`latest`,
|
|
26
|
+
* `0`, `0.30`) and their variants (`latest-slim`, `0-slim`). */
|
|
27
|
+
pinnedOnly?: boolean;
|
|
28
|
+
/** Drop commit-hash tags (`sha-<hex>` or a bare hex string). */
|
|
29
|
+
excludeSha?: boolean;
|
|
30
|
+
/** Drop semver prereleases (`0.30.1-rc.1`, `…-alpha`). Variant suffixes like
|
|
31
|
+
* `-slim` / `-rust-1.95.0` are NOT prereleases and are kept. */
|
|
32
|
+
excludePrerelease?: boolean;
|
|
33
|
+
/** A tag must match at least one of these to be kept. */
|
|
34
|
+
include?: RegExp[];
|
|
35
|
+
/** A tag matching any of these is dropped. */
|
|
36
|
+
exclude?: RegExp[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface BaseImageCatalogOptions {
|
|
40
|
+
/** `namespace/repository`, e.g. `telorun/node`. */
|
|
41
|
+
repository: string;
|
|
42
|
+
/** Always-present default ref — the pre-selected option and the
|
|
43
|
+
* Docker-Hub-unreachable fallback, e.g. `telorun/node:latest-slim`. */
|
|
44
|
+
defaultRef: string;
|
|
45
|
+
filter?: TagFilter;
|
|
46
|
+
/** Cap the advertised list to this many tags (newest first). Default 20. */
|
|
47
|
+
limit?: number;
|
|
48
|
+
/** Background refresh cadence in ms. Default 1h. */
|
|
49
|
+
refreshIntervalMs?: number;
|
|
50
|
+
/** Upper bound on tags pulled from Docker Hub before filtering. Default 200. */
|
|
51
|
+
maxTagsScanned?: number;
|
|
52
|
+
/** Per-request timeout for a Docker Hub call, in ms. Default 10s. */
|
|
53
|
+
requestTimeoutMs?: number;
|
|
54
|
+
/** Injectable fetch for tests; defaults to the global `fetch`. */
|
|
55
|
+
fetchImpl?: typeof fetch;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const SHA_TAG = /^(?:sha-)?[0-9a-f]{7,}$/i;
|
|
59
|
+
const PINNED_TAG = /^\d+\.\d+\.\d+(?:[-.].*)?$/;
|
|
60
|
+
const PRERELEASE_CORE = /^\d+\.\d+\.\d+-(.+)$/;
|
|
61
|
+
/** First `-` identifier on a pinned tag that marks it a prerelease rather than a
|
|
62
|
+
* build variant (`-slim`, `-rust-*`). */
|
|
63
|
+
const PRERELEASE_TOKENS = new Set([
|
|
64
|
+
"rc",
|
|
65
|
+
"alpha",
|
|
66
|
+
"beta",
|
|
67
|
+
"pre",
|
|
68
|
+
"preview",
|
|
69
|
+
"next",
|
|
70
|
+
"canary",
|
|
71
|
+
"dev",
|
|
72
|
+
"nightly",
|
|
73
|
+
"snapshot",
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
function isShaTag(tag: string): boolean {
|
|
77
|
+
return SHA_TAG.test(tag);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isPrerelease(tag: string): boolean {
|
|
81
|
+
const m = PRERELEASE_CORE.exec(tag);
|
|
82
|
+
if (!m) return false;
|
|
83
|
+
const firstId = m[1].split(/[-.]/, 1)[0].toLowerCase();
|
|
84
|
+
return PRERELEASE_TOKENS.has(firstId);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Apply a {@link TagFilter} to a tag list, preserving input order. Pure — the
|
|
88
|
+
* unit-testable core of the catalog. */
|
|
89
|
+
export function filterTags(tags: string[], filter: TagFilter = {}): string[] {
|
|
90
|
+
return tags.filter((tag) => {
|
|
91
|
+
if (filter.excludeSha && isShaTag(tag)) return false;
|
|
92
|
+
if (filter.pinnedOnly && !PINNED_TAG.test(tag)) return false;
|
|
93
|
+
if (filter.excludePrerelease && isPrerelease(tag)) return false;
|
|
94
|
+
if (filter.include && filter.include.length > 0 && !filter.include.some((re) => re.test(tag))) {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
if (filter.exclude && filter.exclude.some((re) => re.test(tag))) return false;
|
|
98
|
+
return true;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function splitRepository(repository: string): [namespace: string, repo: string] {
|
|
103
|
+
const slash = repository.indexOf("/");
|
|
104
|
+
// A bare name (no slash) is a Docker official image under `library/`.
|
|
105
|
+
if (slash < 0) return ["library", repository];
|
|
106
|
+
return [repository.slice(0, slash), repository.slice(slash + 1)];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function unique(refs: string[]): string[] {
|
|
110
|
+
const seen = new Set<string>();
|
|
111
|
+
const out: string[] = [];
|
|
112
|
+
for (const ref of refs) {
|
|
113
|
+
if (!seen.has(ref)) {
|
|
114
|
+
seen.add(ref);
|
|
115
|
+
out.push(ref);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Fetch a repository's tags from Docker Hub, newest first, bounded by
|
|
123
|
+
* `maxTagsScanned`. Paginates the `/v2/repositories/{ns}/{repo}/tags` endpoint
|
|
124
|
+
* and sorts by `last_updated` descending so the cap keeps the most recent tags
|
|
125
|
+
* regardless of the API's page ordering. Throws on a network/HTTP error — the
|
|
126
|
+
* caller decides whether a failure degrades to default-only.
|
|
127
|
+
*/
|
|
128
|
+
async function fetchRepoTags(
|
|
129
|
+
repository: string,
|
|
130
|
+
maxTagsScanned: number,
|
|
131
|
+
requestTimeoutMs: number,
|
|
132
|
+
fetchImpl: typeof fetch,
|
|
133
|
+
): Promise<string[]> {
|
|
134
|
+
const [namespace, repo] = splitRepository(repository);
|
|
135
|
+
const collected: Array<{ name: string; lastUpdated: string }> = [];
|
|
136
|
+
let url: string | null =
|
|
137
|
+
`https://hub.docker.com/v2/repositories/${encodeURIComponent(namespace)}/` +
|
|
138
|
+
`${encodeURIComponent(repo)}/tags?page_size=100`;
|
|
139
|
+
|
|
140
|
+
while (url && collected.length < maxTagsScanned) {
|
|
141
|
+
const res = await fetchImpl(url, {
|
|
142
|
+
headers: { Accept: "application/json" },
|
|
143
|
+
signal: AbortSignal.timeout(requestTimeoutMs),
|
|
144
|
+
});
|
|
145
|
+
if (!res.ok) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Docker Hub tags request for '${repository}' failed: ${res.status} ${res.statusText}`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
const body = (await res.json()) as {
|
|
151
|
+
next?: string | null;
|
|
152
|
+
results?: Array<{ name?: string; last_updated?: string }>;
|
|
153
|
+
};
|
|
154
|
+
for (const r of body.results ?? []) {
|
|
155
|
+
if (typeof r.name === "string") {
|
|
156
|
+
collected.push({ name: r.name, lastUpdated: r.last_updated ?? "" });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
url = body.next ?? null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
collected.sort((a, b) =>
|
|
163
|
+
a.lastUpdated < b.lastUpdated ? 1 : a.lastUpdated > b.lastUpdated ? -1 : 0,
|
|
164
|
+
);
|
|
165
|
+
return collected.map((c) => c.name);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Parse a Docker image reference into the Docker Hub coordinates needed to look
|
|
170
|
+
* up its digest, or `null` when the ref points at a non-Docker-Hub registry.
|
|
171
|
+
* Handles the `library/` shorthand, optional `docker.io` host, and an already
|
|
172
|
+
* `@sha256:`-pinned digest (carried through verbatim).
|
|
173
|
+
*/
|
|
174
|
+
export function parseDockerHubRef(
|
|
175
|
+
ref: string,
|
|
176
|
+
): { namespace: string; repo: string; tag: string; digest?: string } | null {
|
|
177
|
+
let rest = ref;
|
|
178
|
+
let digest: string | undefined;
|
|
179
|
+
const at = ref.indexOf("@");
|
|
180
|
+
if (at >= 0) {
|
|
181
|
+
digest = ref.slice(at + 1);
|
|
182
|
+
rest = ref.slice(0, at);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// A first path segment with a `.`/`:` (or `localhost`) is a registry host.
|
|
186
|
+
const firstSlash = rest.indexOf("/");
|
|
187
|
+
const firstPart = firstSlash >= 0 ? rest.slice(0, firstSlash) : "";
|
|
188
|
+
if (firstPart.includes(".") || firstPart.includes(":") || firstPart === "localhost") {
|
|
189
|
+
const hubHosts = new Set(["docker.io", "index.docker.io", "registry-1.docker.io"]);
|
|
190
|
+
if (!hubHosts.has(firstPart)) return null;
|
|
191
|
+
rest = rest.slice(firstSlash + 1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// `rest` is now `name[:tag]`; the tag is the segment after a colon that isn't
|
|
195
|
+
// inside the path (i.e. after the last `/`).
|
|
196
|
+
const lastColon = rest.lastIndexOf(":");
|
|
197
|
+
const lastSlash = rest.lastIndexOf("/");
|
|
198
|
+
let name = rest;
|
|
199
|
+
let tag = "latest";
|
|
200
|
+
if (lastColon > lastSlash) {
|
|
201
|
+
name = rest.slice(0, lastColon);
|
|
202
|
+
tag = rest.slice(lastColon + 1);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const slash = name.indexOf("/");
|
|
206
|
+
return {
|
|
207
|
+
namespace: slash >= 0 ? name.slice(0, slash) : "library",
|
|
208
|
+
repo: slash >= 0 ? name.slice(slash + 1) : name,
|
|
209
|
+
tag,
|
|
210
|
+
digest,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Resolve a Docker Hub image ref's CURRENT manifest digest. A moving tag
|
|
216
|
+
* (`latest-slim`) maps to a new digest once a new version is published, so a
|
|
217
|
+
* build that folds this into its cache key detects that the base moved and
|
|
218
|
+
* rebuilds. An already `@sha256:`-pinned ref returns its digest without a
|
|
219
|
+
* network call. Returns `undefined` for a non-Docker-Hub ref (not an error —
|
|
220
|
+
* resolution simply doesn't apply) or when a Hub request fails. A genuine
|
|
221
|
+
* request failure is reported to `onError` rather than swallowed, so an operator
|
|
222
|
+
* whose Hub calls persistently fail (auth / network / rate-limit) gets a signal
|
|
223
|
+
* instead of an unexplained "always reuses the cached image".
|
|
224
|
+
*/
|
|
225
|
+
export async function resolveTagDigest(
|
|
226
|
+
ref: string,
|
|
227
|
+
opts: {
|
|
228
|
+
fetchImpl?: typeof fetch;
|
|
229
|
+
requestTimeoutMs?: number;
|
|
230
|
+
onError?: (err: unknown) => void;
|
|
231
|
+
} = {},
|
|
232
|
+
): Promise<string | undefined> {
|
|
233
|
+
const parsed = parseDockerHubRef(ref);
|
|
234
|
+
if (!parsed) return undefined;
|
|
235
|
+
if (parsed.digest) return parsed.digest;
|
|
236
|
+
|
|
237
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
238
|
+
const url =
|
|
239
|
+
`https://hub.docker.com/v2/repositories/${encodeURIComponent(parsed.namespace)}/` +
|
|
240
|
+
`${encodeURIComponent(parsed.repo)}/tags/${encodeURIComponent(parsed.tag)}`;
|
|
241
|
+
try {
|
|
242
|
+
const res = await fetchImpl(url, {
|
|
243
|
+
headers: { Accept: "application/json" },
|
|
244
|
+
signal: AbortSignal.timeout(opts.requestTimeoutMs ?? 10_000),
|
|
245
|
+
});
|
|
246
|
+
if (!res.ok) {
|
|
247
|
+
opts.onError?.(new Error(`Docker Hub tag request for '${ref}' failed: ${res.status} ${res.statusText}`));
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
const body = (await res.json()) as { digest?: string; images?: Array<{ digest?: string }> };
|
|
251
|
+
return body.digest ?? body.images?.find((i) => i.digest)?.digest ?? undefined;
|
|
252
|
+
} catch (err) {
|
|
253
|
+
opts.onError?.(err);
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export class BaseImageCatalog {
|
|
259
|
+
private readonly repository: string;
|
|
260
|
+
private readonly defaultRef: string;
|
|
261
|
+
private readonly filter: TagFilter;
|
|
262
|
+
private readonly limit: number;
|
|
263
|
+
private readonly refreshIntervalMs: number;
|
|
264
|
+
private readonly maxTagsScanned: number;
|
|
265
|
+
private readonly requestTimeoutMs: number;
|
|
266
|
+
private readonly fetchImpl: typeof fetch;
|
|
267
|
+
/** Filtered tags (bare, newest first); empty until the first refresh. */
|
|
268
|
+
private tags: string[] = [];
|
|
269
|
+
private timer?: ReturnType<typeof setInterval>;
|
|
270
|
+
|
|
271
|
+
constructor(opts: BaseImageCatalogOptions) {
|
|
272
|
+
this.repository = opts.repository;
|
|
273
|
+
this.defaultRef = opts.defaultRef;
|
|
274
|
+
this.filter = opts.filter ?? {};
|
|
275
|
+
this.limit = opts.limit ?? 20;
|
|
276
|
+
this.refreshIntervalMs = opts.refreshIntervalMs ?? 60 * 60 * 1000;
|
|
277
|
+
this.maxTagsScanned = opts.maxTagsScanned ?? 200;
|
|
278
|
+
this.requestTimeoutMs = opts.requestTimeoutMs ?? 10_000;
|
|
279
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** The advertised image refs: the default first, then the filtered tags. */
|
|
283
|
+
current(): string[] {
|
|
284
|
+
const refs = this.tags.map((tag) => `${this.repository}:${tag}`);
|
|
285
|
+
return unique([this.defaultRef, ...refs]);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Whether `ref` is offered by this catalog (server-side allowlist check). */
|
|
289
|
+
isAllowed(ref: string): boolean {
|
|
290
|
+
return this.current().includes(ref);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Refetch and re-filter the tag list. Throws on a fetch/HTTP failure. */
|
|
294
|
+
async refresh(): Promise<void> {
|
|
295
|
+
const tags = await fetchRepoTags(
|
|
296
|
+
this.repository,
|
|
297
|
+
this.maxTagsScanned,
|
|
298
|
+
this.requestTimeoutMs,
|
|
299
|
+
this.fetchImpl,
|
|
300
|
+
);
|
|
301
|
+
this.tags = filterTags(tags, this.filter).slice(0, this.limit);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Begin periodic background refresh. Refresh failures are surfaced to
|
|
305
|
+
* `onError` rather than swallowed; the last good list keeps serving. */
|
|
306
|
+
start(onError?: (err: unknown) => void): void {
|
|
307
|
+
if (this.timer) return;
|
|
308
|
+
this.timer = setInterval(() => {
|
|
309
|
+
this.refresh().catch((err) => onError?.(err));
|
|
310
|
+
}, this.refreshIntervalMs);
|
|
311
|
+
this.timer.unref?.();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
stop(): void {
|
|
315
|
+
if (this.timer) {
|
|
316
|
+
clearInterval(this.timer);
|
|
317
|
+
this.timer = undefined;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { sessionConfigSchema } from "./capabilities-schema.js";
|
|
4
|
+
|
|
5
|
+
describe("sessionConfigSchema", () => {
|
|
6
|
+
it("advertises editable image/pullPolicy with defaults", () => {
|
|
7
|
+
const schema = sessionConfigSchema({ imageDefault: "telorun/node:0-slim" });
|
|
8
|
+
const props = schema.properties as Record<string, Record<string, unknown>>;
|
|
9
|
+
expect(props.image.default).toBe("telorun/node:0-slim");
|
|
10
|
+
expect(props.image.readOnly).toBeUndefined();
|
|
11
|
+
expect(props.pullPolicy.default).toBe("missing");
|
|
12
|
+
expect(props.registryUrl).toBeUndefined();
|
|
13
|
+
expect(schema.required).toEqual(["image", "pullPolicy"]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("locks image (only) when enforced; pullPolicy stays editable", () => {
|
|
17
|
+
const schema = sessionConfigSchema({ imageDefault: "telorun/node:latest-slim", enforced: true });
|
|
18
|
+
const props = schema.properties as Record<string, Record<string, unknown>>;
|
|
19
|
+
expect(props.image.readOnly).toBe(true);
|
|
20
|
+
expect(props.image.default).toBe("telorun/node:latest-slim");
|
|
21
|
+
expect(props.pullPolicy.readOnly).toBeUndefined();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("includes registryUrl only when requested", () => {
|
|
25
|
+
const schema = sessionConfigSchema({ imageDefault: "x", registryUrl: true });
|
|
26
|
+
const props = schema.properties as Record<string, unknown>;
|
|
27
|
+
expect(props.registryUrl).toBeDefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("renders image as an editable enum picker — overriding enforced — when imageEnum is set", () => {
|
|
31
|
+
const schema = sessionConfigSchema({
|
|
32
|
+
imageDefault: "telorun/node:latest-slim",
|
|
33
|
+
enforced: true,
|
|
34
|
+
imageEnum: ["telorun/node:latest-slim", "telorun/node:0.30.1-slim"],
|
|
35
|
+
});
|
|
36
|
+
const props = schema.properties as Record<string, Record<string, unknown>>;
|
|
37
|
+
expect(props.image.enum).toEqual(["telorun/node:latest-slim", "telorun/node:0.30.1-slim"]);
|
|
38
|
+
expect(props.image.readOnly).toBeUndefined();
|
|
39
|
+
expect(props.image.default).toBe("telorun/node:latest-slim");
|
|
40
|
+
// pullPolicy is a client-editable freshness control.
|
|
41
|
+
expect(props.pullPolicy.readOnly).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("ignores an empty imageEnum and falls back to the enforced field", () => {
|
|
45
|
+
const schema = sessionConfigSchema({
|
|
46
|
+
imageDefault: "telorun/node:latest-slim",
|
|
47
|
+
enforced: true,
|
|
48
|
+
imageEnum: [],
|
|
49
|
+
});
|
|
50
|
+
const props = schema.properties as Record<string, Record<string, unknown>>;
|
|
51
|
+
expect(props.image.enum).toBeUndefined();
|
|
52
|
+
expect(props.image.readOnly).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { JsonSchema, PullPolicy } from "./contract.js";
|
|
2
|
+
|
|
3
|
+
export interface SessionConfigSchemaOptions {
|
|
4
|
+
/** Default container image; becomes the field's advertised `default`. */
|
|
5
|
+
imageDefault: string;
|
|
6
|
+
/** When set (and non-empty), constrains `image` to this allowlist — advertised
|
|
7
|
+
* as a JSON Schema `enum` so the editor renders a base-image picker. The
|
|
8
|
+
* picker is editable within the list, so an `imageEnum` overrides `enforced`
|
|
9
|
+
* for the `image` field (a one-entry enum is effectively locked anyway). */
|
|
10
|
+
imageEnum?: string[];
|
|
11
|
+
/** Default pull policy (defaults to `missing`). */
|
|
12
|
+
pullPolicyDefault?: PullPolicy;
|
|
13
|
+
/** Override the `pullPolicy` field description. `pullPolicy` semantics differ
|
|
14
|
+
* per backend (docker: container pull policy; k8s: base-image build
|
|
15
|
+
* freshness), so each runner can supply wording that matches its behaviour. */
|
|
16
|
+
pullPolicyDescription?: string;
|
|
17
|
+
/** When true, `image` is server-enforced — advertised `readOnly` (locked to
|
|
18
|
+
* `imageDefault`) so the editor renders it disabled but still sends it. No
|
|
19
|
+
* effect once `imageEnum` is set (the picker is the constraint). `pullPolicy`
|
|
20
|
+
* is always client-editable. */
|
|
21
|
+
enforced?: boolean;
|
|
22
|
+
/** Include the optional `registryUrl` field (a runner that forwards a module
|
|
23
|
+
* registry URL to its workloads). */
|
|
24
|
+
registryUrl?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Builds the JSON Schema a runner advertises on `/v1/capabilities` for the
|
|
29
|
+
* editable `SessionConfig` surface. `baseUrl` is deliberately absent — the
|
|
30
|
+
* client owns it. Server-enforced runners pass `enforced: true` to lock `image`
|
|
31
|
+
* as `readOnly` (the value still travels on the wire); `pullPolicy` is always
|
|
32
|
+
* client-editable.
|
|
33
|
+
*/
|
|
34
|
+
export function sessionConfigSchema(opts: SessionConfigSchemaOptions): JsonSchema {
|
|
35
|
+
const readOnly = opts.enforced === true;
|
|
36
|
+
const hasEnum = Array.isArray(opts.imageEnum) && opts.imageEnum.length > 0;
|
|
37
|
+
const properties: Record<string, JsonSchema> = {
|
|
38
|
+
image: {
|
|
39
|
+
type: "string",
|
|
40
|
+
minLength: 1,
|
|
41
|
+
default: opts.imageDefault,
|
|
42
|
+
title: "Image",
|
|
43
|
+
description: "Container image the runner spawns for each run.",
|
|
44
|
+
// An allowlist renders as an editable picker; otherwise fall back to the
|
|
45
|
+
// enforced (readOnly) single value.
|
|
46
|
+
...(hasEnum ? { enum: opts.imageEnum } : readOnly ? { readOnly: true } : {}),
|
|
47
|
+
},
|
|
48
|
+
pullPolicy: {
|
|
49
|
+
type: "string",
|
|
50
|
+
enum: ["missing", "always", "never"],
|
|
51
|
+
default: opts.pullPolicyDefault ?? "missing",
|
|
52
|
+
title: "Pull policy",
|
|
53
|
+
description:
|
|
54
|
+
opts.pullPolicyDescription ??
|
|
55
|
+
"`missing` pulls on first use; `always` forces a pull every run; `never` fails if the image isn't present.",
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
if (opts.registryUrl) {
|
|
59
|
+
properties.registryUrl = {
|
|
60
|
+
type: "string",
|
|
61
|
+
title: "Registry URL",
|
|
62
|
+
description:
|
|
63
|
+
"Optional base URL for the telo module registry, forwarded to the runner as TELO_REGISTRY_URL. Leave blank for the default registry.",
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
type: "object",
|
|
68
|
+
required: ["image", "pullPolicy"],
|
|
69
|
+
properties,
|
|
70
|
+
};
|
|
71
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
|
|
4
|
+
import type { RunnerTerms } from "./contract.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Backend-neutral runner configuration. Concrete runners (docker, k8s) extend
|
|
8
|
+
* `RunnerCoreConfig` with their own fields and reuse these parse helpers so
|
|
9
|
+
* config validation and error reporting stay identical across backends.
|
|
10
|
+
*/
|
|
11
|
+
export interface RunnerCoreConfig {
|
|
12
|
+
port: number;
|
|
13
|
+
logLevel: string;
|
|
14
|
+
maxSessions: number;
|
|
15
|
+
exitTtlMs: number;
|
|
16
|
+
replayBufferBytes: number;
|
|
17
|
+
corsOrigins: string[] | "*";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class RunnerConfigError extends Error {}
|
|
21
|
+
|
|
22
|
+
export function parsePositiveInt(
|
|
23
|
+
raw: string | undefined,
|
|
24
|
+
fallback: number,
|
|
25
|
+
field: string,
|
|
26
|
+
): number {
|
|
27
|
+
if (raw === undefined || raw.trim() === "") return fallback;
|
|
28
|
+
const n = Number(raw);
|
|
29
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
30
|
+
throw new RunnerConfigError(`${field} must be a positive integer, got '${raw}'.`);
|
|
31
|
+
}
|
|
32
|
+
return n;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function parseBool(raw: string | undefined, fallback: boolean, field: string): boolean {
|
|
36
|
+
const v = raw?.trim().toLowerCase();
|
|
37
|
+
if (v === undefined || v === "") return fallback;
|
|
38
|
+
if (v === "true" || v === "1" || v === "yes") return true;
|
|
39
|
+
if (v === "false" || v === "0" || v === "no") return false;
|
|
40
|
+
throw new RunnerConfigError(`${field} must be a boolean (true/false), got '${raw}'.`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function parsePort(raw: string | undefined, fallback: number): number {
|
|
44
|
+
const portStr = raw ?? String(fallback);
|
|
45
|
+
const port = Number(portStr);
|
|
46
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
47
|
+
throw new RunnerConfigError(`PORT must be an integer in 1..65535, got '${portStr}'.`);
|
|
48
|
+
}
|
|
49
|
+
return port;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the runner's usage terms from the environment. Terms are opt-in: returns
|
|
54
|
+
* `undefined` (no gate) unless a non-empty body is provided.
|
|
55
|
+
*
|
|
56
|
+
* The body comes from `RUNNER_TERMS_FILE` (a path read at startup — the natural
|
|
57
|
+
* fit for a k8s ConfigMap mount or a bind-mounted file) or, for short notes,
|
|
58
|
+
* inline `RUNNER_TERMS_BODY`. `RUNNER_TERMS_VERSION` is optional and defaults to
|
|
59
|
+
* a short hash of the body, so editing the agreement automatically re-prompts
|
|
60
|
+
* every client; set it explicitly only to control "material change vs. typo".
|
|
61
|
+
* A configured-but-unreadable `RUNNER_TERMS_FILE` is a hard error rather than a
|
|
62
|
+
* silent "no terms", so a misconfiguration can't quietly drop the gate.
|
|
63
|
+
*/
|
|
64
|
+
export function loadTermsFromEnv(env: NodeJS.ProcessEnv): RunnerTerms | undefined {
|
|
65
|
+
const body = resolveTermsBody(env);
|
|
66
|
+
if (!body || body.trim() === "") return undefined;
|
|
67
|
+
return {
|
|
68
|
+
version: env.RUNNER_TERMS_VERSION?.trim() || hashTermsVersion(body),
|
|
69
|
+
title: env.RUNNER_TERMS_TITLE?.trim() || "Usage agreement",
|
|
70
|
+
body,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function resolveTermsBody(env: NodeJS.ProcessEnv): string | undefined {
|
|
75
|
+
const file = env.RUNNER_TERMS_FILE?.trim();
|
|
76
|
+
if (file) {
|
|
77
|
+
try {
|
|
78
|
+
return readFileSync(file, "utf8");
|
|
79
|
+
} catch (err) {
|
|
80
|
+
throw new RunnerConfigError(
|
|
81
|
+
`RUNNER_TERMS_FILE could not be read at '${file}': ${(err as Error).message}`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return env.RUNNER_TERMS_BODY;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Short content fingerprint used as the terms version when none is set. Any
|
|
89
|
+
* edit to the body changes it, which re-prompts every client. */
|
|
90
|
+
function hashTermsVersion(body: string): string {
|
|
91
|
+
return createHash("sha256").update(body).digest("hex").slice(0, 12);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function parseCorsOrigins(raw: string | undefined): string[] | "*" {
|
|
95
|
+
if (raw === undefined || raw.trim() === "" || raw.trim() === "*") return "*";
|
|
96
|
+
return raw
|
|
97
|
+
.split(",")
|
|
98
|
+
.map((s) => s.trim())
|
|
99
|
+
.filter((s) => s.length > 0);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function loadCoreConfig(
|
|
103
|
+
env: NodeJS.ProcessEnv,
|
|
104
|
+
defaults: { port?: number } = {},
|
|
105
|
+
): RunnerCoreConfig {
|
|
106
|
+
return {
|
|
107
|
+
port: parsePort(env.PORT, defaults.port ?? 8061),
|
|
108
|
+
logLevel: env.LOG_LEVEL?.trim() || "info",
|
|
109
|
+
maxSessions: parsePositiveInt(env.RUNNER_MAX_SESSIONS, 32, "RUNNER_MAX_SESSIONS"),
|
|
110
|
+
// Exited sessions linger so the editor can re-attach and replay their console
|
|
111
|
+
// + inspection history after a page reload. The registry evicts the oldest
|
|
112
|
+
// *terminal* session early when at capacity, so a long TTL never blocks a new
|
|
113
|
+
// run while live sessions are protected.
|
|
114
|
+
exitTtlMs: parsePositiveInt(env.RUNNER_EXIT_TTL_MS, 4 * 60 * 60 * 1000, "RUNNER_EXIT_TTL_MS"),
|
|
115
|
+
replayBufferBytes: parsePositiveInt(
|
|
116
|
+
env.RUNNER_REPLAY_BUFFER_BYTES,
|
|
117
|
+
5_000_000,
|
|
118
|
+
"RUNNER_REPLAY_BUFFER_BYTES",
|
|
119
|
+
),
|
|
120
|
+
corsOrigins: parseCorsOrigins(env.RUNNER_CORS_ORIGINS),
|
|
121
|
+
};
|
|
122
|
+
}
|