kubeagent 0.1.4 → 0.1.6

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/cli.js CHANGED
@@ -3,6 +3,9 @@ import { Command } from "commander";
3
3
  import chalk from "chalk";
4
4
  import ora from "ora";
5
5
  import { homedir } from "node:os";
6
+ import { createRequire } from "node:module";
7
+ const require = createRequire(import.meta.url);
8
+ const { version } = require("../package.json");
6
9
  function expandPath(p) {
7
10
  return p.startsWith("~/") ? homedir() + p.slice(1) : p;
8
11
  }
@@ -11,6 +14,7 @@ import { onboard } from "./onboard/index.js";
11
14
  import { runChecks } from "./monitor/index.js";
12
15
  import { startMonitor } from "./monitor/index.js";
13
16
  import { handleIssues } from "./orchestrator.js";
17
+ import { sendResolve } from "./notify/index.js";
14
18
  import { diagnose } from "./diagnoser/index.js";
15
19
  import { buildSystemPrompt } from "./kb/loader.js";
16
20
  import { join } from "node:path";
@@ -25,7 +29,7 @@ const program = new Command();
25
29
  program
26
30
  .name("kubeagent")
27
31
  .description("AI-powered Kubernetes management CLI")
28
- .version("0.1.0");
32
+ .version(version);
29
33
  program
30
34
  .command("status")
31
35
  .description("Quick cluster health summary (no LLM)")
@@ -128,6 +132,8 @@ program
128
132
  }
129
133
  const { stop } = startMonitor({ context }, interval, async (issues) => {
130
134
  await handleIssues(issues, config, context, noInteractive);
135
+ }, async (resolvedKeys) => {
136
+ await sendResolve(resolvedKeys, config, context);
131
137
  });
132
138
  // Handle graceful shutdown
133
139
  process.on("SIGINT", () => {
package/dist/config.d.ts CHANGED
@@ -25,7 +25,20 @@ export interface TelegramChannel {
25
25
  severity: NotificationSeverity;
26
26
  label?: string;
27
27
  }
28
- export type NotificationChannel = SlackChannel | TelegramChannel;
28
+ export interface WebhookChannel {
29
+ type: "webhook";
30
+ url: string;
31
+ secret?: string;
32
+ severity: NotificationSeverity;
33
+ label?: string;
34
+ }
35
+ export interface PagerDutyChannel {
36
+ type: "pagerduty";
37
+ routing_key: string;
38
+ severity: NotificationSeverity;
39
+ label?: string;
40
+ }
41
+ export type NotificationChannel = SlackChannel | TelegramChannel | WebhookChannel | PagerDutyChannel;
29
42
  export declare const ALL_ACTIONS: readonly ["restart_pod", "rollout_restart", "scale_deployment", "set_resources"];
30
43
  export type RemediationAction = (typeof ALL_ACTIONS)[number];
31
44
  export interface KubeAgentConfig {
@@ -1,7 +1,10 @@
1
1
  import { type KubectlOptions } from "../kubectl.js";
2
2
  import type { Issue } from "./types.js";
3
3
  export type IssueCallback = (issues: Issue[]) => void | Promise<void>;
4
+ export type ResolveCallback = (resolvedKeys: string[]) => void | Promise<void>;
4
5
  export declare function runChecks(options: KubectlOptions): Promise<Issue[]>;
5
- export declare function startMonitor(options: KubectlOptions, intervalMs: number, onIssues: IssueCallback): {
6
+ export declare function computeResolvedKeys(activeKeys: Set<string>, currentKeys: Set<string>): string[];
7
+ export declare function updateActiveKeys(activeKeys: Set<string>, resolvedKeys: string[], currentKeys: Set<string>): void;
8
+ export declare function startMonitor(options: KubectlOptions, intervalMs: number, onIssues: IssueCallback, onResolved?: ResolveCallback): {
6
9
  stop: () => void;
7
10
  };
@@ -48,11 +48,21 @@ export async function runChecks(options) {
48
48
  }
49
49
  // How long a pod must be Pending before it's reported as an issue.
50
50
  const PENDING_GRACE_MS = 60_000;
51
- export function startMonitor(options, intervalMs, onIssues) {
51
+ export function computeResolvedKeys(activeKeys, currentKeys) {
52
+ return [...activeKeys].filter((k) => !currentKeys.has(k));
53
+ }
54
+ export function updateActiveKeys(activeKeys, resolvedKeys, currentKeys) {
55
+ for (const k of resolvedKeys)
56
+ activeKeys.delete(k);
57
+ for (const k of currentKeys)
58
+ activeKeys.add(k);
59
+ }
60
+ export function startMonitor(options, intervalMs, onIssues, onResolved) {
52
61
  let running = true;
53
62
  let inFlight = false;
54
63
  // Tracks when each pending pod was first seen: "namespace/name" -> Date
55
64
  const pendingSince = new Map();
65
+ const activeIssueKeys = new Set();
56
66
  let pendingRecheckTimer = null;
57
67
  const tick = async () => {
58
68
  if (!running || inFlight)
@@ -93,6 +103,18 @@ export function startMonitor(options, intervalMs, onIssues) {
93
103
  }, PENDING_GRACE_MS);
94
104
  }
95
105
  const reportableIssues = [...otherIssues, ...reportablePending];
106
+ // Resolve tracking
107
+ const currentKeys = new Set(reportableIssues.map((i) => `${i.kind}:${i.namespace}:${i.resource}`));
108
+ const resolvedKeys = computeResolvedKeys(activeIssueKeys, currentKeys);
109
+ updateActiveKeys(activeIssueKeys, resolvedKeys, currentKeys);
110
+ if (resolvedKeys.length > 0 && onResolved && running) {
111
+ try {
112
+ await onResolved(resolvedKeys);
113
+ }
114
+ catch (err) {
115
+ console.error("Resolve callback failed:", err.message);
116
+ }
117
+ }
96
118
  if (reportableIssues.length > 0 && running) {
97
119
  process.stdout.write("\r\x1b[K");
98
120
  await onIssues(reportableIssues);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { computeResolvedKeys, updateActiveKeys } from "./index.js";
3
+ describe("resolve tracking", () => {
4
+ it("returns keys that disappeared since last tick", () => {
5
+ const active = new Set(["pod_crashloop:prod:web", "pod_oom:prod:worker"]);
6
+ const current = new Set(["pod_oom:prod:worker"]);
7
+ expect(computeResolvedKeys(active, current)).toEqual(["pod_crashloop:prod:web"]);
8
+ });
9
+ it("returns empty array when nothing cleared", () => {
10
+ const active = new Set(["pod_crashloop:prod:web"]);
11
+ const current = new Set(["pod_crashloop:prod:web"]);
12
+ expect(computeResolvedKeys(active, current)).toEqual([]);
13
+ });
14
+ it("updates active set correctly after resolve", () => {
15
+ const active = new Set(["pod_crashloop:prod:web", "pod_oom:prod:worker"]);
16
+ const current = new Set(["pod_oom:prod:worker", "node_not_ready::node1"]);
17
+ const resolved = computeResolvedKeys(active, current);
18
+ updateActiveKeys(active, resolved, current);
19
+ expect([...active].sort()).toEqual(["node_not_ready::node1", "pod_oom:prod:worker"]);
20
+ });
21
+ });
@@ -2,4 +2,5 @@ import type { Issue } from "../monitor/types.js";
2
2
  import type { KubeAgentConfig, NotificationChannel } from "../config.js";
3
3
  export declare function sendNotification(issues: Issue[], config: KubeAgentConfig, clusterContext?: string): Promise<void>;
4
4
  export declare function broadcastQuestion(question: string, choices: string[] | undefined, config: KubeAgentConfig, clusterContext?: string): Promise<void>;
5
+ export declare function sendResolve(resolvedKeys: string[], config: KubeAgentConfig, clusterContext?: string): Promise<void>;
5
6
  export declare function describeChannel(channel: NotificationChannel): string;
@@ -1,5 +1,7 @@
1
1
  import { sendSlack, sendSlackQuestion } from "./slack.js";
2
2
  import { sendTelegram, sendTelegramQuestion } from "./telegram.js";
3
+ import { sendWebhook } from "./webhook.js";
4
+ import { sendPagerDuty, resolvePagerDuty } from "./pagerduty.js";
3
5
  const SEVERITY_ORDER = { info: 0, warning: 1, critical: 2 };
4
6
  export async function sendNotification(issues, config, clusterContext) {
5
7
  if (!config.notifications.channels.length)
@@ -16,6 +18,12 @@ export async function sendNotification(issues, config, clusterContext) {
16
18
  case "telegram":
17
19
  await sendTelegram(filtered, channel, clusterContext);
18
20
  break;
21
+ case "webhook":
22
+ await sendWebhook(filtered, channel, clusterContext);
23
+ break;
24
+ case "pagerduty":
25
+ await sendPagerDuty(filtered, channel, clusterContext);
26
+ break;
19
27
  }
20
28
  }));
21
29
  }
@@ -26,6 +34,19 @@ export async function broadcastQuestion(question, choices, config, clusterContex
26
34
  switch (channel.type) {
27
35
  case "slack": return sendSlackQuestion(channel, question, choices, clusterContext);
28
36
  case "telegram": return sendTelegramQuestion(channel, question, choices, clusterContext);
37
+ case "webhook":
38
+ case "pagerduty":
39
+ return;
40
+ }
41
+ }));
42
+ }
43
+ export async function sendResolve(resolvedKeys, config, clusterContext) {
44
+ if (!config.notifications.channels.length)
45
+ return;
46
+ const pdKeys = resolvedKeys.map((k) => `kubeagent:${k}`);
47
+ await Promise.all(config.notifications.channels.map(async (channel) => {
48
+ if (channel.type === "pagerduty") {
49
+ await resolvePagerDuty(pdKeys, channel);
29
50
  }
30
51
  }));
31
52
  }
@@ -36,5 +57,9 @@ export function describeChannel(channel) {
36
57
  return `Slack${label} ${channel.webhook_url.slice(0, 40)}… min: ${channel.severity}`;
37
58
  case "telegram":
38
59
  return `Telegram${label} chat: ${channel.chat_id} min: ${channel.severity}`;
60
+ case "webhook":
61
+ return `Webhook${label} ${channel.url.slice(0, 40)}… min: ${channel.severity}`;
62
+ case "pagerduty":
63
+ return `PagerDuty${label} key: ${channel.routing_key.slice(0, 8)}… min: ${channel.severity}`;
39
64
  }
40
65
  }
@@ -0,0 +1,9 @@
1
+ import type { Issue } from "../monitor/types.js";
2
+ import type { PagerDutyChannel } from "../config.js";
3
+ export declare function dedupKey(issue: Issue): string;
4
+ export declare function sendPagerDuty(issues: Issue[], channel: PagerDutyChannel, clusterContext?: string): Promise<void>;
5
+ export declare function resolvePagerDuty(dedupKeys: string[], channel: PagerDutyChannel): Promise<void>;
6
+ export declare function testPagerDutyCredentials(routingKey: string): Promise<{
7
+ ok: boolean;
8
+ error?: string;
9
+ }>;
@@ -0,0 +1,75 @@
1
+ const PD_ENDPOINT = "https://events.pagerduty.com/v2/enqueue";
2
+ export function dedupKey(issue) {
3
+ return `kubeagent:${issue.kind}:${issue.namespace}:${issue.resource}`;
4
+ }
5
+ async function postToPagerDuty(body) {
6
+ try {
7
+ const res = await fetch(PD_ENDPOINT, {
8
+ method: "POST",
9
+ headers: { "Content-Type": "application/json" },
10
+ body: JSON.stringify(body),
11
+ signal: AbortSignal.timeout(10_000),
12
+ });
13
+ if (!res.ok)
14
+ console.error(`PagerDuty: ${res.status} ${res.statusText}`);
15
+ }
16
+ catch (err) {
17
+ console.error(`PagerDuty error: ${err.message}`);
18
+ }
19
+ }
20
+ export async function sendPagerDuty(issues, channel, clusterContext) {
21
+ await Promise.all(issues.map((issue) => postToPagerDuty({
22
+ routing_key: channel.routing_key,
23
+ event_action: "trigger",
24
+ dedup_key: dedupKey(issue),
25
+ payload: {
26
+ summary: issue.message,
27
+ source: clusterContext ?? "kubeagent",
28
+ severity: issue.severity,
29
+ timestamp: issue.timestamp.toISOString(),
30
+ custom_details: {
31
+ namespace: issue.namespace,
32
+ resource: issue.resource,
33
+ kind: issue.kind,
34
+ },
35
+ },
36
+ })));
37
+ }
38
+ export async function resolvePagerDuty(dedupKeys, channel) {
39
+ await Promise.all(dedupKeys.map((key) => postToPagerDuty({
40
+ routing_key: channel.routing_key,
41
+ event_action: "resolve",
42
+ dedup_key: key,
43
+ })));
44
+ }
45
+ export async function testPagerDutyCredentials(routingKey) {
46
+ const testKey = "kubeagent-test";
47
+ try {
48
+ const res = await fetch(PD_ENDPOINT, {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json" },
51
+ body: JSON.stringify({
52
+ routing_key: routingKey,
53
+ event_action: "trigger",
54
+ dedup_key: testKey,
55
+ payload: { summary: "KubeAgent PagerDuty integration test", source: "kubeagent", severity: "info" },
56
+ }),
57
+ signal: AbortSignal.timeout(10_000),
58
+ });
59
+ if (!res.ok) {
60
+ const body = await res.json().catch(() => ({}));
61
+ return { ok: false, error: body.message ?? `HTTP ${res.status}` };
62
+ }
63
+ // Fire-and-forget cleanup — don't let resolve failure affect the result
64
+ fetch(PD_ENDPOINT, {
65
+ method: "POST",
66
+ headers: { "Content-Type": "application/json" },
67
+ body: JSON.stringify({ routing_key: routingKey, event_action: "resolve", dedup_key: testKey }),
68
+ signal: AbortSignal.timeout(10_000),
69
+ }).catch(() => { });
70
+ return { ok: true };
71
+ }
72
+ catch (err) {
73
+ return { ok: false, error: err.message };
74
+ }
75
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ const mockFetch = vi.fn();
3
+ vi.stubGlobal("fetch", mockFetch);
4
+ const channel = { type: "pagerduty", routing_key: "R0123456789abcdef", severity: "warning" };
5
+ const issue = { kind: "pod_crashloop", severity: "critical", namespace: "prod", resource: "api-web", message: "Pod crash-looping (3 times)", details: {}, timestamp: new Date("2026-01-01T00:00:00Z") };
6
+ describe("sendPagerDuty", () => {
7
+ beforeEach(() => {
8
+ mockFetch.mockReset();
9
+ mockFetch.mockResolvedValue({ ok: true, status: 202, json: async () => ({ status: "success" }) });
10
+ });
11
+ it("POSTs to PagerDuty Events API v2", async () => {
12
+ const { sendPagerDuty } = await import("./pagerduty.js");
13
+ await sendPagerDuty([issue], channel, "hetzner-prod");
14
+ expect(mockFetch.mock.calls[0][0]).toBe("https://events.pagerduty.com/v2/enqueue");
15
+ });
16
+ it("sends trigger with correct dedup_key", async () => {
17
+ const { sendPagerDuty } = await import("./pagerduty.js");
18
+ await sendPagerDuty([issue], channel);
19
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
20
+ expect(body.event_action).toBe("trigger");
21
+ expect(body.dedup_key).toBe("kubeagent:pod_crashloop:prod:api-web");
22
+ expect(body.routing_key).toBe("R0123456789abcdef");
23
+ });
24
+ it("maps critical severity correctly", async () => {
25
+ const { sendPagerDuty } = await import("./pagerduty.js");
26
+ await sendPagerDuty([{ ...issue, severity: "critical" }], channel);
27
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
28
+ expect(body.payload.severity).toBe("critical");
29
+ });
30
+ it("maps warning severity correctly", async () => {
31
+ const { sendPagerDuty } = await import("./pagerduty.js");
32
+ await sendPagerDuty([{ ...issue, severity: "warning" }], channel);
33
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
34
+ expect(body.payload.severity).toBe("warning");
35
+ });
36
+ it("maps info severity correctly", async () => {
37
+ const { sendPagerDuty } = await import("./pagerduty.js");
38
+ await sendPagerDuty([{ ...issue, severity: "info" }], channel);
39
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
40
+ expect(body.payload.severity).toBe("info");
41
+ });
42
+ it("fires one request per issue", async () => {
43
+ const { sendPagerDuty } = await import("./pagerduty.js");
44
+ await sendPagerDuty([issue, { ...issue, kind: "pod_oom", resource: "worker" }], channel);
45
+ expect(mockFetch).toHaveBeenCalledTimes(2);
46
+ });
47
+ it("does not throw on non-2xx", async () => {
48
+ mockFetch.mockResolvedValue({ ok: false, status: 400, statusText: "Bad Request", json: async () => ({}) });
49
+ const { sendPagerDuty } = await import("./pagerduty.js");
50
+ await expect(sendPagerDuty([issue], channel)).resolves.not.toThrow();
51
+ });
52
+ });
53
+ describe("resolvePagerDuty", () => {
54
+ beforeEach(() => {
55
+ mockFetch.mockReset();
56
+ mockFetch.mockResolvedValue({ ok: true, status: 202, json: async () => ({}) });
57
+ });
58
+ it("sends resolve events for each dedup key", async () => {
59
+ const { resolvePagerDuty } = await import("./pagerduty.js");
60
+ await resolvePagerDuty(["kubeagent:pod_crashloop:prod:api-web", "kubeagent:pod_oom:prod:worker"], channel);
61
+ expect(mockFetch).toHaveBeenCalledTimes(2);
62
+ const body0 = JSON.parse(mockFetch.mock.calls[0][1].body);
63
+ expect(body0.event_action).toBe("resolve");
64
+ expect(body0.dedup_key).toBe("kubeagent:pod_crashloop:prod:api-web");
65
+ });
66
+ it("does not throw on error", async () => {
67
+ mockFetch.mockRejectedValue(new Error("network failure"));
68
+ const { resolvePagerDuty } = await import("./pagerduty.js");
69
+ await expect(resolvePagerDuty(["kubeagent:pod_crashloop:prod:api-web"], channel)).resolves.not.toThrow();
70
+ });
71
+ });
72
+ describe("testPagerDutyCredentials", () => {
73
+ beforeEach(() => {
74
+ mockFetch.mockReset();
75
+ mockFetch.mockResolvedValue({ ok: true, status: 202, json: async () => ({}) });
76
+ });
77
+ it("returns ok:true when trigger succeeds", async () => {
78
+ const { testPagerDutyCredentials } = await import("./pagerduty.js");
79
+ const result = await testPagerDutyCredentials("R0123456789abcdef");
80
+ expect(result.ok).toBe(true);
81
+ expect(result.error).toBeUndefined();
82
+ });
83
+ it("returns ok:false with error when trigger fails", async () => {
84
+ mockFetch.mockResolvedValue({ ok: false, status: 400, statusText: "Bad Request", json: async () => ({ message: "Invalid routing key" }) });
85
+ const { testPagerDutyCredentials } = await import("./pagerduty.js");
86
+ const result = await testPagerDutyCredentials("bad-key");
87
+ expect(result.ok).toBe(false);
88
+ expect(result.error).toBe("Invalid routing key");
89
+ });
90
+ it("returns ok:false on network error", async () => {
91
+ mockFetch.mockRejectedValue(new Error("ECONNREFUSED"));
92
+ const { testPagerDutyCredentials } = await import("./pagerduty.js");
93
+ const result = await testPagerDutyCredentials("R0123456789abcdef");
94
+ expect(result.ok).toBe(false);
95
+ expect(result.error).toBe("ECONNREFUSED");
96
+ });
97
+ });
@@ -1,4 +1,7 @@
1
1
  import type { NotificationChannel, SlackChannel, TelegramChannel } from "../config.js";
2
+ import type { WebhookChannel, PagerDutyChannel } from "../config.js";
2
3
  export declare function setupSlack(): Promise<SlackChannel | null>;
3
4
  export declare function setupTelegram(): Promise<TelegramChannel | null>;
5
+ export declare function setupWebhook(): Promise<WebhookChannel | null>;
6
+ export declare function setupPagerDuty(): Promise<PagerDutyChannel | null>;
4
7
  export declare function interactiveAddChannel(): Promise<NotificationChannel | null>;
@@ -2,6 +2,11 @@ import readline from "node:readline";
2
2
  import chalk from "chalk";
3
3
  import { sendSlack } from "./slack.js";
4
4
  import { testTelegramCredentials, autoDetectTelegramChatId } from "./telegram.js";
5
+ import { testPagerDutyCredentials } from "./pagerduty.js";
6
+ import { sendWebhook } from "./webhook.js";
7
+ import { loadAuth } from "../auth.js";
8
+ import { registerWebhook } from "../proxy-client.js";
9
+ import { randomBytes } from "node:crypto";
5
10
  async function ask(question, hint) {
6
11
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
7
12
  const hintStr = hint ? chalk.dim(` (${hint})`) : "";
@@ -83,18 +88,86 @@ export async function setupTelegram() {
83
88
  }
84
89
  return { type: "telegram", bot_token, chat_id, severity, label: label || undefined };
85
90
  }
91
+ export async function setupWebhook() {
92
+ console.log(chalk.bold("\n Webhook Setup"));
93
+ console.log(chalk.dim(" POST JSON alerts to any HTTP endpoint.\n"));
94
+ const url = await ask("Webhook URL", "https://...");
95
+ if (!url)
96
+ return null;
97
+ const label = await ask("Label (optional)", "e.g. n8n-alerts");
98
+ const severity = await pickSeverity();
99
+ // Generate secret server-side if logged in, otherwise locally
100
+ let secret;
101
+ const auth = loadAuth();
102
+ if (auth?.apiKey) {
103
+ process.stdout.write(chalk.dim("\n Generating secret via KubeAgent server..."));
104
+ const result = await registerWebhook(auth, url, label || undefined, severity);
105
+ if (result.secret) {
106
+ secret = result.secret;
107
+ console.log(chalk.green(" done!"));
108
+ }
109
+ else {
110
+ secret = randomBytes(32).toString("hex");
111
+ console.log(chalk.yellow(" failed, generated locally."));
112
+ }
113
+ }
114
+ else {
115
+ secret = randomBytes(32).toString("hex");
116
+ }
117
+ console.log(chalk.bold.yellow("\n ⚠ Copy this Bearer token — it won't be shown again:"));
118
+ console.log(chalk.green(` ${secret}\n`));
119
+ console.log(chalk.dim(" Configure your endpoint to verify: Authorization: Bearer <token>\n"));
120
+ const channel = { type: "webhook", url, secret, severity, label: label || undefined };
121
+ process.stdout.write(chalk.dim(" Sending test payload..."));
122
+ await sendWebhook([{ kind: "pod_pending", severity: "warning", namespace: "test", resource: "kubeagent-test", message: "KubeAgent webhook integration working ✓", details: {}, timestamp: new Date() }], channel);
123
+ console.log(chalk.green(" sent!"));
124
+ const confirmed = await ask("Did you receive it? [y/N]");
125
+ if (confirmed.toLowerCase() !== "y") {
126
+ console.log(chalk.yellow(" Not confirmed — channel not saved."));
127
+ return null;
128
+ }
129
+ return channel;
130
+ }
131
+ export async function setupPagerDuty() {
132
+ console.log(chalk.bold("\n PagerDuty Setup"));
133
+ console.log(chalk.dim(" Get your routing key: PagerDuty → Services → Integrations → Events API v2\n"));
134
+ const routing_key = await ask("Routing key");
135
+ if (!routing_key)
136
+ return null;
137
+ const label = await ask("Label (optional)", "e.g. prod-cluster");
138
+ const severity = await pickSeverity();
139
+ process.stdout.write(chalk.dim("\n Sending test event to PagerDuty..."));
140
+ const result = await testPagerDutyCredentials(routing_key);
141
+ if (!result.ok) {
142
+ console.log(chalk.red(` failed: ${result.error}`));
143
+ return null;
144
+ }
145
+ console.log(chalk.green(` accepted (test event triggered and resolved)`));
146
+ const confirmed = await ask("Did you receive it in PagerDuty? [y/N]");
147
+ if (confirmed.toLowerCase() !== "y") {
148
+ console.log(chalk.yellow(" Not confirmed — channel not saved."));
149
+ return null;
150
+ }
151
+ return { type: "pagerduty", routing_key, severity, label: label || undefined };
152
+ }
86
153
  export async function interactiveAddChannel() {
87
154
  console.log(chalk.bold("\nAdd notification channel:\n"));
88
155
  console.log(` ${chalk.cyan("1")}. Slack`);
89
156
  console.log(` ${chalk.cyan("2")}. Telegram`);
90
- console.log(` ${chalk.cyan("3")}. Cancel\n`);
157
+ console.log(` ${chalk.cyan("3")}. Webhook`);
158
+ console.log(` ${chalk.cyan("4")}. PagerDuty`);
159
+ console.log(` ${chalk.cyan("5")}. Cancel\n`);
91
160
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
92
161
  const choice = await new Promise((resolve) => {
93
162
  rl.question(chalk.cyan(" Choice [1]: "), (a) => { rl.close(); resolve(a.trim()); });
94
163
  });
95
- if (choice === "3" || choice === "")
164
+ if (choice === "5" || choice === "")
96
165
  return null;
97
166
  if (choice === "2")
98
167
  return setupTelegram();
168
+ if (choice === "3")
169
+ return setupWebhook();
170
+ if (choice === "4")
171
+ return setupPagerDuty();
99
172
  return setupSlack();
100
173
  }
@@ -0,0 +1,3 @@
1
+ import type { Issue } from "../monitor/types.js";
2
+ import type { WebhookChannel } from "../config.js";
3
+ export declare function sendWebhook(issues: Issue[], channel: WebhookChannel, clusterContext?: string): Promise<void>;
@@ -0,0 +1,42 @@
1
+ export async function sendWebhook(issues, channel, clusterContext) {
2
+ try {
3
+ const url = new URL(channel.url);
4
+ if (!["https:", "http:"].includes(url.protocol)) {
5
+ console.error("Webhook: invalid URL protocol");
6
+ return;
7
+ }
8
+ }
9
+ catch {
10
+ console.error("Webhook: invalid URL");
11
+ return;
12
+ }
13
+ const payload = {
14
+ cluster: clusterContext ?? null,
15
+ timestamp: new Date().toISOString(),
16
+ issues: issues.map((i) => ({
17
+ kind: i.kind,
18
+ severity: i.severity,
19
+ namespace: i.namespace,
20
+ resource: i.resource,
21
+ message: i.message,
22
+ details: i.details,
23
+ timestamp: i.timestamp.toISOString(),
24
+ })),
25
+ };
26
+ const headers = { "Content-Type": "application/json" };
27
+ if (channel.secret)
28
+ headers["Authorization"] = `Bearer ${channel.secret}`;
29
+ try {
30
+ const res = await fetch(channel.url, {
31
+ method: "POST",
32
+ headers,
33
+ body: JSON.stringify(payload),
34
+ signal: AbortSignal.timeout(10_000),
35
+ });
36
+ if (!res.ok)
37
+ console.error(`Webhook: ${res.status} ${res.statusText}`);
38
+ }
39
+ catch (err) {
40
+ console.error(`Webhook error: ${err.message}`);
41
+ }
42
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,53 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ const mockFetch = vi.fn();
3
+ vi.stubGlobal("fetch", mockFetch);
4
+ const channel = { type: "webhook", url: "https://example.com/hook", secret: "testsecret", severity: "warning" };
5
+ const issue = { kind: "pod_crashloop", severity: "critical", namespace: "prod", resource: "api-web", message: "Pod crash-looping", details: { restartCount: 3 }, timestamp: new Date("2026-01-01T00:00:00Z") };
6
+ describe("sendWebhook", () => {
7
+ beforeEach(() => {
8
+ mockFetch.mockReset();
9
+ mockFetch.mockResolvedValue({ ok: true, status: 200, statusText: "OK" });
10
+ });
11
+ it("POSTs to the configured URL", async () => {
12
+ const { sendWebhook } = await import("./webhook.js");
13
+ await sendWebhook([issue], channel, "hetzner-prod");
14
+ expect(mockFetch).toHaveBeenCalledOnce();
15
+ expect(mockFetch.mock.calls[0][0]).toBe("https://example.com/hook");
16
+ expect(mockFetch.mock.calls[0][1].method).toBe("POST");
17
+ });
18
+ it("sends Authorization: Bearer header when secret is set", async () => {
19
+ const { sendWebhook } = await import("./webhook.js");
20
+ await sendWebhook([issue], channel, "hetzner-prod");
21
+ expect(mockFetch.mock.calls[0][1].headers["Authorization"]).toBe("Bearer testsecret");
22
+ });
23
+ it("omits Authorization header when no secret", async () => {
24
+ const noSecretChannel = { ...channel, secret: undefined };
25
+ const { sendWebhook } = await import("./webhook.js");
26
+ await sendWebhook([issue], noSecretChannel);
27
+ expect(mockFetch.mock.calls[0][1].headers["Authorization"]).toBeUndefined();
28
+ });
29
+ it("includes cluster, timestamp, and issues in payload", async () => {
30
+ const { sendWebhook } = await import("./webhook.js");
31
+ await sendWebhook([issue], channel, "hetzner-prod");
32
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body);
33
+ expect(body.cluster).toBe("hetzner-prod");
34
+ expect(body.issues).toHaveLength(1);
35
+ expect(body.issues[0].kind).toBe("pod_crashloop");
36
+ });
37
+ it("does not throw on non-2xx response", async () => {
38
+ mockFetch.mockResolvedValue({ ok: false, status: 500, statusText: "Error" });
39
+ const { sendWebhook } = await import("./webhook.js");
40
+ await expect(sendWebhook([issue], channel)).resolves.not.toThrow();
41
+ });
42
+ it("does not throw on network error", async () => {
43
+ mockFetch.mockRejectedValue(new Error("network failure"));
44
+ const { sendWebhook } = await import("./webhook.js");
45
+ await expect(sendWebhook([issue], channel)).resolves.not.toThrow();
46
+ });
47
+ it("rejects invalid URL without throwing", async () => {
48
+ const badChannel = { ...channel, url: "not-a-url" };
49
+ const { sendWebhook } = await import("./webhook.js");
50
+ await expect(sendWebhook([issue], badChannel)).resolves.not.toThrow();
51
+ expect(mockFetch).not.toHaveBeenCalled();
52
+ });
53
+ });
@@ -13,3 +13,6 @@ export declare function reportIncident(auth: AuthState, incident: {
13
13
  actionTaken?: string;
14
14
  verified?: boolean;
15
15
  }): Promise<void>;
16
+ export declare function registerWebhook(auth: AuthState, url: string, label: string | undefined, severity: string): Promise<{
17
+ secret?: string;
18
+ }>;
@@ -70,3 +70,22 @@ export async function reportIncident(auth, incident) {
70
70
  body: JSON.stringify(incident),
71
71
  });
72
72
  }
73
+ export async function registerWebhook(auth, url, label, severity) {
74
+ const apiKey = auth.apiKey ?? auth.token;
75
+ try {
76
+ const res = await fetch(`${auth.serverUrl}/integrations/webhook`, {
77
+ method: "POST",
78
+ headers: {
79
+ "Content-Type": "application/json",
80
+ Authorization: `ApiKey ${apiKey}`,
81
+ },
82
+ body: JSON.stringify({ url, label, severity }),
83
+ });
84
+ if (!res.ok)
85
+ return {};
86
+ return (await res.json());
87
+ }
88
+ catch {
89
+ return {};
90
+ }
91
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kubeagent",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "AI-powered Kubernetes management CLI",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "type": "module",