@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/tool.js
ADDED
|
@@ -0,0 +1,593 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `environment` model tool — the preset-plane face of the inventory.
|
|
3
|
+
*
|
|
4
|
+
* Three working actions plus help:
|
|
5
|
+
*
|
|
6
|
+
* - `overview` — compact all-cluster summary (middleware counts by type,
|
|
7
|
+
* unknown count, stale flag, scan time).
|
|
8
|
+
* - `show` — one cluster in detail: middleware instances, the unknown
|
|
9
|
+
* bucket, relation edges.
|
|
10
|
+
* - `refresh` — re-scan every k8s profile in the ops-access registry.
|
|
11
|
+
*
|
|
12
|
+
* Freshness: overview/show call ensureFresh first — when the inventory is
|
|
13
|
+
* missing or its oldest section is older than the configured TTL, a refresh
|
|
14
|
+
* runs before answering. Nothing scans at session start; apply() only
|
|
15
|
+
* registers the tool.
|
|
16
|
+
*
|
|
17
|
+
* Refresh resolves each k8s profile WITHOUT an agent identity: the access
|
|
18
|
+
* gate's broker falls back to the ro tier for agent-less resolves, which is
|
|
19
|
+
* exactly the read-only discipline the scanner wants. kubeconfig paths are
|
|
20
|
+
* used to spawn kubectl and never surface in results — every error string
|
|
21
|
+
* crossing into tool output is scrubbed.
|
|
22
|
+
*
|
|
23
|
+
* @module @elinpf/dsh-ops-tool-environment
|
|
24
|
+
*/
|
|
25
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
26
|
+
import { readInventory, refreshInventory } from './inventory.js';
|
|
27
|
+
import { HELP_TEXT, TOOL_DESCRIPTION } from './doctrine.js';
|
|
28
|
+
const MONITORING_SCHEMA = {
|
|
29
|
+
type: 'object',
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
properties: {
|
|
32
|
+
up: { type: 'integer', required: true },
|
|
33
|
+
down: { type: 'integer', required: true },
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
const CEPH_NS_NAME_SCHEMA = { type: 'object', additionalProperties: false, properties: { namespace: { type: 'string', required: true }, name: { type: 'string', required: true } } };
|
|
37
|
+
/** rook-ceph footprint hints in show output (ticket 15). */
|
|
38
|
+
const CEPH_HINTS_SCHEMA = {
|
|
39
|
+
type: 'object',
|
|
40
|
+
additionalProperties: false,
|
|
41
|
+
properties: {
|
|
42
|
+
pools: { type: 'array', required: true, items: CEPH_NS_NAME_SCHEMA },
|
|
43
|
+
clusters: { type: 'array', required: true, items: CEPH_NS_NAME_SCHEMA },
|
|
44
|
+
toolsPod: CEPH_NS_NAME_SCHEMA,
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
const RESOURCE_REF_SCHEMA = {
|
|
48
|
+
type: 'object',
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
properties: {
|
|
51
|
+
kind: { type: 'string', required: true },
|
|
52
|
+
namespace: { type: 'string', required: true },
|
|
53
|
+
name: { type: 'string', required: true },
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
const ANOMALY_SCHEMA = {
|
|
57
|
+
type: 'object',
|
|
58
|
+
additionalProperties: false,
|
|
59
|
+
properties: {
|
|
60
|
+
kind: { type: 'string', required: true },
|
|
61
|
+
severity: { type: 'string', required: true },
|
|
62
|
+
message: { type: 'string', required: true },
|
|
63
|
+
ref: { ...RESOURCE_REF_SCHEMA, required: true },
|
|
64
|
+
related: RESOURCE_REF_SCHEMA,
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
// ── Shaping helpers ──────────────────────────────────────────────────────────
|
|
68
|
+
function summarize(name, section) {
|
|
69
|
+
const byTypeMap = new Map();
|
|
70
|
+
for (const m of section.middleware)
|
|
71
|
+
byTypeMap.set(m.type, (byTypeMap.get(m.type) ?? 0) + 1);
|
|
72
|
+
const summary = {
|
|
73
|
+
name,
|
|
74
|
+
scannedAt: section.scannedAt,
|
|
75
|
+
stale: section.stale === true,
|
|
76
|
+
middleware: section.middleware.length,
|
|
77
|
+
unknown: section.workloads.filter(w => w.type === 'unknown').length,
|
|
78
|
+
down: section.workloads.reduce((n, w) => n + (w.monitoring?.down ?? 0), 0),
|
|
79
|
+
anomalies: (section.anomalies ?? []).length,
|
|
80
|
+
byType: [...byTypeMap.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([type, count]) => ({ type, count })),
|
|
81
|
+
};
|
|
82
|
+
if (section.ceph !== undefined && section.ceph.pools.length > 0)
|
|
83
|
+
summary.cephPools = section.ceph.pools.length;
|
|
84
|
+
if (section.lastError !== undefined)
|
|
85
|
+
summary.lastError = section.lastError;
|
|
86
|
+
return summary;
|
|
87
|
+
}
|
|
88
|
+
function detailOf(name, section) {
|
|
89
|
+
const detail = {
|
|
90
|
+
name,
|
|
91
|
+
scannedAt: section.scannedAt,
|
|
92
|
+
stale: section.stale === true,
|
|
93
|
+
middleware: section.middleware,
|
|
94
|
+
unknown: section.workloads
|
|
95
|
+
.filter(w => w.type === 'unknown')
|
|
96
|
+
.map(w => {
|
|
97
|
+
const u = { name: w.name, namespace: w.namespace, kind: w.kind, images: w.images };
|
|
98
|
+
if (w.monitoring !== undefined)
|
|
99
|
+
u.monitoring = w.monitoring;
|
|
100
|
+
return u;
|
|
101
|
+
}),
|
|
102
|
+
edges: section.edges,
|
|
103
|
+
anomalies: section.anomalies ?? [], // absent in sections written before anomalies existed
|
|
104
|
+
counts: {
|
|
105
|
+
services: section.services.length,
|
|
106
|
+
ingresses: section.ingresses.length,
|
|
107
|
+
workloads: section.workloads.length,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
if (section.lastError !== undefined)
|
|
111
|
+
detail.lastError = section.lastError;
|
|
112
|
+
if (section.prometheusService !== undefined)
|
|
113
|
+
detail.prometheusService = section.prometheusService;
|
|
114
|
+
if (section.ceph !== undefined)
|
|
115
|
+
detail.ceph = section.ceph;
|
|
116
|
+
return detail;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Apply show's optional filters to a cluster detail. Both filters narrow the
|
|
120
|
+
* middleware list and the unknown bucket; when a filter is given, edges are
|
|
121
|
+
* kept only when their WORKLOAD endpoint survives the filter — that endpoint
|
|
122
|
+
* is `from` for uses-service/uses-middleware/references-secret edges and `to`
|
|
123
|
+
* for fronts edges (whose from is a Service). An investigation starts from a
|
|
124
|
+
* workload and follows its outgoing edges, so edges whose workload fell out
|
|
125
|
+
* of the filtered set are noise.
|
|
126
|
+
*/
|
|
127
|
+
export function filterDetail(detail, filter) {
|
|
128
|
+
const ns = filter.namespace;
|
|
129
|
+
const name = filter.name?.toLowerCase();
|
|
130
|
+
if (ns === undefined && name === undefined)
|
|
131
|
+
return detail;
|
|
132
|
+
const matches = (namespace, workloadName) => (ns === undefined || namespace === ns)
|
|
133
|
+
&& (name === undefined || workloadName.toLowerCase().includes(name));
|
|
134
|
+
const middleware = detail.middleware.filter(m => matches(m.namespace, m.workload));
|
|
135
|
+
const unknown = detail.unknown.filter(u => matches(u.namespace, u.name));
|
|
136
|
+
const kept = new Set([
|
|
137
|
+
...middleware.map(m => `${m.namespace}/${m.workload}`),
|
|
138
|
+
...unknown.map(u => `${u.namespace}/${u.name}`),
|
|
139
|
+
]);
|
|
140
|
+
const edges = detail.edges.filter(e => {
|
|
141
|
+
const wl = e.kind === 'fronts' ? e.to : e.from;
|
|
142
|
+
return kept.has(`${wl.namespace}/${wl.name}`);
|
|
143
|
+
});
|
|
144
|
+
// Anomalies narrow with the lists: workload-subject anomalies follow the
|
|
145
|
+
// workload filter; Service-subject anomalies survive when a surviving
|
|
146
|
+
// middleware instance is fronted by that Service.
|
|
147
|
+
const anomalies = detail.anomalies.filter(a => {
|
|
148
|
+
if (a.ref.kind === 'Service') {
|
|
149
|
+
return middleware.some(m => m.namespace === a.ref.namespace && m.serviceEntries.includes(a.ref.name));
|
|
150
|
+
}
|
|
151
|
+
return matches(a.ref.namespace, a.ref.name);
|
|
152
|
+
});
|
|
153
|
+
return { ...detail, middleware, unknown, edges, anomalies };
|
|
154
|
+
}
|
|
155
|
+
// ── The tool factory ─────────────────────────────────────────────────────────
|
|
156
|
+
export function createEnvironmentTool(ctx, config, deps = {}) {
|
|
157
|
+
const read = deps.readInventory ?? readInventory;
|
|
158
|
+
const refreshAll = deps.refreshInventory ?? refreshInventory;
|
|
159
|
+
const now = deps.now ?? (() => Date.now());
|
|
160
|
+
const ttlMs = config.ttlMinutes * 60_000;
|
|
161
|
+
/** Resolve opsAccess per call — never cached, never a static inject. */
|
|
162
|
+
const getOpsAccess = () => ctx.get('opsAccess');
|
|
163
|
+
/**
|
|
164
|
+
* Collect scan targets from the registry: every k8s entry whose ro tier
|
|
165
|
+
* resolves. Entries that fail resolve are reported as skipped, with the
|
|
166
|
+
* kubeconfig path scrubbed out of the reason (defense in depth — the
|
|
167
|
+
* registry's own errors never carry it).
|
|
168
|
+
*/
|
|
169
|
+
async function collectTargets(opsAccess) {
|
|
170
|
+
const entries = await opsAccess.listAll();
|
|
171
|
+
const targets = [];
|
|
172
|
+
const skipped = [];
|
|
173
|
+
for (const entry of entries.filter(e => e.kind === 'k8s')) {
|
|
174
|
+
try {
|
|
175
|
+
// No agent identity on purpose: the gate's broker falls back to ro
|
|
176
|
+
// for agent-less resolves — scanning is read-only by discipline.
|
|
177
|
+
const profile = await opsAccess.resolve('k8s', entry.name);
|
|
178
|
+
const kubeconfigPath = profile.fields.kubeconfigPath;
|
|
179
|
+
if (typeof kubeconfigPath === 'string' && kubeconfigPath !== '') {
|
|
180
|
+
targets.push({ cluster: entry.name, kubeconfigPath });
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
skipped.push({ cluster: entry.name, status: 'skipped', error: 'profile has no kubeconfigPath field' });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
skipped.push({ cluster: entry.name, status: 'skipped', error: err instanceof Error ? err.message : String(err) });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return { targets, skipped };
|
|
191
|
+
}
|
|
192
|
+
/** Re-scan all registered k8s clusters and shape the per-cluster report. */
|
|
193
|
+
async function doRefresh(opsAccess) {
|
|
194
|
+
const { targets, skipped } = await collectTargets(opsAccess);
|
|
195
|
+
const inventory = await refreshAll(targets, {
|
|
196
|
+
file: config.inventoryFile,
|
|
197
|
+
userRulesFile: config.rulesFile,
|
|
198
|
+
scanTimeoutMs: config.scanTimeoutMs,
|
|
199
|
+
prometheusTimeoutMs: config.prometheusTimeoutMs,
|
|
200
|
+
});
|
|
201
|
+
const results = [...skipped];
|
|
202
|
+
for (const target of targets) {
|
|
203
|
+
const section = inventory.clusters[target.cluster];
|
|
204
|
+
if (!section) {
|
|
205
|
+
results.push({ cluster: target.cluster, status: 'stale', error: 'scan produced no section' });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
const summary = summarize(target.cluster, section);
|
|
209
|
+
const entry = {
|
|
210
|
+
cluster: target.cluster,
|
|
211
|
+
status: summary.stale ? 'stale' : 'ok',
|
|
212
|
+
middleware: summary.middleware,
|
|
213
|
+
unknown: summary.unknown,
|
|
214
|
+
};
|
|
215
|
+
if (summary.lastError !== undefined)
|
|
216
|
+
entry.error = summary.lastError;
|
|
217
|
+
results.push(entry);
|
|
218
|
+
}
|
|
219
|
+
results.sort((a, b) => a.cluster.localeCompare(b.cluster));
|
|
220
|
+
return { action: 'refresh', results, refreshedAt: new Date(now()).toISOString() };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* TTL gate before reads: refresh when the inventory is missing or its
|
|
224
|
+
* oldest section is past the TTL. Best-effort — without opsAccess (or on
|
|
225
|
+
* total scan failure) the caller answers from whatever cache exists.
|
|
226
|
+
*/
|
|
227
|
+
async function ensureFresh() {
|
|
228
|
+
const inventory = await read(config.inventoryFile);
|
|
229
|
+
const sections = Object.values(inventory?.clusters ?? {});
|
|
230
|
+
const oldest = sections.reduce((min, s) => {
|
|
231
|
+
const t = Date.parse(s.scannedAt);
|
|
232
|
+
return Number.isNaN(t) ? 0 : Math.min(min, t);
|
|
233
|
+
}, Number.POSITIVE_INFINITY);
|
|
234
|
+
const expired = oldest === Number.POSITIVE_INFINITY || now() - oldest > ttlMs;
|
|
235
|
+
if (!expired)
|
|
236
|
+
return undefined;
|
|
237
|
+
const opsAccess = getOpsAccess();
|
|
238
|
+
if (!opsAccess)
|
|
239
|
+
return 'inventory is expired or missing, but the ops-access service is unavailable — answering from cache';
|
|
240
|
+
await doRefresh(opsAccess);
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
return defineTool({
|
|
244
|
+
name: 'environment',
|
|
245
|
+
description: TOOL_DESCRIPTION,
|
|
246
|
+
parameters: {
|
|
247
|
+
action: {
|
|
248
|
+
type: 'string', required: true, enum: ['overview', 'show', 'refresh', 'help'],
|
|
249
|
+
description: 'overview: all clusters, compact. show: one cluster, details + edges (requires cluster). refresh: re-scan now. help: full usage.',
|
|
250
|
+
},
|
|
251
|
+
cluster: { type: 'string', description: 'Cluster name (required for show). Use overview or list_access to see names.' },
|
|
252
|
+
namespace: { type: 'string', description: 'show only: keep middleware/unknown workloads in this namespace (exact match).' },
|
|
253
|
+
name: { type: 'string', description: 'show only: keep middleware/unknown workloads whose name contains this substring (case-insensitive). Combined with namespace as AND.' },
|
|
254
|
+
},
|
|
255
|
+
output: {
|
|
256
|
+
schema: {
|
|
257
|
+
type: 'object',
|
|
258
|
+
additionalProperties: false,
|
|
259
|
+
properties: {
|
|
260
|
+
action: { type: 'string', required: true },
|
|
261
|
+
help: { type: 'string' },
|
|
262
|
+
note: { type: 'string' },
|
|
263
|
+
error: { type: 'string' },
|
|
264
|
+
totalClusters: { type: 'integer' },
|
|
265
|
+
refreshedAt: { type: 'string' },
|
|
266
|
+
anomalies: {
|
|
267
|
+
type: 'array',
|
|
268
|
+
items: {
|
|
269
|
+
type: 'object',
|
|
270
|
+
additionalProperties: false,
|
|
271
|
+
properties: {
|
|
272
|
+
cluster: { type: 'string', required: true },
|
|
273
|
+
kind: { type: 'string', required: true },
|
|
274
|
+
severity: { type: 'string', required: true },
|
|
275
|
+
message: { type: 'string', required: true },
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
clusters: {
|
|
280
|
+
type: 'array',
|
|
281
|
+
items: {
|
|
282
|
+
type: 'object',
|
|
283
|
+
additionalProperties: false,
|
|
284
|
+
properties: {
|
|
285
|
+
name: { type: 'string', required: true },
|
|
286
|
+
scannedAt: { type: 'string', required: true },
|
|
287
|
+
stale: { type: 'boolean', required: true },
|
|
288
|
+
middleware: { type: 'integer', required: true },
|
|
289
|
+
unknown: { type: 'integer', required: true },
|
|
290
|
+
down: { type: 'integer', required: true },
|
|
291
|
+
anomalies: { type: 'integer', required: true },
|
|
292
|
+
byType: {
|
|
293
|
+
type: 'array',
|
|
294
|
+
required: true,
|
|
295
|
+
items: {
|
|
296
|
+
type: 'object',
|
|
297
|
+
additionalProperties: false,
|
|
298
|
+
properties: {
|
|
299
|
+
type: { type: 'string', required: true },
|
|
300
|
+
count: { type: 'integer', required: true },
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
cephPools: { type: 'integer' },
|
|
305
|
+
lastError: { type: 'string' },
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
},
|
|
309
|
+
cluster: {
|
|
310
|
+
type: 'object',
|
|
311
|
+
additionalProperties: false,
|
|
312
|
+
properties: {
|
|
313
|
+
name: { type: 'string', required: true },
|
|
314
|
+
scannedAt: { type: 'string', required: true },
|
|
315
|
+
stale: { type: 'boolean', required: true },
|
|
316
|
+
lastError: { type: 'string' },
|
|
317
|
+
prometheusService: { type: 'string' },
|
|
318
|
+
ceph: CEPH_HINTS_SCHEMA,
|
|
319
|
+
counts: {
|
|
320
|
+
type: 'object',
|
|
321
|
+
additionalProperties: false,
|
|
322
|
+
required: true,
|
|
323
|
+
properties: {
|
|
324
|
+
services: { type: 'integer', required: true },
|
|
325
|
+
ingresses: { type: 'integer', required: true },
|
|
326
|
+
workloads: { type: 'integer', required: true },
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
middleware: {
|
|
330
|
+
type: 'array',
|
|
331
|
+
required: true,
|
|
332
|
+
items: {
|
|
333
|
+
type: 'object',
|
|
334
|
+
additionalProperties: false,
|
|
335
|
+
properties: {
|
|
336
|
+
type: { type: 'string', required: true },
|
|
337
|
+
namespace: { type: 'string', required: true },
|
|
338
|
+
workload: { type: 'string', required: true },
|
|
339
|
+
workloadKind: { type: 'string', required: true },
|
|
340
|
+
images: { type: 'array', required: true, items: { type: 'string' } },
|
|
341
|
+
serviceEntries: { type: 'array', required: true, items: { type: 'string' } },
|
|
342
|
+
monitoring: MONITORING_SCHEMA,
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
unknown: {
|
|
347
|
+
type: 'array',
|
|
348
|
+
required: true,
|
|
349
|
+
items: {
|
|
350
|
+
type: 'object',
|
|
351
|
+
additionalProperties: false,
|
|
352
|
+
properties: {
|
|
353
|
+
name: { type: 'string', required: true },
|
|
354
|
+
namespace: { type: 'string', required: true },
|
|
355
|
+
kind: { type: 'string', required: true },
|
|
356
|
+
images: { type: 'array', required: true, items: { type: 'string' } },
|
|
357
|
+
monitoring: MONITORING_SCHEMA,
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
edges: {
|
|
362
|
+
type: 'array',
|
|
363
|
+
required: true,
|
|
364
|
+
items: {
|
|
365
|
+
type: 'object',
|
|
366
|
+
additionalProperties: false,
|
|
367
|
+
properties: {
|
|
368
|
+
kind: { type: 'string', required: true },
|
|
369
|
+
from: {
|
|
370
|
+
type: 'object', required: true, additionalProperties: false,
|
|
371
|
+
properties: {
|
|
372
|
+
kind: { type: 'string', required: true },
|
|
373
|
+
namespace: { type: 'string', required: true },
|
|
374
|
+
name: { type: 'string', required: true },
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
to: {
|
|
378
|
+
type: 'object', required: true, additionalProperties: false,
|
|
379
|
+
properties: {
|
|
380
|
+
kind: { type: 'string', required: true },
|
|
381
|
+
namespace: { type: 'string', required: true },
|
|
382
|
+
name: { type: 'string', required: true },
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
via: { type: 'string', required: true },
|
|
386
|
+
targetType: { type: 'string' },
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
},
|
|
390
|
+
anomalies: {
|
|
391
|
+
type: 'array',
|
|
392
|
+
required: true,
|
|
393
|
+
items: ANOMALY_SCHEMA,
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
results: {
|
|
398
|
+
type: 'array',
|
|
399
|
+
items: {
|
|
400
|
+
type: 'object',
|
|
401
|
+
additionalProperties: false,
|
|
402
|
+
properties: {
|
|
403
|
+
cluster: { type: 'string', required: true },
|
|
404
|
+
status: { type: 'string', required: true, enum: ['ok', 'stale', 'skipped'] },
|
|
405
|
+
middleware: { type: 'integer' },
|
|
406
|
+
unknown: { type: 'integer' },
|
|
407
|
+
error: { type: 'string' },
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
render: (args, value) => [{ type: 'text', text: renderResult(value, args) }],
|
|
414
|
+
},
|
|
415
|
+
async execute(args) {
|
|
416
|
+
try {
|
|
417
|
+
switch (args.action) {
|
|
418
|
+
case 'help':
|
|
419
|
+
return { action: 'help', help: HELP_TEXT };
|
|
420
|
+
case 'overview': {
|
|
421
|
+
const note = await ensureFresh();
|
|
422
|
+
const inventory = await read(config.inventoryFile);
|
|
423
|
+
const clusters = Object.entries(inventory?.clusters ?? {})
|
|
424
|
+
.map(([name, section]) => summarize(name, section));
|
|
425
|
+
const anomalies = [];
|
|
426
|
+
for (const [name, section] of Object.entries(inventory?.clusters ?? {})) {
|
|
427
|
+
for (const a of section.anomalies ?? []) {
|
|
428
|
+
anomalies.push({ cluster: name, kind: a.kind, severity: a.severity, message: a.message });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const result = {
|
|
432
|
+
action: 'overview',
|
|
433
|
+
totalClusters: clusters.length,
|
|
434
|
+
clusters,
|
|
435
|
+
};
|
|
436
|
+
if (anomalies.length > 0)
|
|
437
|
+
result.anomalies = anomalies;
|
|
438
|
+
if (note !== undefined)
|
|
439
|
+
result.note = note;
|
|
440
|
+
if (clusters.length === 0) {
|
|
441
|
+
result.note = (result.note ? result.note + '; ' : '')
|
|
442
|
+
+ 'inventory is empty — no k8s clusters registered in ops-access, or every scan has failed so far';
|
|
443
|
+
}
|
|
444
|
+
return result;
|
|
445
|
+
}
|
|
446
|
+
case 'show': {
|
|
447
|
+
if (!args.cluster)
|
|
448
|
+
return { action: 'show', error: 'show requires the cluster parameter' };
|
|
449
|
+
const note = await ensureFresh();
|
|
450
|
+
const inventory = await read(config.inventoryFile);
|
|
451
|
+
const section = inventory?.clusters[args.cluster];
|
|
452
|
+
if (!section) {
|
|
453
|
+
const known = Object.keys(inventory?.clusters ?? {}).sort();
|
|
454
|
+
return {
|
|
455
|
+
action: 'show',
|
|
456
|
+
error: `unknown cluster "${args.cluster}" in the inventory` + (known.length > 0 ? `. Known: ${known.join(', ')}` : ' — the inventory is empty'),
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
const result = {
|
|
460
|
+
action: 'show',
|
|
461
|
+
cluster: filterDetail(detailOf(args.cluster, section), { namespace: args.namespace, name: args.name }),
|
|
462
|
+
};
|
|
463
|
+
if (note !== undefined)
|
|
464
|
+
result.note = note;
|
|
465
|
+
return result;
|
|
466
|
+
}
|
|
467
|
+
case 'refresh': {
|
|
468
|
+
const opsAccess = getOpsAccess();
|
|
469
|
+
if (!opsAccess) {
|
|
470
|
+
return { action: 'refresh', error: 'ops-access service unavailable — is the ops-access plugin mounted in this preset?' };
|
|
471
|
+
}
|
|
472
|
+
return await doRefresh(opsAccess);
|
|
473
|
+
}
|
|
474
|
+
default:
|
|
475
|
+
return { action: args.action, error: `unknown action "${args.action}" — use overview, show, refresh, or help` };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
catch (err) {
|
|
479
|
+
// refreshInventory folds per-cluster failures into sections and
|
|
480
|
+
// collectTargets catches resolve errors, so reaching here means an
|
|
481
|
+
// unexpected bug — no credential path flows through this message.
|
|
482
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
483
|
+
return { action: args.action, error: message };
|
|
484
|
+
}
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
// ── Render ───────────────────────────────────────────────────────────────────
|
|
489
|
+
function renderMonitoring(m) {
|
|
490
|
+
if (m === undefined)
|
|
491
|
+
return '';
|
|
492
|
+
return m.down > 0 ? ` · prometheus: up ${m.up} [DOWN ${m.down}]` : ` · prometheus: up ${m.up}`;
|
|
493
|
+
}
|
|
494
|
+
function renderResult(value, args = {}) {
|
|
495
|
+
if (value.help !== undefined)
|
|
496
|
+
return value.help;
|
|
497
|
+
const lines = [];
|
|
498
|
+
if (value.error !== undefined)
|
|
499
|
+
lines.push(`[error] ${value.error}`);
|
|
500
|
+
if (value.note !== undefined)
|
|
501
|
+
lines.push(`[note] ${value.note}`);
|
|
502
|
+
if (value.clusters !== undefined) {
|
|
503
|
+
lines.push(`Environment inventory — ${value.totalClusters ?? 0} cluster(s):`);
|
|
504
|
+
for (const c of value.clusters) {
|
|
505
|
+
const types = c.byType.map(t => `${t.type}×${t.count}`).join(', ') || 'none';
|
|
506
|
+
const stale = c.stale ? ' [STALE]' : '';
|
|
507
|
+
const down = c.down > 0 ? ` · PROMETHEUS DOWN: ${c.down}` : '';
|
|
508
|
+
const anomalies = c.anomalies > 0 ? ` · ${c.anomalies} anomalies` : '';
|
|
509
|
+
const ceph = (c.cephPools ?? 0) > 0 ? ` · ceph pools: ${c.cephPools}` : '';
|
|
510
|
+
lines.push(`- ${c.name}: ${c.middleware} middleware (${types}), ${c.unknown} unknown, scanned ${c.scannedAt}${stale}${down}${anomalies}${ceph}`);
|
|
511
|
+
if (c.lastError !== undefined)
|
|
512
|
+
lines.push(` last error: ${c.lastError}`);
|
|
513
|
+
}
|
|
514
|
+
if (value.anomalies !== undefined && value.anomalies.length > 0) {
|
|
515
|
+
lines.push('Anomalies:');
|
|
516
|
+
for (const a of value.anomalies) {
|
|
517
|
+
lines.push(`- [${a.severity}] ${a.cluster}: ${a.message}`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (value.cluster !== undefined) {
|
|
522
|
+
const c = value.cluster;
|
|
523
|
+
// In-place annotation: workload-subject anomalies mark their workload
|
|
524
|
+
// line; Service-subject anomalies mark the middleware they front.
|
|
525
|
+
const workloadNotes = new Map();
|
|
526
|
+
const serviceNotes = new Map();
|
|
527
|
+
for (const a of c.anomalies) {
|
|
528
|
+
const map = a.ref.kind === 'Service' ? serviceNotes : workloadNotes;
|
|
529
|
+
const key = `${a.ref.namespace}/${a.ref.name}`;
|
|
530
|
+
map.set(key, [...(map.get(key) ?? []), a.message]);
|
|
531
|
+
}
|
|
532
|
+
const notesFor = (namespace, name, serviceEntries) => {
|
|
533
|
+
const notes = [...(workloadNotes.get(`${namespace}/${name}`) ?? [])];
|
|
534
|
+
for (const svc of serviceEntries)
|
|
535
|
+
notes.push(...(serviceNotes.get(`${namespace}/${svc}`) ?? []));
|
|
536
|
+
return notes.map(n => ` · [!] ${n}`).join('');
|
|
537
|
+
};
|
|
538
|
+
lines.push(`Cluster ${c.name} — scanned ${c.scannedAt}${c.stale ? ' [STALE]' : ''}`
|
|
539
|
+
+ ` (${c.counts.workloads} workloads, ${c.counts.services} services, ${c.counts.ingresses} ingresses)`
|
|
540
|
+
+ (c.prometheusService !== undefined ? ` · prometheus: ${c.prometheusService}` : ''));
|
|
541
|
+
const filterBits = [
|
|
542
|
+
args.namespace !== undefined ? `namespace=${args.namespace}` : undefined,
|
|
543
|
+
args.name !== undefined ? `name~=${args.name}` : undefined,
|
|
544
|
+
].filter(Boolean);
|
|
545
|
+
if (filterBits.length > 0)
|
|
546
|
+
lines.push(`filtered by ${filterBits.join(' AND ')} (lists below are the matching subset)`);
|
|
547
|
+
if (c.lastError !== undefined)
|
|
548
|
+
lines.push(`last error: ${c.lastError}`);
|
|
549
|
+
// rook-ceph hints: pool names are the ceph tool's -p arguments; the
|
|
550
|
+
// tools pod location (or its absence) saves a live discovery step.
|
|
551
|
+
if (c.ceph !== undefined) {
|
|
552
|
+
const pools = c.ceph.pools.map(p => p.name).join(', ');
|
|
553
|
+
const clusters = c.ceph.clusters.map(cl => `${cl.namespace}/${cl.name}`).join(', ');
|
|
554
|
+
lines.push('ceph: ' + (c.ceph.pools.length > 0 ? `pools ${pools}` : 'no CephBlockPool CRs found')
|
|
555
|
+
+ (clusters !== '' ? ` · cluster ${clusters}` : '')
|
|
556
|
+
+ (c.ceph.toolsPod !== undefined ? ` · tools pod ${c.ceph.toolsPod.namespace}/${c.ceph.toolsPod.name}` : ' · no rook-ceph-tools pod deployed'));
|
|
557
|
+
}
|
|
558
|
+
lines.push('Middleware:');
|
|
559
|
+
for (const m of c.middleware) {
|
|
560
|
+
lines.push(`- ${m.type} · ${m.namespace}/${m.workload} (${m.workloadKind}) · svc: ${m.serviceEntries.join(', ') || 'none'} · ${m.images.join(', ')}${renderMonitoring(m.monitoring)}${notesFor(m.namespace, m.workload, m.serviceEntries)}`);
|
|
561
|
+
}
|
|
562
|
+
if (c.middleware.length === 0)
|
|
563
|
+
lines.push('- (none recognized)');
|
|
564
|
+
lines.push('Unknown workloads:');
|
|
565
|
+
for (const u of c.unknown) {
|
|
566
|
+
lines.push(`- ${u.namespace}/${u.name} (${u.kind}) · ${u.images.join(', ')}${renderMonitoring(u.monitoring)}${notesFor(u.namespace, u.name, [])}`);
|
|
567
|
+
}
|
|
568
|
+
if (c.unknown.length === 0)
|
|
569
|
+
lines.push('- (none)');
|
|
570
|
+
lines.push('Relations:');
|
|
571
|
+
for (const e of c.edges) {
|
|
572
|
+
lines.push(`- [${e.kind}] ${e.from.namespace}/${e.from.name} → ${e.to.namespace}/${e.to.name}`
|
|
573
|
+
+ (e.targetType !== undefined ? ` (${e.targetType})` : '') + ` via ${e.via}`);
|
|
574
|
+
}
|
|
575
|
+
if (c.edges.length === 0)
|
|
576
|
+
lines.push('- (none)');
|
|
577
|
+
}
|
|
578
|
+
if (value.results !== undefined) {
|
|
579
|
+
lines.push(`Refresh finished at ${value.refreshedAt ?? '?'}:`);
|
|
580
|
+
for (const r of value.results) {
|
|
581
|
+
if (r.status === 'ok') {
|
|
582
|
+
lines.push(`- ${r.cluster}: ok (${r.middleware ?? 0} middleware, ${r.unknown ?? 0} unknown)`);
|
|
583
|
+
}
|
|
584
|
+
else if (r.status === 'stale') {
|
|
585
|
+
lines.push(`- ${r.cluster}: FAILED — kept previous data (stale)${r.error !== undefined ? ` · ${r.error}` : ''}`);
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
lines.push(`- ${r.cluster}: skipped${r.error !== undefined ? ` · ${r.error}` : ''}`);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return lines.join('\n');
|
|
593
|
+
}
|