@intentius/chant-lexicon-k8s 0.30.0 → 0.31.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 +55 -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/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/describe-resources.d.ts +41 -24
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/export-resources.d.ts +27 -1
- package/dist/export-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.d.ts +2 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts +38 -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/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 +131 -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/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/describe-resources.test.ts +396 -191
- package/src/describe-resources.ts +134 -118
- package/src/export-resources-io.test.ts +76 -51
- package/src/export-resources.ts +63 -35
- package/src/generated/operations.json +2156 -0
- package/src/lifecycle-integration.test.ts +132 -92
- package/src/op/activities/index.ts +2 -1
- package/src/op/activities/kubectl.test.ts +148 -0
- package/src/op/activities/kubectl.ts +86 -13
- package/src/op/activities/wait-for-ready.test.ts +94 -0
- package/src/op/activities/wait-for-ready.ts +66 -15
- 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,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The generated operation surface — chant #1074.
|
|
3
|
+
*
|
|
4
|
+
* `describeResources` used to reach the cluster through a hand-written
|
|
5
|
+
* `entityType → kubectl resource` map with twenty entries in it. Every one of
|
|
6
|
+
* the other ~180 generated resource types, and every CRD, fell off the end of
|
|
7
|
+
* it. The map was hand-maintained precisely because nothing derived it, and
|
|
8
|
+
* nothing derived it because the codegen pass that produces the types never
|
|
9
|
+
* emitted the addressing half.
|
|
10
|
+
*
|
|
11
|
+
* It does now. This artifact is written by the same `generate()` run that
|
|
12
|
+
* writes `lexicon-k8s.json` and `index.d.ts`, out of the same parsed results,
|
|
13
|
+
* so a resource that has a declarable class necessarily has an operation entry
|
|
14
|
+
* with the same apiVersion and kind. `operation-surface.test.ts` asserts that
|
|
15
|
+
* correspondence rather than trusting it.
|
|
16
|
+
*
|
|
17
|
+
* What it is not: an authority on what a given cluster serves. `plural` and
|
|
18
|
+
* `scope` are what the schema says; the live client confirms both against the
|
|
19
|
+
* cluster's own discovery before addressing anything, because a cluster can
|
|
20
|
+
* serve a different version of a CRD than the one chant generated from.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { K8sParseResult } from "../spec/parse";
|
|
24
|
+
import { gvkToApiVersion } from "../spec/parse";
|
|
25
|
+
import { pluralizeKind, type K8sOperationDescriptor, type K8sOperationTable } from "../api/operation-surface";
|
|
26
|
+
|
|
27
|
+
export type { K8sOperationDescriptor, K8sOperationTable };
|
|
28
|
+
|
|
29
|
+
/** Build the operation table from the same parsed results the types come from. */
|
|
30
|
+
export function buildOperationTable(results: K8sParseResult[]): K8sOperationTable {
|
|
31
|
+
const table: K8sOperationTable = {};
|
|
32
|
+
for (const result of results) {
|
|
33
|
+
if (result.isProperty) continue;
|
|
34
|
+
const entityType = result.resource.typeName;
|
|
35
|
+
// A later result for the same type wins nothing — the first parse of a
|
|
36
|
+
// preferred version is canonical, matching the registry's own precedence.
|
|
37
|
+
if (table[entityType]) continue;
|
|
38
|
+
table[entityType] = {
|
|
39
|
+
entityType,
|
|
40
|
+
apiVersion: gvkToApiVersion(result.gvk),
|
|
41
|
+
kind: result.gvk.kind,
|
|
42
|
+
plural: result.operation?.plural ?? pluralizeKind(result.gvk.kind),
|
|
43
|
+
scope: result.operation?.scope ?? "Namespaced",
|
|
44
|
+
verbs: result.operation?.verbs ?? [],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return table;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Serialize the table, key-sorted so regeneration produces a stable diff. */
|
|
51
|
+
export function generateOperationsJSON(results: K8sParseResult[]): string {
|
|
52
|
+
const table = buildOperationTable(results);
|
|
53
|
+
const sorted: K8sOperationTable = {};
|
|
54
|
+
for (const key of Object.keys(table).sort()) sorted[key] = table[key];
|
|
55
|
+
return `${JSON.stringify(sorted, null, 2)}\n`;
|
|
56
|
+
}
|
package/src/codegen/generate.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { loadMultipleCRDs } from "../crd/loader";
|
|
|
17
17
|
import { CRD_SOURCES } from "../crd/crd-sources";
|
|
18
18
|
import { NamingStrategy, propertyTypeName, extractDefName } from "./naming";
|
|
19
19
|
import { generateLexiconJSON } from "./generate-lexicon";
|
|
20
|
+
import { generateOperationsJSON } from "./generate-operations";
|
|
20
21
|
import { generateTypeScriptDeclarations } from "./generate-typescript";
|
|
21
22
|
import {
|
|
22
23
|
generateRuntimeIndex as coreGenerateRuntimeIndex,
|
|
@@ -98,6 +99,13 @@ export async function generate(opts: K8sGenerateOptions = {}): Promise<GenerateR
|
|
|
98
99
|
generateRuntimeIndex: (results, naming) => {
|
|
99
100
|
return generateRuntimeIndex(results, naming as NamingStrategy);
|
|
100
101
|
},
|
|
102
|
+
|
|
103
|
+
// chant #1074 — the operation surface, out of the same results the types
|
|
104
|
+
// and the registry come out of, so the live client cannot address a kind
|
|
105
|
+
// differently from how the declarable surface names it.
|
|
106
|
+
generateExtraArtifacts: (results) => ({
|
|
107
|
+
"operations.json": generateOperationsJSON(results),
|
|
108
|
+
}),
|
|
101
109
|
};
|
|
102
110
|
|
|
103
111
|
return generatePipeline(config, opts);
|
|
@@ -114,6 +122,7 @@ export function writeGeneratedFiles(result: GenerateResult, baseDir: string): vo
|
|
|
114
122
|
"index.d.ts": result.typesDTS,
|
|
115
123
|
"index.ts": result.indexTS,
|
|
116
124
|
"runtime.ts": `/**\n * Runtime factory constructors — re-exported from core.\n */\nexport { createResource, createProperty } from "@intentius/chant/runtime";\n`,
|
|
125
|
+
...(result.extraArtifacts ?? {}),
|
|
117
126
|
},
|
|
118
127
|
});
|
|
119
128
|
}
|
package/src/config.ts
CHANGED
|
@@ -60,4 +60,21 @@ export interface K8sClusterProfile {
|
|
|
60
60
|
export interface K8sChantConfig {
|
|
61
61
|
/** Named environment → cluster bindings, keyed by environment name. */
|
|
62
62
|
profiles?: Record<string, K8sClusterProfile>;
|
|
63
|
+
/**
|
|
64
|
+
* Exec credential-plugin commands chant may execute (chant #1074).
|
|
65
|
+
*
|
|
66
|
+
* On EKS, AKS and GKE, kubeconfig authentication is a subprocess:
|
|
67
|
+
* `aws eks get-token`, `kubelogin`, `gke-gcloud-auth-plugin`. Those three
|
|
68
|
+
* plus `kubectl` are allowed by default. Anything else the kubeconfig names
|
|
69
|
+
* is refused, because an exec plugin is an arbitrary binary named in a file
|
|
70
|
+
* chant did not write. Setting this **replaces** the default list.
|
|
71
|
+
*
|
|
72
|
+
* ```ts
|
|
73
|
+
* k8s: {
|
|
74
|
+
* profiles: { prod: { context: "prod-eks" } },
|
|
75
|
+
* execCredentialPlugins: ["aws", "my-org-oidc-helper"],
|
|
76
|
+
* } satisfies K8sChantConfig
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
execCredentialPlugins?: string[];
|
|
63
80
|
}
|
package/src/crd/parser.ts
CHANGED
|
@@ -124,6 +124,14 @@ export function parseCRDSpec(spec: CRDSpec): K8sParseResult[] {
|
|
|
124
124
|
propertyTypes: status.propertyType ? [...propertyTypes, status.propertyType] : propertyTypes,
|
|
125
125
|
enums: [],
|
|
126
126
|
gvk,
|
|
127
|
+
// chant #1074 — the CRD declares its own plural and scope, so the
|
|
128
|
+
// operation surface for a custom resource comes from the same document its
|
|
129
|
+
// types do, exactly as the OpenAPI `paths` supply them for built-in kinds.
|
|
130
|
+
operation: {
|
|
131
|
+
plural: spec.names.plural,
|
|
132
|
+
scope: spec.scope ?? "Namespaced",
|
|
133
|
+
verbs: ["delete", "get", "list", "patch", "post", "put", "watch"],
|
|
134
|
+
},
|
|
127
135
|
});
|
|
128
136
|
|
|
129
137
|
return results;
|
package/src/crd/types.ts
CHANGED
|
@@ -34,6 +34,13 @@ export interface CRDSource {
|
|
|
34
34
|
export interface CRDSpec {
|
|
35
35
|
/** API group (e.g. "cert-manager.io") */
|
|
36
36
|
group: string;
|
|
37
|
+
/**
|
|
38
|
+
* Whether instances are namespaced. Declared by the CRD itself, so it is the
|
|
39
|
+
* authoritative source for a custom resource's scope — the equivalent of what
|
|
40
|
+
* the OpenAPI `paths` say for built-in kinds (chant #1074). Defaults to
|
|
41
|
+
* `Namespaced`, matching the API server's own default.
|
|
42
|
+
*/
|
|
43
|
+
scope?: "Namespaced" | "Cluster";
|
|
37
44
|
/** Name variants for the CRD */
|
|
38
45
|
names: {
|
|
39
46
|
kind: string;
|