Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/core/project/backends/cdk/environment.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => ({
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions src/core/project/backends/cdk/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ export function readBootstrapState(stacks?: Stack[]): Exclude<BootstrapState, {
: { kind: "outdated", version };
}

export function isBootstrapStackNotFound(error: unknown): boolean {
/** True when CloudFormation reports the stack does not exist (its "not found" signal is a thrown ValidationError, not an empty result). */
export function isStackNotFound(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const candidate = error as { name?: unknown; message?: unknown };
return (
Expand Down Expand Up @@ -92,7 +93,7 @@ export async function probeBootstrap(
try {
return readBootstrapState(await read(region, credentials));
} catch (error) {
if (isBootstrapStackNotFound(error)) return { kind: "absent" };
if (isStackNotFound(error)) return { kind: "absent" };
throw error;
}
}
Expand Down
62 changes: 62 additions & 0 deletions src/core/project/backends/cdk/stackReader.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { describe, expect, test } from "bun:test";
import type { Stack } from "@aws-sdk/client-cloudformation";
import { describeStack, type DescribeStacks } from "./stackReader";
import type { CdkCredentialProvider } from "./toolkit";

const credentials: CdkCredentialProvider = async () => ({
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);
});
});
65 changes: 65 additions & 0 deletions src/core/project/backends/cdk/stackReader.ts
Original file line number Diff line number Diff line change
@@ -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<Stack[] | undefined>;

// 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<Stack | undefined> {
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;
}
Loading