@telorun/k8s-runner 0.6.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/LICENSE +17 -0
- package/README.md +206 -0
- package/dist/bundle-store.d.ts +32 -0
- package/dist/bundle-store.d.ts.map +1 -0
- package/dist/bundle-store.js +86 -0
- package/dist/bundle-store.js.map +1 -0
- package/dist/capabilities.d.ts +15 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +30 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/config.d.ts +95 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +76 -0
- package/dist/config.js.map +1 -0
- package/dist/k8s/backend.d.ts +19 -0
- package/dist/k8s/backend.d.ts.map +1 -0
- package/dist/k8s/backend.js +488 -0
- package/dist/k8s/backend.js.map +1 -0
- package/dist/k8s/client.d.ts +16 -0
- package/dist/k8s/client.d.ts.map +1 -0
- package/dist/k8s/client.js +24 -0
- package/dist/k8s/client.js.map +1 -0
- package/dist/k8s/image-build.d.ts +105 -0
- package/dist/k8s/image-build.d.ts.map +1 -0
- package/dist/k8s/image-build.js +432 -0
- package/dist/k8s/image-build.js.map +1 -0
- package/dist/k8s/ingress.d.ts +15 -0
- package/dist/k8s/ingress.d.ts.map +1 -0
- package/dist/k8s/ingress.js +102 -0
- package/dist/k8s/ingress.js.map +1 -0
- package/dist/k8s/pod-spec.d.ts +41 -0
- package/dist/k8s/pod-spec.d.ts.map +1 -0
- package/dist/k8s/pod-spec.js +146 -0
- package/dist/k8s/pod-spec.js.map +1 -0
- package/dist/limits.d.ts +25 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +58 -0
- package/dist/limits.js.map +1 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +93 -0
- package/dist/server.js.map +1 -0
- package/dist/tar.d.ts +10 -0
- package/dist/tar.d.ts.map +1 -0
- package/dist/tar.js +63 -0
- package/dist/tar.js.map +1 -0
- package/package.json +41 -0
- package/src/bundle-store.ts +101 -0
- package/src/capabilities.ts +40 -0
- package/src/config.ts +201 -0
- package/src/k8s/backend-failure.test.ts +148 -0
- package/src/k8s/backend.ts +535 -0
- package/src/k8s/client.ts +39 -0
- package/src/k8s/image-build.test.ts +244 -0
- package/src/k8s/image-build.ts +547 -0
- package/src/k8s/ingress.test.ts +146 -0
- package/src/k8s/ingress.ts +127 -0
- package/src/k8s/pod-spec.ts +180 -0
- package/src/limits.test.ts +59 -0
- package/src/limits.ts +90 -0
- package/src/server.ts +124 -0
- package/src/tar.test.ts +55 -0
- package/src/tar.ts +68 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { V1Job } from "@kubernetes/client-node";
|
|
4
|
+
import type { PullPolicy, RunBundle } from "@telorun/runner-core";
|
|
5
|
+
import { SessionStartError, extractDependencyKey, resolveTagDigest } from "@telorun/runner-core";
|
|
6
|
+
|
|
7
|
+
import type { ImageBuildConfig } from "../config.js";
|
|
8
|
+
import type { BundleStore } from "../bundle-store.js";
|
|
9
|
+
import type { KubeClient } from "./client.js";
|
|
10
|
+
|
|
11
|
+
const CONTEXT_DIR = "/workspace";
|
|
12
|
+
const POLL_INTERVAL_MS = 2_000;
|
|
13
|
+
/** Grace beyond the Job's own activeDeadlineSeconds before the runner gives up. */
|
|
14
|
+
const WAIT_GRACE_MS = 30_000;
|
|
15
|
+
|
|
16
|
+
export interface ImageTagInputs {
|
|
17
|
+
bundle: RunBundle;
|
|
18
|
+
/** Base image the per-app image is built FROM. */
|
|
19
|
+
baseImage: string;
|
|
20
|
+
/** Resolved digest of `baseImage`, folded into the tag so a moved moving-tag
|
|
21
|
+
* (e.g. `latest-slim`) yields a fresh tag and rebuilds. Set only under the
|
|
22
|
+
* `always` pull policy; omitted → keyed on the base TAG STRING alone. */
|
|
23
|
+
baseDigest?: string;
|
|
24
|
+
/** Telo module registry baked into the build (affects resolved manifests). */
|
|
25
|
+
teloRegistryUrl: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Content-address the per-app image on its DEPENDENCY closure — the only thing
|
|
30
|
+
* `telo install` bakes into `.telo/{manifests,npm}`: the `imports:` set plus any
|
|
31
|
+
* controllers declared by inline `Telo.Definition` docs (`extractDependencyKey`).
|
|
32
|
+
* A body-only edit (resource config, CEL) keeps the tag stable so the image is
|
|
33
|
+
* reused and the new body is delivered to the pod at runtime; an import or
|
|
34
|
+
* controller change produces a fresh tag and rebuilds. The entry path is
|
|
35
|
+
* deliberately excluded — it's supplied to `telo run` at runtime, and the same
|
|
36
|
+
* imports resolve the same closure regardless of the entry filename.
|
|
37
|
+
*
|
|
38
|
+
* `local_path` controllers (or an unparseable file) set `fullContentFallback`,
|
|
39
|
+
* which folds the whole bundle into the digest — keying degrades to today's
|
|
40
|
+
* content-address, which is correct (those depend on body bytes) if less optimal.
|
|
41
|
+
*/
|
|
42
|
+
export function computeImageTag(inputs: ImageTagInputs): string {
|
|
43
|
+
const key = extractDependencyKey(inputs.bundle);
|
|
44
|
+
const h = createHash("sha256");
|
|
45
|
+
h.update("base\0" + inputs.baseImage + "\0");
|
|
46
|
+
if (inputs.baseDigest) h.update("base-digest\0" + inputs.baseDigest + "\0");
|
|
47
|
+
h.update("telo-registry\0" + inputs.teloRegistryUrl + "\0");
|
|
48
|
+
for (const source of key.importSources) h.update("import\0" + source + "\0");
|
|
49
|
+
for (const locator of key.controllerLocators) h.update("controller\0" + locator + "\0");
|
|
50
|
+
if (key.fullContentFallback) {
|
|
51
|
+
const files = [...inputs.bundle.files].sort((a, b) =>
|
|
52
|
+
a.relativePath < b.relativePath ? -1 : a.relativePath > b.relativePath ? 1 : 0,
|
|
53
|
+
);
|
|
54
|
+
for (const f of files) {
|
|
55
|
+
h.update("file\0" + f.relativePath + "\0");
|
|
56
|
+
h.update(f.contents);
|
|
57
|
+
h.update("\0");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return h.digest("hex").slice(0, 32);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function imageRef(config: ImageBuildConfig, tag: string): string {
|
|
64
|
+
return `${config.repository}:${tag}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Dockerfile for a self-contained per-app image: `telo install` populates
|
|
69
|
+
* `/telo-cache/{manifests,npm}` so the runtime `telo run` resolves every
|
|
70
|
+
* controller and module from disk with no network and no install. The telo
|
|
71
|
+
* registry arrives as a build-arg so the build (not the running session)
|
|
72
|
+
* carries the registry dependency.
|
|
73
|
+
*
|
|
74
|
+
* `TELO_CACHE_DIR=/telo-cache` relocates the baked `.telo` OFF `/app`, so at
|
|
75
|
+
* runtime the pod mounts the freshly-delivered body at `/app` without shadowing
|
|
76
|
+
* the deps. The deps depend only on the imports/controllers closure (see
|
|
77
|
+
* `computeImageTag`); the body COPYed here is whichever session first built this
|
|
78
|
+
* tag — disposable, replaced at boot.
|
|
79
|
+
*/
|
|
80
|
+
export function buildDockerfile(opts: { baseImage: string; entryRelativePath: string }): string {
|
|
81
|
+
return [
|
|
82
|
+
`FROM ${opts.baseImage}`,
|
|
83
|
+
`WORKDIR /app`,
|
|
84
|
+
`COPY . /app`,
|
|
85
|
+
`ARG TELO_REGISTRY_URL`,
|
|
86
|
+
`ENV TELO_REGISTRY_URL=$TELO_REGISTRY_URL`,
|
|
87
|
+
`ENV TELO_CACHE_DIR=/telo-cache`,
|
|
88
|
+
`RUN telo install /app/${opts.entryRelativePath}`,
|
|
89
|
+
``,
|
|
90
|
+
].join("\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface KanikoJobArgs {
|
|
94
|
+
config: ImageBuildConfig;
|
|
95
|
+
jobName: string;
|
|
96
|
+
/** Tokenized URL the initContainer fetches the build context (bundle + Dockerfile) from. */
|
|
97
|
+
contextUrl: string;
|
|
98
|
+
/** Fully-qualified image ref Kaniko pushes. */
|
|
99
|
+
destination: string;
|
|
100
|
+
/** Small image (wget + tar) for the context-fetch initContainer. */
|
|
101
|
+
initImage: string;
|
|
102
|
+
managedByLabel: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Builds the Kaniko Job. An initContainer fetches the tokenized context tarball
|
|
107
|
+
* into a shared emptyDir; Kaniko builds it and pushes to the registry. The Job
|
|
108
|
+
* is single-attempt (`backoffLimit: 0`), deadline-bounded, and self-cleaning
|
|
109
|
+
* (`ttlSecondsAfterFinished`). Kaniko needs a writable rootfs, so this runs in
|
|
110
|
+
* the trusted `telo-builds` namespace at baseline PodSecurity — never alongside
|
|
111
|
+
* untrusted session pods.
|
|
112
|
+
*/
|
|
113
|
+
export function buildKanikoJob(args: KanikoJobArgs): V1Job {
|
|
114
|
+
const { config } = args;
|
|
115
|
+
const kanikoArgs = [
|
|
116
|
+
`--context=dir://${CONTEXT_DIR}`,
|
|
117
|
+
`--dockerfile=${CONTEXT_DIR}/Dockerfile`,
|
|
118
|
+
`--destination=${args.destination}`,
|
|
119
|
+
`--build-arg=TELO_REGISTRY_URL=${config.teloRegistryUrl}`,
|
|
120
|
+
];
|
|
121
|
+
if (config.insecureRegistry) {
|
|
122
|
+
kanikoArgs.push("--insecure", "--skip-tls-verify", "--insecure-pull", "--skip-tls-verify-pull");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const labels = {
|
|
126
|
+
"app.kubernetes.io/managed-by": args.managedByLabel,
|
|
127
|
+
"telo.run/build": "true",
|
|
128
|
+
};
|
|
129
|
+
const usesPushSecret = Boolean(config.pushSecretName);
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
apiVersion: "batch/v1",
|
|
133
|
+
kind: "Job",
|
|
134
|
+
metadata: { name: args.jobName, namespace: config.namespace, labels },
|
|
135
|
+
spec: {
|
|
136
|
+
backoffLimit: 0,
|
|
137
|
+
activeDeadlineSeconds: config.timeoutSeconds,
|
|
138
|
+
ttlSecondsAfterFinished: 300,
|
|
139
|
+
template: {
|
|
140
|
+
metadata: { labels },
|
|
141
|
+
spec: {
|
|
142
|
+
restartPolicy: "Never",
|
|
143
|
+
automountServiceAccountToken: false,
|
|
144
|
+
initContainers: [
|
|
145
|
+
{
|
|
146
|
+
name: "context-fetch",
|
|
147
|
+
image: args.initImage,
|
|
148
|
+
command: ["sh", "-c"],
|
|
149
|
+
args: [
|
|
150
|
+
`set -e; wget -qO /tmp/context.tgz "${args.contextUrl}"; ` +
|
|
151
|
+
`tar xzf /tmp/context.tgz -C ${CONTEXT_DIR}`,
|
|
152
|
+
],
|
|
153
|
+
volumeMounts: [
|
|
154
|
+
{ name: "context", mountPath: CONTEXT_DIR },
|
|
155
|
+
{ name: "tmp", mountPath: "/tmp" },
|
|
156
|
+
],
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
containers: [
|
|
160
|
+
{
|
|
161
|
+
name: "kaniko",
|
|
162
|
+
image: config.builderImage,
|
|
163
|
+
args: kanikoArgs,
|
|
164
|
+
volumeMounts: [
|
|
165
|
+
{ name: "context", mountPath: CONTEXT_DIR },
|
|
166
|
+
...(usesPushSecret
|
|
167
|
+
? [{ name: "docker-config", mountPath: "/kaniko/.docker" }]
|
|
168
|
+
: []),
|
|
169
|
+
],
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
volumes: [
|
|
173
|
+
{ name: "context", emptyDir: {} },
|
|
174
|
+
{ name: "tmp", emptyDir: {} },
|
|
175
|
+
...(usesPushSecret
|
|
176
|
+
? [
|
|
177
|
+
{
|
|
178
|
+
name: "docker-config",
|
|
179
|
+
secret: {
|
|
180
|
+
secretName: config.pushSecretName,
|
|
181
|
+
items: [{ key: ".dockerconfigjson", path: "config.json" }],
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
]
|
|
185
|
+
: []),
|
|
186
|
+
],
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface EnsureImageDeps {
|
|
194
|
+
kube: KubeClient;
|
|
195
|
+
build: ImageBuildConfig;
|
|
196
|
+
bundleStore: BundleStore;
|
|
197
|
+
initImage: string;
|
|
198
|
+
managedByLabel: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export interface EnsureImageArgs {
|
|
202
|
+
bundle: RunBundle;
|
|
203
|
+
entryRelativePath: string;
|
|
204
|
+
baseImage: string;
|
|
205
|
+
/** Base-image freshness. `always` pins the per-app image to the base's current
|
|
206
|
+
* Docker Hub digest so a moved moving-tag rebuilds; `missing` / `never` (and
|
|
207
|
+
* `always` on a base whose digest can't be resolved) reuse the cached build. */
|
|
208
|
+
pullPolicy: PullPolicy;
|
|
209
|
+
/** Build-phase progress for the editor feed. */
|
|
210
|
+
onProgress?: (message: string, done?: boolean) => void;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Process-local single-flight: concurrent session-creates for byte-identical
|
|
215
|
+
* bundles share one build instead of each racing to create the same Job.
|
|
216
|
+
*/
|
|
217
|
+
const inFlight = new Map<string, Promise<string>>();
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Resolve the per-app image, building it on-cluster if absent. Returns the image
|
|
221
|
+
* ref to run; throws `SessionStartError` (stage `create`) with the build pod's
|
|
222
|
+
* log tail on failure so the cause reaches the editor instead of an opaque hang.
|
|
223
|
+
*/
|
|
224
|
+
export async function ensureSessionImage(
|
|
225
|
+
deps: EnsureImageDeps,
|
|
226
|
+
args: EnsureImageArgs,
|
|
227
|
+
): Promise<string> {
|
|
228
|
+
// Under the `always` pull policy, pin the per-app image to the base image's
|
|
229
|
+
// CURRENT digest: a moved moving-tag (latest-slim) then yields a fresh tag and
|
|
230
|
+
// rebuilds. Movement detection is Docker-Hub-only — if the digest can't be
|
|
231
|
+
// resolved (a non-Hub base, or a transient Hub failure) the build can't tell
|
|
232
|
+
// the base moved and reuses the cached image, so `always` degrades to
|
|
233
|
+
// `missing` for that base. We deliberately do NOT force a rebuild on an
|
|
234
|
+
// unresolved digest: the per-app tag would be unchanged, so the rebuild would
|
|
235
|
+
// overwrite the same `repo:tag` — which nodes pulling `IfNotPresent` never
|
|
236
|
+
// re-pull, and a peer build for that tag would be reused via 409 anyway. So a
|
|
237
|
+
// tagless rebuild buys no freshness; only a changed digest does.
|
|
238
|
+
let baseDigest: string | undefined;
|
|
239
|
+
if (args.pullPolicy === "always") {
|
|
240
|
+
baseDigest = await resolveTagDigest(args.baseImage, {
|
|
241
|
+
onError: (err) =>
|
|
242
|
+
args.onProgress?.(`Base image freshness check failed (${msg(err)}); using cached image`),
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const tag = computeImageTag({
|
|
247
|
+
bundle: args.bundle,
|
|
248
|
+
baseImage: args.baseImage,
|
|
249
|
+
baseDigest,
|
|
250
|
+
teloRegistryUrl: deps.build.teloRegistryUrl,
|
|
251
|
+
});
|
|
252
|
+
const ref = imageRef(deps.build, tag);
|
|
253
|
+
|
|
254
|
+
const existing = inFlight.get(ref);
|
|
255
|
+
if (existing) {
|
|
256
|
+
args.onProgress?.("Waiting for in-progress build…");
|
|
257
|
+
return existing;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const work = buildOnce(deps, args, tag, ref).finally(() => inFlight.delete(ref));
|
|
261
|
+
inFlight.set(ref, work);
|
|
262
|
+
return work;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function buildOnce(
|
|
266
|
+
deps: EnsureImageDeps,
|
|
267
|
+
args: EnsureImageArgs,
|
|
268
|
+
tag: string,
|
|
269
|
+
ref: string,
|
|
270
|
+
): Promise<string> {
|
|
271
|
+
if (await imageExists(deps.kube, deps.build, tag)) {
|
|
272
|
+
args.onProgress?.("Using cached image", true);
|
|
273
|
+
return ref;
|
|
274
|
+
}
|
|
275
|
+
args.onProgress?.("Building image…");
|
|
276
|
+
|
|
277
|
+
const dockerfile = buildDockerfile({
|
|
278
|
+
baseImage: args.baseImage,
|
|
279
|
+
entryRelativePath: args.entryRelativePath,
|
|
280
|
+
});
|
|
281
|
+
const buildId = `build-${tag}`;
|
|
282
|
+
const contextUrl = await deps.bundleStore.stageBuildContext(buildId, args.bundle, dockerfile);
|
|
283
|
+
const jobName = `telo-build-${tag}`;
|
|
284
|
+
const job = buildKanikoJob({
|
|
285
|
+
config: deps.build,
|
|
286
|
+
jobName,
|
|
287
|
+
contextUrl,
|
|
288
|
+
destination: ref,
|
|
289
|
+
initImage: deps.initImage,
|
|
290
|
+
managedByLabel: deps.managedByLabel,
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
await deps.kube.batch.createNamespacedJob({ namespace: deps.build.namespace, body: job });
|
|
295
|
+
} catch (err) {
|
|
296
|
+
if (!isConflict(err)) {
|
|
297
|
+
deps.bundleStore.drop(buildId);
|
|
298
|
+
throw new SessionStartError(
|
|
299
|
+
"start_failed",
|
|
300
|
+
"create",
|
|
301
|
+
`failed to create image build job: ${msg(err)}`,
|
|
302
|
+
msg(err),
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
// 409: a build for this exact tag is already running (peer/prior attempt) —
|
|
306
|
+
// fall through and wait for it rather than starting a duplicate.
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
await waitForJob(deps.kube, deps.build, jobName);
|
|
311
|
+
} finally {
|
|
312
|
+
deps.bundleStore.drop(buildId);
|
|
313
|
+
}
|
|
314
|
+
args.onProgress?.("Image built", true);
|
|
315
|
+
return ref;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function waitForJob(
|
|
319
|
+
kube: KubeClient,
|
|
320
|
+
build: ImageBuildConfig,
|
|
321
|
+
jobName: string,
|
|
322
|
+
): Promise<void> {
|
|
323
|
+
const deadline = Date.now() + build.timeoutSeconds * 1000 + WAIT_GRACE_MS;
|
|
324
|
+
while (Date.now() < deadline) {
|
|
325
|
+
let status: V1Job["status"];
|
|
326
|
+
try {
|
|
327
|
+
const job = await kube.batch.readNamespacedJob({ name: jobName, namespace: build.namespace });
|
|
328
|
+
status = job.status;
|
|
329
|
+
} catch (err) {
|
|
330
|
+
if (is404(err)) {
|
|
331
|
+
throw new SessionStartError(
|
|
332
|
+
"start_failed",
|
|
333
|
+
"create",
|
|
334
|
+
`image build job '${jobName}' disappeared before completing`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
await sleep(POLL_INTERVAL_MS);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
if ((status?.succeeded ?? 0) >= 1) return;
|
|
341
|
+
if ((status?.failed ?? 0) >= 1) {
|
|
342
|
+
const logs = await buildLogTail(kube, build.namespace, jobName);
|
|
343
|
+
throw new SessionStartError(
|
|
344
|
+
"start_failed",
|
|
345
|
+
"create",
|
|
346
|
+
`image build failed${logs ? `: ${logs}` : ""}`,
|
|
347
|
+
logs || undefined,
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
await sleep(POLL_INTERVAL_MS);
|
|
351
|
+
}
|
|
352
|
+
throw new SessionStartError(
|
|
353
|
+
"start_failed",
|
|
354
|
+
"create",
|
|
355
|
+
`image build did not finish within ${build.timeoutSeconds}s`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const MANIFEST_ACCEPT = [
|
|
360
|
+
"application/vnd.oci.image.manifest.v1+json",
|
|
361
|
+
"application/vnd.oci.image.index.v1+json",
|
|
362
|
+
"application/vnd.docker.distribution.manifest.v2+json",
|
|
363
|
+
"application/vnd.docker.distribution.manifest.list.v2+json",
|
|
364
|
+
].join(", ");
|
|
365
|
+
|
|
366
|
+
/** Registry pull credentials decoded from a `.dockerconfigjson` Secret. */
|
|
367
|
+
export interface RegistryAuth {
|
|
368
|
+
username: string;
|
|
369
|
+
password: string;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Best-effort registry manifest HEAD; a hit lets the runner skip the build.
|
|
374
|
+
* Reuses the push Secret's dockerconfig so the check authenticates against a
|
|
375
|
+
* private registry — without credentials a private registry answers 401 and the
|
|
376
|
+
* runner would rebuild every time. Fails safe to `false` (build) on any error.
|
|
377
|
+
*/
|
|
378
|
+
async function imageExists(
|
|
379
|
+
kube: KubeClient,
|
|
380
|
+
build: ImageBuildConfig,
|
|
381
|
+
tag: string,
|
|
382
|
+
): Promise<boolean> {
|
|
383
|
+
if (!build.registryApiUrl) return false;
|
|
384
|
+
const repoPath = repoPathOf(build.repository);
|
|
385
|
+
if (!repoPath) return false;
|
|
386
|
+
try {
|
|
387
|
+
const base = build.registryApiUrl.replace(/\/+$/, "");
|
|
388
|
+
const url = `${base}/v2/${repoPath}/manifests/${tag}`;
|
|
389
|
+
const auth = await resolveRegistryAuth(kube, build);
|
|
390
|
+
const res = await manifestHead(url, repoPath, auth);
|
|
391
|
+
return res.ok;
|
|
392
|
+
} catch {
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* HEAD the manifest, handling both auth schemes a registry may demand: Basic
|
|
399
|
+
* (sent upfront when credentials exist) and Bearer token (acquired from the
|
|
400
|
+
* realm advertised in a 401 `WWW-Authenticate` challenge, then retried).
|
|
401
|
+
*/
|
|
402
|
+
async function manifestHead(
|
|
403
|
+
url: string,
|
|
404
|
+
repoPath: string,
|
|
405
|
+
auth: RegistryAuth | undefined,
|
|
406
|
+
): Promise<Response> {
|
|
407
|
+
const headers: Record<string, string> = { Accept: MANIFEST_ACCEPT };
|
|
408
|
+
if (auth) headers.Authorization = `Basic ${basicCredential(auth)}`;
|
|
409
|
+
const res = await fetch(url, { method: "HEAD", headers });
|
|
410
|
+
if (res.status !== 401 || !auth) return res;
|
|
411
|
+
|
|
412
|
+
const token = await acquireBearerToken(res.headers.get("www-authenticate"), repoPath, auth);
|
|
413
|
+
if (!token) return res;
|
|
414
|
+
return fetch(url, {
|
|
415
|
+
method: "HEAD",
|
|
416
|
+
headers: { Accept: MANIFEST_ACCEPT, Authorization: `Bearer ${token}` },
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Exchange Basic credentials for a pull token at the challenge's `realm`. */
|
|
421
|
+
async function acquireBearerToken(
|
|
422
|
+
challenge: string | null,
|
|
423
|
+
repoPath: string,
|
|
424
|
+
auth: RegistryAuth,
|
|
425
|
+
): Promise<string | undefined> {
|
|
426
|
+
if (!challenge) return undefined;
|
|
427
|
+
const m = /^Bearer\s+(.*)$/i.exec(challenge.trim());
|
|
428
|
+
if (!m) return undefined;
|
|
429
|
+
const params = parseAuthParams(m[1]);
|
|
430
|
+
if (!params.realm) return undefined;
|
|
431
|
+
|
|
432
|
+
const tokenUrl = new URL(params.realm);
|
|
433
|
+
if (params.service) tokenUrl.searchParams.set("service", params.service);
|
|
434
|
+
tokenUrl.searchParams.set("scope", params.scope || `repository:${repoPath}:pull`);
|
|
435
|
+
const res = await fetch(tokenUrl.toString(), {
|
|
436
|
+
headers: { Authorization: `Basic ${basicCredential(auth)}` },
|
|
437
|
+
});
|
|
438
|
+
if (!res.ok) return undefined;
|
|
439
|
+
const body = (await res.json()) as { token?: string; access_token?: string };
|
|
440
|
+
return body.token ?? body.access_token;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Read + decode the push Secret's dockerconfig into credentials for the repo's host. */
|
|
444
|
+
async function resolveRegistryAuth(
|
|
445
|
+
kube: KubeClient,
|
|
446
|
+
build: ImageBuildConfig,
|
|
447
|
+
): Promise<RegistryAuth | undefined> {
|
|
448
|
+
if (!build.pushSecretName) return undefined;
|
|
449
|
+
try {
|
|
450
|
+
const secret = await kube.core.readNamespacedSecret({
|
|
451
|
+
name: build.pushSecretName,
|
|
452
|
+
namespace: build.namespace,
|
|
453
|
+
});
|
|
454
|
+
const raw = secret.data?.[".dockerconfigjson"];
|
|
455
|
+
if (!raw) return undefined;
|
|
456
|
+
const json = Buffer.from(raw, "base64").toString("utf8");
|
|
457
|
+
return parseDockerConfigAuth(json, build.repository);
|
|
458
|
+
} catch {
|
|
459
|
+
return undefined;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Extract credentials for `repository`'s host from a `.dockerconfigjson` payload. */
|
|
464
|
+
export function parseDockerConfigAuth(
|
|
465
|
+
dockerconfigjson: string,
|
|
466
|
+
repository: string,
|
|
467
|
+
): RegistryAuth | undefined {
|
|
468
|
+
let cfg: { auths?: Record<string, { auth?: string; username?: string; password?: string }> };
|
|
469
|
+
try {
|
|
470
|
+
cfg = JSON.parse(dockerconfigjson);
|
|
471
|
+
} catch {
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
474
|
+
const auths = cfg.auths ?? {};
|
|
475
|
+
const host = registryHost(repository);
|
|
476
|
+
const entry = auths[host] ?? Object.entries(auths).find(([k]) => registryHost(k) === host)?.[1];
|
|
477
|
+
if (!entry) return undefined;
|
|
478
|
+
if (entry.username && entry.password) {
|
|
479
|
+
return { username: entry.username, password: entry.password };
|
|
480
|
+
}
|
|
481
|
+
if (entry.auth) {
|
|
482
|
+
const decoded = Buffer.from(entry.auth, "base64").toString("utf8");
|
|
483
|
+
const sep = decoded.indexOf(":");
|
|
484
|
+
if (sep >= 0) return { username: decoded.slice(0, sep), password: decoded.slice(sep + 1) };
|
|
485
|
+
}
|
|
486
|
+
return undefined;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** Host portion of a registry ref / dockerconfig key (scheme + path stripped). */
|
|
490
|
+
export function registryHost(ref: string): string {
|
|
491
|
+
return ref.replace(/^https?:\/\//, "").split("/")[0];
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** Parse `key="value"` pairs out of a `WWW-Authenticate: Bearer ...` challenge. */
|
|
495
|
+
export function parseAuthParams(challenge: string): Record<string, string> {
|
|
496
|
+
const params: Record<string, string> = {};
|
|
497
|
+
const re = /(\w+)="([^"]*)"/g;
|
|
498
|
+
let m: RegExpExecArray | null;
|
|
499
|
+
while ((m = re.exec(challenge)) !== null) params[m[1]] = m[2];
|
|
500
|
+
return params;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function basicCredential(auth: RegistryAuth): string {
|
|
504
|
+
return Buffer.from(`${auth.username}:${auth.password}`).toString("base64");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Last log lines of the build pod, for an actionable failure message. */
|
|
508
|
+
async function buildLogTail(kube: KubeClient, namespace: string, jobName: string): Promise<string> {
|
|
509
|
+
try {
|
|
510
|
+
const pods = await kube.core.listNamespacedPod({
|
|
511
|
+
namespace,
|
|
512
|
+
labelSelector: `job-name=${jobName}`,
|
|
513
|
+
});
|
|
514
|
+
const podName = pods.items?.[0]?.metadata?.name;
|
|
515
|
+
if (!podName) return "";
|
|
516
|
+
const log = await kube.core.readNamespacedPodLog({ name: podName, namespace, tailLines: 20 });
|
|
517
|
+
return typeof log === "string" ? log.trim() : "";
|
|
518
|
+
} catch {
|
|
519
|
+
return "";
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function repoPathOf(repository: string): string | null {
|
|
524
|
+
const slash = repository.indexOf("/");
|
|
525
|
+
return slash >= 0 ? repository.slice(slash + 1) : null;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function statusCode(err: unknown): number | undefined {
|
|
529
|
+
const e = err as { statusCode?: number; code?: number; response?: { statusCode?: number } };
|
|
530
|
+
return e?.statusCode ?? e?.code ?? e?.response?.statusCode;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function is404(err: unknown): boolean {
|
|
534
|
+
return statusCode(err) === 404;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function isConflict(err: unknown): boolean {
|
|
538
|
+
return statusCode(err) === 409;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function msg(err: unknown): string {
|
|
542
|
+
return err instanceof Error ? err.message : String(err);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function sleep(ms: number): Promise<void> {
|
|
546
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
547
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { PortMapping } from "@telorun/runner-core";
|
|
4
|
+
|
|
5
|
+
import type { K8sRunnerConfig } from "../config.js";
|
|
6
|
+
import { buildSessionIngress, endpointsFor } from "./ingress.js";
|
|
7
|
+
|
|
8
|
+
const config = {
|
|
9
|
+
sessionNamespace: "telo-sessions",
|
|
10
|
+
sessionIngressBaseDomain: "telo.run",
|
|
11
|
+
sessionIngressClassName: "nginx",
|
|
12
|
+
managedByLabel: "telo-k8s-runner",
|
|
13
|
+
} as K8sRunnerConfig;
|
|
14
|
+
|
|
15
|
+
const sessionId = "hzyayvabgyvz";
|
|
16
|
+
const ports: PortMapping[] = [
|
|
17
|
+
{ port: 8080, protocol: "tcp" },
|
|
18
|
+
{ port: 9090, protocol: "tcp" },
|
|
19
|
+
{ port: 5000, protocol: "udp" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
describe("endpointsFor", () => {
|
|
23
|
+
it("gives every tcp port a <port>-<id>.<domain> host + url, udp host-less", () => {
|
|
24
|
+
expect(endpointsFor(config, sessionId, ports)).toEqual([
|
|
25
|
+
{
|
|
26
|
+
host: `8080-${sessionId}.telo.run`,
|
|
27
|
+
port: 8080,
|
|
28
|
+
protocol: "tcp",
|
|
29
|
+
url: `https://8080-${sessionId}.telo.run`,
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
host: `9090-${sessionId}.telo.run`,
|
|
33
|
+
port: 9090,
|
|
34
|
+
protocol: "tcp",
|
|
35
|
+
url: `https://9090-${sessionId}.telo.run`,
|
|
36
|
+
},
|
|
37
|
+
{ host: "", port: 5000, protocol: "udp" },
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("leaves hosts blank when no ingress base domain is configured", () => {
|
|
42
|
+
const logsOnly = { ...config, sessionIngressBaseDomain: undefined } as K8sRunnerConfig;
|
|
43
|
+
expect(endpointsFor(logsOnly, sessionId, ports)).toEqual([
|
|
44
|
+
{ host: "", port: 8080, protocol: "tcp" },
|
|
45
|
+
{ host: "", port: 9090, protocol: "tcp" },
|
|
46
|
+
{ host: "", port: 5000, protocol: "udp" },
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("buildSessionIngress", () => {
|
|
52
|
+
it("emits one rule per tcp port to the matching service port", () => {
|
|
53
|
+
const { ingress, hosts } = buildSessionIngress(
|
|
54
|
+
config,
|
|
55
|
+
sessionId,
|
|
56
|
+
`telo-run-${sessionId}`,
|
|
57
|
+
"pod",
|
|
58
|
+
"uid",
|
|
59
|
+
ports,
|
|
60
|
+
);
|
|
61
|
+
expect(hosts).toEqual([`8080-${sessionId}.telo.run`, `9090-${sessionId}.telo.run`]);
|
|
62
|
+
expect(ingress.spec?.ingressClassName).toBe("nginx");
|
|
63
|
+
expect(ingress.spec?.rules).toEqual([
|
|
64
|
+
{
|
|
65
|
+
host: `8080-${sessionId}.telo.run`,
|
|
66
|
+
http: {
|
|
67
|
+
paths: [
|
|
68
|
+
{
|
|
69
|
+
path: "/",
|
|
70
|
+
pathType: "Prefix",
|
|
71
|
+
backend: { service: { name: `telo-run-${sessionId}`, port: { number: 8080 } } },
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
host: `9090-${sessionId}.telo.run`,
|
|
78
|
+
http: {
|
|
79
|
+
paths: [
|
|
80
|
+
{
|
|
81
|
+
path: "/",
|
|
82
|
+
pathType: "Prefix",
|
|
83
|
+
backend: { service: { name: `telo-run-${sessionId}`, port: { number: 9090 } } },
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("produces no rules when there are no tcp ports", () => {
|
|
92
|
+
const { ingress, hosts } = buildSessionIngress(
|
|
93
|
+
config,
|
|
94
|
+
sessionId,
|
|
95
|
+
`telo-run-${sessionId}`,
|
|
96
|
+
"pod",
|
|
97
|
+
"uid",
|
|
98
|
+
[{ port: 5000, protocol: "udp" }],
|
|
99
|
+
);
|
|
100
|
+
expect(hosts).toEqual([]);
|
|
101
|
+
expect(ingress.spec?.rules).toEqual([]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("omits the tls block when no tls secret is configured", () => {
|
|
105
|
+
const { ingress } = buildSessionIngress(
|
|
106
|
+
config,
|
|
107
|
+
sessionId,
|
|
108
|
+
`telo-run-${sessionId}`,
|
|
109
|
+
"pod",
|
|
110
|
+
"uid",
|
|
111
|
+
ports,
|
|
112
|
+
);
|
|
113
|
+
expect(ingress.spec?.tls).toBeUndefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("presents the configured tls secret for every tcp host", () => {
|
|
117
|
+
const tlsConfig = { ...config, sessionIngressTlsSecretName: "telo-origin-tls" } as K8sRunnerConfig;
|
|
118
|
+
const { ingress } = buildSessionIngress(
|
|
119
|
+
tlsConfig,
|
|
120
|
+
sessionId,
|
|
121
|
+
`telo-run-${sessionId}`,
|
|
122
|
+
"pod",
|
|
123
|
+
"uid",
|
|
124
|
+
ports,
|
|
125
|
+
);
|
|
126
|
+
expect(ingress.spec?.tls).toEqual([
|
|
127
|
+
{
|
|
128
|
+
hosts: [`8080-${sessionId}.telo.run`, `9090-${sessionId}.telo.run`],
|
|
129
|
+
secretName: "telo-origin-tls",
|
|
130
|
+
},
|
|
131
|
+
]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("omits the tls block when there are no tcp hosts to secure", () => {
|
|
135
|
+
const tlsConfig = { ...config, sessionIngressTlsSecretName: "telo-origin-tls" } as K8sRunnerConfig;
|
|
136
|
+
const { ingress } = buildSessionIngress(
|
|
137
|
+
tlsConfig,
|
|
138
|
+
sessionId,
|
|
139
|
+
`telo-run-${sessionId}`,
|
|
140
|
+
"pod",
|
|
141
|
+
"uid",
|
|
142
|
+
[{ port: 5000, protocol: "udp" }],
|
|
143
|
+
);
|
|
144
|
+
expect(ingress.spec?.tls).toBeUndefined();
|
|
145
|
+
});
|
|
146
|
+
});
|