diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index f152b528b..7cfefd11c 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -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. */ @@ -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`, }, ], }, @@ -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, }, ]); @@ -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); + }); }); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 516ce0aa9..b853d3fbc 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -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, @@ -51,17 +51,12 @@ import { describeStack } from "./cdk/stackReader"; type StackDescriber = typeof describeStack; -function findDeployedResourceId( - stack: Stack, - input: Pick, -): 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 = { @@ -266,10 +261,11 @@ export class CdkBackend implements ProjectBackend { project: Project, input: ResolveDeployedResourcesBackendInput, ): Promise { - 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.`, @@ -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 }] : []; }); } diff --git a/src/core/project/backends/types.ts b/src/core/project/backends/types.ts index dccb11da8..67c0af850 100644 --- a/src/core/project/backends/types.ts +++ b/src/core/project/backends/types.ts @@ -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. */ diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 4f1b016fb..144705073 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -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"; @@ -906,7 +903,10 @@ export class FsProjectManager implements ProjectManager { input: ResolveDeployedResourcesInput, ): Promise { 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 }; } diff --git a/src/handlers/project/invoke/screen.tsx b/src/handlers/project/invoke/screen.tsx index 00ba1386a..411a84ab8 100644 --- a/src/handlers/project/invoke/screen.tsx +++ b/src/handlers/project/invoke/screen.tsx @@ -83,24 +83,29 @@ export function ProjectInvokePickerScreen({ ctx, core }: ScreenProps) { const rows = useMemo( () => - (deployed?.resources ?? []).map((resource) => { - if (resource.resourceType === "runtime") { - const configured = project?.spec.runtimes.find(({ name }) => name === resource.name); + (deployed?.resources ?? []) + .filter( + (r): r is typeof r & { resourceType: "runtime" | "harness" } => + r.resourceType === "runtime" || r.resourceType === "harness", + ) + .map((resource) => { + if (resource.resourceType === "runtime") { + const configured = project?.spec.runtimes.find(({ name }) => name === resource.name); + return { + ...resource, + type: "Runtime" as const, + protocol: configured?.protocol ?? "HTTP", + source: configured?.codeLocation ?? "-", + }; + } + const configured = project?.spec.harnesses.find(({ name }) => name === resource.name); return { ...resource, - type: "Runtime" as const, - protocol: configured?.protocol ?? "HTTP", - source: configured?.codeLocation ?? "-", + type: "Harness" as const, + protocol: "-", + source: configured?.path ?? "-", }; - } - const configured = project?.spec.harnesses.find(({ name }) => name === resource.name); - return { - ...resource, - type: "Harness" as const, - protocol: "-", - source: configured?.path ?? "-", - }; - }), + }), [deployed, project], ); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 5b0b2e7b2..8c9b12e9f 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -143,12 +143,30 @@ export type ResolveDeployedResourceInput = { export type ResolveDeployedResourcesInput = { target: string; + allowMissing?: boolean; }; +export type DeployableResource = + | "runtime" + | "harness" + | "memory" + | "knowledge-base" + | "credential" + | "evaluator" + | "online-eval" + | "gateway" + | "gateway-target" + | "policy-engine" + | "policy" + | "config-bundle" + | "payment" + | "capacity-provider"; + export type ResolvedDeployedResource = { - resourceType: ProjectInvokableResource; + resourceType: DeployableResource; name: string; id: string; + parent?: string; target: AwsDeploymentTarget; };