Skip to content
Closed
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
151 changes: 145 additions & 6 deletions src/core/project/backends/cdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const TARGET = {
} as const;
const STACK_ARN =
"arn:aws:cloudformation:us-east-1:111122223333:stack/AgentCore-example-default/abc";
const ARN = `arn:aws:bedrock-agentcore:${TARGET.region}:${TARGET.account}`;
const json = new FsReadWriteJson({ logger: createSilentLogger() });

/** A template holding only what CDK adds itself, as an empty project synthesizes. */
Expand Down Expand Up @@ -614,12 +615,12 @@ describe("CdkBackend.resolveDeployedResources", () => {
StackStatus: "CREATE_COMPLETE",
Outputs: [
{
ExportName: "AgentCore-example-default-checkout-agent-RuntimeId",
OutputValue: "checkout_agent-AbCdEf1234",
ExportName: "AgentCore-example-default-checkout-agent-RuntimeArn",
OutputValue: `${ARN}:runtime/checkout_agent-AbCdEf1234`,
},
{
ExportName: "AgentCore-example-default-Harness-support-agent-Id",
OutputValue: "support_agent-AbCdEf1234",
ExportName: "AgentCore-example-default-Harness-support-agent-Arn",
OutputValue: `${ARN}:harness/support_agent-AbCdEf1234`,
},
],
},
Expand All @@ -631,13 +632,13 @@ describe("CdkBackend.resolveDeployedResources", () => {
{
resourceType: "runtime",
name: "checkout_agent",
id: "checkout_agent-AbCdEf1234",
id: `${ARN}:runtime/checkout_agent-AbCdEf1234`,
target: TARGET,
},
{
resourceType: "harness",
name: "support_agent",
id: "support_agent-AbCdEf1234",
id: `${ARN}:harness/support_agent-AbCdEf1234`,
target: TARGET,
},
]);
Expand Down Expand Up @@ -705,4 +706,142 @@ describe("CdkBackend.resolveDeployedResources", () => {
).rejects.toThrow(/expects AWS account 111122223333.*999900001111/s);
expect(subject.stackReads).toEqual([]);
});

test("resolves every deployed resource type: exports, payment OutputKey, credential from state, nested parents, underscores", async () => {
const input = await project();
input.spec = {
...input.spec,
runtimes: [{ name: "web" }],
harnesses: [{ name: "chat" }],
memories: [{ name: "user_mem" }], // underscore must map to -user-mem-
knowledgeBases: [{ name: "kb" }],
credentials: [{ name: "cred" }], // id comes from deployed-state, not outputs
evaluators: [{ name: "ev" }],
onlineEvalConfigs: [{ name: "oe" }],
agentCoreGateways: [{ name: "gw", targets: [{ name: "tgt" }] }],
policyEngines: [{ name: "pe", policies: [{ name: "pol" }] }],
configBundles: [{ name: "cb" }],
payments: [{ name: "pay" }],
} as unknown as typeof input.spec;
await updateTargetState(json, input.rootPath, TARGET.name, {
stackArn: STACK_ARN,
resources: { credentials: { cred: { credentialProviderArn: "arn:aws:cred/cred" } } },
});
const S = "AgentCore-example-default";
const out = (ExportName: string, OutputValue: string) => ({ ExportName, OutputValue });
const subject = harness({
describedStack: {
StackName: S,
CreationTime: new Date(0),
StackStatus: "CREATE_COMPLETE",
Outputs: [
out(`${S}-web-RuntimeArn`, `${ARN}:runtime/web-1`),
out(`${S}-Harness-chat-Arn`, `${ARN}:harness/chat-1`),
out(`${S}-Memory-user-mem-Arn`, `${ARN}:memory/mem-1`),
out(`${S}-KnowledgeBase-kb-Arn`, `${ARN}:knowledge-base/kb-1`),
out(`${S}-Evaluator-ev-Arn`, `${ARN}:evaluator/ev-1`),
out(`${S}-OnlineEval-oe-Arn`, `${ARN}:online-eval/oe-1`),
out(`${S}-Gateway-gw-Arn`, `${ARN}:gateway/gw-1`),
out(`${S}-GatewayTarget-tgt-Id`, "tgt-1"),
out(`${S}-PolicyEngine-pe-Arn`, `${ARN}:policy-engine/pe-1`),
out(`${S}-Policy-pe-pol-Arn`, `${ARN}:policy/pol-1`),
out(`${S}-ConfigBundle-cb-Arn`, `${ARN}:config-bundle/cb-1`),
{ OutputKey: "PaymentpayManagerArn", OutputValue: `${ARN}:payment-manager/pay-1` },
],
},
});

const resources = await subject.backend.resolveDeployedResources(input, { target: TARGET });

expect(resources).toEqual([
{ resourceType: "runtime", name: "web", id: `${ARN}:runtime/web-1`, target: TARGET },
{ resourceType: "harness", name: "chat", id: `${ARN}:harness/chat-1`, target: TARGET },
{ resourceType: "memory", name: "user_mem", id: `${ARN}:memory/mem-1`, target: TARGET },
{
resourceType: "knowledge-base",
name: "kb",
id: `${ARN}:knowledge-base/kb-1`,
target: TARGET,
},
{ resourceType: "credential", name: "cred", id: "arn:aws:cred/cred", target: TARGET },
{ resourceType: "evaluator", name: "ev", id: `${ARN}:evaluator/ev-1`, target: TARGET },
{ resourceType: "online-eval", name: "oe", id: `${ARN}:online-eval/oe-1`, target: TARGET },
{ resourceType: "gateway", name: "gw", id: `${ARN}:gateway/gw-1`, target: TARGET },
{ resourceType: "gateway-target", name: "tgt", parent: "gw", id: "tgt-1", target: TARGET },
{
resourceType: "policy-engine",
name: "pe",
id: `${ARN}:policy-engine/pe-1`,
target: TARGET,
},
{
resourceType: "policy",
name: "pol",
parent: "pe",
id: `${ARN}:policy/pol-1`,
target: TARGET,
},
{
resourceType: "config-bundle",
name: "cb",
id: `${ARN}:config-bundle/cb-1`,
target: TARGET,
},
{ resourceType: "payment", name: "pay", id: `${ARN}:payment-manager/pay-1`, target: TARGET },
]);
expect(subject.stackReads).toHaveLength(1);
});

test("omits a declared non-runtime resource that has no deployed output", async () => {
const input = await project();
input.spec = {
...input.spec,
runtimes: [],
harnesses: [],
memories: [{ name: "mem" }],
} as unknown as typeof input.spec;
await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN });
const subject = harness({
describedStack: {
StackName: "AgentCore-example-default",
CreationTime: new Date(0),
StackStatus: "CREATE_COMPLETE",
Outputs: [],
},
});

await expect(
subject.backend.resolveDeployedResources(input, { target: TARGET }),
).resolves.toEqual([]);
});

test("allowMissing returns [] instead of throwing when the target has no stack ARN", async () => {
const input = await project();
const subject = harness({ describedStack: null });

await expect(
subject.backend.resolveDeployedResources(input, { target: TARGET, allowMissing: true }),
).resolves.toEqual([]);
expect(subject.stackReads).toEqual([]);
});

test("allowMissing returns [] instead of throwing when the recorded stack is gone", async () => {
const input = await project();
await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN });
const subject = harness({ describedStack: null });

await expect(
subject.backend.resolveDeployedResources(input, { target: TARGET, allowMissing: true }),
).resolves.toEqual([]);
});

test("allowMissing does not swallow a wrong-account error", async () => {
const input = await project();
await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN });
const subject = harness({ account: "999900001111" });

await expect(
subject.backend.resolveDeployedResources(input, { target: TARGET, allowMissing: true }),
).rejects.toThrow(/expects AWS account 111122223333.*999900001111/s);
});
});
113 changes: 94 additions & 19 deletions src/core/project/backends/cdk.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import type { Stack } from "@aws-sdk/client-cloudformation";
import { MalformedServiceResponseError, ProjectStateError } from "../../../errors/errors";
import type {
DeployableResource,
DeployResult,
Project,
ProjectEvent,
Expand Down Expand Up @@ -51,17 +51,12 @@ import { describeStack } from "./cdk/stackReader";

type StackDescriber = typeof describeStack;

function findDeployedResourceId(
stack: Stack,
input: Pick<ResolvedDeployedResource, "resourceType" | "name">,
): string | undefined {
if (!stack.StackName) return undefined;
const exportResourceName = input.name.replaceAll("_", "-");
const exportName =
input.resourceType === "runtime"
? `${stack.StackName}-${exportResourceName}-RuntimeId`
: `${stack.StackName}-Harness-${exportResourceName}-Id`;
return stack.Outputs?.find((output) => output.ExportName === exportName)?.OutputValue;
function cfnExportName(...parts: string[]): string {
return parts.map((part) => part.replace(/_/g, "-").replace(/[^a-zA-Z0-9:-]/g, "")).join("-");
}

function toCdkId(name: string): string {
return name.replace(/_/g, "");
}

export type CdkBackendConfig = {
Expand Down Expand Up @@ -266,10 +261,11 @@ export class CdkBackend implements ProjectBackend {
project: Project,
input: ResolveDeployedResourcesBackendInput,
): Promise<ResolvedDeployedResource[]> {
const { target } = input;
const { target, allowMissing } = input;
const deployedState = await readDeployedState(this.json, project.rootPath);
const stackArn = deployedState.targets[target.name]?.stackArn;
if (!stackArn) {
if (allowMissing) return [];
throw new ProjectStateError(
`Project '${project.name}' is not deployed to target '${target.name}'. ` +
`Run 'agentcore project deploy --target ${target.name}' first.`,
Expand All @@ -279,19 +275,98 @@ export class CdkBackend implements ProjectBackend {
const credentials = await this.credentialsForTarget(target);
const stack = await this.describeStack(target.region, credentials, stackArn);
if (!stack) {
if (allowMissing) return [];
throw new ProjectStateError(
`Project '${project.name}' is not deployed to target '${target.name}'. ` +
`Run 'agentcore project deploy --target ${target.name}' first.`,
);
}

const resources = [
...project.spec.runtimes.map(({ name }) => ({ resourceType: "runtime" as const, name })),
...project.spec.harnesses.map(({ name }) => ({ resourceType: "harness" as const, name })),
const { spec } = project;
const credentialArns = deployedState.targets[target.name]?.resources?.credentials ?? {};

type Declared = { resourceType: DeployableResource; name: string; parent?: string };

const byExportName = (...parts: string[]) => {
if (!stack.StackName) return undefined;
const want = cfnExportName(stack.StackName, ...parts);
return stack.Outputs?.find((output) => output.ExportName === want)?.OutputValue;
};

const idOf = ({ resourceType, name, parent }: Declared): string | undefined => {
switch (resourceType) {
case "runtime":
return byExportName(name, "RuntimeArn");
case "harness":
return byExportName("Harness", name, "Arn");
case "memory":
return byExportName("Memory", name, "Arn");
case "knowledge-base":
return byExportName("KnowledgeBase", name, "Arn");
case "evaluator":
return byExportName("Evaluator", name, "Arn");
case "online-eval":
return byExportName("OnlineEval", name, "Arn");
case "gateway":
return byExportName("Gateway", name, "Arn");
case "gateway-target":
return byExportName("GatewayTarget", name, "Id");
case "policy-engine":
return byExportName("PolicyEngine", name, "Arn");
case "policy":
return byExportName("Policy", parent ?? "", name, "Arn");
case "config-bundle":
return byExportName("ConfigBundle", name, "Arn");
case "capacity-provider":
return byExportName("CapacityProvider", name, "Arn");
case "payment":
return stack.Outputs?.find(
(output) => output.OutputKey === `Payment${toCdkId(name)}ManagerArn`,
)?.OutputValue;
case "credential":
return credentialArns[name]?.credentialProviderArn;
default: {
const unhandled: never = resourceType;
return unhandled;
}
}
};

const declared: Declared[] = [
...spec.runtimes.map(({ name }) => ({ resourceType: "runtime" as const, name })),
...spec.harnesses.map(({ name }) => ({ resourceType: "harness" as const, name })),
...spec.memories.map(({ name }) => ({ resourceType: "memory" as const, name })),
...spec.knowledgeBases.map(({ name }) => ({ resourceType: "knowledge-base" as const, name })),
...spec.credentials.map(({ name }) => ({ resourceType: "credential" as const, name })),
...spec.evaluators.map(({ name }) => ({ resourceType: "evaluator" as const, name })),
...spec.onlineEvalConfigs.map(({ name }) => ({ resourceType: "online-eval" as const, name })),
...spec.agentCoreGateways.flatMap((gw) => [
{ resourceType: "gateway" as const, name: gw.name },
...(gw.targets ?? []).map(({ name }) => ({
resourceType: "gateway-target" as const,
name,
parent: gw.name,
})),
]),
...(spec.unassignedTargets ?? []).map(({ name }) => ({
resourceType: "gateway-target" as const,
name,
})),
...spec.policyEngines.flatMap((engine) => [
{ resourceType: "policy-engine" as const, name: engine.name },
...(engine.policies ?? []).map(({ name }) => ({
resourceType: "policy" as const,
name,
parent: engine.name,
})),
]),
...spec.configBundles.map(({ name }) => ({ resourceType: "config-bundle" as const, name })),
...(spec.payments ?? []).map(({ name }) => ({ resourceType: "payment" as const, name })),
];
return resources.flatMap((resource) => {
const id = findDeployedResourceId(stack, resource);
return id ? [{ ...resource, id, target }] : [];

return declared.flatMap((r) => {
const id = idOf(r);
return id ? [{ ...r, id, target }] : [];
});
}

Expand Down
1 change: 1 addition & 0 deletions src/core/project/backends/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type DeployBackendInput = {

export type ResolveDeployedResourcesBackendInput = {
target: AwsDeploymentTarget;
allowMissing?: boolean;
};

/** Builds the deployable artifacts owned by a project's selected backend. */
Expand Down
8 changes: 4 additions & 4 deletions src/core/project/manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -889,9 +889,6 @@ export class FsProjectManager implements ProjectManager {
const resource = resolved.resources.find(
({ resourceType, name }) => resourceType === input.resourceType && name === input.name,
);
// The declared target wins over the copy on the item: the manager resolved it
// from aws-targets.json, and both invoke handlers pin the AWS region off this
// value, so trusting a backend's echo would let it redirect the call.
if (resource) return { ...resource, target: resolved.target };

const label = input.resourceType === "runtime" ? "Runtime" : "Harness";
Expand All @@ -906,7 +903,10 @@ export class FsProjectManager implements ProjectManager {
input: ResolveDeployedResourcesInput,
): Promise<ResolvedDeployedResources> {
const target = await this.resolveExistingTarget(project, input.target);
const resources = await this.backendFor(project).resolveDeployedResources(project, { target });
const resources = await this.backendFor(project).resolveDeployedResources(project, {
target,
allowMissing: input.allowMissing,
});
return { resources, target };
}

Expand Down
Loading
Loading