ralph-hero-mcp-server 2.5.142 → 2.5.168
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/dist/index.js +6 -0
- package/dist/lib/delegation-log.js +199 -0
- package/dist/lib/kubectl-exec.js +70 -0
- package/dist/tools/delegation-tools.js +44 -0
- package/dist/tools/sre-tools.js +334 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -31,7 +31,9 @@ import { registerDecomposeTools } from "./tools/decompose-tools.js";
|
|
|
31
31
|
import { registerViewTools } from "./tools/view-tools.js";
|
|
32
32
|
import { registerPlanGraphTools } from "./tools/plan-graph-tools.js";
|
|
33
33
|
import { registerActivityTools } from "./tools/activity-tools.js";
|
|
34
|
+
import { registerDelegationTools } from "./tools/delegation-tools.js";
|
|
34
35
|
import { registerTrendsTools } from "./tools/trends-tools.js";
|
|
36
|
+
import { registerSreTools } from "./tools/sre-tools.js";
|
|
35
37
|
/**
|
|
36
38
|
* Initialize the GitHub client from environment variables.
|
|
37
39
|
*/
|
|
@@ -445,8 +447,12 @@ async function main() {
|
|
|
445
447
|
registerPlanGraphTools(server, client);
|
|
446
448
|
// Activity log reader (recent_activity tool — pure filesystem, no GitHub client)
|
|
447
449
|
registerActivityTools(server);
|
|
450
|
+
// Delegation telemetry reader (delegation_stats tool — pure filesystem, no GitHub client)
|
|
451
|
+
registerDelegationTools(server);
|
|
448
452
|
// Trends tools (capture_snapshot — JSONL persistence under ~/.ralph-hero/snapshots/)
|
|
449
453
|
registerTrendsTools(server, client, fieldCache);
|
|
454
|
+
// SRE operation tools (kubectl autoremediation — typed argv, no-shell invariant)
|
|
455
|
+
registerSreTools(server, client, fieldCache);
|
|
450
456
|
// Debug tools (only when RALPH_DEBUG=true)
|
|
451
457
|
if (process.env.RALPH_DEBUG === 'true') {
|
|
452
458
|
registerDebugTools(server, client);
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure read library for the local ralph-delegate JSONL audit log.
|
|
3
|
+
*
|
|
4
|
+
* The log lives at `~/.ralph-hero/delegate.log` (overridable via
|
|
5
|
+
* `RALPH_DELEGATE_LOG_PATH`). One JSON object per line, append-only,
|
|
6
|
+
* written by `plugin/ralph-hero/scripts/ralph-delegate.sh`. This library
|
|
7
|
+
* only reads — it never writes.
|
|
8
|
+
*
|
|
9
|
+
* Schema versioning: F1 (issue #1185) does NOT emit an explicit
|
|
10
|
+
* `schemaVersion` field on each line. F5 treats lines containing the
|
|
11
|
+
* required fields `{ts, task, status, ms}` as **implicit v1**. A future
|
|
12
|
+
* issue MAY add an explicit `schemaVersion >= 2` — when that happens, the
|
|
13
|
+
* shape-check should be expanded to honor it. TODO: revisit when the
|
|
14
|
+
* producer side emits `schemaVersion`.
|
|
15
|
+
*
|
|
16
|
+
* Determinism: pure functions. Filesystem reads are the only side effect.
|
|
17
|
+
* Missing log file resolves to a zero-state result with no throw, matching
|
|
18
|
+
* the activity.ts precedent (the steady state for opt-out users).
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from "node:fs/promises";
|
|
21
|
+
import * as os from "node:os";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Test hook + default path resolution
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
/**
|
|
27
|
+
* Optional override used only by tests. Production code never sets this.
|
|
28
|
+
* Mirrors `__setSnapshotRoot` in `snapshots.ts`.
|
|
29
|
+
*/
|
|
30
|
+
let delegateLogPathOverride = null;
|
|
31
|
+
/** Test hook: override the default log path. Pass `null` to restore. */
|
|
32
|
+
export function __setDelegateLogPath(path) {
|
|
33
|
+
delegateLogPathOverride = path;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the default log path: env var override, then `~/.ralph-hero/delegate.log`.
|
|
37
|
+
* Expands a leading `~/` (mirrors `ralph-delegate.sh:208-211`).
|
|
38
|
+
*/
|
|
39
|
+
export function defaultDelegationLogPath() {
|
|
40
|
+
if (delegateLogPathOverride !== null)
|
|
41
|
+
return delegateLogPathOverride;
|
|
42
|
+
const fromEnv = process.env.RALPH_DELEGATE_LOG_PATH;
|
|
43
|
+
if (fromEnv && fromEnv.length > 0) {
|
|
44
|
+
return expandHome(fromEnv);
|
|
45
|
+
}
|
|
46
|
+
return path.join(os.homedir(), ".ralph-hero", "delegate.log");
|
|
47
|
+
}
|
|
48
|
+
function expandHome(p) {
|
|
49
|
+
if (p.startsWith("~/")) {
|
|
50
|
+
return path.join(os.homedir(), p.slice(2));
|
|
51
|
+
}
|
|
52
|
+
return p;
|
|
53
|
+
}
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Reader
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
/**
|
|
58
|
+
* Read the delegation audit log. Returns a zero-state result with
|
|
59
|
+
* `fileExists: false` when the file is missing — never throws on ENOENT.
|
|
60
|
+
* Lines that fail JSON.parse OR lack required fields are skipped and
|
|
61
|
+
* counted in `skippedLines`; each skip emits a `console.warn` with a
|
|
62
|
+
* truncated prefix of the offending line.
|
|
63
|
+
*/
|
|
64
|
+
export async function readDelegationLog(config) {
|
|
65
|
+
const logPath = config.logPath;
|
|
66
|
+
let content;
|
|
67
|
+
try {
|
|
68
|
+
content = await fs.readFile(logPath, "utf8");
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
if (e &&
|
|
72
|
+
typeof e === "object" &&
|
|
73
|
+
"code" in e &&
|
|
74
|
+
e.code === "ENOENT") {
|
|
75
|
+
return { events: [], skippedLines: 0, fileExists: false, logPath };
|
|
76
|
+
}
|
|
77
|
+
throw e;
|
|
78
|
+
}
|
|
79
|
+
const events = [];
|
|
80
|
+
let skipped = 0;
|
|
81
|
+
for (const raw of content.split("\n")) {
|
|
82
|
+
const line = raw.trim();
|
|
83
|
+
if (line.length === 0)
|
|
84
|
+
continue;
|
|
85
|
+
let parsed;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(line);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
skipped++;
|
|
91
|
+
console.warn(`[delegation-log] Skipping malformed line in ${logPath}: ${line.slice(0, 80)}`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (!isDelegationEventShape(parsed)) {
|
|
95
|
+
skipped++;
|
|
96
|
+
console.warn(`[delegation-log] Skipping line with missing required fields in ${logPath}: ${line.slice(0, 80)}`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
events.push(parsed);
|
|
100
|
+
}
|
|
101
|
+
return { events, skippedLines: skipped, fileExists: true, logPath };
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Implicit-v1 shape check: presence of `{ts, task, status, ms}` with
|
|
105
|
+
* correct primitive types. A future explicit `schemaVersion >= 2` may
|
|
106
|
+
* extend this gate.
|
|
107
|
+
*/
|
|
108
|
+
function isDelegationEventShape(v) {
|
|
109
|
+
if (!v || typeof v !== "object")
|
|
110
|
+
return false;
|
|
111
|
+
const o = v;
|
|
112
|
+
return (typeof o.ts === "string" &&
|
|
113
|
+
typeof o.task === "string" &&
|
|
114
|
+
typeof o.status === "string" &&
|
|
115
|
+
typeof o.ms === "number");
|
|
116
|
+
}
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Aggregator
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
/**
|
|
121
|
+
* Aggregate parsed events into per-task + totals. Pure function — does
|
|
122
|
+
* no I/O. Percentiles use the nearest-rank method against successful
|
|
123
|
+
* (status=ok) calls only.
|
|
124
|
+
*/
|
|
125
|
+
export function aggregateDelegationStats(events) {
|
|
126
|
+
const byTask = {};
|
|
127
|
+
const okMsByTask = {};
|
|
128
|
+
let totalCalls = 0;
|
|
129
|
+
let totalFallbacks = 0;
|
|
130
|
+
let totalBytesIn = 0;
|
|
131
|
+
let totalBytesOut = 0;
|
|
132
|
+
for (const ev of events) {
|
|
133
|
+
const task = ev.task;
|
|
134
|
+
if (!byTask[task]) {
|
|
135
|
+
byTask[task] = {
|
|
136
|
+
calls: 0,
|
|
137
|
+
fallbacks: 0,
|
|
138
|
+
p50Ms: null,
|
|
139
|
+
p99Ms: null,
|
|
140
|
+
bytesIn: 0,
|
|
141
|
+
bytesOut: 0,
|
|
142
|
+
tokens: null,
|
|
143
|
+
};
|
|
144
|
+
okMsByTask[task] = [];
|
|
145
|
+
}
|
|
146
|
+
byTask[task].calls += 1;
|
|
147
|
+
totalCalls += 1;
|
|
148
|
+
if (isFallbackStatus(ev.status)) {
|
|
149
|
+
byTask[task].fallbacks += 1;
|
|
150
|
+
totalFallbacks += 1;
|
|
151
|
+
}
|
|
152
|
+
const bIn = typeof ev.bytes_in === "number" ? ev.bytes_in : 0;
|
|
153
|
+
const bOut = typeof ev.bytes_out === "number" ? ev.bytes_out : 0;
|
|
154
|
+
byTask[task].bytesIn += bIn;
|
|
155
|
+
byTask[task].bytesOut += bOut;
|
|
156
|
+
totalBytesIn += bIn;
|
|
157
|
+
totalBytesOut += bOut;
|
|
158
|
+
if (ev.status === "ok") {
|
|
159
|
+
okMsByTask[task].push(ev.ms);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Compute percentiles
|
|
163
|
+
for (const task of Object.keys(byTask)) {
|
|
164
|
+
const samples = okMsByTask[task];
|
|
165
|
+
byTask[task].p50Ms = percentile(samples, 0.5);
|
|
166
|
+
byTask[task].p99Ms = percentile(samples, 0.99);
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
totals: {
|
|
170
|
+
calls: totalCalls,
|
|
171
|
+
fallbacks: totalFallbacks,
|
|
172
|
+
bytesIn: totalBytesIn,
|
|
173
|
+
bytesOut: totalBytesOut,
|
|
174
|
+
},
|
|
175
|
+
byTask,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* A fallback is any non-`ok`, non-`dry_run` status: timeout, unreachable,
|
|
180
|
+
* parse_error, http_*. `dry_run` is operator-driven and not a real
|
|
181
|
+
* delegation failure, so it does not count.
|
|
182
|
+
*/
|
|
183
|
+
function isFallbackStatus(status) {
|
|
184
|
+
return status !== "ok" && status !== "dry_run";
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Nearest-rank percentile: `sorted[ceil(q * n) - 1]`. Returns `null`
|
|
188
|
+
* when the sample is empty.
|
|
189
|
+
*/
|
|
190
|
+
function percentile(samples, q) {
|
|
191
|
+
if (samples.length === 0)
|
|
192
|
+
return null;
|
|
193
|
+
const sorted = [...samples].sort((a, b) => a - b);
|
|
194
|
+
const rank = Math.ceil(q * sorted.length);
|
|
195
|
+
// Guard the edge case q=0 (would index -1)
|
|
196
|
+
const idx = Math.max(0, rank - 1);
|
|
197
|
+
return sorted[idx];
|
|
198
|
+
}
|
|
199
|
+
//# sourceMappingURL=delegation-log.js.map
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared kubectl execution helper for all SRE operation tools.
|
|
3
|
+
*
|
|
4
|
+
* INVARIANT: This module NEVER invokes a shell. All kubectl calls go through
|
|
5
|
+
* `child_process.execFile` with `shell: false` (the execFile default), which
|
|
6
|
+
* passes argv directly to execve(2). String-interpolated shell commands and
|
|
7
|
+
* `exec()` are explicitly forbidden here.
|
|
8
|
+
*
|
|
9
|
+
* The {@link FORBIDDEN_FLAGS} list provides a defense-in-depth check on top of
|
|
10
|
+
* the typed Zod schemas in sre-tools.ts. The typed schemas should already make
|
|
11
|
+
* these flags unreachable, but the helper enforces a hard floor so a future
|
|
12
|
+
* regression (e.g., a new typed param that happens to produce a forbidden flag)
|
|
13
|
+
* is caught at the exec layer, not silently allowed.
|
|
14
|
+
*/
|
|
15
|
+
import { execFile as _execFile } from "node:child_process";
|
|
16
|
+
import { promisify } from "node:util";
|
|
17
|
+
const execFileAsync = promisify(_execFile);
|
|
18
|
+
/**
|
|
19
|
+
* Flags that are unconditionally forbidden in kubectl argv.
|
|
20
|
+
*
|
|
21
|
+
* These represent destructive or resource-exhausting operations that the
|
|
22
|
+
* sre-fixit agent must never perform. They are defense-in-depth: the typed
|
|
23
|
+
* Zod schemas in sre-tools.ts make them structurally unreachable, but this
|
|
24
|
+
* list catches future regressions where a new typed param happens to produce
|
|
25
|
+
* one of these strings.
|
|
26
|
+
*/
|
|
27
|
+
export const FORBIDDEN_FLAGS = [
|
|
28
|
+
"--force",
|
|
29
|
+
"--cascade=foreground",
|
|
30
|
+
"--grace-period=0",
|
|
31
|
+
"--delete-emptydir-data",
|
|
32
|
+
];
|
|
33
|
+
/**
|
|
34
|
+
* Execute kubectl with the given argv array.
|
|
35
|
+
*
|
|
36
|
+
* Uses `child_process.execFile` (shell: false by default) so the argv is
|
|
37
|
+
* passed directly to execve(2) — no shell interpretation, no metacharacter
|
|
38
|
+
* expansion, no glob expansion.
|
|
39
|
+
*
|
|
40
|
+
* @param args - Typed argv array (e.g. `["scale", "--namespace", "default", ...]`).
|
|
41
|
+
* Never accepts a string command.
|
|
42
|
+
* @throws {Error} If any element of `args` matches a {@link FORBIDDEN_FLAGS} entry.
|
|
43
|
+
* @returns A typed result with `stdout`, `stderr`, and `exitCode`.
|
|
44
|
+
*/
|
|
45
|
+
export async function runKubectl(args) {
|
|
46
|
+
// Defense-in-depth: reject any argv element that matches a forbidden flag.
|
|
47
|
+
for (const arg of args) {
|
|
48
|
+
if (FORBIDDEN_FLAGS.includes(arg)) {
|
|
49
|
+
throw new Error(`kubectl argv contains forbidden flag: ${arg}. ` +
|
|
50
|
+
`Forbidden flags: ${FORBIDDEN_FLAGS.join(", ")}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const { stdout, stderr } = await execFileAsync("kubectl", args, {
|
|
55
|
+
shell: false,
|
|
56
|
+
});
|
|
57
|
+
return { stdout, stderr, exitCode: 0 };
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
// execFile rejects when the process exits with a non-zero code.
|
|
61
|
+
// The error object carries stdout/stderr from the failed run.
|
|
62
|
+
const e = err;
|
|
63
|
+
return {
|
|
64
|
+
stdout: e.stdout ?? "",
|
|
65
|
+
stderr: e.stderr ?? "",
|
|
66
|
+
exitCode: typeof e.code === "number" ? e.code : 1,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
//# sourceMappingURL=kubectl-exec.js.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registers the `ralph_hero__delegation_stats` MCP tool. Pure read-only
|
|
3
|
+
* surface over the JSONL audit log written by `ralph-delegate.sh` (F1).
|
|
4
|
+
*
|
|
5
|
+
* Follows the same registration convention as `activity-tools.ts`: no
|
|
6
|
+
* GitHub client argument, defaults pulled from env var with a homedir
|
|
7
|
+
* fallback, returns `toolSuccess` on missing-file (zero-state) so callers
|
|
8
|
+
* can render a dashboard without an error path.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { readDelegationLog, aggregateDelegationStats, defaultDelegationLogPath, } from "../lib/delegation-log.js";
|
|
12
|
+
import { toolSuccess, toolError } from "../types.js";
|
|
13
|
+
const TOKENS_REASON = "F1 audit-log does not capture token usage; bytes used as a proxy";
|
|
14
|
+
export function registerDelegationTools(server) {
|
|
15
|
+
server.tool("ralph_hero__delegation_stats", "Read-only telemetry over the local ralph-delegate JSONL audit log. Returns per-task call counts, fallback counts (non-ok/non-dry_run), p50/p99 latency from successful calls, and bytes_in/bytes_out aggregates. Reads RALPH_DELEGATE_LOG_PATH (default ~/.ralph-hero/delegate.log). Missing log file returns a zero-state result, never errors.", {
|
|
16
|
+
logPath: z
|
|
17
|
+
.string()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe("Optional override for the JSONL log path. Defaults to RALPH_DELEGATE_LOG_PATH or ~/.ralph-hero/delegate.log."),
|
|
20
|
+
}, async (params) => {
|
|
21
|
+
try {
|
|
22
|
+
const resolvedPath = params.logPath ?? defaultDelegationLogPath();
|
|
23
|
+
const read = await readDelegationLog({ logPath: resolvedPath });
|
|
24
|
+
const stats = aggregateDelegationStats(read.events);
|
|
25
|
+
return toolSuccess({
|
|
26
|
+
logPath: read.logPath,
|
|
27
|
+
fileExists: read.fileExists,
|
|
28
|
+
totals: {
|
|
29
|
+
calls: stats.totals.calls,
|
|
30
|
+
fallbacks: stats.totals.fallbacks,
|
|
31
|
+
bytesIn: stats.totals.bytesIn,
|
|
32
|
+
bytesOut: stats.totals.bytesOut,
|
|
33
|
+
skippedLines: read.skippedLines,
|
|
34
|
+
},
|
|
35
|
+
byTask: stats.byTask,
|
|
36
|
+
tokensReason: TOKENS_REASON,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
return toolError(err instanceof Error ? err.message : String(err));
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=delegation-tools.js.map
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SRE operation tools for kubectl autoremediation.
|
|
3
|
+
*
|
|
4
|
+
* This module registers the `ralph_hero__sre__*` family of typed MCP tools.
|
|
5
|
+
* Each tool accepts explicit, narrowly-typed parameters and delegates to the
|
|
6
|
+
* shared {@link runKubectl} helper from `../lib/kubectl-exec.ts`.
|
|
7
|
+
*
|
|
8
|
+
* INVARIANT (no-shell): All kubectl invocations go through `runKubectl`, which
|
|
9
|
+
* uses `child_process.execFile` with `shell: false`. There are NO string-
|
|
10
|
+
* interpolated shell commands, NO `exec()` calls, and NO `Bash` tool usage in
|
|
11
|
+
* this module or the agent that consumes it. Argv is always a plain array
|
|
12
|
+
* literal — never built via template strings, string concat, or user-controlled
|
|
13
|
+
* flag pass-through.
|
|
14
|
+
*
|
|
15
|
+
* Phases 2-5 of the GH-1285 plan add the four operation tool registrations
|
|
16
|
+
* (sre__scale, sre__rollout_restart, sre__delete_pod, sre__drain) inside
|
|
17
|
+
* `registerSreTools`. Phase 1 (GH-1287) establishes the module skeleton and
|
|
18
|
+
* wires the registration call in index.ts.
|
|
19
|
+
*/
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import { runKubectl } from "../lib/kubectl-exec.js";
|
|
22
|
+
import { toolSuccess, toolError } from "../types.js";
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Shared Zod field schemas
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
/**
|
|
27
|
+
* RFC 1123 label regex for Kubernetes namespace and deployment names.
|
|
28
|
+
* Intentionally rejects shell metacharacters, slashes, newlines, and empty
|
|
29
|
+
* strings as a single Zod check.
|
|
30
|
+
*/
|
|
31
|
+
const k8sLabelSchema = z.string().min(1).regex(/^[a-z0-9-]+$/);
|
|
32
|
+
/**
|
|
33
|
+
* Bounded replica count. Ceiling is 50 — configurable in a follow-up; the
|
|
34
|
+
* explicit ceiling prevents runaway scale-out from an LLM miscalculation.
|
|
35
|
+
*/
|
|
36
|
+
export const REPLICA_CEILING = 50;
|
|
37
|
+
const replicasSchema = z.number().int().min(0).max(REPLICA_CEILING);
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// sre__scale schemas
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
/**
|
|
42
|
+
* Raw Zod shape for the sre__scale tool.
|
|
43
|
+
* Passed to `server.tool()` which expects a `ZodRawShape` (plain object of
|
|
44
|
+
* Zod field schemas).
|
|
45
|
+
*/
|
|
46
|
+
const sreScaleShape = {
|
|
47
|
+
namespace: k8sLabelSchema.describe("Kubernetes namespace (RFC 1123 label: lowercase alphanumeric and hyphens only)."),
|
|
48
|
+
deployment: k8sLabelSchema.describe("Deployment name (RFC 1123 label: lowercase alphanumeric and hyphens only)."),
|
|
49
|
+
replicas: replicasSchema.describe(`Target replica count (integer, 0–${REPLICA_CEILING}).`),
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Strict Zod object schema for sre__scale parameters.
|
|
53
|
+
*
|
|
54
|
+
* Exported so adversarial-input tests (sre-tools.test.ts) can call
|
|
55
|
+
* `.safeParse()` directly. `.strict()` ensures unknown keys are rejected —
|
|
56
|
+
* any attempt to pass extra fields (e.g., a `flags` bypass) is caught at
|
|
57
|
+
* the schema level.
|
|
58
|
+
*/
|
|
59
|
+
export const sreScaleSchema = z.object(sreScaleShape).strict();
|
|
60
|
+
/**
|
|
61
|
+
* Build the kubectl argv array for a scale operation.
|
|
62
|
+
*
|
|
63
|
+
* Exported for unit-testing the argv shape independently of the MCP server.
|
|
64
|
+
* The argv is a plain array literal — no string interpolation, no concat.
|
|
65
|
+
*
|
|
66
|
+
* @param namespace - Validated Kubernetes namespace.
|
|
67
|
+
* @param deployment - Validated deployment name.
|
|
68
|
+
* @param replicas - Validated replica count.
|
|
69
|
+
*/
|
|
70
|
+
export function buildScaleArgv(namespace, deployment, replicas) {
|
|
71
|
+
return [
|
|
72
|
+
"scale",
|
|
73
|
+
"--namespace",
|
|
74
|
+
namespace,
|
|
75
|
+
"deployment",
|
|
76
|
+
deployment,
|
|
77
|
+
"--replicas",
|
|
78
|
+
String(replicas),
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// sre__rollout_restart schemas (Phase 3 / GH-1289)
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
/**
|
|
85
|
+
* Raw Zod shape for the sre__rollout_restart tool.
|
|
86
|
+
*/
|
|
87
|
+
const sreRolloutRestartShape = {
|
|
88
|
+
namespace: k8sLabelSchema.describe("Kubernetes namespace (RFC 1123 label: lowercase alphanumeric and hyphens only)."),
|
|
89
|
+
deployment: k8sLabelSchema.describe("Deployment name (RFC 1123 label: lowercase alphanumeric and hyphens only)."),
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* Strict Zod object schema for sre__rollout_restart parameters.
|
|
93
|
+
*
|
|
94
|
+
* Exported so adversarial-input tests (sre-tools.test.ts) can call
|
|
95
|
+
* `.safeParse()` directly. `.strict()` ensures unknown keys are rejected.
|
|
96
|
+
*/
|
|
97
|
+
export const sreRolloutRestartSchema = z.object(sreRolloutRestartShape).strict();
|
|
98
|
+
/**
|
|
99
|
+
* Build the kubectl argv array for a rollout restart operation.
|
|
100
|
+
*
|
|
101
|
+
* Exported for unit-testing the argv shape independently of the MCP server.
|
|
102
|
+
*
|
|
103
|
+
* NOTE — deliberate template-literal exception: This is the only argv builder
|
|
104
|
+
* in the sre__* family that uses a template literal. The `deployment/<name>`
|
|
105
|
+
* form is specific to `kubectl rollout restart`'s resource-qualified argument
|
|
106
|
+
* syntax. The interpolation is safe by construction: the `deployment` Zod
|
|
107
|
+
* schema (`/^[a-z0-9-]+$/`) forbids `/`, newlines, and shell metacharacters —
|
|
108
|
+
* the only characters that could escape the literal prefix. Do NOT generalise
|
|
109
|
+
* this pattern to other phases; phases 2, 4, and 5 keep plain array literals.
|
|
110
|
+
*
|
|
111
|
+
* @param namespace - Validated Kubernetes namespace.
|
|
112
|
+
* @param deployment - Validated deployment name.
|
|
113
|
+
*/
|
|
114
|
+
export function buildRolloutRestartArgv(namespace, deployment) {
|
|
115
|
+
return [
|
|
116
|
+
"rollout",
|
|
117
|
+
"restart",
|
|
118
|
+
"--namespace",
|
|
119
|
+
namespace,
|
|
120
|
+
`deployment/${deployment}`,
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// sre__delete_pod schemas (Phase 4 / GH-1290)
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
/**
|
|
127
|
+
* Raw Zod shape for the sre__delete_pod tool.
|
|
128
|
+
*
|
|
129
|
+
* The schema has exactly two fields — `namespace` and `pod`. There is no
|
|
130
|
+
* label-selector field, no `--force` field, and no `--grace-period` field.
|
|
131
|
+
* The absence of these fields is the primary typed-surface guarantee: there is
|
|
132
|
+
* no way to express bulk deletion, forced deletion, or grace-period override
|
|
133
|
+
* through this schema. `.strict()` rejects any extra keys the caller might
|
|
134
|
+
* try to pass.
|
|
135
|
+
*/
|
|
136
|
+
const sreDeletePodShape = {
|
|
137
|
+
namespace: k8sLabelSchema.describe("Kubernetes namespace (RFC 1123 label: lowercase alphanumeric and hyphens only)."),
|
|
138
|
+
pod: k8sLabelSchema.describe("Pod name to delete (RFC 1123 label: lowercase alphanumeric and hyphens only). " +
|
|
139
|
+
"Single pod only — no label selector, no wildcard."),
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* Strict Zod object schema for sre__delete_pod parameters.
|
|
143
|
+
*
|
|
144
|
+
* Exported so adversarial-input tests (sre-tools.test.ts) can call
|
|
145
|
+
* `.safeParse()` directly, including the `.strict()` no-label-selector
|
|
146
|
+
* assertion. `.strict()` ensures unknown keys (e.g., `selector: "app=foo"`)
|
|
147
|
+
* are rejected at the schema level.
|
|
148
|
+
*/
|
|
149
|
+
export const sreDeletePodSchema = z.object(sreDeletePodShape).strict();
|
|
150
|
+
/**
|
|
151
|
+
* Build the kubectl argv array for a delete-pod operation.
|
|
152
|
+
*
|
|
153
|
+
* Exported for unit-testing the argv shape independently of the MCP server.
|
|
154
|
+
* The argv is a plain array literal — no string interpolation, no concat.
|
|
155
|
+
*
|
|
156
|
+
* @param namespace - Validated Kubernetes namespace.
|
|
157
|
+
* @param pod - Validated pod name (single pod, no label selector).
|
|
158
|
+
*/
|
|
159
|
+
export function buildDeletePodArgv(namespace, pod) {
|
|
160
|
+
return ["delete", "pod", "--namespace", namespace, pod];
|
|
161
|
+
}
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// sre__drain schemas (Phase 5 / GH-1291)
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
/**
|
|
166
|
+
* Node name regex — slightly looser than the RFC 1123 label regex because
|
|
167
|
+
* Kubernetes node names can be in FQDN form (e.g., "node-1.us-east-1.example.com").
|
|
168
|
+
* The dot (`.`) is the only addition over the namespace/deployment regex.
|
|
169
|
+
* Intentionally rejects shell metacharacters, slashes, newlines, and empty
|
|
170
|
+
* strings as a single Zod check.
|
|
171
|
+
*/
|
|
172
|
+
const k8sNodeNameSchema = z.string().min(1).regex(/^[a-z0-9.-]+$/);
|
|
173
|
+
/**
|
|
174
|
+
* Bounded grace period in seconds. Minimum is 1 — gracePeriodSeconds=0 is
|
|
175
|
+
* equivalent to --force (immediate kill) and is explicitly forbidden by the
|
|
176
|
+
* plan's Shared Constraint #3. Maximum is 3600 (one hour).
|
|
177
|
+
*/
|
|
178
|
+
const gracePeriodSecondsSchema = z.number().int().min(1).max(3600);
|
|
179
|
+
/**
|
|
180
|
+
* Bounded timeout in seconds. Minimum is 1. Maximum is 3600 (one hour).
|
|
181
|
+
*/
|
|
182
|
+
const timeoutSecondsSchema = z.number().int().min(1).max(3600);
|
|
183
|
+
/**
|
|
184
|
+
* Raw Zod shape for the sre__drain tool.
|
|
185
|
+
*
|
|
186
|
+
* `--namespace` is intentionally absent: `kubectl drain` targets a node, which
|
|
187
|
+
* is a cluster-scoped resource. Do not add a namespace field — its absence is
|
|
188
|
+
* correct per the plan's Phase 5 note.
|
|
189
|
+
*
|
|
190
|
+
* `--ignore-daemonsets` is hard-coded into argv by the builder; it is NOT a
|
|
191
|
+
* user-controllable parameter. `--force` and `--delete-emptydir-data` are
|
|
192
|
+
* structurally unreachable through this schema.
|
|
193
|
+
*/
|
|
194
|
+
const sreDrainShape = {
|
|
195
|
+
node: k8sNodeNameSchema.describe("Node name to drain (RFC 1123 / FQDN form: lowercase alphanumeric, hyphens, and dots only)."),
|
|
196
|
+
gracePeriodSeconds: gracePeriodSecondsSchema.optional().describe("Grace period for evicting pods in seconds (integer, 1–3600). " +
|
|
197
|
+
"Omit to use the pod's default. " +
|
|
198
|
+
"0 is explicitly forbidden (equivalent to --force)."),
|
|
199
|
+
timeoutSeconds: timeoutSecondsSchema.optional().describe("Timeout for the drain operation in seconds (integer, 1–3600). " +
|
|
200
|
+
"Omit to use kubectl's default."),
|
|
201
|
+
};
|
|
202
|
+
/**
|
|
203
|
+
* Strict Zod object schema for sre__drain parameters.
|
|
204
|
+
*
|
|
205
|
+
* Exported so adversarial-input tests (sre-tools.test.ts) can call
|
|
206
|
+
* `.safeParse()` directly. `.strict()` ensures unknown keys are rejected —
|
|
207
|
+
* any attempt to pass extra fields (e.g., a `force` bypass) is caught at
|
|
208
|
+
* the schema level.
|
|
209
|
+
*/
|
|
210
|
+
export const sreDrainSchema = z.object(sreDrainShape).strict();
|
|
211
|
+
/**
|
|
212
|
+
* Build the kubectl argv array for a drain operation.
|
|
213
|
+
*
|
|
214
|
+
* Exported for unit-testing the argv shape independently of the MCP server.
|
|
215
|
+
* The argv is a plain array literal — no string interpolation, no concat
|
|
216
|
+
* (drain follows the same plain-array-literal pattern as phases 2 and 4).
|
|
217
|
+
*
|
|
218
|
+
* INVARIANTS (enforced by construction):
|
|
219
|
+
* - `--ignore-daemonsets` is always present (hard-coded, not user-controlled).
|
|
220
|
+
* - `--force` is never present (no schema field; helper also rejects it).
|
|
221
|
+
* - `--delete-emptydir-data` is never present (no schema field).
|
|
222
|
+
* - `--grace-period=0` is never present (gracePeriodSeconds min is 1 via Zod).
|
|
223
|
+
*
|
|
224
|
+
* @param node - Validated node name.
|
|
225
|
+
* @param gracePeriodSeconds - Optional validated grace period (seconds, >= 1).
|
|
226
|
+
* @param timeoutSeconds - Optional validated timeout (seconds, >= 1).
|
|
227
|
+
*/
|
|
228
|
+
export function buildDrainArgv(node, gracePeriodSeconds, timeoutSeconds) {
|
|
229
|
+
const argv = ["drain", node, "--ignore-daemonsets"];
|
|
230
|
+
if (gracePeriodSeconds !== undefined) {
|
|
231
|
+
argv.push("--grace-period", String(gracePeriodSeconds));
|
|
232
|
+
}
|
|
233
|
+
if (timeoutSeconds !== undefined) {
|
|
234
|
+
argv.push("--timeout", `${timeoutSeconds}s`);
|
|
235
|
+
}
|
|
236
|
+
return argv;
|
|
237
|
+
}
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
/**
|
|
240
|
+
* Register all SRE operation tools on the MCP server.
|
|
241
|
+
*
|
|
242
|
+
* The `client` and `fieldCache` parameters are accepted for API consistency
|
|
243
|
+
* with other `register*Tools` functions. They are unused in Phase 1 (scaffold
|
|
244
|
+
* only); Phases 2-5 may use `client` for issue context lookups if needed.
|
|
245
|
+
*
|
|
246
|
+
* @param server - The MCP server instance to register tools on.
|
|
247
|
+
* @param client - GitHub client (reserved for future phases).
|
|
248
|
+
* @param fieldCache - Field option cache (reserved for future phases).
|
|
249
|
+
*/
|
|
250
|
+
export function registerSreTools(server,
|
|
251
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
252
|
+
_client,
|
|
253
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
254
|
+
_fieldCache) {
|
|
255
|
+
// -------------------------------------------------------------------------
|
|
256
|
+
// ralph_hero__sre__scale (Phase 2 / GH-1288)
|
|
257
|
+
// -------------------------------------------------------------------------
|
|
258
|
+
server.tool("ralph_hero__sre__scale", "Scale a Kubernetes deployment to a specified replica count. " +
|
|
259
|
+
"Typed parameters only — no shell, no flag pass-through. " +
|
|
260
|
+
`Replica ceiling: ${REPLICA_CEILING}.`, sreScaleShape, async ({ namespace, deployment, replicas }) => {
|
|
261
|
+
const argv = buildScaleArgv(namespace, deployment, replicas);
|
|
262
|
+
try {
|
|
263
|
+
const result = await runKubectl(argv);
|
|
264
|
+
if (result.exitCode !== 0) {
|
|
265
|
+
return toolError(`kubectl scale failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);
|
|
266
|
+
}
|
|
267
|
+
return toolSuccess(result);
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
271
|
+
return toolError(`kubectl scale failed: ${message}`);
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
// -------------------------------------------------------------------------
|
|
275
|
+
// ralph_hero__sre__rollout_restart (Phase 3 / GH-1289)
|
|
276
|
+
// -------------------------------------------------------------------------
|
|
277
|
+
server.tool("ralph_hero__sre__rollout_restart", "Trigger a rolling restart of a Kubernetes deployment. " +
|
|
278
|
+
"Typed parameters only — no shell, no flag pass-through. " +
|
|
279
|
+
"Equivalent to: kubectl rollout restart deployment/<name> -n <namespace>.", sreRolloutRestartShape, async ({ namespace, deployment }) => {
|
|
280
|
+
const argv = buildRolloutRestartArgv(namespace, deployment);
|
|
281
|
+
try {
|
|
282
|
+
const result = await runKubectl(argv);
|
|
283
|
+
if (result.exitCode !== 0) {
|
|
284
|
+
return toolError(`kubectl rollout restart failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);
|
|
285
|
+
}
|
|
286
|
+
return toolSuccess(result);
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
290
|
+
return toolError(`kubectl rollout restart failed: ${message}`);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
// -------------------------------------------------------------------------
|
|
294
|
+
// ralph_hero__sre__delete_pod (Phase 4 / GH-1290)
|
|
295
|
+
// -------------------------------------------------------------------------
|
|
296
|
+
server.tool("ralph_hero__sre__delete_pod", "Delete a single named pod in a Kubernetes namespace. " +
|
|
297
|
+
"Single pod by name only — no label selector, no --force, no --grace-period=0. " +
|
|
298
|
+
"Typed parameters only — no shell, no flag pass-through.", sreDeletePodShape, async ({ namespace, pod }) => {
|
|
299
|
+
const argv = buildDeletePodArgv(namespace, pod);
|
|
300
|
+
try {
|
|
301
|
+
const result = await runKubectl(argv);
|
|
302
|
+
if (result.exitCode !== 0) {
|
|
303
|
+
return toolError(`kubectl delete pod failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);
|
|
304
|
+
}
|
|
305
|
+
return toolSuccess(result);
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
309
|
+
return toolError(`kubectl delete pod failed: ${message}`);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
// -------------------------------------------------------------------------
|
|
313
|
+
// ralph_hero__sre__drain (Phase 5 / GH-1291)
|
|
314
|
+
// -------------------------------------------------------------------------
|
|
315
|
+
server.tool("ralph_hero__sre__drain", "Drain a Kubernetes node by evicting all non-daemonset pods. " +
|
|
316
|
+
"--ignore-daemonsets is hard-coded on (always present). " +
|
|
317
|
+
"--force, --delete-emptydir-data, and --grace-period=0 are structurally unreachable. " +
|
|
318
|
+
"Cluster-scoped operation — no --namespace flag. " +
|
|
319
|
+
"Typed parameters only — no shell, no flag pass-through.", sreDrainShape, async ({ node, gracePeriodSeconds, timeoutSeconds }) => {
|
|
320
|
+
const argv = buildDrainArgv(node, gracePeriodSeconds, timeoutSeconds);
|
|
321
|
+
try {
|
|
322
|
+
const result = await runKubectl(argv);
|
|
323
|
+
if (result.exitCode !== 0) {
|
|
324
|
+
return toolError(`kubectl drain failed (exit ${result.exitCode}): ${result.stderr || result.stdout}`);
|
|
325
|
+
}
|
|
326
|
+
return toolSuccess(result);
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
330
|
+
return toolError(`kubectl drain failed: ${message}`);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
//# sourceMappingURL=sre-tools.js.map
|