@intentius/chant-lexicon-aws 0.124.32 → 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/health.d.ts +61 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +216 -0
- 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/plugin.d.ts.map +1 -1
- package/dist/plugin.js +6 -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/health.test.ts +136 -0
- package/src/health.ts +281 -0
- 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/plugin.ts +8 -0
- package/src/serializer-foreign-attr.test.ts +43 -0
- package/src/serializer.ts +58 -5
|
@@ -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/src/plugin.ts
CHANGED
|
@@ -911,6 +911,14 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
|
|
|
911
911
|
/** The noise rules the deep pass applies to both the live and declared trees. */
|
|
912
912
|
deepNormalizationHooks: awsDeepNormalizationHooks,
|
|
913
913
|
|
|
914
|
+
/** Runtime health (#4177): Route 53 health checks and ECS services. Implementation in ./health.ts. */
|
|
915
|
+
healthTypes: ["AWS::Route53::HealthCheck", "AWS::ECS::Service"],
|
|
916
|
+
|
|
917
|
+
async observeHealth(options) {
|
|
918
|
+
const { observeHealthAws } = await import("./health");
|
|
919
|
+
return observeHealthAws(options);
|
|
920
|
+
},
|
|
921
|
+
|
|
914
922
|
async describeStackStatus(options: { environment: string; stack: string }): Promise<StackStatusObservation | null> {
|
|
915
923
|
const { getRuntime } = await import("@intentius/chant/runtime-adapter");
|
|
916
924
|
const rt = getRuntime();
|
|
@@ -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
|
};
|