@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.
@@ -32891,6 +32891,27 @@ export declare class ServiceProfile {
32891
32891
  readonly LoRaWAN_UlRatePolicy: string;
32892
32892
  }
32893
32893
 
32894
+ export declare class ServiceSetting {
32895
+ constructor(props: {
32896
+ /** The ID of the service setting, such as /ssm/parameter-store/high-throughput-enabled. */
32897
+ SettingId: string;
32898
+ /** The value of the service setting. */
32899
+ SettingValue: string;
32900
+ /** The ARN of the service setting. */
32901
+ Arn?: string;
32902
+ /** The last time the service setting was modified. */
32903
+ LastModifiedDate?: string;
32904
+ /** The ARN of the last modified user. */
32905
+ LastModifiedUser?: string;
32906
+ /** The status of the service setting. The value can be Default, Customized or PendingUpdate. */
32907
+ Status?: string;
32908
+ }, attributes?: CFResourceAttributes);
32909
+ readonly Arn: string;
32910
+ readonly LastModifiedDate: string;
32911
+ readonly LastModifiedUser: string;
32912
+ readonly Status: string;
32913
+ }
32914
+
32894
32915
  export declare class ServiceTemplate {
32895
32916
  constructor(props: {
32896
32917
  /** <p>The Amazon Resource Name (ARN) of the service template.</p> */
@@ -1356,6 +1356,7 @@ export const ServiceNetworkServiceAssociation = createResource("AWS::VpcLattice:
1356
1356
  export const ServiceNetworkVpcAssociation = createResource("AWS::VpcLattice::ServiceNetworkVpcAssociation", "aws", {"Arn":"Arn","CreatedAt":"CreatedAt","Id":"Id","ServiceNetworkArn":"ServiceNetworkArn","ServiceNetworkId":"ServiceNetworkId","ServiceNetworkName":"ServiceNetworkName","Status":"Status","VpcId":"VpcId"});
1357
1357
  export const ServicePrincipalName = createResource("AWS::PCAConnectorAD::ServicePrincipalName", "aws", {});
1358
1358
  export const ServiceProfile = createResource("AWS::IoTWireless::ServiceProfile", "aws", {"Id":"Id","Arn":"Arn","LoRaWAN_UlRate":"LoRaWAN.UlRate","LoRaWAN_UlBucketSize":"LoRaWAN.UlBucketSize","LoRaWAN_UlRatePolicy":"LoRaWAN.UlRatePolicy","LoRaWAN_DlRate":"LoRaWAN.DlRate","LoRaWAN_DlBucketSize":"LoRaWAN.DlBucketSize","LoRaWAN_DlRatePolicy":"LoRaWAN.DlRatePolicy","LoRaWAN_DevStatusReqFreq":"LoRaWAN.DevStatusReqFreq","LoRaWAN_ReportDevStatusBattery":"LoRaWAN.ReportDevStatusBattery","LoRaWAN_ReportDevStatusMargin":"LoRaWAN.ReportDevStatusMargin","LoRaWAN_DrMin":"LoRaWAN.DrMin","LoRaWAN_DrMax":"LoRaWAN.DrMax","LoRaWAN_ChannelMask":"LoRaWAN.ChannelMask","LoRaWAN_HrAllowed":"LoRaWAN.HrAllowed","LoRaWAN_NwkGeoLoc":"LoRaWAN.NwkGeoLoc","LoRaWAN_TargetPer":"LoRaWAN.TargetPer","LoRaWAN_MinGwDiversity":"LoRaWAN.MinGwDiversity"});
1359
+ export const ServiceSetting = createResource("AWS::SSM::ServiceSetting", "aws", {"Status":"Status","LastModifiedDate":"LastModifiedDate","LastModifiedUser":"LastModifiedUser","Arn":"Arn"});
1359
1360
  export const ServiceTemplate = createResource("AWS::Proton::ServiceTemplate", "aws", {"Arn":"Arn"});
1360
1361
  export const SESConfigurationSet = createResource("AWS::SES::ConfigurationSet", "aws", {});
1361
1362
  export const SESConfigurationSetEventDestination = createResource("AWS::SES::ConfigurationSetEventDestination", "aws", {"Id":"Id"});
@@ -91208,6 +91208,36 @@
91208
91208
  "kind": "property",
91209
91209
  "lexicon": "aws"
91210
91210
  },
91211
+ "ServiceSetting": {
91212
+ "resourceType": "AWS::SSM::ServiceSetting",
91213
+ "kind": "resource",
91214
+ "lexicon": "aws",
91215
+ "attrs": {
91216
+ "Status": "Status",
91217
+ "LastModifiedDate": "LastModifiedDate",
91218
+ "LastModifiedUser": "LastModifiedUser",
91219
+ "Arn": "Arn"
91220
+ },
91221
+ "propertyConstraints": {
91222
+ "SettingId": {
91223
+ "minLength": 1,
91224
+ "maxLength": 1000
91225
+ },
91226
+ "SettingValue": {
91227
+ "minLength": 1,
91228
+ "maxLength": 4096
91229
+ },
91230
+ "LastModifiedDate": {
91231
+ "format": "date-time"
91232
+ }
91233
+ },
91234
+ "createOnly": [
91235
+ "SettingId"
91236
+ ],
91237
+ "primaryIdentifier": [
91238
+ "Arn"
91239
+ ]
91240
+ },
91211
91241
  "ServiceSoftwareOptions": {
91212
91242
  "resourceType": "AWS::OpenSearchService::Domain.ServiceSoftwareOptions",
91213
91243
  "kind": "property",
package/src/index.ts CHANGED
@@ -30,13 +30,30 @@ export { awsPlugin } from "./plugin";
30
30
  export {
31
31
  observeResourcesDeepAws,
32
32
  awsDeepNormalizationHooks,
33
- parseCloudControlResource,
34
33
  hasOwnershipMarker,
35
34
  DEEP_READABLE_TYPES,
36
35
  AWS_READ_ONLY_NAMES,
37
36
  AWS_SERVICE_DEFAULTS,
38
37
  } from "./deep-observe";
39
38
 
39
+ // The native read transport (#1206) — the applier's own transport, pointed at
40
+ // CloudFormation Query and Cloud Control. `parseCloudControlResource` used to
41
+ // live in ./deep-observe and parsed AWS CLI stdout; the reader no longer speaks
42
+ // that wire format, so `parseResourceDescription` (which takes the API's own
43
+ // `ResourceDescription` object) replaces it.
44
+ export {
45
+ describeStackResources,
46
+ describeStackOutputs,
47
+ getResource,
48
+ listResources,
49
+ parseResourceDescription,
50
+ AwsReadError,
51
+ type AwsReadHttp,
52
+ type AwsReadClientOptions,
53
+ type CloudControlDescription,
54
+ type StackResource,
55
+ } from "./api/read-client";
56
+
40
57
  // Intrinsics
41
58
  export {
42
59
  Sub,
@@ -6,7 +6,7 @@
6
6
  * cloud edge (the runtime adapter's spawn) mocked. Proves the seam between core
7
7
  * and a real lexicon — not a `createMockPlugin` fixture.
8
8
  */
9
- import { describe, test, expect, vi, beforeEach } from "vitest";
9
+ import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
10
10
  import { mkdtempSync, rmSync, readdirSync, readFileSync } from "node:fs";
11
11
  import { tmpdir } from "node:os";
12
12
  import { join } from "node:path";
@@ -40,8 +40,47 @@ const liveTemplate = {
40
40
 
41
41
  const ok = (stdout: string) => ({ stdout, stderr: "", exitCode: 0 });
42
42
 
43
+ /* The stack reads moved off the CLI onto the CloudFormation Query protocol
44
+ * (#1206), so they are stubbed at `fetch` rather than at `spawn`. The paths
45
+ * still on the CLI — `describeStackStatus`, `exportResources`, and the
46
+ * per-kind property reads — keep using `spawnMock` below. */
47
+
48
+ const stackResourcesXml = (rows: Array<{ logicalId: string; type: string; physicalId: string }>) =>
49
+ `<DescribeStackResourcesResponse><StackResources>${rows
50
+ .map(
51
+ (r) =>
52
+ `<member><LogicalResourceId>${r.logicalId}</LogicalResourceId><ResourceType>${r.type}</ResourceType>` +
53
+ `<PhysicalResourceId>${r.physicalId}</PhysicalResourceId><ResourceStatus>CREATE_COMPLETE</ResourceStatus>` +
54
+ `<Timestamp>2026-01-01T00:00:00Z</Timestamp></member>`,
55
+ )
56
+ .join("")}</StackResources></DescribeStackResourcesResponse>`;
57
+
58
+ const stackOutputsXml = (outputs: Record<string, string> = {}) =>
59
+ `<DescribeStacksResponse><Outputs>${Object.entries(outputs)
60
+ .map(([k, v]) => `<member><OutputKey>${k}</OutputKey><OutputValue>${v}</OutputValue></member>`)
61
+ .join("")}</Outputs></DescribeStacksResponse>`;
62
+
63
+ const queryErrorXml = (code: string, message: string) =>
64
+ `<ErrorResponse><Error><Code>${code}</Code><Message>${message}</Message></Error></ErrorResponse>`;
65
+
66
+ /** Route a CloudFormation Query call by its `Action`, and answer as CFN would. */
67
+ const stubCfn = (route: (action: string) => { status?: number; text: string }): void => {
68
+ vi.spyOn(globalThis, "fetch").mockImplementation((async (_url: string, init: { body: string }) => {
69
+ const action = new URLSearchParams(init.body).get("Action") ?? "";
70
+ const res = route(action);
71
+ return { status: res.status ?? 200, text: () => Promise.resolve(res.text) };
72
+ }) as unknown as typeof fetch);
73
+ };
74
+
75
+ /** The whole-stack refusal both the credentials and the missing-stack cases use. */
76
+ const stubCfnError = (code: string, message: string): void =>
77
+ stubCfn(() => ({ status: 400, text: queryErrorXml(code, message) }));
78
+
43
79
  describe("aws lifecycle integration (#163)", () => {
44
80
  beforeEach(() => spawnMock.mockReset());
81
+ // A `fetch` stub left standing would serve the next test's stack read, which
82
+ // is how a credentials case can come back looking healthy.
83
+ afterEach(() => vi.restoreAllMocks());
45
84
 
46
85
  test("live-import driver: real exportResources → IR → generated source", async () => {
47
86
  spawnMock.mockResolvedValue(ok(JSON.stringify({ TemplateBody: liveTemplate })));
@@ -65,27 +104,11 @@ describe("aws lifecycle integration (#163)", () => {
65
104
  });
66
105
 
67
106
  test("changeset path: real describeResources → buildChangeSet verdicts", async () => {
68
- // describe-stack-resources then describe-stacks.
69
- spawnMock.mockImplementation((argv?: string[]) => {
70
- if (argv?.includes("describe-stack-resources")) {
71
- return Promise.resolve(
72
- ok(
73
- JSON.stringify({
74
- StackResources: [
75
- {
76
- LogicalResourceId: "MyBucket",
77
- ResourceType: "AWS::S3::Bucket",
78
- PhysicalResourceId: "my-bucket",
79
- ResourceStatus: "CREATE_COMPLETE",
80
- Timestamp: "2026-01-01T00:00:00Z",
81
- },
82
- ],
83
- }),
84
- ),
85
- );
86
- }
87
- return Promise.resolve(ok(JSON.stringify({ Stacks: [{ Outputs: [] }] })));
88
- });
107
+ stubCfn((action) =>
108
+ action === "DescribeStackResources"
109
+ ? { text: stackResourcesXml([{ logicalId: "MyBucket", type: "AWS::S3::Bucket", physicalId: "my-bucket" }]) }
110
+ : { text: stackOutputsXml() },
111
+ );
89
112
 
90
113
  const { resources: observedNow } = normalizeObservation(
91
114
  await awsPlugin.describeResources!({
@@ -96,7 +119,7 @@ describe("aws lifecycle integration (#163)", () => {
96
119
  }),
97
120
  );
98
121
  expect(observedNow.MyBucket?.type).toBe("AWS::S3::Bucket");
99
- // Ownership verdicts are total (#1089): describe-stack-resources carries no
122
+ // Ownership verdicts are total (#1089): DescribeStackResources carries no
100
123
  // tags, so the verdict is an explicit `unknown`, not a missing field.
101
124
  expect(observedNow.MyBucket?.ownership).toBe("unknown");
102
125
 
@@ -160,7 +183,7 @@ describe("aws lifecycle integration (#163)", () => {
160
183
  * NOT-OBSERVED, and that survives describe → plan → component status.
161
184
  */
162
185
  test("tri-state chain: a failed stack read stays unobserved through describe → plan → status (#1089)", async () => {
163
- spawnMock.mockResolvedValue({ stdout: "", stderr: "Unable to locate credentials", exitCode: 255 });
186
+ stubCfnError("AccessDenied", "Unable to locate credentials");
164
187
 
165
188
  const observed = normalizeObservation(
166
189
  await awsPlugin.describeResources!({
@@ -190,7 +213,7 @@ describe("aws lifecycle integration (#163)", () => {
190
213
  });
191
214
 
192
215
  test("a stack that does not exist is a real absence — every declared entity is a create", async () => {
193
- spawnMock.mockResolvedValue({ stdout: "", stderr: "ValidationError: Stack with id prod does not exist", exitCode: 255 });
216
+ stubCfnError("ValidationError", "Stack with id prod does not exist");
194
217
 
195
218
  const observed = normalizeObservation(
196
219
  await awsPlugin.describeResources!({
@@ -222,7 +245,7 @@ describeObservationConformance({
222
245
  declared: ["MyBucket", "MyQueue"],
223
246
  expectUnobserved: ["MyBucket", "MyQueue"],
224
247
  run: () => {
225
- spawnMock.mockResolvedValue({ stdout: "", stderr: "Unable to locate credentials", exitCode: 255 });
248
+ stubCfnError("AccessDenied", "Unable to locate credentials");
226
249
  return awsPlugin.describeResources!({
227
250
  environment: "prod",
228
251
  buildOutput: "",
@@ -236,7 +259,7 @@ describeObservationConformance({
236
259
  declared: ["MyBucket"],
237
260
  expectAbsent: ["MyBucket"],
238
261
  run: () => {
239
- spawnMock.mockResolvedValue({ stdout: "", stderr: "ValidationError: Stack with id prod does not exist", exitCode: 255 });
262
+ stubCfnError("ValidationError", "Stack with id prod does not exist");
240
263
  return awsPlugin.describeResources!({
241
264
  environment: "prod",
242
265
  buildOutput: "",
@@ -250,24 +273,10 @@ describeObservationConformance({
250
273
  declared: ["MyBucket"],
251
274
  expectPresent: ["MyBucket"],
252
275
  run: () => {
253
- spawnMock.mockImplementation((argv?: string[]) =>
254
- Promise.resolve(
255
- argv?.includes("describe-stack-resources")
256
- ? ok(
257
- JSON.stringify({
258
- StackResources: [
259
- {
260
- LogicalResourceId: "MyBucket",
261
- ResourceType: "AWS::S3::Bucket",
262
- PhysicalResourceId: "my-bucket",
263
- ResourceStatus: "CREATE_COMPLETE",
264
- Timestamp: "2026-01-01T00:00:00Z",
265
- },
266
- ],
267
- }),
268
- )
269
- : ok(JSON.stringify({ Stacks: [{ Outputs: [] }] })),
270
- ),
276
+ stubCfn((action) =>
277
+ action === "DescribeStackResources"
278
+ ? { text: stackResourcesXml([{ logicalId: "MyBucket", type: "AWS::S3::Bucket", physicalId: "my-bucket" }]) }
279
+ : { text: stackOutputsXml() },
271
280
  );
272
281
  return awsPlugin.describeResources!({
273
282
  environment: "prod",
package/src/plugin.ts CHANGED
@@ -17,10 +17,17 @@ import { awsSerializer } from "./serializer";
17
17
  import { FLOCI_EMULATOR } from "./op/activities/floci";
18
18
  import { applyAwsEndpointArgv } from "./components/cloud-executor";
19
19
  import { stackDoesNotExist } from "./stack-errors";
20
+ import {
21
+ AwsReadError,
22
+ describeStackOutputs,
23
+ describeStackResources,
24
+ type AwsReadClientOptions,
25
+ type StackResource,
26
+ } from "./api/read-client";
20
27
  import { awsDeepNormalizationHooks, observeResourcesDeepAws } from "./deep-observe";
21
28
  import { awsReferenceCatalog } from "./reference-catalog";
22
29
  import { AMBIENT_KINDS } from "./ambient";
23
- import { describeOwnProperties, stampRegion } from "./properties";
30
+ import { canDescribe, describeOwnProperties, stampRegion } from "./properties";
24
31
  import { stampProviderDefaults } from "./defaults";
25
32
  import { resolveTemplateAttrs } from "./live-attrs";
26
33
  import { CFParser } from "./import/parser";
@@ -541,12 +548,14 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
541
548
  region?: string;
542
549
  owned?: boolean;
543
550
  }): Promise<ObservationResult> {
544
- const { getRuntime } = await import("@intentius/chant/runtime-adapter");
545
551
  const { observation, unobservedAll } = await import("@intentius/chant/observation");
546
- const rt = getRuntime();
547
552
  const resources: Record<string, ResourceMetadata> = {};
548
- // Multi-region estates: target this stack's region, not the ambient one.
549
- const regionArgs = options.region ? ["--region", options.region] : [];
553
+ // The applier's own transport, pointed at the read APIs (#1206). Multi-region
554
+ // estates target this stack's region, not the ambient one.
555
+ const client: AwsReadClientOptions = {
556
+ ...(process.env.AWS_ENDPOINT_URL ? { endpoint: process.env.AWS_ENDPOINT_URL } : {}),
557
+ ...(options.region ? { region: options.region } : {}),
558
+ };
550
559
 
551
560
  if (options.owned) {
552
561
  // describe-stack-resources does not return tags, so ownership cannot be
@@ -569,29 +578,26 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
569
578
  // single-stack convention is the stack named after the environment (#932).
570
579
  const stackName = options.stack ?? `${options.environment}`;
571
580
 
572
- // Describe stack resources. Inject --endpoint-url from AWS_ENDPOINT_URL so a
581
+ // Describe stack resources. The endpoint override rides the client, so a
573
582
  // local emulator (Floci) is observed instead of real AWS (#926) — behold
574
583
  // serve --local relies on this for the overlay.
575
- const listResult = await rt.spawn(applyAwsEndpointArgv([
576
- "aws", "cloudformation", "describe-stack-resources",
577
- "--stack-name", stackName,
578
- ...regionArgs,
579
- "--output", "json",
580
- ], process.env.AWS_ENDPOINT_URL));
581
-
582
- if (listResult.exitCode !== 0) {
584
+ let stackResources: StackResource[];
585
+ try {
586
+ stackResources = await describeStackResources(stackName, client);
587
+ } catch (err) {
583
588
  // A stack that doesn't exist yet is the pre-first-apply state: nothing is
584
589
  // deployed for this env, so there are no live resources (every declared
585
590
  // resource is "pending") — not an error. That is a real absence, so the
586
591
  // empty result is the honest one and `create` is the right proposal.
587
- if (stackDoesNotExist(listResult.stderr)) {
592
+ if (err instanceof AwsReadError && stackDoesNotExist(err.message)) {
588
593
  return observation(resources);
589
594
  }
590
595
  // Any other failure (credentials, throttling, a region that can't be
591
596
  // reached) establishes nothing about what is deployed. Reporting every
592
597
  // declared entity as NOT-OBSERVED (#1089) is what keeps a broken read
593
598
  // from arriving downstream as "none of this exists".
594
- const reason = /credential|token|expired|AccessDenied|not authorized|UnauthorizedOperation/i.test(listResult.stderr)
599
+ const detail = err instanceof AwsReadError && err.code ? `${err.code}: ${err.message}` : String(err instanceof Error ? err.message : err);
600
+ const reason = /credential|token|expired|AccessDenied|not authorized|Unauthorized/i.test(detail)
595
601
  ? "no-credentials"
596
602
  : "read-failed";
597
603
  return observation(
@@ -599,45 +605,22 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
599
605
  unobservedAll(
600
606
  options.entityNames,
601
607
  reason,
602
- `describe-stack-resources failed for stack "${stackName}": ${listResult.stderr.trim().split("\n")[0] ?? ""}`,
608
+ `DescribeStackResources failed for stack "${stackName}": ${detail}`,
603
609
  ),
604
610
  );
605
611
  }
606
612
 
607
- const data = JSON.parse(listResult.stdout) as {
608
- StackResources: Array<{
609
- LogicalResourceId: string;
610
- ResourceType: string;
611
- PhysicalResourceId: string;
612
- ResourceStatus: string;
613
- Timestamp: string;
614
- }>;
615
- };
616
-
617
613
  // Map logical names from build to stack resources
618
- const stackResourceMap = new Map<string, typeof data.StackResources[0]>();
619
- for (const r of data.StackResources) {
620
- stackResourceMap.set(r.LogicalResourceId, r);
621
- }
622
-
623
- // Get stack outputs
624
- const describeResult = await rt.spawn(applyAwsEndpointArgv([
625
- "aws", "cloudformation", "describe-stacks",
626
- "--stack-name", stackName,
627
- ...regionArgs,
628
- "--output", "json",
629
- ], process.env.AWS_ENDPOINT_URL));
614
+ const stackResourceMap = new Map(stackResources.map((r) => [r.logicalId, r]));
630
615
 
616
+ // Get stack outputs. A stack whose resources read fine but whose outputs do
617
+ // not is still a usable observation, so this failure is swallowed exactly as
618
+ // the non-zero exit code used to be.
631
619
  let stackOutputs: Record<string, string> = {};
632
- if (describeResult.exitCode === 0) {
633
- const stacks = JSON.parse(describeResult.stdout) as {
634
- Stacks: Array<{ Outputs?: Array<{ OutputKey: string; OutputValue: string }> }>;
635
- };
636
- if (stacks.Stacks[0]?.Outputs) {
637
- for (const o of stacks.Stacks[0].Outputs) {
638
- stackOutputs[o.OutputKey] = o.OutputValue;
639
- }
640
- }
620
+ try {
621
+ stackOutputs = await describeStackOutputs(stackName, client);
622
+ } catch {
623
+ stackOutputs = {};
641
624
  }
642
625
 
643
626
  for (const entityName of options.entityNames) {
@@ -655,10 +638,10 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
655
638
  }
656
639
 
657
640
  resources[entityName] = {
658
- type: stackResource.ResourceType,
659
- physicalId: stackResource.PhysicalResourceId,
660
- status: stackResource.ResourceStatus,
661
- lastUpdated: stackResource.Timestamp,
641
+ type: stackResource.type,
642
+ physicalId: stackResource.physicalId ?? "",
643
+ status: stackResource.status ?? "",
644
+ lastUpdated: stackResource.timestamp ?? "",
662
645
  // Total verdict (#1089): describe-stack-resources returns no tags, so
663
646
  // this path cannot read the ownership marker. Say `unknown` explicitly
664
647
  // rather than leaving the field off and letting each consumer guess —
@@ -671,9 +654,37 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
671
654
  // Each resource's OWN properties, on top of the stack outputs above (#1279).
672
655
  // Until this, a node's `attrs` were the stack's exports replicated onto
673
656
  // every member, so no instance carried its own `VpcId`.
674
- const withProperties = stampProviderDefaults(
675
- stampRegion(await describeOwnProperties(resources, options.region), options.region),
676
- );
657
+ const own = await describeOwnProperties(resources, options.region);
658
+ const withProperties = stampProviderDefaults(stampRegion(own.resources, options.region));
659
+
660
+ // The own-property read is still a CLI shell-out while the stack reads are
661
+ // not (#1206), so the two halves can now fail independently. When the
662
+ // enrichment could not run at all, the resources it would have described are
663
+ // identified but not described — and `lifecycle diff` compares these
664
+ // attributes, so presenting them anyway reports every previously-recorded
665
+ // property as removed. That is a failed read arriving as drift, which is the
666
+ // shape #1089 exists to prevent, so say it is a hole instead.
667
+ const undescribed = own.transportFailed
668
+ ? Object.keys(withProperties).filter((name) => canDescribe(withProperties[name].type))
669
+ : [];
670
+ if (undescribed.length > 0) {
671
+ const described: Record<string, ResourceMetadata> = {};
672
+ for (const [name, meta] of Object.entries(withProperties)) {
673
+ if (!undescribed.includes(name)) described[name] = meta;
674
+ }
675
+ // Attribute each hole to the call that failed for *its* kind, rather than
676
+ // to whichever call happened to fail first.
677
+ const holes: Record<string, { type: string; reason: "read-failed"; detail: string }> = {};
678
+ for (const name of undescribed) {
679
+ const type = withProperties[name].type;
680
+ holes[name] = {
681
+ type,
682
+ reason: "read-failed",
683
+ detail: `the stack was read, but this resource's own properties were not — ${own.failures.get(type) ?? "the describe call failed"}`,
684
+ };
685
+ }
686
+ return observation(described, holes);
687
+ }
677
688
 
678
689
  // Every entity the stack answered for was answered for: an entity the
679
690
  // template doesn't carry is genuinely not in this stack, which is an
@@ -29,7 +29,7 @@ describe("describeOwnProperties (#1279)", () => {
29
29
 
30
30
  it("joins each resource's own properties back by physical id", async () => {
31
31
  spawnMock.mockResolvedValue(ok(reservations(instance("i-1", "vpc-a"), instance("i-2", "vpc-b"))));
32
- const merged = await describeOwnProperties(observed);
32
+ const { resources: merged } = await describeOwnProperties(observed);
33
33
  expect(merged.web.attributes?.VpcId).toBe("vpc-a");
34
34
  expect(merged.api.attributes?.VpcId).toBe("vpc-b");
35
35
  });
@@ -50,7 +50,7 @@ describe("describeOwnProperties (#1279)", () => {
50
50
  },
51
51
  };
52
52
  spawnMock.mockResolvedValue(ok(reservations(instance("i-1", "vpc-a"))));
53
- const merged = await describeOwnProperties(withOutputs);
53
+ const { resources: merged } = await describeOwnProperties(withOutputs);
54
54
  expect(merged.web.attributes?.expVpcId).toBe("vpc-exported");
55
55
  expect(merged.web.attributes?.VpcId).toBe("vpc-a");
56
56
  });
@@ -64,19 +64,52 @@ describe("describeOwnProperties (#1279)", () => {
64
64
  .mockResolvedValueOnce(fail) // the batch, killed by i-2
65
65
  .mockResolvedValueOnce(ok(reservations(instance("i-1", "vpc-a"))))
66
66
  .mockResolvedValueOnce(fail); // i-2 really is gone
67
- const merged = await describeOwnProperties(observed);
67
+ const { resources: merged } = await describeOwnProperties(observed);
68
68
  expect(merged.web.attributes?.VpcId).toBe("vpc-a");
69
69
  expect(merged.api.attributes).toBeUndefined();
70
70
  });
71
71
 
72
72
  it("leaves the observation untouched when the kind cannot be read at all", async () => {
73
73
  spawnMock.mockResolvedValue(fail);
74
- const merged = await describeOwnProperties({ web: observed.web });
74
+ const { resources: merged } = await describeOwnProperties({ web: observed.web });
75
75
  expect(merged.web.attributes).toBeUndefined();
76
76
  });
77
77
 
78
+ // #1206: these attributes are compared by `lifecycle diff`, so a read that
79
+ // fails after the resource is already identified reports every recorded
80
+ // property as removed. The caller can only avoid that if the failure is told.
81
+ it("reports a total failure, so a caller can call it a hole rather than thin data", async () => {
82
+ spawnMock.mockResolvedValue({ stdout: "", stderr: "spawn aws ENOENT", exitCode: 127 });
83
+ const result = await describeOwnProperties({ web: observed.web });
84
+ expect(result.transportFailed).toBe(true);
85
+ expect(result.failures.get("AWS::EC2::Instance")).toContain("ec2 describe-instances");
86
+ expect(result.failures.get("AWS::EC2::Instance")).toContain("ENOENT");
87
+ });
88
+
89
+ it("a per-id retry that fails for every id is a failed kind, not an empty one", async () => {
90
+ // The batch dies, then so does every retry. Before #1206 the retry path
91
+ // replaced the null with `[]`, which read as "the account has nothing".
92
+ spawnMock.mockResolvedValue(fail);
93
+ const result = await describeOwnProperties(observed);
94
+ expect(result.transportFailed).toBe(true);
95
+ });
96
+
97
+ it("a kind that answers keeps the pass best-effort, even when another fails", async () => {
98
+ // One kind readable, one not: still detail-loss, not a hole. Only a total
99
+ // failure changes the caller's verdict.
100
+ spawnMock.mockImplementation((argv?: string[]) =>
101
+ Promise.resolve(argv?.includes("describe-instances") ? ok(reservations(instance("i-1", "vpc-a"))) : fail),
102
+ );
103
+ const result = await describeOwnProperties({
104
+ web: observed.web,
105
+ net: { type: "AWS::EC2::Subnet", status: "OK", physicalId: "subnet-1" },
106
+ });
107
+ expect(result.transportFailed).toBe(false);
108
+ expect(result.resources.web.attributes?.VpcId).toBe("vpc-a");
109
+ });
110
+
78
111
  it("does not call out for a kind it cannot describe", async () => {
79
- const merged = await describeOwnProperties({
112
+ const { resources: merged } = await describeOwnProperties({
80
113
  fn: { type: "AWS::Lambda::Function", status: "OK", physicalId: "fn-1" },
81
114
  });
82
115
  expect(spawnMock).not.toHaveBeenCalled();
package/src/properties.ts CHANGED
@@ -86,20 +86,37 @@ export function canDescribe(kind: string): boolean {
86
86
  return kind in DESCRIBE;
87
87
  }
88
88
 
89
+ /** What an enrichment pass managed to do, so a caller can tell a miss from a no-op. */
90
+ export interface OwnPropertiesResult {
91
+ resources: Record<string, ResourceMetadata>;
92
+ /**
93
+ * True when enrichment was attempted and *every* kind's read failed — the
94
+ * transport is unavailable rather than the account being quiet. Callers must
95
+ * not present the result as a complete observation (#1089).
96
+ */
97
+ transportFailed: boolean;
98
+ /** Why each failed kind failed, keyed by CloudFormation type, so a caller can attribute per resource. */
99
+ failures: Map<string, string>;
100
+ }
101
+
89
102
  /**
90
103
  * Merge each resource's own properties into an observation, in place of nothing.
91
104
  *
92
- * Best-effort per kind, and per kind only: an endpoint that cannot answer
93
- * `describe-subnets` still yields instance properties, and a total failure
94
- * leaves the observation exactly as it arrived. The managed observation is
95
- * already complete without any of this — these are additional facts about
96
- * resources chant has already identified, so a miss costs detail, never
97
- * correctness.
105
+ * Best-effort per kind: an endpoint that cannot answer `describe-subnets` still
106
+ * yields instance properties. A miss costs detail — with one exception that used
107
+ * to be silent and is now reported.
108
+ *
109
+ * The exception (#1206): these attributes are compared by `lifecycle diff`, so a
110
+ * read that fails after the resource is already identified does not degrade to
111
+ * "less detail", it degrades to *drift* — the snapshot has `GroupId`, the live
112
+ * read has nothing, and the differ reports the property as removed. A partial
113
+ * failure still rides the best-effort path, but a total one is a hole the caller
114
+ * has to declare rather than a thinner set of facts.
98
115
  */
99
116
  export async function describeOwnProperties(
100
117
  resources: Record<string, ResourceMetadata>,
101
118
  region?: string,
102
- ): Promise<Record<string, ResourceMetadata>> {
119
+ ): Promise<OwnPropertiesResult> {
103
120
  // Group the physical ids to look up by kind, so each kind is one call.
104
121
  const wanted = new Map<string, Map<string, string[]>>();
105
122
  for (const [name, meta] of Object.entries(resources)) {
@@ -108,22 +125,25 @@ export async function describeOwnProperties(
108
125
  byId.set(meta.physicalId, [...(byId.get(meta.physicalId) ?? []), name]);
109
126
  wanted.set(meta.type, byId);
110
127
  }
111
- if (wanted.size === 0) return resources;
128
+ if (wanted.size === 0) return { resources, transportFailed: false, failures: new Map() };
112
129
 
113
130
  const { getRuntime } = await import("@intentius/chant/runtime-adapter");
114
131
  const rt = getRuntime();
115
132
  const regionArgs = region ? ["--region", region] : [];
116
133
  const merged = { ...resources };
134
+ const failures = new Map<string, string>();
135
+ /** The last failure for a kind, phrased for a human — stderr when there is any, the exit status otherwise. */
136
+ let lastReason = "";
117
137
 
118
138
  /** One describe for a set of ids. `null` when the call itself failed. */
119
139
  const read = async (spec: (typeof DESCRIBE)[string], ids: string[]) => {
120
- const result = await rt.spawn(
121
- applyAwsEndpointArgv(
122
- ["aws", ...spec.argv, spec.idFlag, ...ids, ...regionArgs, "--output", "json"],
123
- process.env.AWS_ENDPOINT_URL,
124
- ),
125
- );
126
- if (result.exitCode !== 0) return null;
140
+ const argv = ["aws", ...spec.argv, spec.idFlag, ...ids, ...regionArgs, "--output", "json"];
141
+ const result = await rt.spawn(applyAwsEndpointArgv(argv, process.env.AWS_ENDPOINT_URL));
142
+ if (result.exitCode !== 0) {
143
+ const stderr = result.stderr.trim().split("\n")[0];
144
+ lastReason = `${spec.argv.join(" ")} failed${stderr ? `: ${stderr}` : ` (${result.exitCode})`}`;
145
+ return null;
146
+ }
127
147
  return (JSON.parse(result.stdout)[spec.key] ?? []) as Array<Record<string, unknown>>;
128
148
  };
129
149
 
@@ -137,10 +157,15 @@ export async function describeOwnProperties(
137
157
  // down with it, and the result is indistinguishable from "the account has
138
158
  // nothing to say". Retry one at a time so the damage stops at the bad id.
139
159
  if (top === null && ids.length > 1) {
140
- top = [];
141
- for (const id of ids) top.push(...((await read(spec, [id])) ?? []));
160
+ const perId = await Promise.all(ids.map((id) => read(spec, [id])));
161
+ // Every retry failing is the kind failing, not the kind being empty —
162
+ // the distinction the old `top = []` erased.
163
+ top = perId.every((r) => r === null) ? null : perId.flatMap((r) => r ?? []);
164
+ }
165
+ if (top === null) {
166
+ failures.set(kind, lastReason);
167
+ continue;
142
168
  }
143
- if (top === null) continue;
144
169
  // `describe-instances` buries instances one level down under reservations;
145
170
  // the others return the resources directly.
146
171
  const rows = spec.nested
@@ -158,9 +183,10 @@ export async function describeOwnProperties(
158
183
  };
159
184
  }
160
185
  }
161
- } catch {
162
- continue;
186
+ } catch (err) {
187
+ failures.set(kind, `${spec.argv.join(" ")} failed: ${err instanceof Error ? err.message : String(err)}`);
163
188
  }
164
189
  }
165
- return merged;
190
+
191
+ return { resources: merged, transportFailed: failures.size === wanted.size, failures };
166
192
  }