@compforge/harness-toolbox 0.1.1 → 0.3.1

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 (45) hide show
  1. package/README.md +79 -4
  2. package/dist/{chunk-9j36zvn4.js → chunk-0cq1gx86.js} +5 -5
  3. package/dist/chunk-1kpbvat9.js +89 -0
  4. package/dist/chunk-4pdwsfcx.js +111 -0
  5. package/dist/chunk-a9htb6cr.js +4 -0
  6. package/dist/{chunk-sry9e02n.js → chunk-jkpwa8tb.js} +17 -0
  7. package/dist/chunk-kbewz29a.js +95 -0
  8. package/dist/{chunk-1at7zs0z.js → chunk-mnad4zkp.js} +3 -3
  9. package/dist/chunk-ntfm5e85.js +27 -0
  10. package/dist/chunk-y3dx8jh6.js +94 -0
  11. package/dist/chunk-z5pm2emz.js +4 -0
  12. package/dist/client-manager.d.ts +1 -14
  13. package/dist/client-manager.js +2 -3
  14. package/dist/client.d.ts +1 -5
  15. package/dist/client.js +6 -0
  16. package/dist/datasource.d.ts +2 -11
  17. package/dist/datasource.js +11 -5
  18. package/dist/errors.d.ts +14 -0
  19. package/dist/errors.js +10 -0
  20. package/dist/kubernetes/client.d.ts +6 -1
  21. package/dist/kubernetes/client.js +9 -85
  22. package/dist/kubernetes/environment.d.ts +19 -0
  23. package/dist/kubernetes/environment.js +43 -0
  24. package/dist/kubernetes/pod-log-client.js +2 -3
  25. package/dist/kubernetes/pod-log-datasource.d.ts +3 -3
  26. package/dist/kubernetes/pod-log-datasource.js +8 -7
  27. package/dist/kubernetes/resources.d.ts +23 -0
  28. package/dist/kubernetes/resources.js +8 -0
  29. package/dist/kubernetes/service-port-forward.js +2 -2
  30. package/dist/kubernetes/workload.d.ts +7 -0
  31. package/dist/kubernetes/workload.js +7 -0
  32. package/dist/mysql/client.d.ts +4 -2
  33. package/dist/mysql/client.js +2 -1
  34. package/dist/mysql/index.js +2 -1
  35. package/dist/mysql/readonly.d.ts +9 -0
  36. package/dist/mysql/readonly.js +8 -0
  37. package/dist/mysql/types.d.ts +13 -0
  38. package/dist/redis/index.js +3 -3
  39. package/dist/s3/datasource.d.ts +3 -3
  40. package/dist/s3/datasource.js +3 -3
  41. package/dist/s3/index.js +3 -3
  42. package/package.json +6 -1
  43. package/dist/chunk-3dt25p98.js +0 -59
  44. package/dist/chunk-hqx7j9xe.js +0 -14
  45. package/dist/{chunk-p097seg7.js → chunk-205a4vma.js} +3 -3
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Harness Toolbox
2
2
 
3
+ Client, ClientProvider, DataSource, EnvironmentContext and ClientManager are owned by the sibling TypeScript package
4
+ `@compforge/harness-common`. Toolbox provides protocol implementations and transports;
5
+ its lifecycle entrypoints re-export the same common symbols.
6
+
3
7
  Shared infrastructure for diagnostic tools, test runners and business adapters. Use the same
4
8
  clients to access Kubernetes, MySQL, Redis, OpenSearch and S3-compatible object storage without depending on a particular
5
9
  command, plugin protocol, test model or verdict.
@@ -8,20 +12,77 @@ The package supports Node.js 22+ and Bun. Install with `npm install @compforge/h
8
12
  Protocol adapters have separate entry points; importing lifecycle or transport contracts does
9
13
  not load database or Kubernetes drivers.
10
14
 
11
- A `DataSource` identifies a reusable client and constructs it. The `Client` owns initialization
15
+ ## Workload discovery
16
+
17
+ Borrow a KubernetesClient through KubernetesEnvironment and the shared ClientManager lifecycle, then call
18
+ `client.resolveWorkload(workload, environmentId)`. The ID is a stable target identity from your
19
+ environment registry, not a display name or kubeconfig path. The Workload's namespace overrides the client's default.
20
+ Resource, Service selector and labels locations return all matching Pod incarnations without
21
+ readiness filtering; successful empty discovery returns an empty array. Missing resources,
22
+ selectorless Services and failed access raise KubernetesError.
23
+
24
+ The resource path uses client-node kubeconfig authentication/TLS and bounded native JSON reads,
25
+ not kubectl error text. Configure request timeout, concurrency and response bytes with the
26
+ client's ResourceLimits constructor argument. Existing exec/port-forward operations remain unchanged.
27
+ See [the shared Workload model](../../../docs/workload.md).
28
+
29
+ ```ts
30
+ import { ClientManager, EnvironmentContext } from "@compforge/harness-toolbox";
31
+ import { KubernetesEnvironment } from "@compforge/harness-toolbox/kubernetes/environment";
32
+
33
+ const clients = new ClientManager();
34
+ try {
35
+ const ctx = new EnvironmentContext(
36
+ new KubernetesEnvironment("dev",
37
+ { kubeconfig: "/config/cluster", context: "dev", namespace: "app" },
38
+ { timeoutMs: 10_000, concurrency: 4, maxBytes: 8 * 1024 * 1024 }),
39
+ clients, performance.now() + 30_000,
40
+ );
41
+ const kube = await ctx.clients.get(ctx.environment);
42
+ const pods = await kube.resolveWorkload({
43
+ name: "api", platform: "kubernetes",
44
+ location: { kind: "resource", resource_kind: "Deployment", name: "api" },
45
+ }, "cluster-a/runtime");
46
+ } finally {
47
+ await clients.dispose();
48
+ }
49
+ ```
50
+
51
+ The context does not cancel work automatically. Its budget is available as remainingMs;
52
+ the root owns cancellation and must join child work before disposing clients.
53
+
54
+ Catch ToolboxError from the root entry or `@compforge/harness-toolbox/errors`; use `kind` and
55
+ `code` for decisions. Native `cause` may contain sensitive details and is debug-only.
56
+
57
+ For coordinated common/toolbox development, install and build common before installing toolbox:
58
+
59
+ ```sh
60
+ # From sdks/typescript/toolbox
61
+ (cd ../common && bun install --frozen-lockfile && make build)
62
+ bun install --frozen-lockfile
63
+ make lint
64
+ make test
65
+ ```
66
+
67
+ The toolbox root override resolves common from `../common` only for local development;
68
+ the published dependency remains a registry version. Publish common before toolbox.
69
+ The Makefile builds common before typechecking or building toolbox.
70
+
71
+ A `ClientProvider` identifies a reusable client and constructs it; `DataSource` adds data-access semantics.
72
+ Accessible environments implement ClientProvider directly without becoming data sources. The `Client` owns initialization
12
73
  and idempotent disposal; a `ClientManager` joins concurrent initialization for the same key and
13
74
  closes owned clients when the caller finishes. A `ConnectionSource` resolves connection settings
14
75
  and available `Transport` paths. Clients execute protocol operations through those paths.
15
76
 
16
77
  ```ts
17
- import { ClientManager, dataSourceKey, type DataSource } from "@compforge/harness-toolbox";
78
+ import { ClientManager, clientKey, type DataSource } from "@compforge/harness-toolbox";
18
79
  import { MysqlClient } from "@compforge/harness-toolbox/mysql";
19
80
  import { DirectTransport } from "@compforge/harness-toolbox/transport";
20
81
 
21
82
  const target = { host: "localhost", port: 3306, database: "app", user: "reader", password: "..." };
22
83
  const source: DataSource<MysqlClient> = {
23
- key: dataSourceKey("mysql", target),
24
- createClient: signal => new MysqlClient({
84
+ clientKey: clientKey("mysql", target),
85
+ createClient: (_clients, signal) => new MysqlClient({
25
86
  resolve: async () => target,
26
87
  transports: [new DirectTransport()],
27
88
  }, { signal, connectTimeoutMs: 5_000, queryTimeoutMs: 10_000 }),
@@ -81,3 +142,17 @@ for mapped routes; direct endpoints also support virtual-hosted buckets. Signed
81
142
  identity remain the logical endpoint, not the forwarded address. Private CAs may be supplied
82
143
  explicitly with `ca`; certificate verification stays enabled. Initialization prepares the client
83
144
  and route without listing buckets or claiming that credentials have been validated.
145
+
146
+ ## Bounded MySQL reads
147
+
148
+ `MysqlClient.database.queryReadonly(target, sql, values, { timeoutMs, maxRows, maxBytes })` uses
149
+ the shared query slot and a disposable native session with `NO_BACKSLASH_ESCAPES`, a SELECT deadline,
150
+ and a READ ONLY transaction. Prepared execution binds values and retains rows incrementally,
151
+ returning `rows`, `columns`, `bytes`, `truncated`, and `truncation`. An extra row proves row truncation;
152
+ a row exceeding the remaining byte budget is not retained.
153
+
154
+ Callers own SQL classification, function policy, and least-privilege identities: READ ONLY alone
155
+ is not an arbitrary-SQL sandbox. This API requires MySQL 5.7.8+ `max_execution_time` and does not
156
+ fall back to Pod Python. Completion, truncation, timeout, and cancellation destroy the disposable
157
+ session without replay or changes to ordinary borrowed sessions. Disconnect does not prove immediate
158
+ server cancellation. The byte budget limits retained JSON rows, not individual wire packets or fields.
@@ -1,6 +1,6 @@
1
1
  import {
2
- dataSourceKey
3
- } from "./chunk-hqx7j9xe.js";
2
+ clientKey
3
+ } from "./chunk-z5pm2emz.js";
4
4
  import {
5
5
  S3Client
6
6
  } from "./chunk-zp1s833x.js";
@@ -10,7 +10,7 @@ import {
10
10
 
11
11
  // src/s3/datasource.ts
12
12
  class S3DataSource {
13
- key;
13
+ clientKey;
14
14
  #target;
15
15
  #limits;
16
16
  #transport;
@@ -18,13 +18,13 @@ class S3DataSource {
18
18
  this.#target = structuredClone(target);
19
19
  this.#limits = { ...limits };
20
20
  this.#transport = route?.transport ?? new DirectTransport;
21
- this.key = dataSourceKey("s3", {
21
+ this.clientKey = clientKey("s3", {
22
22
  target: this.#target,
23
23
  limits: this.#limits,
24
24
  route: route ? { key: route.key, transport: route.transport.name } : "direct"
25
25
  });
26
26
  }
27
- createClient(signal) {
27
+ createClient(_clients, signal) {
28
28
  return new S3Client({
29
29
  resolve: async () => this.#target,
30
30
  transports: [this.#transport]
@@ -0,0 +1,89 @@
1
+ import {
2
+ ConcurrencyPool
3
+ } from "./chunk-v3hxvnrc.js";
4
+ import {
5
+ KubernetesError,
6
+ ToolboxError,
7
+ kubernetesHttpError
8
+ } from "./chunk-ntfm5e85.js";
9
+
10
+ // src/kubernetes/resources.ts
11
+ import { KubeConfig } from "@kubernetes/client-node";
12
+ import fetch from "node-fetch/lib/index.js";
13
+ function resourceAccess(options, signal, limits) {
14
+ if (![limits.timeoutMs, limits.concurrency, limits.maxBytes].every((value) => Number.isSafeInteger(value) && value > 0)) {
15
+ throw new KubernetesError("Resource limits must be positive integers", { kind: "invalid_argument" });
16
+ }
17
+ const config = new KubeConfig;
18
+ try {
19
+ if (options.kubeconfig)
20
+ config.loadFromFile(options.kubeconfig);
21
+ else
22
+ config.loadFromDefault();
23
+ if (options.context)
24
+ config.setCurrentContext(options.context);
25
+ } catch (cause) {
26
+ throw new KubernetesError("Cannot load Kubernetes access configuration", { kind: "connection_failed", cause });
27
+ }
28
+ const cluster = config.getCurrentCluster();
29
+ if (!cluster)
30
+ throw new KubernetesError("Kubernetes context has no cluster", { kind: "invalid_argument" });
31
+ if (new URL(cluster.server).protocol === "http:") {
32
+ config.clusters = config.clusters.map((item) => item === cluster ? { ...item, skipTLSVerify: true } : item);
33
+ }
34
+ const pool = new ConcurrencyPool(limits.concurrency);
35
+ return {
36
+ get: (namespace, resource, name, selector) => pool.run(async () => {
37
+ const group = ["pods", "services"].includes(resource) ? "/api/v1" : "/apis/apps/v1";
38
+ const path = group + "/namespaces/" + encodeURIComponent(namespace) + "/" + resource + (name ? "/" + encodeURIComponent(name) : "");
39
+ const url = new URL(cluster.server.replace(/\/$/, "") + path);
40
+ if (selector)
41
+ url.searchParams.set("labelSelector", selector);
42
+ const controller = new AbortController;
43
+ const abort = () => controller.abort(signal.reason);
44
+ signal.addEventListener("abort", abort, { once: true });
45
+ let timedOut = false;
46
+ const timer = setTimeout(() => {
47
+ timedOut = true;
48
+ controller.abort();
49
+ }, limits.timeoutMs);
50
+ try {
51
+ signal.throwIfAborted();
52
+ const init = await config.applyToFetchOptions({});
53
+ controller.signal.throwIfAborted();
54
+ const response = await fetch(url.href, { ...init, signal: controller.signal, size: limits.maxBytes });
55
+ if (!response.ok) {
56
+ controller.abort();
57
+ const error = kubernetesHttpError(response.status);
58
+ throw new KubernetesError("Read " + resource + " in namespace " + namespace + " failed", { kind: error.kind, code: error.code });
59
+ }
60
+ return await response.json();
61
+ } catch (cause) {
62
+ if (signal.aborted)
63
+ throw signal.reason;
64
+ if (cause instanceof ToolboxError)
65
+ throw cause;
66
+ const type = cause && typeof cause === "object" && "type" in cause ? cause.type : undefined;
67
+ const code = cause && typeof cause === "object" && "code" in cause && typeof cause.code === "string" ? cause.code : undefined;
68
+ const tls = code !== undefined && [
69
+ "CERT_HAS_EXPIRED",
70
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
71
+ "SELF_SIGNED_CERT_IN_CHAIN",
72
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
73
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
74
+ "ERR_TLS_CERT_ALTNAME_INVALID"
75
+ ].includes(code);
76
+ throw new KubernetesError("Read " + resource + " in namespace " + namespace + " failed", {
77
+ kind: timedOut ? "timeout" : type === "max-size" ? "limit_exceeded" : type === "invalid-json" ? "invalid_response" : tls ? "tls_verification_failed" : code === "ECONNRESET" ? "connection_lost" : "connection_failed",
78
+ cause,
79
+ code
80
+ });
81
+ } finally {
82
+ clearTimeout(timer);
83
+ signal.removeEventListener("abort", abort);
84
+ }
85
+ }, signal)
86
+ };
87
+ }
88
+
89
+ export { resourceAccess };
@@ -0,0 +1,111 @@
1
+ import {
2
+ ServicePortForwarder
3
+ } from "./chunk-205a4vma.js";
4
+ import {
5
+ KubectlExecutor
6
+ } from "./chunk-0rs1aacd.js";
7
+ import {
8
+ resourceAccess
9
+ } from "./chunk-1kpbvat9.js";
10
+ import {
11
+ resolveWorkload
12
+ } from "./chunk-y3dx8jh6.js";
13
+ import {
14
+ KubernetesError
15
+ } from "./chunk-ntfm5e85.js";
16
+
17
+ // src/kubernetes/client.ts
18
+ class KubernetesClient {
19
+ kube;
20
+ resourceLimits;
21
+ #controller = new AbortController;
22
+ signal;
23
+ #executors = new Map;
24
+ #forwarders = new Map;
25
+ #operations = new Set;
26
+ #disposal;
27
+ #resources;
28
+ constructor(kube, signal, executor, resourceLimits = { timeoutMs: 30000, concurrency: 4, maxBytes: 8 * 1024 * 1024 }, resources) {
29
+ this.kube = kube;
30
+ this.resourceLimits = resourceLimits;
31
+ this.signal = signal ? AbortSignal.any([signal, this.#controller.signal]) : this.#controller.signal;
32
+ if (executor)
33
+ this.#executors.set(kube.namespace, executor);
34
+ this.#resources = resources;
35
+ }
36
+ async initialize() {
37
+ this.signal.throwIfAborted();
38
+ }
39
+ resolveWorkload(workload, environment) {
40
+ return this.#track(() => {
41
+ const namespace = workload.namespace ?? this.kube.namespace;
42
+ if (!namespace.trim())
43
+ throw new KubernetesError("Workload namespace is required", { kind: "invalid_argument" });
44
+ if (!environment.trim())
45
+ throw new KubernetesError("Environment identity is required", { kind: "invalid_argument" });
46
+ this.#resources ??= resourceAccess(this.kube, this.signal, this.resourceLimits);
47
+ return resolveWorkload(this.#resources, workload, namespace, environment);
48
+ });
49
+ }
50
+ #executor(namespace) {
51
+ let executor = this.#executors.get(namespace);
52
+ if (!executor) {
53
+ executor = new KubectlExecutor({ ...this.kube, namespace });
54
+ this.#executors.set(namespace, executor);
55
+ }
56
+ return executor;
57
+ }
58
+ #track(work) {
59
+ this.signal.throwIfAborted();
60
+ const pending = Promise.resolve().then(() => {
61
+ this.signal.throwIfAborted();
62
+ return work();
63
+ });
64
+ this.#operations.add(pending);
65
+ pending.then(() => this.#operations.delete(pending), () => this.#operations.delete(pending));
66
+ return pending;
67
+ }
68
+ #options(options) {
69
+ return { ...options, signal: options?.signal ? AbortSignal.any([this.signal, options.signal]) : this.signal };
70
+ }
71
+ run(namespace, command, options) {
72
+ return this.#track(() => this.#executor(namespace).run(command, this.#options(options)));
73
+ }
74
+ exec(namespace, target, command, options) {
75
+ return this.#track(() => this.#executor(namespace).exec(target, command, this.#options(options)));
76
+ }
77
+ forward(namespace, target) {
78
+ return this.#track(async () => {
79
+ let pending = this.#forwarders.get(namespace);
80
+ if (!pending) {
81
+ pending = ServicePortForwarder.create(this.#executor(namespace), { ...this.kube, namespace });
82
+ this.#forwarders.set(namespace, pending);
83
+ pending.catch(() => {
84
+ if (this.#forwarders.get(namespace) === pending)
85
+ this.#forwarders.delete(namespace);
86
+ });
87
+ }
88
+ const forwarder = await pending;
89
+ this.signal.throwIfAborted();
90
+ return forwarder.forward(target);
91
+ });
92
+ }
93
+ dispose() {
94
+ return this.#disposal ??= (async () => {
95
+ this.#controller.abort(new Error("Kubernetes client disposed"));
96
+ await Promise.allSettled(this.#operations);
97
+ const results = await Promise.allSettled([...this.#forwarders.values()].map(async (pending) => {
98
+ const forwarder = await pending.catch(() => {
99
+ return;
100
+ });
101
+ forwarder?.stop();
102
+ }));
103
+ this.#forwarders.clear();
104
+ const errors = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
105
+ if (errors.length)
106
+ throw new AggregateError(errors, "Kubernetes client cleanup failed");
107
+ })();
108
+ }
109
+ }
110
+
111
+ export { KubernetesClient };
@@ -0,0 +1,4 @@
1
+ // src/client.ts
2
+ import { clientKey } from "@compforge/harness-common";
3
+
4
+ export { clientKey };
@@ -1,3 +1,7 @@
1
+ import {
2
+ queryReadonlySession,
3
+ validateQueryLimits
4
+ } from "./chunk-kbewz29a.js";
1
5
  import {
2
6
  queryMysqlViaPod
3
7
  } from "./chunk-s06hs7a8.js";
@@ -24,6 +28,19 @@ class MysqlDatabase {
24
28
  query(target, sql, values) {
25
29
  return this.#queries.run(() => this.#query(target, sql, values), this.options.signal);
26
30
  }
31
+ queryReadonly(target, sql, values, limits) {
32
+ validateQueryLimits(limits);
33
+ return this.#queries.run(async () => {
34
+ const session = await this.#open(target);
35
+ if (session.kind !== "tcp")
36
+ throw new Error("Bounded readonly MySQL queries require a native TCP transport");
37
+ try {
38
+ return await queryReadonlySession(session.connection, sql, values, limits, this.options.signal);
39
+ } finally {
40
+ session.connection.destroy();
41
+ }
42
+ }, this.options.signal);
43
+ }
27
44
  initialize(target) {
28
45
  return this.#queries.run(async () => {
29
46
  await this.#session(target);
@@ -0,0 +1,95 @@
1
+ // src/mysql/readonly.ts
2
+ function validateQueryLimits(limits) {
3
+ for (const name of ["timeoutMs", "maxRows", "maxBytes"]) {
4
+ const value = limits[name];
5
+ if (!Number.isSafeInteger(value) || value < 1 || value > 2147483647) {
6
+ throw new Error(`${name} must be a positive integer <= 2147483647`);
7
+ }
8
+ }
9
+ }
10
+ async function queryReadonlySession(connection, sql, values, limits, signal) {
11
+ validateQueryLimits(limits);
12
+ signal?.throwIfAborted();
13
+ let timer;
14
+ let abort;
15
+ try {
16
+ return await Promise.race([
17
+ (async () => {
18
+ await connection.query("SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_BACKSLASH_ESCAPES'");
19
+ await connection.query(`SET SESSION max_execution_time = ${limits.timeoutMs}`);
20
+ await connection.query("START TRANSACTION READ ONLY");
21
+ signal?.throwIfAborted();
22
+ return await collectRows(connection, sql, values, limits);
23
+ })(),
24
+ new Promise((_resolve, reject) => {
25
+ const fail = (reason) => {
26
+ connection.destroy();
27
+ reject(reason);
28
+ };
29
+ abort = () => fail(signal?.reason ?? new Error("MySQL query cancelled"));
30
+ signal?.addEventListener("abort", abort, { once: true });
31
+ timer = setTimeout(() => fail(new Error("MySQL readonly query timed out")), limits.timeoutMs);
32
+ if (signal?.aborted)
33
+ abort();
34
+ })
35
+ ]);
36
+ } finally {
37
+ if (timer)
38
+ clearTimeout(timer);
39
+ if (abort)
40
+ signal?.removeEventListener("abort", abort);
41
+ connection.destroy();
42
+ }
43
+ }
44
+ function collectRows(connection, sql, values, limits) {
45
+ const native = connection.connection;
46
+ return new Promise((resolve, reject) => {
47
+ const result = { rows: [], columns: [], bytes: 0, truncated: false };
48
+ let settled = false;
49
+ const finish = (truncation) => {
50
+ if (settled)
51
+ return;
52
+ settled = true;
53
+ result.truncated = !!truncation;
54
+ if (truncation)
55
+ result.truncation = truncation;
56
+ resolve(result);
57
+ };
58
+ const query = native.execute({ sql, values: [...values], timeout: limits.timeoutMs });
59
+ query.on("fields", (fields) => {
60
+ result.columns = fields?.map((field) => field.name) ?? [];
61
+ });
62
+ query.on("result", (row) => {
63
+ if (settled)
64
+ return;
65
+ if (result.rows.length >= limits.maxRows) {
66
+ finish("rows");
67
+ connection.destroy();
68
+ return;
69
+ }
70
+ try {
71
+ const bytes = Buffer.byteLength(JSON.stringify(row));
72
+ if (result.bytes + bytes > limits.maxBytes) {
73
+ finish("bytes");
74
+ connection.destroy();
75
+ return;
76
+ }
77
+ result.rows.push(row);
78
+ result.bytes += bytes;
79
+ } catch (error) {
80
+ settled = true;
81
+ connection.destroy();
82
+ reject(error);
83
+ }
84
+ });
85
+ query.on("error", (error) => {
86
+ if (!settled) {
87
+ settled = true;
88
+ reject(error);
89
+ }
90
+ });
91
+ query.on("end", () => finish());
92
+ });
93
+ }
94
+
95
+ export { validateQueryLimits, queryReadonlySession };
@@ -9,8 +9,8 @@ import {
9
9
  LogCaptureFile
10
10
  } from "./chunk-1vfwvgkj.js";
11
11
  import {
12
- dataSourceKey
13
- } from "./chunk-hqx7j9xe.js";
12
+ clientKey
13
+ } from "./chunk-a9htb6cr.js";
14
14
 
15
15
  // src/kubernetes/pod-log-client.ts
16
16
  import { copyFileSync, mkdtempSync, rmSync } from "node:fs";
@@ -40,7 +40,7 @@ class PodLogClient {
40
40
  this.signal.throwIfAborted();
41
41
  if (!this.#directory || this.#disposal)
42
42
  throw new Error("Log capture session is not active");
43
- const key = scope.instance && request.sinceTime ? dataSourceKey("pod-log", {
43
+ const key = scope.instance && request.sinceTime ? clientKey("pod-log", {
44
44
  scope,
45
45
  pod: request.pod,
46
46
  container: request.container,
@@ -0,0 +1,27 @@
1
+ // src/errors.ts
2
+ class ToolboxError extends Error {
3
+ kind;
4
+ code;
5
+ constructor(message, options) {
6
+ super(message, { cause: options.cause });
7
+ this.name = new.target.name;
8
+ this.kind = options.kind;
9
+ this.code = options.code;
10
+ }
11
+ }
12
+
13
+ class KubernetesError extends ToolboxError {
14
+ }
15
+ function kubernetesHttpError(status) {
16
+ const kind = {
17
+ 401: "authentication_failed",
18
+ 403: "permission_denied",
19
+ 404: "resource_not_found",
20
+ 408: "timeout",
21
+ 429: "limit_exceeded",
22
+ 504: "timeout"
23
+ }[status] ?? "operation_failed";
24
+ return new KubernetesError("Kubernetes resource request failed", { kind, code: status });
25
+ }
26
+
27
+ export { ToolboxError, KubernetesError, kubernetesHttpError };
@@ -0,0 +1,94 @@
1
+ import {
2
+ KubernetesError
3
+ } from "./chunk-ntfm5e85.js";
4
+
5
+ // src/kubernetes/workload.ts
6
+ function failure(message, kind) {
7
+ throw new KubernetesError(message, { kind });
8
+ }
9
+ function selector(value, service = false) {
10
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
11
+ return failure("Resource has no Pod selector", "unsupported_operation");
12
+ }
13
+ const input = value;
14
+ const labels = service ? value : input.matchLabels ?? {};
15
+ const terms = Object.entries(labels).sort(([a], [b]) => a.localeCompare(b)).map(([key, item]) => {
16
+ if (!key || /[,=()!\s]/.test(key) || typeof item !== "string" || /[,=()!\s]/.test(item)) {
17
+ return failure("Invalid label selector", "invalid_argument");
18
+ }
19
+ return key + "=" + item;
20
+ });
21
+ for (const entry of service ? [] : input.matchExpressions ?? []) {
22
+ const values = entry.values ?? [];
23
+ if (!entry.key || /[,=()!\s]/.test(entry.key) || values.some((v) => /[,=()!\s]/.test(v))) {
24
+ return failure("Invalid selector expression", "invalid_response");
25
+ }
26
+ if ((entry.operator === "In" || entry.operator === "NotIn") && values.length) {
27
+ terms.push(entry.key + (entry.operator === "In" ? " in (" : " notin (") + [...values].sort().join(",") + ")");
28
+ } else if (entry.operator === "Exists" && !values.length)
29
+ terms.push(entry.key);
30
+ else if (entry.operator === "DoesNotExist" && !values.length)
31
+ terms.push("!" + entry.key);
32
+ else
33
+ return failure("Unsupported selector expression", "invalid_response");
34
+ }
35
+ if (!terms.length)
36
+ return failure("Resource has no Pod selector", "unsupported_operation");
37
+ return terms.join(",");
38
+ }
39
+ async function resolveWorkload(access, workload, namespace, environment) {
40
+ if (!environment.trim())
41
+ return failure("Environment identity is required", "invalid_argument");
42
+ if (!namespace.trim())
43
+ return failure("Workload namespace is required", "invalid_argument");
44
+ const location = workload.location;
45
+ let selected;
46
+ let pods;
47
+ if (location.kind === "labels") {
48
+ if (!Object.keys(location.labels).length)
49
+ return failure("Pod labels must be nonempty", "invalid_argument");
50
+ selected = selector(location.labels, true);
51
+ const result = await access.get(namespace, "pods", undefined, selected);
52
+ if (!Array.isArray(result.items))
53
+ return failure("Pod list lacks items", "invalid_response");
54
+ pods = result.items;
55
+ } else {
56
+ const resource = location.kind === "service" ? "services" : {
57
+ Pod: "pods",
58
+ Deployment: "deployments",
59
+ StatefulSet: "statefulsets",
60
+ DaemonSet: "daemonsets"
61
+ }[location.resource_kind];
62
+ if (!resource)
63
+ return failure("Unsupported workload resource", "unsupported_operation");
64
+ if (!location.name.trim())
65
+ return failure("Resource name is required", "invalid_argument");
66
+ const item = await access.get(namespace, resource, location.name);
67
+ if (resource === "pods")
68
+ pods = [item];
69
+ else {
70
+ selected = selector(item.spec?.selector, resource === "services");
71
+ const result = await access.get(namespace, "pods", undefined, selected);
72
+ if (!Array.isArray(result.items))
73
+ return failure("Pod list lacks items", "invalid_response");
74
+ pods = result.items;
75
+ }
76
+ }
77
+ return pods.map((pod) => {
78
+ const metadata = pod.metadata;
79
+ if (!metadata?.name || !metadata.uid || metadata.namespace !== namespace) {
80
+ return failure("Pod response lacks identity or has a different namespace", "invalid_response");
81
+ }
82
+ return {
83
+ platform: "kubernetes",
84
+ environment,
85
+ workload: workload.name,
86
+ namespace,
87
+ pod: metadata.name,
88
+ uid: metadata.uid,
89
+ container: workload.container
90
+ };
91
+ }).sort((a, b) => a.pod.localeCompare(b.pod) || a.uid.localeCompare(b.uid));
92
+ }
93
+
94
+ export { resolveWorkload };
@@ -0,0 +1,4 @@
1
+ // src/datasource.ts
2
+ import { clientKey, ClientManager, EnvironmentContext } from "@compforge/harness-common";
3
+
4
+ export { clientKey, ClientManager, EnvironmentContext };
@@ -1,14 +1 @@
1
- import type { Client } from "./client";
2
- import type { DataSource } from "./datasource";
3
- /** Consumers borrow clients; only the root owner receives the disposal capability. */
4
- export interface ClientProvider {
5
- get<C extends Client>(source: DataSource<C>): Promise<C>;
6
- }
7
- /** @spec One root execution shares successful initialization per identity and closes clients at finalize. */
8
- export declare class ClientManager implements ClientProvider {
9
- #private;
10
- readonly signal: AbortSignal;
11
- constructor(signal?: AbortSignal);
12
- get<C extends Client>(source: DataSource<C>): Promise<C>;
13
- dispose(): Promise<void>;
14
- }
1
+ export { ClientManager } from "@compforge/harness-common";
@@ -1,6 +1,5 @@
1
- import {
2
- ClientManager
3
- } from "./chunk-3dt25p98.js";
1
+ // src/client-manager.ts
2
+ import { ClientManager } from "@compforge/harness-common";
4
3
  export {
5
4
  ClientManager
6
5
  };
package/dist/client.d.ts CHANGED
@@ -1,5 +1 @@
1
- /** A protocol client owns its initialization and idempotent cleanup, including partial initialization. */
2
- export interface Client {
3
- initialize(): Promise<void>;
4
- dispose(): Promise<void>;
5
- }
1
+ export { clientKey, type Client, type ClientManager, type ClientProvider } from "@compforge/harness-common";
package/dist/client.js CHANGED
@@ -0,0 +1,6 @@
1
+ import {
2
+ clientKey
3
+ } from "./chunk-a9htb6cr.js";
4
+ export {
5
+ clientKey
6
+ };