@intentius/chant-lexicon-k8s 0.28.0 → 0.30.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.
@@ -7,9 +7,14 @@
7
7
  * name (using the props.metadata.name + props.metadata.namespace from #39's
8
8
  * entity-prop pass-through).
9
9
  *
10
- * Resource-not-found is silent `state diff --live` then reports it as
11
- * missing (declared, not in cloud). Unknown entity types are warn-skipped;
12
- * extending the KUBECTL_RESOURCE map covers more.
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.
13
18
  *
14
19
  * Before touching any resource, the environment is resolved to a cluster
15
20
  * identity (chant #1100) via `resolveClusterTarget` — see `./config.ts` for
@@ -19,7 +24,7 @@
19
24
  * error rather than silently reading the wrong cluster. No binding keeps
20
25
  * today's behavior (ambient context), with a visible warning.
21
26
  */
22
- import type { ResourceMetadata } from "@intentius/chant/lexicon";
27
+ import type { ObservationResult } from "@intentius/chant/lexicon";
23
28
  /**
24
29
  * Map chant entity types to `kubectl get` resource names. Add entries here
25
30
  * as new types are needed.
@@ -34,5 +39,5 @@ export declare function describeResources(options: {
34
39
  props: Record<string, unknown>;
35
40
  }>;
36
41
  owned?: boolean;
37
- }): Promise<Record<string, ResourceMetadata>>;
42
+ }): Promise<ObservationResult>;
38
43
  //# sourceMappingURL=describe-resources.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAIH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,0BAA0B,CAAC;AAOjE;;;GAGG;AACH,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAoBnD,CAAC;AAsCF,wBAAsB,iBAAiB,CAAC,OAAO,EAAE;IAC/C,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC9E,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC,CA2D5C"}
1
+ {"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AAIH,OAAO,KAAK,EAAE,iBAAiB,EAAsC,MAAM,0BAA0B,CAAC;AAQtG;;;GAGG;AACH,eAAO,MAAM,gBAAgB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAoBnD,CAAC;AAsCF,wBAAsB,iBAAiB,CAAC,OAAO,EAAE;IAC/C,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC9E,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAyF7B"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "63b8e4b79b980ec7b6fb5e49b43686eeb7b024e6feab86d6d71169c65db10587",
4
+ "manifest.json": "a14947ba11b4d27cc1387e873f31bcacec4af1e574efe03110ee9f826c74451a",
5
5
  "meta.json": "6d112006daa7196eb28619ac5a3893b709dc2b845edbd537db7d7b471e8dc104",
6
6
  "types/index.d.ts": "7b873148146a5512c22baabfde6cfcc3248c24c2c7578f68a19544af5dc675f6",
7
7
  "rules/argo-appset-single-project.ts": "afa9f310753aa2d475f35012b13135b2dfaebed2a35d44edbe185ebc07673674",
@@ -50,5 +50,5 @@
50
50
  "skills/chant-k8s-aks.md": "e18f0e2b055f72cd7a37deaf258d7027c2d4d3e286e8fd4975b27a1f981a3ad9",
51
51
  "skills/chant-k8s-argo.md": "b1a0b826559d8c5033a479c5781efaf650320f0aee4419d8841170bd3393cea5"
52
52
  },
53
- "composite": "d6deea6f0e6f08944da12ce71f90784b9fce48659eee22cbf223ae331c7e1e22"
53
+ "composite": "76ad1e3946f333f82f54e8da4c6b1f6e3ba11c7137d7f5b2728a58e786b1341e"
54
54
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "k8s",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "K8s",
6
6
  "intrinsics": [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-k8s",
3
- "version": "0.28.0",
3
+ "version": "0.30.0",
4
4
  "description": "Kubernetes lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -75,7 +75,7 @@
75
75
  "typescript": "^5.9.3"
76
76
  },
77
77
  "peerDependencies": {
78
- "@intentius/chant": "^0.28.0",
78
+ "@intentius/chant": "^0.30.0",
79
79
  "typescript": "^5.9.3"
80
80
  }
81
81
  }
@@ -60,13 +60,13 @@ describe("k8s describeResources", () => {
60
60
 
61
61
  const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web", "webSvc"], entities });
62
62
 
63
- expect(result["web"]).toMatchObject({
63
+ expect(result.resources["web"]).toMatchObject({
64
64
  type: "K8s::Apps::Deployment",
65
65
  physicalId: "uid-1",
66
66
  status: "READY",
67
67
  attributes: expect.objectContaining({ namespace: "prod", labels: { app: "web" } }),
68
68
  });
69
- expect(result["webSvc"]).toMatchObject({
69
+ expect(result.resources["webSvc"]).toMatchObject({
70
70
  type: "K8s::Core::Service",
71
71
  physicalId: "uid-2",
72
72
  status: "PRESENT",
@@ -82,10 +82,12 @@ describe("k8s describeResources", () => {
82
82
 
83
83
  const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["missing"], entities });
84
84
 
85
- expect(result).toEqual({});
85
+ // A real NotFound is an absence: neither present nor unobserved.
86
+ expect(result.resources).toEqual({});
87
+ expect(result.unobserved ?? {}).toEqual({});
86
88
  });
87
89
 
88
- test("entity types without kubectl mapping are warn-skipped", async () => {
90
+ test("entity types without kubectl mapping are reported unobserved, not absent (#1089)", async () => {
89
91
  const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
90
92
  const entities = makeEntities([
91
93
  { name: "exotic", entityType: "K8s::Custom::CRD::SomeOperator", props: { metadata: { name: "x" } } },
@@ -93,13 +95,69 @@ describe("k8s describeResources", () => {
93
95
 
94
96
  const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["exotic"], entities });
95
97
 
96
- expect(result).toEqual({});
97
- expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("kubectl mapping"));
98
+ expect(result.resources).toEqual({});
99
+ expect(result.unobserved?.exotic).toMatchObject({
100
+ type: "K8s::Custom::CRD::SomeOperator",
101
+ reason: "unsupported-kind",
102
+ });
103
+ expect(execMock).not.toHaveBeenCalled();
98
104
  const mappingWarning = warnSpy.mock.calls.find((c) => String(c[0]).includes("kubectl mapping"));
99
105
  expect(mappingWarning?.[0]).toContain("K8s::Custom::CRD::SomeOperator");
100
106
  warnSpy.mockRestore();
101
107
  });
102
108
 
109
+ // #1089 — a kubectl failure that is not a NotFound proves nothing about the
110
+ // object's existence, and must not reach the change set as an absence.
111
+ test.each([
112
+ ["error: You must be logged in to the server (Unauthorized)", "no-credentials"],
113
+ ["The connection to the server 127.0.0.1:6443 was refused - did you specify the right host or port?", "no-binding"],
114
+ ["error: unexpected EOF while parsing", "read-failed"],
115
+ ])("kubectl failure %j is reported unobserved (%s)", async (stderr, reason) => {
116
+ execMock.mockImplementation(() => { throw Object.assign(new Error("kubectl failed"), { stderr }); });
117
+ const entities = makeEntities([
118
+ { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
119
+ ]);
120
+
121
+ const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities });
122
+
123
+ expect(result.resources).toEqual({});
124
+ expect(result.unobserved?.web?.reason).toBe(reason);
125
+ });
126
+
127
+ test("a CRD kind the server does not serve is a real absence (nothing of that kind can exist)", async () => {
128
+ execMock.mockImplementation(() => {
129
+ throw Object.assign(new Error("kubectl failed"), {
130
+ stderr: 'error: the server doesn\'t have a resource type "widgets"',
131
+ });
132
+ });
133
+ const entities = makeEntities([
134
+ { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
135
+ ]);
136
+
137
+ const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities });
138
+
139
+ expect(result.resources).toEqual({});
140
+ expect(result.unobserved ?? {}).toEqual({});
141
+ });
142
+
143
+ test("--owned withholds an unmarked live object as `filtered`, never as absent (#1089)", async () => {
144
+ execMock.mockResolvedValue({
145
+ stdout: JSON.stringify({
146
+ metadata: { name: "web", namespace: "prod", uid: "uid-1", creationTimestamp: "t", labels: { app: "web" } },
147
+ status: { readyReplicas: 1, replicas: 1 },
148
+ }),
149
+ stderr: "",
150
+ });
151
+ const entities = makeEntities([
152
+ { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
153
+ ]);
154
+
155
+ const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities, owned: true });
156
+
157
+ expect(result.resources).toEqual({});
158
+ expect(result.unobserved?.web?.reason).toBe("filtered");
159
+ });
160
+
103
161
  test("Deployment with replicas != readyReplicas reports PROGRESSING", async () => {
104
162
  execMock.mockResolvedValue({
105
163
  stdout: JSON.stringify({
@@ -115,7 +173,7 @@ describe("k8s describeResources", () => {
115
173
 
116
174
  const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities });
117
175
 
118
- expect(result["web"].status).toBe("PROGRESSING(1/3)");
176
+ expect(result.resources["web"].status).toBe("PROGRESSING(1/3)");
119
177
  });
120
178
 
121
179
  test("Pod uses status.phase as the status", async () => {
@@ -133,7 +191,7 @@ describe("k8s describeResources", () => {
133
191
 
134
192
  const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["p"], entities });
135
193
 
136
- expect(result["p"].status).toBe("Running");
194
+ expect(result.resources["p"].status).toBe("Running");
137
195
  });
138
196
 
139
197
  test("namespace-less resource omits the -n flag", async () => {
@@ -159,14 +217,15 @@ describe("k8s describeResources", () => {
159
217
  expect(receivedCmd).toContain("namespace mynamespace");
160
218
  });
161
219
 
162
- test("entity without metadata.name is silently skipped", async () => {
220
+ test("entity without metadata.name is unobserved — nothing was queried", async () => {
163
221
  const entities = makeEntities([
164
222
  { name: "broken", entityType: "K8s::Apps::Deployment", props: {} },
165
223
  ]);
166
224
 
167
225
  const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["broken"], entities });
168
226
 
169
- expect(result).toEqual({});
227
+ expect(result.resources).toEqual({});
228
+ expect(result.unobserved?.broken?.reason).toBe("read-failed");
170
229
  expect(execMock).not.toHaveBeenCalled();
171
230
  });
172
231
 
@@ -196,7 +255,7 @@ describe("k8s describeResources", () => {
196
255
  const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities: deploymentEntities() });
197
256
 
198
257
  expect(receivedCmd).toContain("--context prod-eks");
199
- expect(result["web"]).toMatchObject({ type: "K8s::Apps::Deployment", physicalId: "uid-1", status: "READY" });
258
+ expect(result.resources["web"]).toMatchObject({ type: "K8s::Apps::Deployment", physicalId: "uid-1", status: "READY" });
200
259
  });
201
260
 
202
261
  test("bound and ambient context mismatches: refuses loudly instead of observing the wrong cluster", async () => {
@@ -228,7 +287,7 @@ describe("k8s describeResources", () => {
228
287
  // Unchanged: no ambient-context probe, no --context flag, same result shape as today.
229
288
  expect(receivedCmd).not.toContain("--context");
230
289
  expect(receivedCmd).not.toContain("current-context");
231
- expect(result["web"]).toMatchObject({ type: "K8s::Apps::Deployment", physicalId: "uid-1", status: "READY" });
290
+ expect(result.resources["web"]).toMatchObject({ type: "K8s::Apps::Deployment", physicalId: "uid-1", status: "READY" });
232
291
 
233
292
  // Visible, not silent: a warning names the environment and the missing binding.
234
293
  const bindingWarning = warnSpy.mock.calls.find((c) => String(c[0]).includes("no cluster binding"));
@@ -7,9 +7,14 @@
7
7
  * name (using the props.metadata.name + props.metadata.namespace from #39's
8
8
  * entity-prop pass-through).
9
9
  *
10
- * Resource-not-found is silent `state diff --live` then reports it as
11
- * missing (declared, not in cloud). Unknown entity types are warn-skipped;
12
- * extending the KUBECTL_RESOURCE map covers more.
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.
13
18
  *
14
19
  * Before touching any resource, the environment is resolved to a cluster
15
20
  * identity (chant #1100) via `resolveClusterTarget` — see `./config.ts` for
@@ -22,10 +27,11 @@
22
27
 
23
28
  import { exec } from "node:child_process";
24
29
  import { promisify } from "node:util";
25
- import type { ResourceMetadata } from "@intentius/chant/lexicon";
30
+ import type { ObservationResult, ResourceMetadata, UnobservedEntity } from "@intentius/chant/lexicon";
31
+ import { observation } from "@intentius/chant/observation";
26
32
  import { hasOwnershipMarker, classifyOwnership, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
27
33
  import { loadChantConfig } from "@intentius/chant/config";
28
- import { resolveClusterTarget } from "@intentius/chant/kubectl-context";
34
+ import { resolveClusterTarget, classifyKubectlFailure } from "@intentius/chant/kubectl-context";
29
35
 
30
36
  const execAsync = promisify(exec);
31
37
 
@@ -97,8 +103,9 @@ export async function describeResources(options: {
97
103
  entityNames: string[];
98
104
  entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
99
105
  owned?: boolean;
100
- }): Promise<Record<string, ResourceMetadata>> {
106
+ }): Promise<ObservationResult> {
101
107
  const result: Record<string, ResourceMetadata> = {};
108
+ const unobserved: Record<string, UnobservedEntity> = {};
102
109
  const skippedTypes = new Set<string>();
103
110
 
104
111
  // Resolve the cluster identity for this environment before touching any
@@ -112,13 +119,29 @@ export async function describeResources(options: {
112
119
  for (const [entityName, { entityType, props }] of options.entities) {
113
120
  const kubectlResource = KUBECTL_RESOURCE[entityType];
114
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).
115
125
  skippedTypes.add(entityType);
126
+ unobserved[entityName] = {
127
+ type: entityType,
128
+ reason: "unsupported-kind",
129
+ detail: `no kubectl mapping for ${entityType} — extend KUBECTL_RESOURCE to observe it`,
130
+ };
116
131
  continue;
117
132
  }
118
133
 
119
134
  const metadata = props.metadata as { name?: string; namespace?: string } | undefined;
120
135
  const name = metadata?.name;
121
- if (!name) continue;
136
+ if (!name) {
137
+ // Nothing to query by. Not an absence — chant never issued a read.
138
+ unobserved[entityName] = {
139
+ type: entityType,
140
+ reason: "read-failed",
141
+ detail: "declared entity has no metadata.name to query by",
142
+ };
143
+ continue;
144
+ }
122
145
 
123
146
  const nsArg = metadata.namespace ? ["-n", metadata.namespace] : [];
124
147
  const cmd = ["kubectl", "get", kubectlResource, name, ...nsArg, ...ctxArg, "-o", "json"].join(" ");
@@ -126,8 +149,16 @@ export async function describeResources(options: {
126
149
  try {
127
150
  const { stdout } = await execAsync(cmd);
128
151
  const obj: KubectlResponse = JSON.parse(stdout);
129
- // owned filter: skip resources not carrying chant's marker label.
152
+ // 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
155
+ // declared-but-foreign resource into a proposed `create`.
130
156
  if (options.owned && !hasOwnershipMarker(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS)) {
157
+ unobserved[entityName] = {
158
+ type: entityType,
159
+ reason: "filtered",
160
+ detail: "live object carries no chant ownership marker and --owned was requested",
161
+ };
131
162
  continue;
132
163
  }
133
164
  result[entityName] = {
@@ -142,18 +173,23 @@ export async function describeResources(options: {
142
173
  resourceVersion: obj.metadata?.resourceVersion,
143
174
  }),
144
175
  };
145
- } catch {
146
- // Resource not found / kubectl error leave it out so state diff
147
- // can report it as missing. Don't fail the whole snapshot.
176
+ } 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);
181
+ if (outcome.kind === "unobserved") {
182
+ unobserved[entityName] = { type: entityType, reason: outcome.reason, detail: outcome.detail };
183
+ }
148
184
  }
149
185
  }
150
186
 
151
187
  if (skippedTypes.size > 0) {
152
188
  // eslint-disable-next-line no-console
153
189
  console.warn(
154
- `[k8s] skipped ${skippedTypes.size} entity type(s) without kubectl mapping: ${[...skippedTypes].join(", ")}`,
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`,
155
191
  );
156
192
  }
157
193
 
158
- return result;
194
+ return observation(result, unobserved);
159
195
  }
@@ -31,6 +31,9 @@ vi.mock("node:child_process", async () => {
31
31
  const { k8sPlugin } = await import("./plugin");
32
32
  const { liveImportFromPlugins } = await import("@intentius/chant/cli/commands/import");
33
33
  const { buildChangeSet } = await import("@intentius/chant/lifecycle/change-set");
34
+ const { normalizeObservation } = await import("@intentius/chant/observation");
35
+ const { liveEvidenceFromChangeSet, reconcileStatus } = await import("@intentius/chant/lifecycle/status");
36
+ const { describeObservationConformance } = await import("@intentius/chant-test-utils");
34
37
 
35
38
  const liveDeployment = {
36
39
  apiVersion: "apps/v1",
@@ -82,14 +85,16 @@ describe("k8s lifecycle integration (#163)", () => {
82
85
  : new Error("not found"),
83
86
  );
84
87
 
85
- const observedNow = await k8sPlugin.describeResources!({
86
- environment: "prod",
87
- buildOutput: "",
88
- entityNames: ["web"],
89
- entities: new Map([
90
- ["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } }],
91
- ]),
92
- });
88
+ const { resources: observedNow } = normalizeObservation(
89
+ await k8sPlugin.describeResources!({
90
+ environment: "prod",
91
+ buildOutput: "",
92
+ entityNames: ["web"],
93
+ entities: new Map([
94
+ ["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } }],
95
+ ]),
96
+ }),
97
+ );
93
98
  expect(observedNow.web?.type).toBe("K8s::Apps::Deployment");
94
99
 
95
100
  const cs = buildChangeSet("prod", {
@@ -108,4 +113,130 @@ describe("k8s lifecycle integration (#163)", () => {
108
113
  });
109
114
  expect(cs2.entries.find((e) => e.name === "web")!.action).toBe("noop");
110
115
  });
116
+
117
+ /**
118
+ * The #1089 chain, end to end on the real plugin: a declared CRD the lexicon
119
+ * has no reader for goes describe → plan → component status without ever
120
+ * turning into a create or a "stale" component.
121
+ */
122
+ test("tri-state chain: an unreadable CRD stays unobserved through describe → plan → status (#1089)", async () => {
123
+ execMock.mockImplementation((cmd?: string) =>
124
+ cmd?.includes("deployment.apps web")
125
+ ? {
126
+ stdout: JSON.stringify({
127
+ metadata: { name: "web", namespace: "prod", uid: "uid-1" },
128
+ status: { readyReplicas: 3, replicas: 3 },
129
+ }),
130
+ stderr: "",
131
+ }
132
+ : new Error('Error from server (NotFound): deployments.apps "gone" not found'),
133
+ );
134
+
135
+ const entities = new Map([
136
+ ["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } }],
137
+ ["gone", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "gone", namespace: "prod" } } }],
138
+ ["widget", { entityType: "K8s::Example::Widget", props: { metadata: { name: "widget", namespace: "prod" } } }],
139
+ ]);
140
+
141
+ // 1. describe — three declared entities, three different verdicts.
142
+ const observed = normalizeObservation(
143
+ await k8sPlugin.describeResources!({
144
+ environment: "prod",
145
+ buildOutput: "",
146
+ entityNames: [...entities.keys()],
147
+ entities,
148
+ }),
149
+ );
150
+ expect(Object.keys(observed.resources)).toEqual(["web"]);
151
+ expect(observed.unobserved.widget.reason).toBe("unsupported-kind");
152
+ expect(observed.unobserved.gone).toBeUndefined(); // NotFound is an absence
153
+
154
+ // 2. plan — the CRD is `unobserved`; only the confirmed-absent one is a create.
155
+ const cs = buildChangeSet("prod", {
156
+ declared: new Set(entities.keys()),
157
+ observedNow: observed.resources,
158
+ observedThen: undefined,
159
+ unobserved: observed.unobserved,
160
+ });
161
+ const byName = Object.fromEntries(cs.entries.map((e) => [e.name, e.action]));
162
+ expect(byName).toEqual({ web: "noop", gone: "create", widget: "unobserved" });
163
+
164
+ // 3. status — a recorded component whose entity was never read reports
165
+ // `unknown`, not `stale`, and carries no `live` boolean at all.
166
+ const rows = reconcileStatus("prod", [
167
+ { component: "widget", env: "prod", digest: "sha256:abc", gitSha: "g", runId: "r", timestamp: "2026-01-01T00:00:00Z", actor: "ci" },
168
+ ], { liveEvidence: liveEvidenceFromChangeSet(cs) });
169
+ const widgetRow = rows.find((r) => r.component === "widget")!;
170
+ expect(widgetRow.reconciliation).toBe("unknown");
171
+ expect(widgetRow.live).toBeUndefined();
172
+ expect(widgetRow.unobserved?.reason).toBe("unsupported-kind");
173
+ expect(widgetRow.detail).toContain("could not be observed");
174
+ });
175
+ });
176
+
177
+ // The shared conformance suite (#1089) — every observing lexicon runs it.
178
+ describeObservationConformance({
179
+ lexicon: "k8s",
180
+ scenarios: [
181
+ {
182
+ name: "a CRD kind with no reader",
183
+ declared: ["widget", "gone"],
184
+ expectUnobserved: ["widget"],
185
+ expectAbsent: ["gone"],
186
+ run: () => {
187
+ execMock.mockImplementation(() => new Error('Error from server (NotFound): deployments.apps "gone" not found'));
188
+ return k8sPlugin.describeResources!({
189
+ environment: "prod",
190
+ buildOutput: "",
191
+ entityNames: ["widget", "gone"],
192
+ entities: new Map([
193
+ ["widget", { entityType: "K8s::Example::Widget", props: { metadata: { name: "widget" } } }],
194
+ ["gone", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "gone" } } }],
195
+ ]),
196
+ });
197
+ },
198
+ },
199
+ {
200
+ name: "an unreachable API server",
201
+ declared: ["web"],
202
+ expectUnobserved: ["web"],
203
+ run: () => {
204
+ execMock.mockImplementation(() =>
205
+ Object.assign(new Error("kubectl failed"), {
206
+ stderr: "The connection to the server 127.0.0.1:6443 was refused - did you specify the right host or port?",
207
+ }),
208
+ );
209
+ return k8sPlugin.describeResources!({
210
+ environment: "prod",
211
+ buildOutput: "",
212
+ entityNames: ["web"],
213
+ entities: new Map([
214
+ ["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web" } } }],
215
+ ]),
216
+ });
217
+ },
218
+ },
219
+ {
220
+ name: "a healthy read",
221
+ declared: ["web"],
222
+ expectPresent: ["web"],
223
+ run: () => {
224
+ execMock.mockImplementation(() => ({
225
+ stdout: JSON.stringify({
226
+ metadata: { name: "web", namespace: "prod", uid: "uid-1", labels: { "app.kubernetes.io/managed-by": "chant" } },
227
+ status: { readyReplicas: 1, replicas: 1 },
228
+ }),
229
+ stderr: "",
230
+ }));
231
+ return k8sPlugin.describeResources!({
232
+ environment: "prod",
233
+ buildOutput: "",
234
+ entityNames: ["web"],
235
+ entities: new Map([
236
+ ["web", { entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web" } } }],
237
+ ]),
238
+ });
239
+ },
240
+ },
241
+ ],
111
242
  });