@intentius/chant-lexicon-k8s 0.45.0 → 0.49.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 (160) 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 +6 -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/vllm-serving-runtime.d.ts +100 -0
  12. package/dist/composites/vllm-serving-runtime.d.ts.map +1 -0
  13. package/dist/crd/crd-sources.d.ts.map +1 -1
  14. package/dist/deep-observe-hooks.d.ts.map +1 -1
  15. package/dist/describe-resources.d.ts.map +1 -1
  16. package/dist/generated/index.d.ts +167 -40
  17. package/dist/generated/index.d.ts.map +1 -1
  18. package/dist/group-namespace.d.ts.map +1 -1
  19. package/dist/index.d.ts +3 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/integrity.json +13 -5
  22. package/dist/kube/apply.d.ts.map +1 -1
  23. package/dist/lint/audit-catalog.d.ts.map +1 -1
  24. package/dist/lint/post-synth/index.d.ts.map +1 -1
  25. package/dist/lint/post-synth/k8s-helpers.d.ts +28 -1
  26. package/dist/lint/post-synth/k8s-helpers.d.ts.map +1 -1
  27. package/dist/lint/post-synth/sops-helpers.d.ts +38 -0
  28. package/dist/lint/post-synth/sops-helpers.d.ts.map +1 -0
  29. package/dist/lint/post-synth/wk8404.d.ts +12 -0
  30. package/dist/lint/post-synth/wk8404.d.ts.map +1 -0
  31. package/dist/lint/post-synth/wk8405.d.ts +13 -0
  32. package/dist/lint/post-synth/wk8405.d.ts.map +1 -0
  33. package/dist/lint/post-synth/wk8406.d.ts +12 -0
  34. package/dist/lint/post-synth/wk8406.d.ts.map +1 -0
  35. package/dist/lint/post-synth/wk8407.d.ts +21 -0
  36. package/dist/lint/post-synth/wk8407.d.ts.map +1 -0
  37. package/dist/lint/post-synth/wk8503.d.ts +39 -0
  38. package/dist/lint/post-synth/wk8503.d.ts.map +1 -0
  39. package/dist/lint/post-synth/wk8504.d.ts +28 -0
  40. package/dist/lint/post-synth/wk8504.d.ts.map +1 -0
  41. package/dist/lint/post-synth/wk8505.d.ts +28 -0
  42. package/dist/lint/post-synth/wk8505.d.ts.map +1 -0
  43. package/dist/manifest.json +1 -1
  44. package/dist/meta.json +1039 -68
  45. package/dist/okf/index.md +26 -2
  46. package/dist/okf/rules/FLUX002.md +0 -1
  47. package/dist/okf/rules/WK8404.md +11 -0
  48. package/dist/okf/rules/WK8405.md +17 -0
  49. package/dist/okf/rules/WK8406.md +11 -0
  50. package/dist/okf/rules/WK8407.md +15 -0
  51. package/dist/okf/rules/WK8503.md +17 -0
  52. package/dist/okf/rules/WK8504.md +11 -0
  53. package/dist/okf/rules/WK8505.md +15 -0
  54. package/dist/okf/types/AWSClusterControllerIdentity.md +14 -0
  55. package/dist/okf/types/AWSManagedCluster.md +14 -0
  56. package/dist/okf/types/AWSManagedControlPlane.md +14 -0
  57. package/dist/okf/types/AWSManagedMachinePool.md +14 -0
  58. package/dist/okf/types/CAPICluster.md +14 -0
  59. package/dist/okf/types/Certificate.md +1 -0
  60. package/dist/okf/types/ClusterResourceSet.md +14 -0
  61. package/dist/okf/types/ClusterServingRuntime.md +14 -0
  62. package/dist/okf/types/{Cluster.md → CnpgCluster.md} +2 -2
  63. package/dist/okf/types/DBInstance.md +14 -0
  64. package/dist/okf/types/{Bucket.md → FluxBucket.md} +2 -6
  65. package/dist/okf/types/HelmChartProxy.md +14 -0
  66. package/dist/okf/types/IamRole.md +14 -0
  67. package/dist/okf/types/InferenceService.md +19 -0
  68. package/dist/okf/types/InfisicalDynamicSecret.md +4 -0
  69. package/dist/okf/types/InfisicalSecret.md +4 -0
  70. package/dist/okf/types/Kustomization.md +1 -0
  71. package/dist/okf/types/MachinePool.md +14 -0
  72. package/dist/okf/types/PDB.md +1 -0
  73. package/dist/okf/types/PodIdentityAssociation.md +14 -0
  74. package/dist/okf/types/Policy.md +14 -0
  75. package/dist/okf/types/S3Bucket.md +14 -0
  76. package/dist/okf/types/ServingRuntime.md +14 -0
  77. package/dist/okf/types/StatefulSet.md +1 -0
  78. package/dist/okf/types/User.md +14 -0
  79. package/dist/op/activities/ensure-secret.d.ts +41 -0
  80. package/dist/op/activities/ensure-secret.d.ts.map +1 -0
  81. package/dist/op/activities/index.d.ts +2 -0
  82. package/dist/op/activities/index.d.ts.map +1 -1
  83. package/dist/op/activities/kubectl.d.ts +9 -0
  84. package/dist/op/activities/kubectl.d.ts.map +1 -1
  85. package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
  86. package/dist/plugin.d.ts.map +1 -1
  87. package/dist/rules/k8s-helpers.ts +55 -1
  88. package/dist/rules/sops-helpers.ts +76 -0
  89. package/dist/rules/wk8404.ts +55 -0
  90. package/dist/rules/wk8405.ts +88 -0
  91. package/dist/rules/wk8406.ts +60 -0
  92. package/dist/rules/wk8407.ts +71 -0
  93. package/dist/rules/wk8503.ts +220 -0
  94. package/dist/rules/wk8504.ts +51 -0
  95. package/dist/rules/wk8505.ts +63 -0
  96. package/dist/secret-labels.d.ts +31 -0
  97. package/dist/secret-labels.d.ts.map +1 -0
  98. package/dist/secret-store.d.ts +73 -0
  99. package/dist/secret-store.d.ts.map +1 -0
  100. package/dist/serializer.d.ts.map +1 -1
  101. package/dist/sops/encrypted-secret-file.d.ts +52 -0
  102. package/dist/sops/encrypted-secret-file.d.ts.map +1 -0
  103. package/dist/sops/entity.d.ts +31 -0
  104. package/dist/sops/entity.d.ts.map +1 -0
  105. package/dist/teardown.d.ts +46 -0
  106. package/dist/teardown.d.ts.map +1 -0
  107. package/dist/types/index.d.ts +261 -9
  108. package/package.json +4 -4
  109. package/src/components/kubectl-apply.ts +5 -0
  110. package/src/composites/composites.test.ts +273 -0
  111. package/src/composites/flux-app.ts +23 -0
  112. package/src/composites/index.ts +11 -0
  113. package/src/composites/inference-service.ts +140 -0
  114. package/src/composites/model.ts +150 -0
  115. package/src/composites/vllm-serving-runtime.test.ts +167 -0
  116. package/src/composites/vllm-serving-runtime.ts +196 -0
  117. package/src/crd/cnpg.test.ts +8 -3
  118. package/src/crd/crd-sources.ts +183 -0
  119. package/src/crd/parser.test.ts +14 -0
  120. package/src/deep-observe-hooks.ts +24 -0
  121. package/src/deep-observe.test.ts +89 -0
  122. package/src/describe-resources.ts +6 -1
  123. package/src/generated/index.d.ts +261 -9
  124. package/src/generated/index.ts +167 -40
  125. package/src/generated/lexicon-k8s.json +1039 -68
  126. package/src/generated/operations.json +272 -0
  127. package/src/group-namespace.test.ts +31 -0
  128. package/src/group-namespace.ts +13 -0
  129. package/src/index.ts +6 -0
  130. package/src/kube/apply.ts +5 -0
  131. package/src/lifecycle-integration.test.ts +42 -0
  132. package/src/lint/audit-catalog.ts +7 -0
  133. package/src/lint/post-synth/index.ts +14 -0
  134. package/src/lint/post-synth/k8s-helpers.ts +55 -1
  135. package/src/lint/post-synth/post-synth.test.ts +826 -0
  136. package/src/lint/post-synth/sops-helpers.ts +76 -0
  137. package/src/lint/post-synth/wk8404.ts +55 -0
  138. package/src/lint/post-synth/wk8405.ts +88 -0
  139. package/src/lint/post-synth/wk8406.ts +60 -0
  140. package/src/lint/post-synth/wk8407.ts +71 -0
  141. package/src/lint/post-synth/wk8503.ts +220 -0
  142. package/src/lint/post-synth/wk8504.ts +51 -0
  143. package/src/lint/post-synth/wk8505.ts +63 -0
  144. package/src/op/activities/ensure-secret.ts +71 -0
  145. package/src/op/activities/index.ts +7 -0
  146. package/src/op/activities/kubectl.test.ts +32 -0
  147. package/src/op/activities/kubectl.ts +28 -5
  148. package/src/op/activities/wait-for-ready.test.ts +34 -0
  149. package/src/op/activities/wait-for-ready.ts +26 -0
  150. package/src/plugin.ts +34 -2
  151. package/src/secret-labels.ts +36 -0
  152. package/src/secret-store.test.ts +184 -0
  153. package/src/secret-store.ts +165 -0
  154. package/src/serializer.ts +33 -2
  155. package/src/sops/encrypted-secret-file.test.ts +333 -0
  156. package/src/sops/encrypted-secret-file.ts +264 -0
  157. package/src/sops/entity.ts +53 -0
  158. package/src/teardown.test.ts +240 -0
  159. package/src/teardown.ts +239 -0
  160. package/src/testdata/sops/db-credentials.sops.yaml +23 -0
@@ -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,33 @@ 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
+ const warnings: string[] = [];
254
262
 
255
263
  for (const [name, entity] of entities) {
256
264
  if (isPropertyDeclarable(entity)) continue;
257
265
  if (isDefaultLabels(entity) || isDefaultAnnotations(entity)) continue;
258
266
 
267
+ // Committed SOPS ciphertext (epic lex00/iac-cd-bench#6) leaves as a
268
+ // SIDECAR and never as a document in the primary output. That is the
269
+ // load-bearing line: chant's appliers read the primary output, so an
270
+ // undecrypted Secret cannot reach a cluster through them. The bytes are
271
+ // copied exactly as committed — no re-emit, no label merge, no
272
+ // ownership stamp — because re-serializing would break the MAC the
273
+ // `sops` block carries over the plaintext.
274
+ if (isEncryptedSecretFileEntity(entity)) {
275
+ const existing = files[entity.filename];
276
+ if (existing !== undefined && existing !== entity.text) {
277
+ warnings.push(
278
+ `k8s: two committed-encrypted secrets emit the same sidecar filename ` +
279
+ `"${entity.filename}" — the second (${entity.sourcePath}) wins`,
280
+ );
281
+ }
282
+ files[entity.filename] = entity.text;
283
+ continue;
284
+ }
285
+
259
286
  // A kustomize build root's document (#1548 piece 3) is render-final:
260
287
  // the overlay decided every field, so the props ARE the manifest and
261
288
  // the spec-inference heuristics below (built for typed declarables)
@@ -386,7 +413,11 @@ export const k8sSerializer: Serializer = {
386
413
  }
387
414
  }
388
415
 
389
- return [...namespaceDocs, ...otherDocs].join("\n---\n");
416
+ const primary = [...namespaceDocs, ...otherDocs].join("\n---\n");
417
+ // A bare string when there is nothing extra to write, so the common case
418
+ // stays byte-identical to what every existing consumer already reads.
419
+ if (Object.keys(files).length === 0 && warnings.length === 0) return primary;
420
+ return { primary, files, ...(warnings.length > 0 ? { warnings } : {}) };
390
421
  },
391
422
  };
392
423
 
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Committed SOPS ciphertext, end to end. What must hold: a
3
+ * committed-encrypted `declareSecret()` resolves its file at `buildRoots()`,
4
+ * the bytes leave the build BYTE-FOR-BYTE as a sidecar, the primary output
5
+ * never carries them, and every way the claim can be false — missing file,
6
+ * wrong name, no `sops` block, a value that is not `ENC[...]` — refuses with
7
+ * the path in the message and nothing emitted.
8
+ *
9
+ * The fixture (`../testdata/sops/db-credentials.sops.yaml`) is a real
10
+ * sops-shaped document: cleartext structure, `ENC[...]` values, an age
11
+ * recipient block and a `mac`. Nothing in this suite decrypts anything —
12
+ * there is no key, and no code path that could use one.
13
+ */
14
+ import { describe, test, expect } from "vitest";
15
+ import { fileURLToPath } from "node:url";
16
+ import { dirname, join } from "node:path";
17
+ import { readFileSync } from "node:fs";
18
+ import { mkdir, rm, writeFile } from "node:fs/promises";
19
+ import { tmpdir } from "node:os";
20
+ import { build } from "@intentius/chant";
21
+ import { declareSecret, type CommittedEncryptedSecretDeclaration } from "@intentius/chant/secret-provenance";
22
+ import type { Declarable } from "@intentius/chant/declarable";
23
+ import {
24
+ encryptedSecretBuildRoot,
25
+ resolveEncryptedSecrets,
26
+ validateEncryptedSecretDocument,
27
+ committedEncryptedDeclarations,
28
+ } from "./encrypted-secret-file";
29
+ import { isEncryptedSecretFileEntity, ENCRYPTED_SECRET_FILE_TYPE } from "./entity";
30
+ import { k8sSerializer } from "../serializer";
31
+ import { k8sPlugin } from "../plugin";
32
+
33
+ const here = dirname(fileURLToPath(import.meta.url));
34
+ const fixtureRoot = join(here, "..", "testdata", "sops");
35
+ const FIXTURE = "db-credentials.sops.yaml";
36
+ const FIXTURE_TEXT = readFileSync(join(fixtureRoot, FIXTURE), "utf-8");
37
+ /** Absolute path to core's factory — a temp project cannot resolve "@intentius/chant". */
38
+ const DECLARE_SECRET_MODULE = join(here, "..", "..", "..", "..", "packages", "core", "src", "secret-provenance");
39
+
40
+ function declaration(
41
+ overrides: Partial<{ name: string; file: string }> = {},
42
+ ): CommittedEncryptedSecretDeclaration {
43
+ return declareSecret({
44
+ name: overrides.name ?? "db-credentials",
45
+ provenance: "committed-encrypted",
46
+ file: overrides.file ?? FIXTURE,
47
+ keys: ["POSTGRES_USER", "POSTGRES_PASSWORD"],
48
+ });
49
+ }
50
+
51
+ function declarations(...decls: CommittedEncryptedSecretDeclaration[]) {
52
+ return new Map(decls.map((d, i) => [`decl${i}`, d]));
53
+ }
54
+
55
+ describe("resolveEncryptedSecrets — the buildRoots read path", () => {
56
+ test("reads the committed bytes and wraps them in one entity", () => {
57
+ const entities = resolveEncryptedSecrets({
58
+ projectRoot: fixtureRoot,
59
+ declarations: declarations(declaration()),
60
+ });
61
+
62
+ expect([...entities.keys()]).toEqual(["sops:db-credentials"]);
63
+ const entity = entities.get("sops:db-credentials")!;
64
+ expect(isEncryptedSecretFileEntity(entity)).toBe(true);
65
+ if (!isEncryptedSecretFileEntity(entity)) return;
66
+ expect(entity.lexicon).toBe("k8s");
67
+ expect(entity.entityType).toBe(ENCRYPTED_SECRET_FILE_TYPE);
68
+ expect(entity.filename).toBe(FIXTURE);
69
+ expect(entity.secretName).toBe("db-credentials");
70
+ // The namespace is read from the file, not restated by the author — SOPS
71
+ // encrypts values, not structure, so metadata is cleartext.
72
+ expect(entity.namespace).toBe("apps");
73
+ expect(entity.text).toBe(FIXTURE_TEXT);
74
+ });
75
+
76
+ test("a declaration in a subdirectory keeps the basename as the sidecar name", () => {
77
+ const entities = resolveEncryptedSecrets({
78
+ projectRoot: join(fixtureRoot, ".."),
79
+ declarations: declarations(declaration({ file: `sops/${FIXTURE}` })),
80
+ });
81
+ const entity = [...entities.values()][0];
82
+ expect(isEncryptedSecretFileEntity(entity) && entity.filename).toBe(FIXTURE);
83
+ });
84
+
85
+ test("no committed-encrypted declarations contributes nothing", async () => {
86
+ const contribution = await encryptedSecretBuildRoot({ projectRoot: fixtureRoot, config: {} });
87
+ expect(contribution.entities.size).toBe(0);
88
+ });
89
+
90
+ test("the plugin hook resolves declarations from ctx.entities", async () => {
91
+ const contribution = await k8sPlugin.buildRoots!({
92
+ projectRoot: fixtureRoot,
93
+ config: {},
94
+ entities: new Map<string, Declarable>([["dbCredentials", declaration()]]),
95
+ });
96
+ expect([...contribution.entities.keys()]).toEqual(["sops:db-credentials"]);
97
+ });
98
+
99
+ test("committedEncryptedDeclarations ignores the other three kinds", () => {
100
+ const entities = new Map<string, Declarable>([
101
+ ["a", declareSecret({ name: "a", provenance: "referenced" })],
102
+ ["b", declareSecret({ name: "b", provenance: "generated-once", keys: ["k"] })],
103
+ ["c", declaration()],
104
+ ]);
105
+ expect([...committedEncryptedDeclarations(entities).keys()]).toEqual(["c"]);
106
+ });
107
+
108
+ test("a missing file refuses, naming the resolved path", () => {
109
+ expect(() =>
110
+ resolveEncryptedSecrets({
111
+ projectRoot: fixtureRoot,
112
+ declarations: declarations(declaration({ name: "gone", file: "nowhere.sops.yaml" })),
113
+ }),
114
+ ).toThrow(/not readable at .*nowhere\.sops\.yaml/);
115
+ });
116
+
117
+ test("two declarations whose paths share a basename refuse rather than overwrite", async () => {
118
+ const dir = join(tmpdir(), `chant-sops-collide-${Date.now()}-${Math.random()}`);
119
+ await mkdir(join(dir, "a"), { recursive: true });
120
+ await mkdir(join(dir, "b"), { recursive: true });
121
+ try {
122
+ await writeFile(join(dir, "a", "creds.sops.yaml"), FIXTURE_TEXT);
123
+ await writeFile(
124
+ join(dir, "b", "creds.sops.yaml"),
125
+ FIXTURE_TEXT.replace("name: db-credentials", "name: other-credentials"),
126
+ );
127
+ expect(() =>
128
+ resolveEncryptedSecrets({
129
+ projectRoot: dir,
130
+ declarations: declarations(
131
+ declaration({ file: "a/creds.sops.yaml" }),
132
+ declaration({ name: "other-credentials", file: "b/creds.sops.yaml" }),
133
+ ),
134
+ }),
135
+ ).toThrow(/collides/);
136
+ } finally {
137
+ await rm(dir, { recursive: true, force: true });
138
+ }
139
+ });
140
+ });
141
+
142
+ describe("validateEncryptedSecretDocument", () => {
143
+ const declared = { name: "db-credentials", file: FIXTURE };
144
+
145
+ test("the fixture is clean, and its key names (never values) are read back", () => {
146
+ const { problems, document } = validateEncryptedSecretDocument(FIXTURE_TEXT, declared);
147
+ expect(problems).toEqual([]);
148
+ expect(document!.name).toBe("db-credentials");
149
+ expect(document!.namespace).toBe("apps");
150
+ expect(document!.keys.sort()).toEqual(["POSTGRES_PASSWORD", "POSTGRES_USER"]);
151
+ });
152
+
153
+ test("a metadata.name other than the declared one is a problem", () => {
154
+ const text = FIXTURE_TEXT.replace("name: db-credentials", "name: other-credentials");
155
+ const { problems } = validateEncryptedSecretDocument(text, declared);
156
+ expect(problems.join("\n")).toMatch(/metadata\.name "other-credentials"/);
157
+ });
158
+
159
+ test("a document that is not a v1 Secret is a problem", () => {
160
+ const text = FIXTURE_TEXT.replace("kind: Secret", "kind: ConfigMap");
161
+ const { problems } = validateEncryptedSecretDocument(text, declared);
162
+ expect(problems.join("\n")).toMatch(/not a v1 Secret/);
163
+ });
164
+
165
+ test("a missing sops block is a problem — the file was never encrypted", () => {
166
+ const text = FIXTURE_TEXT.slice(0, FIXTURE_TEXT.indexOf("sops:"));
167
+ const { problems } = validateEncryptedSecretDocument(text, declared);
168
+ expect(problems.join("\n")).toMatch(/no top-level `sops` block/);
169
+ });
170
+
171
+ test("a plaintext data value is a problem, reported by KEY name only", () => {
172
+ const text = FIXTURE_TEXT.replace(
173
+ /POSTGRES_PASSWORD: ENC\[[^\]]*\]/,
174
+ "POSTGRES_PASSWORD: hunter2",
175
+ );
176
+ const { problems } = validateEncryptedSecretDocument(text, declared);
177
+ const message = problems.join("\n");
178
+ expect(message).toMatch(/stringData\."POSTGRES_PASSWORD" is not encrypted/);
179
+ // The constitutional line: a value is tested, never echoed.
180
+ expect(message).not.toContain("hunter2");
181
+ });
182
+
183
+ test("multi-document files are refused in v1", () => {
184
+ const { problems } = validateEncryptedSecretDocument(`${FIXTURE_TEXT}\n---\n${FIXTURE_TEXT}`, declared);
185
+ expect(problems.join("\n")).toMatch(/contains 2 YAML documents/);
186
+ });
187
+
188
+ test("unparseable YAML is refused, not thrown through", () => {
189
+ const { problems } = validateEncryptedSecretDocument("a:\n - b\n c: [", declared);
190
+ expect(problems.length).toBe(1);
191
+ expect(problems[0]).toMatch(/not valid YAML/);
192
+ });
193
+
194
+ test("every problem is reported at once, not one per build", () => {
195
+ const text = FIXTURE_TEXT.replace("name: db-credentials", "name: wrong").replace(
196
+ /POSTGRES_USER: ENC\[[^\]]*\]/,
197
+ "POSTGRES_USER: postgres",
198
+ );
199
+ const { problems } = validateEncryptedSecretDocument(text, declared);
200
+ expect(problems.length).toBeGreaterThanOrEqual(2);
201
+ });
202
+
203
+ test("resolution refuses on any problem, emitting nothing", () => {
204
+ const dir = join(tmpdir(), `chant-sops-bad-${Date.now()}-${Math.random()}`);
205
+ return (async () => {
206
+ await mkdir(dir, { recursive: true });
207
+ try {
208
+ await writeFile(
209
+ join(dir, FIXTURE),
210
+ FIXTURE_TEXT.replace(/POSTGRES_USER: ENC\[[^\]]*\]/, "POSTGRES_USER: postgres"),
211
+ );
212
+ expect(() =>
213
+ resolveEncryptedSecrets({ projectRoot: dir, declarations: declarations(declaration()) }),
214
+ ).toThrow(/does not resolve[\s\S]*is not encrypted/);
215
+ } finally {
216
+ await rm(dir, { recursive: true, force: true });
217
+ }
218
+ })();
219
+ });
220
+ });
221
+
222
+ describe("sidecar emission", () => {
223
+ const workload: Declarable = {
224
+ lexicon: "k8s",
225
+ entityType: "K8s::Apps::Deployment",
226
+ kind: "resource",
227
+ props: {
228
+ metadata: { name: "api", namespace: "apps" },
229
+ spec: {
230
+ template: {
231
+ spec: {
232
+ containers: [
233
+ { name: "api", image: "api:1.0", envFrom: [{ secretRef: { name: "db-credentials" } }] },
234
+ ],
235
+ },
236
+ },
237
+ },
238
+ },
239
+ [Symbol.for("chant.declarable")]: true,
240
+ } as unknown as Declarable;
241
+
242
+ function serializeWithCiphertext() {
243
+ const entities = new Map<string, Declarable>([["api", workload]]);
244
+ for (const [name, entity] of resolveEncryptedSecrets({
245
+ projectRoot: fixtureRoot,
246
+ declarations: declarations(declaration()),
247
+ })) {
248
+ entities.set(name, entity);
249
+ }
250
+ return k8sSerializer.serialize(entities);
251
+ }
252
+
253
+ test("the ciphertext leaves as a sidecar file, byte for byte", () => {
254
+ const result = serializeWithCiphertext();
255
+ expect(typeof result).toBe("object");
256
+ if (typeof result === "string") return;
257
+ expect(Object.keys(result.files ?? {})).toEqual([FIXTURE]);
258
+ expect(result.files![FIXTURE]).toBe(FIXTURE_TEXT);
259
+ });
260
+
261
+ test("the primary output carries no ciphertext and no Secret document", () => {
262
+ const result = serializeWithCiphertext();
263
+ const primary = typeof result === "string" ? result : result.primary;
264
+ expect(primary).toContain("kind: Deployment");
265
+ expect(primary).not.toContain("ENC[");
266
+ expect(primary).not.toContain("kind: Secret");
267
+ expect(primary).not.toContain("sops");
268
+ });
269
+
270
+ test("a build with no ciphertext still returns a bare string — the common case is unchanged", () => {
271
+ const result = k8sSerializer.serialize(new Map<string, Declarable>([["api", workload]]));
272
+ expect(typeof result).toBe("string");
273
+ });
274
+
275
+ test("the whole path through build(): declaration in, sidecar out", async () => {
276
+ const dir = join(tmpdir(), `chant-sops-build-${Date.now()}-${Math.random()}`);
277
+ await mkdir(dir, { recursive: true });
278
+ try {
279
+ await writeFile(join(dir, FIXTURE), FIXTURE_TEXT);
280
+ await writeFile(
281
+ join(dir, "secrets.infra.ts"),
282
+ `import { declareSecret } from ${JSON.stringify(DECLARE_SECRET_MODULE)};
283
+ export const dbCredentials = declareSecret({
284
+ name: "db-credentials",
285
+ provenance: "committed-encrypted",
286
+ file: ${JSON.stringify(FIXTURE)},
287
+ keys: ["POSTGRES_USER", "POSTGRES_PASSWORD"],
288
+ });
289
+ `,
290
+ );
291
+
292
+ const result = await build(dir, [k8sSerializer], undefined, {
293
+ buildRoots: [(ctx) => k8sPlugin.buildRoots!({ projectRoot: dir, config: {}, entities: ctx.entities })],
294
+ });
295
+
296
+ expect(result.errors).toEqual([]);
297
+ const output = result.outputs.get("k8s")!;
298
+ expect(typeof output).toBe("object");
299
+ if (typeof output === "string") return;
300
+ expect(output.files![FIXTURE]).toBe(FIXTURE_TEXT);
301
+ expect(output.primary).not.toContain("ENC[");
302
+ } finally {
303
+ await rm(dir, { recursive: true, force: true });
304
+ }
305
+ });
306
+
307
+ test("a broken declaration fails the build with a message, not a stack trace", async () => {
308
+ const dir = join(tmpdir(), `chant-sops-build-bad-${Date.now()}-${Math.random()}`);
309
+ await mkdir(dir, { recursive: true });
310
+ try {
311
+ await writeFile(
312
+ join(dir, "secrets.infra.ts"),
313
+ `import { declareSecret } from ${JSON.stringify(DECLARE_SECRET_MODULE)};
314
+ export const dbCredentials = declareSecret({
315
+ name: "db-credentials",
316
+ provenance: "committed-encrypted",
317
+ file: "secrets/never-committed.sops.yaml",
318
+ });
319
+ `,
320
+ );
321
+
322
+ const result = await build(dir, [k8sSerializer], undefined, {
323
+ buildRoots: [(ctx) => k8sPlugin.buildRoots!({ projectRoot: dir, config: {}, entities: ctx.entities })],
324
+ });
325
+
326
+ expect(result.errors.length).toBe(1);
327
+ expect(result.errors[0].message).toContain("never-committed.sops.yaml");
328
+ expect(result.errors[0].message).toContain("not readable");
329
+ } finally {
330
+ await rm(dir, { recursive: true, force: true });
331
+ }
332
+ });
333
+ });