@intentius/chant-lexicon-aws 0.44.8 → 0.44.10

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.
@@ -0,0 +1,114 @@
1
+ /**
2
+ * The identity fallback on the stack read (#1647).
3
+ *
4
+ * `describeResources` asks CloudFormation exactly one question — is there a
5
+ * logical id named <entity> in this stack? — which reads a freshly
6
+ * carve-emitted, still-Terraform-owned resource as confirmed-absent even
7
+ * though the declared `BucketName` names it precisely. When a declared entity
8
+ * is absent from the stack AND its props spell the type's full primary
9
+ * identifier (the spec knowledge the codegen already compiled into
10
+ * `lexicon-aws.json`), ask Cloud Control for it by identity. Found means
11
+ * OBSERVED: `ownership: "foreign"` (it exists and something other than this
12
+ * stack owns it — Terraform, a console hand, another tool) and
13
+ * `status: "EXTERNAL"` (live outside the stack, deliberately not a
14
+ * CloudFormation status word).
15
+ *
16
+ * Best-effort ON TOP of a stack answer, never instead of one: a genuine miss
17
+ * (`ResourceNotFoundException`) keeps the stack's absent verdict, and so does
18
+ * every other refusal (`UnsupportedOperation` — a Floci without Cloud
19
+ * Control — credentials, throttling). The stack read succeeded; absence at
20
+ * stack scope is an honest verdict this fallback can refine but must never
21
+ * degrade into a hole, or a pre-first-apply plan would stop proposing
22
+ * `create` the moment the emulator lacks Cloud Control.
23
+ */
24
+ import { createRequire } from "module";
25
+ import { getResource, type AwsReadClientOptions } from "./api/read-client";
26
+ import type { ResourceMetadata } from "@intentius/chant/lexicon";
27
+
28
+ const require = createRequire(import.meta.url);
29
+
30
+ interface LexiconEntry {
31
+ resourceType: string;
32
+ kind: string;
33
+ primaryIdentifier?: string[];
34
+ }
35
+
36
+ let byResourceType: Map<string, LexiconEntry> | undefined;
37
+ function manifestByType(): Map<string, LexiconEntry> {
38
+ if (!byResourceType) {
39
+ const manifest = require("./generated/lexicon-aws.json") as Record<string, LexiconEntry>;
40
+ byResourceType = new Map(Object.values(manifest).map((e) => [e.resourceType, e]));
41
+ }
42
+ return byResourceType;
43
+ }
44
+
45
+ /**
46
+ * The Cloud Control identifier the declared props spell, or undefined when the
47
+ * type has no primary identifier on record or any part of it is absent or
48
+ * non-scalar (a Ref, an intrinsic, a server-assigned name). Multi-part
49
+ * identifiers join with `|`, Cloud Control's own separator.
50
+ */
51
+ export function declaredIdentifier(entityType: string, props: Record<string, unknown>): string | undefined {
52
+ const entry = manifestByType().get(entityType);
53
+ const parts = entry?.primaryIdentifier;
54
+ if (!entry || entry.kind !== "resource" || !parts || parts.length === 0) return undefined;
55
+ const values: string[] = [];
56
+ for (const part of parts) {
57
+ const v = props[part];
58
+ if (typeof v !== "string" && typeof v !== "number") return undefined;
59
+ const s = String(v);
60
+ if (!s) return undefined;
61
+ values.push(s);
62
+ }
63
+ return values.join("|");
64
+ }
65
+
66
+ /** The same scrub the stack-output path applies, on live property KEYS. */
67
+ function redactSensitive(properties: Record<string, unknown>): Record<string, unknown> | undefined {
68
+ const out: Record<string, unknown> = {};
69
+ for (const [key, value] of Object.entries(properties)) {
70
+ out[key] = /password|secret|token/i.test(key) ? "[REDACTED]" : value;
71
+ }
72
+ return Object.keys(out).length > 0 ? out : undefined;
73
+ }
74
+
75
+ /**
76
+ * Identity-read every entity the stack did not answer for and whose props
77
+ * spell an identifier. `already` is the stack's answer — an entity the stack
78
+ * DID return is never re-read. The `queried` map records the address each
79
+ * attempted read was issued against (#1620), whatever the verdict.
80
+ */
81
+ export async function observeByIdentity(
82
+ entityNames: string[],
83
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }> | undefined,
84
+ already: Record<string, ResourceMetadata>,
85
+ client: AwsReadClientOptions,
86
+ ): Promise<{ resources: Record<string, ResourceMetadata>; queried: Record<string, string> }> {
87
+ const resources: Record<string, ResourceMetadata> = {};
88
+ const queried: Record<string, string> = {};
89
+ if (!entities) return { resources, queried };
90
+ for (const name of entityNames) {
91
+ if (already[name]) continue;
92
+ const entity = entities.get(name);
93
+ if (!entity) continue;
94
+ const identifier = declaredIdentifier(entity.entityType, entity.props ?? {});
95
+ if (!identifier) continue;
96
+ queried[name] = `cloudcontrol:GetResource:${entity.entityType}:${identifier}`;
97
+ try {
98
+ const found = await getResource(entity.entityType, identifier, client);
99
+ if (!found) continue;
100
+ resources[name] = {
101
+ type: entity.entityType,
102
+ physicalId: found.identifier || identifier,
103
+ status: "EXTERNAL",
104
+ ownership: "foreign",
105
+ ...(redactSensitive(found.properties) ? { attributes: redactSensitive(found.properties) } : {}),
106
+ };
107
+ } catch {
108
+ // Refusals of every kind keep the stack's verdict — see the module
109
+ // comment for why a failed refinement must not become a hole.
110
+ continue;
111
+ }
112
+ }
113
+ return { resources, queried };
114
+ }
package/src/index.ts CHANGED
@@ -52,8 +52,21 @@ export {
52
52
  type AwsReadClientOptions,
53
53
  type CloudControlDescription,
54
54
  type StackResource,
55
+ type AwsCredentials,
56
+ type AwsCredentialResolver,
57
+ type AwsCredentialSource,
55
58
  } from "./api/read-client";
56
59
 
60
+ // SigV4 (#1686). Exported as its own surface because it is deliberately not
61
+ // specific to this transport: cedar's AVP client is the next caller, and the
62
+ // point of the module is that there is one signer rather than one per lexicon.
63
+ export {
64
+ signRequest,
65
+ resolveCredentials,
66
+ EMPTY_PAYLOAD_SHA256,
67
+ type SigV4Request,
68
+ } from "./api/sigv4";
69
+
57
70
  // Intrinsics
58
71
  export {
59
72
  Sub,
@@ -143,6 +143,75 @@ describe("aws lifecycle integration (#163)", () => {
143
143
  expect(cs2.entries.find((e) => e.name === "MyBucket")!.action).toBe("noop");
144
144
  });
145
145
 
146
+ // #1647 — the carve state, end to end: terraform applied the bucket, carve
147
+ // emitted a carveout declaring it by BucketName, and no CFN stack has ever
148
+ // heard of it. The stack read alone said confirmed-absent (missing → a plan
149
+ // proposing create for a bucket that EXISTS); the identity fallback asks
150
+ // Cloud Control by the declared identifier and the verdict comes back
151
+ // observed.
152
+ test("identity fallback: a declared, stack-absent, live resource reads observed, not missing (#1647)", async () => {
153
+ const routeBoth = (cc: { status?: number; text: string }): void => {
154
+ vi.spyOn(globalThis, "fetch").mockImplementation((async (url: string, init: { body: string; headers?: Record<string, string> }) => {
155
+ const target = init.headers?.["x-amz-target"] ?? "";
156
+ if (target.endsWith("GetResource")) return { status: cc.status ?? 200, text: () => Promise.resolve(cc.text) };
157
+ const action = new URLSearchParams(init.body).get("Action") ?? "";
158
+ return {
159
+ status: 200,
160
+ text: () => Promise.resolve(action === "DescribeStackResources" ? stackResourcesXml([]) : stackOutputsXml()),
161
+ };
162
+ }) as unknown as typeof fetch);
163
+ };
164
+
165
+ const entities = new Map([
166
+ ["assets", { entityType: "AWS::S3::Bucket", props: { BucketName: "acme-platform-assets-prod" } }],
167
+ ]);
168
+
169
+ routeBoth({
170
+ text: JSON.stringify({
171
+ ResourceDescription: {
172
+ Identifier: "acme-platform-assets-prod",
173
+ Properties: JSON.stringify({ BucketName: "acme-platform-assets-prod" }),
174
+ },
175
+ }),
176
+ });
177
+ const observed = normalizeObservation(
178
+ await awsPlugin.describeResources!({
179
+ environment: "prod",
180
+ buildOutput: "",
181
+ entityNames: ["assets"],
182
+ entities,
183
+ }),
184
+ );
185
+ expect(observed.resources.assets).toMatchObject({
186
+ type: "AWS::S3::Bucket",
187
+ status: "EXTERNAL",
188
+ ownership: "foreign",
189
+ });
190
+ // #1620: the identity read's address rides the observation.
191
+ expect(observed.queried.assets).toContain("acme-platform-assets-prod");
192
+
193
+ // Through the change set: declared + live → noop, never create. `foreign`
194
+ // ownership never escalates anything (#120's rule holds).
195
+ const cs = buildChangeSet("prod", { declared: new Set(["assets"]), observedNow: observed.resources, observedThen: undefined });
196
+ expect(cs.entries.find((e) => e.name === "assets")!.action).toBe("noop");
197
+
198
+ // An emulator without Cloud Control keeps today's verdict exactly: absent,
199
+ // create proposed — the fallback must not turn pre-first-apply into a hole.
200
+ routeBoth({ status: 400, text: JSON.stringify({ __type: "UnsupportedOperation", message: "not supported" }) });
201
+ const degraded = normalizeObservation(
202
+ await awsPlugin.describeResources!({
203
+ environment: "prod",
204
+ buildOutput: "",
205
+ entityNames: ["assets"],
206
+ entities,
207
+ }),
208
+ );
209
+ expect(degraded.resources.assets).toBeUndefined();
210
+ expect(degraded.unobserved.assets).toBeUndefined();
211
+ const cs2 = buildChangeSet("prod", { declared: new Set(["assets"]), observedNow: degraded.resources, observedThen: undefined });
212
+ expect(cs2.entries.find((e) => e.name === "assets")!.action).toBe("create");
213
+ });
214
+
146
215
  describe("describeStackStatus (#57 — per-component stack presence)", () => {
147
216
  const err = (stderr: string) => ({ stdout: "", stderr, exitCode: 255 });
148
217
 
package/src/plugin.ts CHANGED
@@ -548,6 +548,7 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
548
548
  environment: string;
549
549
  buildOutput: string;
550
550
  entityNames: string[];
551
+ entities?: Map<string, { entityType: string; props: Record<string, unknown> }>;
551
552
  stack?: string;
552
553
  region?: string;
553
554
  owned?: boolean;
@@ -592,9 +593,14 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
592
593
  // A stack that doesn't exist yet is the pre-first-apply state: nothing is
593
594
  // deployed for this env, so there are no live resources (every declared
594
595
  // resource is "pending") — not an error. That is a real absence, so the
595
- // empty result is the honest one and `create` is the right proposal.
596
+ // empty result is the honest one and `create` is the right proposal
597
+ // EXCEPT for an entity whose props spell its own physical identity
598
+ // (#1647): a freshly carve-emitted, still-Terraform-owned resource lives
599
+ // in no stack at all, and only an identity read can see it.
596
600
  if (err instanceof AwsReadError && stackDoesNotExist(err.message)) {
597
- return observation(resources);
601
+ const { observeByIdentity } = await import("./identity-observe");
602
+ const identity = await observeByIdentity(options.entityNames, options.entities, resources, client);
603
+ return observation({ ...resources, ...identity.resources }, undefined, identity.queried);
598
604
  }
599
605
  // Any other failure (credentials, throttling, a region that can't be
600
606
  // reached) establishes nothing about what is deployed. Reporting every
@@ -655,6 +661,14 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
655
661
  };
656
662
  }
657
663
 
664
+ // The identity fallback (#1647): entities the stack did not answer for but
665
+ // whose declared props spell a full primary identifier get a Cloud Control
666
+ // read before "absent" stands. Computed against the stack's answer and
667
+ // merged at the return sites, so the own-property enrichment below neither
668
+ // re-describes nor un-observes what identity found.
669
+ const { observeByIdentity } = await import("./identity-observe");
670
+ const identity = await observeByIdentity(options.entityNames, options.entities, resources, client);
671
+
658
672
  // Each resource's OWN properties, on top of the stack outputs above (#1279).
659
673
  // Until this, a node's `attrs` were the stack's exports replicated onto
660
674
  // every member, so no instance carried its own `VpcId`.
@@ -687,13 +701,13 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
687
701
  detail: `the stack was read, but this resource's own properties were not — ${own.failures.get(type) ?? "the describe call failed"}`,
688
702
  };
689
703
  }
690
- return observation(described, holes);
704
+ return observation({ ...described, ...identity.resources }, holes, identity.queried);
691
705
  }
692
706
 
693
707
  // Every entity the stack answered for was answered for: an entity the
694
708
  // template doesn't carry is genuinely not in this stack, which is an
695
- // absence, not a hole.
696
- return observation(withProperties);
709
+ // absence, not a hole — unless the identity fallback saw it live (#1647).
710
+ return observation({ ...withProperties, ...identity.resources }, undefined, identity.queried);
697
711
  },
698
712
 
699
713
  /**