kubeagent 0.1.35 → 0.1.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/dist/auth.js +15 -0
- package/dist/check/index.d.ts +34 -0
- package/dist/check/index.js +77 -0
- package/dist/check/index.test.d.ts +1 -0
- package/dist/check/index.test.js +202 -0
- package/dist/check/run.d.ts +10 -0
- package/dist/check/run.js +114 -0
- package/dist/cli.js +131 -0
- package/dist/detector/cache.d.ts +6 -0
- package/dist/detector/cache.js +44 -0
- package/dist/detector/cache.test.d.ts +1 -0
- package/dist/detector/cache.test.js +54 -0
- package/dist/detector/catalog.d.ts +17 -0
- package/dist/detector/catalog.js +88 -0
- package/dist/detector/catalog.test.d.ts +1 -0
- package/dist/detector/catalog.test.js +33 -0
- package/dist/detector/eol.d.ts +13 -0
- package/dist/detector/eol.js +64 -0
- package/dist/detector/eol.test.d.ts +1 -0
- package/dist/detector/eol.test.js +78 -0
- package/dist/detector/image-parser.d.ts +11 -0
- package/dist/detector/image-parser.js +59 -0
- package/dist/detector/image-parser.test.d.ts +1 -0
- package/dist/detector/image-parser.test.js +87 -0
- package/dist/detector/index.d.ts +33 -0
- package/dist/detector/index.js +217 -0
- package/dist/detector/index.test.d.ts +1 -0
- package/dist/detector/index.test.js +210 -0
- package/dist/detector/osv.d.ts +12 -0
- package/dist/detector/osv.js +50 -0
- package/dist/detector/osv.test.d.ts +1 -0
- package/dist/detector/osv.test.js +86 -0
- package/dist/detector/report.d.ts +6 -0
- package/dist/detector/report.js +91 -0
- package/dist/detector/sources.d.ts +55 -0
- package/dist/detector/sources.js +218 -0
- package/dist/detector/sources.test.d.ts +1 -0
- package/dist/detector/sources.test.js +36 -0
- package/dist/diagnoser/approval.test.d.ts +1 -0
- package/dist/diagnoser/approval.test.js +49 -0
- package/dist/diagnoser/index.d.ts +16 -0
- package/dist/diagnoser/index.js +37 -11
- package/dist/diagnoser/tools.d.ts +2 -2
- package/dist/kb/loader.js +6 -0
- package/dist/kb/writer.d.ts +1 -0
- package/dist/kb/writer.js +4 -0
- package/dist/monitor/index.d.ts +11 -1
- package/dist/monitor/index.js +45 -14
- package/dist/monitor/scope.test.d.ts +1 -0
- package/dist/monitor/scope.test.js +87 -0
- package/dist/notify/discord.js +6 -9
- package/dist/notify/ssrf-guard.d.ts +5 -0
- package/dist/notify/ssrf-guard.js +150 -0
- package/dist/notify/teams.js +6 -9
- package/dist/notify/webhook.js +7 -9
- package/dist/notify/webhook.test.js +21 -0
- package/dist/onboard/index.js +23 -1
- package/dist/orchestrator.js +2 -1
- package/dist/telemetry.js +27 -1
- package/package.json +10 -1
package/README.md
CHANGED
|
@@ -27,8 +27,10 @@ kubeagent watch
|
|
|
27
27
|
| `status` | Quick cluster health check (no LLM) |
|
|
28
28
|
| `onboard` | Scan cluster + codebases, generate knowledge base |
|
|
29
29
|
| `watch` | Continuous monitoring with auto-remediation |
|
|
30
|
+
| `check` | One-shot cluster health check for CI pipelines (read-only by default, JSON output, exit codes) |
|
|
30
31
|
| `diagnose <resource>` | One-shot diagnosis of a pod/deployment/service |
|
|
31
32
|
| `scan <directory>` | Match local project directories to cluster deployments |
|
|
33
|
+
| `scan-apps` | Detect OSS apps in the cluster, list affected workloads + ingress domains, check for CVEs / EOL (`--report` for AI-prioritized remediation) |
|
|
32
34
|
| `notify` | Manage notification channels (`list`, `add`, `remove`, `test`) |
|
|
33
35
|
| `login` | Log in to KubeAgent (browser or `--device` for headless) |
|
|
34
36
|
| `account` | Show account info and token balance |
|
|
@@ -42,6 +44,30 @@ kubeagent watch
|
|
|
42
44
|
- `-i, --interval <seconds>` — Check interval for `watch` (default: 300)
|
|
43
45
|
- `--no-interactive` — Auto-deny all approvals, skip questions (for `watch` in background/CI)
|
|
44
46
|
|
|
47
|
+
## CI Usage
|
|
48
|
+
|
|
49
|
+
`kubeagent check` is the one-shot mode for CI pipelines. It scans the cluster, prints a report, and exits with a code your build can gate on — no daemon, no prompts, no onboarding required.
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# After deploying: confirm pods reached Ready
|
|
53
|
+
kubectl rollout status deployment/my-app -n my-ns --timeout=5m
|
|
54
|
+
|
|
55
|
+
# Then confirm the system is actually healthy
|
|
56
|
+
kubeagent check -n my-ns
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The two steps are complementary: `rollout status` answers *"did Kubernetes accept the deploy?"*, `kubeagent check` answers *"is the system actually healthy?"* — catching crashloops, evicted pods, and failed jobs that surface seconds after pods reach Ready.
|
|
60
|
+
|
|
61
|
+
**Exit codes:** `0` = no issues at/above threshold · `1` = issues found · `2` = kubeagent error (unreachable cluster, bad flag, timeout).
|
|
62
|
+
|
|
63
|
+
**Common flags:**
|
|
64
|
+
- `-n, --namespace <ns>` — scope to one or more namespaces (repeatable)
|
|
65
|
+
- `--fail-on <severity>` — fail-threshold: `info`, `warning`, or `critical` (default: `critical`)
|
|
66
|
+
- `--format json` — emit a versioned JSON report instead of human output
|
|
67
|
+
- `--diagnose` — run LLM root-cause analysis on findings (requires `KUBEAGENT_API_KEY` env var)
|
|
68
|
+
|
|
69
|
+
See [docs/CI.md](docs/CI.md) for full reference, GitHub Actions and GitLab CI snippets, and the JSON schema.
|
|
70
|
+
|
|
45
71
|
## Knowledge Base
|
|
46
72
|
|
|
47
73
|
KubeAgent builds a per-cluster knowledge base at `~/.kubeagent/clusters/<context>/` during onboarding:
|
|
@@ -52,6 +78,7 @@ KubeAgent builds a per-cluster knowledge base at `~/.kubeagent/clusters/<context
|
|
|
52
78
|
clusters/
|
|
53
79
|
hetzner-prod/
|
|
54
80
|
cluster.md # Nodes, namespaces, deployments, services
|
|
81
|
+
applications.md # Detected OSS apps with CVE / EOL status
|
|
55
82
|
projects/
|
|
56
83
|
api.md # Tech stack, dependencies, notes
|
|
57
84
|
runbooks/ # Custom runbooks (manually added)
|
|
@@ -122,6 +149,13 @@ Safe actions (pod restarts, rollout restarts, scaling up) are applied automatica
|
|
|
122
149
|
**Where is my API key stored?**
|
|
123
150
|
Credentials are stored locally at `~/.kubeagent/auth.json` and are never sent anywhere except the KubeAgent API.
|
|
124
151
|
|
|
152
|
+
## Links
|
|
153
|
+
|
|
154
|
+
- [Homepage](https://kubeagent.net)
|
|
155
|
+
- [Blog](https://kubeagent.net/blog/) — incident walkthroughs, agentic-diagnosis patterns, on-call economics ([RSS](https://kubeagent.net/blog/rss.xml))
|
|
156
|
+
- [Docs](https://kubeagent.net/docs)
|
|
157
|
+
- [Sign up](https://app.kubeagent.net/register)
|
|
158
|
+
|
|
125
159
|
## License
|
|
126
160
|
|
|
127
161
|
Proprietary — see [LICENSE](./LICENSE) for details. Commercial use requires a paid plan at [kubeagent.net](https://kubeagent.net).
|
package/dist/auth.js
CHANGED
|
@@ -7,7 +7,22 @@ import { dbg } from "./debug.js";
|
|
|
7
7
|
function authPath() {
|
|
8
8
|
return join(configDir(), "auth.json");
|
|
9
9
|
}
|
|
10
|
+
const DEFAULT_SERVER_URL = "https://api.kubeagent.net";
|
|
11
|
+
const DEFAULT_APP_URL = "https://app.kubeagent.net";
|
|
10
12
|
export function loadAuth() {
|
|
13
|
+
// Env-var path for CI: KUBEAGENT_API_KEY takes precedence so machines can
|
|
14
|
+
// run without a local auth.json. KUBEAGENT_SERVER_URL / KUBEAGENT_APP_URL
|
|
15
|
+
// are optional overrides for self-hosted setups.
|
|
16
|
+
const envKey = process.env.KUBEAGENT_API_KEY;
|
|
17
|
+
if (envKey) {
|
|
18
|
+
dbg("auth", "loaded from env", { keyPrefix: envKey.slice(0, 12) + "..." });
|
|
19
|
+
return {
|
|
20
|
+
serverUrl: process.env.KUBEAGENT_SERVER_URL ?? DEFAULT_SERVER_URL,
|
|
21
|
+
appUrl: process.env.KUBEAGENT_APP_URL ?? DEFAULT_APP_URL,
|
|
22
|
+
token: envKey,
|
|
23
|
+
apiKey: envKey,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
11
26
|
const path = authPath();
|
|
12
27
|
if (!existsSync(path)) {
|
|
13
28
|
dbg("auth", "no auth file found", { path });
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Issue, Severity } from "../monitor/types.js";
|
|
2
|
+
export declare const SEVERITY_RANK: Record<Severity, number>;
|
|
3
|
+
export declare function isValidSeverity(s: string): s is Severity;
|
|
4
|
+
export interface CheckReportSummary {
|
|
5
|
+
critical: number;
|
|
6
|
+
warning: number;
|
|
7
|
+
info: number;
|
|
8
|
+
podCount: number;
|
|
9
|
+
nodeCount: number;
|
|
10
|
+
}
|
|
11
|
+
export interface CheckReport {
|
|
12
|
+
version: 1;
|
|
13
|
+
context: string;
|
|
14
|
+
namespaces: string[] | "all";
|
|
15
|
+
scannedAt: string;
|
|
16
|
+
durationMs: number;
|
|
17
|
+
summary: CheckReportSummary;
|
|
18
|
+
issues: Issue[];
|
|
19
|
+
diagnosis: {
|
|
20
|
+
analysis: string;
|
|
21
|
+
} | null;
|
|
22
|
+
exit: {
|
|
23
|
+
code: 0 | 1;
|
|
24
|
+
reason: string;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export declare function filterIssuesByNamespace(issues: Issue[], namespaces: string[] | "all"): Issue[];
|
|
28
|
+
export declare function summarizeIssues(issues: Issue[], podCount: number, nodeCount: number): CheckReportSummary;
|
|
29
|
+
export declare function computeExitCode(summary: CheckReportSummary, failOn: Severity): {
|
|
30
|
+
code: 0 | 1;
|
|
31
|
+
reason: string;
|
|
32
|
+
};
|
|
33
|
+
export declare function renderJson(report: CheckReport): string;
|
|
34
|
+
export declare function renderHuman(report: CheckReport): string;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
export const SEVERITY_RANK = {
|
|
3
|
+
info: 0,
|
|
4
|
+
warning: 1,
|
|
5
|
+
critical: 2,
|
|
6
|
+
};
|
|
7
|
+
export function isValidSeverity(s) {
|
|
8
|
+
return s === "info" || s === "warning" || s === "critical";
|
|
9
|
+
}
|
|
10
|
+
export function filterIssuesByNamespace(issues, namespaces) {
|
|
11
|
+
if (namespaces === "all" || namespaces.length === 0)
|
|
12
|
+
return issues;
|
|
13
|
+
const set = new Set(namespaces);
|
|
14
|
+
return issues.filter((i) => set.has(i.namespace));
|
|
15
|
+
}
|
|
16
|
+
export function summarizeIssues(issues, podCount, nodeCount) {
|
|
17
|
+
return {
|
|
18
|
+
critical: issues.filter((i) => i.severity === "critical").length,
|
|
19
|
+
warning: issues.filter((i) => i.severity === "warning").length,
|
|
20
|
+
info: issues.filter((i) => i.severity === "info").length,
|
|
21
|
+
podCount,
|
|
22
|
+
nodeCount,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function computeExitCode(summary, failOn) {
|
|
26
|
+
const threshold = SEVERITY_RANK[failOn];
|
|
27
|
+
const triggered = [];
|
|
28
|
+
if (SEVERITY_RANK.critical >= threshold && summary.critical > 0)
|
|
29
|
+
triggered.push("critical");
|
|
30
|
+
if (SEVERITY_RANK.warning >= threshold && summary.warning > 0)
|
|
31
|
+
triggered.push("warning");
|
|
32
|
+
if (SEVERITY_RANK.info >= threshold && summary.info > 0)
|
|
33
|
+
triggered.push("info");
|
|
34
|
+
if (triggered.length === 0) {
|
|
35
|
+
return { code: 0, reason: `no issues at or above fail-on=${failOn}` };
|
|
36
|
+
}
|
|
37
|
+
return { code: 1, reason: `fail-on=${failOn} threshold met (${triggered.join(", ")})` };
|
|
38
|
+
}
|
|
39
|
+
export function renderJson(report) {
|
|
40
|
+
return JSON.stringify({
|
|
41
|
+
...report,
|
|
42
|
+
issues: report.issues.map((i) => ({
|
|
43
|
+
kind: i.kind,
|
|
44
|
+
severity: i.severity,
|
|
45
|
+
namespace: i.namespace,
|
|
46
|
+
resource: i.resource,
|
|
47
|
+
message: i.message,
|
|
48
|
+
details: i.details,
|
|
49
|
+
})),
|
|
50
|
+
}, null, 2);
|
|
51
|
+
}
|
|
52
|
+
export function renderHuman(report) {
|
|
53
|
+
const lines = [];
|
|
54
|
+
const nsLabel = report.namespaces === "all" ? "all" : report.namespaces.join(", ");
|
|
55
|
+
lines.push(`KubeAgent check — context: ${chalk.cyan(report.context)} namespace: ${chalk.cyan(nsLabel)}`);
|
|
56
|
+
const { summary, durationMs } = report;
|
|
57
|
+
const headlineIcon = summary.critical > 0 ? chalk.red("✖") : summary.warning > 0 ? chalk.yellow("⚠") : chalk.green("✔");
|
|
58
|
+
const headlineMsg = summary.critical + summary.warning + summary.info === 0
|
|
59
|
+
? "all clear"
|
|
60
|
+
: `${summary.critical} critical, ${summary.warning} warnings, ${summary.info} info`;
|
|
61
|
+
lines.push(`${headlineIcon} ${headlineMsg} ${chalk.dim(`(scanned ${summary.podCount} pods, ${summary.nodeCount} nodes in ${(durationMs / 1000).toFixed(1)}s)`)}`);
|
|
62
|
+
if (report.issues.length > 0) {
|
|
63
|
+
lines.push("");
|
|
64
|
+
for (const issue of report.issues) {
|
|
65
|
+
const sevColor = issue.severity === "critical" ? chalk.red : issue.severity === "warning" ? chalk.yellow : chalk.dim;
|
|
66
|
+
lines.push(` ${sevColor(issue.severity.padEnd(8))} ${issue.namespace} ${chalk.dim(issue.kind.padEnd(16))} ${issue.resource} ${issue.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (report.diagnosis) {
|
|
70
|
+
lines.push("");
|
|
71
|
+
lines.push(chalk.bold("Diagnosis:"));
|
|
72
|
+
lines.push(report.diagnosis.analysis);
|
|
73
|
+
}
|
|
74
|
+
lines.push("");
|
|
75
|
+
lines.push(chalk.dim(`Exit: ${report.exit.code} (${report.exit.reason})`));
|
|
76
|
+
return lines.join("\n");
|
|
77
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { computeExitCode, filterIssuesByNamespace, isValidSeverity, renderHuman, renderJson, summarizeIssues, } from "./index.js";
|
|
3
|
+
function makeIssue(overrides = {}) {
|
|
4
|
+
return {
|
|
5
|
+
kind: "pod_crashloop",
|
|
6
|
+
severity: "critical",
|
|
7
|
+
namespace: "default",
|
|
8
|
+
resource: "test-pod",
|
|
9
|
+
message: "CrashLoopBackOff",
|
|
10
|
+
details: {},
|
|
11
|
+
timestamp: new Date("2026-05-28T10:00:00Z"),
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
describe("isValidSeverity", () => {
|
|
16
|
+
it("accepts the three valid severities", () => {
|
|
17
|
+
expect(isValidSeverity("info")).toBe(true);
|
|
18
|
+
expect(isValidSeverity("warning")).toBe(true);
|
|
19
|
+
expect(isValidSeverity("critical")).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
it("rejects anything else", () => {
|
|
22
|
+
expect(isValidSeverity("bogus")).toBe(false);
|
|
23
|
+
expect(isValidSeverity("")).toBe(false);
|
|
24
|
+
expect(isValidSeverity("CRITICAL")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe("filterIssuesByNamespace", () => {
|
|
28
|
+
const issues = [
|
|
29
|
+
makeIssue({ namespace: "foo" }),
|
|
30
|
+
makeIssue({ namespace: "bar" }),
|
|
31
|
+
makeIssue({ namespace: "baz" }),
|
|
32
|
+
];
|
|
33
|
+
it("returns all issues when scope is 'all'", () => {
|
|
34
|
+
expect(filterIssuesByNamespace(issues, "all")).toHaveLength(3);
|
|
35
|
+
});
|
|
36
|
+
it("returns all issues when namespace list is empty", () => {
|
|
37
|
+
expect(filterIssuesByNamespace(issues, [])).toHaveLength(3);
|
|
38
|
+
});
|
|
39
|
+
it("filters to the specified namespaces", () => {
|
|
40
|
+
const result = filterIssuesByNamespace(issues, ["foo", "baz"]);
|
|
41
|
+
expect(result.map((i) => i.namespace)).toEqual(["foo", "baz"]);
|
|
42
|
+
});
|
|
43
|
+
it("returns empty when no namespaces match", () => {
|
|
44
|
+
expect(filterIssuesByNamespace(issues, ["nope"])).toHaveLength(0);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe("summarizeIssues", () => {
|
|
48
|
+
it("counts each severity", () => {
|
|
49
|
+
const issues = [
|
|
50
|
+
makeIssue({ severity: "critical" }),
|
|
51
|
+
makeIssue({ severity: "critical" }),
|
|
52
|
+
makeIssue({ severity: "warning" }),
|
|
53
|
+
makeIssue({ severity: "info" }),
|
|
54
|
+
];
|
|
55
|
+
const s = summarizeIssues(issues, 10, 3);
|
|
56
|
+
expect(s).toEqual({ critical: 2, warning: 1, info: 1, podCount: 10, nodeCount: 3 });
|
|
57
|
+
});
|
|
58
|
+
it("zeros when no issues", () => {
|
|
59
|
+
expect(summarizeIssues([], 5, 2)).toEqual({
|
|
60
|
+
critical: 0,
|
|
61
|
+
warning: 0,
|
|
62
|
+
info: 0,
|
|
63
|
+
podCount: 5,
|
|
64
|
+
nodeCount: 2,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
describe("computeExitCode", () => {
|
|
69
|
+
const empty = { critical: 0, warning: 0, info: 0, podCount: 0, nodeCount: 0 };
|
|
70
|
+
it("exits 0 when no issues at any threshold", () => {
|
|
71
|
+
expect(computeExitCode(empty, "critical").code).toBe(0);
|
|
72
|
+
expect(computeExitCode(empty, "warning").code).toBe(0);
|
|
73
|
+
expect(computeExitCode(empty, "info").code).toBe(0);
|
|
74
|
+
});
|
|
75
|
+
it("fail-on=critical only fails on critical", () => {
|
|
76
|
+
expect(computeExitCode({ ...empty, critical: 1 }, "critical").code).toBe(1);
|
|
77
|
+
expect(computeExitCode({ ...empty, warning: 5 }, "critical").code).toBe(0);
|
|
78
|
+
expect(computeExitCode({ ...empty, info: 10 }, "critical").code).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
it("fail-on=warning fails on warning or critical", () => {
|
|
81
|
+
expect(computeExitCode({ ...empty, critical: 1 }, "warning").code).toBe(1);
|
|
82
|
+
expect(computeExitCode({ ...empty, warning: 1 }, "warning").code).toBe(1);
|
|
83
|
+
expect(computeExitCode({ ...empty, info: 1 }, "warning").code).toBe(0);
|
|
84
|
+
});
|
|
85
|
+
it("fail-on=info fails on anything", () => {
|
|
86
|
+
expect(computeExitCode({ ...empty, info: 1 }, "info").code).toBe(1);
|
|
87
|
+
expect(computeExitCode({ ...empty, warning: 1 }, "info").code).toBe(1);
|
|
88
|
+
expect(computeExitCode({ ...empty, critical: 1 }, "info").code).toBe(1);
|
|
89
|
+
});
|
|
90
|
+
it("reason mentions all triggered severities", () => {
|
|
91
|
+
const result = computeExitCode({ ...empty, critical: 1, warning: 1 }, "warning");
|
|
92
|
+
expect(result.code).toBe(1);
|
|
93
|
+
expect(result.reason).toContain("critical");
|
|
94
|
+
expect(result.reason).toContain("warning");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe("renderJson", () => {
|
|
98
|
+
function makeReport(overrides = {}) {
|
|
99
|
+
return {
|
|
100
|
+
version: 1,
|
|
101
|
+
context: "hetzner-prod",
|
|
102
|
+
namespaces: ["kubeagent"],
|
|
103
|
+
scannedAt: "2026-05-28T10:00:00.000Z",
|
|
104
|
+
durationMs: 1200,
|
|
105
|
+
summary: { critical: 1, warning: 0, info: 0, podCount: 14, nodeCount: 3 },
|
|
106
|
+
issues: [
|
|
107
|
+
makeIssue({
|
|
108
|
+
namespace: "kubeagent",
|
|
109
|
+
resource: "kubeagent-server-7d9-xj2",
|
|
110
|
+
message: "CrashLoopBackOff (5 restarts)",
|
|
111
|
+
details: { restartCount: 5 },
|
|
112
|
+
}),
|
|
113
|
+
],
|
|
114
|
+
diagnosis: null,
|
|
115
|
+
exit: { code: 1, reason: "fail-on=critical threshold met (critical)" },
|
|
116
|
+
...overrides,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
it("emits versioned schema", () => {
|
|
120
|
+
const parsed = JSON.parse(renderJson(makeReport()));
|
|
121
|
+
expect(parsed.version).toBe(1);
|
|
122
|
+
});
|
|
123
|
+
it("strips timestamp from issues (not part of v1 schema)", () => {
|
|
124
|
+
const parsed = JSON.parse(renderJson(makeReport()));
|
|
125
|
+
expect(parsed.issues[0]).not.toHaveProperty("timestamp");
|
|
126
|
+
expect(parsed.issues[0].kind).toBe("pod_crashloop");
|
|
127
|
+
expect(parsed.issues[0].details.restartCount).toBe(5);
|
|
128
|
+
});
|
|
129
|
+
it("emits diagnosis: null when not run", () => {
|
|
130
|
+
const parsed = JSON.parse(renderJson(makeReport()));
|
|
131
|
+
expect(parsed.diagnosis).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
it("includes diagnosis when present", () => {
|
|
134
|
+
const parsed = JSON.parse(renderJson(makeReport({ diagnosis: { analysis: "Root cause: DB unreachable" } })));
|
|
135
|
+
expect(parsed.diagnosis.analysis).toContain("DB unreachable");
|
|
136
|
+
});
|
|
137
|
+
it("handles empty issues", () => {
|
|
138
|
+
const parsed = JSON.parse(renderJson(makeReport({
|
|
139
|
+
issues: [],
|
|
140
|
+
summary: { critical: 0, warning: 0, info: 0, podCount: 14, nodeCount: 3 },
|
|
141
|
+
exit: { code: 0, reason: "no issues at or above fail-on=critical" },
|
|
142
|
+
})));
|
|
143
|
+
expect(parsed.issues).toEqual([]);
|
|
144
|
+
expect(parsed.exit.code).toBe(0);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
describe("renderHuman", () => {
|
|
148
|
+
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
149
|
+
function makeReport(overrides = {}) {
|
|
150
|
+
return {
|
|
151
|
+
version: 1,
|
|
152
|
+
context: "hetzner-prod",
|
|
153
|
+
namespaces: "all",
|
|
154
|
+
scannedAt: "2026-05-28T10:00:00.000Z",
|
|
155
|
+
durationMs: 1200,
|
|
156
|
+
summary: { critical: 0, warning: 0, info: 0, podCount: 14, nodeCount: 3 },
|
|
157
|
+
issues: [],
|
|
158
|
+
diagnosis: null,
|
|
159
|
+
exit: { code: 0, reason: "no issues at or above fail-on=critical" },
|
|
160
|
+
...overrides,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
it("shows 'all clear' when no issues", () => {
|
|
164
|
+
const out = stripAnsi(renderHuman(makeReport()));
|
|
165
|
+
expect(out).toContain("all clear");
|
|
166
|
+
expect(out).toContain("Exit: 0");
|
|
167
|
+
});
|
|
168
|
+
it("renders critical issues with severity column", () => {
|
|
169
|
+
const out = stripAnsi(renderHuman(makeReport({
|
|
170
|
+
summary: { critical: 1, warning: 0, info: 0, podCount: 14, nodeCount: 3 },
|
|
171
|
+
issues: [
|
|
172
|
+
{
|
|
173
|
+
kind: "pod_crashloop",
|
|
174
|
+
severity: "critical",
|
|
175
|
+
namespace: "kubeagent",
|
|
176
|
+
resource: "server-xj2",
|
|
177
|
+
message: "CrashLoopBackOff",
|
|
178
|
+
details: {},
|
|
179
|
+
timestamp: new Date(),
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
exit: { code: 1, reason: "fail-on=critical threshold met (critical)" },
|
|
183
|
+
})));
|
|
184
|
+
expect(out).toContain("1 critical");
|
|
185
|
+
expect(out).toContain("server-xj2");
|
|
186
|
+
expect(out).toContain("CrashLoopBackOff");
|
|
187
|
+
expect(out).toContain("Exit: 1");
|
|
188
|
+
});
|
|
189
|
+
it("shows diagnosis block when present", () => {
|
|
190
|
+
const out = stripAnsi(renderHuman(makeReport({ diagnosis: { analysis: "Root cause analysis here" } })));
|
|
191
|
+
expect(out).toContain("Diagnosis");
|
|
192
|
+
expect(out).toContain("Root cause analysis here");
|
|
193
|
+
});
|
|
194
|
+
it("shows 'all' for cluster-wide scope", () => {
|
|
195
|
+
const out = stripAnsi(renderHuman(makeReport({ namespaces: "all" })));
|
|
196
|
+
expect(out).toContain("namespace: all");
|
|
197
|
+
});
|
|
198
|
+
it("shows namespace list for scoped runs", () => {
|
|
199
|
+
const out = stripAnsi(renderHuman(makeReport({ namespaces: ["foo", "bar"] })));
|
|
200
|
+
expect(out).toContain("foo, bar");
|
|
201
|
+
});
|
|
202
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface CheckCliOptions {
|
|
2
|
+
context?: string;
|
|
3
|
+
namespace?: string[];
|
|
4
|
+
allNamespaces?: boolean;
|
|
5
|
+
failOn: string;
|
|
6
|
+
format: string;
|
|
7
|
+
diagnose?: boolean;
|
|
8
|
+
timeout: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function runCheck(opts: CheckCliOptions): Promise<void>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { kubectl } from "../kubectl.js";
|
|
3
|
+
import { runChecks } from "../monitor/index.js";
|
|
4
|
+
import { computeExitCode, isValidSeverity, renderHuman, renderJson, summarizeIssues, } from "./index.js";
|
|
5
|
+
class CheckError extends Error {
|
|
6
|
+
hint;
|
|
7
|
+
constructor(message, hint) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.hint = hint;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function emitError(message, hint) {
|
|
13
|
+
process.stderr.write(chalk.red(`✖ ${message}\n`));
|
|
14
|
+
if (hint)
|
|
15
|
+
process.stderr.write(chalk.dim(` ${hint}\n`));
|
|
16
|
+
process.exit(2);
|
|
17
|
+
}
|
|
18
|
+
export async function runCheck(opts) {
|
|
19
|
+
// --- flag validation ---
|
|
20
|
+
if (!isValidSeverity(opts.failOn)) {
|
|
21
|
+
emitError(`invalid --fail-on value "${opts.failOn}"`, "expected one of: info, warning, critical");
|
|
22
|
+
}
|
|
23
|
+
if (opts.format !== "human" && opts.format !== "json") {
|
|
24
|
+
emitError(`invalid --format value "${opts.format}"`, "expected one of: human, json");
|
|
25
|
+
}
|
|
26
|
+
const timeoutSec = Number.parseInt(opts.timeout, 10);
|
|
27
|
+
if (!Number.isFinite(timeoutSec) || timeoutSec <= 0) {
|
|
28
|
+
emitError(`invalid --timeout value "${opts.timeout}"`, "expected a positive integer (seconds)");
|
|
29
|
+
}
|
|
30
|
+
if (opts.diagnose && !process.env.KUBEAGENT_API_KEY) {
|
|
31
|
+
emitError("--diagnose requires KUBEAGENT_API_KEY env var", "set it as a CI secret, e.g. in GitHub Actions: env: { KUBEAGENT_API_KEY: ${{ secrets.KUBEAGENT_API_KEY }} }");
|
|
32
|
+
}
|
|
33
|
+
const failOn = opts.failOn;
|
|
34
|
+
const namespaces = opts.allNamespaces || !opts.namespace || opts.namespace.length === 0
|
|
35
|
+
? "all"
|
|
36
|
+
: opts.namespace;
|
|
37
|
+
// --- run with timeout wrapper ---
|
|
38
|
+
const startedAt = Date.now();
|
|
39
|
+
let timeoutHandle;
|
|
40
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
41
|
+
timeoutHandle = setTimeout(() => reject(new CheckError(`check timed out after ${timeoutSec}s`)), timeoutSec * 1000);
|
|
42
|
+
});
|
|
43
|
+
try {
|
|
44
|
+
const report = await Promise.race([
|
|
45
|
+
doCheck({ failOn, namespaces, format: opts.format, context: opts.context, diagnose: !!opts.diagnose, startedAt }),
|
|
46
|
+
timeoutPromise,
|
|
47
|
+
]);
|
|
48
|
+
if (timeoutHandle)
|
|
49
|
+
clearTimeout(timeoutHandle);
|
|
50
|
+
const output = opts.format === "json" ? renderJson(report) : renderHuman(report);
|
|
51
|
+
process.stdout.write(output + "\n");
|
|
52
|
+
process.exit(report.exit.code);
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
if (timeoutHandle)
|
|
56
|
+
clearTimeout(timeoutHandle);
|
|
57
|
+
const message = err.message || "check failed";
|
|
58
|
+
if (err instanceof CheckError) {
|
|
59
|
+
emitError(message, err.hint);
|
|
60
|
+
}
|
|
61
|
+
emitError(message);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function doCheck(args) {
|
|
65
|
+
const { failOn, namespaces, context, diagnose: shouldDiagnose, startedAt } = args;
|
|
66
|
+
const kubectlOpts = context ? { context } : {};
|
|
67
|
+
// Preflight: confirm the cluster is reachable so we fail fast with exit 2
|
|
68
|
+
// rather than a confusing kubectl error mid-scan.
|
|
69
|
+
try {
|
|
70
|
+
await kubectl(["cluster-info", "--request-timeout=5s"], kubectlOpts);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
throw new CheckError(`cannot reach cluster: ${err.message}`, "check your kubeconfig and --context flag");
|
|
74
|
+
}
|
|
75
|
+
// Resolve effective context name for the report (kubectl current-context
|
|
76
|
+
// is fine when --context not passed).
|
|
77
|
+
let effectiveContext = context ?? "(current-context)";
|
|
78
|
+
if (!context) {
|
|
79
|
+
try {
|
|
80
|
+
effectiveContext = (await kubectl(["config", "current-context"])).trim();
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// non-fatal — keep the placeholder
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Scope kubectl queries to the requested namespaces so namespace-bound
|
|
87
|
+
// CI service accounts don't need cluster-wide list permissions.
|
|
88
|
+
const scope = namespaces === "all" ? undefined : { namespaces };
|
|
89
|
+
const summaryResult = await runChecks(kubectlOpts, true, scope);
|
|
90
|
+
const filteredIssues = summaryResult.issues;
|
|
91
|
+
const summary = summarizeIssues(filteredIssues, summaryResult.podCount, summaryResult.nodeCount);
|
|
92
|
+
let diagnosis = null;
|
|
93
|
+
if (shouldDiagnose && filteredIssues.length > 0) {
|
|
94
|
+
const { diagnose } = await import("../diagnoser/index.js");
|
|
95
|
+
const result = await diagnose(filteredIssues, "", context, {
|
|
96
|
+
autoFix: false,
|
|
97
|
+
noInteractive: true,
|
|
98
|
+
onApproval: async () => false,
|
|
99
|
+
});
|
|
100
|
+
diagnosis = { analysis: result.analysis };
|
|
101
|
+
}
|
|
102
|
+
const exit = computeExitCode(summary, failOn);
|
|
103
|
+
return {
|
|
104
|
+
version: 1,
|
|
105
|
+
context: effectiveContext,
|
|
106
|
+
namespaces,
|
|
107
|
+
scannedAt: new Date(startedAt).toISOString(),
|
|
108
|
+
durationMs: Date.now() - startedAt,
|
|
109
|
+
summary,
|
|
110
|
+
issues: filteredIssues,
|
|
111
|
+
diagnosis,
|
|
112
|
+
exit,
|
|
113
|
+
};
|
|
114
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -170,6 +170,20 @@ program
|
|
|
170
170
|
// Keep process alive
|
|
171
171
|
await new Promise(() => { });
|
|
172
172
|
});
|
|
173
|
+
program
|
|
174
|
+
.command("check")
|
|
175
|
+
.description("One-shot cluster health check (CI-friendly, read-only by default)")
|
|
176
|
+
.option("-c, --context <context>", "Kubernetes context (defaults to kubeconfig current-context)")
|
|
177
|
+
.option("-n, --namespace <namespace>", "Limit scope to namespace (repeatable)", (val, prev) => [...(prev ?? []), val], undefined)
|
|
178
|
+
.option("--all-namespaces", "Scan all namespaces (default; overrides -n if both set)")
|
|
179
|
+
.option("--fail-on <severity>", "Exit non-zero on issues >= severity (info|warning|critical)", "critical")
|
|
180
|
+
.option("--format <fmt>", "Output format: human|json", "human")
|
|
181
|
+
.option("--diagnose", "Run LLM analysis on findings (requires KUBEAGENT_API_KEY)")
|
|
182
|
+
.option("--timeout <seconds>", "Max time for the check itself", "60")
|
|
183
|
+
.action(async (opts) => {
|
|
184
|
+
const { runCheck } = await import("./check/run.js");
|
|
185
|
+
await runCheck(opts);
|
|
186
|
+
});
|
|
173
187
|
program
|
|
174
188
|
.command("diagnose <resource>")
|
|
175
189
|
.description("One-shot diagnosis of a specific pod/deployment/service")
|
|
@@ -328,6 +342,123 @@ program
|
|
|
328
342
|
}
|
|
329
343
|
}
|
|
330
344
|
});
|
|
345
|
+
program
|
|
346
|
+
.command("scan-apps")
|
|
347
|
+
.description("Detect open-source apps in the cluster and check for CVEs / EOL versions")
|
|
348
|
+
.option("-c, --context <context>", "Kubernetes context")
|
|
349
|
+
.option("--no-cache", "Force refresh of CVE/EOL data")
|
|
350
|
+
.option("--json", "Print JSON result instead of writing the knowledge base")
|
|
351
|
+
.option("--report", "Also generate an AI-prioritized remediation report (uses tokens)")
|
|
352
|
+
.action(async (opts) => {
|
|
353
|
+
const { detectApplications, formatApplicationsMarkdown } = await import("./detector/index.js");
|
|
354
|
+
const { writeApplicationsKb, ensureKbDir } = await import("./kb/writer.js");
|
|
355
|
+
const { kubectl } = await import("./kubectl.js");
|
|
356
|
+
let contextName = opts.context;
|
|
357
|
+
if (!contextName) {
|
|
358
|
+
try {
|
|
359
|
+
contextName = (await kubectl(["config", "current-context"])).trim();
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
contextName = "default";
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const spinner = ora("Scanning cluster for OSS applications...").start();
|
|
366
|
+
let result;
|
|
367
|
+
try {
|
|
368
|
+
result = await detectApplications({
|
|
369
|
+
context: contextName,
|
|
370
|
+
noCache: opts.cache === false,
|
|
371
|
+
onProgress: (step) => {
|
|
372
|
+
spinner.text = `Scanning cluster for OSS applications — ${step}...`;
|
|
373
|
+
},
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
spinner.fail(`Scan failed: ${err.message}`);
|
|
378
|
+
process.exit(1);
|
|
379
|
+
}
|
|
380
|
+
const eolCount = result.apps.filter((a) => a.eol?.eol).length;
|
|
381
|
+
const cveCount = result.apps.filter((a) => a.cves.length > 0).length;
|
|
382
|
+
spinner.succeed(`Inventoried ${result.apps.length} app${result.apps.length === 1 ? "" : "s"} ` +
|
|
383
|
+
`(${eolCount} EOL, ${cveCount} with CVEs, ${result.unknowns.length} unmatched)`);
|
|
384
|
+
if (opts.json) {
|
|
385
|
+
console.log(JSON.stringify(result, null, 2));
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const kbDir = join(configDir(), "clusters", contextName);
|
|
389
|
+
ensureKbDir(kbDir);
|
|
390
|
+
writeApplicationsKb(kbDir, formatApplicationsMarkdown(result));
|
|
391
|
+
console.log(chalk.dim(`Inventory written to ${join(kbDir, "applications.md")}`));
|
|
392
|
+
if (result.apps.length === 0 && result.unknowns.length === 0) {
|
|
393
|
+
console.log(chalk.dim("No detectable OSS applications found."));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const renderWorkloads = (workloads) => {
|
|
397
|
+
for (const w of workloads) {
|
|
398
|
+
const hosts = w.hosts && w.hosts.length > 0 ? chalk.cyan(` ${w.hosts.join(", ")}`) : "";
|
|
399
|
+
console.log(chalk.dim(` ↳ ${w.kind}/${w.namespace}/${w.name}`) + hosts);
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
console.log();
|
|
403
|
+
for (const a of result.apps) {
|
|
404
|
+
if (a.eol?.eol) {
|
|
405
|
+
const since = a.eol.eolDate ? ` since ${a.eol.eolDate}` : "";
|
|
406
|
+
const latest = a.eol.latest ? chalk.dim(` (latest: ${a.eol.latest})`) : "";
|
|
407
|
+
console.log(chalk.red(`[critical] ${a.display} ${a.version} — EOL${since}`) +
|
|
408
|
+
latest +
|
|
409
|
+
chalk.dim(` ${a.workloads.length} workload(s)`));
|
|
410
|
+
renderWorkloads(a.workloads);
|
|
411
|
+
}
|
|
412
|
+
else if (a.cves.length > 0) {
|
|
413
|
+
const ids = a.cves.slice(0, 3).map((c) => c.id).join(", ");
|
|
414
|
+
const more = a.cves.length > 3 ? ` + ${a.cves.length - 3} more` : "";
|
|
415
|
+
console.log(chalk.yellow(`[warning] ${a.display} ${a.version} — ${a.cves.length} CVE(s): ${ids}${more}`) +
|
|
416
|
+
chalk.dim(` ${a.workloads.length} workload(s)`));
|
|
417
|
+
renderWorkloads(a.workloads);
|
|
418
|
+
}
|
|
419
|
+
else {
|
|
420
|
+
console.log(chalk.green(`[info] ${a.display} ${a.version} — no known CVEs`) +
|
|
421
|
+
chalk.dim(` ${a.workloads.length} workload(s)`));
|
|
422
|
+
renderWorkloads(a.workloads);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
if (opts.report) {
|
|
426
|
+
if (result.apps.length === 0) {
|
|
427
|
+
console.log(chalk.dim("\nNo apps to analyze — skipping report."));
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
const auth = loadAuth();
|
|
431
|
+
if (!auth?.apiKey) {
|
|
432
|
+
console.error(chalk.red("\n--report requires login. Run: kubeagent login"));
|
|
433
|
+
process.exit(1);
|
|
434
|
+
}
|
|
435
|
+
const { generateReport } = await import("./detector/report.js");
|
|
436
|
+
const { writeFileSync } = await import("node:fs");
|
|
437
|
+
const reportSpinner = ora("Generating remediation report...").start();
|
|
438
|
+
let report;
|
|
439
|
+
try {
|
|
440
|
+
report = await generateReport(result, auth, {
|
|
441
|
+
onProgress: (s) => { reportSpinner.text = `Generating remediation report — ${s}...`; },
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
catch (err) {
|
|
445
|
+
const e = err;
|
|
446
|
+
if (e.status === 402 || e.code === "token_exhausted") {
|
|
447
|
+
reportSpinner.fail("Token balance exhausted. Run: kubeagent account");
|
|
448
|
+
}
|
|
449
|
+
else {
|
|
450
|
+
reportSpinner.fail(`Report failed: ${e.message.replace("KubeAgent proxy: ", "")}`);
|
|
451
|
+
}
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
reportSpinner.succeed("Remediation report ready");
|
|
455
|
+
const reportPath = join(kbDir, "report.md");
|
|
456
|
+
writeFileSync(reportPath, report);
|
|
457
|
+
console.log(chalk.dim(`Report written to ${reportPath}`));
|
|
458
|
+
console.log();
|
|
459
|
+
console.log(report);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
331
462
|
// Notify commands
|
|
332
463
|
const notifyCmd = program.command("notify").description("Manage notification channels");
|
|
333
464
|
notifyCmd
|