@intentius/chant-lexicon-aws 0.28.0 → 0.30.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.
@@ -0,0 +1,384 @@
1
+ /**
2
+ * AWS deep observation (#1015) — the reference implementation of the epic's
3
+ * deep-observe contract (#1014).
4
+ *
5
+ * `describeResources` reads `cloudformation describe-stack-resources`, which
6
+ * returns a status, a physical id and a timestamp per resource. That is
7
+ * CloudFormation's view of the world, and CloudFormation only compares
8
+ * properties it was told about. A property somebody edited in the console — an
9
+ * inline policy, a bucket setting, a security-group rule — is invisible to it.
10
+ * That gap is why go-to-k/cdk-real-drift exists, and it is what this reader
11
+ * closes: the live resource model comes from the **Cloud Control API**, which
12
+ * bypasses CloudFormation entirely and returns the resource as the service
13
+ * actually holds it.
14
+ *
15
+ * Correlation is unchanged: Cloud Control is addressed by the physical id that
16
+ * `describe-stack-resources` already reports per logical id, so the results
17
+ * line up with the same IR node ids `live-attrs.ts` relies on.
18
+ *
19
+ * ## Scope of the first cut
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.
27
+ *
28
+ * ## Nothing here talks to real AWS on its own terms
29
+ *
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.
33
+ */
34
+
35
+ import type {
36
+ DeepArrayElement,
37
+ DeepNode,
38
+ DeepNormalizationHooks,
39
+ DeepObservationResult,
40
+ DeepResourceObservation,
41
+ UnobservedEntity,
42
+ UnobservedReason,
43
+ } from "@intentius/chant/lexicon";
44
+ import { applyAwsEndpointArgv } from "./components/cloud-executor";
45
+ import { stackDoesNotExist } from "./stack-errors";
46
+ import { AWS_TAG_OWNERSHIP_KEYS } from "./ownership";
47
+
48
+ /**
49
+ * CloudFormation types this reader can read live. Each is addressable in Cloud
50
+ * Control by the physical id CloudFormation already reports.
51
+ */
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
+ ]);
58
+
59
+ /**
60
+ * Property names that are server-populated wherever they appear — identifiers
61
+ * the service mints, timestamps it stamps, counters it maintains. Matched on
62
+ * the final path segment, because AWS repeats these names at every nesting
63
+ * depth and a per-type list of full paths would be a maintenance trap.
64
+ *
65
+ * Deliberately excludes ambiguous names like `Id` and `Name`: `VpcId` and
66
+ * `BucketName` are declared inputs, and pruning a declared input is how a
67
+ * normalization pass starts hiding real drift.
68
+ */
69
+ export const AWS_READ_ONLY_NAMES: ReadonlySet<string> = new Set([
70
+ "Arn",
71
+ "RoleId",
72
+ "PolicyId",
73
+ "GroupId",
74
+ "OwnerId",
75
+ "AttachmentCount",
76
+ "PermissionsBoundaryUsageCount",
77
+ "DefaultVersionId",
78
+ "IsAttachable",
79
+ "CreateDate",
80
+ "CreationDate",
81
+ "UpdateDate",
82
+ "LastModified",
83
+ "LastModifiedTime",
84
+ "DualStackDomainName",
85
+ "RegionalDomainName",
86
+ "WebsiteURL",
87
+ ]);
88
+
89
+ /**
90
+ * Service defaults, per type, as index-erased property paths. A live value
91
+ * equal to its default is subtracted **only when source never declared that
92
+ * property** — cdk-real-drift's default subtraction, and the reason
93
+ * {@link DeepNode.counterpart} exists. Declaring the default explicitly keeps
94
+ * the property in the diff, so a later change to it still reports.
95
+ */
96
+ export const AWS_SERVICE_DEFAULTS: Record<string, Record<string, unknown>> = {
97
+ "AWS::S3::Bucket": {
98
+ "VersioningConfiguration.Status": "Suspended",
99
+ "AccelerateConfiguration.AccelerationStatus": "Suspended",
100
+ "ObjectLockEnabled": false,
101
+ },
102
+ "AWS::IAM::Role": {
103
+ "Path": "/",
104
+ "MaxSessionDuration": 3600,
105
+ },
106
+ "AWS::IAM::ManagedPolicy": {
107
+ "Path": "/",
108
+ },
109
+ "AWS::EC2::SecurityGroup": {
110
+ "GroupDescription": "default VPC security group",
111
+ },
112
+ };
113
+
114
+ /** Stable JSON with sorted keys — the fallback ordering key for a set-like array. */
115
+ function canonicalJson(value: unknown): string {
116
+ return JSON.stringify(value, (_k, v: unknown) =>
117
+ v && typeof v === "object" && !Array.isArray(v)
118
+ ? Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
119
+ : v,
120
+ ) ?? "";
121
+ }
122
+
123
+ /** The final segment of an index-erased pattern (`Policies[].PolicyName` → `PolicyName`). */
124
+ function lastSegment(pattern: string): string {
125
+ const withoutIndex = pattern.replace(/\[\]$/, "");
126
+ const dot = withoutIndex.lastIndexOf(".");
127
+ return dot === -1 ? withoutIndex : withoutIndex.slice(dot + 1);
128
+ }
129
+
130
+ /**
131
+ * The aws lexicon's noise rules. The three classes the epic names for AWS —
132
+ * server-populated fields, unstable ordering (tags, policy statements), and
133
+ * provider defaults — plus nothing else: a rule that is not one of those is a
134
+ * rule that hides drift.
135
+ */
136
+ export const awsDeepNormalizationHooks: DeepNormalizationHooks = {
137
+ prune(node: DeepNode): boolean {
138
+ // Read-only / server-populated. Pruned on both sides: if source somehow
139
+ // declares an arn-shaped output, comparing it to the live one is still
140
+ // meaningless.
141
+ if (AWS_READ_ONLY_NAMES.has(lastSegment(node.pattern))) return true;
142
+
143
+ // Provider defaults, on the live side only, and only where source is silent
144
+ // about the property. `"unknown"` (a one-sided normalization) never prunes:
145
+ // the reader must not decide this before the declared tree is in hand.
146
+ if (node.side !== "live" || node.counterpart !== "absent") return false;
147
+ const defaults = AWS_SERVICE_DEFAULTS[node.entityType];
148
+ if (!defaults) return false;
149
+ if (!Object.prototype.hasOwnProperty.call(defaults, node.pattern)) return false;
150
+ return defaults[node.pattern] === node.value;
151
+ },
152
+
153
+ /**
154
+ * The key doubles as a path segment (`Tags[#env].Value`), so it is the
155
+ * element's own identity where AWS gives one — a tag key, a statement Sid, an
156
+ * action string — and canonical JSON only as a fallback.
157
+ */
158
+ orderKey(element: DeepArrayElement): string | undefined {
159
+ const name = lastSegment(element.pattern);
160
+ const el = element.element;
161
+
162
+ // Tags are a set. AWS returns them in whatever order it likes, and a
163
+ // reordered tag list is the single loudest false positive in a raw diff.
164
+ if (name === "Tags") {
165
+ const key = isRecord(el) ? el.Key : undefined;
166
+ return typeof key === "string" ? key : canonicalJson(el);
167
+ }
168
+
169
+ // IAM policy statements are a set, and so are the Action/Resource lists
170
+ // inside them. `Sid` is the natural identity when the author gave one.
171
+ if (name === "Statement") {
172
+ const sid = isRecord(el) ? el.Sid : undefined;
173
+ return typeof sid === "string" ? sid : canonicalJson(el);
174
+ }
175
+ if (name === "Action" || name === "NotAction" || name === "Resource" || name === "NotResource") {
176
+ return typeof el === "string" ? el : canonicalJson(el);
177
+ }
178
+
179
+ // Security-group rules are a set — the console appends, chant declares in
180
+ // source order, and neither order means anything to EC2.
181
+ if (name === "SecurityGroupIngress" || name === "SecurityGroupEgress" || name === "IpRanges") {
182
+ return canonicalJson(el);
183
+ }
184
+
185
+ return undefined;
186
+ },
187
+ };
188
+
189
+ function isRecord(value: unknown): value is Record<string, unknown> {
190
+ return typeof value === "object" && value !== null && !Array.isArray(value);
191
+ }
192
+
193
+ /** One live resource as `cloudcontrol get-resource` returns it. Exported for tests. */
194
+ export interface CloudControlResource {
195
+ identifier: string;
196
+ properties: Record<string, unknown>;
197
+ }
198
+
199
+ /**
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.
204
+ */
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)
233
+ ? "no-credentials"
234
+ : "read-failed";
235
+ }
236
+
237
+ /** True when the live property tree carries chant's ownership marker tag. */
238
+ export function hasOwnershipMarker(properties: Record<string, unknown>): boolean {
239
+ const tags = properties.Tags;
240
+ if (!Array.isArray(tags)) return false;
241
+ return tags.some((t) => isRecord(t) && t.Key === AWS_TAG_OWNERSHIP_KEYS.managedBy);
242
+ }
243
+
244
+ export interface AwsDeepObserveOptions {
245
+ environment: string;
246
+ entityNames: string[];
247
+ entities?: Map<string, { entityType: string; props: Record<string, unknown> }>;
248
+ stack?: string;
249
+ owned?: boolean;
250
+ }
251
+
252
+ /**
253
+ * Read the live property tree for each declared entity via Cloud Control.
254
+ *
255
+ * Two reads per run plus one per readable resource: `describe-stack-resources`
256
+ * resolves logical id → (type, physical id), then `cloudcontrol get-resource`
257
+ * fetches each model. The first read's failure modes are the thin path's,
258
+ * verbatim — a stack that does not exist yet is a real absence (nothing is
259
+ * deployed, so there are no properties to drift), anything else is a hole for
260
+ * every declared entity.
261
+ */
262
+ export async function observeResourcesDeepAws(
263
+ options: AwsDeepObserveOptions,
264
+ ): Promise<DeepObservationResult> {
265
+ const { getRuntime } = await import("@intentius/chant/runtime-adapter");
266
+ const { deepObservation, normalizeDeepProperties } = await import("@intentius/chant/deep-observation");
267
+ const { unobservedAll } = await import("@intentius/chant/observation");
268
+ const rt = getRuntime();
269
+
270
+ const stackName = options.stack ?? options.environment;
271
+ const endpoint = process.env.AWS_ENDPOINT_URL;
272
+
273
+ const listResult = await rt.spawn(applyAwsEndpointArgv([
274
+ "aws", "cloudformation", "describe-stack-resources",
275
+ "--stack-name", stackName,
276
+ "--output", "json",
277
+ ], endpoint));
278
+
279
+ if (listResult.exitCode !== 0) {
280
+ if (stackDoesNotExist(listResult.stderr)) return deepObservation({});
281
+ return deepObservation(
282
+ {},
283
+ unobservedAll(
284
+ options.entityNames,
285
+ classifyFailure(listResult.stderr),
286
+ `describe-stack-resources failed for stack "${stackName}": ${listResult.stderr.trim().split("\n")[0] ?? ""}`,
287
+ ),
288
+ );
289
+ }
290
+
291
+ let stackResources: Array<{ LogicalResourceId: string; ResourceType: string; PhysicalResourceId?: string }> = [];
292
+ try {
293
+ const parsed = JSON.parse(listResult.stdout) as {
294
+ StackResources?: Array<{ LogicalResourceId: string; ResourceType: string; PhysicalResourceId?: string }>;
295
+ };
296
+ stackResources = parsed.StackResources ?? [];
297
+ } catch {
298
+ return deepObservation(
299
+ {},
300
+ unobservedAll(options.entityNames, "read-failed", `unparseable describe-stack-resources output for stack "${stackName}"`),
301
+ );
302
+ }
303
+
304
+ const byLogicalId = new Map(stackResources.map((r) => [r.LogicalResourceId, r]));
305
+ const resources: Record<string, DeepResourceObservation> = {};
306
+ const unobserved: Record<string, UnobservedEntity> = {};
307
+
308
+ for (const entityName of options.entityNames) {
309
+ const stackResource = byLogicalId.get(entityName);
310
+ // Not in the stack at all. The thin read reports that absence; restating it
311
+ // here as a property hole would turn one finding into two.
312
+ if (!stackResource) continue;
313
+
314
+ const type = stackResource.ResourceType;
315
+ if (!DEEP_READABLE_TYPES.has(type)) {
316
+ unobserved[entityName] = {
317
+ type,
318
+ reason: "unsupported-kind",
319
+ detail: `no deep reader for ${type} — Cloud Control coverage is opt-in per type`,
320
+ };
321
+ continue;
322
+ }
323
+ const identifier = stackResource.PhysicalResourceId;
324
+ if (!identifier) {
325
+ unobserved[entityName] = {
326
+ type,
327
+ reason: "read-failed",
328
+ detail: "the stack reports no physical id, so the live resource cannot be addressed",
329
+ };
330
+ continue;
331
+ }
332
+
333
+ const getResult = await rt.spawn(applyAwsEndpointArgv([
334
+ "aws", "cloudcontrol", "get-resource",
335
+ "--type-name", type,
336
+ "--identifier", identifier,
337
+ "--output", "json",
338
+ ], endpoint));
339
+
340
+ if (getResult.exitCode !== 0) {
341
+ unobserved[entityName] = {
342
+ type,
343
+ reason: classifyFailure(getResult.stderr),
344
+ detail: `cloudcontrol get-resource failed for ${type} "${identifier}": ${getResult.stderr.trim().split("\n")[0] ?? ""}`,
345
+ };
346
+ continue;
347
+ }
348
+
349
+ const parsed = parseCloudControlResource(getResult.stdout);
350
+ if (!parsed) {
351
+ unobserved[entityName] = {
352
+ type,
353
+ reason: "read-failed",
354
+ detail: `unparseable cloudcontrol get-resource output for ${type} "${identifier}"`,
355
+ };
356
+ continue;
357
+ }
358
+
359
+ // Cloud Control *does* return tags, so unlike the thin path this one can
360
+ // answer the ownership question (#1015's open note). A resource withheld by
361
+ // the filter is `filtered`, never absent: it exists, it just isn't chant's.
362
+ const owned = hasOwnershipMarker(parsed.properties);
363
+ if (options.owned && !owned) {
364
+ unobserved[entityName] = {
365
+ type,
366
+ reason: "filtered",
367
+ detail: `live resource carries no ${AWS_TAG_OWNERSHIP_KEYS.managedBy} tag`,
368
+ };
369
+ continue;
370
+ }
371
+
372
+ resources[entityName] = {
373
+ type,
374
+ physicalId: identifier,
375
+ properties: normalizeDeepProperties(parsed.properties, {
376
+ entityType: type,
377
+ side: "live",
378
+ hooks: awsDeepNormalizationHooks,
379
+ }),
380
+ };
381
+ }
382
+
383
+ return deepObservation(resources, unobserved);
384
+ }
@@ -63656,6 +63656,12 @@
63656
63656
  "pattern": "[0-9]*.[0-9]*.[0-9]*",
63657
63657
  "minLength": 0,
63658
63658
  "maxLength": 16
63659
+ },
63660
+ "CreationTime": {
63661
+ "format": "date-time"
63662
+ },
63663
+ "LastModifiedTime": {
63664
+ "format": "date-time"
63659
63665
  }
63660
63666
  },
63661
63667
  "createOnly": [
package/src/index.ts CHANGED
@@ -25,6 +25,18 @@ export type { LexiconOutput } from "@intentius/chant/lexicon-output";
25
25
  // Plugin
26
26
  export { awsPlugin } from "./plugin";
27
27
 
28
+ // Deep observation (#1015): the Cloud Control reader and the noise rules it
29
+ // shares with core's normalization pass.
30
+ export {
31
+ observeResourcesDeepAws,
32
+ awsDeepNormalizationHooks,
33
+ parseCloudControlResource,
34
+ hasOwnershipMarker,
35
+ DEEP_READABLE_TYPES,
36
+ AWS_READ_ONLY_NAMES,
37
+ AWS_SERVICE_DEFAULTS,
38
+ } from "./deep-observe";
39
+
28
40
  // Intrinsics
29
41
  export {
30
42
  Sub,
@@ -27,6 +27,9 @@ vi.mock("@intentius/chant/runtime-adapter", async (importOriginal) => {
27
27
  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
+ const { normalizeObservation } = await import("@intentius/chant/observation");
31
+ const { liveEvidenceFromChangeSet, reconcileStatus } = await import("@intentius/chant/lifecycle/status");
32
+ const { describeObservationConformance } = await import("@intentius/chant-test-utils");
30
33
 
31
34
  const liveTemplate = {
32
35
  AWSTemplateFormatVersion: "2010-09-09",
@@ -84,12 +87,18 @@ describe("aws lifecycle integration (#163)", () => {
84
87
  return Promise.resolve(ok(JSON.stringify({ Stacks: [{ Outputs: [] }] })));
85
88
  });
86
89
 
87
- const observedNow = await awsPlugin.describeResources!({
88
- environment: "prod",
89
- buildOutput: "",
90
- entityNames: ["MyBucket"],
91
- });
90
+ const { resources: observedNow } = normalizeObservation(
91
+ await awsPlugin.describeResources!({
92
+ environment: "prod",
93
+ buildOutput: "",
94
+ entityNames: ["MyBucket"],
95
+ entities: new Map(),
96
+ }),
97
+ );
92
98
  expect(observedNow.MyBucket?.type).toBe("AWS::S3::Bucket");
99
+ // Ownership verdicts are total (#1089): describe-stack-resources carries no
100
+ // tags, so the verdict is an explicit `unknown`, not a missing field.
101
+ expect(observedNow.MyBucket?.ownership).toBe("unknown");
93
102
 
94
103
  // Declared "MyQueue" is absent from live → create; live "MyBucket" is
95
104
  // undeclared and unmarked → adopt (never delete without ownership).
@@ -144,4 +153,129 @@ describe("aws lifecycle integration (#163)", () => {
144
153
  expect(obs).toBeNull();
145
154
  });
146
155
  });
156
+
157
+ /**
158
+ * The #1089 chain on the real plugin: a CloudFormation read that fails for
159
+ * any reason other than "stack does not exist" reports every declared entity
160
+ * NOT-OBSERVED, and that survives describe → plan → component status.
161
+ */
162
+ 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 });
164
+
165
+ const observed = normalizeObservation(
166
+ await awsPlugin.describeResources!({
167
+ environment: "prod",
168
+ buildOutput: "",
169
+ entityNames: ["MyBucket", "MyQueue"],
170
+ entities: new Map(),
171
+ }),
172
+ );
173
+ expect(observed.resources).toEqual({});
174
+ expect(observed.unobserved.MyBucket.reason).toBe("no-credentials");
175
+
176
+ const cs = buildChangeSet("prod", {
177
+ declared: new Set(["MyBucket", "MyQueue"]),
178
+ observedNow: observed.resources,
179
+ observedThen: undefined,
180
+ unobserved: observed.unobserved,
181
+ });
182
+ expect(cs.entries.map((e) => e.action)).toEqual(["unobserved", "unobserved"]);
183
+
184
+ const rows = reconcileStatus("prod", [
185
+ { component: "MyBucket", env: "prod", digest: "sha256:abc", gitSha: "g", runId: "r", timestamp: "2026-01-01T00:00:00Z", actor: "ci" },
186
+ ], { liveEvidence: liveEvidenceFromChangeSet(cs) });
187
+ expect(rows[0].reconciliation).toBe("unknown");
188
+ expect(rows[0].live).toBeUndefined();
189
+ expect(rows[0].unobserved?.reason).toBe("no-credentials");
190
+ });
191
+
192
+ 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 });
194
+
195
+ const observed = normalizeObservation(
196
+ await awsPlugin.describeResources!({
197
+ environment: "prod",
198
+ buildOutput: "",
199
+ entityNames: ["MyBucket"],
200
+ entities: new Map(),
201
+ }),
202
+ );
203
+ expect(observed.resources).toEqual({});
204
+ expect(observed.unobserved).toEqual({});
205
+
206
+ const cs = buildChangeSet("prod", {
207
+ declared: new Set(["MyBucket"]),
208
+ observedNow: observed.resources,
209
+ observedThen: undefined,
210
+ unobserved: observed.unobserved,
211
+ });
212
+ expect(cs.entries[0].action).toBe("create");
213
+ });
214
+ });
215
+
216
+ // The shared conformance suite (#1089).
217
+ describeObservationConformance({
218
+ lexicon: "aws",
219
+ scenarios: [
220
+ {
221
+ name: "a stack read that fails on credentials",
222
+ declared: ["MyBucket", "MyQueue"],
223
+ expectUnobserved: ["MyBucket", "MyQueue"],
224
+ run: () => {
225
+ spawnMock.mockResolvedValue({ stdout: "", stderr: "Unable to locate credentials", exitCode: 255 });
226
+ return awsPlugin.describeResources!({
227
+ environment: "prod",
228
+ buildOutput: "",
229
+ entityNames: ["MyBucket", "MyQueue"],
230
+ entities: new Map(),
231
+ });
232
+ },
233
+ },
234
+ {
235
+ name: "a stack that does not exist yet",
236
+ declared: ["MyBucket"],
237
+ expectAbsent: ["MyBucket"],
238
+ run: () => {
239
+ spawnMock.mockResolvedValue({ stdout: "", stderr: "ValidationError: Stack with id prod does not exist", exitCode: 255 });
240
+ return awsPlugin.describeResources!({
241
+ environment: "prod",
242
+ buildOutput: "",
243
+ entityNames: ["MyBucket"],
244
+ entities: new Map(),
245
+ });
246
+ },
247
+ },
248
+ {
249
+ name: "a healthy stack read",
250
+ declared: ["MyBucket"],
251
+ expectPresent: ["MyBucket"],
252
+ 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
+ ),
271
+ );
272
+ return awsPlugin.describeResources!({
273
+ environment: "prod",
274
+ buildOutput: "",
275
+ entityNames: ["MyBucket"],
276
+ entities: new Map(),
277
+ });
278
+ },
279
+ },
280
+ ],
147
281
  });