@jadenrazo/cloudcost-mcp 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +129 -39
- package/data/resource-equivalents.json +154 -15
- package/dist/{chunk-XDBTEI42.js → chunk-TRRAOOVF.js} +6 -16
- package/dist/{chunk-EI63JCAA.js → chunk-VP34WG7Z.js} +4030 -2048
- package/dist/cli.js +12 -12
- package/dist/index.js +902 -42
- package/dist/{loader-INGM66QV.js → loader-VXYJYDIH.js} +2 -2
- package/package.json +30 -10
package/dist/index.js
CHANGED
|
@@ -1,25 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
|
+
CostEngine,
|
|
3
4
|
PricingCache,
|
|
4
5
|
PricingEngine,
|
|
6
|
+
SUPPORTED_CURRENCIES,
|
|
5
7
|
analyzeTerraform,
|
|
6
8
|
analyzeTerraformSchema,
|
|
9
|
+
bool,
|
|
7
10
|
compareProviders,
|
|
8
11
|
compareProvidersSchema,
|
|
12
|
+
convertBreakdownCurrency,
|
|
13
|
+
convertCurrency,
|
|
9
14
|
estimateCost,
|
|
10
15
|
estimateCostSchema,
|
|
11
16
|
loadConfig,
|
|
12
17
|
logger,
|
|
13
18
|
mapInstance,
|
|
19
|
+
mapRegion,
|
|
20
|
+
num,
|
|
14
21
|
optimizeCost,
|
|
15
22
|
optimizeCostSchema,
|
|
23
|
+
parseTerraform,
|
|
16
24
|
setLogLevel,
|
|
25
|
+
str,
|
|
17
26
|
whatIf,
|
|
18
27
|
whatIfSchema
|
|
19
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-VP34WG7Z.js";
|
|
20
29
|
import {
|
|
21
30
|
getResourceEquivalents
|
|
22
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-TRRAOOVF.js";
|
|
23
32
|
|
|
24
33
|
// src/server.ts
|
|
25
34
|
import { createRequire } from "module";
|
|
@@ -35,17 +44,13 @@ function findEquivalent(resourceType, sourceProvider, targetProvider) {
|
|
|
35
44
|
return resourceType;
|
|
36
45
|
}
|
|
37
46
|
const equivalents = getResourceEquivalents();
|
|
38
|
-
const row = equivalents.find(
|
|
39
|
-
(entry) => entry[sourceProvider] === resourceType
|
|
40
|
-
);
|
|
47
|
+
const row = equivalents.find((entry) => entry[sourceProvider] === resourceType);
|
|
41
48
|
if (!row) return null;
|
|
42
49
|
return row[targetProvider] ?? null;
|
|
43
50
|
}
|
|
44
51
|
function findAllEquivalents(resourceType, sourceProvider) {
|
|
45
52
|
const equivalents = getResourceEquivalents();
|
|
46
|
-
const row = equivalents.find(
|
|
47
|
-
(entry) => entry[sourceProvider] === resourceType
|
|
48
|
-
);
|
|
53
|
+
const row = equivalents.find((entry) => entry[sourceProvider] === resourceType);
|
|
49
54
|
if (!row) return {};
|
|
50
55
|
const result = {};
|
|
51
56
|
const providers = ["aws", "azure", "gcp"];
|
|
@@ -60,9 +65,7 @@ function findAllEquivalents(resourceType, sourceProvider) {
|
|
|
60
65
|
|
|
61
66
|
// src/tools/get-equivalents.ts
|
|
62
67
|
var getEquivalentsSchema = z.object({
|
|
63
|
-
resource_type: z.string().describe(
|
|
64
|
-
"Terraform resource type to look up (e.g. aws_instance, google_compute_instance)"
|
|
65
|
-
),
|
|
68
|
+
resource_type: z.string().describe("Terraform resource type to look up (e.g. aws_instance, google_compute_instance)"),
|
|
66
69
|
source_provider: z.enum(["aws", "azure", "gcp"]).describe("Cloud provider the resource type belongs to"),
|
|
67
70
|
target_provider: z.enum(["aws", "azure", "gcp"]).optional().describe(
|
|
68
71
|
"Specific target provider. When omitted, equivalents for all providers are returned."
|
|
@@ -76,11 +79,7 @@ async function getEquivalents(params) {
|
|
|
76
79
|
let resourceEquivalents;
|
|
77
80
|
if (params.target_provider) {
|
|
78
81
|
const targetProvider = params.target_provider;
|
|
79
|
-
const equivalent = findEquivalent(
|
|
80
|
-
params.resource_type,
|
|
81
|
-
sourceProvider,
|
|
82
|
-
targetProvider
|
|
83
|
-
);
|
|
82
|
+
const equivalent = findEquivalent(params.resource_type, sourceProvider, targetProvider);
|
|
84
83
|
resourceEquivalents = { [targetProvider]: equivalent };
|
|
85
84
|
} else {
|
|
86
85
|
resourceEquivalents = findAllEquivalents(params.resource_type, sourceProvider);
|
|
@@ -91,29 +90,38 @@ async function getEquivalents(params) {
|
|
|
91
90
|
const targets = params.target_provider ? [params.target_provider] : providers.filter((p) => p !== sourceProvider);
|
|
92
91
|
instanceEquivalents = {};
|
|
93
92
|
for (const target of targets) {
|
|
94
|
-
instanceEquivalents[target] = mapInstance(
|
|
95
|
-
params.instance_type,
|
|
96
|
-
sourceProvider,
|
|
97
|
-
target
|
|
98
|
-
);
|
|
93
|
+
instanceEquivalents[target] = mapInstance(params.instance_type, sourceProvider, target);
|
|
99
94
|
}
|
|
100
95
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
instance_type: params.instance_type,
|
|
107
|
-
instance_equivalents: instanceEquivalents
|
|
108
|
-
} : {}
|
|
96
|
+
const filteredResourceEquivalents = Object.fromEntries(
|
|
97
|
+
Object.entries(resourceEquivalents).filter(([, v]) => v !== null)
|
|
98
|
+
);
|
|
99
|
+
const result = {
|
|
100
|
+
resource_equivalents: filteredResourceEquivalents
|
|
109
101
|
};
|
|
102
|
+
if (instanceEquivalents !== void 0) {
|
|
103
|
+
const filteredInstanceEquivalents = Object.fromEntries(
|
|
104
|
+
Object.entries(instanceEquivalents).filter(([, v]) => v !== null)
|
|
105
|
+
);
|
|
106
|
+
result.instance_type = params.instance_type;
|
|
107
|
+
result.instance_equivalents = filteredInstanceEquivalents;
|
|
108
|
+
}
|
|
109
|
+
return result;
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
// src/tools/get-pricing.ts
|
|
113
113
|
import { z as z2 } from "zod";
|
|
114
114
|
var getPricingSchema = z2.object({
|
|
115
115
|
provider: z2.enum(["aws", "azure", "gcp"]).describe("Cloud provider to look up pricing for"),
|
|
116
|
-
service: z2.enum([
|
|
116
|
+
service: z2.enum([
|
|
117
|
+
"compute",
|
|
118
|
+
"database",
|
|
119
|
+
"storage",
|
|
120
|
+
"network",
|
|
121
|
+
"load_balancer",
|
|
122
|
+
"nat_gateway",
|
|
123
|
+
"kubernetes"
|
|
124
|
+
]).describe("Service category (network defaults to nat_gateway for backward compatibility)"),
|
|
117
125
|
resource_type: z2.string().describe(
|
|
118
126
|
"Instance type, storage type, or resource identifier (e.g. t3.large, gp3, Standard_D4s_v3)"
|
|
119
127
|
),
|
|
@@ -131,20 +139,828 @@ async function getPricing(params, pricingEngine) {
|
|
|
131
139
|
kubernetes: "kubernetes"
|
|
132
140
|
};
|
|
133
141
|
const service = serviceMap[params.service] ?? params.service;
|
|
134
|
-
const
|
|
142
|
+
const rawPrice = await pricingEngine.getPrice(
|
|
135
143
|
provider,
|
|
136
144
|
service,
|
|
137
145
|
params.resource_type,
|
|
138
146
|
params.region
|
|
139
147
|
);
|
|
148
|
+
let price = null;
|
|
149
|
+
if (rawPrice !== null) {
|
|
150
|
+
const {
|
|
151
|
+
description: _d,
|
|
152
|
+
attributes,
|
|
153
|
+
...priceRest
|
|
154
|
+
} = rawPrice;
|
|
155
|
+
price = { ...priceRest };
|
|
156
|
+
if (attributes && Object.keys(attributes).length > 0) {
|
|
157
|
+
price.attributes = attributes;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { price };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/tools/analyze-plan.ts
|
|
164
|
+
import { z as z3 } from "zod";
|
|
165
|
+
|
|
166
|
+
// src/parsers/terraform/plan-parser.ts
|
|
167
|
+
var PROVIDER_MAP = {
|
|
168
|
+
aws: "aws",
|
|
169
|
+
azurerm: "azure",
|
|
170
|
+
google: "gcp"
|
|
171
|
+
};
|
|
172
|
+
function detectProviderFromName(providerName) {
|
|
173
|
+
const segments = providerName.split("/");
|
|
174
|
+
const shortName = segments[segments.length - 1];
|
|
175
|
+
return PROVIDER_MAP[shortName] ?? "aws";
|
|
176
|
+
}
|
|
177
|
+
var DEFAULT_REGIONS = {
|
|
178
|
+
aws: "us-east-1",
|
|
179
|
+
azure: "eastus",
|
|
180
|
+
gcp: "us-central1"
|
|
181
|
+
};
|
|
182
|
+
function detectRegion(attrs, provider) {
|
|
183
|
+
if (!attrs) return DEFAULT_REGIONS[provider];
|
|
184
|
+
if (provider === "aws") {
|
|
185
|
+
const az = typeof attrs["availability_zone"] === "string" ? attrs["availability_zone"] : null;
|
|
186
|
+
if (az) {
|
|
187
|
+
return az.replace(/[a-z]$/, "");
|
|
188
|
+
}
|
|
189
|
+
const region = typeof attrs["region"] === "string" ? attrs["region"] : null;
|
|
190
|
+
if (region) return region;
|
|
191
|
+
}
|
|
192
|
+
if (provider === "azure") {
|
|
193
|
+
const loc = typeof attrs["location"] === "string" ? attrs["location"] : null;
|
|
194
|
+
if (loc) return loc;
|
|
195
|
+
}
|
|
196
|
+
if (provider === "gcp") {
|
|
197
|
+
const zone = typeof attrs["zone"] === "string" ? attrs["zone"] : null;
|
|
198
|
+
if (zone) {
|
|
199
|
+
const parts = zone.split("-");
|
|
200
|
+
return parts.length > 2 ? parts.slice(0, -1).join("-") : zone;
|
|
201
|
+
}
|
|
202
|
+
const region = typeof attrs["region"] === "string" ? attrs["region"] : null;
|
|
203
|
+
if (region) return region;
|
|
204
|
+
}
|
|
205
|
+
return DEFAULT_REGIONS[provider];
|
|
206
|
+
}
|
|
207
|
+
var ATTRIBUTE_KEYS = [
|
|
208
|
+
"instance_type",
|
|
209
|
+
"vm_size",
|
|
210
|
+
"machine_type",
|
|
211
|
+
"engine",
|
|
212
|
+
"engine_version",
|
|
213
|
+
"storage_type",
|
|
214
|
+
"iops",
|
|
215
|
+
"throughput_mbps",
|
|
216
|
+
"multi_az",
|
|
217
|
+
"replicas",
|
|
218
|
+
"node_count",
|
|
219
|
+
"min_node_count",
|
|
220
|
+
"max_node_count",
|
|
221
|
+
"os",
|
|
222
|
+
"tier",
|
|
223
|
+
"sku"
|
|
224
|
+
];
|
|
225
|
+
var STORAGE_SIZE_KEYS = ["size", "storage_size_gb", "disk_size_gb", "max_size_gb"];
|
|
226
|
+
function extractAttributes(attrs) {
|
|
227
|
+
const result = {};
|
|
228
|
+
for (const key of ATTRIBUTE_KEYS) {
|
|
229
|
+
if (attrs[key] !== void 0 && attrs[key] !== null) {
|
|
230
|
+
result[key] = attrs[key];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
for (const key of STORAGE_SIZE_KEYS) {
|
|
234
|
+
const val = attrs[key];
|
|
235
|
+
if (typeof val === "number" && val > 0) {
|
|
236
|
+
result.storage_size_gb = val;
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
242
|
+
function extractTags(attrs) {
|
|
243
|
+
const tags = attrs["tags"];
|
|
244
|
+
if (!tags || typeof tags !== "object" || Array.isArray(tags)) return {};
|
|
245
|
+
const result = {};
|
|
246
|
+
for (const [k, v] of Object.entries(tags)) {
|
|
247
|
+
result[k] = String(v);
|
|
248
|
+
}
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
function buildResource(rc, attrs, provider, region) {
|
|
252
|
+
return {
|
|
253
|
+
id: rc.address,
|
|
254
|
+
type: rc.type,
|
|
255
|
+
name: rc.name,
|
|
256
|
+
provider,
|
|
257
|
+
region,
|
|
258
|
+
attributes: extractAttributes(attrs),
|
|
259
|
+
tags: extractTags(attrs),
|
|
260
|
+
source_file: "plan.json"
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
function classifyAction(actions) {
|
|
264
|
+
if (actions.length === 2) {
|
|
265
|
+
if (actions.includes("create") && actions.includes("delete")) {
|
|
266
|
+
return "replace";
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (actions.length === 1) {
|
|
270
|
+
const a = actions[0];
|
|
271
|
+
if (a === "create") return "create";
|
|
272
|
+
if (a === "delete") return "delete";
|
|
273
|
+
if (a === "update") return "update";
|
|
274
|
+
if (a === "no-op") return "no-op";
|
|
275
|
+
if (a === "read") return "no-op";
|
|
276
|
+
}
|
|
277
|
+
return "no-op";
|
|
278
|
+
}
|
|
279
|
+
function buildInventory(resources, warnings) {
|
|
280
|
+
const byType = {};
|
|
281
|
+
for (const r of resources) {
|
|
282
|
+
byType[r.type] = (byType[r.type] ?? 0) + 1;
|
|
283
|
+
}
|
|
284
|
+
const providerCounts = {};
|
|
285
|
+
for (const r of resources) {
|
|
286
|
+
providerCounts[r.provider] = (providerCounts[r.provider] ?? 0) + 1;
|
|
287
|
+
}
|
|
288
|
+
let dominant = "aws";
|
|
289
|
+
let max = 0;
|
|
290
|
+
for (const [p, c] of Object.entries(providerCounts)) {
|
|
291
|
+
if (c > max) {
|
|
292
|
+
max = c;
|
|
293
|
+
dominant = p;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const dominantResource = resources.find((r) => r.provider === dominant);
|
|
297
|
+
const region = dominantResource?.region ?? DEFAULT_REGIONS[dominant];
|
|
298
|
+
return {
|
|
299
|
+
provider: dominant,
|
|
300
|
+
region,
|
|
301
|
+
resources,
|
|
302
|
+
total_count: resources.length,
|
|
303
|
+
by_type: byType,
|
|
304
|
+
parse_warnings: warnings
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
function parseTerraformPlan(planJson) {
|
|
308
|
+
let plan;
|
|
309
|
+
try {
|
|
310
|
+
plan = JSON.parse(planJson);
|
|
311
|
+
} catch (err) {
|
|
312
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
313
|
+
throw new Error(`Invalid plan JSON: ${msg}`);
|
|
314
|
+
}
|
|
315
|
+
if (!Array.isArray(plan.resource_changes)) {
|
|
316
|
+
throw new Error("Invalid plan JSON: missing or non-array resource_changes field");
|
|
317
|
+
}
|
|
318
|
+
const beforeResources = [];
|
|
319
|
+
const afterResources = [];
|
|
320
|
+
const changes = [];
|
|
321
|
+
const warnings = [];
|
|
322
|
+
const summary = {
|
|
323
|
+
creates: 0,
|
|
324
|
+
updates: 0,
|
|
325
|
+
deletes: 0,
|
|
326
|
+
no_ops: 0,
|
|
327
|
+
replaces: 0
|
|
328
|
+
};
|
|
329
|
+
for (const rc of plan.resource_changes) {
|
|
330
|
+
const provider = detectProviderFromName(rc.provider_name);
|
|
331
|
+
const action = classifyAction(rc.change.actions);
|
|
332
|
+
const region = detectRegion(rc.change.after, provider) || detectRegion(rc.change.before, provider);
|
|
333
|
+
if (rc.change.before) {
|
|
334
|
+
beforeResources.push(buildResource(rc, rc.change.before, provider, region));
|
|
335
|
+
}
|
|
336
|
+
if (rc.change.after) {
|
|
337
|
+
afterResources.push(buildResource(rc, rc.change.after, provider, region));
|
|
338
|
+
}
|
|
339
|
+
changes.push({
|
|
340
|
+
address: rc.address,
|
|
341
|
+
type: rc.type,
|
|
342
|
+
action,
|
|
343
|
+
before_attributes: rc.change.before,
|
|
344
|
+
after_attributes: rc.change.after
|
|
345
|
+
});
|
|
346
|
+
switch (action) {
|
|
347
|
+
case "create":
|
|
348
|
+
summary.creates++;
|
|
349
|
+
break;
|
|
350
|
+
case "update":
|
|
351
|
+
summary.updates++;
|
|
352
|
+
break;
|
|
353
|
+
case "delete":
|
|
354
|
+
summary.deletes++;
|
|
355
|
+
break;
|
|
356
|
+
case "replace":
|
|
357
|
+
summary.replaces++;
|
|
358
|
+
break;
|
|
359
|
+
case "no-op":
|
|
360
|
+
summary.no_ops++;
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
logger.debug("parseTerraformPlan complete", {
|
|
365
|
+
resourceChanges: plan.resource_changes.length,
|
|
366
|
+
summary
|
|
367
|
+
});
|
|
368
|
+
return {
|
|
369
|
+
before: buildInventory(beforeResources, warnings),
|
|
370
|
+
after: buildInventory(afterResources, warnings),
|
|
371
|
+
changes,
|
|
372
|
+
summary
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// src/tools/analyze-plan.ts
|
|
377
|
+
var analyzePlanSchema = z3.object({
|
|
378
|
+
plan_json: z3.string().describe("Output of 'terraform show -json <planfile>' or 'terraform plan -json'"),
|
|
379
|
+
provider: z3.enum(["aws", "azure", "gcp"]).optional().describe("Target provider for cost estimation. If omitted, auto-detected from plan."),
|
|
380
|
+
region: z3.string().optional().describe("Target region. If omitted, detected from plan resources."),
|
|
381
|
+
currency: z3.enum(SUPPORTED_CURRENCIES).optional().default("USD").describe("Output currency")
|
|
382
|
+
});
|
|
383
|
+
async function analyzePlan(params, pricingEngine, config) {
|
|
384
|
+
const analysis = parseTerraformPlan(params.plan_json);
|
|
385
|
+
const targetProvider = params.provider ?? analysis.after.provider;
|
|
386
|
+
const targetRegion = params.region ?? mapRegion(analysis.after.region, analysis.after.provider, targetProvider);
|
|
387
|
+
const costEngine = new CostEngine(pricingEngine, config);
|
|
388
|
+
const [beforeBreakdown, afterBreakdown] = await Promise.all([
|
|
389
|
+
analysis.before.resources.length > 0 ? costEngine.calculateBreakdown(analysis.before.resources, targetProvider, targetRegion) : null,
|
|
390
|
+
analysis.after.resources.length > 0 ? costEngine.calculateBreakdown(analysis.after.resources, targetProvider, targetRegion) : null
|
|
391
|
+
]);
|
|
392
|
+
const currency = params.currency ?? "USD";
|
|
393
|
+
const warnings = [
|
|
394
|
+
...analysis.before.parse_warnings,
|
|
395
|
+
...analysis.after.parse_warnings,
|
|
396
|
+
...beforeBreakdown?.warnings ?? [],
|
|
397
|
+
...afterBreakdown?.warnings ?? []
|
|
398
|
+
];
|
|
399
|
+
const beforeCostMap = /* @__PURE__ */ new Map();
|
|
400
|
+
if (beforeBreakdown) {
|
|
401
|
+
for (const est of beforeBreakdown.by_resource) {
|
|
402
|
+
beforeCostMap.set(est.resource_id, est.monthly_cost);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
const afterCostMap = /* @__PURE__ */ new Map();
|
|
406
|
+
if (afterBreakdown) {
|
|
407
|
+
for (const est of afterBreakdown.by_resource) {
|
|
408
|
+
afterCostMap.set(est.resource_id, est.monthly_cost);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const resourceDiffs = [];
|
|
412
|
+
for (const change of analysis.changes) {
|
|
413
|
+
const beforeMonthly = beforeCostMap.get(change.address) ?? 0;
|
|
414
|
+
const afterMonthly = afterCostMap.get(change.address) ?? 0;
|
|
415
|
+
const delta = afterMonthly - beforeMonthly;
|
|
416
|
+
resourceDiffs.push({
|
|
417
|
+
address: change.address,
|
|
418
|
+
resource_type: change.type,
|
|
419
|
+
action: change.action,
|
|
420
|
+
before_monthly: round2(beforeMonthly),
|
|
421
|
+
after_monthly: round2(afterMonthly),
|
|
422
|
+
delta_monthly: round2(delta),
|
|
423
|
+
currency: "USD"
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
const totalBefore = beforeBreakdown?.total_monthly ?? 0;
|
|
427
|
+
const totalAfter = afterBreakdown?.total_monthly ?? 0;
|
|
428
|
+
let result = {
|
|
429
|
+
provider: targetProvider,
|
|
430
|
+
region: targetRegion,
|
|
431
|
+
currency: "USD",
|
|
432
|
+
total_before_monthly: round2(totalBefore),
|
|
433
|
+
total_after_monthly: round2(totalAfter),
|
|
434
|
+
total_delta_monthly: round2(totalAfter - totalBefore),
|
|
435
|
+
summary: analysis.summary,
|
|
436
|
+
resource_diffs: resourceDiffs,
|
|
437
|
+
warnings: [...new Set(warnings)]
|
|
438
|
+
};
|
|
439
|
+
if (currency !== "USD") {
|
|
440
|
+
result = applyResultCurrency(result, currency);
|
|
441
|
+
}
|
|
442
|
+
logger.debug("analyzePlan complete", {
|
|
443
|
+
provider: result.provider,
|
|
444
|
+
region: result.region,
|
|
445
|
+
totalBefore: result.total_before_monthly,
|
|
446
|
+
totalAfter: result.total_after_monthly,
|
|
447
|
+
delta: result.total_delta_monthly
|
|
448
|
+
});
|
|
449
|
+
return result;
|
|
450
|
+
}
|
|
451
|
+
function round2(n) {
|
|
452
|
+
return Math.round(n * 100) / 100;
|
|
453
|
+
}
|
|
454
|
+
function applyResultCurrency(result, currency) {
|
|
455
|
+
return {
|
|
456
|
+
...result,
|
|
457
|
+
currency,
|
|
458
|
+
total_before_monthly: round2(convertCurrency(result.total_before_monthly, currency)),
|
|
459
|
+
total_after_monthly: round2(convertCurrency(result.total_after_monthly, currency)),
|
|
460
|
+
total_delta_monthly: round2(convertCurrency(result.total_delta_monthly, currency)),
|
|
461
|
+
resource_diffs: result.resource_diffs.map((d) => ({
|
|
462
|
+
...d,
|
|
463
|
+
currency,
|
|
464
|
+
before_monthly: round2(convertCurrency(d.before_monthly, currency)),
|
|
465
|
+
after_monthly: round2(convertCurrency(d.after_monthly, currency)),
|
|
466
|
+
delta_monthly: round2(convertCurrency(d.delta_monthly, currency))
|
|
467
|
+
}))
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// src/tools/compare-actual.ts
|
|
472
|
+
import { z as z4 } from "zod";
|
|
473
|
+
|
|
474
|
+
// src/parsers/terraform/state-parser.ts
|
|
475
|
+
var PROVIDER_MAP2 = {
|
|
476
|
+
aws: "aws",
|
|
477
|
+
azurerm: "azure",
|
|
478
|
+
google: "gcp",
|
|
479
|
+
"google-beta": "gcp"
|
|
480
|
+
};
|
|
481
|
+
function detectProviderFromString(providerAddr) {
|
|
482
|
+
const match = providerAddr.match(/\/([^"/]+)"\s*\]?\s*$/);
|
|
483
|
+
if (match) {
|
|
484
|
+
return PROVIDER_MAP2[match[1]];
|
|
485
|
+
}
|
|
486
|
+
for (const [key, value] of Object.entries(PROVIDER_MAP2)) {
|
|
487
|
+
if (providerAddr.includes(`/${key}"`) || providerAddr.includes(`/${key}]`)) {
|
|
488
|
+
return value;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return void 0;
|
|
492
|
+
}
|
|
493
|
+
var PROVIDER_DEFAULTS = {
|
|
494
|
+
aws: "us-east-1",
|
|
495
|
+
azure: "eastus",
|
|
496
|
+
gcp: "us-central1"
|
|
497
|
+
};
|
|
498
|
+
function detectRegion2(attrs, provider) {
|
|
499
|
+
switch (provider) {
|
|
500
|
+
case "aws": {
|
|
501
|
+
const az = str(attrs["availability_zone"]);
|
|
502
|
+
if (az) return az.replace(/[a-z]$/, "");
|
|
503
|
+
const region = str(attrs["region"]);
|
|
504
|
+
if (region) return region;
|
|
505
|
+
return PROVIDER_DEFAULTS.aws;
|
|
506
|
+
}
|
|
507
|
+
case "azure": {
|
|
508
|
+
const location = str(attrs["location"]);
|
|
509
|
+
if (location) return location;
|
|
510
|
+
return PROVIDER_DEFAULTS.azure;
|
|
511
|
+
}
|
|
512
|
+
case "gcp": {
|
|
513
|
+
const zone = str(attrs["zone"]);
|
|
514
|
+
if (zone) {
|
|
515
|
+
const parts = zone.split("-");
|
|
516
|
+
return parts.length > 2 ? parts.slice(0, -1).join("-") : zone;
|
|
517
|
+
}
|
|
518
|
+
const region = str(attrs["region"]);
|
|
519
|
+
if (region) return region;
|
|
520
|
+
return PROVIDER_DEFAULTS.gcp;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
function extractAttributes2(attrs, resourceType) {
|
|
525
|
+
const result = {};
|
|
526
|
+
if (str(attrs["instance_type"])) result.instance_type = str(attrs["instance_type"]);
|
|
527
|
+
if (str(attrs["instance_class"])) result.instance_type = str(attrs["instance_class"]);
|
|
528
|
+
if (str(attrs["vm_size"])) result.vm_size = str(attrs["vm_size"]);
|
|
529
|
+
if (str(attrs["machine_type"])) result.machine_type = str(attrs["machine_type"]);
|
|
530
|
+
if (str(attrs["engine"])) result.engine = str(attrs["engine"]);
|
|
531
|
+
if (str(attrs["engine_version"])) result.engine_version = str(attrs["engine_version"]);
|
|
532
|
+
if (attrs["multi_az"] !== void 0) result.multi_az = bool(attrs["multi_az"]);
|
|
533
|
+
if (attrs["replicas"] !== void 0) result.replicas = num(attrs["replicas"]);
|
|
534
|
+
if (str(attrs["storage_type"])) result.storage_type = str(attrs["storage_type"]);
|
|
535
|
+
if (attrs["allocated_storage"] !== void 0)
|
|
536
|
+
result.storage_size_gb = num(attrs["allocated_storage"]);
|
|
537
|
+
if (attrs["size"] !== void 0 && result.storage_size_gb === void 0)
|
|
538
|
+
result.storage_size_gb = num(attrs["size"]);
|
|
539
|
+
if (attrs["iops"] !== void 0) result.iops = num(attrs["iops"]);
|
|
540
|
+
if (attrs["throughput"] !== void 0) result.throughput_mbps = num(attrs["throughput"]);
|
|
541
|
+
if (str(attrs["tier"])) result.tier = str(attrs["tier"]);
|
|
542
|
+
if (str(attrs["sku"])) result.sku = str(attrs["sku"]);
|
|
543
|
+
if (str(attrs["sku_name"])) result.sku = str(attrs["sku_name"]);
|
|
544
|
+
if (attrs["node_count"] !== void 0) result.node_count = num(attrs["node_count"]);
|
|
545
|
+
if (attrs["min_node_count"] !== void 0) result.min_node_count = num(attrs["min_node_count"]);
|
|
546
|
+
if (attrs["max_node_count"] !== void 0) result.max_node_count = num(attrs["max_node_count"]);
|
|
547
|
+
if (str(attrs["os_type"])) result.os = str(attrs["os_type"]);
|
|
548
|
+
if (resourceType === "aws_ebs_volume" || resourceType === "aws_ebs_snapshot") {
|
|
549
|
+
if (str(attrs["type"]) && !result.storage_type) result.storage_type = str(attrs["type"]);
|
|
550
|
+
}
|
|
551
|
+
if (str(attrs["load_balancer_type"]))
|
|
552
|
+
result.load_balancer_type = str(attrs["load_balancer_type"]);
|
|
553
|
+
if (attrs["internal"] !== void 0) result.internal = bool(attrs["internal"]);
|
|
554
|
+
return result;
|
|
555
|
+
}
|
|
556
|
+
function extractTags2(attrs) {
|
|
557
|
+
const tags = attrs["tags"];
|
|
558
|
+
if (!tags || typeof tags !== "object" || Array.isArray(tags)) return {};
|
|
559
|
+
const result = {};
|
|
560
|
+
for (const [k, v] of Object.entries(tags)) {
|
|
561
|
+
if (typeof v === "string") result[k] = v;
|
|
562
|
+
else if (v !== null && v !== void 0) result[k] = String(v);
|
|
563
|
+
}
|
|
564
|
+
return result;
|
|
565
|
+
}
|
|
566
|
+
function parseTerraformState(stateJson) {
|
|
567
|
+
const warnings = [];
|
|
568
|
+
let state;
|
|
569
|
+
try {
|
|
570
|
+
state = JSON.parse(stateJson);
|
|
571
|
+
} catch (err) {
|
|
572
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
573
|
+
logger.error("Failed to parse Terraform state JSON", { error: msg });
|
|
574
|
+
return {
|
|
575
|
+
provider: "aws",
|
|
576
|
+
region: PROVIDER_DEFAULTS.aws,
|
|
577
|
+
resources: [],
|
|
578
|
+
total_count: 0,
|
|
579
|
+
by_type: {},
|
|
580
|
+
parse_warnings: [`Invalid state JSON: ${msg}`]
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
if (!Array.isArray(state.resources)) {
|
|
584
|
+
return {
|
|
585
|
+
provider: "aws",
|
|
586
|
+
region: PROVIDER_DEFAULTS.aws,
|
|
587
|
+
resources: [],
|
|
588
|
+
total_count: 0,
|
|
589
|
+
by_type: {},
|
|
590
|
+
parse_warnings: warnings
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
const allResources = [];
|
|
594
|
+
for (const block of state.resources) {
|
|
595
|
+
if (block.mode !== "managed") continue;
|
|
596
|
+
const provider = detectProviderFromString(block.provider);
|
|
597
|
+
if (!provider) {
|
|
598
|
+
warnings.push(
|
|
599
|
+
`Skipping resource "${block.type}.${block.name}": unknown provider "${block.provider}"`
|
|
600
|
+
);
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
if (!Array.isArray(block.instances)) continue;
|
|
604
|
+
for (const instance of block.instances) {
|
|
605
|
+
const attrs = instance.attributes ?? {};
|
|
606
|
+
const region = detectRegion2(attrs, provider);
|
|
607
|
+
const tags = extractTags2(attrs);
|
|
608
|
+
const attributes = extractAttributes2(attrs, block.type);
|
|
609
|
+
let id = `${block.type}.${block.name}`;
|
|
610
|
+
if (instance.index_key !== void 0) {
|
|
611
|
+
id += typeof instance.index_key === "number" ? `[${instance.index_key}]` : `["${instance.index_key}"]`;
|
|
612
|
+
}
|
|
613
|
+
allResources.push({
|
|
614
|
+
id,
|
|
615
|
+
type: block.type,
|
|
616
|
+
name: block.name,
|
|
617
|
+
provider,
|
|
618
|
+
region,
|
|
619
|
+
attributes,
|
|
620
|
+
tags,
|
|
621
|
+
source_file: "terraform.tfstate"
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
const providerCounts = {};
|
|
626
|
+
for (const r of allResources) {
|
|
627
|
+
providerCounts[r.provider] = (providerCounts[r.provider] ?? 0) + 1;
|
|
628
|
+
}
|
|
629
|
+
let dominantProvider = "aws";
|
|
630
|
+
let maxCount = 0;
|
|
631
|
+
for (const [p, c] of Object.entries(providerCounts)) {
|
|
632
|
+
if (c > maxCount) {
|
|
633
|
+
maxCount = c;
|
|
634
|
+
dominantProvider = p;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
const dominantRegion = allResources.find((r) => r.provider === dominantProvider)?.region ?? PROVIDER_DEFAULTS[dominantProvider];
|
|
638
|
+
const byType = {};
|
|
639
|
+
for (const r of allResources) {
|
|
640
|
+
byType[r.type] = (byType[r.type] ?? 0) + 1;
|
|
641
|
+
}
|
|
642
|
+
logger.info("Terraform state parse complete", {
|
|
643
|
+
resourceCount: allResources.length,
|
|
644
|
+
provider: dominantProvider,
|
|
645
|
+
region: dominantRegion,
|
|
646
|
+
warningCount: warnings.length
|
|
647
|
+
});
|
|
648
|
+
return {
|
|
649
|
+
provider: dominantProvider,
|
|
650
|
+
region: dominantRegion,
|
|
651
|
+
resources: allResources,
|
|
652
|
+
total_count: allResources.length,
|
|
653
|
+
by_type: byType,
|
|
654
|
+
parse_warnings: warnings
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// src/tools/compare-actual.ts
|
|
659
|
+
var compareActualSchema = z4.object({
|
|
660
|
+
state_json: z4.string().describe("Contents of terraform.tfstate file (JSON)"),
|
|
661
|
+
files: z4.array(
|
|
662
|
+
z4.object({
|
|
663
|
+
path: z4.string().describe("File path"),
|
|
664
|
+
content: z4.string().describe("File content (HCL)")
|
|
665
|
+
})
|
|
666
|
+
).optional().describe("Optional Terraform files to compare planned costs against actual"),
|
|
667
|
+
tfvars: z4.string().optional().describe("Contents of terraform.tfvars file"),
|
|
668
|
+
provider: z4.enum(["aws", "azure", "gcp"]).optional().describe("Target cloud provider override. Defaults to auto-detecting from the state."),
|
|
669
|
+
region: z4.string().optional().describe(
|
|
670
|
+
"Target region override for pricing. Defaults to the region detected from the state."
|
|
671
|
+
),
|
|
672
|
+
currency: z4.enum(SUPPORTED_CURRENCIES).optional().default("USD").describe("Output currency for cost estimates. Defaults to USD.")
|
|
673
|
+
});
|
|
674
|
+
async function compareActual(params, pricingEngine, config) {
|
|
675
|
+
const warnings = [];
|
|
676
|
+
const stateInventory = parseTerraformState(params.state_json);
|
|
677
|
+
warnings.push(...stateInventory.parse_warnings);
|
|
678
|
+
const targetProvider = params.provider ?? stateInventory.provider;
|
|
679
|
+
const targetRegion = params.region ?? stateInventory.region;
|
|
680
|
+
const costEngine = new CostEngine(pricingEngine, config);
|
|
681
|
+
const actualBreakdown = await costEngine.calculateBreakdown(
|
|
682
|
+
stateInventory.resources,
|
|
683
|
+
targetProvider,
|
|
684
|
+
targetRegion
|
|
685
|
+
);
|
|
686
|
+
warnings.push(...actualBreakdown.warnings ?? []);
|
|
687
|
+
const actualByResource = actualBreakdown.by_resource.map((r) => ({
|
|
688
|
+
resource_id: r.resource_id,
|
|
689
|
+
resource_type: r.resource_type,
|
|
690
|
+
monthly_cost: Math.round(r.monthly_cost * 100) / 100
|
|
691
|
+
}));
|
|
692
|
+
const currency = params.currency ?? "USD";
|
|
693
|
+
const result = {
|
|
694
|
+
provider: targetProvider,
|
|
695
|
+
region: targetRegion,
|
|
696
|
+
currency,
|
|
697
|
+
actual_costs: {
|
|
698
|
+
total_monthly: Math.round(actualBreakdown.total_monthly * 100) / 100,
|
|
699
|
+
total_yearly: Math.round(actualBreakdown.total_monthly * 12 * 100) / 100,
|
|
700
|
+
resource_count: stateInventory.total_count,
|
|
701
|
+
by_resource: actualByResource
|
|
702
|
+
},
|
|
703
|
+
warnings: []
|
|
704
|
+
};
|
|
705
|
+
if (params.files && params.files.length > 0) {
|
|
706
|
+
const plannedInventory = await parseTerraform(params.files, params.tfvars);
|
|
707
|
+
warnings.push(...plannedInventory.parse_warnings);
|
|
708
|
+
const plannedProvider = params.provider ?? plannedInventory.provider;
|
|
709
|
+
const plannedRegion = params.region ?? mapRegion(plannedInventory.region, plannedInventory.provider, plannedProvider);
|
|
710
|
+
const plannedBreakdown = await costEngine.calculateBreakdown(
|
|
711
|
+
plannedInventory.resources,
|
|
712
|
+
plannedProvider,
|
|
713
|
+
plannedRegion
|
|
714
|
+
);
|
|
715
|
+
warnings.push(...plannedBreakdown.warnings ?? []);
|
|
716
|
+
const plannedByResource = plannedBreakdown.by_resource.map((r) => ({
|
|
717
|
+
resource_id: r.resource_id,
|
|
718
|
+
resource_type: r.resource_type,
|
|
719
|
+
monthly_cost: Math.round(r.monthly_cost * 100) / 100
|
|
720
|
+
}));
|
|
721
|
+
result.planned_costs = {
|
|
722
|
+
total_monthly: Math.round(plannedBreakdown.total_monthly * 100) / 100,
|
|
723
|
+
total_yearly: Math.round(plannedBreakdown.total_monthly * 12 * 100) / 100,
|
|
724
|
+
resource_count: plannedInventory.total_count,
|
|
725
|
+
by_resource: plannedByResource
|
|
726
|
+
};
|
|
727
|
+
const deltaMonthly = result.actual_costs.total_monthly - result.planned_costs.total_monthly;
|
|
728
|
+
const deltaYearly = result.actual_costs.total_yearly - result.planned_costs.total_yearly;
|
|
729
|
+
const pctChange = result.planned_costs.total_monthly === 0 ? result.actual_costs.total_monthly === 0 ? 0 : 100 : Math.round(deltaMonthly / result.planned_costs.total_monthly * 1e4) / 100;
|
|
730
|
+
result.delta = {
|
|
731
|
+
monthly: Math.round(deltaMonthly * 100) / 100,
|
|
732
|
+
yearly: Math.round(deltaYearly * 100) / 100,
|
|
733
|
+
pct_change: pctChange
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
if (currency !== "USD") {
|
|
737
|
+
const actualConverted = convertBreakdownCurrency(actualBreakdown, currency);
|
|
738
|
+
result.actual_costs.total_monthly = Math.round(actualConverted.total_monthly * 100) / 100;
|
|
739
|
+
result.actual_costs.total_yearly = Math.round(actualConverted.total_monthly * 12 * 100) / 100;
|
|
740
|
+
result.actual_costs.by_resource = actualConverted.by_resource.map((r) => ({
|
|
741
|
+
resource_id: r.resource_id,
|
|
742
|
+
resource_type: r.resource_type,
|
|
743
|
+
monthly_cost: Math.round(r.monthly_cost * 100) / 100
|
|
744
|
+
}));
|
|
745
|
+
if (result.planned_costs && result.delta) {
|
|
746
|
+
const deltaMonthly = result.actual_costs.total_monthly - result.planned_costs.total_monthly;
|
|
747
|
+
result.delta.monthly = Math.round(deltaMonthly * 100) / 100;
|
|
748
|
+
result.delta.yearly = Math.round(deltaMonthly * 12 * 100) / 100;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
result.warnings = [...new Set(warnings)];
|
|
752
|
+
return result;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// src/tools/price-trends.ts
|
|
756
|
+
import { z as z5 } from "zod";
|
|
757
|
+
var priceTrendsSchema = z5.object({
|
|
758
|
+
provider: z5.enum(["aws", "azure", "gcp"]).describe("Cloud provider"),
|
|
759
|
+
service: z5.string().describe("Service category (e.g., ec2, rds, virtual-machines)"),
|
|
760
|
+
resource_type: z5.string().describe("Resource type (e.g., t3.large, Standard_D2s_v5)"),
|
|
761
|
+
region: z5.string().describe("Cloud region"),
|
|
762
|
+
limit: z5.number().optional().default(30).describe("Max number of price points to return"),
|
|
763
|
+
since: z5.string().optional().describe("ISO date to start from (e.g., 2024-01-01)")
|
|
764
|
+
});
|
|
765
|
+
function priceTrends(params, cache) {
|
|
766
|
+
const { provider, service, resource_type, region, limit, since } = params;
|
|
767
|
+
const history = cache.getPriceHistory(provider, service, resource_type, region, {
|
|
768
|
+
limit,
|
|
769
|
+
since
|
|
770
|
+
});
|
|
771
|
+
const priceChange = cache.getPriceChange(provider, service, resource_type, region);
|
|
772
|
+
const currentPrice = history.length > 0 ? { price_per_unit: history[0].price_per_unit, unit: history[0].unit } : null;
|
|
140
773
|
return {
|
|
141
774
|
provider,
|
|
142
|
-
service
|
|
143
|
-
resource_type
|
|
144
|
-
region
|
|
145
|
-
|
|
775
|
+
service,
|
|
776
|
+
resource_type,
|
|
777
|
+
region,
|
|
778
|
+
current_price: currentPrice,
|
|
779
|
+
price_history: history,
|
|
780
|
+
price_change: priceChange,
|
|
781
|
+
data_points: history.length
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// src/tools/detect-anomalies.ts
|
|
786
|
+
import { z as z6 } from "zod";
|
|
787
|
+
var detectAnomaliesSchema = z6.object({
|
|
788
|
+
files: z6.array(
|
|
789
|
+
z6.object({
|
|
790
|
+
path: z6.string().describe("File path"),
|
|
791
|
+
content: z6.string().describe("File content")
|
|
792
|
+
})
|
|
793
|
+
),
|
|
794
|
+
tfvars: z6.string().optional().describe("Variable overrides"),
|
|
795
|
+
provider: z6.enum(["aws", "azure", "gcp"]).describe("Target cloud provider"),
|
|
796
|
+
region: z6.string().optional().describe("Target region"),
|
|
797
|
+
currency: z6.enum(SUPPORTED_CURRENCIES).optional().default("USD").describe("Output currency for cost estimates. Defaults to USD."),
|
|
798
|
+
budget_monthly: z6.number().optional().describe("Monthly budget threshold in USD"),
|
|
799
|
+
budget_per_resource: z6.number().optional().describe("Per-resource cost threshold in USD"),
|
|
800
|
+
price_change_threshold: z6.number().optional().default(10).describe("Alert on price changes exceeding this percentage")
|
|
801
|
+
});
|
|
802
|
+
var MIN_VCPUS_FOR_OVERSIZING = 8;
|
|
803
|
+
function isOversizedInstance(instanceType) {
|
|
804
|
+
if (!instanceType) return false;
|
|
805
|
+
if (/xlarge/i.test(instanceType)) return true;
|
|
806
|
+
const azureMatch = instanceType.match(/Standard_[A-Z](\d+)s?_v\d+/i);
|
|
807
|
+
if (azureMatch) {
|
|
808
|
+
const vcpus = parseInt(azureMatch[1], 10);
|
|
809
|
+
return vcpus >= MIN_VCPUS_FOR_OVERSIZING;
|
|
810
|
+
}
|
|
811
|
+
const gcpMatch = instanceType.match(/(?:n1|n2|n2d|c2|e2)-(?:standard|highmem|highcpu)-(\d+)/);
|
|
812
|
+
if (gcpMatch) {
|
|
813
|
+
const vcpus = parseInt(gcpMatch[1], 10);
|
|
814
|
+
return vcpus >= MIN_VCPUS_FOR_OVERSIZING;
|
|
815
|
+
}
|
|
816
|
+
return false;
|
|
817
|
+
}
|
|
818
|
+
function serviceLabelForType(resourceType) {
|
|
819
|
+
if (resourceType.startsWith("aws_instance") || resourceType.startsWith("aws_eks_node_group"))
|
|
820
|
+
return "ec2";
|
|
821
|
+
if (resourceType.startsWith("azurerm_linux_virtual_machine") || resourceType.startsWith("azurerm_windows_virtual_machine"))
|
|
822
|
+
return "virtual-machines";
|
|
823
|
+
if (resourceType.startsWith("google_compute_instance") || resourceType.startsWith("google_container_node_pool"))
|
|
824
|
+
return "compute-engine";
|
|
825
|
+
if (resourceType.includes("db_instance") || resourceType.includes("sql_database") || resourceType.includes("postgresql") || resourceType.includes("mysql") || resourceType.includes("mssql"))
|
|
826
|
+
return "database";
|
|
827
|
+
if (resourceType.includes("s3") || resourceType.includes("storage_account") || resourceType.includes("storage_bucket"))
|
|
828
|
+
return "storage";
|
|
829
|
+
return "general";
|
|
830
|
+
}
|
|
831
|
+
async function detectAnomalies(params, pricingEngine, cache, config) {
|
|
832
|
+
const inventory = await parseTerraform(params.files, params.tfvars);
|
|
833
|
+
const targetProvider = params.provider;
|
|
834
|
+
const targetRegion = params.region ?? mapRegion(inventory.region, inventory.provider, targetProvider);
|
|
835
|
+
const costEngine = new CostEngine(pricingEngine, config);
|
|
836
|
+
const breakdown = await costEngine.calculateBreakdown(
|
|
837
|
+
inventory.resources,
|
|
838
|
+
targetProvider,
|
|
839
|
+
targetRegion
|
|
840
|
+
);
|
|
841
|
+
const totalMonthly = breakdown.total_monthly;
|
|
842
|
+
const byResource = breakdown.by_resource;
|
|
843
|
+
const anomalies = [];
|
|
844
|
+
const recommendations = [];
|
|
845
|
+
if (params.budget_monthly != null && totalMonthly > params.budget_monthly) {
|
|
846
|
+
anomalies.push({
|
|
847
|
+
type: "budget_exceeded",
|
|
848
|
+
severity: "high",
|
|
849
|
+
resource: "_total",
|
|
850
|
+
resource_type: "aggregate",
|
|
851
|
+
message: `Total monthly cost $${totalMonthly.toFixed(2)} exceeds budget of $${params.budget_monthly.toFixed(2)}`,
|
|
852
|
+
details: {
|
|
853
|
+
current_cost: totalMonthly,
|
|
854
|
+
threshold: params.budget_monthly
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
recommendations.push(
|
|
858
|
+
`Total monthly cost exceeds budget by $${(totalMonthly - params.budget_monthly).toFixed(2)}. Consider right-sizing or removing unused resources.`
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
for (const est of byResource) {
|
|
862
|
+
if (params.budget_per_resource != null && est.monthly_cost > params.budget_per_resource) {
|
|
863
|
+
anomalies.push({
|
|
864
|
+
type: "budget_exceeded",
|
|
865
|
+
severity: "high",
|
|
866
|
+
resource: est.resource_id,
|
|
867
|
+
resource_type: est.resource_type,
|
|
868
|
+
message: `Resource ${est.resource_id} costs $${est.monthly_cost.toFixed(2)}/mo, exceeding per-resource budget of $${params.budget_per_resource.toFixed(2)}`,
|
|
869
|
+
details: {
|
|
870
|
+
current_cost: est.monthly_cost,
|
|
871
|
+
threshold: params.budget_per_resource
|
|
872
|
+
}
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
const priceChangeThreshold = params.price_change_threshold ?? 10;
|
|
877
|
+
for (const est of byResource) {
|
|
878
|
+
const service = serviceLabelForType(est.resource_type);
|
|
879
|
+
const instanceType = resolveInstanceType(est);
|
|
880
|
+
const resourceKey = instanceType ?? est.resource_type;
|
|
881
|
+
const priceChange = cache.getPriceChange(targetProvider, service, resourceKey, targetRegion);
|
|
882
|
+
if (priceChange && Math.abs(priceChange.change_percent) > priceChangeThreshold) {
|
|
883
|
+
const direction = priceChange.change_percent > 0 ? "increased" : "decreased";
|
|
884
|
+
anomalies.push({
|
|
885
|
+
type: "price_change",
|
|
886
|
+
severity: Math.abs(priceChange.change_percent) > 20 ? "high" : "medium",
|
|
887
|
+
resource: est.resource_id,
|
|
888
|
+
resource_type: est.resource_type,
|
|
889
|
+
message: `Pricing for ${est.resource_id} has ${direction} by ${Math.abs(priceChange.change_percent).toFixed(1)}%`,
|
|
890
|
+
details: {
|
|
891
|
+
current_cost: est.monthly_cost,
|
|
892
|
+
price_change_percent: priceChange.change_percent
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
recommendations.push(
|
|
896
|
+
`Review pricing change for ${est.resource_id} (${Math.abs(priceChange.change_percent).toFixed(1)}% ${direction}). Consider locking in reserved pricing.`
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (totalMonthly > 0) {
|
|
901
|
+
for (const est of byResource) {
|
|
902
|
+
const sharePercent = est.monthly_cost / totalMonthly * 100;
|
|
903
|
+
if (sharePercent > 50 && byResource.length > 1) {
|
|
904
|
+
anomalies.push({
|
|
905
|
+
type: "cost_concentration",
|
|
906
|
+
severity: "medium",
|
|
907
|
+
resource: est.resource_id,
|
|
908
|
+
resource_type: est.resource_type,
|
|
909
|
+
message: `Resource ${est.resource_id} accounts for ${sharePercent.toFixed(1)}% of total cost (concentration risk)`,
|
|
910
|
+
details: {
|
|
911
|
+
current_cost: est.monthly_cost,
|
|
912
|
+
cost_share_percent: sharePercent
|
|
913
|
+
}
|
|
914
|
+
});
|
|
915
|
+
recommendations.push(
|
|
916
|
+
`${est.resource_id} dominates total spend at ${sharePercent.toFixed(1)}%. Evaluate whether workload can be distributed or right-sized.`
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
for (const est of byResource) {
|
|
922
|
+
const instanceType = resolveInstanceType(est);
|
|
923
|
+
if (isOversizedInstance(instanceType)) {
|
|
924
|
+
anomalies.push({
|
|
925
|
+
type: "potential_oversizing",
|
|
926
|
+
severity: "low",
|
|
927
|
+
resource: est.resource_id,
|
|
928
|
+
resource_type: est.resource_type,
|
|
929
|
+
message: `Instance ${est.resource_id} uses ${instanceType} which may be over-provisioned`,
|
|
930
|
+
details: {
|
|
931
|
+
current_cost: est.monthly_cost
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
recommendations.push(
|
|
935
|
+
`Consider whether ${est.resource_id} (${instanceType}) needs all allocated resources. Smaller instance types can significantly reduce cost.`
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
const high = anomalies.filter((a) => a.severity === "high").length;
|
|
940
|
+
const medium = anomalies.filter((a) => a.severity === "medium").length;
|
|
941
|
+
const low = anomalies.filter((a) => a.severity === "low").length;
|
|
942
|
+
return {
|
|
943
|
+
total_monthly: totalMonthly,
|
|
944
|
+
anomalies,
|
|
945
|
+
summary: {
|
|
946
|
+
total_resources: byResource.length,
|
|
947
|
+
anomaly_count: anomalies.length,
|
|
948
|
+
high_severity: high,
|
|
949
|
+
medium_severity: medium,
|
|
950
|
+
low_severity: low
|
|
951
|
+
},
|
|
952
|
+
recommendations: [...new Set(recommendations)]
|
|
146
953
|
};
|
|
147
954
|
}
|
|
955
|
+
function resolveInstanceType(est) {
|
|
956
|
+
for (const item of est.breakdown) {
|
|
957
|
+
const match = item.description.match(
|
|
958
|
+
/\b(t[23]\.\w+|m[5-7]\.\w+|c[5-7]\.\w+|r[5-7]\.\w+|Standard_\w+|n[12]d?-\w+-\d+|c2-\w+-\d+|e2-\w+-\d+)\b/i
|
|
959
|
+
);
|
|
960
|
+
if (match) return match[1];
|
|
961
|
+
}
|
|
962
|
+
return void 0;
|
|
963
|
+
}
|
|
148
964
|
|
|
149
965
|
// src/tools/index.ts
|
|
150
966
|
function registerTools(server, config) {
|
|
@@ -157,7 +973,7 @@ function registerTools(server, config) {
|
|
|
157
973
|
async (params) => {
|
|
158
974
|
const result = await analyzeTerraform(params);
|
|
159
975
|
return {
|
|
160
|
-
content: [{ type: "text", text: JSON.stringify(result
|
|
976
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
161
977
|
};
|
|
162
978
|
}
|
|
163
979
|
);
|
|
@@ -168,7 +984,7 @@ function registerTools(server, config) {
|
|
|
168
984
|
async (params) => {
|
|
169
985
|
const result = await estimateCost(params, pricingEngine, config);
|
|
170
986
|
return {
|
|
171
|
-
content: [{ type: "text", text: JSON.stringify(result
|
|
987
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
172
988
|
};
|
|
173
989
|
}
|
|
174
990
|
);
|
|
@@ -179,7 +995,7 @@ function registerTools(server, config) {
|
|
|
179
995
|
async (params) => {
|
|
180
996
|
const result = await compareProviders(params, pricingEngine, config);
|
|
181
997
|
return {
|
|
182
|
-
content: [{ type: "text", text: JSON.stringify(result
|
|
998
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
183
999
|
};
|
|
184
1000
|
}
|
|
185
1001
|
);
|
|
@@ -190,7 +1006,7 @@ function registerTools(server, config) {
|
|
|
190
1006
|
async (params) => {
|
|
191
1007
|
const result = await getEquivalents(params);
|
|
192
1008
|
return {
|
|
193
|
-
content: [{ type: "text", text: JSON.stringify(result
|
|
1009
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
194
1010
|
};
|
|
195
1011
|
}
|
|
196
1012
|
);
|
|
@@ -201,7 +1017,7 @@ function registerTools(server, config) {
|
|
|
201
1017
|
async (params) => {
|
|
202
1018
|
const result = await getPricing(params, pricingEngine);
|
|
203
1019
|
return {
|
|
204
|
-
content: [{ type: "text", text: JSON.stringify(result
|
|
1020
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
205
1021
|
};
|
|
206
1022
|
}
|
|
207
1023
|
);
|
|
@@ -212,7 +1028,7 @@ function registerTools(server, config) {
|
|
|
212
1028
|
async (params) => {
|
|
213
1029
|
const result = await optimizeCost(params, pricingEngine, config);
|
|
214
1030
|
return {
|
|
215
|
-
content: [{ type: "text", text: JSON.stringify(result
|
|
1031
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
216
1032
|
};
|
|
217
1033
|
}
|
|
218
1034
|
);
|
|
@@ -223,7 +1039,51 @@ function registerTools(server, config) {
|
|
|
223
1039
|
async (params) => {
|
|
224
1040
|
const result = await whatIf(params, pricingEngine, config);
|
|
225
1041
|
return {
|
|
226
|
-
content: [{ type: "text", text: JSON.stringify(result
|
|
1042
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
);
|
|
1046
|
+
server.tool(
|
|
1047
|
+
"analyze_plan",
|
|
1048
|
+
"Parse Terraform plan JSON (from 'terraform show -json' or 'terraform plan -json') and return a cost-of-change analysis showing before/after costs and per-resource deltas.",
|
|
1049
|
+
analyzePlanSchema.shape,
|
|
1050
|
+
async (params) => {
|
|
1051
|
+
const result = await analyzePlan(params, pricingEngine, config);
|
|
1052
|
+
return {
|
|
1053
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
);
|
|
1057
|
+
server.tool(
|
|
1058
|
+
"compare_actual",
|
|
1059
|
+
"Parse a Terraform state file (.tfstate) and calculate actual infrastructure costs. Optionally compare against planned costs from HCL files to show drift.",
|
|
1060
|
+
compareActualSchema.shape,
|
|
1061
|
+
async (params) => {
|
|
1062
|
+
const result = await compareActual(params, pricingEngine, config);
|
|
1063
|
+
return {
|
|
1064
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
);
|
|
1068
|
+
server.tool(
|
|
1069
|
+
"price_trends",
|
|
1070
|
+
"Query historical pricing trends for a specific cloud resource. Returns recorded price history, the most recent price change, and summary metadata.",
|
|
1071
|
+
priceTrendsSchema.shape,
|
|
1072
|
+
(params) => {
|
|
1073
|
+
const result = priceTrends(params, cache);
|
|
1074
|
+
return {
|
|
1075
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
);
|
|
1079
|
+
server.tool(
|
|
1080
|
+
"detect_anomalies",
|
|
1081
|
+
"Analyze infrastructure costs and flag anomalies \u2014 resources whose estimated costs are unusual compared to historical baselines or configured thresholds.",
|
|
1082
|
+
detectAnomaliesSchema.shape,
|
|
1083
|
+
async (params) => {
|
|
1084
|
+
const result = await detectAnomalies(params, pricingEngine, cache, config);
|
|
1085
|
+
return {
|
|
1086
|
+
content: [{ type: "text", text: JSON.stringify(result) }]
|
|
227
1087
|
};
|
|
228
1088
|
}
|
|
229
1089
|
);
|