@intentius/chant-lexicon-aws 0.46.0 → 0.49.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 (114) 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/composites/agentcore-agent.d.ts +26 -1
  4. package/dist/composites/agentcore-agent.d.ts.map +1 -1
  5. package/dist/composites/bucket-deployment.d.ts +82 -0
  6. package/dist/composites/bucket-deployment.d.ts.map +1 -0
  7. package/dist/composites/dynamodb-table.d.ts +45 -0
  8. package/dist/composites/dynamodb-table.d.ts.map +1 -0
  9. package/dist/composites/ec2-instance-bundle.d.ts +61 -0
  10. package/dist/composites/ec2-instance-bundle.d.ts.map +1 -0
  11. package/dist/composites/ecr-repository.d.ts +49 -0
  12. package/dist/composites/ecr-repository.d.ts.map +1 -0
  13. package/dist/composites/fargate-service.d.ts +16 -0
  14. package/dist/composites/fargate-service.d.ts.map +1 -1
  15. package/dist/composites/index.d.ts +16 -2
  16. package/dist/composites/index.d.ts.map +1 -1
  17. package/dist/composites/monitoring-stack.d.ts +99 -0
  18. package/dist/composites/monitoring-stack.d.ts.map +1 -0
  19. package/dist/composites/nlb-service.d.ts +48 -0
  20. package/dist/composites/nlb-service.d.ts.map +1 -0
  21. package/dist/composites/step-functions-workflow.d.ts +36 -0
  22. package/dist/composites/step-functions-workflow.d.ts.map +1 -0
  23. package/dist/deep-observe.d.ts +45 -0
  24. package/dist/deep-observe.d.ts.map +1 -1
  25. package/dist/deep-topology.d.ts +104 -0
  26. package/dist/deep-topology.d.ts.map +1 -0
  27. package/dist/effect-receipt-row.d.ts +92 -0
  28. package/dist/effect-receipt-row.d.ts.map +1 -0
  29. package/dist/index.d.ts +6 -2
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/integrity.json +7 -4
  32. package/dist/lint/audit-catalog.d.ts.map +1 -1
  33. package/dist/lint/post-synth/cf-refs.d.ts +17 -0
  34. package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
  35. package/dist/lint/post-synth/index.d.ts.map +1 -1
  36. package/dist/lint/post-synth/waw061.d.ts +19 -0
  37. package/dist/lint/post-synth/waw061.d.ts.map +1 -0
  38. package/dist/lint/post-synth/waw062.d.ts +19 -0
  39. package/dist/lint/post-synth/waw062.d.ts.map +1 -0
  40. package/dist/lint/post-synth/waw063.d.ts +35 -0
  41. package/dist/lint/post-synth/waw063.d.ts.map +1 -0
  42. package/dist/manifest.json +1 -1
  43. package/dist/okf/index.md +3 -0
  44. package/dist/okf/rules/WAW061.md +17 -0
  45. package/dist/okf/rules/WAW062.md +27 -0
  46. package/dist/okf/rules/WAW063.md +19 -0
  47. package/dist/okf/types/Action.md +1 -0
  48. package/dist/okf/types/AutoScalingGroup.md +1 -0
  49. package/dist/okf/types/Bucket.md +1 -0
  50. package/dist/okf/types/ECRRepository.md +1 -0
  51. package/dist/okf/types/Function.md +1 -0
  52. package/dist/okf/types/IamPolicy.md +1 -0
  53. package/dist/okf/types/LaunchTemplate.md +1 -0
  54. package/dist/okf/types/LoadBalancer.md +1 -0
  55. package/dist/okf/types/ManagedPolicy.md +2 -0
  56. package/dist/okf/types/Map.md +1 -0
  57. package/dist/okf/types/Role.md +2 -0
  58. package/dist/okf/types/SecurityGroup.md +1 -0
  59. package/dist/okf/types/StateMachine.md +4 -0
  60. package/dist/okf/types/Subnet.md +4 -0
  61. package/dist/okf/types/Table.md +1 -0
  62. package/dist/okf/types/Type.md +3 -0
  63. package/dist/okf/types/Vpc.md +1 -0
  64. package/dist/op/activities/index.d.ts +6 -0
  65. package/dist/op/activities/index.d.ts.map +1 -1
  66. package/dist/plugin.d.ts.map +1 -1
  67. package/dist/receipt-store.d.ts +109 -0
  68. package/dist/receipt-store.d.ts.map +1 -0
  69. package/dist/rules/cf-refs.ts +40 -0
  70. package/dist/rules/waw061.ts +67 -0
  71. package/dist/rules/waw062.ts +0 -0
  72. package/dist/rules/waw063.ts +0 -0
  73. package/dist/serializer.d.ts.map +1 -1
  74. package/dist/skills/chant-aws-carve-terraform.md +26 -23
  75. package/package.json +3 -3
  76. package/src/api/read-client.test.ts +9 -0
  77. package/src/api/read-client.ts +15 -2
  78. package/src/composites/agentcore-agent.ts +35 -13
  79. package/src/composites/bucket-deployment.test.ts +84 -0
  80. package/src/composites/bucket-deployment.ts +153 -0
  81. package/src/composites/composites.test.ts +325 -0
  82. package/src/composites/dynamodb-table.test.ts +154 -0
  83. package/src/composites/dynamodb-table.ts +124 -0
  84. package/src/composites/ec2-instance-bundle.ts +134 -0
  85. package/src/composites/ecr-repository.test.ts +118 -0
  86. package/src/composites/ecr-repository.ts +117 -0
  87. package/src/composites/fargate-service.ts +19 -3
  88. package/src/composites/index.ts +21 -2
  89. package/src/composites/monitoring-stack.test.ts +130 -0
  90. package/src/composites/monitoring-stack.ts +164 -0
  91. package/src/composites/nlb-service.ts +117 -0
  92. package/src/composites/step-functions-workflow.ts +127 -0
  93. package/src/deep-observe.test.ts +201 -1
  94. package/src/deep-observe.ts +204 -0
  95. package/src/deep-topology.test.ts +392 -0
  96. package/src/deep-topology.ts +237 -0
  97. package/src/effect-receipt-row.test.ts +164 -0
  98. package/src/effect-receipt-row.ts +147 -0
  99. package/src/index.ts +28 -7
  100. package/src/lint/audit-catalog.ts +6 -0
  101. package/src/lint/post-synth/cf-refs.ts +40 -0
  102. package/src/lint/post-synth/index.ts +6 -0
  103. package/src/lint/post-synth/waw061.test.ts +99 -0
  104. package/src/lint/post-synth/waw061.ts +67 -0
  105. package/src/lint/post-synth/waw062.test.ts +109 -0
  106. package/src/lint/post-synth/waw062.ts +0 -0
  107. package/src/lint/post-synth/waw063.test.ts +220 -0
  108. package/src/lint/post-synth/waw063.ts +0 -0
  109. package/src/op/activities/index.ts +15 -0
  110. package/src/plugin.ts +41 -10
  111. package/src/receipt-store.test.ts +204 -0
  112. package/src/receipt-store.ts +315 -0
  113. package/src/serializer.ts +105 -2
  114. package/src/skills/chant-aws-carve-terraform.md +26 -23
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Deep-read coverage for the EC2 topology types (#1269).
3
+ *
4
+ * The folds' inputs — VPC, subnet, route table, internet gateway, and the
5
+ * joins between them — were exactly the types the deep reader could not read,
6
+ * so a snapshot-backed query could answer identity questions and not a single
7
+ * fold question. These tests drive each added type through the real reader
8
+ * against faked describes, and close with the fold-level proof: a question
9
+ * that was previously unanswerable (is this VPC the provider's default?)
10
+ * answered from the deep read alone.
11
+ *
12
+ * Same seams as deep-observe.test.ts: Cloud Control and CloudFormation are
13
+ * faked at the injected `http` transport, the EC2 describes at the runtime's
14
+ * `spawn`.
15
+ */
16
+ import { describe, test, expect, vi, beforeEach } from "vitest";
17
+
18
+ const spawnMock = vi.fn();
19
+ vi.mock("@intentius/chant/runtime-adapter", async (importOriginal) => {
20
+ const actual = await importOriginal<typeof import("@intentius/chant/runtime-adapter")>();
21
+ return { ...actual, getRuntime: () => ({ ...actual.getRuntime(), spawn: spawnMock }) };
22
+ });
23
+
24
+ const { observeResourcesDeepAws, DEEP_READABLE_TYPES, awsDeepNormalizationHooks } = await import("./deep-observe");
25
+ const { vpcToModel, subnetToModel, routeTableToModel, internetGatewayToModel } = await import("./deep-topology");
26
+ const { stampProviderDefaults } = await import("./defaults");
27
+ const { normalizeDeepObservation } = await import("@intentius/chant/deep-observation");
28
+ const { diffDeepObservation } = await import("@intentius/chant/lifecycle/deep-observe");
29
+
30
+ const ok = (text: string) => ({ status: 200, text });
31
+
32
+ /** A Cloud Control `GetResource` body — the model arrives as a JSON string. */
33
+ const cloudControl = (identifier: string, properties: Record<string, unknown>) =>
34
+ ok(JSON.stringify({ ResourceDescription: { Identifier: identifier, Properties: JSON.stringify(properties) } }));
35
+
36
+ const stackResources = (rows: Array<[string, string, string]>) =>
37
+ ok(
38
+ `<DescribeStackResourcesResponse><DescribeStackResourcesResult><StackResources>${rows
39
+ .map(
40
+ ([logicalId, type, physicalId]) =>
41
+ `<member><LogicalResourceId>${logicalId}</LogicalResourceId><ResourceType>${type}</ResourceType>` +
42
+ `<PhysicalResourceId>${physicalId}</PhysicalResourceId><ResourceStatus>CREATE_COMPLETE</ResourceStatus>` +
43
+ `<Timestamp>2026-01-01T00:00:00Z</Timestamp></member>`,
44
+ )
45
+ .join("")}</StackResources></DescribeStackResourcesResult></DescribeStackResourcesResponse>`,
46
+ );
47
+
48
+ type FakeResponse = { status: number; text: string };
49
+
50
+ /** Route on the Cloud Control identifier; `undefined` is the CloudFormation call. */
51
+ function httpFake(route: (identifier: string | undefined) => FakeResponse) {
52
+ return async (_url: string, init: { headers: Record<string, string>; body: string }) => {
53
+ const target = init.headers["x-amz-target"];
54
+ const identifier = target ? (JSON.parse(init.body) as { Identifier?: string }).Identifier : undefined;
55
+ return route(identifier);
56
+ };
57
+ }
58
+
59
+ /** Route each EC2 bulk describe on its subcommand (`argv[2]`). */
60
+ function ec2Describes(byCommand: Record<string, Record<string, unknown[]>>) {
61
+ spawnMock.mockImplementation((argv: string[]) => {
62
+ const body = byCommand[argv[2]];
63
+ if (!body) return Promise.resolve({ stdout: "", stderr: `no fake for ${argv[2]}`, exitCode: 255 });
64
+ return Promise.resolve({ stdout: JSON.stringify(body), stderr: "", exitCode: 0 });
65
+ });
66
+ }
67
+
68
+ const entities = (
69
+ record: Record<string, { entityType: string; props: Record<string, unknown> }>,
70
+ ): Map<string, { entityType: string; props: Record<string, unknown> }> => new Map(Object.entries(record));
71
+
72
+ const vpcRow = {
73
+ VpcId: "vpc-01",
74
+ State: "available",
75
+ OwnerId: "111122223333",
76
+ CidrBlock: "10.0.0.0/16",
77
+ DhcpOptionsId: "dopt-01",
78
+ InstanceTenancy: "default",
79
+ IsDefault: false,
80
+ CidrBlockAssociationSet: [{ AssociationId: "vpc-cidr-assoc-01", CidrBlock: "10.0.0.0/16" }],
81
+ Tags: [{ Key: "env", Value: "prod" }],
82
+ };
83
+
84
+ // Braces matter: `mockReset()` returns the mock, and a function returned from
85
+ // a vitest hook is called as a cleanup hook — the mock invoked with no argv.
86
+ beforeEach(() => {
87
+ spawnMock.mockReset();
88
+ });
89
+
90
+ describe("the topology translations", () => {
91
+ test("a VPC row maps onto the resource model, keeping the provider's default marker", () => {
92
+ expect(vpcToModel(vpcRow)).toEqual({
93
+ VpcId: "vpc-01",
94
+ CidrBlock: "10.0.0.0/16",
95
+ InstanceTenancy: "default",
96
+ IsDefault: false,
97
+ Tags: [{ Key: "env", Value: "prod" }],
98
+ });
99
+ });
100
+
101
+ test("a subnet row keeps both fold inputs: the VPC hop and MapPublicIpOnLaunch", () => {
102
+ expect(
103
+ subnetToModel({
104
+ SubnetId: "subnet-01",
105
+ VpcId: "vpc-01",
106
+ State: "available",
107
+ OwnerId: "111122223333",
108
+ CidrBlock: "10.0.1.0/24",
109
+ AvailabilityZone: "us-east-1a",
110
+ AvailabilityZoneId: "use1-az1",
111
+ AvailableIpAddressCount: 250,
112
+ MapPublicIpOnLaunch: true,
113
+ AssignIpv6AddressOnCreation: false,
114
+ DefaultForAz: false,
115
+ Ipv6Native: false,
116
+ PrivateDnsNameOptionsOnLaunch: { HostnameType: "ip-name" },
117
+ }),
118
+ ).toEqual({
119
+ SubnetId: "subnet-01",
120
+ VpcId: "vpc-01",
121
+ CidrBlock: "10.0.1.0/24",
122
+ AvailabilityZone: "us-east-1a",
123
+ AvailabilityZoneId: "use1-az1",
124
+ MapPublicIpOnLaunch: true,
125
+ AssignIpv6AddressOnCreation: false,
126
+ Ipv6Native: false,
127
+ DefaultForAz: false,
128
+ });
129
+ });
130
+
131
+ test("a route table's routes and associations are its own declared resources, not carried twice", () => {
132
+ expect(
133
+ routeTableToModel({
134
+ RouteTableId: "rtb-01",
135
+ VpcId: "vpc-01",
136
+ Routes: [{ DestinationCidrBlock: "0.0.0.0/0", GatewayId: "igw-01" }],
137
+ Associations: [{ Main: false, SubnetId: "subnet-01" }],
138
+ PropagatingVgws: [],
139
+ }),
140
+ ).toEqual({ RouteTableId: "rtb-01", VpcId: "vpc-01" });
141
+ });
142
+
143
+ test("an internet gateway is tags and nothing else; an empty tag set is absence, not a value", () => {
144
+ expect(internetGatewayToModel({ InternetGatewayId: "igw-01", Attachments: [], Tags: [] })).toEqual({
145
+ InternetGatewayId: "igw-01",
146
+ });
147
+ });
148
+ });
149
+
150
+ describe("deep-reading an EC2 topology stack", () => {
151
+ const topologyStack = stackResources([
152
+ ["Net", "AWS::EC2::VPC", "vpc-01"],
153
+ ["PublicSubnet", "AWS::EC2::Subnet", "subnet-01"],
154
+ ["PublicRoutes", "AWS::EC2::RouteTable", "rtb-01"],
155
+ ["Igw", "AWS::EC2::InternetGateway", "igw-01"],
156
+ ]);
157
+
158
+ test("every topology type from #1269 is readable", () => {
159
+ for (const type of [
160
+ "AWS::EC2::VPC",
161
+ "AWS::EC2::Subnet",
162
+ "AWS::EC2::RouteTable",
163
+ "AWS::EC2::Route",
164
+ "AWS::EC2::SubnetRouteTableAssociation",
165
+ "AWS::EC2::InternetGateway",
166
+ "AWS::EC2::VPCGatewayAttachment",
167
+ "AWS::EC2::Instance",
168
+ "AWS::EC2::LaunchTemplate",
169
+ ]) {
170
+ expect(DEEP_READABLE_TYPES.has(type)).toBe(true);
171
+ }
172
+ });
173
+
174
+ test("one bulk describe per type, and every tree is recorded", async () => {
175
+ ec2Describes({
176
+ "describe-vpcs": { Vpcs: [vpcRow] },
177
+ "describe-subnets": {
178
+ Subnets: [{ SubnetId: "subnet-01", VpcId: "vpc-01", CidrBlock: "10.0.1.0/24", MapPublicIpOnLaunch: true }],
179
+ },
180
+ "describe-route-tables": { RouteTables: [{ RouteTableId: "rtb-01", VpcId: "vpc-01", Routes: [] }] },
181
+ "describe-internet-gateways": { InternetGateways: [{ InternetGatewayId: "igw-01", Attachments: [] }] },
182
+ });
183
+ const result = normalizeDeepObservation(
184
+ await observeResourcesDeepAws({
185
+ environment: "prod",
186
+ entityNames: ["Net", "PublicSubnet", "PublicRoutes", "Igw"],
187
+ http: httpFake(() => topologyStack),
188
+ }),
189
+ );
190
+
191
+ expect(Object.keys(result.resources).sort()).toEqual(["Igw", "Net", "PublicRoutes", "PublicSubnet"]);
192
+ expect(result.unobserved).toEqual({});
193
+
194
+ // The containment hop and the public-IP fact the folds read.
195
+ expect(result.resources.PublicSubnet.properties).toMatchObject({
196
+ VpcId: "vpc-01",
197
+ MapPublicIpOnLaunch: true,
198
+ });
199
+ // Server-minted ids are pruned by the schema's own readOnlyProperties.
200
+ expect(result.resources.Net.properties.VpcId).toBeUndefined();
201
+ expect(result.resources.PublicSubnet.properties.SubnetId).toBeUndefined();
202
+ // The provider's default marker survives into the snapshot for the fold.
203
+ expect(result.resources.Net.properties.IsDefault).toBe(false);
204
+
205
+ // Four types, four describes — never one call per resource.
206
+ expect(spawnMock).toHaveBeenCalledTimes(4);
207
+ const commands = spawnMock.mock.calls.map((c) => (c[0] as string[])[2]).sort();
208
+ expect(commands).toEqual([
209
+ "describe-internet-gateways",
210
+ "describe-route-tables",
211
+ "describe-subnets",
212
+ "describe-vpcs",
213
+ ]);
214
+ });
215
+
216
+ test("the routing-chain joins read through Cloud Control by their physical id", async () => {
217
+ const http = httpFake((identifier) => {
218
+ if (identifier === undefined) {
219
+ return stackResources([
220
+ ["DefaultRoute", "AWS::EC2::Route", "rtb-01|0.0.0.0/0"],
221
+ ["SubnetRoutes", "AWS::EC2::SubnetRouteTableAssociation", "rtbassoc-01"],
222
+ ["IgwAttachment", "AWS::EC2::VPCGatewayAttachment", "IGW|vpc-01"],
223
+ ]);
224
+ }
225
+ if (identifier === "rtb-01|0.0.0.0/0") {
226
+ return cloudControl(identifier, {
227
+ RouteTableId: "rtb-01",
228
+ DestinationCidrBlock: "0.0.0.0/0",
229
+ GatewayId: "igw-01",
230
+ });
231
+ }
232
+ if (identifier === "rtbassoc-01") {
233
+ return cloudControl(identifier, { Id: "rtbassoc-01", RouteTableId: "rtb-01", SubnetId: "subnet-01" });
234
+ }
235
+ return cloudControl(identifier, { InternetGatewayId: "igw-01", VpcId: "vpc-01" });
236
+ });
237
+ const result = normalizeDeepObservation(
238
+ await observeResourcesDeepAws({
239
+ environment: "prod",
240
+ entityNames: ["DefaultRoute", "SubnetRoutes", "IgwAttachment"],
241
+ http,
242
+ }),
243
+ );
244
+
245
+ // The `0.0.0.0/0` -> igw edge `internetFacing` walks.
246
+ expect(result.resources.DefaultRoute.properties).toEqual({
247
+ RouteTableId: "rtb-01",
248
+ DestinationCidrBlock: "0.0.0.0/0",
249
+ GatewayId: "igw-01",
250
+ });
251
+ // Subnet -> route table, with the association's own minted id pruned.
252
+ expect(result.resources.SubnetRoutes.properties).toEqual({
253
+ RouteTableId: "rtb-01",
254
+ SubnetId: "subnet-01",
255
+ });
256
+ // Igw -> VPC.
257
+ expect(result.resources.IgwAttachment.properties).toEqual({
258
+ InternetGatewayId: "igw-01",
259
+ VpcId: "vpc-01",
260
+ });
261
+ expect(spawnMock).not.toHaveBeenCalled();
262
+ });
263
+
264
+ test("a type without a source is still a stated hole, not a silent one", async () => {
265
+ const result = normalizeDeepObservation(
266
+ await observeResourcesDeepAws({
267
+ environment: "prod",
268
+ entityNames: ["Ip"],
269
+ http: httpFake(() => stackResources([["Ip", "AWS::EC2::EIP", "203.0.113.7"]])),
270
+ }),
271
+ );
272
+ expect(result.resources).toEqual({});
273
+ expect(result.unobserved.Ip.reason).toBe("unsupported-kind");
274
+ });
275
+ });
276
+
277
+ describe("the fold answers (#1269)", () => {
278
+ const vpcStack = stackResources([
279
+ ["Net", "AWS::EC2::VPC", "vpc-01"],
280
+ ["Legacy", "AWS::EC2::VPC", "vpc-02"],
281
+ ]);
282
+
283
+ test("IsDefault on a VPC — previously unanswerable — answers from the deep read", async () => {
284
+ ec2Describes({
285
+ "describe-vpcs": {
286
+ Vpcs: [vpcRow, { ...vpcRow, VpcId: "vpc-02", CidrBlock: "172.31.0.0/16", IsDefault: true, Tags: [] }],
287
+ },
288
+ });
289
+ const observed = normalizeDeepObservation(
290
+ await observeResourcesDeepAws({
291
+ environment: "prod",
292
+ entityNames: ["Net", "Legacy"],
293
+ http: httpFake(() => vpcStack),
294
+ }),
295
+ );
296
+
297
+ // The classification fold reads the provider's own marker off the
298
+ // recorded properties — nothing to re-fetch, no heuristics.
299
+ const stamped = stampProviderDefaults(
300
+ Object.fromEntries(
301
+ Object.entries(observed.resources).map(([name, r]) => [
302
+ name,
303
+ { type: r.type, status: "CREATE_COMPLETE", attributes: r.properties },
304
+ ]),
305
+ ),
306
+ );
307
+ expect(stamped.Legacy.attributes?.providerDefault).toBe(true);
308
+ expect(stamped.Net.attributes?.providerDefault).toBeUndefined();
309
+ });
310
+
311
+ test("the marker feeds the fold without surfacing as drift on a declared VPC", async () => {
312
+ ec2Describes({ "describe-vpcs": { Vpcs: [vpcRow] } });
313
+ const observed = normalizeDeepObservation(
314
+ await observeResourcesDeepAws({
315
+ environment: "prod",
316
+ entityNames: ["Net"],
317
+ http: httpFake(() => stackResources([["Net", "AWS::EC2::VPC", "vpc-01"]])),
318
+ }),
319
+ );
320
+ const declared = entities({
321
+ Net: {
322
+ entityType: "AWS::EC2::VPC",
323
+ props: { CidrBlock: "10.0.0.0/16", Tags: [{ Key: "env", Value: "prod" }] },
324
+ },
325
+ });
326
+
327
+ // In the snapshot for a query to read…
328
+ expect(observed.resources.Net.properties.IsDefault).toBe(false);
329
+ // …and subtracted in the diff, where source never declared it.
330
+ const diff = diffDeepObservation(declared, observed, awsDeepNormalizationHooks);
331
+ expect(diff.drifted).toEqual([]);
332
+ expect(diff.unchanged).toEqual(["Net"]);
333
+ });
334
+
335
+ test("declared DNS attributes are a source blind spot, not absence drift; a visible change still reports", async () => {
336
+ ec2Describes({ "describe-vpcs": { Vpcs: [{ ...vpcRow, CidrBlock: "10.9.0.0/16", Tags: [] }] } });
337
+ const observed = normalizeDeepObservation(
338
+ await observeResourcesDeepAws({
339
+ environment: "prod",
340
+ entityNames: ["Net"],
341
+ http: httpFake(() => stackResources([["Net", "AWS::EC2::VPC", "vpc-01"]])),
342
+ }),
343
+ );
344
+ const declared = entities({
345
+ Net: {
346
+ entityType: "AWS::EC2::VPC",
347
+ props: { CidrBlock: "10.0.0.0/16", EnableDnsSupport: true, EnableDnsHostnames: true },
348
+ },
349
+ });
350
+
351
+ const diff = diffDeepObservation(declared, observed, awsDeepNormalizationHooks);
352
+ expect(diff.drifted).toEqual([
353
+ {
354
+ name: "Net",
355
+ type: "AWS::EC2::VPC",
356
+ changes: [
357
+ { path: "CidrBlock", kind: "changed", declared: "10.0.0.0/16", live: "10.9.0.0/16" },
358
+ ],
359
+ },
360
+ ]);
361
+ });
362
+
363
+ test("an out-of-band MapPublicIpOnLaunch flip reports; the service default does not", async () => {
364
+ const subnetRow = (mapPublic: boolean) => ({
365
+ Subnets: [{ SubnetId: "subnet-01", VpcId: "vpc-01", CidrBlock: "10.0.1.0/24", MapPublicIpOnLaunch: mapPublic }],
366
+ });
367
+ const declared = entities({
368
+ PublicSubnet: { entityType: "AWS::EC2::Subnet", props: { VpcId: "vpc-01", CidrBlock: "10.0.1.0/24" } },
369
+ });
370
+ const observe = async () =>
371
+ normalizeDeepObservation(
372
+ await observeResourcesDeepAws({
373
+ environment: "prod",
374
+ entityNames: ["PublicSubnet"],
375
+ http: httpFake(() => stackResources([["PublicSubnet", "AWS::EC2::Subnet", "subnet-01"]])),
376
+ }),
377
+ );
378
+
379
+ ec2Describes({ "describe-subnets": subnetRow(false) });
380
+ expect(diffDeepObservation(declared, await observe(), awsDeepNormalizationHooks).drifted).toEqual([]);
381
+
382
+ ec2Describes({ "describe-subnets": subnetRow(true) });
383
+ const diff = diffDeepObservation(declared, await observe(), awsDeepNormalizationHooks);
384
+ expect(diff.drifted).toEqual([
385
+ {
386
+ name: "PublicSubnet",
387
+ type: "AWS::EC2::Subnet",
388
+ changes: [{ path: "MapPublicIpOnLaunch", kind: "undeclared", live: true }],
389
+ },
390
+ ]);
391
+ });
392
+ });
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Deep-read sources for the EC2 topology types (#1269).
3
+ *
4
+ * chant's distinguishing answers are folds over topology: `internetFacing`
5
+ * resolves subnet -> route table -> internet gateway, `effectiveIngress`
6
+ * resolves rules across attached security groups, and the default-VPC
7
+ * classification (`defaults.ts`) reads the provider's own markers. The inputs
8
+ * to all of them are the types this file covers — with only the original four
9
+ * kinds readable, a deep snapshot of an EC2 estate carried almost no
10
+ * properties, and a snapshot-backed query could answer identity questions but
11
+ * not a single fold question.
12
+ *
13
+ * Two sources, chosen per type:
14
+ *
15
+ * - The container types (VPC, subnet, route table, internet gateway) read
16
+ * through the EC2 describes. The fold facts live there and nowhere else:
17
+ * `IsDefault`, `DefaultForAz` and a subnet's live `MapPublicIpOnLaunch` are
18
+ * not part of the CloudFormation resource model, so Cloud Control cannot
19
+ * report them. The describes are also bulk (one call per type for the whole
20
+ * stack) and are implemented by the emulators that do not serve Cloud
21
+ * Control's `GetResource`.
22
+ *
23
+ * - The join types (route, association, gateway attachment) and the two whose
24
+ * describes do not fit a flat bulk join (instance rows nest inside
25
+ * reservations; launch-template data lives in a separate versions call) read
26
+ * through Cloud Control, which returns their model in the declared side's
27
+ * own shape.
28
+ *
29
+ * Each type carries its normalization entries alongside (#1269's note: adding
30
+ * a type without them trades a hole for a false positive, which is worse).
31
+ * They are merged into the tables in `deep-observe.ts`.
32
+ */
33
+
34
+ import type { DeepSource } from "./deep-observe";
35
+
36
+ /**
37
+ * `describe-vpcs` -> the `AWS::EC2::VPC` resource model, plus `IsDefault`.
38
+ *
39
+ * `IsDefault` is the provider's own marker for the VPC nobody wrote — the fact
40
+ * the default classification reads — and it exists only on this surface.
41
+ * Carried even though the model does not name it; the diff subtracts it as a
42
+ * service default (`false`, gated on source silence) so a CloudFormation-made
43
+ * VPC does not report it as undeclared drift.
44
+ */
45
+ export function vpcToModel(row: Record<string, unknown>): Record<string, unknown> {
46
+ return {
47
+ // The schema's readOnlyProperties prune the id after the ownership check.
48
+ ...(typeof row.VpcId === "string" ? { VpcId: row.VpcId } : {}),
49
+ ...(typeof row.CidrBlock === "string" ? { CidrBlock: row.CidrBlock } : {}),
50
+ ...(typeof row.InstanceTenancy === "string" ? { InstanceTenancy: row.InstanceTenancy } : {}),
51
+ ...(typeof row.IsDefault === "boolean" ? { IsDefault: row.IsDefault } : {}),
52
+ ...nonEmptyTags(row),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * `describe-subnets` -> the `AWS::EC2::Subnet` resource model, plus
58
+ * `DefaultForAz`.
59
+ *
60
+ * `MapPublicIpOnLaunch` is half of `internetFacing`; `DefaultForAz` is the
61
+ * provider's default-subnet marker, off the model for the same reason as a
62
+ * VPC's `IsDefault` and handled the same way.
63
+ */
64
+ export function subnetToModel(row: Record<string, unknown>): Record<string, unknown> {
65
+ return {
66
+ ...(typeof row.SubnetId === "string" ? { SubnetId: row.SubnetId } : {}),
67
+ ...(typeof row.VpcId === "string" ? { VpcId: row.VpcId } : {}),
68
+ ...(typeof row.CidrBlock === "string" ? { CidrBlock: row.CidrBlock } : {}),
69
+ ...(typeof row.AvailabilityZone === "string" ? { AvailabilityZone: row.AvailabilityZone } : {}),
70
+ ...(typeof row.AvailabilityZoneId === "string" ? { AvailabilityZoneId: row.AvailabilityZoneId } : {}),
71
+ ...(typeof row.MapPublicIpOnLaunch === "boolean" ? { MapPublicIpOnLaunch: row.MapPublicIpOnLaunch } : {}),
72
+ ...(typeof row.AssignIpv6AddressOnCreation === "boolean"
73
+ ? { AssignIpv6AddressOnCreation: row.AssignIpv6AddressOnCreation }
74
+ : {}),
75
+ ...(typeof row.EnableDns64 === "boolean" ? { EnableDns64: row.EnableDns64 } : {}),
76
+ ...(typeof row.Ipv6Native === "boolean" ? { Ipv6Native: row.Ipv6Native } : {}),
77
+ ...(typeof row.DefaultForAz === "boolean" ? { DefaultForAz: row.DefaultForAz } : {}),
78
+ ...nonEmptyTags(row),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * `describe-route-tables` -> the `AWS::EC2::RouteTable` resource model.
84
+ *
85
+ * The row also carries `Routes` and `Associations`, and this mapping drops
86
+ * them on purpose: a template declares each route as its own
87
+ * `AWS::EC2::Route` and each association as its own
88
+ * `AWS::EC2::SubnetRouteTableAssociation`, both covered below, so carrying
89
+ * them here would report every declared route twice — once as an undeclared
90
+ * array on the table and once through its own resource.
91
+ */
92
+ export function routeTableToModel(row: Record<string, unknown>): Record<string, unknown> {
93
+ return {
94
+ ...(typeof row.RouteTableId === "string" ? { RouteTableId: row.RouteTableId } : {}),
95
+ ...(typeof row.VpcId === "string" ? { VpcId: row.VpcId } : {}),
96
+ ...nonEmptyTags(row),
97
+ };
98
+ }
99
+
100
+ /** `describe-internet-gateways` -> the `AWS::EC2::InternetGateway` model, which is tags and nothing else. */
101
+ export function internetGatewayToModel(row: Record<string, unknown>): Record<string, unknown> {
102
+ return {
103
+ ...(typeof row.InternetGatewayId === "string" ? { InternetGatewayId: row.InternetGatewayId } : {}),
104
+ ...nonEmptyTags(row),
105
+ };
106
+ }
107
+
108
+ /**
109
+ * An empty tag set is the absence of tags, not a value. EC2 always sends the
110
+ * key, so carrying it through reports `Tags: <undeclared> -> []` on every
111
+ * untagged resource — agreement rendered as drift. Same rule as the
112
+ * security-group mapping.
113
+ */
114
+ function nonEmptyTags(row: Record<string, unknown>): Record<string, unknown> {
115
+ return Array.isArray(row.Tags) && row.Tags.length > 0 ? { Tags: row.Tags } : {};
116
+ }
117
+
118
+ /**
119
+ * The topology types' entries for `DEEP_SOURCES`.
120
+ *
121
+ * The routing-chain joins are addressed by their physical id — for a
122
+ * registry-backed type CloudFormation's physical id *is* the Cloud Control
123
+ * identifier (`rtb-…|0.0.0.0/0` for a route) — so Cloud Control's
124
+ * one-get-per-resource shape fits them exactly and returns the declared
125
+ * model's own field names.
126
+ */
127
+ export const EC2_TOPOLOGY_SOURCES: Record<string, DeepSource> = {
128
+ "AWS::EC2::VPC": {
129
+ via: "ec2",
130
+ argv: ["ec2", "describe-vpcs"],
131
+ idFlag: "--vpc-ids",
132
+ key: "Vpcs",
133
+ id: "VpcId",
134
+ toModel: vpcToModel,
135
+ },
136
+ "AWS::EC2::Subnet": {
137
+ via: "ec2",
138
+ argv: ["ec2", "describe-subnets"],
139
+ idFlag: "--subnet-ids",
140
+ key: "Subnets",
141
+ id: "SubnetId",
142
+ toModel: subnetToModel,
143
+ },
144
+ "AWS::EC2::RouteTable": {
145
+ via: "ec2",
146
+ argv: ["ec2", "describe-route-tables"],
147
+ idFlag: "--route-table-ids",
148
+ key: "RouteTables",
149
+ id: "RouteTableId",
150
+ toModel: routeTableToModel,
151
+ },
152
+ "AWS::EC2::InternetGateway": {
153
+ via: "ec2",
154
+ argv: ["ec2", "describe-internet-gateways"],
155
+ idFlag: "--internet-gateway-ids",
156
+ key: "InternetGateways",
157
+ id: "InternetGatewayId",
158
+ toModel: internetGatewayToModel,
159
+ },
160
+ // The `0.0.0.0/0` -> igw edge `internetFacing` walks.
161
+ "AWS::EC2::Route": { via: "cloud-control" },
162
+ // Subnet -> route table.
163
+ "AWS::EC2::SubnetRouteTableAssociation": { via: "cloud-control" },
164
+ // Igw -> VPC.
165
+ "AWS::EC2::VPCGatewayAttachment": { via: "cloud-control" },
166
+ // Subnet, security groups, launch template — the instance end of both folds.
167
+ "AWS::EC2::Instance": { via: "cloud-control" },
168
+ // The indirect security-group hop `effectiveIngress` resolves through.
169
+ "AWS::EC2::LaunchTemplate": { via: "cloud-control" },
170
+ };
171
+
172
+ /**
173
+ * Service defaults for the topology types, merged into
174
+ * `AWS_SERVICE_DEFAULTS`. Subtracted on the live side only where source never
175
+ * declared the property — which is how the fold facts (`IsDefault`,
176
+ * `DefaultForAz`, a live `MapPublicIpOnLaunch`) stay in the snapshot for a
177
+ * query to read while never surfacing as drift on a resource CloudFormation
178
+ * itself created.
179
+ */
180
+ export const EC2_TOPOLOGY_SERVICE_DEFAULTS: Record<string, Record<string, unknown>> = {
181
+ "AWS::EC2::VPC": {
182
+ InstanceTenancy: "default",
183
+ IsDefault: false,
184
+ },
185
+ "AWS::EC2::Subnet": {
186
+ MapPublicIpOnLaunch: false,
187
+ AssignIpv6AddressOnCreation: false,
188
+ EnableDns64: false,
189
+ Ipv6Native: false,
190
+ DefaultForAz: false,
191
+ },
192
+ "AWS::EC2::Instance": {
193
+ Tenancy: "default",
194
+ Monitoring: false,
195
+ EbsOptimized: false,
196
+ SourceDestCheck: true,
197
+ DisableApiTermination: false,
198
+ },
199
+ };
200
+
201
+ /**
202
+ * Values the service picks when the template does not supply one, merged into
203
+ * `AWS_GENERATED_NAMES`. A subnet that names no availability zone gets one
204
+ * assigned — a different one is real placement drift only when the template
205
+ * had an opinion, which is exactly the counterpart gate.
206
+ */
207
+ export const EC2_TOPOLOGY_GENERATED_NAMES: Record<string, ReadonlySet<string>> = {
208
+ "AWS::EC2::Subnet": new Set(["AvailabilityZone", "AvailabilityZoneId"]),
209
+ "AWS::EC2::Instance": new Set(["AvailabilityZone"]),
210
+ };
211
+
212
+ /**
213
+ * Declared properties a type's source cannot see (#1269).
214
+ *
215
+ * `describe-vpcs` does not return the DNS attributes — they live behind
216
+ * `describe-vpc-attribute` — and neither describe returns the IPAM inputs,
217
+ * which are consumed at creation. A declared value for one of these has
218
+ * nothing live to stand against, so the diff would report `value -> <absent>`
219
+ * on every clean apply: a blind spot rendered as drift. Pruned on the
220
+ * declared side, and only where the live tree is silent, so a source that
221
+ * does report the field (Cloud Control, some day) is still compared.
222
+ */
223
+ export const AWS_DEEP_BLIND_SPOTS: Record<string, ReadonlySet<string>> = {
224
+ "AWS::EC2::VPC": new Set([
225
+ "EnableDnsSupport",
226
+ "EnableDnsHostnames",
227
+ "Ipv4IpamPoolId",
228
+ "Ipv4NetmaskLength",
229
+ ]),
230
+ "AWS::EC2::Subnet": new Set([
231
+ "Ipv6CidrBlock",
232
+ "Ipv4IpamPoolId",
233
+ "Ipv4NetmaskLength",
234
+ "Ipv6IpamPoolId",
235
+ "Ipv6NetmaskLength",
236
+ ]),
237
+ };