@compforge/harness-toolbox 0.4.0 → 0.4.1

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 CHANGED
@@ -103,7 +103,23 @@ selectorless Services and failed access raise KubernetesError.
103
103
 
104
104
  The resource path uses client-node kubeconfig authentication/TLS and bounded native JSON reads,
105
105
  not kubectl error text. Configure request timeout, concurrency and response bytes with the
106
- client's ResourceLimits constructor argument. Existing exec/port-forward operations remain unchanged.
106
+ client's ResourceLimits constructor argument. Native exec uses the same root capacity/byte policy
107
+ with a separate slot pool; its per-call timeout defaults to that policy and covers queueing,
108
+ authentication, upgrade and execution. Explicit per-call timeouts support longer commands.
109
+
110
+ Exec requires `get pods/exec` permission for the WebSocket upgrade (kubectl/SPDY paths may use
111
+ `create pods/exec`). It connects directly to the Pod exec WebSocket endpoint without a preliminary Pod GET or
112
+ kubectl subprocess. Each command owns a stream session; failures are never retried or replayed.
113
+ Kubeconfig controls authentication, TLS and proxy access. stdout/stderr and remote exit status
114
+ remain separate; retained output and individual messages are bounded. Streaming callers may
115
+ set `collectStdout: false` and enforce their own total byte budget. Root disposal cancels active
116
+ and queued sessions. Protocol v4 is accepted for output-only commands; stdin requires v5 to
117
+ close input without losing remaining output/status. Bun does not expose HTTP upgrade rejection
118
+ status, so those failures retain an unknown connection error. Native exec with a kubeconfig
119
+ proxy requires Node: Bun cannot reliably release proxy sockets or preserve TLS name overrides.
120
+ It fails before opening a session on Bun; use Node or explicitly provide an Executor adapter.
121
+ The transport never bypasses the proxy or TLS checks.
122
+ Port-forward still uses kubectl.
107
123
  See [the shared Workload model](../../../docs/workload.md).
108
124
 
109
125
  ```ts
@@ -7,6 +7,9 @@ import {
7
7
  import {
8
8
  ServicePortForwarder
9
9
  } from "./chunk-2171tajs.js";
10
+ import {
11
+ KubernetesExecTransport
12
+ } from "./chunk-shsabpc1.js";
10
13
  import {
11
14
  KubernetesError
12
15
  } from "./chunk-20kv4wc3.js";
@@ -24,11 +27,14 @@ class KubernetesClient {
24
27
  #forwarders = new Map;
25
28
  #operations = new Set;
26
29
  #disposal;
30
+ #exec;
31
+ #injectedExecutor;
27
32
  #resources;
28
33
  #transport;
29
34
  constructor(kube, signal, executor, resourceLimits = { timeoutMs: 30000, concurrency: 4, maxBytes: 8 * 1024 * 1024 }, resources) {
30
35
  this.kube = kube;
31
36
  this.resourceLimits = resourceLimits;
37
+ this.#injectedExecutor = executor;
32
38
  this.signal = signal ? AbortSignal.any([signal, this.#controller.signal]) : this.#controller.signal;
33
39
  if (executor)
34
40
  this.#executors.set(kube.namespace, executor);
@@ -86,7 +92,7 @@ class KubernetesClient {
86
92
  };
87
93
  }
88
94
  exec(namespace, target, command, options) {
89
- return this.#track(() => this.#executor(namespace).exec(target, command, this.#options(options)));
95
+ return this.#track(() => this.#injectedExecutor && namespace === this.kube.namespace ? this.#injectedExecutor.exec(target, command, this.#options(options)) : (this.#exec ??= new KubernetesExecTransport(this.kube, this.signal, this.resourceLimits)).exec(namespace, target, command, this.#options(options)));
90
96
  }
91
97
  forward(namespace, target) {
92
98
  return this.#track(async () => {
@@ -0,0 +1,267 @@
1
+ import {
2
+ KubernetesError,
3
+ kubernetesHttpError
4
+ } from "./chunk-20kv4wc3.js";
5
+ import {
6
+ ConcurrencyPool
7
+ } from "./chunk-v3hxvnrc.js";
8
+
9
+ // src/kubernetes/exec-transport.ts
10
+ import { KubeConfig } from "@kubernetes/client-node";
11
+ import WebSocket from "ws";
12
+ class KubernetesExecTransport {
13
+ signal;
14
+ limits;
15
+ #config = new KubeConfig;
16
+ #capacity;
17
+ #server;
18
+ constructor(options, signal, limits) {
19
+ this.signal = signal;
20
+ this.limits = limits;
21
+ if (!Object.values(limits).every((value) => Number.isSafeInteger(value) && value > 0)) {
22
+ throw new KubernetesError("Exec limits must be positive integers", { kind: "invalid_argument" });
23
+ }
24
+ if (options.kubeconfig)
25
+ this.#config.loadFromFile(options.kubeconfig);
26
+ else
27
+ this.#config.loadFromDefault();
28
+ if (options.context)
29
+ this.#config.setCurrentContext(options.context);
30
+ const cluster = this.#config.getCurrentCluster();
31
+ if (!cluster)
32
+ throw new KubernetesError("Kubernetes context has no cluster", { kind: "invalid_argument" });
33
+ if (process.versions.bun && cluster.proxyUrl) {
34
+ throw new KubernetesError("Native exec through a kubeconfig proxy requires a Node host", { kind: "unsupported_operation" });
35
+ }
36
+ this.#server = cluster.server.replace(/\/$/, "");
37
+ if (new URL(cluster.server).protocol === "http:")
38
+ this.#config.clusters = this.#config.clusters.map((item) => item === cluster ? { ...item, skipTLSVerify: true } : item);
39
+ this.#capacity = new ConcurrencyPool(limits.concurrency);
40
+ }
41
+ async exec(namespace, target, command, options = {}) {
42
+ const started = performance.now();
43
+ const timeoutMs = options.timeoutMs ?? this.limits.timeoutMs;
44
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || !namespace || !target.pod || !command.length) {
45
+ throw new KubernetesError("Exec requires a namespace, Pod, command and positive timeout", { kind: "invalid_argument" });
46
+ }
47
+ const caller = options.signal ? AbortSignal.any([this.signal, options.signal]) : this.signal;
48
+ const deadline = new AbortController;
49
+ const signal = AbortSignal.any([caller, deadline.signal]);
50
+ const timer = setTimeout(() => deadline.abort(), timeoutMs);
51
+ try {
52
+ return await this.#capacity.run(() => this.#session(namespace, target, command, options, signal, started, deadline.signal), signal);
53
+ } catch (error) {
54
+ if (caller.aborted)
55
+ throw caller.reason;
56
+ if (deadline.signal.aborted)
57
+ return this.#result(namespace, target, command, started, null, "", "Exec deadline exceeded", true);
58
+ throw error;
59
+ } finally {
60
+ clearTimeout(timer);
61
+ }
62
+ }
63
+ #result(namespace, target, command, started, exitCode, stdout, stderr, timedOut = false) {
64
+ return {
65
+ ok: exitCode === 0 && !timedOut,
66
+ exitCode,
67
+ stdout,
68
+ stderr,
69
+ timedOut,
70
+ durationMs: performance.now() - started,
71
+ command: [
72
+ "kubernetes-api",
73
+ "exec",
74
+ "-n",
75
+ namespace,
76
+ target.pod,
77
+ ...target.container ? ["-c", target.container] : [],
78
+ "--",
79
+ ...command
80
+ ]
81
+ };
82
+ }
83
+ #session(namespace, target, command, options, signal, started, deadline) {
84
+ return new Promise((resolve, reject) => {
85
+ let socket;
86
+ let agent;
87
+ let input;
88
+ let settled = false;
89
+ let stdout = "", stderr = "", retained = 0;
90
+ const outDecoder = new TextDecoder, errDecoder = new TextDecoder;
91
+ const finish = (exitCode, error) => {
92
+ if (settled)
93
+ return;
94
+ settled = true;
95
+ signal.removeEventListener("abort", abort);
96
+ socket?.terminate();
97
+ agent?.destroy();
98
+ input?.cancel().catch(() => {});
99
+ const tail = outDecoder.decode(), errTail = errDecoder.decode();
100
+ if (options.collectStdout !== false)
101
+ stdout += tail;
102
+ stderr += errTail;
103
+ try {
104
+ if (tail)
105
+ options.onStdout?.(tail);
106
+ if (errTail)
107
+ options.onStderr?.(errTail);
108
+ } catch (cause) {
109
+ error ??= cause instanceof Error ? cause : new Error("Exec output callback failed");
110
+ }
111
+ if (error)
112
+ reject(error);
113
+ else
114
+ resolve(this.#result(namespace, target, command, started, exitCode, stdout, stderr, deadline.aborted));
115
+ };
116
+ const abort = () => {
117
+ if (deadline.aborted) {
118
+ stderr += `
119
+ Exec deadline exceeded`;
120
+ finish(null);
121
+ } else
122
+ finish(null, signal.reason instanceof Error ? signal.reason : new Error("Exec aborted"));
123
+ };
124
+ const fail = (kind, cause) => finish(null, new KubernetesError(`Kubernetes exec ${namespace}/${target.pod} failed`, { kind, cause }));
125
+ if (signal.aborted) {
126
+ abort();
127
+ return;
128
+ }
129
+ signal.addEventListener("abort", abort, { once: true });
130
+ const request = { headers: {} };
131
+ this.#config.applyToHTTPSOptions(request).then(() => {
132
+ agent = request.agent;
133
+ if (settled || signal.aborted) {
134
+ agent?.destroy();
135
+ return;
136
+ }
137
+ const url = new URL(`${this.#server}/api/v1/namespaces/${encodeURIComponent(namespace)}/pods/${encodeURIComponent(target.pod)}/exec`);
138
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
139
+ for (const arg of command)
140
+ url.searchParams.append("command", arg);
141
+ url.searchParams.set("stdout", "true");
142
+ url.searchParams.set("stderr", "true");
143
+ url.searchParams.set("stdin", String(options.stdin !== undefined));
144
+ url.searchParams.set("tty", "false");
145
+ if (target.container)
146
+ url.searchParams.set("container", target.container);
147
+ socket = new WebSocket(url, options.stdin === undefined ? ["v5.channel.k8s.io", "v4.channel.k8s.io"] : ["v5.channel.k8s.io"], { ...request, ...process.versions.bun ? { tls: {
148
+ ca: request.ca,
149
+ cert: request.cert,
150
+ key: request.key,
151
+ pfx: request.pfx,
152
+ passphrase: request.passphrase,
153
+ rejectUnauthorized: request.rejectUnauthorized,
154
+ serverName: request.servername
155
+ } } : {}, maxPayload: this.limits.maxBytes, handshakeTimeout: this.limits.timeoutMs });
156
+ if (!process.versions.bun)
157
+ socket.on("unexpected-response", (request2, response) => {
158
+ const status = response.statusCode ?? 500;
159
+ response.destroy();
160
+ request2.destroy();
161
+ const failure = kubernetesHttpError(status);
162
+ finish(null, new KubernetesError(`Kubernetes exec ${namespace}/${target.pod} HTTP ${status}`, {
163
+ kind: failure.kind,
164
+ code: status
165
+ }));
166
+ });
167
+ socket.on("error", (error) => fail("connection_failed", error));
168
+ socket.on("close", () => {
169
+ if (!settled)
170
+ fail("connection_lost");
171
+ });
172
+ socket.on("message", (data, binary) => {
173
+ if (settled)
174
+ return;
175
+ try {
176
+ if (!binary)
177
+ throw new Error("Exec frame must be binary");
178
+ const frame = Buffer.isBuffer(data) ? data : Array.isArray(data) ? Buffer.concat(data) : Buffer.from(data);
179
+ if (!frame.length)
180
+ throw new Error("Empty exec frame");
181
+ if (frame.length > this.limits.maxBytes) {
182
+ fail("limit_exceeded");
183
+ return;
184
+ }
185
+ const channel = frame[0], payload = frame.subarray(1);
186
+ if (channel === 255) {
187
+ if (payload.length !== 1 || ![0, 1, 2].includes(payload[0]))
188
+ throw new Error("Invalid channel close");
189
+ return;
190
+ }
191
+ if (channel === 3) {
192
+ const status = JSON.parse(payload.toString("utf8"));
193
+ if (status.status === "Success") {
194
+ finish(0);
195
+ return;
196
+ }
197
+ const code = status.details?.causes?.find((cause) => cause.reason === "ExitCode")?.message;
198
+ if (status.status !== "Failure" || !/^\d+$/.test(code ?? ""))
199
+ throw new Error("Exec status lacks exit code");
200
+ if (!Number.isSafeInteger(Number(code)))
201
+ throw new Error("Invalid exit code");
202
+ finish(Number(code));
203
+ return;
204
+ }
205
+ if (channel !== 1 && channel !== 2)
206
+ throw new Error("Invalid exec channel");
207
+ if (channel === 2 || options.collectStdout !== false)
208
+ retained += payload.length;
209
+ if (retained > this.limits.maxBytes) {
210
+ fail("limit_exceeded");
211
+ return;
212
+ }
213
+ if (channel === 1) {
214
+ options.onStdoutBytes?.(payload);
215
+ const text = outDecoder.decode(payload, { stream: true });
216
+ if (options.collectStdout !== false)
217
+ stdout += text;
218
+ options.onStdout?.(text);
219
+ } else {
220
+ const text = errDecoder.decode(payload, { stream: true });
221
+ stderr += text;
222
+ options.onStderr?.(text);
223
+ }
224
+ } catch (error) {
225
+ fail("invalid_response", error);
226
+ }
227
+ });
228
+ socket.on("open", () => {
229
+ if (options.stdin === undefined || settled)
230
+ return;
231
+ const send = (bytes) => new Promise((done, failed) => {
232
+ if (settled) {
233
+ failed(new Error("Exec closed"));
234
+ return;
235
+ }
236
+ socket.send(Buffer.concat([Buffer.from([0]), bytes]), (error) => error ? failed(error) : done());
237
+ });
238
+ (async () => {
239
+ const stdin = options.stdin;
240
+ if (typeof stdin === "string" || stdin instanceof Uint8Array)
241
+ await send(typeof stdin === "string" ? new TextEncoder().encode(stdin) : stdin);
242
+ else {
243
+ const reader = stdin.getReader();
244
+ input = reader;
245
+ while (!settled) {
246
+ const chunk = await reader.read();
247
+ if (chunk.done)
248
+ break;
249
+ await send(chunk.value);
250
+ }
251
+ }
252
+ if (!settled)
253
+ socket.send(Buffer.from([255, 0]));
254
+ })().catch((error) => {
255
+ if (!settled)
256
+ fail("connection_failed", error);
257
+ });
258
+ });
259
+ }).catch((error) => {
260
+ if (!settled)
261
+ fail("connection_failed", error);
262
+ });
263
+ });
264
+ }
265
+ }
266
+
267
+ export { KubernetesExecTransport };
@@ -1,12 +1,13 @@
1
1
  import {
2
2
  KubernetesClient
3
- } from "../chunk-y529s0mz.js";
3
+ } from "../chunk-8kts470p.js";
4
4
  import"../chunk-4ms037k2.js";
5
5
  import"../chunk-0s27bgga.js";
6
6
  import"../chunk-2171tajs.js";
7
7
  import"../chunk-j46x7j7c.js";
8
8
  import"../chunk-6fnhzhdb.js";
9
9
  import"../chunk-ftkrjqkc.js";
10
+ import"../chunk-shsabpc1.js";
10
11
  import"../chunk-a9htb6cr.js";
11
12
  import"../chunk-20kv4wc3.js";
12
13
  import"../chunk-v3hxvnrc.js";
@@ -1,12 +1,13 @@
1
1
  import {
2
2
  KubernetesClient
3
- } from "../chunk-y529s0mz.js";
3
+ } from "../chunk-8kts470p.js";
4
4
  import"../chunk-4ms037k2.js";
5
5
  import"../chunk-0s27bgga.js";
6
6
  import"../chunk-2171tajs.js";
7
7
  import"../chunk-j46x7j7c.js";
8
8
  import"../chunk-6fnhzhdb.js";
9
9
  import"../chunk-ftkrjqkc.js";
10
+ import"../chunk-shsabpc1.js";
10
11
  import {
11
12
  clientKey
12
13
  } from "../chunk-a9htb6cr.js";
@@ -0,0 +1,19 @@
1
+ import type { ExecTarget, ExecResult, RunOptions } from "./executor";
2
+ import type { KubernetesConnectionOptions } from "./resource-transport";
3
+ export interface KubernetesExecLimits {
4
+ timeoutMs: number;
5
+ concurrency: number;
6
+ maxBytes: number;
7
+ }
8
+ /**
9
+ * @spec Each exec owns one channel session; it is never retried or replayed.
10
+ * @rule The root owns configuration and capacity. Deadlines cover queueing, auth, upgrade and execution.
11
+ * @why A WebSocket upgrade cannot reuse an ordinary HTTP request connection as another exec session.
12
+ */
13
+ export declare class KubernetesExecTransport {
14
+ #private;
15
+ private readonly signal;
16
+ private readonly limits;
17
+ constructor(options: KubernetesConnectionOptions, signal: AbortSignal, limits: KubernetesExecLimits);
18
+ exec(namespace: string, target: ExecTarget, command: string[], options?: RunOptions): Promise<ExecResult>;
19
+ }
@@ -0,0 +1,8 @@
1
+ import {
2
+ KubernetesExecTransport
3
+ } from "../chunk-shsabpc1.js";
4
+ import"../chunk-20kv4wc3.js";
5
+ import"../chunk-v3hxvnrc.js";
6
+ export {
7
+ KubernetesExecTransport
8
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compforge/harness-toolbox",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Shared infrastructure clients, transports and bounded data capture",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -106,16 +106,15 @@
106
106
  "@redis/client": "6.1.0",
107
107
  "@smithy/node-http-handler": "4.12.1",
108
108
  "mysql2": "3.23.1",
109
- "node-fetch": "2.7.0"
109
+ "node-fetch": "2.7.0",
110
+ "ws": "8.21.3"
110
111
  },
111
112
  "devDependencies": {
112
113
  "@types/bun": "latest",
113
114
  "typescript": "^5.6.0",
114
115
  "@types/node-fetch": "2.6.13",
115
- "@types/node": "^22"
116
- },
117
- "overrides": {
118
- "@compforge/harness-common": "file:../common"
116
+ "@types/node": "^22",
117
+ "@types/ws": "^8.18.1"
119
118
  },
120
119
  "publishConfig": {
121
120
  "access": "public"