@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
@@ -1,6 +1,7 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import {
3
3
  waitForReady,
4
+ apiResourceFetcher,
4
5
  ReadinessFailedError,
5
6
  readinessFor,
6
7
  isReady,
@@ -11,6 +12,7 @@ import {
11
12
  } from "./wait-for-ready";
12
13
  // The k8sWait profile marks ReadinessFailedError non-retryable for this activity.
13
14
  import { TEMPORAL_ACTIVITY_PROFILES } from "@intentius/chant-lexicon-temporal/config";
15
+ import { fakeCluster, objectKey } from "../../api/fake-cluster";
14
16
 
15
17
  /** A fetcher returning a scripted sequence of objects, repeating the last. */
16
18
  function scriptedFetcher(sequence: Array<Record<string, unknown>>): ResourceFetcher {
@@ -103,3 +105,95 @@ describe("waitForReady", () => {
103
105
  expect((obj as any).status.state).toBe("running");
104
106
  });
105
107
  });
108
+
109
+ /**
110
+ * chant #1074 — the reader underneath. The activity's contract is unchanged
111
+ * (`kind` is still whatever `kubectl get` accepts), so what has to be proven is
112
+ * that the same strings still resolve, now through the cluster's own API
113
+ * discovery rather than by handing them to a `kubectl` process.
114
+ */
115
+ describe("apiResourceFetcher (chant #1074)", () => {
116
+ const readyObject = (apiVersion: string, kind: string, name: string, namespace?: string) => ({
117
+ apiVersion,
118
+ kind,
119
+ metadata: { name, ...(namespace ? { namespace } : {}), generation: 1 },
120
+ status: { observedGeneration: 1, conditions: [{ type: "Ready", status: "True" }] },
121
+ });
122
+
123
+ test.each([
124
+ ["raycluster.ray.io", "ray.io/v1", "RayCluster", "rayclusters", "K8s::Ray::RayCluster"],
125
+ ["certificates", "cert-manager.io/v1", "Certificate", "certificates", "K8s::CertManager::Certificate"],
126
+ ["Certificate", "cert-manager.io/v1", "Certificate", "certificates", "K8s::CertManager::Certificate"],
127
+ ["deployments", "apps/v1", "Deployment", "deployments", "K8s::Apps::Deployment"],
128
+ ])("`%s` resolves to %s %s via discovery", async (kindArg, apiVersion, kind, plural, entityType) => {
129
+ const cluster = fakeCluster({
130
+ serves: [entityType],
131
+ objects: { [objectKey(apiVersion, kind, "thing", "prod")]: readyObject(apiVersion, kind, "thing", "prod") },
132
+ });
133
+
134
+ const obj = await waitForReady(
135
+ { kind: kindArg, name: "thing", namespace: "prod", intervalMs: 0 },
136
+ undefined,
137
+ apiResourceFetcher(cluster.connector),
138
+ );
139
+
140
+ expect((obj as Record<string, unknown>).kind).toBe(kind);
141
+ expect(cluster.layer.paths()).toContain(
142
+ `${apiVersion.includes("/") ? `/apis/${apiVersion}` : `/api/${apiVersion}`}/namespaces/prod/${plural}/thing`,
143
+ );
144
+ });
145
+
146
+ test("resolution and the connection are done once, not once per poll", async () => {
147
+ const notReady = {
148
+ apiVersion: "cert-manager.io/v1",
149
+ kind: "Certificate",
150
+ metadata: { name: "tls", namespace: "prod", generation: 1 },
151
+ status: { observedGeneration: 1, conditions: [{ type: "Ready", status: "False" }] },
152
+ };
153
+ let polls = 0;
154
+ const cluster = fakeCluster({
155
+ serves: ["K8s::CertManager::Certificate"],
156
+ objects: { [objectKey("cert-manager.io/v1", "Certificate", "tls", "prod")]: notReady },
157
+ respond: (req) => {
158
+ if (!req.path.endsWith("/certificates/tls")) return undefined;
159
+ polls++;
160
+ return polls < 3
161
+ ? { body: notReady }
162
+ : { body: { ...notReady, status: { observedGeneration: 1, conditions: [{ type: "Ready", status: "True" }] } } };
163
+ },
164
+ });
165
+
166
+ await waitForReady(
167
+ { kind: "certificate", name: "tls", namespace: "prod", intervalMs: 0 },
168
+ undefined,
169
+ apiResourceFetcher(cluster.connector),
170
+ );
171
+
172
+ expect(polls).toBe(3);
173
+ expect(cluster.connects).toHaveLength(1);
174
+ // Discovery once; the three object reads reuse the cached resource list.
175
+ expect(cluster.layer.paths().filter((p) => p === "/apis/cert-manager.io/v1")).toHaveLength(1);
176
+ });
177
+
178
+ test("a kind the cluster does not serve fails loudly instead of polling forever", async () => {
179
+ const cluster = fakeCluster({ serves: ["K8s::Apps::Deployment"] });
180
+ await expect(
181
+ waitForReady({ kind: "widgets", name: "w", intervalMs: 0 }, undefined, apiResourceFetcher(cluster.connector)),
182
+ ).rejects.toThrow(/no resource matching "widgets"/);
183
+ });
184
+
185
+ test("an explicit context is passed to the connector, closing the read/write split", async () => {
186
+ const cluster = fakeCluster({
187
+ serves: ["K8s::Apps::Deployment"],
188
+ objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: readyObject("apps/v1", "Deployment", "web", "prod") },
189
+ });
190
+
191
+ await waitForReady(
192
+ { kind: "deployments", name: "web", namespace: "prod", context: "test-context", intervalMs: 0 },
193
+ undefined,
194
+ apiResourceFetcher(cluster.connector),
195
+ );
196
+
197
+ expect(cluster.connects[0]).toMatchObject({ context: "test-context" });
198
+ });
199
+ });
@@ -1,8 +1,5 @@
1
- import { exec } from "node:child_process";
2
- import { promisify } from "node:util";
3
1
  import { safeHeartbeat, sleep } from "@intentius/chant/op";
4
-
5
- const execAsync = promisify(exec);
2
+ import { defaultK8sConnector, type K8sConnector } from "../../api/connect";
6
3
 
7
4
  /**
8
5
  * waitForReady — block until any operator-backed Kubernetes resource reports
@@ -11,9 +8,16 @@ const execAsync = promisify(exec);
11
8
  * Like `waitForArgoSync`, this activity is intentionally **dependency-light**:
12
9
  * its signature is primitives + a plain readiness spec, so a Temporal worker
13
10
  * loads it without importing the generated CRD declarable surface. It reads the
14
- * resource via `kubectl get -o json` (injectable for tests) and evaluates the
15
- * spec's predicates. It generalizes the bespoke `waitForArgoSync` /
16
- * `waitForStack` waits — see #365.
11
+ * resource and evaluates the spec's predicates. It generalizes the bespoke
12
+ * `waitForArgoSync` / `waitForStack` waits see #365.
13
+ *
14
+ * chant #1074 moved the read from `kubectl get -o json` to the typed API
15
+ * client, so a worker image needs no `kubectl` binary. The signature is
16
+ * unchanged — `kind` is still whatever `kubectl get` accepts, because that is
17
+ * what every existing caller passes, and the client resolves it through the
18
+ * cluster's own API discovery exactly as kubectl does: plural, then singular,
19
+ * then kind, then short name, with anything after the first dot read as the
20
+ * API group.
17
21
  */
18
22
 
19
23
  // ── Readiness spec (plain data — no generated-type imports) ──────────
@@ -154,6 +158,11 @@ export interface WaitForReadyArgs {
154
158
  * `.context` through.
155
159
  */
156
160
  context?: string;
161
+ /**
162
+ * chant environment, used to resolve `k8s.profiles.<env>.context` when no
163
+ * explicit `context` is given. Optional and additive.
164
+ */
165
+ environment?: string;
157
166
  /** API group, used to pick a readiness override when `spec` is not given. */
158
167
  group?: string;
159
168
  /** Explicit readiness spec — wins over the registry/default. */
@@ -168,15 +177,57 @@ export type ResourceFetcher = (
168
177
  signal?: AbortSignal,
169
178
  ) => Promise<Record<string, unknown>>;
170
179
 
171
- /** Read the resource via `kubectl get -o json`. */
172
- async function fetchViaKubectl(args: WaitForReadyArgs, signal?: AbortSignal): Promise<Record<string, unknown>> {
173
- const ns = args.namespace ? `-n ${args.namespace}` : "";
174
- const ctx = args.context ? `--context ${args.context}` : "";
175
- const { stdout } = await execAsync(`kubectl get ${args.kind} ${args.name} ${ns} ${ctx} -o json`, { signal });
176
- return JSON.parse(stdout) as Record<string, unknown>;
180
+ /**
181
+ * Read the resource through the typed API client.
182
+ *
183
+ * A client is built once per `waitForReady` call and reused for every poll, so
184
+ * a 20-minute wait does not re-parse the kubeconfig or re-invoke an exec
185
+ * credential plugin on each iteration — and neither does it re-run discovery,
186
+ * which the client caches per API version.
187
+ */
188
+ export function apiResourceFetcher(connect: K8sConnector = defaultK8sConnector): ResourceFetcher {
189
+ let connection: ReturnType<K8sConnector> | undefined;
190
+ let resolved: { apiVersion: string; kind: string } | undefined;
191
+
192
+ return async (args, signal) => {
193
+ connection ??= connect({
194
+ ...(args.environment !== undefined ? { environment: args.environment } : {}),
195
+ ...(args.context !== undefined ? { context: args.context } : {}),
196
+ });
197
+ const { client } = await connection;
198
+
199
+ if (!resolved) {
200
+ const info = await client.resolve(
201
+ { resource: args.kind, ...(args.group ? { group: args.group } : {}) },
202
+ signal,
203
+ );
204
+ if (!info) {
205
+ throw new Error(
206
+ `waitForReady: the cluster's API discovery reports no resource matching "${args.kind}"` +
207
+ `${args.group ? ` in group "${args.group}"` : ""} — nothing to wait for`,
208
+ );
209
+ }
210
+ resolved = { apiVersion: info.apiVersion, kind: info.kind };
211
+ }
212
+
213
+ return (await client.read(
214
+ {
215
+ apiVersion: resolved.apiVersion,
216
+ kind: resolved.kind,
217
+ name: args.name,
218
+ ...(args.namespace ? { namespace: args.namespace } : {}),
219
+ },
220
+ { signal },
221
+ )) as Record<string, unknown>;
222
+ };
177
223
  }
178
224
 
179
- export const defaultResourceFetcher: ResourceFetcher = (args, signal) => fetchViaKubectl(args, signal);
225
+ /**
226
+ * The production reader. Each call builds its own fetcher, so nothing is
227
+ * shared between two unrelated waits; a single `waitForReady` passes one
228
+ * fetcher through all of its polls, which is where the caching matters.
229
+ */
230
+ export const defaultResourceFetcher: ResourceFetcher = (args, signal) => apiResourceFetcher()(args, signal);
180
231
 
181
232
  /**
182
233
  * Poll until the resource satisfies its readiness spec. Throws
@@ -189,7 +240,7 @@ export const defaultResourceFetcher: ResourceFetcher = (args, signal) => fetchVi
189
240
  export async function waitForReady(
190
241
  args: WaitForReadyArgs,
191
242
  signal?: AbortSignal,
192
- fetcher: ResourceFetcher = defaultResourceFetcher,
243
+ fetcher: ResourceFetcher = apiResourceFetcher(),
193
244
  ): Promise<Record<string, unknown>> {
194
245
  const spec = args.spec ?? readinessFor(args.group, args.kind);
195
246
  const interval = args.intervalMs ?? 15_000;
package/src/spec/parse.ts CHANGED
@@ -53,6 +53,26 @@ export interface GroupVersionKind {
53
53
  kind: string;
54
54
  }
55
55
 
56
+ /**
57
+ * How this resource is addressed over the API — chant #1074.
58
+ *
59
+ * Read out of the same document the resource's types come from (the OpenAPI
60
+ * `paths` for core kinds, the CRD's `spec.names` / `spec.scope` for custom
61
+ * ones), so the operation surface and the declarable surface cannot drift
62
+ * apart the way a hand-maintained `kind → kubectl resource` table did.
63
+ *
64
+ * It is a starting point, not the authority: the live client confirms plural
65
+ * and scope against the cluster's own discovery, which is the only thing that
66
+ * knows what a given cluster actually serves.
67
+ */
68
+ export interface ParsedOperation {
69
+ /** Plural path segment, e.g. `deployments`. */
70
+ plural: string;
71
+ scope: "Namespaced" | "Cluster";
72
+ /** Verbs the schema documents for the named resource, e.g. `get`, `patch`. */
73
+ verbs: string[];
74
+ }
75
+
56
76
  export interface K8sParseResult {
57
77
  resource: ParsedResource;
58
78
  propertyTypes: ParsedPropertyType[];
@@ -60,6 +80,8 @@ export interface K8sParseResult {
60
80
  gvk: GroupVersionKind;
61
81
  /** Whether this entity is a property type (nested inside resources) */
62
82
  isProperty?: boolean;
83
+ /** How the API addresses this resource. Absent for property types. */
84
+ operation?: ParsedOperation;
63
85
  }
64
86
 
65
87
  // ── Swagger types ──────────────────────────────────────────────────
@@ -90,9 +112,17 @@ interface SwaggerProperty extends SwaggerDefinition {
90
112
 
91
113
  interface SwaggerSpec {
92
114
  definitions?: Record<string, SwaggerDefinition>;
115
+ paths?: Record<string, SwaggerPathItem>;
93
116
  [key: string]: unknown;
94
117
  }
95
118
 
119
+ interface SwaggerOperation {
120
+ "x-kubernetes-group-version-kind"?: GroupVersionKind;
121
+ "x-kubernetes-action"?: string;
122
+ }
123
+
124
+ type SwaggerPathItem = Record<string, SwaggerOperation | unknown>;
125
+
96
126
  // ── Well-known property type definitions ───────────────────────────
97
127
 
98
128
  /**
@@ -162,6 +192,7 @@ export function parseK8sSwagger(data: string | Buffer): K8sParseResult[] {
162
192
  const spec: SwaggerSpec = JSON.parse(typeof data === "string" ? data : data.toString("utf-8"));
163
193
  const definitions = spec.definitions ?? {};
164
194
  const results: K8sParseResult[] = [];
195
+ const operations = parseOperations(spec.paths);
165
196
 
166
197
  // Phase 1: Extract top-level resources (definitions with GVK)
167
198
  for (const [defKey, def] of Object.entries(definitions)) {
@@ -176,7 +207,11 @@ export function parseK8sSwagger(data: string | Buffer): K8sParseResult[] {
176
207
 
177
208
  const typeName = gvkToTypeName(gvk);
178
209
  const result = extractResource(defKey, def, typeName, gvk, definitions);
179
- if (result) results.push(result);
210
+ if (result) {
211
+ const operation = operations.get(gvkKey(gvk));
212
+ if (operation) result.operation = operation;
213
+ results.push(result);
214
+ }
180
215
  }
181
216
 
182
217
  // Phase 2: Extract well-known property types
@@ -191,6 +226,63 @@ export function parseK8sSwagger(data: string | Buffer): K8sParseResult[] {
191
226
  return results;
192
227
  }
193
228
 
229
+ /** Stable key for a GVK, used to join the `paths` pass onto the `definitions` pass. */
230
+ export function gvkKey(gvk: GroupVersionKind): string {
231
+ return `${gvk.group}|${gvk.version}|${gvk.kind}`;
232
+ }
233
+
234
+ /**
235
+ * Derive the operation surface from the OpenAPI `paths` — chant #1074.
236
+ *
237
+ * Every Kubernetes operation carries `x-kubernetes-group-version-kind` and
238
+ * `x-kubernetes-action`, and the path itself carries the two facts a REST call
239
+ * needs and a definition does not have: the plural segment, and whether the
240
+ * resource is namespaced (`/namespaces/{namespace}/` appears in its path).
241
+ *
242
+ * Only paths addressing a single named object (`.../{plural}/{name}`) are read,
243
+ * so subresource paths (`.../{name}/status`, `.../{name}/scale`) and collection
244
+ * paths do not supply the plural — but their verbs are collected, because
245
+ * "this kind can be listed" is worth knowing.
246
+ */
247
+ export function parseOperations(paths: Record<string, SwaggerPathItem> | undefined): Map<string, ParsedOperation> {
248
+ const out = new Map<string, ParsedOperation>();
249
+ if (!paths) return out;
250
+
251
+ for (const [path, item] of Object.entries(paths)) {
252
+ for (const operation of Object.values(item ?? {})) {
253
+ if (!operation || typeof operation !== "object") continue;
254
+ const op = operation as SwaggerOperation;
255
+ const gvk = op["x-kubernetes-group-version-kind"];
256
+ const action = op["x-kubernetes-action"];
257
+ if (!gvk || !action) continue;
258
+
259
+ const segments = path.split("/").filter(Boolean);
260
+ const last = segments[segments.length - 1];
261
+ // `.../{plural}/{name}` — the only shape that names the plural
262
+ // unambiguously. `/api/v1/namespaces/{name}` is such a shape too, and
263
+ // correctly yields plural `namespaces`, cluster-scoped.
264
+ if (last !== "{name}") continue;
265
+ const plural = segments[segments.length - 2];
266
+ if (!plural || plural.startsWith("{")) continue;
267
+
268
+ const key = gvkKey(gvk);
269
+ const existing = out.get(key);
270
+ if (existing) {
271
+ if (!existing.verbs.includes(action)) existing.verbs.push(action);
272
+ continue;
273
+ }
274
+ out.set(key, {
275
+ plural,
276
+ scope: path.includes("/namespaces/{namespace}/") ? "Namespaced" : "Cluster",
277
+ verbs: [action],
278
+ });
279
+ }
280
+ }
281
+
282
+ for (const operation of out.values()) operation.verbs.sort();
283
+ return out;
284
+ }
285
+
194
286
  /**
195
287
  * Convert GVK to our type name convention: K8s::{Group}::{Kind}
196
288
  */