@compforge/harness-toolbox 0.4.3 → 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 +8 -0
- package/dist/chunk-5zgz1z41.js +145 -0
- package/dist/{chunk-8kts470p.js → chunk-c2n8zvyn.js} +12 -6
- 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/package.json +4 -1
package/README.md
CHANGED
|
@@ -376,3 +376,11 @@ reason. The caller owns shared transports such as `PodRelayTransport` and their
|
|
|
376
376
|
|
|
377
377
|
Routed HTTPS preserves the original TLS server name. Bun disables HTTP keep-alive because its reused
|
|
378
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).
|
|
@@ -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" });
|
|
@@ -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";
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { ErrorKind } from "../errors";
|
|
2
|
+
/** CPU cores and bytes; never percentages or unitless Kubernetes quantity strings. */
|
|
3
|
+
export interface ComputeQuantity {
|
|
4
|
+
cpu: number;
|
|
5
|
+
memory: number;
|
|
6
|
+
}
|
|
7
|
+
export interface ComputeIssue {
|
|
8
|
+
source: string;
|
|
9
|
+
kind: ErrorKind;
|
|
10
|
+
message: string;
|
|
11
|
+
code?: string | number;
|
|
12
|
+
}
|
|
13
|
+
export interface ComputeRead<T> {
|
|
14
|
+
status: "complete" | "partial" | "unavailable";
|
|
15
|
+
items: T[];
|
|
16
|
+
issues: ComputeIssue[];
|
|
17
|
+
}
|
|
18
|
+
export interface ComputeUsage extends ComputeQuantity {
|
|
19
|
+
timestamp: string;
|
|
20
|
+
window: string;
|
|
21
|
+
}
|
|
22
|
+
export interface NodeCompute {
|
|
23
|
+
name: string;
|
|
24
|
+
uid: string;
|
|
25
|
+
ready: boolean;
|
|
26
|
+
unschedulable: boolean;
|
|
27
|
+
capacity: ComputeQuantity;
|
|
28
|
+
allocatable: ComputeQuantity;
|
|
29
|
+
}
|
|
30
|
+
export interface PodCompute {
|
|
31
|
+
namespace: string;
|
|
32
|
+
name: string;
|
|
33
|
+
uid: string;
|
|
34
|
+
createdAt?: string;
|
|
35
|
+
node?: string;
|
|
36
|
+
phase: string;
|
|
37
|
+
ready: boolean;
|
|
38
|
+
restarts: number;
|
|
39
|
+
/** Steady-state application and native sidecar container names, for metrics coverage. */
|
|
40
|
+
containers: string[];
|
|
41
|
+
/** Spec-based effective requests, including init/sidecar peak, Pod-level overrides and overhead. */
|
|
42
|
+
requests: ComputeQuantity;
|
|
43
|
+
/** Sum/peak of declared limits, NOT an upper bound when containers have no limit. */
|
|
44
|
+
configuredLimits: ComputeQuantity;
|
|
45
|
+
/** Container counts, not cores/bytes; a Pod-level budget can still apply. */
|
|
46
|
+
containersWithoutLimits: {
|
|
47
|
+
cpu: number;
|
|
48
|
+
memory: number;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export interface NodeComputeUsage extends ComputeUsage {
|
|
52
|
+
name: string;
|
|
53
|
+
}
|
|
54
|
+
export interface PodComputeUsage extends ComputeUsage {
|
|
55
|
+
namespace: string;
|
|
56
|
+
name: string;
|
|
57
|
+
uid: string;
|
|
58
|
+
}
|
|
59
|
+
export interface PodComputeSummary {
|
|
60
|
+
pods: number;
|
|
61
|
+
activePods: number;
|
|
62
|
+
scheduledPods: number;
|
|
63
|
+
readyPods: number;
|
|
64
|
+
/** Active Pods only; unscheduled requests are demand, not allocated node capacity. */
|
|
65
|
+
requests: ComputeQuantity;
|
|
66
|
+
scheduledRequests: ComputeQuantity;
|
|
67
|
+
configuredLimits: ComputeQuantity;
|
|
68
|
+
/** Container counts, not cores/bytes; a Pod-level budget can still apply. */
|
|
69
|
+
containersWithoutLimits: {
|
|
70
|
+
cpu: number;
|
|
71
|
+
memory: number;
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export interface ClusterCompute {
|
|
75
|
+
environment: string;
|
|
76
|
+
observedAt: string;
|
|
77
|
+
scope: "cluster";
|
|
78
|
+
nodes: ComputeRead<NodeCompute>;
|
|
79
|
+
pods: ComputeRead<PodCompute>;
|
|
80
|
+
usage: ComputeRead<NodeComputeUsage>;
|
|
81
|
+
/** Totals describe acquired evidence only; consult each read's coverage before claiming cluster totals. */
|
|
82
|
+
summary: {
|
|
83
|
+
capacity?: ComputeQuantity;
|
|
84
|
+
allocatable?: ComputeQuantity;
|
|
85
|
+
pods?: PodComputeSummary;
|
|
86
|
+
usage?: ComputeQuantity;
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
export interface WorkloadCompute {
|
|
90
|
+
environment: string;
|
|
91
|
+
observedAt: string;
|
|
92
|
+
/** Scope is exactly the supplied instance set, not a claim of complete Service discovery. */
|
|
93
|
+
scope: "instances";
|
|
94
|
+
workloads: readonly {
|
|
95
|
+
namespace: string;
|
|
96
|
+
workload: string;
|
|
97
|
+
pod: string;
|
|
98
|
+
uid: string;
|
|
99
|
+
}[];
|
|
100
|
+
pods: ComputeRead<PodCompute>;
|
|
101
|
+
usage: ComputeRead<PodComputeUsage>;
|
|
102
|
+
summary: {
|
|
103
|
+
pods?: PodComputeSummary;
|
|
104
|
+
usage?: ComputeQuantity;
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export interface ComputeOptions {
|
|
108
|
+
environment: string;
|
|
109
|
+
signal?: AbortSignal;
|
|
110
|
+
/** Whole collection budget, including all pages and metrics reads. */
|
|
111
|
+
timeoutMs: number;
|
|
112
|
+
maxPods: number;
|
|
113
|
+
maxNodes: number;
|
|
114
|
+
pageSize: number;
|
|
115
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ComputeQuantity, PodCompute, PodComputeSummary } from "./compute-model";
|
|
2
|
+
import type { Resource } from "./resources";
|
|
3
|
+
type ObjectValue = Record<string, unknown>;
|
|
4
|
+
export declare function object(value: unknown): ObjectValue;
|
|
5
|
+
export declare function requiredString(value: unknown, field: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* @why client-node 1.4 quantityToScalar uses BigInt for binary suffixes and rejects valid fractional Gi values.
|
|
8
|
+
* @spec Quantities normalize to cores/bytes; invalid or unrepresentable values never become zero usage.
|
|
9
|
+
*/
|
|
10
|
+
export declare function parseComputeQuantity(value: unknown): number;
|
|
11
|
+
export declare const zero: () => ComputeQuantity;
|
|
12
|
+
export declare function add(left: ComputeQuantity, right: ComputeQuantity): ComputeQuantity;
|
|
13
|
+
export declare function quantities(value: unknown, required?: boolean): ComputeQuantity;
|
|
14
|
+
export declare function sum(values: readonly ComputeQuantity[]): ComputeQuantity;
|
|
15
|
+
/** Spec projection only: do not claim current cgroup allocation or scheduler admission headroom. */
|
|
16
|
+
export declare function podCompute(value: Resource): PodCompute;
|
|
17
|
+
export declare function summarizePods(pods: readonly PodCompute[]): PodComputeSummary;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {
|
|
2
|
+
add,
|
|
3
|
+
object,
|
|
4
|
+
parseComputeQuantity,
|
|
5
|
+
podCompute,
|
|
6
|
+
quantities,
|
|
7
|
+
requiredString,
|
|
8
|
+
sum,
|
|
9
|
+
summarizePods,
|
|
10
|
+
zero
|
|
11
|
+
} from "../chunk-5zgz1z41.js";
|
|
12
|
+
import"../chunk-20kv4wc3.js";
|
|
13
|
+
export {
|
|
14
|
+
zero,
|
|
15
|
+
summarizePods,
|
|
16
|
+
sum,
|
|
17
|
+
requiredString,
|
|
18
|
+
quantities,
|
|
19
|
+
podCompute,
|
|
20
|
+
parseComputeQuantity,
|
|
21
|
+
object,
|
|
22
|
+
add
|
|
23
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { WorkloadInstance } from "@compforge/harness-common";
|
|
2
|
+
import type { ResourceAccess } from "./resources";
|
|
3
|
+
import type { ClusterCompute, ComputeOptions, WorkloadCompute } from "./compute-model";
|
|
4
|
+
export type * from "./compute-model";
|
|
5
|
+
export { parseComputeQuantity } from "./compute-quantity";
|
|
6
|
+
/**
|
|
7
|
+
* @spec Cluster reads are independent: forbidden Nodes/metrics are coverage gaps, not cluster-health failures.
|
|
8
|
+
* @rule access must be an explicit cluster-scoped view, e.g. KubernetesClient.clusterResources().
|
|
9
|
+
* No namespace fallback, client creation or cleanup occurs here.
|
|
10
|
+
*/
|
|
11
|
+
export declare function collectClusterCompute(access: ResourceAccess, options: ComputeOptions): Promise<ClusterCompute>;
|
|
12
|
+
/**
|
|
13
|
+
* @spec Whole-Pod accounting deduplicates physical UID across Workload aliases and container defaults.
|
|
14
|
+
* @spec Only caller-supplied instances are read; cluster permissions are not needed.
|
|
15
|
+
* @rule The caller retains discovery coverage. This result never asserts that input covers all Service instances.
|
|
16
|
+
*/
|
|
17
|
+
export declare function collectWorkloadCompute(access: ResourceAccess, instances: readonly WorkloadInstance[], options: ComputeOptions): Promise<WorkloadCompute>;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import {
|
|
2
|
+
object,
|
|
3
|
+
parseComputeQuantity,
|
|
4
|
+
podCompute,
|
|
5
|
+
quantities,
|
|
6
|
+
requiredString,
|
|
7
|
+
sum,
|
|
8
|
+
summarizePods
|
|
9
|
+
} from "../chunk-5zgz1z41.js";
|
|
10
|
+
import {
|
|
11
|
+
KubernetesError
|
|
12
|
+
} from "../chunk-20kv4wc3.js";
|
|
13
|
+
|
|
14
|
+
// src/kubernetes/compute.ts
|
|
15
|
+
function requestBudget(options) {
|
|
16
|
+
if (!options.environment.trim() || [options.timeoutMs, options.maxPods, options.maxNodes, options.pageSize].some((value) => !Number.isSafeInteger(value) || value <= 0)) {
|
|
17
|
+
throw new KubernetesError("Compute requires environment and positive timeout/item/page budgets", { kind: "invalid_argument" });
|
|
18
|
+
}
|
|
19
|
+
const deadline = performance.now() + options.timeoutMs;
|
|
20
|
+
return () => {
|
|
21
|
+
options.signal?.throwIfAborted();
|
|
22
|
+
const remaining = Math.ceil(deadline - performance.now());
|
|
23
|
+
if (remaining <= 0)
|
|
24
|
+
throw new KubernetesError("Compute collection budget exhausted", { kind: "timeout" });
|
|
25
|
+
return { signal: options.signal, timeoutMs: remaining };
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function issue(source, error) {
|
|
29
|
+
const kind = error instanceof KubernetesError ? error.kind : "operation_failed";
|
|
30
|
+
return {
|
|
31
|
+
source,
|
|
32
|
+
kind,
|
|
33
|
+
code: error instanceof KubernetesError ? error.code : undefined,
|
|
34
|
+
message: kind === "permission_denied" ? "Permission denied" : `Kubernetes compute read failed: ${kind}`
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function finish(items, issues, observed) {
|
|
38
|
+
return { items, issues, status: !issues.length ? "complete" : observed ? "partial" : "unavailable" };
|
|
39
|
+
}
|
|
40
|
+
function gap(source, kind, message) {
|
|
41
|
+
return { source, kind, message };
|
|
42
|
+
}
|
|
43
|
+
async function readPages(access, query, maxItems, options, request, parse, identity) {
|
|
44
|
+
const source = `${query.apiVersion}/${query.resource}`;
|
|
45
|
+
const items = [], issues = [];
|
|
46
|
+
const tokens = new Set, identities = new Set;
|
|
47
|
+
let continuation, observed = false, read = 0;
|
|
48
|
+
try {
|
|
49
|
+
do {
|
|
50
|
+
const page = await access.list({ ...query, limit: Math.min(options.pageSize, maxItems - read), continue: continuation }, request());
|
|
51
|
+
if (!Array.isArray(page.items))
|
|
52
|
+
throw new KubernetesError("List lacks items", { kind: "invalid_response" });
|
|
53
|
+
observed = true;
|
|
54
|
+
for (const item of page.items.slice(0, maxItems - read)) {
|
|
55
|
+
try {
|
|
56
|
+
const parsed = parse(item), key = identity(parsed);
|
|
57
|
+
if (!identities.has(key)) {
|
|
58
|
+
items.push(parsed);
|
|
59
|
+
identities.add(key);
|
|
60
|
+
}
|
|
61
|
+
} catch (error) {
|
|
62
|
+
issues.push(issue(source, error));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
read += page.items.length;
|
|
66
|
+
continuation = page.metadata?.continue || undefined;
|
|
67
|
+
if (read > maxItems || read >= maxItems && continuation) {
|
|
68
|
+
issues.push(gap(source, "limit_exceeded", `Collection limited to ${maxItems} items`));
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
if (continuation && tokens.has(continuation)) {
|
|
72
|
+
issues.push(gap(source, "invalid_response", "Pagination did not advance"));
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
if (continuation)
|
|
76
|
+
tokens.add(continuation);
|
|
77
|
+
} while (continuation);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
options.signal?.throwIfAborted();
|
|
80
|
+
issues.push(issue(source, error));
|
|
81
|
+
}
|
|
82
|
+
return finish(items, issues, observed);
|
|
83
|
+
}
|
|
84
|
+
function nodeCompute(value) {
|
|
85
|
+
const meta = object(value.metadata), status = object(value.status), spec = object(value.spec);
|
|
86
|
+
const conditions = Array.isArray(status.conditions) ? status.conditions.map(object) : [];
|
|
87
|
+
return {
|
|
88
|
+
name: requiredString(meta.name, "node name"),
|
|
89
|
+
uid: requiredString(meta.uid, "node uid"),
|
|
90
|
+
ready: conditions.some((c) => c.type === "Ready" && c.status === "True"),
|
|
91
|
+
unschedulable: spec.unschedulable === true,
|
|
92
|
+
capacity: quantities(status.capacity, true),
|
|
93
|
+
allocatable: quantities(status.allocatable, true)
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function usage(value) {
|
|
97
|
+
const timestamp = requiredString(value.timestamp, "metrics timestamp"), window = requiredString(value.window, "metrics window");
|
|
98
|
+
if (!Number.isFinite(Date.parse(timestamp)))
|
|
99
|
+
throw new KubernetesError("Invalid metrics timestamp", { kind: "invalid_response" });
|
|
100
|
+
return { ...quantities(value.usage, true), timestamp, window };
|
|
101
|
+
}
|
|
102
|
+
async function collectClusterCompute(access, options) {
|
|
103
|
+
const request = requestBudget(options);
|
|
104
|
+
const nodes = await readPages(access, { apiVersion: "v1", resource: "nodes" }, options.maxNodes, options, request, nodeCompute, (n) => n.uid);
|
|
105
|
+
const pods = await readPages(access, { apiVersion: "v1", resource: "pods" }, options.maxPods, options, request, podCompute, (p) => `${p.namespace}/${p.uid}`);
|
|
106
|
+
const metrics = await readPages(access, { apiVersion: "metrics.k8s.io/v1beta1", resource: "nodes" }, options.maxNodes, options, request, (item) => ({ ...usage(item), name: requiredString(item.metadata?.name, "metrics node name") }), (n) => n.name);
|
|
107
|
+
if (nodes.status !== "unavailable" && metrics.status !== "unavailable") {
|
|
108
|
+
const measured = new Set(metrics.items.map((item) => item.name));
|
|
109
|
+
for (const node of nodes.items)
|
|
110
|
+
if (!measured.has(node.name)) {
|
|
111
|
+
metrics.issues.push(gap(`nodes/${node.name}`, "resource_not_found", "Node has no metrics sample"));
|
|
112
|
+
}
|
|
113
|
+
if (metrics.issues.length)
|
|
114
|
+
metrics.status = "partial";
|
|
115
|
+
}
|
|
116
|
+
options.signal?.throwIfAborted();
|
|
117
|
+
return {
|
|
118
|
+
environment: options.environment,
|
|
119
|
+
observedAt: new Date().toISOString(),
|
|
120
|
+
scope: "cluster",
|
|
121
|
+
nodes,
|
|
122
|
+
pods,
|
|
123
|
+
usage: metrics,
|
|
124
|
+
summary: {
|
|
125
|
+
capacity: nodes.status !== "complete" && !nodes.items.length ? undefined : sum(nodes.items.map((n) => n.capacity)),
|
|
126
|
+
allocatable: nodes.status !== "complete" && !nodes.items.length ? undefined : sum(nodes.items.map((n) => n.allocatable)),
|
|
127
|
+
pods: pods.status !== "complete" && !pods.items.length ? undefined : summarizePods(pods.items),
|
|
128
|
+
usage: metrics.status === "unavailable" || !metrics.items.length && (metrics.status !== "complete" || nodes.status !== "complete" || nodes.items.length > 0) ? undefined : sum(metrics.items)
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
async function collectWorkloadCompute(access, instances, options) {
|
|
133
|
+
const request = requestBudget(options);
|
|
134
|
+
const selected = new Map;
|
|
135
|
+
for (const instance of instances) {
|
|
136
|
+
if (instance.environment !== options.environment) {
|
|
137
|
+
throw new KubernetesError("Compute instance belongs to a different environment", { kind: "invalid_argument" });
|
|
138
|
+
}
|
|
139
|
+
selected.set(JSON.stringify([instance.namespace, instance.pod, instance.uid]), instance);
|
|
140
|
+
}
|
|
141
|
+
const pods = [], metrics = [];
|
|
142
|
+
const podIssues = [], metricIssues = [];
|
|
143
|
+
if (selected.size > options.maxPods)
|
|
144
|
+
podIssues.push(gap("pods", "limit_exceeded", `Collection limited to ${options.maxPods} Pods`));
|
|
145
|
+
for (const instance of [...selected.values()].slice(0, options.maxPods)) {
|
|
146
|
+
const source = `pods/${instance.namespace}/${instance.pod}`;
|
|
147
|
+
let pod;
|
|
148
|
+
try {
|
|
149
|
+
pod = podCompute(await access.get({ apiVersion: "v1", resource: "pods", namespace: instance.namespace, name: instance.pod }, request()));
|
|
150
|
+
if (pod.uid !== instance.uid || pod.name !== instance.pod || pod.namespace !== instance.namespace) {
|
|
151
|
+
throw new KubernetesError("Pod identity changed after discovery", { kind: "invalid_response" });
|
|
152
|
+
}
|
|
153
|
+
pods.push(pod);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
options.signal?.throwIfAborted();
|
|
156
|
+
podIssues.push(issue(source, error));
|
|
157
|
+
metricIssues.push(gap(source, "operation_failed", "Metrics not collected because Pod identity could not be verified"));
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (pod.phase === "Succeeded" || pod.phase === "Failed")
|
|
161
|
+
continue;
|
|
162
|
+
try {
|
|
163
|
+
const raw = await access.get({ apiVersion: "metrics.k8s.io/v1beta1", resource: "pods", namespace: pod.namespace, name: pod.name }, request());
|
|
164
|
+
if (raw.metadata?.name !== pod.name || raw.metadata?.namespace !== pod.namespace || !Array.isArray(raw.containers)) {
|
|
165
|
+
throw new KubernetesError("Pod metrics lacks identity or containers", { kind: "invalid_response" });
|
|
166
|
+
}
|
|
167
|
+
const names = raw.containers.map((c) => requiredString(object(c).name, "metrics container name"));
|
|
168
|
+
if (new Set(names).size !== names.length || pod.containers.some((name) => !names.includes(name))) {
|
|
169
|
+
throw new KubernetesError("Pod metrics does not cover its containers", { kind: "invalid_response" });
|
|
170
|
+
}
|
|
171
|
+
const values = raw.containers.map((c) => quantities(object(c).usage, true));
|
|
172
|
+
if (!values.length)
|
|
173
|
+
throw new KubernetesError("Empty Pod metrics", { kind: "invalid_response" });
|
|
174
|
+
const measured = {
|
|
175
|
+
...usage({ ...raw, usage: { cpu: String(sum(values).cpu), memory: String(sum(values).memory) } }),
|
|
176
|
+
namespace: pod.namespace,
|
|
177
|
+
name: pod.name,
|
|
178
|
+
uid: pod.uid
|
|
179
|
+
};
|
|
180
|
+
if (raw.metadata.uid && raw.metadata.uid !== pod.uid || pod.createdAt && Date.parse(measured.timestamp) < Date.parse(pod.createdAt)) {
|
|
181
|
+
throw new KubernetesError("Pod metrics precedes current Pod incarnation", { kind: "invalid_response" });
|
|
182
|
+
}
|
|
183
|
+
metrics.push(measured);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
options.signal?.throwIfAborted();
|
|
186
|
+
metricIssues.push(issue(`metrics/${source}`, error));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (selected.size > options.maxPods)
|
|
190
|
+
metricIssues.push(gap("metrics/pods", "limit_exceeded", "Metrics limited to collected Pods"));
|
|
191
|
+
options.signal?.throwIfAborted();
|
|
192
|
+
const podRead = finish(pods, podIssues, pods.length > 0 || !selected.size);
|
|
193
|
+
const metricRead = finish(metrics, metricIssues, metrics.length > 0 || !podIssues.length && pods.every((p) => p.phase === "Succeeded" || p.phase === "Failed"));
|
|
194
|
+
return {
|
|
195
|
+
environment: options.environment,
|
|
196
|
+
observedAt: new Date().toISOString(),
|
|
197
|
+
scope: "instances",
|
|
198
|
+
workloads: instances.map(({ namespace, workload, pod, uid }) => ({ namespace, workload, pod, uid })),
|
|
199
|
+
pods: podRead,
|
|
200
|
+
usage: metricRead,
|
|
201
|
+
summary: {
|
|
202
|
+
pods: podRead.status === "unavailable" ? undefined : summarizePods(pods),
|
|
203
|
+
usage: metricRead.status === "unavailable" ? undefined : sum(metrics)
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
export {
|
|
208
|
+
parseComputeQuantity,
|
|
209
|
+
collectWorkloadCompute,
|
|
210
|
+
collectClusterCompute
|
|
211
|
+
};
|
|
@@ -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 {
|
|
12
12
|
clientKey
|
|
13
13
|
} from "../chunk-a9htb6cr.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@compforge/harness-toolbox",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
4
4
|
"description": "Shared infrastructure clients, transports and bounded data capture",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -116,6 +116,9 @@
|
|
|
116
116
|
"@types/node": "^22",
|
|
117
117
|
"@types/ws": "^8.18.1"
|
|
118
118
|
},
|
|
119
|
+
"overrides": {
|
|
120
|
+
"@compforge/harness-common": "file:../common"
|
|
121
|
+
},
|
|
119
122
|
"publishConfig": {
|
|
120
123
|
"access": "public"
|
|
121
124
|
},
|