@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
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fake cluster to point the k8s lexicon at, for tests (chant #1074).
|
|
3
|
+
*
|
|
4
|
+
* It builds a **real** `@intentius/chant-k8s-client` over a literal kubeconfig
|
|
5
|
+
* with `@kubernetes/client-node`'s HTTP send replaced. So kubeconfig parsing,
|
|
6
|
+
* context selection, the credential policy, API discovery, path construction
|
|
7
|
+
* and the auth path all run for real; the only thing that does not happen is
|
|
8
|
+
* the socket. Nothing reads `~/.kube/config` or `$KUBECONFIG`, and nothing can
|
|
9
|
+
* reach a cluster the developer happens to have a context for.
|
|
10
|
+
*
|
|
11
|
+
* Shipped rather than kept in a test file because the lexicon's own tests, the
|
|
12
|
+
* cross-lexicon lifecycle suite and a consumer wiring chant into their harness
|
|
13
|
+
* all need the same thing.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { K8sObject } from "@intentius/chant-k8s-client";
|
|
17
|
+
import { apiResourceList, fakeKubeconfig, fakeRequestLayer, statusBody } from "@intentius/chant-k8s-client/testing";
|
|
18
|
+
import type { FakeRequestLayer, RecordedRequest } from "@intentius/chant-k8s-client/testing";
|
|
19
|
+
import { createK8sClient } from "@intentius/chant-k8s-client";
|
|
20
|
+
import type { ConnectedClient, ConnectOptions, K8sConnector } from "./connect";
|
|
21
|
+
import { operationTable } from "./operation-surface";
|
|
22
|
+
|
|
23
|
+
export interface FakeClusterOptions {
|
|
24
|
+
/**
|
|
25
|
+
* Objects the cluster holds, keyed by
|
|
26
|
+
* `<apiVersion>/<Kind>/<namespace|_>/<name>`, e.g.
|
|
27
|
+
* `apps/v1/Deployment/prod/web`. Build keys with {@link objectKey}.
|
|
28
|
+
*/
|
|
29
|
+
objects?: Record<string, K8sObject>;
|
|
30
|
+
/**
|
|
31
|
+
* Entity types (or `<apiVersion> <Kind>` pairs) this cluster serves. Defaults
|
|
32
|
+
* to every type in the generated operation surface, which is what makes a
|
|
33
|
+
* CRD resolvable in a test without registering anything.
|
|
34
|
+
*/
|
|
35
|
+
serves?: readonly string[];
|
|
36
|
+
/** Full control: return a response for a request, or undefined to fall through. */
|
|
37
|
+
respond?: (request: RecordedRequest) => { status?: number; body?: unknown } | undefined;
|
|
38
|
+
/** Kubeconfig to hand the client. Defaults to a single-context one. */
|
|
39
|
+
kubeconfig?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FakeCluster {
|
|
43
|
+
connector: K8sConnector;
|
|
44
|
+
layer: FakeRequestLayer;
|
|
45
|
+
/** Connect options every call received, for asserting the binding path. */
|
|
46
|
+
connects: ConnectOptions[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Key an object the way {@link fakeCluster} indexes them. */
|
|
50
|
+
export function objectKey(apiVersion: string, kind: string, name: string, namespace?: string): string {
|
|
51
|
+
return `${apiVersion}/${kind}/${namespace ?? "_"}/${name}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build a live object with chant's ownership marker already on it. */
|
|
55
|
+
export function ownedObject(
|
|
56
|
+
apiVersion: string,
|
|
57
|
+
kind: string,
|
|
58
|
+
name: string,
|
|
59
|
+
namespace: string | undefined,
|
|
60
|
+
extra: Partial<K8sObject> = {},
|
|
61
|
+
): K8sObject {
|
|
62
|
+
return {
|
|
63
|
+
apiVersion,
|
|
64
|
+
kind,
|
|
65
|
+
metadata: {
|
|
66
|
+
name,
|
|
67
|
+
...(namespace ? { namespace } : {}),
|
|
68
|
+
uid: `uid-${name}`,
|
|
69
|
+
resourceVersion: "1",
|
|
70
|
+
labels: { "app.kubernetes.io/managed-by": "chant" },
|
|
71
|
+
...(extra.metadata ?? {}),
|
|
72
|
+
},
|
|
73
|
+
...(extra.status ? { status: extra.status } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ServedResource {
|
|
78
|
+
apiVersion: string;
|
|
79
|
+
kind: string;
|
|
80
|
+
plural: string;
|
|
81
|
+
namespaced: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function servedResources(serves: readonly string[] | undefined): ServedResource[] {
|
|
85
|
+
const table = operationTable();
|
|
86
|
+
const entries = serves
|
|
87
|
+
? serves.map((s) => table[s]).filter((d) => d !== undefined)
|
|
88
|
+
: Object.values(table);
|
|
89
|
+
return entries
|
|
90
|
+
.filter((d) => d.verbs.length > 0)
|
|
91
|
+
.map((d) => ({
|
|
92
|
+
apiVersion: d.apiVersion,
|
|
93
|
+
kind: d.kind,
|
|
94
|
+
plural: d.plural,
|
|
95
|
+
namespaced: d.scope === "Namespaced",
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A cluster that answers discovery for the kinds it serves and holds the
|
|
101
|
+
* objects it was given. Anything else 404s with a real `Status` body, which is
|
|
102
|
+
* how a test drives the absent branch of the observation tri-state.
|
|
103
|
+
*/
|
|
104
|
+
export function fakeCluster(options: FakeClusterOptions = {}): FakeCluster {
|
|
105
|
+
const resources = servedResources(options.serves);
|
|
106
|
+
const byApiVersion = new Map<string, ServedResource[]>();
|
|
107
|
+
for (const r of resources) {
|
|
108
|
+
const list = byApiVersion.get(r.apiVersion) ?? [];
|
|
109
|
+
list.push(r);
|
|
110
|
+
byApiVersion.set(r.apiVersion, list);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Path → the object it addresses, and list path → its members, precomputed
|
|
114
|
+
// so the responder below is a pair of lookups.
|
|
115
|
+
const byPath = new Map<string, K8sObject>();
|
|
116
|
+
const listPaths = new Map<string, K8sObject[]>();
|
|
117
|
+
const addToList = (path: string, object: K8sObject): void => {
|
|
118
|
+
const list = listPaths.get(path) ?? [];
|
|
119
|
+
list.push(object);
|
|
120
|
+
listPaths.set(path, list);
|
|
121
|
+
};
|
|
122
|
+
for (const [key, object] of Object.entries(options.objects ?? {})) {
|
|
123
|
+
const [group, version, kind, namespace, name] = splitKey(key);
|
|
124
|
+
const apiVersion = group ? `${group}/${version}` : version;
|
|
125
|
+
const resource = resources.find((r) => r.apiVersion === apiVersion && r.kind === kind);
|
|
126
|
+
if (!resource) continue;
|
|
127
|
+
const base = apiVersion.includes("/") ? `/apis/${apiVersion}` : `/api/${apiVersion}`;
|
|
128
|
+
if (resource.namespaced) {
|
|
129
|
+
const ns = namespace === "_" ? "default" : namespace;
|
|
130
|
+
byPath.set(`${base}/namespaces/${ns}/${resource.plural}/${name}`, object);
|
|
131
|
+
addToList(`${base}/namespaces/${ns}/${resource.plural}`, object);
|
|
132
|
+
} else {
|
|
133
|
+
byPath.set(`${base}/${resource.plural}/${name}`, object);
|
|
134
|
+
}
|
|
135
|
+
// Cluster-wide list, which is what `chant import` sweeps.
|
|
136
|
+
addToList(`${base}/${resource.plural}`, object);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const groups = new Map<string, string>();
|
|
140
|
+
for (const apiVersion of byApiVersion.keys()) {
|
|
141
|
+
if (!apiVersion.includes("/")) continue;
|
|
142
|
+
const group = apiVersion.slice(0, apiVersion.indexOf("/"));
|
|
143
|
+
if (!groups.has(group)) groups.set(group, apiVersion);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const layer = fakeRequestLayer((request) => {
|
|
147
|
+
const custom = options.respond?.(request);
|
|
148
|
+
if (custom !== undefined) return custom;
|
|
149
|
+
|
|
150
|
+
if (request.path === "/api") return { body: { kind: "APIVersions", versions: ["v1"] } };
|
|
151
|
+
if (request.path === "/apis") {
|
|
152
|
+
return {
|
|
153
|
+
body: {
|
|
154
|
+
kind: "APIGroupList",
|
|
155
|
+
groups: [...groups].map(([name, apiVersion]) => ({
|
|
156
|
+
name,
|
|
157
|
+
preferredVersion: { groupVersion: apiVersion, version: apiVersion.split("/")[1] },
|
|
158
|
+
versions: [{ groupVersion: apiVersion }],
|
|
159
|
+
})),
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const apiVersion = apiVersionFromDiscoveryPath(request.path);
|
|
165
|
+
if (apiVersion) {
|
|
166
|
+
const served = byApiVersion.get(apiVersion);
|
|
167
|
+
if (!served) return { status: 404, body: statusBody(404, "NotFound", "the server could not find the requested resource") };
|
|
168
|
+
return {
|
|
169
|
+
body: apiResourceList(
|
|
170
|
+
apiVersion,
|
|
171
|
+
served.map((r) => ({ name: r.plural, kind: r.kind, namespaced: r.namespaced })),
|
|
172
|
+
),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const object = byPath.get(request.path);
|
|
177
|
+
if (object) return { body: object };
|
|
178
|
+
|
|
179
|
+
if (isListPath(request.path, resources)) {
|
|
180
|
+
return { body: { kind: "List", items: listPaths.get(request.path) ?? [], metadata: {} } };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { status: 404, body: statusBody(404, "NotFound", `${request.path} not found`) };
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const connects: ConnectOptions[] = [];
|
|
187
|
+
const connector: K8sConnector = async (connectOptions): Promise<ConnectedClient> => {
|
|
188
|
+
connects.push(connectOptions);
|
|
189
|
+
const client = await createK8sClient({
|
|
190
|
+
kubeconfig: options.kubeconfig ?? fakeKubeconfig(),
|
|
191
|
+
requestLayer: layer,
|
|
192
|
+
...(connectOptions.context ? { context: connectOptions.context } : {}),
|
|
193
|
+
...connectOptions.client,
|
|
194
|
+
});
|
|
195
|
+
return { client, target: { source: "ambient" } };
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
return { connector, layer, connects };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function splitKey(key: string): [group: string, version: string, kind: string, namespace: string, name: string] {
|
|
202
|
+
const parts = key.split("/");
|
|
203
|
+
// `apps/v1/Deployment/prod/web` (5) or `v1/Service/prod/web` (4)
|
|
204
|
+
if (parts.length === 5) return [parts[0], parts[1], parts[2], parts[3], parts[4]];
|
|
205
|
+
return ["", parts[0], parts[1], parts[2], parts[3]];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function apiVersionFromDiscoveryPath(path: string): string | undefined {
|
|
209
|
+
if (/^\/api\/[^/]+$/.test(path)) return path.slice("/api/".length);
|
|
210
|
+
if (/^\/apis\/[^/]+\/[^/]+$/.test(path)) return path.slice("/apis/".length);
|
|
211
|
+
return undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** True when the path addresses a collection of a kind this cluster serves. */
|
|
215
|
+
function isListPath(path: string, resources: ServedResource[]): boolean {
|
|
216
|
+
const plural = path.split("/").pop();
|
|
217
|
+
return resources.some((r) => r.plural === plural);
|
|
218
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generated operation surface (chant #1074) — and the anti-skew gate that
|
|
3
|
+
* is the whole reason it is generated rather than written.
|
|
4
|
+
*
|
|
5
|
+
* The issue's requirement is that the client's addressing and the declarable
|
|
6
|
+
* types come out of the same codegen pass "so types and client cannot skew".
|
|
7
|
+
* Generating them together is the mechanism; this file is the proof, and it is
|
|
8
|
+
* what would fail if someone later added a resource to one artifact and not the
|
|
9
|
+
* other.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, test, expect } from "vitest";
|
|
13
|
+
import { createRequire } from "module";
|
|
14
|
+
import { addressableEntityTypes, deriveOperation, operationFor, operationTable, pluralizeKind } from "./operation-surface";
|
|
15
|
+
|
|
16
|
+
const require = createRequire(import.meta.url);
|
|
17
|
+
|
|
18
|
+
interface RegistryEntry {
|
|
19
|
+
resourceType: string;
|
|
20
|
+
kind: "resource" | "property";
|
|
21
|
+
apiVersion?: string;
|
|
22
|
+
gvkKind?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const registry = require("../generated/lexicon-k8s.json") as Record<string, RegistryEntry>;
|
|
26
|
+
|
|
27
|
+
describe("operation surface ↔ declarable registry (the anti-skew gate)", () => {
|
|
28
|
+
test("every registry resource with a GVK has an operation entry saying the same thing", () => {
|
|
29
|
+
const table = operationTable();
|
|
30
|
+
const missing: string[] = [];
|
|
31
|
+
const disagreeing: string[] = [];
|
|
32
|
+
|
|
33
|
+
for (const entry of Object.values(registry)) {
|
|
34
|
+
if (entry.kind !== "resource" || !entry.apiVersion || !entry.gvkKind) continue;
|
|
35
|
+
const operation = table[entry.resourceType];
|
|
36
|
+
if (!operation) {
|
|
37
|
+
missing.push(entry.resourceType);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (operation.apiVersion !== entry.apiVersion || operation.kind !== entry.gvkKind) {
|
|
41
|
+
disagreeing.push(
|
|
42
|
+
`${entry.resourceType}: registry ${entry.apiVersion}/${entry.gvkKind} vs operations ${operation.apiVersion}/${operation.kind}`,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
expect(missing, "resources the serializer can emit but the client cannot address").toEqual([]);
|
|
48
|
+
expect(disagreeing, "resources the serializer and the client disagree about").toEqual([]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("no operation entry names a type the registry does not carry", () => {
|
|
52
|
+
const known = new Set(Object.values(registry).map((e) => e.resourceType));
|
|
53
|
+
const orphans = addressableEntityTypes().filter((t) => !known.has(t));
|
|
54
|
+
expect(orphans, "operation entries with no declarable type behind them").toEqual([]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("coverage is an order of magnitude past the twenty-entry map it replaces", () => {
|
|
58
|
+
// The retired KUBECTL_RESOURCE had 20 entries and every CRD fell off it.
|
|
59
|
+
expect(addressableEntityTypes().length).toBeGreaterThan(150);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("addressing", () => {
|
|
64
|
+
test.each([
|
|
65
|
+
["K8s::Apps::Deployment", "apps/v1", "Deployment", "deployments", "Namespaced"],
|
|
66
|
+
["K8s::Core::Pod", "v1", "Pod", "pods", "Namespaced"],
|
|
67
|
+
["K8s::Core::Namespace", "v1", "Namespace", "namespaces", "Cluster"],
|
|
68
|
+
["K8s::Rbac::ClusterRole", "rbac.authorization.k8s.io/v1", "ClusterRole", "clusterroles", "Cluster"],
|
|
69
|
+
["K8s::Networking::Ingress", "networking.k8s.io/v1", "Ingress", "ingresses", "Namespaced"],
|
|
70
|
+
["K8s::Batch::CronJob", "batch/v1", "CronJob", "cronjobs", "Namespaced"],
|
|
71
|
+
])("%s → %s %s /%s (%s)", (entityType, apiVersion, kind, plural, scope) => {
|
|
72
|
+
expect(operationFor(entityType)).toMatchObject({ apiVersion, kind, plural, scope });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// Every one of these was `unsupported-kind` before #1074.
|
|
76
|
+
test.each([
|
|
77
|
+
["K8s::Ray::RayCluster", "ray.io/v1", "RayCluster", "rayclusters"],
|
|
78
|
+
["K8s::Argo::Application", "argoproj.io/v1alpha1", "Application", "applications"],
|
|
79
|
+
["K8s::CertManager::Certificate", "cert-manager.io/v1", "Certificate", "certificates"],
|
|
80
|
+
["K8s::Monitoring::ServiceMonitor", "monitoring.coreos.com/v1", "ServiceMonitor", "servicemonitors"],
|
|
81
|
+
])("CRD %s → %s %s /%s", (entityType, apiVersion, kind, plural) => {
|
|
82
|
+
expect(operationFor(entityType)).toMatchObject({ apiVersion, kind, plural });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("the plural and scope come from the schema, not from a guess", () => {
|
|
86
|
+
// `ingresses`, not `ingresss`; `namespaces` cluster-scoped, not namespaced.
|
|
87
|
+
const ingress = operationFor("K8s::Networking::Ingress")!;
|
|
88
|
+
expect(ingress.plural).toBe("ingresses");
|
|
89
|
+
expect(operationFor("K8s::Core::Namespace")!.scope).toBe("Cluster");
|
|
90
|
+
// And verbs are documented rather than assumed.
|
|
91
|
+
expect(ingress.verbs).toContain("get");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("an entity type in no known API group is not guessed at", () => {
|
|
95
|
+
expect(operationFor("K8s::NotAGroupChantKnows::Thing")).toBeUndefined();
|
|
96
|
+
expect(operationFor("AWS::S3::Bucket")).toBeUndefined();
|
|
97
|
+
expect(operationFor("nonsense")).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("derivation covers a checkout with no generated artifacts", () => {
|
|
101
|
+
expect(deriveOperation("K8s::Apps::Deployment")).toMatchObject({ apiVersion: "apps/v1", kind: "Deployment" });
|
|
102
|
+
expect(deriveOperation("K8s::Ray::RayCluster")).toBeUndefined();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("pluralizeKind", () => {
|
|
107
|
+
test.each([
|
|
108
|
+
["Deployment", "deployments"],
|
|
109
|
+
["Ingress", "ingresses"],
|
|
110
|
+
["NetworkPolicy", "networkpolicies"],
|
|
111
|
+
["Endpoints", "endpointses"],
|
|
112
|
+
["Gateway", "gateways"],
|
|
113
|
+
])("%s → %s", (kind, plural) => {
|
|
114
|
+
expect(pluralizeKind(kind)).toBe(plural);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the generated operation surface — chant #1074.
|
|
3
|
+
*
|
|
4
|
+
* The table itself is written by `chant generate` (see
|
|
5
|
+
* `../codegen/generate-operations.ts`); this is the runtime side, and it is
|
|
6
|
+
* deliberately the only place in the lexicon that knows how an entity type
|
|
7
|
+
* becomes an API address.
|
|
8
|
+
*
|
|
9
|
+
* Loading mirrors the serializer's handling of `lexicon-k8s.json`: a `require`
|
|
10
|
+
* behind a try/catch, so a checkout that has not run `chant generate` yet
|
|
11
|
+
* degrades to derivation rather than failing to import. Nothing here touches
|
|
12
|
+
* the filesystem at module load (chant #1081) — the first call does, and caches.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createRequire } from "module";
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
|
|
19
|
+
/** One resource's addressing information, as `chant generate` emits it. */
|
|
20
|
+
export interface K8sOperationDescriptor {
|
|
21
|
+
/** chant entity type, e.g. `K8s::Apps::Deployment`. */
|
|
22
|
+
entityType: string;
|
|
23
|
+
/** `v1`, `apps/v1`, `ray.io/v1` — what goes in a manifest. */
|
|
24
|
+
apiVersion: string;
|
|
25
|
+
/** `Deployment`, `RayCluster`. */
|
|
26
|
+
kind: string;
|
|
27
|
+
/** Plural path segment the schema documents, e.g. `deployments`. */
|
|
28
|
+
plural: string;
|
|
29
|
+
scope: "Namespaced" | "Cluster";
|
|
30
|
+
/** Verbs the schema documents for the named-object path. */
|
|
31
|
+
verbs: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The generated file's shape: entity type → descriptor. */
|
|
35
|
+
export type K8sOperationTable = Record<string, K8sOperationDescriptor>;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Fallback plural for a kind whose schema documented no named-object path.
|
|
39
|
+
*
|
|
40
|
+
* Kubernetes' own pluralization for resource names is the lowercased kind with
|
|
41
|
+
* English plural rules applied — the three cases `kubectl` implements. It only
|
|
42
|
+
* fires for kinds the OpenAPI paths do not cover, and the live client overrides
|
|
43
|
+
* it from the cluster's discovery regardless, so it is a placeholder rather
|
|
44
|
+
* than a second table to maintain.
|
|
45
|
+
*/
|
|
46
|
+
export function pluralizeKind(kind: string): string {
|
|
47
|
+
const lower = kind.toLowerCase();
|
|
48
|
+
if (/(s|x|z|ch|sh)$/.test(lower)) return `${lower}es`;
|
|
49
|
+
if (/[^aeiou]y$/.test(lower)) return `${lower.slice(0, -1)}ies`;
|
|
50
|
+
return `${lower}s`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Well-known API group → apiVersion, for a checkout with no generated table.
|
|
55
|
+
* Deliberately the same list the serializer falls back to, so a lexicon
|
|
56
|
+
* running without generated artifacts addresses what it serializes.
|
|
57
|
+
*/
|
|
58
|
+
const FALLBACK_GROUP_VERSIONS: Record<string, string> = {
|
|
59
|
+
Core: "v1",
|
|
60
|
+
Apps: "apps/v1",
|
|
61
|
+
Batch: "batch/v1",
|
|
62
|
+
Networking: "networking.k8s.io/v1",
|
|
63
|
+
Policy: "policy/v1",
|
|
64
|
+
Rbac: "rbac.authorization.k8s.io/v1",
|
|
65
|
+
Storage: "storage.k8s.io/v1",
|
|
66
|
+
Autoscaling: "autoscaling/v2",
|
|
67
|
+
Admissionregistration: "admissionregistration.k8s.io/v1",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
let cached: K8sOperationTable | null = null;
|
|
71
|
+
|
|
72
|
+
/** The generated table, loaded once. Empty when nothing has been generated. */
|
|
73
|
+
export function operationTable(): K8sOperationTable {
|
|
74
|
+
if (cached) return cached;
|
|
75
|
+
try {
|
|
76
|
+
cached = require("../generated/operations.json") as K8sOperationTable;
|
|
77
|
+
} catch {
|
|
78
|
+
cached = {};
|
|
79
|
+
}
|
|
80
|
+
return cached;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Reset the memoized table. Tests only. */
|
|
84
|
+
export function resetOperationTableForTests(): void {
|
|
85
|
+
cached = null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* How to address a declared entity type over the API.
|
|
90
|
+
*
|
|
91
|
+
* Returns undefined only for a type the generated surface does not carry and
|
|
92
|
+
* whose `K8s::<Group>::<Kind>` shape yields no known group — which is the one
|
|
93
|
+
* case where chant genuinely does not know what to ask for. Every generated
|
|
94
|
+
* resource type, including every CRD baked in at generation time, is in the
|
|
95
|
+
* table; that is the difference between this and the twenty-entry map it
|
|
96
|
+
* replaces.
|
|
97
|
+
*/
|
|
98
|
+
export function operationFor(entityType: string): K8sOperationDescriptor | undefined {
|
|
99
|
+
const table = operationTable();
|
|
100
|
+
const found = table[entityType];
|
|
101
|
+
if (found) return found;
|
|
102
|
+
return deriveOperation(entityType);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Last-resort derivation from the entity type's own shape, for a lexicon
|
|
107
|
+
* running without generated artifacts. It cannot invent an API group it has
|
|
108
|
+
* never seen, so an unknown group returns undefined rather than a guess that
|
|
109
|
+
* would address the wrong thing.
|
|
110
|
+
*/
|
|
111
|
+
export function deriveOperation(entityType: string): K8sOperationDescriptor | undefined {
|
|
112
|
+
const parts = entityType.split("::");
|
|
113
|
+
if (parts.length !== 3 || parts[0] !== "K8s") return undefined;
|
|
114
|
+
const apiVersion = FALLBACK_GROUP_VERSIONS[parts[1]];
|
|
115
|
+
if (!apiVersion) return undefined;
|
|
116
|
+
return {
|
|
117
|
+
entityType,
|
|
118
|
+
apiVersion,
|
|
119
|
+
kind: parts[2],
|
|
120
|
+
plural: pluralizeKind(parts[2]),
|
|
121
|
+
scope: "Namespaced",
|
|
122
|
+
verbs: [],
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Every entity type the generated surface can address. */
|
|
127
|
+
export function addressableEntityTypes(): string[] {
|
|
128
|
+
return Object.keys(operationTable()).sort();
|
|
129
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { describe, test, expect, vi } from "vitest";
|
|
2
|
+
import { resolveK8sOwnerChain } from "./owner-chain";
|
|
3
|
+
import type { K8sObject } from "@intentius/chant-k8s-client";
|
|
4
|
+
|
|
5
|
+
function pod(uid: string, ownerRefs?: Array<Record<string, unknown>>): K8sObject {
|
|
6
|
+
return {
|
|
7
|
+
apiVersion: "v1",
|
|
8
|
+
kind: "Pod",
|
|
9
|
+
metadata: { name: "p", namespace: "prod", uid, ...(ownerRefs ? { ownerReferences: ownerRefs } : {}) },
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function ownerRef(kind: string, name: string, uid: string, controller = true): Record<string, unknown> {
|
|
14
|
+
return { apiVersion: "apps/v1", kind, name, uid, controller };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("resolveK8sOwnerChain (#1077)", () => {
|
|
18
|
+
test("no ownerReferences at all → unowned", async () => {
|
|
19
|
+
const reader = { readIfPresent: vi.fn() };
|
|
20
|
+
const result = await resolveK8sOwnerChain(pod("pod-uid"), {
|
|
21
|
+
declaredByUid: new Map(),
|
|
22
|
+
reader,
|
|
23
|
+
namespace: "prod",
|
|
24
|
+
});
|
|
25
|
+
expect(result).toEqual({ root: "unowned" });
|
|
26
|
+
expect(reader.readIfPresent).not.toHaveBeenCalled();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("owner uid matches a declared entity directly → declared, no extra reads", async () => {
|
|
30
|
+
const reader = { readIfPresent: vi.fn() };
|
|
31
|
+
const result = await resolveK8sOwnerChain(pod("pod-uid", [ownerRef("Deployment", "web", "deploy-uid")]), {
|
|
32
|
+
declaredByUid: new Map([["deploy-uid", "web"]]),
|
|
33
|
+
reader,
|
|
34
|
+
namespace: "prod",
|
|
35
|
+
});
|
|
36
|
+
expect(result).toEqual({ root: "declared", entity: "web" });
|
|
37
|
+
expect(reader.readIfPresent).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("Pod → ReplicaSet (undeclared, read live) → Deployment (declared) resolves through the intermediate hop", async () => {
|
|
41
|
+
const replicaSet: K8sObject = {
|
|
42
|
+
apiVersion: "apps/v1",
|
|
43
|
+
kind: "ReplicaSet",
|
|
44
|
+
metadata: {
|
|
45
|
+
name: "web-7d9f8c9c8",
|
|
46
|
+
namespace: "prod",
|
|
47
|
+
uid: "rs-uid",
|
|
48
|
+
ownerReferences: [ownerRef("Deployment", "web", "deploy-uid")],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
const reader = { readIfPresent: vi.fn().mockResolvedValue(replicaSet) };
|
|
52
|
+
const result = await resolveK8sOwnerChain(pod("pod-uid", [ownerRef("ReplicaSet", "web-7d9f8c9c8", "rs-uid")]), {
|
|
53
|
+
declaredByUid: new Map([["deploy-uid", "web"]]),
|
|
54
|
+
reader,
|
|
55
|
+
namespace: "prod",
|
|
56
|
+
});
|
|
57
|
+
expect(result).toEqual({ root: "declared", entity: "web" });
|
|
58
|
+
expect(reader.readIfPresent).toHaveBeenCalledTimes(1);
|
|
59
|
+
expect(reader.readIfPresent).toHaveBeenCalledWith({
|
|
60
|
+
apiVersion: "apps/v1",
|
|
61
|
+
kind: "ReplicaSet",
|
|
62
|
+
name: "web-7d9f8c9c8",
|
|
63
|
+
namespace: "prod",
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("chain resolves fully to a live, undeclared root → foreign", async () => {
|
|
68
|
+
const replicaSet: K8sObject = {
|
|
69
|
+
apiVersion: "apps/v1",
|
|
70
|
+
kind: "ReplicaSet",
|
|
71
|
+
metadata: { name: "other-rs", namespace: "prod", uid: "rs-uid" }, // no further owner
|
|
72
|
+
};
|
|
73
|
+
const reader = { readIfPresent: vi.fn().mockResolvedValue(replicaSet) };
|
|
74
|
+
const result = await resolveK8sOwnerChain(pod("pod-uid", [ownerRef("ReplicaSet", "other-rs", "rs-uid")]), {
|
|
75
|
+
declaredByUid: new Map(), // nothing declared at all
|
|
76
|
+
reader,
|
|
77
|
+
namespace: "prod",
|
|
78
|
+
});
|
|
79
|
+
expect(result).toEqual({ root: "foreign" });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("an owner read that 404s (readIfPresent → undefined) is conservative unknown, not foreign", async () => {
|
|
83
|
+
const reader = { readIfPresent: vi.fn().mockResolvedValue(undefined) };
|
|
84
|
+
const result = await resolveK8sOwnerChain(pod("pod-uid", [ownerRef("ReplicaSet", "gone-rs", "rs-uid")]), {
|
|
85
|
+
declaredByUid: new Map(),
|
|
86
|
+
reader,
|
|
87
|
+
namespace: "prod",
|
|
88
|
+
});
|
|
89
|
+
expect(result).toEqual({ root: "unknown" });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("an owner read that throws (RBAC denial, transport error) is conservative unknown", async () => {
|
|
93
|
+
const reader = { readIfPresent: vi.fn().mockRejectedValue(new Error("Forbidden")) };
|
|
94
|
+
const result = await resolveK8sOwnerChain(pod("pod-uid", [ownerRef("ReplicaSet", "denied-rs", "rs-uid")]), {
|
|
95
|
+
declaredByUid: new Map(),
|
|
96
|
+
reader,
|
|
97
|
+
namespace: "prod",
|
|
98
|
+
});
|
|
99
|
+
expect(result).toEqual({ root: "unknown" });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("a cycle (A owns B, B owns A) terminates and classifies unknown rather than looping forever", async () => {
|
|
103
|
+
const objA: K8sObject = {
|
|
104
|
+
apiVersion: "v1",
|
|
105
|
+
kind: "Widget",
|
|
106
|
+
metadata: { name: "a", namespace: "prod", uid: "a-uid", ownerReferences: [ownerRef("Widget", "b", "b-uid")] },
|
|
107
|
+
};
|
|
108
|
+
const objB: K8sObject = {
|
|
109
|
+
apiVersion: "v1",
|
|
110
|
+
kind: "Widget",
|
|
111
|
+
metadata: { name: "b", namespace: "prod", uid: "b-uid", ownerReferences: [ownerRef("Widget", "a", "a-uid")] },
|
|
112
|
+
};
|
|
113
|
+
const reader = {
|
|
114
|
+
readIfPresent: vi.fn(async ({ name }: { name: string }) => (name === "a" ? objA : objB)),
|
|
115
|
+
};
|
|
116
|
+
const start: K8sObject = {
|
|
117
|
+
apiVersion: "v1",
|
|
118
|
+
kind: "Widget",
|
|
119
|
+
metadata: { name: "start", namespace: "prod", uid: "start-uid", ownerReferences: [ownerRef("Widget", "a", "a-uid")] },
|
|
120
|
+
};
|
|
121
|
+
const result = await resolveK8sOwnerChain(start, { declaredByUid: new Map(), reader, namespace: "prod" });
|
|
122
|
+
expect(result).toEqual({ root: "unknown" });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("respects a custom maxDepth, fetching no more than maxDepth + 1 objects", async () => {
|
|
126
|
+
// A chain 5 hops deep, but bounded to 2 — never reaches the declared root.
|
|
127
|
+
const objects: Record<string, K8sObject> = {};
|
|
128
|
+
for (let i = 0; i < 5; i++) {
|
|
129
|
+
objects[`n${i}`] = {
|
|
130
|
+
apiVersion: "v1",
|
|
131
|
+
kind: "Widget",
|
|
132
|
+
metadata: { name: `n${i}`, namespace: "prod", uid: `uid-${i}`, ownerReferences: [ownerRef("Widget", `n${i + 1}`, `uid-${i + 1}`)] },
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
const reader = {
|
|
136
|
+
readIfPresent: vi.fn(async ({ name }: { name: string }) => objects[name]),
|
|
137
|
+
};
|
|
138
|
+
const start: K8sObject = {
|
|
139
|
+
apiVersion: "v1",
|
|
140
|
+
kind: "Widget",
|
|
141
|
+
metadata: { name: "start", namespace: "prod", uid: "start-uid", ownerReferences: [ownerRef("Widget", "n0", "uid-0")] },
|
|
142
|
+
};
|
|
143
|
+
const result = await resolveK8sOwnerChain(start, {
|
|
144
|
+
declaredByUid: new Map([["uid-4", "deep-entity"]]), // reachable, but past the bound
|
|
145
|
+
reader,
|
|
146
|
+
namespace: "prod",
|
|
147
|
+
maxDepth: 2,
|
|
148
|
+
});
|
|
149
|
+
expect(result).toEqual({ root: "unknown" });
|
|
150
|
+
expect(reader.readIfPresent.mock.calls.length).toBeLessThanOrEqual(3); // maxDepth + 1
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a starting object without a uid cannot be classified — unknown", async () => {
|
|
154
|
+
const reader = { readIfPresent: vi.fn() };
|
|
155
|
+
const noUid: K8sObject = { apiVersion: "v1", kind: "Pod", metadata: { name: "p", namespace: "prod" } };
|
|
156
|
+
const result = await resolveK8sOwnerChain(noUid, { declaredByUid: new Map(), reader, namespace: "prod" });
|
|
157
|
+
expect(result).toEqual({ root: "unknown" });
|
|
158
|
+
expect(reader.readIfPresent).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("picks the controller:true owner reference when several are present", async () => {
|
|
162
|
+
const reader = { readIfPresent: vi.fn() };
|
|
163
|
+
const refs = [ownerRef("OtherOwner", "shared", "shared-uid", false), ownerRef("Deployment", "web", "deploy-uid", true)];
|
|
164
|
+
const result = await resolveK8sOwnerChain(pod("pod-uid", refs), {
|
|
165
|
+
declaredByUid: new Map([["deploy-uid", "web"]]),
|
|
166
|
+
reader,
|
|
167
|
+
namespace: "prod",
|
|
168
|
+
});
|
|
169
|
+
expect(result).toEqual({ root: "declared", entity: "web" });
|
|
170
|
+
});
|
|
171
|
+
});
|