@intentius/chant-lexicon-k8s 0.30.0 → 0.32.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 (137) hide show
  1. package/dist/api/classify.d.ts +65 -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/api/owner-chain.d.ts +46 -0
  10. package/dist/api/owner-chain.d.ts.map +1 -0
  11. package/dist/api/sweep-types.d.ts +17 -0
  12. package/dist/api/sweep-types.d.ts.map +1 -0
  13. package/dist/codegen/generate-operations.d.ts +29 -0
  14. package/dist/codegen/generate-operations.d.ts.map +1 -0
  15. package/dist/codegen/generate.d.ts.map +1 -1
  16. package/dist/config.d.ts +17 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/crd/parser.d.ts.map +1 -1
  19. package/dist/crd/types.d.ts +7 -0
  20. package/dist/crd/types.d.ts.map +1 -1
  21. package/dist/deep-observe-hooks.d.ts +86 -0
  22. package/dist/deep-observe-hooks.d.ts.map +1 -0
  23. package/dist/deep-observe.d.ts +146 -0
  24. package/dist/deep-observe.d.ts.map +1 -0
  25. package/dist/describe-resources.d.ts +58 -26
  26. package/dist/describe-resources.d.ts.map +1 -1
  27. package/dist/export-resources.d.ts +31 -1
  28. package/dist/export-resources.d.ts.map +1 -1
  29. package/dist/integrity.json +2 -2
  30. package/dist/kube/apply.d.ts +26 -0
  31. package/dist/kube/apply.d.ts.map +1 -0
  32. package/dist/kube/connect.d.ts +30 -0
  33. package/dist/kube/connect.d.ts.map +1 -0
  34. package/dist/kube/delete.d.ts +27 -0
  35. package/dist/kube/delete.d.ts.map +1 -0
  36. package/dist/kube/describe.d.ts +19 -0
  37. package/dist/kube/describe.d.ts.map +1 -0
  38. package/dist/kube/events.d.ts +34 -0
  39. package/dist/kube/events.d.ts.map +1 -0
  40. package/dist/kube/flags.d.ts +40 -0
  41. package/dist/kube/flags.d.ts.map +1 -0
  42. package/dist/kube/get.d.ts +21 -0
  43. package/dist/kube/get.d.ts.map +1 -0
  44. package/dist/kube/group.d.ts +19 -0
  45. package/dist/kube/group.d.ts.map +1 -0
  46. package/dist/kube/logs.d.ts +16 -0
  47. package/dist/kube/logs.d.ts.map +1 -0
  48. package/dist/kube/project.d.ts +59 -0
  49. package/dist/kube/project.d.ts.map +1 -0
  50. package/dist/kube/render.d.ts +75 -0
  51. package/dist/kube/render.d.ts.map +1 -0
  52. package/dist/kube/source.d.ts +19 -0
  53. package/dist/kube/source.d.ts.map +1 -0
  54. package/dist/kube/target.d.ts +52 -0
  55. package/dist/kube/target.d.ts.map +1 -0
  56. package/dist/kube/testing.d.ts +21 -0
  57. package/dist/kube/testing.d.ts.map +1 -0
  58. package/dist/kube/top.d.ts +23 -0
  59. package/dist/kube/top.d.ts.map +1 -0
  60. package/dist/kube/verdict.d.ts +45 -0
  61. package/dist/kube/verdict.d.ts.map +1 -0
  62. package/dist/kube/wait.d.ts +23 -0
  63. package/dist/kube/wait.d.ts.map +1 -0
  64. package/dist/manifest.json +1 -1
  65. package/dist/op/activities/index.d.ts +7 -4
  66. package/dist/op/activities/index.d.ts.map +1 -1
  67. package/dist/op/activities/kubectl.d.ts +120 -2
  68. package/dist/op/activities/kubectl.d.ts.map +1 -1
  69. package/dist/op/activities/wait-for-ready.d.ts +30 -3
  70. package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
  71. package/dist/plugin.d.ts.map +1 -1
  72. package/dist/spec/parse.d.ts +42 -0
  73. package/dist/spec/parse.d.ts.map +1 -1
  74. package/package.json +5 -2
  75. package/src/api/classify.test.ts +133 -0
  76. package/src/api/classify.ts +144 -0
  77. package/src/api/connect.ts +104 -0
  78. package/src/api/fake-cluster.ts +218 -0
  79. package/src/api/operation-surface.test.ts +116 -0
  80. package/src/api/operation-surface.ts +129 -0
  81. package/src/api/owner-chain.test.ts +171 -0
  82. package/src/api/owner-chain.ts +144 -0
  83. package/src/api/sweep-types.ts +36 -0
  84. package/src/codegen/generate-operations.ts +56 -0
  85. package/src/codegen/generate.ts +9 -0
  86. package/src/config.ts +17 -0
  87. package/src/crd/parser.ts +8 -0
  88. package/src/crd/types.ts +7 -0
  89. package/src/deep-observe-hooks.ts +125 -0
  90. package/src/deep-observe.test.ts +668 -0
  91. package/src/deep-observe.ts +299 -0
  92. package/src/describe-resources.test.ts +619 -191
  93. package/src/describe-resources.ts +218 -117
  94. package/src/export-resources-io.test.ts +76 -51
  95. package/src/export-resources.ts +47 -35
  96. package/src/generated/operations.json +2156 -0
  97. package/src/kube/apply.test.ts +105 -0
  98. package/src/kube/apply.ts +101 -0
  99. package/src/kube/connect.ts +54 -0
  100. package/src/kube/delete.test.ts +123 -0
  101. package/src/kube/delete.ts +174 -0
  102. package/src/kube/describe.test.ts +106 -0
  103. package/src/kube/describe.ts +195 -0
  104. package/src/kube/events.test.ts +115 -0
  105. package/src/kube/events.ts +160 -0
  106. package/src/kube/flags.test.ts +65 -0
  107. package/src/kube/flags.ts +114 -0
  108. package/src/kube/get.test.ts +195 -0
  109. package/src/kube/get.ts +190 -0
  110. package/src/kube/group.test.ts +33 -0
  111. package/src/kube/group.ts +125 -0
  112. package/src/kube/logs.test.ts +126 -0
  113. package/src/kube/logs.ts +96 -0
  114. package/src/kube/project.test.ts +57 -0
  115. package/src/kube/project.ts +106 -0
  116. package/src/kube/render.test.ts +142 -0
  117. package/src/kube/render.ts +252 -0
  118. package/src/kube/source.test.ts +138 -0
  119. package/src/kube/source.ts +139 -0
  120. package/src/kube/target.test.ts +67 -0
  121. package/src/kube/target.ts +89 -0
  122. package/src/kube/testing.ts +42 -0
  123. package/src/kube/top.test.ts +149 -0
  124. package/src/kube/top.ts +120 -0
  125. package/src/kube/verdict.test.ts +48 -0
  126. package/src/kube/verdict.ts +77 -0
  127. package/src/kube/wait.test.ts +125 -0
  128. package/src/kube/wait.ts +160 -0
  129. package/src/lifecycle-integration.test.ts +132 -92
  130. package/src/op/activities/index.ts +7 -3
  131. package/src/op/activities/kubectl.test.ts +420 -0
  132. package/src/op/activities/kubectl.ts +379 -13
  133. package/src/op/activities/wait-for-ready.test.ts +94 -0
  134. package/src/op/activities/wait-for-ready.ts +66 -15
  135. package/src/plugin.test.ts +58 -1
  136. package/src/plugin.ts +29 -0
  137. package/src/spec/parse.ts +93 -1
@@ -41,6 +41,25 @@ export interface GroupVersionKind {
41
41
  version: string;
42
42
  kind: string;
43
43
  }
44
+ /**
45
+ * How this resource is addressed over the API — chant #1074.
46
+ *
47
+ * Read out of the same document the resource's types come from (the OpenAPI
48
+ * `paths` for core kinds, the CRD's `spec.names` / `spec.scope` for custom
49
+ * ones), so the operation surface and the declarable surface cannot drift
50
+ * apart the way a hand-maintained `kind → kubectl resource` table did.
51
+ *
52
+ * It is a starting point, not the authority: the live client confirms plural
53
+ * and scope against the cluster's own discovery, which is the only thing that
54
+ * knows what a given cluster actually serves.
55
+ */
56
+ export interface ParsedOperation {
57
+ /** Plural path segment, e.g. `deployments`. */
58
+ plural: string;
59
+ scope: "Namespaced" | "Cluster";
60
+ /** Verbs the schema documents for the named resource, e.g. `get`, `patch`. */
61
+ verbs: string[];
62
+ }
44
63
  export interface K8sParseResult {
45
64
  resource: ParsedResource;
46
65
  propertyTypes: ParsedPropertyType[];
@@ -48,12 +67,35 @@ export interface K8sParseResult {
48
67
  gvk: GroupVersionKind;
49
68
  /** Whether this entity is a property type (nested inside resources) */
50
69
  isProperty?: boolean;
70
+ /** How the API addresses this resource. Absent for property types. */
71
+ operation?: ParsedOperation;
51
72
  }
73
+ interface SwaggerOperation {
74
+ "x-kubernetes-group-version-kind"?: GroupVersionKind;
75
+ "x-kubernetes-action"?: string;
76
+ }
77
+ type SwaggerPathItem = Record<string, SwaggerOperation | unknown>;
52
78
  /**
53
79
  * Parse the Kubernetes OpenAPI swagger.json into multiple resource results.
54
80
  * Returns one result per top-level resource identified by x-kubernetes-group-version-kind.
55
81
  */
56
82
  export declare function parseK8sSwagger(data: string | Buffer): K8sParseResult[];
83
+ /** Stable key for a GVK, used to join the `paths` pass onto the `definitions` pass. */
84
+ export declare function gvkKey(gvk: GroupVersionKind): string;
85
+ /**
86
+ * Derive the operation surface from the OpenAPI `paths` — chant #1074.
87
+ *
88
+ * Every Kubernetes operation carries `x-kubernetes-group-version-kind` and
89
+ * `x-kubernetes-action`, and the path itself carries the two facts a REST call
90
+ * needs and a definition does not have: the plural segment, and whether the
91
+ * resource is namespaced (`/namespaces/{namespace}/` appears in its path).
92
+ *
93
+ * Only paths addressing a single named object (`.../{plural}/{name}`) are read,
94
+ * so subresource paths (`.../{name}/status`, `.../{name}/scale`) and collection
95
+ * paths do not supply the plural — but their verbs are collected, because
96
+ * "this kind can be listed" is worth knowing.
97
+ */
98
+ export declare function parseOperations(paths: Record<string, SwaggerPathItem> | undefined): Map<string, ParsedOperation>;
57
99
  /**
58
100
  * Convert GVK to our type name convention: K8s::{Group}::{Kind}
59
101
  */
@@ -1 +1 @@
1
- {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/spec/parse.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sCAAsC,CAAC;AAShF,YAAY,EAAE,mBAAmB,EAAE,CAAC;AAEpC,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,WAAW,EAAE,mBAAmB,CAAC;CAClC;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,gDAAgD;IAChD,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,cAAc,EAAE,CAAC;CAC9B;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,UAAU,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpD,oBAAoB,EAAE,MAAM,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,cAAc,CAAC;IACzB,aAAa,EAAE,kBAAkB,EAAE,CAAC;IACpC,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,GAAG,EAAE,gBAAgB,CAAC;IACtB,uEAAuE;IACvE,UAAU,CAAC,EAAE,OAAO,CAAC;CACtB;AA8FD;;;GAGG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,cAAc,EAAE,CA+BvE;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAG3D;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAK7D;AA6PD;;GAEG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGrD;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGvD"}
1
+ {"version":3,"file":"parse.d.ts","sourceRoot":"","sources":["../../src/spec/parse.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,sCAAsC,CAAC;AAShF,YAAY,EAAE,mBAAmB,EAAE,CAAC;AAEpC,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,WAAW,EAAE,mBAAmB,CAAC;CAClC;AAED,MAAM,WAAW,kBAAkB;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,gDAAgD;IAChD,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,cAAc,EAAE,CAAC;CAC9B;AAED,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,UAAU,EAAE,cAAc,EAAE,CAAC;IAC7B,UAAU,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpD,oBAAoB,EAAE,MAAM,EAAE,CAAC;CAChC;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,eAAe;IAC9B,+CAA+C;IAC/C,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,YAAY,GAAG,SAAS,CAAC;IAChC,8EAA8E;IAC9E,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED,MAAM,WAAW,cAAc;IAC7B,QAAQ,EAAE,cAAc,CAAC;IACzB,aAAa,EAAE,kBAAkB,EAAE,CAAC;IACpC,KAAK,EAAE,UAAU,EAAE,CAAC;IACpB,GAAG,EAAE,gBAAgB,CAAC;IACtB,uEAAuE;IACvE,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,sEAAsE;IACtE,SAAS,CAAC,EAAE,eAAe,CAAC;CAC7B;AAkCD,UAAU,gBAAgB;IACxB,iCAAiC,CAAC,EAAE,gBAAgB,CAAC;IACrD,qBAAqB,CAAC,EAAE,MAAM,CAAC;CAChC;AAED,KAAK,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,CAAC;AA+DlE;;;GAGG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,cAAc,EAAE,CAoCvE;AAED,uFAAuF;AACvF,wBAAgB,MAAM,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAEpD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,GAAG,SAAS,GAAG,GAAG,CAAC,MAAM,EAAE,eAAe,CAAC,CAqChH;AAED;;GAEG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAG3D;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,gBAAgB,GAAG,MAAM,CAK7D;AA6PD;;GAEG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGrD;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAGvD"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-k8s",
3
- "version": "0.30.0",
3
+ "version": "0.32.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",
@@ -70,12 +70,15 @@
70
70
  "js-yaml": "^4.1.1",
71
71
  "@types/js-yaml": "^4.0.9"
72
72
  },
73
+ "optionalDependencies": {
74
+ "@intentius/chant-k8s-client": "^0.32.0"
75
+ },
73
76
  "devDependencies": {
74
77
  "@intentius/chant": "*",
75
78
  "typescript": "^5.9.3"
76
79
  },
77
80
  "peerDependencies": {
78
- "@intentius/chant": "^0.30.0",
81
+ "@intentius/chant": "^0.32.0",
79
82
  "typescript": "^5.9.3"
80
83
  }
81
84
  }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Typed failure → observation tri-state (chant #1074 over #1089).
3
+ *
4
+ * The point of the move is that these verdicts are read off `code` and
5
+ * `reason` rather than matched against English. The cases below are the same
6
+ * ones `classifyKubectlFailure` covers in core, driven by the client's real
7
+ * error objects, so the contract can be compared side by side.
8
+ */
9
+
10
+ import { describe, test, expect } from "vitest";
11
+ import {
12
+ ExecCredentialNotAllowedError,
13
+ K8sApiError,
14
+ K8sClientUnavailableError,
15
+ K8sTransportError,
16
+ KubeConfigError,
17
+ UnknownResourceError,
18
+ } from "@intentius/chant-k8s-client";
19
+ import { classifyApiFailure, isMissingClientPackage, isWholeLexiconFailure, MISSING_CLIENT_DETAIL } from "./classify";
20
+
21
+ const status = (code: number, reason: string) => new K8sApiError(code, reason, "message", "apps/v1 Deployment prod/web");
22
+
23
+ describe("classifyApiFailure", () => {
24
+ test("a NotFound is an absence — the only shape that may become a create", () => {
25
+ expect(classifyApiFailure(status(404, "NotFound"))).toEqual({ kind: "absent" });
26
+ });
27
+
28
+ test("a kind the cluster does not serve is an absence too — no instance of it can exist", () => {
29
+ // The rule core already had for `the server doesn't have a resource type`,
30
+ // and the reason it had it: the usual cause is a CRD this very plan has not
31
+ // applied yet, and calling it a hole would suppress the needed create.
32
+ expect(classifyApiFailure(new UnknownResourceError("widgets.example.com/v1 Widget"))).toEqual({ kind: "absent" });
33
+ });
34
+
35
+ test.each([
36
+ [401, "Unauthorized"],
37
+ [403, "Forbidden"],
38
+ ])("HTTP %i proves nothing about existence → no-credentials", (code, reason) => {
39
+ expect(classifyApiFailure(status(code, reason))).toMatchObject({ kind: "unobserved", reason: "no-credentials" });
40
+ });
41
+
42
+ test.each([
43
+ [409, "Conflict"],
44
+ [429, "TooManyRequests"],
45
+ [500, "InternalError"],
46
+ [503, "ServiceUnavailable"],
47
+ ])("HTTP %i → read-failed", (code, reason) => {
48
+ expect(classifyApiFailure(status(code, reason))).toMatchObject({ kind: "unobserved", reason: "read-failed" });
49
+ });
50
+
51
+ test("a transport failure is no-binding — the same verdict kubectl's 'unable to connect' produced", () => {
52
+ const err = new K8sTransportError("connect ECONNREFUSED 127.0.0.1:6443", "apps/v1 Deployment prod/web");
53
+ expect(classifyApiFailure(err)).toMatchObject({ kind: "unobserved", reason: "no-binding" });
54
+ });
55
+
56
+ test("an unusable kubeconfig is no-binding", () => {
57
+ expect(classifyApiFailure(new KubeConfigError("no context named prod-eks"))).toMatchObject({
58
+ kind: "unobserved",
59
+ reason: "no-binding",
60
+ });
61
+ });
62
+
63
+ test("a refused credential plugin is no-credentials", () => {
64
+ expect(classifyApiFailure(new ExecCredentialNotAllowedError("harvest", ["aws"]))).toMatchObject({
65
+ kind: "unobserved",
66
+ reason: "no-credentials",
67
+ });
68
+ });
69
+
70
+ test("a missing client package is read-failed, never an absence", () => {
71
+ expect(classifyApiFailure(new K8sClientUnavailableError())).toMatchObject({
72
+ kind: "unobserved",
73
+ reason: "read-failed",
74
+ });
75
+ });
76
+
77
+ test("anything unrecognized is read-failed rather than assumed absent", () => {
78
+ expect(classifyApiFailure(new Error("something else entirely"))).toMatchObject({
79
+ kind: "unobserved",
80
+ reason: "read-failed",
81
+ });
82
+ expect(classifyApiFailure("a string")).toMatchObject({ kind: "unobserved", reason: "read-failed" });
83
+ expect(classifyApiFailure(undefined)).toMatchObject({ kind: "unobserved", reason: "read-failed" });
84
+ });
85
+
86
+ test("the detail is one line, capped", () => {
87
+ const long = new K8sApiError(500, "InternalError", "x".repeat(500));
88
+ const outcome = classifyApiFailure(long);
89
+ expect(outcome.kind).toBe("unobserved");
90
+ if (outcome.kind === "unobserved") {
91
+ expect(outcome.detail.length).toBeLessThanOrEqual(200);
92
+ expect(outcome.detail).not.toContain("\n");
93
+ }
94
+ });
95
+
96
+ test("classification survives a duplicated copy of the client package", () => {
97
+ // Discrimination is by `name`, not `instanceof`, so an error from a second
98
+ // physical copy of the package in a consumer's tree still classifies.
99
+ const foreign = Object.assign(new Error("not found"), { name: "K8sApiError", statusCode: 404, reason: "NotFound" });
100
+ expect(classifyApiFailure(foreign)).toEqual({ kind: "absent" });
101
+ });
102
+ });
103
+
104
+ describe("whole-lexicon failures", () => {
105
+ test.each([
106
+ [new KubeConfigError("no cluster"), true],
107
+ [new ExecCredentialNotAllowedError("harvest", ["aws"]), true],
108
+ [new K8sClientUnavailableError(), true],
109
+ [status(404, "NotFound"), false],
110
+ [new K8sTransportError("refused"), false],
111
+ ])("%s", (err, expected) => {
112
+ expect(isWholeLexiconFailure(err)).toBe(expected);
113
+ });
114
+ });
115
+
116
+ describe("missing client package detection", () => {
117
+ test("a module-resolution failure naming either package is recognized", () => {
118
+ for (const spec of ["@intentius/chant-k8s-client", "@kubernetes/client-node"]) {
119
+ const err = Object.assign(new Error(`Cannot find package '${spec}'`), { code: "ERR_MODULE_NOT_FOUND" });
120
+ expect(isMissingClientPackage(err)).toBe(true);
121
+ }
122
+ });
123
+
124
+ test("an unrelated module error is not", () => {
125
+ const err = Object.assign(new Error("Cannot find package 'left-pad'"), { code: "ERR_MODULE_NOT_FOUND" });
126
+ expect(isMissingClientPackage(err)).toBe(false);
127
+ expect(isMissingClientPackage(new Error("nope"))).toBe(false);
128
+ });
129
+
130
+ test("the detail names the install command", () => {
131
+ expect(MISSING_CLIENT_DETAIL).toContain("npm i @intentius/chant-k8s-client");
132
+ });
133
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Typed API failure → the observation tri-state (chant #1089, #1074).
3
+ *
4
+ * `classifyKubectlFailure` in core does this by matching English on stderr,
5
+ * because that was all `kubectl` gave it. The API server has always sent a
6
+ * `Status` object with a numeric code and a `reason` enum; the typed client
7
+ * carries it through, so classification here reads fields rather than
8
+ * searching for substrings. The verdicts are deliberately the same ones the
9
+ * kubectl path produced — this replaces the evidence, not the contract.
10
+ *
11
+ * The one that reads as a surprise, and is not: **a kind the cluster does not
12
+ * serve is an absence, not a hole.** No instance of an unserved kind can exist
13
+ * there, and the usual cause is a CRD the very plan being computed has not
14
+ * applied yet. Calling it NOT-OBSERVED would suppress the create that is
15
+ * genuinely needed. That is core's existing rule (`classifyKubectlFailure`
16
+ * treats "the server doesn't have a resource type" as absent) and it survives
17
+ * the move intact.
18
+ *
19
+ * Errors are discriminated by `name`, not `instanceof`. The client is an
20
+ * optional dependency reached through a dynamic import, so this module must
21
+ * not carry a static value import of it — and `name` is set explicitly by
22
+ * every one of its error classes, which also makes the classification survive
23
+ * a duplicated copy of the package in a consumer's tree.
24
+ */
25
+
26
+ import type { UnobservedReason } from "@intentius/chant/lexicon";
27
+
28
+ /** What a failed read actually proved. Mirrors core's `KubectlReadOutcome`. */
29
+ export type K8sReadOutcome =
30
+ /** The API server answered and the object is not there. Safe to plan a create. */
31
+ | { kind: "absent" }
32
+ /** The read proved nothing about the object's existence. */
33
+ | { kind: "unobserved"; reason: UnobservedReason; detail: string };
34
+
35
+ /** The client's error names, as its classes stamp them. */
36
+ const NAMES = {
37
+ api: "K8sApiError",
38
+ transport: "K8sTransportError",
39
+ unavailable: "K8sClientUnavailableError",
40
+ execRefused: "ExecCredentialNotAllowedError",
41
+ kubeconfig: "KubeConfigError",
42
+ unknownResource: "UnknownResourceError",
43
+ } as const;
44
+
45
+ interface ErrorLike {
46
+ name?: string;
47
+ message?: string;
48
+ statusCode?: number;
49
+ reason?: string;
50
+ }
51
+
52
+ function shapeOf(err: unknown): ErrorLike {
53
+ return (typeof err === "object" && err !== null ? err : {}) as ErrorLike;
54
+ }
55
+
56
+ /** Classify a failure from the typed client into the observation tri-state. */
57
+ export function classifyApiFailure(err: unknown): K8sReadOutcome {
58
+ const e = shapeOf(err);
59
+
60
+ switch (e.name) {
61
+ // The cluster's discovery does not serve this kind, so no instance of it
62
+ // exists here. A real absence — see the module comment.
63
+ case NAMES.unknownResource:
64
+ return { kind: "absent" };
65
+
66
+ case NAMES.api: {
67
+ const code = e.statusCode;
68
+ if (code === 404 || e.reason === "NotFound") return { kind: "absent" };
69
+ if (code === 401 || code === 403 || e.reason === "Unauthorized" || e.reason === "Forbidden") {
70
+ return { kind: "unobserved", reason: "no-credentials", detail: detailOf(err) };
71
+ }
72
+ return { kind: "unobserved", reason: "read-failed", detail: detailOf(err) };
73
+ }
74
+
75
+ // Never reached an API server: DNS, TCP, TLS, proxy, abort. Says nothing
76
+ // about what is running there. Same verdict the kubectl path gave
77
+ // "unable to connect to the server".
78
+ case NAMES.transport:
79
+ return { kind: "unobserved", reason: "no-binding", detail: detailOf(err) };
80
+
81
+ // No usable kubeconfig / cluster / context for this environment.
82
+ case NAMES.kubeconfig:
83
+ return { kind: "unobserved", reason: "no-binding", detail: detailOf(err) };
84
+
85
+ // The credential path itself was refused, so nothing was ever authorized.
86
+ case NAMES.execRefused:
87
+ return { kind: "unobserved", reason: "no-credentials", detail: detailOf(err) };
88
+
89
+ // The client package is not installed — chant could not look, which is a
90
+ // hole and emphatically not an empty cluster.
91
+ case NAMES.unavailable:
92
+ return { kind: "unobserved", reason: "read-failed", detail: detailOf(err) };
93
+
94
+ default:
95
+ return { kind: "unobserved", reason: "read-failed", detail: detailOf(err) };
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The cluster's own discovery reports no such kind at all — distinct from a
101
+ * kind that exists but currently has zero live instances. `classifyApiFailure`
102
+ * folds both into `absent` for the declared-entity read path, where "no
103
+ * instance can exist" is exactly the signal a `create` needs. `chant kube get`
104
+ * (chant #1079) needs the finer distinction: an ad hoc `chant kube get
105
+ * widgets` against a kind the cluster has never heard of should say so, not
106
+ * print "No resources found." as if `widgets` were a real, empty kind.
107
+ */
108
+ export function isUnknownResource(err: unknown): boolean {
109
+ return shapeOf(err).name === NAMES.unknownResource;
110
+ }
111
+
112
+ /**
113
+ * Whether a failure kills the whole observation rather than one entity. A
114
+ * refused binding, a missing client package and a rejected credential plugin
115
+ * are all true of every entity, so they propagate and core marks the lot
116
+ * NOT-OBSERVED with one reason instead of repeating the same failure N times.
117
+ */
118
+ export function isWholeLexiconFailure(err: unknown): boolean {
119
+ const name = shapeOf(err).name;
120
+ return name === NAMES.kubeconfig || name === NAMES.execRefused || name === NAMES.unavailable;
121
+ }
122
+
123
+ /**
124
+ * A dynamic import of the optional client package that failed to resolve looks
125
+ * like an ordinary module error. Recognizing it lets `describeResources` report
126
+ * a missing dependency as a missing dependency rather than as a broken cluster.
127
+ */
128
+ export function isMissingClientPackage(err: unknown): boolean {
129
+ const e = shapeOf(err) as ErrorLike & { code?: string };
130
+ if (e.name === NAMES.unavailable) return true;
131
+ if (e.code !== "ERR_MODULE_NOT_FOUND" && e.code !== "MODULE_NOT_FOUND") return false;
132
+ return (e.message ?? "").includes("@intentius/chant-k8s-client") || (e.message ?? "").includes("@kubernetes/client-node");
133
+ }
134
+
135
+ /** The message a missing client package should produce. */
136
+ export const MISSING_CLIENT_DETAIL =
137
+ "the Kubernetes API client is not installed — run `npm i @intentius/chant-k8s-client` " +
138
+ "(it is an optional dependency of @intentius/chant-lexicon-k8s, so `--omit=optional` installs skip it)";
139
+
140
+ function detailOf(err: unknown): string {
141
+ const text = err instanceof Error ? err.message : String(err);
142
+ const line = text.split("\n").find((l) => l.trim().length > 0)?.trim() ?? text.trim();
143
+ return line.length > 200 ? `${line.slice(0, 197)}...` : line;
144
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Getting from a chant environment to a live API client — chant #1074.
3
+ *
4
+ * The cluster binding (chant #1100, landed in #1155) is unchanged by the move
5
+ * off `kubectl`, and deliberately so: `resolveClusterTarget` in core stays the
6
+ * single resolver, shared with the GCP lexicon's Config Connector observation,
7
+ * and the refusal it throws on a bound-but-mismatched context still aborts
8
+ * before any resource is touched. What changes is only *how the ambient
9
+ * context is read*: the typed client parses the kubeconfig it is about to use
10
+ * rather than shelling `kubectl config current-context`, so a worker image with
11
+ * no `kubectl` in it can still check the binding. Same question, same three
12
+ * outcomes:
13
+ *
14
+ * - **Bound and ambient agrees** — the bound context is passed explicitly to
15
+ * the client, never left to the ambient default.
16
+ * - **Bound and ambient disagrees** — `ClusterBindingMismatchError`, thrown
17
+ * before the first request. Core turns the throw into NOT-OBSERVED for every
18
+ * declared entity (chant #1089), which is what stops a wrong-cluster read
19
+ * becoming a confident list of creates.
20
+ * - **Unbound** — the kubeconfig's own current-context, with the same visible
21
+ * warning naming the environment and the missing binding.
22
+ */
23
+
24
+ import { loadChantConfig } from "@intentius/chant/config";
25
+ import { resolveClusterTarget, type ResolvedClusterTarget } from "@intentius/chant/kubectl-context";
26
+ import type { K8sClient, K8sClientOptions } from "@intentius/chant-k8s-client";
27
+ import type { K8sChantConfig } from "../config";
28
+
29
+ /**
30
+ * Build a client for an environment. Injectable so tests drive the real client
31
+ * against a fake request layer, and so `describeResources` and the Op
32
+ * activities share one connection story rather than each inventing one.
33
+ */
34
+ export type K8sConnector = (options: ConnectOptions) => Promise<ConnectedClient>;
35
+
36
+ export interface ConnectOptions {
37
+ /**
38
+ * chant environment being observed or applied to. Omitting it skips the
39
+ * config lookup and the binding check — which is what an Op activity called
40
+ * with no environment and no explicit context has always done: use whatever
41
+ * the kubeconfig selects.
42
+ */
43
+ environment?: string;
44
+ /** Directory whose `chant.config.ts` carries `k8s.profiles`. Defaults to cwd. */
45
+ cwd?: string;
46
+ /**
47
+ * An explicit context, for the Op write path whose activity contract has
48
+ * always taken one. Skips the config lookup and the binding check entirely.
49
+ */
50
+ context?: string;
51
+ /** Extra client options — the request-layer seam, concurrency. */
52
+ client?: Partial<K8sClientOptions>;
53
+ }
54
+
55
+ export interface ConnectedClient {
56
+ client: K8sClient;
57
+ target: ResolvedClusterTarget;
58
+ }
59
+
60
+ /**
61
+ * The production connector: read the project's config, resolve the binding,
62
+ * build a client honoring it.
63
+ */
64
+ export const defaultK8sConnector: K8sConnector = async (options) => {
65
+ const { createK8sClient, readAmbientContext } = await import("@intentius/chant-k8s-client");
66
+
67
+ if (options.context !== undefined) {
68
+ const client = await createK8sClient({
69
+ context: options.context,
70
+ contextSource: "bound",
71
+ ...options.client,
72
+ });
73
+ return { client, target: { context: options.context, source: "bound" } };
74
+ }
75
+
76
+ if (options.environment === undefined) {
77
+ const client = await createK8sClient({ contextSource: "ambient", ...options.client });
78
+ return { client, target: { source: "ambient" } };
79
+ }
80
+
81
+ const { config } = await loadChantConfig(options.cwd ?? process.cwd());
82
+ const k8sConfig = (config as { k8s?: K8sChantConfig }).k8s;
83
+ const execAllowlist = k8sConfig?.execCredentialPlugins;
84
+
85
+ // The binding check reads the kubeconfig's current-context directly. Only a
86
+ // context name is needed, and requiring a *usable* ambient cluster to check a
87
+ // binding would defeat the binding: pointing an environment at a specific
88
+ // context is how you recover from an ambient one that is wrong or broken.
89
+ const target = await resolveClusterTarget(config as Record<string, unknown>, options.environment, "k8s", {
90
+ ambientContext: () =>
91
+ readAmbientContext({
92
+ kubeconfig: options.client?.kubeconfig,
93
+ kubeconfigPath: options.client?.kubeconfigPath,
94
+ }),
95
+ });
96
+
97
+ const client = await createK8sClient({
98
+ ...options.client,
99
+ ...(target.context ? { context: target.context } : {}),
100
+ contextSource: target.source,
101
+ ...(execAllowlist ? { execAllowlist } : {}),
102
+ });
103
+ return { client, target };
104
+ };