diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 9e308d1de..701339bce 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -146,12 +146,53 @@ async function main() { // Extract credentials from deployed state for this target const targetState = (deployedState as Record)?.targets as - Record> | undefined; + | Record> + | undefined; const targetResources = target ? (targetState?.[target.name]?.resources as Record | undefined) : undefined; const credentials = targetResources?.credentials as - Record | undefined; + | Record + | undefined; + + // Payment credential provider ARNs live in the same credentials map as identity credentials + const paymentCredentials = credentials; + + const paymentSpec = specAny.payments?.length + ? specAny.payments.map( + (p: { + name: string; + description?: string; + authorizerType: 'AWS_IAM' | 'CUSTOM_JWT'; + authorizerConfiguration?: unknown; + autoPayment?: boolean; + paymentToolAllowlist?: string[]; + networkPreferences?: string[]; + connectors: { name: string; provider?: string; credentialName: string }[]; + }) => ({ + name: p.name, + description: p.description, + authorizerType: p.authorizerType, + authorizerConfiguration: p.authorizerConfiguration, + autoPayment: p.autoPayment, + paymentToolAllowlist: p.paymentToolAllowlist, + networkPreferences: p.networkPreferences, + connectors: p.connectors.map(c => { + const credentialProviderArn = paymentCredentials?.[c.credentialName]?.credentialProviderArn; + if (!credentialProviderArn) { + // Fail fast with an actionable message rather than passing an empty + // ARN that fails opaquely server-side at CreatePaymentConnector. + throw new Error( + `Payment connector "${c.name}" on manager "${p.name}" references credential ` + + `"${c.credentialName}", but no deployed credential provider was found for it. ` + + `Run \`agentcore deploy\` so the credential provider is created first.` + ); + } + return { name: c.name, provider: c.provider, credentialProviderArn }; + }), + }) + ) + : undefined; new AgentCoreStack(app, stackName, { spec, @@ -159,6 +200,7 @@ async function main() { credentials, connectorParametersByFile, harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined, + paymentSpec, env, description: target ? `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})` diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index 9592561d2..3dac0669d 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -1,12 +1,15 @@ import { AgentCoreApplication, AgentCoreMcp, - AgentCorePayments, + AgentCorePaymentManager, + AgentCorePaymentConnector, type AgentCoreProjectSpec, type AgentCoreMcpSpec, + type CustomJWTAuthorizerConfig, type HarnessDeploymentConfig, } from '@aws/agentcore-cdk'; import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; +import * as iam from 'aws-cdk-lib/aws-iam'; import { Construct } from 'constructs'; /** @@ -16,6 +19,23 @@ import { Construct } from 'constructs'; */ export type HarnessConfig = HarnessDeploymentConfig; +export interface PaymentConnectorSpec { + name: string; + provider: 'CoinbaseCDP' | 'StripePrivy'; + credentialProviderArn: string; +} + +export interface PaymentSpec { + name: string; + description?: string; + authorizerType: 'AWS_IAM' | 'CUSTOM_JWT'; + authorizerConfiguration?: { customJWTAuthorizer: CustomJWTAuthorizerConfig }; + autoPayment?: boolean; + paymentToolAllowlist?: string[]; + networkPreferences?: string[]; + connectors: PaymentConnectorSpec[]; +} + export interface AgentCoreStackProps extends StackProps { /** * The AgentCore project specification containing agents, memories, and credentials. @@ -38,6 +58,30 @@ export interface AgentCoreStackProps extends StackProps { * connectorConfigFile path. Forwarded to AgentCoreApplication. */ connectorParametersByFile?: Record>; + /** + * Payment specifications with resolved credential provider ARNs. + */ + paymentSpec?: PaymentSpec[]; +} + +function toCdkId(name: string): string { + return name.replace(/_/g, ''); +} + +/** + * Decide whether a deployed runtime should receive payment env vars + IAM grants. + * Payments today only ships a runtime shim for Python HTTP runtimes; injecting + * AGENTCORE_PAYMENT_* env vars into TypeScript / MCP / A2A / AGUI runtimes + * would surface env vars they cannot consume and would dilute least-privilege + * IAM grants for runtimes that never call ProcessPayment. + */ +function isPaymentEligibleAgent(agent: { entrypoint?: string; protocol?: string }): boolean { + if (agent.protocol && agent.protocol !== 'HTTP') { + return false; + } + const entrypoint = typeof agent.entrypoint === 'string' ? agent.entrypoint : ''; + const entrypointFile = entrypoint.split(':')[0] ?? ''; + return entrypointFile.endsWith('.py'); } /** @@ -53,7 +97,7 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec, mcpSpec, credentials, harnesses, connectorParametersByFile } = props; + const { spec, mcpSpec, credentials, harnesses, connectorParametersByFile, paymentSpec } = props; // Create AgentCoreApplication with all agents and harness roles // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -68,11 +112,6 @@ export class AgentCoreStack extends Stack { appProps.credentials = credentials; } this.application = new AgentCoreApplication(this, 'Application', appProps as any); - new AgentCorePayments(this, 'Payments', { - spec, - credentials, - agentCoreApplication: this.application, - }); // Create AgentCoreMcp if there are gateways configured if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { @@ -85,6 +124,122 @@ export class AgentCoreStack extends Stack { }); } + // Create payment infrastructure via CFN constructs + if (paymentSpec && paymentSpec.length > 0) { + for (const payment of paymentSpec) { + const mgrId = toCdkId(payment.name); + const manager = new AgentCorePaymentManager(this, `Payment${mgrId}`, { + projectName: spec.name, + name: payment.name, + authorizerType: payment.authorizerType, + description: payment.description, + authorizerConfiguration: payment.authorizerConfiguration, + tags: spec.tags, + }); + + const prefix = `AGENTCORE_PAYMENT_${payment.name.toUpperCase().replace(/-/g, '_')}`; + + // Wire env vars from construct output tokens into eligible agent environments only. + // See isPaymentEligibleAgent — non-Python or non-HTTP runtimes have no shim that + // can consume these env vars, and giving them sts:AssumeRole on the + // ProcessPaymentRole would broaden the privilege surface unnecessarily. + for (const env of this.application.environments.values()) { + if (!isPaymentEligibleAgent(env.agent)) { + continue; + } + env.runtime.addEnvironmentVariable(`${prefix}_MANAGER_ARN`, manager.paymentManagerArn); + env.runtime.addEnvironmentVariable(`${prefix}_PROCESS_PAYMENT_ROLE_ARN`, manager.processPaymentRoleArn); + + // Grant runtime execution role permission to assume the ProcessPaymentRole. + // The ProcessPaymentRole's trust policy allows AccountRootPrincipal, but the + // caller still needs sts:AssumeRole on its own role to perform the assumption. + env.runtime.role.addToPrincipalPolicy( + new iam.PolicyStatement({ + actions: ['sts:AssumeRole'], + resources: [manager.processPaymentRoleArn], + }) + ); + + // Grant payment data-plane actions directly to the runtime role. + // + // NOTE: This deviates from the canonical role model in the AgentCore Payments + // beta guide, which assigns Get/List/Create instrument+session actions to a + // separate ManagementRole and limits the agent's role to ProcessPayment only. + // The current SDK plugin (AgentCorePaymentsPlugin.generate_payment_header) + // calls GetPaymentInstrument internally during the 402 auto-pay path, so the + // runtime role needs read access. CreatePaymentSession is included so + // `agentcore invoke --auto-session` works without a separate ManagementRole + // call. Tighten this if the SDK is updated to accept pre-fetched instrument + // details and split create-session into a backend-only flow. + env.runtime.role.addToPrincipalPolicy( + new iam.PolicyStatement({ + actions: [ + 'bedrock-agentcore:GetPaymentInstrument', + 'bedrock-agentcore:ListPaymentInstruments', + 'bedrock-agentcore:GetPaymentInstrumentBalance', + 'bedrock-agentcore:GetPaymentSession', + 'bedrock-agentcore:ListPaymentSessions', + 'bedrock-agentcore:CreatePaymentSession', + 'bedrock-agentcore:ProcessPayment', + ], + resources: [manager.paymentManagerArn, `${manager.paymentManagerArn}/*`], + }) + ); + + if (payment.autoPayment !== undefined) { + env.runtime.addEnvironmentVariable(`${prefix}_AUTO_PAYMENT`, String(payment.autoPayment)); + } + if (payment.paymentToolAllowlist) { + env.runtime.addEnvironmentVariable(`${prefix}_TOOL_ALLOWLIST`, payment.paymentToolAllowlist.join(',')); + } + if (payment.networkPreferences) { + env.runtime.addEnvironmentVariable(`${prefix}_NETWORK_PREFERENCES`, payment.networkPreferences.join(',')); + } + if (payment.authorizerType === 'CUSTOM_JWT') { + env.runtime.addEnvironmentVariable(`${prefix}_AUTH_MODE`, 'bearer'); + } + } + + // Create connectors for this manager + for (const connector of payment.connectors) { + const connId = toCdkId(connector.name); + const conn = new AgentCorePaymentConnector(this, `Payment${mgrId}${connId}`, { + projectName: spec.name, + paymentManager: manager, + connectorName: connector.name, + connectorType: connector.provider, + credentialProviderArn: connector.credentialProviderArn, + }); + + // Wire first connector's ID as env var (eligible agents only) + if (connector === payment.connectors[0]) { + for (const env of this.application.environments.values()) { + if (!isPaymentEligibleAgent(env.agent)) continue; + env.runtime.addEnvironmentVariable(`${prefix}_CONNECTOR_ID`, conn.paymentConnectorId); + } + } + + new CfnOutput(this, `Payment${mgrId}${connId}ConnectorId`, { + value: conn.paymentConnectorId, + }); + } + + // CFN Outputs for post-deploy state parsing + new CfnOutput(this, `Payment${mgrId}ManagerArn`, { + value: manager.paymentManagerArn, + }); + new CfnOutput(this, `Payment${mgrId}ManagerId`, { + value: manager.paymentManagerId, + }); + new CfnOutput(this, `Payment${mgrId}ProcessPaymentRoleArn`, { + value: manager.processPaymentRoleArn, + }); + new CfnOutput(this, `Payment${mgrId}ResourceRetrievalRoleArn`, { + value: manager.resourceRetrievalRoleArn, + }); + } + } + // Stack-level output new CfnOutput(this, 'StackNameOutput', { description: 'Name of the CloudFormation Stack', diff --git a/src/assets/cdk/package.json b/src/assets/cdk/package.json index 407a29fbd..0ac28f946 100644 --- a/src/assets/cdk/package.json +++ b/src/assets/cdk/package.json @@ -23,7 +23,7 @@ "typescript": "~5.9.3" }, "dependencies": { - "@aws/agentcore-cdk": "0.1.0-alpha.49", + "@aws/agentcore-cdk": "0.1.0-alpha.45", "aws-cdk-lib": "~2.266.0", "constructs": "~10.7.0" } diff --git a/src/assets/cdk/test/cdk.test.ts b/src/assets/cdk/test/cdk.test.ts index 2db16484f..8db318ada 100644 --- a/src/assets/cdk/test/cdk.test.ts +++ b/src/assets/cdk/test/cdk.test.ts @@ -1,29 +1,6 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; import * as cdk from 'aws-cdk-lib'; -import { Match, Template } from 'aws-cdk-lib/assertions'; - -const originalCwd = process.cwd(); -const originalInitCwd = process.env.INIT_CWD; -const testRoot = mkdtempSync(join(tmpdir(), 'agentcore-cdk-test-')); -const testConfigDir = join(testRoot, 'agentcore'); -let AgentCoreStack: typeof import('../lib/cdk-stack').AgentCoreStack; - -beforeAll(async () => { - process.chdir(testRoot); - process.env.INIT_CWD = testRoot; - mkdirSync(testConfigDir, { recursive: true }); - writeFileSync(join(testConfigDir, 'agentcore.json'), '{}'); - ({ AgentCoreStack } = await import('../lib/cdk-stack')); -}); - -afterAll(() => { - process.chdir(originalCwd); - if (originalInitCwd === undefined) delete process.env.INIT_CWD; - else process.env.INIT_CWD = originalInitCwd; - rmSync(testRoot, { recursive: true, force: true }); -}); +import { Template } from 'aws-cdk-lib/assertions'; +import { AgentCoreStack } from '../lib/cdk-stack'; test('AgentCoreStack synthesizes with empty spec', () => { const app = new cdk.App(); @@ -52,145 +29,3 @@ test('AgentCoreStack synthesizes with empty spec', () => { Description: 'Name of the CloudFormation Stack', }); }); - -test('AgentCoreStack synthesizes manual and Quick Create payment connectors', () => { - const app = new cdk.App(); - const stack = new AgentCoreStack(app, 'TestStack', { - spec: { - name: 'testproject', - version: 1, - managedBy: 'CDK' as const, - runtimes: [], - memories: [], - credentials: [ - { - authorizerType: 'PaymentCredentialProvider', - name: 'coinbase', - provider: 'CoinbaseCDP', - }, - ], - evaluators: [], - onlineEvalConfigs: [], - configBundles: [], - policyEngines: [], - payments: [ - { - name: 'Payments', - authorizerType: 'AWS_IAM', - connectors: [ - { - name: 'Manual', - provider: 'CoinbaseCDP', - credentialName: 'coinbase', - }, - { - name: 'Quick', - provider: 'CoinbaseCDP', - provisionMode: 'QUICK_CREATE', - }, - ], - }, - ], - agentCoreGateways: [], - mcpRuntimeTools: [], - unassignedTargets: [], - datasets: [], - knowledgeBases: [], - }, - credentials: { - coinbase: { - credentialProviderArn: - 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/paymentcredentialprovider/coinbase', - }, - }, - }); - const template = Template.fromStack(stack); - - template.resourceCountIs('AWS::BedrockAgentCore::PaymentConnector', 2); - template.hasResourceProperties('AWS::BedrockAgentCore::PaymentConnector', { - ConnectorName: 'Manual', - ProvisionMode: Match.absent(), - }); - template.hasResourceProperties('AWS::BedrockAgentCore::PaymentConnector', { - ConnectorName: 'Quick', - ConnectorType: 'CoinbaseCDP', - ProvisionMode: 'QUICK_CREATE', - CredentialProviderConfigurations: [], - }); - expect(Object.keys(template.findOutputs('*')).some(key => key.includes('AuthorizationUrl'))).toBe(true); -}); - -test('AgentCoreStack preserves complete and type-distinct payment resource identities', () => { - const app = new cdk.App(); - const stack = new AgentCoreStack(app, 'TestStack', { - spec: { - name: 'testproject', - version: 1, - managedBy: 'CDK' as const, - runtimes: [], - memories: [], - credentials: [], - evaluators: [], - onlineEvalConfigs: [], - configBundles: [], - policyEngines: [], - payments: [ - { - name: 'Payments', - authorizerType: 'AWS_IAM', - connectors: [ - { - name: 'foo_bar', - provider: 'CoinbaseCDP', - provisionMode: 'QUICK_CREATE', - }, - { - name: 'foobar', - provider: 'CoinbaseCDP', - provisionMode: 'QUICK_CREATE', - }, - ], - }, - { - name: 'A', - authorizerType: 'AWS_IAM', - connectors: [ - { - name: 'B', - provider: 'CoinbaseCDP', - provisionMode: 'QUICK_CREATE', - }, - { - name: 'BC', - provider: 'CoinbaseCDP', - provisionMode: 'QUICK_CREATE', - }, - ], - }, - { - name: 'AB', - authorizerType: 'AWS_IAM', - connectors: [ - { - name: 'C', - provider: 'CoinbaseCDP', - provisionMode: 'QUICK_CREATE', - }, - ], - }, - { - name: 'M1AC1B', - authorizerType: 'AWS_IAM', - connectors: [], - }, - ], - agentCoreGateways: [], - mcpRuntimeTools: [], - unassignedTargets: [], - datasets: [], - knowledgeBases: [], - }, - }); - - Template.fromStack(stack).resourceCountIs('AWS::BedrockAgentCore::PaymentConnector', 5); -}); diff --git a/src/core/project/backends/cdk/environment.ts b/src/core/project/backends/cdk/environment.ts index 11e778389..6cc6ae79c 100644 --- a/src/core/project/backends/cdk/environment.ts +++ b/src/core/project/backends/cdk/environment.ts @@ -150,7 +150,11 @@ export async function probeStack( } } -export const resolveAwsAccount: AccountResolver = async (region, credentials) => { +/** Omitting `credentials` resolves through the default AWS SDK provider chain. */ +export const resolveAwsAccount = async ( + region: string, + credentials?: CdkCredentialProvider, +): Promise => { const { GetCallerIdentityCommand, STSClient } = await import("@aws-sdk/client-sts"); const client = new STSClient({ credentials, region }); try { diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 89c435159..c306c7062 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -12,7 +12,6 @@ import { type DeployResult, type Project, type ProjectEvent, - type TeardownConfirmationHandler, } from "../../handlers/project/types"; import { createSilentLogger } from "../../testing"; import type { DeployBackendInput, ProjectBackend } from "./backends/types"; @@ -347,8 +346,11 @@ describe("FsProjectManager.build", () => { describe("FsProjectManager.deploy", () => { type DeployCall = { project: Project; input: DeployBackendInput }; - function deployManager() { + const STS_ACCOUNT = "999900001111"; + + function deployManager(options?: { account?: string | Error }) { const calls: DeployCall[] = []; + const accountCalls: string[] = []; const backend: ProjectBackend = { async *build() {}, async *deploy(project, input) { @@ -359,9 +361,16 @@ describe("FsProjectManager.deploy", () => { }; return { calls, + accountCalls, manager: new FsProjectManager({ logger: createSilentLogger(), backends: { CDK: backend }, + resolveAccount: async (region) => { + accountCalls.push(region); + const outcome = options?.account ?? STS_ACCOUNT; + if (outcome instanceof Error) throw outcome; + return outcome; + }, }), }; } @@ -387,11 +396,12 @@ describe("FsProjectManager.deploy", () => { manager: FsProjectManager, project: Project, target: string, - confirmTeardown: TeardownConfirmationHandler = async () => false, + options: { region?: string } = {}, ): Promise<{ events: ProjectEvent[]; result: DeployResult }> { const generator = manager.deploy(project, { target, - confirmTeardown, + region: options.region ?? "us-east-1", + confirmTeardown: async () => false, }); const events: ProjectEvent[] = []; while (true) { @@ -444,16 +454,20 @@ describe("FsProjectManager.deploy", () => { test.each([ ["a missing file", undefined], ["an empty list", []], - ])("rejects %s before invoking the backend", async (_label, configured) => { - const root = await inTempDirectory(); - const subject = deployManager(); - const project = await projectWithTargets(root, configured); - - await expect(deploy(subject.manager, project, "default")).rejects.toThrow( - /No deployment targets are configured/, - ); - expect(subject.calls).toEqual([]); - }); + ])( + "rejects %s before invoking the backend when a named target is requested", + async (_label, configured) => { + const root = await inTempDirectory(); + const subject = deployManager(); + const project = await projectWithTargets(root, configured); + + await expect(deploy(subject.manager, project, "staging")).rejects.toThrow( + /No deployment targets are configured/, + ); + expect(subject.calls).toEqual([]); + expect(subject.accountCalls).toEqual([]); + }, + ); test.each([ ["malformed JSON", "{ not-json"], @@ -483,6 +497,135 @@ describe("FsProjectManager.deploy", () => { ); expect(subject.calls).toEqual([]); }); + + const targetsFile = (root: string) => join(root, "agentcore", "aws-targets.json"); + const SYNTHESIZED: AwsDeploymentTarget = { + name: "default", + account: STS_ACCOUNT, + region: "us-east-2", + }; + const CREATED_MESSAGE = + `Created default deployment target: account ${STS_ACCOUNT}, ` + + `region us-east-2 (${join("agentcore", "aws-targets.json")})`; + + test.each([ + ["a missing file", undefined], + ["an empty list", []], + ])("synthesizes the default target from %s", async (_label, configured) => { + const root = await inTempDirectory(); + const subject = deployManager(); + const project = await projectWithTargets(root, configured); + + const deployed = await deploy(subject.manager, project, "default", { region: "us-east-2" }); + + expect(subject.accountCalls).toEqual(["us-east-2"]); + expect(subject.calls).toHaveLength(1); + expect(subject.calls[0]?.input.target).toEqual(SYNTHESIZED); + expect(deployed.events).toEqual([ + { message: CREATED_MESSAGE }, + { message: "Backend deployment started" }, + ]); + expect(await Bun.file(targetsFile(root)).json()).toEqual([SYNTHESIZED]); + }); + + test("appends the default target and preserves other entries byte for byte", async () => { + const root = await inTempDirectory(); + const subject = deployManager(); + // Non-canonical key order plus a key the schema does not know about, so a + // rewrite through the schema (which would reorder and strip) is caught. + const existing = + `[\n` + + ` {\n` + + ` "region": "eu-west-1",\n` + + ` "name": "prod",\n` + + ` "account": "444455556666",\n` + + ` "note": "hand-tuned"\n` + + ` }\n` + + `]`; + const project = await projectWithTargets(root, existing); + + await deploy(subject.manager, project, "default", { region: "us-east-2" }); + + expect(subject.calls[0]?.input.target).toEqual(SYNTHESIZED); + expect(await Bun.file(targetsFile(root)).text()).toBe( + `[\n` + + ` {\n` + + ` "region": "eu-west-1",\n` + + ` "name": "prod",\n` + + ` "account": "444455556666",\n` + + ` "note": "hand-tuned"\n` + + ` },\n` + + ` {\n` + + ` "name": "default",\n` + + ` "account": "${STS_ACCOUNT}",\n` + + ` "region": "us-east-2"\n` + + ` }\n` + + `]`, + ); + }); + + test("never synthesizes a named target", async () => { + const root = await inTempDirectory(); + const subject = deployManager(); + const project = await projectWithTargets(root, targets); + + await expect(deploy(subject.manager, project, "gamma")).rejects.toThrow( + /no deployment target named 'gamma'.*staging, prod/s, + ); + expect(subject.calls).toEqual([]); + expect(subject.accountCalls).toEqual([]); + expect(await Bun.file(targetsFile(root)).json()).toEqual(targets); + }); + + test("rejects an unsupported region without calling STS or writing the file", async () => { + const root = await inTempDirectory(); + const subject = deployManager(); + const project = await projectWithTargets(root, undefined); + + const attempt = deploy(subject.manager, project, "default", { region: "us-west-1" }); + + await expect(attempt).rejects.toThrow(/'us-west-1' is not an AgentCore-supported region/); + await expect( + deploy(subject.manager, project, "default", { region: "us-west-1" }), + ).rejects.toThrow(/Supported regions: .*us-east-1.*Re-run with --region/s); + expect(subject.calls).toEqual([]); + expect(subject.accountCalls).toEqual([]); + expect(await Bun.file(targetsFile(root)).exists()).toBe(false); + }); + + test("reports an actionable error when the account cannot be resolved", async () => { + const root = await inTempDirectory(); + const subject = deployManager({ + account: new Error("The security token included in the request is expired"), + }); + const project = await projectWithTargets(root, undefined); + + await expect( + deploy(subject.manager, project, "default", { region: "us-east-2" }), + ).rejects.toThrow( + /the AWS account could not be resolved: The security token included in the request is expired[\s\S]*aws configure/, + ); + expect(subject.calls).toEqual([]); + expect(await Bun.file(targetsFile(root)).exists()).toBe(false); + }); + + test("leaves an existing default target alone", async () => { + const root = await inTempDirectory(); + const subject = deployManager(); + const configured: AwsDeploymentTarget[] = [ + { name: "default", account: "111122223333", region: "us-west-2" }, + ]; + const contents = JSON.stringify(configured, null, 2); + const project = await projectWithTargets(root, contents); + + // The requested region differs from the entry's; the entry must win. + const deployed = await deploy(subject.manager, project, "default", { region: "us-east-2" }); + + expect(subject.accountCalls).toEqual([]); + expect(subject.calls[0]?.input.target).toEqual(configured[0]!); + expect(deployed.events).toEqual([{ message: "Backend deployment started" }]); + expect(await Bun.file(targetsFile(root)).text()).toBe(contents); + }); }); describe("FsProjectManager.resolve", () => { diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index fae997d59..8a6696f0e 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -38,13 +38,22 @@ import { enclosingProjectRoot, projectSpecPath } from "./fsUtils"; import { AgentCoreCLIError, InputValidationError, + InvalidEnvironmentError, + MalformedServiceResponseError, NotImplementedError, ProjectStateError, } from "../../errors/errors"; import z from "zod"; import { CdkBackend } from "./backends/cdk"; +import { resolveAwsAccount } from "./backends/cdk/environment"; import type { ProjectBackend } from "./backends/types"; -import { AwsDeploymentTargetsSchema } from "../../projectSchemas/aws-targets"; +import { + AgentCoreRegionSchema, + AwsDeploymentTargetSchema, + AwsDeploymentTargetsSchema, + DEFAULT_TARGET_NAME, + type AwsDeploymentTarget, +} from "../../projectSchemas/aws-targets"; import type { RuntimeResourceConfig } from "../../handlers/project/add/runtime/types"; import type { TemplateRenderer } from "./templates/types"; import { HandlebarsTemplateRenderer } from "./templates/renderer"; @@ -61,6 +70,12 @@ type ProjectManagerConfig = { json?: ReadWriteJson; backends?: Partial>; templateRenderer?: TemplateRenderer; + /** + * Resolves the AWS account behind the active credentials (STS + * GetCallerIdentity), used to synthesize the default deployment target. + * Injectable so unit tests never call AWS. + */ + resolveAccount?: (region: string) => Promise; }; /** @@ -74,6 +89,7 @@ export class FsProjectManager implements ProjectManager { private readonly checkTool: typeof requireTool; private readonly json: ReadWriteJson; private readonly backends: Partial>; + private readonly resolveAccount: (region: string) => Promise; constructor(config: ProjectManagerConfig) { this.logger = config.logger; @@ -91,6 +107,7 @@ export class FsProjectManager implements ProjectManager { }), }; this.templateRenderer = config.templateRenderer ?? new HandlebarsTemplateRenderer(); + this.resolveAccount = config.resolveAccount ?? resolveAwsAccount; } public async resolve(input: ResolveProjectInput): Promise { @@ -497,24 +514,37 @@ export class FsProjectManager implements ProjectManager { input: DeployProjectInput, ): AsyncGenerator { const targetsPath = join(project.rootPath, "agentcore", "aws-targets.json"); - if (!existsSync(targetsPath)) { - throw new ProjectStateError( - `No deployment targets are configured for project '${project.name}'. ` + - `Add ${targetsPath}, for example:\n\n${TARGETS_EXAMPLE}`, - ); - } + const fileExists = existsSync(targetsPath); + const targets = fileExists ? await this.json.read(targetsPath, AwsDeploymentTargetsSchema) : []; - const targets = await this.json.read(targetsPath, AwsDeploymentTargetsSchema); + let target = targets.find((candidate) => candidate.name === input.target); - if (targets.length === 0) { - throw new ProjectStateError( - `No deployment targets are configured for project '${project.name}'. ` + - `Add at least one to ${targetsPath}, for example:\n\n${TARGETS_EXAMPLE}`, - ); + // A freshly created project defines no targets, so the default one is + // synthesized from the environment rather than demanded up front. Only + // `default` gets this treatment: inventing a *named* target would turn a + // typo'd --target into a deployment somewhere unintended. + if (!target && input.target === DEFAULT_TARGET_NAME) { + target = await this.provisionDefaultTarget(project, targetsPath, input.region); + yield { + message: + `Created default deployment target: account ${target.account}, ` + + `region ${target.region} (${join("agentcore", "aws-targets.json")})`, + }; } - const target = targets.find((candidate) => candidate.name === input.target); if (!target) { + if (!fileExists) { + throw new ProjectStateError( + `No deployment targets are configured for project '${project.name}'. ` + + `Add ${targetsPath}, for example:\n\n${TARGETS_EXAMPLE}`, + ); + } + if (targets.length === 0) { + throw new ProjectStateError( + `No deployment targets are configured for project '${project.name}'. ` + + `Add at least one to ${targetsPath}, for example:\n\n${TARGETS_EXAMPLE}`, + ); + } throw new ProjectStateError( `Project '${project.name}' has no deployment target named '${input.target}'. ` + `${targetsPath} defines: ${targets.map(({ name }) => name).join(", ")}.`, @@ -527,6 +557,63 @@ export class FsProjectManager implements ProjectManager { }); } + /** + * Builds the default deployment target from the environment — the active + * credentials' account and the CLI's effective region — and persists it to + * aws-targets.json alongside any targets already defined there. + */ + private async provisionDefaultTarget( + project: Project, + targetsPath: string, + region: string, + ): Promise { + const supportedRegion = AgentCoreRegionSchema.safeParse(region); + if (!supportedRegion.success) { + throw new InputValidationError( + `Cannot create the default deployment target for project '${project.name}': ` + + `'${region}' is not an AgentCore-supported region.\n` + + `Supported regions: ${AgentCoreRegionSchema.options.join(", ")}.\n` + + `Re-run with --region or set AWS_REGION to one of them.`, + ); + } + + let account: string; + try { + account = await this.resolveAccount(supportedRegion.data); + } catch (error) { + const cause = AgentCoreCLIError.fromError(error); + throw new InvalidEnvironmentError( + `Cannot create the default deployment target for project '${project.name}' because ` + + `the AWS account could not be resolved: ${cause.message}\n` + + `Check that valid AWS credentials are configured (for example via 'aws configure', ` + + `AWS_PROFILE, or environment variables) and re-run 'agentcore project deploy'.`, + { cause: error }, + ); + } + + const entry = AwsDeploymentTargetSchema.safeParse({ + name: DEFAULT_TARGET_NAME, + account, + region: supportedRegion.data, + }); + if (!entry.success) { + throw new MalformedServiceResponseError( + `STS returned an AWS account ID that is not usable as a deployment target:\n` + + z.prettifyError(entry.error), + { cause: entry.error }, + ); + } + + // Merged into the raw file contents rather than the schema-parsed targets, + // so existing entries keep their exact key order and any fields the schema + // does not know about. + const existing = existsSync(targetsPath) + ? await this.json.read(targetsPath, z.array(z.record(z.string(), z.unknown()))) + : []; + await this.json.write(targetsPath, [...existing, entry.data]); + return entry.data; + } + private backendFor(project: Project): ProjectBackend { const backend = this.backends[project.spec.managedBy]; if (!backend) { diff --git a/src/handlers/project/deploy/index.test.ts b/src/handlers/project/deploy/index.test.ts index 1c00dafe3..8b6bc0038 100644 --- a/src/handlers/project/deploy/index.test.ts +++ b/src/handlers/project/deploy/index.test.ts @@ -68,6 +68,7 @@ type TestDeployOptions = { isTTY?: boolean; stdin?: string; teardown?: TeardownConfirmationRequest; + resolveAccount?: (region: string) => Promise; }; function testDeployCommand( @@ -77,7 +78,10 @@ function testDeployCommand( ) { const io = testIO({ isTTY: options.isTTY, stdin: options.stdin }); const fake = fakeBackend(result, events, options.teardown); - const core = new TestCoreClient({ backends: { CDK: fake.backend } }); + const core = new TestCoreClient({ + backends: { CDK: fake.backend }, + resolveAccount: options.resolveAccount, + }); const root = createRootHandler(core, { io: io.io, globalConfigAccessor: new TestGlobalConfigAccessor(), @@ -280,11 +284,63 @@ describe("project deploy handler", () => { expect(subject.calls).toEqual([]); }); - test("requires deployment targets to be configured", async () => { + test("requires deployment targets to be configured for a named target", async () => { const subject = testDeployCommand({ outputs: {} }); await inProjectWithTargets(subject, JSON.stringify([])); - await expect(subject.run()).rejects.toThrow(/No deployment targets are configured/); + await expect(subject.run(["--target", "staging"])).rejects.toThrow( + /No deployment targets are configured/, + ); + expect(subject.calls).toEqual([]); + }); + + // The zero-configuration path: a fresh project's aws-targets.json is [], so + // the first deploy must invent the default target rather than demand edits. + test("creates the default target from the environment on first deploy", async () => { + const subject = testDeployCommand({ outputs: { RuntimeArn: "arn:runtime" } }); + const projectRoot = await inProjectWithTargets(subject, JSON.stringify([])); + + await subject.run(["--region", "us-west-2"]); + + expect(subject.calls).toHaveLength(1); + expect(subject.calls[0]?.input.target).toEqual({ + name: "default", + account: "111122223333", + region: "us-west-2", + }); + expect(subject.io.stderr()).toContain( + "Created default deployment target: account 111122223333, region us-west-2", + ); + expect(subject.io.stderr()).toContain("Deployed project 'orders' to target 'default'"); + expect(await Bun.file(join(projectRoot, "agentcore", "aws-targets.json")).json()).toEqual([ + { name: "default", account: "111122223333", region: "us-west-2" }, + ]); + }); + + test("rejects an unsupported region instead of writing an invalid target", async () => { + const subject = testDeployCommand({ outputs: {} }); + const projectRoot = await inProjectWithTargets(subject, JSON.stringify([])); + + const message = await messageFrom(subject.run(["--region", "us-west-1"])); + + expect(message).toContain("'us-west-1' is not an AgentCore-supported region"); + expect(message).toContain("us-east-1"); + expect(subject.calls).toEqual([]); + expect(await Bun.file(join(projectRoot, "agentcore", "aws-targets.json")).text()).toBe("[]"); + }); + + test("explains how to fix unresolvable credentials", async () => { + const subject = testDeployCommand({ outputs: {} }, [], { + resolveAccount: async () => { + throw new Error("Could not load credentials from any providers"); + }, + }); + await inProjectWithTargets(subject, JSON.stringify([])); + + const message = await messageFrom(subject.run(["--region", "us-east-1"])); + + expect(message).toContain("Could not load credentials from any providers"); + expect(message).toContain("aws configure"); expect(subject.calls).toEqual([]); }); }); diff --git a/src/handlers/project/deploy/index.ts b/src/handlers/project/deploy/index.ts index d904d2b0c..6c74a2da1 100644 --- a/src/handlers/project/deploy/index.ts +++ b/src/handlers/project/deploy/index.ts @@ -2,9 +2,10 @@ import { createInterface } from "node:readline/promises"; import z from "zod"; import { UserCancellationError } from "../../../errors/errors"; import type { AppIO } from "../../../io"; +import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets"; import { createHandler, flag, ProjectKey } from "../../../router"; import { JsonRendererKey } from "../../../tui"; -import { JsonKey } from "../../keys"; +import { JsonKey, RegionKey } from "../../keys"; import type { ProjectManager, TeardownConfirmationRequest, @@ -21,7 +22,12 @@ export const createDeployProjectHandler = (config: DeployProjectHandlerConfig) = name: "deploy", description: "deploy the project to AWS", flags: [ - flag("target", "name of the aws-targets.json entry to deploy", z.string().default("default")), + flag( + "target", + "name of the aws-targets.json entry to deploy; the default target is created " + + "automatically from your AWS account and region on first deploy", + z.string().default(DEFAULT_TARGET_NAME), + ), flag( "yes", "confirm removing the target's stack when the project declares nothing to deploy", @@ -44,6 +50,7 @@ export const createDeployProjectHandler = (config: DeployProjectHandlerConfig) = // generator's return value, which `for await` discards. const deployment = config.projectManager.deploy(project, { target: flags.target, + region: ctx.require(RegionKey), confirmTeardown: createTeardownConfirmationHandler(config.io, flags.yes, canPrompt), }); let next = await deployment.next(); diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 9c642432d..cb42155df 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -918,8 +918,13 @@ describe("project deploy", () => { await expect(run(["deploy"])).rejects.toThrow(/No AgentCore project found/); }); - test("rejects a project with no deployment targets", async () => { + // A bare `deploy` on a fresh project synthesizes the default target instead + // of rejecting (covered with a stubbed backend in deploy/index.test.ts); only + // a named target still demands configuration. + test("rejects a project with no deployment targets for a named target", async () => { await inProject(); - await expect(run(["deploy"])).rejects.toThrow(/No deployment targets are configured/); + await expect(run(["deploy", "--target", "staging"])).rejects.toThrow( + /No deployment targets are configured/, + ); }); }); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index e40db8e71..4b67f04ef 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -85,6 +85,12 @@ export type TeardownConfirmationHandler = ( export type DeployProjectInput = { /** Name of the aws-targets.json entry to deploy. */ target: string; + /** + * The effective AWS region the CLI already resolved (--region flag, env, + * shared config file). Used to synthesize the default target when + * aws-targets.json does not define one — never to override a defined target. + */ + region: string; /** Requests approval after the backend discovers that this deploy is a teardown. */ confirmTeardown: TeardownConfirmationHandler; }; diff --git a/src/projectSchemas/aws-targets.ts b/src/projectSchemas/aws-targets.ts index 6d847db2d..be699f3f1 100644 --- a/src/projectSchemas/aws-targets.ts +++ b/src/projectSchemas/aws-targets.ts @@ -25,6 +25,13 @@ export const AgentCoreRegionSchema = z.enum([ "us-gov-west-1", ]); +/** + * The target `project deploy` uses when --target is omitted. Only this target + * is ever synthesized from the environment when aws-targets.json lacks it; + * named targets must be defined explicitly so a typo cannot invent one. + */ +export const DEFAULT_TARGET_NAME = "default"; + export const DeploymentTargetNameSchema = z .string() .min(1) diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 8fcce7a46..eba250e6d 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -1209,6 +1209,7 @@ type TestCoreClientOptions = { json?: ReadWriteJson; backends?: Partial>; createCloudFormationClient?: CreateCloudFormationClient; + resolveAccount?: (region: string) => Promise; }; export class TestIdentityClient implements CoreIdentityClient { @@ -2246,6 +2247,9 @@ export class TestCoreClient implements Core { this.projectCommands.push({ command, cwd }); }, checkTool: async () => {}, // CI hosts don't have uv installed + // Deploy synthesizes the default target through STS; stub the lookup so + // tests stay hermetic. + resolveAccount: options?.resolveAccount ?? (async () => "111122223333"), }); } }