@compforge/harness-toolbox 0.4.0 → 0.4.2

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
@@ -18,45 +18,49 @@ not load database or Kubernetes drivers.
18
18
 
19
19
  ## File sources
20
20
 
21
- Use `LocalFileDataSource` for a local file or `PodFileDataSource` for a file in an explicit
22
- namespace/Pod/container. Both extend Common's `FileDataSource`, so a Service can store them directly
23
- as DataSources and consumers can recognize files with `instanceof FileDataSource`. No additional
24
- `id` or `kind` wrapper is required; consumer-owned selection names remain separate from client identity.
21
+ A FileDataSource is a caller-declared acquisition group. Use `LocalFileDataSource` for local paths
22
+ or `PodFileDataSource` for paths in one explicit namespace/Pod/container. Both extend Common's
23
+ `FileDataSource`; no additional id/kind wrapper is required.
25
24
 
26
25
  ```ts
27
- import { ClientManager, FileDataSource } from "@compforge/harness-common";
26
+ import { ClientManager } from "@compforge/harness-common";
28
27
  import { LocalFileDataSource, PodFileDataSource } from "@compforge/harness-toolbox/file";
29
28
 
30
- const source = new LocalFileDataSource("./config.yaml", { concurrency: 2 });
29
+ const source = new LocalFileDataSource(["./config.yaml", "./secret/config.yaml"], { concurrency: 2 });
31
30
  const clients = new ClientManager();
32
31
  try {
33
- if (source instanceof FileDataSource) {
34
- const file = await clients.get(source);
35
- const bytes = await file.read({ maxBytes: 1024 * 1024, timeoutMs: 15_000 });
36
- console.log(source.path, new TextDecoder("utf-8", { fatal: true }).decode(bytes));
32
+ const file = await clients.get(source);
33
+ const results = await file.read({ maxBytes: 1024 * 1024, maxTotalBytes: 2 * 1024 * 1024, timeoutMs: 15_000 });
34
+ for (const result of results) {
35
+ if (result.status === "error") console.error(result.path, result.error.message);
36
+ else console.log(result.path, new TextDecoder("utf-8", { fatal: true }).decode(result.bytes));
37
37
  }
38
- } finally {
39
- await clients.dispose();
40
- }
38
+ } finally { await clients.dispose(); }
41
39
 
42
- // `environment` is a KubernetesEnvironment (or an equivalent ClientProvider).
43
- // Resolve Workloads first and create one source per chosen Pod/container.
40
+ // Resolve Workloads first. This entire group is fetched through one exec session.
44
41
  const podSource = new PodFileDataSource(environment,
45
42
  { namespace: "app", pod: "worker-abc", container: "worker" },
46
- "/etc/app/config.yaml", { concurrency: 2 });
43
+ ["/etc/app/config.yaml", "/etc/app/secret/config.yaml"], { concurrency: 2 });
47
44
  ```
48
45
 
49
- Each read reopens the file; the manager shares clients, not previous file contents. The byte limit
50
- rejects oversized reads, including a zero-byte limit on a nonempty file. Timeouts include queue time;
51
- caller cancellation and disposal cancel active reads. Keep the source path and Pod target with any
52
- persisted evidence. A successful read describes bytes at that location, not the application's parsed
53
- or effective configuration and not an atomic filesystem snapshot.
54
-
55
- Local files may be symlinks to regular files. Pod reads require `pods/exec` access and `head -c` in the
56
- selected container; paths are passed as argv without a shell. A Pod name is an access location, not
57
- a UID fence; callers needing incarnation guarantees must verify discovery around collection. Pod
58
- sources borrow Kubernetes access from the root manager and never close it themselves. Unsupported
59
- commands, missing paths and denied reads fail explicitly; they are not empty file results.
46
+ Every read reopens all declared paths and returns one ordered result per path. Empty files succeed;
47
+ missing, denied, nonregular and oversized files return individual errors with no partial bytes.
48
+ An error in one file does not discard sibling results. Per-file limits reset for each path; the total
49
+ budget bounds retained decoded bytes, including incomplete files. Queueing and the whole group
50
+ share one deadline. Cancellation, total-budget exhaustion, deadline and transport/protocol failure
51
+ reject the operation. Sources share clients, never previous file contents.
52
+
53
+ Pod groups use one exec and require `sh`, `head -c`, `base64` and `tr`. Paths remain positional
54
+ arguments even when they contain shell syntax or newlines. Indexed base64 records carry raw binary
55
+ bytes plus each file's exit status; truncated or malformed records reject without replay. No remote
56
+ temporary files are written. Local reads allow symlinks to regular files and reject nonregular files.
57
+ Keep the path and Pod target on evidence; grouping guarantees neither an atomic filesystem snapshot
58
+ nor application configuration precedence. A Pod name is not a UID fence; callers needing incarnation
59
+ guarantees must check discovery around collection. File clients borrow root Kubernetes access and
60
+ never close it themselves.
61
+
62
+ Version 0.4.2 requires Common 0.5.4. Replace single path arguments with arrays, supply `maxTotalBytes`,
63
+ and handle per-file results. Publish Common before Toolbox; this change has no single-file legacy adapter.
60
64
 
61
65
  ## Kubernetes resources
62
66
 
@@ -103,7 +107,23 @@ selectorless Services and failed access raise KubernetesError.
103
107
 
104
108
  The resource path uses client-node kubeconfig authentication/TLS and bounded native JSON reads,
105
109
  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.
110
+ client's ResourceLimits constructor argument. Native exec uses the same root capacity/byte policy
111
+ with a separate slot pool; its per-call timeout defaults to that policy and covers queueing,
112
+ authentication, upgrade and execution. Explicit per-call timeouts support longer commands.
113
+
114
+ Exec requires `get pods/exec` permission for the WebSocket upgrade (kubectl/SPDY paths may use
115
+ `create pods/exec`). It connects directly to the Pod exec WebSocket endpoint without a preliminary Pod GET or
116
+ kubectl subprocess. Each command owns a stream session; failures are never retried or replayed.
117
+ Kubeconfig controls authentication, TLS and proxy access. stdout/stderr and remote exit status
118
+ remain separate; retained output and individual messages are bounded. Streaming callers may
119
+ set `collectStdout: false` and enforce their own total byte budget. Root disposal cancels active
120
+ and queued sessions. Protocol v4 is accepted for output-only commands; stdin requires v5 to
121
+ close input without losing remaining output/status. Bun does not expose HTTP upgrade rejection
122
+ status, so those failures retain an unknown connection error. Native exec with a kubeconfig
123
+ proxy requires Node: Bun cannot reliably release proxy sockets or preserve TLS name overrides.
124
+ It fails before opening a session on Bun; use Node or explicitly provide an Executor adapter.
125
+ The transport never bypasses the proxy or TLS checks.
126
+ Port-forward still uses kubectl.
107
127
  See [the shared Workload model](../../../docs/workload.md).
108
128
 
109
129
  ```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,157 @@
1
+ import {
2
+ BoundedFileClient,
3
+ fileClientOptions,
4
+ filePaths
5
+ } from "./chunk-kkts16w9.js";
6
+ import {
7
+ FileError
8
+ } from "./chunk-20kv4wc3.js";
9
+
10
+ // src/file/pod.ts
11
+ import { posix } from "node:path";
12
+ import { clientKey, FileDataSource } from "@compforge/harness-common";
13
+ var READ_FILES = String.raw`limit=$1
14
+ shift
15
+ index=0
16
+ for path do
17
+ printf '%s ' "$index"
18
+ { if [ -f "$path" ]; then head -c "$limit" "$path" 2>/dev/null; status=$?; else status=1; fi
19
+ printf '\000%03d' "$status"
20
+ } | base64 | tr -d '\r\n'
21
+ printf '\n'
22
+ index=$((index + 1))
23
+ done`;
24
+ function fileRecords(paths, maxBytes, target, sink) {
25
+ let index = 0, header = "", started = false, carry = "", encodedSize = 0, padded = false;
26
+ let trailer = Buffer.alloc(0);
27
+ const maximumEncoded = Math.ceil((maxBytes + 5) / 3) * 4;
28
+ const invalid = () => new FileError("Invalid Pod file group response", { kind: "invalid_response" });
29
+ const body = (part) => {
30
+ if (padded && part)
31
+ throw invalid();
32
+ encodedSize += part.length;
33
+ if (encodedSize > maximumEncoded)
34
+ throw invalid();
35
+ const text = carry + part, end = text.length - text.length % 4;
36
+ carry = text.slice(end);
37
+ if (!end)
38
+ return;
39
+ const encoded = text.slice(0, end), decoded = Buffer.from(encoded, "base64");
40
+ if (decoded.toString("base64") !== encoded)
41
+ throw invalid();
42
+ padded = encoded.endsWith("=");
43
+ if (padded && carry)
44
+ throw invalid();
45
+ const bytes = Buffer.concat([trailer, decoded]);
46
+ if (bytes.length > 4)
47
+ sink.receive(index, bytes.subarray(0, -4));
48
+ trailer = Buffer.from(bytes.subarray(Math.max(0, bytes.length - 4)));
49
+ };
50
+ return {
51
+ receive(bytes) {
52
+ if (bytes.some((byte) => byte > 127))
53
+ throw invalid();
54
+ const text = Buffer.from(bytes).toString("ascii");
55
+ for (let offset = 0;offset < text.length; ) {
56
+ if (!started) {
57
+ const char = text[offset++];
58
+ if (char !== " ") {
59
+ if (!/\d/.test(char) || header.length >= String(paths.length).length)
60
+ throw invalid();
61
+ header += char;
62
+ continue;
63
+ }
64
+ if (index >= paths.length || header !== String(index))
65
+ throw invalid();
66
+ started = true;
67
+ }
68
+ const newline = text.indexOf(`
69
+ `, offset);
70
+ body(text.slice(offset, newline < 0 ? text.length : newline));
71
+ if (newline < 0)
72
+ return;
73
+ if (carry || trailer.length !== 4 || trailer[0] !== 0)
74
+ throw invalid();
75
+ const status = trailer.subarray(1).toString("ascii");
76
+ if (!/^\d{3}$/.test(status) || Number(status) > 255)
77
+ throw invalid();
78
+ sink.complete(index, Number(status) === 0 ? undefined : new FileError(`Cannot read Pod file: ${target.namespace}/${target.pod}/${target.container} ${paths[index]}`, { kind: "operation_failed", code: Number(status) }));
79
+ index++;
80
+ header = "";
81
+ started = false;
82
+ encodedSize = 0;
83
+ padded = false;
84
+ trailer = Buffer.alloc(0);
85
+ offset = newline + 1;
86
+ }
87
+ },
88
+ finish() {
89
+ if (started || header || index !== paths.length)
90
+ throw invalid();
91
+ }
92
+ };
93
+ }
94
+
95
+ class PodFileClient extends BoundedFileClient {
96
+ target;
97
+ acquire;
98
+ #kubernetes;
99
+ constructor(paths, target, options, signal, acquire) {
100
+ super(paths, options, signal);
101
+ this.target = target;
102
+ this.acquire = acquire;
103
+ }
104
+ async initializeAccess() {
105
+ this.#kubernetes = await this.acquire();
106
+ }
107
+ async capture(signal, limits, sink) {
108
+ const abort = new AbortController;
109
+ const combined = AbortSignal.any([signal, abort.signal]);
110
+ const records = fileRecords(this.paths, limits.maxBytes, this.target, sink);
111
+ const result = await this.#kubernetes.exec(this.target.namespace, this.target, ["sh", "-c", READ_FILES, "file-source", String(limits.maxBytes + 1), ...this.paths], {
112
+ signal: combined,
113
+ timeoutMs: limits.timeoutMs,
114
+ collectStdout: false,
115
+ onStdoutBytes: (bytes) => {
116
+ if (combined.aborted)
117
+ return;
118
+ try {
119
+ records.receive(bytes);
120
+ combined.throwIfAborted();
121
+ } catch (error) {
122
+ abort.abort(error);
123
+ }
124
+ }
125
+ });
126
+ combined.throwIfAborted();
127
+ if (!result.ok)
128
+ throw new FileError(`Cannot acquire Pod file group: ${this.target.namespace}/${this.target.pod}/${this.target.container}`, { kind: result.timedOut ? "timeout" : "operation_failed", code: result.exitCode ?? undefined });
129
+ records.finish();
130
+ }
131
+ }
132
+
133
+ class PodFileDataSource extends FileDataSource {
134
+ kubernetes;
135
+ paths;
136
+ target;
137
+ clientKey;
138
+ #options;
139
+ constructor(kubernetes, target, paths, options) {
140
+ super();
141
+ this.kubernetes = kubernetes;
142
+ this.paths = filePaths(paths);
143
+ if (this.paths.some((path) => !posix.isAbsolute(path)))
144
+ throw new FileError("Pod file paths must be absolute", { kind: "invalid_argument" });
145
+ if ([target.namespace, target.pod, target.container].some((value) => !value.trim())) {
146
+ throw new FileError("Pod file namespace, Pod and Container are required", { kind: "invalid_argument" });
147
+ }
148
+ this.target = Object.freeze({ namespace: target.namespace, pod: target.pod, container: target.container });
149
+ this.#options = fileClientOptions(options);
150
+ this.clientKey = clientKey("pod-file", { access: kubernetes.clientKey, target: this.target, paths: this.paths, options: this.#options });
151
+ }
152
+ createClient(clients, signal) {
153
+ return new PodFileClient(this.paths, this.target, this.#options, signal, () => clients.get(this.kubernetes));
154
+ }
155
+ }
156
+
157
+ export { PodFileClient, PodFileDataSource };
@@ -0,0 +1,131 @@
1
+ import {
2
+ FileError,
3
+ ToolboxError
4
+ } from "./chunk-20kv4wc3.js";
5
+ import {
6
+ ConcurrencyPool
7
+ } from "./chunk-v3hxvnrc.js";
8
+
9
+ // src/file/client.ts
10
+ function fileClientOptions(options) {
11
+ if (!Number.isSafeInteger(options.concurrency) || options.concurrency < 1) {
12
+ throw new FileError("File concurrency must be a positive integer", { kind: "invalid_argument" });
13
+ }
14
+ return Object.freeze({ concurrency: options.concurrency });
15
+ }
16
+ function filePaths(paths) {
17
+ if (!paths.length || paths.some((path) => typeof path !== "string" || !path || path.includes("\x00")) || new Set(paths).size !== paths.length) {
18
+ throw new FileError("File paths must be nonempty, distinct and contain no NUL", { kind: "invalid_argument" });
19
+ }
20
+ return Object.freeze([...paths]);
21
+ }
22
+
23
+ class BoundedFileClient {
24
+ paths;
25
+ #controller = new AbortController;
26
+ #signal;
27
+ #pool;
28
+ #operations = new Set;
29
+ #ready = false;
30
+ #disposal;
31
+ constructor(paths, options, signal) {
32
+ this.paths = filePaths(paths);
33
+ this.#pool = new ConcurrencyPool(fileClientOptions(options).concurrency);
34
+ this.#signal = AbortSignal.any([signal, this.#controller.signal]);
35
+ }
36
+ async initialize() {
37
+ this.#signal.throwIfAborted();
38
+ await this.initializeAccess();
39
+ this.#signal.throwIfAborted();
40
+ this.#ready = true;
41
+ }
42
+ async initializeAccess() {}
43
+ async read(options) {
44
+ this.#signal.throwIfAborted();
45
+ if (!this.#ready)
46
+ throw new FileError("File client is not initialized", { kind: "operation_failed" });
47
+ if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0 || options.maxBytes >= Number.MAX_SAFE_INTEGER - 5 || !Number.isSafeInteger(options.maxTotalBytes) || options.maxTotalBytes < 0 || !Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 2147483647) {
48
+ throw new FileError("Invalid file group read limits", { kind: "invalid_argument" });
49
+ }
50
+ const controller = new AbortController;
51
+ const signal = AbortSignal.any([this.#signal, controller.signal, ...options.signal ? [options.signal] : []]);
52
+ const timer = setTimeout(() => controller.abort(new FileError("File group read timed out", { kind: "timeout" })), options.timeoutMs);
53
+ const operation = this.#pool.run(async () => {
54
+ const files = this.paths.map((path) => ({ path, chunks: [], size: 0, done: false, error: undefined }));
55
+ let retained = 0;
56
+ const discard = (file, error) => {
57
+ retained -= file.size;
58
+ file.size = 0;
59
+ file.chunks = [];
60
+ file.error ??= error;
61
+ };
62
+ const entry = (index) => {
63
+ const file = files[index];
64
+ if (!file || file.done)
65
+ throw new FileError("Invalid file group response", { kind: "invalid_response" });
66
+ return file;
67
+ };
68
+ const sink = {
69
+ receive: (index, bytes) => {
70
+ if (signal.aborted)
71
+ return;
72
+ const file = entry(index);
73
+ if (file.error)
74
+ return;
75
+ if (bytes.byteLength > options.maxBytes - file.size) {
76
+ discard(file, new FileError(`File exceeds ${options.maxBytes} bytes: ${file.path}`, { kind: "limit_exceeded" }));
77
+ } else if (bytes.byteLength > options.maxTotalBytes - retained) {
78
+ controller.abort(new FileError(`File group exceeds ${options.maxTotalBytes} bytes`, { kind: "limit_exceeded" }));
79
+ } else {
80
+ file.chunks.push(Uint8Array.from(bytes));
81
+ file.size += bytes.byteLength;
82
+ retained += bytes.byteLength;
83
+ }
84
+ },
85
+ complete: (index, error) => {
86
+ if (signal.aborted)
87
+ return;
88
+ const file = entry(index);
89
+ if (error)
90
+ discard(file, error);
91
+ file.done = true;
92
+ }
93
+ };
94
+ await this.capture(signal, options, sink);
95
+ signal.throwIfAborted();
96
+ if (files.some((file) => !file.done))
97
+ throw new FileError("Incomplete file group response", { kind: "invalid_response" });
98
+ return files.map((file) => {
99
+ if (file.error)
100
+ return { path: file.path, status: "error", error: file.error };
101
+ const bytes = new Uint8Array(file.size);
102
+ let offset = 0;
103
+ for (const chunk of file.chunks) {
104
+ bytes.set(chunk, offset);
105
+ offset += chunk.byteLength;
106
+ }
107
+ return { path: file.path, status: "ok", bytes };
108
+ });
109
+ }, signal);
110
+ this.#operations.add(operation);
111
+ try {
112
+ return await operation;
113
+ } catch (cause) {
114
+ signal.throwIfAborted();
115
+ if (cause instanceof ToolboxError)
116
+ throw cause;
117
+ throw new FileError("Cannot acquire file group", { kind: "operation_failed", cause });
118
+ } finally {
119
+ clearTimeout(timer);
120
+ this.#operations.delete(operation);
121
+ }
122
+ }
123
+ dispose() {
124
+ return this.#disposal ??= (async () => {
125
+ this.#controller.abort(new Error("File client disposed"));
126
+ await Promise.allSettled(this.#operations);
127
+ })();
128
+ }
129
+ }
130
+
131
+ export { fileClientOptions, filePaths, BoundedFileClient };
@@ -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 };
@@ -0,0 +1,65 @@
1
+ import {
2
+ BoundedFileClient,
3
+ fileClientOptions,
4
+ filePaths
5
+ } from "./chunk-kkts16w9.js";
6
+ import {
7
+ FileError
8
+ } from "./chunk-20kv4wc3.js";
9
+
10
+ // src/file/local.ts
11
+ import { constants } from "node:fs";
12
+ import { open } from "node:fs/promises";
13
+ import { resolve } from "node:path";
14
+ import { clientKey, FileDataSource } from "@compforge/harness-common";
15
+ class LocalFileClient extends BoundedFileClient {
16
+ async capture(signal, limits, sink) {
17
+ for (const [index, path] of this.paths.entries()) {
18
+ try {
19
+ signal.throwIfAborted();
20
+ const file = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
21
+ try {
22
+ signal.throwIfAborted();
23
+ if (!(await file.stat()).isFile())
24
+ throw new FileError(`Not a regular file: ${path}`, { kind: "unsupported_operation" });
25
+ const stream = file.createReadStream({
26
+ autoClose: false,
27
+ signal,
28
+ end: limits.maxBytes,
29
+ highWaterMark: Math.min(limits.maxBytes + 1, 64 * 1024)
30
+ });
31
+ for await (const bytes of stream)
32
+ sink.receive(index, bytes);
33
+ } finally {
34
+ await file.close();
35
+ }
36
+ sink.complete(index);
37
+ } catch (cause) {
38
+ signal.throwIfAborted();
39
+ const code = cause?.code;
40
+ sink.complete(index, cause instanceof FileError ? cause : new FileError(`Cannot read local file: ${path}`, {
41
+ kind: code === "ENOENT" || code === "ENOTDIR" ? "resource_not_found" : code === "EACCES" || code === "EPERM" ? "permission_denied" : "operation_failed",
42
+ code,
43
+ cause
44
+ }));
45
+ }
46
+ }
47
+ }
48
+ }
49
+
50
+ class LocalFileDataSource extends FileDataSource {
51
+ paths;
52
+ clientKey;
53
+ #options;
54
+ constructor(paths, options) {
55
+ super();
56
+ this.paths = filePaths(filePaths(paths).map((path) => resolve(path)));
57
+ this.#options = fileClientOptions(options);
58
+ this.clientKey = clientKey("local-file", { paths: this.paths, options: this.#options });
59
+ }
60
+ createClient(_clients, signal) {
61
+ return new LocalFileClient(this.paths, this.#options, signal);
62
+ }
63
+ }
64
+
65
+ export { LocalFileClient, LocalFileDataSource };
@@ -1,17 +1,22 @@
1
- import type { FileClient, FileReadOptions } from "@compforge/harness-common";
1
+ import type { FileClient, FileReadOptions, FileReadResult } from "@compforge/harness-common";
2
2
  export interface FileClientOptions {
3
3
  concurrency: number;
4
4
  }
5
+ export interface FileCaptureSink {
6
+ receive(index: number, bytes: Uint8Array): void;
7
+ complete(index: number, error?: Error): void;
8
+ }
5
9
  export declare function fileClientOptions(options: FileClientOptions): Readonly<FileClientOptions>;
6
- /** Shared limits and lifecycle; transports stream bytes and own per-read resource cleanup. */
10
+ export declare function filePaths(paths: readonly string[]): readonly string[];
11
+ /** Group lifecycle and budgets; a per-file failure releases its partial bytes and preserves sibling results. */
7
12
  export declare abstract class BoundedFileClient implements FileClient {
8
13
  #private;
9
- readonly path: string;
10
- constructor(path: string, options: FileClientOptions, signal: AbortSignal);
14
+ readonly paths: readonly string[];
15
+ constructor(paths: readonly string[], options: FileClientOptions, signal: AbortSignal);
11
16
  initialize(): Promise<void>;
12
17
  protected initializeAccess(): Promise<void>;
13
- protected abstract capture(signal: AbortSignal, limits: Pick<FileReadOptions, "maxBytes" | "timeoutMs">, receive: (bytes: Uint8Array) => void): Promise<void>;
14
- /** @spec Limits apply while streaming; a failed read never publishes partial bytes. */
15
- read(options: FileReadOptions): Promise<Uint8Array>;
18
+ protected abstract capture(signal: AbortSignal, limits: FileReadOptions, sink: FileCaptureSink): Promise<void>;
19
+ /** @spec Enforce budgets while streaming; file failures discard only that file, never publish partial bytes. */
20
+ read(options: FileReadOptions): Promise<readonly FileReadResult[]>;
16
21
  dispose(): Promise<void>;
17
22
  }
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  BoundedFileClient,
3
- fileClientOptions
4
- } from "../chunk-mzdz6wwc.js";
3
+ fileClientOptions,
4
+ filePaths
5
+ } from "../chunk-kkts16w9.js";
5
6
  import"../chunk-20kv4wc3.js";
6
7
  import"../chunk-v3hxvnrc.js";
7
8
  export {
9
+ filePaths,
8
10
  fileClientOptions,
9
11
  BoundedFileClient
10
12
  };
@@ -1,4 +1,4 @@
1
- export { FileDataSource, type FileClient, type FileReadOptions } from "@compforge/harness-common";
1
+ export { FileDataSource, type FileClient, type FileReadOptions, type FileReadResult } from "@compforge/harness-common";
2
2
  export { FileError } from "../errors";
3
3
  export type { FileClientOptions } from "./client";
4
4
  export { LocalFileDataSource, LocalFileClient } from "./local";
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  PodFileClient,
3
3
  PodFileDataSource
4
- } from "../chunk-mxyvpy79.js";
4
+ } from "../chunk-grpygk6f.js";
5
5
  import {
6
6
  LocalFileClient,
7
7
  LocalFileDataSource
8
- } from "../chunk-ba883sdp.js";
9
- import"../chunk-mzdz6wwc.js";
8
+ } from "../chunk-sqbew04a.js";
9
+ import"../chunk-kkts16w9.js";
10
10
  import {
11
11
  FileError
12
12
  } from "../chunk-20kv4wc3.js";
@@ -1,13 +1,13 @@
1
1
  import { FileDataSource, type ClientManager, type FileReadOptions } from "@compforge/harness-common";
2
- import { BoundedFileClient, type FileClientOptions } from "./client";
2
+ import { BoundedFileClient, type FileClientOptions, type FileCaptureSink } from "./client";
3
3
  export declare class LocalFileClient extends BoundedFileClient {
4
- protected capture(signal: AbortSignal, limits: Pick<FileReadOptions, "maxBytes" | "timeoutMs">, receive: (bytes: Uint8Array) => void): Promise<void>;
4
+ protected capture(signal: AbortSignal, limits: FileReadOptions, sink: FileCaptureSink): Promise<void>;
5
5
  }
6
- /** @spec Resolve relative paths once at declaration; reads reopen the path to observe replaced mounted files. */
6
+ /** @spec Resolve paths once at declaration; each group read reopens them to observe replaced mounted files. */
7
7
  export declare class LocalFileDataSource extends FileDataSource<LocalFileClient> {
8
8
  #private;
9
- readonly path: string;
9
+ readonly paths: readonly string[];
10
10
  readonly clientKey: string;
11
- constructor(path: string, options: FileClientOptions);
11
+ constructor(paths: readonly string[], options: FileClientOptions);
12
12
  createClient(_clients: Pick<ClientManager, "get">, signal: AbortSignal): LocalFileClient;
13
13
  }
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  LocalFileClient,
3
3
  LocalFileDataSource
4
- } from "../chunk-ba883sdp.js";
5
- import"../chunk-mzdz6wwc.js";
4
+ } from "../chunk-sqbew04a.js";
5
+ import"../chunk-kkts16w9.js";
6
6
  import"../chunk-20kv4wc3.js";
7
7
  import"../chunk-v3hxvnrc.js";
8
8
  export {
@@ -1,6 +1,6 @@
1
1
  import { FileDataSource, type ClientManager, type ClientProvider, type FileReadOptions } from "@compforge/harness-common";
2
2
  import type { KubernetesClient } from "../kubernetes/client";
3
- import { BoundedFileClient, type FileClientOptions } from "./client";
3
+ import { BoundedFileClient, type FileClientOptions, type FileCaptureSink } from "./client";
4
4
  export interface PodFileTarget {
5
5
  namespace: string;
6
6
  pod: string;
@@ -11,21 +11,22 @@ export declare class PodFileClient extends BoundedFileClient {
11
11
  #private;
12
12
  readonly target: Readonly<PodFileTarget>;
13
13
  private readonly acquire;
14
- constructor(path: string, target: Readonly<PodFileTarget>, options: FileClientOptions, signal: AbortSignal, acquire: () => Promise<PodFileAccess>);
14
+ constructor(paths: readonly string[], target: Readonly<PodFileTarget>, options: FileClientOptions, signal: AbortSignal, acquire: () => Promise<PodFileAccess>);
15
15
  protected initializeAccess(): Promise<void>;
16
- protected capture(signal: AbortSignal, limits: Pick<FileReadOptions, "maxBytes" | "timeoutMs">, receive: (bytes: Uint8Array) => void): Promise<void>;
16
+ /** @spec The whole declared group uses exactly one exec; file failures are records, never implicit retries. */
17
+ protected capture(signal: AbortSignal, limits: FileReadOptions, sink: FileCaptureSink): Promise<void>;
17
18
  }
18
19
  /**
19
- * @spec One explicit Pod/container/path per source; workload discovery and replica traversal belong to callers.
20
+ * @spec One explicit Pod/container and one caller-declared acquisition group; discovery belongs to consumers.
20
21
  * @rule Borrow Kubernetes access from the root manager; file clients never dispose that shared dependency.
21
22
  * @see {@link component://../../../docs/toolbox.md}
22
23
  */
23
24
  export declare class PodFileDataSource extends FileDataSource<PodFileClient> {
24
25
  #private;
25
26
  private readonly kubernetes;
26
- readonly path: string;
27
+ readonly paths: readonly string[];
27
28
  readonly target: Readonly<PodFileTarget>;
28
29
  readonly clientKey: string;
29
- constructor(kubernetes: ClientProvider<PodFileAccess>, target: PodFileTarget, path: string, options: FileClientOptions);
30
+ constructor(kubernetes: ClientProvider<PodFileAccess>, target: PodFileTarget, paths: readonly string[], options: FileClientOptions);
30
31
  createClient(clients: Pick<ClientManager, "get">, signal: AbortSignal): PodFileClient;
31
32
  }
package/dist/file/pod.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import {
2
2
  PodFileClient,
3
3
  PodFileDataSource
4
- } from "../chunk-mxyvpy79.js";
5
- import"../chunk-mzdz6wwc.js";
4
+ } from "../chunk-grpygk6f.js";
5
+ import"../chunk-kkts16w9.js";
6
6
  import"../chunk-20kv4wc3.js";
7
7
  import"../chunk-v3hxvnrc.js";
8
8
  export {
@@ -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.2",
4
4
  "description": "Shared infrastructure clients, transports and bounded data capture",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -99,23 +99,22 @@
99
99
  "README.md"
100
100
  ],
101
101
  "dependencies": {
102
- "@compforge/harness-common": "0.5.3",
102
+ "@compforge/harness-common": "0.5.4",
103
103
  "@aws-sdk/client-s3": "3.1133.0",
104
104
  "@kubernetes/client-node": "1.4.0",
105
105
  "@opensearch-project/opensearch": "3.6.0",
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"
@@ -1,60 +0,0 @@
1
- import {
2
- BoundedFileClient,
3
- fileClientOptions
4
- } from "./chunk-mzdz6wwc.js";
5
- import {
6
- FileError
7
- } from "./chunk-20kv4wc3.js";
8
-
9
- // src/file/local.ts
10
- import { constants } from "node:fs";
11
- import { open } from "node:fs/promises";
12
- import { resolve } from "node:path";
13
- import { clientKey, FileDataSource } from "@compforge/harness-common";
14
- class LocalFileClient extends BoundedFileClient {
15
- async capture(signal, limits, receive) {
16
- try {
17
- const file = await open(this.path, constants.O_RDONLY | constants.O_NONBLOCK);
18
- try {
19
- signal.throwIfAborted();
20
- if (!(await file.stat()).isFile()) {
21
- throw new FileError(`Not a regular file: ${this.path}`, { kind: "unsupported_operation" });
22
- }
23
- const stream = file.createReadStream({ autoClose: false, signal, highWaterMark: Math.min(limits.maxBytes + 1, 64 * 1024) });
24
- for await (const bytes of stream)
25
- receive(bytes);
26
- } finally {
27
- await file.close();
28
- }
29
- } catch (cause) {
30
- signal.throwIfAborted();
31
- if (cause instanceof FileError)
32
- throw cause;
33
- const code = cause?.code;
34
- throw new FileError(`Cannot read local file: ${this.path}`, {
35
- kind: code === "ENOENT" || code === "ENOTDIR" ? "resource_not_found" : code === "EACCES" || code === "EPERM" ? "permission_denied" : "operation_failed",
36
- code,
37
- cause
38
- });
39
- }
40
- }
41
- }
42
-
43
- class LocalFileDataSource extends FileDataSource {
44
- path;
45
- clientKey;
46
- #options;
47
- constructor(path, options) {
48
- super();
49
- if (!path || path.includes("\x00"))
50
- throw new FileError("File path must be nonempty and contain no NUL", { kind: "invalid_argument" });
51
- this.path = resolve(path);
52
- this.#options = fileClientOptions(options);
53
- this.clientKey = clientKey("local-file", { path: this.path, options: this.#options });
54
- }
55
- createClient(_clients, signal) {
56
- return new LocalFileClient(this.path, this.#options, signal);
57
- }
58
- }
59
-
60
- export { LocalFileClient, LocalFileDataSource };
@@ -1,62 +0,0 @@
1
- import {
2
- BoundedFileClient,
3
- fileClientOptions
4
- } from "./chunk-mzdz6wwc.js";
5
- import {
6
- FileError
7
- } from "./chunk-20kv4wc3.js";
8
-
9
- // src/file/pod.ts
10
- import { posix } from "node:path";
11
- import { clientKey, FileDataSource } from "@compforge/harness-common";
12
- class PodFileClient extends BoundedFileClient {
13
- target;
14
- acquire;
15
- #kubernetes;
16
- constructor(path, target, options, signal, acquire) {
17
- super(path, options, signal);
18
- this.target = target;
19
- this.acquire = acquire;
20
- }
21
- async initializeAccess() {
22
- this.#kubernetes = await this.acquire();
23
- }
24
- async capture(signal, limits, receive) {
25
- const result = await this.#kubernetes.exec(this.target.namespace, this.target, ["head", "-c", String(limits.maxBytes + 1), this.path], { signal, timeoutMs: limits.timeoutMs, collectStdout: false, onStdoutBytes: receive });
26
- signal.throwIfAborted();
27
- if (!result.ok)
28
- throw new FileError(`Cannot read Pod file: ${this.target.namespace}/${this.target.pod}/${this.target.container} ${this.path}`, { kind: result.timedOut ? "timeout" : "operation_failed", code: result.exitCode ?? undefined });
29
- }
30
- }
31
-
32
- class PodFileDataSource extends FileDataSource {
33
- kubernetes;
34
- path;
35
- target;
36
- clientKey;
37
- #options;
38
- constructor(kubernetes, target, path, options) {
39
- super();
40
- this.kubernetes = kubernetes;
41
- if (!posix.isAbsolute(path) || path.includes("\x00")) {
42
- throw new FileError("Pod file path must be absolute and contain no NUL", { kind: "invalid_argument" });
43
- }
44
- if ([target.namespace, target.pod, target.container].some((value) => !value.trim())) {
45
- throw new FileError("Pod file namespace, Pod and Container are required", { kind: "invalid_argument" });
46
- }
47
- this.path = path;
48
- this.target = Object.freeze({ namespace: target.namespace, pod: target.pod, container: target.container });
49
- this.#options = fileClientOptions(options);
50
- this.clientKey = clientKey("pod-file", {
51
- access: kubernetes.clientKey,
52
- target: this.target,
53
- path: this.path,
54
- options: this.#options
55
- });
56
- }
57
- createClient(clients, signal) {
58
- return new PodFileClient(this.path, this.target, this.#options, signal, () => clients.get(this.kubernetes));
59
- }
60
- }
61
-
62
- export { PodFileClient, PodFileDataSource };
@@ -1,91 +0,0 @@
1
- import {
2
- FileError,
3
- ToolboxError
4
- } from "./chunk-20kv4wc3.js";
5
- import {
6
- ConcurrencyPool
7
- } from "./chunk-v3hxvnrc.js";
8
-
9
- // src/file/client.ts
10
- function fileClientOptions(options) {
11
- if (!Number.isSafeInteger(options.concurrency) || options.concurrency < 1) {
12
- throw new FileError("File concurrency must be a positive integer", { kind: "invalid_argument" });
13
- }
14
- return Object.freeze({ concurrency: options.concurrency });
15
- }
16
-
17
- class BoundedFileClient {
18
- path;
19
- #controller = new AbortController;
20
- #signal;
21
- #pool;
22
- #operations = new Set;
23
- #ready = false;
24
- #disposal;
25
- constructor(path, options, signal) {
26
- this.path = path;
27
- this.#pool = new ConcurrencyPool(fileClientOptions(options).concurrency);
28
- this.#signal = AbortSignal.any([signal, this.#controller.signal]);
29
- }
30
- async initialize() {
31
- this.#signal.throwIfAborted();
32
- await this.initializeAccess();
33
- this.#signal.throwIfAborted();
34
- this.#ready = true;
35
- }
36
- async initializeAccess() {}
37
- async read(options) {
38
- this.#signal.throwIfAborted();
39
- if (!this.#ready)
40
- throw new FileError(`File client is not initialized: ${this.path}`, { kind: "operation_failed" });
41
- if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 0 || options.maxBytes === Number.MAX_SAFE_INTEGER || !Number.isSafeInteger(options.timeoutMs) || options.timeoutMs <= 0 || options.timeoutMs > 2147483647) {
42
- throw new FileError(`Invalid file read limits: ${this.path}`, { kind: "invalid_argument" });
43
- }
44
- const { maxBytes, timeoutMs } = options;
45
- const controller = new AbortController;
46
- const signal = AbortSignal.any([this.#signal, controller.signal, ...options.signal ? [options.signal] : []]);
47
- const timer = setTimeout(() => controller.abort(new FileError(`File read timed out: ${this.path}`, { kind: "timeout" })), timeoutMs);
48
- const operation = this.#pool.run(async () => {
49
- const chunks = [];
50
- let size = 0;
51
- await this.capture(signal, { maxBytes, timeoutMs }, (bytes) => {
52
- if (signal.aborted)
53
- return;
54
- if (bytes.byteLength > maxBytes - size) {
55
- controller.abort(new FileError(`File exceeds ${maxBytes} bytes: ${this.path}`, { kind: "limit_exceeded" }));
56
- return;
57
- }
58
- chunks.push(Uint8Array.from(bytes));
59
- size += bytes.byteLength;
60
- });
61
- signal.throwIfAborted();
62
- const result = new Uint8Array(size);
63
- let offset = 0;
64
- for (const chunk of chunks) {
65
- result.set(chunk, offset);
66
- offset += chunk.byteLength;
67
- }
68
- return result;
69
- }, signal);
70
- this.#operations.add(operation);
71
- try {
72
- return await operation;
73
- } catch (cause) {
74
- signal.throwIfAborted();
75
- if (cause instanceof ToolboxError)
76
- throw cause;
77
- throw new FileError(`Cannot read file: ${this.path}`, { kind: "operation_failed", cause });
78
- } finally {
79
- clearTimeout(timer);
80
- this.#operations.delete(operation);
81
- }
82
- }
83
- dispose() {
84
- return this.#disposal ??= (async () => {
85
- this.#controller.abort(new Error(`File client disposed: ${this.path}`));
86
- await Promise.allSettled(this.#operations);
87
- })();
88
- }
89
- }
90
-
91
- export { fileClientOptions, BoundedFileClient };