@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.
@@ -0,0 +1,146 @@
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 { isMiddlewareType } from './classify.js';
23
+ /**
24
+ * Matches `<name>.<namespace>.svc` and `<name>.<namespace>.svc.cluster.local`
25
+ * inside arbitrary text (URLs, JDBC strings, bare hosts). Only the
26
+ * namespace-qualified forms — the short in-namespace form is too ambiguous.
27
+ */
28
+ const SVC_PATTERN = /\b([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\.([a-z0-9](?:[a-z0-9-]*[a-z0-9])?)\.svc(?:\.cluster\.local)?\b/gi;
29
+ function workloadRef(w) {
30
+ return { kind: w.kind, namespace: w.namespace, name: w.name };
31
+ }
32
+ function serviceRef(s) {
33
+ return { kind: 'Service', namespace: s.namespace, name: s.name };
34
+ }
35
+ function keyOf(ref) {
36
+ return `${ref.kind}/${ref.namespace}/${ref.name}`;
37
+ }
38
+ /** Extract distinct (name, namespace) Service addresses mentioned in a text value. */
39
+ export function findServiceAddresses(value) {
40
+ const found = new Map();
41
+ for (const match of value.matchAll(SVC_PATTERN)) {
42
+ const name = match[1].toLowerCase();
43
+ const namespace = match[2].toLowerCase();
44
+ found.set(`${namespace}/${name}`, { name, namespace });
45
+ }
46
+ return [...found.values()];
47
+ }
48
+ /** A Service fronts a workload when its selector is a subset of the pod template labels. */
49
+ function selectorMatches(selector, podLabels) {
50
+ return Object.entries(selector).every(([k, v]) => podLabels[k] === v);
51
+ }
52
+ /** Derive all relation edges for one scanned cluster. Never throws. */
53
+ export function buildRelations(input) {
54
+ try {
55
+ const { scan, classified } = input;
56
+ const edges = [];
57
+ const seen = new Set();
58
+ const push = (edge) => {
59
+ const id = `${edge.kind}|${keyOf(edge.from)}|${keyOf(edge.to)}|${edge.via}`;
60
+ if (!seen.has(id)) {
61
+ seen.add(id);
62
+ edges.push(edge);
63
+ }
64
+ };
65
+ const servicesByKey = new Map();
66
+ for (const svc of scan.services)
67
+ servicesByKey.set(keyOf(serviceRef(svc)), svc);
68
+ // Service -> workload ownership edges, and the reverse index for composition.
69
+ const frontsByService = new Map();
70
+ for (const svc of scan.services) {
71
+ if (!svc.selector)
72
+ continue;
73
+ for (const w of classified) {
74
+ if (w.namespace !== svc.namespace)
75
+ continue;
76
+ if (!selectorMatches(svc.selector, w.podLabels))
77
+ continue;
78
+ push({ kind: 'fronts', from: serviceRef(svc), to: workloadRef(w), via: 'selector' });
79
+ const list = frontsByService.get(keyOf(serviceRef(svc))) ?? [];
80
+ list.push(w);
81
+ frontsByService.set(keyOf(serviceRef(svc)), list);
82
+ }
83
+ }
84
+ const configMapsByKey = new Map(scan.configMaps.map(cm => [`${cm.namespace}/${cm.name}`, cm]));
85
+ for (const w of classified) {
86
+ const from = workloadRef(w);
87
+ // Plaintext env values.
88
+ for (const [key, value] of Object.entries(w.env)) {
89
+ for (const addr of findServiceAddresses(value)) {
90
+ const svc = servicesByKey.get(`Service/${addr.namespace}/${addr.name}`);
91
+ if (!svc)
92
+ continue;
93
+ push({ kind: 'uses-service', from, to: serviceRef(svc), via: `env:${key}` });
94
+ }
95
+ }
96
+ // Data values of ConfigMaps this workload references.
97
+ for (const cmName of w.configMapRefs) {
98
+ const cm = configMapsByKey.get(`${w.namespace}/${cmName}`);
99
+ if (!cm)
100
+ continue;
101
+ for (const [key, value] of Object.entries(cm.data)) {
102
+ for (const addr of findServiceAddresses(value)) {
103
+ const svc = servicesByKey.get(`Service/${addr.namespace}/${addr.name}`);
104
+ if (!svc)
105
+ continue;
106
+ push({ kind: 'uses-service', from, to: serviceRef(svc), via: `configmap:${cmName}:${key}` });
107
+ }
108
+ }
109
+ }
110
+ // Secret references — the name only, never a value.
111
+ for (const secretName of w.secretRefs) {
112
+ push({
113
+ kind: 'references-secret',
114
+ from,
115
+ to: { kind: 'Secret', namespace: w.namespace, name: secretName },
116
+ via: 'secretRef',
117
+ });
118
+ }
119
+ }
120
+ // Compose: workload uses a Service that fronts a middleware instance.
121
+ const middlewareTypes = new Map();
122
+ for (const w of classified) {
123
+ if (isMiddlewareType(w.type))
124
+ middlewareTypes.set(keyOf(workloadRef(w)), w.type);
125
+ }
126
+ for (const edge of edges.filter(e => e.kind === 'uses-service')) {
127
+ for (const instance of frontsByService.get(keyOf(edge.to)) ?? []) {
128
+ const type = middlewareTypes.get(keyOf(workloadRef(instance)));
129
+ if (!type)
130
+ continue;
131
+ push({
132
+ kind: 'uses-middleware',
133
+ from: edge.from,
134
+ to: workloadRef(instance),
135
+ via: edge.via,
136
+ targetType: type,
137
+ });
138
+ }
139
+ }
140
+ return edges;
141
+ }
142
+ catch {
143
+ // Best-effort by contract: a malformed scan must never break the inventory.
144
+ return [];
145
+ }
146
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Cluster scanner — the read side of the environment inventory.
3
+ *
4
+ * Runs `kubectl --kubeconfig <path> get ... -o json` for workloads
5
+ * (deploy/sts/ds), services, ingresses, configmaps, and secrets, and reduces
6
+ * the responses to a {@link ClusterScan} of plain data. No files are written
7
+ * here; classification and persistence live elsewhere.
8
+ *
9
+ * Security discipline (hard rules):
10
+ * - Secrets are read metadata-only: the kubectl call selects only
11
+ * namespace/name via jsonpath, so `data`/`stringData` never enters the
12
+ * process, let alone the inventory.
13
+ * - The kubeconfig path never appears in thrown errors — kubectl echoes it
14
+ * in stderr, so every error is scrubbed before it propagates.
15
+ * - Container env values: only literal `value` entries are captured;
16
+ * `valueFrom` contributes reference names (configMapKeyRef/secretKeyRef),
17
+ * never resolved values.
18
+ *
19
+ * @module @elinpf/dsh-ops-tool-environment
20
+ */
21
+ import type { SpawnFn } from './prometheus.js';
22
+ import type { ClusterScan } from './types.js';
23
+ /** Injectable command runner: argv (no shell) -> captured stdout. Throws on failure. */
24
+ export type ExecFn = (args: string[], opts: {
25
+ timeoutMs: number;
26
+ }) => Promise<{
27
+ stdout: string;
28
+ }>;
29
+ export declare const SCAN_TIMEOUT_MS = 30000;
30
+ /** A cluster scan failure. The message is always kubeconfig-path-scrubbed. */
31
+ export declare class ScanError extends Error {
32
+ /** The cluster profile id this failure belongs to. */
33
+ readonly cluster: string;
34
+ constructor(message: string,
35
+ /** The cluster profile id this failure belongs to. */
36
+ cluster: string);
37
+ }
38
+ /** Default runner: spawn kubectl with argv, capture stdout/stderr, 30s timeout. */
39
+ export declare const defaultExec: ExecFn;
40
+ export interface ScanClusterInput {
41
+ /** Cluster profile id — the only cluster identity that appears in output. */
42
+ cluster: string;
43
+ /** Real kubeconfig path. Used in argv only; never surfaced in output/errors. */
44
+ kubeconfigPath: string;
45
+ /** Injectable exec for tests; defaults to spawning kubectl. */
46
+ exec?: ExecFn;
47
+ /** Injectable port-forward spawn (tests); defaults to node:child_process. */
48
+ spawn?: SpawnFn;
49
+ /** Injectable fetch (tests); defaults to global fetch. */
50
+ fetchFn?: typeof fetch;
51
+ /** Scan timestamp; defaults to now (injectable for deterministic tests). */
52
+ now?: Date;
53
+ /** Per-kubectl-call timeout (ms); defaults to SCAN_TIMEOUT_MS. */
54
+ timeoutMs?: number;
55
+ /** Prometheus scrape timeout (ms); defaults to the scrape's own 15s. */
56
+ prometheusTimeoutMs?: number;
57
+ }
58
+ /** Replace every occurrence of the kubeconfig path with a display token. */
59
+ export declare function scrubKubeconfigPath(text: string, kubeconfigPath: string): string;
60
+ /**
61
+ * Scan one cluster. All resource reads run against the same kubeconfig; any
62
+ * failure (unreachable cluster, timeout, malformed response) rejects with a
63
+ * ScanError whose message never contains the kubeconfig path.
64
+ */
65
+ export declare function scanCluster(input: ScanClusterInput): Promise<ClusterScan>;
package/lib/scanner.js ADDED
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Cluster scanner — the read side of the environment inventory.
3
+ *
4
+ * Runs `kubectl --kubeconfig <path> get ... -o json` for workloads
5
+ * (deploy/sts/ds), services, ingresses, configmaps, and secrets, and reduces
6
+ * the responses to a {@link ClusterScan} of plain data. No files are written
7
+ * here; classification and persistence live elsewhere.
8
+ *
9
+ * Security discipline (hard rules):
10
+ * - Secrets are read metadata-only: the kubectl call selects only
11
+ * namespace/name via jsonpath, so `data`/`stringData` never enters the
12
+ * process, let alone the inventory.
13
+ * - The kubeconfig path never appears in thrown errors — kubectl echoes it
14
+ * in stderr, so every error is scrubbed before it propagates.
15
+ * - Container env values: only literal `value` entries are captured;
16
+ * `valueFrom` contributes reference names (configMapKeyRef/secretKeyRef),
17
+ * never resolved values.
18
+ *
19
+ * @module @elinpf/dsh-ops-tool-environment
20
+ */
21
+ import { spawn } from 'node:child_process';
22
+ import { scrapePrometheusTargets } from './prometheus.js';
23
+ export const SCAN_TIMEOUT_MS = 30_000;
24
+ /** A cluster scan failure. The message is always kubeconfig-path-scrubbed. */
25
+ export class ScanError extends Error {
26
+ cluster;
27
+ constructor(message,
28
+ /** The cluster profile id this failure belongs to. */
29
+ cluster) {
30
+ super(message);
31
+ this.cluster = cluster;
32
+ this.name = 'ScanError';
33
+ }
34
+ }
35
+ /** Default runner: spawn kubectl with argv, capture stdout/stderr, 30s timeout. */
36
+ export const defaultExec = (args, { timeoutMs }) => new Promise((resolve, reject) => {
37
+ const child = spawn(args[0], args.slice(1), { stdio: ['ignore', 'pipe', 'pipe'] });
38
+ const stdout = [];
39
+ const stderr = [];
40
+ const timer = setTimeout(() => {
41
+ child.kill('SIGKILL');
42
+ reject(new Error(`command timed out after ${timeoutMs}ms`));
43
+ }, timeoutMs);
44
+ child.stdout.on('data', (chunk) => stdout.push(chunk));
45
+ child.stderr.on('data', (chunk) => stderr.push(chunk));
46
+ child.on('error', (err) => {
47
+ clearTimeout(timer);
48
+ reject(err);
49
+ });
50
+ child.on('close', (code) => {
51
+ clearTimeout(timer);
52
+ if (code === 0) {
53
+ resolve({ stdout: Buffer.concat(stdout).toString('utf8') });
54
+ }
55
+ else {
56
+ reject(new Error(`kubectl exited with code ${code ?? 'null'}: ${Buffer.concat(stderr).toString('utf8').trim()}`));
57
+ }
58
+ });
59
+ });
60
+ /** Replace every occurrence of the kubeconfig path with a display token. */
61
+ export function scrubKubeconfigPath(text, kubeconfigPath) {
62
+ return text.split(kubeconfigPath).join('<kubeconfig>');
63
+ }
64
+ function parseList(json, what) {
65
+ try {
66
+ const doc = JSON.parse(json);
67
+ if (!doc || !Array.isArray(doc.items))
68
+ throw new Error('missing .items');
69
+ return doc;
70
+ }
71
+ catch (err) {
72
+ throw new Error(`cannot parse ${what} response: ${err.message}`);
73
+ }
74
+ }
75
+ function asLabels(value) {
76
+ if (!value || typeof value !== 'object')
77
+ return {};
78
+ const out = {};
79
+ for (const [k, v] of Object.entries(value)) {
80
+ if (typeof v === 'string')
81
+ out[k] = v;
82
+ }
83
+ return out;
84
+ }
85
+ function reduceWorkload(kind, item) {
86
+ const meta = item.metadata ?? {};
87
+ const podSpec = item.spec?.template?.spec ?? {};
88
+ const containers = [...(podSpec.containers ?? []), ...(podSpec.initContainers ?? [])];
89
+ const images = [];
90
+ const env = {};
91
+ const configMapRefs = new Set();
92
+ const secretRefs = new Set();
93
+ for (const container of containers) {
94
+ if (typeof container.image === 'string')
95
+ images.push(container.image);
96
+ for (const entry of container.env ?? []) {
97
+ // Literal values only — valueFrom entries contribute reference names below.
98
+ if (typeof entry?.name === 'string' && typeof entry?.value === 'string') {
99
+ env[entry.name] = entry.value;
100
+ }
101
+ const cmKeyRef = entry?.valueFrom?.configMapKeyRef;
102
+ if (typeof cmKeyRef?.name === 'string')
103
+ configMapRefs.add(cmKeyRef.name);
104
+ const secretKeyRef = entry?.valueFrom?.secretKeyRef;
105
+ if (typeof secretKeyRef?.name === 'string')
106
+ secretRefs.add(secretKeyRef.name);
107
+ }
108
+ for (const from of container.envFrom ?? []) {
109
+ if (typeof from?.configMapRef?.name === 'string')
110
+ configMapRefs.add(from.configMapRef.name);
111
+ // Secret reference: record the NAME only. Values are never touched.
112
+ if (typeof from?.secretRef?.name === 'string')
113
+ secretRefs.add(from.secretRef.name);
114
+ }
115
+ }
116
+ return {
117
+ kind,
118
+ namespace: meta.namespace ?? 'default',
119
+ name: meta.name ?? '',
120
+ images,
121
+ labels: asLabels(meta.labels),
122
+ podLabels: asLabels(item.spec?.template?.metadata?.labels),
123
+ env,
124
+ configMapRefs: [...configMapRefs].sort(),
125
+ secretRefs: [...secretRefs].sort(),
126
+ };
127
+ }
128
+ function reduceService(item) {
129
+ const meta = item.metadata ?? {};
130
+ const spec = item.spec ?? {};
131
+ const ports = (spec.ports ?? []).map((p) => {
132
+ const port = { port: p?.port };
133
+ if (p?.targetPort !== undefined)
134
+ port.targetPort = p.targetPort;
135
+ if (typeof p?.name === 'string')
136
+ port.name = p.name;
137
+ return port;
138
+ }).filter((p) => typeof p.port === 'number');
139
+ const selector = spec.selector && Object.keys(spec.selector).length > 0 ? asLabels(spec.selector) : null;
140
+ const svc = {
141
+ namespace: meta.namespace ?? 'default',
142
+ name: meta.name ?? '',
143
+ type: spec.type ?? 'ClusterIP',
144
+ ports,
145
+ selector,
146
+ labels: asLabels(meta.labels),
147
+ };
148
+ if (typeof spec.clusterIP === 'string' && spec.clusterIP !== 'None')
149
+ svc.clusterIP = spec.clusterIP;
150
+ if (typeof spec.externalName === 'string')
151
+ svc.externalName = spec.externalName;
152
+ return svc;
153
+ }
154
+ function reduceIngress(item) {
155
+ const meta = item.metadata ?? {};
156
+ const hosts = new Set();
157
+ const serviceBackends = [];
158
+ for (const rule of item.spec?.rules ?? []) {
159
+ if (typeof rule?.host === 'string')
160
+ hosts.add(rule.host);
161
+ for (const path of rule?.http?.paths ?? []) {
162
+ const svc = path?.backend?.service;
163
+ if (typeof svc?.name !== 'string')
164
+ continue;
165
+ const backend = { serviceName: svc.name };
166
+ const port = svc.port?.number ?? svc.port?.name;
167
+ if (port !== undefined)
168
+ backend.servicePort = port;
169
+ serviceBackends.push(backend);
170
+ }
171
+ }
172
+ return {
173
+ namespace: meta.namespace ?? 'default',
174
+ name: meta.name ?? '',
175
+ hosts: [...hosts].sort(),
176
+ serviceBackends,
177
+ };
178
+ }
179
+ function reduceConfigMap(item) {
180
+ const meta = item.metadata ?? {};
181
+ const data = {};
182
+ for (const [k, v] of Object.entries(item.data ?? {})) {
183
+ if (typeof v === 'string')
184
+ data[k] = v;
185
+ }
186
+ return { namespace: meta.namespace ?? 'default', name: meta.name ?? '', data };
187
+ }
188
+ /** Endpoints reduced to ready-address count (subsets[].addresses; notReady excluded). */
189
+ function reduceEndpoints(item) {
190
+ const meta = item.metadata ?? {};
191
+ let addresses = 0;
192
+ for (const subset of item.subsets ?? []) {
193
+ addresses += (subset?.addresses ?? []).length;
194
+ }
195
+ return { namespace: meta.namespace ?? 'default', name: meta.name ?? '', addresses };
196
+ }
197
+ /**
198
+ * Metadata-only Secret list. The scanner asks kubectl for a jsonpath of
199
+ * `namespace<TAB>name` per item so Secret data never crosses into this
200
+ * process. Malformed lines are skipped (best-effort, never fatal).
201
+ */
202
+ function parseSecretNames(output) {
203
+ const secrets = [];
204
+ for (const line of output.split('\n')) {
205
+ const trimmed = line.trim();
206
+ if (!trimmed)
207
+ continue;
208
+ const tab = trimmed.indexOf('\t');
209
+ if (tab <= 0)
210
+ continue;
211
+ secrets.push({ namespace: trimmed.slice(0, tab), name: trimmed.slice(tab + 1) });
212
+ }
213
+ return secrets;
214
+ }
215
+ const SECRETS_JSONPATH = '{range .items[*]}{.metadata.namespace}{\'\\t\'}{.metadata.name}{\'\\n\'}{end}';
216
+ /**
217
+ * Scan one cluster. All resource reads run against the same kubeconfig; any
218
+ * failure (unreachable cluster, timeout, malformed response) rejects with a
219
+ * ScanError whose message never contains the kubeconfig path.
220
+ */
221
+ export async function scanCluster(input) {
222
+ const exec = input.exec ?? defaultExec;
223
+ const base = ['kubectl', `--kubeconfig=${input.kubeconfigPath}`];
224
+ const run = async (args) => {
225
+ try {
226
+ return await exec([...base, ...args], { timeoutMs: input.timeoutMs ?? SCAN_TIMEOUT_MS });
227
+ }
228
+ catch (err) {
229
+ const message = err instanceof Error ? err.message : String(err);
230
+ throw new ScanError(scrubKubeconfigPath(message, input.kubeconfigPath), input.cluster);
231
+ }
232
+ };
233
+ const parseOrThrow = (json, what) => {
234
+ try {
235
+ return parseList(json, what);
236
+ }
237
+ catch (err) {
238
+ throw new ScanError(err.message, input.cluster);
239
+ }
240
+ };
241
+ const [workloads, services, ingresses, configMaps] = await Promise.all([
242
+ run(['get', 'deployments,statefulsets,daemonsets', '--all-namespaces', '-o', 'json']),
243
+ run(['get', 'services', '--all-namespaces', '-o', 'json']),
244
+ run(['get', 'ingresses', '--all-namespaces', '-o', 'json']),
245
+ run(['get', 'configmaps', '--all-namespaces', '-o', 'json']),
246
+ ]);
247
+ // Secrets are metadata-only reference names — nice to have, not worth
248
+ // failing a cluster over: the k8s `view` ClusterRole does NOT include
249
+ // secrets, so a strict ro account gets Forbidden here. The workload
250
+ // secretRefs (from pod specs) already carry the reference names.
251
+ const secrets = await run(['get', 'secrets', '--all-namespaces', '-o', `jsonpath=${SECRETS_JSONPATH}`])
252
+ .catch(() => ({ stdout: '' }));
253
+ // Endpoints feed the backend-less-Service anomaly detector. Same degraded
254
+ // discipline: a failed read yields undefined and the detector skips —
255
+ // never a guess, never a scan failure.
256
+ const endpointsRaw = await run(['get', 'endpoints', '--all-namespaces', '-o', 'json'])
257
+ .catch(() => undefined);
258
+ // rook-ceph footprint (ticket 15): CephBlockPool/CephCluster CRs and the
259
+ // rook-ceph-tools pod location. Same degraded discipline as endpoints —
260
+ // CRDs absent or the ro account unable to list them (built-in `view`
261
+ // covers no ceph.rook.io resources) yields no hints, never a scan failure.
262
+ const cephCrsRaw = await run(['get', 'cephclusters.ceph.rook.io,cephblockpools.ceph.rook.io', '--all-namespaces', '-o', 'json'])
263
+ .catch(() => undefined);
264
+ const cephToolsRaw = await run(['get', 'pods', '--all-namespaces', '-l', 'app=rook-ceph-tools', '-o', `jsonpath=${SECRETS_JSONPATH}`])
265
+ .catch(() => undefined);
266
+ const workloadItems = [];
267
+ // A combined `get a,b,c -o json` returns a single List whose items mix kinds.
268
+ for (const item of parseOrThrow(workloads.stdout, 'workloads').items ?? []) {
269
+ const kind = item?.kind;
270
+ if (kind === 'Deployment' || kind === 'StatefulSet' || kind === 'DaemonSet') {
271
+ workloadItems.push(reduceWorkload(kind, item));
272
+ }
273
+ }
274
+ const serviceItems = (parseOrThrow(services.stdout, 'services').items ?? []).map(reduceService);
275
+ // Prometheus corroboration: best-effort enhancement. Any failure (no
276
+ // Prometheus service, port-forward timeout, unreachable API) yields
277
+ // undefined — the main scan result is unaffected and the cluster is NOT
278
+ // marked stale for this.
279
+ let prometheus;
280
+ try {
281
+ prometheus = await scrapePrometheusTargets({
282
+ kubeconfigPath: input.kubeconfigPath,
283
+ services: serviceItems,
284
+ spawn: input.spawn,
285
+ fetchFn: input.fetchFn,
286
+ ...(input.prometheusTimeoutMs !== undefined ? { timeoutMs: input.prometheusTimeoutMs } : {}),
287
+ });
288
+ }
289
+ catch {
290
+ prometheus = undefined;
291
+ }
292
+ const scan = {
293
+ cluster: input.cluster,
294
+ scannedAt: (input.now ?? new Date()).toISOString(),
295
+ workloads: workloadItems,
296
+ services: serviceItems,
297
+ ingresses: (parseOrThrow(ingresses.stdout, 'ingresses').items ?? []).map(reduceIngress),
298
+ configMaps: (parseOrThrow(configMaps.stdout, 'configmaps').items ?? []).map(reduceConfigMap),
299
+ secrets: parseSecretNames(secrets.stdout),
300
+ };
301
+ if (endpointsRaw !== undefined) {
302
+ scan.endpoints = (parseOrThrow(endpointsRaw.stdout, 'endpoints').items ?? []).map(reduceEndpoints);
303
+ }
304
+ // Fold the ceph hints: emit only when something was actually found.
305
+ let ceph;
306
+ if (cephCrsRaw !== undefined) {
307
+ const pools = [];
308
+ const clusters = [];
309
+ for (const item of parseOrThrow(cephCrsRaw.stdout, 'ceph CRs').items ?? []) {
310
+ const ns = item?.metadata?.namespace;
311
+ const nm = item?.metadata?.name;
312
+ if (typeof ns !== 'string' || typeof nm !== 'string')
313
+ continue;
314
+ if (item.kind === 'CephBlockPool')
315
+ pools.push({ namespace: ns, name: nm });
316
+ else if (item.kind === 'CephCluster')
317
+ clusters.push({ namespace: ns, name: nm });
318
+ }
319
+ if (pools.length + clusters.length > 0)
320
+ ceph = { pools, clusters };
321
+ }
322
+ // The tools-pod read reuses the namespace<TAB>name reduction (metadata only).
323
+ const toolsPods = cephToolsRaw !== undefined ? parseSecretNames(cephToolsRaw.stdout) : [];
324
+ if (toolsPods.length > 0) {
325
+ ceph = ceph ?? { pools: [], clusters: [] };
326
+ ceph.toolsPod = toolsPods[0];
327
+ }
328
+ if (ceph !== undefined)
329
+ scan.ceph = ceph;
330
+ if (prometheus !== undefined)
331
+ scan.prometheus = prometheus;
332
+ return scan;
333
+ }