@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,34 @@
|
|
|
1
|
+
import { carveApply, formatCarveApply } from "../commands/carve-apply";
|
|
2
|
+
import { formatError } from "../format";
|
|
3
|
+
import type { CommandContext } from "../registry";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `chant carve apply --from <tf-dir> --select <address> --env <env> [--stack <name>] [--write]`
|
|
7
|
+
*
|
|
8
|
+
* Resolves the ownership marker + graduation runbook. BYOL-honest: no cloud
|
|
9
|
+
* calls. `--write` saves the graduation doc. No lexicon plugins needed.
|
|
10
|
+
*/
|
|
11
|
+
export async function runCarveApply(ctx: CommandContext): Promise<number> {
|
|
12
|
+
const { args } = ctx;
|
|
13
|
+
|
|
14
|
+
const result = await carveApply({
|
|
15
|
+
from: args.migrateFrom, // `--from <terraform-dir>`
|
|
16
|
+
select: args.selectAddress, // `--select <tf-address>`
|
|
17
|
+
env: args.env,
|
|
18
|
+
stack: args.carveStack,
|
|
19
|
+
statePath: args.statePath,
|
|
20
|
+
output: args.output,
|
|
21
|
+
write: args.write,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (!result.ok) {
|
|
25
|
+
console.error(formatError({
|
|
26
|
+
message: result.error ?? "carve apply failed",
|
|
27
|
+
hint: "chant carve apply --from ./tf --select aws_s3_bucket.assets --env prod",
|
|
28
|
+
}));
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
console.log(formatCarveApply(result));
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { carveBridge, formatCarveBridge } from "../commands/carve-bridge";
|
|
2
|
+
import { formatError } from "../format";
|
|
3
|
+
import type { CommandContext } from "../registry";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `chant carve bridge --from <tf-dir> --select <address> [--output <dir>] [--apply-rewrites]`
|
|
7
|
+
*
|
|
8
|
+
* Read-only by default: writes proposed survivor patches + a runbook to an
|
|
9
|
+
* output dir. `--apply-rewrites` edits the surviving `.tf` in place. Loads no
|
|
10
|
+
* lexicon plugins — pure Terraform-side analysis.
|
|
11
|
+
*/
|
|
12
|
+
export async function runCarveBridge(ctx: CommandContext): Promise<number> {
|
|
13
|
+
const { args } = ctx;
|
|
14
|
+
|
|
15
|
+
const result = await carveBridge({
|
|
16
|
+
from: args.migrateFrom, // `--from <terraform-dir>`
|
|
17
|
+
select: args.selectAddress, // `--select <tf-address>`
|
|
18
|
+
statePath: args.statePath,
|
|
19
|
+
output: args.output,
|
|
20
|
+
applyRewrites: args.applyRewrites,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (!result.ok) {
|
|
24
|
+
console.error(formatError({
|
|
25
|
+
message: result.error ?? "carve bridge failed",
|
|
26
|
+
hint: "chant carve bridge --from ./tf --select aws_s3_bucket.assets",
|
|
27
|
+
}));
|
|
28
|
+
return 1;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.log(formatCarveBridge(result));
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { carveEmit, formatCarveEmit } from "../commands/carve-emit";
|
|
2
|
+
import { liveImportFromPlugins } from "../commands/import";
|
|
3
|
+
import { loadPlugins } from "../plugins";
|
|
4
|
+
import { formatError } from "../format";
|
|
5
|
+
import type { CommandContext } from "../registry";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `chant carve emit --from <tf-dir> --select <address> (--state <tfstate> | --env <env>)`
|
|
9
|
+
*
|
|
10
|
+
* Adopts the selected Terraform resource into typed chant source. `--state`
|
|
11
|
+
* adopts offline from the tfstate (no plugins); `--env` adopts via the live
|
|
12
|
+
* cloud import path, for which the target lexicon is loaded lazily here — so a
|
|
13
|
+
* state-only carve never pays the plugin-load cost.
|
|
14
|
+
*/
|
|
15
|
+
export async function runCarveEmit(ctx: CommandContext): Promise<number> {
|
|
16
|
+
const { args } = ctx;
|
|
17
|
+
|
|
18
|
+
// Load plugins only for the live path; the offline --state path needs none.
|
|
19
|
+
let plugins = ctx.plugins;
|
|
20
|
+
if (!args.statePath && args.env) {
|
|
21
|
+
try {
|
|
22
|
+
plugins = await loadPlugins(["aws"]);
|
|
23
|
+
} catch {
|
|
24
|
+
// fall through with whatever ctx provided; carveEmit surfaces a clear error
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const result = await carveEmit(
|
|
29
|
+
{
|
|
30
|
+
from: args.migrateFrom, // `--from <terraform-dir>`
|
|
31
|
+
select: args.selectAddress, // `--select <tf-address>`
|
|
32
|
+
env: args.env,
|
|
33
|
+
liveName: args.liveName, // `--live-name <cfn-logical-id>`
|
|
34
|
+
statePath: args.statePath,
|
|
35
|
+
output: args.output,
|
|
36
|
+
reportFile: args.reportFile,
|
|
37
|
+
},
|
|
38
|
+
{ plugins, liveImport: liveImportFromPlugins },
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
if (!result.ok) {
|
|
42
|
+
console.error(formatError({
|
|
43
|
+
message: result.error ?? "carve emit failed",
|
|
44
|
+
hint: "chant carve emit --from ./tf --select aws_s3_bucket.assets --env prod",
|
|
45
|
+
}));
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log(formatCarveEmit(result));
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { carveAdvise, carveJson, formatCarveReport } from "../commands/carve";
|
|
2
|
+
import { formatError } from "../format";
|
|
3
|
+
import type { CommandContext } from "../registry";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `chant carve advise --from <terraform-dir> [--json] [--report <path>]`
|
|
7
|
+
*
|
|
8
|
+
* Read-only. Reuses `--from` (shared with migrate/import) for the estate dir.
|
|
9
|
+
* Loads no chant project and no lexicon plugins — pure analysis of a foreign
|
|
10
|
+
* Terraform tree.
|
|
11
|
+
*/
|
|
12
|
+
export async function runCarveAdvise(ctx: CommandContext): Promise<number> {
|
|
13
|
+
const { args } = ctx;
|
|
14
|
+
|
|
15
|
+
const result = await carveAdvise({
|
|
16
|
+
from: args.migrateFrom, // `--from <terraform-dir>`
|
|
17
|
+
statePath: args.statePath, // `--state <tfstate>`
|
|
18
|
+
reportFile: args.reportFile, // `--report <path>`
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
if (!result.ok) {
|
|
22
|
+
console.error(formatError({
|
|
23
|
+
message: result.error ?? "carve advise failed",
|
|
24
|
+
hint: "chant carve advise --from ./path/to/terraform",
|
|
25
|
+
}));
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (args.json) {
|
|
30
|
+
console.log(JSON.stringify(carveJson(result), null, 2));
|
|
31
|
+
} else {
|
|
32
|
+
console.log(formatCarveReport(result));
|
|
33
|
+
}
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Fallback for `chant carve` with no/unknown subcommand. */
|
|
38
|
+
export async function runCarveUnknown(_ctx: CommandContext): Promise<number> {
|
|
39
|
+
console.error(formatError({
|
|
40
|
+
message: "Unknown carve subcommand",
|
|
41
|
+
hint: "chant carve advise --from <terraform-dir> (read-only peelability report)",
|
|
42
|
+
}));
|
|
43
|
+
return 1;
|
|
44
|
+
}
|
package/src/cli/main.ts
CHANGED
|
@@ -16,6 +16,10 @@ import { runInit, runInitLexicon } from "./handlers/init";
|
|
|
16
16
|
import { runList, runDescribe, runImport, runAudit, runUpdate, runDoctor } from "./handlers/misc";
|
|
17
17
|
import { runVendor } from "./handlers/vendor";
|
|
18
18
|
import { runMigrate } from "./handlers/migrate";
|
|
19
|
+
import { runCarveAdvise, runCarveUnknown } from "./handlers/carve";
|
|
20
|
+
import { runCarveEmit } from "./handlers/carve-emit";
|
|
21
|
+
import { runCarveBridge } from "./handlers/carve-bridge";
|
|
22
|
+
import { runCarveApply } from "./handlers/carve-apply";
|
|
19
23
|
import { runLifecycleSnapshot, runLifecycleShow, runLifecycleDiff, runLifecycleRollback, runLifecyclePlan, runLifecycleAffected, runLifecycleLog, runLifecycleUnknown } from "./handlers/lifecycle";
|
|
20
24
|
import { runComponentsStatus, runComponentsReleaseRecord, runComponentsUnknown } from "./handlers/components";
|
|
21
25
|
import { runGraph } from "./handlers/graph";
|
|
@@ -116,6 +120,18 @@ export function parseArgs(args: string[]): ParsedArgs {
|
|
|
116
120
|
result.owned = true;
|
|
117
121
|
} else if (arg === "--verbatim") {
|
|
118
122
|
result.verbatim = true;
|
|
123
|
+
} else if (arg === "--state") {
|
|
124
|
+
result.statePath = args[++i];
|
|
125
|
+
} else if (arg === "--select") {
|
|
126
|
+
result.selectAddress = args[++i];
|
|
127
|
+
} else if (arg === "--live-name") {
|
|
128
|
+
result.liveName = args[++i];
|
|
129
|
+
} else if (arg === "--apply-rewrites") {
|
|
130
|
+
result.applyRewrites = true;
|
|
131
|
+
} else if (arg === "--stack") {
|
|
132
|
+
result.carveStack = args[++i];
|
|
133
|
+
} else if (arg === "--write") {
|
|
134
|
+
result.write = true;
|
|
119
135
|
} else if (arg === "--to") {
|
|
120
136
|
result.migrateTo = args[++i];
|
|
121
137
|
} else if (arg === "--emit") {
|
|
@@ -245,6 +261,23 @@ Commands:
|
|
|
245
261
|
--template <file> / --theme <file> for the html report)
|
|
246
262
|
migrate <file> Translate a workflow between lexicons
|
|
247
263
|
(default: --from github --to gitlab)
|
|
264
|
+
carve advise Read-only Terraform peelability advisor: rank which
|
|
265
|
+
--from <tf-dir> resources are cheap to carve into native chant
|
|
266
|
+
(--json, --report <path>). Emits nothing, changes nothing.
|
|
267
|
+
Needs @cdktf/hcl2json (npm install -D @cdktf/hcl2json).
|
|
268
|
+
carve emit Adopt a selected TF resource into chant source + report
|
|
269
|
+
--from <tf-dir> its boundary. --state <tfstate> adopts offline
|
|
270
|
+
--select <addr> (recommended for TF-managed resources); --env
|
|
271
|
+
--state|--env <env> adopts via live cloud import (--live-name
|
|
272
|
+
<logical-id> narrows a multi-resource stack).
|
|
273
|
+
carve bridge Generate the surviving-TF patch (data sources + rewired
|
|
274
|
+
--from <tf-dir> refs) + deferred inputs + reversible runbook.
|
|
275
|
+
--select <addr> Writes proposals for review; --apply-rewrites
|
|
276
|
+
edits the .tf in place.
|
|
277
|
+
carve apply Apply graduation: ownership marker + finalized apply
|
|
278
|
+
--from <tf-dir> runbook (dial-turn observe→apply). BYOL —
|
|
279
|
+
--select <addr> no cloud call; --write saves the doc.
|
|
280
|
+
--env <env>
|
|
248
281
|
|
|
249
282
|
Ops:
|
|
250
283
|
run <name> Start an Op workflow (spawns worker + submits to Temporal)
|
|
@@ -428,6 +461,20 @@ const registry: CommandDef[] = [
|
|
|
428
461
|
{ name: "import", handler: runImport },
|
|
429
462
|
{ name: "audit", handler: runAudit },
|
|
430
463
|
{ name: "migrate", handler: runMigrate },
|
|
464
|
+
// Read-only Terraform peelability advisor (#214). Compound so "advise" lands
|
|
465
|
+
// in args.path; the estate dir comes from --from. No plugins, no project.
|
|
466
|
+
{ name: "carve advise", handler: runCarveAdvise },
|
|
467
|
+
// Emit step (#197): adopt a selected TF resource into chant source. The
|
|
468
|
+
// --state path is offline (no plugins); the --env live path loads the target
|
|
469
|
+
// lexicon lazily in the handler, so this command does not require plugins.
|
|
470
|
+
{ name: "carve emit", handler: runCarveEmit },
|
|
471
|
+
// Boundary bridging (#197): patch the surviving TF (data sources + rewired
|
|
472
|
+
// refs) + runbook. No plugins; Terraform-side only. Read-only unless
|
|
473
|
+
// --apply-rewrites.
|
|
474
|
+
{ name: "carve bridge", handler: runCarveBridge },
|
|
475
|
+
// Apply graduation (#197): ownership marker + finalized apply runbook.
|
|
476
|
+
// BYOL-honest — no cloud call; --write saves the graduation doc.
|
|
477
|
+
{ name: "carve apply", handler: runCarveApply },
|
|
431
478
|
{ name: "init", handler: runInit },
|
|
432
479
|
{ name: "init lexicon", handler: runInitLexicon },
|
|
433
480
|
{ name: "update", handler: runUpdate },
|
|
@@ -477,6 +524,7 @@ const registry: CommandDef[] = [
|
|
|
477
524
|
{ name: "serve mcp", requiresPlugins: true, handler: runServeMcp },
|
|
478
525
|
|
|
479
526
|
// Fallback for unknown subcommands (must come after compound entries)
|
|
527
|
+
{ name: "carve", handler: runCarveUnknown },
|
|
480
528
|
{ name: "emulator", requiresPlugins: true, handler: runEmulator },
|
|
481
529
|
{ name: "lifecycle", handler: runLifecycleUnknown },
|
|
482
530
|
{ name: "dev", handler: runDevUnknown },
|
package/src/cli/registry.ts
CHANGED
|
@@ -31,6 +31,18 @@ export interface ParsedArgs {
|
|
|
31
31
|
live: boolean;
|
|
32
32
|
/** `chant migrate --from <name>` (default "github") */
|
|
33
33
|
migrateFrom?: string;
|
|
34
|
+
/** `chant carve advise --state <path>` — opt-in .tfstate for accurate instance counts */
|
|
35
|
+
statePath?: string;
|
|
36
|
+
/** `chant carve emit --select <tf-address>` — the Terraform resource to carve */
|
|
37
|
+
selectAddress?: string;
|
|
38
|
+
/** `chant carve emit --live-name <logicalId>` — CFN logical ID for live adoption */
|
|
39
|
+
liveName?: string;
|
|
40
|
+
/** `chant carve bridge --apply-rewrites` — write rewritten survivor .tf in place */
|
|
41
|
+
applyRewrites?: boolean;
|
|
42
|
+
/** `chant carve apply --stack <name>` — ownership-marker stack for graduation */
|
|
43
|
+
carveStack?: string;
|
|
44
|
+
/** `chant carve apply --write` — save the graduation doc */
|
|
45
|
+
write?: boolean;
|
|
34
46
|
/** `chant migrate --to <name>` (default "gitlab") */
|
|
35
47
|
migrateTo?: string;
|
|
36
48
|
/** `chant migrate --emit yaml|ts` */
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { carveAdvise, formatCarveReport } from "../../cli/commands/carve";
|
|
4
|
+
import { loadHcl2json } from "../parse";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* End-to-end demo test (#214 T5): run the advisor against the runnable sample
|
|
8
|
+
* estate the docs point at, and pin the headline scores so the doc examples
|
|
9
|
+
* stay honest. Skips cleanly when the optional wasm parser is absent.
|
|
10
|
+
*/
|
|
11
|
+
let parserAvailable = false;
|
|
12
|
+
try {
|
|
13
|
+
await loadHcl2json();
|
|
14
|
+
parserAvailable = true;
|
|
15
|
+
} catch {
|
|
16
|
+
parserAvailable = false;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const ESTATE = join(__dirname, "sample-estate");
|
|
20
|
+
|
|
21
|
+
describe("carve advise against the sample estate", () => {
|
|
22
|
+
test("bands the estate the way the docs describe", async () => {
|
|
23
|
+
if (!parserAvailable) return;
|
|
24
|
+
const r = await carveAdvise({ from: ESTATE });
|
|
25
|
+
expect(r.ok).toBe(true);
|
|
26
|
+
const byAddr = Object.fromEntries((r.results ?? []).map((x) => [x.address, x]));
|
|
27
|
+
|
|
28
|
+
// Clean leaves
|
|
29
|
+
expect(byAddr["aws_s3_bucket.assets"].band).toBe("clean leaf");
|
|
30
|
+
expect(byAddr["aws_s3_bucket.assets"].score).toBe(88); // 1 inbound (Lambda); versioning folded
|
|
31
|
+
expect(byAddr["aws_cloudwatch_log_group.api"].score).toBe(100); // clean, no edges
|
|
32
|
+
|
|
33
|
+
// Folded sub-resource is not ranked on its own
|
|
34
|
+
expect(byAddr["aws_s3_bucket_versioning.assets"]).toBeUndefined();
|
|
35
|
+
|
|
36
|
+
// A subnet is a clean leaf (one outbound edge to the VPC).
|
|
37
|
+
expect(byAddr["aws_subnet.a"].score).toBe(96);
|
|
38
|
+
|
|
39
|
+
// The VPC three subnets hang off of is carvable, but with boundary work.
|
|
40
|
+
expect(byAddr["aws_vpc.main"].band).toBe("carvable w/ edits");
|
|
41
|
+
expect(byAddr["aws_vpc.main"].score).toBe(64); // 100 - 12*3 inbound
|
|
42
|
+
|
|
43
|
+
// Unsupported provider scores 0 → leave in Terraform
|
|
44
|
+
expect(byAddr["random_pet.suffix"].score).toBe(0);
|
|
45
|
+
expect(byAddr["random_pet.suffix"].band).toBe("leave in Terraform");
|
|
46
|
+
|
|
47
|
+
// The report never proposes a mutation.
|
|
48
|
+
const text = formatCarveReport(r);
|
|
49
|
+
expect(text).not.toMatch(/state rm|apply|destroy/i);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, test, expect, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { carveEmit } from "../../cli/commands/carve-emit";
|
|
6
|
+
import { carveBridge } from "../../cli/commands/carve-bridge";
|
|
7
|
+
import { carveApply } from "../../cli/commands/carve-apply";
|
|
8
|
+
import { loadHcl2json } from "../parse";
|
|
9
|
+
import type { ImportResult, LiveImportOptions } from "../../cli/commands/import";
|
|
10
|
+
import type { LexiconPlugin } from "../../lexicon";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* End-to-end: one resource through the whole strangler-fig arc — emit → bridge
|
|
14
|
+
* → apply — on a single estate, asserting the steps compose. Live import is
|
|
15
|
+
* faked (no cloud). Skips when the wasm parser 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_s3_bucket_versioning" "assets" {
|
|
28
|
+
bucket = aws_s3_bucket.assets.id
|
|
29
|
+
versioning_configuration { status = "Enabled" }
|
|
30
|
+
}
|
|
31
|
+
resource "aws_lambda_function" "api" {
|
|
32
|
+
function_name = "myapp-api"
|
|
33
|
+
environment { variables = { B = aws_s3_bucket.assets.bucket, A = aws_s3_bucket.assets.arn } }
|
|
34
|
+
}
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
const fakeImport = vi.fn(
|
|
38
|
+
async (_p: LexiconPlugin[], _o: LiveImportOptions): Promise<ImportResult> => ({
|
|
39
|
+
success: true,
|
|
40
|
+
generatedFiles: ["infra/assets.ts"],
|
|
41
|
+
warnings: [],
|
|
42
|
+
lexicon: "aws",
|
|
43
|
+
}),
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
describe("carve arc: emit → bridge → apply", () => {
|
|
47
|
+
test("carries aws_s3_bucket.assets from Terraform to chant-owned", async () => {
|
|
48
|
+
if (!parserAvailable) return;
|
|
49
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-arc-"));
|
|
50
|
+
try {
|
|
51
|
+
writeFileSync(join(dir, "main.tf"), ESTATE);
|
|
52
|
+
const sel = "aws_s3_bucket.assets";
|
|
53
|
+
|
|
54
|
+
// 1. emit: adopt + boundary (Lambda inbound; versioning folded).
|
|
55
|
+
const emit = await carveEmit({ from: dir, select: sel, env: "prod" }, { plugins: [], liveImport: fakeImport });
|
|
56
|
+
expect(emit.ok).toBe(true);
|
|
57
|
+
expect(emit.selector).toEqual({ type: "AWS::S3::Bucket" }); // live selector is by native type
|
|
58
|
+
expect(emit.report!.inbound.map((e) => e.survivor)).toEqual(["aws_lambda_function.api"]);
|
|
59
|
+
|
|
60
|
+
// 2. bridge: data source + rewired survivor (dry-run).
|
|
61
|
+
const out = join(dir, "carveout");
|
|
62
|
+
const bridge = await carveBridge({ from: dir, select: sel, output: out });
|
|
63
|
+
expect(bridge.ok).toBe(true);
|
|
64
|
+
expect(bridge.plan!.dataSources[0].type).toBe("aws_s3_bucket");
|
|
65
|
+
const rewired = bridge.plan!.rewrites.find((r) => r.changed)!;
|
|
66
|
+
expect(rewired.rewritten).toContain("data.aws_s3_bucket.assets.bucket");
|
|
67
|
+
|
|
68
|
+
// 3. apply: ownership marker + graduation plan.
|
|
69
|
+
const apply = await carveApply({ from: dir, select: sel, env: "prod", stack: "assets" });
|
|
70
|
+
expect(apply.ok).toBe(true);
|
|
71
|
+
expect(apply.plan!.ownershipTags["chant:managed-by"]).toBe("chant");
|
|
72
|
+
expect(apply.plan!.steps.join("\n")).toMatch(/terraform import aws_s3_bucket\.assets/);
|
|
73
|
+
} finally {
|
|
74
|
+
rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# A small, deliberately mixed Terraform estate for the carve-out advisor demo.
|
|
2
|
+
# Some resources are clean leaves, some carry boundary edges, some should stay.
|
|
3
|
+
|
|
4
|
+
# Clean leaf: a bucket a Lambda reads from. Its versioning sub-resource folds in.
|
|
5
|
+
resource "aws_s3_bucket" "assets" {
|
|
6
|
+
bucket = "myapp-assets-prod"
|
|
7
|
+
tags = { Team = "web", Env = "prod" }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
resource "aws_s3_bucket_versioning" "assets" {
|
|
11
|
+
bucket = aws_s3_bucket.assets.id
|
|
12
|
+
versioning_configuration {
|
|
13
|
+
status = "Enabled"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
# Clean leaf: a log group nothing depends on.
|
|
18
|
+
resource "aws_cloudwatch_log_group" "api" {
|
|
19
|
+
name = "/myapp/api"
|
|
20
|
+
retention_in_days = 30
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# Stays in Terraform: the Lambda reads the bucket (tier-2 map + outbound edge).
|
|
24
|
+
resource "aws_lambda_function" "api" {
|
|
25
|
+
function_name = "myapp-api"
|
|
26
|
+
environment {
|
|
27
|
+
variables = {
|
|
28
|
+
ASSETS_BUCKET = aws_s3_bucket.assets.bucket
|
|
29
|
+
ASSETS_ARN = aws_s3_bucket.assets.arn
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
# Carvable w/ edits: three subnets depend on the VPC, so carving it means three
|
|
35
|
+
# data-source patches to the surviving Terraform — real but bounded boundary work.
|
|
36
|
+
resource "aws_vpc" "main" {
|
|
37
|
+
cidr_block = "10.0.0.0/16"
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
resource "aws_subnet" "a" {
|
|
41
|
+
vpc_id = aws_vpc.main.id
|
|
42
|
+
cidr_block = "10.0.1.0/24"
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
resource "aws_subnet" "b" {
|
|
46
|
+
vpc_id = aws_vpc.main.id
|
|
47
|
+
cidr_block = "10.0.2.0/24"
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
resource "aws_subnet" "c" {
|
|
51
|
+
vpc_id = aws_vpc.main.id
|
|
52
|
+
cidr_block = "10.0.3.0/24"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
# Leave in Terraform: an unsupported provider with no native mapping, scored 0.
|
|
56
|
+
resource "random_pet" "suffix" {
|
|
57
|
+
length = 2
|
|
58
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { adoptFromState, canAdoptFromState, supportedStateAdoptionTypes } from "./adopt-state";
|
|
3
|
+
import type { StateResource } from "./state";
|
|
4
|
+
|
|
5
|
+
describe("adoptFromState", () => {
|
|
6
|
+
test("maps a real S3 bucket state resource to native chant source", () => {
|
|
7
|
+
const resource: StateResource = {
|
|
8
|
+
type: "aws_s3_bucket",
|
|
9
|
+
name: "assets",
|
|
10
|
+
attributes: {
|
|
11
|
+
id: "myapp-assets-prod",
|
|
12
|
+
bucket: "myapp-assets-prod",
|
|
13
|
+
arn: "arn:aws:s3:::myapp-assets-prod",
|
|
14
|
+
tags: { Team: "web", Env: "prod" },
|
|
15
|
+
force_destroy: false,
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
const out = adoptFromState(resource)!;
|
|
19
|
+
expect(out.fileName).toBe("assets.ts");
|
|
20
|
+
expect(out.mapped).toBe(true);
|
|
21
|
+
expect(out.nativeType).toBe("AWS::S3::Bucket");
|
|
22
|
+
|
|
23
|
+
// Correct constructor + CloudFormation PascalCase props from real TF attrs.
|
|
24
|
+
expect(out.content).toContain('import { Bucket } from "@intentius/chant-lexicon-aws";');
|
|
25
|
+
expect(out.content).toContain("export const assets = new Bucket({");
|
|
26
|
+
expect(out.content).toContain('BucketName: "myapp-assets-prod"');
|
|
27
|
+
expect(out.content).toContain('{"Key":"Team","Value":"web"}');
|
|
28
|
+
expect(out.content).toContain('{"Key":"Env","Value":"prod"}');
|
|
29
|
+
|
|
30
|
+
// Unmapped attributes are preserved for reconciliation, not dropped.
|
|
31
|
+
expect(out.content).toContain("Unmapped Terraform attributes");
|
|
32
|
+
expect(out.content).toContain("force_destroy");
|
|
33
|
+
// Mapped keys are not repeated in the unmapped block.
|
|
34
|
+
expect(out.content).not.toContain('"bucket":');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("maps a log group with retention", () => {
|
|
38
|
+
const out = adoptFromState({
|
|
39
|
+
type: "aws_cloudwatch_log_group",
|
|
40
|
+
name: "api",
|
|
41
|
+
attributes: { id: "/myapp/api", name: "/myapp/api", retention_in_days: 30, arn: "arn:...:log-group:/myapp/api" },
|
|
42
|
+
})!;
|
|
43
|
+
expect(out.content).toContain("new LogGroup({");
|
|
44
|
+
expect(out.content).toContain('LogGroupName: "/myapp/api"');
|
|
45
|
+
expect(out.content).toContain("RetentionInDays: 30");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("canAdoptFromState gates on a known native constructor", () => {
|
|
49
|
+
expect(canAdoptFromState("aws_s3_bucket")).toBe(true);
|
|
50
|
+
expect(canAdoptFromState("random_pet")).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("supportedStateAdoptionTypes lists the mappable types, sorted", () => {
|
|
54
|
+
const types = supportedStateAdoptionTypes();
|
|
55
|
+
expect(types).toContain("aws_s3_bucket");
|
|
56
|
+
expect(types).toContain("aws_sqs_queue");
|
|
57
|
+
expect(types).toEqual([...types].sort());
|
|
58
|
+
expect(types).not.toContain("aws_instance"); // not yet mapped
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("returns null for a type with no native constructor", () => {
|
|
62
|
+
expect(adoptFromState({ type: "random_pet", name: "x", attributes: { id: "abc" } })).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adopt a Terraform-managed resource into chant source from its `.tfstate`
|
|
3
|
+
* (#1009). This is the correct adoption source for carving OUT of Terraform:
|
|
4
|
+
* a TF resource is created through the provider API, not CloudFormation, so it
|
|
5
|
+
* is not in any CFN stack — but its resolved attributes ARE in the state file.
|
|
6
|
+
*
|
|
7
|
+
* chant AWS constructors take CloudFormation PascalCase properties (e.g.
|
|
8
|
+
* `new Bucket({ BucketName })`), while Terraform state uses the provider's
|
|
9
|
+
* snake_case attributes (`bucket`, `tags`). The mapping is driven by the single
|
|
10
|
+
* AWS carve-out table (`aws-resources.ts`) — the same table the advisor's tier
|
|
11
|
+
* map derives from, so every AWS type advise ranks can be emitted. Attributes
|
|
12
|
+
* without a mapping are preserved in a reference comment, never dropped.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { AWS_CARVE_TYPES, AWS_LEXICON_IMPORT, awsCarveType, applyAwsMapper } from "./aws-resources";
|
|
16
|
+
import type { StateResource } from "./state";
|
|
17
|
+
|
|
18
|
+
export interface AdoptedSource {
|
|
19
|
+
fileName: string;
|
|
20
|
+
content: string;
|
|
21
|
+
/** True when at least one attribute was mapped to a native prop. */
|
|
22
|
+
mapped: boolean;
|
|
23
|
+
nativeType: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Is this Terraform type adoptable from state (has a native constructor)? */
|
|
27
|
+
export function canAdoptFromState(tfType: string): boolean {
|
|
28
|
+
return awsCarveType(tfType) !== undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Terraform types that can currently be adopted from state, for user-facing hints. */
|
|
32
|
+
export function supportedStateAdoptionTypes(): string[] {
|
|
33
|
+
return AWS_CARVE_TYPES.map((t) => t.tfType).sort();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Render chant source for a state-adopted resource. Emits the native
|
|
38
|
+
* constructor with mapped properties, plus a reference comment listing the
|
|
39
|
+
* Terraform attributes that were not mapped so nothing is silently dropped.
|
|
40
|
+
*/
|
|
41
|
+
export function adoptFromState(resource: StateResource): AdoptedSource | null {
|
|
42
|
+
const entry = awsCarveType(resource.type);
|
|
43
|
+
if (!entry) return null;
|
|
44
|
+
|
|
45
|
+
const { props, mappedKeys } = applyAwsMapper(entry, resource.attributes);
|
|
46
|
+
const unmapped: Record<string, unknown> = {};
|
|
47
|
+
for (const [k, v] of Object.entries(resource.attributes)) {
|
|
48
|
+
if (!mappedKeys.includes(k)) unmapped[k] = v;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const L: string[] = [];
|
|
52
|
+
L.push(`// Adopted from Terraform state: ${resource.type}.${resource.name} -> ${entry.nativeType}`);
|
|
53
|
+
L.push(`// Properties mapped from Terraform attributes (CloudFormation PascalCase).`);
|
|
54
|
+
L.push(`import { ${entry.ctor} } from "${AWS_LEXICON_IMPORT}";`);
|
|
55
|
+
L.push("");
|
|
56
|
+
L.push(`export const ${resource.name} = new ${entry.ctor}(${renderObject(props, 2)});`);
|
|
57
|
+
if (Object.keys(unmapped).length) {
|
|
58
|
+
L.push("");
|
|
59
|
+
L.push("/* Unmapped Terraform attributes (reconcile to native props before building):");
|
|
60
|
+
L.push(JSON.stringify(unmapped, null, 2));
|
|
61
|
+
L.push("*/");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
fileName: `${resource.name}.ts`,
|
|
66
|
+
content: L.join("\n") + "\n",
|
|
67
|
+
mapped: Object.keys(props).length > 0,
|
|
68
|
+
nativeType: entry.nativeType,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Render a JS object literal with stable key order and given indent. */
|
|
73
|
+
function renderObject(obj: Record<string, unknown>, indent: number): string {
|
|
74
|
+
const keys = Object.keys(obj);
|
|
75
|
+
if (keys.length === 0) return "{}";
|
|
76
|
+
const pad = " ".repeat(indent);
|
|
77
|
+
const body = keys.map((k) => `${pad}${k}: ${JSON.stringify(obj[k])},`).join("\n");
|
|
78
|
+
return `{\n${body}\n}`;
|
|
79
|
+
}
|