Skip to content

Commit c7ebd03

Browse files
committed
fix(import): detect already-imported resources early and improve CFN error messages
Check deployed-state.json before making any config changes to catch resources already imported in the current project. Also detect the "already exists in stack" CFN error and provide a friendlier message explaining the resource must be removed from the other stack first.
1 parent 5b4bbad commit c7ebd03

5 files changed

Lines changed: 73 additions & 2 deletions

File tree

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const mockCopyAgentSource = vi.fn();
2121
const mockToStackName = vi.fn();
2222

2323
const mockParseAndValidateArn = vi.fn();
24+
const mockFindResourceInDeployedState = vi.fn();
2425

2526
vi.mock('../import-utils', () => ({
2627
resolveProjectContext: (...args: unknown[]) => mockResolveProjectContext(...args),
@@ -29,6 +30,7 @@ vi.mock('../import-utils', () => ({
2930
copyAgentSource: (...args: unknown[]) => mockCopyAgentSource(...args),
3031
toStackName: (...args: unknown[]) => mockToStackName(...args),
3132
parseAndValidateArn: (...args: unknown[]) => mockParseAndValidateArn(...args),
33+
findResourceInDeployedState: (...args: unknown[]) => mockFindResourceInDeployedState(...args),
3234
}));
3335

3436
const mockGetAgentRuntimeDetail = vi.fn();
@@ -128,6 +130,8 @@ function setupDefaultMocks() {
128130
resourceId: 'rt-123',
129131
});
130132

133+
mockFindResourceInDeployedState.mockResolvedValue(undefined);
134+
131135
mockConfigIO.readProjectSpec.mockResolvedValue({ ...defaultProjectSpec, runtimes: [] });
132136
}
133137

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { silentIoHost } from '../../cdk/toolkit-lib';
66
import { ExecLogger } from '../../logging';
77
import { bootstrapEnvironment, buildCdkProject, checkBootstrapNeeded, synthesizeCdk } from '../../operations/deploy';
88
import {
9+
findResourceInDeployedState,
910
parseAndValidateArn,
1011
resolveImportTarget,
1112
resolveProjectContext,
@@ -158,6 +159,20 @@ export async function handleImportMemory(options: ImportResourceOptions): Promis
158159
logPath: logger.getRelativeLogPath(),
159160
};
160161
}
162+
const targetName = target.name ?? 'default';
163+
const existingResource = await findResourceInDeployedState(ctx.configIO, targetName, 'memory', memoryId);
164+
if (existingResource) {
165+
const error = `Memory "${memoryId}" is already imported in this project as "${existingResource}". Remove it first before re-importing.`;
166+
logger.endStep('error', error);
167+
logger.finalize(false);
168+
return {
169+
success: false,
170+
error,
171+
resourceType: 'memory',
172+
resourceName: localName,
173+
logPath: logger.getRelativeLogPath(),
174+
};
175+
}
161176
logger.endStep('success');
162177

163178
// 5. Add to project config
@@ -180,7 +195,6 @@ export async function handleImportMemory(options: ImportResourceOptions): Promis
180195

181196
const synthInfo = await toolkitWrapper.synth();
182197
const assemblyDirectory = synthInfo.assemblyDirectory;
183-
const targetName = target.name ?? 'default';
184198
const stackName = toStackName(ctx.projectName, targetName);
185199
const synthTemplatePath = path.join(assemblyDirectory, `${stackName}.template.json`);
186200

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

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ExecLogger } from '../../logging';
77
import { bootstrapEnvironment, buildCdkProject, checkBootstrapNeeded, synthesizeCdk } from '../../operations/deploy';
88
import {
99
copyAgentSource,
10+
findResourceInDeployedState,
1011
parseAndValidateArn,
1112
resolveImportTarget,
1213
resolveProjectContext,
@@ -238,6 +239,20 @@ export async function handleImportRuntime(options: ImportResourceOptions): Promi
238239
logPath: logger.getRelativeLogPath(),
239240
};
240241
}
242+
const targetName = target.name ?? 'default';
243+
const existingResource = await findResourceInDeployedState(ctx.configIO, targetName, 'runtime', runtimeId);
244+
if (existingResource) {
245+
const error = `Runtime "${runtimeId}" is already imported in this project as "${existingResource}". Remove it first before re-importing.`;
246+
logger.endStep('error', error);
247+
logger.finalize(false);
248+
return {
249+
success: false,
250+
error,
251+
resourceType: 'runtime',
252+
resourceName: localName,
253+
logPath: logger.getRelativeLogPath(),
254+
};
255+
}
241256
logger.endStep('success');
242257

243258
// 7. Copy source code
@@ -273,7 +288,6 @@ export async function handleImportRuntime(options: ImportResourceOptions): Promi
273288

274289
const synthInfo = await toolkitWrapper.synth();
275290
const assemblyDirectory = synthInfo.assemblyDirectory;
276-
const targetName = target.name ?? 'default';
277291
const stackName = toStackName(ctx.projectName, targetName);
278292
const synthTemplatePath = path.join(assemblyDirectory, `${stackName}.template.json`);
279293

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,33 @@ export function toStackName(projectName: string, targetName: string): string {
165165
// Deployed State Update
166166
// ============================================================================
167167

168+
/**
169+
* Check if a resource ID is already tracked in deployed-state.json for the given target.
170+
* Returns the name it's tracked under, or undefined if not found.
171+
*/
172+
export async function findResourceInDeployedState(
173+
configIO: ConfigIO,
174+
targetName: string,
175+
resourceType: 'runtime' | 'memory',
176+
resourceId: string
177+
): Promise<string | undefined> {
178+
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any */
179+
const state: any = await configIO.readDeployedState().catch(() => ({ targets: {} }));
180+
const targetState = state.targets?.[targetName];
181+
if (!targetState?.resources) return undefined;
182+
183+
const collection = resourceType === 'runtime' ? targetState.resources.runtimes : targetState.resources.memories;
184+
if (!collection) return undefined;
185+
186+
for (const [name, entry] of Object.entries(collection)) {
187+
const idField = resourceType === 'runtime' ? 'runtimeId' : 'memoryId';
188+
if ((entry as any)[idField] === resourceId) return name;
189+
}
190+
/* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any */
191+
192+
return undefined;
193+
}
194+
168195
export interface ImportedResource {
169196
type: 'runtime' | 'memory';
170197
name: string;

src/cli/commands/import/phase2-import.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,18 @@ export async function executePhase2(options: Phase2Options): Promise<Phase2Resul
117117
return { success: true };
118118
} catch (err: unknown) {
119119
const message = err instanceof Error ? err.message : String(err);
120+
121+
// Detect "already exists in stack" errors and provide a friendlier message
122+
const alreadyInStackMatch = /(.+) already exists in stack (.+)/.exec(message);
123+
if (alreadyInStackMatch) {
124+
const resourceId = alreadyInStackMatch[1];
125+
const existingStack = alreadyInStackMatch[2];
126+
return {
127+
success: false,
128+
error: `Resource "${resourceId}" is already managed by CloudFormation stack "${existingStack}". It must be removed from that stack before importing into this project.`,
129+
};
130+
}
131+
120132
return { success: false, error: `Import change set failed: ${message}` };
121133
}
122134
}

0 commit comments

Comments
 (0)