@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
|
@@ -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 = {
|
package/src/terraform/bridge.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* Bridge: add a `data` source for the (now chant-managed) resource and
|
|
9
9
|
* rewrite the survivor's `type.name.attr` references to `data.type.name.attr`.
|
|
10
10
|
* Required immediately, or `terraform plan` errors on the dangling ref.
|
|
11
|
+
* An `output` block reading the carved resource is such a survivor (#1638):
|
|
12
|
+
* same data source, same textual rewrite, applied to the output's value.
|
|
11
13
|
*
|
|
12
14
|
* - outbound edge → the carved resource read a value from a survivor. That
|
|
13
15
|
* value must enter chant from outside synthesis, as a deploy-time input.
|
|
@@ -62,6 +64,12 @@ export interface BridgePlan {
|
|
|
62
64
|
deferredInputs: DeferredInput[];
|
|
63
65
|
/** Every carved address whose own block the rewrites remove, across files. */
|
|
64
66
|
excised: string[];
|
|
67
|
+
/**
|
|
68
|
+
* `output` blocks whose value the rewrites repoint at a data source (#1638),
|
|
69
|
+
* as `output.<name>`. They are inbound edges like any other — listed
|
|
70
|
+
* separately only because the patch is a one-line expression edit.
|
|
71
|
+
*/
|
|
72
|
+
outputRewrites: string[];
|
|
65
73
|
runbook: string;
|
|
66
74
|
}
|
|
67
75
|
|
|
@@ -139,13 +147,19 @@ export function generateBridge(
|
|
|
139
147
|
};
|
|
140
148
|
});
|
|
141
149
|
|
|
150
|
+
const outputRewrites = report.inbound
|
|
151
|
+
.filter((e) => e.bridge === "tf-output-rewrite")
|
|
152
|
+
.map((e) => e.survivor)
|
|
153
|
+
.sort();
|
|
154
|
+
|
|
142
155
|
return {
|
|
143
156
|
target: report.target,
|
|
144
157
|
dataSources,
|
|
145
158
|
rewrites,
|
|
146
159
|
deferredInputs,
|
|
147
160
|
excised,
|
|
148
|
-
|
|
161
|
+
outputRewrites,
|
|
162
|
+
runbook: buildRunbook(report, dataSources, deferredInputs, excised, outputRewrites),
|
|
149
163
|
};
|
|
150
164
|
}
|
|
151
165
|
|
|
@@ -155,6 +169,7 @@ function buildRunbook(
|
|
|
155
169
|
dataSources: DataSourceBlock[],
|
|
156
170
|
deferred: DeferredInput[],
|
|
157
171
|
excised: string[],
|
|
172
|
+
outputRewrites: string[],
|
|
158
173
|
): string {
|
|
159
174
|
const carvedAddrs = report.carveSet.map((m) => m.address);
|
|
160
175
|
const L: string[] = [];
|
|
@@ -180,6 +195,9 @@ function buildRunbook(
|
|
|
180
195
|
} else if (!excised.length) {
|
|
181
196
|
L.push(" # no inbound edges — no survivor patch needed.");
|
|
182
197
|
}
|
|
198
|
+
if (outputRewrites.length) {
|
|
199
|
+
L.push(` # and repoints these output block(s) at the data source: ${outputRewrites.join(", ")}`);
|
|
200
|
+
}
|
|
183
201
|
L.push(" terraform plan # expect: in-place updates to the survivors only");
|
|
184
202
|
L.push(" terraform apply");
|
|
185
203
|
L.push("");
|
|
@@ -87,6 +87,40 @@ describe("boundaryReport", () => {
|
|
|
87
87
|
]);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
test("an output block is an inbound edge with its own bridge kind (#1638)", () => {
|
|
91
|
+
const tree: Hcl2JsonTree = {
|
|
92
|
+
resource: { aws_s3_bucket: { assets: [{ bucket: "b" }] } },
|
|
93
|
+
output: { assets_bucket: [{ value: "${aws_s3_bucket.assets.bucket}" }] },
|
|
94
|
+
};
|
|
95
|
+
const report = boundaryReport(buildFixtureGraph(tree), "aws_s3_bucket.assets")!;
|
|
96
|
+
expect(report.inbound).toEqual([
|
|
97
|
+
{
|
|
98
|
+
direction: "inbound",
|
|
99
|
+
survivor: "output.assets_bucket",
|
|
100
|
+
carved: "aws_s3_bucket.assets",
|
|
101
|
+
attrs: ["bucket"],
|
|
102
|
+
via: ["value"],
|
|
103
|
+
bridge: "tf-output-rewrite",
|
|
104
|
+
required: "immediately",
|
|
105
|
+
},
|
|
106
|
+
]);
|
|
107
|
+
expect(report.peelability).toBe(96); // the cheaper output weight, not 88
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("an output on a folded sub-resource is still the parent's boundary work", () => {
|
|
111
|
+
const tree: Hcl2JsonTree = {
|
|
112
|
+
resource: {
|
|
113
|
+
aws_s3_bucket: { assets: [{ bucket: "b" }] },
|
|
114
|
+
aws_s3_bucket_versioning: { assets: [{ bucket: "${aws_s3_bucket.assets.id}" }] },
|
|
115
|
+
},
|
|
116
|
+
output: { versioning_id: [{ value: "${aws_s3_bucket_versioning.assets.id}" }] },
|
|
117
|
+
};
|
|
118
|
+
const report = boundaryReport(buildFixtureGraph(tree), "aws_s3_bucket.assets")!;
|
|
119
|
+
expect(report.inbound.map((e) => [e.survivor, e.carved, e.bridge])).toEqual([
|
|
120
|
+
["output.versioning_id", "aws_s3_bucket_versioning.assets", "tf-output-rewrite"],
|
|
121
|
+
]);
|
|
122
|
+
});
|
|
123
|
+
|
|
90
124
|
test("unsupported type is flagged in diagnostics", () => {
|
|
91
125
|
const tree: Hcl2JsonTree = { resource: { random_pet: { n: [{ length: 2 }] } } };
|
|
92
126
|
const report = boundaryReport(buildFixtureGraph(tree), "random_pet.n")!;
|
package/src/terraform/carve.ts
CHANGED
|
Binary file
|
|
@@ -157,6 +157,51 @@ describe("buildGraph", () => {
|
|
|
157
157
|
expect(byAddr["aws_s3_bucket.assets"].identity).toBe("myapp-assets-prod"); // flat attr unaffected
|
|
158
158
|
});
|
|
159
159
|
|
|
160
|
+
test("an output block referencing a resource is an inbound edge, tagged as an output (#1638)", () => {
|
|
161
|
+
const tree: Hcl2JsonTree = {
|
|
162
|
+
resource: { aws_s3_bucket: { assets: [{ bucket: "x" }] } },
|
|
163
|
+
output: {
|
|
164
|
+
assets_bucket: [{ value: "${aws_s3_bucket.assets.bucket}" }],
|
|
165
|
+
assets_pair: [{ value: ["${aws_s3_bucket.assets.arn}", "${aws_s3_bucket.assets.id}"], description: "both" }],
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
const g = buildFixtureGraph(tree);
|
|
169
|
+
|
|
170
|
+
// An output is a referrer, never a node — nothing carves an output.
|
|
171
|
+
expect(g.nodes.map((n) => n.address)).toEqual(["aws_s3_bucket.assets"]);
|
|
172
|
+
expect(inboundEdges(g, "aws_s3_bucket.assets")).toEqual([
|
|
173
|
+
{
|
|
174
|
+
from: "output.assets_bucket",
|
|
175
|
+
to: "aws_s3_bucket.assets",
|
|
176
|
+
attrs: ["bucket"],
|
|
177
|
+
via: ["value"],
|
|
178
|
+
fromKind: "output",
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
from: "output.assets_pair",
|
|
182
|
+
to: "aws_s3_bucket.assets",
|
|
183
|
+
attrs: ["arn", "id"],
|
|
184
|
+
via: ["value"],
|
|
185
|
+
fromKind: "output",
|
|
186
|
+
},
|
|
187
|
+
]);
|
|
188
|
+
// Nothing can depend on an output in turn.
|
|
189
|
+
expect(outboundEdges(g, "output.assets_bucket").map((e) => e.to)).toEqual(["aws_s3_bucket.assets"]);
|
|
190
|
+
expect(inboundEdges(g, "output.assets_bucket")).toEqual([]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("an output referencing a var or a data source contributes no edge", () => {
|
|
194
|
+
const tree: Hcl2JsonTree = {
|
|
195
|
+
data: { aws_ami: { ubuntu: [{ owners: ["1"] }] } },
|
|
196
|
+
resource: { aws_s3_bucket: { assets: [{ bucket: "x" }] } },
|
|
197
|
+
output: {
|
|
198
|
+
name: [{ value: "${var.name}" }],
|
|
199
|
+
ami: [{ value: "${data.aws_ami.ubuntu.id}" }],
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
expect(buildFixtureGraph(tree).edges).toEqual([]);
|
|
203
|
+
});
|
|
204
|
+
|
|
160
205
|
test("var / local references are not edges", () => {
|
|
161
206
|
const tree: Hcl2JsonTree = {
|
|
162
207
|
resource: {
|
package/src/terraform/graph.ts
CHANGED
|
@@ -81,9 +81,9 @@ export function refFromAccessor(accessor: string): RawRef | null {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
/**
|
|
84
|
-
* Every string value carrying an interpolation across the tree's resource
|
|
85
|
-
* module blocks — exactly the expressions `parse.ts` must resolve
|
|
86
|
-
* AST before `buildGraph` can classify them.
|
|
84
|
+
* Every string value carrying an interpolation across the tree's resource,
|
|
85
|
+
* module and output blocks — exactly the expressions `parse.ts` must resolve
|
|
86
|
+
* through the AST before `buildGraph` can classify them.
|
|
87
87
|
*/
|
|
88
88
|
export function collectExpressions(tree: Hcl2JsonTree): string[] {
|
|
89
89
|
const exprs = new Set<string>();
|
|
@@ -98,6 +98,7 @@ export function collectExpressions(tree: Hcl2JsonTree): string[] {
|
|
|
98
98
|
};
|
|
99
99
|
for (const named of Object.values(tree.resource ?? {})) for (const blocks of Object.values(named)) visit(blocks);
|
|
100
100
|
for (const blocks of Object.values(tree.module ?? {})) visit(blocks);
|
|
101
|
+
for (const blocks of Object.values(tree.output ?? {})) visit(blocks);
|
|
101
102
|
return [...exprs].sort();
|
|
102
103
|
}
|
|
103
104
|
|
|
@@ -167,6 +168,12 @@ function literalIdentity(block: unknown, type: string): string | undefined {
|
|
|
167
168
|
* marks the referring node dynamic. An edge is recorded only when its target
|
|
168
169
|
* resolves to a known resource/module node — references to `var`/`local`/data
|
|
169
170
|
* are dropped.
|
|
171
|
+
*
|
|
172
|
+
* `output` blocks are not nodes either — nothing carves an output — but they
|
|
173
|
+
* do reference, and a reference to a carved resource breaks the surviving plan
|
|
174
|
+
* exactly like a resource's does. So an output contributes an edge tagged
|
|
175
|
+
* `fromKind: "output"` from the pseudo-address `output.<name>` (#1638),
|
|
176
|
+
* which the scorer weights lower and `carve bridge` patches.
|
|
170
177
|
*/
|
|
171
178
|
export function buildGraph(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): TfGraph {
|
|
172
179
|
const nodes: TfNode[] = [];
|
|
@@ -216,22 +223,34 @@ export function buildGraph(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): TfGrap
|
|
|
216
223
|
});
|
|
217
224
|
}
|
|
218
225
|
|
|
226
|
+
// Output blocks: referrers without being nodes (#1638). Their pseudo-address
|
|
227
|
+
// never joins `known`, so nothing can depend on an output in turn.
|
|
228
|
+
const outputRefs = new Map<string, RawRef[]>();
|
|
229
|
+
for (const [name, blocks] of Object.entries(tree.output ?? {})) {
|
|
230
|
+
const block = Array.isArray(blocks) ? blocks[0] : blocks;
|
|
231
|
+
outputRefs.set(`output.${name}`, refsInBlock(block, exprRefs));
|
|
232
|
+
}
|
|
233
|
+
|
|
219
234
|
// Edges: keep only references that resolve to a known node.
|
|
220
235
|
const known = new Set(nodes.map((n) => n.address));
|
|
221
236
|
const edges: TfEdge[] = [];
|
|
222
|
-
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
237
|
+
const collect = (source: Map<string, RawRef[]>, fromKind?: "output"): void => {
|
|
238
|
+
for (const [from, refs] of source) {
|
|
239
|
+
const byTarget = new Map<string, { attrs: Set<string>; via: Set<string> }>();
|
|
240
|
+
for (const ref of refs) {
|
|
241
|
+
if (ref.address === from || !known.has(ref.address)) continue;
|
|
242
|
+
if (!byTarget.has(ref.address)) byTarget.set(ref.address, { attrs: new Set(), via: new Set() });
|
|
243
|
+
const target = byTarget.get(ref.address)!;
|
|
244
|
+
if (ref.attr) target.attrs.add(ref.attr);
|
|
245
|
+
if (ref.via) target.via.add(ref.via);
|
|
246
|
+
}
|
|
247
|
+
for (const [to, { attrs, via }] of byTarget) {
|
|
248
|
+
edges.push({ from, to, attrs: [...attrs].sort(), via: [...via].sort(), ...(fromKind ? { fromKind } : {}) });
|
|
249
|
+
}
|
|
233
250
|
}
|
|
234
|
-
}
|
|
251
|
+
};
|
|
252
|
+
collect(rawRefsByNode);
|
|
253
|
+
collect(outputRefs, "output");
|
|
235
254
|
|
|
236
255
|
// Code-point ordering (not localeCompare) so output is locale-independent and
|
|
237
256
|
// punctuation sorts predictably (`.` < `_`).
|
|
@@ -242,7 +261,7 @@ export function buildGraph(tree: Hcl2JsonTree, exprRefs: ExpressionRefs): TfGrap
|
|
|
242
261
|
};
|
|
243
262
|
}
|
|
244
263
|
|
|
245
|
-
/** Edges where
|
|
264
|
+
/** Edges where something in the surviving Terraform depends on `address`. */
|
|
246
265
|
export function inboundEdges(graph: TfGraph, address: string): TfEdge[] {
|
|
247
266
|
return graph.edges.filter((e) => e.to === address);
|
|
248
267
|
}
|
package/src/terraform/parse.ts
CHANGED
|
@@ -68,7 +68,7 @@ async function resolveExpressionRefs(hcl2json: Hcl2Json, tree: Hcl2JsonTree): Pr
|
|
|
68
68
|
return refs;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
/** Deep-merge hcl2json trees across files (resource/module/data namespaces). */
|
|
71
|
+
/** Deep-merge hcl2json trees across files (resource/module/data/output namespaces). */
|
|
72
72
|
function mergeTrees(into: Hcl2JsonTree, next: Hcl2JsonTree): void {
|
|
73
73
|
for (const section of ["resource", "data"] as const) {
|
|
74
74
|
const src = next[section];
|
|
@@ -79,6 +79,9 @@ function mergeTrees(into: Hcl2JsonTree, next: Hcl2JsonTree): void {
|
|
|
79
79
|
}
|
|
80
80
|
}
|
|
81
81
|
if (next.module) into.module = { ...(into.module ?? {}), ...next.module };
|
|
82
|
+
// Outputs usually live in their own file (#1638) — merge them, or the graph
|
|
83
|
+
// never sees the estate's outputs.tf at all.
|
|
84
|
+
if (next.output) into.output = { ...(into.output ?? {}), ...next.output };
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
/** List every `.tf` file directly under `dir` (non-recursive; matches Terraform's own module scoping). */
|
|
@@ -115,6 +115,31 @@ describe("scoreEstate — penalties", () => {
|
|
|
115
115
|
expect(map["aws_sns_topic.alerts"].score).toBe(88); // 100 - 12*1 inbound
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
+
test("an output block reading a resource costs 4, not 12 (#1638)", () => {
|
|
119
|
+
const tree: Hcl2JsonTree = {
|
|
120
|
+
resource: { aws_s3_bucket: { assets: [{ bucket: "b" }] } },
|
|
121
|
+
output: { assets_bucket: [{ value: "${aws_s3_bucket.assets.bucket}" }] },
|
|
122
|
+
};
|
|
123
|
+
const bucket = byAddress(scoreEstate(buildFixtureGraph(tree)))["aws_s3_bucket.assets"];
|
|
124
|
+
expect(bucket.score).toBe(96); // 100 - 4*1 output
|
|
125
|
+
expect(bucket.breakdown.outputs).toBe(1);
|
|
126
|
+
expect(bucket.breakdown.inbound).toBe(0); // not counted as a data-source patch
|
|
127
|
+
expect(bucket.breakdown.penalties.outputs).toBe(-4);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("an output and a resource reference are counted apart", () => {
|
|
131
|
+
const tree: Hcl2JsonTree = {
|
|
132
|
+
resource: {
|
|
133
|
+
aws_s3_bucket: { assets: [{ bucket: "b" }] },
|
|
134
|
+
aws_lambda_function: { api: [{ environment: { variables: { B: "${aws_s3_bucket.assets.bucket}" } } }] },
|
|
135
|
+
},
|
|
136
|
+
output: { assets_arn: [{ value: "${aws_s3_bucket.assets.arn}" }] },
|
|
137
|
+
};
|
|
138
|
+
const bucket = byAddress(scoreEstate(buildFixtureGraph(tree)))["aws_s3_bucket.assets"];
|
|
139
|
+
expect(bucket.breakdown).toMatchObject({ inbound: 1, outputs: 1 });
|
|
140
|
+
expect(bucket.score).toBe(84); // 100 - 12 - 4
|
|
141
|
+
});
|
|
142
|
+
|
|
118
143
|
test("results are ranked most-peelable first", () => {
|
|
119
144
|
const tree: Hcl2JsonTree = {
|
|
120
145
|
resource: {
|