@intentius/chant-lexicon-aws 0.34.1 → 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/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/properties.d.ts +23 -7
- package/dist/properties.d.ts.map +1 -1
- 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/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.ts
CHANGED
|
@@ -16,20 +16,31 @@
|
|
|
16
16
|
* `describe-stack-resources` already reports per logical id, so the results
|
|
17
17
|
* line up with the same IR node ids `live-attrs.ts` relies on.
|
|
18
18
|
*
|
|
19
|
-
* ##
|
|
19
|
+
* ## One reader, several sources (#1269)
|
|
20
20
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
21
|
+
* Cloud Control is the default source, not the only one. It has the property
|
|
22
|
+
* this reader wants most — it returns the *CloudFormation resource model*, so
|
|
23
|
+
* its payload lines up with the declared side without translation — but its
|
|
24
|
+
* coverage is not the same as the provider's. A security group read through
|
|
25
|
+
* Cloud Control comes back as identity and a description; read through
|
|
26
|
+
* `ec2 describe-security-groups` it comes back with every ingress and egress
|
|
27
|
+
* rule, which is the property people actually edit out of band.
|
|
28
|
+
*
|
|
29
|
+
* So each type names its source in {@link DEEP_SOURCES}, and a source that is
|
|
30
|
+
* not Cloud Control supplies a `toModel` that maps the provider's shape onto
|
|
31
|
+
* the CloudFormation one. Translation is the price of the richer read, and it
|
|
32
|
+
* belongs next to the type it translates rather than inside the diff.
|
|
33
|
+
*
|
|
34
|
+
* A declared resource of a type with no source reports NOT-OBSERVED with
|
|
35
|
+
* `unsupported-kind` — it may well exist, and saying nothing about it is the
|
|
36
|
+
* only honest answer.
|
|
27
37
|
*
|
|
28
38
|
* ## Nothing here talks to real AWS on its own terms
|
|
29
39
|
*
|
|
30
|
-
* Every call goes through the
|
|
31
|
-
*
|
|
32
|
-
* local emulator
|
|
40
|
+
* Every call goes through `./api/read-client.ts` — the applier's own transport,
|
|
41
|
+
* pointed at the read APIs (#1206) — so `AWS_ENDPOINT_URL` redirects the whole
|
|
42
|
+
* reader at a local emulator, with no CLI to spawn and typed failures instead of
|
|
43
|
+
* parsed stderr.
|
|
33
44
|
*/
|
|
34
45
|
|
|
35
46
|
import type {
|
|
@@ -41,20 +52,96 @@ import type {
|
|
|
41
52
|
UnobservedEntity,
|
|
42
53
|
UnobservedReason,
|
|
43
54
|
} from "@intentius/chant/lexicon";
|
|
44
|
-
import {
|
|
45
|
-
|
|
55
|
+
import {
|
|
56
|
+
AwsReadError,
|
|
57
|
+
describeStackResources,
|
|
58
|
+
getResource,
|
|
59
|
+
type AwsReadClientOptions,
|
|
60
|
+
type AwsReadHttp,
|
|
61
|
+
} from "./api/read-client";
|
|
46
62
|
import { AWS_TAG_OWNERSHIP_KEYS } from "./ownership";
|
|
63
|
+
import { applyAwsEndpointArgv } from "./components/cloud-executor";
|
|
64
|
+
import { toIngressRules } from "./dependencies";
|
|
47
65
|
|
|
48
66
|
/**
|
|
49
|
-
*
|
|
50
|
-
*
|
|
67
|
+
* Where each type's live model comes from.
|
|
68
|
+
*
|
|
69
|
+
* `cloud-control` is addressed by the physical id `describe-stack-resources`
|
|
70
|
+
* already reports, and needs no translation. An `ec2` source names a bulk
|
|
71
|
+
* describe — one call for every id of that type, not one per resource — plus
|
|
72
|
+
* the mapping from the EC2 shape onto the CloudFormation model the declared
|
|
73
|
+
* side is written in.
|
|
51
74
|
*/
|
|
52
|
-
export
|
|
53
|
-
"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
]
|
|
75
|
+
export type DeepSource =
|
|
76
|
+
| { via: "cloud-control" }
|
|
77
|
+
| {
|
|
78
|
+
via: "ec2";
|
|
79
|
+
/** Bulk describe argv, minus the ids and the region. */
|
|
80
|
+
argv: string[];
|
|
81
|
+
/** Flag the physical ids are passed under. */
|
|
82
|
+
idFlag: string;
|
|
83
|
+
/** Top-level key of the result array. */
|
|
84
|
+
key: string;
|
|
85
|
+
/** Field on each row carrying the physical id, for the join back. */
|
|
86
|
+
id: string;
|
|
87
|
+
/** EC2 row -> CloudFormation resource model. */
|
|
88
|
+
toModel: (row: Record<string, unknown>) => Record<string, unknown>;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export const DEEP_SOURCES: Record<string, DeepSource> = {
|
|
92
|
+
"AWS::S3::Bucket": { via: "cloud-control" },
|
|
93
|
+
"AWS::IAM::Role": { via: "cloud-control" },
|
|
94
|
+
"AWS::IAM::ManagedPolicy": { via: "cloud-control" },
|
|
95
|
+
// Cloud Control returns a security group's identity and description and none
|
|
96
|
+
// of its rules (#1269). The EC2 API returns the whole thing, so the drift
|
|
97
|
+
// people care about — an ingress rule edited in the console — is legible.
|
|
98
|
+
"AWS::EC2::SecurityGroup": {
|
|
99
|
+
via: "ec2",
|
|
100
|
+
argv: ["ec2", "describe-security-groups"],
|
|
101
|
+
idFlag: "--group-ids",
|
|
102
|
+
key: "SecurityGroups",
|
|
103
|
+
id: "GroupId",
|
|
104
|
+
toModel: securityGroupToModel,
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Types this reader can read live. Derived from {@link DEEP_SOURCES} so the two
|
|
110
|
+
* cannot drift apart — the shape of bug #1280 is about.
|
|
111
|
+
*/
|
|
112
|
+
export const DEEP_READABLE_TYPES: ReadonlySet<string> = new Set(Object.keys(DEEP_SOURCES));
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `describe-security-groups` -> the `AWS::EC2::SecurityGroup` resource model.
|
|
116
|
+
*
|
|
117
|
+
* The two surfaces disagree about names and about nesting. EC2 says
|
|
118
|
+
* `Description` where the template says `GroupDescription`, and nests rule
|
|
119
|
+
* sources under `IpRanges[]` / `Ipv6Ranges[]` / `UserIdGroupPairs[]` where the
|
|
120
|
+
* template writes one flat rule per source. `toIngressRules` already resolves
|
|
121
|
+
* the nesting for the topology fold (#1273); this reuses it so there is one
|
|
122
|
+
* translation of that shape in the lexicon, not two.
|
|
123
|
+
*
|
|
124
|
+
* Rules are matched by their whole canonical value (the `orderKey` hook below),
|
|
125
|
+
* so a field the template carries and this mapping drops is not a smaller diff —
|
|
126
|
+
* it is every rule reported twice, once absent and once undeclared. That is why
|
|
127
|
+
* a range's own `Description` is carried through.
|
|
128
|
+
*/
|
|
129
|
+
export function securityGroupToModel(row: Record<string, unknown>): Record<string, unknown> {
|
|
130
|
+
const ingress = toIngressRules((row.IpPermissions ?? []) as never);
|
|
131
|
+
const egress = toIngressRules((row.IpPermissionsEgress ?? []) as never);
|
|
132
|
+
return {
|
|
133
|
+
...(typeof row.Description === "string" ? { GroupDescription: row.Description } : {}),
|
|
134
|
+
...(typeof row.GroupName === "string" ? { GroupName: row.GroupName } : {}),
|
|
135
|
+
...(typeof row.VpcId === "string" ? { VpcId: row.VpcId } : {}),
|
|
136
|
+
...(typeof row.GroupId === "string" ? { GroupId: row.GroupId } : {}),
|
|
137
|
+
// An empty tag set is the absence of tags, not a value. EC2 always sends
|
|
138
|
+
// the key, so carrying it through reports `Tags: <undeclared> -> []` on
|
|
139
|
+
// every untagged group — agreement rendered as drift.
|
|
140
|
+
...(Array.isArray(row.Tags) && row.Tags.length > 0 ? { Tags: row.Tags } : {}),
|
|
141
|
+
...(ingress.length > 0 ? { SecurityGroupIngress: ingress } : {}),
|
|
142
|
+
...(egress.length > 0 ? { SecurityGroupEgress: egress } : {}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
58
145
|
|
|
59
146
|
/**
|
|
60
147
|
* Property names that are server-populated wherever they appear — identifiers
|
|
@@ -108,9 +195,28 @@ export const AWS_SERVICE_DEFAULTS: Record<string, Record<string, unknown>> = {
|
|
|
108
195
|
},
|
|
109
196
|
"AWS::EC2::SecurityGroup": {
|
|
110
197
|
"GroupDescription": "default VPC security group",
|
|
198
|
+
// EC2 gives every group an allow-all egress rule when the template declares
|
|
199
|
+
// none (#1269). Patterns are index-erased, so these two match the default
|
|
200
|
+
// rule wherever it lands in the set — and only while source declares no
|
|
201
|
+
// egress at all, which is what `counterpart: "absent"` checks.
|
|
202
|
+
"SecurityGroupEgress[].CidrIp": "0.0.0.0/0",
|
|
203
|
+
"SecurityGroupEgress[].IpProtocol": "-1",
|
|
111
204
|
},
|
|
112
205
|
};
|
|
113
206
|
|
|
207
|
+
/**
|
|
208
|
+
* Names the service mints when a template does not supply one (#1269).
|
|
209
|
+
*
|
|
210
|
+
* Unlike {@link AWS_SERVICE_DEFAULTS} there is no fixed value to compare: a
|
|
211
|
+
* CloudFormation-generated security-group name is a different random string on
|
|
212
|
+
* every deploy. Pruned on the live side only, and only where source is silent —
|
|
213
|
+
* a template that names the group keeps the property in the diff, so a later
|
|
214
|
+
* rename still reports.
|
|
215
|
+
*/
|
|
216
|
+
export const AWS_GENERATED_NAMES: Record<string, ReadonlySet<string>> = {
|
|
217
|
+
"AWS::EC2::SecurityGroup": new Set(["GroupName"]),
|
|
218
|
+
};
|
|
219
|
+
|
|
114
220
|
/** Stable JSON with sorted keys — the fallback ordering key for a set-like array. */
|
|
115
221
|
function canonicalJson(value: unknown): string {
|
|
116
222
|
return JSON.stringify(value, (_k, v: unknown) =>
|
|
@@ -144,6 +250,17 @@ export const awsDeepNormalizationHooks: DeepNormalizationHooks = {
|
|
|
144
250
|
// about the property. `"unknown"` (a one-sided normalization) never prunes:
|
|
145
251
|
// the reader must not decide this before the declared tree is in hand.
|
|
146
252
|
if (node.side !== "live" || node.counterpart !== "absent") return false;
|
|
253
|
+
|
|
254
|
+
// chant's own ownership marker. The serializer stamps it onto the template,
|
|
255
|
+
// so it is live on every managed resource and absent from the declared
|
|
256
|
+
// *properties* the diff compares — chant reading its own signature back as
|
|
257
|
+
// drift. Counterpart-gated, so a template that declares the tag itself is
|
|
258
|
+
// still compared against what is live.
|
|
259
|
+
if (isOwnershipTag(node.value)) return true;
|
|
260
|
+
|
|
261
|
+
// A name the service generated because source did not supply one.
|
|
262
|
+
if (AWS_GENERATED_NAMES[node.entityType]?.has(node.pattern)) return true;
|
|
263
|
+
|
|
147
264
|
const defaults = AWS_SERVICE_DEFAULTS[node.entityType];
|
|
148
265
|
if (!defaults) return false;
|
|
149
266
|
if (!Object.prototype.hasOwnProperty.call(defaults, node.pattern)) return false;
|
|
@@ -178,7 +295,10 @@ export const awsDeepNormalizationHooks: DeepNormalizationHooks = {
|
|
|
178
295
|
|
|
179
296
|
// Security-group rules are a set — the console appends, chant declares in
|
|
180
297
|
// source order, and neither order means anything to EC2.
|
|
181
|
-
if (name === "SecurityGroupIngress" || name === "SecurityGroupEgress"
|
|
298
|
+
if (name === "SecurityGroupIngress" || name === "SecurityGroupEgress") {
|
|
299
|
+
return securityGroupRuleKey(el) ?? canonicalJson(el);
|
|
300
|
+
}
|
|
301
|
+
if (name === "IpRanges") {
|
|
182
302
|
return canonicalJson(el);
|
|
183
303
|
}
|
|
184
304
|
|
|
@@ -190,50 +310,77 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
190
310
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
191
311
|
}
|
|
192
312
|
|
|
193
|
-
/** One live resource as
|
|
313
|
+
/** One live resource as Cloud Control returns it. Exported for tests. */
|
|
194
314
|
export interface CloudControlResource {
|
|
195
315
|
identifier: string;
|
|
196
316
|
properties: Record<string, unknown>;
|
|
197
317
|
}
|
|
198
318
|
|
|
199
319
|
/**
|
|
200
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
203
|
-
* failed read, not an empty resource.
|
|
320
|
+
* Classify a failed read the same way the thin path does — off the API's own
|
|
321
|
+
* error code where there is one (#1206), falling back to the message for a
|
|
322
|
+
* transport-level failure that never reached the service.
|
|
204
323
|
*/
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
} catch {
|
|
210
|
-
return null;
|
|
211
|
-
}
|
|
212
|
-
if (!isRecord(envelope)) return null;
|
|
213
|
-
const description = envelope.ResourceDescription;
|
|
214
|
-
if (!isRecord(description)) return null;
|
|
215
|
-
const raw = description.Properties;
|
|
216
|
-
if (typeof raw !== "string") return null;
|
|
217
|
-
let properties: unknown;
|
|
218
|
-
try {
|
|
219
|
-
properties = JSON.parse(raw);
|
|
220
|
-
} catch {
|
|
221
|
-
return null;
|
|
222
|
-
}
|
|
223
|
-
if (!isRecord(properties)) return null;
|
|
224
|
-
return {
|
|
225
|
-
identifier: typeof description.Identifier === "string" ? description.Identifier : "",
|
|
226
|
-
properties,
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/** Classify a failed AWS CLI call the same way the thin read does. */
|
|
231
|
-
function classifyFailure(stderr: string): UnobservedReason {
|
|
232
|
-
return /credential|token|expired|AccessDenied|not authorized|UnauthorizedOperation/i.test(stderr)
|
|
324
|
+
function classifyFailure(err: unknown): UnobservedReason {
|
|
325
|
+
const code = err instanceof AwsReadError ? err.code ?? "" : "";
|
|
326
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
327
|
+
return /credential|token|expired|AccessDenied|NotAuthorized|Unauthorized/i.test(`${code} ${message}`)
|
|
233
328
|
? "no-credentials"
|
|
234
329
|
: "read-failed";
|
|
235
330
|
}
|
|
236
331
|
|
|
332
|
+
/** The message a failed read reports, without a stack trace or a stderr tail. */
|
|
333
|
+
function failureDetail(err: unknown): string {
|
|
334
|
+
if (err instanceof AwsReadError) return err.code ? `${err.code}: ${err.message}` : err.message;
|
|
335
|
+
return err instanceof Error ? err.message : String(err);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** True when a CloudFormation read failed only because the stack isn't there yet. */
|
|
339
|
+
function isStackMissing(err: unknown): boolean {
|
|
340
|
+
return err instanceof AwsReadError && /does not exist/i.test(err.message);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* What identifies a security-group rule, as a path segment.
|
|
345
|
+
*
|
|
346
|
+
* `canonicalJson` of a rule runs past the length a path segment may carry
|
|
347
|
+
* (`MAX_KEYED_SEGMENT`, 60), so keying by it makes the flattener give up and
|
|
348
|
+
* compare the set positionally. A rule added at the top then shifts every rule
|
|
349
|
+
* below it, and one added rule reports as "the first rule changed, and a new
|
|
350
|
+
* one appeared at the end" — detection is right, attribution is not.
|
|
351
|
+
*
|
|
352
|
+
* Identity is protocol, port range and source, which is what EC2 itself
|
|
353
|
+
* enforces uniqueness on. `Description` is deliberately excluded: editing a
|
|
354
|
+
* rule's description is a change *to that rule*, not the removal of one rule
|
|
355
|
+
* and the addition of another.
|
|
356
|
+
*/
|
|
357
|
+
function securityGroupRuleKey(element: unknown): string | undefined {
|
|
358
|
+
if (!isRecord(element)) return undefined;
|
|
359
|
+
const source =
|
|
360
|
+
typeof element.CidrIp === "string"
|
|
361
|
+
? element.CidrIp
|
|
362
|
+
: typeof element.CidrIpv6 === "string"
|
|
363
|
+
? element.CidrIpv6
|
|
364
|
+
: typeof element.SourceSecurityGroupId === "string"
|
|
365
|
+
? element.SourceSecurityGroupId
|
|
366
|
+
: undefined;
|
|
367
|
+
// No recognisable source is not a rule this can identify; the caller falls
|
|
368
|
+
// back rather than keying every such rule to the same segment.
|
|
369
|
+
if (source === undefined) return undefined;
|
|
370
|
+
const protocol = typeof element.IpProtocol === "string" ? element.IpProtocol : "-1";
|
|
371
|
+
const from = element.FromPort ?? "*";
|
|
372
|
+
const to = element.ToPort ?? "*";
|
|
373
|
+
return `${protocol}:${String(from)}:${String(to)}:${source}`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Every tag key the serializer stamps as chant's ownership marker. */
|
|
377
|
+
const OWNERSHIP_TAG_KEYS: ReadonlySet<string> = new Set(Object.values(AWS_TAG_OWNERSHIP_KEYS));
|
|
378
|
+
|
|
379
|
+
/** True when `value` is a `{Key, Value}` tag whose key is one chant stamps itself. */
|
|
380
|
+
function isOwnershipTag(value: unknown): boolean {
|
|
381
|
+
return isRecord(value) && typeof value.Key === "string" && OWNERSHIP_TAG_KEYS.has(value.Key);
|
|
382
|
+
}
|
|
383
|
+
|
|
237
384
|
/** True when the live property tree carries chant's ownership marker tag. */
|
|
238
385
|
export function hasOwnershipMarker(properties: Record<string, unknown>): boolean {
|
|
239
386
|
const tags = properties.Tags;
|
|
@@ -252,142 +399,211 @@ export interface AwsDeepObserveOptions {
|
|
|
252
399
|
* silently records no properties for them. */
|
|
253
400
|
region?: string;
|
|
254
401
|
owned?: boolean;
|
|
402
|
+
/** Injectable transport, mirroring `awsApply`'s `http` — tests reach the reader without a network. */
|
|
403
|
+
http?: AwsReadHttp;
|
|
255
404
|
}
|
|
256
405
|
|
|
257
406
|
/**
|
|
258
407
|
* Read the live property tree for each declared entity via Cloud Control.
|
|
259
408
|
*
|
|
260
|
-
* Two reads per run plus one per readable resource: `
|
|
261
|
-
* resolves logical id → (type, physical id), then `
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
265
|
-
*
|
|
409
|
+
* Two reads per run plus one per readable resource: `DescribeStackResources`
|
|
410
|
+
* resolves logical id → (type, physical id), then `GetResource` fetches each
|
|
411
|
+
* model. The first read's failure modes are the thin path's, verbatim — a stack
|
|
412
|
+
* that does not exist yet is a real absence (nothing is deployed, so there are
|
|
413
|
+
* no properties to drift), anything else is a hole for every declared entity.
|
|
414
|
+
*
|
|
415
|
+
* The per-resource reads run concurrently (#1201/#1206). They were serial when
|
|
416
|
+
* each one was a process spawn, which made a deep snapshot of a large stack
|
|
417
|
+
* cost N round trips end to end.
|
|
266
418
|
*/
|
|
267
419
|
export async function observeResourcesDeepAws(
|
|
268
420
|
options: AwsDeepObserveOptions,
|
|
269
421
|
): Promise<DeepObservationResult> {
|
|
270
|
-
const { getRuntime } = await import("@intentius/chant/runtime-adapter");
|
|
271
422
|
const { deepObservation, normalizeDeepProperties } = await import("@intentius/chant/deep-observation");
|
|
272
|
-
const { unobservedAll } = await import("@intentius/chant/observation");
|
|
273
|
-
const rt = getRuntime();
|
|
423
|
+
const { unobservedAll, boundedConcurrently } = await import("@intentius/chant/observation");
|
|
274
424
|
|
|
275
425
|
const stackName = options.stack ?? options.environment;
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
"aws", "cloudformation", "describe-stack-resources",
|
|
282
|
-
"--stack-name", stackName,
|
|
283
|
-
...regionArgs,
|
|
284
|
-
"--output", "json",
|
|
285
|
-
], endpoint));
|
|
426
|
+
const client: AwsReadClientOptions = {
|
|
427
|
+
...(process.env.AWS_ENDPOINT_URL ? { endpoint: process.env.AWS_ENDPOINT_URL } : {}),
|
|
428
|
+
...(options.region ? { region: options.region } : {}),
|
|
429
|
+
...(options.http ? { http: options.http } : {}),
|
|
430
|
+
};
|
|
286
431
|
|
|
287
|
-
|
|
288
|
-
|
|
432
|
+
let stackResources: Awaited<ReturnType<typeof describeStackResources>>;
|
|
433
|
+
try {
|
|
434
|
+
stackResources = await describeStackResources(stackName, client);
|
|
435
|
+
} catch (err) {
|
|
436
|
+
if (isStackMissing(err)) return deepObservation({});
|
|
289
437
|
return deepObservation(
|
|
290
438
|
{},
|
|
291
439
|
unobservedAll(
|
|
292
440
|
options.entityNames,
|
|
293
|
-
classifyFailure(
|
|
294
|
-
`
|
|
441
|
+
classifyFailure(err),
|
|
442
|
+
`DescribeStackResources failed for stack "${stackName}": ${failureDetail(err)}`,
|
|
295
443
|
),
|
|
296
444
|
);
|
|
297
445
|
}
|
|
298
446
|
|
|
299
|
-
|
|
300
|
-
try {
|
|
301
|
-
const parsed = JSON.parse(listResult.stdout) as {
|
|
302
|
-
StackResources?: Array<{ LogicalResourceId: string; ResourceType: string; PhysicalResourceId?: string }>;
|
|
303
|
-
};
|
|
304
|
-
stackResources = parsed.StackResources ?? [];
|
|
305
|
-
} catch {
|
|
306
|
-
return deepObservation(
|
|
307
|
-
{},
|
|
308
|
-
unobservedAll(options.entityNames, "read-failed", `unparseable describe-stack-resources output for stack "${stackName}"`),
|
|
309
|
-
);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
const byLogicalId = new Map(stackResources.map((r) => [r.LogicalResourceId, r]));
|
|
447
|
+
const byLogicalId = new Map(stackResources.map((r) => [r.logicalId, r]));
|
|
313
448
|
const resources: Record<string, DeepResourceObservation> = {};
|
|
314
449
|
const unobserved: Record<string, UnobservedEntity> = {};
|
|
315
450
|
|
|
316
|
-
for
|
|
451
|
+
// Types with a bulk source are read once for the whole stack, before the
|
|
452
|
+
// per-entity pass — one `describe-security-groups` for every group, not one
|
|
453
|
+
// call per group.
|
|
454
|
+
const bulk = await readBulkSources(
|
|
455
|
+
options.entityNames.flatMap((name) => {
|
|
456
|
+
const r = byLogicalId.get(name);
|
|
457
|
+
return r?.physicalId ? [{ type: r.type, id: r.physicalId }] : [];
|
|
458
|
+
}),
|
|
459
|
+
options.region,
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
await boundedConcurrently(options.entityNames, async (entityName) => {
|
|
317
463
|
const stackResource = byLogicalId.get(entityName);
|
|
318
464
|
// Not in the stack at all. The thin read reports that absence; restating it
|
|
319
465
|
// here as a property hole would turn one finding into two.
|
|
320
|
-
if (!stackResource)
|
|
466
|
+
if (!stackResource) return;
|
|
321
467
|
|
|
322
|
-
const type = stackResource.
|
|
323
|
-
|
|
468
|
+
const type = stackResource.type;
|
|
469
|
+
const source = DEEP_SOURCES[type];
|
|
470
|
+
if (!source) {
|
|
324
471
|
unobserved[entityName] = {
|
|
325
472
|
type,
|
|
326
473
|
reason: "unsupported-kind",
|
|
327
|
-
detail: `no deep reader for ${type} —
|
|
474
|
+
detail: `no deep reader for ${type} — coverage is opt-in per type`,
|
|
328
475
|
};
|
|
329
|
-
|
|
476
|
+
return;
|
|
330
477
|
}
|
|
331
|
-
const identifier = stackResource.
|
|
478
|
+
const identifier = stackResource.physicalId;
|
|
332
479
|
if (!identifier) {
|
|
333
480
|
unobserved[entityName] = {
|
|
334
481
|
type,
|
|
335
482
|
reason: "read-failed",
|
|
336
483
|
detail: "the stack reports no physical id, so the live resource cannot be addressed",
|
|
337
484
|
};
|
|
338
|
-
|
|
485
|
+
return;
|
|
339
486
|
}
|
|
340
487
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
type,
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
488
|
+
let properties: Record<string, unknown>;
|
|
489
|
+
if (source.via === "ec2") {
|
|
490
|
+
const answer = bulk.get(type);
|
|
491
|
+
if (!answer) {
|
|
492
|
+
unobserved[entityName] = {
|
|
493
|
+
type,
|
|
494
|
+
reason: "read-failed",
|
|
495
|
+
detail: `${source.argv.join(" ")} failed, so ${type} could not be read deeply`,
|
|
496
|
+
};
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const row = answer.get(identifier);
|
|
500
|
+
// The describe answered, and this id was not in it. That is absence, and
|
|
501
|
+
// the thin read already reports it — same reasoning as a resource missing
|
|
502
|
+
// from the stack.
|
|
503
|
+
if (!row) return;
|
|
504
|
+
properties = source.toModel(row);
|
|
505
|
+
} else {
|
|
506
|
+
let parsed: CloudControlResource | null;
|
|
507
|
+
try {
|
|
508
|
+
parsed = await getResource(type, identifier, client);
|
|
509
|
+
} catch (err) {
|
|
510
|
+
unobserved[entityName] = {
|
|
511
|
+
type,
|
|
512
|
+
reason: classifyFailure(err),
|
|
513
|
+
detail: `GetResource failed for ${type} "${identifier}": ${failureDetail(err)}`,
|
|
514
|
+
};
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
if (!parsed) {
|
|
518
|
+
unobserved[entityName] = {
|
|
519
|
+
type,
|
|
520
|
+
reason: "read-failed",
|
|
521
|
+
detail: `unparseable GetResource response for ${type} "${identifier}"`,
|
|
522
|
+
};
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
properties = parsed.properties;
|
|
366
526
|
}
|
|
367
527
|
|
|
368
|
-
//
|
|
369
|
-
// answer the ownership question (#1015's open note).
|
|
370
|
-
// the filter is `filtered`, never absent: it exists,
|
|
371
|
-
|
|
528
|
+
// Both sources return tags where the service carries them, so unlike the
|
|
529
|
+
// thin path this one can answer the ownership question (#1015's open note).
|
|
530
|
+
// A resource withheld by the filter is `filtered`, never absent: it exists,
|
|
531
|
+
// it just isn't chant's.
|
|
532
|
+
const owned = hasOwnershipMarker(properties);
|
|
372
533
|
if (options.owned && !owned) {
|
|
373
534
|
unobserved[entityName] = {
|
|
374
535
|
type,
|
|
375
536
|
reason: "filtered",
|
|
376
537
|
detail: `live resource carries no ${AWS_TAG_OWNERSHIP_KEYS.managedBy} tag`,
|
|
377
538
|
};
|
|
378
|
-
|
|
539
|
+
return;
|
|
379
540
|
}
|
|
380
541
|
|
|
381
542
|
resources[entityName] = {
|
|
382
543
|
type,
|
|
383
544
|
physicalId: identifier,
|
|
384
|
-
properties: normalizeDeepProperties(
|
|
545
|
+
properties: normalizeDeepProperties(properties, {
|
|
385
546
|
entityType: type,
|
|
386
547
|
side: "live",
|
|
387
548
|
hooks: awsDeepNormalizationHooks,
|
|
388
549
|
}),
|
|
389
550
|
};
|
|
390
|
-
}
|
|
551
|
+
});
|
|
391
552
|
|
|
392
553
|
return deepObservation(resources, unobserved);
|
|
393
554
|
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Read every `ec2`-sourced type in bulk, keyed by physical id.
|
|
558
|
+
*
|
|
559
|
+
* A type whose describe fails is absent from the returned map, which the caller
|
|
560
|
+
* turns into a hole for each of its resources — never into an absence, because
|
|
561
|
+
* a failed read establishes nothing about what exists.
|
|
562
|
+
*
|
|
563
|
+
* These reads still shell the CLI, unlike the Cloud Control path (#1206). The
|
|
564
|
+
* EC2 API speaks the Query protocol with its own lowerCamelCase XML, so going
|
|
565
|
+
* native here is a second wire format rather than a reuse of the existing
|
|
566
|
+
* client, and it belongs with the port of `properties.ts` / `ambient.ts` /
|
|
567
|
+
* `dependencies.ts` — the modules that already read EC2 this way.
|
|
568
|
+
*/
|
|
569
|
+
async function readBulkSources(
|
|
570
|
+
wanted: Array<{ type: string; id: string }>,
|
|
571
|
+
region?: string,
|
|
572
|
+
): Promise<Map<string, Map<string, Record<string, unknown>>>> {
|
|
573
|
+
const byType = new Map<string, string[]>();
|
|
574
|
+
for (const { type, id } of wanted) {
|
|
575
|
+
if (DEEP_SOURCES[type]?.via !== "ec2") continue;
|
|
576
|
+
byType.set(type, [...(byType.get(type) ?? []), id]);
|
|
577
|
+
}
|
|
578
|
+
const out = new Map<string, Map<string, Record<string, unknown>>>();
|
|
579
|
+
if (byType.size === 0) return out;
|
|
580
|
+
|
|
581
|
+
const { getRuntime } = await import("@intentius/chant/runtime-adapter");
|
|
582
|
+
const rt = getRuntime();
|
|
583
|
+
const regionArgs = region ? ["--region", region] : [];
|
|
584
|
+
|
|
585
|
+
for (const [type, ids] of byType) {
|
|
586
|
+
const source = DEEP_SOURCES[type];
|
|
587
|
+
if (source?.via !== "ec2") continue;
|
|
588
|
+
try {
|
|
589
|
+
const result = await rt.spawn(
|
|
590
|
+
applyAwsEndpointArgv(
|
|
591
|
+
["aws", ...source.argv, source.idFlag, ...ids, ...regionArgs, "--output", "json"],
|
|
592
|
+
process.env.AWS_ENDPOINT_URL,
|
|
593
|
+
),
|
|
594
|
+
);
|
|
595
|
+
if (result.exitCode !== 0) continue;
|
|
596
|
+
const rows = (JSON.parse(result.stdout)[source.key] ?? []) as Array<Record<string, unknown>>;
|
|
597
|
+
const byId = new Map<string, Record<string, unknown>>();
|
|
598
|
+
for (const row of rows) {
|
|
599
|
+
const id = row[source.id];
|
|
600
|
+
if (typeof id === "string") byId.set(id, row);
|
|
601
|
+
}
|
|
602
|
+
out.set(type, byId);
|
|
603
|
+
} catch {
|
|
604
|
+
// One type's transport failing is that type's hole, not the whole read's.
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
return out;
|
|
609
|
+
}
|
package/src/dependencies.ts
CHANGED
|
@@ -26,9 +26,9 @@ interface RawIpPermission {
|
|
|
26
26
|
IpProtocol?: string;
|
|
27
27
|
FromPort?: number;
|
|
28
28
|
ToPort?: number;
|
|
29
|
-
IpRanges?: Array<{ CidrIp?: string }>;
|
|
30
|
-
Ipv6Ranges?: Array<{ CidrIpv6?: string }>;
|
|
31
|
-
UserIdGroupPairs?: Array<{ GroupId?: string }>;
|
|
29
|
+
IpRanges?: Array<{ CidrIp?: string; Description?: string }>;
|
|
30
|
+
Ipv6Ranges?: Array<{ CidrIpv6?: string; Description?: string }>;
|
|
31
|
+
UserIdGroupPairs?: Array<{ GroupId?: string; Description?: string }>;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/**
|
|
@@ -52,14 +52,19 @@ export function toIngressRules(permissions: RawIpPermission[]): Array<Record<str
|
|
|
52
52
|
...(permission.FromPort != null ? { FromPort: permission.FromPort } : {}),
|
|
53
53
|
...(permission.ToPort != null ? { ToPort: permission.ToPort } : {}),
|
|
54
54
|
};
|
|
55
|
+
// A source's own `Description` rides with it. The fold ignores it, but the
|
|
56
|
+
// deep reader (#1269) matches rules by their whole value against a template
|
|
57
|
+
// that carries one, and a dropped field there reports every rule twice.
|
|
58
|
+
const described = (source: { Description?: string }) =>
|
|
59
|
+
source.Description ? { Description: source.Description } : {};
|
|
55
60
|
for (const range of permission.IpRanges ?? []) {
|
|
56
|
-
if (range.CidrIp) rules.push({ ...base, CidrIp: range.CidrIp });
|
|
61
|
+
if (range.CidrIp) rules.push({ ...base, CidrIp: range.CidrIp, ...described(range) });
|
|
57
62
|
}
|
|
58
63
|
for (const range of permission.Ipv6Ranges ?? []) {
|
|
59
|
-
if (range.CidrIpv6) rules.push({ ...base, CidrIpv6: range.CidrIpv6 });
|
|
64
|
+
if (range.CidrIpv6) rules.push({ ...base, CidrIpv6: range.CidrIpv6, ...described(range) });
|
|
60
65
|
}
|
|
61
66
|
for (const pair of permission.UserIdGroupPairs ?? []) {
|
|
62
|
-
if (pair.GroupId) rules.push({ ...base, SourceSecurityGroupId: pair.GroupId });
|
|
67
|
+
if (pair.GroupId) rules.push({ ...base, SourceSecurityGroupId: pair.GroupId, ...described(pair) });
|
|
63
68
|
}
|
|
64
69
|
}
|
|
65
70
|
return rules;
|