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