@intentius/chant-lexicon-k8s 0.46.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/dist/components/kubectl-apply.d.ts +5 -0
  2. package/dist/components/kubectl-apply.d.ts.map +1 -1
  3. package/dist/composites/flux-app.d.ts +12 -0
  4. package/dist/composites/flux-app.d.ts.map +1 -1
  5. package/dist/composites/index.d.ts +8 -0
  6. package/dist/composites/index.d.ts.map +1 -1
  7. package/dist/composites/inference-service.d.ts +86 -0
  8. package/dist/composites/inference-service.d.ts.map +1 -0
  9. package/dist/composites/model.d.ts +80 -0
  10. package/dist/composites/model.d.ts.map +1 -0
  11. package/dist/composites/operator-stack.d.ts +183 -0
  12. package/dist/composites/operator-stack.d.ts.map +1 -0
  13. package/dist/composites/vllm-serving-runtime.d.ts +100 -0
  14. package/dist/composites/vllm-serving-runtime.d.ts.map +1 -0
  15. package/dist/crd/crd-sources.d.ts.map +1 -1
  16. package/dist/deep-observe-hooks.d.ts.map +1 -1
  17. package/dist/generated/index.d.ts +167 -40
  18. package/dist/generated/index.d.ts.map +1 -1
  19. package/dist/group-namespace.d.ts.map +1 -1
  20. package/dist/index.d.ts +3 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/integrity.json +41 -33
  23. package/dist/kube/apply.d.ts.map +1 -1
  24. package/dist/lint/audit-catalog.d.ts.map +1 -1
  25. package/dist/lint/post-synth/argo-helpers.d.ts.map +1 -1
  26. package/dist/lint/post-synth/crd-schema-helpers.d.ts.map +1 -1
  27. package/dist/lint/post-synth/index.d.ts.map +1 -1
  28. package/dist/lint/post-synth/k8s-helpers.d.ts +56 -6
  29. package/dist/lint/post-synth/k8s-helpers.d.ts.map +1 -1
  30. package/dist/lint/post-synth/sops-helpers.d.ts +38 -0
  31. package/dist/lint/post-synth/sops-helpers.d.ts.map +1 -0
  32. package/dist/lint/post-synth/wk8005.d.ts.map +1 -1
  33. package/dist/lint/post-synth/wk8006.d.ts.map +1 -1
  34. package/dist/lint/post-synth/wk8041.d.ts.map +1 -1
  35. package/dist/lint/post-synth/wk8042.d.ts.map +1 -1
  36. package/dist/lint/post-synth/wk8101.d.ts.map +1 -1
  37. package/dist/lint/post-synth/wk8102.d.ts.map +1 -1
  38. package/dist/lint/post-synth/wk8103.d.ts.map +1 -1
  39. package/dist/lint/post-synth/wk8104.d.ts.map +1 -1
  40. package/dist/lint/post-synth/wk8105.d.ts.map +1 -1
  41. package/dist/lint/post-synth/wk8201.d.ts.map +1 -1
  42. package/dist/lint/post-synth/wk8202.d.ts.map +1 -1
  43. package/dist/lint/post-synth/wk8203.d.ts.map +1 -1
  44. package/dist/lint/post-synth/wk8204.d.ts.map +1 -1
  45. package/dist/lint/post-synth/wk8205.d.ts.map +1 -1
  46. package/dist/lint/post-synth/wk8207.d.ts.map +1 -1
  47. package/dist/lint/post-synth/wk8208.d.ts.map +1 -1
  48. package/dist/lint/post-synth/wk8209.d.ts.map +1 -1
  49. package/dist/lint/post-synth/wk8301.d.ts.map +1 -1
  50. package/dist/lint/post-synth/wk8302.d.ts.map +1 -1
  51. package/dist/lint/post-synth/wk8303.d.ts.map +1 -1
  52. package/dist/lint/post-synth/wk8304.d.ts.map +1 -1
  53. package/dist/lint/post-synth/wk8305.d.ts.map +1 -1
  54. package/dist/lint/post-synth/wk8306.d.ts.map +1 -1
  55. package/dist/lint/post-synth/wk8401.d.ts.map +1 -1
  56. package/dist/lint/post-synth/wk8402.d.ts.map +1 -1
  57. package/dist/lint/post-synth/wk8403.d.ts.map +1 -1
  58. package/dist/lint/post-synth/wk8404.d.ts +12 -0
  59. package/dist/lint/post-synth/wk8404.d.ts.map +1 -0
  60. package/dist/lint/post-synth/wk8405.d.ts +13 -0
  61. package/dist/lint/post-synth/wk8405.d.ts.map +1 -0
  62. package/dist/lint/post-synth/wk8406.d.ts +12 -0
  63. package/dist/lint/post-synth/wk8406.d.ts.map +1 -0
  64. package/dist/lint/post-synth/wk8407.d.ts +21 -0
  65. package/dist/lint/post-synth/wk8407.d.ts.map +1 -0
  66. package/dist/lint/post-synth/wk8503.d.ts +39 -0
  67. package/dist/lint/post-synth/wk8503.d.ts.map +1 -0
  68. package/dist/lint/post-synth/wk8504.d.ts +28 -0
  69. package/dist/lint/post-synth/wk8504.d.ts.map +1 -0
  70. package/dist/lint/post-synth/wk8505.d.ts +28 -0
  71. package/dist/lint/post-synth/wk8505.d.ts.map +1 -0
  72. package/dist/manifest.json +1 -1
  73. package/dist/meta.json +1039 -68
  74. package/dist/okf/index.md +26 -2
  75. package/dist/okf/rules/FLUX002.md +0 -1
  76. package/dist/okf/rules/WK8404.md +11 -0
  77. package/dist/okf/rules/WK8405.md +17 -0
  78. package/dist/okf/rules/WK8406.md +11 -0
  79. package/dist/okf/rules/WK8407.md +15 -0
  80. package/dist/okf/rules/WK8503.md +17 -0
  81. package/dist/okf/rules/WK8504.md +11 -0
  82. package/dist/okf/rules/WK8505.md +15 -0
  83. package/dist/okf/types/AWSClusterControllerIdentity.md +14 -0
  84. package/dist/okf/types/AWSManagedCluster.md +14 -0
  85. package/dist/okf/types/AWSManagedControlPlane.md +14 -0
  86. package/dist/okf/types/AWSManagedMachinePool.md +14 -0
  87. package/dist/okf/types/CAPICluster.md +14 -0
  88. package/dist/okf/types/Certificate.md +1 -0
  89. package/dist/okf/types/ClusterResourceSet.md +14 -0
  90. package/dist/okf/types/ClusterServingRuntime.md +14 -0
  91. package/dist/okf/types/{Cluster.md → CnpgCluster.md} +2 -2
  92. package/dist/okf/types/DBInstance.md +14 -0
  93. package/dist/okf/types/{Bucket.md → FluxBucket.md} +2 -6
  94. package/dist/okf/types/HelmChartProxy.md +14 -0
  95. package/dist/okf/types/IamRole.md +14 -0
  96. package/dist/okf/types/InferenceService.md +19 -0
  97. package/dist/okf/types/InfisicalDynamicSecret.md +4 -0
  98. package/dist/okf/types/InfisicalSecret.md +4 -0
  99. package/dist/okf/types/Kustomization.md +1 -0
  100. package/dist/okf/types/MachinePool.md +14 -0
  101. package/dist/okf/types/PDB.md +1 -0
  102. package/dist/okf/types/PodIdentityAssociation.md +14 -0
  103. package/dist/okf/types/Policy.md +14 -0
  104. package/dist/okf/types/S3Bucket.md +14 -0
  105. package/dist/okf/types/ServingRuntime.md +14 -0
  106. package/dist/okf/types/StatefulSet.md +1 -0
  107. package/dist/okf/types/User.md +14 -0
  108. package/dist/op/activities/ensure-secret.d.ts +41 -0
  109. package/dist/op/activities/ensure-secret.d.ts.map +1 -0
  110. package/dist/op/activities/index.d.ts +2 -0
  111. package/dist/op/activities/index.d.ts.map +1 -1
  112. package/dist/op/activities/kubectl.d.ts +9 -0
  113. package/dist/op/activities/kubectl.d.ts.map +1 -1
  114. package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
  115. package/dist/op/builders.d.ts +69 -0
  116. package/dist/op/builders.d.ts.map +1 -0
  117. package/dist/plugin.d.ts.map +1 -1
  118. package/dist/rules/argo-helpers.ts +2 -6
  119. package/dist/rules/crd-schema-helpers.ts +4 -6
  120. package/dist/rules/k8s-helpers.ts +87 -24
  121. package/dist/rules/sops-helpers.ts +76 -0
  122. package/dist/rules/wk8005.ts +27 -32
  123. package/dist/rules/wk8006.ts +33 -38
  124. package/dist/rules/wk8041.ts +19 -24
  125. package/dist/rules/wk8042.ts +17 -22
  126. package/dist/rules/wk8101.ts +29 -34
  127. package/dist/rules/wk8102.ts +13 -18
  128. package/dist/rules/wk8103.ts +17 -22
  129. package/dist/rules/wk8104.ts +30 -35
  130. package/dist/rules/wk8105.ts +16 -21
  131. package/dist/rules/wk8201.ts +26 -31
  132. package/dist/rules/wk8202.ts +17 -22
  133. package/dist/rules/wk8203.ts +17 -22
  134. package/dist/rules/wk8204.ts +46 -51
  135. package/dist/rules/wk8205.ts +27 -32
  136. package/dist/rules/wk8207.ts +14 -19
  137. package/dist/rules/wk8208.ts +14 -19
  138. package/dist/rules/wk8209.ts +14 -19
  139. package/dist/rules/wk8301.ts +25 -30
  140. package/dist/rules/wk8302.ts +19 -24
  141. package/dist/rules/wk8303.ts +23 -26
  142. package/dist/rules/wk8304.ts +35 -40
  143. package/dist/rules/wk8305.ts +39 -42
  144. package/dist/rules/wk8306.ts +20 -25
  145. package/dist/rules/wk8401.ts +59 -64
  146. package/dist/rules/wk8402.ts +13 -18
  147. package/dist/rules/wk8403.ts +32 -37
  148. package/dist/rules/wk8404.ts +49 -0
  149. package/dist/rules/wk8405.ts +85 -0
  150. package/dist/rules/wk8406.ts +55 -0
  151. package/dist/rules/wk8407.ts +66 -0
  152. package/dist/rules/wk8503.ts +220 -0
  153. package/dist/rules/wk8504.ts +51 -0
  154. package/dist/rules/wk8505.ts +63 -0
  155. package/dist/secret-labels.d.ts +31 -0
  156. package/dist/secret-labels.d.ts.map +1 -0
  157. package/dist/secret-store.d.ts +73 -0
  158. package/dist/secret-store.d.ts.map +1 -0
  159. package/dist/serializer.d.ts.map +1 -1
  160. package/dist/sops/encrypted-secret-file.d.ts +52 -0
  161. package/dist/sops/encrypted-secret-file.d.ts.map +1 -0
  162. package/dist/sops/entity.d.ts +31 -0
  163. package/dist/sops/entity.d.ts.map +1 -0
  164. package/dist/teardown.d.ts.map +1 -1
  165. package/dist/types/index.d.ts +261 -9
  166. package/package.json +4 -4
  167. package/src/components/kubectl-apply.ts +5 -0
  168. package/src/composites/composites.test.ts +556 -0
  169. package/src/composites/flux-app.ts +23 -0
  170. package/src/composites/index.ts +15 -0
  171. package/src/composites/inference-service.ts +140 -0
  172. package/src/composites/model.ts +150 -0
  173. package/src/composites/operator-stack.ts +385 -0
  174. package/src/composites/vllm-serving-runtime.test.ts +167 -0
  175. package/src/composites/vllm-serving-runtime.ts +196 -0
  176. package/src/crd/cnpg.test.ts +8 -3
  177. package/src/crd/crd-sources.ts +183 -0
  178. package/src/crd/parser.test.ts +14 -0
  179. package/src/deep-observe-hooks.ts +24 -0
  180. package/src/deep-observe.test.ts +89 -0
  181. package/src/generated/index.d.ts +261 -9
  182. package/src/generated/index.ts +167 -40
  183. package/src/generated/lexicon-k8s.json +1039 -68
  184. package/src/generated/operations.json +272 -0
  185. package/src/group-namespace.test.ts +31 -0
  186. package/src/group-namespace.ts +13 -0
  187. package/src/index.ts +17 -0
  188. package/src/kube/apply.ts +5 -0
  189. package/src/lint/audit-catalog.ts +7 -0
  190. package/src/lint/post-synth/argo-helpers.ts +2 -6
  191. package/src/lint/post-synth/crd-schema-helpers.ts +4 -6
  192. package/src/lint/post-synth/index.ts +14 -0
  193. package/src/lint/post-synth/k8s-helpers.test.ts +26 -9
  194. package/src/lint/post-synth/k8s-helpers.ts +87 -24
  195. package/src/lint/post-synth/post-synth.test.ts +826 -0
  196. package/src/lint/post-synth/sops-helpers.ts +76 -0
  197. package/src/lint/post-synth/wk8005.ts +27 -32
  198. package/src/lint/post-synth/wk8006.ts +33 -38
  199. package/src/lint/post-synth/wk8041.ts +19 -24
  200. package/src/lint/post-synth/wk8042.ts +17 -22
  201. package/src/lint/post-synth/wk8101.ts +29 -34
  202. package/src/lint/post-synth/wk8102.ts +13 -18
  203. package/src/lint/post-synth/wk8103.ts +17 -22
  204. package/src/lint/post-synth/wk8104.ts +30 -35
  205. package/src/lint/post-synth/wk8105.ts +16 -21
  206. package/src/lint/post-synth/wk8201.ts +26 -31
  207. package/src/lint/post-synth/wk8202.ts +17 -22
  208. package/src/lint/post-synth/wk8203.ts +17 -22
  209. package/src/lint/post-synth/wk8204.ts +46 -51
  210. package/src/lint/post-synth/wk8205.ts +27 -32
  211. package/src/lint/post-synth/wk8207.ts +14 -19
  212. package/src/lint/post-synth/wk8208.ts +14 -19
  213. package/src/lint/post-synth/wk8209.ts +14 -19
  214. package/src/lint/post-synth/wk8301.ts +25 -30
  215. package/src/lint/post-synth/wk8302.ts +19 -24
  216. package/src/lint/post-synth/wk8303.ts +23 -26
  217. package/src/lint/post-synth/wk8304.ts +35 -40
  218. package/src/lint/post-synth/wk8305.ts +39 -42
  219. package/src/lint/post-synth/wk8306.ts +20 -25
  220. package/src/lint/post-synth/wk8401.ts +59 -64
  221. package/src/lint/post-synth/wk8402.ts +13 -18
  222. package/src/lint/post-synth/wk8403.ts +32 -37
  223. package/src/lint/post-synth/wk8404.ts +49 -0
  224. package/src/lint/post-synth/wk8405.ts +85 -0
  225. package/src/lint/post-synth/wk8406.ts +55 -0
  226. package/src/lint/post-synth/wk8407.ts +66 -0
  227. package/src/lint/post-synth/wk8503.ts +220 -0
  228. package/src/lint/post-synth/wk8504.ts +51 -0
  229. package/src/lint/post-synth/wk8505.ts +63 -0
  230. package/src/op/activities/ensure-secret.ts +71 -0
  231. package/src/op/activities/index.ts +7 -0
  232. package/src/op/activities/kubectl.test.ts +32 -0
  233. package/src/op/activities/kubectl.ts +28 -5
  234. package/src/op/activities/wait-for-ready.test.ts +34 -0
  235. package/src/op/activities/wait-for-ready.ts +26 -0
  236. package/src/op/builders.test.ts +82 -0
  237. package/src/op/builders.ts +95 -0
  238. package/src/plugin.ts +21 -2
  239. package/src/secret-labels.ts +36 -0
  240. package/src/secret-store.test.ts +184 -0
  241. package/src/secret-store.ts +165 -0
  242. package/src/serializer.ts +41 -2
  243. package/src/sops/encrypted-secret-file.test.ts +333 -0
  244. package/src/sops/encrypted-secret-file.ts +264 -0
  245. package/src/sops/entity.ts +53 -0
  246. package/src/teardown.test.ts +36 -0
  247. package/src/teardown.ts +14 -0
  248. package/src/testdata/sops/db-credentials.sops.yaml +23 -0
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The k8s SecretStoreAdapter (chant #1830) over the fake cluster — a real
3
+ * `@intentius/chant-k8s-client` with only the HTTP send replaced, the same
4
+ * harness the teardown and prune suites use.
5
+ *
6
+ * What is asserted is the constitutional line as much as the behavior: no
7
+ * return value and no log line of the read half ever carries a `data` value,
8
+ * and the write half consumes each key's material exactly once, at the write,
9
+ * with the ownership marker and the generated-once label stamped on.
10
+ */
11
+
12
+ import { describe, test, expect, vi, afterEach } from "vitest";
13
+ import {
14
+ SecretMaterial,
15
+ consumeSecretMaterial,
16
+ ensureSecretMaterialization,
17
+ SecretContractMismatchError,
18
+ } from "@intentius/chant/secret-materialization";
19
+ import { LABEL_OWNERSHIP_KEYS, OWNERSHIP_MANAGED_BY_VALUE } from "@intentius/chant/ownership";
20
+ import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
21
+ import type { RecordedRequest } from "@intentius/chant-k8s-client/testing";
22
+ import { fakeCluster, objectKey } from "./api/fake-cluster";
23
+ import { k8sSecretStore } from "./secret-store";
24
+ import { GENERATED_ONCE_LABEL_KEY, GENERATED_ONCE_LABEL_VALUE } from "./secret-labels";
25
+
26
+ const PLAINTEXT_A = "plain-material-alpha";
27
+ const PLAINTEXT_B = "plain-material-bravo";
28
+ const LIVE_VALUE_1 = Buffer.from("live-master-bytes", "utf-8").toString("base64");
29
+ const LIVE_VALUE_2 = Buffer.from("live-conf-bytes", "utf-8").toString("base64");
30
+
31
+ /** Every string no read-path return or log may ever contain. */
32
+ const FORBIDDEN = [PLAINTEXT_A, PLAINTEXT_B, LIVE_VALUE_1, LIVE_VALUE_2, "live-master-bytes", "live-conf-bytes"];
33
+
34
+ function liveSecret(): K8sObject {
35
+ return {
36
+ apiVersion: "v1",
37
+ kind: "Secret",
38
+ metadata: {
39
+ name: "master-key",
40
+ namespace: "prod",
41
+ uid: "uid-master-key",
42
+ labels: {
43
+ [LABEL_OWNERSHIP_KEYS.managedBy]: OWNERSHIP_MANAGED_BY_VALUE,
44
+ [GENERATED_ONCE_LABEL_KEY]: GENERATED_ONCE_LABEL_VALUE,
45
+ },
46
+ annotations: { "example.com/note": "hand-written" },
47
+ },
48
+ type: "Opaque",
49
+ data: { MASTER_KEY: LIVE_VALUE_1, "app.conf": LIVE_VALUE_2 },
50
+ };
51
+ }
52
+
53
+ function clusterWithSecret() {
54
+ return fakeCluster({
55
+ objects: { [objectKey("v1", "Secret", "master-key", "prod")]: liveSecret() },
56
+ respond: (req: RecordedRequest) => (req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined),
57
+ });
58
+ }
59
+
60
+ async function clientOf(cluster: ReturnType<typeof fakeCluster>): Promise<K8sClient> {
61
+ return (await cluster.connector({})).client;
62
+ }
63
+
64
+ /** Spies on every console channel; returns everything logged as one string. */
65
+ function spyConsole(): { flush: () => string } {
66
+ const calls: unknown[][] = [];
67
+ for (const level of ["log", "info", "warn", "error", "debug"] as const) {
68
+ vi.spyOn(console, level).mockImplementation((...args: unknown[]) => {
69
+ calls.push(args);
70
+ });
71
+ }
72
+ return { flush: () => calls.flat().map(String).join("\n") };
73
+ }
74
+
75
+ afterEach(() => {
76
+ vi.restoreAllMocks();
77
+ });
78
+
79
+ describe("k8sSecretStore — the read half never returns or logs a value", () => {
80
+ test("exists answers presence and nothing else", async () => {
81
+ const cluster = clusterWithSecret();
82
+ const store = k8sSecretStore("prod", await clientOf(cluster));
83
+ await expect(store.exists("master-key")).resolves.toBe(true);
84
+ await expect(store.exists("absent")).resolves.toBe(false);
85
+ });
86
+
87
+ test("describe projects key NAMES and labels/annotations, never data values", async () => {
88
+ const cluster = clusterWithSecret();
89
+ const spy = spyConsole();
90
+ const store = k8sSecretStore("prod", await clientOf(cluster));
91
+
92
+ const description = await store.describe("master-key");
93
+
94
+ expect(description.keys).toEqual(["MASTER_KEY", "app.conf"]);
95
+ expect(description.metadata?.[GENERATED_ONCE_LABEL_KEY]).toBe(GENERATED_ONCE_LABEL_VALUE);
96
+ expect(description.metadata?.["example.com/note"]).toBe("hand-written");
97
+ // The claim that matters: nothing value-shaped in the return, under any
98
+ // key — the description carries exactly `keys` and `metadata`, and no
99
+ // byte of the stored values appears anywhere in its serialization.
100
+ expect(Object.keys(description).sort()).toEqual(["keys", "metadata"]);
101
+ const serialized = JSON.stringify(description);
102
+ for (const forbidden of FORBIDDEN) expect(serialized).not.toContain(forbidden);
103
+ for (const forbidden of FORBIDDEN) expect(spy.flush()).not.toContain(forbidden);
104
+ });
105
+
106
+ test("ensureSecretMaterialization over the adapter: present-and-matching writes nothing", async () => {
107
+ const cluster = clusterWithSecret();
108
+ const store = k8sSecretStore("prod", await clientOf(cluster));
109
+ const outcome = await ensureSecretMaterialization(store, {
110
+ name: "master-key",
111
+ keys: ["MASTER_KEY", "app.conf"],
112
+ });
113
+ expect(outcome).toEqual({ outcome: "present", name: "master-key", keys: ["MASTER_KEY", "app.conf"] });
114
+ expect(cluster.layer.requests.filter((r) => r.method === "PATCH")).toEqual([]);
115
+ });
116
+
117
+ test("a key-set mismatch fails loudly naming key names, never values", async () => {
118
+ const cluster = clusterWithSecret();
119
+ const store = k8sSecretStore("prod", await clientOf(cluster));
120
+ const err = await ensureSecretMaterialization(store, {
121
+ name: "master-key",
122
+ keys: ["MASTER_KEY", "MISSING_KEY"],
123
+ }).catch((e: unknown) => e);
124
+ expect(err).toBeInstanceOf(SecretContractMismatchError);
125
+ const message = String((err as Error).message);
126
+ expect(message).toContain("MISSING_KEY");
127
+ expect(message).toContain("app.conf");
128
+ for (const forbidden of FORBIDDEN) expect(message).not.toContain(forbidden);
129
+ });
130
+ });
131
+
132
+ describe("k8sSecretStore — create consumes material at the write and stamps the markers", () => {
133
+ test("mints one value per key, consumes each handle exactly once, stamps ownership + generated-once", async () => {
134
+ const cluster = fakeCluster({
135
+ respond: (req: RecordedRequest) => (req.method === "PATCH" ? { body: JSON.parse(String(req.body)) } : undefined),
136
+ });
137
+ const minted: SecretMaterial[] = [];
138
+ const generate = vi.fn((key: string) => {
139
+ const handle = SecretMaterial.mint(key === "MASTER_KEY" ? PLAINTEXT_A : PLAINTEXT_B);
140
+ minted.push(handle);
141
+ return handle;
142
+ });
143
+
144
+ const store = k8sSecretStore("prod", await clientOf(cluster), {
145
+ marker: { stack: "shop", env: "dev" },
146
+ });
147
+ const outcome = await ensureSecretMaterialization(
148
+ store,
149
+ { name: "master-key", keys: ["MASTER_KEY", "SECOND_KEY"] },
150
+ generate,
151
+ );
152
+ expect(outcome).toEqual({ outcome: "created", name: "master-key", keys: ["MASTER_KEY", "SECOND_KEY"] });
153
+
154
+ // One mint per declared key, and every handle burned at the write: a
155
+ // second consume of any of them throws.
156
+ expect(generate).toHaveBeenCalledTimes(2);
157
+ for (const handle of minted) {
158
+ expect(() => consumeSecretMaterial(handle)).toThrow(/already consumed/);
159
+ }
160
+
161
+ const patch = cluster.layer.requests.find((r) => r.method === "PATCH");
162
+ expect(patch).toBeDefined();
163
+ expect(patch!.path).toBe("/api/v1/namespaces/prod/secrets/master-key");
164
+ expect(patch!.query.fieldManager).toBe("chant:shop");
165
+ const body = JSON.parse(String(patch!.body)) as K8sObject;
166
+ expect(body.metadata?.labels).toEqual({
167
+ [LABEL_OWNERSHIP_KEYS.managedBy]: OWNERSHIP_MANAGED_BY_VALUE,
168
+ [LABEL_OWNERSHIP_KEYS.stack]: "shop",
169
+ [LABEL_OWNERSHIP_KEYS.env]: "dev",
170
+ [GENERATED_ONCE_LABEL_KEY]: GENERATED_ONCE_LABEL_VALUE,
171
+ });
172
+ expect(body.data).toEqual({
173
+ MASTER_KEY: Buffer.from(PLAINTEXT_A, "utf-8").toString("base64"),
174
+ SECOND_KEY: Buffer.from(PLAINTEXT_B, "utf-8").toString("base64"),
175
+ });
176
+ });
177
+
178
+ test("the handle itself never leaks material: stringify/inspect show only the redaction marker", () => {
179
+ const handle = SecretMaterial.mint(PLAINTEXT_A);
180
+ expect(String(handle)).not.toContain(PLAINTEXT_A);
181
+ expect(JSON.stringify({ handle })).not.toContain(PLAINTEXT_A);
182
+ consumeSecretMaterial(handle); // leave no live handle behind
183
+ });
184
+ });
@@ -0,0 +1,165 @@
1
+ /**
2
+ * The Kubernetes row of the secret-store seam (chant #1830, epic #1365) — a
3
+ * `SecretStoreAdapter` over the typed client, for the `ensure-secret`
4
+ * capability verb and the `ensureSecret(...)` op step (both #1829, both
5
+ * surfaces over core's one materialization engine).
6
+ *
7
+ * The constitutional line, held structurally:
8
+ *
9
+ * - `exists` and `describe` read the live Secret and report key NAMES and
10
+ * labels/annotations only. The `data` values are projected to their key
11
+ * names inside one function frame and the object is dropped — nothing
12
+ * value-shaped is returned, logged, or retained. The mismatch check in core
13
+ * therefore has nothing to compare but names, by construction.
14
+ * - `create` is the only writer. It redeems each key's material through
15
+ * `consumeSecretMaterial` AT the write — the engine hands the generator
16
+ * through this seam and never calls it — and returns nothing.
17
+ *
18
+ * Every minted Secret carries chant's ownership marker plus the
19
+ * `chant.intentius.io/generated-once` label (./secret-labels.ts), which is
20
+ * what both prune paths key their `retained` outcome on: a generated-once
21
+ * Secret is never in any prunable set, because the stored bytes are the only
22
+ * copy of material chant never held.
23
+ *
24
+ * Not exported from the package entry point — like ./teardown.ts, this module
25
+ * names the API client, which must stay off the build path (chant #1074,
26
+ * examples/k8s-client-boundary.test.ts). Consumers reach it by subpath:
27
+ * `@intentius/chant-lexicon-k8s/secret-store`.
28
+ */
29
+
30
+ import type {
31
+ SecretMaterialGenerator,
32
+ SecretStoreAdapter,
33
+ SecretStoreDescription,
34
+ } from "@intentius/chant/secret-materialization";
35
+ import { consumeSecretMaterial } from "@intentius/chant/secret-materialization";
36
+ import {
37
+ createEnsureSecretCapability,
38
+ type EnsureSecretInput,
39
+ type EnsureSecretOutput,
40
+ } from "@intentius/chant/components/verbs/ensure-secret";
41
+ import type { Capability } from "@intentius/chant/components/capability";
42
+ import {
43
+ LABEL_OWNERSHIP_KEYS,
44
+ OWNERSHIP_MANAGED_BY_VALUE,
45
+ ownershipEntries,
46
+ type OwnershipMarker,
47
+ } from "@intentius/chant/ownership";
48
+ import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
49
+ import { defaultK8sConnector, type K8sConnector } from "./api/connect";
50
+ import { GENERATED_ONCE_LABEL_KEY, GENERATED_ONCE_LABEL_VALUE } from "./secret-labels";
51
+
52
+ const SECRET_REF = { apiVersion: "v1", kind: "Secret" } as const;
53
+
54
+ export interface K8sSecretStoreOptions {
55
+ /**
56
+ * Ownership identity to stamp on a minted Secret, next to the
57
+ * generated-once label. Omitted stamps `managed-by=chant` alone — the
58
+ * Secret is still chant-owned and still never prunable.
59
+ */
60
+ marker?: OwnershipMarker;
61
+ /** chant environment, resolved through `k8s.profiles.<env>.context` when no client is handed in. */
62
+ environment?: string;
63
+ /** Explicit kubectl context, for the op write path. */
64
+ context?: string;
65
+ /** Project directory whose `chant.config.ts` carries the k8s profiles. Defaults to cwd. */
66
+ cwd?: string;
67
+ /** The connector to build a client with when none is handed in. Test seam. */
68
+ connect?: K8sConnector;
69
+ }
70
+
71
+ /**
72
+ * Build the k8s `SecretStoreAdapter` for one namespace.
73
+ *
74
+ * Pass a `client` where the caller already holds one (an activity that
75
+ * connected for the rest of its work); omit it and the adapter connects
76
+ * lazily through the same environment→cluster binding every other k8s path
77
+ * uses (./api/connect.ts).
78
+ */
79
+ export function k8sSecretStore(
80
+ namespace: string,
81
+ client?: K8sClient,
82
+ options: K8sSecretStoreOptions = {},
83
+ ): SecretStoreAdapter {
84
+ let pending: Promise<K8sClient> | undefined = client ? Promise.resolve(client) : undefined;
85
+ const clientOf = (): Promise<K8sClient> =>
86
+ (pending ??= (options.connect ?? defaultK8sConnector)({
87
+ ...(options.environment !== undefined ? { environment: options.environment } : {}),
88
+ ...(options.context !== undefined ? { context: options.context } : {}),
89
+ ...(options.cwd !== undefined ? { cwd: options.cwd } : {}),
90
+ }).then((connected) => connected.client));
91
+
92
+ return {
93
+ async exists(name: string): Promise<boolean> {
94
+ const c = await clientOf();
95
+ return (await c.readIfPresent({ ...SECRET_REF, name, namespace })) !== undefined;
96
+ },
97
+
98
+ async describe(name: string): Promise<SecretStoreDescription> {
99
+ const c = await clientOf();
100
+ const live = await c.read({ ...SECRET_REF, name, namespace });
101
+ // Project key names and drop the object in the same frame. The values
102
+ // exist only in `live`, which nothing below touches again — the return
103
+ // carries names and metadata, never a byte of `data`.
104
+ const keys = Object.keys((live.data as Record<string, unknown> | undefined) ?? {}).sort();
105
+ const metadata: Record<string, string> = {
106
+ ...(live.metadata?.annotations ?? {}),
107
+ ...(live.metadata?.labels ?? {}),
108
+ };
109
+ return { keys, metadata };
110
+ },
111
+
112
+ async create(name: string, keys: readonly string[], generate: SecretMaterialGenerator): Promise<void> {
113
+ const c = await clientOf();
114
+ // Mint and consume per key, at the write. `consumeSecretMaterial` burns
115
+ // each handle, so the material cannot be redeemed a second time by
116
+ // anything, this adapter included.
117
+ const data: Record<string, string> = {};
118
+ for (const key of keys) {
119
+ data[key] = Buffer.from(consumeSecretMaterial(await generate(key)), "utf-8").toString("base64");
120
+ }
121
+ const secret: K8sObject = {
122
+ apiVersion: SECRET_REF.apiVersion,
123
+ kind: SECRET_REF.kind,
124
+ metadata: {
125
+ name,
126
+ namespace,
127
+ labels: {
128
+ ...(options.marker
129
+ ? ownershipEntries(LABEL_OWNERSHIP_KEYS, options.marker)
130
+ : { [LABEL_OWNERSHIP_KEYS.managedBy]: OWNERSHIP_MANAGED_BY_VALUE }),
131
+ [GENERATED_ONCE_LABEL_KEY]: GENERATED_ONCE_LABEL_VALUE,
132
+ },
133
+ },
134
+ type: "Opaque",
135
+ data,
136
+ };
137
+ // Server-side apply as chant's field manager — the same write path every
138
+ // other chant mutation takes. The result (which echoes `data`) is
139
+ // deliberately discarded.
140
+ await c.apply(secret, {
141
+ ...(options.marker ? { fieldManager: `chant:${options.marker.stack}` } : {}),
142
+ });
143
+ },
144
+ };
145
+ }
146
+
147
+ /**
148
+ * The real `ensure-secret` capability for a Kubernetes namespace — core's
149
+ * factory (#1829) over this module's adapter. Replaces the starter set's
150
+ * typed stub for a project whose secret store is the cluster:
151
+ *
152
+ * ```ts
153
+ * registry.register(k8sEnsureSecretCapability("prod", undefined, {
154
+ * marker: { stack: "shop", env: "prod" },
155
+ * environment: "prod",
156
+ * }));
157
+ * ```
158
+ */
159
+ export function k8sEnsureSecretCapability(
160
+ namespace: string,
161
+ client?: K8sClient,
162
+ options: K8sSecretStoreOptions = {},
163
+ ): Capability<EnsureSecretInput, EnsureSecretOutput> {
164
+ return createEnsureSecretCapability({ store: k8sSecretStore(namespace, client, options) });
165
+ }
package/src/serializer.ts CHANGED
@@ -15,6 +15,7 @@ import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-w
15
15
  import { emitYAML } from "@intentius/chant/yaml";
16
16
  import { isDefaultLabels, isDefaultAnnotations, type DefaultLabels, type DefaultAnnotations } from "./default-labels";
17
17
  import { isRenderedManifestEntity } from "./kustomize/rendered-entity";
18
+ import { isEncryptedSecretFileEntity } from "./sops/entity";
18
19
 
19
20
  const require = createRequire(import.meta.url);
20
21
 
@@ -225,7 +226,11 @@ export const k8sSerializer: Serializer = {
225
226
  // `chant-disable ARGO001` already written (#1349).
226
227
  extraRulePrefixes: ["ARGO", "FLUX"],
227
228
 
228
- serialize(entities: Map<string, Declarable>, _outputs?: LexiconOutput[], context?: SerializeContext): string {
229
+ serialize(
230
+ entities: Map<string, Declarable>,
231
+ _outputs?: LexiconOutput[],
232
+ context?: SerializeContext,
233
+ ): string | SerializerResult {
229
234
  // Build reverse map: entity → name
230
235
  const entityNames = new Map<Declarable, string>();
231
236
  for (const [name, entity] of entities) {
@@ -251,11 +256,36 @@ export const k8sSerializer: Serializer = {
251
256
 
252
257
  const namespaceDocs: string[] = [];
253
258
  const otherDocs: string[] = [];
259
+ /** Sidecar files — committed ciphertext, copied byte-for-byte. */
260
+ const files: Record<string, string> = {};
261
+ /** Basenames within `files` that must never be JSON.parse round-tripped (chant#1937). */
262
+ const verbatimFiles: string[] = [];
263
+ const warnings: string[] = [];
254
264
 
255
265
  for (const [name, entity] of entities) {
256
266
  if (isPropertyDeclarable(entity)) continue;
257
267
  if (isDefaultLabels(entity) || isDefaultAnnotations(entity)) continue;
258
268
 
269
+ // Committed SOPS ciphertext (epic lex00/iac-cd-bench#6) leaves as a
270
+ // SIDECAR and never as a document in the primary output. That is the
271
+ // load-bearing line: chant's appliers read the primary output, so an
272
+ // undecrypted Secret cannot reach a cluster through them. The bytes are
273
+ // copied exactly as committed — no re-emit, no label merge, no
274
+ // ownership stamp — because re-serializing would break the MAC the
275
+ // `sops` block carries over the plaintext.
276
+ if (isEncryptedSecretFileEntity(entity)) {
277
+ const existing = files[entity.filename];
278
+ if (existing !== undefined && existing !== entity.text) {
279
+ warnings.push(
280
+ `k8s: two committed-encrypted secrets emit the same sidecar filename ` +
281
+ `"${entity.filename}" — the second (${entity.sourcePath}) wins`,
282
+ );
283
+ }
284
+ files[entity.filename] = entity.text;
285
+ if (!verbatimFiles.includes(entity.filename)) verbatimFiles.push(entity.filename);
286
+ continue;
287
+ }
288
+
259
289
  // A kustomize build root's document (#1548 piece 3) is render-final:
260
290
  // the overlay decided every field, so the props ARE the manifest and
261
291
  // the spec-inference heuristics below (built for typed declarables)
@@ -386,7 +416,16 @@ export const k8sSerializer: Serializer = {
386
416
  }
387
417
  }
388
418
 
389
- return [...namespaceDocs, ...otherDocs].join("\n---\n");
419
+ const primary = [...namespaceDocs, ...otherDocs].join("\n---\n");
420
+ // A bare string when there is nothing extra to write, so the common case
421
+ // stays byte-identical to what every existing consumer already reads.
422
+ if (Object.keys(files).length === 0 && warnings.length === 0) return primary;
423
+ return {
424
+ primary,
425
+ files,
426
+ ...(verbatimFiles.length > 0 ? { verbatimFiles } : {}),
427
+ ...(warnings.length > 0 ? { warnings } : {}),
428
+ };
390
429
  },
391
430
  };
392
431