@intentius/chant-lexicon-k8s 0.44.1 → 0.44.3

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.
@@ -1 +1 @@
1
- {"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAwD,MAAM,0BAA0B,CAAC;AAGxH,OAAO,KAAK,EAAa,SAAS,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,EAAuB,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAmBvE;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,SAAS,GAAG,MAAM,CA2CvD;AA4FD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,SAAS,GAAG,MAAM,EAAE,GAAG,SAAS,CAetE;AAQD,MAAM,WAAW,wBAAwB;IACvC,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;IAChB,sFAAsF;IACtF,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,wBAAwB,EACjC,OAAO,GAAE,YAAkC,GAC1C,OAAO,CAAC,iBAAiB,CAAC,CAuH5B"}
1
+ {"version":3,"file":"describe-resources.d.ts","sourceRoot":"","sources":["../src/describe-resources.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsDG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAwD,MAAM,0BAA0B,CAAC;AAGxH,OAAO,KAAK,EAAa,SAAS,EAAE,MAAM,6BAA6B,CAAC;AACxE,OAAO,EAAuB,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAmBvE;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,SAAS,GAAG,MAAM,CA2CvD;AA4FD,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,SAAS,GAAG,MAAM,EAAE,GAAG,SAAS,CAetE;AAQD,MAAM,WAAW,wBAAwB;IACvC,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;IAChB,sFAAsF;IACtF,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,wBAAwB,EACjC,OAAO,GAAE,YAAkC,GAC1C,OAAO,CAAC,iBAAiB,CAAC,CAoJ5B"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "df671e29d9407ee96948c664b023efdb945fe12b235b2d80e9068a21981ff8a9",
4
+ "manifest.json": "d94d393cea4fe4e3bebaa9590a85d8966e15e27d6cf2233ed07395182146501b",
5
5
  "meta.json": "f568fb8a0cbd4adf041baccaa3e1164c2e3c5c0090186236f887f0084f5521a8",
6
6
  "types/index.d.ts": "ef2c9df6a4d1ebfbe33f96c094fbbe3fc7909522ea38e086666fbf3c8ac477fd",
7
7
  "rules/argo-appset-single-project.ts": "afa9f310753aa2d475f35012b13135b2dfaebed2a35d44edbe185ebc07673674",
@@ -54,5 +54,5 @@
54
54
  "skills/chant-k8s-argo.md": "b1a0b826559d8c5033a479c5781efaf650320f0aee4419d8841170bd3393cea5",
55
55
  "skills/chant-k8s-flux.md": "9670efc2e9854bd14686b5adeb707396b9030a4dcaee6529b30abf56efe2c50c"
56
56
  },
57
- "composite": "429fe4248b671efc49187546209d335f57c4483ed1e1cbf2e98fb61490f0b235"
57
+ "composite": "1031cf6d53da6ad5ddf631aaa8160f1a5ca914c95a124b0827e42b40b99ab270"
58
58
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "k8s",
3
- "version": "0.44.1",
3
+ "version": "0.44.3",
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.44.1",
3
+ "version": "0.44.3",
4
4
  "description": "Kubernetes lexicon for chant — declarative IaC in TypeScript",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -76,7 +76,7 @@
76
76
  "@types/js-yaml": "^4.0.9"
77
77
  },
78
78
  "optionalDependencies": {
79
- "@intentius/chant-k8s-client": "^0.44.1"
79
+ "@intentius/chant-k8s-client": "^0.44.3"
80
80
  },
81
81
  "devDependencies": {
82
82
  "@intentius/chant": "*",
@@ -84,7 +84,7 @@
84
84
  },
85
85
  "peerDependencies": {
86
86
  "zod": "^4.3.6",
87
- "@intentius/chant": "^0.44.1",
87
+ "@intentius/chant": "^0.44.3",
88
88
  "typescript": "^5.9.3"
89
89
  }
90
90
  }
@@ -103,13 +103,30 @@ describe("classifyApiFailure", () => {
103
103
  }
104
104
  });
105
105
 
106
- test("the context note survives the one-line cap rather than truncating with the message (#1488)", () => {
106
+ test("the context note leads the detail, so it survives the one-line cap and any consumer truncation (#1488, #1620)", () => {
107
107
  const err = new K8sApiError(500, "InternalError", "x".repeat(500));
108
108
  err.contextNote = 'context "prod-eks" (bound by k8s.profiles.prod.context)';
109
109
  const outcome = classifyApiFailure(err);
110
110
  expect(outcome.kind).toBe("unobserved");
111
111
  if (outcome.kind === "unobserved") {
112
- expect(outcome.detail.endsWith('context "prod-eks" (bound by k8s.profiles.prod.context)')).toBe(true);
112
+ expect(outcome.detail.startsWith('context "prod-eks" (bound by k8s.profiles.prod.context)')).toBe(true);
113
+ // The error itself still follows — leading with the cluster does not
114
+ // cost the message.
115
+ expect(outcome.detail).toContain("xxx");
116
+ }
117
+ });
118
+
119
+ test("the context appears within the first 160 chars of a long multi-line error's detail (#1620)", () => {
120
+ // behold's estate overlay truncates a reason to its first line / 160 chars;
121
+ // the context must land inside that window or it vanishes exactly on the
122
+ // failures long enough to need it.
123
+ const err = new K8sApiError(500, "InternalError", `${"y".repeat(300)}\n${"z".repeat(300)}`);
124
+ err.contextNote = 'context "prod-eks" (bound by k8s.profiles.prod.context)';
125
+ const outcome = classifyApiFailure(err);
126
+ expect(outcome.kind).toBe("unobserved");
127
+ if (outcome.kind === "unobserved") {
128
+ const firstLine = outcome.detail.split("\n")[0];
129
+ expect(firstLine.slice(0, 160)).toContain('context "prod-eks" (bound by k8s.profiles.prod.context)');
113
130
  }
114
131
  });
115
132
 
@@ -145,7 +145,10 @@ function detailOf(err: unknown): string {
145
145
  const capped = line.length > 200 ? `${line.slice(0, 197)}...` : line;
146
146
  // #1488 — a reason that does not say which cluster was read turned an
147
147
  // ambient-context switch into an afternoon. The client stamps the context it
148
- // resolved onto every failure; append it after the cap so it never truncates.
148
+ // resolved onto every failure. The note LEADS the detail (#1620): consumers
149
+ // truncate a reason to its first line and ~160 chars, and a note appended
150
+ // after a long error is cut off exactly when the error is long enough to
151
+ // need it. Leading, it survives any suffix truncation.
149
152
  const note = shapeOf(err).contextNote;
150
- return note ? `${capped} — ${note}` : capped;
153
+ return note ? `${note} — ${capped}` : capped;
151
154
  }
@@ -105,6 +105,81 @@ describe("k8s describeResources", () => {
105
105
  expect(result.unobserved ?? {}).toEqual({});
106
106
  });
107
107
 
108
+ // The behold#192 shape (#1620): a Flux-deployed object declares no
109
+ // metadata.namespace (the controller stamps targetNamespace at apply), so the
110
+ // live read scopes to the client's default namespace, finds nothing, and
111
+ // correctly reports absence. Every layer is spec-correct and the picture
112
+ // still says the opposite of the truth — unless the report carries the
113
+ // address it actually asked, which is what `queried` is.
114
+ test("an absent verdict carries the resolved query address, namespace defaulted and visible (#1620)", async () => {
115
+ const cluster = fakeCluster({
116
+ // The object lives where Flux put it — not where the read will look.
117
+ objects: { [objectKey("apps/v1", "Deployment", "web", "flux-target")]: web },
118
+ });
119
+ const result = await describeResources(
120
+ {
121
+ environment: "prod",
122
+ buildOutput: "",
123
+ entityNames: ["web"],
124
+ entities: makeEntities([
125
+ // No metadata.namespace declared — Flux stamps it at apply time.
126
+ { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web" } } },
127
+ ]),
128
+ },
129
+ cluster.connector,
130
+ );
131
+
132
+ // Still a clean absence: in neither map, the tri-state unshifted.
133
+ expect(result.resources).toEqual({});
134
+ expect(result.unobserved ?? {}).toEqual({});
135
+ // But the report can now explain itself: the read went to `default`.
136
+ expect(result.queried?.web).toBe("/apis/apps/v1/namespaces/default/deployments/web");
137
+ });
138
+
139
+ test("a present entity carries its query address too — the map is total over issued reads (#1620)", async () => {
140
+ const cluster = fakeCluster({
141
+ objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: web },
142
+ });
143
+ const result = await describeResources(
144
+ {
145
+ environment: "prod",
146
+ buildOutput: "",
147
+ entityNames: ["web"],
148
+ entities: makeEntities([
149
+ { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
150
+ ]),
151
+ },
152
+ cluster.connector,
153
+ );
154
+ expect(result.resources.web).toBeDefined();
155
+ expect(result.queried?.web).toBe("/apis/apps/v1/namespaces/prod/deployments/web");
156
+ });
157
+
158
+ test("a read-failed unobserved entry carries the address the failing read was issued against (#1620)", async () => {
159
+ const cluster = fakeCluster({
160
+ respond: (req) =>
161
+ req.path.endsWith("/deployments/web")
162
+ ? { status: 500, body: statusBody(500, "InternalError", "etcdserver: request timed out") }
163
+ : undefined,
164
+ });
165
+ const result = await describeResources(
166
+ {
167
+ environment: "prod",
168
+ buildOutput: "",
169
+ entityNames: ["web"],
170
+ entities: makeEntities([
171
+ { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
172
+ ]),
173
+ },
174
+ cluster.connector,
175
+ );
176
+ expect(result.unobserved?.web).toMatchObject({
177
+ reason: "read-failed",
178
+ queried: "/apis/apps/v1/namespaces/prod/deployments/web",
179
+ });
180
+ expect(result.queried?.web).toBe("/apis/apps/v1/namespaces/prod/deployments/web");
181
+ });
182
+
108
183
  // The headline of chant #1074: the twenty-entry map is gone, so a CRD is an
109
184
  // ordinary read rather than a permanent hole.
110
185
  test("a CRD is observed like anything else — no KUBECTL_RESOURCE entry required", async () => {
@@ -255,6 +255,14 @@ export async function describeResources(
255
255
  ): Promise<ObservationResult> {
256
256
  const resources: Record<string, ResourceMetadata> = {};
257
257
  const unobserved: Record<string, UnobservedEntity> = {};
258
+ // The resolved query address per declared entity (#1620) — the exact request
259
+ // path each read was issued against, with the namespace the client actually
260
+ // defaulted to when the declaration carries none. This is what lets an
261
+ // absence explain itself: a Flux-deployed object declaring no
262
+ // metadata.namespace reads from `default`, correctly comes back 404, and
263
+ // without the address the report says "not there" when the truth is "not
264
+ // looked for where it lives".
265
+ const queried: Record<string, string> = {};
258
266
 
259
267
  const declared: Declared[] = [...options.entities].map(([entityName, entity]) => ({
260
268
  entityName,
@@ -323,13 +331,27 @@ export async function describeResources(
323
331
  return;
324
332
  }
325
333
 
334
+ const ref = {
335
+ apiVersion: operation.apiVersion,
336
+ kind: operation.kind,
337
+ name,
338
+ ...(metadata?.namespace ? { namespace: metadata.namespace } : {}),
339
+ };
340
+
341
+ // Resolve the address BEFORE the read, so every verdict below — present,
342
+ // absent, filtered, read-failed — carries the same record of what was
343
+ // asked. Resolution rides the client's cached discovery; a kind discovery
344
+ // does not serve has no path, and a resolution failure classifies through
345
+ // the read below, never here.
346
+ try {
347
+ const address = typeof client.pathFor === "function" ? await client.pathFor(ref) : undefined;
348
+ if (address) queried[entityName] = address;
349
+ } catch {
350
+ // The read below hits the same resolution and classifies its failure.
351
+ }
352
+
326
353
  try {
327
- const obj = await client.read({
328
- apiVersion: operation.apiVersion,
329
- kind: operation.kind,
330
- name,
331
- ...(metadata?.namespace ? { namespace: metadata.namespace } : {}),
332
- });
354
+ const obj = await client.read(ref);
333
355
 
334
356
  // owned filter: withhold resources not carrying chant's marker label.
335
357
  // Withheld is not absent (chant #1089) — this object exists, it just
@@ -340,6 +362,7 @@ export async function describeResources(
340
362
  type: entityType,
341
363
  reason: "filtered",
342
364
  detail: "live object carries no chant ownership marker and --owned was requested",
365
+ ...(queried[entityName] ? { queried: queried[entityName] } : {}),
343
366
  };
344
367
  return;
345
368
  }
@@ -361,16 +384,22 @@ export async function describeResources(
361
384
  } catch (err) {
362
385
  const outcome = classifyApiFailure(err);
363
386
  if (outcome.kind === "unobserved") {
364
- unobserved[entityName] = { type: entityType, reason: outcome.reason, detail: outcome.detail };
387
+ unobserved[entityName] = {
388
+ type: entityType,
389
+ reason: outcome.reason,
390
+ detail: outcome.detail,
391
+ ...(queried[entityName] ? { queried: queried[entityName] } : {}),
392
+ };
365
393
  }
366
394
  // `absent` deliberately records nothing: in neither map is how the
367
- // contract spells "asked, and it is not there".
395
+ // contract spells "asked, and it is not there". Its address stays in
396
+ // `queried` (#1620), which is the only record an absence gets.
368
397
  }
369
398
  });
370
399
 
371
400
  await addRuntimeChildren(client, resources, unobserved, options.owned, declared);
372
401
 
373
- return observation(resources, unobserved);
402
+ return observation(resources, unobserved, queried);
374
403
  }
375
404
 
376
405
  /**