@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 +17 -1
- package/dist/{chunk-y529s0mz.js → chunk-8kts470p.js} +7 -1
- package/dist/chunk-shsabpc1.js +267 -0
- package/dist/kubernetes/client.js +2 -1
- package/dist/kubernetes/environment.js +2 -1
- package/dist/kubernetes/exec-transport.d.ts +19 -0
- package/dist/kubernetes/exec-transport.js +8 -0
- package/package.json +5 -6
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.
|
|
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.#
|
|
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-
|
|
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-
|
|
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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compforge/harness-toolbox",
|
|
3
|
-
"version": "0.4.
|
|
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"
|