@intentius/chant-lexicon-k8s 0.30.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.
- package/dist/api/classify.d.ts +65 -0
- package/dist/api/classify.d.ts.map +1 -0
- package/dist/api/connect.d.ts +58 -0
- package/dist/api/connect.d.ts.map +1 -0
- package/dist/api/fake-cluster.d.ts +55 -0
- package/dist/api/fake-cluster.d.ts.map +1 -0
- package/dist/api/operation-surface.d.ts +64 -0
- package/dist/api/operation-surface.d.ts.map +1 -0
- package/dist/api/owner-chain.d.ts +46 -0
- package/dist/api/owner-chain.d.ts.map +1 -0
- package/dist/api/sweep-types.d.ts +17 -0
- package/dist/api/sweep-types.d.ts.map +1 -0
- package/dist/codegen/generate-operations.d.ts +29 -0
- package/dist/codegen/generate-operations.d.ts.map +1 -0
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/crd/parser.d.ts.map +1 -1
- package/dist/crd/types.d.ts +7 -0
- package/dist/crd/types.d.ts.map +1 -1
- package/dist/deep-observe-hooks.d.ts +86 -0
- package/dist/deep-observe-hooks.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +146 -0
- package/dist/deep-observe.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +58 -26
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/export-resources.d.ts +31 -1
- package/dist/export-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/kube/apply.d.ts +26 -0
- package/dist/kube/apply.d.ts.map +1 -0
- package/dist/kube/connect.d.ts +30 -0
- package/dist/kube/connect.d.ts.map +1 -0
- package/dist/kube/delete.d.ts +27 -0
- package/dist/kube/delete.d.ts.map +1 -0
- package/dist/kube/describe.d.ts +19 -0
- package/dist/kube/describe.d.ts.map +1 -0
- package/dist/kube/events.d.ts +34 -0
- package/dist/kube/events.d.ts.map +1 -0
- package/dist/kube/flags.d.ts +40 -0
- package/dist/kube/flags.d.ts.map +1 -0
- package/dist/kube/get.d.ts +21 -0
- package/dist/kube/get.d.ts.map +1 -0
- package/dist/kube/group.d.ts +19 -0
- package/dist/kube/group.d.ts.map +1 -0
- package/dist/kube/logs.d.ts +16 -0
- package/dist/kube/logs.d.ts.map +1 -0
- package/dist/kube/project.d.ts +59 -0
- package/dist/kube/project.d.ts.map +1 -0
- package/dist/kube/render.d.ts +75 -0
- package/dist/kube/render.d.ts.map +1 -0
- package/dist/kube/source.d.ts +19 -0
- package/dist/kube/source.d.ts.map +1 -0
- package/dist/kube/target.d.ts +52 -0
- package/dist/kube/target.d.ts.map +1 -0
- package/dist/kube/testing.d.ts +21 -0
- package/dist/kube/testing.d.ts.map +1 -0
- package/dist/kube/top.d.ts +23 -0
- package/dist/kube/top.d.ts.map +1 -0
- package/dist/kube/verdict.d.ts +45 -0
- package/dist/kube/verdict.d.ts.map +1 -0
- package/dist/kube/wait.d.ts +23 -0
- package/dist/kube/wait.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.d.ts +7 -4
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts +120 -2
- package/dist/op/activities/kubectl.d.ts.map +1 -1
- package/dist/op/activities/wait-for-ready.d.ts +30 -3
- package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/spec/parse.d.ts +42 -0
- package/dist/spec/parse.d.ts.map +1 -1
- package/package.json +5 -2
- package/src/api/classify.test.ts +133 -0
- package/src/api/classify.ts +144 -0
- package/src/api/connect.ts +104 -0
- package/src/api/fake-cluster.ts +218 -0
- package/src/api/operation-surface.test.ts +116 -0
- package/src/api/operation-surface.ts +129 -0
- package/src/api/owner-chain.test.ts +171 -0
- package/src/api/owner-chain.ts +144 -0
- package/src/api/sweep-types.ts +36 -0
- package/src/codegen/generate-operations.ts +56 -0
- package/src/codegen/generate.ts +9 -0
- package/src/config.ts +17 -0
- package/src/crd/parser.ts +8 -0
- package/src/crd/types.ts +7 -0
- package/src/deep-observe-hooks.ts +125 -0
- package/src/deep-observe.test.ts +668 -0
- package/src/deep-observe.ts +299 -0
- package/src/describe-resources.test.ts +619 -191
- package/src/describe-resources.ts +218 -117
- package/src/export-resources-io.test.ts +76 -51
- package/src/export-resources.ts +47 -35
- package/src/generated/operations.json +2156 -0
- package/src/kube/apply.test.ts +105 -0
- package/src/kube/apply.ts +101 -0
- package/src/kube/connect.ts +54 -0
- package/src/kube/delete.test.ts +123 -0
- package/src/kube/delete.ts +174 -0
- package/src/kube/describe.test.ts +106 -0
- package/src/kube/describe.ts +195 -0
- package/src/kube/events.test.ts +115 -0
- package/src/kube/events.ts +160 -0
- package/src/kube/flags.test.ts +65 -0
- package/src/kube/flags.ts +114 -0
- package/src/kube/get.test.ts +195 -0
- package/src/kube/get.ts +190 -0
- package/src/kube/group.test.ts +33 -0
- package/src/kube/group.ts +125 -0
- package/src/kube/logs.test.ts +126 -0
- package/src/kube/logs.ts +96 -0
- package/src/kube/project.test.ts +57 -0
- package/src/kube/project.ts +106 -0
- package/src/kube/render.test.ts +142 -0
- package/src/kube/render.ts +252 -0
- package/src/kube/source.test.ts +138 -0
- package/src/kube/source.ts +139 -0
- package/src/kube/target.test.ts +67 -0
- package/src/kube/target.ts +89 -0
- package/src/kube/testing.ts +42 -0
- package/src/kube/top.test.ts +149 -0
- package/src/kube/top.ts +120 -0
- package/src/kube/verdict.test.ts +48 -0
- package/src/kube/verdict.ts +77 -0
- package/src/kube/wait.test.ts +125 -0
- package/src/kube/wait.ts +160 -0
- package/src/lifecycle-integration.test.ts +132 -92
- package/src/op/activities/index.ts +7 -3
- package/src/op/activities/kubectl.test.ts +420 -0
- package/src/op/activities/kubectl.ts +379 -13
- package/src/op/activities/wait-for-ready.test.ts +94 -0
- package/src/op/activities/wait-for-ready.ts +66 -15
- package/src/plugin.test.ts +58 -1
- package/src/plugin.ts +29 -0
- package/src/spec/parse.ts +93 -1
package/src/kube/top.ts
ADDED
|
@@ -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
|
+
});
|
package/src/kube/wait.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant kube wait` (chant #1079) — the issue names this one explicitly:
|
|
3
|
+
* "`wait` should use the existing readiness-spec registry in
|
|
4
|
+
* `op/activities/wait-for-ready.ts`, which already handles the Argo case
|
|
5
|
+
* where health and sync replace a `Ready` condition — something `kubectl
|
|
6
|
+
* wait --for` cannot express without jsonpath gymnastics." This verb is a
|
|
7
|
+
* thin CLI shell around that exact registry (`readinessFor`/`isReady`/
|
|
8
|
+
* `firstTerminal`/`waitForReady`) — not a second implementation of it.
|
|
9
|
+
*
|
|
10
|
+
* `--for=condition=<Type>[=<Status>]` overrides the registry with a single
|
|
11
|
+
* condition check, matching kubectl's own flag. `--for=delete` polls for
|
|
12
|
+
* absence instead. Neither given falls back to the registry: the generic
|
|
13
|
+
* `Ready` condition, or a resource-specific override (Argo's `Application`
|
|
14
|
+
* today).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { formatUnobserved } from "@intentius/chant/observation";
|
|
18
|
+
import { defaultK8sConnector, type K8sConnector } from "../api/connect";
|
|
19
|
+
import { classifyApiFailure } from "../api/classify";
|
|
20
|
+
import {
|
|
21
|
+
waitForReady,
|
|
22
|
+
readinessFor,
|
|
23
|
+
ReadinessFailedError,
|
|
24
|
+
type ReadinessSpec,
|
|
25
|
+
type ResourceFetcher,
|
|
26
|
+
} from "../op/activities/wait-for-ready";
|
|
27
|
+
import { kubeConnect } from "./connect";
|
|
28
|
+
import { parseKubeFlags, connectOptionsFrom, parseDurationMs } from "./flags";
|
|
29
|
+
import { loadKubeProjectContext, type KubeProjectContext } from "./project";
|
|
30
|
+
import { resolveKubeTarget, isTargetError, type KubeTarget } from "./target";
|
|
31
|
+
|
|
32
|
+
export interface WaitDeps {
|
|
33
|
+
connect?: K8sConnector;
|
|
34
|
+
loadProject?: (cwd?: string) => Promise<KubeProjectContext | undefined>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type ForSpec = { kind: "delete" } | { kind: "spec"; spec: ReadinessSpec };
|
|
38
|
+
|
|
39
|
+
function parseFor(value: string | undefined): ForSpec | undefined {
|
|
40
|
+
if (value === undefined) return undefined;
|
|
41
|
+
if (value === "delete") return { kind: "delete" };
|
|
42
|
+
const match = /^condition=([^=]+)(?:=(.+))?$/.exec(value);
|
|
43
|
+
if (!match) {
|
|
44
|
+
throw new Error(`--for expects "condition=<Type>[=<Status>]" or "delete", got "${value}"`);
|
|
45
|
+
}
|
|
46
|
+
return { kind: "spec", spec: { ready: [{ conditionType: match[1], status: match[2] ?? "True" }] } };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function targetLabel(target: KubeTarget): string {
|
|
50
|
+
return "apiVersion" in target.selector ? target.selector.kind : target.selector.resource;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function runWait(rawArgs: string[], deps: WaitDeps = {}): Promise<number> {
|
|
54
|
+
const connect = deps.connect ?? defaultK8sConnector;
|
|
55
|
+
const loadProject = deps.loadProject ?? loadKubeProjectContext;
|
|
56
|
+
|
|
57
|
+
let flags;
|
|
58
|
+
let forSpec: ForSpec | undefined;
|
|
59
|
+
let timeoutMs: number;
|
|
60
|
+
try {
|
|
61
|
+
flags = parseKubeFlags(rawArgs, { value: { "--for": "for", "--timeout": "timeout" } });
|
|
62
|
+
forSpec = parseFor(flags.values.for);
|
|
63
|
+
timeoutMs = flags.values.timeout !== undefined ? parseDurationMs(flags.values.timeout) : 30_000;
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
66
|
+
return 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const project = await loadProject();
|
|
70
|
+
const target = resolveKubeTarget(flags.positional, flags.values.namespace, project);
|
|
71
|
+
if (isTargetError(target)) {
|
|
72
|
+
console.error(`error: ${target.error}`);
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
if (!target.name) {
|
|
76
|
+
console.error("error: chant kube wait requires a single named resource, e.g. `chant kube wait deployment web`");
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const connected = await kubeConnect(connectOptionsFrom(flags.values), connect);
|
|
81
|
+
if (connected.kind === "unobserved") {
|
|
82
|
+
console.error(formatUnobserved(targetLabel(target), { reason: connected.reason, detail: connected.detail }));
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
const { client } = connected;
|
|
86
|
+
|
|
87
|
+
let info;
|
|
88
|
+
try {
|
|
89
|
+
info = await client.resolve(target.selector);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const outcome = classifyApiFailure(err);
|
|
92
|
+
console.error(
|
|
93
|
+
formatUnobserved(targetLabel(target), {
|
|
94
|
+
reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
|
|
95
|
+
detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
if (!info) {
|
|
101
|
+
console.error(`error: the server doesn't have a resource type "${targetLabel(target)}"`);
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const namespace = target.namespace ?? client.defaultNamespace;
|
|
106
|
+
const controller = new AbortController();
|
|
107
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
if (forSpec?.kind === "delete") {
|
|
111
|
+
await waitForDeletion(client, { apiVersion: info.apiVersion, kind: info.kind, name: target.name, namespace }, controller.signal);
|
|
112
|
+
console.log(`${info.kind}/${target.name} deleted`);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const spec = forSpec?.kind === "spec" ? forSpec.spec : readinessFor(info.group, info.kind);
|
|
117
|
+
const fetcher: ResourceFetcher = async (args, signal) =>
|
|
118
|
+
(await client.read(
|
|
119
|
+
{ apiVersion: info.apiVersion, kind: info.kind, name: args.name, ...(args.namespace ? { namespace: args.namespace } : {}) },
|
|
120
|
+
{ signal },
|
|
121
|
+
)) as Record<string, unknown>;
|
|
122
|
+
|
|
123
|
+
await waitForReady({ kind: info.kind, name: target.name, namespace, group: info.group, spec }, controller.signal, fetcher);
|
|
124
|
+
console.log(`${info.kind}/${target.name} condition met`);
|
|
125
|
+
return 0;
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err instanceof ReadinessFailedError) {
|
|
128
|
+
console.error(err.message);
|
|
129
|
+
return 1;
|
|
130
|
+
}
|
|
131
|
+
if (controller.signal.aborted) {
|
|
132
|
+
console.error(`error: timed out after ${timeoutMs}ms waiting for ${info.kind}/${target.name}`);
|
|
133
|
+
return 1;
|
|
134
|
+
}
|
|
135
|
+
const outcome = classifyApiFailure(err);
|
|
136
|
+
console.error(
|
|
137
|
+
formatUnobserved(`${info.kind}/${target.name}`, {
|
|
138
|
+
reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
|
|
139
|
+
detail: outcome.kind === "unobserved" ? outcome.detail : String(err),
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
return 1;
|
|
143
|
+
} finally {
|
|
144
|
+
clearTimeout(timer);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function waitForDeletion(
|
|
149
|
+
client: { readIfPresent: (ref: { apiVersion: string; kind: string; name: string; namespace?: string }, opts: { signal?: AbortSignal }) => Promise<unknown> },
|
|
150
|
+
ref: { apiVersion: string; kind: string; name: string; namespace?: string },
|
|
151
|
+
signal: AbortSignal,
|
|
152
|
+
intervalMs = 5_000,
|
|
153
|
+
): Promise<void> {
|
|
154
|
+
while (true) {
|
|
155
|
+
if (signal.aborted) throw new Error("wait --for=delete aborted");
|
|
156
|
+
const obj = await client.readIfPresent(ref, { signal });
|
|
157
|
+
if (!obj) return;
|
|
158
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
159
|
+
}
|
|
160
|
+
}
|