@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,224 @@
1
+ /**
2
+ * `planManifest` (#3998): a dry-run apply of every declared document,
3
+ * compared with the live object, against the fake cluster.
4
+ *
5
+ * The fake API server below answers a `dryRun=All` apply the way a real one
6
+ * does in the respects the plan depends on: the live object merged with the
7
+ * applied fields, a field the server defaults filled in, and fresh
8
+ * bookkeeping (resource version, generation, managed fields) on every
9
+ * answer. Nothing it answers is persisted.
10
+ */
11
+
12
+ import { describe, test, expect } from "vitest";
13
+ import type { K8sObject } from "@intentius/chant-k8s-client";
14
+ import { statusBody, type RecordedRequest } from "@intentius/chant-k8s-client/testing";
15
+ import { fakeCluster, objectKey, ownedObject } from "../../api/fake-cluster";
16
+ import { planManifest } from "./kubectl";
17
+ import { diffFields, normalizeServerObject } from "./apply-diff";
18
+
19
+ type Obj = Record<string, any>;
20
+
21
+ function merge(base: Obj, patch: Obj): Obj {
22
+ const out: Obj = { ...base };
23
+ for (const [k, v] of Object.entries(patch)) {
24
+ out[k] = v && typeof v === "object" && !Array.isArray(v) && out[k] && typeof out[k] === "object" ? merge(out[k], v) : v;
25
+ }
26
+ return out;
27
+ }
28
+
29
+ /** The live objects, by API path. */
30
+ type Live = Record<string, Obj>;
31
+
32
+ /** A dry-run responder over `live`. `refuse` answers a path with a 422 instead. */
33
+ function dryRunServer(live: Live, refuse: Record<string, string> = {}, missingNamespaces: string[] = []) {
34
+ return (req: RecordedRequest) => {
35
+ if (req.method !== "PATCH") return undefined;
36
+ if (req.query.dryRun !== "All") throw new Error(`a plan sent a PATCH without dryRun=All: ${req.path}`);
37
+ const missing = missingNamespaces.find((ns) => req.path.includes(`/namespaces/${ns}/`));
38
+ if (missing) return { status: 404, body: statusBody(404, "NotFound", `namespaces "${missing}" not found`) };
39
+ if (refuse[req.path]) return { status: 422, body: statusBody(422, "Invalid", refuse[req.path]) };
40
+ const body = JSON.parse(String(req.body)) as Obj;
41
+ const before = live[req.path];
42
+ const result = merge(before ?? {}, body);
43
+ if (result.kind === "Deployment") result.spec = { revisionHistoryLimit: 10, ...(result.spec ?? {}) };
44
+ const ns = req.path.match(/\/namespaces\/([^/]+)\//)?.[1];
45
+ result.metadata = {
46
+ ...result.metadata,
47
+ ...(ns ? { namespace: ns } : {}),
48
+ uid: before?.metadata?.uid ?? "uid-dry-run",
49
+ resourceVersion: "99",
50
+ generation: (before?.metadata?.generation ?? 0) + 1,
51
+ creationTimestamp: before?.metadata?.creationTimestamp ?? "2026-10-19T00:00:00Z",
52
+ managedFields: [{ manager: req.query.fieldManager, operation: "Apply" }],
53
+ };
54
+ return { body: result };
55
+ };
56
+ }
57
+
58
+ const DEPLOY_PATH = "/apis/apps/v1/namespaces/prod/deployments/web";
59
+
60
+ const declared = (replicas: number): Obj => ({
61
+ apiVersion: "apps/v1",
62
+ kind: "Deployment",
63
+ metadata: { name: "web", namespace: "prod" },
64
+ spec: { replicas },
65
+ });
66
+
67
+ /** The live Deployment as the server holds it after chant applied `replicas`. */
68
+ const liveWeb = (replicas: number): K8sObject =>
69
+ ({
70
+ ...ownedObject("apps/v1", "Deployment", "web", "prod", {
71
+ metadata: {
72
+ name: "web",
73
+ namespace: "prod",
74
+ labels: { "app.kubernetes.io/managed-by": "chant", "chant.intentius.io/stack": "web" },
75
+ generation: 4,
76
+ creationTimestamp: "2026-01-01T00:00:00Z",
77
+ managedFields: [{ manager: "chant:web", operation: "Apply" }],
78
+ } as never,
79
+ }),
80
+ spec: { replicas, revisionHistoryLimit: 10 },
81
+ status: { readyReplicas: replicas },
82
+ }) as K8sObject;
83
+
84
+ describe("planManifest (#3998)", () => {
85
+ test("an object with no live counterpart plans as a create, and nothing is persisted", async () => {
86
+ const cluster = fakeCluster({ respond: dryRunServer({}) });
87
+ const plan = await planManifest({ manifest: "k8s.yaml", documents: [declared(3)], stack: "web" }, undefined, cluster.connector);
88
+ expect(plan.fieldManager).toBe("chant:web");
89
+ expect(plan.objects).toHaveLength(1);
90
+ expect(plan.objects[0]).toMatchObject({
91
+ ref: { apiVersion: "apps/v1", kind: "Deployment", name: "web", namespace: "prod" },
92
+ action: "create",
93
+ changes: [],
94
+ });
95
+ expect(plan.objects[0].object?.metadata).toMatchObject({ labels: { "chant.intentius.io/stack": "web" } });
96
+ const patches = cluster.layer.requests.filter((r) => r.method === "PATCH");
97
+ expect(patches.map((p) => p.query)).toEqual([{ fieldManager: "chant:web", force: "false", dryRun: "All" }]);
98
+ expect(cluster.layer.requests.some((r) => r.method === "DELETE" || r.method === "POST" || r.method === "PUT")).toBe(false);
99
+ });
100
+
101
+ test("a manifest whose dry run equals the live object plans as no change, bookkeeping and defaults ignored", async () => {
102
+ const live = liveWeb(3);
103
+ const cluster = fakeCluster({
104
+ objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: live },
105
+ respond: dryRunServer({ [DEPLOY_PATH]: live as Obj }),
106
+ });
107
+ const plan = await planManifest({ manifest: "k8s.yaml", documents: [declared(3)], stack: "web" }, undefined, cluster.connector);
108
+ expect(plan.objects.map((o) => [o.action, o.changes])).toEqual([["no-op", []]]);
109
+ });
110
+
111
+ test("a changed field plans as an update naming the field, before and after", async () => {
112
+ const live = liveWeb(3);
113
+ const cluster = fakeCluster({
114
+ objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: live },
115
+ respond: dryRunServer({ [DEPLOY_PATH]: live as Obj }),
116
+ });
117
+ const plan = await planManifest({ manifest: "k8s.yaml", documents: [declared(5)], stack: "web" }, undefined, cluster.connector);
118
+ expect(plan.objects[0].action).toBe("update");
119
+ expect(plan.objects[0].changes).toEqual([{ path: "spec.replicas", before: 3, after: 5 }]);
120
+ });
121
+
122
+ test("a change the server refuses as immutable plans as an update carrying the refusal", async () => {
123
+ const live = liveWeb(3);
124
+ const message = 'Deployment.apps "web" is invalid: spec.selector: Invalid value: field is immutable';
125
+ const cluster = fakeCluster({
126
+ objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: live },
127
+ respond: dryRunServer({ [DEPLOY_PATH]: live as Obj }, { [DEPLOY_PATH]: message }),
128
+ });
129
+ const plan = await planManifest({ manifest: "k8s.yaml", documents: [declared(3)], stack: "web" }, undefined, cluster.connector);
130
+ expect(plan.objects[0]).toMatchObject({ action: "update", changes: [], refused: message });
131
+ });
132
+
133
+ test("any other refusal fails the plan, as it would fail the apply", async () => {
134
+ const cluster = fakeCluster({ respond: dryRunServer({}, { [DEPLOY_PATH]: "spec.replicas: Invalid value: -1" }) });
135
+ await expect(
136
+ planManifest({ manifest: "k8s.yaml", documents: [declared(-1)], stack: "web" }, undefined, cluster.connector),
137
+ ).rejects.toThrow(/Invalid value: -1/);
138
+ });
139
+
140
+ test("an object in a namespace the same plan creates plans as a create without a dry run of its own", async () => {
141
+ const cluster = fakeCluster({ respond: dryRunServer({}, {}, ["fresh"]) });
142
+ const plan = await planManifest(
143
+ {
144
+ manifest: "k8s.yaml",
145
+ documents: [
146
+ { apiVersion: "v1", kind: "Namespace", metadata: { name: "fresh" } },
147
+ { ...declared(1), metadata: { name: "web", namespace: "fresh" } },
148
+ ],
149
+ stack: "web",
150
+ },
151
+ undefined,
152
+ cluster.connector,
153
+ );
154
+ expect(plan.objects.map((o) => [o.ref.kind, o.action])).toEqual([["Namespace", "create"], ["Deployment", "create"]]);
155
+ });
156
+
157
+ test("a namespace that does not exist and is not created here fails the plan", async () => {
158
+ const cluster = fakeCluster({ respond: dryRunServer({}, {}, ["prod"]) });
159
+ await expect(
160
+ planManifest({ manifest: "k8s.yaml", documents: [declared(1)], stack: "web" }, undefined, cluster.connector),
161
+ ).rejects.toThrow(/namespaces "prod" not found/);
162
+ });
163
+
164
+ describe("deletes come from the apply's prune scope", () => {
165
+ const objects = () => ({
166
+ [objectKey("apps/v1", "Deployment", "web", "prod")]: liveWeb(3),
167
+ [objectKey("apps/v1", "Deployment", "old", "prod")]: ownedObject("apps/v1", "Deployment", "old", "prod"),
168
+ [objectKey("apps/v1", "Deployment", "handmade", "prod")]: {
169
+ apiVersion: "apps/v1",
170
+ kind: "Deployment",
171
+ metadata: { name: "handmade", namespace: "prod", labels: { app: "handmade" } },
172
+ },
173
+ [objectKey("apps/v1", "Deployment", "elsewhere", "staging")]: ownedObject("apps/v1", "Deployment", "elsewhere", "staging"),
174
+ [objectKey("v1", "Secret", "master-key", "prod")]: ownedObject("v1", "Secret", "master-key", "prod", {
175
+ metadata: { labels: { "app.kubernetes.io/managed-by": "chant", "chant.intentius.io/generated-once": "true" } },
176
+ }),
177
+ });
178
+
179
+ test("owned-only plans a delete for the owned, undeclared object in the touched namespace, and deletes nothing", async () => {
180
+ const cluster = fakeCluster({ objects: objects(), respond: dryRunServer({ [DEPLOY_PATH]: liveWeb(3) as Obj }) });
181
+ const plan = await planManifest(
182
+ { manifest: "k8s.yaml", documents: [declared(3)], stack: "web", deleteMode: "owned-only" },
183
+ undefined,
184
+ cluster.connector,
185
+ );
186
+ expect(plan.deletes).toEqual([{ apiVersion: "apps/v1", kind: "Deployment", name: "old", namespace: "prod" }]);
187
+ expect(plan.retained).toEqual([{ apiVersion: "v1", kind: "Secret", name: "master-key", namespace: "prod" }]);
188
+ expect(cluster.layer.requests.filter((r) => r.method === "DELETE")).toEqual([]);
189
+ });
190
+
191
+ test("never plans no deletes and lists nothing", async () => {
192
+ const cluster = fakeCluster({ objects: objects(), respond: dryRunServer({ [DEPLOY_PATH]: liveWeb(3) as Obj }) });
193
+ const plan = await planManifest({ manifest: "k8s.yaml", documents: [declared(3)], stack: "web" }, undefined, cluster.connector);
194
+ expect(plan.deletes).toEqual([]);
195
+ expect(cluster.layer.requests.filter((r) => r.method === "GET" && r.path.endsWith("/deployments"))).toEqual([]);
196
+ });
197
+ });
198
+ });
199
+
200
+ describe("apply-diff (#3998)", () => {
201
+ test("normalizing drops the server's bookkeeping and status, nothing else", () => {
202
+ const normalized = normalizeServerObject({
203
+ apiVersion: "v1",
204
+ kind: "ConfigMap",
205
+ metadata: { name: "c", labels: { a: "1" }, uid: "u", resourceVersion: "9", generation: 2, creationTimestamp: "t", managedFields: [] },
206
+ data: { k: "v" },
207
+ status: { phase: "x" },
208
+ });
209
+ expect(normalized).toEqual({ apiVersion: "v1", kind: "ConfigMap", metadata: { name: "c", labels: { a: "1" } }, data: { k: "v" } });
210
+ });
211
+
212
+ test("objects are walked to their leaves, lists compared whole, odd keys bracketed", () => {
213
+ const changes = diffFields(
214
+ { metadata: { labels: { "app.kubernetes.io/name": "a", keep: "x" } }, spec: { ports: [{ port: 80 }], gone: 1 } },
215
+ { metadata: { labels: { "app.kubernetes.io/name": "b", keep: "x" } }, spec: { ports: [{ port: 81 }], added: 2 } },
216
+ );
217
+ expect(changes).toEqual([
218
+ { path: 'metadata.labels["app.kubernetes.io/name"]', before: "a", after: "b" },
219
+ { path: "spec.added", after: 2 },
220
+ { path: "spec.gone", before: 1 },
221
+ { path: "spec.ports", before: [{ port: 80 }], after: [{ port: 81 }] },
222
+ ]);
223
+ });
224
+ });
@@ -46,6 +46,7 @@ import { operationFor } from "../../api/operation-surface";
46
46
  import { DEFAULT_IMPORT_TYPES } from "../../api/sweep-types";
47
47
  import { isGeneratedOnce } from "../../secret-labels";
48
48
  import { isEffectReceiptObject } from "../../effect-receipt-row";
49
+ import { diffFields, normalizeServerObject, type FieldChange } from "./apply-diff";
49
50
 
50
51
  /**
51
52
  * How the apply treats chant-owned objects that are no longer declared. The
@@ -362,6 +363,178 @@ export async function applyManifest(
362
363
  return { fieldManager, applied, pruned, retained };
363
364
  }
364
365
 
366
+ /** What a dry-run apply would do to one declared object (#3998). */
367
+ export interface PlannedObject {
368
+ ref: AppliedRef;
369
+ /** `create` when no live object exists, `no-op` when the dry run equals it, `update` otherwise. */
370
+ action: "create" | "update" | "no-op";
371
+ /**
372
+ * For a create, the document as the apply sends it (ownership stamped).
373
+ * Not the dry-run result: the server can fill in values a dry run picks
374
+ * afresh each time (a Service's cluster IP), which would make the plan
375
+ * differ from itself.
376
+ */
377
+ object?: Record<string, unknown>;
378
+ /** The fields an update changes, live against the dry-run result, both normalized. */
379
+ changes: FieldChange[];
380
+ /**
381
+ * The API server rejected the dry run of a change to a live object because
382
+ * it changes an immutable field. The apply does not delete and recreate,
383
+ * so it would fail the same way. The server's message.
384
+ */
385
+ refused?: string;
386
+ }
387
+
388
+ /** What {@link planManifest} found. */
389
+ export interface ManifestPlanResult {
390
+ fieldManager: string;
391
+ stack?: string;
392
+ objects: PlannedObject[];
393
+ /** What the apply's prune would delete: empty unless `deleteMode` deletes. */
394
+ deletes: AppliedRef[];
395
+ /** Owned, undeclared objects the prune keeps (#1830, #2074). */
396
+ retained: AppliedRef[];
397
+ }
398
+
399
+ /** A 404 from the API server, matched by shape. */
400
+ function isNotFound(err: unknown): boolean {
401
+ const e = err as { name?: unknown; statusCode?: unknown };
402
+ return !!e && e.name === "K8sApiError" && e.statusCode === 404;
403
+ }
404
+
405
+ /** A 422 from the API server whose message says a field is immutable. Matched by shape, as {@link isChantSelfConflict} is. */
406
+ function immutableRefusal(err: unknown): string | undefined {
407
+ const e = err as { name?: unknown; statusCode?: unknown; apiMessage?: unknown; message?: unknown };
408
+ if (!e || e.name !== "K8sApiError" || e.statusCode !== 422) return undefined;
409
+ const message = typeof e.apiMessage === "string" && e.apiMessage !== "" ? e.apiMessage : String(e.message ?? "");
410
+ return /immutable/i.test(message) ? message : undefined;
411
+ }
412
+
413
+ /**
414
+ * Say what {@link applyManifest} would do, without changing anything (#3998).
415
+ *
416
+ * Every document is stamped and dry-run applied exactly as the apply sends
417
+ * it: the same field manager, `force: false`, and the same retake of a
418
+ * conflict chant has only with itself. The live object is read first and
419
+ * compared with the dry-run result through `./apply-diff.ts`, so a field the
420
+ * server defaults counts as unchanged when the live object already has it.
421
+ *
422
+ * An object in a namespace an earlier document of the same plan creates
423
+ * cannot be dry-run (the server answers 404, as the namespace was never
424
+ * persisted), so it plans as a create of the document as sent.
425
+ *
426
+ * Deletes come from the apply's own prune scope (`findOrphans`): marker
427
+ * scoped, in the namespaces the apply set touches, generated-once Secrets
428
+ * and effect receipts kept. They are found only when `deleteMode` is not
429
+ * `never`, as the apply prunes only then.
430
+ */
431
+ export async function planManifest(
432
+ args: KubectlApplyArgs,
433
+ signal?: AbortSignal,
434
+ connect: K8sConnector = defaultK8sConnector,
435
+ ): Promise<ManifestPlanResult> {
436
+ const documents = args.documents ?? readManifestDocuments(args.manifest);
437
+ const { fieldManager, stack } = await resolveApplyIdentity(args);
438
+ const { client } = await connect({
439
+ ...(args.environment !== undefined ? { environment: args.environment } : {}),
440
+ ...(args.context !== undefined ? { context: args.context } : {}),
441
+ ...(args.cwd !== undefined ? { cwd: args.cwd } : {}),
442
+ });
443
+
444
+ const objects: PlannedObject[] = [];
445
+ /** Namespaces this plan creates. A dry run cannot place an object in one, since nothing persisted it. */
446
+ const creating = new Set<string>();
447
+ for (const document of documents) {
448
+ const stamped = stampOwnership(document as K8sObject, stack);
449
+ const declaredRef = {
450
+ apiVersion: String(stamped.apiVersion ?? ""),
451
+ kind: String(stamped.kind ?? ""),
452
+ name: String(stamped.metadata?.name ?? ""),
453
+ ...(stamped.metadata?.namespace !== undefined ? { namespace: String(stamped.metadata.namespace) } : {}),
454
+ };
455
+ const live = await client.readIfPresent(declaredRef, signal ? { signal } : {});
456
+ let result: K8sObject;
457
+ try {
458
+ try {
459
+ result = await client.apply(stamped, { fieldManager, force: args.force ?? false, dryRun: true, signal });
460
+ } catch (err) {
461
+ if (!isChantSelfConflict(err)) throw err;
462
+ result = await client.apply(stamped, { fieldManager, force: true, dryRun: true, signal });
463
+ }
464
+ } catch (err) {
465
+ if (!live && declaredRef.namespace !== undefined && creating.has(declaredRef.namespace) && isNotFound(err)) {
466
+ objects.push({ ref: declaredRef, action: "create", object: stamped as Record<string, unknown>, changes: [] });
467
+ continue;
468
+ }
469
+ const refused = live ? immutableRefusal(err) : undefined;
470
+ if (refused === undefined) throw err;
471
+ objects.push({ ref: refOf(live!, document), action: "update", changes: [], refused });
472
+ continue;
473
+ }
474
+ const ref = refOf(result, document);
475
+ if (!live) {
476
+ if (ref.kind === "Namespace" && ref.apiVersion === "v1") creating.add(ref.name);
477
+ objects.push({ ref, action: "create", object: stamped as Record<string, unknown>, changes: [] });
478
+ continue;
479
+ }
480
+ const changes = diffFields(
481
+ normalizeServerObject(live as Record<string, unknown>),
482
+ normalizeServerObject(result as Record<string, unknown>),
483
+ );
484
+ objects.push({ ref, action: changes.length === 0 ? "no-op" : "update", changes });
485
+ }
486
+
487
+ const deleteMode = args.deleteMode ?? "never";
488
+ const { candidates, retained } =
489
+ deleteMode === "never"
490
+ ? { candidates: [], retained: [] }
491
+ : await findOrphans(
492
+ client,
493
+ objects.map((o) => o.ref),
494
+ { ...(stack !== undefined ? { stack } : {}), ...(signal ? { signal } : {}) },
495
+ );
496
+ return { fieldManager, ...(stack !== undefined ? { stack } : {}), objects, deletes: candidates, retained };
497
+ }
498
+
499
+ /** Where {@link readLiveObjects} reads, and which objects. */
500
+ export interface ReadLiveObjectsArgs {
501
+ refs: AppliedRef[];
502
+ environment?: string;
503
+ context?: string;
504
+ cwd?: string;
505
+ }
506
+
507
+ /**
508
+ * Read each of `refs` as the cluster holds it now, `undefined` for one that
509
+ * does not exist (#3999). `kubectl-apply` reads the fields its step names as
510
+ * outputs from these.
511
+ */
512
+ export async function readLiveObjects(
513
+ args: ReadLiveObjectsArgs,
514
+ signal?: AbortSignal,
515
+ connect: K8sConnector = defaultK8sConnector,
516
+ ): Promise<Array<Record<string, unknown> | undefined>> {
517
+ if (args.refs.length === 0) return [];
518
+ const { client } = await connect({
519
+ ...(args.environment !== undefined ? { environment: args.environment } : {}),
520
+ ...(args.context !== undefined ? { context: args.context } : {}),
521
+ ...(args.cwd !== undefined ? { cwd: args.cwd } : {}),
522
+ });
523
+ const out: Array<Record<string, unknown> | undefined> = [];
524
+ for (const ref of args.refs) out.push((await client.readIfPresent(ref, signal ? { signal } : {})) as Record<string, unknown> | undefined);
525
+ return out;
526
+ }
527
+
528
+ /** The ref an applied or dry-run object answers with, as {@link applyManifest} records it. */
529
+ function refOf(result: K8sObject, document: Record<string, unknown>): AppliedRef {
530
+ return {
531
+ apiVersion: String(result.apiVersion ?? document.apiVersion ?? ""),
532
+ kind: String(result.kind ?? document.kind ?? ""),
533
+ name: String(result.metadata?.name ?? ""),
534
+ ...(result.metadata?.namespace !== undefined ? { namespace: String(result.metadata.namespace) } : {}),
535
+ };
536
+ }
537
+
365
538
  /**
366
539
  * Apply every document in `args.manifest`.
367
540
  * Uses the longInfra profile: 20m timeout, three attempts backing off from 30s.
@@ -405,6 +578,36 @@ async function pruneOrphans(
405
578
  applied: readonly AppliedRef[],
406
579
  options: { stack?: string; signal?: AbortSignal },
407
580
  ): Promise<{ pruned: AppliedRef[]; retained: AppliedRef[] }> {
581
+ const { candidates, retained } = await findOrphans(client, applied, options);
582
+ for (const ref of retained) {
583
+ console.log(
584
+ ref.kind === "Secret"
585
+ ? `${ref.apiVersion} ${ref.kind}/${ref.name} retained (generated-once — owned and no longer declared, ` +
586
+ `but never pruned; delete it explicitly if you mean to)`
587
+ : `${ref.apiVersion} ${ref.kind}/${ref.name} retained (effect receipt: owned and never declared ` +
588
+ `in an apply set, but never pruned; the effect() step is its sole writer)`,
589
+ );
590
+ }
591
+ const pruned: AppliedRef[] = [];
592
+ for (const ref of candidates) {
593
+ await client.delete(ref, { ...(options.signal ? { signal: options.signal } : {}) });
594
+ pruned.push(ref);
595
+ console.log(`${ref.apiVersion} ${ref.kind}/${ref.name} pruned (chant-owned, no longer declared)`);
596
+ }
597
+ return { pruned, retained };
598
+ }
599
+
600
+ /**
601
+ * The objects {@link pruneOrphans} would delete (`candidates`) and the owned,
602
+ * undeclared ones it keeps (`retained`), found without deleting anything. A
603
+ * plan (#3998) asks this for its delete entries, so the plan and the apply
604
+ * share one prune scope.
605
+ */
606
+ async function findOrphans(
607
+ client: K8sClient,
608
+ applied: readonly AppliedRef[],
609
+ options: { stack?: string; signal?: AbortSignal },
610
+ ): Promise<{ candidates: AppliedRef[]; retained: AppliedRef[] }> {
408
611
  const stack = options.stack;
409
612
  const selector = [
410
613
  `${LABEL_OWNERSHIP_KEYS.managedBy}=${OWNERSHIP_MANAGED_BY_VALUE}`,
@@ -498,10 +701,6 @@ async function pruneOrphans(
498
701
  // an explicit act (`kubectl delete`, or a future gated op).
499
702
  if (target.kind === "Secret" && isGeneratedOnce(item.metadata?.labels)) {
500
703
  retained.push(ref);
501
- console.log(
502
- `${ref.apiVersion} ${ref.kind}/${ref.name} retained (generated-once — owned and no longer declared, ` +
503
- `but never pruned; delete it explicitly if you mean to)`,
504
- );
505
704
  continue;
506
705
  }
507
706
  // An effect receipt never enters the prunable set either (#2074, epic
@@ -512,22 +711,11 @@ async function pruneOrphans(
512
711
  // the estate re-running a migration on every apply.
513
712
  if (target.kind === "ConfigMap" && isEffectReceiptObject(item.metadata?.labels)) {
514
713
  retained.push(ref);
515
- console.log(
516
- `${ref.apiVersion} ${ref.kind}/${ref.name} retained (effect receipt: owned and never declared ` +
517
- `in an apply set, but never pruned; the effect() step is its sole writer)`,
518
- );
519
714
  continue;
520
715
  }
521
716
  candidates.push(ref);
522
717
  }
523
-
524
- const pruned: AppliedRef[] = [];
525
- for (const ref of candidates) {
526
- await client.delete(ref, { ...(options.signal ? { signal: options.signal } : {}) });
527
- pruned.push(ref);
528
- console.log(`${ref.apiVersion} ${ref.kind}/${ref.name} pruned (chant-owned, no longer declared)`);
529
- }
530
- return { pruned, retained };
718
+ return { candidates, retained };
531
719
  }
532
720
 
533
721
  function refKey(ref: AppliedRef): string {
package/src/serializer.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { createRequire } from "module";
9
+ import { moduleSourceUrl } from "@intentius/chant/module-source";
9
10
  import type { Declarable } from "@intentius/chant/declarable";
10
11
  import { isPropertyDeclarable, isResourceDeclarable } from "@intentius/chant/declarable";
11
12
  import type { Serializer, SerializerResult, SerializeContext } from "@intentius/chant/serializer";
@@ -37,7 +38,7 @@ import { isEncryptedSecretFileEntity } from "./sops/entity";
37
38
  import { isK8sInclude } from "./include/entity";
38
39
  import { isRuleGroup, ruleGroupConfig } from "@intentius/chant-lexicon-prometheus";
39
40
 
40
- const require = createRequire(import.meta.url);
41
+ const require = createRequire(moduleSourceUrl(import.meta.url));
41
42
 
42
43
  /**
43
44
  * K8s resource kinds whose properties live directly on the manifest