@intentius/chant 0.44.6 → 0.44.8
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 +7 -2
- package/dist/cli/commands/carve.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.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 +39 -1
- package/src/cli/commands/carve.ts +8 -2
- package/src/cli/handlers/lifecycle.test.ts +27 -0
- package/src/cli/handlers/lifecycle.ts +3 -0
- package/src/terraform/__fixtures__/advise.test.ts +6 -4
- 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
|
@@ -100,6 +100,43 @@ describe("carveAdvise", () => {
|
|
|
100
100
|
});
|
|
101
101
|
});
|
|
102
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
|
+
|
|
103
140
|
test("formatCarveReport groups by band and never suggests a mutation", async () => {
|
|
104
141
|
if (!parserAvailable) return;
|
|
105
142
|
await withEstate(async (dir) => {
|
|
@@ -132,10 +169,11 @@ describe("carveAdvise", () => {
|
|
|
132
169
|
breakdown: {
|
|
133
170
|
inbound: 0,
|
|
134
171
|
outbound: 0,
|
|
172
|
+
outputs: 0,
|
|
135
173
|
tier: 1,
|
|
136
174
|
hasDynamic: false,
|
|
137
175
|
instances: 1,
|
|
138
|
-
penalties: { inbound: 0, outbound: 0, tier: 0, dynamic: 0, instances: 0 },
|
|
176
|
+
penalties: { inbound: 0, outbound: 0, outputs: 0, tier: 0, dynamic: 0, instances: 0 },
|
|
139
177
|
},
|
|
140
178
|
},
|
|
141
179
|
],
|
|
@@ -73,8 +73,9 @@ export async function carveAdvise(opts: CarveAdviseOptions): Promise<CarveAdvise
|
|
|
73
73
|
* says which shape it is. The promise attached to this number:
|
|
74
74
|
*
|
|
75
75
|
* - **Additive within a version.** New top-level fields, new per-resource
|
|
76
|
-
* fields,
|
|
77
|
-
*
|
|
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.
|
|
78
79
|
* - **A removal, a rename, or a changed meaning bumps it.** So does narrowing
|
|
79
80
|
* a field's type (an optional becoming required is additive; the reverse is
|
|
80
81
|
* not).
|
|
@@ -97,6 +98,10 @@ export interface CarveJsonResource extends Peelability {
|
|
|
97
98
|
* stand in for it. Edges internal to the carve set are not boundary work and
|
|
98
99
|
* are not listed.
|
|
99
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
|
+
*
|
|
100
105
|
* Present (possibly with two empty lists) whenever the graph was available;
|
|
101
106
|
* absent means this chant did not compute it — "none" and "not reported" are
|
|
102
107
|
* different claims. `breakdown.inbound`/`outbound` keep the counts.
|
|
@@ -184,6 +189,7 @@ function reasons(r: Peelability): string {
|
|
|
184
189
|
if (b.tier === null) return "no known native mapping (unsupported provider/type)";
|
|
185
190
|
const parts: string[] = [];
|
|
186
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)`);
|
|
187
193
|
if (b.outbound) parts.push(`${b.outbound} outbound (deferred input each)`);
|
|
188
194
|
if (b.tier > 1) parts.push(`tier ${b.tier} map`);
|
|
189
195
|
if (b.hasDynamic) parts.push("count/for_each/data present");
|
|
@@ -677,6 +677,33 @@ describe("runLifecyclePlan", () => {
|
|
|
677
677
|
expect(stdoutBuf.join("\n")).toContain("bucket");
|
|
678
678
|
});
|
|
679
679
|
|
|
680
|
+
// #1620 — the resolved read address rides plan entries the same way it rides
|
|
681
|
+
// the live diff. Regression: the plan path dropped the observation's queried
|
|
682
|
+
// map, so only unobserved rows ever carried an address while the docs
|
|
683
|
+
// promised it per-entry.
|
|
684
|
+
test("--json carries the observation's queried address on observed entries", async () => {
|
|
685
|
+
buildMock.mockResolvedValue(makeBuildResult({ k8s: ["web"] }));
|
|
686
|
+
const plugins: LexiconPlugin[] = [
|
|
687
|
+
createMockPlugin({
|
|
688
|
+
name: "k8s",
|
|
689
|
+
describeResources: async () => ({
|
|
690
|
+
observation: "v1" as const,
|
|
691
|
+
resources: { web: meta({ type: "K8s::Apps::Deployment" }) },
|
|
692
|
+
queried: { web: "/apis/apps/v1/namespaces/default/deployments/web" },
|
|
693
|
+
}),
|
|
694
|
+
}),
|
|
695
|
+
];
|
|
696
|
+
const exit = await runLifecyclePlan({
|
|
697
|
+
args: makeArgs({ path: "plan", extraPositional: "prod", json: true }),
|
|
698
|
+
plugins,
|
|
699
|
+
serializers: plugins.map((p) => p.serializer),
|
|
700
|
+
});
|
|
701
|
+
expect(exit).toBe(0);
|
|
702
|
+
const plan = JSON.parse(stdoutBuf.join("\n"));
|
|
703
|
+
const web = plan.entries.find((e: { name: string }) => e.name === "web");
|
|
704
|
+
expect(web.queried).toBe("/apis/apps/v1/namespaces/default/deployments/web");
|
|
705
|
+
});
|
|
706
|
+
|
|
680
707
|
// #1166 — plan is always a live read (no `--live` flag of its own), so a
|
|
681
708
|
// declared environment endpoint applies here exactly as it does for
|
|
682
709
|
// `chant graph --live` / `chant lifecycle diff --live`.
|
|
@@ -1178,6 +1178,9 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1178
1178
|
observedNow: observed.resources,
|
|
1179
1179
|
observedThen,
|
|
1180
1180
|
unobserved: observed.unobserved,
|
|
1181
|
+
// The resolved read address rides every entry, not just unobserved
|
|
1182
|
+
// ones — the diff path already passes it (#1620); plan lost it.
|
|
1183
|
+
queried: observed.queried,
|
|
1181
1184
|
});
|
|
1182
1185
|
merged.entries.push(...cs.entries);
|
|
1183
1186
|
checked++;
|
|
@@ -129,11 +129,13 @@ describe("carve advise --json against the sample estate", () => {
|
|
|
129
129
|
if (!parserAvailable) return;
|
|
130
130
|
const report = carveJson(await carveAdvise({ from: ESTATE }));
|
|
131
131
|
|
|
132
|
-
// `breakdown.inbound`/`outbound` are what the score was computed
|
|
133
|
-
// stay for backward compatibility. They must agree with the lists,
|
|
134
|
-
// arithmetic a reader prints beside the drawn edges is a lie.
|
|
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.
|
|
135
137
|
for (const r of report.resources) {
|
|
136
|
-
expect([r.address, r.boundary!.inbound.length]).toEqual([r.address, r.breakdown.inbound]);
|
|
138
|
+
expect([r.address, r.boundary!.inbound.length]).toEqual([r.address, r.breakdown.inbound + r.breakdown.outputs]);
|
|
137
139
|
expect([r.address, r.boundary!.outbound.length]).toEqual([r.address, r.breakdown.outbound]);
|
|
138
140
|
}
|
|
139
141
|
|
|
@@ -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
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect } from "vitest";
|
|
2
|
-
import { AWS_CARVE_TYPES, awsCarveType, applyAwsMapper } from "./aws-resources";
|
|
2
|
+
import { AWS_CARVE_TYPES, AWS_FOLD_MAPPERS, awsCarveType, applyAwsMapper, applyAwsFold } from "./aws-resources";
|
|
3
3
|
import { TIER_MAP, FOLDS_INTO, IDENTITY_ATTR } from "./tier-map";
|
|
4
4
|
import { canAdoptFromState } from "./adopt-state";
|
|
5
5
|
|
|
@@ -100,6 +100,107 @@ describe("applyAwsMapper", () => {
|
|
|
100
100
|
});
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
describe("folded sub-resource mappers (#1637)", () => {
|
|
104
|
+
test("every fold mapper is for a type that actually folds", () => {
|
|
105
|
+
for (const tfType of Object.keys(AWS_FOLD_MAPPERS)) {
|
|
106
|
+
expect(FOLDS_INTO[tfType]).toBeDefined();
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("versioning becomes the parent's VersioningConfiguration", () => {
|
|
111
|
+
const fold = applyAwsFold("aws_s3_bucket_versioning", {
|
|
112
|
+
id: "my-bucket",
|
|
113
|
+
bucket: "my-bucket",
|
|
114
|
+
versioning_configuration: [{ status: "Enabled", mfa_delete: "" }],
|
|
115
|
+
expected_bucket_owner: "",
|
|
116
|
+
})!;
|
|
117
|
+
expect(fold.props).toEqual({ VersioningConfiguration: { Status: "Enabled" } });
|
|
118
|
+
// The parent link is not content; the leftover attribute still reports.
|
|
119
|
+
expect(fold.unmapped).toEqual({ expected_bucket_owner: "" });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("a status CloudFormation cannot spell maps nothing and consumes nothing", () => {
|
|
123
|
+
const fold = applyAwsFold("aws_s3_bucket_versioning", {
|
|
124
|
+
bucket: "b",
|
|
125
|
+
versioning_configuration: [{ status: "Disabled" }],
|
|
126
|
+
})!;
|
|
127
|
+
expect(fold.props).toEqual({});
|
|
128
|
+
expect(fold.unmapped).toEqual({ versioning_configuration: [{ status: "Disabled" }] });
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("the public access block becomes the parent's PublicAccessBlockConfiguration", () => {
|
|
132
|
+
const fold = applyAwsFold("aws_s3_bucket_public_access_block", {
|
|
133
|
+
id: "my-bucket",
|
|
134
|
+
bucket: "my-bucket",
|
|
135
|
+
block_public_acls: true,
|
|
136
|
+
block_public_policy: true,
|
|
137
|
+
ignore_public_acls: true,
|
|
138
|
+
restrict_public_buckets: false,
|
|
139
|
+
})!;
|
|
140
|
+
expect(fold.props).toEqual({
|
|
141
|
+
PublicAccessBlockConfiguration: {
|
|
142
|
+
BlockPublicAcls: true,
|
|
143
|
+
BlockPublicPolicy: true,
|
|
144
|
+
IgnorePublicAcls: true,
|
|
145
|
+
RestrictPublicBuckets: false,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
expect(fold.unmapped).toEqual({});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("the SSE sub-resource becomes BucketEncryption, KMS key included", () => {
|
|
152
|
+
const fold = applyAwsFold("aws_s3_bucket_server_side_encryption_configuration", {
|
|
153
|
+
bucket: "my-bucket",
|
|
154
|
+
rule: [
|
|
155
|
+
{
|
|
156
|
+
apply_server_side_encryption_by_default: [{ sse_algorithm: "aws:kms", kms_master_key_id: "arn:aws:kms:k" }],
|
|
157
|
+
bucket_key_enabled: true,
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
})!;
|
|
161
|
+
expect(fold.props).toEqual({
|
|
162
|
+
BucketEncryption: {
|
|
163
|
+
ServerSideEncryptionConfiguration: [
|
|
164
|
+
{ ServerSideEncryptionByDefault: { SSEAlgorithm: "aws:kms", KMSMasterKeyID: "arn:aws:kms:k" }, BucketKeyEnabled: true },
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("a sub-resource type with no mapping yet returns null (the caller reports it)", () => {
|
|
171
|
+
expect(applyAwsFold("aws_s3_bucket_policy", { policy: "{}" })).toBeNull();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("the bucket's own in-state versioning and SSE blocks map too", () => {
|
|
175
|
+
const { props, mappedKeys } = applyAwsMapper(awsCarveType("aws_s3_bucket")!, {
|
|
176
|
+
bucket: "my-bucket",
|
|
177
|
+
versioning: [{ enabled: true, mfa_delete: false }],
|
|
178
|
+
server_side_encryption_configuration: [
|
|
179
|
+
{ rule: [{ apply_server_side_encryption_by_default: [{ sse_algorithm: "AES256", kms_master_key_id: "" }], bucket_key_enabled: false }] },
|
|
180
|
+
],
|
|
181
|
+
});
|
|
182
|
+
expect(props.VersioningConfiguration).toEqual({ Status: "Enabled" });
|
|
183
|
+
expect(props.BucketEncryption).toEqual({
|
|
184
|
+
ServerSideEncryptionConfiguration: [
|
|
185
|
+
{ ServerSideEncryptionByDefault: { SSEAlgorithm: "AES256" }, BucketKeyEnabled: false },
|
|
186
|
+
],
|
|
187
|
+
});
|
|
188
|
+
expect(mappedKeys).toContain("versioning");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("an unversioned bucket's empty blocks map nothing and stay reported", () => {
|
|
192
|
+
const { props, mappedKeys } = applyAwsMapper(awsCarveType("aws_s3_bucket")!, {
|
|
193
|
+
bucket: "my-bucket",
|
|
194
|
+
versioning: [{ enabled: false, mfa_delete: false }],
|
|
195
|
+
server_side_encryption_configuration: [],
|
|
196
|
+
});
|
|
197
|
+
expect(props).not.toHaveProperty("VersioningConfiguration");
|
|
198
|
+
expect(props).not.toHaveProperty("BucketEncryption");
|
|
199
|
+
expect(mappedKeys).not.toContain("versioning");
|
|
200
|
+
expect(mappedKeys).not.toContain("server_side_encryption_configuration");
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
103
204
|
describe("tier + identity coverage maps (#998)", () => {
|
|
104
205
|
test("kubernetes provider types rank, with _v1 aliases sharing the entry", () => {
|
|
105
206
|
expect(TIER_MAP.kubernetes_manifest).toEqual({ tier: 1, mapsTo: "k8s:manifest" });
|
|
@@ -46,10 +46,64 @@ const asJson = (v: unknown): unknown => {
|
|
|
46
46
|
};
|
|
47
47
|
const json = (prop: string): FieldSpec => ({ prop, transform: asJson });
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Terraform state renders a nested block as a one-element list. Take that
|
|
51
|
+
* entry (or the object itself, if the provider wrote it unwrapped).
|
|
52
|
+
*/
|
|
53
|
+
function firstBlock(value: unknown): Record<string, unknown> | undefined {
|
|
54
|
+
const candidate = Array.isArray(value) ? value[0] : value;
|
|
55
|
+
return candidate && typeof candidate === "object" ? (candidate as Record<string, unknown>) : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A list of TF server-side-encryption `rule` blocks → the CFN `BucketEncryption`
|
|
60
|
+
* property. Shared by the bucket's own (deprecated, still state-resident)
|
|
61
|
+
* `server_side_encryption_configuration` block and by the modern
|
|
62
|
+
* `aws_s3_bucket_server_side_encryption_configuration` sub-resource, whose
|
|
63
|
+
* `rule` list has the same shape.
|
|
64
|
+
*/
|
|
65
|
+
function sseRulesToCfn(rules: unknown): unknown {
|
|
66
|
+
if (!Array.isArray(rules)) return undefined;
|
|
67
|
+
const cfnRules: Array<Record<string, unknown>> = [];
|
|
68
|
+
for (const raw of rules) {
|
|
69
|
+
if (!raw || typeof raw !== "object") continue;
|
|
70
|
+
const rule = raw as Record<string, unknown>;
|
|
71
|
+
const out: Record<string, unknown> = {};
|
|
72
|
+
const byDefault = firstBlock(rule.apply_server_side_encryption_by_default);
|
|
73
|
+
const algorithm = byDefault?.sse_algorithm;
|
|
74
|
+
if (typeof algorithm === "string" && algorithm) {
|
|
75
|
+
const sse: Record<string, unknown> = { SSEAlgorithm: algorithm };
|
|
76
|
+
const kmsKey = byDefault?.kms_master_key_id;
|
|
77
|
+
if (typeof kmsKey === "string" && kmsKey) sse.KMSMasterKeyID = kmsKey;
|
|
78
|
+
out.ServerSideEncryptionByDefault = sse;
|
|
79
|
+
}
|
|
80
|
+
if (typeof rule.bucket_key_enabled === "boolean") out.BucketKeyEnabled = rule.bucket_key_enabled;
|
|
81
|
+
if (Object.keys(out).length) cfnRules.push(out);
|
|
82
|
+
}
|
|
83
|
+
return cfnRules.length ? { ServerSideEncryptionConfiguration: cfnRules } : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The bucket's own deprecated `versioning` block, as state still carries it.
|
|
88
|
+
* Only an enabled bucket says anything CloudFormation needs: a bucket that
|
|
89
|
+
* never had versioning has no `VersioningConfiguration` at all, so `enabled =
|
|
90
|
+
* false` maps to nothing and the block stays in the unmapped comment.
|
|
91
|
+
*/
|
|
92
|
+
function legacyVersioningToCfn(value: unknown): unknown {
|
|
93
|
+
const block = firstBlock(value);
|
|
94
|
+
return block?.enabled === true ? { Status: "Enabled" } : undefined;
|
|
95
|
+
}
|
|
96
|
+
|
|
49
97
|
export const AWS_CARVE_TYPES: AwsCarveType[] = [
|
|
50
98
|
// ── Storage & data ──
|
|
51
99
|
{ tfType: "aws_s3_bucket", tier: 1, nativeType: "AWS::S3::Bucket", ctor: "Bucket", identityAttr: "bucket",
|
|
52
|
-
fields: {
|
|
100
|
+
fields: {
|
|
101
|
+
bucket: "BucketName",
|
|
102
|
+
// The provider still resolves these two into the bucket's own state, even
|
|
103
|
+
// when the config declares them through sub-resources (#1637).
|
|
104
|
+
server_side_encryption_configuration: { prop: "BucketEncryption", transform: (v) => sseRulesToCfn(firstBlock(v)?.rule) },
|
|
105
|
+
versioning: { prop: "VersioningConfiguration", transform: legacyVersioningToCfn },
|
|
106
|
+
}, tags: true },
|
|
53
107
|
{ tfType: "aws_dynamodb_table", tier: 2, nativeType: "AWS::DynamoDB::Table", ctor: "Table", identityAttr: "name",
|
|
54
108
|
fields: { name: "TableName", billing_mode: "BillingMode" }, tags: true },
|
|
55
109
|
{ tfType: "aws_efs_file_system", tier: 1, nativeType: "AWS::EFS::FileSystem", ctor: "EFSFileSystem",
|
|
@@ -267,6 +321,91 @@ export function awsCarveType(tfType: string): AwsCarveType | undefined {
|
|
|
267
321
|
return BY_TYPE.get(tfType);
|
|
268
322
|
}
|
|
269
323
|
|
|
324
|
+
/**
|
|
325
|
+
* How a folded sub-resource (see `FOLDS_INTO`) joins its parent's emitted
|
|
326
|
+
* properties (#1637). Terraform splits configuration the CloudFormation shape
|
|
327
|
+
* keeps inside the parent resource, so the fold is not just a carve-set
|
|
328
|
+
* membership claim: the sub-resource's attributes have to land in the parent's
|
|
329
|
+
* props, or the emitted resource silently loses what the Terraform declared.
|
|
330
|
+
*/
|
|
331
|
+
export interface AwsFoldMapper {
|
|
332
|
+
/** Sub-resource attributes this mapper reads. Anything else stays unmapped. */
|
|
333
|
+
consumes: string[];
|
|
334
|
+
/** The parent CFN properties this sub-resource contributes. */
|
|
335
|
+
map: (attrs: Record<string, unknown>) => Record<string, unknown>;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Identity and parent-link attributes: never content, never reported unmapped. */
|
|
339
|
+
const FOLD_LINK_ATTRS = new Set(["id", "arn", "bucket"]);
|
|
340
|
+
|
|
341
|
+
export const AWS_FOLD_MAPPERS: Record<string, AwsFoldMapper> = {
|
|
342
|
+
aws_s3_bucket_versioning: {
|
|
343
|
+
consumes: ["versioning_configuration"],
|
|
344
|
+
map: (attrs) => {
|
|
345
|
+
const status = firstBlock(attrs.versioning_configuration)?.status;
|
|
346
|
+
// CFN takes Enabled/Suspended only; TF's third state ("Disabled", write-once
|
|
347
|
+
// buckets) has no CloudFormation spelling and stays in the comment.
|
|
348
|
+
return status === "Enabled" || status === "Suspended" ? { VersioningConfiguration: { Status: status } } : {};
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
aws_s3_bucket_public_access_block: {
|
|
352
|
+
consumes: ["block_public_acls", "block_public_policy", "ignore_public_acls", "restrict_public_buckets"],
|
|
353
|
+
map: (attrs) => {
|
|
354
|
+
const config: Record<string, unknown> = {};
|
|
355
|
+
const flags: Array<[string, string]> = [
|
|
356
|
+
["block_public_acls", "BlockPublicAcls"],
|
|
357
|
+
["block_public_policy", "BlockPublicPolicy"],
|
|
358
|
+
["ignore_public_acls", "IgnorePublicAcls"],
|
|
359
|
+
["restrict_public_buckets", "RestrictPublicBuckets"],
|
|
360
|
+
];
|
|
361
|
+
for (const [tfAttr, prop] of flags) {
|
|
362
|
+
if (typeof attrs[tfAttr] === "boolean") config[prop] = attrs[tfAttr];
|
|
363
|
+
}
|
|
364
|
+
return Object.keys(config).length ? { PublicAccessBlockConfiguration: config } : {};
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
aws_s3_bucket_server_side_encryption_configuration: {
|
|
368
|
+
consumes: ["rule"],
|
|
369
|
+
map: (attrs) => {
|
|
370
|
+
const encryption = sseRulesToCfn(attrs.rule);
|
|
371
|
+
return encryption ? { BucketEncryption: encryption } : {};
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Apply a folded sub-resource's state attributes to its parent's properties.
|
|
378
|
+
*
|
|
379
|
+
* Returns the props it contributes plus the attributes that stay genuinely
|
|
380
|
+
* unmappable (which the emitted source preserves in its reference comment). A
|
|
381
|
+
* mapper that produced nothing consumes nothing — the caller reports the whole
|
|
382
|
+
* sub-resource rather than claiming a fold that did not happen. `null` means
|
|
383
|
+
* this sub-resource type has no fold mapping at all.
|
|
384
|
+
*/
|
|
385
|
+
export function applyAwsFold(
|
|
386
|
+
tfType: string,
|
|
387
|
+
attrs: Record<string, unknown>,
|
|
388
|
+
): { props: Record<string, unknown>; unmapped: Record<string, unknown> } | null {
|
|
389
|
+
const mapper = AWS_FOLD_MAPPERS[tfType];
|
|
390
|
+
if (!mapper) return null;
|
|
391
|
+
const props = mapper.map(attrs);
|
|
392
|
+
const consumed = new Set(Object.keys(props).length ? mapper.consumes : []);
|
|
393
|
+
return { props, unmapped: unmappedFoldAttrs(attrs, consumed) };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** A folded sub-resource's attributes minus what was consumed and its parent link. */
|
|
397
|
+
export function unmappedFoldAttrs(
|
|
398
|
+
attrs: Record<string, unknown>,
|
|
399
|
+
consumed: ReadonlySet<string> = new Set(),
|
|
400
|
+
): Record<string, unknown> {
|
|
401
|
+
const rest: Record<string, unknown> = {};
|
|
402
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
403
|
+
if (consumed.has(key) || FOLD_LINK_ATTRS.has(key)) continue;
|
|
404
|
+
rest[key] = value;
|
|
405
|
+
}
|
|
406
|
+
return rest;
|
|
407
|
+
}
|
|
408
|
+
|
|
270
409
|
/** TF `tags` map → CloudFormation `Tags` list of {Key, Value}. */
|
|
271
410
|
function tagsToCfn(tags: unknown): Array<{ Key: string; Value: unknown }> | undefined {
|
|
272
411
|
if (!tags || typeof tags !== "object" || Array.isArray(tags)) return undefined;
|
|
@@ -291,8 +430,11 @@ export function applyAwsMapper(
|
|
|
291
430
|
if (typeof spec === "string") {
|
|
292
431
|
props[spec] = value;
|
|
293
432
|
} else {
|
|
433
|
+
// A transform that declines (undefined) has mapped nothing — the attribute
|
|
434
|
+
// stays in the unmapped report rather than being claimed and dropped.
|
|
294
435
|
const t = spec.transform(value);
|
|
295
|
-
if (t
|
|
436
|
+
if (t === undefined) continue;
|
|
437
|
+
props[spec.prop] = t;
|
|
296
438
|
}
|
|
297
439
|
mappedKeys.push(tfAttr);
|
|
298
440
|
}
|
|
@@ -110,6 +110,36 @@ describe("generateBridge — inbound (data-source rewrite)", () => {
|
|
|
110
110
|
});
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
+
describe("generateBridge — output blocks (#1638)", () => {
|
|
114
|
+
const withOutput: Hcl2JsonTree = {
|
|
115
|
+
resource: { aws_s3_bucket: { assets: [{ bucket: "myapp-assets-prod" }] } },
|
|
116
|
+
output: { assets_bucket: [{ value: "${aws_s3_bucket.assets.bucket}" }] },
|
|
117
|
+
};
|
|
118
|
+
const OUTPUTS_TF = `output "assets_bucket" {\n value = aws_s3_bucket.assets.bucket\n}\n`;
|
|
119
|
+
|
|
120
|
+
test("an output-only dependency still gets a data source and a rewrite", () => {
|
|
121
|
+
const report = boundaryReport(buildFixtureGraph(withOutput), "aws_s3_bucket.assets")!;
|
|
122
|
+
const plan = generateBridge(report, [{ path: "outputs.tf", content: OUTPUTS_TF }], identities);
|
|
123
|
+
|
|
124
|
+
// Before #1638 the graph could not see the output, so this was an unpatched
|
|
125
|
+
// dependency: no data source, no rewrite, a broken plan at handoff.
|
|
126
|
+
expect(plan.dataSources.map((d) => d.address)).toEqual(["aws_s3_bucket.assets"]);
|
|
127
|
+
expect(plan.outputRewrites).toEqual(["output.assets_bucket"]);
|
|
128
|
+
|
|
129
|
+
const rewrite = plan.rewrites.find((r) => r.path === "outputs.tf")!;
|
|
130
|
+
expect(rewrite.changed).toBe(true);
|
|
131
|
+
expect(rewrite.rewritten).toContain("value = data.aws_s3_bucket.assets.bucket");
|
|
132
|
+
expect(plan.runbook).toContain("repoints these output block(s) at the data source: output.assets_bucket");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("no outputs → nothing listed", () => {
|
|
136
|
+
const report = boundaryReport(buildFixtureGraph(workedExample), "aws_s3_bucket.assets")!;
|
|
137
|
+
const plan = generateBridge(report, [{ path: "api.tf", content: API_TF }], identities);
|
|
138
|
+
expect(plan.outputRewrites).toEqual([]);
|
|
139
|
+
expect(plan.runbook).not.toContain("repoints these output block(s) at the data source");
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
113
143
|
describe("generateBridge — outbound (deferred inputs)", () => {
|
|
114
144
|
test("records outbound edges as deferred deploy-time inputs", () => {
|
|
115
145
|
const tree: Hcl2JsonTree = {
|