@intentius/chant-lexicon-k8s 0.41.0 → 0.41.1
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/components/builders.d.ts +17 -0
- package/dist/components/builders.d.ts.map +1 -0
- package/dist/components/capability-plugin.d.ts +6 -0
- package/dist/components/capability-plugin.d.ts.map +1 -0
- package/dist/components/index.d.ts +16 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/kubectl-apply.d.ts +79 -0
- package/dist/components/kubectl-apply.d.ts.map +1 -0
- package/dist/describe-stack-status.d.ts +29 -0
- package/dist/describe-stack-status.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/serializer.d.ts.map +1 -1
- package/package.json +7 -2
- package/src/components/builders.ts +17 -0
- package/src/components/capability-plugin.ts +28 -0
- package/src/components/index.ts +21 -0
- package/src/components/kubectl-apply.test.ts +33 -0
- package/src/components/kubectl-apply.ts +104 -0
- package/src/describe-stack-status.test.ts +75 -0
- package/src/describe-stack-status.ts +100 -0
- package/src/index.ts +6 -0
- package/src/plugin.ts +12 -0
- package/src/serializer.test.ts +102 -0
- package/src/serializer.ts +72 -7
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed step-builders for the k8s lexicon's verbs — the same ergonomic sugar
|
|
3
|
+
* the aws lexicon's `components/builders.ts` offers (#658), reusing the
|
|
4
|
+
* exported `step` projection from `@intentius/chant/components`.
|
|
5
|
+
*
|
|
6
|
+
* `kubectl-apply` is a `needs-opt-out` capability (a server-side apply keeps
|
|
7
|
+
* no previous object state, so there is nothing native to roll back to), which
|
|
8
|
+
* means COMP003 requires every step of it to carry a `noRollback: "<reason>"`,
|
|
9
|
+
* a component-level `rollback` phase, or a sibling safety step. The builder
|
|
10
|
+
* admits `noRollback` directly so the common opt-out reads as one typed call
|
|
11
|
+
* instead of a raw object literal.
|
|
12
|
+
*/
|
|
13
|
+
import type { KubectlApplyInput } from "./kubectl-apply.js";
|
|
14
|
+
export declare const kubectlApply: (input: KubectlApplyInput & {
|
|
15
|
+
noRollback?: string;
|
|
16
|
+
}) => import("@intentius/chant/components").Step;
|
|
17
|
+
//# sourceMappingURL=builders.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"builders.d.ts","sourceRoot":"","sources":["../../src/components/builders.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEzD,eAAO,MAAM,YAAY;iBAA2C,MAAM;gDAAoB,CAAC"}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { CapabilityPlugin } from "@intentius/chant/components/capability-plugin";
|
|
2
|
+
export declare const K8S_VERB_FAMILIES: {
|
|
3
|
+
readonly apply: readonly ["kubectl-apply"];
|
|
4
|
+
};
|
|
5
|
+
export declare const k8sCapabilityPlugin: CapabilityPlugin;
|
|
6
|
+
//# sourceMappingURL=capability-plugin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"capability-plugin.d.ts","sourceRoot":"","sources":["../../src/components/capability-plugin.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,+CAA+C,CAAC;AAGtF,eAAO,MAAM,iBAAiB;;CAEpB,CAAC;AAEX,eAAO,MAAM,mBAAmB,EAAE,gBASjC,CAAC"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The k8s lexicon's component/release surface: the `kubectl-apply` capability,
|
|
3
|
+
* its typed step-builder, and the `k8sCapabilityPlugin` core loads when a
|
|
4
|
+
* project declares `lexicons: ["k8s"]` (#1495). Component authors import the
|
|
5
|
+
* builders from here (`@intentius/chant-lexicon-k8s/components`), the way AWS
|
|
6
|
+
* verbs come from `@intentius/chant-lexicon-aws/components`.
|
|
7
|
+
*
|
|
8
|
+
* Everything exported here is on the build path (#1074): nothing may
|
|
9
|
+
* statically import the Kubernetes API client chain (`../op/activities/*`,
|
|
10
|
+
* `../kube/*`) — `kubectl-apply.ts` reaches its applier by dynamic import
|
|
11
|
+
* inside `run()`, and this module must stay that clean.
|
|
12
|
+
*/
|
|
13
|
+
export { k8sCapabilityPlugin, K8S_VERB_FAMILIES } from "./capability-plugin.js";
|
|
14
|
+
export * from "./builders.js";
|
|
15
|
+
export { kubectlApplyCapability, createKubectlApplyCapability, type KubectlApplyInput, type KubectlApplyOutcome, } from "./kubectl-apply.js";
|
|
16
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAC7E,cAAc,YAAY,CAAC;AAC3B,OAAO,EACL,sBAAsB,EACtB,4BAA4B,EAC5B,KAAK,iBAAiB,EACtB,KAAK,mBAAmB,GACzB,MAAM,iBAAiB,CAAC"}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kubectl-apply` — the Kubernetes apply leaf for the component model
|
|
3
|
+
* (#1495 piece 2).
|
|
4
|
+
*
|
|
5
|
+
* The apply itself already exists: chant's server-side apply as the field
|
|
6
|
+
* manager `chant:<stack>` with the marker-scoped owned-only prune (#1075),
|
|
7
|
+
* reachable from Ops and from `ApplyOp({ target: "kubectl" })`. What was
|
|
8
|
+
* missing is a *capability*, so a component whose deploy is a Kubernetes
|
|
9
|
+
* manifest had nothing to compose but a `shell` step — and, downstream, no
|
|
10
|
+
* deploy unit for `chant components status --live` to observe (the walk asks
|
|
11
|
+
* the step's `stack`, and a shell step has none to name).
|
|
12
|
+
*
|
|
13
|
+
* `stack` doubles as both the ownership identity the apply stamps
|
|
14
|
+
* (`chant:<stack>`, the prune selector) and the deploy unit the status walk
|
|
15
|
+
* reads (core `components/deploy-units.ts`) — one name, both jobs, the same
|
|
16
|
+
* pairing `cfn-deploy` has with its stack.
|
|
17
|
+
*/
|
|
18
|
+
import type { Capability } from "@intentius/chant/components/capability";
|
|
19
|
+
/**
|
|
20
|
+
* Structural mirrors of the activity module's argument/result shapes.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately NOT imported from ../op/activities/kubectl — not even as
|
|
23
|
+
* `import type`: the #1074 boundary test walks type-only imports too, and a
|
|
24
|
+
* static reference here would put the API-client chain on the build path this
|
|
25
|
+
* module is loaded from (the lexicon entry point). The applier is reached by
|
|
26
|
+
* dynamic import inside run(), the same mechanism plugin.ts and the kube verb
|
|
27
|
+
* modules use; TypeScript checks these shapes against the real ones at the
|
|
28
|
+
* call site, so drift is a compile error there, not a silent mismatch.
|
|
29
|
+
*/
|
|
30
|
+
interface AppliedRef {
|
|
31
|
+
apiVersion: string;
|
|
32
|
+
kind: string;
|
|
33
|
+
name: string;
|
|
34
|
+
namespace?: string;
|
|
35
|
+
}
|
|
36
|
+
/** What the apply did — the activity's own result shape. */
|
|
37
|
+
export interface KubectlApplyOutcome {
|
|
38
|
+
/** The field manager every object was applied as. */
|
|
39
|
+
fieldManager: string;
|
|
40
|
+
applied: AppliedRef[];
|
|
41
|
+
/** Objects deleted because they carried chant's marker and are no longer declared. */
|
|
42
|
+
pruned: AppliedRef[];
|
|
43
|
+
}
|
|
44
|
+
interface ApplierArgs {
|
|
45
|
+
manifest: string;
|
|
46
|
+
environment?: string;
|
|
47
|
+
stack?: string;
|
|
48
|
+
context?: string;
|
|
49
|
+
deleteMode?: "never" | "owned-only" | "gated";
|
|
50
|
+
}
|
|
51
|
+
type Applier = (args: ApplierArgs) => Promise<KubectlApplyOutcome>;
|
|
52
|
+
export interface KubectlApplyInput {
|
|
53
|
+
/** Path to a manifest file, or a directory of them. */
|
|
54
|
+
manifest: string;
|
|
55
|
+
/**
|
|
56
|
+
* The deploy unit / ownership stack. Optional: omitted derives the field
|
|
57
|
+
* manager from the project's `ownership.stack` as the Op activity does —
|
|
58
|
+
* but then the unit is invisible to `components status --live`, which only
|
|
59
|
+
* reads a literal on the step. Name it.
|
|
60
|
+
*/
|
|
61
|
+
stack?: string;
|
|
62
|
+
/** kubectl context. Omitted resolves `k8s.profiles.<ctx.env>.context`. */
|
|
63
|
+
context?: string;
|
|
64
|
+
/**
|
|
65
|
+
* What happens to chant-owned objects no longer in the manifest — the same
|
|
66
|
+
* vocabulary the Op activity and `ApplyOp` use. Default `never`; the prune
|
|
67
|
+
* is always marker-scoped, so an object chant did not stamp is never a
|
|
68
|
+
* candidate.
|
|
69
|
+
*/
|
|
70
|
+
delete?: "never" | "owned-only";
|
|
71
|
+
}
|
|
72
|
+
/** Factory with an injectable applier, so tests assert the delegation without
|
|
73
|
+
* a cluster — the same seam `createGenerateSbomCapability` uses. The default
|
|
74
|
+
* applier is resolved by dynamic import on first run, keeping the API-client
|
|
75
|
+
* chain off the build path (#1074). */
|
|
76
|
+
export declare function createKubectlApplyCapability(apply?: Applier): Capability<KubectlApplyInput, KubectlApplyOutcome>;
|
|
77
|
+
export declare const kubectlApplyCapability: Capability<KubectlApplyInput, KubectlApplyOutcome>;
|
|
78
|
+
export {};
|
|
79
|
+
//# sourceMappingURL=kubectl-apply.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"kubectl-apply.d.ts","sourceRoot":"","sources":["../../src/components/kubectl-apply.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,KAAK,EAAE,UAAU,EAAiB,MAAM,wCAAwC,CAAC;AAExF;;;;;;;;;;GAUG;AACH,UAAU,UAAU;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,4DAA4D;AAC5D,MAAM,WAAW,mBAAmB;IAClC,qDAAqD;IACrD,YAAY,EAAE,MAAM,CAAC;IACrB,OAAO,EAAE,UAAU,EAAE,CAAC;IACtB,sFAAsF;IACtF,MAAM,EAAE,UAAU,EAAE,CAAC;CACtB;AAED,UAAU,WAAW;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,OAAO,GAAG,YAAY,GAAG,OAAO,CAAC;CAC/C;AAED,KAAK,OAAO,GAAG,CAAC,IAAI,EAAE,WAAW,KAAK,OAAO,CAAC,mBAAmB,CAAC,CAAC;AAEnE,MAAM,WAAW,iBAAiB;IAChC,uDAAuD;IACvD,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;;OAKG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,0EAA0E;IAC1E,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,OAAO,GAAG,YAAY,CAAC;CACjC;AAED;;;uCAGuC;AACvC,wBAAgB,4BAA4B,CAC1C,KAAK,CAAC,EAAE,OAAO,GACd,UAAU,CAAC,iBAAiB,EAAE,mBAAmB,CAAC,CAkBpD;AAED,eAAO,MAAM,sBAAsB,oDAAiC,CAAC"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy-unit status for Kubernetes (#1495 piece 3).
|
|
3
|
+
*
|
|
4
|
+
* A Kubernetes deploy unit *is* a label selector: every object chant applies
|
|
5
|
+
* carries its stack identity in the labels the serializer stamps
|
|
6
|
+
* (`app.kubernetes.io/managed-by=chant`, `chant.intentius.io/stack=<stack>` —
|
|
7
|
+
* see core's ownership marking). So "is this unit present" is one selector
|
|
8
|
+
* query, and "is it healthy" is whether every matching workload its controller
|
|
9
|
+
* reports on is Ready. No new identity concept — this reads back exactly what
|
|
10
|
+
* `kubectl-apply` (piece 2) and the serializer already write.
|
|
11
|
+
*
|
|
12
|
+
* The selector deliberately omits `chant.intentius.io/env`: `ownership.env` is
|
|
13
|
+
* a config identity stamped at synthesis, not necessarily the environment name
|
|
14
|
+
* a caller observes with, and a mismatch would read a deployed unit as absent
|
|
15
|
+
* — the failure mode the tri-state exists to prevent.
|
|
16
|
+
*
|
|
17
|
+
* The sweep is bounded to the kinds chant's own k8s surface deploys as
|
|
18
|
+
* stack-scoped objects (workloads + the service/config plumbing around them).
|
|
19
|
+
* A CRD-only unit is out of this bound and reports absent rather than null —
|
|
20
|
+
* a real limit, stated here rather than papered over; widening the sweep to
|
|
21
|
+
* discovery-driven kinds is a follow-up, not a silent claim.
|
|
22
|
+
*/
|
|
23
|
+
import type { StackStatusObservation } from "@intentius/chant/lexicon";
|
|
24
|
+
import { type K8sConnector } from "./api/connect.js";
|
|
25
|
+
export declare function describeStackStatus(options: {
|
|
26
|
+
environment: string;
|
|
27
|
+
stack: string;
|
|
28
|
+
}, connect?: K8sConnector): Promise<StackStatusObservation | null>;
|
|
29
|
+
//# sourceMappingURL=describe-stack-status.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"describe-stack-status.d.ts","sourceRoot":"","sources":["../src/describe-stack-status.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,0BAA0B,CAAC;AAGvE,OAAO,EAAuB,KAAK,YAAY,EAAE,MAAM,eAAe,CAAC;AAgCvE,wBAAsB,mBAAmB,CACvC,OAAO,EAAE;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,EAC/C,OAAO,GAAE,YAAkC,GAC1C,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CAuCxC"}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { k8sSerializer } from "./serializer.js";
|
|
2
2
|
export { k8sPlugin } from "./plugin.js";
|
|
3
|
+
export { k8sCapabilityPlugin, K8S_VERB_FAMILIES } from "./components/capability-plugin.js";
|
|
4
|
+
export { kubectlApplyCapability, createKubectlApplyCapability, type KubectlApplyInput } from "./components/kubectl-apply.js";
|
|
3
5
|
export { defaultLabels, defaultAnnotations, isDefaultLabels, isDefaultAnnotations } from "./default-labels.js";
|
|
4
6
|
export { DEFAULT_LABELS_MARKER, DEFAULT_ANNOTATIONS_MARKER } from "./default-labels.js";
|
|
5
7
|
export { K8sLabels, K8sAnnotations } from "./variables.js";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAC;AAG7C,OAAO,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "df06efa4031426355eb12f159a646012593f5bc9d4b4df424b453dde81aab0be",
|
|
5
5
|
"meta.json": "c0b141b882c51483c1d1aead748a0eb171aceca7432edd2a0404b062bf6d7831",
|
|
6
6
|
"types/index.d.ts": "3be2af7494aca94adfa16269cb9c4e7a155529872207a3d70b0c4d39914f9eec",
|
|
7
7
|
"rules/argo-appset-single-project.ts": "afa9f310753aa2d475f35012b13135b2dfaebed2a35d44edbe185ebc07673674",
|
|
@@ -50,5 +50,5 @@
|
|
|
50
50
|
"skills/chant-k8s-aks.md": "e18f0e2b055f72cd7a37deaf258d7027c2d4d3e286e8fd4975b27a1f981a3ad9",
|
|
51
51
|
"skills/chant-k8s-argo.md": "b1a0b826559d8c5033a479c5781efaf650320f0aee4419d8841170bd3393cea5"
|
|
52
52
|
},
|
|
53
|
-
"composite": "
|
|
53
|
+
"composite": "58171232438afe93a43c83a5685856c5992179242282773d1cc41bea86ee7c41"
|
|
54
54
|
}
|
package/dist/manifest.json
CHANGED
package/dist/plugin.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAqC,MAAM,0BAA0B,CAAC;AAwBjG,eAAO,MAAM,SAAS,EAAE,
|
|
1
|
+
{"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,aAAa,EAAqC,MAAM,0BAA0B,CAAC;AAwBjG,eAAO,MAAM,SAAS,EAAE,aAynBvB,CAAC"}
|
package/dist/serializer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;
|
|
1
|
+
{"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;AA6MlG;;GAEG;AACH,eAAO,MAAM,aAAa,EAAE,UA8I3B,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-k8s",
|
|
3
|
-
"version": "0.41.
|
|
3
|
+
"version": "0.41.1",
|
|
4
4
|
"description": "Kubernetes lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -44,6 +44,11 @@
|
|
|
44
44
|
"types": "./dist/detect.d.ts",
|
|
45
45
|
"default": "./src/detect.ts"
|
|
46
46
|
},
|
|
47
|
+
"./components": {
|
|
48
|
+
"development": "./src/components/index.ts",
|
|
49
|
+
"types": "./dist/components/index.d.ts",
|
|
50
|
+
"default": "./src/components/index.ts"
|
|
51
|
+
},
|
|
47
52
|
"./lint/post-synth": {
|
|
48
53
|
"development": "./src/lint/post-synth/index.ts",
|
|
49
54
|
"types": "./dist/lint/post-synth/index.d.ts",
|
|
@@ -79,7 +84,7 @@
|
|
|
79
84
|
},
|
|
80
85
|
"peerDependencies": {
|
|
81
86
|
"zod": "^4.3.6",
|
|
82
|
-
"@intentius/chant": "^0.41.
|
|
87
|
+
"@intentius/chant": "^0.41.1",
|
|
83
88
|
"typescript": "^5.9.3"
|
|
84
89
|
}
|
|
85
90
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed step-builders for the k8s lexicon's verbs — the same ergonomic sugar
|
|
3
|
+
* the aws lexicon's `components/builders.ts` offers (#658), reusing the
|
|
4
|
+
* exported `step` projection from `@intentius/chant/components`.
|
|
5
|
+
*
|
|
6
|
+
* `kubectl-apply` is a `needs-opt-out` capability (a server-side apply keeps
|
|
7
|
+
* no previous object state, so there is nothing native to roll back to), which
|
|
8
|
+
* means COMP003 requires every step of it to carry a `noRollback: "<reason>"`,
|
|
9
|
+
* a component-level `rollback` phase, or a sibling safety step. The builder
|
|
10
|
+
* admits `noRollback` directly so the common opt-out reads as one typed call
|
|
11
|
+
* instead of a raw object literal.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { step } from "@intentius/chant/components";
|
|
15
|
+
import type { KubectlApplyInput } from "./kubectl-apply";
|
|
16
|
+
|
|
17
|
+
export const kubectlApply = step<KubectlApplyInput & { noRollback?: string }>("kubectl-apply");
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `k8sCapabilityPlugin` — the k8s lexicon's capability plugin (#1495 piece 2).
|
|
3
|
+
*
|
|
4
|
+
* One leaf for now: `kubectl-apply`, the shared apply verb Kubernetes levels
|
|
5
|
+
* everything to (docs/components/cloud-boundary calls it the one portable
|
|
6
|
+
* apply family). Contributed through core's `CapabilityPlugin` contract the
|
|
7
|
+
* same way the aws lexicon contributes `cfn-deploy` — a project declaring
|
|
8
|
+
* `lexicons: ["k8s"]` gets the verb registered automatically when it runs
|
|
9
|
+
* components.
|
|
10
|
+
*/
|
|
11
|
+
import type { Capability } from "@intentius/chant/components/capability";
|
|
12
|
+
import type { CapabilityPlugin } from "@intentius/chant/components/capability-plugin";
|
|
13
|
+
import { kubectlApplyCapability } from "./kubectl-apply";
|
|
14
|
+
|
|
15
|
+
export const K8S_VERB_FAMILIES = {
|
|
16
|
+
apply: ["kubectl-apply"],
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
export const k8sCapabilityPlugin: CapabilityPlugin = {
|
|
20
|
+
name: "k8s",
|
|
21
|
+
version: "0.41.0",
|
|
22
|
+
capabilities(): Array<Capability<never, unknown>> {
|
|
23
|
+
return [kubectlApplyCapability as Capability<never, unknown>];
|
|
24
|
+
},
|
|
25
|
+
families(): Record<string, readonly string[]> {
|
|
26
|
+
return K8S_VERB_FAMILIES;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The k8s lexicon's component/release surface: the `kubectl-apply` capability,
|
|
3
|
+
* its typed step-builder, and the `k8sCapabilityPlugin` core loads when a
|
|
4
|
+
* project declares `lexicons: ["k8s"]` (#1495). Component authors import the
|
|
5
|
+
* builders from here (`@intentius/chant-lexicon-k8s/components`), the way AWS
|
|
6
|
+
* verbs come from `@intentius/chant-lexicon-aws/components`.
|
|
7
|
+
*
|
|
8
|
+
* Everything exported here is on the build path (#1074): nothing may
|
|
9
|
+
* statically import the Kubernetes API client chain (`../op/activities/*`,
|
|
10
|
+
* `../kube/*`) — `kubectl-apply.ts` reaches its applier by dynamic import
|
|
11
|
+
* inside `run()`, and this module must stay that clean.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export { k8sCapabilityPlugin, K8S_VERB_FAMILIES } from "./capability-plugin";
|
|
15
|
+
export * from "./builders";
|
|
16
|
+
export {
|
|
17
|
+
kubectlApplyCapability,
|
|
18
|
+
createKubectlApplyCapability,
|
|
19
|
+
type KubectlApplyInput,
|
|
20
|
+
type KubectlApplyOutcome,
|
|
21
|
+
} from "./kubectl-apply";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { createKubectlApplyCapability, k8sCapabilityPlugin } from "../index";
|
|
3
|
+
import { isCapabilityPlugin } from "@intentius/chant/components/capability-plugin";
|
|
4
|
+
|
|
5
|
+
describe("kubectl-apply capability (#1495 piece 2)", () => {
|
|
6
|
+
test("the plugin satisfies the CapabilityPlugin contract and registers the verb", () => {
|
|
7
|
+
expect(isCapabilityPlugin(k8sCapabilityPlugin)).toBe(true);
|
|
8
|
+
const kinds = k8sCapabilityPlugin.capabilities().map((c) => c.kind);
|
|
9
|
+
expect(kinds).toContain("kubectl-apply");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("run delegates to the server-side apply with the component's env and the step's stack", async () => {
|
|
13
|
+
let seen: unknown;
|
|
14
|
+
const cap = createKubectlApplyCapability(async (args) => {
|
|
15
|
+
seen = args;
|
|
16
|
+
return { applied: [], pruned: [], fieldManager: "chant:kubemicrovm-ops" };
|
|
17
|
+
});
|
|
18
|
+
await cap.run({ env: "dev", component: "workload" }, { manifest: "k8s.yaml", stack: "kubemicrovm-ops", delete: "owned-only" });
|
|
19
|
+
expect(seen).toEqual({ manifest: "k8s.yaml", environment: "dev", stack: "kubemicrovm-ops", deleteMode: "owned-only" });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("a mutating verb with no safe undo declares needs-opt-out for COMP003", () => {
|
|
23
|
+
expect(createKubectlApplyCapability().rollbackPolicy).toBe("needs-opt-out");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("the stack field is the deploy unit core's status walk reads (#1495 piece 1)", async () => {
|
|
27
|
+
const { deployUnits } = await import("@intentius/chant/components/deploy-units");
|
|
28
|
+
const units = deployUnits([
|
|
29
|
+
{ phase: "Apply", steps: [{ kind: "kubectl-apply", manifest: "k8s.yaml", stack: "kubemicrovm-ops" }] } as never,
|
|
30
|
+
]);
|
|
31
|
+
expect(units).toEqual([{ unit: "kubemicrovm-ops", lexicon: "k8s" }]);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `kubectl-apply` — the Kubernetes apply leaf for the component model
|
|
3
|
+
* (#1495 piece 2).
|
|
4
|
+
*
|
|
5
|
+
* The apply itself already exists: chant's server-side apply as the field
|
|
6
|
+
* manager `chant:<stack>` with the marker-scoped owned-only prune (#1075),
|
|
7
|
+
* reachable from Ops and from `ApplyOp({ target: "kubectl" })`. What was
|
|
8
|
+
* missing is a *capability*, so a component whose deploy is a Kubernetes
|
|
9
|
+
* manifest had nothing to compose but a `shell` step — and, downstream, no
|
|
10
|
+
* deploy unit for `chant components status --live` to observe (the walk asks
|
|
11
|
+
* the step's `stack`, and a shell step has none to name).
|
|
12
|
+
*
|
|
13
|
+
* `stack` doubles as both the ownership identity the apply stamps
|
|
14
|
+
* (`chant:<stack>`, the prune selector) and the deploy unit the status walk
|
|
15
|
+
* reads (core `components/deploy-units.ts`) — one name, both jobs, the same
|
|
16
|
+
* pairing `cfn-deploy` has with its stack.
|
|
17
|
+
*/
|
|
18
|
+
import type { Capability, DeployContext } from "@intentius/chant/components/capability";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Structural mirrors of the activity module's argument/result shapes.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately NOT imported from ../op/activities/kubectl — not even as
|
|
24
|
+
* `import type`: the #1074 boundary test walks type-only imports too, and a
|
|
25
|
+
* static reference here would put the API-client chain on the build path this
|
|
26
|
+
* module is loaded from (the lexicon entry point). The applier is reached by
|
|
27
|
+
* dynamic import inside run(), the same mechanism plugin.ts and the kube verb
|
|
28
|
+
* modules use; TypeScript checks these shapes against the real ones at the
|
|
29
|
+
* call site, so drift is a compile error there, not a silent mismatch.
|
|
30
|
+
*/
|
|
31
|
+
interface AppliedRef {
|
|
32
|
+
apiVersion: string;
|
|
33
|
+
kind: string;
|
|
34
|
+
name: string;
|
|
35
|
+
namespace?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** What the apply did — the activity's own result shape. */
|
|
39
|
+
export interface KubectlApplyOutcome {
|
|
40
|
+
/** The field manager every object was applied as. */
|
|
41
|
+
fieldManager: string;
|
|
42
|
+
applied: AppliedRef[];
|
|
43
|
+
/** Objects deleted because they carried chant's marker and are no longer declared. */
|
|
44
|
+
pruned: AppliedRef[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface ApplierArgs {
|
|
48
|
+
manifest: string;
|
|
49
|
+
environment?: string;
|
|
50
|
+
stack?: string;
|
|
51
|
+
context?: string;
|
|
52
|
+
deleteMode?: "never" | "owned-only" | "gated";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type Applier = (args: ApplierArgs) => Promise<KubectlApplyOutcome>;
|
|
56
|
+
|
|
57
|
+
export interface KubectlApplyInput {
|
|
58
|
+
/** Path to a manifest file, or a directory of them. */
|
|
59
|
+
manifest: string;
|
|
60
|
+
/**
|
|
61
|
+
* The deploy unit / ownership stack. Optional: omitted derives the field
|
|
62
|
+
* manager from the project's `ownership.stack` as the Op activity does —
|
|
63
|
+
* but then the unit is invisible to `components status --live`, which only
|
|
64
|
+
* reads a literal on the step. Name it.
|
|
65
|
+
*/
|
|
66
|
+
stack?: string;
|
|
67
|
+
/** kubectl context. Omitted resolves `k8s.profiles.<ctx.env>.context`. */
|
|
68
|
+
context?: string;
|
|
69
|
+
/**
|
|
70
|
+
* What happens to chant-owned objects no longer in the manifest — the same
|
|
71
|
+
* vocabulary the Op activity and `ApplyOp` use. Default `never`; the prune
|
|
72
|
+
* is always marker-scoped, so an object chant did not stamp is never a
|
|
73
|
+
* candidate.
|
|
74
|
+
*/
|
|
75
|
+
delete?: "never" | "owned-only";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Factory with an injectable applier, so tests assert the delegation without
|
|
79
|
+
* a cluster — the same seam `createGenerateSbomCapability` uses. The default
|
|
80
|
+
* applier is resolved by dynamic import on first run, keeping the API-client
|
|
81
|
+
* chain off the build path (#1074). */
|
|
82
|
+
export function createKubectlApplyCapability(
|
|
83
|
+
apply?: Applier,
|
|
84
|
+
): Capability<KubectlApplyInput, KubectlApplyOutcome> {
|
|
85
|
+
return {
|
|
86
|
+
kind: "kubectl-apply",
|
|
87
|
+
// A server-side apply has no native undo (the previous object state is not
|
|
88
|
+
// kept by the API server), so COMP003 requires the component to acknowledge
|
|
89
|
+
// the compensation gap — the same posture as s3-sync/run-migration.
|
|
90
|
+
rollbackPolicy: "needs-opt-out",
|
|
91
|
+
async run(ctx: DeployContext, input: KubectlApplyInput): Promise<KubectlApplyOutcome> {
|
|
92
|
+
const applier: Applier = apply ?? (await import("../op/activities/kubectl")).applyManifest;
|
|
93
|
+
return applier({
|
|
94
|
+
manifest: input.manifest,
|
|
95
|
+
environment: ctx.env,
|
|
96
|
+
...(input.stack !== undefined ? { stack: input.stack } : {}),
|
|
97
|
+
...(input.context !== undefined ? { context: input.context } : {}),
|
|
98
|
+
...(input.delete !== undefined ? { deleteMode: input.delete } : {}),
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const kubectlApplyCapability = createKubectlApplyCapability();
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { describeStackStatus } from "./describe-stack-status";
|
|
3
|
+
import type { K8sConnector } from "./api/connect";
|
|
4
|
+
|
|
5
|
+
/** A connector whose client answers list() from a canned map keyed by kind. */
|
|
6
|
+
function fakeConnector(byKind: Record<string, unknown[]>, opts: { failKinds?: string[]; recordSelectors?: string[] } = {}): K8sConnector {
|
|
7
|
+
return (async () => ({
|
|
8
|
+
client: {
|
|
9
|
+
async list(selector: { kind: string }, listOpts?: { labelSelector?: string }) {
|
|
10
|
+
if (listOpts?.labelSelector && opts.recordSelectors) opts.recordSelectors.push(listOpts.labelSelector);
|
|
11
|
+
if (opts.failKinds?.includes(selector.kind)) throw new Error("rbac denied");
|
|
12
|
+
return byKind[selector.kind] ?? [];
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
})) as unknown as K8sConnector;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const readyDeployment = {
|
|
19
|
+
kind: "Deployment",
|
|
20
|
+
metadata: { name: "cc-api", labels: { "chant.intentius.io/stack": "cc" } },
|
|
21
|
+
status: { replicas: 2, readyReplicas: 2 },
|
|
22
|
+
};
|
|
23
|
+
const laggingDeployment = { ...readyDeployment, status: { replicas: 2, readyReplicas: 1 } };
|
|
24
|
+
const service = { kind: "Service", metadata: { name: "cc-api" } };
|
|
25
|
+
|
|
26
|
+
describe("k8s describeStackStatus (#1495 piece 3)", () => {
|
|
27
|
+
test("selects on the labels chant's serializer stamps", async () => {
|
|
28
|
+
const selectors: string[] = [];
|
|
29
|
+
await describeStackStatus({ environment: "local", stack: "kubemicrovm-ops" }, fakeConnector({}, { recordSelectors: selectors }));
|
|
30
|
+
expect(selectors[0]).toBe("app.kubernetes.io/managed-by=chant,chant.intentius.io/stack=kubemicrovm-ops");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("present + healthy when every matching workload is ready", async () => {
|
|
34
|
+
const out = await describeStackStatus({ environment: "local", stack: "cc" }, fakeConnector({ Deployment: [readyDeployment], Service: [service] }));
|
|
35
|
+
expect(out).toEqual({ stack: "cc", present: true, status: "1/1 workloads ready", healthy: true });
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("present but unhealthy when a workload lags its replica count", async () => {
|
|
39
|
+
const out = await describeStackStatus({ environment: "local", stack: "cc" }, fakeConnector({ Deployment: [laggingDeployment] }));
|
|
40
|
+
expect(out).toMatchObject({ present: true, healthy: false });
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("presence-only objects report present and assert nothing about health", async () => {
|
|
44
|
+
const out = await describeStackStatus({ environment: "local", stack: "cc" }, fakeConnector({ Service: [service] }));
|
|
45
|
+
expect(out).toEqual({ stack: "cc", present: true, status: "1 objects present", healthy: true });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("zero matches is the pre-first-apply state — absent, not indeterminate", async () => {
|
|
49
|
+
const out = await describeStackStatus({ environment: "local", stack: "cc" }, fakeConnector({}));
|
|
50
|
+
expect(out).toEqual({ stack: "cc", present: false });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("one kind's read failing does not fail the sweep", async () => {
|
|
54
|
+
const out = await describeStackStatus(
|
|
55
|
+
{ environment: "local", stack: "cc" },
|
|
56
|
+
fakeConnector({ Deployment: [readyDeployment] }, { failKinds: ["Ingress", "CronJob"] }),
|
|
57
|
+
);
|
|
58
|
+
expect(out).toMatchObject({ present: true, healthy: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("no cluster binding is indeterminate — null, never a confident absence", async () => {
|
|
62
|
+
const broken: K8sConnector = (async () => {
|
|
63
|
+
throw new Error("no context bound for env");
|
|
64
|
+
}) as unknown as K8sConnector;
|
|
65
|
+
expect(await describeStackStatus({ environment: "local", stack: "cc" }, broken)).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("every read failing is indeterminate too", async () => {
|
|
69
|
+
const out = await describeStackStatus(
|
|
70
|
+
{ environment: "local", stack: "cc" },
|
|
71
|
+
fakeConnector({}, { failKinds: ["Deployment", "StatefulSet", "DaemonSet", "Service", "ConfigMap", "Secret", "ServiceAccount", "Namespace", "Ingress", "PodDisruptionBudget", "HorizontalPodAutoscaler", "CronJob"] }),
|
|
72
|
+
);
|
|
73
|
+
expect(out).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deploy-unit status for Kubernetes (#1495 piece 3).
|
|
3
|
+
*
|
|
4
|
+
* A Kubernetes deploy unit *is* a label selector: every object chant applies
|
|
5
|
+
* carries its stack identity in the labels the serializer stamps
|
|
6
|
+
* (`app.kubernetes.io/managed-by=chant`, `chant.intentius.io/stack=<stack>` —
|
|
7
|
+
* see core's ownership marking). So "is this unit present" is one selector
|
|
8
|
+
* query, and "is it healthy" is whether every matching workload its controller
|
|
9
|
+
* reports on is Ready. No new identity concept — this reads back exactly what
|
|
10
|
+
* `kubectl-apply` (piece 2) and the serializer already write.
|
|
11
|
+
*
|
|
12
|
+
* The selector deliberately omits `chant.intentius.io/env`: `ownership.env` is
|
|
13
|
+
* a config identity stamped at synthesis, not necessarily the environment name
|
|
14
|
+
* a caller observes with, and a mismatch would read a deployed unit as absent
|
|
15
|
+
* — the failure mode the tri-state exists to prevent.
|
|
16
|
+
*
|
|
17
|
+
* The sweep is bounded to the kinds chant's own k8s surface deploys as
|
|
18
|
+
* stack-scoped objects (workloads + the service/config plumbing around them).
|
|
19
|
+
* A CRD-only unit is out of this bound and reports absent rather than null —
|
|
20
|
+
* a real limit, stated here rather than papered over; widening the sweep to
|
|
21
|
+
* discovery-driven kinds is a follow-up, not a silent claim.
|
|
22
|
+
*/
|
|
23
|
+
import type { StackStatusObservation } from "@intentius/chant/lexicon";
|
|
24
|
+
import { LABEL_OWNERSHIP_KEYS, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
|
|
25
|
+
import type { K8sObject } from "@intentius/chant-k8s-client";
|
|
26
|
+
import { defaultK8sConnector, type K8sConnector } from "./api/connect";
|
|
27
|
+
|
|
28
|
+
/** The kinds a chant-applied stack's objects land as — the sweep's bound. */
|
|
29
|
+
const SWEEP_KINDS: ReadonlyArray<{ apiVersion: string; kind: string }> = [
|
|
30
|
+
{ apiVersion: "apps/v1", kind: "Deployment" },
|
|
31
|
+
{ apiVersion: "apps/v1", kind: "StatefulSet" },
|
|
32
|
+
{ apiVersion: "apps/v1", kind: "DaemonSet" },
|
|
33
|
+
{ apiVersion: "v1", kind: "Service" },
|
|
34
|
+
{ apiVersion: "v1", kind: "ConfigMap" },
|
|
35
|
+
{ apiVersion: "v1", kind: "Secret" },
|
|
36
|
+
{ apiVersion: "v1", kind: "ServiceAccount" },
|
|
37
|
+
{ apiVersion: "v1", kind: "Namespace" },
|
|
38
|
+
{ apiVersion: "networking.k8s.io/v1", kind: "Ingress" },
|
|
39
|
+
{ apiVersion: "policy/v1", kind: "PodDisruptionBudget" },
|
|
40
|
+
{ apiVersion: "autoscaling/v2", kind: "HorizontalPodAutoscaler" },
|
|
41
|
+
{ apiVersion: "batch/v1", kind: "CronJob" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** A workload is healthy when its controller reports every replica ready; a
|
|
45
|
+
* kind with no readiness story (a ConfigMap, a Service) asserts nothing. */
|
|
46
|
+
function objectHealthy(obj: K8sObject): boolean | undefined {
|
|
47
|
+
const status = obj.status as { replicas?: unknown; readyReplicas?: unknown; desiredNumberScheduled?: unknown; numberReady?: unknown } | undefined;
|
|
48
|
+
if (!status) return undefined;
|
|
49
|
+
if (typeof status.replicas === "number") {
|
|
50
|
+
return (typeof status.readyReplicas === "number" ? status.readyReplicas : 0) >= status.replicas;
|
|
51
|
+
}
|
|
52
|
+
if (typeof status.desiredNumberScheduled === "number") {
|
|
53
|
+
return (typeof status.numberReady === "number" ? status.numberReady : 0) >= status.desiredNumberScheduled;
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function describeStackStatus(
|
|
59
|
+
options: { environment: string; stack: string },
|
|
60
|
+
connect: K8sConnector = defaultK8sConnector,
|
|
61
|
+
): Promise<StackStatusObservation | null> {
|
|
62
|
+
const selector =
|
|
63
|
+
`${LABEL_OWNERSHIP_KEYS.managedBy}=${OWNERSHIP_MANAGED_BY_VALUE},` +
|
|
64
|
+
`${LABEL_OWNERSHIP_KEYS.stack}=${options.stack}`;
|
|
65
|
+
|
|
66
|
+
let client;
|
|
67
|
+
try {
|
|
68
|
+
({ client } = await connect({ environment: options.environment }));
|
|
69
|
+
} catch {
|
|
70
|
+
// No cluster binding / unreadable kubeconfig — indeterminate, never a
|
|
71
|
+
// confident "not there".
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const matched: K8sObject[] = [];
|
|
76
|
+
let anyRead = false;
|
|
77
|
+
for (const target of SWEEP_KINDS) {
|
|
78
|
+
try {
|
|
79
|
+
const items = await client.list(target, { labelSelector: selector });
|
|
80
|
+
anyRead = true;
|
|
81
|
+
matched.push(...items);
|
|
82
|
+
} catch {
|
|
83
|
+
// One kind's list failing (RBAC, an aggregated API down) proves nothing
|
|
84
|
+
// about the others; keep sweeping.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!anyRead) return null; // every read failed — indeterminate
|
|
88
|
+
|
|
89
|
+
if (matched.length === 0) return { stack: options.stack, present: false };
|
|
90
|
+
|
|
91
|
+
const verdicts = matched.map(objectHealthy).filter((v): v is boolean => v !== undefined);
|
|
92
|
+
const healthy = verdicts.length > 0 ? verdicts.every(Boolean) : true;
|
|
93
|
+
const ready = verdicts.filter(Boolean).length;
|
|
94
|
+
return {
|
|
95
|
+
stack: options.stack,
|
|
96
|
+
present: true,
|
|
97
|
+
status: verdicts.length > 0 ? `${ready}/${verdicts.length} workloads ready` : `${matched.length} objects present`,
|
|
98
|
+
healthy,
|
|
99
|
+
};
|
|
100
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,12 @@ export { k8sSerializer } from "./serializer";
|
|
|
4
4
|
// Plugin
|
|
5
5
|
export { k8sPlugin } from "./plugin";
|
|
6
6
|
|
|
7
|
+
// The capability plugin core's loader discovers on this package (#1495 piece 2)
|
|
8
|
+
// — the kubectl-apply leaf a component composes, the way aws contributes
|
|
9
|
+
// cfn-deploy.
|
|
10
|
+
export { k8sCapabilityPlugin, K8S_VERB_FAMILIES } from "./components/capability-plugin";
|
|
11
|
+
export { kubectlApplyCapability, createKubectlApplyCapability, type KubectlApplyInput } from "./components/kubectl-apply";
|
|
12
|
+
|
|
7
13
|
// Default labels & annotations
|
|
8
14
|
export { defaultLabels, defaultAnnotations, isDefaultLabels, isDefaultAnnotations } from "./default-labels";
|
|
9
15
|
export { DEFAULT_LABELS_MARKER, DEFAULT_ANNOTATIONS_MARKER } from "./default-labels";
|
package/src/plugin.ts
CHANGED
|
@@ -631,6 +631,18 @@ const { deployment, service, serviceMonitor, prometheusRule } = MonitoredService
|
|
|
631
631
|
return describeResources(options);
|
|
632
632
|
},
|
|
633
633
|
|
|
634
|
+
/**
|
|
635
|
+
* Deploy-unit presence + health for `chant components status --live`
|
|
636
|
+
* (#1495 piece 3): a Kubernetes deploy unit is the label selector chant's
|
|
637
|
+
* own serializer stamps, so this reads back
|
|
638
|
+
* `app.kubernetes.io/managed-by=chant, chant.intentius.io/stack=<stack>`
|
|
639
|
+
* and rolls readiness up from the matching workloads' controllers.
|
|
640
|
+
*/
|
|
641
|
+
async describeStackStatus(options) {
|
|
642
|
+
const { describeStackStatus } = await import("./describe-stack-status");
|
|
643
|
+
return describeStackStatus(options);
|
|
644
|
+
},
|
|
645
|
+
|
|
634
646
|
async exportResources(options) {
|
|
635
647
|
const { exportResources } = await import("./export-resources");
|
|
636
648
|
return exportResources(options);
|
package/src/serializer.test.ts
CHANGED
|
@@ -510,3 +510,105 @@ describe("k8sSerializer", () => {
|
|
|
510
510
|
expect(result).not.toMatch(/^spec:\s*\n\s+rules:/m);
|
|
511
511
|
});
|
|
512
512
|
});
|
|
513
|
+
|
|
514
|
+
// ── Cross-resource references (#1493) ────────────────────────────────
|
|
515
|
+
//
|
|
516
|
+
// Kubernetes YAML has no deploy-time reference mechanism, so a reference has
|
|
517
|
+
// to be resolved before serialization or it means nothing. These used to
|
|
518
|
+
// serialize to the *logical* name, so a Deployment referencing its PVC emitted
|
|
519
|
+
// `claimName: pgClaim` and named a resource that does not exist — it applied
|
|
520
|
+
// cleanly and failed on the cluster.
|
|
521
|
+
|
|
522
|
+
function mockAttrRef(attribute: string, logicalName: string): any {
|
|
523
|
+
// Shape isAttrRefLike() checks for: parent object, string attribute, and
|
|
524
|
+
// _setLogicalName. Without the last one the walker treats it as a plain
|
|
525
|
+
// object and the test silently asserts nothing.
|
|
526
|
+
return {
|
|
527
|
+
attribute,
|
|
528
|
+
parent: { deref: () => undefined },
|
|
529
|
+
_setLogicalName: () => {},
|
|
530
|
+
getLogicalName: () => logicalName,
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
describe("cross-resource references resolve at build time (#1493)", () => {
|
|
535
|
+
test(".name resolves to the referenced resource's metadata.name", () => {
|
|
536
|
+
const claim = mockResource("K8s::Core::PersistentVolumeClaim", {
|
|
537
|
+
metadata: { name: "fountain-postgres", namespace: "fountain" },
|
|
538
|
+
spec: { accessModes: ["ReadWriteOnce"] },
|
|
539
|
+
});
|
|
540
|
+
const dep = mockResource("K8s::Apps::Deployment", {
|
|
541
|
+
metadata: { name: "fountain-postgres", namespace: "fountain" },
|
|
542
|
+
spec: {
|
|
543
|
+
template: {
|
|
544
|
+
spec: {
|
|
545
|
+
volumes: [
|
|
546
|
+
{ name: "data", persistentVolumeClaim: { claimName: mockAttrRef("name", "pgClaim") } },
|
|
547
|
+
],
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
const yaml = k8sSerializer.serialize(new Map([["pgClaim", claim], ["pgDeployment", dep]]));
|
|
553
|
+
expect(yaml).toContain("claimName: fountain-postgres");
|
|
554
|
+
expect(yaml).not.toContain("claimName: pgClaim");
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// A resource that declares no name gets one derived from its logical name.
|
|
558
|
+
// A reference to it has to derive the SAME one or the two silently disagree.
|
|
559
|
+
test(".name matches the derived name when the target declares none", () => {
|
|
560
|
+
const claim = mockResource("K8s::Core::PersistentVolumeClaim", {
|
|
561
|
+
spec: { accessModes: ["ReadWriteOnce"] },
|
|
562
|
+
});
|
|
563
|
+
const dep = mockResource("K8s::Apps::Deployment", {
|
|
564
|
+
spec: { template: { spec: { volumes: [{ claimName: mockAttrRef("name", "pgClaim") }] } } },
|
|
565
|
+
});
|
|
566
|
+
const yaml = k8sSerializer.serialize(new Map([["pgClaim", claim], ["dep", dep]]));
|
|
567
|
+
expect(yaml).toContain("name: pg-claim");
|
|
568
|
+
expect(yaml).toContain("claimName: pg-claim");
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test(".namespace resolves to the declared namespace", () => {
|
|
572
|
+
const svc = mockResource("K8s::Core::Service", {
|
|
573
|
+
metadata: { name: "fountain", namespace: "fountain" },
|
|
574
|
+
});
|
|
575
|
+
const dep = mockResource("K8s::Apps::Deployment", {
|
|
576
|
+
metadata: { name: "app" },
|
|
577
|
+
spec: { host: mockAttrRef("namespace", "service") },
|
|
578
|
+
});
|
|
579
|
+
const yaml = k8sSerializer.serialize(new Map([["service", svc], ["dep", dep]]));
|
|
580
|
+
expect(yaml).toContain("host: fountain");
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
// A UID is assigned by the API server at admission, so emitting any
|
|
584
|
+
// build-time string for it would be a fabrication.
|
|
585
|
+
test(".uid is refused rather than fabricated", () => {
|
|
586
|
+
const svc = mockResource("K8s::Core::Service", { metadata: { name: "fountain" } });
|
|
587
|
+
const dep = mockResource("K8s::Apps::Deployment", {
|
|
588
|
+
spec: { ownerRef: mockAttrRef("uid", "service") },
|
|
589
|
+
});
|
|
590
|
+
expect(() => k8sSerializer.serialize(new Map([["service", svc], ["dep", dep]]))).toThrow(
|
|
591
|
+
/assigned by the API server/,
|
|
592
|
+
);
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test("an unresolvable namespace says what to set rather than emitting a wrong one", () => {
|
|
596
|
+
const svc = mockResource("K8s::Core::Service", { metadata: { name: "fountain" } });
|
|
597
|
+
const dep = mockResource("K8s::Apps::Deployment", {
|
|
598
|
+
spec: { ns: mockAttrRef("namespace", "service") },
|
|
599
|
+
});
|
|
600
|
+
expect(() => k8sSerializer.serialize(new Map([["service", svc], ["dep", dep]]))).toThrow(
|
|
601
|
+
/declares no metadata.namespace/,
|
|
602
|
+
);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test("an attribute k8s cannot express is refused with the reason", () => {
|
|
606
|
+
const svc = mockResource("K8s::Core::Service", { metadata: { name: "fountain" } });
|
|
607
|
+
const dep = mockResource("K8s::Apps::Deployment", {
|
|
608
|
+
spec: { x: mockAttrRef("clusterIP", "service") },
|
|
609
|
+
});
|
|
610
|
+
expect(() => k8sSerializer.serialize(new Map([["service", svc], ["dep", dep]]))).toThrow(
|
|
611
|
+
/resolves .name and .namespace/,
|
|
612
|
+
);
|
|
613
|
+
});
|
|
614
|
+
});
|
package/src/serializer.ts
CHANGED
|
@@ -113,16 +113,82 @@ function deriveGVKFromType(entityType: string): { apiVersion: string; kind: stri
|
|
|
113
113
|
return { apiVersion, kind };
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* The name a resource gets when it does not declare one: the logical name,
|
|
118
|
+
* kebab-cased. Shared with the manifest builder below so a reference and the
|
|
119
|
+
* thing it references cannot derive different names (#1493).
|
|
120
|
+
*/
|
|
121
|
+
function derivedName(logicalName: string): string {
|
|
122
|
+
return logicalName.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* What a cross-resource reference resolves to on this substrate (#1493).
|
|
127
|
+
*
|
|
128
|
+
* Kubernetes YAML has no deploy-time reference mechanism — no `!Ref`, no
|
|
129
|
+
* `Fn::GetAtt`. Whatever a reference is going to mean has to be resolved here,
|
|
130
|
+
* before serialization, or it means nothing at all.
|
|
131
|
+
*
|
|
132
|
+
* This used to return the *logical* name for every attribute, so
|
|
133
|
+
* `claimName: pgClaim.name` emitted `claimName: pgClaim` and the manifest
|
|
134
|
+
* named a resource that does not exist. It applied cleanly and failed on the
|
|
135
|
+
* cluster, which is the failure this whole system exists to remove. The
|
|
136
|
+
* lexicon's own plugin comment already described the intended behaviour —
|
|
137
|
+
* "resolves to metadata.name" — so this brings the code to the documentation
|
|
138
|
+
* rather than the other way round.
|
|
139
|
+
*
|
|
140
|
+
* `uid` is refused rather than resolved: a UID is assigned by the API server
|
|
141
|
+
* at admission, so no build-time value exists and any string emitted here
|
|
142
|
+
* would be a fabrication.
|
|
143
|
+
*/
|
|
144
|
+
function resolveK8sAttr(entity: Declarable | undefined, logicalName: string, attr: string): unknown {
|
|
145
|
+
if (attr === "uid") {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`Cannot reference "${logicalName}.uid": a Kubernetes UID is assigned by the API server at ` +
|
|
148
|
+
`admission, so it has no build-time value. Reference .name, or carry the UID at runtime ` +
|
|
149
|
+
`with fieldRef: { fieldPath: "metadata.uid" }.`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const metadata =
|
|
154
|
+
entity && isResourceDeclarable(entity) && typeof entity.props === "object" && entity.props !== null
|
|
155
|
+
? ((entity.props as Record<string, unknown>).metadata as Record<string, unknown> | undefined)
|
|
156
|
+
: undefined;
|
|
157
|
+
|
|
158
|
+
if (attr === "name") {
|
|
159
|
+
// Same fallback the manifest builder applies, so a reference to a resource
|
|
160
|
+
// that declares no name still resolves to the name it will actually get.
|
|
161
|
+
const declared = metadata?.name;
|
|
162
|
+
return typeof declared === "string" && declared.length > 0 ? declared : derivedName(logicalName);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (attr === "namespace") {
|
|
166
|
+
const ns = metadata?.namespace;
|
|
167
|
+
if (typeof ns === "string" && ns.length > 0) return ns;
|
|
168
|
+
throw new Error(
|
|
169
|
+
`Cannot reference "${logicalName}.namespace": it declares no metadata.namespace, and the ` +
|
|
170
|
+
`namespace a manifest lands in is decided at apply time (kubectl -n, or the context's ` +
|
|
171
|
+
`default). Set metadata.namespace on "${logicalName}" if the reference needs to be stable.`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Cannot reference "${logicalName}.${attr}": the k8s lexicon resolves .name and .namespace at ` +
|
|
177
|
+
`build time, and Kubernetes YAML has no way to express any other attribute reference.`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
116
181
|
/**
|
|
117
182
|
* K8s visitor for the generic serializer walker.
|
|
118
183
|
*/
|
|
119
184
|
function k8sVisitor(entityNames: Map<Declarable, string>): SerializerVisitor {
|
|
185
|
+
// name → entity, so an AttrRef can reach the resource it points at. The
|
|
186
|
+
// walker hands the visitor a logical name, not the Declarable.
|
|
187
|
+
const byName = new Map<string, Declarable>();
|
|
188
|
+
for (const [entity, name] of entityNames) byName.set(name, entity);
|
|
189
|
+
|
|
120
190
|
return {
|
|
121
|
-
attrRef: (name, attr) =>
|
|
122
|
-
// For K8s, attribute references typically resolve to metadata.name
|
|
123
|
-
if (attr === "name") return name;
|
|
124
|
-
return name;
|
|
125
|
-
},
|
|
191
|
+
attrRef: (name, attr) => resolveK8sAttr(byName.get(name), name, attr),
|
|
126
192
|
resourceRef: (name) => name,
|
|
127
193
|
propertyDeclarable: (entity, walk) => {
|
|
128
194
|
if (!isResourceDeclarable(entity) || typeof entity.props !== "object" || entity.props === null) {
|
|
@@ -209,8 +275,7 @@ export const k8sSerializer: Serializer = {
|
|
|
209
275
|
// Build metadata
|
|
210
276
|
const metadata: Record<string, unknown> = props.metadata as Record<string, unknown> ?? {};
|
|
211
277
|
if (!metadata.name) {
|
|
212
|
-
|
|
213
|
-
metadata.name = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
278
|
+
metadata.name = derivedName(name);
|
|
214
279
|
}
|
|
215
280
|
|
|
216
281
|
// Merge default labels
|