@jadenrazo/cloudcost-mcp 0.5.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.
@@ -11,7 +11,7 @@ import {
11
11
  getRegionMappings,
12
12
  getRegionPriceMultipliers,
13
13
  getStorageMap
14
- } from "./chunk-TRRAOOVF.js";
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 normalizeAwsCompute(rawProduct, rawPrice, region) {
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: (/* @__PURE__ */ new Date()).toISOString()
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: (/* @__PURE__ */ new Date()).toISOString()
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: (/* @__PURE__ */ new Date()).toISOString()
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((resolve2) => setTimeout(resolve2, ms));
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.089,
922
- "c7i.xlarge": 0.178,
933
+ "c7i.large": 0.0893,
934
+ "c7i.xlarge": 0.1785,
923
935
  "c7i.2xlarge": 0.357,
924
- "c7g.large": 0.072,
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.101,
938
- "r6g.xlarge": 0.202,
939
- "r6g.2xlarge": 0.403,
940
- "r6g.4xlarge": 0.806,
941
- "r7i.large": 0.133,
942
- "r7i.xlarge": 0.266,
943
- "r7i.2xlarge": 0.532,
944
- "r7g.large": 0.107,
945
- "r7g.xlarge": 0.214,
946
- "r7g.2xlarge": 0.428,
947
- "r7g.4xlarge": 0.856,
948
- // 8th-gen Graviton4 families
949
- "m8g.large": 0.077,
950
- "m8g.xlarge": 0.154,
951
- "m8g.2xlarge": 0.308,
952
- "m8g.4xlarge": 0.616,
953
- "c8g.large": 0.068,
954
- "c8g.xlarge": 0.136,
955
- "c8g.2xlarge": 0.272,
956
- "c8g.4xlarge": 0.544,
957
- "r8g.large": 0.101,
958
- "r8g.xlarge": 0.202,
959
- "r8g.2xlarge": 0.404,
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": 32.7726
977
+ "p4d.24xlarge": 21.9576
968
978
  };
969
979
  var RDS_BASE_PRICES = {
970
- "db.t3.micro": 0.017,
971
- "db.t3.small": 0.034,
972
- "db.t3.medium": 0.068,
973
- "db.t3.large": 0.136,
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.171,
980
- "db.m5.xlarge": 0.342,
981
- "db.m6g.large": 0.154,
982
- "db.m6g.xlarge": 0.308,
983
- "db.m6g.2xlarge": 0.616,
984
- "db.m6i.large": 0.171,
985
- "db.m6i.xlarge": 0.342,
986
- "db.m6i.2xlarge": 0.684,
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.336,
989
- "db.m7g.2xlarge": 0.672,
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.218,
994
- "db.r6g.xlarge": 0.437,
995
- "db.r6g.2xlarge": 0.874,
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
- const effectiveDate = (/* @__PURE__ */ new Date()).toISOString();
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: (/* @__PURE__ */ new Date()).toISOString()
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.085,
1659
- standard_f4s_v2: 0.17,
1660
- standard_f8s_v2: 0.34,
1661
- standard_f16s_v2: 0.68,
1662
- standard_f32s_v2: 1.36,
1663
- standard_l8s_v3: 0.624,
1664
- standard_l16s_v3: 1.248,
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
- // v6 series
1671
- standard_d2s_v6: 0.096,
1672
- standard_d4s_v6: 0.192,
1673
- standard_d8s_v6: 0.384,
1674
- standard_d16s_v6: 0.768,
1675
- standard_e2s_v6: 0.126,
1676
- standard_e4s_v6: 0.252,
1677
- standard_e8s_v6: 0.504,
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.toLowerCase().replace(/\s+/g, "");
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.toLowerCase().replace(/\s+/g, "");
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
- const match = vmName.match(/Standard_([A-Z])(\d+)([a-z]*)_v(\d+)/i);
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.toLowerCase().replace(/\s+/g, "");
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 'Consumption'`;
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 fetch(pageUrl, {
2471
- signal: AbortSignal.timeout(3e4),
2472
- headers: { Accept: "application/json" }
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
- logger.error("Failed to parse HCL", { filename, error: message });
2813
- throw new Error(`HCL parse error in "${filename}": ${message}`);
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 = { ...a };
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
- warnings.push(`Parse error in module file ${file.path}: ${msg}`);
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: file.path,
3751
- error: msg
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
- warnings.push(`Extraction error in module file ${path}: ${msg}`);
3775
- logger.warn("Resource extraction failed in module", { path, error: msg });
3776
- }
3777
- }
3778
- const nestedResources = await resolveModules(combined, dirPath, variables, warnings, depth + 1);
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 "${moduleName}" has no source attribute and will be skipped.`);
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 "${moduleName}" uses a git source ("${source}") which is not supported. Skipping.`
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 = resolve(basePath, source);
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 = join2(basePath, ".terraform", "modules");
3812
- const candidateDirect = join2(terraformModulesDir, moduleName);
3813
- if (existsSync2(candidateDirect)) {
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 modulesJson = JSON.parse(readFileSync2(modulesJsonPath, "utf-8"));
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
- moduleDir = resolve(basePath, entry.Dir);
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
- warnings.push(
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 "${moduleName}" (source: "${source}") not found in .terraform/modules/. Run "terraform init" to download modules before estimating costs.`
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", { moduleName, source });
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", { moduleName, source, moduleDir, depth });
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 = z.object({
4978
- files: z.array(
4979
- z.object({
4980
- path: z.string().describe("File path"),
4981
- content: z.string().describe("File content (HCL)")
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: z.string().optional().describe("Contents of terraform.tfvars file"),
4985
- include_dependencies: z.boolean().optional().default(false).describe(
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 z2 } from "zod";
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-VXYJYDIH.js");
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
- const family = classifyInstanceFamily(effectiveInstance, targetProvider);
5286
- const providerFactors = SPOT_DISCOUNT_FACTORS[targetProvider] ?? SPOT_DISCOUNT_FACTORS.aws;
5287
- const factor = providerFactors[family] ?? providerFactors.general ?? 0.35;
5288
- const savingsPct = Math.round((1 - factor) * 100);
5289
- hourlyPrice = hourlyPrice * factor;
5290
- pricingSource = "spot-estimate";
5291
- notes.push(`Spot pricing applied (${savingsPct}% discount from on-demand)`);
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 = z2.object({
8278
- files: z2.array(
8279
- z2.object({
8280
- path: z2.string().describe("File path"),
8281
- content: z2.string().describe("File content (HCL)")
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: z2.string().optional().describe("Contents of terraform.tfvars file"),
8285
- provider: z2.enum(["aws", "azure", "gcp"]).describe("Target cloud provider to estimate costs for"),
8286
- region: z2.string().optional().describe(
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: z2.enum(SUPPORTED_CURRENCIES).optional().default("USD").describe("Output currency for cost estimates. Defaults to USD.")
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 z3 } from "zod";
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 rates = DISCOUNT_RATES[provider];
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 = z3.object({
9120
- files: z3.array(
9121
- z3.object({
9122
- path: z3.string().describe("File path"),
9123
- content: z3.string().describe("File content (HCL)")
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: z3.string().optional().describe("Contents of terraform.tfvars file"),
9127
- format: z3.enum(["markdown", "json", "csv", "focus"]).default("markdown").describe("Output report format"),
9128
- providers: z3.array(z3.enum(["aws", "azure", "gcp"])).default(["aws", "azure", "gcp"]).describe("Cloud providers to include in the comparison"),
9129
- currency: z3.enum(SUPPORTED_CURRENCIES).optional().default("USD").describe("Output currency for cost estimates. Defaults to USD.")
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 z4 } from "zod";
9212
- var optimizeCostSchema = z4.object({
9213
- files: z4.array(
9214
- z4.object({
9215
- path: z4.string().describe("File path"),
9216
- content: z4.string().describe("File content (HCL)")
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: z4.string().optional().describe("Contents of terraform.tfvars file"),
9220
- providers: z4.array(z4.enum(["aws", "azure", "gcp"])).default(["aws", "azure", "gcp"]).describe("Providers to calculate costs against for cross-provider recommendations")
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 z5 } from "zod";
9257
- var whatIfSchema = z5.object({
9258
- files: z5.array(
9259
- z5.object({
9260
- path: z5.string().describe("File path"),
9261
- content: z5.string().describe("File content (HCL)")
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: z5.array(
9265
- z5.object({
9266
- resource_id: z5.string().describe("The resource ID to modify (e.g. aws_instance.web)"),
9267
- attribute: z5.string().describe("The attribute name to change (e.g. instance_type, storage_size_gb)"),
9268
- new_value: z5.union([z5.string(), z5.number()]).describe("The new value for the attribute")
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: z5.enum(["aws", "azure", "gcp"]).optional().describe("Target cloud provider. Defaults to auto-detecting from the files"),
9272
- region: z5.string().optional().describe("Target region for pricing. Defaults to the region detected from provider blocks"),
9273
- tfvars: z5.string().optional().describe("Contents of a terraform.tfvars file for variable resolution")
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-E7KOWAMW.js.map
10336
+ //# sourceMappingURL=chunk-6O2Y6MKU.js.map