@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.
Files changed (109) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +38 -0
  3. package/dist/backend.d.ts +71 -0
  4. package/dist/backend.d.ts.map +1 -0
  5. package/dist/backend.js +2 -0
  6. package/dist/backend.js.map +1 -0
  7. package/dist/base-image-catalog.d.ts +110 -0
  8. package/dist/base-image-catalog.d.ts.map +1 -0
  9. package/dist/base-image-catalog.js +245 -0
  10. package/dist/base-image-catalog.js.map +1 -0
  11. package/dist/capabilities-schema.d.ts +33 -0
  12. package/dist/capabilities-schema.d.ts.map +1 -0
  13. package/dist/capabilities-schema.js +44 -0
  14. package/dist/capabilities-schema.js.map +1 -0
  15. package/dist/config.d.ts +37 -0
  16. package/dist/config.d.ts.map +1 -0
  17. package/dist/config.js +93 -0
  18. package/dist/config.js.map +1 -0
  19. package/dist/contract.d.ts +170 -0
  20. package/dist/contract.d.ts.map +1 -0
  21. package/dist/contract.js +24 -0
  22. package/dist/contract.js.map +1 -0
  23. package/dist/debug/relay.d.ts +25 -0
  24. package/dist/debug/relay.d.ts.map +1 -0
  25. package/dist/debug/relay.js +89 -0
  26. package/dist/debug/relay.js.map +1 -0
  27. package/dist/dependency-key.d.ts +38 -0
  28. package/dist/dependency-key.d.ts.map +1 -0
  29. package/dist/dependency-key.js +68 -0
  30. package/dist/dependency-key.js.map +1 -0
  31. package/dist/index.d.ts +20 -0
  32. package/dist/index.d.ts.map +1 -0
  33. package/dist/index.js +19 -0
  34. package/dist/index.js.map +1 -0
  35. package/dist/routes/capabilities.d.ts +12 -0
  36. package/dist/routes/capabilities.d.ts.map +1 -0
  37. package/dist/routes/capabilities.js +15 -0
  38. package/dist/routes/capabilities.js.map +1 -0
  39. package/dist/routes/health.d.ts +5 -0
  40. package/dist/routes/health.d.ts.map +1 -0
  41. package/dist/routes/health.js +8 -0
  42. package/dist/routes/health.js.map +1 -0
  43. package/dist/routes/io.d.ts +8 -0
  44. package/dist/routes/io.d.ts.map +1 -0
  45. package/dist/routes/io.js +239 -0
  46. package/dist/routes/io.js.map +1 -0
  47. package/dist/routes/probe.d.ts +7 -0
  48. package/dist/routes/probe.d.ts.map +1 -0
  49. package/dist/routes/probe.js +22 -0
  50. package/dist/routes/probe.js.map +1 -0
  51. package/dist/routes/sessions.d.ts +22 -0
  52. package/dist/routes/sessions.d.ts.map +1 -0
  53. package/dist/routes/sessions.js +223 -0
  54. package/dist/routes/sessions.js.map +1 -0
  55. package/dist/server.d.ts +34 -0
  56. package/dist/server.d.ts.map +1 -0
  57. package/dist/server.js +67 -0
  58. package/dist/server.js.map +1 -0
  59. package/dist/session/bundle-path.d.ts +12 -0
  60. package/dist/session/bundle-path.d.ts.map +1 -0
  61. package/dist/session/bundle-path.js +27 -0
  62. package/dist/session/bundle-path.js.map +1 -0
  63. package/dist/session/byte-ring-buffer.d.ts +29 -0
  64. package/dist/session/byte-ring-buffer.d.ts.map +1 -0
  65. package/dist/session/byte-ring-buffer.js +54 -0
  66. package/dist/session/byte-ring-buffer.js.map +1 -0
  67. package/dist/session/registry.d.ts +62 -0
  68. package/dist/session/registry.d.ts.map +1 -0
  69. package/dist/session/registry.js +156 -0
  70. package/dist/session/registry.js.map +1 -0
  71. package/dist/session/ring-buffer.d.ts +37 -0
  72. package/dist/session/ring-buffer.d.ts.map +1 -0
  73. package/dist/session/ring-buffer.js +64 -0
  74. package/dist/session/ring-buffer.js.map +1 -0
  75. package/dist/session/session-id.d.ts +3 -0
  76. package/dist/session/session-id.d.ts.map +1 -0
  77. package/dist/session/session-id.js +19 -0
  78. package/dist/session/session-id.js.map +1 -0
  79. package/dist/sse/channel.d.ts +11 -0
  80. package/dist/sse/channel.d.ts.map +1 -0
  81. package/dist/sse/channel.js +129 -0
  82. package/dist/sse/channel.js.map +1 -0
  83. package/package.json +48 -0
  84. package/src/backend.ts +88 -0
  85. package/src/base-image-catalog.test.ts +209 -0
  86. package/src/base-image-catalog.ts +320 -0
  87. package/src/capabilities-schema.test.ts +54 -0
  88. package/src/capabilities-schema.ts +71 -0
  89. package/src/config.ts +122 -0
  90. package/src/contract.ts +170 -0
  91. package/src/debug/relay.ts +104 -0
  92. package/src/dependency-key.test.ts +64 -0
  93. package/src/dependency-key.ts +105 -0
  94. package/src/index.ts +33 -0
  95. package/src/routes/capabilities.ts +20 -0
  96. package/src/routes/health.ts +9 -0
  97. package/src/routes/io.ts +265 -0
  98. package/src/routes/probe.ts +35 -0
  99. package/src/routes/sessions.ts +270 -0
  100. package/src/server.ts +108 -0
  101. package/src/session/bundle-path.ts +27 -0
  102. package/src/session/byte-ring-buffer.ts +62 -0
  103. package/src/session/registry.test.ts +34 -0
  104. package/src/session/registry.ts +185 -0
  105. package/src/session/ring-buffer.test.ts +54 -0
  106. package/src/session/ring-buffer.ts +75 -0
  107. package/src/session/session-id.test.ts +17 -0
  108. package/src/session/session-id.ts +20 -0
  109. package/src/sse/channel.ts +154 -0
@@ -0,0 +1,129 @@
1
+ import { isTerminal } from "../contract.js";
2
+ import { SessionEvictedError } from "../session/registry.js";
3
+ const HEARTBEAT_MS = 20_000;
4
+ export async function streamSessionEvents(args) {
5
+ const { registry, req, reply, sessionId, corsOrigins } = args;
6
+ const entry = registry.get(sessionId);
7
+ if (!entry) {
8
+ reply.code(404).send({ error: "not_found", message: `session '${sessionId}' not in registry` });
9
+ return;
10
+ }
11
+ const lastEventId = resolveLastEventId(req);
12
+ const raw = reply.raw;
13
+ // @fastify/cors adds Access-Control-Allow-Origin via an onSend hook, which
14
+ // never fires for this route because we bypass reply and write directly to
15
+ // reply.raw. Inject the CORS headers manually from our config.
16
+ const headers = {
17
+ "content-type": "text/event-stream",
18
+ "cache-control": "no-cache, no-transform",
19
+ connection: "keep-alive",
20
+ "x-accel-buffering": "no",
21
+ };
22
+ const allowOrigin = resolveAllowOrigin(corsOrigins, req.headers.origin);
23
+ if (allowOrigin) {
24
+ headers["access-control-allow-origin"] = allowOrigin;
25
+ headers["vary"] = "Origin";
26
+ }
27
+ raw.writeHead(200, headers);
28
+ // Replay any buffered history > lastEventId. `hasGap` triggers a synthetic
29
+ // gap marker so the client knows earlier output was evicted before we could
30
+ // deliver it.
31
+ const { entries, hasGap } = entry.buffer.replay(lastEventId);
32
+ if (hasGap) {
33
+ writeFrame(raw, "gap", { reason: "buffer_evicted" });
34
+ }
35
+ for (const buffered of entries) {
36
+ writeBufferedEvent(raw, buffered);
37
+ }
38
+ // If the session already reached a terminal status before the client hit
39
+ // /events, replay has delivered the terminal status frame and we're done.
40
+ if (isTerminal(entry.status)) {
41
+ raw.end();
42
+ return;
43
+ }
44
+ let unsubscribe = null;
45
+ let heartbeat = null;
46
+ let closed = false;
47
+ const cleanup = () => {
48
+ if (closed)
49
+ return;
50
+ closed = true;
51
+ if (heartbeat)
52
+ clearInterval(heartbeat);
53
+ if (unsubscribe)
54
+ unsubscribe();
55
+ if (!raw.writableEnded)
56
+ raw.end();
57
+ };
58
+ heartbeat = setInterval(() => {
59
+ if (!raw.writableEnded)
60
+ raw.write(": heartbeat\n\n");
61
+ }, HEARTBEAT_MS);
62
+ heartbeat.unref?.();
63
+ try {
64
+ unsubscribe = registry.subscribe(sessionId, (buffered) => {
65
+ writeBufferedEvent(raw, buffered);
66
+ if (buffered.event.type === "status" && isTerminal(buffered.event.status)) {
67
+ cleanup();
68
+ }
69
+ });
70
+ }
71
+ catch (err) {
72
+ if (err instanceof SessionEvictedError) {
73
+ cleanup();
74
+ return;
75
+ }
76
+ throw err;
77
+ }
78
+ req.raw.on("close", cleanup);
79
+ req.raw.on("end", cleanup);
80
+ // Keep the response alive — Fastify's handler must not return until the
81
+ // stream closes, otherwise it sends a default end.
82
+ await new Promise((resolve) => {
83
+ raw.on("close", resolve);
84
+ raw.on("finish", resolve);
85
+ });
86
+ }
87
+ function resolveLastEventId(req) {
88
+ // Spec: header wins over query. EventSource sets the header on native
89
+ // auto-reconnect; the query param is for fresh instances (tab reload) where
90
+ // sse-client.ts passes the persisted id explicitly.
91
+ const header = req.headers["last-event-id"];
92
+ const fromHeader = parseId(Array.isArray(header) ? header[0] : header);
93
+ if (fromHeader !== null)
94
+ return fromHeader;
95
+ const query = req.query;
96
+ const fromQuery = parseId(query?.lastEventId);
97
+ return fromQuery ?? 0;
98
+ }
99
+ function resolveAllowOrigin(corsOrigins, requestOrigin) {
100
+ if (corsOrigins === "*")
101
+ return "*";
102
+ const origin = Array.isArray(requestOrigin) ? requestOrigin[0] : requestOrigin;
103
+ if (!origin)
104
+ return null;
105
+ return corsOrigins.includes(origin) ? origin : null;
106
+ }
107
+ function parseId(raw) {
108
+ if (!raw)
109
+ return null;
110
+ const n = Number.parseInt(raw, 10);
111
+ // `Last-Event-ID: 0` is valid per the SSE spec — it means "resume from the
112
+ // beginning." Our ids start at 1, so replay(0) returns all buffered entries
113
+ // and the hasGap check correctly compares against first-resident-id.
114
+ return Number.isFinite(n) && n >= 0 ? n : null;
115
+ }
116
+ function writeBufferedEvent(raw, buffered) {
117
+ writeFrame(raw, buffered.event.type, buffered.event, buffered.id);
118
+ }
119
+ function writeFrame(raw, event, data, id) {
120
+ if (!("writable" in raw) || raw.writableEnded)
121
+ return;
122
+ let frame = "";
123
+ if (id !== undefined)
124
+ frame += `id: ${id}\n`;
125
+ frame += `event: ${event}\n`;
126
+ frame += `data: ${JSON.stringify(data)}\n\n`;
127
+ raw.write(frame);
128
+ }
129
+ //# sourceMappingURL=channel.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"channel.js","sourceRoot":"","sources":["../../src/sse/channel.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@telorun/runner-core",
3
+ "version": "0.5.2",
4
+ "description": "Backend-neutral core for Telo runners: the /v1 session contract, the RunnerBackend interface, the session registry, SSE/byte ring buffers, and bundle handling. Shared by docker-runner and k8s-runner.",
5
+ "keywords": [
6
+ "telo",
7
+ "runner"
8
+ ],
9
+ "author": "Bartosz Pasiński <bartosz.pasinski@codenet.pl>",
10
+ "license": "SEE LICENSE IN LICENSE",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/telorun/telo.git",
14
+ "directory": "packages/runner-core"
15
+ },
16
+ "type": "module",
17
+ "main": "./dist/index.js",
18
+ "types": "./dist/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "import": "./dist/index.js",
22
+ "types": "./dist/index.d.ts"
23
+ }
24
+ },
25
+ "files": [
26
+ "dist/**",
27
+ "src/**"
28
+ ],
29
+ "dependencies": {
30
+ "@fastify/cors": "^11.0.1",
31
+ "@fastify/websocket": "^11.0.1",
32
+ "fastify": "^5.7.2",
33
+ "yaml": "^2.8.3",
34
+ "@telorun/debug-wire": "0.3.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^20.0.0",
38
+ "@types/ws": "^8.5.13",
39
+ "typescript": "^5.0.0",
40
+ "vitest": "^2.1.8",
41
+ "ws": "^8.18.0"
42
+ },
43
+ "scripts": {
44
+ "build": "tsc",
45
+ "test": "vitest run",
46
+ "test:watch": "vitest"
47
+ }
48
+ }
package/src/backend.ts ADDED
@@ -0,0 +1,88 @@
1
+ import type { DebugFrame } from "@telorun/debug-wire";
2
+
3
+ import type {
4
+ AvailabilityReport,
5
+ PortMapping,
6
+ ProbeConfig,
7
+ RunBundle,
8
+ RunPhase,
9
+ RunStatus,
10
+ SessionConfig,
11
+ } from "./contract.js";
12
+
13
+ /**
14
+ * The seam between backend-neutral session machinery (routes, registry, SSE)
15
+ * and a concrete workload runtime (docker container, kubernetes pod).
16
+ *
17
+ * Deliberately abstract: a byte-stream out (`onOutput`), a stdin writer
18
+ * (`BackendSession.writeStdin`), a resize signal, and a wait/exit (`done`) —
19
+ * NOT docker's `ReadWriteStream` duplex. The k8s backend serves the same shape
20
+ * over the Pod `attach` subresource; the docker backend adapts its hijacked
21
+ * attach duplex onto it. Bundle delivery is the backend's responsibility too
22
+ * (docker writes a shared-volume workdir; k8s stages the bundle for an
23
+ * initContainer fetch), so the spec carries the raw `bundle` rather than a
24
+ * pre-resolved on-disk path.
25
+ */
26
+ export interface RunnerBackend {
27
+ /** Report readiness without starting a session (daemon/cluster reachable,
28
+ * image pullable, scaffolding present). Backs `POST /v1/probe`. */
29
+ probe(config: ProbeConfig): Promise<AvailabilityReport>;
30
+
31
+ /** Spawn the workload and wire it to the supplied callbacks. Resolves once
32
+ * the workload has started (after a `running` status is emitted); rejects
33
+ * with `SessionStartError` for any pre-start failure. */
34
+ start(spec: BackendStartSpec): Promise<BackendSession>;
35
+
36
+ /** Reap workloads orphaned by a prior runner process, matched by the
37
+ * backend's own labelling. Run once at boot — the session registry is
38
+ * in-memory, so a restart otherwise leaks running workloads. */
39
+ reapOrphans?(): Promise<void>;
40
+ }
41
+
42
+ export interface BackendStartSpec {
43
+ sessionId: string;
44
+ bundle: RunBundle;
45
+ /** Bundle-relative entry path, already traversal-normalized by core. */
46
+ entryRelativePath: string;
47
+ env: Record<string, string>;
48
+ ports: PortMapping[];
49
+ config: SessionConfig;
50
+ /** When true, launch the workload with `--inspect` and relay its kernel debug
51
+ * stream via `onDebug`. The inspect endpoint stays reachable only by the
52
+ * runner — never published outward. */
53
+ inspect: boolean;
54
+
55
+ /** Emit a lifecycle status. The backend drives `starting` → `running` →
56
+ * terminal (`exited`/`failed`/`stopped`). */
57
+ onStatus(status: RunStatus): void;
58
+ /** Emit a progress message for a coming-up phase (build / provision / boot).
59
+ * Additive to status — surfaces what's happening while the session is still
60
+ * `starting`, driving the editor's spinner + step feed. */
61
+ onProgress(phase: RunPhase, message: string, done?: boolean): void;
62
+ /** Raw merged stdout/stderr (PTY) bytes from the workload. */
63
+ onOutput(chunk: Buffer): void;
64
+ /** A frame relayed from the workload's kernel debug stream. Only called when
65
+ * `inspect` is true and the backend has connected to the inspect endpoint. */
66
+ onDebug(frame: DebugFrame): void;
67
+ /** True once a user stop / shutdown has been requested — lets the backend
68
+ * classify a kill as `stopped` rather than `failed`. */
69
+ isUserStopped(): boolean;
70
+ }
71
+
72
+ export interface BackendSession {
73
+ /** Write bytes to the workload's stdin (PTY). A no-op once the workload has
74
+ * terminated, so callers need not track liveness. */
75
+ writeStdin(bytes: Uint8Array): void;
76
+
77
+ /** Resize the workload's PTY. */
78
+ resize(cols: number, rows: number): void;
79
+
80
+ /** Resolves after the workload terminates and its terminal status has been
81
+ * emitted via `onStatus`. Never rejects — terminal failures surface as a
82
+ * `failed` status. */
83
+ readonly done: Promise<void>;
84
+
85
+ /** Force-stop the workload. Idempotent and safe to call after natural exit
86
+ * (a backend should treat an already-gone workload as a no-op). */
87
+ stop(): Promise<void>;
88
+ }
@@ -0,0 +1,209 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ BaseImageCatalog,
5
+ filterTags,
6
+ parseDockerHubRef,
7
+ resolveTagDigest,
8
+ } from "./base-image-catalog.js";
9
+
10
+ const SAMPLE = [
11
+ "latest",
12
+ "latest-slim",
13
+ "0",
14
+ "0-slim",
15
+ "0.30",
16
+ "0.30-slim",
17
+ "0.30.1",
18
+ "0.30.1-slim",
19
+ "0.30.0",
20
+ "0.29.3-slim",
21
+ "latest-rust-1.95.0-slim",
22
+ "0.30.1-rust-1.95.0",
23
+ "0.30.1-rc.1",
24
+ "0.30.1-rc.1-slim",
25
+ "sha-abc1234",
26
+ "deadbeef1234",
27
+ ];
28
+
29
+ describe("filterTags", () => {
30
+ it("pinnedOnly keeps only MAJOR.MINOR.PATCH[-variant] tags", () => {
31
+ const out = filterTags(SAMPLE, { pinnedOnly: true });
32
+ expect(out).toEqual([
33
+ "0.30.1",
34
+ "0.30.1-slim",
35
+ "0.30.0",
36
+ "0.29.3-slim",
37
+ "0.30.1-rust-1.95.0",
38
+ "0.30.1-rc.1",
39
+ "0.30.1-rc.1-slim",
40
+ ]);
41
+ });
42
+
43
+ it("excludeSha drops commit-hash tags", () => {
44
+ const out = filterTags(SAMPLE, { excludeSha: true });
45
+ expect(out).not.toContain("sha-abc1234");
46
+ expect(out).not.toContain("deadbeef1234");
47
+ expect(out).toContain("0.30.1");
48
+ });
49
+
50
+ it("excludePrerelease drops semver prereleases but keeps build variants", () => {
51
+ const out = filterTags(["0.30.1", "0.30.1-slim", "0.30.1-rust-1.95.0", "0.30.1-rc.1", "0.30.1-rc.1-slim"], {
52
+ excludePrerelease: true,
53
+ });
54
+ expect(out).toEqual(["0.30.1", "0.30.1-slim", "0.30.1-rust-1.95.0"]);
55
+ });
56
+
57
+ it("pinnedOnly + excludeSha + excludePrerelease yields the release menu", () => {
58
+ const out = filterTags(SAMPLE, { pinnedOnly: true, excludeSha: true, excludePrerelease: true });
59
+ expect(out).toEqual(["0.30.1", "0.30.1-slim", "0.30.0", "0.29.3-slim", "0.30.1-rust-1.95.0"]);
60
+ });
61
+
62
+ it("include / exclude regexes are the escape hatch", () => {
63
+ expect(filterTags(SAMPLE, { include: [/-slim$/] })).toEqual([
64
+ "latest-slim",
65
+ "0-slim",
66
+ "0.30-slim",
67
+ "0.30.1-slim",
68
+ "0.29.3-slim",
69
+ "latest-rust-1.95.0-slim",
70
+ "0.30.1-rc.1-slim",
71
+ ]);
72
+ expect(filterTags(SAMPLE, { pinnedOnly: true, exclude: [/-rust-/] })).not.toContain(
73
+ "0.30.1-rust-1.95.0",
74
+ );
75
+ });
76
+ });
77
+
78
+ /** A fake Docker Hub tags endpoint serving one page of `results`. */
79
+ function fakeHub(results: Array<{ name: string; last_updated?: string }>): typeof fetch {
80
+ return (async () =>
81
+ new Response(JSON.stringify({ next: null, results }), {
82
+ status: 200,
83
+ headers: { "content-type": "application/json" },
84
+ })) as unknown as typeof fetch;
85
+ }
86
+
87
+ describe("BaseImageCatalog", () => {
88
+ const defaultRef = "telorun/node:latest-slim";
89
+
90
+ it("serves only the default before the first refresh", () => {
91
+ const catalog = new BaseImageCatalog({ repository: "telorun/node", defaultRef });
92
+ expect(catalog.current()).toEqual([defaultRef]);
93
+ expect(catalog.isAllowed(defaultRef)).toBe(true);
94
+ expect(catalog.isAllowed("telorun/node:0.30.1-slim")).toBe(false);
95
+ });
96
+
97
+ it("advertises the default first, then filtered tags newest-first within the limit", async () => {
98
+ const catalog = new BaseImageCatalog({
99
+ repository: "telorun/node",
100
+ defaultRef,
101
+ filter: { pinnedOnly: true },
102
+ limit: 2,
103
+ fetchImpl: fakeHub([
104
+ { name: "0.30.0", last_updated: "2026-06-14T00:00:00Z" },
105
+ { name: "0.30.1", last_updated: "2026-06-15T00:00:00Z" },
106
+ { name: "0.29.9", last_updated: "2026-06-10T00:00:00Z" },
107
+ { name: "latest", last_updated: "2026-06-15T00:00:00Z" },
108
+ ]),
109
+ });
110
+ await catalog.refresh();
111
+ expect(catalog.current()).toEqual([
112
+ defaultRef,
113
+ "telorun/node:0.30.1",
114
+ "telorun/node:0.30.0",
115
+ ]);
116
+ expect(catalog.isAllowed("telorun/node:0.30.1")).toBe(true);
117
+ expect(catalog.isAllowed("telorun/node:0.29.9")).toBe(false); // dropped by limit
118
+ });
119
+
120
+ it("keeps the default offered even when filters would exclude it", async () => {
121
+ const catalog = new BaseImageCatalog({
122
+ repository: "telorun/node",
123
+ defaultRef, // latest-slim — a moving tag pinnedOnly drops
124
+ filter: { pinnedOnly: true },
125
+ fetchImpl: fakeHub([{ name: "0.30.1", last_updated: "2026-06-15T00:00:00Z" }]),
126
+ });
127
+ await catalog.refresh();
128
+ expect(catalog.current()).toContain(defaultRef);
129
+ expect(catalog.isAllowed(defaultRef)).toBe(true);
130
+ });
131
+
132
+ it("throws on a non-ok Docker Hub response so the caller can degrade", async () => {
133
+ const catalog = new BaseImageCatalog({
134
+ repository: "telorun/node",
135
+ defaultRef,
136
+ fetchImpl: (async () => new Response("nope", { status: 500 })) as unknown as typeof fetch,
137
+ });
138
+ await expect(catalog.refresh()).rejects.toThrow(/Docker Hub tags request/);
139
+ expect(catalog.current()).toEqual([defaultRef]); // unchanged
140
+ });
141
+ });
142
+
143
+ describe("parseDockerHubRef", () => {
144
+ it("parses namespace/repo:tag", () => {
145
+ expect(parseDockerHubRef("telorun/node:latest-slim")).toEqual({
146
+ namespace: "telorun",
147
+ repo: "node",
148
+ tag: "latest-slim",
149
+ digest: undefined,
150
+ });
151
+ });
152
+
153
+ it("defaults the tag and the library namespace", () => {
154
+ expect(parseDockerHubRef("node")).toMatchObject({ namespace: "library", repo: "node", tag: "latest" });
155
+ });
156
+
157
+ it("strips a docker.io host and carries an @digest through", () => {
158
+ expect(parseDockerHubRef("docker.io/telorun/node:0.30.1")).toMatchObject({
159
+ namespace: "telorun",
160
+ repo: "node",
161
+ tag: "0.30.1",
162
+ });
163
+ expect(parseDockerHubRef("telorun/node@sha256:abc")?.digest).toBe("sha256:abc");
164
+ });
165
+
166
+ it("returns null for a non-Docker-Hub registry", () => {
167
+ expect(parseDockerHubRef("ghcr.io/telorun/node:latest")).toBeNull();
168
+ expect(parseDockerHubRef("registry.telo-runner.svc:5000/x:y")).toBeNull();
169
+ });
170
+ });
171
+
172
+ describe("resolveTagDigest", () => {
173
+ it("returns an already-pinned digest without a network call", async () => {
174
+ let called = false;
175
+ const fetchImpl = (async () => {
176
+ called = true;
177
+ return new Response("{}");
178
+ }) as unknown as typeof fetch;
179
+ expect(await resolveTagDigest("telorun/node@sha256:deadbeef", { fetchImpl })).toBe(
180
+ "sha256:deadbeef",
181
+ );
182
+ expect(called).toBe(false);
183
+ });
184
+
185
+ it("reads the manifest digest from the Docker Hub tag endpoint", async () => {
186
+ const fetchImpl = (async () =>
187
+ new Response(JSON.stringify({ digest: "sha256:abc123", images: [{ digest: "sha256:zzz" }] }), {
188
+ status: 200,
189
+ headers: { "content-type": "application/json" },
190
+ })) as unknown as typeof fetch;
191
+ expect(await resolveTagDigest("telorun/node:latest-slim", { fetchImpl })).toBe("sha256:abc123");
192
+ });
193
+
194
+ it("returns undefined for a non-Hub ref or on a failed request", async () => {
195
+ expect(await resolveTagDigest("ghcr.io/foo/bar:1")).toBeUndefined();
196
+ const fetchImpl = (async () => new Response("nope", { status: 404 })) as unknown as typeof fetch;
197
+ expect(await resolveTagDigest("telorun/node:missing", { fetchImpl })).toBeUndefined();
198
+ });
199
+
200
+ it("reports a genuine Hub failure to onError but stays silent for a non-Hub ref", async () => {
201
+ const errors: unknown[] = [];
202
+ const fail = (async () => new Response("boom", { status: 500 })) as unknown as typeof fetch;
203
+ await resolveTagDigest("telorun/node:latest", { fetchImpl: fail, onError: (e) => errors.push(e) });
204
+ expect(errors).toHaveLength(1);
205
+
206
+ await resolveTagDigest("ghcr.io/foo/bar:1", { onError: (e) => errors.push(e) });
207
+ expect(errors).toHaveLength(1); // non-Hub ref is not an error
208
+ });
209
+ });