@intentius/chant-lexicon-k8s 0.31.0 → 0.32.0

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.
Files changed (103) hide show
  1. package/dist/api/classify.d.ts +10 -0
  2. package/dist/api/classify.d.ts.map +1 -1
  3. package/dist/api/owner-chain.d.ts +46 -0
  4. package/dist/api/owner-chain.d.ts.map +1 -0
  5. package/dist/api/sweep-types.d.ts +17 -0
  6. package/dist/api/sweep-types.d.ts.map +1 -0
  7. package/dist/deep-observe-hooks.d.ts +86 -0
  8. package/dist/deep-observe-hooks.d.ts.map +1 -0
  9. package/dist/deep-observe.d.ts +146 -0
  10. package/dist/deep-observe.d.ts.map +1 -0
  11. package/dist/describe-resources.d.ts +15 -0
  12. package/dist/describe-resources.d.ts.map +1 -1
  13. package/dist/export-resources.d.ts +6 -2
  14. package/dist/export-resources.d.ts.map +1 -1
  15. package/dist/integrity.json +2 -2
  16. package/dist/kube/apply.d.ts +26 -0
  17. package/dist/kube/apply.d.ts.map +1 -0
  18. package/dist/kube/connect.d.ts +30 -0
  19. package/dist/kube/connect.d.ts.map +1 -0
  20. package/dist/kube/delete.d.ts +27 -0
  21. package/dist/kube/delete.d.ts.map +1 -0
  22. package/dist/kube/describe.d.ts +19 -0
  23. package/dist/kube/describe.d.ts.map +1 -0
  24. package/dist/kube/events.d.ts +34 -0
  25. package/dist/kube/events.d.ts.map +1 -0
  26. package/dist/kube/flags.d.ts +40 -0
  27. package/dist/kube/flags.d.ts.map +1 -0
  28. package/dist/kube/get.d.ts +21 -0
  29. package/dist/kube/get.d.ts.map +1 -0
  30. package/dist/kube/group.d.ts +19 -0
  31. package/dist/kube/group.d.ts.map +1 -0
  32. package/dist/kube/logs.d.ts +16 -0
  33. package/dist/kube/logs.d.ts.map +1 -0
  34. package/dist/kube/project.d.ts +59 -0
  35. package/dist/kube/project.d.ts.map +1 -0
  36. package/dist/kube/render.d.ts +75 -0
  37. package/dist/kube/render.d.ts.map +1 -0
  38. package/dist/kube/source.d.ts +19 -0
  39. package/dist/kube/source.d.ts.map +1 -0
  40. package/dist/kube/target.d.ts +52 -0
  41. package/dist/kube/target.d.ts.map +1 -0
  42. package/dist/kube/testing.d.ts +21 -0
  43. package/dist/kube/testing.d.ts.map +1 -0
  44. package/dist/kube/top.d.ts +23 -0
  45. package/dist/kube/top.d.ts.map +1 -0
  46. package/dist/kube/verdict.d.ts +45 -0
  47. package/dist/kube/verdict.d.ts.map +1 -0
  48. package/dist/kube/wait.d.ts +23 -0
  49. package/dist/kube/wait.d.ts.map +1 -0
  50. package/dist/manifest.json +1 -1
  51. package/dist/op/activities/index.d.ts +6 -3
  52. package/dist/op/activities/index.d.ts.map +1 -1
  53. package/dist/op/activities/kubectl.d.ts +91 -9
  54. package/dist/op/activities/kubectl.d.ts.map +1 -1
  55. package/dist/plugin.d.ts.map +1 -1
  56. package/package.json +3 -3
  57. package/src/api/classify.ts +13 -0
  58. package/src/api/owner-chain.test.ts +171 -0
  59. package/src/api/owner-chain.ts +144 -0
  60. package/src/api/sweep-types.ts +36 -0
  61. package/src/deep-observe-hooks.ts +125 -0
  62. package/src/deep-observe.test.ts +668 -0
  63. package/src/deep-observe.ts +299 -0
  64. package/src/describe-resources.test.ts +224 -1
  65. package/src/describe-resources.ts +86 -1
  66. package/src/export-resources.ts +6 -22
  67. package/src/kube/apply.test.ts +105 -0
  68. package/src/kube/apply.ts +101 -0
  69. package/src/kube/connect.ts +54 -0
  70. package/src/kube/delete.test.ts +123 -0
  71. package/src/kube/delete.ts +174 -0
  72. package/src/kube/describe.test.ts +106 -0
  73. package/src/kube/describe.ts +195 -0
  74. package/src/kube/events.test.ts +115 -0
  75. package/src/kube/events.ts +160 -0
  76. package/src/kube/flags.test.ts +65 -0
  77. package/src/kube/flags.ts +114 -0
  78. package/src/kube/get.test.ts +195 -0
  79. package/src/kube/get.ts +190 -0
  80. package/src/kube/group.test.ts +33 -0
  81. package/src/kube/group.ts +125 -0
  82. package/src/kube/logs.test.ts +126 -0
  83. package/src/kube/logs.ts +96 -0
  84. package/src/kube/project.test.ts +57 -0
  85. package/src/kube/project.ts +106 -0
  86. package/src/kube/render.test.ts +142 -0
  87. package/src/kube/render.ts +252 -0
  88. package/src/kube/source.test.ts +138 -0
  89. package/src/kube/source.ts +139 -0
  90. package/src/kube/target.test.ts +67 -0
  91. package/src/kube/target.ts +89 -0
  92. package/src/kube/testing.ts +42 -0
  93. package/src/kube/top.test.ts +149 -0
  94. package/src/kube/top.ts +120 -0
  95. package/src/kube/verdict.test.ts +48 -0
  96. package/src/kube/verdict.ts +77 -0
  97. package/src/kube/wait.test.ts +125 -0
  98. package/src/kube/wait.ts +160 -0
  99. package/src/op/activities/index.ts +6 -3
  100. package/src/op/activities/kubectl.test.ts +298 -26
  101. package/src/op/activities/kubectl.ts +313 -20
  102. package/src/plugin.test.ts +58 -1
  103. package/src/plugin.ts +29 -0
@@ -0,0 +1,106 @@
1
+ import { describe, test, expect, vi, afterEach } from "vitest";
2
+ import { ClusterBindingMismatchError } from "@intentius/chant/kubectl-context";
3
+ import { fakeCluster, objectKey } from "../api/fake-cluster";
4
+ import { runDescribe } from "./describe";
5
+ import { fakeDeclarable, fakeProjectContext } from "./testing";
6
+
7
+ afterEach(() => vi.restoreAllMocks());
8
+
9
+ function spyConsole() {
10
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
11
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
12
+ return { log, error };
13
+ }
14
+
15
+ describe("chant kube describe (#1079)", () => {
16
+ test("happy path: prints identity, chant section, spec/status, and an events section", async () => {
17
+ const cluster = fakeCluster({
18
+ objects: {
19
+ [objectKey("apps/v1", "Deployment", "web", "prod")]: {
20
+ apiVersion: "apps/v1",
21
+ kind: "Deployment",
22
+ metadata: { name: "web", namespace: "prod", uid: "u1", labels: { app: "web" } },
23
+ spec: { replicas: 2 },
24
+ status: { readyReplicas: 2, replicas: 2 },
25
+ },
26
+ },
27
+ });
28
+ const { log } = spyConsole();
29
+
30
+ const code = await runDescribe(["deployment", "web", "-n", "prod"], { connect: cluster.connector });
31
+
32
+ expect(code).toBe(0);
33
+ const out = log.mock.calls.map((c) => c[0]).join("\n");
34
+ expect(out).toContain("Name: web");
35
+ expect(out).toContain("Namespace: prod");
36
+ expect(out).toContain("Chant:");
37
+ expect(out).toContain("Verdict: unavailable");
38
+ expect(out).toContain("Spec:");
39
+ expect(out).toContain("Events:");
40
+ });
41
+
42
+ test("with a project, a matched entity's verdict and source are reported", async () => {
43
+ const cluster = fakeCluster({
44
+ objects: {
45
+ [objectKey("apps/v1", "Deployment", "web", "prod")]: {
46
+ apiVersion: "apps/v1",
47
+ kind: "Deployment",
48
+ metadata: { name: "web", namespace: "prod", uid: "u1" },
49
+ spec: { replicas: 2 },
50
+ },
51
+ },
52
+ });
53
+ const project = fakeProjectContext({
54
+ web: fakeDeclarable(
55
+ "K8s::Apps::Deployment",
56
+ { metadata: { name: "web", namespace: "prod" }, spec: { replicas: 2 } },
57
+ { sourceFile: "/project/src/infra.ts", composite: "WebApp" },
58
+ ),
59
+ });
60
+ const { log } = spyConsole();
61
+
62
+ await runDescribe(["deployment", "web", "-n", "prod"], { connect: cluster.connector, loadProject: async () => project });
63
+
64
+ const out = log.mock.calls.map((c) => c[0]).join("\n");
65
+ expect(out).toContain("Verdict: declared");
66
+ expect(out).toContain("Entity: web");
67
+ expect(out).toContain("src/infra.ts");
68
+ expect(out).toContain("via WebApp");
69
+ });
70
+
71
+ test("binding mismatch renders NOT-OBSERVED", async () => {
72
+ const { error } = spyConsole();
73
+ const connect = async () => {
74
+ throw new ClusterBindingMismatchError("prod", "prod-eks", "dev-eks");
75
+ };
76
+
77
+ const code = await runDescribe(["deployment", "web", "--env", "prod"], { connect });
78
+
79
+ expect(code).toBe(1);
80
+ expect(error.mock.calls[0][0]).toContain("no binding for this environment");
81
+ });
82
+
83
+ test("unknown flag is rejected", async () => {
84
+ const { error } = spyConsole();
85
+ const connect = vi.fn();
86
+
87
+ const code = await runDescribe(["deployment", "web", "--bogus"], { connect });
88
+
89
+ expect(code).toBe(1);
90
+ expect(error.mock.calls[0][0]).toContain("Unknown flag");
91
+ expect(connect).not.toHaveBeenCalled();
92
+ });
93
+
94
+ test("tri-state: a 500 on discovery renders NOT-OBSERVED, not an empty page", async () => {
95
+ const cluster = fakeCluster({
96
+ respond: (req) => (req.path === "/apis" ? { status: 500, body: { kind: "Status", message: "etcd unavailable" } } : undefined),
97
+ });
98
+ const { error, log } = spyConsole();
99
+
100
+ const code = await runDescribe(["deployment", "web"], { connect: cluster.connector });
101
+
102
+ expect(code).toBe(1);
103
+ expect(error.mock.calls[0][0]).toContain("read failed");
104
+ expect(log).not.toHaveBeenCalled();
105
+ });
106
+ });
@@ -0,0 +1,195 @@
1
+ /**
2
+ * `chant kube describe` (chant #1079) — a generic, multi-section human view:
3
+ * identity, chant's own verdict + provenance (when inside a project), a
4
+ * pretty-printed spec/status, and the object's Events (reusing `./events.ts`'s
5
+ * `listEvents`/`filterInvolved`, the same machinery `chant kube events`
6
+ * itself uses — one implementation, not two). This is not kubectl's
7
+ * per-kind template set (`OldReplicaSets`/`NewReplicaSet` for a Deployment,
8
+ * per-container detail for a Pod, and so on for every built-in kind) — a
9
+ * generic describe covering the fields every object carries, plus the
10
+ * context only chant can add.
11
+ */
12
+
13
+ import type { K8sObject } from "@intentius/chant-k8s-client";
14
+ import { formatUnobserved } from "@intentius/chant/observation";
15
+ import { defaultK8sConnector, type K8sConnector } from "../api/connect";
16
+ import { classifyApiFailure } from "../api/classify";
17
+ import { statusFromObject } from "../describe-resources";
18
+ import { kubeConnect } from "./connect";
19
+ import { parseKubeFlags, connectOptionsFrom } from "./flags";
20
+ import { loadKubeProjectContext, relativeProvenance, type KubeProjectContext } from "./project";
21
+ import { resolveKubeTarget, matchLiveObject, isTargetError, type KubeTarget } from "./target";
22
+ import { verdictFor } from "./verdict";
23
+ import { relativeAge } from "./render";
24
+ import { listEvents, filterInvolved, renderEvents } from "./events";
25
+
26
+ export interface DescribeDeps {
27
+ connect?: K8sConnector;
28
+ loadProject?: (cwd?: string) => Promise<KubeProjectContext | undefined>;
29
+ }
30
+
31
+ function targetLabel(target: KubeTarget): string {
32
+ return "apiVersion" in target.selector ? target.selector.kind : target.selector.resource;
33
+ }
34
+
35
+ function keyValueBlock(m: Record<string, string> | undefined): string {
36
+ if (!m || Object.keys(m).length === 0) return "<none>";
37
+ const entries = Object.entries(m);
38
+ return entries.map(([k, v], i) => (i === 0 ? `${k}=${v}` : ` ${k}=${v}`)).join("\n");
39
+ }
40
+
41
+ function indentedJson(value: unknown): string {
42
+ return JSON.stringify(value, null, 2)
43
+ .split("\n")
44
+ .map((l) => ` ${l}`)
45
+ .join("\n");
46
+ }
47
+
48
+ async function renderBlock(
49
+ client: Parameters<typeof listEvents>[0],
50
+ obj: K8sObject,
51
+ info: { apiVersion: string; kind: string },
52
+ project: KubeProjectContext | undefined,
53
+ ): Promise<string> {
54
+ const lines: string[] = [];
55
+ lines.push(`Name: ${obj.metadata?.name ?? ""}`);
56
+ if (obj.metadata?.namespace) lines.push(`Namespace: ${obj.metadata.namespace}`);
57
+ lines.push(`API Version: ${obj.apiVersion ?? info.apiVersion}`);
58
+ lines.push(`Kind: ${obj.kind ?? info.kind}`);
59
+ lines.push(`Labels: ${keyValueBlock(obj.metadata?.labels)}`);
60
+ lines.push(`Annotations: ${keyValueBlock(obj.metadata?.annotations)}`);
61
+ lines.push(`Created: ${obj.metadata?.creationTimestamp ?? "<unknown>"} (${relativeAge(obj.metadata?.creationTimestamp)} ago)`);
62
+ lines.push(`Status: ${statusFromObject(obj)}`);
63
+
64
+ lines.push("Chant:");
65
+ if (!project) {
66
+ lines.push(" Verdict: unavailable (not running inside a chant project)");
67
+ } else {
68
+ const match = matchLiveObject(project, {
69
+ apiVersion: obj.apiVersion ?? info.apiVersion,
70
+ kind: obj.kind ?? info.kind,
71
+ name: obj.metadata?.name ?? "",
72
+ namespace: obj.metadata?.namespace,
73
+ });
74
+ lines.push(` Verdict: ${verdictFor(obj, match)}`);
75
+ if (match) {
76
+ const prov = relativeProvenance(project, match.entity);
77
+ lines.push(` Entity: ${match.entityName}`);
78
+ lines.push(` Source: ${prov?.sourceFile ?? "(unknown)"}${prov?.composite ? ` (via ${prov.composite})` : ""}`);
79
+ }
80
+ }
81
+
82
+ if (obj.spec !== undefined) {
83
+ lines.push("Spec:");
84
+ lines.push(indentedJson(obj.spec));
85
+ }
86
+ if (obj.status !== undefined) {
87
+ lines.push("Status:");
88
+ lines.push(indentedJson(obj.status));
89
+ }
90
+
91
+ try {
92
+ const events = await listEvents(client, { namespace: obj.metadata?.namespace });
93
+ const involved = filterInvolved(events, { kind: obj.kind ?? info.kind, name: obj.metadata?.name ?? "" });
94
+ lines.push("Events:");
95
+ lines.push(renderEvents(involved));
96
+ } catch {
97
+ lines.push("Events: <could not be read>");
98
+ }
99
+
100
+ return lines.join("\n");
101
+ }
102
+
103
+ export async function runDescribe(rawArgs: string[], deps: DescribeDeps = {}): Promise<number> {
104
+ const connect = deps.connect ?? defaultK8sConnector;
105
+ const loadProject = deps.loadProject ?? loadKubeProjectContext;
106
+
107
+ let flags;
108
+ try {
109
+ flags = parseKubeFlags(rawArgs);
110
+ } catch (err) {
111
+ console.error(err instanceof Error ? err.message : String(err));
112
+ return 1;
113
+ }
114
+
115
+ const project = await loadProject();
116
+ const target = resolveKubeTarget(flags.positional, flags.values.namespace, project);
117
+ if (isTargetError(target)) {
118
+ console.error(`error: ${target.error}`);
119
+ return 1;
120
+ }
121
+
122
+ const connected = await kubeConnect(connectOptionsFrom(flags.values), connect);
123
+ if (connected.kind === "unobserved") {
124
+ console.error(formatUnobserved(targetLabel(target), { reason: connected.reason, detail: connected.detail }));
125
+ return 1;
126
+ }
127
+ const { client } = connected;
128
+
129
+ let info;
130
+ try {
131
+ info = await client.resolve(target.selector);
132
+ } catch (err) {
133
+ const outcome = classifyApiFailure(err);
134
+ console.error(
135
+ formatUnobserved(targetLabel(target), {
136
+ reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
137
+ detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
138
+ }),
139
+ );
140
+ return 1;
141
+ }
142
+ if (!info) {
143
+ console.error(`error: the server doesn't have a resource type "${targetLabel(target)}"`);
144
+ return 1;
145
+ }
146
+
147
+ const namespace = flags.flags.allNamespaces ? undefined : (target.namespace ?? client.defaultNamespace);
148
+ const ref = { apiVersion: info.apiVersion, kind: info.kind };
149
+
150
+ let objects: K8sObject[];
151
+ if (target.name) {
152
+ try {
153
+ const obj = await client.readIfPresent({ ...ref, name: target.name, ...(namespace ? { namespace } : {}) });
154
+ if (!obj) {
155
+ console.error(`Error from server (NotFound): ${info.name} "${target.name}" not found`);
156
+ return 1;
157
+ }
158
+ objects = [obj];
159
+ } catch (err) {
160
+ const outcome = classifyApiFailure(err);
161
+ console.error(
162
+ formatUnobserved(`${info.kind}/${target.name}`, {
163
+ reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
164
+ detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
165
+ }),
166
+ );
167
+ return 1;
168
+ }
169
+ } else {
170
+ try {
171
+ objects = await client.list(ref, {
172
+ ...(namespace ? { namespace } : {}),
173
+ ...(flags.values.selector ? { labelSelector: flags.values.selector } : {}),
174
+ });
175
+ } catch (err) {
176
+ const outcome = classifyApiFailure(err);
177
+ console.error(
178
+ formatUnobserved(info.kind, {
179
+ reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
180
+ detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
181
+ }),
182
+ );
183
+ return 1;
184
+ }
185
+ }
186
+
187
+ if (objects.length === 0) {
188
+ console.log("No resources found.");
189
+ return 0;
190
+ }
191
+
192
+ const blocks = await Promise.all(objects.map((obj) => renderBlock(client, obj, info, project)));
193
+ console.log(blocks.join("\n\n\n"));
194
+ return 0;
195
+ }
@@ -0,0 +1,115 @@
1
+ import { describe, test, expect, vi, afterEach } from "vitest";
2
+ import { ClusterBindingMismatchError } from "@intentius/chant/kubectl-context";
3
+ import { fakeCluster } from "../api/fake-cluster";
4
+ import { runEvents } from "./events";
5
+
6
+ afterEach(() => vi.restoreAllMocks());
7
+
8
+ function spyConsole() {
9
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
10
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
11
+ return { log, error };
12
+ }
13
+
14
+ function event(name: string, reason: string, involved: { kind: string; name: string }, lastTimestamp: string) {
15
+ return {
16
+ apiVersion: "v1",
17
+ kind: "Event",
18
+ metadata: { name, namespace: "prod", uid: `uid-${name}` },
19
+ involvedObject: involved,
20
+ reason,
21
+ type: "Normal",
22
+ message: `${reason} happened`,
23
+ lastTimestamp,
24
+ };
25
+ }
26
+
27
+ describe("chant kube events (#1079)", () => {
28
+ test("happy path: lists events oldest first", async () => {
29
+ const cluster = fakeCluster({
30
+ respond: (req) =>
31
+ req.path === "/api/v1/namespaces/prod/events"
32
+ ? {
33
+ body: {
34
+ items: [
35
+ event("e2", "Started", { kind: "Pod", name: "web" }, "2026-01-01T00:02:00Z"),
36
+ event("e1", "Scheduled", { kind: "Pod", name: "web" }, "2026-01-01T00:00:00Z"),
37
+ ],
38
+ },
39
+ }
40
+ : undefined,
41
+ });
42
+ const { log } = spyConsole();
43
+
44
+ const code = await runEvents(["-n", "prod"], { connect: cluster.connector });
45
+
46
+ expect(code).toBe(0);
47
+ const out = log.mock.calls[0][0] as string;
48
+ const scheduledIdx = out.indexOf("Scheduled");
49
+ const startedIdx = out.indexOf("Started");
50
+ expect(scheduledIdx).toBeGreaterThan(-1);
51
+ expect(startedIdx).toBeGreaterThan(scheduledIdx);
52
+ });
53
+
54
+ test("--for filters by involvedObject kind/name", async () => {
55
+ const cluster = fakeCluster({
56
+ respond: (req) =>
57
+ req.path === "/api/v1/namespaces/prod/events"
58
+ ? {
59
+ body: {
60
+ items: [
61
+ event("e1", "Started", { kind: "Pod", name: "web" }, "2026-01-01T00:00:00Z"),
62
+ event("e2", "Started", { kind: "Pod", name: "other" }, "2026-01-01T00:00:00Z"),
63
+ ],
64
+ },
65
+ }
66
+ : undefined,
67
+ });
68
+ const { log } = spyConsole();
69
+
70
+ await runEvents(["-n", "prod", "--for=Pod/web"], { connect: cluster.connector });
71
+
72
+ const out = log.mock.calls[0][0] as string;
73
+ expect(out).toContain("Pod/web");
74
+ expect(out).not.toContain("Pod/other");
75
+ });
76
+
77
+ test("binding mismatch refuses loudly", async () => {
78
+ const { error } = spyConsole();
79
+ const connect = async () => {
80
+ throw new ClusterBindingMismatchError("prod", "prod-eks", "dev-eks");
81
+ };
82
+
83
+ const code = await runEvents(["--env", "prod"], { connect });
84
+
85
+ expect(code).toBe(1);
86
+ expect(error.mock.calls[0][0]).toContain("no binding for this environment");
87
+ });
88
+
89
+ test("unknown flag is rejected before any connection", async () => {
90
+ const { error } = spyConsole();
91
+ const connect = vi.fn();
92
+
93
+ const code = await runEvents(["--bogus"], { connect });
94
+
95
+ expect(code).toBe(1);
96
+ expect(error.mock.calls[0][0]).toContain("Unknown flag");
97
+ expect(connect).not.toHaveBeenCalled();
98
+ });
99
+
100
+ test("tri-state: an RBAC-denied list renders NOT-OBSERVED, not an empty list", async () => {
101
+ const cluster = fakeCluster({
102
+ respond: (req) =>
103
+ req.path === "/api/v1/namespaces/prod/events"
104
+ ? { status: 403, body: { kind: "Status", reason: "Forbidden", message: "events is forbidden" } }
105
+ : undefined,
106
+ });
107
+ const { error, log } = spyConsole();
108
+
109
+ const code = await runEvents(["-n", "prod"], { connect: cluster.connector });
110
+
111
+ expect(code).toBe(1);
112
+ expect(error.mock.calls[0][0]).toContain("no credentials");
113
+ expect(log).not.toHaveBeenCalled();
114
+ });
115
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * `chant kube events` (chant #1079) — the core `v1 Event` kind is addressed
3
+ * exactly like any other read (`client.list`), no client extension needed.
4
+ * The one thing the typed client's `ListOptions` does not carry is a field
5
+ * selector, so `--for <kind>/<name>` (kubectl's own `kubectl events
6
+ * --for=kind/name` shape) filters client-side against `involvedObject` after
7
+ * the list comes back, rather than asking the server to. Event volume in a
8
+ * single namespace is small enough that this is a fine trade for not
9
+ * widening the client's surface for one caller.
10
+ */
11
+
12
+ import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
13
+ import { formatUnobserved } from "@intentius/chant/observation";
14
+ import { defaultK8sConnector, type K8sConnector } from "../api/connect";
15
+ import { classifyApiFailure } from "../api/classify";
16
+ import { kubeConnect } from "./connect";
17
+ import { parseKubeFlags, connectOptionsFrom } from "./flags";
18
+ import { renderTable, renderJson, renderYaml, relativeAge, parseOutput } from "./render";
19
+
20
+ export interface EventsDeps {
21
+ connect?: K8sConnector;
22
+ }
23
+
24
+ interface InvolvedObject {
25
+ kind?: string;
26
+ name?: string;
27
+ namespace?: string;
28
+ }
29
+
30
+ function involvedOf(event: K8sObject): InvolvedObject {
31
+ return (event.involvedObject as InvolvedObject | undefined) ?? {};
32
+ }
33
+
34
+ function eventTimestamp(event: K8sObject): string | undefined {
35
+ return (
36
+ (event.lastTimestamp as string | undefined) ??
37
+ (event.eventTime as string | undefined) ??
38
+ event.metadata?.creationTimestamp
39
+ );
40
+ }
41
+
42
+ /** Events whose `involvedObject` names `ref`, oldest first — kubectl's own default order. */
43
+ export function filterInvolved(events: readonly K8sObject[], ref: { kind: string; name: string }): K8sObject[] {
44
+ return events.filter((e) => {
45
+ const involved = involvedOf(e);
46
+ return involved.kind === ref.kind && involved.name === ref.name;
47
+ });
48
+ }
49
+
50
+ function sortByTime(events: readonly K8sObject[]): K8sObject[] {
51
+ return [...events].sort((a, b) => {
52
+ const ta = eventTimestamp(a);
53
+ const tb = eventTimestamp(b);
54
+ if (!ta && !tb) return 0;
55
+ if (!ta) return -1;
56
+ if (!tb) return 1;
57
+ return Date.parse(ta) - Date.parse(tb);
58
+ });
59
+ }
60
+
61
+ /**
62
+ * List Events for `namespace` (or every namespace, when `namespace` is
63
+ * undefined), sorted oldest first. Shared by `runEvents` and `describe`'s own
64
+ * Events section, so the two verbs agree on what "the events for this object"
65
+ * means.
66
+ */
67
+ export async function listEvents(
68
+ client: K8sClient,
69
+ options: { namespace?: string; labelSelector?: string } = {},
70
+ ): Promise<K8sObject[]> {
71
+ const events = await client.list(
72
+ { apiVersion: "v1", kind: "Event" },
73
+ { ...(options.namespace ? { namespace: options.namespace } : {}), ...(options.labelSelector ? { labelSelector: options.labelSelector } : {}) },
74
+ );
75
+ return sortByTime(events);
76
+ }
77
+
78
+ function eventRow(e: K8sObject): string[] {
79
+ const involved = involvedOf(e);
80
+ const count = typeof e.count === "number" ? e.count : undefined;
81
+ return [
82
+ relativeAge(eventTimestamp(e)),
83
+ (e.type as string | undefined) ?? "",
84
+ (e.reason as string | undefined) ?? "",
85
+ `${involved.kind ?? ""}/${involved.name ?? ""}`,
86
+ `${(e.message as string | undefined) ?? ""}${count && count > 1 ? ` (x${count})` : ""}`,
87
+ ];
88
+ }
89
+
90
+ /** Render an events table — reused by `describe`'s Events section. */
91
+ export function renderEvents(events: readonly K8sObject[]): string {
92
+ if (events.length === 0) return "No events found.";
93
+ return renderTable(["LAST SEEN", "TYPE", "REASON", "OBJECT", "MESSAGE"], events.map(eventRow));
94
+ }
95
+
96
+ function parseFor(value: string | undefined): { kind: string; name: string } | undefined {
97
+ if (!value) return undefined;
98
+ const slash = value.indexOf("/");
99
+ if (slash === -1) throw new Error(`--for expects KIND/NAME, e.g. --for=Pod/web-abc123`);
100
+ return { kind: value.slice(0, slash), name: value.slice(slash + 1) };
101
+ }
102
+
103
+ export async function runEvents(rawArgs: string[], deps: EventsDeps = {}): Promise<number> {
104
+ const connect = deps.connect ?? defaultK8sConnector;
105
+
106
+ let flags;
107
+ try {
108
+ flags = parseKubeFlags(rawArgs, { value: { "--for": "for" } });
109
+ } catch (err) {
110
+ console.error(err instanceof Error ? err.message : String(err));
111
+ return 1;
112
+ }
113
+
114
+ let output;
115
+ let forRef;
116
+ try {
117
+ output = parseOutput(flags.values.output);
118
+ forRef = parseFor(flags.values.for);
119
+ } catch (err) {
120
+ console.error(err instanceof Error ? err.message : String(err));
121
+ return 1;
122
+ }
123
+
124
+ const connected = await kubeConnect(connectOptionsFrom(flags.values), connect);
125
+ if (connected.kind === "unobserved") {
126
+ console.error(formatUnobserved("events", { reason: connected.reason, detail: connected.detail }));
127
+ return 1;
128
+ }
129
+ const { client } = connected;
130
+
131
+ const namespace = flags.flags.allNamespaces ? undefined : (flags.values.namespace ?? client.defaultNamespace);
132
+
133
+ let events: K8sObject[];
134
+ try {
135
+ events = await listEvents(client, { namespace, ...(flags.values.selector ? { labelSelector: flags.values.selector } : {}) });
136
+ } catch (err) {
137
+ const outcome = classifyApiFailure(err);
138
+ console.error(
139
+ formatUnobserved("events", {
140
+ reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
141
+ detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
142
+ }),
143
+ );
144
+ return 1;
145
+ }
146
+
147
+ if (forRef) events = filterInvolved(events, forRef);
148
+
149
+ switch (output.kind) {
150
+ case "json":
151
+ console.log(renderJson(events));
152
+ return 0;
153
+ case "yaml":
154
+ console.log(renderYaml(events));
155
+ return 0;
156
+ default:
157
+ console.log(renderEvents(events));
158
+ return 0;
159
+ }
160
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { parseKubeFlags, connectOptionsFrom, parseDurationSeconds, parseDurationMs } from "./flags";
3
+
4
+ describe("parseKubeFlags (chant #1079)", () => {
5
+ test("splits positional args from the common flag set", () => {
6
+ const { positional, values, flags } = parseKubeFlags(["deployment", "web", "-n", "prod", "-A"]);
7
+ expect(positional).toEqual(["deployment", "web"]);
8
+ expect(values.namespace).toBe("prod");
9
+ expect(flags.allNamespaces).toBe(true);
10
+ });
11
+
12
+ test("accepts a joined --flag=value form (#1127 discipline)", () => {
13
+ const { values } = parseKubeFlags(["--namespace=prod", "--output=json"]);
14
+ expect(values).toEqual({ namespace: "prod", output: "json" });
15
+ });
16
+
17
+ test("merges a verb's own flag spec with the common set", () => {
18
+ const { values, flags } = parseKubeFlags(["--tail", "50", "--previous"], {
19
+ value: { "--tail": "tail" },
20
+ boolean: { "--previous": "previous" },
21
+ });
22
+ expect(values.tail).toBe("50");
23
+ expect(flags.previous).toBe(true);
24
+ });
25
+
26
+ test("an unrecognized flag throws the shared unknown-flag error", () => {
27
+ expect(() => parseKubeFlags(["--bogus"])).toThrow(/Unknown flag: --bogus/);
28
+ });
29
+
30
+ test("a value flag given as a boolean via = throws (#1127)", () => {
31
+ expect(() => parseKubeFlags(["--all-namespaces=yes"])).toThrow(/boolean flag/);
32
+ });
33
+
34
+ test("a value flag with nothing after it is an error, not a silent positional swallow", () => {
35
+ expect(() => parseKubeFlags(["--namespace"])).toThrow(/requires a value/);
36
+ });
37
+
38
+ test("a bare '-' is a positional, not a flag (kubectl's stdin convention)", () => {
39
+ const { positional } = parseKubeFlags(["apply", "-"]);
40
+ expect(positional).toEqual(["apply", "-"]);
41
+ });
42
+ });
43
+
44
+ describe("connectOptionsFrom", () => {
45
+ test("maps env/context/kubeconfig to ConnectOptions", () => {
46
+ expect(connectOptionsFrom({ env: "prod" })).toEqual({ environment: "prod" });
47
+ expect(connectOptionsFrom({ context: "prod-eks" })).toEqual({ context: "prod-eks" });
48
+ expect(connectOptionsFrom({ kubeconfig: "/tmp/kc" })).toEqual({ client: { kubeconfigPath: "/tmp/kc" } });
49
+ expect(connectOptionsFrom({})).toEqual({});
50
+ });
51
+ });
52
+
53
+ describe("parseDurationSeconds / parseDurationMs", () => {
54
+ test("parses bare seconds and suffixed durations", () => {
55
+ expect(parseDurationSeconds("45")).toBe(45);
56
+ expect(parseDurationSeconds("45s")).toBe(45);
57
+ expect(parseDurationSeconds("90m")).toBe(5400);
58
+ expect(parseDurationSeconds("2h")).toBe(7200);
59
+ expect(parseDurationMs("1h")).toBe(3_600_000);
60
+ });
61
+
62
+ test("rejects a nonsense duration", () => {
63
+ expect(() => parseDurationSeconds("soon")).toThrow(/expected a duration/);
64
+ });
65
+ });