@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,76 @@
1
+ /**
2
+ * Shared helper for the committed-encrypted secret checks: join each
3
+ * `declareSecret({ provenance: "committed-encrypted" })` in the entity map to
4
+ * the sidecar file the build emitted for it.
5
+ *
6
+ * The join has to go through `output.files`, not `getPrimaryOutput`. The
7
+ * ciphertext is deliberately kept OUT of the primary output — that is what
8
+ * makes applying an undecrypted Secret structurally impossible — so a check
9
+ * that reads only the primary output sees nothing at all here.
10
+ *
11
+ * Excluded from check auto-discovery by the "helper" filename filter.
12
+ */
13
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
14
+ import type { CommittedEncryptedSecretDeclaration } from "@intentius/chant/secret-provenance";
15
+ import { getAdditionalFiles } from "./k8s-helpers";
16
+ import { committedEncryptedDeclarations, sidecarFilename, validateEncryptedSecretDocument, type EncryptedSecretDocument } from "../../sops/encrypted-secret-file";
17
+
18
+ /** One committed-encrypted declaration, joined to what the build emitted for it. */
19
+ export interface ResolvedEncryptedSecret {
20
+ /** The entity-map key the declaration was discovered under. */
21
+ entityName: string;
22
+ declaration: CommittedEncryptedSecretDeclaration;
23
+ /** The sidecar filename the declaration's path maps to. */
24
+ filename: string;
25
+ /** Undefined when the build emitted no such sidecar — the file did not resolve. */
26
+ text?: string;
27
+ /** Everything wrong with the emitted document. Empty when it is clean. */
28
+ problems: string[];
29
+ /** Parsed structure, when the document parsed at all. */
30
+ document?: EncryptedSecretDocument;
31
+ }
32
+
33
+ /** Every sidecar file across every lexicon output, keyed by filename. */
34
+ export function allAdditionalFiles(ctx: PostSynthContext): Map<string, string> {
35
+ const files = new Map<string, string>();
36
+ for (const [, output] of ctx.outputs) {
37
+ for (const [filename, content] of Object.entries(getAdditionalFiles(output))) {
38
+ files.set(filename, content);
39
+ }
40
+ }
41
+ return files;
42
+ }
43
+
44
+ /**
45
+ * Join every committed-encrypted declaration to its emitted sidecar,
46
+ * validating the document as it goes. A declaration with no sidecar comes
47
+ * back with `text` undefined and one problem naming the missing file.
48
+ */
49
+ export function resolveEncryptedSecretClaims(ctx: PostSynthContext): ResolvedEncryptedSecret[] {
50
+ const declarations = committedEncryptedDeclarations(ctx.entities);
51
+ if (declarations.size === 0) return [];
52
+
53
+ const files = allAdditionalFiles(ctx);
54
+ const resolved: ResolvedEncryptedSecret[] = [];
55
+
56
+ for (const [entityName, declaration] of declarations) {
57
+ const filename = sidecarFilename(declaration.file);
58
+ const text = files.get(filename);
59
+ if (text === undefined) {
60
+ resolved.push({
61
+ entityName,
62
+ declaration,
63
+ filename,
64
+ problems: [
65
+ `the build emitted no file for "${declaration.file}" — ` +
66
+ `the declared ciphertext did not resolve`,
67
+ ],
68
+ });
69
+ continue;
70
+ }
71
+ const { problems, document } = validateEncryptedSecretDocument(text, declaration);
72
+ resolved.push({ entityName, declaration, filename, text, problems, document });
73
+ }
74
+
75
+ return resolved;
76
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * WK8404: GPU request without a matching toleration
3
+ *
4
+ * A pod that requests `nvidia.com/gpu` but carries no toleration for the
5
+ * `nvidia.com/gpu` taint won't schedule onto a tainted GPU node pool (see
6
+ * `GpuNodePool`, #987, which taints its nodes `nvidia.com/gpu=present` by
7
+ * default). The pod sits `Pending` with `0/N nodes are available` until a
8
+ * toleration is added.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import {
13
+ getPrimaryOutput,
14
+ parseK8sManifests,
15
+ extractContainers,
16
+ extractPodSpec,
17
+ requestsGpu,
18
+ toleratesGpu,
19
+ GPU_POD_KINDS,
20
+ } from "./k8s-helpers";
21
+
22
+ export const wk8404: PostSynthCheck = {
23
+ id: "WK8404",
24
+ description: "GPU request without a matching nvidia.com/gpu toleration",
25
+
26
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
27
+ const diagnostics: PostSynthDiagnostic[] = [];
28
+
29
+ for (const [, output] of ctx.outputs) {
30
+ const yaml = getPrimaryOutput(output);
31
+ const manifests = parseK8sManifests(yaml);
32
+
33
+ for (const manifest of manifests) {
34
+ if (!manifest.kind || !GPU_POD_KINDS.has(manifest.kind)) continue;
35
+
36
+ const containers = extractContainers(manifest);
37
+ if (!requestsGpu(containers)) continue;
38
+
39
+ const podSpec = extractPodSpec(manifest);
40
+ if (toleratesGpu(podSpec?.tolerations)) continue;
41
+
42
+ const resourceName = manifest.metadata?.name ?? manifest.kind;
43
+ diagnostics.push({
44
+ checkId: "WK8404",
45
+ severity: "error",
46
+ message: `${manifest.kind} "${resourceName}" requests nvidia.com/gpu but has no toleration for the nvidia.com/gpu taint — it will not schedule onto a tainted GPU node pool`,
47
+ entity: resourceName,
48
+ lexicon: "k8s",
49
+ });
50
+ }
51
+ }
52
+
53
+ return diagnostics;
54
+ },
55
+ };
@@ -0,0 +1,88 @@
1
+ /**
2
+ * WK8405: Serving workload without a PodDisruptionBudget
3
+ *
4
+ * An InferenceService (or a Deployment carrying a serving
5
+ * `app.kubernetes.io/component` label — e.g. `inference-service` or
6
+ * `vllm-serving-runtime`) with no PodDisruptionBudget selecting it can lose
7
+ * every replica at once during a voluntary disruption (node drain, cluster
8
+ * upgrade), taking the model endpoint down. Serving workloads should ship
9
+ * with a PDB.
10
+ */
11
+
12
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
13
+ import { getPrimaryOutput, parseK8sManifests } from "./k8s-helpers";
14
+ import type { K8sManifest } from "./k8s-helpers";
15
+
16
+ /** `app.kubernetes.io/component` values this lexicon's composites use for serving workloads. */
17
+ const SERVING_COMPONENTS = new Set(["inference-service", "vllm-serving-runtime", "model-serving"]);
18
+
19
+ function isServingWorkload(manifest: K8sManifest): boolean {
20
+ if (manifest.kind === "InferenceService") return true;
21
+ if (manifest.kind !== "Deployment" && manifest.kind !== "StatefulSet") return false;
22
+ const component = manifest.metadata?.labels?.["app.kubernetes.io/component"];
23
+ return component !== undefined && SERVING_COMPONENTS.has(component);
24
+ }
25
+
26
+ export const wk8405: PostSynthCheck = {
27
+ id: "WK8405",
28
+ description: "Serving workload (InferenceService / serving Deployment) has no PodDisruptionBudget",
29
+
30
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
31
+ const diagnostics: PostSynthDiagnostic[] = [];
32
+
33
+ for (const [, output] of ctx.outputs) {
34
+ const yaml = getPrimaryOutput(output);
35
+ const manifests = parseK8sManifests(yaml);
36
+
37
+ const pdbSelectors = collectPdbSelectors(manifests);
38
+
39
+ for (const manifest of manifests) {
40
+ if (!isServingWorkload(manifest)) continue;
41
+
42
+ const labels = manifest.metadata?.labels;
43
+ const resourceName = manifest.metadata?.name ?? manifest.kind ?? "workload";
44
+
45
+ if (!labels || !hasCoveringPdb(labels, pdbSelectors)) {
46
+ diagnostics.push({
47
+ checkId: "WK8405",
48
+ severity: "info",
49
+ message: `${manifest.kind} "${resourceName}" is a serving workload with no PodDisruptionBudget — add a PDB so a node drain or cluster upgrade cannot take down every replica at once`,
50
+ entity: resourceName,
51
+ lexicon: "k8s",
52
+ });
53
+ }
54
+ }
55
+ }
56
+
57
+ return diagnostics;
58
+ },
59
+ };
60
+
61
+ function collectPdbSelectors(manifests: K8sManifest[]): Array<Record<string, string>> {
62
+ const selectors: Array<Record<string, string>> = [];
63
+
64
+ for (const manifest of manifests) {
65
+ if (manifest.kind !== "PodDisruptionBudget") continue;
66
+
67
+ const spec = manifest.spec;
68
+ if (!spec) continue;
69
+
70
+ const selector = spec.selector as Record<string, unknown> | undefined;
71
+ const matchLabels = selector?.matchLabels as Record<string, string> | undefined;
72
+
73
+ if (matchLabels && typeof matchLabels === "object") {
74
+ selectors.push(matchLabels);
75
+ }
76
+ }
77
+
78
+ return selectors;
79
+ }
80
+
81
+ function hasCoveringPdb(
82
+ workloadLabels: Record<string, string>,
83
+ pdbSelectors: Array<Record<string, string>>,
84
+ ): boolean {
85
+ return pdbSelectors.some((pdbLabels) =>
86
+ Object.entries(pdbLabels).every(([key, value]) => workloadLabels[key] === value),
87
+ );
88
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * WK8406: No resource limits on a GPU pod
3
+ *
4
+ * A container that requests `nvidia.com/gpu` but sets no cpu/memory limits
5
+ * can starve other pods on the same (expensive, scarce) GPU node of CPU and
6
+ * memory headroom. Unlike WK8201's general resource-limits guidance, this
7
+ * check is scoped to GPU-requesting containers specifically, since GPU node
8
+ * capacity is costlier and more contended than a general-purpose pool.
9
+ */
10
+
11
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
12
+ import { getPrimaryOutput, parseK8sManifests, extractContainers, GPU_POD_KINDS, NVIDIA_GPU_RESOURCE } from "./k8s-helpers";
13
+
14
+ export const wk8406: PostSynthCheck = {
15
+ id: "WK8406",
16
+ description: "GPU-requesting container has no cpu/memory resource limits",
17
+
18
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
19
+ const diagnostics: PostSynthDiagnostic[] = [];
20
+
21
+ for (const [, output] of ctx.outputs) {
22
+ const yaml = getPrimaryOutput(output);
23
+ const manifests = parseK8sManifests(yaml);
24
+
25
+ for (const manifest of manifests) {
26
+ if (!manifest.kind || !GPU_POD_KINDS.has(manifest.kind)) continue;
27
+
28
+ const containers = extractContainers(manifest);
29
+ const resourceName = manifest.metadata?.name ?? manifest.kind;
30
+
31
+ for (const container of containers) {
32
+ const requests = container.resources?.requests as Record<string, unknown> | undefined;
33
+ const limits = container.resources?.limits as Record<string, unknown> | undefined;
34
+ const requestsGpu = Boolean(requests?.[NVIDIA_GPU_RESOURCE]) || Boolean(limits?.[NVIDIA_GPU_RESOURCE]);
35
+ if (!requestsGpu) continue;
36
+
37
+ const missing: string[] = [];
38
+ if (!limits || typeof limits !== "object") {
39
+ missing.push("cpu", "memory");
40
+ } else {
41
+ if (!limits.cpu) missing.push("cpu");
42
+ if (!limits.memory) missing.push("memory");
43
+ }
44
+
45
+ if (missing.length > 0) {
46
+ diagnostics.push({
47
+ checkId: "WK8406",
48
+ severity: "warning",
49
+ message: `GPU container "${container.name ?? "(unnamed)"}" in ${manifest.kind} "${resourceName}" is missing resource limits for ${missing.join(", ")} — a GPU pod without cpu/memory limits can starve its (scarce, expensive) node`,
50
+ entity: resourceName,
51
+ lexicon: "k8s",
52
+ });
53
+ }
54
+ }
55
+ }
56
+ }
57
+
58
+ return diagnostics;
59
+ },
60
+ };
@@ -0,0 +1,71 @@
1
+ /**
2
+ * WK8407: Unpinned model version
3
+ *
4
+ * KServe's InferenceService predictor pins a model by `storageUri`. The
5
+ * `Model` composite (#986) always appends `version` as the trailing path
6
+ * segment (`scheme://id/version`), so a resolved `Model` reference is
7
+ * always pinned — but `InferenceService.model` also accepts a raw
8
+ * `storageUri` string (#985), which can bypass that guarantee (e.g.
9
+ * `"gs://bucket/model"` with no version segment, or a floating tag like
10
+ * `"latest"`/`"main"`). An unpinned reference means a redeploy of the same
11
+ * InferenceService can silently pick up different weights.
12
+ */
13
+
14
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
15
+ import { getPrimaryOutput, parseK8sManifests } from "./k8s-helpers";
16
+
17
+ /** Trailing path segments that mean "no explicit version" even though present. */
18
+ const FLOATING_TAGS = new Set(["latest", "main", "master", "head"]);
19
+
20
+ /**
21
+ * True when `storageUri` has an explicit, non-floating version segment:
22
+ * `scheme://path/version` with at least two path segments after the
23
+ * scheme, and the last segment isn't a known floating tag.
24
+ */
25
+ export function isPinnedStorageUri(storageUri: string): boolean {
26
+ const m = /^[a-z][a-z0-9+.-]*:\/\/(.+)$/i.exec(storageUri.trim());
27
+ if (!m) return false;
28
+
29
+ const segments = m[1].split("/").filter((s) => s.length > 0);
30
+ if (segments.length < 2) return false;
31
+
32
+ const last = segments[segments.length - 1].toLowerCase();
33
+ return !FLOATING_TAGS.has(last);
34
+ }
35
+
36
+ export const wk8407: PostSynthCheck = {
37
+ id: "WK8407",
38
+ description: "InferenceService model storageUri has no explicit version segment",
39
+
40
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
41
+ const diagnostics: PostSynthDiagnostic[] = [];
42
+
43
+ for (const [, output] of ctx.outputs) {
44
+ const yaml = getPrimaryOutput(output);
45
+ const manifests = parseK8sManifests(yaml);
46
+
47
+ for (const manifest of manifests) {
48
+ if (manifest.kind !== "InferenceService") continue;
49
+
50
+ const spec = manifest.spec as Record<string, unknown> | undefined;
51
+ const predictor = spec?.predictor as Record<string, unknown> | undefined;
52
+ const model = predictor?.model as Record<string, unknown> | undefined;
53
+ const storageUri = model?.storageUri as string | undefined;
54
+ if (!storageUri) continue;
55
+
56
+ if (!isPinnedStorageUri(storageUri)) {
57
+ const resourceName = manifest.metadata?.name ?? "InferenceService";
58
+ diagnostics.push({
59
+ checkId: "WK8407",
60
+ severity: "warning",
61
+ message: `InferenceService "${resourceName}": model storageUri "${storageUri}" has no explicit version segment — pin it (e.g. via the Model composite's required \`version\`) so a redeploy can't silently pick up different weights`,
62
+ entity: resourceName,
63
+ lexicon: "k8s",
64
+ });
65
+ }
66
+ }
67
+ }
68
+
69
+ return diagnostics;
70
+ },
71
+ };
@@ -0,0 +1,220 @@
1
+ /**
2
+ * WK8503: Workload consumes a Secret nothing in the output produces
3
+ *
4
+ * A pod that references a Secret which no manifest in the same build creates
5
+ * passes `chant build`, passes `chant lint`, applies cleanly — and then fails
6
+ * at pod start with `CreateContainerConfigError` (chant #1382). This check
7
+ * closes that gap with the same cross-resource bundle-join ARGO003 uses: every
8
+ * secret reference in a pod spec (envFrom.secretRef, env valueFrom
9
+ * secretKeyRef, secret volumes, projected secret sources, imagePullSecrets)
10
+ * must resolve to a Secret the output produces.
11
+ *
12
+ * Producers are the Secret manifests themselves plus the secret-materializing
13
+ * CRs the lexicon knows: `ExternalSecret` (spec.target.name, defaulting to the
14
+ * CR's own name), `InfisicalSecret` / `InfisicalDynamicSecret`
15
+ * (spec.managedSecretReference.secretName — its secretNamespace is honored
16
+ * separately from the CR's metadata.namespace), and cert-manager
17
+ * `Certificate` (spec.secretName). Namespaces must be compatible; a side with
18
+ * no namespace matches any.
19
+ *
20
+ * The typed waiver is a `SecretProvenance` declaration (chant #1828, epic
21
+ * #1365): a `declareSecret({ name, provenance: "referenced" | "from-provider"
22
+ * | "generated-once" })` covering the secret's name says the estate knows
23
+ * where the value comes from, and silences the check. That is what lets this
24
+ * ship at error severity with no suppression comment — a secret minted out of
25
+ * band is declared, not ignored. References marked `optional: true` are
26
+ * skipped (the author already said it may not exist).
27
+ *
28
+ * `committed-encrypted` is deliberately NOT in that waiver set. The other
29
+ * three kinds are promises about something outside the build; a
30
+ * committed-encrypted declaration is a claim about an artifact inside it, and
31
+ * the build emits that artifact. So it joins the PRODUCER set instead, with
32
+ * its namespace read from the emitted ciphertext (SOPS leaves metadata
33
+ * cleartext) — which means namespace matching applies to it exactly as it
34
+ * does to a literal Secret, and a declaration whose file is broken fails
35
+ * WK8504 rather than silently waiving this check.
36
+ */
37
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
38
+ import { collectSecretDeclarations } from "@intentius/chant/secret-provenance";
39
+ import { extractPodSpec, type K8sManifest } from "./k8s-helpers";
40
+ import { allManifests } from "./argo-helpers";
41
+ import { resolveEncryptedSecretClaims } from "./sops-helpers";
42
+
43
+ /** A Secret some manifest in the output materializes. */
44
+ interface ProducedSecret {
45
+ name: string;
46
+ namespace?: string;
47
+ }
48
+
49
+ /** One secret reference found in a pod spec. */
50
+ interface SecretRef {
51
+ name: string;
52
+ /** Which field referenced it, for the message (e.g. "envFrom.secretRef"). */
53
+ via: string;
54
+ }
55
+
56
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
57
+ return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : undefined;
58
+ }
59
+
60
+ function asArray(value: unknown): unknown[] {
61
+ return Array.isArray(value) ? value : [];
62
+ }
63
+
64
+ /** Collect every Secret the output produces, from literal Secrets and the
65
+ * secret-materializing CR kinds the lexicon ships. */
66
+ function collectProducedSecrets(manifests: K8sManifest[]): ProducedSecret[] {
67
+ const produced: ProducedSecret[] = [];
68
+ for (const m of manifests) {
69
+ const metaName = m.metadata?.name;
70
+ const metaNs = m.metadata?.namespace;
71
+ switch (m.kind) {
72
+ case "Secret": {
73
+ if (typeof metaName === "string") produced.push({ name: metaName, namespace: metaNs });
74
+ break;
75
+ }
76
+ case "ExternalSecret": {
77
+ // external-secrets.io — writes spec.target.name, defaulting to the CR's own name.
78
+ const target = asRecord(m.spec?.target);
79
+ const targetName = typeof target?.name === "string" ? target.name : metaName;
80
+ if (typeof targetName === "string") produced.push({ name: targetName, namespace: metaNs });
81
+ break;
82
+ }
83
+ case "InfisicalSecret":
84
+ case "InfisicalDynamicSecret": {
85
+ // secrets.infisical.com — writes spec.managedSecretReference.secretName;
86
+ // secretNamespace is carried separately from the CR's own namespace.
87
+ const ref = asRecord(m.spec?.managedSecretReference);
88
+ if (typeof ref?.secretName === "string") {
89
+ const ns = typeof ref.secretNamespace === "string" ? ref.secretNamespace : metaNs;
90
+ produced.push({ name: ref.secretName, namespace: ns });
91
+ }
92
+ break;
93
+ }
94
+ case "Certificate": {
95
+ // cert-manager — materializes the keypair into spec.secretName.
96
+ const secretName = m.spec?.secretName;
97
+ if (typeof secretName === "string") produced.push({ name: secretName, namespace: metaNs });
98
+ break;
99
+ }
100
+ }
101
+ }
102
+ return produced;
103
+ }
104
+
105
+ /** Collect every non-optional secret reference in a pod spec. */
106
+ function collectSecretRefs(podSpec: Record<string, unknown>): SecretRef[] {
107
+ const refs: SecretRef[] = [];
108
+
109
+ const containers = [
110
+ ...asArray(podSpec.containers),
111
+ ...asArray(podSpec.initContainers),
112
+ ...asArray(podSpec.ephemeralContainers),
113
+ ];
114
+ for (const c of containers) {
115
+ const container = asRecord(c);
116
+ if (!container) continue;
117
+ for (const e of asArray(container.envFrom)) {
118
+ const secretRef = asRecord(asRecord(e)?.secretRef);
119
+ if (typeof secretRef?.name === "string" && secretRef.optional !== true) {
120
+ refs.push({ name: secretRef.name, via: "envFrom.secretRef" });
121
+ }
122
+ }
123
+ for (const e of asArray(container.env)) {
124
+ const keyRef = asRecord(asRecord(asRecord(e)?.valueFrom)?.secretKeyRef);
125
+ if (typeof keyRef?.name === "string" && keyRef.optional !== true) {
126
+ refs.push({ name: keyRef.name, via: "env[].valueFrom.secretKeyRef" });
127
+ }
128
+ }
129
+ }
130
+
131
+ for (const v of asArray(podSpec.volumes)) {
132
+ const volume = asRecord(v);
133
+ if (!volume) continue;
134
+ const secret = asRecord(volume.secret);
135
+ if (typeof secret?.secretName === "string" && secret.optional !== true) {
136
+ refs.push({ name: secret.secretName, via: "volumes[].secret.secretName" });
137
+ }
138
+ const projected = asRecord(volume.projected);
139
+ for (const s of asArray(projected?.sources)) {
140
+ const source = asRecord(asRecord(s)?.secret);
141
+ if (typeof source?.name === "string" && source.optional !== true) {
142
+ refs.push({ name: source.name, via: "volumes[].projected.sources[].secret" });
143
+ }
144
+ }
145
+ }
146
+
147
+ for (const p of asArray(podSpec.imagePullSecrets)) {
148
+ const pull = asRecord(p);
149
+ if (typeof pull?.name === "string") {
150
+ refs.push({ name: pull.name, via: "imagePullSecrets" });
151
+ }
152
+ }
153
+
154
+ return refs;
155
+ }
156
+
157
+ /** True when a produced Secret satisfies a reference. A side with no
158
+ * namespace matches any — only two explicit, different namespaces miss. */
159
+ function producerCovers(producer: ProducedSecret, name: string, namespace: string | undefined): boolean {
160
+ if (producer.name !== name) return false;
161
+ return producer.namespace === undefined || namespace === undefined || producer.namespace === namespace;
162
+ }
163
+
164
+ export const wk8503: PostSynthCheck = {
165
+ id: "WK8503",
166
+ description:
167
+ "Workload consumes a Secret nothing in the output produces — produce it (Secret, ExternalSecret, InfisicalSecret, Certificate) or declare its provenance with declareSecret()",
168
+
169
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
170
+ const diagnostics: PostSynthDiagnostic[] = [];
171
+ const manifests = allManifests(ctx);
172
+ const produced = collectProducedSecrets(manifests);
173
+
174
+ // Committed ciphertext produces a Secret as surely as a Secret manifest
175
+ // does — Flux decrypts the emitted sidecar straight into the cluster — so
176
+ // it joins the producer set, namespace and all. A claim whose file did
177
+ // not resolve produces nothing: WK8504 reports why, and this check still
178
+ // fires, which is the point of not treating it as a waiver.
179
+ for (const claim of resolveEncryptedSecretClaims(ctx)) {
180
+ if (claim.problems.length > 0) continue;
181
+ produced.push({ name: claim.declaration.name, namespace: claim.document?.namespace });
182
+ }
183
+
184
+ // The typed waiver: a SecretProvenance declaration covering the name, for
185
+ // the three kinds whose value genuinely comes from outside this build.
186
+ const declaredNames = new Set<string>();
187
+ for (const decl of collectSecretDeclarations(ctx.entities).values()) {
188
+ if (decl.provenance === "committed-encrypted") continue;
189
+ declaredNames.add(decl.name);
190
+ }
191
+
192
+ for (const manifest of manifests) {
193
+ const podSpec = extractPodSpec(manifest);
194
+ if (!podSpec) continue;
195
+ const workload = manifest.metadata?.name ?? manifest.kind ?? "workload";
196
+ const namespace = manifest.metadata?.namespace;
197
+
198
+ const reported = new Set<string>();
199
+ for (const ref of collectSecretRefs(podSpec)) {
200
+ if (reported.has(ref.name)) continue;
201
+ if (declaredNames.has(ref.name)) continue;
202
+ if (produced.some((p) => producerCovers(p, ref.name, namespace))) continue;
203
+ reported.add(ref.name);
204
+ diagnostics.push({
205
+ checkId: "WK8503",
206
+ severity: "error",
207
+ message:
208
+ `${manifest.kind} "${workload}" consumes Secret "${ref.name}" (via ${ref.via}), ` +
209
+ `but nothing in the build output produces it — the pod will fail at start. ` +
210
+ `Produce it in this build, or declare its provenance: ` +
211
+ `declareSecret({ name: "${ref.name}", provenance: "referenced" }).`,
212
+ entity: workload,
213
+ lexicon: "k8s",
214
+ });
215
+ }
216
+ }
217
+
218
+ return diagnostics;
219
+ },
220
+ };
@@ -0,0 +1,51 @@
1
+ /**
2
+ * WK8504: committed-encrypted secret declaration does not resolve
3
+ *
4
+ * `declareSecret({ provenance: "committed-encrypted", file })` is a claim
5
+ * about an artifact INSIDE the build, which is what separates it from the
6
+ * three kinds that promise something outside one. A claim inside the build
7
+ * should be verified rather than taken on trust, and this is the check that
8
+ * makes it falsifiable: it fires when the declared file produced no sidecar,
9
+ * when the emitted document is not a `v1` Secret of the declared name, when
10
+ * it carries no `sops` block, or when a `data`/`stringData` value is not
11
+ * `ENC[...]`-shaped.
12
+ *
13
+ * That last case is the failure this rule exists for: someone edits
14
+ * `db-credentials.sops.yaml` by hand, forgets `sops -e`, and commits
15
+ * plaintext. Before this, nothing in chant would notice.
16
+ *
17
+ * It reads `output.files`, not `getPrimaryOutput`. The ciphertext is
18
+ * deliberately kept out of the primary output — that is what makes applying
19
+ * an undecrypted Secret structurally impossible — so every other post-synth
20
+ * check in the lexicon is blind to it by construction, and this one has to
21
+ * ask for the sidecars explicitly.
22
+ *
23
+ * A value is only ever tested for the `ENC[` prefix; the message names the
24
+ * offending KEY, never what was found there.
25
+ */
26
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
27
+ import { resolveEncryptedSecretClaims } from "./sops-helpers";
28
+
29
+ export const wk8504: PostSynthCheck = {
30
+ id: "WK8504",
31
+ description:
32
+ "committed-encrypted secret declaration does not resolve — the declared file is missing, is not the named Secret, or is not encrypted",
33
+
34
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
35
+ const diagnostics: PostSynthDiagnostic[] = [];
36
+
37
+ for (const claim of resolveEncryptedSecretClaims(ctx)) {
38
+ for (const problem of claim.problems) {
39
+ diagnostics.push({
40
+ checkId: "WK8504",
41
+ severity: "error",
42
+ message: `declareSecret("${claim.declaration.name}"): ${problem}`,
43
+ entity: claim.declaration.name,
44
+ lexicon: "k8s",
45
+ });
46
+ }
47
+ }
48
+
49
+ return diagnostics;
50
+ },
51
+ };