@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,6 @@
1
+ ---
2
+ title: "@elinpf/dsh-ops-tool-environment"
3
+ description:
4
+ zh: "环境清单 — 只读扫描注册的 k8s 集群落盘 environment.yaml,附 environment 工具(overview/show/refresh)"
5
+ en: "Environment inventory — read-only scan of registered k8s clusters into environment.yaml, plus the environment tool (overview/show/refresh)"
6
+ ---
package/README.md ADDED
@@ -0,0 +1,50 @@
1
+ # @elinpf/dsh-ops-tool-environment
2
+
3
+ Environment inventory for DeepSeek Harness ops mode — a read-only, fully deterministic scanner that maps the k8s clusters registered in `ops-access` into `~/.dsh-ops/environment.yaml`, plus the model-facing `environment` tool the agent reads it through (spec `docs/specs/0004-environment-inventory.md`).
4
+
5
+ ## The `environment` tool
6
+
7
+ Four actions: `overview` (all clusters, compact: middleware counts by type, unknown count, stale flag, scan time), `show` (one cluster: middleware instances, unknown bucket, relation edges), `refresh` (re-scan every registered k8s cluster now), `help` (full usage — progressive disclosure; the system prompt carries only a one-line pointer).
8
+
9
+ - **Freshness** — reads re-scan when the inventory is missing or its oldest section is past the TTL (`ttlMinutes`, default 60); nothing scans at session start
10
+ - **Read-only credentials** — refresh resolves k8s profiles without an agent identity, so the access gate's broker falls back to the ro tier
11
+ - **Two mount rows** — realm topology splits the plugin: the tool entry (`.`) sits in the `ops-access-registry` group (needs `opsAccess`), the `./prompt` subpath plugin registers the one-line methodology section through `ops-prompts` and sits in the `ops-orchestration` group. Entry-local isolate realms are invisible across groups, so one row cannot see both services.
12
+
13
+ ## What it does
14
+
15
+ For each registered k8s cluster, the scanner pulls workloads (deploy/sts/ds), Services, Ingresses, ConfigMaps, and Secret **metadata** from the k8s API via `kubectl`, classifies workloads against a middleware table, derives best-effort relation edges, and persists one section per cluster with a scan timestamp.
16
+
17
+ - **Deterministic** — same cluster, same bytes; zero LLM involvement
18
+ - **Freshness** — each section carries `scannedAt`; a failed refresh keeps the old section and marks it `stale: true`
19
+ - **Unknown bucket** — unrecognized workloads stay listed with name and image
20
+ - **Prometheus corroboration** — when a cluster has a discoverable Prometheus service (name contains `prometheus`, port 9090, `monitoring` namespace preferred), the scanner reads `/api/v1/targets` over a short-lived `kubectl port-forward` and attaches `monitoring: { up, down }` per workload. Any failure here degrades silently — the cluster section is still written and never marked stale for it
21
+ - **Anomaly detection** — two generic-semantics detectors per section: `cross-namespace-ref` (a workload references a Service in another namespace) and `service-no-backend` (a Service has a selector but zero ready Endpoints addresses — Endpoints being k8s' authoritative backend answer). overview lists them in one section; show annotates entries in place
22
+ - **User rules** — `~/.dsh-ops/environment-rules.yaml` appends/overrides classification rules
23
+
24
+ ## Security discipline
25
+
26
+ - Secrets are fetched **metadata-only** (jsonpath of namespace/name) — `data` never enters the process
27
+ - The kubeconfig path is scrubbed from every error before it can reach the inventory, logs, or the model
28
+ - Only literal container env values are read; `valueFrom` contributes reference names only
29
+
30
+ ## Module map
31
+
32
+ | Module | Role |
33
+ |---|---|
34
+ | `src/scanner.ts` | kubectl reads → `ClusterScan` (pure data, injectable exec, 30s timeout) |
35
+ | `src/classify.ts` | image/chart/label → middleware type; built-in table + user rules file |
36
+ | `src/relations.ts` | best-effort edges: `uses-service`, `fronts`, `uses-middleware`, `references-secret` |
37
+ | `src/anomalies.ts` | rule-based anomaly detectors: `cross-namespace-ref`, `service-no-backend` (endpoints-based) |
38
+ | `src/prometheus.ts` | Prometheus corroboration: service discovery, `kubectl port-forward` lifecycle (always reaped), targets parsing, workload matching |
39
+ | `src/inventory.ts` | `environment.yaml` persistence, read API, refresh + stale logic |
40
+ | `src/tool.ts` | the `environment` tool factory (actions, TTL gate, render); `createEnvironmentTool` takes injectable deps for tests |
41
+ | `src/doctrine.ts` | single source of the tool description, one-line prompt, and help text |
42
+ | `src/prompt.ts` | `./prompt` subpath plugin: registers the methodology line via `ops-prompts` |
43
+ | `src/index.ts` | tool plugin entry (name/inject/Config/apply) + scanner-core re-exports |
44
+
45
+ ## Development
46
+
47
+ ```sh
48
+ npm run build # tsc → lib/
49
+ npm test # vitest
50
+ ```
package/README.zh.md ADDED
@@ -0,0 +1,50 @@
1
+ # @elinpf/dsh-ops-tool-environment
2
+
3
+ DeepSeek Harness 运维模式的环境清单 —— 只读、纯确定性的盘点器:遍历 `ops-access` 注册的 k8s 集群,盘出环境地图落盘为 `~/.dsh-ops/environment.yaml`;agent 经模型工具 `environment` 消费(spec 见 `docs/specs/0004-environment-inventory.md`)。
4
+
5
+ ## environment 工具
6
+
7
+ 四个动作:`overview`(各集群摘要:中间件分类计数、unknown 数、stale 标记、盘点时间)、`show`(单集群详情:中间件实例、unknown 桶、关联边)、`refresh`(立即重扫全部注册的 k8s 集群)、`help`(完整用法——渐进披露,系统提示词只放一句引导)。
8
+
9
+ - **新鲜度** —— 清单缺失或最老段超 TTL(`ttlMinutes`,默认 60 分钟)时读操作先自动重扫;会话启动绝不扫描
10
+ - **只读凭证** —— refresh 解析 k8s 档案不带 agent 身份,审计门 broker 无 agent 时回落 ro 档
11
+ - **两行挂载** —— realm 拓扑拆分:工具入口(`.`)在 `ops-access-registry` 组(要 `opsAccess`),`./prompt` 子路径插件经 `ops-prompts` 注册那句提示词、挂在 `ops-orchestration` 组。entry-local isolate realm 跨组不可见,一行拿不到两个服务
12
+
13
+ ## 做什么
14
+
15
+ 对每个注册的 k8s 集群,经 `kubectl` 拉取工作负载(deploy/sts/ds)、Service、Ingress、ConfigMap 和 Secret **元数据**,按分类表识别中间件实例,尽力而为连关联边,按集群分段落盘并带盘点时间戳。
16
+
17
+ - **确定性** —— 同一集群扫两次字节一致,零 LLM
18
+ - **新鲜度** —— 每段带 `scannedAt`;刷新失败保留旧段并标 `stale: true`
19
+ - **unknown 桶** —— 识别不出的工作负载照常列出名称与镜像
20
+ - **Prometheus 印证** —— 集群里有可发现的 Prometheus service(名字含 `prometheus`、带 9090 端口、`monitoring` 命名空间优先)时,经短生命周期 `kubectl port-forward` 读 `/api/v1/targets`,给工作负载附 `monitoring: { up, down }`。此增强任何失败都静默降级——清单段照常写入,不标 stale
21
+ - **异常标注** —— 每段跑两条通用语义检测器:`cross-namespace-ref`(工作负载引用别的命名空间的 Service)和 `service-no-backend`(Service 有 selector 但 Endpoints 零就绪地址——endpoints 是 k8s 自己的权威答案)。overview 单列一节,show 就地标注
22
+ - **用户规则** —— `~/.dsh-ops/environment-rules.yaml` 可追加/覆盖分类规则
23
+
24
+ ## 安全纪律
25
+
26
+ - Secret 只取 metadata(jsonpath 只选 namespace/name),`data` 不进进程
27
+ - kubeconfig 路径在进入清单/日志/模型上下文前一律擦除为 `<kubeconfig>`
28
+ - 容器 env 只读明文字面值;`valueFrom` 只记引用名
29
+
30
+ ## 模块划分
31
+
32
+ | 模块 | 职责 |
33
+ |---|---|
34
+ | `src/scanner.ts` | kubectl 读取 → `ClusterScan`(纯数据,exec 可注入,30s 超时) |
35
+ | `src/classify.ts` | 镜像/chart/label → 中间件类型;内置表 + 用户规则文件 |
36
+ | `src/relations.ts` | 关联边:`uses-service`、`fronts`、`uses-middleware`、`references-secret` |
37
+ | `src/anomalies.ts` | 异常检测器:`cross-namespace-ref`、`service-no-backend`(基于 endpoints) |
38
+ | `src/prometheus.ts` | Prometheus 印证:service 发现、`kubectl port-forward` 生命周期(必然回收)、targets 解析、工作负载匹配 |
39
+ | `src/inventory.ts` | `environment.yaml` 落盘、读接口、refresh + stale 逻辑 |
40
+ | `src/tool.ts` | `environment` 工具工厂(动作、TTL 门、render);`createEnvironmentTool` 依赖可注入便于测试 |
41
+ | `src/doctrine.ts` | 工具描述、一句提示词、help 全文的唯一事实源 |
42
+ | `src/prompt.ts` | `./prompt` 子路径插件:经 `ops-prompts` 注册提示词 |
43
+ | `src/index.ts` | 工具插件入口(name/inject/Config/apply)+ 盘点器核心 re-export |
44
+
45
+ ## 开发
46
+
47
+ ```sh
48
+ npm run build # tsc → lib/
49
+ npm test # vitest
50
+ ```
@@ -0,0 +1 @@
1
+ []
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Anomaly detection over a scanned cluster (spec 0003 follow-up).
3
+ *
4
+ * Two deterministic detectors, both built on generic k8s semantics only —
5
+ * namespace string comparison and selector/endpoints agreement. Nothing
6
+ * here knows any environment's names; the same rules must flag (or stay
7
+ * silent on) any cluster shape.
8
+ *
9
+ * - `cross-namespace-ref` (info): a uses-service/uses-middleware edge whose
10
+ * Service lives in a different namespace than the referencing workload.
11
+ * Often legitimate (shared infrastructure), but a prime suspect when an
12
+ * environment behaves like another one — the classic "e2e pointing at the
13
+ * wrong namespace's database".
14
+ * - `service-no-backend` (warning): a Service with a pod selector whose
15
+ * Endpoints object has ZERO ready addresses (or no Endpoints at all).
16
+ * Endpoints are k8s' own authoritative answer to "does this Service have
17
+ * backends" — preferred over inferring from fronts edges, which cannot
18
+ * distinguish "selector matches nothing" from "pods exist but are not
19
+ * ready / not in the scanned set".
20
+ *
21
+ * @module @elinpf/dsh-ops-tool-environment
22
+ */
23
+ import type { Anomaly, RelationEdge, ScannedEndpoints, ScannedService } from './types.js';
24
+ export interface DetectAnomaliesInput {
25
+ edges: RelationEdge[];
26
+ services: ScannedService[];
27
+ /** Undefined when the endpoints read failed — the no-backend detector then skips. */
28
+ endpoints?: ScannedEndpoints[];
29
+ }
30
+ /** All detected anomalies, deterministically ordered. Never throws. */
31
+ export declare function detectAnomalies(input: DetectAnomaliesInput): Anomaly[];
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Anomaly detection over a scanned cluster (spec 0003 follow-up).
3
+ *
4
+ * Two deterministic detectors, both built on generic k8s semantics only —
5
+ * namespace string comparison and selector/endpoints agreement. Nothing
6
+ * here knows any environment's names; the same rules must flag (or stay
7
+ * silent on) any cluster shape.
8
+ *
9
+ * - `cross-namespace-ref` (info): a uses-service/uses-middleware edge whose
10
+ * Service lives in a different namespace than the referencing workload.
11
+ * Often legitimate (shared infrastructure), but a prime suspect when an
12
+ * environment behaves like another one — the classic "e2e pointing at the
13
+ * wrong namespace's database".
14
+ * - `service-no-backend` (warning): a Service with a pod selector whose
15
+ * Endpoints object has ZERO ready addresses (or no Endpoints at all).
16
+ * Endpoints are k8s' own authoritative answer to "does this Service have
17
+ * backends" — preferred over inferring from fronts edges, which cannot
18
+ * distinguish "selector matches nothing" from "pods exist but are not
19
+ * ready / not in the scanned set".
20
+ *
21
+ * @module @elinpf/dsh-ops-tool-environment
22
+ */
23
+ function ref(kind, namespace, name) {
24
+ return { kind, namespace, name };
25
+ }
26
+ /** All detected anomalies, deterministically ordered. Never throws. */
27
+ export function detectAnomalies(input) {
28
+ try {
29
+ const anomalies = [];
30
+ // ── Cross-namespace references ──────────────────────────────────────────
31
+ const seenCross = new Set();
32
+ for (const edge of input.edges) {
33
+ if (edge.kind !== 'uses-service' && edge.kind !== 'uses-middleware')
34
+ continue;
35
+ if (edge.from.namespace === edge.to.namespace)
36
+ continue;
37
+ const id = `${edge.from.namespace}/${edge.from.name}→${edge.to.namespace}/${edge.to.name}`;
38
+ if (seenCross.has(id))
39
+ continue;
40
+ seenCross.add(id);
41
+ anomalies.push({
42
+ kind: 'cross-namespace-ref',
43
+ severity: 'info',
44
+ ref: ref(edge.from.kind, edge.from.namespace, edge.from.name),
45
+ related: ref('Service', edge.to.namespace, edge.to.name),
46
+ message: `${edge.from.namespace}/${edge.from.name} references Service ${edge.to.namespace}/${edge.to.name} across namespaces`,
47
+ });
48
+ }
49
+ // ── Services with a selector but no ready backends ─────────────────────
50
+ // Only runs on real endpoints data; a failed read must not fabricate
51
+ // anomalies.
52
+ if (input.endpoints !== undefined) {
53
+ const endpointsByKey = new Map(input.endpoints.map(e => [`${e.namespace}/${e.name}`, e]));
54
+ for (const svc of input.services) {
55
+ if (svc.selector === null)
56
+ continue; // selector-less (ExternalName/manual) has no backend contract
57
+ const ep = endpointsByKey.get(`${svc.namespace}/${svc.name}`);
58
+ if (ep !== undefined && ep.addresses > 0)
59
+ continue;
60
+ anomalies.push({
61
+ kind: 'service-no-backend',
62
+ severity: 'warning',
63
+ ref: ref('Service', svc.namespace, svc.name),
64
+ message: `Service ${svc.namespace}/${svc.name} has a selector but no ready endpoints (no backend pods)`,
65
+ });
66
+ }
67
+ }
68
+ anomalies.sort((a, b) => a.kind.localeCompare(b.kind)
69
+ || a.ref.namespace.localeCompare(b.ref.namespace)
70
+ || a.ref.name.localeCompare(b.ref.name));
71
+ return anomalies;
72
+ }
73
+ catch {
74
+ // Best-effort by contract: detection must never break the inventory.
75
+ return [];
76
+ }
77
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Middleware classification table.
3
+ *
4
+ * A workload is classified from three signal sources — image names, chart
5
+ * names, and labels — matched against an ordered rule list. First match wins.
6
+ * Built-in rules cover the common middleware of the ops environment; a user
7
+ * rules file (default `~/.dsh-ops/environment-rules.yaml`) can append or
8
+ * override them: user rules are consulted before built-in ones.
9
+ *
10
+ * Types are plain strings: a middleware name ('redis', 'mysql', ...),
11
+ * 'infra' for cluster plumbing (cert-manager, coredns, chaos-mesh, ...),
12
+ * or 'unknown' when nothing matches. 'unknown' is not an error — those
13
+ * workloads form the unknown bucket and stay visible.
14
+ *
15
+ * User rules file format:
16
+ *
17
+ * rules:
18
+ * - pattern: 'my-internal-mq' # case-insensitive regex
19
+ * type: mqtt
20
+ *
21
+ * @module @elinpf/dsh-ops-tool-environment
22
+ */
23
+ import type { ScannedWorkload } from './types.js';
24
+ /** One classification rule: a case-insensitive regex mapped to a type. */
25
+ export interface ClassificationRule {
26
+ /** Regex source, matched case-insensitively against image/chart/label signals. */
27
+ pattern: string;
28
+ /** Type assigned on match ('redis', 'infra', a company-specific name, ...). */
29
+ type: string;
30
+ }
31
+ /** Signals a workload is classified on. */
32
+ export interface ClassifyInput {
33
+ images: string[];
34
+ labels: Record<string, string>;
35
+ /** Workload name — a weak signal, only used by rules that want it. */
36
+ name?: string;
37
+ }
38
+ export declare const DEFAULT_USER_RULES_FILE = "~/.dsh-ops/environment-rules.yaml";
39
+ /**
40
+ * Built-in table. Ordered: more specific patterns before generic ones so
41
+ * e.g. `redis-exporter` classifies as prometheus plumbing before `redis`
42
+ * would claim it. Infra components map to the 'infra' type, not a middleware.
43
+ */
44
+ export declare const builtinRules: ClassificationRule[];
45
+ export declare function expandHome(p: string): string;
46
+ /**
47
+ * Load user classification rules. Missing or malformed file is not an
48
+ * error — the built-in table alone still works — so this always resolves
49
+ * to a (possibly empty) rule list. Entries without a string pattern/type
50
+ * are skipped.
51
+ */
52
+ export declare function loadUserRules(file?: string): ClassificationRule[];
53
+ /** Classify one signal set against an ordered rule list; first match wins. */
54
+ export declare function classifySignals(input: ClassifyInput, rules: ClassificationRule[]): string;
55
+ /**
56
+ * Classify a scanned workload. Loads built-in rules, then overlays the user
57
+ * rules file (user rules win ties — they are consulted first).
58
+ */
59
+ export declare function classifyWorkload(workload: Pick<ScannedWorkload, 'images' | 'labels' | 'name'>, opts?: {
60
+ userRulesFile?: string;
61
+ extraRules?: ClassificationRule[];
62
+ }): string;
63
+ /** True for the middleware types worth surfacing as instances (not infra, not unknown). */
64
+ export declare function isMiddlewareType(type: string): boolean;
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Middleware classification table.
3
+ *
4
+ * A workload is classified from three signal sources — image names, chart
5
+ * names, and labels — matched against an ordered rule list. First match wins.
6
+ * Built-in rules cover the common middleware of the ops environment; a user
7
+ * rules file (default `~/.dsh-ops/environment-rules.yaml`) can append or
8
+ * override them: user rules are consulted before built-in ones.
9
+ *
10
+ * Types are plain strings: a middleware name ('redis', 'mysql', ...),
11
+ * 'infra' for cluster plumbing (cert-manager, coredns, chaos-mesh, ...),
12
+ * or 'unknown' when nothing matches. 'unknown' is not an error — those
13
+ * workloads form the unknown bucket and stay visible.
14
+ *
15
+ * User rules file format:
16
+ *
17
+ * rules:
18
+ * - pattern: 'my-internal-mq' # case-insensitive regex
19
+ * type: mqtt
20
+ *
21
+ * @module @elinpf/dsh-ops-tool-environment
22
+ */
23
+ import { readFileSync } from 'node:fs';
24
+ import os from 'node:os';
25
+ import { parse as parseYaml } from 'yaml';
26
+ export const DEFAULT_USER_RULES_FILE = '~/.dsh-ops/environment-rules.yaml';
27
+ /**
28
+ * Built-in table. Ordered: more specific patterns before generic ones so
29
+ * e.g. `redis-exporter` classifies as prometheus plumbing before `redis`
30
+ * would claim it. Infra components map to the 'infra' type, not a middleware.
31
+ */
32
+ export const builtinRules = [
33
+ // ── exporters / sidecars before their middleware ────────────────────────
34
+ { pattern: 'redis[-_.]exporter', type: 'infra' },
35
+ { pattern: 'mysql[-_.]exporter', type: 'infra' },
36
+ { pattern: 'elasticsearch[-_.]exporter', type: 'infra' },
37
+ { pattern: 'kafka[-_.]exporter', type: 'infra' },
38
+ { pattern: 'mongodb[-_.]exporter', type: 'infra' },
39
+ { pattern: 'postgres[-_.]exporter', type: 'infra' },
40
+ { pattern: 'mysqld[-_.]exporter', type: 'infra' },
41
+ // ── middleware (spec 0003 list) ─────────────────────────────────────────
42
+ { pattern: 'nacos', type: 'nacos' },
43
+ { pattern: 'sentinel', type: 'sentinel' },
44
+ { pattern: 'seata', type: 'seata' },
45
+ { pattern: '\\bredis\\b', type: 'redis' },
46
+ { pattern: 'elasticsearch', type: 'elasticsearch' },
47
+ { pattern: '\\bkafka\\b', type: 'kafka' },
48
+ { pattern: '\\bmysql\\b', type: 'mysql' },
49
+ { pattern: '\\bmariadb\\b', type: 'mysql' },
50
+ { pattern: 'clickhouse', type: 'clickhouse' },
51
+ { pattern: '\\bminio\\b', type: 'minio' },
52
+ { pattern: '\\bmilvus\\b', type: 'milvus' },
53
+ { pattern: '\\bemqx\\b', type: 'mqtt' },
54
+ { pattern: 'mosquitto', type: 'mqtt' },
55
+ { pattern: 'hivemq', type: 'mqtt' },
56
+ { pattern: 'vernemq', type: 'mqtt' },
57
+ { pattern: '\\bmongo\\b', type: 'mongodb' },
58
+ { pattern: 'mongodb', type: 'mongodb' },
59
+ { pattern: '\\bpostgres\\b', type: 'postgres' },
60
+ { pattern: 'postgresql', type: 'postgres' },
61
+ // ── monitoring-suite members before the generic prometheus rule ─────────
62
+ { pattern: 'node[-_.]exporter', type: 'infra' },
63
+ { pattern: 'kube-state-metrics', type: 'infra' },
64
+ { pattern: 'prometheus-config-reloader', type: 'infra' },
65
+ { pattern: 'prometheus-operator', type: 'infra' },
66
+ { pattern: '\\bgrafana\\b', type: 'grafana' },
67
+ { pattern: '\\balertmanager\\b', type: 'alertmanager' },
68
+ { pattern: 'prometheus', type: 'prometheus' },
69
+ // common companions of the listed middleware
70
+ { pattern: '\\bzookeeper\\b', type: 'zookeeper' },
71
+ { pattern: '\\brabbitmq\\b', type: 'rabbitmq' },
72
+ { pattern: '\\bnats\\b', type: 'nats' },
73
+ { pattern: '\\bconsul\\b', type: 'consul' },
74
+ { pattern: '\\betcd\\b', type: 'etcd' },
75
+ // ── infra (cluster plumbing, not middleware) ────────────────────────────
76
+ { pattern: 'cert-manager', type: 'infra' },
77
+ { pattern: 'coredns', type: 'infra' },
78
+ { pattern: 'chaos-mesh', type: 'infra' },
79
+ { pattern: 'chaos-daemon', type: 'infra' },
80
+ { pattern: 'ingress-nginx', type: 'infra' },
81
+ { pattern: 'nginx-ingress', type: 'infra' },
82
+ { pattern: 'metrics-server', type: 'infra' },
83
+ { pattern: '\\bcalico\\b', type: 'infra' },
84
+ { pattern: '\\bcilium\\b', type: 'infra' },
85
+ { pattern: 'kube-proxy', type: 'infra' },
86
+ { pattern: 'local-path-provisioner', type: 'infra' },
87
+ { pattern: '\\btraefik\\b', type: 'infra' },
88
+ ];
89
+ export function expandHome(p) {
90
+ const home = process.env.HOME ?? os.homedir();
91
+ if (p === '~')
92
+ return home;
93
+ if (p.startsWith('~/'))
94
+ return home + p.slice(1);
95
+ return p;
96
+ }
97
+ /**
98
+ * Load user classification rules. Missing or malformed file is not an
99
+ * error — the built-in table alone still works — so this always resolves
100
+ * to a (possibly empty) rule list. Entries without a string pattern/type
101
+ * are skipped.
102
+ */
103
+ export function loadUserRules(file = DEFAULT_USER_RULES_FILE) {
104
+ let text;
105
+ try {
106
+ text = readFileSync(expandHome(file), 'utf8');
107
+ }
108
+ catch {
109
+ return [];
110
+ }
111
+ try {
112
+ const doc = parseYaml(text);
113
+ if (!doc || !Array.isArray(doc.rules))
114
+ return [];
115
+ const rules = [];
116
+ for (const entry of doc.rules) {
117
+ if (!entry || typeof entry !== 'object')
118
+ continue;
119
+ const { pattern, type } = entry;
120
+ if (typeof pattern !== 'string' || typeof type !== 'string')
121
+ continue;
122
+ try {
123
+ new RegExp(pattern); // validate — a broken regex skips this entry only
124
+ }
125
+ catch {
126
+ continue;
127
+ }
128
+ rules.push({ pattern, type });
129
+ }
130
+ return rules;
131
+ }
132
+ catch {
133
+ return [];
134
+ }
135
+ }
136
+ /**
137
+ * Candidate signal strings for a workload: image basenames (tag stripped)
138
+ * and full images, chart-ish label values, and every label as `key=value`.
139
+ */
140
+ function signalsOf(input) {
141
+ const signals = [];
142
+ for (const image of input.images) {
143
+ const basename = image.split('/').pop() ?? image;
144
+ signals.push(basename.replace(/:[^:]*$/, ''), basename, image);
145
+ }
146
+ for (const [key, value] of Object.entries(input.labels)) {
147
+ signals.push(value, `${key}=${value}`);
148
+ }
149
+ if (input.name)
150
+ signals.push(input.name);
151
+ return signals;
152
+ }
153
+ /** Classify one signal set against an ordered rule list; first match wins. */
154
+ export function classifySignals(input, rules) {
155
+ const compiled = rules.map(r => ({ re: new RegExp(r.pattern, 'i'), type: r.type }));
156
+ for (const signal of signalsOf(input)) {
157
+ for (const { re, type } of compiled) {
158
+ if (re.test(signal))
159
+ return type;
160
+ }
161
+ }
162
+ return 'unknown';
163
+ }
164
+ /**
165
+ * Classify a scanned workload. Loads built-in rules, then overlays the user
166
+ * rules file (user rules win ties — they are consulted first).
167
+ */
168
+ export function classifyWorkload(workload, opts = {}) {
169
+ const userRules = opts.extraRules ?? loadUserRules(opts.userRulesFile);
170
+ return classifySignals(workload, [...userRules, ...builtinRules]);
171
+ }
172
+ /** True for the middleware types worth surfacing as instances (not infra, not unknown). */
173
+ export function isMiddlewareType(type) {
174
+ return type !== 'unknown' && type !== 'infra';
175
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Single source for the environment tool's wording: the tool description,
3
+ * the one-line methodology section, and the full help text. Following the
4
+ * progressive-disclosure doctrine, only the one-liner lives in the system
5
+ * prompt; the model pulls HELP_TEXT on demand via action=help.
6
+ *
7
+ * @module @elinpf/dsh-ops-tool-environment
8
+ */
9
+ export declare const HELP_POINTER = "Call environment action=help for full usage.";
10
+ export declare const TOOL_DESCRIPTION: string;
11
+ /** The one-line system-prompt section (registered through ops-prompts). */
12
+ export declare const STATIC_PROMPT: string;
13
+ export declare const HELP_TEXT = "# environment \u2014 environment inventory\n\nThe inventory maps every k8s cluster registered in ops-access: workloads,\nservices, middleware instances (classified by image/chart/label), and\nbest-effort relation edges (who connects to whom). It is regenerated by\ndeterministic scans \u2014 no LLM guessing \u2014 and persisted at\n~/.dsh-ops/environment.yaml with a per-cluster scannedAt timestamp.\n\n## Actions\n\n- overview \u2014 all clusters, compact: middleware counts by type, unknown\n workload count, stale flag, scan time. Start here before any\n environment-related investigation.\n- show \u2014 one cluster, full detail. Requires the cluster parameter (a\n cluster name from overview / list_access). Returns middleware instances\n (type/namespace/workload/service entries), the unknown bucket\n (unrecognized workloads, still listed), and relation edges. Optional\n filters narrow the lists: namespace (exact match) and name (substring,\n case-insensitive; ANDed together). When filtering, edges are kept only\n when their workload endpoint survives \u2014 for fronts edges (Service \u2192\n workload) that endpoint is the \"to\" side.\n- refresh \u2014 re-scan every registered k8s cluster now. A cluster that is\n unreachable keeps its previous section, marked stale. Read-only: the\n scan resolves the ro credential tier only.\n- help \u2014 this text.\n\n## Freshness\n\nSections older than the TTL (default 1h) are re-scanned automatically on\nthe next overview/show call \u2014 you never trigger scans explicitly except\nwhen you know the environment just changed (then call refresh).\n\n## Reading edges\n\n- uses-middleware app \u2192 middleware instance (targetType is the middleware type)\n- uses-service app \u2192 Service (an address seen in env/ConfigMap values)\n- fronts Service \u2192 workload it selects\n- references-secret app \u2192 Secret, reference NAME only \u2014 values are never read\n\n## Unknown bucket\n\nWorkloads the classification table cannot identify are listed under\nunknown with name and image \u2014 they are not errors. Site-specific\ncomponents can be taught via ~/.dsh-ops/environment-rules.yaml.\n\n## Monitoring corroboration\n\nWhen a cluster has a discoverable Prometheus service (monitoring\nnamespace, port 9090), entries carry monitoring: { up, down } counts\nscraped from /api/v1/targets over a short-lived kubectl port-forward.\nA down count means Prometheus sees the instance failing \u2014 investigate\nthose first. Clusters without Prometheus simply have no monitoring data.\n\n## Anomalies\n\nEach cluster section carries rule-detected anomalies (generic k8s\nsemantics only, zero LLM):\n\n- cross-namespace-ref (info) \u2014 a workload references a Service in a\n DIFFERENT namespace. Often legitimate for shared infrastructure, but a\n prime suspect when an environment behaves like another one (e.g. a\n test environment pointing at another namespace's database).\n- service-no-backend (warning) \u2014 a Service has a pod selector but its\n Endpoints have zero ready addresses: nothing answers on it.\n\noverview lists all anomalies in one section; show annotates the involved\nentries in place with [!].";
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Single source for the environment tool's wording: the tool description,
3
+ * the one-line methodology section, and the full help text. Following the
4
+ * progressive-disclosure doctrine, only the one-liner lives in the system
5
+ * prompt; the model pulls HELP_TEXT on demand via action=help.
6
+ *
7
+ * @module @elinpf/dsh-ops-tool-environment
8
+ */
9
+ export const HELP_POINTER = 'Call environment action=help for full usage.';
10
+ export const TOOL_DESCRIPTION = 'Read the environment inventory: which middleware runs on which registered k8s cluster, and best-effort relation edges between apps and middleware. ' +
11
+ 'Actions: overview (all clusters, compact), show (one cluster, details + edges; narrow with the optional namespace and name filters instead of paging a large output), refresh (re-scan now), help (full usage). ' +
12
+ 'Data comes from ~/.dsh-ops/environment.yaml, refreshed automatically when older than its TTL — reads are cheap, prefer overview over blind kubectl exploration.';
13
+ /** The one-line system-prompt section (registered through ops-prompts). */
14
+ export const STATIC_PROMPT = [
15
+ '## environment — 环境清单',
16
+ `排查环境问题前先 \`environment\` action=overview 看环境全貌(集群/中间件/关联边),不要从零 kubectl 摸索。${HELP_POINTER}`,
17
+ ].join('\n');
18
+ export const HELP_TEXT = `# environment — environment inventory
19
+
20
+ The inventory maps every k8s cluster registered in ops-access: workloads,
21
+ services, middleware instances (classified by image/chart/label), and
22
+ best-effort relation edges (who connects to whom). It is regenerated by
23
+ deterministic scans — no LLM guessing — and persisted at
24
+ ~/.dsh-ops/environment.yaml with a per-cluster scannedAt timestamp.
25
+
26
+ ## Actions
27
+
28
+ - overview — all clusters, compact: middleware counts by type, unknown
29
+ workload count, stale flag, scan time. Start here before any
30
+ environment-related investigation.
31
+ - show — one cluster, full detail. Requires the cluster parameter (a
32
+ cluster name from overview / list_access). Returns middleware instances
33
+ (type/namespace/workload/service entries), the unknown bucket
34
+ (unrecognized workloads, still listed), and relation edges. Optional
35
+ filters narrow the lists: namespace (exact match) and name (substring,
36
+ case-insensitive; ANDed together). When filtering, edges are kept only
37
+ when their workload endpoint survives — for fronts edges (Service →
38
+ workload) that endpoint is the "to" side.
39
+ - refresh — re-scan every registered k8s cluster now. A cluster that is
40
+ unreachable keeps its previous section, marked stale. Read-only: the
41
+ scan resolves the ro credential tier only.
42
+ - help — this text.
43
+
44
+ ## Freshness
45
+
46
+ Sections older than the TTL (default 1h) are re-scanned automatically on
47
+ the next overview/show call — you never trigger scans explicitly except
48
+ when you know the environment just changed (then call refresh).
49
+
50
+ ## Reading edges
51
+
52
+ - uses-middleware app → middleware instance (targetType is the middleware type)
53
+ - uses-service app → Service (an address seen in env/ConfigMap values)
54
+ - fronts Service → workload it selects
55
+ - references-secret app → Secret, reference NAME only — values are never read
56
+
57
+ ## Unknown bucket
58
+
59
+ Workloads the classification table cannot identify are listed under
60
+ unknown with name and image — they are not errors. Site-specific
61
+ components can be taught via ~/.dsh-ops/environment-rules.yaml.
62
+
63
+ ## Monitoring corroboration
64
+
65
+ When a cluster has a discoverable Prometheus service (monitoring
66
+ namespace, port 9090), entries carry monitoring: { up, down } counts
67
+ scraped from /api/v1/targets over a short-lived kubectl port-forward.
68
+ A down count means Prometheus sees the instance failing — investigate
69
+ those first. Clusters without Prometheus simply have no monitoring data.
70
+
71
+ ## Anomalies
72
+
73
+ Each cluster section carries rule-detected anomalies (generic k8s
74
+ semantics only, zero LLM):
75
+
76
+ - cross-namespace-ref (info) — a workload references a Service in a
77
+ DIFFERENT namespace. Often legitimate for shared infrastructure, but a
78
+ prime suspect when an environment behaves like another one (e.g. a
79
+ test environment pointing at another namespace's database).
80
+ - service-no-backend (warning) — a Service has a pod selector but its
81
+ Endpoints have zero ready addresses: nothing answers on it.
82
+
83
+ overview lists all anomalies in one section; show annotates the involved
84
+ entries in place with [!].`;
package/lib/index.d.ts ADDED
@@ -0,0 +1,40 @@
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 type { Context } from '@deepseek-ai/cordis';
19
+ import z from '@deepseek-ai/schemastery';
20
+ import type { EnvironmentToolConfig } from './tool.js';
21
+ export declare const name = "ops-tool-environment";
22
+ export declare const inject: string[];
23
+ export declare const Config: z<EnvironmentToolConfig>;
24
+ export declare function apply(ctx: Context, config: EnvironmentToolConfig): void;
25
+ export type { Anomaly, ClassifiedWorkload, ClusterInventory, ClusterScan, MiddlewareInstance, MonitoringStatus, PromTarget, RelationEdge, ResourceRef, ScannedConfigMap, ScannedEndpoints, ScannedIngress, ScannedSecret, ScannedService, ScannedWorkload, } from './types.js';
26
+ export { builtinRules, classifySignals, classifyWorkload, DEFAULT_USER_RULES_FILE, expandHome, isMiddlewareType, loadUserRules, } from './classify.js';
27
+ export type { ClassificationRule, ClassifyInput } from './classify.js';
28
+ export { defaultExec, ScanError, SCAN_TIMEOUT_MS, scanCluster, scrubKubeconfigPath, } from './scanner.js';
29
+ export type { ExecFn, ScanClusterInput } from './scanner.js';
30
+ export { buildRelations, findServiceAddresses } from './relations.js';
31
+ export type { BuildRelationsInput } from './relations.js';
32
+ export { detectAnomalies } from './anomalies.js';
33
+ export type { DetectAnomaliesInput } from './anomalies.js';
34
+ export { findPrometheusService, matchTargetsToWorkloads, parseActiveTargets, scrapePrometheusTargets, } from './prometheus.js';
35
+ export type { PortForwardProcess, ScrapeOptions, SpawnFn } from './prometheus.js';
36
+ export { buildClusterInventory, DEFAULT_INVENTORY_FILE, readInventory, refreshInventory, } from './inventory.js';
37
+ export type { EnvironmentInventory, InventorySection, RefreshOptions, RefreshTarget, } from './inventory.js';
38
+ export { createEnvironmentTool, filterDetail } from './tool.js';
39
+ export type { ClusterDetail, ClusterSummary, DisplayAnomaly, DisplayEdge, EnvironmentToolConfig, EnvironmentToolDeps, EnvironmentToolResult, OverviewAnomaly, RefreshResultEntry, ShowFilter, UnknownWorkload, } from './tool.js';
40
+ export { HELP_POINTER, HELP_TEXT, STATIC_PROMPT, TOOL_DESCRIPTION } from './doctrine.js';