@friggframework/devtools 2.0.0--canary.474.6a0bba7.0 → 2.0.0--canary.474.ca45ad3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/infrastructure/domains/health/application/use-cases/__tests__/execute-resource-import-use-case.test.js +679 -0
- package/infrastructure/domains/health/application/use-cases/execute-resource-import-use-case.js +221 -0
- package/infrastructure/domains/health/domain/services/__tests__/import-progress-monitor.test.js +971 -0
- package/infrastructure/domains/health/domain/services/__tests__/import-template-generator.test.js +1150 -0
- package/infrastructure/domains/health/domain/services/__tests__/update-progress-monitor.test.js +419 -0
- package/infrastructure/domains/health/domain/services/import-progress-monitor.js +195 -0
- package/infrastructure/domains/health/domain/services/import-template-generator.js +435 -0
- package/infrastructure/domains/health/domain/services/property-mutability-config.js +382 -0
- package/infrastructure/domains/health/domain/services/update-progress-monitor.js +192 -0
- package/package.json +6 -6
package/infrastructure/domains/health/domain/services/__tests__/update-progress-monitor.test.js
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UpdateProgressMonitor Tests
|
|
3
|
+
*
|
|
4
|
+
* TDD tests for monitoring CloudFormation UPDATE operations
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { UpdateProgressMonitor } = require('../update-progress-monitor');
|
|
8
|
+
const StackIdentifier = require('../../../domain/value-objects/stack-identifier');
|
|
9
|
+
|
|
10
|
+
// Mock timers for testing delays and timeouts
|
|
11
|
+
jest.useFakeTimers();
|
|
12
|
+
|
|
13
|
+
describe('UpdateProgressMonitor', () => {
|
|
14
|
+
let monitor;
|
|
15
|
+
let mockCFRepo;
|
|
16
|
+
let onProgressCallback;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
// Reset mock CloudFormation repository
|
|
20
|
+
mockCFRepo = {
|
|
21
|
+
getStackEvents: jest.fn(),
|
|
22
|
+
getStackStatus: jest.fn(),
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Reset progress callback
|
|
26
|
+
onProgressCallback = jest.fn();
|
|
27
|
+
|
|
28
|
+
// Create monitor instance
|
|
29
|
+
monitor = new UpdateProgressMonitor({
|
|
30
|
+
cloudFormationRepository: mockCFRepo,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Clear all timers
|
|
34
|
+
jest.clearAllTimers();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
jest.clearAllTimers();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('constructor', () => {
|
|
42
|
+
it('should require cloudFormationRepository', () => {
|
|
43
|
+
expect(() => new UpdateProgressMonitor({})).toThrow(
|
|
44
|
+
'cloudFormationRepository is required'
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should create instance with valid dependencies', () => {
|
|
49
|
+
const monitor = new UpdateProgressMonitor({
|
|
50
|
+
cloudFormationRepository: mockCFRepo,
|
|
51
|
+
});
|
|
52
|
+
expect(monitor).toBeInstanceOf(UpdateProgressMonitor);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('monitorUpdate - successful update', () => {
|
|
57
|
+
it('should monitor single resource update to completion', async () => {
|
|
58
|
+
const stackIdentifier = new StackIdentifier({
|
|
59
|
+
stackName: 'test-stack',
|
|
60
|
+
region: 'us-east-1',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const resourceLogicalIds = ['AttioLambdaFunction'];
|
|
64
|
+
|
|
65
|
+
// Mock stack events sequence
|
|
66
|
+
mockCFRepo.getStackEvents
|
|
67
|
+
// First poll: UPDATE_IN_PROGRESS
|
|
68
|
+
.mockResolvedValueOnce([
|
|
69
|
+
{
|
|
70
|
+
LogicalResourceId: 'AttioLambdaFunction',
|
|
71
|
+
ResourceStatus: 'UPDATE_IN_PROGRESS',
|
|
72
|
+
Timestamp: new Date('2025-01-01T00:00:01Z'),
|
|
73
|
+
},
|
|
74
|
+
])
|
|
75
|
+
// Second poll: UPDATE_COMPLETE
|
|
76
|
+
.mockResolvedValueOnce([
|
|
77
|
+
{
|
|
78
|
+
LogicalResourceId: 'AttioLambdaFunction',
|
|
79
|
+
ResourceStatus: 'UPDATE_IN_PROGRESS',
|
|
80
|
+
Timestamp: new Date('2025-01-01T00:00:01Z'),
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
LogicalResourceId: 'AttioLambdaFunction',
|
|
84
|
+
ResourceStatus: 'UPDATE_COMPLETE',
|
|
85
|
+
Timestamp: new Date('2025-01-01T00:00:05Z'),
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
|
|
89
|
+
// Mock stack status
|
|
90
|
+
mockCFRepo.getStackStatus.mockResolvedValue('UPDATE_COMPLETE');
|
|
91
|
+
|
|
92
|
+
// Start monitoring in background
|
|
93
|
+
const monitorPromise = monitor.monitorUpdate({
|
|
94
|
+
stackIdentifier,
|
|
95
|
+
resourceLogicalIds,
|
|
96
|
+
onProgress: onProgressCallback,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Advance timers to trigger first poll (2 seconds)
|
|
100
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
101
|
+
|
|
102
|
+
// Advance timers to trigger second poll (2 more seconds)
|
|
103
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
104
|
+
|
|
105
|
+
// Wait for monitoring to complete
|
|
106
|
+
const result = await monitorPromise;
|
|
107
|
+
|
|
108
|
+
// Verify result
|
|
109
|
+
expect(result.success).toBe(true);
|
|
110
|
+
expect(result.updatedCount).toBe(1);
|
|
111
|
+
expect(result.failedCount).toBe(0);
|
|
112
|
+
|
|
113
|
+
// Verify progress callbacks
|
|
114
|
+
expect(onProgressCallback).toHaveBeenCalledWith({
|
|
115
|
+
logicalId: 'AttioLambdaFunction',
|
|
116
|
+
status: 'IN_PROGRESS',
|
|
117
|
+
});
|
|
118
|
+
expect(onProgressCallback).toHaveBeenCalledWith({
|
|
119
|
+
logicalId: 'AttioLambdaFunction',
|
|
120
|
+
status: 'COMPLETE',
|
|
121
|
+
progress: 1,
|
|
122
|
+
total: 1,
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should monitor multiple resources updating simultaneously', async () => {
|
|
127
|
+
const stackIdentifier = new StackIdentifier({
|
|
128
|
+
stackName: 'test-stack',
|
|
129
|
+
region: 'us-east-1',
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const resourceLogicalIds = [
|
|
133
|
+
'AttioLambdaFunction',
|
|
134
|
+
'PipedriveLambdaFunction',
|
|
135
|
+
'ZohoCrmLambdaFunction',
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
// Mock stack events - all resources update together
|
|
139
|
+
mockCFRepo.getStackEvents
|
|
140
|
+
// First poll: All IN_PROGRESS
|
|
141
|
+
.mockResolvedValueOnce([
|
|
142
|
+
{
|
|
143
|
+
LogicalResourceId: 'AttioLambdaFunction',
|
|
144
|
+
ResourceStatus: 'UPDATE_IN_PROGRESS',
|
|
145
|
+
Timestamp: new Date('2025-01-01T00:00:01Z'),
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
LogicalResourceId: 'PipedriveLambdaFunction',
|
|
149
|
+
ResourceStatus: 'UPDATE_IN_PROGRESS',
|
|
150
|
+
Timestamp: new Date('2025-01-01T00:00:02Z'),
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
LogicalResourceId: 'ZohoCrmLambdaFunction',
|
|
154
|
+
ResourceStatus: 'UPDATE_IN_PROGRESS',
|
|
155
|
+
Timestamp: new Date('2025-01-01T00:00:03Z'),
|
|
156
|
+
},
|
|
157
|
+
])
|
|
158
|
+
// Second poll: First complete
|
|
159
|
+
.mockResolvedValueOnce([
|
|
160
|
+
{
|
|
161
|
+
LogicalResourceId: 'AttioLambdaFunction',
|
|
162
|
+
ResourceStatus: 'UPDATE_IN_PROGRESS',
|
|
163
|
+
Timestamp: new Date('2025-01-01T00:00:01Z'),
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
LogicalResourceId: 'AttioLambdaFunction',
|
|
167
|
+
ResourceStatus: 'UPDATE_COMPLETE',
|
|
168
|
+
Timestamp: new Date('2025-01-01T00:00:10Z'),
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
LogicalResourceId: 'PipedriveLambdaFunction',
|
|
172
|
+
ResourceStatus: 'UPDATE_IN_PROGRESS',
|
|
173
|
+
Timestamp: new Date('2025-01-01T00:00:02Z'),
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
LogicalResourceId: 'ZohoCrmLambdaFunction',
|
|
177
|
+
ResourceStatus: 'UPDATE_IN_PROGRESS',
|
|
178
|
+
Timestamp: new Date('2025-01-01T00:00:03Z'),
|
|
179
|
+
},
|
|
180
|
+
])
|
|
181
|
+
// Third poll: All complete
|
|
182
|
+
.mockResolvedValueOnce([
|
|
183
|
+
{
|
|
184
|
+
LogicalResourceId: 'AttioLambdaFunction',
|
|
185
|
+
ResourceStatus: 'UPDATE_COMPLETE',
|
|
186
|
+
Timestamp: new Date('2025-01-01T00:00:10Z'),
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
LogicalResourceId: 'PipedriveLambdaFunction',
|
|
190
|
+
ResourceStatus: 'UPDATE_COMPLETE',
|
|
191
|
+
Timestamp: new Date('2025-01-01T00:00:11Z'),
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
LogicalResourceId: 'ZohoCrmLambdaFunction',
|
|
195
|
+
ResourceStatus: 'UPDATE_COMPLETE',
|
|
196
|
+
Timestamp: new Date('2025-01-01T00:00:12Z'),
|
|
197
|
+
},
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
mockCFRepo.getStackStatus.mockResolvedValue('UPDATE_COMPLETE');
|
|
201
|
+
|
|
202
|
+
const monitorPromise = monitor.monitorUpdate({
|
|
203
|
+
stackIdentifier,
|
|
204
|
+
resourceLogicalIds,
|
|
205
|
+
onProgress: onProgressCallback,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Advance through polling intervals
|
|
209
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
210
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
211
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
212
|
+
|
|
213
|
+
const result = await monitorPromise;
|
|
214
|
+
|
|
215
|
+
expect(result.success).toBe(true);
|
|
216
|
+
expect(result.updatedCount).toBe(3);
|
|
217
|
+
expect(result.failedCount).toBe(0);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('monitorUpdate - failed updates', () => {
|
|
222
|
+
it('should detect and report UPDATE_FAILED resources', async () => {
|
|
223
|
+
const stackIdentifier = new StackIdentifier({
|
|
224
|
+
stackName: 'test-stack',
|
|
225
|
+
region: 'us-east-1',
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
const resourceLogicalIds = ['AttioLambdaFunction'];
|
|
229
|
+
|
|
230
|
+
mockCFRepo.getStackEvents
|
|
231
|
+
.mockResolvedValueOnce([
|
|
232
|
+
{
|
|
233
|
+
LogicalResourceId: 'AttioLambdaFunction',
|
|
234
|
+
ResourceStatus: 'UPDATE_IN_PROGRESS',
|
|
235
|
+
Timestamp: new Date('2025-01-01T00:00:01Z'),
|
|
236
|
+
},
|
|
237
|
+
])
|
|
238
|
+
.mockResolvedValueOnce([
|
|
239
|
+
{
|
|
240
|
+
LogicalResourceId: 'AttioLambdaFunction',
|
|
241
|
+
ResourceStatus: 'UPDATE_IN_PROGRESS',
|
|
242
|
+
Timestamp: new Date('2025-01-01T00:00:01Z'),
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
LogicalResourceId: 'AttioLambdaFunction',
|
|
246
|
+
ResourceStatus: 'UPDATE_FAILED',
|
|
247
|
+
ResourceStatusReason: 'Subnet does not exist: subnet-invalid',
|
|
248
|
+
Timestamp: new Date('2025-01-01T00:00:05Z'),
|
|
249
|
+
},
|
|
250
|
+
]);
|
|
251
|
+
|
|
252
|
+
mockCFRepo.getStackStatus.mockResolvedValue('UPDATE_ROLLBACK_COMPLETE');
|
|
253
|
+
|
|
254
|
+
const monitorPromise = monitor.monitorUpdate({
|
|
255
|
+
stackIdentifier,
|
|
256
|
+
resourceLogicalIds,
|
|
257
|
+
onProgress: onProgressCallback,
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
261
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
262
|
+
|
|
263
|
+
const result = await monitorPromise;
|
|
264
|
+
|
|
265
|
+
expect(result.success).toBe(false);
|
|
266
|
+
expect(result.updatedCount).toBe(0);
|
|
267
|
+
expect(result.failedCount).toBe(1);
|
|
268
|
+
expect(result.failedResources).toHaveLength(1);
|
|
269
|
+
expect(result.failedResources[0].logicalId).toBe('AttioLambdaFunction');
|
|
270
|
+
expect(result.failedResources[0].reason).toBe('Subnet does not exist: subnet-invalid');
|
|
271
|
+
|
|
272
|
+
// Verify FAILED callback was triggered
|
|
273
|
+
expect(onProgressCallback).toHaveBeenCalledWith({
|
|
274
|
+
logicalId: 'AttioLambdaFunction',
|
|
275
|
+
status: 'FAILED',
|
|
276
|
+
reason: 'Subnet does not exist: subnet-invalid',
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it('should detect stack rollback during update', async () => {
|
|
281
|
+
const stackIdentifier = new StackIdentifier({
|
|
282
|
+
stackName: 'test-stack',
|
|
283
|
+
region: 'us-east-1',
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const resourceLogicalIds = ['AttioLambdaFunction'];
|
|
287
|
+
|
|
288
|
+
mockCFRepo.getStackEvents.mockResolvedValue([
|
|
289
|
+
{
|
|
290
|
+
LogicalResourceId: 'AttioLambdaFunction',
|
|
291
|
+
ResourceStatus: 'UPDATE_IN_PROGRESS',
|
|
292
|
+
Timestamp: new Date('2025-01-01T00:00:01Z'),
|
|
293
|
+
},
|
|
294
|
+
]);
|
|
295
|
+
|
|
296
|
+
// Stack status shows rollback in progress
|
|
297
|
+
mockCFRepo.getStackStatus.mockResolvedValue('UPDATE_ROLLBACK_IN_PROGRESS');
|
|
298
|
+
|
|
299
|
+
const monitorPromise = monitor.monitorUpdate({
|
|
300
|
+
stackIdentifier,
|
|
301
|
+
resourceLogicalIds,
|
|
302
|
+
onProgress: onProgressCallback,
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
306
|
+
|
|
307
|
+
await expect(monitorPromise).rejects.toThrow('Update operation failed and rolled back');
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
describe('monitorUpdate - timeout handling', () => {
|
|
312
|
+
it('should timeout after 5 minutes', async () => {
|
|
313
|
+
const stackIdentifier = new StackIdentifier({
|
|
314
|
+
stackName: 'test-stack',
|
|
315
|
+
region: 'us-east-1',
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
const resourceLogicalIds = ['AttioLambdaFunction'];
|
|
319
|
+
|
|
320
|
+
// Mock events that never complete
|
|
321
|
+
mockCFRepo.getStackEvents.mockResolvedValue([
|
|
322
|
+
{
|
|
323
|
+
LogicalResourceId: 'AttioLambdaFunction',
|
|
324
|
+
ResourceStatus: 'UPDATE_IN_PROGRESS',
|
|
325
|
+
Timestamp: new Date('2025-01-01T00:00:01Z'),
|
|
326
|
+
},
|
|
327
|
+
]);
|
|
328
|
+
|
|
329
|
+
mockCFRepo.getStackStatus.mockResolvedValue('UPDATE_IN_PROGRESS');
|
|
330
|
+
|
|
331
|
+
const monitorPromise = monitor.monitorUpdate({
|
|
332
|
+
stackIdentifier,
|
|
333
|
+
resourceLogicalIds,
|
|
334
|
+
onProgress: onProgressCallback,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// Advance past 5 minute timeout (300000ms)
|
|
338
|
+
await jest.advanceTimersByTimeAsync(300000 + 2000);
|
|
339
|
+
|
|
340
|
+
await expect(monitorPromise).rejects.toThrow('Update operation timed out');
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe('monitorUpdate - event deduplication', () => {
|
|
345
|
+
it('should not process duplicate events', async () => {
|
|
346
|
+
const stackIdentifier = new StackIdentifier({
|
|
347
|
+
stackName: 'test-stack',
|
|
348
|
+
region: 'us-east-1',
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const resourceLogicalIds = ['AttioLambdaFunction'];
|
|
352
|
+
|
|
353
|
+
// Mock duplicate events (same timestamp + logicalId + status)
|
|
354
|
+
mockCFRepo.getStackEvents
|
|
355
|
+
.mockResolvedValueOnce([
|
|
356
|
+
{
|
|
357
|
+
LogicalResourceId: 'AttioLambdaFunction',
|
|
358
|
+
ResourceStatus: 'UPDATE_IN_PROGRESS',
|
|
359
|
+
Timestamp: new Date('2025-01-01T00:00:01Z'),
|
|
360
|
+
},
|
|
361
|
+
])
|
|
362
|
+
.mockResolvedValueOnce([
|
|
363
|
+
// Duplicate event
|
|
364
|
+
{
|
|
365
|
+
LogicalResourceId: 'AttioLambdaFunction',
|
|
366
|
+
ResourceStatus: 'UPDATE_IN_PROGRESS',
|
|
367
|
+
Timestamp: new Date('2025-01-01T00:00:01Z'),
|
|
368
|
+
},
|
|
369
|
+
// New event
|
|
370
|
+
{
|
|
371
|
+
LogicalResourceId: 'AttioLambdaFunction',
|
|
372
|
+
ResourceStatus: 'UPDATE_COMPLETE',
|
|
373
|
+
Timestamp: new Date('2025-01-01T00:00:05Z'),
|
|
374
|
+
},
|
|
375
|
+
]);
|
|
376
|
+
|
|
377
|
+
mockCFRepo.getStackStatus.mockResolvedValue('UPDATE_COMPLETE');
|
|
378
|
+
|
|
379
|
+
const monitorPromise = monitor.monitorUpdate({
|
|
380
|
+
stackIdentifier,
|
|
381
|
+
resourceLogicalIds,
|
|
382
|
+
onProgress: onProgressCallback,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
386
|
+
await jest.advanceTimersByTimeAsync(2000);
|
|
387
|
+
|
|
388
|
+
await monitorPromise;
|
|
389
|
+
|
|
390
|
+
// IN_PROGRESS callback should only be called once (not twice for duplicate)
|
|
391
|
+
const inProgressCalls = onProgressCallback.mock.calls.filter(
|
|
392
|
+
(call) => call[0].status === 'IN_PROGRESS'
|
|
393
|
+
);
|
|
394
|
+
expect(inProgressCalls).toHaveLength(1);
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
describe('monitorUpdate - no resources to track', () => {
|
|
399
|
+
it('should return immediately if no resources to track', async () => {
|
|
400
|
+
const stackIdentifier = new StackIdentifier({
|
|
401
|
+
stackName: 'test-stack',
|
|
402
|
+
region: 'us-east-1',
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const result = await monitor.monitorUpdate({
|
|
406
|
+
stackIdentifier,
|
|
407
|
+
resourceLogicalIds: [],
|
|
408
|
+
onProgress: onProgressCallback,
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
expect(result.success).toBe(true);
|
|
412
|
+
expect(result.updatedCount).toBe(0);
|
|
413
|
+
expect(result.failedCount).toBe(0);
|
|
414
|
+
|
|
415
|
+
// Should not have polled CloudFormation
|
|
416
|
+
expect(mockCFRepo.getStackEvents).not.toHaveBeenCalled();
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ImportProgressMonitor - Monitor CloudFormation Import Operation Progress
|
|
3
|
+
*
|
|
4
|
+
* Domain Layer - Service
|
|
5
|
+
*
|
|
6
|
+
* Monitors CloudFormation import operations by polling stack events and tracking
|
|
7
|
+
* resource import progress. Provides real-time progress callbacks and detects
|
|
8
|
+
* failures, rollbacks, and timeouts.
|
|
9
|
+
*
|
|
10
|
+
* Responsibilities:
|
|
11
|
+
* - Poll CloudFormation stack events during import
|
|
12
|
+
* - Track progress per resource (IN_PROGRESS, COMPLETE, FAILED)
|
|
13
|
+
* - Detect stack rollback states
|
|
14
|
+
* - Timeout after 5 minutes
|
|
15
|
+
* - Provide progress callbacks for UI updates
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
class ImportProgressMonitor {
|
|
19
|
+
/**
|
|
20
|
+
* Create progress monitor with CloudFormation repository dependency
|
|
21
|
+
*
|
|
22
|
+
* @param {Object} params
|
|
23
|
+
* @param {Object} params.cloudFormationRepository - CloudFormation operations
|
|
24
|
+
*/
|
|
25
|
+
constructor({ cloudFormationRepository }) {
|
|
26
|
+
if (!cloudFormationRepository) {
|
|
27
|
+
throw new Error('cloudFormationRepository is required');
|
|
28
|
+
}
|
|
29
|
+
this.cfRepo = cloudFormationRepository;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Monitor import operation progress
|
|
34
|
+
*
|
|
35
|
+
* Polls CloudFormation stack events every 2 seconds to track resource import progress.
|
|
36
|
+
* Calls onProgress callback with status updates for each resource.
|
|
37
|
+
* Detects failures, rollbacks, and timeouts.
|
|
38
|
+
*
|
|
39
|
+
* @param {Object} params
|
|
40
|
+
* @param {Object} params.stackIdentifier - Stack identifier { stackName, region }
|
|
41
|
+
* @param {Array<string>} params.resourceLogicalIds - Logical IDs to track
|
|
42
|
+
* @param {Function} params.onProgress - Progress callback function
|
|
43
|
+
* @returns {Promise<Object>} Import result
|
|
44
|
+
*/
|
|
45
|
+
async monitorImport({ stackIdentifier, resourceLogicalIds, onProgress }) {
|
|
46
|
+
const importedResources = new Set();
|
|
47
|
+
const failedResources = [];
|
|
48
|
+
const processedEvents = new Set(); // Track processed events by timestamp + logicalId
|
|
49
|
+
let elapsedTime = 0; // Track elapsed time manually for fake timers compatibility
|
|
50
|
+
const TIMEOUT_MS = 300000; // 5 minutes
|
|
51
|
+
const POLL_INTERVAL_MS = 2000; // 2 seconds
|
|
52
|
+
|
|
53
|
+
// Continue polling until all resources are complete or failed
|
|
54
|
+
while (
|
|
55
|
+
importedResources.size + failedResources.length <
|
|
56
|
+
resourceLogicalIds.length
|
|
57
|
+
) {
|
|
58
|
+
// Wait 2 seconds before polling
|
|
59
|
+
await this._delay(POLL_INTERVAL_MS);
|
|
60
|
+
elapsedTime += POLL_INTERVAL_MS;
|
|
61
|
+
|
|
62
|
+
// Check for timeout
|
|
63
|
+
if (elapsedTime > TIMEOUT_MS) {
|
|
64
|
+
throw new Error('Import operation timed out');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Get stack events
|
|
68
|
+
const events = await this.cfRepo.getStackEvents({
|
|
69
|
+
stackIdentifier,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Sort events by timestamp (oldest first) for consistent processing
|
|
73
|
+
const sortedEvents = [...events].sort(
|
|
74
|
+
(a, b) => new Date(a.Timestamp) - new Date(b.Timestamp)
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
// Track if we processed any new events in this iteration
|
|
78
|
+
let processedNewEvents = false;
|
|
79
|
+
|
|
80
|
+
// Process events for tracked resources
|
|
81
|
+
for (const event of sortedEvents) {
|
|
82
|
+
const logicalId = event.LogicalResourceId;
|
|
83
|
+
|
|
84
|
+
// Skip if not a tracked resource
|
|
85
|
+
if (!resourceLogicalIds.includes(logicalId)) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Create unique event key to avoid duplicate processing
|
|
90
|
+
const eventKey = `${event.Timestamp.toISOString()}_${logicalId}_${event.ResourceStatus}`;
|
|
91
|
+
|
|
92
|
+
// Skip if already processed
|
|
93
|
+
if (processedEvents.has(eventKey)) {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
processedEvents.add(eventKey);
|
|
98
|
+
processedNewEvents = true;
|
|
99
|
+
|
|
100
|
+
// Handle different resource statuses
|
|
101
|
+
if (event.ResourceStatus === 'IMPORT_IN_PROGRESS') {
|
|
102
|
+
// Call progress callback with IN_PROGRESS status
|
|
103
|
+
if (onProgress) {
|
|
104
|
+
onProgress({
|
|
105
|
+
logicalId,
|
|
106
|
+
status: 'IN_PROGRESS',
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
} else if (event.ResourceStatus === 'IMPORT_COMPLETE' || event.ResourceStatus === 'UPDATE_COMPLETE') {
|
|
110
|
+
// Mark resource as imported
|
|
111
|
+
// Note: CloudFormation sends IMPORT_COMPLETE then UPDATE_COMPLETE (for tagging)
|
|
112
|
+
// We count either as successfully imported
|
|
113
|
+
importedResources.add(logicalId);
|
|
114
|
+
|
|
115
|
+
// Call progress callback with COMPLETE status
|
|
116
|
+
if (onProgress) {
|
|
117
|
+
onProgress({
|
|
118
|
+
logicalId,
|
|
119
|
+
status: 'COMPLETE',
|
|
120
|
+
progress: importedResources.size,
|
|
121
|
+
total: resourceLogicalIds.length,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
} else if (event.ResourceStatus === 'IMPORT_FAILED') {
|
|
125
|
+
// Add to failed resources
|
|
126
|
+
const reason = event.ResourceStatusReason || 'Unknown error';
|
|
127
|
+
failedResources.push({
|
|
128
|
+
logicalId,
|
|
129
|
+
reason,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Call progress callback with FAILED status
|
|
133
|
+
if (onProgress) {
|
|
134
|
+
onProgress({
|
|
135
|
+
logicalId,
|
|
136
|
+
status: 'FAILED',
|
|
137
|
+
reason,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check if all resources are now accounted for
|
|
144
|
+
const allResourcesProcessed =
|
|
145
|
+
importedResources.size + failedResources.length >=
|
|
146
|
+
resourceLogicalIds.length;
|
|
147
|
+
|
|
148
|
+
// If all resources processed, exit loop to return result
|
|
149
|
+
if (allResourcesProcessed) {
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Check stack status AFTER processing events - if rollback in progress, throw
|
|
154
|
+
const stackStatus = await this.cfRepo.getStackStatus(stackIdentifier);
|
|
155
|
+
if (stackStatus.includes('ROLLBACK') && stackStatus !== 'IMPORT_ROLLBACK_COMPLETE') {
|
|
156
|
+
throw new Error('Import operation failed and rolled back');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Check final stack status before returning
|
|
161
|
+
const finalStackStatus = await this.cfRepo.getStackStatus(stackIdentifier);
|
|
162
|
+
|
|
163
|
+
// If stack rolled back completely and monitoring just finished,
|
|
164
|
+
// check if we should throw or return
|
|
165
|
+
if (finalStackStatus.includes('ROLLBACK')) {
|
|
166
|
+
// If we have any imported resources, return result (partial success)
|
|
167
|
+
// Otherwise, throw error (complete failure)
|
|
168
|
+
if (importedResources.size === 0 && failedResources.length > 0) {
|
|
169
|
+
throw new Error('Import operation failed and rolled back');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Return result
|
|
174
|
+
const success = failedResources.length === 0;
|
|
175
|
+
return {
|
|
176
|
+
success,
|
|
177
|
+
importedCount: importedResources.size,
|
|
178
|
+
failedCount: failedResources.length,
|
|
179
|
+
failedResources,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Delay helper for polling intervals
|
|
185
|
+
*
|
|
186
|
+
* @param {number} ms - Milliseconds to delay
|
|
187
|
+
* @returns {Promise<void>}
|
|
188
|
+
* @private
|
|
189
|
+
*/
|
|
190
|
+
async _delay(ms) {
|
|
191
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
module.exports = { ImportProgressMonitor };
|