@intentius/chant 0.41.19 → 0.42.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.
- package/dist/cli/build-params-cli.d.ts +7 -4
- package/dist/cli/build-params-cli.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/carve-apply.d.ts +9 -0
- package/dist/cli/commands/carve-apply.d.ts.map +1 -1
- package/dist/cli/commands/carve-bridge.d.ts +7 -0
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -1
- package/dist/cli/commands/carve-emit.d.ts +7 -0
- package/dist/cli/commands/carve-emit.d.ts.map +1 -1
- package/dist/cli/commands/import.d.ts +16 -0
- package/dist/cli/commands/import.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +2 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/handlers/carve-apply.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/explain.d.ts +11 -0
- package/dist/cli/handlers/explain.d.ts.map +1 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +4 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/docs-rule-scanning.d.ts +11 -0
- package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
- package/dist/codegen/okf-lexicon.d.ts +36 -0
- package/dist/codegen/okf-lexicon.d.ts.map +1 -0
- package/dist/codegen/package.d.ts +2 -1
- package/dist/codegen/package.d.ts.map +1 -1
- package/dist/components/deploy-units.d.ts.map +1 -1
- package/dist/components/verbs/sbom.d.ts +1 -1
- package/dist/components/verbs/sbom.d.ts.map +1 -1
- package/dist/components/verbs/vuln-gate.d.ts +15 -4
- package/dist/components/verbs/vuln-gate.d.ts.map +1 -1
- package/dist/components/verbs/vuln-scan.d.ts +18 -6
- package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
- package/dist/config.d.ts +12 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +13 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/governance.d.ts +37 -0
- package/dist/governance.d.ts.map +1 -0
- package/dist/lexicon.d.ts +3 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/okf.d.ts +57 -0
- package/dist/okf.d.ts.map +1 -0
- package/dist/reconcile.d.ts +15 -0
- package/dist/reconcile.d.ts.map +1 -1
- package/dist/terraform/__fixtures__/build-graph.d.ts +13 -0
- package/dist/terraform/__fixtures__/build-graph.d.ts.map +1 -0
- package/dist/terraform/adopt-state.d.ts +33 -1
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/bridge.d.ts +7 -1
- package/dist/terraform/bridge.d.ts.map +1 -1
- package/dist/terraform/carve.d.ts +12 -0
- package/dist/terraform/carve.d.ts.map +1 -1
- package/dist/terraform/excise.d.ts +31 -0
- package/dist/terraform/excise.d.ts.map +1 -0
- package/dist/terraform/graduate.d.ts +12 -0
- package/dist/terraform/graduate.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts +35 -5
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/manifest.d.ts +89 -0
- package/dist/terraform/manifest.d.ts.map +1 -0
- package/dist/terraform/parse.d.ts +9 -4
- package/dist/terraform/parse.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +7 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/dist/terraform/unified-diff.d.ts +11 -0
- package/dist/terraform/unified-diff.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/__snapshots__/okf.test.ts.snap +381 -0
- package/src/cli/build-params-cli.test.ts +47 -3
- package/src/cli/build-params-cli.ts +22 -5
- package/src/cli/commands/build.test.ts +44 -0
- package/src/cli/commands/build.ts +15 -2
- package/src/cli/commands/carve-apply.test.ts +94 -2
- package/src/cli/commands/carve-apply.ts +62 -11
- package/src/cli/commands/carve-bridge.test.ts +82 -4
- package/src/cli/commands/carve-bridge.ts +79 -11
- package/src/cli/commands/carve-emit-state.test.ts +110 -1
- package/src/cli/commands/carve-emit.ts +184 -8
- package/src/cli/commands/import.ts +53 -5
- package/src/cli/commands/init.ts +1 -1
- package/src/cli/handlers/build.test.ts +3 -1
- package/src/cli/handlers/carve-apply.ts +1 -0
- package/src/cli/handlers/components.ts +4 -2
- package/src/cli/handlers/explain.test.ts +93 -0
- package/src/cli/handlers/explain.ts +60 -0
- package/src/cli/handlers/lifecycle.ts +9 -7
- package/src/cli/handlers/misc.ts +40 -1
- package/src/cli/handlers/run.test.ts +3 -1
- package/src/cli/main.ts +25 -5
- package/src/cli/mcp/server.test.ts +17 -0
- package/src/cli/mcp/tools/explain.ts +15 -3
- package/src/cli/registry.ts +4 -0
- package/src/codegen/__snapshots__/okf-lexicon.test.ts.snap +170 -0
- package/src/codegen/docs-rule-scanning.ts +35 -14
- package/src/codegen/okf-lexicon.test.ts +248 -0
- package/src/codegen/okf-lexicon.ts +303 -0
- package/src/codegen/package.ts +29 -4
- package/src/codegen/publish-order.test.ts +1 -1
- package/src/codegen/release-wiring.test.ts +7 -3
- package/src/components/config-defaults.test.ts +30 -0
- package/src/components/deploy-units.ts +3 -0
- package/src/components/verbs/__fixtures__/grype-with-kev-epss.json +1 -0
- package/src/components/verbs/__fixtures__/trivy-with-kev-epss.json +631 -0
- package/src/components/verbs/exploitability-roundtrip.test.ts +190 -0
- package/src/components/verbs/sbom.ts +1 -1
- package/src/components/verbs/vuln-gate.test.ts +144 -1
- package/src/components/verbs/vuln-gate.ts +61 -10
- package/src/components/verbs/vuln-scan.test.ts +94 -0
- package/src/components/verbs/vuln-scan.ts +44 -9
- package/src/config.test.ts +21 -0
- package/src/config.ts +18 -0
- package/src/deep-observation.test.ts +19 -0
- package/src/deep-observation.ts +32 -13
- package/src/governance.test.ts +72 -0
- package/src/governance.ts +54 -0
- package/src/lexicon.ts +3 -0
- package/src/lifecycle/observe.test.ts +18 -0
- package/src/lifecycle/observe.ts +10 -3
- package/src/lifecycle/snapshot.ts +5 -4
- package/src/okf.test.ts +169 -0
- package/src/okf.ts +308 -0
- package/src/reconcile.ts +32 -1
- package/src/terraform/__fixtures__/build-graph.ts +25 -0
- package/src/terraform/adopt-state.test.ts +34 -1
- package/src/terraform/adopt-state.ts +69 -2
- package/src/terraform/bridge.test.ts +38 -14
- package/src/terraform/bridge.ts +48 -14
- package/src/terraform/carve.test.ts +9 -7
- package/src/terraform/carve.ts +0 -0
- package/src/terraform/excise.test.ts +92 -0
- package/src/terraform/excise.ts +139 -0
- package/src/terraform/graduate.test.ts +73 -6
- package/src/terraform/graduate.ts +49 -0
- package/src/terraform/graph.test.ts +56 -10
- package/src/terraform/graph.ts +112 -45
- package/src/terraform/manifest.test.ts +105 -0
- package/src/terraform/manifest.ts +162 -0
- package/src/terraform/parse.test.ts +39 -1
- package/src/terraform/parse.ts +36 -9
- package/src/terraform/score.test.ts +8 -8
- package/src/terraform/state.test.ts +4 -4
- package/src/terraform/types.ts +7 -0
- package/src/terraform/unified-diff.test.ts +77 -0
- package/src/terraform/unified-diff.ts +146 -0
|
@@ -732,3 +732,47 @@ describe("resolveBuildFormat", () => {
|
|
|
732
732
|
expect(resolveBuildFormat("yaml", "template.yaml")).toEqual({ format: "yaml" });
|
|
733
733
|
});
|
|
734
734
|
});
|
|
735
|
+
|
|
736
|
+
describe("buildCommand error grouping (fountain-ops#62)", () => {
|
|
737
|
+
let testDir: string;
|
|
738
|
+
|
|
739
|
+
beforeEach(async () => {
|
|
740
|
+
testDir = join(tmpdir(), `chant-cli-dedup-${Date.now()}-${Math.random()}`);
|
|
741
|
+
await mkdir(testDir, { recursive: true });
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
afterEach(async () => {
|
|
745
|
+
await rm(testDir, { recursive: true, force: true });
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("the same message from three files surfaces once, with a count of the others", async () => {
|
|
749
|
+
// The fountain-ops shape: a guard in a shared params module throws during
|
|
750
|
+
// every resource file's evaluation, so the identical sentence arrives
|
|
751
|
+
// once per file — fourteen times, in the report that named this issue.
|
|
752
|
+
for (const f of ["a", "b", "c"]) {
|
|
753
|
+
await writeFile(
|
|
754
|
+
join(testDir, `${f}.infra.ts`),
|
|
755
|
+
`throw new Error('tier "ha" cannot run on this seam — fix the seam');\n`,
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const result = await buildCommand({ path: testDir, format: "json", serializers: [] });
|
|
760
|
+
|
|
761
|
+
expect(result.success).toBe(false);
|
|
762
|
+
const matching = result.errors.filter((e) => e.includes("cannot run on this seam"));
|
|
763
|
+
expect(matching).toHaveLength(1);
|
|
764
|
+
expect(matching[0]).toContain("and the same from 2 more files");
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
test("distinct messages keep their own lines", async () => {
|
|
768
|
+
await writeFile(join(testDir, "a.infra.ts"), `throw new Error("first failure");\n`);
|
|
769
|
+
await writeFile(join(testDir, "b.infra.ts"), `throw new Error("second failure");\n`);
|
|
770
|
+
|
|
771
|
+
const result = await buildCommand({ path: testDir, format: "json", serializers: [] });
|
|
772
|
+
|
|
773
|
+
expect(result.success).toBe(false);
|
|
774
|
+
expect(result.errors.some((e) => e.includes("first failure"))).toBe(true);
|
|
775
|
+
expect(result.errors.some((e) => e.includes("second failure"))).toBe(true);
|
|
776
|
+
expect(result.errors.some((e) => e.includes("more files"))).toBe(false);
|
|
777
|
+
});
|
|
778
|
+
});
|
|
@@ -275,13 +275,26 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
275
275
|
}
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
-
// Format errors
|
|
278
|
+
// Format errors — grouped by message, because one refusal can surface once
|
|
279
|
+
// per resource file. A project whose params module throws (a guard like
|
|
280
|
+
// "this seam cannot back that tier") throws in EVERY file that imports it,
|
|
281
|
+
// and fourteen copies of one sentence bury the sentence
|
|
282
|
+
// (INTENTIUS/fountain-ops#62). The first file to hit it is named, the rest
|
|
283
|
+
// become a count; errors with genuinely different messages keep their own
|
|
284
|
+
// lines, and a message that appears once is printed exactly as before.
|
|
285
|
+
const byMessage = new Map<string, { first: (typeof result.errors)[number]; files: number }>();
|
|
279
286
|
for (const error of result.errors) {
|
|
287
|
+
const key = `${error.name ?? ""}:${error.message}`;
|
|
288
|
+
const seen = byMessage.get(key);
|
|
289
|
+
if (seen) seen.files += 1;
|
|
290
|
+
else byMessage.set(key, { first: error, files: 1 });
|
|
291
|
+
}
|
|
292
|
+
for (const { first: error, files } of byMessage.values()) {
|
|
280
293
|
const formatted = formatError({
|
|
281
294
|
file: "file" in error ? (error as unknown as Record<string, unknown>).file as string | undefined : undefined,
|
|
282
295
|
line: "line" in error ? (error as unknown as Record<string, unknown>).line as number | undefined : undefined,
|
|
283
296
|
column: "column" in error ? (error as unknown as Record<string, unknown>).column as number | undefined : undefined,
|
|
284
|
-
message: error.message,
|
|
297
|
+
message: files > 1 ? `${error.message} (and the same from ${files - 1} more files)` : error.message,
|
|
285
298
|
name: error.name,
|
|
286
299
|
});
|
|
287
300
|
errors.push(formatted);
|
|
@@ -4,6 +4,7 @@ import { tmpdir } from "os";
|
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { carveApply, formatCarveApply } from "./carve-apply";
|
|
6
6
|
import { loadHcl2json } from "../../terraform/parse";
|
|
7
|
+
import { readCarveManifest, writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
|
|
7
8
|
|
|
8
9
|
let parserAvailable = false;
|
|
9
10
|
try {
|
|
@@ -30,10 +31,101 @@ async function withEstate<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
|
30
31
|
}
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
function manifestFor(target: string, dir: string): CarveManifest {
|
|
35
|
+
return {
|
|
36
|
+
version: 1,
|
|
37
|
+
target,
|
|
38
|
+
from: dir,
|
|
39
|
+
boundary: {
|
|
40
|
+
target,
|
|
41
|
+
carveSet: [{ address: target, type: target.split(".")[0] }],
|
|
42
|
+
peelability: 90,
|
|
43
|
+
inbound: [],
|
|
44
|
+
outbound: [],
|
|
45
|
+
reversible: true,
|
|
46
|
+
diagnostics: [],
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
33
51
|
describe("carveApply", () => {
|
|
34
|
-
test("requires --from
|
|
52
|
+
test("requires --from; without --select it needs a carve manifest", async () => {
|
|
35
53
|
expect((await carveApply({})).error).toContain("--from");
|
|
36
|
-
|
|
54
|
+
await withEstate(async (dir) => {
|
|
55
|
+
const res = await carveApply({ from: dir });
|
|
56
|
+
expect(res.ok).toBe(false);
|
|
57
|
+
expect(res.error).toContain("--select");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("composes with the carve manifest: target resolved without --select, graduation recorded", async () => {
|
|
62
|
+
if (!parserAvailable) return;
|
|
63
|
+
await withEstate(async (dir) => {
|
|
64
|
+
const out = join(dir, "carveout");
|
|
65
|
+
writeCarveManifest(out, manifestFor("aws_s3_bucket.assets", dir));
|
|
66
|
+
|
|
67
|
+
const res = await carveApply({ from: dir, output: out, env: "prod", stack: "assets" });
|
|
68
|
+
expect(res.ok).toBe(true);
|
|
69
|
+
expect(res.selectFromManifest).toBe(true);
|
|
70
|
+
expect(formatCarveApply(res)).toContain("target from the carve manifest");
|
|
71
|
+
|
|
72
|
+
const m = readCarveManifest(res.manifestPath!)!;
|
|
73
|
+
expect(m.apply!.marker).toEqual({ stack: "assets", env: "prod" });
|
|
74
|
+
expect(m.apply!.ownershipTags["chant:managed-by"]).toBe("chant");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("--write-source stamps the ownership marker into the emitted source and records it", async () => {
|
|
79
|
+
if (!parserAvailable) return;
|
|
80
|
+
await withEstate(async (dir) => {
|
|
81
|
+
const out = join(dir, "carveout");
|
|
82
|
+
const emitted = join(out, "assets.ts");
|
|
83
|
+
const manifest = manifestFor("aws_s3_bucket.assets", dir);
|
|
84
|
+
manifest.emit = { source: "tfstate", files: [emitted], at: "t" };
|
|
85
|
+
writeCarveManifest(out, manifest);
|
|
86
|
+
writeFileSync(emitted, 'export const assets = new Bucket({\n BucketName: "myapp-assets-prod",\n});\n');
|
|
87
|
+
|
|
88
|
+
const res = await carveApply({ from: dir, output: out, env: "prod", stack: "assets", writeSource: true });
|
|
89
|
+
expect(res.ok).toBe(true);
|
|
90
|
+
expect(res.stamped).toEqual([emitted]);
|
|
91
|
+
|
|
92
|
+
const stamped = readFileSync(emitted, "utf-8");
|
|
93
|
+
expect(stamped).toContain('{"Key":"chant:managed-by","Value":"chant"}');
|
|
94
|
+
expect(stamped).toContain('{"Key":"chant:stack","Value":"assets"}');
|
|
95
|
+
expect(stamped).toContain('{"Key":"chant:env","Value":"prod"}');
|
|
96
|
+
|
|
97
|
+
const m = readCarveManifest(res.manifestPath!)!;
|
|
98
|
+
expect(m.apply!.stampedFiles).toEqual([emitted]);
|
|
99
|
+
expect(formatCarveApply(res)).toContain("Stamped the ownership marker into");
|
|
100
|
+
|
|
101
|
+
// Idempotent: a second graduation leaves the source stable.
|
|
102
|
+
const again = await carveApply({ from: dir, output: out, env: "prod", stack: "assets", writeSource: true });
|
|
103
|
+
expect(again.ok).toBe(true);
|
|
104
|
+
expect(readFileSync(emitted, "utf-8")).toBe(stamped);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("--write-source without an emit record in the manifest is a clear error", async () => {
|
|
109
|
+
if (!parserAvailable) return;
|
|
110
|
+
await withEstate(async (dir) => {
|
|
111
|
+
const out = join(dir, "carveout");
|
|
112
|
+
writeCarveManifest(out, manifestFor("aws_s3_bucket.assets", dir));
|
|
113
|
+
const res = await carveApply({ from: dir, output: out, env: "prod", writeSource: true });
|
|
114
|
+
expect(res.ok).toBe(false);
|
|
115
|
+
expect(res.error).toContain("carve emit");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("falls back to the manifest's persisted boundary when the block is already excised", async () => {
|
|
120
|
+
if (!parserAvailable) return;
|
|
121
|
+
await withEstate(async (dir) => {
|
|
122
|
+
const out = join(dir, "carveout");
|
|
123
|
+
writeCarveManifest(out, manifestFor("aws_s3_bucket.gone", dir));
|
|
124
|
+
|
|
125
|
+
const res = await carveApply({ from: dir, output: out, env: "prod" });
|
|
126
|
+
expect(res.ok).toBe(true);
|
|
127
|
+
expect(res.plan!.target).toBe("aws_s3_bucket.gone");
|
|
128
|
+
});
|
|
37
129
|
});
|
|
38
130
|
|
|
39
131
|
test("plans graduation with ownership marker; writes nothing by default", async () => {
|
|
@@ -9,14 +9,16 @@
|
|
|
9
9
|
* lifecycle you brought.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { existsSync, statSync, mkdirSync, writeFileSync } from "fs";
|
|
12
|
+
import { existsSync, statSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
13
13
|
import { join } from "path";
|
|
14
14
|
import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
|
|
15
15
|
import { boundaryReport } from "../../terraform/carve";
|
|
16
|
-
import { graduationPlan, type GraduationPlan } from "../../terraform/graduate";
|
|
16
|
+
import { graduationPlan, stampOwnershipIntoSource, type GraduationPlan } from "../../terraform/graduate";
|
|
17
|
+
import { resolveCarveManifest, updateCarveManifest } from "../../terraform/manifest";
|
|
17
18
|
|
|
18
19
|
export interface CarveApplyOptions {
|
|
19
20
|
from?: string;
|
|
21
|
+
/** Optional when the output dir holds a single carve manifest from `carve emit`. */
|
|
20
22
|
select?: string;
|
|
21
23
|
env?: string;
|
|
22
24
|
stack?: string;
|
|
@@ -24,6 +26,8 @@ export interface CarveApplyOptions {
|
|
|
24
26
|
output?: string;
|
|
25
27
|
/** Write the graduation doc to the output dir. */
|
|
26
28
|
write?: boolean;
|
|
29
|
+
/** Stamp the ownership marker into the emitted chant source. */
|
|
30
|
+
writeSource?: boolean;
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
export interface CarveApplyResult {
|
|
@@ -31,36 +35,77 @@ export interface CarveApplyResult {
|
|
|
31
35
|
error?: string;
|
|
32
36
|
plan?: GraduationPlan;
|
|
33
37
|
written?: string;
|
|
38
|
+
/** Emitted source files the ownership marker was stamped into. */
|
|
39
|
+
stamped?: string[];
|
|
40
|
+
/** The carve state manifest this graduation was recorded into. */
|
|
41
|
+
manifestPath?: string;
|
|
42
|
+
/** True when the target came from the manifest, not --select. */
|
|
43
|
+
selectFromManifest?: boolean;
|
|
34
44
|
}
|
|
35
45
|
|
|
36
46
|
export async function carveApply(opts: CarveApplyOptions): Promise<CarveApplyResult> {
|
|
37
47
|
if (!opts.from) return { ok: false, error: "chant carve apply requires --from <terraform-dir>" };
|
|
38
|
-
if (!opts.select) return { ok: false, error: "chant carve apply requires --select <tf-address>" };
|
|
39
48
|
if (!existsSync(opts.from) || !statSync(opts.from).isDirectory()) {
|
|
40
49
|
return { ok: false, error: `Not a directory: ${opts.from}` };
|
|
41
50
|
}
|
|
42
51
|
|
|
52
|
+
// Compose with the manifest emit/bridge persisted: it supplies the target
|
|
53
|
+
// (and tfstate) when --select is absent, and its persisted boundary stands in
|
|
54
|
+
// when the carved block is already excised from the estate.
|
|
55
|
+
const outDir = opts.output ?? join(opts.from, "carveout");
|
|
56
|
+
const resolved = resolveCarveManifest(outDir, opts.select);
|
|
57
|
+
if (!opts.select && resolved.error) return { ok: false, error: resolved.error };
|
|
58
|
+
const select = opts.select ?? resolved.manifest!.target;
|
|
59
|
+
const statePath = opts.statePath ?? resolved.manifest?.statePath;
|
|
60
|
+
|
|
43
61
|
let plan: GraduationPlan;
|
|
44
62
|
try {
|
|
45
|
-
const graph = await parseTerraformDir(opts.from, { statePath
|
|
46
|
-
const report = boundaryReport(graph,
|
|
47
|
-
if (!report) return { ok: false, error: `${
|
|
63
|
+
const graph = await parseTerraformDir(opts.from, { statePath });
|
|
64
|
+
const report = boundaryReport(graph, select) ?? resolved.manifest?.boundary ?? null;
|
|
65
|
+
if (!report) return { ok: false, error: `${select} not found in ${opts.from}` };
|
|
48
66
|
plan = graduationPlan(report, { stack: opts.stack, env: opts.env });
|
|
49
67
|
} catch (err) {
|
|
50
68
|
if (err instanceof Hcl2JsonNotInstalled) return { ok: false, error: err.message };
|
|
51
69
|
return { ok: false, error: `Failed to plan graduation: ${err instanceof Error ? err.message : String(err)}` };
|
|
52
70
|
}
|
|
53
71
|
|
|
72
|
+
// Stamp the marker into the emitted source (`--write-source`) — the emitted
|
|
73
|
+
// files are known from the manifest, so the tags the plan promises are the
|
|
74
|
+
// tags the source actually carries into the apply.
|
|
75
|
+
let stamped: string[] | undefined;
|
|
76
|
+
if (opts.writeSource) {
|
|
77
|
+
const files = resolved.manifest?.emit?.files ?? [];
|
|
78
|
+
if (files.length === 0) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
error: "--write-source needs the emitted source recorded in the carve manifest — run `chant carve emit` first.",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
stamped = [];
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
if (!existsSync(file)) return { ok: false, error: `Emitted source not found: ${file}` };
|
|
87
|
+
const result = stampOwnershipIntoSource(readFileSync(file, "utf-8"), plan.ownershipTags);
|
|
88
|
+
if (!result) return { ok: false, error: `No constructor to stamp in ${file}.` };
|
|
89
|
+
if (result.changed) writeFileSync(file, result.content);
|
|
90
|
+
stamped.push(file);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
54
94
|
let written: string | undefined;
|
|
55
95
|
if (opts.write) {
|
|
56
|
-
const outDir = opts.output ?? join(opts.from, "carveout");
|
|
57
96
|
mkdirSync(outDir, { recursive: true });
|
|
58
|
-
const slug =
|
|
97
|
+
const slug = select.replace(/[^A-Za-z0-9_]+/g, "-");
|
|
59
98
|
written = join(outDir, `${slug}-graduation.md`);
|
|
60
99
|
writeFileSync(written, renderGraduationDoc(plan) + "\n");
|
|
61
100
|
}
|
|
62
101
|
|
|
63
|
-
|
|
102
|
+
// Record the graduation into an existing manifest; never create one here —
|
|
103
|
+
// plan-only stays plan-only unless a carve flow is already in progress.
|
|
104
|
+
const manifestPath = updateCarveManifest(outDir, select, {
|
|
105
|
+
apply: { marker: plan.marker, ownershipTags: plan.ownershipTags, stampedFiles: stamped, at: new Date().toISOString() },
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return { ok: true, plan, written, stamped, manifestPath, selectFromManifest: !opts.select };
|
|
64
109
|
}
|
|
65
110
|
|
|
66
111
|
function renderGraduationDoc(plan: GraduationPlan): string {
|
|
@@ -86,7 +131,7 @@ export function formatCarveApply(result: CarveApplyResult): string {
|
|
|
86
131
|
if (!result.ok || !result.plan) return result.error ?? "carve apply failed";
|
|
87
132
|
const p = result.plan;
|
|
88
133
|
const L: string[] = [];
|
|
89
|
-
L.push(`Apply graduation for ${p.target} (dial-turn: observe → apply).`);
|
|
134
|
+
L.push(`Apply graduation for ${p.target} (dial-turn: observe → apply)${result.selectFromManifest ? " — target from the carve manifest" : ""}.`);
|
|
90
135
|
L.push(` Ownership marker: stack=${p.marker.stack}${p.marker.env ? `, env=${p.marker.env}` : ""}`);
|
|
91
136
|
L.push(" Ownership tags (stamped on apply):");
|
|
92
137
|
for (const [k, v] of Object.entries(p.ownershipTags)) L.push(` ${k} = ${v}`);
|
|
@@ -97,10 +142,16 @@ export function formatCarveApply(result: CarveApplyResult): string {
|
|
|
97
142
|
for (const w of p.warnings) L.push(` ! ${w}`);
|
|
98
143
|
}
|
|
99
144
|
L.push("");
|
|
145
|
+
if (result.stamped?.length) {
|
|
146
|
+
L.push(`Stamped the ownership marker into: ${result.stamped.join(", ")}`);
|
|
147
|
+
}
|
|
100
148
|
if (result.written) {
|
|
101
149
|
L.push(`Wrote graduation doc: ${result.written}`);
|
|
102
|
-
} else {
|
|
150
|
+
} else if (!result.stamped?.length) {
|
|
103
151
|
L.push("Plan only — no cloud call, nothing written. The apply is your lifecycle (BYOL). Add --write to save this as a doc.");
|
|
104
152
|
}
|
|
153
|
+
if (result.manifestPath) {
|
|
154
|
+
L.push(`Recorded in the carve manifest: ${result.manifestPath}`);
|
|
155
|
+
}
|
|
105
156
|
return L.join("\n");
|
|
106
157
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { execFileSync } from "child_process";
|
|
2
3
|
import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from "fs";
|
|
3
4
|
import { tmpdir } from "os";
|
|
4
5
|
import { join } from "path";
|
|
5
6
|
import { carveBridge, formatCarveBridge } from "./carve-bridge";
|
|
6
7
|
import { loadHcl2json } from "../../terraform/parse";
|
|
8
|
+
import { readCarveManifest, writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
|
|
7
9
|
|
|
8
10
|
let parserAvailable = false;
|
|
9
11
|
try {
|
|
@@ -39,10 +41,52 @@ async function withEstate<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
|
39
41
|
}
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
function manifestFor(target: string, dir: string): CarveManifest {
|
|
45
|
+
return {
|
|
46
|
+
version: 1,
|
|
47
|
+
target,
|
|
48
|
+
from: dir,
|
|
49
|
+
boundary: {
|
|
50
|
+
target,
|
|
51
|
+
carveSet: [{ address: target, type: target.split(".")[0] }],
|
|
52
|
+
peelability: 90,
|
|
53
|
+
inbound: [],
|
|
54
|
+
outbound: [],
|
|
55
|
+
reversible: true,
|
|
56
|
+
diagnostics: [],
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
42
61
|
describe("carveBridge", () => {
|
|
43
|
-
test("requires --from
|
|
62
|
+
test("requires --from; without --select it needs a carve manifest", async () => {
|
|
44
63
|
expect((await carveBridge({})).error).toContain("--from");
|
|
45
|
-
|
|
64
|
+
await withEstate(async (dir) => {
|
|
65
|
+
const res = await carveBridge({ from: dir });
|
|
66
|
+
expect(res.ok).toBe(false);
|
|
67
|
+
expect(res.error).toContain("--select");
|
|
68
|
+
expect(res.error).toContain("carve emit");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("composes with the carve manifest: target resolved without --select, bridge recorded", async () => {
|
|
73
|
+
if (!parserAvailable) return;
|
|
74
|
+
await withEstate(async (dir) => {
|
|
75
|
+
const out = join(dir, "carveout");
|
|
76
|
+
writeCarveManifest(out, manifestFor("aws_s3_bucket.assets", dir));
|
|
77
|
+
|
|
78
|
+
const res = await carveBridge({ from: dir, output: out });
|
|
79
|
+
expect(res.ok).toBe(true);
|
|
80
|
+
expect(res.selectFromManifest).toBe(true);
|
|
81
|
+
expect(res.plan!.target).toBe("aws_s3_bucket.assets");
|
|
82
|
+
expect(formatCarveBridge(res)).toContain("target from the carve manifest");
|
|
83
|
+
|
|
84
|
+
const m = readCarveManifest(res.manifestPath!)!;
|
|
85
|
+
expect(m.bridge!.written.length).toBeGreaterThan(0);
|
|
86
|
+
expect(m.bridge!.appliedInPlace).toBe(false);
|
|
87
|
+
// The boundary is refreshed from the estate, not left as the stub.
|
|
88
|
+
expect(m.boundary.inbound.map((e) => e.survivor)).toEqual(["aws_lambda_function.api"]);
|
|
89
|
+
});
|
|
46
90
|
});
|
|
47
91
|
|
|
48
92
|
test("dry-run: writes runbook + data sources + proposed survivor, touches no .tf in place", async () => {
|
|
@@ -65,6 +109,39 @@ describe("carveBridge", () => {
|
|
|
65
109
|
});
|
|
66
110
|
});
|
|
67
111
|
|
|
112
|
+
test("emits one git-applyable patch carrying the whole survivor edit", async () => {
|
|
113
|
+
if (!parserAvailable) return;
|
|
114
|
+
await withEstate(async (dir) => {
|
|
115
|
+
const out = join(dir, "carveout");
|
|
116
|
+
const res = await carveBridge({ from: dir, select: "aws_s3_bucket.assets", output: out });
|
|
117
|
+
expect(res.ok).toBe(true);
|
|
118
|
+
expect(res.patchPath).toBe(join(out, "aws_s3_bucket-assets-bridge.patch"));
|
|
119
|
+
expect(res.written).toContain(res.patchPath);
|
|
120
|
+
|
|
121
|
+
const patch = readFileSync(res.patchPath!, "utf-8");
|
|
122
|
+
expect(patch).toContain("diff --git a/aws_s3_bucket-assets-datasources.tf b/aws_s3_bucket-assets-datasources.tf");
|
|
123
|
+
expect(patch).toContain("new file mode 100644");
|
|
124
|
+
expect(patch).toContain("diff --git a/api.tf b/api.tf");
|
|
125
|
+
expect(patch).toContain("- ASSETS_BUCKET = aws_s3_bucket.assets.bucket");
|
|
126
|
+
expect(patch).toContain("+ ASSETS_BUCKET = data.aws_s3_bucket.assets.bucket");
|
|
127
|
+
|
|
128
|
+
const m = readCarveManifest(res.manifestPath!)!;
|
|
129
|
+
expect(m.bridge!.patch).toBe(res.patchPath);
|
|
130
|
+
expect(formatCarveBridge(res)).toContain("git apply");
|
|
131
|
+
|
|
132
|
+
// The patch really applies: `git apply` from the estate reproduces the
|
|
133
|
+
// rewired survivor and the new data-source file.
|
|
134
|
+
try {
|
|
135
|
+
execFileSync("git", ["apply", res.patchPath!], { cwd: dir });
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return; // no git on this machine
|
|
138
|
+
throw err;
|
|
139
|
+
}
|
|
140
|
+
expect(readFileSync(join(dir, "api.tf"), "utf-8")).toContain("data.aws_s3_bucket.assets.bucket");
|
|
141
|
+
expect(readFileSync(join(dir, "aws_s3_bucket-assets-datasources.tf"), "utf-8")).toContain('data "aws_s3_bucket" "assets"');
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
68
145
|
test("--apply-rewrites edits the survivor .tf in place", async () => {
|
|
69
146
|
if (!parserAvailable) return;
|
|
70
147
|
await withEstate(async (dir) => {
|
|
@@ -77,10 +154,11 @@ describe("carveBridge", () => {
|
|
|
77
154
|
expect(res.ok).toBe(true);
|
|
78
155
|
expect(res.appliedInPlace).toBe(true);
|
|
79
156
|
|
|
80
|
-
// api.tf rewritten in place; bucket.tf
|
|
157
|
+
// api.tf rewritten in place; bucket.tf's carved declaration excised
|
|
158
|
+
// (#998 — after `terraform state rm`, the block would re-create it).
|
|
81
159
|
const api = readFileSync(join(dir, "api.tf"), "utf-8");
|
|
82
160
|
expect(api).toContain("data.aws_s3_bucket.assets.bucket");
|
|
83
|
-
expect(readFileSync(join(dir, "bucket.tf"), "utf-8")).
|
|
161
|
+
expect(readFileSync(join(dir, "bucket.tf"), "utf-8")).not.toContain('resource "aws_s3_bucket" "assets"');
|
|
84
162
|
});
|
|
85
163
|
});
|
|
86
164
|
|
|
@@ -10,14 +10,17 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { existsSync, statSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "fs";
|
|
13
|
-
import { join, basename } from "path";
|
|
13
|
+
import { join, basename, relative, resolve } from "path";
|
|
14
14
|
import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
|
|
15
|
-
import { boundaryReport } from "../../terraform/carve";
|
|
15
|
+
import { boundaryReport, type CarveReport } from "../../terraform/carve";
|
|
16
16
|
import { generateBridge, type BridgePlan, type CarvedIdentity } from "../../terraform/bridge";
|
|
17
17
|
import { IDENTITY_ATTR } from "../../terraform/tier-map";
|
|
18
|
+
import { resolveCarveManifest, writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
|
|
19
|
+
import { newFileDiff, unifiedDiff } from "../../terraform/unified-diff";
|
|
18
20
|
|
|
19
21
|
export interface CarveBridgeOptions {
|
|
20
22
|
from?: string;
|
|
23
|
+
/** Optional when the output dir holds a single carve manifest from `carve emit`. */
|
|
21
24
|
select?: string;
|
|
22
25
|
statePath?: string;
|
|
23
26
|
/** Output directory for proposed files (default `<from>/carveout`). */
|
|
@@ -34,6 +37,12 @@ export interface CarveBridgeResult {
|
|
|
34
37
|
written?: string[];
|
|
35
38
|
/** True if survivor files were edited in place. */
|
|
36
39
|
appliedInPlace?: boolean;
|
|
40
|
+
/** The git-applyable `.patch` carrying the whole survivor edit. */
|
|
41
|
+
patchPath?: string;
|
|
42
|
+
/** The carve state manifest this bridge composed with / recorded into. */
|
|
43
|
+
manifestPath?: string;
|
|
44
|
+
/** True when the target came from the manifest, not --select. */
|
|
45
|
+
selectFromManifest?: boolean;
|
|
37
46
|
}
|
|
38
47
|
|
|
39
48
|
function listTfFiles(dir: string): string[] {
|
|
@@ -45,16 +54,25 @@ function listTfFiles(dir: string): string[] {
|
|
|
45
54
|
|
|
46
55
|
export async function carveBridge(opts: CarveBridgeOptions): Promise<CarveBridgeResult> {
|
|
47
56
|
if (!opts.from) return { ok: false, error: "chant carve bridge requires --from <terraform-dir>" };
|
|
48
|
-
if (!opts.select) return { ok: false, error: "chant carve bridge requires --select <tf-address>" };
|
|
49
57
|
if (!existsSync(opts.from) || !statSync(opts.from).isDirectory()) {
|
|
50
58
|
return { ok: false, error: `Not a directory: ${opts.from}` };
|
|
51
59
|
}
|
|
52
60
|
|
|
61
|
+
// Compose with the carve state manifest `carve emit` persisted: without
|
|
62
|
+
// --select it supplies the target (and tfstate), with it it is updated below.
|
|
63
|
+
const outDir = opts.output ?? join(opts.from, "carveout");
|
|
64
|
+
const resolved = resolveCarveManifest(outDir, opts.select);
|
|
65
|
+
if (!opts.select && resolved.error) return { ok: false, error: resolved.error };
|
|
66
|
+
const select = opts.select ?? resolved.manifest!.target;
|
|
67
|
+
const statePath = opts.statePath ?? resolved.manifest?.statePath;
|
|
68
|
+
|
|
53
69
|
let plan: BridgePlan;
|
|
70
|
+
let report: CarveReport;
|
|
54
71
|
try {
|
|
55
|
-
const graph = await parseTerraformDir(opts.from, { statePath
|
|
56
|
-
const
|
|
57
|
-
if (!
|
|
72
|
+
const graph = await parseTerraformDir(opts.from, { statePath });
|
|
73
|
+
const found = boundaryReport(graph, select);
|
|
74
|
+
if (!found) return { ok: false, error: `${select} not found in ${opts.from}` };
|
|
75
|
+
report = found;
|
|
58
76
|
|
|
59
77
|
// Physical identities for the carved resources, for the data sources.
|
|
60
78
|
const identities = new Map<string, CarvedIdentity>();
|
|
@@ -71,8 +89,7 @@ export async function carveBridge(opts: CarveBridgeOptions): Promise<CarveBridge
|
|
|
71
89
|
return { ok: false, error: `Failed to build the bridge: ${err instanceof Error ? err.message : String(err)}` };
|
|
72
90
|
}
|
|
73
91
|
|
|
74
|
-
const slug =
|
|
75
|
-
const outDir = opts.output ?? join(opts.from, "carveout");
|
|
92
|
+
const slug = select.replace(/[^A-Za-z0-9_]+/g, "-");
|
|
76
93
|
mkdirSync(outDir, { recursive: true });
|
|
77
94
|
const written: string[] = [];
|
|
78
95
|
|
|
@@ -101,19 +118,66 @@ export async function carveBridge(opts: CarveBridgeOptions): Promise<CarveBridge
|
|
|
101
118
|
}
|
|
102
119
|
}
|
|
103
120
|
|
|
104
|
-
|
|
121
|
+
// The whole survivor edit as one git-applyable patch: the data-source file as
|
|
122
|
+
// a new file, plus the rewired references — `cd <from> && git apply <patch>`.
|
|
123
|
+
let patchPath: string | undefined;
|
|
124
|
+
if (plan.dataSources.length || changed.length) {
|
|
125
|
+
const chunks: string[] = [];
|
|
126
|
+
if (plan.dataSources.length) {
|
|
127
|
+
chunks.push(newFileDiff(`${slug}-datasources.tf`, plan.dataSources.map((d) => d.hcl).join("\n\n") + "\n"));
|
|
128
|
+
}
|
|
129
|
+
for (const r of changed) {
|
|
130
|
+
chunks.push(unifiedDiff(relative(opts.from, r.path), r.original, r.rewritten));
|
|
131
|
+
}
|
|
132
|
+
patchPath = join(outDir, `${slug}-bridge.patch`);
|
|
133
|
+
writeFileSync(patchPath, chunks.join(""));
|
|
134
|
+
written.push(patchPath);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Record the bridge into the manifest (create one on a standalone bridge),
|
|
138
|
+
// so `carve apply` composes with this step too.
|
|
139
|
+
const applied = Boolean(opts.applyRewrites) && changed.length > 0;
|
|
140
|
+
const manifest: CarveManifest = resolved.manifest ?? {
|
|
141
|
+
version: 1,
|
|
142
|
+
target: select,
|
|
143
|
+
from: resolve(opts.from),
|
|
144
|
+
statePath: statePath ? resolve(statePath) : undefined,
|
|
145
|
+
boundary: report,
|
|
146
|
+
};
|
|
147
|
+
manifest.boundary = report;
|
|
148
|
+
manifest.bridge = {
|
|
149
|
+
written: written.map((w) => resolve(w)),
|
|
150
|
+
appliedInPlace: applied,
|
|
151
|
+
patch: patchPath ? resolve(patchPath) : undefined,
|
|
152
|
+
excised: plan.excised.length ? plan.excised : undefined,
|
|
153
|
+
at: new Date().toISOString(),
|
|
154
|
+
};
|
|
155
|
+
const manifestPath = writeCarveManifest(outDir, manifest);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
ok: true,
|
|
159
|
+
plan,
|
|
160
|
+
written,
|
|
161
|
+
appliedInPlace: applied,
|
|
162
|
+
patchPath,
|
|
163
|
+
manifestPath,
|
|
164
|
+
selectFromManifest: !opts.select,
|
|
165
|
+
};
|
|
105
166
|
}
|
|
106
167
|
|
|
107
168
|
export function formatCarveBridge(result: CarveBridgeResult): string {
|
|
108
169
|
if (!result.ok || !result.plan) return result.error ?? "carve bridge failed";
|
|
109
170
|
const p = result.plan;
|
|
110
171
|
const L: string[] = [];
|
|
111
|
-
L.push(`Boundary bridge for ${p.target}:`);
|
|
172
|
+
L.push(`Boundary bridge for ${p.target}${result.selectFromManifest ? " (target from the carve manifest)" : ""}:`);
|
|
112
173
|
if (p.dataSources.length) {
|
|
113
174
|
L.push(` ${p.dataSources.length} data source(s) for survivors to read:`);
|
|
114
175
|
for (const d of p.dataSources) L.push(` data.${d.type}.${d.name} (was ${d.address})`);
|
|
115
176
|
} else {
|
|
116
|
-
L.push(" no inbound edges — no
|
|
177
|
+
L.push(" no inbound edges — nothing reads the carved resource, no data source needed.");
|
|
178
|
+
}
|
|
179
|
+
if (p.excised.length) {
|
|
180
|
+
L.push(` carved block(s) excised from the survivor source: ${p.excised.join(", ")}`);
|
|
117
181
|
}
|
|
118
182
|
const changed = p.rewrites.filter((r) => r.changed);
|
|
119
183
|
if (changed.length) {
|
|
@@ -124,6 +188,10 @@ export function formatCarveBridge(result: CarveBridgeResult): string {
|
|
|
124
188
|
for (const d of p.deferredInputs) L.push(` - ${d.note}`);
|
|
125
189
|
}
|
|
126
190
|
L.push("");
|
|
191
|
+
if (result.patchPath && !result.appliedInPlace) {
|
|
192
|
+
L.push(`One git-applyable patch carries the whole edit: git apply --directory=<terraform-dir> ${resolve(result.patchPath)}`);
|
|
193
|
+
L.push(" (--directory is the Terraform dir relative to your repo root; plain `git apply` from the dir works outside a repo.)");
|
|
194
|
+
}
|
|
127
195
|
if (result.appliedInPlace) {
|
|
128
196
|
L.push("Rewritten survivor Terraform in place. Review with `git diff`, then `terraform plan`.");
|
|
129
197
|
} else {
|