@elinpf/dsh-ops-tool-prometheus 0.2.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 +4 -0
- package/README.md +32 -0
- package/README.zh.md +32 -0
- package/cordis.patch.yml +1 -0
- package/lib/index.d.ts +40 -0
- package/lib/index.js +281 -0
- package/lib/invariant.d.ts +16 -0
- package/lib/invariant.js +26 -0
- package/lib/types.d.ts +29 -0
- package/lib/types.js +9 -0
- package/package.json +60 -0
package/README.i18n.yaml
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
title: "@elinpf/dsh-ops-tool-prometheus"
|
|
2
|
+
description:
|
|
3
|
+
zh: "运维模式 prometheus 工具 — 解析 prometheus 档案,直接对 Prometheus HTTP API 执行 PromQL 查询(instant/range),bearer token 自动注入"
|
|
4
|
+
en: "prometheus tool for ops mode — resolves prometheus profiles and runs PromQL instant/range queries over the Prometheus HTTP API, injecting the bearer token automatically"
|
package/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# @elinpf/dsh-ops-tool-prometheus
|
|
2
|
+
|
|
3
|
+
The `prometheus` tool for DeepSeek Harness ops mode — resolves a named `prometheus` profile through the ops-access seam and runs a PromQL query directly against the Prometheus HTTP API (`/api/v1/query` / `/api/v1/query_range`), injecting the bearer token automatically. No shell, no curl heredocs.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
The model calls `prometheus` with a `cluster` (profile name) and a `query` (PromQL). With no time arguments it runs an instant query at now; `time` (RFC3339 or unix seconds) pins the instant; `start`+`end`+`step` (all three) make it a range query. The tool resolves the profile on every call (no caching — credential edits take effect immediately), reads the optional bearer-token file, sends the request with a 30 s timeout (`timeoutSec`, 1–600 s, overrides per call), and returns the suite-standard `{ exitCode, stdout, stderr, command, error? }` result.
|
|
8
|
+
|
|
9
|
+
## Design notes
|
|
10
|
+
|
|
11
|
+
- **HTTP, not shell.** The Prometheus API is a plain GET with URL-encoded parameters — routing it through `ctx.shell` + curl only added quoting bugs (the motivation for this tool: a real incident session hand-wrote ~15 curl heredocs, 5 of them broken). The result shape, output schema, and render duplicate the `@elinpf/dsh-ops-shell-tool` contract verbatim so the four consumer tools cannot drift apart.
|
|
12
|
+
- **Failure taxonomy.** A PromQL rejection (`status: "error"` in the API envelope) is the server's answer: `exitCode: 1`, `errorType: error` on stderr. Transport failures — connection refused, timeout (`AbortSignal.timeout`), caller abort, non-Prometheus HTTP status — are `exitCode: -1` with the cause named in `error`, never a bare -1.
|
|
13
|
+
- **No secret material in band.** The token travels only in the `Authorization` header, which is never logged or echoed; every returned string is additionally scrubbed against the token value defensively. The server URL is plain connection metadata and DOES appear in the displayed command (`prometheus <cluster> query='...' @ http://host:9090/api/v1/query`) so the agent can confirm which instance it hit. A token-file read failure is reported without its path — credential paths never reach the model.
|
|
14
|
+
- **Size guards.** Range results are capped at 100 series (rest omitted with a note), 50 points per series (evenly sampled, first and last kept, noted in the series header), and ~100KB of stdout (cut with a truncation note pointing at narrower selectors / larger steps).
|
|
15
|
+
- **Registration is an effect.** The tool registers via `ctx.effect(() => ctx.tools.register(...))`, so fiber disposal / HMR unloads it cleanly. The `./invariant` subpath registers a no-op invariant companion: the tool is stateless and owns no session events.
|
|
16
|
+
|
|
17
|
+
## Configuration
|
|
18
|
+
|
|
19
|
+
Schemastery schema, one option:
|
|
20
|
+
|
|
21
|
+
| Key | Default | Meaning |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `timeoutMs` | `30000` | Per-call HTTP timeout for Prometheus queries (ms). Slow queries may need more. |
|
|
24
|
+
|
|
25
|
+
## Testing
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm run build # tsc → lib/ (plugins load lib/, not src/)
|
|
29
|
+
npx vitest run # unit tests against a mock ctx + injected fake fetch — no network
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The suite covers instant/range routing and URL encoding, argument-shape rejections (time vs start/end/step, partial range), bearer-token injection/scrubbing, the Prometheus/HTTP/network/timeout failure mapping, the series/points/bytes size guards, render purity, export shape (`.` / `./invariant` / `./types`), and HMR unload.
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# @elinpf/dsh-ops-tool-prometheus
|
|
2
|
+
|
|
3
|
+
DeepSeek Harness 运维模式下的 `prometheus` 工具 — 通过 ops-access 接缝按名字解析 `prometheus` 档案(profile),直接对 Prometheus HTTP API(`/api/v1/query` / `/api/v1/query_range`)执行 PromQL 查询,bearer token 自动注入。不走 shell,不写 curl heredoc。
|
|
4
|
+
|
|
5
|
+
## 它做什么
|
|
6
|
+
|
|
7
|
+
模型用 `cluster`(档案名)和 `query`(PromQL)调用 `prometheus` 工具。不带时间参数 = 当前时刻的 instant 查询;`time`(RFC3339 或 unix 秒)固定 instant 时刻;`start`+`end`+`step` 三个都给 = range 查询。工具每次调用都重新解析档案(不缓存,凭证改动立即生效),读取可选的 bearer-token 文件,以 30 秒超时发请求(`timeoutSec` 可单次覆盖,1–600 秒),返回套件标准结果 `{ exitCode, stdout, stderr, command, error? }`。
|
|
8
|
+
|
|
9
|
+
## 设计要点
|
|
10
|
+
|
|
11
|
+
- **走 HTTP,不走 shell。** Prometheus API 就是一次 URL 编码的 GET——经 `ctx.shell` + curl 只会引入引号 bug(本工具的动机:一次真实排查会话手写了约 15 次 curl heredoc,写错 5 次)。结果形态、输出 schema、render 逐字复刻 `@elinpf/dsh-ops-shell-tool` 的契约,保证消费工具不各自漂移。
|
|
12
|
+
- **失败分级。** PromQL 被拒(API 信封里 `status: "error"`)是服务器的回答:`exitCode: 1`,stderr 放 `errorType: error`。传输层失败——连接被拒、超时(`AbortSignal.timeout`)、调用方中止、非 Prometheus 的 HTTP 状态——一律 `exitCode: -1`,`error` 写明首因,永不裸 -1。
|
|
13
|
+
- **秘密不经过工具。** token 只出现在 `Authorization` 头里,永不进日志或回显;所有返回字符串额外按 token 值做防御性擦除。服务器 URL 是普通连接参数,会出现在展示命令里(`prometheus <cluster> query='...' @ http://host:9090/api/v1/query`),让 agent 能确认打到哪个实例。token 文件读取失败的报错不带路径——凭证路径永不进模型视野。
|
|
14
|
+
- **大小护栏。** range 结果最多 100 个 series(超出省略并注明)、每 series 50 个点(均匀抽样、首尾必留、头部注明)、stdout ~100KB(截断并注明,指路更窄的选择器/更大的 step)。
|
|
15
|
+
- **注册即 effect。** 工具经 `ctx.effect(() => ctx.tools.register(...))` 注册,fiber 销毁 / HMR 时干净卸载。`./invariant` 子路径注册一个空实现的 invariant 伴生插件:本工具无状态、不拥有 session 事件。
|
|
16
|
+
|
|
17
|
+
## 配置项
|
|
18
|
+
|
|
19
|
+
schemastery schema,仅一项:
|
|
20
|
+
|
|
21
|
+
| 键 | 默认值 | 含义 |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `timeoutMs` | `30000` | 单次 Prometheus 查询的 HTTP 超时(毫秒)。慢查询可调大。 |
|
|
24
|
+
|
|
25
|
+
## 测试方式
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npm run build # tsc → lib/(插件加载的是 lib/,不是 src/)
|
|
29
|
+
npx vitest run # 基于 mock ctx + 注入的 fake fetch 的单元测试,不碰网络
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
测试覆盖:instant/range 路由与 URL 编码、参数形状拒绝(time 与 start/end/step 互斥、range 三缺一)、bearer token 注入与擦除、Prometheus/HTTP/网络/超时失败映射、series/点数/字节三级大小护栏、render 纯函数性、导出形态(`.` / `./invariant` / `./types` 三个入口)、以及 HMR 卸载。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[]
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ops prometheus tool consumer.
|
|
3
|
+
*
|
|
4
|
+
* The `prometheus` tool: resolves a `prometheus` profile through the
|
|
5
|
+
* ops-access seam and runs a PromQL query directly against the Prometheus
|
|
6
|
+
* HTTP API (GET /api/v1/query or /api/v1/query_range) — no ctx.shell, no
|
|
7
|
+
* curl heredocs. When the profile carries a token fileField, the file is
|
|
8
|
+
* read per call and sent as `Authorization: Bearer`; the token itself never
|
|
9
|
+
* reaches the result, the displayed command, or any error text (every
|
|
10
|
+
* returned string is scrubbed against it defensively).
|
|
11
|
+
*
|
|
12
|
+
* The result shape `{ exitCode, stdout, stderr, command, error? }`, output
|
|
13
|
+
* schema, and render duplicate the ops-shell-tool contract verbatim — that
|
|
14
|
+
* factory wraps ctx.shell commands, which this tool is not, and it does not
|
|
15
|
+
* export the contract separately.
|
|
16
|
+
*
|
|
17
|
+
* @module @elinpf/dsh-ops-tool-prometheus
|
|
18
|
+
*/
|
|
19
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
20
|
+
import z from '@deepseek-ai/schemastery';
|
|
21
|
+
import type { PrometheusToolConfig } from './types.js';
|
|
22
|
+
export type { PrometheusToolConfig, PrometheusToolResult } from './types.js';
|
|
23
|
+
export declare const name = "ops-tool-prometheus";
|
|
24
|
+
export declare const inject: string[];
|
|
25
|
+
export declare const Config: z<PrometheusToolConfig>;
|
|
26
|
+
/** `up{instance="x",job="y"}` — the __name__ label leads, the rest sort-free. */
|
|
27
|
+
export declare function metricToString(metric: Record<string, string>): string;
|
|
28
|
+
/** Format a successful Prometheus response body by its resultType. */
|
|
29
|
+
export declare function formatResult(data: {
|
|
30
|
+
resultType?: string;
|
|
31
|
+
result?: unknown;
|
|
32
|
+
}): string;
|
|
33
|
+
/** Cut stdout at ~100KB with an explicit note — never a silent truncation. */
|
|
34
|
+
export declare function truncateStdout(text: string): string;
|
|
35
|
+
/** Injectable fetch (tests); defaults to the global fetch. */
|
|
36
|
+
export interface PrometheusToolDeps {
|
|
37
|
+
fetchFn?: typeof fetch;
|
|
38
|
+
}
|
|
39
|
+
export declare function createPrometheusTool(ctx: Context, config: PrometheusToolConfig, deps?: PrometheusToolDeps): import("@deepseek-ai/dsh-tools").ToolDefinition;
|
|
40
|
+
export declare function apply(ctx: Context, config: PrometheusToolConfig): void;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ops prometheus tool consumer.
|
|
3
|
+
*
|
|
4
|
+
* The `prometheus` tool: resolves a `prometheus` profile through the
|
|
5
|
+
* ops-access seam and runs a PromQL query directly against the Prometheus
|
|
6
|
+
* HTTP API (GET /api/v1/query or /api/v1/query_range) — no ctx.shell, no
|
|
7
|
+
* curl heredocs. When the profile carries a token fileField, the file is
|
|
8
|
+
* read per call and sent as `Authorization: Bearer`; the token itself never
|
|
9
|
+
* reaches the result, the displayed command, or any error text (every
|
|
10
|
+
* returned string is scrubbed against it defensively).
|
|
11
|
+
*
|
|
12
|
+
* The result shape `{ exitCode, stdout, stderr, command, error? }`, output
|
|
13
|
+
* schema, and render duplicate the ops-shell-tool contract verbatim — that
|
|
14
|
+
* factory wraps ctx.shell commands, which this tool is not, and it does not
|
|
15
|
+
* export the contract separately.
|
|
16
|
+
*
|
|
17
|
+
* @module @elinpf/dsh-ops-tool-prometheus
|
|
18
|
+
*/
|
|
19
|
+
import z from '@deepseek-ai/schemastery';
|
|
20
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
21
|
+
import { readFile } from 'node:fs/promises';
|
|
22
|
+
// ── Plugin identity ───────────────────────────────────────────────────────────
|
|
23
|
+
export const name = 'ops-tool-prometheus';
|
|
24
|
+
export const inject = ['tools'];
|
|
25
|
+
// ── Config ───────────────────────────────────────────────────────────────────
|
|
26
|
+
export const Config = z.object({
|
|
27
|
+
/** Per-call HTTP timeout for Prometheus queries (ms). Slow queries may need more. */
|
|
28
|
+
timeoutMs: z.number().default(30000),
|
|
29
|
+
});
|
|
30
|
+
// ── Output contract (verbatim copy of the ops-shell-tool one) ────────────────
|
|
31
|
+
const output = {
|
|
32
|
+
schema: {
|
|
33
|
+
type: 'object',
|
|
34
|
+
additionalProperties: false,
|
|
35
|
+
properties: {
|
|
36
|
+
exitCode: { type: 'number', required: true },
|
|
37
|
+
stdout: { type: 'string', required: true },
|
|
38
|
+
stderr: { type: 'string', required: true },
|
|
39
|
+
command: { type: 'string', required: true },
|
|
40
|
+
error: { type: 'string' },
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
// Pure function of (args, value): same inputs, same text, no state touched.
|
|
44
|
+
render: (_args, value) => {
|
|
45
|
+
const parts = [];
|
|
46
|
+
if (value.command)
|
|
47
|
+
parts.push(`$ ${value.command}`);
|
|
48
|
+
if (value.stdout)
|
|
49
|
+
parts.push(value.stdout);
|
|
50
|
+
if (value.stderr)
|
|
51
|
+
parts.push(`[stderr]\n${value.stderr}`);
|
|
52
|
+
if (value.error)
|
|
53
|
+
parts.push(`[error] ${value.error}`);
|
|
54
|
+
if (value.exitCode !== 0 && value.exitCode !== undefined && value.exitCode !== null) {
|
|
55
|
+
parts.push(`[exit code: ${value.exitCode}]`);
|
|
56
|
+
}
|
|
57
|
+
return [{ type: 'text', text: parts.join('\n\n') || '(no output)' }];
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
// ── Formatting ───────────────────────────────────────────────────────────────
|
|
61
|
+
/** Hard caps guarding the model's context against a fat range query. */
|
|
62
|
+
const MAX_SERIES = 100;
|
|
63
|
+
const MAX_POINTS_PER_SERIES = 50;
|
|
64
|
+
/** ~100KB — stdout beyond this is cut and noted. */
|
|
65
|
+
const MAX_STDOUT_CHARS = 100 * 1024;
|
|
66
|
+
/** `up{instance="x",job="y"}` — the __name__ label leads, the rest sort-free. */
|
|
67
|
+
export function metricToString(metric) {
|
|
68
|
+
const name = metric.__name__ ?? '';
|
|
69
|
+
const labels = Object.entries(metric)
|
|
70
|
+
.filter(([k]) => k !== '__name__')
|
|
71
|
+
.map(([k, v]) => `${k}="${v}"`)
|
|
72
|
+
.join(',');
|
|
73
|
+
if (labels === '')
|
|
74
|
+
return name || '{}';
|
|
75
|
+
return `${name}{${labels}}`;
|
|
76
|
+
}
|
|
77
|
+
/** Prometheus timestamps are unix seconds (possibly fractional). */
|
|
78
|
+
function iso(ts) {
|
|
79
|
+
return new Date(ts * 1000).toISOString();
|
|
80
|
+
}
|
|
81
|
+
function formatVector(result) {
|
|
82
|
+
if (result.length === 0)
|
|
83
|
+
return 'empty result — the query matched no series at that time';
|
|
84
|
+
const shown = result.slice(0, MAX_SERIES);
|
|
85
|
+
const lines = shown.map((s) => `${metricToString(s.metric)} = ${s.value?.[1]} @ ${iso(s.value?.[0] ?? 0)}`);
|
|
86
|
+
if (result.length > shown.length) {
|
|
87
|
+
lines.push(`(+${result.length - shown.length} more series omitted — narrow the query with more label matchers)`);
|
|
88
|
+
}
|
|
89
|
+
return lines.join('\n');
|
|
90
|
+
}
|
|
91
|
+
function formatScalar(result) {
|
|
92
|
+
return `${result[1]} @ ${iso(result[0])}`;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Evenly sample `values` down to MAX_POINTS_PER_SERIES, always keeping the
|
|
96
|
+
* first and last point — the shape of a spike survives sampling.
|
|
97
|
+
*/
|
|
98
|
+
function samplePoints(values) {
|
|
99
|
+
if (values.length <= MAX_POINTS_PER_SERIES)
|
|
100
|
+
return { points: values, sampled: false };
|
|
101
|
+
const points = [];
|
|
102
|
+
for (let i = 0; i < MAX_POINTS_PER_SERIES; i++) {
|
|
103
|
+
points.push(values[Math.floor(i * (values.length - 1) / (MAX_POINTS_PER_SERIES - 1))]);
|
|
104
|
+
}
|
|
105
|
+
return { points, sampled: true };
|
|
106
|
+
}
|
|
107
|
+
function formatMatrix(result) {
|
|
108
|
+
if (result.length === 0)
|
|
109
|
+
return 'empty result — the query matched no series in that range';
|
|
110
|
+
const shown = result.slice(0, MAX_SERIES);
|
|
111
|
+
const blocks = shown.map((s) => {
|
|
112
|
+
const values = s.values ?? [];
|
|
113
|
+
const { points, sampled } = samplePoints(values);
|
|
114
|
+
const header = `${metricToString(s.metric)} — ${values.length} points${sampled ? `, showing ${points.length} evenly sampled` : ''}`;
|
|
115
|
+
return [header, ...points.map(([ts, v]) => ` ${iso(ts)} ${v}`)].join('\n');
|
|
116
|
+
});
|
|
117
|
+
if (result.length > shown.length) {
|
|
118
|
+
blocks.push(`(+${result.length - shown.length} more series omitted — narrow the query with more label matchers)`);
|
|
119
|
+
}
|
|
120
|
+
return blocks.join('\n\n');
|
|
121
|
+
}
|
|
122
|
+
/** Format a successful Prometheus response body by its resultType. */
|
|
123
|
+
export function formatResult(data) {
|
|
124
|
+
switch (data.resultType) {
|
|
125
|
+
case 'vector': return formatVector((data.result ?? []));
|
|
126
|
+
case 'matrix': return formatMatrix((data.result ?? []));
|
|
127
|
+
case 'scalar':
|
|
128
|
+
case 'string': return formatScalar(data.result);
|
|
129
|
+
default: return `unsupported resultType "${String(data.resultType)}" — raw result:\n${JSON.stringify(data.result ?? null, null, 2)}`;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/** Cut stdout at ~100KB with an explicit note — never a silent truncation. */
|
|
133
|
+
export function truncateStdout(text) {
|
|
134
|
+
if (text.length <= MAX_STDOUT_CHARS)
|
|
135
|
+
return text;
|
|
136
|
+
return text.slice(0, MAX_STDOUT_CHARS)
|
|
137
|
+
+ `\n... [truncated: the result exceeded ${Math.round(MAX_STDOUT_CHARS / 1024)}KB — narrow the query (more label matchers), shorten the range, or raise the step]`;
|
|
138
|
+
}
|
|
139
|
+
/** Single-quote a value for the display command (display only, never executed). */
|
|
140
|
+
function quoteArg(value) {
|
|
141
|
+
return "'" + value.split("'").join("'\\''") + "'";
|
|
142
|
+
}
|
|
143
|
+
function errorMessage(e) {
|
|
144
|
+
return String(e?.message || e);
|
|
145
|
+
}
|
|
146
|
+
export function createPrometheusTool(ctx, config, deps = {}) {
|
|
147
|
+
const fetchFn = deps.fetchFn ?? fetch;
|
|
148
|
+
return defineTool({
|
|
149
|
+
name: 'prometheus',
|
|
150
|
+
description: 'Query a Prometheus server with PromQL over its HTTP API: an instant query (optionally at `time`), or a range query when start+end+step are all given. Resolves the server URL and optional bearer token from a registered `prometheus` profile (see list_access). The API is read-only. Prefer this over hand-rolled curl: credentials, encoding, and timeouts are handled.',
|
|
151
|
+
parameters: {
|
|
152
|
+
cluster: { type: 'string', required: true, description: 'Prometheus profile name (kind `prometheus`; a `prometheus/` prefix is tolerated). Use list_access to see options.' },
|
|
153
|
+
query: { type: 'string', required: true, description: 'The PromQL expression, e.g. `rate(http_requests_total{job="api"}[5m])`.' },
|
|
154
|
+
time: { type: 'string', description: 'Instant query evaluation time: RFC3339 or unix seconds. Defaults to now. Mutually exclusive with start/end/step.' },
|
|
155
|
+
start: { type: 'string', description: 'Range query start (RFC3339 or unix seconds). Requires end and step.' },
|
|
156
|
+
end: { type: 'string', description: 'Range query end (RFC3339 or unix seconds). Requires start and step.' },
|
|
157
|
+
step: { type: 'string', description: 'Range query resolution step, e.g. "15s", "1m". Requires start and end.' },
|
|
158
|
+
timeoutSec: { type: 'number', description: 'Optional per-call timeout in seconds (default 30, max 600). Use only for a query you KNOW is slow (a wide range over many series) — a longer wait does not fix an unreachable server.' },
|
|
159
|
+
},
|
|
160
|
+
output,
|
|
161
|
+
async execute(args, exec) {
|
|
162
|
+
const cluster = args.cluster;
|
|
163
|
+
const query = args.query;
|
|
164
|
+
const time = args.time;
|
|
165
|
+
const start = args.start;
|
|
166
|
+
const end = args.end;
|
|
167
|
+
const step = args.step;
|
|
168
|
+
const fail = (message, command = '') => ({ error: message, exitCode: -1, stdout: '', stderr: message, command });
|
|
169
|
+
// Argument shape first — cheap, side-effect free, and the teaching
|
|
170
|
+
// message is most useful BEFORE a credential resolve has happened.
|
|
171
|
+
const rangeArgs = [start, end, step].filter((v) => v !== undefined).length;
|
|
172
|
+
if (time !== undefined && rangeArgs > 0) {
|
|
173
|
+
return fail('time is mutually exclusive with start/end/step — an instant query uses time (or neither, for now); a range query uses start+end+step');
|
|
174
|
+
}
|
|
175
|
+
if (rangeArgs > 0 && rangeArgs < 3) {
|
|
176
|
+
return fail('a range query needs ALL of start, end and step (e.g. step "15s") — got only ' + rangeArgs + ' of them');
|
|
177
|
+
}
|
|
178
|
+
const isRange = rangeArgs === 3;
|
|
179
|
+
const display = `prometheus ${cluster} query=${quoteArg(query)}`
|
|
180
|
+
+ (time !== undefined ? ` time=${time}` : '')
|
|
181
|
+
+ (isRange ? ` start=${start} end=${end} step=${step}` : '');
|
|
182
|
+
let token;
|
|
183
|
+
/** Defensive: the token must never reach model-visible output. */
|
|
184
|
+
const scrub = (text) => token !== undefined && token.length >= 8 ? text.split(token).join('<token>') : text;
|
|
185
|
+
let command = display;
|
|
186
|
+
try {
|
|
187
|
+
// Resolve the seam per call through ctx.get — never a static inject,
|
|
188
|
+
// never cached. Same discipline as the shell-tool factory.
|
|
189
|
+
const opsAccess = ctx.get('opsAccess');
|
|
190
|
+
if (!opsAccess) {
|
|
191
|
+
return fail('ops-access service unavailable — is the ops-access plugin mounted in this preset?', display);
|
|
192
|
+
}
|
|
193
|
+
// Pass the caller agent through so the access gate (if mounted) can
|
|
194
|
+
// key grants on the session id. Core tolerates a `prometheus/` prefix
|
|
195
|
+
// on the name.
|
|
196
|
+
const profile = await opsAccess.resolve('prometheus', cluster, exec.agent);
|
|
197
|
+
const baseUrl = String(profile.fields.url ?? '');
|
|
198
|
+
const endpoint = isRange ? '/api/v1/query_range' : '/api/v1/query';
|
|
199
|
+
command = `${display} @ ${baseUrl}${endpoint}`;
|
|
200
|
+
// The token field is a PATH to a managed file (fileField). A read
|
|
201
|
+
// failure is reported without the path — credential paths never
|
|
202
|
+
// reach the model (same discipline as the shell-tool's ref tokens).
|
|
203
|
+
if (typeof profile.fields.token === 'string' && profile.fields.token !== '') {
|
|
204
|
+
try {
|
|
205
|
+
token = (await readFile(profile.fields.token, 'utf8')).trim();
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return fail(`the bearer token file of profile "${profile.name}" could not be read — re-register the token via register_access or the admin UI`, command);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const u = new URL(baseUrl + endpoint);
|
|
212
|
+
u.searchParams.set('query', query);
|
|
213
|
+
if (isRange) {
|
|
214
|
+
u.searchParams.set('start', start);
|
|
215
|
+
u.searchParams.set('end', end);
|
|
216
|
+
u.searchParams.set('step', step);
|
|
217
|
+
}
|
|
218
|
+
else if (time !== undefined) {
|
|
219
|
+
u.searchParams.set('time', time);
|
|
220
|
+
}
|
|
221
|
+
let timeoutMs = config.timeoutMs;
|
|
222
|
+
const override = args.timeoutSec;
|
|
223
|
+
if (typeof override === 'number' && Number.isFinite(override) && override >= 1 && override <= 600) {
|
|
224
|
+
timeoutMs = Math.round(override * 1000);
|
|
225
|
+
}
|
|
226
|
+
const signals = [AbortSignal.timeout(timeoutMs)];
|
|
227
|
+
if (exec.signal)
|
|
228
|
+
signals.push(exec.signal);
|
|
229
|
+
// Headers carry the token; they are never logged or echoed — only
|
|
230
|
+
// the URL (plain connection metadata) appears in `command`.
|
|
231
|
+
const headers = token !== undefined ? { authorization: `Bearer ${token}` } : {};
|
|
232
|
+
const response = await fetchFn(u.toString(), { headers, signal: AbortSignal.any(signals) });
|
|
233
|
+
const text = await response.text();
|
|
234
|
+
let body;
|
|
235
|
+
try {
|
|
236
|
+
body = JSON.parse(text);
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
body = undefined;
|
|
240
|
+
}
|
|
241
|
+
// A PromQL rejection arrives as status:'error' (usually with HTTP
|
|
242
|
+
// 4xx) — that is the SERVER's answer, so it is exitCode 1, not -1.
|
|
243
|
+
if (body?.status === 'error') {
|
|
244
|
+
const detail = `${body.errorType ?? 'error'}: ${body.error ?? 'unknown error'}`;
|
|
245
|
+
return { exitCode: 1, stdout: '', stderr: scrub(detail), command, error: scrub(detail) };
|
|
246
|
+
}
|
|
247
|
+
if (!response.ok) {
|
|
248
|
+
const message = `the Prometheus server answered HTTP ${response.status} ${response.statusText}`.trimEnd();
|
|
249
|
+
return { ...fail(scrub(message), command), stderr: scrub(text.slice(0, 2000)) };
|
|
250
|
+
}
|
|
251
|
+
if (body?.status !== 'success') {
|
|
252
|
+
return fail(scrub(`unexpected response from the Prometheus server (not a prometheus API envelope, ${text.length} bytes) — is ${baseUrl} really a Prometheus?`), command);
|
|
253
|
+
}
|
|
254
|
+
const stdout = truncateStdout(scrub(formatResult(body.data ?? {})));
|
|
255
|
+
const stderr = (body.warnings ?? []).map((w) => `warning: ${scrub(w)}`).join('\n');
|
|
256
|
+
return { exitCode: 0, stdout, stderr, command };
|
|
257
|
+
}
|
|
258
|
+
catch (e) {
|
|
259
|
+
const err = e;
|
|
260
|
+
let message;
|
|
261
|
+
if (err.name === 'TimeoutError') {
|
|
262
|
+
message = `timed out — the Prometheus server did not answer within the tool timeout. For a known-slow query pass timeoutSec (max 600); for a wide range query raise the step or narrow the selector instead`;
|
|
263
|
+
}
|
|
264
|
+
else if (err.name === 'AbortError') {
|
|
265
|
+
message = 'aborted: the caller cancelled this query before it finished.';
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
// fetch network failures (TypeError 'fetch failed' + cause) — the
|
|
269
|
+
// message carries no headers, and the URL is not secret.
|
|
270
|
+
const cause = err.cause?.message ? ` (${err.cause.message})` : '';
|
|
271
|
+
message = `connection to the Prometheus server failed: ${errorMessage(e)}${cause}`;
|
|
272
|
+
}
|
|
273
|
+
return fail(scrub(message), command);
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
// ── Plugin apply ─────────────────────────────────────────────────────────────
|
|
279
|
+
export function apply(ctx, config) {
|
|
280
|
+
ctx.effect(() => ctx.tools.register(createPrometheusTool(ctx, config)));
|
|
281
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invariant companion for @elinpf/dsh-ops-tool-prometheus.
|
|
3
|
+
*
|
|
4
|
+
* @module @elinpf/dsh-ops-tool-prometheus/invariant
|
|
5
|
+
*/
|
|
6
|
+
/** Cordis companion plugin name. */
|
|
7
|
+
declare const name = "ops-prometheus-invariant";
|
|
8
|
+
/** Service required before the companion can reserve package ownership. */
|
|
9
|
+
declare const inject: string[];
|
|
10
|
+
/**
|
|
11
|
+
* Register this package's invariant companion.
|
|
12
|
+
* @param ctx - Context carrying the invariant service.
|
|
13
|
+
* @returns a promise resolving after registration.
|
|
14
|
+
*/
|
|
15
|
+
declare const apply: (ctx: any) => Promise<void>;
|
|
16
|
+
export { apply, inject, name };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Invariant companion for @elinpf/dsh-ops-tool-prometheus.
|
|
3
|
+
*
|
|
4
|
+
* @module @elinpf/dsh-ops-tool-prometheus/invariant
|
|
5
|
+
*/
|
|
6
|
+
const PACKAGE_NAME = '@elinpf/dsh-ops-tool-prometheus';
|
|
7
|
+
/** Cordis companion plugin name. */
|
|
8
|
+
const name = 'ops-prometheus-invariant';
|
|
9
|
+
/** Service required before the companion can reserve package ownership. */
|
|
10
|
+
const inject = ['invariants'];
|
|
11
|
+
/**
|
|
12
|
+
* No runtime invariant: this tool is stateless. It owns no session events and
|
|
13
|
+
* no durable shape — every call re-resolves the credential profile through
|
|
14
|
+
* the ops-access seam and runs one HTTP query, so there is nothing to fold
|
|
15
|
+
* and nothing to check.
|
|
16
|
+
*/
|
|
17
|
+
const install = () => { };
|
|
18
|
+
/**
|
|
19
|
+
* Register this package's invariant companion.
|
|
20
|
+
* @param ctx - Context carrying the invariant service.
|
|
21
|
+
* @returns a promise resolving after registration.
|
|
22
|
+
*/
|
|
23
|
+
const apply = async (ctx) => {
|
|
24
|
+
ctx.invariants.register(PACKAGE_NAME, install);
|
|
25
|
+
};
|
|
26
|
+
export { apply, inject, name };
|
package/lib/types.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type definitions for the ops-tool-prometheus plugin.
|
|
3
|
+
*
|
|
4
|
+
* Types only — no runtime values live here (not even consts), so importing
|
|
5
|
+
* this module never pulls in plugin code.
|
|
6
|
+
*
|
|
7
|
+
* @module @elinpf/dsh-ops-tool-prometheus
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Resolved configuration of the ops-tool-prometheus plugin (post-schema defaults).
|
|
11
|
+
*/
|
|
12
|
+
export interface PrometheusToolConfig {
|
|
13
|
+
/** Per-call HTTP timeout for Prometheus queries (ms). Slow queries may need more. */
|
|
14
|
+
timeoutMs: number;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The suite-standard tool result shape — identical to ShellToolResult in
|
|
18
|
+
* @elinpf/dsh-ops-shell-tool. Duplicated here (with the output schema/render)
|
|
19
|
+
* because this tool speaks HTTP, not shell: it does not go through
|
|
20
|
+
* registerProfiledShellTool, and ops-shell-tool does not export the contract
|
|
21
|
+
* separately.
|
|
22
|
+
*/
|
|
23
|
+
export interface PrometheusToolResult {
|
|
24
|
+
exitCode: number;
|
|
25
|
+
stdout: string;
|
|
26
|
+
stderr: string;
|
|
27
|
+
command: string;
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
package/lib/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@elinpf/dsh-ops-tool-prometheus",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "prometheus tool consumer of the ops-access seam — resolves prometheus profiles and runs PromQL instant/range queries over the Prometheus HTTP API, injecting the bearer token automatically.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"types": "lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"default": "./lib/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./invariant": {
|
|
14
|
+
"types": "./lib/invariant.d.ts",
|
|
15
|
+
"default": "./lib/invariant.js"
|
|
16
|
+
},
|
|
17
|
+
"./types": {
|
|
18
|
+
"types": "./lib/types.d.ts",
|
|
19
|
+
"default": "./lib/types.js"
|
|
20
|
+
},
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"lib/index.js",
|
|
25
|
+
"lib/invariant.js",
|
|
26
|
+
"lib/types.js",
|
|
27
|
+
"lib/**/*.d.ts",
|
|
28
|
+
"cordis.patch.yml"
|
|
29
|
+
],
|
|
30
|
+
"dsh": {
|
|
31
|
+
"bundle": {
|
|
32
|
+
"patch": "./cordis.patch.yml"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@deepseek-ai/schemastery": "^3.18.1"
|
|
37
|
+
},
|
|
38
|
+
"peerDependencies": {
|
|
39
|
+
"@deepseek-ai/cordis": "^4.0.1",
|
|
40
|
+
"@deepseek-ai/dsh-tools": "^0.1.0-rc.8",
|
|
41
|
+
"@elinpf/dsh-ops-access": "^0.2.0"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@deepseek-ai/cordis": "4.0.1",
|
|
45
|
+
"@deepseek-ai/dsh-tools": "0.0.1-rc.1",
|
|
46
|
+
"@types/node": "^22.0.0",
|
|
47
|
+
"typescript": "^5.4.0",
|
|
48
|
+
"vitest": "^4.1.11",
|
|
49
|
+
"@elinpf/dsh-ops-access": "0.2.0"
|
|
50
|
+
},
|
|
51
|
+
"license": "MIT",
|
|
52
|
+
"publishConfig": {
|
|
53
|
+
"access": "public"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"build": "tsc",
|
|
57
|
+
"typecheck": "tsc --noEmit",
|
|
58
|
+
"test": "vitest run"
|
|
59
|
+
}
|
|
60
|
+
}
|