@jadenrazo/cloudcost-mcp 0.1.7 → 0.1.9
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 +33 -29
- package/dist/index.js +115 -45
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
|
+
<h1 align="center">CloudCost MCP Server</h1>
|
|
2
|
+
|
|
1
3
|
<p align="center">
|
|
2
|
-
|
|
3
|
-
<
|
|
4
|
-
|
|
5
|
-
<br />
|
|
6
|
-
Built on the <a href="https://modelcontextprotocol.io">Model Context Protocol</a> for seamless AI agent integration.
|
|
7
|
-
</p>
|
|
4
|
+
Multi-cloud cost analysis for Terraform — powered by live pricing data from AWS, Azure, and GCP.
|
|
5
|
+
<br />
|
|
6
|
+
Built on the <a href="https://modelcontextprotocol.io">Model Context Protocol</a> for seamless AI agent integration.
|
|
8
7
|
</p>
|
|
9
8
|
|
|
10
9
|
<p align="center">
|
|
10
|
+
<a href="https://github.com/jadenrazo/CloudCostMCP/actions/workflows/ci.yml"><img src="https://github.com/jadenrazo/CloudCostMCP/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
|
|
11
|
+
<a href="https://www.npmjs.com/package/@jadenrazo/cloudcost-mcp"><img src="https://img.shields.io/npm/v/@jadenrazo/cloudcost-mcp.svg" alt="npm version" /></a>
|
|
12
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT" /></a>
|
|
13
|
+
<img src="https://img.shields.io/badge/node-%3E%3D20-brightgreen" alt="Node.js" />
|
|
14
|
+
</p>
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
|
|
16
|
+
<p align="right">
|
|
17
|
+
<img src="https://github.com/user-attachments/assets/7d5f613a-851e-4480-900f-438d13f9a56e" alt="CloudCost MCP demo" width="700" />
|
|
14
18
|
</p>
|
|
15
19
|
|
|
16
20
|
<p align="center">
|
|
@@ -322,27 +326,27 @@ Configuration priority: environment variables > config file > built-in defaults.
|
|
|
322
326
|
│ (src/server.ts) │
|
|
323
327
|
└──────────────┬────────────────┘
|
|
324
328
|
│
|
|
325
|
-
|
|
326
|
-
│ │
|
|
327
|
-
┌─────────▼─────────┐ ┌─────────▼─────────┐
|
|
328
|
-
│ Tool Handlers │
|
|
329
|
-
│
|
|
330
|
-
└─────────┬─────────┘
|
|
331
|
-
│
|
|
332
|
-
|
|
333
|
-
│ PricingEngine (router)
|
|
334
|
-
│
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
│ AWS Bulk │ │ Azure Retail │ │ GCP Bundled
|
|
339
|
-
│ Loader │ │ Client │ │ Loader
|
|
340
|
-
│ (CSV + JSON) │ │ (REST API) │ │ (static JSON)
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
AWS Bulk Pricing Azure Retail API
|
|
345
|
-
CSV (public) (public, no auth)
|
|
329
|
+
┌────────────────────────┼───────────────────────┐
|
|
330
|
+
│ │ │
|
|
331
|
+
┌─────────▼─────────┐ ┌─────────▼─────────┐ ┌─────────▼─────────┐
|
|
332
|
+
│ Tool Handlers │ │ HCL Parsers │ │ Cost Engine │
|
|
333
|
+
│ (src/tools/*.ts) │ │ (src/parsers/) │ │ (src/calculator/) │
|
|
334
|
+
└─────────┬─────────┘ └───────────────────┘ └─────────┬─────────┘
|
|
335
|
+
│ │
|
|
336
|
+
┌─────────▼────────────────────────────────────────────────▼─────────┐
|
|
337
|
+
│ PricingEngine (router) │
|
|
338
|
+
│ (src/pricing/pricing-engine.ts) │
|
|
339
|
+
└──────┬───────────────────┬─────────────────────┬───────────────────┘
|
|
340
|
+
│ │ │
|
|
341
|
+
┌────────▼───────┐ ┌────────▼───────┐ ┌────────▼───────┐
|
|
342
|
+
│ AWS Bulk │ │ Azure Retail │ │ GCP Bundled │
|
|
343
|
+
│ Loader │ │ Client │ │ Loader │
|
|
344
|
+
│ (CSV + JSON) │ │ (REST API) │ │ (static JSON) │
|
|
345
|
+
└────────┬───────┘ └────────┬───────┘ └────────┬───────┘
|
|
346
|
+
│ │ │
|
|
347
|
+
▼ ▼ ▼
|
|
348
|
+
AWS Bulk Pricing Azure Retail API data/gcp-pricing/
|
|
349
|
+
CSV (public) (public, no auth) (bundled files)
|
|
346
350
|
```
|
|
347
351
|
|
|
348
352
|
### Key Design Decisions
|
package/dist/index.js
CHANGED
|
@@ -270,17 +270,30 @@ function extractUnit(priceDimensions) {
|
|
|
270
270
|
}
|
|
271
271
|
return "Hrs";
|
|
272
272
|
}
|
|
273
|
+
function extractFromTerms(onDemandTerms, defaultUnit) {
|
|
274
|
+
for (const skuTerms of Object.values(onDemandTerms)) {
|
|
275
|
+
if (skuTerms?.priceDimensions) {
|
|
276
|
+
return {
|
|
277
|
+
price: extractUsdPrice(skuTerms.priceDimensions),
|
|
278
|
+
unit: extractUnit(skuTerms.priceDimensions)
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
for (const offerTerm of Object.values(skuTerms ?? {})) {
|
|
282
|
+
const dims = offerTerm?.priceDimensions;
|
|
283
|
+
if (dims) {
|
|
284
|
+
return {
|
|
285
|
+
price: extractUsdPrice(dims),
|
|
286
|
+
unit: extractUnit(dims)
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return { price: 0, unit: defaultUnit };
|
|
292
|
+
}
|
|
273
293
|
function normalizeAwsCompute(rawProduct, rawPrice, region) {
|
|
274
294
|
const attrs = rawProduct?.attributes ?? {};
|
|
275
295
|
const terms = rawPrice?.terms?.OnDemand ?? {};
|
|
276
|
-
|
|
277
|
-
let unit = "Hrs";
|
|
278
|
-
for (const term of Object.values(terms)) {
|
|
279
|
-
const dims = term?.priceDimensions ?? {};
|
|
280
|
-
price = extractUsdPrice(dims);
|
|
281
|
-
unit = extractUnit(dims);
|
|
282
|
-
break;
|
|
283
|
-
}
|
|
296
|
+
const { price, unit } = extractFromTerms(terms, "Hrs");
|
|
284
297
|
return {
|
|
285
298
|
provider: "aws",
|
|
286
299
|
service: "ec2",
|
|
@@ -295,7 +308,8 @@ function normalizeAwsCompute(rawProduct, rawPrice, region) {
|
|
|
295
308
|
vcpu: attrs.vcpu ?? "",
|
|
296
309
|
memory: attrs.memory ?? "",
|
|
297
310
|
operating_system: attrs.operatingSystem ?? "Linux",
|
|
298
|
-
tenancy: attrs.tenancy ?? "Shared"
|
|
311
|
+
tenancy: attrs.tenancy ?? "Shared",
|
|
312
|
+
pricing_source: "live"
|
|
299
313
|
},
|
|
300
314
|
effective_date: (/* @__PURE__ */ new Date()).toISOString()
|
|
301
315
|
};
|
|
@@ -303,14 +317,7 @@ function normalizeAwsCompute(rawProduct, rawPrice, region) {
|
|
|
303
317
|
function normalizeAwsDatabase(rawProduct, rawPrice, region) {
|
|
304
318
|
const attrs = rawProduct?.attributes ?? {};
|
|
305
319
|
const terms = rawPrice?.terms?.OnDemand ?? {};
|
|
306
|
-
|
|
307
|
-
let unit = "Hrs";
|
|
308
|
-
for (const term of Object.values(terms)) {
|
|
309
|
-
const dims = term?.priceDimensions ?? {};
|
|
310
|
-
price = extractUsdPrice(dims);
|
|
311
|
-
unit = extractUnit(dims);
|
|
312
|
-
break;
|
|
313
|
-
}
|
|
320
|
+
const { price, unit } = extractFromTerms(terms, "Hrs");
|
|
314
321
|
return {
|
|
315
322
|
provider: "aws",
|
|
316
323
|
service: "rds",
|
|
@@ -325,7 +332,8 @@ function normalizeAwsDatabase(rawProduct, rawPrice, region) {
|
|
|
325
332
|
database_engine: attrs.databaseEngine ?? "",
|
|
326
333
|
deployment_option: attrs.deploymentOption ?? "Single-AZ",
|
|
327
334
|
vcpu: attrs.vcpu ?? "",
|
|
328
|
-
memory: attrs.memory ?? ""
|
|
335
|
+
memory: attrs.memory ?? "",
|
|
336
|
+
pricing_source: "live"
|
|
329
337
|
},
|
|
330
338
|
effective_date: (/* @__PURE__ */ new Date()).toISOString()
|
|
331
339
|
};
|
|
@@ -333,14 +341,7 @@ function normalizeAwsDatabase(rawProduct, rawPrice, region) {
|
|
|
333
341
|
function normalizeAwsStorage(rawProduct, rawPrice, region) {
|
|
334
342
|
const attrs = rawProduct?.attributes ?? {};
|
|
335
343
|
const terms = rawPrice?.terms?.OnDemand ?? {};
|
|
336
|
-
|
|
337
|
-
let unit = "GB-Mo";
|
|
338
|
-
for (const term of Object.values(terms)) {
|
|
339
|
-
const dims = term?.priceDimensions ?? {};
|
|
340
|
-
price = extractUsdPrice(dims);
|
|
341
|
-
unit = extractUnit(dims);
|
|
342
|
-
break;
|
|
343
|
-
}
|
|
344
|
+
const { price, unit } = extractFromTerms(terms, "GB-Mo");
|
|
344
345
|
const volumeType = attrs.volumeApiName ?? attrs.volumeType ?? "gp3";
|
|
345
346
|
return {
|
|
346
347
|
provider: "aws",
|
|
@@ -354,7 +355,8 @@ function normalizeAwsStorage(rawProduct, rawPrice, region) {
|
|
|
354
355
|
attributes: {
|
|
355
356
|
volume_type: volumeType,
|
|
356
357
|
max_iops: attrs.maxIopsvolume ?? "",
|
|
357
|
-
max_throughput: attrs.maxThroughputvolume ?? ""
|
|
358
|
+
max_throughput: attrs.maxThroughputvolume ?? "",
|
|
359
|
+
pricing_source: "live"
|
|
358
360
|
},
|
|
359
361
|
effective_date: (/* @__PURE__ */ new Date()).toISOString()
|
|
360
362
|
};
|
|
@@ -579,7 +581,7 @@ var AwsBulkLoader = class _AwsBulkLoader {
|
|
|
579
581
|
if (afterCsv) return afterCsv;
|
|
580
582
|
}
|
|
581
583
|
} catch (err) {
|
|
582
|
-
logger.
|
|
584
|
+
logger.warn("AWS EC2 CSV streaming failed", {
|
|
583
585
|
region,
|
|
584
586
|
instanceType,
|
|
585
587
|
err: err instanceof Error ? err.message : String(err)
|
|
@@ -595,7 +597,7 @@ var AwsBulkLoader = class _AwsBulkLoader {
|
|
|
595
597
|
}
|
|
596
598
|
}
|
|
597
599
|
} catch (err) {
|
|
598
|
-
logger.
|
|
600
|
+
logger.warn("AWS bulk EC2 JSON fetch failed, using fallback", {
|
|
599
601
|
region,
|
|
600
602
|
instanceType,
|
|
601
603
|
err: err instanceof Error ? err.message : String(err)
|
|
@@ -617,7 +619,7 @@ var AwsBulkLoader = class _AwsBulkLoader {
|
|
|
617
619
|
}
|
|
618
620
|
}
|
|
619
621
|
} catch (err) {
|
|
620
|
-
logger.
|
|
622
|
+
logger.warn("AWS bulk RDS fetch failed, using fallback", {
|
|
621
623
|
region,
|
|
622
624
|
instanceClass,
|
|
623
625
|
err: err instanceof Error ? err.message : String(err)
|
|
@@ -639,7 +641,7 @@ var AwsBulkLoader = class _AwsBulkLoader {
|
|
|
639
641
|
}
|
|
640
642
|
}
|
|
641
643
|
} catch (err) {
|
|
642
|
-
logger.
|
|
644
|
+
logger.warn("AWS bulk EBS fetch failed, using fallback", {
|
|
643
645
|
region,
|
|
644
646
|
volumeType,
|
|
645
647
|
err: err instanceof Error ? err.message : String(err)
|
|
@@ -918,7 +920,8 @@ var AwsBulkLoader = class _AwsBulkLoader {
|
|
|
918
920
|
extractEc2Price(bulk, instanceType, region, os) {
|
|
919
921
|
const products = bulk?.products ?? {};
|
|
920
922
|
const onDemand = bulk?.terms?.OnDemand ?? {};
|
|
921
|
-
|
|
923
|
+
const sortedEntries = Object.entries(products).sort(([a], [b]) => a.localeCompare(b));
|
|
924
|
+
for (const [sku, product] of sortedEntries) {
|
|
922
925
|
const attrs = product?.attributes ?? {};
|
|
923
926
|
if (attrs.instanceType === instanceType && (attrs.operatingSystem ?? "").toLowerCase() === os.toLowerCase() && attrs.tenancy === "Shared" && attrs.capacitystatus === "Used") {
|
|
924
927
|
const priceTerms = onDemand[sku];
|
|
@@ -932,7 +935,8 @@ var AwsBulkLoader = class _AwsBulkLoader {
|
|
|
932
935
|
extractRdsPrice(bulk, instanceClass, region, engine) {
|
|
933
936
|
const products = bulk?.products ?? {};
|
|
934
937
|
const onDemand = bulk?.terms?.OnDemand ?? {};
|
|
935
|
-
|
|
938
|
+
const sortedEntries = Object.entries(products).sort(([a], [b]) => a.localeCompare(b));
|
|
939
|
+
for (const [sku, product] of sortedEntries) {
|
|
936
940
|
const attrs = product?.attributes ?? {};
|
|
937
941
|
if (attrs.instanceType === instanceClass && (attrs.databaseEngine ?? "").toLowerCase().includes(engine.toLowerCase()) && attrs.deploymentOption === "Single-AZ") {
|
|
938
942
|
const priceTerms = onDemand[sku];
|
|
@@ -946,7 +950,8 @@ var AwsBulkLoader = class _AwsBulkLoader {
|
|
|
946
950
|
extractEbsPrice(bulk, volumeType, region) {
|
|
947
951
|
const products = bulk?.products ?? {};
|
|
948
952
|
const onDemand = bulk?.terms?.OnDemand ?? {};
|
|
949
|
-
|
|
953
|
+
const sortedEntries = Object.entries(products).sort(([a], [b]) => a.localeCompare(b));
|
|
954
|
+
for (const [sku, product] of sortedEntries) {
|
|
950
955
|
const attrs = product?.attributes ?? {};
|
|
951
956
|
const apiName = (attrs.volumeApiName ?? "").toLowerCase();
|
|
952
957
|
if (product?.productFamily === "Storage" && (apiName === volumeType.toLowerCase() || (attrs.volumeType ?? "").toLowerCase() === volumeType.toLowerCase())) {
|
|
@@ -1296,7 +1301,7 @@ var AzureRetailClient = class {
|
|
|
1296
1301
|
return result;
|
|
1297
1302
|
}
|
|
1298
1303
|
} catch (err) {
|
|
1299
|
-
logger.
|
|
1304
|
+
logger.warn("Azure Retail API VM fetch failed, using fallback", {
|
|
1300
1305
|
region,
|
|
1301
1306
|
vmSize,
|
|
1302
1307
|
err: err instanceof Error ? err.message : String(err)
|
|
@@ -1311,15 +1316,18 @@ var AzureRetailClient = class {
|
|
|
1311
1316
|
try {
|
|
1312
1317
|
const armRegion = region.toLowerCase().replace(/\s+/g, "");
|
|
1313
1318
|
const serviceName = `Azure Database for ${engine}`;
|
|
1314
|
-
const
|
|
1315
|
-
|
|
1316
|
-
if (items.length > 0) {
|
|
1317
|
-
const result = normalizeAzureDatabase(items[0]);
|
|
1319
|
+
const result = await this.queryDatabasePrice(tier, armRegion, serviceName);
|
|
1320
|
+
if (result) {
|
|
1318
1321
|
this.cache.set(cacheKey, result, "azure", "db", region, CACHE_TTL2);
|
|
1319
1322
|
return result;
|
|
1320
1323
|
}
|
|
1324
|
+
logger.warn("Azure Retail API returned no matching DB pricing", {
|
|
1325
|
+
region,
|
|
1326
|
+
tier,
|
|
1327
|
+
engine
|
|
1328
|
+
});
|
|
1321
1329
|
} catch (err) {
|
|
1322
|
-
logger.
|
|
1330
|
+
logger.warn("Azure Retail API DB fetch failed, using fallback", {
|
|
1323
1331
|
region,
|
|
1324
1332
|
tier,
|
|
1325
1333
|
err: err instanceof Error ? err.message : String(err)
|
|
@@ -1327,6 +1335,58 @@ var AzureRetailClient = class {
|
|
|
1327
1335
|
}
|
|
1328
1336
|
return this.fallbackDatabasePrice(tier, region, engine, cacheKey);
|
|
1329
1337
|
}
|
|
1338
|
+
/**
|
|
1339
|
+
* Azure DB flexible server tier names follow the pattern:
|
|
1340
|
+
* GP_Standard_D2s_v3 → General Purpose, D-series, 2 vCPUs, v3
|
|
1341
|
+
* MO_Standard_E4ds_v5 → Memory Optimized, E-series, 4 vCPUs, v5
|
|
1342
|
+
*
|
|
1343
|
+
* The Retail API prices DB compute in two ways:
|
|
1344
|
+
* 1. Per-instance entries with armSkuName like "Standard_D2ds_v5" (total price)
|
|
1345
|
+
* 2. Per-vCore entries for a series (e.g., Dsv3Series_Compute) × vCPU count
|
|
1346
|
+
*
|
|
1347
|
+
* This method tries approach 1 first, then falls back to approach 2.
|
|
1348
|
+
*/
|
|
1349
|
+
async queryDatabasePrice(tier, armRegion, serviceName) {
|
|
1350
|
+
const vmName = tier.replace(/^(GP|MO|BC|GEN)_/i, "");
|
|
1351
|
+
const exactFilter = `serviceName eq '${serviceName}' and armRegionName eq '${armRegion}' and priceType eq 'Consumption' and armSkuName eq '${vmName}'`;
|
|
1352
|
+
const exactItems = await this.queryPricing(exactFilter);
|
|
1353
|
+
if (exactItems.length > 0) {
|
|
1354
|
+
exactItems.sort((a, b) => (a.skuId ?? "").localeCompare(b.skuId ?? ""));
|
|
1355
|
+
const match = exactItems.find(
|
|
1356
|
+
(i) => (i.meterName ?? "").toLowerCase().includes("vcore")
|
|
1357
|
+
) ?? exactItems[0];
|
|
1358
|
+
return normalizeAzureDatabase(match);
|
|
1359
|
+
}
|
|
1360
|
+
const parsed = this.parseVmSeries(vmName);
|
|
1361
|
+
if (!parsed) return null;
|
|
1362
|
+
const tierPrefix = tier.match(/^(GP|MO|BC|GEN)_/i)?.[1]?.toUpperCase();
|
|
1363
|
+
const seriesFamily = tierPrefix === "MO" ? "Memory_Optimized" : "General_Purpose";
|
|
1364
|
+
const seriesFilter = `serviceName eq '${serviceName}' and armRegionName eq '${armRegion}' and priceType eq 'Consumption' and contains(armSkuName, '${seriesFamily}') and contains(armSkuName, '${parsed.series}')`;
|
|
1365
|
+
const seriesItems = await this.queryPricing(seriesFilter);
|
|
1366
|
+
if (seriesItems.length > 0) {
|
|
1367
|
+
seriesItems.sort((a, b) => (a.skuId ?? "").localeCompare(b.skuId ?? ""));
|
|
1368
|
+
const perVcore = seriesItems[0].retailPrice ?? 0;
|
|
1369
|
+
const totalPrice = perVcore * parsed.vcpus;
|
|
1370
|
+
const adjusted = { ...seriesItems[0], retailPrice: totalPrice };
|
|
1371
|
+
return normalizeAzureDatabase(adjusted);
|
|
1372
|
+
}
|
|
1373
|
+
return null;
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Parse a VM name like "Standard_D2s_v3" into series info.
|
|
1377
|
+
* Returns { series: "Dsv3", vcpus: 2 } or null if unparseable.
|
|
1378
|
+
*/
|
|
1379
|
+
parseVmSeries(vmName) {
|
|
1380
|
+
const match = vmName.match(
|
|
1381
|
+
/Standard_([A-Z])(\d+)([a-z]*)_v(\d+)/i
|
|
1382
|
+
);
|
|
1383
|
+
if (!match) return null;
|
|
1384
|
+
const [, family, vcpuStr, variant, version] = match;
|
|
1385
|
+
const vcpus = parseInt(vcpuStr, 10);
|
|
1386
|
+
if (isNaN(vcpus) || vcpus === 0) return null;
|
|
1387
|
+
const series = `${family}${variant}v${version}`;
|
|
1388
|
+
return { series, vcpus };
|
|
1389
|
+
}
|
|
1330
1390
|
async getStoragePrice(diskType, region) {
|
|
1331
1391
|
const cacheKey = this.buildCacheKey("disk", region, diskType);
|
|
1332
1392
|
const cached = this.cache.get(cacheKey);
|
|
@@ -1336,12 +1396,17 @@ var AzureRetailClient = class {
|
|
|
1336
1396
|
const filter = this.buildODataFilter("Storage", armRegion, diskType);
|
|
1337
1397
|
const items = await this.queryPricing(filter);
|
|
1338
1398
|
if (items.length > 0) {
|
|
1399
|
+
items.sort((a, b) => (a.skuId ?? "").localeCompare(b.skuId ?? ""));
|
|
1339
1400
|
const result = normalizeAzureStorage(items[0]);
|
|
1340
1401
|
this.cache.set(cacheKey, result, "azure", "storage", region, CACHE_TTL2);
|
|
1341
1402
|
return result;
|
|
1342
1403
|
}
|
|
1404
|
+
logger.warn("Azure Retail API returned no matching storage pricing", {
|
|
1405
|
+
region,
|
|
1406
|
+
diskType
|
|
1407
|
+
});
|
|
1343
1408
|
} catch (err) {
|
|
1344
|
-
logger.
|
|
1409
|
+
logger.warn("Azure Retail API storage fetch failed, using fallback", {
|
|
1345
1410
|
region,
|
|
1346
1411
|
diskType,
|
|
1347
1412
|
err: err instanceof Error ? err.message : String(err)
|
|
@@ -1442,16 +1507,19 @@ var AzureRetailClient = class {
|
|
|
1442
1507
|
*/
|
|
1443
1508
|
pickVmItem(items, vmSize, os) {
|
|
1444
1509
|
if (items.length === 0) return null;
|
|
1510
|
+
const sorted = [...items].sort(
|
|
1511
|
+
(a, b) => (a.skuId ?? "").localeCompare(b.skuId ?? "")
|
|
1512
|
+
);
|
|
1445
1513
|
const vmLower = vmSize.toLowerCase();
|
|
1446
1514
|
const isWindows = os.toLowerCase().includes("windows");
|
|
1447
|
-
for (const item of
|
|
1515
|
+
for (const item of sorted) {
|
|
1448
1516
|
const skuLower = (item.skuName ?? "").toLowerCase();
|
|
1449
1517
|
const hasWindows = skuLower.includes("windows");
|
|
1450
1518
|
if (skuLower.includes(vmLower) && (isWindows && hasWindows || !isWindows && !hasWindows)) {
|
|
1451
1519
|
return item;
|
|
1452
1520
|
}
|
|
1453
1521
|
}
|
|
1454
|
-
return
|
|
1522
|
+
return sorted[0];
|
|
1455
1523
|
}
|
|
1456
1524
|
// -------------------------------------------------------------------------
|
|
1457
1525
|
// Internal helpers – size interpolation
|
|
@@ -2920,7 +2988,7 @@ function findNearestInstance(spec, targetProvider) {
|
|
|
2920
2988
|
if (candidate.category === spec.category) {
|
|
2921
2989
|
score += SCORE_CATEGORY;
|
|
2922
2990
|
}
|
|
2923
|
-
if (score > bestScore) {
|
|
2991
|
+
if (score > bestScore || score === bestScore && bestType !== null && candidate.instance_type < bestType) {
|
|
2924
2992
|
bestScore = score;
|
|
2925
2993
|
bestType = candidate.instance_type;
|
|
2926
2994
|
}
|
|
@@ -3657,7 +3725,9 @@ var CostEngine = class {
|
|
|
3657
3725
|
total_monthly: Math.round(totalMonthly * 100) / 100,
|
|
3658
3726
|
total_yearly: Math.round(totalMonthly * 12 * 100) / 100,
|
|
3659
3727
|
currency: "USD",
|
|
3660
|
-
by_service:
|
|
3728
|
+
by_service: Object.fromEntries(
|
|
3729
|
+
Object.entries(byService).map(([k, v]) => [k, Math.round(v * 100) / 100])
|
|
3730
|
+
),
|
|
3661
3731
|
by_resource: estimates,
|
|
3662
3732
|
generated_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3663
3733
|
warnings
|