@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/config.ts
CHANGED
|
@@ -46,31 +46,49 @@ export interface LimitCeilings {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
49
|
+
* Which routing layer the runner publishes per-session routes on.
|
|
50
|
+
*
|
|
51
|
+
* - `auto` — resolve from what is CONFIGURED, then from what the cluster
|
|
52
|
+
* serves; refuse at boot when the answer is genuinely ambiguous.
|
|
53
|
+
* - `ingress` — `networking.k8s.io/v1` Ingress.
|
|
54
|
+
* - `gateway` — `gateway.networking.k8s.io/v1` HTTPRoute.
|
|
55
|
+
* - `none` — publish nothing; the session is logs-only even with a base
|
|
56
|
+
* domain set.
|
|
53
57
|
*/
|
|
54
|
-
export
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
export type SessionRoutingMode = "auto" | "ingress" | "gateway" | "none";
|
|
59
|
+
|
|
60
|
+
const ROUTING_MODES: readonly SessionRoutingMode[] = ["auto", "ingress", "gateway", "none"];
|
|
61
|
+
|
|
62
|
+
/** The Gateway a session's HTTPRoute attaches to. Never detected: picking one of
|
|
63
|
+
* several Gateways is the same guess as picking one of two routing APIs, and a
|
|
64
|
+
* route attached to the wrong Gateway fails exactly as silently as no route. */
|
|
65
|
+
export interface GatewayParentConfig {
|
|
66
|
+
name: string;
|
|
67
|
+
/** Defaults to the session namespace — mirroring Gateway API's own
|
|
68
|
+
* `parentRefs[].namespace` default, which is the route's own namespace. */
|
|
59
69
|
namespace: string;
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
/** Optional listener name, when the Gateway has several and only one should
|
|
71
|
+
* carry session traffic. */
|
|
72
|
+
sectionName?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface SessionRoutingConfig {
|
|
76
|
+
mode: SessionRoutingMode;
|
|
77
|
+
/** Wildcard base domain for per-session hosts; unset → logs-only whatever the
|
|
78
|
+
* mode, since without it no host can be constructed at all. */
|
|
79
|
+
baseDomain?: string;
|
|
80
|
+
/** IngressClass for created session Ingresses (ingress mode). */
|
|
81
|
+
ingressClassName?: string;
|
|
82
|
+
/** `kubernetes.io/tls` Secret (in the session namespace) the per-session
|
|
83
|
+
* Ingress presents so an upstream (e.g. Cloudflare Full (Strict)) can validate
|
|
84
|
+
* the origin. Must cover `*.<baseDomain>`. INGRESS MODE ONLY — under Gateway
|
|
85
|
+
* API the certificate belongs to the Gateway's listener, so setting this in
|
|
86
|
+
* gateway mode is refused at boot rather than silently ignored. */
|
|
87
|
+
tlsSecretName?: string;
|
|
88
|
+
gateway?: GatewayParentConfig;
|
|
89
|
+
/** How long a published route may go unclaimed by any controller before it is
|
|
90
|
+
* reported `unprogrammed`. */
|
|
91
|
+
routeReadyTimeoutMs: number;
|
|
74
92
|
}
|
|
75
93
|
|
|
76
94
|
export interface K8sRunnerConfig extends RunnerCoreConfig {
|
|
@@ -78,28 +96,24 @@ export interface K8sRunnerConfig extends RunnerCoreConfig {
|
|
|
78
96
|
* with these. */
|
|
79
97
|
displayName: string;
|
|
80
98
|
description: string;
|
|
81
|
-
/** Namespace where session Pods/Services
|
|
99
|
+
/** Namespace where session Pods/Services and routing objects are created. */
|
|
82
100
|
sessionNamespace: string;
|
|
83
101
|
/** Default image for spawned session Pods (telorun/node). Baked into the runner
|
|
84
102
|
* image as `RUNNER_IMAGE` at build time (the kernel version the runner was
|
|
85
103
|
* built against); the literal fallback below is for a source checkout only. */
|
|
86
104
|
defaultImage: string;
|
|
87
|
-
/** Small image for the bundle
|
|
105
|
+
/** Small image for the bundle-fetch initContainer (needs wget + tar). */
|
|
88
106
|
initImage: string;
|
|
107
|
+
/** Optional dockerconfig Secret (in the session namespace) the kubelet pulls
|
|
108
|
+
* session images with — the kernel image, and any operator catalog image
|
|
109
|
+
* (`RUNNER_APPS`) held in a private registry. */
|
|
110
|
+
imagePullSecret?: string;
|
|
89
111
|
/** Optional sandbox RuntimeClass (gvisor/kata). Unset → cluster default (runc). */
|
|
90
112
|
runtimeClass?: string;
|
|
91
|
-
/**
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
/** Optional `kubernetes.io/tls` Secret (in the session namespace) the per-session
|
|
96
|
-
* Ingress presents so an upstream (e.g. Cloudflare Full (Strict)) can validate
|
|
97
|
-
* the origin. Must cover the wildcard `*.<sessionIngressBaseDomain>`. Unset → no TLS block. */
|
|
98
|
-
sessionIngressTlsSecretName?: string;
|
|
99
|
-
/** On-cluster image build — the only delivery path (always present). */
|
|
100
|
-
build: ImageBuildConfig;
|
|
101
|
-
/** Runner's own in-cluster base URL, used to build the bundle/build-context
|
|
102
|
-
* fetch URL the initContainer curls (e.g. http://k8s-runner.telo-runner:8062). */
|
|
113
|
+
/** How per-session routes are published. */
|
|
114
|
+
sessionRouting: SessionRoutingConfig;
|
|
115
|
+
/** Runner's own in-cluster base URL, used to build the bundle fetch URL the
|
|
116
|
+
* initContainer curls (e.g. http://k8s-runner.telo-runner:8062). */
|
|
103
117
|
selfUrl: string;
|
|
104
118
|
/** Label applied to every session object, used for orphan reaping. */
|
|
105
119
|
managedByLabel: string;
|
|
@@ -128,6 +142,69 @@ function parseTagRegex(raw: string | undefined, field: string): RegExp[] | undef
|
|
|
128
142
|
}
|
|
129
143
|
}
|
|
130
144
|
|
|
145
|
+
/** Parse the routing mode, THROWING on an unrecognized one rather than degrading
|
|
146
|
+
* to a default — a typo'd mode that silently fell back to `auto` is the same
|
|
147
|
+
* class of failure this whole mechanism exists to remove. */
|
|
148
|
+
function parseRoutingMode(raw: string | undefined): SessionRoutingMode {
|
|
149
|
+
const v = raw?.trim();
|
|
150
|
+
if (!v) return "auto";
|
|
151
|
+
if ((ROUTING_MODES as readonly string[]).includes(v)) return v as SessionRoutingMode;
|
|
152
|
+
throw new RunnerConfigError(
|
|
153
|
+
`SESSION_ROUTING_MODE must be one of ${ROUTING_MODES.join(" | ")} (got '${v}').`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function loadSessionRoutingConfig(
|
|
158
|
+
env: NodeJS.ProcessEnv,
|
|
159
|
+
sessionNamespace: string,
|
|
160
|
+
): SessionRoutingConfig {
|
|
161
|
+
const mode = parseRoutingMode(env.SESSION_ROUTING_MODE);
|
|
162
|
+
const gatewayName = env.SESSION_GATEWAY_NAME?.trim() || undefined;
|
|
163
|
+
const tlsSecretName = env.SESSION_INGRESS_TLS_SECRET?.trim() || undefined;
|
|
164
|
+
|
|
165
|
+
// A certificate configured for a layer that will never present it is a silent
|
|
166
|
+
// downgrade to plaintext origin traffic, so it is refused rather than ignored.
|
|
167
|
+
// This is only the FAST PATH, for the mode that states the layer outright: the
|
|
168
|
+
// authoritative refusal is at routing resolution, because under `auto` a named
|
|
169
|
+
// Gateway decides the layer and nothing here can see that yet.
|
|
170
|
+
if (mode === "gateway" && tlsSecretName) {
|
|
171
|
+
throw new RunnerConfigError(
|
|
172
|
+
"SESSION_INGRESS_TLS_SECRET is set but SESSION_ROUTING_MODE=gateway. Under Gateway API the " +
|
|
173
|
+
"origin certificate belongs to the Gateway listener's own `tls.certificateRefs`, not to the " +
|
|
174
|
+
"per-session route. Move the Secret reference to the Gateway and unset this.",
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
if (mode === "gateway" && !gatewayName) {
|
|
178
|
+
throw new RunnerConfigError(
|
|
179
|
+
"SESSION_ROUTING_MODE=gateway requires SESSION_GATEWAY_NAME — the Gateway each session's " +
|
|
180
|
+
"HTTPRoute attaches to. Set it (and SESSION_GATEWAY_NAMESPACE when the Gateway is not in " +
|
|
181
|
+
`'${sessionNamespace}').`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
mode,
|
|
187
|
+
baseDomain: env.SESSION_ROUTING_BASE_DOMAIN?.trim() || undefined,
|
|
188
|
+
ingressClassName: env.SESSION_INGRESS_CLASS?.trim() || undefined,
|
|
189
|
+
tlsSecretName,
|
|
190
|
+
...(gatewayName
|
|
191
|
+
? {
|
|
192
|
+
gateway: {
|
|
193
|
+
name: gatewayName,
|
|
194
|
+
namespace: env.SESSION_GATEWAY_NAMESPACE?.trim() || sessionNamespace,
|
|
195
|
+
sectionName: env.SESSION_GATEWAY_SECTION_NAME?.trim() || undefined,
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
: {}),
|
|
199
|
+
routeReadyTimeoutMs:
|
|
200
|
+
parsePositiveInt(
|
|
201
|
+
env.SESSION_ROUTE_READY_TIMEOUT_SECONDS,
|
|
202
|
+
60,
|
|
203
|
+
"SESSION_ROUTE_READY_TIMEOUT_SECONDS",
|
|
204
|
+
) * 1000,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
131
208
|
function loadBaseImageCatalogConfig(env: NodeJS.ProcessEnv): BaseImageCatalogConfig {
|
|
132
209
|
return {
|
|
133
210
|
enabled: parseBool(env.RUNNER_BASE_IMAGE_CATALOG_ENABLED, true, "RUNNER_BASE_IMAGE_CATALOG_ENABLED"),
|
|
@@ -162,50 +239,35 @@ export function loadK8sRunnerConfig(env: NodeJS.ProcessEnv): K8sRunnerConfig {
|
|
|
162
239
|
);
|
|
163
240
|
}
|
|
164
241
|
|
|
165
|
-
const
|
|
166
|
-
if (!repository) {
|
|
167
|
-
throw new RunnerConfigError(
|
|
168
|
-
"RUNNER_IMAGE_REPOSITORY env var is required. The runner prebuilds a per-app session image for " +
|
|
169
|
-
"every run (there is no in-pod install fallback); set it to the registry repository the built " +
|
|
170
|
-
"images are pushed to / pulled from (e.g. registry.example.com/telo-sessions). The tag is the " +
|
|
171
|
-
"bundle content hash.",
|
|
172
|
-
);
|
|
173
|
-
}
|
|
174
|
-
const build: ImageBuildConfig = {
|
|
175
|
-
repository: repository.replace(/\/+$/, ""),
|
|
176
|
-
namespace: env.RUNNER_BUILD_NAMESPACE?.trim() || "telo-builds",
|
|
177
|
-
builderImage: env.RUNNER_BUILDER_IMAGE?.trim() || "gcr.io/kaniko-project/executor:latest",
|
|
178
|
-
timeoutSeconds: parsePositiveInt(
|
|
179
|
-
env.RUNNER_BUILD_TIMEOUT_SECONDS,
|
|
180
|
-
600,
|
|
181
|
-
"RUNNER_BUILD_TIMEOUT_SECONDS",
|
|
182
|
-
),
|
|
183
|
-
insecureRegistry: env.RUNNER_REGISTRY_INSECURE?.trim() === "true",
|
|
184
|
-
registryApiUrl: env.RUNNER_REGISTRY_API_URL?.trim() || undefined,
|
|
185
|
-
pushSecretName: env.RUNNER_REGISTRY_PUSH_SECRET?.trim() || undefined,
|
|
186
|
-
imagePullSecret: env.RUNNER_IMAGE_PULL_SECRET?.trim() || undefined,
|
|
187
|
-
};
|
|
242
|
+
const sessionNamespace = env.RUNNER_SESSION_NAMESPACE?.trim() || "telo-sessions";
|
|
188
243
|
|
|
189
244
|
return {
|
|
190
245
|
...loadCoreConfig(env, { port: DEFAULT_PORT }),
|
|
191
246
|
displayName: env.RUNNER_DISPLAY_NAME?.trim() || "Telo Runner",
|
|
192
247
|
description:
|
|
193
248
|
env.RUNNER_DESCRIPTION?.trim() || "Runs the Telo application in a cloud environment",
|
|
194
|
-
sessionNamespace
|
|
249
|
+
sessionNamespace,
|
|
195
250
|
defaultImage: env.RUNNER_IMAGE?.trim() || "telorun/node:latest-slim",
|
|
196
251
|
initImage: env.RUNNER_INIT_IMAGE?.trim() || "busybox:stable",
|
|
252
|
+
imagePullSecret: env.RUNNER_IMAGE_PULL_SECRET?.trim() || undefined,
|
|
197
253
|
runtimeClass: env.RUNNER_RUNTIME_CLASS?.trim() || undefined,
|
|
198
|
-
|
|
199
|
-
sessionIngressClassName: env.SESSION_INGRESS_CLASS?.trim() || undefined,
|
|
200
|
-
sessionIngressTlsSecretName: env.SESSION_INGRESS_TLS_SECRET?.trim() || undefined,
|
|
201
|
-
build,
|
|
254
|
+
sessionRouting: loadSessionRoutingConfig(env, sessionNamespace),
|
|
202
255
|
selfUrl: selfUrl.replace(/\/+$/, ""),
|
|
203
256
|
managedByLabel: env.RUNNER_MANAGED_BY?.trim() || "telo-k8s-runner",
|
|
257
|
+
// Sized for a pod that RESOLVES ITS OWN module closure. They used to be
|
|
258
|
+
// 50m / 100Mi / 512Mi, which described a different workload: the closure
|
|
259
|
+
// arrived in image layers, so the pod only ran it. It now downloads,
|
|
260
|
+
// unpacks and resolves it in-pod, into an emptyDir that counts against
|
|
261
|
+
// ephemeral-storage — so the old numbers meant an OOMKill on the memory
|
|
262
|
+
// ceiling and an eviction on the storage one, for the ordinary case. The
|
|
263
|
+
// watch path already ran this workload and already carried the roomier
|
|
264
|
+
// ceiling; these match it. `appLimits` stays separate: it still differs in
|
|
265
|
+
// TTL, and both remain the operator's policy to tighten.
|
|
204
266
|
limits: {
|
|
205
|
-
cpu: env.RUNNER_MAX_CPU?.trim() || "
|
|
206
|
-
memory: env.RUNNER_MAX_MEMORY?.trim() || "
|
|
267
|
+
cpu: env.RUNNER_MAX_CPU?.trim() || "500m",
|
|
268
|
+
memory: env.RUNNER_MAX_MEMORY?.trim() || "512Mi",
|
|
207
269
|
ttlSeconds: parsePositiveInt(env.RUNNER_MAX_TTL_SECONDS, 3600, "RUNNER_MAX_TTL_SECONDS"),
|
|
208
|
-
ephemeralStorage: env.RUNNER_MAX_EPHEMERAL_STORAGE?.trim() || "
|
|
270
|
+
ephemeralStorage: env.RUNNER_MAX_EPHEMERAL_STORAGE?.trim() || "1Gi",
|
|
209
271
|
},
|
|
210
272
|
appLimits: {
|
|
211
273
|
cpu: env.RUNNER_APP_MAX_CPU?.trim() || "500m",
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { ApiException } from "@kubernetes/client-node";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { apiFailure, apiReason } from "./api-error.js";
|
|
5
|
+
|
|
6
|
+
/** A 403 with an arbitrary admission message. `body` is the raw JSON text,
|
|
7
|
+
* which is what the client-node deserializer leaves for a status code it does
|
|
8
|
+
* not recognize — exactly the 403 case ("Unknown API Status Code!"). */
|
|
9
|
+
function forbidden(message: string): ApiException<string> {
|
|
10
|
+
const body = JSON.stringify({
|
|
11
|
+
kind: "Status",
|
|
12
|
+
apiVersion: "v1",
|
|
13
|
+
status: "Failure",
|
|
14
|
+
message,
|
|
15
|
+
reason: "Forbidden",
|
|
16
|
+
code: 403,
|
|
17
|
+
});
|
|
18
|
+
return new ApiException<string>(403, "Unknown API Status Code!", body, {
|
|
19
|
+
"audit-id": "240bebaf-17aa-4684-8732-210b5b720f5a",
|
|
20
|
+
"x-kubernetes-pf-flowschema-uid": "f895735a-d707-400c-88de-23e24406fa8c",
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const RBAC_DENIAL =
|
|
25
|
+
'pods is forbidden: User "system:serviceaccount:telo-runner:telo-k8s-runner" cannot create resource "pods" in the namespace "telo-sessions"';
|
|
26
|
+
const QUOTA_DENIAL =
|
|
27
|
+
'pods "telo-run-abc" is forbidden: exceeded quota: telo-k8s-runner-sessions, requested: pods=1, used: pods=32, limited: pods=32';
|
|
28
|
+
|
|
29
|
+
describe("apiReason", () => {
|
|
30
|
+
it("keeps the status and reason, and nothing else the exception carried", () => {
|
|
31
|
+
const reason = apiReason(forbidden(RBAC_DENIAL));
|
|
32
|
+
expect(reason).toContain("Forbidden");
|
|
33
|
+
expect(reason).toContain("403");
|
|
34
|
+
// The whole point: no cluster identities, no audit id, no headers.
|
|
35
|
+
expect(reason).not.toContain("system:serviceaccount");
|
|
36
|
+
expect(reason).not.toContain("audit-id");
|
|
37
|
+
expect(reason).not.toContain("flowschema");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("names RBAC as the fix for a rejection only the operator can clear", () => {
|
|
41
|
+
expect(apiReason(forbidden(RBAC_DENIAL))).toContain("RBAC");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("tells a quota rejection from an RBAC one — same code, same reason", () => {
|
|
45
|
+
// The chart ships a 32-pod ResourceQuota enabled by default, so the 33rd
|
|
46
|
+
// session is a 403 `Forbidden` that has nothing to do with RBAC. Sending
|
|
47
|
+
// that operator to re-check a Role is the wrong action at the one moment
|
|
48
|
+
// the message is read under load.
|
|
49
|
+
const reason = apiReason(forbidden(QUOTA_DENIAL));
|
|
50
|
+
expect(reason).toContain("quota");
|
|
51
|
+
expect(reason).not.toContain("RBAC");
|
|
52
|
+
// The admission message is classified, never echoed.
|
|
53
|
+
expect(reason).not.toContain("telo-k8s-runner-sessions");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("stays neutral on a 403 whose message identifies neither", () => {
|
|
57
|
+
const reason = apiReason(forbidden("denied by a validating webhook"));
|
|
58
|
+
expect(reason).toContain("rejected the request");
|
|
59
|
+
expect(reason).not.toContain("RBAC");
|
|
60
|
+
expect(reason).not.toContain("quota");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("reads a parsed status body as well as a raw one", () => {
|
|
64
|
+
const parsed = new ApiException<unknown>(404, "Not Found", { reason: "NotFound" }, {});
|
|
65
|
+
expect(apiReason(parsed)).toContain("NotFound");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("reports an unreachable apiserver as unreachable, not as a rejection", () => {
|
|
69
|
+
// A system error's `code` is a STRING and a DOMException's is a small
|
|
70
|
+
// number, both in the field an HTTP status also lands in. Reading either as
|
|
71
|
+
// a status claimed the apiserver refused something it was never asked.
|
|
72
|
+
const refused = Object.assign(new Error("connect ECONNREFUSED"), { code: "ECONNREFUSED" });
|
|
73
|
+
const aborted = Object.assign(new Error("The operation was aborted"), { code: 20 });
|
|
74
|
+
for (const err of [refused, aborted, new Error("socket hang up")]) {
|
|
75
|
+
expect(apiReason(err)).toBe("the Kubernetes API could not be reached");
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
describe("apiFailure", () => {
|
|
81
|
+
it("carries the raw exception as `cause` so the runner log keeps the detail", () => {
|
|
82
|
+
const raw = forbidden(RBAC_DENIAL);
|
|
83
|
+
const failure = apiFailure(raw, "create", "could not create the session pod");
|
|
84
|
+
expect(failure.stage).toBe("create");
|
|
85
|
+
expect(failure.message).toContain("could not create the session pod");
|
|
86
|
+
expect(failure.message).not.toContain("system:serviceaccount");
|
|
87
|
+
expect(failure.cause).toBe(raw);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { SessionStartError, type StartFailureStage } from "@telorun/runner-core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A Kubernetes API rejection, phrased for the person who clicked Run.
|
|
5
|
+
*
|
|
6
|
+
* `ApiException.message` is a full HTTP dump — status line, the raw `Status`
|
|
7
|
+
* body and every response header — and a start failure's message travels
|
|
8
|
+
* verbatim to the client as the session's terminal `failed` status. That put
|
|
9
|
+
* the runner's ServiceAccount name, the request's audit id and its flowschema
|
|
10
|
+
* UIDs on an end user's screen, none of which they can act on.
|
|
11
|
+
*
|
|
12
|
+
* So the client sees the operation, the HTTP status and the API's own one-word
|
|
13
|
+
* `reason` — enough for an operator to know what was refused — and never the
|
|
14
|
+
* body or the headers. The raw exception rides along as the error's `cause`,
|
|
15
|
+
* which the runner's log serializer records, so nothing is swallowed: the detail
|
|
16
|
+
* moves to the log, it does not disappear.
|
|
17
|
+
*/
|
|
18
|
+
export function apiFailure(
|
|
19
|
+
err: unknown,
|
|
20
|
+
stage: StartFailureStage,
|
|
21
|
+
action: string,
|
|
22
|
+
): SessionStartError {
|
|
23
|
+
return withCause(
|
|
24
|
+
new SessionStartError("start_failed", stage, `${action}: ${apiReason(err)}`),
|
|
25
|
+
err,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Attach the original error as `cause` so a log serializer can reach it.
|
|
30
|
+
* Every wrap on a failure path goes through here — a rewrap that drops the
|
|
31
|
+
* cause silently undoes the whole point of summarizing the message. */
|
|
32
|
+
export function withCause<E extends Error>(error: E, cause: unknown): E {
|
|
33
|
+
error.cause = cause;
|
|
34
|
+
return error;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The client-safe half of a Kubernetes API error: what was refused, and who
|
|
38
|
+
* can act on it — never the `Status` message, which names cluster identities. */
|
|
39
|
+
export function apiReason(err: unknown): string {
|
|
40
|
+
const code = statusCode(err);
|
|
41
|
+
// No HTTP status means the request never got an answer: a refused connection,
|
|
42
|
+
// a DNS failure, an abort. Those carry a `code` too (`ECONNREFUSED`, an
|
|
43
|
+
// `ABORT_ERR` number), which is why `statusCode` accepts only what an HTTP
|
|
44
|
+
// response could have produced — reporting `HTTP ECONNREFUSED` claimed the
|
|
45
|
+
// apiserver rejected something when it was never reached, which is exactly
|
|
46
|
+
// the case where the difference matters.
|
|
47
|
+
if (code === undefined) return "the Kubernetes API could not be reached";
|
|
48
|
+
|
|
49
|
+
const reason = statusReason(err);
|
|
50
|
+
const detail = reason ? `${reason}, HTTP ${code}` : `HTTP ${code}`;
|
|
51
|
+
const cause = refusalCause(err);
|
|
52
|
+
if (cause === "quota") {
|
|
53
|
+
return `the session namespace's resource quota is exhausted (${detail})`;
|
|
54
|
+
}
|
|
55
|
+
if (cause === "rbac") {
|
|
56
|
+
return `the runner is not permitted to do this (${detail}). The cluster operator must check the runner's RBAC.`;
|
|
57
|
+
}
|
|
58
|
+
return `the Kubernetes API rejected the request (${detail})`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Which of the two things a `403 Forbidden` means here.
|
|
63
|
+
*
|
|
64
|
+
* The status code alone cannot say: an RBAC denial and a `ResourceQuota`
|
|
65
|
+
* rejection are both `403` with `reason: Forbidden`, and this chart ships a
|
|
66
|
+
* quota (32 pods) enabled by default — so blaming RBAC on every 403 tells the
|
|
67
|
+
* operator to re-check a Role at exactly the moment the cluster is simply full.
|
|
68
|
+
* The two also differ in when they happen: an RBAC 403 is an install-time
|
|
69
|
+
* mistake, a quota 403 arrives under load.
|
|
70
|
+
*
|
|
71
|
+
* The only thing that separates them is the admission message, which is why it
|
|
72
|
+
* is READ here and never echoed: the classification crosses the boundary, the
|
|
73
|
+
* text does not. An unrecognized 403 degrades to the neutral wording rather than
|
|
74
|
+
* to a guess.
|
|
75
|
+
*/
|
|
76
|
+
function refusalCause(err: unknown): "quota" | "rbac" | undefined {
|
|
77
|
+
const code = statusCode(err);
|
|
78
|
+
if (code !== 401 && code !== 403) return undefined;
|
|
79
|
+
const message = statusMessage(err);
|
|
80
|
+
if (message && /exceeded quota|forbidden: failed quota/i.test(message)) return "quota";
|
|
81
|
+
if (!message || /\bis forbidden: User\b|\bcannot \w+ resource\b/i.test(message)) return "rbac";
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The API `Status` object, however the client-node deserializer left it: a
|
|
86
|
+
* parsed object for a status code it recognizes, the raw JSON text for one it
|
|
87
|
+
* does not (which is the 403 case, reported as "Unknown API Status Code!"). */
|
|
88
|
+
function status(err: unknown): Record<string, unknown> | undefined {
|
|
89
|
+
const body = (err as { body?: unknown })?.body;
|
|
90
|
+
const parsed = typeof body === "string" ? parseJson(body) : body;
|
|
91
|
+
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `Forbidden`, `NotFound`, `Invalid`, … — the one word safe to pass on. */
|
|
95
|
+
function statusReason(err: unknown): string | undefined {
|
|
96
|
+
const reason = status(err)?.reason;
|
|
97
|
+
return typeof reason === "string" && reason.trim() !== "" ? reason : undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Read for classification only — it names cluster identities and never leaves. */
|
|
101
|
+
function statusMessage(err: unknown): string | undefined {
|
|
102
|
+
const message = status(err)?.message;
|
|
103
|
+
return typeof message === "string" ? message : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseJson(text: string): unknown {
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(text);
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The HTTP status an error carries, or `undefined` when it carries none.
|
|
116
|
+
*
|
|
117
|
+
* The number check is the whole point: `code` is also where Node puts a system
|
|
118
|
+
* error's string code and where a `DOMException` puts its legacy numeric one, so
|
|
119
|
+
* an unreachable apiserver and an aborted request both arrive here looking like
|
|
120
|
+
* a status. Only a value in the HTTP range is one.
|
|
121
|
+
*/
|
|
122
|
+
export function statusCode(err: unknown): number | undefined {
|
|
123
|
+
const e = err as { statusCode?: unknown; code?: unknown; response?: { statusCode?: unknown } };
|
|
124
|
+
for (const candidate of [e?.statusCode, e?.code, e?.response?.statusCode]) {
|
|
125
|
+
if (typeof candidate === "number" && candidate >= 100 && candidate <= 599) return candidate;
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|