diff --git a/package.json b/package.json index 1bd2478b..3179d4ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aws/agentcore", - "version": "0.3.0-preview.2.1", + "version": "0.3.0-preview.3", "description": "CLI for Amazon Bedrock AgentCore", "license": "Apache-2.0", "repository": { diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 89ef1df5..1eb87cc5 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -59,15 +59,20 @@ async function main() { // Read MCP configuration if it exists let mcpSpec; - let mcpDeployedState; try { mcpSpec = await configIO.readMcpSpec(); - const deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); - mcpDeployedState = deployedState?.mcp; } catch { // MCP config is optional } + // Read deployed state for credential ARNs (populated by pre-deploy identity setup) + let deployedState: Record | undefined; + try { + deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); + } catch { + // Deployed state may not exist on first deploy + } + if (targets.length === 0) { throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); } @@ -78,10 +83,15 @@ async function main() { const env = toEnvironment(target); const stackName = toStackName(spec.name, target.name); + // Extract credentials from deployed state for this target + const targetState = (deployedState as Record)?.targets as Record> | undefined; + const targetResources = targetState?.[target.name]?.resources as Record | undefined; + const credentials = targetResources?.credentials as Record | undefined; + new AgentCoreStack(app, stackName, { spec, mcpSpec, - mcpDeployedState, + credentials, env, description: \`AgentCore stack for \${spec.name} deployed to \${target.name} (\${target.region})\`, tags: { @@ -221,8 +231,7 @@ exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/lib/cdk-stack.ts shou AgentCoreApplication, AgentCoreMcp, type AgentCoreProjectSpec, - type McpSpec, - type McpDeployedState, + type AgentCoreMcpSpec, } from '@aws/agentcore-cdk'; import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; import { Construct } from 'constructs'; @@ -235,11 +244,11 @@ export interface AgentCoreStackProps extends StackProps { /** * The MCP specification containing gateways and servers. */ - mcpSpec?: McpSpec; + mcpSpec?: AgentCoreMcpSpec; /** - * The MCP deployed state. + * Credential provider ARNs from deployed state, keyed by credential name. */ - mcpDeployedState?: McpDeployedState; + credentials?: Record; } /** @@ -255,7 +264,7 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec, mcpSpec, mcpDeployedState } = props; + const { spec, mcpSpec, credentials } = props; // Create AgentCoreApplication with all agents this.application = new AgentCoreApplication(this, 'Application', { @@ -265,9 +274,10 @@ export class AgentCoreStack extends Stack { // Create AgentCoreMcp if there are gateways configured if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { new AgentCoreMcp(this, 'Mcp', { - spec: mcpSpec, - deployedState: mcpDeployedState, - application: this.application, + projectName: spec.name, + mcpSpec, + agentCoreApplication: this.application, + credentials, }); } @@ -318,7 +328,7 @@ exports[`Assets Directory Snapshots > CDK assets > cdk/cdk/package.json should m }, "dependencies": { "@aws/agentcore-cdk": "^0.1.0-alpha.1", - "aws-cdk-lib": "2.234.1", + "aws-cdk-lib": "2.239.0", "constructs": "^10.0.0" } } diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index e590b9f2..498eca3a 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -26,15 +26,20 @@ async function main() { // Read MCP configuration if it exists let mcpSpec; - let mcpDeployedState; try { mcpSpec = await configIO.readMcpSpec(); - const deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); - mcpDeployedState = deployedState?.mcp; } catch { // MCP config is optional } + // Read deployed state for credential ARNs (populated by pre-deploy identity setup) + let deployedState: Record | undefined; + try { + deployedState = JSON.parse(fs.readFileSync(path.join(configRoot, '.cli', 'deployed-state.json'), 'utf8')); + } catch { + // Deployed state may not exist on first deploy + } + if (targets.length === 0) { throw new Error('No deployment targets configured. Please define targets in agentcore/aws-targets.json'); } @@ -45,10 +50,15 @@ async function main() { const env = toEnvironment(target); const stackName = toStackName(spec.name, target.name); + // Extract credentials from deployed state for this target + const targetState = (deployedState as Record)?.targets as Record> | undefined; + const targetResources = targetState?.[target.name]?.resources as Record | undefined; + const credentials = targetResources?.credentials as Record | undefined; + new AgentCoreStack(app, stackName, { spec, mcpSpec, - mcpDeployedState, + credentials, env, description: `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})`, tags: { diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index fbff1465..ecbf15b8 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -2,8 +2,7 @@ import { AgentCoreApplication, AgentCoreMcp, type AgentCoreProjectSpec, - type McpSpec, - type McpDeployedState, + type AgentCoreMcpSpec, } from '@aws/agentcore-cdk'; import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; import { Construct } from 'constructs'; @@ -16,11 +15,11 @@ export interface AgentCoreStackProps extends StackProps { /** * The MCP specification containing gateways and servers. */ - mcpSpec?: McpSpec; + mcpSpec?: AgentCoreMcpSpec; /** - * The MCP deployed state. + * Credential provider ARNs from deployed state, keyed by credential name. */ - mcpDeployedState?: McpDeployedState; + credentials?: Record; } /** @@ -36,7 +35,7 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec, mcpSpec, mcpDeployedState } = props; + const { spec, mcpSpec, credentials } = props; // Create AgentCoreApplication with all agents this.application = new AgentCoreApplication(this, 'Application', { @@ -46,9 +45,10 @@ export class AgentCoreStack extends Stack { // Create AgentCoreMcp if there are gateways configured if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { new AgentCoreMcp(this, 'Mcp', { - spec: mcpSpec, - deployedState: mcpDeployedState, - application: this.application, + projectName: spec.name, + mcpSpec, + agentCoreApplication: this.application, + credentials, }); } diff --git a/src/assets/cdk/package.json b/src/assets/cdk/package.json index 77e21bd0..eb09002e 100644 --- a/src/assets/cdk/package.json +++ b/src/assets/cdk/package.json @@ -24,7 +24,7 @@ }, "dependencies": { "@aws/agentcore-cdk": "^0.1.0-alpha.1", - "aws-cdk-lib": "2.234.1", + "aws-cdk-lib": "2.239.0", "constructs": "^10.0.0" } } diff --git a/src/cli/cloudformation/outputs.ts b/src/cli/cloudformation/outputs.ts index 7e053e09..9cf5ae77 100644 --- a/src/cli/cloudformation/outputs.ts +++ b/src/cli/cloudformation/outputs.ts @@ -45,8 +45,8 @@ export function parseGatewayOutputs( const gatewayNames = Object.keys(gatewaySpecs); const gatewayIdMap = new Map(gatewayNames.map(name => [toPascalId(name), name])); - // Match pattern: Gateway{GatewayName}UrlOutput - const outputPattern = /^Gateway(.+?)UrlOutput/; + // Match patterns: Gateway{Name}{Type}Output or McpGateway{Name}{Type}Output + const outputPattern = /^(?:Mcp)?Gateway(.+?)(Id|Arn|Url)Output/; for (const [key, value] of Object.entries(outputs)) { const match = outputPattern.exec(key); diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index bc6b14ce..70cd4bcc 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -20,6 +20,7 @@ import { } from '../../operations/deploy'; import { formatTargetStatus, getGatewayTargetStatuses } from '../../operations/deploy/gateway-status'; import type { DeployResult } from './types'; +import type { DeployedState } from '../../../schema'; export interface ValidatedDeployOptions { target: string; @@ -104,70 +105,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0 ? new SecureCredentials(envCredentials) : undefined; + // Unified credentials map for deployed state (both API Key and OAuth) + const deployedCredentials: Record< + string, + { credentialProviderArn: string; clientSecretArn?: string; callbackUrl?: string } + > = {}; + if (hasIdentityApiProviders(context.projectSpec)) { startStep('Creating credentials...'); @@ -201,14 +145,19 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise = {}; if (hasIdentityOAuthProviders(context.projectSpec)) { startStep('Creating OAuth credentials...'); @@ -226,10 +175,10 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0) { + const existingPreSynthState = await configIO.readDeployedState().catch(() => ({targets: {}} as DeployedState)); + const targetState = existingPreSynthState.targets?.[target.name] ?? { resources: {} }; + targetState.resources ??= {}; + targetState.resources.credentials = deployedCredentials; + if (identityKmsKeyArn) targetState.resources.identityKmsKeyArn = identityKmsKeyArn; + await configIO.writeDeployedState({ + ...existingPreSynthState, + targets: { ...existingPreSynthState.targets, [target.name]: targetState }, + }); + } + + // Synthesize CloudFormation templates + startStep('Synthesize CloudFormation'); + const switchableIoHost = options.verbose ? createSwitchableIoHost() : undefined; + const synthResult = await synthesizeCdk( + context.cdkProject, + switchableIoHost ? { ioHost: switchableIoHost.ioHost } : undefined + ); + toolkitWrapper = synthResult.toolkitWrapper; + const stackNames = synthResult.stackNames; + if (stackNames.length === 0) { + endStep('error', 'No stacks found'); + logger.finalize(false); + return { success: false, error: 'No stacks found to deploy', logPath: logger.getRelativeLogPath() }; + } + const stackName = stackNames[0]!; + endStep('success'); + + // Check if bootstrap needed + startStep('Check bootstrap status'); + const bootstrapCheck = await checkBootstrapNeeded(context.awsTargets); + if (bootstrapCheck.needsBootstrap) { + if (options.autoConfirm) { + logger.log('Bootstrap needed, auto-confirming...'); + await bootstrapEnvironment(toolkitWrapper, target); + } else { + endStep('error', 'Bootstrap required'); + logger.finalize(false); + return { + success: false, + error: 'AWS environment needs bootstrapping. Run with --yes to auto-bootstrap.', + logPath: logger.getRelativeLogPath(), + }; + } + } + endStep('success'); + + // Check stack deployability + startStep('Check stack status'); + const deployabilityCheck = await checkStackDeployability(target.region, stackNames); + if (!deployabilityCheck.canDeploy) { + endStep('error', deployabilityCheck.message); + logger.finalize(false); + return { + success: false, + error: deployabilityCheck.message ?? 'Stack is not in a deployable state', + logPath: logger.getRelativeLogPath(), + }; + } + endStep('success'); + + // Plan mode: stop after synth and checks, don't deploy + if (options.plan) { + logger.finalize(true); + await toolkitWrapper.dispose(); + toolkitWrapper = null; + return { + success: true, + targetName: target.name, + stackName, + logPath: logger.getRelativeLogPath(), + }; + } + // Deploy const hasGateways = mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0; const deployStepName = hasGateways ? 'Deploying gateways...' : 'Deploy to AWS'; @@ -313,7 +338,7 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise { +): Promise<{ success: boolean; credentialProviderArn?: string; error?: string }> { try { await client.send( new CreateApiKeyCredentialProviderCommand({ @@ -48,11 +48,18 @@ export async function createApiKeyProvider( apiKey: apiKey, }) ); - return { success: true }; + // Create response doesn't include credentialProviderArn — fetch it + const getResponse = await client.send(new GetApiKeyCredentialProviderCommand({ name: providerName })); + return { success: true, credentialProviderArn: getResponse.credentialProviderArn }; } catch (error) { const errorName = (error as { name?: string }).name; if (errorName === 'ConflictException' || errorName === 'ResourceAlreadyExistsException') { - return { success: true }; + try { + const getResponse = await client.send(new GetApiKeyCredentialProviderCommand({ name: providerName })); + return { success: true, credentialProviderArn: getResponse.credentialProviderArn }; + } catch { + return { success: true }; + } } return { success: false, @@ -68,7 +75,7 @@ export async function updateApiKeyProvider( client: BedrockAgentCoreControlClient, providerName: string, apiKey: string -): Promise<{ success: boolean; error?: string }> { +): Promise<{ success: boolean; credentialProviderArn?: string; error?: string }> { try { await client.send( new UpdateApiKeyCredentialProviderCommand({ @@ -76,7 +83,9 @@ export async function updateApiKeyProvider( apiKey: apiKey, }) ); - return { success: true }; + // Update response doesn't include credentialProviderArn — fetch it + const getResponse = await client.send(new GetApiKeyCredentialProviderCommand({ name: providerName })); + return { success: true, credentialProviderArn: getResponse.credentialProviderArn }; } catch (error) { return { success: false, diff --git a/src/cli/operations/identity/oauth2-credential-provider.ts b/src/cli/operations/identity/oauth2-credential-provider.ts index e148d61f..cd037670 100644 --- a/src/cli/operations/identity/oauth2-credential-provider.ts +++ b/src/cli/operations/identity/oauth2-credential-provider.ts @@ -96,7 +96,12 @@ export async function createOAuth2Provider( ): Promise<{ success: boolean; result?: OAuth2ProviderResult; error?: string }> { try { const response = await client.send(new CreateOauth2CredentialProviderCommand(buildOAuth2Config(params))); - const result = extractResult(response); + let result = extractResult(response); + if (!result) { + // Create response may not include credentialProviderArn — fetch it + const getResult = await getOAuth2Provider(client, params.name); + result = getResult.result; + } if (!result) { return { success: false, error: 'No credential provider ARN in response' }; } @@ -146,7 +151,11 @@ export async function updateOAuth2Provider( ): Promise<{ success: boolean; result?: OAuth2ProviderResult; error?: string }> { try { const response = await client.send(new UpdateOauth2CredentialProviderCommand(buildOAuth2Config(params))); - const result = extractResult(response); + let result = extractResult(response); + if (!result) { + const getResult = await getOAuth2Provider(client, params.name); + result = getResult.result; + } if (!result) { return { success: false, error: 'No credential provider ARN in response' }; } diff --git a/src/cli/tui/hooks/useCdkPreflight.ts b/src/cli/tui/hooks/useCdkPreflight.ts index 785e60c9..9b95c742 100644 --- a/src/cli/tui/hooks/useCdkPreflight.ts +++ b/src/cli/tui/hooks/useCdkPreflight.ts @@ -1,4 +1,5 @@ -import { SecureCredentials } from '../../../lib'; +import { ConfigIO, SecureCredentials } from '../../../lib'; +import type { DeployedState } from '../../../schema'; import { AwsCredentialsError, validateAwsCredentials } from '../../aws/account'; import { type CdkToolkitWrapper, type SwitchableIoHost, createSwitchableIoHost } from '../../cdk/toolkit-lib'; import { getErrorMessage, isExpiredTokenError, isNoCredentialsError } from '../../errors'; @@ -361,6 +362,20 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { return; } + // Check if API key providers need setup before CDK synth (CDK needs credential ARNs) + // Skip this check if skipIdentityCheck is true (e.g., plan command only synthesizes) + const needsCredentialSetup = !skipIdentityCheck && (hasIdentityApiProviders(preflightContext.projectSpec) || hasIdentityOAuthProviders(preflightContext.projectSpec)); + if (needsCredentialSetup) { + // Get all credentials for the prompt (not just missing ones) + const allCredentials = getAllCredentials(preflightContext.projectSpec); + + // Always show dialog when credentials exist + setMissingCredentials(allCredentials); + setPhase('credentials-prompt'); + isRunningRef.current = false; // Reset so identity-setup can run after user input + return; + } + // Step: Synthesize CloudFormation updateStep(STEP_SYNTH, { status: 'running' }); logger.startStep('Synthesize CloudFormation'); @@ -422,20 +437,6 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { updateStep(STEP_STACK_STATUS, { status: 'success' }); } - // Check if API key providers need setup - always prompt user for credential source - // Skip this check if skipIdentityCheck is true (e.g., plan command only synthesizes) - const needsApiKeySetup = !skipIdentityCheck && hasIdentityApiProviders(preflightContext.projectSpec); - if (needsApiKeySetup) { - // Get all credentials for the prompt (not just missing ones) - const allCredentials = getAllCredentials(preflightContext.projectSpec); - - // Always show dialog when credentials exist - setMissingCredentials(allCredentials); - setPhase('credentials-prompt'); - isRunningRef.current = false; // Reset so identity-setup can run after user input - return; - } - // Check if bootstrap is needed const bootstrapCheck = await checkBootstrapNeeded(preflightContext.awsTargets); if (bootstrapCheck.needsBootstrap && bootstrapCheck.target) { @@ -566,6 +567,19 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { logger.endStep('success'); setSteps(prev => prev.map((s, i) => (i === prev.length - 1 ? { ...s, status: 'success' } : s))); + // Collect API Key credential ARNs for deployed state + const deployedCredentials: Record< + string, + { credentialProviderArn: string; clientSecretArn?: string; callbackUrl?: string } + > = {}; + for (const result of identityResult.results) { + if (result.credentialProviderArn) { + deployedCredentials[result.providerName] = { + credentialProviderArn: result.credentialProviderArn, + }; + } + } + // Set up OAuth credential providers if needed if (hasIdentityOAuthProviders(context.projectSpec)) { setSteps(prev => [...prev, { label: 'Set up OAuth providers', status: 'running' }]); @@ -617,19 +631,57 @@ export function useCdkPreflight(options: PreflightOptions): PreflightResult { } } setOauthCredentials(creds); + Object.assign(deployedCredentials, creds); logger.endStep('success'); setSteps(prev => prev.map((s, i) => (i === prev.length - 1 ? { ...s, status: 'success' } : s))); } + // Write partial deployed state with credential ARNs before CDK synth + if (Object.keys(deployedCredentials).length > 0) { + const configIO = new ConfigIO(); + const target = context.awsTargets[0]; + const existingState = await configIO.readDeployedState().catch(() => ({ targets: {} } as DeployedState)); + const targetState = existingState.targets?.[target!.name] ?? { resources: {} }; + targetState.resources ??= {}; + targetState.resources.credentials = deployedCredentials; + if (identityResult.kmsKeyArn) targetState.resources.identityKmsKeyArn = identityResult.kmsKeyArn; + await configIO.writeDeployedState({ + ...existingState, + targets: { ...existingState.targets, [target!.name]: targetState }, + }); + } + // Clear runtime credentials setRuntimeCredentials(null); + // Re-synth now that credentials are in deployed state + updateStep(STEP_SYNTH, { status: 'running' }); + logger.startStep('Synthesize CloudFormation'); + try { + const synthResult = await synthesizeCdk(context.cdkProject, { + ioHost: switchableIoHost.ioHost, + previousWrapper: wrapperRef.current, + }); + wrapperRef.current = synthResult.toolkitWrapper; + setCdkToolkitWrapper(synthResult.toolkitWrapper); + setStackNames(synthResult.stackNames); + logger.endStep('success'); + updateStep(STEP_SYNTH, { status: 'success' }); + } catch (err) { + const errorMsg = formatError(err); + logger.endStep('error', errorMsg); + updateStep(STEP_SYNTH, { status: 'error', error: logger.getFailureMessage('Synthesize CloudFormation') }); + setPhase('error'); + isRunningRef.current = false; + return; + } + // Check if bootstrap is needed const bootstrapCheck = await checkBootstrapNeeded(context.awsTargets); if (bootstrapCheck.needsBootstrap && bootstrapCheck.target) { setBootstrapContext({ - toolkitWrapper: wrapperRef.current!, + toolkitWrapper: wrapperRef.current, target: bootstrapCheck.target, }); setPhase('bootstrap-confirm');