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(name: string): Stack { + return { StackName: name, CreationTime: new Date(0), StackStatus: "CREATE_COMPLETE" }; +} + +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")]; + }; + + const result = await describeStack( + "eu-west-1", + credentials, + "AgentCore-example-prod", + describe, + ); + + expect(result).toEqual(stack("AgentCore-example-prod")); + expect(names).toEqual(["AgentCore-example-prod"]); + }); + + 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", + }); + const describe: DescribeStacks = async () => { + throw notFound; + }; + + expect(await describeStack("us-east-1", credentials, "missing", describe)).toBeUndefined(); + }); + + test("throws on an empty successful response — distinct from not-found", async () => { + const describe: DescribeStacks = async () => []; + await expect(describeStack("us-east-1", credentials, "empty", describe)).rejects.toThrow( + /returned no stack/, + ); + }); + + test("propagates errors other than not-found", async () => { + const failure = Object.assign(new Error("User is not authorized"), { + name: "AccessDeniedException", + }); + const describe: DescribeStacks = async () => { + throw 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 new file mode 100644 index 000000000..a0a4bf88f --- /dev/null +++ b/src/core/project/backends/cdk/stackReader.ts @@ -0,0 +1,65 @@ +import type { Stack } from "@aws-sdk/client-cloudformation"; +import { MalformedServiceResponseError } from "../../../../errors/errors"; +import { isStackNotFound } from "./environment"; +import type { CdkCredentialProvider } from "./toolkit"; + +/** + * Runs a `DescribeStacks` for one stack, returning the matched stacks (or + * undefined). Injectable so callers/tests can supply the AWS call. + */ +export type DescribeStacks = (stackName: string) => Promise; + +// 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, +): 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(); + } + }; +} + +/** + * Describes a project's CloudFormation stack, returning it or undefined when it + * does not exist. Accepts a stack name or ARN. + * + * 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 async function describeStack( + region: string, + credentials: CdkCredentialProvider, + stackName: string, + describe: DescribeStacks = cloudFormationDescriber(region, credentials), +): Promise { + let stacks: Stack[] | undefined; + try { + 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; +}