@intentius/chant-lexicon-aws 0.46.0 → 0.50.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 (183) hide show
  1. package/dist/api/read-client.d.ts +10 -2
  2. package/dist/api/read-client.d.ts.map +1 -1
  3. package/dist/codegen/generate-lexicon.d.ts +5 -0
  4. package/dist/codegen/generate-lexicon.d.ts.map +1 -1
  5. package/dist/components/host-delivery.d.ts +4 -3
  6. package/dist/components/host-delivery.d.ts.map +1 -1
  7. package/dist/composites/agentcore-agent.d.ts +26 -1
  8. package/dist/composites/agentcore-agent.d.ts.map +1 -1
  9. package/dist/composites/bucket-deployment.d.ts +82 -0
  10. package/dist/composites/bucket-deployment.d.ts.map +1 -0
  11. package/dist/composites/dynamodb-table.d.ts +45 -0
  12. package/dist/composites/dynamodb-table.d.ts.map +1 -0
  13. package/dist/composites/ec2-instance-bundle.d.ts +61 -0
  14. package/dist/composites/ec2-instance-bundle.d.ts.map +1 -0
  15. package/dist/composites/ecr-repository.d.ts +49 -0
  16. package/dist/composites/ecr-repository.d.ts.map +1 -0
  17. package/dist/composites/fargate-service.d.ts +16 -0
  18. package/dist/composites/fargate-service.d.ts.map +1 -1
  19. package/dist/composites/index.d.ts +16 -2
  20. package/dist/composites/index.d.ts.map +1 -1
  21. package/dist/composites/monitoring-stack.d.ts +99 -0
  22. package/dist/composites/monitoring-stack.d.ts.map +1 -0
  23. package/dist/composites/nlb-service.d.ts +48 -0
  24. package/dist/composites/nlb-service.d.ts.map +1 -0
  25. package/dist/composites/step-functions-workflow.d.ts +36 -0
  26. package/dist/composites/step-functions-workflow.d.ts.map +1 -0
  27. package/dist/deep-observe.d.ts +45 -0
  28. package/dist/deep-observe.d.ts.map +1 -1
  29. package/dist/deep-topology.d.ts +104 -0
  30. package/dist/deep-topology.d.ts.map +1 -0
  31. package/dist/effect-receipt-row.d.ts +92 -0
  32. package/dist/effect-receipt-row.d.ts.map +1 -0
  33. package/dist/index.d.ts +7 -2
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/integrity.json +14 -6
  36. package/dist/lint/audit-catalog.d.ts.map +1 -1
  37. package/dist/lint/post-synth/cf-refs.d.ts +17 -0
  38. package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
  39. package/dist/lint/post-synth/index.d.ts.map +1 -1
  40. package/dist/lint/post-synth/waw016.d.ts +10 -2
  41. package/dist/lint/post-synth/waw016.d.ts.map +1 -1
  42. package/dist/lint/post-synth/waw061.d.ts +19 -0
  43. package/dist/lint/post-synth/waw061.d.ts.map +1 -0
  44. package/dist/lint/post-synth/waw062.d.ts +19 -0
  45. package/dist/lint/post-synth/waw062.d.ts.map +1 -0
  46. package/dist/lint/post-synth/waw063.d.ts +35 -0
  47. package/dist/lint/post-synth/waw063.d.ts.map +1 -0
  48. package/dist/lint/post-synth/waw064.d.ts +17 -0
  49. package/dist/lint/post-synth/waw064.d.ts.map +1 -0
  50. package/dist/lint/post-synth/waw065.d.ts +23 -0
  51. package/dist/lint/post-synth/waw065.d.ts.map +1 -0
  52. package/dist/lint/post-synth/waw066.d.ts +25 -0
  53. package/dist/lint/post-synth/waw066.d.ts.map +1 -0
  54. package/dist/lint/post-synth/waw067.d.ts +24 -0
  55. package/dist/lint/post-synth/waw067.d.ts.map +1 -0
  56. package/dist/lint/post-synth/waw068.d.ts +20 -0
  57. package/dist/lint/post-synth/waw068.d.ts.map +1 -0
  58. package/dist/manifest.json +1 -1
  59. package/dist/meta.json +34 -0
  60. package/dist/okf/index.md +8 -0
  61. package/dist/okf/rules/WAW061.md +17 -0
  62. package/dist/okf/rules/WAW062.md +27 -0
  63. package/dist/okf/rules/WAW063.md +19 -0
  64. package/dist/okf/rules/WAW064.md +18 -0
  65. package/dist/okf/rules/WAW065.md +25 -0
  66. package/dist/okf/rules/WAW066.md +22 -0
  67. package/dist/okf/rules/WAW067.md +20 -0
  68. package/dist/okf/rules/WAW068.md +19 -0
  69. package/dist/okf/types/Action.md +1 -0
  70. package/dist/okf/types/AutoScalingGroup.md +1 -0
  71. package/dist/okf/types/Bucket.md +1 -0
  72. package/dist/okf/types/EC2Route.md +2 -0
  73. package/dist/okf/types/ECRRepository.md +1 -0
  74. package/dist/okf/types/Function.md +1 -0
  75. package/dist/okf/types/IamPolicy.md +1 -0
  76. package/dist/okf/types/InternetGateway.md +4 -0
  77. package/dist/okf/types/LaunchTemplate.md +1 -0
  78. package/dist/okf/types/LoadBalancer.md +1 -0
  79. package/dist/okf/types/ManagedPolicy.md +2 -0
  80. package/dist/okf/types/Map.md +5 -0
  81. package/dist/okf/types/NatGateway.md +4 -0
  82. package/dist/okf/types/Role.md +2 -0
  83. package/dist/okf/types/RouteTable.md +4 -0
  84. package/dist/okf/types/SecurityGroup.md +1 -0
  85. package/dist/okf/types/StateMachine.md +4 -0
  86. package/dist/okf/types/Subnet.md +6 -0
  87. package/dist/okf/types/SubnetRouteTableAssociation.md +5 -0
  88. package/dist/okf/types/Table.md +4 -0
  89. package/dist/okf/types/TransitGateway.md +6 -0
  90. package/dist/okf/types/TransitGatewayAttachment.md +4 -0
  91. package/dist/okf/types/TransitGatewayPeeringAttachment.md +4 -0
  92. package/dist/okf/types/TransitGatewayRoute.md +5 -0
  93. package/dist/okf/types/TransitGatewayRouteTable.md +4 -0
  94. package/dist/okf/types/TransitGatewayRouteTableAssociation.md +4 -0
  95. package/dist/okf/types/TransitGatewayRouteTablePropagation.md +4 -0
  96. package/dist/okf/types/TransitGatewayVpcAttachment.md +4 -0
  97. package/dist/okf/types/Type.md +8 -0
  98. package/dist/okf/types/VPNConnection.md +4 -0
  99. package/dist/okf/types/VPNGateway.md +4 -0
  100. package/dist/okf/types/Vpc.md +1 -0
  101. package/dist/op/activities/index.d.ts +6 -0
  102. package/dist/op/activities/index.d.ts.map +1 -1
  103. package/dist/op/builders.d.ts +47 -0
  104. package/dist/op/builders.d.ts.map +1 -0
  105. package/dist/plugin.d.ts.map +1 -1
  106. package/dist/receipt-store.d.ts +109 -0
  107. package/dist/receipt-store.d.ts.map +1 -0
  108. package/dist/rules/cf-refs.ts +40 -0
  109. package/dist/rules/waw016.ts +37 -16
  110. package/dist/rules/waw061.ts +67 -0
  111. package/dist/rules/waw062.ts +0 -0
  112. package/dist/rules/waw063.ts +0 -0
  113. package/dist/rules/waw064.ts +52 -0
  114. package/dist/rules/waw065.ts +110 -0
  115. package/dist/rules/waw066.ts +155 -0
  116. package/dist/rules/waw067.ts +114 -0
  117. package/dist/rules/waw068.ts +78 -0
  118. package/dist/serializer.d.ts.map +1 -1
  119. package/dist/skills/chant-aws-carve-terraform.md +26 -23
  120. package/dist/spec/parse.d.ts +7 -0
  121. package/dist/spec/parse.d.ts.map +1 -1
  122. package/package.json +3 -3
  123. package/src/api/read-client.test.ts +9 -0
  124. package/src/api/read-client.ts +15 -2
  125. package/src/codegen/fallback.ts +1 -0
  126. package/src/codegen/generate-lexicon.ts +6 -0
  127. package/src/codegen/sam.ts +9 -0
  128. package/src/components/host-delivery.ts +4 -3
  129. package/src/composites/agentcore-agent.ts +35 -13
  130. package/src/composites/bucket-deployment.test.ts +84 -0
  131. package/src/composites/bucket-deployment.ts +153 -0
  132. package/src/composites/composites.test.ts +325 -0
  133. package/src/composites/dynamodb-table.test.ts +154 -0
  134. package/src/composites/dynamodb-table.ts +124 -0
  135. package/src/composites/ec2-instance-bundle.ts +134 -0
  136. package/src/composites/ecr-repository.test.ts +118 -0
  137. package/src/composites/ecr-repository.ts +117 -0
  138. package/src/composites/fargate-service.ts +19 -3
  139. package/src/composites/index.ts +21 -2
  140. package/src/composites/monitoring-stack.test.ts +130 -0
  141. package/src/composites/monitoring-stack.ts +164 -0
  142. package/src/composites/nlb-service.ts +117 -0
  143. package/src/composites/step-functions-workflow.ts +127 -0
  144. package/src/deep-observe.test.ts +201 -1
  145. package/src/deep-observe.ts +204 -0
  146. package/src/deep-topology.test.ts +392 -0
  147. package/src/deep-topology.ts +237 -0
  148. package/src/effect-receipt-row.test.ts +164 -0
  149. package/src/effect-receipt-row.ts +147 -0
  150. package/src/generated/lexicon-aws.json +34 -0
  151. package/src/index.ts +36 -7
  152. package/src/lint/audit-catalog.ts +16 -0
  153. package/src/lint/post-synth/cf-refs.ts +40 -0
  154. package/src/lint/post-synth/index.ts +16 -0
  155. package/src/lint/post-synth/waw016.test.ts +61 -4
  156. package/src/lint/post-synth/waw016.ts +37 -16
  157. package/src/lint/post-synth/waw061.test.ts +99 -0
  158. package/src/lint/post-synth/waw061.ts +67 -0
  159. package/src/lint/post-synth/waw062.test.ts +109 -0
  160. package/src/lint/post-synth/waw062.ts +0 -0
  161. package/src/lint/post-synth/waw063.test.ts +220 -0
  162. package/src/lint/post-synth/waw063.ts +0 -0
  163. package/src/lint/post-synth/waw064.test.ts +79 -0
  164. package/src/lint/post-synth/waw064.ts +52 -0
  165. package/src/lint/post-synth/waw065.test.ts +90 -0
  166. package/src/lint/post-synth/waw065.ts +110 -0
  167. package/src/lint/post-synth/waw066.test.ts +108 -0
  168. package/src/lint/post-synth/waw066.ts +155 -0
  169. package/src/lint/post-synth/waw067.test.ts +79 -0
  170. package/src/lint/post-synth/waw067.ts +114 -0
  171. package/src/lint/post-synth/waw068.test.ts +74 -0
  172. package/src/lint/post-synth/waw068.ts +78 -0
  173. package/src/lsp/hover.ts +17 -2
  174. package/src/op/activities/index.ts +15 -0
  175. package/src/op/builders.test.ts +61 -0
  176. package/src/op/builders.ts +72 -0
  177. package/src/plugin.ts +41 -10
  178. package/src/receipt-store.test.ts +204 -0
  179. package/src/receipt-store.ts +315 -0
  180. package/src/serializer.ts +105 -2
  181. package/src/skills/chant-aws-carve-terraform.md +26 -23
  182. package/src/spec/parse.test.ts +74 -0
  183. package/src/spec/parse.ts +26 -6
@@ -0,0 +1,155 @@
1
+ /**
2
+ * WAW066: Private Subnet Route Table Has No Working Default Route
3
+ *
4
+ * Cross-resource join: a subnet's outbound path depends on a chain of three
5
+ * independently-declared resources — the `AWS::EC2::SubnetRouteTableAssociation`
6
+ * that pins it to a route table, the `AWS::EC2::Route` on that table for
7
+ * `0.0.0.0/0`, and the gateway that route names. CloudFormation validates
8
+ * none of this end-to-end: a subnet can be wired to a route table with no
9
+ * default route at all, or with a default route whose NAT gateway / Transit
10
+ * Gateway target was renamed or removed elsewhere in the template, and the
11
+ * stack still deploys clean. The subnet is black-holed — every deploy
12
+ * succeeds, and the only symptom is a workload with no outbound connectivity.
13
+ *
14
+ * A route table with a default route to an `AWS::EC2::InternetGateway` is
15
+ * treated as a public subnet and is out of scope for this check — this rule
16
+ * only flags the private case: no default route, or one whose target
17
+ * (`NatGatewayId` / `TransitGatewayId` / etc.) does not resolve to a
18
+ * resource declared in the same template. A route naming a target that
19
+ * exists in the template resolves cleanly and is never flagged, per the
20
+ * cross-resource contract.
21
+ */
22
+
23
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
24
+ import { parseCFTemplate, findResourceRefs, type CFResource, type CFTemplate } from "./cf-refs";
25
+
26
+ type RouteClass = "public" | "resolved" | "dangling";
27
+
28
+ const OTHER_TARGET_KEYS = [
29
+ "NatGatewayId",
30
+ "TransitGatewayId",
31
+ "VpcPeeringConnectionId",
32
+ "EgressOnlyInternetGatewayId",
33
+ "LocalGatewayId",
34
+ "CarrierGatewayId",
35
+ "NetworkInterfaceId",
36
+ "InstanceId",
37
+ ] as const;
38
+
39
+ /** Classify a `0.0.0.0/0` Route's target. A target Ref that exists always resolves. */
40
+ function classifyDefaultRoute(route: CFResource, resources: Record<string, CFResource>): RouteClass {
41
+ const props = route.Properties ?? {};
42
+
43
+ if (props.GatewayId !== undefined) {
44
+ if (typeof props.GatewayId === "string") {
45
+ return props.GatewayId.startsWith("igw-") ? "public" : "resolved";
46
+ }
47
+ const refs = findResourceRefs(props.GatewayId);
48
+ if (refs.size !== 1) return "resolved"; // unprovable intrinsic — stay quiet
49
+ const [id] = refs;
50
+ if (!resources[id]) return "dangling";
51
+ return resources[id].Type === "AWS::EC2::InternetGateway" ? "public" : "resolved";
52
+ }
53
+
54
+ for (const key of OTHER_TARGET_KEYS) {
55
+ const value = (props as Record<string, unknown>)[key];
56
+ if (value === undefined) continue;
57
+ if (typeof value === "string") return "resolved"; // literal id from outside the template
58
+ const refs = findResourceRefs(value);
59
+ if (refs.size !== 1) return "resolved";
60
+ const [id] = refs;
61
+ return resources[id] ? "resolved" : "dangling";
62
+ }
63
+
64
+ return "resolved"; // no recognized target key — can't classify, stay quiet
65
+ }
66
+
67
+ function isDefaultRoute(route: CFResource): boolean {
68
+ return route.Properties?.DestinationCidrBlock === "0.0.0.0/0";
69
+ }
70
+
71
+ export function checkPrivateSubnetDefaultRoute(ctx: PostSynthContext): PostSynthDiagnostic[] {
72
+ const diagnostics: PostSynthDiagnostic[] = [];
73
+
74
+ for (const [_lexicon, output] of ctx.outputs) {
75
+ const template: CFTemplate | null = parseCFTemplate(output);
76
+ if (!template?.Resources) continue;
77
+ const resources = template.Resources;
78
+
79
+ // subnetId -> Set<routeTableId>, from explicit associations only.
80
+ const subnetRouteTables = new Map<string, Set<string>>();
81
+ for (const resource of Object.values(resources)) {
82
+ if (resource.Type !== "AWS::EC2::SubnetRouteTableAssociation") continue;
83
+ const props = resource.Properties ?? {};
84
+ const subnetRefs = findResourceRefs(props.SubnetId);
85
+ const rtRefs = findResourceRefs(props.RouteTableId);
86
+ for (const subnetId of subnetRefs) {
87
+ let set = subnetRouteTables.get(subnetId);
88
+ if (!set) {
89
+ set = new Set();
90
+ subnetRouteTables.set(subnetId, set);
91
+ }
92
+ for (const rtId of rtRefs) set.add(rtId);
93
+ }
94
+ }
95
+
96
+ // routeTableId -> Route resources targeting it.
97
+ const routesByTable = new Map<string, CFResource[]>();
98
+ for (const resource of Object.values(resources)) {
99
+ if (resource.Type !== "AWS::EC2::Route") continue;
100
+ for (const rtId of findResourceRefs(resource.Properties?.RouteTableId)) {
101
+ let list = routesByTable.get(rtId);
102
+ if (!list) {
103
+ list = [];
104
+ routesByTable.set(rtId, list);
105
+ }
106
+ list.push(resource);
107
+ }
108
+ }
109
+
110
+ for (const [subnetId, rtIds] of subnetRouteTables) {
111
+ if (resources[subnetId]?.Type !== "AWS::EC2::Subnet") continue;
112
+
113
+ for (const rtId of rtIds) {
114
+ if (resources[rtId]?.Type !== "AWS::EC2::RouteTable") continue;
115
+
116
+ const routes = routesByTable.get(rtId) ?? [];
117
+ const defaultRoutes = routes.filter(isDefaultRoute);
118
+
119
+ if (defaultRoutes.length === 0) {
120
+ diagnostics.push({
121
+ checkId: "WAW066",
122
+ severity: "warning",
123
+ message: `Subnet "${subnetId}" is associated with route table "${rtId}", which has no default route (0.0.0.0/0) — outbound traffic from this subnet has no path out, black-holed by omission`,
124
+ entity: subnetId,
125
+ lexicon: "aws",
126
+ });
127
+ continue;
128
+ }
129
+
130
+ const classes = defaultRoutes.map((route) => classifyDefaultRoute(route, resources));
131
+ if (classes.includes("public")) continue; // has a real IGW default route — public subnet, out of scope
132
+ if (classes.every((c) => c === "dangling")) {
133
+ diagnostics.push({
134
+ checkId: "WAW066",
135
+ severity: "error",
136
+ message: `Subnet "${subnetId}"'s route table "${rtId}" has a default route (0.0.0.0/0) whose target does not exist in this template — outbound traffic from this subnet is black-holed`,
137
+ entity: subnetId,
138
+ lexicon: "aws",
139
+ });
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ return diagnostics;
146
+ }
147
+
148
+ export const waw066: PostSynthCheck = {
149
+ id: "WAW066",
150
+ description: "Private subnet's route table has no default route, or its default route targets a gateway that does not exist in the template",
151
+
152
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
153
+ return checkPrivateSubnetDefaultRoute(ctx);
154
+ },
155
+ };
@@ -0,0 +1,79 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { createPostSynthContext } from "@intentius/chant-test-utils";
3
+ import { waw067, checkNatGatewaySingleAz } from "./waw067";
4
+
5
+ function makeCtx(template: object) {
6
+ return createPostSynthContext({ aws: template });
7
+ }
8
+
9
+ describe("WAW067: Single-AZ NAT Gateway Serves Multi-AZ Private Subnets", () => {
10
+ test("check metadata", () => {
11
+ expect(waw067.id).toBe("WAW067");
12
+ expect(waw067.description).toContain("NAT gateway");
13
+ });
14
+
15
+ test("one NAT gateway serving private subnets in two AZs → warning", () => {
16
+ const ctx = makeCtx({
17
+ Resources: {
18
+ Vpc: { Type: "AWS::EC2::VPC", Properties: { CidrBlock: "10.0.0.0/16" } },
19
+ PublicSubnetA: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.0.0/24", AvailabilityZone: "us-east-1a" } },
20
+ NatGateway: { Type: "AWS::EC2::NatGateway", Properties: { SubnetId: { Ref: "PublicSubnetA" } } },
21
+
22
+ PrivateSubnetA: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.1.0/24", AvailabilityZone: "us-east-1a" } },
23
+ PrivateRouteTableA: { Type: "AWS::EC2::RouteTable", Properties: { VpcId: { Ref: "Vpc" } } },
24
+ PrivateAssocA: { Type: "AWS::EC2::SubnetRouteTableAssociation", Properties: { SubnetId: { Ref: "PrivateSubnetA" }, RouteTableId: { Ref: "PrivateRouteTableA" } } },
25
+ PrivateDefaultRouteA: { Type: "AWS::EC2::Route", Properties: { RouteTableId: { Ref: "PrivateRouteTableA" }, DestinationCidrBlock: "0.0.0.0/0", NatGatewayId: { Ref: "NatGateway" } } },
26
+
27
+ PrivateSubnetB: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.2.0/24", AvailabilityZone: "us-east-1b" } },
28
+ PrivateRouteTableB: { Type: "AWS::EC2::RouteTable", Properties: { VpcId: { Ref: "Vpc" } } },
29
+ PrivateAssocB: { Type: "AWS::EC2::SubnetRouteTableAssociation", Properties: { SubnetId: { Ref: "PrivateSubnetB" }, RouteTableId: { Ref: "PrivateRouteTableB" } } },
30
+ PrivateDefaultRouteB: { Type: "AWS::EC2::Route", Properties: { RouteTableId: { Ref: "PrivateRouteTableB" }, DestinationCidrBlock: "0.0.0.0/0", NatGatewayId: { Ref: "NatGateway" } } },
31
+ },
32
+ });
33
+ const diags = checkNatGatewaySingleAz(ctx);
34
+ expect(diags).toHaveLength(1);
35
+ expect(diags[0].checkId).toBe("WAW067");
36
+ expect(diags[0].severity).toBe("warning");
37
+ expect(diags[0].entity).toBe("NatGateway");
38
+ expect(diags[0].message).toContain("us-east-1a");
39
+ expect(diags[0].message).toContain("us-east-1b");
40
+ });
41
+
42
+ test("one NAT gateway per AZ, each serving only its own AZ's subnets → no diagnostic", () => {
43
+ const ctx = makeCtx({
44
+ Resources: {
45
+ Vpc: { Type: "AWS::EC2::VPC", Properties: { CidrBlock: "10.0.0.0/16" } },
46
+
47
+ PublicSubnetA: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.0.0/24", AvailabilityZone: "us-east-1a" } },
48
+ NatGatewayA: { Type: "AWS::EC2::NatGateway", Properties: { SubnetId: { Ref: "PublicSubnetA" } } },
49
+ PrivateSubnetA: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.1.0/24", AvailabilityZone: "us-east-1a" } },
50
+ PrivateRouteTableA: { Type: "AWS::EC2::RouteTable", Properties: { VpcId: { Ref: "Vpc" } } },
51
+ PrivateAssocA: { Type: "AWS::EC2::SubnetRouteTableAssociation", Properties: { SubnetId: { Ref: "PrivateSubnetA" }, RouteTableId: { Ref: "PrivateRouteTableA" } } },
52
+ PrivateDefaultRouteA: { Type: "AWS::EC2::Route", Properties: { RouteTableId: { Ref: "PrivateRouteTableA" }, DestinationCidrBlock: "0.0.0.0/0", NatGatewayId: { Ref: "NatGatewayA" } } },
53
+
54
+ PublicSubnetB: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.10.0/24", AvailabilityZone: "us-east-1b" } },
55
+ NatGatewayB: { Type: "AWS::EC2::NatGateway", Properties: { SubnetId: { Ref: "PublicSubnetB" } } },
56
+ PrivateSubnetB: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.2.0/24", AvailabilityZone: "us-east-1b" } },
57
+ PrivateRouteTableB: { Type: "AWS::EC2::RouteTable", Properties: { VpcId: { Ref: "Vpc" } } },
58
+ PrivateAssocB: { Type: "AWS::EC2::SubnetRouteTableAssociation", Properties: { SubnetId: { Ref: "PrivateSubnetB" }, RouteTableId: { Ref: "PrivateRouteTableB" } } },
59
+ PrivateDefaultRouteB: { Type: "AWS::EC2::Route", Properties: { RouteTableId: { Ref: "PrivateRouteTableB" }, DestinationCidrBlock: "0.0.0.0/0", NatGatewayId: { Ref: "NatGatewayB" } } },
60
+ },
61
+ });
62
+ expect(checkNatGatewaySingleAz(ctx)).toHaveLength(0);
63
+ });
64
+
65
+ test("one NAT gateway serving only subnets in its own AZ → no diagnostic", () => {
66
+ const ctx = makeCtx({
67
+ Resources: {
68
+ Vpc: { Type: "AWS::EC2::VPC", Properties: { CidrBlock: "10.0.0.0/16" } },
69
+ PublicSubnetA: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.0.0/24", AvailabilityZone: "us-east-1a" } },
70
+ NatGateway: { Type: "AWS::EC2::NatGateway", Properties: { SubnetId: { Ref: "PublicSubnetA" } } },
71
+ PrivateSubnetA: { Type: "AWS::EC2::Subnet", Properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.1.0/24", AvailabilityZone: "us-east-1a" } },
72
+ PrivateRouteTableA: { Type: "AWS::EC2::RouteTable", Properties: { VpcId: { Ref: "Vpc" } } },
73
+ PrivateAssocA: { Type: "AWS::EC2::SubnetRouteTableAssociation", Properties: { SubnetId: { Ref: "PrivateSubnetA" }, RouteTableId: { Ref: "PrivateRouteTableA" } } },
74
+ PrivateDefaultRouteA: { Type: "AWS::EC2::Route", Properties: { RouteTableId: { Ref: "PrivateRouteTableA" }, DestinationCidrBlock: "0.0.0.0/0", NatGatewayId: { Ref: "NatGateway" } } },
75
+ },
76
+ });
77
+ expect(checkNatGatewaySingleAz(ctx)).toHaveLength(0);
78
+ });
79
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * WAW067: Single-AZ NAT Gateway Serves Multi-AZ Private Subnets
3
+ *
4
+ * Cross-resource join across four resource kinds: a NAT gateway lives in one
5
+ * subnet, which lives in one Availability Zone; a route table's default
6
+ * route names the NAT gateway it forwards to; a subnet is pinned to a route
7
+ * table by an `AWS::EC2::SubnetRouteTableAssociation`. Follow that chain and
8
+ * a common reliability gap falls out — private subnets in two or more AZs
9
+ * all defaulting to the *same* NAT gateway. CloudFormation has no opinion on
10
+ * this; it deploys cleanly. But a NAT gateway lives entirely inside one AZ,
11
+ * so if that AZ has an outage, every subnet depending on it loses egress —
12
+ * including the subnets in AZs that were otherwise healthy. The
13
+ * Well-Architected fix is one NAT gateway per AZ, each serving only its own
14
+ * AZ's subnets.
15
+ *
16
+ * Only fires when the Availability Zones involved are literal strings —
17
+ * an `Fn::GetAZs`/`Fn::Select`-derived AZ can't be compared statically, so a
18
+ * subnet without a literal `AvailabilityZone` is silently excluded rather
19
+ * than guessed at.
20
+ */
21
+
22
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
23
+ import { parseCFTemplate, findResourceRefs, type CFTemplate } from "./cf-refs";
24
+
25
+ export function checkNatGatewaySingleAz(ctx: PostSynthContext): PostSynthDiagnostic[] {
26
+ const diagnostics: PostSynthDiagnostic[] = [];
27
+
28
+ for (const [_lexicon, output] of ctx.outputs) {
29
+ const template: CFTemplate | null = parseCFTemplate(output);
30
+ if (!template?.Resources) continue;
31
+ const resources = template.Resources;
32
+
33
+ // NAT gateway id -> its own subnet's literal AZ.
34
+ const natGatewayAz = new Map<string, string>();
35
+ for (const [logicalId, resource] of Object.entries(resources)) {
36
+ if (resource.Type !== "AWS::EC2::NatGateway") continue;
37
+ const subnetRefs = findResourceRefs(resource.Properties?.SubnetId);
38
+ if (subnetRefs.size !== 1) continue;
39
+ const [subnetId] = subnetRefs;
40
+ const az = resources[subnetId]?.Properties?.AvailabilityZone;
41
+ if (typeof az === "string") natGatewayAz.set(logicalId, az);
42
+ }
43
+ if (natGatewayAz.size === 0) continue;
44
+
45
+ // route table id -> the NAT gateway its default route forwards to.
46
+ const routeTableNatTarget = new Map<string, string>();
47
+ for (const resource of Object.values(resources)) {
48
+ if (resource.Type !== "AWS::EC2::Route") continue;
49
+ const props = resource.Properties ?? {};
50
+ if (props.DestinationCidrBlock !== "0.0.0.0/0") continue;
51
+ const natRefs = findResourceRefs(props.NatGatewayId);
52
+ if (natRefs.size !== 1) continue;
53
+ const [natId] = natRefs;
54
+ if (!natGatewayAz.has(natId)) continue;
55
+ for (const rtId of findResourceRefs(props.RouteTableId)) {
56
+ routeTableNatTarget.set(rtId, natId);
57
+ }
58
+ }
59
+
60
+ // subnet id -> route table id, from explicit associations only.
61
+ const subnetRouteTable = new Map<string, string>();
62
+ for (const resource of Object.values(resources)) {
63
+ if (resource.Type !== "AWS::EC2::SubnetRouteTableAssociation") continue;
64
+ const props = resource.Properties ?? {};
65
+ const subnetRefs = findResourceRefs(props.SubnetId);
66
+ const rtRefs = findResourceRefs(props.RouteTableId);
67
+ if (subnetRefs.size !== 1 || rtRefs.size !== 1) continue;
68
+ const [subnetId] = subnetRefs;
69
+ const [rtId] = rtRefs;
70
+ subnetRouteTable.set(subnetId, rtId);
71
+ }
72
+
73
+ // NAT gateway id -> the set of subnets that default-route through it.
74
+ const servedSubnets = new Map<string, Set<string>>();
75
+ for (const [subnetId, rtId] of subnetRouteTable) {
76
+ const natId = routeTableNatTarget.get(rtId);
77
+ if (!natId) continue;
78
+ let set = servedSubnets.get(natId);
79
+ if (!set) {
80
+ set = new Set();
81
+ servedSubnets.set(natId, set);
82
+ }
83
+ set.add(subnetId);
84
+ }
85
+
86
+ for (const [natId, subnetIds] of servedSubnets) {
87
+ const azs = new Set<string>();
88
+ for (const subnetId of subnetIds) {
89
+ const az = resources[subnetId]?.Properties?.AvailabilityZone;
90
+ if (typeof az === "string") azs.add(az);
91
+ }
92
+ if (azs.size > 1) {
93
+ diagnostics.push({
94
+ checkId: "WAW067",
95
+ severity: "warning",
96
+ message: `NAT Gateway "${natId}" (in ${natGatewayAz.get(natId)}) is the default-route egress for private subnets across ${azs.size} Availability Zones (${[...azs].sort().join(", ")}) — a single-AZ NAT gateway is a single point of failure for cross-AZ egress; add one NAT gateway per AZ`,
97
+ entity: natId,
98
+ lexicon: "aws",
99
+ });
100
+ }
101
+ }
102
+ }
103
+
104
+ return diagnostics;
105
+ }
106
+
107
+ export const waw067: PostSynthCheck = {
108
+ id: "WAW067",
109
+ description: "A single-AZ NAT gateway serves private subnets across multiple Availability Zones — single point of failure for cross-AZ egress",
110
+
111
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
112
+ return checkNatGatewaySingleAz(ctx);
113
+ },
114
+ };
@@ -0,0 +1,74 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { createPostSynthContext } from "@intentius/chant-test-utils";
3
+ import { waw068, checkVpnGatewayRedundancy } from "./waw068";
4
+
5
+ function makeCtx(template: object) {
6
+ return createPostSynthContext({ aws: template });
7
+ }
8
+
9
+ describe("WAW068: VPN Gateway Has A Single VPN Connection", () => {
10
+ test("check metadata", () => {
11
+ expect(waw068.id).toBe("WAW068");
12
+ expect(waw068.description).toContain("VPN Connection");
13
+ });
14
+
15
+ test("VPN Gateway with a single VPN connection → warning", () => {
16
+ const ctx = makeCtx({
17
+ Resources: {
18
+ Vgw: { Type: "AWS::EC2::VPNGateway", Properties: { Type: "ipsec.1" } },
19
+ CustomerGw: { Type: "AWS::EC2::CustomerGateway", Properties: { Type: "ipsec.1", BgpAsn: 65000, IpAddress: "203.0.113.1" } },
20
+ VpnConnection: {
21
+ Type: "AWS::EC2::VPNConnection",
22
+ Properties: { Type: "ipsec.1", CustomerGatewayId: { Ref: "CustomerGw" }, VpnGatewayId: { Ref: "Vgw" } },
23
+ },
24
+ },
25
+ });
26
+ const diags = checkVpnGatewayRedundancy(ctx);
27
+ expect(diags).toHaveLength(1);
28
+ expect(diags[0].checkId).toBe("WAW068");
29
+ expect(diags[0].severity).toBe("warning");
30
+ expect(diags[0].entity).toBe("Vgw");
31
+ expect(diags[0].message).toContain("VpnConnection");
32
+ });
33
+
34
+ test("Transit Gateway with a single VPN connection → warning", () => {
35
+ const ctx = makeCtx({
36
+ Resources: {
37
+ Tgw: { Type: "AWS::EC2::TransitGateway", Properties: {} },
38
+ CustomerGw: { Type: "AWS::EC2::CustomerGateway", Properties: { Type: "ipsec.1", BgpAsn: 65000, IpAddress: "203.0.113.1" } },
39
+ VpnConnection: {
40
+ Type: "AWS::EC2::VPNConnection",
41
+ Properties: { Type: "ipsec.1", CustomerGatewayId: { Ref: "CustomerGw" }, TransitGatewayId: { Ref: "Tgw" } },
42
+ },
43
+ },
44
+ });
45
+ const diags = checkVpnGatewayRedundancy(ctx);
46
+ expect(diags).toHaveLength(1);
47
+ expect(diags[0].entity).toBe("Tgw");
48
+ expect(diags[0].message).toContain("Transit Gateway");
49
+ });
50
+
51
+ test("VPN Gateway with two VPN connections attached → no diagnostic", () => {
52
+ const ctx = makeCtx({
53
+ Resources: {
54
+ Vgw: { Type: "AWS::EC2::VPNGateway", Properties: { Type: "ipsec.1" } },
55
+ CustomerGwA: { Type: "AWS::EC2::CustomerGateway", Properties: { Type: "ipsec.1", BgpAsn: 65000, IpAddress: "203.0.113.1" } },
56
+ CustomerGwB: { Type: "AWS::EC2::CustomerGateway", Properties: { Type: "ipsec.1", BgpAsn: 65000, IpAddress: "203.0.113.2" } },
57
+ VpnConnectionA: {
58
+ Type: "AWS::EC2::VPNConnection",
59
+ Properties: { Type: "ipsec.1", CustomerGatewayId: { Ref: "CustomerGwA" }, VpnGatewayId: { Ref: "Vgw" } },
60
+ },
61
+ VpnConnectionB: {
62
+ Type: "AWS::EC2::VPNConnection",
63
+ Properties: { Type: "ipsec.1", CustomerGatewayId: { Ref: "CustomerGwB" }, VpnGatewayId: { Ref: "Vgw" } },
64
+ },
65
+ },
66
+ });
67
+ expect(checkVpnGatewayRedundancy(ctx)).toHaveLength(0);
68
+ });
69
+
70
+ test("no VPN connections at all → no diagnostic", () => {
71
+ const ctx = makeCtx({ Resources: { Vgw: { Type: "AWS::EC2::VPNGateway", Properties: { Type: "ipsec.1" } } } });
72
+ expect(checkVpnGatewayRedundancy(ctx)).toHaveLength(0);
73
+ });
74
+ });
@@ -0,0 +1,78 @@
1
+ /**
2
+ * WAW068: VPN Gateway Has A Single VPN Connection
3
+ *
4
+ * An `AWS::EC2::VPNConnection` always gets two tunnels from AWS, but that
5
+ * redundancy only covers the tunnel pair inside one connection — it does
6
+ * nothing if the connection's Customer Gateway, on-prem device, or the
7
+ * single site it terminates at goes down. The reliability boundary that
8
+ * matters is the connection itself: a `AWS::EC2::VPNGateway` (or a Transit
9
+ * Gateway used for VPN attachment) with only one `VPNConnection` attached
10
+ * has no fallback path for hybrid connectivity if that connection's
11
+ * Customer Gateway or on-prem side fails.
12
+ *
13
+ * Cross-resource join: group every declared `VPNConnection` by the gateway
14
+ * (`VpnGatewayId` or `TransitGatewayId`) it attaches to, and flag any
15
+ * gateway left with exactly one.
16
+ */
17
+
18
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
19
+ import { parseCFTemplate, findResourceRefs, type CFTemplate } from "./cf-refs";
20
+
21
+ export function checkVpnGatewayRedundancy(ctx: PostSynthContext): PostSynthDiagnostic[] {
22
+ const diagnostics: PostSynthDiagnostic[] = [];
23
+
24
+ for (const [_lexicon, output] of ctx.outputs) {
25
+ const template: CFTemplate | null = parseCFTemplate(output);
26
+ if (!template?.Resources) continue;
27
+ const resources = template.Resources;
28
+
29
+ // gateway logical id -> the VPNConnection logical ids attached to it.
30
+ const connectionsByGateway = new Map<string, string[]>();
31
+
32
+ for (const [logicalId, resource] of Object.entries(resources)) {
33
+ if (resource.Type !== "AWS::EC2::VPNConnection") continue;
34
+ const props = resource.Properties ?? {};
35
+
36
+ const gatewayProp = props.VpnGatewayId !== undefined ? props.VpnGatewayId : props.TransitGatewayId;
37
+ if (gatewayProp === undefined) continue; // no declared gateway target — can't group, stay quiet
38
+
39
+ const gwRefs = findResourceRefs(gatewayProp);
40
+ if (gwRefs.size !== 1) continue;
41
+ const [gwId] = gwRefs;
42
+
43
+ const gw = resources[gwId];
44
+ if (!gw || (gw.Type !== "AWS::EC2::VPNGateway" && gw.Type !== "AWS::EC2::TransitGateway")) continue;
45
+
46
+ let list = connectionsByGateway.get(gwId);
47
+ if (!list) {
48
+ list = [];
49
+ connectionsByGateway.set(gwId, list);
50
+ }
51
+ list.push(logicalId);
52
+ }
53
+
54
+ for (const [gwId, connectionIds] of connectionsByGateway) {
55
+ if (connectionIds.length !== 1) continue;
56
+
57
+ const kind = resources[gwId].Type === "AWS::EC2::VPNGateway" ? "VPN Gateway" : "Transit Gateway";
58
+ diagnostics.push({
59
+ checkId: "WAW068",
60
+ severity: "warning",
61
+ message: `${kind} "${gwId}" has only one VPN connection ("${connectionIds[0]}") attached — no redundant path if this connection, its tunnels, or its Customer Gateway fail; attach a second VPNConnection (ideally to a separate Customer Gateway) for hybrid-connectivity redundancy`,
62
+ entity: gwId,
63
+ lexicon: "aws",
64
+ });
65
+ }
66
+ }
67
+
68
+ return diagnostics;
69
+ }
70
+
71
+ export const waw068: PostSynthCheck = {
72
+ id: "WAW068",
73
+ description: "VPN Gateway or Transit Gateway has only one attached VPN Connection — no redundant path for hybrid connectivity",
74
+
75
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
76
+ return checkVpnGatewayRedundancy(ctx);
77
+ },
78
+ };
package/src/lsp/hover.ts CHANGED
@@ -61,9 +61,24 @@ function resourceHover(className: string, entry: LexiconEntry): HoverInfo | unde
61
61
  lines.push(`**Conditionally immutable:** ${entry.conditionalCreateOnly.map((p) => `\`${p}\``).join(", ")}`);
62
62
  }
63
63
 
64
- if (entry.deprecatedProperties?.length) {
64
+ // Declared and inferred deprecations rest on different evidence, so they
65
+ // read as separate lines rather than one merged list (#1701). The core
66
+ // `LexiconEntry` gains `inferredDeprecations` in the next core release; this
67
+ // lexicon builds against the published core, so widen locally until then.
68
+ const withBasis = entry as LexiconEntry & { inferredDeprecations?: string[] };
69
+ const inferred = new Set(withBasis.inferredDeprecations ?? []);
70
+ const declared = (entry.deprecatedProperties ?? []).filter((p) => !inferred.has(p));
71
+
72
+ if (declared.length > 0) {
73
+ lines.push("");
74
+ lines.push(`**Deprecated properties:** ${declared.map((p) => `\`${p}\``).join(", ")}`);
75
+ }
76
+
77
+ if (inferred.size > 0) {
65
78
  lines.push("");
66
- lines.push(`**Deprecated properties:** ${entry.deprecatedProperties.map((p) => `\`${p}\``).join(", ")}`);
79
+ lines.push(
80
+ `**Possibly deprecated** (description text only, not declared by the Registry): ${[...inferred].map((p) => `\`${p}\``).join(", ")}`,
81
+ );
67
82
  }
68
83
 
69
84
  return { contents: lines.join("\n") };
@@ -46,6 +46,21 @@ export {
46
46
  } from "./aws-apply";
47
47
  export type { AwsApplyArgs, RollbackStackArgs, AwsHttp } from "./aws-apply";
48
48
 
49
+ // Effect-receipt activities (#1835) — core's receipt seam (#1834) bound to
50
+ // the SSM store, the way the k8s lexicon binds `ensureSecret` (#1830). The
51
+ // registry keys exported functions by name, so the three bound activities are
52
+ // re-exported individually: `receiptRead`/`receiptWrite` serve the `effect()`
53
+ // step's read-compare-run-write, `receiptStaleness` serves WatchOp's
54
+ // read-only phase. The store resolves its path identity and endpoint lazily
55
+ // at first use, so this module stays cheap to load.
56
+ import { receiptActivities } from "@intentius/chant/op/receipt-store";
57
+ import { awsReceiptStore } from "../../receipt-store";
58
+
59
+ const boundReceiptActivities = receiptActivities(awsReceiptStore());
60
+ export const receiptRead = boundReceiptActivities.receiptRead;
61
+ export const receiptWrite = boundReceiptActivities.receiptWrite;
62
+ export const receiptStaleness = boundReceiptActivities.receiptStaleness;
63
+
49
64
  export { awsAgentCoreFetchTrace } from "../../agentcore/trace-fetch";
50
65
  export type {
51
66
  AgentCoreTraceSource,
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Typed step-builder wrappers (chant #1288 Stage 2) — see
3
+ * `lexicons/k8s/src/op/builders.test.ts`'s module doc for what's asserted
4
+ * and why.
5
+ */
6
+
7
+ import { describe, test, expect } from "vitest";
8
+ import { awsApply as awsApplyOld, awsDelete as awsDeleteOld, flociUp as flociUpOld, flociDown as flociDownOld, stepOutput, type StepOutputRef } from "@intentius/chant/op";
9
+ import { awsApply, awsDelete, flociUp, flociDown } from "./builders";
10
+
11
+ describe("aws typed step builders (#1288 Stage 2)", () => {
12
+ test("awsApply: identical ActivityStep to core's original", () => {
13
+ const opts = { stackName: "my-stack" };
14
+ expect(awsApply("dist/template.json", opts)).toEqual(awsApplyOld("dist/template.json", opts));
15
+ });
16
+
17
+ test("awsApply: identical ActivityStep with more opts", () => {
18
+ const opts = { stackName: "my-stack", region: "us-west-2", capabilities: ["CAPABILITY_NAMED_IAM"], profile: "fastIdempotent" as const };
19
+ expect(awsApply("dist/template.json", opts)).toEqual(awsApplyOld("dist/template.json", opts));
20
+ });
21
+
22
+ test("awsDelete: identical ActivityStep to core's original", () => {
23
+ const opts = { stackName: "my-stack" };
24
+ expect(awsDelete("dist/template.json", opts)).toEqual(awsDeleteOld("dist/template.json", opts));
25
+ });
26
+
27
+ test("flociUp/flociDown: identical ActivityStep to core's original", () => {
28
+ expect(flociUp()).toEqual(flociUpOld());
29
+ expect(flociUp({ port: 4567 })).toEqual(flociUpOld({ port: 4567 }));
30
+ expect(flociDown()).toEqual(flociDownOld());
31
+ });
32
+
33
+ test("awsApply: accepts a StepOutputRef in a typed slot", () => {
34
+ const ref = stepOutput("resolve-region", "region");
35
+ const step = awsApply("dist/template.json", { stackName: "my-stack", region: ref });
36
+ expect(step.args?.region).toBe(ref);
37
+ });
38
+
39
+ test("awsApply: .out is reachable when an id is given", () => {
40
+ const step = awsApply("dist/template.json", { stackName: "my-stack", id: "apply" });
41
+ const ref: StepOutputRef = step.out.releaseName;
42
+ expect(ref.step).toBe("apply");
43
+ });
44
+ });
45
+
46
+ // ── Compile-time-only: authoring-time type errors (never executed) ──────────
47
+ function _typeChecksOnly(): void {
48
+ // @ts-expect-error — stackName is required (the activity fails without it);
49
+ // omitting opts entirely is no longer a way around that.
50
+ awsApply("dist/template.json");
51
+
52
+ // @ts-expect-error — "stackname" (wrong case) is not a key of AwsApplyArgs.
53
+ awsApply("dist/template.json", { stackname: "my-stack" });
54
+
55
+ // @ts-expect-error — capabilities must be string[], not a single string.
56
+ awsApply("dist/template.json", { stackName: "my-stack", capabilities: "CAPABILITY_NAMED_IAM" });
57
+
58
+ // @ts-expect-error — port must be a number.
59
+ flociUp({ port: "4566" });
60
+ }
61
+ void _typeChecksOnly;