@friggframework/devtools 2.0.0--canary.474.884529c.0 → 2.0.0--canary.474.988ec0b.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.
@@ -0,0 +1,343 @@
1
+ /**
2
+ * Tests for ReconcilePropertiesUseCase
3
+ *
4
+ * Use case for reconciling property drift between CloudFormation template
5
+ * and actual cloud resources (frigg repair --reconcile command)
6
+ */
7
+
8
+ const ReconcilePropertiesUseCase = require('./reconcile-properties-use-case');
9
+ const StackIdentifier = require('../../domain/value-objects/stack-identifier');
10
+ const PropertyMismatch = require('../../domain/entities/property-mismatch');
11
+ const PropertyMutability = require('../../domain/value-objects/property-mutability');
12
+
13
+ describe('ReconcilePropertiesUseCase', () => {
14
+ let useCase;
15
+ let mockPropertyReconciler;
16
+
17
+ beforeEach(() => {
18
+ // Mock property reconciler
19
+ mockPropertyReconciler = {
20
+ canReconcile: jest.fn(),
21
+ reconcileProperty: jest.fn(),
22
+ reconcileMultipleProperties: jest.fn(),
23
+ previewReconciliation: jest.fn(),
24
+ };
25
+
26
+ useCase = new ReconcilePropertiesUseCase({
27
+ propertyReconciler: mockPropertyReconciler,
28
+ });
29
+ });
30
+
31
+ describe('reconcileSingleProperty', () => {
32
+ it('should reconcile a single mutable property mismatch', async () => {
33
+ const stackIdentifier = new StackIdentifier({
34
+ stackName: 'my-app-prod',
35
+ region: 'us-east-1',
36
+ });
37
+
38
+ const mismatch = new PropertyMismatch({
39
+ propertyPath: 'Properties.EnableDnsSupport',
40
+ expectedValue: true,
41
+ actualValue: false,
42
+ mutability: PropertyMutability.MUTABLE,
43
+ });
44
+
45
+ // Mock reconciler can handle this mismatch
46
+ mockPropertyReconciler.canReconcile.mockResolvedValue(true);
47
+
48
+ // Mock reconciliation result
49
+ mockPropertyReconciler.reconcileProperty.mockResolvedValue({
50
+ success: true,
51
+ mode: 'template',
52
+ propertyPath: 'Properties.EnableDnsSupport',
53
+ oldValue: true,
54
+ newValue: false,
55
+ message: 'Template updated to match actual resource state',
56
+ });
57
+
58
+ const result = await useCase.reconcileSingleProperty({
59
+ stackIdentifier,
60
+ logicalId: 'MyVPC',
61
+ mismatch,
62
+ mode: 'template',
63
+ });
64
+
65
+ expect(result.success).toBe(true);
66
+ expect(result.mode).toBe('template');
67
+ expect(result.propertyPath).toBe('Properties.EnableDnsSupport');
68
+ expect(mockPropertyReconciler.canReconcile).toHaveBeenCalledWith(mismatch);
69
+ expect(mockPropertyReconciler.reconcileProperty).toHaveBeenCalled();
70
+ });
71
+
72
+ it('should fail if property cannot be reconciled', async () => {
73
+ const stackIdentifier = new StackIdentifier({
74
+ stackName: 'my-app-prod',
75
+ region: 'us-east-1',
76
+ });
77
+
78
+ const mismatch = new PropertyMismatch({
79
+ propertyPath: 'Properties.CidrBlock',
80
+ expectedValue: '10.0.0.0/16',
81
+ actualValue: '10.1.0.0/16',
82
+ mutability: PropertyMutability.IMMUTABLE,
83
+ });
84
+
85
+ // Mock reconciler cannot handle immutable property
86
+ mockPropertyReconciler.canReconcile.mockResolvedValue(false);
87
+
88
+ await expect(
89
+ useCase.reconcileSingleProperty({
90
+ stackIdentifier,
91
+ logicalId: 'MyVPC',
92
+ mismatch,
93
+ mode: 'template',
94
+ })
95
+ ).rejects.toThrow('Property Properties.CidrBlock cannot be reconciled automatically');
96
+ });
97
+
98
+ it('should use resource mode when specified', async () => {
99
+ const stackIdentifier = new StackIdentifier({
100
+ stackName: 'my-app-prod',
101
+ region: 'us-east-1',
102
+ });
103
+
104
+ const mismatch = new PropertyMismatch({
105
+ propertyPath: 'Properties.EnableDnsSupport',
106
+ expectedValue: true,
107
+ actualValue: false,
108
+ mutability: PropertyMutability.MUTABLE,
109
+ });
110
+
111
+ mockPropertyReconciler.canReconcile.mockResolvedValue(true);
112
+
113
+ mockPropertyReconciler.reconcileProperty.mockResolvedValue({
114
+ success: true,
115
+ mode: 'resource',
116
+ propertyPath: 'Properties.EnableDnsSupport',
117
+ oldValue: false,
118
+ newValue: true,
119
+ message: 'Resource updated to match template definition',
120
+ });
121
+
122
+ const result = await useCase.reconcileSingleProperty({
123
+ stackIdentifier,
124
+ logicalId: 'MyVPC',
125
+ mismatch,
126
+ mode: 'resource',
127
+ });
128
+
129
+ expect(result.success).toBe(true);
130
+ expect(result.mode).toBe('resource');
131
+ expect(mockPropertyReconciler.reconcileProperty).toHaveBeenCalledWith({
132
+ stackIdentifier,
133
+ logicalId: 'MyVPC',
134
+ mismatch,
135
+ mode: 'resource',
136
+ });
137
+ });
138
+ });
139
+
140
+ describe('reconcileMultipleProperties', () => {
141
+ it('should reconcile multiple property mismatches for a resource', async () => {
142
+ const stackIdentifier = new StackIdentifier({
143
+ stackName: 'my-app-prod',
144
+ region: 'us-east-1',
145
+ });
146
+
147
+ const mismatches = [
148
+ new PropertyMismatch({
149
+ propertyPath: 'Properties.EnableDnsSupport',
150
+ expectedValue: true,
151
+ actualValue: false,
152
+ mutability: PropertyMutability.MUTABLE,
153
+ }),
154
+ new PropertyMismatch({
155
+ propertyPath: 'Properties.EnableDnsHostnames',
156
+ expectedValue: true,
157
+ actualValue: false,
158
+ mutability: PropertyMutability.MUTABLE,
159
+ }),
160
+ ];
161
+
162
+ // Mock all mismatches can be reconciled
163
+ mockPropertyReconciler.canReconcile.mockResolvedValue(true);
164
+
165
+ // Mock batch reconciliation
166
+ mockPropertyReconciler.reconcileMultipleProperties.mockResolvedValue({
167
+ reconciledCount: 2,
168
+ failedCount: 0,
169
+ results: [
170
+ {
171
+ success: true,
172
+ mode: 'template',
173
+ propertyPath: 'Properties.EnableDnsSupport',
174
+ oldValue: true,
175
+ newValue: false,
176
+ message: 'Template updated',
177
+ },
178
+ {
179
+ success: true,
180
+ mode: 'template',
181
+ propertyPath: 'Properties.EnableDnsHostnames',
182
+ oldValue: true,
183
+ newValue: false,
184
+ message: 'Template updated',
185
+ },
186
+ ],
187
+ message: 'Reconciled 2 of 2 properties',
188
+ });
189
+
190
+ const result = await useCase.reconcileMultipleProperties({
191
+ stackIdentifier,
192
+ logicalId: 'MyVPC',
193
+ mismatches,
194
+ mode: 'template',
195
+ });
196
+
197
+ expect(result.reconciledCount).toBe(2);
198
+ expect(result.failedCount).toBe(0);
199
+ expect(result.results).toHaveLength(2);
200
+ });
201
+
202
+ it('should skip immutable properties and reconcile mutable ones', async () => {
203
+ const stackIdentifier = new StackIdentifier({
204
+ stackName: 'my-app-prod',
205
+ region: 'us-east-1',
206
+ });
207
+
208
+ const mismatches = [
209
+ new PropertyMismatch({
210
+ propertyPath: 'Properties.EnableDnsSupport',
211
+ expectedValue: true,
212
+ actualValue: false,
213
+ mutability: PropertyMutability.MUTABLE,
214
+ }),
215
+ new PropertyMismatch({
216
+ propertyPath: 'Properties.CidrBlock',
217
+ expectedValue: '10.0.0.0/16',
218
+ actualValue: '10.1.0.0/16',
219
+ mutability: PropertyMutability.IMMUTABLE,
220
+ }),
221
+ ];
222
+
223
+ // Mock first can be reconciled, second cannot
224
+ mockPropertyReconciler.canReconcile
225
+ .mockResolvedValueOnce(true)
226
+ .mockResolvedValueOnce(false);
227
+
228
+ // Mock reconciliation of the mutable property
229
+ mockPropertyReconciler.reconcileMultipleProperties.mockResolvedValue({
230
+ reconciledCount: 1,
231
+ failedCount: 0,
232
+ results: [
233
+ {
234
+ success: true,
235
+ mode: 'template',
236
+ propertyPath: 'Properties.EnableDnsSupport',
237
+ oldValue: true,
238
+ newValue: false,
239
+ message: 'Template updated',
240
+ },
241
+ ],
242
+ message: 'Reconciled 1 of 1 properties',
243
+ });
244
+
245
+ const result = await useCase.reconcileMultipleProperties({
246
+ stackIdentifier,
247
+ logicalId: 'MyVPC',
248
+ mismatches,
249
+ mode: 'template',
250
+ });
251
+
252
+ expect(result.reconciledCount).toBe(1); // One mutable property reconciled
253
+ expect(result.skippedCount).toBe(1); // One immutable property skipped
254
+ expect(result.reconcilableCount).toBe(1);
255
+ expect(result.results).toHaveLength(1);
256
+ });
257
+ });
258
+
259
+ describe('previewReconciliation', () => {
260
+ it('should preview reconciliation changes', async () => {
261
+ const stackIdentifier = new StackIdentifier({
262
+ stackName: 'my-app-prod',
263
+ region: 'us-east-1',
264
+ });
265
+
266
+ const mismatch = new PropertyMismatch({
267
+ propertyPath: 'Properties.EnableDnsSupport',
268
+ expectedValue: true,
269
+ actualValue: false,
270
+ mutability: PropertyMutability.MUTABLE,
271
+ });
272
+
273
+ // Mock preview
274
+ mockPropertyReconciler.previewReconciliation.mockResolvedValue({
275
+ canReconcile: true,
276
+ mode: 'template',
277
+ propertyPath: 'Properties.EnableDnsSupport',
278
+ currentValue: true,
279
+ proposedValue: false,
280
+ impact: 'Will update CloudFormation template to match actual resource state',
281
+ warnings: [],
282
+ });
283
+
284
+ const preview = await useCase.previewReconciliation({
285
+ stackIdentifier,
286
+ logicalId: 'MyVPC',
287
+ mismatch,
288
+ mode: 'template',
289
+ });
290
+
291
+ expect(preview.canReconcile).toBe(true);
292
+ expect(preview.mode).toBe('template');
293
+ expect(preview.impact).toContain('CloudFormation template');
294
+ });
295
+
296
+ it('should include warnings for risky reconciliations', async () => {
297
+ const stackIdentifier = new StackIdentifier({
298
+ stackName: 'my-app-prod',
299
+ region: 'us-east-1',
300
+ });
301
+
302
+ const mismatch = new PropertyMismatch({
303
+ propertyPath: 'Properties.EngineVersion',
304
+ expectedValue: '13.7',
305
+ actualValue: '13.8',
306
+ mutability: PropertyMutability.CONDITIONAL,
307
+ });
308
+
309
+ // Mock preview with warnings
310
+ mockPropertyReconciler.previewReconciliation.mockResolvedValue({
311
+ canReconcile: true,
312
+ mode: 'resource',
313
+ propertyPath: 'Properties.EngineVersion',
314
+ currentValue: '13.7',
315
+ proposedValue: '13.8',
316
+ impact: 'Will update database engine version - may cause downtime',
317
+ warnings: [
318
+ 'Engine version downgrade may not be supported',
319
+ 'Downtime may occur during version change',
320
+ ],
321
+ });
322
+
323
+ const preview = await useCase.previewReconciliation({
324
+ stackIdentifier,
325
+ logicalId: 'MyDBCluster',
326
+ mismatch,
327
+ mode: 'resource',
328
+ });
329
+
330
+ expect(preview.canReconcile).toBe(true);
331
+ expect(preview.warnings).toHaveLength(2);
332
+ expect(preview.warnings[0]).toContain('Engine version');
333
+ });
334
+ });
335
+
336
+ describe('constructor', () => {
337
+ it('should require propertyReconciler', () => {
338
+ expect(() => {
339
+ new ReconcilePropertiesUseCase({});
340
+ }).toThrow('propertyReconciler is required');
341
+ });
342
+ });
343
+ });
@@ -0,0 +1,229 @@
1
+ /**
2
+ * RepairViaImportUseCase - Import Orphaned Resources into CloudFormation Stack
3
+ *
4
+ * Application Layer - Use Case
5
+ *
6
+ * Business logic for the "frigg repair --import" command. Orchestrates resource
7
+ * import operations to fix orphaned resources by bringing them under CloudFormation management.
8
+ *
9
+ * Responsibilities:
10
+ * - Validate resources can be imported
11
+ * - Retrieve resource details from cloud
12
+ * - Generate CloudFormation template snippets
13
+ * - Execute import operations (single or batch)
14
+ * - Track import operation status
15
+ */
16
+
17
+ class RepairViaImportUseCase {
18
+ /**
19
+ * Create use case with required dependencies
20
+ *
21
+ * @param {Object} params
22
+ * @param {IResourceImporter} params.resourceImporter - Resource import operations
23
+ * @param {IResourceDetector} params.resourceDetector - Resource discovery and details
24
+ */
25
+ constructor({ resourceImporter, resourceDetector }) {
26
+ if (!resourceImporter) {
27
+ throw new Error('resourceImporter is required');
28
+ }
29
+ if (!resourceDetector) {
30
+ throw new Error('resourceDetector is required');
31
+ }
32
+
33
+ this.resourceImporter = resourceImporter;
34
+ this.resourceDetector = resourceDetector;
35
+ }
36
+
37
+ /**
38
+ * Import a single orphaned resource into a CloudFormation stack
39
+ *
40
+ * @param {Object} params
41
+ * @param {StackIdentifier} params.stackIdentifier - Target stack
42
+ * @param {string} params.logicalId - Desired logical ID for resource in template
43
+ * @param {string} params.physicalId - Physical ID of resource in cloud
44
+ * @param {string} params.resourceType - CloudFormation resource type
45
+ * @returns {Promise<Object>} Import result
46
+ */
47
+ async importSingleResource({ stackIdentifier, logicalId, physicalId, resourceType }) {
48
+ // 1. Validate resource can be imported
49
+ const validation = await this.resourceImporter.validateImport({
50
+ resourceType,
51
+ physicalId,
52
+ region: stackIdentifier.region,
53
+ });
54
+
55
+ if (!validation.canImport) {
56
+ throw new Error(validation.reason);
57
+ }
58
+
59
+ // 2. Get detailed resource properties from cloud
60
+ const resourceDetails = await this.resourceDetector.getResourceDetails({
61
+ resourceType,
62
+ physicalId,
63
+ region: stackIdentifier.region,
64
+ });
65
+
66
+ // 3. Execute import operation
67
+ const importResult = await this.resourceImporter.importResource({
68
+ stackIdentifier,
69
+ logicalId,
70
+ resourceType,
71
+ physicalId,
72
+ properties: resourceDetails.properties,
73
+ });
74
+
75
+ // 4. Return result with warnings if any
76
+ return {
77
+ success: true,
78
+ operationId: importResult.operationId,
79
+ status: importResult.status,
80
+ message: importResult.message,
81
+ warnings: validation.warnings || [],
82
+ resource: {
83
+ logicalId,
84
+ physicalId,
85
+ resourceType,
86
+ },
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Import multiple orphaned resources into a stack in batch
92
+ *
93
+ * @param {Object} params
94
+ * @param {StackIdentifier} params.stackIdentifier - Target stack
95
+ * @param {Array<Object>} params.resources - Resources to import
96
+ * @param {string} params.resources[].logicalId - Logical ID
97
+ * @param {string} params.resources[].physicalId - Physical ID
98
+ * @param {string} params.resources[].resourceType - Resource type
99
+ * @returns {Promise<Object>} Batch import result
100
+ */
101
+ async importMultipleResources({ stackIdentifier, resources }) {
102
+ const validationErrors = [];
103
+ const validResources = [];
104
+
105
+ // 1. Validate all resources first
106
+ for (const resource of resources) {
107
+ try {
108
+ const validation = await this.resourceImporter.validateImport({
109
+ resourceType: resource.resourceType,
110
+ physicalId: resource.physicalId,
111
+ region: stackIdentifier.region,
112
+ });
113
+
114
+ if (!validation.canImport) {
115
+ validationErrors.push({
116
+ logicalId: resource.logicalId,
117
+ physicalId: resource.physicalId,
118
+ reason: validation.reason,
119
+ });
120
+ continue;
121
+ }
122
+
123
+ // Get resource details
124
+ const resourceDetails = await this.resourceDetector.getResourceDetails({
125
+ resourceType: resource.resourceType,
126
+ physicalId: resource.physicalId,
127
+ region: stackIdentifier.region,
128
+ });
129
+
130
+ validResources.push({
131
+ logicalId: resource.logicalId,
132
+ resourceType: resource.resourceType,
133
+ physicalId: resource.physicalId,
134
+ properties: resourceDetails.properties,
135
+ });
136
+ } catch (error) {
137
+ validationErrors.push({
138
+ logicalId: resource.logicalId,
139
+ physicalId: resource.physicalId,
140
+ reason: error.message,
141
+ });
142
+ }
143
+ }
144
+
145
+ // 2. If ANY validation failed, fail the entire batch (all-or-nothing approach)
146
+ if (validationErrors.length > 0) {
147
+ return {
148
+ success: false,
149
+ importedCount: 0,
150
+ failedCount: validationErrors.length,
151
+ validationErrors,
152
+ message: `${validationErrors.length} resource(s) failed validation - batch import aborted`,
153
+ };
154
+ }
155
+
156
+ // 3. All validations passed - import resources in batch
157
+ if (validResources.length > 0) {
158
+ const importResult = await this.resourceImporter.importMultipleResources({
159
+ stackIdentifier,
160
+ resources: validResources,
161
+ });
162
+
163
+ return {
164
+ success: true,
165
+ importedCount: importResult.importedCount,
166
+ failedCount: importResult.failedCount,
167
+ operationId: importResult.operationId,
168
+ status: importResult.status,
169
+ message: importResult.message,
170
+ details: importResult.details,
171
+ };
172
+ }
173
+
174
+ // 4. No resources provided
175
+ return {
176
+ success: false,
177
+ importedCount: 0,
178
+ failedCount: 0,
179
+ message: 'No resources provided for import',
180
+ };
181
+ }
182
+
183
+ /**
184
+ * Get status of an ongoing import operation
185
+ *
186
+ * @param {Object} params
187
+ * @param {string} params.operationId - CloudFormation change set ID
188
+ * @returns {Promise<Object>} Operation status
189
+ */
190
+ async getImportStatus({ operationId }) {
191
+ return await this.resourceImporter.getImportStatus(operationId);
192
+ }
193
+
194
+ /**
195
+ * Preview what template changes will be made for an import
196
+ *
197
+ * @param {Object} params
198
+ * @param {StackIdentifier} params.stackIdentifier - Target stack
199
+ * @param {string} params.logicalId - Desired logical ID
200
+ * @param {string} params.physicalId - Physical resource ID
201
+ * @param {string} params.resourceType - Resource type
202
+ * @returns {Promise<Object>} Preview with template snippet
203
+ */
204
+ async previewImport({ stackIdentifier, logicalId, physicalId, resourceType }) {
205
+ // Get resource details from cloud
206
+ const resourceDetails = await this.resourceDetector.getResourceDetails({
207
+ resourceType,
208
+ physicalId,
209
+ region: stackIdentifier.region,
210
+ });
211
+
212
+ // Generate template snippet
213
+ const templateSnippet = await this.resourceImporter.generateTemplateSnippet({
214
+ logicalId,
215
+ resourceType,
216
+ properties: resourceDetails.properties,
217
+ });
218
+
219
+ return {
220
+ logicalId,
221
+ physicalId,
222
+ resourceType,
223
+ templateSnippet,
224
+ properties: resourceDetails.properties,
225
+ };
226
+ }
227
+ }
228
+
229
+ module.exports = RepairViaImportUseCase;