@intentius/chant-lexicon-aws 0.44.13 → 0.45.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.
Files changed (52) hide show
  1. package/dist/agentcore/trace-fetch.d.ts +4 -1
  2. package/dist/agentcore/trace-fetch.d.ts.map +1 -1
  3. package/dist/api/read-client.d.ts +31 -1
  4. package/dist/api/read-client.d.ts.map +1 -1
  5. package/dist/codegen/docs.d.ts.map +1 -1
  6. package/dist/components/capability-plugin.d.ts.map +1 -1
  7. package/dist/components/cloud-executor.d.ts +9 -0
  8. package/dist/components/cloud-executor.d.ts.map +1 -1
  9. package/dist/composites/agentcore-agent.d.ts +33 -19
  10. package/dist/composites/agentcore-agent.d.ts.map +1 -1
  11. package/dist/composites/index.d.ts +1 -1
  12. package/dist/composites/index.d.ts.map +1 -1
  13. package/dist/deep-observe.d.ts +21 -0
  14. package/dist/deep-observe.d.ts.map +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/integrity.json +2 -2
  18. package/dist/manifest.json +1 -1
  19. package/dist/op/activities/aws-apply.d.ts +14 -4
  20. package/dist/op/activities/aws-apply.d.ts.map +1 -1
  21. package/dist/plugin.d.ts.map +1 -1
  22. package/dist/properties.d.ts +4 -3
  23. package/dist/properties.d.ts.map +1 -1
  24. package/dist/spec/fetch.d.ts +12 -1
  25. package/dist/spec/fetch.d.ts.map +1 -1
  26. package/package.json +2 -2
  27. package/src/agentcore/trace-fetch.test.ts +17 -0
  28. package/src/agentcore/trace-fetch.ts +7 -2
  29. package/src/api/read-client.test.ts +87 -0
  30. package/src/api/read-client.ts +57 -1
  31. package/src/codegen/docs-links.test.ts +44 -30
  32. package/src/codegen/docs.ts +2 -1034
  33. package/src/components/capability-plugin.ts +5 -2
  34. package/src/components/cloud-executor.test.ts +29 -1
  35. package/src/components/cloud-executor.ts +23 -5
  36. package/src/composites/agentcore-agent.test.ts +32 -12
  37. package/src/composites/agentcore-agent.ts +43 -23
  38. package/src/composites/index.ts +1 -1
  39. package/src/composites/microvm-app.test.ts +2 -2
  40. package/src/deep-observe.test.ts +94 -0
  41. package/src/deep-observe.ts +58 -2
  42. package/src/import/roundtrip-fixtures.test.ts +1 -1
  43. package/src/index.ts +3 -1
  44. package/src/lifecycle-integration.test.ts +89 -0
  45. package/src/op/activities/aws-apply.test.ts +40 -4
  46. package/src/op/activities/aws-apply.ts +23 -7
  47. package/src/plugin.ts +34 -35
  48. package/src/properties.test.ts +5 -5
  49. package/src/properties.ts +6 -5
  50. package/src/serializer.test.ts +73 -33
  51. package/src/spec/fetch.test.ts +40 -0
  52. package/src/spec/fetch.ts +24 -3
@@ -28,8 +28,11 @@ const { awsPlugin } = await import("./plugin");
28
28
  const { liveImportFromPlugins } = await import("@intentius/chant/cli/commands/import");
29
29
  const { buildChangeSet } = await import("@intentius/chant/lifecycle/change-set");
30
30
  const { normalizeObservation } = await import("@intentius/chant/observation");
31
+ const { buildLiveGraphIr } = await import("@intentius/chant/graph-ir");
31
32
  const { liveEvidenceFromChangeSet, reconcileStatus } = await import("@intentius/chant/lifecycle/status");
32
33
  const { describeObservationConformance } = await import("@intentius/chant-test-utils");
34
+ const { observeResources } = await import("@intentius/chant/lifecycle/observe");
35
+ const { DECLARABLE_MARKER } = await import("@intentius/chant/declarable");
33
36
 
34
37
  const liveTemplate = {
35
38
  AWSTemplateFormatVersion: "2010-09-09",
@@ -143,6 +146,49 @@ describe("aws lifecycle integration (#163)", () => {
143
146
  expect(cs2.entries.find((e) => e.name === "MyBucket")!.action).toBe("noop");
144
147
  });
145
148
 
149
+ // #1279 — a node's attributes are the resource's own. The stack's outputs
150
+ // used to be copied onto every member, so a VPC carried `expWebIp` and no
151
+ // `CidrBlock`; they now ride the envelope once, keyed by the stack.
152
+ test("an observed VPC carries its own properties; the stack carries the exports (#1279)", async () => {
153
+ stubCfn((action) =>
154
+ action === "DescribeStackResources"
155
+ ? { text: stackResourcesXml([{ logicalId: "vpc", type: "AWS::EC2::VPC", physicalId: "vpc-a5a41663" }]) }
156
+ : { text: stackOutputsXml({ expVpcId: "vpc-a5a41663", expWebIp: "54.1.2.3", expDbPassword: "hunter2" }) },
157
+ );
158
+ spawnMock.mockResolvedValue(
159
+ ok(JSON.stringify({ Vpcs: [{ VpcId: "vpc-a5a41663", CidrBlock: "10.0.0.0/16", IsDefault: false }] })),
160
+ );
161
+
162
+ const observed = normalizeObservation(
163
+ await awsPlugin.describeResources!({
164
+ environment: "prod",
165
+ buildOutput: "",
166
+ entityNames: ["vpc"],
167
+ entities: new Map(),
168
+ }),
169
+ );
170
+
171
+ const attrs = observed.resources.vpc?.attributes ?? {};
172
+ expect(attrs.CidrBlock).toBe("10.0.0.0/16");
173
+ expect(attrs.IsDefault).toBe(false);
174
+ expect(Object.keys(attrs).filter((k) => k.startsWith("exp"))).toEqual([]);
175
+
176
+ expect(observed.stackExports).toEqual({
177
+ prod: { expVpcId: "vpc-a5a41663", expWebIp: "54.1.2.3", expDbPassword: "[REDACTED]" },
178
+ });
179
+
180
+ // And through the graph: the exports land on the IR's `exports`, not on the node.
181
+ const ir = buildLiveGraphIr([{ lexicon: "aws", resources: observed.resources, stackExports: observed.stackExports }]);
182
+ const node = ir.nodes.find((n) => n.id === "vpc")!;
183
+ expect(node.attrs.CidrBlock).toBe("10.0.0.0/16");
184
+ expect(node.attrs.expVpcId).toBeUndefined();
185
+ expect(ir.exports).toEqual([
186
+ { name: "expDbPassword", value: "[REDACTED]", stack: "prod" },
187
+ { name: "expVpcId", value: "vpc-a5a41663", stack: "prod" },
188
+ { name: "expWebIp", value: "54.1.2.3", stack: "prod" },
189
+ ]);
190
+ });
191
+
146
192
  // #1647 — the carve state, end to end: terraform applied the bucket, carve
147
193
  // emitted a carveout declaring it by BucketName, and no CFN stack has ever
148
194
  // heard of it. The stack read alone said confirmed-absent (missing → a plan
@@ -303,6 +349,49 @@ describe("aws lifecycle integration (#163)", () => {
303
349
  });
304
350
  expect(cs.entries[0].action).toBe("create");
305
351
  });
352
+
353
+ // #1265 — the ownership notice printed once per `describeResources` call, so
354
+ // a four-stack project got four identical copies ahead of every answer. It
355
+ // is a property of the read path, not of a stack: the plugin now returns it
356
+ // as a note on the observation and core says it once per run.
357
+ test("owned read on a four-stack project: one ownership note, nothing printed", async () => {
358
+ stubCfn((action) =>
359
+ action === "DescribeStackResources"
360
+ ? { text: stackResourcesXml([{ logicalId: "MyBucket", type: "AWS::S3::Bucket", physicalId: "my-bucket" }]) }
361
+ : { text: stackOutputsXml() },
362
+ );
363
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
364
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
365
+ const entities = new Map([
366
+ ["MyBucket", { [DECLARABLE_MARKER]: true as const, lexicon: "aws", entityType: "AWS::S3::Bucket", props: {} }],
367
+ ]);
368
+ const result = await observeResources(
369
+ "prod",
370
+ [awsPlugin],
371
+ { errors: [], warnings: [], entities, outputs: new Map([["aws", ""]]) } as never,
372
+ { owned: true, stacks: ["prod-net", "prod-data", "prod-app", "prod-edge"] },
373
+ );
374
+ const ownership = result.notes.filter((n) => n.includes("ownership filter unavailable"));
375
+ expect(ownership).toHaveLength(1);
376
+ expect(ownership[0]).toBe(
377
+ "[aws] ownership filter unavailable on describeResources (no tags from describe-stack-resources) — returning all, each with an explicit `unknown` verdict; use `chant import --from <env> --owned` for ownership-filtered export",
378
+ );
379
+ expect(result.warnings.join("\n")).not.toContain("ownership filter unavailable");
380
+ expect(warn).not.toHaveBeenCalled();
381
+ expect(error).not.toHaveBeenCalled();
382
+ });
383
+
384
+ test("an unowned read carries no ownership note", async () => {
385
+ stubCfn((action) =>
386
+ action === "DescribeStackResources"
387
+ ? { text: stackResourcesXml([{ logicalId: "MyBucket", type: "AWS::S3::Bucket", physicalId: "my-bucket" }]) }
388
+ : { text: stackOutputsXml() },
389
+ );
390
+ const observed = normalizeObservation(
391
+ await awsPlugin.describeResources!({ environment: "prod", buildOutput: "", entityNames: ["MyBucket"], entities: new Map() }),
392
+ );
393
+ expect(observed.notes).toEqual([]);
394
+ });
306
395
  });
307
396
 
308
397
  // The shared conformance suite (#1089).
@@ -25,8 +25,16 @@ const describe_ = (status: string) => `<DescribeStacksResponse><Stacks><member><
25
25
 
26
26
  describe("CFN pure helpers (#awsApply)", () => {
27
27
  test("cfnUrl: endpoint override vs real regional host", () => {
28
- expect(cfnUrl("http://localhost:4566")).toBe("http://localhost:4566/");
29
- expect(cfnUrl(undefined, "eu-west-1")).toBe("https://cloudformation.eu-west-1.amazonaws.com/");
28
+ expect(cfnUrl("http://localhost:4566", undefined, {})).toBe("http://localhost:4566/");
29
+ expect(cfnUrl(undefined, "eu-west-1", {})).toBe("https://cloudformation.eu-west-1.amazonaws.com/");
30
+ });
31
+
32
+ test("cfnUrl: AWS_ENDPOINT_URL[_CLOUDFORMATION] is an override too, the same rule as the read client (#1694)", () => {
33
+ expect(cfnUrl(undefined, "eu-west-1", { AWS_ENDPOINT_URL: "http://localhost:4566" })).toBe("http://localhost:4566/");
34
+ expect(
35
+ cfnUrl(undefined, "eu-west-1", { AWS_ENDPOINT_URL: "http://all:1", AWS_ENDPOINT_URL_CLOUDFORMATION: "http://cfn:2" }),
36
+ ).toBe("http://cfn:2/");
37
+ expect(cfnUrl("http://opt:3", "eu-west-1", { AWS_ENDPOINT_URL: "http://all:1" })).toBe("http://opt:3/");
30
38
  });
31
39
 
32
40
  test("cfnForm stamps Action + Version", () => {
@@ -55,9 +63,9 @@ describe("CFN pure helpers (#awsApply)", () => {
55
63
  });
56
64
  });
57
65
 
58
- function tmpl(): string {
66
+ function tmpl(extra: Record<string, unknown> = {}): string {
59
67
  const p = `/tmp/chant-cfn-${process.pid}-${Math.round(performance.now())}.json`;
60
- writeFileSync(p, JSON.stringify({ Resources: { B: { Type: "AWS::S3::Bucket" } } }));
68
+ writeFileSync(p, JSON.stringify({ ...extra, Resources: { B: { Type: "AWS::S3::Bucket" } } }));
61
69
  return p;
62
70
  }
63
71
 
@@ -77,6 +85,34 @@ describe("awsApply flow (#awsApply)", () => {
77
85
  expect(calls).toEqual(["DescribeStacks", "CreateStack", "DescribeStacks"]);
78
86
  });
79
87
 
88
+ test("capabilities follow the template: NAMED_IAM alone, plus AUTO_EXPAND for a Transform (#980)", async () => {
89
+ const sent: Record<string, string>[] = [];
90
+ let described = 0;
91
+ const http: AwsHttp = async (_url, form) => {
92
+ if (form.Action === "DescribeStacks") return described++ % 2 === 0 ? { status: 400, text: MISSING } : { status: 200, text: describe_("CREATE_COMPLETE") };
93
+ sent.push(form);
94
+ return { status: 200, text: CREATE_OK };
95
+ };
96
+ const plain = tmpl();
97
+ await awsApply({ templatePath: plain, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http);
98
+ unlinkSync(plain);
99
+ expect(sent[0]["Capabilities.member.1"]).toBe("CAPABILITY_NAMED_IAM");
100
+ expect(sent[0]["Capabilities.member.2"]).toBeUndefined();
101
+
102
+ const macro = tmpl({ Transform: "AWS::SecretsManager-2020-07-23" });
103
+ await awsApply({ templatePath: macro, stackName: "s", endpoint: "http://x", intervalMs: 1 }, undefined, http);
104
+ unlinkSync(macro);
105
+ expect(sent[1]["Capabilities.member.1"]).toBe("CAPABILITY_NAMED_IAM");
106
+ expect(sent[1]["Capabilities.member.2"]).toBe("CAPABILITY_AUTO_EXPAND");
107
+
108
+ // An explicit list still wins.
109
+ const explicit = tmpl({ Transform: "AWS::Serverless-2016-10-31" });
110
+ await awsApply({ templatePath: explicit, stackName: "s", endpoint: "http://x", intervalMs: 1, capabilities: ["CAPABILITY_IAM"] }, undefined, http);
111
+ unlinkSync(explicit);
112
+ expect(sent[2]["Capabilities.member.1"]).toBe("CAPABILITY_IAM");
113
+ expect(sent[2]["Capabilities.member.2"]).toBeUndefined();
114
+ });
115
+
80
116
  test("update path: existing stack → UpdateStack → UPDATE_COMPLETE", async () => {
81
117
  let described = 0;
82
118
  const http: AwsHttp = async (_url, form) => {
@@ -1,20 +1,28 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { safeHeartbeat, sleep } from "@intentius/chant/op";
3
+ import { awsDeployCapabilitiesForBody } from "../../components/cloud-executor.js";
4
+ import { resolveEndpointOverride } from "../../api/read-client.js";
3
5
 
4
6
  const DEFAULT_REGION = "us-east-1";
5
7
  const CFN_API_VERSION = "2010-05-15";
6
- const DEFAULT_CAPABILITIES = ["CAPABILITY_NAMED_IAM"];
7
8
 
8
9
  export interface AwsApplyArgs {
9
10
  /** Path to a built CloudFormation template (JSON/YAML). */
10
11
  templatePath: string;
11
12
  /** CloudFormation stack name — the deploy boundary. */
12
13
  stackName: string;
13
- /** CFN endpoint override (e.g. Floci `http://localhost:4566`). Default: real CloudFormation. */
14
+ /**
15
+ * CFN endpoint override (e.g. Floci `http://localhost:4566`). Omitted,
16
+ * `AWS_ENDPOINT_URL_CLOUDFORMATION` then `AWS_ENDPOINT_URL` answer — the same
17
+ * rule the read client applies (#1694). With neither: real CloudFormation.
18
+ */
14
19
  endpoint?: string;
15
20
  /** Region (real CFN host + `Version` context). Default: `us-east-1`. */
16
21
  region?: string;
17
- /** Capabilities to acknowledge. Default: `["CAPABILITY_NAMED_IAM"]`. */
22
+ /**
23
+ * Capabilities to acknowledge. Default: `CAPABILITY_NAMED_IAM`, plus
24
+ * `CAPABILITY_AUTO_EXPAND` when the template has a top-level `Transform` (#980).
25
+ */
18
26
  capabilities?: string[];
19
27
  /** Stack-settle timeout in ms. Default: `300000`. */
20
28
  timeoutMs?: number;
@@ -37,9 +45,17 @@ const defaultHttp: AwsHttp = async (url, form, signal) => {
37
45
 
38
46
  // ── Pure helpers (CFN Query protocol) ─────────────────────────────────────────
39
47
 
40
- /** The CloudFormation endpoint URL — the override, or the real regional host. */
41
- export function cfnUrl(endpoint?: string, region = DEFAULT_REGION): string {
42
- return `${(endpoint ?? `https://cloudformation.${region}.amazonaws.com`).replace(/\/$/, "")}/`;
48
+ /**
49
+ * The CloudFormation endpoint URL — the override, or the real regional host.
50
+ * `env` is what the ambient-variable fallback reads; injectable for tests.
51
+ */
52
+ export function cfnUrl(
53
+ endpoint?: string,
54
+ region = DEFAULT_REGION,
55
+ env: Record<string, string | undefined> = process.env,
56
+ ): string {
57
+ const override = resolveEndpointOverride("cloudformation", endpoint, env);
58
+ return `${(override ?? `https://cloudformation.${region}.amazonaws.com`).replace(/\/$/, "")}/`;
43
59
  }
44
60
 
45
61
  /** A CFN Query-protocol form body: `Action` + `Version` + params. */
@@ -122,8 +138,8 @@ export async function awsApply(
122
138
  http: AwsHttp = defaultHttp,
123
139
  ): Promise<{ stackName: string; status: string; action: "created" | "updated" | "unchanged" }> {
124
140
  const url = cfnUrl(args.endpoint, args.region);
125
- const capabilities = args.capabilities ?? DEFAULT_CAPABILITIES;
126
141
  const templateBody = readFileSync(args.templatePath, "utf8");
142
+ const capabilities = args.capabilities ?? awsDeployCapabilitiesForBody(templateBody);
127
143
  const timeoutMs = args.timeoutMs ?? 300_000;
128
144
  const intervalMs = args.intervalMs ?? 3_000;
129
145
 
package/src/plugin.ts CHANGED
@@ -47,8 +47,14 @@ export { stackDoesNotExist } from "./stack-errors";
47
47
  * Provides serializer, lint rules, template detection,
48
48
  * import parsing, and code generation for AWS CloudFormation.
49
49
  */
50
- /** #1265 — the ownership notice is about the environment, so it is said once. */
51
- let warnedOwnership = false;
50
+ /**
51
+ * describe-stack-resources returns no tags, so the ownership filter cannot be
52
+ * applied on this read path. Returned as a run-level note rather than printed
53
+ * (#1265): core says it once per run, after the answer, however many stacks
54
+ * were read. The text is the contract consumers grep for.
55
+ */
56
+ const OWNERSHIP_UNAVAILABLE_NOTE =
57
+ "ownership filter unavailable on describeResources (no tags from describe-stack-resources) — returning all, each with an explicit `unknown` verdict; use `chant import --from <env> --owned` for ownership-filtered export";
52
58
 
53
59
  export const awsPlugin: LexiconPlugin = {
54
60
  name: "aws",
@@ -558,25 +564,17 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
558
564
  // The applier's own transport, pointed at the read APIs (#1206). Multi-region
559
565
  // estates target this stack's region, not the ambient one.
560
566
  const client: AwsReadClientOptions = {
561
- ...(process.env.AWS_ENDPOINT_URL ? { endpoint: process.env.AWS_ENDPOINT_URL } : {}),
562
567
  ...(options.region ? { region: options.region } : {}),
563
568
  };
564
569
 
565
- if (options.owned) {
566
- // describe-stack-resources does not return tags, so ownership cannot be
567
- // determined here. Degrade to detect-only rather than silently filtering.
568
- //
569
- // Once per process, not once per stack (#1265). It is a property of the
570
- // environment, not of each stack, and a four-stack project printed four
571
- // identical copies ahead of every answer enough that an agent piping
572
- // `graph --format ir` with `2>&1` had to skip lines to find the JSON.
573
- warnedOwnership ||
574
- // eslint-disable-next-line no-console
575
- console.warn(
576
- "[aws] ownership filter unavailable on describeResources (no tags from describe-stack-resources) — returning all, each with an explicit `unknown` verdict; use `chant import --from <env> --owned` for ownership-filtered export",
577
- );
578
- warnedOwnership = true;
579
- }
570
+ // describe-stack-resources does not return tags, so ownership cannot be
571
+ // determined here. Degrade to detect-only rather than silently filtering,
572
+ // and say so as a note on the observation. It is a property of the
573
+ // environment, not of each stack: a four-stack project once printed four
574
+ // identical copies ahead of every answer (#1265) enough that an agent
575
+ // piping `graph --format ir` with `2>&1` had to skip lines to find the
576
+ // JSON. Core dedupes the note across stacks and prints it with the footer.
577
+ const notes = options.owned ? [OWNERSHIP_UNAVAILABLE_NOTE] : undefined;
580
578
 
581
579
  // Derive stack name. A multi-stack project passes the explicit CloudFormation
582
580
  // stack this observation targets (see `stacks` in ChantConfig); otherwise the
@@ -600,7 +598,7 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
600
598
  if (err instanceof AwsReadError && stackDoesNotExist(err.message)) {
601
599
  const { observeByIdentity } = await import("./identity-observe");
602
600
  const identity = await observeByIdentity(options.entityNames, options.entities, resources, client);
603
- return observation({ ...resources, ...identity.resources }, undefined, identity.queried);
601
+ return observation({ ...resources, ...identity.resources }, undefined, identity.queried, notes);
604
602
  }
605
603
  // Any other failure (credentials, throttling, a region that can't be
606
604
  // reached) establishes nothing about what is deployed. Reporting every
@@ -617,6 +615,8 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
617
615
  reason,
618
616
  `DescribeStackResources failed for stack "${stackName}": ${detail}`,
619
617
  ),
618
+ undefined,
619
+ notes,
620
620
  );
621
621
  }
622
622
 
@@ -633,20 +633,20 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
633
633
  stackOutputs = {};
634
634
  }
635
635
 
636
+ // The outputs are the stack's, not any member's (#1279). They used to be
637
+ // copied onto every resource's `attributes`, so a VPC carried the stack's
638
+ // `expWebIp` and no `CidrBlock`. They ride the envelope once, keyed by the
639
+ // stack, scrubbed of anything that looks secret.
640
+ const exports: Record<string, unknown> = {};
641
+ for (const [key, value] of Object.entries(stackOutputs)) {
642
+ exports[key] = /password|secret|token|key/i.test(key) ? "[REDACTED]" : value;
643
+ }
644
+ const stackExports = Object.keys(exports).length > 0 ? { [stackName]: exports } : undefined;
645
+
636
646
  for (const entityName of options.entityNames) {
637
647
  const stackResource = stackResourceMap.get(entityName);
638
648
  if (!stackResource) continue;
639
649
 
640
- const attributes: Record<string, unknown> = {};
641
- // Include stack outputs as attributes (scrub sensitive ones)
642
- for (const [key, value] of Object.entries(stackOutputs)) {
643
- if (/password|secret|token|key/i.test(key)) {
644
- attributes[key] = "[REDACTED]";
645
- } else {
646
- attributes[key] = value;
647
- }
648
- }
649
-
650
650
  resources[entityName] = {
651
651
  type: stackResource.type,
652
652
  physicalId: stackResource.physicalId ?? "",
@@ -657,7 +657,6 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
657
657
  // rather than leaving the field off and letting each consumer guess —
658
658
  // the change set never escalates `unknown` to a delete.
659
659
  ownership: "unknown",
660
- attributes: Object.keys(attributes).length > 0 ? attributes : undefined,
661
660
  };
662
661
  }
663
662
 
@@ -669,9 +668,9 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
669
668
  const { observeByIdentity } = await import("./identity-observe");
670
669
  const identity = await observeByIdentity(options.entityNames, options.entities, resources, client);
671
670
 
672
- // Each resource's OWN properties, on top of the stack outputs above (#1279).
673
- // Until this, a node's `attrs` were the stack's exports replicated onto
674
- // every member, so no instance carried its own `VpcId`.
671
+ // Each resource's OWN properties (#1279). Until this, a node's `attrs` were
672
+ // the stack's exports replicated onto every member, so no instance carried
673
+ // its own `VpcId`.
675
674
  const own = await describeOwnProperties(resources, options.region);
676
675
  const withProperties = stampProviderDefaults(stampRegion(own.resources, options.region));
677
676
 
@@ -701,13 +700,13 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
701
700
  detail: `the stack was read, but this resource's own properties were not — ${own.failures.get(type) ?? "the describe call failed"}`,
702
701
  };
703
702
  }
704
- return observation({ ...described, ...identity.resources }, holes, identity.queried);
703
+ return observation({ ...described, ...identity.resources }, holes, identity.queried, notes, stackExports);
705
704
  }
706
705
 
707
706
  // Every entity the stack answered for was answered for: an entity the
708
707
  // template doesn't carry is genuinely not in this stack, which is an
709
708
  // absence, not a hole — unless the identity fallback saw it live (#1647).
710
- return observation({ ...withProperties, ...identity.resources }, undefined, identity.queried);
709
+ return observation({ ...withProperties, ...identity.resources }, undefined, identity.queried, notes, stackExports);
711
710
  },
712
711
 
713
712
  /**
@@ -40,18 +40,18 @@ describe("describeOwnProperties (#1279)", () => {
40
40
  expect(spawnMock).toHaveBeenCalledTimes(1);
41
41
  });
42
42
 
43
- it("keeps stack outputs, and lets the resource's own property win the name", async () => {
44
- const withOutputs = {
43
+ it("keeps what the entry already carried, and lets the resource's own property win the name", async () => {
44
+ const withPrior = {
45
45
  web: {
46
46
  type: "AWS::EC2::Instance",
47
47
  status: "OK",
48
48
  physicalId: "i-1",
49
- attributes: { expVpcId: "vpc-exported", VpcId: "stale" },
49
+ attributes: { region: "us-east-1", VpcId: "stale" },
50
50
  },
51
51
  };
52
52
  spawnMock.mockResolvedValue(ok(reservations(instance("i-1", "vpc-a"))));
53
- const { resources: merged } = await describeOwnProperties(withOutputs);
54
- expect(merged.web.attributes?.expVpcId).toBe("vpc-exported");
53
+ const { resources: merged } = await describeOwnProperties(withPrior);
54
+ expect(merged.web.attributes?.region).toBe("us-east-1");
55
55
  expect(merged.web.attributes?.VpcId).toBe("vpc-a");
56
56
  });
57
57
 
package/src/properties.ts CHANGED
@@ -16,9 +16,10 @@
16
16
  * A deep read (Cloud Control) answers this properly but is a per-resource call
17
17
  * and is not available on every endpoint. This is the cheap middle: one describe
18
18
  * per kind for the whole observation, joined back by physical id. Stack outputs
19
- * are kept they were the only attributes for a long time and queries lean on
20
- * them — but a resource's own properties win a name collision, because they are
21
- * the resource's.
19
+ * no longer ride the resource at all they are the stack's, and travel on the
20
+ * observation envelope's `stackExports`. Whatever a resource already carries is
21
+ * kept underneath its own properties, which win a name collision because they
22
+ * are the resource's.
22
23
  */
23
24
 
24
25
  import { applyAwsEndpointArgv } from "./components/cloud-executor";
@@ -177,8 +178,8 @@ export async function describeOwnProperties(
177
178
  for (const name of byId.get(id) ?? []) {
178
179
  merged[name] = {
179
180
  ...merged[name],
180
- // Own properties last: a resource's `VpcId` outranks a stack output
181
- // that happens to share the name.
181
+ // Own properties last: a resource's `VpcId` outranks anything the
182
+ // entry already carried under the same name.
182
183
  attributes: { ...(merged[name].attributes ?? {}), ...row },
183
184
  };
184
185
  }