@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,42 @@
1
+ /**
2
+ * Test doubles for `chant kube` (chant #1079).
3
+ *
4
+ * `./project.ts`'s real `loadKubeProjectContext` builds an actual project
5
+ * (`chant.config.ts` read, source discovered, serialized) — every verb takes
6
+ * it as an injectable dependency for exactly this reason: a unit test builds
7
+ * a `KubeProjectContext` by hand instead, the same way `../api/fake-cluster.ts`
8
+ * lets a test build a client without a socket.
9
+ */
10
+
11
+ import { DECLARABLE_MARKER, type Declarable } from "@intentius/chant/declarable";
12
+ import { setProvenance, type EntityProvenance } from "@intentius/chant/provenance";
13
+ import type { ChantConfig } from "@intentius/chant/config";
14
+ import type { KubeProjectContext } from "./project";
15
+
16
+ export type FakeDeclarable = Declarable & { props: Record<string, unknown> };
17
+
18
+ /** A minimal `Declarable` carrying `props`, optionally stamped with build provenance. */
19
+ export function fakeDeclarable(
20
+ entityType: string,
21
+ props: Record<string, unknown>,
22
+ provenance?: EntityProvenance,
23
+ ): FakeDeclarable {
24
+ const entity: FakeDeclarable = {
25
+ lexicon: "k8s",
26
+ entityType,
27
+ kind: "resource",
28
+ props,
29
+ [DECLARABLE_MARKER]: true,
30
+ };
31
+ if (provenance) setProvenance(entity, provenance);
32
+ return entity;
33
+ }
34
+
35
+ /** A `KubeProjectContext` built from a plain name → entity map, no disk I/O. */
36
+ export function fakeProjectContext(
37
+ entities: Record<string, Declarable>,
38
+ config: ChantConfig = {},
39
+ cwd = "/project",
40
+ ): KubeProjectContext {
41
+ return { cwd, config, entities: new Map(Object.entries(entities)) };
42
+ }
@@ -0,0 +1,149 @@
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 { runTop } from "./top";
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
+ const METRICS_DISCOVERY = {
15
+ path: "/apis/metrics.k8s.io/v1beta1",
16
+ body: {
17
+ kind: "APIResourceList",
18
+ apiVersion: "v1",
19
+ groupVersion: "metrics.k8s.io/v1beta1",
20
+ resources: [
21
+ { name: "pods", singularName: "", namespaced: true, kind: "PodMetrics", verbs: ["get", "list"] },
22
+ { name: "nodes", singularName: "", namespaced: false, kind: "NodeMetrics", verbs: ["get", "list"] },
23
+ ],
24
+ },
25
+ };
26
+
27
+ function withMetricsGroup(respond: (req: { path: string }) => { status?: number; body?: unknown } | undefined) {
28
+ return (req: { path: string }) => {
29
+ if (req.path === "/apis") {
30
+ return {
31
+ body: {
32
+ kind: "APIGroupList",
33
+ groups: [
34
+ {
35
+ name: "metrics.k8s.io",
36
+ preferredVersion: { groupVersion: "metrics.k8s.io/v1beta1" },
37
+ versions: [{ groupVersion: "metrics.k8s.io/v1beta1" }],
38
+ },
39
+ ],
40
+ },
41
+ };
42
+ }
43
+ if (req.path === METRICS_DISCOVERY.path) return { body: METRICS_DISCOVERY.body };
44
+ return respond(req);
45
+ };
46
+ }
47
+
48
+ describe("chant kube top (#1079)", () => {
49
+ test("happy path: pods usage, per container, unsummed", async () => {
50
+ const cluster = fakeCluster({
51
+ serves: [],
52
+ respond: withMetricsGroup((req) =>
53
+ req.path === "/apis/metrics.k8s.io/v1beta1/namespaces/prod/pods"
54
+ ? {
55
+ body: {
56
+ items: [
57
+ {
58
+ metadata: { name: "web", namespace: "prod" },
59
+ containers: [{ name: "app", usage: { cpu: "250m", memory: "128Mi" } }],
60
+ },
61
+ ],
62
+ },
63
+ }
64
+ : undefined,
65
+ ),
66
+ });
67
+ const { log } = spyConsole();
68
+
69
+ const code = await runTop(["pods", "-n", "prod"], { connect: cluster.connector });
70
+
71
+ expect(code).toBe(0);
72
+ const out = log.mock.calls[0][0] as string;
73
+ expect(out).toContain("web");
74
+ expect(out).toContain("app:250m");
75
+ expect(out).toContain("app:128Mi");
76
+ });
77
+
78
+ test("nodes usage", async () => {
79
+ const cluster = fakeCluster({
80
+ serves: [],
81
+ respond: withMetricsGroup((req) =>
82
+ req.path === "/apis/metrics.k8s.io/v1beta1/nodes"
83
+ ? { body: { items: [{ metadata: { name: "node-1" }, usage: { cpu: "500m", memory: "2Gi" } }] } }
84
+ : undefined,
85
+ ),
86
+ });
87
+ const { log } = spyConsole();
88
+
89
+ const code = await runTop(["nodes"], { connect: cluster.connector });
90
+
91
+ expect(code).toBe(0);
92
+ const out = log.mock.calls[0][0] as string;
93
+ expect(out).toContain("node-1");
94
+ expect(out).toContain("500m");
95
+ expect(out).toContain("2Gi");
96
+ });
97
+
98
+ test("metrics-server absent renders a clear message, not an empty table", async () => {
99
+ const cluster = fakeCluster({ serves: [] });
100
+ const { error, log } = spyConsole();
101
+
102
+ const code = await runTop(["pods"], { connect: cluster.connector });
103
+
104
+ expect(code).toBe(1);
105
+ expect(error.mock.calls[0][0]).toContain("metrics-server");
106
+ expect(log).not.toHaveBeenCalled();
107
+ });
108
+
109
+ test("binding mismatch refuses loudly", async () => {
110
+ const { error } = spyConsole();
111
+ const connect = async () => {
112
+ throw new ClusterBindingMismatchError("prod", "prod-eks", "dev-eks");
113
+ };
114
+
115
+ const code = await runTop(["pods", "--env", "prod"], { connect });
116
+
117
+ expect(code).toBe(1);
118
+ expect(error.mock.calls[0][0]).toContain("no binding for this environment");
119
+ });
120
+
121
+ test("unknown flag is rejected before any connection", async () => {
122
+ const { error } = spyConsole();
123
+ const connect = vi.fn();
124
+
125
+ const code = await runTop(["pods", "--bogus"], { connect });
126
+
127
+ expect(code).toBe(1);
128
+ expect(error.mock.calls[0][0]).toContain("Unknown flag");
129
+ expect(connect).not.toHaveBeenCalled();
130
+ });
131
+
132
+ test("tri-state: an RBAC-denied metrics read renders NOT-OBSERVED", async () => {
133
+ const cluster = fakeCluster({
134
+ serves: [],
135
+ respond: withMetricsGroup((req) =>
136
+ req.path === "/apis/metrics.k8s.io/v1beta1/namespaces/prod/pods"
137
+ ? { status: 403, body: { kind: "Status", reason: "Forbidden", message: "forbidden" } }
138
+ : undefined,
139
+ ),
140
+ });
141
+ const { error, log } = spyConsole();
142
+
143
+ const code = await runTop(["pods", "-n", "prod"], { connect: cluster.connector });
144
+
145
+ expect(code).toBe(1);
146
+ expect(error.mock.calls[0][0]).toContain("no credentials");
147
+ expect(log).not.toHaveBeenCalled();
148
+ });
149
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * `chant kube top pods|nodes` (chant #1079).
3
+ *
4
+ * `metrics.k8s.io` is not part of the standard Kubernetes OpenAPI schema chant's
5
+ * codegen ingests — it is an aggregated API a cluster may or may not be running
6
+ * (metrics-server). It needs no entry in the generated operation surface either
7
+ * way: the typed client's kubectl-style selector (`{ resource, group }`) resolves
8
+ * purely off the *cluster's own* discovery, the same path `waitForReady`'s `kind`
9
+ * argument already uses for CRDs — so this needs no client change, only the
10
+ * right selector.
11
+ *
12
+ * Kubectl's own `top` sums each Pod's per-container usage into one CPU/memory
13
+ * figure, which needs real quantity-unit arithmetic (`250m` + `100m`, `128Mi` +
14
+ * `64Mi`). That normalization is not implemented here — each container's raw
15
+ * usage string is shown as the API reported it, unsummed. Good enough for "is
16
+ * anything obviously starved", not a replacement for `kubectl top --containers`.
17
+ */
18
+
19
+ import { formatUnobserved } from "@intentius/chant/observation";
20
+ import { defaultK8sConnector, type K8sConnector } from "../api/connect";
21
+ import { classifyApiFailure } from "../api/classify";
22
+ import { kubeConnect } from "./connect";
23
+ import { parseKubeFlags, connectOptionsFrom } from "./flags";
24
+ import { renderTable } from "./render";
25
+
26
+ export interface TopDeps {
27
+ connect?: K8sConnector;
28
+ }
29
+
30
+ const RESOURCE_ALIASES: Record<string, "pods" | "nodes"> = {
31
+ pod: "pods",
32
+ pods: "pods",
33
+ po: "pods",
34
+ node: "nodes",
35
+ nodes: "nodes",
36
+ no: "nodes",
37
+ };
38
+
39
+ export async function runTop(rawArgs: string[], deps: TopDeps = {}): Promise<number> {
40
+ const connect = deps.connect ?? defaultK8sConnector;
41
+
42
+ let flags;
43
+ try {
44
+ flags = parseKubeFlags(rawArgs);
45
+ } catch (err) {
46
+ console.error(err instanceof Error ? err.message : String(err));
47
+ return 1;
48
+ }
49
+
50
+ const resource = RESOURCE_ALIASES[flags.positional[0] ?? "pods"];
51
+ if (!resource) {
52
+ console.error(`error: unknown resource "${flags.positional[0]}" — chant kube top supports "pods" and "nodes"`);
53
+ return 1;
54
+ }
55
+
56
+ const connected = await kubeConnect(connectOptionsFrom(flags.values), connect);
57
+ if (connected.kind === "unobserved") {
58
+ console.error(formatUnobserved(resource, { reason: connected.reason, detail: connected.detail }));
59
+ return 1;
60
+ }
61
+ const { client } = connected;
62
+
63
+ let info;
64
+ try {
65
+ info = await client.resolve({ resource, group: "metrics.k8s.io" });
66
+ } catch (err) {
67
+ const outcome = classifyApiFailure(err);
68
+ console.error(
69
+ formatUnobserved(resource, {
70
+ reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
71
+ detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
72
+ }),
73
+ );
74
+ return 1;
75
+ }
76
+ if (!info) {
77
+ console.error(
78
+ "error: the metrics API (metrics.k8s.io) is not available in this cluster — is metrics-server installed?",
79
+ );
80
+ return 1;
81
+ }
82
+
83
+ const namespace = resource === "pods" && !flags.flags.allNamespaces ? (flags.values.namespace ?? client.defaultNamespace) : undefined;
84
+
85
+ let items;
86
+ try {
87
+ items = await client.list(
88
+ { apiVersion: info.apiVersion, kind: info.kind },
89
+ { ...(namespace ? { namespace } : {}) },
90
+ );
91
+ } catch (err) {
92
+ const outcome = classifyApiFailure(err);
93
+ console.error(
94
+ formatUnobserved(resource, {
95
+ reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
96
+ detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
97
+ }),
98
+ );
99
+ return 1;
100
+ }
101
+
102
+ if (resource === "nodes") {
103
+ const rows = items.map((n) => [
104
+ n.metadata?.name ?? "",
105
+ String((n.usage as Record<string, unknown> | undefined)?.cpu ?? "<unknown>"),
106
+ String((n.usage as Record<string, unknown> | undefined)?.memory ?? "<unknown>"),
107
+ ]);
108
+ console.log(renderTable(["NAME", "CPU", "MEMORY"], rows));
109
+ return 0;
110
+ }
111
+
112
+ const rows = items.map((p) => {
113
+ const containers = (p.containers as Array<{ name?: string; usage?: Record<string, unknown> }> | undefined) ?? [];
114
+ const cpu = containers.map((c) => `${c.name}:${c.usage?.cpu ?? "?"}`).join(",") || "<none>";
115
+ const memory = containers.map((c) => `${c.name}:${c.usage?.memory ?? "?"}`).join(",") || "<none>";
116
+ return [p.metadata?.namespace ?? "", p.metadata?.name ?? "", cpu, memory];
117
+ });
118
+ console.log(renderTable(["NAMESPACE", "NAME", "CPU (per container)", "MEMORY (per container)"], rows));
119
+ return 0;
120
+ }
@@ -0,0 +1,48 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { verdictFor, declaredSubsetMatches } from "./verdict";
3
+ import type { K8sObject } from "@intentius/chant-k8s-client";
4
+ import type { DeclaredMatch } from "./project";
5
+ import { fakeDeclarable } from "./testing";
6
+
7
+ function match(props: Record<string, unknown>): DeclaredMatch {
8
+ return { entityName: "web", entity: fakeDeclarable("K8s::Apps::Deployment", props), props };
9
+ }
10
+
11
+ describe("declaredSubsetMatches", () => {
12
+ test("extra live fields (server defaults, status) are not drift", () => {
13
+ expect(
14
+ declaredSubsetMatches({ spec: { replicas: 2 } }, { spec: { replicas: 2, strategy: "RollingUpdate" }, status: { readyReplicas: 2 } }),
15
+ ).toBe(true);
16
+ });
17
+
18
+ test("a declared field the live object disagrees on is drift", () => {
19
+ expect(declaredSubsetMatches({ spec: { replicas: 2 } }, { spec: { replicas: 9 } })).toBe(false);
20
+ });
21
+
22
+ test("arrays must match length and every element", () => {
23
+ expect(declaredSubsetMatches({ a: [1, 2] }, { a: [1, 2, 3] })).toBe(false);
24
+ expect(declaredSubsetMatches({ a: [1, 2] }, { a: [1, 2] })).toBe(true);
25
+ });
26
+ });
27
+
28
+ describe("verdictFor", () => {
29
+ test("a matched entity with an agreeing live object is declared", () => {
30
+ const live = { metadata: { name: "web" }, spec: { replicas: 2 } } as unknown as K8sObject;
31
+ expect(verdictFor(live, match({ metadata: { name: "web" }, spec: { replicas: 2 } }))).toBe("declared");
32
+ });
33
+
34
+ test("a matched entity whose live spec disagrees is drifted", () => {
35
+ const live = { metadata: { name: "web" }, spec: { replicas: 9 } } as unknown as K8sObject;
36
+ expect(verdictFor(live, match({ metadata: { name: "web" }, spec: { replicas: 2 } }))).toBe("drifted");
37
+ });
38
+
39
+ test("no match + chant's marker label is owned", () => {
40
+ const live = { metadata: { name: "x", labels: { "app.kubernetes.io/managed-by": "chant" } } } as unknown as K8sObject;
41
+ expect(verdictFor(live, undefined)).toBe("owned");
42
+ });
43
+
44
+ test("no match + no marker is foreign-owned", () => {
45
+ const live = { metadata: { name: "x" } } as unknown as K8sObject;
46
+ expect(verdictFor(live, undefined)).toBe("foreign-owned");
47
+ });
48
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The chant verdict column `chant kube get` carries on every row (chant
3
+ * #1079's acceptance criterion: "get annotates each row with the declared /
4
+ * owned / drifted / foreign-owned verdict").
5
+ *
6
+ * Four states:
7
+ *
8
+ * - `declared` — this project's source names an entity at this exact
9
+ * apiVersion/kind/name(/namespace), and the live object agrees with it on
10
+ * every field the source declares.
11
+ * - `drifted` — same match, but the live object disagrees with the source on
12
+ * at least one declared field.
13
+ * - `owned` — no declared match, but the live object carries chant's
14
+ * ownership marker (`app.kubernetes.io/managed-by=chant`) — something
15
+ * chant applied that source no longer declares, or a stack/entity this
16
+ * project's build didn't resolve.
17
+ * - `foreign-owned` — no declared match and no marker: chant does not
18
+ * consider this object its own.
19
+ *
20
+ * `unavailable` is not a verdict about the object; it is what every row gets
21
+ * when the project context could not be loaded at all (chant #1079's
22
+ * "degrades gracefully outside a chant project" — `get` and `describe` still
23
+ * work, this column just has nothing to say).
24
+ *
25
+ * The drift check here is a fast, best-effort *declared-subset* comparison —
26
+ * every path the source sets must agree on the live object — not the
27
+ * managed-fields-precise diff `chant lifecycle diff --live --deep` computes
28
+ * (chant #1076). It exists for a table column that must render for dozens of
29
+ * rows in one `get` call; the deep, field-ownership-aware answer stays the
30
+ * lifecycle command's job. A live object can disagree with the source on a
31
+ * field a controller owns (Kubernetes rewriting `spec.replicas` under an
32
+ * HPA, say) and still show `drifted` here — reach for the deep diff before
33
+ * treating this column as a verdict rather than a hint.
34
+ */
35
+
36
+ import type { K8sObject } from "@intentius/chant-k8s-client";
37
+ import { hasOwnershipMarker, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
38
+ import type { DeclaredMatch } from "./project";
39
+
40
+ export type KubeVerdict = "declared" | "owned" | "drifted" | "foreign-owned" | "unavailable";
41
+
42
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
43
+ return typeof v === "object" && v !== null && !Array.isArray(v);
44
+ }
45
+
46
+ /**
47
+ * True when every path `declared` sets is present and equal on `live`.
48
+ * Extra fields on `live` (server defaults, status, anything the source never
49
+ * mentions) are not drift — chant never declared an opinion about them.
50
+ */
51
+ export function declaredSubsetMatches(declared: unknown, live: unknown): boolean {
52
+ if (Array.isArray(declared)) {
53
+ if (!Array.isArray(live) || declared.length !== live.length) return false;
54
+ return declared.every((item, i) => declaredSubsetMatches(item, live[i]));
55
+ }
56
+ if (isPlainObject(declared)) {
57
+ if (!isPlainObject(live)) return false;
58
+ return Object.entries(declared).every(([key, value]) => declaredSubsetMatches(value, live[key]));
59
+ }
60
+ return declared === live;
61
+ }
62
+
63
+ /** The declared fields worth comparing — everything but the name/namespace identity itself. */
64
+ function declaredComparable(props: Record<string, unknown> | undefined): Record<string, unknown> {
65
+ if (!props) return {};
66
+ const { metadata, ...rest } = props;
67
+ if (!isPlainObject(metadata)) return rest;
68
+ const { name: _name, namespace: _namespace, ...restMetadata } = metadata;
69
+ return { ...rest, ...(Object.keys(restMetadata).length > 0 ? { metadata: restMetadata } : {}) };
70
+ }
71
+
72
+ export function verdictFor(live: K8sObject, match: DeclaredMatch | undefined): KubeVerdict {
73
+ if (match) {
74
+ return declaredSubsetMatches(declaredComparable(match.props), live) ? "declared" : "drifted";
75
+ }
76
+ return hasOwnershipMarker(live.metadata?.labels, LABEL_OWNERSHIP_KEYS) ? "owned" : "foreign-owned";
77
+ }
@@ -0,0 +1,125 @@
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 { runWait } from "./wait";
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 certificate(ready: boolean) {
15
+ return {
16
+ apiVersion: "cert-manager.io/v1",
17
+ kind: "Certificate",
18
+ metadata: { name: "web-tls", namespace: "prod", uid: "u1" },
19
+ status: { conditions: ready ? [{ type: "Ready", status: "True" }] : [{ type: "Ready", status: "False" }] },
20
+ };
21
+ }
22
+
23
+ describe("chant kube wait (#1079)", () => {
24
+ test("happy path: the registry's default Ready condition is satisfied immediately", async () => {
25
+ const cluster = fakeCluster({
26
+ objects: { [objectKey("cert-manager.io/v1", "Certificate", "web-tls", "prod")]: certificate(true) },
27
+ });
28
+ const { log } = spyConsole();
29
+
30
+ const code = await runWait(["certificate", "web-tls", "-n", "prod"], { connect: cluster.connector });
31
+
32
+ expect(code).toBe(0);
33
+ expect(log.mock.calls[0][0]).toContain("condition met");
34
+ });
35
+
36
+ test("--for=condition=<Type>=<Status> overrides the registry", async () => {
37
+ const cluster = fakeCluster({
38
+ objects: {
39
+ [objectKey("apps/v1", "Deployment", "web", "prod")]: {
40
+ apiVersion: "apps/v1",
41
+ kind: "Deployment",
42
+ metadata: { name: "web", namespace: "prod", uid: "u1" },
43
+ status: { conditions: [{ type: "Available", status: "True" }] },
44
+ },
45
+ },
46
+ });
47
+ const { log } = spyConsole();
48
+
49
+ const code = await runWait(["deployment", "web", "-n", "prod", "--for=condition=Available"], {
50
+ connect: cluster.connector,
51
+ });
52
+
53
+ expect(code).toBe(0);
54
+ expect(log.mock.calls[0][0]).toContain("condition met");
55
+ });
56
+
57
+ test("--for=delete polls for absence", async () => {
58
+ const cluster = fakeCluster({});
59
+ const { log } = spyConsole();
60
+
61
+ const code = await runWait(["deployment", "ghost", "-n", "prod", "--for=delete", "--timeout=5s"], {
62
+ connect: cluster.connector,
63
+ });
64
+
65
+ expect(code).toBe(0);
66
+ expect(log.mock.calls[0][0]).toContain("deleted");
67
+ });
68
+
69
+ test("a resource that never becomes ready times out honestly", async () => {
70
+ const cluster = fakeCluster({
71
+ objects: { [objectKey("cert-manager.io/v1", "Certificate", "web-tls", "prod")]: certificate(false) },
72
+ });
73
+ const { error } = spyConsole();
74
+
75
+ const code = await runWait(["certificate", "web-tls", "-n", "prod", "--timeout=1s"], { connect: cluster.connector });
76
+
77
+ expect(code).toBe(1);
78
+ expect(error.mock.calls[0][0]).toContain("timed out");
79
+ }, 10_000);
80
+
81
+ test("binding mismatch refuses loudly, before any wait loop starts", async () => {
82
+ const { error } = spyConsole();
83
+ const connect = async () => {
84
+ throw new ClusterBindingMismatchError("prod", "prod-eks", "dev-eks");
85
+ };
86
+
87
+ const code = await runWait(["deployment", "web", "--env", "prod"], { connect });
88
+
89
+ expect(code).toBe(1);
90
+ expect(error.mock.calls[0][0]).toContain("no binding for this environment");
91
+ });
92
+
93
+ test("unknown flag is rejected before any connection", async () => {
94
+ const { error } = spyConsole();
95
+ const connect = vi.fn();
96
+
97
+ const code = await runWait(["deployment", "web", "--bogus"], { connect });
98
+
99
+ expect(code).toBe(1);
100
+ expect(error.mock.calls[0][0]).toContain("Unknown flag");
101
+ expect(connect).not.toHaveBeenCalled();
102
+ });
103
+
104
+ test("wait requires a single named resource", async () => {
105
+ const { error } = spyConsole();
106
+ const connect = vi.fn();
107
+
108
+ const code = await runWait(["deployment"], { connect });
109
+
110
+ expect(code).toBe(1);
111
+ expect(error.mock.calls[0][0]).toContain("requires a single named resource");
112
+ expect(connect).not.toHaveBeenCalled();
113
+ });
114
+
115
+ test("tri-state: a kind the cluster does not serve renders an explicit error", async () => {
116
+ const cluster = fakeCluster({ serves: [] });
117
+ const { error, log } = spyConsole();
118
+
119
+ const code = await runWait(["widgets", "gizmo"], { connect: cluster.connector });
120
+
121
+ expect(code).toBe(1);
122
+ expect(error.mock.calls[0][0]).toContain(`doesn't have a resource type "widgets"`);
123
+ expect(log).not.toHaveBeenCalled();
124
+ });
125
+ });