@intentius/chant-lexicon-aws 0.34.0 → 0.37.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 +108 -0
- package/dist/api/read-client.d.ts.map +1 -0
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/composites/lambda-function.d.ts +4 -4
- package/dist/composites/lambda-function.d.ts.map +1 -1
- package/dist/deep-observe.d.ts +87 -26
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/dependencies.d.ts +3 -0
- package/dist/dependencies.d.ts.map +1 -1
- package/dist/generated/index.d.ts +1 -0
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/manifest.json +1 -1
- package/dist/meta.json +30 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/properties.d.ts +23 -7
- package/dist/properties.d.ts.map +1 -1
- package/dist/types/index.d.ts +21 -0
- package/package.json +2 -2
- package/src/api/read-client.test.ts +203 -0
- package/src/api/read-client.ts +286 -0
- package/src/codegen/docs.ts +21 -4
- package/src/deep-observe.test.ts +357 -99
- package/src/deep-observe.ts +347 -131
- package/src/dependencies.ts +11 -6
- package/src/generated/index.d.ts +21 -0
- package/src/generated/index.ts +1 -0
- package/src/generated/lexicon-aws.json +30 -0
- package/src/index.ts +18 -1
- package/src/lifecycle-integration.test.ts +54 -45
- package/src/plugin.ts +66 -55
- package/src/properties.test.ts +38 -5
- package/src/properties.ts +47 -21
package/src/deep-observe.test.ts
CHANGED
|
@@ -2,15 +2,17 @@
|
|
|
2
2
|
* AWS deep observation (#1015) — the reference row of the deep-observe contract
|
|
3
3
|
* (#1014).
|
|
4
4
|
*
|
|
5
|
-
* Every AWS interaction here is a
|
|
6
|
-
* reads ambient credentials, or reaches a network: the reader's only edge
|
|
7
|
-
*
|
|
5
|
+
* Every AWS interaction here is a faked HTTP call (#1206). Nothing spawns a
|
|
6
|
+
* CLI, reads ambient credentials, or reaches a network: the reader's only edge
|
|
7
|
+
* is its transport, which is injected as `http` where the reader is called
|
|
8
|
+
* directly and stubbed at `fetch` where the plugin builds its own.
|
|
8
9
|
*/
|
|
9
|
-
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
10
|
+
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
|
10
11
|
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
12
|
+
// The Cloud Control source is injected as `http`; the EC2 source still shells
|
|
13
|
+
// the CLI (#1269), so that one edge is mocked here. Partial mock for the same
|
|
14
|
+
// reason lifecycle-integration.test.ts uses one — this module is reachable from
|
|
15
|
+
// real exports the plugin path touches.
|
|
14
16
|
const spawnMock = vi.fn();
|
|
15
17
|
vi.mock("@intentius/chant/runtime-adapter", async (importOriginal) => {
|
|
16
18
|
const actual = await importOriginal<typeof import("@intentius/chant/runtime-adapter")>();
|
|
@@ -21,52 +23,80 @@ const { awsPlugin } = await import("./plugin");
|
|
|
21
23
|
const {
|
|
22
24
|
observeResourcesDeepAws,
|
|
23
25
|
awsDeepNormalizationHooks,
|
|
24
|
-
parseCloudControlResource,
|
|
25
26
|
hasOwnershipMarker,
|
|
26
27
|
} = await import("./deep-observe");
|
|
28
|
+
const { parseResourceDescription } = await import("./api/read-client");
|
|
27
29
|
const { deepDiffForLexicon } = await import("@intentius/chant/lifecycle/deep-observe");
|
|
28
|
-
const { normalizeDeepObservation, normalizeDeepProperties } = await import("@intentius/chant/deep-observation");
|
|
30
|
+
const { normalizeDeepObservation, normalizeDeepProperties, flattenDeepProperties } = await import("@intentius/chant/deep-observation");
|
|
29
31
|
|
|
30
|
-
const ok = (
|
|
31
|
-
const fail = (stderr: string) => ({ stdout: "", stderr, exitCode: 255 });
|
|
32
|
+
const ok = (text: string) => ({ status: 200, text });
|
|
32
33
|
|
|
33
|
-
/** A
|
|
34
|
+
/** A Cloud Control `GetResource` body — the model arrives as a JSON string. */
|
|
34
35
|
const cloudControl = (identifier: string, properties: Record<string, unknown>) =>
|
|
35
36
|
ok(JSON.stringify({ ResourceDescription: { Identifier: identifier, Properties: JSON.stringify(properties) } }));
|
|
36
37
|
|
|
38
|
+
/** A modelled service error, in the shape AWS JSON 1.0 sends it. */
|
|
39
|
+
const apiError = (type: string, message: string, status = 400) =>
|
|
40
|
+
({ status, text: JSON.stringify({ __type: type, message }) });
|
|
41
|
+
|
|
42
|
+
/** A CloudFormation Query `<Error>` document. */
|
|
43
|
+
const queryError = (code: string, message: string) =>
|
|
44
|
+
({ status: 400, text: `<ErrorResponse><Error><Code>${code}</Code><Message>${message}</Message></Error></ErrorResponse>` });
|
|
45
|
+
|
|
37
46
|
const stackResources = (rows: Array<[string, string, string]>) =>
|
|
38
47
|
ok(
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
}
|
|
47
|
-
}),
|
|
48
|
+
`<DescribeStackResourcesResponse><DescribeStackResourcesResult><StackResources>${rows
|
|
49
|
+
.map(
|
|
50
|
+
([logicalId, type, physicalId]) =>
|
|
51
|
+
`<member><LogicalResourceId>${logicalId}</LogicalResourceId><ResourceType>${type}</ResourceType>` +
|
|
52
|
+
`<PhysicalResourceId>${physicalId}</PhysicalResourceId><ResourceStatus>CREATE_COMPLETE</ResourceStatus>` +
|
|
53
|
+
`<Timestamp>2026-01-01T00:00:00Z</Timestamp></member>`,
|
|
54
|
+
)
|
|
55
|
+
.join("")}</StackResources></DescribeStackResourcesResult></DescribeStackResourcesResponse>`,
|
|
48
56
|
);
|
|
49
57
|
|
|
58
|
+
type FakeResponse = { status: number; text: string };
|
|
59
|
+
interface FakeCall {
|
|
60
|
+
url: string;
|
|
61
|
+
target?: string;
|
|
62
|
+
body: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A transport fake in place of the old `spawn` fake. `route` sees the Cloud
|
|
67
|
+
* Control identifier (or `undefined` for the CloudFormation call) and returns
|
|
68
|
+
* the response; every call is recorded so a test can assert the endpoint the
|
|
69
|
+
* reader actually reached.
|
|
70
|
+
*/
|
|
71
|
+
function httpFake(route: (identifier: string | undefined, call: FakeCall) => FakeResponse) {
|
|
72
|
+
const calls: FakeCall[] = [];
|
|
73
|
+
const http = async (url: string, init: { headers: Record<string, string>; body: string }) => {
|
|
74
|
+
const target = init.headers["x-amz-target"];
|
|
75
|
+
const call: FakeCall = { url, ...(target ? { target } : {}), body: init.body };
|
|
76
|
+
calls.push(call);
|
|
77
|
+
const identifier = target ? (JSON.parse(init.body) as { Identifier?: string }).Identifier : undefined;
|
|
78
|
+
return route(identifier, call);
|
|
79
|
+
};
|
|
80
|
+
return { http, calls };
|
|
81
|
+
}
|
|
82
|
+
|
|
50
83
|
const entities = (
|
|
51
84
|
record: Record<string, { entityType: string; props: Record<string, unknown> }>,
|
|
52
85
|
): Map<string, { entityType: string; props: Record<string, unknown> }> => new Map(Object.entries(record));
|
|
53
86
|
|
|
54
|
-
const argvOf = (call: unknown[]): string[] => call[0] as string[];
|
|
55
87
|
|
|
56
|
-
describe("
|
|
88
|
+
describe("parseResourceDescription", () => {
|
|
57
89
|
test("unwraps the doubly-encoded model", () => {
|
|
58
|
-
expect(
|
|
90
|
+
expect(parseResourceDescription({ Identifier: "b", Properties: JSON.stringify({ BucketName: "b" }) })).toEqual({
|
|
59
91
|
identifier: "b",
|
|
60
92
|
properties: { BucketName: "b" },
|
|
61
93
|
});
|
|
62
94
|
});
|
|
63
95
|
|
|
64
96
|
test("an unparseable body is a failed read, not an empty resource", () => {
|
|
65
|
-
expect(
|
|
66
|
-
expect(
|
|
67
|
-
expect(
|
|
68
|
-
parseCloudControlResource(JSON.stringify({ ResourceDescription: { Properties: "{oops" } })),
|
|
69
|
-
).toBeNull();
|
|
97
|
+
expect(parseResourceDescription("not an object")).toBeNull();
|
|
98
|
+
expect(parseResourceDescription({})).toBeNull();
|
|
99
|
+
expect(parseResourceDescription({ Properties: "{oops" })).toBeNull();
|
|
70
100
|
});
|
|
71
101
|
});
|
|
72
102
|
|
|
@@ -87,6 +117,91 @@ describe("the aws noise rules", () => {
|
|
|
87
117
|
expect(out.Tags).toEqual([{ Key: "env", Value: "a" }, { Key: "team", Value: "b" }]);
|
|
88
118
|
});
|
|
89
119
|
|
|
120
|
+
// chant stamps its ownership marker onto the template, so it is live on every
|
|
121
|
+
// managed resource and absent from the declared properties the diff compares.
|
|
122
|
+
// Reporting it is chant reading its own signature back as drift.
|
|
123
|
+
test("chant's own ownership tags are not drift", () => {
|
|
124
|
+
const out = normalizeDeepProperties(
|
|
125
|
+
{
|
|
126
|
+
Tags: [
|
|
127
|
+
{ Key: "chant:managed-by", Value: "chant" },
|
|
128
|
+
{ Key: "chant:stack", Value: "web" },
|
|
129
|
+
{ Key: "chant:env", Value: "prod" },
|
|
130
|
+
{ Key: "team", Value: "payments" },
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
entityType: "AWS::EC2::SecurityGroup",
|
|
135
|
+
side: "live",
|
|
136
|
+
hooks: awsDeepNormalizationHooks,
|
|
137
|
+
counterpartPaths: new Set<string>(),
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
expect(out.Tags).toEqual([{ Key: "team", Value: "payments" }]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("a template that declares the marker itself still has it compared", () => {
|
|
144
|
+
const out = normalizeDeepProperties(
|
|
145
|
+
{ Tags: [{ Key: "chant:stack", Value: "renamed" }] },
|
|
146
|
+
{
|
|
147
|
+
entityType: "AWS::EC2::SecurityGroup",
|
|
148
|
+
side: "live",
|
|
149
|
+
hooks: awsDeepNormalizationHooks,
|
|
150
|
+
// Source declares the tag, so the counterpart is present and the
|
|
151
|
+
// suppression must not apply — a changed value is real drift.
|
|
152
|
+
counterpartPaths: new Set(["Tags[].Key", "Tags[].Value", "Tags[0]"]),
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
expect(out.Tags).toEqual([{ Key: "chant:stack", Value: "renamed" }]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// A rule's canonical JSON is longer than a path segment may carry, so keying
|
|
159
|
+
// by it made the flattener compare rule sets positionally — one added rule
|
|
160
|
+
// then reported as "the first rule changed, and a new one appeared".
|
|
161
|
+
test("keys a security-group rule by protocol, ports and source", () => {
|
|
162
|
+
const out = flattenDeepProperties(
|
|
163
|
+
{
|
|
164
|
+
SecurityGroupIngress: [
|
|
165
|
+
{ IpProtocol: "tcp", FromPort: 22, ToPort: 22, CidrIp: "203.0.113.0/24", Description: "ssh from the office" },
|
|
166
|
+
{ IpProtocol: "tcp", FromPort: 443, ToPort: 443, CidrIp: "0.0.0.0/0" },
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
{ entityType: "AWS::EC2::SecurityGroup", side: "live", hooks: awsDeepNormalizationHooks },
|
|
170
|
+
);
|
|
171
|
+
const paths = [...out.keys()];
|
|
172
|
+
expect(paths).toContain("SecurityGroupIngress[#tcp:22:22:203.0.113.0/24].CidrIp");
|
|
173
|
+
expect(paths).toContain("SecurityGroupIngress[#tcp:443:443:0.0.0.0/0].CidrIp");
|
|
174
|
+
// Positional segments would mean the set is being compared by position.
|
|
175
|
+
expect(paths.some((p) => p.startsWith("SecurityGroupIngress[0]"))).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("a rule keeps its identity when only its description changes", () => {
|
|
179
|
+
const key = (description: string) =>
|
|
180
|
+
[
|
|
181
|
+
...flattenDeepProperties(
|
|
182
|
+
{ SecurityGroupIngress: [{ IpProtocol: "tcp", FromPort: 22, ToPort: 22, CidrIp: "10.0.0.0/8", Description: description }] },
|
|
183
|
+
{ entityType: "AWS::EC2::SecurityGroup", side: "live", hooks: awsDeepNormalizationHooks },
|
|
184
|
+
).keys(),
|
|
185
|
+
].filter((p) => p.endsWith(".CidrIp"));
|
|
186
|
+
// Editing a description is a change to that rule, not a delete plus an add.
|
|
187
|
+
expect(key("before")).toEqual(key("after"));
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("a rule with no recognisable source falls back rather than colliding", () => {
|
|
191
|
+
const out = flattenDeepProperties(
|
|
192
|
+
{
|
|
193
|
+
SecurityGroupIngress: [
|
|
194
|
+
{ IpProtocol: "tcp", FromPort: 1, ToPort: 1 },
|
|
195
|
+
{ IpProtocol: "udp", FromPort: 2, ToPort: 2 },
|
|
196
|
+
],
|
|
197
|
+
},
|
|
198
|
+
{ entityType: "AWS::EC2::SecurityGroup", side: "live", hooks: awsDeepNormalizationHooks },
|
|
199
|
+
);
|
|
200
|
+
// Two sourceless rules must not key to the same segment; canonical JSON
|
|
201
|
+
// still distinguishes them.
|
|
202
|
+
expect([...out.keys()].filter((p) => p.endsWith(".IpProtocol"))).toHaveLength(2);
|
|
203
|
+
});
|
|
204
|
+
|
|
90
205
|
test("canonicalizes policy statement and action order", () => {
|
|
91
206
|
const out = normalizeDeepProperties(
|
|
92
207
|
{
|
|
@@ -146,32 +261,26 @@ describe("hasOwnershipMarker", () => {
|
|
|
146
261
|
});
|
|
147
262
|
|
|
148
263
|
describe("observeResourcesDeepAws", () => {
|
|
149
|
-
beforeEach(() =>
|
|
150
|
-
// A bare arrow returning the mock would register the mock itself as
|
|
151
|
-
// vitest's cleanup hook, and vitest would then call it with no arguments.
|
|
152
|
-
spawnMock.mockReset();
|
|
153
|
-
});
|
|
264
|
+
beforeEach(() => spawnMock.mockReset());
|
|
154
265
|
|
|
155
|
-
test("reads each resource through
|
|
266
|
+
test("reads each resource through Cloud Control, honoring AWS_ENDPOINT_URL", async () => {
|
|
156
267
|
const previous = process.env.AWS_ENDPOINT_URL;
|
|
157
268
|
process.env.AWS_ENDPOINT_URL = "http://127.0.0.1:5566";
|
|
158
269
|
try {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
: cloudControl("acme-assets", { BucketName: "acme-assets" }),
|
|
164
|
-
),
|
|
270
|
+
const fake = httpFake((identifier) =>
|
|
271
|
+
identifier === undefined
|
|
272
|
+
? stackResources([["Assets", "AWS::S3::Bucket", "acme-assets"]])
|
|
273
|
+
: cloudControl("acme-assets", { BucketName: "acme-assets" }),
|
|
165
274
|
);
|
|
166
275
|
const result = normalizeDeepObservation(
|
|
167
|
-
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"] }),
|
|
276
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"], http: fake.http }),
|
|
168
277
|
);
|
|
169
278
|
expect(result.resources.Assets.properties).toEqual({ BucketName: "acme-assets" });
|
|
170
279
|
expect(result.resources.Assets.physicalId).toBe("acme-assets");
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
280
|
+
// The endpoint override reaches both APIs — no `--endpoint-url` argv to
|
|
281
|
+
// forget, because there is no argv.
|
|
282
|
+
for (const call of fake.calls) expect(call.url).toBe("http://127.0.0.1:5566/");
|
|
283
|
+
expect(fake.calls.map((c) => c.target)).toEqual([undefined, "CloudApiService.GetResource"]);
|
|
175
284
|
} finally {
|
|
176
285
|
if (previous === undefined) delete process.env.AWS_ENDPOINT_URL;
|
|
177
286
|
else process.env.AWS_ENDPOINT_URL = previous;
|
|
@@ -179,85 +288,219 @@ describe("observeResourcesDeepAws", () => {
|
|
|
179
288
|
});
|
|
180
289
|
|
|
181
290
|
test("a type with no reader is unsupported-kind, never absent", async () => {
|
|
182
|
-
|
|
291
|
+
const fake = httpFake(() => stackResources([["Queue", "AWS::SQS::Queue", "q-1"]]));
|
|
183
292
|
const result = normalizeDeepObservation(
|
|
184
|
-
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Queue"] }),
|
|
293
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Queue"], http: fake.http }),
|
|
185
294
|
);
|
|
186
295
|
expect(result.resources).toEqual({});
|
|
187
296
|
expect(result.unobserved.Queue.reason).toBe("unsupported-kind");
|
|
188
297
|
});
|
|
189
298
|
|
|
190
299
|
test("an expired token on the deep read is no-credentials, per resource", async () => {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
: fail("An error occurred (ExpiredToken) when calling GetResource: The security token expired"),
|
|
196
|
-
),
|
|
300
|
+
const fake = httpFake((identifier) =>
|
|
301
|
+
identifier === undefined
|
|
302
|
+
? stackResources([["Assets", "AWS::S3::Bucket", "acme-assets"]])
|
|
303
|
+
: apiError("ExpiredTokenException", "The security token included in the request is expired"),
|
|
197
304
|
);
|
|
198
305
|
const result = normalizeDeepObservation(
|
|
199
|
-
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"] }),
|
|
306
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"], http: fake.http }),
|
|
200
307
|
);
|
|
201
308
|
expect(result.unobserved.Assets.reason).toBe("no-credentials");
|
|
202
309
|
expect(result.resources).toEqual({});
|
|
203
310
|
});
|
|
204
311
|
|
|
312
|
+
test("an unsupported operation is a hole for that resource, and says which operation", async () => {
|
|
313
|
+
// What Floci answers for GetResource today: the service is reachable and
|
|
314
|
+
// refuses the call, which is neither absence nor a credential problem.
|
|
315
|
+
const fake = httpFake((identifier) =>
|
|
316
|
+
identifier === undefined
|
|
317
|
+
? stackResources([["Assets", "AWS::S3::Bucket", "acme-assets"]])
|
|
318
|
+
: apiError("UnsupportedOperation", "Operation GetResource is not supported."),
|
|
319
|
+
);
|
|
320
|
+
const result = normalizeDeepObservation(
|
|
321
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"], http: fake.http }),
|
|
322
|
+
);
|
|
323
|
+
expect(result.unobserved.Assets.reason).toBe("read-failed");
|
|
324
|
+
expect(result.unobserved.Assets.detail).toContain("UnsupportedOperation");
|
|
325
|
+
expect(result.unobserved.Assets.detail).toContain("GetResource");
|
|
326
|
+
});
|
|
327
|
+
|
|
205
328
|
test("a stack that does not exist yet is a real absence — no properties, no holes", async () => {
|
|
206
|
-
|
|
329
|
+
const fake = httpFake(() => queryError("ValidationError", "Stack with id prod does not exist"));
|
|
207
330
|
const result = normalizeDeepObservation(
|
|
208
|
-
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"] }),
|
|
331
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Assets"], http: fake.http }),
|
|
209
332
|
);
|
|
210
333
|
expect(result).toEqual({ resources: {}, unobserved: {} });
|
|
211
334
|
});
|
|
212
335
|
|
|
213
336
|
test("any other stack-read failure is a hole for every declared entity", async () => {
|
|
214
|
-
|
|
337
|
+
const fake = httpFake(() => ({ status: 503, text: "<html>service unavailable</html>" }));
|
|
215
338
|
const result = normalizeDeepObservation(
|
|
216
|
-
await observeResourcesDeepAws({ environment: "prod", entityNames: ["A", "B"] }),
|
|
339
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["A", "B"], http: fake.http }),
|
|
217
340
|
);
|
|
218
341
|
expect(Object.keys(result.unobserved)).toEqual(["A", "B"]);
|
|
219
342
|
expect(result.unobserved.A.reason).toBe("read-failed");
|
|
220
343
|
});
|
|
221
344
|
|
|
222
345
|
test("--owned withholds an unmarked resource as `filtered`, not as absent", async () => {
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
: cloudControl("theirs", { BucketName: "theirs" }),
|
|
233
|
-
),
|
|
346
|
+
const fake = httpFake((identifier) =>
|
|
347
|
+
identifier === undefined
|
|
348
|
+
? stackResources([
|
|
349
|
+
["Ours", "AWS::S3::Bucket", "ours"],
|
|
350
|
+
["Theirs", "AWS::S3::Bucket", "theirs"],
|
|
351
|
+
])
|
|
352
|
+
: identifier === "ours"
|
|
353
|
+
? cloudControl("ours", { BucketName: "ours", Tags: [{ Key: "chant:managed-by", Value: "chant" }] })
|
|
354
|
+
: cloudControl("theirs", { BucketName: "theirs" }),
|
|
234
355
|
);
|
|
235
356
|
const result = normalizeDeepObservation(
|
|
236
|
-
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Ours", "Theirs"], owned: true }),
|
|
357
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Ours", "Theirs"], owned: true, http: fake.http }),
|
|
237
358
|
);
|
|
238
359
|
expect(Object.keys(result.resources)).toEqual(["Ours"]);
|
|
239
360
|
expect(result.unobserved.Theirs.reason).toBe("filtered");
|
|
240
361
|
});
|
|
241
362
|
|
|
242
363
|
test("secret-bearing properties are masked before they reach the tree", async () => {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
: cloudControl("app-role", { RoleName: "app-role", ClientSecret: "s3cr3t" }),
|
|
248
|
-
),
|
|
364
|
+
const fake = httpFake((identifier) =>
|
|
365
|
+
identifier === undefined
|
|
366
|
+
? stackResources([["Role", "AWS::IAM::Role", "app-role"]])
|
|
367
|
+
: cloudControl("app-role", { RoleName: "app-role", ClientSecret: "s3cr3t" }),
|
|
249
368
|
);
|
|
250
369
|
const result = normalizeDeepObservation(
|
|
251
|
-
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Role"] }),
|
|
370
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Role"], http: fake.http }),
|
|
252
371
|
);
|
|
253
372
|
expect(result.resources.Role.properties.ClientSecret).toBe("[REDACTED]");
|
|
254
373
|
expect(JSON.stringify(result)).not.toContain("s3cr3t");
|
|
255
374
|
});
|
|
256
375
|
|
|
257
376
|
test("a multi-stack project reads the stack it was handed", async () => {
|
|
258
|
-
|
|
259
|
-
await observeResourcesDeepAws({ environment: "prod", entityNames: ["A"], stack: "payments-prod" });
|
|
260
|
-
expect(
|
|
377
|
+
const fake = httpFake(() => stackResources([]));
|
|
378
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["A"], stack: "payments-prod", http: fake.http });
|
|
379
|
+
expect(fake.calls[0]?.body).toContain("StackName=payments-prod");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// #1269 — Cloud Control returns a security group's identity and description
|
|
383
|
+
// and none of its rules, so the type is sourced from the EC2 API instead.
|
|
384
|
+
describe("a type sourced from EC2 rather than Cloud Control", () => {
|
|
385
|
+
const sgRow = (permissions: unknown[]) => ({
|
|
386
|
+
GroupId: "sg-01",
|
|
387
|
+
GroupName: "app-sg",
|
|
388
|
+
Description: "app tier",
|
|
389
|
+
VpcId: "vpc-1",
|
|
390
|
+
Tags: [{ Key: "chant:managed-by", Value: "chant" }],
|
|
391
|
+
IpPermissions: permissions,
|
|
392
|
+
IpPermissionsEgress: [],
|
|
393
|
+
});
|
|
394
|
+
const ssh = {
|
|
395
|
+
IpProtocol: "tcp",
|
|
396
|
+
FromPort: 22,
|
|
397
|
+
ToPort: 22,
|
|
398
|
+
IpRanges: [{ CidrIp: "203.0.113.0/24", Description: "office" }],
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
test("reads the rules Cloud Control does not return, in the template's shape", async () => {
|
|
402
|
+
spawnMock.mockResolvedValue({
|
|
403
|
+
stdout: JSON.stringify({ SecurityGroups: [sgRow([ssh])] }),
|
|
404
|
+
stderr: "",
|
|
405
|
+
exitCode: 0,
|
|
406
|
+
});
|
|
407
|
+
const fake = httpFake(() => stackResources([["Perimeter", "AWS::EC2::SecurityGroup", "sg-01"]]));
|
|
408
|
+
const result = normalizeDeepObservation(
|
|
409
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Perimeter"], http: fake.http }),
|
|
410
|
+
);
|
|
411
|
+
// EC2's `Description`/`IpPermissions` arrive as the CloudFormation model's
|
|
412
|
+
// `GroupDescription`/`SecurityGroupIngress`, one flat rule per source.
|
|
413
|
+
expect(result.resources.Perimeter.properties).toMatchObject({
|
|
414
|
+
GroupDescription: "app tier",
|
|
415
|
+
VpcId: "vpc-1",
|
|
416
|
+
SecurityGroupIngress: [
|
|
417
|
+
{ IpProtocol: "tcp", FromPort: 22, ToPort: 22, CidrIp: "203.0.113.0/24", Description: "office" },
|
|
418
|
+
],
|
|
419
|
+
});
|
|
420
|
+
// The physical id is server-populated and pruned, as on every other type.
|
|
421
|
+
expect(result.resources.Perimeter.properties.GroupId).toBeUndefined();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("one describe for every group in the stack, not one per group", async () => {
|
|
425
|
+
spawnMock.mockResolvedValue({
|
|
426
|
+
stdout: JSON.stringify({ SecurityGroups: [sgRow([ssh]), { ...sgRow([]), GroupId: "sg-02" }] }),
|
|
427
|
+
stderr: "",
|
|
428
|
+
exitCode: 0,
|
|
429
|
+
});
|
|
430
|
+
const fake = httpFake(() =>
|
|
431
|
+
stackResources([
|
|
432
|
+
["A", "AWS::EC2::SecurityGroup", "sg-01"],
|
|
433
|
+
["B", "AWS::EC2::SecurityGroup", "sg-02"],
|
|
434
|
+
]),
|
|
435
|
+
);
|
|
436
|
+
const result = normalizeDeepObservation(
|
|
437
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["A", "B"], http: fake.http }),
|
|
438
|
+
);
|
|
439
|
+
expect(Object.keys(result.resources).sort()).toEqual(["A", "B"]);
|
|
440
|
+
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
441
|
+
expect(spawnMock.mock.calls[0][0]).toEqual(expect.arrayContaining(["describe-security-groups", "sg-01", "sg-02"]));
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("a failed describe is a hole for that type, never an absence", async () => {
|
|
445
|
+
spawnMock.mockResolvedValue({ stdout: "", stderr: "throttled", exitCode: 255 });
|
|
446
|
+
const fake = httpFake(() => stackResources([["Perimeter", "AWS::EC2::SecurityGroup", "sg-01"]]));
|
|
447
|
+
const result = normalizeDeepObservation(
|
|
448
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Perimeter"], http: fake.http }),
|
|
449
|
+
);
|
|
450
|
+
expect(result.resources).toEqual({});
|
|
451
|
+
expect(result.unobserved.Perimeter.reason).toBe("read-failed");
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("a group the describe answered for but did not return is absent, not a hole", async () => {
|
|
455
|
+
// The read succeeded and the id was not in it. The thin path reports that
|
|
456
|
+
// absence; a second report here would turn one finding into two.
|
|
457
|
+
spawnMock.mockResolvedValue({ stdout: JSON.stringify({ SecurityGroups: [] }), stderr: "", exitCode: 0 });
|
|
458
|
+
const fake = httpFake(() => stackResources([["Perimeter", "AWS::EC2::SecurityGroup", "sg-01"]]));
|
|
459
|
+
const result = normalizeDeepObservation(
|
|
460
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Perimeter"], http: fake.http }),
|
|
461
|
+
);
|
|
462
|
+
expect(result.resources).toEqual({});
|
|
463
|
+
expect(result.unobserved).toEqual({});
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("--owned reads the marker off the EC2 tags", async () => {
|
|
467
|
+
spawnMock.mockResolvedValue({
|
|
468
|
+
stdout: JSON.stringify({ SecurityGroups: [{ ...sgRow([ssh]), Tags: [{ Key: "team", Value: "other" }] }] }),
|
|
469
|
+
stderr: "",
|
|
470
|
+
exitCode: 0,
|
|
471
|
+
});
|
|
472
|
+
const fake = httpFake(() => stackResources([["Perimeter", "AWS::EC2::SecurityGroup", "sg-01"]]));
|
|
473
|
+
const result = normalizeDeepObservation(
|
|
474
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["Perimeter"], owned: true, http: fake.http }),
|
|
475
|
+
);
|
|
476
|
+
expect(result.unobserved.Perimeter.reason).toBe("filtered");
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test("the per-resource reads are concurrent, not one round trip after another", async () => {
|
|
481
|
+
let inFlight = 0;
|
|
482
|
+
let peak = 0;
|
|
483
|
+
const http = async (_url: string, init: { headers: Record<string, string>; body: string }) => {
|
|
484
|
+
const target = init.headers["x-amz-target"];
|
|
485
|
+
if (!target) {
|
|
486
|
+
return stackResources([
|
|
487
|
+
["A", "AWS::S3::Bucket", "a"],
|
|
488
|
+
["B", "AWS::S3::Bucket", "b"],
|
|
489
|
+
["C", "AWS::S3::Bucket", "c"],
|
|
490
|
+
]);
|
|
491
|
+
}
|
|
492
|
+
inFlight += 1;
|
|
493
|
+
peak = Math.max(peak, inFlight);
|
|
494
|
+
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
495
|
+
inFlight -= 1;
|
|
496
|
+
const identifier = (JSON.parse(init.body) as { Identifier: string }).Identifier;
|
|
497
|
+
return cloudControl(identifier, { BucketName: identifier });
|
|
498
|
+
};
|
|
499
|
+
const result = normalizeDeepObservation(
|
|
500
|
+
await observeResourcesDeepAws({ environment: "prod", entityNames: ["A", "B", "C"], http }),
|
|
501
|
+
);
|
|
502
|
+
expect(Object.keys(result.resources).sort()).toEqual(["A", "B", "C"]);
|
|
503
|
+
expect(peak).toBeGreaterThan(1);
|
|
261
504
|
});
|
|
262
505
|
});
|
|
263
506
|
|
|
@@ -266,10 +509,13 @@ describe("observeResourcesDeepAws", () => {
|
|
|
266
509
|
* baseline, and exactly the genuine drift.
|
|
267
510
|
*/
|
|
268
511
|
describe("end to end: declared + mutated live + baseline (#1015)", () => {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
512
|
+
// The security group in this estate is sourced from EC2 (#1269), which still
|
|
513
|
+
// shells the CLI; it stands in for a deep read that fails.
|
|
514
|
+
beforeEach(() => spawnMock.mockResolvedValue({ stdout: "", stderr: "throttled", exitCode: 255 }));
|
|
515
|
+
// This block drives the real plugin, which builds its own transport, so the
|
|
516
|
+
// seam here is `fetch` itself rather than an injected `http` (#1206).
|
|
517
|
+
afterEach(() => {
|
|
518
|
+
vi.restoreAllMocks();
|
|
273
519
|
});
|
|
274
520
|
|
|
275
521
|
const declared = entities({
|
|
@@ -306,9 +552,14 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
|
|
|
306
552
|
});
|
|
307
553
|
|
|
308
554
|
const wireMocks = (): void => {
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
555
|
+
vi.spyOn(globalThis, "fetch").mockImplementation((async (_url: string, init: { headers: Record<string, string>; body: string }) => {
|
|
556
|
+
const target = init.headers["x-amz-target"];
|
|
557
|
+
const identifier = target ? (JSON.parse(init.body) as { Identifier: string }).Identifier : undefined;
|
|
558
|
+
const respond = (r: { status: number; text: string }) =>
|
|
559
|
+
({ status: r.status, text: () => Promise.resolve(r.text) });
|
|
560
|
+
|
|
561
|
+
if (identifier === undefined) {
|
|
562
|
+
return respond(
|
|
312
563
|
stackResources([
|
|
313
564
|
["Assets", "AWS::S3::Bucket", "acme-assets"],
|
|
314
565
|
["AppRole", "AWS::IAM::Role", "app-role"],
|
|
@@ -317,8 +568,8 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
|
|
|
317
568
|
]),
|
|
318
569
|
);
|
|
319
570
|
}
|
|
320
|
-
if (
|
|
321
|
-
return
|
|
571
|
+
if (identifier === "acme-assets") {
|
|
572
|
+
return respond(
|
|
322
573
|
cloudControl("acme-assets", {
|
|
323
574
|
BucketName: "acme-assets",
|
|
324
575
|
// GENUINE: somebody turned versioning off in the console.
|
|
@@ -336,8 +587,8 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
|
|
|
336
587
|
}),
|
|
337
588
|
);
|
|
338
589
|
}
|
|
339
|
-
if (
|
|
340
|
-
return
|
|
590
|
+
if (identifier === "app-role") {
|
|
591
|
+
return respond(
|
|
341
592
|
cloudControl("app-role", {
|
|
342
593
|
RoleName: "app-role",
|
|
343
594
|
// NOISE: statements and actions in a different order than source.
|
|
@@ -358,11 +609,11 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
|
|
|
358
609
|
}),
|
|
359
610
|
);
|
|
360
611
|
}
|
|
361
|
-
if (
|
|
362
|
-
return
|
|
612
|
+
if (identifier === "sg-01") {
|
|
613
|
+
return respond(apiError("ValidationException", "sg-01 is read through EC2, not Cloud Control"));
|
|
363
614
|
}
|
|
364
|
-
return
|
|
365
|
-
});
|
|
615
|
+
return respond(apiError("ValidationException", `unexpected call for ${identifier}`));
|
|
616
|
+
}) as unknown as typeof fetch);
|
|
366
617
|
};
|
|
367
618
|
|
|
368
619
|
const baseline = {
|
|
@@ -419,14 +670,14 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
|
|
|
419
670
|
name: "Jobs",
|
|
420
671
|
type: "AWS::SQS::Queue",
|
|
421
672
|
reason: "unsupported-kind",
|
|
422
|
-
detail: "no deep reader for AWS::SQS::Queue —
|
|
673
|
+
detail: "no deep reader for AWS::SQS::Queue — coverage is opt-in per type",
|
|
423
674
|
},
|
|
424
675
|
{
|
|
425
676
|
name: "Perimeter",
|
|
426
677
|
type: "AWS::EC2::SecurityGroup",
|
|
427
678
|
reason: "read-failed",
|
|
428
679
|
detail:
|
|
429
|
-
|
|
680
|
+
"ec2 describe-security-groups failed, so AWS::EC2::SecurityGroup could not be read deeply",
|
|
430
681
|
},
|
|
431
682
|
]);
|
|
432
683
|
});
|
|
@@ -471,7 +722,14 @@ describe("end to end: declared + mutated live + baseline (#1015)", () => {
|
|
|
471
722
|
});
|
|
472
723
|
|
|
473
724
|
test("a whole-lexicon failure is a hole for every declared entity, not a clean report", async () => {
|
|
474
|
-
|
|
725
|
+
// The stack read itself is refused, so nothing downstream ever runs. CFN
|
|
726
|
+
// speaks the Query protocol, so the refusal arrives as an `<Error>`
|
|
727
|
+
// document rather than the JSON one Cloud Control would send.
|
|
728
|
+
const refused = queryError("AccessDenied", "User is not authorized to perform cloudformation:DescribeStackResources");
|
|
729
|
+
vi.spyOn(globalThis, "fetch").mockImplementation((async () => ({
|
|
730
|
+
status: refused.status,
|
|
731
|
+
text: () => Promise.resolve(refused.text),
|
|
732
|
+
})) as unknown as typeof fetch);
|
|
475
733
|
const result = await deepDiffForLexicon(awsPlugin, {
|
|
476
734
|
environment: "prod",
|
|
477
735
|
buildOutput: "",
|