Skip to content
Open
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
5 changes: 5 additions & 0 deletions src/core/factories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { BedrockAgentCoreControlClient } from "@aws-sdk/client-bedrock-agentcore
import { BedrockAgentCoreClient } from "@aws-sdk/client-bedrock-agentcore";
import { IAMClient } from "@aws-sdk/client-iam";
import { CloudWatchLogsClient } from "@aws-sdk/client-cloudwatch-logs";
import { CloudFormationClient } from "@aws-sdk/client-cloudformation";
import type {
CreateCloudFormationClient,
CreateControlClient,
CreateDataClient,
CreateIamClient,
Expand All @@ -24,3 +26,6 @@ export const createIamClient: CreateIamClient = (config) => new IAMClient({ ...c

export const createLogsClient: CreateLogsClient = (config) =>
new CloudWatchLogsClient({ ...config });

export const createCloudFormationClient: CreateCloudFormationClient = (config) =>
new CloudFormationClient({ ...config });
4 changes: 4 additions & 0 deletions src/core/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
AwsClients,
ClientConfig,
CoreFetch,
CreateCloudFormationClient,
CreateControlClient,
CreateDataClient,
CreateIamClient,
Expand All @@ -26,12 +27,14 @@ export type {
ClientConfig,
CoreFetch,
CreateControlClient,
CreateCloudFormationClient,
CreateDataClient,
CreateIamClient,
CreateLogsClient,
} from "./types";

type CoreClientConfig = {
createCloudFormationClient?: CreateCloudFormationClient;
createControlClient: CreateControlClient;
createDataClient: CreateDataClient;
createIamClient: CreateIamClient;
Expand Down Expand Up @@ -88,6 +91,7 @@ export class CoreClient implements AwsClients {

this.projectManager = new FsProjectManager({
logger: this.logger.child({ module: "projectManager" }),
createCloudFormationClient: config.createCloudFormationClient,
});
}

Expand Down
205 changes: 182 additions & 23 deletions src/core/project/backends/cdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ProjectSpecSchema } from "../../../projectSchemas/project";
import { createSilentLogger } from "../../../testing";
import { CdkBackend } from "./cdk";
import { DEPLOYED_STATE_RELATIVE_PATH } from "./cdk/deployedState";
import type { DeployBackendInput } from "./types";
import type { BootstrapState } from "./cdk/environment";
import type { CdkCredentialProvider, CdkOperation, CdkOutputs, CdkRunOptions } from "./cdk/toolkit";

Expand All @@ -17,6 +18,13 @@ const TARGET = {
region: "us-east-1",
} as const;

/** A template holding only what CDK adds itself, as an empty project synthesizes. */
const METADATA_ONLY = { CDKMetadata: { Type: "AWS::CDK::Metadata" } };

function deployInput(overrides: Partial<DeployBackendInput> = {}): DeployBackendInput {
return { target: TARGET, confirmTeardown: async () => false, ...overrides };
}

const tempDirectories: string[] = [];

afterEach(async () => {
Expand Down Expand Up @@ -51,24 +59,51 @@ async function project(withDependencies = true): Promise<Project> {
};
}

async function writeAssembly(project: Project, targetNames: string[]): Promise<void> {
type AssemblyOptions = {
/** Overrides the resources every stack's template declares. */
resources?: Record<string, unknown>;
};

async function writeAssembly(
project: Project,
targetNames: string[],
options: AssemblyOptions = {},
): Promise<void> {
const directory = assemblyDirectory(project);
await mkdir(directory, { recursive: true });
const stacks = targetNames.map((target, index) => ({
target,
id: `AgentCore-example-${target}-${index}`,
templateFile: `AgentCore-example-${target}-${index}.template.json`,
}));

await Promise.all(
stacks.map((stack) =>
writeFile(
join(directory, stack.templateFile),
JSON.stringify({
Resources: options.resources ?? { Runtime: { Type: "AWS::BedrockAgentCore::Runtime" } },
}),
),
),
);

await writeFile(
join(directory, "manifest.json"),
JSON.stringify({
version: "36.0.0",
artifacts: {
Tree: { type: "cdk:tree" },
...Object.fromEntries(
targetNames.flatMap((target, index) => [
[
`AgentCore-example-${target}-${index}`,
{
type: "aws:cloudformation:stack",
properties: { tags: { "agentcore:target-name": target } },
stacks.map((stack) => [
stack.id,
{
type: "aws:cloudformation:stack",
properties: {
templateFile: stack.templateFile,
tags: { "agentcore:target-name": stack.target },
},
],
},
]),
),
},
Expand All @@ -85,6 +120,8 @@ type HarnessOptions = {
template?: boolean;
failOperation?: CdkOperation["kind"];
bootstrapError?: Error;
/** Whether CloudFormation still holds the target's stack. Defaults to present. */
stackExists?: boolean;
};

function harness(options: HarnessOptions = {}) {
Expand All @@ -95,6 +132,7 @@ function harness(options: HarnessOptions = {}) {
const bootstrapCredentials: CdkCredentialProvider[] = [];
const accountRegions: string[] = [];
const bootstrapRegions: string[] = [];
const stackProbes: string[] = [];
let templateLoads = 0;
let templateCleanups = 0;
const credentials: CdkCredentialProvider = async () => ({
Expand Down Expand Up @@ -123,6 +161,10 @@ function harness(options: HarnessOptions = {}) {
if (options.bootstrapError) throw options.bootstrapError;
return options.bootstrap ?? { kind: "current", version: 30 };
},
stack: async (stackName) => {
stackProbes.push(stackName);
return options.stackExists ?? true;
},
cdk: async (operation, runOptions) => {
runs.push({ operation, options: runOptions });
if (operation.kind === options.failOperation) {
Expand Down Expand Up @@ -164,6 +206,7 @@ function harness(options: HarnessOptions = {}) {
credentialRegions,
credentials,
runs,
stackProbes,
templateLoads: () => templateLoads,
templateCleanups: () => templateCleanups,
};
Expand Down Expand Up @@ -225,7 +268,7 @@ describe("CdkBackend.deploy", () => {
await writeAssembly(input, [TARGET.name]);
const subject = harness({ outputs: { RuntimeArn: "arn:runtime" } });

const deployed = await collectDeploy(subject.backend.deploy(input, { target: TARGET }));
const deployed = await collectDeploy(subject.backend.deploy(input, deployInput()));

expect(deployed.events).toEqual([
{ message: `Verifying AWS account ${TARGET.account}` },
Expand Down Expand Up @@ -263,7 +306,7 @@ describe("CdkBackend.deploy", () => {
stackArn: "arn:aws:cloudformation:us-east-1:111122223333:stack/AgentCore-example-default/abc",
});

await collectDeploy(subject.backend.deploy(input, { target: TARGET }));
await collectDeploy(subject.backend.deploy(input, deployInput()));

const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH);
expect(JSON.parse(await Bun.file(statePath).text())).toEqual({
Expand All @@ -281,7 +324,7 @@ describe("CdkBackend.deploy", () => {
await writeAssembly(input, [TARGET.name]);
const subject = harness({ outputs: { RuntimeArn: "arn:runtime" }, omitStackArn: true });

await expect(collectDeploy(subject.backend.deploy(input, { target: TARGET }))).rejects.toThrow(
await expect(collectDeploy(subject.backend.deploy(input, deployInput()))).rejects.toThrow(
/without a stack ARN/,
);
expect(existsSync(join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH))).toBe(false);
Expand All @@ -294,14 +337,132 @@ describe("CdkBackend.deploy", () => {
await writeFile(statePath, "{ not valid json");
const subject = harness({ outputs: { RuntimeArn: "arn:runtime" } });

await expect(
collectDeploy(subject.backend.deploy(input, { target: TARGET })),
).rejects.toThrow();
await expect(collectDeploy(subject.backend.deploy(input, deployInput()))).rejects.toThrow();
// Validated before synth/bootstrap/deploy, so nothing ran against AWS.
expect(subject.commands).toEqual([]);
expect(subject.runs).toEqual([]);
});

test("will not remove a stack the user did not ask to remove", async () => {
const input = await project();
await writeAssembly(input, [TARGET.name], { resources: {} });
const subject = harness();

await expect(collectDeploy(subject.backend.deploy(input, deployInput()))).rejects.toThrow(
/would delete stack 'AgentCore-example-default-0'.*--yes/s,
);
// Nothing reached the Toolkit, so no stack was deleted and none bootstrapped.
expect(subject.runs).toEqual([]);
expect(subject.bootstrapRegions).toEqual([]);
});

test("requests confirmation with the exact teardown details", async () => {
const input = await project();
await writeAssembly(input, [TARGET.name], { resources: METADATA_ONLY });
const requests: unknown[] = [];
const subject = harness();

await expect(
collectDeploy(
subject.backend.deploy(
input,
deployInput({
confirmTeardown: async (request) => {
requests.push(request);
return false;
},
}),
),
),
).rejects.toThrow(/--yes/);

expect(requests).toEqual([
{
projectName: "example",
targetName: "default",
resourceDescription: "stack 'AgentCore-example-default-0' and every resource in it",
account: TARGET.account,
region: TARGET.region,
},
]);
expect(subject.runs).toEqual([]);
});

test("treats a template holding only CDK's own metadata as nothing to deploy", async () => {
// An empty project still synthesizes this one resource, so a check for an
// empty Resources block would let the teardown case through as a deploy.
const input = await project();
await writeAssembly(input, [TARGET.name], { resources: METADATA_ONLY });
const subject = harness();

await expect(collectDeploy(subject.backend.deploy(input, deployInput()))).rejects.toThrow(
/--yes/,
);
expect(subject.runs).toEqual([]);
});

test("removes the stack when the teardown is confirmed", async () => {
const input = await project();
await writeAssembly(input, [TARGET.name], { resources: METADATA_ONLY });
const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH);
await mkdir(dirname(statePath), { recursive: true });
await writeFile(
statePath,
JSON.stringify({
targets: {
default: { stackArn: "arn:stack:default" },
prod: { stackArn: "arn:stack:prod" },
},
}),
);
const subject = harness();
const deployed = await collectDeploy(
subject.backend.deploy(
input,
deployInput({
confirmTeardown: async () => true,
}),
),
);

expect(deployed.result).toEqual({ outputs: {}, tornDown: true });
expect(deployed.events).toContainEqual({
message: "Removing stack AgentCore-example-default-0",
});
// Destroyed explicitly, rather than by deploying an empty template and
// letting the Toolkit infer a deletion.
expect(subject.runs.map(({ operation }) => operation)).toEqual([
{ kind: "destroy", stackArtifactId: "AgentCore-example-default-0" },
]);
expect(subject.stackProbes).toEqual(["AgentCore-example-default-0"]);
expect(JSON.parse(await Bun.file(statePath).text())).toEqual({
targets: { prod: { stackArn: "arn:stack:prod" } },
});
});

test("says to add a resource when there is no stack to remove either", async () => {
const input = await project();
await writeAssembly(input, [TARGET.name], { resources: METADATA_ONLY });
const subject = harness({ stackExists: false });

await expect(
collectDeploy(
subject.backend.deploy(input, deployInput({ confirmTeardown: async () => true })),
),
).rejects.toThrow(/no stack .* exists .* to remove.*Add a resource/s);
expect(subject.runs).toEqual([]);
});

test("does not probe for a stack when there is something to deploy", async () => {
const input = await project();
await writeAssembly(input, [TARGET.name]);
const subject = harness();

await collectDeploy(subject.backend.deploy(input, deployInput()));

expect(subject.stackProbes).toEqual([]);
});

test.each([
["absent", { kind: "absent" } as const],
["outdated", { kind: "outdated", version: 29 } as const],
Expand All @@ -310,7 +471,7 @@ describe("CdkBackend.deploy", () => {
await writeAssembly(input, [TARGET.name]);
const subject = harness({ bootstrap });

const deployed = await collectDeploy(subject.backend.deploy(input, { target: TARGET }));
const deployed = await collectDeploy(subject.backend.deploy(input, deployInput()));

expect(subject.runs.map(({ operation }) => operation)).toEqual([
{
Expand All @@ -328,7 +489,7 @@ describe("CdkBackend.deploy", () => {
const input = await project();
const subject = harness({ account: "999900001111" });

await expect(collectDeploy(subject.backend.deploy(input, { target: TARGET }))).rejects.toThrow(
await expect(collectDeploy(subject.backend.deploy(input, deployInput()))).rejects.toThrow(
/expects AWS account 111122223333.*999900001111/,
);
expect(subject.commands).toEqual([]);
Expand All @@ -341,7 +502,7 @@ describe("CdkBackend.deploy", () => {
await writeAssembly(input, ["other"]);
const subject = harness();

await expect(collectDeploy(subject.backend.deploy(input, { target: TARGET }))).rejects.toThrow(
await expect(collectDeploy(subject.backend.deploy(input, deployInput()))).rejects.toThrow(
/no stack for deployment target 'default'/,
);
expect(subject.bootstrapRegions).toEqual([]);
Expand All @@ -353,7 +514,7 @@ describe("CdkBackend.deploy", () => {
await writeAssembly(input, [TARGET.name, TARGET.name]);
const subject = harness();

await expect(collectDeploy(subject.backend.deploy(input, { target: TARGET }))).rejects.toThrow(
await expect(collectDeploy(subject.backend.deploy(input, deployInput()))).rejects.toThrow(
/2 stacks for deployment target 'default'/,
);
expect(subject.bootstrapRegions).toEqual([]);
Expand All @@ -366,9 +527,7 @@ describe("CdkBackend.deploy", () => {
const failure = new Error("CDKToolkit is UPDATE_IN_PROGRESS");
const subject = harness({ bootstrapError: failure });

await expect(collectDeploy(subject.backend.deploy(input, { target: TARGET }))).rejects.toBe(
failure,
);
await expect(collectDeploy(subject.backend.deploy(input, deployInput()))).rejects.toBe(failure);
expect(subject.runs).toEqual([]);
});

Expand All @@ -377,7 +536,7 @@ describe("CdkBackend.deploy", () => {
await writeAssembly(input, [TARGET.name]);
const subject = harness({ bootstrap: { kind: "absent" }, template: true });

await collectDeploy(subject.backend.deploy(input, { target: TARGET }));
await collectDeploy(subject.backend.deploy(input, deployInput()));

expect(subject.runs[0]?.operation).toEqual({
kind: "bootstrap",
Expand All @@ -397,7 +556,7 @@ describe("CdkBackend.deploy", () => {
failOperation: "bootstrap",
});

await expect(collectDeploy(subject.backend.deploy(input, { target: TARGET }))).rejects.toThrow(
await expect(collectDeploy(subject.backend.deploy(input, deployInput()))).rejects.toThrow(
"bootstrap failed",
);
expect(subject.templateCleanups()).toBe(1);
Expand Down
Loading
Loading