kubeagent 0.1.5 → 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 +3 -0
- package/dist/config.d.ts +14 -1
- package/dist/monitor/index.d.ts +4 -1
- package/dist/monitor/index.js +23 -1
- package/dist/monitor/resolve.test.d.ts +1 -0
- package/dist/monitor/resolve.test.js +21 -0
- package/dist/notify/index.d.ts +1 -0
- package/dist/notify/index.js +25 -0
- package/dist/notify/pagerduty.d.ts +9 -0
- package/dist/notify/pagerduty.js +75 -0
- package/dist/notify/pagerduty.test.d.ts +1 -0
- package/dist/notify/pagerduty.test.js +97 -0
- package/dist/notify/setup.d.ts +3 -0
- package/dist/notify/setup.js +75 -2
- package/dist/notify/webhook.d.ts +3 -0
- package/dist/notify/webhook.js +42 -0
- package/dist/notify/webhook.test.d.ts +1 -0
- package/dist/notify/webhook.test.js +53 -0
- package/dist/proxy-client.d.ts +3 -0
- package/dist/proxy-client.js +19 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -14,6 +14,7 @@ import { onboard } from "./onboard/index.js";
|
|
|
14
14
|
import { runChecks } from "./monitor/index.js";
|
|
15
15
|
import { startMonitor } from "./monitor/index.js";
|
|
16
16
|
import { handleIssues } from "./orchestrator.js";
|
|
17
|
+
import { sendResolve } from "./notify/index.js";
|
|
17
18
|
import { diagnose } from "./diagnoser/index.js";
|
|
18
19
|
import { buildSystemPrompt } from "./kb/loader.js";
|
|
19
20
|
import { join } from "node:path";
|
|
@@ -131,6 +132,8 @@ program
|
|
|
131
132
|
}
|
|
132
133
|
const { stop } = startMonitor({ context }, interval, async (issues) => {
|
|
133
134
|
await handleIssues(issues, config, context, noInteractive);
|
|
135
|
+
}, async (resolvedKeys) => {
|
|
136
|
+
await sendResolve(resolvedKeys, config, context);
|
|
134
137
|
});
|
|
135
138
|
// Handle graceful shutdown
|
|
136
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
|
|
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 {
|
package/dist/monitor/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
};
|
package/dist/monitor/index.js
CHANGED
|
@@ -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
|
|
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
|
+
});
|
package/dist/notify/index.d.ts
CHANGED
|
@@ -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;
|
package/dist/notify/index.js
CHANGED
|
@@ -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
|
+
});
|
package/dist/notify/setup.d.ts
CHANGED
|
@@ -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>;
|
package/dist/notify/setup.js
CHANGED
|
@@ -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")}.
|
|
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 === "
|
|
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,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
|
+
});
|
package/dist/proxy-client.d.ts
CHANGED
|
@@ -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
|
+
}>;
|
package/dist/proxy-client.js
CHANGED
|
@@ -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
|
+
}
|