@gugananuvem/aws-local-simulator 1.0.12 → 1.0.14

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.
Files changed (57) hide show
  1. package/README.md +235 -11
  2. package/package.json +12 -2
  3. package/src/config/default-config.js +1 -0
  4. package/src/index.js +18 -2
  5. package/src/server.js +36 -32
  6. package/src/services/apigateway/index.js +5 -0
  7. package/src/services/apigateway/server.js +20 -0
  8. package/src/services/apigateway/simulator.js +13 -3
  9. package/src/services/athena/index.js +75 -0
  10. package/src/services/athena/server.js +101 -0
  11. package/src/services/athena/simulador.js +998 -0
  12. package/src/services/athena/simulator.js +346 -0
  13. package/src/services/cloudformation/index.js +106 -0
  14. package/src/services/cloudformation/server.js +417 -0
  15. package/src/services/cloudformation/simulador.js +1045 -0
  16. package/src/services/cloudtrail/index.js +84 -0
  17. package/src/services/cloudtrail/server.js +235 -0
  18. package/src/services/cloudtrail/simulador.js +719 -0
  19. package/src/services/cloudwatch/index.js +84 -0
  20. package/src/services/cloudwatch/server.js +366 -0
  21. package/src/services/cloudwatch/simulador.js +1173 -0
  22. package/src/services/cognito/index.js +5 -0
  23. package/src/services/cognito/simulator.js +4 -0
  24. package/src/services/config/index.js +96 -0
  25. package/src/services/config/server.js +215 -0
  26. package/src/services/config/simulador.js +1260 -0
  27. package/src/services/dynamodb/index.js +7 -3
  28. package/src/services/dynamodb/server.js +4 -2
  29. package/src/services/dynamodb/simulator.js +39 -29
  30. package/src/services/eventbridge/index.js +55 -51
  31. package/src/services/eventbridge/server.js +209 -0
  32. package/src/services/eventbridge/simulator.js +684 -0
  33. package/src/services/index.js +30 -4
  34. package/src/services/kms/index.js +75 -0
  35. package/src/services/kms/server.js +67 -0
  36. package/src/services/kms/simulator.js +324 -0
  37. package/src/services/lambda/index.js +5 -0
  38. package/src/services/lambda/simulator.js +48 -38
  39. package/src/services/parameter-store/index.js +80 -0
  40. package/src/services/parameter-store/server.js +50 -0
  41. package/src/services/parameter-store/simulator.js +201 -0
  42. package/src/services/s3/index.js +7 -3
  43. package/src/services/s3/server.js +20 -13
  44. package/src/services/s3/simulator.js +163 -407
  45. package/src/services/secret-manager/index.js +80 -0
  46. package/src/services/secret-manager/server.js +50 -0
  47. package/src/services/secret-manager/simulator.js +171 -0
  48. package/src/services/sns/index.js +55 -42
  49. package/src/services/sns/server.js +580 -0
  50. package/src/services/sns/simulator.js +1482 -0
  51. package/src/services/sqs/index.js +2 -4
  52. package/src/services/sqs/server.js +4 -2
  53. package/src/services/xray/index.js +83 -0
  54. package/src/services/xray/server.js +308 -0
  55. package/src/services/xray/simulador.js +994 -0
  56. package/src/utils/cloudtrail-audit.js +129 -0
  57. package/src/utils/local-store.js +18 -2
@@ -0,0 +1,1045 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview CloudFormation Simulator
5
+ *
6
+ * Suporta:
7
+ * - CreateStack / UpdateStack / DeleteStack
8
+ * - DescribeStacks / ListStacks
9
+ * - CreateChangeSet / DescribeChangeSet / ExecuteChangeSet / DeleteChangeSet / ListChangeSets
10
+ * - DescribeStackResources / ListStackResources
11
+ * - GetTemplate
12
+ * - ValidateTemplate
13
+ * - Tags
14
+ * - Persistência via LocalStore
15
+ */
16
+
17
+ const { randomUUID } = require('crypto');
18
+ const yaml = require('js-yaml');
19
+ const { CloudTrailAudit } = require('../../utils/cloudtrail-audit');
20
+
21
+ // ─── Erros tipados ───────────────────────────────────────────────────────────
22
+
23
+ class CloudFormationError extends Error {
24
+ constructor(code, message, statusCode = 400) {
25
+ super(message);
26
+ this.code = code;
27
+ this.statusCode = statusCode;
28
+ }
29
+ }
30
+
31
+ const Errors = {
32
+ AlreadyExists: (name) =>
33
+ new CloudFormationError('AlreadyExistsException', `Stack [${name}] already exists`, 400),
34
+ DoesNotExist: (name) =>
35
+ new CloudFormationError('ValidationError', `Stack with id ${name} does not exist`, 400),
36
+ ChangeSetNotFound: (name) =>
37
+ new CloudFormationError('ChangeSetNotFoundException', `ChangeSet [${name}] does not exist`, 404),
38
+ InvalidTemplate: (msg) =>
39
+ new CloudFormationError('ValidationError', `Template format error: ${msg}`, 400),
40
+ InvalidAction: (msg) =>
41
+ new CloudFormationError('ValidationError', msg, 400),
42
+ };
43
+
44
+ // ─── Constantes ──────────────────────────────────────────────────────────────
45
+
46
+ const REGION = 'us-east-1';
47
+ const ACCOUNT = '000000000000';
48
+
49
+ const StackStatus = {
50
+ CREATE_IN_PROGRESS: 'CREATE_IN_PROGRESS',
51
+ CREATE_COMPLETE: 'CREATE_COMPLETE',
52
+ CREATE_FAILED: 'CREATE_FAILED',
53
+ UPDATE_IN_PROGRESS: 'UPDATE_IN_PROGRESS',
54
+ UPDATE_COMPLETE: 'UPDATE_COMPLETE',
55
+ UPDATE_FAILED: 'UPDATE_FAILED',
56
+ DELETE_IN_PROGRESS: 'DELETE_IN_PROGRESS',
57
+ DELETE_COMPLETE: 'DELETE_COMPLETE',
58
+ DELETE_FAILED: 'DELETE_FAILED',
59
+ ROLLBACK_IN_PROGRESS: 'ROLLBACK_IN_PROGRESS',
60
+ ROLLBACK_COMPLETE: 'ROLLBACK_COMPLETE',
61
+ };
62
+
63
+ const ChangeSetStatus = {
64
+ CREATE_PENDING: 'CREATE_PENDING',
65
+ CREATE_IN_PROGRESS: 'CREATE_IN_PROGRESS',
66
+ CREATE_COMPLETE: 'CREATE_COMPLETE',
67
+ DELETE_COMPLETE: 'DELETE_COMPLETE',
68
+ FAILED: 'FAILED',
69
+ };
70
+
71
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
72
+
73
+ function now() {
74
+ return new Date().toISOString();
75
+ }
76
+
77
+ function stackArn(stackName, stackId) {
78
+ return `arn:aws:cloudformation:${REGION}:${ACCOUNT}:stack/${stackName}/${stackId}`;
79
+ }
80
+
81
+ function changeSetArn(stackName, changeSetName) {
82
+ return `arn:aws:cloudformation:${REGION}:${ACCOUNT}:changeSet/${changeSetName}/${stackName}`;
83
+ }
84
+
85
+ // ─── Template Parser ─────────────────────────────────────────────────────────
86
+
87
+ /**
88
+ * Faz parse básico do template (JSON ou YAML string → objeto)
89
+ */
90
+ function parseTemplate(template) {
91
+ if (!template) throw Errors.InvalidTemplate('Template body is required');
92
+ if (typeof template === 'object') return template;
93
+ try {
94
+ return JSON.parse(template);
95
+ } catch (_) {
96
+ try {
97
+ // Schema customizado que trata tags AWS como !Ref, !Sub, !If, etc.
98
+ const awsSchema = yaml.DEFAULT_SCHEMA.extend([
99
+ new yaml.Type('!Ref', { kind: 'scalar', construct: d => ({ Ref: d }) }),
100
+ new yaml.Type('!Sub', { kind: 'scalar', construct: d => ({ 'Fn::Sub': d }) }),
101
+ new yaml.Type('!Sub', { kind: 'sequence', construct: d => ({ 'Fn::Sub': d }) }),
102
+ new yaml.Type('!If', { kind: 'sequence', construct: d => ({ 'Fn::If': d }) }),
103
+ new yaml.Type('!Equals', { kind: 'sequence', construct: d => ({ 'Fn::Equals': d }) }),
104
+ new yaml.Type('!Not', { kind: 'sequence', construct: d => ({ 'Fn::Not': d }) }),
105
+ new yaml.Type('!And', { kind: 'sequence', construct: d => ({ 'Fn::And': d }) }),
106
+ new yaml.Type('!Or', { kind: 'sequence', construct: d => ({ 'Fn::Or': d }) }),
107
+ new yaml.Type('!Select', { kind: 'sequence', construct: d => ({ 'Fn::Select': d }) }),
108
+ new yaml.Type('!Split', { kind: 'sequence', construct: d => ({ 'Fn::Split': d }) }),
109
+ new yaml.Type('!Join', { kind: 'sequence', construct: d => ({ 'Fn::Join': d }) }),
110
+ new yaml.Type('!GetAtt', { kind: 'scalar', construct: d => ({ 'Fn::GetAtt': d.split('.') }) }),
111
+ new yaml.Type('!FindInMap', { kind: 'sequence', construct: d => ({ 'Fn::FindInMap': d }) }),
112
+ new yaml.Type('!Base64', { kind: 'scalar', construct: d => ({ 'Fn::Base64': d }) }),
113
+ new yaml.Type('!Cidr', { kind: 'sequence', construct: d => ({ 'Fn::Cidr': d }) }),
114
+ new yaml.Type('!ImportValue',{ kind: 'scalar', construct: d => ({ 'Fn::ImportValue': d }) }),
115
+ ]);
116
+ return yaml.load(template, { schema: awsSchema });
117
+ } catch (yamlErr) {
118
+ throw Errors.InvalidTemplate(`Could not parse template as JSON or YAML: ${yamlErr.message}`);
119
+ }
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Extrai recursos de um template e gera lista de stack resources
125
+ */
126
+ function extractResources(parsedTemplate, stackName) {
127
+ const resources = [];
128
+ const templateResources = parsedTemplate?.Resources || {};
129
+ for (const [logicalId, resource] of Object.entries(templateResources)) {
130
+ resources.push({
131
+ LogicalResourceId: logicalId,
132
+ PhysicalResourceId: `${stackName}-${logicalId}-${randomUUID().slice(0, 8)}`,
133
+ ResourceType: resource.Type || 'AWS::CloudFormation::WaitConditionHandle',
134
+ ResourceStatus: 'CREATE_COMPLETE',
135
+ Timestamp: now(),
136
+ DriftInformation: { StackResourceDriftStatus: 'NOT_CHECKED' },
137
+ });
138
+ }
139
+ return resources;
140
+ }
141
+
142
+ /**
143
+ * Resolve parâmetros do template
144
+ */
145
+ function resolveParameters(templateParams, inputParams) {
146
+ const resolved = {};
147
+ const templateDefs = templateParams || {};
148
+ const inputMap = {};
149
+
150
+ for (const param of (inputParams || [])) {
151
+ inputMap[param.ParameterKey] = param;
152
+ }
153
+
154
+ for (const [key, def] of Object.entries(templateDefs)) {
155
+ const input = inputMap[key];
156
+ if (input) {
157
+ resolved[key] = input.UsePreviousValue
158
+ ? def.Default || ''
159
+ : (input.ParameterValue || def.Default || '');
160
+ } else {
161
+ resolved[key] = def.Default || '';
162
+ }
163
+ }
164
+
165
+ return Object.entries(resolved).map(([k, v]) => ({
166
+ ParameterKey: k,
167
+ ParameterValue: v,
168
+ ResolvedValue: v,
169
+ }));
170
+ }
171
+
172
+ /**
173
+ * Calcula outputs do template (simples)
174
+ */
175
+ function resolveOutputs(parsedTemplate, stackName) {
176
+ const outputs = [];
177
+ const templateOutputs = parsedTemplate?.Outputs || {};
178
+ for (const [key, def] of Object.entries(templateOutputs)) {
179
+ outputs.push({
180
+ OutputKey: key,
181
+ OutputValue: def.Value || `${stackName}-${key}-output`,
182
+ Description: def.Description || '',
183
+ ExportName: def.Export?.Name || undefined,
184
+ });
185
+ }
186
+ return outputs;
187
+ }
188
+
189
+ // ─── Simulador ───────────────────────────────────────────────────────────────
190
+
191
+ class CloudFormationSimulator {
192
+ /**
193
+ * @param {Object} config
194
+ * @param {Object} store - LocalStore
195
+ * @param {Object} logger
196
+ */
197
+ constructor(config, store, logger) {
198
+ this.config = config;
199
+ this.store = store;
200
+ this.logger = logger;
201
+
202
+ /** @type {Map<string, Object>} stackName → stack */
203
+ this.stacks = new Map();
204
+
205
+ /** @type {Map<string, Object[]>} stackName → resources[] */
206
+ this.stackResources = new Map();
207
+
208
+ /** @type {Map<string, Object>} changeSetArn → changeSet */
209
+ this.changeSets = new Map();
210
+
211
+ this.audit = new CloudTrailAudit('cloudformation.amazonaws.com');
212
+
213
+ // Simuladores injetados via injectDependencies
214
+ this.s3Simulator = null;
215
+ this.sqsSimulator = null;
216
+ this.dynamoSimulator = null;
217
+ this.kmsSimulator = null;
218
+ this.secretsSimulator = null;
219
+ this.parameterStoreSimulator = null;
220
+ this.athenaSimulator = null;
221
+ }
222
+
223
+ // ─── Persistência ──────────────────────────────────────────────
224
+
225
+ async load() {
226
+ try {
227
+ const data = await this.store.read('cloudformation', 'data');
228
+ if (data) {
229
+ if (data.stacks) this.stacks = new Map(Object.entries(data.stacks));
230
+ if (data.stackResources) this.stackResources = new Map(Object.entries(data.stackResources));
231
+ if (data.changeSets) this.changeSets = new Map(Object.entries(data.changeSets));
232
+ this.logger.info('[CloudFormation] Loaded persisted data');
233
+ }
234
+ } catch (_) {
235
+ this.logger.debug('[CloudFormation] No persisted data found, starting fresh');
236
+ }
237
+ }
238
+
239
+ async save() {
240
+ try {
241
+ await this.store.write('cloudformation', 'data', {
242
+ stacks: Object.fromEntries(this.stacks),
243
+ stackResources: Object.fromEntries(this.stackResources),
244
+ changeSets: Object.fromEntries(this.changeSets),
245
+ });
246
+ } catch (err) {
247
+ this.logger.warn(`[CloudFormation] Failed to persist data: ${err.message}`);
248
+ }
249
+ }
250
+
251
+ async reset() {
252
+ this.stacks.clear();
253
+ this.stackResources.clear();
254
+ this.changeSets.clear();
255
+ try { await this.store.clear('cloudformation'); } catch (_) {}
256
+ this.logger.info('[CloudFormation] Reset complete');
257
+ }
258
+
259
+ // ─── Stacks ────────────────────────────────────────────────────
260
+
261
+ /**
262
+ * CreateStack
263
+ */
264
+ async createStack({
265
+ StackName,
266
+ TemplateBody,
267
+ TemplateURL,
268
+ Parameters = [],
269
+ Capabilities = [],
270
+ Tags = [],
271
+ OnFailure = 'ROLLBACK',
272
+ TimeoutInMinutes,
273
+ NotificationARNs = [],
274
+ RoleARN,
275
+ DisableRollback = false,
276
+ }) {
277
+ if (!StackName) throw Errors.InvalidAction('StackName is required');
278
+ if (this.stacks.has(StackName)) throw Errors.AlreadyExists(StackName);
279
+
280
+ const template = parseTemplate(TemplateBody || '{}');
281
+ const stackId = randomUUID();
282
+ const arn = stackArn(StackName, stackId);
283
+ const resolvedParams = resolveParameters(template.Parameters, Parameters);
284
+ const outputs = resolveOutputs(template, StackName);
285
+ const resources = extractResources(template, StackName);
286
+
287
+ const stack = {
288
+ StackId: arn,
289
+ StackName,
290
+ StackStatus: StackStatus.CREATE_COMPLETE,
291
+ StackStatusReason: 'Stack created successfully',
292
+ CreationTime: now(),
293
+ LastUpdatedTime: now(),
294
+ Parameters: resolvedParams,
295
+ Outputs: outputs,
296
+ Capabilities,
297
+ Tags,
298
+ NotificationARNs,
299
+ RoleARN: RoleARN || '',
300
+ TimeoutInMinutes: TimeoutInMinutes || 0,
301
+ DisableRollback,
302
+ OnFailure,
303
+ TemplateBody: TemplateBody || JSON.stringify(template),
304
+ EnableTerminationProtection: false,
305
+ DriftInformation: { StackDriftStatus: 'NOT_CHECKED' },
306
+ };
307
+
308
+ this.stacks.set(StackName, stack);
309
+ this.stackResources.set(StackName, resources);
310
+
311
+ this.logger.info(`[CloudFormation] Created stack: ${StackName} (${resources.length} resources)`);
312
+ await this.save();
313
+
314
+ // Provisiona os recursos nos simuladores injetados
315
+ await this._provisionResources(StackName, template, resolvedParams);
316
+
317
+ this.audit.record({
318
+ eventName: 'CreateStack',
319
+ readOnly: false,
320
+ resources: [{ ARN: arn, type: 'AWS::CloudFormation::Stack' }],
321
+ requestParameters: { stackName: StackName }
322
+ });
323
+
324
+ return { StackId: arn };
325
+ }
326
+
327
+ /**
328
+ * Provisiona recursos do template nos simuladores locais
329
+ */
330
+ async _provisionResources(stackName, template, resolvedParams) {
331
+ const resources = template?.Resources || {};
332
+
333
+ // Helper para resolver !Ref de parâmetros
334
+ const resolveRef = (value) => {
335
+ if (!value) return value;
336
+ if (typeof value === 'object' && value.Ref) {
337
+ const param = resolvedParams.find(p => p.ParameterKey === value.Ref);
338
+ return param ? param.ParameterValue : value.Ref;
339
+ }
340
+ if (typeof value === 'object' && value['Fn::Sub']) {
341
+ return value['Fn::Sub'].replace(/\$\{([^}]+)\}/g, (_, key) => {
342
+ const param = resolvedParams.find(p => p.ParameterKey === key);
343
+ return param ? param.ParameterValue : key;
344
+ });
345
+ }
346
+ return value;
347
+ };
348
+
349
+ const stackResourceList = this.stackResources.get(stackName) || [];
350
+
351
+ for (const [logicalId, resource] of Object.entries(resources)) {
352
+ try {
353
+ const props = resource.Properties || {};
354
+ let physicalId = null;
355
+
356
+ switch (resource.Type) {
357
+
358
+ case 'AWS::S3::Bucket': {
359
+ if (!this.s3Simulator) break;
360
+ const bucketName = resolveRef(props.BucketName) || `${stackName}-${logicalId}`.toLowerCase();
361
+ this.s3Simulator.createBucket(bucketName);
362
+ physicalId = bucketName;
363
+ this.logger.info(`[CloudFormation] Provisionado S3 bucket: ${bucketName}`);
364
+ break;
365
+ }
366
+
367
+ case 'AWS::SQS::Queue': {
368
+ if (!this.sqsSimulator) break;
369
+ const queueName = resolveRef(props.QueueName) || `${stackName}-${logicalId}`;
370
+ this.sqsSimulator.createQueue(queueName);
371
+ physicalId = queueName;
372
+ this.logger.info(`[CloudFormation] Provisionada SQS queue: ${queueName}`);
373
+ break;
374
+ }
375
+
376
+ case 'AWS::DynamoDB::Table': {
377
+ if (!this.dynamoSimulator) break;
378
+ const tableName = resolveRef(props.TableName) || `${stackName}-${logicalId}`;
379
+ const attrDefs = (props.AttributeDefinitions || []).map(a => ({
380
+ AttributeName: resolveRef(a.AttributeName),
381
+ AttributeType: a.AttributeType,
382
+ }));
383
+ const keySchema = (props.KeySchema || []).map(k => ({
384
+ AttributeName: resolveRef(k.AttributeName),
385
+ KeyType: k.KeyType,
386
+ }));
387
+ await this.dynamoSimulator.createTable({
388
+ TableName: tableName,
389
+ AttributeDefinitions: attrDefs,
390
+ KeySchema: keySchema,
391
+ BillingMode: props.BillingMode || 'PAY_PER_REQUEST',
392
+ Tags: props.Tags || [],
393
+ });
394
+ physicalId = tableName;
395
+ this.logger.info(`[CloudFormation] Provisionada DynamoDB table: ${tableName}`);
396
+ break;
397
+ }
398
+
399
+ case 'AWS::Athena::WorkGroup': {
400
+ if (!this.athenaSimulator) break;
401
+ const wgName = resolveRef(props.Name) || `${stackName}-${logicalId}`;
402
+ await this.athenaSimulator.createWorkGroup({
403
+ Name: wgName,
404
+ Description: resolveRef(props.Description) || '',
405
+ Configuration: props.WorkGroupConfiguration || {},
406
+ });
407
+ physicalId = wgName;
408
+ this.logger.info(`[CloudFormation] Provisionado Athena WorkGroup: ${wgName}`);
409
+ break;
410
+ }
411
+
412
+ case 'AWS::KMS::Key': {
413
+ if (!this.kmsSimulator) break;
414
+ const result = await this.kmsSimulator.createKey({
415
+ Description: resolveRef(props.Description) || `${stackName}-${logicalId}`,
416
+ KeyUsage: props.KeyUsage || 'ENCRYPT_DECRYPT',
417
+ KeySpec: props.KeySpec || 'SYMMETRIC_DEFAULT',
418
+ Tags: props.Tags || [],
419
+ });
420
+ physicalId = result.KeyMetadata.KeyId;
421
+ this.logger.info(`[CloudFormation] Provisionada KMS key: ${physicalId}`);
422
+ break;
423
+ }
424
+
425
+ case 'AWS::SecretsManager::Secret': {
426
+ if (!this.secretsSimulator) break;
427
+ const secretName = resolveRef(props.Name) || `${stackName}-${logicalId}`;
428
+ await this.secretsSimulator.createSecret({
429
+ Name: secretName,
430
+ Description: resolveRef(props.Description) || '',
431
+ SecretString: resolveRef(props.SecretString) || '{}',
432
+ Tags: props.Tags || [],
433
+ });
434
+ physicalId = secretName;
435
+ this.logger.info(`[CloudFormation] Provisionado Secret: ${secretName}`);
436
+ break;
437
+ }
438
+
439
+ case 'AWS::SSM::Parameter': {
440
+ if (!this.parameterStoreSimulator) break;
441
+ const paramName = resolveRef(props.Name) || `/${stackName}/${logicalId}`;
442
+ await this.parameterStoreSimulator.putParameter({
443
+ Name: paramName,
444
+ Value: resolveRef(props.Value) || '',
445
+ Type: props.Type || 'String',
446
+ Description: resolveRef(props.Description) || '',
447
+ Tags: props.Tags || [],
448
+ Overwrite: true,
449
+ });
450
+ physicalId = paramName;
451
+ this.logger.info(`[CloudFormation] Provisionado SSM Parameter: ${paramName}`);
452
+ break;
453
+ }
454
+
455
+ default:
456
+ this.logger.debug(`[CloudFormation] Tipo não provisionado localmente: ${resource.Type} (${logicalId})`);
457
+ }
458
+
459
+ // Atualiza o PhysicalResourceId com o nome real do recurso
460
+ if (physicalId) {
461
+ const entry = stackResourceList.find(r => r.LogicalResourceId === logicalId);
462
+ if (entry) entry.PhysicalResourceId = physicalId;
463
+ }
464
+
465
+ } catch (err) {
466
+ this.logger.warn(`[CloudFormation] Erro ao provisionar ${logicalId} (${resource.Type}): ${err.message}`);
467
+ }
468
+ }
469
+ }
470
+
471
+ /**
472
+ * UpdateStack
473
+ */
474
+ async updateStack({
475
+ StackName,
476
+ TemplateBody,
477
+ UsePreviousTemplate = false,
478
+ Parameters = [],
479
+ Capabilities = [],
480
+ Tags,
481
+ RoleARN,
482
+ NotificationARNs,
483
+ }) {
484
+ const stack = this._getStack(StackName);
485
+
486
+ const templateBody = UsePreviousTemplate
487
+ ? stack.TemplateBody
488
+ : (TemplateBody || stack.TemplateBody);
489
+
490
+ const template = parseTemplate(templateBody);
491
+ const resolvedParams = resolveParameters(template.Parameters, Parameters.length ? Parameters : stack.Parameters.map(p => ({ ParameterKey: p.ParameterKey, UsePreviousValue: true })));
492
+ const outputs = resolveOutputs(template, StackName);
493
+ const resources = extractResources(template, StackName);
494
+
495
+ stack.StackStatus = StackStatus.UPDATE_COMPLETE;
496
+ stack.StackStatusReason = 'Stack updated successfully';
497
+ stack.LastUpdatedTime = now();
498
+ stack.TemplateBody = templateBody;
499
+ stack.Parameters = resolvedParams;
500
+ stack.Outputs = outputs;
501
+ if (Tags) stack.Tags = Tags;
502
+ if (RoleARN) stack.RoleARN = RoleARN;
503
+ if (NotificationARNs) stack.NotificationARNs = NotificationARNs;
504
+ if (Capabilities.length) stack.Capabilities = Capabilities;
505
+
506
+ this.stackResources.set(StackName, resources);
507
+
508
+ this.logger.info(`[CloudFormation] Updated stack: ${StackName}`);
509
+ await this.save();
510
+
511
+ return { StackId: stack.StackId };
512
+ }
513
+
514
+ /**
515
+ * DeleteStack
516
+ */
517
+ async deleteStack({ StackName, RetainResources = [] }) {
518
+ if (!this.stacks.has(StackName)) {
519
+ // AWS retorna sucesso mesmo se a stack não existe
520
+ return {};
521
+ }
522
+
523
+ const stack = this.stacks.get(StackName);
524
+
525
+ if (stack.EnableTerminationProtection) {
526
+ throw new CloudFormationError(
527
+ 'ValidationError',
528
+ `Stack [${StackName}] cannot be deleted while TerminationProtection is enabled`,
529
+ 400
530
+ );
531
+ }
532
+
533
+ // Captura recursos antes de remover do map
534
+ const resources = this.stackResources.get(StackName) || [];
535
+
536
+ this.stacks.delete(StackName);
537
+ this.stackResources.delete(StackName);
538
+
539
+ // Remove changesets associados
540
+ for (const [key, cs] of this.changeSets.entries()) {
541
+ if (cs.StackName === StackName) this.changeSets.delete(key);
542
+ }
543
+
544
+ // Deprovisiona os recursos nos simuladores
545
+ await this._deprovisionResources(resources);
546
+
547
+ this.logger.info(`[CloudFormation] Deleted stack: ${StackName}`);
548
+ await this.save();
549
+
550
+ return {};
551
+ }
552
+
553
+ async _deprovisionResources(resources) {
554
+ for (const resource of resources) {
555
+ const { ResourceType, PhysicalResourceId } = resource;
556
+ try {
557
+ switch (ResourceType) {
558
+ case 'AWS::S3::Bucket':
559
+ if (this.s3Simulator && PhysicalResourceId) {
560
+ this.s3Simulator.deleteBucket(PhysicalResourceId);
561
+ this.logger.info(`[CloudFormation] Removido S3 bucket: ${PhysicalResourceId}`);
562
+ }
563
+ break;
564
+
565
+ case 'AWS::SQS::Queue':
566
+ if (this.sqsSimulator && PhysicalResourceId) {
567
+ this.sqsSimulator.deleteQueue(PhysicalResourceId);
568
+ this.logger.info(`[CloudFormation] Removida SQS queue: ${PhysicalResourceId}`);
569
+ }
570
+ break;
571
+
572
+ case 'AWS::DynamoDB::Table':
573
+ if (this.dynamoSimulator && PhysicalResourceId) {
574
+ await this.dynamoSimulator.deleteTable({ TableName: PhysicalResourceId });
575
+ this.logger.info(`[CloudFormation] Removida DynamoDB table: ${PhysicalResourceId}`);
576
+ }
577
+ break;
578
+
579
+ case 'AWS::KMS::Key':
580
+ if (this.kmsSimulator && PhysicalResourceId) {
581
+ await this.kmsSimulator.scheduleKeyDeletion({ KeyId: PhysicalResourceId, PendingWindowInDays: 7 });
582
+ this.logger.info(`[CloudFormation] Agendada exclusão KMS key: ${PhysicalResourceId}`);
583
+ }
584
+ break;
585
+
586
+ case 'AWS::SecretsManager::Secret':
587
+ if (this.secretsSimulator && PhysicalResourceId) {
588
+ await this.secretsSimulator.deleteSecret({ SecretId: PhysicalResourceId, ForceDeleteWithoutRecovery: true });
589
+ this.logger.info(`[CloudFormation] Removido Secret: ${PhysicalResourceId}`);
590
+ }
591
+ break;
592
+
593
+ case 'AWS::SSM::Parameter':
594
+ if (this.parameterStoreSimulator && PhysicalResourceId) {
595
+ await this.parameterStoreSimulator.deleteParameter({ Name: PhysicalResourceId });
596
+ this.logger.info(`[CloudFormation] Removido SSM Parameter: ${PhysicalResourceId}`);
597
+ }
598
+ break;
599
+
600
+ case 'AWS::Athena::WorkGroup':
601
+ if (this.athenaSimulator && PhysicalResourceId) {
602
+ await this.athenaSimulator.deleteWorkGroup({ WorkGroup: PhysicalResourceId, RecursiveDeleteOption: true });
603
+ this.logger.info(`[CloudFormation] Removido Athena WorkGroup: ${PhysicalResourceId}`);
604
+ }
605
+ break;
606
+
607
+ default:
608
+ this.logger.debug(`[CloudFormation] Tipo não desprovisionado: ${ResourceType}`);
609
+ }
610
+ } catch (err) {
611
+ this.logger.warn(`[CloudFormation] Erro ao remover ${ResourceType} (${PhysicalResourceId}): ${err.message}`);
612
+ }
613
+ }
614
+ }
615
+
616
+ /**
617
+ * DescribeStacks
618
+ */
619
+ describeStacks({ StackName } = {}) {
620
+ if (StackName) {
621
+ const stack = this._getStack(StackName);
622
+ return { Stacks: [this._formatStack(stack)] };
623
+ }
624
+ const stacks = Array.from(this.stacks.values()).map(s => this._formatStack(s));
625
+ return { Stacks: stacks };
626
+ }
627
+
628
+ /**
629
+ * ListStacks
630
+ */
631
+ listStacks({ StackStatusFilter = [], NextToken } = {}) {
632
+ let items = Array.from(this.stacks.values());
633
+
634
+ if (StackStatusFilter.length > 0) {
635
+ items = items.filter(s => StackStatusFilter.includes(s.StackStatus));
636
+ }
637
+
638
+ let startIdx = 0;
639
+ if (NextToken) {
640
+ startIdx = parseInt(Buffer.from(NextToken, 'base64').toString('utf8'), 10) || 0;
641
+ }
642
+
643
+ const page = items.slice(startIdx, startIdx + 100).map(s => ({
644
+ StackId: s.StackId,
645
+ StackName: s.StackName,
646
+ StackStatus: s.StackStatus,
647
+ StackStatusReason: s.StackStatusReason,
648
+ CreationTime: s.CreationTime,
649
+ LastUpdatedTime: s.LastUpdatedTime,
650
+ DeletionTime: s.DeletionTime,
651
+ DriftInformation: s.DriftInformation,
652
+ }));
653
+
654
+ const hasMore = startIdx + 100 < items.length;
655
+ const newNextToken = hasMore
656
+ ? Buffer.from(String(startIdx + 100)).toString('base64')
657
+ : undefined;
658
+
659
+ return { StackSummaries: page, NextToken: newNextToken };
660
+ }
661
+
662
+ // ─── Template ──────────────────────────────────────────────────
663
+
664
+ /**
665
+ * ValidateTemplate
666
+ */
667
+ validateTemplate({ TemplateBody, TemplateURL }) {
668
+ const body = TemplateBody || '{}';
669
+ const template = parseTemplate(body);
670
+
671
+ const parameters = Object.entries(template.Parameters || {}).map(([key, def]) => ({
672
+ ParameterKey: key,
673
+ DefaultValue: def.Default || '',
674
+ NoEcho: def.NoEcho || false,
675
+ Description: def.Description || '',
676
+ }));
677
+
678
+ const capabilities = [];
679
+ const resources = Object.values(template.Resources || {});
680
+ const hasIAM = resources.some(r =>
681
+ r.Type && (r.Type.includes('IAM') || r.Type.includes('Role'))
682
+ );
683
+ if (hasIAM) capabilities.push('CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM');
684
+
685
+ return {
686
+ Parameters: parameters,
687
+ Description: template.Description || '',
688
+ Capabilities: capabilities,
689
+ CapabilitiesReason: capabilities.length > 0
690
+ ? 'The following resource(s) require capabilities: [AWS::IAM::Role]'
691
+ : '',
692
+ };
693
+ }
694
+
695
+ /**
696
+ * GetTemplate
697
+ */
698
+ getTemplate({ StackName, TemplateStage = 'Original' }) {
699
+ const stack = this._getStack(StackName);
700
+ return {
701
+ TemplateBody: stack.TemplateBody || '{}',
702
+ StagesAvailable: ['Original', 'Processed'],
703
+ };
704
+ }
705
+
706
+ // ─── StackResources ────────────────────────────────────────────
707
+
708
+ /**
709
+ * DescribeStackResources
710
+ */
711
+ describeStackResources({ StackName, LogicalResourceId }) {
712
+ const resources = this.stackResources.get(StackName) || [];
713
+ let filtered = resources;
714
+ if (LogicalResourceId) {
715
+ filtered = resources.filter(r => r.LogicalResourceId === LogicalResourceId);
716
+ }
717
+ return {
718
+ StackResources: filtered.map(r => ({ ...r, StackName, StackId: this.stacks.get(StackName)?.StackId })),
719
+ };
720
+ }
721
+
722
+ /**
723
+ * ListStackResources
724
+ */
725
+ listStackResources({ StackName, NextToken } = {}) {
726
+ if (!this.stacks.has(StackName)) throw Errors.DoesNotExist(StackName);
727
+ const resources = this.stackResources.get(StackName) || [];
728
+
729
+ let startIdx = 0;
730
+ if (NextToken) {
731
+ startIdx = parseInt(Buffer.from(NextToken, 'base64').toString('utf8'), 10) || 0;
732
+ }
733
+
734
+ const page = resources.slice(startIdx, startIdx + 100);
735
+ const hasMore = startIdx + 100 < resources.length;
736
+ const newNextToken = hasMore
737
+ ? Buffer.from(String(startIdx + 100)).toString('base64')
738
+ : undefined;
739
+
740
+ return {
741
+ StackResourceSummaries: page,
742
+ NextToken: newNextToken,
743
+ };
744
+ }
745
+
746
+ // ─── ChangeSets ────────────────────────────────────────────────
747
+
748
+ /**
749
+ * CreateChangeSet
750
+ */
751
+ async createChangeSet({
752
+ StackName,
753
+ ChangeSetName,
754
+ TemplateBody,
755
+ UsePreviousTemplate = false,
756
+ Parameters = [],
757
+ Capabilities = [],
758
+ Tags = [],
759
+ Description = '',
760
+ ChangeSetType = 'UPDATE',
761
+ }) {
762
+ if (!ChangeSetName) throw Errors.InvalidAction('ChangeSetName is required');
763
+ if (!StackName) throw Errors.InvalidAction('StackName is required');
764
+
765
+ // Para CREATE, a stack não deve existir; para UPDATE, deve existir
766
+ if (ChangeSetType === 'UPDATE' && !this.stacks.has(StackName)) {
767
+ throw Errors.DoesNotExist(StackName);
768
+ }
769
+
770
+ const csArn = changeSetArn(StackName, ChangeSetName);
771
+
772
+ const existingStack = this.stacks.get(StackName);
773
+ const templateBody = UsePreviousTemplate
774
+ ? (existingStack?.TemplateBody || '{}')
775
+ : (TemplateBody || '{}');
776
+
777
+ const template = parseTemplate(templateBody);
778
+ const newResources = extractResources(template, StackName);
779
+ const currentResources = this.stackResources.get(StackName) || [];
780
+
781
+ // Calcula changes (adições, remoções, modificações)
782
+ const changes = this._computeChanges(currentResources, newResources);
783
+
784
+ const changeSet = {
785
+ ChangeSetId: csArn,
786
+ ChangeSetName,
787
+ StackName,
788
+ StackId: existingStack?.StackId || stackArn(StackName, randomUUID()),
789
+ Status: ChangeSetStatus.CREATE_COMPLETE,
790
+ StatusReason: 'Complete',
791
+ Description,
792
+ ChangeSetType,
793
+ CreationTime: now(),
794
+ Parameters,
795
+ Capabilities,
796
+ Tags,
797
+ TemplateBody: templateBody,
798
+ Changes: changes,
799
+ ExecutionStatus: 'AVAILABLE',
800
+ };
801
+
802
+ this.changeSets.set(csArn, changeSet);
803
+
804
+ this.logger.info(`[CloudFormation] Created ChangeSet: ${ChangeSetName} on ${StackName} (${changes.length} changes)`);
805
+ await this.save();
806
+
807
+ return { Id: csArn, StackId: changeSet.StackId };
808
+ }
809
+
810
+ /**
811
+ * DescribeChangeSet
812
+ */
813
+ describeChangeSet({ ChangeSetName, StackName, NextToken }) {
814
+ const changeSet = this._getChangeSet(ChangeSetName, StackName);
815
+ return { ...changeSet };
816
+ }
817
+
818
+ /**
819
+ * ExecuteChangeSet
820
+ */
821
+ async executeChangeSet({ ChangeSetName, StackName, ClientRequestToken }) {
822
+ const changeSet = this._getChangeSet(ChangeSetName, StackName);
823
+
824
+ if (changeSet.ExecutionStatus !== 'AVAILABLE') {
825
+ throw new CloudFormationError(
826
+ 'InvalidChangeSetStatus',
827
+ `ChangeSet [${ChangeSetName}] cannot be executed in its current status [${changeSet.ExecutionStatus}]`,
828
+ 400
829
+ );
830
+ }
831
+
832
+ const template = parseTemplate(changeSet.TemplateBody);
833
+ const resources = extractResources(template, StackName);
834
+ const resolvedParams = resolveParameters(template.Parameters, changeSet.Parameters);
835
+ const outputs = resolveOutputs(template, StackName);
836
+
837
+ if (changeSet.ChangeSetType === 'CREATE') {
838
+ // Cria a stack
839
+ const stackId = randomUUID();
840
+ const arn = stackArn(StackName, stackId);
841
+ const stack = {
842
+ StackId: changeSet.StackId || arn,
843
+ StackName,
844
+ StackStatus: StackStatus.CREATE_COMPLETE,
845
+ StackStatusReason: 'Stack created via ChangeSet',
846
+ CreationTime: now(),
847
+ LastUpdatedTime: now(),
848
+ Parameters: resolvedParams,
849
+ Outputs: outputs,
850
+ Capabilities: changeSet.Capabilities,
851
+ Tags: changeSet.Tags,
852
+ NotificationARNs: [],
853
+ TemplateBody: changeSet.TemplateBody,
854
+ EnableTerminationProtection: false,
855
+ DriftInformation: { StackDriftStatus: 'NOT_CHECKED' },
856
+ };
857
+ this.stacks.set(StackName, stack);
858
+ } else {
859
+ // Atualiza stack existente
860
+ const stack = this.stacks.get(StackName);
861
+ if (stack) {
862
+ stack.StackStatus = StackStatus.UPDATE_COMPLETE;
863
+ stack.StackStatusReason = 'Stack updated via ChangeSet';
864
+ stack.LastUpdatedTime = now();
865
+ stack.TemplateBody = changeSet.TemplateBody;
866
+ stack.Parameters = resolvedParams;
867
+ stack.Outputs = outputs;
868
+ }
869
+ }
870
+
871
+ this.stackResources.set(StackName, resources);
872
+ changeSet.ExecutionStatus = 'EXECUTE_COMPLETE';
873
+ changeSet.Status = 'UPDATE_COMPLETE';
874
+
875
+ this.logger.info(`[CloudFormation] Executed ChangeSet: ${ChangeSetName} on ${StackName}`);
876
+ await this.save();
877
+
878
+ return {};
879
+ }
880
+
881
+ /**
882
+ * DeleteChangeSet
883
+ */
884
+ async deleteChangeSet({ ChangeSetName, StackName }) {
885
+ const changeSet = this._getChangeSet(ChangeSetName, StackName);
886
+ this.changeSets.delete(changeSet.ChangeSetId);
887
+
888
+ this.logger.info(`[CloudFormation] Deleted ChangeSet: ${ChangeSetName}`);
889
+ await this.save();
890
+
891
+ return {};
892
+ }
893
+
894
+ /**
895
+ * ListChangeSets
896
+ */
897
+ listChangeSets({ StackName, NextToken } = {}) {
898
+ const items = Array.from(this.changeSets.values())
899
+ .filter(cs => cs.StackName === StackName)
900
+ .map(cs => ({
901
+ ChangeSetId: cs.ChangeSetId,
902
+ ChangeSetName: cs.ChangeSetName,
903
+ StackId: cs.StackId,
904
+ StackName: cs.StackName,
905
+ ExecutionStatus: cs.ExecutionStatus,
906
+ Status: cs.Status,
907
+ StatusReason: cs.StatusReason,
908
+ Description: cs.Description,
909
+ CreationTime: cs.CreationTime,
910
+ }));
911
+
912
+ return { Summaries: items };
913
+ }
914
+
915
+ // ─── Helpers privados ──────────────────────────────────────────
916
+
917
+ _getStack(nameOrArn) {
918
+ // Busca por nome ou ARN
919
+ if (this.stacks.has(nameOrArn)) return this.stacks.get(nameOrArn);
920
+
921
+ // Tenta por ARN
922
+ for (const stack of this.stacks.values()) {
923
+ if (stack.StackId === nameOrArn) return stack;
924
+ }
925
+
926
+ throw Errors.DoesNotExist(nameOrArn);
927
+ }
928
+
929
+ _getChangeSet(changeSetName, stackName) {
930
+ // Busca por ARN direto
931
+ if (this.changeSets.has(changeSetName)) {
932
+ return this.changeSets.get(changeSetName);
933
+ }
934
+
935
+ // Busca por nome + stackName
936
+ const csArn = changeSetArn(stackName, changeSetName);
937
+ if (this.changeSets.has(csArn)) {
938
+ return this.changeSets.get(csArn);
939
+ }
940
+
941
+ // Busca linear
942
+ for (const cs of this.changeSets.values()) {
943
+ if (cs.ChangeSetName === changeSetName && (!stackName || cs.StackName === stackName)) {
944
+ return cs;
945
+ }
946
+ }
947
+
948
+ throw Errors.ChangeSetNotFound(changeSetName);
949
+ }
950
+
951
+ _formatStack(stack) {
952
+ return {
953
+ StackId: stack.StackId,
954
+ StackName: stack.StackName,
955
+ StackStatus: stack.StackStatus,
956
+ StackStatusReason: stack.StackStatusReason,
957
+ CreationTime: stack.CreationTime,
958
+ LastUpdatedTime: stack.LastUpdatedTime,
959
+ Parameters: stack.Parameters || [],
960
+ Outputs: stack.Outputs || [],
961
+ Capabilities: stack.Capabilities || [],
962
+ Tags: stack.Tags || [],
963
+ NotificationARNs: stack.NotificationARNs || [],
964
+ RoleARN: stack.RoleARN || '',
965
+ EnableTerminationProtection: stack.EnableTerminationProtection || false,
966
+ DriftInformation: stack.DriftInformation || { StackDriftStatus: 'NOT_CHECKED' },
967
+ };
968
+ }
969
+
970
+ _computeChanges(currentResources, newResources) {
971
+ const changes = [];
972
+ const currentMap = new Map(currentResources.map(r => [r.LogicalResourceId, r]));
973
+ const newMap = new Map(newResources.map(r => [r.LogicalResourceId, r]));
974
+
975
+ // Adições
976
+ for (const [id, res] of newMap.entries()) {
977
+ if (!currentMap.has(id)) {
978
+ changes.push({
979
+ Type: 'Resource',
980
+ ResourceChange: {
981
+ Action: 'Add',
982
+ LogicalResourceId: id,
983
+ ResourceType: res.ResourceType,
984
+ Replacement: 'False',
985
+ Scope: [],
986
+ Details: [],
987
+ },
988
+ });
989
+ }
990
+ }
991
+
992
+ // Remoções
993
+ for (const [id, res] of currentMap.entries()) {
994
+ if (!newMap.has(id)) {
995
+ changes.push({
996
+ Type: 'Resource',
997
+ ResourceChange: {
998
+ Action: 'Remove',
999
+ LogicalResourceId: id,
1000
+ PhysicalResourceId: res.PhysicalResourceId,
1001
+ ResourceType: res.ResourceType,
1002
+ Replacement: 'False',
1003
+ Scope: [],
1004
+ Details: [],
1005
+ },
1006
+ });
1007
+ }
1008
+ }
1009
+
1010
+ // Modificações (mesmo ID, tipo pode mudar)
1011
+ for (const [id, res] of newMap.entries()) {
1012
+ if (currentMap.has(id)) {
1013
+ const current = currentMap.get(id);
1014
+ if (current.ResourceType !== res.ResourceType) {
1015
+ changes.push({
1016
+ Type: 'Resource',
1017
+ ResourceChange: {
1018
+ Action: 'Modify',
1019
+ LogicalResourceId: id,
1020
+ PhysicalResourceId: current.PhysicalResourceId,
1021
+ ResourceType: res.ResourceType,
1022
+ Replacement: 'True',
1023
+ Scope: ['Properties'],
1024
+ Details: [],
1025
+ },
1026
+ });
1027
+ }
1028
+ }
1029
+ }
1030
+
1031
+ return changes;
1032
+ }
1033
+
1034
+ // ─── Admin ────────────────────────────────────────────────────
1035
+
1036
+ getStats() {
1037
+ return {
1038
+ stacks: this.stacks.size,
1039
+ changeSets: this.changeSets.size,
1040
+ resources: Array.from(this.stackResources.values()).reduce((acc, r) => acc + r.length, 0),
1041
+ };
1042
+ }
1043
+ }
1044
+
1045
+ module.exports = { CloudFormationSimulator };