@compforge/harness-toolbox 0.4.1 → 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 +31 -27
- package/dist/chunk-grpygk6f.js +157 -0
- package/dist/chunk-kkts16w9.js +131 -0
- package/dist/chunk-sqbew04a.js +65 -0
- package/dist/file/client.d.ts +12 -7
- package/dist/file/client.js +4 -2
- package/dist/file/index.d.ts +1 -1
- package/dist/file/index.js +3 -3
- package/dist/file/local.d.ts +5 -5
- package/dist/file/local.js +2 -2
- package/dist/file/pod.d.ts +7 -6
- package/dist/file/pod.js +2 -2
- package/package.json +2 -2
- package/dist/chunk-ba883sdp.js +0 -60
- package/dist/chunk-mxyvpy79.js +0 -62
- package/dist/chunk-mzdz6wwc.js +0 -91
package/README.md
CHANGED
|
@@ -18,45 +18,49 @@ not load database or Kubernetes drivers.
|
|
|
18
18
|
|
|
19
19
|
## File sources
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
namespace/Pod/container. Both extend Common's
|
|
23
|
-
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
|
@@ -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 };
|
package/dist/file/client.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
10
|
-
constructor(
|
|
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:
|
|
14
|
-
/** @spec
|
|
15
|
-
read(options: FileReadOptions): Promise<
|
|
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
|
}
|
package/dist/file/client.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
BoundedFileClient,
|
|
3
|
-
fileClientOptions
|
|
4
|
-
|
|
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
|
};
|
package/dist/file/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/file/index.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
PodFileClient,
|
|
3
3
|
PodFileDataSource
|
|
4
|
-
} from "../chunk-
|
|
4
|
+
} from "../chunk-grpygk6f.js";
|
|
5
5
|
import {
|
|
6
6
|
LocalFileClient,
|
|
7
7
|
LocalFileDataSource
|
|
8
|
-
} from "../chunk-
|
|
9
|
-
import"../chunk-
|
|
8
|
+
} from "../chunk-sqbew04a.js";
|
|
9
|
+
import"../chunk-kkts16w9.js";
|
|
10
10
|
import {
|
|
11
11
|
FileError
|
|
12
12
|
} from "../chunk-20kv4wc3.js";
|
package/dist/file/local.d.ts
CHANGED
|
@@ -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:
|
|
4
|
+
protected capture(signal: AbortSignal, limits: FileReadOptions, sink: FileCaptureSink): Promise<void>;
|
|
5
5
|
}
|
|
6
|
-
/** @spec Resolve
|
|
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
|
|
9
|
+
readonly paths: readonly string[];
|
|
10
10
|
readonly clientKey: string;
|
|
11
|
-
constructor(
|
|
11
|
+
constructor(paths: readonly string[], options: FileClientOptions);
|
|
12
12
|
createClient(_clients: Pick<ClientManager, "get">, signal: AbortSignal): LocalFileClient;
|
|
13
13
|
}
|
package/dist/file/local.js
CHANGED
package/dist/file/pod.d.ts
CHANGED
|
@@ -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(
|
|
14
|
+
constructor(paths: readonly string[], target: Readonly<PodFileTarget>, options: FileClientOptions, signal: AbortSignal, acquire: () => Promise<PodFileAccess>);
|
|
15
15
|
protected initializeAccess(): Promise<void>;
|
|
16
|
-
|
|
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
|
|
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
|
|
27
|
+
readonly paths: readonly string[];
|
|
27
28
|
readonly target: Readonly<PodFileTarget>;
|
|
28
29
|
readonly clientKey: string;
|
|
29
|
-
constructor(kubernetes: ClientProvider<PodFileAccess>, target: PodFileTarget,
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compforge/harness-toolbox",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Shared infrastructure clients, transports and bounded data capture",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"README.md"
|
|
100
100
|
],
|
|
101
101
|
"dependencies": {
|
|
102
|
-
"@compforge/harness-common": "0.5.
|
|
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",
|
package/dist/chunk-ba883sdp.js
DELETED
|
@@ -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 };
|
package/dist/chunk-mxyvpy79.js
DELETED
|
@@ -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 };
|
package/dist/chunk-mzdz6wwc.js
DELETED
|
@@ -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 };
|