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
145 changes: 145 additions & 0 deletions src/core/project/backends/cdk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type {
} from "./cdk/credentials";
import { DEPLOYED_STATE_RELATIVE_PATH, updateTargetState } from "./cdk/deployedState";
import type { DeployBackendInput } from "./types";
import type { ResolvedProjectResource } from "../../../handlers/project/types";
import type { BootstrapState } from "./cdk/environment";
import type { CdkCredentialProvider, CdkOperation, CdkOutputs, CdkRunOptions } from "./cdk/toolkit";

Expand Down Expand Up @@ -933,3 +934,147 @@ describe("CdkBackend.resolveDeployedResources", () => {
expect(subject.stackReads).toEqual([]);
});
});

describe("CdkBackend.resolveProjectResources", () => {
const out = (ExportName: string, OutputValue: string) => ({ ExportName, OutputValue });
const key = (OutputKey: string, OutputValue: string) => ({ OutputKey, OutputValue });
const S = "AgentCore-example-default";

test("resolves every declared type: exports, payment OutputKeys, 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" }], // an underscore becomes a dash in the export
knowledgeBases: [{ name: "kb" }],
credentials: [{ name: "cred" }], // read from deployed state, not the stack
evaluators: [{ name: "ev" }],
onlineEvalConfigs: [{ name: "oe" }],
agentCoreGateways: [{ name: "gw", targets: [{ name: "tgt" }] }],
policyEngines: [{ name: "pe", policies: [{ name: "pol" }] }],
configBundles: [{ name: "cb" }],
payments: [{ name: "pay", connectors: [{ name: "wallet_one" }] }],
} 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 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:kb/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"),
key("PaymentpayManagerArn", "arn:payment-manager/pay-1"),
key("PaymentpaywalletoneConnectorId", "conn-1"),
],
},
});

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

// [type, name, arn, [children...]] so a child under the wrong owner fails here
const shape = (resource: ResolvedProjectResource): unknown => [
resource.resourceType,
resource.name,
resource.deploymentState === "deployed" ? resource.id : undefined,
...(resource.children ? [resource.children.map(shape)] : []),
];
expect(resources.map(shape)).toEqual([
["runtime", "web", "arn:runtime/web-1"],
["harness", "chat", "arn:harness/chat-1"],
["memory", "user_mem", "arn:memory/mem-1"],
["knowledge-base", "kb", "arn:kb/kb-1"],
["credential", "cred", "arn:aws:cred/cred"],
["evaluator", "ev", "arn:evaluator/ev-1"],
["online-eval", "oe", "arn:online-eval/oe-1"],
["gateway", "gw", "arn:gateway/gw-1", [["gateway-target", "tgt", "tgt-1"]]],
["policy-engine", "pe", "arn:policy-engine/pe-1", [["policy", "pol", "arn:policy/pol-1"]]],
["config-bundle", "cb", "arn:config-bundle/cb-1"],
[
"payment-manager",
"pay",
"arn:payment-manager/pay-1",
[["payment-connector", "wallet_one", "conn-1"]],
],
]);
expect(subject.stackReads).toHaveLength(1);
});

test("reports a declared resource the stack does not publish as local-only", async () => {
const input = await project();
input.spec = {
...input.spec,
memories: [{ name: "shortTerm" }, { name: "longTerm" }],
} as unknown as typeof input.spec;
await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN });
const subject = harness({
describedStack: {
StackName: S,
CreationTime: new Date(0),
StackStatus: "CREATE_COMPLETE",
Outputs: [out(`${S}-Memory-shortTerm-Arn`, "arn:memory/short-1")],
},
});

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

expect(resources).toEqual([
{
resourceType: "memory",
name: "shortTerm",
deploymentState: "deployed",
id: "arn:memory/short-1",
},
{ resourceType: "memory", name: "longTerm", deploymentState: "local-only" },
]);
});

test("reports local-only without reading AWS when the target has no recorded stack", async () => {
const input = await project();
input.spec = {
...input.spec,
memories: [{ name: "mem" }],
} as unknown as typeof input.spec;
const subject = harness({ describedStack: null });

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

expect(resources).toEqual([
{ resourceType: "memory", name: "mem", deploymentState: "local-only" },
]);
expect(subject.stackReads).toEqual([]);
expect(subject.accountCredentials).toEqual([]);
});

test("nests a gateway's targets under the gateway", async () => {
const input = await project();
input.spec = {
...input.spec,
agentCoreGateways: [{ name: "gw", targets: [{ name: "owned" }, { name: "second" }] }],
} as unknown as typeof input.spec;
const subject = harness({ describedStack: null });

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

expect(
resources.map(({ resourceType, name, children }) => [
resourceType,
name,
children?.map((child) => child.name),
]),
).toEqual([["gateway", "gw", ["owned", "second"]]]);
});
});
142 changes: 142 additions & 0 deletions src/core/project/backends/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import type {
DeployResult,
Project,
ProjectEvent,
DeployableResource,
ResolvedDeployedResource,
ResolvedProjectResource,
} from "../../../handlers/project/types";
import {
createLineSplitter,
Expand All @@ -23,6 +25,7 @@ import type {
DeployBackendInput,
ProjectBackend,
ResolveDeployedResourcesBackendInput,
ResolveProjectResourcesBackendInput,
} from "./types";
import { createCloudFormationClient } from "../../factories";
import type { CreateCloudFormationClient } from "../../types";
Expand Down Expand Up @@ -69,6 +72,11 @@ type StackDescriber = typeof describeStack;
*/
const MAX_ERROR_OUTPUT_LINES = 20;

// Payment logical ids drop underscores the same way the template's toCdkId does.
function cdkId(name: string): string {
return name.replace(/_/g, "");
}

function findDeployedResourceId(
stack: Stack,
input: Pick<ResolvedDeployedResource, "resourceType" | "name">,
Expand Down Expand Up @@ -403,6 +411,140 @@ export class CdkBackend implements ProjectBackend {
});
}

public async resolveProjectResources(
project: Project,
input: ResolveProjectResourcesBackendInput,
): Promise<ResolvedProjectResource[]> {
const { target } = input;
const { spec } = project;
const deployedState = await readDeployedState(this.json, project.rootPath);
const recorded = deployedState.targets[target.name];

// No recorded stack means nothing was ever deployed to this target, which
// every resource below reports as local-only.
const stack = recorded?.stackArn
? await this.describeStack(
target.region,
await this.credentialsForTarget(target),
recorded.stackArn,
)
: undefined;

const byExportName = (...parts: string[]) => {
if (!stack?.StackName) return undefined;
// The CDK library builds every ExportName through this shared helper
// https://github.com/aws/agentcore-l3-cdk-constructs/blob/main/src/cdk/logical-ids.ts#L84
const want = [stack.StackName, ...parts]
.map((part) => part.replace(/_/g, "-").replace(/[^a-zA-Z0-9:-]/g, ""))
.join("-");
return stack.Outputs?.find((output) => output.ExportName === want)?.OutputValue;
};

const byOutputKey = (key: string) =>
stack?.Outputs?.find((output) => output.OutputKey === key)?.OutputValue;

const arnOf = (
resourceType: DeployableResource,
name: string,
owner?: string,
): 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":
// The L3 exports an id for targets and never an ARN
return byExportName("GatewayTarget", name, "Id");
case "policy":
// ExportName: <StackName>-Policy-<engineName>-<policyName>-Arn
return byExportName("Policy", owner ?? "", name, "Arn");
case "policy-engine":
return byExportName("PolicyEngine", name, "Arn");
case "config-bundle":
return byExportName("ConfigBundle", name, "Arn");
case "payment-manager":
// The CLI template writes the payment outputs. It does not set an
// exportName on them. Therefore match on the OutputKey. The template
// makes that key from the manager name.
// See src/assets/cdk/lib/cdk-stack.ts
return byOutputKey(`Payment${cdkId(name)}ManagerArn`);
case "payment-connector":
// The same template does not set an exportName. Therefore match on the
// OutputKey. The template writes only a connector id, and never an ARN.
return byOutputKey(`Payment${cdkId(owner ?? "")}${cdkId(name)}ConnectorId`);
case "credential":
// The CLI creates credential providers imperatively. The stack does not
// contain them. Therefore read the ARN from the deployed state file.
return recorded?.resources?.credentials?.[name]?.credentialProviderArn;
default: {
const unhandled: never = resourceType;
return unhandled;
}
}
};

// Resolves one declared resource, and keeps its children with it. The spec
// already says which resource owns which, so status never has to pair them
// up again by name. `owner` only builds the export name, so it is not
// reported.
const resolve = (
resourceType: DeployableResource,
name: string,
options: { owner?: string; children?: ResolvedProjectResource[] } = {},
): ResolvedProjectResource => {
const id = arnOf(resourceType, name, options.owner);
return {
resourceType,
name,
...(options.children?.length ? { children: options.children } : {}),
...(id ? { deploymentState: "deployed", id } : { deploymentState: "local-only" }),
};
};

return [
...spec.runtimes.map(({ name }) => resolve("runtime", name)),
...spec.harnesses.map(({ name }) => resolve("harness", name)),
...spec.memories.map(({ name }) => resolve("memory", name)),
...spec.knowledgeBases.map(({ name }) => resolve("knowledge-base", name)),
...spec.credentials.map(({ name }) => resolve("credential", name)),
...spec.evaluators.map(({ name }) => resolve("evaluator", name)),
...spec.onlineEvalConfigs.map(({ name }) => resolve("online-eval", name)),
...spec.agentCoreGateways.map((gateway) =>
resolve("gateway", gateway.name, {
children: (gateway.targets ?? []).map(({ name }) =>
resolve("gateway-target", name, { owner: gateway.name }),
),
}),
),
...spec.policyEngines.map((engine) =>
resolve("policy-engine", engine.name, {
children: (engine.policies ?? []).map(({ name }) =>
resolve("policy", name, { owner: engine.name }),
),
}),
),
...spec.configBundles.map(({ name }) => resolve("config-bundle", name)),
...(spec.payments ?? []).map((manager) =>
resolve("payment-manager", manager.name, {
children: (manager.connectors ?? []).map(({ name }) =>
resolve("payment-connector", name, { owner: manager.name }),
),
}),
),
];
}

private async credentialsForTarget(target: AwsDeploymentTarget) {
const credentials = await this.resolveCredentials(target.region);
const account = await this.resolveAccount(target.region, credentials);
Expand Down
16 changes: 16 additions & 0 deletions src/core/project/backends/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
Project,
ProjectEvent,
ResolvedDeployedResource,
ResolvedProjectResource,
TeardownConfirmationHandler,
} from "../../../handlers/project/types";
import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets";
Expand All @@ -18,6 +19,10 @@ export type ResolveDeployedResourcesBackendInput = {
target: AwsDeploymentTarget;
};

export type ResolveProjectResourcesBackendInput = {
target: AwsDeploymentTarget;
};

/** Builds the deployable artifacts owned by a project's selected backend. */
export interface ProjectBackend {
build(project: Project): AsyncGenerator<ProjectEvent, void>;
Expand All @@ -26,4 +31,15 @@ export interface ProjectBackend {
project: Project,
input: ResolveDeployedResourcesBackendInput,
): Promise<ResolvedDeployedResource[]>;
/**
* Reports every resource the project declares against the target, including the
* ones it has not deployed.
*
* TODO: merge resolveDeployedResources and resolveProjectResources; the two are
* similar enough that one resolver should serve both invoke and status.
*/
resolveProjectResources(
project: Project,
input: ResolveProjectResourcesBackendInput,
): Promise<ResolvedProjectResource[]>;
}
3 changes: 3 additions & 0 deletions src/core/project/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,9 @@ describe("FsProjectManager.deploy", () => {
async resolveDeployedResources() {
return [];
},
async resolveProjectResources() {
return [];
},
};
return {
calls,
Expand Down
11 changes: 11 additions & 0 deletions src/core/project/manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import type {
ResolvedDeployedResource,
ResolvedDeployedResources,
ResolveProjectInput,
ResolveProjectResourcesInput,
ResolvedProjectResources,
ResolveTargetInput,
Project,
ProjectManager,
Expand Down Expand Up @@ -935,6 +937,15 @@ export class FsProjectManager implements ProjectManager {
return { resources, target };
}

public async resolveProjectResources(
project: Project,
input: ResolveProjectResourcesInput,
): Promise<ResolvedProjectResources> {
const target = await this.resolveExistingTarget(project, input.target);
const resources = await this.backendFor(project).resolveProjectResources(project, { target });
return { resources, target };
}

private async resolveExistingTarget(
project: Project,
name: string,
Expand Down
Loading
Loading