@friggframework/devtools 2.0.0--canary.474.d64c550.0 → 2.0.0--canary.474.efd7936.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,386 @@
1
+ /**
2
+ * AWSStackRepository - AWS CloudFormation Stack Adapter
3
+ *
4
+ * Infrastructure Adapter - Hexagonal Architecture
5
+ *
6
+ * Implements IStackRepository port for AWS CloudFormation.
7
+ * Handles CloudFormation API operations using AWS SDK v3.
8
+ *
9
+ * Lazy-loads AWS SDK to minimize cold start time and memory usage.
10
+ */
11
+
12
+ const IStackRepository = require('../../application/ports/IStackRepository');
13
+ const yaml = require('js-yaml');
14
+
15
+ // Lazy-loaded AWS SDK CloudFormation client
16
+ let CloudFormationClient,
17
+ DescribeStacksCommand,
18
+ ListStackResourcesCommand,
19
+ DescribeStackResourcesCommand,
20
+ DescribeStackResourceCommand,
21
+ GetTemplateCommand,
22
+ DetectStackDriftCommand,
23
+ DescribeStackDriftDetectionStatusCommand,
24
+ DescribeStackResourceDriftsCommand;
25
+
26
+ /**
27
+ * Lazy load CloudFormation SDK
28
+ */
29
+ function loadCloudFormation() {
30
+ if (!CloudFormationClient) {
31
+ const cfModule = require('@aws-sdk/client-cloudformation');
32
+ CloudFormationClient = cfModule.CloudFormationClient;
33
+ DescribeStacksCommand = cfModule.DescribeStacksCommand;
34
+ ListStackResourcesCommand = cfModule.ListStackResourcesCommand;
35
+ DescribeStackResourcesCommand = cfModule.DescribeStackResourcesCommand;
36
+ DescribeStackResourceCommand = cfModule.DescribeStackResourceCommand;
37
+ GetTemplateCommand = cfModule.GetTemplateCommand;
38
+ DetectStackDriftCommand = cfModule.DetectStackDriftCommand;
39
+ DescribeStackDriftDetectionStatusCommand =
40
+ cfModule.DescribeStackDriftDetectionStatusCommand;
41
+ DescribeStackResourceDriftsCommand = cfModule.DescribeStackResourceDriftsCommand;
42
+ }
43
+ }
44
+
45
+ class AWSStackRepository extends IStackRepository {
46
+ /**
47
+ * Create AWS Stack Repository
48
+ *
49
+ * @param {Object} [config={}]
50
+ * @param {string} [config.region] - AWS region (defaults to AWS_REGION env var)
51
+ */
52
+ constructor(config = {}) {
53
+ super();
54
+ this.region = config.region || process.env.AWS_REGION || 'us-east-1';
55
+ this.client = null;
56
+ }
57
+
58
+ /**
59
+ * Get or create CloudFormation client
60
+ * @private
61
+ */
62
+ _getClient() {
63
+ if (!this.client) {
64
+ loadCloudFormation();
65
+ this.client = new CloudFormationClient({ region: this.region });
66
+ }
67
+ return this.client;
68
+ }
69
+
70
+ /**
71
+ * Get stack information by identifier
72
+ */
73
+ async getStack(identifier) {
74
+ const client = this._getClient();
75
+
76
+ try {
77
+ const command = new DescribeStacksCommand({
78
+ StackName: identifier.stackName,
79
+ });
80
+
81
+ const response = await client.send(command);
82
+
83
+ if (!response.Stacks || response.Stacks.length === 0) {
84
+ throw new Error(
85
+ `Stack ${identifier.stackName} does not exist in region ${this.region}`
86
+ );
87
+ }
88
+
89
+ const stack = response.Stacks[0];
90
+
91
+ // Parse ARN to get account ID
92
+ const arnMatch = stack.StackId.match(/:(\d{12}):/);
93
+ const accountId = arnMatch ? arnMatch[1] : identifier.accountId;
94
+
95
+ return {
96
+ stackName: stack.StackName,
97
+ region: this.region,
98
+ accountId,
99
+ stackId: stack.StackId,
100
+ status: stack.StackStatus,
101
+ creationTime: stack.CreationTime,
102
+ lastUpdatedTime: stack.LastUpdatedTime,
103
+ parameters: this._parseParameters(stack.Parameters),
104
+ outputs: this._parseOutputs(stack.Outputs),
105
+ tags: this._parseTags(stack.Tags),
106
+ };
107
+ } catch (error) {
108
+ if (error.name === 'ValidationError' || error.message?.includes('does not exist')) {
109
+ throw new Error(
110
+ `Stack ${identifier.stackName} does not exist in region ${this.region}`
111
+ );
112
+ }
113
+ throw error;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * List all resources in a stack
119
+ */
120
+ async listResources(identifier) {
121
+ const client = this._getClient();
122
+ const resources = [];
123
+ let nextToken = null;
124
+
125
+ do {
126
+ const command = new ListStackResourcesCommand({
127
+ StackName: identifier.stackName,
128
+ NextToken: nextToken,
129
+ });
130
+
131
+ const response = await client.send(command);
132
+
133
+ if (response.StackResourceSummaries) {
134
+ for (const resource of response.StackResourceSummaries) {
135
+ resources.push({
136
+ logicalId: resource.LogicalResourceId,
137
+ physicalId: resource.PhysicalResourceId,
138
+ resourceType: resource.ResourceType,
139
+ status: resource.ResourceStatus,
140
+ lastUpdatedTime: resource.LastUpdatedTimestamp,
141
+ driftStatus: resource.DriftInformation?.StackResourceDriftStatus || 'NOT_CHECKED',
142
+ });
143
+ }
144
+ }
145
+
146
+ nextToken = response.NextToken;
147
+ } while (nextToken);
148
+
149
+ return resources;
150
+ }
151
+
152
+ /**
153
+ * Get resource details from stack
154
+ */
155
+ async getResource(identifier, logicalId) {
156
+ const client = this._getClient();
157
+
158
+ const command = new DescribeStackResourceCommand({
159
+ StackName: identifier.stackName,
160
+ LogicalResourceId: logicalId,
161
+ });
162
+
163
+ const response = await client.send(command);
164
+ const resource = response.StackResourceDetail;
165
+
166
+ return {
167
+ logicalId: resource.LogicalResourceId,
168
+ physicalId: resource.PhysicalResourceId,
169
+ resourceType: resource.ResourceType,
170
+ status: resource.ResourceStatus,
171
+ properties: {}, // CloudFormation doesn't return properties directly
172
+ metadata: this._parseMetadata(resource.Metadata),
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Get the CloudFormation template for a stack
178
+ */
179
+ async getTemplate(identifier) {
180
+ const client = this._getClient();
181
+
182
+ const command = new GetTemplateCommand({
183
+ StackName: identifier.stackName,
184
+ TemplateStage: 'Original',
185
+ });
186
+
187
+ const response = await client.send(command);
188
+ const templateBody = response.TemplateBody;
189
+
190
+ // Try to parse as JSON first
191
+ try {
192
+ return JSON.parse(templateBody);
193
+ } catch {
194
+ // If not JSON, try YAML
195
+ try {
196
+ return yaml.load(templateBody);
197
+ } catch {
198
+ throw new Error('Failed to parse template body as JSON or YAML');
199
+ }
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Check if a stack exists
205
+ */
206
+ async exists(identifier) {
207
+ try {
208
+ const stack = await this.getStack(identifier);
209
+
210
+ // Check if stack is in a deleted state
211
+ const deletedStates = ['DELETE_COMPLETE', 'DELETE_IN_PROGRESS'];
212
+ return !deletedStates.includes(stack.status);
213
+ } catch (error) {
214
+ if (error.message?.includes('does not exist')) {
215
+ return false;
216
+ }
217
+ throw error;
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Detect drift for the entire stack
223
+ */
224
+ async detectStackDrift(identifier) {
225
+ const client = this._getClient();
226
+
227
+ // Initiate drift detection
228
+ const detectCommand = new DetectStackDriftCommand({
229
+ StackName: identifier.stackName,
230
+ });
231
+
232
+ const detectResponse = await client.send(detectCommand);
233
+ const driftDetectionId = detectResponse.StackDriftDetectionId;
234
+
235
+ // Poll for detection completion
236
+ let detectionStatus = 'DETECTION_IN_PROGRESS';
237
+ let statusResponse;
238
+
239
+ while (detectionStatus === 'DETECTION_IN_PROGRESS') {
240
+ await this._sleep(1000); // Wait 1 second between polls
241
+
242
+ const statusCommand = new DescribeStackDriftDetectionStatusCommand({
243
+ StackDriftDetectionId: driftDetectionId,
244
+ });
245
+
246
+ statusResponse = await client.send(statusCommand);
247
+ detectionStatus = statusResponse.DetectionStatus;
248
+ }
249
+
250
+ if (detectionStatus !== 'DETECTION_COMPLETE') {
251
+ throw new Error(`Drift detection failed with status: ${detectionStatus}`);
252
+ }
253
+
254
+ return {
255
+ stackDriftStatus: statusResponse.StackDriftStatus,
256
+ driftedResourceCount: statusResponse.DriftedStackResourceCount || 0,
257
+ detectionTime: statusResponse.Timestamp,
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Get drift details for a specific resource
263
+ */
264
+ async getResourceDrift(identifier, logicalId) {
265
+ const client = this._getClient();
266
+
267
+ const command = new DescribeStackResourceDriftsCommand({
268
+ StackName: identifier.stackName,
269
+ StackResourceDriftStatusFilters: ['MODIFIED', 'DELETED', 'IN_SYNC'],
270
+ });
271
+
272
+ const response = await client.send(command);
273
+
274
+ // Find the specific resource
275
+ const resourceDrift = response.StackResourceDrifts?.find(
276
+ (drift) => drift.LogicalResourceId === logicalId
277
+ );
278
+
279
+ if (!resourceDrift) {
280
+ throw new Error(
281
+ `No drift information found for resource ${logicalId} in stack ${identifier.stackName}`
282
+ );
283
+ }
284
+
285
+ return {
286
+ driftStatus: resourceDrift.StackResourceDriftStatus,
287
+ expectedProperties: this._parseProperties(resourceDrift.ExpectedProperties),
288
+ actualProperties: this._parseProperties(resourceDrift.ActualProperties),
289
+ propertyDifferences: resourceDrift.PropertyDifferences || [],
290
+ };
291
+ }
292
+
293
+ // ========================================
294
+ // Private Helper Methods
295
+ // ========================================
296
+
297
+ /**
298
+ * Parse CloudFormation parameters to key-value object
299
+ * @private
300
+ */
301
+ _parseParameters(parameters) {
302
+ if (!parameters || parameters.length === 0) {
303
+ return {};
304
+ }
305
+
306
+ const result = {};
307
+ for (const param of parameters) {
308
+ result[param.ParameterKey] = param.ParameterValue;
309
+ }
310
+ return result;
311
+ }
312
+
313
+ /**
314
+ * Parse CloudFormation outputs to key-value object
315
+ * @private
316
+ */
317
+ _parseOutputs(outputs) {
318
+ if (!outputs || outputs.length === 0) {
319
+ return {};
320
+ }
321
+
322
+ const result = {};
323
+ for (const output of outputs) {
324
+ result[output.OutputKey] = output.OutputValue;
325
+ }
326
+ return result;
327
+ }
328
+
329
+ /**
330
+ * Parse CloudFormation tags to key-value object
331
+ * @private
332
+ */
333
+ _parseTags(tags) {
334
+ if (!tags || tags.length === 0) {
335
+ return {};
336
+ }
337
+
338
+ const result = {};
339
+ for (const tag of tags) {
340
+ result[tag.Key] = tag.Value;
341
+ }
342
+ return result;
343
+ }
344
+
345
+ /**
346
+ * Parse resource metadata
347
+ * @private
348
+ */
349
+ _parseMetadata(metadata) {
350
+ if (!metadata) {
351
+ return {};
352
+ }
353
+
354
+ try {
355
+ return JSON.parse(metadata);
356
+ } catch {
357
+ return {};
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Parse property JSON string
363
+ * @private
364
+ */
365
+ _parseProperties(propertiesString) {
366
+ if (!propertiesString) {
367
+ return {};
368
+ }
369
+
370
+ try {
371
+ return JSON.parse(propertiesString);
372
+ } catch {
373
+ return {};
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Sleep for specified milliseconds
379
+ * @private
380
+ */
381
+ _sleep(ms) {
382
+ return new Promise((resolve) => setTimeout(resolve, ms));
383
+ }
384
+ }
385
+
386
+ module.exports = AWSStackRepository;
@@ -0,0 +1,580 @@
1
+ /**
2
+ * Tests for AWSStackRepository Adapter
3
+ *
4
+ * Tests CloudFormation API integration using mocked AWS SDK clients
5
+ */
6
+
7
+ const AWSStackRepository = require('./aws-stack-repository');
8
+ const StackIdentifier = require('../../domain/value-objects/stack-identifier');
9
+
10
+ // Mock AWS SDK
11
+ jest.mock('@aws-sdk/client-cloudformation', () => {
12
+ return {
13
+ CloudFormationClient: jest.fn(),
14
+ DescribeStacksCommand: jest.fn(),
15
+ ListStackResourcesCommand: jest.fn(),
16
+ DescribeStackResourcesCommand: jest.fn(),
17
+ DescribeStackResourceCommand: jest.fn(),
18
+ GetTemplateCommand: jest.fn(),
19
+ DetectStackDriftCommand: jest.fn(),
20
+ DescribeStackDriftDetectionStatusCommand: jest.fn(),
21
+ DescribeStackResourceDriftsCommand: jest.fn(),
22
+ };
23
+ });
24
+
25
+ describe('AWSStackRepository', () => {
26
+ let repository;
27
+ let mockSend;
28
+ let mockClient;
29
+
30
+ beforeEach(() => {
31
+ // Reset mocks
32
+ jest.clearAllMocks();
33
+
34
+ // Create mock client with send method
35
+ mockSend = jest.fn();
36
+ mockClient = {
37
+ send: mockSend,
38
+ };
39
+
40
+ // Mock CloudFormationClient constructor
41
+ const { CloudFormationClient } = require('@aws-sdk/client-cloudformation');
42
+ CloudFormationClient.mockImplementation(() => mockClient);
43
+
44
+ repository = new AWSStackRepository();
45
+ });
46
+
47
+ describe('getStack', () => {
48
+ it('should get stack information', async () => {
49
+ const identifier = new StackIdentifier({
50
+ stackName: 'my-app-prod',
51
+ region: 'us-east-1',
52
+ accountId: '123456789012',
53
+ });
54
+
55
+ const mockStack = {
56
+ StackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/my-app-prod/guid',
57
+ StackName: 'my-app-prod',
58
+ StackStatus: 'UPDATE_COMPLETE',
59
+ CreationTime: new Date('2024-01-01T00:00:00Z'),
60
+ LastUpdatedTime: new Date('2024-01-15T00:00:00Z'),
61
+ Parameters: [
62
+ { ParameterKey: 'Environment', ParameterValue: 'production' },
63
+ ],
64
+ Outputs: [
65
+ { OutputKey: 'VpcId', OutputValue: 'vpc-123' },
66
+ ],
67
+ Tags: [
68
+ { Key: 'Team', Value: 'platform' },
69
+ ],
70
+ };
71
+
72
+ mockSend.mockResolvedValue({
73
+ Stacks: [mockStack],
74
+ });
75
+
76
+ const stack = await repository.getStack(identifier);
77
+
78
+ expect(stack).toEqual({
79
+ stackName: 'my-app-prod',
80
+ region: 'us-east-1',
81
+ accountId: '123456789012',
82
+ stackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/my-app-prod/guid',
83
+ status: 'UPDATE_COMPLETE',
84
+ creationTime: new Date('2024-01-01T00:00:00Z'),
85
+ lastUpdatedTime: new Date('2024-01-15T00:00:00Z'),
86
+ parameters: { Environment: 'production' },
87
+ outputs: { VpcId: 'vpc-123' },
88
+ tags: { Team: 'platform' },
89
+ });
90
+
91
+ expect(mockSend).toHaveBeenCalledTimes(1);
92
+ });
93
+
94
+ it('should throw error if stack does not exist', async () => {
95
+ const identifier = new StackIdentifier({
96
+ stackName: 'non-existent',
97
+ region: 'us-east-1',
98
+ });
99
+
100
+ mockSend.mockRejectedValue({
101
+ name: 'ValidationError',
102
+ message: 'Stack does not exist',
103
+ });
104
+
105
+ await expect(repository.getStack(identifier)).rejects.toThrow(
106
+ 'Stack non-existent does not exist in region us-east-1'
107
+ );
108
+ });
109
+
110
+ it('should handle stacks without parameters', async () => {
111
+ const identifier = new StackIdentifier({
112
+ stackName: 'simple-stack',
113
+ region: 'us-east-1',
114
+ });
115
+
116
+ mockSend.mockResolvedValue({
117
+ Stacks: [
118
+ {
119
+ StackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/simple-stack/guid',
120
+ StackName: 'simple-stack',
121
+ StackStatus: 'CREATE_COMPLETE',
122
+ CreationTime: new Date('2024-01-01T00:00:00Z'),
123
+ },
124
+ ],
125
+ });
126
+
127
+ const stack = await repository.getStack(identifier);
128
+
129
+ expect(stack.parameters).toEqual({});
130
+ expect(stack.outputs).toEqual({});
131
+ expect(stack.tags).toEqual({});
132
+ });
133
+ });
134
+
135
+ describe('listResources', () => {
136
+ it('should list all stack resources', async () => {
137
+ const identifier = new StackIdentifier({
138
+ stackName: 'my-app-prod',
139
+ region: 'us-east-1',
140
+ });
141
+
142
+ mockSend.mockResolvedValue({
143
+ StackResourceSummaries: [
144
+ {
145
+ LogicalResourceId: 'MyVPC',
146
+ PhysicalResourceId: 'vpc-123',
147
+ ResourceType: 'AWS::EC2::VPC',
148
+ ResourceStatus: 'CREATE_COMPLETE',
149
+ LastUpdatedTimestamp: new Date('2024-01-15T00:00:00Z'),
150
+ DriftInformation: { StackResourceDriftStatus: 'IN_SYNC' },
151
+ },
152
+ {
153
+ LogicalResourceId: 'MySubnet',
154
+ PhysicalResourceId: 'subnet-456',
155
+ ResourceType: 'AWS::EC2::Subnet',
156
+ ResourceStatus: 'CREATE_COMPLETE',
157
+ LastUpdatedTimestamp: new Date('2024-01-15T00:00:00Z'),
158
+ DriftInformation: { StackResourceDriftStatus: 'NOT_CHECKED' },
159
+ },
160
+ ],
161
+ });
162
+
163
+ const resources = await repository.listResources(identifier);
164
+
165
+ expect(resources).toHaveLength(2);
166
+ expect(resources[0]).toEqual({
167
+ logicalId: 'MyVPC',
168
+ physicalId: 'vpc-123',
169
+ resourceType: 'AWS::EC2::VPC',
170
+ status: 'CREATE_COMPLETE',
171
+ lastUpdatedTime: new Date('2024-01-15T00:00:00Z'),
172
+ driftStatus: 'IN_SYNC',
173
+ });
174
+
175
+ expect(resources[1].driftStatus).toBe('NOT_CHECKED');
176
+ });
177
+
178
+ it('should handle pagination for large stacks', async () => {
179
+ const identifier = new StackIdentifier({
180
+ stackName: 'large-stack',
181
+ region: 'us-east-1',
182
+ });
183
+
184
+ // First page
185
+ mockSend.mockResolvedValueOnce({
186
+ StackResourceSummaries: [
187
+ {
188
+ LogicalResourceId: 'Resource1',
189
+ PhysicalResourceId: 'res-1',
190
+ ResourceType: 'AWS::EC2::VPC',
191
+ ResourceStatus: 'CREATE_COMPLETE',
192
+ LastUpdatedTimestamp: new Date('2024-01-15T00:00:00Z'),
193
+ DriftInformation: { StackResourceDriftStatus: 'IN_SYNC' },
194
+ },
195
+ ],
196
+ NextToken: 'token-123',
197
+ });
198
+
199
+ // Second page
200
+ mockSend.mockResolvedValueOnce({
201
+ StackResourceSummaries: [
202
+ {
203
+ LogicalResourceId: 'Resource2',
204
+ PhysicalResourceId: 'res-2',
205
+ ResourceType: 'AWS::EC2::Subnet',
206
+ ResourceStatus: 'CREATE_COMPLETE',
207
+ LastUpdatedTimestamp: new Date('2024-01-15T00:00:00Z'),
208
+ DriftInformation: { StackResourceDriftStatus: 'IN_SYNC' },
209
+ },
210
+ ],
211
+ });
212
+
213
+ const resources = await repository.listResources(identifier);
214
+
215
+ expect(resources).toHaveLength(2);
216
+ expect(mockSend).toHaveBeenCalledTimes(2);
217
+ });
218
+
219
+ it('should throw error if stack does not exist', async () => {
220
+ const identifier = new StackIdentifier({
221
+ stackName: 'non-existent',
222
+ region: 'us-east-1',
223
+ });
224
+
225
+ const error = new Error('Stack does not exist');
226
+ error.name = 'ValidationError';
227
+ mockSend.mockRejectedValue(error);
228
+
229
+ await expect(repository.listResources(identifier)).rejects.toThrow('Stack does not exist');
230
+ });
231
+ });
232
+
233
+ describe('getResource', () => {
234
+ it('should get resource details', async () => {
235
+ const identifier = new StackIdentifier({
236
+ stackName: 'my-app-prod',
237
+ region: 'us-east-1',
238
+ });
239
+
240
+ mockSend.mockResolvedValue({
241
+ StackResourceDetail: {
242
+ LogicalResourceId: 'MyVPC',
243
+ PhysicalResourceId: 'vpc-123',
244
+ ResourceType: 'AWS::EC2::VPC',
245
+ ResourceStatus: 'CREATE_COMPLETE',
246
+ Metadata: '{"Description": "Main VPC"}',
247
+ },
248
+ });
249
+
250
+ const resource = await repository.getResource(identifier, 'MyVPC');
251
+
252
+ expect(resource).toEqual({
253
+ logicalId: 'MyVPC',
254
+ physicalId: 'vpc-123',
255
+ resourceType: 'AWS::EC2::VPC',
256
+ status: 'CREATE_COMPLETE',
257
+ properties: {},
258
+ metadata: { Description: 'Main VPC' },
259
+ });
260
+ });
261
+
262
+ it('should handle resource without metadata', async () => {
263
+ const identifier = new StackIdentifier({
264
+ stackName: 'my-app-prod',
265
+ region: 'us-east-1',
266
+ });
267
+
268
+ mockSend.mockResolvedValue({
269
+ StackResourceDetail: {
270
+ LogicalResourceId: 'MyVPC',
271
+ PhysicalResourceId: 'vpc-123',
272
+ ResourceType: 'AWS::EC2::VPC',
273
+ ResourceStatus: 'CREATE_COMPLETE',
274
+ },
275
+ });
276
+
277
+ const resource = await repository.getResource(identifier, 'MyVPC');
278
+
279
+ expect(resource.metadata).toEqual({});
280
+ });
281
+
282
+ it('should throw error if resource does not exist', async () => {
283
+ const identifier = new StackIdentifier({
284
+ stackName: 'my-app-prod',
285
+ region: 'us-east-1',
286
+ });
287
+
288
+ const error = new Error('Resource does not exist');
289
+ error.name = 'ValidationError';
290
+ mockSend.mockRejectedValue(error);
291
+
292
+ await expect(repository.getResource(identifier, 'NonExistent')).rejects.toThrow('Resource does not exist');
293
+ });
294
+ });
295
+
296
+ describe('getTemplate', () => {
297
+ it('should get CloudFormation template', async () => {
298
+ const identifier = new StackIdentifier({
299
+ stackName: 'my-app-prod',
300
+ region: 'us-east-1',
301
+ });
302
+
303
+ const mockTemplate = {
304
+ Resources: {
305
+ MyVPC: {
306
+ Type: 'AWS::EC2::VPC',
307
+ Properties: {
308
+ CidrBlock: '10.0.0.0/16',
309
+ },
310
+ },
311
+ },
312
+ };
313
+
314
+ mockSend.mockResolvedValue({
315
+ TemplateBody: JSON.stringify(mockTemplate),
316
+ });
317
+
318
+ const template = await repository.getTemplate(identifier);
319
+
320
+ expect(template).toEqual(mockTemplate);
321
+ });
322
+
323
+ it('should handle YAML templates', async () => {
324
+ const identifier = new StackIdentifier({
325
+ stackName: 'my-app-prod',
326
+ region: 'us-east-1',
327
+ });
328
+
329
+ const yamlTemplate = `
330
+ Resources:
331
+ MyVPC:
332
+ Type: AWS::EC2::VPC
333
+ Properties:
334
+ CidrBlock: 10.0.0.0/16
335
+ `;
336
+
337
+ mockSend.mockResolvedValue({
338
+ TemplateBody: yamlTemplate,
339
+ });
340
+
341
+ const template = await repository.getTemplate(identifier);
342
+
343
+ expect(template).toHaveProperty('Resources');
344
+ expect(template.Resources).toHaveProperty('MyVPC');
345
+ });
346
+ });
347
+
348
+ describe('exists', () => {
349
+ it('should return true if stack exists', async () => {
350
+ const identifier = new StackIdentifier({
351
+ stackName: 'my-app-prod',
352
+ region: 'us-east-1',
353
+ });
354
+
355
+ mockSend.mockResolvedValue({
356
+ Stacks: [{
357
+ StackName: 'my-app-prod',
358
+ StackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/my-app-prod/guid',
359
+ StackStatus: 'UPDATE_COMPLETE',
360
+ CreationTime: new Date('2024-01-01T00:00:00Z'),
361
+ }],
362
+ });
363
+
364
+ const exists = await repository.exists(identifier);
365
+
366
+ expect(exists).toBe(true);
367
+ });
368
+
369
+ it('should return false if stack does not exist', async () => {
370
+ const identifier = new StackIdentifier({
371
+ stackName: 'non-existent',
372
+ region: 'us-east-1',
373
+ });
374
+
375
+ const error = new Error('Stack does not exist');
376
+ error.name = 'ValidationError';
377
+ mockSend.mockRejectedValue(error);
378
+
379
+ const exists = await repository.exists(identifier);
380
+
381
+ expect(exists).toBe(false);
382
+ });
383
+
384
+ it('should return false if stack is deleted', async () => {
385
+ const identifier = new StackIdentifier({
386
+ stackName: 'deleted-stack',
387
+ region: 'us-east-1',
388
+ });
389
+
390
+ mockSend.mockResolvedValue({
391
+ Stacks: [{
392
+ StackName: 'deleted-stack',
393
+ StackId: 'arn:aws:cloudformation:us-east-1:123456789012:stack/deleted-stack/guid',
394
+ StackStatus: 'DELETE_COMPLETE',
395
+ CreationTime: new Date('2024-01-01T00:00:00Z'),
396
+ }],
397
+ });
398
+
399
+ const exists = await repository.exists(identifier);
400
+
401
+ expect(exists).toBe(false);
402
+ });
403
+ });
404
+
405
+ describe('detectStackDrift', () => {
406
+ it('should detect stack drift', async () => {
407
+ const identifier = new StackIdentifier({
408
+ stackName: 'my-app-prod',
409
+ region: 'us-east-1',
410
+ });
411
+
412
+ // Mock drift detection initiation
413
+ mockSend.mockResolvedValueOnce({
414
+ StackDriftDetectionId: 'drift-detection-123',
415
+ });
416
+
417
+ // Mock drift detection status polling
418
+ mockSend.mockResolvedValueOnce({
419
+ DetectionStatus: 'DETECTION_COMPLETE',
420
+ StackDriftStatus: 'DRIFTED',
421
+ DriftedStackResourceCount: 2,
422
+ Timestamp: new Date('2024-01-15T10:00:00Z'),
423
+ });
424
+
425
+ const result = await repository.detectStackDrift(identifier);
426
+
427
+ expect(result).toEqual({
428
+ stackDriftStatus: 'DRIFTED',
429
+ driftedResourceCount: 2,
430
+ detectionTime: new Date('2024-01-15T10:00:00Z'),
431
+ });
432
+
433
+ expect(mockSend).toHaveBeenCalledTimes(2);
434
+ });
435
+
436
+ it('should handle in-sync stacks', async () => {
437
+ const identifier = new StackIdentifier({
438
+ stackName: 'my-app-prod',
439
+ region: 'us-east-1',
440
+ });
441
+
442
+ mockSend.mockResolvedValueOnce({
443
+ StackDriftDetectionId: 'drift-detection-123',
444
+ });
445
+
446
+ mockSend.mockResolvedValueOnce({
447
+ DetectionStatus: 'DETECTION_COMPLETE',
448
+ StackDriftStatus: 'IN_SYNC',
449
+ DriftedStackResourceCount: 0,
450
+ Timestamp: new Date('2024-01-15T10:00:00Z'),
451
+ });
452
+
453
+ const result = await repository.detectStackDrift(identifier);
454
+
455
+ expect(result.stackDriftStatus).toBe('IN_SYNC');
456
+ expect(result.driftedResourceCount).toBe(0);
457
+ });
458
+
459
+ it('should poll until detection completes', async () => {
460
+ const identifier = new StackIdentifier({
461
+ stackName: 'my-app-prod',
462
+ region: 'us-east-1',
463
+ });
464
+
465
+ mockSend.mockResolvedValueOnce({
466
+ StackDriftDetectionId: 'drift-detection-123',
467
+ });
468
+
469
+ // First poll: in progress
470
+ mockSend.mockResolvedValueOnce({
471
+ DetectionStatus: 'DETECTION_IN_PROGRESS',
472
+ });
473
+
474
+ // Second poll: complete
475
+ mockSend.mockResolvedValueOnce({
476
+ DetectionStatus: 'DETECTION_COMPLETE',
477
+ StackDriftStatus: 'IN_SYNC',
478
+ DriftedStackResourceCount: 0,
479
+ Timestamp: new Date('2024-01-15T10:00:00Z'),
480
+ });
481
+
482
+ const result = await repository.detectStackDrift(identifier);
483
+
484
+ expect(result.stackDriftStatus).toBe('IN_SYNC');
485
+ expect(mockSend).toHaveBeenCalledTimes(3);
486
+ });
487
+ });
488
+
489
+ describe('getResourceDrift', () => {
490
+ it('should get resource drift details', async () => {
491
+ const identifier = new StackIdentifier({
492
+ stackName: 'my-app-prod',
493
+ region: 'us-east-1',
494
+ });
495
+
496
+ mockSend.mockResolvedValue({
497
+ StackResourceDrifts: [
498
+ {
499
+ LogicalResourceId: 'MyVPC',
500
+ StackResourceDriftStatus: 'MODIFIED',
501
+ ExpectedProperties: JSON.stringify({
502
+ CidrBlock: '10.0.0.0/16',
503
+ EnableDnsSupport: true,
504
+ }),
505
+ ActualProperties: JSON.stringify({
506
+ CidrBlock: '10.0.0.0/16',
507
+ EnableDnsSupport: false,
508
+ }),
509
+ PropertyDifferences: [
510
+ {
511
+ PropertyPath: '/Properties/EnableDnsSupport',
512
+ ExpectedValue: 'true',
513
+ ActualValue: 'false',
514
+ DifferenceType: 'NOT_EQUAL',
515
+ },
516
+ ],
517
+ },
518
+ ],
519
+ });
520
+
521
+ const drift = await repository.getResourceDrift(identifier, 'MyVPC');
522
+
523
+ expect(drift).toEqual({
524
+ driftStatus: 'MODIFIED',
525
+ expectedProperties: {
526
+ CidrBlock: '10.0.0.0/16',
527
+ EnableDnsSupport: true,
528
+ },
529
+ actualProperties: {
530
+ CidrBlock: '10.0.0.0/16',
531
+ EnableDnsSupport: false,
532
+ },
533
+ propertyDifferences: [
534
+ {
535
+ PropertyPath: '/Properties/EnableDnsSupport',
536
+ ExpectedValue: 'true',
537
+ ActualValue: 'false',
538
+ DifferenceType: 'NOT_EQUAL',
539
+ },
540
+ ],
541
+ });
542
+ });
543
+
544
+ it('should handle resources in sync', async () => {
545
+ const identifier = new StackIdentifier({
546
+ stackName: 'my-app-prod',
547
+ region: 'us-east-1',
548
+ });
549
+
550
+ mockSend.mockResolvedValue({
551
+ StackResourceDrifts: [
552
+ {
553
+ LogicalResourceId: 'MyVPC',
554
+ StackResourceDriftStatus: 'IN_SYNC',
555
+ ExpectedProperties: JSON.stringify({ CidrBlock: '10.0.0.0/16' }),
556
+ ActualProperties: JSON.stringify({ CidrBlock: '10.0.0.0/16' }),
557
+ PropertyDifferences: [],
558
+ },
559
+ ],
560
+ });
561
+
562
+ const drift = await repository.getResourceDrift(identifier, 'MyVPC');
563
+
564
+ expect(drift.driftStatus).toBe('IN_SYNC');
565
+ expect(drift.propertyDifferences).toEqual([]);
566
+ });
567
+ });
568
+
569
+ describe('constructor', () => {
570
+ it('should create instance with default region', () => {
571
+ const repo = new AWSStackRepository();
572
+ expect(repo).toBeInstanceOf(AWSStackRepository);
573
+ });
574
+
575
+ it('should create instance with custom region', () => {
576
+ const repo = new AWSStackRepository({ region: 'eu-west-1' });
577
+ expect(repo).toBeInstanceOf(AWSStackRepository);
578
+ });
579
+ });
580
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/devtools",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.474.d64c550.0",
4
+ "version": "2.0.0--canary.474.efd7936.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-ec2": "^3.835.0",
7
7
  "@aws-sdk/client-kms": "^3.835.0",
@@ -11,8 +11,8 @@
11
11
  "@babel/eslint-parser": "^7.18.9",
12
12
  "@babel/parser": "^7.25.3",
13
13
  "@babel/traverse": "^7.25.3",
14
- "@friggframework/schemas": "2.0.0--canary.474.d64c550.0",
15
- "@friggframework/test": "2.0.0--canary.474.d64c550.0",
14
+ "@friggframework/schemas": "2.0.0--canary.474.efd7936.0",
15
+ "@friggframework/test": "2.0.0--canary.474.efd7936.0",
16
16
  "@hapi/boom": "^10.0.1",
17
17
  "@inquirer/prompts": "^5.3.8",
18
18
  "axios": "^1.7.2",
@@ -34,8 +34,8 @@
34
34
  "serverless-http": "^2.7.0"
35
35
  },
36
36
  "devDependencies": {
37
- "@friggframework/eslint-config": "2.0.0--canary.474.d64c550.0",
38
- "@friggframework/prettier-config": "2.0.0--canary.474.d64c550.0",
37
+ "@friggframework/eslint-config": "2.0.0--canary.474.efd7936.0",
38
+ "@friggframework/prettier-config": "2.0.0--canary.474.efd7936.0",
39
39
  "aws-sdk-client-mock": "^4.1.0",
40
40
  "aws-sdk-client-mock-jest": "^4.1.0",
41
41
  "jest": "^30.1.3",
@@ -70,5 +70,5 @@
70
70
  "publishConfig": {
71
71
  "access": "public"
72
72
  },
73
- "gitHead": "d64c55085840ccaf0cf6666ba699eb20873d9a81"
73
+ "gitHead": "efd79365aef7bb559b15d072ae85000069c61837"
74
74
  }