@intentius/chant-lexicon-aws 0.124.33 → 0.124.34
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/integrity.json +3 -2
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-catalog.js +1 -0
- package/dist/lint/audit-lineage.d.ts.map +1 -1
- package/dist/lint/audit-lineage.js +4 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/index.js +2 -0
- package/dist/lint/post-synth/waw070.d.ts +14 -0
- package/dist/lint/post-synth/waw070.d.ts.map +1 -0
- package/dist/lint/post-synth/waw070.js +81 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -0
- package/dist/okf/rules/WAW070.md +11 -0
- package/dist/rules/waw070.ts +89 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/serializer.js +43 -5
- package/package.json +2 -2
- package/src/lint/audit-catalog.ts +1 -0
- package/src/lint/audit-lineage.ts +4 -0
- package/src/lint/post-synth/index.ts +2 -0
- package/src/lint/post-synth/waw070.test.ts +59 -0
- package/src/lint/post-synth/waw070.ts +89 -0
- package/src/serializer-foreign-attr.test.ts +43 -0
- package/src/serializer.ts +58 -5
package/dist/integrity.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"algorithm": "sha256",
|
|
3
3
|
"artifacts": {
|
|
4
|
-
"manifest.json": "
|
|
4
|
+
"manifest.json": "0caff76f30c615cd569668c41563a7ca22dc2c0a8faa435471c3fa8ae5b7120a",
|
|
5
5
|
"meta.json": "3e18663eb9eb09920882f8014020432330a74ccd480689e7384c2022cbb10f39",
|
|
6
6
|
"types/index.d.ts": "8651f9ae166668f4311eea1fe61c3fe235f345947951c1d23d1df24a4fd3384f",
|
|
7
7
|
"rules/hardcoded-region.ts": "957eadcaa9f39cc17d21437565d7a94607d9b082cf28918b178fa067f07498a9",
|
|
@@ -69,9 +69,10 @@
|
|
|
69
69
|
"rules/waw067.ts": "8e66f3512569c106c4c6868786bb870af1b3b05d3542118ca6bb5c61c57314e7",
|
|
70
70
|
"rules/waw068.ts": "adbfe84e9b3cdafc072346d87a5df81a97ab07bc5171f3d4abb824c29e9a66a0",
|
|
71
71
|
"rules/waw069.ts": "4792bb3943d5e08039f45cbf554f45263703288acf4ca01d88ad778083023d27",
|
|
72
|
+
"rules/waw070.ts": "35bd2281ab072a1d6138e0db9f9d6f4f0b6c5af4deacb28aa693177c654db66a",
|
|
72
73
|
"skills/chant-aws.md": "4d65eb160e001f2af42eb7c622a5b498c2fd1f5afbd6c8155615c010fb265c63",
|
|
73
74
|
"skills/chant-aws-eks.md": "8789255709ff004ad0a875fd5999edcdc66fc6e33d710db058d9f42703bcfdfe",
|
|
74
75
|
"skills/chant-aws-carve-terraform.md": "8780b94ce60aded935938d5ef20ea667738354051da1bf9ca6d70d9886f65d21"
|
|
75
76
|
},
|
|
76
|
-
"composite": "
|
|
77
|
+
"composite": "57806890c056b83500ed7e1d32c8ccff0869c61229f566321563fb61800ace32"
|
|
77
78
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"audit-catalog.d.ts","sourceRoot":"","sources":["../../src/lint/audit-catalog.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAa,KAAK,QAAQ,EAAgC,MAAM,gCAAgC,CAAC;AASxG,6CAA6C;AAC7C,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,
|
|
1
|
+
{"version":3,"file":"audit-catalog.d.ts","sourceRoot":"","sources":["../../src/lint/audit-catalog.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAa,KAAK,QAAQ,EAAgC,MAAM,gCAAgC,CAAC;AASxG,6CAA6C;AAC7C,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,QAAQ,CA4EpD,CAAC"}
|
|
@@ -82,6 +82,7 @@ export const awsAuditCatalog = {
|
|
|
82
82
|
WAW067: auditRule("WAW067", "report-only", "guidance", "Single-AZ NAT gateway serves multi-AZ private subnets", "Add one NAT gateway per Availability Zone and point each AZ's subnets at its own.", { category: "best-practice" }),
|
|
83
83
|
WAW068: auditRule("WAW068", "report-only", "guidance", "VPN Gateway or Transit Gateway has only one attached VPN Connection", "Attach a second VPNConnection (ideally to a separate Customer Gateway) for redundancy.", { category: "best-practice" }),
|
|
84
84
|
WAW069: auditRule("WAW069", "merge-worthy", "guidance", "Template references a condition it never declares", "Declare the condition in the Conditions section (new Condition(...)), or fix the referenced name.", { category: "correctness" }),
|
|
85
|
+
WAW070: auditRule("WAW070", "merge-worthy", "guidance", "Ref or Fn::GetAtt names something the template doesn't declare", "Reference a resource or parameter in this template. A value from another lexicon's resource arrives as a parameter.", { category: "correctness" }),
|
|
85
86
|
};
|
|
86
87
|
// Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).
|
|
87
88
|
applyLineage(awsAuditCatalog, awsAuditLineage);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"audit-lineage.d.ts","sourceRoot":"","sources":["../../src/lint/audit-lineage.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAE9D,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"audit-lineage.d.ts","sourceRoot":"","sources":["../../src/lint/audit-lineage.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,gCAAgC,CAAC;AAE9D,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,EAAE,CAyJrD,CAAC"}
|
|
@@ -147,4 +147,8 @@ export const awsAuditLineage = {
|
|
|
147
147
|
WAW069: [
|
|
148
148
|
{ tool: "cfn-lint", rule: "E8002", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E8002", relation: "equivalent" },
|
|
149
149
|
],
|
|
150
|
+
WAW070: [
|
|
151
|
+
{ tool: "cfn-lint", rule: "E1012", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E1012", relation: "equivalent" },
|
|
152
|
+
{ tool: "cfn-lint", rule: "E1010", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E1010", relation: "overlaps" },
|
|
153
|
+
],
|
|
150
154
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lint/post-synth/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kCAAkC,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/lint/post-synth/index.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,kCAAkC,CAAC;AAgEvE,eAAO,MAAM,eAAe,EAAE,cAAc,EA+D3C,CAAC"}
|
|
@@ -59,6 +59,7 @@ import { waw066 } from "./waw066.js";
|
|
|
59
59
|
import { waw067 } from "./waw067.js";
|
|
60
60
|
import { waw068 } from "./waw068.js";
|
|
61
61
|
import { waw069 } from "./waw069.js";
|
|
62
|
+
import { waw070 } from "./waw070.js";
|
|
62
63
|
export const postSynthChecks = [
|
|
63
64
|
cor020,
|
|
64
65
|
ext001,
|
|
@@ -121,4 +122,5 @@ export const postSynthChecks = [
|
|
|
121
122
|
waw067,
|
|
122
123
|
waw068,
|
|
123
124
|
waw069,
|
|
125
|
+
waw070,
|
|
124
126
|
];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW070: Ref or Fn::GetAtt names something the template doesn't declare
|
|
3
|
+
*
|
|
4
|
+
* A `Ref` must name a resource, a parameter or an `AWS::` pseudo-parameter,
|
|
5
|
+
* and a `Fn::GetAtt` must name a resource. CloudFormation rejects a template
|
|
6
|
+
* whose reference names neither (cfn-lint E1012/E1010). chant built such a
|
|
7
|
+
* template when a value read another lexicon's resource (#4180); the
|
|
8
|
+
* serializer now turns that into a parameter, and this check catches any
|
|
9
|
+
* reference still left dangling.
|
|
10
|
+
*/
|
|
11
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
12
|
+
export declare function checkDanglingRefs(ctx: PostSynthContext): PostSynthDiagnostic[];
|
|
13
|
+
export declare const waw070: PostSynthCheck;
|
|
14
|
+
//# sourceMappingURL=waw070.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"waw070.d.ts","sourceRoot":"","sources":["../../../src/lint/post-synth/waw070.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAC;AA8B9G,wBAAgB,iBAAiB,CAAC,GAAG,EAAE,gBAAgB,GAAG,mBAAmB,EAAE,CAsC9E;AAED,eAAO,MAAM,MAAM,EAAE,cAOpB,CAAC"}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW070: Ref or Fn::GetAtt names something the template doesn't declare
|
|
3
|
+
*
|
|
4
|
+
* A `Ref` must name a resource, a parameter or an `AWS::` pseudo-parameter,
|
|
5
|
+
* and a `Fn::GetAtt` must name a resource. CloudFormation rejects a template
|
|
6
|
+
* whose reference names neither (cfn-lint E1012/E1010). chant built such a
|
|
7
|
+
* template when a value read another lexicon's resource (#4180); the
|
|
8
|
+
* serializer now turns that into a parameter, and this check catches any
|
|
9
|
+
* reference still left dangling.
|
|
10
|
+
*/
|
|
11
|
+
import { parseCFTemplate } from "./cf-refs.js";
|
|
12
|
+
function collectRefs(value, out, seen = new Set()) {
|
|
13
|
+
if (value === null || typeof value !== "object" || seen.has(value))
|
|
14
|
+
return;
|
|
15
|
+
seen.add(value);
|
|
16
|
+
if (Array.isArray(value)) {
|
|
17
|
+
for (const item of value)
|
|
18
|
+
collectRefs(item, out, seen);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const obj = value;
|
|
22
|
+
if (typeof obj.Ref === "string" && Object.keys(obj).length === 1) {
|
|
23
|
+
out.push({ target: obj.Ref, via: "Ref" });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
const getAtt = obj["Fn::GetAtt"];
|
|
27
|
+
if (getAtt !== undefined && Object.keys(obj).length === 1) {
|
|
28
|
+
const target = Array.isArray(getAtt) ? getAtt[0] : typeof getAtt === "string" ? getAtt.split(".")[0] : undefined;
|
|
29
|
+
if (typeof target === "string")
|
|
30
|
+
out.push({ target, via: "Fn::GetAtt" });
|
|
31
|
+
if (Array.isArray(getAtt))
|
|
32
|
+
collectRefs(getAtt.slice(1), out, seen);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
for (const v of Object.values(obj))
|
|
36
|
+
collectRefs(v, out, seen);
|
|
37
|
+
}
|
|
38
|
+
export function checkDanglingRefs(ctx) {
|
|
39
|
+
const diagnostics = [];
|
|
40
|
+
for (const [lexicon, output] of ctx.outputs) {
|
|
41
|
+
if (lexicon !== "aws")
|
|
42
|
+
continue;
|
|
43
|
+
const template = parseCFTemplate(output);
|
|
44
|
+
if (!template?.Resources || typeof template.Resources !== "object")
|
|
45
|
+
continue;
|
|
46
|
+
const resources = new Set(Object.keys(template.Resources));
|
|
47
|
+
const parameters = new Set(Object.keys(template.Parameters ?? {}));
|
|
48
|
+
const report = (where, value) => {
|
|
49
|
+
const refs = [];
|
|
50
|
+
collectRefs(value, refs);
|
|
51
|
+
for (const ref of refs) {
|
|
52
|
+
const known = ref.via === "Ref"
|
|
53
|
+
? resources.has(ref.target) || parameters.has(ref.target) || ref.target.startsWith("AWS::")
|
|
54
|
+
: resources.has(ref.target);
|
|
55
|
+
if (known)
|
|
56
|
+
continue;
|
|
57
|
+
diagnostics.push({
|
|
58
|
+
checkId: "WAW070",
|
|
59
|
+
severity: "error",
|
|
60
|
+
message: `${where} has ${ref.via} "${ref.target}", which is not ${ref.via === "Ref" ? "a resource, a parameter or a pseudo-parameter" : "a resource"} in this template`,
|
|
61
|
+
entity: where.replace(/^(Resource|Output) /, "").replace(/"/g, ""),
|
|
62
|
+
lexicon: "aws",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
|
67
|
+
report(`Resource "${logicalId}"`, resource.Properties);
|
|
68
|
+
}
|
|
69
|
+
for (const [name, out] of Object.entries(template.Outputs ?? {})) {
|
|
70
|
+
report(`Output "${name}"`, out?.Value);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return diagnostics;
|
|
74
|
+
}
|
|
75
|
+
export const waw070 = {
|
|
76
|
+
id: "WAW070",
|
|
77
|
+
description: "Ref or Fn::GetAtt names something the template doesn't declare",
|
|
78
|
+
check(ctx) {
|
|
79
|
+
return checkDanglingRefs(ctx);
|
|
80
|
+
},
|
|
81
|
+
};
|
package/dist/manifest.json
CHANGED
package/dist/okf/index.md
CHANGED
|
@@ -1936,3 +1936,4 @@ okf_version: '0.2'
|
|
|
1936
1936
|
* [WAW067](/rules/WAW067.md) - A single-AZ NAT gateway serves private subnets across multiple Availability Zones — single point of failure for cross-AZ egress
|
|
1937
1937
|
* [WAW068](/rules/WAW068.md) - VPN Gateway or Transit Gateway has only one attached VPN Connection — no redundant path for hybrid connectivity
|
|
1938
1938
|
* [WAW069](/rules/WAW069.md) - Template references a condition it never declares — resource/output Condition keys, Fn::If, and Condition references must name an entry in the Conditions sec...
|
|
1939
|
+
* [WAW070](/rules/WAW070.md) - Ref or Fn::GetAtt names something the template doesn't declare
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: post-synth-check
|
|
3
|
+
title: WAW070
|
|
4
|
+
description: Ref or Fn::GetAtt names something the template doesn't declare
|
|
5
|
+
id: WAW070
|
|
6
|
+
severity: error
|
|
7
|
+
category: post-synth
|
|
8
|
+
lexicon: aws
|
|
9
|
+
docs: https://intentius.io/chant/lexicons/aws/rules/
|
|
10
|
+
---
|
|
11
|
+
Ref or Fn::GetAtt names something the template doesn't declare
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW070: Ref or Fn::GetAtt names something the template doesn't declare
|
|
3
|
+
*
|
|
4
|
+
* A `Ref` must name a resource, a parameter or an `AWS::` pseudo-parameter,
|
|
5
|
+
* and a `Fn::GetAtt` must name a resource. CloudFormation rejects a template
|
|
6
|
+
* whose reference names neither (cfn-lint E1012/E1010). chant built such a
|
|
7
|
+
* template when a value read another lexicon's resource (#4180); the
|
|
8
|
+
* serializer now turns that into a parameter, and this check catches any
|
|
9
|
+
* reference still left dangling.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
13
|
+
import { parseCFTemplate } from "./cf-refs";
|
|
14
|
+
|
|
15
|
+
interface DanglingRef {
|
|
16
|
+
target: string;
|
|
17
|
+
via: "Ref" | "Fn::GetAtt";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function collectRefs(value: unknown, out: DanglingRef[], seen = new Set<unknown>()): void {
|
|
21
|
+
if (value === null || typeof value !== "object" || seen.has(value)) return;
|
|
22
|
+
seen.add(value);
|
|
23
|
+
if (Array.isArray(value)) {
|
|
24
|
+
for (const item of value) collectRefs(item, out, seen);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const obj = value as Record<string, unknown>;
|
|
28
|
+
if (typeof obj.Ref === "string" && Object.keys(obj).length === 1) {
|
|
29
|
+
out.push({ target: obj.Ref, via: "Ref" });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const getAtt = obj["Fn::GetAtt"];
|
|
33
|
+
if (getAtt !== undefined && Object.keys(obj).length === 1) {
|
|
34
|
+
const target = Array.isArray(getAtt) ? getAtt[0] : typeof getAtt === "string" ? getAtt.split(".")[0] : undefined;
|
|
35
|
+
if (typeof target === "string") out.push({ target, via: "Fn::GetAtt" });
|
|
36
|
+
if (Array.isArray(getAtt)) collectRefs(getAtt.slice(1), out, seen);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const v of Object.values(obj)) collectRefs(v, out, seen);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function checkDanglingRefs(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
43
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
44
|
+
|
|
45
|
+
for (const [lexicon, output] of ctx.outputs) {
|
|
46
|
+
if (lexicon !== "aws") continue;
|
|
47
|
+
const template = parseCFTemplate(output);
|
|
48
|
+
if (!template?.Resources || typeof template.Resources !== "object") continue;
|
|
49
|
+
|
|
50
|
+
const resources = new Set(Object.keys(template.Resources));
|
|
51
|
+
const parameters = new Set(Object.keys((template.Parameters as Record<string, unknown> | undefined) ?? {}));
|
|
52
|
+
|
|
53
|
+
const report = (where: string, value: unknown) => {
|
|
54
|
+
const refs: DanglingRef[] = [];
|
|
55
|
+
collectRefs(value, refs);
|
|
56
|
+
for (const ref of refs) {
|
|
57
|
+
const known = ref.via === "Ref"
|
|
58
|
+
? resources.has(ref.target) || parameters.has(ref.target) || ref.target.startsWith("AWS::")
|
|
59
|
+
: resources.has(ref.target);
|
|
60
|
+
if (known) continue;
|
|
61
|
+
diagnostics.push({
|
|
62
|
+
checkId: "WAW070",
|
|
63
|
+
severity: "error",
|
|
64
|
+
message: `${where} has ${ref.via} "${ref.target}", which is not ${ref.via === "Ref" ? "a resource, a parameter or a pseudo-parameter" : "a resource"} in this template`,
|
|
65
|
+
entity: where.replace(/^(Resource|Output) /, "").replace(/"/g, ""),
|
|
66
|
+
lexicon: "aws",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
|
72
|
+
report(`Resource "${logicalId}"`, resource.Properties);
|
|
73
|
+
}
|
|
74
|
+
for (const [name, out] of Object.entries((template.Outputs as Record<string, { Value?: unknown }> | undefined) ?? {})) {
|
|
75
|
+
report(`Output "${name}"`, out?.Value);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return diagnostics;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const waw070: PostSynthCheck = {
|
|
83
|
+
id: "WAW070",
|
|
84
|
+
description: "Ref or Fn::GetAtt names something the template doesn't declare",
|
|
85
|
+
|
|
86
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
87
|
+
return checkDanglingRefs(ctx);
|
|
88
|
+
},
|
|
89
|
+
};
|
package/dist/serializer.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;
|
|
1
|
+
{"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAsC,MAAM,6BAA6B,CAAC;AA0nBlG;;GAEG;AACH,eAAO,MAAM,aAAa,EAAE,UAgF3B,CAAC"}
|
package/dist/serializer.js
CHANGED
|
@@ -3,6 +3,7 @@ import { ownershipEntries } from "@intentius/chant/ownership";
|
|
|
3
3
|
import { isEffectReceipt, receiptExpectation, referenceInputPaths, } from "@intentius/chant/effect-receipt";
|
|
4
4
|
import { AWS_TAG_OWNERSHIP_KEYS, OWNERSHIP_METADATA_KEY } from "./ownership.js";
|
|
5
5
|
import { AWS_EFFECT_RECEIPT_ENTITY_TYPE, EFFECT_RECEIPTS_METADATA_KEY, RECEIPT_UNRESOLVED_VALUE_NOTE, receiptParameterName, } from "./effect-receipt-row.js";
|
|
6
|
+
import { sanitizeLogicalId } from "@intentius/chant/lexicon-output";
|
|
6
7
|
import { walkValue } from "@intentius/chant/serializer-walker";
|
|
7
8
|
import { isChildProject } from "@intentius/chant/child-project";
|
|
8
9
|
import { isStackOutput } from "@intentius/chant/stack-output";
|
|
@@ -31,12 +32,34 @@ const PARAMETER_CONSTRAINT_KEYS = [
|
|
|
31
32
|
["maxValue", "MaxValue"],
|
|
32
33
|
["noEcho", "NoEcho"],
|
|
33
34
|
];
|
|
35
|
+
/** Per template (keyed by its entity map): the foreign attributes its values read, by parameter name. */
|
|
36
|
+
const foreignAttrs = new WeakMap();
|
|
37
|
+
const namesIn = new WeakMap();
|
|
38
|
+
function holds(entityNames, name) {
|
|
39
|
+
let names = namesIn.get(entityNames);
|
|
40
|
+
if (!names) {
|
|
41
|
+
names = new Set(entityNames.values());
|
|
42
|
+
namesIn.set(entityNames, names);
|
|
43
|
+
}
|
|
44
|
+
return names.has(name);
|
|
45
|
+
}
|
|
34
46
|
/**
|
|
35
47
|
* CloudFormation-specific visitor for the generic serializer walker.
|
|
36
48
|
*/
|
|
37
49
|
function cfnVisitor(entityNames) {
|
|
38
50
|
return {
|
|
39
|
-
attrRef: (name, attr) =>
|
|
51
|
+
attrRef: (name, attr) => {
|
|
52
|
+
if (holds(entityNames, name))
|
|
53
|
+
return { "Fn::GetAtt": [name, attr] };
|
|
54
|
+
const param = sanitizeLogicalId(name, attr);
|
|
55
|
+
let foreign = foreignAttrs.get(entityNames);
|
|
56
|
+
if (!foreign) {
|
|
57
|
+
foreign = new Map();
|
|
58
|
+
foreignAttrs.set(entityNames, foreign);
|
|
59
|
+
}
|
|
60
|
+
foreign.set(param, { entity: name, attribute: attr });
|
|
61
|
+
return { Ref: param };
|
|
62
|
+
},
|
|
40
63
|
resourceRef: (name) => ({ Ref: name }),
|
|
41
64
|
propertyDeclarable: (entity, walk) => {
|
|
42
65
|
if (!isResourceDeclarable(entity) || typeof entity.props !== "object" || entity.props === null) {
|
|
@@ -152,7 +175,7 @@ function renderReceiptRows(receipts, ownership) {
|
|
|
152
175
|
/**
|
|
153
176
|
* Serialize a set of entities into a CFTemplate object (without JSON.stringify).
|
|
154
177
|
*/
|
|
155
|
-
function serializeToTemplate(entities, outputs, extraParameters, extraOutputs, ownership, receiptRows) {
|
|
178
|
+
function serializeToTemplate(entities, outputs, extraParameters, extraOutputs, ownership, receiptRows, warnings = []) {
|
|
156
179
|
const template = {
|
|
157
180
|
AWSTemplateFormatVersion: "2010-09-09",
|
|
158
181
|
Resources: {},
|
|
@@ -435,6 +458,17 @@ function serializeToTemplate(entities, outputs, extraParameters, extraOutputs, o
|
|
|
435
458
|
if (extraOutputs && Object.keys(extraOutputs).length > 0) {
|
|
436
459
|
template.Outputs = { ...template.Outputs, ...extraOutputs };
|
|
437
460
|
}
|
|
461
|
+
// Values read from resources this template doesn't hold become parameters.
|
|
462
|
+
for (const [param, ref] of foreignAttrs.get(entityNames) ?? []) {
|
|
463
|
+
if (template.Resources[param] || template.Parameters?.[param])
|
|
464
|
+
continue;
|
|
465
|
+
template.Parameters = template.Parameters ?? {};
|
|
466
|
+
template.Parameters[param] = {
|
|
467
|
+
Type: "String",
|
|
468
|
+
Description: `${ref.entity}.${ref.attribute}, from another lexicon's output "${param}"`,
|
|
469
|
+
};
|
|
470
|
+
warnings.push(`aws: "${ref.entity}" is not in this template, so its ${ref.attribute} is the parameter "${param}". Deploy "${ref.entity}" first and pass its output "${param}" in.`);
|
|
471
|
+
}
|
|
438
472
|
refuseImportEnvelopes(template);
|
|
439
473
|
return template;
|
|
440
474
|
}
|
|
@@ -483,8 +517,10 @@ export const awsSerializer = {
|
|
|
483
517
|
}
|
|
484
518
|
// No nested stacks — use the simple path
|
|
485
519
|
if (!hasChildProjects) {
|
|
486
|
-
const
|
|
487
|
-
|
|
520
|
+
const warnings = [];
|
|
521
|
+
const template = serializeToTemplate(entities, outputs, undefined, undefined, ownership, receiptRows, warnings);
|
|
522
|
+
const primary = JSON.stringify(template, null, 2);
|
|
523
|
+
return warnings.length > 0 ? { primary, warnings } : primary;
|
|
488
524
|
}
|
|
489
525
|
// Has child projects — produce multi-file output
|
|
490
526
|
const allFiles = {};
|
|
@@ -519,11 +555,13 @@ export const awsSerializer = {
|
|
|
519
555
|
}
|
|
520
556
|
}
|
|
521
557
|
// Serialize the parent template (ChildProjectInstance entities become CF::Stack resources)
|
|
522
|
-
const
|
|
558
|
+
const warnings = [];
|
|
559
|
+
const parentTemplate = serializeToTemplate(entities, outputs, parentParams, undefined, ownership, receiptRows, warnings);
|
|
523
560
|
const primary = JSON.stringify(parentTemplate, null, 2);
|
|
524
561
|
return {
|
|
525
562
|
primary,
|
|
526
563
|
files: allFiles,
|
|
564
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
527
565
|
};
|
|
528
566
|
},
|
|
529
567
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@intentius/chant-lexicon-aws",
|
|
3
|
-
"version": "0.124.
|
|
3
|
+
"version": "0.124.34",
|
|
4
4
|
"description": "AWS CloudFormation lexicon for chant — declarative IaC in TypeScript",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"homepage": "https://intentius.io/chant",
|
|
@@ -80,6 +80,6 @@
|
|
|
80
80
|
"typescript": "^5.9.3"
|
|
81
81
|
},
|
|
82
82
|
"peerDependencies": {
|
|
83
|
-
"@intentius/chant": "^0.124.
|
|
83
|
+
"@intentius/chant": "^0.124.34"
|
|
84
84
|
}
|
|
85
85
|
}
|
|
@@ -90,6 +90,7 @@ export const awsAuditCatalog: Record<string, RuleMeta> = {
|
|
|
90
90
|
WAW067: auditRule("WAW067", "report-only", "guidance", "Single-AZ NAT gateway serves multi-AZ private subnets", "Add one NAT gateway per Availability Zone and point each AZ's subnets at its own.", { category: "best-practice" }),
|
|
91
91
|
WAW068: auditRule("WAW068", "report-only", "guidance", "VPN Gateway or Transit Gateway has only one attached VPN Connection", "Attach a second VPNConnection (ideally to a separate Customer Gateway) for redundancy.", { category: "best-practice" }),
|
|
92
92
|
WAW069: auditRule("WAW069", "merge-worthy", "guidance", "Template references a condition it never declares", "Declare the condition in the Conditions section (new Condition(...)), or fix the referenced name.", { category: "correctness" }),
|
|
93
|
+
WAW070: auditRule("WAW070", "merge-worthy", "guidance", "Ref or Fn::GetAtt names something the template doesn't declare", "Reference a resource or parameter in this template. A value from another lexicon's resource arrives as a parameter.", { category: "correctness" }),
|
|
93
94
|
};
|
|
94
95
|
|
|
95
96
|
// Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).
|
|
@@ -156,4 +156,8 @@ export const awsAuditLineage: Record<string, Lineage[]> = {
|
|
|
156
156
|
WAW069: [
|
|
157
157
|
{ tool: "cfn-lint", rule: "E8002", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E8002", relation: "equivalent" },
|
|
158
158
|
],
|
|
159
|
+
WAW070: [
|
|
160
|
+
{ tool: "cfn-lint", rule: "E1012", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E1012", relation: "equivalent" },
|
|
161
|
+
{ tool: "cfn-lint", rule: "E1010", url: "https://github.com/aws-cloudformation/cfn-lint/blob/main/docs/rules.md#E1010", relation: "overlaps" },
|
|
162
|
+
],
|
|
159
163
|
};
|
|
@@ -61,6 +61,7 @@ import { waw066 } from "./waw066";
|
|
|
61
61
|
import { waw067 } from "./waw067";
|
|
62
62
|
import { waw068 } from "./waw068";
|
|
63
63
|
import { waw069 } from "./waw069";
|
|
64
|
+
import { waw070 } from "./waw070";
|
|
64
65
|
|
|
65
66
|
export const postSynthChecks: PostSynthCheck[] = [
|
|
66
67
|
cor020,
|
|
@@ -124,4 +125,5 @@ export const postSynthChecks: PostSynthCheck[] = [
|
|
|
124
125
|
waw067,
|
|
125
126
|
waw068,
|
|
126
127
|
waw069,
|
|
128
|
+
waw070,
|
|
127
129
|
];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { createPostSynthContext } from "@intentius/chant-test-utils";
|
|
3
|
+
import { waw070, checkDanglingRefs } from "./waw070";
|
|
4
|
+
|
|
5
|
+
function makeCtx(template: object) {
|
|
6
|
+
return createPostSynthContext({ aws: template });
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("WAW070: Ref or Fn::GetAtt names something the template doesn't declare", () => {
|
|
10
|
+
test("check metadata", () => {
|
|
11
|
+
expect(waw070.id).toBe("WAW070");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("Fn::GetAtt to a resource not in the template → error", () => {
|
|
15
|
+
const diags = checkDanglingRefs(makeCtx({
|
|
16
|
+
Resources: {
|
|
17
|
+
Record: { Type: "AWS::Route53::RecordSet", Properties: { ResourceRecords: [{ "Fn::GetAtt": ["app", "id"] }] } },
|
|
18
|
+
},
|
|
19
|
+
}));
|
|
20
|
+
expect(diags).toHaveLength(1);
|
|
21
|
+
expect(diags[0].severity).toBe("error");
|
|
22
|
+
expect(diags[0].message).toContain('Fn::GetAtt "app"');
|
|
23
|
+
expect(diags[0].entity).toBe("Record");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("Ref to something undeclared in an output → error", () => {
|
|
27
|
+
const diags = checkDanglingRefs(makeCtx({
|
|
28
|
+
Resources: { Bucket: { Type: "AWS::S3::Bucket" } },
|
|
29
|
+
Outputs: { Name: { Value: { Ref: "Missing" } } },
|
|
30
|
+
}));
|
|
31
|
+
expect(diags).toHaveLength(1);
|
|
32
|
+
expect(diags[0].message).toContain('Output "Name" has Ref "Missing"');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("resources, parameters, pseudo-parameters and the string GetAtt form are fine", () => {
|
|
36
|
+
const diags = checkDanglingRefs(makeCtx({
|
|
37
|
+
Parameters: { AppFqdn: { Type: "String" } },
|
|
38
|
+
Resources: {
|
|
39
|
+
Bucket: { Type: "AWS::S3::Bucket" },
|
|
40
|
+
Record: {
|
|
41
|
+
Type: "AWS::Route53::RecordSet",
|
|
42
|
+
Properties: {
|
|
43
|
+
ResourceRecords: [{ Ref: "AppFqdn" }],
|
|
44
|
+
Comment: { "Fn::Join": ["", [{ Ref: "AWS::Region" }, { "Fn::GetAtt": "Bucket.Arn" }, { Ref: "Bucket" }]] },
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}));
|
|
49
|
+
expect(diags).toEqual([]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("Fn::GetAtt to a parameter → error", () => {
|
|
53
|
+
const diags = checkDanglingRefs(makeCtx({
|
|
54
|
+
Parameters: { P: { Type: "String" } },
|
|
55
|
+
Resources: { R: { Type: "AWS::SNS::Topic", Properties: { TopicName: { "Fn::GetAtt": ["P", "X"] } } } },
|
|
56
|
+
}));
|
|
57
|
+
expect(diags).toHaveLength(1);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WAW070: Ref or Fn::GetAtt names something the template doesn't declare
|
|
3
|
+
*
|
|
4
|
+
* A `Ref` must name a resource, a parameter or an `AWS::` pseudo-parameter,
|
|
5
|
+
* and a `Fn::GetAtt` must name a resource. CloudFormation rejects a template
|
|
6
|
+
* whose reference names neither (cfn-lint E1012/E1010). chant built such a
|
|
7
|
+
* template when a value read another lexicon's resource (#4180); the
|
|
8
|
+
* serializer now turns that into a parameter, and this check catches any
|
|
9
|
+
* reference still left dangling.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
13
|
+
import { parseCFTemplate } from "./cf-refs";
|
|
14
|
+
|
|
15
|
+
interface DanglingRef {
|
|
16
|
+
target: string;
|
|
17
|
+
via: "Ref" | "Fn::GetAtt";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function collectRefs(value: unknown, out: DanglingRef[], seen = new Set<unknown>()): void {
|
|
21
|
+
if (value === null || typeof value !== "object" || seen.has(value)) return;
|
|
22
|
+
seen.add(value);
|
|
23
|
+
if (Array.isArray(value)) {
|
|
24
|
+
for (const item of value) collectRefs(item, out, seen);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const obj = value as Record<string, unknown>;
|
|
28
|
+
if (typeof obj.Ref === "string" && Object.keys(obj).length === 1) {
|
|
29
|
+
out.push({ target: obj.Ref, via: "Ref" });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const getAtt = obj["Fn::GetAtt"];
|
|
33
|
+
if (getAtt !== undefined && Object.keys(obj).length === 1) {
|
|
34
|
+
const target = Array.isArray(getAtt) ? getAtt[0] : typeof getAtt === "string" ? getAtt.split(".")[0] : undefined;
|
|
35
|
+
if (typeof target === "string") out.push({ target, via: "Fn::GetAtt" });
|
|
36
|
+
if (Array.isArray(getAtt)) collectRefs(getAtt.slice(1), out, seen);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
for (const v of Object.values(obj)) collectRefs(v, out, seen);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function checkDanglingRefs(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
43
|
+
const diagnostics: PostSynthDiagnostic[] = [];
|
|
44
|
+
|
|
45
|
+
for (const [lexicon, output] of ctx.outputs) {
|
|
46
|
+
if (lexicon !== "aws") continue;
|
|
47
|
+
const template = parseCFTemplate(output);
|
|
48
|
+
if (!template?.Resources || typeof template.Resources !== "object") continue;
|
|
49
|
+
|
|
50
|
+
const resources = new Set(Object.keys(template.Resources));
|
|
51
|
+
const parameters = new Set(Object.keys((template.Parameters as Record<string, unknown> | undefined) ?? {}));
|
|
52
|
+
|
|
53
|
+
const report = (where: string, value: unknown) => {
|
|
54
|
+
const refs: DanglingRef[] = [];
|
|
55
|
+
collectRefs(value, refs);
|
|
56
|
+
for (const ref of refs) {
|
|
57
|
+
const known = ref.via === "Ref"
|
|
58
|
+
? resources.has(ref.target) || parameters.has(ref.target) || ref.target.startsWith("AWS::")
|
|
59
|
+
: resources.has(ref.target);
|
|
60
|
+
if (known) continue;
|
|
61
|
+
diagnostics.push({
|
|
62
|
+
checkId: "WAW070",
|
|
63
|
+
severity: "error",
|
|
64
|
+
message: `${where} has ${ref.via} "${ref.target}", which is not ${ref.via === "Ref" ? "a resource, a parameter or a pseudo-parameter" : "a resource"} in this template`,
|
|
65
|
+
entity: where.replace(/^(Resource|Output) /, "").replace(/"/g, ""),
|
|
66
|
+
lexicon: "aws",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
for (const [logicalId, resource] of Object.entries(template.Resources)) {
|
|
72
|
+
report(`Resource "${logicalId}"`, resource.Properties);
|
|
73
|
+
}
|
|
74
|
+
for (const [name, out] of Object.entries((template.Outputs as Record<string, { Value?: unknown }> | undefined) ?? {})) {
|
|
75
|
+
report(`Output "${name}"`, out?.Value);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return diagnostics;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const waw070: PostSynthCheck = {
|
|
83
|
+
id: "WAW070",
|
|
84
|
+
description: "Ref or Fn::GetAtt names something the template doesn't declare",
|
|
85
|
+
|
|
86
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
87
|
+
return checkDanglingRefs(ctx);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A value read from a resource this template doesn't hold, such as another
|
|
3
|
+
* lexicon's, is a parameter, not a dangling Fn::GetAtt (#4180).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import { AttrRef } from "@intentius/chant";
|
|
8
|
+
import { DECLARABLE_MARKER } from "@intentius/chant/declarable";
|
|
9
|
+
import { awsSerializer } from "./serializer";
|
|
10
|
+
|
|
11
|
+
function entity(lexicon: string, entityType: string, props: Record<string, unknown> = {}): any {
|
|
12
|
+
return { [DECLARABLE_MARKER]: true, lexicon, entityType, kind: "resource", props };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("aws foreign attributes", () => {
|
|
16
|
+
it("turns another lexicon's attribute into a parameter and warns", () => {
|
|
17
|
+
const app = entity("azure", "Microsoft.App/containerApps", { name: "web" });
|
|
18
|
+
const fqdn = new AttrRef(app, "configuration.ingress.fqdn");
|
|
19
|
+
fqdn._setLogicalName("app");
|
|
20
|
+
const record = entity("aws", "AWS::Route53::RecordSet", { Type: "CNAME", ResourceRecords: [fqdn] });
|
|
21
|
+
|
|
22
|
+
const result = awsSerializer.serialize(new Map([["record", record]]));
|
|
23
|
+
expect(typeof result).toBe("object");
|
|
24
|
+
const { primary, warnings } = result as { primary: string; warnings?: string[] };
|
|
25
|
+
const template = JSON.parse(primary);
|
|
26
|
+
|
|
27
|
+
expect(template.Resources.record.Properties.ResourceRecords).toEqual([{ Ref: "appConfigurationIngressFqdn" }]);
|
|
28
|
+
expect(template.Parameters.appConfigurationIngressFqdn.Type).toBe("String");
|
|
29
|
+
expect(warnings).toHaveLength(1);
|
|
30
|
+
expect(warnings![0]).toContain('"app" is not in this template');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("keeps Fn::GetAtt for a resource in the template", () => {
|
|
34
|
+
const bucket = entity("aws", "AWS::S3::Bucket");
|
|
35
|
+
const arn = new AttrRef(bucket, "Arn");
|
|
36
|
+
arn._setLogicalName("bucket");
|
|
37
|
+
const topic = entity("aws", "AWS::SNS::Topic", { TopicName: arn });
|
|
38
|
+
|
|
39
|
+
const result = awsSerializer.serialize(new Map([["bucket", bucket], ["topic", topic]]));
|
|
40
|
+
expect(typeof result).toBe("string");
|
|
41
|
+
expect(JSON.parse(result as string).Resources.topic.Properties.TopicName).toEqual({ "Fn::GetAtt": ["bucket", "Arn"] });
|
|
42
|
+
});
|
|
43
|
+
});
|
package/src/serializer.ts
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
RECEIPT_UNRESOLVED_VALUE_NOTE,
|
|
16
16
|
receiptParameterName,
|
|
17
17
|
} from "./effect-receipt-row";
|
|
18
|
-
import type
|
|
18
|
+
import { sanitizeLogicalId, 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";
|
|
@@ -104,12 +104,47 @@ interface CFOutput {
|
|
|
104
104
|
Condition?: string;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* An attribute of a resource this template doesn't hold (#4180): another
|
|
109
|
+
* lexicon's, such as an Azure Container App's hostname read by a Route 53
|
|
110
|
+
* record. `Fn::GetAtt` can't name it, so the template takes the value as a
|
|
111
|
+
* parameter, named as the source lexicon's output for it is
|
|
112
|
+
* (`LexiconOutput.auto`), and whoever deploys passes that output in.
|
|
113
|
+
*/
|
|
114
|
+
interface ForeignAttr {
|
|
115
|
+
entity: string;
|
|
116
|
+
attribute: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Per template (keyed by its entity map): the foreign attributes its values read, by parameter name. */
|
|
120
|
+
const foreignAttrs = new WeakMap<Map<Declarable, string>, Map<string, ForeignAttr>>();
|
|
121
|
+
const namesIn = new WeakMap<Map<Declarable, string>, Set<string>>();
|
|
122
|
+
|
|
123
|
+
function holds(entityNames: Map<Declarable, string>, name: string): boolean {
|
|
124
|
+
let names = namesIn.get(entityNames);
|
|
125
|
+
if (!names) {
|
|
126
|
+
names = new Set(entityNames.values());
|
|
127
|
+
namesIn.set(entityNames, names);
|
|
128
|
+
}
|
|
129
|
+
return names.has(name);
|
|
130
|
+
}
|
|
131
|
+
|
|
107
132
|
/**
|
|
108
133
|
* CloudFormation-specific visitor for the generic serializer walker.
|
|
109
134
|
*/
|
|
110
135
|
function cfnVisitor(entityNames: Map<Declarable, string>): SerializerVisitor {
|
|
111
136
|
return {
|
|
112
|
-
attrRef: (name, attr) =>
|
|
137
|
+
attrRef: (name, attr) => {
|
|
138
|
+
if (holds(entityNames, name)) return { "Fn::GetAtt": [name, attr] };
|
|
139
|
+
const param = sanitizeLogicalId(name, attr);
|
|
140
|
+
let foreign = foreignAttrs.get(entityNames);
|
|
141
|
+
if (!foreign) {
|
|
142
|
+
foreign = new Map();
|
|
143
|
+
foreignAttrs.set(entityNames, foreign);
|
|
144
|
+
}
|
|
145
|
+
foreign.set(param, { entity: name, attribute: attr });
|
|
146
|
+
return { Ref: param };
|
|
147
|
+
},
|
|
113
148
|
resourceRef: (name) => ({ Ref: name }),
|
|
114
149
|
propertyDeclarable: (entity, walk) => {
|
|
115
150
|
if (!isResourceDeclarable(entity) || typeof entity.props !== "object" || entity.props === null) {
|
|
@@ -267,6 +302,7 @@ function serializeToTemplate(
|
|
|
267
302
|
extraOutputs?: Record<string, CFOutput>,
|
|
268
303
|
ownership?: OwnershipMarker,
|
|
269
304
|
receiptRows?: Record<string, ReceiptRow>,
|
|
305
|
+
warnings: string[] = [],
|
|
270
306
|
): CFTemplate {
|
|
271
307
|
const template: CFTemplate = {
|
|
272
308
|
AWSTemplateFormatVersion: "2010-09-09",
|
|
@@ -560,6 +596,19 @@ function serializeToTemplate(
|
|
|
560
596
|
template.Outputs = { ...template.Outputs, ...extraOutputs };
|
|
561
597
|
}
|
|
562
598
|
|
|
599
|
+
// Values read from resources this template doesn't hold become parameters.
|
|
600
|
+
for (const [param, ref] of foreignAttrs.get(entityNames) ?? []) {
|
|
601
|
+
if (template.Resources[param] || template.Parameters?.[param]) continue;
|
|
602
|
+
template.Parameters = template.Parameters ?? {};
|
|
603
|
+
template.Parameters[param] = {
|
|
604
|
+
Type: "String",
|
|
605
|
+
Description: `${ref.entity}.${ref.attribute}, from another lexicon's output "${param}"`,
|
|
606
|
+
};
|
|
607
|
+
warnings.push(
|
|
608
|
+
`aws: "${ref.entity}" is not in this template, so its ${ref.attribute} is the parameter "${param}". Deploy "${ref.entity}" first and pass its output "${param}" in.`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
563
612
|
refuseImportEnvelopes(template);
|
|
564
613
|
return template;
|
|
565
614
|
}
|
|
@@ -616,8 +665,10 @@ export const awsSerializer: Serializer = {
|
|
|
616
665
|
|
|
617
666
|
// No nested stacks — use the simple path
|
|
618
667
|
if (!hasChildProjects) {
|
|
619
|
-
const
|
|
620
|
-
|
|
668
|
+
const warnings: string[] = [];
|
|
669
|
+
const template = serializeToTemplate(entities, outputs, undefined, undefined, ownership, receiptRows, warnings);
|
|
670
|
+
const primary = JSON.stringify(template, null, 2);
|
|
671
|
+
return warnings.length > 0 ? { primary, warnings } : primary;
|
|
621
672
|
}
|
|
622
673
|
|
|
623
674
|
// Has child projects — produce multi-file output
|
|
@@ -656,12 +707,14 @@ export const awsSerializer: Serializer = {
|
|
|
656
707
|
}
|
|
657
708
|
|
|
658
709
|
// Serialize the parent template (ChildProjectInstance entities become CF::Stack resources)
|
|
659
|
-
const
|
|
710
|
+
const warnings: string[] = [];
|
|
711
|
+
const parentTemplate = serializeToTemplate(entities, outputs, parentParams, undefined, ownership, receiptRows, warnings);
|
|
660
712
|
const primary = JSON.stringify(parentTemplate, null, 2);
|
|
661
713
|
|
|
662
714
|
return {
|
|
663
715
|
primary,
|
|
664
716
|
files: allFiles,
|
|
717
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
665
718
|
};
|
|
666
719
|
},
|
|
667
720
|
};
|