@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.
- package/dist/api/read-client.d.ts +10 -2
- package/dist/api/read-client.d.ts.map +1 -1
- package/dist/composites/agentcore-agent.d.ts +26 -1
- package/dist/composites/agentcore-agent.d.ts.map +1 -1
- package/dist/composites/bucket-deployment.d.ts +82 -0
- package/dist/composites/bucket-deployment.d.ts.map +1 -0
- package/dist/composites/dynamodb-table.d.ts +45 -0
- package/dist/composites/dynamodb-table.d.ts.map +1 -0
- package/dist/composites/ec2-instance-bundle.d.ts +61 -0
- package/dist/composites/ec2-instance-bundle.d.ts.map +1 -0
- package/dist/composites/ecr-repository.d.ts +49 -0
- package/dist/composites/ecr-repository.d.ts.map +1 -0
- package/dist/composites/fargate-service.d.ts +16 -0
- package/dist/composites/fargate-service.d.ts.map +1 -1
- package/dist/composites/index.d.ts +16 -2
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/monitoring-stack.d.ts +99 -0
- package/dist/composites/monitoring-stack.d.ts.map +1 -0
- package/dist/composites/nlb-service.d.ts +48 -0
- package/dist/composites/nlb-service.d.ts.map +1 -0
- package/dist/composites/step-functions-workflow.d.ts +36 -0
- package/dist/composites/step-functions-workflow.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +45 -0
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/deep-topology.d.ts +104 -0
- package/dist/deep-topology.d.ts.map +1 -0
- package/dist/effect-receipt-row.d.ts +92 -0
- package/dist/effect-receipt-row.d.ts.map +1 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +7 -4
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/post-synth/cf-refs.d.ts +17 -0
- package/dist/lint/post-synth/cf-refs.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/waw061.d.ts +19 -0
- package/dist/lint/post-synth/waw061.d.ts.map +1 -0
- package/dist/lint/post-synth/waw062.d.ts +19 -0
- package/dist/lint/post-synth/waw062.d.ts.map +1 -0
- package/dist/lint/post-synth/waw063.d.ts +35 -0
- package/dist/lint/post-synth/waw063.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +3 -0
- package/dist/okf/rules/WAW061.md +17 -0
- package/dist/okf/rules/WAW062.md +27 -0
- package/dist/okf/rules/WAW063.md +19 -0
- package/dist/okf/types/Action.md +1 -0
- package/dist/okf/types/AutoScalingGroup.md +1 -0
- package/dist/okf/types/Bucket.md +1 -0
- package/dist/okf/types/ECRRepository.md +1 -0
- package/dist/okf/types/Function.md +1 -0
- package/dist/okf/types/IamPolicy.md +1 -0
- package/dist/okf/types/LaunchTemplate.md +1 -0
- package/dist/okf/types/LoadBalancer.md +1 -0
- package/dist/okf/types/ManagedPolicy.md +2 -0
- package/dist/okf/types/Map.md +1 -0
- package/dist/okf/types/Role.md +2 -0
- package/dist/okf/types/SecurityGroup.md +1 -0
- package/dist/okf/types/StateMachine.md +4 -0
- package/dist/okf/types/Subnet.md +4 -0
- package/dist/okf/types/Table.md +1 -0
- package/dist/okf/types/Type.md +3 -0
- package/dist/okf/types/Vpc.md +1 -0
- package/dist/op/activities/index.d.ts +6 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/receipt-store.d.ts +109 -0
- package/dist/receipt-store.d.ts.map +1 -0
- package/dist/rules/cf-refs.ts +40 -0
- package/dist/rules/waw061.ts +67 -0
- package/dist/rules/waw062.ts +0 -0
- package/dist/rules/waw063.ts +0 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/skills/chant-aws-carve-terraform.md +26 -23
- package/package.json +3 -3
- package/src/api/read-client.test.ts +9 -0
- package/src/api/read-client.ts +15 -2
- package/src/composites/agentcore-agent.ts +35 -13
- package/src/composites/bucket-deployment.test.ts +84 -0
- package/src/composites/bucket-deployment.ts +153 -0
- package/src/composites/composites.test.ts +325 -0
- package/src/composites/dynamodb-table.test.ts +154 -0
- package/src/composites/dynamodb-table.ts +124 -0
- package/src/composites/ec2-instance-bundle.ts +134 -0
- package/src/composites/ecr-repository.test.ts +118 -0
- package/src/composites/ecr-repository.ts +117 -0
- package/src/composites/fargate-service.ts +19 -3
- package/src/composites/index.ts +21 -2
- package/src/composites/monitoring-stack.test.ts +130 -0
- package/src/composites/monitoring-stack.ts +164 -0
- package/src/composites/nlb-service.ts +117 -0
- package/src/composites/step-functions-workflow.ts +127 -0
- package/src/deep-observe.test.ts +201 -1
- package/src/deep-observe.ts +204 -0
- package/src/deep-topology.test.ts +392 -0
- package/src/deep-topology.ts +237 -0
- package/src/effect-receipt-row.test.ts +164 -0
- package/src/effect-receipt-row.ts +147 -0
- package/src/index.ts +28 -7
- package/src/lint/audit-catalog.ts +6 -0
- package/src/lint/post-synth/cf-refs.ts +40 -0
- package/src/lint/post-synth/index.ts +6 -0
- package/src/lint/post-synth/waw061.test.ts +99 -0
- package/src/lint/post-synth/waw061.ts +67 -0
- package/src/lint/post-synth/waw062.test.ts +109 -0
- package/src/lint/post-synth/waw062.ts +0 -0
- package/src/lint/post-synth/waw063.test.ts +220 -0
- package/src/lint/post-synth/waw063.ts +0 -0
- package/src/op/activities/index.ts +15 -0
- package/src/plugin.ts +41 -10
- package/src/receipt-store.test.ts +204 -0
- package/src/receipt-store.ts +315 -0
- package/src/serializer.ts +105 -2
- 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
|
+
};
|