@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.
Files changed (54) hide show
  1. package/dist/api/classify.d.ts +55 -0
  2. package/dist/api/classify.d.ts.map +1 -0
  3. package/dist/api/connect.d.ts +58 -0
  4. package/dist/api/connect.d.ts.map +1 -0
  5. package/dist/api/fake-cluster.d.ts +55 -0
  6. package/dist/api/fake-cluster.d.ts.map +1 -0
  7. package/dist/api/operation-surface.d.ts +64 -0
  8. package/dist/api/operation-surface.d.ts.map +1 -0
  9. package/dist/codegen/generate-operations.d.ts +29 -0
  10. package/dist/codegen/generate-operations.d.ts.map +1 -0
  11. package/dist/codegen/generate.d.ts.map +1 -1
  12. package/dist/config.d.ts +17 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/crd/parser.d.ts.map +1 -1
  15. package/dist/crd/types.d.ts +7 -0
  16. package/dist/crd/types.d.ts.map +1 -1
  17. package/dist/describe-resources.d.ts +41 -24
  18. package/dist/describe-resources.d.ts.map +1 -1
  19. package/dist/export-resources.d.ts +27 -1
  20. package/dist/export-resources.d.ts.map +1 -1
  21. package/dist/integrity.json +2 -2
  22. package/dist/manifest.json +1 -1
  23. package/dist/op/activities/index.d.ts +2 -2
  24. package/dist/op/activities/index.d.ts.map +1 -1
  25. package/dist/op/activities/kubectl.d.ts +38 -2
  26. package/dist/op/activities/kubectl.d.ts.map +1 -1
  27. package/dist/op/activities/wait-for-ready.d.ts +30 -3
  28. package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
  29. package/dist/spec/parse.d.ts +42 -0
  30. package/dist/spec/parse.d.ts.map +1 -1
  31. package/package.json +5 -2
  32. package/src/api/classify.test.ts +133 -0
  33. package/src/api/classify.ts +131 -0
  34. package/src/api/connect.ts +104 -0
  35. package/src/api/fake-cluster.ts +218 -0
  36. package/src/api/operation-surface.test.ts +116 -0
  37. package/src/api/operation-surface.ts +129 -0
  38. package/src/codegen/generate-operations.ts +56 -0
  39. package/src/codegen/generate.ts +9 -0
  40. package/src/config.ts +17 -0
  41. package/src/crd/parser.ts +8 -0
  42. package/src/crd/types.ts +7 -0
  43. package/src/describe-resources.test.ts +396 -191
  44. package/src/describe-resources.ts +134 -118
  45. package/src/export-resources-io.test.ts +76 -51
  46. package/src/export-resources.ts +63 -35
  47. package/src/generated/operations.json +2156 -0
  48. package/src/lifecycle-integration.test.ts +132 -92
  49. package/src/op/activities/index.ts +2 -1
  50. package/src/op/activities/kubectl.test.ts +148 -0
  51. package/src/op/activities/kubectl.ts +86 -13
  52. package/src/op/activities/wait-for-ready.test.ts +94 -0
  53. package/src/op/activities/wait-for-ready.ts +66 -15
  54. 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
+ }
@@ -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;