kubeagent 0.1.36 → 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 CHANGED
@@ -27,6 +27,7 @@ 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 |
32
33
  | `scan-apps` | Detect OSS apps in the cluster, list affected workloads + ingress domains, check for CVEs / EOL (`--report` for AI-prioritized remediation) |
@@ -43,6 +44,30 @@ kubeagent watch
43
44
  - `-i, --interval <seconds>` — Check interval for `watch` (default: 300)
44
45
  - `--no-interactive` — Auto-deny all approvals, skip questions (for `watch` in background/CI)
45
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
+
46
71
  ## Knowledge Base
47
72
 
48
73
  KubeAgent builds a per-cluster knowledge base at `~/.kubeagent/clusters/<context>/` during onboarding:
@@ -124,6 +149,13 @@ Safe actions (pod restarts, rollout restarts, scaling up) are applied automatica
124
149
  **Where is my API key stored?**
125
150
  Credentials are stored locally at `~/.kubeagent/auth.json` and are never sent anywhere except the KubeAgent API.
126
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
+
127
159
  ## License
128
160
 
129
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")
@@ -338,12 +352,21 @@ program
338
352
  .action(async (opts) => {
339
353
  const { detectApplications, formatApplicationsMarkdown } = await import("./detector/index.js");
340
354
  const { writeApplicationsKb, ensureKbDir } = await import("./kb/writer.js");
341
- const contextName = opts.context ?? "default";
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
+ }
342
365
  const spinner = ora("Scanning cluster for OSS applications...").start();
343
366
  let result;
344
367
  try {
345
368
  result = await detectApplications({
346
- context: opts.context,
369
+ context: contextName,
347
370
  noCache: opts.cache === false,
348
371
  onProgress: (step) => {
349
372
  spinner.text = `Scanning cluster for OSS applications — ${step}...`;
@@ -33,6 +33,18 @@ export interface HelmCandidate {
33
33
  }
34
34
  export type Candidate = ImageCandidate | LabelCandidate | HelmCandidate;
35
35
  export declare function fetchWorkloadCandidates(options: KubectlOptions): Promise<Candidate[]>;
36
+ interface HelmReleasePayload {
37
+ name: string;
38
+ namespace: string;
39
+ chart?: {
40
+ metadata?: {
41
+ name?: string;
42
+ version?: string;
43
+ appVersion?: string;
44
+ };
45
+ };
46
+ }
47
+ export declare function decodeHelmRelease(b64: string): HelmReleasePayload | null;
36
48
  export declare function fetchHelmCandidates(options: KubectlOptions): Promise<HelmCandidate[]>;
37
49
  /**
38
50
  * Build a map of workload key (Kind/namespace/name) → ingress hostnames.
@@ -40,3 +52,4 @@ export declare function fetchHelmCandidates(options: KubectlOptions): Promise<He
40
52
  * Returns an empty map on failure (ingress visibility is non-essential).
41
53
  */
42
54
  export declare function fetchIngressHostMap(options: KubectlOptions): Promise<Map<string, string[]>>;
55
+ export {};
@@ -51,7 +51,7 @@ export async function fetchWorkloadCandidates(options) {
51
51
  all.push(...extractFromWorkload("DaemonSet", w));
52
52
  return all;
53
53
  }
54
- function decodeHelmRelease(b64) {
54
+ export function decodeHelmRelease(b64) {
55
55
  try {
56
56
  // Helm v3 stores releases as base64(gzip(json)). When fetched via
57
57
  // `kubectl get secrets -o json`, the API server applies its own base64
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { gzipSync } from "node:zlib";
3
+ import { Buffer } from "node:buffer";
4
+ import { decodeHelmRelease } from "./sources.js";
5
+ // Reproduce the encoding chain Helm + the k8s API actually produce.
6
+ // Helm stores the release as `base64(gzip(json))` inside the Secret value,
7
+ // and the k8s API base64-encodes Secret `data.*` values on the wire — so the
8
+ // string kubectl returns is `base64(base64(gzip(json)))`. This test exists
9
+ // to lock in the decoder's behaviour against that chain so we can't
10
+ // regress to a single-base64 decode (which silently drops every Helm
11
+ // release at runtime).
12
+ function encodeAsKubectlReturnsIt(payload) {
13
+ const json = JSON.stringify(payload);
14
+ const gzipped = gzipSync(Buffer.from(json, "utf-8"));
15
+ const helmLayer = gzipped.toString("base64");
16
+ const apiLayer = Buffer.from(helmLayer, "utf-8").toString("base64");
17
+ return apiLayer;
18
+ }
19
+ describe("decodeHelmRelease", () => {
20
+ it("decodes a release blob in the format kubectl returns", () => {
21
+ const release = {
22
+ name: "postgres",
23
+ namespace: "data",
24
+ chart: { metadata: { name: "postgresql", version: "12.0.0", appVersion: "15.2.0" } },
25
+ };
26
+ const encoded = encodeAsKubectlReturnsIt(release);
27
+ expect(decodeHelmRelease(encoded)).toEqual(release);
28
+ });
29
+ it("returns null on garbage input rather than throwing", () => {
30
+ expect(decodeHelmRelease("not-base64-and-not-gzip")).toBeNull();
31
+ });
32
+ it("returns null on valid base64 that isn't gzip", () => {
33
+ const apiLayer = Buffer.from("aGVsbG8gd29ybGQ=", "utf-8").toString("base64");
34
+ expect(decodeHelmRelease(apiLayer)).toBeNull();
35
+ });
36
+ });
@@ -0,0 +1 @@
1
+ export {};