@elinpf/dsh-ops-tool-environment 0.1.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.i18n.yaml +6 -0
- package/README.md +50 -0
- package/README.zh.md +50 -0
- package/cordis.patch.yml +1 -0
- package/lib/anomalies.d.ts +31 -0
- package/lib/anomalies.js +77 -0
- package/lib/classify.d.ts +64 -0
- package/lib/classify.js +175 -0
- package/lib/doctrine.d.ts +13 -0
- package/lib/doctrine.js +84 -0
- package/lib/index.d.ts +40 -0
- package/lib/index.js +44 -0
- package/lib/inventory.d.ts +77 -0
- package/lib/inventory.js +168 -0
- package/lib/prometheus.d.ts +92 -0
- package/lib/prometheus.js +190 -0
- package/lib/prompt.d.ts +22 -0
- package/lib/prompt.js +43 -0
- package/lib/relations.d.ts +34 -0
- package/lib/relations.js +146 -0
- package/lib/scanner.d.ts +65 -0
- package/lib/scanner.js +333 -0
- package/lib/tool.d.ts +159 -0
- package/lib/tool.js +593 -0
- package/lib/types.d.ts +217 -0
- package/lib/types.js +11 -0
- package/package.json +57 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment inventory tool plugin (preset plane).
|
|
3
|
+
*
|
|
4
|
+
* Registers the model-facing `environment` tool (overview / show / refresh /
|
|
5
|
+
* help) on top of the ticket-01 scanner core. The tool resolves the
|
|
6
|
+
* ops-access seam per call via ctx.get — never a static inject, never
|
|
7
|
+
* cached — so it must be mounted in the same isolate realm as opsAccess
|
|
8
|
+
* (the `ops-access-registry` group in ops-preset.yml). The one-line
|
|
9
|
+
* methodology section lives in the `./prompt` subpath plugin, mounted with
|
|
10
|
+
* the prompt-channel consumers.
|
|
11
|
+
*
|
|
12
|
+
* apply() only registers the tool: nothing scans at session start. Scans
|
|
13
|
+
* happen on explicit refresh, or on a read whose oldest section is past
|
|
14
|
+
* the TTL.
|
|
15
|
+
*
|
|
16
|
+
* @module @elinpf/dsh-ops-tool-environment
|
|
17
|
+
*/
|
|
18
|
+
import z from '@deepseek-ai/schemastery';
|
|
19
|
+
import { DEFAULT_USER_RULES_FILE } from './classify.js';
|
|
20
|
+
import { DEFAULT_INVENTORY_FILE } from './inventory.js';
|
|
21
|
+
import { createEnvironmentTool } from './tool.js';
|
|
22
|
+
// ── Plugin identity ───────────────────────────────────────────────────────────
|
|
23
|
+
export const name = 'ops-tool-environment';
|
|
24
|
+
export const inject = ['tools'];
|
|
25
|
+
// ── Config ───────────────────────────────────────────────────────────────────
|
|
26
|
+
export const Config = z.object({
|
|
27
|
+
inventoryFile: z.string().default(DEFAULT_INVENTORY_FILE),
|
|
28
|
+
rulesFile: z.string().default(DEFAULT_USER_RULES_FILE),
|
|
29
|
+
ttlMinutes: z.number().default(60),
|
|
30
|
+
scanTimeoutMs: z.number().default(30000),
|
|
31
|
+
prometheusTimeoutMs: z.number().default(15000),
|
|
32
|
+
});
|
|
33
|
+
// ── Plugin apply ─────────────────────────────────────────────────────────────
|
|
34
|
+
export function apply(ctx, config) {
|
|
35
|
+
ctx.effect(() => ctx.tools.register(createEnvironmentTool(ctx, config)));
|
|
36
|
+
}
|
|
37
|
+
export { builtinRules, classifySignals, classifyWorkload, DEFAULT_USER_RULES_FILE, expandHome, isMiddlewareType, loadUserRules, } from './classify.js';
|
|
38
|
+
export { defaultExec, ScanError, SCAN_TIMEOUT_MS, scanCluster, scrubKubeconfigPath, } from './scanner.js';
|
|
39
|
+
export { buildRelations, findServiceAddresses } from './relations.js';
|
|
40
|
+
export { detectAnomalies } from './anomalies.js';
|
|
41
|
+
export { findPrometheusService, matchTargetsToWorkloads, parseActiveTargets, scrapePrometheusTargets, } from './prometheus.js';
|
|
42
|
+
export { buildClusterInventory, DEFAULT_INVENTORY_FILE, readInventory, refreshInventory, } from './inventory.js';
|
|
43
|
+
export { createEnvironmentTool, filterDetail } from './tool.js';
|
|
44
|
+
export { HELP_POINTER, HELP_TEXT, STATIC_PROMPT, TOOL_DESCRIPTION } from './doctrine.js';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inventory persistence — `~/.dsh-ops/environment.yaml`.
|
|
3
|
+
*
|
|
4
|
+
* The file is auto-generated (header says so), segmented per cluster, and
|
|
5
|
+
* each section carries the scan timestamp. Freshness discipline lives here:
|
|
6
|
+
* refreshInventory re-scans every target cluster; a cluster that fails
|
|
7
|
+
* (unreachable, timeout, kubectl error) keeps its previous section with
|
|
8
|
+
* `stale: true` and a sanitized `lastError` — the section never disappears
|
|
9
|
+
* and the failure never aborts the other clusters.
|
|
10
|
+
*
|
|
11
|
+
* Security discipline: the kubeconfig path is used to run kubectl and then
|
|
12
|
+
* scrubbed out of every error before it is stored; Secret values never
|
|
13
|
+
* enter the pipeline (see scanner.ts). What lands in this file is safe to
|
|
14
|
+
* show the model verbatim.
|
|
15
|
+
*
|
|
16
|
+
* @module @elinpf/dsh-ops-tool-environment
|
|
17
|
+
*/
|
|
18
|
+
import type { SpawnFn } from './prometheus.js';
|
|
19
|
+
import type { ExecFn } from './scanner.js';
|
|
20
|
+
import type { ClusterInventory, ClusterScan } from './types.js';
|
|
21
|
+
export declare const DEFAULT_INVENTORY_FILE = "~/.dsh-ops/environment.yaml";
|
|
22
|
+
/** One cluster section: the inventory plus freshness markers. */
|
|
23
|
+
export interface InventorySection extends ClusterInventory {
|
|
24
|
+
/** Set when the last refresh failed and this section holds older data. */
|
|
25
|
+
stale?: boolean;
|
|
26
|
+
/** Sanitized message of the last refresh failure (no credential paths). */
|
|
27
|
+
lastError?: string;
|
|
28
|
+
}
|
|
29
|
+
/** The whole environment.yaml document. */
|
|
30
|
+
export interface EnvironmentInventory {
|
|
31
|
+
version: 1;
|
|
32
|
+
clusters: Record<string, InventorySection>;
|
|
33
|
+
}
|
|
34
|
+
/** A cluster to refresh: profile id plus its resolved kubeconfig path. */
|
|
35
|
+
export interface RefreshTarget {
|
|
36
|
+
cluster: string;
|
|
37
|
+
kubeconfigPath: string;
|
|
38
|
+
}
|
|
39
|
+
export interface RefreshOptions {
|
|
40
|
+
/** Inventory file path; defaults to ~/.dsh-ops/environment.yaml. */
|
|
41
|
+
file?: string;
|
|
42
|
+
/** Injectable kubectl runner (tests); defaults to spawning kubectl. */
|
|
43
|
+
exec?: ExecFn;
|
|
44
|
+
/** Injectable port-forward spawn for the Prometheus scrape (tests). */
|
|
45
|
+
spawn?: SpawnFn;
|
|
46
|
+
/** Injectable fetch for the Prometheus scrape (tests). */
|
|
47
|
+
fetchFn?: typeof fetch;
|
|
48
|
+
/** User classification rules file; defaults to ~/.dsh-ops/environment-rules.yaml. */
|
|
49
|
+
userRulesFile?: string;
|
|
50
|
+
/** Timestamp source (tests). */
|
|
51
|
+
now?: Date;
|
|
52
|
+
/** Per-kubectl-call scan timeout (ms); defaults to the scanner's own 30s. */
|
|
53
|
+
scanTimeoutMs?: number;
|
|
54
|
+
/** Prometheus scrape timeout (ms); defaults to the scrape's own 15s. */
|
|
55
|
+
prometheusTimeoutMs?: number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Assemble the per-cluster inventory from a raw scan: classify every
|
|
59
|
+
* workload, collect middleware instances with their Service entries, and
|
|
60
|
+
* derive relation edges. Deterministic — output lists are sorted so the
|
|
61
|
+
* same cluster scanned twice yields the same section.
|
|
62
|
+
*/
|
|
63
|
+
export declare function buildClusterInventory(scan: ClusterScan, opts?: {
|
|
64
|
+
userRulesFile?: string;
|
|
65
|
+
}): ClusterInventory;
|
|
66
|
+
/** Read the inventory file. Returns null when it does not exist or is malformed. */
|
|
67
|
+
export declare function readInventory(file?: string): Promise<EnvironmentInventory | null>;
|
|
68
|
+
/**
|
|
69
|
+
* Refresh the inventory for the given clusters.
|
|
70
|
+
*
|
|
71
|
+
* Per cluster: scan + assemble + replace the section. On failure the
|
|
72
|
+
* previous section survives with `stale: true` and a sanitized lastError;
|
|
73
|
+
* a cluster with no previous data gets an empty stale section so the gap
|
|
74
|
+
* is visible. Sections of clusters not in `targets` are left untouched.
|
|
75
|
+
* Never throws — per-cluster failures are folded into their sections.
|
|
76
|
+
*/
|
|
77
|
+
export declare function refreshInventory(targets: RefreshTarget[], opts?: RefreshOptions): Promise<EnvironmentInventory>;
|
package/lib/inventory.js
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inventory persistence — `~/.dsh-ops/environment.yaml`.
|
|
3
|
+
*
|
|
4
|
+
* The file is auto-generated (header says so), segmented per cluster, and
|
|
5
|
+
* each section carries the scan timestamp. Freshness discipline lives here:
|
|
6
|
+
* refreshInventory re-scans every target cluster; a cluster that fails
|
|
7
|
+
* (unreachable, timeout, kubectl error) keeps its previous section with
|
|
8
|
+
* `stale: true` and a sanitized `lastError` — the section never disappears
|
|
9
|
+
* and the failure never aborts the other clusters.
|
|
10
|
+
*
|
|
11
|
+
* Security discipline: the kubeconfig path is used to run kubectl and then
|
|
12
|
+
* scrubbed out of every error before it is stored; Secret values never
|
|
13
|
+
* enter the pipeline (see scanner.ts). What lands in this file is safe to
|
|
14
|
+
* show the model verbatim.
|
|
15
|
+
*
|
|
16
|
+
* @module @elinpf/dsh-ops-tool-environment
|
|
17
|
+
*/
|
|
18
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
19
|
+
import { dirname } from 'node:path';
|
|
20
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
21
|
+
import { classifyWorkload, expandHome, isMiddlewareType } from './classify.js';
|
|
22
|
+
import { detectAnomalies } from './anomalies.js';
|
|
23
|
+
import { matchTargetsToWorkloads } from './prometheus.js';
|
|
24
|
+
import { buildRelations } from './relations.js';
|
|
25
|
+
import { scanCluster, scrubKubeconfigPath } from './scanner.js';
|
|
26
|
+
export const DEFAULT_INVENTORY_FILE = '~/.dsh-ops/environment.yaml';
|
|
27
|
+
const HEADER = [
|
|
28
|
+
'# AUTO-GENERATED by @elinpf/dsh-ops-tool-environment — DO NOT EDIT BY HAND.',
|
|
29
|
+
'# Regenerated from live k8s scans; manual changes are overwritten on the next refresh.',
|
|
30
|
+
].join('\n');
|
|
31
|
+
const byNsName = (a, b) => a.namespace.localeCompare(b.namespace) || a.name.localeCompare(b.name);
|
|
32
|
+
/**
|
|
33
|
+
* Assemble the per-cluster inventory from a raw scan: classify every
|
|
34
|
+
* workload, collect middleware instances with their Service entries, and
|
|
35
|
+
* derive relation edges. Deterministic — output lists are sorted so the
|
|
36
|
+
* same cluster scanned twice yields the same section.
|
|
37
|
+
*/
|
|
38
|
+
export function buildClusterInventory(scan, opts = {}) {
|
|
39
|
+
const classified = scan.workloads
|
|
40
|
+
.map(w => ({ ...w, type: classifyWorkload(w, { userRulesFile: opts.userRulesFile }) }))
|
|
41
|
+
.sort(byNsName);
|
|
42
|
+
// Prometheus corroboration: attach up/down counts per workload (best-effort;
|
|
43
|
+
// absent when the cluster had no scrapable Prometheus).
|
|
44
|
+
if (scan.prometheus !== undefined) {
|
|
45
|
+
const statuses = matchTargetsToWorkloads(classified, scan.prometheus.targets);
|
|
46
|
+
for (const w of classified) {
|
|
47
|
+
const status = statuses.get(`${w.namespace}/${w.name}`);
|
|
48
|
+
if (status !== undefined)
|
|
49
|
+
w.monitoring = status;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const edges = buildRelations({ scan, classified });
|
|
53
|
+
const services = [...scan.services].sort(byNsName);
|
|
54
|
+
const middleware = [];
|
|
55
|
+
for (const w of classified) {
|
|
56
|
+
if (!isMiddlewareType(w.type))
|
|
57
|
+
continue;
|
|
58
|
+
const serviceEntries = edges
|
|
59
|
+
.filter(e => e.kind === 'fronts' && e.to.kind === w.kind && e.to.namespace === w.namespace && e.to.name === w.name)
|
|
60
|
+
.map(e => e.from.name)
|
|
61
|
+
.sort();
|
|
62
|
+
const instance = {
|
|
63
|
+
type: w.type,
|
|
64
|
+
namespace: w.namespace,
|
|
65
|
+
workload: w.name,
|
|
66
|
+
workloadKind: w.kind,
|
|
67
|
+
images: [...w.images].sort(),
|
|
68
|
+
serviceEntries,
|
|
69
|
+
};
|
|
70
|
+
if (w.monitoring !== undefined)
|
|
71
|
+
instance.monitoring = w.monitoring;
|
|
72
|
+
middleware.push(instance);
|
|
73
|
+
}
|
|
74
|
+
middleware.sort((a, b) => a.namespace.localeCompare(b.namespace) || a.workload.localeCompare(b.workload));
|
|
75
|
+
edges.sort((a, b) => a.kind.localeCompare(b.kind)
|
|
76
|
+
|| a.from.namespace.localeCompare(b.from.namespace)
|
|
77
|
+
|| a.from.name.localeCompare(b.from.name)
|
|
78
|
+
|| a.to.name.localeCompare(b.to.name)
|
|
79
|
+
|| a.via.localeCompare(b.via));
|
|
80
|
+
const inventory = {
|
|
81
|
+
scannedAt: scan.scannedAt,
|
|
82
|
+
middleware,
|
|
83
|
+
anomalies: detectAnomalies({ edges, services, endpoints: scan.endpoints }),
|
|
84
|
+
workloads: classified,
|
|
85
|
+
services,
|
|
86
|
+
ingresses: [...scan.ingresses].sort(byNsName),
|
|
87
|
+
edges,
|
|
88
|
+
};
|
|
89
|
+
if (scan.prometheus !== undefined)
|
|
90
|
+
inventory.prometheusService = scan.prometheus.service;
|
|
91
|
+
if (scan.ceph !== undefined)
|
|
92
|
+
inventory.ceph = scan.ceph;
|
|
93
|
+
return inventory;
|
|
94
|
+
}
|
|
95
|
+
/** Read the inventory file. Returns null when it does not exist or is malformed. */
|
|
96
|
+
export async function readInventory(file = DEFAULT_INVENTORY_FILE) {
|
|
97
|
+
let text;
|
|
98
|
+
try {
|
|
99
|
+
text = await readFile(expandHome(file), 'utf8');
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const doc = parseYaml(text);
|
|
106
|
+
if (!doc || typeof doc !== 'object' || typeof doc.clusters !== 'object' || doc.clusters === null) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
return doc;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Serialize and write the inventory, prepending the do-not-edit header. */
|
|
116
|
+
async function writeInventory(file, inventory) {
|
|
117
|
+
const path = expandHome(file);
|
|
118
|
+
await mkdir(dirname(path), { recursive: true });
|
|
119
|
+
const body = stringifyYaml(inventory);
|
|
120
|
+
await writeFile(path, `${HEADER}\n${body}`, 'utf8');
|
|
121
|
+
}
|
|
122
|
+
function emptySection(scannedAt) {
|
|
123
|
+
return { scannedAt, middleware: [], anomalies: [], workloads: [], services: [], ingresses: [], edges: [] };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Refresh the inventory for the given clusters.
|
|
127
|
+
*
|
|
128
|
+
* Per cluster: scan + assemble + replace the section. On failure the
|
|
129
|
+
* previous section survives with `stale: true` and a sanitized lastError;
|
|
130
|
+
* a cluster with no previous data gets an empty stale section so the gap
|
|
131
|
+
* is visible. Sections of clusters not in `targets` are left untouched.
|
|
132
|
+
* Never throws — per-cluster failures are folded into their sections.
|
|
133
|
+
*/
|
|
134
|
+
export async function refreshInventory(targets, opts = {}) {
|
|
135
|
+
const file = opts.file ?? DEFAULT_INVENTORY_FILE;
|
|
136
|
+
const now = opts.now ?? new Date();
|
|
137
|
+
const existing = await readInventory(file);
|
|
138
|
+
const clusters = { ...(existing?.clusters ?? {}) };
|
|
139
|
+
await Promise.all(targets.map(async (target) => {
|
|
140
|
+
try {
|
|
141
|
+
const scan = await scanCluster({
|
|
142
|
+
cluster: target.cluster,
|
|
143
|
+
kubeconfigPath: target.kubeconfigPath,
|
|
144
|
+
exec: opts.exec,
|
|
145
|
+
spawn: opts.spawn,
|
|
146
|
+
fetchFn: opts.fetchFn,
|
|
147
|
+
timeoutMs: opts.scanTimeoutMs,
|
|
148
|
+
prometheusTimeoutMs: opts.prometheusTimeoutMs,
|
|
149
|
+
now,
|
|
150
|
+
});
|
|
151
|
+
clusters[target.cluster] = buildClusterInventory(scan, { userRulesFile: opts.userRulesFile });
|
|
152
|
+
}
|
|
153
|
+
catch (err) {
|
|
154
|
+
const raw = err instanceof Error ? err.message : String(err);
|
|
155
|
+
// Defense in depth: scanner errors are pre-scrubbed; scrub again here so
|
|
156
|
+
// no code path can persist the real kubeconfig path.
|
|
157
|
+
const lastError = scrubKubeconfigPath(raw, target.kubeconfigPath);
|
|
158
|
+
const previous = clusters[target.cluster] ?? emptySection(now.toISOString());
|
|
159
|
+
clusters[target.cluster] = { ...previous, stale: true, lastError };
|
|
160
|
+
}
|
|
161
|
+
}));
|
|
162
|
+
const inventory = {
|
|
163
|
+
version: 1,
|
|
164
|
+
clusters: Object.fromEntries(Object.entries(clusters).sort(([a], [b]) => a.localeCompare(b))),
|
|
165
|
+
};
|
|
166
|
+
await writeInventory(file, inventory);
|
|
167
|
+
return inventory;
|
|
168
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prometheus read-only corroboration (spec 0003, ticket 03).
|
|
3
|
+
*
|
|
4
|
+
* When a cluster has a discoverable Prometheus service, the scanner opens a
|
|
5
|
+
* `kubectl port-forward` to it, reads `/api/v1/targets?state=active`, and
|
|
6
|
+
* matches targets onto workloads by namespace + pod-name prefix. The result
|
|
7
|
+
* is a compact `monitoring: { up, down }` per workload — k8s data and
|
|
8
|
+
* Prometheus state corroborate each other, and a down instance surfaces in
|
|
9
|
+
* the inventory.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is an ENHANCEMENT, never a hard dependency: no Prometheus
|
|
12
|
+
* service, port-forward failure, timeout, or malformed response all degrade
|
|
13
|
+
* to "no monitoring data" — the cluster section is still written and is NOT
|
|
14
|
+
* marked stale (stale is reserved for k8s scan failures).
|
|
15
|
+
*
|
|
16
|
+
* Lifecycle discipline: the port-forward child is always killed (SIGKILL
|
|
17
|
+
* after a SIGTERM grace) and reaped before this module returns, on every
|
|
18
|
+
* path — success, error, or timeout. The kubeconfig path appears only in
|
|
19
|
+
* the child's argv, never in returned data.
|
|
20
|
+
*
|
|
21
|
+
* @module @elinpf/dsh-ops-tool-environment
|
|
22
|
+
*/
|
|
23
|
+
import type { ScannedService } from './types.js';
|
|
24
|
+
/** One active Prometheus target, reduced to what matching needs. */
|
|
25
|
+
export interface PromTarget {
|
|
26
|
+
namespace?: string;
|
|
27
|
+
pod?: string;
|
|
28
|
+
service?: string;
|
|
29
|
+
job?: string;
|
|
30
|
+
/** Prometheus health string: 'up' | 'down' | 'unknown'. */
|
|
31
|
+
health: string;
|
|
32
|
+
}
|
|
33
|
+
/** Compact per-workload monitoring status attached to inventory entries. */
|
|
34
|
+
export interface MonitoringStatus {
|
|
35
|
+
up: number;
|
|
36
|
+
down: number;
|
|
37
|
+
}
|
|
38
|
+
/** Minimal child-process surface the scraper drives (injectable for tests). */
|
|
39
|
+
export interface PortForwardProcess {
|
|
40
|
+
stdout: NodeJS.ReadableStream | null;
|
|
41
|
+
stderr: NodeJS.ReadableStream | null;
|
|
42
|
+
/** Set once the process has exited (node's ChildProcess has this natively). */
|
|
43
|
+
exitCode?: number | null;
|
|
44
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
45
|
+
once(event: 'exit', listener: (code: number | null) => void): unknown;
|
|
46
|
+
}
|
|
47
|
+
export type SpawnFn = (args: string[]) => PortForwardProcess;
|
|
48
|
+
/**
|
|
49
|
+
* Find the cluster's Prometheus service. Heuristic: a Service whose name
|
|
50
|
+
* contains 'prometheus' and which exposes port 9090; the `monitoring`
|
|
51
|
+
* namespace wins ties (kube-prometheus-stack names its service
|
|
52
|
+
* `*-kube-prometheus-stack-prometheus` there). Returns undefined when no
|
|
53
|
+
* candidate exists — the caller then skips the enhancement.
|
|
54
|
+
*/
|
|
55
|
+
export declare function findPrometheusService(services: ScannedService[]): ScannedService | undefined;
|
|
56
|
+
/** Parse a /api/v1/targets response. Malformed input yields [], never throws. */
|
|
57
|
+
export declare function parseActiveTargets(body: unknown): PromTarget[];
|
|
58
|
+
interface WorkloadIdentity {
|
|
59
|
+
namespace: string;
|
|
60
|
+
name: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Match targets onto workloads by namespace + pod-name prefix
|
|
64
|
+
* (`<workload>-<suffix>` covers Deployment ReplicaSet hashes, StatefulSet
|
|
65
|
+
* ordinals, and DaemonSet suffixes). A pod matching several names goes to
|
|
66
|
+
* the LONGEST one (`foo-bar-xyz` belongs to `foo-bar`, not `foo`).
|
|
67
|
+
* Targets without a pod label, or matching no workload, are dropped.
|
|
68
|
+
*/
|
|
69
|
+
export declare function matchTargetsToWorkloads(workloads: WorkloadIdentity[], targets: PromTarget[]): Map<string, MonitoringStatus>;
|
|
70
|
+
export interface ScrapeOptions {
|
|
71
|
+
kubeconfigPath: string;
|
|
72
|
+
/** The scanned services of the cluster (discovery source). */
|
|
73
|
+
services: ScannedService[];
|
|
74
|
+
/** Injectable process spawn (tests); defaults to node:child_process. */
|
|
75
|
+
spawn?: SpawnFn;
|
|
76
|
+
/** Injectable fetch (tests); defaults to global fetch. */
|
|
77
|
+
fetchFn?: typeof fetch;
|
|
78
|
+
/** Readiness + overall watchdog budget. Default 15s. */
|
|
79
|
+
timeoutMs?: number;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Discover the cluster's Prometheus and scrape its active targets through a
|
|
83
|
+
* kubectl port-forward. Returns undefined on ANY failure — no candidate,
|
|
84
|
+
* spawn error, readiness timeout, fetch error, malformed JSON — and always
|
|
85
|
+
* reaps the child process. The caller treats undefined as "no monitoring
|
|
86
|
+
* data", never as a scan failure.
|
|
87
|
+
*/
|
|
88
|
+
export declare function scrapePrometheusTargets(opts: ScrapeOptions): Promise<{
|
|
89
|
+
service: string;
|
|
90
|
+
targets: PromTarget[];
|
|
91
|
+
} | undefined>;
|
|
92
|
+
export {};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prometheus read-only corroboration (spec 0003, ticket 03).
|
|
3
|
+
*
|
|
4
|
+
* When a cluster has a discoverable Prometheus service, the scanner opens a
|
|
5
|
+
* `kubectl port-forward` to it, reads `/api/v1/targets?state=active`, and
|
|
6
|
+
* matches targets onto workloads by namespace + pod-name prefix. The result
|
|
7
|
+
* is a compact `monitoring: { up, down }` per workload — k8s data and
|
|
8
|
+
* Prometheus state corroborate each other, and a down instance surfaces in
|
|
9
|
+
* the inventory.
|
|
10
|
+
*
|
|
11
|
+
* Everything here is an ENHANCEMENT, never a hard dependency: no Prometheus
|
|
12
|
+
* service, port-forward failure, timeout, or malformed response all degrade
|
|
13
|
+
* to "no monitoring data" — the cluster section is still written and is NOT
|
|
14
|
+
* marked stale (stale is reserved for k8s scan failures).
|
|
15
|
+
*
|
|
16
|
+
* Lifecycle discipline: the port-forward child is always killed (SIGKILL
|
|
17
|
+
* after a SIGTERM grace) and reaped before this module returns, on every
|
|
18
|
+
* path — success, error, or timeout. The kubeconfig path appears only in
|
|
19
|
+
* the child's argv, never in returned data.
|
|
20
|
+
*
|
|
21
|
+
* @module @elinpf/dsh-ops-tool-environment
|
|
22
|
+
*/
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
import { createServer } from 'node:net';
|
|
25
|
+
const defaultSpawn = (args) => spawn(args[0], args.slice(1), { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
26
|
+
// ── Discovery ────────────────────────────────────────────────────────────────
|
|
27
|
+
/**
|
|
28
|
+
* Find the cluster's Prometheus service. Heuristic: a Service whose name
|
|
29
|
+
* contains 'prometheus' and which exposes port 9090; the `monitoring`
|
|
30
|
+
* namespace wins ties (kube-prometheus-stack names its service
|
|
31
|
+
* `*-kube-prometheus-stack-prometheus` there). Returns undefined when no
|
|
32
|
+
* candidate exists — the caller then skips the enhancement.
|
|
33
|
+
*/
|
|
34
|
+
export function findPrometheusService(services) {
|
|
35
|
+
const candidates = services.filter(s => s.name.includes('prometheus') && s.ports.some(p => p.port === 9090));
|
|
36
|
+
return candidates.find(s => s.namespace === 'monitoring') ?? candidates[0];
|
|
37
|
+
}
|
|
38
|
+
// ── Targets parsing ──────────────────────────────────────────────────────────
|
|
39
|
+
/** Parse a /api/v1/targets response. Malformed input yields [], never throws. */
|
|
40
|
+
export function parseActiveTargets(body) {
|
|
41
|
+
try {
|
|
42
|
+
const data = body?.data;
|
|
43
|
+
if (!data || !Array.isArray(data.activeTargets))
|
|
44
|
+
return [];
|
|
45
|
+
const targets = [];
|
|
46
|
+
for (const entry of data.activeTargets) {
|
|
47
|
+
const labels = entry?.labels ?? {};
|
|
48
|
+
const health = entry?.health;
|
|
49
|
+
if (typeof health !== 'string')
|
|
50
|
+
continue;
|
|
51
|
+
const target = { health };
|
|
52
|
+
for (const key of ['namespace', 'pod', 'service', 'job']) {
|
|
53
|
+
const value = labels[key];
|
|
54
|
+
if (typeof value === 'string' && value !== '')
|
|
55
|
+
target[key] = value;
|
|
56
|
+
}
|
|
57
|
+
targets.push(target);
|
|
58
|
+
}
|
|
59
|
+
return targets;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function workloadKey(w) {
|
|
66
|
+
return `${w.namespace}/${w.name}`;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Match targets onto workloads by namespace + pod-name prefix
|
|
70
|
+
* (`<workload>-<suffix>` covers Deployment ReplicaSet hashes, StatefulSet
|
|
71
|
+
* ordinals, and DaemonSet suffixes). A pod matching several names goes to
|
|
72
|
+
* the LONGEST one (`foo-bar-xyz` belongs to `foo-bar`, not `foo`).
|
|
73
|
+
* Targets without a pod label, or matching no workload, are dropped.
|
|
74
|
+
*/
|
|
75
|
+
export function matchTargetsToWorkloads(workloads, targets) {
|
|
76
|
+
const statuses = new Map();
|
|
77
|
+
for (const target of targets) {
|
|
78
|
+
if (!target.namespace || !target.pod)
|
|
79
|
+
continue;
|
|
80
|
+
let best;
|
|
81
|
+
for (const w of workloads) {
|
|
82
|
+
if (w.namespace !== target.namespace)
|
|
83
|
+
continue;
|
|
84
|
+
if (target.pod !== w.name && !target.pod.startsWith(`${w.name}-`))
|
|
85
|
+
continue;
|
|
86
|
+
if (!best || w.name.length > best.name.length)
|
|
87
|
+
best = w;
|
|
88
|
+
}
|
|
89
|
+
if (!best)
|
|
90
|
+
continue;
|
|
91
|
+
const key = workloadKey(best);
|
|
92
|
+
const status = statuses.get(key) ?? { up: 0, down: 0 };
|
|
93
|
+
if (target.health === 'up')
|
|
94
|
+
status.up++;
|
|
95
|
+
else if (target.health === 'down')
|
|
96
|
+
status.down++;
|
|
97
|
+
statuses.set(key, status);
|
|
98
|
+
}
|
|
99
|
+
return statuses;
|
|
100
|
+
}
|
|
101
|
+
const DEFAULT_SCRAPE_TIMEOUT_MS = 15_000;
|
|
102
|
+
const KILL_GRACE_MS = 2_000;
|
|
103
|
+
/** Pick an ephemeral local port by binding and releasing :0. */
|
|
104
|
+
function pickLocalPort() {
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
const server = createServer();
|
|
107
|
+
server.once('error', reject);
|
|
108
|
+
server.listen(0, '127.0.0.1', () => {
|
|
109
|
+
const address = server.address();
|
|
110
|
+
const port = typeof address === 'object' && address !== null ? address.port : 0;
|
|
111
|
+
server.close(() => port > 0 ? resolve(port) : reject(new Error('no ephemeral port')));
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/** Kill the child (SIGTERM, then SIGKILL after a grace) and wait for exit. */
|
|
116
|
+
async function reap(child) {
|
|
117
|
+
if (child.exitCode !== undefined && child.exitCode !== null)
|
|
118
|
+
return; // already dead
|
|
119
|
+
const exited = new Promise(resolve => child.once('exit', () => resolve()));
|
|
120
|
+
child.kill('SIGTERM');
|
|
121
|
+
const deadline = new Promise(resolve => setTimeout(resolve, KILL_GRACE_MS));
|
|
122
|
+
await Promise.race([exited, deadline]);
|
|
123
|
+
child.kill('SIGKILL'); // no-op if already dead
|
|
124
|
+
await Promise.race([exited, deadline]);
|
|
125
|
+
}
|
|
126
|
+
/** Wait until kubectl port-forward announces readiness (or dies trying). */
|
|
127
|
+
function waitForForwarding(child, timeoutMs) {
|
|
128
|
+
return new Promise((resolve, reject) => {
|
|
129
|
+
let buffer = '';
|
|
130
|
+
const onExit = (code) => {
|
|
131
|
+
cleanup();
|
|
132
|
+
reject(new Error(`port-forward exited before ready (code ${code ?? 'null'})`));
|
|
133
|
+
};
|
|
134
|
+
const onData = (chunk) => {
|
|
135
|
+
buffer += chunk.toString('utf8');
|
|
136
|
+
if (buffer.includes('Forwarding from')) {
|
|
137
|
+
cleanup();
|
|
138
|
+
resolve();
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
const timer = setTimeout(() => {
|
|
142
|
+
cleanup();
|
|
143
|
+
reject(new Error(`port-forward not ready within ${timeoutMs}ms`));
|
|
144
|
+
}, timeoutMs);
|
|
145
|
+
function cleanup() {
|
|
146
|
+
clearTimeout(timer);
|
|
147
|
+
child.stdout?.removeListener('data', onData);
|
|
148
|
+
}
|
|
149
|
+
child.stdout?.on('data', onData);
|
|
150
|
+
child.once('exit', onExit);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Discover the cluster's Prometheus and scrape its active targets through a
|
|
155
|
+
* kubectl port-forward. Returns undefined on ANY failure — no candidate,
|
|
156
|
+
* spawn error, readiness timeout, fetch error, malformed JSON — and always
|
|
157
|
+
* reaps the child process. The caller treats undefined as "no monitoring
|
|
158
|
+
* data", never as a scan failure.
|
|
159
|
+
*/
|
|
160
|
+
export async function scrapePrometheusTargets(opts) {
|
|
161
|
+
const service = findPrometheusService(opts.services);
|
|
162
|
+
if (!service)
|
|
163
|
+
return undefined;
|
|
164
|
+
const servicePort = service.ports.find(p => p.port === 9090).port;
|
|
165
|
+
const spawnFn = opts.spawn ?? defaultSpawn;
|
|
166
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
167
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_SCRAPE_TIMEOUT_MS;
|
|
168
|
+
let child;
|
|
169
|
+
try {
|
|
170
|
+
const localPort = await pickLocalPort();
|
|
171
|
+
child = spawnFn([
|
|
172
|
+
'kubectl', `--kubeconfig=${opts.kubeconfigPath}`,
|
|
173
|
+
'-n', service.namespace,
|
|
174
|
+
'port-forward', `svc/${service.name}`, `${localPort}:${servicePort}`,
|
|
175
|
+
]);
|
|
176
|
+
await waitForForwarding(child, timeoutMs);
|
|
177
|
+
const response = await fetchFn(`http://127.0.0.1:${localPort}/api/v1/targets?state=active`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
178
|
+
if (!response.ok)
|
|
179
|
+
return undefined;
|
|
180
|
+
const targets = parseActiveTargets(await response.json());
|
|
181
|
+
return { service: `${service.namespace}/${service.name}`, targets };
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
finally {
|
|
187
|
+
if (child)
|
|
188
|
+
await reap(child);
|
|
189
|
+
}
|
|
190
|
+
}
|
package/lib/prompt.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt half of the environment inventory (preset plane).
|
|
3
|
+
*
|
|
4
|
+
* Registers the one-line methodology section ("run environment overview
|
|
5
|
+
* before investigating") through the ops-prompts channel. Split from the
|
|
6
|
+
* tool entry because of realm topology: the tool needs the opsAccess
|
|
7
|
+
* isolate realm (ops-access-registry group), the prompt needs the
|
|
8
|
+
* opsPrompts realm (ops-orchestration group), and entry-local realms are
|
|
9
|
+
* invisible across groups. Mount this row inside the ops-orchestration
|
|
10
|
+
* group; see ops-preset.yml.
|
|
11
|
+
*
|
|
12
|
+
* @module @elinpf/dsh-ops-tool-environment/prompt
|
|
13
|
+
*/
|
|
14
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
15
|
+
import type { OpsPromptsHandle } from '@elinpf/dsh-ops-prompts';
|
|
16
|
+
declare module '@deepseek-ai/cordis' {
|
|
17
|
+
interface Context {
|
|
18
|
+
opsPrompts?: OpsPromptsHandle;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export declare const name = "ops-tool-environment-prompt";
|
|
22
|
+
export declare function apply(ctx: Context): void;
|
package/lib/prompt.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompt half of the environment inventory (preset plane).
|
|
3
|
+
*
|
|
4
|
+
* Registers the one-line methodology section ("run environment overview
|
|
5
|
+
* before investigating") through the ops-prompts channel. Split from the
|
|
6
|
+
* tool entry because of realm topology: the tool needs the opsAccess
|
|
7
|
+
* isolate realm (ops-access-registry group), the prompt needs the
|
|
8
|
+
* opsPrompts realm (ops-orchestration group), and entry-local realms are
|
|
9
|
+
* invisible across groups. Mount this row inside the ops-orchestration
|
|
10
|
+
* group; see ops-preset.yml.
|
|
11
|
+
*
|
|
12
|
+
* @module @elinpf/dsh-ops-tool-environment/prompt
|
|
13
|
+
*/
|
|
14
|
+
import { STATIC_PROMPT } from './doctrine.js';
|
|
15
|
+
// ── Plugin identity ───────────────────────────────────────────────────────────
|
|
16
|
+
export const name = 'ops-tool-environment-prompt';
|
|
17
|
+
// ── Plugin apply ─────────────────────────────────────────────────────────────
|
|
18
|
+
export function apply(ctx) {
|
|
19
|
+
// registerMethodology returns a disposer — route it through ctx.effect so
|
|
20
|
+
// the section leaves the ops-prompts registry when this plugin's fiber is
|
|
21
|
+
// disposed (HMR reload / preset unmount), not just on process exit.
|
|
22
|
+
const registerThroughHandle = (rctx, opsPrompts) => {
|
|
23
|
+
rctx.effect(() => opsPrompts.registerMethodology({
|
|
24
|
+
name: 'environment:usage',
|
|
25
|
+
order: 250,
|
|
26
|
+
text: STATIC_PROMPT,
|
|
27
|
+
}));
|
|
28
|
+
};
|
|
29
|
+
// The preset mounts the group's plugins concurrently, so a one-shot
|
|
30
|
+
// ctx.get can lose the race against ops-prompts' provide — fall back to
|
|
31
|
+
// ctx.inject, which defers until the service arrives. When ops-prompts is
|
|
32
|
+
// genuinely absent, the tool description and action=help still carry the
|
|
33
|
+
// usage documentation.
|
|
34
|
+
const immediate = ctx.get('opsPrompts');
|
|
35
|
+
if (immediate !== undefined) {
|
|
36
|
+
registerThroughHandle(ctx, immediate);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
ctx.inject(['opsPrompts'], (pctx) => {
|
|
40
|
+
registerThroughHandle(pctx, pctx.opsPrompts);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort relation edges between scanned resources.
|
|
3
|
+
*
|
|
4
|
+
* Sources of truth, in order of strength:
|
|
5
|
+
*
|
|
6
|
+
* - `fronts` — a Service whose pod selector is a subset of a workload's pod
|
|
7
|
+
* template labels fronts that workload. Deterministic, from k8s itself.
|
|
8
|
+
* - `uses-service` — a literal `env` value or a data value of a referenced
|
|
9
|
+
* ConfigMap contains a `<name>.<namespace>.svc[.cluster.local]` address
|
|
10
|
+
* that resolves to a scanned Service.
|
|
11
|
+
* - `uses-middleware` — composition of the two above: a workload that uses
|
|
12
|
+
* a Service which fronts a recognized middleware instance.
|
|
13
|
+
* - `references-secret` — envFrom/valueFrom secret references, NAME only.
|
|
14
|
+
* Secret values are never read anywhere in this package.
|
|
15
|
+
*
|
|
16
|
+
* Everything here is best-effort: a value that parses to nothing produces
|
|
17
|
+
* no edge and no error. A missing ConfigMap, a dangling Service name, a
|
|
18
|
+
* selector-less Service — all simply yield no edge.
|
|
19
|
+
*
|
|
20
|
+
* @module @elinpf/dsh-ops-tool-environment
|
|
21
|
+
*/
|
|
22
|
+
import type { ClassifiedWorkload, ClusterScan, RelationEdge } from './types.js';
|
|
23
|
+
/** Extract distinct (name, namespace) Service addresses mentioned in a text value. */
|
|
24
|
+
export declare function findServiceAddresses(value: string): Array<{
|
|
25
|
+
name: string;
|
|
26
|
+
namespace: string;
|
|
27
|
+
}>;
|
|
28
|
+
export interface BuildRelationsInput {
|
|
29
|
+
scan: ClusterScan;
|
|
30
|
+
/** Workloads after classification — used to compose uses-middleware edges. */
|
|
31
|
+
classified: ClassifiedWorkload[];
|
|
32
|
+
}
|
|
33
|
+
/** Derive all relation edges for one scanned cluster. Never throws. */
|
|
34
|
+
export declare function buildRelations(input: BuildRelationsInput): RelationEdge[];
|