@compforge/harness-toolbox 0.5.2 → 0.5.4
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
|
@@ -430,3 +430,14 @@ timeouts remain separate, and failed operations are never replayed on another tr
|
|
|
430
430
|
DataLoader accepts an optional `logger.debug(message)` and a non-sensitive `label`.
|
|
431
431
|
Diagnostics distinguish miss, wait, hit, bypass, not-retained and close using scope/read
|
|
432
432
|
identifiers, never keys, values or errors. Logging does not change retention or retry policy.
|
|
433
|
+
|
|
434
|
+
### Network connectivity
|
|
435
|
+
|
|
436
|
+
`@compforge/harness-toolbox/network` exports `probeConnectivity({host, port, tls?}, execute, {timeoutMs, signal?})`.
|
|
437
|
+
The caller supplies an executor in the consumer environment (host, Pod or sandbox),
|
|
438
|
+
which runs Python 3 with the supplied argv and stdin and enforces timeout/cancellation.
|
|
439
|
+
DNS, TCP and optional verified TLS have separate results and timings. Domain names,
|
|
440
|
+
IP literals and Kubernetes `service.namespace` names use the same API. There is no
|
|
441
|
+
implicit execution on the caller host or ICMP requirement. Network failure returns
|
|
442
|
+
evidence; missing Python, execution failure and incomplete output throw. TLS success
|
|
443
|
+
does not establish HTTP authorization, object access or application download policy.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { type Client, type ClientManager, type ClientProvider, type DataSource } from "@compforge/harness-common";
|
|
2
|
+
import { type ResourceAccess, type ResourceRequestOptions } from "./resources";
|
|
3
|
+
export interface KubernetesSecretTarget {
|
|
4
|
+
readonly namespace: string;
|
|
5
|
+
readonly name: string;
|
|
6
|
+
}
|
|
7
|
+
export interface KubernetesSecretRead {
|
|
8
|
+
resourceVersion?: string;
|
|
9
|
+
observedAt: string;
|
|
10
|
+
values: Record<string, string>;
|
|
11
|
+
/** A successful read can establish absence without treating it as an API failure. */
|
|
12
|
+
missingKeys: string[];
|
|
13
|
+
}
|
|
14
|
+
export type KubernetesSecretAccess = Client & Pick<ResourceAccess, "get">;
|
|
15
|
+
export declare function secretKeys(keys: readonly string[]): readonly string[];
|
|
16
|
+
/**
|
|
17
|
+
* @spec Only declared UTF-8 keys enter results; Kubernetes failures retain their kind and never become missing keys.
|
|
18
|
+
* @rule The borrowed resource access owns transport capacity and byte limits; request deadlines and cancellation flow through get.
|
|
19
|
+
* @see {@link component://../../../docs/toolbox.md}
|
|
20
|
+
*/
|
|
21
|
+
export declare class KubernetesSecretClient implements Client {
|
|
22
|
+
#private;
|
|
23
|
+
private readonly acquire;
|
|
24
|
+
readonly keys: readonly string[];
|
|
25
|
+
constructor(keys: readonly string[], signal: AbortSignal, acquire: () => Promise<{
|
|
26
|
+
target: KubernetesSecretTarget;
|
|
27
|
+
access: Pick<ResourceAccess, "get">;
|
|
28
|
+
}>);
|
|
29
|
+
get target(): Readonly<KubernetesSecretTarget>;
|
|
30
|
+
initialize(): Promise<void>;
|
|
31
|
+
read(options?: ResourceRequestOptions): Promise<KubernetesSecretRead>;
|
|
32
|
+
private capture;
|
|
33
|
+
/** @spec Disposal cancels and drains owned reads without closing borrowed Kubernetes access. */
|
|
34
|
+
dispose(): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
/** Named Secret acquisition; callers own discovery, access policy and interpretation of the selected values. */
|
|
37
|
+
export declare class KubernetesSecretDataSource implements DataSource<KubernetesSecretClient> {
|
|
38
|
+
private readonly kubernetes;
|
|
39
|
+
readonly clientKey: string;
|
|
40
|
+
readonly target: Readonly<KubernetesSecretTarget>;
|
|
41
|
+
readonly keys: readonly string[];
|
|
42
|
+
constructor(kubernetes: ClientProvider<KubernetesSecretAccess>, target: KubernetesSecretTarget, keys: readonly string[]);
|
|
43
|
+
createClient(clients: Pick<ClientManager, "get">, signal: AbortSignal): KubernetesSecretClient;
|
|
44
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resourcePath
|
|
3
|
+
} from "../chunk-dpcge1xt.js";
|
|
4
|
+
import {
|
|
5
|
+
KubernetesError
|
|
6
|
+
} from "../chunk-rkt41za2.js";
|
|
7
|
+
|
|
8
|
+
// src/kubernetes/secret-datasource.ts
|
|
9
|
+
import { clientKey } from "@compforge/harness-common";
|
|
10
|
+
function secretKeys(keys) {
|
|
11
|
+
if (!keys.length || keys.some((key) => !/^[A-Za-z0-9._-]+$/.test(key)) || new Set(keys).size !== keys.length) {
|
|
12
|
+
throw new KubernetesError("Secret keys must be nonempty, distinct Kubernetes data keys", { kind: "invalid_argument" });
|
|
13
|
+
}
|
|
14
|
+
return Object.freeze([...keys]);
|
|
15
|
+
}
|
|
16
|
+
function secretTarget(target) {
|
|
17
|
+
if (!target.namespace || !target.name)
|
|
18
|
+
throw new KubernetesError("Secret target requires namespace and name", { kind: "invalid_argument" });
|
|
19
|
+
resourcePath({ apiVersion: "v1", resource: "secrets", namespace: target.namespace }, target.name);
|
|
20
|
+
return Object.freeze({ namespace: target.namespace, name: target.name });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
class KubernetesSecretClient {
|
|
24
|
+
acquire;
|
|
25
|
+
keys;
|
|
26
|
+
#controller = new AbortController;
|
|
27
|
+
#signal;
|
|
28
|
+
#operations = new Set;
|
|
29
|
+
#target;
|
|
30
|
+
#access;
|
|
31
|
+
#disposal;
|
|
32
|
+
constructor(keys, signal, acquire) {
|
|
33
|
+
this.acquire = acquire;
|
|
34
|
+
this.keys = secretKeys(keys);
|
|
35
|
+
this.#signal = AbortSignal.any([signal, this.#controller.signal]);
|
|
36
|
+
}
|
|
37
|
+
get target() {
|
|
38
|
+
if (!this.#target)
|
|
39
|
+
throw new KubernetesError("Secret client is not initialized", { kind: "operation_failed" });
|
|
40
|
+
return this.#target;
|
|
41
|
+
}
|
|
42
|
+
async initialize() {
|
|
43
|
+
this.#signal.throwIfAborted();
|
|
44
|
+
const { target, access } = await this.acquire();
|
|
45
|
+
this.#signal.throwIfAborted();
|
|
46
|
+
this.#target = secretTarget(target);
|
|
47
|
+
this.#access = access;
|
|
48
|
+
}
|
|
49
|
+
async read(options = {}) {
|
|
50
|
+
const signal = options.signal ? AbortSignal.any([this.#signal, options.signal]) : this.#signal;
|
|
51
|
+
signal.throwIfAborted();
|
|
52
|
+
const target = this.target;
|
|
53
|
+
const operation = this.capture(target, { ...options, signal });
|
|
54
|
+
this.#operations.add(operation);
|
|
55
|
+
try {
|
|
56
|
+
return await operation;
|
|
57
|
+
} finally {
|
|
58
|
+
this.#operations.delete(operation);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
async capture(target, options) {
|
|
62
|
+
const secret = await this.#access.get({ apiVersion: "v1", resource: "secrets", ...target }, options);
|
|
63
|
+
options.signal.throwIfAborted();
|
|
64
|
+
const values = Object.create(null);
|
|
65
|
+
const missingKeys = [];
|
|
66
|
+
for (const key of this.keys) {
|
|
67
|
+
if (!Object.hasOwn(secret.data ?? {}, key)) {
|
|
68
|
+
missingKeys.push(key);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const encoded = secret.data[key];
|
|
72
|
+
try {
|
|
73
|
+
const bytes = Buffer.from(encoded, "base64");
|
|
74
|
+
if (bytes.toString("base64") !== encoded)
|
|
75
|
+
throw new Error("invalid base64");
|
|
76
|
+
values[key] = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
77
|
+
} catch {
|
|
78
|
+
throw new KubernetesError(`Secret key is not valid base64 UTF-8: ${key}`, { kind: "invalid_response" });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
values,
|
|
83
|
+
missingKeys,
|
|
84
|
+
observedAt: new Date().toISOString(),
|
|
85
|
+
...typeof secret.metadata?.resourceVersion === "string" ? { resourceVersion: secret.metadata.resourceVersion } : {}
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
dispose() {
|
|
89
|
+
this.#disposal ??= (async () => {
|
|
90
|
+
this.#controller.abort(new KubernetesError("Secret client disposed", { kind: "operation_failed" }));
|
|
91
|
+
await Promise.allSettled([...this.#operations]);
|
|
92
|
+
this.#target = undefined;
|
|
93
|
+
this.#access = undefined;
|
|
94
|
+
})();
|
|
95
|
+
return this.#disposal;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
class KubernetesSecretDataSource {
|
|
100
|
+
kubernetes;
|
|
101
|
+
clientKey;
|
|
102
|
+
target;
|
|
103
|
+
keys;
|
|
104
|
+
constructor(kubernetes, target, keys) {
|
|
105
|
+
this.kubernetes = kubernetes;
|
|
106
|
+
this.target = secretTarget(target);
|
|
107
|
+
this.keys = secretKeys(keys);
|
|
108
|
+
this.clientKey = clientKey("kubernetes-secret", { access: kubernetes.clientKey, target: this.target, keys: this.keys });
|
|
109
|
+
}
|
|
110
|
+
createClient(clients, signal) {
|
|
111
|
+
return new KubernetesSecretClient(this.keys, signal, async () => ({ target: this.target, access: await clients.get(this.kubernetes) }));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export {
|
|
115
|
+
secretKeys,
|
|
116
|
+
KubernetesSecretDataSource,
|
|
117
|
+
KubernetesSecretClient
|
|
118
|
+
};
|
package/dist/mysql/index.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import {
|
|
2
|
+
withReadScope
|
|
3
|
+
} from "../chunk-4sqvppz7.js";
|
|
1
4
|
import {
|
|
2
5
|
MysqlClient,
|
|
3
6
|
MysqlDatabase,
|
|
@@ -15,9 +18,6 @@ import {
|
|
|
15
18
|
} from "../chunk-1gyshqd9.js";
|
|
16
19
|
import"../chunk-835fssgz.js";
|
|
17
20
|
import"../chunk-pfar9cm3.js";
|
|
18
|
-
import {
|
|
19
|
-
withReadScope
|
|
20
|
-
} from "../chunk-4sqvppz7.js";
|
|
21
21
|
import"../chunk-18gcp0ck.js";
|
|
22
22
|
import"../chunk-kap6ka3n.js";
|
|
23
23
|
import"../chunk-v3hxvnrc.js";
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** A probe runs in the caller-selected environment; it never falls back to the Doctor/CLI host. */
|
|
2
|
+
export interface ConnectivityTarget {
|
|
3
|
+
host: string;
|
|
4
|
+
port: number;
|
|
5
|
+
tls?: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface ConnectivityStage {
|
|
8
|
+
stage: "dns" | "tcp" | "tls";
|
|
9
|
+
status: "passed" | "failed";
|
|
10
|
+
elapsedMs: number;
|
|
11
|
+
addresses?: string[];
|
|
12
|
+
address?: string;
|
|
13
|
+
error?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface ConnectivityResult {
|
|
16
|
+
target: ConnectivityTarget;
|
|
17
|
+
stages: ConnectivityStage[];
|
|
18
|
+
status: "passed" | "failed";
|
|
19
|
+
}
|
|
20
|
+
export interface ConnectivityExecutor {
|
|
21
|
+
(command: readonly string[], options: {
|
|
22
|
+
stdin: string;
|
|
23
|
+
timeoutMs: number;
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}): Promise<string>;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* @spec DNS, TCP and optional verified TLS are observed from the supplied executor's network context.
|
|
29
|
+
* @rule A hostname (including service.namespace) and an IP use the same path; connectivity requires an explicit port.
|
|
30
|
+
* @rule Tool failure/timeout throws; it is not evidence that the target is unhealthy. No ICMP, credentials or HTTP policy is implied.
|
|
31
|
+
*/
|
|
32
|
+
export declare function probeConnectivity(target: ConnectivityTarget, execute: ConnectivityExecutor, options: {
|
|
33
|
+
timeoutMs: number;
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
}): Promise<ConnectivityResult>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// src/network/connectivity.ts
|
|
2
|
+
var SCRIPT = `import json,socket,ssl,sys,time
|
|
3
|
+
q=json.load(sys.stdin)
|
|
4
|
+
start=time.monotonic()
|
|
5
|
+
deadline=start+q["timeoutMs"]/1000
|
|
6
|
+
stages=[]
|
|
7
|
+
def remaining():
|
|
8
|
+
t=deadline-time.monotonic()
|
|
9
|
+
if t<=0: raise TimeoutError()
|
|
10
|
+
return t
|
|
11
|
+
def add(stage,t,**values):
|
|
12
|
+
stages.append(dict(stage=stage,elapsedMs=round((time.monotonic()-t)*1000,3),**values))
|
|
13
|
+
phase="dns"
|
|
14
|
+
t=time.monotonic()
|
|
15
|
+
sock=None
|
|
16
|
+
try:
|
|
17
|
+
addresses=socket.getaddrinfo(q["host"],q["port"],type=socket.SOCK_STREAM)
|
|
18
|
+
if not addresses: raise socket.gaierror()
|
|
19
|
+
add(phase,t,status="passed",addresses=list(dict.fromkeys(a[4][0] for a in addresses))[:8])
|
|
20
|
+
phase="tcp"
|
|
21
|
+
t=time.monotonic()
|
|
22
|
+
failure=None
|
|
23
|
+
for family,kind,proto,_,address in addresses[:8]:
|
|
24
|
+
candidate=socket.socket(family,kind,proto)
|
|
25
|
+
try:
|
|
26
|
+
candidate.settimeout(remaining())
|
|
27
|
+
candidate.connect(address)
|
|
28
|
+
sock=candidate
|
|
29
|
+
break
|
|
30
|
+
except OSError as e:
|
|
31
|
+
failure=e
|
|
32
|
+
candidate.close()
|
|
33
|
+
if sock is None: raise failure or OSError()
|
|
34
|
+
add(phase,t,status="passed",address=sock.getpeername()[0])
|
|
35
|
+
if q.get("tls"):
|
|
36
|
+
phase="tls"
|
|
37
|
+
t=time.monotonic()
|
|
38
|
+
sock.settimeout(remaining())
|
|
39
|
+
sock=ssl.create_default_context().wrap_socket(sock,server_hostname=q["host"])
|
|
40
|
+
add(phase,t,status="passed")
|
|
41
|
+
except (OSError,ValueError) as e:
|
|
42
|
+
add(phase,t,status="failed",error=type(e).__name__)
|
|
43
|
+
finally:
|
|
44
|
+
if sock is not None: sock.close()
|
|
45
|
+
print(json.dumps(dict(stages=stages,status="failed" if any(s["status"]=="failed" for s in stages) else "passed")))`;
|
|
46
|
+
async function probeConnectivity(target, execute, options) {
|
|
47
|
+
if (!target.host.trim() || /[\s/\\?#@]/.test(target.host) || !Number.isInteger(target.port) || target.port < 1 || target.port > 65535) {
|
|
48
|
+
throw new Error("Connectivity requires a hostname/IP and a port between 1 and 65535");
|
|
49
|
+
}
|
|
50
|
+
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)
|
|
51
|
+
throw new Error("Connectivity timeout must be positive");
|
|
52
|
+
options.signal?.throwIfAborted();
|
|
53
|
+
const raw = await execute(["python3", "-c", SCRIPT], { ...options, stdin: JSON.stringify({ ...target, timeoutMs: options.timeoutMs }) });
|
|
54
|
+
options.signal?.throwIfAborted();
|
|
55
|
+
const value = JSON.parse(raw);
|
|
56
|
+
const expected = ["dns", "tcp", ...target.tls ? ["tls"] : []];
|
|
57
|
+
if (!value || !Array.isArray(value.stages) || !value.stages.length || value.stages.length > expected.length || !["passed", "failed"].includes(value.status) || value.stages.some((stage, index) => !stage || stage.stage !== expected[index] || !["passed", "failed"].includes(stage.status) || !Number.isFinite(stage.elapsedMs) || stage.elapsedMs < 0 || index < value.stages.length - 1 && stage.status !== "passed") || (value.status === "passed" ? value.stages.length !== expected.length || value.stages.some((stage) => stage.status !== "passed") : value.stages.at(-1)?.status !== "failed"))
|
|
58
|
+
throw new Error("Invalid connectivity probe result");
|
|
59
|
+
return { target: { ...target }, stages: value.stages, status: value.status };
|
|
60
|
+
}
|
|
61
|
+
export {
|
|
62
|
+
probeConnectivity
|
|
63
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compforge/harness-toolbox",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.4",
|
|
4
4
|
"description": "Shared infrastructure clients, transports and bounded data capture",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -96,6 +96,10 @@
|
|
|
96
96
|
"./http": {
|
|
97
97
|
"types": "./dist/http/index.d.ts",
|
|
98
98
|
"import": "./dist/http/index.js"
|
|
99
|
+
},
|
|
100
|
+
"./network": {
|
|
101
|
+
"types": "./dist/network/connectivity.d.ts",
|
|
102
|
+
"import": "./dist/network/connectivity.js"
|
|
99
103
|
}
|
|
100
104
|
},
|
|
101
105
|
"files": [
|