@intentius/chant 0.18.36 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/carve-apply.d.ts +30 -0
- package/dist/cli/commands/carve-apply.d.ts.map +1 -0
- package/dist/cli/commands/carve-bridge.d.ts +32 -0
- package/dist/cli/commands/carve-bridge.d.ts.map +1 -0
- package/dist/cli/commands/carve-emit.d.ts +58 -0
- package/dist/cli/commands/carve-emit.d.ts.map +1 -0
- package/dist/cli/commands/carve.d.ts +29 -0
- package/dist/cli/commands/carve.d.ts.map +1 -0
- package/dist/cli/handlers/carve-apply.d.ts +9 -0
- package/dist/cli/handlers/carve-apply.d.ts.map +1 -0
- package/dist/cli/handlers/carve-bridge.d.ts +10 -0
- package/dist/cli/handlers/carve-bridge.d.ts.map +1 -0
- package/dist/cli/handlers/carve-emit.d.ts +11 -0
- package/dist/cli/handlers/carve-emit.d.ts.map +1 -0
- package/dist/cli/handlers/carve.d.ts +12 -0
- package/dist/cli/handlers/carve.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +12 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/terraform/adopt-state.d.ts +32 -0
- package/dist/terraform/adopt-state.d.ts.map +1 -0
- package/dist/terraform/aws-resources.d.ts +49 -0
- package/dist/terraform/aws-resources.d.ts.map +1 -0
- package/dist/terraform/bridge.d.ts +65 -0
- package/dist/terraform/bridge.d.ts.map +1 -0
- package/dist/terraform/carve.d.ts +66 -0
- package/dist/terraform/carve.d.ts.map +1 -0
- package/dist/terraform/graduate.d.ts +43 -0
- package/dist/terraform/graduate.d.ts.map +1 -0
- package/dist/terraform/graph.d.ts +24 -0
- package/dist/terraform/graph.d.ts.map +1 -0
- package/dist/terraform/parse.d.ts +33 -0
- package/dist/terraform/parse.d.ts.map +1 -0
- package/dist/terraform/score.d.ts +50 -0
- package/dist/terraform/score.d.ts.map +1 -0
- package/dist/terraform/state.d.ts +42 -0
- package/dist/terraform/state.d.ts.map +1 -0
- package/dist/terraform/tier-map.d.ts +38 -0
- package/dist/terraform/tier-map.d.ts.map +1 -0
- package/dist/terraform/types.d.ts +66 -0
- package/dist/terraform/types.d.ts.map +1 -0
- package/package.json +2 -1
- package/src/cli/commands/carve-apply.test.ts +82 -0
- package/src/cli/commands/carve-apply.ts +106 -0
- package/src/cli/commands/carve-bridge.test.ts +97 -0
- package/src/cli/commands/carve-bridge.ts +134 -0
- package/src/cli/commands/carve-emit-state.test.ts +114 -0
- package/src/cli/commands/carve-emit.test.ts +158 -0
- package/src/cli/commands/carve-emit.ts +181 -0
- package/src/cli/commands/carve.test.ts +104 -0
- package/src/cli/commands/carve.ts +123 -0
- package/src/cli/handlers/carve-apply.ts +34 -0
- package/src/cli/handlers/carve-bridge.ts +33 -0
- package/src/cli/handlers/carve-emit.ts +51 -0
- package/src/cli/handlers/carve.ts +44 -0
- package/src/cli/main.ts +48 -0
- package/src/cli/registry.ts +12 -0
- package/src/terraform/__fixtures__/advise.test.ts +51 -0
- package/src/terraform/__fixtures__/carve-arc.test.ts +77 -0
- package/src/terraform/__fixtures__/sample-estate/main.tf +58 -0
- package/src/terraform/adopt-state.test.ts +64 -0
- package/src/terraform/adopt-state.ts +79 -0
- package/src/terraform/aws-resources.test.ts +74 -0
- package/src/terraform/aws-resources.ts +154 -0
- package/src/terraform/bridge.test.ts +118 -0
- package/src/terraform/bridge.ts +164 -0
- package/src/terraform/carve.test.ts +97 -0
- package/src/terraform/carve.ts +0 -0
- package/src/terraform/graduate.test.ts +49 -0
- package/src/terraform/graduate.ts +78 -0
- package/src/terraform/graph.test.ts +137 -0
- package/src/terraform/graph.ts +176 -0
- package/src/terraform/parse.test.ts +56 -0
- package/src/terraform/parse.ts +87 -0
- package/src/terraform/score.test.ts +140 -0
- package/src/terraform/score.ts +148 -0
- package/src/terraform/state.test.ts +76 -0
- package/src/terraform/state.ts +87 -0
- package/src/terraform/tier-map.ts +60 -0
- package/src/terraform/types.ts +69 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, test, expect, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { carveEmit, formatCarveEmit } from "./carve-emit";
|
|
6
|
+
import { loadHcl2json } from "../../terraform/parse";
|
|
7
|
+
import type { ImportResult, LiveImportOptions } from "./import";
|
|
8
|
+
import type { LexiconPlugin } from "../../lexicon";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Real (non-faked) adoption from `.tfstate` — #1009. A Terraform-managed
|
|
12
|
+
* resource is not in any CloudFormation stack, so the live (CFN) import path
|
|
13
|
+
* cannot adopt it; its resolved shape lives in the state file. This exercises
|
|
14
|
+
* the offline state-adoption path end to end with real state data. Skips only
|
|
15
|
+
* if the wasm parser (for the boundary report) is absent.
|
|
16
|
+
*/
|
|
17
|
+
let parserAvailable = false;
|
|
18
|
+
try {
|
|
19
|
+
await loadHcl2json();
|
|
20
|
+
parserAvailable = true;
|
|
21
|
+
} catch {
|
|
22
|
+
parserAvailable = false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ESTATE = `
|
|
26
|
+
resource "aws_s3_bucket" "assets" { bucket = "myapp-assets-prod" }
|
|
27
|
+
resource "aws_lambda_function" "api" {
|
|
28
|
+
environment { variables = { B = aws_s3_bucket.assets.bucket } }
|
|
29
|
+
}
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
// A realistic terraform.tfstate v4 with the bucket's resolved attributes.
|
|
33
|
+
const TFSTATE = JSON.stringify({
|
|
34
|
+
version: 4,
|
|
35
|
+
terraform_version: "1.7.0",
|
|
36
|
+
resources: [
|
|
37
|
+
{
|
|
38
|
+
mode: "managed",
|
|
39
|
+
type: "aws_s3_bucket",
|
|
40
|
+
name: "assets",
|
|
41
|
+
provider: 'provider["registry.terraform.io/hashicorp/aws"]',
|
|
42
|
+
instances: [
|
|
43
|
+
{
|
|
44
|
+
attributes: {
|
|
45
|
+
id: "myapp-assets-prod",
|
|
46
|
+
bucket: "myapp-assets-prod",
|
|
47
|
+
arn: "arn:aws:s3:::myapp-assets-prod",
|
|
48
|
+
tags: { Team: "web", Env: "prod" },
|
|
49
|
+
force_destroy: false,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// The live-import fake must never be called on the state path.
|
|
58
|
+
const liveImport = vi.fn(
|
|
59
|
+
async (_p: LexiconPlugin[], _o: LiveImportOptions): Promise<ImportResult> => ({
|
|
60
|
+
success: true,
|
|
61
|
+
generatedFiles: [],
|
|
62
|
+
warnings: [],
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
async function withEstate<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
67
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-emit-state-"));
|
|
68
|
+
try {
|
|
69
|
+
writeFileSync(join(dir, "main.tf"), ESTATE);
|
|
70
|
+
writeFileSync(join(dir, "terraform.tfstate"), TFSTATE);
|
|
71
|
+
return await fn(dir);
|
|
72
|
+
} finally {
|
|
73
|
+
rmSync(dir, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe("carve emit --state (real adoption from tfstate)", () => {
|
|
78
|
+
test("emits native chant source from real state attributes, no cloud call", async () => {
|
|
79
|
+
if (!parserAvailable) return;
|
|
80
|
+
await withEstate(async (dir) => {
|
|
81
|
+
const out = join(dir, "carveout");
|
|
82
|
+
const res = await carveEmit(
|
|
83
|
+
{ from: dir, select: "aws_s3_bucket.assets", statePath: join(dir, "terraform.tfstate"), output: out },
|
|
84
|
+
{ plugins: [], liveImport },
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(res.ok).toBe(true);
|
|
88
|
+
expect(res.source).toBe("tfstate");
|
|
89
|
+
expect(liveImport).not.toHaveBeenCalled(); // offline — no cloud
|
|
90
|
+
|
|
91
|
+
// A real .ts file with the native constructor + props from real state.
|
|
92
|
+
expect(res.emittedFiles).toEqual([join(out, "assets.ts")]);
|
|
93
|
+
const emitted = readFileSync(res.emittedFiles![0], "utf-8");
|
|
94
|
+
expect(emitted).toContain("new Bucket({");
|
|
95
|
+
expect(emitted).toContain('BucketName: "myapp-assets-prod"');
|
|
96
|
+
expect(emitted).toContain("Adopted from Terraform state");
|
|
97
|
+
|
|
98
|
+
// Boundary still classified (the Lambda inbound).
|
|
99
|
+
expect(res.report!.inbound.map((e) => e.survivor)).toEqual(["aws_lambda_function.api"]);
|
|
100
|
+
|
|
101
|
+
const text = formatCarveEmit(res);
|
|
102
|
+
expect(text).toContain("Adopted from Terraform state (offline)");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("without --state or --env it explains both adoption sources", async () => {
|
|
107
|
+
if (!parserAvailable) return;
|
|
108
|
+
await withEstate(async (dir) => {
|
|
109
|
+
const res = await carveEmit({ from: dir, select: "aws_s3_bucket.assets" }, { plugins: [], liveImport });
|
|
110
|
+
expect(res.ok).toBe(false);
|
|
111
|
+
expect(res.error).toMatch(/--state .*or --env/);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { describe, test, expect, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { carveEmit, formatCarveEmit } from "./carve-emit";
|
|
6
|
+
import { loadHcl2json } from "../../terraform/parse";
|
|
7
|
+
import type { ImportResult, LiveImportOptions } from "./import";
|
|
8
|
+
import type { LexiconPlugin } from "../../lexicon";
|
|
9
|
+
|
|
10
|
+
let parserAvailable = false;
|
|
11
|
+
try {
|
|
12
|
+
await loadHcl2json();
|
|
13
|
+
parserAvailable = true;
|
|
14
|
+
} catch {
|
|
15
|
+
parserAvailable = false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ESTATE = `
|
|
19
|
+
resource "aws_s3_bucket" "assets" {
|
|
20
|
+
bucket = "myapp-assets-prod"
|
|
21
|
+
}
|
|
22
|
+
resource "aws_s3_bucket_versioning" "assets" {
|
|
23
|
+
bucket = aws_s3_bucket.assets.id
|
|
24
|
+
versioning_configuration { status = "Enabled" }
|
|
25
|
+
}
|
|
26
|
+
resource "aws_lambda_function" "api" {
|
|
27
|
+
function_name = "myapp-api"
|
|
28
|
+
environment {
|
|
29
|
+
variables = {
|
|
30
|
+
ASSETS_BUCKET = aws_s3_bucket.assets.bucket
|
|
31
|
+
ASSETS_ARN = aws_s3_bucket.assets.arn
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
resource "random_pet" "suffix" {
|
|
36
|
+
length = 2
|
|
37
|
+
}
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
async function withEstate<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
41
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-carve-emit-"));
|
|
42
|
+
try {
|
|
43
|
+
writeFileSync(join(dir, "main.tf"), ESTATE);
|
|
44
|
+
return await fn(dir);
|
|
45
|
+
} finally {
|
|
46
|
+
rmSync(dir, { recursive: true, force: true });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const fakeImport = (files: string[] = ["infra/assets.ts"]) =>
|
|
51
|
+
vi.fn(
|
|
52
|
+
async (_plugins: LexiconPlugin[], _options: LiveImportOptions): Promise<ImportResult> => ({
|
|
53
|
+
success: true,
|
|
54
|
+
generatedFiles: files,
|
|
55
|
+
warnings: [],
|
|
56
|
+
lexicon: "aws",
|
|
57
|
+
}),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const noPlugins: LexiconPlugin[] = [];
|
|
61
|
+
|
|
62
|
+
describe("carveEmit — validation", () => {
|
|
63
|
+
test("requires --from, --select, --env", async () => {
|
|
64
|
+
const li = fakeImport();
|
|
65
|
+
expect((await carveEmit({}, { plugins: noPlugins, liveImport: li })).error).toContain("--from");
|
|
66
|
+
expect((await carveEmit({ from: "/x" }, { plugins: noPlugins, liveImport: li })).error).toContain("--select");
|
|
67
|
+
expect(li).not.toHaveBeenCalled();
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("carveEmit — emit + boundary", () => {
|
|
72
|
+
test("adopts the selected resource with a native selector and reports the boundary", async () => {
|
|
73
|
+
if (!parserAvailable) return;
|
|
74
|
+
await withEstate(async (dir) => {
|
|
75
|
+
const li = fakeImport(["infra/assets.ts"]);
|
|
76
|
+
const reportFile = join(dir, "carveout-report.json");
|
|
77
|
+
const res = await carveEmit(
|
|
78
|
+
{ from: dir, select: "aws_s3_bucket.assets", env: "prod", reportFile },
|
|
79
|
+
{ plugins: noPlugins, liveImport: li },
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
expect(res.ok).toBe(true);
|
|
83
|
+
// Live selector is by native type: the CFN import filters by logical id,
|
|
84
|
+
// which is not the Terraform physical name, so name is not passed here.
|
|
85
|
+
expect(res.selector).toEqual({ type: "AWS::S3::Bucket" });
|
|
86
|
+
expect(li).toHaveBeenCalledOnce();
|
|
87
|
+
expect(li.mock.calls[0][1]).toMatchObject({
|
|
88
|
+
environment: "prod",
|
|
89
|
+
selector: { type: "AWS::S3::Bucket" },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Boundary: one inbound edge from the Lambda; versioning folded away.
|
|
93
|
+
expect(res.report!.inbound).toHaveLength(1);
|
|
94
|
+
expect(res.report!.inbound[0].survivor).toBe("aws_lambda_function.api");
|
|
95
|
+
expect(res.report!.outbound).toHaveLength(0);
|
|
96
|
+
|
|
97
|
+
// Report written to disk.
|
|
98
|
+
const written = JSON.parse(readFileSync(reportFile, "utf-8"));
|
|
99
|
+
expect(written.target).toBe("aws_s3_bucket.assets");
|
|
100
|
+
expect(written.reversible).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("refuses to emit an unsupported type (no native mapping)", async () => {
|
|
105
|
+
if (!parserAvailable) return;
|
|
106
|
+
await withEstate(async (dir) => {
|
|
107
|
+
const li = fakeImport();
|
|
108
|
+
const res = await carveEmit(
|
|
109
|
+
{ from: dir, select: "random_pet.suffix", env: "prod" },
|
|
110
|
+
{ plugins: noPlugins, liveImport: li },
|
|
111
|
+
);
|
|
112
|
+
expect(res.ok).toBe(false);
|
|
113
|
+
expect(res.error).toMatch(/no known native mapping/i);
|
|
114
|
+
expect(li).not.toHaveBeenCalled(); // never adopts something it can't map
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("errors when the address is not in the estate", async () => {
|
|
119
|
+
if (!parserAvailable) return;
|
|
120
|
+
await withEstate(async (dir) => {
|
|
121
|
+
const res = await carveEmit(
|
|
122
|
+
{ from: dir, select: "aws_s3_bucket.nope", env: "prod" },
|
|
123
|
+
{ plugins: noPlugins, liveImport: fakeImport() },
|
|
124
|
+
);
|
|
125
|
+
expect(res.ok).toBe(false);
|
|
126
|
+
expect(res.error).toContain("not found");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("formatCarveEmit describes the adoption and the pending boundary work", async () => {
|
|
131
|
+
if (!parserAvailable) return;
|
|
132
|
+
await withEstate(async (dir) => {
|
|
133
|
+
const res = await carveEmit(
|
|
134
|
+
{ from: dir, select: "aws_s3_bucket.assets", env: "prod" },
|
|
135
|
+
{ plugins: noPlugins, liveImport: fakeImport(["infra/assets.ts"]) },
|
|
136
|
+
);
|
|
137
|
+
const text = formatCarveEmit(res);
|
|
138
|
+
expect(text).toContain("observe position, reversible");
|
|
139
|
+
expect(text).toContain("Adopted live as AWS::S3::Bucket");
|
|
140
|
+
expect(text).toContain("inbound");
|
|
141
|
+
expect(text).toContain("carve bridge"); // points at the next step
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("--live-name narrows the live selector to a CFN logical id", async () => {
|
|
146
|
+
if (!parserAvailable) return;
|
|
147
|
+
await withEstate(async (dir) => {
|
|
148
|
+
const li = fakeImport(["infra/assets.ts"]);
|
|
149
|
+
const res = await carveEmit(
|
|
150
|
+
{ from: dir, select: "aws_s3_bucket.assets", env: "prod", liveName: "AssetsBucket" },
|
|
151
|
+
{ plugins: noPlugins, liveImport: li },
|
|
152
|
+
);
|
|
153
|
+
expect(res.ok).toBe(true);
|
|
154
|
+
expect(res.selector).toEqual({ type: "AWS::S3::Bucket", name: "AssetsBucket" });
|
|
155
|
+
expect(formatCarveEmit(res)).toContain('logical id "AssetsBucket"');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant carve emit` — the first strangler-fig emit step (#197).
|
|
3
|
+
*
|
|
4
|
+
* Given a resource selected from a Terraform estate, it:
|
|
5
|
+
* 1. classifies the boundary (which edges the carve cuts),
|
|
6
|
+
* 2. adopts the live resource into typed chant source via live import
|
|
7
|
+
* (`chant import --from <env>` machinery — cloud→code, read-only export),
|
|
8
|
+
* 3. writes a boundary report.
|
|
9
|
+
*
|
|
10
|
+
* It does not patch the surviving Terraform or apply anything — that is the
|
|
11
|
+
* boundary-bridging and apply-graduation work (later PRs). This lands the
|
|
12
|
+
* resource at the observe position: emitted, reversible, nothing mutated.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, statSync, writeFileSync, mkdirSync } from "fs";
|
|
16
|
+
import { join } from "path";
|
|
17
|
+
import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
|
|
18
|
+
import { boundaryReport, type CarveReport } from "../../terraform/carve";
|
|
19
|
+
import { resolveTier } from "../../terraform/tier-map";
|
|
20
|
+
import { readStateResource } from "../../terraform/state";
|
|
21
|
+
import { adoptFromState, canAdoptFromState, supportedStateAdoptionTypes } from "../../terraform/adopt-state";
|
|
22
|
+
import type { LexiconPlugin, ResourceSelector } from "../../lexicon";
|
|
23
|
+
import type { ImportResult, LiveImportOptions } from "./import";
|
|
24
|
+
|
|
25
|
+
export interface CarveEmitOptions {
|
|
26
|
+
/** Terraform estate directory (`--from`). */
|
|
27
|
+
from?: string;
|
|
28
|
+
/** Terraform address to carve, e.g. `aws_s3_bucket.assets` (`--select`). */
|
|
29
|
+
select?: string;
|
|
30
|
+
/** Live environment to adopt from (`--env`). */
|
|
31
|
+
env?: string;
|
|
32
|
+
/**
|
|
33
|
+
* CloudFormation logical ID to adopt on the live path (`--live-name`). The
|
|
34
|
+
* live import filters a stack's template by logical ID, which is not the
|
|
35
|
+
* Terraform physical name — so the live selector is by native type by
|
|
36
|
+
* default, and this narrows it when a stack has several of that type.
|
|
37
|
+
*/
|
|
38
|
+
liveName?: string;
|
|
39
|
+
/** Opt-in `.tfstate` for instance counts (`--state`). */
|
|
40
|
+
statePath?: string;
|
|
41
|
+
/** Output directory for the emitted source (`--output`). */
|
|
42
|
+
output?: string;
|
|
43
|
+
/** Write the boundary report JSON here (`--report`). */
|
|
44
|
+
reportFile?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Injected so tests exercise emit without cloud calls. */
|
|
48
|
+
export interface CarveEmitDeps {
|
|
49
|
+
plugins: LexiconPlugin[];
|
|
50
|
+
liveImport: (plugins: LexiconPlugin[], options: LiveImportOptions) => Promise<ImportResult>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CarveEmitResult {
|
|
54
|
+
ok: boolean;
|
|
55
|
+
error?: string;
|
|
56
|
+
report?: CarveReport;
|
|
57
|
+
emit?: ImportResult;
|
|
58
|
+
/** The selector used for the live adoption, for transparency (cloud path). */
|
|
59
|
+
selector?: ResourceSelector;
|
|
60
|
+
/** How the resource was adopted. */
|
|
61
|
+
source?: "tfstate" | "live";
|
|
62
|
+
/** Emitted file path(s). */
|
|
63
|
+
emittedFiles?: string[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Promise<CarveEmitResult> {
|
|
67
|
+
if (!opts.from) return { ok: false, error: "chant carve emit requires --from <terraform-dir>" };
|
|
68
|
+
if (!opts.select) return { ok: false, error: "chant carve emit requires --select <tf-address>" };
|
|
69
|
+
// Adoption source: --state adopts offline from tfstate (correct for a
|
|
70
|
+
// Terraform-managed resource); --env adopts via the live cloud import path.
|
|
71
|
+
if (!opts.statePath && !opts.env) {
|
|
72
|
+
return { ok: false, error: "chant carve emit requires --state <tfstate> (offline, recommended) or --env <environment>" };
|
|
73
|
+
}
|
|
74
|
+
if (!existsSync(opts.from) || !statSync(opts.from).isDirectory()) {
|
|
75
|
+
return { ok: false, error: `Not a directory: ${opts.from}` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
let report: CarveReport | null;
|
|
79
|
+
let tfType: string | undefined;
|
|
80
|
+
try {
|
|
81
|
+
const graph = await parseTerraformDir(opts.from, { statePath: opts.statePath });
|
|
82
|
+
report = boundaryReport(graph, opts.select);
|
|
83
|
+
tfType = graph.nodes.find((n) => n.address === opts.select)?.type;
|
|
84
|
+
} catch (err) {
|
|
85
|
+
if (err instanceof Hcl2JsonNotInstalled) return { ok: false, error: err.message };
|
|
86
|
+
return { ok: false, error: `Failed to parse Terraform in ${opts.from}: ${err instanceof Error ? err.message : String(err)}` };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!report) return { ok: false, error: `${opts.select} not found in ${opts.from}` };
|
|
90
|
+
|
|
91
|
+
const tier = tfType ? resolveTier(tfType) : null;
|
|
92
|
+
if (!tier) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
error: `${opts.select} (${tfType ?? "unknown type"}) has no known native mapping, so it cannot be emitted. Advisor bands it "leave in Terraform".`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (opts.reportFile) writeFileSync(opts.reportFile, JSON.stringify(report, null, 2));
|
|
100
|
+
|
|
101
|
+
// ── Adoption path 1: from .tfstate (offline, correct for TF-managed) ──
|
|
102
|
+
if (opts.statePath) {
|
|
103
|
+
if (!canAdoptFromState(tfType!)) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
error:
|
|
107
|
+
`${tfType} cannot be adopted from state yet (no native constructor mapping).\n` +
|
|
108
|
+
`Supported types: ${supportedStateAdoptionTypes().join(", ")}. ` +
|
|
109
|
+
`Coverage is expanding — see chant issue #1001.`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const stateResource = readStateResource(opts.statePath, opts.select);
|
|
113
|
+
if (!stateResource) {
|
|
114
|
+
return { ok: false, error: `${opts.select} not found in state ${opts.statePath} (or is a data source / module-nested).` };
|
|
115
|
+
}
|
|
116
|
+
const adopted = adoptFromState(stateResource);
|
|
117
|
+
if (!adopted) return { ok: false, error: `Could not adopt ${opts.select} from state.` };
|
|
118
|
+
|
|
119
|
+
const outDir = opts.output ?? join(opts.from, "carveout");
|
|
120
|
+
mkdirSync(outDir, { recursive: true });
|
|
121
|
+
const outPath = join(outDir, adopted.fileName);
|
|
122
|
+
writeFileSync(outPath, adopted.content);
|
|
123
|
+
|
|
124
|
+
return { ok: true, report, source: "tfstate", emittedFiles: [outPath] };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Adoption path 2: live import (cloud→code) ──
|
|
128
|
+
// The live import filters a CloudFormation stack's template by logical ID, not
|
|
129
|
+
// the Terraform physical name — so select by native type, narrowing to a
|
|
130
|
+
// logical ID only when the caller passes --live-name (a stack with several of
|
|
131
|
+
// that type). `identity` (the TF physical name) is not a valid CFN selector.
|
|
132
|
+
const selector: ResourceSelector = opts.liveName
|
|
133
|
+
? { type: tier.mapsTo, name: opts.liveName }
|
|
134
|
+
: { type: tier.mapsTo };
|
|
135
|
+
|
|
136
|
+
const emit = await deps.liveImport(deps.plugins, {
|
|
137
|
+
environment: opts.env!,
|
|
138
|
+
selector,
|
|
139
|
+
output: opts.output,
|
|
140
|
+
lexicon: tier.mapsTo.split("::")[0]?.toLowerCase() === "aws" ? "aws" : undefined,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
return { ok: true, report, emit, selector, source: "live", emittedFiles: emit.generatedFiles };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Human-readable emit summary: what was adopted and what boundary work remains. */
|
|
147
|
+
export function formatCarveEmit(result: CarveEmitResult): string {
|
|
148
|
+
if (!result.ok || !result.report) return result.error ?? "carve emit failed";
|
|
149
|
+
const r = result.report;
|
|
150
|
+
const lines: string[] = [];
|
|
151
|
+
lines.push(`Carved ${r.target} (peelability ${r.peelability}) — observe position, reversible.`);
|
|
152
|
+
if (result.source === "tfstate") {
|
|
153
|
+
lines.push(" Adopted from Terraform state (offline).");
|
|
154
|
+
} else if (result.selector) {
|
|
155
|
+
const named = result.selector.name ? ` (logical id "${result.selector.name}")` : "";
|
|
156
|
+
lines.push(` Adopted live as ${result.selector.type}${named}.`);
|
|
157
|
+
}
|
|
158
|
+
if (result.emittedFiles?.length) {
|
|
159
|
+
lines.push(` Emitted: ${result.emittedFiles.join(", ")}`);
|
|
160
|
+
}
|
|
161
|
+
if (r.carveSet.length > 1) {
|
|
162
|
+
const folded = r.carveSet.filter((m) => m.foldedInto).map((m) => m.address);
|
|
163
|
+
lines.push(` Folded in: ${folded.join(", ")}`);
|
|
164
|
+
}
|
|
165
|
+
lines.push("");
|
|
166
|
+
lines.push("Boundary (not yet patched — that is `carve bridge`, the next step):");
|
|
167
|
+
if (r.inbound.length === 0 && r.outbound.length === 0) {
|
|
168
|
+
lines.push(" none — a clean leaf, nothing depends on it and it depends on nothing.");
|
|
169
|
+
}
|
|
170
|
+
for (const e of r.inbound) {
|
|
171
|
+
lines.push(` inbound ${e.survivor} reads ${e.attrs.join(", ")} → rewrite to a data source (required immediately)`);
|
|
172
|
+
}
|
|
173
|
+
for (const e of r.outbound) {
|
|
174
|
+
lines.push(` outbound ${e.carved} reads ${e.survivor}.${e.attrs.join(", ")} → deferred deploy-time input (at apply)`);
|
|
175
|
+
}
|
|
176
|
+
if (r.diagnostics.length) {
|
|
177
|
+
lines.push("");
|
|
178
|
+
for (const d of r.diagnostics) lines.push(` ! ${d}`);
|
|
179
|
+
}
|
|
180
|
+
return lines.join("\n");
|
|
181
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { carveAdvise, carveJson, formatCarveReport } from "./carve";
|
|
6
|
+
import { loadHcl2json } from "../../terraform/parse";
|
|
7
|
+
|
|
8
|
+
let parserAvailable = false;
|
|
9
|
+
try {
|
|
10
|
+
await loadHcl2json();
|
|
11
|
+
parserAvailable = true;
|
|
12
|
+
} catch {
|
|
13
|
+
parserAvailable = false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const ESTATE = `
|
|
17
|
+
resource "aws_s3_bucket" "assets" {
|
|
18
|
+
bucket = "myapp-assets-prod"
|
|
19
|
+
}
|
|
20
|
+
resource "aws_s3_bucket_versioning" "assets" {
|
|
21
|
+
bucket = aws_s3_bucket.assets.id
|
|
22
|
+
versioning_configuration { status = "Enabled" }
|
|
23
|
+
}
|
|
24
|
+
resource "aws_lambda_function" "api" {
|
|
25
|
+
environment {
|
|
26
|
+
variables = {
|
|
27
|
+
ASSETS_BUCKET = aws_s3_bucket.assets.bucket
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
resource "random_pet" "name" {
|
|
32
|
+
length = 2
|
|
33
|
+
}
|
|
34
|
+
`;
|
|
35
|
+
|
|
36
|
+
async function withEstate<T>(fn: (dir: string) => Promise<T>): Promise<T> {
|
|
37
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-carve-"));
|
|
38
|
+
try {
|
|
39
|
+
writeFileSync(join(dir, "main.tf"), ESTATE);
|
|
40
|
+
return await fn(dir);
|
|
41
|
+
} finally {
|
|
42
|
+
rmSync(dir, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("carveAdvise", () => {
|
|
47
|
+
test("requires --from", async () => {
|
|
48
|
+
const r = await carveAdvise({});
|
|
49
|
+
expect(r.ok).toBe(false);
|
|
50
|
+
expect(r.error).toContain("--from");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("errors on a non-directory", async () => {
|
|
54
|
+
const r = await carveAdvise({ from: join(tmpdir(), "definitely-not-here-xyz") });
|
|
55
|
+
expect(r.ok).toBe(false);
|
|
56
|
+
expect(r.error).toContain("Not a directory");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("scores a real estate, ranks it, and writes a report (real wasm)", async () => {
|
|
60
|
+
if (!parserAvailable) return;
|
|
61
|
+
await withEstate(async (dir) => {
|
|
62
|
+
const reportFile = join(dir, "report.json");
|
|
63
|
+
const r = await carveAdvise({ from: dir, reportFile });
|
|
64
|
+
expect(r.ok).toBe(true);
|
|
65
|
+
|
|
66
|
+
const addrs = (r.results ?? []).map((x) => x.address);
|
|
67
|
+
// versioning is folded into the bucket, so it is not ranked on its own.
|
|
68
|
+
expect(addrs).not.toContain("aws_s3_bucket_versioning.assets");
|
|
69
|
+
expect(addrs).toContain("aws_s3_bucket.assets");
|
|
70
|
+
expect(addrs).toContain("random_pet.name");
|
|
71
|
+
|
|
72
|
+
const bucket = r.results!.find((x) => x.address === "aws_s3_bucket.assets")!;
|
|
73
|
+
expect(bucket.score).toBe(88);
|
|
74
|
+
expect(bucket.band).toBe("clean leaf");
|
|
75
|
+
|
|
76
|
+
const petunia = r.results!.find((x) => x.address === "random_pet.name")!;
|
|
77
|
+
expect(petunia.score).toBe(0); // unsupported provider
|
|
78
|
+
|
|
79
|
+
// report file is valid JSON with the advisory banner + band counts
|
|
80
|
+
const payload = JSON.parse(readFileSync(reportFile, "utf-8"));
|
|
81
|
+
expect(payload.advisory).toContain("read-only");
|
|
82
|
+
expect(payload.bands["clean leaf"]).toBeGreaterThanOrEqual(1);
|
|
83
|
+
expect(payload.count).toBe(r.results!.length);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("formatCarveReport groups by band and never suggests a mutation", async () => {
|
|
88
|
+
if (!parserAvailable) return;
|
|
89
|
+
await withEstate(async (dir) => {
|
|
90
|
+
const r = await carveAdvise({ from: dir });
|
|
91
|
+
const text = formatCarveReport(r);
|
|
92
|
+
expect(text).toContain("CLEAN LEAF");
|
|
93
|
+
expect(text).toContain("LEAVE IN TERRAFORM");
|
|
94
|
+
expect(text).toContain("Advises only");
|
|
95
|
+
expect(text).not.toMatch(/terraform state rm|apply|destroy/i);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("carveJson carries the read-only advisory banner", () => {
|
|
100
|
+
const payload = carveJson({ ok: true, from: "x", results: [] }) as { advisory: string; count: number };
|
|
101
|
+
expect(payload.advisory).toContain("read-only");
|
|
102
|
+
expect(payload.count).toBe(0);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant carve advise` — the read-only Terraform peelability advisor (#214).
|
|
3
|
+
*
|
|
4
|
+
* Points at a Terraform estate and reports which resources/modules are cheap to
|
|
5
|
+
* carve into native chant later and which should stay in Terraform. It emits
|
|
6
|
+
* nothing, patches nothing, and touches no live resource — pure analysis. The
|
|
7
|
+
* emit/boundary/apply phases stay in #197, gated on demand.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, statSync, writeFileSync } from "fs";
|
|
11
|
+
import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
|
|
12
|
+
import { scoreEstate, type Peelability, type PeelabilityBand } from "../../terraform/score";
|
|
13
|
+
|
|
14
|
+
export interface CarveAdviseOptions {
|
|
15
|
+
/** Terraform estate directory (from `--from`). */
|
|
16
|
+
from?: string;
|
|
17
|
+
/** Opt-in `.tfstate` path (from `--state`): accurate fan-out instance counts. */
|
|
18
|
+
statePath?: string;
|
|
19
|
+
/** Write the full JSON report to this path (from `--report <path>`). */
|
|
20
|
+
reportFile?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CarveAdviseResult {
|
|
24
|
+
ok: boolean;
|
|
25
|
+
error?: string;
|
|
26
|
+
from?: string;
|
|
27
|
+
results?: Peelability[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const BAND_ORDER: PeelabilityBand[] = ["clean leaf", "carvable w/ edits", "leave in Terraform"];
|
|
31
|
+
|
|
32
|
+
export async function carveAdvise(opts: CarveAdviseOptions): Promise<CarveAdviseResult> {
|
|
33
|
+
if (!opts.from) {
|
|
34
|
+
return { ok: false, error: "chant carve advise requires --from <terraform-dir>" };
|
|
35
|
+
}
|
|
36
|
+
if (!existsSync(opts.from) || !statSync(opts.from).isDirectory()) {
|
|
37
|
+
return { ok: false, error: `Not a directory: ${opts.from}` };
|
|
38
|
+
}
|
|
39
|
+
if (opts.statePath && !existsSync(opts.statePath)) {
|
|
40
|
+
return { ok: false, error: `State file not found: ${opts.statePath}` };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let results: Peelability[];
|
|
44
|
+
try {
|
|
45
|
+
results = scoreEstate(await parseTerraformDir(opts.from, { statePath: opts.statePath }));
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err instanceof Hcl2JsonNotInstalled) return { ok: false, error: err.message };
|
|
48
|
+
return { ok: false, error: `Failed to parse Terraform in ${opts.from}: ${err instanceof Error ? err.message : String(err)}` };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (opts.reportFile) {
|
|
52
|
+
writeFileSync(opts.reportFile, JSON.stringify(carveJson({ ok: true, from: opts.from, results }), null, 2));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return { ok: true, from: opts.from, results };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The `--json` / `--report` payload. */
|
|
59
|
+
export function carveJson(result: CarveAdviseResult): unknown {
|
|
60
|
+
const results = result.results ?? [];
|
|
61
|
+
const counts = Object.fromEntries(
|
|
62
|
+
BAND_ORDER.map((b) => [b, results.filter((r) => r.band === b).length]),
|
|
63
|
+
);
|
|
64
|
+
return {
|
|
65
|
+
from: result.from,
|
|
66
|
+
advisory: "read-only — emits nothing, patches nothing, touches no live resource",
|
|
67
|
+
count: results.length,
|
|
68
|
+
bands: counts,
|
|
69
|
+
resources: results,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Human-readable banded, ranked summary. */
|
|
74
|
+
export function formatCarveReport(result: CarveAdviseResult): string {
|
|
75
|
+
const results = result.results ?? [];
|
|
76
|
+
if (results.length === 0) {
|
|
77
|
+
return `No carvable resources found in ${result.from}\n(only data sources, providers, or unsupported types were present).`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const lines: string[] = [];
|
|
81
|
+
lines.push(`Terraform carve-out advisory for ${result.from}`);
|
|
82
|
+
lines.push(` ${results.length} resource(s)/module(s) scored. Advises only — nothing is emitted or changed.`);
|
|
83
|
+
lines.push("");
|
|
84
|
+
|
|
85
|
+
for (const band of BAND_ORDER) {
|
|
86
|
+
const inBand = results.filter((r) => r.band === band);
|
|
87
|
+
if (inBand.length === 0) continue;
|
|
88
|
+
lines.push(`${bandLabel(band)} (${inBand.length})`);
|
|
89
|
+
for (const r of inBand) {
|
|
90
|
+
const target = r.mapsTo ? ` -> ${r.mapsTo}` : "";
|
|
91
|
+
lines.push(` ${String(r.score).padStart(3)} ${r.address}${target}`);
|
|
92
|
+
lines.push(` ${reasons(r)}`);
|
|
93
|
+
}
|
|
94
|
+
lines.push("");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
lines.push("Bands: 80-100 carve now | 50-79 carve with boundary edits | 0-49 leave in Terraform");
|
|
98
|
+
return lines.join("\n").trimEnd();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function bandLabel(band: PeelabilityBand): string {
|
|
102
|
+
switch (band) {
|
|
103
|
+
case "clean leaf":
|
|
104
|
+
return "CLEAN LEAF — carve now";
|
|
105
|
+
case "carvable w/ edits":
|
|
106
|
+
return "CARVABLE — has boundary work";
|
|
107
|
+
case "leave in Terraform":
|
|
108
|
+
return "LEAVE IN TERRAFORM";
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** One-line explanation of what drove a score, from its breakdown. */
|
|
113
|
+
function reasons(r: Peelability): string {
|
|
114
|
+
const b = r.breakdown;
|
|
115
|
+
if (b.tier === null) return "no known native mapping (unsupported provider/type)";
|
|
116
|
+
const parts: string[] = [];
|
|
117
|
+
if (b.inbound) parts.push(`${b.inbound} inbound (data-source patch each)`);
|
|
118
|
+
if (b.outbound) parts.push(`${b.outbound} outbound (deferred input each)`);
|
|
119
|
+
if (b.tier > 1) parts.push(`tier ${b.tier} map`);
|
|
120
|
+
if (b.hasDynamic) parts.push("count/for_each/data present");
|
|
121
|
+
if (b.instances > 1) parts.push(`${b.instances} instances`);
|
|
122
|
+
return parts.length ? parts.join(", ") : "clean 1:1 native map, no boundary edges";
|
|
123
|
+
}
|