diff --git a/src/cli/commands/import/__tests__/import-runtime-handler.test.ts b/src/cli/commands/import/__tests__/import-runtime-handler.test.ts index 799663e38..63f178924 100644 --- a/src/cli/commands/import/__tests__/import-runtime-handler.test.ts +++ b/src/cli/commands/import/__tests__/import-runtime-handler.test.ts @@ -32,7 +32,8 @@ const mockFailResult = vi.fn((...args: unknown[]) => ({ logPath: 'test.log', })); -vi.mock('../import-utils', () => ({ +vi.mock('../import-utils', async importOriginal => ({ + ...(await importOriginal()), resolveProjectContext: (...args: unknown[]) => mockResolveProjectContext(...args), resolveImportTarget: (...args: unknown[]) => mockResolveImportTarget(...args), resolveImportContext: (...args: unknown[]) => mockResolveImportContext(...args), diff --git a/src/cli/commands/import/__tests__/strip-reserved-tags.test.ts b/src/cli/commands/import/__tests__/strip-reserved-tags.test.ts new file mode 100644 index 000000000..b0b20d408 --- /dev/null +++ b/src/cli/commands/import/__tests__/strip-reserved-tags.test.ts @@ -0,0 +1,35 @@ +import { TagsSchema } from '../../../../schema/schemas/primitives/tags'; +import { stripReservedTags } from '../import-utils'; +import { describe, expect, it } from 'vitest'; + +// Regression: a runtime retained after its CloudFormation stack was deleted keeps orphaned +// aws:cloudformation:* tags. The import commands used to copy them verbatim into agentcore.json, +// where TagsSchema rejects the reserved "aws:" prefix and blocks the import. +describe('stripReservedTags', () => { + const orphaned = { + 'aws:cloudformation:stack-name': 'AgentCore-MyProject-default', + 'aws:cloudformation:logical-id': 'ApplicationAgentMyAgentRuntime140BFE6B', + 'aws:cloudformation:stack-id': + 'arn:aws:cloudformation:us-east-1:123456789012:stack/AgentCore-MyProject-default/abc', + }; + + it('drops every aws: reserved key, leaving no tags to persist', () => { + expect(stripReservedTags(orphaned)).toBeUndefined(); + }); + + it('keeps user tags and removes only reserved ones', () => { + const kept = stripReservedTags({ ...orphaned, team: 'life-sciences', env: 'test' }); + expect(kept).toEqual({ team: 'life-sciences', env: 'test' }); + }); + + it('passes undefined through', () => { + expect(stripReservedTags(undefined)).toBeUndefined(); + }); + + it('produces tags that survive the config schema (the actual blocker)', () => { + // Before the fix, feeding orphaned tags straight to TagsSchema throws "aws:" reserved. + expect(() => TagsSchema.parse(orphaned)).toThrow(); + const cleaned = stripReservedTags({ ...orphaned, team: 'life-sciences' }); + expect(() => TagsSchema.parse(cleaned)).not.toThrow(); + }); +}); diff --git a/src/cli/commands/import/import-evaluator.ts b/src/cli/commands/import/import-evaluator.ts index 21e68ed07..c136284bb 100644 --- a/src/cli/commands/import/import-evaluator.ts +++ b/src/cli/commands/import/import-evaluator.ts @@ -10,7 +10,7 @@ import { } from '../../aws/agentcore-control'; import { ANSI } from '../../constants'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; -import { failResult, parseAndValidateArn } from './import-utils'; +import { failResult, parseAndValidateArn, stripReservedTags } from './import-utils'; import { executeResourceImport } from './resource-import'; import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; import { ResourceNotFoundException } from '@aws-sdk/client-bedrock-agentcore-control'; @@ -51,6 +51,8 @@ export function toEvaluatorSpec(detail: GetEvaluatorResult, localName: string): ); } + const tags = stripReservedTags(detail.tags); + return { success: true, evaluator: { @@ -59,7 +61,7 @@ export function toEvaluatorSpec(detail: GetEvaluatorResult, localName: string): ...(detail.description && { description: detail.description }), config, ...(detail.kmsKeyArn && { kmsKeyArn: detail.kmsKeyArn }), - ...(detail.tags && Object.keys(detail.tags).length > 0 && { tags: detail.tags }), + ...(tags && { tags }), }, }; } diff --git a/src/cli/commands/import/import-gateway.ts b/src/cli/commands/import/import-gateway.ts index 163a6ede2..24220d9e0 100644 --- a/src/cli/commands/import/import-gateway.ts +++ b/src/cli/commands/import/import-gateway.ts @@ -27,6 +27,7 @@ import { findResourceInDeployedState, parseAndValidateArn, resolveImportContext, + stripReservedTags, toStackName, } from './import-utils'; import { findLogicalIdByProperty, findLogicalIdsByType } from './template-utils'; @@ -310,6 +311,8 @@ export function toGatewaySpec(options: { }; } + const tags = stripReservedTags(gateway.tags); + return { name: localName, resourceName: gateway.name, @@ -322,7 +325,7 @@ export function toGatewaySpec(options: { exceptionLevel, ...(policyEngineConfiguration && { policyEngineConfiguration }), ...(gateway.roleArn && { executionRoleArn: gateway.roleArn }), - ...(gateway.tags && Object.keys(gateway.tags).length > 0 && { tags: gateway.tags }), + ...(tags && { tags }), }; } diff --git a/src/cli/commands/import/import-memory.ts b/src/cli/commands/import/import-memory.ts index 90a799633..5a4b60344 100644 --- a/src/cli/commands/import/import-memory.ts +++ b/src/cli/commands/import/import-memory.ts @@ -4,7 +4,7 @@ import type { MemoryDetail, MemorySummary } from '../../aws/agentcore-control'; import { getMemoryDetail, listAllMemories } from '../../aws/agentcore-control'; import { ANSI } from '../../constants'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; -import { parseAndValidateArn } from './import-utils'; +import { parseAndValidateArn, stripReservedTags } from './import-utils'; import { executeResourceImport } from './resource-import'; import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types'; import type { Command } from '@commander-js/extra-typings'; @@ -72,12 +72,14 @@ function toMemorySpec(memory: MemoryDetail, localName: string): Memory { }) .filter(Boolean); + const tags = stripReservedTags(memory.tags); + return { name: localName, eventExpiryDuration: Math.max(3, Math.min(365, memory.eventExpiryDuration)), strategies, ...(indexedKeys && indexedKeys.length > 0 && { indexedKeys }), - ...(memory.tags && Object.keys(memory.tags).length > 0 && { tags: memory.tags }), + ...(tags && { tags }), ...(memory.encryptionKeyArn && { encryptionKeyArn: memory.encryptionKeyArn }), ...(memory.executionRoleArn && { executionRoleArn: memory.executionRoleArn }), }; diff --git a/src/cli/commands/import/import-runtime.ts b/src/cli/commands/import/import-runtime.ts index 58aa53fad..a6c706716 100644 --- a/src/cli/commands/import/import-runtime.ts +++ b/src/cli/commands/import/import-runtime.ts @@ -3,7 +3,7 @@ import type { AgentRuntimeDetail, AgentRuntimeSummary } from '../../aws/agentcor import { getAgentRuntimeDetail, listAllAgentRuntimes } from '../../aws/agentcore-control'; import { ANSI } from '../../constants'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; -import { copyAgentSource, failResult, parseAndValidateArn } from './import-utils'; +import { copyAgentSource, failResult, parseAndValidateArn, stripReservedTags } from './import-utils'; import { executeResourceImport } from './resource-import'; import type { ImportResourceResult, ResourceImportDescriptor, RuntimeImportOptions } from './types'; import type { Command } from '@commander-js/extra-typings'; @@ -65,8 +65,9 @@ function toAgentEnvSpec( spec.envVars = Object.entries(runtime.environmentVariables).map(([name, value]) => ({ name, value })); } - if (runtime.tags && Object.keys(runtime.tags).length > 0) { - spec.tags = runtime.tags; + const tags = stripReservedTags(runtime.tags); + if (tags) { + spec.tags = tags; } if (runtime.lifecycleConfiguration) { diff --git a/src/cli/commands/import/import-utils.ts b/src/cli/commands/import/import-utils.ts index aac02d6e0..48f43455d 100644 --- a/src/cli/commands/import/import-utils.ts +++ b/src/cli/commands/import/import-utils.ts @@ -15,6 +15,20 @@ import * as path from 'node:path'; const { green, reset } = ANSI; +/** + * Drop AWS-managed reserved tags (aws:* keys) when importing an existing resource. + * CloudFormation stamps aws:cloudformation:{stack-id,stack-name,logical-id} on every + * resource it provisions; a runtime retained after its stack is deleted keeps them as + * orphans. They are not customer-writable, so persisting them into agentcore.json would + * fail TagKeySchema ("aws:" is reserved) and block the import. + * Returns undefined when nothing user-owned remains, so callers can omit the tags field. + */ +export function stripReservedTags(tags: Record | undefined): Record | undefined { + if (!tags) return undefined; + const userTags = Object.fromEntries(Object.entries(tags).filter(([key]) => !key.startsWith('aws:'))); + return Object.keys(userTags).length > 0 ? userTags : undefined; +} + export interface ImportContext { ctx: ProjectContext; target: AwsDeploymentTarget;