@intentius/chant-lexicon-aws 0.109.0 → 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.
- package/dist/condition.d.ts +12 -1
- package/dist/condition.d.ts.map +1 -1
- package/dist/import/generator.d.ts +69 -0
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/intrinsics.d.ts +58 -6
- package/dist/intrinsics.d.ts.map +1 -1
- package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
- package/dist/lint/post-synth/waw042.d.ts.map +1 -1
- package/dist/manifest.json +22 -1
- package/dist/mapping.d.ts +27 -0
- package/dist/mapping.d.ts.map +1 -0
- package/dist/parameter.d.ts +16 -1
- package/dist/parameter.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/cf-refs.ts +12 -2
- package/dist/rules/waw042.ts +25 -4
- package/dist/serializer.d.ts.map +1 -1
- package/dist/template-sections.d.ts +44 -0
- package/dist/template-sections.d.ts.map +1 -0
- package/package.json +2 -2
- package/src/condition.test.ts +31 -0
- package/src/condition.ts +15 -1
- package/src/import/generator.test.ts +24 -3
- package/src/import/generator.ts +322 -49
- package/src/import/parser.test.ts +0 -1
- package/src/import/parser.ts +74 -21
- package/src/import/roundtrip-build.test.ts +322 -0
- package/src/import/roundtrip-fixtures.test.ts +3 -3
- package/src/index.ts +10 -0
- package/src/intrinsics.test.ts +7 -2
- package/src/intrinsics.ts +102 -13
- package/src/lint/post-synth/cf-refs.ts +12 -2
- package/src/lint/post-synth/waw019.test.ts +32 -0
- package/src/lint/post-synth/waw042.test.ts +28 -0
- package/src/lint/post-synth/waw042.ts +25 -4
- package/src/mapping.test.ts +64 -0
- package/src/mapping.ts +41 -0
- package/src/parameter.ts +22 -1
- package/src/plugin.test.ts +2 -1
- package/src/plugin.ts +3 -0
- package/src/serializer.ts +79 -12
- package/src/template-sections.ts +71 -0
package/src/import/parser.ts
CHANGED
|
@@ -27,7 +27,12 @@ const cfnYamlTypes = [
|
|
|
27
27
|
}),
|
|
28
28
|
new yaml.Type("!GetAtt", {
|
|
29
29
|
kind: "scalar",
|
|
30
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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 "
|
|
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
|
|
package/src/intrinsics.test.ts
CHANGED
|
@@ -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": [
|
|
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": [
|
|
151
|
+
"Fn::Select": [1, [{ Ref: "A" }, { Ref: "B" }]],
|
|
147
152
|
});
|
|
148
153
|
});
|
|
149
154
|
});
|