@compforge/harness-toolbox 0.4.2 → 0.4.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 +28 -0
- package/dist/{chunk-c6z919qb.js → chunk-5tbzf9wd.js} +1 -1
- package/dist/chunk-5zgz1z41.js +145 -0
- package/dist/{chunk-8kts470p.js → chunk-c2n8zvyn.js} +12 -6
- package/dist/chunk-yp6tcw22.js +366 -0
- package/dist/kubernetes/client.d.ts +4 -0
- package/dist/kubernetes/client.js +3 -3
- package/dist/kubernetes/compute-model.d.ts +115 -0
- package/dist/kubernetes/compute-model.js +0 -0
- package/dist/kubernetes/compute-quantity.d.ts +18 -0
- package/dist/kubernetes/compute-quantity.js +23 -0
- package/dist/kubernetes/compute.d.ts +17 -0
- package/dist/kubernetes/compute.js +211 -0
- package/dist/kubernetes/environment.js +3 -3
- package/dist/redis/client.d.ts +27 -8
- package/dist/redis/client.js +9 -1
- package/dist/redis/index.js +11 -4
- package/dist/redis/topology.d.ts +7 -4
- package/dist/redis/topology.js +1 -1
- package/package.json +4 -1
- package/dist/chunk-ajhdd4hp.js +0 -267
package/README.md
CHANGED
|
@@ -62,6 +62,26 @@ never close it themselves.
|
|
|
62
62
|
Version 0.4.2 requires Common 0.5.4. Replace single path arguments with arrays, supply `maxTotalBytes`,
|
|
63
63
|
and handle per-file results. Publish Common before Toolbox; this change has no single-file legacy adapter.
|
|
64
64
|
|
|
65
|
+
## Redis sources
|
|
66
|
+
|
|
67
|
+
`RedisClient` owns resolved configuration and reusable node connections. Use `clusterType` to
|
|
68
|
+
select `single`, `cluster` or `sentinel`; `endpoints` contains that mode's seed addresses. Sentinel
|
|
69
|
+
configuration additionally provides `sentinelMasterName` and optional Sentinel credentials,
|
|
70
|
+
separate from data-node credentials. Cluster connections require database zero.
|
|
71
|
+
|
|
72
|
+
Supply ordered TCP transports through `ConnectionSource`. Only mapping or handshake network
|
|
73
|
+
failures advance to the next transport; authentication and TLS verification failures stop immediately.
|
|
74
|
+
`connectTimeoutMs` bounds each route (mapping and handshake); it defaults to the command
|
|
75
|
+
`timeoutMs`. `acquireTimeoutMs` bounds all candidates together and defaults to the per-route budget
|
|
76
|
+
times the number of routes. A spent total budget does not start another route.
|
|
77
|
+
`onRoute` reports the selected route and the preceding failure code without credentials.
|
|
78
|
+
|
|
79
|
+
`client.topology()` refreshes topology using the same connections and route policy as
|
|
80
|
+
`client.access.connection(endpoint, database)`. A root signal cancels connection preparation and
|
|
81
|
+
active commands. Commands and pipelines are sent once, with no offline queue or automatic replay;
|
|
82
|
+
a subsequent acquisition replaces an unhealthy connection. The caller owns `dispose()` and transport
|
|
83
|
+
cleanup, and diagnostics own sampling policy and collected observations.
|
|
84
|
+
|
|
65
85
|
## Kubernetes resources
|
|
66
86
|
|
|
67
87
|
Borrow a root `KubernetesClient` through `KubernetesEnvironment` and `ClientManager`. Use
|
|
@@ -356,3 +376,11 @@ reason. The caller owns shared transports such as `PodRelayTransport` and their
|
|
|
356
376
|
|
|
357
377
|
Routed HTTPS preserves the original TLS server name. Bun disables HTTP keep-alive because its reused
|
|
358
378
|
socket recovery can replay a failed request below the driver; Node retains connection pooling.
|
|
379
|
+
|
|
380
|
+
## Kubernetes compute evidence
|
|
381
|
+
|
|
382
|
+
`kubernetes/compute` exports `collectClusterCompute` and `collectWorkloadCompute`. They borrow
|
|
383
|
+
ResourceAccess, use explicit deadlines/item/page budgets, and retain read coverage independently.
|
|
384
|
+
Use `client.clusterResources()` for Nodes and all-namespace Pods; normal client methods retain their
|
|
385
|
+
configured namespace. A forbidden cluster read records `permission_denied`; it does not block a
|
|
386
|
+
separate namespace-scoped Workload read. See [compute collection](../../../docs/toolbox.md#kubernetes-compute-evidence).
|
|
@@ -26,7 +26,7 @@ function sentinelRows(value) {
|
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
28
|
async function discoverSentinel(access, config) {
|
|
29
|
-
const sentinels = config.
|
|
29
|
+
const sentinels = config.endpoints;
|
|
30
30
|
let lastError;
|
|
31
31
|
for (const sentinel of sentinels) {
|
|
32
32
|
try {
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import {
|
|
2
|
+
KubernetesError
|
|
3
|
+
} from "./chunk-20kv4wc3.js";
|
|
4
|
+
|
|
5
|
+
// src/kubernetes/compute-quantity.ts
|
|
6
|
+
function object(value) {
|
|
7
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
8
|
+
}
|
|
9
|
+
function invalid(message) {
|
|
10
|
+
throw new KubernetesError(message, { kind: "invalid_response" });
|
|
11
|
+
}
|
|
12
|
+
function requiredString(value, field) {
|
|
13
|
+
if (typeof value !== "string" || !value)
|
|
14
|
+
return invalid(`Kubernetes response lacks ${field}`);
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
function parseComputeQuantity(value) {
|
|
18
|
+
if (typeof value !== "string")
|
|
19
|
+
return invalid("Kubernetes quantity must be a string");
|
|
20
|
+
const match = /^([+-]?(?:\d+(?:\.\d*)?|\.\d+))([eE][+-]?\d+|[numkMGTPE]|[KMGTPE]i)?$/.exec(value);
|
|
21
|
+
if (!match)
|
|
22
|
+
return invalid("Invalid Kubernetes quantity");
|
|
23
|
+
const suffix = match[2] ?? "";
|
|
24
|
+
const factors = {
|
|
25
|
+
"": 1,
|
|
26
|
+
n: 0.000000001,
|
|
27
|
+
u: 0.000001,
|
|
28
|
+
m: 0.001,
|
|
29
|
+
k: 1000,
|
|
30
|
+
M: 1e6,
|
|
31
|
+
G: 1e9,
|
|
32
|
+
T: 1000000000000,
|
|
33
|
+
P: 1000000000000000,
|
|
34
|
+
E: 1000000000000000000,
|
|
35
|
+
Ki: 2 ** 10,
|
|
36
|
+
Mi: 2 ** 20,
|
|
37
|
+
Gi: 2 ** 30,
|
|
38
|
+
Ti: 2 ** 40,
|
|
39
|
+
Pi: 2 ** 50,
|
|
40
|
+
Ei: 2 ** 60
|
|
41
|
+
};
|
|
42
|
+
const result = Number(match[1]) * (/^[eE][+-]?\d+$/.test(suffix) ? 10 ** Number(suffix.slice(1)) : factors[suffix]);
|
|
43
|
+
if (!Number.isFinite(result) || result < 0 || result > Number.MAX_SAFE_INTEGER) {
|
|
44
|
+
return invalid("Kubernetes quantity is outside the supported numeric range");
|
|
45
|
+
}
|
|
46
|
+
return result;
|
|
47
|
+
}
|
|
48
|
+
var zero = () => ({ cpu: 0, memory: 0 });
|
|
49
|
+
function add(left, right) {
|
|
50
|
+
return { cpu: left.cpu + right.cpu, memory: left.memory + right.memory };
|
|
51
|
+
}
|
|
52
|
+
function max(left, right) {
|
|
53
|
+
return { cpu: Math.max(left.cpu, right.cpu), memory: Math.max(left.memory, right.memory) };
|
|
54
|
+
}
|
|
55
|
+
function quantities(value, required = false) {
|
|
56
|
+
const input = object(value);
|
|
57
|
+
return {
|
|
58
|
+
cpu: input.cpu === undefined && !required ? 0 : parseComputeQuantity(input.cpu),
|
|
59
|
+
memory: input.memory === undefined && !required ? 0 : parseComputeQuantity(input.memory)
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function sum(values) {
|
|
63
|
+
return values.reduce(add, zero());
|
|
64
|
+
}
|
|
65
|
+
function containers(value) {
|
|
66
|
+
if (!Array.isArray(value))
|
|
67
|
+
return invalid("Pod response lacks containers");
|
|
68
|
+
return value.map((item) => {
|
|
69
|
+
const container = object(item);
|
|
70
|
+
requiredString(container.name, "container.name");
|
|
71
|
+
return container;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
function podCompute(value) {
|
|
75
|
+
const meta = object(value.metadata), spec = object(value.spec), status = object(value.status);
|
|
76
|
+
const app = containers(spec.containers);
|
|
77
|
+
if (!app.length)
|
|
78
|
+
return invalid("Pod must contain an application container");
|
|
79
|
+
const init = containers(spec.initContainers ?? []);
|
|
80
|
+
const effective = (field) => {
|
|
81
|
+
let steady = sum(app.map((c) => quantities(object(c.resources)[field])));
|
|
82
|
+
let sidecars = zero(), peak = zero();
|
|
83
|
+
for (const container of init) {
|
|
84
|
+
const current = quantities(object(container.resources)[field]);
|
|
85
|
+
if (container.restartPolicy === "Always") {
|
|
86
|
+
sidecars = add(sidecars, current);
|
|
87
|
+
steady = add(steady, current);
|
|
88
|
+
peak = max(peak, sidecars);
|
|
89
|
+
} else
|
|
90
|
+
peak = max(peak, add(sidecars, current));
|
|
91
|
+
}
|
|
92
|
+
const result = max(steady, peak);
|
|
93
|
+
const podLevel = object(object(spec.resources)[field]);
|
|
94
|
+
const overhead = quantities(spec.overhead);
|
|
95
|
+
for (const key of ["cpu", "memory"]) {
|
|
96
|
+
if (podLevel[key] !== undefined)
|
|
97
|
+
result[key] = parseComputeQuantity(podLevel[key]);
|
|
98
|
+
if (field === "requests" || result[key] > 0)
|
|
99
|
+
result[key] += overhead[key];
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
};
|
|
103
|
+
const without = zero();
|
|
104
|
+
for (const container of [...app, ...init]) {
|
|
105
|
+
const limits = object(object(container.resources).limits);
|
|
106
|
+
for (const key of ["cpu", "memory"])
|
|
107
|
+
if (limits[key] === undefined)
|
|
108
|
+
without[key]++;
|
|
109
|
+
}
|
|
110
|
+
const conditions = Array.isArray(status.conditions) ? status.conditions.map(object) : [];
|
|
111
|
+
const states = [
|
|
112
|
+
...Array.isArray(status.containerStatuses) ? status.containerStatuses : [],
|
|
113
|
+
...Array.isArray(status.initContainerStatuses) ? status.initContainerStatuses : []
|
|
114
|
+
].map(object);
|
|
115
|
+
return {
|
|
116
|
+
namespace: requiredString(meta.namespace, "metadata.namespace"),
|
|
117
|
+
name: requiredString(meta.name, "metadata.name"),
|
|
118
|
+
uid: requiredString(meta.uid, "metadata.uid"),
|
|
119
|
+
createdAt: typeof meta.creationTimestamp === "string" ? meta.creationTimestamp : undefined,
|
|
120
|
+
node: typeof spec.nodeName === "string" && spec.nodeName ? spec.nodeName : undefined,
|
|
121
|
+
phase: requiredString(status.phase, "status.phase"),
|
|
122
|
+
ready: conditions.some((c) => c.type === "Ready" && c.status === "True"),
|
|
123
|
+
restarts: states.reduce((n, c) => n + (typeof c.restartCount === "number" ? c.restartCount : 0), 0),
|
|
124
|
+
containers: [...app, ...init.filter((c) => c.restartPolicy === "Always")].map((c) => String(c.name)),
|
|
125
|
+
requests: effective("requests"),
|
|
126
|
+
configuredLimits: effective("limits"),
|
|
127
|
+
containersWithoutLimits: without
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function summarizePods(pods) {
|
|
131
|
+
const active = pods.filter((p) => p.phase !== "Succeeded" && p.phase !== "Failed");
|
|
132
|
+
const scheduled = active.filter((p) => p.node);
|
|
133
|
+
return {
|
|
134
|
+
pods: pods.length,
|
|
135
|
+
activePods: active.length,
|
|
136
|
+
scheduledPods: scheduled.length,
|
|
137
|
+
readyPods: active.filter((p) => p.ready).length,
|
|
138
|
+
requests: sum(active.map((p) => p.requests)),
|
|
139
|
+
scheduledRequests: sum(scheduled.map((p) => p.requests)),
|
|
140
|
+
configuredLimits: sum(active.map((p) => p.configuredLimits)),
|
|
141
|
+
containersWithoutLimits: sum(active.map((p) => p.containersWithoutLimits))
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export { object, requiredString, parseComputeQuantity, zero, add, quantities, sum, podCompute, summarizePods };
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import {
|
|
2
|
-
KubernetesResourceTransport
|
|
3
|
-
} from "./chunk-4ms037k2.js";
|
|
4
|
-
import {
|
|
5
|
-
resolveWorkload
|
|
6
|
-
} from "./chunk-0s27bgga.js";
|
|
7
1
|
import {
|
|
8
2
|
ServicePortForwarder
|
|
9
3
|
} from "./chunk-2171tajs.js";
|
|
4
|
+
import {
|
|
5
|
+
KubernetesResourceTransport
|
|
6
|
+
} from "./chunk-4ms037k2.js";
|
|
10
7
|
import {
|
|
11
8
|
KubernetesExecTransport
|
|
12
9
|
} from "./chunk-shsabpc1.js";
|
|
10
|
+
import {
|
|
11
|
+
resolveWorkload
|
|
12
|
+
} from "./chunk-0s27bgga.js";
|
|
13
13
|
import {
|
|
14
14
|
KubernetesError
|
|
15
15
|
} from "./chunk-20kv4wc3.js";
|
|
@@ -83,6 +83,12 @@ class KubernetesClient {
|
|
|
83
83
|
list(query, options) {
|
|
84
84
|
return this.#track(() => this.#resourceAccess().list({ ...query, namespace: query.namespace ?? this.kube.namespace }, this.#options(options)));
|
|
85
85
|
}
|
|
86
|
+
clusterResources() {
|
|
87
|
+
return {
|
|
88
|
+
get: (ref, options) => this.#track(() => this.#resourceAccess().get(ref, this.#options(options))),
|
|
89
|
+
list: (query, options) => this.#track(() => this.#resourceAccess().list(query, this.#options(options)))
|
|
90
|
+
};
|
|
91
|
+
}
|
|
86
92
|
inNamespace(namespace) {
|
|
87
93
|
if (!namespace.trim())
|
|
88
94
|
throw new KubernetesError("Namespace is required", { kind: "invalid_argument" });
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import {
|
|
2
|
+
discoverRedisTopology
|
|
3
|
+
} from "./chunk-5tbzf9wd.js";
|
|
4
|
+
import {
|
|
5
|
+
ToolboxError
|
|
6
|
+
} from "./chunk-20kv4wc3.js";
|
|
7
|
+
import {
|
|
8
|
+
isConnectionNetworkError
|
|
9
|
+
} from "./chunk-h8fwx0k7.js";
|
|
10
|
+
|
|
11
|
+
// src/redis/client.ts
|
|
12
|
+
import { createClient, SocketClosedUnexpectedlyError, ConnectionTimeoutError } from "@redis/client";
|
|
13
|
+
function stringValue(value) {
|
|
14
|
+
if (Buffer.isBuffer(value))
|
|
15
|
+
return value.toString("utf8");
|
|
16
|
+
return String(value ?? "");
|
|
17
|
+
}
|
|
18
|
+
function scalar(value) {
|
|
19
|
+
if (/^-?(?:\d+|\d+\.\d+)$/.test(value))
|
|
20
|
+
return Number(value);
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
function parseRedisInfo(raw) {
|
|
24
|
+
const result = {};
|
|
25
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
26
|
+
if (!line || line.startsWith("#"))
|
|
27
|
+
continue;
|
|
28
|
+
const separator = line.indexOf(":");
|
|
29
|
+
if (separator < 1)
|
|
30
|
+
continue;
|
|
31
|
+
const name = line.slice(0, separator);
|
|
32
|
+
const value = line.slice(separator + 1);
|
|
33
|
+
if (/^db\d+$/.test(name)) {
|
|
34
|
+
result[name] = Object.fromEntries(value.split(",").map((item) => {
|
|
35
|
+
const index = item.indexOf("=");
|
|
36
|
+
return [item.slice(0, index), scalar(item.slice(index + 1))];
|
|
37
|
+
}));
|
|
38
|
+
} else {
|
|
39
|
+
result[name] = scalar(value);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
async function abortable(pending, signal, cancel) {
|
|
45
|
+
if (signal.aborted) {
|
|
46
|
+
cancel?.();
|
|
47
|
+
pending.catch(() => {});
|
|
48
|
+
signal.throwIfAborted();
|
|
49
|
+
}
|
|
50
|
+
let abort;
|
|
51
|
+
const cancelled = new Promise((_, reject) => {
|
|
52
|
+
abort = () => {
|
|
53
|
+
cancel?.();
|
|
54
|
+
reject(signal.reason);
|
|
55
|
+
};
|
|
56
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
57
|
+
});
|
|
58
|
+
try {
|
|
59
|
+
return await Promise.race([pending, cancelled]);
|
|
60
|
+
} finally {
|
|
61
|
+
signal.removeEventListener("abort", abort);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class RedisConnection {
|
|
66
|
+
endpoint;
|
|
67
|
+
client;
|
|
68
|
+
timeoutMs;
|
|
69
|
+
lastError;
|
|
70
|
+
signal;
|
|
71
|
+
#abort;
|
|
72
|
+
constructor(endpoint, client, timeoutMs, lastError, signal) {
|
|
73
|
+
this.endpoint = endpoint;
|
|
74
|
+
this.client = client;
|
|
75
|
+
this.timeoutMs = timeoutMs;
|
|
76
|
+
this.lastError = lastError;
|
|
77
|
+
this.signal = signal;
|
|
78
|
+
this.#abort = () => this.close();
|
|
79
|
+
signal?.addEventListener("abort", this.#abort, { once: true });
|
|
80
|
+
}
|
|
81
|
+
static async connect(endpoint, mapped, config, clientFactory = createClient) {
|
|
82
|
+
config.signal?.throwIfAborted();
|
|
83
|
+
config.connectSignal?.throwIfAborted();
|
|
84
|
+
const baseSocket = {
|
|
85
|
+
host: mapped.host,
|
|
86
|
+
port: mapped.port,
|
|
87
|
+
connectTimeout: config.connectTimeoutMs ?? config.timeoutMs,
|
|
88
|
+
reconnectStrategy: false
|
|
89
|
+
};
|
|
90
|
+
const socket = config.useSsl ? { ...baseSocket, tls: true, servername: mapped.servername ?? endpoint.host } : { ...baseSocket, tls: false };
|
|
91
|
+
const client = clientFactory({
|
|
92
|
+
socket,
|
|
93
|
+
database: config.database,
|
|
94
|
+
username: config.username,
|
|
95
|
+
password: config.password,
|
|
96
|
+
disableOfflineQueue: true,
|
|
97
|
+
commandOptions: { timeout: config.timeoutMs }
|
|
98
|
+
});
|
|
99
|
+
let lastError;
|
|
100
|
+
client.on("error", (err) => {
|
|
101
|
+
lastError = err instanceof Error ? err : new Error(String(err));
|
|
102
|
+
});
|
|
103
|
+
const timeout = new AbortController;
|
|
104
|
+
const timer = setTimeout(() => timeout.abort(new ToolboxError("Redis handshake timed out", { kind: "timeout", code: "ETIMEDOUT" })), config.connectTimeoutMs ?? config.timeoutMs);
|
|
105
|
+
const signal = AbortSignal.any([
|
|
106
|
+
timeout.signal,
|
|
107
|
+
...config.signal ? [config.signal] : [],
|
|
108
|
+
...config.connectSignal ? [config.connectSignal] : []
|
|
109
|
+
]);
|
|
110
|
+
const close = () => {
|
|
111
|
+
if (client.isOpen)
|
|
112
|
+
client.destroy();
|
|
113
|
+
};
|
|
114
|
+
try {
|
|
115
|
+
await abortable(client.connect(), signal, close);
|
|
116
|
+
signal.throwIfAborted();
|
|
117
|
+
return new RedisConnection(endpoint, client, config.timeoutMs, () => lastError, config.signal);
|
|
118
|
+
} catch (error) {
|
|
119
|
+
close();
|
|
120
|
+
throw error;
|
|
121
|
+
} finally {
|
|
122
|
+
clearTimeout(timer);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
get isReady() {
|
|
126
|
+
return !this.signal?.aborted && this.client.isReady;
|
|
127
|
+
}
|
|
128
|
+
async#execute(operationName, operation) {
|
|
129
|
+
this.signal?.throwIfAborted();
|
|
130
|
+
if (!this.client.isOpen) {
|
|
131
|
+
const reason = this.lastError()?.message;
|
|
132
|
+
throw new Error(`Redis connection is closed${reason ? `: ${reason}` : ""}`);
|
|
133
|
+
}
|
|
134
|
+
const timeout = new AbortController;
|
|
135
|
+
const timer = setTimeout(() => timeout.abort(new ToolboxError(`Redis ${operationName} timed out after ${this.timeoutMs}ms (${this.endpoint.host}:${this.endpoint.port})`, { kind: "timeout", code: "ETIMEDOUT" })), this.timeoutMs);
|
|
136
|
+
const signal = this.signal ? AbortSignal.any([this.signal, timeout.signal]) : timeout.signal;
|
|
137
|
+
try {
|
|
138
|
+
return await abortable(Promise.resolve().then(() => {
|
|
139
|
+
signal.throwIfAborted();
|
|
140
|
+
return operation();
|
|
141
|
+
}), signal, () => this.close());
|
|
142
|
+
} finally {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
ping() {
|
|
147
|
+
return this.#execute("PING", () => this.client.ping());
|
|
148
|
+
}
|
|
149
|
+
async info(section) {
|
|
150
|
+
const raw = await this.#execute(section ? `INFO ${section}` : "INFO", () => section ? this.client.sendCommand(["INFO", section]) : this.client.sendCommand(["INFO"]));
|
|
151
|
+
return parseRedisInfo(stringValue(raw));
|
|
152
|
+
}
|
|
153
|
+
async dbSize() {
|
|
154
|
+
return Number(await this.#execute("DBSIZE", () => this.client.sendCommand(["DBSIZE"])));
|
|
155
|
+
}
|
|
156
|
+
command(args) {
|
|
157
|
+
return this.#execute(args[0]?.toUpperCase() ?? "command", () => this.client.sendCommand(args));
|
|
158
|
+
}
|
|
159
|
+
async pipeline(commands) {
|
|
160
|
+
const commandNames = new Set(commands.map((command) => command[0]?.toUpperCase()).filter(Boolean));
|
|
161
|
+
const operationName = commandNames.size === 1 ? `${[...commandNames][0]} pipeline` : "pipeline";
|
|
162
|
+
return await this.#execute(operationName, () => {
|
|
163
|
+
const pipeline = this.client.multi();
|
|
164
|
+
for (const command of commands)
|
|
165
|
+
pipeline.addCommand(command);
|
|
166
|
+
return pipeline.execAsPipeline();
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
async scan(cursor, count) {
|
|
170
|
+
const reply = await this.command(["SCAN", cursor, "COUNT", String(count)]);
|
|
171
|
+
if (!Array.isArray(reply) || !Array.isArray(reply[1]))
|
|
172
|
+
throw new Error("Redis SCAN 返回格式无效");
|
|
173
|
+
return { cursor: stringValue(reply[0]), keys: reply[1].map(stringValue) };
|
|
174
|
+
}
|
|
175
|
+
close() {
|
|
176
|
+
if (this.#abort)
|
|
177
|
+
this.signal?.removeEventListener("abort", this.#abort);
|
|
178
|
+
if (this.client.isOpen)
|
|
179
|
+
this.client.destroy();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
class RedisAccess {
|
|
184
|
+
baseConfig;
|
|
185
|
+
connectionFactory;
|
|
186
|
+
lifecycle;
|
|
187
|
+
#connections = new Map;
|
|
188
|
+
#controller = new AbortController;
|
|
189
|
+
#signal;
|
|
190
|
+
#transports;
|
|
191
|
+
#closing;
|
|
192
|
+
constructor(transports, baseConfig, connectionFactory = RedisConnection.connect, lifecycle = {}) {
|
|
193
|
+
this.baseConfig = baseConfig;
|
|
194
|
+
this.connectionFactory = connectionFactory;
|
|
195
|
+
this.lifecycle = lifecycle;
|
|
196
|
+
this.#transports = Array.isArray(transports) ? transports : [transports];
|
|
197
|
+
if (!this.#transports.length)
|
|
198
|
+
throw new Error("Redis requires a TCP transport");
|
|
199
|
+
for (const value of [
|
|
200
|
+
baseConfig.timeoutMs,
|
|
201
|
+
baseConfig.connectTimeoutMs ?? baseConfig.timeoutMs,
|
|
202
|
+
baseConfig.acquireTimeoutMs ?? (baseConfig.connectTimeoutMs ?? baseConfig.timeoutMs) * this.#transports.length
|
|
203
|
+
]) {
|
|
204
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
205
|
+
throw new Error("Redis timeouts must be positive milliseconds");
|
|
206
|
+
}
|
|
207
|
+
this.#signal = lifecycle.signal ? AbortSignal.any([lifecycle.signal, this.#controller.signal]) : this.#controller.signal;
|
|
208
|
+
}
|
|
209
|
+
async connection(endpoint, database, credentials = this.baseConfig) {
|
|
210
|
+
this.#signal.throwIfAborted();
|
|
211
|
+
const key = [endpoint.host, endpoint.port, database, credentials.username ?? "", credentials.password ?? ""].join("\x00");
|
|
212
|
+
const existing = this.#connections.get(key);
|
|
213
|
+
if (existing) {
|
|
214
|
+
const connection = await existing;
|
|
215
|
+
this.#signal.throwIfAborted();
|
|
216
|
+
if (connection.isReady)
|
|
217
|
+
return connection;
|
|
218
|
+
if (this.#connections.get(key) === existing) {
|
|
219
|
+
this.#connections.delete(key);
|
|
220
|
+
connection.close();
|
|
221
|
+
}
|
|
222
|
+
return this.connection(endpoint, database, credentials);
|
|
223
|
+
}
|
|
224
|
+
const pending = this.#open(endpoint, database, credentials);
|
|
225
|
+
this.#connections.set(key, pending);
|
|
226
|
+
pending.catch(() => {
|
|
227
|
+
if (this.#connections.get(key) === pending)
|
|
228
|
+
this.#connections.delete(key);
|
|
229
|
+
});
|
|
230
|
+
return pending;
|
|
231
|
+
}
|
|
232
|
+
async#open(endpoint, database, credentials) {
|
|
233
|
+
const timeout = new AbortController;
|
|
234
|
+
const attemptBudget = this.baseConfig.connectTimeoutMs ?? this.baseConfig.timeoutMs;
|
|
235
|
+
const budget = this.baseConfig.acquireTimeoutMs ?? attemptBudget * this.#transports.length;
|
|
236
|
+
const deadline = performance.now() + budget;
|
|
237
|
+
const timer = setTimeout(() => timeout.abort(new ToolboxError(`Redis connection timed out after ${budget}ms (${endpoint.host}:${endpoint.port})`, { kind: "timeout", code: "ETIMEDOUT" })), budget);
|
|
238
|
+
const signal = AbortSignal.any([this.#signal, timeout.signal]);
|
|
239
|
+
let reason;
|
|
240
|
+
try {
|
|
241
|
+
for (let index = 0;index < this.#transports.length; index++) {
|
|
242
|
+
signal.throwIfAborted();
|
|
243
|
+
const transport = this.#transports[index];
|
|
244
|
+
const attempt = new AbortController;
|
|
245
|
+
const remaining = Math.min(attemptBudget, Math.max(1, Math.ceil(deadline - performance.now())));
|
|
246
|
+
const attemptTimer = setTimeout(() => attempt.abort(new ToolboxError(`Redis connection attempt timed out (${transport.name})`, { kind: "timeout", code: "ETIMEDOUT" })), remaining);
|
|
247
|
+
const attemptSignal = AbortSignal.any([signal, attempt.signal]);
|
|
248
|
+
try {
|
|
249
|
+
const mapped = await abortable(transport.connect(endpoint), attemptSignal);
|
|
250
|
+
attemptSignal.throwIfAborted();
|
|
251
|
+
const pending = this.connectionFactory(endpoint, mapped, {
|
|
252
|
+
...this.baseConfig,
|
|
253
|
+
...credentials,
|
|
254
|
+
database,
|
|
255
|
+
connectTimeoutMs: remaining,
|
|
256
|
+
signal: this.#signal,
|
|
257
|
+
connectSignal: attemptSignal
|
|
258
|
+
});
|
|
259
|
+
const prepared = pending.then((connection2) => {
|
|
260
|
+
if (attemptSignal.aborted) {
|
|
261
|
+
connection2.close();
|
|
262
|
+
attemptSignal.throwIfAborted();
|
|
263
|
+
}
|
|
264
|
+
return connection2;
|
|
265
|
+
});
|
|
266
|
+
const connection = await abortable(prepared, attemptSignal);
|
|
267
|
+
this.lifecycle.onRoute?.({ transport: mapped.host === endpoint.host && mapped.port === endpoint.port ? "direct" : transport.name, reason });
|
|
268
|
+
return connection;
|
|
269
|
+
} catch (failure) {
|
|
270
|
+
signal.throwIfAborted();
|
|
271
|
+
const error = attemptSignal.aborted ? attemptSignal.reason : failure;
|
|
272
|
+
const network = isConnectionNetworkError(error) || error instanceof SocketClosedUnexpectedlyError || error instanceof ConnectionTimeoutError;
|
|
273
|
+
if (!network || index === this.#transports.length - 1)
|
|
274
|
+
throw error;
|
|
275
|
+
reason = error instanceof SocketClosedUnexpectedlyError ? "connection_lost" : error instanceof ConnectionTimeoutError ? "ETIMEDOUT" : String(error.code);
|
|
276
|
+
} finally {
|
|
277
|
+
clearTimeout(attemptTimer);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
throw new Error("Redis requires a TCP transport");
|
|
281
|
+
} finally {
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
close() {
|
|
286
|
+
return this.#closing ??= (async () => {
|
|
287
|
+
this.#controller.abort(new Error("Redis access is closed"));
|
|
288
|
+
const settled = await Promise.allSettled(this.#connections.values());
|
|
289
|
+
this.#connections.clear();
|
|
290
|
+
const errors = [];
|
|
291
|
+
for (const result of settled)
|
|
292
|
+
if (result.status === "fulfilled") {
|
|
293
|
+
try {
|
|
294
|
+
result.value.close();
|
|
295
|
+
} catch (error) {
|
|
296
|
+
errors.push(error);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (errors.length)
|
|
300
|
+
throw new AggregateError(errors, "Redis connection cleanup failed");
|
|
301
|
+
})();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
class RedisClient {
|
|
306
|
+
source;
|
|
307
|
+
lifecycle;
|
|
308
|
+
#controller = new AbortController;
|
|
309
|
+
signal;
|
|
310
|
+
#initialization;
|
|
311
|
+
#disposal;
|
|
312
|
+
#access;
|
|
313
|
+
#target;
|
|
314
|
+
constructor(source, lifecycle = {}) {
|
|
315
|
+
this.source = source;
|
|
316
|
+
this.lifecycle = lifecycle;
|
|
317
|
+
this.signal = lifecycle.signal ? AbortSignal.any([lifecycle.signal, this.#controller.signal]) : this.#controller.signal;
|
|
318
|
+
}
|
|
319
|
+
initialize() {
|
|
320
|
+
this.signal.throwIfAborted();
|
|
321
|
+
return this.#initialization ??= (async () => {
|
|
322
|
+
this.#target = await this.source.resolve();
|
|
323
|
+
this.signal.throwIfAborted();
|
|
324
|
+
if (!this.source.transports.length || this.source.transports.some((transport) => transport.kind !== "tcp")) {
|
|
325
|
+
throw new Error("Redis requires TCP transports");
|
|
326
|
+
}
|
|
327
|
+
if (this.#target.clusterType === "cluster" && this.#target.database !== 0)
|
|
328
|
+
throw new Error("Redis Cluster requires database 0");
|
|
329
|
+
this.#access = new RedisAccess(this.source.transports, this.#target, undefined, { ...this.lifecycle, signal: this.signal });
|
|
330
|
+
})();
|
|
331
|
+
}
|
|
332
|
+
get access() {
|
|
333
|
+
this.signal.throwIfAborted();
|
|
334
|
+
if (!this.#access)
|
|
335
|
+
throw new Error("Redis client is not initialized");
|
|
336
|
+
return this.#access;
|
|
337
|
+
}
|
|
338
|
+
topology() {
|
|
339
|
+
return discoverRedisTopology(this.access, this.target);
|
|
340
|
+
}
|
|
341
|
+
get target() {
|
|
342
|
+
if (!this.#target)
|
|
343
|
+
throw new Error("Redis client is not initialized");
|
|
344
|
+
return this.#target;
|
|
345
|
+
}
|
|
346
|
+
dispose() {
|
|
347
|
+
return this.#disposal ??= (async () => {
|
|
348
|
+
this.#controller.abort(new Error("Redis client disposed"));
|
|
349
|
+
await this.#initialization?.catch(() => {});
|
|
350
|
+
await this.#access?.close();
|
|
351
|
+
})();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async function openRedis(source, lifecycle = {}) {
|
|
355
|
+
const client = new RedisClient(source, lifecycle);
|
|
356
|
+
try {
|
|
357
|
+
await client.initialize();
|
|
358
|
+
} catch (error) {
|
|
359
|
+
await client.dispose();
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
lifecycle.onDispose?.(() => client.dispose());
|
|
363
|
+
return client;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export { parseRedisInfo, RedisConnection, RedisAccess, RedisClient, openRedis };
|
|
@@ -23,6 +23,10 @@ export declare class KubernetesClient implements Client {
|
|
|
23
23
|
*/
|
|
24
24
|
get(ref: ResourceRef, options?: ResourceRequestOptions): Promise<import("./resources").Resource>;
|
|
25
25
|
list(query: ResourceListQuery, options?: ResourceRequestOptions): Promise<import("./resources").ResourceList>;
|
|
26
|
+
/** Explicit root scope for Nodes and all-namespace lists; no default namespace is inserted.
|
|
27
|
+
* @spec This view borrows root transport, cancellation and cleanup just like namespace views.
|
|
28
|
+
*/
|
|
29
|
+
clusterResources(): ResourceAccess;
|
|
26
30
|
inNamespace(namespace: string): NamespaceResources;
|
|
27
31
|
exec(namespace: string, target: ExecTarget, command: string[], options?: RunOptions): Promise<import("./executor").ExecResult>;
|
|
28
32
|
forward(namespace: string, target: KubernetesEndpoint): Promise<import("./service-port-forward").ForwardedEndpoint>;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
import {
|
|
2
2
|
KubernetesClient
|
|
3
|
-
} from "../chunk-
|
|
4
|
-
import"../chunk-4ms037k2.js";
|
|
5
|
-
import"../chunk-0s27bgga.js";
|
|
3
|
+
} from "../chunk-c2n8zvyn.js";
|
|
6
4
|
import"../chunk-2171tajs.js";
|
|
7
5
|
import"../chunk-j46x7j7c.js";
|
|
8
6
|
import"../chunk-6fnhzhdb.js";
|
|
7
|
+
import"../chunk-4ms037k2.js";
|
|
9
8
|
import"../chunk-ftkrjqkc.js";
|
|
10
9
|
import"../chunk-shsabpc1.js";
|
|
10
|
+
import"../chunk-0s27bgga.js";
|
|
11
11
|
import"../chunk-a9htb6cr.js";
|
|
12
12
|
import"../chunk-20kv4wc3.js";
|
|
13
13
|
import"../chunk-v3hxvnrc.js";
|