@compforge/harness-toolbox 0.3.11 → 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
@@ -58,6 +58,40 @@ a UID fence; callers needing incarnation guarantees must verify discovery around
58
58
  sources borrow Kubernetes access from the root manager and never close it themselves. Unsupported
59
59
  commands, missing paths and denied reads fail explicitly; they are not empty file results.
60
60
 
61
+ ## Kubernetes resources
62
+
63
+ Borrow a root `KubernetesClient` through `KubernetesEnvironment` and `ClientManager`. Use
64
+ `get(ref)` and `list(query)` for resource reads; `inNamespace(namespace)` binds a borrowed view
65
+ to a namespace without creating a second client or pool:
66
+
67
+ ```ts
68
+ const kube = await clients.get(environment);
69
+ const ns = kube.inNamespace("app");
70
+ const service = await ns.get({ apiVersion: "v1", resource: "services", name: "api" });
71
+ const pods = await ns.list({ apiVersion: "v1", resource: "pods", labelSelector: "app=api" },
72
+ { timeoutMs: 15_000, signal });
73
+ ```
74
+
75
+ `resource` is the plural REST resource name, not a Kind or kubectl alias. Explicit API versions
76
+ support built-in resources and custom resources without a discovery request or local allowlist.
77
+ The root uses its configured namespace unless the query overrides it; namespace views enforce
78
+ their bound namespace. Resource objects and list metadata are returned intact. List requests
79
+ accept label/field selectors and explicit `limit`/`continue` pages; they do not silently read all pages.
80
+ Errors are `KubernetesError` objects carrying a stable kind and the HTTP status when available.
81
+ Callers retain the query with their evidence; resource operations do not produce shell command results.
82
+
83
+ The root owns one HTTP transport with keep-alive connections shared across namespaces and resource
84
+ kinds. Authentication refreshes on each request. TLS/proxy identity changes select a new pool and
85
+ retire the previous one after its active reads finish. Requests have bounded bytes and a deadline
86
+ covering queueing, authentication and body consumption. `dispose()` cancels work, waits for tracked
87
+ operations, and closes connections. Views and injected borrowed resource readers have no ownership
88
+ of that lifecycle. No query results are cached and failures are never replayed through another transport.
89
+
90
+ Native resource access uses kubeconfig authentication, TLS and explicit `proxy-url`. It does not
91
+ select transports based on shell proxy environment variables. `exec` and `forward` are separate
92
+ streaming operations; their implementation does not intercept resource reads. `KubernetesClient`
93
+ has no `run(argv)` method. Concurrency remains caller-configured rather than inferred from pool size.
94
+
61
95
  ## Workload discovery
62
96
 
63
97
  Borrow a KubernetesClient through KubernetesEnvironment and the shared ClientManager lifecycle, then call
@@ -69,7 +103,23 @@ selectorless Services and failed access raise KubernetesError.
69
103
 
70
104
  The resource path uses client-node kubeconfig authentication/TLS and bounded native JSON reads,
71
105
  not kubectl error text. Configure request timeout, concurrency and response bytes with the
72
- 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.
73
123
  See [the shared Workload model](../../../docs/workload.md).
74
124
 
75
125
  ```ts
@@ -48,7 +48,7 @@ async function resolveWorkload(access, workload, namespace, environment) {
48
48
  if (!Object.keys(location.labels).length)
49
49
  return failure("Pod labels must be nonempty", "invalid_argument");
50
50
  selected = selector(location.labels, true);
51
- const result = await access.get(namespace, "pods", undefined, selected);
51
+ const result = await access.list({ namespace, apiVersion: "v1", resource: "pods", labelSelector: selected });
52
52
  if (!Array.isArray(result.items))
53
53
  return failure("Pod list lacks items", "invalid_response");
54
54
  pods = result.items;
@@ -63,12 +63,17 @@ async function resolveWorkload(access, workload, namespace, environment) {
63
63
  return failure("Unsupported workload resource", "unsupported_operation");
64
64
  if (!location.name.trim())
65
65
  return failure("Resource name is required", "invalid_argument");
66
- const item = await access.get(namespace, resource, location.name);
66
+ const item = await access.get({
67
+ namespace,
68
+ resource,
69
+ name: location.name,
70
+ apiVersion: resource === "pods" || resource === "services" ? "v1" : "apps/v1"
71
+ });
67
72
  if (resource === "pods")
68
73
  pods = [item];
69
74
  else {
70
75
  selected = selector(item.spec?.selector, resource === "services");
71
- const result = await access.get(namespace, "pods", undefined, selected);
76
+ const result = await access.list({ namespace, apiVersion: "v1", resource: "pods", labelSelector: selected });
72
77
  if (!Array.isArray(result.items))
73
78
  return failure("Pod list lacks items", "invalid_response");
74
79
  pods = result.items;
@@ -0,0 +1,210 @@
1
+ import {
2
+ resourcePath
3
+ } from "./chunk-ftkrjqkc.js";
4
+ import {
5
+ clientKey
6
+ } from "./chunk-a9htb6cr.js";
7
+ import {
8
+ KubernetesError,
9
+ ToolboxError,
10
+ kubernetesHttpError
11
+ } from "./chunk-20kv4wc3.js";
12
+ import {
13
+ ConcurrencyPool
14
+ } from "./chunk-v3hxvnrc.js";
15
+
16
+ // src/kubernetes/resource-transport.ts
17
+ import { KubeConfig } from "@kubernetes/client-node";
18
+ import fetch from "node-fetch/lib/index.js";
19
+ import { Headers } from "node-fetch";
20
+ class KubernetesResourceTransport {
21
+ limits;
22
+ #config = new KubeConfig;
23
+ #controller = new AbortController;
24
+ #pool;
25
+ #agents = new Set;
26
+ #server;
27
+ #signal;
28
+ #current;
29
+ constructor(options, signal, limits) {
30
+ this.limits = limits;
31
+ if (![limits.timeoutMs, limits.concurrency, limits.maxBytes].every((value) => Number.isSafeInteger(value) && value > 0)) {
32
+ throw new KubernetesError("Resource limits must be positive integers", { kind: "invalid_argument" });
33
+ }
34
+ try {
35
+ if (options.kubeconfig)
36
+ this.#config.loadFromFile(options.kubeconfig);
37
+ else
38
+ this.#config.loadFromDefault();
39
+ if (options.context)
40
+ this.#config.setCurrentContext(options.context);
41
+ } catch (cause) {
42
+ throw new KubernetesError("Cannot load Kubernetes access configuration", { kind: "connection_failed", cause });
43
+ }
44
+ const cluster = this.#config.getCurrentCluster();
45
+ if (!cluster)
46
+ throw new KubernetesError("Kubernetes context has no cluster", { kind: "invalid_argument" });
47
+ this.#server = cluster.server.replace(/\/$/, "");
48
+ if (new URL(cluster.server).protocol === "http:") {
49
+ this.#config.clusters = this.#config.clusters.map((item) => item === cluster ? { ...item, skipTLSVerify: true } : item);
50
+ }
51
+ this.#signal = AbortSignal.any([signal, this.#controller.signal]);
52
+ this.#pool = new ConcurrencyPool(limits.concurrency);
53
+ }
54
+ async get(ref, options) {
55
+ if (!ref.name)
56
+ throw new KubernetesError("Resource name is required", { kind: "invalid_argument" });
57
+ return this.#read(resourcePath(ref, ref.name), undefined, options);
58
+ }
59
+ async list(query, options) {
60
+ const params = new URLSearchParams;
61
+ if (query.labelSelector)
62
+ params.set("labelSelector", query.labelSelector);
63
+ if (query.fieldSelector)
64
+ params.set("fieldSelector", query.fieldSelector);
65
+ if (query.continue)
66
+ params.set("continue", query.continue);
67
+ if (query.limit !== undefined) {
68
+ if (!Number.isSafeInteger(query.limit) || query.limit <= 0) {
69
+ throw new KubernetesError("Resource page limit must be a positive integer", { kind: "invalid_argument" });
70
+ }
71
+ params.set("limit", String(query.limit));
72
+ }
73
+ const value = await this.#read(resourcePath(query), params, options);
74
+ if (!Array.isArray(value.items) || value.items.some((item) => !isObject(item))) {
75
+ throw new KubernetesError("Resource list lacks object items", { kind: "invalid_response" });
76
+ }
77
+ return value;
78
+ }
79
+ dispose() {
80
+ this.#controller.abort(new Error("Kubernetes resource transport disposed"));
81
+ for (const entry of this.#agents)
82
+ entry.agent.destroy();
83
+ this.#agents.clear();
84
+ this.#current = undefined;
85
+ }
86
+ async#read(path, params, options) {
87
+ const timeoutMs = options?.timeoutMs ?? this.limits.timeoutMs;
88
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
89
+ throw new KubernetesError("Resource timeout must be a positive integer", { kind: "invalid_argument" });
90
+ }
91
+ const caller = options?.signal ? AbortSignal.any([this.#signal, options.signal]) : this.#signal;
92
+ const deadline = new AbortController;
93
+ const signal = AbortSignal.any([caller, deadline.signal]);
94
+ const timer = setTimeout(() => deadline.abort(), Math.min(timeoutMs, this.limits.timeoutMs));
95
+ try {
96
+ return await this.#pool.run(async () => {
97
+ const request = await this.#authenticate(signal);
98
+ if (signal.aborted) {
99
+ request.agent.destroy();
100
+ signal.throwIfAborted();
101
+ }
102
+ const entry = this.#acquireAgent(request);
103
+ try {
104
+ const url = new URL(this.#server + path);
105
+ if (params)
106
+ url.search = params.toString();
107
+ const headers = new Headers;
108
+ for (const [name, value2] of Object.entries(request.headers ?? {})) {
109
+ for (const entry2 of Array.isArray(value2) ? value2 : [value2]) {
110
+ if (entry2 !== undefined)
111
+ headers.append(name, String(entry2));
112
+ }
113
+ }
114
+ if (request.auth)
115
+ headers.set("Authorization", "Basic " + Buffer.from(request.auth).toString("base64"));
116
+ const response = await fetch(url.href, { agent: entry.agent, headers, signal, size: this.limits.maxBytes });
117
+ if (!response.ok) {
118
+ response.body.destroy();
119
+ const error = kubernetesHttpError(response.status);
120
+ throw new KubernetesError("Read Kubernetes resource " + path + " failed", { kind: error.kind, code: error.code });
121
+ }
122
+ const value = await response.json();
123
+ signal.throwIfAborted();
124
+ if (!isObject(value))
125
+ throw new KubernetesError("Resource response is not an object", { kind: "invalid_response" });
126
+ return value;
127
+ } finally {
128
+ entry.users--;
129
+ this.#retire(entry);
130
+ }
131
+ }, signal);
132
+ } catch (cause) {
133
+ if (caller.aborted)
134
+ throw caller.reason;
135
+ if (cause instanceof ToolboxError)
136
+ throw cause;
137
+ const type = cause && typeof cause === "object" && "type" in cause ? cause.type : undefined;
138
+ const code = cause && typeof cause === "object" && "code" in cause && typeof cause.code === "string" ? cause.code : undefined;
139
+ const tls = code !== undefined && [
140
+ "CERT_HAS_EXPIRED",
141
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
142
+ "SELF_SIGNED_CERT_IN_CHAIN",
143
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
144
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
145
+ "ERR_TLS_CERT_ALTNAME_INVALID"
146
+ ].includes(code);
147
+ throw new KubernetesError("Read Kubernetes resource " + path + " failed", {
148
+ kind: deadline.signal.aborted ? "timeout" : type === "max-size" ? "limit_exceeded" : type === "invalid-json" ? "invalid_response" : tls ? "tls_verification_failed" : code === "ECONNRESET" ? "connection_lost" : "connection_failed",
149
+ cause,
150
+ code
151
+ });
152
+ } finally {
153
+ clearTimeout(timer);
154
+ }
155
+ }
156
+ #authenticate(signal) {
157
+ return new Promise((resolve, reject) => {
158
+ if (signal.aborted) {
159
+ reject(signal.reason);
160
+ return;
161
+ }
162
+ const abort = () => reject(signal.reason);
163
+ signal.addEventListener("abort", abort, { once: true });
164
+ const options = { headers: {} };
165
+ this.#config.applyToHTTPSOptions(options).then(() => {
166
+ signal.removeEventListener("abort", abort);
167
+ if (signal.aborted) {
168
+ options.agent.destroy();
169
+ reject(signal.reason);
170
+ } else
171
+ resolve(options);
172
+ }, (error) => {
173
+ signal.removeEventListener("abort", abort);
174
+ reject(error);
175
+ });
176
+ });
177
+ }
178
+ #acquireAgent(options) {
179
+ const fresh = options.agent;
180
+ const cluster = this.#config.getCurrentCluster();
181
+ const tls = { ...options, agent: undefined, headers: undefined, auth: undefined };
182
+ const key = clientKey("kubernetes-agent", { server: this.#server, proxy: cluster?.proxyUrl, tls });
183
+ if (this.#current?.key === key) {
184
+ fresh.destroy();
185
+ } else {
186
+ fresh.keepAlive = true;
187
+ fresh.maxSockets = this.limits.concurrency;
188
+ fresh.maxTotalSockets = this.limits.concurrency;
189
+ fresh.maxFreeSockets = this.limits.concurrency;
190
+ const previous = this.#current;
191
+ this.#current = { key, agent: fresh, users: 0 };
192
+ this.#agents.add(this.#current);
193
+ if (previous)
194
+ this.#retire(previous);
195
+ }
196
+ this.#current.users++;
197
+ return this.#current;
198
+ }
199
+ #retire(entry) {
200
+ if (entry !== this.#current && entry.users === 0) {
201
+ entry.agent.destroy();
202
+ this.#agents.delete(entry);
203
+ }
204
+ }
205
+ }
206
+ function isObject(value) {
207
+ return value !== null && typeof value === "object" && !Array.isArray(value);
208
+ }
209
+
210
+ export { KubernetesResourceTransport };
@@ -1,12 +1,15 @@
1
+ import {
2
+ KubernetesResourceTransport
3
+ } from "./chunk-4ms037k2.js";
1
4
  import {
2
5
  resolveWorkload
3
- } from "./chunk-8axd2y2m.js";
6
+ } from "./chunk-0s27bgga.js";
4
7
  import {
5
8
  ServicePortForwarder
6
9
  } from "./chunk-2171tajs.js";
7
10
  import {
8
- resourceAccess
9
- } from "./chunk-9n9z2pty.js";
11
+ KubernetesExecTransport
12
+ } from "./chunk-shsabpc1.js";
10
13
  import {
11
14
  KubernetesError
12
15
  } from "./chunk-20kv4wc3.js";
@@ -24,10 +27,14 @@ class KubernetesClient {
24
27
  #forwarders = new Map;
25
28
  #operations = new Set;
26
29
  #disposal;
30
+ #exec;
31
+ #injectedExecutor;
27
32
  #resources;
33
+ #transport;
28
34
  constructor(kube, signal, executor, resourceLimits = { timeoutMs: 30000, concurrency: 4, maxBytes: 8 * 1024 * 1024 }, resources) {
29
35
  this.kube = kube;
30
36
  this.resourceLimits = resourceLimits;
37
+ this.#injectedExecutor = executor;
31
38
  this.signal = signal ? AbortSignal.any([signal, this.#controller.signal]) : this.#controller.signal;
32
39
  if (executor)
33
40
  this.#executors.set(kube.namespace, executor);
@@ -43,10 +50,12 @@ class KubernetesClient {
43
50
  throw new KubernetesError("Workload namespace is required", { kind: "invalid_argument" });
44
51
  if (!environment.trim())
45
52
  throw new KubernetesError("Environment identity is required", { kind: "invalid_argument" });
46
- this.#resources ??= resourceAccess(this.kube, this.signal, this.resourceLimits);
47
- return resolveWorkload(this.#resources, workload, namespace, environment);
53
+ return resolveWorkload(this, workload, namespace, environment);
48
54
  });
49
55
  }
56
+ #resourceAccess() {
57
+ return this.#resources ??= this.#transport = new KubernetesResourceTransport(this.kube, this.signal, this.resourceLimits);
58
+ }
50
59
  #executor(namespace) {
51
60
  let executor = this.#executors.get(namespace);
52
61
  if (!executor) {
@@ -68,11 +77,22 @@ class KubernetesClient {
68
77
  #options(options) {
69
78
  return { ...options, signal: options?.signal ? AbortSignal.any([this.signal, options.signal]) : this.signal };
70
79
  }
71
- run(namespace, command, options) {
72
- return this.#track(() => this.#executor(namespace).run(command, this.#options(options)));
80
+ get(ref, options) {
81
+ return this.#track(() => this.#resourceAccess().get({ ...ref, namespace: ref.namespace ?? this.kube.namespace }, this.#options(options)));
82
+ }
83
+ list(query, options) {
84
+ return this.#track(() => this.#resourceAccess().list({ ...query, namespace: query.namespace ?? this.kube.namespace }, this.#options(options)));
85
+ }
86
+ inNamespace(namespace) {
87
+ if (!namespace.trim())
88
+ throw new KubernetesError("Namespace is required", { kind: "invalid_argument" });
89
+ return {
90
+ get: (ref, options) => this.get({ ...ref, namespace }, options),
91
+ list: (query, options) => this.list({ ...query, namespace }, options)
92
+ };
73
93
  }
74
94
  exec(namespace, target, command, options) {
75
- 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)));
76
96
  }
77
97
  forward(namespace, target) {
78
98
  return this.#track(async () => {
@@ -101,6 +121,7 @@ class KubernetesClient {
101
121
  forwarder?.stop();
102
122
  }));
103
123
  this.#forwarders.clear();
124
+ this.#transport?.dispose();
104
125
  const errors = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
105
126
  if (errors.length)
106
127
  throw new AggregateError(errors, "Kubernetes client cleanup failed");
@@ -0,0 +1,15 @@
1
+ import {
2
+ KubernetesError
3
+ } from "./chunk-20kv4wc3.js";
4
+
5
+ // src/kubernetes/resources.ts
6
+ function resourcePath(query, name) {
7
+ const parts = query.apiVersion.split("/");
8
+ if (parts.length > 2 || parts.some((part) => !/^[a-z0-9][a-z0-9.-]*$/.test(part)) || !/^[a-z][a-z0-9-]*$/.test(query.resource) || query.namespace !== undefined && !/^[a-z0-9][a-z0-9-]*$/.test(query.namespace) || name !== undefined && !/^[a-z0-9][a-z0-9.-]*$/.test(name)) {
9
+ throw new KubernetesError("Invalid Kubernetes resource identity", { kind: "invalid_argument" });
10
+ }
11
+ const prefix = parts.length === 1 ? "/api/" : "/apis/";
12
+ return prefix + parts.join("/") + (query.namespace === undefined ? "" : "/namespaces/" + query.namespace) + "/" + query.resource + (name === undefined ? "" : "/" + name);
13
+ }
14
+
15
+ export { resourcePath };
@@ -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,21 +1,29 @@
1
1
  import type { Client } from "../client";
2
- import { type Executor, type KubectlOptions, type ExecTarget, type RunOptions } from "./executor";
2
+ import { type Executor, type ExecTarget, type RunOptions } from "./executor";
3
3
  import type { KubernetesEndpoint } from "./endpoint";
4
4
  import type { Workload, WorkloadInstance } from "@compforge/harness-common";
5
- import { type ResourceAccess, type ResourceLimits } from "./resources";
5
+ import type { ResourceAccess, ResourceLimits, ResourceRef, ResourceListQuery, ResourceRequestOptions, NamespaceResources } from "./resources";
6
+ import { type KubernetesConnectionOptions } from "./resource-transport";
6
7
  /** Owns cluster transports; protocol callers borrow this client without closing it. */
7
8
  export declare class KubernetesClient implements Client {
8
9
  #private;
9
10
  private readonly kube;
10
11
  private readonly resourceLimits;
11
12
  readonly signal: AbortSignal;
12
- constructor(kube: KubectlOptions & {
13
+ constructor(kube: KubernetesConnectionOptions & {
13
14
  namespace: string;
14
15
  }, signal?: AbortSignal, executor?: Executor, resourceLimits?: ResourceLimits, resources?: ResourceAccess);
15
16
  initialize(): Promise<void>;
16
17
  /** environment is a stable target ID from the caller's registry, not access configuration. */
17
18
  resolveWorkload(workload: Workload, environment: string): Promise<WorkloadInstance[]>;
18
- run(namespace: string, command: string[], options?: RunOptions): Promise<import("./executor").ExecResult>;
19
+ /**
20
+ * @spec Resource operations return API objects and structured errors, sharing one root HTTP transport.
21
+ * @rule Namespace views borrow the root lifecycle; reads never fall back to command execution.
22
+ * @see {@link docs/toolbox.md}
23
+ */
24
+ get(ref: ResourceRef, options?: ResourceRequestOptions): Promise<import("./resources").Resource>;
25
+ list(query: ResourceListQuery, options?: ResourceRequestOptions): Promise<import("./resources").ResourceList>;
26
+ inNamespace(namespace: string): NamespaceResources;
19
27
  exec(namespace: string, target: ExecTarget, command: string[], options?: RunOptions): Promise<import("./executor").ExecResult>;
20
28
  forward(namespace: string, target: KubernetesEndpoint): Promise<import("./service-port-forward").ForwardedEndpoint>;
21
29
  dispose(): Promise<void>;
@@ -1,11 +1,14 @@
1
1
  import {
2
2
  KubernetesClient
3
- } from "../chunk-a1cb44ga.js";
4
- import"../chunk-8axd2y2m.js";
3
+ } from "../chunk-8kts470p.js";
4
+ import"../chunk-4ms037k2.js";
5
+ import"../chunk-0s27bgga.js";
5
6
  import"../chunk-2171tajs.js";
6
7
  import"../chunk-j46x7j7c.js";
7
8
  import"../chunk-6fnhzhdb.js";
8
- import"../chunk-9n9z2pty.js";
9
+ import"../chunk-ftkrjqkc.js";
10
+ import"../chunk-shsabpc1.js";
11
+ import"../chunk-a9htb6cr.js";
9
12
  import"../chunk-20kv4wc3.js";
10
13
  import"../chunk-v3hxvnrc.js";
11
14
  import"../chunk-631vce5z.js";
@@ -1,7 +1,7 @@
1
1
  import { type ClientProvider, type ClientManager } from "../client";
2
2
  import type { Environment } from "@compforge/harness-common";
3
3
  import { KubernetesClient } from "./client";
4
- import type { KubectlOptions } from "./executor";
4
+ import type { KubernetesConnectionOptions } from "./resource-transport";
5
5
  import type { ResourceLimits } from "./resources";
6
6
  /** Cluster access is a client provider; the execution owns client lifetime. */
7
7
  export declare class KubernetesEnvironment implements Environment, ClientProvider<KubernetesClient> {
@@ -11,7 +11,7 @@ export declare class KubernetesEnvironment implements Environment, ClientProvide
11
11
  readonly clientKey: string;
12
12
  /** Deployment metadata only; not part of client reuse or access configuration. */
13
13
  readonly imageRegistry?: string;
14
- constructor(name: string, kube: KubectlOptions & {
14
+ constructor(name: string, kube: KubernetesConnectionOptions & {
15
15
  namespace: string;
16
16
  imageRegistry?: string;
17
17
  }, limits: ResourceLimits);
@@ -1,11 +1,13 @@
1
1
  import {
2
2
  KubernetesClient
3
- } from "../chunk-a1cb44ga.js";
4
- import"../chunk-8axd2y2m.js";
3
+ } from "../chunk-8kts470p.js";
4
+ import"../chunk-4ms037k2.js";
5
+ import"../chunk-0s27bgga.js";
5
6
  import"../chunk-2171tajs.js";
6
7
  import"../chunk-j46x7j7c.js";
7
8
  import"../chunk-6fnhzhdb.js";
8
- import"../chunk-9n9z2pty.js";
9
+ import"../chunk-ftkrjqkc.js";
10
+ import"../chunk-shsabpc1.js";
9
11
  import {
10
12
  clientKey
11
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
+ };
@@ -0,0 +1,19 @@
1
+ import { type Resource, type ResourceAccess, type ResourceList, type ResourceListQuery, type ResourceRef, type ResourceRequestOptions, type ResourceLimits } from "./resources";
2
+ export interface KubernetesConnectionOptions {
3
+ kubeconfig?: string;
4
+ context?: string;
5
+ }
6
+ /**
7
+ * @spec One root transport owns HTTP connections across resource kinds and namespaces.
8
+ * @rule Credentials refresh per request; TLS/proxy changes retire the old pool after its active reads finish.
9
+ * @rule Deadlines cover queueing, authentication and response consumption; failed requests are never replayed.
10
+ * @see {@link docs/toolbox.md}
11
+ */
12
+ export declare class KubernetesResourceTransport implements ResourceAccess {
13
+ #private;
14
+ private readonly limits;
15
+ constructor(options: KubernetesConnectionOptions, signal: AbortSignal, limits: ResourceLimits);
16
+ get(ref: ResourceRef, options?: ResourceRequestOptions): Promise<Resource>;
17
+ list(query: ResourceListQuery, options?: ResourceRequestOptions): Promise<ResourceList>;
18
+ dispose(): void;
19
+ }
@@ -0,0 +1,10 @@
1
+ import {
2
+ KubernetesResourceTransport
3
+ } from "../chunk-4ms037k2.js";
4
+ import"../chunk-ftkrjqkc.js";
5
+ import"../chunk-a9htb6cr.js";
6
+ import"../chunk-20kv4wc3.js";
7
+ import"../chunk-v3hxvnrc.js";
8
+ export {
9
+ KubernetesResourceTransport
10
+ };
@@ -1,23 +1,63 @@
1
- import type { KubectlOptions } from "./executor";
1
+ /** API resource names are plural REST names, not Kind names or kubectl aliases. */
2
+ export interface ResourceQuery {
3
+ apiVersion: string;
4
+ resource: string;
5
+ namespace?: string;
6
+ }
7
+ export interface ResourceRef extends ResourceQuery {
8
+ name: string;
9
+ }
10
+ export interface ResourceListQuery extends ResourceQuery {
11
+ labelSelector?: string;
12
+ fieldSelector?: string;
13
+ limit?: number;
14
+ continue?: string;
15
+ }
16
+ /** Preserve the server manifest; consumers own projections into domain models. */
2
17
  export interface Resource {
18
+ [field: string]: unknown;
19
+ apiVersion?: string;
20
+ kind?: string;
3
21
  metadata?: {
4
22
  name?: string;
5
23
  namespace?: string;
6
24
  uid?: string;
25
+ [field: string]: unknown;
7
26
  };
8
27
  spec?: {
9
28
  selector?: unknown;
29
+ [field: string]: unknown;
10
30
  };
11
- items?: Resource[];
31
+ }
32
+ export interface ResourceList {
33
+ apiVersion?: string;
12
34
  kind?: string;
35
+ metadata?: {
36
+ continue?: string;
37
+ resourceVersion?: string;
38
+ [field: string]: unknown;
39
+ };
40
+ items: Resource[];
41
+ [field: string]: unknown;
42
+ }
43
+ export interface ResourceRequestOptions {
44
+ signal?: AbortSignal;
45
+ /** Total budget, including capacity wait, authentication and response consumption. */
46
+ timeoutMs?: number;
13
47
  }
14
48
  export interface ResourceAccess {
15
- get(namespace: string, resource: string, name?: string, selector?: string): Promise<Resource>;
49
+ get(ref: ResourceRef, options?: ResourceRequestOptions): Promise<Resource>;
50
+ list(query: ResourceListQuery, options?: ResourceRequestOptions): Promise<ResourceList>;
16
51
  }
17
52
  export interface ResourceLimits {
18
53
  timeoutMs: number;
19
54
  concurrency: number;
20
55
  maxBytes: number;
21
56
  }
22
- /** Native JSON reads use the same client-node auth/TLS path as native Pod logs. */
23
- export declare function resourceAccess(options: KubectlOptions, signal: AbortSignal, limits: ResourceLimits): ResourceAccess;
57
+ /** Namespace views borrow the root client and cannot close it or override their scope. */
58
+ export interface NamespaceResources {
59
+ get(ref: Omit<ResourceRef, "namespace">, options?: ResourceRequestOptions): Promise<Resource>;
60
+ list(query: Omit<ResourceListQuery, "namespace">, options?: ResourceRequestOptions): Promise<ResourceList>;
61
+ }
62
+ /** Explicit scope avoids interpreting an omitted namespace as a cluster-wide list. */
63
+ export declare function resourcePath(query: ResourceQuery, name?: string): string;
@@ -1,8 +1,7 @@
1
1
  import {
2
- resourceAccess
3
- } from "../chunk-9n9z2pty.js";
2
+ resourcePath
3
+ } from "../chunk-ftkrjqkc.js";
4
4
  import"../chunk-20kv4wc3.js";
5
- import"../chunk-v3hxvnrc.js";
6
5
  export {
7
- resourceAccess
6
+ resourcePath
8
7
  };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  resolveWorkload
3
- } from "../chunk-8axd2y2m.js";
3
+ } from "../chunk-0s27bgga.js";
4
4
  import"../chunk-20kv4wc3.js";
5
5
  export {
6
6
  resolveWorkload
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compforge/harness-toolbox",
3
- "version": "0.3.11",
3
+ "version": "0.4.1",
4
4
  "description": "Shared infrastructure clients, transports and bounded data capture",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -106,13 +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
+ "@types/node": "^22",
117
+ "@types/ws": "^8.18.1"
116
118
  },
117
119
  "publishConfig": {
118
120
  "access": "public"
@@ -1,89 +0,0 @@
1
- import {
2
- KubernetesError,
3
- ToolboxError,
4
- kubernetesHttpError
5
- } from "./chunk-20kv4wc3.js";
6
- import {
7
- ConcurrencyPool
8
- } from "./chunk-v3hxvnrc.js";
9
-
10
- // src/kubernetes/resources.ts
11
- import { KubeConfig } from "@kubernetes/client-node";
12
- import fetch from "node-fetch/lib/index.js";
13
- function resourceAccess(options, signal, limits) {
14
- if (![limits.timeoutMs, limits.concurrency, limits.maxBytes].every((value) => Number.isSafeInteger(value) && value > 0)) {
15
- throw new KubernetesError("Resource limits must be positive integers", { kind: "invalid_argument" });
16
- }
17
- const config = new KubeConfig;
18
- try {
19
- if (options.kubeconfig)
20
- config.loadFromFile(options.kubeconfig);
21
- else
22
- config.loadFromDefault();
23
- if (options.context)
24
- config.setCurrentContext(options.context);
25
- } catch (cause) {
26
- throw new KubernetesError("Cannot load Kubernetes access configuration", { kind: "connection_failed", cause });
27
- }
28
- const cluster = config.getCurrentCluster();
29
- if (!cluster)
30
- throw new KubernetesError("Kubernetes context has no cluster", { kind: "invalid_argument" });
31
- if (new URL(cluster.server).protocol === "http:") {
32
- config.clusters = config.clusters.map((item) => item === cluster ? { ...item, skipTLSVerify: true } : item);
33
- }
34
- const pool = new ConcurrencyPool(limits.concurrency);
35
- return {
36
- get: (namespace, resource, name, selector) => pool.run(async () => {
37
- const group = ["pods", "services"].includes(resource) ? "/api/v1" : "/apis/apps/v1";
38
- const path = group + "/namespaces/" + encodeURIComponent(namespace) + "/" + resource + (name ? "/" + encodeURIComponent(name) : "");
39
- const url = new URL(cluster.server.replace(/\/$/, "") + path);
40
- if (selector)
41
- url.searchParams.set("labelSelector", selector);
42
- const controller = new AbortController;
43
- const abort = () => controller.abort(signal.reason);
44
- signal.addEventListener("abort", abort, { once: true });
45
- let timedOut = false;
46
- const timer = setTimeout(() => {
47
- timedOut = true;
48
- controller.abort();
49
- }, limits.timeoutMs);
50
- try {
51
- signal.throwIfAborted();
52
- const init = await config.applyToFetchOptions({});
53
- controller.signal.throwIfAborted();
54
- const response = await fetch(url.href, { ...init, signal: controller.signal, size: limits.maxBytes });
55
- if (!response.ok) {
56
- controller.abort();
57
- const error = kubernetesHttpError(response.status);
58
- throw new KubernetesError("Read " + resource + " in namespace " + namespace + " failed", { kind: error.kind, code: error.code });
59
- }
60
- return await response.json();
61
- } catch (cause) {
62
- if (signal.aborted)
63
- throw signal.reason;
64
- if (cause instanceof ToolboxError)
65
- throw cause;
66
- const type = cause && typeof cause === "object" && "type" in cause ? cause.type : undefined;
67
- const code = cause && typeof cause === "object" && "code" in cause && typeof cause.code === "string" ? cause.code : undefined;
68
- const tls = code !== undefined && [
69
- "CERT_HAS_EXPIRED",
70
- "DEPTH_ZERO_SELF_SIGNED_CERT",
71
- "SELF_SIGNED_CERT_IN_CHAIN",
72
- "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
73
- "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
74
- "ERR_TLS_CERT_ALTNAME_INVALID"
75
- ].includes(code);
76
- throw new KubernetesError("Read " + resource + " in namespace " + namespace + " failed", {
77
- kind: timedOut ? "timeout" : type === "max-size" ? "limit_exceeded" : type === "invalid-json" ? "invalid_response" : tls ? "tls_verification_failed" : code === "ECONNRESET" ? "connection_lost" : "connection_failed",
78
- cause,
79
- code
80
- });
81
- } finally {
82
- clearTimeout(timer);
83
- signal.removeEventListener("abort", abort);
84
- }
85
- }, signal)
86
- };
87
- }
88
-
89
- export { resourceAccess };