@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
@@ -2,79 +2,53 @@
2
2
  * Live introspection of a Kubernetes cluster — implements the
3
3
  * LexiconPlugin.describeResources() contract for the k8s lexicon.
4
4
  *
5
- * For each declared K8s entity, runs `kubectl get <kind> <name> [-n <ns>] -o json`
6
- * and maps the response to a ResourceMetadata entry keyed by the chant entity
7
- * name (using the props.metadata.name + props.metadata.namespace from #39's
8
- * entity-prop pass-through).
5
+ * ## What changed, and why it matters (chant #1074)
9
6
  *
10
- * The observation tri-state (#1089) is what the return value carries. A genuine
11
- * `NotFound` from the API server is an absence, and only that becomes a `create`
12
- * downstream. An entity type with no entry in `KUBECTL_RESOURCE` every CRD —
13
- * was never looked at, and comes back `unsupported-kind`: it may well be running
14
- * in the cluster, and proposing to create it would be a guess. Auth failures and
15
- * unreachable API servers come back `no-credentials` / `no-binding` for the same
16
- * reason. Extending the KUBECTL_RESOURCE map converts unsupported-kind holes into
17
- * real reads; until then they are holes chant admits to.
7
+ * This used to run `kubectl get <kind> <name> -o json` once per declared
8
+ * entity, serially, resolved through a hardcoded twenty-entry
9
+ * `KUBECTL_RESOURCE` map. Every CRD and every uncommon type fell off the end
10
+ * of that map and came back as a hole. Coverage, concurrency and error quality
11
+ * were all capped by it, and `lifecycle diff --live`, `lifecycle plan` and
12
+ * behold's overlay inherited the cap.
18
13
  *
19
- * Before touching any resource, the environment is resolved to a cluster
20
- * identity (chant #1100) via `resolveClusterTarget` — see `./config.ts` for
21
- * the `k8s.profiles.<env>.context` binding shape. A declared binding is
22
- * passed explicitly as `--context` on every kubectl call below; an ambient
23
- * context that disagrees with it aborts the whole describe with a loud
24
- * error rather than silently reading the wrong cluster. No binding keeps
25
- * today's behavior (ambient context), with a visible warning.
14
+ * Now:
15
+ *
16
+ * - **Coverage.** An entity type becomes an API address through the generated
17
+ * operation surface (`./api/operation-surface.ts`), which the same codegen
18
+ * pass that emits the declarable classes writes 184 types rather than 20,
19
+ * CRDs included. The address is then confirmed against the cluster's *own*
20
+ * discovery, which is what knows the plural and the scope for the version
21
+ * that cluster actually serves.
22
+ * - **Concurrency.** Reads run through the client's bounded pool. A hundred
23
+ * entities are a hundred concurrent HTTP GETs sharing one connection and one
24
+ * cached credential, not a hundred serial process spawns.
25
+ * - **Error quality.** Failures arrive as typed errors carrying the API
26
+ * server's own `code` and `reason`, so the tri-state verdict is read off a
27
+ * field instead of matched against English on stderr.
28
+ *
29
+ * ## What did not change
30
+ *
31
+ * The observation tri-state (chant #1089) and the cluster binding (chant
32
+ * #1100/#1155) are the same contracts they were. A genuine `NotFound` — and a
33
+ * kind the cluster's discovery does not serve, where no instance can exist —
34
+ * is an absence, and only an absence becomes a `create`. Everything else is
35
+ * NOT-OBSERVED with a reason. A declared `k8s.profiles.<env>.context` that
36
+ * disagrees with the ambient one still refuses before a single resource is
37
+ * touched, via the same `resolveClusterTarget` the GCP lexicon shares.
26
38
  */
27
39
 
28
- import { exec } from "node:child_process";
29
- import { promisify } from "node:util";
30
40
  import type { ObservationResult, ResourceMetadata, UnobservedEntity } from "@intentius/chant/lexicon";
31
- import { observation } from "@intentius/chant/observation";
41
+ import { observation, unobservedAll } from "@intentius/chant/observation";
32
42
  import { hasOwnershipMarker, classifyOwnership, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
33
- import { loadChantConfig } from "@intentius/chant/config";
34
- import { resolveClusterTarget, classifyKubectlFailure } from "@intentius/chant/kubectl-context";
35
-
36
- const execAsync = promisify(exec);
37
-
38
- /**
39
- * Map chant entity types to `kubectl get` resource names. Add entries here
40
- * as new types are needed.
41
- */
42
- export const KUBECTL_RESOURCE: Record<string, string> = {
43
- "K8s::Apps::Deployment": "deployment.apps",
44
- "K8s::Apps::StatefulSet": "statefulset.apps",
45
- "K8s::Apps::DaemonSet": "daemonset.apps",
46
- "K8s::Apps::ReplicaSet": "replicaset.apps",
47
- "K8s::Core::Service": "service",
48
- "K8s::Core::ConfigMap": "configmap",
49
- "K8s::Core::Secret": "secret",
50
- "K8s::Core::Namespace": "namespace",
51
- "K8s::Core::Pod": "pod",
52
- "K8s::Core::PersistentVolumeClaim": "persistentvolumeclaim",
53
- "K8s::Core::ServiceAccount": "serviceaccount",
54
- "K8s::Batch::Job": "job.batch",
55
- "K8s::Batch::CronJob": "cronjob.batch",
56
- "K8s::Networking::Ingress": "ingress.networking.k8s.io",
57
- "K8s::Networking::NetworkPolicy": "networkpolicy.networking.k8s.io",
58
- "K8s::Rbac::Role": "role.rbac.authorization.k8s.io",
59
- "K8s::Rbac::RoleBinding": "rolebinding.rbac.authorization.k8s.io",
60
- "K8s::Rbac::ClusterRole": "clusterrole.rbac.authorization.k8s.io",
61
- "K8s::Rbac::ClusterRoleBinding": "clusterrolebinding.rbac.authorization.k8s.io",
62
- };
63
-
64
- interface KubectlResponse {
65
- metadata?: {
66
- name?: string;
67
- namespace?: string;
68
- uid?: string;
69
- creationTimestamp?: string;
70
- labels?: Record<string, string>;
71
- resourceVersion?: string;
72
- };
73
- status?: {
74
- phase?: string;
75
- [k: string]: unknown;
76
- };
77
- }
43
+ import type { K8sObject } from "@intentius/chant-k8s-client";
44
+ import { defaultK8sConnector, type K8sConnector } from "./api/connect";
45
+ import {
46
+ classifyApiFailure,
47
+ isMissingClientPackage,
48
+ isWholeLexiconFailure,
49
+ MISSING_CLIENT_DETAIL,
50
+ } from "./api/classify";
51
+ import { operationFor } from "./api/operation-surface";
78
52
 
79
53
  function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
80
54
  const out: Record<string, unknown> = {};
@@ -84,51 +58,98 @@ function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<strin
84
58
  return out;
85
59
  }
86
60
 
87
- function statusFromKubectl(obj: KubectlResponse): string {
88
- // Different K8s resource types report status differently. Fall back to
89
- // "PRESENT" if we can't extract a meaningful field.
90
- const phase = obj.status?.phase;
61
+ /**
62
+ * Collapse a live object to one status word. Unchanged from the kubectl path
63
+ * the shape of the response is the same JSON either way.
64
+ */
65
+ export function statusFromObject(obj: K8sObject): string {
66
+ const status = obj.status;
67
+ const phase = status?.phase;
91
68
  if (typeof phase === "string") return phase;
92
- // Deployment/StatefulSet — readyReplicas == replicas → READY
93
- const status = obj.status as Record<string, unknown> | undefined;
94
69
  if (status && typeof status.readyReplicas === "number" && typeof status.replicas === "number") {
95
- return status.readyReplicas === status.replicas ? "READY" : `PROGRESSING(${status.readyReplicas}/${status.replicas})`;
70
+ return status.readyReplicas === status.replicas
71
+ ? "READY"
72
+ : `PROGRESSING(${status.readyReplicas}/${status.replicas})`;
96
73
  }
97
74
  return "PRESENT";
98
75
  }
99
76
 
100
- export async function describeResources(options: {
77
+ interface Declared {
78
+ entityName: string;
79
+ entityType: string;
80
+ props: Record<string, unknown>;
81
+ }
82
+
83
+ export interface DescribeResourcesOptions {
101
84
  environment: string;
102
85
  buildOutput: string;
103
86
  entityNames: string[];
104
87
  entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
105
88
  owned?: boolean;
106
- }): Promise<ObservationResult> {
107
- const result: Record<string, ResourceMetadata> = {};
89
+ /** Directory whose `chant.config.ts` carries the cluster binding. Defaults to cwd. */
90
+ cwd?: string;
91
+ }
92
+
93
+ export async function describeResources(
94
+ options: DescribeResourcesOptions,
95
+ connect: K8sConnector = defaultK8sConnector,
96
+ ): Promise<ObservationResult> {
97
+ const resources: Record<string, ResourceMetadata> = {};
108
98
  const unobserved: Record<string, UnobservedEntity> = {};
109
- const skippedTypes = new Set<string>();
110
99
 
111
- // Resolve the cluster identity for this environment before touching any
112
- // resource — a declared-but-mismatched binding throws here, aborting the
113
- // whole describe rather than letting a per-entity try/catch below absorb
114
- // it as an ordinary "not found".
115
- const { config } = await loadChantConfig(process.cwd());
116
- const target = await resolveClusterTarget(config as Record<string, unknown>, options.environment, "k8s");
117
- const ctxArg = target.context ? ["--context", target.context] : [];
100
+ const declared: Declared[] = [...options.entities].map(([entityName, entity]) => ({
101
+ entityName,
102
+ entityType: entity.entityType,
103
+ props: entity.props,
104
+ }));
105
+
106
+ // Connect first. The binding check lives here, so a bound-but-mismatched
107
+ // context throws before any resource is read — core turns that into
108
+ // NOT-OBSERVED for every declared entity rather than an empty result.
109
+ let client;
110
+ try {
111
+ ({ client } = await connect({ environment: options.environment, cwd: options.cwd }));
112
+ } catch (err) {
113
+ if (isMissingClientPackage(err)) {
114
+ return observation(
115
+ {},
116
+ unobservedAll(
117
+ declared.map((d) => d.entityName),
118
+ "read-failed",
119
+ MISSING_CLIENT_DETAIL,
120
+ options.entities,
121
+ ),
122
+ );
123
+ }
124
+ if (isWholeLexiconFailure(err)) {
125
+ const outcome = classifyApiFailure(err);
126
+ return observation(
127
+ {},
128
+ unobservedAll(
129
+ declared.map((d) => d.entityName),
130
+ outcome.kind === "unobserved" ? outcome.reason : "read-failed",
131
+ outcome.kind === "unobserved" ? outcome.detail : undefined,
132
+ options.entities,
133
+ ),
134
+ );
135
+ }
136
+ // A cluster-binding mismatch (chant #1100) belongs here: it is a loud
137
+ // refusal, and core's whole-lexicon handling is what turns it into an
138
+ // honest hole per entity.
139
+ throw err;
140
+ }
118
141
 
119
- for (const [entityName, { entityType, props }] of options.entities) {
120
- const kubectlResource = KUBECTL_RESOURCE[entityType];
121
- if (!kubectlResource) {
122
- // No reader for this kind (every CRD). The object may exist; chant has no
123
- // way to ask. Reporting it as unobserved is what stops `lifecycle plan`
124
- // proposing to create a CRD that is already in the cluster (#1089).
125
- skippedTypes.add(entityType);
142
+ await client.concurrently(declared, async ({ entityName, entityType, props }) => {
143
+ const operation = operationFor(entityType);
144
+ if (!operation) {
145
+ // chant knows no API address for this type at all not even its group.
146
+ // The object may well exist, so this is a hole, never an absence.
126
147
  unobserved[entityName] = {
127
148
  type: entityType,
128
149
  reason: "unsupported-kind",
129
- detail: `no kubectl mapping for ${entityType} — extend KUBECTL_RESOURCE to observe it`,
150
+ detail: `no generated operation surface for ${entityType} — run \`chant generate\` in the k8s lexicon, or declare the CRD as a codegen source`,
130
151
  };
131
- continue;
152
+ return;
132
153
  }
133
154
 
134
155
  const metadata = props.metadata as { name?: string; namespace?: string } | undefined;
@@ -140,18 +161,20 @@ export async function describeResources(options: {
140
161
  reason: "read-failed",
141
162
  detail: "declared entity has no metadata.name to query by",
142
163
  };
143
- continue;
164
+ return;
144
165
  }
145
166
 
146
- const nsArg = metadata.namespace ? ["-n", metadata.namespace] : [];
147
- const cmd = ["kubectl", "get", kubectlResource, name, ...nsArg, ...ctxArg, "-o", "json"].join(" ");
148
-
149
167
  try {
150
- const { stdout } = await execAsync(cmd);
151
- const obj: KubectlResponse = JSON.parse(stdout);
168
+ const obj = await client.read({
169
+ apiVersion: operation.apiVersion,
170
+ kind: operation.kind,
171
+ name,
172
+ ...(metadata?.namespace ? { namespace: metadata.namespace } : {}),
173
+ });
174
+
152
175
  // owned filter: withhold resources not carrying chant's marker label.
153
- // Withheld is not absent (#1089) — this object exists, it just isn't
154
- // chant's, and dropping it silently is how `--owned` used to turn a
176
+ // Withheld is not absent (chant #1089) — this object exists, it just
177
+ // isn't chant's, and dropping it silently is how `--owned` used to turn a
155
178
  // declared-but-foreign resource into a proposed `create`.
156
179
  if (options.owned && !hasOwnershipMarker(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS)) {
157
180
  unobserved[entityName] = {
@@ -159,12 +182,13 @@ export async function describeResources(options: {
159
182
  reason: "filtered",
160
183
  detail: "live object carries no chant ownership marker and --owned was requested",
161
184
  };
162
- continue;
185
+ return;
163
186
  }
164
- result[entityName] = {
187
+
188
+ resources[entityName] = {
165
189
  type: entityType,
166
190
  physicalId: obj.metadata?.uid,
167
- status: statusFromKubectl(obj),
191
+ status: statusFromObject(obj),
168
192
  lastUpdated: obj.metadata?.creationTimestamp,
169
193
  ownership: classifyOwnership(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS),
170
194
  attributes: pruneUndefined({
@@ -174,22 +198,14 @@ export async function describeResources(options: {
174
198
  }),
175
199
  };
176
200
  } catch (err) {
177
- // Only a real NotFound leaves the entity out (an absence the diff may
178
- // read as missing and the plan as create). Auth, connectivity, and every
179
- // other failure prove nothing about existence and are reported as such.
180
- const outcome = classifyKubectlFailure(err);
201
+ const outcome = classifyApiFailure(err);
181
202
  if (outcome.kind === "unobserved") {
182
203
  unobserved[entityName] = { type: entityType, reason: outcome.reason, detail: outcome.detail };
183
204
  }
205
+ // `absent` deliberately records nothing: in neither map is how the
206
+ // contract spells "asked, and it is not there".
184
207
  }
185
- }
186
-
187
- if (skippedTypes.size > 0) {
188
- // eslint-disable-next-line no-console
189
- console.warn(
190
- `[k8s] no kubectl mapping for ${skippedTypes.size} entity type(s): ${[...skippedTypes].join(", ")} — reported as unobserved (not absent), so no create is proposed for them`,
191
- );
192
- }
208
+ });
193
209
 
194
- return observation(result, unobserved);
210
+ return observation(resources, unobserved);
195
211
  }
@@ -1,27 +1,17 @@
1
- import { describe, test, expect, vi, beforeEach } from "vitest";
2
-
3
- // Synchronous delivery (value, or Error for skipped kinds) avoids dangling
4
- // unhandled rejections when a kind is missing / RBAC-denied.
5
- const execMock = vi.fn();
6
- vi.mock("node:child_process", async () => {
7
- const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
8
- return {
9
- ...actual,
10
- exec: (
11
- cmd: string,
12
- cb: (err: Error | null, out: { stdout: string; stderr: string }) => void,
13
- ) => {
14
- const r = execMock(cmd);
15
- queueMicrotask(() =>
16
- r instanceof Error
17
- ? cb(r, { stdout: "", stderr: "" })
18
- : cb(null, r as { stdout: string; stderr: string }),
19
- );
20
- },
21
- };
22
- });
23
-
24
- const { exportResources } = await import("./export-resources");
1
+ /**
2
+ * `exportResources` I/O glue (#160), over the typed API client (chant #1074).
3
+ *
4
+ * `KUBECTL_RESOURCE` used to be two things at once: the set of kinds a bare
5
+ * `chant import` sweeps, and the only way the lexicon knew how to address
6
+ * anything. #1074 removed the second job — addressing now comes from the
7
+ * generated operation surface plus the cluster's discovery — so what is left
8
+ * here is the product decision about what a default import covers, and a
9
+ * `--selector type=` import can now name any generated type, CRDs included.
10
+ */
11
+ import { describe, test, expect } from "vitest";
12
+ import { exportResources, DEFAULT_IMPORT_TYPES } from "./export-resources";
13
+ import { fakeCluster, objectKey } from "./api/fake-cluster";
14
+ import { statusBody } from "@intentius/chant-k8s-client/testing";
25
15
 
26
16
  const liveDeployment = {
27
17
  apiVersion: "apps/v1",
@@ -30,43 +20,78 @@ const liveDeployment = {
30
20
  spec: { replicas: 3, selector: { matchLabels: { app: "web" } } },
31
21
  };
32
22
 
33
- const emptyList = { stdout: JSON.stringify({ items: [] }), stderr: "" };
23
+ const liveRayCluster = {
24
+ apiVersion: "ray.io/v1",
25
+ kind: "RayCluster",
26
+ metadata: { name: "ml", namespace: "ray", uid: "r-1" },
27
+ spec: { rayVersion: "2.9.0" },
28
+ };
34
29
 
35
30
  describe("k8s exportResources I/O glue (#160)", () => {
36
- beforeEach(() => execMock.mockReset());
31
+ test("sweeps the default types by LIST and maps to IR", async () => {
32
+ const cluster = fakeCluster({
33
+ objects: { [objectKey("apps/v1", "Deployment", "web", "default")]: liveDeployment },
34
+ });
35
+
36
+ const ir = await exportResources({ environment: "prod" }, cluster.connector);
37
37
 
38
- test("sweeps known kinds via `kubectl get <kind> -A -o json` and maps to IR", async () => {
39
- execMock.mockImplementation((cmd?: string) =>
40
- cmd?.includes("get deployment.apps")
41
- ? { stdout: JSON.stringify({ items: [liveDeployment] }), stderr: "" }
42
- : emptyList,
43
- );
44
- const ir = await exportResources({ environment: "prod" });
45
- const cmds = execMock.mock.calls.map((c) => c[0] as string).filter(Boolean);
46
- expect(cmds.every((c) => c.startsWith("kubectl get ") && c.endsWith("-A -o json"))).toBe(true);
47
- expect(cmds).toContain("kubectl get deployment.apps -A -o json");
48
38
  expect(ir.resources.map((r) => r.type)).toContain("K8s::Apps::Deployment");
39
+ // Cluster-wide collection paths, which is what `kubectl get -A` was doing.
40
+ expect(cluster.layer.paths()).toContain("/apis/apps/v1/deployments");
41
+ expect(cluster.layer.paths()).toContain("/api/v1/services");
42
+ expect(cluster.layer.paths().some((p) => p.includes("/namespaces/"))).toBe(false);
49
43
  });
50
44
 
51
45
  test("a kind that errors is skipped; the rest of the sweep still maps", async () => {
52
- execMock.mockImplementation((cmd?: string) => {
53
- if (cmd?.includes("get secret ")) return new Error("Error from server (Forbidden)");
54
- if (cmd?.includes("get deployment.apps")) {
55
- return { stdout: JSON.stringify({ items: [liveDeployment] }), stderr: "" };
56
- }
57
- return emptyList;
46
+ const cluster = fakeCluster({
47
+ objects: { [objectKey("apps/v1", "Deployment", "web", "default")]: liveDeployment },
48
+ respond: (req) =>
49
+ req.path === "/api/v1/secrets" ? { status: 403, body: statusBody(403, "Forbidden", "rbac") } : undefined,
58
50
  });
59
- const ir = await exportResources({ environment: "prod" });
51
+
52
+ const ir = await exportResources({ environment: "prod" }, cluster.connector);
60
53
  expect(ir.resources.map((r) => r.type)).toContain("K8s::Apps::Deployment");
61
54
  });
62
55
 
63
- test("a type selector narrows the sweep to a single kubectl get", async () => {
64
- execMock.mockImplementation(() => ({
65
- stdout: JSON.stringify({ items: [liveDeployment] }),
66
- stderr: "",
67
- }));
68
- await exportResources({ environment: "prod", selector: { type: "K8s::Apps::Deployment" } });
69
- expect(execMock.mock.calls.length).toBe(1);
70
- expect(execMock.mock.calls[0][0]).toBe("kubectl get deployment.apps -A -o json");
56
+ test("a type selector narrows the sweep to one kind", async () => {
57
+ const cluster = fakeCluster({
58
+ objects: { [objectKey("apps/v1", "Deployment", "web", "default")]: liveDeployment },
59
+ });
60
+
61
+ await exportResources({ environment: "prod", selector: { type: "K8s::Apps::Deployment" } }, cluster.connector);
62
+
63
+ // One discovery request plus one collection read, and nothing else.
64
+ expect(cluster.layer.paths()).toEqual(["/apis/apps/v1", "/apis/apps/v1/deployments"]);
65
+ });
66
+
67
+ test("a CRD can now be imported by name — it never could through the twenty-entry map", async () => {
68
+ const cluster = fakeCluster({
69
+ objects: { [objectKey("ray.io/v1", "RayCluster", "ml", "ray")]: liveRayCluster },
70
+ });
71
+
72
+ await exportResources({ environment: "prod", selector: { type: "K8s::Ray::RayCluster" } }, cluster.connector);
73
+ expect(cluster.layer.paths()).toContain("/apis/ray.io/v1/rayclusters");
74
+ });
75
+
76
+ test("an unknown selector type sweeps nothing rather than everything", async () => {
77
+ const cluster = fakeCluster();
78
+ const ir = await exportResources(
79
+ { environment: "prod", selector: { type: "K8s::NotAGroupChantKnows::Thing" } },
80
+ cluster.connector,
81
+ );
82
+ expect(ir.resources).toEqual([]);
83
+ expect(cluster.layer.requests).toHaveLength(0);
84
+ });
85
+
86
+ test("the default sweep still covers the workload, config, networking and RBAC kinds", () => {
87
+ for (const type of [
88
+ "K8s::Apps::Deployment",
89
+ "K8s::Core::Service",
90
+ "K8s::Core::ConfigMap",
91
+ "K8s::Networking::Ingress",
92
+ "K8s::Rbac::ClusterRole",
93
+ ]) {
94
+ expect(DEFAULT_IMPORT_TYPES).toContain(type);
95
+ }
71
96
  });
72
97
  });
@@ -3,56 +3,84 @@
3
3
  * LexiconPlugin.exportResources() so `chant import --from <cluster-env>`
4
4
  * regenerates live objects as chant TypeScript.
5
5
  *
6
- * Reads live objects with `kubectl get <kinds> -A -o json`, strips
7
- * server-managed noise to reach the declared shape (kept under `verbatim`),
8
- * and maps to the import IR via the shared K8sParser. All I/O lives here; the
9
- * cleaning and IR-building logic is pure in `./import/live-export`.
6
+ * Reads live objects through the typed API client (chant #1074, previously
7
+ * `kubectl get <kinds> -A -o json`), strips server-managed noise to reach the
8
+ * declared shape (kept under `verbatim`), and maps to the import IR via the
9
+ * shared K8sParser. All I/O lives here; the cleaning and IR-building logic is
10
+ * pure in `./import/live-export`.
11
+ *
12
+ * The list below is a *product* decision — what a bare `chant import` should
13
+ * sweep when the caller names no type — not an addressing limit. It used to be
14
+ * both, because `KUBECTL_RESOURCE` was simultaneously the sweep set and the
15
+ * only way the lexicon knew how to address anything (chant #1074 removed the
16
+ * second job). A `--selector type=<entity type>` import can now name any of the
17
+ * ~180 types the generated operation surface carries, CRDs included.
10
18
  */
11
- import { exec } from "node:child_process";
12
- import { promisify } from "node:util";
13
19
  import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
14
- import { KUBECTL_RESOURCE } from "./describe-resources";
20
+ import { defaultK8sConnector, type K8sConnector } from "./api/connect";
21
+ import { operationFor } from "./api/operation-surface";
15
22
  import { buildExportFromObjects } from "./import/live-export";
16
23
 
17
- const execAsync = promisify(exec);
18
-
19
- interface KubectlList {
20
- items?: unknown[];
21
- }
24
+ /**
25
+ * Entity types a bare `chant import --from <cluster>` sweeps: the workload,
26
+ * config, networking and RBAC kinds people actually author. Everything else is
27
+ * reachable by naming it with `--selector type=...`.
28
+ */
29
+ export const DEFAULT_IMPORT_TYPES: readonly string[] = [
30
+ "K8s::Apps::Deployment",
31
+ "K8s::Apps::StatefulSet",
32
+ "K8s::Apps::DaemonSet",
33
+ "K8s::Apps::ReplicaSet",
34
+ "K8s::Core::Service",
35
+ "K8s::Core::ConfigMap",
36
+ "K8s::Core::Secret",
37
+ "K8s::Core::Namespace",
38
+ "K8s::Core::Pod",
39
+ "K8s::Core::PersistentVolumeClaim",
40
+ "K8s::Core::ServiceAccount",
41
+ "K8s::Batch::Job",
42
+ "K8s::Batch::CronJob",
43
+ "K8s::Networking::Ingress",
44
+ "K8s::Networking::NetworkPolicy",
45
+ "K8s::Rbac::Role",
46
+ "K8s::Rbac::RoleBinding",
47
+ "K8s::Rbac::ClusterRole",
48
+ "K8s::Rbac::ClusterRoleBinding",
49
+ ];
22
50
 
23
- export async function exportResources(options: {
24
- environment: string;
25
- selector?: ResourceSelector;
26
- owned?: boolean;
27
- verbatim?: boolean;
28
- }): Promise<ExportedTemplate> {
29
- // Resolve which kinds to query. A type selector narrows to one kind;
30
- // otherwise sweep every kind chant knows how to map.
31
- const kinds = options.selector?.type
32
- ? [KUBECTL_RESOURCE[options.selector.type]].filter(Boolean)
33
- : Array.from(new Set(Object.values(KUBECTL_RESOURCE)));
51
+ export async function exportResources(
52
+ options: {
53
+ environment: string;
54
+ selector?: ResourceSelector;
55
+ owned?: boolean;
56
+ verbatim?: boolean;
57
+ cwd?: string;
58
+ },
59
+ connect: K8sConnector = defaultK8sConnector,
60
+ ): Promise<ExportedTemplate> {
61
+ const types = options.selector?.type ? [options.selector.type] : DEFAULT_IMPORT_TYPES;
62
+ const operations = types.map((t) => operationFor(t)).filter((o) => o !== undefined);
34
63
 
35
- if (kinds.length === 0) {
64
+ if (operations.length === 0) {
36
65
  return { resources: [], parameters: [] };
37
66
  }
38
67
 
68
+ const { client } = await connect({ environment: options.environment, cwd: options.cwd });
69
+
39
70
  // One List per kind keeps parsing simple and isolates a missing-kind error
40
- // to that kind rather than failing the whole export.
41
- const objects: unknown[] = [];
42
- for (const kind of kinds) {
71
+ // to that kind rather than failing the whole export. They now run
72
+ // concurrently instead of serially.
73
+ const perKind = await client.concurrently(operations, async (operation) => {
43
74
  try {
44
- const { stdout } = await execAsync(
45
- ["kubectl", "get", kind, "-A", "-o", "json"].join(" "),
46
- );
47
- const list = JSON.parse(stdout) as KubectlList;
48
- if (Array.isArray(list.items)) objects.push(...list.items);
75
+ return await client.list({ apiVersion: operation.apiVersion, kind: operation.kind });
49
76
  } catch {
50
- // Kind not present in the cluster / RBAC denied — skip it, don't fail
77
+ // Kind not served by this cluster / RBAC denied — skip it, don't fail
51
78
  // the whole export.
79
+ return [];
52
80
  }
53
- }
81
+ });
54
82
 
55
- return buildExportFromObjects(objects, {
83
+ return buildExportFromObjects(perKind.flat(), {
56
84
  verbatim: options.verbatim,
57
85
  selector: options.selector,
58
86
  owned: options.owned,