From ad8ca2a3930b15967c0cb422583cb2cda8a4d2d0 Mon Sep 17 00:00:00 2001 From: gitikavj Date: Wed, 26 Aug 2026 16:32:28 +0000 Subject: [PATCH 1/3] feat: read project stack state live from CloudFormation Adds a reader that describes a project's CloudFormation stack and classifies its lifecycle into not-deployed / in-progress / failed / ready, returning the stack outputs (resource ARNs/IDs) only when settled and successful. This is the source-of-truth side of the deploy-state refactor: resource details come from CloudFormation on demand rather than a local snapshot that can go stale. Generalizes the existing bootstrap not-found helper to isStackNotFound and reuses it. No command is wired to this yet; project status consumes it in a follow-up. --- .../project/backends/cdk/environment.test.ts | 4 +- src/core/project/backends/cdk/environment.ts | 5 +- .../project/backends/cdk/stackReader.test.ts | 128 ++++++++++++++++++ src/core/project/backends/cdk/stackReader.ts | 97 +++++++++++++ 4 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 src/core/project/backends/cdk/stackReader.test.ts create mode 100644 src/core/project/backends/cdk/stackReader.ts diff --git a/src/core/project/backends/cdk/environment.test.ts b/src/core/project/backends/cdk/environment.test.ts index 64d2c83a9..fddbbaa63 100644 --- a/src/core/project/backends/cdk/environment.test.ts +++ b/src/core/project/backends/cdk/environment.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { Stack } from "@aws-sdk/client-cloudformation"; -import { isBootstrapStackNotFound, probeBootstrap, readBootstrapState } from "./environment"; +import { isStackNotFound, probeBootstrap, readBootstrapState } from "./environment"; import type { CdkCredentialProvider } from "./toolkit"; const credentials: CdkCredentialProvider = async () => ({ @@ -69,7 +69,7 @@ describe("probeBootstrap", () => { name: "ValidationError", }); - expect(isBootstrapStackNotFound(notFound)).toBe(true); + expect(isStackNotFound(notFound)).toBe(true); expect( await probeBootstrap("us-east-1", credentials, async () => { throw notFound; diff --git a/src/core/project/backends/cdk/environment.ts b/src/core/project/backends/cdk/environment.ts index 8169e125c..55a6667b9 100644 --- a/src/core/project/backends/cdk/environment.ts +++ b/src/core/project/backends/cdk/environment.ts @@ -60,7 +60,8 @@ export function readBootstrapState(stacks?: Stack[]): Exclude ({ + accessKeyId: "access-key", + secretAccessKey: "secret-key", +}); + +function stack(status: Stack["StackStatus"], outputs?: Stack["Outputs"]): Stack { + return { + StackName: "AgentCore-example-default", + CreationTime: new Date(0), + StackStatus: status, + ...(outputs && { Outputs: outputs }), + }; +} + +describe("classifyStack", () => { + test("treats an absent stack as not deployed", () => { + expect(classifyStack(undefined)).toEqual({ kind: "not-deployed" }); + }); + + test("treats a deleted stack (described by ARN) as not deployed", () => { + expect(classifyStack(stack("DELETE_COMPLETE"))).toEqual({ kind: "not-deployed" }); + }); + + test.each([ + "CREATE_IN_PROGRESS", + "UPDATE_IN_PROGRESS", + "DELETE_IN_PROGRESS", + "REVIEW_IN_PROGRESS", + "UPDATE_ROLLBACK_IN_PROGRESS", + ] as const)("reports %s as in-progress", (status) => { + expect(classifyStack(stack(status))).toEqual({ kind: "in-progress", status }); + }); + + test.each([ + "ROLLBACK_COMPLETE", + "CREATE_FAILED", + "ROLLBACK_FAILED", + "UPDATE_FAILED", + "UPDATE_ROLLBACK_FAILED", + "DELETE_FAILED", + ] as const)("reports %s as failed", (status) => { + expect(classifyStack(stack(status))).toEqual({ kind: "failed", status }); + }); + + test.each(["CREATE_COMPLETE", "UPDATE_COMPLETE", "UPDATE_ROLLBACK_COMPLETE"] as const)( + "reports %s as ready with its outputs", + (status) => { + const result = classifyStack( + stack(status, [ + { OutputKey: "RuntimeArn", OutputValue: "arn:runtime" }, + { OutputKey: "MemoryId", OutputValue: "mem-123" }, + ]), + ); + expect(result).toEqual({ + kind: "ready", + status, + outputs: { RuntimeArn: "arn:runtime", MemoryId: "mem-123" }, + }); + }, + ); + + test("returns empty outputs when a ready stack declares none", () => { + expect(classifyStack(stack("CREATE_COMPLETE"))).toEqual({ + kind: "ready", + status: "CREATE_COMPLETE", + outputs: {}, + }); + }); + + test("skips partial output entries", () => { + const result = classifyStack( + stack("CREATE_COMPLETE", [ + { OutputKey: "RuntimeArn", OutputValue: "arn:runtime" }, + { OutputKey: "NoValue" }, + { OutputValue: "no-key" }, + ]), + ); + expect(result).toEqual({ + kind: "ready", + status: "CREATE_COMPLETE", + outputs: { RuntimeArn: "arn:runtime" }, + }); + }); +}); + +describe("readStackState", () => { + test("classifies the stack the reader returns, passing region + credentials + name through", async () => { + const calls: { region: string; credentials: CdkCredentialProvider; stackName: string }[] = []; + const read: StackReader = async (region, creds, stackName) => { + calls.push({ region, credentials: creds, stackName }); + return stack("CREATE_COMPLETE", [{ OutputKey: "RuntimeArn", OutputValue: "arn:runtime" }]); + }; + + const result = await readStackState("eu-west-1", credentials, "AgentCore-example-prod", read); + + expect(result).toEqual({ + kind: "ready", + status: "CREATE_COMPLETE", + outputs: { RuntimeArn: "arn:runtime" }, + }); + expect(calls).toEqual([ + { region: "eu-west-1", credentials, stackName: "AgentCore-example-prod" }, + ]); + }); + + test("maps a missing stack to not-deployed", async () => { + const read: StackReader = async () => undefined; + expect(await readStackState("us-east-1", credentials, "missing", read)).toEqual({ + kind: "not-deployed", + }); + }); + + test("propagates non-not-found errors from the reader", async () => { + const failure = Object.assign(new Error("User is not authorized"), { + name: "AccessDeniedException", + }); + const read: StackReader = async () => { + throw failure; + }; + + await expect(readStackState("us-east-1", credentials, "denied", read)).rejects.toBe(failure); + }); +}); diff --git a/src/core/project/backends/cdk/stackReader.ts b/src/core/project/backends/cdk/stackReader.ts new file mode 100644 index 000000000..81d3784ad --- /dev/null +++ b/src/core/project/backends/cdk/stackReader.ts @@ -0,0 +1,97 @@ +import type { Stack } from "@aws-sdk/client-cloudformation"; +import { isStackNotFound } from "./environment"; +import type { CdkCredentialProvider } from "./toolkit"; + +/** + * Live state of a project's CloudFormation stack. + * + * Deployed state now stores only the stack ARN; the resource ARNs/IDs are read + * back from CloudFormation on demand so they can never go stale. This is the + * shape callers (e.g. `project status`) consume. + */ +export type StackState = + | { kind: "not-deployed" } + | { kind: "in-progress"; status: string } + | { kind: "failed"; status: string } + | { kind: "ready"; status: string; outputs: Record }; + +// Terminal statuses where the stack's resources exist and its outputs are +// meaningful. UPDATE_ROLLBACK_COMPLETE is included: an update failed but rolled +// back to the previous working state, so the outputs still describe live +// resources. ROLLBACK_COMPLETE (a failed *create*) is not — it leaves no usable +// resources — so it falls through to "failed". +const READY_STATUSES = new Set([ + "CREATE_COMPLETE", + "UPDATE_COMPLETE", + "UPDATE_ROLLBACK_COMPLETE", + "IMPORT_COMPLETE", + "IMPORT_ROLLBACK_COMPLETE", +]); + +/** Reads a single stack by name, returning undefined when it does not exist. */ +export type StackReader = ( + region: string, + credentials: CdkCredentialProvider, + stackName: string, +) => Promise; + +function outputsToRecord(outputs: Stack["Outputs"]): Record { + const record: Record = {}; + for (const { OutputKey, OutputValue } of outputs ?? []) { + if (OutputKey !== undefined && OutputValue !== undefined) record[OutputKey] = OutputValue; + } + return record; +} + +/** + * Maps a described stack (or its absence) onto {@link StackState}. + * + * CloudFormation's lifecycle is the substance here: an in-flight operation has + * no settled outputs, a rolled-back create is broken, and a deleted stack is + * effectively not deployed. Only a settled, successful status yields outputs. + */ +export function classifyStack(stack: Stack | undefined): StackState { + if (!stack) return { kind: "not-deployed" }; + + const status = stack.StackStatus; + // A stack described by ARN after deletion comes back as DELETE_COMPLETE; + // treat it the same as never-deployed. + if (!status || status === "DELETE_COMPLETE") return { kind: "not-deployed" }; + if (status.endsWith("_IN_PROGRESS")) return { kind: "in-progress", status }; + if (READY_STATUSES.has(status)) { + return { kind: "ready", status, outputs: outputsToRecord(stack.Outputs) }; + } + return { kind: "failed", status }; +} + +const describeStack: StackReader = async (region, credentials, stackName) => { + const { CloudFormationClient, DescribeStacksCommand } = + await import("@aws-sdk/client-cloudformation"); + const client = new CloudFormationClient({ credentials, region }); + try { + const response = await client.send(new DescribeStacksCommand({ StackName: stackName })); + return response.Stacks?.[0]; + } catch (error) { + // Not-found is a thrown ValidationError, not an empty result. Every other + // error (auth, throttling, malformed request) is real and propagates. + if (isStackNotFound(error)) return undefined; + throw error; + } finally { + client.destroy(); + } +}; + +/** + * Reads a project's stack live from CloudFormation and classifies it. + * + * `read` is injectable for tests; production uses a real {@link DescribeStacksCommand}. + * Accepts either a stack name or a stack ARN as `stackName`. + */ +export async function readStackState( + region: string, + credentials: CdkCredentialProvider, + stackName: string, + read: StackReader = describeStack, +): Promise { + return classifyStack(await read(region, credentials, stackName)); +} From 38fa2bdc50de52f254f272dee0a65ed61d03da14 Mon Sep 17 00:00:00 2001 From: gitikavj Date: Wed, 26 Aug 2026 23:59:44 +0000 Subject: [PATCH 2/3] refactor: trim reader to the raw DescribeStacks API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review, drop the stack-status classification (not-deployed / in-progress / failed / ready) and the StackState shape — that's a project status interface decision and belongs with whoever builds it, not baked in ahead of the consumer. Keep just describeStack: a DescribeStacks call that returns the stack or undefined when it doesn't exist. The CloudFormation call is injectable at the function seam (lazy-loaded like environment.ts), so it's unit-tested without a real client; wiring it through CoreClient/the project manager is left to the consumer. --- .../project/backends/cdk/stackReader.test.ts | 128 ++++-------------- src/core/project/backends/cdk/stackReader.ts | 109 +++++---------- 2 files changed, 62 insertions(+), 175 deletions(-) diff --git a/src/core/project/backends/cdk/stackReader.test.ts b/src/core/project/backends/cdk/stackReader.test.ts index d9b2e0bed..4f58d30ca 100644 --- a/src/core/project/backends/cdk/stackReader.test.ts +++ b/src/core/project/backends/cdk/stackReader.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { Stack } from "@aws-sdk/client-cloudformation"; -import { classifyStack, readStackState, type StackReader } from "./stackReader"; +import { describeStack, type DescribeStacks } from "./stackReader"; import type { CdkCredentialProvider } from "./toolkit"; const credentials: CdkCredentialProvider = async () => ({ @@ -8,121 +8,53 @@ const credentials: CdkCredentialProvider = async () => ({ secretAccessKey: "secret-key", }); -function stack(status: Stack["StackStatus"], outputs?: Stack["Outputs"]): Stack { - return { - StackName: "AgentCore-example-default", - CreationTime: new Date(0), - StackStatus: status, - ...(outputs && { Outputs: outputs }), - }; +function stack(name: string): Stack { + return { StackName: name, CreationTime: new Date(0), StackStatus: "CREATE_COMPLETE" }; } -describe("classifyStack", () => { - test("treats an absent stack as not deployed", () => { - expect(classifyStack(undefined)).toEqual({ kind: "not-deployed" }); - }); - - test("treats a deleted stack (described by ARN) as not deployed", () => { - expect(classifyStack(stack("DELETE_COMPLETE"))).toEqual({ kind: "not-deployed" }); - }); +describe("describeStack", () => { + test("returns the described stack, passing the stack name to the describer", async () => { + const names: string[] = []; + const describe: DescribeStacks = async (stackName) => { + names.push(stackName); + return [stack("AgentCore-example-prod")]; + }; - test.each([ - "CREATE_IN_PROGRESS", - "UPDATE_IN_PROGRESS", - "DELETE_IN_PROGRESS", - "REVIEW_IN_PROGRESS", - "UPDATE_ROLLBACK_IN_PROGRESS", - ] as const)("reports %s as in-progress", (status) => { - expect(classifyStack(stack(status))).toEqual({ kind: "in-progress", status }); - }); + const result = await describeStack( + "eu-west-1", + credentials, + "AgentCore-example-prod", + describe, + ); - test.each([ - "ROLLBACK_COMPLETE", - "CREATE_FAILED", - "ROLLBACK_FAILED", - "UPDATE_FAILED", - "UPDATE_ROLLBACK_FAILED", - "DELETE_FAILED", - ] as const)("reports %s as failed", (status) => { - expect(classifyStack(stack(status))).toEqual({ kind: "failed", status }); + expect(result).toEqual(stack("AgentCore-example-prod")); + expect(names).toEqual(["AgentCore-example-prod"]); }); - test.each(["CREATE_COMPLETE", "UPDATE_COMPLETE", "UPDATE_ROLLBACK_COMPLETE"] as const)( - "reports %s as ready with its outputs", - (status) => { - const result = classifyStack( - stack(status, [ - { OutputKey: "RuntimeArn", OutputValue: "arn:runtime" }, - { OutputKey: "MemoryId", OutputValue: "mem-123" }, - ]), - ); - expect(result).toEqual({ - kind: "ready", - status, - outputs: { RuntimeArn: "arn:runtime", MemoryId: "mem-123" }, - }); - }, - ); - - test("returns empty outputs when a ready stack declares none", () => { - expect(classifyStack(stack("CREATE_COMPLETE"))).toEqual({ - kind: "ready", - status: "CREATE_COMPLETE", - outputs: {}, + test("returns undefined when CloudFormation reports the stack does not exist", async () => { + const notFound = Object.assign(new Error("Stack with id missing does not exist"), { + name: "ValidationError", }); - }); - - test("skips partial output entries", () => { - const result = classifyStack( - stack("CREATE_COMPLETE", [ - { OutputKey: "RuntimeArn", OutputValue: "arn:runtime" }, - { OutputKey: "NoValue" }, - { OutputValue: "no-key" }, - ]), - ); - expect(result).toEqual({ - kind: "ready", - status: "CREATE_COMPLETE", - outputs: { RuntimeArn: "arn:runtime" }, - }); - }); -}); - -describe("readStackState", () => { - test("classifies the stack the reader returns, passing region + credentials + name through", async () => { - const calls: { region: string; credentials: CdkCredentialProvider; stackName: string }[] = []; - const read: StackReader = async (region, creds, stackName) => { - calls.push({ region, credentials: creds, stackName }); - return stack("CREATE_COMPLETE", [{ OutputKey: "RuntimeArn", OutputValue: "arn:runtime" }]); + const describe: DescribeStacks = async () => { + throw notFound; }; - const result = await readStackState("eu-west-1", credentials, "AgentCore-example-prod", read); - - expect(result).toEqual({ - kind: "ready", - status: "CREATE_COMPLETE", - outputs: { RuntimeArn: "arn:runtime" }, - }); - expect(calls).toEqual([ - { region: "eu-west-1", credentials, stackName: "AgentCore-example-prod" }, - ]); + expect(await describeStack("us-east-1", credentials, "missing", describe)).toBeUndefined(); }); - test("maps a missing stack to not-deployed", async () => { - const read: StackReader = async () => undefined; - expect(await readStackState("us-east-1", credentials, "missing", read)).toEqual({ - kind: "not-deployed", - }); + test("returns undefined when the describer yields no stacks", async () => { + const describe: DescribeStacks = async () => []; + expect(await describeStack("us-east-1", credentials, "empty", describe)).toBeUndefined(); }); - test("propagates non-not-found errors from the reader", async () => { + test("propagates errors other than not-found", async () => { const failure = Object.assign(new Error("User is not authorized"), { name: "AccessDeniedException", }); - const read: StackReader = async () => { + const describe: DescribeStacks = async () => { throw failure; }; - await expect(readStackState("us-east-1", credentials, "denied", read)).rejects.toBe(failure); + await expect(describeStack("us-east-1", credentials, "denied", describe)).rejects.toBe(failure); }); }); diff --git a/src/core/project/backends/cdk/stackReader.ts b/src/core/project/backends/cdk/stackReader.ts index 81d3784ad..ec6a53074 100644 --- a/src/core/project/backends/cdk/stackReader.ts +++ b/src/core/project/backends/cdk/stackReader.ts @@ -3,95 +3,50 @@ import { isStackNotFound } from "./environment"; import type { CdkCredentialProvider } from "./toolkit"; /** - * Live state of a project's CloudFormation stack. - * - * Deployed state now stores only the stack ARN; the resource ARNs/IDs are read - * back from CloudFormation on demand so they can never go stale. This is the - * shape callers (e.g. `project status`) consume. + * Runs a `DescribeStacks` for one stack, returning the matched stacks (or + * undefined). Injectable so callers/tests can supply the AWS call. */ -export type StackState = - | { kind: "not-deployed" } - | { kind: "in-progress"; status: string } - | { kind: "failed"; status: string } - | { kind: "ready"; status: string; outputs: Record }; - -// Terminal statuses where the stack's resources exist and its outputs are -// meaningful. UPDATE_ROLLBACK_COMPLETE is included: an update failed but rolled -// back to the previous working state, so the outputs still describe live -// resources. ROLLBACK_COMPLETE (a failed *create*) is not — it leaves no usable -// resources — so it falls through to "failed". -const READY_STATUSES = new Set([ - "CREATE_COMPLETE", - "UPDATE_COMPLETE", - "UPDATE_ROLLBACK_COMPLETE", - "IMPORT_COMPLETE", - "IMPORT_ROLLBACK_COMPLETE", -]); +export type DescribeStacks = (stackName: string) => Promise; -/** Reads a single stack by name, returning undefined when it does not exist. */ -export type StackReader = ( +// Real describer: lazily imports the SDK (kept off the CLI startup path, like +// environment.ts) and scopes a client to the target region + credentials. +function cloudFormationDescriber( region: string, credentials: CdkCredentialProvider, - stackName: string, -) => Promise; - -function outputsToRecord(outputs: Stack["Outputs"]): Record { - const record: Record = {}; - for (const { OutputKey, OutputValue } of outputs ?? []) { - if (OutputKey !== undefined && OutputValue !== undefined) record[OutputKey] = OutputValue; - } - return record; +): DescribeStacks { + return async (stackName) => { + const { CloudFormationClient, DescribeStacksCommand } = + await import("@aws-sdk/client-cloudformation"); + const client = new CloudFormationClient({ credentials, region }); + try { + const response = await client.send(new DescribeStacksCommand({ StackName: stackName })); + return response.Stacks; + } finally { + client.destroy(); + } + }; } /** - * Maps a described stack (or its absence) onto {@link StackState}. + * Describes a project's CloudFormation stack, returning it or undefined when it + * does not exist. Accepts a stack name or ARN. * - * CloudFormation's lifecycle is the substance here: an in-flight operation has - * no settled outputs, a rolled-back create is broken, and a deleted stack is - * effectively not deployed. Only a settled, successful status yields outputs. + * This is only the read: interpreting the stack's status and outputs (deployed + * vs. in-progress vs. failed, which outputs to surface) is left to the caller — + * e.g. `project status` — which owns that shape. */ -export function classifyStack(stack: Stack | undefined): StackState { - if (!stack) return { kind: "not-deployed" }; - - const status = stack.StackStatus; - // A stack described by ARN after deletion comes back as DELETE_COMPLETE; - // treat it the same as never-deployed. - if (!status || status === "DELETE_COMPLETE") return { kind: "not-deployed" }; - if (status.endsWith("_IN_PROGRESS")) return { kind: "in-progress", status }; - if (READY_STATUSES.has(status)) { - return { kind: "ready", status, outputs: outputsToRecord(stack.Outputs) }; - } - return { kind: "failed", status }; -} - -const describeStack: StackReader = async (region, credentials, stackName) => { - const { CloudFormationClient, DescribeStacksCommand } = - await import("@aws-sdk/client-cloudformation"); - const client = new CloudFormationClient({ credentials, region }); +export async function describeStack( + region: string, + credentials: CdkCredentialProvider, + stackName: string, + describe: DescribeStacks = cloudFormationDescriber(region, credentials), +): Promise { try { - const response = await client.send(new DescribeStacksCommand({ StackName: stackName })); - return response.Stacks?.[0]; - } catch (error) { - // Not-found is a thrown ValidationError, not an empty result. Every other + // Not-found is a thrown ValidationError, not an empty result; every other // error (auth, throttling, malformed request) is real and propagates. + return (await describe(stackName))?.[0]; + } catch (error) { if (isStackNotFound(error)) return undefined; throw error; - } finally { - client.destroy(); } -}; - -/** - * Reads a project's stack live from CloudFormation and classifies it. - * - * `read` is injectable for tests; production uses a real {@link DescribeStacksCommand}. - * Accepts either a stack name or a stack ARN as `stackName`. - */ -export async function readStackState( - region: string, - credentials: CdkCredentialProvider, - stackName: string, - read: StackReader = describeStack, -): Promise { - return classifyStack(await read(region, credentials, stackName)); } From c3f9896f66f6f3baafe79577d80c01d6bd922c50 Mon Sep 17 00:00:00 2001 From: gitikavj Date: Thu, 27 Aug 2026 06:03:09 +0000 Subject: [PATCH 3/3] fix: throw on an empty successful DescribeStacks response A missing stack is reported by a thrown ValidationError, so that stays the only not-found (undefined) signal. A successful response with no stack is malformed, not not-found; return undefined there would misreport a service problem as 'not deployed'. Throw MalformedServiceResponseError instead, matching the bootstrap reader. --- .../project/backends/cdk/stackReader.test.ts | 6 ++++-- src/core/project/backends/cdk/stackReader.ts | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/core/project/backends/cdk/stackReader.test.ts b/src/core/project/backends/cdk/stackReader.test.ts index 4f58d30ca..0fde6d28e 100644 --- a/src/core/project/backends/cdk/stackReader.test.ts +++ b/src/core/project/backends/cdk/stackReader.test.ts @@ -42,9 +42,11 @@ describe("describeStack", () => { expect(await describeStack("us-east-1", credentials, "missing", describe)).toBeUndefined(); }); - test("returns undefined when the describer yields no stacks", async () => { + test("throws on an empty successful response — distinct from not-found", async () => { const describe: DescribeStacks = async () => []; - expect(await describeStack("us-east-1", credentials, "empty", describe)).toBeUndefined(); + await expect(describeStack("us-east-1", credentials, "empty", describe)).rejects.toThrow( + /returned no stack/, + ); }); test("propagates errors other than not-found", async () => { diff --git a/src/core/project/backends/cdk/stackReader.ts b/src/core/project/backends/cdk/stackReader.ts index ec6a53074..a0a4bf88f 100644 --- a/src/core/project/backends/cdk/stackReader.ts +++ b/src/core/project/backends/cdk/stackReader.ts @@ -1,4 +1,5 @@ import type { Stack } from "@aws-sdk/client-cloudformation"; +import { MalformedServiceResponseError } from "../../../../errors/errors"; import { isStackNotFound } from "./environment"; import type { CdkCredentialProvider } from "./toolkit"; @@ -41,12 +42,24 @@ export async function describeStack( stackName: string, describe: DescribeStacks = cloudFormationDescriber(region, credentials), ): Promise { + let stacks: Stack[] | undefined; try { - // Not-found is a thrown ValidationError, not an empty result; every other - // error (auth, throttling, malformed request) is real and propagates. - return (await describe(stackName))?.[0]; + stacks = await describe(stackName); } catch (error) { + // A missing stack is reported by a thrown ValidationError, not an empty + // result, so this is the only "not deployed" signal. Every other error + // (auth, throttling, malformed request) is real and propagates. if (isStackNotFound(error)) return undefined; throw error; } + + const stack = stacks?.[0]; + if (!stack) { + // A *successful* DescribeStacks with no stack is malformed, not not-found; + // returning undefined would misreport a service problem as "not deployed". + throw new MalformedServiceResponseError( + `CloudFormation returned no stack after describing '${stackName}'`, + ); + } + return stack; }