@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,1173 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @fileoverview CloudWatch Simulator
5
+ *
6
+ * Suporta:
7
+ * Logs:
8
+ * - CreateLogGroup / DeleteLogGroup / DescribeLogGroups
9
+ * - CreateLogStream / DeleteLogStream / DescribeLogStreams
10
+ * - PutLogEvents / GetLogEvents / FilterLogEvents
11
+ * - PutRetentionPolicy / DeleteRetentionPolicy
12
+ * - PutSubscriptionFilter / DeleteSubscriptionFilter / DescribeSubscriptionFilters
13
+ * - Integração com Lambda (recebe logs automaticamente)
14
+ *
15
+ * Metrics:
16
+ * - PutMetricData
17
+ * - GetMetricStatistics
18
+ * - ListMetrics
19
+ *
20
+ * Alarms:
21
+ * - PutMetricAlarm / DeleteAlarms / DescribeAlarms
22
+ * - SetAlarmState
23
+ * - DescribeAlarmsForMetric
24
+ * - Ações SNS ao mudar de estado
25
+ *
26
+ * Persistência via LocalStore
27
+ */
28
+
29
+ const { randomUUID } = require('crypto');
30
+
31
+ // ─── Erros tipados ────────────────────────────────────────────────────────────
32
+
33
+ class CloudWatchError extends Error {
34
+ constructor(code, message, statusCode = 400) {
35
+ super(message);
36
+ this.code = code;
37
+ this.statusCode = statusCode;
38
+ }
39
+ }
40
+
41
+ const Errors = {
42
+ LogGroupNotFound: (name) =>
43
+ new CloudWatchError('ResourceNotFoundException', `The specified log group does not exist: ${name}`, 404),
44
+ LogStreamNotFound: (name) =>
45
+ new CloudWatchError('ResourceNotFoundException', `The specified log stream does not exist: ${name}`, 404),
46
+ LogGroupAlreadyExists: (name) =>
47
+ new CloudWatchError('ResourceAlreadyExistsException', `The specified log group already exists: ${name}`, 400),
48
+ LogStreamAlreadyExists: (name) =>
49
+ new CloudWatchError('ResourceAlreadyExistsException', `The specified log stream already exists: ${name}`, 400),
50
+ AlarmNotFound: (name) =>
51
+ new CloudWatchError('ResourceNotFoundException', `Alarm [${name}] does not exist`, 404),
52
+ InvalidParameter: (msg) =>
53
+ new CloudWatchError('InvalidParameterValue', msg, 400),
54
+ InvalidToken: () =>
55
+ new CloudWatchError('InvalidParameterException', 'The specified sequence token is invalid', 400),
56
+ };
57
+
58
+ // ─── Constantes ───────────────────────────────────────────────────────────────
59
+
60
+ const REGION = 'us-east-1';
61
+ const ACCOUNT = '000000000000';
62
+
63
+ const AlarmState = {
64
+ OK: 'OK',
65
+ ALARM: 'ALARM',
66
+ INSUFFICIENT_DATA: 'INSUFFICIENT_DATA',
67
+ };
68
+
69
+ const ComparisonOperator = {
70
+ GreaterThanOrEqualToThreshold: 'GreaterThanOrEqualToThreshold',
71
+ GreaterThanThreshold: 'GreaterThanThreshold',
72
+ LessThanThreshold: 'LessThanThreshold',
73
+ LessThanOrEqualToThreshold: 'LessThanOrEqualToThreshold',
74
+ };
75
+
76
+ const Statistic = {
77
+ SampleCount: 'SampleCount',
78
+ Average: 'Average',
79
+ Sum: 'Sum',
80
+ Minimum: 'Minimum',
81
+ Maximum: 'Maximum',
82
+ };
83
+
84
+ // ─── Utilitários ─────────────────────────────────────────────────────────────
85
+
86
+ function nowIso() {
87
+ return new Date().toISOString();
88
+ }
89
+
90
+ function nowMs() {
91
+ return Date.now();
92
+ }
93
+
94
+ function logGroupArn(name) {
95
+ return `arn:aws:logs:${REGION}:${ACCOUNT}:log-group:${name}`;
96
+ }
97
+
98
+ function logStreamArn(groupName, streamName) {
99
+ return `arn:aws:logs:${REGION}:${ACCOUNT}:log-group:${groupName}:log-stream:${streamName}`;
100
+ }
101
+
102
+ function alarmArn(name) {
103
+ return `arn:aws:cloudwatch:${REGION}:${ACCOUNT}:alarm:${name}`;
104
+ }
105
+
106
+ // ─── CloudWatch Simulator ─────────────────────────────────────────────────────
107
+
108
+ class CloudWatchSimulator {
109
+ /**
110
+ * @param {Object} config - Global config
111
+ * @param {Object} store - LocalStore
112
+ * @param {Object} logger - Logger
113
+ */
114
+ constructor(config, store, logger) {
115
+ this.config = config;
116
+ this.store = store;
117
+ this.logger = logger;
118
+
119
+ // Logs
120
+ /** @type {Map<string, Object>} name -> logGroup */
121
+ this.logGroups = new Map();
122
+ /** @type {Map<string, Map<string, Object>>} groupName -> streamName -> logStream */
123
+ this.logStreams = new Map();
124
+ /** @type {Map<string, Array>} `groupName/streamName` -> events[] */
125
+ this.logEvents = new Map();
126
+ /** @type {Map<string, Array>} groupName -> subscriptionFilters[] */
127
+ this.subscriptionFilters = new Map();
128
+
129
+ // Metrics
130
+ /** @type {Array} all metric data points */
131
+ this.metricData = [];
132
+
133
+ // Alarms
134
+ /** @type {Map<string, Object>} name -> alarm */
135
+ this.alarms = new Map();
136
+
137
+ // Referência ao simulador de SNS (injetado depois)
138
+ this.snsSimulator = null;
139
+ this.lambdaSimulator = null;
140
+
141
+ // Config CloudWatch
142
+ this.cwConfig = config?.cloudwatch || {};
143
+ this.retentionDefault = this.cwConfig.retentionInDays || 7;
144
+ this.lambdaLogGroup = this.cwConfig.lambdaLogGroup || '/aws/lambda';
145
+ }
146
+
147
+ // ─── Persistência ─────────────────────────────────────────────────────────
148
+
149
+ async load() {
150
+ try {
151
+ const data = await this.store.load('cloudwatch');
152
+ if (data) {
153
+ if (data.logGroups) {
154
+ this.logGroups = new Map(Object.entries(data.logGroups));
155
+ }
156
+ if (data.logStreams) {
157
+ this.logStreams = new Map(
158
+ Object.entries(data.logStreams).map(([g, streams]) => [
159
+ g,
160
+ new Map(Object.entries(streams)),
161
+ ])
162
+ );
163
+ }
164
+ if (data.logEvents) {
165
+ this.logEvents = new Map(Object.entries(data.logEvents));
166
+ }
167
+ if (data.subscriptionFilters) {
168
+ this.subscriptionFilters = new Map(Object.entries(data.subscriptionFilters));
169
+ }
170
+ if (data.metricData) {
171
+ this.metricData = data.metricData;
172
+ }
173
+ if (data.alarms) {
174
+ this.alarms = new Map(Object.entries(data.alarms));
175
+ }
176
+ this.logger.info('[CloudWatch] Data loaded from store');
177
+ }
178
+ } catch (err) {
179
+ this.logger.warn('[CloudWatch] No persisted data found, starting fresh');
180
+ }
181
+ }
182
+
183
+ async save() {
184
+ try {
185
+ const logStreamsObj = {};
186
+ for (const [g, streams] of this.logStreams.entries()) {
187
+ logStreamsObj[g] = Object.fromEntries(streams);
188
+ }
189
+
190
+ const data = {
191
+ logGroups: Object.fromEntries(this.logGroups),
192
+ logStreams: logStreamsObj,
193
+ logEvents: Object.fromEntries(this.logEvents),
194
+ subscriptionFilters: Object.fromEntries(this.subscriptionFilters),
195
+ metricData: this.metricData,
196
+ alarms: Object.fromEntries(this.alarms),
197
+ };
198
+ await this.store.save('cloudwatch', data);
199
+ } catch (err) {
200
+ this.logger.error('[CloudWatch] Failed to persist data:', err.message);
201
+ }
202
+ }
203
+
204
+ reset() {
205
+ this.logGroups.clear();
206
+ this.logStreams.clear();
207
+ this.logEvents.clear();
208
+ this.subscriptionFilters.clear();
209
+ this.metricData = [];
210
+ this.alarms.clear();
211
+ this.logger.info('[CloudWatch] State reset');
212
+ }
213
+
214
+ // ─── Log Groups ───────────────────────────────────────────────────────────
215
+
216
+ /**
217
+ * CreateLogGroup
218
+ */
219
+ createLogGroup({ logGroupName, retentionInDays, tags }) {
220
+ if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
221
+ if (this.logGroups.has(logGroupName)) throw Errors.LogGroupAlreadyExists(logGroupName);
222
+
223
+ const group = {
224
+ logGroupName,
225
+ arn: logGroupArn(logGroupName),
226
+ creationTime: nowMs(),
227
+ retentionInDays: retentionInDays || this.retentionDefault,
228
+ metricFilterCount: 0,
229
+ storedBytes: 0,
230
+ tags: tags || {},
231
+ };
232
+
233
+ this.logGroups.set(logGroupName, group);
234
+ this.logStreams.set(logGroupName, new Map());
235
+ this.subscriptionFilters.set(logGroupName, []);
236
+
237
+ this.logger.info(`[CloudWatch] Log group created: ${logGroupName}`);
238
+ this.save();
239
+ return group;
240
+ }
241
+
242
+ /**
243
+ * DeleteLogGroup
244
+ */
245
+ deleteLogGroup({ logGroupName }) {
246
+ if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
247
+ if (!this.logGroups.has(logGroupName)) throw Errors.LogGroupNotFound(logGroupName);
248
+
249
+ this.logGroups.delete(logGroupName);
250
+
251
+ // Remove streams and events
252
+ const streams = this.logStreams.get(logGroupName);
253
+ if (streams) {
254
+ for (const streamName of streams.keys()) {
255
+ this.logEvents.delete(`${logGroupName}/${streamName}`);
256
+ }
257
+ }
258
+ this.logStreams.delete(logGroupName);
259
+ this.subscriptionFilters.delete(logGroupName);
260
+
261
+ this.logger.info(`[CloudWatch] Log group deleted: ${logGroupName}`);
262
+ this.save();
263
+ }
264
+
265
+ /**
266
+ * DescribeLogGroups
267
+ */
268
+ describeLogGroups({ logGroupNamePrefix, logGroupNamePattern, limit = 50, nextToken } = {}) {
269
+ let groups = [...this.logGroups.values()];
270
+
271
+ if (logGroupNamePrefix) {
272
+ groups = groups.filter(g => g.logGroupName.startsWith(logGroupNamePrefix));
273
+ }
274
+ if (logGroupNamePattern) {
275
+ const re = new RegExp(logGroupNamePattern);
276
+ groups = groups.filter(g => re.test(g.logGroupName));
277
+ }
278
+
279
+ // Pagination
280
+ let startIdx = 0;
281
+ if (nextToken) {
282
+ startIdx = parseInt(nextToken, 10) || 0;
283
+ }
284
+ const page = groups.slice(startIdx, startIdx + limit);
285
+ const newNextToken = startIdx + limit < groups.length ? String(startIdx + limit) : null;
286
+
287
+ return { logGroups: page, nextToken: newNextToken };
288
+ }
289
+
290
+ /**
291
+ * PutRetentionPolicy
292
+ */
293
+ putRetentionPolicy({ logGroupName, retentionInDays }) {
294
+ if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
295
+ const group = this.logGroups.get(logGroupName);
296
+ if (!group) throw Errors.LogGroupNotFound(logGroupName);
297
+
298
+ group.retentionInDays = retentionInDays;
299
+ this.logger.info(`[CloudWatch] Retention policy set: ${logGroupName} = ${retentionInDays} days`);
300
+ this.save();
301
+ }
302
+
303
+ /**
304
+ * DeleteRetentionPolicy
305
+ */
306
+ deleteRetentionPolicy({ logGroupName }) {
307
+ if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
308
+ const group = this.logGroups.get(logGroupName);
309
+ if (!group) throw Errors.LogGroupNotFound(logGroupName);
310
+
311
+ delete group.retentionInDays;
312
+ this.logger.info(`[CloudWatch] Retention policy deleted: ${logGroupName}`);
313
+ this.save();
314
+ }
315
+
316
+ // ─── Log Streams ──────────────────────────────────────────────────────────
317
+
318
+ /**
319
+ * CreateLogStream
320
+ */
321
+ createLogStream({ logGroupName, logStreamName }) {
322
+ if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
323
+ if (!logStreamName) throw Errors.InvalidParameter('logStreamName is required');
324
+ if (!this.logGroups.has(logGroupName)) throw Errors.LogGroupNotFound(logGroupName);
325
+
326
+ const streams = this.logStreams.get(logGroupName);
327
+ if (streams.has(logStreamName)) throw Errors.LogStreamAlreadyExists(logStreamName);
328
+
329
+ const stream = {
330
+ logStreamName,
331
+ arn: logStreamArn(logGroupName, logStreamName),
332
+ creationTime: nowMs(),
333
+ firstEventTimestamp: null,
334
+ lastEventTimestamp: null,
335
+ lastIngestionTime: null,
336
+ uploadSequenceToken: '1',
337
+ storedBytes: 0,
338
+ };
339
+
340
+ streams.set(logStreamName, stream);
341
+ this.logEvents.set(`${logGroupName}/${logStreamName}`, []);
342
+
343
+ this.logger.info(`[CloudWatch] Log stream created: ${logGroupName}/${logStreamName}`);
344
+ this.save();
345
+ return stream;
346
+ }
347
+
348
+ /**
349
+ * DeleteLogStream
350
+ */
351
+ deleteLogStream({ logGroupName, logStreamName }) {
352
+ if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
353
+ if (!logStreamName) throw Errors.InvalidParameter('logStreamName is required');
354
+ if (!this.logGroups.has(logGroupName)) throw Errors.LogGroupNotFound(logGroupName);
355
+
356
+ const streams = this.logStreams.get(logGroupName);
357
+ if (!streams || !streams.has(logStreamName)) throw Errors.LogStreamNotFound(logStreamName);
358
+
359
+ streams.delete(logStreamName);
360
+ this.logEvents.delete(`${logGroupName}/${logStreamName}`);
361
+
362
+ this.logger.info(`[CloudWatch] Log stream deleted: ${logGroupName}/${logStreamName}`);
363
+ this.save();
364
+ }
365
+
366
+ /**
367
+ * DescribeLogStreams
368
+ */
369
+ describeLogStreams({ logGroupName, logStreamNamePrefix, orderBy = 'LogStreamName', descending = false, limit = 50, nextToken } = {}) {
370
+ if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
371
+ if (!this.logGroups.has(logGroupName)) throw Errors.LogGroupNotFound(logGroupName);
372
+
373
+ const streams = this.logStreams.get(logGroupName);
374
+ let result = streams ? [...streams.values()] : [];
375
+
376
+ if (logStreamNamePrefix) {
377
+ result = result.filter(s => s.logStreamName.startsWith(logStreamNamePrefix));
378
+ }
379
+
380
+ // Ordenação
381
+ result.sort((a, b) => {
382
+ let va, vb;
383
+ if (orderBy === 'LastEventTime') {
384
+ va = a.lastEventTimestamp || 0;
385
+ vb = b.lastEventTimestamp || 0;
386
+ } else {
387
+ va = a.logStreamName;
388
+ vb = b.logStreamName;
389
+ }
390
+ if (va < vb) return descending ? 1 : -1;
391
+ if (va > vb) return descending ? -1 : 1;
392
+ return 0;
393
+ });
394
+
395
+ // Pagination
396
+ let startIdx = 0;
397
+ if (nextToken) startIdx = parseInt(nextToken, 10) || 0;
398
+ const page = result.slice(startIdx, startIdx + limit);
399
+ const newNextToken = startIdx + limit < result.length ? String(startIdx + limit) : null;
400
+
401
+ return { logStreams: page, nextToken: newNextToken };
402
+ }
403
+
404
+ // ─── Log Events ───────────────────────────────────────────────────────────
405
+
406
+ /**
407
+ * PutLogEvents
408
+ */
409
+ async putLogEvents({ logGroupName, logStreamName, logEvents, sequenceToken }) {
410
+ if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
411
+ if (!logStreamName) throw Errors.InvalidParameter('logStreamName is required');
412
+ if (!logEvents || !Array.isArray(logEvents)) throw Errors.InvalidParameter('logEvents must be an array');
413
+
414
+ // Auto-create group/stream se não existirem (comportamento permissivo)
415
+ if (!this.logGroups.has(logGroupName)) {
416
+ this.createLogGroup({ logGroupName });
417
+ }
418
+ const streams = this.logStreams.get(logGroupName);
419
+ if (!streams.has(logStreamName)) {
420
+ this.createLogStream({ logGroupName, logStreamName });
421
+ }
422
+
423
+ const stream = streams.get(logStreamName);
424
+ const key = `${logGroupName}/${logStreamName}`;
425
+ const existing = this.logEvents.get(key) || [];
426
+
427
+ const now = nowMs();
428
+ const newEvents = logEvents.map(e => ({
429
+ timestamp: e.timestamp || now,
430
+ message: e.message || '',
431
+ ingestionTime: now,
432
+ }));
433
+
434
+ // Ordena por timestamp
435
+ newEvents.sort((a, b) => a.timestamp - b.timestamp);
436
+ const all = [...existing, ...newEvents].sort((a, b) => a.timestamp - b.timestamp);
437
+
438
+ // Aplica retenção (remove eventos mais antigos que retentionInDays)
439
+ const group = this.logGroups.get(logGroupName);
440
+ if (group && group.retentionInDays) {
441
+ const cutoff = now - group.retentionInDays * 24 * 60 * 60 * 1000;
442
+ const filtered = all.filter(e => e.timestamp >= cutoff);
443
+ this.logEvents.set(key, filtered);
444
+ } else {
445
+ this.logEvents.set(key, all);
446
+ }
447
+
448
+ // Atualiza metadados do stream
449
+ const timestamps = newEvents.map(e => e.timestamp);
450
+ const minTs = Math.min(...timestamps);
451
+ const maxTs = Math.max(...timestamps);
452
+
453
+ if (!stream.firstEventTimestamp || minTs < stream.firstEventTimestamp) {
454
+ stream.firstEventTimestamp = minTs;
455
+ }
456
+ if (!stream.lastEventTimestamp || maxTs > stream.lastEventTimestamp) {
457
+ stream.lastEventTimestamp = maxTs;
458
+ }
459
+ stream.lastIngestionTime = now;
460
+ stream.uploadSequenceToken = String(parseInt(stream.uploadSequenceToken || '0', 10) + newEvents.length);
461
+
462
+ // Atualiza storedBytes do grupo
463
+ const bytes = newEvents.reduce((acc, e) => acc + Buffer.byteLength(e.message, 'utf8'), 0);
464
+ group.storedBytes = (group.storedBytes || 0) + bytes;
465
+
466
+ this.logger.debug(`[CloudWatch] PutLogEvents: ${logGroupName}/${logStreamName} +${newEvents.length} events`);
467
+
468
+ // Envia para subscription filters
469
+ await this._deliverToSubscriptionFilters(logGroupName, logStreamName, newEvents);
470
+
471
+ this.save();
472
+
473
+ return {
474
+ nextSequenceToken: stream.uploadSequenceToken,
475
+ rejectedLogEventsInfo: null,
476
+ };
477
+ }
478
+
479
+ /**
480
+ * GetLogEvents
481
+ */
482
+ getLogEvents({ logGroupName, logStreamName, startTime, endTime, nextToken, limit = 10000, startFromHead = false }) {
483
+ if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
484
+ if (!logStreamName) throw Errors.InvalidParameter('logStreamName is required');
485
+ if (!this.logGroups.has(logGroupName)) throw Errors.LogGroupNotFound(logGroupName);
486
+
487
+ const streams = this.logStreams.get(logGroupName);
488
+ if (!streams || !streams.has(logStreamName)) throw Errors.LogStreamNotFound(logStreamName);
489
+
490
+ const key = `${logGroupName}/${logStreamName}`;
491
+ let events = this.logEvents.get(key) || [];
492
+
493
+ if (startTime) events = events.filter(e => e.timestamp >= startTime);
494
+ if (endTime) events = events.filter(e => e.timestamp <= endTime);
495
+
496
+ if (!startFromHead) events = [...events].reverse();
497
+
498
+ // Pagination
499
+ let startIdx = 0;
500
+ if (nextToken) startIdx = parseInt(nextToken, 10) || 0;
501
+ const page = events.slice(startIdx, startIdx + limit);
502
+ const newNextToken = startIdx + limit < events.length ? String(startIdx + limit) : null;
503
+
504
+ return {
505
+ events: page,
506
+ nextForwardToken: startFromHead ? newNextToken : null,
507
+ nextBackwardToken: !startFromHead ? newNextToken : null,
508
+ };
509
+ }
510
+
511
+ /**
512
+ * FilterLogEvents
513
+ */
514
+ filterLogEvents({ logGroupName, logStreamNames, startTime, endTime, filterPattern, nextToken, limit = 10000 }) {
515
+ if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
516
+ if (!this.logGroups.has(logGroupName)) throw Errors.LogGroupNotFound(logGroupName);
517
+
518
+ const streams = this.logStreams.get(logGroupName);
519
+ if (!streams) return { events: [], searchedLogStreams: [], nextToken: null };
520
+
521
+ let matchingStreams = [...streams.keys()];
522
+ if (logStreamNames && logStreamNames.length > 0) {
523
+ matchingStreams = matchingStreams.filter(s => logStreamNames.includes(s));
524
+ }
525
+
526
+ let allEvents = [];
527
+ for (const streamName of matchingStreams) {
528
+ const key = `${logGroupName}/${streamName}`;
529
+ const events = (this.logEvents.get(key) || []).map(e => ({ ...e, logStreamName: streamName }));
530
+ allEvents = allEvents.concat(events);
531
+ }
532
+
533
+ // Filtro por tempo
534
+ if (startTime) allEvents = allEvents.filter(e => e.timestamp >= startTime);
535
+ if (endTime) allEvents = allEvents.filter(e => e.timestamp <= endTime);
536
+
537
+ // Filtro por padrão (simples — verifica se a mensagem contém o padrão)
538
+ if (filterPattern) {
539
+ try {
540
+ // Suporta padrões simples: termos literais e "?" (negação)
541
+ const terms = filterPattern.split(/\s+/);
542
+ allEvents = allEvents.filter(e => {
543
+ return terms.every(term => {
544
+ if (term.startsWith('?')) {
545
+ return true; // termo opcional
546
+ }
547
+ return e.message.includes(term);
548
+ });
549
+ });
550
+ } catch (_) {
551
+ // ignora padrões inválidos
552
+ }
553
+ }
554
+
555
+ // Ordena por timestamp
556
+ allEvents.sort((a, b) => a.timestamp - b.timestamp);
557
+
558
+ // Pagination
559
+ let startIdx = 0;
560
+ if (nextToken) startIdx = parseInt(nextToken, 10) || 0;
561
+ const page = allEvents.slice(startIdx, startIdx + limit);
562
+ const newNextToken = startIdx + limit < allEvents.length ? String(startIdx + limit) : null;
563
+
564
+ const searchedLogStreams = matchingStreams.map(s => ({
565
+ logStreamName: s,
566
+ searchedCompletely: true,
567
+ }));
568
+
569
+ return { events: page, searchedLogStreams, nextToken: newNextToken };
570
+ }
571
+
572
+ // ─── Subscription Filters ─────────────────────────────────────────────────
573
+
574
+ /**
575
+ * PutSubscriptionFilter
576
+ */
577
+ putSubscriptionFilter({ logGroupName, filterName, filterPattern, destinationArn, distribution }) {
578
+ if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
579
+ if (!filterName) throw Errors.InvalidParameter('filterName is required');
580
+ if (!destinationArn) throw Errors.InvalidParameter('destinationArn is required');
581
+ if (!this.logGroups.has(logGroupName)) throw Errors.LogGroupNotFound(logGroupName);
582
+
583
+ const filters = this.subscriptionFilters.get(logGroupName) || [];
584
+
585
+ // Remove filter existente com mesmo nome
586
+ const idx = filters.findIndex(f => f.filterName === filterName);
587
+ if (idx !== -1) filters.splice(idx, 1);
588
+
589
+ // Máximo 2 subscription filters por grupo
590
+ if (filters.length >= 2) {
591
+ throw Errors.InvalidParameter('A log group can have at most 2 subscription filters');
592
+ }
593
+
594
+ filters.push({
595
+ filterName,
596
+ filterPattern: filterPattern || '',
597
+ destinationArn,
598
+ distribution: distribution || 'ByLogStream',
599
+ creationTime: nowMs(),
600
+ logGroupName,
601
+ });
602
+
603
+ this.subscriptionFilters.set(logGroupName, filters);
604
+ this.logger.info(`[CloudWatch] Subscription filter set: ${logGroupName} -> ${filterName}`);
605
+ this.save();
606
+ }
607
+
608
+ /**
609
+ * DeleteSubscriptionFilter
610
+ */
611
+ deleteSubscriptionFilter({ logGroupName, filterName }) {
612
+ if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
613
+ if (!filterName) throw Errors.InvalidParameter('filterName is required');
614
+ if (!this.logGroups.has(logGroupName)) throw Errors.LogGroupNotFound(logGroupName);
615
+
616
+ const filters = this.subscriptionFilters.get(logGroupName) || [];
617
+ const idx = filters.findIndex(f => f.filterName === filterName);
618
+ if (idx === -1) throw new CloudWatchError('ResourceNotFoundException', `Subscription filter [${filterName}] not found`, 404);
619
+
620
+ filters.splice(idx, 1);
621
+ this.subscriptionFilters.set(logGroupName, filters);
622
+ this.logger.info(`[CloudWatch] Subscription filter deleted: ${logGroupName}/${filterName}`);
623
+ this.save();
624
+ }
625
+
626
+ /**
627
+ * DescribeSubscriptionFilters
628
+ */
629
+ describeSubscriptionFilters({ logGroupName, filterNamePrefix, limit = 50, nextToken } = {}) {
630
+ if (!logGroupName) throw Errors.InvalidParameter('logGroupName is required');
631
+ if (!this.logGroups.has(logGroupName)) throw Errors.LogGroupNotFound(logGroupName);
632
+
633
+ let filters = this.subscriptionFilters.get(logGroupName) || [];
634
+ if (filterNamePrefix) {
635
+ filters = filters.filter(f => f.filterName.startsWith(filterNamePrefix));
636
+ }
637
+
638
+ let startIdx = 0;
639
+ if (nextToken) startIdx = parseInt(nextToken, 10) || 0;
640
+ const page = filters.slice(startIdx, startIdx + limit);
641
+ const newNextToken = startIdx + limit < filters.length ? String(startIdx + limit) : null;
642
+
643
+ return { subscriptionFilters: page, nextToken: newNextToken };
644
+ }
645
+
646
+ /**
647
+ * Entrega eventos para subscription filters (Lambda ou SQS)
648
+ * @private
649
+ */
650
+ async _deliverToSubscriptionFilters(logGroupName, logStreamName, events) {
651
+ const filters = this.subscriptionFilters.get(logGroupName) || [];
652
+ if (filters.length === 0) return;
653
+
654
+ for (const filter of filters) {
655
+ let matchedEvents = events;
656
+
657
+ // Aplica filtro de padrão
658
+ if (filter.filterPattern) {
659
+ const terms = filter.filterPattern.split(/\s+/).filter(t => t && !t.startsWith('?'));
660
+ matchedEvents = events.filter(e => terms.every(t => e.message.includes(t)));
661
+ }
662
+
663
+ if (matchedEvents.length === 0) continue;
664
+
665
+ const payload = {
666
+ awslogs: {
667
+ data: Buffer.from(
668
+ JSON.stringify({
669
+ messageType: 'DATA_MESSAGE',
670
+ owner: ACCOUNT,
671
+ logGroup: logGroupName,
672
+ logStream: logStreamName,
673
+ subscriptionFilters: [filter.filterName],
674
+ logEvents: matchedEvents.map(e => ({
675
+ id: randomUUID().replace(/-/g, ''),
676
+ timestamp: e.timestamp,
677
+ message: e.message,
678
+ })),
679
+ })
680
+ ).toString('base64'),
681
+ },
682
+ };
683
+
684
+ // Entrega para Lambda se tiver referência
685
+ if (filter.destinationArn.includes(':lambda:') && this.lambdaSimulator) {
686
+ try {
687
+ const fnName = filter.destinationArn.split(':').pop();
688
+ await this.lambdaSimulator.invoke(fnName, payload, { eventType: 'cloudwatch-logs' });
689
+ this.logger.debug(`[CloudWatch] Delivered ${matchedEvents.length} events to Lambda ${fnName}`);
690
+ } catch (err) {
691
+ this.logger.error(`[CloudWatch] Failed to deliver to Lambda: ${err.message}`);
692
+ }
693
+ }
694
+ }
695
+ }
696
+
697
+ // ─── Integração Lambda ────────────────────────────────────────────────────
698
+
699
+ /**
700
+ * Adiciona logs de uma execução Lambda automaticamente
701
+ * Chamado pelo simulador Lambda após cada invocação
702
+ */
703
+ async putLambdaLogs(functionName, requestId, logs) {
704
+ const logGroupName = `${this.lambdaLogGroup}/${functionName}`;
705
+ const logStreamName = `${new Date().toISOString().slice(0, 10).replace(/-/g, '/')}/${randomUUID().slice(0, 8)}`;
706
+
707
+ // Monta eventos de log
708
+ const now = nowMs();
709
+ const logEvents = [
710
+ { timestamp: now, message: `START RequestId: ${requestId} Version: $LATEST` },
711
+ ...logs.map((line, i) => ({ timestamp: now + i + 1, message: String(line) })),
712
+ { timestamp: now + logs.length + 1, message: `END RequestId: ${requestId}` },
713
+ { timestamp: now + logs.length + 2, message: `REPORT RequestId: ${requestId}` },
714
+ ];
715
+
716
+ await this.putLogEvents({ logGroupName, logStreamName, logEvents });
717
+ }
718
+
719
+ // ─── Metrics ─────────────────────────────────────────────────────────────
720
+
721
+ /**
722
+ * PutMetricData
723
+ */
724
+ putMetricData({ namespace, metricData }) {
725
+ if (!namespace) throw Errors.InvalidParameter('namespace is required');
726
+ if (!metricData || !Array.isArray(metricData)) throw Errors.InvalidParameter('metricData must be an array');
727
+
728
+ const now = nowMs();
729
+
730
+ for (const metric of metricData) {
731
+ const point = {
732
+ namespace,
733
+ metricName: metric.metricName,
734
+ dimensions: metric.dimensions || [],
735
+ timestamp: metric.timestamp ? new Date(metric.timestamp).getTime() : now,
736
+ value: metric.value !== undefined ? metric.value : null,
737
+ statistic: metric.statistic || null,
738
+ counts: metric.counts || null,
739
+ values: metric.values || null,
740
+ unit: metric.unit || 'None',
741
+ storageResolution: metric.storageResolution || 60,
742
+ };
743
+ this.metricData.push(point);
744
+ }
745
+
746
+ // Mantém apenas os últimos 14 dias
747
+ const cutoff = now - 14 * 24 * 60 * 60 * 1000;
748
+ this.metricData = this.metricData.filter(p => p.timestamp >= cutoff);
749
+
750
+ this.logger.debug(`[CloudWatch] PutMetricData: ${namespace} +${metricData.length} points`);
751
+
752
+ // Verifica alarms após novo dado
753
+ this._evaluateAlarms(namespace);
754
+
755
+ this.save();
756
+ }
757
+
758
+ /**
759
+ * GetMetricStatistics
760
+ */
761
+ getMetricStatistics({ namespace, metricName, dimensions, startTime, endTime, period, statistics, unit }) {
762
+ if (!namespace) throw Errors.InvalidParameter('namespace is required');
763
+ if (!metricName) throw Errors.InvalidParameter('metricName is required');
764
+ if (!startTime) throw Errors.InvalidParameter('startTime is required');
765
+ if (!endTime) throw Errors.InvalidParameter('endTime is required');
766
+ if (!period) throw Errors.InvalidParameter('period is required');
767
+ if (!statistics || !Array.isArray(statistics)) throw Errors.InvalidParameter('statistics must be an array');
768
+
769
+ const start = new Date(startTime).getTime();
770
+ const end = new Date(endTime).getTime();
771
+
772
+ // Filtra pontos
773
+ let points = this.metricData.filter(p => {
774
+ if (p.namespace !== namespace) return false;
775
+ if (p.metricName !== metricName) return false;
776
+ if (p.timestamp < start || p.timestamp > end) return false;
777
+ if (unit && p.unit !== unit) return false;
778
+
779
+ // Verifica dimensions
780
+ if (dimensions && dimensions.length > 0) {
781
+ const pDims = p.dimensions || [];
782
+ return dimensions.every(d =>
783
+ pDims.some(pd => pd.name === d.name && pd.value === d.value)
784
+ );
785
+ }
786
+ return true;
787
+ });
788
+
789
+ // Agrupa por período
790
+ const buckets = new Map();
791
+ for (const point of points) {
792
+ const bucket = Math.floor((point.timestamp - start) / (period * 1000)) * period * 1000 + start;
793
+ if (!buckets.has(bucket)) buckets.set(bucket, []);
794
+ buckets.get(bucket).push(point.value !== null ? point.value : 0);
795
+ }
796
+
797
+ // Calcula estatísticas por bucket
798
+ const datapoints = [];
799
+ for (const [timestamp, values] of buckets.entries()) {
800
+ const dp = { timestamp: new Date(timestamp).toISOString(), unit: unit || 'None' };
801
+
802
+ if (statistics.includes('SampleCount')) dp.sampleCount = values.length;
803
+ if (statistics.includes('Sum')) dp.sum = values.reduce((a, b) => a + b, 0);
804
+ if (statistics.includes('Average')) dp.average = values.reduce((a, b) => a + b, 0) / values.length;
805
+ if (statistics.includes('Minimum')) dp.minimum = Math.min(...values);
806
+ if (statistics.includes('Maximum')) dp.maximum = Math.max(...values);
807
+
808
+ datapoints.push(dp);
809
+ }
810
+
811
+ datapoints.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp));
812
+
813
+ return { label: metricName, datapoints };
814
+ }
815
+
816
+ /**
817
+ * ListMetrics
818
+ */
819
+ listMetrics({ namespace, metricName, dimensions, nextToken } = {}) {
820
+ const seen = new Map();
821
+
822
+ for (const point of this.metricData) {
823
+ if (namespace && point.namespace !== namespace) continue;
824
+ if (metricName && point.metricName !== metricName) continue;
825
+
826
+ const key = `${point.namespace}/${point.metricName}/${JSON.stringify(point.dimensions || [])}`;
827
+ if (!seen.has(key)) {
828
+ seen.set(key, {
829
+ namespace: point.namespace,
830
+ metricName: point.metricName,
831
+ dimensions: point.dimensions || [],
832
+ });
833
+ }
834
+ }
835
+
836
+ let metrics = [...seen.values()];
837
+
838
+ // Filtro por dimensions
839
+ if (dimensions && dimensions.length > 0) {
840
+ metrics = metrics.filter(m =>
841
+ dimensions.every(d =>
842
+ (m.dimensions || []).some(md => md.name === d.name && md.value === d.value)
843
+ )
844
+ );
845
+ }
846
+
847
+ // Paginação simples
848
+ let startIdx = 0;
849
+ if (nextToken) startIdx = parseInt(nextToken, 10) || 0;
850
+ const limit = 500;
851
+ const page = metrics.slice(startIdx, startIdx + limit);
852
+ const newNextToken = startIdx + limit < metrics.length ? String(startIdx + limit) : null;
853
+
854
+ return { metrics: page, nextToken: newNextToken };
855
+ }
856
+
857
+ // ─── Alarms ───────────────────────────────────────────────────────────────
858
+
859
+ /**
860
+ * PutMetricAlarm
861
+ */
862
+ putMetricAlarm(params) {
863
+ const {
864
+ alarmName,
865
+ alarmDescription,
866
+ actionsEnabled = true,
867
+ okActions = [],
868
+ alarmActions = [],
869
+ insufficientDataActions = [],
870
+ metricName,
871
+ namespace,
872
+ statistic = 'Average',
873
+ dimensions = [],
874
+ period = 60,
875
+ evaluationPeriods = 1,
876
+ datapointsToAlarm,
877
+ threshold,
878
+ comparisonOperator,
879
+ treatMissingData = 'missing',
880
+ unit,
881
+ } = params;
882
+
883
+ if (!alarmName) throw Errors.InvalidParameter('alarmName is required');
884
+ if (!metricName) throw Errors.InvalidParameter('metricName is required');
885
+ if (!namespace) throw Errors.InvalidParameter('namespace is required');
886
+ if (!comparisonOperator) throw Errors.InvalidParameter('comparisonOperator is required');
887
+ if (threshold === undefined) throw Errors.InvalidParameter('threshold is required');
888
+
889
+ const existing = this.alarms.get(alarmName);
890
+ const alarm = {
891
+ alarmName,
892
+ alarmArn: alarmArn(alarmName),
893
+ alarmDescription: alarmDescription || '',
894
+ actionsEnabled,
895
+ okActions,
896
+ alarmActions,
897
+ insufficientDataActions,
898
+ metricName,
899
+ namespace,
900
+ statistic,
901
+ dimensions,
902
+ period,
903
+ evaluationPeriods,
904
+ datapointsToAlarm: datapointsToAlarm || evaluationPeriods,
905
+ threshold,
906
+ comparisonOperator,
907
+ treatMissingData,
908
+ unit: unit || null,
909
+ stateValue: existing ? existing.stateValue : AlarmState.INSUFFICIENT_DATA,
910
+ stateReason: existing ? existing.stateReason : 'Alarm created',
911
+ stateReasonData: existing ? existing.stateReasonData : null,
912
+ stateUpdatedTimestamp: existing ? existing.stateUpdatedTimestamp : nowIso(),
913
+ alarmConfigurationUpdatedTimestamp: nowIso(),
914
+ };
915
+
916
+ this.alarms.set(alarmName, alarm);
917
+ this.logger.info(`[CloudWatch] Alarm created/updated: ${alarmName}`);
918
+ this.save();
919
+
920
+ // Avalia imediatamente
921
+ this._evaluateAlarm(alarm);
922
+
923
+ return alarm;
924
+ }
925
+
926
+ /**
927
+ * DeleteAlarms
928
+ */
929
+ deleteAlarms({ alarmNames }) {
930
+ if (!alarmNames || !Array.isArray(alarmNames)) throw Errors.InvalidParameter('alarmNames is required');
931
+
932
+ for (const name of alarmNames) {
933
+ this.alarms.delete(name);
934
+ this.logger.info(`[CloudWatch] Alarm deleted: ${name}`);
935
+ }
936
+ this.save();
937
+ }
938
+
939
+ /**
940
+ * DescribeAlarms
941
+ */
942
+ describeAlarms({ alarmNames, alarmNamePrefix, stateValue, actionPrefix, maxRecords = 50, nextToken } = {}) {
943
+ let alarms = [...this.alarms.values()];
944
+
945
+ if (alarmNames && alarmNames.length > 0) {
946
+ alarms = alarms.filter(a => alarmNames.includes(a.alarmName));
947
+ }
948
+ if (alarmNamePrefix) {
949
+ alarms = alarms.filter(a => a.alarmName.startsWith(alarmNamePrefix));
950
+ }
951
+ if (stateValue) {
952
+ alarms = alarms.filter(a => a.stateValue === stateValue);
953
+ }
954
+ if (actionPrefix) {
955
+ alarms = alarms.filter(a =>
956
+ [...(a.alarmActions || []), ...(a.okActions || []), ...(a.insufficientDataActions || [])]
957
+ .some(action => action.startsWith(actionPrefix))
958
+ );
959
+ }
960
+
961
+ let startIdx = 0;
962
+ if (nextToken) startIdx = parseInt(nextToken, 10) || 0;
963
+ const page = alarms.slice(startIdx, startIdx + maxRecords);
964
+ const newNextToken = startIdx + maxRecords < alarms.length ? String(startIdx + maxRecords) : null;
965
+
966
+ return { metricAlarms: page, nextToken: newNextToken };
967
+ }
968
+
969
+ /**
970
+ * DescribeAlarmsForMetric
971
+ */
972
+ describeAlarmsForMetric({ metricName, namespace, statistic, dimensions, period, unit } = {}) {
973
+ if (!metricName) throw Errors.InvalidParameter('metricName is required');
974
+ if (!namespace) throw Errors.InvalidParameter('namespace is required');
975
+
976
+ let alarms = [...this.alarms.values()].filter(a =>
977
+ a.metricName === metricName && a.namespace === namespace
978
+ );
979
+
980
+ if (statistic) alarms = alarms.filter(a => a.statistic === statistic);
981
+ if (period) alarms = alarms.filter(a => a.period === period);
982
+ if (unit) alarms = alarms.filter(a => a.unit === unit);
983
+
984
+ return { metricAlarms: alarms };
985
+ }
986
+
987
+ /**
988
+ * SetAlarmState
989
+ */
990
+ setAlarmState({ alarmName, stateValue, stateReason, stateReasonData }) {
991
+ if (!alarmName) throw Errors.InvalidParameter('alarmName is required');
992
+ if (!stateValue) throw Errors.InvalidParameter('stateValue is required');
993
+ if (!stateReason) throw Errors.InvalidParameter('stateReason is required');
994
+
995
+ const alarm = this.alarms.get(alarmName);
996
+ if (!alarm) throw Errors.AlarmNotFound(alarmName);
997
+
998
+ const prevState = alarm.stateValue;
999
+ alarm.stateValue = stateValue;
1000
+ alarm.stateReason = stateReason;
1001
+ alarm.stateReasonData = stateReasonData || null;
1002
+ alarm.stateUpdatedTimestamp = nowIso();
1003
+
1004
+ this.logger.info(`[CloudWatch] Alarm state set: ${alarmName} -> ${stateValue}`);
1005
+ this.save();
1006
+
1007
+ // Dispara ações se mudou de estado
1008
+ if (prevState !== stateValue) {
1009
+ this._triggerAlarmActions(alarm, prevState);
1010
+ }
1011
+ }
1012
+
1013
+ /**
1014
+ * Avalia todos os alarms de um namespace após novos dados
1015
+ * @private
1016
+ */
1017
+ _evaluateAlarms(namespace) {
1018
+ for (const alarm of this.alarms.values()) {
1019
+ if (alarm.namespace === namespace) {
1020
+ this._evaluateAlarm(alarm);
1021
+ }
1022
+ }
1023
+ }
1024
+
1025
+ /**
1026
+ * Avalia um alarm individual com base nos dados de métrica recentes
1027
+ * @private
1028
+ */
1029
+ _evaluateAlarm(alarm) {
1030
+ const now = nowMs();
1031
+ const windowStart = now - alarm.evaluationPeriods * alarm.period * 1000;
1032
+
1033
+ let points = this.metricData.filter(p => {
1034
+ if (p.namespace !== alarm.namespace) return false;
1035
+ if (p.metricName !== alarm.metricName) return false;
1036
+ if (p.timestamp < windowStart) return false;
1037
+
1038
+ if (alarm.dimensions && alarm.dimensions.length > 0) {
1039
+ const pDims = p.dimensions || [];
1040
+ return alarm.dimensions.every(d =>
1041
+ pDims.some(pd => pd.name === d.name && pd.value === d.value)
1042
+ );
1043
+ }
1044
+ return true;
1045
+ });
1046
+
1047
+ if (points.length === 0) {
1048
+ const newState = alarm.treatMissingData === 'breaching'
1049
+ ? AlarmState.ALARM
1050
+ : alarm.treatMissingData === 'notBreaching'
1051
+ ? AlarmState.OK
1052
+ : AlarmState.INSUFFICIENT_DATA;
1053
+
1054
+ this._updateAlarmState(alarm, newState, 'Insufficient data for evaluation');
1055
+ return;
1056
+ }
1057
+
1058
+ // Calcula estatística
1059
+ const values = points.map(p => p.value !== null ? p.value : 0);
1060
+ let statValue;
1061
+ switch (alarm.statistic) {
1062
+ case 'Sum': statValue = values.reduce((a, b) => a + b, 0); break;
1063
+ case 'Minimum': statValue = Math.min(...values); break;
1064
+ case 'Maximum': statValue = Math.max(...values); break;
1065
+ case 'SampleCount': statValue = values.length; break;
1066
+ case 'Average':
1067
+ default: statValue = values.reduce((a, b) => a + b, 0) / values.length;
1068
+ }
1069
+
1070
+ // Compara com threshold
1071
+ let breaching = false;
1072
+ switch (alarm.comparisonOperator) {
1073
+ case 'GreaterThanOrEqualToThreshold': breaching = statValue >= alarm.threshold; break;
1074
+ case 'GreaterThanThreshold': breaching = statValue > alarm.threshold; break;
1075
+ case 'LessThanThreshold': breaching = statValue < alarm.threshold; break;
1076
+ case 'LessThanOrEqualToThreshold': breaching = statValue <= alarm.threshold; break;
1077
+ }
1078
+
1079
+ const newState = breaching ? AlarmState.ALARM : AlarmState.OK;
1080
+ const reason = `Threshold Crossed: ${statValue} ${alarm.comparisonOperator} ${alarm.threshold}`;
1081
+ this._updateAlarmState(alarm, newState, reason);
1082
+ }
1083
+
1084
+ /**
1085
+ * Atualiza estado de um alarm e dispara ações se mudou
1086
+ * @private
1087
+ */
1088
+ _updateAlarmState(alarm, newState, reason) {
1089
+ if (alarm.stateValue === newState) return;
1090
+
1091
+ const prevState = alarm.stateValue;
1092
+ alarm.stateValue = newState;
1093
+ alarm.stateReason = reason;
1094
+ alarm.stateUpdatedTimestamp = nowIso();
1095
+
1096
+ this.logger.info(`[CloudWatch] Alarm state changed: ${alarm.alarmName} ${prevState} -> ${newState}`);
1097
+ this._triggerAlarmActions(alarm, prevState);
1098
+ }
1099
+
1100
+ /**
1101
+ * Dispara ações de alarm (SNS)
1102
+ * @private
1103
+ */
1104
+ async _triggerAlarmActions(alarm, prevState) {
1105
+ if (!alarm.actionsEnabled) return;
1106
+
1107
+ let actions = [];
1108
+ if (alarm.stateValue === AlarmState.ALARM) actions = alarm.alarmActions || [];
1109
+ else if (alarm.stateValue === AlarmState.OK) actions = alarm.okActions || [];
1110
+ else if (alarm.stateValue === AlarmState.INSUFFICIENT_DATA) actions = alarm.insufficientDataActions || [];
1111
+
1112
+ for (const actionArn of actions) {
1113
+ if (actionArn.includes(':sns:') && this.snsSimulator) {
1114
+ try {
1115
+ const message = JSON.stringify({
1116
+ AlarmName: alarm.alarmName,
1117
+ AlarmDescription: alarm.alarmDescription,
1118
+ AWSAccountId: ACCOUNT,
1119
+ NewStateValue: alarm.stateValue,
1120
+ NewStateReason: alarm.stateReason,
1121
+ OldStateValue: prevState,
1122
+ Trigger: {
1123
+ MetricName: alarm.metricName,
1124
+ Namespace: alarm.namespace,
1125
+ Threshold: alarm.threshold,
1126
+ },
1127
+ });
1128
+
1129
+ const topicArn = actionArn;
1130
+ await this.snsSimulator.publish({ topicArn, message, subject: `ALARM: ${alarm.alarmName}` });
1131
+ this.logger.info(`[CloudWatch] Alarm action triggered: ${alarm.alarmName} -> SNS ${topicArn}`);
1132
+ } catch (err) {
1133
+ this.logger.error(`[CloudWatch] Failed to trigger alarm action: ${err.message}`);
1134
+ }
1135
+ }
1136
+ }
1137
+ }
1138
+
1139
+ // ─── Admin ────────────────────────────────────────────────────────────────
1140
+
1141
+ getStatus() {
1142
+ return {
1143
+ logGroups: this.logGroups.size,
1144
+ logStreams: [...this.logStreams.values()].reduce((acc, s) => acc + s.size, 0),
1145
+ logEventCount: [...this.logEvents.values()].reduce((acc, e) => acc + e.length, 0),
1146
+ metricDataPoints: this.metricData.length,
1147
+ alarms: this.alarms.size,
1148
+ };
1149
+ }
1150
+
1151
+ listAdminLogGroups() {
1152
+ return [...this.logGroups.values()].map(g => ({
1153
+ ...g,
1154
+ streamCount: (this.logStreams.get(g.logGroupName) || new Map()).size,
1155
+ }));
1156
+ }
1157
+
1158
+ listAdminAlarms() {
1159
+ return [...this.alarms.values()];
1160
+ }
1161
+
1162
+ listAdminMetrics() {
1163
+ const seen = new Map();
1164
+ for (const p of this.metricData) {
1165
+ const key = `${p.namespace}/${p.metricName}`;
1166
+ if (!seen.has(key)) seen.set(key, { namespace: p.namespace, metricName: p.metricName, count: 0 });
1167
+ seen.get(key).count++;
1168
+ }
1169
+ return [...seen.values()];
1170
+ }
1171
+ }
1172
+
1173
+ module.exports = { CloudWatchSimulator };