@intentius/chant-lexicon-k8s 0.29.0 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/classify.d.ts +55 -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/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/describe-resources.d.ts +41 -24
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/export-resources.d.ts +27 -1
- package/dist/export-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.d.ts +2 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts +38 -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/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 +131 -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/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/describe-resources.test.ts +396 -191
- package/src/describe-resources.ts +134 -118
- package/src/export-resources-io.test.ts +76 -51
- package/src/export-resources.ts +63 -35
- package/src/generated/operations.json +2156 -0
- package/src/lifecycle-integration.test.ts +132 -92
- package/src/op/activities/index.ts +2 -1
- package/src/op/activities/kubectl.test.ts +148 -0
- package/src/op/activities/kubectl.ts +86 -13
- package/src/op/activities/wait-for-ready.test.ts +94 -0
- package/src/op/activities/wait-for-ready.ts +66 -15
- package/src/spec/parse.ts +93 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type K8sConnector } from "../../api/connect.js";
|
|
1
2
|
/**
|
|
2
3
|
* waitForReady — block until any operator-backed Kubernetes resource reports
|
|
3
4
|
* ready, driven by a data-only **readiness spec** rather than per-CRD code.
|
|
@@ -5,9 +6,16 @@
|
|
|
5
6
|
* Like `waitForArgoSync`, this activity is intentionally **dependency-light**:
|
|
6
7
|
* its signature is primitives + a plain readiness spec, so a Temporal worker
|
|
7
8
|
* loads it without importing the generated CRD declarable surface. It reads the
|
|
8
|
-
* resource
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* resource and evaluates the spec's predicates. It generalizes the bespoke
|
|
10
|
+
* `waitForArgoSync` / `waitForStack` waits — see #365.
|
|
11
|
+
*
|
|
12
|
+
* chant #1074 moved the read from `kubectl get -o json` to the typed API
|
|
13
|
+
* client, so a worker image needs no `kubectl` binary. The signature is
|
|
14
|
+
* unchanged — `kind` is still whatever `kubectl get` accepts, because that is
|
|
15
|
+
* what every existing caller passes, and the client resolves it through the
|
|
16
|
+
* cluster's own API discovery exactly as kubectl does: plural, then singular,
|
|
17
|
+
* then kind, then short name, with anything after the first dot read as the
|
|
18
|
+
* API group.
|
|
11
19
|
*/
|
|
12
20
|
/** Match a Kubernetes-style `status.conditions[]` entry by `type`. */
|
|
13
21
|
export interface ConditionMatch {
|
|
@@ -72,6 +80,11 @@ export interface WaitForReadyArgs {
|
|
|
72
80
|
* `.context` through.
|
|
73
81
|
*/
|
|
74
82
|
context?: string;
|
|
83
|
+
/**
|
|
84
|
+
* chant environment, used to resolve `k8s.profiles.<env>.context` when no
|
|
85
|
+
* explicit `context` is given. Optional and additive.
|
|
86
|
+
*/
|
|
87
|
+
environment?: string;
|
|
75
88
|
/** API group, used to pick a readiness override when `spec` is not given. */
|
|
76
89
|
group?: string;
|
|
77
90
|
/** Explicit readiness spec — wins over the registry/default. */
|
|
@@ -81,6 +94,20 @@ export interface WaitForReadyArgs {
|
|
|
81
94
|
}
|
|
82
95
|
/** Pluggable resource reader — overridden in tests with a fake. */
|
|
83
96
|
export type ResourceFetcher = (args: WaitForReadyArgs, signal?: AbortSignal) => Promise<Record<string, unknown>>;
|
|
97
|
+
/**
|
|
98
|
+
* Read the resource through the typed API client.
|
|
99
|
+
*
|
|
100
|
+
* A client is built once per `waitForReady` call and reused for every poll, so
|
|
101
|
+
* a 20-minute wait does not re-parse the kubeconfig or re-invoke an exec
|
|
102
|
+
* credential plugin on each iteration — and neither does it re-run discovery,
|
|
103
|
+
* which the client caches per API version.
|
|
104
|
+
*/
|
|
105
|
+
export declare function apiResourceFetcher(connect?: K8sConnector): ResourceFetcher;
|
|
106
|
+
/**
|
|
107
|
+
* The production reader. Each call builds its own fetcher, so nothing is
|
|
108
|
+
* shared between two unrelated waits; a single `waitForReady` passes one
|
|
109
|
+
* fetcher through all of its polls, which is where the caching matters.
|
|
110
|
+
*/
|
|
84
111
|
export declare const defaultResourceFetcher: ResourceFetcher;
|
|
85
112
|
/**
|
|
86
113
|
* Poll until the resource satisfies its readiness spec. Throws
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"wait-for-ready.d.ts","sourceRoot":"","sources":["../../../src/op/activities/wait-for-ready.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"wait-for-ready.d.ts","sourceRoot":"","sources":["../../../src/op/activities/wait-for-ready.ts"],"names":[],"mappings":"AACA,OAAO,EAAuB,KAAK,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAE3E;;;;;;;;;;;;;;;;;GAiBG;AAIH,sEAAsE;AACtE,MAAM,WAAW,cAAc;IAC7B,aAAa,EAAE,MAAM,CAAC;IACtB,4DAA4D;IAC5D,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,2DAA2D;AAC3D,MAAM,WAAW,SAAS;IACxB,mCAAmC;IACnC,IAAI,EAAE,MAAM,CAAC;IACb,iDAAiD;IACjD,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;IACnC,4CAA4C;IAC5C,KAAK,CAAC,EAAE,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,CAAC;CAEhC;AAED,MAAM,MAAM,cAAc,GAAG,cAAc,GAAG,SAAS,CAAC;AAExD,MAAM,WAAW,aAAa;IAC5B,kDAAkD;IAClD,KAAK,EAAE,cAAc,EAAE,CAAC;IACxB,oEAAoE;IACpE,QAAQ,CAAC,EAAE,cAAc,EAAE,CAAC;IAC5B;;;OAGG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B;AAED;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,EAAE,aAG/B,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,EAAE,MAAM,CAAC,MAAM,EAAE,aAAa,CAS7D,CAAC;AAEF,kFAAkF;AAClF,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,IAAI,EAAE,MAAM,GAAG,aAAa,CAEnF;AAwCD,qFAAqF;AACrF,wBAAgB,OAAO,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,aAAa,GAAG,OAAO,CAGlE;AAED,gFAAgF;AAChF,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,aAAa,GAAG,cAAc,GAAG,SAAS,CAE3F;AAID,6EAA6E;AAC7E,qBAAa,oBAAqB,SAAQ,KAAK;gBACjC,OAAO,EAAE,MAAM;CAI5B;AAED,MAAM,WAAW,gBAAgB;IAC/B,gGAAgG;IAChG,IAAI,EAAE,MAAM,CAAC;IACb,qBAAqB;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,2CAA2C;IAC3C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,gEAAgE;IAChE,IAAI,CAAC,EAAE,aAAa,CAAC;IACrB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,mEAAmE;AACnE,MAAM,MAAM,eAAe,GAAG,CAC5B,IAAI,EAAE,gBAAgB,EACtB,MAAM,CAAC,EAAE,WAAW,KACjB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;AAEtC;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,GAAE,YAAkC,GAAG,eAAe,CAmC/F;AAED;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,EAAE,eAAsE,CAAC;AAE5G;;;;;;;GAOG;AACH,wBAAsB,YAAY,CAChC,IAAI,EAAE,gBAAgB,EACtB,MAAM,CAAC,EAAE,WAAW,EACpB,OAAO,GAAE,eAAsC,GAC9C,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAuBlC"}
|
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.31.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.31.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.31.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,131 @@
|
|
|
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
|
+
* Whether a failure kills the whole observation rather than one entity. A
|
|
101
|
+
* refused binding, a missing client package and a rejected credential plugin
|
|
102
|
+
* are all true of every entity, so they propagate and core marks the lot
|
|
103
|
+
* NOT-OBSERVED with one reason instead of repeating the same failure N times.
|
|
104
|
+
*/
|
|
105
|
+
export function isWholeLexiconFailure(err: unknown): boolean {
|
|
106
|
+
const name = shapeOf(err).name;
|
|
107
|
+
return name === NAMES.kubeconfig || name === NAMES.execRefused || name === NAMES.unavailable;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A dynamic import of the optional client package that failed to resolve looks
|
|
112
|
+
* like an ordinary module error. Recognizing it lets `describeResources` report
|
|
113
|
+
* a missing dependency as a missing dependency rather than as a broken cluster.
|
|
114
|
+
*/
|
|
115
|
+
export function isMissingClientPackage(err: unknown): boolean {
|
|
116
|
+
const e = shapeOf(err) as ErrorLike & { code?: string };
|
|
117
|
+
if (e.name === NAMES.unavailable) return true;
|
|
118
|
+
if (e.code !== "ERR_MODULE_NOT_FOUND" && e.code !== "MODULE_NOT_FOUND") return false;
|
|
119
|
+
return (e.message ?? "").includes("@intentius/chant-k8s-client") || (e.message ?? "").includes("@kubernetes/client-node");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The message a missing client package should produce. */
|
|
123
|
+
export const MISSING_CLIENT_DETAIL =
|
|
124
|
+
"the Kubernetes API client is not installed — run `npm i @intentius/chant-k8s-client` " +
|
|
125
|
+
"(it is an optional dependency of @intentius/chant-lexicon-k8s, so `--omit=optional` installs skip it)";
|
|
126
|
+
|
|
127
|
+
function detailOf(err: unknown): string {
|
|
128
|
+
const text = err instanceof Error ? err.message : String(err);
|
|
129
|
+
const line = text.split("\n").find((l) => l.trim().length > 0)?.trim() ?? text.trim();
|
|
130
|
+
return line.length > 200 ? `${line.slice(0, 197)}...` : line;
|
|
131
|
+
}
|
|
@@ -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
|
+
};
|