@compforge/harness-toolbox 0.4.1 → 0.4.3

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,69 @@ 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.
64
+
65
+ ## Redis sources
66
+
67
+ `RedisClient` owns resolved configuration and reusable node connections. Use `clusterType` to
68
+ select `single`, `cluster` or `sentinel`; `endpoints` contains that mode's seed addresses. Sentinel
69
+ configuration additionally provides `sentinelMasterName` and optional Sentinel credentials,
70
+ separate from data-node credentials. Cluster connections require database zero.
71
+
72
+ Supply ordered TCP transports through `ConnectionSource`. Only mapping or handshake network
73
+ failures advance to the next transport; authentication and TLS verification failures stop immediately.
74
+ `connectTimeoutMs` bounds each route (mapping and handshake); it defaults to the command
75
+ `timeoutMs`. `acquireTimeoutMs` bounds all candidates together and defaults to the per-route budget
76
+ times the number of routes. A spent total budget does not start another route.
77
+ `onRoute` reports the selected route and the preceding failure code without credentials.
78
+
79
+ `client.topology()` refreshes topology using the same connections and route policy as
80
+ `client.access.connection(endpoint, database)`. A root signal cancels connection preparation and
81
+ active commands. Commands and pipelines are sent once, with no offline queue or automatic replay;
82
+ a subsequent acquisition replaces an unhealthy connection. The caller owns `dispose()` and transport
83
+ cleanup, and diagnostics own sampling policy and collected observations.
60
84
 
61
85
  ## Kubernetes resources
62
86
 
@@ -26,7 +26,7 @@ function sentinelRows(value) {
26
26
  });
27
27
  }
28
28
  async function discoverSentinel(access, config) {
29
- const sentinels = config.sentinelHosts.length ? config.sentinelHosts : config.endpoints;
29
+ const sentinels = config.endpoints;
30
30
  let lastError;
31
31
  for (const sentinel of sentinels) {
32
32
  try {
@@ -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,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 };