Skip to content

Commit fdee4e3

Browse files
jariy17jariy17
andauthored
fix(import): strip reserved aws: tags from imported resources (#2289)
CloudFormation stamps aws:cloudformation:{stack-id,stack-name,logical-id} on every resource it provisions. A resource retained after its stack is deleted keeps these as orphans, and they are not customer-writable. The import commands copied all tags from the source resource verbatim into agentcore.json. Any aws:-prefixed key then fails TagKeySchema ("aws:" is reserved) during config validation, blocking the import before any AWS call: runtimes[0].tags.aws:cloudformation:stack-name: Invalid key in record Add a shared stripReservedTags() helper and apply it at all four import read-paths (runtime, memory, gateway, evaluator). User tags are preserved; reserved tags are dropped and stay untouched on the resource in AWS. Co-authored-by: jariy17 <tjariy+jariy17@users.noreply.github.com>
1 parent 1d94c5d commit fdee4e3

7 files changed

Lines changed: 67 additions & 9 deletions

File tree

‎src/cli/commands/import/__tests__/import-runtime-handler.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ const mockFailResult = vi.fn((...args: unknown[]) => ({
3232
logPath: 'test.log',
3333
}));
3434

35-
vi.mock('../import-utils', () => ({
35+
vi.mock('../import-utils', async importOriginal => ({
36+
...(await importOriginal<typeof import('../import-utils')>()),
3637
resolveProjectContext: (...args: unknown[]) => mockResolveProjectContext(...args),
3738
resolveImportTarget: (...args: unknown[]) => mockResolveImportTarget(...args),
3839
resolveImportContext: (...args: unknown[]) => mockResolveImportContext(...args),
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { TagsSchema } from '../../../../schema/schemas/primitives/tags';
2+
import { stripReservedTags } from '../import-utils';
3+
import { describe, expect, it } from 'vitest';
4+
5+
// Regression: a runtime retained after its CloudFormation stack was deleted keeps orphaned
6+
// aws:cloudformation:* tags. The import commands used to copy them verbatim into agentcore.json,
7+
// where TagsSchema rejects the reserved "aws:" prefix and blocks the import.
8+
describe('stripReservedTags', () => {
9+
const orphaned = {
10+
'aws:cloudformation:stack-name': 'AgentCore-MyProject-default',
11+
'aws:cloudformation:logical-id': 'ApplicationAgentMyAgentRuntime140BFE6B',
12+
'aws:cloudformation:stack-id':
13+
'arn:aws:cloudformation:us-east-1:123456789012:stack/AgentCore-MyProject-default/abc',
14+
};
15+
16+
it('drops every aws: reserved key, leaving no tags to persist', () => {
17+
expect(stripReservedTags(orphaned)).toBeUndefined();
18+
});
19+
20+
it('keeps user tags and removes only reserved ones', () => {
21+
const kept = stripReservedTags({ ...orphaned, team: 'life-sciences', env: 'test' });
22+
expect(kept).toEqual({ team: 'life-sciences', env: 'test' });
23+
});
24+
25+
it('passes undefined through', () => {
26+
expect(stripReservedTags(undefined)).toBeUndefined();
27+
});
28+
29+
it('produces tags that survive the config schema (the actual blocker)', () => {
30+
// Before the fix, feeding orphaned tags straight to TagsSchema throws "aws:" reserved.
31+
expect(() => TagsSchema.parse(orphaned)).toThrow();
32+
const cleaned = stripReservedTags({ ...orphaned, team: 'life-sciences' });
33+
expect(() => TagsSchema.parse(cleaned)).not.toThrow();
34+
});
35+
});

‎src/cli/commands/import/import-evaluator.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from '../../aws/agentcore-control';
1111
import { ANSI } from '../../constants';
1212
import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js';
13-
import { failResult, parseAndValidateArn } from './import-utils';
13+
import { failResult, parseAndValidateArn, stripReservedTags } from './import-utils';
1414
import { executeResourceImport } from './resource-import';
1515
import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types';
1616
import { ResourceNotFoundException } from '@aws-sdk/client-bedrock-agentcore-control';
@@ -51,6 +51,8 @@ export function toEvaluatorSpec(detail: GetEvaluatorResult, localName: string):
5151
);
5252
}
5353

54+
const tags = stripReservedTags(detail.tags);
55+
5456
return {
5557
success: true,
5658
evaluator: {
@@ -59,7 +61,7 @@ export function toEvaluatorSpec(detail: GetEvaluatorResult, localName: string):
5961
...(detail.description && { description: detail.description }),
6062
config,
6163
...(detail.kmsKeyArn && { kmsKeyArn: detail.kmsKeyArn }),
62-
...(detail.tags && Object.keys(detail.tags).length > 0 && { tags: detail.tags }),
64+
...(tags && { tags }),
6365
},
6466
};
6567
}

‎src/cli/commands/import/import-gateway.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
findResourceInDeployedState,
2828
parseAndValidateArn,
2929
resolveImportContext,
30+
stripReservedTags,
3031
toStackName,
3132
} from './import-utils';
3233
import { findLogicalIdByProperty, findLogicalIdsByType } from './template-utils';
@@ -310,6 +311,8 @@ export function toGatewaySpec(options: {
310311
};
311312
}
312313

314+
const tags = stripReservedTags(gateway.tags);
315+
313316
return {
314317
name: localName,
315318
resourceName: gateway.name,
@@ -322,7 +325,7 @@ export function toGatewaySpec(options: {
322325
exceptionLevel,
323326
...(policyEngineConfiguration && { policyEngineConfiguration }),
324327
...(gateway.roleArn && { executionRoleArn: gateway.roleArn }),
325-
...(gateway.tags && Object.keys(gateway.tags).length > 0 && { tags: gateway.tags }),
328+
...(tags && { tags }),
326329
};
327330
}
328331

‎src/cli/commands/import/import-memory.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { MemoryDetail, MemorySummary } from '../../aws/agentcore-control';
44
import { getMemoryDetail, listAllMemories } from '../../aws/agentcore-control';
55
import { ANSI } from '../../constants';
66
import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js';
7-
import { parseAndValidateArn } from './import-utils';
7+
import { parseAndValidateArn, stripReservedTags } from './import-utils';
88
import { executeResourceImport } from './resource-import';
99
import type { ImportResourceOptions, ImportResourceResult, ResourceImportDescriptor } from './types';
1010
import type { Command } from '@commander-js/extra-typings';
@@ -72,12 +72,14 @@ function toMemorySpec(memory: MemoryDetail, localName: string): Memory {
7272
})
7373
.filter(Boolean);
7474

75+
const tags = stripReservedTags(memory.tags);
76+
7577
return {
7678
name: localName,
7779
eventExpiryDuration: Math.max(3, Math.min(365, memory.eventExpiryDuration)),
7880
strategies,
7981
...(indexedKeys && indexedKeys.length > 0 && { indexedKeys }),
80-
...(memory.tags && Object.keys(memory.tags).length > 0 && { tags: memory.tags }),
82+
...(tags && { tags }),
8183
...(memory.encryptionKeyArn && { encryptionKeyArn: memory.encryptionKeyArn }),
8284
...(memory.executionRoleArn && { executionRoleArn: memory.executionRoleArn }),
8385
};

‎src/cli/commands/import/import-runtime.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { AgentRuntimeDetail, AgentRuntimeSummary } from '../../aws/agentcor
33
import { getAgentRuntimeDetail, listAllAgentRuntimes } from '../../aws/agentcore-control';
44
import { ANSI } from '../../constants';
55
import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js';
6-
import { copyAgentSource, failResult, parseAndValidateArn } from './import-utils';
6+
import { copyAgentSource, failResult, parseAndValidateArn, stripReservedTags } from './import-utils';
77
import { executeResourceImport } from './resource-import';
88
import type { ImportResourceResult, ResourceImportDescriptor, RuntimeImportOptions } from './types';
99
import type { Command } from '@commander-js/extra-typings';
@@ -65,8 +65,9 @@ function toAgentEnvSpec(
6565
spec.envVars = Object.entries(runtime.environmentVariables).map(([name, value]) => ({ name, value }));
6666
}
6767

68-
if (runtime.tags && Object.keys(runtime.tags).length > 0) {
69-
spec.tags = runtime.tags;
68+
const tags = stripReservedTags(runtime.tags);
69+
if (tags) {
70+
spec.tags = tags;
7071
}
7172

7273
if (runtime.lifecycleConfiguration) {

‎src/cli/commands/import/import-utils.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,20 @@ import * as path from 'node:path';
1515

1616
const { green, reset } = ANSI;
1717

18+
/**
19+
* Drop AWS-managed reserved tags (aws:* keys) when importing an existing resource.
20+
* CloudFormation stamps aws:cloudformation:{stack-id,stack-name,logical-id} on every
21+
* resource it provisions; a runtime retained after its stack is deleted keeps them as
22+
* orphans. They are not customer-writable, so persisting them into agentcore.json would
23+
* fail TagKeySchema ("aws:" is reserved) and block the import.
24+
* Returns undefined when nothing user-owned remains, so callers can omit the tags field.
25+
*/
26+
export function stripReservedTags(tags: Record<string, string> | undefined): Record<string, string> | undefined {
27+
if (!tags) return undefined;
28+
const userTags = Object.fromEntries(Object.entries(tags).filter(([key]) => !key.startsWith('aws:')));
29+
return Object.keys(userTags).length > 0 ? userTags : undefined;
30+
}
31+
1832
export interface ImportContext {
1933
ctx: ProjectContext;
2034
target: AwsDeploymentTarget;

0 commit comments

Comments
 (0)