@compforge/harness-toolbox 0.3.11 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,6 +58,40 @@ a UID fence; callers needing incarnation guarantees must verify discovery around
58
58
  sources borrow Kubernetes access from the root manager and never close it themselves. Unsupported
59
59
  commands, missing paths and denied reads fail explicitly; they are not empty file results.
60
60
 
61
+ ## Kubernetes resources
62
+
63
+ Borrow a root `KubernetesClient` through `KubernetesEnvironment` and `ClientManager`. Use
64
+ `get(ref)` and `list(query)` for resource reads; `inNamespace(namespace)` binds a borrowed view
65
+ to a namespace without creating a second client or pool:
66
+
67
+ ```ts
68
+ const kube = await clients.get(environment);
69
+ const ns = kube.inNamespace("app");
70
+ const service = await ns.get({ apiVersion: "v1", resource: "services", name: "api" });
71
+ const pods = await ns.list({ apiVersion: "v1", resource: "pods", labelSelector: "app=api" },
72
+ { timeoutMs: 15_000, signal });
73
+ ```
74
+
75
+ `resource` is the plural REST resource name, not a Kind or kubectl alias. Explicit API versions
76
+ support built-in resources and custom resources without a discovery request or local allowlist.
77
+ The root uses its configured namespace unless the query overrides it; namespace views enforce
78
+ their bound namespace. Resource objects and list metadata are returned intact. List requests
79
+ accept label/field selectors and explicit `limit`/`continue` pages; they do not silently read all pages.
80
+ Errors are `KubernetesError` objects carrying a stable kind and the HTTP status when available.
81
+ Callers retain the query with their evidence; resource operations do not produce shell command results.
82
+
83
+ The root owns one HTTP transport with keep-alive connections shared across namespaces and resource
84
+ kinds. Authentication refreshes on each request. TLS/proxy identity changes select a new pool and
85
+ retire the previous one after its active reads finish. Requests have bounded bytes and a deadline
86
+ covering queueing, authentication and body consumption. `dispose()` cancels work, waits for tracked
87
+ operations, and closes connections. Views and injected borrowed resource readers have no ownership
88
+ of that lifecycle. No query results are cached and failures are never replayed through another transport.
89
+
90
+ Native resource access uses kubeconfig authentication, TLS and explicit `proxy-url`. It does not
91
+ select transports based on shell proxy environment variables. `exec` and `forward` are separate
92
+ streaming operations; their implementation does not intercept resource reads. `KubernetesClient`
93
+ has no `run(argv)` method. Concurrency remains caller-configured rather than inferred from pool size.
94
+
61
95
  ## Workload discovery
62
96
 
63
97
  Borrow a KubernetesClient through KubernetesEnvironment and the shared ClientManager lifecycle, then call
@@ -48,7 +48,7 @@ async function resolveWorkload(access, workload, namespace, environment) {
48
48
  if (!Object.keys(location.labels).length)
49
49
  return failure("Pod labels must be nonempty", "invalid_argument");
50
50
  selected = selector(location.labels, true);
51
- const result = await access.get(namespace, "pods", undefined, selected);
51
+ const result = await access.list({ namespace, apiVersion: "v1", resource: "pods", labelSelector: selected });
52
52
  if (!Array.isArray(result.items))
53
53
  return failure("Pod list lacks items", "invalid_response");
54
54
  pods = result.items;
@@ -63,12 +63,17 @@ async function resolveWorkload(access, workload, namespace, environment) {
63
63
  return failure("Unsupported workload resource", "unsupported_operation");
64
64
  if (!location.name.trim())
65
65
  return failure("Resource name is required", "invalid_argument");
66
- const item = await access.get(namespace, resource, location.name);
66
+ const item = await access.get({
67
+ namespace,
68
+ resource,
69
+ name: location.name,
70
+ apiVersion: resource === "pods" || resource === "services" ? "v1" : "apps/v1"
71
+ });
67
72
  if (resource === "pods")
68
73
  pods = [item];
69
74
  else {
70
75
  selected = selector(item.spec?.selector, resource === "services");
71
- const result = await access.get(namespace, "pods", undefined, selected);
76
+ const result = await access.list({ namespace, apiVersion: "v1", resource: "pods", labelSelector: selected });
72
77
  if (!Array.isArray(result.items))
73
78
  return failure("Pod list lacks items", "invalid_response");
74
79
  pods = result.items;
@@ -0,0 +1,210 @@
1
+ import {
2
+ resourcePath
3
+ } from "./chunk-ftkrjqkc.js";
4
+ import {
5
+ clientKey
6
+ } from "./chunk-a9htb6cr.js";
7
+ import {
8
+ KubernetesError,
9
+ ToolboxError,
10
+ kubernetesHttpError
11
+ } from "./chunk-20kv4wc3.js";
12
+ import {
13
+ ConcurrencyPool
14
+ } from "./chunk-v3hxvnrc.js";
15
+
16
+ // src/kubernetes/resource-transport.ts
17
+ import { KubeConfig } from "@kubernetes/client-node";
18
+ import fetch from "node-fetch/lib/index.js";
19
+ import { Headers } from "node-fetch";
20
+ class KubernetesResourceTransport {
21
+ limits;
22
+ #config = new KubeConfig;
23
+ #controller = new AbortController;
24
+ #pool;
25
+ #agents = new Set;
26
+ #server;
27
+ #signal;
28
+ #current;
29
+ constructor(options, signal, limits) {
30
+ this.limits = limits;
31
+ if (![limits.timeoutMs, limits.concurrency, limits.maxBytes].every((value) => Number.isSafeInteger(value) && value > 0)) {
32
+ throw new KubernetesError("Resource limits must be positive integers", { kind: "invalid_argument" });
33
+ }
34
+ try {
35
+ if (options.kubeconfig)
36
+ this.#config.loadFromFile(options.kubeconfig);
37
+ else
38
+ this.#config.loadFromDefault();
39
+ if (options.context)
40
+ this.#config.setCurrentContext(options.context);
41
+ } catch (cause) {
42
+ throw new KubernetesError("Cannot load Kubernetes access configuration", { kind: "connection_failed", cause });
43
+ }
44
+ const cluster = this.#config.getCurrentCluster();
45
+ if (!cluster)
46
+ throw new KubernetesError("Kubernetes context has no cluster", { kind: "invalid_argument" });
47
+ this.#server = cluster.server.replace(/\/$/, "");
48
+ if (new URL(cluster.server).protocol === "http:") {
49
+ this.#config.clusters = this.#config.clusters.map((item) => item === cluster ? { ...item, skipTLSVerify: true } : item);
50
+ }
51
+ this.#signal = AbortSignal.any([signal, this.#controller.signal]);
52
+ this.#pool = new ConcurrencyPool(limits.concurrency);
53
+ }
54
+ async get(ref, options) {
55
+ if (!ref.name)
56
+ throw new KubernetesError("Resource name is required", { kind: "invalid_argument" });
57
+ return this.#read(resourcePath(ref, ref.name), undefined, options);
58
+ }
59
+ async list(query, options) {
60
+ const params = new URLSearchParams;
61
+ if (query.labelSelector)
62
+ params.set("labelSelector", query.labelSelector);
63
+ if (query.fieldSelector)
64
+ params.set("fieldSelector", query.fieldSelector);
65
+ if (query.continue)
66
+ params.set("continue", query.continue);
67
+ if (query.limit !== undefined) {
68
+ if (!Number.isSafeInteger(query.limit) || query.limit <= 0) {
69
+ throw new KubernetesError("Resource page limit must be a positive integer", { kind: "invalid_argument" });
70
+ }
71
+ params.set("limit", String(query.limit));
72
+ }
73
+ const value = await this.#read(resourcePath(query), params, options);
74
+ if (!Array.isArray(value.items) || value.items.some((item) => !isObject(item))) {
75
+ throw new KubernetesError("Resource list lacks object items", { kind: "invalid_response" });
76
+ }
77
+ return value;
78
+ }
79
+ dispose() {
80
+ this.#controller.abort(new Error("Kubernetes resource transport disposed"));
81
+ for (const entry of this.#agents)
82
+ entry.agent.destroy();
83
+ this.#agents.clear();
84
+ this.#current = undefined;
85
+ }
86
+ async#read(path, params, options) {
87
+ const timeoutMs = options?.timeoutMs ?? this.limits.timeoutMs;
88
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
89
+ throw new KubernetesError("Resource timeout must be a positive integer", { kind: "invalid_argument" });
90
+ }
91
+ const caller = options?.signal ? AbortSignal.any([this.#signal, options.signal]) : this.#signal;
92
+ const deadline = new AbortController;
93
+ const signal = AbortSignal.any([caller, deadline.signal]);
94
+ const timer = setTimeout(() => deadline.abort(), Math.min(timeoutMs, this.limits.timeoutMs));
95
+ try {
96
+ return await this.#pool.run(async () => {
97
+ const request = await this.#authenticate(signal);
98
+ if (signal.aborted) {
99
+ request.agent.destroy();
100
+ signal.throwIfAborted();
101
+ }
102
+ const entry = this.#acquireAgent(request);
103
+ try {
104
+ const url = new URL(this.#server + path);
105
+ if (params)
106
+ url.search = params.toString();
107
+ const headers = new Headers;
108
+ for (const [name, value2] of Object.entries(request.headers ?? {})) {
109
+ for (const entry2 of Array.isArray(value2) ? value2 : [value2]) {
110
+ if (entry2 !== undefined)
111
+ headers.append(name, String(entry2));
112
+ }
113
+ }
114
+ if (request.auth)
115
+ headers.set("Authorization", "Basic " + Buffer.from(request.auth).toString("base64"));
116
+ const response = await fetch(url.href, { agent: entry.agent, headers, signal, size: this.limits.maxBytes });
117
+ if (!response.ok) {
118
+ response.body.destroy();
119
+ const error = kubernetesHttpError(response.status);
120
+ throw new KubernetesError("Read Kubernetes resource " + path + " failed", { kind: error.kind, code: error.code });
121
+ }
122
+ const value = await response.json();
123
+ signal.throwIfAborted();
124
+ if (!isObject(value))
125
+ throw new KubernetesError("Resource response is not an object", { kind: "invalid_response" });
126
+ return value;
127
+ } finally {
128
+ entry.users--;
129
+ this.#retire(entry);
130
+ }
131
+ }, signal);
132
+ } catch (cause) {
133
+ if (caller.aborted)
134
+ throw caller.reason;
135
+ if (cause instanceof ToolboxError)
136
+ throw cause;
137
+ const type = cause && typeof cause === "object" && "type" in cause ? cause.type : undefined;
138
+ const code = cause && typeof cause === "object" && "code" in cause && typeof cause.code === "string" ? cause.code : undefined;
139
+ const tls = code !== undefined && [
140
+ "CERT_HAS_EXPIRED",
141
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
142
+ "SELF_SIGNED_CERT_IN_CHAIN",
143
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
144
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
145
+ "ERR_TLS_CERT_ALTNAME_INVALID"
146
+ ].includes(code);
147
+ throw new KubernetesError("Read Kubernetes resource " + path + " failed", {
148
+ kind: deadline.signal.aborted ? "timeout" : type === "max-size" ? "limit_exceeded" : type === "invalid-json" ? "invalid_response" : tls ? "tls_verification_failed" : code === "ECONNRESET" ? "connection_lost" : "connection_failed",
149
+ cause,
150
+ code
151
+ });
152
+ } finally {
153
+ clearTimeout(timer);
154
+ }
155
+ }
156
+ #authenticate(signal) {
157
+ return new Promise((resolve, reject) => {
158
+ if (signal.aborted) {
159
+ reject(signal.reason);
160
+ return;
161
+ }
162
+ const abort = () => reject(signal.reason);
163
+ signal.addEventListener("abort", abort, { once: true });
164
+ const options = { headers: {} };
165
+ this.#config.applyToHTTPSOptions(options).then(() => {
166
+ signal.removeEventListener("abort", abort);
167
+ if (signal.aborted) {
168
+ options.agent.destroy();
169
+ reject(signal.reason);
170
+ } else
171
+ resolve(options);
172
+ }, (error) => {
173
+ signal.removeEventListener("abort", abort);
174
+ reject(error);
175
+ });
176
+ });
177
+ }
178
+ #acquireAgent(options) {
179
+ const fresh = options.agent;
180
+ const cluster = this.#config.getCurrentCluster();
181
+ const tls = { ...options, agent: undefined, headers: undefined, auth: undefined };
182
+ const key = clientKey("kubernetes-agent", { server: this.#server, proxy: cluster?.proxyUrl, tls });
183
+ if (this.#current?.key === key) {
184
+ fresh.destroy();
185
+ } else {
186
+ fresh.keepAlive = true;
187
+ fresh.maxSockets = this.limits.concurrency;
188
+ fresh.maxTotalSockets = this.limits.concurrency;
189
+ fresh.maxFreeSockets = this.limits.concurrency;
190
+ const previous = this.#current;
191
+ this.#current = { key, agent: fresh, users: 0 };
192
+ this.#agents.add(this.#current);
193
+ if (previous)
194
+ this.#retire(previous);
195
+ }
196
+ this.#current.users++;
197
+ return this.#current;
198
+ }
199
+ #retire(entry) {
200
+ if (entry !== this.#current && entry.users === 0) {
201
+ entry.agent.destroy();
202
+ this.#agents.delete(entry);
203
+ }
204
+ }
205
+ }
206
+ function isObject(value) {
207
+ return value !== null && typeof value === "object" && !Array.isArray(value);
208
+ }
209
+
210
+ export { KubernetesResourceTransport };
@@ -0,0 +1,15 @@
1
+ import {
2
+ KubernetesError
3
+ } from "./chunk-20kv4wc3.js";
4
+
5
+ // src/kubernetes/resources.ts
6
+ function resourcePath(query, name) {
7
+ const parts = query.apiVersion.split("/");
8
+ if (parts.length > 2 || parts.some((part) => !/^[a-z0-9][a-z0-9.-]*$/.test(part)) || !/^[a-z][a-z0-9-]*$/.test(query.resource) || query.namespace !== undefined && !/^[a-z0-9][a-z0-9-]*$/.test(query.namespace) || name !== undefined && !/^[a-z0-9][a-z0-9.-]*$/.test(name)) {
9
+ throw new KubernetesError("Invalid Kubernetes resource identity", { kind: "invalid_argument" });
10
+ }
11
+ const prefix = parts.length === 1 ? "/api/" : "/apis/";
12
+ return prefix + parts.join("/") + (query.namespace === undefined ? "" : "/namespaces/" + query.namespace) + "/" + query.resource + (name === undefined ? "" : "/" + name);
13
+ }
14
+
15
+ export { resourcePath };
@@ -1,12 +1,12 @@
1
+ import {
2
+ KubernetesResourceTransport
3
+ } from "./chunk-4ms037k2.js";
1
4
  import {
2
5
  resolveWorkload
3
- } from "./chunk-8axd2y2m.js";
6
+ } from "./chunk-0s27bgga.js";
4
7
  import {
5
8
  ServicePortForwarder
6
9
  } from "./chunk-2171tajs.js";
7
- import {
8
- resourceAccess
9
- } from "./chunk-9n9z2pty.js";
10
10
  import {
11
11
  KubernetesError
12
12
  } from "./chunk-20kv4wc3.js";
@@ -25,6 +25,7 @@ class KubernetesClient {
25
25
  #operations = new Set;
26
26
  #disposal;
27
27
  #resources;
28
+ #transport;
28
29
  constructor(kube, signal, executor, resourceLimits = { timeoutMs: 30000, concurrency: 4, maxBytes: 8 * 1024 * 1024 }, resources) {
29
30
  this.kube = kube;
30
31
  this.resourceLimits = resourceLimits;
@@ -43,10 +44,12 @@ class KubernetesClient {
43
44
  throw new KubernetesError("Workload namespace is required", { kind: "invalid_argument" });
44
45
  if (!environment.trim())
45
46
  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);
47
+ return resolveWorkload(this, workload, namespace, environment);
48
48
  });
49
49
  }
50
+ #resourceAccess() {
51
+ return this.#resources ??= this.#transport = new KubernetesResourceTransport(this.kube, this.signal, this.resourceLimits);
52
+ }
50
53
  #executor(namespace) {
51
54
  let executor = this.#executors.get(namespace);
52
55
  if (!executor) {
@@ -68,8 +71,19 @@ class KubernetesClient {
68
71
  #options(options) {
69
72
  return { ...options, signal: options?.signal ? AbortSignal.any([this.signal, options.signal]) : this.signal };
70
73
  }
71
- run(namespace, command, options) {
72
- return this.#track(() => this.#executor(namespace).run(command, this.#options(options)));
74
+ get(ref, options) {
75
+ return this.#track(() => this.#resourceAccess().get({ ...ref, namespace: ref.namespace ?? this.kube.namespace }, this.#options(options)));
76
+ }
77
+ list(query, options) {
78
+ return this.#track(() => this.#resourceAccess().list({ ...query, namespace: query.namespace ?? this.kube.namespace }, this.#options(options)));
79
+ }
80
+ inNamespace(namespace) {
81
+ if (!namespace.trim())
82
+ throw new KubernetesError("Namespace is required", { kind: "invalid_argument" });
83
+ return {
84
+ get: (ref, options) => this.get({ ...ref, namespace }, options),
85
+ list: (query, options) => this.list({ ...query, namespace }, options)
86
+ };
73
87
  }
74
88
  exec(namespace, target, command, options) {
75
89
  return this.#track(() => this.#executor(namespace).exec(target, command, this.#options(options)));
@@ -101,6 +115,7 @@ class KubernetesClient {
101
115
  forwarder?.stop();
102
116
  }));
103
117
  this.#forwarders.clear();
118
+ this.#transport?.dispose();
104
119
  const errors = results.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
105
120
  if (errors.length)
106
121
  throw new AggregateError(errors, "Kubernetes client cleanup failed");
@@ -1,21 +1,29 @@
1
1
  import type { Client } from "../client";
2
- import { type Executor, type KubectlOptions, type ExecTarget, type RunOptions } from "./executor";
2
+ import { type Executor, type ExecTarget, type RunOptions } from "./executor";
3
3
  import type { KubernetesEndpoint } from "./endpoint";
4
4
  import type { Workload, WorkloadInstance } from "@compforge/harness-common";
5
- import { type ResourceAccess, type ResourceLimits } from "./resources";
5
+ import type { ResourceAccess, ResourceLimits, ResourceRef, ResourceListQuery, ResourceRequestOptions, NamespaceResources } from "./resources";
6
+ import { type KubernetesConnectionOptions } from "./resource-transport";
6
7
  /** Owns cluster transports; protocol callers borrow this client without closing it. */
7
8
  export declare class KubernetesClient implements Client {
8
9
  #private;
9
10
  private readonly kube;
10
11
  private readonly resourceLimits;
11
12
  readonly signal: AbortSignal;
12
- constructor(kube: KubectlOptions & {
13
+ constructor(kube: KubernetesConnectionOptions & {
13
14
  namespace: string;
14
15
  }, signal?: AbortSignal, executor?: Executor, resourceLimits?: ResourceLimits, resources?: ResourceAccess);
15
16
  initialize(): Promise<void>;
16
17
  /** environment is a stable target ID from the caller's registry, not access configuration. */
17
18
  resolveWorkload(workload: Workload, environment: string): Promise<WorkloadInstance[]>;
18
- run(namespace: string, command: string[], options?: RunOptions): Promise<import("./executor").ExecResult>;
19
+ /**
20
+ * @spec Resource operations return API objects and structured errors, sharing one root HTTP transport.
21
+ * @rule Namespace views borrow the root lifecycle; reads never fall back to command execution.
22
+ * @see {@link docs/toolbox.md}
23
+ */
24
+ get(ref: ResourceRef, options?: ResourceRequestOptions): Promise<import("./resources").Resource>;
25
+ list(query: ResourceListQuery, options?: ResourceRequestOptions): Promise<import("./resources").ResourceList>;
26
+ inNamespace(namespace: string): NamespaceResources;
19
27
  exec(namespace: string, target: ExecTarget, command: string[], options?: RunOptions): Promise<import("./executor").ExecResult>;
20
28
  forward(namespace: string, target: KubernetesEndpoint): Promise<import("./service-port-forward").ForwardedEndpoint>;
21
29
  dispose(): Promise<void>;
@@ -1,11 +1,13 @@
1
1
  import {
2
2
  KubernetesClient
3
- } from "../chunk-a1cb44ga.js";
4
- import"../chunk-8axd2y2m.js";
3
+ } from "../chunk-y529s0mz.js";
4
+ import"../chunk-4ms037k2.js";
5
+ import"../chunk-0s27bgga.js";
5
6
  import"../chunk-2171tajs.js";
6
7
  import"../chunk-j46x7j7c.js";
7
8
  import"../chunk-6fnhzhdb.js";
8
- import"../chunk-9n9z2pty.js";
9
+ import"../chunk-ftkrjqkc.js";
10
+ import"../chunk-a9htb6cr.js";
9
11
  import"../chunk-20kv4wc3.js";
10
12
  import"../chunk-v3hxvnrc.js";
11
13
  import"../chunk-631vce5z.js";
@@ -1,7 +1,7 @@
1
1
  import { type ClientProvider, type ClientManager } from "../client";
2
2
  import type { Environment } from "@compforge/harness-common";
3
3
  import { KubernetesClient } from "./client";
4
- import type { KubectlOptions } from "./executor";
4
+ import type { KubernetesConnectionOptions } from "./resource-transport";
5
5
  import type { ResourceLimits } from "./resources";
6
6
  /** Cluster access is a client provider; the execution owns client lifetime. */
7
7
  export declare class KubernetesEnvironment implements Environment, ClientProvider<KubernetesClient> {
@@ -11,7 +11,7 @@ export declare class KubernetesEnvironment implements Environment, ClientProvide
11
11
  readonly clientKey: string;
12
12
  /** Deployment metadata only; not part of client reuse or access configuration. */
13
13
  readonly imageRegistry?: string;
14
- constructor(name: string, kube: KubectlOptions & {
14
+ constructor(name: string, kube: KubernetesConnectionOptions & {
15
15
  namespace: string;
16
16
  imageRegistry?: string;
17
17
  }, limits: ResourceLimits);
@@ -1,11 +1,12 @@
1
1
  import {
2
2
  KubernetesClient
3
- } from "../chunk-a1cb44ga.js";
4
- import"../chunk-8axd2y2m.js";
3
+ } from "../chunk-y529s0mz.js";
4
+ import"../chunk-4ms037k2.js";
5
+ import"../chunk-0s27bgga.js";
5
6
  import"../chunk-2171tajs.js";
6
7
  import"../chunk-j46x7j7c.js";
7
8
  import"../chunk-6fnhzhdb.js";
8
- import"../chunk-9n9z2pty.js";
9
+ import"../chunk-ftkrjqkc.js";
9
10
  import {
10
11
  clientKey
11
12
  } from "../chunk-a9htb6cr.js";
@@ -0,0 +1,19 @@
1
+ import { type Resource, type ResourceAccess, type ResourceList, type ResourceListQuery, type ResourceRef, type ResourceRequestOptions, type ResourceLimits } from "./resources";
2
+ export interface KubernetesConnectionOptions {
3
+ kubeconfig?: string;
4
+ context?: string;
5
+ }
6
+ /**
7
+ * @spec One root transport owns HTTP connections across resource kinds and namespaces.
8
+ * @rule Credentials refresh per request; TLS/proxy changes retire the old pool after its active reads finish.
9
+ * @rule Deadlines cover queueing, authentication and response consumption; failed requests are never replayed.
10
+ * @see {@link docs/toolbox.md}
11
+ */
12
+ export declare class KubernetesResourceTransport implements ResourceAccess {
13
+ #private;
14
+ private readonly limits;
15
+ constructor(options: KubernetesConnectionOptions, signal: AbortSignal, limits: ResourceLimits);
16
+ get(ref: ResourceRef, options?: ResourceRequestOptions): Promise<Resource>;
17
+ list(query: ResourceListQuery, options?: ResourceRequestOptions): Promise<ResourceList>;
18
+ dispose(): void;
19
+ }
@@ -0,0 +1,10 @@
1
+ import {
2
+ KubernetesResourceTransport
3
+ } from "../chunk-4ms037k2.js";
4
+ import"../chunk-ftkrjqkc.js";
5
+ import"../chunk-a9htb6cr.js";
6
+ import"../chunk-20kv4wc3.js";
7
+ import"../chunk-v3hxvnrc.js";
8
+ export {
9
+ KubernetesResourceTransport
10
+ };
@@ -1,23 +1,63 @@
1
- import type { KubectlOptions } from "./executor";
1
+ /** API resource names are plural REST names, not Kind names or kubectl aliases. */
2
+ export interface ResourceQuery {
3
+ apiVersion: string;
4
+ resource: string;
5
+ namespace?: string;
6
+ }
7
+ export interface ResourceRef extends ResourceQuery {
8
+ name: string;
9
+ }
10
+ export interface ResourceListQuery extends ResourceQuery {
11
+ labelSelector?: string;
12
+ fieldSelector?: string;
13
+ limit?: number;
14
+ continue?: string;
15
+ }
16
+ /** Preserve the server manifest; consumers own projections into domain models. */
2
17
  export interface Resource {
18
+ [field: string]: unknown;
19
+ apiVersion?: string;
20
+ kind?: string;
3
21
  metadata?: {
4
22
  name?: string;
5
23
  namespace?: string;
6
24
  uid?: string;
25
+ [field: string]: unknown;
7
26
  };
8
27
  spec?: {
9
28
  selector?: unknown;
29
+ [field: string]: unknown;
10
30
  };
11
- items?: Resource[];
31
+ }
32
+ export interface ResourceList {
33
+ apiVersion?: string;
12
34
  kind?: string;
35
+ metadata?: {
36
+ continue?: string;
37
+ resourceVersion?: string;
38
+ [field: string]: unknown;
39
+ };
40
+ items: Resource[];
41
+ [field: string]: unknown;
42
+ }
43
+ export interface ResourceRequestOptions {
44
+ signal?: AbortSignal;
45
+ /** Total budget, including capacity wait, authentication and response consumption. */
46
+ timeoutMs?: number;
13
47
  }
14
48
  export interface ResourceAccess {
15
- get(namespace: string, resource: string, name?: string, selector?: string): Promise<Resource>;
49
+ get(ref: ResourceRef, options?: ResourceRequestOptions): Promise<Resource>;
50
+ list(query: ResourceListQuery, options?: ResourceRequestOptions): Promise<ResourceList>;
16
51
  }
17
52
  export interface ResourceLimits {
18
53
  timeoutMs: number;
19
54
  concurrency: number;
20
55
  maxBytes: number;
21
56
  }
22
- /** Native JSON reads use the same client-node auth/TLS path as native Pod logs. */
23
- export declare function resourceAccess(options: KubectlOptions, signal: AbortSignal, limits: ResourceLimits): ResourceAccess;
57
+ /** Namespace views borrow the root client and cannot close it or override their scope. */
58
+ export interface NamespaceResources {
59
+ get(ref: Omit<ResourceRef, "namespace">, options?: ResourceRequestOptions): Promise<Resource>;
60
+ list(query: Omit<ResourceListQuery, "namespace">, options?: ResourceRequestOptions): Promise<ResourceList>;
61
+ }
62
+ /** Explicit scope avoids interpreting an omitted namespace as a cluster-wide list. */
63
+ export declare function resourcePath(query: ResourceQuery, name?: string): string;
@@ -1,8 +1,7 @@
1
1
  import {
2
- resourceAccess
3
- } from "../chunk-9n9z2pty.js";
2
+ resourcePath
3
+ } from "../chunk-ftkrjqkc.js";
4
4
  import"../chunk-20kv4wc3.js";
5
- import"../chunk-v3hxvnrc.js";
6
5
  export {
7
- resourceAccess
6
+ resourcePath
8
7
  };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  resolveWorkload
3
- } from "../chunk-8axd2y2m.js";
3
+ } from "../chunk-0s27bgga.js";
4
4
  import"../chunk-20kv4wc3.js";
5
5
  export {
6
6
  resolveWorkload
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@compforge/harness-toolbox",
3
- "version": "0.3.11",
3
+ "version": "0.4.0",
4
4
  "description": "Shared infrastructure clients, transports and bounded data capture",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -114,6 +114,9 @@
114
114
  "@types/node-fetch": "2.6.13",
115
115
  "@types/node": "^22"
116
116
  },
117
+ "overrides": {
118
+ "@compforge/harness-common": "file:../common"
119
+ },
117
120
  "publishConfig": {
118
121
  "access": "public"
119
122
  },
@@ -1,89 +0,0 @@
1
- import {
2
- KubernetesError,
3
- ToolboxError,
4
- kubernetesHttpError
5
- } from "./chunk-20kv4wc3.js";
6
- import {
7
- ConcurrencyPool
8
- } from "./chunk-v3hxvnrc.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 };