@friggframework/devtools 2.0.0--canary.474.d64c550.0 → 2.0.0--canary.474.082077e.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/infrastructure/adapters/aws-resource-detector.js +471 -0
- package/infrastructure/domains/health/infrastructure/adapters/aws-resource-detector.test.js +497 -0
- package/infrastructure/domains/health/infrastructure/adapters/aws-stack-repository.js +386 -0
- package/infrastructure/domains/health/infrastructure/adapters/aws-stack-repository.test.js +580 -0
- package/package.json +6 -6
|
@@ -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;
|