@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.
@@ -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 { LexiconOutput } from "@intentius/chant/lexicon-output";
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) => ({ "Fn::GetAtt": [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 template = serializeToTemplate(entities, outputs, undefined, undefined, ownership, receiptRows);
620
- return JSON.stringify(template, null, 2);
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 parentTemplate = serializeToTemplate(entities, outputs, parentParams, undefined, ownership, receiptRows);
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
  };