@intentius/chant-lexicon-aws 0.108.1 → 0.110.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/condition.d.ts +12 -1
  2. package/dist/condition.d.ts.map +1 -1
  3. package/dist/import/generator.d.ts +69 -0
  4. package/dist/import/generator.d.ts.map +1 -1
  5. package/dist/import/parser.d.ts.map +1 -1
  6. package/dist/index.d.ts +4 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/integrity.json +4 -4
  9. package/dist/intrinsics.d.ts +58 -6
  10. package/dist/intrinsics.d.ts.map +1 -1
  11. package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
  12. package/dist/lint/post-synth/waw042.d.ts.map +1 -1
  13. package/dist/manifest.json +22 -1
  14. package/dist/mapping.d.ts +27 -0
  15. package/dist/mapping.d.ts.map +1 -0
  16. package/dist/parameter.d.ts +16 -1
  17. package/dist/parameter.d.ts.map +1 -1
  18. package/dist/plugin.d.ts.map +1 -1
  19. package/dist/rules/cf-refs.ts +12 -2
  20. package/dist/rules/waw042.ts +25 -4
  21. package/dist/serializer.d.ts.map +1 -1
  22. package/dist/template-sections.d.ts +44 -0
  23. package/dist/template-sections.d.ts.map +1 -0
  24. package/package.json +2 -2
  25. package/src/condition.test.ts +31 -0
  26. package/src/condition.ts +15 -1
  27. package/src/import/generator.test.ts +24 -3
  28. package/src/import/generator.ts +322 -49
  29. package/src/import/parser.test.ts +0 -1
  30. package/src/import/parser.ts +74 -21
  31. package/src/import/roundtrip-build.test.ts +322 -0
  32. package/src/import/roundtrip-fixtures.test.ts +3 -3
  33. package/src/index.ts +10 -0
  34. package/src/intrinsics.test.ts +7 -2
  35. package/src/intrinsics.ts +102 -13
  36. package/src/lint/post-synth/cf-refs.ts +12 -2
  37. package/src/lint/post-synth/waw019.test.ts +32 -0
  38. package/src/lint/post-synth/waw042.test.ts +28 -0
  39. package/src/lint/post-synth/waw042.ts +25 -4
  40. package/src/mapping.test.ts +64 -0
  41. package/src/mapping.ts +41 -0
  42. package/src/parameter.ts +22 -1
  43. package/src/plugin.test.ts +2 -1
  44. package/src/plugin.ts +3 -0
  45. package/src/serializer.ts +79 -12
  46. package/src/template-sections.ts +71 -0
@@ -27,7 +27,12 @@ const cfnYamlTypes = [
27
27
  }),
28
28
  new yaml.Type("!GetAtt", {
29
29
  kind: "scalar",
30
- construct: (data: string) => ({ "Fn::GetAtt": data.split(".") }),
30
+ // `!GetAtt Db.Endpoint.Address` names the attribute `Endpoint.Address`:
31
+ // the logical id ends at the first dot.
32
+ construct: (data: string) => {
33
+ const dot = data.indexOf(".");
34
+ return { "Fn::GetAtt": dot < 0 ? [data] : [data.slice(0, dot), data.slice(dot + 1)] };
35
+ },
31
36
  }),
32
37
  new yaml.Type("!GetAtt", {
33
38
  kind: "sequence",
@@ -115,6 +120,8 @@ const CF_SCHEMA = yaml.DEFAULT_SCHEMA.extend(cfnYamlTypes);
115
120
  interface CFTemplate {
116
121
  AWSTemplateFormatVersion?: string;
117
122
  Description?: string;
123
+ Metadata?: Record<string, unknown>;
124
+ Mappings?: Record<string, Record<string, Record<string, unknown>>>;
118
125
  Parameters?: Record<string, CFParameter>;
119
126
  Conditions?: Record<string, unknown>;
120
127
  Resources?: Record<string, CFResource>;
@@ -138,8 +145,24 @@ interface CFParameter {
138
145
  Type: string;
139
146
  Description?: string;
140
147
  Default?: unknown;
148
+ [constraint: string]: unknown;
141
149
  }
142
150
 
151
+ /** Parameter keys import carries besides Type, Description and Default. */
152
+ const PARAMETER_CONSTRAINT_KEYS = [
153
+ "AllowedValues",
154
+ "AllowedPattern",
155
+ "ConstraintDescription",
156
+ "MinLength",
157
+ "MaxLength",
158
+ "MinValue",
159
+ "MaxValue",
160
+ "NoEcho",
161
+ ] as const;
162
+
163
+ /** Resource attributes import carries besides Type, Properties, Metadata and Condition. */
164
+ const RESOURCE_ATTRIBUTE_KEYS = ["DependsOn", "DeletionPolicy", "UpdateReplacePolicy", "UpdatePolicy", "CreationPolicy"] as const;
165
+
143
166
  /**
144
167
  * CloudFormation resource
145
168
  */
@@ -149,6 +172,10 @@ interface CFResource {
149
172
  Metadata?: Record<string, unknown>;
150
173
  DependsOn?: string | string[];
151
174
  Condition?: string;
175
+ DeletionPolicy?: string;
176
+ UpdateReplacePolicy?: string;
177
+ UpdatePolicy?: unknown;
178
+ CreationPolicy?: unknown;
152
179
  }
153
180
 
154
181
  /**
@@ -184,6 +211,8 @@ export class CFParser extends BaseValueParser implements TemplateParser {
184
211
  metadata: {
185
212
  version: template.AWSTemplateFormatVersion ?? "2010-09-09",
186
213
  description: template.Description,
214
+ ...(template.Metadata !== undefined ? { templateMetadata: this.parseValue(template.Metadata) } : {}),
215
+ ...(template.Mappings !== undefined ? { mappings: template.Mappings } : {}),
187
216
  },
188
217
  };
189
218
  }
@@ -195,6 +224,8 @@ export class CFParser extends BaseValueParser implements TemplateParser {
195
224
  private static readonly CARRIED_SECTIONS = new Set([
196
225
  "AWSTemplateFormatVersion",
197
226
  "Description",
227
+ "Metadata",
228
+ "Mappings",
198
229
  "Parameters",
199
230
  "Conditions",
200
231
  "Resources",
@@ -250,13 +281,19 @@ export class CFParser extends BaseValueParser implements TemplateParser {
250
281
  * Parse parameters section
251
282
  */
252
283
  private parseParameters(params: Record<string, CFParameter>): ParameterIR[] {
253
- return Object.entries(params).map(([name, param]) => ({
254
- name,
255
- type: param.Type,
256
- description: param.Description,
257
- defaultValue: param.Default,
258
- required: param.Default === undefined,
259
- }));
284
+ return Object.entries(params).map(([name, param]) => {
285
+ const constraints = Object.fromEntries(
286
+ PARAMETER_CONSTRAINT_KEYS.filter((k) => param[k] !== undefined).map((k) => [k, param[k]]),
287
+ );
288
+ return {
289
+ name,
290
+ type: param.Type,
291
+ description: param.Description,
292
+ defaultValue: param.Default,
293
+ required: param.Default === undefined,
294
+ ...(Object.keys(constraints).length > 0 ? { constraints } : {}),
295
+ };
296
+ });
260
297
  }
261
298
 
262
299
  /**
@@ -265,13 +302,19 @@ export class CFParser extends BaseValueParser implements TemplateParser {
265
302
  private parseResources(resources: Record<string, CFResource>): ResourceIR[] {
266
303
  return Object.entries(resources)
267
304
  .filter(([_, resource]) => typeof resource?.Type === "string")
268
- .map(([logicalId, resource]) => ({
269
- logicalId,
270
- type: resource.Type,
271
- properties: this.parseProperties(resource.Properties ?? {}),
272
- metadata: resource.Metadata,
273
- condition: typeof resource.Condition === "string" ? resource.Condition : undefined,
274
- }));
305
+ .map(([logicalId, resource]) => {
306
+ const attributes = Object.fromEntries(
307
+ RESOURCE_ATTRIBUTE_KEYS.filter((k) => resource[k] !== undefined).map((k) => [k, this.parseValue(resource[k])]),
308
+ );
309
+ return {
310
+ logicalId,
311
+ type: resource.Type,
312
+ properties: this.parseProperties(resource.Properties ?? {}),
313
+ metadata: resource.Metadata !== undefined ? (this.parseValue(resource.Metadata) as Record<string, unknown>) : undefined,
314
+ condition: typeof resource.Condition === "string" ? resource.Condition : undefined,
315
+ ...(Object.keys(attributes).length > 0 ? { attributes } : {}),
316
+ };
317
+ });
275
318
  }
276
319
 
277
320
  /**
@@ -305,9 +348,9 @@ export class CFParser extends BaseValueParser implements TemplateParser {
305
348
  if (Array.isArray(value) && value.length === 2) {
306
349
  return { __intrinsic: "GetAtt", logicalId: value[0], attribute: value[1] };
307
350
  }
308
- if (typeof value === "string") {
309
- const [logicalId, attribute] = value.split(".", 2);
310
- return { __intrinsic: "GetAtt", logicalId, attribute };
351
+ if (typeof value === "string" && value.indexOf(".") > 0) {
352
+ const dot = value.indexOf(".");
353
+ return { __intrinsic: "GetAtt", logicalId: value.slice(0, dot), attribute: value.slice(dot + 1) };
311
354
  }
312
355
  }
313
356
 
@@ -344,8 +387,12 @@ export class CFParser extends BaseValueParser implements TemplateParser {
344
387
  }
345
388
 
346
389
  if (key === "Fn::Select") {
390
+ // The index is kept as written (0 or "0"). A list source is `values`;
391
+ // anything else (a Ref to a list parameter, Fn::GetAZs, Fn::Split) is
392
+ // `source`, the list itself. Wrapping it in a list would select the
393
+ // list rather than an item of it.
347
394
  const selectValue = value as [string | number, unknown];
348
- const index = Number(selectValue[0]);
395
+ const index = selectValue[0];
349
396
  const source = selectValue[1];
350
397
  if (Array.isArray(source)) {
351
398
  return {
@@ -357,7 +404,7 @@ export class CFParser extends BaseValueParser implements TemplateParser {
357
404
  return {
358
405
  __intrinsic: "Select",
359
406
  index,
360
- values: [this.parseValue(source)],
407
+ source: this.parseValue(source),
361
408
  };
362
409
  }
363
410
 
@@ -376,9 +423,15 @@ export class CFParser extends BaseValueParser implements TemplateParser {
376
423
 
377
424
  if (key === "Fn::FindInMap") {
378
425
  const mapValue = value as unknown[];
426
+ if (!Array.isArray(mapValue) || mapValue.length !== 3) {
427
+ throw new Error(
428
+ `Fn::FindInMap takes [MapName, TopLevelKey, SecondLevelKey]; this one has ${JSON.stringify(value)} ` +
429
+ "(a fourth DefaultValue argument needs AWS::LanguageExtensions, which import does not carry)",
430
+ );
431
+ }
379
432
  return {
380
433
  __intrinsic: "FindInMap",
381
- mapName: mapValue[0],
434
+ mapName: this.parseValue(mapValue[0]),
382
435
  firstKey: this.parseValue(mapValue[1]),
383
436
  secondKey: this.parseValue(mapValue[2]),
384
437
  };
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Import, then build the generated source, then compare with the template
3
+ * imported. Each case is a shape from a real template that did not come back.
4
+ */
5
+ import { describe, test, expect } from "vitest";
6
+ import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from "fs";
7
+ import { join } from "path";
8
+ import { CFParser } from "./parser";
9
+ import { CFGenerator } from "./generator";
10
+ import { build } from "@intentius/chant/build";
11
+ import { generateOrganizedFiles } from "@intentius/chant/cli/commands/import";
12
+ import { awsSerializer } from "../serializer";
13
+
14
+ const parser = new CFParser();
15
+ const generator = new CFGenerator();
16
+
17
+ /** Import `content` with the layout `chant import` uses, build what it wrote, and return the built template. */
18
+ async function roundTrip(content: string): Promise<{ template: Record<string, any>; source: string }> {
19
+ const { files } = generateOrganizedFiles(parser.parse(content), generator);
20
+ expect(files.map((f) => f.path)).toEqual(["main.ts"]);
21
+ const source = files[0].content;
22
+ const dir = mkdtempSync(join(import.meta.dirname, "../../.roundtrip-tmp-"));
23
+ try {
24
+ const srcDir = join(dir, "src");
25
+ mkdirSync(srcDir);
26
+ writeFileSync(join(srcDir, "main.ts"), source);
27
+ const result = await build(srcDir, [awsSerializer]);
28
+ expect(result.errors.map((e) => e.message)).toEqual([]);
29
+ return { template: JSON.parse(result.outputs.get("aws") as string), source };
30
+ } finally {
31
+ rmSync(dir, { recursive: true, force: true });
32
+ }
33
+ }
34
+
35
+ describe("import layout", () => {
36
+ test("the generator writes the whole template to one main.ts", () => {
37
+ expect(generator.ownsLayout).toBe(true);
38
+ });
39
+
40
+ test("a template of more than three resources across categories builds from one file", async () => {
41
+ const original = {
42
+ Parameters: { AppName: { Type: "String" } },
43
+ Resources: {
44
+ MyCluster: { Type: "AWS::ECS::Cluster", Properties: { ClusterName: { Ref: "AppName" } } },
45
+ MyBucket: { Type: "AWS::S3::Bucket", Properties: { BucketName: { Ref: "AppName" } } },
46
+ MyQueue: { Type: "AWS::SQS::Queue", Properties: { QueueName: { Ref: "AppName" } } },
47
+ MyTopic: { Type: "AWS::SNS::Topic", Properties: { TopicName: { "Fn::GetAtt": ["MyQueue", "Arn"] } } },
48
+ },
49
+ };
50
+ const { template } = await roundTrip(JSON.stringify(original));
51
+ expect(template.Resources).toEqual(original.Resources);
52
+ });
53
+ });
54
+
55
+ describe("Fn::Sub", () => {
56
+ test("a bare ${Name} of a parameter or resource, and a ${!Literal}, come back as written", async () => {
57
+ const sub = "${Env}-${MyBucket}-${MyBucket.Arn}-${AWS::Region}-${!Literal}";
58
+ const original = {
59
+ Parameters: { Env: { Type: "String" } },
60
+ Resources: {
61
+ MyBucket: { Type: "AWS::S3::Bucket" },
62
+ MyQueue: { Type: "AWS::SQS::Queue", Properties: { QueueName: { "Fn::Sub": sub } } },
63
+ },
64
+ };
65
+ const { template, source } = await roundTrip(JSON.stringify(original));
66
+ expect(source).toContain("${Ref(Env)}-${Ref(MyBucket)}-${MyBucket.Arn}");
67
+ expect(template.Resources.MyQueue.Properties.QueueName).toEqual({ "Fn::Sub": sub });
68
+ });
69
+
70
+ test("a dotted name the class does not expose stays a ${Id.Attr} reference", async () => {
71
+ const sub = "queue ${MyQueue.QueueName}";
72
+ const original = {
73
+ Resources: {
74
+ MyQueue: { Type: "AWS::SQS::Queue" },
75
+ MyTopic: { Type: "AWS::SNS::Topic", Properties: { DisplayName: { "Fn::Sub": sub } } },
76
+ },
77
+ };
78
+ const { template } = await roundTrip(JSON.stringify(original));
79
+ expect(template.Resources.MyTopic.Properties.DisplayName).toEqual({ "Fn::Sub": sub });
80
+ });
81
+ });
82
+
83
+ describe("Fn::GetAtt", () => {
84
+ test("an attribute the class does not list is written by name, and the output keeps it", async () => {
85
+ const original = {
86
+ Resources: {
87
+ MyQueue: { Type: "AWS::SQS::Queue" },
88
+ MyTopic: { Type: "AWS::SNS::Topic", Properties: { DisplayName: { "Fn::GetAtt": ["MyQueue", "QueueName"] } } },
89
+ },
90
+ Outputs: {
91
+ Name: { Value: { "Fn::GetAtt": ["MyQueue", "QueueName"] } },
92
+ Arn: { Value: { "Fn::GetAtt": ["MyQueue", "Arn"] } },
93
+ },
94
+ };
95
+ const { template, source } = await roundTrip(JSON.stringify(original));
96
+ expect(source).toContain('GetAtt("MyQueue", "QueueName")');
97
+ expect(template.Resources.MyTopic.Properties).toEqual(original.Resources.MyTopic.Properties);
98
+ expect(template.Outputs).toEqual(original.Outputs);
99
+ });
100
+
101
+ test("YAML !GetAtt Db.Endpoint.Address names the attribute Endpoint.Address", async () => {
102
+ const yaml = [
103
+ "Resources:",
104
+ " MyDB:",
105
+ " Type: AWS::RDS::DBInstance",
106
+ " Properties:",
107
+ " DBInstanceClass: db.t3.micro",
108
+ " Engine: mysql",
109
+ " StorageEncrypted: true",
110
+ " BackupRetentionPeriod: 7",
111
+ "Outputs:",
112
+ " Address:",
113
+ " Value: !Join ['', ['jdbc:mysql://', !GetAtt MyDB.Endpoint.Address, ':', !GetAtt MyDB.Endpoint.Port]]",
114
+ ].join("\n");
115
+ const ir = parser.parse(yaml);
116
+ expect(ir.outputs?.[0].value).toMatchObject({
117
+ values: [
118
+ "jdbc:mysql://",
119
+ { __intrinsic: "GetAtt", logicalId: "MyDB", attribute: "Endpoint.Address" },
120
+ ":",
121
+ { __intrinsic: "GetAtt", logicalId: "MyDB", attribute: "Endpoint.Port" },
122
+ ],
123
+ });
124
+ const { template } = await roundTrip(yaml);
125
+ expect(template.Outputs.Address.Value).toEqual({
126
+ "Fn::Join": [
127
+ "",
128
+ ["jdbc:mysql://", { "Fn::GetAtt": ["MyDB", "Endpoint.Address"] }, ":", { "Fn::GetAtt": ["MyDB", "Endpoint.Port"] }],
129
+ ],
130
+ });
131
+ });
132
+ });
133
+
134
+ describe("what import used to drop", () => {
135
+ test("resource attributes come back: DependsOn, the policies, and Metadata with an intrinsic in it", async () => {
136
+ const original = {
137
+ Resources: {
138
+ MyQueue: {
139
+ Type: "AWS::SQS::Queue",
140
+ DeletionPolicy: "Retain",
141
+ UpdateReplacePolicy: "Retain",
142
+ Metadata: { Note: { "Fn::Sub": "${AWS::StackName}-queue" } },
143
+ },
144
+ MyDB: {
145
+ Type: "AWS::RDS::DBInstance",
146
+ DependsOn: ["MyQueue", "MyGroup"],
147
+ DeletionPolicy: "Snapshot",
148
+ UpdateReplacePolicy: "Snapshot",
149
+ Properties: { DBInstanceClass: "db.t3.micro", Engine: "mysql", StorageEncrypted: true, BackupRetentionPeriod: 7 },
150
+ },
151
+ MyGroup: {
152
+ Type: "AWS::AutoScaling::AutoScalingGroup",
153
+ DependsOn: "MyQueue",
154
+ CreationPolicy: { ResourceSignal: { Count: 1, Timeout: "PT15M" } },
155
+ UpdatePolicy: { AutoScalingRollingUpdate: { MinInstancesInService: 1 } },
156
+ Properties: { MinSize: "1", MaxSize: "2", AvailabilityZones: ["us-east-1a"] },
157
+ },
158
+ },
159
+ };
160
+ const { template, source } = await roundTrip(JSON.stringify(original));
161
+ expect(source).toContain("DependsOn: [MyQueue, MyGroup]");
162
+ expect(template.Resources).toEqual(original.Resources);
163
+ });
164
+
165
+ test("the template's Description and Metadata come back", async () => {
166
+ const original = {
167
+ Description: "A queue",
168
+ Metadata: { "cfn-lint": { config: { ignore_checks: ["W3005"] } }, Stack: { Ref: "AWS::StackName" } },
169
+ Resources: { MyQueue: { Type: "AWS::SQS::Queue" } },
170
+ };
171
+ const ir = parser.parse(JSON.stringify(original));
172
+ expect(ir.warnings).toBeUndefined();
173
+ const { template } = await roundTrip(JSON.stringify(original));
174
+ expect(template.Description).toBe(original.Description);
175
+ expect(template.Metadata).toEqual(original.Metadata);
176
+ });
177
+
178
+ test("parameter constraints come back as written", async () => {
179
+ const original = {
180
+ Parameters: {
181
+ Name: {
182
+ Type: "String",
183
+ AllowedPattern: "[a-z]+",
184
+ ConstraintDescription: "lower case",
185
+ MinLength: "1",
186
+ MaxLength: 64,
187
+ NoEcho: true,
188
+ },
189
+ Size: { Type: "Number", Default: 5, MinValue: 1, MaxValue: "10", AllowedValues: [1, 5, 10] },
190
+ },
191
+ Resources: { MyQueue: { Type: "AWS::SQS::Queue", Properties: { QueueName: { Ref: "Name" }, DelaySeconds: { Ref: "Size" } } } },
192
+ };
193
+ const { template } = await roundTrip(JSON.stringify(original));
194
+ expect(template.Parameters).toEqual(original.Parameters);
195
+ });
196
+
197
+ test("Fn::Select of a list parameter selects from the list, and the index is kept as written", async () => {
198
+ const original = {
199
+ Parameters: { Subnets: { Type: "List<AWS::EC2::Subnet::Id>" } },
200
+ Resources: {
201
+ MyInstance: { Type: "AWS::EC2::Instance", Properties: { SubnetId: { "Fn::Select": [0, { Ref: "Subnets" }] } } },
202
+ MyOther: {
203
+ Type: "AWS::EC2::Instance",
204
+ Properties: { SubnetId: { "Fn::Select": ["1", { Ref: "Subnets" }] }, AvailabilityZone: { "Fn::Select": [0, { "Fn::GetAZs": "" }] } },
205
+ },
206
+ },
207
+ };
208
+ const { template } = await roundTrip(JSON.stringify(original));
209
+ expect(template.Resources).toEqual(original.Resources);
210
+ });
211
+
212
+ test("a logical id that is a class name keeps its name", async () => {
213
+ const original = {
214
+ Resources: {
215
+ InternetGateway: { Type: "AWS::EC2::InternetGateway" },
216
+ Attachment: {
217
+ Type: "AWS::EC2::VPCGatewayAttachment",
218
+ Properties: { InternetGatewayId: { Ref: "InternetGateway" }, VpcId: "vpc-1" },
219
+ },
220
+ },
221
+ };
222
+ const { template, source } = await roundTrip(JSON.stringify(original));
223
+ expect(source).toContain("InternetGateway as InternetGatewayResource");
224
+ expect(template.Resources).toEqual(original.Resources);
225
+ });
226
+ });
227
+
228
+ describe("one name in two CloudFormation namespaces", () => {
229
+ test("an output named like a parameter or a resource, and a condition named like a parameter, come back under their names", async () => {
230
+ const original = {
231
+ Parameters: {
232
+ QueueName: { Type: "String" },
233
+ EnableReplica: { Type: "String", Default: "false" },
234
+ },
235
+ Conditions: {
236
+ EnableReplica: { "Fn::Equals": [{ Ref: "EnableReplica" }, "true"] },
237
+ },
238
+ Resources: {
239
+ MyQueue: { Type: "AWS::SQS::Queue", Properties: { QueueName: { Ref: "QueueName" } } },
240
+ Replica: {
241
+ Type: "AWS::SQS::Queue",
242
+ Condition: "EnableReplica",
243
+ Properties: { DelaySeconds: { "Fn::If": ["EnableReplica", 5, 0] } },
244
+ },
245
+ },
246
+ Outputs: {
247
+ QueueName: { Value: { "Fn::GetAtt": ["MyQueue", "QueueName"] } },
248
+ MyQueue: { Value: { Ref: "MyQueue" }, Condition: "EnableReplica" },
249
+ },
250
+ };
251
+ const { template, source } = await roundTrip(JSON.stringify(original));
252
+ expect(source).toContain('export const EnableReplicaCondition = new Condition(');
253
+ expect(source).toContain('name: "EnableReplica" })');
254
+ expect(source).toContain("export const QueueNameOutput = stackOutput(");
255
+ expect(source).toContain("export const MyQueueOutput = stackOutput(");
256
+ expect(template.Parameters).toEqual(original.Parameters);
257
+ expect(template.Conditions).toEqual(original.Conditions);
258
+ expect(template.Resources).toEqual(original.Resources);
259
+ expect(template.Outputs).toEqual(original.Outputs);
260
+ });
261
+ });
262
+
263
+ describe("Mappings and Fn::FindInMap", () => {
264
+ test("a template's Mappings, and FindInMap reads of them, come back", async () => {
265
+ const original = {
266
+ Parameters: { InstanceType: { Type: "String", Default: "t3.micro" } },
267
+ Mappings: {
268
+ RegionMap: { "us-east-1": { AZs: ["us-east-1a", "us-east-1b"] } },
269
+ TypeArch: { "t3.micro": { Arch: "HVM64" } },
270
+ ArchAmi: { "us-east-1": { HVM64: "ami-123" } },
271
+ },
272
+ Resources: {
273
+ MySubnet: {
274
+ Type: "AWS::EC2::Subnet",
275
+ Properties: {
276
+ VpcId: "vpc-1",
277
+ CidrBlock: { "Fn::Select": [0, { "Fn::Cidr": ["10.0.0.0/16", 4, 8] }] },
278
+ AvailabilityZone: { "Fn::Select": [0, { "Fn::FindInMap": ["RegionMap", { Ref: "AWS::Region" }, "AZs"] }] },
279
+ },
280
+ },
281
+ MyInstance: {
282
+ Type: "AWS::EC2::Instance",
283
+ Properties: {
284
+ ImageId: {
285
+ "Fn::FindInMap": ["ArchAmi", { Ref: "AWS::Region" }, { "Fn::FindInMap": ["TypeArch", { Ref: "InstanceType" }, "Arch"] }],
286
+ },
287
+ SubnetId: { "Fn::ImportValue": { "Fn::Sub": "${AWS::StackName}-SubnetId" } },
288
+ },
289
+ },
290
+ },
291
+ };
292
+ const ir = parser.parse(JSON.stringify(original));
293
+ expect(ir.warnings).toBeUndefined();
294
+ const { template, source } = await roundTrip(JSON.stringify(original));
295
+ expect(source).toContain("export const RegionMap = new Mapping(");
296
+ expect(source).toContain('FindInMap(ArchAmi, AWS.Region, FindInMap(TypeArch, Ref(InstanceType), "Arch"))');
297
+ expect(template.Mappings).toEqual(original.Mappings);
298
+ expect(template.Resources).toEqual(original.Resources);
299
+ });
300
+
301
+ test("a mapping named like a resource keeps its name", async () => {
302
+ const original = {
303
+ Mappings: { Config: { a: { b: "queue-name" } } },
304
+ Resources: {
305
+ Config: { Type: "AWS::SQS::Queue", Properties: { QueueName: { "Fn::FindInMap": ["Config", "a", "b"] } } },
306
+ },
307
+ };
308
+ const { template, source } = await roundTrip(JSON.stringify(original));
309
+ expect(source).toContain('export const ConfigMapping = new Mapping(');
310
+ expect(template.Mappings).toEqual(original.Mappings);
311
+ expect(template.Resources).toEqual(original.Resources);
312
+ });
313
+
314
+ test("an intrinsic import cannot write is refused at import, not written as an object", () => {
315
+ const original = {
316
+ Resources: {
317
+ MyQueue: { Type: "AWS::SQS::Queue", Properties: { Tags: { "Fn::Transform": { Name: "AWS::Include", Parameters: {} } } } },
318
+ },
319
+ };
320
+ expect(() => generator.generate(parser.parse(JSON.stringify(original)))).toThrow(/cannot generate Fn::Transform/);
321
+ });
322
+ });
@@ -125,7 +125,7 @@ describe("with-conditions.json build roundtrip (#2069)", () => {
125
125
  expect(mainFile.content).toContain("new Condition(Equals(Ref(Cutover), \"true\"))");
126
126
  expect(mainFile.content).toContain("new Condition(Not(DoCutover))");
127
127
  expect(mainFile.content).toContain("{ Condition: DoCutover }");
128
- expect(mainFile.content).toContain("stackOutput(Ref(Rule), { condition: DoCutover })");
128
+ expect(mainFile.content).toContain('stackOutput(Ref(Rule), { condition: DoCutover, lexicon: "aws" })');
129
129
 
130
130
  const dir = mkdtempSync(join(import.meta.dirname, "../../.roundtrip-tmp-"));
131
131
  try {
@@ -154,10 +154,10 @@ describe("with-conditions.json build roundtrip (#2069)", () => {
154
154
 
155
155
  test("a section import cannot carry is named in a warning, never dropped silently", () => {
156
156
  const template = JSON.parse(readFileSync(join(roundtripDir, "with-conditions.json"), "utf-8"));
157
- template.Mappings = { RegionMap: { "us-east-1": { AMI: "ami-123" } } };
157
+ template.Rules = { Region: { Assertions: [{ Assert: { "Fn::Equals": [{ Ref: "AWS::Region" }, "us-east-1"] } }] } };
158
158
  const ir = parser.parse(JSON.stringify(template));
159
159
  expect(ir.warnings).toEqual([
160
- 'Template section "Mappings" is not carried by import — it is dropped from the generated source',
160
+ 'Template section "Rules" is not carried by import — it is dropped from the generated source',
161
161
  ]);
162
162
  });
163
163
  });
package/src/index.ts CHANGED
@@ -3,12 +3,16 @@ export { Parameter } from "./parameter";
3
3
 
4
4
  // Condition (#2068) — the Conditions-section declarable
5
5
  export { Condition, isCondition, CONDITION_ENTITY_TYPE } from "./condition";
6
+ // Mapping — the Mappings-section declarable, read with FindInMap
7
+ export { Mapping, isMapping, MAPPING_ENTITY_TYPE } from "./mapping";
6
8
 
7
9
  // Default Tags
8
10
  export { defaultTags, isDefaultTags, DEFAULT_TAGS_MARKER } from "./default-tags";
9
11
  export type { DefaultTags, TagEntry } from "./default-tags";
10
12
  export { templateTransform, isTemplateTransform, TEMPLATE_TRANSFORM_MARKER } from "./template-transform";
11
13
  export type { TemplateTransform } from "./template-transform";
14
+ export { templateDescription, templateMetadata, isTemplateSection, TEMPLATE_SECTION_MARKER } from "./template-sections";
15
+ export type { TemplateSection } from "./template-sections";
12
16
 
13
17
  // Effect receipts (#1835, epic #1703): the aws materialization row
14
18
  // (`AWS::SSM::Parameter`, plain String, path derived from the ownership
@@ -125,6 +129,12 @@ export {
125
129
  AndIntrinsic,
126
130
  OrIntrinsic,
127
131
  NotIntrinsic,
132
+ FindInMap,
133
+ FindInMapIntrinsic,
134
+ ImportValue,
135
+ ImportValueIntrinsic,
136
+ Cidr,
137
+ CidrIntrinsic,
128
138
  } from "./intrinsics";
129
139
  export type { ConditionOperand } from "./intrinsics";
130
140
 
@@ -137,13 +137,18 @@ describe("Join intrinsic", () => {
137
137
  describe("Select intrinsic", () => {
138
138
  test("selects from array", () => {
139
139
  const result = Select(0, ["a", "b", "c"]);
140
- expect(result.toJSON()).toEqual({ "Fn::Select": ["0", ["a", "b", "c"]] });
140
+ expect(result.toJSON()).toEqual({ "Fn::Select": [0, ["a", "b", "c"]] });
141
+ });
142
+
143
+ test("keeps the index as written, number or string", () => {
144
+ expect(Select("0", ["a"]).toJSON()).toEqual({ "Fn::Select": ["0", ["a"]] });
145
+ expect(Select(2, Ref("Subnets")).toJSON()).toEqual({ "Fn::Select": [2, { Ref: "Subnets" }] });
141
146
  });
142
147
 
143
148
  test("selects with intrinsic array", () => {
144
149
  const result = Select(1, [Ref("A"), Ref("B")]);
145
150
  expect(result.toJSON()).toEqual({
146
- "Fn::Select": ["1", [{ Ref: "A" }, { Ref: "B" }]],
151
+ "Fn::Select": [1, [{ Ref: "A" }, { Ref: "B" }]],
147
152
  });
148
153
  });
149
154
  });