@intentius/chant-lexicon-k8s 0.0.12

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 (123) hide show
  1. package/dist/integrity.json +32 -0
  2. package/dist/manifest.json +8 -0
  3. package/dist/meta.json +1413 -0
  4. package/dist/rules/hardcoded-namespace.ts +56 -0
  5. package/dist/rules/k8s-helpers.ts +149 -0
  6. package/dist/rules/wk8005.ts +59 -0
  7. package/dist/rules/wk8006.ts +68 -0
  8. package/dist/rules/wk8041.ts +73 -0
  9. package/dist/rules/wk8042.ts +48 -0
  10. package/dist/rules/wk8101.ts +65 -0
  11. package/dist/rules/wk8102.ts +42 -0
  12. package/dist/rules/wk8103.ts +45 -0
  13. package/dist/rules/wk8104.ts +69 -0
  14. package/dist/rules/wk8105.ts +45 -0
  15. package/dist/rules/wk8201.ts +55 -0
  16. package/dist/rules/wk8202.ts +46 -0
  17. package/dist/rules/wk8203.ts +46 -0
  18. package/dist/rules/wk8204.ts +54 -0
  19. package/dist/rules/wk8205.ts +56 -0
  20. package/dist/rules/wk8207.ts +45 -0
  21. package/dist/rules/wk8208.ts +45 -0
  22. package/dist/rules/wk8209.ts +45 -0
  23. package/dist/rules/wk8301.ts +51 -0
  24. package/dist/rules/wk8302.ts +46 -0
  25. package/dist/rules/wk8303.ts +96 -0
  26. package/dist/skills/chant-k8s.md +433 -0
  27. package/dist/types/index.d.ts +2934 -0
  28. package/package.json +30 -0
  29. package/src/actions/actions.test.ts +83 -0
  30. package/src/actions/apps.ts +23 -0
  31. package/src/actions/batch.ts +9 -0
  32. package/src/actions/core.ts +62 -0
  33. package/src/actions/index.ts +50 -0
  34. package/src/actions/networking.ts +15 -0
  35. package/src/actions/rbac.ts +13 -0
  36. package/src/codegen/docs-cli.ts +3 -0
  37. package/src/codegen/docs.ts +1147 -0
  38. package/src/codegen/generate-cli.ts +41 -0
  39. package/src/codegen/generate-lexicon.ts +69 -0
  40. package/src/codegen/generate-typescript.ts +97 -0
  41. package/src/codegen/generate.ts +144 -0
  42. package/src/codegen/naming.test.ts +63 -0
  43. package/src/codegen/naming.ts +187 -0
  44. package/src/codegen/package.ts +56 -0
  45. package/src/codegen/patches.ts +108 -0
  46. package/src/codegen/snapshot.test.ts +95 -0
  47. package/src/codegen/typecheck.test.ts +24 -0
  48. package/src/codegen/typecheck.ts +4 -0
  49. package/src/codegen/versions.ts +43 -0
  50. package/src/composites/autoscaled-service.ts +236 -0
  51. package/src/composites/composites.test.ts +1109 -0
  52. package/src/composites/cron-workload.ts +167 -0
  53. package/src/composites/index.ts +14 -0
  54. package/src/composites/namespace-env.ts +163 -0
  55. package/src/composites/node-agent.ts +224 -0
  56. package/src/composites/stateful-app.ts +134 -0
  57. package/src/composites/web-app.ts +180 -0
  58. package/src/composites/worker-pool.ts +230 -0
  59. package/src/coverage.test.ts +27 -0
  60. package/src/coverage.ts +35 -0
  61. package/src/crd/loader.ts +112 -0
  62. package/src/crd/parser.test.ts +217 -0
  63. package/src/crd/parser.ts +279 -0
  64. package/src/crd/types.ts +54 -0
  65. package/src/default-labels.test.ts +111 -0
  66. package/src/default-labels.ts +122 -0
  67. package/src/generated/index.d.ts +2934 -0
  68. package/src/generated/index.ts +203 -0
  69. package/src/generated/lexicon-k8s.json +1413 -0
  70. package/src/generated/runtime.ts +4 -0
  71. package/src/import/generator.test.ts +121 -0
  72. package/src/import/generator.ts +285 -0
  73. package/src/import/parser.test.ts +156 -0
  74. package/src/import/parser.ts +204 -0
  75. package/src/import/roundtrip.test.ts +86 -0
  76. package/src/index.ts +38 -0
  77. package/src/lint/post-synth/k8s-helpers.test.ts +219 -0
  78. package/src/lint/post-synth/k8s-helpers.ts +149 -0
  79. package/src/lint/post-synth/post-synth.test.ts +969 -0
  80. package/src/lint/post-synth/wk8005.ts +59 -0
  81. package/src/lint/post-synth/wk8006.ts +68 -0
  82. package/src/lint/post-synth/wk8041.ts +73 -0
  83. package/src/lint/post-synth/wk8042.ts +48 -0
  84. package/src/lint/post-synth/wk8101.ts +65 -0
  85. package/src/lint/post-synth/wk8102.ts +42 -0
  86. package/src/lint/post-synth/wk8103.ts +45 -0
  87. package/src/lint/post-synth/wk8104.ts +69 -0
  88. package/src/lint/post-synth/wk8105.ts +45 -0
  89. package/src/lint/post-synth/wk8201.ts +55 -0
  90. package/src/lint/post-synth/wk8202.ts +46 -0
  91. package/src/lint/post-synth/wk8203.ts +46 -0
  92. package/src/lint/post-synth/wk8204.ts +54 -0
  93. package/src/lint/post-synth/wk8205.ts +56 -0
  94. package/src/lint/post-synth/wk8207.ts +45 -0
  95. package/src/lint/post-synth/wk8208.ts +45 -0
  96. package/src/lint/post-synth/wk8209.ts +45 -0
  97. package/src/lint/post-synth/wk8301.ts +51 -0
  98. package/src/lint/post-synth/wk8302.ts +46 -0
  99. package/src/lint/post-synth/wk8303.ts +96 -0
  100. package/src/lint/rules/hardcoded-namespace.ts +56 -0
  101. package/src/lint/rules/rules.test.ts +69 -0
  102. package/src/lsp/completions.test.ts +64 -0
  103. package/src/lsp/completions.ts +20 -0
  104. package/src/lsp/hover.test.ts +69 -0
  105. package/src/lsp/hover.ts +68 -0
  106. package/src/package-cli.ts +28 -0
  107. package/src/plugin.test.ts +209 -0
  108. package/src/plugin.ts +915 -0
  109. package/src/serializer.test.ts +275 -0
  110. package/src/serializer.ts +278 -0
  111. package/src/spec/fetch.test.ts +24 -0
  112. package/src/spec/fetch.ts +68 -0
  113. package/src/spec/parse.test.ts +102 -0
  114. package/src/spec/parse.ts +477 -0
  115. package/src/testdata/manifests/configmap.yaml +7 -0
  116. package/src/testdata/manifests/deployment.yaml +22 -0
  117. package/src/testdata/manifests/full-app.yaml +61 -0
  118. package/src/testdata/manifests/secret.yaml +7 -0
  119. package/src/testdata/manifests/service.yaml +15 -0
  120. package/src/validate-cli.ts +21 -0
  121. package/src/validate.test.ts +29 -0
  122. package/src/validate.ts +46 -0
  123. package/src/variables.ts +36 -0
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Kubernetes YAML parser for `chant import`.
3
+ *
4
+ * Parses Kubernetes manifests (single or multi-document YAML) into the core
5
+ * TemplateIR format, mapping K8s resources to the K8s::{Group}::{Kind} type
6
+ * convention.
7
+ */
8
+
9
+ import type { TemplateParser, TemplateIR, ResourceIR } from "@intentius/chant/import/parser";
10
+ import { parseYAML } from "@intentius/chant/yaml";
11
+
12
+ // ── GVK to type name mapping ───────────────────────────────────────
13
+
14
+ /**
15
+ * Well-known kind+apiVersion pairs mapped to K8s type names.
16
+ * Key format: "apiVersion/kind" (core group uses just "kind").
17
+ */
18
+ const GVK_TYPE_MAP: Record<string, string> = {
19
+ // Core (v1)
20
+ "v1/Pod": "K8s::Core::Pod",
21
+ "v1/Service": "K8s::Core::Service",
22
+ "v1/ConfigMap": "K8s::Core::ConfigMap",
23
+ "v1/Secret": "K8s::Core::Secret",
24
+ "v1/Namespace": "K8s::Core::Namespace",
25
+ "v1/ServiceAccount": "K8s::Core::ServiceAccount",
26
+ "v1/PersistentVolume": "K8s::Core::PersistentVolume",
27
+ "v1/PersistentVolumeClaim": "K8s::Core::PersistentVolumeClaim",
28
+ "v1/Endpoints": "K8s::Core::Endpoints",
29
+ "v1/LimitRange": "K8s::Core::LimitRange",
30
+ "v1/ResourceQuota": "K8s::Core::ResourceQuota",
31
+
32
+ // Apps (apps/v1)
33
+ "apps/v1/Deployment": "K8s::Apps::Deployment",
34
+ "apps/v1/StatefulSet": "K8s::Apps::StatefulSet",
35
+ "apps/v1/DaemonSet": "K8s::Apps::DaemonSet",
36
+ "apps/v1/ReplicaSet": "K8s::Apps::ReplicaSet",
37
+
38
+ // Batch (batch/v1)
39
+ "batch/v1/Job": "K8s::Batch::Job",
40
+ "batch/v1/CronJob": "K8s::Batch::CronJob",
41
+
42
+ // Networking (networking.k8s.io/v1)
43
+ "networking.k8s.io/v1/Ingress": "K8s::Networking::Ingress",
44
+ "networking.k8s.io/v1/NetworkPolicy": "K8s::Networking::NetworkPolicy",
45
+ "networking.k8s.io/v1/IngressClass": "K8s::Networking::IngressClass",
46
+
47
+ // RBAC (rbac.authorization.k8s.io/v1)
48
+ "rbac.authorization.k8s.io/v1/Role": "K8s::Rbac::Role",
49
+ "rbac.authorization.k8s.io/v1/ClusterRole": "K8s::Rbac::ClusterRole",
50
+ "rbac.authorization.k8s.io/v1/RoleBinding": "K8s::Rbac::RoleBinding",
51
+ "rbac.authorization.k8s.io/v1/ClusterRoleBinding": "K8s::Rbac::ClusterRoleBinding",
52
+
53
+ // Autoscaling (autoscaling/v2)
54
+ "autoscaling/v2/HorizontalPodAutoscaler": "K8s::Autoscaling::HorizontalPodAutoscaler",
55
+ "autoscaling/v1/HorizontalPodAutoscaler": "K8s::Autoscaling::HorizontalPodAutoscaler",
56
+
57
+ // Policy (policy/v1)
58
+ "policy/v1/PodDisruptionBudget": "K8s::Policy::PodDisruptionBudget",
59
+
60
+ // Storage (storage.k8s.io/v1)
61
+ "storage.k8s.io/v1/StorageClass": "K8s::Storage::StorageClass",
62
+
63
+ // Certificates (certificates.k8s.io/v1)
64
+ "certificates.k8s.io/v1/CertificateSigningRequest": "K8s::Certificates::CertificateSigningRequest",
65
+
66
+ // Coordination (coordination.k8s.io/v1)
67
+ "coordination.k8s.io/v1/Lease": "K8s::Coordination::Lease",
68
+
69
+ // Discovery (discovery.k8s.io/v1)
70
+ "discovery.k8s.io/v1/EndpointSlice": "K8s::Discovery::EndpointSlice",
71
+ };
72
+
73
+ /**
74
+ * Resolve apiVersion + kind to a K8s type name.
75
+ * Falls back to constructing from the apiVersion group.
76
+ */
77
+ function resolveTypeName(apiVersion: string, kind: string): string {
78
+ // Try exact match
79
+ const key = `${apiVersion}/${kind}`;
80
+ if (GVK_TYPE_MAP[key]) return GVK_TYPE_MAP[key];
81
+
82
+ // Core group uses just kind
83
+ if (apiVersion === "v1") {
84
+ const coreKey = `v1/${kind}`;
85
+ if (GVK_TYPE_MAP[coreKey]) return GVK_TYPE_MAP[coreKey];
86
+ }
87
+
88
+ // Construct from apiVersion group
89
+ const group = apiVersionToGroup(apiVersion);
90
+ return `K8s::${group}::${kind}`;
91
+ }
92
+
93
+ /**
94
+ * Extract PascalCase group name from an apiVersion string.
95
+ * "apps/v1" → "Apps"
96
+ * "v1" → "Core"
97
+ * "networking.k8s.io/v1" → "Networking"
98
+ * "rbac.authorization.k8s.io/v1" → "Rbac"
99
+ */
100
+ function apiVersionToGroup(apiVersion: string): string {
101
+ const slashIdx = apiVersion.indexOf("/");
102
+ if (slashIdx === -1) return "Core"; // core group (v1)
103
+
104
+ const groupStr = apiVersion.slice(0, slashIdx);
105
+ const firstSegment = groupStr.split(".")[0];
106
+ if (firstSegment === "rbac") return "Rbac";
107
+ return firstSegment.charAt(0).toUpperCase() + firstSegment.slice(1);
108
+ }
109
+
110
+ /**
111
+ * Convert a K8s object name to a camelCase logical identifier.
112
+ * Combines kind (lowercased first char) with the object name (PascalCased).
113
+ *
114
+ * E.g., kind="Deployment", name="my-app" → "deploymentMyApp"
115
+ */
116
+ function toLogicalId(kind: string, name: string | undefined): string {
117
+ const prefix = kind.charAt(0).toLowerCase() + kind.slice(1);
118
+ if (!name) return prefix;
119
+
120
+ const pascalName = name
121
+ .split(/[-_.]/)
122
+ .map((seg) => seg.charAt(0).toUpperCase() + seg.slice(1))
123
+ .join("");
124
+ return `${prefix}${pascalName}`;
125
+ }
126
+
127
+ // ── Parser ──────────────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Kubernetes YAML parser implementation.
131
+ *
132
+ * Supports multi-document YAML (split on `---`). Each document is expected
133
+ * to be a standard Kubernetes object with apiVersion, kind, and metadata.
134
+ */
135
+ export class K8sParser implements TemplateParser {
136
+ parse(content: string): TemplateIR {
137
+ const resources: ResourceIR[] = [];
138
+ const namespaces = new Set<string>();
139
+
140
+ // Split on YAML document separator, filter blanks
141
+ const documents = content
142
+ .split(/^---\s*$/m)
143
+ .map((d) => d.trim())
144
+ .filter((d) => d.length > 0 && !/^[\s#]*$/.test(d.replace(/#[^\n]*/g, "")));
145
+
146
+ for (const docStr of documents) {
147
+ const doc = parseYAML(docStr);
148
+ if (!doc || typeof doc !== "object") continue;
149
+
150
+ const resource = this.parseDocument(doc as Record<string, unknown>);
151
+ if (resource) {
152
+ resources.push(resource);
153
+
154
+ // Track namespaces
155
+ const ns = (doc as Record<string, unknown>).metadata as Record<string, unknown> | undefined;
156
+ if (ns?.namespace && typeof ns.namespace === "string") {
157
+ namespaces.add(ns.namespace);
158
+ }
159
+ }
160
+ }
161
+
162
+ const metadata: Record<string, unknown> = {};
163
+ if (namespaces.size > 0) {
164
+ metadata.namespaces = [...namespaces];
165
+ }
166
+
167
+ return {
168
+ resources,
169
+ parameters: [],
170
+ metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
171
+ };
172
+ }
173
+
174
+ private parseDocument(doc: Record<string, unknown>): ResourceIR | null {
175
+ const apiVersion = doc.apiVersion as string | undefined;
176
+ const kind = doc.kind as string | undefined;
177
+
178
+ if (!apiVersion || !kind) return null;
179
+
180
+ const metadata = (doc.metadata ?? {}) as Record<string, unknown>;
181
+ const name = metadata.name as string | undefined;
182
+ const type = resolveTypeName(apiVersion, kind);
183
+ const logicalId = toLogicalId(kind, name);
184
+
185
+ // Extract the user-configurable properties (skip apiVersion, kind)
186
+ const properties: Record<string, unknown> = {};
187
+ for (const [key, value] of Object.entries(doc)) {
188
+ if (key === "apiVersion" || key === "kind") continue;
189
+ properties[key] = value;
190
+ }
191
+
192
+ return {
193
+ logicalId,
194
+ type,
195
+ properties,
196
+ metadata: {
197
+ originalName: name,
198
+ apiVersion,
199
+ kind,
200
+ ...(metadata.namespace ? { namespace: metadata.namespace as string } : {}),
201
+ },
202
+ };
203
+ }
204
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { readFileSync } from "fs";
3
+ import { join, dirname } from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { K8sParser } from "./parser";
6
+ import { K8sGenerator } from "./generator";
7
+
8
+ const testdataDir = join(
9
+ dirname(dirname(fileURLToPath(import.meta.url))),
10
+ "testdata",
11
+ "manifests",
12
+ );
13
+
14
+ const parser = new K8sParser();
15
+ const generator = new K8sGenerator();
16
+
17
+ describe("roundtrip: parse YAML → generate TypeScript", () => {
18
+ test("Deployment roundtrip", () => {
19
+ const yaml = readFileSync(join(testdataDir, "deployment.yaml"), "utf-8");
20
+ const ir = parser.parse(yaml);
21
+ const files = generator.generate(ir);
22
+
23
+ expect(files.length).toBe(1);
24
+ expect(files[0].content).toContain("new Deployment");
25
+ expect(files[0].content).toContain("nginx");
26
+ });
27
+
28
+ test("multi-doc full-app roundtrip", () => {
29
+ const yaml = readFileSync(join(testdataDir, "full-app.yaml"), "utf-8");
30
+ const ir = parser.parse(yaml);
31
+ expect(ir.resources.length).toBe(4); // Deployment + Service + Ingress + ConfigMap
32
+
33
+ const files = generator.generate(ir);
34
+ expect(files[0].content).toContain("Deployment");
35
+ expect(files[0].content).toContain("Service");
36
+ expect(files[0].content).toContain("Ingress");
37
+ expect(files[0].content).toContain("ConfigMap");
38
+ });
39
+
40
+ test("Service roundtrip preserves port info", () => {
41
+ const yaml = readFileSync(join(testdataDir, "service.yaml"), "utf-8");
42
+ const ir = parser.parse(yaml);
43
+ const files = generator.generate(ir);
44
+
45
+ expect(files[0].content).toContain("new Service");
46
+ expect(files[0].content).toContain("80");
47
+ });
48
+
49
+ test("ConfigMap roundtrip preserves data", () => {
50
+ const yaml = readFileSync(join(testdataDir, "configmap.yaml"), "utf-8");
51
+ const ir = parser.parse(yaml);
52
+ const files = generator.generate(ir);
53
+
54
+ expect(files[0].content).toContain("ConfigMap");
55
+ expect(files[0].content).toContain("DATABASE_URL");
56
+ });
57
+
58
+ test("inline YAML roundtrip", () => {
59
+ const yaml = `
60
+ apiVersion: apps/v1
61
+ kind: Deployment
62
+ metadata:
63
+ name: test-app
64
+ spec:
65
+ replicas: 1
66
+ selector:
67
+ matchLabels:
68
+ app: test
69
+ template:
70
+ metadata:
71
+ labels:
72
+ app: test
73
+ spec:
74
+ containers:
75
+ - name: test
76
+ image: test:1.0
77
+ ports:
78
+ - containerPort: 8080
79
+ `;
80
+ const ir = parser.parse(yaml);
81
+ const files = generator.generate(ir);
82
+
83
+ expect(files[0].content).toContain("new Deployment");
84
+ expect(files[0].content).toContain("export const");
85
+ });
86
+ });
package/src/index.ts ADDED
@@ -0,0 +1,38 @@
1
+ // Serializer
2
+ export { k8sSerializer } from "./serializer";
3
+
4
+ // Plugin
5
+ export { k8sPlugin } from "./plugin";
6
+
7
+ // Default labels & annotations
8
+ export { defaultLabels, defaultAnnotations, isDefaultLabels, isDefaultAnnotations } from "./default-labels";
9
+ export { DEFAULT_LABELS_MARKER, DEFAULT_ANNOTATIONS_MARKER } from "./default-labels";
10
+
11
+ // Variables / label constants
12
+ export { K8sLabels, K8sAnnotations } from "./variables";
13
+
14
+ // Generated entities — export everything from generated index
15
+ // After running `chant generate`, this re-exports all K8s resource classes
16
+ export * from "./generated/index";
17
+
18
+ // Composites
19
+ export { WebApp, StatefulApp, CronWorkload, AutoscaledService, WorkerPool, NamespaceEnv, NodeAgent } from "./composites/index";
20
+ export type { WebAppProps, WebAppResult, StatefulAppProps, StatefulAppResult, CronWorkloadProps, CronWorkloadResult, AutoscaledServiceProps, AutoscaledServiceResult, WorkerPoolProps, WorkerPoolResult, NamespaceEnvProps, NamespaceEnvResult, NodeAgentProps, NodeAgentResult } from "./composites/index";
21
+
22
+ // RBAC verb constants
23
+ export * from "./actions/index";
24
+
25
+ // Spec utilities (for tooling)
26
+ export { fetchK8sSchema, fetchSchemas, K8S_SCHEMA_VERSION } from "./spec/fetch";
27
+ export { parseK8sSwagger, k8sShortName, k8sServiceName, gvkToTypeName, gvkToApiVersion } from "./spec/parse";
28
+ export type { K8sParseResult, ParsedResource, ParsedProperty, ParsedPropertyType, ParsedEnum, GroupVersionKind } from "./spec/parse";
29
+
30
+ // Code generation pipeline
31
+ export { generate, writeGeneratedFiles } from "./codegen/generate";
32
+ export { packageLexicon } from "./codegen/package";
33
+ export type { PackageOptions, PackageResult } from "./codegen/package";
34
+
35
+ // CRD framework
36
+ export type { CRDSource, CRDSpec } from "./crd/types";
37
+ export { parseCRD, parseCRDSpec } from "./crd/parser";
38
+ export { loadCRDs, loadMultipleCRDs } from "./crd/loader";
@@ -0,0 +1,219 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ parseK8sManifests,
4
+ extractContainers,
5
+ extractPodSpec,
6
+ WORKLOAD_KINDS,
7
+ } from "./k8s-helpers";
8
+
9
+ describe("parseK8sManifests", () => {
10
+ test("splits multi-doc YAML", () => {
11
+ const yaml = `
12
+ apiVersion: v1
13
+ kind: Service
14
+ metadata:
15
+ name: svc
16
+ ---
17
+ apiVersion: apps/v1
18
+ kind: Deployment
19
+ metadata:
20
+ name: deploy
21
+ `;
22
+ const manifests = parseK8sManifests(yaml);
23
+ expect(manifests.length).toBe(2);
24
+ expect(manifests[0].kind).toBe("Service");
25
+ expect(manifests[1].kind).toBe("Deployment");
26
+ });
27
+
28
+ test("handles single document", () => {
29
+ const yaml = `
30
+ apiVersion: v1
31
+ kind: ConfigMap
32
+ metadata:
33
+ name: config
34
+ data:
35
+ key: value
36
+ `;
37
+ const manifests = parseK8sManifests(yaml);
38
+ expect(manifests.length).toBe(1);
39
+ expect(manifests[0].kind).toBe("ConfigMap");
40
+ });
41
+
42
+ test("handles empty/invalid YAML gracefully", () => {
43
+ expect(parseK8sManifests("")).toEqual([]);
44
+ expect(parseK8sManifests("---")).toEqual([]);
45
+ // "---\n---" doesn't split on /\n---\n/ — the full string is parsed as empty object
46
+ expect(parseK8sManifests("---\n---\n")).toEqual([]);
47
+ });
48
+
49
+ test("skips blank documents between separators", () => {
50
+ const yaml = `
51
+ apiVersion: v1
52
+ kind: Pod
53
+ metadata:
54
+ name: p
55
+ ---
56
+
57
+ ---
58
+ apiVersion: v1
59
+ kind: Service
60
+ metadata:
61
+ name: s
62
+ `;
63
+ const manifests = parseK8sManifests(yaml);
64
+ expect(manifests.length).toBe(2);
65
+ });
66
+ });
67
+
68
+ describe("extractContainers", () => {
69
+ test("extracts from Deployment", () => {
70
+ const manifest = {
71
+ kind: "Deployment",
72
+ spec: {
73
+ template: {
74
+ spec: {
75
+ containers: [{ name: "app", image: "nginx" }],
76
+ },
77
+ },
78
+ },
79
+ };
80
+ const containers = extractContainers(manifest);
81
+ expect(containers.length).toBe(1);
82
+ expect(containers[0].name).toBe("app");
83
+ });
84
+
85
+ test("extracts from Pod", () => {
86
+ const manifest = {
87
+ kind: "Pod",
88
+ spec: {
89
+ containers: [{ name: "app", image: "nginx" }],
90
+ },
91
+ };
92
+ const containers = extractContainers(manifest);
93
+ expect(containers.length).toBe(1);
94
+ });
95
+
96
+ test("extracts from StatefulSet", () => {
97
+ const manifest = {
98
+ kind: "StatefulSet",
99
+ spec: {
100
+ template: {
101
+ spec: {
102
+ containers: [{ name: "db", image: "postgres" }],
103
+ },
104
+ },
105
+ },
106
+ };
107
+ const containers = extractContainers(manifest);
108
+ expect(containers.length).toBe(1);
109
+ });
110
+
111
+ test("extracts from DaemonSet", () => {
112
+ const manifest = {
113
+ kind: "DaemonSet",
114
+ spec: {
115
+ template: {
116
+ spec: {
117
+ containers: [{ name: "agent", image: "fluentd" }],
118
+ },
119
+ },
120
+ },
121
+ };
122
+ const containers = extractContainers(manifest);
123
+ expect(containers.length).toBe(1);
124
+ });
125
+
126
+ test("extracts from Job", () => {
127
+ const manifest = {
128
+ kind: "Job",
129
+ spec: {
130
+ template: {
131
+ spec: {
132
+ containers: [{ name: "worker", image: "job:1.0" }],
133
+ },
134
+ },
135
+ },
136
+ };
137
+ const containers = extractContainers(manifest);
138
+ expect(containers.length).toBe(1);
139
+ });
140
+
141
+ test("extracts from CronJob", () => {
142
+ const manifest = {
143
+ kind: "CronJob",
144
+ spec: {
145
+ jobTemplate: {
146
+ spec: {
147
+ template: {
148
+ spec: {
149
+ containers: [{ name: "cron", image: "cron:1.0" }],
150
+ },
151
+ },
152
+ },
153
+ },
154
+ },
155
+ };
156
+ const containers = extractContainers(manifest);
157
+ expect(containers.length).toBe(1);
158
+ });
159
+
160
+ test("includes init containers", () => {
161
+ const manifest = {
162
+ kind: "Deployment",
163
+ spec: {
164
+ template: {
165
+ spec: {
166
+ containers: [{ name: "app", image: "nginx" }],
167
+ initContainers: [{ name: "init", image: "busybox" }],
168
+ },
169
+ },
170
+ },
171
+ };
172
+ const containers = extractContainers(manifest);
173
+ expect(containers.length).toBe(2);
174
+ });
175
+
176
+ test("returns empty for non-workload types", () => {
177
+ const manifest = { kind: "ConfigMap", data: {} };
178
+ const containers = extractContainers(manifest);
179
+ expect(containers.length).toBe(0);
180
+ });
181
+ });
182
+
183
+ describe("extractPodSpec", () => {
184
+ test("returns spec for Pod", () => {
185
+ const manifest = {
186
+ kind: "Pod",
187
+ spec: { containers: [{ name: "app" }] },
188
+ };
189
+ const podSpec = extractPodSpec(manifest);
190
+ expect(podSpec).toBeDefined();
191
+ expect(podSpec!.containers).toBeDefined();
192
+ });
193
+
194
+ test("returns null for ConfigMap", () => {
195
+ const manifest = { kind: "ConfigMap", data: {} };
196
+ expect(extractPodSpec(manifest)).toBeNull();
197
+ });
198
+
199
+ test("returns null when no spec", () => {
200
+ const manifest = { kind: "Deployment" };
201
+ expect(extractPodSpec(manifest)).toBeNull();
202
+ });
203
+ });
204
+
205
+ describe("WORKLOAD_KINDS", () => {
206
+ test("includes all expected kinds", () => {
207
+ expect(WORKLOAD_KINDS.has("Pod")).toBe(true);
208
+ expect(WORKLOAD_KINDS.has("Deployment")).toBe(true);
209
+ expect(WORKLOAD_KINDS.has("StatefulSet")).toBe(true);
210
+ expect(WORKLOAD_KINDS.has("DaemonSet")).toBe(true);
211
+ expect(WORKLOAD_KINDS.has("Job")).toBe(true);
212
+ expect(WORKLOAD_KINDS.has("CronJob")).toBe(true);
213
+ });
214
+
215
+ test("does not include non-workload kinds", () => {
216
+ expect(WORKLOAD_KINDS.has("Service")).toBe(false);
217
+ expect(WORKLOAD_KINDS.has("ConfigMap")).toBe(false);
218
+ });
219
+ });
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Shared helpers for Kubernetes post-synthesis lint rules.
3
+ *
4
+ * Provides YAML parsing for multi-document K8s manifests and container
5
+ * extraction logic that handles all common workload types.
6
+ */
7
+
8
+ import { parseYAML } from "@intentius/chant/yaml";
9
+ export { getPrimaryOutput } from "@intentius/chant/lint/post-synth";
10
+
11
+ /**
12
+ * A parsed Kubernetes manifest (loosely typed).
13
+ */
14
+ export interface K8sManifest {
15
+ apiVersion?: string;
16
+ kind?: string;
17
+ metadata?: {
18
+ name?: string;
19
+ namespace?: string;
20
+ labels?: Record<string, string>;
21
+ [key: string]: unknown;
22
+ };
23
+ spec?: Record<string, unknown>;
24
+ data?: Record<string, unknown>;
25
+ [key: string]: unknown;
26
+ }
27
+
28
+ /**
29
+ * A Kubernetes container spec (loosely typed).
30
+ */
31
+ export interface K8sContainer {
32
+ name?: string;
33
+ image?: string;
34
+ env?: Array<{ name?: string; value?: unknown; valueFrom?: unknown }>;
35
+ ports?: Array<{ name?: string; containerPort?: number; [key: string]: unknown }>;
36
+ resources?: {
37
+ limits?: Record<string, unknown>;
38
+ requests?: Record<string, unknown>;
39
+ };
40
+ securityContext?: Record<string, unknown>;
41
+ livenessProbe?: unknown;
42
+ readinessProbe?: unknown;
43
+ imagePullPolicy?: string;
44
+ [key: string]: unknown;
45
+ }
46
+
47
+ /**
48
+ * Split a multi-document YAML string on `---` boundaries and parse each
49
+ * document into a K8sManifest.
50
+ */
51
+ export function parseK8sManifests(yaml: string): K8sManifest[] {
52
+ const documents = yaml.split(/\n---\n/);
53
+ const manifests: K8sManifest[] = [];
54
+
55
+ for (const doc of documents) {
56
+ const trimmed = doc.trim();
57
+ if (trimmed === "" || trimmed === "---") continue;
58
+ try {
59
+ const parsed = parseYAML(trimmed);
60
+ if (typeof parsed === "object" && parsed !== null) {
61
+ manifests.push(parsed as K8sManifest);
62
+ }
63
+ } catch {
64
+ // Skip unparseable documents
65
+ }
66
+ }
67
+
68
+ return manifests;
69
+ }
70
+
71
+ /**
72
+ * Extract the pod spec from any workload manifest.
73
+ *
74
+ * Handles:
75
+ * - Pod: spec directly
76
+ * - Deployment, StatefulSet, DaemonSet: spec.template.spec
77
+ * - Job: spec.template.spec
78
+ * - CronJob: spec.jobTemplate.spec.template.spec
79
+ */
80
+ export function extractPodSpec(
81
+ manifest: K8sManifest,
82
+ ): Record<string, unknown> | null {
83
+ const kind = manifest.kind;
84
+ const spec = manifest.spec;
85
+ if (!spec) return null;
86
+
87
+ switch (kind) {
88
+ case "Pod":
89
+ return spec as Record<string, unknown>;
90
+
91
+ case "Deployment":
92
+ case "StatefulSet":
93
+ case "DaemonSet":
94
+ case "Job": {
95
+ const template = spec.template as Record<string, unknown> | undefined;
96
+ return (template?.spec as Record<string, unknown>) ?? null;
97
+ }
98
+
99
+ case "CronJob": {
100
+ const jobTemplate = spec.jobTemplate as Record<string, unknown> | undefined;
101
+ const jobSpec = jobTemplate?.spec as Record<string, unknown> | undefined;
102
+ const template = jobSpec?.template as Record<string, unknown> | undefined;
103
+ return (template?.spec as Record<string, unknown>) ?? null;
104
+ }
105
+
106
+ default:
107
+ return null;
108
+ }
109
+ }
110
+
111
+ /**
112
+ * Extract all containers (including init containers) from a workload manifest.
113
+ */
114
+ export function extractContainers(manifest: K8sManifest): K8sContainer[] {
115
+ const podSpec = extractPodSpec(manifest);
116
+ if (!podSpec) return [];
117
+
118
+ const containers: K8sContainer[] = [];
119
+
120
+ if (Array.isArray(podSpec.containers)) {
121
+ for (const c of podSpec.containers) {
122
+ if (typeof c === "object" && c !== null) {
123
+ containers.push(c as K8sContainer);
124
+ }
125
+ }
126
+ }
127
+
128
+ if (Array.isArray(podSpec.initContainers)) {
129
+ for (const c of podSpec.initContainers) {
130
+ if (typeof c === "object" && c !== null) {
131
+ containers.push(c as K8sContainer);
132
+ }
133
+ }
134
+ }
135
+
136
+ return containers;
137
+ }
138
+
139
+ /**
140
+ * Workload kinds that contain pod templates.
141
+ */
142
+ export const WORKLOAD_KINDS = new Set([
143
+ "Pod",
144
+ "Deployment",
145
+ "StatefulSet",
146
+ "DaemonSet",
147
+ "Job",
148
+ "CronJob",
149
+ ]);