@telorun/k8s-runner 0.13.0 → 0.15.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 +166 -107
- package/dist/bundle-store.d.ts +6 -13
- package/dist/bundle-store.d.ts.map +1 -1
- package/dist/bundle-store.js +6 -18
- package/dist/bundle-store.js.map +1 -1
- package/dist/capabilities.d.ts +3 -3
- package/dist/capabilities.js +4 -4
- package/dist/capabilities.js.map +1 -1
- package/dist/config.d.ts +47 -37
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +64 -25
- package/dist/config.js.map +1 -1
- package/dist/k8s/api-error.d.ts +34 -0
- package/dist/k8s/api-error.d.ts.map +1 -0
- package/dist/k8s/api-error.js +118 -0
- package/dist/k8s/api-error.js.map +1 -0
- package/dist/k8s/backend.d.ts +4 -0
- package/dist/k8s/backend.d.ts.map +1 -1
- package/dist/k8s/backend.js +77 -51
- package/dist/k8s/backend.js.map +1 -1
- package/dist/k8s/client.d.ts +7 -2
- package/dist/k8s/client.d.ts.map +1 -1
- package/dist/k8s/client.js +3 -2
- package/dist/k8s/client.js.map +1 -1
- package/dist/k8s/pod-spec.d.ts +8 -9
- package/dist/k8s/pod-spec.d.ts.map +1 -1
- package/dist/k8s/pod-spec.js +19 -29
- package/dist/k8s/pod-spec.js.map +1 -1
- package/dist/k8s/pod-status.d.ts +5 -0
- package/dist/k8s/pod-status.d.ts.map +1 -1
- package/dist/k8s/pod-status.js +9 -2
- package/dist/k8s/pod-status.js.map +1 -1
- package/dist/k8s/routing/create-or-replace.d.ts +5 -0
- package/dist/k8s/routing/create-or-replace.d.ts.map +1 -0
- package/dist/k8s/routing/create-or-replace.js +15 -0
- package/dist/k8s/routing/create-or-replace.js.map +1 -0
- package/dist/k8s/routing/gateway-router.d.ts +5 -0
- package/dist/k8s/routing/gateway-router.d.ts.map +1 -0
- package/dist/k8s/routing/gateway-router.js +178 -0
- package/dist/k8s/routing/gateway-router.js.map +1 -0
- package/dist/k8s/routing/index.d.ts +24 -0
- package/dist/k8s/routing/index.d.ts.map +1 -0
- package/dist/k8s/routing/index.js +27 -0
- package/dist/k8s/routing/index.js.map +1 -0
- package/dist/k8s/routing/ingress-router.d.ts +13 -0
- package/dist/k8s/routing/ingress-router.d.ts.map +1 -0
- package/dist/k8s/routing/ingress-router.js +125 -0
- package/dist/k8s/routing/ingress-router.js.map +1 -0
- package/dist/k8s/routing/route-health.d.ts +34 -0
- package/dist/k8s/routing/route-health.d.ts.map +1 -0
- package/dist/k8s/routing/route-health.js +77 -0
- package/dist/k8s/routing/route-health.js.map +1 -0
- package/dist/k8s/routing/routing-mode.d.ts +45 -0
- package/dist/k8s/routing/routing-mode.d.ts.map +1 -0
- package/dist/k8s/routing/routing-mode.js +141 -0
- package/dist/k8s/routing/routing-mode.js.map +1 -0
- package/dist/k8s/routing/session-endpoints.d.ts +25 -0
- package/dist/k8s/routing/session-endpoints.d.ts.map +1 -0
- package/dist/k8s/routing/session-endpoints.js +36 -0
- package/dist/k8s/routing/session-endpoints.js.map +1 -0
- package/dist/k8s/routing/session-router.d.ts +57 -0
- package/dist/k8s/routing/session-router.d.ts.map +1 -0
- package/dist/k8s/routing/session-router.js +2 -0
- package/dist/k8s/routing/session-router.js.map +1 -0
- package/dist/k8s/routing/session-service.d.ts +14 -0
- package/dist/k8s/routing/session-service.d.ts.map +1 -0
- package/dist/k8s/routing/session-service.js +42 -0
- package/dist/k8s/routing/session-service.js.map +1 -0
- package/dist/k8s/watch-session.d.ts +7 -5
- package/dist/k8s/watch-session.d.ts.map +1 -1
- package/dist/k8s/watch-session.js +90 -83
- package/dist/k8s/watch-session.js.map +1 -1
- package/dist/k8s/workspace-configmap.d.ts.map +1 -1
- package/dist/k8s/workspace-configmap.js +3 -12
- package/dist/k8s/workspace-configmap.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +37 -4
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
- package/src/bundle-store.ts +6 -19
- package/src/capabilities.ts +4 -4
- package/src/config.ts +130 -68
- package/src/k8s/api-error.test.ts +89 -0
- package/src/k8s/api-error.ts +128 -0
- package/src/k8s/backend.ts +103 -58
- package/src/k8s/client.ts +10 -3
- package/src/k8s/pod-spec.test.ts +57 -2
- package/src/k8s/pod-spec.ts +25 -36
- package/src/k8s/pod-status.ts +10 -2
- package/src/k8s/routing/create-or-replace.ts +16 -0
- package/src/k8s/routing/gateway-router.test.ts +236 -0
- package/src/k8s/routing/gateway-router.ts +219 -0
- package/src/k8s/routing/index.ts +44 -0
- package/src/k8s/routing/ingress-router.test.ts +236 -0
- package/src/k8s/routing/ingress-router.ts +157 -0
- package/src/k8s/routing/route-health.test.ts +215 -0
- package/src/k8s/routing/route-health.ts +95 -0
- package/src/k8s/routing/routing-mode.test.ts +218 -0
- package/src/k8s/routing/routing-mode.ts +191 -0
- package/src/k8s/routing/session-endpoints.ts +45 -0
- package/src/k8s/routing/session-router.ts +59 -0
- package/src/k8s/routing/session-service.ts +55 -0
- package/src/k8s/watch-pod-spec.test.ts +0 -1
- package/src/k8s/watch-session.ts +90 -77
- package/src/k8s/workspace-configmap.ts +3 -15
- package/src/server.ts +41 -4
- package/dist/k8s/image-build.d.ts +0 -103
- package/dist/k8s/image-build.d.ts.map +0 -1
- package/dist/k8s/image-build.js +0 -427
- package/dist/k8s/image-build.js.map +0 -1
- package/dist/k8s/ingress.d.ts +0 -15
- package/dist/k8s/ingress.d.ts.map +0 -1
- package/dist/k8s/ingress.js +0 -102
- package/dist/k8s/ingress.js.map +0 -1
- package/src/k8s/image-build.test.ts +0 -238
- package/src/k8s/image-build.ts +0 -540
- package/src/k8s/ingress.test.ts +0 -146
- package/src/k8s/ingress.ts +0 -127
package/src/k8s/watch-session.ts
CHANGED
|
@@ -23,8 +23,17 @@ import {
|
|
|
23
23
|
|
|
24
24
|
import type { K8sRunnerConfig } from "../config.js";
|
|
25
25
|
import { clampLimits } from "../limits.js";
|
|
26
|
+
import { apiFailure, apiReason } from "./api-error.js";
|
|
26
27
|
import type { KubeClient } from "./client.js";
|
|
27
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
buildSessionService,
|
|
30
|
+
createOrReplace,
|
|
31
|
+
endpointsFor,
|
|
32
|
+
sessionObjectName,
|
|
33
|
+
watchRouteHealth,
|
|
34
|
+
type PublishedRoute,
|
|
35
|
+
type SessionRouter,
|
|
36
|
+
} from "./routing/index.js";
|
|
28
37
|
import { buildWatchPod, inspectPortFor, WORKSPACE_PORT } from "./pod-spec.js";
|
|
29
38
|
import { deletePod, is404, msg, podPhase, podStatus, provisionMessage } from "./pod-status.js";
|
|
30
39
|
import { ensureWorkspaceConfigMap } from "./workspace-configmap.js";
|
|
@@ -48,23 +57,24 @@ interface ResizableSocket {
|
|
|
48
57
|
export interface WatchSessionDeps {
|
|
49
58
|
kube: KubeClient;
|
|
50
59
|
config: K8sRunnerConfig;
|
|
60
|
+
/** Resolved at boot; absent → logs-only. */
|
|
61
|
+
router?: SessionRouter;
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
/**
|
|
54
65
|
* A watch session: one pod, one workspace volume, one container per running
|
|
55
66
|
* application, and the session outliving every run inside it.
|
|
56
67
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* later reload resolves from local disk.
|
|
68
|
+
* Its cache is what separates it from a run session: the module closure lands in
|
|
69
|
+
* a directory that lives as long as the POD rather than as long as one run, so
|
|
70
|
+
* the download happens once per session and every later reload resolves from
|
|
71
|
+
* local disk.
|
|
62
72
|
*/
|
|
63
73
|
export async function startWatchSession(
|
|
64
74
|
deps: WatchSessionDeps,
|
|
65
75
|
spec: BackendStartSpec,
|
|
66
76
|
): Promise<BackendSession> {
|
|
67
|
-
const { kube, config } = deps;
|
|
77
|
+
const { kube, config, router } = deps;
|
|
68
78
|
const ns = config.sessionNamespace;
|
|
69
79
|
const limits = clampLimits(config.appLimits, undefined);
|
|
70
80
|
|
|
@@ -86,6 +96,9 @@ export async function startWatchSession(
|
|
|
86
96
|
stdins: Map<string, PassThrough>;
|
|
87
97
|
abort: AbortController;
|
|
88
98
|
stopWatch: () => void;
|
|
99
|
+
/** Aborts the previous route-health watch when a reload republishes routes —
|
|
100
|
+
* the old host set is no longer what the session serves. */
|
|
101
|
+
routeAbort?: AbortController;
|
|
89
102
|
}
|
|
90
103
|
let runtime: PodRuntime | null = null;
|
|
91
104
|
|
|
@@ -117,12 +130,7 @@ export async function startWatchSession(
|
|
|
117
130
|
try {
|
|
118
131
|
created = await kube.core.createNamespacedPod({ namespace: ns, body: pod });
|
|
119
132
|
} catch (err) {
|
|
120
|
-
throw
|
|
121
|
-
"start_failed",
|
|
122
|
-
"create",
|
|
123
|
-
`failed to create pod: ${msg(err)}`,
|
|
124
|
-
msg(err),
|
|
125
|
-
);
|
|
133
|
+
throw apiFailure(err, "create", "could not create the session pod");
|
|
126
134
|
}
|
|
127
135
|
|
|
128
136
|
const abort = new AbortController();
|
|
@@ -171,22 +179,22 @@ export async function startWatchSession(
|
|
|
171
179
|
const agent = agentEndpoint();
|
|
172
180
|
spec.onStatus({
|
|
173
181
|
kind: "running",
|
|
174
|
-
endpoints: endpointsFor(config, spec.sessionId, allPorts(apps)),
|
|
182
|
+
endpoints: endpointsFor(config, spec.sessionId, allPorts(apps), Boolean(router)),
|
|
175
183
|
...(agent ? { agent } : {}),
|
|
176
184
|
});
|
|
177
185
|
}
|
|
178
186
|
|
|
179
187
|
/** Where this session's co-resident agent answers. The pod's containers share
|
|
180
188
|
* one network namespace, so the agent rides the session's own Service and
|
|
181
|
-
*
|
|
189
|
+
* routing objects — the port is simply in the routed set, and the host follows the
|
|
182
190
|
* same `<port>-<sessionId>` scheme every app port does. */
|
|
183
191
|
function agentEndpoint(): RunnerEndpoint | undefined {
|
|
184
192
|
const port = spec.agent?.port;
|
|
185
193
|
if (port === undefined) return undefined;
|
|
186
|
-
return endpointsFor(config, spec.sessionId, [{ port, protocol: "tcp" }])[0];
|
|
194
|
+
return endpointsFor(config, spec.sessionId, [{ port, protocol: "tcp" }], Boolean(router))[0];
|
|
187
195
|
}
|
|
188
196
|
|
|
189
|
-
/** Every port the session's Service and
|
|
197
|
+
/** Every port the session's Service and routing objects must carry: the apps' declared
|
|
190
198
|
* ports plus the agent's. Kept apart from `allPorts`, which answers what the
|
|
191
199
|
* APPLICATIONS declare — that set is what the client is told about and what a
|
|
192
200
|
* reload re-reads, and the agent belongs in neither. */
|
|
@@ -299,7 +307,7 @@ export async function startWatchSession(
|
|
|
299
307
|
// channel rather than aborting the start.
|
|
300
308
|
spec.onOutput(
|
|
301
309
|
app.name,
|
|
302
|
-
Buffer.from(`\r\n[runner] failed to attach: ${
|
|
310
|
+
Buffer.from(`\r\n[runner] failed to attach: ${apiReason(err)}\r\n`),
|
|
303
311
|
tagFor(app, "stderr"),
|
|
304
312
|
);
|
|
305
313
|
}
|
|
@@ -327,8 +335,8 @@ export async function startWatchSession(
|
|
|
327
335
|
* which matters, because a manifest the runner could not parse would otherwise
|
|
328
336
|
* have to leave routing alone and report, on the hot path of every save.
|
|
329
337
|
*
|
|
330
|
-
* A pod's `containerPort` list is documentation; the Service and the
|
|
331
|
-
* are what make a port reachable, so this needs no pod recreate.
|
|
338
|
+
* A pod's `containerPort` list is documentation; the Service and the routing
|
|
339
|
+
* objects are what make a port reachable, so this needs no pod recreate.
|
|
332
340
|
*/
|
|
333
341
|
async function applyPortsResolved(appName: string, frame: DebugFrame): Promise<void> {
|
|
334
342
|
const declared = portsResolvedFrom(frame);
|
|
@@ -364,16 +372,16 @@ export async function startWatchSession(
|
|
|
364
372
|
await publishEndpoints(rt, apps);
|
|
365
373
|
} catch (err) {
|
|
366
374
|
spec.onEndpoints(appName, {
|
|
367
|
-
rejected: accepted.map((p) => ({ port: p.port, reason:
|
|
375
|
+
rejected: accepted.map((p) => ({ port: p.port, reason: apiReason(err) })),
|
|
368
376
|
});
|
|
369
377
|
return;
|
|
370
378
|
}
|
|
371
379
|
spec.onEndpoints(appName, {
|
|
372
380
|
...(accepted.length > 0
|
|
373
|
-
? { added: endpointsFor(config, spec.sessionId, accepted) }
|
|
381
|
+
? { added: endpointsFor(config, spec.sessionId, accepted, Boolean(router)) }
|
|
374
382
|
: {}),
|
|
375
383
|
...(removed.length > 0
|
|
376
|
-
? { removed: endpointsFor(config, spec.sessionId, removed) }
|
|
384
|
+
? { removed: endpointsFor(config, spec.sessionId, removed, Boolean(router)) }
|
|
377
385
|
: {}),
|
|
378
386
|
...(rejected.length > 0
|
|
379
387
|
? {
|
|
@@ -493,21 +501,25 @@ export async function startWatchSession(
|
|
|
493
501
|
}
|
|
494
502
|
|
|
495
503
|
/**
|
|
496
|
-
* Create or re-patch the Service and
|
|
497
|
-
* port set. Adding a `ports:` entry is as ordinary an edit as
|
|
498
|
-
* import, and a container may bind any port regardless of what the
|
|
499
|
-
* declares — so without this the app listens and is simply
|
|
500
|
-
*
|
|
501
|
-
* documentation; the Service and
|
|
502
|
-
* is why this needs no pod recreate.
|
|
504
|
+
* Create or re-patch the Service and the routing objects for the session's
|
|
505
|
+
* whole declared port set. Adding a `ports:` entry is as ordinary an edit as
|
|
506
|
+
* adding an import, and a container may bind any port regardless of what the
|
|
507
|
+
* pod spec declares — so without this the app listens and is simply
|
|
508
|
+
* unreachable: no route, no error, no event. The pod's `containerPort` list is
|
|
509
|
+
* documentation; the Service and the routing objects are what make a port
|
|
510
|
+
* reachable, which is why this needs no pod recreate.
|
|
511
|
+
*
|
|
512
|
+
* An EMPTY routed set still reaches the router, because dropping the last
|
|
513
|
+
* declared port is a reload like any other and its routes must go with it —
|
|
514
|
+
* returning early here left them serving a port nothing listens on.
|
|
503
515
|
*/
|
|
504
516
|
async function publishEndpoints(rt: PodRuntime, forApps: BackendAppSpec[]): Promise<void> {
|
|
505
|
-
if (!
|
|
517
|
+
if (!router) return;
|
|
506
518
|
const ports = routedPorts(forApps);
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
519
|
+
const serviceName = sessionObjectName(spec.sessionId);
|
|
520
|
+
if (ports.length > 0) {
|
|
521
|
+
const service = buildSessionService(config, spec.sessionId, rt.name, rt.uid, ports);
|
|
522
|
+
await createOrReplace(
|
|
511
523
|
() => kube.core.createNamespacedService({ namespace: ns, body: service }),
|
|
512
524
|
async () => {
|
|
513
525
|
// A Service replace must carry the assigned `clusterIP` and the current
|
|
@@ -532,37 +544,51 @@ export async function startWatchSession(
|
|
|
532
544
|
},
|
|
533
545
|
});
|
|
534
546
|
},
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
spec.sessionId,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
const published = await router.publish({
|
|
550
|
+
sessionId: spec.sessionId,
|
|
539
551
|
serviceName,
|
|
540
|
-
rt.name,
|
|
541
|
-
rt.uid,
|
|
552
|
+
podName: rt.name,
|
|
553
|
+
podUid: rt.uid,
|
|
542
554
|
ports,
|
|
543
|
-
);
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
555
|
+
});
|
|
556
|
+
watchRoutes(rt, router, published, forApps);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Verify the routing layer programmed what was just published, and report each
|
|
560
|
+
* host. A watch session republishes on every reload, so the previous watch is
|
|
561
|
+
* dropped and a fresh one started against the new host set. */
|
|
562
|
+
function watchRoutes(
|
|
563
|
+
rt: PodRuntime,
|
|
564
|
+
sessionRouter: SessionRouter,
|
|
565
|
+
published: PublishedRoute[],
|
|
566
|
+
forApps: BackendAppSpec[],
|
|
567
|
+
): void {
|
|
568
|
+
rt.routeAbort?.abort();
|
|
569
|
+
if (published.length === 0) return;
|
|
570
|
+
const abort = new AbortController();
|
|
571
|
+
rt.routeAbort = abort;
|
|
572
|
+
// A port belongs to at most one app (the session-wide uniqueness rule); the
|
|
573
|
+
// co-resident agent's port belongs to none, and reports with no `app`.
|
|
574
|
+
const ownerOf = new Map<number, string>();
|
|
575
|
+
for (const app of forApps) {
|
|
576
|
+
for (const p of app.ports) ownerOf.set(p.port, app.name);
|
|
577
|
+
}
|
|
578
|
+
void watchRouteHealth({
|
|
579
|
+
router: sessionRouter,
|
|
580
|
+
sessionId: spec.sessionId,
|
|
581
|
+
routes: published,
|
|
582
|
+
onState: (route, state, reason) =>
|
|
583
|
+
spec.onRoute(ownerOf.get(route.port), {
|
|
584
|
+
host: route.host,
|
|
585
|
+
port: route.port,
|
|
586
|
+
state,
|
|
587
|
+
reason,
|
|
588
|
+
}),
|
|
589
|
+
signal: AbortSignal.any([abort.signal, rt.abort.signal]),
|
|
590
|
+
timeoutMs: config.sessionRouting.routeReadyTimeoutMs,
|
|
591
|
+
});
|
|
566
592
|
}
|
|
567
593
|
|
|
568
594
|
async function teardownPod(): Promise<void> {
|
|
@@ -644,14 +670,6 @@ export async function startWatchSession(
|
|
|
644
670
|
return app.io === "tty" ? "tty" : real;
|
|
645
671
|
}
|
|
646
672
|
|
|
647
|
-
async function upsert(create: () => Promise<unknown>, replace: () => Promise<unknown>) {
|
|
648
|
-
try {
|
|
649
|
-
await create();
|
|
650
|
-
} catch (err) {
|
|
651
|
-
if (!isConflict(err)) throw err;
|
|
652
|
-
await replace();
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
673
|
}
|
|
656
674
|
|
|
657
675
|
function allPorts(apps: BackendAppSpec[]): PortMapping[] {
|
|
@@ -673,11 +691,6 @@ function freshPodName(sessionId: string): string {
|
|
|
673
691
|
return `telo-watch-${sessionId}-${Date.now().toString(36)}${podSequence.toString(36)}`;
|
|
674
692
|
}
|
|
675
693
|
|
|
676
|
-
function isConflict(err: unknown): boolean {
|
|
677
|
-
const e = err as { statusCode?: number; code?: number; response?: { statusCode?: number } };
|
|
678
|
-
return (e?.statusCode ?? e?.code ?? e?.response?.statusCode) === 409;
|
|
679
|
-
}
|
|
680
|
-
|
|
681
694
|
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
|
|
682
695
|
return new Promise((resolve, reject) => {
|
|
683
696
|
const timer = setTimeout(resolve, ms);
|
|
@@ -4,6 +4,7 @@ import { workspaceAppManifest, WORKSPACE_APP_FILENAME } from "@telorun/runner-co
|
|
|
4
4
|
|
|
5
5
|
import type { K8sRunnerConfig } from "../config.js";
|
|
6
6
|
import type { KubeClient } from "./client.js";
|
|
7
|
+
import { is404, isConflict } from "./pod-status.js";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* The workspace application's manifest, delivered to the pod as a ConfigMap.
|
|
@@ -39,7 +40,7 @@ export async function ensureWorkspaceConfigMap(
|
|
|
39
40
|
await kube.core.readNamespacedConfigMap({ name, namespace: config.sessionNamespace });
|
|
40
41
|
return name;
|
|
41
42
|
} catch (err) {
|
|
42
|
-
if (!
|
|
43
|
+
if (!is404(err)) throw err;
|
|
43
44
|
}
|
|
44
45
|
try {
|
|
45
46
|
await kube.core.createNamespacedConfigMap({
|
|
@@ -64,19 +65,6 @@ export async function ensureWorkspaceConfigMap(
|
|
|
64
65
|
return name;
|
|
65
66
|
}
|
|
66
67
|
|
|
67
|
-
function statusOf(err: unknown): number | undefined {
|
|
68
|
-
const e = err as { statusCode?: number; code?: number; response?: { statusCode?: number } };
|
|
69
|
-
return e?.statusCode ?? e?.code ?? e?.response?.statusCode;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function isNotFound(err: unknown): boolean {
|
|
73
|
-
return statusOf(err) === 404;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function isConflict(err: unknown): boolean {
|
|
77
|
-
return statusOf(err) === 409;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
68
|
/**
|
|
81
69
|
* Delete workspace-app ConfigMaps no live pod mounts.
|
|
82
70
|
*
|
|
@@ -126,7 +114,7 @@ export async function sweepWorkspaceConfigMaps(
|
|
|
126
114
|
await kube.core.deleteNamespacedConfigMap({ name, namespace: config.sessionNamespace });
|
|
127
115
|
removed += 1;
|
|
128
116
|
} catch (err) {
|
|
129
|
-
if (!
|
|
117
|
+
if (!is404(err)) log.warn({ err, name }, "failed to delete a stale workspace ConfigMap");
|
|
130
118
|
}
|
|
131
119
|
}
|
|
132
120
|
if (removed > 0) log.info({ removed }, "swept stale workspace-app ConfigMaps");
|
package/src/server.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { kubernetesRunnerCapabilities } from "./capabilities.js";
|
|
|
16
16
|
import { loadK8sRunnerConfig, RunnerConfigError, type K8sRunnerConfig } from "./config.js";
|
|
17
17
|
import { createKubernetesBackend } from "./k8s/backend.js";
|
|
18
18
|
import { createKubeClient } from "./k8s/client.js";
|
|
19
|
+
import { createSessionRouter } from "./k8s/routing/index.js";
|
|
19
20
|
import { sweepWorkspaceConfigMaps } from "./k8s/workspace-configmap.js";
|
|
20
21
|
|
|
21
22
|
const VERSION: string = packageJson.version;
|
|
@@ -54,7 +55,7 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
|
|
|
54
55
|
agents: deps.config.watch.enabled ? coResidentAgentNames(apps) : undefined,
|
|
55
56
|
}),
|
|
56
57
|
// Operator-predefined apps (RUNNER_APPS; none when unset). Advertised on
|
|
57
|
-
// /v1/capabilities; app sessions run the catalog image directly
|
|
58
|
+
// /v1/capabilities; app sessions run the catalog image directly.
|
|
58
59
|
apps,
|
|
59
60
|
validateConfig: catalog
|
|
60
61
|
? (sessionConfig: SessionConfig): string | undefined =>
|
|
@@ -64,8 +65,8 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
|
|
|
64
65
|
`Allowed images: ${catalog.current().join(", ")}`
|
|
65
66
|
: undefined,
|
|
66
67
|
});
|
|
67
|
-
// Mount the internal, tokenized fetch route on the same app so a
|
|
68
|
-
// initContainer can pull the
|
|
68
|
+
// Mount the internal, tokenized fetch route on the same app so a session pod's
|
|
69
|
+
// initContainer can pull the bundle tarball.
|
|
69
70
|
deps.bundleStore.registerRoute(handle.app);
|
|
70
71
|
return handle;
|
|
71
72
|
}
|
|
@@ -84,7 +85,22 @@ async function main(): Promise<void> {
|
|
|
84
85
|
|
|
85
86
|
const kube = createKubeClient();
|
|
86
87
|
const bundleStore = new BundleStore(config.selfUrl);
|
|
87
|
-
|
|
88
|
+
|
|
89
|
+
// Resolve the routing layer ONCE, here: an unroutable configuration must fail
|
|
90
|
+
// the runner rather than every session it would otherwise accept, publish
|
|
91
|
+
// routes for, and leave 404-ing with nothing reported.
|
|
92
|
+
let routing: Awaited<ReturnType<typeof createSessionRouter>>;
|
|
93
|
+
try {
|
|
94
|
+
routing = await createSessionRouter(kube, config);
|
|
95
|
+
} catch (err) {
|
|
96
|
+
if (err instanceof RunnerConfigError) {
|
|
97
|
+
process.stderr.write(`${err.message}\n`);
|
|
98
|
+
process.exit(2);
|
|
99
|
+
}
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const backend = createKubernetesBackend({ kube, config, bundleStore, router: routing.router });
|
|
88
104
|
|
|
89
105
|
const catalog = config.baseImageCatalog.enabled
|
|
90
106
|
? new BaseImageCatalog({
|
|
@@ -98,6 +114,27 @@ async function main(): Promise<void> {
|
|
|
98
114
|
|
|
99
115
|
const { app, registry } = await buildServer({ backend, config, bundleStore, catalog });
|
|
100
116
|
|
|
117
|
+
// Say which layer carries session traffic. An operator reading "logs-only" here
|
|
118
|
+
// is the difference between a misconfiguration found at boot and one found as a
|
|
119
|
+
// 404 on a URL the editor handed a user.
|
|
120
|
+
if (routing.resolved.layer === "none") {
|
|
121
|
+
app.log.warn({ reason: routing.resolved.reason }, "session routing disabled (logs-only)");
|
|
122
|
+
} else {
|
|
123
|
+
app.log.info(
|
|
124
|
+
{
|
|
125
|
+
layer: routing.resolved.layer,
|
|
126
|
+
...(routing.resolved.layer === "gateway"
|
|
127
|
+
? {
|
|
128
|
+
apiVersion: routing.resolved.apiVersion,
|
|
129
|
+
gateway: `${config.sessionRouting.gateway?.namespace}/${config.sessionRouting.gateway?.name}`,
|
|
130
|
+
}
|
|
131
|
+
: { ingressClass: config.sessionRouting.ingressClassName ?? "(cluster default)" }),
|
|
132
|
+
baseDomain: config.sessionRouting.baseDomain,
|
|
133
|
+
},
|
|
134
|
+
"session routing resolved",
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
101
138
|
// Populate the catalog before serving so the first /v1/capabilities carries the
|
|
102
139
|
// full menu; a fetch failure degrades to the default image (surfaced, not
|
|
103
140
|
// swallowed) and the periodic refresh retries.
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import type { V1Job } from "@kubernetes/client-node";
|
|
2
|
-
import type { PullPolicy, RunBundle } from "@telorun/runner-core";
|
|
3
|
-
import type { ImageBuildConfig } from "../config.js";
|
|
4
|
-
import type { BundleStore } from "../bundle-store.js";
|
|
5
|
-
import type { KubeClient } from "./client.js";
|
|
6
|
-
export interface ImageTagInputs {
|
|
7
|
-
bundle: RunBundle;
|
|
8
|
-
/** Base image the per-app image is built FROM. */
|
|
9
|
-
baseImage: string;
|
|
10
|
-
/** Resolved digest of `baseImage`, folded into the tag so a moved moving-tag
|
|
11
|
-
* (e.g. `latest-slim`) yields a fresh tag and rebuilds. Set only under the
|
|
12
|
-
* `always` pull policy; omitted → keyed on the base TAG STRING alone. */
|
|
13
|
-
baseDigest?: string;
|
|
14
|
-
}
|
|
15
|
-
/**
|
|
16
|
-
* Content-address the per-app image on its DEPENDENCY closure — the only thing
|
|
17
|
-
* `telo install` bakes into `.telo/{manifests,npm}`: the `imports:` set plus any
|
|
18
|
-
* controllers declared by inline `Telo.Definition` docs (`extractDependencyKey`).
|
|
19
|
-
* A body-only edit (resource config, CEL) keeps the tag stable so the image is
|
|
20
|
-
* reused and the new body is delivered to the pod at runtime; an import or
|
|
21
|
-
* controller change produces a fresh tag and rebuilds. The entry path is
|
|
22
|
-
* deliberately excluded — it's supplied to `telo run` at runtime, and the same
|
|
23
|
-
* imports resolve the same closure regardless of the entry filename.
|
|
24
|
-
*
|
|
25
|
-
* `local_path` controllers (or an unparseable file) set `fullContentFallback`,
|
|
26
|
-
* which folds the whole bundle into the digest — keying degrades to today's
|
|
27
|
-
* content-address, which is correct (those depend on body bytes) if less optimal.
|
|
28
|
-
*/
|
|
29
|
-
export declare function computeImageTag(inputs: ImageTagInputs): string;
|
|
30
|
-
export declare function imageRef(config: ImageBuildConfig, tag: string): string;
|
|
31
|
-
/**
|
|
32
|
-
* Dockerfile for a self-contained per-app image: `telo install` populates
|
|
33
|
-
* `/telo-cache/{manifests,npm}` so the runtime `telo run` resolves every
|
|
34
|
-
* controller and module from disk with no network and no install. The telo
|
|
35
|
-
* registry arrives as a build-arg so the build (not the running session)
|
|
36
|
-
* carries the registry dependency.
|
|
37
|
-
*
|
|
38
|
-
* `TELO_CACHE_DIR=/telo-cache` relocates the baked `.telo` OFF `/app`, so at
|
|
39
|
-
* runtime the pod mounts the freshly-delivered body at `/app` without shadowing
|
|
40
|
-
* the deps. The deps depend only on the imports/controllers closure (see
|
|
41
|
-
* `computeImageTag`); the body COPYed here is whichever session first built this
|
|
42
|
-
* tag — disposable, replaced at boot.
|
|
43
|
-
*/
|
|
44
|
-
export declare function buildDockerfile(opts: {
|
|
45
|
-
baseImage: string;
|
|
46
|
-
entryRelativePath: string;
|
|
47
|
-
}): string;
|
|
48
|
-
export interface KanikoJobArgs {
|
|
49
|
-
config: ImageBuildConfig;
|
|
50
|
-
jobName: string;
|
|
51
|
-
/** Tokenized URL the initContainer fetches the build context (bundle + Dockerfile) from. */
|
|
52
|
-
contextUrl: string;
|
|
53
|
-
/** Fully-qualified image ref Kaniko pushes. */
|
|
54
|
-
destination: string;
|
|
55
|
-
/** Small image (wget + tar) for the context-fetch initContainer. */
|
|
56
|
-
initImage: string;
|
|
57
|
-
managedByLabel: string;
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Builds the Kaniko Job. An initContainer fetches the tokenized context tarball
|
|
61
|
-
* into a shared emptyDir; Kaniko builds it and pushes to the registry. The Job
|
|
62
|
-
* is single-attempt (`backoffLimit: 0`), deadline-bounded, and self-cleaning
|
|
63
|
-
* (`ttlSecondsAfterFinished`). Kaniko needs a writable rootfs, so this runs in
|
|
64
|
-
* the trusted `telo-builds` namespace at baseline PodSecurity — never alongside
|
|
65
|
-
* untrusted session pods.
|
|
66
|
-
*/
|
|
67
|
-
export declare function buildKanikoJob(args: KanikoJobArgs): V1Job;
|
|
68
|
-
export interface EnsureImageDeps {
|
|
69
|
-
kube: KubeClient;
|
|
70
|
-
build: ImageBuildConfig;
|
|
71
|
-
bundleStore: BundleStore;
|
|
72
|
-
initImage: string;
|
|
73
|
-
managedByLabel: string;
|
|
74
|
-
}
|
|
75
|
-
export interface EnsureImageArgs {
|
|
76
|
-
bundle: RunBundle;
|
|
77
|
-
entryRelativePath: string;
|
|
78
|
-
baseImage: string;
|
|
79
|
-
/** Base-image freshness. `always` pins the per-app image to the base's current
|
|
80
|
-
* Docker Hub digest so a moved moving-tag rebuilds; `missing` / `never` (and
|
|
81
|
-
* `always` on a base whose digest can't be resolved) reuse the cached build. */
|
|
82
|
-
pullPolicy: PullPolicy;
|
|
83
|
-
/** Build-phase progress for the editor feed. */
|
|
84
|
-
onProgress?: (message: string, done?: boolean) => void;
|
|
85
|
-
}
|
|
86
|
-
/**
|
|
87
|
-
* Resolve the per-app image, building it on-cluster if absent. Returns the image
|
|
88
|
-
* ref to run; throws `SessionStartError` (stage `create`) with the build pod's
|
|
89
|
-
* log tail on failure so the cause reaches the editor instead of an opaque hang.
|
|
90
|
-
*/
|
|
91
|
-
export declare function ensureSessionImage(deps: EnsureImageDeps, args: EnsureImageArgs): Promise<string>;
|
|
92
|
-
/** Registry pull credentials decoded from a `.dockerconfigjson` Secret. */
|
|
93
|
-
export interface RegistryAuth {
|
|
94
|
-
username: string;
|
|
95
|
-
password: string;
|
|
96
|
-
}
|
|
97
|
-
/** Extract credentials for `repository`'s host from a `.dockerconfigjson` payload. */
|
|
98
|
-
export declare function parseDockerConfigAuth(dockerconfigjson: string, repository: string): RegistryAuth | undefined;
|
|
99
|
-
/** Host portion of a registry ref / dockerconfig key (scheme + path stripped). */
|
|
100
|
-
export declare function registryHost(ref: string): string;
|
|
101
|
-
/** Parse `key="value"` pairs out of a `WWW-Authenticate: Bearer ...` challenge. */
|
|
102
|
-
export declare function parseAuthParams(challenge: string): Record<string, string>;
|
|
103
|
-
//# sourceMappingURL=image-build.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"image-build.d.ts","sourceRoot":"","sources":["../../src/k8s/image-build.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,yBAAyB,CAAC;AACrD,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAGlE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAO9C,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,SAAS,CAAC;IAClB,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB;;8EAE0E;IAC1E,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,CAkB9D;AAED,wBAAgB,QAAQ,CAAC,MAAM,EAAE,gBAAgB,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAEtE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,iBAAiB,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAS9F;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,gBAAgB,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,4FAA4F;IAC5F,UAAU,EAAE,MAAM,CAAC;IACnB,+CAA+C;IAC/C,WAAW,EAAE,MAAM,CAAC;IACpB,oEAAoE;IACpE,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,aAAa,GAAG,KAAK,CA6EzD;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,UAAU,CAAC;IACjB,KAAK,EAAE,gBAAgB,CAAC;IACxB,WAAW,EAAE,WAAW,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,SAAS,CAAC;IAClB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB;;qFAEiF;IACjF,UAAU,EAAE,UAAU,CAAC;IACvB,gDAAgD;IAChD,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;CACxD;AAQD;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,eAAe,EACrB,IAAI,EAAE,eAAe,GACpB,OAAO,CAAC,MAAM,CAAC,CAmCjB;AAuGD,2EAA2E;AAC3E,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AA6FD,sFAAsF;AACtF,wBAAgB,qBAAqB,CACnC,gBAAgB,EAAE,MAAM,EACxB,UAAU,EAAE,MAAM,GACjB,YAAY,GAAG,SAAS,CAoB1B;AAED,kFAAkF;AAClF,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED,mFAAmF;AACnF,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAMzE"}
|