@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,63 @@
1
+ /**
2
+ * WK8505: committed-encrypted secret with no Flux decryption wiring
3
+ *
4
+ * Flux is the thing that actually decrypts a committed-encrypted secret's
5
+ * sidecar, via `spec.decryption` on the reconciling `Kustomization`
6
+ * (`FluxAppFor`'s `decryption` option). Nothing forces the two calls to be
7
+ * wired together: `declareSecret({ provenance: "committed-encrypted" })` and
8
+ * `FluxAppFor(...)` are independent, so it is easy to add the ciphertext
9
+ * without adding the wiring — the build succeeds, WK8504 passes because the
10
+ * file resolves cleanly, and Flux still applies the sidecar as raw
11
+ * `ENC[...]` bytes because nothing told it to decrypt first.
12
+ *
13
+ * Warning, not error. The reference project shape (`examples/flux-apps`)
14
+ * runs the workload build and the Flux build as separate `chant build`
15
+ * invocations, so "no Kustomization in THIS build sets spec.decryption" can
16
+ * mean "wired up in the other build" as easily as "forgotten" — the design
17
+ * doc's §4 explains why no path-to-build-target join exists to tell those
18
+ * apart. Promote to error if that join ever becomes available.
19
+ *
20
+ * Fires only on claims that actually resolved (`problems.length === 0`, via
21
+ * `resolveEncryptedSecretClaims`), not on every raw declaration. An
22
+ * unresolved declaration is already WK8504's job at error severity; firing
23
+ * this warning on top of it would report a Flux wiring gap for a Secret the
24
+ * build has not confirmed exists.
25
+ */
26
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
27
+ import { allManifests, manifestsOfKind } from "./argo-helpers";
28
+ import type { K8sManifest } from "./k8s-helpers";
29
+ import { resolveEncryptedSecretClaims } from "./sops-helpers";
30
+
31
+ /** Flux Kustomizations only — the kustomize.config.k8s.io kind shares the name. */
32
+ function fluxKustomizations(manifests: K8sManifest[]): K8sManifest[] {
33
+ return manifestsOfKind(manifests, "Kustomization").filter((m) => {
34
+ const apiVersion = m.apiVersion;
35
+ return typeof apiVersion !== "string" || apiVersion.startsWith("kustomize.toolkit.fluxcd.io/");
36
+ });
37
+ }
38
+
39
+ export const wk8505: PostSynthCheck = {
40
+ id: "WK8505",
41
+ description:
42
+ "committed-encrypted secret with no Flux decryption wiring — add decryption: 'sops' to the FluxAppFor reconciling the path that carries it",
43
+
44
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
45
+ const kustomizations = fluxKustomizations(allManifests(ctx));
46
+ if (kustomizations.length === 0) return [];
47
+ if (kustomizations.some((k) => k.spec?.decryption !== undefined)) return [];
48
+
49
+ const resolved = resolveEncryptedSecretClaims(ctx).filter((claim) => claim.problems.length === 0);
50
+
51
+ return resolved.map((claim) => ({
52
+ checkId: "WK8505",
53
+ severity: "warning",
54
+ message:
55
+ `declareSecret("${claim.declaration.name}") is committed-encrypted, but no Flux Kustomization ` +
56
+ `in this build sets spec.decryption — Flux will apply "${claim.filename}" without decrypting it. ` +
57
+ `Add decryption: "sops" to the FluxAppFor reconciling the path that carries it, or ignore if that ` +
58
+ `Kustomization is declared in a different build.`,
59
+ entity: claim.declaration.name,
60
+ lexicon: "k8s",
61
+ }));
62
+ },
63
+ };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `ensureSecret` — the activity behind core's `ensureSecret(...)` op step
3
+ * builder (#1829), provided by the store's lexicon as that builder promises
4
+ * (#1830). Registered by name through the activity registry when a project's
5
+ * `chant.config.ts` lists the `k8s` lexicon, exactly like `kubectlApply`.
6
+ *
7
+ * All it does is connect (the same environment→cluster binding every k8s
8
+ * path uses), build the namespace-scoped store adapter, and run core's one
9
+ * materialization engine over it. The contract — present means done, never
10
+ * mint over an existing value, mismatches fail naming key names only, no
11
+ * output carries material — lives in core (`secret-materialization.ts`) and
12
+ * in the adapter (../../secret-store.ts), not here.
13
+ */
14
+
15
+ import {
16
+ ensureSecretMaterialization,
17
+ type EnsureSecretOutcome,
18
+ } from "@intentius/chant/secret-materialization";
19
+ import type { OwnershipMarker } from "@intentius/chant/ownership";
20
+ import { defaultK8sConnector, type K8sConnector } from "../../api/connect";
21
+ import { k8sSecretStore } from "../../secret-store";
22
+
23
+ export interface EnsureSecretActivityArgs {
24
+ /** The Secret's `metadata.name`. */
25
+ name: string;
26
+ /** The declared key-set. Creation mints one value per key; verification compares names only. */
27
+ keys: string[];
28
+ /** Declared metadata (labels/annotations) an existing Secret must carry. Mismatches name the KEY. */
29
+ metadata?: Record<string, string>;
30
+ /** Target namespace. Omitted uses the connected context's default namespace. */
31
+ namespace?: string;
32
+ /** chant environment, resolved through `k8s.profiles.<env>.context`. */
33
+ environment?: string;
34
+ /** Explicit kubectl context — same precedence as `kubectlApply`. */
35
+ context?: string;
36
+ /** Project directory whose `chant.config.ts` carries the k8s profiles. */
37
+ cwd?: string;
38
+ /** Ownership stack to stamp on a minted Secret (with `env`, the full marker). */
39
+ stack?: string;
40
+ /** Ownership env to stamp alongside `stack`. */
41
+ env?: string;
42
+ }
43
+
44
+ /**
45
+ * Ensure a `generated-once` Secret exists in the target cluster. Returns
46
+ * names only — see core's `EnsureSecretOutcome`.
47
+ */
48
+ export async function ensureSecret(
49
+ args: EnsureSecretActivityArgs,
50
+ _signal?: AbortSignal,
51
+ connect: K8sConnector = defaultK8sConnector,
52
+ ): Promise<EnsureSecretOutcome> {
53
+ const { client } = await connect({
54
+ ...(args.environment !== undefined ? { environment: args.environment } : {}),
55
+ ...(args.context !== undefined ? { context: args.context } : {}),
56
+ ...(args.cwd !== undefined ? { cwd: args.cwd } : {}),
57
+ });
58
+
59
+ const marker: OwnershipMarker | undefined = args.stack
60
+ ? { stack: args.stack, ...(args.env ? { env: args.env } : {}) }
61
+ : undefined;
62
+
63
+ const store = k8sSecretStore(args.namespace ?? client.defaultNamespace, client, {
64
+ ...(marker ? { marker } : {}),
65
+ });
66
+ return ensureSecretMaterialization(store, {
67
+ name: args.name,
68
+ keys: args.keys,
69
+ ...(args.metadata ? { metadata: args.metadata } : {}),
70
+ });
71
+ }
@@ -20,6 +20,13 @@
20
20
  export { kubectlApply, applyManifest, readManifestDocuments } from "./kubectl";
21
21
  export type { KubectlApplyArgs, ApplyManifestResult, AppliedRef, ApplyDeleteMode } from "./kubectl";
22
22
 
23
+ // Generated-once secret materialization (#1830) — the activity behind core's
24
+ // `ensureSecret(...)` step builder (#1829). Read-then-write over the
25
+ // namespace-scoped store adapter in ../../secret-store.ts; no output carries
26
+ // secret material.
27
+ export { ensureSecret } from "./ensure-secret";
28
+ export type { EnsureSecretActivityArgs } from "./ensure-secret";
29
+
23
30
  export { waitForArgoSync, defaultArgoStatusFetcher, ArgoSyncFailedError } from "./argo";
24
31
  export type { WaitForArgoSyncArgs, ArgoAppStatus, ArgoStatusFetcher } from "./argo";
25
32
 
@@ -461,6 +461,38 @@ describe("the ownership-scoped prune (chant #1075)", () => {
461
461
  expect(result.pruned.map((p) => p.name)).toEqual(["old"]);
462
462
  });
463
463
 
464
+ test("a generated-once secret is excluded from the prunable set and reported retained; a plain owned secret still prunes (#1830)", async () => {
465
+ const cluster = fakeCluster({
466
+ respond: echoApplies,
467
+ objects: {
468
+ [objectKey("apps/v1", "Deployment", "web", "prod")]: ownedObject("apps/v1", "Deployment", "web", "prod"),
469
+ [objectKey("v1", "Secret", "master-key", "prod")]: ownedObject("v1", "Secret", "master-key", "prod", {
470
+ metadata: {
471
+ labels: {
472
+ "app.kubernetes.io/managed-by": "chant",
473
+ "chant.intentius.io/generated-once": "true",
474
+ },
475
+ },
476
+ }),
477
+ [objectKey("v1", "Secret", "stale-config", "prod")]: ownedObject("v1", "Secret", "stale-config", "prod"),
478
+ },
479
+ });
480
+ const result = await applyManifest(
481
+ { manifest: manifest(), deleteMode: "owned-only" },
482
+ undefined,
483
+ cluster.connector,
484
+ );
485
+
486
+ expect(result.retained).toEqual([
487
+ { apiVersion: "v1", kind: "Secret", name: "master-key", namespace: "prod" },
488
+ ]);
489
+ expect(result.pruned).toEqual([
490
+ { apiVersion: "v1", kind: "Secret", name: "stale-config", namespace: "prod" },
491
+ ]);
492
+ const deletes = cluster.layer.requests.filter((r) => r.method === "DELETE").map((r) => r.path);
493
+ expect(deletes).toEqual(["/api/v1/namespaces/prod/secrets/stale-config"]);
494
+ });
495
+
464
496
  test("an object without chant's marker is never a candidate", async () => {
465
497
  const cluster = clusterWithOrphans();
466
498
  await applyManifest({ manifest: manifest(), deleteMode: "owned-only" }, undefined, cluster.connector);
@@ -45,6 +45,7 @@ import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
45
45
  import { defaultK8sConnector, type K8sConnector } from "../../api/connect";
46
46
  import { operationFor } from "../../api/operation-surface";
47
47
  import { DEFAULT_IMPORT_TYPES } from "../../api/sweep-types";
48
+ import { isGeneratedOnce } from "../../secret-labels";
48
49
 
49
50
  /**
50
51
  * How the apply treats chant-owned objects that are no longer declared. The
@@ -133,6 +134,15 @@ export interface ApplyManifestResult {
133
134
  applied: AppliedRef[];
134
135
  /** Objects deleted because they carried chant's marker and are no longer declared. */
135
136
  pruned: AppliedRef[];
137
+ /**
138
+ * Owned, no longer declared, and deliberately KEPT (#1830, epic #1365
139
+ * decision 5): Secrets carrying the `chant.intentius.io/generated-once`
140
+ * label never enter the prunable set — the stored bytes are the only copy
141
+ * of material chant never held. Reported loudly here and on the console;
142
+ * deletion is an explicit act (`kubectl delete`, or a future gated op),
143
+ * never a prune's. Present whenever the prune ran.
144
+ */
145
+ retained?: AppliedRef[];
136
146
  }
137
147
 
138
148
  /** Read a manifest path — one file, or every YAML/JSON file in a directory. */
@@ -351,15 +361,15 @@ export async function applyManifest(
351
361
  }
352
362
 
353
363
  const deleteMode = args.deleteMode ?? "never";
354
- const pruned =
364
+ const { pruned, retained } =
355
365
  deleteMode === "never" || args.dryRun
356
- ? []
366
+ ? { pruned: [], retained: [] }
357
367
  : await pruneOrphans(client, applied, {
358
368
  ...(stack !== undefined ? { stack } : {}),
359
369
  signal,
360
370
  });
361
371
 
362
- return { fieldManager, applied, pruned };
372
+ return { fieldManager, applied, pruned, retained };
363
373
  } finally {
364
374
  clearInterval(heartbeatInterval);
365
375
  }
@@ -407,7 +417,7 @@ async function pruneOrphans(
407
417
  client: K8sClient,
408
418
  applied: readonly AppliedRef[],
409
419
  options: { stack?: string; signal?: AbortSignal },
410
- ): Promise<AppliedRef[]> {
420
+ ): Promise<{ pruned: AppliedRef[]; retained: AppliedRef[] }> {
411
421
  const stack = options.stack;
412
422
  const selector = [
413
423
  `${LABEL_OWNERSHIP_KEYS.managedBy}=${OWNERSHIP_MANAGED_BY_VALUE}`,
@@ -476,6 +486,7 @@ async function pruneOrphans(
476
486
  });
477
487
 
478
488
  const candidates: AppliedRef[] = [];
489
+ const retained: AppliedRef[] = [];
479
490
  const seen = new Set<string>();
480
491
  for (const { target, item } of found.flat()) {
481
492
  const name = item.metadata?.name;
@@ -494,6 +505,18 @@ async function pruneOrphans(
494
505
  const key = refKey(ref);
495
506
  if (declared.has(key) || seen.has(key)) continue;
496
507
  seen.add(key);
508
+ // A generated-once Secret never enters the prunable set (#1830, epic
509
+ // #1365 decision 5): its bytes are the only copy of material chant never
510
+ // held, so no sweep may destroy them. Kept, and said loudly — deletion is
511
+ // an explicit act (`kubectl delete`, or a future gated op).
512
+ if (target.kind === "Secret" && isGeneratedOnce(item.metadata?.labels)) {
513
+ retained.push(ref);
514
+ console.log(
515
+ `${ref.apiVersion} ${ref.kind}/${ref.name} retained (generated-once — owned and no longer declared, ` +
516
+ `but never pruned; delete it explicitly if you mean to)`,
517
+ );
518
+ continue;
519
+ }
497
520
  candidates.push(ref);
498
521
  }
499
522
 
@@ -504,7 +527,7 @@ async function pruneOrphans(
504
527
  safeHeartbeat({ step: "prune", pruned: `${ref.kind}/${ref.name}` });
505
528
  console.log(`${ref.apiVersion} ${ref.kind}/${ref.name} pruned (chant-owned, no longer declared)`);
506
529
  }
507
- return pruned;
530
+ return { pruned, retained };
508
531
  }
509
532
 
510
533
  function refKey(ref: AppliedRef): string {
@@ -76,6 +76,40 @@ describe("readiness model", () => {
76
76
  expect(firstTerminal(ready([{ type: "Ready", status: "False", reason: "AuthenticationFailed" } as never]), git)).toBeDefined();
77
77
  });
78
78
 
79
+ test("CNRM overrides fail fast on Ready=False wedge reasons, DependencyNotReady is not terminal (#1725)", () => {
80
+ // ContainerCluster and ContainerNodePool back examples/cockroachdb-multi-region-gke's
81
+ // `longInfra` waits — a cluster wedged at UpdateFailed used to poll out
82
+ // the whole timeout instead of failing fast.
83
+ const cluster = readinessFor("container.cnrm.cloud.google.com", "ContainerCluster");
84
+ expect(cluster).not.toBe(DEFAULT_READINESS);
85
+ expect(isReady(ready([{ type: "Ready", status: "True" }]), cluster)).toBe(true);
86
+
87
+ const updateFailed = ready([{ type: "Ready", status: "False", reason: "UpdateFailed" } as never]);
88
+ expect(isReady(updateFailed, cluster)).toBe(false);
89
+ expect(firstTerminal(updateFailed, cluster)).toBeDefined();
90
+
91
+ const managementConflict = ready([{ type: "Ready", status: "False", reason: "ManagementConflict" } as never]);
92
+ expect(firstTerminal(managementConflict, cluster)).toBeDefined();
93
+
94
+ // DependencyNotReady is the ordinary "still waiting on another CNRM
95
+ // resource" state (e.g. a cluster waiting on its network) — not a wedge.
96
+ const dependencyNotReady = ready([{ type: "Ready", status: "False", reason: "DependencyNotReady" } as never]);
97
+ expect(firstTerminal(dependencyNotReady, cluster)).toBeUndefined();
98
+
99
+ // In-progress reconciles are not terminal either.
100
+ const updating = ready([{ type: "Ready", status: "False", reason: "Updating" } as never]);
101
+ expect(firstTerminal(updating, cluster)).toBeUndefined();
102
+
103
+ const nodePool = readinessFor("container.cnrm.cloud.google.com", "ContainerNodePool");
104
+ expect(nodePool).not.toBe(DEFAULT_READINESS);
105
+ expect(
106
+ firstTerminal(ready([{ type: "Ready", status: "False", reason: "UpdateFailed" } as never]), nodePool),
107
+ ).toBeDefined();
108
+ expect(
109
+ firstTerminal(ready([{ type: "Ready", status: "False", reason: "DependencyNotReady" } as never]), nodePool),
110
+ ).toBeUndefined();
111
+ });
112
+
79
113
  test("Argo override uses health/sync, not a Ready condition", () => {
80
114
  const spec = readinessFor("argoproj.io", "Application");
81
115
  expect(spec).not.toBe(DEFAULT_READINESS);
@@ -98,6 +98,30 @@ const FLUX_TERMINAL = (reasons: string[]): ReadinessSpec => ({
98
98
  observedGeneration: true,
99
99
  });
100
100
 
101
+ /**
102
+ * Config Connector (CNRM) wedge reasons (#1725): CNRM is kstatus-conformant —
103
+ * `Ready=True` + `observedGeneration` is the correct ready predicate, same as
104
+ * `DEFAULT_READINESS` — so, like `FLUX_TERMINAL`, the value added here is
105
+ * failing fast on a `Ready=False` reason that will not heal by waiting.
106
+ * `UpdateFailed` is CNRM giving up on a reconcile (bad spec, quota, API
107
+ * error); `ManagementConflict` is two controllers fighting over the same GCP
108
+ * resource. Neither resolves on its own.
109
+ *
110
+ * `DependencyNotReady` is deliberately excluded: it is the normal state while
111
+ * a resource waits on another one it depends on (e.g. a `ContainerCluster`
112
+ * waiting on its network) — exactly the "still being created" case this spec
113
+ * has to distinguish from a real wedge, not fold into it.
114
+ *
115
+ * One entry per kind, matching `FLUX_TERMINAL`'s convention, rather than a
116
+ * group-level key: CNRM's kinds don't share a single apiGroup (`ContainerCluster`
117
+ * and `ContainerNodePool` are `container.cnrm.cloud.google.com`; `SQLInstance`
118
+ * would be `sql.cnrm.cloud.google.com`, etc.), so a group-level key would need
119
+ * prefix matching against `*.cnrm.cloud.google.com` — a real change to
120
+ * `readinessFor`'s exact-match lookup for a handful of entries. Not worth it
121
+ * until more CNRM kinds actually need the override.
122
+ */
123
+ const CNRM_TERMINAL = (): ReadinessSpec => FLUX_TERMINAL(["UpdateFailed", "ManagementConflict"]);
124
+
101
125
  export const READINESS_OVERRIDES: Record<string, ReadinessSpec> = {
102
126
  "argoproj.io/Application": {
103
127
  ready: [
@@ -139,6 +163,8 @@ export const READINESS_OVERRIDES: Record<string, ReadinessSpec> = {
139
163
  "ChartPackageError",
140
164
  "StorageOperationFailed",
141
165
  ]),
166
+ "container.cnrm.cloud.google.com/ContainerCluster": CNRM_TERMINAL(),
167
+ "container.cnrm.cloud.google.com/ContainerNodePool": CNRM_TERMINAL(),
142
168
  };
143
169
 
144
170
  /** Resolve the readiness spec for a resource: registry override, else default. */
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Typed step-builder wrappers (chant #1288 Stage 2). Two things asserted:
3
+ *
4
+ * - **Backward compat**: for every input core's original untyped builder
5
+ * accepted, the new typed twin here produces a byte-identical
6
+ * `ActivityStep` (own enumerable fields — `.out` is a non-enumerable
7
+ * getter on both, per `builders.ts`'s `activity()`, so `toEqual` never
8
+ * sees it). Same construction path (`activity()` + `takeProfileAndId`),
9
+ * so this is really asserting the two call sites agree, not re-deriving
10
+ * the logic.
11
+ * - **Step-output refs compose**: a {@link StepOutputRef} in a typed slot
12
+ * type-checks and lands in the step's `args` untouched (chant #1950).
13
+ *
14
+ * The `@ts-expect-error` block at the bottom is a compile-time-only check —
15
+ * never executed, just walked by `npm run typecheck` (tsc), which fails if a
16
+ * marked line does NOT error (an "unused '@ts-expect-error' directive").
17
+ */
18
+
19
+ import { describe, test, expect } from "vitest";
20
+ import {
21
+ kubectlApply as kubectlApplyOld,
22
+ waitForReady as waitForReadyOld,
23
+ ensureSecret as ensureSecretOld,
24
+ stepOutput,
25
+ type StepOutputRef,
26
+ } from "@intentius/chant/op";
27
+ import { kubectlApply, waitForReady, ensureSecret } from "./builders";
28
+
29
+ describe("k8s typed step builders (#1288 Stage 2)", () => {
30
+ test("kubectlApply: identical ActivityStep to core's original for a minimal call", () => {
31
+ expect(kubectlApply("dist/k8s.yaml")).toEqual(kubectlApplyOld("dist/k8s.yaml"));
32
+ });
33
+
34
+ test("kubectlApply: identical ActivityStep with opts (profile override, force, deleteMode)", () => {
35
+ const opts = { context: "kind-dev", force: true, deleteMode: "owned-only" as const, profile: "fastIdempotent" as const };
36
+ expect(kubectlApply("dist/k8s.yaml", opts)).toEqual(kubectlApplyOld("dist/k8s.yaml", opts));
37
+ });
38
+
39
+ test("kubectlApply: accepts a StepOutputRef in a typed slot", () => {
40
+ const ref = stepOutput("build-step", "manifestPath");
41
+ const step = kubectlApply("dist/k8s.yaml", { context: ref });
42
+ expect(step.args?.context).toBe(ref);
43
+ });
44
+
45
+ test("waitForReady: identical ActivityStep to core's original", () => {
46
+ const opts = { namespace: "prod", intervalMs: 5000 };
47
+ expect(waitForReady("certificate", "my-cert", opts)).toEqual(waitForReadyOld("certificate", "my-cert", opts));
48
+ });
49
+
50
+ test("ensureSecret: identical ActivityStep to core's original", () => {
51
+ const opts = { metadata: { team: "platform" } };
52
+ expect(ensureSecret("db-creds", ["password"], opts)).toEqual(ensureSecretOld("db-creds", ["password"], opts));
53
+ });
54
+
55
+ test("kubectlApply: .out is reachable when an id is given", () => {
56
+ const step = kubectlApply("dist/k8s.yaml", { id: "apply" });
57
+ const ref: StepOutputRef = step.out.fieldManager;
58
+ expect(ref.step).toBe("apply");
59
+ expect(ref.path).toBe("fieldManager");
60
+ });
61
+ });
62
+
63
+ // ── Compile-time-only: authoring-time type errors (never executed) ──────────
64
+ function _typeChecksOnly(): void {
65
+ // @ts-expect-error — "nameSpace" is not a key of KubectlApplyArgs (the
66
+ // exact typo class chant #1288 names: helmInstall("api", "./chart", {
67
+ // nameSpace: "prod" }) silently no-opped before this).
68
+ kubectlApply("dist/k8s.yaml", { nameSpace: "prod" });
69
+
70
+ // @ts-expect-error — deleteMode must be one of ApplyDeleteMode's literals.
71
+ kubectlApply("dist/k8s.yaml", { deleteMode: "always" });
72
+
73
+ // @ts-expect-error — force must be boolean, not a string.
74
+ kubectlApply("dist/k8s.yaml", { force: "yes" });
75
+
76
+ // @ts-expect-error — waitForReady's readiness spec field is misspelled.
77
+ waitForReady("certificate", "my-cert", { specc: {} });
78
+
79
+ // @ts-expect-error — ensureSecret's keys must be string[], not a single string.
80
+ ensureSecret("db-creds", "password");
81
+ }
82
+ void _typeChecksOnly;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Typed step-builder wrappers for this lexicon's activities (chant #1288
3
+ * Stage 2 — "regenerate the step builders as fully typed wrappers"). Core
4
+ * cannot import a lexicon's `*Args` types (lexicons depend on core, not the
5
+ * other way around — see `packages/core/src/op/activity-contract.ts`'s module
6
+ * doc for the same layering constraint on Stage 1's contracts), so a fully
7
+ * typed `kubectlApply`/`waitForReady`/`ensureSecret` has to live beside the
8
+ * `*Args` interface it derives from: here, next to `./activities/kubectl.ts`
9
+ * etc. `opts`'s type in each wrapper below IS the activity's own `*Args`
10
+ * interface (via `Omit`/`WithStepRefs`) — never a hand-restated mirror — so
11
+ * `kubectlApply`/`waitForReady`/`ensureSecret`'s full option surface (the
12
+ * dozen-plus cross-field options on `kubectlApply` that Stage 1's zod
13
+ * contracts deliberately skipped rather than duplicate) is authoring-time
14
+ * typed for free, and stays in sync with the implementation by construction.
15
+ *
16
+ * Every field also accepts a {@link StepOutputRef} in its place
17
+ * ({@link WithStepRefs}, chant #1950) — a later step's args can consume an
18
+ * earlier step's declared output.
19
+ *
20
+ * `core`'s own `kubectlApply`/`waitForReady`/`ensureSecret` (in
21
+ * `@intentius/chant/op`, re-exported from `@intentius/chant-lexicon-temporal`
22
+ * for lexicon-agnostic single-import convenience) are UNCHANGED and produce
23
+ * byte-identical `ActivityStep` output for the same inputs — these are purely
24
+ * additive. Deliberately not swapped into the temporal barrel: that would
25
+ * make `@intentius/chant-lexicon-temporal` depend on this package (and on
26
+ * helm, for `helmInstall`) at runtime, which is exactly the "a worker image
27
+ * needs no kubectl binary" / "temporal stays product-agnostic" property the
28
+ * `op/activities/index.ts` module docs across this repo call out on purpose
29
+ * (activities were moved OUT of temporal into per-product lexicons for this
30
+ * reason; the step-builder layer shouldn't reintroduce the coupling from the
31
+ * other direction). An author who wants the typed surface imports it from
32
+ * here — `@intentius/chant-lexicon-k8s` — which a project using `kubectlApply`
33
+ * already depends on; existing `@intentius/chant-lexicon-temporal` imports
34
+ * keep working exactly as before, opting in only if the import is changed.
35
+ */
36
+
37
+ import {
38
+ activity,
39
+ takeProfileAndId,
40
+ type ActivityStep,
41
+ type NamedActivityStep,
42
+ type WithStepRefs,
43
+ } from "@intentius/chant/op";
44
+ import type { KubectlApplyArgs } from "./activities/kubectl";
45
+ import type { WaitForReadyArgs } from "./activities/wait-for-ready";
46
+ import type { EnsureSecretActivityArgs } from "./activities/ensure-secret";
47
+
48
+ /** Extra opts every wrapper below accepts alongside its activity's own fields. */
49
+ type StepOpts = { profile?: ActivityStep["profile"]; id?: string };
50
+
51
+ /**
52
+ * Run `kubectl apply -f <manifest>` — the fully typed twin of core's
53
+ * `kubectlApply` step-builder shortcut (chant #1288 Stage 2). `opts` is
54
+ * {@link KubectlApplyArgs} itself (minus the positional `manifest`) — every
55
+ * field `applyManifest` accepts, including `deleteMode`, `force`,
56
+ * `fieldManager`, `documents`, is authoring-time typed here, not just the
57
+ * subset a hand-written zod mirror would bother restating. Defaults to the
58
+ * `longInfra` profile (override via `opts.profile`).
59
+ */
60
+ export const kubectlApply = (
61
+ manifest: string,
62
+ opts?: WithStepRefs<Omit<KubectlApplyArgs, "manifest">> & StepOpts,
63
+ ): NamedActivityStep => {
64
+ const { args, profile, id } = takeProfileAndId(opts as Record<string, unknown> | undefined);
65
+ return activity("kubectlApply", { manifest, ...args }, { profile: profile ?? "longInfra", ...(id ? { id } : {}) });
66
+ };
67
+
68
+ /**
69
+ * Poll a Kubernetes resource until it reports ready, driven by a data-only
70
+ * readiness spec (CRD-aware; #365) — the fully typed twin of core's
71
+ * `waitForReady`. `opts` is {@link WaitForReadyArgs} itself, minus the
72
+ * positional `kind`/`name`. Defaults to the `k8sWait` profile.
73
+ */
74
+ export const waitForReady = (
75
+ kind: string,
76
+ name: string,
77
+ opts?: WithStepRefs<Omit<WaitForReadyArgs, "kind" | "name">> & StepOpts,
78
+ ): NamedActivityStep => {
79
+ const { args, profile, id } = takeProfileAndId(opts as Record<string, unknown> | undefined);
80
+ return activity("waitForReady", { kind, name, ...args }, { profile: profile ?? "k8sWait", ...(id ? { id } : {}) });
81
+ };
82
+
83
+ /**
84
+ * Ensure a `generated-once` secret exists in the target store (#1829) — the
85
+ * fully typed twin of core's `ensureSecret`. `opts` is
86
+ * {@link EnsureSecretActivityArgs} itself, minus the positional `name`/`keys`.
87
+ */
88
+ export const ensureSecret = (
89
+ name: string,
90
+ keys: string[],
91
+ opts?: WithStepRefs<Omit<EnsureSecretActivityArgs, "name" | "keys">> & StepOpts,
92
+ ): NamedActivityStep => {
93
+ const { args, profile, id } = takeProfileAndId(opts as Record<string, unknown> | undefined);
94
+ return activity("ensureSecret", { name, keys, ...args }, { profile, ...(id ? { id } : {}) });
95
+ };
package/src/plugin.ts CHANGED
@@ -31,6 +31,8 @@ import { LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
31
31
  import { k8sConfigSchema } from "./config-schema";
32
32
  import type { K8sChantConfig } from "./config";
33
33
  import { renderKustomizeRoots } from "./kustomize/root";
34
+ import { encryptedSecretBuildRoot } from "./sops/encrypted-secret-file";
35
+ import type { Declarable } from "@intentius/chant/declarable";
34
36
 
35
37
  export const k8sPlugin: LexiconPlugin = {
36
38
  name: "k8s",
@@ -79,10 +81,27 @@ export const k8sPlugin: LexiconPlugin = {
79
81
  // fallback) and the documents join the build as verbatim manifest entities:
80
82
  // serialized with ownership stamping, checked post-synth, observed by
81
83
  // `lifecycle diff --live`. See ./kustomize/root.ts.
84
+ //
85
+ // The same hook also resolves committed-encrypted `declareSecret()`
86
+ // declarations: it reads the ciphertext each one names and contributes it
87
+ // as a verbatim sidecar entity (see ./sops/encrypted-secret-file.ts). Both
88
+ // halves are one filesystem read at the one sanctioned impure seam.
82
89
  async buildRoots(ctx) {
90
+ const entities = new Map<string, Declarable>();
91
+ const warnings: string[] = [];
92
+
83
93
  const roots = (ctx.config as { k8s?: K8sChantConfig }).k8s?.kustomize?.roots ?? [];
84
- if (roots.length === 0) return { entities: new Map() };
85
- return renderKustomizeRoots({ projectRoot: ctx.projectRoot, roots });
94
+ if (roots.length > 0) {
95
+ const rendered = await renderKustomizeRoots({ projectRoot: ctx.projectRoot, roots });
96
+ for (const [name, entity] of rendered.entities) entities.set(name, entity);
97
+ warnings.push(...rendered.warnings);
98
+ }
99
+
100
+ const encrypted = await encryptedSecretBuildRoot(ctx);
101
+ for (const [name, entity] of encrypted.entities) entities.set(name, entity);
102
+ warnings.push(...(encrypted.warnings ?? []));
103
+
104
+ return { entities, warnings };
86
105
  },
87
106
 
88
107
  // K8s YAML has no template interpolation functions like CloudFormation's
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The `generated-once` marker label (chant #1830, epic #1365 decision 5).
3
+ *
4
+ * `k8sSecretStore` (./secret-store.ts) stamps this label onto every Secret it
5
+ * mints, next to the ownership marker. Both k8s prune paths key on it:
6
+ *
7
+ * - the apply-path prune (`applyManifest`'s owned-only sweep,
8
+ * op/activities/kubectl.ts) excludes a labeled Secret from the prunable set
9
+ * and reports it `retained` instead of deleting it;
10
+ * - env teardown (./teardown.ts `executeTeardown`) reports the same `retained`
11
+ * outcome for it, loudly, and never deletes it.
12
+ *
13
+ * The rationale is the materialization contract: the stored bytes are the ONLY
14
+ * copy of material chant never held, so no sweep may destroy them. Deletion is
15
+ * an explicit human act — `kubectl delete secret <name>`, or a future gated
16
+ * op — never a side effect of "no longer declared".
17
+ *
18
+ * Dependency-light on purpose: no client import, so the constants are usable
19
+ * from the build path, the prune paths, and the docs/tests alike.
20
+ */
21
+
22
+ /** The label key a generated-once Secret carries. */
23
+ export const GENERATED_ONCE_LABEL_KEY = "chant.intentius.io/generated-once";
24
+
25
+ /** The label value `k8sSecretStore` stamps. */
26
+ export const GENERATED_ONCE_LABEL_VALUE = "true";
27
+
28
+ /**
29
+ * True when a live object's labels mark it generated-once. Any non-empty
30
+ * value counts: the label's presence is the claim, and a sweep must err on
31
+ * the side of keeping.
32
+ */
33
+ export function isGeneratedOnce(labels: Record<string, unknown> | undefined): boolean {
34
+ const value = labels?.[GENERATED_ONCE_LABEL_KEY];
35
+ return typeof value === "string" && value.length > 0;
36
+ }