@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/intrinsics.ts
CHANGED
|
@@ -2,7 +2,8 @@ import { INTRINSIC_MARKER, resolveIntrinsicValue, isIntrinsic, type Intrinsic }
|
|
|
2
2
|
import { buildInterpolatedString, defaultInterpolationSerializer } from "@intentius/chant/intrinsic-interpolation";
|
|
3
3
|
import { type Declarable } from "@intentius/chant/declarable";
|
|
4
4
|
import { getLogicalName } from "@intentius/chant/utils";
|
|
5
|
-
import { isCondition, type Condition } from "./condition";
|
|
5
|
+
import { conditionName, isCondition, type Condition } from "./condition";
|
|
6
|
+
import { isMapping, mappingName, type Mapping } from "./mapping";
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* An operand allowed where CloudFormation expects a condition (#2068): a
|
|
@@ -17,7 +18,7 @@ function resolveConditionOperand(operand: ConditionOperand): unknown {
|
|
|
17
18
|
return { Condition: operand };
|
|
18
19
|
}
|
|
19
20
|
if (isCondition(operand)) {
|
|
20
|
-
return { Condition:
|
|
21
|
+
return { Condition: conditionName(operand) };
|
|
21
22
|
}
|
|
22
23
|
return resolveIntrinsicValue(operand);
|
|
23
24
|
}
|
|
@@ -37,10 +38,14 @@ export class SubIntrinsic implements Intrinsic {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
toJSON(): { "Fn::Sub": string } {
|
|
40
|
-
const
|
|
41
|
+
const base = defaultInterpolationSerializer(
|
|
41
42
|
(name, attr) => `\${${name}.${attr}}`,
|
|
42
43
|
(ref) => `\${${ref}}`,
|
|
43
44
|
);
|
|
45
|
+
// A GetAtt written by name (`GetAtt("Db", "Endpoint.Address")`) has the
|
|
46
|
+
// `${Db.Endpoint.Address}` form inside a Sub, like an attribute reference.
|
|
47
|
+
const serialize = (value: unknown): string =>
|
|
48
|
+
value instanceof GetAttIntrinsic ? `\${${value.logicalName}.${value.attribute}}` : base(value);
|
|
44
49
|
return { "Fn::Sub": buildInterpolatedString(this.templateParts, this.values, serialize) };
|
|
45
50
|
}
|
|
46
51
|
}
|
|
@@ -92,8 +97,8 @@ export function Ref(target: string | Declarable): RefIntrinsic {
|
|
|
92
97
|
*/
|
|
93
98
|
export class GetAttIntrinsic implements Intrinsic {
|
|
94
99
|
readonly [INTRINSIC_MARKER] = true as const;
|
|
95
|
-
|
|
96
|
-
|
|
100
|
+
readonly logicalName: string;
|
|
101
|
+
readonly attribute: string;
|
|
97
102
|
|
|
98
103
|
constructor(logicalName: string, attribute: string) {
|
|
99
104
|
this.logicalName = logicalName;
|
|
@@ -129,7 +134,7 @@ export class IfIntrinsic implements Intrinsic {
|
|
|
129
134
|
}
|
|
130
135
|
|
|
131
136
|
toJSON(): { "Fn::If": [string, unknown, unknown] } {
|
|
132
|
-
const name = typeof this.conditionName === "string" ? this.conditionName :
|
|
137
|
+
const name = typeof this.conditionName === "string" ? this.conditionName : conditionName(this.conditionName);
|
|
133
138
|
return { "Fn::If": [name, resolveIntrinsicValue(this.valueIfTrue), resolveIntrinsicValue(this.valueIfFalse)] };
|
|
134
139
|
}
|
|
135
140
|
}
|
|
@@ -292,26 +297,27 @@ export function Join(delimiter: string, values: unknown[] | Intrinsic): JoinIntr
|
|
|
292
297
|
*/
|
|
293
298
|
export class SelectIntrinsic implements Intrinsic {
|
|
294
299
|
readonly [INTRINSIC_MARKER] = true as const;
|
|
295
|
-
private index: number;
|
|
300
|
+
private index: number | string;
|
|
296
301
|
private values: unknown[] | Intrinsic;
|
|
297
302
|
|
|
298
|
-
constructor(index: number, values: unknown[] | Intrinsic) {
|
|
303
|
+
constructor(index: number | string, values: unknown[] | Intrinsic) {
|
|
299
304
|
this.index = index;
|
|
300
305
|
this.values = values;
|
|
301
306
|
}
|
|
302
307
|
|
|
303
|
-
toJSON(): { "Fn::Select": [string, unknown] } {
|
|
308
|
+
toJSON(): { "Fn::Select": [number | string, unknown] } {
|
|
304
309
|
const resolvedValues = Array.isArray(this.values)
|
|
305
310
|
? this.values.map(resolveIntrinsicValue)
|
|
306
|
-
: (this.values
|
|
307
|
-
return { "Fn::Select": [
|
|
311
|
+
: resolveIntrinsicValue(this.values);
|
|
312
|
+
return { "Fn::Select": [this.index, resolvedValues] };
|
|
308
313
|
}
|
|
309
314
|
}
|
|
310
315
|
|
|
311
316
|
/**
|
|
312
|
-
* Create a Select intrinsic
|
|
317
|
+
* Create a Select intrinsic. The index is emitted as written: CloudFormation
|
|
318
|
+
* accepts `0` and `"0"`.
|
|
313
319
|
*/
|
|
314
|
-
export function Select(index: number, values: unknown[] | Intrinsic): SelectIntrinsic {
|
|
320
|
+
export function Select(index: number | string, values: unknown[] | Intrinsic): SelectIntrinsic {
|
|
315
321
|
return new SelectIntrinsic(index, values);
|
|
316
322
|
}
|
|
317
323
|
|
|
@@ -397,3 +403,86 @@ export class GetAZsIntrinsic implements Intrinsic {
|
|
|
397
403
|
export function GetAZs(region?: string | Intrinsic): GetAZsIntrinsic {
|
|
398
404
|
return new GetAZsIntrinsic(region);
|
|
399
405
|
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Fn::FindInMap intrinsic function
|
|
409
|
+
* Reads the value under two keys of a declared mapping.
|
|
410
|
+
*/
|
|
411
|
+
export class FindInMapIntrinsic implements Intrinsic {
|
|
412
|
+
readonly [INTRINSIC_MARKER] = true as const;
|
|
413
|
+
private mapName: string | Mapping;
|
|
414
|
+
private topLevelKey: unknown;
|
|
415
|
+
private secondLevelKey: unknown;
|
|
416
|
+
|
|
417
|
+
constructor(mapName: string | Mapping, topLevelKey: unknown, secondLevelKey: unknown) {
|
|
418
|
+
this.mapName = mapName;
|
|
419
|
+
this.topLevelKey = topLevelKey;
|
|
420
|
+
this.secondLevelKey = secondLevelKey;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
toJSON(): { "Fn::FindInMap": [string, unknown, unknown] } {
|
|
424
|
+
const name = isMapping(this.mapName) ? mappingName(this.mapName) : (this.mapName as string);
|
|
425
|
+
return {
|
|
426
|
+
"Fn::FindInMap": [name, resolveIntrinsicValue(this.topLevelKey), resolveIntrinsicValue(this.secondLevelKey)],
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Create a FindInMap intrinsic. The mapping is the `Mapping` declarable, or
|
|
433
|
+
* its name. Either key may be a literal or a value intrinsic
|
|
434
|
+
* (`FindInMap(regionMap, AWS.Region, "AMI")`).
|
|
435
|
+
*/
|
|
436
|
+
export function FindInMap(mapName: string | Mapping, topLevelKey: unknown, secondLevelKey: unknown): FindInMapIntrinsic {
|
|
437
|
+
return new FindInMapIntrinsic(mapName, topLevelKey, secondLevelKey);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Fn::ImportValue intrinsic function
|
|
442
|
+
* Reads a value another stack exports.
|
|
443
|
+
*/
|
|
444
|
+
export class ImportValueIntrinsic implements Intrinsic {
|
|
445
|
+
readonly [INTRINSIC_MARKER] = true as const;
|
|
446
|
+
private sharedValue: unknown;
|
|
447
|
+
|
|
448
|
+
constructor(sharedValue: unknown) {
|
|
449
|
+
this.sharedValue = sharedValue;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
toJSON(): { "Fn::ImportValue": unknown } {
|
|
453
|
+
return { "Fn::ImportValue": resolveIntrinsicValue(this.sharedValue) };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Create an ImportValue intrinsic. The export name is a string or a value intrinsic (`Sub`). */
|
|
458
|
+
export function ImportValue(sharedValue: unknown): ImportValueIntrinsic {
|
|
459
|
+
return new ImportValueIntrinsic(sharedValue);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Fn::Cidr intrinsic function
|
|
464
|
+
* Splits an address block into `count` CIDR blocks of `cidrBits` host bits.
|
|
465
|
+
*/
|
|
466
|
+
export class CidrIntrinsic implements Intrinsic {
|
|
467
|
+
readonly [INTRINSIC_MARKER] = true as const;
|
|
468
|
+
private ipBlock: unknown;
|
|
469
|
+
private count: unknown;
|
|
470
|
+
private cidrBits: unknown;
|
|
471
|
+
|
|
472
|
+
constructor(ipBlock: unknown, count: unknown, cidrBits: unknown) {
|
|
473
|
+
this.ipBlock = ipBlock;
|
|
474
|
+
this.count = count;
|
|
475
|
+
this.cidrBits = cidrBits;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
toJSON(): { "Fn::Cidr": [unknown, unknown, unknown] } {
|
|
479
|
+
return {
|
|
480
|
+
"Fn::Cidr": [resolveIntrinsicValue(this.ipBlock), resolveIntrinsicValue(this.count), resolveIntrinsicValue(this.cidrBits)],
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Create a Cidr intrinsic. Each argument is a literal or a value intrinsic, written as the template writes it. */
|
|
486
|
+
export function Cidr(ipBlock: unknown, count: unknown, cidrBits: unknown): CidrIntrinsic {
|
|
487
|
+
return new CidrIntrinsic(ipBlock, count, cidrBits);
|
|
488
|
+
}
|
|
@@ -209,6 +209,16 @@ export function ipv4CidrContains(outer: string, inner: string): boolean | null {
|
|
|
209
209
|
return iStart >= oStart && iEnd <= oEnd;
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
+
/**
|
|
213
|
+
* A port as a number. CloudFormation accepts `"80"` as well as `80`, so a
|
|
214
|
+
* string of digits counts; anything else reads as -1, which means all ports.
|
|
215
|
+
*/
|
|
216
|
+
function portNumber(port: unknown): number {
|
|
217
|
+
if (typeof port === "number") return port;
|
|
218
|
+
if (typeof port === "string" && /^-?\d+$/.test(port.trim())) return Number(port);
|
|
219
|
+
return -1;
|
|
220
|
+
}
|
|
221
|
+
|
|
212
222
|
/**
|
|
213
223
|
* Check if a port range [fromPort, toPort] contains any of the sensitive ports.
|
|
214
224
|
*/
|
|
@@ -220,8 +230,8 @@ export function portRangeContainsSensitive(
|
|
|
220
230
|
// Missing ports means all ports
|
|
221
231
|
if (fromPort === undefined && toPort === undefined) return true;
|
|
222
232
|
|
|
223
|
-
const from =
|
|
224
|
-
const to =
|
|
233
|
+
const from = portNumber(fromPort);
|
|
234
|
+
const to = portNumber(toPort);
|
|
225
235
|
|
|
226
236
|
// If either is an intrinsic, we can't statically verify
|
|
227
237
|
if (isIntrinsic(fromPort) || isIntrinsic(toPort)) return false;
|
|
@@ -31,6 +31,38 @@ describe("WAW019: Security Group Unrestricted Ingress", () => {
|
|
|
31
31
|
expect(diags[0].severity).toBe("error");
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
+
test("ports written as strings are read as numbers", () => {
|
|
35
|
+
// CloudFormation accepts FromPort: "80"; port 80 is not sensitive.
|
|
36
|
+
const ctx = makeCtx({
|
|
37
|
+
Resources: {
|
|
38
|
+
MySG: {
|
|
39
|
+
Type: "AWS::EC2::SecurityGroup",
|
|
40
|
+
Properties: {
|
|
41
|
+
SecurityGroupIngress: [
|
|
42
|
+
{ IpProtocol: "tcp", FromPort: "80", ToPort: "80", CidrIp: "0.0.0.0/0" },
|
|
43
|
+
{ IpProtocol: "tcp", FromPort: "22", ToPort: "22", CidrIp: "10.0.0.0/8" },
|
|
44
|
+
],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
expect(checkUnrestrictedIngress(ctx)).toHaveLength(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a sensitive port written as a string is still flagged", () => {
|
|
53
|
+
const ctx = makeCtx({
|
|
54
|
+
Resources: {
|
|
55
|
+
MySG: {
|
|
56
|
+
Type: "AWS::EC2::SecurityGroup",
|
|
57
|
+
Properties: {
|
|
58
|
+
SecurityGroupIngress: [{ IpProtocol: "tcp", FromPort: "22", ToPort: "22", CidrIp: "0.0.0.0/0" }],
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
expect(checkUnrestrictedIngress(ctx)).toHaveLength(1);
|
|
64
|
+
});
|
|
65
|
+
|
|
34
66
|
test("flags SG with ::/0 on port 3389", () => {
|
|
35
67
|
const ctx = makeCtx({
|
|
36
68
|
Resources: {
|
|
@@ -117,6 +117,34 @@ describe("WAW042: S3 Bucket Missing TLS-Only Policy", () => {
|
|
|
117
117
|
expect(checkS3TlsOnlyPolicy(ctx)).toHaveLength(1);
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
+
test("no diagnostic when the BucketPolicy names the bucket with the same Fn::Sub as its BucketName", () => {
|
|
121
|
+
const name = { "Fn::Sub": "${AppName}-${AWS::Region}-${AWS::AccountId}" };
|
|
122
|
+
const ctx = makeCtx({
|
|
123
|
+
Resources: {
|
|
124
|
+
MyBucket: { Type: "AWS::S3::Bucket", Properties: { BucketName: name } },
|
|
125
|
+
MyBucketPolicy: {
|
|
126
|
+
Type: "AWS::S3::BucketPolicy",
|
|
127
|
+
Properties: { Bucket: name, PolicyDocument: { Statement: [secureTransportDeny] } },
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
expect(checkS3TlsOnlyPolicy(ctx)).toHaveLength(0);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("a policy naming a different bucket by Fn::Sub does not clear this one", () => {
|
|
135
|
+
const ctx = makeCtx({
|
|
136
|
+
Resources: {
|
|
137
|
+
MyBucket: { Type: "AWS::S3::Bucket", Properties: { BucketName: { "Fn::Sub": "${AppName}-data" } } },
|
|
138
|
+
MyBucketPolicy: {
|
|
139
|
+
Type: "AWS::S3::BucketPolicy",
|
|
140
|
+
Properties: { Bucket: { "Fn::Sub": "${AppName}-logs" }, PolicyDocument: { Statement: [secureTransportDeny] } },
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
const diags = checkS3TlsOnlyPolicy(ctx);
|
|
145
|
+
expect(diags.map((d) => d.entity)).toEqual(["MyBucket"]);
|
|
146
|
+
});
|
|
147
|
+
|
|
120
148
|
test("a policy covering a different bucket does not clear this one", () => {
|
|
121
149
|
const ctx = makeCtx({
|
|
122
150
|
Resources: {
|
|
@@ -26,10 +26,31 @@ function statementDeniesInsecureTransport(stmt: Record<string, unknown>): boolea
|
|
|
26
26
|
return false;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
/**
|
|
30
|
-
function
|
|
29
|
+
/** A value as JSON with its object keys sorted, for comparing two values. */
|
|
30
|
+
function canonical(value: unknown): string {
|
|
31
|
+
return JSON.stringify(value, (_key, v) =>
|
|
32
|
+
v && typeof v === "object" && !Array.isArray(v)
|
|
33
|
+
? Object.fromEntries(Object.keys(v).sort().map((k) => [k, (v as Record<string, unknown>)[k]]))
|
|
34
|
+
: v,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Logical ids of every bucket a BucketPolicy's `Bucket` property resolves to:
|
|
40
|
+
* a bucket it references (`Ref`, `GetAtt`), or a bucket whose `BucketName`
|
|
41
|
+
* is the same value (`Fn::Sub: "${AppName}-${AWS::Region}"` on both).
|
|
42
|
+
*/
|
|
43
|
+
function bucketPolicyTargets(resource: CFResource, resources: Record<string, CFResource>): Set<string> {
|
|
31
44
|
const props = resource.Properties ?? {};
|
|
32
|
-
|
|
45
|
+
const targets = findResourceRefs(props.Bucket);
|
|
46
|
+
if (props.Bucket === undefined) return targets;
|
|
47
|
+
const policyBucket = canonical(props.Bucket);
|
|
48
|
+
for (const [logicalId, other] of Object.entries(resources)) {
|
|
49
|
+
if (other.Type !== "AWS::S3::Bucket") continue;
|
|
50
|
+
const name = other.Properties?.BucketName;
|
|
51
|
+
if (name !== undefined && canonical(name) === policyBucket) targets.add(logicalId);
|
|
52
|
+
}
|
|
53
|
+
return targets;
|
|
33
54
|
}
|
|
34
55
|
|
|
35
56
|
export function checkS3TlsOnlyPolicy(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
@@ -55,7 +76,7 @@ export function checkS3TlsOnlyPolicy(ctx: PostSynthContext): PostSynthDiagnostic
|
|
|
55
76
|
);
|
|
56
77
|
if (!hasTlsDeny) continue;
|
|
57
78
|
|
|
58
|
-
for (const bucketId of bucketPolicyTargets(resource)) {
|
|
79
|
+
for (const bucketId of bucketPolicyTargets(resource, template.Resources)) {
|
|
59
80
|
bucketsWithTlsPolicy.add(bucketId);
|
|
60
81
|
}
|
|
61
82
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { Mapping, isMapping, MAPPING_ENTITY_TYPE } from "./mapping";
|
|
3
|
+
import { FindInMap, Ref } from "./intrinsics";
|
|
4
|
+
import { AWS } from "./pseudo";
|
|
5
|
+
import { Parameter } from "./parameter";
|
|
6
|
+
import { awsSerializer } from "./serializer";
|
|
7
|
+
import { createResource } from "@intentius/chant/runtime";
|
|
8
|
+
import { resolveAttrRefs } from "@intentius/chant/discovery/resolve";
|
|
9
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
10
|
+
|
|
11
|
+
const Subnet = createResource("AWS::EC2::Subnet", "aws", {});
|
|
12
|
+
|
|
13
|
+
function serialize(entities: Map<string, Declarable>): Record<string, any> {
|
|
14
|
+
resolveAttrRefs(entities);
|
|
15
|
+
return JSON.parse(awsSerializer.serialize(entities) as string);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("Mapping and FindInMap", () => {
|
|
19
|
+
test("a Mapping is lifted into Mappings and FindInMap reads it by name", () => {
|
|
20
|
+
const regionMap = new Mapping({ "us-east-1": { AZs: ["us-east-1a", "us-east-1b"] } });
|
|
21
|
+
const env = new Parameter("String");
|
|
22
|
+
const subnet = new Subnet({
|
|
23
|
+
AvailabilityZone: FindInMap(regionMap, AWS.Region, "AZs"),
|
|
24
|
+
Tag: FindInMap("Other", Ref(env), "Key"),
|
|
25
|
+
});
|
|
26
|
+
const template = serialize(
|
|
27
|
+
new Map<string, Declarable>([
|
|
28
|
+
["RegionMap", regionMap],
|
|
29
|
+
["Env", env],
|
|
30
|
+
["Subnet", subnet],
|
|
31
|
+
]),
|
|
32
|
+
);
|
|
33
|
+
expect(template.Mappings).toEqual({ RegionMap: { "us-east-1": { AZs: ["us-east-1a", "us-east-1b"] } } });
|
|
34
|
+
expect(template.Resources.Subnet.Properties.AvailabilityZone).toEqual({
|
|
35
|
+
"Fn::FindInMap": ["RegionMap", { Ref: "AWS::Region" }, "AZs"],
|
|
36
|
+
});
|
|
37
|
+
expect(template.Resources.Subnet.Properties.Tag).toEqual({ "Fn::FindInMap": ["Other", { Ref: "Env" }, "Key"] });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("a Mapping's name option is its key in Mappings and in FindInMap", () => {
|
|
41
|
+
const map = new Mapping({ a: { b: "c" } }, { name: "Shared" });
|
|
42
|
+
const subnet = new Subnet({ Tag: FindInMap(map, "a", "b") });
|
|
43
|
+
const template = serialize(new Map<string, Declarable>([["SharedMapping", map], ["Subnet", subnet]]));
|
|
44
|
+
expect(Object.keys(template.Mappings)).toEqual(["Shared"]);
|
|
45
|
+
expect(template.Resources.Subnet.Properties.Tag).toEqual({ "Fn::FindInMap": ["Shared", "a", "b"] });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("isMapping and the entity type", () => {
|
|
49
|
+
const map = new Mapping({});
|
|
50
|
+
expect(map.entityType).toBe(MAPPING_ENTITY_TYPE);
|
|
51
|
+
expect(isMapping(map)).toBe(true);
|
|
52
|
+
expect(isMapping(new Parameter("String"))).toBe(false);
|
|
53
|
+
expect(() => new Mapping([] as never)).toThrow(/map must be an object/);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("an import envelope in the template", () => {
|
|
58
|
+
test("fails the build instead of being written as CloudFormation", () => {
|
|
59
|
+
const subnet = new Subnet({ AvailabilityZone: { __intrinsic: "FindInMap", mapName: "RegionMap", firstKey: "a", secondKey: "b" } });
|
|
60
|
+
expect(() => serialize(new Map<string, Declarable>([["Subnet", subnet]]))).toThrow(
|
|
61
|
+
/Resources\.Subnet\.Properties\.AvailabilityZone: \{ __intrinsic: "FindInMap" \} is an import envelope/,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
});
|
package/src/mapping.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CloudFormation template mapping.
|
|
3
|
+
*
|
|
4
|
+
* A `Mapping` declarable is lifted into the template's `Mappings` section by
|
|
5
|
+
* the serializer, the way `Condition` is lifted into `Conditions`. Read a
|
|
6
|
+
* value from it with `FindInMap(mapping, topLevelKey, secondLevelKey)`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { DECLARABLE_MARKER, isDeclarable, type Declarable } from "@intentius/chant/declarable";
|
|
10
|
+
import { getLogicalName } from "@intentius/chant/utils";
|
|
11
|
+
|
|
12
|
+
export const MAPPING_ENTITY_TYPE = "AWS::CloudFormation::Mapping";
|
|
13
|
+
|
|
14
|
+
export class Mapping implements Declarable {
|
|
15
|
+
readonly [DECLARABLE_MARKER] = true as const;
|
|
16
|
+
readonly lexicon = "aws";
|
|
17
|
+
readonly entityType = MAPPING_ENTITY_TYPE;
|
|
18
|
+
/** Top-level key -> second-level key -> value. */
|
|
19
|
+
readonly map: Record<string, Record<string, unknown>>;
|
|
20
|
+
/** The mapping's key in the template's `Mappings`, when it is not the
|
|
21
|
+
* export name. */
|
|
22
|
+
readonly name?: string;
|
|
23
|
+
|
|
24
|
+
constructor(map: Record<string, Record<string, unknown>>, options?: { name?: string }) {
|
|
25
|
+
if (typeof map !== "object" || map === null || Array.isArray(map)) {
|
|
26
|
+
throw new Error("new Mapping(map): map must be an object of top-level keys to objects of second-level keys");
|
|
27
|
+
}
|
|
28
|
+
this.map = map;
|
|
29
|
+
if (options?.name !== undefined) this.name = options.name;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Type guard for the `Mapping` declarable, duck-typed on `entityType` (#1137). */
|
|
34
|
+
export function isMapping(value: unknown): value is Mapping {
|
|
35
|
+
return isDeclarable(value) && value.entityType === MAPPING_ENTITY_TYPE;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The name a mapping has in the template: its `name` option, else its logical name. */
|
|
39
|
+
export function mappingName(mapping: Mapping): string {
|
|
40
|
+
return mapping.name ?? getLogicalName(mapping);
|
|
41
|
+
}
|
package/src/parameter.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { DECLARABLE_MARKER, type CoreParameter } from "@intentius/chant/declarable";
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* What CloudFormation checks a parameter value against. Numeric bounds are
|
|
5
|
+
* kept as written: templates write `MinLength: "1"` as often as `1`.
|
|
6
|
+
*/
|
|
7
|
+
export interface ParameterConstraints {
|
|
8
|
+
allowedValues?: unknown[];
|
|
9
|
+
allowedPattern?: string;
|
|
10
|
+
constraintDescription?: string;
|
|
11
|
+
minLength?: number | string;
|
|
12
|
+
maxLength?: number | string;
|
|
13
|
+
minValue?: number | string;
|
|
14
|
+
maxValue?: number | string;
|
|
15
|
+
noEcho?: boolean | string;
|
|
16
|
+
}
|
|
17
|
+
|
|
3
18
|
export class Parameter implements CoreParameter {
|
|
4
19
|
readonly [DECLARABLE_MARKER] = true as const;
|
|
5
20
|
readonly lexicon = "aws";
|
|
@@ -7,10 +22,16 @@ export class Parameter implements CoreParameter {
|
|
|
7
22
|
readonly parameterType: string;
|
|
8
23
|
readonly description?: string;
|
|
9
24
|
readonly defaultValue?: unknown;
|
|
25
|
+
readonly constraints?: ParameterConstraints;
|
|
10
26
|
|
|
11
|
-
constructor(type: string, options?: { description?: string; defaultValue?: unknown }) {
|
|
27
|
+
constructor(type: string, options?: { description?: string; defaultValue?: unknown } & ParameterConstraints) {
|
|
12
28
|
this.parameterType = type;
|
|
13
29
|
this.description = options?.description;
|
|
14
30
|
this.defaultValue = options?.defaultValue;
|
|
31
|
+
if (options) {
|
|
32
|
+
const { description: _d, defaultValue: _v, ...constraints } = options;
|
|
33
|
+
const set = Object.entries(constraints).filter(([, v]) => v !== undefined);
|
|
34
|
+
if (set.length > 0) this.constraints = Object.fromEntries(set) as ParameterConstraints;
|
|
35
|
+
}
|
|
15
36
|
}
|
|
16
37
|
}
|
package/src/plugin.test.ts
CHANGED
|
@@ -48,7 +48,7 @@ describe("awsPlugin", () => {
|
|
|
48
48
|
|
|
49
49
|
test("returns intrinsics", () => {
|
|
50
50
|
const intrinsics = awsPlugin.intrinsics!();
|
|
51
|
-
expect(intrinsics.length).toBe(
|
|
51
|
+
expect(intrinsics.length).toBe(16);
|
|
52
52
|
const names = intrinsics.map((i) => i.name);
|
|
53
53
|
expect(names).toContain("Sub");
|
|
54
54
|
expect(names).toContain("Ref");
|
|
@@ -57,6 +57,7 @@ describe("awsPlugin", () => {
|
|
|
57
57
|
expect(names).toContain("And");
|
|
58
58
|
expect(names).toContain("Or");
|
|
59
59
|
expect(names).toContain("Not");
|
|
60
|
+
expect(names).toContain("FindInMap");
|
|
60
61
|
});
|
|
61
62
|
|
|
62
63
|
test("returns pseudo-parameters", () => {
|
package/src/plugin.ts
CHANGED
|
@@ -118,6 +118,9 @@ export const awsPlugin: LexiconPlugin = {
|
|
|
118
118
|
{ name: "And", description: "Fn::And — condition conjunction", isTag: false, foldsAsCall: true },
|
|
119
119
|
{ name: "Or", description: "Fn::Or — condition disjunction", isTag: false, foldsAsCall: true },
|
|
120
120
|
{ name: "Not", description: "Fn::Not — condition negation", isTag: false, foldsAsCall: true },
|
|
121
|
+
{ name: "FindInMap", description: "Fn::FindInMap — value from a declared mapping", isTag: false, foldsAsCall: true },
|
|
122
|
+
{ name: "ImportValue", description: "Fn::ImportValue — value another stack exports", isTag: false, foldsAsCall: true },
|
|
123
|
+
{ name: "Cidr", description: "Fn::Cidr — CIDR blocks from an address block", isTag: false, foldsAsCall: true },
|
|
121
124
|
];
|
|
122
125
|
},
|
|
123
126
|
|
package/src/serializer.ts
CHANGED
|
@@ -19,11 +19,13 @@ import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
|
19
19
|
import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
|
|
20
20
|
import { isChildProject, type ChildProjectInstance } from "@intentius/chant/child-project";
|
|
21
21
|
import { isStackOutput, type StackOutput } from "@intentius/chant/stack-output";
|
|
22
|
-
import { isAttrRefLike
|
|
22
|
+
import { isAttrRefLike } from "@intentius/chant/utils";
|
|
23
23
|
import { resolveDependsOn } from "@intentius/chant/resource-attributes";
|
|
24
|
-
import { isCondition } from "./condition";
|
|
24
|
+
import { conditionName, isCondition } from "./condition";
|
|
25
|
+
import { isMapping } from "./mapping";
|
|
25
26
|
import { isDefaultTags, type TagEntry } from "./default-tags";
|
|
26
27
|
import { isTemplateTransform } from "./template-transform";
|
|
28
|
+
import { isTemplateSection } from "./template-sections";
|
|
27
29
|
import { loadTaggableResources } from "./taggable";
|
|
28
30
|
|
|
29
31
|
/**
|
|
@@ -42,6 +44,7 @@ interface CFTemplate {
|
|
|
42
44
|
Metadata?: Record<string, unknown>;
|
|
43
45
|
Transform?: string | string[];
|
|
44
46
|
Parameters?: Record<string, CFParameter>;
|
|
47
|
+
Mappings?: Record<string, unknown>;
|
|
45
48
|
Conditions?: Record<string, unknown>;
|
|
46
49
|
Resources: Record<string, CFResource>;
|
|
47
50
|
Outputs?: Record<string, CFOutput>;
|
|
@@ -57,13 +60,25 @@ interface CFParameter {
|
|
|
57
60
|
AllowedValues?: unknown[];
|
|
58
61
|
AllowedPattern?: string;
|
|
59
62
|
ConstraintDescription?: string;
|
|
60
|
-
MaxLength?: number;
|
|
61
|
-
MaxValue?: number;
|
|
62
|
-
MinLength?: number;
|
|
63
|
-
MinValue?: number;
|
|
64
|
-
NoEcho?: boolean;
|
|
63
|
+
MaxLength?: number | string;
|
|
64
|
+
MaxValue?: number | string;
|
|
65
|
+
MinLength?: number | string;
|
|
66
|
+
MinValue?: number | string;
|
|
67
|
+
NoEcho?: boolean | string;
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
/** Parameter constraint options and the CloudFormation keys they emit as. */
|
|
71
|
+
const PARAMETER_CONSTRAINT_KEYS = [
|
|
72
|
+
["allowedValues", "AllowedValues"],
|
|
73
|
+
["allowedPattern", "AllowedPattern"],
|
|
74
|
+
["constraintDescription", "ConstraintDescription"],
|
|
75
|
+
["minLength", "MinLength"],
|
|
76
|
+
["maxLength", "MaxLength"],
|
|
77
|
+
["minValue", "MinValue"],
|
|
78
|
+
["maxValue", "MaxValue"],
|
|
79
|
+
["noEcho", "NoEcho"],
|
|
80
|
+
] as const;
|
|
81
|
+
|
|
67
82
|
/**
|
|
68
83
|
* CloudFormation resource
|
|
69
84
|
*/
|
|
@@ -314,6 +329,22 @@ function serializeToTemplate(
|
|
|
314
329
|
template.Transform = transforms;
|
|
315
330
|
}
|
|
316
331
|
|
|
332
|
+
// Template-level Description and Metadata declarations. Declared Metadata
|
|
333
|
+
// keys come first; the ownership marker and receipt rows written above are
|
|
334
|
+
// chant's own and win on a key collision.
|
|
335
|
+
const declaredMetadata: Record<string, unknown> = {};
|
|
336
|
+
for (const [, entity] of entities) {
|
|
337
|
+
if (!isTemplateSection(entity)) continue;
|
|
338
|
+
if (entity.section === "Description") {
|
|
339
|
+
template.Description = entity.value as string;
|
|
340
|
+
} else {
|
|
341
|
+
Object.assign(declaredMetadata, toCFValue(entity.value, entityNames) as Record<string, unknown>);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (Object.keys(declaredMetadata).length > 0) {
|
|
345
|
+
template.Metadata = { ...declaredMetadata, ...(template.Metadata ?? {}) };
|
|
346
|
+
}
|
|
347
|
+
|
|
317
348
|
// Process entities
|
|
318
349
|
for (const [name, entity] of entities) {
|
|
319
350
|
// Skip StackOutput entities — they go in the Outputs section
|
|
@@ -327,7 +358,7 @@ function serializeToTemplate(
|
|
|
327
358
|
}
|
|
328
359
|
|
|
329
360
|
// Skip TemplateTransform entities — lifted to the top-level Transform above
|
|
330
|
-
if (isTemplateTransform(entity)) {
|
|
361
|
+
if (isTemplateTransform(entity) || isTemplateSection(entity)) {
|
|
331
362
|
continue;
|
|
332
363
|
}
|
|
333
364
|
|
|
@@ -348,14 +379,27 @@ function serializeToTemplate(
|
|
|
348
379
|
param.Default = entity.defaultValue;
|
|
349
380
|
}
|
|
350
381
|
|
|
382
|
+
const constraints = (entity as { constraints?: Record<string, unknown> }).constraints;
|
|
383
|
+
if (constraints) {
|
|
384
|
+
for (const [option, key] of PARAMETER_CONSTRAINT_KEYS) {
|
|
385
|
+
if (constraints[option] !== undefined) (param as unknown as Record<string, unknown>)[key] = constraints[option];
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
351
389
|
template.Parameters[name] = param;
|
|
390
|
+
} else if (isMapping(entity)) {
|
|
391
|
+
// Mapping declarable -> Mappings section, read with FindInMap.
|
|
392
|
+
if (!template.Mappings) {
|
|
393
|
+
template.Mappings = {};
|
|
394
|
+
}
|
|
395
|
+
template.Mappings[entity.name ?? name] = toCFValue(entity.map, entityNames);
|
|
352
396
|
} else if (isCondition(entity)) {
|
|
353
397
|
// Condition declarable → Conditions section (#2068), lifted the way
|
|
354
398
|
// Parameter is lifted into Parameters above.
|
|
355
399
|
if (!template.Conditions) {
|
|
356
400
|
template.Conditions = {};
|
|
357
401
|
}
|
|
358
|
-
template.Conditions[name] = toCFValue(entity.expression, entityNames);
|
|
402
|
+
template.Conditions[entity.name ?? name] = toCFValue(entity.expression, entityNames);
|
|
359
403
|
} else if (isChildProject(entity)) {
|
|
360
404
|
// ChildProjectInstance → AWS::CloudFormation::Stack resource
|
|
361
405
|
const childProject = entity as ChildProjectInstance;
|
|
@@ -409,7 +453,7 @@ function serializeToTemplate(
|
|
|
409
453
|
// as well as a name string (#2068).
|
|
410
454
|
if (attrs.Condition) {
|
|
411
455
|
resource.Condition = isCondition(attrs.Condition)
|
|
412
|
-
?
|
|
456
|
+
? conditionName(attrs.Condition, entityNames)
|
|
413
457
|
: attrs.Condition as string;
|
|
414
458
|
}
|
|
415
459
|
if (attrs.DeletionPolicy) resource.DeletionPolicy = attrs.DeletionPolicy as string;
|
|
@@ -489,10 +533,11 @@ function serializeToTemplate(
|
|
|
489
533
|
const condition = (stackOutput as { condition?: unknown }).condition;
|
|
490
534
|
if (condition) {
|
|
491
535
|
output.Condition = isCondition(condition)
|
|
492
|
-
?
|
|
536
|
+
? conditionName(condition, entityNames)
|
|
493
537
|
: condition as string;
|
|
494
538
|
}
|
|
495
|
-
|
|
539
|
+
// Read defensively, like exportName: the name option postdates older cores.
|
|
540
|
+
template.Outputs[(stackOutput as { name?: string }).name ?? name] = output;
|
|
496
541
|
}
|
|
497
542
|
}
|
|
498
543
|
|
|
@@ -515,9 +560,31 @@ function serializeToTemplate(
|
|
|
515
560
|
template.Outputs = { ...template.Outputs, ...extraOutputs };
|
|
516
561
|
}
|
|
517
562
|
|
|
563
|
+
refuseImportEnvelopes(template);
|
|
518
564
|
return template;
|
|
519
565
|
}
|
|
520
566
|
|
|
567
|
+
/**
|
|
568
|
+
* Throw on an import IR envelope (`{ "__intrinsic": "FindInMap", ... }`) in
|
|
569
|
+
* the synthesized template. It is not CloudFormation, and a build that wrote
|
|
570
|
+
* one used to exit 0 with a template CloudFormation rejects.
|
|
571
|
+
*/
|
|
572
|
+
function refuseImportEnvelopes(value: unknown, path: string[] = []): void {
|
|
573
|
+
if (value === null || typeof value !== "object") return;
|
|
574
|
+
if (Array.isArray(value)) {
|
|
575
|
+
value.forEach((item, i) => refuseImportEnvelopes(item, [...path, String(i)]));
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
const obj = value as Record<string, unknown>;
|
|
579
|
+
if (typeof obj.__intrinsic === "string") {
|
|
580
|
+
throw new Error(
|
|
581
|
+
`${path.join(".")}: { __intrinsic: "${obj.__intrinsic}" } is an import envelope, not CloudFormation; ` +
|
|
582
|
+
`write the ${obj.__intrinsic} intrinsic instead`,
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
for (const [key, v] of Object.entries(obj)) refuseImportEnvelopes(v, [...path, key]);
|
|
586
|
+
}
|
|
587
|
+
|
|
521
588
|
/**
|
|
522
589
|
* AWS CloudFormation serializer implementation
|
|
523
590
|
*/
|