@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.
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
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: getLogicalName(operand) };
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 serialize = defaultInterpolationSerializer(
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
- private logicalName: string;
96
- private attribute: string;
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 : getLogicalName(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 as Intrinsic & { toJSON(): unknown }).toJSON();
307
- return { "Fn::Select": [String(this.index), resolvedValues] };
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 = typeof fromPort === "number" ? fromPort : -1;
224
- const to = typeof toPort === "number" ? toPort : -1;
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
- /** Logical ids of every bucket a BucketPolicy's `Bucket` property resolves to. */
30
- function bucketPolicyTargets(resource: CFResource): Set<string> {
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
- return findResourceRefs(props.Bucket);
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
  }
@@ -48,7 +48,7 @@ describe("awsPlugin", () => {
48
48
 
49
49
  test("returns intrinsics", () => {
50
50
  const intrinsics = awsPlugin.intrinsics!();
51
- expect(intrinsics.length).toBe(13);
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, getLogicalName } from "@intentius/chant/utils";
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
- ? entityNames.get(attrs.Condition) ?? getLogicalName(attrs.Condition)
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
- ? entityNames.get(condition) ?? getLogicalName(condition)
536
+ ? conditionName(condition, entityNames)
493
537
  : condition as string;
494
538
  }
495
- template.Outputs[name] = output;
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
  */