@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.
- package/dist/api/classify.d.ts +65 -0
- package/dist/api/classify.d.ts.map +1 -0
- package/dist/api/connect.d.ts +58 -0
- package/dist/api/connect.d.ts.map +1 -0
- package/dist/api/fake-cluster.d.ts +55 -0
- package/dist/api/fake-cluster.d.ts.map +1 -0
- package/dist/api/operation-surface.d.ts +64 -0
- package/dist/api/operation-surface.d.ts.map +1 -0
- package/dist/api/owner-chain.d.ts +46 -0
- package/dist/api/owner-chain.d.ts.map +1 -0
- package/dist/api/sweep-types.d.ts +17 -0
- package/dist/api/sweep-types.d.ts.map +1 -0
- package/dist/codegen/generate-operations.d.ts +29 -0
- package/dist/codegen/generate-operations.d.ts.map +1 -0
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/crd/parser.d.ts.map +1 -1
- package/dist/crd/types.d.ts +7 -0
- package/dist/crd/types.d.ts.map +1 -1
- package/dist/deep-observe-hooks.d.ts +86 -0
- package/dist/deep-observe-hooks.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +146 -0
- package/dist/deep-observe.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +58 -26
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/export-resources.d.ts +31 -1
- package/dist/export-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/kube/apply.d.ts +26 -0
- package/dist/kube/apply.d.ts.map +1 -0
- package/dist/kube/connect.d.ts +30 -0
- package/dist/kube/connect.d.ts.map +1 -0
- package/dist/kube/delete.d.ts +27 -0
- package/dist/kube/delete.d.ts.map +1 -0
- package/dist/kube/describe.d.ts +19 -0
- package/dist/kube/describe.d.ts.map +1 -0
- package/dist/kube/events.d.ts +34 -0
- package/dist/kube/events.d.ts.map +1 -0
- package/dist/kube/flags.d.ts +40 -0
- package/dist/kube/flags.d.ts.map +1 -0
- package/dist/kube/get.d.ts +21 -0
- package/dist/kube/get.d.ts.map +1 -0
- package/dist/kube/group.d.ts +19 -0
- package/dist/kube/group.d.ts.map +1 -0
- package/dist/kube/logs.d.ts +16 -0
- package/dist/kube/logs.d.ts.map +1 -0
- package/dist/kube/project.d.ts +59 -0
- package/dist/kube/project.d.ts.map +1 -0
- package/dist/kube/render.d.ts +75 -0
- package/dist/kube/render.d.ts.map +1 -0
- package/dist/kube/source.d.ts +19 -0
- package/dist/kube/source.d.ts.map +1 -0
- package/dist/kube/target.d.ts +52 -0
- package/dist/kube/target.d.ts.map +1 -0
- package/dist/kube/testing.d.ts +21 -0
- package/dist/kube/testing.d.ts.map +1 -0
- package/dist/kube/top.d.ts +23 -0
- package/dist/kube/top.d.ts.map +1 -0
- package/dist/kube/verdict.d.ts +45 -0
- package/dist/kube/verdict.d.ts.map +1 -0
- package/dist/kube/wait.d.ts +23 -0
- package/dist/kube/wait.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.d.ts +7 -4
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts +120 -2
- package/dist/op/activities/kubectl.d.ts.map +1 -1
- package/dist/op/activities/wait-for-ready.d.ts +30 -3
- package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/spec/parse.d.ts +42 -0
- package/dist/spec/parse.d.ts.map +1 -1
- package/package.json +5 -2
- package/src/api/classify.test.ts +133 -0
- package/src/api/classify.ts +144 -0
- package/src/api/connect.ts +104 -0
- package/src/api/fake-cluster.ts +218 -0
- package/src/api/operation-surface.test.ts +116 -0
- package/src/api/operation-surface.ts +129 -0
- package/src/api/owner-chain.test.ts +171 -0
- package/src/api/owner-chain.ts +144 -0
- package/src/api/sweep-types.ts +36 -0
- package/src/codegen/generate-operations.ts +56 -0
- package/src/codegen/generate.ts +9 -0
- package/src/config.ts +17 -0
- package/src/crd/parser.ts +8 -0
- package/src/crd/types.ts +7 -0
- package/src/deep-observe-hooks.ts +125 -0
- package/src/deep-observe.test.ts +668 -0
- package/src/deep-observe.ts +299 -0
- package/src/describe-resources.test.ts +619 -191
- package/src/describe-resources.ts +218 -117
- package/src/export-resources-io.test.ts +76 -51
- package/src/export-resources.ts +47 -35
- package/src/generated/operations.json +2156 -0
- package/src/kube/apply.test.ts +105 -0
- package/src/kube/apply.ts +101 -0
- package/src/kube/connect.ts +54 -0
- package/src/kube/delete.test.ts +123 -0
- package/src/kube/delete.ts +174 -0
- package/src/kube/describe.test.ts +106 -0
- package/src/kube/describe.ts +195 -0
- package/src/kube/events.test.ts +115 -0
- package/src/kube/events.ts +160 -0
- package/src/kube/flags.test.ts +65 -0
- package/src/kube/flags.ts +114 -0
- package/src/kube/get.test.ts +195 -0
- package/src/kube/get.ts +190 -0
- package/src/kube/group.test.ts +33 -0
- package/src/kube/group.ts +125 -0
- package/src/kube/logs.test.ts +126 -0
- package/src/kube/logs.ts +96 -0
- package/src/kube/project.test.ts +57 -0
- package/src/kube/project.ts +106 -0
- package/src/kube/render.test.ts +142 -0
- package/src/kube/render.ts +252 -0
- package/src/kube/source.test.ts +138 -0
- package/src/kube/source.ts +139 -0
- package/src/kube/target.test.ts +67 -0
- package/src/kube/target.ts +89 -0
- package/src/kube/testing.ts +42 -0
- package/src/kube/top.test.ts +149 -0
- package/src/kube/top.ts +120 -0
- package/src/kube/verdict.test.ts +48 -0
- package/src/kube/verdict.ts +77 -0
- package/src/kube/wait.test.ts +125 -0
- package/src/kube/wait.ts +160 -0
- package/src/lifecycle-integration.test.ts +132 -92
- package/src/op/activities/index.ts +7 -3
- package/src/op/activities/kubectl.test.ts +420 -0
- package/src/op/activities/kubectl.ts +379 -13
- package/src/op/activities/wait-for-ready.test.ts +94 -0
- package/src/op/activities/wait-for-ready.ts +66 -15
- package/src/plugin.test.ts +58 -1
- package/src/plugin.ts +29 -0
- package/src/spec/parse.ts +93 -1
package/dist/spec/parse.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/spec/parse.d.ts.map
CHANGED
|
@@ -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;
|
|
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.
|
|
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.
|
|
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
|
+
};
|