@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.
@@ -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
- * ## Scope of the first cut
19
+ * ## One reader, several sources (#1269)
20
20
  *
21
- * Four high-signal types (S3 buckets, IAM roles and managed policies, EC2
22
- * security groups) rather than all 30+. The point of the first row is to prove
23
- * the contract and the noise rules; widening the type table is additive and
24
- * needs no contract change. A declared resource of any other type reports
25
- * NOT-OBSERVED with `unsupported-kind` it may well exist, and saying nothing
26
- * about it is the only honest answer.
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 runtime adapter's `spawn` and
31
- * `applyAwsEndpointArgv`, so `AWS_ENDPOINT_URL` redirects the whole reader at a
32
- * local emulator exactly as the existing describe path does.
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 { applyAwsEndpointArgv } from "./components/cloud-executor";
45
- import { stackDoesNotExist } from "./stack-errors";
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
- * CloudFormation types this reader can read live. Each is addressable in Cloud
50
- * Control by the physical id CloudFormation already reports.
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 const DEEP_READABLE_TYPES: ReadonlySet<string> = new Set([
53
- "AWS::S3::Bucket",
54
- "AWS::IAM::Role",
55
- "AWS::IAM::ManagedPolicy",
56
- "AWS::EC2::SecurityGroup",
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" || name === "IpRanges") {
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 `cloudcontrol get-resource` returns it. Exported for tests. */
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
- * Parse a `cloudcontrol get-resource` payload. Cloud Control returns the model
201
- * as a JSON *string* inside the envelope, so this unwraps twice. Returns null
202
- * for anything that does not parse to an object — an unparseable body is a
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
- export function parseCloudControlResource(stdout: string): CloudControlResource | null {
206
- let envelope: unknown;
207
- try {
208
- envelope = JSON.parse(stdout);
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: `describe-stack-resources`
261
- * resolves logical id → (type, physical id), then `cloudcontrol get-resource`
262
- * fetches each model. The first read's failure modes are the thin path's,
263
- * verbatim — a stack that does not exist yet is a real absence (nothing is
264
- * deployed, so there are no properties to drift), anything else is a hole for
265
- * every declared entity.
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 endpoint = process.env.AWS_ENDPOINT_URL;
277
-
278
- const regionArgs = options.region ? ["--region", options.region] : [];
279
-
280
- const listResult = await rt.spawn(applyAwsEndpointArgv([
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
- if (listResult.exitCode !== 0) {
288
- if (stackDoesNotExist(listResult.stderr)) return deepObservation({});
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(listResult.stderr),
294
- `describe-stack-resources failed for stack "${stackName}": ${listResult.stderr.trim().split("\n")[0] ?? ""}`,
441
+ classifyFailure(err),
442
+ `DescribeStackResources failed for stack "${stackName}": ${failureDetail(err)}`,
295
443
  ),
296
444
  );
297
445
  }
298
446
 
299
- let stackResources: Array<{ LogicalResourceId: string; ResourceType: string; PhysicalResourceId?: string }> = [];
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 (const entityName of options.entityNames) {
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) continue;
466
+ if (!stackResource) return;
321
467
 
322
- const type = stackResource.ResourceType;
323
- if (!DEEP_READABLE_TYPES.has(type)) {
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} — Cloud Control coverage is opt-in per type`,
474
+ detail: `no deep reader for ${type} — coverage is opt-in per type`,
328
475
  };
329
- continue;
476
+ return;
330
477
  }
331
- const identifier = stackResource.PhysicalResourceId;
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
- continue;
485
+ return;
339
486
  }
340
487
 
341
- const getResult = await rt.spawn(applyAwsEndpointArgv([
342
- "aws", "cloudcontrol", "get-resource",
343
- "--type-name", type,
344
- "--identifier", identifier,
345
- ...regionArgs,
346
- "--output", "json",
347
- ], endpoint));
348
-
349
- if (getResult.exitCode !== 0) {
350
- unobserved[entityName] = {
351
- type,
352
- reason: classifyFailure(getResult.stderr),
353
- detail: `cloudcontrol get-resource failed for ${type} "${identifier}": ${getResult.stderr.trim().split("\n")[0] ?? ""}`,
354
- };
355
- continue;
356
- }
357
-
358
- const parsed = parseCloudControlResource(getResult.stdout);
359
- if (!parsed) {
360
- unobserved[entityName] = {
361
- type,
362
- reason: "read-failed",
363
- detail: `unparseable cloudcontrol get-resource output for ${type} "${identifier}"`,
364
- };
365
- continue;
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
- // Cloud Control *does* return tags, so unlike the thin path this one can
369
- // answer the ownership question (#1015's open note). A resource withheld by
370
- // the filter is `filtered`, never absent: it exists, it just isn't chant's.
371
- const owned = hasOwnershipMarker(parsed.properties);
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
- continue;
539
+ return;
379
540
  }
380
541
 
381
542
  resources[entityName] = {
382
543
  type,
383
544
  physicalId: identifier,
384
- properties: normalizeDeepProperties(parsed.properties, {
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
+ }
@@ -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;