@compforge/harness-toolbox 0.3.10 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +76 -0
  2. package/dist/{chunk-p6k59m3q.js → chunk-0s27bgga.js} +9 -4
  3. package/dist/{chunk-zhkrydeg.js → chunk-20kv4wc3.js} +4 -1
  4. package/dist/chunk-4ms037k2.js +210 -0
  5. package/dist/{chunk-b7f39m4v.js → chunk-8mb6natq.js} +1 -1
  6. package/dist/chunk-ba883sdp.js +60 -0
  7. package/dist/chunk-ftkrjqkc.js +15 -0
  8. package/dist/{chunk-ahc00qs5.js → chunk-h73h2b7y.js} +5 -5
  9. package/dist/chunk-mxyvpy79.js +62 -0
  10. package/dist/chunk-mzdz6wwc.js +91 -0
  11. package/dist/{chunk-y94xq8b4.js → chunk-y529s0mz.js} +26 -11
  12. package/dist/datasource.js +1 -1
  13. package/dist/errors.d.ts +2 -0
  14. package/dist/errors.js +4 -2
  15. package/dist/file/client.d.ts +17 -0
  16. package/dist/file/client.js +10 -0
  17. package/dist/file/index.d.ts +5 -0
  18. package/dist/file/index.js +24 -0
  19. package/dist/file/local.d.ts +13 -0
  20. package/dist/file/local.js +11 -0
  21. package/dist/file/pod.d.ts +31 -0
  22. package/dist/file/pod.js +11 -0
  23. package/dist/kubernetes/client.d.ts +12 -4
  24. package/dist/kubernetes/client.js +6 -4
  25. package/dist/kubernetes/environment.d.ts +2 -2
  26. package/dist/kubernetes/environment.js +5 -4
  27. package/dist/kubernetes/pod-log-datasource.js +1 -1
  28. package/dist/kubernetes/resource-transport.d.ts +19 -0
  29. package/dist/kubernetes/resource-transport.js +10 -0
  30. package/dist/kubernetes/resources.d.ts +45 -5
  31. package/dist/kubernetes/resources.js +4 -5
  32. package/dist/kubernetes/workload.js +2 -2
  33. package/dist/mysql/client.js +3 -3
  34. package/dist/mysql/index.js +12 -12
  35. package/dist/opensearch/client.js +2 -2
  36. package/dist/opensearch/index.js +7 -7
  37. package/dist/s3/datasource.js +1 -1
  38. package/dist/s3/index.js +1 -1
  39. package/package.json +6 -2
  40. package/dist/chunk-fhtbvj0s.js +0 -89
package/README.md CHANGED
@@ -16,6 +16,82 @@ claiming snapshot consistency; callers supply cancellation and decide how to per
16
16
  Protocol adapters have separate entry points; importing lifecycle or transport contracts does
17
17
  not load database or Kubernetes drivers.
18
18
 
19
+ ## File sources
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.
25
+
26
+ ```ts
27
+ import { ClientManager, FileDataSource } from "@compforge/harness-common";
28
+ import { LocalFileDataSource, PodFileDataSource } from "@compforge/harness-toolbox/file";
29
+
30
+ const source = new LocalFileDataSource("./config.yaml", { concurrency: 2 });
31
+ const clients = new ClientManager();
32
+ 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));
37
+ }
38
+ } finally {
39
+ await clients.dispose();
40
+ }
41
+
42
+ // `environment` is a KubernetesEnvironment (or an equivalent ClientProvider).
43
+ // Resolve Workloads first and create one source per chosen Pod/container.
44
+ const podSource = new PodFileDataSource(environment,
45
+ { namespace: "app", pod: "worker-abc", container: "worker" },
46
+ "/etc/app/config.yaml", { concurrency: 2 });
47
+ ```
48
+
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.
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
+
19
95
  ## Workload discovery
20
96
 
21
97
  Borrow a KubernetesClient through KubernetesEnvironment and the shared ClientManager lifecycle, then call
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  KubernetesError
3
- } from "./chunk-zhkrydeg.js";
3
+ } from "./chunk-20kv4wc3.js";
4
4
 
5
5
  // src/kubernetes/workload.ts
6
6
  function failure(message, kind) {
@@ -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;
@@ -12,6 +12,9 @@ class ToolboxError extends Error {
12
12
 
13
13
  class KubernetesError extends ToolboxError {
14
14
  }
15
+
16
+ class FileError extends ToolboxError {
17
+ }
15
18
  function kubernetesHttpError(status) {
16
19
  const kind = {
17
20
  401: "authentication_failed",
@@ -38,4 +41,4 @@ function kubernetesCommandError(result) {
38
41
  return status ? kubernetesHttpError(status) : new KubernetesError("Kubernetes resource request failed", { kind: "operation_failed" });
39
42
  }
40
43
 
41
- export { ToolboxError, KubernetesError, kubernetesHttpError, kubernetesCommandError };
44
+ export { ToolboxError, KubernetesError, FileError, kubernetesHttpError, kubernetesCommandError };
@@ -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,6 +1,6 @@
1
1
  import {
2
2
  ToolboxError
3
- } from "./chunk-zhkrydeg.js";
3
+ } from "./chunk-20kv4wc3.js";
4
4
  import {
5
5
  isConnectionNetworkError
6
6
  } from "./chunk-h8fwx0k7.js";
@@ -0,0 +1,60 @@
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 };
@@ -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 };
@@ -1,3 +1,7 @@
1
+ import {
2
+ queryMysqlViaPod,
3
+ queryMysqlViaPodBatch
4
+ } from "./chunk-m91t44jh.js";
1
5
  import {
2
6
  queryReadonlySession,
3
7
  validateQueryLimits
@@ -5,13 +9,9 @@ import {
5
9
  import {
6
10
  destroyMysqlConnection
7
11
  } from "./chunk-136egyff.js";
8
- import {
9
- queryMysqlViaPod,
10
- queryMysqlViaPodBatch
11
- } from "./chunk-m91t44jh.js";
12
12
  import {
13
13
  ToolboxError
14
- } from "./chunk-zhkrydeg.js";
14
+ } from "./chunk-20kv4wc3.js";
15
15
  import {
16
16
  ConcurrencyPool
17
17
  } from "./chunk-v3hxvnrc.js";
@@ -0,0 +1,62 @@
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 };
@@ -0,0 +1,91 @@
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 };