@intentius/chant 0.52.2 → 0.53.1

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 (125) hide show
  1. package/dist/cdk/advise.d.ts +29 -0
  2. package/dist/cdk/advise.d.ts.map +1 -0
  3. package/dist/cdk/assembly.d.ts +38 -0
  4. package/dist/cdk/assembly.d.ts.map +1 -0
  5. package/dist/cdk/graph.d.ts +68 -0
  6. package/dist/cdk/graph.d.ts.map +1 -0
  7. package/dist/cdk/tier-map.d.ts +44 -0
  8. package/dist/cdk/tier-map.d.ts.map +1 -0
  9. package/dist/cdk/types.d.ts +114 -0
  10. package/dist/cdk/types.d.ts.map +1 -0
  11. package/dist/cli/commands/carve-apply.d.ts.map +1 -1
  12. package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
  13. package/dist/cli/commands/carve-emit.d.ts.map +1 -1
  14. package/dist/cli/commands/carve.d.ts +48 -6
  15. package/dist/cli/commands/carve.d.ts.map +1 -1
  16. package/dist/cli/handlers/carve-emit.d.ts.map +1 -1
  17. package/dist/cli/handlers/carve.d.ts +5 -4
  18. package/dist/cli/handlers/carve.d.ts.map +1 -1
  19. package/dist/cli/handlers/lifecycle.d.ts +10 -0
  20. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  21. package/dist/cli/handlers/op-progress.d.ts +37 -4
  22. package/dist/cli/handlers/op-progress.d.ts.map +1 -1
  23. package/dist/cli/handlers/operator.d.ts +61 -1
  24. package/dist/cli/handlers/operator.d.ts.map +1 -1
  25. package/dist/cli/handlers/run.d.ts.map +1 -1
  26. package/dist/cli/main.d.ts.map +1 -1
  27. package/dist/cli/registry.d.ts +9 -1
  28. package/dist/cli/registry.d.ts.map +1 -1
  29. package/dist/identity.d.ts +196 -0
  30. package/dist/identity.d.ts.map +1 -0
  31. package/dist/index.d.ts +1 -0
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/lexicon.d.ts +41 -0
  34. package/dist/lexicon.d.ts.map +1 -1
  35. package/dist/lifecycle/converge-ledger.d.ts +81 -1
  36. package/dist/lifecycle/converge-ledger.d.ts.map +1 -1
  37. package/dist/lifecycle/gate-ledger.d.ts +43 -1
  38. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  39. package/dist/terraform/adopt-state.d.ts +17 -66
  40. package/dist/terraform/adopt-state.d.ts.map +1 -1
  41. package/dist/terraform/aws-resources.d.ts +1 -1
  42. package/dist/terraform/carve-provider.d.ts +142 -0
  43. package/dist/terraform/carve-provider.d.ts.map +1 -0
  44. package/dist/terraform/carve.d.ts +36 -3
  45. package/dist/terraform/carve.d.ts.map +1 -1
  46. package/dist/terraform/emit-source.d.ts +25 -0
  47. package/dist/terraform/emit-source.d.ts.map +1 -0
  48. package/dist/terraform/graduate.d.ts +11 -1
  49. package/dist/terraform/graduate.d.ts.map +1 -1
  50. package/dist/terraform/providers/aws.d.ts +19 -0
  51. package/dist/terraform/providers/aws.d.ts.map +1 -0
  52. package/dist/terraform/providers/gcp.d.ts +41 -0
  53. package/dist/terraform/providers/gcp.d.ts.map +1 -0
  54. package/dist/terraform/providers/index.d.ts +15 -0
  55. package/dist/terraform/providers/index.d.ts.map +1 -0
  56. package/dist/terraform/providers/kubernetes.d.ts +29 -0
  57. package/dist/terraform/providers/kubernetes.d.ts.map +1 -0
  58. package/dist/terraform/score.d.ts +70 -4
  59. package/dist/terraform/score.d.ts.map +1 -1
  60. package/dist/terraform/tier-map.d.ts +30 -35
  61. package/dist/terraform/tier-map.d.ts.map +1 -1
  62. package/dist/terraform/types.d.ts +6 -0
  63. package/dist/terraform/types.d.ts.map +1 -1
  64. package/package.json +1 -1
  65. package/src/cdk/__fixtures__/cdk.out/AppStack.template.json +171 -0
  66. package/src/cdk/__fixtures__/cdk.out/DataStack.template.json +90 -0
  67. package/src/cdk/__fixtures__/cdk.out/cdk.out +1 -0
  68. package/src/cdk/__fixtures__/cdk.out/manifest.json +30 -0
  69. package/src/cdk/__fixtures__/cdk.out/tree.json +201 -0
  70. package/src/cdk/__fixtures__/cdk.out-dummy/LookupStack.template.json +29 -0
  71. package/src/cdk/__fixtures__/cdk.out-dummy/manifest.json +26 -0
  72. package/src/cdk/advise.test.ts +208 -0
  73. package/src/cdk/advise.ts +44 -0
  74. package/src/cdk/assembly.ts +133 -0
  75. package/src/cdk/graph.test.ts +206 -0
  76. package/src/cdk/graph.ts +525 -0
  77. package/src/cdk/tier-map.ts +71 -0
  78. package/src/cdk/types.ts +115 -0
  79. package/src/cli/commands/carve-apply.ts +20 -4
  80. package/src/cli/commands/carve-bridge.ts +9 -5
  81. package/src/cli/commands/carve-emit-k8s.test.ts +262 -0
  82. package/src/cli/commands/carve-emit-provider.test.ts +207 -0
  83. package/src/cli/commands/carve-emit.ts +28 -24
  84. package/src/cli/commands/carve.ts +139 -36
  85. package/src/cli/handlers/carve-emit.ts +8 -3
  86. package/src/cli/handlers/carve.ts +8 -7
  87. package/src/cli/handlers/lifecycle.test.ts +187 -1
  88. package/src/cli/handlers/lifecycle.ts +125 -1
  89. package/src/cli/handlers/op-progress.test.ts +67 -1
  90. package/src/cli/handlers/op-progress.ts +70 -14
  91. package/src/cli/handlers/operator.test.ts +302 -1
  92. package/src/cli/handlers/operator.ts +193 -5
  93. package/src/cli/handlers/run.ts +11 -13
  94. package/src/cli/main.test.ts +19 -0
  95. package/src/cli/main.ts +38 -10
  96. package/src/cli/registry.ts +9 -1
  97. package/src/identity.test.ts +199 -0
  98. package/src/identity.ts +346 -0
  99. package/src/index.ts +1 -0
  100. package/src/lexicon.ts +53 -0
  101. package/src/lifecycle/converge-ledger.test.ts +110 -0
  102. package/src/lifecycle/converge-ledger.ts +110 -2
  103. package/src/lifecycle/gate-ledger.test.ts +81 -1
  104. package/src/lifecycle/gate-ledger.ts +64 -1
  105. package/src/terraform/__fixtures__/gcp-estate/main.tf +60 -0
  106. package/src/terraform/adopt-state.test.ts +131 -0
  107. package/src/terraform/adopt-state.ts +19 -167
  108. package/src/terraform/aws-resources.test.ts +38 -22
  109. package/src/terraform/aws-resources.ts +1 -1
  110. package/src/terraform/carve-provider.test.ts +155 -0
  111. package/src/terraform/carve-provider.ts +237 -0
  112. package/src/terraform/carve.test.ts +55 -1
  113. package/src/terraform/carve.ts +0 -0
  114. package/src/terraform/emit-source.ts +39 -0
  115. package/src/terraform/graduate.test.ts +37 -0
  116. package/src/terraform/graduate.ts +55 -7
  117. package/src/terraform/graph.ts +3 -3
  118. package/src/terraform/providers/aws.ts +169 -0
  119. package/src/terraform/providers/gcp.test.ts +228 -0
  120. package/src/terraform/providers/gcp.ts +329 -0
  121. package/src/terraform/providers/index.ts +21 -0
  122. package/src/terraform/providers/kubernetes.ts +224 -0
  123. package/src/terraform/score.ts +111 -25
  124. package/src/terraform/tier-map.ts +45 -107
  125. package/src/terraform/types.ts +6 -0
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The parts of a CDK cloud assembly the carve advisor reads (#1056).
3
+ *
4
+ * A cloud assembly is already JSON, so there is no parser to install and no
5
+ * expression AST to resolve — the Terraform path's two hardest layers do not
6
+ * exist here. Three files carry everything the advisor needs:
7
+ *
8
+ * - `manifest.json` — which artifacts are stacks, which template file each
9
+ * one synthesized to, and (crucially) whether any context lookup went
10
+ * unresolved.
11
+ * - `tree.json` — the construct tree, so CloudFormation resources can be
12
+ * grouped back under the construct that emitted them. Optional: CDK can be
13
+ * told not to write it, and the advisor degrades to a coarser grouping.
14
+ * - `*.template.json` — the CloudFormation templates themselves, already in
15
+ * `AWS::*` type space, which is exactly what the AWS tier map keys on.
16
+ *
17
+ * Everything here is post-jsii, so a Python or Java CDK app reads through the
18
+ * same code path as a TypeScript one. Nothing in this module reads CDK source.
19
+ *
20
+ * The declarations are deliberately loose — every field optional, unknown keys
21
+ * tolerated — because the assembly schema is not chant's to own and gains
22
+ * fields between CDK releases.
23
+ */
24
+
25
+ /** A CloudFormation resource as it appears in a synthesized template. */
26
+ export interface CfnResource {
27
+ Type?: string;
28
+ Properties?: Record<string, unknown>;
29
+ /** `aws:cdk:path` names the emitting construct; `aws:asset:*` marks an asset. */
30
+ Metadata?: Record<string, unknown>;
31
+ DependsOn?: string | string[];
32
+ /** Names a template `Condition` — the CloudFormation analogue of `count`. */
33
+ Condition?: string;
34
+ [k: string]: unknown;
35
+ }
36
+
37
+ /** A CloudFormation `Outputs` entry. */
38
+ export interface CfnOutput {
39
+ Value?: unknown;
40
+ Export?: { Name?: unknown };
41
+ [k: string]: unknown;
42
+ }
43
+
44
+ export interface CfnTemplate {
45
+ Parameters?: Record<string, Record<string, unknown>>;
46
+ Conditions?: Record<string, unknown>;
47
+ Resources?: Record<string, CfnResource>;
48
+ Outputs?: Record<string, CfnOutput>;
49
+ [k: string]: unknown;
50
+ }
51
+
52
+ /** One artifact in `manifest.json`. */
53
+ export interface CdkArtifact {
54
+ type?: string;
55
+ properties?: { templateFile?: string; [k: string]: unknown };
56
+ displayName?: string;
57
+ dependencies?: string[];
58
+ environment?: string;
59
+ [k: string]: unknown;
60
+ }
61
+
62
+ /** One unresolved context lookup, as `cdk synth` records it. */
63
+ export interface CdkMissingContext {
64
+ key?: string;
65
+ provider?: string;
66
+ props?: Record<string, unknown>;
67
+ }
68
+
69
+ export interface CdkManifest {
70
+ version?: string;
71
+ artifacts?: Record<string, CdkArtifact>;
72
+ /**
73
+ * Context queries synthesis could not answer. Non-empty means the templates
74
+ * hold placeholder values rather than the real account's, so nothing
75
+ * synthesized from them describes real infrastructure.
76
+ */
77
+ missing?: CdkMissingContext[];
78
+ [k: string]: unknown;
79
+ }
80
+
81
+ /** A node of `tree.json`. Children are keyed by construct id. */
82
+ export interface CdkTreeNode {
83
+ id?: string;
84
+ path?: string;
85
+ children?: Record<string, CdkTreeNode>;
86
+ /** `fqn` is the construct class — the L1/L2/L3 signal. */
87
+ constructInfo?: { fqn?: string; version?: string };
88
+ attributes?: Record<string, unknown>;
89
+ }
90
+
91
+ export interface CdkTreeFile {
92
+ version?: string;
93
+ tree?: CdkTreeNode;
94
+ }
95
+
96
+ /** One CloudFormation stack artifact, with its template read. */
97
+ export interface CdkStack {
98
+ /** Artifact id in `manifest.json`, e.g. `Stage-AppStack`. */
99
+ id: string;
100
+ /** Construct path of the stack, e.g. `Stage/AppStack`. */
101
+ path: string;
102
+ templateFile: string;
103
+ template: CfnTemplate;
104
+ }
105
+
106
+ /** A read cloud assembly: the manifest, the construct tree, the stacks. */
107
+ export interface CloudAssembly {
108
+ dir: string;
109
+ manifest: CdkManifest;
110
+ /** Absent when the app was synthesized without tree metadata. */
111
+ tree?: CdkTreeNode;
112
+ stacks: CdkStack[];
113
+ /** Everything that went wrong or is worth saying about the read itself. */
114
+ diagnostics: string[];
115
+ }
@@ -11,10 +11,12 @@
11
11
 
12
12
  import { existsSync, statSync, mkdirSync, readFileSync, writeFileSync } from "fs";
13
13
  import { join } from "path";
14
+ import { cdkNotSupported, isCloudAssembly } from "../../cdk/assembly";
14
15
  import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
15
16
  import { boundaryReport } from "../../terraform/carve";
16
17
  import { graduationPlan, stampOwnershipIntoSource, type GraduationPlan } from "../../terraform/graduate";
17
18
  import { resolveCarveManifest, updateCarveManifest } from "../../terraform/manifest";
19
+ import { resolveEmitProvider } from "../../terraform/carve-provider";
18
20
 
19
21
  export interface CarveApplyOptions {
20
22
  from?: string;
@@ -48,6 +50,7 @@ export async function carveApply(opts: CarveApplyOptions): Promise<CarveApplyRes
48
50
  if (!existsSync(opts.from) || !statSync(opts.from).isDirectory()) {
49
51
  return { ok: false, error: `Not a directory: ${opts.from}` };
50
52
  }
53
+ if (isCloudAssembly(opts.from)) return { ok: false, error: cdkNotSupported(opts.from, "apply") };
51
54
 
52
55
  // Compose with the manifest emit/bridge persisted: it supplies the target
53
56
  // (and tfstate) when --select is absent, and its persisted boundary stands in
@@ -63,7 +66,12 @@ export async function carveApply(opts: CarveApplyOptions): Promise<CarveApplyRes
63
66
  const graph = await parseTerraformDir(opts.from, { statePath });
64
67
  const report = boundaryReport(graph, select) ?? resolved.manifest?.boundary ?? null;
65
68
  if (!report) return { ok: false, error: `${select} not found in ${opts.from}` };
66
- plan = graduationPlan(report, { stack: opts.stack, env: opts.env });
69
+ // The carved type decides what the apply step even looks like: a
70
+ // CloudFormation deploy for aws, a cluster apply for k8s (#999). The
71
+ // manifest records the type; the graph has it when there is no manifest.
72
+ const tfType = resolved.manifest?.tfType ?? graph.nodes.find((n) => n.address === select)?.type;
73
+ const lexicon = tfType ? resolveEmitProvider(tfType)?.lexicon : undefined;
74
+ plan = graduationPlan(report, { stack: opts.stack, env: opts.env, lexicon });
67
75
  } catch (err) {
68
76
  if (err instanceof Hcl2JsonNotInstalled) return { ok: false, error: err.message };
69
77
  return { ok: false, error: `Failed to plan graduation: ${err instanceof Error ? err.message : String(err)}` };
@@ -85,7 +93,15 @@ export async function carveApply(opts: CarveApplyOptions): Promise<CarveApplyRes
85
93
  for (const file of files) {
86
94
  if (!existsSync(file)) return { ok: false, error: `Emitted source not found: ${file}` };
87
95
  const result = stampOwnershipIntoSource(readFileSync(file, "utf-8"), plan.ownershipTags);
88
- if (!result) return { ok: false, error: `No constructor to stamp in ${file}.` };
96
+ if (!result) {
97
+ return {
98
+ ok: false,
99
+ error:
100
+ `No constructor to stamp in ${file}. Tag-based ownership is a property of a native constructor call; ` +
101
+ `on a Kubernetes object the marker is a label the k8s build merges in (defaultLabels), not a tag in the ` +
102
+ `emitted source. Re-run without --write-source to get the marker and the graduation plan.`,
103
+ };
104
+ }
89
105
  if (result.changed) writeFileSync(file, result.content);
90
106
  stamped.push(file);
91
107
  }
@@ -114,7 +130,7 @@ function renderGraduationDoc(plan: GraduationPlan): string {
114
130
  L.push("");
115
131
  L.push(`Ownership marker: stack=${plan.marker.stack}${plan.marker.env ? `, env=${plan.marker.env}` : ""}`);
116
132
  L.push("");
117
- L.push("## Ownership tags (stamped on apply so chant owns the resource)");
133
+ L.push(`## Ownership ${plan.markerKind} (stamped on apply so chant owns the resource)`);
118
134
  for (const [k, v] of Object.entries(plan.ownershipTags)) L.push(`- ${k} = ${v}`);
119
135
  L.push("");
120
136
  L.push("## Steps");
@@ -133,7 +149,7 @@ export function formatCarveApply(result: CarveApplyResult): string {
133
149
  const L: string[] = [];
134
150
  L.push(`Apply graduation for ${p.target} (dial-turn: observe → apply)${result.selectFromManifest ? " — target from the carve manifest" : ""}.`);
135
151
  L.push(` Ownership marker: stack=${p.marker.stack}${p.marker.env ? `, env=${p.marker.env}` : ""}`);
136
- L.push(" Ownership tags (stamped on apply):");
152
+ L.push(` Ownership ${p.markerKind} (stamped on apply):`);
137
153
  for (const [k, v] of Object.entries(p.ownershipTags)) L.push(` ${k} = ${v}`);
138
154
  L.push("");
139
155
  for (const s of p.steps) L.push(` ${s}`);
@@ -11,10 +11,11 @@
11
11
 
12
12
  import { existsSync, statSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
13
13
  import { join, basename, relative, resolve } from "path";
14
+ import { cdkNotSupported, isCloudAssembly } from "../../cdk/assembly";
14
15
  import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
15
16
  import { boundaryReport, type CarveReport } from "../../terraform/carve";
16
17
  import { generateBridge, type BridgePlan, type CarvedIdentity } from "../../terraform/bridge";
17
- import { IDENTITY_ATTR, canBridge } from "../../terraform/tier-map";
18
+ import { identityAttrOf, canBridge } from "../../terraform/tier-map";
18
19
  import { resolveCarveManifest, writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
19
20
  import { newFileDiff, unifiedDiff } from "../../terraform/unified-diff";
20
21
 
@@ -57,6 +58,7 @@ export async function carveBridge(opts: CarveBridgeOptions): Promise<CarveBridge
57
58
  if (!existsSync(opts.from) || !statSync(opts.from).isDirectory()) {
58
59
  return { ok: false, error: `Not a directory: ${opts.from}` };
59
60
  }
61
+ if (isCloudAssembly(opts.from)) return { ok: false, error: cdkNotSupported(opts.from, "bridge") };
60
62
 
61
63
  // Compose with the carve state manifest `carve emit` persisted: without
62
64
  // --select it supplies the target (and tfstate), with it it is updated below.
@@ -78,7 +80,9 @@ export async function carveBridge(opts: CarveBridgeOptions): Promise<CarveBridge
78
80
  // dotted identity attribute is a path into nested blocks, and a data body
79
81
  // is flat `attr = value`. `kubernetes_manifest` is the standing case — the
80
82
  // provider has no `kubernetes_manifest` data source either; its generic
81
- // read is `kubernetes_resource`, a different shape (#999).
83
+ // read is `kubernetes_resource`, a different shape. Emit adopting the type
84
+ // (#999) does not lift this: reading a manifest out of state and reading it
85
+ // back through a data source are separate problems (#2034).
82
86
  const carvedTypes = report.carveSet.map((m) => m.type).filter((t): t is string => t !== undefined);
83
87
  const unbridgeable = [...new Set(carvedTypes.filter((t) => !canBridge(t)))];
84
88
  if (unbridgeable.length) {
@@ -86,9 +90,9 @@ export async function carveBridge(opts: CarveBridgeOptions): Promise<CarveBridge
86
90
  ok: false,
87
91
  error:
88
92
  `${select} cannot be bridged: ${unbridgeable
89
- .map((t) => `${t} identifies itself by \`${IDENTITY_ATTR[t]}\``)
93
+ .map((t) => `${t} identifies itself by \`${identityAttrOf(t)}\``)
90
94
  .join(", ")}, a path into nested blocks that a Terraform data source body cannot express. ` +
91
- `Bridging these types needs a data-source mapping — see chant issue #999.`,
95
+ `Bridging these types needs a data-source mapping — see chant issue #2034.`,
92
96
  };
93
97
  }
94
98
 
@@ -96,7 +100,7 @@ export async function carveBridge(opts: CarveBridgeOptions): Promise<CarveBridge
96
100
  const identities = new Map<string, CarvedIdentity>();
97
101
  for (const node of graph.nodes) {
98
102
  if (node.type && node.identity) {
99
- identities.set(node.address, { attr: IDENTITY_ATTR[node.type], value: node.identity });
103
+ identities.set(node.address, { attr: identityAttrOf(node.type), value: node.identity });
100
104
  }
101
105
  }
102
106
 
@@ -0,0 +1,262 @@
1
+ import { describe, test, expect, vi } from "vitest";
2
+ import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { carveEmit, formatCarveEmit } from "./carve-emit";
6
+ import { carveBridge } from "./carve-bridge";
7
+ import { loadHcl2json } from "../../terraform/parse";
8
+ import type { ImportResult, LiveImportOptions } from "./import";
9
+ import type { LexiconPlugin } from "../../lexicon";
10
+
11
+ /**
12
+ * `carve emit` over the kubernetes provider (#999), end to end: a Terraform
13
+ * estate that manages Kubernetes objects through `kubernetes_manifest`, carved
14
+ * into a buildable chant project for the k8s lexicon.
15
+ *
16
+ * The point of the fixture is the second resource. A `kubernetes_manifest` has
17
+ * no fixed entity type — the kind is inside the body — so a cert-manager
18
+ * Certificate and a core ConfigMap go through the identical path, which is what
19
+ * "one rule covers every CRD" has to mean in practice.
20
+ */
21
+ let parserAvailable = false;
22
+ try {
23
+ await loadHcl2json();
24
+ parserAvailable = true;
25
+ } catch {
26
+ parserAvailable = false;
27
+ }
28
+
29
+ const ESTATE = `
30
+ resource "kubernetes_namespace" "web" {
31
+ metadata {
32
+ name = "web"
33
+ }
34
+ }
35
+
36
+ resource "kubernetes_manifest" "app_config" {
37
+ manifest = {
38
+ apiVersion = "v1"
39
+ kind = "ConfigMap"
40
+ metadata = {
41
+ name = "app-config"
42
+ namespace = kubernetes_namespace.web.metadata[0].name
43
+ }
44
+ data = {
45
+ LOG_LEVEL = "info"
46
+ }
47
+ }
48
+ }
49
+
50
+ resource "kubernetes_manifest" "web_cert" {
51
+ manifest = {
52
+ apiVersion = "cert-manager.io/v1"
53
+ kind = "Certificate"
54
+ metadata = {
55
+ name = "web-tls"
56
+ namespace = "web"
57
+ }
58
+ spec = {
59
+ secretName = "web-tls"
60
+ dnsNames = ["web.example.com"]
61
+ }
62
+ }
63
+ }
64
+
65
+ resource "kubernetes_config_map" "legacy" {
66
+ metadata {
67
+ name = "legacy"
68
+ namespace = "web"
69
+ }
70
+ data = {
71
+ OLD = "true"
72
+ }
73
+ }
74
+ `;
75
+
76
+ const TFSTATE = JSON.stringify({
77
+ version: 4,
78
+ resources: [
79
+ {
80
+ mode: "managed",
81
+ type: "kubernetes_manifest",
82
+ name: "app_config",
83
+ instances: [
84
+ {
85
+ attributes: {
86
+ manifest: {
87
+ apiVersion: "v1",
88
+ kind: "ConfigMap",
89
+ metadata: { name: "app-config", namespace: "web", labels: { "app.kubernetes.io/name": "web" } },
90
+ data: { LOG_LEVEL: "info" },
91
+ },
92
+ computed_fields: ["metadata.labels", "metadata.annotations"],
93
+ field_manager: null,
94
+ },
95
+ },
96
+ ],
97
+ },
98
+ {
99
+ mode: "managed",
100
+ type: "kubernetes_manifest",
101
+ name: "web_cert",
102
+ instances: [
103
+ {
104
+ attributes: {
105
+ manifest: {
106
+ apiVersion: "cert-manager.io/v1",
107
+ kind: "Certificate",
108
+ metadata: { name: "web-tls", namespace: "web" },
109
+ spec: { secretName: "web-tls", dnsNames: ["web.example.com"] },
110
+ },
111
+ },
112
+ },
113
+ ],
114
+ },
115
+ {
116
+ mode: "managed",
117
+ type: "kubernetes_config_map",
118
+ name: "legacy",
119
+ instances: [{ attributes: { id: "web/legacy", data: { OLD: "true" } } }],
120
+ },
121
+ ],
122
+ });
123
+
124
+ const fakeImport = () =>
125
+ vi.fn(
126
+ async (_plugins: LexiconPlugin[], _options: LiveImportOptions): Promise<ImportResult> => ({
127
+ success: true,
128
+ generatedFiles: ["infra/main.ts"],
129
+ warnings: [],
130
+ }),
131
+ );
132
+
133
+ async function withEstate<T>(fn: (dir: string) => Promise<T>): Promise<T> {
134
+ const dir = mkdtempSync(join(tmpdir(), "chant-carve-k8s-"));
135
+ try {
136
+ writeFileSync(join(dir, "main.tf"), ESTATE);
137
+ writeFileSync(join(dir, "terraform.tfstate"), TFSTATE);
138
+ return await fn(dir);
139
+ } finally {
140
+ rmSync(dir, { recursive: true, force: true });
141
+ }
142
+ }
143
+
144
+ describe("carve emit — kubernetes_manifest (#999)", () => {
145
+ test("carves a ConfigMap manifest into a buildable k8s project", async () => {
146
+ if (!parserAvailable) return;
147
+ await withEstate(async (dir) => {
148
+ const out = join(dir, "carveout");
149
+ const liveImport = fakeImport();
150
+ const res = await carveEmit(
151
+ { from: dir, select: "kubernetes_manifest.app_config", statePath: join(dir, "terraform.tfstate"), output: out },
152
+ { plugins: [], liveImport },
153
+ );
154
+
155
+ expect(res.ok).toBe(true);
156
+ expect(res.source).toBe("tfstate");
157
+ expect(liveImport).not.toHaveBeenCalled();
158
+
159
+ const source = readFileSync(join(out, "src", "app_config.ts"), "utf-8");
160
+ expect(source).toContain('import { k8sManifest } from "@intentius/chant-lexicon-k8s";');
161
+ expect(source).toContain("export const app_config = k8sManifest({");
162
+ expect(source).toContain('apiVersion: "v1"');
163
+ expect(source).toContain('kind: "ConfigMap"');
164
+ expect(source).toContain('"app.kubernetes.io/name": "web"');
165
+ expect(source).toContain("-> v1 ConfigMap");
166
+
167
+ // The scaffold targets the provider's lexicon, so the emitted project
168
+ // builds with `npm install && npm run build` as it stands.
169
+ const pkg = JSON.parse(readFileSync(join(out, "package.json"), "utf-8"));
170
+ expect(pkg.scripts.build).toBe("chant build src --lexicon k8s");
171
+ expect(Object.keys(pkg.dependencies)).toEqual(["@intentius/chant", "@intentius/chant-lexicon-k8s"]);
172
+ expect(readFileSync(join(out, "chant.config.ts"), "utf-8")).toContain('lexicons: ["k8s"]');
173
+
174
+ // The namespace the manifest read is a survivor, so it is an outbound
175
+ // edge and becomes a declared build parameter.
176
+ expect(res.params?.map((p) => p.name)).toEqual(["manifest"]);
177
+ expect(source).toContain("Deferred deploy-time input: manifest read kubernetes_namespace.web");
178
+ // Nothing is substituted: state resolved `manifest` to the whole object.
179
+ expect(source).not.toContain("params.");
180
+
181
+ expect(res.manifestPath).toBe(join(out, "kubernetes_manifest-app_config.carve.json"));
182
+ expect(formatCarveEmit(res)).toContain("Adopted from Terraform state (offline).");
183
+ });
184
+ });
185
+
186
+ test("a CRD manifest carves through the same path as a core kind", async () => {
187
+ if (!parserAvailable) return;
188
+ await withEstate(async (dir) => {
189
+ const out = join(dir, "carveout");
190
+ const res = await carveEmit(
191
+ { from: dir, select: "kubernetes_manifest.web_cert", statePath: join(dir, "terraform.tfstate"), output: out },
192
+ { plugins: [], liveImport: fakeImport() },
193
+ );
194
+
195
+ expect(res.ok).toBe(true);
196
+ const source = readFileSync(join(out, "src", "web_cert.ts"), "utf-8");
197
+ expect(source).toContain("-> cert-manager.io/v1 Certificate");
198
+ expect(source).toContain('apiVersion: "cert-manager.io/v1"');
199
+ expect(source).toContain('kind: "Certificate"');
200
+ expect(source).toContain('secretName: "web-tls"');
201
+ expect(source).toContain('"web.example.com",');
202
+ // No CRD-specific mapping was needed, so nothing was left behind.
203
+ expect(source).not.toContain("TODO");
204
+ });
205
+ });
206
+
207
+ test("--env refuses: the kind is in the body, so a live export has nothing to filter on", async () => {
208
+ if (!parserAvailable) return;
209
+ await withEstate(async (dir) => {
210
+ const liveImport = fakeImport();
211
+ const res = await carveEmit(
212
+ { from: dir, select: "kubernetes_manifest.app_config", env: "prod" },
213
+ { plugins: [], liveImport },
214
+ );
215
+ expect(res.ok).toBe(false);
216
+ expect(res.error).toContain("no live adoption path");
217
+ expect(res.error).toContain("--state");
218
+ expect(liveImport).not.toHaveBeenCalled();
219
+ });
220
+ });
221
+
222
+ test("a typed kubernetes resource ranks but is refused identically on both paths", async () => {
223
+ if (!parserAvailable) return;
224
+ await withEstate(async (dir) => {
225
+ const liveImport = fakeImport();
226
+ const live = await carveEmit(
227
+ { from: dir, select: "kubernetes_config_map.legacy", env: "prod" },
228
+ { plugins: [], liveImport },
229
+ );
230
+ const state = await carveEmit(
231
+ { from: dir, select: "kubernetes_config_map.legacy", statePath: join(dir, "terraform.tfstate") },
232
+ { plugins: [], liveImport },
233
+ );
234
+ expect(live.ok).toBe(false);
235
+ expect(state.error).toBe(live.error);
236
+ expect(live.error).toContain("kubernetes_config_map cannot be emitted yet");
237
+ expect(live.error).toContain("kubernetes_manifest");
238
+ expect(liveImport).not.toHaveBeenCalled();
239
+ });
240
+ });
241
+
242
+ test("bridge still refuses the carved manifest, so emit writes no half-bridged estate", async () => {
243
+ if (!parserAvailable) return;
244
+ await withEstate(async (dir) => {
245
+ const out = join(dir, "carveout");
246
+ const emitted = await carveEmit(
247
+ { from: dir, select: "kubernetes_manifest.app_config", statePath: join(dir, "terraform.tfstate"), output: out },
248
+ { plugins: [], liveImport: fakeImport() },
249
+ );
250
+ expect(emitted.ok).toBe(true);
251
+
252
+ // Emit does not unlock bridge: `manifest.metadata.name` is a path into
253
+ // nested blocks and a data-source body is flat `attr = value`, so the
254
+ // survivors' references cannot be repointed yet.
255
+ const bridged = await carveBridge({ from: dir, select: "kubernetes_manifest.app_config", output: out });
256
+ expect(bridged.ok).toBe(false);
257
+ expect(bridged.error).toContain("cannot be bridged");
258
+ expect(bridged.error).toContain("manifest.metadata.name");
259
+ expect(existsSync(join(out, "kubernetes_manifest-app_config-runbook.md"))).toBe(false);
260
+ });
261
+ });
262
+ });