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
171 changes: 171 additions & 0 deletions src/core/project/backends/cdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1542,3 +1542,174 @@ describe("CdkBackend.resolveProjectResources", () => {
).toEqual([["gateway", "gw", ["owned", "second"]]]);
});
});

describe("CdkBackend.deploy and the Box gateway", () => {
const MEMORY = { name: "recall", eventExpiryDuration: 30, strategies: [] };
const runtime = (name: string) => ({
name,
build: "CodeZip",
entrypoint: "main.py",
codeLocation: `app/${name}`,
runtimeVersion: "PYTHON_3_12",
});
const RUNTIMES = [runtime("agent"), runtime("my_agent")];
const RECORDED = ["example-default-agent-box", "example-default-my-agent-box"];
const LOST_MARKER_ERROR =
`agentcore.json has no "projectType": "box", so this deploy would delete the Box ` +
`gateways 'example-default-agent-box', 'example-default-my-agent-box'. Add "projectType": ` +
`"box" back to agentcore.json, or re-run with --yes to delete the gateways.`;

async function boxProject(spec: Record<string, unknown>): Promise<Project> {
const input = await project();
return { ...input, spec: ProjectSpecSchema.parse({ name: "example", version: 2, ...spec }) };
}

async function targetState(input: Project): Promise<Record<string, unknown>> {
const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH);
return JSON.parse(await Bun.file(statePath).text()).targets.default;
}

async function recordBoxGateways(input: Project, names: string[] = RECORDED): Promise<void> {
await updateTargetState(json, input.rootPath, TARGET.name, {
stackArn: STACK_ARN,
resources: { boxGateways: names.map((name) => ({ name })) },
});
}

test("records the Box gateway of each runtime after the deploy", async () => {
const input = await boxProject({ projectType: "box", runtimes: RUNTIMES });
await writeAssembly(input, [TARGET.name]);

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

expect((await targetState(input)).resources).toEqual({
credentials: {},
boxGateways: RECORDED.map((name) => ({ name })),
});
});

test("records no Box gateway for a Box project with no runtime", async () => {
const input = await boxProject({ projectType: "box", memories: [MEMORY] });
await writeAssembly(input, [TARGET.name]);

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

expect((await targetState(input)).resources).toEqual({ credentials: {} });
});

test("records the v0 name, with no target segment, for a migrated project", async () => {
const input = await boxProject({
projectType: "box",
migratedFrom: "v1",
runtimes: [runtime("agent")],
});
await writeAssembly(input, [TARGET.name]);

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

expect((await targetState(input)).resources).toMatchObject({
boxGateways: [{ name: "example-agent-box" }],
});
});

test("stops a deploy that would delete a recorded Box gateway, before any change", async () => {
const input = await boxProject({ memories: [MEMORY] });
await recordBoxGateways(input);
await writeAssembly(input, [TARGET.name]);
const requests: unknown[] = [];
const subject = harness();

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

expect(requests).toEqual([
{ projectName: "example", targetName: "default", gatewayNames: RECORDED },
]);
expect(subject.commands).toEqual([]);
expect(subject.runs).toEqual([]);
expect((await targetState(input)).resources).toEqual({
boxGateways: RECORDED.map((name) => ({ name })),
});
});

test("stops the deploy when no confirmation handler is given, and names one gateway", async () => {
const input = await boxProject({ memories: [MEMORY] });
await recordBoxGateways(input, ["example-default-agent-box"]);
await writeAssembly(input, [TARGET.name]);

await expect(collectDeploy(harness().backend.deploy(input, deployInput()))).rejects.toThrow(
`agentcore.json has no "projectType": "box", so this deploy would delete the Box gateway ` +
`'example-default-agent-box'. Add "projectType": "box" back to agentcore.json, or re-run ` +
`with --yes to delete the gateway.`,
);
});

test("deploys when the deletion is confirmed, and clears the record", async () => {
const input = await boxProject({ memories: [MEMORY] });
await recordBoxGateways(input);
await writeAssembly(input, [TARGET.name]);
const subject = harness();

await collectDeploy(
subject.backend.deploy(input, deployInput({ confirmBoxGatewayDeletion: async () => true })),
);

expect(subject.runs.map(({ operation }) => operation.kind)).toEqual(["deploy"]);
expect((await targetState(input)).resources).toEqual({ credentials: {} });
});

test("leaves a project with no resources to the teardown confirmation alone", async () => {
// The state after `agentcore remove all`: no projectType and no resources.
const input = await boxProject({});
await recordBoxGateways(input);
await writeAssembly(input, [TARGET.name], { resources: METADATA_ONLY });
let asked = false;

await expect(
collectDeploy(
harness().backend.deploy(
input,
deployInput({
confirmBoxGatewayDeletion: async () => {
asked = true;
return false;
},
}),
),
),
).rejects.toThrow(/would delete stack 'AgentCore-example-default-0'.*--yes/s);
expect(asked).toBe(false);
});

test("never asks about a Box gateway in a project that has never had one", async () => {
const input = await boxProject({ memories: [MEMORY] });
await writeAssembly(input, [TARGET.name]);
let asked = false;

await collectDeploy(
harness().backend.deploy(
input,
deployInput({
confirmBoxGatewayDeletion: async () => {
asked = true;
return true;
},
}),
),
);

expect(asked).toBe(false);
expect((await targetState(input)).resources).toEqual({ credentials: {} });
});
});
48 changes: 47 additions & 1 deletion src/core/project/backends/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ import {
import { withOutputEvents } from "../events";
import type { Logger } from "../../../logging";
import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets";
import {
boxGatewayAwsName,
declaresNoResources,
describeBoxGateways,
isBoxProject,
} from "../../../projectSchemas/project";
import type {
DeployBackendInput,
ProjectBackend,
Expand Down Expand Up @@ -269,6 +275,7 @@ export class CdkBackend implements ProjectBackend {
// before any AWS mutation, so a local problem never leaves credentials
// provisioned or the stack ARN unrecorded.
await this.ensureCdkDependencies(project);
await this.assertBoxGatewayKept(project, input);

// Read before provisioning rewrites the credentials map: it is the only record
// of what this target provisioned, so it is the only way to find a provider whose
Expand Down Expand Up @@ -370,9 +377,19 @@ export class CdkBackend implements ProjectBackend {
// Persist the deployed stack's ARN so later commands read live resource state
// from CloudFormation. Merged per target, so deploying one target never drops
// another's recorded state.
// The Box gateways are recorded so that a later deploy can see a lost project type. A deploy
// without it clears the record: the stack no longer has the gateways.
await updateTargetState(this.json, project.rootPath, target.name, {
stackArn,
resources: { bmaSession: undefined },
resources: {
bmaSession: undefined,
boxGateways:
isBoxProject(project.spec) && project.spec.runtimes.length > 0
? project.spec.runtimes.map((runtime) => ({
name: boxGatewayAwsName(project.spec, target.name, runtime.name),
}))
: undefined,
},
});

const bmaRuntimes = project.spec.runtimes.filter(
Expand Down Expand Up @@ -422,6 +439,35 @@ export class CdkBackend implements ProjectBackend {
return { outputs };
}

/**
* Stops a deploy that would delete the Box gateways because agentcore.json lost its
* "projectType": "box", unless the user confirms. A spec with no resources skips this: that is the state
* after `agentcore remove all`, and the teardown confirmation already covers every resource.
*/
private async assertBoxGatewayKept(project: Project, input: DeployBackendInput): Promise<void> {
if (isBoxProject(project.spec) || declaresNoResources(project.spec)) return;
const { target } = input;
const state = await readDeployedState(this.json, project.rootPath);
const gatewayNames = (state.targets[target.name]?.resources?.boxGateways ?? []).map(
({ name }) => name,
);
if (gatewayNames.length === 0) return;
const confirmed = await input.confirmBoxGatewayDeletion?.({
projectName: project.name,
targetName: target.name,
gatewayNames,
});
if (!confirmed) {
const plural = gatewayNames.length === 1 ? "gateway" : "gateways";
throw new ProjectStateError(
`agentcore.json has no "projectType": "box", so this deploy would delete ` +
`${describeBoxGateways(gatewayNames)}. Add "projectType": "box" back to agentcore.json, ` +
`or re-run with --yes to delete the ${plural}.`,
{ meta: { gatewayNames } },
);
}
}

/**
* Removes the target's stack, for a deploy of a project that declares nothing
* to deploy.
Expand Down
6 changes: 6 additions & 0 deletions src/core/project/backends/cdk/deployedState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ const ResourceStateSchema = z
runtimeArns: z.array(z.string()),
})
.optional(),
/**
* The Box gateways (one for each runtime) that the last deploy of a Box project created. A
* deploy reads them to find out that agentcore.json lost its "projectType": "box", which would
* delete the gateways.
*/
boxGateways: z.array(z.object({ name: z.string() }).passthrough()).optional(),
// The legacy deployer recorded the CloudFormation stack name
// here. New deploys record the stack ARN instead. Keep this
// field so projects can be correctly inspected after upgrading.
Expand Down
3 changes: 3 additions & 0 deletions src/core/project/backends/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
ResolvedDeployedResource,
ResolvedProjectResource,
TeardownConfirmationHandler,
BoxGatewayDeletionHandler,
} from "../../../handlers/project/types";
import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets";

Expand All @@ -13,6 +14,8 @@ export type DeployBackendInput = {
target: AwsDeploymentTarget;
/** Requests approval after synthesis identifies a teardown. */
confirmTeardown: TeardownConfirmationHandler;
/** Requests approval for a deploy that would delete the Box gateway; absent means declined. */
confirmBoxGatewayDeletion?: BoxGatewayDeletionHandler;
/** Whether to enable CloudWatch Transaction Search on deploy (global config). */
transactionSearch?: boolean;
/** Leaves the CDK bootstrap stack alone for accounts bootstrapped outside the CLI. */
Expand Down
Loading
Loading