@intentius/chant 0.44.5 → 0.44.7
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-bridge.d.ts.map +1 -1
- package/dist/cli/commands/carve-emit.d.ts +3 -1
- package/dist/cli/commands/carve-emit.d.ts.map +1 -1
- package/dist/cli/commands/carve.d.ts +64 -2
- package/dist/cli/commands/carve.d.ts.map +1 -1
- package/dist/terraform/adopt-state.d.ts +18 -1
- package/dist/terraform/adopt-state.d.ts.map +1 -1
- package/dist/terraform/aws-resources.d.ts +29 -0
- package/dist/terraform/aws-resources.d.ts.map +1 -1
- package/dist/terraform/bridge.d.ts +8 -0
- package/dist/terraform/bridge.d.ts.map +1 -1
- package/dist/terraform/carve.d.ts +8 -3
- package/dist/terraform/carve.d.ts.map +1 -1
- package/dist/terraform/graph.d.ts +10 -4
- package/dist/terraform/graph.d.ts.map +1 -1
- package/dist/terraform/parse.d.ts.map +1 -1
- package/dist/terraform/score.d.ts +11 -0
- package/dist/terraform/score.d.ts.map +1 -1
- package/dist/terraform/types.d.ts +11 -0
- package/dist/terraform/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/carve-bridge.test.ts +32 -0
- package/src/cli/commands/carve-bridge.ts +3 -0
- package/src/cli/commands/carve-emit-state.test.ts +107 -0
- package/src/cli/commands/carve-emit.ts +37 -5
- package/src/cli/commands/carve.test.ts +82 -3
- package/src/cli/commands/carve.ts +81 -6
- package/src/terraform/__fixtures__/advise.test.ts +114 -1
- package/src/terraform/adopt-state.test.ts +64 -0
- package/src/terraform/adopt-state.ts +52 -2
- package/src/terraform/aws-resources.test.ts +102 -1
- package/src/terraform/aws-resources.ts +144 -2
- package/src/terraform/bridge.test.ts +30 -0
- package/src/terraform/bridge.ts +19 -1
- package/src/terraform/carve.test.ts +34 -0
- package/src/terraform/carve.ts +0 -0
- package/src/terraform/graph.test.ts +45 -0
- package/src/terraform/graph.ts +35 -16
- package/src/terraform/parse.ts +4 -1
- package/src/terraform/score.test.ts +25 -0
- package/src/terraform/score.ts +27 -4
- package/src/terraform/types.ts +11 -0
|
@@ -17,9 +17,15 @@ import { basename, join, resolve } from "path";
|
|
|
17
17
|
import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
|
|
18
18
|
import { boundaryReport, deferredParamName, type CarveReport } from "../../terraform/carve";
|
|
19
19
|
import { resolveTier } from "../../terraform/tier-map";
|
|
20
|
-
import { readStateResource } from "../../terraform/state";
|
|
20
|
+
import { readStateResource, type StateResource } from "../../terraform/state";
|
|
21
21
|
import { writeCarveManifest, type CarveManifest } from "../../terraform/manifest";
|
|
22
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
adoptFromState,
|
|
24
|
+
canAdoptFromState,
|
|
25
|
+
supportedStateAdoptionTypes,
|
|
26
|
+
type DeferredParam,
|
|
27
|
+
type FoldedContribution,
|
|
28
|
+
} from "../../terraform/adopt-state";
|
|
23
29
|
import { getChantVersion } from "./init";
|
|
24
30
|
import type { LexiconPlugin, ResourceSelector } from "../../lexicon";
|
|
25
31
|
import type { ImportResult, LiveImportOptions } from "./import";
|
|
@@ -67,6 +73,8 @@ export interface CarveEmitResult {
|
|
|
67
73
|
scaffolded?: string[];
|
|
68
74
|
/** Deferred outbound inputs declared as build parameters in the emitted project (#998). */
|
|
69
75
|
params?: DeferredParam[];
|
|
76
|
+
/** Folded sub-resources and the props each joined into the emitted parent (#1637). */
|
|
77
|
+
folded?: FoldedContribution[];
|
|
70
78
|
/** The persisted carve state manifest bridge/apply compose with. */
|
|
71
79
|
manifestPath?: string;
|
|
72
80
|
}
|
|
@@ -125,7 +133,13 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
|
|
|
125
133
|
// value the carved block read enters the emitted project as a declared
|
|
126
134
|
// param, defaulted to the value the state resolved.
|
|
127
135
|
const params = deferredParams(report, stateResource.attributes);
|
|
128
|
-
|
|
136
|
+
// The carve set's folded sub-resources come out of the same state file:
|
|
137
|
+
// their mappable attributes belong in the parent's emitted props (#1637).
|
|
138
|
+
const folded = report.carveSet
|
|
139
|
+
.filter((m) => m.foldedInto)
|
|
140
|
+
.map((m) => readStateResource(opts.statePath!, m.address))
|
|
141
|
+
.filter((r): r is StateResource => r !== null);
|
|
142
|
+
const adopted = adoptFromState(stateResource, params, folded);
|
|
129
143
|
if (!adopted) return { ok: false, error: `Could not adopt ${opts.select} from state.` };
|
|
130
144
|
|
|
131
145
|
// The output dir is a buildable chant project: source in src/, plus the
|
|
@@ -139,7 +153,16 @@ export async function carveEmit(opts: CarveEmitOptions, deps: CarveEmitDeps): Pr
|
|
|
139
153
|
const scaffolded = scaffoldProject(outDir, lexicon, params);
|
|
140
154
|
|
|
141
155
|
const manifestPath = persistManifest(outDir, opts, report, tfType, "tfstate", [outPath], params);
|
|
142
|
-
return {
|
|
156
|
+
return {
|
|
157
|
+
ok: true,
|
|
158
|
+
report,
|
|
159
|
+
source: "tfstate",
|
|
160
|
+
emittedFiles: [outPath],
|
|
161
|
+
scaffolded,
|
|
162
|
+
manifestPath,
|
|
163
|
+
params,
|
|
164
|
+
folded: adopted.folded,
|
|
165
|
+
};
|
|
143
166
|
}
|
|
144
167
|
|
|
145
168
|
// ── Adoption path 2: live import (cloud→code) ──
|
|
@@ -335,7 +358,16 @@ export function formatCarveEmit(result: CarveEmitResult): string {
|
|
|
335
358
|
lines.push(` State manifest: ${result.manifestPath} — carve bridge/apply pick the target up from here.`);
|
|
336
359
|
}
|
|
337
360
|
if (r.carveSet.length > 1) {
|
|
338
|
-
|
|
361
|
+
// Say what each fold actually contributed — announcing the fold without the
|
|
362
|
+
// properties was the bug behind #1637.
|
|
363
|
+
const contributed = new Map((result.folded ?? []).map((f) => [f.address, f.props]));
|
|
364
|
+
const folded = r.carveSet
|
|
365
|
+
.filter((m) => m.foldedInto)
|
|
366
|
+
.map((m) => {
|
|
367
|
+
const props = contributed.get(m.address);
|
|
368
|
+
if (props === undefined) return m.address;
|
|
369
|
+
return props.length ? `${m.address} (${props.join(", ")})` : `${m.address} (nothing mappable)`;
|
|
370
|
+
});
|
|
339
371
|
lines.push(` Folded in: ${folded.join(", ")}`);
|
|
340
372
|
}
|
|
341
373
|
lines.push("");
|
|
@@ -2,7 +2,7 @@ import { describe, test, expect } from "vitest";
|
|
|
2
2
|
import { mkdtempSync, writeFileSync, rmSync, readFileSync } from "fs";
|
|
3
3
|
import { tmpdir } from "os";
|
|
4
4
|
import { join } from "path";
|
|
5
|
-
import { carveAdvise, carveJson, formatCarveReport } from "./carve";
|
|
5
|
+
import { carveAdvise, carveJson, formatCarveReport, CARVE_REPORT_VERSION } from "./carve";
|
|
6
6
|
import { loadHcl2json } from "../../terraform/parse";
|
|
7
7
|
|
|
8
8
|
let parserAvailable = false;
|
|
@@ -78,12 +78,65 @@ describe("carveAdvise", () => {
|
|
|
78
78
|
|
|
79
79
|
// report file is valid JSON with the advisory banner + band counts
|
|
80
80
|
const payload = JSON.parse(readFileSync(reportFile, "utf-8"));
|
|
81
|
+
expect(payload.version).toBe(CARVE_REPORT_VERSION);
|
|
81
82
|
expect(payload.advisory).toContain("read-only");
|
|
82
83
|
expect(payload.bands["clean leaf"]).toBeGreaterThanOrEqual(1);
|
|
83
84
|
expect(payload.count).toBe(r.results!.length);
|
|
85
|
+
|
|
86
|
+
// The written report carries the edge lists, not just the counts (#1636).
|
|
87
|
+
const written = payload.resources.find((x: { address: string }) => x.address === "aws_s3_bucket.assets");
|
|
88
|
+
expect(written.boundary.inbound).toEqual([
|
|
89
|
+
{
|
|
90
|
+
direction: "inbound",
|
|
91
|
+
survivor: "aws_lambda_function.api",
|
|
92
|
+
carved: "aws_s3_bucket.assets",
|
|
93
|
+
attrs: ["bucket"],
|
|
94
|
+
via: ["environment"],
|
|
95
|
+
bridge: "tf-data-source",
|
|
96
|
+
required: "immediately",
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
expect(written.boundary.outbound).toEqual([]);
|
|
84
100
|
});
|
|
85
101
|
});
|
|
86
102
|
|
|
103
|
+
test("an output block in its own file counts against the score it reads (#1638)", async () => {
|
|
104
|
+
if (!parserAvailable) return;
|
|
105
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-carve-outputs-"));
|
|
106
|
+
try {
|
|
107
|
+
writeFileSync(join(dir, "main.tf"), ESTATE);
|
|
108
|
+
// Outputs almost always live in their own file — the merge across files
|
|
109
|
+
// has to carry them, or the graph never sees the estate's outputs.tf.
|
|
110
|
+
writeFileSync(
|
|
111
|
+
join(dir, "outputs.tf"),
|
|
112
|
+
`output "assets_bucket" {\n value = aws_s3_bucket.assets.bucket\n}\n`,
|
|
113
|
+
);
|
|
114
|
+
const r = await carveAdvise({ from: dir });
|
|
115
|
+
expect(r.ok).toBe(true);
|
|
116
|
+
|
|
117
|
+
// Outputs are not carve candidates.
|
|
118
|
+
expect((r.results ?? []).map((x) => x.address)).not.toContain("output.assets_bucket");
|
|
119
|
+
|
|
120
|
+
const bucket = r.results!.find((x) => x.address === "aws_s3_bucket.assets")!;
|
|
121
|
+
expect(bucket.breakdown).toMatchObject({ inbound: 1, outputs: 1 });
|
|
122
|
+
expect(bucket.score).toBe(84); // 88 with the Lambda alone, minus 4 for the output
|
|
123
|
+
expect(formatCarveReport(r)).toContain("1 output block(s) reading it (one-line rewrite each)");
|
|
124
|
+
|
|
125
|
+
const written = carveJson(r).resources.find((x) => x.address === "aws_s3_bucket.assets")!;
|
|
126
|
+
expect(written.boundary!.inbound).toContainEqual({
|
|
127
|
+
direction: "inbound",
|
|
128
|
+
survivor: "output.assets_bucket",
|
|
129
|
+
carved: "aws_s3_bucket.assets",
|
|
130
|
+
attrs: ["bucket"],
|
|
131
|
+
via: ["value"],
|
|
132
|
+
bridge: "tf-output-rewrite",
|
|
133
|
+
required: "immediately",
|
|
134
|
+
});
|
|
135
|
+
} finally {
|
|
136
|
+
rmSync(dir, { recursive: true, force: true });
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
87
140
|
test("formatCarveReport groups by band and never suggests a mutation", async () => {
|
|
88
141
|
if (!parserAvailable) return;
|
|
89
142
|
await withEstate(async (dir) => {
|
|
@@ -96,9 +149,35 @@ describe("carveAdvise", () => {
|
|
|
96
149
|
});
|
|
97
150
|
});
|
|
98
151
|
|
|
99
|
-
test("carveJson carries the read-only advisory banner", () => {
|
|
100
|
-
const payload = carveJson({ ok: true, from: "x", results: [] })
|
|
152
|
+
test("carveJson carries the read-only advisory banner and the schema version", () => {
|
|
153
|
+
const payload = carveJson({ ok: true, from: "x", results: [] });
|
|
101
154
|
expect(payload.advisory).toContain("read-only");
|
|
102
155
|
expect(payload.count).toBe(0);
|
|
156
|
+
expect(payload.version).toBe(1);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("carveJson omits boundary entirely with no graph — 'none' and 'not reported' differ", () => {
|
|
160
|
+
const payload = carveJson({
|
|
161
|
+
ok: true,
|
|
162
|
+
from: "x",
|
|
163
|
+
results: [
|
|
164
|
+
{
|
|
165
|
+
address: "aws_vpc.main",
|
|
166
|
+
kind: "resource",
|
|
167
|
+
score: 100,
|
|
168
|
+
band: "clean leaf",
|
|
169
|
+
breakdown: {
|
|
170
|
+
inbound: 0,
|
|
171
|
+
outbound: 0,
|
|
172
|
+
outputs: 0,
|
|
173
|
+
tier: 1,
|
|
174
|
+
hasDynamic: false,
|
|
175
|
+
instances: 1,
|
|
176
|
+
penalties: { inbound: 0, outbound: 0, outputs: 0, tier: 0, dynamic: 0, instances: 0 },
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
expect(payload.resources[0]).not.toHaveProperty("boundary");
|
|
103
182
|
});
|
|
104
183
|
});
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
import { existsSync, statSync, writeFileSync } from "fs";
|
|
11
11
|
import { parseTerraformDir, Hcl2JsonNotInstalled } from "../../terraform/parse";
|
|
12
12
|
import { scoreEstate, type Peelability, type PeelabilityBand } from "../../terraform/score";
|
|
13
|
+
import { boundaryReport, type BoundaryEdge } from "../../terraform/carve";
|
|
14
|
+
import type { TfGraph } from "../../terraform/types";
|
|
13
15
|
|
|
14
16
|
export interface CarveAdviseOptions {
|
|
15
17
|
/** Terraform estate directory (from `--from`). */
|
|
@@ -25,6 +27,12 @@ export interface CarveAdviseResult {
|
|
|
25
27
|
error?: string;
|
|
26
28
|
from?: string;
|
|
27
29
|
results?: Peelability[];
|
|
30
|
+
/**
|
|
31
|
+
* The parsed dependency graph the scores came from. Kept so the JSON report
|
|
32
|
+
* can carry the boundary edge lists (#1636) rather than only their counts.
|
|
33
|
+
* Not part of the JSON payload — `carveJson` derives from it.
|
|
34
|
+
*/
|
|
35
|
+
graph?: TfGraph;
|
|
28
36
|
}
|
|
29
37
|
|
|
30
38
|
const BAND_ORDER: PeelabilityBand[] = ["clean leaf", "carvable w/ edits", "leave in Terraform"];
|
|
@@ -40,36 +48,102 @@ export async function carveAdvise(opts: CarveAdviseOptions): Promise<CarveAdvise
|
|
|
40
48
|
return { ok: false, error: `State file not found: ${opts.statePath}` };
|
|
41
49
|
}
|
|
42
50
|
|
|
51
|
+
let graph: TfGraph;
|
|
43
52
|
let results: Peelability[];
|
|
44
53
|
try {
|
|
45
|
-
|
|
54
|
+
graph = await parseTerraformDir(opts.from, { statePath: opts.statePath });
|
|
55
|
+
results = scoreEstate(graph);
|
|
46
56
|
} catch (err) {
|
|
47
57
|
if (err instanceof Hcl2JsonNotInstalled) return { ok: false, error: err.message };
|
|
48
58
|
return { ok: false, error: `Failed to parse Terraform in ${opts.from}: ${err instanceof Error ? err.message : String(err)}` };
|
|
49
59
|
}
|
|
50
60
|
|
|
61
|
+
const result: CarveAdviseResult = { ok: true, from: opts.from, results, graph };
|
|
51
62
|
if (opts.reportFile) {
|
|
52
|
-
writeFileSync(opts.reportFile, JSON.stringify(carveJson(
|
|
63
|
+
writeFileSync(opts.reportFile, JSON.stringify(carveJson(result), null, 2));
|
|
53
64
|
}
|
|
54
65
|
|
|
55
|
-
return
|
|
66
|
+
return result;
|
|
56
67
|
}
|
|
57
68
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
69
|
+
/**
|
|
70
|
+
* The schema version of the `--json` / `--report` payload (#1636).
|
|
71
|
+
*
|
|
72
|
+
* The report is a cross-tool contract — behold renders it as a graph — so it
|
|
73
|
+
* says which shape it is. The promise attached to this number:
|
|
74
|
+
*
|
|
75
|
+
* - **Additive within a version.** New top-level fields, new per-resource
|
|
76
|
+
* fields, new kinds of entry in an existing list, and new values in an
|
|
77
|
+
* open-ended enum (a `bridge` kind, say) may appear in any release. A
|
|
78
|
+
* reader must ignore keys and values it does not know.
|
|
79
|
+
* - **A removal, a rename, or a changed meaning bumps it.** So does narrowing
|
|
80
|
+
* a field's type (an optional becoming required is additive; the reverse is
|
|
81
|
+
* not).
|
|
82
|
+
* - A reader that does not know the version it is handed should refuse the
|
|
83
|
+
* report rather than half-read it.
|
|
84
|
+
*/
|
|
85
|
+
export const CARVE_REPORT_VERSION = 1;
|
|
86
|
+
|
|
87
|
+
/** One ranked resource in the JSON report: its score, plus the boundary its carve would cut. */
|
|
88
|
+
export interface CarveJsonResource extends Peelability {
|
|
89
|
+
/**
|
|
90
|
+
* Every dependency edge carving this resource would cut (#1636), in chant's
|
|
91
|
+
* own `BoundaryEdge` shape — the same classification the emit/bridge path
|
|
92
|
+
* runs on. `inbound` edges need a Terraform `data`-source patch the moment
|
|
93
|
+
* the carve lands; `outbound` edges become deploy-time inputs, deferred
|
|
94
|
+
* until apply.
|
|
95
|
+
*
|
|
96
|
+
* The lists are the *carve set's* boundary, so a folded sub-resource never
|
|
97
|
+
* appears as an endpoint: it carves with its parent, and the parent's edges
|
|
98
|
+
* stand in for it. Edges internal to the carve set are not boundary work and
|
|
99
|
+
* are not listed.
|
|
100
|
+
*
|
|
101
|
+
* An inbound edge's survivor can be an `output.<name>` pseudo-address
|
|
102
|
+
* (#1638), carrying `bridge: "tf-output-rewrite"` and `via: ["value"]`. It
|
|
103
|
+
* is counted in `breakdown.outputs`, not `breakdown.inbound`.
|
|
104
|
+
*
|
|
105
|
+
* Present (possibly with two empty lists) whenever the graph was available;
|
|
106
|
+
* absent means this chant did not compute it — "none" and "not reported" are
|
|
107
|
+
* different claims. `breakdown.inbound`/`outbound` keep the counts.
|
|
108
|
+
*/
|
|
109
|
+
boundary?: { inbound: BoundaryEdge[]; outbound: BoundaryEdge[] };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The `chant carve advise --json` / `--report` payload. Versioned; see {@link CARVE_REPORT_VERSION}. */
|
|
113
|
+
export interface CarveJsonReport {
|
|
114
|
+
version: number;
|
|
115
|
+
from?: string;
|
|
116
|
+
advisory: string;
|
|
117
|
+
count: number;
|
|
118
|
+
/** Band name -> how many resources landed in it. */
|
|
119
|
+
bands: Record<string, number>;
|
|
120
|
+
resources: CarveJsonResource[];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Build the `--json` / `--report` payload. */
|
|
124
|
+
export function carveJson(result: CarveAdviseResult): CarveJsonReport {
|
|
60
125
|
const results = result.results ?? [];
|
|
61
126
|
const counts = Object.fromEntries(
|
|
62
127
|
BAND_ORDER.map((b) => [b, results.filter((r) => r.band === b).length]),
|
|
63
128
|
);
|
|
64
129
|
return {
|
|
130
|
+
version: CARVE_REPORT_VERSION,
|
|
65
131
|
from: result.from,
|
|
66
132
|
advisory: "read-only — emits nothing, patches nothing, touches no live resource",
|
|
67
133
|
count: results.length,
|
|
68
134
|
bands: counts,
|
|
69
|
-
resources: results,
|
|
135
|
+
resources: results.map((r) => withBoundary(r, result.graph)),
|
|
70
136
|
};
|
|
71
137
|
}
|
|
72
138
|
|
|
139
|
+
/** A scored resource plus its carve boundary, when the graph is at hand (#1636). */
|
|
140
|
+
function withBoundary(r: Peelability, graph?: TfGraph): CarveJsonResource {
|
|
141
|
+
if (!graph) return r;
|
|
142
|
+
const report = boundaryReport(graph, r.address);
|
|
143
|
+
if (!report) return r;
|
|
144
|
+
return { ...r, boundary: { inbound: report.inbound, outbound: report.outbound } };
|
|
145
|
+
}
|
|
146
|
+
|
|
73
147
|
/** Human-readable banded, ranked summary. */
|
|
74
148
|
export function formatCarveReport(result: CarveAdviseResult): string {
|
|
75
149
|
const results = result.results ?? [];
|
|
@@ -115,6 +189,7 @@ function reasons(r: Peelability): string {
|
|
|
115
189
|
if (b.tier === null) return "no known native mapping (unsupported provider/type)";
|
|
116
190
|
const parts: string[] = [];
|
|
117
191
|
if (b.inbound) parts.push(`${b.inbound} inbound (data-source patch each)`);
|
|
192
|
+
if (b.outputs) parts.push(`${b.outputs} output block(s) reading it (one-line rewrite each)`);
|
|
118
193
|
if (b.outbound) parts.push(`${b.outbound} outbound (deferred input each)`);
|
|
119
194
|
if (b.tier > 1) parts.push(`tier ${b.tier} map`);
|
|
120
195
|
if (b.hasDynamic) parts.push("count/for_each/data present");
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
2
|
import { join } from "path";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
carveAdvise,
|
|
5
|
+
carveJson,
|
|
6
|
+
formatCarveReport,
|
|
7
|
+
CARVE_REPORT_VERSION,
|
|
8
|
+
type CarveJsonResource,
|
|
9
|
+
} from "../../cli/commands/carve";
|
|
4
10
|
import { loadHcl2json } from "../parse";
|
|
5
11
|
|
|
6
12
|
/**
|
|
@@ -49,3 +55,110 @@ describe("carve advise against the sample estate", () => {
|
|
|
49
55
|
expect(text).not.toMatch(/state rm|apply|destroy/i);
|
|
50
56
|
});
|
|
51
57
|
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The JSON payload as a cross-tool contract (#1636): a schema version, and the
|
|
61
|
+
* boundary edge LISTS beside the counts. behold's carve lens renders these —
|
|
62
|
+
* the counts alone can't be paired back into edges (this estate has 4 inbound
|
|
63
|
+
* and 4 outbound, which admits several matchings), so the lists are the thing
|
|
64
|
+
* that makes a predicted diff drawable.
|
|
65
|
+
*/
|
|
66
|
+
describe("carve advise --json against the sample estate", () => {
|
|
67
|
+
test("is a versioned report whose resources carry their boundary edges", async () => {
|
|
68
|
+
if (!parserAvailable) return;
|
|
69
|
+
const report = carveJson(await carveAdvise({ from: ESTATE }));
|
|
70
|
+
|
|
71
|
+
expect(report.version).toBe(CARVE_REPORT_VERSION);
|
|
72
|
+
expect(report.count).toBe(8);
|
|
73
|
+
expect(report.resources).toHaveLength(8);
|
|
74
|
+
|
|
75
|
+
const byAddr = Object.fromEntries(report.resources.map((x) => [x.address, x]));
|
|
76
|
+
|
|
77
|
+
// The VPC's three subnets: each one a data-source patch to surviving TF.
|
|
78
|
+
expect(byAddr["aws_vpc.main"].boundary!.inbound).toEqual([
|
|
79
|
+
{ direction: "inbound", survivor: "aws_subnet.a", carved: "aws_vpc.main", attrs: ["id"], via: ["vpc_id"], bridge: "tf-data-source", required: "immediately" },
|
|
80
|
+
{ direction: "inbound", survivor: "aws_subnet.b", carved: "aws_vpc.main", attrs: ["id"], via: ["vpc_id"], bridge: "tf-data-source", required: "immediately" },
|
|
81
|
+
{ direction: "inbound", survivor: "aws_subnet.c", carved: "aws_vpc.main", attrs: ["id"], via: ["vpc_id"], bridge: "tf-data-source", required: "immediately" },
|
|
82
|
+
]);
|
|
83
|
+
expect(byAddr["aws_vpc.main"].boundary!.outbound).toEqual([]);
|
|
84
|
+
|
|
85
|
+
// The same cut, seen from the other end: the subnet's carve defers a value.
|
|
86
|
+
expect(byAddr["aws_subnet.a"].boundary!.outbound).toEqual([
|
|
87
|
+
{ direction: "outbound", survivor: "aws_vpc.main", carved: "aws_subnet.a", attrs: ["id"], via: ["vpc_id"], bridge: "deferred-input", required: "at-apply" },
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
// A clean leaf nothing touches reports two empty lists, not a missing key:
|
|
91
|
+
// "none" is a claim the advisor is willing to make.
|
|
92
|
+
expect(byAddr["aws_cloudwatch_log_group.api"].boundary).toEqual({ inbound: [], outbound: [] });
|
|
93
|
+
|
|
94
|
+
// The Lambda reads two of the bucket's attributes — one edge, both attrs.
|
|
95
|
+
expect(byAddr["aws_lambda_function.api"].boundary!.outbound).toEqual([
|
|
96
|
+
{ direction: "outbound", survivor: "aws_s3_bucket.assets", carved: "aws_lambda_function.api", attrs: ["arn", "bucket"], via: ["environment"], bridge: "deferred-input", required: "at-apply" },
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("every edge endpoint is a real Terraform address in the estate", async () => {
|
|
101
|
+
if (!parserAvailable) return;
|
|
102
|
+
const report = carveJson(await carveAdvise({ from: ESTATE }));
|
|
103
|
+
const ranked = new Set(report.resources.map((r) => r.address));
|
|
104
|
+
|
|
105
|
+
for (const edge of allEdges(report.resources)) {
|
|
106
|
+
// The carved side is always ranked — it is the resource reporting it.
|
|
107
|
+
expect(ranked.has(edge.carved)).toBe(true);
|
|
108
|
+
// The survivor side is a real address too. Every survivor in this estate
|
|
109
|
+
// is itself carvable, so it is ranked; in general a survivor may be an
|
|
110
|
+
// unranked address (a folded sub-resource's parent is never one, but an
|
|
111
|
+
// unsupported type is), which is why this checks the estate's own set.
|
|
112
|
+
expect(ranked.has(edge.survivor)).toBe(true);
|
|
113
|
+
expect(edge.survivor).not.toBe(edge.carved);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("a folded sub-resource is never an edge endpoint — it carves with its parent", async () => {
|
|
118
|
+
if (!parserAvailable) return;
|
|
119
|
+
const report = carveJson(await carveAdvise({ from: ESTATE }));
|
|
120
|
+
const endpoints = allEdges(report.resources).flatMap((e) => [e.survivor, e.carved]);
|
|
121
|
+
|
|
122
|
+
// `aws_s3_bucket_versioning.assets` references its bucket, but that edge is
|
|
123
|
+
// internal to the bucket's carve set: not boundary work, and not an endpoint.
|
|
124
|
+
expect(endpoints).not.toContain("aws_s3_bucket_versioning.assets");
|
|
125
|
+
expect(report.resources.map((r) => r.address)).not.toContain("aws_s3_bucket_versioning.assets");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("the counts and the edge lists tell the same story", async () => {
|
|
129
|
+
if (!parserAvailable) return;
|
|
130
|
+
const report = carveJson(await carveAdvise({ from: ESTATE }));
|
|
131
|
+
|
|
132
|
+
// `breakdown.inbound`/`outbound`/`outputs` are what the score was computed
|
|
133
|
+
// from and stay for backward compatibility. They must agree with the lists,
|
|
134
|
+
// or the arithmetic a reader prints beside the drawn edges is a lie. The
|
|
135
|
+
// inbound list holds both resource and output survivors (#1638), so it is
|
|
136
|
+
// the two counts together.
|
|
137
|
+
for (const r of report.resources) {
|
|
138
|
+
expect([r.address, r.boundary!.inbound.length]).toEqual([r.address, r.breakdown.inbound + r.breakdown.outputs]);
|
|
139
|
+
expect([r.address, r.boundary!.outbound.length]).toEqual([r.address, r.breakdown.outbound]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const inbound = report.resources.reduce((s, r) => s + r.breakdown.inbound, 0);
|
|
143
|
+
const outbound = report.resources.reduce((s, r) => s + r.breakdown.outbound, 0);
|
|
144
|
+
expect([inbound, outbound]).toEqual([4, 4]);
|
|
145
|
+
|
|
146
|
+
// Each cut is reported from both ends — inbound from the depended-on side,
|
|
147
|
+
// outbound from the depending side — so the two totals are the same set of
|
|
148
|
+
// edges seen twice. Keyed in dependency direction, that is 4 distinct cuts.
|
|
149
|
+
const cuts = new Set(
|
|
150
|
+
allEdges(report.resources).map((e) =>
|
|
151
|
+
e.direction === "inbound" ? `${e.survivor} -> ${e.carved}` : `${e.carved} -> ${e.survivor}`,
|
|
152
|
+
),
|
|
153
|
+
);
|
|
154
|
+
expect([...cuts].sort()).toEqual([
|
|
155
|
+
"aws_lambda_function.api -> aws_s3_bucket.assets",
|
|
156
|
+
"aws_subnet.a -> aws_vpc.main",
|
|
157
|
+
"aws_subnet.b -> aws_vpc.main",
|
|
158
|
+
"aws_subnet.c -> aws_vpc.main",
|
|
159
|
+
]);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const allEdges = (resources: CarveJsonResource[]) =>
|
|
164
|
+
resources.flatMap((r) => [...(r.boundary?.inbound ?? []), ...(r.boundary?.outbound ?? [])]);
|
|
@@ -78,6 +78,70 @@ describe("adoptFromState", () => {
|
|
|
78
78
|
expect(out.content).toContain('FunctionName: "myapp-api"');
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
test("folded sub-resources join the parent's emitted properties (#1637)", () => {
|
|
82
|
+
const bucket: StateResource = {
|
|
83
|
+
type: "aws_s3_bucket",
|
|
84
|
+
name: "assets",
|
|
85
|
+
attributes: {
|
|
86
|
+
id: "myapp-assets-prod",
|
|
87
|
+
bucket: "myapp-assets-prod",
|
|
88
|
+
versioning: [{ enabled: true, mfa_delete: false }],
|
|
89
|
+
server_side_encryption_configuration: [
|
|
90
|
+
{ rule: [{ apply_server_side_encryption_by_default: [{ sse_algorithm: "AES256" }], bucket_key_enabled: false }] },
|
|
91
|
+
],
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
const out = adoptFromState(bucket, [], [
|
|
95
|
+
{
|
|
96
|
+
type: "aws_s3_bucket_versioning",
|
|
97
|
+
name: "assets",
|
|
98
|
+
attributes: { bucket: "myapp-assets-prod", versioning_configuration: [{ status: "Enabled" }], mfa: null },
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
type: "aws_s3_bucket_public_access_block",
|
|
102
|
+
name: "assets",
|
|
103
|
+
attributes: {
|
|
104
|
+
bucket: "myapp-assets-prod",
|
|
105
|
+
block_public_acls: true,
|
|
106
|
+
block_public_policy: true,
|
|
107
|
+
ignore_public_acls: true,
|
|
108
|
+
restrict_public_buckets: true,
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
])!;
|
|
112
|
+
|
|
113
|
+
expect(out.content).toContain('VersioningConfiguration: {"Status":"Enabled"}');
|
|
114
|
+
expect(out.content).toContain(
|
|
115
|
+
'PublicAccessBlockConfiguration: {"BlockPublicAcls":true,"BlockPublicPolicy":true,"IgnorePublicAcls":true,"RestrictPublicBuckets":true}',
|
|
116
|
+
);
|
|
117
|
+
// The bucket's own in-state SSE block lands as BucketEncryption.
|
|
118
|
+
expect(out.content).toContain('BucketEncryption: {"ServerSideEncryptionConfiguration"');
|
|
119
|
+
// The source says what each fold contributed.
|
|
120
|
+
expect(out.content).toContain("// Folded in aws_s3_bucket_versioning.assets -> VersioningConfiguration");
|
|
121
|
+
expect(out.content).toContain(
|
|
122
|
+
"// Folded in aws_s3_bucket_public_access_block.assets -> PublicAccessBlockConfiguration",
|
|
123
|
+
);
|
|
124
|
+
expect(out.folded).toEqual([
|
|
125
|
+
{ address: "aws_s3_bucket_versioning.assets", props: ["VersioningConfiguration"] },
|
|
126
|
+
{ address: "aws_s3_bucket_public_access_block.assets", props: ["PublicAccessBlockConfiguration"] },
|
|
127
|
+
]);
|
|
128
|
+
// Only the genuinely unmappable leftover is in the comment.
|
|
129
|
+
expect(out.content).toContain('"aws_s3_bucket_versioning.assets"');
|
|
130
|
+
expect(out.content).toContain('"mfa": null');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("a folded sub-resource with no mapping yet is reported, not dropped", () => {
|
|
134
|
+
const out = adoptFromState(
|
|
135
|
+
{ type: "aws_s3_bucket", name: "assets", attributes: { bucket: "b" } },
|
|
136
|
+
[],
|
|
137
|
+
[{ type: "aws_s3_bucket_policy", name: "assets", attributes: { bucket: "b", policy: '{"Statement":[]}' } }],
|
|
138
|
+
)!;
|
|
139
|
+
expect(out.folded).toEqual([{ address: "aws_s3_bucket_policy.assets", props: [] }]);
|
|
140
|
+
expect(out.content).toContain("// Folded in aws_s3_bucket_policy.assets -> nothing mappable");
|
|
141
|
+
expect(out.content).toContain('"aws_s3_bucket_policy.assets"');
|
|
142
|
+
expect(out.content).toContain('"policy"');
|
|
143
|
+
});
|
|
144
|
+
|
|
81
145
|
test("canAdoptFromState gates on a known native constructor", () => {
|
|
82
146
|
expect(canAdoptFromState("aws_s3_bucket")).toBe(true);
|
|
83
147
|
expect(canAdoptFromState("random_pet")).toBe(false);
|
|
@@ -18,7 +18,14 @@
|
|
|
18
18
|
* resolved value as the declared default (see `carve-emit.ts`'s scaffold).
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
AWS_CARVE_TYPES,
|
|
23
|
+
AWS_LEXICON_IMPORT,
|
|
24
|
+
awsCarveType,
|
|
25
|
+
applyAwsMapper,
|
|
26
|
+
applyAwsFold,
|
|
27
|
+
unmappedFoldAttrs,
|
|
28
|
+
} from "./aws-resources";
|
|
22
29
|
import type { StateResource } from "./state";
|
|
23
30
|
|
|
24
31
|
/** The core subpath the emitted source reads build parameters from. */
|
|
@@ -43,6 +50,14 @@ export interface DeferredParam {
|
|
|
43
50
|
default?: string | number | boolean;
|
|
44
51
|
}
|
|
45
52
|
|
|
53
|
+
/** What one folded sub-resource contributed to the parent's emitted props (#1637). */
|
|
54
|
+
export interface FoldedContribution {
|
|
55
|
+
/** The sub-resource's Terraform address, e.g. `aws_s3_bucket_versioning.assets`. */
|
|
56
|
+
address: string;
|
|
57
|
+
/** CFN properties it added to the parent, e.g. `["VersioningConfiguration"]`. */
|
|
58
|
+
props: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
46
61
|
export interface AdoptedSource {
|
|
47
62
|
fileName: string;
|
|
48
63
|
content: string;
|
|
@@ -51,6 +66,8 @@ export interface AdoptedSource {
|
|
|
51
66
|
nativeType: string;
|
|
52
67
|
/** Deferred params actually substituted into the emitted props (#998). */
|
|
53
68
|
parameterized: string[];
|
|
69
|
+
/** Folded sub-resources and the props each one joined into the parent (#1637). */
|
|
70
|
+
folded: FoldedContribution[];
|
|
54
71
|
}
|
|
55
72
|
|
|
56
73
|
/** Is this Terraform type adoptable from state (has a native constructor)? */
|
|
@@ -79,13 +96,38 @@ class ParamRef {
|
|
|
79
96
|
* A mapped attribute named by a `DeferredParam` renders as a `params.<name>`
|
|
80
97
|
* reference (a real chant build parameter) instead of the state literal —
|
|
81
98
|
* the value came from a survivor, so it stays overridable per build.
|
|
99
|
+
*
|
|
100
|
+
* `folded` carries the carve set's sub-resources (`aws_s3_bucket_versioning`
|
|
101
|
+
* and friends), read from the same state file. Their mappable attributes join
|
|
102
|
+
* the parent's props (#1637) — a fold that only announced itself and left the
|
|
103
|
+
* emitted resource without the versioning or public-access block the Terraform
|
|
104
|
+
* declared was a silent loss of configuration. A sub-resource's setting wins
|
|
105
|
+
* over the parent's own legacy in-state block: it is the one the config
|
|
106
|
+
* actually declares.
|
|
82
107
|
*/
|
|
83
|
-
export function adoptFromState(
|
|
108
|
+
export function adoptFromState(
|
|
109
|
+
resource: StateResource,
|
|
110
|
+
params: DeferredParam[] = [],
|
|
111
|
+
folded: StateResource[] = [],
|
|
112
|
+
): AdoptedSource | null {
|
|
84
113
|
const entry = awsCarveType(resource.type);
|
|
85
114
|
if (!entry) return null;
|
|
86
115
|
|
|
87
116
|
const { props, mappedKeys } = applyAwsMapper(entry, resource.attributes);
|
|
88
117
|
|
|
118
|
+
const contributions: FoldedContribution[] = [];
|
|
119
|
+
const foldedUnmapped: Record<string, Record<string, unknown>> = {};
|
|
120
|
+
for (const sub of folded) {
|
|
121
|
+
const address = `${sub.type}.${sub.name}`;
|
|
122
|
+
const fold = applyAwsFold(sub.type, sub.attributes);
|
|
123
|
+
// No fold mapping for this sub-resource type: it still carves with the
|
|
124
|
+
// parent, so report its attributes rather than dropping them on the floor.
|
|
125
|
+
const rest = fold ? fold.unmapped : unmappedFoldAttrs(sub.attributes);
|
|
126
|
+
if (fold) Object.assign(props, fold.props);
|
|
127
|
+
if (Object.keys(rest).length) foldedUnmapped[address] = rest;
|
|
128
|
+
contributions.push({ address, props: Object.keys(fold?.props ?? {}) });
|
|
129
|
+
}
|
|
130
|
+
|
|
89
131
|
// Substitute deferred inputs: only plain (untransformed) field mappings can
|
|
90
132
|
// carry a parameter reference — a transform ran against the literal at emit
|
|
91
133
|
// time and cannot re-run at build. Everything else keeps the state literal.
|
|
@@ -102,10 +144,17 @@ export function adoptFromState(resource: StateResource, params: DeferredParam[]
|
|
|
102
144
|
for (const [k, v] of Object.entries(resource.attributes)) {
|
|
103
145
|
if (!mappedKeys.includes(k)) unmapped[k] = v;
|
|
104
146
|
}
|
|
147
|
+
// A folded sub-resource's leftovers are keyed by its address, so the comment
|
|
148
|
+
// says which block a stray attribute came from.
|
|
149
|
+
for (const [address, attrs] of Object.entries(foldedUnmapped)) unmapped[address] = attrs;
|
|
105
150
|
|
|
106
151
|
const L: string[] = [];
|
|
107
152
|
L.push(`// Adopted from Terraform state: ${resource.type}.${resource.name} -> ${entry.nativeType}`);
|
|
108
153
|
L.push(`// Properties mapped from Terraform attributes (CloudFormation PascalCase).`);
|
|
154
|
+
for (const c of contributions) {
|
|
155
|
+
const into = c.props.length ? c.props.join(", ") : "nothing mappable — see the reference comment below";
|
|
156
|
+
L.push(`// Folded in ${c.address} -> ${into}`);
|
|
157
|
+
}
|
|
109
158
|
L.push(`import { ${entry.ctor} } from "${AWS_LEXICON_IMPORT}";`);
|
|
110
159
|
if (parameterized.length) {
|
|
111
160
|
L.push(`// Deferred deploy-time input(s) — declared in chant.config.ts's buildParams.`);
|
|
@@ -126,6 +175,7 @@ export function adoptFromState(resource: StateResource, params: DeferredParam[]
|
|
|
126
175
|
mapped: Object.keys(props).length > 0,
|
|
127
176
|
nativeType: entry.nativeType,
|
|
128
177
|
parameterized,
|
|
178
|
+
folded: contributions,
|
|
129
179
|
};
|
|
130
180
|
}
|
|
131
181
|
|