Skip to content

Commit 66a44f0

Browse files
jesseturner21claude
andcommitted
fix(import): rollback config on CDK/CloudFormation phase failure
When import fails during CDK build/synth or CloudFormation phases, the merged config (agentcore.json) was already written to disk. On retry, the import would detect existing agents and report false success without completing the CloudFormation import. This snapshots the original config before merging and restores it if any later phase fails. Constraint: Rollback only triggers after config is written (configWritten flag) Constraint: Snapshot must be a deep clone since projectSpec is mutated in-place Rejected: Deferred config write until after all phases | too invasive, changes control flow for all paths Confidence: high Scope-risk: narrow Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 75a7b3d commit 66a44f0

2 files changed

Lines changed: 388 additions & 5 deletions

File tree

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
/**
2+
* Test: Config Rollback on Import Failure
3+
*
4+
* Verifies that when CDK build/synth or CloudFormation phases fail after
5+
* the merged config has been written to disk, the config is rolled back
6+
* to its pre-import state.
7+
*/
8+
import { handleImport } from '../actions';
9+
import type { ParsedStarterToolkitConfig } from '../types';
10+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
11+
12+
// ── Hoisted mock fns ────────────────────────────────────────────────────────
13+
14+
const {
15+
mockFindConfigRoot,
16+
mockConfigIOInstance,
17+
MockConfigIOClass,
18+
mockValidateAwsCredentials,
19+
mockBuildCdkProject,
20+
mockSynthesizeCdk,
21+
mockCheckBootstrapNeeded,
22+
mockBootstrapEnvironment,
23+
mockSetupPythonProject,
24+
mockExecutePhase1,
25+
mockGetDeployedTemplate,
26+
mockExecutePhase2,
27+
mockPublishCdkAssets,
28+
mockParseStarterToolkitYaml,
29+
mockExistsSync,
30+
mockMkdirSync,
31+
mockCopyFileSync,
32+
mockReaddirSync,
33+
mockReadFileSync,
34+
mockWriteFileSync,
35+
} = vi.hoisted(() => {
36+
const inst = {
37+
readProjectSpec: vi.fn(),
38+
writeProjectSpec: vi.fn(),
39+
readAWSDeploymentTargets: vi.fn(),
40+
writeAWSDeploymentTargets: vi.fn(),
41+
readDeployedState: vi.fn(),
42+
writeDeployedState: vi.fn(),
43+
};
44+
return {
45+
mockFindConfigRoot: vi.fn(),
46+
mockConfigIOInstance: inst,
47+
MockConfigIOClass: vi.fn(function (this: any) {
48+
Object.assign(this, inst);
49+
return this;
50+
}),
51+
mockValidateAwsCredentials: vi.fn(),
52+
mockBuildCdkProject: vi.fn(),
53+
mockSynthesizeCdk: vi.fn(),
54+
mockSetupPythonProject: vi.fn(),
55+
mockExecutePhase1: vi.fn(),
56+
mockGetDeployedTemplate: vi.fn(),
57+
mockExecutePhase2: vi.fn(),
58+
mockCheckBootstrapNeeded: vi.fn(),
59+
mockBootstrapEnvironment: vi.fn(),
60+
mockPublishCdkAssets: vi.fn(),
61+
mockParseStarterToolkitYaml: vi.fn(),
62+
mockExistsSync: vi.fn(),
63+
mockMkdirSync: vi.fn(),
64+
mockCopyFileSync: vi.fn(),
65+
mockReaddirSync: vi.fn(),
66+
mockReadFileSync: vi.fn(),
67+
mockWriteFileSync: vi.fn(),
68+
};
69+
});
70+
71+
// ── Module mocks ─────────────────────────────────────────────────────────────
72+
73+
vi.mock('../../../../lib', () => ({
74+
APP_DIR: 'app',
75+
ConfigIO: MockConfigIOClass,
76+
findConfigRoot: (...args: unknown[]) => mockFindConfigRoot(...args),
77+
}));
78+
79+
vi.mock('../../../aws/account', () => ({
80+
validateAwsCredentials: (...args: unknown[]) => mockValidateAwsCredentials(...args),
81+
}));
82+
83+
vi.mock('../../../operations/deploy', () => ({
84+
buildCdkProject: (...args: unknown[]) => mockBuildCdkProject(...args),
85+
synthesizeCdk: (...args: unknown[]) => mockSynthesizeCdk(...args),
86+
checkBootstrapNeeded: (...args: unknown[]) => mockCheckBootstrapNeeded(...args),
87+
bootstrapEnvironment: (...args: unknown[]) => mockBootstrapEnvironment(...args),
88+
}));
89+
90+
vi.mock('../../../cdk/local-cdk-project', () => ({
91+
LocalCdkProject: vi.fn(),
92+
}));
93+
94+
vi.mock('../../../cdk/toolkit-lib', () => ({
95+
silentIoHost: {},
96+
}));
97+
98+
vi.mock('../../../logging', () => ({
99+
ExecLogger: class MockExecLogger {
100+
startStep = vi.fn();
101+
endStep = vi.fn();
102+
log = vi.fn();
103+
finalize = vi.fn();
104+
getRelativeLogPath = vi.fn().mockReturnValue('agentcore/.cli/logs/import/import-mock.log');
105+
logFilePath = 'agentcore/.cli/logs/import/import-mock.log';
106+
},
107+
}));
108+
109+
vi.mock('../../../operations/python/setup', () => ({
110+
setupPythonProject: (...args: unknown[]) => mockSetupPythonProject(...args),
111+
}));
112+
113+
vi.mock('../phase1-update', () => ({
114+
executePhase1: (...args: unknown[]) => mockExecutePhase1(...args),
115+
getDeployedTemplate: (...args: unknown[]) => mockGetDeployedTemplate(...args),
116+
}));
117+
118+
vi.mock('../phase2-import', () => ({
119+
executePhase2: (...args: unknown[]) => mockExecutePhase2(...args),
120+
publishCdkAssets: (...args: unknown[]) => mockPublishCdkAssets(...args),
121+
}));
122+
123+
vi.mock('../yaml-parser', () => ({
124+
parseStarterToolkitYaml: (...args: unknown[]) => mockParseStarterToolkitYaml(...args),
125+
}));
126+
127+
vi.mock('node:fs', () => ({
128+
existsSync: (...args: unknown[]) => mockExistsSync(...args),
129+
mkdirSync: (...args: unknown[]) => mockMkdirSync(...args),
130+
copyFileSync: (...args: unknown[]) => mockCopyFileSync(...args),
131+
readdirSync: (...args: unknown[]) => mockReaddirSync(...args),
132+
readFileSync: (...args: unknown[]) => mockReadFileSync(...args),
133+
writeFileSync: (...args: unknown[]) => mockWriteFileSync(...args),
134+
}));
135+
136+
// ── Test Fixtures ────────────────────────────────────────────────────────────
137+
138+
function makeParsedConfig(overrides?: Partial<ParsedStarterToolkitConfig>): ParsedStarterToolkitConfig {
139+
return {
140+
defaultAgent: 'my-agent',
141+
agents: [
142+
{
143+
name: 'my-agent',
144+
entrypoint: 'main.py',
145+
build: 'CodeZip' as const,
146+
runtimeVersion: 'PYTHON_3_12',
147+
language: 'python' as const,
148+
sourcePath: '/tmp/src/my-agent',
149+
networkMode: 'PUBLIC' as const,
150+
protocol: 'HTTP' as const,
151+
enableOtel: true,
152+
physicalAgentId: 'rt-abc123',
153+
physicalAgentArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/rt-abc123',
154+
},
155+
],
156+
memories: [],
157+
credentials: [],
158+
awsTarget: { account: '123456789012', region: 'us-east-1' },
159+
...overrides,
160+
};
161+
}
162+
163+
function makeProjectSpec() {
164+
return {
165+
name: 'TestProject',
166+
version: 1,
167+
agents: [],
168+
memories: [],
169+
credentials: [],
170+
};
171+
}
172+
173+
const synthTemplate = {
174+
AWSTemplateFormatVersion: '2010-09-09',
175+
Resources: {
176+
MyAgentRuntime: {
177+
Type: 'AWS::BedrockAgentCore::Runtime',
178+
Properties: { AgentRuntimeName: 'TestProject_my-agent' },
179+
},
180+
MyRole: {
181+
Type: 'AWS::IAM::Role',
182+
Properties: { RoleName: 'my-role' },
183+
},
184+
},
185+
};
186+
187+
const deployedTemplate = {
188+
AWSTemplateFormatVersion: '2010-09-09',
189+
Resources: {
190+
MyRole: {
191+
Type: 'AWS::IAM::Role',
192+
Properties: { RoleName: 'my-role' },
193+
},
194+
},
195+
};
196+
197+
// ── Common setup ─────────────────────────────────────────────────────────────
198+
199+
function setupCommonMocks() {
200+
mockFindConfigRoot.mockReturnValue('/tmp/project/agentcore');
201+
202+
mockConfigIOInstance.readAWSDeploymentTargets.mockResolvedValue([
203+
{ name: 'default', account: '123456789012', region: 'us-east-1' },
204+
]);
205+
206+
mockValidateAwsCredentials.mockResolvedValue(undefined);
207+
mockSetupPythonProject.mockResolvedValue({ status: 'success' });
208+
209+
mockExistsSync.mockReturnValue(true);
210+
mockReaddirSync.mockReturnValue([]);
211+
mockReadFileSync.mockReturnValue(JSON.stringify(synthTemplate));
212+
213+
mockCheckBootstrapNeeded.mockResolvedValue({ needsBootstrap: false });
214+
mockBootstrapEnvironment.mockResolvedValue(undefined);
215+
mockBuildCdkProject.mockResolvedValue(undefined);
216+
mockSynthesizeCdk.mockResolvedValue({
217+
toolkitWrapper: {
218+
synth: vi.fn().mockResolvedValue({ assemblyDirectory: '/tmp/cdk.out' }),
219+
dispose: vi.fn(),
220+
},
221+
});
222+
223+
mockExecutePhase1.mockResolvedValue({ success: true, stackExists: true });
224+
mockGetDeployedTemplate.mockResolvedValue(deployedTemplate);
225+
mockExecutePhase2.mockResolvedValue({ success: true });
226+
mockPublishCdkAssets.mockResolvedValue(undefined);
227+
228+
mockConfigIOInstance.readDeployedState.mockResolvedValue({ targets: {} });
229+
mockConfigIOInstance.writeDeployedState.mockResolvedValue(undefined);
230+
mockConfigIOInstance.writeProjectSpec.mockResolvedValue(undefined);
231+
}
232+
233+
// ── Tests ────────────────────────────────────────────────────────────────────
234+
235+
describe('Config Rollback on Import Failure', () => {
236+
beforeEach(() => {
237+
vi.clearAllMocks();
238+
setupCommonMocks();
239+
});
240+
241+
afterEach(() => {
242+
vi.restoreAllMocks();
243+
});
244+
245+
it('rolls back config when Phase 1 fails', async () => {
246+
mockConfigIOInstance.readProjectSpec.mockResolvedValue(makeProjectSpec());
247+
mockParseStarterToolkitYaml.mockReturnValue(makeParsedConfig());
248+
mockExecutePhase1.mockResolvedValue({ success: false, error: 'stack update failed' });
249+
250+
const result = await handleImport({ source: '/tmp/config.yaml' });
251+
252+
expect(result.success).toBe(false);
253+
expect(result.error).toContain('Phase 1 failed');
254+
255+
// First call = merge write, second call = rollback with original (empty) agents
256+
expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2);
257+
const rollbackData = mockConfigIOInstance.writeProjectSpec.mock.calls[1]![0];
258+
expect(rollbackData.agents).toEqual([]);
259+
});
260+
261+
it('rolls back config when Phase 2 fails', async () => {
262+
mockConfigIOInstance.readProjectSpec.mockResolvedValue(makeProjectSpec());
263+
mockParseStarterToolkitYaml.mockReturnValue(makeParsedConfig());
264+
mockExecutePhase2.mockResolvedValue({ success: false, error: 'import changeset failed' });
265+
266+
const result = await handleImport({ source: '/tmp/config.yaml' });
267+
268+
expect(result.success).toBe(false);
269+
expect(result.error).toContain('Phase 2 failed');
270+
271+
expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2);
272+
const rollbackData = mockConfigIOInstance.writeProjectSpec.mock.calls[1]![0];
273+
expect(rollbackData.agents).toEqual([]);
274+
});
275+
276+
it('rolls back config when CDK build throws', async () => {
277+
mockConfigIOInstance.readProjectSpec.mockResolvedValue(makeProjectSpec());
278+
mockParseStarterToolkitYaml.mockReturnValue(makeParsedConfig());
279+
mockBuildCdkProject.mockRejectedValue(new Error('CDK build failed'));
280+
281+
const result = await handleImport({ source: '/tmp/config.yaml' });
282+
283+
expect(result.success).toBe(false);
284+
expect(result.error).toContain('CDK build failed');
285+
286+
expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2);
287+
const rollbackData = mockConfigIOInstance.writeProjectSpec.mock.calls[1]![0];
288+
expect(rollbackData.agents).toEqual([]);
289+
});
290+
291+
it('does not rollback on successful import', async () => {
292+
mockConfigIOInstance.readProjectSpec.mockResolvedValue(makeProjectSpec());
293+
mockParseStarterToolkitYaml.mockReturnValue(makeParsedConfig());
294+
295+
const result = await handleImport({ source: '/tmp/config.yaml' });
296+
297+
expect(result.success).toBe(true);
298+
// Only one write: the merge write
299+
expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(1);
300+
});
301+
302+
it('does not rollback on early validation failure (no agents in YAML)', async () => {
303+
mockConfigIOInstance.readProjectSpec.mockResolvedValue(makeProjectSpec());
304+
mockParseStarterToolkitYaml.mockReturnValue({
305+
defaultAgent: '',
306+
agents: [],
307+
memories: [],
308+
credentials: [],
309+
awsTarget: {},
310+
});
311+
312+
const result = await handleImport({ source: '/tmp/config.yaml' });
313+
314+
expect(result.success).toBe(false);
315+
expect(result.error).toContain('No agents found');
316+
// Config was never written, so no rollback
317+
expect(mockConfigIOInstance.writeProjectSpec).not.toHaveBeenCalled();
318+
});
319+
320+
it('emits progress message during rollback', async () => {
321+
mockConfigIOInstance.readProjectSpec.mockResolvedValue(makeProjectSpec());
322+
mockParseStarterToolkitYaml.mockReturnValue(makeParsedConfig());
323+
mockExecutePhase1.mockResolvedValue({ success: false, error: 'failed' });
324+
325+
const progressMessages: string[] = [];
326+
await handleImport({
327+
source: '/tmp/config.yaml',
328+
onProgress: msg => progressMessages.push(msg),
329+
});
330+
331+
expect(progressMessages.some(m => m.includes('Rolling back config changes'))).toBe(true);
332+
});
333+
334+
it('rolls back config when getDeployedTemplate returns null', async () => {
335+
mockConfigIOInstance.readProjectSpec.mockResolvedValue(makeProjectSpec());
336+
mockParseStarterToolkitYaml.mockReturnValue(makeParsedConfig());
337+
mockGetDeployedTemplate.mockResolvedValue(null);
338+
339+
const result = await handleImport({ source: '/tmp/config.yaml' });
340+
341+
expect(result.success).toBe(false);
342+
expect(result.error).toContain('Could not read deployed template');
343+
344+
expect(mockConfigIOInstance.writeProjectSpec).toHaveBeenCalledTimes(2);
345+
const rollbackData = mockConfigIOInstance.writeProjectSpec.mock.calls[1]![0];
346+
expect(rollbackData.agents).toEqual([]);
347+
});
348+
});

0 commit comments

Comments
 (0)