|
| 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