@compforge/harness-toolbox 0.1.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.
- package/README.md +47 -0
- package/dist/chunk-0rs1aacd.js +127 -0
- package/dist/chunk-1at7zs0z.js +104 -0
- package/dist/chunk-1vfwvgkj.js +104 -0
- package/dist/chunk-205a4vma.js +85 -0
- package/dist/chunk-3dt25p98.js +59 -0
- package/dist/chunk-6fnhzhdb.js +103 -0
- package/dist/chunk-8nm9f0hh.js +92 -0
- package/dist/chunk-ajhdd4hp.js +267 -0
- package/dist/chunk-c6z919qb.js +98 -0
- package/dist/chunk-djk4kmxb.js +0 -0
- package/dist/chunk-hqx7j9xe.js +14 -0
- package/dist/chunk-hv2kx47y.js +92 -0
- package/dist/chunk-j46x7j7c.js +73 -0
- package/dist/chunk-jr9y0ghr.js +157 -0
- package/dist/chunk-mkhyty75.js +166 -0
- package/dist/chunk-mp88s7r7.js +12 -0
- package/dist/chunk-pfar9cm3.js +0 -0
- package/dist/chunk-qq77x9ek.js +34 -0
- package/dist/chunk-s06hs7a8.js +79 -0
- package/dist/chunk-s1sjyem7.js +41 -0
- package/dist/chunk-v3hxvnrc.js +47 -0
- package/dist/chunk-w3a4p6jm.js +124 -0
- package/dist/chunk-xy4g56g4.js +50 -0
- package/dist/chunk-ztenxgjv.js +27 -0
- package/dist/client-manager.d.ts +14 -0
- package/dist/client-manager.js +6 -0
- package/dist/client.d.ts +5 -0
- package/dist/client.js +0 -0
- package/dist/concurrency.d.ts +7 -0
- package/dist/concurrency.js +6 -0
- package/dist/datasource.d.ts +26 -0
- package/dist/datasource.js +10 -0
- package/dist/kubernetes/client-node-pod-log.d.ts +31 -0
- package/dist/kubernetes/client-node-pod-log.js +425 -0
- package/dist/kubernetes/client.d.ts +17 -0
- package/dist/kubernetes/client.js +92 -0
- package/dist/kubernetes/endpoint.d.ts +4 -0
- package/dist/kubernetes/endpoint.js +0 -0
- package/dist/kubernetes/executor.d.ts +45 -0
- package/dist/kubernetes/executor.js +13 -0
- package/dist/kubernetes/log-capture-file.d.ts +12 -0
- package/dist/kubernetes/log-capture-file.js +6 -0
- package/dist/kubernetes/log-capture-plan.d.ts +29 -0
- package/dist/kubernetes/log-capture-plan.js +9 -0
- package/dist/kubernetes/log-timestamp.d.ts +2 -0
- package/dist/kubernetes/log-timestamp.js +6 -0
- package/dist/kubernetes/pod-log-client.d.ts +25 -0
- package/dist/kubernetes/pod-log-client.js +12 -0
- package/dist/kubernetes/pod-log-datasource.d.ts +17 -0
- package/dist/kubernetes/pod-log-datasource.js +31 -0
- package/dist/kubernetes/pod-log.d.ts +52 -0
- package/dist/kubernetes/pod-log.js +121 -0
- package/dist/kubernetes/pod.d.ts +61 -0
- package/dist/kubernetes/pod.js +8 -0
- package/dist/kubernetes/port-forward.d.ts +42 -0
- package/dist/kubernetes/port-forward.js +10 -0
- package/dist/kubernetes/service-port-forward.d.ts +29 -0
- package/dist/kubernetes/service-port-forward.js +11 -0
- package/dist/kubernetes/service.d.ts +31 -0
- package/dist/kubernetes/service.js +17 -0
- package/dist/mysql/client.d.ts +36 -0
- package/dist/mysql/client.js +13 -0
- package/dist/mysql/index.d.ts +3 -0
- package/dist/mysql/index.js +18 -0
- package/dist/mysql/pod.d.ts +7 -0
- package/dist/mysql/pod.js +8 -0
- package/dist/mysql/target.d.ts +12 -0
- package/dist/mysql/target.js +6 -0
- package/dist/mysql/types.d.ts +16 -0
- package/dist/mysql/types.js +1 -0
- package/dist/opensearch/client.d.ts +62 -0
- package/dist/opensearch/client.js +12 -0
- package/dist/opensearch/endpoint.d.ts +16 -0
- package/dist/opensearch/endpoint.js +6 -0
- package/dist/opensearch/index.d.ts +3 -0
- package/dist/opensearch/index.js +17 -0
- package/dist/opensearch/types.d.ts +8 -0
- package/dist/opensearch/types.js +1 -0
- package/dist/process/index.d.ts +13 -0
- package/dist/process/index.js +8 -0
- package/dist/redis/client.d.ts +90 -0
- package/dist/redis/client.js +14 -0
- package/dist/redis/index.d.ts +3 -0
- package/dist/redis/index.js +24 -0
- package/dist/redis/key-stats.d.ts +27 -0
- package/dist/redis/key-stats.js +8 -0
- package/dist/redis/topology.d.ts +23 -0
- package/dist/redis/topology.js +6 -0
- package/dist/transport/index.d.ts +40 -0
- package/dist/transport/index.js +12 -0
- package/package.json +96 -0
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Harness Toolbox
|
|
2
|
+
|
|
3
|
+
Shared infrastructure for diagnostic tools, test runners and business adapters. Use the same
|
|
4
|
+
clients to access Kubernetes, MySQL, Redis and OpenSearch without depending on a particular
|
|
5
|
+
command, plugin protocol, test model or verdict.
|
|
6
|
+
|
|
7
|
+
The package supports Node.js 22+ and Bun. Install with `npm install @compforge/harness-toolbox`.
|
|
8
|
+
Protocol adapters have separate entry points; importing lifecycle or transport contracts does
|
|
9
|
+
not load database or Kubernetes drivers.
|
|
10
|
+
|
|
11
|
+
A `DataSource` identifies a reusable client and constructs it. The `Client` owns initialization
|
|
12
|
+
and idempotent disposal; a `ClientManager` joins concurrent initialization for the same key and
|
|
13
|
+
closes owned clients when the caller finishes. A `ConnectionSource` resolves connection settings
|
|
14
|
+
and available `Transport` paths. Clients execute protocol operations through those paths.
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
import { ClientManager, dataSourceKey, type DataSource } from "@compforge/harness-toolbox";
|
|
18
|
+
import { MysqlClient } from "@compforge/harness-toolbox/mysql";
|
|
19
|
+
import { DirectTransport } from "@compforge/harness-toolbox/transport";
|
|
20
|
+
|
|
21
|
+
const target = { host: "localhost", port: 3306, database: "app", user: "reader", password: "..." };
|
|
22
|
+
const source: DataSource<MysqlClient> = {
|
|
23
|
+
key: dataSourceKey("mysql", target),
|
|
24
|
+
createClient: signal => new MysqlClient({
|
|
25
|
+
resolve: async () => target,
|
|
26
|
+
transports: [new DirectTransport()],
|
|
27
|
+
}, { signal, connectTimeoutMs: 5_000, queryTimeoutMs: 10_000 }),
|
|
28
|
+
};
|
|
29
|
+
const clients = new ClientManager();
|
|
30
|
+
try {
|
|
31
|
+
const db = await clients.get(source);
|
|
32
|
+
const rows = await db.database.query(db.target, "SELECT 1 AS value", []);
|
|
33
|
+
} finally {
|
|
34
|
+
await clients.dispose();
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Callers own credentials, authorization, query semantics, capacity and timeouts. MySQL can use
|
|
39
|
+
TCP or a Python-capable Pod; the latter needs PyMySQL. Transport fallback occurs only when
|
|
40
|
+
establishing a connection, never by replaying a failed SQL operation. Pod inputs use stdin.
|
|
41
|
+
|
|
42
|
+
Pod log capture shares an identical Pod/container instance and absolute time window once per
|
|
43
|
+
client, while consumers filter independently and retain their own raw copies. Supply an explicit
|
|
44
|
+
capture policy, shared concurrency pool and byte budget; the package does not choose product
|
|
45
|
+
limits. Temporary capture files live until client disposal.
|
|
46
|
+
|
|
47
|
+
See [toolbox contracts](../../../docs/toolbox.md) for ownership and cross-language semantics.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import {
|
|
2
|
+
spawnProcess
|
|
3
|
+
} from "./chunk-ztenxgjv.js";
|
|
4
|
+
|
|
5
|
+
// src/kubernetes/executor.ts
|
|
6
|
+
function buildKubectlArgs(opts, sub) {
|
|
7
|
+
const args = ["kubectl"];
|
|
8
|
+
if (opts.kubeconfig)
|
|
9
|
+
args.push("--kubeconfig", opts.kubeconfig);
|
|
10
|
+
if (opts.context)
|
|
11
|
+
args.push("--context", opts.context);
|
|
12
|
+
if (opts.namespace)
|
|
13
|
+
args.push("-n", opts.namespace);
|
|
14
|
+
return [...args, ...sub];
|
|
15
|
+
}
|
|
16
|
+
function buildExecArgs(opts, target, cmd, interactive) {
|
|
17
|
+
const sub = ["exec"];
|
|
18
|
+
if (interactive)
|
|
19
|
+
sub.push("-i");
|
|
20
|
+
sub.push(target.pod);
|
|
21
|
+
if (target.container)
|
|
22
|
+
sub.push("-c", target.container);
|
|
23
|
+
sub.push("--", ...cmd);
|
|
24
|
+
return buildKubectlArgs(opts, sub);
|
|
25
|
+
}
|
|
26
|
+
var DEFAULT_TIMEOUT_MS = 30000;
|
|
27
|
+
async function readOutput(stream, onChunk, onBytes, collect = true) {
|
|
28
|
+
const reader = stream.getReader();
|
|
29
|
+
const decoder = collect || onChunk ? new TextDecoder : undefined;
|
|
30
|
+
let output = "";
|
|
31
|
+
while (true) {
|
|
32
|
+
const { done, value } = await reader.read();
|
|
33
|
+
if (done)
|
|
34
|
+
break;
|
|
35
|
+
onBytes?.(value);
|
|
36
|
+
if (decoder) {
|
|
37
|
+
const chunk = decoder.decode(value, { stream: true });
|
|
38
|
+
if (collect)
|
|
39
|
+
output += chunk;
|
|
40
|
+
onChunk?.(chunk);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const tail = decoder?.decode() ?? "";
|
|
44
|
+
if (collect)
|
|
45
|
+
output += tail;
|
|
46
|
+
if (tail)
|
|
47
|
+
onChunk?.(tail);
|
|
48
|
+
return output;
|
|
49
|
+
}
|
|
50
|
+
async function runArgv(argv, opts) {
|
|
51
|
+
const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
52
|
+
const started = Date.now();
|
|
53
|
+
let proc;
|
|
54
|
+
try {
|
|
55
|
+
proc = spawnProcess(argv, { stdin: opts?.stdin });
|
|
56
|
+
} catch (err) {
|
|
57
|
+
return {
|
|
58
|
+
ok: false,
|
|
59
|
+
exitCode: null,
|
|
60
|
+
stdout: "",
|
|
61
|
+
stderr: err instanceof Error ? err.message : String(err),
|
|
62
|
+
durationMs: Date.now() - started,
|
|
63
|
+
timedOut: false,
|
|
64
|
+
command: argv
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
let timedOut = false;
|
|
68
|
+
let aborted = false;
|
|
69
|
+
const abort = () => {
|
|
70
|
+
aborted = true;
|
|
71
|
+
proc.kill();
|
|
72
|
+
};
|
|
73
|
+
const timer = setTimeout(() => {
|
|
74
|
+
timedOut = true;
|
|
75
|
+
proc.kill();
|
|
76
|
+
}, timeoutMs);
|
|
77
|
+
if (opts?.signal?.aborted)
|
|
78
|
+
abort();
|
|
79
|
+
else
|
|
80
|
+
opts?.signal?.addEventListener("abort", abort, { once: true });
|
|
81
|
+
try {
|
|
82
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
83
|
+
readOutput(proc.stdout, opts?.onStdout, opts?.onStdoutBytes, opts?.collectStdout ?? true),
|
|
84
|
+
readOutput(proc.stderr, opts?.onStderr),
|
|
85
|
+
proc.exited
|
|
86
|
+
]);
|
|
87
|
+
return {
|
|
88
|
+
ok: exitCode === 0 && !timedOut,
|
|
89
|
+
exitCode,
|
|
90
|
+
stdout,
|
|
91
|
+
stderr: timedOut ? `${stderr}
|
|
92
|
+
[timeout after ${timeoutMs}ms]`.trim() : aborted ? `${stderr}
|
|
93
|
+
[aborted]`.trim() : stderr,
|
|
94
|
+
durationMs: Date.now() - started,
|
|
95
|
+
timedOut,
|
|
96
|
+
command: argv
|
|
97
|
+
};
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
exitCode: null,
|
|
102
|
+
stdout: "",
|
|
103
|
+
stderr: err instanceof Error ? err.message : String(err),
|
|
104
|
+
durationMs: Date.now() - started,
|
|
105
|
+
timedOut,
|
|
106
|
+
command: argv
|
|
107
|
+
};
|
|
108
|
+
} finally {
|
|
109
|
+
clearTimeout(timer);
|
|
110
|
+
opts?.signal?.removeEventListener("abort", abort);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
class KubectlExecutor {
|
|
115
|
+
opts;
|
|
116
|
+
constructor(opts) {
|
|
117
|
+
this.opts = opts;
|
|
118
|
+
}
|
|
119
|
+
run(sub, opts) {
|
|
120
|
+
return runArgv(buildKubectlArgs(this.opts, sub), opts);
|
|
121
|
+
}
|
|
122
|
+
exec(target, cmd, opts) {
|
|
123
|
+
return runArgv(buildExecArgs(this.opts, target, cmd, opts?.stdin !== undefined), opts);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { buildKubectlArgs, buildExecArgs, runArgv, KubectlExecutor };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PodLogByteBudget,
|
|
3
|
+
runPodLogCapturePlan
|
|
4
|
+
} from "./chunk-8nm9f0hh.js";
|
|
5
|
+
import {
|
|
6
|
+
logTimestampNanos
|
|
7
|
+
} from "./chunk-mp88s7r7.js";
|
|
8
|
+
import {
|
|
9
|
+
LogCaptureFile
|
|
10
|
+
} from "./chunk-1vfwvgkj.js";
|
|
11
|
+
import {
|
|
12
|
+
dataSourceKey
|
|
13
|
+
} from "./chunk-hqx7j9xe.js";
|
|
14
|
+
|
|
15
|
+
// src/kubernetes/pod-log-client.ts
|
|
16
|
+
import { copyFileSync, mkdtempSync, rmSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
class PodLogClient {
|
|
21
|
+
pool;
|
|
22
|
+
signal;
|
|
23
|
+
policy;
|
|
24
|
+
#sources = new Map;
|
|
25
|
+
#consumers = new Set;
|
|
26
|
+
#budget;
|
|
27
|
+
#directory;
|
|
28
|
+
#disposal;
|
|
29
|
+
constructor(pool, signal, policy, budget = new PodLogByteBudget(policy.maxTotalBytes)) {
|
|
30
|
+
this.pool = pool;
|
|
31
|
+
this.signal = signal;
|
|
32
|
+
this.policy = policy;
|
|
33
|
+
this.#budget = budget;
|
|
34
|
+
}
|
|
35
|
+
async initialize() {
|
|
36
|
+
this.signal.throwIfAborted();
|
|
37
|
+
this.#directory ??= mkdtempSync(join(tmpdir(), "harness-log-sources-"));
|
|
38
|
+
}
|
|
39
|
+
capture(access, scope, request, onReuse) {
|
|
40
|
+
this.signal.throwIfAborted();
|
|
41
|
+
if (!this.#directory || this.#disposal)
|
|
42
|
+
throw new Error("Log capture session is not active");
|
|
43
|
+
const key = scope.instance && request.sinceTime ? dataSourceKey("pod-log", {
|
|
44
|
+
scope,
|
|
45
|
+
pod: request.pod,
|
|
46
|
+
container: request.container,
|
|
47
|
+
previous: !!request.previous,
|
|
48
|
+
allContainers: !!request.allContainers,
|
|
49
|
+
prefix: !!request.prefix,
|
|
50
|
+
tail: request.tail,
|
|
51
|
+
sinceTime: logTimestampNanos(request.sinceTime)?.toString(),
|
|
52
|
+
untilTime: request.untilTime ? logTimestampNanos(request.untilTime)?.toString() : undefined,
|
|
53
|
+
limitBytes: request.limitBytes
|
|
54
|
+
}) : randomUUID();
|
|
55
|
+
let source = this.#sources.get(key);
|
|
56
|
+
const reused = source !== undefined;
|
|
57
|
+
if (!source) {
|
|
58
|
+
const file = new LogCaptureFile(join(this.#directory, `${randomUUID()}.log`));
|
|
59
|
+
const result = Promise.resolve().then(async () => {
|
|
60
|
+
try {
|
|
61
|
+
const captures = await runPodLogCapturePlan(access, [{ target: undefined, request: {
|
|
62
|
+
...request,
|
|
63
|
+
rawFilePath: undefined,
|
|
64
|
+
collectStdout: false,
|
|
65
|
+
onLine: (line) => file.append(line)
|
|
66
|
+
} }], this.policy, this.pool, this.signal, this.#budget);
|
|
67
|
+
const capture = captures[0]?.capture;
|
|
68
|
+
return capture;
|
|
69
|
+
} finally {
|
|
70
|
+
file.finish();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
result.catch(() => {});
|
|
74
|
+
source = { file, result };
|
|
75
|
+
this.#sources.set(key, source);
|
|
76
|
+
} else
|
|
77
|
+
onReuse?.();
|
|
78
|
+
const pending = this.#consume(source, request, reused);
|
|
79
|
+
this.#consumers.add(pending);
|
|
80
|
+
pending.then(() => this.#consumers.delete(pending), () => this.#consumers.delete(pending));
|
|
81
|
+
return pending;
|
|
82
|
+
}
|
|
83
|
+
async#consume(source, request, reused) {
|
|
84
|
+
const started = Date.now();
|
|
85
|
+
await source.file.follow(request.onLine ?? (() => {}));
|
|
86
|
+
if (request.rawFilePath)
|
|
87
|
+
copyFileSync(source.file.path, request.rawFilePath);
|
|
88
|
+
const result = await source.result;
|
|
89
|
+
return result && { ...result, durationMs: Date.now() - started, bytesRead: reused ? 0 : result.bytesRead, reused };
|
|
90
|
+
}
|
|
91
|
+
dispose() {
|
|
92
|
+
return this.#disposal ??= (async () => {
|
|
93
|
+
await Promise.allSettled([...this.#sources.values()].map((source) => source.result));
|
|
94
|
+
await Promise.allSettled(this.#consumers);
|
|
95
|
+
for (const source of this.#sources.values())
|
|
96
|
+
source.file.close();
|
|
97
|
+
this.#sources.clear();
|
|
98
|
+
if (this.#directory)
|
|
99
|
+
rmSync(this.#directory, { recursive: true, force: true });
|
|
100
|
+
})();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export { PodLogClient };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// src/kubernetes/log-capture-file.ts
|
|
2
|
+
import { closeSync, openSync, readSync, writeSync } from "node:fs";
|
|
3
|
+
import { setImmediate } from "node:timers/promises";
|
|
4
|
+
var BLOCK_BYTES = 64 * 1024;
|
|
5
|
+
|
|
6
|
+
class LogCaptureFile {
|
|
7
|
+
path;
|
|
8
|
+
#fd;
|
|
9
|
+
#pending = [];
|
|
10
|
+
#pendingBytes = 0;
|
|
11
|
+
#flushed = 0;
|
|
12
|
+
#finished = false;
|
|
13
|
+
#waiters = new Set;
|
|
14
|
+
constructor(path) {
|
|
15
|
+
this.path = path;
|
|
16
|
+
}
|
|
17
|
+
get size() {
|
|
18
|
+
return this.#flushed + this.#pendingBytes;
|
|
19
|
+
}
|
|
20
|
+
append(line) {
|
|
21
|
+
const chunk = Buffer.from(`${line}
|
|
22
|
+
`);
|
|
23
|
+
this.#pending.push(chunk);
|
|
24
|
+
this.#pendingBytes += chunk.length;
|
|
25
|
+
if (this.#pendingBytes >= BLOCK_BYTES)
|
|
26
|
+
this.#flush();
|
|
27
|
+
this.#wake();
|
|
28
|
+
}
|
|
29
|
+
#flush() {
|
|
30
|
+
if (!this.#pendingBytes)
|
|
31
|
+
return;
|
|
32
|
+
this.#fd ??= openSync(this.path, "w+", 384);
|
|
33
|
+
const bytes = Buffer.concat(this.#pending, this.#pendingBytes);
|
|
34
|
+
let offset = 0;
|
|
35
|
+
while (offset < bytes.length)
|
|
36
|
+
offset += writeSync(this.#fd, bytes, offset, bytes.length - offset);
|
|
37
|
+
this.#flushed += bytes.length;
|
|
38
|
+
this.#pending = [];
|
|
39
|
+
this.#pendingBytes = 0;
|
|
40
|
+
}
|
|
41
|
+
#wake() {
|
|
42
|
+
for (const resolve of this.#waiters)
|
|
43
|
+
resolve();
|
|
44
|
+
this.#waiters.clear();
|
|
45
|
+
}
|
|
46
|
+
finish() {
|
|
47
|
+
try {
|
|
48
|
+
this.#flush();
|
|
49
|
+
if (this.#flushed === 0)
|
|
50
|
+
this.#fd ??= openSync(this.path, "w+", 384);
|
|
51
|
+
} finally {
|
|
52
|
+
this.close();
|
|
53
|
+
this.#finished = true;
|
|
54
|
+
this.#wake();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async follow(onLine) {
|
|
58
|
+
let cursor = 0;
|
|
59
|
+
let pending = "";
|
|
60
|
+
const decoder = new TextDecoder;
|
|
61
|
+
const consume = (text) => {
|
|
62
|
+
const lines = (pending + text).split(`
|
|
63
|
+
`);
|
|
64
|
+
pending = lines.pop();
|
|
65
|
+
for (const line of lines)
|
|
66
|
+
onLine(line);
|
|
67
|
+
};
|
|
68
|
+
while (true) {
|
|
69
|
+
if (cursor < this.#flushed) {
|
|
70
|
+
const buffer = Buffer.allocUnsafe(Math.min(BLOCK_BYTES, this.#flushed - cursor));
|
|
71
|
+
const fd = this.#fd ?? openSync(this.path, "r");
|
|
72
|
+
let count;
|
|
73
|
+
try {
|
|
74
|
+
count = readSync(fd, buffer, 0, buffer.length, cursor);
|
|
75
|
+
} finally {
|
|
76
|
+
if (this.#fd === undefined)
|
|
77
|
+
closeSync(fd);
|
|
78
|
+
}
|
|
79
|
+
cursor += count;
|
|
80
|
+
consume(decoder.decode(buffer.subarray(0, count), { stream: true }));
|
|
81
|
+
await setImmediate();
|
|
82
|
+
} else if (cursor < this.size) {
|
|
83
|
+
const buffer = Buffer.concat(this.#pending, this.#pendingBytes).subarray(cursor - this.#flushed);
|
|
84
|
+
cursor += buffer.length;
|
|
85
|
+
consume(decoder.decode(buffer, { stream: true }));
|
|
86
|
+
} else if (this.#finished) {
|
|
87
|
+
consume(decoder.decode());
|
|
88
|
+
if (pending)
|
|
89
|
+
onLine(pending);
|
|
90
|
+
return;
|
|
91
|
+
} else {
|
|
92
|
+
await new Promise((resolve) => this.#waiters.add(resolve));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
close() {
|
|
97
|
+
if (this.#fd !== undefined) {
|
|
98
|
+
closeSync(this.#fd);
|
|
99
|
+
this.#fd = undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export { LogCaptureFile };
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {
|
|
2
|
+
PortForwardScope
|
|
3
|
+
} from "./chunk-hv2kx47y.js";
|
|
4
|
+
import {
|
|
5
|
+
findPodsForService,
|
|
6
|
+
findService,
|
|
7
|
+
listServiceNetwork
|
|
8
|
+
} from "./chunk-j46x7j7c.js";
|
|
9
|
+
import {
|
|
10
|
+
findPod
|
|
11
|
+
} from "./chunk-6fnhzhdb.js";
|
|
12
|
+
|
|
13
|
+
// src/kubernetes/service-port-forward.ts
|
|
14
|
+
class ServicePortForwarder {
|
|
15
|
+
opts;
|
|
16
|
+
services;
|
|
17
|
+
pods;
|
|
18
|
+
#cache = new Map;
|
|
19
|
+
#scope;
|
|
20
|
+
constructor(opts, services, pods, starter) {
|
|
21
|
+
this.opts = opts;
|
|
22
|
+
this.services = services;
|
|
23
|
+
this.pods = pods;
|
|
24
|
+
this.#scope = new PortForwardScope(starter);
|
|
25
|
+
}
|
|
26
|
+
static async create(executor, opts, starter) {
|
|
27
|
+
const network = await listServiceNetwork(executor, opts.namespace);
|
|
28
|
+
return new ServicePortForwarder(opts, network.services, network.pods, starter);
|
|
29
|
+
}
|
|
30
|
+
get activeForwards() {
|
|
31
|
+
return this.#scope.active;
|
|
32
|
+
}
|
|
33
|
+
forward(endpoint) {
|
|
34
|
+
const key = `${endpoint.host}:${endpoint.port}`;
|
|
35
|
+
let pending = this.#cache.get(key);
|
|
36
|
+
if (!pending) {
|
|
37
|
+
pending = this.#forward(endpoint);
|
|
38
|
+
this.#cache.set(key, pending);
|
|
39
|
+
}
|
|
40
|
+
return pending;
|
|
41
|
+
}
|
|
42
|
+
async forwardServiceTargets(endpoint) {
|
|
43
|
+
const service = findService(this.services, endpoint, this.opts.namespace);
|
|
44
|
+
if (!service)
|
|
45
|
+
return [await this.forward(endpoint)];
|
|
46
|
+
const pods = findPodsForService(this.services, this.pods, service.name, service.namespace);
|
|
47
|
+
if (!pods.length)
|
|
48
|
+
return [await this.forward(endpoint)];
|
|
49
|
+
const servicePort = service.ports.find((port) => port.port === endpoint.port);
|
|
50
|
+
if (typeof servicePort?.targetPort === "string")
|
|
51
|
+
return [await this.forward(endpoint)];
|
|
52
|
+
const remotePort = servicePort?.targetPort ?? endpoint.port;
|
|
53
|
+
const results = await Promise.allSettled(pods.map(async (pod) => ({
|
|
54
|
+
...await this.forward({ host: pod.name, port: remotePort }),
|
|
55
|
+
pod: pod.name
|
|
56
|
+
})));
|
|
57
|
+
const targets = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
|
|
58
|
+
if (targets.length)
|
|
59
|
+
return targets;
|
|
60
|
+
throw results.find((result) => result.status === "rejected")?.reason;
|
|
61
|
+
}
|
|
62
|
+
async#forward(endpoint) {
|
|
63
|
+
const service = findService(this.services, endpoint, this.opts.namespace);
|
|
64
|
+
const pod = service ? undefined : findPod(this.pods, endpoint, this.opts.namespace);
|
|
65
|
+
const target = service ? { kind: "service", name: service.name, namespace: service.namespace } : pod ? { kind: "pod", name: pod.name, namespace: pod.namespace } : undefined;
|
|
66
|
+
if (!target)
|
|
67
|
+
return endpoint;
|
|
68
|
+
const result = await this.#scope.start({
|
|
69
|
+
...this.opts,
|
|
70
|
+
namespace: target.namespace,
|
|
71
|
+
target: { kind: target.kind, name: target.name },
|
|
72
|
+
remotePort: endpoint.port
|
|
73
|
+
});
|
|
74
|
+
if (!result.value) {
|
|
75
|
+
throw new Error(`为 ${target.kind}/${target.name} 建立 port-forward 失败:${result.reason ?? "unknown error"}`);
|
|
76
|
+
}
|
|
77
|
+
return { host: "127.0.0.1", port: result.value.localPort, servername: endpoint.host };
|
|
78
|
+
}
|
|
79
|
+
stop() {
|
|
80
|
+
this.#scope.stop();
|
|
81
|
+
this.#cache.clear();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export { ServicePortForwarder };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// src/client-manager.ts
|
|
2
|
+
class ClientManager {
|
|
3
|
+
#controller = new AbortController;
|
|
4
|
+
signal;
|
|
5
|
+
#clients = new Map;
|
|
6
|
+
#ready = [];
|
|
7
|
+
#disposal;
|
|
8
|
+
constructor(signal) {
|
|
9
|
+
this.signal = signal ? AbortSignal.any([signal, this.#controller.signal]) : this.#controller.signal;
|
|
10
|
+
}
|
|
11
|
+
get(source) {
|
|
12
|
+
this.signal.throwIfAborted();
|
|
13
|
+
const existing = this.#clients.get(source.key);
|
|
14
|
+
if (existing)
|
|
15
|
+
return existing;
|
|
16
|
+
const pending = Promise.resolve().then(async () => {
|
|
17
|
+
this.signal.throwIfAborted();
|
|
18
|
+
const client = source.createClient(this.signal);
|
|
19
|
+
try {
|
|
20
|
+
await client.initialize();
|
|
21
|
+
this.signal.throwIfAborted();
|
|
22
|
+
this.#ready.push(client);
|
|
23
|
+
return client;
|
|
24
|
+
} catch (error) {
|
|
25
|
+
try {
|
|
26
|
+
await client.dispose();
|
|
27
|
+
} catch (cleanup) {
|
|
28
|
+
throw new AggregateError([error, cleanup], "Client initialization and cleanup failed");
|
|
29
|
+
}
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
this.#clients.set(source.key, pending);
|
|
34
|
+
pending.catch(() => {
|
|
35
|
+
if (this.#clients.get(source.key) === pending)
|
|
36
|
+
this.#clients.delete(source.key);
|
|
37
|
+
});
|
|
38
|
+
return pending;
|
|
39
|
+
}
|
|
40
|
+
dispose() {
|
|
41
|
+
return this.#disposal ??= (async () => {
|
|
42
|
+
this.#controller.abort(new Error("Client manager disposed"));
|
|
43
|
+
const settled = await Promise.allSettled(this.#clients.values());
|
|
44
|
+
this.#clients.clear();
|
|
45
|
+
const errors = settled.flatMap((result) => result.status === "rejected" && result.reason instanceof AggregateError ? [result.reason] : []);
|
|
46
|
+
for (const client of this.#ready.splice(0).reverse()) {
|
|
47
|
+
try {
|
|
48
|
+
await client.dispose();
|
|
49
|
+
} catch (error) {
|
|
50
|
+
errors.push(error);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (errors.length)
|
|
54
|
+
throw new AggregateError(errors, "Client cleanup failed");
|
|
55
|
+
})();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { ClientManager };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// src/kubernetes/pod.ts
|
|
2
|
+
function termination(raw) {
|
|
3
|
+
if (!raw)
|
|
4
|
+
return;
|
|
5
|
+
return {
|
|
6
|
+
exitCode: raw.exitCode,
|
|
7
|
+
signal: raw.signal,
|
|
8
|
+
reason: raw.reason,
|
|
9
|
+
message: raw.message,
|
|
10
|
+
startedAt: raw.startedAt,
|
|
11
|
+
finishedAt: raw.finishedAt,
|
|
12
|
+
containerId: raw.containerID
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
function containerState(raw) {
|
|
16
|
+
if (raw?.waiting) {
|
|
17
|
+
return { kind: "waiting", reason: raw.waiting.reason, message: raw.waiting.message };
|
|
18
|
+
}
|
|
19
|
+
if (raw?.running)
|
|
20
|
+
return { kind: "running", startedAt: raw.running.startedAt };
|
|
21
|
+
const terminated = termination(raw?.terminated);
|
|
22
|
+
return terminated ? { kind: "terminated", ...terminated } : undefined;
|
|
23
|
+
}
|
|
24
|
+
function parsePods(raw, defaultNamespace) {
|
|
25
|
+
const value = JSON.parse(raw);
|
|
26
|
+
return (value.items ?? []).flatMap((item) => {
|
|
27
|
+
const name = item.metadata?.name;
|
|
28
|
+
if (!name)
|
|
29
|
+
return [];
|
|
30
|
+
const statusByName = new Map((item.status?.containerStatuses ?? []).flatMap((container) => {
|
|
31
|
+
const containerName = container.name?.trim();
|
|
32
|
+
return containerName ? [[containerName, container]] : [];
|
|
33
|
+
}));
|
|
34
|
+
const declaredContainers = (item.spec?.containers ?? []).flatMap((container) => {
|
|
35
|
+
const containerName = container.name?.trim();
|
|
36
|
+
if (!containerName)
|
|
37
|
+
return [];
|
|
38
|
+
const status = statusByName.get(containerName);
|
|
39
|
+
const ports = (container.ports ?? []).flatMap((port) => Number.isInteger(port.containerPort) ? [{ name: port.name, containerPort: port.containerPort }] : []);
|
|
40
|
+
return [{
|
|
41
|
+
name: containerName,
|
|
42
|
+
image: container.image?.trim() ?? "",
|
|
43
|
+
imageId: status?.imageID?.trim() || undefined,
|
|
44
|
+
...status?.containerID ? { containerId: status.containerID } : {},
|
|
45
|
+
...ports.length ? { ports } : {},
|
|
46
|
+
requests: container.resources?.requests ?? {},
|
|
47
|
+
limits: container.resources?.limits ?? {},
|
|
48
|
+
ready: status?.ready,
|
|
49
|
+
started: status?.started,
|
|
50
|
+
restartCount: status?.restartCount ?? 0,
|
|
51
|
+
state: containerState(status?.state),
|
|
52
|
+
lastTermination: termination(status?.lastState?.terminated),
|
|
53
|
+
hasPreviousTerminated: !!status?.lastState?.terminated?.containerID
|
|
54
|
+
}];
|
|
55
|
+
});
|
|
56
|
+
return [{
|
|
57
|
+
kind: "pod",
|
|
58
|
+
namespace: item.metadata?.namespace ?? defaultNamespace,
|
|
59
|
+
name,
|
|
60
|
+
...item.metadata?.uid ? { uid: item.metadata.uid } : {},
|
|
61
|
+
serviceAccountName: item.spec?.serviceAccountName?.trim() || item.spec?.serviceAccount?.trim() || "default",
|
|
62
|
+
ip: item.status?.podIP,
|
|
63
|
+
phase: item.status?.phase ?? "Unknown",
|
|
64
|
+
reason: item.status?.reason,
|
|
65
|
+
message: item.status?.message,
|
|
66
|
+
conditions: (item.status?.conditions ?? []).flatMap((condition) => condition.type && condition.status ? [{
|
|
67
|
+
type: condition.type,
|
|
68
|
+
status: condition.status,
|
|
69
|
+
reason: condition.reason,
|
|
70
|
+
message: condition.message,
|
|
71
|
+
lastTransitionTime: condition.lastTransitionTime
|
|
72
|
+
}] : []),
|
|
73
|
+
labels: item.metadata?.labels ?? {},
|
|
74
|
+
containers: declaredContainers.length > 0 ? declaredContainers : (item.status?.containerStatuses ?? []).flatMap((container) => {
|
|
75
|
+
const containerName = container.name?.trim();
|
|
76
|
+
if (!containerName)
|
|
77
|
+
return [];
|
|
78
|
+
return [{
|
|
79
|
+
name: containerName,
|
|
80
|
+
image: "",
|
|
81
|
+
imageId: container.imageID?.trim() || undefined,
|
|
82
|
+
...container.containerID ? { containerId: container.containerID } : {},
|
|
83
|
+
requests: {},
|
|
84
|
+
limits: {},
|
|
85
|
+
ready: container.ready,
|
|
86
|
+
started: container.started,
|
|
87
|
+
restartCount: container.restartCount ?? 0,
|
|
88
|
+
state: containerState(container.state),
|
|
89
|
+
lastTermination: termination(container.lastState?.terminated),
|
|
90
|
+
hasPreviousTerminated: !!container.lastState?.terminated?.containerID
|
|
91
|
+
}];
|
|
92
|
+
})
|
|
93
|
+
}];
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
function findPod(pods, endpoint, defaultNamespace) {
|
|
97
|
+
const host = endpoint.host.replace(/\.$/, "");
|
|
98
|
+
const labels = host.split(".");
|
|
99
|
+
const dnsNamespace = labels.length >= 4 && labels[3] === "svc" ? labels[2] : undefined;
|
|
100
|
+
return pods.find((pod) => pod.namespace === (dnsNamespace ?? defaultNamespace) && (pod.ip === host || pod.name === host || host.startsWith(`${pod.name}.`)));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export { parsePods, findPod };
|