From 5264d47ba759b3b9c8dca3555decd81b707a86ca Mon Sep 17 00:00:00 2001 From: Alexander Richey Date: Sun, 30 Aug 2026 23:01:28 -0400 Subject: [PATCH] feat(deploy): auto-provision the default deployment target from STS `project deploy` now synthesizes the `default` target when aws-targets.json is missing, empty, or lacks a `default` entry: the account comes from STS GetCallerIdentity and the region from the CLI's already-resolved effective region, validated against AgentCoreRegionSchema before anything is written. Existing entries are preserved byte-for-byte, the synthesized entry is reported on stderr, and the deploy proceeds in the same invocation. Named targets still require explicit configuration, and unsupported regions or unresolvable credentials fail with actionable errors before the file is touched. Also restores the vended CDK app (bin/cdk.ts, lib/cdk-stack.ts, test/cdk.test.ts, package.json) to its last publishable state: it referenced an AgentCorePayments L3 that no released @aws/agentcore-cdk exports, so every fresh scaffold failed `cdk synth` at tsc before reaching AWS. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01XfADH54TZn2SSGUUixft8P --- src/assets/cdk/bin/cdk.ts | 46 ++++- src/assets/cdk/lib/cdk-stack.ts | 169 +++++++++++++++++- src/assets/cdk/package.json | 2 +- src/assets/cdk/test/cdk.test.ts | 169 +----------------- src/core/project/backends/cdk/environment.ts | 6 +- src/core/project/manager.test.ts | 171 +++++++++++++++++-- src/core/project/manager.tsx | 115 +++++++++++-- src/handlers/project/deploy/index.test.ts | 62 ++++++- src/handlers/project/deploy/index.ts | 11 +- src/handlers/project/project.test.ts | 9 +- src/handlers/project/types.ts | 6 + src/projectSchemas/aws-targets.ts | 7 + src/testing/TestCoreClient.tsx | 4 + 13 files changed, 564 insertions(+), 213 deletions(-) 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"), }); } }