diff --git a/.gitignore b/.gitignore index 3b00d92ae..eb1399efa 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,9 @@ ProtocolTesting/ # Auto-cloned CDK constructs (from scripts/bundle.mjs) .cdk-constructs-clone/ + +# E2E test artifacts +e2e-tests/fixtures/import/bugbash-resources.json + +# oh-my-claudecode +.omc/ diff --git a/e2e-tests/fixtures/import/cleanup_resources.py b/e2e-tests/fixtures/import/cleanup_resources.py index 120ced18f..0728b711e 100644 --- a/e2e-tests/fixtures/import/cleanup_resources.py +++ b/e2e-tests/fixtures/import/cleanup_resources.py @@ -51,6 +51,10 @@ def main(): rid = val.get("id") if not rid: continue + # Gateway targets are deleted automatically when the parent gateway is deleted + if "gateway" in key and "target" in key: + print(f"Skipping {key} (deleted with parent gateway)") + continue try: if "runtime" in key: client.delete_agent_runtime(agentRuntimeId=rid) @@ -58,6 +62,8 @@ def main(): client.delete_memory(memoryId=rid) elif "evaluator" in key: client.delete_evaluator(evaluatorId=rid) + elif "gateway" in key: + client.delete_gateway(gatewayIdentifier=rid) print(f"Deleted {key}: {rid}") except Exception as e: print(f"Could not delete {key} ({rid}): {e}") diff --git a/e2e-tests/fixtures/import/common.py b/e2e-tests/fixtures/import/common.py index b49ffb277..369ec0bb0 100644 --- a/e2e-tests/fixtures/import/common.py +++ b/e2e-tests/fixtures/import/common.py @@ -256,3 +256,48 @@ def tag_resource(client, arn, tags): """Tag a resource via the control plane API.""" print(f"Tagging resource with {tags}...") client.tag_resource(resourceArn=arn, tags=tags) + + +def wait_for_gateway(client, gateway_id, timeout=120): + """Wait for a gateway to reach READY status.""" + print(f"Waiting for gateway {gateway_id} to become READY...") + start = time.time() + while time.time() - start < timeout: + resp = client.get_gateway(gatewayIdentifier=gateway_id) + status = resp.get("status", "UNKNOWN") + if status == "READY": + print(f"Gateway {gateway_id} is READY") + return True + if status in ("CREATE_FAILED", "FAILED"): + reason = resp.get("statusReasons", [{}]) + print(f"ERROR: Gateway {gateway_id} status: {status} — {reason}") + return False + elapsed = int(time.time() - start) + print(f" Status: {status} ({elapsed}s elapsed)") + time.sleep(5) + print(f"WARNING: Gateway did not reach READY after {timeout}s") + return False + + +def wait_for_gateway_target(client, gateway_id, target_id, timeout=120): + """Wait for a gateway target to reach READY status.""" + print(f"Waiting for target {target_id} to become READY...") + start = time.time() + while time.time() - start < timeout: + resp = client.get_gateway_target( + gatewayIdentifier=gateway_id, + targetId=target_id, + ) + status = resp.get("status", "UNKNOWN") + if status == "READY": + print(f"Target {target_id} is READY") + return True + if status in ("CREATE_FAILED", "FAILED"): + reason = resp.get("statusReasons", [{}]) + print(f"ERROR: Target {target_id} status: {status} — {reason}") + return False + elapsed = int(time.time() - start) + print(f" Status: {status} ({elapsed}s elapsed)") + time.sleep(5) + print(f"WARNING: Target did not reach READY after {timeout}s") + return False diff --git a/e2e-tests/fixtures/import/setup_gateway.py b/e2e-tests/fixtures/import/setup_gateway.py new file mode 100644 index 000000000..e190d0dfc --- /dev/null +++ b/e2e-tests/fixtures/import/setup_gateway.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Setup: Gateway with MCP server target + tags. + +Tests: gateway import, target mapping, authorizerType, enableSemanticSearch, + exceptionLevel, tags, deployed state nesting under mcp.gateways. + +Creates: + 1. A gateway with NONE authorizer + semantic search enabled + 2. An MCP Server target pointing to a public test endpoint +""" +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import time +from common import ( + REGION, get_control_client, ensure_role, save_resource, + tag_resource, wait_for_gateway, wait_for_gateway_target, +) + + +def main(): + role_arn = ensure_role() + client = get_control_client() + ts = int(time.time()) + gateway_name = f"bugbashGw{ts}" + + # ------------------------------------------------------------------ + # 1. Create gateway + # ------------------------------------------------------------------ + print(f"Creating gateway: {gateway_name}") + resp = client.create_gateway( + name=gateway_name, + description="Bugbash gateway for import testing", + roleArn=role_arn, + protocolType="MCP", + protocolConfiguration={ + "mcp": { + "supportedVersions": ["2025-03-26"], + "searchType": "SEMANTIC", + }, + }, + authorizerType="NONE", + exceptionLevel="DEBUG", + ) + + gateway_id = resp["gatewayId"] + gateway_arn = resp["gatewayArn"] + print(f"Gateway ID: {gateway_id}") + print(f"Gateway ARN: {gateway_arn}") + + tag_resource(client, gateway_arn, { + "env": "bugbash", + "team": "agentcore-cli", + }) + + save_resource("gateway", gateway_arn, gateway_id) + + if not wait_for_gateway(client, gateway_id): + print("Gateway creation failed. Aborting target creation.") + sys.exit(1) + + # ------------------------------------------------------------------ + # 2. Create MCP Server target + # ------------------------------------------------------------------ + target_name = "mcpTarget" + print(f"\nCreating MCP Server target: {target_name}") + target_resp = client.create_gateway_target( + gatewayIdentifier=gateway_id, + name=target_name, + targetConfiguration={ + "mcp": { + "mcpServer": { + "endpoint": "https://mcp.exa.ai/mcp", + }, + }, + }, + ) + + target_id = target_resp["targetId"] + print(f"Target ID: {target_id}") + + save_resource("gateway-target-mcp", gateway_arn, target_id) + wait_for_gateway_target(client, gateway_id, target_id) + + +if __name__ == "__main__": + main() diff --git a/e2e-tests/import-resources.test.ts b/e2e-tests/import-resources.test.ts index e97f62f42..67733875e 100644 --- a/e2e-tests/import-resources.test.ts +++ b/e2e-tests/import-resources.test.ts @@ -30,7 +30,7 @@ const hasPython = })(); const canRun = prereqs.npm && prereqs.git && prereqs.uv && hasAws && hasPython; -describe.sequential('e2e: import runtime/memory/evaluator', () => { +describe.sequential('e2e: import runtime/memory/evaluator/gateway', () => { const region = process.env.AWS_REGION ?? 'us-east-1'; const fixtureDir = join(__dirname, 'fixtures', 'import'); const appDir = join(fixtureDir, 'app'); @@ -40,6 +40,7 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => { let runtimeArn: string; let memoryArn: string; let evaluatorArn: string; + let gatewayArn: string; let projectPath: string; let testDir: string; @@ -50,7 +51,7 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => { // Each script creates a resource and saves its ARN/ID to bugbash-resources.json. // Scripts run sequentially because save_resource() does a read-modify-write // on a shared bugbash-resources.json file — parallel runs would race. - for (const script of ['setup_runtime_basic.py', 'setup_memory_full.py', 'setup_evaluator.py']) { + for (const script of ['setup_runtime_basic.py', 'setup_memory_full.py', 'setup_evaluator.py', 'setup_gateway.py']) { const result = await spawnAndCollect('uv', ['run', '--with', 'boto3', 'python3', script], fixtureDir, { AWS_REGION: region, DEFAULT_EVALUATOR_MODEL, @@ -68,6 +69,7 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => { runtimeArn = resources['runtime-basic']!.arn; memoryArn = resources['memory-full']!.arn; evaluatorArn = resources['evaluator-llm']!.arn; + gatewayArn = resources.gateway!.arn; // 3. Create a destination CLI project (no agent — we'll import one) testDir = join(tmpdir(), `agentcore-e2e-import-${randomUUID()}`); @@ -163,6 +165,22 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => { 600_000 ); + it.skipIf(!canRun)( + 'imports a gateway by ARN', + async () => { + const result = await run(['import', 'gateway', '--arn', gatewayArn]); + + if (result.exitCode !== 0) { + console.log('Import gateway stdout:', result.stdout); + console.log('Import gateway stderr:', result.stderr); + } + + expect(result.exitCode, `Import gateway failed: ${result.stderr}`).toBe(0); + expect(stripAnsi(result.stdout).toLowerCase()).toContain('imported successfully'); + }, + 600_000 + ); + // ── Verification tests ──────────────────────────────────────────── it.skipIf(!canRun)( @@ -187,6 +205,73 @@ describe.sequential('e2e: import runtime/memory/evaluator', () => { const evaluator = json.resources.find(r => r.resourceType === 'evaluator'); expect(evaluator, 'Imported evaluator should appear in status').toBeDefined(); + + const gateway = json.resources.find(r => r.resourceType === 'gateway'); + expect(gateway, 'Imported gateway should appear in status').toBeDefined(); + }, + 120_000 + ); + + it.skipIf(!canRun)( + 'agentcore.json has correct gateway fields', + async () => { + const configPath = join(projectPath, 'agentcore', 'agentcore.json'); + const config = JSON.parse(await readFile(configPath, 'utf-8')) as { + agentCoreGateways: { + name: string; + resourceName?: string; + description?: string; + authorizerType: string; + enableSemanticSearch: boolean; + exceptionLevel: string; + executionRoleArn?: string; + tags?: Record; + targets: { name: string; targetType: string; endpoint?: string }[]; + }[]; + }; + + expect(config.agentCoreGateways.length, 'Should have one gateway').toBe(1); + const gw = config.agentCoreGateways[0]!; + + expect(gw.name, 'Gateway name should be set').toBeTruthy(); + expect(gw.resourceName, 'resourceName should preserve AWS name').toBeTruthy(); + expect(gw.description).toBe('Bugbash gateway for import testing'); + expect(gw.authorizerType).toBe('NONE'); + expect(gw.enableSemanticSearch).toBe(true); + expect(gw.exceptionLevel).toBe('DEBUG'); + expect(gw.tags).toEqual({ env: 'bugbash', team: 'agentcore-cli' }); + + expect(gw.executionRoleArn, 'executionRoleArn should be preserved from AWS').toBeTruthy(); + expect(gw.executionRoleArn).toContain('bugbash-agentcore-role'); + + expect(gw.targets.length, 'Should have one target').toBe(1); + expect(gw.targets[0]!.name).toBe('mcpTarget'); + expect(gw.targets[0]!.targetType).toBe('mcpServer'); + expect(gw.targets[0]!.endpoint).toBe('https://mcp.exa.ai/mcp'); + }, + 120_000 + ); + + it.skipIf(!canRun)( + 'deployed-state.json has gateway entry', + async () => { + const statePath = join(projectPath, 'agentcore', '.cli', 'deployed-state.json'); + const state = JSON.parse(await readFile(statePath, 'utf-8')) as Record; + + // Gateway state is stored under targets..resources.mcp.gateways + const targets = state.targets as Record } } }>; + const targetEntries = Object.values(targets); + expect(targetEntries.length).toBeGreaterThan(0); + + const firstTarget = targetEntries[0]!; + const gateways = firstTarget.resources?.mcp?.gateways; + expect(gateways, 'deployed-state should have mcp.gateways entry').toBeDefined(); + + const gatewayEntries = Object.values(gateways!); + expect(gatewayEntries.length, 'Should have one gateway in deployed state').toBe(1); + + const gwState = gatewayEntries[0] as { gatewayId?: string; gatewayArn?: string }; + expect(gwState.gatewayId, 'Gateway ID should be recorded').toBeTruthy(); }, 120_000 ); diff --git a/src/cli/aws/agentcore-control.ts b/src/cli/aws/agentcore-control.ts index d44c6473f..162c2b3a6 100644 --- a/src/cli/aws/agentcore-control.ts +++ b/src/cli/aws/agentcore-control.ts @@ -4,10 +4,14 @@ import { BedrockAgentCoreControlClient, GetAgentRuntimeCommand, GetEvaluatorCommand, + GetGatewayCommand, + GetGatewayTargetCommand, GetMemoryCommand, GetOnlineEvaluationConfigCommand, ListAgentRuntimesCommand, ListEvaluatorsCommand, + ListGatewayTargetsCommand, + ListGatewaysCommand, ListMemoriesCommand, ListOnlineEvaluationConfigsCommand, ListTagsForResourceCommand, @@ -781,3 +785,361 @@ export async function getOnlineEvaluationConfig( evaluatorIds, }; } + +// ============================================================================ +// Gateways — List & Get +// ============================================================================ + +export interface GatewaySummary { + gatewayId: string; + name: string; + status: string; + description?: string; + authorizerType: string; +} + +export interface GatewayDetail { + gatewayId: string; + gatewayArn: string; + gatewayUrl?: string; + name: string; + status: string; + description?: string; + authorizerType: string; + roleArn?: string; + authorizerConfiguration?: { + customJwtAuthorizer?: { + discoveryUrl: string; + allowedAudience?: string[]; + allowedClients?: string[]; + allowedScopes?: string[]; + customClaims?: { + inboundTokenClaimName: string; + inboundTokenClaimValueType: string; + authorizingClaimMatchValue: { + claimMatchValue: { matchValueString?: string; matchValueStringList?: string[] }; + claimMatchOperator: string; + }; + }[]; + }; + }; + protocolConfiguration?: { + mcp?: { searchType?: string }; + }; + exceptionLevel?: string; + policyEngineConfiguration?: { + arn: string; + mode: string; + }; + tags?: Record; +} + +export interface ListGatewaysResult { + gateways: GatewaySummary[]; + nextToken?: string; +} + +export async function listGatewaysPage( + options: { region: string; maxResults?: number; nextToken?: string }, + client?: BedrockAgentCoreControlClient +): Promise { + const resolvedClient = client ?? createControlClient(options.region); + + const command = new ListGatewaysCommand({ + maxResults: options.maxResults, + nextToken: options.nextToken, + }); + + const response = await resolvedClient.send(command); + + return { + gateways: (response.items ?? []).map(g => ({ + gatewayId: g.gatewayId ?? '', + name: g.name ?? '', + status: g.status ?? 'UNKNOWN', + description: g.description, + authorizerType: g.authorizerType ?? 'NONE', + })), + nextToken: response.nextToken, + }; +} + +/** + * List all Gateways in the given region, paginating through all pages. + */ +export async function listAllGateways(options: { region: string }): Promise { + return paginateAll(options.region, async (opts, client) => { + const result = await listGatewaysPage(opts, client); + return { items: result.gateways, nextToken: result.nextToken }; + }); +} + +/** + * Get full details of a Gateway by ID. + */ +export async function getGatewayDetail(options: { region: string; gatewayId: string }): Promise { + const client = createControlClient(options.region); + + const command = new GetGatewayCommand({ + gatewayIdentifier: options.gatewayId, + }); + + const response = await client.send(command); + + let authorizerConfiguration: GatewayDetail['authorizerConfiguration']; + if (response.authorizerConfiguration && 'customJWTAuthorizer' in response.authorizerConfiguration) { + const jwt = response.authorizerConfiguration.customJWTAuthorizer; + if (jwt) { + authorizerConfiguration = { + customJwtAuthorizer: { + discoveryUrl: jwt.discoveryUrl ?? '', + allowedAudience: jwt.allowedAudience, + allowedClients: jwt.allowedClients, + allowedScopes: jwt.allowedScopes, + customClaims: jwt.customClaims?.map(c => ({ + inboundTokenClaimName: c.inboundTokenClaimName ?? '', + inboundTokenClaimValueType: c.inboundTokenClaimValueType ?? 'STRING', + authorizingClaimMatchValue: { + claimMatchValue: { + matchValueString: + c.authorizingClaimMatchValue?.claimMatchValue && + 'matchValueString' in c.authorizingClaimMatchValue.claimMatchValue + ? c.authorizingClaimMatchValue.claimMatchValue.matchValueString + : undefined, + matchValueStringList: + c.authorizingClaimMatchValue?.claimMatchValue && + 'matchValueStringList' in c.authorizingClaimMatchValue.claimMatchValue + ? c.authorizingClaimMatchValue.claimMatchValue.matchValueStringList + : undefined, + }, + claimMatchOperator: c.authorizingClaimMatchValue?.claimMatchOperator ?? 'EQUALS', + }, + })), + }, + }; + } + } + + let protocolConfiguration: GatewayDetail['protocolConfiguration']; + if (response.protocolConfiguration && 'mcp' in response.protocolConfiguration) { + protocolConfiguration = { + mcp: { searchType: response.protocolConfiguration.mcp?.searchType }, + }; + } + + const tags = await fetchTags(client, response.gatewayArn, 'gateway'); + + return { + gatewayId: response.gatewayId ?? '', + gatewayArn: response.gatewayArn ?? '', + gatewayUrl: response.gatewayUrl, + name: response.name ?? '', + status: response.status ?? 'UNKNOWN', + description: response.description, + authorizerType: response.authorizerType ?? 'NONE', + roleArn: response.roleArn, + authorizerConfiguration, + protocolConfiguration, + exceptionLevel: response.exceptionLevel, + policyEngineConfiguration: response.policyEngineConfiguration + ? { arn: response.policyEngineConfiguration.arn ?? '', mode: response.policyEngineConfiguration.mode ?? '' } + : undefined, + tags, + }; +} + +// ============================================================================ +// Gateway Targets — List & Get +// ============================================================================ + +export interface GatewayTargetSummary { + targetId: string; + name: string; + status: string; + description?: string; +} + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export interface GatewayTargetDetail { + targetId: string; + name: string; + status: string; + description?: string; + targetConfiguration?: { + mcp?: { + mcpServer?: { endpoint: string }; + apiGateway?: { + restApiId: string; + stage: string; + apiGatewayToolConfiguration?: { + toolFilters?: { filterPath: string; methods: string[] }[]; + toolOverrides?: { name: string; path: string; method: string; description?: string }[]; + }; + }; + openApiSchema?: { s3?: { uri: string; bucketOwnerAccountId?: string }; inlinePayload?: string }; + smithyModel?: { s3?: { uri: string; bucketOwnerAccountId?: string }; inlinePayload?: string }; + lambda?: { lambdaArn: string; toolSchema?: any }; + }; + }; + credentialProviderConfigurations?: { + credentialProviderType: string; + credentialProvider?: { + oauthCredentialProvider?: { providerArn: string; scopes?: string[] }; + apiKeyCredentialProvider?: { providerArn: string }; + }; + }[]; +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + +export interface ListGatewayTargetsResult { + targets: GatewayTargetSummary[]; + nextToken?: string; +} + +export async function listGatewayTargetsPage( + options: { region: string; gatewayId: string; maxResults?: number; nextToken?: string }, + client?: BedrockAgentCoreControlClient +): Promise { + const resolvedClient = client ?? createControlClient(options.region); + + const command = new ListGatewayTargetsCommand({ + gatewayIdentifier: options.gatewayId, + maxResults: options.maxResults, + nextToken: options.nextToken, + }); + + const response = await resolvedClient.send(command); + + return { + targets: (response.items ?? []).map(t => ({ + targetId: t.targetId ?? '', + name: t.name ?? '', + status: t.status ?? 'UNKNOWN', + description: t.description, + })), + nextToken: response.nextToken, + }; +} + +/** + * List all targets for a Gateway, paginating through all pages. + */ +export async function listAllGatewayTargets(options: { + region: string; + gatewayId: string; +}): Promise { + const client = createControlClient(options.region); + const items: GatewayTargetSummary[] = []; + let nextToken: string | undefined; + + do { + const result = await listGatewayTargetsPage( + { region: options.region, gatewayId: options.gatewayId, maxResults: 100, nextToken }, + client + ); + items.push(...result.targets); + nextToken = result.nextToken; + } while (nextToken); + + return items; +} + +/** + * Get full details of a Gateway Target by gateway ID and target ID. + */ +export async function getGatewayTargetDetail(options: { + region: string; + gatewayId: string; + targetId: string; +}): Promise { + const client = createControlClient(options.region); + + const command = new GetGatewayTargetCommand({ + gatewayIdentifier: options.gatewayId, + targetId: options.targetId, + }); + + const response = await client.send(command); + + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ + let targetConfiguration: GatewayTargetDetail['targetConfiguration']; + if (response.targetConfiguration && 'mcp' in response.targetConfiguration) { + const mcp = response.targetConfiguration.mcp as any; + targetConfiguration = { mcp: {} }; + + if (mcp?.mcpServer) { + targetConfiguration.mcp!.mcpServer = { endpoint: mcp.mcpServer.endpoint ?? '' }; + } + if (mcp?.apiGateway) { + targetConfiguration.mcp!.apiGateway = { + restApiId: mcp.apiGateway.restApiId ?? '', + stage: mcp.apiGateway.stage ?? '', + apiGatewayToolConfiguration: mcp.apiGateway.apiGatewayToolConfiguration + ? { + toolFilters: mcp.apiGateway.apiGatewayToolConfiguration.toolFilters?.map((f: any) => ({ + filterPath: f.filterPath ?? '', + methods: f.methods ?? [], + })), + toolOverrides: mcp.apiGateway.apiGatewayToolConfiguration.toolOverrides?.map((o: any) => ({ + name: o.name ?? '', + path: o.path ?? '', + method: o.method ?? '', + description: o.description, + })), + } + : undefined, + }; + } + if (mcp?.openApiSchema) { + targetConfiguration.mcp!.openApiSchema = { + s3: mcp.openApiSchema.s3 + ? { uri: mcp.openApiSchema.s3.uri ?? '', bucketOwnerAccountId: mcp.openApiSchema.s3.bucketOwnerAccountId } + : undefined, + inlinePayload: mcp.openApiSchema.inlinePayload, + }; + } + if (mcp?.smithyModel) { + targetConfiguration.mcp!.smithyModel = { + s3: mcp.smithyModel.s3 + ? { uri: mcp.smithyModel.s3.uri ?? '', bucketOwnerAccountId: mcp.smithyModel.s3.bucketOwnerAccountId } + : undefined, + inlinePayload: mcp.smithyModel.inlinePayload, + }; + } + if (mcp?.lambda) { + targetConfiguration.mcp!.lambda = { + lambdaArn: mcp.lambda.lambdaArn ?? '', + toolSchema: mcp.lambda.toolSchema, + }; + } + } + + const credentialProviderConfigurations: GatewayTargetDetail['credentialProviderConfigurations'] = ( + response.credentialProviderConfigurations ?? [] + ).map((c: any) => ({ + credentialProviderType: c.credentialProviderType ?? '', + credentialProvider: c.credentialProvider + ? { + oauthCredentialProvider: c.credentialProvider.oauthCredentialProvider + ? { + providerArn: c.credentialProvider.oauthCredentialProvider.providerArn ?? '', + scopes: c.credentialProvider.oauthCredentialProvider.scopes, + } + : undefined, + apiKeyCredentialProvider: c.credentialProvider.apiKeyCredentialProvider + ? { providerArn: c.credentialProvider.apiKeyCredentialProvider.providerArn ?? '' } + : undefined, + } + : undefined, + })); + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call */ + + return { + targetId: response.targetId ?? '', + name: response.name ?? '', + status: response.status ?? 'UNKNOWN', + description: response.description, + targetConfiguration, + credentialProviderConfigurations, + }; +} diff --git a/src/cli/commands/import/__tests__/import-gateway-cfn.test.ts b/src/cli/commands/import/__tests__/import-gateway-cfn.test.ts new file mode 100644 index 000000000..a402cc2e2 --- /dev/null +++ b/src/cli/commands/import/__tests__/import-gateway-cfn.test.ts @@ -0,0 +1,279 @@ +/** + * Tests for buildCredentialArnMap and CFN template resource matching logic + * used in the gateway import flow. + */ +import { buildCredentialArnMap } from '../import-gateway'; +import type { CfnTemplate } from '../template-utils'; +import { findLogicalIdByProperty, findLogicalIdsByType } from '../template-utils'; +import { describe, expect, it } from 'vitest'; + +// ============================================================================ +// Part 1: buildCredentialArnMap +// ============================================================================ + +describe('buildCredentialArnMap', () => { + it('reads credentials from deployed state', async () => { + const configIO = { + readDeployedState: () => + Promise.resolve({ + targets: { + default: { + resources: { + credentials: { + myCred: { credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential/myCred' }, + }, + }, + }, + }, + }), + }; + + const map = await buildCredentialArnMap(configIO, 'default'); + expect(map.size).toBe(1); + expect(map.get('arn:aws:bedrock:us-east-1:123456789012:credential/myCred')).toBe('myCred'); + }); + + it('handles multiple credentials', async () => { + const configIO = { + readDeployedState: () => + Promise.resolve({ + targets: { + default: { + resources: { + credentials: { + oauthCred: { credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential/oauth' }, + apiKeyCred: { credentialProviderArn: 'arn:aws:bedrock:us-east-1:123456789012:credential/apikey' }, + }, + }, + }, + }, + }), + }; + + const map = await buildCredentialArnMap(configIO, 'default'); + expect(map.size).toBe(2); + expect(map.get('arn:aws:bedrock:us-east-1:123456789012:credential/oauth')).toBe('oauthCred'); + expect(map.get('arn:aws:bedrock:us-east-1:123456789012:credential/apikey')).toBe('apiKeyCred'); + }); + + it('returns empty map when readDeployedState throws', async () => { + const configIO = { + readDeployedState: () => Promise.reject(new Error('No deployed state file')), + }; + + const map = await buildCredentialArnMap(configIO, 'default'); + expect(map.size).toBe(0); + }); + + it('returns empty map when no credentials key exists', async () => { + const configIO = { + readDeployedState: () => + Promise.resolve({ + targets: { + default: { + resources: {}, + }, + }, + }), + }; + + const map = await buildCredentialArnMap(configIO, 'default'); + expect(map.size).toBe(0); + }); + + it('returns empty map when targets is empty', async () => { + const configIO = { + readDeployedState: () => Promise.resolve({ targets: {} }), + }; + + const map = await buildCredentialArnMap(configIO, 'default'); + expect(map.size).toBe(0); + }); +}); + +// ============================================================================ +// Part 2: CFN template matching (findLogicalIdByProperty, findLogicalIdsByType) +// ============================================================================ + +describe('findLogicalIdByProperty – gateway scenarios', () => { + it('finds gateway by Name = projectName-localName', () => { + const template: CfnTemplate = { + Resources: { + MyGatewayResource: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: 'myProject-myGateway', + }, + }, + }, + }; + + const result = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::Gateway', 'Name', 'myProject-myGateway'); + expect(result).toBe('MyGatewayResource'); + }); + + it('finds gateway by resourceName (localName only) as fallback', () => { + const template: CfnTemplate = { + Resources: { + GatewayA: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: 'someOtherName', + }, + }, + GatewayB: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: 'myGateway', + }, + }, + }, + }; + + const result = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::Gateway', 'Name', 'myGateway'); + expect(result).toBe('GatewayB'); + }); + + it('finds target by Name property', () => { + const template: CfnTemplate = { + Resources: { + TargetLogical1: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { + Name: 'mcpTarget', + }, + }, + }, + }; + + const result = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::GatewayTarget', 'Name', 'mcpTarget'); + expect(result).toBe('TargetLogical1'); + }); +}); + +describe('findLogicalIdsByType – gateway fallback', () => { + it('returns the single gateway when name-based lookup fails', () => { + const template: CfnTemplate = { + Resources: { + OnlyGateway: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: 'completely-different-name', + }, + }, + SomeRole: { + Type: 'AWS::IAM::Role', + Properties: {}, + }, + }, + }; + + // Name-based lookup fails + const byName = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::Gateway', 'Name', 'myProject-myGateway'); + expect(byName).toBeUndefined(); + + // Type-based fallback returns the single gateway + const allGateways = findLogicalIdsByType(template, 'AWS::BedrockAgentCore::Gateway'); + expect(allGateways).toHaveLength(1); + expect(allGateways[0]).toBe('OnlyGateway'); + }); + + it('returns single target for fallback when one target and one in targetIdMap', () => { + const template: CfnTemplate = { + Resources: { + OnlyTarget: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { + Name: 'different-name', + }, + }, + }, + }; + + const allTargets = findLogicalIdsByType(template, 'AWS::BedrockAgentCore::GatewayTarget'); + expect(allTargets).toHaveLength(1); + expect(allTargets[0]).toBe('OnlyTarget'); + }); + + it('returns multiple targets preventing fallback when more than one exists', () => { + const template: CfnTemplate = { + Resources: { + Target1: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { Name: 'targetA' }, + }, + Target2: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { Name: 'targetB' }, + }, + }, + }; + + const allTargets = findLogicalIdsByType(template, 'AWS::BedrockAgentCore::GatewayTarget'); + expect(allTargets).toHaveLength(2); + + // Name-based matching must succeed — fallback is not safe with multiple targets + // Simulate the import-gateway logic: only fallback if allTargets.length === 1 && targetIdMap.size === 1 + const targetIdMap = new Map([ + ['targetA', 'tid-1'], + ['targetB', 'tid-2'], + ]); + const shouldFallback = allTargets.length === 1 && targetIdMap.size === 1; + expect(shouldFallback).toBe(false); + }); +}); + +// ============================================================================ +// Part 3: Fn::Join / Fn::Sub patterns in findLogicalIdByProperty +// ============================================================================ + +describe('findLogicalIdByProperty – intrinsic function patterns', () => { + it('matches Fn::Join Name via regex second pass', () => { + const template: CfnTemplate = { + Resources: { + JoinGateway: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: { 'Fn::Join': ['-', ['prefix', 'myGateway']] }, + }, + }, + }, + }; + + const result = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::Gateway', 'Name', 'myGateway'); + expect(result).toBe('JoinGateway'); + }); + + it('avoids false substring matches with regex boundary check', () => { + const template: CfnTemplate = { + Resources: { + WrongGateway: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: { 'Fn::Join': ['-', ['prefix', 'myGateway_v2']] }, + }, + }, + }, + }; + + const result = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::Gateway', 'Name', 'myGateway'); + // "myGateway" should NOT match "myGateway_v2" due to boundary check + expect(result).toBeUndefined(); + }); + + it('matches Fn::Sub Name via regex second pass', () => { + const template: CfnTemplate = { + Resources: { + SubGateway: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { + Name: { 'Fn::Sub': '${AWS::StackName}-myGateway' }, + }, + }, + }, + }; + + const result = findLogicalIdByProperty(template, 'AWS::BedrockAgentCore::Gateway', 'Name', 'myGateway'); + expect(result).toBe('SubGateway'); + }); +}); diff --git a/src/cli/commands/import/__tests__/import-gateway-flow.test.ts b/src/cli/commands/import/__tests__/import-gateway-flow.test.ts new file mode 100644 index 000000000..1e58d5073 --- /dev/null +++ b/src/cli/commands/import/__tests__/import-gateway-flow.test.ts @@ -0,0 +1,455 @@ +/** + * Tests for handleImportGateway — the main gateway import flow. + * + * Covers: + * - Happy path: successful import with --arn + * - Rollback on pipeline failure and noResources + * - Duplicate detection (name + deployed state ID) + * - Name validation (invalid name, --name override) + * - Auto-select / multi-gateway / no gateways + * - Skipped targets warning + * - Non-READY gateway warning + */ +import { handleImportGateway } from '../import-gateway'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// ── Hoisted mock fns ──────────────────────────────────────────────────────── + +const { + mockFindConfigRoot, + mockConfigIOInstance, + MockConfigIOClass, + mockValidateAwsCredentials, + mockDetectAccount, + mockGetGatewayDetail, + mockListAllGateways, + mockListAllGatewayTargets, + mockGetGatewayTargetDetail, + mockExecuteCdkImportPipeline, +} = vi.hoisted(() => { + const inst = { + readProjectSpec: vi.fn(), + writeProjectSpec: vi.fn(), + readAWSDeploymentTargets: vi.fn(), + readDeployedState: vi.fn(), + writeDeployedState: vi.fn(), + }; + return { + mockFindConfigRoot: vi.fn(), + mockConfigIOInstance: inst, + MockConfigIOClass: vi.fn(function (this: any) { + Object.assign(this, inst); + return this; + }), + mockValidateAwsCredentials: vi.fn(), + mockDetectAccount: vi.fn(), + mockGetGatewayDetail: vi.fn(), + mockListAllGateways: vi.fn(), + mockListAllGatewayTargets: vi.fn(), + mockGetGatewayTargetDetail: vi.fn(), + mockExecuteCdkImportPipeline: vi.fn(), + }; +}); + +// ── Module mocks ───────────────────────────────────────────────────────────── + +vi.mock('../../../../lib', () => ({ + APP_DIR: 'app', + ConfigIO: MockConfigIOClass, + findConfigRoot: (...args: unknown[]) => mockFindConfigRoot(...args), +})); + +vi.mock('../../../aws/account', () => ({ + validateAwsCredentials: (...args: unknown[]) => mockValidateAwsCredentials(...args), + detectAccount: (...args: unknown[]) => mockDetectAccount(...args), +})); + +vi.mock('../../../aws/agentcore-control', () => ({ + getGatewayDetail: (...args: unknown[]) => mockGetGatewayDetail(...args), + listAllGateways: (...args: unknown[]) => mockListAllGateways(...args), + listAllGatewayTargets: (...args: unknown[]) => mockListAllGatewayTargets(...args), + getGatewayTargetDetail: (...args: unknown[]) => mockGetGatewayTargetDetail(...args), +})); + +vi.mock('../../../logging', () => ({ + ExecLogger: class MockExecLogger { + startStep = vi.fn(); + endStep = vi.fn(); + log = vi.fn(); + finalize = vi.fn(); + getRelativeLogPath = vi.fn().mockReturnValue('agentcore/.cli/logs/import/import-gateway-mock.log'); + logFilePath = 'agentcore/.cli/logs/import/import-gateway-mock.log'; + }, +})); + +vi.mock('../import-pipeline', () => ({ + executeCdkImportPipeline: (...args: unknown[]) => mockExecuteCdkImportPipeline(...args), +})); + +// ── Test Fixtures ──────────────────────────────────────────────────────────── + +const ACCOUNT = '123456789012'; +const REGION = 'us-east-1'; +const GATEWAY_ID = 'gw-abc123'; +const GATEWAY_ARN = `arn:aws:bedrock-agentcore:${REGION}:${ACCOUNT}:gateway/${GATEWAY_ID}`; +const GATEWAY_NAME = 'MyGateway'; + +function makeProjectSpec(gateways: any[] = []) { + return { + name: 'TestProject', + version: 1, + runtimes: [], + memories: [], + credentials: [], + agentCoreGateways: gateways, + }; +} + +function makeGatewayDetail(overrides?: Record) { + return { + gatewayId: GATEWAY_ID, + gatewayArn: GATEWAY_ARN, + name: GATEWAY_NAME, + status: 'READY', + authorizerType: 'NONE', + ...overrides, + }; +} + +function makeTargetSummary(id: string, name: string) { + return { targetId: id, name, status: 'READY' }; +} + +function makeTargetDetail(id: string, name: string, endpoint: string) { + return { + targetId: id, + name, + status: 'READY', + targetConfiguration: { + mcp: { + mcpServer: { endpoint }, + }, + }, + }; +} + +// ── Common setup ───────────────────────────────────────────────────────────── + +function setupCommonMocks() { + mockFindConfigRoot.mockReturnValue('/tmp/project/agentcore'); + + mockConfigIOInstance.readAWSDeploymentTargets.mockResolvedValue([ + { name: 'default', account: ACCOUNT, region: REGION }, + ]); + + mockValidateAwsCredentials.mockResolvedValue(undefined); + mockDetectAccount.mockResolvedValue(ACCOUNT); + + mockConfigIOInstance.readProjectSpec.mockResolvedValue(makeProjectSpec()); + mockConfigIOInstance.writeProjectSpec.mockResolvedValue(undefined); + mockConfigIOInstance.readDeployedState.mockResolvedValue({ targets: {} }); + + mockGetGatewayDetail.mockResolvedValue(makeGatewayDetail()); + mockListAllGateways.mockResolvedValue([ + { gatewayId: GATEWAY_ID, name: GATEWAY_NAME, status: 'READY', authorizerType: 'NONE' }, + ]); + mockListAllGatewayTargets.mockResolvedValue([makeTargetSummary('tgt-1', 'target1')]); + mockGetGatewayTargetDetail.mockResolvedValue(makeTargetDetail('tgt-1', 'target1', 'https://example.com/mcp')); + + mockExecuteCdkImportPipeline.mockResolvedValue({ success: true }); +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('handleImportGateway', () => { + beforeEach(() => { + vi.clearAllMocks(); + setupCommonMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // ── Happy path ────────────────────────────────────────────────────────── + + describe('Happy path', () => { + it('successfully imports a gateway with --arn', async () => { + const result = await handleImportGateway({ arn: GATEWAY_ARN }); + + expect(result.success).toBe(true); + expect(result.resourceId).toBe(GATEWAY_ID); + expect(result.resourceType).toBe('gateway'); + expect(result.resourceName).toBe(GATEWAY_NAME); + + // writeProjectSpec called once with gateway added + expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(1); + const writtenSpec = mockConfigIOInstance.writeProjectSpec.mock.calls[0]![0]; + expect(writtenSpec.agentCoreGateways).toHaveLength(1); + expect(writtenSpec.agentCoreGateways[0].name).toBe(GATEWAY_NAME); + expect(writtenSpec.agentCoreGateways[0].targets).toHaveLength(1); + }); + }); + + // ── Rollback ──────────────────────────────────────────────────────────── + + describe('Rollback', () => { + it('rolls back config on pipeline failure', async () => { + mockExecuteCdkImportPipeline.mockResolvedValue({ success: false, error: 'Phase 2 failed' }); + + const result = await handleImportGateway({ arn: GATEWAY_ARN }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Phase 2 failed'); + + // First call = write merged config, second call = rollback + expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2); + const rollbackSpec = mockConfigIOInstance.writeProjectSpec.mock.calls[1]![0]; + expect(rollbackSpec.agentCoreGateways).toEqual([]); + }); + + it('rolls back config on noResources (logical ID not found)', async () => { + mockExecuteCdkImportPipeline.mockResolvedValue({ success: true, noResources: true }); + + const result = await handleImportGateway({ arn: GATEWAY_ARN }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Could not find logical ID'); + + // First call = write merged config, second call = rollback + expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2); + const rollbackSpec = mockConfigIOInstance.writeProjectSpec.mock.calls[1]![0]; + expect(rollbackSpec.agentCoreGateways).toEqual([]); + }); + }); + + // ── Duplicate detection ───────────────────────────────────────────────── + + describe('Duplicate detection', () => { + it('rejects when gateway name already exists in project', async () => { + mockConfigIOInstance.readProjectSpec.mockResolvedValue(makeProjectSpec([{ name: GATEWAY_NAME, targets: [] }])); + + const result = await handleImportGateway({ arn: GATEWAY_ARN }); + + expect(result.success).toBe(false); + expect(result.error).toContain('already exists'); + expect(mockConfigIOInstance.writeProjectSpec).not.toHaveBeenCalled(); + }); + + it('rejects when gateway ID is already tracked in deployed state', async () => { + mockConfigIOInstance.readDeployedState.mockResolvedValue({ + targets: { + default: { + resources: { + mcp: { + gateways: { + ExistingGateway: { gatewayId: GATEWAY_ID }, + }, + }, + }, + }, + }, + }); + + const result = await handleImportGateway({ arn: GATEWAY_ARN }); + + expect(result.success).toBe(false); + expect(result.error).toContain('already imported'); + expect(mockConfigIOInstance.writeProjectSpec).not.toHaveBeenCalled(); + }); + }); + + // ── Name validation ───────────────────────────────────────────────────── + + describe('Name validation', () => { + it('rejects invalid name starting with a number', async () => { + mockGetGatewayDetail.mockResolvedValue(makeGatewayDetail({ name: '123gateway' })); + + const result = await handleImportGateway({ arn: GATEWAY_ARN }); + + expect(result.success).toBe(false); + expect(result.error).toContain('Invalid name'); + expect(result.error).toContain('must start with a letter'); + expect(mockConfigIOInstance.writeProjectSpec).not.toHaveBeenCalled(); + }); + + it('uses --name override with original resourceName preserved', async () => { + const result = await handleImportGateway({ arn: GATEWAY_ARN, name: 'myCustomName' }); + + expect(result.success).toBe(true); + expect(result.resourceName).toBe('myCustomName'); + + const writtenSpec = mockConfigIOInstance.writeProjectSpec.mock.calls[0]![0]; + const addedGateway = writtenSpec.agentCoreGateways[0]; + expect(addedGateway.name).toBe('myCustomName'); + expect(addedGateway.resourceName).toBe(GATEWAY_NAME); + }); + }); + + // ── Auto-select / multi-gateway ───────────────────────────────────────── + + describe('Auto-select / multi-gateway', () => { + it('auto-selects when only 1 gateway exists and no --arn', async () => { + mockListAllGateways.mockResolvedValue([ + { gatewayId: GATEWAY_ID, name: GATEWAY_NAME, status: 'READY', authorizerType: 'NONE' }, + ]); + + const result = await handleImportGateway({}); + + expect(result.success).toBe(true); + expect(result.resourceId).toBe(GATEWAY_ID); + expect(mockGetGatewayDetail).toHaveBeenCalledWith({ region: REGION, gatewayId: GATEWAY_ID }); + }); + + it('fails when multiple gateways exist and no --arn', async () => { + mockListAllGateways.mockResolvedValue([ + { gatewayId: 'gw-1', name: 'Gateway1', status: 'READY', authorizerType: 'NONE' }, + { gatewayId: 'gw-2', name: 'Gateway2', status: 'READY', authorizerType: 'NONE' }, + ]); + + const result = await handleImportGateway({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('Multiple gateways found'); + }); + + it('fails when no gateways exist and no --arn', async () => { + mockListAllGateways.mockResolvedValue([]); + + const result = await handleImportGateway({}); + + expect(result.success).toBe(false); + expect(result.error).toContain('No gateways found'); + }); + }); + + // ── Target mapping ────────────────────────────────────────────────────── + + describe('Target mapping', () => { + it('emits warning when some targets cannot be mapped', async () => { + // 2 target summaries, but one has no MCP config so it will be skipped + mockListAllGatewayTargets.mockResolvedValue([ + makeTargetSummary('tgt-1', 'goodTarget'), + makeTargetSummary('tgt-2', 'badTarget'), + ]); + + mockGetGatewayTargetDetail.mockImplementation((opts: { targetId: string }) => { + if (opts.targetId === 'tgt-1') { + return Promise.resolve(makeTargetDetail('tgt-1', 'goodTarget', 'https://example.com/mcp')); + } + // No MCP config => will be skipped + return Promise.resolve({ + targetId: 'tgt-2', + name: 'badTarget', + status: 'READY', + targetConfiguration: {}, + }); + }); + + const progressMessages: string[] = []; + const result = await handleImportGateway({ + arn: GATEWAY_ARN, + onProgress: (msg: string) => progressMessages.push(msg), + }); + + expect(result.success).toBe(true); + + // Verify warning about unmapped targets + expect(progressMessages.some(m => m.includes('1 target(s) could not be mapped'))).toBe(true); + }); + + it('emits warning for non-READY gateway but continues', async () => { + mockGetGatewayDetail.mockResolvedValue(makeGatewayDetail({ status: 'CREATING' })); + + const progressMessages: string[] = []; + const result = await handleImportGateway({ + arn: GATEWAY_ARN, + onProgress: (msg: string) => progressMessages.push(msg), + }); + + expect(result.success).toBe(true); + expect(progressMessages.some(m => m.includes('CREATING') && m.includes('not READY'))).toBe(true); + }); + }); + + // ── Re-import into existing stack (logical-ID collision) ──────────────── + + describe('buildResourcesToImport — excludes already-deployed logical IDs', () => { + it('skips deployed targets with the same Name when importing a new gateway', async () => { + await handleImportGateway({ arn: GATEWAY_ARN }); + + const pipelineInput = mockExecuteCdkImportPipeline.mock.calls[0]![0]; + const build = pipelineInput.buildResourcesToImport; + + // Deployed template already contains a gateway + target (from a prior import) + // whose target Name collides with the one being newly imported. + const deployedTemplate = { + Resources: { + OldGatewayLogicalId: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { Name: `TestProject-${GATEWAY_NAME}` }, + }, + OldTargetLogicalId: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { Name: 'target1' }, + }, + }, + }; + + // Synth template contains both old and new resources (same names). + const synthTemplate = { + Resources: { + OldGatewayLogicalId: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { Name: `TestProject-${GATEWAY_NAME}` }, + }, + OldTargetLogicalId: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { Name: 'target1' }, + }, + NewGatewayLogicalId: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { Name: `TestProject-${GATEWAY_NAME}` }, + }, + NewTargetLogicalId: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { Name: 'target1' }, + }, + }, + }; + + const resources = build(synthTemplate, deployedTemplate); + + const logicalIds = resources.map((r: { logicalResourceId: string }) => r.logicalResourceId); + expect(logicalIds).toContain('NewGatewayLogicalId'); + expect(logicalIds).toContain('NewTargetLogicalId'); + expect(logicalIds).not.toContain('OldGatewayLogicalId'); + expect(logicalIds).not.toContain('OldTargetLogicalId'); + }); + + it('first-ever import (empty deployed template) still resolves resources', async () => { + await handleImportGateway({ arn: GATEWAY_ARN }); + + const pipelineInput = mockExecuteCdkImportPipeline.mock.calls[0]![0]; + const build = pipelineInput.buildResourcesToImport; + + const deployedTemplate = { Resources: {} }; + const synthTemplate = { + Resources: { + GatewayLogicalId: { + Type: 'AWS::BedrockAgentCore::Gateway', + Properties: { Name: `TestProject-${GATEWAY_NAME}` }, + }, + TargetLogicalId: { + Type: 'AWS::BedrockAgentCore::GatewayTarget', + Properties: { Name: 'target1' }, + }, + }, + }; + + const resources = build(synthTemplate, deployedTemplate); + const logicalIds = resources.map((r: { logicalResourceId: string }) => r.logicalResourceId); + expect(logicalIds).toEqual(['GatewayLogicalId', 'TargetLogicalId']); + }); + }); +}); diff --git a/src/cli/commands/import/__tests__/import-gateway-spec.test.ts b/src/cli/commands/import/__tests__/import-gateway-spec.test.ts new file mode 100644 index 000000000..7c2963edf --- /dev/null +++ b/src/cli/commands/import/__tests__/import-gateway-spec.test.ts @@ -0,0 +1,311 @@ +/** + * toGatewaySpec Unit Tests + * + * Covers gateway-level field mapping from AWS GetGateway response + * to CLI AgentCoreGateway schema: + * - Authorizer type mapping (NONE, AWS_IAM, CUSTOM_JWT with claims, empty arrays) + * - Semantic search configuration + * - Exception level mapping + * - Policy engine configuration + * - Description, tags, resourceName, executionRoleArn + */ +import type { AgentCoreGatewayTarget } from '../../../../schema'; +import type { GatewayDetail } from '../../../aws/agentcore-control'; +import { toGatewaySpec } from '../import-gateway'; +import { describe, expect, it } from 'vitest'; + +/** Helper to build a minimal GatewayDetail for tests. */ +function makeGateway(overrides: Partial = {}): GatewayDetail { + return { + gatewayId: 'gw-test-001', + gatewayArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:gateway/gw-test-001', + name: 'TestGateway', + status: 'READY', + authorizerType: 'NONE', + ...overrides, + }; +} + +const emptyTargets: AgentCoreGatewayTarget[] = []; + +// ============================================================================ +// Authorizer Type Mapping +// ============================================================================ + +describe('toGatewaySpec – authorizer type mapping', () => { + it('NONE authorizerType: no authorizerConfiguration in output', () => { + const gw = makeGateway({ authorizerType: 'NONE' }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.authorizerType).toBe('NONE'); + expect(result).not.toHaveProperty('authorizerConfiguration'); + }); + + it('AWS_IAM authorizerType: maps to AWS_IAM, no authorizerConfiguration', () => { + const gw = makeGateway({ authorizerType: 'AWS_IAM' }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.authorizerType).toBe('AWS_IAM'); + expect(result).not.toHaveProperty('authorizerConfiguration'); + }); + + it('CUSTOM_JWT basic: maps discoveryUrl, allowedAudience, allowedClients, allowedScopes', () => { + const gw = makeGateway({ + authorizerType: 'CUSTOM_JWT', + authorizerConfiguration: { + customJwtAuthorizer: { + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedAudience: ['aud1', 'aud2'], + allowedClients: ['client1'], + allowedScopes: ['read', 'write'], + }, + }, + }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.authorizerType).toBe('CUSTOM_JWT'); + expect(result.authorizerConfiguration).toBeDefined(); + const jwt = result.authorizerConfiguration!.customJwtAuthorizer!; + expect(jwt.discoveryUrl).toBe('https://example.com/.well-known/openid-configuration'); + expect(jwt.allowedAudience).toEqual(['aud1', 'aud2']); + expect(jwt.allowedClients).toEqual(['client1']); + expect(jwt.allowedScopes).toEqual(['read', 'write']); + }); + + it('CUSTOM_JWT with customClaims: maps full claim structure', () => { + const gw = makeGateway({ + authorizerType: 'CUSTOM_JWT', + authorizerConfiguration: { + customJwtAuthorizer: { + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedAudience: ['aud1'], + customClaims: [ + { + inboundTokenClaimName: 'department', + inboundTokenClaimValueType: 'STRING', + authorizingClaimMatchValue: { + claimMatchOperator: 'EQUALS', + claimMatchValue: { matchValueString: 'engineering' }, + }, + }, + { + inboundTokenClaimName: 'roles', + inboundTokenClaimValueType: 'STRING_ARRAY', + authorizingClaimMatchValue: { + claimMatchOperator: 'CONTAINS_ANY', + claimMatchValue: { matchValueStringList: ['admin', 'editor'] }, + }, + }, + ], + }, + }, + }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + const claims = result.authorizerConfiguration!.customJwtAuthorizer!.customClaims!; + expect(claims).toHaveLength(2); + + expect(claims[0]!.inboundTokenClaimName).toBe('department'); + expect(claims[0]!.inboundTokenClaimValueType).toBe('STRING'); + expect(claims[0]!.authorizingClaimMatchValue.claimMatchOperator).toBe('EQUALS'); + expect(claims[0]!.authorizingClaimMatchValue.claimMatchValue.matchValueString).toBe('engineering'); + expect(claims[0]!.authorizingClaimMatchValue.claimMatchValue).not.toHaveProperty('matchValueStringList'); + + expect(claims[1]!.inboundTokenClaimName).toBe('roles'); + expect(claims[1]!.inboundTokenClaimValueType).toBe('STRING_ARRAY'); + expect(claims[1]!.authorizingClaimMatchValue.claimMatchOperator).toBe('CONTAINS_ANY'); + expect(claims[1]!.authorizingClaimMatchValue.claimMatchValue.matchValueStringList).toEqual(['admin', 'editor']); + expect(claims[1]!.authorizingClaimMatchValue.claimMatchValue).not.toHaveProperty('matchValueString'); + }); + + it('CUSTOM_JWT with empty arrays: allowedAudience=[], allowedClients=[] are omitted', () => { + const gw = makeGateway({ + authorizerType: 'CUSTOM_JWT', + authorizerConfiguration: { + customJwtAuthorizer: { + discoveryUrl: 'https://example.com/.well-known/openid-configuration', + allowedAudience: [], + allowedClients: [], + allowedScopes: ['openid'], + }, + }, + }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + const jwt = result.authorizerConfiguration!.customJwtAuthorizer!; + expect(jwt).not.toHaveProperty('allowedAudience'); + expect(jwt).not.toHaveProperty('allowedClients'); + expect(jwt.allowedScopes).toEqual(['openid']); + }); + + it('missing authorizerType: defaults to NONE', () => { + const gw = makeGateway(); + // Simulate undefined authorizerType by deleting after construction + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (gw as any).authorizerType; + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.authorizerType).toBe('NONE'); + expect(result).not.toHaveProperty('authorizerConfiguration'); + }); +}); + +// ============================================================================ +// Semantic Search +// ============================================================================ + +describe('toGatewaySpec – semantic search', () => { + it('searchType=SEMANTIC: enableSemanticSearch is true', () => { + const gw = makeGateway({ + protocolConfiguration: { mcp: { searchType: 'SEMANTIC' } }, + }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.enableSemanticSearch).toBe(true); + }); + + it('searchType=KEYWORD: enableSemanticSearch is false', () => { + const gw = makeGateway({ + protocolConfiguration: { mcp: { searchType: 'KEYWORD' } }, + }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.enableSemanticSearch).toBe(false); + }); + + it('protocolConfiguration missing: enableSemanticSearch is false', () => { + const gw = makeGateway(); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.enableSemanticSearch).toBe(false); + }); +}); + +// ============================================================================ +// Exception Level +// ============================================================================ + +describe('toGatewaySpec – exception level', () => { + it('exceptionLevel=DEBUG: maps to DEBUG', () => { + const gw = makeGateway({ exceptionLevel: 'DEBUG' }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.exceptionLevel).toBe('DEBUG'); + }); + + it('exceptionLevel undefined: maps to NONE', () => { + const gw = makeGateway({ exceptionLevel: undefined }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.exceptionLevel).toBe('NONE'); + }); + + it('exceptionLevel other value: maps to NONE', () => { + const gw = makeGateway({ exceptionLevel: 'VERBOSE' }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.exceptionLevel).toBe('NONE'); + }); +}); + +// ============================================================================ +// Policy Engine +// ============================================================================ + +describe('toGatewaySpec – policy engine', () => { + it('policyEngineConfiguration present: extracts name from ARN last segment, preserves mode', () => { + const gw = makeGateway({ + policyEngineConfiguration: { + arn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:policy-engine/my_policy_engine', + mode: 'ENFORCE', + }, + }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.policyEngineConfiguration).toBeDefined(); + expect(result.policyEngineConfiguration!.policyEngineName).toBe('my_policy_engine'); + expect(result.policyEngineConfiguration!.mode).toBe('ENFORCE'); + }); + + it('policyEngineConfiguration absent: field omitted', () => { + const gw = makeGateway(); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result).not.toHaveProperty('policyEngineConfiguration'); + }); +}); + +// ============================================================================ +// Other Fields +// ============================================================================ + +describe('toGatewaySpec – other fields', () => { + it('resourceName is always set to gateway.name', () => { + const gw = makeGateway({ name: 'AwsGatewayName' }); + const result = toGatewaySpec(gw, emptyTargets, 'local_name'); + + expect(result.resourceName).toBe('AwsGatewayName'); + expect(result.name).toBe('local_name'); + }); + + it('description present: included in output', () => { + const gw = makeGateway({ description: 'My gateway description' }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.description).toBe('My gateway description'); + }); + + it('description undefined: omitted from output', () => { + const gw = makeGateway({ description: undefined }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result).not.toHaveProperty('description'); + }); + + it('tags present with entries: included in output', () => { + const gw = makeGateway({ tags: { env: 'prod', team: 'platform' } }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.tags).toEqual({ env: 'prod', team: 'platform' }); + }); + + it('tags empty object: omitted from output', () => { + const gw = makeGateway({ tags: {} }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result).not.toHaveProperty('tags'); + }); + + it('tags undefined: omitted from output', () => { + const gw = makeGateway({ tags: undefined }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result).not.toHaveProperty('tags'); + }); + + it('executionRoleArn: mapped from gateway.roleArn', () => { + const gw = makeGateway({ roleArn: 'arn:aws:iam::123456789012:role/GatewayRole' }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result.executionRoleArn).toBe('arn:aws:iam::123456789012:role/GatewayRole'); + }); + + it('roleArn undefined: executionRoleArn omitted from output', () => { + const gw = makeGateway({ roleArn: undefined }); + const result = toGatewaySpec(gw, emptyTargets, 'my_gw'); + + expect(result).not.toHaveProperty('executionRoleArn'); + }); + + it('targets are passed through to output', () => { + const targets: AgentCoreGatewayTarget[] = [ + { name: 'target1', targetType: 'mcpServer', endpoint: 'https://mcp.example.com' }, + ]; + const gw = makeGateway(); + const result = toGatewaySpec(gw, targets, 'my_gw'); + + expect(result.targets).toBe(targets); + expect(result.targets).toHaveLength(1); + expect(result.targets[0]!.name).toBe('target1'); + }); +}); diff --git a/src/cli/commands/import/__tests__/import-gateway-targets.test.ts b/src/cli/commands/import/__tests__/import-gateway-targets.test.ts new file mode 100644 index 000000000..3624ce545 --- /dev/null +++ b/src/cli/commands/import/__tests__/import-gateway-targets.test.ts @@ -0,0 +1,355 @@ +/** + * Import Gateway Target Mapping Unit Tests + * + * Covers toGatewayTargetSpec for non-mcpServer target types: + * - apiGateway: toolFilters, toolOverrides, outboundAuth + * - openApiSchema: S3 URI mapping, missing URI warning + * - smithyModel: S3 URI mapping, missing URI warning + * - lambda: lambdaFunctionArn mapping, missing ARN, inline-only schema + * - Unrecognized target type + */ +import type { GatewayTargetDetail } from '../../../aws/agentcore-control'; +import { toGatewayTargetSpec } from '../import-gateway'; +import { describe, expect, it, vi } from 'vitest'; + +/** Helper to build a minimal GatewayTargetDetail with only the fields under test. */ +function baseDetail(overrides: Partial = {}): GatewayTargetDetail { + return { + targetId: 'tgt-001', + name: 'test_target', + status: 'READY', + ...overrides, + }; +} + +// ============================================================================ +// apiGateway target +// ============================================================================ + +describe('toGatewayTargetSpec — apiGateway', () => { + it('maps restApiId, stage, and toolFilters correctly', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + apiGateway: { + restApiId: 'abc123', + stage: 'prod', + apiGatewayToolConfiguration: { + toolFilters: [ + { filterPath: '/pets', methods: ['GET', 'POST'] }, + { filterPath: '/users', methods: ['GET'] }, + ], + }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeDefined(); + expect(result!.name).toBe('test_target'); + expect(result!.targetType).toBe('apiGateway'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const apigw = (result as any).apiGateway; + expect(apigw.restApiId).toBe('abc123'); + expect(apigw.stage).toBe('prod'); + expect(apigw.apiGatewayToolConfiguration.toolFilters).toEqual([ + { filterPath: '/pets', methods: ['GET', 'POST'] }, + { filterPath: '/users', methods: ['GET'] }, + ]); + }); + + it('maps toolOverrides when present', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + apiGateway: { + restApiId: 'abc123', + stage: 'prod', + apiGatewayToolConfiguration: { + toolFilters: [], + toolOverrides: [ + { name: 'listPets', path: '/pets', method: 'GET', description: 'List all pets' }, + { name: 'createPet', path: '/pets', method: 'POST' }, + ], + }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const apigw = (result as any).apiGateway; + expect(apigw.apiGatewayToolConfiguration.toolOverrides).toEqual([ + { name: 'listPets', path: '/pets', method: 'GET', description: 'List all pets' }, + { name: 'createPet', path: '/pets', method: 'POST' }, + ]); + }); + + it('omits toolOverrides when not present', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + apiGateway: { + restApiId: 'abc123', + stage: 'prod', + apiGatewayToolConfiguration: { + toolFilters: [{ filterPath: '/pets', methods: ['GET'] }], + }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const apigw = (result as any).apiGateway; + expect(apigw.apiGatewayToolConfiguration.toolOverrides).toBeUndefined(); + }); + + it('returns outboundAuth when OAuth credential is configured', () => { + const providerArn = 'arn:aws:bedrock-agentcore:us-west-2:123456789012:credential-provider/cred-001'; + const detail = baseDetail({ + targetConfiguration: { + mcp: { + apiGateway: { + restApiId: 'abc123', + stage: 'prod', + apiGatewayToolConfiguration: { toolFilters: [] }, + }, + }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: 'OAUTH', + credentialProvider: { + oauthCredentialProvider: { + providerArn, + scopes: ['read', 'write'], + }, + }, + }, + ], + }); + + const credentials = new Map([[providerArn, 'my_oauth_cred']]); + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, credentials, onProgress); + + expect(result).toBeDefined(); + expect(result!.outboundAuth).toEqual({ + type: 'OAUTH', + credentialName: 'my_oauth_cred', + scopes: ['read', 'write'], + }); + }); +}); + +// ============================================================================ +// openApiSchema target +// ============================================================================ + +describe('toGatewayTargetSpec — openApiSchema', () => { + it('maps S3 URI and bucketOwnerAccountId correctly', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + openApiSchema: { + s3: { uri: 's3://my-bucket/schema.yaml', bucketOwnerAccountId: '123456789012' }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeDefined(); + expect(result!.name).toBe('test_target'); + expect(result!.targetType).toBe('openApiSchema'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const schemaSource = (result as any).schemaSource; + expect(schemaSource.s3.uri).toBe('s3://my-bucket/schema.yaml'); + expect(schemaSource.s3.bucketOwnerAccountId).toBe('123456789012'); + }); + + it('returns undefined and emits warning when S3 URI is missing', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + openApiSchema: { inlinePayload: '{"openapi":"3.0.0"}' }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('(openApiSchema) has no S3 URI, skipping')); + }); +}); + +// ============================================================================ +// smithyModel target +// ============================================================================ + +describe('toGatewayTargetSpec — smithyModel', () => { + it('maps S3 URI correctly', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + smithyModel: { + s3: { uri: 's3://models-bucket/model.json' }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeDefined(); + expect(result!.name).toBe('test_target'); + expect(result!.targetType).toBe('smithyModel'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const schemaSource = (result as any).schemaSource; + expect(schemaSource.s3.uri).toBe('s3://models-bucket/model.json'); + expect(schemaSource.s3.bucketOwnerAccountId).toBeUndefined(); + }); + + it('returns undefined and emits warning when S3 URI is missing', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + smithyModel: { inlinePayload: '{"smithy":"1.0"}' }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('(smithyModel) has no S3 URI, skipping')); + }); +}); + +// ============================================================================ +// lambda target +// ============================================================================ + +describe('toGatewayTargetSpec — lambda', () => { + it('maps lambda with S3 tool schema to lambdaFunctionArn type', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: 'arn:aws:lambda:us-west-2:123456789012:function:my-func', + toolSchema: { s3: { uri: 's3://schemas/tools.json' } }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeDefined(); + expect(result!.name).toBe('test_target'); + expect(result!.targetType).toBe('lambdaFunctionArn'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const lambdaConfig = (result as any).lambdaFunctionArn; + expect(lambdaConfig.lambdaArn).toBe('arn:aws:lambda:us-west-2:123456789012:function:my-func'); + expect(lambdaConfig.toolSchemaFile).toBe('s3://schemas/tools.json'); + }); + + it('returns undefined and emits warning when lambdaArn is missing', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: '', + toolSchema: { s3: { uri: 's3://schemas/tools.json' } }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('(lambda) has no ARN, skipping')); + }); + + it('returns undefined and emits warning when lambda has inline schema only', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: 'arn:aws:lambda:us-west-2:123456789012:function:my-func', + toolSchema: { inlinePayload: '{"tools":[]}' }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith( + expect.stringContaining('has inline tool schema, which cannot be imported') + ); + }); + + it('emits progress message for successful lambda mapping', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: { + lambda: { + lambdaArn: 'arn:aws:lambda:us-west-2:123456789012:function:my-func', + toolSchema: { s3: { uri: 's3://schemas/tools.json' } }, + }, + }, + }, + }); + + const onProgress = vi.fn(); + toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('Mapping compute-backed Lambda target')); + }); +}); + +// ============================================================================ +// Unrecognized target type +// ============================================================================ + +describe('toGatewayTargetSpec — unrecognized target type', () => { + it('returns undefined and emits warning when no known mcp type matches', () => { + const detail = baseDetail({ + targetConfiguration: { + mcp: {}, + }, + }); + + const onProgress = vi.fn(); + const result = toGatewayTargetSpec(detail, new Map(), onProgress); + + expect(result).toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('unrecognized target type')); + }); +}); diff --git a/src/cli/commands/import/__tests__/import-gateway.test.ts b/src/cli/commands/import/__tests__/import-gateway.test.ts new file mode 100644 index 000000000..bdde88bbe --- /dev/null +++ b/src/cli/commands/import/__tests__/import-gateway.test.ts @@ -0,0 +1,264 @@ +/** + * Tests for toGatewayTargetSpec() — mcpServer target mapping and credential resolution. + */ +import type { GatewayTargetDetail } from '../../../aws/agentcore-control'; +import { + _resolveOutboundAuth as resolveOutboundAuth, + _toGatewayTargetSpec as toGatewayTargetSpec, +} from '../import-gateway'; +import { describe, expect, it, vi } from 'vitest'; + +// ============================================================================ +// Helpers +// ============================================================================ + +function makeDetail(overrides: Partial = {}): GatewayTargetDetail { + return { + targetId: 'tgt-001', + name: 'my-mcp-target', + status: 'READY', + ...overrides, + }; +} + +// ============================================================================ +// toGatewayTargetSpec — mcpServer mapping +// ============================================================================ + +describe('toGatewayTargetSpec — mcpServer targets', () => { + it('maps mcpServer with no auth', () => { + const detail = makeDetail({ + targetConfiguration: { + mcp: { + mcpServer: { endpoint: 'https://example.com/mcp' }, + }, + }, + }); + const credentials = new Map(); + const onProgress = vi.fn(); + + const result = toGatewayTargetSpec(detail, credentials, onProgress); + + expect(result).toEqual({ + name: 'my-mcp-target', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + }); + expect(result).not.toHaveProperty('outboundAuth'); + expect(onProgress).not.toHaveBeenCalled(); + }); + + it('maps mcpServer with OAuth credential (resolved)', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/my-oauth'; + const detail = makeDetail({ + targetConfiguration: { + mcp: { + mcpServer: { endpoint: 'https://example.com/mcp' }, + }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: 'OAUTH', + credentialProvider: { + oauthCredentialProvider: { + providerArn, + scopes: ['read', 'write'], + }, + }, + }, + ], + }); + const credentials = new Map([[providerArn, 'my-oauth-cred']]); + const onProgress = vi.fn(); + + const result = toGatewayTargetSpec(detail, credentials, onProgress); + + expect(result).toEqual({ + name: 'my-mcp-target', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + outboundAuth: { + type: 'OAUTH', + credentialName: 'my-oauth-cred', + scopes: ['read', 'write'], + }, + }); + }); + + it('maps mcpServer with API_KEY credential (resolved)', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/my-apikey'; + const detail = makeDetail({ + targetConfiguration: { + mcp: { + mcpServer: { endpoint: 'https://example.com/mcp' }, + }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: 'API_KEY', + credentialProvider: { + apiKeyCredentialProvider: { + providerArn, + }, + }, + }, + ], + }); + const credentials = new Map([[providerArn, 'my-api-key-cred']]); + const onProgress = vi.fn(); + + const result = toGatewayTargetSpec(detail, credentials, onProgress); + + expect(result).toEqual({ + name: 'my-mcp-target', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + outboundAuth: { + type: 'API_KEY', + credentialName: 'my-api-key-cred', + }, + }); + }); + + it('returns undefined outboundAuth and warns when OAuth credential not in project', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/missing-oauth'; + const detail = makeDetail({ + targetConfiguration: { + mcp: { + mcpServer: { endpoint: 'https://example.com/mcp' }, + }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: 'OAUTH', + credentialProvider: { + oauthCredentialProvider: { + providerArn, + scopes: ['read'], + }, + }, + }, + ], + }); + const credentials = new Map(); // empty — not resolved + const onProgress = vi.fn(); + + const result = toGatewayTargetSpec(detail, credentials, onProgress); + + expect(result).toEqual({ + name: 'my-mcp-target', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + }); + expect(result).not.toHaveProperty('outboundAuth'); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('OAuth credential')); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('Configure credentials manually after import')); + }); + + it('returns undefined outboundAuth and warns when API_KEY credential not in project', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/missing-apikey'; + const detail = makeDetail({ + targetConfiguration: { + mcp: { + mcpServer: { endpoint: 'https://example.com/mcp' }, + }, + }, + credentialProviderConfigurations: [ + { + credentialProviderType: 'API_KEY', + credentialProvider: { + apiKeyCredentialProvider: { + providerArn, + }, + }, + }, + ], + }); + const credentials = new Map(); // empty — not resolved + const onProgress = vi.fn(); + + const result = toGatewayTargetSpec(detail, credentials, onProgress); + + expect(result).toEqual({ + name: 'my-mcp-target', + targetType: 'mcpServer', + endpoint: 'https://example.com/mcp', + }); + expect(result).not.toHaveProperty('outboundAuth'); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('API Key credential')); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('Configure credentials manually after import')); + }); + + it('returns undefined and warns when target has no MCP configuration', () => { + const detail = makeDetail({ + targetConfiguration: undefined, + }); + const credentials = new Map(); + const onProgress = vi.fn(); + + const result = toGatewayTargetSpec(detail, credentials, onProgress); + + expect(result).toBeUndefined(); + expect(onProgress).toHaveBeenCalledWith(expect.stringContaining('no MCP configuration')); + }); +}); + +// ============================================================================ +// resolveOutboundAuth — OAuth scopes handling +// ============================================================================ + +describe('resolveOutboundAuth — scopes handling', () => { + it('includes scopes when OAuth provider has non-empty scopes array', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/oauth-scoped'; + const detail = makeDetail({ + credentialProviderConfigurations: [ + { + credentialProviderType: 'OAUTH', + credentialProvider: { + oauthCredentialProvider: { + providerArn, + scopes: ['openid', 'profile', 'email'], + }, + }, + }, + ], + }); + const credentials = new Map([[providerArn, 'scoped-cred']]); + const onProgress = vi.fn(); + + const result = resolveOutboundAuth(detail, credentials, onProgress); + + expect(result).toEqual({ + type: 'OAUTH', + credentialName: 'scoped-cred', + scopes: ['openid', 'profile', 'email'], + }); + }); + + it('omits scopes when OAuth provider has empty scopes array', () => { + const providerArn = 'arn:aws:bedrock:us-east-1:123456789012:credential-provider/oauth-no-scope'; + const detail = makeDetail({ + credentialProviderConfigurations: [ + { + credentialProviderType: 'OAUTH', + credentialProvider: { + oauthCredentialProvider: { + providerArn, + scopes: [], + }, + }, + }, + ], + }); + const credentials = new Map([[providerArn, 'no-scope-cred']]); + const onProgress = vi.fn(); + + const result = resolveOutboundAuth(detail, credentials, onProgress); + + expect(result).toEqual({ + type: 'OAUTH', + credentialName: 'no-scope-cred', + }); + expect(result).not.toHaveProperty('scopes'); + }); +}); diff --git a/src/cli/commands/import/actions.ts b/src/cli/commands/import/actions.ts index c0bdc337f..e6bccca21 100644 --- a/src/cli/commands/import/actions.ts +++ b/src/cli/commands/import/actions.ts @@ -542,11 +542,14 @@ export async function handleImport(options: ImportOptions): Promise { + buildResourcesToImport: (synthTemplate, deployedTemplate) => { const resourcesToImport: ResourceToImport[] = []; + const deployedIds = new Set(Object.keys(deployedTemplate.Resources)); for (const agent of agentsToImport) { - const runtimeLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Runtime'); + const runtimeLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Runtime').filter( + id => !deployedIds.has(id) + ); let logicalId: string | undefined; const expectedRuntimeName = `${projectName}_${agent.name}`; @@ -554,7 +557,8 @@ export async function handleImport(options: ImportOptions): Promise !deployedIds.has(id) + ); let logicalId: string | undefined; - logicalId = findLogicalIdByProperty(synthTemplate, 'AWS::BedrockAgentCore::Memory', 'Name', memory.name); + logicalId = findLogicalIdByProperty(synthTemplate, 'AWS::BedrockAgentCore::Memory', 'Name', memory.name, { + excludeLogicalIds: deployedIds, + }); // CDK prefixes memory names with the project name (e.g. "myproject_Agent_mem"), // so also try matching with the project name prefix. if (!logicalId) { const prefixedName = `${projectName}_${memory.name}`; - logicalId = findLogicalIdByProperty(synthTemplate, 'AWS::BedrockAgentCore::Memory', 'Name', prefixedName); + logicalId = findLogicalIdByProperty(synthTemplate, 'AWS::BedrockAgentCore::Memory', 'Name', prefixedName, { + excludeLogicalIds: deployedIds, + }); } if (!logicalId && memoryLogicalIds.length === 1) { diff --git a/src/cli/commands/import/command.ts b/src/cli/commands/import/command.ts index 3fd4f745d..ea783d0a2 100644 --- a/src/cli/commands/import/command.ts +++ b/src/cli/commands/import/command.ts @@ -1,6 +1,7 @@ import { handleImport } from './actions'; import { ANSI } from './constants'; import { registerImportEvaluator } from './import-evaluator'; +import { registerImportGateway } from './import-gateway'; import { registerImportMemory } from './import-memory'; import { registerImportOnlineEval } from './import-online-eval'; import { registerImportRuntime } from './import-runtime'; @@ -12,7 +13,7 @@ const { green, yellow, cyan, dim, reset } = ANSI; export const registerImport = (program: Command) => { const importCmd = program .command('import') - .description('Import a runtime, memory, or starter toolkit into this project. [experimental]'); + .description('Import a runtime, memory, gateway, or starter toolkit into this project. [experimental]'); // Existing YAML flow: agentcore import --source importCmd @@ -152,4 +153,5 @@ export const registerImport = (program: Command) => { registerImportMemory(importCmd); registerImportEvaluator(importCmd); registerImportOnlineEval(importCmd); + registerImportGateway(importCmd); }; diff --git a/src/cli/commands/import/constants.ts b/src/cli/commands/import/constants.ts index 93c25f902..e2291384b 100644 --- a/src/cli/commands/import/constants.ts +++ b/src/cli/commands/import/constants.ts @@ -18,6 +18,7 @@ export const CFN_RESOURCE_IDENTIFIERS: Record = { 'AWS::BedrockAgentCore::Runtime': ['AgentRuntimeId'], 'AWS::BedrockAgentCore::Memory': ['MemoryId'], 'AWS::BedrockAgentCore::Gateway': ['GatewayIdentifier'], + 'AWS::BedrockAgentCore::GatewayTarget': ['GatewayIdentifier', 'TargetId'], 'AWS::BedrockAgentCore::Evaluator': ['EvaluatorId'], 'AWS::BedrockAgentCore::OnlineEvaluationConfig': ['OnlineEvaluationConfigId'], }; diff --git a/src/cli/commands/import/import-gateway.ts b/src/cli/commands/import/import-gateway.ts new file mode 100644 index 000000000..b300ed0e0 --- /dev/null +++ b/src/cli/commands/import/import-gateway.ts @@ -0,0 +1,679 @@ +import type { + AgentCoreGateway, + AgentCoreGatewayTarget, + AgentCoreProjectSpec, + AuthorizerConfig, + CustomClaimValidation, + GatewayAuthorizerType, + GatewayExceptionLevel, + GatewayPolicyEngineConfiguration, + OutboundAuth, +} from '../../../schema'; +import type { GatewayDetail, GatewayTargetDetail } from '../../aws/agentcore-control'; +import { + getGatewayDetail, + getGatewayTargetDetail, + listAllGatewayTargets, + listAllGateways, +} from '../../aws/agentcore-control'; +import { isAccessDeniedError } from '../../errors'; +import { ANSI, NAME_REGEX } from './constants'; +import { executeCdkImportPipeline } from './import-pipeline'; +import { + failResult, + findResourceInDeployedState, + parseAndValidateArn, + resolveImportContext, + toStackName, +} from './import-utils'; +import { findLogicalIdByProperty, findLogicalIdsByType } from './template-utils'; +import type { ImportResourceOptions, ImportResourceResult, ResourceToImport } from './types'; +import type { Command } from '@commander-js/extra-typings'; + +// ============================================================================ +// AWS → CLI Schema Mapping +// ============================================================================ + +/** + * Map GetGatewayTarget response to CLI AgentCoreGatewayTarget schema. + * Determines target type from the targetConfiguration.mcp union. + */ +function toGatewayTargetSpec( + detail: GatewayTargetDetail, + credentials: Map, + onProgress: (msg: string) => void +): AgentCoreGatewayTarget | undefined { + const mcp = detail.targetConfiguration?.mcp; + if (!mcp) { + onProgress(`Warning: Target "${detail.name}" has no MCP configuration, skipping`); + return undefined; + } + + const outboundAuth = resolveOutboundAuth(detail, credentials, onProgress); + + // MCP Server (external endpoint) + if (mcp.mcpServer) { + return { + name: detail.name, + targetType: 'mcpServer', + endpoint: mcp.mcpServer.endpoint, + ...(outboundAuth && { outboundAuth }), + }; + } + + // API Gateway + if (mcp.apiGateway) { + const apigw = mcp.apiGateway; + /* eslint-disable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */ + const target: AgentCoreGatewayTarget = { + name: detail.name, + targetType: 'apiGateway', + apiGateway: { + restApiId: apigw.restApiId, + stage: apigw.stage, + apiGatewayToolConfiguration: { + toolFilters: (apigw.apiGatewayToolConfiguration?.toolFilters ?? []).map(f => ({ + filterPath: f.filterPath, + methods: f.methods, + })) as any, + ...(apigw.apiGatewayToolConfiguration?.toolOverrides && { + toolOverrides: apigw.apiGatewayToolConfiguration.toolOverrides.map(o => ({ + name: o.name, + path: o.path, + method: o.method, + ...(o.description && { description: o.description }), + })), + }), + }, + } as any, + ...(outboundAuth && { outboundAuth }), + }; + /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment */ + return target; + } + + // OpenAPI Schema + if (mcp.openApiSchema) { + const schema = mcp.openApiSchema; + if (schema.s3?.uri) { + return { + name: detail.name, + targetType: 'openApiSchema', + schemaSource: { + s3: { + uri: schema.s3.uri, + ...(schema.s3.bucketOwnerAccountId && { bucketOwnerAccountId: schema.s3.bucketOwnerAccountId }), + }, + }, + ...(outboundAuth && { outboundAuth }), + }; + } + onProgress(`Warning: Target "${detail.name}" (openApiSchema) has no S3 URI, skipping`); + return undefined; + } + + // Smithy Model + if (mcp.smithyModel) { + const schema = mcp.smithyModel; + if (schema.s3?.uri) { + return { + name: detail.name, + targetType: 'smithyModel', + schemaSource: { + s3: { + uri: schema.s3.uri, + ...(schema.s3.bucketOwnerAccountId && { bucketOwnerAccountId: schema.s3.bucketOwnerAccountId }), + }, + }, + ...(outboundAuth && { outboundAuth }), + }; + } + onProgress(`Warning: Target "${detail.name}" (smithyModel) has no S3 URI, skipping`); + return undefined; + } + + // Lambda (compute-backed) → map to lambdaFunctionArn + if (mcp.lambda) { + const lambdaArn = mcp.lambda.lambdaArn; + if (!lambdaArn) { + onProgress(`Warning: Target "${detail.name}" (lambda) has no ARN, skipping`); + return undefined; + } + + // Extract tool schema S3 URI if available + /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ + const toolSchema = mcp.lambda.toolSchema; + const s3Uri: string | undefined = toolSchema?.s3?.uri; + /* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment */ + + if (s3Uri) { + onProgress(`Mapping compute-backed Lambda target "${detail.name}" to lambdaFunctionArn type`); + return { + name: detail.name, + targetType: 'lambdaFunctionArn', + lambdaFunctionArn: { + lambdaArn, + toolSchemaFile: s3Uri, + }, + }; + } + + // Lambda without S3 schema — can't import as lambdaFunctionArn since toolSchemaFile is required + onProgress(`Warning: Target "${detail.name}" (lambda) has inline tool schema, which cannot be imported. Skipping.`); + return undefined; + } + + onProgress(`Warning: Target "${detail.name}" has an unrecognized target type, skipping`); + return undefined; +} + +/** + * Resolve outbound auth from credential provider configurations. + */ +function resolveOutboundAuth( + detail: GatewayTargetDetail, + credentials: Map, + onProgress: (msg: string) => void +): OutboundAuth | undefined { + const configs = detail.credentialProviderConfigurations; + if (!configs || configs.length === 0) return undefined; + + for (const config of configs) { + if (config.credentialProviderType === 'OAUTH' && config.credentialProvider?.oauthCredentialProvider) { + const providerArn = config.credentialProvider.oauthCredentialProvider.providerArn; + const credentialName = credentials.get(providerArn); + if (credentialName) { + return { + type: 'OAUTH', + credentialName, + ...(config.credentialProvider.oauthCredentialProvider.scopes?.length && { + scopes: config.credentialProvider.oauthCredentialProvider.scopes, + }), + }; + } + onProgress( + `Warning: Target "${detail.name}" uses OAuth credential (${providerArn}) not found in project. ` + + 'Configure credentials manually after import with `agentcore add credential`.' + ); + return undefined; + } + + if (config.credentialProviderType === 'API_KEY' && config.credentialProvider?.apiKeyCredentialProvider) { + const providerArn = config.credentialProvider.apiKeyCredentialProvider.providerArn; + const credentialName = credentials.get(providerArn); + if (credentialName) { + return { type: 'API_KEY', credentialName }; + } + onProgress( + `Warning: Target "${detail.name}" uses API Key credential (${providerArn}) not found in project. ` + + 'Configure credentials manually after import with `agentcore add credential`.' + ); + return undefined; + } + + // GATEWAY_IAM_ROLE — no outbound auth needed + } + + return undefined; +} + +/** + * Map GetGateway + GetGatewayTarget[] responses to CLI AgentCoreGateway schema. + * @internal + */ +export function toGatewaySpec( + gateway: GatewayDetail, + targets: AgentCoreGatewayTarget[], + localName: string +): AgentCoreGateway { + const authorizerType = (gateway.authorizerType ?? 'NONE') as GatewayAuthorizerType; + + let authorizerConfiguration: AuthorizerConfig | undefined; + if (authorizerType === 'CUSTOM_JWT' && gateway.authorizerConfiguration?.customJwtAuthorizer) { + const jwt = gateway.authorizerConfiguration.customJwtAuthorizer; + authorizerConfiguration = { + customJwtAuthorizer: { + discoveryUrl: jwt.discoveryUrl, + ...(jwt.allowedAudience?.length && { allowedAudience: jwt.allowedAudience }), + ...(jwt.allowedClients?.length && { allowedClients: jwt.allowedClients }), + ...(jwt.allowedScopes?.length && { allowedScopes: jwt.allowedScopes }), + ...(jwt.customClaims?.length && { + customClaims: jwt.customClaims.map( + (c): CustomClaimValidation => ({ + inboundTokenClaimName: c.inboundTokenClaimName, + inboundTokenClaimValueType: c.inboundTokenClaimValueType as 'STRING' | 'STRING_ARRAY', + authorizingClaimMatchValue: { + claimMatchOperator: c.authorizingClaimMatchValue.claimMatchOperator as + | 'EQUALS' + | 'CONTAINS' + | 'CONTAINS_ANY', + claimMatchValue: { + ...(c.authorizingClaimMatchValue.claimMatchValue.matchValueString && { + matchValueString: c.authorizingClaimMatchValue.claimMatchValue.matchValueString, + }), + ...(c.authorizingClaimMatchValue.claimMatchValue.matchValueStringList && { + matchValueStringList: c.authorizingClaimMatchValue.claimMatchValue.matchValueStringList, + }), + }, + }, + }) + ), + }), + }, + }; + } + + const enableSemanticSearch = gateway.protocolConfiguration?.mcp?.searchType === 'SEMANTIC'; + const exceptionLevel: GatewayExceptionLevel = gateway.exceptionLevel === 'DEBUG' ? 'DEBUG' : 'NONE'; + + let policyEngineConfiguration: GatewayPolicyEngineConfiguration | undefined; + if (gateway.policyEngineConfiguration) { + // Extract policy engine name from ARN (last segment after /) + const arnParts = gateway.policyEngineConfiguration.arn.split('/'); + const policyEngineName = arnParts[arnParts.length - 1] ?? gateway.policyEngineConfiguration.arn; + policyEngineConfiguration = { + policyEngineName, + mode: gateway.policyEngineConfiguration.mode as 'LOG_ONLY' | 'ENFORCE', + }; + } + + return { + name: localName, + resourceName: gateway.name, + ...(gateway.description && { description: gateway.description }), + targets, + authorizerType, + ...(authorizerConfiguration && { authorizerConfiguration }), + enableSemanticSearch, + exceptionLevel, + ...(policyEngineConfiguration && { policyEngineConfiguration }), + ...(gateway.roleArn && { executionRoleArn: gateway.roleArn }), + ...(gateway.tags && Object.keys(gateway.tags).length > 0 && { tags: gateway.tags }), + }; +} + +// ============================================================================ +// Credential ARN → Name Resolution +// ============================================================================ + +/** + * Build a map from credential provider ARN → credential name + * using the project's deployed state. + * @internal + */ +export async function buildCredentialArnMap( + configIO: { readDeployedState: () => Promise }, + targetName: string +): Promise> { + const map = new Map(); + try { + /* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument */ + const state = (await configIO.readDeployedState()) as any; + const credentials = state?.targets?.[targetName]?.resources?.credentials; + if (credentials && typeof credentials === 'object') { + for (const [name, entry] of Object.entries(credentials)) { + const arn = (entry as any)?.credentialProviderArn; + if (typeof arn === 'string') { + map.set(arn, name); + } + } + } + /* eslint-enable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument */ + } catch { + // No deployed state — credentials won't be resolved + } + return map; +} + +// ============================================================================ +// Import Flow +// ============================================================================ + +/** + * Handle `agentcore import gateway`. + */ +export async function handleImportGateway(options: ImportResourceOptions): Promise { + let configSnapshot: AgentCoreProjectSpec | undefined; + let configWritten = false; + let importCtx: Awaited> | undefined; + + const rollback = async () => { + if (configWritten && configSnapshot && importCtx) { + try { + await importCtx.ctx.configIO.writeProjectSpec(configSnapshot); + } catch (err) { + console.warn(`Warning: Could not restore agentcore.json: ${err instanceof Error ? err.message : String(err)}`); + } + } + }; + + try { + // 1-2. Validate project context and resolve target + importCtx = await resolveImportContext(options, 'import-gateway'); + const { ctx, target, logger, onProgress } = importCtx; + + // 3. Fetch gateway from AWS + logger.startStep('Fetch gateway from AWS'); + let gatewayId: string; + + if (options.arn) { + gatewayId = parseAndValidateArn(options.arn, 'gateway', target).resourceId; + } else { + onProgress('Listing gateways in your account...'); + const summaries = await listAllGateways({ region: target.region }); + + if (summaries.length === 0) { + return failResult(logger, 'No gateways found in your account.', 'gateway', ''); + } + + if (summaries.length === 1) { + gatewayId = summaries[0]!.gatewayId; + onProgress(`Found 1 gateway: ${summaries[0]!.name} (${gatewayId}). Auto-selecting.`); + } else { + console.log(`\nFound ${summaries.length} gateway(s):\n`); + for (let i = 0; i < summaries.length; i++) { + const s = summaries[i]!; + console.log( + ` ${ANSI.dim}[${i + 1}]${ANSI.reset} ${s.name} — ${s.status}\n` + + ` ${ANSI.dim}${s.gatewayId} (${s.authorizerType})${ANSI.reset}` + ); + } + console.log(''); + return failResult( + logger, + 'Multiple gateways found. Use --arn to specify which gateway to import.', + 'gateway', + '' + ); + } + } + + onProgress(`Fetching gateway details for ${gatewayId}...`); + let gatewayDetail; + try { + gatewayDetail = await getGatewayDetail({ region: target.region, gatewayId }); + } catch (err) { + if (isAccessDeniedError(err)) { + return failResult( + logger, + `Gateway "${gatewayId}" could not be found in region ${target.region}. ` + + `AWS returned AccessDenied, which for this service typically means the gateway does not exist, ` + + `the ARN is malformed, or your credentials lack bedrock-agentcore:GetGateway permission. ` + + `Verify the ARN with: aws bedrock-agentcore-control list-gateways --region ${target.region}`, + 'gateway', + options.name ?? '' + ); + } + throw err; + } + + if (gatewayDetail.status !== 'READY') { + onProgress(`Warning: Gateway status is ${gatewayDetail.status}, not READY`); + } + + // 3b. Fetch all targets + onProgress('Listing gateway targets...'); + const targetSummaries = await listAllGatewayTargets({ region: target.region, gatewayId }); + onProgress(`Found ${targetSummaries.length} target(s) for gateway`); + + const targetDetails: GatewayTargetDetail[] = []; + for (const ts of targetSummaries) { + const td = await getGatewayTargetDetail({ region: target.region, gatewayId, targetId: ts.targetId }); + targetDetails.push(td); + } + logger.endStep('success'); + + // 4. Validate name + logger.startStep('Validate name'); + const localName = options.name ?? gatewayDetail.name; + if (!NAME_REGEX.test(localName)) { + return failResult( + logger, + `Invalid name "${localName}". Name must start with a letter and contain only letters, numbers, and underscores (max 48 chars).`, + 'gateway', + localName + ); + } + onProgress(`Gateway: ${gatewayDetail.name} -> local name: ${localName}`); + logger.endStep('success'); + + // 5. Check for duplicates + logger.startStep('Check for duplicates'); + const projectSpec = await ctx.configIO.readProjectSpec(); + const existingNames = new Set(projectSpec.agentCoreGateways.map(g => g.name)); + if (existingNames.has(localName)) { + return failResult( + logger, + `Gateway "${localName}" already exists in the project. Use --name to specify a different local name.`, + 'gateway', + localName + ); + } + const targetName = target.name ?? 'default'; + const existingResource = await findResourceInDeployedState(ctx.configIO, targetName, 'gateway', gatewayId); + if (existingResource) { + return failResult( + logger, + `Gateway "${gatewayId}" is already imported in this project as "${existingResource}". Remove it first before re-importing.`, + 'gateway', + localName + ); + } + logger.endStep('success'); + + // 6. Map AWS responses to CLI schema + logger.startStep('Map gateway to project schema'); + const credentialArnMap = await buildCredentialArnMap(ctx.configIO, targetName); + + const mappedTargets: AgentCoreGatewayTarget[] = []; + for (const td of targetDetails) { + const mapped = toGatewayTargetSpec(td, credentialArnMap, onProgress); + if (mapped) { + mappedTargets.push(mapped); + } + } + + const gatewaySpec = toGatewaySpec(gatewayDetail, mappedTargets, localName); + onProgress(`Mapped gateway with ${mappedTargets.length} target(s)`); + if (mappedTargets.length < targetDetails.length) { + onProgress( + `Warning: ${targetDetails.length - mappedTargets.length} target(s) could not be mapped and were skipped` + ); + } + logger.endStep('success'); + + // 7. Update project config + logger.startStep('Update project config'); + configSnapshot = JSON.parse(JSON.stringify(projectSpec)) as AgentCoreProjectSpec; + projectSpec.agentCoreGateways.push(gatewaySpec); + await ctx.configIO.writeProjectSpec(projectSpec); + configWritten = true; + onProgress(`Added gateway "${localName}" to agentcore.json`); + logger.endStep('success'); + + // 8. CDK build -> synth -> bootstrap -> phase 1 -> phase 2 -> update state + logger.startStep('Build and synth CDK'); + const stackName = toStackName(ctx.projectName, targetName); + + // Build target ID map for CFN import: target name → physical target ID + const targetIdMap = new Map(); + for (const td of targetDetails) { + const mappedTarget = mappedTargets.find(mt => mt.name === td.name); + if (mappedTarget) { + targetIdMap.set(td.name, td.targetId); + } + } + + const pipelineResult = await executeCdkImportPipeline({ + projectRoot: ctx.projectRoot, + stackName, + target, + configIO: ctx.configIO, + targetName, + onProgress, + buildResourcesToImport: (synthTemplate, deployedTemplate) => { + const resourcesToImport: ResourceToImport[] = []; + + // Exclude logical IDs already managed by the stack so we never re-import + // a previously-imported gateway or target with a colliding Name. + const deployedIds = new Set(Object.keys(deployedTemplate.Resources)); + + // Find gateway logical ID + const gatewayResourceName = `${ctx.projectName}-${localName}`; + let gatewayLogicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Gateway', + 'Name', + gatewayResourceName, + { excludeLogicalIds: deployedIds } + ); + gatewayLogicalId ??= findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Gateway', + 'Name', + localName, + { excludeLogicalIds: deployedIds } + ); + if (!gatewayLogicalId) { + const candidateGatewayIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Gateway').filter( + id => !deployedIds.has(id) + ); + if (candidateGatewayIds.length === 1) { + gatewayLogicalId = candidateGatewayIds[0]; + } + } + + if (!gatewayLogicalId) { + return []; + } + + resourcesToImport.push({ + resourceType: 'AWS::BedrockAgentCore::Gateway', + logicalResourceId: gatewayLogicalId, + resourceIdentifier: { GatewayIdentifier: gatewayId }, + }); + + // Find target logical IDs (excluding those already in the deployed stack) + const candidateTargetIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::GatewayTarget').filter( + id => !deployedIds.has(id) + ); + + for (const [tName, tId] of targetIdMap) { + // Try name-based matching first + let targetLogicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::GatewayTarget', + 'Name', + tName, + { excludeLogicalIds: deployedIds } + ); + + // Fall back: if exactly one unmatched target logical ID remains, use it + if (!targetLogicalId && candidateTargetIds.length === 1 && targetIdMap.size === 1) { + targetLogicalId = candidateTargetIds[0]; + } + + if (targetLogicalId) { + resourcesToImport.push({ + resourceType: 'AWS::BedrockAgentCore::GatewayTarget', + logicalResourceId: targetLogicalId, + resourceIdentifier: { GatewayIdentifier: gatewayId, TargetId: tId }, + }); + } else { + onProgress(`Warning: Could not find logical ID for target "${tName}" in CloudFormation template`); + } + } + + return resourcesToImport; + }, + deployedStateEntries: [{ type: 'gateway', name: localName, id: gatewayId, arn: gatewayDetail.gatewayArn }], + }); + + if (pipelineResult.noResources) { + const error = `Could not find logical ID for gateway "${localName}" in CloudFormation template`; + await rollback(); + return failResult(logger, error, 'gateway', localName); + } + + if (!pipelineResult.success) { + await rollback(); + logger.endStep('error', pipelineResult.error); + logger.finalize(false); + return { + success: false, + error: pipelineResult.error, + resourceType: 'gateway', + resourceName: localName, + logPath: logger.getRelativeLogPath(), + }; + } + logger.endStep('success'); + + // 9. Return success + logger.finalize(true); + return { + success: true, + resourceType: 'gateway', + resourceName: localName, + resourceId: gatewayId, + logPath: logger.getRelativeLogPath(), + }; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + await rollback(); + if (importCtx) { + importCtx.logger.log(message, 'error'); + importCtx.logger.finalize(false); + } + return { + success: false, + error: message, + resourceType: 'gateway', + resourceName: options.name ?? '', + logPath: importCtx?.logger.getRelativeLogPath(), + }; + } +} + +/** @internal — exported for unit testing */ +export { + toGatewayTargetSpec as _toGatewayTargetSpec, + toGatewayTargetSpec, + resolveOutboundAuth as _resolveOutboundAuth, +}; + +// ============================================================================ +// Command Registration +// ============================================================================ + +/** + * Register the `import gateway` subcommand. + */ +export function registerImportGateway(importCmd: Command): void { + importCmd + .command('gateway') + .description('Import an existing AgentCore Gateway (with targets) from your AWS account') + .option('--arn ', 'Gateway ARN to import') + .action(async (cliOptions: ImportResourceOptions) => { + const result = await handleImportGateway(cliOptions); + + if (result.success) { + console.log(''); + console.log(`${ANSI.green}Gateway imported successfully!${ANSI.reset}`); + console.log(` Name: ${result.resourceName}`); + console.log(` ID: ${result.resourceId}`); + console.log(''); + console.log(`${ANSI.dim}Next steps:${ANSI.reset}`); + console.log(` agentcore deploy ${ANSI.dim}Deploy the imported stack${ANSI.reset}`); + console.log(` agentcore status ${ANSI.dim}Verify resource status${ANSI.reset}`); + console.log(` agentcore fetch access ${ANSI.dim}Get gateway URL and token${ANSI.reset}`); + console.log(''); + } else { + console.error(`\n${ANSI.red}[error]${ANSI.reset} ${result.error}`); + if (result.logPath) { + console.error(`Log: ${result.logPath}`); + } + process.exit(1); + } + }); +} diff --git a/src/cli/commands/import/import-pipeline.ts b/src/cli/commands/import/import-pipeline.ts index 6f6444f9a..75b9c70dd 100644 --- a/src/cli/commands/import/import-pipeline.ts +++ b/src/cli/commands/import/import-pipeline.ts @@ -21,7 +21,7 @@ export interface CdkImportPipelineInput { onProgress: (message: string) => void; /** Caller builds the import resource list from the synthesized template. */ - buildResourcesToImport: (synthTemplate: CfnTemplate) => ResourceToImport[]; + buildResourcesToImport: (synthTemplate: CfnTemplate, deployedTemplate: CfnTemplate) => ResourceToImport[]; /** Entries to write into deployed-state.json after a successful import. */ deployedStateEntries: ImportedResource[]; @@ -114,7 +114,7 @@ export async function executeCdkImportPipeline(input: CdkImportPipelineInput): P } // 7. Build resources to import (caller-specific logic) - const resourcesToImport = buildResourcesToImport(synthTemplate); + const resourcesToImport = buildResourcesToImport(synthTemplate, deployedTemplate); if (resourcesToImport.length === 0) { return { success: true, noResources: true }; diff --git a/src/cli/commands/import/import-utils.ts b/src/cli/commands/import/import-utils.ts index d224870ec..8aa9dd0af 100644 --- a/src/cli/commands/import/import-utils.ts +++ b/src/cli/commands/import/import-utils.ts @@ -130,13 +130,30 @@ export async function resolveImportTarget(options: ResolveTargetOptions): Promis // Validate ARN format early if provided if ( arn && - !/^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config)\/(.+)$/.test(arn) + !/^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config|gateway)\/(.+)$/.test( + arn + ) ) { throw new Error( - `Not a valid ARN: "${arn}".\nExpected format: arn:aws:bedrock-agentcore:::/` + `Not a valid ARN: "${arn}".\nExpected format: arn:aws:bedrock-agentcore:::/` ); } + // Detect region mismatch between caller's AWS_REGION and the ARN's region up front. + // Without this the ARN's region silently wins and the user can import cross-region + // by accident, leaving agentcore.json pointed at a region they didn't intend. + if (arn) { + const arnRegionMatch = /^arn:aws:bedrock-agentcore:([^:]+):/.exec(arn); + const arnRegion = arnRegionMatch?.[1]; + const envRegion = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION; + if (arnRegion && envRegion && envRegion !== arnRegion) { + throw new Error( + `Region mismatch: AWS_REGION is "${envRegion}" but the ARN is in "${arnRegion}". ` + + `Either re-run with AWS_REGION=${arnRegion} or pass an ARN from ${envRegion}.` + ); + } + } + let targets = await configIO.readAWSDeploymentTargets(); if (targets.length === 0) { @@ -210,7 +227,7 @@ export interface ParsedArn { } const ARN_PATTERN = - /^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config)\/(.+)$/; + /^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config|gateway)\/(.+)$/; /** Unified config for each importable resource type — ARN mapping, deployed state keys. */ const RESOURCE_TYPE_CONFIG: Record< @@ -229,6 +246,7 @@ const RESOURCE_TYPE_CONFIG: Record< collectionKey: 'onlineEvalConfigs', idField: 'onlineEvaluationConfigId', }, + gateway: { arnType: 'gateway', collectionKey: 'mcp.gateways', idField: 'gatewayId' }, }; /** @@ -302,7 +320,11 @@ export async function findResourceInDeployedState( const { collectionKey, idField } = RESOURCE_TYPE_CONFIG[resourceType]; - const collection = targetState.resources[collectionKey]; + // Handle nested path (e.g., 'mcp.gateways') by traversing dot-separated keys + let collection: any = targetState.resources; + for (const key of collectionKey.split('.')) { + collection = collection?.[key]; + } if (!collection) return undefined; for (const [name, entry] of Object.entries(collection)) { if ((entry as any)[idField] === resourceId) return name; @@ -360,6 +382,13 @@ export async function updateDeployedState( onlineEvaluationConfigId: resource.id, onlineEvaluationConfigArn: resource.arn, }; + } else if (resource.type === 'gateway') { + targetState.resources.mcp ??= {}; + targetState.resources.mcp.gateways ??= {}; + targetState.resources.mcp.gateways[resource.name] = { + gatewayId: resource.id, + gatewayArn: resource.arn, + }; } } diff --git a/src/cli/commands/import/resource-import.ts b/src/cli/commands/import/resource-import.ts index 6418e3676..6ce79b825 100644 --- a/src/cli/commands/import/resource-import.ts +++ b/src/cli/commands/import/resource-import.ts @@ -158,13 +158,16 @@ export async function executeResourceImport( configIO: ctx.configIO, targetName, onProgress, - buildResourcesToImport: synthTemplate => { + buildResourcesToImport: (synthTemplate, deployedTemplate) => { + const deployedIds = new Set(Object.keys(deployedTemplate.Resources)); + // Try matching by name property (plain name first, then prefixed) let logicalId = findLogicalIdByProperty( synthTemplate, descriptor.cfnResourceType, descriptor.cfnNameProperty, - localName + localName, + { excludeLogicalIds: deployedIds } ); if (!logicalId) { @@ -173,13 +176,16 @@ export async function executeResourceImport( synthTemplate, descriptor.cfnResourceType, descriptor.cfnNameProperty, - prefixedName + prefixedName, + { excludeLogicalIds: deployedIds } ); } // Fall back to single resource by type if (!logicalId) { - const allLogicalIds = findLogicalIdsByType(synthTemplate, descriptor.cfnResourceType); + const allLogicalIds = findLogicalIdsByType(synthTemplate, descriptor.cfnResourceType).filter( + id => !deployedIds.has(id) + ); if (allLogicalIds.length === 1) { logicalId = allLogicalIds[0]; } diff --git a/src/cli/commands/import/template-utils.ts b/src/cli/commands/import/template-utils.ts index 4e6a516af..e4d4f35e9 100644 --- a/src/cli/commands/import/template-utils.ts +++ b/src/cli/commands/import/template-utils.ts @@ -192,10 +192,14 @@ export function findLogicalIdByProperty( template: CfnTemplate, resourceType: string, propertyName: string, - propertyValue: string + propertyValue: string, + options?: { excludeLogicalIds?: ReadonlySet } ): string | undefined { + const exclude = options?.excludeLogicalIds; + // First pass: exact string match (highest confidence) for (const [logicalId, resource] of Object.entries(template.Resources)) { + if (exclude?.has(logicalId)) continue; if (resource.Type === resourceType && resource.Properties) { if (resource.Properties[propertyName] === propertyValue) { return logicalId; @@ -211,6 +215,7 @@ export function findLogicalIdByProperty( const pattern = new RegExp(escaped + '(?=[^a-zA-Z0-9_]|$)'); for (const [logicalId, resource] of Object.entries(template.Resources)) { + if (exclude?.has(logicalId)) continue; if (resource.Type === resourceType && resource.Properties) { const propVal = resource.Properties[propertyName]; if (typeof propVal === 'object' && propVal !== null) { diff --git a/src/cli/commands/import/types.ts b/src/cli/commands/import/types.ts index eab11c0a8..a3dbc0a4f 100644 --- a/src/cli/commands/import/types.ts +++ b/src/cli/commands/import/types.ts @@ -74,7 +74,7 @@ export interface ParsedStarterToolkitConfig { * Resource types supported by the import subcommands. * Use the array for runtime checks (e.g., IMPORTABLE_RESOURCES.includes(x)). */ -export const IMPORTABLE_RESOURCES = ['runtime', 'memory', 'evaluator', 'online-eval'] as const; +export const IMPORTABLE_RESOURCES = ['runtime', 'memory', 'evaluator', 'online-eval', 'gateway'] as const; export type ImportableResourceType = (typeof IMPORTABLE_RESOURCES)[number]; /** diff --git a/src/cli/tui/screens/import/ArnInputScreen.tsx b/src/cli/tui/screens/import/ArnInputScreen.tsx index 188f9a694..a33f527a4 100644 --- a/src/cli/tui/screens/import/ArnInputScreen.tsx +++ b/src/cli/tui/screens/import/ArnInputScreen.tsx @@ -4,7 +4,8 @@ import { Screen } from '../../components/Screen'; import { TextInput } from '../../components/TextInput'; import { HELP_TEXT } from '../../constants'; -const ARN_PATTERN = /^arn:aws:bedrock-agentcore:[^:]+:[^:]+:(runtime|memory|evaluator|online-evaluation-config)\/.+$/; +const ARN_PATTERN = + /^arn:aws:bedrock-agentcore:[^:]+:[^:]+:(runtime|memory|evaluator|online-evaluation-config|gateway)\/.+$/; function validateArn(value: string): true | string { if (!ARN_PATTERN.test(value)) { @@ -24,6 +25,7 @@ const RESOURCE_TYPE_LABELS: Record = { memory: 'Import Memory', evaluator: 'Import Evaluator', 'online-eval': 'Import Online Eval Config', + gateway: 'Import Gateway', }; export function ArnInputScreen({ resourceType, onSubmit, onExit }: ArnInputScreenProps) { @@ -40,6 +42,7 @@ export function ArnInputScreen({ resourceType, onSubmit, onExit }: ArnInputScree onSubmit={onSubmit} onCancel={onExit} customValidation={validateArn} + expandable /> diff --git a/src/cli/tui/screens/import/ImportProgressScreen.tsx b/src/cli/tui/screens/import/ImportProgressScreen.tsx index bd7096d5f..3771ffbab 100644 --- a/src/cli/tui/screens/import/ImportProgressScreen.tsx +++ b/src/cli/tui/screens/import/ImportProgressScreen.tsx @@ -49,7 +49,9 @@ export function ImportProgressScreen({ ? (await import('../../../commands/import/import-memory')).handleImportMemory : importType === 'evaluator' ? (await import('../../../commands/import/import-evaluator')).handleImportEvaluator - : (await import('../../../commands/import/import-online-eval')).handleImportOnlineEval; + : importType === 'gateway' + ? (await import('../../../commands/import/import-gateway')).handleImportGateway + : (await import('../../../commands/import/import-online-eval')).handleImportOnlineEval; const result = await handler({ arn, code, onProgress }); if (result.success) { diff --git a/src/cli/tui/screens/import/ImportSelectScreen.tsx b/src/cli/tui/screens/import/ImportSelectScreen.tsx index 21ab114f8..77ee7580b 100644 --- a/src/cli/tui/screens/import/ImportSelectScreen.tsx +++ b/src/cli/tui/screens/import/ImportSelectScreen.tsx @@ -2,7 +2,7 @@ import type { SelectableItem } from '../../components/SelectList'; import { SelectScreen } from '../../components/SelectScreen'; import { Text } from 'ink'; -export type ImportType = 'runtime' | 'memory' | 'evaluator' | 'online-eval' | 'starter-toolkit'; +export type ImportType = 'runtime' | 'memory' | 'evaluator' | 'online-eval' | 'gateway' | 'starter-toolkit'; interface ImportSelectItem extends SelectableItem { id: ImportType; @@ -29,6 +29,11 @@ const IMPORT_OPTIONS: ImportSelectItem[] = [ title: 'Online Eval Config', description: 'Import an existing AgentCore Online Evaluation Config from your AWS account', }, + { + id: 'gateway', + title: 'Gateway', + description: 'Import an existing AgentCore Gateway (with targets) from your AWS account', + }, { id: 'starter-toolkit', title: 'From Starter Toolkit', diff --git a/src/schema/schemas/mcp.ts b/src/schema/schemas/mcp.ts index aaaa4e9cd..6b857a157 100644 --- a/src/schema/schemas/mcp.ts +++ b/src/schema/schemas/mcp.ts @@ -580,6 +580,8 @@ export type GatewayPolicyEngineConfiguration = z.infer