@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.
Files changed (118) hide show
  1. package/README.md +166 -107
  2. package/dist/bundle-store.d.ts +6 -13
  3. package/dist/bundle-store.d.ts.map +1 -1
  4. package/dist/bundle-store.js +6 -18
  5. package/dist/bundle-store.js.map +1 -1
  6. package/dist/capabilities.d.ts +3 -3
  7. package/dist/capabilities.js +4 -4
  8. package/dist/capabilities.js.map +1 -1
  9. package/dist/config.d.ts +47 -37
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +64 -25
  12. package/dist/config.js.map +1 -1
  13. package/dist/k8s/api-error.d.ts +34 -0
  14. package/dist/k8s/api-error.d.ts.map +1 -0
  15. package/dist/k8s/api-error.js +118 -0
  16. package/dist/k8s/api-error.js.map +1 -0
  17. package/dist/k8s/backend.d.ts +4 -0
  18. package/dist/k8s/backend.d.ts.map +1 -1
  19. package/dist/k8s/backend.js +77 -51
  20. package/dist/k8s/backend.js.map +1 -1
  21. package/dist/k8s/client.d.ts +7 -2
  22. package/dist/k8s/client.d.ts.map +1 -1
  23. package/dist/k8s/client.js +3 -2
  24. package/dist/k8s/client.js.map +1 -1
  25. package/dist/k8s/pod-spec.d.ts +8 -9
  26. package/dist/k8s/pod-spec.d.ts.map +1 -1
  27. package/dist/k8s/pod-spec.js +19 -29
  28. package/dist/k8s/pod-spec.js.map +1 -1
  29. package/dist/k8s/pod-status.d.ts +5 -0
  30. package/dist/k8s/pod-status.d.ts.map +1 -1
  31. package/dist/k8s/pod-status.js +9 -2
  32. package/dist/k8s/pod-status.js.map +1 -1
  33. package/dist/k8s/routing/create-or-replace.d.ts +5 -0
  34. package/dist/k8s/routing/create-or-replace.d.ts.map +1 -0
  35. package/dist/k8s/routing/create-or-replace.js +15 -0
  36. package/dist/k8s/routing/create-or-replace.js.map +1 -0
  37. package/dist/k8s/routing/gateway-router.d.ts +5 -0
  38. package/dist/k8s/routing/gateway-router.d.ts.map +1 -0
  39. package/dist/k8s/routing/gateway-router.js +178 -0
  40. package/dist/k8s/routing/gateway-router.js.map +1 -0
  41. package/dist/k8s/routing/index.d.ts +24 -0
  42. package/dist/k8s/routing/index.d.ts.map +1 -0
  43. package/dist/k8s/routing/index.js +27 -0
  44. package/dist/k8s/routing/index.js.map +1 -0
  45. package/dist/k8s/routing/ingress-router.d.ts +13 -0
  46. package/dist/k8s/routing/ingress-router.d.ts.map +1 -0
  47. package/dist/k8s/routing/ingress-router.js +125 -0
  48. package/dist/k8s/routing/ingress-router.js.map +1 -0
  49. package/dist/k8s/routing/route-health.d.ts +34 -0
  50. package/dist/k8s/routing/route-health.d.ts.map +1 -0
  51. package/dist/k8s/routing/route-health.js +77 -0
  52. package/dist/k8s/routing/route-health.js.map +1 -0
  53. package/dist/k8s/routing/routing-mode.d.ts +45 -0
  54. package/dist/k8s/routing/routing-mode.d.ts.map +1 -0
  55. package/dist/k8s/routing/routing-mode.js +141 -0
  56. package/dist/k8s/routing/routing-mode.js.map +1 -0
  57. package/dist/k8s/routing/session-endpoints.d.ts +25 -0
  58. package/dist/k8s/routing/session-endpoints.d.ts.map +1 -0
  59. package/dist/k8s/routing/session-endpoints.js +36 -0
  60. package/dist/k8s/routing/session-endpoints.js.map +1 -0
  61. package/dist/k8s/routing/session-router.d.ts +57 -0
  62. package/dist/k8s/routing/session-router.d.ts.map +1 -0
  63. package/dist/k8s/routing/session-router.js +2 -0
  64. package/dist/k8s/routing/session-router.js.map +1 -0
  65. package/dist/k8s/routing/session-service.d.ts +14 -0
  66. package/dist/k8s/routing/session-service.d.ts.map +1 -0
  67. package/dist/k8s/routing/session-service.js +42 -0
  68. package/dist/k8s/routing/session-service.js.map +1 -0
  69. package/dist/k8s/watch-session.d.ts +7 -5
  70. package/dist/k8s/watch-session.d.ts.map +1 -1
  71. package/dist/k8s/watch-session.js +90 -83
  72. package/dist/k8s/watch-session.js.map +1 -1
  73. package/dist/k8s/workspace-configmap.d.ts.map +1 -1
  74. package/dist/k8s/workspace-configmap.js +3 -12
  75. package/dist/k8s/workspace-configmap.js.map +1 -1
  76. package/dist/server.d.ts.map +1 -1
  77. package/dist/server.js +37 -4
  78. package/dist/server.js.map +1 -1
  79. package/package.json +2 -2
  80. package/src/bundle-store.ts +6 -19
  81. package/src/capabilities.ts +4 -4
  82. package/src/config.ts +130 -68
  83. package/src/k8s/api-error.test.ts +89 -0
  84. package/src/k8s/api-error.ts +128 -0
  85. package/src/k8s/backend.ts +103 -58
  86. package/src/k8s/client.ts +10 -3
  87. package/src/k8s/pod-spec.test.ts +57 -2
  88. package/src/k8s/pod-spec.ts +25 -36
  89. package/src/k8s/pod-status.ts +10 -2
  90. package/src/k8s/routing/create-or-replace.ts +16 -0
  91. package/src/k8s/routing/gateway-router.test.ts +236 -0
  92. package/src/k8s/routing/gateway-router.ts +219 -0
  93. package/src/k8s/routing/index.ts +44 -0
  94. package/src/k8s/routing/ingress-router.test.ts +236 -0
  95. package/src/k8s/routing/ingress-router.ts +157 -0
  96. package/src/k8s/routing/route-health.test.ts +215 -0
  97. package/src/k8s/routing/route-health.ts +95 -0
  98. package/src/k8s/routing/routing-mode.test.ts +218 -0
  99. package/src/k8s/routing/routing-mode.ts +191 -0
  100. package/src/k8s/routing/session-endpoints.ts +45 -0
  101. package/src/k8s/routing/session-router.ts +59 -0
  102. package/src/k8s/routing/session-service.ts +55 -0
  103. package/src/k8s/watch-pod-spec.test.ts +0 -1
  104. package/src/k8s/watch-session.ts +90 -77
  105. package/src/k8s/workspace-configmap.ts +3 -15
  106. package/src/server.ts +41 -4
  107. package/dist/k8s/image-build.d.ts +0 -103
  108. package/dist/k8s/image-build.d.ts.map +0 -1
  109. package/dist/k8s/image-build.js +0 -427
  110. package/dist/k8s/image-build.js.map +0 -1
  111. package/dist/k8s/ingress.d.ts +0 -15
  112. package/dist/k8s/ingress.d.ts.map +0 -1
  113. package/dist/k8s/ingress.js +0 -102
  114. package/dist/k8s/ingress.js.map +0 -1
  115. package/src/k8s/image-build.test.ts +0 -238
  116. package/src/k8s/image-build.ts +0 -540
  117. package/src/k8s/ingress.test.ts +0 -146
  118. 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
- * On-cluster image-build settings. The runner ALWAYS prebuilds a self-contained
50
- * per-app image (controllers + module manifests baked in via `telo install`) and
51
- * the session pod runs it directly — there is no in-pod install fallback, so a
52
- * registry repository (`RUNNER_IMAGE_REPOSITORY`) is required.
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 interface ImageBuildConfig {
55
- /** Registry repository the per-app image is pushed to / pulled from; the tag
56
- * is the bundle content hash (e.g. `registry.telo-runner.svc:5000/telo-sessions`). */
57
- repository: string;
58
- /** Namespace the trusted Kaniko build Jobs run in (registry egress lives here). */
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
- /** Builder image Kaniko (or a compatible `--context`/`--destination` executor). */
61
- builderImage: string;
62
- /** Build Job `activeDeadlineSeconds` and the runner's wait budget. */
63
- timeoutSeconds: number;
64
- /** Push/pull to an insecure (HTTP / self-signed) registry — the in-cluster default. */
65
- insecureRegistry: boolean;
66
- /** HTTP(S) base for a best-effort manifest existence check (skip build on hit);
67
- * undefinedalways build. */
68
- registryApiUrl?: string;
69
- /** Optional dockerconfig Secret (in the build namespace) Kaniko pushes with. */
70
- pushSecretName?: string;
71
- /** Optional dockerconfig Secret (in the session namespace) the kubelet uses to
72
- * pull per-app images from a private registry. */
73
- imagePullSecret?: string;
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/Ingresses are created. */
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/build-context fetch initContainer (needs wget + tar). */
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
- /** Wildcard base domain for per-session ingress; unset → logs-only. */
92
- sessionIngressBaseDomain?: string;
93
- /** Optional IngressClass name for created session Ingresses. */
94
- sessionIngressClassName?: string;
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 repository = env.RUNNER_IMAGE_REPOSITORY?.trim();
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: env.RUNNER_SESSION_NAMESPACE?.trim() || "telo-sessions",
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
- sessionIngressBaseDomain: env.SESSION_INGRESS_BASE_DOMAIN?.trim() || undefined,
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() || "50m",
206
- memory: env.RUNNER_MAX_MEMORY?.trim() || "100Mi",
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() || "512Mi",
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
+ }