@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/README.i18n.yaml
ADDED
|
@@ -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
|
+
```
|
package/cordis.patch.yml
ADDED
|
@@ -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[];
|
package/lib/anomalies.js
ADDED
|
@@ -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;
|
package/lib/classify.js
ADDED
|
@@ -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 [!].";
|
package/lib/doctrine.js
ADDED
|
@@ -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';
|