@intentius/chant-lexicon-k8s 0.124.31 → 0.124.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (270) hide show
  1. package/dist/actions/apps.js +18 -0
  2. package/dist/actions/batch.js +6 -0
  3. package/dist/actions/core.js +48 -0
  4. package/dist/actions/index.js +13 -0
  5. package/dist/actions/networking.js +11 -0
  6. package/dist/actions/rbac.js +8 -0
  7. package/dist/api/classify.js +123 -0
  8. package/dist/api/connect.js +62 -0
  9. package/dist/api/fake-cluster.js +175 -0
  10. package/dist/api/operation-surface.d.ts.map +1 -1
  11. package/dist/api/operation-surface.js +109 -0
  12. package/dist/api/owner-chain.js +100 -0
  13. package/dist/api/sweep-types.js +35 -0
  14. package/dist/behaviour-kinds.js +82 -0
  15. package/dist/codegen/docs-cli.js +2 -0
  16. package/dist/codegen/docs.js +146 -0
  17. package/dist/codegen/generate-cli.js +30 -0
  18. package/dist/codegen/generate-lexicon.js +74 -0
  19. package/dist/codegen/generate-list-map-keys.js +88 -0
  20. package/dist/codegen/generate-operations.js +53 -0
  21. package/dist/codegen/generate-typescript.js +124 -0
  22. package/dist/codegen/generate.js +173 -0
  23. package/dist/codegen/naming.js +174 -0
  24. package/dist/codegen/package.js +36 -0
  25. package/dist/codegen/patches.js +88 -0
  26. package/dist/codegen/typecheck.js +4 -0
  27. package/dist/codegen/versions.js +41 -0
  28. package/dist/components/argo-app.js +51 -0
  29. package/dist/components/builders.js +22 -0
  30. package/dist/components/capability-plugin.js +31 -0
  31. package/dist/components/flux-reconcile.js +61 -0
  32. package/dist/components/index.d.ts +1 -1
  33. package/dist/components/index.d.ts.map +1 -1
  34. package/dist/components/index.js +18 -0
  35. package/dist/components/kubectl-apply.d.ts +83 -1
  36. package/dist/components/kubectl-apply.d.ts.map +1 -1
  37. package/dist/components/kubectl-apply.js +274 -0
  38. package/dist/components/kustomize-apply.js +25 -0
  39. package/dist/composites/adot-collector.js +184 -0
  40. package/dist/composites/agic-ingress.js +85 -0
  41. package/dist/composites/aks-external-dns-agent.js +146 -0
  42. package/dist/composites/alb-ingress.js +86 -0
  43. package/dist/composites/argo-app.js +199 -0
  44. package/dist/composites/autoscaled-service.js +174 -0
  45. package/dist/composites/azure-disk-storage-class.js +47 -0
  46. package/dist/composites/azure-file-storage-class.js +48 -0
  47. package/dist/composites/azure-monitor-collector.js +176 -0
  48. package/dist/composites/batch-job.js +139 -0
  49. package/dist/composites/catalog.js +4110 -0
  50. package/dist/composites/cockroachdb-cluster.js +371 -0
  51. package/dist/composites/cockroachdb-region-stack.js +369 -0
  52. package/dist/composites/config-connector-context.js +40 -0
  53. package/dist/composites/configured-app.js +135 -0
  54. package/dist/composites/cron-schedule.js +38 -0
  55. package/dist/composites/cron-workload.js +109 -0
  56. package/dist/composites/ebs-storage-class.js +54 -0
  57. package/dist/composites/efs-storage-class.js +46 -0
  58. package/dist/composites/external-dns-agent.js +122 -0
  59. package/dist/composites/filestore-storage-class.js +49 -0
  60. package/dist/composites/fluent-bit-agent.js +157 -0
  61. package/dist/composites/flux-app.js +156 -0
  62. package/dist/composites/gce-ingress.js +83 -0
  63. package/dist/composites/gce-pd-storage-class.js +49 -0
  64. package/dist/composites/gke-external-dns-agent.js +129 -0
  65. package/dist/composites/gke-fluent-bit-agent.js +165 -0
  66. package/dist/composites/gke-gateway.js +91 -0
  67. package/dist/composites/gke-otel-collector.js +94 -0
  68. package/dist/composites/index.js +51 -0
  69. package/dist/composites/inference-service.js +76 -0
  70. package/dist/composites/irsa-service-account.js +77 -0
  71. package/dist/composites/member-defaults.js +14 -0
  72. package/dist/composites/metrics-server.js +181 -0
  73. package/dist/composites/model.js +92 -0
  74. package/dist/composites/monitored-service.js +152 -0
  75. package/dist/composites/namespace-env.js +114 -0
  76. package/dist/composites/network-isolated-app.js +124 -0
  77. package/dist/composites/node-agent.js +149 -0
  78. package/dist/composites/operator-stack.js +268 -0
  79. package/dist/composites/otel-collector-agent.js +142 -0
  80. package/dist/composites/otel-collector-gateway.js +216 -0
  81. package/dist/composites/otel-collector-node.js +176 -0
  82. package/dist/composites/otel-collector-rbac.js +115 -0
  83. package/dist/composites/otel-collector-shape.js +128 -0
  84. package/dist/composites/otel-collector.js +115 -0
  85. package/dist/composites/otel-operator-collector.js +123 -0
  86. package/dist/composites/ray-cluster.js +378 -0
  87. package/dist/composites/ray-job.js +136 -0
  88. package/dist/composites/ray-service.js +178 -0
  89. package/dist/composites/secure-ingress.js +100 -0
  90. package/dist/composites/security-context.js +1 -0
  91. package/dist/composites/sidecar-app.js +105 -0
  92. package/dist/composites/stateful-app.js +111 -0
  93. package/dist/composites/vllm-serving-runtime.js +114 -0
  94. package/dist/composites/web-app.js +157 -0
  95. package/dist/composites/worker-pool.js +167 -0
  96. package/dist/composites/workload-identity-sa.js +81 -0
  97. package/dist/composites/workload-identity-service-account.js +77 -0
  98. package/dist/config-schema.js +72 -0
  99. package/dist/config.js +59 -0
  100. package/dist/coverage.js +27 -0
  101. package/dist/crd/crd-sources.js +615 -0
  102. package/dist/crd/loader.js +181 -0
  103. package/dist/crd/oci-chart.js +144 -0
  104. package/dist/crd/parser.js +382 -0
  105. package/dist/crd/project-codegen.js +273 -0
  106. package/dist/crd/types.js +7 -0
  107. package/dist/deep-observe-hooks.js +306 -0
  108. package/dist/deep-observe.js +234 -0
  109. package/dist/default-labels.js +87 -0
  110. package/dist/describe-resources.js +699 -0
  111. package/dist/describe-stack-status.js +99 -0
  112. package/dist/detect.js +11 -0
  113. package/dist/effect-receipt-row.js +205 -0
  114. package/dist/export-resources.js +39 -0
  115. package/dist/generated/index.js +1092 -0
  116. package/dist/generated/runtime.js +4 -0
  117. package/dist/group-namespace.js +96 -0
  118. package/dist/import/embedded.js +197 -0
  119. package/dist/import/generator.js +288 -0
  120. package/dist/import/live-export.js +85 -0
  121. package/dist/import/parser.js +204 -0
  122. package/dist/import/testdata/embedded/fixtures.js +60 -0
  123. package/dist/include/entity.js +78 -0
  124. package/dist/include/resolve.js +170 -0
  125. package/dist/index.js +59 -0
  126. package/dist/integrity.json +3 -3
  127. package/dist/kube/apply.js +90 -0
  128. package/dist/kube/connect.js +46 -0
  129. package/dist/kube/delete.js +138 -0
  130. package/dist/kube/describe.js +169 -0
  131. package/dist/kube/events.js +132 -0
  132. package/dist/kube/flags.js +91 -0
  133. package/dist/kube/get.js +159 -0
  134. package/dist/kube/group.js +123 -0
  135. package/dist/kube/logs.js +82 -0
  136. package/dist/kube/project.js +94 -0
  137. package/dist/kube/render.js +227 -0
  138. package/dist/kube/source.js +122 -0
  139. package/dist/kube/target.js +63 -0
  140. package/dist/kube/testing.js +28 -0
  141. package/dist/kube/top.js +99 -0
  142. package/dist/kube/verdict.js +72 -0
  143. package/dist/kube/wait.js +129 -0
  144. package/dist/kustomize/render.js +73 -0
  145. package/dist/kustomize/rendered-entity.js +67 -0
  146. package/dist/kustomize/root.js +68 -0
  147. package/dist/lint/audit-catalog.js +61 -0
  148. package/dist/lint/audit-lineage.js +118 -0
  149. package/dist/lint/post-synth/argo-helpers.js +36 -0
  150. package/dist/lint/post-synth/argo002.js +31 -0
  151. package/dist/lint/post-synth/argo003.js +53 -0
  152. package/dist/lint/post-synth/argo005.js +54 -0
  153. package/dist/lint/post-synth/crd-schema-helpers.d.ts.map +1 -1
  154. package/dist/lint/post-synth/crd-schema-helpers.js +214 -0
  155. package/dist/lint/post-synth/flux002.js +56 -0
  156. package/dist/lint/post-synth/flux003.js +50 -0
  157. package/dist/lint/post-synth/index.js +104 -0
  158. package/dist/lint/post-synth/k8s-helpers.js +186 -0
  159. package/dist/lint/post-synth/monitoring-selection-helpers.js +163 -0
  160. package/dist/lint/post-synth/otel-placement-helpers.js +293 -0
  161. package/dist/lint/post-synth/sops-helpers.js +43 -0
  162. package/dist/lint/post-synth/wk8005.js +44 -0
  163. package/dist/lint/post-synth/wk8006.js +56 -0
  164. package/dist/lint/post-synth/wk8041.js +60 -0
  165. package/dist/lint/post-synth/wk8042.js +36 -0
  166. package/dist/lint/post-synth/wk8101.js +52 -0
  167. package/dist/lint/post-synth/wk8102.js +30 -0
  168. package/dist/lint/post-synth/wk8103.js +33 -0
  169. package/dist/lint/post-synth/wk8104.js +58 -0
  170. package/dist/lint/post-synth/wk8105.js +33 -0
  171. package/dist/lint/post-synth/wk8201.js +44 -0
  172. package/dist/lint/post-synth/wk8202.js +34 -0
  173. package/dist/lint/post-synth/wk8203.js +34 -0
  174. package/dist/lint/post-synth/wk8204.js +71 -0
  175. package/dist/lint/post-synth/wk8205.js +38 -0
  176. package/dist/lint/post-synth/wk8207.js +33 -0
  177. package/dist/lint/post-synth/wk8208.js +33 -0
  178. package/dist/lint/post-synth/wk8209.js +33 -0
  179. package/dist/lint/post-synth/wk8301.js +49 -0
  180. package/dist/lint/post-synth/wk8302.js +34 -0
  181. package/dist/lint/post-synth/wk8303.js +68 -0
  182. package/dist/lint/post-synth/wk8304.js +58 -0
  183. package/dist/lint/post-synth/wk8305.js +96 -0
  184. package/dist/lint/post-synth/wk8306.js +38 -0
  185. package/dist/lint/post-synth/wk8401.js +86 -0
  186. package/dist/lint/post-synth/wk8402.js +31 -0
  187. package/dist/lint/post-synth/wk8403.js +49 -0
  188. package/dist/lint/post-synth/wk8404.js +36 -0
  189. package/dist/lint/post-synth/wk8405.js +65 -0
  190. package/dist/lint/post-synth/wk8406.js +50 -0
  191. package/dist/lint/post-synth/wk8407.js +58 -0
  192. package/dist/lint/post-synth/wk8501.js +36 -0
  193. package/dist/lint/post-synth/wk8502.js +35 -0
  194. package/dist/lint/post-synth/wk8503.js +164 -0
  195. package/dist/lint/post-synth/wk8504.js +20 -0
  196. package/dist/lint/post-synth/wk8505.js +31 -0
  197. package/dist/lint/post-synth/wk8601.js +40 -0
  198. package/dist/lint/post-synth/wk8602.js +120 -0
  199. package/dist/lint/post-synth/wk8603.js +63 -0
  200. package/dist/lint/post-synth/wk8604.js +31 -0
  201. package/dist/lint/post-synth/wk8605.js +124 -0
  202. package/dist/lint/post-synth/wk8606.js +22 -0
  203. package/dist/lint/post-synth/wk8701.js +31 -0
  204. package/dist/lint/post-synth/wk8702.js +37 -0
  205. package/dist/lint/post-synth/wk8703.js +31 -0
  206. package/dist/lint/post-synth/wk8704.js +135 -0
  207. package/dist/lint/post-synth/wk8705.js +38 -0
  208. package/dist/lint/rules/argo-appset-single-project.js +56 -0
  209. package/dist/lint/rules/argo-ast.js +94 -0
  210. package/dist/lint/rules/argo-automated-prune.js +59 -0
  211. package/dist/lint/rules/flux-source-ref-pin.js +44 -0
  212. package/dist/lint/rules/hardcoded-namespace.js +57 -0
  213. package/dist/lint/rules/latest-image-tag.js +103 -0
  214. package/dist/lint/rules/missing-resource-limits.js +85 -0
  215. package/dist/lsp/completions.d.ts.map +1 -1
  216. package/dist/lsp/completions.js +18 -0
  217. package/dist/lsp/hover.d.ts.map +1 -1
  218. package/dist/lsp/hover.js +57 -0
  219. package/dist/manifest-entity.js +71 -0
  220. package/dist/manifest.json +1 -1
  221. package/dist/micro-time.js +49 -0
  222. package/dist/op/activities/apply-diff.d.ts +31 -0
  223. package/dist/op/activities/apply-diff.d.ts.map +1 -0
  224. package/dist/op/activities/apply-diff.js +79 -0
  225. package/dist/op/activities/argo.js +98 -0
  226. package/dist/op/activities/ensure-secret.js +38 -0
  227. package/dist/op/activities/index.js +51 -0
  228. package/dist/op/activities/kubectl.d.ts +64 -0
  229. package/dist/op/activities/kubectl.d.ts.map +1 -1
  230. package/dist/op/activities/kubectl.js +517 -0
  231. package/dist/op/activities/wait-for-ready.js +229 -0
  232. package/dist/op/builders.js +68 -0
  233. package/dist/package-cli.js +21 -0
  234. package/dist/patch/json-patch.js +187 -0
  235. package/dist/patch/rendered-patch.js +94 -0
  236. package/dist/plugin.js +741 -0
  237. package/dist/project-kinds.js +56 -0
  238. package/dist/receipt-store.js +216 -0
  239. package/dist/rules/crd-schema-helpers.ts +2 -1
  240. package/dist/secret-labels.js +33 -0
  241. package/dist/secret-store.js +116 -0
  242. package/dist/serializer.d.ts.map +1 -1
  243. package/dist/serializer.js +578 -0
  244. package/dist/sops/encrypted-secret-file.js +194 -0
  245. package/dist/sops/entity.js +32 -0
  246. package/dist/spec/fetch.js +58 -0
  247. package/dist/spec/parse.js +622 -0
  248. package/dist/subscribe-changes.js +168 -0
  249. package/dist/teardown.js +197 -0
  250. package/dist/telemetry-release.js +86 -0
  251. package/dist/validate-cli.js +17 -0
  252. package/dist/validate.js +47 -0
  253. package/dist/variables.js +34 -0
  254. package/dist/whoami.js +109 -0
  255. package/package.json +12 -12
  256. package/src/api/operation-surface.ts +2 -1
  257. package/src/codegen/package.ts +2 -2
  258. package/src/components/index.ts +3 -0
  259. package/src/components/kubectl-apply-plan.k3d.e2e.test.ts +127 -0
  260. package/src/components/kubectl-apply.test.ts +262 -0
  261. package/src/components/kubectl-apply.ts +341 -12
  262. package/src/deep-observe-hooks.ts +2 -2
  263. package/src/import/roundtrip-build.test.ts +46 -0
  264. package/src/lint/post-synth/crd-schema-helpers.ts +2 -1
  265. package/src/lsp/completions.ts +2 -1
  266. package/src/lsp/hover.ts +2 -1
  267. package/src/op/activities/apply-diff.ts +87 -0
  268. package/src/op/activities/kubectl-plan.test.ts +224 -0
  269. package/src/op/activities/kubectl.ts +204 -16
  270. package/src/serializer.ts +2 -1
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The k8s change signal, `LexiconPlugin.subscribeChanges` (chant #1981).
3
+ *
4
+ * Kubernetes is the one substrate chant reaches where a change stream is
5
+ * complete, trustworthy, and needs nothing deployed into the cluster being
6
+ * observed: the Watch API is served for every kind the API server serves, is
7
+ * `resourceVersion`-based so a reconnect has a defined resume point, and is
8
+ * authorized by the same read credentials `describeResources` already uses.
9
+ * Every cloud substrate fails on that last point. Subscribing to EventBridge,
10
+ * Cloud Asset Inventory or Event Grid means writing infrastructure into the
11
+ * account being watched, which inverts the property that makes a read-only
12
+ * watch safe to point at production. The verdict table in the operator guide
13
+ * records that per lexicon; this file is the one place it came out `yes`.
14
+ *
15
+ * ## What this is allowed to conclude: nothing
16
+ *
17
+ * A watch event never becomes an observation. The frames are read, and then
18
+ * discarded. The only thing that leaves this module is a no-argument
19
+ * `onChange()`, which wakes an operator tick that re-observes the estate from
20
+ * scratch through the ordinary read path. There is no code here that could
21
+ * turn a `DELETED` frame into a proposed `create`, because there is no channel
22
+ * from a frame to anything but a function call with no parameters.
23
+ *
24
+ * That is also why a missed event costs nothing. A `410 Gone`, a dropped
25
+ * connection, a subscription that never got established: all of them slow
26
+ * detection back to the operator's timer, and none of them make the estate
27
+ * read as clean.
28
+ *
29
+ * ## Scope
30
+ *
31
+ * One watch per (kind, namespace) the declared entities name. The kinds come
32
+ * from the same generated operation surface `describeResources` addresses
33
+ * entities through; the namespaces come from the declarations themselves,
34
+ * falling back to the client's own default for a namespaced entity that
35
+ * declares none. Nothing widens that: a project declaring three Deployments in
36
+ * one namespace opens one connection, not a cluster-wide firehose.
37
+ */
38
+ import { defaultK8sConnector } from "./api/connect.js";
39
+ import { operationFor } from "./api/operation-surface.js";
40
+ /**
41
+ * The most connections one subscription will hold open.
42
+ *
43
+ * A watch is a long-lived HTTP/2 stream against the API server, and one per
44
+ * (kind, namespace) is cheap right up until an estate declares eighty kinds
45
+ * across a dozen namespaces. Past this ceiling the honest move is to refuse
46
+ * the whole subscription and say so, rather than to open some arbitrary
47
+ * prefix of it: a partial watch is a signal that goes quiet for exactly the
48
+ * resources nobody chose to drop. The operator then runs on its timer, which
49
+ * is what it did before this existed.
50
+ */
51
+ export const MAX_WATCHES = 32;
52
+ /**
53
+ * The distinct (kind, namespace) pairs a declared estate implies.
54
+ *
55
+ * Deterministic order, so a refusal past {@link MAX_WATCHES} names the same
56
+ * scope every time and a test can assert on it.
57
+ */
58
+ export function watchTargets(entities, defaultNamespace) {
59
+ const byKey = new Map();
60
+ const unaddressable = new Set();
61
+ for (const [, entity] of entities) {
62
+ const operation = operationFor(entity.entityType);
63
+ if (!operation) {
64
+ // chant knows no API address for this type, the same hole
65
+ // `describeResources` reports as `unsupported-kind`. Nothing to watch,
66
+ // and never a reason to widen to something else.
67
+ unaddressable.add(entity.entityType);
68
+ continue;
69
+ }
70
+ const declared = entity.props.metadata?.namespace;
71
+ const namespace = operation.scope === "Namespaced" ? (declared ?? defaultNamespace) : undefined;
72
+ const key = `${operation.apiVersion}|${operation.kind}|${namespace ?? ""}`;
73
+ if (!byKey.has(key)) {
74
+ byKey.set(key, {
75
+ apiVersion: operation.apiVersion,
76
+ kind: operation.kind,
77
+ ...(namespace ? { namespace } : {}),
78
+ });
79
+ }
80
+ }
81
+ return {
82
+ targets: [...byKey.values()].sort((a, b) => `${a.apiVersion}|${a.kind}|${a.namespace ?? ""}`.localeCompare(`${b.apiVersion}|${b.kind}|${b.namespace ?? ""}`)),
83
+ unaddressable: [...unaddressable].sort(),
84
+ };
85
+ }
86
+ /** Human phrasing of one target, for a log line. */
87
+ function targetText(target) {
88
+ return `${target.apiVersion} ${target.kind}${target.namespace ? ` in ${target.namespace}` : ""}`;
89
+ }
90
+ /**
91
+ * Open one watch per declared (kind, namespace) and report every event as a
92
+ * bare `onChange()`.
93
+ *
94
+ * Throws only for a failure that makes the whole subscription impossible: no
95
+ * entities in scope, a cluster binding that will not resolve, a scope past the
96
+ * ceiling. The operator turns that into one logged line and keeps polling.
97
+ * Once the subscription is live, nothing throws: a watch that dies reports
98
+ * through `onError` and the operator re-subscribes on its next round.
99
+ */
100
+ export async function subscribeChanges(options, connect = defaultK8sConnector) {
101
+ const entities = options.entities;
102
+ if (!entities || entities.size === 0) {
103
+ throw new Error("no declared k8s entities in scope. A change signal watches what the project declares, and there is nothing to watch");
104
+ }
105
+ // The same connect path the read takes, so the cluster a signal comes from
106
+ // is the cluster a tick would read. A binding that refuses, refuses here.
107
+ const { client } = await connect({
108
+ environment: options.environment,
109
+ cwd: options.cwd,
110
+ });
111
+ const { targets, unaddressable } = watchTargets(entities, client.defaultNamespace);
112
+ if (unaddressable.length > 0) {
113
+ // A hole in the signal, reported the way a hole in an observation is:
114
+ // named, not swallowed. The timer still covers these kinds.
115
+ options.onError?.(`no API address for ${unaddressable.join(", ")}, so changes to those kinds are found on the timer alone`);
116
+ }
117
+ if (targets.length === 0) {
118
+ throw new Error("no watchable k8s kinds among the declared entities");
119
+ }
120
+ if (targets.length > MAX_WATCHES) {
121
+ throw new Error(`the declared estate needs ${targets.length} watch connections, past the ${MAX_WATCHES} ceiling, so it is ` +
122
+ "running on the operator's timer rather than opening a partial watch that would go quiet for the rest");
123
+ }
124
+ const handles = [];
125
+ /** Reported once for the whole subscription: the operator re-subscribes as a whole. */
126
+ let reported = false;
127
+ const reportOnce = (message) => {
128
+ if (reported)
129
+ return;
130
+ reported = true;
131
+ options.onError?.(message);
132
+ };
133
+ const stopOnAbort = () => {
134
+ void closeAll();
135
+ };
136
+ let closing;
137
+ const closeAll = () => {
138
+ if (!closing) {
139
+ options.signal.removeEventListener("abort", stopOnAbort);
140
+ const open = handles.splice(0, handles.length);
141
+ closing = Promise.allSettled(open.map((h) => h.close())).then(() => undefined);
142
+ }
143
+ return closing;
144
+ };
145
+ try {
146
+ for (const target of targets) {
147
+ handles.push(await client.watch({ apiVersion: target.apiVersion, kind: target.kind }, {
148
+ ...(target.namespace ? { namespace: target.namespace } : {}),
149
+ signal: options.signal,
150
+ // The whole consumption of a watch event, in one line: it happened,
151
+ // so look again. The frame is not read, not stored, not passed on.
152
+ onEvent: () => options.onChange(),
153
+ onError: (message) => reportOnce(`watch on ${targetText(target)} ended: ${message}`),
154
+ }));
155
+ }
156
+ }
157
+ catch (err) {
158
+ // A partial open is not a subscription. Unwind what did open, and let the
159
+ // operator report one failure and keep its timer.
160
+ await closeAll();
161
+ throw err;
162
+ }
163
+ if (options.signal.aborted)
164
+ await closeAll();
165
+ else
166
+ options.signal.addEventListener("abort", stopOnAbort, { once: true });
167
+ return { close: closeAll };
168
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Env teardown for the k8s lexicon (chant #1222) — both halves of the
3
+ * `teardownOwned` / `executeTeardown` capability pair.
4
+ *
5
+ * Selection is the prune's marker selector (op/activities/kubectl.ts) with the
6
+ * env dimension added: `app.kubernetes.io/managed-by=chant` +
7
+ * `chant.intentius.io/stack=<stack>` + `chant.intentius.io/env=<env>`, sent to
8
+ * the server as a label selector and re-checked object by object with
9
+ * `readOwnership` — the server-side filter narrows the read, the client-side
10
+ * re-check is the one that matters, because a delete is not undoable.
11
+ *
12
+ * Swept kinds are chant's default sweep set (`DEFAULT_IMPORT_TYPES`, the same
13
+ * list the ownership-scoped prune unions in), listed cluster-wide: env
14
+ * teardown has no apply set to scope namespaces by, and the marker selector is
15
+ * the scope. A kind the cluster's discovery does not serve cannot hold a
16
+ * marked object and is skipped; a kind that resolves but fails to list is a
17
+ * hole (#1089) — unknown, never absent.
18
+ *
19
+ * Execution deletes the handed-over candidates through the typed client's own
20
+ * `delete()` — the identical delete the prune and `chant kube delete` use —
21
+ * with namespaces last, so a namespace's own members get individual outcomes
22
+ * before the cascade would have swallowed them. Before each delete the live
23
+ * object is re-read and its marker re-verified; an object that vanished is
24
+ * `deleted` (teardown is idempotent), one whose identity no longer matches is
25
+ * `not-prunable`, never deleted.
26
+ */
27
+ import { LABEL_OWNERSHIP_KEYS, OWNERSHIP_MANAGED_BY_VALUE, readOwnership, } from "@intentius/chant/ownership";
28
+ import { DEFAULT_IMPORT_TYPES } from "./api/sweep-types.js";
29
+ import { operationFor } from "./api/operation-surface.js";
30
+ import { defaultK8sConnector } from "./api/connect.js";
31
+ import { isGeneratedOnce } from "./secret-labels.js";
32
+ const NAMESPACE_TYPE = "K8s::Core::Namespace";
33
+ /** The label selector that scopes everything here: managed-by + stack + env. */
34
+ function markerSelector(marker) {
35
+ return [
36
+ `${LABEL_OWNERSHIP_KEYS.managedBy}=${OWNERSHIP_MANAGED_BY_VALUE}`,
37
+ `${LABEL_OWNERSHIP_KEYS.stack}=${marker.stack}`,
38
+ ...(marker.env ? [`${LABEL_OWNERSHIP_KEYS.env}=${marker.env}`] : []),
39
+ ].join(",");
40
+ }
41
+ /** True when a live object's own labels carry exactly the requested identity. */
42
+ function matchesMarker(labels, marker) {
43
+ const read = readOwnership(labels, LABEL_OWNERSHIP_KEYS);
44
+ return read !== undefined && read.stack === marker.stack && read.env === marker.env;
45
+ }
46
+ /**
47
+ * Enumerate the cluster's marker-matching objects — the k8s half of
48
+ * `chant lifecycle teardown <env>` planning.
49
+ */
50
+ export async function teardownOwned(options, connect = defaultK8sConnector) {
51
+ const { client } = await connect({ environment: options.environment });
52
+ const selector = markerSelector(options.marker);
53
+ const candidates = [];
54
+ const holes = [];
55
+ for (const entityType of DEFAULT_IMPORT_TYPES) {
56
+ const operation = operationFor(entityType);
57
+ if (!operation)
58
+ continue;
59
+ const ref = { apiVersion: operation.apiVersion, kind: operation.kind };
60
+ let served;
61
+ try {
62
+ served = (await client.resolve(ref)) !== undefined;
63
+ }
64
+ catch (err) {
65
+ holes.push({
66
+ name: entityType,
67
+ type: entityType,
68
+ reason: "read-failed",
69
+ detail: err instanceof Error ? err.message : String(err),
70
+ });
71
+ continue;
72
+ }
73
+ // A kind this cluster's discovery does not serve cannot hold a marked
74
+ // object — absence of the kind is knowledge, not a hole.
75
+ if (!served)
76
+ continue;
77
+ let items;
78
+ try {
79
+ items = await client.list(ref, { labelSelector: selector });
80
+ }
81
+ catch (err) {
82
+ holes.push({
83
+ name: entityType,
84
+ type: entityType,
85
+ reason: "read-failed",
86
+ detail: err instanceof Error ? err.message : String(err),
87
+ });
88
+ continue;
89
+ }
90
+ for (const item of items) {
91
+ const name = item.metadata?.name;
92
+ if (!name)
93
+ continue;
94
+ // The re-check that matters: a server (or fake) that ignored the label
95
+ // selector must not widen the set past the requested identity.
96
+ if (!matchesMarker(item.metadata?.labels, options.marker))
97
+ continue;
98
+ // Already terminating — deleting again is noise, not progress.
99
+ if (item.metadata?.deletionTimestamp !== undefined)
100
+ continue;
101
+ const namespace = item.metadata?.namespace;
102
+ candidates.push({
103
+ name: namespace ? `${namespace}/${name}` : name,
104
+ type: entityType,
105
+ ...(item.metadata?.uid ? { physicalId: String(item.metadata.uid) } : {}),
106
+ marker: readOwnership(item.metadata?.labels, LABEL_OWNERSHIP_KEYS),
107
+ });
108
+ }
109
+ }
110
+ return { candidates, ...(holes.length > 0 ? { holes } : {}) };
111
+ }
112
+ /**
113
+ * Delete the handed-over candidates, namespaces last. One outcome per
114
+ * candidate, always.
115
+ */
116
+ export async function executeTeardown(options, connect = defaultK8sConnector) {
117
+ const { client } = await connect({ environment: options.environment });
118
+ // Namespaces last: their members get individual deletes (and individual
119
+ // outcomes) before the namespace cascade sweeps whatever is left.
120
+ const ordered = [
121
+ ...options.candidates.filter((c) => c.type !== NAMESPACE_TYPE),
122
+ ...options.candidates.filter((c) => c.type === NAMESPACE_TYPE),
123
+ ];
124
+ const outcomes = [];
125
+ for (const candidate of ordered) {
126
+ outcomes.push(await deleteCandidate(client, candidate, options.marker));
127
+ }
128
+ return { outcomes };
129
+ }
130
+ async function deleteCandidate(client, candidate, marker) {
131
+ const base = {
132
+ name: candidate.name,
133
+ type: candidate.type,
134
+ ...(candidate.physicalId ? { physicalId: candidate.physicalId } : {}),
135
+ };
136
+ const operation = operationFor(candidate.type);
137
+ if (!operation) {
138
+ return { ...base, outcome: "not-prunable", detail: `no operation mapping for type ${candidate.type}` };
139
+ }
140
+ let info;
141
+ try {
142
+ info = await client.resolve({ apiVersion: operation.apiVersion, kind: operation.kind });
143
+ }
144
+ catch (err) {
145
+ return { ...base, outcome: "failed", detail: err instanceof Error ? err.message : String(err) };
146
+ }
147
+ if (!info) {
148
+ return { ...base, outcome: "not-prunable", detail: `the cluster does not serve ${operation.kind}` };
149
+ }
150
+ const slash = candidate.name.indexOf("/");
151
+ const namespace = info.namespaced && slash > 0 ? candidate.name.slice(0, slash) : undefined;
152
+ const name = info.namespaced && slash > 0 ? candidate.name.slice(slash + 1) : candidate.name;
153
+ const ref = {
154
+ apiVersion: operation.apiVersion,
155
+ kind: operation.kind,
156
+ name,
157
+ ...(namespace !== undefined ? { namespace } : {}),
158
+ };
159
+ // Re-read and re-verify the identity right before deleting: the enumeration
160
+ // is a moment old at best, and a delete is not undoable.
161
+ let live;
162
+ try {
163
+ live = await client.readIfPresent(ref);
164
+ }
165
+ catch (err) {
166
+ return { ...base, outcome: "failed", detail: err instanceof Error ? err.message : String(err) };
167
+ }
168
+ if (live === undefined) {
169
+ return { ...base, outcome: "deleted", detail: "already absent" };
170
+ }
171
+ if (!matchesMarker(live.metadata?.labels, marker)) {
172
+ return {
173
+ ...base,
174
+ outcome: "not-prunable",
175
+ detail: "the live object no longer carries the requested marker identity",
176
+ };
177
+ }
178
+ // A generated-once Secret survives every sweep, env teardown included
179
+ // (#1830, epic #1365 decision 5): the stored bytes are the only copy of
180
+ // material chant never held. `retained` is the loud keep — the row says the
181
+ // env is not clean and why. Deletion is an explicit act (`kubectl delete
182
+ // secret <name>`, or a future gated op), never a teardown's.
183
+ if (operation.kind === "Secret" && isGeneratedOnce(live.metadata?.labels)) {
184
+ return {
185
+ ...base,
186
+ outcome: "retained",
187
+ detail: "generated-once secret — deliberately kept, never swept; delete it explicitly (kubectl delete) if you mean to",
188
+ };
189
+ }
190
+ try {
191
+ await client.delete(ref);
192
+ }
193
+ catch (err) {
194
+ return { ...base, outcome: "failed", detail: err instanceof Error ? err.message : String(err) };
195
+ }
196
+ return { ...base, outcome: "deleted" };
197
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Where a workload keeps its pod spec, and the release attributes a deploy
3
+ * writes onto it (#3059, #3061, ws-081).
4
+ *
5
+ * The build stamps each container of a workload with `OTEL_SERVICE_NAME` and
6
+ * `OTEL_RESOURCE_ATTRIBUTES` (the serializer), and with
7
+ * `CHANT_RELEASE_ATTRIBUTES` read through the downward API from the pod
8
+ * annotation `chant.intentius.io/release-attributes`, which
9
+ * `OTEL_RESOURCE_ATTRIBUTES` ends with as `$(CHANT_RELEASE_ATTRIBUTES)`. An
10
+ * absent annotation reads as empty, so the built manifest works as it is. A
11
+ * release deploy (`kubectl-apply` with a release on its context) sets the
12
+ * annotation on the pod template of each stamped workload in what it applies,
13
+ * leaving the committed manifest the same bytes.
14
+ *
15
+ * Kept free of the API client and the serializer so the capability module can
16
+ * import it without loading either.
17
+ */
18
+ import { RELEASE_ATTRIBUTES_ANNOTATION, RELEASE_ATTRIBUTES_VARIABLE } from "@intentius/chant/telemetry-attribution";
19
+ /** Where each workload kind keeps its pod spec. */
20
+ export const POD_SPEC_PATHS = {
21
+ Pod: ["spec"],
22
+ Deployment: ["spec", "template", "spec"],
23
+ StatefulSet: ["spec", "template", "spec"],
24
+ DaemonSet: ["spec", "template", "spec"],
25
+ ReplicaSet: ["spec", "template", "spec"],
26
+ Job: ["spec", "template", "spec"],
27
+ CronJob: ["spec", "jobTemplate", "spec", "template", "spec"],
28
+ };
29
+ /** The core API groups those kinds live in; a CRD that reuses a kind name is not a workload. */
30
+ const WORKLOAD_API_VERSIONS = new Set(["v1", "apps/v1", "batch/v1"]);
31
+ function isObj(value) {
32
+ return typeof value === "object" && value !== null && !Array.isArray(value);
33
+ }
34
+ function at(value, path) {
35
+ let cur = value;
36
+ for (const key of path) {
37
+ if (!isObj(cur))
38
+ return undefined;
39
+ cur = cur[key];
40
+ }
41
+ return cur;
42
+ }
43
+ /** A workload's pod spec, or undefined for anything else. */
44
+ export function podSpecOf(manifest) {
45
+ const path = POD_SPEC_PATHS[manifest.kind];
46
+ if (!path || !WORKLOAD_API_VERSIONS.has(manifest.apiVersion))
47
+ return undefined;
48
+ const spec = at(manifest, path);
49
+ return isObj(spec) ? spec : undefined;
50
+ }
51
+ /** The env entry that reads the release attributes from the pod annotation. */
52
+ export function releaseAttributesEnv() {
53
+ return {
54
+ name: RELEASE_ATTRIBUTES_VARIABLE,
55
+ valueFrom: { fieldRef: { fieldPath: `metadata.annotations['${RELEASE_ATTRIBUTES_ANNOTATION}']` } },
56
+ };
57
+ }
58
+ /** True when some container of the workload reads the release attributes, i.e. the build stamped it. */
59
+ function readsRelease(podSpec) {
60
+ const containers = podSpec.containers;
61
+ if (!Array.isArray(containers))
62
+ return false;
63
+ return containers.some((c) => isObj(c) && Array.isArray(c.env) && c.env.some((e) => isObj(e) && e.name === RELEASE_ATTRIBUTES_VARIABLE && isObj(e.valueFrom)));
64
+ }
65
+ /**
66
+ * Set the release-attributes annotation on the pod template (or the Pod) of
67
+ * every stamped workload in `documents`, in place, and return how many it
68
+ * set. A workload the build did not stamp is left alone.
69
+ */
70
+ export function annotateRelease(documents, suffix) {
71
+ let count = 0;
72
+ for (const doc of documents) {
73
+ const podSpec = podSpecOf(doc);
74
+ if (!podSpec || !readsRelease(podSpec))
75
+ continue;
76
+ const path = POD_SPEC_PATHS[doc.kind];
77
+ const owner = path.length === 1 ? doc : at(doc, path.slice(0, -1));
78
+ if (!isObj(owner))
79
+ continue;
80
+ const metadata = isObj(owner.metadata) ? owner.metadata : {};
81
+ const annotations = isObj(metadata.annotations) ? metadata.annotations : {};
82
+ owner.metadata = { ...metadata, annotations: { ...annotations, [RELEASE_ATTRIBUTES_ANNOTATION]: suffix } };
83
+ count++;
84
+ }
85
+ return count;
86
+ }
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * CLI entry point for Kubernetes lexicon validation.
4
+ */
5
+ import { validate } from "./validate.js";
6
+ import { printValidationResult } from "@intentius/chant/codegen/validate";
7
+ async function main() {
8
+ const result = await validate();
9
+ printValidationResult(result);
10
+ if (!result.success) {
11
+ process.exit(1);
12
+ }
13
+ }
14
+ main().catch((err) => {
15
+ console.error("Validation failed:", err);
16
+ process.exit(1);
17
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Validate generated lexicon-k8s artifacts.
3
+ *
4
+ * Thin wrapper around the core validation framework
5
+ * with Kubernetes-specific configuration.
6
+ */
7
+ import { dirname } from "path";
8
+ import { fileURLToPath } from "url";
9
+ import { validateLexiconArtifacts } from "@intentius/chant/codegen/validate";
10
+ const REQUIRED_NAMES = [
11
+ // Core resources
12
+ "Pod", "Service", "ConfigMap", "Secret", "Namespace",
13
+ "ServiceAccount", "PersistentVolume", "PersistentVolumeClaim",
14
+ // Apps
15
+ "Deployment", "StatefulSet", "DaemonSet", "ReplicaSet",
16
+ // Batch
17
+ "Job", "CronJob",
18
+ // Networking
19
+ "Ingress", "NetworkPolicy",
20
+ // RBAC
21
+ "Role", "ClusterRole", "RoleBinding", "ClusterRoleBinding",
22
+ // Autoscaling
23
+ "HorizontalPodAutoscaler",
24
+ // Policy
25
+ "PodDisruptionBudget",
26
+ // Property types
27
+ "Container", "Volume", "VolumeMount", "EnvVar", "ServicePort",
28
+ "Probe", "ResourceRequirements", "SecurityContext",
29
+ ];
30
+ /**
31
+ * Validate the generated lexicon-k8s artifacts.
32
+ */
33
+ export async function validate(opts) {
34
+ const basePath = opts?.basePath ?? dirname(dirname(fileURLToPath(import.meta.url)));
35
+ return validateLexiconArtifacts({
36
+ lexiconJsonFilename: "lexicon-k8s.json",
37
+ requiredNames: REQUIRED_NAMES,
38
+ basePath,
39
+ // chant #1475 — the baseline was re-baselined with the k3s CRD work
40
+ // (#1605). k8s generates from a pinned kubernetes release tag plus CRDs
41
+ // vendored in this repo, so a fresh generate is deterministic and the
42
+ // surface can only move through a change here. Gate on every validate,
43
+ // not just under CHANT_RELEASE_GATE, so a CRD batch can no longer leave
44
+ // the snapshot behind the way #1319/#1320/#1321 did.
45
+ checkSurfaceSnapshot: "always",
46
+ });
47
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Kubernetes pseudo-parameters / well-known variables.
3
+ *
4
+ * Unlike AWS which has built-in pseudo-parameters (AWS::Region, etc.),
5
+ * Kubernetes doesn't have equivalent built-in variables. This module
6
+ * provides common label key constants for use in resource definitions.
7
+ */
8
+ /**
9
+ * Well-known Kubernetes label keys from the standard label taxonomy.
10
+ * These are string constants, not runtime-resolved pseudo-parameters.
11
+ */
12
+ export const K8sLabels = {
13
+ /** app.kubernetes.io/name */
14
+ AppName: "app.kubernetes.io/name",
15
+ /** app.kubernetes.io/instance */
16
+ AppInstance: "app.kubernetes.io/instance",
17
+ /** app.kubernetes.io/version */
18
+ AppVersion: "app.kubernetes.io/version",
19
+ /** app.kubernetes.io/component */
20
+ AppComponent: "app.kubernetes.io/component",
21
+ /** app.kubernetes.io/part-of */
22
+ AppPartOf: "app.kubernetes.io/part-of",
23
+ /** app.kubernetes.io/managed-by */
24
+ AppManagedBy: "app.kubernetes.io/managed-by",
25
+ };
26
+ /**
27
+ * Well-known Kubernetes annotation keys.
28
+ */
29
+ export const K8sAnnotations = {
30
+ /** kubernetes.io/change-cause */
31
+ ChangeCause: "kubernetes.io/change-cause",
32
+ /** kubernetes.io/description */
33
+ Description: "kubernetes.io/description",
34
+ };
package/dist/whoami.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Who chant would act as in a cluster — the lexicon half of `chant lifecycle
3
+ * whoami` (chant #1982).
4
+ *
5
+ * The connector already resolves all of this and hands it back on every
6
+ * observation: `defaultK8sConnector` picks a context from
7
+ * `k8s.profiles.<env>.context` or from the ambient kubeconfig, the client
8
+ * records the server it resolved and which credential path authorized it, and
9
+ * `describeResources` then throws the lot away. This reports it, and reports
10
+ * it BEFORE a read rather than after a wrong one.
11
+ *
12
+ * Same connector, same environment, same options, so the server named here is
13
+ * the server the read talks to. A test pins that: a whoami that names a
14
+ * cluster the read does not use would be worse than no whoami at all.
15
+ *
16
+ * ## The principal is the API server's answer, not the kubeconfig's
17
+ *
18
+ * A kubeconfig `user` entry is a local alias. The cluster may map that
19
+ * credential onto any subject it likes, which is exactly why "am I acting as
20
+ * the identity I think I am" is a real question. So the principal comes from
21
+ * `SelfSubjectReview` — the call behind `kubectl auth whoami` — which the API
22
+ * server answers with the subject it authenticated. It creates nothing.
23
+ *
24
+ * A cluster older than the review API serves no version of it. That is
25
+ * `read-failed` with a detail saying so, not a fallback to the local alias
26
+ * dressed up as an identity.
27
+ *
28
+ * ## No credential in the answer
29
+ *
30
+ * The credential path is reported by NAME — `exec-plugin` and its command,
31
+ * `token`, `client-certificate`. A kubeconfig holding a static bearer token is
32
+ * the case where the identity signal IS a secret, and the report says
33
+ * `credential: token` and stops there. The token, the client key and the exec
34
+ * plugin's output never leave the client.
35
+ */
36
+ import { defaultK8sConnector } from "./api/connect.js";
37
+ import { classifyApiFailure, isMissingClientPackage, MISSING_CLIENT_DETAIL } from "./api/classify.js";
38
+ /** Everything about the caller's credential except the credential. */
39
+ function credentialNote(credential, execCommand) {
40
+ return execCommand ? `credential ${credential} (${execCommand})` : `credential ${credential}`;
41
+ }
42
+ /**
43
+ * The k8s `describeIdentity`. Connects the way the read connects, asks the API
44
+ * server who it is talking to, and reports the context that binding came from.
45
+ */
46
+ export async function describeIdentity(options, connect = defaultK8sConnector) {
47
+ let connected;
48
+ try {
49
+ connected = await connect({ environment: options.environment, cwd: options.cwd });
50
+ }
51
+ catch (err) {
52
+ if (isMissingClientPackage(err)) {
53
+ return { unresolved: { reason: "read-failed", detail: MISSING_CLIENT_DETAIL } };
54
+ }
55
+ const outcome = classifyApiFailure(err);
56
+ return {
57
+ unresolved: outcome.kind === "unobserved"
58
+ ? { reason: outcome.reason, detail: outcome.detail }
59
+ : { reason: "no-binding", detail: err instanceof Error ? err.message : String(err) },
60
+ };
61
+ }
62
+ const { client, target } = connected;
63
+ const provenance = client.provenance;
64
+ const context = provenance.context ?? "(unset)";
65
+ const scope = `${context} ns=${client.defaultNamespace}`;
66
+ // Where the context came from, in the project's own vocabulary. An ambient
67
+ // context is not a failure — but it is the one an operator most often did
68
+ // not mean, so it says which binding is missing.
69
+ const binding = target.source === "bound"
70
+ ? `k8s.profiles.${options.environment}.context`
71
+ : `ambient kubeconfig current-context (no k8s.profiles.${options.environment} binding)`;
72
+ const source = [
73
+ binding,
74
+ credentialNote(provenance.credential, provenance.execCommand),
75
+ `kubeconfig ${provenance.kubeconfigSource}`,
76
+ ].join("; ");
77
+ let subject;
78
+ try {
79
+ subject = await client.selfSubjectReview();
80
+ }
81
+ catch (err) {
82
+ const outcome = classifyApiFailure(err);
83
+ return {
84
+ unresolved: {
85
+ reason: outcome.kind === "unobserved" ? outcome.reason : "read-failed",
86
+ detail: outcome.kind === "unobserved"
87
+ ? `SelfSubjectReview against ${provenance.server}: ${outcome.detail}`
88
+ : `SelfSubjectReview against ${provenance.server} did not answer`,
89
+ },
90
+ };
91
+ }
92
+ if (!subject) {
93
+ return {
94
+ unresolved: {
95
+ reason: "read-failed",
96
+ detail: `${provenance.server} serves no authentication.k8s.io SelfSubjectReview, so the API server ` +
97
+ `cannot be asked who it authenticated (the API landed in Kubernetes 1.26). The kubeconfig ` +
98
+ `context is "${context}" with ${credentialNote(provenance.credential, provenance.execCommand)}, ` +
99
+ `which names a local credential entry, not the subject the cluster resolves it to.`,
100
+ },
101
+ };
102
+ }
103
+ return {
104
+ identity: subject.username,
105
+ scope,
106
+ source,
107
+ endpoint: provenance.server,
108
+ };
109
+ }