@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/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>;
@@ -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
+ }
@@ -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[];