@gugananuvem/aws-local-simulator 1.0.33 → 1.0.34

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