@jadenrazo/cloudcost-mcp 1.0.0 → 1.0.1
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/CHANGELOG.md +16 -0
- package/data/aws-pricing/metadata.json +8 -0
- package/data/azure-pricing/metadata.json +8 -0
- package/data/gcp-pricing/metadata.json +4 -2
- package/dist/{chunk-E7KOWAMW.js → chunk-6O2Y6MKU.js} +1139 -198
- package/dist/{chunk-TRRAOOVF.js → chunk-MNFT5YKN.js} +13 -1
- package/dist/cli.js +2 -2
- package/dist/index.js +43 -21
- package/dist/{loader-VXYJYDIH.js → loader-UWVEXYMR.js} +4 -2
- package/package.json +2 -2
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
getRegionMappings,
|
|
12
12
|
getRegionPriceMultipliers,
|
|
13
13
|
getStorageMap
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-MNFT5YKN.js";
|
|
15
15
|
|
|
16
16
|
// src/config.ts
|
|
17
17
|
import { readFileSync, existsSync } from "fs";
|
|
@@ -562,7 +562,14 @@ function extractFromTerms(onDemandTerms, defaultUnit) {
|
|
|
562
562
|
}
|
|
563
563
|
return { price: 0, unit: defaultUnit };
|
|
564
564
|
}
|
|
565
|
-
function
|
|
565
|
+
function resolveEffectiveDate(effectiveDate) {
|
|
566
|
+
if (effectiveDate) {
|
|
567
|
+
const t = new Date(effectiveDate);
|
|
568
|
+
if (!Number.isNaN(t.getTime())) return t.toISOString();
|
|
569
|
+
}
|
|
570
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
571
|
+
}
|
|
572
|
+
function normalizeAwsCompute(rawProduct, rawPrice, region, effectiveDate) {
|
|
566
573
|
const attrs = rawProduct?.attributes ?? {};
|
|
567
574
|
const terms = rawPrice?.terms?.OnDemand ?? {};
|
|
568
575
|
const { price, unit } = extractFromTerms(terms, "Hrs");
|
|
@@ -583,10 +590,10 @@ function normalizeAwsCompute(rawProduct, rawPrice, region) {
|
|
|
583
590
|
tenancy: attrs.tenancy ?? "Shared",
|
|
584
591
|
pricing_source: "live"
|
|
585
592
|
},
|
|
586
|
-
effective_date: (
|
|
593
|
+
effective_date: resolveEffectiveDate(effectiveDate)
|
|
587
594
|
};
|
|
588
595
|
}
|
|
589
|
-
function normalizeAwsDatabase(rawProduct, rawPrice, region) {
|
|
596
|
+
function normalizeAwsDatabase(rawProduct, rawPrice, region, effectiveDate) {
|
|
590
597
|
const attrs = rawProduct?.attributes ?? {};
|
|
591
598
|
const terms = rawPrice?.terms?.OnDemand ?? {};
|
|
592
599
|
const { price, unit } = extractFromTerms(terms, "Hrs");
|
|
@@ -607,10 +614,10 @@ function normalizeAwsDatabase(rawProduct, rawPrice, region) {
|
|
|
607
614
|
memory: attrs.memory ?? "",
|
|
608
615
|
pricing_source: "live"
|
|
609
616
|
},
|
|
610
|
-
effective_date: (
|
|
617
|
+
effective_date: resolveEffectiveDate(effectiveDate)
|
|
611
618
|
};
|
|
612
619
|
}
|
|
613
|
-
function normalizeAwsStorage(rawProduct, rawPrice, region) {
|
|
620
|
+
function normalizeAwsStorage(rawProduct, rawPrice, region, effectiveDate) {
|
|
614
621
|
const attrs = rawProduct?.attributes ?? {};
|
|
615
622
|
const terms = rawPrice?.terms?.OnDemand ?? {};
|
|
616
623
|
const { price, unit } = extractFromTerms(terms, "GB-Mo");
|
|
@@ -630,7 +637,7 @@ function normalizeAwsStorage(rawProduct, rawPrice, region) {
|
|
|
630
637
|
max_throughput: attrs.maxThroughputvolume ?? "",
|
|
631
638
|
pricing_source: "live"
|
|
632
639
|
},
|
|
633
|
-
effective_date: (
|
|
640
|
+
effective_date: resolveEffectiveDate(effectiveDate)
|
|
634
641
|
};
|
|
635
642
|
}
|
|
636
643
|
|
|
@@ -683,7 +690,7 @@ function sleep(ms) {
|
|
|
683
690
|
if (process.env.NODE_ENV === "test" || process.env.VITEST) {
|
|
684
691
|
return Promise.resolve();
|
|
685
692
|
}
|
|
686
|
-
return new Promise((
|
|
693
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
687
694
|
}
|
|
688
695
|
var CIRCUIT_FAILURE_THRESHOLD = 5;
|
|
689
696
|
var CIRCUIT_OPEN_DURATION_MS = 5 * 60 * 1e3;
|
|
@@ -901,6 +908,11 @@ var EC2_BASE_PRICES = {
|
|
|
901
908
|
"m7i.large": 0.1008,
|
|
902
909
|
"m7i.xlarge": 0.2016,
|
|
903
910
|
"m7i.2xlarge": 0.4032,
|
|
911
|
+
"m7i-flex.large": 0.0958,
|
|
912
|
+
"m7i-flex.xlarge": 0.1915,
|
|
913
|
+
"m7i-flex.2xlarge": 0.383,
|
|
914
|
+
"m7i-flex.4xlarge": 0.7661,
|
|
915
|
+
"m7i-flex.8xlarge": 1.5322,
|
|
904
916
|
"m7g.large": 0.0816,
|
|
905
917
|
"m7g.xlarge": 0.1632,
|
|
906
918
|
"m7g.2xlarge": 0.3264,
|
|
@@ -918,10 +930,10 @@ var EC2_BASE_PRICES = {
|
|
|
918
930
|
"c6g.xlarge": 0.136,
|
|
919
931
|
"c6g.2xlarge": 0.272,
|
|
920
932
|
"c6g.4xlarge": 0.544,
|
|
921
|
-
"c7i.large": 0.
|
|
922
|
-
"c7i.xlarge": 0.
|
|
933
|
+
"c7i.large": 0.0893,
|
|
934
|
+
"c7i.xlarge": 0.1785,
|
|
923
935
|
"c7i.2xlarge": 0.357,
|
|
924
|
-
"c7g.large": 0.
|
|
936
|
+
"c7g.large": 0.0725,
|
|
925
937
|
"c7g.xlarge": 0.145,
|
|
926
938
|
"c7g.2xlarge": 0.29,
|
|
927
939
|
"c7g.4xlarge": 0.58,
|
|
@@ -934,65 +946,63 @@ var EC2_BASE_PRICES = {
|
|
|
934
946
|
"r6i.large": 0.126,
|
|
935
947
|
"r6i.xlarge": 0.252,
|
|
936
948
|
"r6i.2xlarge": 0.504,
|
|
937
|
-
"r6g.large": 0.
|
|
938
|
-
"r6g.xlarge": 0.
|
|
939
|
-
"r6g.2xlarge": 0.
|
|
940
|
-
"r6g.4xlarge": 0.
|
|
941
|
-
"r7i.large": 0.
|
|
942
|
-
"r7i.xlarge": 0.
|
|
943
|
-
"r7i.2xlarge": 0.
|
|
944
|
-
"r7g.large": 0.
|
|
945
|
-
"r7g.xlarge": 0.
|
|
946
|
-
"r7g.2xlarge": 0.
|
|
947
|
-
"r7g.4xlarge": 0.
|
|
948
|
-
|
|
949
|
-
"m8g.
|
|
950
|
-
"m8g.
|
|
951
|
-
"m8g.
|
|
952
|
-
"
|
|
953
|
-
"c8g.
|
|
954
|
-
"c8g.
|
|
955
|
-
"c8g.
|
|
956
|
-
"
|
|
957
|
-
"r8g.
|
|
958
|
-
"r8g.
|
|
959
|
-
"r8g.
|
|
960
|
-
"r8g.4xlarge": 0.808,
|
|
961
|
-
// GPU instances
|
|
949
|
+
"r6g.large": 0.1008,
|
|
950
|
+
"r6g.xlarge": 0.2016,
|
|
951
|
+
"r6g.2xlarge": 0.4032,
|
|
952
|
+
"r6g.4xlarge": 0.8064,
|
|
953
|
+
"r7i.large": 0.1323,
|
|
954
|
+
"r7i.xlarge": 0.2646,
|
|
955
|
+
"r7i.2xlarge": 0.5292,
|
|
956
|
+
"r7g.large": 0.1071,
|
|
957
|
+
"r7g.xlarge": 0.2142,
|
|
958
|
+
"r7g.2xlarge": 0.4284,
|
|
959
|
+
"r7g.4xlarge": 0.8568,
|
|
960
|
+
"m8g.large": 0.0898,
|
|
961
|
+
"m8g.xlarge": 0.1795,
|
|
962
|
+
"m8g.2xlarge": 0.359,
|
|
963
|
+
"m8g.4xlarge": 0.7181,
|
|
964
|
+
"c8g.large": 0.0798,
|
|
965
|
+
"c8g.xlarge": 0.1595,
|
|
966
|
+
"c8g.2xlarge": 0.319,
|
|
967
|
+
"c8g.4xlarge": 0.6381,
|
|
968
|
+
"r8g.large": 0.1178,
|
|
969
|
+
"r8g.xlarge": 0.2356,
|
|
970
|
+
"r8g.2xlarge": 0.4713,
|
|
971
|
+
"r8g.4xlarge": 0.9426,
|
|
962
972
|
"g5.xlarge": 1.006,
|
|
963
973
|
"g5.2xlarge": 1.212,
|
|
964
974
|
"g5.4xlarge": 1.624,
|
|
965
975
|
"g5.8xlarge": 2.448,
|
|
966
976
|
"g5.12xlarge": 5.672,
|
|
967
|
-
"p4d.24xlarge":
|
|
977
|
+
"p4d.24xlarge": 21.9576
|
|
968
978
|
};
|
|
969
979
|
var RDS_BASE_PRICES = {
|
|
970
|
-
"db.t3.micro": 0.
|
|
971
|
-
"db.t3.small": 0.
|
|
972
|
-
"db.t3.medium": 0.
|
|
973
|
-
"db.t3.large": 0.
|
|
980
|
+
"db.t3.micro": 0.018,
|
|
981
|
+
"db.t3.small": 0.036,
|
|
982
|
+
"db.t3.medium": 0.072,
|
|
983
|
+
"db.t3.large": 0.145,
|
|
974
984
|
"db.t4g.micro": 0.016,
|
|
975
985
|
"db.t4g.small": 0.032,
|
|
976
986
|
"db.t4g.medium": 0.065,
|
|
977
987
|
"db.t4g.large": 0.129,
|
|
978
988
|
"db.t4g.xlarge": 0.258,
|
|
979
|
-
"db.m5.large": 0.
|
|
980
|
-
"db.m5.xlarge": 0.
|
|
981
|
-
"db.m6g.large": 0.
|
|
982
|
-
"db.m6g.xlarge": 0.
|
|
983
|
-
"db.m6g.2xlarge": 0.
|
|
984
|
-
"db.m6i.large": 0.
|
|
985
|
-
"db.m6i.xlarge": 0.
|
|
986
|
-
"db.m6i.2xlarge": 0.
|
|
989
|
+
"db.m5.large": 0.178,
|
|
990
|
+
"db.m5.xlarge": 0.356,
|
|
991
|
+
"db.m6g.large": 0.159,
|
|
992
|
+
"db.m6g.xlarge": 0.318,
|
|
993
|
+
"db.m6g.2xlarge": 0.636,
|
|
994
|
+
"db.m6i.large": 0.178,
|
|
995
|
+
"db.m6i.xlarge": 0.356,
|
|
996
|
+
"db.m6i.2xlarge": 0.712,
|
|
987
997
|
"db.m7g.large": 0.168,
|
|
988
|
-
"db.m7g.xlarge": 0.
|
|
989
|
-
"db.m7g.2xlarge": 0.
|
|
998
|
+
"db.m7g.xlarge": 0.337,
|
|
999
|
+
"db.m7g.2xlarge": 0.674,
|
|
990
1000
|
"db.r5.large": 0.25,
|
|
991
1001
|
"db.r5.xlarge": 0.5,
|
|
992
1002
|
"db.r5.2xlarge": 1,
|
|
993
|
-
"db.r6g.large": 0.
|
|
994
|
-
"db.r6g.xlarge": 0.
|
|
995
|
-
"db.r6g.2xlarge": 0.
|
|
1003
|
+
"db.r6g.large": 0.225,
|
|
1004
|
+
"db.r6g.xlarge": 0.45,
|
|
1005
|
+
"db.r6g.2xlarge": 0.899,
|
|
996
1006
|
"db.r6i.large": 0.25,
|
|
997
1007
|
"db.r6i.xlarge": 0.5,
|
|
998
1008
|
"db.r6i.2xlarge": 1
|
|
@@ -1247,7 +1257,7 @@ var AwsBulkLoader = class {
|
|
|
1247
1257
|
let headerFound = false;
|
|
1248
1258
|
let leftover = "";
|
|
1249
1259
|
let cachedCount = 0;
|
|
1250
|
-
|
|
1260
|
+
let effectiveDate = (/* @__PURE__ */ new Date()).toISOString();
|
|
1251
1261
|
while (true) {
|
|
1252
1262
|
const { value, done } = await reader.read();
|
|
1253
1263
|
if (done) break;
|
|
@@ -1258,6 +1268,17 @@ var AwsBulkLoader = class {
|
|
|
1258
1268
|
const line = rawLine.trimEnd();
|
|
1259
1269
|
if (!line) continue;
|
|
1260
1270
|
if (!headerFound) {
|
|
1271
|
+
if (line.startsWith('"Publication Date"') || line.startsWith("Publication Date")) {
|
|
1272
|
+
const metaFields = parseCsvLine(line);
|
|
1273
|
+
const rawDate = (metaFields[1] ?? "").trim();
|
|
1274
|
+
if (rawDate) {
|
|
1275
|
+
const parsed = new Date(rawDate);
|
|
1276
|
+
if (!isNaN(parsed.getTime())) {
|
|
1277
|
+
effectiveDate = parsed.toISOString();
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
continue;
|
|
1281
|
+
}
|
|
1261
1282
|
if (line.startsWith('"SKU"') || line.startsWith("SKU")) {
|
|
1262
1283
|
const headers = parseCsvLine(line);
|
|
1263
1284
|
for (let h = 0; h < headers.length; h++) {
|
|
@@ -1362,7 +1383,7 @@ var AwsBulkLoader = class {
|
|
|
1362
1383
|
tenancy: "Shared",
|
|
1363
1384
|
pricing_source: "live"
|
|
1364
1385
|
},
|
|
1365
|
-
effective_date:
|
|
1386
|
+
effective_date: effectiveDate
|
|
1366
1387
|
},
|
|
1367
1388
|
"aws",
|
|
1368
1389
|
"ec2",
|
|
@@ -1422,6 +1443,7 @@ var AwsBulkLoader = class {
|
|
|
1422
1443
|
extractEc2Price(bulk, instanceType, region, os) {
|
|
1423
1444
|
const products = bulk?.products ?? {};
|
|
1424
1445
|
const onDemand = bulk?.terms?.OnDemand ?? {};
|
|
1446
|
+
const publicationDate = bulk?.publicationDate;
|
|
1425
1447
|
const sortedEntries = Object.entries(products).sort(([a], [b]) => a.localeCompare(b));
|
|
1426
1448
|
for (const [sku, product] of sortedEntries) {
|
|
1427
1449
|
const attrs = product?.attributes ?? {};
|
|
@@ -1431,7 +1453,8 @@ var AwsBulkLoader = class {
|
|
|
1431
1453
|
return normalizeAwsCompute(
|
|
1432
1454
|
product,
|
|
1433
1455
|
{ terms: { OnDemand: { [sku]: priceTerms } } },
|
|
1434
|
-
region
|
|
1456
|
+
region,
|
|
1457
|
+
publicationDate
|
|
1435
1458
|
);
|
|
1436
1459
|
}
|
|
1437
1460
|
}
|
|
@@ -1441,6 +1464,7 @@ var AwsBulkLoader = class {
|
|
|
1441
1464
|
extractRdsPrice(bulk, instanceClass, region, engine) {
|
|
1442
1465
|
const products = bulk?.products ?? {};
|
|
1443
1466
|
const onDemand = bulk?.terms?.OnDemand ?? {};
|
|
1467
|
+
const publicationDate = bulk?.publicationDate;
|
|
1444
1468
|
const sortedEntries = Object.entries(products).sort(([a], [b]) => a.localeCompare(b));
|
|
1445
1469
|
for (const [sku, product] of sortedEntries) {
|
|
1446
1470
|
const attrs = product?.attributes ?? {};
|
|
@@ -1450,7 +1474,8 @@ var AwsBulkLoader = class {
|
|
|
1450
1474
|
return normalizeAwsDatabase(
|
|
1451
1475
|
product,
|
|
1452
1476
|
{ terms: { OnDemand: { [sku]: priceTerms } } },
|
|
1453
|
-
region
|
|
1477
|
+
region,
|
|
1478
|
+
publicationDate
|
|
1454
1479
|
);
|
|
1455
1480
|
}
|
|
1456
1481
|
}
|
|
@@ -1460,6 +1485,7 @@ var AwsBulkLoader = class {
|
|
|
1460
1485
|
extractEbsPrice(bulk, volumeType, region) {
|
|
1461
1486
|
const products = bulk?.products ?? {};
|
|
1462
1487
|
const onDemand = bulk?.terms?.OnDemand ?? {};
|
|
1488
|
+
const publicationDate = bulk?.publicationDate;
|
|
1463
1489
|
const sortedEntries = Object.entries(products).sort(([a], [b]) => a.localeCompare(b));
|
|
1464
1490
|
for (const [sku, product] of sortedEntries) {
|
|
1465
1491
|
const attrs = product?.attributes ?? {};
|
|
@@ -1470,7 +1496,8 @@ var AwsBulkLoader = class {
|
|
|
1470
1496
|
return normalizeAwsStorage(
|
|
1471
1497
|
product,
|
|
1472
1498
|
{ terms: { OnDemand: { [sku]: priceTerms } } },
|
|
1473
|
-
region
|
|
1499
|
+
region,
|
|
1500
|
+
publicationDate
|
|
1474
1501
|
);
|
|
1475
1502
|
}
|
|
1476
1503
|
}
|
|
@@ -1561,6 +1588,226 @@ var AwsBulkLoader = class {
|
|
|
1561
1588
|
}
|
|
1562
1589
|
};
|
|
1563
1590
|
|
|
1591
|
+
// src/pricing/aws/spot-client.ts
|
|
1592
|
+
var SPOT_ADVISOR_URL = "https://website.spot.ec2.aws.a2z.com/spot.json";
|
|
1593
|
+
var SPOT_ADVISOR_CACHE_KEY = "aws/spot-advisor/v1";
|
|
1594
|
+
var AwsSpotClient = class {
|
|
1595
|
+
cache;
|
|
1596
|
+
/** In-flight fetch deduplication. */
|
|
1597
|
+
inflight = null;
|
|
1598
|
+
constructor(cache) {
|
|
1599
|
+
this.cache = cache;
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Get the spot discount factor (0–1) for an instance type in a region.
|
|
1603
|
+
*
|
|
1604
|
+
* Returns a fraction representing the **portion of on-demand you pay** — i.e.
|
|
1605
|
+
* `hourly_spot = hourly_on_demand * factor`. Returns null when the live data
|
|
1606
|
+
* is unavailable or doesn't cover the given instance/region combination;
|
|
1607
|
+
* callers should fall back to static family-based estimates in that case.
|
|
1608
|
+
*/
|
|
1609
|
+
async getSpotFactor(instanceType, region, os = "Linux") {
|
|
1610
|
+
try {
|
|
1611
|
+
const doc = await this.loadDocument();
|
|
1612
|
+
if (!doc) return null;
|
|
1613
|
+
const regionData = doc.spot_advisor?.[region];
|
|
1614
|
+
if (!regionData) return null;
|
|
1615
|
+
const osKey = os === "Windows" ? "Windows" : "Linux";
|
|
1616
|
+
const osData = regionData[osKey];
|
|
1617
|
+
if (!osData) return null;
|
|
1618
|
+
const entry = osData[instanceType];
|
|
1619
|
+
if (!entry || typeof entry.s !== "number") return null;
|
|
1620
|
+
const savingsPct = Math.max(0, Math.min(95, entry.s));
|
|
1621
|
+
const factor = 1 - savingsPct / 100;
|
|
1622
|
+
return Math.max(0.05, factor);
|
|
1623
|
+
} catch (err) {
|
|
1624
|
+
logger.debug("AwsSpotClient.getSpotFactor failed", {
|
|
1625
|
+
instanceType,
|
|
1626
|
+
region,
|
|
1627
|
+
err: err instanceof Error ? err.message : String(err)
|
|
1628
|
+
});
|
|
1629
|
+
return null;
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
/**
|
|
1633
|
+
* Fetch and cache the full Spot Advisor document. Uses the shared SQLite
|
|
1634
|
+
* cache with the same TTL as other AWS bulk data. Deduplicates concurrent
|
|
1635
|
+
* callers via an in-flight promise.
|
|
1636
|
+
*/
|
|
1637
|
+
loadDocument() {
|
|
1638
|
+
const cached = this.cache.get(SPOT_ADVISOR_CACHE_KEY);
|
|
1639
|
+
if (cached) return Promise.resolve(cached);
|
|
1640
|
+
if (this.inflight) return this.inflight;
|
|
1641
|
+
this.inflight = this.doFetchDocument().finally(() => {
|
|
1642
|
+
this.inflight = null;
|
|
1643
|
+
});
|
|
1644
|
+
return this.inflight;
|
|
1645
|
+
}
|
|
1646
|
+
async doFetchDocument() {
|
|
1647
|
+
logger.debug("Fetching AWS Spot Advisor JSON", { url: SPOT_ADVISOR_URL });
|
|
1648
|
+
try {
|
|
1649
|
+
const res = await fetchWithRetryAndCircuitBreaker(
|
|
1650
|
+
SPOT_ADVISOR_URL,
|
|
1651
|
+
{ signal: AbortSignal.timeout(2e4) },
|
|
1652
|
+
{ maxRetries: 1, baseDelay: 500, maxDelay: 2e3 }
|
|
1653
|
+
);
|
|
1654
|
+
if (!res.ok) {
|
|
1655
|
+
logger.debug("AWS Spot Advisor fetch returned non-OK", { status: res.status });
|
|
1656
|
+
return null;
|
|
1657
|
+
}
|
|
1658
|
+
const doc = await res.json();
|
|
1659
|
+
if (!doc || typeof doc.spot_advisor !== "object") {
|
|
1660
|
+
logger.debug("AWS Spot Advisor response missing spot_advisor key");
|
|
1661
|
+
return null;
|
|
1662
|
+
}
|
|
1663
|
+
this.cache.set(SPOT_ADVISOR_CACHE_KEY, doc, "aws", "spot-advisor", "global", CACHE_TTL);
|
|
1664
|
+
return doc;
|
|
1665
|
+
} catch (err) {
|
|
1666
|
+
logger.debug("AWS Spot Advisor fetch failed", {
|
|
1667
|
+
err: err instanceof Error ? err.message : String(err)
|
|
1668
|
+
});
|
|
1669
|
+
return null;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
};
|
|
1673
|
+
|
|
1674
|
+
// src/pricing/aws/reserved-client.ts
|
|
1675
|
+
var HOURS_IN_TERM = {
|
|
1676
|
+
"1yr": 24 * 365,
|
|
1677
|
+
"3yr": 24 * 365 * 3
|
|
1678
|
+
};
|
|
1679
|
+
function extractRiRatesFromBulk(bulk, instanceType, os = "Linux") {
|
|
1680
|
+
const products = bulk?.products ?? {};
|
|
1681
|
+
const onDemand = bulk?.terms?.OnDemand ?? {};
|
|
1682
|
+
const reserved = bulk?.terms?.Reserved ?? {};
|
|
1683
|
+
let targetSku = null;
|
|
1684
|
+
for (const [sku, product] of Object.entries(products)) {
|
|
1685
|
+
const attrs = product?.attributes ?? {};
|
|
1686
|
+
if (attrs.instanceType === instanceType && (attrs.operatingSystem ?? "").toLowerCase() === os.toLowerCase() && attrs.tenancy === "Shared" && attrs.capacitystatus === "Used") {
|
|
1687
|
+
targetSku = sku;
|
|
1688
|
+
break;
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
1691
|
+
if (!targetSku) return [];
|
|
1692
|
+
const onDemandHourly = firstHourlyPrice(onDemand[targetSku]);
|
|
1693
|
+
if (!onDemandHourly || onDemandHourly <= 0) return [];
|
|
1694
|
+
const reservedOffers = reserved[targetSku];
|
|
1695
|
+
if (!reservedOffers) return [];
|
|
1696
|
+
const rates = [];
|
|
1697
|
+
for (const offer of Object.values(reservedOffers)) {
|
|
1698
|
+
const lease = offer.termAttributes?.LeaseContractLength;
|
|
1699
|
+
const purchase = offer.termAttributes?.PurchaseOption;
|
|
1700
|
+
const term = normalizeTerm(lease);
|
|
1701
|
+
const payment = normalizePayment(purchase);
|
|
1702
|
+
if (!term || !payment) continue;
|
|
1703
|
+
const effectiveHourly = computeEffectiveHourly(offer, term);
|
|
1704
|
+
if (effectiveHourly === null) continue;
|
|
1705
|
+
const rate = 1 - effectiveHourly / onDemandHourly;
|
|
1706
|
+
if (rate <= 0 || rate >= 0.9) continue;
|
|
1707
|
+
rates.push({ term, payment, rate });
|
|
1708
|
+
}
|
|
1709
|
+
return rates;
|
|
1710
|
+
}
|
|
1711
|
+
var AwsReservedClient = class {
|
|
1712
|
+
cache;
|
|
1713
|
+
constructor(cache) {
|
|
1714
|
+
this.cache = cache;
|
|
1715
|
+
}
|
|
1716
|
+
async getRiRates(service, instanceType, region, os = "Linux") {
|
|
1717
|
+
if (service === "AmazonEC2") {
|
|
1718
|
+
logger.debug(
|
|
1719
|
+
"AwsReservedClient: refusing AmazonEC2 (bulk JSON too large for in-memory parse)",
|
|
1720
|
+
{
|
|
1721
|
+
instanceType,
|
|
1722
|
+
region
|
|
1723
|
+
}
|
|
1724
|
+
);
|
|
1725
|
+
return null;
|
|
1726
|
+
}
|
|
1727
|
+
const cacheKey = `aws/ri/${service.toLowerCase()}/${region.toLowerCase()}/${instanceType.toLowerCase()}/${os.toLowerCase()}`;
|
|
1728
|
+
const cached = this.cache.get(cacheKey);
|
|
1729
|
+
if (cached && Array.isArray(cached.rates)) return cached.rates;
|
|
1730
|
+
const url = `${BULK_PRICING_BASE}/${service}/current/${region}/index.json`;
|
|
1731
|
+
logger.debug("Fetching AWS bulk pricing for RI rates", { url });
|
|
1732
|
+
try {
|
|
1733
|
+
const res = await fetchWithRetryAndCircuitBreaker(
|
|
1734
|
+
url,
|
|
1735
|
+
{ signal: AbortSignal.timeout(6e4) },
|
|
1736
|
+
{ maxRetries: 0 }
|
|
1737
|
+
);
|
|
1738
|
+
if (!res.ok) {
|
|
1739
|
+
logger.debug("AWS RI bulk fetch non-OK", { status: res.status });
|
|
1740
|
+
return null;
|
|
1741
|
+
}
|
|
1742
|
+
const bulk = await res.json();
|
|
1743
|
+
const rates = extractRiRatesFromBulk(bulk, instanceType, os);
|
|
1744
|
+
if (rates.length === 0) return null;
|
|
1745
|
+
const entry = {
|
|
1746
|
+
price_per_unit: 0,
|
|
1747
|
+
unit: "none",
|
|
1748
|
+
rates
|
|
1749
|
+
};
|
|
1750
|
+
this.cache.set(cacheKey, entry, "aws", "ri", region, CACHE_TTL);
|
|
1751
|
+
return rates;
|
|
1752
|
+
} catch (err) {
|
|
1753
|
+
logger.debug("AWS RI bulk fetch failed, will use static fallback", {
|
|
1754
|
+
instanceType,
|
|
1755
|
+
region,
|
|
1756
|
+
err: err instanceof Error ? err.message : String(err)
|
|
1757
|
+
});
|
|
1758
|
+
return null;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
};
|
|
1762
|
+
function firstHourlyPrice(terms) {
|
|
1763
|
+
if (!terms) return null;
|
|
1764
|
+
for (const term of Object.values(terms)) {
|
|
1765
|
+
for (const dim of Object.values(term.priceDimensions ?? {})) {
|
|
1766
|
+
if ((dim.unit ?? "").toLowerCase().startsWith("hr")) {
|
|
1767
|
+
const price = parseFloat(dim.pricePerUnit?.USD ?? "");
|
|
1768
|
+
if (isFinite(price) && price > 0) return price;
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
return null;
|
|
1773
|
+
}
|
|
1774
|
+
function computeEffectiveHourly(offer, term) {
|
|
1775
|
+
let upfront = 0;
|
|
1776
|
+
let hourly = 0;
|
|
1777
|
+
let sawAnything = false;
|
|
1778
|
+
for (const dim of Object.values(offer.priceDimensions ?? {})) {
|
|
1779
|
+
const unit = (dim.unit ?? "").toLowerCase();
|
|
1780
|
+
const value = parseFloat(dim.pricePerUnit?.USD ?? "");
|
|
1781
|
+
if (!isFinite(value)) continue;
|
|
1782
|
+
if (unit.startsWith("hr")) {
|
|
1783
|
+
hourly = value;
|
|
1784
|
+
sawAnything = true;
|
|
1785
|
+
} else if (unit === "quantity") {
|
|
1786
|
+
upfront = value;
|
|
1787
|
+
sawAnything = true;
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
if (!sawAnything) return null;
|
|
1791
|
+
return hourly + upfront / HOURS_IN_TERM[term];
|
|
1792
|
+
}
|
|
1793
|
+
function normalizeTerm(lease) {
|
|
1794
|
+
if (lease === "1yr") return "1yr";
|
|
1795
|
+
if (lease === "3yr") return "3yr";
|
|
1796
|
+
return null;
|
|
1797
|
+
}
|
|
1798
|
+
function normalizePayment(purchase) {
|
|
1799
|
+
switch (purchase) {
|
|
1800
|
+
case "No Upfront":
|
|
1801
|
+
return "no_upfront";
|
|
1802
|
+
case "All Upfront":
|
|
1803
|
+
return "all_upfront";
|
|
1804
|
+
case "Partial Upfront":
|
|
1805
|
+
return "partial_upfront";
|
|
1806
|
+
default:
|
|
1807
|
+
return null;
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1564
1811
|
// src/pricing/azure/azure-normalizer.ts
|
|
1565
1812
|
function normalizeAzureCompute(item) {
|
|
1566
1813
|
return {
|
|
@@ -1625,6 +1872,131 @@ function normalizeAzureStorage(item) {
|
|
|
1625
1872
|
};
|
|
1626
1873
|
}
|
|
1627
1874
|
|
|
1875
|
+
// src/pricing/azure/arm-regions.ts
|
|
1876
|
+
var ARM_REGION_MAP = {
|
|
1877
|
+
// Americas
|
|
1878
|
+
eastus: "eastus",
|
|
1879
|
+
eastus2: "eastus2",
|
|
1880
|
+
eastus3: "eastus3",
|
|
1881
|
+
southcentralus: "southcentralus",
|
|
1882
|
+
westus: "westus",
|
|
1883
|
+
westus2: "westus2",
|
|
1884
|
+
westus3: "westus3",
|
|
1885
|
+
centralus: "centralus",
|
|
1886
|
+
northcentralus: "northcentralus",
|
|
1887
|
+
westcentralus: "westcentralus",
|
|
1888
|
+
canadacentral: "canadacentral",
|
|
1889
|
+
canadaeast: "canadaeast",
|
|
1890
|
+
brazilsouth: "brazilsouth",
|
|
1891
|
+
brazilsoutheast: "brazilsoutheast",
|
|
1892
|
+
mexicocentral: "mexicocentral",
|
|
1893
|
+
// Europe
|
|
1894
|
+
northeurope: "northeurope",
|
|
1895
|
+
westeurope: "westeurope",
|
|
1896
|
+
uksouth: "uksouth",
|
|
1897
|
+
ukwest: "ukwest",
|
|
1898
|
+
francecentral: "francecentral",
|
|
1899
|
+
francesouth: "francesouth",
|
|
1900
|
+
germanywestcentral: "germanywestcentral",
|
|
1901
|
+
germanynorth: "germanynorth",
|
|
1902
|
+
switzerlandnorth: "switzerlandnorth",
|
|
1903
|
+
switzerlandwest: "switzerlandwest",
|
|
1904
|
+
norwayeast: "norwayeast",
|
|
1905
|
+
norwaywest: "norwaywest",
|
|
1906
|
+
swedencentral: "swedencentral",
|
|
1907
|
+
swedensouth: "swedensouth",
|
|
1908
|
+
polandcentral: "polandcentral",
|
|
1909
|
+
italynorth: "italynorth",
|
|
1910
|
+
spaincentral: "spaincentral",
|
|
1911
|
+
// Asia Pacific
|
|
1912
|
+
eastasia: "eastasia",
|
|
1913
|
+
southeastasia: "southeastasia",
|
|
1914
|
+
japaneast: "japaneast",
|
|
1915
|
+
japanwest: "japanwest",
|
|
1916
|
+
australiaeast: "australiaeast",
|
|
1917
|
+
australiasoutheast: "australiasoutheast",
|
|
1918
|
+
australiacentral: "australiacentral",
|
|
1919
|
+
australiacentral2: "australiacentral2",
|
|
1920
|
+
centralindia: "centralindia",
|
|
1921
|
+
southindia: "southindia",
|
|
1922
|
+
westindia: "westindia",
|
|
1923
|
+
jioindiawest: "jioindiawest",
|
|
1924
|
+
jioindiacentral: "jioindiacentral",
|
|
1925
|
+
koreacentral: "koreacentral",
|
|
1926
|
+
koreasouth: "koreasouth",
|
|
1927
|
+
// Middle East / Africa
|
|
1928
|
+
uaenorth: "uaenorth",
|
|
1929
|
+
uaecentral: "uaecentral",
|
|
1930
|
+
qatarcentral: "qatarcentral",
|
|
1931
|
+
israelcentral: "israelcentral",
|
|
1932
|
+
southafricanorth: "southafricanorth",
|
|
1933
|
+
southafricawest: "southafricawest"
|
|
1934
|
+
};
|
|
1935
|
+
var ALIASES = {
|
|
1936
|
+
// Americas
|
|
1937
|
+
eastus: "eastus",
|
|
1938
|
+
eastus2: "eastus2",
|
|
1939
|
+
eastus3: "eastus3",
|
|
1940
|
+
southcentralus: "southcentralus",
|
|
1941
|
+
westus: "westus",
|
|
1942
|
+
westus2: "westus2",
|
|
1943
|
+
westus3: "westus3",
|
|
1944
|
+
centralus: "centralus",
|
|
1945
|
+
northcentralus: "northcentralus",
|
|
1946
|
+
westcentralus: "westcentralus",
|
|
1947
|
+
canadacentral: "canadacentral",
|
|
1948
|
+
canadaeast: "canadaeast",
|
|
1949
|
+
brazilsouth: "brazilsouth",
|
|
1950
|
+
brazilsoutheast: "brazilsoutheast",
|
|
1951
|
+
mexicocentral: "mexicocentral",
|
|
1952
|
+
// Europe
|
|
1953
|
+
northeurope: "northeurope",
|
|
1954
|
+
westeurope: "westeurope",
|
|
1955
|
+
uksouth: "uksouth",
|
|
1956
|
+
ukwest: "ukwest",
|
|
1957
|
+
francecentral: "francecentral",
|
|
1958
|
+
francesouth: "francesouth",
|
|
1959
|
+
germanywestcentral: "germanywestcentral",
|
|
1960
|
+
germanynorth: "germanynorth",
|
|
1961
|
+
switzerlandnorth: "switzerlandnorth",
|
|
1962
|
+
switzerlandwest: "switzerlandwest",
|
|
1963
|
+
norwayeast: "norwayeast",
|
|
1964
|
+
norwaywest: "norwaywest",
|
|
1965
|
+
swedencentral: "swedencentral",
|
|
1966
|
+
swedensouth: "swedensouth",
|
|
1967
|
+
polandcentral: "polandcentral",
|
|
1968
|
+
italynorth: "italynorth",
|
|
1969
|
+
spaincentral: "spaincentral",
|
|
1970
|
+
// Asia Pacific
|
|
1971
|
+
eastasia: "eastasia",
|
|
1972
|
+
southeastasia: "southeastasia",
|
|
1973
|
+
japaneast: "japaneast",
|
|
1974
|
+
japanwest: "japanwest",
|
|
1975
|
+
australiaeast: "australiaeast",
|
|
1976
|
+
australiasoutheast: "australiasoutheast",
|
|
1977
|
+
australiacentral: "australiacentral",
|
|
1978
|
+
australiacentral2: "australiacentral2",
|
|
1979
|
+
centralindia: "centralindia",
|
|
1980
|
+
southindia: "southindia",
|
|
1981
|
+
westindia: "westindia",
|
|
1982
|
+
jioindiawest: "jioindiawest",
|
|
1983
|
+
jioindiacentral: "jioindiacentral",
|
|
1984
|
+
koreacentral: "koreacentral",
|
|
1985
|
+
koreasouth: "koreasouth",
|
|
1986
|
+
// Middle East / Africa
|
|
1987
|
+
uaenorth: "uaenorth",
|
|
1988
|
+
uaecentral: "uaecentral",
|
|
1989
|
+
qatarcentral: "qatarcentral",
|
|
1990
|
+
israelcentral: "israelcentral",
|
|
1991
|
+
southafricanorth: "southafricanorth",
|
|
1992
|
+
southafricawest: "southafricawest"
|
|
1993
|
+
};
|
|
1994
|
+
function toArmRegionName(region) {
|
|
1995
|
+
if (!region) return region;
|
|
1996
|
+
const key = region.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
1997
|
+
return ALIASES[key] ?? ARM_REGION_MAP[key] ?? key;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
1628
2000
|
// src/pricing/azure/fallback-data.ts
|
|
1629
2001
|
var VM_BASE_PRICES = {
|
|
1630
2002
|
standard_b1s: 0.0104,
|
|
@@ -1655,27 +2027,26 @@ var VM_BASE_PRICES = {
|
|
|
1655
2027
|
standard_e32s_v5: 2.016,
|
|
1656
2028
|
standard_e2ds_v5: 0.144,
|
|
1657
2029
|
standard_e4ds_v5: 0.288,
|
|
1658
|
-
standard_f2s_v2: 0.
|
|
1659
|
-
standard_f4s_v2: 0.
|
|
1660
|
-
standard_f8s_v2: 0.
|
|
1661
|
-
standard_f16s_v2: 0.
|
|
1662
|
-
standard_f32s_v2: 1.
|
|
1663
|
-
standard_l8s_v3: 0.
|
|
1664
|
-
standard_l16s_v3: 1.
|
|
2030
|
+
standard_f2s_v2: 0.0846,
|
|
2031
|
+
standard_f4s_v2: 0.169,
|
|
2032
|
+
standard_f8s_v2: 0.338,
|
|
2033
|
+
standard_f16s_v2: 0.677,
|
|
2034
|
+
standard_f32s_v2: 1.353,
|
|
2035
|
+
standard_l8s_v3: 0.696,
|
|
2036
|
+
standard_l16s_v3: 1.392,
|
|
1665
2037
|
standard_nc4as_t4_v3: 0.526,
|
|
1666
2038
|
standard_nc8as_t4_v3: 0.752,
|
|
1667
2039
|
standard_e2ps_v5: 0.101,
|
|
1668
2040
|
standard_e4ps_v5: 0.202,
|
|
1669
2041
|
standard_e8ps_v5: 0.403,
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
standard_e16s_v6: 1.008
|
|
2042
|
+
standard_d2s_v6: 0.101,
|
|
2043
|
+
standard_d4s_v6: 0.202,
|
|
2044
|
+
standard_d8s_v6: 0.403,
|
|
2045
|
+
standard_d16s_v6: 0.806,
|
|
2046
|
+
standard_e2s_v6: 0.132,
|
|
2047
|
+
standard_e4s_v6: 0.265,
|
|
2048
|
+
standard_e8s_v6: 0.529,
|
|
2049
|
+
standard_e16s_v6: 1.058
|
|
1679
2050
|
};
|
|
1680
2051
|
var DISK_BASE_PRICES = {
|
|
1681
2052
|
premium_lrs: 0.132,
|
|
@@ -1734,7 +2105,7 @@ var AzureRetailClient = class {
|
|
|
1734
2105
|
const cached = this.cache.get(cacheKey);
|
|
1735
2106
|
if (cached) return cached;
|
|
1736
2107
|
try {
|
|
1737
|
-
const armRegion = region
|
|
2108
|
+
const armRegion = toArmRegionName(region);
|
|
1738
2109
|
const filter = this.buildODataFilter("Virtual Machines", armRegion, vmSize, true);
|
|
1739
2110
|
const items = await this.queryPricing(filter);
|
|
1740
2111
|
const match = this.pickVmItem(items, vmSize, os);
|
|
@@ -1757,7 +2128,7 @@ var AzureRetailClient = class {
|
|
|
1757
2128
|
const cached = this.cache.get(cacheKey);
|
|
1758
2129
|
if (cached) return cached;
|
|
1759
2130
|
try {
|
|
1760
|
-
const armRegion = region
|
|
2131
|
+
const armRegion = toArmRegionName(region);
|
|
1761
2132
|
const serviceName = `Azure Database for ${engine}`;
|
|
1762
2133
|
const result = await this.queryDatabasePrice(tier, armRegion, serviceName);
|
|
1763
2134
|
if (result) {
|
|
@@ -1816,22 +2187,28 @@ var AzureRetailClient = class {
|
|
|
1816
2187
|
/**
|
|
1817
2188
|
* Parse a VM name like "Standard_D2s_v3" into series info.
|
|
1818
2189
|
* Returns { series: "Dsv3", vcpus: 2 } or null if unparseable.
|
|
2190
|
+
*
|
|
2191
|
+
* Real-world Azure SKUs have a lot of shape variation we have to handle:
|
|
2192
|
+
* Standard_D2s_v5 – 1-letter family, lower-case variant
|
|
2193
|
+
* Standard_M64ms_v2 – multi-digit vCPU count
|
|
2194
|
+
* Standard_DC4s_v3 – 2-letter family (confidential compute)
|
|
2195
|
+
* Standard_E96ias_v5 – longer (3-letter) variant
|
|
2196
|
+
* Standard_NC4as_T4_v3 – mid-name accelerator segment (T4)
|
|
2197
|
+
* Standard_Eb8as_v5 – single-letter "sub-family" prefix (b)
|
|
2198
|
+
*
|
|
2199
|
+
* The regex below accepts:
|
|
2200
|
+
* <FAMILY 1-2 letters><optional sub-family letter><vCPU digits>
|
|
2201
|
+
* <variant letters><optional _SEG segments like _T4>_v<version digits>
|
|
1819
2202
|
*/
|
|
1820
2203
|
parseVmSeries(vmName) {
|
|
1821
|
-
|
|
1822
|
-
if (!match) return null;
|
|
1823
|
-
const [, family, vcpuStr, variant, version] = match;
|
|
1824
|
-
const vcpus = parseInt(vcpuStr, 10);
|
|
1825
|
-
if (isNaN(vcpus) || vcpus === 0) return null;
|
|
1826
|
-
const series = `${family}${variant}v${version}`;
|
|
1827
|
-
return { series, vcpus };
|
|
2204
|
+
return parseAzureVmSeries(vmName);
|
|
1828
2205
|
}
|
|
1829
2206
|
async getStoragePrice(diskType, region) {
|
|
1830
2207
|
const cacheKey = this.buildCacheKey("disk", region, diskType);
|
|
1831
2208
|
const cached = this.cache.get(cacheKey);
|
|
1832
2209
|
if (cached) return cached;
|
|
1833
2210
|
try {
|
|
1834
|
-
const armRegion = region
|
|
2211
|
+
const armRegion = toArmRegionName(region);
|
|
1835
2212
|
const filter = this.buildODataFilter("Storage", armRegion, diskType);
|
|
1836
2213
|
const items = await this.queryPricing(filter);
|
|
1837
2214
|
if (items.length > 0) {
|
|
@@ -1903,6 +2280,112 @@ var AzureRetailClient = class {
|
|
|
1903
2280
|
effective_date: (/* @__PURE__ */ new Date()).toISOString()
|
|
1904
2281
|
};
|
|
1905
2282
|
}
|
|
2283
|
+
/**
|
|
2284
|
+
* Fetch the live Spot VM price for a given VM size/region/OS from the Azure
|
|
2285
|
+
* Retail Prices API. Spot VMs are exposed as distinct rows where the
|
|
2286
|
+
* `meterName` contains "Spot" (e.g. "D2s v5 Spot"). Returns `null` when no
|
|
2287
|
+
* spot row is found — callers should fall back to static discount factors.
|
|
2288
|
+
*
|
|
2289
|
+
* Cache key is namespaced under "vm-spot" so spot and on-demand entries
|
|
2290
|
+
* don't collide.
|
|
2291
|
+
*/
|
|
2292
|
+
async getSpotPrice(vmSize, region, os = "linux") {
|
|
2293
|
+
const cacheKey = this.buildCacheKey("vm-spot", region, vmSize, os);
|
|
2294
|
+
const cached = this.cache.get(cacheKey);
|
|
2295
|
+
if (cached) return cached;
|
|
2296
|
+
try {
|
|
2297
|
+
const armRegion = toArmRegionName(region);
|
|
2298
|
+
const filter = this.buildODataFilter("Virtual Machines", armRegion, vmSize, true);
|
|
2299
|
+
const items = await this.queryPricing(filter);
|
|
2300
|
+
const isWindows = os.toLowerCase().includes("windows");
|
|
2301
|
+
const spotItems = items.filter((i) => {
|
|
2302
|
+
const meter = (i.meterName ?? "").toLowerCase();
|
|
2303
|
+
const sku = (i.skuName ?? "").toLowerCase();
|
|
2304
|
+
const hasWindows = sku.includes("windows") || meter.includes("windows");
|
|
2305
|
+
const isSpot = meter.includes("spot") || sku.includes("spot");
|
|
2306
|
+
return isSpot && (isWindows && hasWindows || !isWindows && !hasWindows);
|
|
2307
|
+
});
|
|
2308
|
+
if (spotItems.length > 0) {
|
|
2309
|
+
spotItems.sort((a, b) => (a.skuId ?? "").localeCompare(b.skuId ?? ""));
|
|
2310
|
+
const result = normalizeAzureCompute(spotItems[0]);
|
|
2311
|
+
result.attributes.pricing_source = "live";
|
|
2312
|
+
result.attributes.purchase_option = "spot";
|
|
2313
|
+
this.cache.set(cacheKey, result, "azure", "vm-spot", region, CACHE_TTL2);
|
|
2314
|
+
return result;
|
|
2315
|
+
}
|
|
2316
|
+
} catch (err) {
|
|
2317
|
+
logger.warn("Azure Retail API Spot fetch failed", {
|
|
2318
|
+
region,
|
|
2319
|
+
vmSize,
|
|
2320
|
+
err: err instanceof Error ? err.message : String(err)
|
|
2321
|
+
});
|
|
2322
|
+
}
|
|
2323
|
+
return null;
|
|
2324
|
+
}
|
|
2325
|
+
/**
|
|
2326
|
+
* Fetch the live Reservation price for a VM size/region/term from the Azure
|
|
2327
|
+
* Retail Prices API. The API exposes reservations via `priceType eq
|
|
2328
|
+
* 'Reservation'`, with `reservationTerm` of "1 Year" or "3 Years". Returns
|
|
2329
|
+
* `null` when no reservation row matches — callers should fall back to
|
|
2330
|
+
* static discount factors.
|
|
2331
|
+
*
|
|
2332
|
+
* The returned NormalizedPrice uses the reservation's `unitOfMeasure`
|
|
2333
|
+
* (typically "1 Hour" after Azure's per-hour amortisation but sometimes
|
|
2334
|
+
* "1/Year" for upfront payment). The `retailPrice` is the total reservation
|
|
2335
|
+
* price as returned — callers are expected to convert to an hourly rate
|
|
2336
|
+
* themselves using the unit.
|
|
2337
|
+
*/
|
|
2338
|
+
async getReservationPrice(vmSize, region, term) {
|
|
2339
|
+
const cacheKey = this.buildCacheKey("vm-ri", region, vmSize, term);
|
|
2340
|
+
const cached = this.cache.get(cacheKey);
|
|
2341
|
+
if (cached) return cached;
|
|
2342
|
+
try {
|
|
2343
|
+
const armRegion = toArmRegionName(region);
|
|
2344
|
+
const termLabel = term === "1yr" ? "1 Year" : "3 Years";
|
|
2345
|
+
const filter = `serviceName eq 'Virtual Machines' and armRegionName eq '${armRegion}' and priceType eq 'Reservation' and armSkuName eq '${vmSize}'`;
|
|
2346
|
+
const items = await this.queryPricing(filter);
|
|
2347
|
+
const matches = items.filter((i) => (i.reservationTerm ?? "") === termLabel);
|
|
2348
|
+
if (matches.length > 0) {
|
|
2349
|
+
matches.sort((a, b) => (a.skuId ?? "").localeCompare(b.skuId ?? ""));
|
|
2350
|
+
const result = normalizeAzureCompute(matches[0]);
|
|
2351
|
+
result.attributes.pricing_source = "live";
|
|
2352
|
+
result.attributes.purchase_option = "reservation";
|
|
2353
|
+
result.attributes.reservation_term = termLabel;
|
|
2354
|
+
this.cache.set(cacheKey, result, "azure", "vm-ri", region, CACHE_TTL2);
|
|
2355
|
+
return result;
|
|
2356
|
+
}
|
|
2357
|
+
} catch (err) {
|
|
2358
|
+
logger.warn("Azure Retail API Reservation fetch failed", {
|
|
2359
|
+
region,
|
|
2360
|
+
vmSize,
|
|
2361
|
+
term,
|
|
2362
|
+
err: err instanceof Error ? err.message : String(err)
|
|
2363
|
+
});
|
|
2364
|
+
}
|
|
2365
|
+
return null;
|
|
2366
|
+
}
|
|
2367
|
+
/**
|
|
2368
|
+
* Convenience helper: given a VM size and region, return the effective
|
|
2369
|
+
* *hourly* reservation rate (amortised). Returns `null` when the live API
|
|
2370
|
+
* has no matching reservation. This wraps `getReservationPrice` and
|
|
2371
|
+
* converts "1/Year" / "1/3 Years" billed units into an hourly equivalent
|
|
2372
|
+
* using a 730h/month * 12 / 36 month assumption.
|
|
2373
|
+
*/
|
|
2374
|
+
async getReservationHourlyRate(vmSize, region, term) {
|
|
2375
|
+
const price = await this.getReservationPrice(vmSize, region, term);
|
|
2376
|
+
if (!price) return null;
|
|
2377
|
+
const unit = (price.unit ?? "").toLowerCase();
|
|
2378
|
+
const raw = price.price_per_unit;
|
|
2379
|
+
if (!isFinite(raw) || raw <= 0) return null;
|
|
2380
|
+
if (unit.includes("hour")) {
|
|
2381
|
+
return raw;
|
|
2382
|
+
}
|
|
2383
|
+
if (unit.includes("year")) {
|
|
2384
|
+
const years = term === "1yr" ? 1 : 3;
|
|
2385
|
+
return raw / (8760 * years);
|
|
2386
|
+
}
|
|
2387
|
+
return raw;
|
|
2388
|
+
}
|
|
1906
2389
|
// -------------------------------------------------------------------------
|
|
1907
2390
|
// Internal helpers – live API
|
|
1908
2391
|
// -------------------------------------------------------------------------
|
|
@@ -1927,8 +2410,8 @@ var AzureRetailClient = class {
|
|
|
1927
2410
|
}
|
|
1928
2411
|
return allItems;
|
|
1929
2412
|
}
|
|
1930
|
-
buildODataFilter(service, armRegion, skuName, exactArmSku) {
|
|
1931
|
-
let filter = `serviceName eq '${service}' and armRegionName eq '${armRegion}' and priceType eq '
|
|
2413
|
+
buildODataFilter(service, armRegion, skuName, exactArmSku, priceType = "Consumption") {
|
|
2414
|
+
let filter = `serviceName eq '${service}' and armRegionName eq '${armRegion}' and priceType eq '${priceType}'`;
|
|
1932
2415
|
if (skuName) {
|
|
1933
2416
|
if (exactArmSku) {
|
|
1934
2417
|
filter += ` and armSkuName eq '${skuName}'`;
|
|
@@ -2072,6 +2555,18 @@ var AzureRetailClient = class {
|
|
|
2072
2555
|
return result;
|
|
2073
2556
|
}
|
|
2074
2557
|
};
|
|
2558
|
+
function parseAzureVmSeries(vmName) {
|
|
2559
|
+
const match = vmName.match(
|
|
2560
|
+
/^Standard_([A-Z]{1,2})([a-z]?)(\d+)([a-z]*)((?:_[A-Za-z0-9]+)*)_v(\d+)$/i
|
|
2561
|
+
);
|
|
2562
|
+
if (!match) return null;
|
|
2563
|
+
const [, family, subFamily, vcpuStr, variant, extras, version] = match;
|
|
2564
|
+
const vcpus = parseInt(vcpuStr, 10);
|
|
2565
|
+
if (isNaN(vcpus) || vcpus === 0) return null;
|
|
2566
|
+
const extrasJoined = (extras ?? "").replace(/_/g, "");
|
|
2567
|
+
const series = `${family}${subFamily}${variant}${extrasJoined}v${version}`;
|
|
2568
|
+
return { series, vcpus };
|
|
2569
|
+
}
|
|
2075
2570
|
|
|
2076
2571
|
// src/pricing/gcp/gcp-normalizer.ts
|
|
2077
2572
|
function normalizeGcpCompute(machineType, hourlyPrice, region) {
|
|
@@ -2344,7 +2839,9 @@ var BILLING_API_BASE = "https://cloudbilling.googleapis.com/v1/services";
|
|
|
2344
2839
|
var SERVICE_IDS = {
|
|
2345
2840
|
COMPUTE: "6F81-5844-456A",
|
|
2346
2841
|
CLOUD_SQL: "9662-B51E-5089",
|
|
2347
|
-
CLOUD_STORAGE: "95FF-2EF5-5EA1"
|
|
2842
|
+
CLOUD_STORAGE: "95FF-2EF5-5EA1",
|
|
2843
|
+
// GKE (Kubernetes Engine) service — used to locate Autopilot pod SKUs.
|
|
2844
|
+
GKE: "CCD8-9BF1-090E"
|
|
2348
2845
|
};
|
|
2349
2846
|
var CACHE_TTL3 = 86400;
|
|
2350
2847
|
function protoToFloat(units, nanos) {
|
|
@@ -2450,6 +2947,141 @@ var CloudBillingClient = class {
|
|
|
2450
2947
|
}
|
|
2451
2948
|
return null;
|
|
2452
2949
|
}
|
|
2950
|
+
/**
|
|
2951
|
+
* Fetch Compute Engine GPU (accelerator) SKUs and return the hourly price
|
|
2952
|
+
* for the given accelerator type (e.g. "nvidia-tesla-t4", "nvidia-a100").
|
|
2953
|
+
*
|
|
2954
|
+
* GPUs live under the Compute service (6F81-5844-456A) with
|
|
2955
|
+
* `category.resourceFamily == "Compute"` and
|
|
2956
|
+
* `category.resourceGroup == "GPU"`. Descriptions follow the pattern
|
|
2957
|
+
* "Nvidia Tesla T4 GPU running in Americas".
|
|
2958
|
+
*/
|
|
2959
|
+
async fetchGpuSkus(accelerator, region) {
|
|
2960
|
+
const cacheKey = `gcp/gpu/${region}/${accelerator.toLowerCase()}`;
|
|
2961
|
+
const cached = this.cache.get(cacheKey);
|
|
2962
|
+
if (cached) return cached;
|
|
2963
|
+
try {
|
|
2964
|
+
const skus = await this.fetchSkus(SERVICE_IDS.COMPUTE, region);
|
|
2965
|
+
const result = this.extractGpuPrice(skus, accelerator, region);
|
|
2966
|
+
if (result) {
|
|
2967
|
+
this.cache.set(cacheKey, result, "gcp", "compute-gpu", region, CACHE_TTL3);
|
|
2968
|
+
return result;
|
|
2969
|
+
}
|
|
2970
|
+
} catch (err) {
|
|
2971
|
+
logger.warn("GCP Cloud Billing GPU fetch failed", {
|
|
2972
|
+
accelerator,
|
|
2973
|
+
region,
|
|
2974
|
+
err: err instanceof Error ? err.message : String(err)
|
|
2975
|
+
});
|
|
2976
|
+
}
|
|
2977
|
+
return null;
|
|
2978
|
+
}
|
|
2979
|
+
/**
|
|
2980
|
+
* Fetch Committed Use Discount (CUD) rates for the Compute service in a
|
|
2981
|
+
* region. Returns discount fractions (0..1) against OnDemand base rates,
|
|
2982
|
+
* derived by comparing matching Commit1Yr / Commit3Yr SKUs to their
|
|
2983
|
+
* OnDemand counterparts for the same resource group (e.g. "N1Standard").
|
|
2984
|
+
*
|
|
2985
|
+
* If the API call fails or no matching SKUs are found, returns null and
|
|
2986
|
+
* callers should fall back to static rates.
|
|
2987
|
+
*/
|
|
2988
|
+
async fetchCudRates(region) {
|
|
2989
|
+
const cacheKey = `gcp/cud/${region}`;
|
|
2990
|
+
const cached = this.cache.get(cacheKey);
|
|
2991
|
+
if (cached) return cached;
|
|
2992
|
+
try {
|
|
2993
|
+
const skus = await this.fetchSkus(SERVICE_IDS.COMPUTE, region);
|
|
2994
|
+
const onDemand = /* @__PURE__ */ new Map();
|
|
2995
|
+
const commit1 = /* @__PURE__ */ new Map();
|
|
2996
|
+
const commit3 = /* @__PURE__ */ new Map();
|
|
2997
|
+
const keyOf = (sku) => {
|
|
2998
|
+
const group = sku.category.resourceGroup ?? "";
|
|
2999
|
+
if (!group) return null;
|
|
3000
|
+
const desc = sku.description.toLowerCase();
|
|
3001
|
+
let flavour;
|
|
3002
|
+
if (desc.includes("core") || desc.includes("vcpu")) {
|
|
3003
|
+
flavour = "core";
|
|
3004
|
+
} else if (desc.includes("ram") || desc.includes("memory")) {
|
|
3005
|
+
flavour = "ram";
|
|
3006
|
+
} else {
|
|
3007
|
+
return null;
|
|
3008
|
+
}
|
|
3009
|
+
return `${group}|${flavour}`;
|
|
3010
|
+
};
|
|
3011
|
+
for (const sku of skus) {
|
|
3012
|
+
if (sku.category.resourceFamily !== "Compute") continue;
|
|
3013
|
+
const price = extractUnitPrice(sku);
|
|
3014
|
+
if (price === null) continue;
|
|
3015
|
+
const key = keyOf(sku);
|
|
3016
|
+
if (!key) continue;
|
|
3017
|
+
const usage = sku.category.usageType;
|
|
3018
|
+
if (usage === "OnDemand") {
|
|
3019
|
+
const existing = onDemand.get(key);
|
|
3020
|
+
if (existing === void 0 || price < existing) onDemand.set(key, price);
|
|
3021
|
+
} else if (usage === "Commit1Yr") {
|
|
3022
|
+
const existing = commit1.get(key);
|
|
3023
|
+
if (existing === void 0 || price < existing) commit1.set(key, price);
|
|
3024
|
+
} else if (usage === "Commit3Yr") {
|
|
3025
|
+
const existing = commit3.get(key);
|
|
3026
|
+
if (existing === void 0 || price < existing) commit3.set(key, price);
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
const averageDiscount = (commits) => {
|
|
3030
|
+
const ratios = [];
|
|
3031
|
+
for (const [key, commitPrice] of commits) {
|
|
3032
|
+
const base = onDemand.get(key);
|
|
3033
|
+
if (base === void 0 || base <= 0) continue;
|
|
3034
|
+
const discount = 1 - commitPrice / base;
|
|
3035
|
+
if (discount > 0 && discount < 1) ratios.push(discount);
|
|
3036
|
+
}
|
|
3037
|
+
if (ratios.length === 0) return null;
|
|
3038
|
+
const sum = ratios.reduce((a, b) => a + b, 0);
|
|
3039
|
+
return sum / ratios.length;
|
|
3040
|
+
};
|
|
3041
|
+
const result = {
|
|
3042
|
+
term1yr: averageDiscount(commit1),
|
|
3043
|
+
term3yr: averageDiscount(commit3),
|
|
3044
|
+
sample_count: onDemand.size
|
|
3045
|
+
};
|
|
3046
|
+
if (result.term1yr === null && result.term3yr === null) {
|
|
3047
|
+
return null;
|
|
3048
|
+
}
|
|
3049
|
+
this.cache.set(cacheKey, result, "gcp", "compute-cud", region, CACHE_TTL3);
|
|
3050
|
+
return result;
|
|
3051
|
+
} catch (err) {
|
|
3052
|
+
logger.warn("GCP Cloud Billing CUD fetch failed", {
|
|
3053
|
+
region,
|
|
3054
|
+
err: err instanceof Error ? err.message : String(err)
|
|
3055
|
+
});
|
|
3056
|
+
return null;
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
/**
|
|
3060
|
+
* Fetch GKE Autopilot pod resource rates (per-vCPU-hour, per-GB-memory-hour,
|
|
3061
|
+
* per-GB-ephemeral-storage-hour) from the GKE service catalog. Returns a
|
|
3062
|
+
* NormalizedPrice whose `price_per_unit` is the vCPU rate and whose
|
|
3063
|
+
* `attributes` carry the memory/storage rates as strings — callers that
|
|
3064
|
+
* know a pod's resource request can compute an exact per-pod price.
|
|
3065
|
+
*/
|
|
3066
|
+
async fetchAutopilotPodRates(region) {
|
|
3067
|
+
const cacheKey = `gcp/gke-autopilot/${region}`;
|
|
3068
|
+
const cached = this.cache.get(cacheKey);
|
|
3069
|
+
if (cached) return cached;
|
|
3070
|
+
try {
|
|
3071
|
+
const skus = await this.fetchSkus(SERVICE_IDS.GKE, region);
|
|
3072
|
+
const result = this.extractAutopilotPodRates(skus, region);
|
|
3073
|
+
if (result) {
|
|
3074
|
+
this.cache.set(cacheKey, result, "gcp", "gke-autopilot", region, CACHE_TTL3);
|
|
3075
|
+
return result;
|
|
3076
|
+
}
|
|
3077
|
+
} catch (err) {
|
|
3078
|
+
logger.warn("GCP Cloud Billing GKE Autopilot fetch failed", {
|
|
3079
|
+
region,
|
|
3080
|
+
err: err instanceof Error ? err.message : String(err)
|
|
3081
|
+
});
|
|
3082
|
+
}
|
|
3083
|
+
return null;
|
|
3084
|
+
}
|
|
2453
3085
|
// -------------------------------------------------------------------------
|
|
2454
3086
|
// Internal – API fetch
|
|
2455
3087
|
// -------------------------------------------------------------------------
|
|
@@ -2467,10 +3099,14 @@ var CloudBillingClient = class {
|
|
|
2467
3099
|
do {
|
|
2468
3100
|
const pageUrl = pageToken ? `${url}?pageToken=${pageToken}` : url;
|
|
2469
3101
|
logger.debug("GCP Cloud Billing API request", { url: pageUrl, serviceId, region });
|
|
2470
|
-
const res = await
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
3102
|
+
const res = await fetchWithRetryAndCircuitBreaker(
|
|
3103
|
+
pageUrl,
|
|
3104
|
+
{
|
|
3105
|
+
signal: AbortSignal.timeout(3e4),
|
|
3106
|
+
headers: { Accept: "application/json" }
|
|
3107
|
+
},
|
|
3108
|
+
{ maxRetries: 2 }
|
|
3109
|
+
);
|
|
2474
3110
|
if (!res.ok) {
|
|
2475
3111
|
throw new Error(`GCP Billing API HTTP ${res.status} for service ${serviceId}`);
|
|
2476
3112
|
}
|
|
@@ -2614,13 +3250,124 @@ var CloudBillingClient = class {
|
|
|
2614
3250
|
effective_date: sku.pricingInfo?.[0]?.effectiveTime ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
2615
3251
|
};
|
|
2616
3252
|
}
|
|
3253
|
+
/**
|
|
3254
|
+
* Extract a GPU hourly price for the requested accelerator type.
|
|
3255
|
+
* Matches SKUs with resourceGroup == "GPU" whose description contains the
|
|
3256
|
+
* human-readable model name (e.g. "Tesla T4", "A100 40GB").
|
|
3257
|
+
*/
|
|
3258
|
+
extractGpuPrice(skus, accelerator, region) {
|
|
3259
|
+
const lower = accelerator.toLowerCase();
|
|
3260
|
+
const stripped = lower.replace(/^nvidia-?/, "");
|
|
3261
|
+
const tokens = stripped.split(/[-\s]+/).filter((t) => t.length > 0);
|
|
3262
|
+
if (tokens.length === 0) return null;
|
|
3263
|
+
const candidates = skus.filter((s) => {
|
|
3264
|
+
if (s.category.resourceFamily !== "Compute") return false;
|
|
3265
|
+
if (s.category.resourceGroup !== "GPU") return false;
|
|
3266
|
+
if (s.category.usageType !== "OnDemand") return false;
|
|
3267
|
+
const desc = s.description.toLowerCase();
|
|
3268
|
+
return tokens.every((tok) => desc.includes(tok));
|
|
3269
|
+
});
|
|
3270
|
+
if (candidates.length === 0) return null;
|
|
3271
|
+
candidates.sort((a, b) => a.skuId.localeCompare(b.skuId));
|
|
3272
|
+
const sku = candidates[0];
|
|
3273
|
+
const price = extractUnitPrice(sku);
|
|
3274
|
+
if (price === null) return null;
|
|
3275
|
+
return {
|
|
3276
|
+
provider: "gcp",
|
|
3277
|
+
service: "compute-engine",
|
|
3278
|
+
resource_type: accelerator,
|
|
3279
|
+
region,
|
|
3280
|
+
unit: "h",
|
|
3281
|
+
price_per_unit: price,
|
|
3282
|
+
currency: "USD",
|
|
3283
|
+
description: `GCP GPU ${accelerator} (live: ${sku.description})`,
|
|
3284
|
+
attributes: {
|
|
3285
|
+
accelerator,
|
|
3286
|
+
sku_id: sku.skuId,
|
|
3287
|
+
pricing_source: "live"
|
|
3288
|
+
},
|
|
3289
|
+
effective_date: sku.pricingInfo?.[0]?.effectiveTime ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
3290
|
+
};
|
|
3291
|
+
}
|
|
3292
|
+
/**
|
|
3293
|
+
* Extract GKE Autopilot pod resource rates. Autopilot exposes separate
|
|
3294
|
+
* SKUs per resource dimension (vCPU, memory, ephemeral storage) under the
|
|
3295
|
+
* GKE service. We look for SKUs whose description contains "Autopilot"
|
|
3296
|
+
* and the matching dimension keyword.
|
|
3297
|
+
*/
|
|
3298
|
+
extractAutopilotPodRates(skus, region) {
|
|
3299
|
+
const autopilotSkus = skus.filter((s) => {
|
|
3300
|
+
const desc = s.description.toLowerCase();
|
|
3301
|
+
return s.category.usageType === "OnDemand" && desc.includes("autopilot") && !desc.includes("spot") && !desc.includes("preemptible");
|
|
3302
|
+
});
|
|
3303
|
+
if (autopilotSkus.length === 0) return null;
|
|
3304
|
+
const find = (keywords) => {
|
|
3305
|
+
const matches = autopilotSkus.filter((s) => {
|
|
3306
|
+
const desc = s.description.toLowerCase();
|
|
3307
|
+
return keywords.every((kw) => desc.includes(kw));
|
|
3308
|
+
});
|
|
3309
|
+
if (matches.length === 0) return void 0;
|
|
3310
|
+
matches.sort((a, b) => a.skuId.localeCompare(b.skuId));
|
|
3311
|
+
return matches[0];
|
|
3312
|
+
};
|
|
3313
|
+
const vcpuSku = find(["cpu"]) ?? find(["core"]) ?? find(["vcpu"]);
|
|
3314
|
+
const memorySku = find(["memory"]) ?? find(["ram"]);
|
|
3315
|
+
const storageSku = find(["ephemeral", "storage"]) ?? find(["storage"]);
|
|
3316
|
+
if (!vcpuSku) return null;
|
|
3317
|
+
const vcpuPrice = extractUnitPrice(vcpuSku);
|
|
3318
|
+
if (vcpuPrice === null) return null;
|
|
3319
|
+
const memoryPrice = memorySku ? extractUnitPrice(memorySku) : null;
|
|
3320
|
+
const storagePrice = storageSku ? extractUnitPrice(storageSku) : null;
|
|
3321
|
+
const attributes = {
|
|
3322
|
+
mode: "autopilot",
|
|
3323
|
+
pricing_model: "per_pod_resource",
|
|
3324
|
+
pricing_source: "live",
|
|
3325
|
+
vcpu_hourly: String(vcpuPrice),
|
|
3326
|
+
sku_id_vcpu: vcpuSku.skuId,
|
|
3327
|
+
pricing_note: "Autopilot charges per pod: vCPU, memory, and ephemeral storage are billed separately. price_per_unit reports the vCPU/hr rate; see attributes for memory/storage rates."
|
|
3328
|
+
};
|
|
3329
|
+
if (memoryPrice !== null && memorySku) {
|
|
3330
|
+
attributes.memory_gb_hourly = String(memoryPrice);
|
|
3331
|
+
attributes.sku_id_memory = memorySku.skuId;
|
|
3332
|
+
}
|
|
3333
|
+
if (storagePrice !== null && storageSku) {
|
|
3334
|
+
attributes.ephemeral_storage_gb_hourly = String(storagePrice);
|
|
3335
|
+
attributes.sku_id_storage = storageSku.skuId;
|
|
3336
|
+
}
|
|
3337
|
+
return {
|
|
3338
|
+
provider: "gcp",
|
|
3339
|
+
service: "gke",
|
|
3340
|
+
resource_type: "autopilot-pod",
|
|
3341
|
+
region,
|
|
3342
|
+
unit: "h",
|
|
3343
|
+
price_per_unit: vcpuPrice,
|
|
3344
|
+
currency: "USD",
|
|
3345
|
+
description: `GCP GKE Autopilot pod resources (live: ${vcpuSku.description})`,
|
|
3346
|
+
attributes,
|
|
3347
|
+
effective_date: vcpuSku.pricingInfo?.[0]?.effectiveTime ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
3348
|
+
};
|
|
3349
|
+
}
|
|
2617
3350
|
};
|
|
2618
3351
|
|
|
2619
3352
|
// src/pricing/pricing-engine.ts
|
|
2620
3353
|
var AwsProvider = class {
|
|
2621
3354
|
loader;
|
|
3355
|
+
spotClient;
|
|
3356
|
+
reservedClient;
|
|
2622
3357
|
constructor(cache) {
|
|
2623
3358
|
this.loader = new AwsBulkLoader(cache);
|
|
3359
|
+
this.spotClient = new AwsSpotClient(cache);
|
|
3360
|
+
this.reservedClient = new AwsReservedClient(cache);
|
|
3361
|
+
}
|
|
3362
|
+
getSpotFactor(instanceType, region, os) {
|
|
3363
|
+
return this.spotClient.getSpotFactor(
|
|
3364
|
+
instanceType,
|
|
3365
|
+
region,
|
|
3366
|
+
os === "Windows" ? "Windows" : "Linux"
|
|
3367
|
+
);
|
|
3368
|
+
}
|
|
3369
|
+
getReservedRates(_instanceType, _region, _os) {
|
|
3370
|
+
return Promise.resolve(null);
|
|
2624
3371
|
}
|
|
2625
3372
|
getComputePrice(instanceType, region, os) {
|
|
2626
3373
|
return this.loader.getComputePrice(instanceType, region, os);
|
|
@@ -2664,6 +3411,24 @@ var AzureProvider = class {
|
|
|
2664
3411
|
getKubernetesPrice(region) {
|
|
2665
3412
|
return this.client.getKubernetesPrice(region);
|
|
2666
3413
|
}
|
|
3414
|
+
/**
|
|
3415
|
+
* Azure-specific: fetch live Spot VM pricing from the Retail API. Exposed
|
|
3416
|
+
* on the provider so the compute calculator can prefer live spot rows
|
|
3417
|
+
* over static discount factors. Returns null when the live API has no
|
|
3418
|
+
* matching spot row. Not part of the base `PricingProvider` interface
|
|
3419
|
+
* because AWS/GCP use different spot channels.
|
|
3420
|
+
*/
|
|
3421
|
+
getSpotPrice(vmSize, region, os) {
|
|
3422
|
+
return this.client.getSpotPrice(vmSize, region, os);
|
|
3423
|
+
}
|
|
3424
|
+
/**
|
|
3425
|
+
* Azure-specific: fetch the live Reservation VM hourly rate (1yr / 3yr)
|
|
3426
|
+
* from the Retail API. Returns null when no reservation row matches,
|
|
3427
|
+
* signalling callers to fall back to the static DISCOUNT_RATES table.
|
|
3428
|
+
*/
|
|
3429
|
+
getReservationHourlyRate(vmSize, region, term) {
|
|
3430
|
+
return this.client.getReservationHourlyRate(vmSize, region, term);
|
|
3431
|
+
}
|
|
2667
3432
|
};
|
|
2668
3433
|
var GcpProvider = class {
|
|
2669
3434
|
loader;
|
|
@@ -2789,13 +3554,27 @@ var PricingEngine = class {
|
|
|
2789
3554
|
};
|
|
2790
3555
|
|
|
2791
3556
|
// src/tools/analyze-terraform.ts
|
|
2792
|
-
import { z } from "zod";
|
|
3557
|
+
import { z as z2 } from "zod";
|
|
2793
3558
|
|
|
2794
3559
|
// src/parsers/index.ts
|
|
2795
3560
|
import { dirname as dirname2 } from "path";
|
|
2796
3561
|
|
|
2797
3562
|
// src/parsers/hcl-parser.ts
|
|
2798
3563
|
import { parse } from "@cdktf/hcl2json";
|
|
3564
|
+
|
|
3565
|
+
// src/util/sanitize.ts
|
|
3566
|
+
function sanitizeForMessage(input, maxLen = 256) {
|
|
3567
|
+
const s = typeof input === "string" ? input : String(input);
|
|
3568
|
+
const noControl = s.replace(/[\x00-\x1F\x7F]/g, "");
|
|
3569
|
+
const noInvisible = noControl.replace(
|
|
3570
|
+
/[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u206F\uFEFF]/g,
|
|
3571
|
+
""
|
|
3572
|
+
);
|
|
3573
|
+
if (noInvisible.length <= maxLen) return noInvisible;
|
|
3574
|
+
return `${noInvisible.slice(0, maxLen)}\u2026`;
|
|
3575
|
+
}
|
|
3576
|
+
|
|
3577
|
+
// src/parsers/hcl-parser.ts
|
|
2799
3578
|
async function parseHclToJson(hclContent, filename = "main.tf") {
|
|
2800
3579
|
if (!hclContent.trim()) {
|
|
2801
3580
|
logger.debug("Received empty HCL content, returning empty object", {
|
|
@@ -2809,8 +3588,10 @@ async function parseHclToJson(hclContent, filename = "main.tf") {
|
|
|
2809
3588
|
return result;
|
|
2810
3589
|
} catch (err) {
|
|
2811
3590
|
const message = err instanceof Error ? err.message : String(err);
|
|
2812
|
-
|
|
2813
|
-
|
|
3591
|
+
const safeFilename = sanitizeForMessage(filename, 256);
|
|
3592
|
+
const safeMessage = sanitizeForMessage(message, 512);
|
|
3593
|
+
logger.error("Failed to parse HCL", { filename: safeFilename, error: safeMessage });
|
|
3594
|
+
throw new Error(`HCL parse error in "${safeFilename}": ${safeMessage}`);
|
|
2814
3595
|
}
|
|
2815
3596
|
}
|
|
2816
3597
|
|
|
@@ -3638,8 +4419,43 @@ function extractResources(hclJson, variables, sourceFile, defaultRegions = {}, w
|
|
|
3638
4419
|
|
|
3639
4420
|
// src/parsers/module-resolver.ts
|
|
3640
4421
|
import { readdirSync, readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
|
|
3641
|
-
import { join as join2, resolve } from "path";
|
|
4422
|
+
import { join as join2, resolve as resolve2 } from "path";
|
|
4423
|
+
|
|
4424
|
+
// src/parsers/path-safety.ts
|
|
4425
|
+
import { lstatSync, realpathSync } from "fs";
|
|
4426
|
+
import { resolve, sep } from "path";
|
|
4427
|
+
function resolveWithinBoundary(candidate, basePath, rootBoundary) {
|
|
4428
|
+
const resolved = resolve(basePath, candidate);
|
|
4429
|
+
const boundary = resolve(rootBoundary);
|
|
4430
|
+
if (!isWithin(resolved, boundary)) return null;
|
|
4431
|
+
try {
|
|
4432
|
+
const real = realpathSync(resolved);
|
|
4433
|
+
if (!isWithin(real, boundary)) return null;
|
|
4434
|
+
const stat = lstatSync(resolved);
|
|
4435
|
+
if (stat.isSymbolicLink()) return null;
|
|
4436
|
+
return real;
|
|
4437
|
+
} catch {
|
|
4438
|
+
return resolved;
|
|
4439
|
+
}
|
|
4440
|
+
}
|
|
4441
|
+
function isWithin(child, parent) {
|
|
4442
|
+
if (child === parent) return true;
|
|
4443
|
+
const parentWithSep = parent.endsWith(sep) ? parent : parent + sep;
|
|
4444
|
+
return child.startsWith(parentWithSep);
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4447
|
+
// src/parsers/safe-json.ts
|
|
4448
|
+
var FORBIDDEN_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
4449
|
+
function safeJsonParse(text) {
|
|
4450
|
+
return JSON.parse(text, (key, value) => {
|
|
4451
|
+
if (FORBIDDEN_KEYS.has(key)) return void 0;
|
|
4452
|
+
return value;
|
|
4453
|
+
});
|
|
4454
|
+
}
|
|
4455
|
+
|
|
4456
|
+
// src/parsers/module-resolver.ts
|
|
3642
4457
|
var MAX_MODULE_DEPTH = 10;
|
|
4458
|
+
var FORBIDDEN_MERGE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
3643
4459
|
function readTfFiles(dirPath) {
|
|
3644
4460
|
if (!existsSync2(dirPath)) return [];
|
|
3645
4461
|
let entries;
|
|
@@ -3664,6 +4480,7 @@ function mergeHclJsons(jsons) {
|
|
|
3664
4480
|
const merged = {};
|
|
3665
4481
|
for (const json of jsons) {
|
|
3666
4482
|
for (const [key, value] of Object.entries(json)) {
|
|
4483
|
+
if (FORBIDDEN_MERGE_KEYS.has(key)) continue;
|
|
3667
4484
|
if (!(key in merged)) {
|
|
3668
4485
|
merged[key] = JSON.parse(JSON.stringify(value));
|
|
3669
4486
|
continue;
|
|
@@ -3684,8 +4501,13 @@ function mergeHclJsons(jsons) {
|
|
|
3684
4501
|
return merged;
|
|
3685
4502
|
}
|
|
3686
4503
|
function mergeObjects(a, b) {
|
|
3687
|
-
const result = {
|
|
4504
|
+
const result = {};
|
|
4505
|
+
for (const [key, aVal] of Object.entries(a)) {
|
|
4506
|
+
if (FORBIDDEN_MERGE_KEYS.has(key)) continue;
|
|
4507
|
+
result[key] = aVal;
|
|
4508
|
+
}
|
|
3688
4509
|
for (const [key, bVal] of Object.entries(b)) {
|
|
4510
|
+
if (FORBIDDEN_MERGE_KEYS.has(key)) continue;
|
|
3689
4511
|
const aVal = result[key];
|
|
3690
4512
|
if (aVal !== null && typeof aVal === "object" && !Array.isArray(aVal) && bVal !== null && typeof bVal === "object" && !Array.isArray(bVal)) {
|
|
3691
4513
|
result[key] = mergeObjects(aVal, bVal);
|
|
@@ -3727,10 +4549,10 @@ function prefixResources(resources, moduleName) {
|
|
|
3727
4549
|
id: `module.${moduleName}.${r.id}`
|
|
3728
4550
|
}));
|
|
3729
4551
|
}
|
|
3730
|
-
async function parseModuleDirectory(dirPath, parentVars, moduleInputs, warnings, depth) {
|
|
4552
|
+
async function parseModuleDirectory(dirPath, parentVars, moduleInputs, warnings, depth, rootBoundary) {
|
|
3731
4553
|
if (depth >= MAX_MODULE_DEPTH) {
|
|
3732
4554
|
warnings.push(
|
|
3733
|
-
`Module nesting depth limit (${MAX_MODULE_DEPTH}) reached at "${dirPath}". Skipping further expansion.`
|
|
4555
|
+
`Module nesting depth limit (${MAX_MODULE_DEPTH}) reached at "${sanitizeForMessage(dirPath)}". Skipping further expansion.`
|
|
3734
4556
|
);
|
|
3735
4557
|
return [];
|
|
3736
4558
|
}
|
|
@@ -3745,10 +4567,12 @@ async function parseModuleDirectory(dirPath, parentVars, moduleInputs, warnings,
|
|
|
3745
4567
|
parsedJsons.push({ path: file.path, json });
|
|
3746
4568
|
} catch (err) {
|
|
3747
4569
|
const msg = err instanceof Error ? err.message : String(err);
|
|
3748
|
-
|
|
4570
|
+
const safePath = sanitizeForMessage(file.path);
|
|
4571
|
+
const safeMsg = sanitizeForMessage(msg, 512);
|
|
4572
|
+
warnings.push(`Parse error in module file ${safePath}: ${safeMsg}`);
|
|
3749
4573
|
logger.warn("Skipping module file due to parse error", {
|
|
3750
|
-
path:
|
|
3751
|
-
error:
|
|
4574
|
+
path: safePath,
|
|
4575
|
+
error: safeMsg
|
|
3752
4576
|
});
|
|
3753
4577
|
}
|
|
3754
4578
|
}
|
|
@@ -3771,86 +4595,117 @@ async function parseModuleDirectory(dirPath, parentVars, moduleInputs, warnings,
|
|
|
3771
4595
|
allResources.push(...resources);
|
|
3772
4596
|
} catch (err) {
|
|
3773
4597
|
const msg = err instanceof Error ? err.message : String(err);
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
4598
|
+
const safePath = sanitizeForMessage(path);
|
|
4599
|
+
const safeMsg = sanitizeForMessage(msg, 512);
|
|
4600
|
+
warnings.push(`Extraction error in module file ${safePath}: ${safeMsg}`);
|
|
4601
|
+
logger.warn("Resource extraction failed in module", { path: safePath, error: safeMsg });
|
|
4602
|
+
}
|
|
4603
|
+
}
|
|
4604
|
+
const nestedResources = await resolveModules(
|
|
4605
|
+
combined,
|
|
4606
|
+
dirPath,
|
|
4607
|
+
variables,
|
|
4608
|
+
warnings,
|
|
4609
|
+
depth + 1,
|
|
4610
|
+
rootBoundary
|
|
4611
|
+
);
|
|
3779
4612
|
allResources.push(...nestedResources);
|
|
3780
4613
|
return allResources;
|
|
3781
4614
|
}
|
|
3782
|
-
async function resolveModules(hclJson, basePath, variables, warnings, depth = 0) {
|
|
4615
|
+
async function resolveModules(hclJson, basePath, variables, warnings, depth = 0, rootBoundary) {
|
|
3783
4616
|
const moduleBlock = hclJson["module"];
|
|
3784
4617
|
if (!moduleBlock || typeof moduleBlock !== "object" || Array.isArray(moduleBlock)) {
|
|
3785
4618
|
return [];
|
|
3786
4619
|
}
|
|
4620
|
+
const boundary = resolve2(rootBoundary ?? process.cwd());
|
|
3787
4621
|
const allResources = [];
|
|
3788
4622
|
for (const [moduleName, moduleDeclaration] of Object.entries(
|
|
3789
4623
|
moduleBlock
|
|
3790
4624
|
)) {
|
|
4625
|
+
const safeModuleName = sanitizeForMessage(moduleName, 128);
|
|
3791
4626
|
const source = getSourceAttr(moduleDeclaration);
|
|
3792
4627
|
if (!source) {
|
|
3793
|
-
warnings.push(`Module "${
|
|
4628
|
+
warnings.push(`Module "${safeModuleName}" has no source attribute and will be skipped.`);
|
|
3794
4629
|
continue;
|
|
3795
4630
|
}
|
|
4631
|
+
const safeSource = sanitizeForMessage(source, 256);
|
|
3796
4632
|
const isLocal = source.startsWith("./") || source.startsWith("../");
|
|
3797
4633
|
const isGit = source.startsWith("git::") || source.includes("github.com") || source.includes("bitbucket.org") || source.startsWith("hg::");
|
|
3798
4634
|
if (isGit) {
|
|
3799
4635
|
warnings.push(
|
|
3800
|
-
`Module "${
|
|
4636
|
+
`Module "${safeModuleName}" uses a git source ("${safeSource}") which is not supported. Skipping.`
|
|
3801
4637
|
);
|
|
3802
|
-
logger.debug("Skipping git module", { moduleName, source });
|
|
4638
|
+
logger.debug("Skipping git module", { moduleName: safeModuleName, source: safeSource });
|
|
3803
4639
|
continue;
|
|
3804
4640
|
}
|
|
3805
4641
|
const rawInputs = extractModuleInputs(moduleDeclaration);
|
|
3806
4642
|
const moduleInputs = substituteVariables(rawInputs, variables);
|
|
3807
4643
|
let moduleDir;
|
|
3808
4644
|
if (isLocal) {
|
|
3809
|
-
moduleDir =
|
|
4645
|
+
moduleDir = resolveWithinBoundary(source, basePath, boundary);
|
|
4646
|
+
if (!moduleDir) {
|
|
4647
|
+
warnings.push(
|
|
4648
|
+
`Module "${safeModuleName}" source "${safeSource}" resolves outside the allowed root and was rejected.`
|
|
4649
|
+
);
|
|
4650
|
+
logger.warn("Rejected local module path escaping boundary", {
|
|
4651
|
+
moduleName: safeModuleName,
|
|
4652
|
+
source: safeSource
|
|
4653
|
+
});
|
|
4654
|
+
continue;
|
|
4655
|
+
}
|
|
3810
4656
|
} else {
|
|
3811
|
-
const terraformModulesDir =
|
|
3812
|
-
const candidateDirect =
|
|
3813
|
-
|
|
4657
|
+
const terraformModulesDir = resolve2(basePath, ".terraform", "modules");
|
|
4658
|
+
const candidateDirect = resolveWithinBoundary(
|
|
4659
|
+
moduleName,
|
|
4660
|
+
terraformModulesDir,
|
|
4661
|
+
terraformModulesDir
|
|
4662
|
+
);
|
|
4663
|
+
if (candidateDirect && existsSync2(candidateDirect)) {
|
|
3814
4664
|
moduleDir = candidateDirect;
|
|
3815
4665
|
} else {
|
|
3816
4666
|
const modulesJsonPath = join2(terraformModulesDir, "modules.json");
|
|
4667
|
+
let resolvedFromJson = null;
|
|
3817
4668
|
if (existsSync2(modulesJsonPath)) {
|
|
3818
4669
|
try {
|
|
3819
|
-
const
|
|
4670
|
+
const rawJson = readFileSync2(modulesJsonPath, "utf-8");
|
|
4671
|
+
const modulesJson = safeJsonParse(rawJson);
|
|
3820
4672
|
const entry = modulesJson.Modules?.find(
|
|
3821
4673
|
(m) => m.Key === moduleName || m.Key.startsWith(`${moduleName}.`)
|
|
3822
4674
|
);
|
|
3823
|
-
if (entry?.Dir) {
|
|
3824
|
-
|
|
3825
|
-
} else {
|
|
3826
|
-
warnings.push(
|
|
3827
|
-
`Registry module "${moduleName}" (source: "${source}") not found in .terraform/modules/. Run "terraform init" to download modules before estimating costs.`
|
|
3828
|
-
);
|
|
3829
|
-
logger.debug("Registry module not initialised", { moduleName, source });
|
|
3830
|
-
continue;
|
|
4675
|
+
if (entry?.Dir && typeof entry.Dir === "string") {
|
|
4676
|
+
resolvedFromJson = resolveWithinBoundary(entry.Dir, basePath, boundary);
|
|
3831
4677
|
}
|
|
3832
4678
|
} catch {
|
|
3833
|
-
|
|
3834
|
-
`Registry module "${moduleName}" (source: "${source}") not found in .terraform/modules/. Run "terraform init" to download modules before estimating costs.`
|
|
3835
|
-
);
|
|
3836
|
-
continue;
|
|
4679
|
+
resolvedFromJson = null;
|
|
3837
4680
|
}
|
|
4681
|
+
}
|
|
4682
|
+
if (resolvedFromJson) {
|
|
4683
|
+
moduleDir = resolvedFromJson;
|
|
3838
4684
|
} else {
|
|
3839
4685
|
warnings.push(
|
|
3840
|
-
`Registry module "${
|
|
4686
|
+
`Registry module "${safeModuleName}" (source: "${safeSource}") not found in .terraform/modules/. Run "terraform init" to download modules before estimating costs.`
|
|
3841
4687
|
);
|
|
3842
|
-
logger.debug("Registry module not initialised", {
|
|
4688
|
+
logger.debug("Registry module not initialised", {
|
|
4689
|
+
moduleName: safeModuleName,
|
|
4690
|
+
source: safeSource
|
|
4691
|
+
});
|
|
3843
4692
|
continue;
|
|
3844
4693
|
}
|
|
3845
4694
|
}
|
|
3846
4695
|
}
|
|
3847
|
-
logger.debug("Resolving module", {
|
|
4696
|
+
logger.debug("Resolving module", {
|
|
4697
|
+
moduleName: safeModuleName,
|
|
4698
|
+
source: safeSource,
|
|
4699
|
+
moduleDir,
|
|
4700
|
+
depth
|
|
4701
|
+
});
|
|
3848
4702
|
const childResources = await parseModuleDirectory(
|
|
3849
4703
|
moduleDir,
|
|
3850
4704
|
variables,
|
|
3851
4705
|
moduleInputs,
|
|
3852
4706
|
warnings,
|
|
3853
|
-
depth
|
|
4707
|
+
depth,
|
|
4708
|
+
boundary
|
|
3854
4709
|
);
|
|
3855
4710
|
allResources.push(...prefixResources(childResources, moduleName));
|
|
3856
4711
|
}
|
|
@@ -4973,16 +5828,31 @@ function generateMermaidDiagram(graph) {
|
|
|
4973
5828
|
return lines.join("\n");
|
|
4974
5829
|
}
|
|
4975
5830
|
|
|
5831
|
+
// src/schemas/bounded.ts
|
|
5832
|
+
import { z } from "zod";
|
|
5833
|
+
var MAX_FILE_PATH_LEN = 1024;
|
|
5834
|
+
var MAX_FILE_CONTENT_BYTES = 5 * 1024 * 1024;
|
|
5835
|
+
var MAX_TFVARS_BYTES = 1 * 1024 * 1024;
|
|
5836
|
+
var MAX_PLAN_JSON_BYTES = 20 * 1024 * 1024;
|
|
5837
|
+
var MAX_STATE_JSON_BYTES = 20 * 1024 * 1024;
|
|
5838
|
+
var MAX_SHORT_STRING_LEN = 256;
|
|
5839
|
+
var filePathSchema = z.string().max(MAX_FILE_PATH_LEN, `File path exceeds ${MAX_FILE_PATH_LEN} characters`);
|
|
5840
|
+
var fileContentSchema = z.string().max(MAX_FILE_CONTENT_BYTES, `File content exceeds ${MAX_FILE_CONTENT_BYTES} bytes`);
|
|
5841
|
+
var tfvarsSchema = z.string().max(MAX_TFVARS_BYTES, `tfvars content exceeds ${MAX_TFVARS_BYTES} bytes`);
|
|
5842
|
+
var planJsonSchema = z.string().max(MAX_PLAN_JSON_BYTES, `plan JSON exceeds ${MAX_PLAN_JSON_BYTES} bytes`);
|
|
5843
|
+
var stateJsonSchema = z.string().max(MAX_STATE_JSON_BYTES, `state JSON exceeds ${MAX_STATE_JSON_BYTES} bytes`);
|
|
5844
|
+
var shortStringSchema = z.string().max(MAX_SHORT_STRING_LEN, `value exceeds ${MAX_SHORT_STRING_LEN} characters`);
|
|
5845
|
+
|
|
4976
5846
|
// src/tools/analyze-terraform.ts
|
|
4977
|
-
var analyzeTerraformSchema =
|
|
4978
|
-
files:
|
|
4979
|
-
|
|
4980
|
-
path:
|
|
4981
|
-
content:
|
|
5847
|
+
var analyzeTerraformSchema = z2.object({
|
|
5848
|
+
files: z2.array(
|
|
5849
|
+
z2.object({
|
|
5850
|
+
path: filePathSchema.describe("File path"),
|
|
5851
|
+
content: fileContentSchema.describe("File content (HCL)")
|
|
4982
5852
|
})
|
|
4983
|
-
).describe("Terraform files to analyze"),
|
|
4984
|
-
tfvars:
|
|
4985
|
-
include_dependencies:
|
|
5853
|
+
).max(2e3, "files array exceeds 2000 entries").describe("Terraform files to analyze"),
|
|
5854
|
+
tfvars: tfvarsSchema.optional().describe("Contents of terraform.tfvars file"),
|
|
5855
|
+
include_dependencies: z2.boolean().optional().default(false).describe(
|
|
4986
5856
|
"When true, parse resource references and depends_on blocks to build a dependency graph and Mermaid diagram"
|
|
4987
5857
|
)
|
|
4988
5858
|
});
|
|
@@ -5034,7 +5904,7 @@ async function analyzeTerraform(params) {
|
|
|
5034
5904
|
}
|
|
5035
5905
|
|
|
5036
5906
|
// src/tools/estimate-cost.ts
|
|
5037
|
-
import { z as
|
|
5907
|
+
import { z as z3 } from "zod";
|
|
5038
5908
|
|
|
5039
5909
|
// src/mapping/region-mapper.ts
|
|
5040
5910
|
var DEFAULT_REGIONS = {
|
|
@@ -5244,7 +6114,7 @@ async function calculateComputeCost(resource, targetProvider, targetRegion, pric
|
|
|
5244
6114
|
`No instance mapping found for ${sourceInstanceType} (${resource.provider} -> ${targetProvider})`
|
|
5245
6115
|
);
|
|
5246
6116
|
} else {
|
|
5247
|
-
const { getInstanceMap: getInstanceMap2 } = await import("./loader-
|
|
6117
|
+
const { getInstanceMap: getInstanceMap2 } = await import("./loader-UWVEXYMR.js");
|
|
5248
6118
|
const im = getInstanceMap2();
|
|
5249
6119
|
const dirKey = `${resource.provider}_to_${targetProvider}`;
|
|
5250
6120
|
const directMap = im[dirKey];
|
|
@@ -5282,13 +6152,69 @@ async function calculateComputeCost(resource, targetProvider, targetRegion, pric
|
|
|
5282
6152
|
}
|
|
5283
6153
|
let hourlyPrice = computePrice.price_per_unit;
|
|
5284
6154
|
if (pricingConfig.pricing_model === "spot") {
|
|
5285
|
-
|
|
5286
|
-
|
|
5287
|
-
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5291
|
-
|
|
6155
|
+
let liveSpotApplied = false;
|
|
6156
|
+
if (targetProvider === "azure") {
|
|
6157
|
+
try {
|
|
6158
|
+
const azureProvider = pricingEngine.getProvider("azure");
|
|
6159
|
+
if (typeof azureProvider.getSpotPrice === "function") {
|
|
6160
|
+
const liveSpot = await azureProvider.getSpotPrice(
|
|
6161
|
+
effectiveInstance,
|
|
6162
|
+
targetRegion,
|
|
6163
|
+
resource.attributes.os
|
|
6164
|
+
);
|
|
6165
|
+
if (liveSpot && liveSpot.price_per_unit > 0 && hourlyPrice > 0) {
|
|
6166
|
+
const savingsPct = Math.max(
|
|
6167
|
+
0,
|
|
6168
|
+
Math.round((1 - liveSpot.price_per_unit / hourlyPrice) * 100)
|
|
6169
|
+
);
|
|
6170
|
+
hourlyPrice = liveSpot.price_per_unit;
|
|
6171
|
+
pricingSource = "live";
|
|
6172
|
+
liveSpotApplied = true;
|
|
6173
|
+
notes.push(
|
|
6174
|
+
`Spot pricing applied from live Azure Retail API (${savingsPct}% discount from on-demand)`
|
|
6175
|
+
);
|
|
6176
|
+
}
|
|
6177
|
+
}
|
|
6178
|
+
} catch (err) {
|
|
6179
|
+
logger.debug("Azure live spot lookup failed, using fallback discount factor", {
|
|
6180
|
+
err: err instanceof Error ? err.message : String(err)
|
|
6181
|
+
});
|
|
6182
|
+
}
|
|
6183
|
+
}
|
|
6184
|
+
if (!liveSpotApplied && targetProvider === "aws") {
|
|
6185
|
+
try {
|
|
6186
|
+
const awsProvider = pricingEngine.getProvider("aws");
|
|
6187
|
+
if (typeof awsProvider.getSpotFactor === "function") {
|
|
6188
|
+
const liveFactor = await awsProvider.getSpotFactor(
|
|
6189
|
+
effectiveInstance,
|
|
6190
|
+
targetRegion,
|
|
6191
|
+
resource.attributes.os
|
|
6192
|
+
);
|
|
6193
|
+
if (liveFactor !== null && liveFactor > 0 && liveFactor < 1) {
|
|
6194
|
+
const savingsPct = Math.round((1 - liveFactor) * 100);
|
|
6195
|
+
hourlyPrice = hourlyPrice * liveFactor;
|
|
6196
|
+
pricingSource = "live";
|
|
6197
|
+
liveSpotApplied = true;
|
|
6198
|
+
notes.push(
|
|
6199
|
+
`Spot pricing applied from live AWS Spot Advisor (${savingsPct}% discount from on-demand)`
|
|
6200
|
+
);
|
|
6201
|
+
}
|
|
6202
|
+
}
|
|
6203
|
+
} catch (err) {
|
|
6204
|
+
logger.debug("AWS live spot lookup failed, using fallback discount factor", {
|
|
6205
|
+
err: err instanceof Error ? err.message : String(err)
|
|
6206
|
+
});
|
|
6207
|
+
}
|
|
6208
|
+
}
|
|
6209
|
+
if (!liveSpotApplied) {
|
|
6210
|
+
const family = classifyInstanceFamily(effectiveInstance, targetProvider);
|
|
6211
|
+
const providerFactors = SPOT_DISCOUNT_FACTORS[targetProvider] ?? SPOT_DISCOUNT_FACTORS.aws;
|
|
6212
|
+
const factor = providerFactors[family] ?? providerFactors.general ?? 0.35;
|
|
6213
|
+
const savingsPct = Math.round((1 - factor) * 100);
|
|
6214
|
+
hourlyPrice = hourlyPrice * factor;
|
|
6215
|
+
pricingSource = "spot-estimate";
|
|
6216
|
+
notes.push(`Spot pricing applied (${savingsPct}% discount from on-demand)`);
|
|
6217
|
+
}
|
|
5292
6218
|
}
|
|
5293
6219
|
computeMonthlyCost = hourlyPrice * monthlyHours;
|
|
5294
6220
|
breakdown.push({
|
|
@@ -8274,19 +9200,19 @@ function convertBreakdownCurrency(breakdown, currency) {
|
|
|
8274
9200
|
}
|
|
8275
9201
|
|
|
8276
9202
|
// src/tools/estimate-cost.ts
|
|
8277
|
-
var estimateCostSchema =
|
|
8278
|
-
files:
|
|
8279
|
-
|
|
8280
|
-
path:
|
|
8281
|
-
content:
|
|
9203
|
+
var estimateCostSchema = z3.object({
|
|
9204
|
+
files: z3.array(
|
|
9205
|
+
z3.object({
|
|
9206
|
+
path: filePathSchema.describe("File path"),
|
|
9207
|
+
content: fileContentSchema.describe("File content (HCL)")
|
|
8282
9208
|
})
|
|
8283
|
-
),
|
|
8284
|
-
tfvars:
|
|
8285
|
-
provider:
|
|
8286
|
-
region:
|
|
9209
|
+
).max(2e3, "files array exceeds 2000 entries"),
|
|
9210
|
+
tfvars: tfvarsSchema.optional().describe("Contents of terraform.tfvars file"),
|
|
9211
|
+
provider: z3.enum(["aws", "azure", "gcp"]).describe("Target cloud provider to estimate costs for"),
|
|
9212
|
+
region: z3.string().optional().describe(
|
|
8287
9213
|
"Target region for pricing lookup. Defaults to the source region mapped to the target provider."
|
|
8288
9214
|
),
|
|
8289
|
-
currency:
|
|
9215
|
+
currency: z3.enum(SUPPORTED_CURRENCIES).optional().default("USD").describe("Output currency for cost estimates. Defaults to USD.")
|
|
8290
9216
|
});
|
|
8291
9217
|
async function estimateCost(params, pricingEngine, config) {
|
|
8292
9218
|
const inventory = await parseTerraform(params.files, params.tfvars);
|
|
@@ -8328,7 +9254,7 @@ async function estimateCost(params, pricingEngine, config) {
|
|
|
8328
9254
|
}
|
|
8329
9255
|
|
|
8330
9256
|
// src/tools/compare-providers.ts
|
|
8331
|
-
import { z as
|
|
9257
|
+
import { z as z4 } from "zod";
|
|
8332
9258
|
|
|
8333
9259
|
// src/calculator/reserved.ts
|
|
8334
9260
|
var DISCOUNT_RATES = {
|
|
@@ -8356,8 +9282,12 @@ var DISCOUNT_RATES = {
|
|
|
8356
9282
|
{ term: "3yr", payment: "partial_upfront", rate: 0.45 }
|
|
8357
9283
|
]
|
|
8358
9284
|
};
|
|
8359
|
-
function calculateReservedPricing(onDemandMonthly, provider) {
|
|
8360
|
-
const
|
|
9285
|
+
function calculateReservedPricing(onDemandMonthly, provider, overrides) {
|
|
9286
|
+
const baseRates = DISCOUNT_RATES[provider];
|
|
9287
|
+
const rates = overrides ? baseRates.map((r) => {
|
|
9288
|
+
const override = overrides[r.term];
|
|
9289
|
+
return override !== void 0 ? { ...r, rate: override } : r;
|
|
9290
|
+
}) : baseRates;
|
|
8361
9291
|
const options = rates.map(({ term, payment, rate }) => {
|
|
8362
9292
|
const monthly_cost = onDemandMonthly * (1 - rate);
|
|
8363
9293
|
const monthly_savings = onDemandMonthly - monthly_cost;
|
|
@@ -9116,17 +10046,17 @@ function generateFocusReport(comparison, resources) {
|
|
|
9116
10046
|
}
|
|
9117
10047
|
|
|
9118
10048
|
// src/tools/compare-providers.ts
|
|
9119
|
-
var compareProvidersSchema =
|
|
9120
|
-
files:
|
|
9121
|
-
|
|
9122
|
-
path:
|
|
9123
|
-
content:
|
|
10049
|
+
var compareProvidersSchema = z4.object({
|
|
10050
|
+
files: z4.array(
|
|
10051
|
+
z4.object({
|
|
10052
|
+
path: filePathSchema.describe("File path"),
|
|
10053
|
+
content: fileContentSchema.describe("File content (HCL)")
|
|
9124
10054
|
})
|
|
9125
|
-
),
|
|
9126
|
-
tfvars:
|
|
9127
|
-
format:
|
|
9128
|
-
providers:
|
|
9129
|
-
currency:
|
|
10055
|
+
).max(2e3, "files array exceeds 2000 entries"),
|
|
10056
|
+
tfvars: tfvarsSchema.optional().describe("Contents of terraform.tfvars file"),
|
|
10057
|
+
format: z4.enum(["markdown", "json", "csv", "focus"]).default("markdown").describe("Output report format"),
|
|
10058
|
+
providers: z4.array(z4.enum(["aws", "azure", "gcp"])).default(["aws", "azure", "gcp"]).describe("Cloud providers to include in the comparison"),
|
|
10059
|
+
currency: z4.enum(SUPPORTED_CURRENCIES).optional().default("USD").describe("Output currency for cost estimates. Defaults to USD.")
|
|
9130
10060
|
});
|
|
9131
10061
|
async function compareProviders(params, pricingEngine, config) {
|
|
9132
10062
|
const inventory = await parseTerraform(params.files, params.tfvars);
|
|
@@ -9208,16 +10138,16 @@ async function compareProviders(params, pricingEngine, config) {
|
|
|
9208
10138
|
}
|
|
9209
10139
|
|
|
9210
10140
|
// src/tools/optimize-cost.ts
|
|
9211
|
-
import { z as
|
|
9212
|
-
var optimizeCostSchema =
|
|
9213
|
-
files:
|
|
9214
|
-
|
|
9215
|
-
path:
|
|
9216
|
-
content:
|
|
10141
|
+
import { z as z5 } from "zod";
|
|
10142
|
+
var optimizeCostSchema = z5.object({
|
|
10143
|
+
files: z5.array(
|
|
10144
|
+
z5.object({
|
|
10145
|
+
path: filePathSchema.describe("File path"),
|
|
10146
|
+
content: fileContentSchema.describe("File content (HCL)")
|
|
9217
10147
|
})
|
|
9218
|
-
),
|
|
9219
|
-
tfvars:
|
|
9220
|
-
providers:
|
|
10148
|
+
).max(2e3, "files array exceeds 2000 entries"),
|
|
10149
|
+
tfvars: tfvarsSchema.optional().describe("Contents of terraform.tfvars file"),
|
|
10150
|
+
providers: z5.array(z5.enum(["aws", "azure", "gcp"])).default(["aws", "azure", "gcp"]).describe("Providers to calculate costs against for cross-provider recommendations")
|
|
9221
10151
|
});
|
|
9222
10152
|
async function optimizeCost(params, pricingEngine, config) {
|
|
9223
10153
|
const inventory = await parseTerraform(params.files, params.tfvars);
|
|
@@ -9253,24 +10183,28 @@ async function optimizeCost(params, pricingEngine, config) {
|
|
|
9253
10183
|
}
|
|
9254
10184
|
|
|
9255
10185
|
// src/tools/what-if.ts
|
|
9256
|
-
import { z as
|
|
9257
|
-
var whatIfSchema =
|
|
9258
|
-
files:
|
|
9259
|
-
|
|
9260
|
-
path:
|
|
9261
|
-
content:
|
|
10186
|
+
import { z as z6 } from "zod";
|
|
10187
|
+
var whatIfSchema = z6.object({
|
|
10188
|
+
files: z6.array(
|
|
10189
|
+
z6.object({
|
|
10190
|
+
path: filePathSchema.describe("File path"),
|
|
10191
|
+
content: fileContentSchema.describe("File content (HCL)")
|
|
9262
10192
|
})
|
|
9263
|
-
).describe("Terraform HCL files to analyse"),
|
|
9264
|
-
changes:
|
|
9265
|
-
|
|
9266
|
-
resource_id:
|
|
9267
|
-
|
|
9268
|
-
|
|
10193
|
+
).max(2e3, "files array exceeds 2000 entries").describe("Terraform HCL files to analyse"),
|
|
10194
|
+
changes: z6.array(
|
|
10195
|
+
z6.object({
|
|
10196
|
+
resource_id: shortStringSchema.describe(
|
|
10197
|
+
"The resource ID to modify (e.g. aws_instance.web)"
|
|
10198
|
+
),
|
|
10199
|
+
attribute: shortStringSchema.describe(
|
|
10200
|
+
"The attribute name to change (e.g. instance_type, storage_size_gb)"
|
|
10201
|
+
),
|
|
10202
|
+
new_value: z6.union([shortStringSchema, z6.number()]).describe("The new value for the attribute")
|
|
9269
10203
|
})
|
|
9270
|
-
).describe("List of attribute changes to simulate"),
|
|
9271
|
-
provider:
|
|
9272
|
-
region:
|
|
9273
|
-
tfvars:
|
|
10204
|
+
).max(200, "changes array exceeds 200 entries").describe("List of attribute changes to simulate"),
|
|
10205
|
+
provider: z6.enum(["aws", "azure", "gcp"]).optional().describe("Target cloud provider. Defaults to auto-detecting from the files"),
|
|
10206
|
+
region: z6.string().optional().describe("Target region for pricing. Defaults to the region detected from provider blocks"),
|
|
10207
|
+
tfvars: z6.string().optional().describe("Contents of a terraform.tfvars file for variable resolution")
|
|
9274
10208
|
});
|
|
9275
10209
|
function cloneResources(resources) {
|
|
9276
10210
|
return JSON.parse(JSON.stringify(resources));
|
|
@@ -9371,10 +10305,17 @@ export {
|
|
|
9371
10305
|
logger,
|
|
9372
10306
|
PricingCache,
|
|
9373
10307
|
PricingEngine,
|
|
10308
|
+
sanitizeForMessage,
|
|
9374
10309
|
str,
|
|
9375
10310
|
num,
|
|
9376
10311
|
bool,
|
|
10312
|
+
safeJsonParse,
|
|
9377
10313
|
parseTerraform,
|
|
10314
|
+
filePathSchema,
|
|
10315
|
+
fileContentSchema,
|
|
10316
|
+
tfvarsSchema,
|
|
10317
|
+
planJsonSchema,
|
|
10318
|
+
stateJsonSchema,
|
|
9378
10319
|
analyzeTerraformSchema,
|
|
9379
10320
|
analyzeTerraform,
|
|
9380
10321
|
mapRegion,
|
|
@@ -9392,4 +10333,4 @@ export {
|
|
|
9392
10333
|
whatIfSchema,
|
|
9393
10334
|
whatIf
|
|
9394
10335
|
};
|
|
9395
|
-
//# sourceMappingURL=chunk-
|
|
10336
|
+
//# sourceMappingURL=chunk-6O2Y6MKU.js.map
|