diff --git a/src/cli/aws/agentcore-control.ts b/src/cli/aws/agentcore-control.ts index 108f1397..d44c6473 100644 --- a/src/cli/aws/agentcore-control.ts +++ b/src/cli/aws/agentcore-control.ts @@ -1,3 +1,4 @@ +import type { EvaluationLevel } from '../../schema/schemas/primitives/evaluator'; import { getCredentialProvider } from './account'; import { BedrockAgentCoreControlClient, @@ -8,6 +9,7 @@ import { ListAgentRuntimesCommand, ListEvaluatorsCommand, ListMemoriesCommand, + ListOnlineEvaluationConfigsCommand, ListTagsForResourceCommand, UpdateOnlineEvaluationConfigCommand, } from '@aws-sdk/client-bedrock-agentcore-control'; @@ -24,6 +26,53 @@ export function createControlClient(region: string): BedrockAgentCoreControlClie }); } +/** + * Paginate through all pages of a list API and collect every item. + * Reuses a single client for connection pooling across pages. + */ +async function paginateAll( + region: string, + fetchPage: ( + options: { region: string; maxResults: number; nextToken?: string }, + client: BedrockAgentCoreControlClient + ) => Promise<{ items: T[]; nextToken?: string }> +): Promise { + const client = createControlClient(region); + const items: T[] = []; + let nextToken: string | undefined; + + do { + const result = await fetchPage({ region, maxResults: 100, nextToken }, client); + items.push(...result.items); + nextToken = result.nextToken; + } while (nextToken); + + return items; +} + +/** + * Fetch tags for a resource by ARN. Returns undefined when the ARN is missing, + * the resource has no tags, or the ListTagsForResource call fails. + */ +async function fetchTags( + client: BedrockAgentCoreControlClient, + resourceArn: string | undefined, + resourceLabel: string +): Promise | undefined> { + if (!resourceArn) return undefined; + try { + const response = await client.send(new ListTagsForResourceCommand({ resourceArn })); + if (response.tags && Object.keys(response.tags).length > 0) { + return response.tags; + } + } catch (err) { + console.warn( + `Warning: Failed to fetch tags for ${resourceLabel}: ${err instanceof Error ? err.message : String(err)}` + ); + } + return undefined; +} + export interface GetAgentRuntimeStatusOptions { region: string; runtimeId: string; @@ -113,17 +162,10 @@ export async function listAgentRuntimes( * List all AgentCore Runtimes in the given region, paginating through all pages. */ export async function listAllAgentRuntimes(options: { region: string }): Promise { - const client = createControlClient(options.region); - const runtimes: AgentRuntimeSummary[] = []; - let nextToken: string | undefined; - - do { - const result = await listAgentRuntimes({ region: options.region, maxResults: 100, nextToken }, client); - runtimes.push(...result.runtimes); - nextToken = result.nextToken; - } while (nextToken); - - return runtimes; + return paginateAll(options.region, async (opts, client) => { + const result = await listAgentRuntimes(opts, client); + return { items: result.runtimes, nextToken: result.nextToken }; + }); } export interface GetAgentRuntimeOptions { @@ -221,18 +263,7 @@ export async function getAgentRuntimeDetail(options: GetAgentRuntimeOptions): Pr } } - // Fetch tags via separate API call (same pattern as getMemoryDetail) - let tags: Record | undefined; - if (response.agentRuntimeArn) { - try { - const tagsResponse = await client.send(new ListTagsForResourceCommand({ resourceArn: response.agentRuntimeArn })); - if (tagsResponse.tags && Object.keys(tagsResponse.tags).length > 0) { - tags = tagsResponse.tags; - } - } catch (err) { - console.warn(`Warning: Failed to fetch tags for runtime: ${err instanceof Error ? err.message : String(err)}`); - } - } + const tags = await fetchTags(client, response.agentRuntimeArn, 'runtime'); return { agentRuntimeId: response.agentRuntimeId ?? '', @@ -311,17 +342,10 @@ export async function listMemories( * List all AgentCore Memories in the given region, paginating through all pages. */ export async function listAllMemories(options: { region: string }): Promise { - const client = createControlClient(options.region); - const memories: MemorySummary[] = []; - let nextToken: string | undefined; - - do { - const result = await listMemories({ region: options.region, maxResults: 100, nextToken }, client); - memories.push(...result.memories); - nextToken = result.nextToken; - } while (nextToken); - - return memories; + return paginateAll(options.region, async (opts, client) => { + const result = await listMemories(opts, client); + return { items: result.memories, nextToken: result.nextToken }; + }); } export interface GetMemoryOptions { @@ -378,16 +402,7 @@ export async function getMemoryDetail(options: GetMemoryOptions): Promise | undefined; - try { - const tagsResponse = await client.send(new ListTagsForResourceCommand({ resourceArn: memory.arn })); - if (tagsResponse.tags && Object.keys(tagsResponse.tags).length > 0) { - tags = tagsResponse.tags; - } - } catch (err) { - console.warn(`Warning: Failed to fetch tags for memory: ${err instanceof Error ? err.message : String(err)}`); - } + const tags = await fetchTags(client, memory.arn, 'memory'); return { memoryId: memory.id, @@ -424,13 +439,31 @@ export interface GetEvaluatorOptions { evaluatorId: string; } +export interface GetEvaluatorLlmConfig { + model: string; + instructions: string; + ratingScale: { + numerical?: { value: number; label: string; definition: string }[]; + categorical?: { label: string; definition: string }[]; + }; +} + +export interface GetEvaluatorCodeBasedConfig { + lambdaArn: string; +} + export interface GetEvaluatorResult { evaluatorId: string; evaluatorArn: string; evaluatorName: string; - level: string; + level: EvaluationLevel; status: string; description?: string; + evaluatorConfig?: { + llmAsAJudge?: GetEvaluatorLlmConfig; + codeBased?: GetEvaluatorCodeBasedConfig; + }; + tags?: Record; } export async function getEvaluator(options: GetEvaluatorOptions): Promise { @@ -440,19 +473,75 @@ export async function getEvaluator(options: GetEvaluatorOptions): Promise ({ + value: n.value ?? 0, + label: n.label ?? '', + definition: n.definition ?? '', + })); + } else if ('categorical' in llm.ratingScale && llm.ratingScale.categorical) { + ratingScale.categorical = llm.ratingScale.categorical.map(c => ({ + label: c.label ?? '', + definition: c.definition ?? '', + })); + } + } + evaluatorConfig = { + llmAsAJudge: { model, instructions: llm.instructions ?? '', ratingScale }, + }; + } else if ('codeBased' in response.evaluatorConfig && response.evaluatorConfig.codeBased) { + const cb = response.evaluatorConfig.codeBased; + if ('lambdaConfig' in cb && cb.lambdaConfig) { + evaluatorConfig = { + codeBased: { lambdaArn: cb.lambdaConfig.lambdaArn ?? '' }, + }; + } + } + } + + const tags = await fetchTags(client, response.evaluatorArn, 'evaluator'); + return { evaluatorId: response.evaluatorId, evaluatorArn: response.evaluatorArn ?? '', evaluatorName: response.evaluatorName ?? '', - level: response.level ?? 'SESSION', + level: (response.level ?? 'SESSION') as EvaluationLevel, status: response.status ?? 'UNKNOWN', description: response.description, + evaluatorConfig, + tags, }; } @@ -477,15 +566,18 @@ export interface ListEvaluatorsResult { nextToken?: string; } -export async function listEvaluators(options: ListEvaluatorsOptions): Promise { - const client = createControlClient(options.region); +export async function listEvaluators( + options: ListEvaluatorsOptions, + client?: BedrockAgentCoreControlClient +): Promise { + const resolvedClient = client ?? createControlClient(options.region); const command = new ListEvaluatorsCommand({ maxResults: options.maxResults, nextToken: options.nextToken, }); - const response = await client.send(command); + const response = await resolvedClient.send(command); return { evaluators: (response.evaluators ?? []).map(e => ({ @@ -501,8 +593,82 @@ export async function listEvaluators(options: ListEvaluatorsOptions): Promise
  • { + return paginateAll(options.region, async (opts, client) => { + const result = await listEvaluators(opts, client); + return { + items: result.evaluators.filter(e => !e.evaluatorName.startsWith('Builtin.')), + nextToken: result.nextToken, + }; + }); +} + // ============================================================================ -// Online Eval Config +// Online Eval Config — List +// ============================================================================ + +export interface ListOnlineEvalConfigsOptions { + region: string; + maxResults?: number; + nextToken?: string; +} + +export interface OnlineEvalConfigSummary { + onlineEvaluationConfigId: string; + onlineEvaluationConfigArn: string; + onlineEvaluationConfigName: string; + description?: string; + status: string; + executionStatus: string; +} + +export interface ListOnlineEvalConfigsResult { + configs: OnlineEvalConfigSummary[]; + nextToken?: string; +} + +export async function listOnlineEvaluationConfigs( + options: ListOnlineEvalConfigsOptions, + client?: BedrockAgentCoreControlClient +): Promise { + const resolvedClient = client ?? createControlClient(options.region); + + const command = new ListOnlineEvaluationConfigsCommand({ + maxResults: options.maxResults, + nextToken: options.nextToken, + }); + + const response = await resolvedClient.send(command); + + return { + configs: (response.onlineEvaluationConfigs ?? []).map(c => ({ + onlineEvaluationConfigId: c.onlineEvaluationConfigId ?? '', + onlineEvaluationConfigArn: c.onlineEvaluationConfigArn ?? '', + onlineEvaluationConfigName: c.onlineEvaluationConfigName ?? '', + description: c.description, + status: c.status ?? 'UNKNOWN', + executionStatus: c.executionStatus ?? 'UNKNOWN', + })), + nextToken: response.nextToken, + }; +} + +/** + * List all online evaluation configs in the given region, paginating through all pages. + */ +export async function listAllOnlineEvaluationConfigs(options: { region: string }): Promise { + return paginateAll(options.region, async (opts, client) => { + const result = await listOnlineEvaluationConfigs(opts, client); + return { items: result.configs, nextToken: result.nextToken }; + }); +} + +// ============================================================================ +// Online Eval Config — Update / Get // ============================================================================ export type OnlineEvalExecutionStatus = 'ENABLED' | 'DISABLED'; @@ -568,6 +734,12 @@ export interface GetOnlineEvalConfigResult { description?: string; failureReason?: string; outputLogGroupName?: string; + /** Sampling percentage from the rule config */ + samplingPercentage?: number; + /** Service names from CloudWatch data source config (e.g. "projectName_agentName.DEFAULT") */ + serviceNames?: string[]; + /** Evaluator IDs referenced by this config */ + evaluatorIds?: string[]; } export async function getOnlineEvaluationConfig( @@ -586,6 +758,14 @@ export async function getOnlineEvaluationConfig( } const logGroupName = response.outputConfig?.cloudWatchConfig?.logGroupName; + const samplingPercentage = response.rule?.samplingConfig?.samplingPercentage; + const serviceNames = + response.dataSourceConfig && 'cloudWatchLogs' in response.dataSourceConfig + ? response.dataSourceConfig.cloudWatchLogs?.serviceNames + : undefined; + const evaluatorIds = (response.evaluators ?? []) + .map(e => ('evaluatorId' in e ? e.evaluatorId : undefined)) + .filter((id): id is string => !!id); return { configId: response.onlineEvaluationConfigId, @@ -596,5 +776,8 @@ export async function getOnlineEvaluationConfig( description: response.description, failureReason: response.failureReason, outputLogGroupName: logGroupName, + samplingPercentage, + serviceNames, + evaluatorIds, }; } diff --git a/src/cli/commands/import/__tests__/import-evaluator.test.ts b/src/cli/commands/import/__tests__/import-evaluator.test.ts new file mode 100644 index 00000000..5e6fb5e9 --- /dev/null +++ b/src/cli/commands/import/__tests__/import-evaluator.test.ts @@ -0,0 +1,437 @@ +/** + * Import Evaluator Unit Tests + * + * Covers: + * - toEvaluatorSpec conversion: LLM-as-a-Judge (numerical + categorical), code-based (external) + * - Evaluator with description and tags + * - Missing config error handling + * - Template logical ID lookup for evaluators + * - Phase 2 import resource list construction for evaluators + * - ARN validation for evaluator resource type + */ +import type { GetEvaluatorResult } from '../../../aws/agentcore-control'; +import { toEvaluatorSpec } from '../import-evaluator'; +import { buildImportTemplate, findLogicalIdByProperty, findLogicalIdsByType } from '../template-utils'; +import type { CfnTemplate } from '../template-utils'; +import type { ResourceToImport } from '../types'; +import { describe, expect, it } from 'vitest'; + +// ============================================================================ +// toEvaluatorSpec Conversion Tests +// ============================================================================ + +describe('toEvaluatorSpec', () => { + it('maps LLM-as-a-Judge evaluator with numerical rating scale', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-123', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-123', + evaluatorName: 'my_evaluator', + level: 'SESSION', + status: 'ACTIVE', + description: 'Test evaluator', + evaluatorConfig: { + llmAsAJudge: { + model: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + instructions: 'Evaluate the response quality', + ratingScale: { + numerical: [ + { value: 1, label: 'Poor', definition: 'Low quality response' }, + { value: 5, label: 'Excellent', definition: 'High quality response' }, + ], + }, + }, + }, + tags: { env: 'test' }, + }; + + const result = toEvaluatorSpec(detail, 'my_evaluator'); + + expect(result.name).toBe('my_evaluator'); + expect(result.level).toBe('SESSION'); + expect(result.description).toBe('Test evaluator'); + expect(result.config.llmAsAJudge).toBeDefined(); + expect(result.config.llmAsAJudge!.model).toBe('anthropic.claude-3-5-sonnet-20241022-v2:0'); + expect(result.config.llmAsAJudge!.instructions).toBe('Evaluate the response quality'); + expect(result.config.llmAsAJudge!.ratingScale.numerical).toHaveLength(2); + expect(result.config.llmAsAJudge!.ratingScale.numerical![0]).toEqual({ + value: 1, + label: 'Poor', + definition: 'Low quality response', + }); + expect(result.tags).toEqual({ env: 'test' }); + }); + + it('maps LLM-as-a-Judge evaluator with categorical rating scale', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-456', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-456', + evaluatorName: 'categorical_eval', + level: 'TRACE', + status: 'ACTIVE', + evaluatorConfig: { + llmAsAJudge: { + model: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + instructions: 'Rate as pass or fail', + ratingScale: { + categorical: [ + { label: 'Pass', definition: 'Response meets criteria' }, + { label: 'Fail', definition: 'Response does not meet criteria' }, + ], + }, + }, + }, + }; + + const result = toEvaluatorSpec(detail, 'categorical_eval'); + + expect(result.level).toBe('TRACE'); + expect(result.config.llmAsAJudge).toBeDefined(); + expect(result.config.llmAsAJudge!.ratingScale.categorical).toHaveLength(2); + expect(result.config.llmAsAJudge!.ratingScale.categorical![0]).toEqual({ + label: 'Pass', + definition: 'Response meets criteria', + }); + // No description or tags + expect(result.description).toBeUndefined(); + expect(result.tags).toBeUndefined(); + }); + + it('maps code-based evaluator as external with Lambda ARN', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-code-789', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-code-789', + evaluatorName: 'code_eval', + level: 'TOOL_CALL', + status: 'ACTIVE', + evaluatorConfig: { + codeBased: { + lambdaArn: 'arn:aws:lambda:us-west-2:123456789012:function:my-eval-function', + }, + }, + }; + + const result = toEvaluatorSpec(detail, 'code_eval'); + + expect(result.name).toBe('code_eval'); + expect(result.level).toBe('TOOL_CALL'); + expect(result.config.codeBased).toBeDefined(); + expect(result.config.codeBased!.external).toBeDefined(); + expect(result.config.codeBased!.external!.lambdaArn).toBe( + 'arn:aws:lambda:us-west-2:123456789012:function:my-eval-function' + ); + expect(result.config.llmAsAJudge).toBeUndefined(); + }); + + it('uses provided local name instead of evaluator name from AWS', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-rename', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-rename', + evaluatorName: 'original_name', + level: 'SESSION', + status: 'ACTIVE', + evaluatorConfig: { + llmAsAJudge: { + model: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + instructions: 'Evaluate', + ratingScale: { numerical: [{ value: 1, label: 'Low', definition: 'Low' }] }, + }, + }, + }; + + const result = toEvaluatorSpec(detail, 'custom_local_name'); + + expect(result.name).toBe('custom_local_name'); + }); + + it('throws when evaluator has no recognizable config', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-no-config', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-no-config', + evaluatorName: 'broken_eval', + level: 'SESSION', + status: 'ACTIVE', + }; + + expect(() => toEvaluatorSpec(detail, 'broken_eval')).toThrow('Evaluator "broken_eval" has no recognizable config'); + }); + + it('throws when evaluatorConfig is empty object', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-empty', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-empty', + evaluatorName: 'empty_config_eval', + level: 'SESSION', + status: 'ACTIVE', + evaluatorConfig: {}, + }; + + expect(() => toEvaluatorSpec(detail, 'empty_config_eval')).toThrow('has no recognizable config'); + }); + + it('omits description when not present', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-no-desc', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-no-desc', + evaluatorName: 'no_desc_eval', + level: 'SESSION', + status: 'ACTIVE', + evaluatorConfig: { + llmAsAJudge: { + model: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + instructions: 'Evaluate', + ratingScale: { numerical: [{ value: 1, label: 'Low', definition: 'Low' }] }, + }, + }, + }; + + const result = toEvaluatorSpec(detail, 'no_desc_eval'); + + expect(result.description).toBeUndefined(); + }); + + it('omits tags when empty', () => { + const detail: GetEvaluatorResult = { + evaluatorId: 'eval-empty-tags', + evaluatorArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-empty-tags', + evaluatorName: 'empty_tags_eval', + level: 'SESSION', + status: 'ACTIVE', + evaluatorConfig: { + llmAsAJudge: { + model: 'anthropic.claude-3-5-sonnet-20241022-v2:0', + instructions: 'Evaluate', + ratingScale: { numerical: [{ value: 1, label: 'Low', definition: 'Low' }] }, + }, + }, + tags: {}, + }; + + const result = toEvaluatorSpec(detail, 'empty_tags_eval'); + + expect(result.tags).toBeUndefined(); + }); +}); + +// ============================================================================ +// Template Logical ID Lookup Tests for Evaluators +// ============================================================================ + +describe('Template Logical ID Lookup for Evaluators', () => { + const synthTemplate: CfnTemplate = { + Resources: { + MyEvaluatorResource: { + Type: 'AWS::BedrockAgentCore::Evaluator', + Properties: { + EvaluatorName: 'my_evaluator', + Level: 'SESSION', + }, + }, + PrefixedEvaluatorResource: { + Type: 'AWS::BedrockAgentCore::Evaluator', + Properties: { + EvaluatorName: 'TestProject_prefixed_eval', + Level: 'TRACE', + }, + }, + MyAgentRuntime: { + Type: 'AWS::BedrockAgentCore::Runtime', + Properties: { + AgentRuntimeName: 'TestProject_my_agent', + }, + }, + MyIAMRole: { + Type: 'AWS::IAM::Role', + Properties: { + RoleName: 'MyRole', + }, + }, + }, + }; + + it('finds evaluator logical ID by EvaluatorName property', () => { + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + 'my_evaluator' + ); + expect(logicalId).toBe('MyEvaluatorResource'); + }); + + it('finds prefixed evaluator by full name', () => { + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + 'TestProject_prefixed_eval' + ); + expect(logicalId).toBe('PrefixedEvaluatorResource'); + }); + + it('finds all evaluator logical IDs by type', () => { + const logicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Evaluator'); + expect(logicalIds).toHaveLength(2); + expect(logicalIds).toContain('MyEvaluatorResource'); + expect(logicalIds).toContain('PrefixedEvaluatorResource'); + }); + + it('returns undefined for non-existent evaluator name', () => { + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + 'nonexistent_evaluator' + ); + expect(logicalId).toBeUndefined(); + }); + + it('falls back to single evaluator logical ID when name does not match', () => { + const singleEvalTemplate: CfnTemplate = { + Resources: { + OnlyEvaluator: { + Type: 'AWS::BedrockAgentCore::Evaluator', + Properties: { + EvaluatorName: 'some_eval', + Level: 'SESSION', + }, + }, + }, + }; + + let logicalId = findLogicalIdByProperty( + singleEvalTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + 'different_name' + ); + + // Primary lookup fails + expect(logicalId).toBeUndefined(); + + // Fallback: if there's only one evaluator resource, use it + if (!logicalId) { + const evaluatorLogicalIds = findLogicalIdsByType(singleEvalTemplate, 'AWS::BedrockAgentCore::Evaluator'); + if (evaluatorLogicalIds.length === 1) { + logicalId = evaluatorLogicalIds[0]; + } + } + expect(logicalId).toBe('OnlyEvaluator'); + }); +}); + +// ============================================================================ +// Phase 2 Resource Import List Construction for Evaluators +// ============================================================================ + +describe('Phase 2: ResourceToImport List Construction for Evaluators', () => { + const synthTemplate: CfnTemplate = { + Resources: { + EvaluatorLogicalId: { + Type: 'AWS::BedrockAgentCore::Evaluator', + Properties: { + EvaluatorName: 'my_evaluator', + Level: 'SESSION', + }, + }, + IAMRoleLogicalId: { + Type: 'AWS::IAM::Role', + Properties: {}, + }, + }, + }; + + it('builds ResourceToImport list for evaluator', () => { + const evaluatorName = 'my_evaluator'; + const evaluatorId = 'eval-123'; + + const resourcesToImport: ResourceToImport[] = []; + + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + evaluatorName + ); + + if (logicalId) { + resourcesToImport.push({ + resourceType: 'AWS::BedrockAgentCore::Evaluator', + logicalResourceId: logicalId, + resourceIdentifier: { EvaluatorId: evaluatorId }, + }); + } + + expect(resourcesToImport).toHaveLength(1); + expect(resourcesToImport[0]!.resourceType).toBe('AWS::BedrockAgentCore::Evaluator'); + expect(resourcesToImport[0]!.logicalResourceId).toBe('EvaluatorLogicalId'); + expect(resourcesToImport[0]!.resourceIdentifier).toEqual({ EvaluatorId: 'eval-123' }); + }); + + it('returns empty list when evaluator not found in template', () => { + const emptyTemplate: CfnTemplate = { + Resources: { + IAMRoleLogicalId: { + Type: 'AWS::IAM::Role', + Properties: {}, + }, + }, + }; + + const logicalId = findLogicalIdByProperty( + emptyTemplate, + 'AWS::BedrockAgentCore::Evaluator', + 'EvaluatorName', + 'my_evaluator' + ); + + expect(logicalId).toBeUndefined(); + }); +}); + +// ============================================================================ +// buildImportTemplate Tests for Evaluator Resources +// ============================================================================ + +describe('buildImportTemplate with Evaluator', () => { + it('adds evaluator resource to deployed template with Retain deletion policy', () => { + const deployedTemplate: CfnTemplate = { + Resources: { + ExistingIAMRole: { + Type: 'AWS::IAM::Role', + Properties: { RoleName: 'ExistingRole' }, + }, + }, + }; + + const synthTemplate: CfnTemplate = { + Resources: { + ExistingIAMRole: { + Type: 'AWS::IAM::Role', + Properties: { RoleName: 'ExistingRole' }, + }, + EvaluatorLogicalId: { + Type: 'AWS::BedrockAgentCore::Evaluator', + Properties: { + EvaluatorName: 'my_evaluator', + Level: 'SESSION', + }, + DependsOn: 'ExistingIAMRole', + }, + }, + }; + + const importTemplate = buildImportTemplate(deployedTemplate, synthTemplate, ['EvaluatorLogicalId']); + + // Verify evaluator resource was added + expect(importTemplate.Resources.EvaluatorLogicalId).toBeDefined(); + expect(importTemplate.Resources.EvaluatorLogicalId!.Type).toBe('AWS::BedrockAgentCore::Evaluator'); + expect(importTemplate.Resources.EvaluatorLogicalId!.DeletionPolicy).toBe('Retain'); + expect(importTemplate.Resources.EvaluatorLogicalId!.UpdateReplacePolicy).toBe('Retain'); + + // DependsOn should be removed for import + expect(importTemplate.Resources.EvaluatorLogicalId!.DependsOn).toBeUndefined(); + + // Original resource should still be there + expect(importTemplate.Resources.ExistingIAMRole).toBeDefined(); + }); +}); diff --git a/src/cli/commands/import/__tests__/import-online-eval.test.ts b/src/cli/commands/import/__tests__/import-online-eval.test.ts new file mode 100644 index 00000000..57f0137f --- /dev/null +++ b/src/cli/commands/import/__tests__/import-online-eval.test.ts @@ -0,0 +1,368 @@ +/** + * Import Online Eval Config Unit Tests + * + * Covers: + * - extractAgentName: service name parsing + * - toOnlineEvalConfigSpec conversion: happy path, missing sampling, enableOnCreate + * - Template logical ID lookup for online eval configs + * - Phase 2 import resource list construction for online eval configs + */ +import type { GetOnlineEvalConfigResult } from '../../../aws/agentcore-control'; +import { extractAgentName, toOnlineEvalConfigSpec } from '../import-online-eval'; +import { buildImportTemplate, findLogicalIdByProperty, findLogicalIdsByType } from '../template-utils'; +import type { CfnTemplate } from '../template-utils'; +import type { ResourceToImport } from '../types'; +import { describe, expect, it } from 'vitest'; + +// ============================================================================ +// extractAgentName Tests +// ============================================================================ + +describe('extractAgentName', () => { + it('extracts agent name from service name with .DEFAULT suffix', () => { + expect(extractAgentName(['my_agent.DEFAULT'])).toBe('my_agent'); + }); + + it('extracts agent name with project prefix pattern', () => { + expect(extractAgentName(['testproject_my_agent.DEFAULT'])).toBe('testproject_my_agent'); + }); + + it('returns full string when no dot suffix', () => { + expect(extractAgentName(['my_agent'])).toBe('my_agent'); + }); + + it('returns undefined for empty array', () => { + expect(extractAgentName([])).toBeUndefined(); + }); + + it('uses first service name when multiple provided', () => { + expect(extractAgentName(['agent_one.DEFAULT', 'agent_two.DEFAULT'])).toBe('agent_one'); + }); + + it('handles service name with multiple dots', () => { + expect(extractAgentName(['my.agent.DEFAULT'])).toBe('my.agent'); + }); +}); + +// ============================================================================ +// toOnlineEvalConfigSpec Conversion Tests +// ============================================================================ + +describe('toOnlineEvalConfigSpec', () => { + it('maps online eval config with all fields', () => { + const detail: GetOnlineEvalConfigResult = { + configId: 'oec-123', + configArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/oec-123', + configName: 'QualityMonitor', + status: 'ACTIVE', + executionStatus: 'ENABLED', + description: 'Monitor agent quality', + samplingPercentage: 50, + serviceNames: ['my_agent.DEFAULT'], + evaluatorIds: ['eval-456'], + }; + + const result = toOnlineEvalConfigSpec(detail, 'QualityMonitor', 'my_agent', ['my_evaluator']); + + expect(result.name).toBe('QualityMonitor'); + expect(result.agent).toBe('my_agent'); + expect(result.evaluators).toEqual(['my_evaluator']); + expect(result.samplingRate).toBe(50); + expect(result.description).toBe('Monitor agent quality'); + expect(result.enableOnCreate).toBe(true); + }); + + it('omits enableOnCreate when execution status is DISABLED', () => { + const detail: GetOnlineEvalConfigResult = { + configId: 'oec-456', + configArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/oec-456', + configName: 'DisabledConfig', + status: 'ACTIVE', + executionStatus: 'DISABLED', + samplingPercentage: 10, + serviceNames: ['agent.DEFAULT'], + evaluatorIds: ['eval-1'], + }; + + const result = toOnlineEvalConfigSpec(detail, 'DisabledConfig', 'agent', ['eval_one']); + + expect(result.enableOnCreate).toBeUndefined(); + }); + + it('omits description when not present', () => { + const detail: GetOnlineEvalConfigResult = { + configId: 'oec-789', + configArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/oec-789', + configName: 'NoDesc', + status: 'ACTIVE', + executionStatus: 'ENABLED', + samplingPercentage: 25, + serviceNames: ['agent.DEFAULT'], + evaluatorIds: ['eval-1'], + }; + + const result = toOnlineEvalConfigSpec(detail, 'NoDesc', 'agent', ['eval_one']); + + expect(result.description).toBeUndefined(); + }); + + it('throws when sampling percentage is missing', () => { + const detail: GetOnlineEvalConfigResult = { + configId: 'oec-no-sampling', + configArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/oec-no-sampling', + configName: 'NoSampling', + status: 'ACTIVE', + executionStatus: 'ENABLED', + serviceNames: ['agent.DEFAULT'], + evaluatorIds: ['eval-1'], + }; + + expect(() => toOnlineEvalConfigSpec(detail, 'NoSampling', 'agent', ['eval_one'])).toThrow( + 'has no sampling configuration' + ); + }); + + it('supports multiple evaluator references', () => { + const detail: GetOnlineEvalConfigResult = { + configId: 'oec-multi', + configArn: 'arn:aws:bedrock-agentcore:us-west-2:123456789012:online-evaluation-config/oec-multi', + configName: 'MultiEval', + status: 'ACTIVE', + executionStatus: 'ENABLED', + samplingPercentage: 75, + serviceNames: ['agent.DEFAULT'], + evaluatorIds: ['eval-1', 'eval-2'], + }; + + const result = toOnlineEvalConfigSpec(detail, 'MultiEval', 'agent', [ + 'local_eval', + 'arn:aws:bedrock-agentcore:us-west-2:123456789012:evaluator/eval-2', + ]); + + expect(result.evaluators).toHaveLength(2); + expect(result.evaluators[0]).toBe('local_eval'); + expect(result.evaluators[1]).toMatch(/^arn:/); + }); +}); + +// ============================================================================ +// Template Logical ID Lookup Tests for Online Eval Configs +// ============================================================================ + +describe('Template Logical ID Lookup for Online Eval Configs', () => { + const synthTemplate: CfnTemplate = { + Resources: { + MyOnlineEvalConfig: { + Type: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + Properties: { + OnlineEvaluationConfigName: 'QualityMonitor', + }, + }, + PrefixedOnlineEvalConfig: { + Type: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + Properties: { + OnlineEvaluationConfigName: 'TestProject_PrefixedConfig', + }, + }, + MyAgentRuntime: { + Type: 'AWS::BedrockAgentCore::Runtime', + Properties: { + AgentRuntimeName: 'TestProject_my_agent', + }, + }, + MyIAMRole: { + Type: 'AWS::IAM::Role', + Properties: { + RoleName: 'MyRole', + }, + }, + }, + }; + + it('finds online eval config logical ID by OnlineEvaluationConfigName property', () => { + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + 'QualityMonitor' + ); + expect(logicalId).toBe('MyOnlineEvalConfig'); + }); + + it('finds prefixed online eval config by full name', () => { + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + 'TestProject_PrefixedConfig' + ); + expect(logicalId).toBe('PrefixedOnlineEvalConfig'); + }); + + it('finds all online eval config logical IDs by type', () => { + const logicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::OnlineEvaluationConfig'); + expect(logicalIds).toHaveLength(2); + expect(logicalIds).toContain('MyOnlineEvalConfig'); + expect(logicalIds).toContain('PrefixedOnlineEvalConfig'); + }); + + it('returns undefined for non-existent config name', () => { + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + 'nonexistent_config' + ); + expect(logicalId).toBeUndefined(); + }); + + it('falls back to single online eval config logical ID when name does not match', () => { + const singleConfigTemplate: CfnTemplate = { + Resources: { + OnlyConfig: { + Type: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + Properties: { + OnlineEvaluationConfigName: 'some_config', + }, + }, + }, + }; + + let logicalId = findLogicalIdByProperty( + singleConfigTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + 'different_name' + ); + + // Primary lookup fails + expect(logicalId).toBeUndefined(); + + // Fallback: if there's only one config resource, use it + if (!logicalId) { + const configLogicalIds = findLogicalIdsByType( + singleConfigTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig' + ); + if (configLogicalIds.length === 1) { + logicalId = configLogicalIds[0]; + } + } + expect(logicalId).toBe('OnlyConfig'); + }); +}); + +// ============================================================================ +// Phase 2 Resource Import List Construction for Online Eval Configs +// ============================================================================ + +describe('Phase 2: ResourceToImport List Construction for Online Eval Configs', () => { + const synthTemplate: CfnTemplate = { + Resources: { + OnlineEvalLogicalId: { + Type: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + Properties: { + OnlineEvaluationConfigName: 'QualityMonitor', + }, + }, + IAMRoleLogicalId: { + Type: 'AWS::IAM::Role', + Properties: {}, + }, + }, + }; + + it('builds ResourceToImport list for online eval config', () => { + const configName = 'QualityMonitor'; + const configId = 'oec-123'; + + const resourcesToImport: ResourceToImport[] = []; + + const logicalId = findLogicalIdByProperty( + synthTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + configName + ); + + if (logicalId) { + resourcesToImport.push({ + resourceType: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + logicalResourceId: logicalId, + resourceIdentifier: { OnlineEvaluationConfigId: configId }, + }); + } + + expect(resourcesToImport).toHaveLength(1); + expect(resourcesToImport[0]!.resourceType).toBe('AWS::BedrockAgentCore::OnlineEvaluationConfig'); + expect(resourcesToImport[0]!.logicalResourceId).toBe('OnlineEvalLogicalId'); + expect(resourcesToImport[0]!.resourceIdentifier).toEqual({ OnlineEvaluationConfigId: 'oec-123' }); + }); + + it('returns empty list when online eval config not found in template', () => { + const emptyTemplate: CfnTemplate = { + Resources: { + IAMRoleLogicalId: { + Type: 'AWS::IAM::Role', + Properties: {}, + }, + }, + }; + + const logicalId = findLogicalIdByProperty( + emptyTemplate, + 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + 'OnlineEvaluationConfigName', + 'QualityMonitor' + ); + + expect(logicalId).toBeUndefined(); + }); +}); + +// ============================================================================ +// buildImportTemplate Tests for Online Eval Config Resources +// ============================================================================ + +describe('buildImportTemplate with Online Eval Config', () => { + it('adds online eval config resource to deployed template with Retain deletion policy', () => { + const deployedTemplate: CfnTemplate = { + Resources: { + ExistingIAMRole: { + Type: 'AWS::IAM::Role', + Properties: { RoleName: 'ExistingRole' }, + }, + }, + }; + + const synthTemplate: CfnTemplate = { + Resources: { + ExistingIAMRole: { + Type: 'AWS::IAM::Role', + Properties: { RoleName: 'ExistingRole' }, + }, + OnlineEvalLogicalId: { + Type: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + Properties: { + OnlineEvaluationConfigName: 'QualityMonitor', + }, + DependsOn: 'ExistingIAMRole', + }, + }, + }; + + const importTemplate = buildImportTemplate(deployedTemplate, synthTemplate, ['OnlineEvalLogicalId']); + + // Verify online eval config resource was added + expect(importTemplate.Resources.OnlineEvalLogicalId).toBeDefined(); + expect(importTemplate.Resources.OnlineEvalLogicalId!.Type).toBe('AWS::BedrockAgentCore::OnlineEvaluationConfig'); + expect(importTemplate.Resources.OnlineEvalLogicalId!.DeletionPolicy).toBe('Retain'); + expect(importTemplate.Resources.OnlineEvalLogicalId!.UpdateReplacePolicy).toBe('Retain'); + + // DependsOn should be removed for import + expect(importTemplate.Resources.OnlineEvalLogicalId!.DependsOn).toBeUndefined(); + + // Original resource should still be there + expect(importTemplate.Resources.ExistingIAMRole).toBeDefined(); + }); +}); diff --git a/src/cli/commands/import/command.ts b/src/cli/commands/import/command.ts index 4b043e69..3fd4f745 100644 --- a/src/cli/commands/import/command.ts +++ b/src/cli/commands/import/command.ts @@ -1,14 +1,13 @@ import { handleImport } from './actions'; +import { ANSI } from './constants'; +import { registerImportEvaluator } from './import-evaluator'; import { registerImportMemory } from './import-memory'; +import { registerImportOnlineEval } from './import-online-eval'; import { registerImportRuntime } from './import-runtime'; import type { Command } from '@commander-js/extra-typings'; import * as fs from 'node:fs'; -const green = '\x1b[32m'; -const yellow = '\x1b[33m'; -const cyan = '\x1b[36m'; -const dim = '\x1b[2m'; -const reset = '\x1b[0m'; +const { green, yellow, cyan, dim, reset } = ANSI; export const registerImport = (program: Command) => { const importCmd = program @@ -151,4 +150,6 @@ export const registerImport = (program: Command) => { // Register subcommands for importing individual resource types from AWS registerImportRuntime(importCmd); registerImportMemory(importCmd); + registerImportEvaluator(importCmd); + registerImportOnlineEval(importCmd); }; diff --git a/src/cli/commands/import/constants.ts b/src/cli/commands/import/constants.ts index 78071933..93c25f90 100644 --- a/src/cli/commands/import/constants.ts +++ b/src/cli/commands/import/constants.ts @@ -1,3 +1,16 @@ +/** Name validation regex used by all import handlers. */ +export const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/; + +/** ANSI escape codes for console output. */ +export const ANSI = { + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + cyan: '\x1b[36m', + dim: '\x1b[2m', + reset: '\x1b[0m', +} as const; + /** * CloudFormation resource type to identifier key mapping for IMPORT. */ @@ -5,6 +18,8 @@ export const CFN_RESOURCE_IDENTIFIERS: Record = { 'AWS::BedrockAgentCore::Runtime': ['AgentRuntimeId'], 'AWS::BedrockAgentCore::Memory': ['MemoryId'], 'AWS::BedrockAgentCore::Gateway': ['GatewayIdentifier'], + 'AWS::BedrockAgentCore::Evaluator': ['EvaluatorId'], + 'AWS::BedrockAgentCore::OnlineEvaluationConfig': ['OnlineEvaluationConfigId'], }; /** diff --git a/src/cli/commands/import/import-evaluator.ts b/src/cli/commands/import/import-evaluator.ts new file mode 100644 index 00000000..be85829f --- /dev/null +++ b/src/cli/commands/import/import-evaluator.ts @@ -0,0 +1,153 @@ +import type { Evaluator } from '../../../schema'; +import type { EvaluatorSummary, GetEvaluatorResult } from '../../aws/agentcore-control'; +import { + getEvaluator, + getOnlineEvaluationConfig, + listAllEvaluators, + listAllOnlineEvaluationConfigs, +} from '../../aws/agentcore-control'; +import { ANSI } from './constants'; +import { failResult, parseAndValidateArn } from './import-utils'; +import { executeResourceImport } from './resource-import'; +import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; +import type { Command } from '@commander-js/extra-typings'; + +/** + * Map an AWS GetEvaluator response to the CLI Evaluator spec format. + */ +export function toEvaluatorSpec(detail: GetEvaluatorResult, localName: string): Evaluator { + const level = detail.level || 'SESSION'; + + let config: Evaluator['config']; + + if (detail.evaluatorConfig?.llmAsAJudge) { + const llm = detail.evaluatorConfig.llmAsAJudge; + config = { + llmAsAJudge: { + model: llm.model, + instructions: llm.instructions, + ratingScale: llm.ratingScale, + }, + }; + } else if (detail.evaluatorConfig?.codeBased) { + config = { + codeBased: { + external: { + lambdaArn: detail.evaluatorConfig.codeBased.lambdaArn, + }, + }, + }; + } else { + throw new Error( + `Evaluator "${detail.evaluatorName}" has no recognizable config. ` + + 'Only LLM-as-a-Judge and code-based evaluators can be imported.' + ); + } + + return { + name: localName, + level, + ...(detail.description && { description: detail.description }), + config, + ...(detail.tags && Object.keys(detail.tags).length > 0 && { tags: detail.tags }), + }; +} + +const evaluatorDescriptor: ResourceImportDescriptor = { + resourceType: 'evaluator', + displayName: 'evaluator', + logCommand: 'import-evaluator', + + listResources: region => listAllEvaluators({ region }), + getDetail: (region, id) => getEvaluator({ region, evaluatorId: id }), + parseResourceId: (arn, target) => parseAndValidateArn(arn, 'evaluator', target).resourceId, + + extractSummaryId: s => s.evaluatorId, + formatListItem: (s, i) => + ` ${ANSI.dim}[${i + 1}]${ANSI.reset} ${s.evaluatorName} — ${s.status}\n ${ANSI.dim}${s.evaluatorArn}${ANSI.reset}`, + formatAutoSelectMessage: s => `Found 1 evaluator: ${s.evaluatorName} (${s.evaluatorId}). Auto-selecting.`, + + extractDetailName: d => d.evaluatorName, + extractDetailArn: d => d.evaluatorArn, + readyStatus: 'ACTIVE', + extractDetailStatus: d => d.status, + + getExistingNames: spec => (spec.evaluators ?? []).map(e => e.name), + addToProjectSpec: (detail, localName, spec) => { + (spec.evaluators ??= []).push(toEvaluatorSpec(detail, localName)); + }, + + cfnResourceType: 'AWS::BedrockAgentCore::Evaluator', + cfnNameProperty: 'EvaluatorName', + cfnIdentifierKey: 'EvaluatorId', + + buildDeployedStateEntry: (name, id, d) => ({ type: 'evaluator', name, id, arn: d.evaluatorArn }), + + beforeConfigWrite: async ({ detail, localName, target, onProgress, logger }) => { + // Check if any online eval config references this evaluator. + // CFN IMPORT of locked evaluators always fails because CFN triggers a + // post-import TagResource call that the resource handler rejects. + logger.startStep('Check for online eval config references'); + onProgress('Checking if evaluator is referenced by an online eval config...'); + + const oecSummaries = await listAllOnlineEvaluationConfigs({ region: target.region }); + if (oecSummaries.length > 0) { + const oecDetails = await Promise.all( + oecSummaries.map(s => + getOnlineEvaluationConfig({ region: target.region, configId: s.onlineEvaluationConfigId }) + ) + ); + + const referencingOec = oecDetails.find(oec => oec.evaluatorIds?.includes(detail.evaluatorId)); + + if (referencingOec) { + return failResult( + logger, + `Evaluator "${localName}" is referenced by online eval config "${referencingOec.configName}" and cannot be imported directly (locked by CloudFormation).\n` + + `To import this evaluator along with its online eval config, run:\n` + + ` agentcore import online-eval --arn ${referencingOec.configArn}`, + 'evaluator', + localName + ); + } + } + + logger.endStep('success'); + }, +}; + +/** + * Handle `agentcore import evaluator`. + */ +export async function handleImportEvaluator(options: ImportResourceOptions): Promise { + return executeResourceImport(evaluatorDescriptor, options); +} + +/** + * Register the `import evaluator` subcommand. + */ +export function registerImportEvaluator(importCmd: Command): void { + importCmd + .command('evaluator') + .description('Import an existing AgentCore Evaluator from your AWS account') + .option('--arn ', 'Evaluator ARN to import') + .option('--name ', 'Local name for the imported evaluator') + .option('-y, --yes', 'Auto-confirm prompts') + .action(async (cliOptions: ImportResourceOptions) => { + const result = await handleImportEvaluator(cliOptions); + + if (result.success) { + console.log(''); + console.log(`${ANSI.green}Evaluator imported successfully!${ANSI.reset}`); + console.log(` Name: ${result.resourceName}`); + console.log(` ID: ${result.resourceId}`); + 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-memory.ts b/src/cli/commands/import/import-memory.ts index 3ad55712..81740b72 100644 --- a/src/cli/commands/import/import-memory.ts +++ b/src/cli/commands/import/import-memory.ts @@ -1,22 +1,12 @@ -import type { AgentCoreProjectSpec, Memory } from '../../../schema'; -import type { MemoryDetail } from '../../aws/agentcore-control'; +import type { Memory } from '../../../schema'; +import type { MemoryDetail, MemorySummary } from '../../aws/agentcore-control'; import { getMemoryDetail, listAllMemories } from '../../aws/agentcore-control'; -import { executeCdkImportPipeline } from './import-pipeline'; -import { - failResult, - findResourceInDeployedState, - parseAndValidateArn, - resolveImportContext, - toStackName, -} from './import-utils'; -import { findLogicalIdByProperty, findLogicalIdsByType } from './template-utils'; -import type { ImportResourceOptions, ImportResourceResult } from './types'; +import { ANSI } from './constants'; +import { parseAndValidateArn } from './import-utils'; +import { executeResourceImport } from './resource-import'; +import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; import type { Command } from '@commander-js/extra-typings'; -const green = '\x1b[32m'; -const dim = '\x1b[2m'; -const reset = '\x1b[0m'; - /** * Map strategy type from AWS API format to CLI schema format. * The API returns types like "SEMANTIC_OVERRIDE", "SUMMARY_OVERRIDE", etc. @@ -73,213 +63,42 @@ function toMemorySpec(memory: MemoryDetail, localName: string): Memory { }; } -/** - * Handle `agentcore import memory`. - */ -export async function handleImportMemory(options: ImportResourceOptions): Promise { - // Rollback state - 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-memory'); - const { ctx, target, logger, onProgress } = importCtx; - - // 3. Get memory details from AWS - logger.startStep('Fetch memory from AWS'); - let memoryId: string; - - if (options.arn) { - const parsed = parseAndValidateArn(options.arn, 'memory', target); - memoryId = parsed.resourceId; - } else { - onProgress('Listing memories in your account...'); - const memories = await listAllMemories({ region: target.region }); - - if (memories.length === 0) { - return failResult(logger, 'No memories found in your account.', 'memory', ''); - } - - if (memories.length === 1) { - memoryId = memories[0]!.memoryId; - onProgress(`Found 1 memory: ${memoryId}. Auto-selecting.`); - } else { - console.log(`\nFound ${memories.length} ${memories.length === 1 ? 'memory' : 'memories'}:\n`); - for (let i = 0; i < memories.length; i++) { - const m = memories[i]!; - console.log(` ${dim}[${i + 1}]${reset} ${m.memoryId} — ${m.status}`); - console.log(` ${dim}${m.memoryArn}${reset}`); - } - console.log(''); - - return failResult( - logger, - 'Multiple memories found. Use --arn to specify which memory to import.', - 'memory', - '' - ); - } - } +const memoryDescriptor: ResourceImportDescriptor = { + resourceType: 'memory', + displayName: 'memory', + logCommand: 'import-memory', - onProgress(`Fetching memory details for ${memoryId}...`); - const memoryDetail = await getMemoryDetail({ region: target.region, memoryId }); + listResources: region => listAllMemories({ region }), + getDetail: (region, id) => getMemoryDetail({ region, memoryId: id }), + parseResourceId: (arn, target) => parseAndValidateArn(arn, 'memory', target).resourceId, - if (memoryDetail.status !== 'ACTIVE') { - onProgress(`Warning: Memory status is ${memoryDetail.status}, not ACTIVE`); - } + extractSummaryId: s => s.memoryId, + formatListItem: (s, i) => + ` ${ANSI.dim}[${i + 1}]${ANSI.reset} ${s.memoryId} — ${s.status}\n ${ANSI.dim}${s.memoryArn}${ANSI.reset}`, + formatAutoSelectMessage: s => `Found 1 memory: ${s.memoryId}. Auto-selecting.`, - const localName = options.name ?? memoryDetail.name; - const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/; - 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).`, - 'memory', - localName - ); - } - onProgress(`Memory: ${memoryDetail.name} → local name: ${localName}`); - logger.endStep('success'); + extractDetailName: d => d.name, + extractDetailArn: d => d.memoryArn, + readyStatus: 'ACTIVE', + extractDetailStatus: d => d.status, - // 4. Check for duplicates - logger.startStep('Check for duplicates'); - const projectSpec = await ctx.configIO.readProjectSpec(); - const existingNames = new Set((projectSpec.memories ?? []).map(m => m.name)); - if (existingNames.has(localName)) { - return failResult( - logger, - `Memory "${localName}" already exists in the project. Use --name to specify a different local name.`, - 'memory', - localName - ); - } - const targetName = target.name ?? 'default'; - const existingResource = await findResourceInDeployedState(ctx.configIO, targetName, 'memory', memoryId); - if (existingResource) { - return failResult( - logger, - `Memory "${memoryId}" is already imported in this project as "${existingResource}". Remove it first before re-importing.`, - 'memory', - localName - ); - } - logger.endStep('success'); + getExistingNames: spec => (spec.memories ?? []).map(m => m.name), + addToProjectSpec: (detail, localName, spec) => { + (spec.memories ??= []).push(toMemorySpec(detail, localName)); + }, - // 5. Add to project config - logger.startStep('Update project config'); - configSnapshot = JSON.parse(JSON.stringify(projectSpec)) as AgentCoreProjectSpec; - const memorySpec = toMemorySpec(memoryDetail, localName); - (projectSpec.memories ??= []).push(memorySpec); - await ctx.configIO.writeProjectSpec(projectSpec); - configWritten = true; - onProgress(`Added memory "${localName}" to agentcore.json`); - logger.endStep('success'); + cfnResourceType: 'AWS::BedrockAgentCore::Memory', + cfnNameProperty: 'Name', + cfnIdentifierKey: 'MemoryId', - // 6-10. CDK build → synth → bootstrap → phase 1 → phase 2 → update state - logger.startStep('Build and synth CDK'); - const stackName = toStackName(ctx.projectName, targetName); + buildDeployedStateEntry: (name, id, d) => ({ type: 'memory', name, id, arn: d.memoryArn }), +}; - const pipelineResult = await executeCdkImportPipeline({ - projectRoot: ctx.projectRoot, - stackName, - target, - configIO: ctx.configIO, - targetName, - onProgress, - buildResourcesToImport: synthTemplate => { - let logicalId = findLogicalIdByProperty(synthTemplate, 'AWS::BedrockAgentCore::Memory', 'Name', localName); - - // CDK prefixes memory names with the project name - if (!logicalId) { - const prefixedName = `${ctx.projectName}_${localName}`; - logicalId = findLogicalIdByProperty(synthTemplate, 'AWS::BedrockAgentCore::Memory', 'Name', prefixedName); - } - - if (!logicalId) { - const memoryLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Memory'); - if (memoryLogicalIds.length === 1) { - logicalId = memoryLogicalIds[0]; - } - } - - if (!logicalId) { - return []; - } - - return [ - { - resourceType: 'AWS::BedrockAgentCore::Memory', - logicalResourceId: logicalId, - resourceIdentifier: { MemoryId: memoryId }, - }, - ]; - }, - deployedStateEntries: [ - { - type: 'memory', - name: localName, - id: memoryId, - arn: memoryDetail.memoryArn, - }, - ], - }); - - if (pipelineResult.noResources) { - const error = `Could not find logical ID for memory "${localName}" in CloudFormation template`; - await rollback(); - return failResult(logger, error, 'memory', localName); - } - - if (!pipelineResult.success) { - await rollback(); - logger.endStep('error', pipelineResult.error); - logger.finalize(false); - return { - success: false, - error: pipelineResult.error, - resourceType: 'memory', - resourceName: localName, - logPath: logger.getRelativeLogPath(), - }; - } - logger.endStep('success'); - - logger.finalize(true); - return { - success: true, - resourceType: 'memory', - resourceName: localName, - resourceId: memoryId, - 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: 'memory', - resourceName: options.name ?? '', - logPath: importCtx?.logger.getRelativeLogPath(), - }; - } +/** + * Handle `agentcore import memory`. + */ +export async function handleImportMemory(options: ImportResourceOptions): Promise { + return executeResourceImport(memoryDescriptor, options); } /** @@ -297,12 +116,12 @@ export function registerImportMemory(importCmd: Command): void { if (result.success) { console.log(''); - console.log(`${green}Memory imported successfully!${reset}`); + console.log(`${ANSI.green}Memory imported successfully!${ANSI.reset}`); console.log(` Name: ${result.resourceName}`); console.log(` ID: ${result.resourceId}`); console.log(''); } else { - console.error(`\n\x1b[31m[error]${reset} ${result.error}`); + console.error(`\n${ANSI.red}[error]${ANSI.reset} ${result.error}`); if (result.logPath) { console.error(`Log: ${result.logPath}`); } diff --git a/src/cli/commands/import/import-online-eval.ts b/src/cli/commands/import/import-online-eval.ts new file mode 100644 index 00000000..298ea45f --- /dev/null +++ b/src/cli/commands/import/import-online-eval.ts @@ -0,0 +1,218 @@ +import type { OnlineEvalConfig } from '../../../schema'; +import type { GetOnlineEvalConfigResult, OnlineEvalConfigSummary } from '../../aws/agentcore-control'; +import { + getOnlineEvaluationConfig, + listAllAgentRuntimes, + listAllOnlineEvaluationConfigs, +} from '../../aws/agentcore-control'; +import { ANSI } from './constants'; +import { failResult, findResourceInDeployedState, parseAndValidateArn } from './import-utils'; +import { executeResourceImport } from './resource-import'; +import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; +import type { Command } from '@commander-js/extra-typings'; + +/** + * Derive the agent name from the online eval config's service names. + * Service names follow the pattern: "{agentName}.DEFAULT" + */ +export function extractAgentName(serviceNames: string[]): string | undefined { + if (serviceNames.length === 0) return undefined; + const serviceName = serviceNames[0]!; + const dotIndex = serviceName.lastIndexOf('.'); + if (dotIndex === -1) return serviceName; + return serviceName.slice(0, dotIndex); +} + +/** + * Map an AWS GetOnlineEvaluationConfig response to the CLI OnlineEvalConfig spec format. + */ +export function toOnlineEvalConfigSpec( + detail: GetOnlineEvalConfigResult, + localName: string, + agentName: string, + evaluatorArns: string[] +): OnlineEvalConfig { + if (detail.samplingPercentage == null) { + throw new Error(`Online eval config "${detail.configName}" has no sampling configuration. Cannot import.`); + } + + return { + name: localName, + agent: agentName, + evaluators: evaluatorArns, + samplingRate: detail.samplingPercentage, + ...(detail.description && { description: detail.description }), + ...(detail.executionStatus === 'ENABLED' && { enableOnCreate: true }), + }; +} + +/** + * Build evaluator ARNs from evaluator IDs. + * Online eval configs reference evaluators by ARN rather than importing them, + * since evaluators locked by an online eval config cannot be CFN-imported. + */ +function buildEvaluatorArns(evaluatorIds: string[], region: string, account: string): string[] { + return evaluatorIds.map(id => `arn:aws:bedrock-agentcore:${region}:${account}:evaluator/${id}`); +} + +/** + * Create an online-eval descriptor with closed-over state for reference resolution. + */ +function createOnlineEvalDescriptor(): ResourceImportDescriptor { + // Set by beforeConfigWrite, read by addToProjectSpec. Ordering guaranteed by executeResourceImport. + let resolvedAgentName = ''; + let resolvedEvaluatorArns: string[] = []; + + return { + resourceType: 'online-eval', + displayName: 'online eval config', + logCommand: 'import-online-eval', + + listResources: region => listAllOnlineEvaluationConfigs({ region }), + getDetail: (region, id) => getOnlineEvaluationConfig({ region, configId: id }), + parseResourceId: (arn, target) => parseAndValidateArn(arn, 'online-eval', target).resourceId, + + extractSummaryId: s => s.onlineEvaluationConfigId, + formatListItem: (s, i) => + ` ${ANSI.dim}[${i + 1}]${ANSI.reset} ${s.onlineEvaluationConfigName} — ${s.status} (${s.executionStatus})\n ${ANSI.dim}${s.onlineEvaluationConfigArn}${ANSI.reset}`, + formatAutoSelectMessage: s => + `Found 1 config: ${s.onlineEvaluationConfigName} (${s.onlineEvaluationConfigId}). Auto-selecting.`, + + extractDetailName: d => d.configName, + extractDetailArn: d => d.configArn, + readyStatus: 'ACTIVE', + extractDetailStatus: d => d.status, + + getExistingNames: spec => (spec.onlineEvalConfigs ?? []).map(c => c.name), + addToProjectSpec: (detail, localName, spec) => { + (spec.onlineEvalConfigs ??= []).push( + toOnlineEvalConfigSpec(detail, localName, resolvedAgentName, resolvedEvaluatorArns) + ); + }, + + cfnResourceType: 'AWS::BedrockAgentCore::OnlineEvaluationConfig', + cfnNameProperty: 'OnlineEvaluationConfigName', + cfnIdentifierKey: 'OnlineEvaluationConfigId', + + buildDeployedStateEntry: (name, id, d) => ({ type: 'online-eval', name, id, arn: d.configArn }), + + beforeConfigWrite: async ({ detail, localName, projectSpec, ctx, target, onProgress, logger }) => { + logger.startStep('Resolve references'); + + // Extract agent name from service names + const awsAgentName = extractAgentName(detail.serviceNames ?? []); + if (!awsAgentName) { + return failResult( + logger, + 'Could not determine agent name from online eval config. The config has no data source service names.', + 'online-eval', + localName + ); + } + + // Resolve the local agent name. The AWS name from the OEC service names + // may differ from the local name if the runtime was imported with --name, + // or it may include the CDK project prefix ("{projectName}_{agentName}"). + const agentNames = new Set((projectSpec.runtimes ?? []).map(r => r.name)); + let agentName: string | undefined; + + if (agentNames.has(awsAgentName)) { + // Direct match — local name equals AWS name + agentName = awsAgentName; + } else { + // Strip CDK project prefix if present (service names use "{projectName}_{agentName}") + const prefix = `${ctx.projectName}_`; + if (awsAgentName.startsWith(prefix)) { + const stripped = awsAgentName.slice(prefix.length); + if (agentNames.has(stripped)) { + agentName = stripped; + } + } + } + + if (!agentName) { + // Look up the AWS runtime ID for the AWS name, then find the local name + // that maps to it in deployed state. + onProgress(`Agent "${awsAgentName}" not found by name, checking deployed state...`); + const runtimes = await listAllAgentRuntimes({ region: target.region }); + const matchingRuntime = runtimes.find(r => r.agentRuntimeName === awsAgentName); + + if (matchingRuntime) { + const targetName = target.name ?? 'default'; + const localMatch = await findResourceInDeployedState( + ctx.configIO, + targetName, + 'runtime', + matchingRuntime.agentRuntimeId + ); + if (localMatch && agentNames.has(localMatch)) { + agentName = localMatch; + onProgress(`Resolved AWS runtime "${awsAgentName}" to local name "${agentName}"`); + } + } + } + + if (!agentName) { + return failResult( + logger, + `Online eval config references agent "${awsAgentName}" which is not in this project. ` + + `Import or add the agent first with \`agentcore import runtime\` or \`agentcore add agent\`.`, + 'online-eval', + localName + ); + } + + // Resolve evaluator IDs to ARNs + const evaluatorIds = detail.evaluatorIds ?? []; + if (evaluatorIds.length === 0) { + return failResult( + logger, + 'Online eval config has no evaluators configured. Cannot import.', + 'online-eval', + localName + ); + } + + resolvedEvaluatorArns = buildEvaluatorArns(evaluatorIds, target.region, target.account); + resolvedAgentName = agentName; + onProgress(`Agent: ${agentName}, Evaluators: ${resolvedEvaluatorArns.join(', ')}`); + logger.endStep('success'); + }, + }; +} + +/** + * Handle `agentcore import online-eval`. + */ +export async function handleImportOnlineEval(options: ImportResourceOptions): Promise { + return executeResourceImport(createOnlineEvalDescriptor(), options); +} + +/** + * Register the `import online-eval` subcommand. + */ +export function registerImportOnlineEval(importCmd: Command): void { + importCmd + .command('online-eval') + .description('Import an existing AgentCore Online Evaluation Config from your AWS account') + .option('--arn ', 'Online evaluation config ARN to import') + .option('--name ', 'Local name for the imported online eval config') + .option('-y, --yes', 'Auto-confirm prompts') + .action(async (cliOptions: ImportResourceOptions) => { + const result = await handleImportOnlineEval(cliOptions); + + if (result.success) { + console.log(''); + console.log(`${ANSI.green}Online eval config imported successfully!${ANSI.reset}`); + console.log(` Name: ${result.resourceName}`); + console.log(` ID: ${result.resourceId}`); + 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-runtime.ts b/src/cli/commands/import/import-runtime.ts index 393ba53a..b1921d54 100644 --- a/src/cli/commands/import/import-runtime.ts +++ b/src/cli/commands/import/import-runtime.ts @@ -1,25 +1,14 @@ -import type { AgentCoreProjectSpec, AgentEnvSpec } from '../../../schema'; -import type { AgentRuntimeDetail } from '../../aws/agentcore-control'; +import type { AgentEnvSpec } from '../../../schema'; +import type { AgentRuntimeDetail, AgentRuntimeSummary } from '../../aws/agentcore-control'; import { getAgentRuntimeDetail, listAllAgentRuntimes } from '../../aws/agentcore-control'; -import { executeCdkImportPipeline } from './import-pipeline'; -import { - copyAgentSource, - failResult, - findResourceInDeployedState, - parseAndValidateArn, - resolveImportContext, - toStackName, -} from './import-utils'; -import { findLogicalIdByProperty, findLogicalIdsByType } from './template-utils'; -import type { ImportResourceOptions, ImportResourceResult } from './types'; +import { ANSI } from './constants'; +import { copyAgentSource, failResult, parseAndValidateArn } from './import-utils'; +import { executeResourceImport } from './resource-import'; +import type { ImportResourceResult, ResourceImportDescriptor, RuntimeImportOptions } from './types'; import type { Command } from '@commander-js/extra-typings'; import * as fs from 'node:fs'; import * as path from 'node:path'; -const green = '\x1b[32m'; -const dim = '\x1b[2m'; -const reset = '\x1b[0m'; - /** * Extract the actual entrypoint file from the runtime's entryPoint array. * The array may contain wrapper commands like "opentelemetry-instrument" @@ -91,282 +80,122 @@ function toAgentEnvSpec( } /** - * Handle `agentcore import runtime`. + * Create a runtime descriptor with closed-over state for entrypoint, code location, and rollback. */ -export async function handleImportRuntime(options: ImportResourceOptions): Promise { - // Rollback state - let configSnapshot: AgentCoreProjectSpec | undefined; - let configWritten = false; +function createRuntimeDescriptor( + options: RuntimeImportOptions +): ResourceImportDescriptor { + let resolvedEntrypoint = ''; + let resolvedCodeLocation = ''; let copiedAppDir: string | undefined; - 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)}`); - } - } - if (copiedAppDir && fs.existsSync(copiedAppDir)) { - try { - fs.rmSync(copiedAppDir, { recursive: true, force: true }); - } catch (err) { - console.warn( - `Warning: Could not clean up ${copiedAppDir}: ${err instanceof Error ? err.message : String(err)}` + return { + resourceType: 'runtime', + displayName: 'runtime', + logCommand: 'import-runtime', + + listResources: region => listAllAgentRuntimes({ region }), + getDetail: (region, id) => getAgentRuntimeDetail({ region, runtimeId: id }), + parseResourceId: (arn, target) => parseAndValidateArn(arn, 'runtime', target).resourceId, + + extractSummaryId: s => s.agentRuntimeId, + formatListItem: (s, i) => + ` ${ANSI.dim}[${i + 1}]${ANSI.reset} ${s.agentRuntimeName} — ${s.status}\n ${ANSI.dim}${s.agentRuntimeArn}${ANSI.reset}`, + formatAutoSelectMessage: s => `Found 1 runtime: ${s.agentRuntimeName} (${s.agentRuntimeId}). Auto-selecting.`, + + extractDetailName: d => d.agentRuntimeName, + extractDetailArn: d => d.agentRuntimeArn, + readyStatus: 'READY', + extractDetailStatus: d => d.status, + + getExistingNames: spec => spec.runtimes.map(r => r.name), + addToProjectSpec: (detail, localName, spec) => { + spec.runtimes.push(toAgentEnvSpec(detail, localName, resolvedCodeLocation, resolvedEntrypoint)); + }, + + cfnResourceType: 'AWS::BedrockAgentCore::Runtime', + cfnNameProperty: 'AgentRuntimeName', + cfnIdentifierKey: 'AgentRuntimeId', + + buildDeployedStateEntry: (name, id, d) => ({ type: 'runtime', name, id, arn: d.agentRuntimeArn }), + + beforeConfigWrite: async ({ detail, localName, ctx, onProgress, logger }) => { + // Resolve entrypoint + logger.startStep('Resolve entrypoint'); + const entrypoint = options.entrypoint ?? extractEntrypoint(detail.entryPoint); + if (!entrypoint) { + return failResult( + logger, + 'Could not determine entrypoint from runtime configuration.\n Please re-run with --entrypoint to specify it manually.', + 'runtime', + localName ); } - } - }; - - try { - // 1-2. Validate project context and resolve target - importCtx = await resolveImportContext(options, 'import-runtime'); - const { ctx, target, logger, onProgress } = importCtx; - - // 3. Get runtime details from AWS - logger.startStep('Fetch runtime from AWS'); - let runtimeId: string; - - if (options.arn) { - const parsed = parseAndValidateArn(options.arn, 'runtime', target); - runtimeId = parsed.resourceId; - } else { - // List runtimes and let user pick - onProgress('Listing runtimes in your account...'); - const runtimes = await listAllAgentRuntimes({ region: target.region }); - - if (runtimes.length === 0) { - return failResult(logger, 'No runtimes found in your account. Deploy a runtime first.', 'runtime', ''); - } - - if (runtimes.length === 1) { - runtimeId = runtimes[0]!.agentRuntimeId; - onProgress(`Found 1 runtime: ${runtimes[0]!.agentRuntimeName} (${runtimeId}). Auto-selecting.`); - } else { - console.log(`\nFound ${runtimes.length} runtime(s):\n`); - for (let i = 0; i < runtimes.length; i++) { - const r = runtimes[i]!; - console.log(` ${dim}[${i + 1}]${reset} ${r.agentRuntimeName} — ${r.status}`); - console.log(` ${dim}${r.agentRuntimeArn}${reset}`); - } - console.log(''); + onProgress(`Entrypoint: ${entrypoint}`); + logger.endStep('success'); + // Validate source path + logger.startStep('Validate source path'); + if (!options.code) { return failResult( logger, - 'Multiple runtimes found. Use --arn to specify which runtime to import.', + 'Source path is required for runtime import. Use --code to specify the agent source code directory.', 'runtime', - '' + localName ); } - } - - onProgress(`Fetching runtime details for ${runtimeId}...`); - const runtimeDetail = await getAgentRuntimeDetail({ region: target.region, runtimeId }); - - if (runtimeDetail.status !== 'READY') { - onProgress(`Warning: Runtime status is ${runtimeDetail.status}, not READY`); - } - - // Derive local name - let localName = options.name ?? runtimeDetail.agentRuntimeName; - const prefix = `${ctx.projectName}_`; - if (localName.startsWith(prefix)) { - localName = localName.slice(prefix.length); - } - const NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/; - 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).`, - 'runtime', - localName - ); - } - onProgress(`Runtime: ${runtimeDetail.agentRuntimeName} → local name: ${localName}`); - logger.endStep('success'); - - // 4. Resolve entrypoint - logger.startStep('Resolve entrypoint'); - const entrypoint = options.entrypoint ?? extractEntrypoint(runtimeDetail.entryPoint); - if (!entrypoint) { - return failResult( - logger, - 'Could not determine entrypoint from runtime configuration.\n Please re-run with --entrypoint to specify it manually.', - 'runtime', - localName - ); - } - onProgress(`Entrypoint: ${entrypoint}`); - logger.endStep('success'); - - // 5. Validate source path - logger.startStep('Validate source path'); - if (!options.code) { - return failResult( - logger, - 'Source path is required for runtime import. Use --code to specify the agent source code directory.', - 'runtime', - localName - ); - } - - const sourcePath = path.resolve(options.code); - if (!fs.existsSync(sourcePath)) { - return failResult(logger, `Source path does not exist: ${sourcePath}`, 'runtime', localName); - } - const entrypointPath = path.join(sourcePath, entrypoint); - if (!fs.existsSync(entrypointPath)) { - return failResult( - logger, - `Entrypoint file '${entrypoint}' not found in ${sourcePath}. Ensure --code points to the directory containing your entrypoint file.`, - 'runtime', - localName - ); - } - logger.endStep('success'); - - // 6. Check for duplicates - logger.startStep('Check for duplicates'); - const projectSpec = await ctx.configIO.readProjectSpec(); - const existingNames = new Set(projectSpec.runtimes.map(r => r.name)); - if (existingNames.has(localName)) { - return failResult( - logger, - `Runtime "${localName}" already exists in the project. Use --name to specify a different local name.`, - 'runtime', - localName - ); - } - const targetName = target.name ?? 'default'; - const existingResource = await findResourceInDeployedState(ctx.configIO, targetName, 'runtime', runtimeId); - if (existingResource) { - return failResult( - logger, - `Runtime "${runtimeId}" is already imported in this project as "${existingResource}". Remove it first before re-importing.`, - 'runtime', - localName - ); - } - logger.endStep('success'); - // 7. Copy source code - logger.startStep('Copy agent source'); - const codeLocation = `app/${localName}/`; - copiedAppDir = path.join(ctx.projectRoot, 'app', localName); - await copyAgentSource({ - sourcePath, - agentName: localName, - projectRoot: ctx.projectRoot, - build: runtimeDetail.build, - entrypoint, - onProgress, - }); - logger.endStep('success'); - - // 8. Add to project config - logger.startStep('Update project config'); - configSnapshot = JSON.parse(JSON.stringify(projectSpec)) as AgentCoreProjectSpec; - const agentSpec = toAgentEnvSpec(runtimeDetail, localName, codeLocation, entrypoint); - projectSpec.runtimes.push(agentSpec); - await ctx.configIO.writeProjectSpec(projectSpec); - configWritten = true; - onProgress(`Added runtime "${localName}" to agentcore.json`); - logger.endStep('success'); - - // 9-13. CDK build → synth → bootstrap → phase 1 → phase 2 → update state - logger.startStep('Build and synth CDK'); - const stackName = toStackName(ctx.projectName, targetName); - - const pipelineResult = await executeCdkImportPipeline({ - projectRoot: ctx.projectRoot, - stackName, - target, - configIO: ctx.configIO, - targetName, - onProgress, - buildResourcesToImport: synthTemplate => { - const expectedRuntimeName = `${ctx.projectName}_${localName}`; - let logicalId = findLogicalIdByProperty( - synthTemplate, - 'AWS::BedrockAgentCore::Runtime', - 'AgentRuntimeName', - expectedRuntimeName + const sourcePath = path.resolve(options.code); + if (!fs.existsSync(sourcePath)) { + return failResult(logger, `Source path does not exist: ${sourcePath}`, 'runtime', localName); + } + const entrypointPath = path.join(sourcePath, entrypoint); + if (!fs.existsSync(entrypointPath)) { + return failResult( + logger, + `Entrypoint file '${entrypoint}' not found in ${sourcePath}. Ensure --code points to the directory containing your entrypoint file.`, + 'runtime', + localName ); - - if (!logicalId) { - const runtimeLogicalIds = findLogicalIdsByType(synthTemplate, 'AWS::BedrockAgentCore::Runtime'); - if (runtimeLogicalIds.length === 1) { - logicalId = runtimeLogicalIds[0]; - } - } - - if (!logicalId) { - return []; + } + logger.endStep('success'); + + // Copy agent source + logger.startStep('Copy agent source'); + resolvedCodeLocation = `app/${localName}/`; + resolvedEntrypoint = entrypoint; + copiedAppDir = path.join(ctx.projectRoot, 'app', localName); + await copyAgentSource({ + sourcePath, + agentName: localName, + projectRoot: ctx.projectRoot, + build: detail.build, + entrypoint, + onProgress, + }); + logger.endStep('success'); + }, + + // eslint-disable-next-line @typescript-eslint/require-await + rollbackExtra: async () => { + if (copiedAppDir && fs.existsSync(copiedAppDir)) { + try { + fs.rmSync(copiedAppDir, { recursive: true, force: true }); + } catch (err) { + console.warn( + `Warning: Could not clean up ${copiedAppDir}: ${err instanceof Error ? err.message : String(err)}` + ); } + } + }, + }; +} - return [ - { - resourceType: 'AWS::BedrockAgentCore::Runtime', - logicalResourceId: logicalId, - resourceIdentifier: { AgentRuntimeId: runtimeId }, - }, - ]; - }, - deployedStateEntries: [ - { - type: 'runtime', - name: localName, - id: runtimeId, - arn: runtimeDetail.agentRuntimeArn, - }, - ], - }); - - if (pipelineResult.noResources) { - const error = `Could not find logical ID for runtime "${localName}" in CloudFormation template`; - await rollback(); - return failResult(logger, error, 'runtime', localName); - } - - if (!pipelineResult.success) { - await rollback(); - logger.endStep('error', pipelineResult.error); - logger.finalize(false); - return { - success: false, - error: pipelineResult.error, - resourceType: 'runtime', - resourceName: localName, - logPath: logger.getRelativeLogPath(), - }; - } - logger.endStep('success'); - - logger.finalize(true); - return { - success: true, - resourceType: 'runtime', - resourceName: localName, - resourceId: runtimeId, - 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: 'runtime', - resourceName: options.name ?? '', - logPath: importCtx?.logger.getRelativeLogPath(), - }; - } +/** + * Handle `agentcore import runtime`. + */ +export async function handleImportRuntime(options: RuntimeImportOptions): Promise { + return executeResourceImport(createRuntimeDescriptor(options), options); } /** @@ -381,22 +210,22 @@ export function registerImportRuntime(importCmd: Command): void { .option('--entrypoint ', 'Entrypoint file (auto-detected from runtime, e.g. main.py)') .option('--name ', 'Local name for the imported runtime') .option('-y, --yes', 'Auto-confirm prompts') - .action(async (cliOptions: ImportResourceOptions) => { + .action(async (cliOptions: RuntimeImportOptions) => { const result = await handleImportRuntime(cliOptions); if (result.success) { console.log(''); - console.log(`${green}Runtime imported successfully!${reset}`); + console.log(`${ANSI.green}Runtime imported successfully!${ANSI.reset}`); console.log(` Name: ${result.resourceName}`); console.log(` ID: ${result.resourceId}`); console.log(''); - console.log(`${dim}Next steps:${reset}`); - console.log(` agentcore deploy ${dim}Deploy the imported stack${reset}`); - console.log(` agentcore status ${dim}Verify resource status${reset}`); - console.log(` agentcore invoke ${dim}Test your agent${reset}`); + 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 invoke ${ANSI.dim}Test your agent${ANSI.reset}`); console.log(''); } else { - console.error(`\n\x1b[31m[error]${reset} ${result.error}`); + console.error(`\n${ANSI.red}[error]${ANSI.reset} ${result.error}`); if (result.logPath) { console.error(`Log: ${result.logPath}`); } diff --git a/src/cli/commands/import/import-utils.ts b/src/cli/commands/import/import-utils.ts index 0148163b..d224870e 100644 --- a/src/cli/commands/import/import-utils.ts +++ b/src/cli/commands/import/import-utils.ts @@ -4,7 +4,8 @@ import { detectAccount, validateAwsCredentials } from '../../aws/account'; import { ExecLogger } from '../../logging'; import { setupPythonProject } from '../../operations/python/setup'; import { getTemplatePath } from '../../templates/templateRoot'; -import type { ImportResourceOptions, ImportResourceResult } from './types'; +import { ANSI } from './constants'; +import type { ImportResourceOptions, ImportResourceResult, ImportableResourceType } from './types'; import * as fs from 'node:fs'; import * as path from 'node:path'; @@ -12,8 +13,7 @@ import * as path from 'node:path'; // Import Context (shared setup for import-runtime / import-memory) // ============================================================================ -const green = '\x1b[32m'; -const reset = '\x1b[0m'; +const { green, reset } = ANSI; export interface ImportContext { ctx: ProjectContext; @@ -60,7 +60,7 @@ export async function resolveImportContext(options: ImportResourceOptions, comma export function failResult( logger: ExecLogger, error: string, - resourceType: 'runtime' | 'memory', + resourceType: ImportableResourceType, resourceName: string ): ImportResourceResult { logger.endStep('error', error); @@ -128,9 +128,12 @@ export async function resolveImportTarget(options: ResolveTargetOptions): Promis const { configIO, targetName, arn, onProgress } = options; // Validate ARN format early if provided - if (arn && !/^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory)\/(.+)$/.test(arn)) { + if ( + arn && + !/^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config)\/(.+)$/.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:::/` ); } @@ -206,7 +209,27 @@ export interface ParsedArn { resourceId: string; } -const ARN_PATTERN = /^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory)\/(.+)$/; +const ARN_PATTERN = + /^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory|evaluator|online-evaluation-config)\/(.+)$/; + +/** Unified config for each importable resource type — ARN mapping, deployed state keys. */ +const RESOURCE_TYPE_CONFIG: Record< + ImportableResourceType, + { + arnType: string; + collectionKey: string; + idField: string; + } +> = { + runtime: { arnType: 'runtime', collectionKey: 'runtimes', idField: 'runtimeId' }, + memory: { arnType: 'memory', collectionKey: 'memories', idField: 'memoryId' }, + evaluator: { arnType: 'evaluator', collectionKey: 'evaluators', idField: 'evaluatorId' }, + 'online-eval': { + arnType: 'online-evaluation-config', + collectionKey: 'onlineEvalConfigs', + idField: 'onlineEvaluationConfigId', + }, +}; /** * Parse and validate a BedrockAgentCore ARN. @@ -214,20 +237,21 @@ const ARN_PATTERN = /^arn:aws:bedrock-agentcore:([^:]+):([^:]+):(runtime|memory) */ export function parseAndValidateArn( arn: string, - expectedResourceType: 'runtime' | 'memory', + expectedResourceType: ImportableResourceType, target: { region: string; account: string } ): ParsedArn { const match = ARN_PATTERN.exec(arn); + const expectedArnType = RESOURCE_TYPE_CONFIG[expectedResourceType].arnType; if (!match) { throw new Error( - `Invalid ARN format: "${arn}". Expected format: arn:aws:bedrock-agentcore:::${expectedResourceType}/` + `Invalid ARN format: "${arn}". Expected format: arn:aws:bedrock-agentcore:::${expectedArnType}/` ); } const [, region, account, resourceType, resourceId] = match; - if (resourceType !== expectedResourceType) { - throw new Error(`ARN resource type "${resourceType}" does not match expected type "${expectedResourceType}".`); + if (resourceType !== expectedArnType) { + throw new Error(`ARN resource type "${resourceType}" does not match expected type "${expectedArnType}".`); } if (region !== target.region) { @@ -268,7 +292,7 @@ export function toStackName(projectName: string, targetName: string): string { export async function findResourceInDeployedState( configIO: ConfigIO, targetName: string, - resourceType: 'runtime' | 'memory', + resourceType: ImportableResourceType, resourceId: string ): Promise { /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any */ @@ -276,11 +300,11 @@ export async function findResourceInDeployedState( const targetState = state.targets?.[targetName]; if (!targetState?.resources) return undefined; - const collection = resourceType === 'runtime' ? targetState.resources.runtimes : targetState.resources.memories; - if (!collection) return undefined; + const { collectionKey, idField } = RESOURCE_TYPE_CONFIG[resourceType]; + const collection = targetState.resources[collectionKey]; + if (!collection) return undefined; for (const [name, entry] of Object.entries(collection)) { - const idField = resourceType === 'runtime' ? 'runtimeId' : 'memoryId'; if ((entry as any)[idField] === resourceId) return name; } /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any */ @@ -289,7 +313,7 @@ export async function findResourceInDeployedState( } export interface ImportedResource { - type: 'runtime' | 'memory'; + type: ImportableResourceType; name: string; id: string; arn: string; @@ -324,6 +348,18 @@ export async function updateDeployedState( memoryId: resource.id, memoryArn: resource.arn, }; + } else if (resource.type === 'evaluator') { + targetState.resources.evaluators ??= {}; + targetState.resources.evaluators[resource.name] = { + evaluatorId: resource.id, + evaluatorArn: resource.arn, + }; + } else if (resource.type === 'online-eval') { + targetState.resources.onlineEvalConfigs ??= {}; + targetState.resources.onlineEvalConfigs[resource.name] = { + onlineEvaluationConfigId: resource.id, + onlineEvaluationConfigArn: resource.arn, + }; } } @@ -377,7 +413,6 @@ export function fixPyprojectForSetuptools(pyprojectPath: string): void { const content = fs.readFileSync(pyprojectPath, 'utf-8'); - // Already has [tool.setuptools] section — don't touch it if (content.includes('[tool.setuptools]')) return; // Append the fix @@ -409,7 +444,6 @@ export async function copyAgentSource(options: CopyAgentSourceOptions): Promise< onProgress?.(`Copying agent source from ${sourcePath} to ./${APP_DIR}/${agentName}`); copyDirRecursive(sourcePath, appDir); - // Also copy pyproject.toml from the parent of source_path if it exists const parentPyproject = path.join(path.dirname(sourcePath), 'pyproject.toml'); const destPyproject = path.join(appDir, 'pyproject.toml'); if (fs.existsSync(parentPyproject) && !fs.existsSync(destPyproject)) { diff --git a/src/cli/commands/import/resource-import.ts b/src/cli/commands/import/resource-import.ts new file mode 100644 index 00000000..6418e367 --- /dev/null +++ b/src/cli/commands/import/resource-import.ts @@ -0,0 +1,247 @@ +import type { AgentCoreProjectSpec } from '../../../schema'; +import { NAME_REGEX } from './constants'; +import { executeCdkImportPipeline } from './import-pipeline'; +import { failResult, findResourceInDeployedState, resolveImportContext, toStackName } from './import-utils'; +import { findLogicalIdByProperty, findLogicalIdsByType } from './template-utils'; +import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; + +/** + * Generic import orchestrator. Owns the full 10-step sequence shared by all + * single-resource import commands (runtime, memory, evaluator, online-eval). + * + * Each resource type provides a descriptor declaring its specific behavior. + */ +export async function executeResourceImport( + descriptor: ResourceImportDescriptor, + 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)}`); + } + } + if (descriptor.rollbackExtra) { + await descriptor.rollbackExtra(); + } + }; + + try { + // 1-2. Validate project context and resolve target + importCtx = await resolveImportContext(options, descriptor.logCommand); + const { ctx, target, logger, onProgress } = importCtx; + + // 3. Fetch resource from AWS + logger.startStep(`Fetch ${descriptor.displayName} from AWS`); + let resourceId: string; + + if (options.arn) { + resourceId = descriptor.parseResourceId(options.arn, target); + } else { + onProgress(`Listing ${descriptor.displayName}s in your account...`); + const summaries = await descriptor.listResources(target.region); + + if (summaries.length === 0) { + return failResult(logger, `No ${descriptor.displayName}s found in your account.`, descriptor.resourceType, ''); + } + + if (summaries.length === 1) { + resourceId = descriptor.extractSummaryId(summaries[0]!); + onProgress(descriptor.formatAutoSelectMessage(summaries[0]!)); + } else { + console.log(`\nFound ${summaries.length} ${descriptor.displayName}(s):\n`); + for (let i = 0; i < summaries.length; i++) { + console.log(descriptor.formatListItem(summaries[i]!, i)); + } + console.log(''); + + return failResult( + logger, + `Multiple ${descriptor.displayName}s found. Use --arn to specify which ${descriptor.displayName} to import.`, + descriptor.resourceType, + '' + ); + } + } + + onProgress(`Fetching ${descriptor.displayName} details for ${resourceId}...`); + const detail = await descriptor.getDetail(target.region, resourceId); + + if (descriptor.extractDetailStatus(detail) !== descriptor.readyStatus) { + onProgress( + `Warning: ${descriptor.displayName} status is ${descriptor.extractDetailStatus(detail)}, not ${descriptor.readyStatus}` + ); + } + + // 4. Validate name + const localName = options.name ?? descriptor.extractDetailName(detail); + 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).`, + descriptor.resourceType, + localName + ); + } + onProgress(`${descriptor.displayName}: ${descriptor.extractDetailName(detail)} → 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(descriptor.getExistingNames(projectSpec)); + if (existingNames.has(localName)) { + return failResult( + logger, + `${descriptor.displayName} "${localName}" already exists in the project. Use --name to specify a different local name.`, + descriptor.resourceType, + localName + ); + } + const targetName = target.name ?? 'default'; + const existingResource = await findResourceInDeployedState( + ctx.configIO, + targetName, + descriptor.resourceType, + resourceId + ); + if (existingResource) { + return failResult( + logger, + `${descriptor.displayName} "${resourceId}" is already imported in this project as "${existingResource}". Remove it first before re-importing.`, + descriptor.resourceType, + localName + ); + } + logger.endStep('success'); + + // 6. Optional pre-write hook + if (descriptor.beforeConfigWrite) { + const hookResult = await descriptor.beforeConfigWrite({ + detail, + localName, + projectSpec, + ctx, + target, + options, + onProgress, + logger, + }); + if (hookResult) { + return hookResult; + } + } + + // 7. Update project config + logger.startStep('Update project config'); + configSnapshot = JSON.parse(JSON.stringify(projectSpec)) as AgentCoreProjectSpec; + descriptor.addToProjectSpec(detail, localName, projectSpec); + await ctx.configIO.writeProjectSpec(projectSpec); + configWritten = true; + onProgress(`Added ${descriptor.displayName} "${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); + + const pipelineResult = await executeCdkImportPipeline({ + projectRoot: ctx.projectRoot, + stackName, + target, + configIO: ctx.configIO, + targetName, + onProgress, + buildResourcesToImport: synthTemplate => { + // Try matching by name property (plain name first, then prefixed) + let logicalId = findLogicalIdByProperty( + synthTemplate, + descriptor.cfnResourceType, + descriptor.cfnNameProperty, + localName + ); + + if (!logicalId) { + const prefixedName = `${ctx.projectName}_${localName}`; + logicalId = findLogicalIdByProperty( + synthTemplate, + descriptor.cfnResourceType, + descriptor.cfnNameProperty, + prefixedName + ); + } + + // Fall back to single resource by type + if (!logicalId) { + const allLogicalIds = findLogicalIdsByType(synthTemplate, descriptor.cfnResourceType); + if (allLogicalIds.length === 1) { + logicalId = allLogicalIds[0]; + } + } + + if (!logicalId) { + return []; + } + + return [ + { + resourceType: descriptor.cfnResourceType, + logicalResourceId: logicalId, + resourceIdentifier: { [descriptor.cfnIdentifierKey]: resourceId }, + }, + ]; + }, + deployedStateEntries: [descriptor.buildDeployedStateEntry(localName, resourceId, detail)], + }); + + if (pipelineResult.noResources) { + const error = `Could not find logical ID for ${descriptor.displayName} "${localName}" in CloudFormation template`; + await rollback(); + return failResult(logger, error, descriptor.resourceType, localName); + } + + if (!pipelineResult.success) { + await rollback(); + logger.endStep('error', pipelineResult.error); + logger.finalize(false); + return { + success: false, + error: pipelineResult.error, + resourceType: descriptor.resourceType, + resourceName: localName, + logPath: logger.getRelativeLogPath(), + }; + } + logger.endStep('success'); + + // 9. Return success + logger.finalize(true); + return { + success: true, + resourceType: descriptor.resourceType, + resourceName: localName, + resourceId, + 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: descriptor.resourceType, + resourceName: options.name ?? '', + logPath: importCtx?.logger.getRelativeLogPath(), + }; + } +} diff --git a/src/cli/commands/import/types.ts b/src/cli/commands/import/types.ts index d2b07ef8..eab11c0a 100644 --- a/src/cli/commands/import/types.ts +++ b/src/cli/commands/import/types.ts @@ -1,4 +1,11 @@ -import type { AgentCoreProjectSpec, AuthorizerConfig, RuntimeAuthorizerType } from '../../../schema'; +import type { + AgentCoreProjectSpec, + AuthorizerConfig, + AwsDeploymentTarget, + RuntimeAuthorizerType, +} from '../../../schema'; +import type { ExecLogger } from '../../logging'; +import type { ImportedResource, ProjectContext } from './import-utils'; /** * Parsed representation of a starter toolkit agent from .bedrock_agentcore.yaml. @@ -63,6 +70,13 @@ 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 type ImportableResourceType = (typeof IMPORTABLE_RESOURCES)[number]; + /** * Resource to be imported via CloudFormation IMPORT change set. */ @@ -86,12 +100,12 @@ export interface ImportResult { } /** - * Result for single-resource import (runtime or memory). + * Result for single-resource import (runtime, memory, evaluator, etc.). */ export interface ImportResourceResult { success: boolean; error?: string; - resourceType: 'runtime' | 'memory'; + resourceType: ImportableResourceType; resourceName: string; resourceId?: string; logPath?: string; @@ -102,10 +116,127 @@ export interface ImportResourceResult { */ export interface ImportResourceOptions { arn?: string; - code?: string; target?: string; name?: string; - entrypoint?: string; yes?: boolean; onProgress?: (message: string) => void; } + +/** + * Extended options for runtime import (includes source code fields). + */ +export interface RuntimeImportOptions extends ImportResourceOptions { + code?: string; + entrypoint?: string; +} + +// ============================================================================ +// Generic Resource Import Descriptor +// ============================================================================ + +/** + * Context passed to the beforeConfigWrite hook. + */ +export interface BeforeWriteContext { + detail: TDetail; + localName: string; + projectSpec: AgentCoreProjectSpec; + ctx: ProjectContext; + target: AwsDeploymentTarget; + options: ImportResourceOptions; + onProgress: (msg: string) => void; + logger: ExecLogger; +} + +/** + * Descriptor that defines resource-type-specific behavior for the generic import orchestrator. + * + * TDetail: The AWS "get" API response type (e.g., GetEvaluatorResult) + * TSummary: The AWS "list" API response item type (e.g., EvaluatorSummary) + */ +export interface ResourceImportDescriptor { + /** The importable resource type identifier. */ + resourceType: ImportableResourceType; + + /** Human-readable resource type name for log messages (e.g., "evaluator"). */ + displayName: string; + + /** Logger command name (e.g., 'import-evaluator'). */ + logCommand: string; + + // ---- AWS API ---- + + /** List all resources of this type in the region. */ + listResources: (region: string) => Promise; + + /** Get full details for a single resource by ID. */ + getDetail: (region: string, resourceId: string) => Promise; + + /** Extract the resource ID from an ARN. */ + parseResourceId: (arn: string, target: { region: string; account: string }) => string; + + // ---- List display ---- + + /** Extract ID from a summary item. */ + extractSummaryId: (summary: TSummary) => string; + + /** Format a summary item for console display in multi-result listing. */ + formatListItem: (summary: TSummary, index: number) => string; + + /** Format the auto-select message when exactly 1 result is found. */ + formatAutoSelectMessage: (summary: TSummary) => string; + + // ---- Detail inspection ---- + + /** Extract the canonical name from the detail response. */ + extractDetailName: (detail: TDetail) => string; + + /** Extract the ARN from the detail response. */ + extractDetailArn: (detail: TDetail) => string; + + /** The expected "ready" status value (e.g., 'READY' for runtime, 'ACTIVE' for others). */ + readyStatus: string; + + /** Extract the current status from the detail response. */ + extractDetailStatus: (detail: TDetail) => string; + + // ---- Config ---- + + /** Get the array of existing resource names from the project spec. */ + getExistingNames: (projectSpec: AgentCoreProjectSpec) => string[]; + + /** + * Convert the AWS detail to local spec and add it to the project spec. + * Called after beforeConfigWrite — descriptor factories may rely on state set during that hook. + */ + addToProjectSpec: (detail: TDetail, localName: string, projectSpec: AgentCoreProjectSpec) => void; + + // ---- CFN template matching ---- + + /** CloudFormation resource type string. */ + cfnResourceType: string; + + /** CFN property name used for name-based lookup. */ + cfnNameProperty: string; + + /** CFN resource identifier key for the import. */ + cfnIdentifierKey: string; + + // ---- Deployed state ---- + + /** Build the deployed-state entry for this resource. */ + buildDeployedStateEntry: (localName: string, resourceId: string, detail: TDetail) => ImportedResource; + + // ---- Optional hooks ---- + + /** + * Called after detail fetch + name validation but before config write. + * Always runs before addToProjectSpec — descriptor factories can use this + * to set closed-over state that addToProjectSpec later reads. + * Return an ImportResourceResult to abort, or void to continue. + */ + beforeConfigWrite?: (ctx: BeforeWriteContext) => Promise; + + /** Cleanup on rollback (e.g., runtime deletes copied app directory). */ + rollbackExtra?: () => Promise; +} diff --git a/src/cli/tui/screens/import/ArnInputScreen.tsx b/src/cli/tui/screens/import/ArnInputScreen.tsx index db86c7d9..188f9a69 100644 --- a/src/cli/tui/screens/import/ArnInputScreen.tsx +++ b/src/cli/tui/screens/import/ArnInputScreen.tsx @@ -1,26 +1,35 @@ +import type { ImportableResourceType } from '../../../commands/import/types'; import { Panel } from '../../components/Panel'; import { Screen } from '../../components/Screen'; import { TextInput } from '../../components/TextInput'; import { HELP_TEXT } from '../../constants'; -const ARN_PATTERN = /^arn:aws:bedrock-agentcore:[^:]+:[^:]+:(runtime|memory)\/.+$/; +const ARN_PATTERN = /^arn:aws:bedrock-agentcore:[^:]+:[^:]+:(runtime|memory|evaluator|online-evaluation-config)\/.+$/; function validateArn(value: string): true | string { if (!ARN_PATTERN.test(value)) { - return 'Invalid ARN format. Expected: arn:aws:bedrock-agentcore:::/'; + return 'Invalid ARN format. Expected: arn:aws:bedrock-agentcore:::/'; } return true; } interface ArnInputScreenProps { - resourceType: 'runtime' | 'memory'; + resourceType: ImportableResourceType; onSubmit: (arn: string) => void; onExit: () => void; } +const RESOURCE_TYPE_LABELS: Record = { + runtime: 'Import Runtime', + memory: 'Import Memory', + evaluator: 'Import Evaluator', + 'online-eval': 'Import Online Eval Config', +}; + export function ArnInputScreen({ resourceType, onSubmit, onExit }: ArnInputScreenProps) { - const title = resourceType === 'runtime' ? 'Import Runtime' : 'Import Memory'; - const placeholder = `arn:aws:bedrock-agentcore:::${resourceType}/`; + const title = RESOURCE_TYPE_LABELS[resourceType] ?? `Import ${resourceType}`; + const arnResourceType = resourceType === 'online-eval' ? 'online-evaluation-config' : resourceType; + const placeholder = `arn:aws:bedrock-agentcore:::${arnResourceType}/`; return ( diff --git a/src/cli/tui/screens/import/ImportFlow.tsx b/src/cli/tui/screens/import/ImportFlow.tsx index a592a0f7..ed82962d 100644 --- a/src/cli/tui/screens/import/ImportFlow.tsx +++ b/src/cli/tui/screens/import/ImportFlow.tsx @@ -1,4 +1,9 @@ -import type { ImportResourceResult, ImportResult } from '../../../commands/import/types'; +import { + IMPORTABLE_RESOURCES, + type ImportResourceResult, + type ImportResult, + type ImportableResourceType, +} from '../../../commands/import/types'; import { type NextStep, NextSteps } from '../../components/NextSteps'; import { Panel } from '../../components/Panel'; import { ErrorPrompt } from '../../components/PromptScreen'; @@ -14,7 +19,7 @@ import React, { useState } from 'react'; type ImportFlowState = | { name: 'select-type' } - | { name: 'arn-input'; resourceType: 'runtime' | 'memory' } + | { name: 'arn-input'; resourceType: ImportableResourceType } | { name: 'code-path'; resourceType: 'runtime'; arn: string } | { name: 'yaml-path' } | { @@ -48,8 +53,8 @@ export function ImportFlow({ onBack, onNavigate }: ImportFlowProps) { return ( { - if (type === 'runtime' || type === 'memory') { - setFlow({ name: 'arn-input', resourceType: type }); + if ((IMPORTABLE_RESOURCES as readonly string[]).includes(type)) { + setFlow({ name: 'arn-input', resourceType: type as ImportableResourceType }); } else { setFlow({ name: 'yaml-path' }); } @@ -69,7 +74,7 @@ export function ImportFlow({ onBack, onNavigate }: ImportFlowProps) { } else { setFlow({ name: 'importing', - importType: 'memory', + importType: flow.resourceType, arn, }); } diff --git a/src/cli/tui/screens/import/ImportProgressScreen.tsx b/src/cli/tui/screens/import/ImportProgressScreen.tsx index af19fafb..bd7096d5 100644 --- a/src/cli/tui/screens/import/ImportProgressScreen.tsx +++ b/src/cli/tui/screens/import/ImportProgressScreen.tsx @@ -1,4 +1,5 @@ import type { ImportResourceResult, ImportResult } from '../../../commands/import/types'; +import { IMPORTABLE_RESOURCES } from '../../../commands/import/types'; import { Panel } from '../../components/Panel'; import { Screen } from '../../components/Screen'; import { type Step, StepProgress } from '../../components/StepProgress'; @@ -40,11 +41,15 @@ export function ImportProgressScreen({ started.current = true; const run = async () => { - if (importType === 'runtime' || importType === 'memory') { + if ((IMPORTABLE_RESOURCES as readonly string[]).includes(importType)) { const handler = importType === 'runtime' ? (await import('../../../commands/import/import-runtime')).handleImportRuntime - : (await import('../../../commands/import/import-memory')).handleImportMemory; + : importType === 'memory' + ? (await import('../../../commands/import/import-memory')).handleImportMemory + : importType === 'evaluator' + ? (await import('../../../commands/import/import-evaluator')).handleImportEvaluator + : (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 ded69eb2..21ab114f 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' | 'starter-toolkit'; +export type ImportType = 'runtime' | 'memory' | 'evaluator' | 'online-eval' | 'starter-toolkit'; interface ImportSelectItem extends SelectableItem { id: ImportType; @@ -19,6 +19,16 @@ const IMPORT_OPTIONS: ImportSelectItem[] = [ title: 'Memory', description: 'Import an existing AgentCore Memory from your AWS account', }, + { + id: 'evaluator', + title: 'Evaluator', + description: 'Import an existing AgentCore Evaluator from your AWS account', + }, + { + id: 'online-eval', + title: 'Online Eval Config', + description: 'Import an existing AgentCore Online Evaluation Config from your AWS account', + }, { id: 'starter-toolkit', title: 'From Starter Toolkit',