Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion DESIGN_PRINCIPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,6 @@ If the TypeScript surface later needs a standalone engine, it reimplements the s

Cloud prices change monthly. Hard-coded prices in source code go stale. The pricing layer exposes a query interface and the engine never knows or cares where the data comes from.

The catalog must handle tiered pricing (e.g., Lambda GB-seconds has three price tiers), free tiers (first 1M requests free), and regional variation. It must work offline after an initial seed. Prices refresh on a schedule — cloud pricing changes monthly at most.
The catalog must handle tiered pricing (e.g., Lambda GB-seconds has three price tiers), free tiers (first 1M requests free), and regional variation across all services and regions. Prices are fetched live from the pricing source and refresh on a schedule — cloud pricing changes monthly at most. The bundled seed price list is a test fixture and offline fallback, not a normal setup step.

A provider plugin architecture is premature. Use a normalized multi-cloud source until it doesn't cover a needed provider.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ infra-cost-model what-if model-a.yaml --compare model-b.yaml \
infra-cost-model graph model.yaml
```

## Pricing data

Prices are fetched live from the [Infracost Cloud Pricing API](https://www.infracost.io/docs/), covering all supported services and all regions:

```bash
# Authenticate once: set INFRACOST_API_KEY, or run `infracost auth login`
infra-cost-model sync-pricing # all services, all regions
infra-cost-model sync-pricing --region us-east-1 --region eu-west-1
```

The bundled `data/seed/aws_pricelist_seed.json` is a small us-east-1 fixture used by the test suite only — it is **not** a setup step for users, and `seed-pricing` exists purely for offline/testing.

## References

- Leitner, Cito & Stöckli. "Modelling and Managing Deployment Costs of Microservice-Based Cloud Applications." *UCC 2016*. DOI: 10.1145/2996890.2996901
Expand Down
54 changes: 51 additions & 3 deletions infra_cost_model/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,22 @@ def _build_parser() -> argparse.ArgumentParser:
p_extract.add_argument("--json", action="store_true", help="Output in JSON format")
p_extract.set_defaults(func=cmd_extract)

# seed-pricing
p_seed = sub.add_parser("seed-pricing", help="Seed pricing cache from seed file")
# sync-pricing (live, all services/regions — the normal way to populate prices)
p_sync = sub.add_parser(
"sync-pricing",
help="Fetch live prices from Infracost for all services and regions")
p_sync.add_argument("services", nargs="*", metavar="<metric>",
help="Specific catalog metrics to sync (default: all)")
p_sync.add_argument("--region", action="append", dest="regions", metavar="REGION",
help="Region to sync (repeatable). Default: all known regions")
p_sync.add_argument("--vendor", default="aws", metavar="VENDOR",
help="Cloud vendor (default: aws)")
p_sync.set_defaults(func=cmd_sync_pricing)

# seed-pricing (testing/offline only — NOT a normal user step; see sync-pricing)
p_seed = sub.add_parser(
"seed-pricing",
help="(testing/offline) Load the bundled seed price fixtures into the cache")
p_seed.add_argument("services", nargs="*", metavar="<service>",
help="Specific services to seed (default: all)")
p_seed.add_argument("--all", action="store_true", default=True,
Expand Down Expand Up @@ -331,8 +345,42 @@ def cmd_analyze(args: argparse.Namespace) -> int:
return 1


def cmd_sync_pricing(args: argparse.Namespace) -> int:
"""Fetch live prices from Infracost across all services and regions.

This is the normal way to populate the pricing catalog. Requires an Infracost
credential (INFRACOST_API_KEY or `infracost auth login`); without one it falls
back to the bundled seed fixtures and warns.
"""
from infra_cost_model.pricing.sources.infracost import (
sync_pricing_catalog, _REGION_PREFIX,
)

services = args.services if args.services else None
regions = args.regions if args.regions else sorted(_REGION_PREFIX)

try:
count, source = sync_pricing_catalog(
vendor=args.vendor, services=services, regions=regions)
if source == "infracost":
print(f"✓ Synced {count} prices from {source} across {len(regions)} region(s)")
else:
# No live credential → sync_pricing_catalog fell back to the us-east-1
# seed fixtures; don't claim the full region fan-out happened.
print(f"✓ Loaded {count} prices from {source} "
f"(us-east-1 fallback — set INFRACOST_API_KEY for live, all-region pricing)")
return 0
except RuntimeError as e:
_print_stderr(f"Error: {e}")
return 1


def cmd_seed_pricing(args: argparse.Namespace) -> int:
"""Seed pricing catalog from seed file."""
"""Load bundled seed price fixtures (testing/offline only).

Not a normal user step — real pricing comes from `sync-pricing`. The seed list
is a small us-east-1 fixture used by the test suite.
"""
from infra_cost_model.pricing.sources.infracost import seed_pricing_catalog

services = args.services if args.services else None
Expand Down
30 changes: 18 additions & 12 deletions infra_cost_model/pricing/sources/infracost.py
Original file line number Diff line number Diff line change
Expand Up @@ -566,12 +566,16 @@ def _live_auth_intended(client: "InfracostClient") -> bool:


def sync_pricing_catalog(vendor: str = "aws", services: list[str] = None,
fallback: bool = False) -> tuple[int, str]:
fallback: bool = False,
regions: list[str] = None) -> tuple[int, str]:
"""Sync pricing into the cache, live from Infracost when authenticated.

Falls back to the bundled seed price list when there is no credential, but
emits a ``UserWarning`` when a credential WAS present and the live sync failed
— so a broken live path is never silently mistaken for success.
Fetches every descriptor's prices for each requested region (defaults to
us-east-1 for backward compatibility; the CLI ``sync-pricing`` command passes
the full region set so pricing covers all services and all regions). Falls
back to the bundled seed price list when there is no credential, but emits a
``UserWarning`` when a credential WAS present and the live sync failed — so a
broken live path is never silently mistaken for success.
"""
from infra_cost_model.pricing.cache import PricingCache

Expand All @@ -586,16 +590,18 @@ def sync_pricing_catalog(vendor: str = "aws", services: list[str] = None,
return _sync_fallback(vendor, services, cache)

metrics = services if services else list(METRIC_DESCRIPTORS.keys())
region = "us-east-1"
if not regions:
regions = ["us-east-1"]
total = 0
failures: list[str] = []
for metric in metrics:
if metric not in METRIC_DESCRIPTORS:
continue
try:
total += client.sync_to_cache(cache, metric, region, vendor)
except (RuntimeError, requests.RequestException, KeyError) as exc:
failures.append(f"{metric}: {exc}")
for region in regions:
for metric in metrics:
if metric not in METRIC_DESCRIPTORS:
continue
try:
total += client.sync_to_cache(cache, metric, region, vendor)
except (RuntimeError, requests.RequestException, KeyError) as exc:
failures.append(f"{region}/{metric}: {exc}")

if total == 0:
warnings.warn(
Expand Down
42 changes: 42 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1646,3 +1646,45 @@ def test_coverage_pulumi_format(self):
os.unlink(temp_yaml)
os.unlink(temp_tf)



def test_cli_sync_pricing_defaults_to_all_regions(monkeypatch):
"""`sync-pricing` with no --region syncs every known region."""
import infra_cost_model.pricing.sources.infracost as ic
captured = {}

def fake_sync(vendor="aws", services=None, regions=None):
captured["vendor"] = vendor
captured["services"] = services
captured["regions"] = regions
return (42, "infracost")

monkeypatch.setattr(ic, "sync_pricing_catalog", fake_sync)
rc = main(["sync-pricing"])
assert rc == 0
assert captured["services"] is None
assert set(captured["regions"]) == set(ic._REGION_PREFIX)


def test_cli_sync_pricing_explicit_regions(monkeypatch):
import infra_cost_model.pricing.sources.infracost as ic
captured = {}
monkeypatch.setattr(ic, "sync_pricing_catalog",
lambda vendor="aws", services=None, regions=None:
captured.update(regions=regions) or (1, "infracost"))
rc = main(["sync-pricing", "--region", "eu-west-1", "--region", "us-west-2"])
assert rc == 0
assert captured["regions"] == ["eu-west-1", "us-west-2"]


def test_cli_sync_pricing_fallback_message(monkeypatch, capsys):
"""When no credential → seed fallback, the message must not claim all regions."""
import infra_cost_model.pricing.sources.infracost as ic
monkeypatch.setattr(ic, "sync_pricing_catalog",
lambda vendor="aws", services=None, regions=None: (14, "seed-pricelist"))
rc = main(["sync-pricing"])
assert rc == 0
out = capsys.readouterr().out
assert "seed-pricelist" in out
assert "region(s)" not in out # must not overstate the fan-out
assert "fallback" in out.lower()
33 changes: 33 additions & 0 deletions tests/test_infracost_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,3 +436,36 @@ def test_ap_region_prefixes_match_aws_codes():
assert _REGION_PREFIX["ap-south-1"] == "APS3" # Mumbai
assert _REGION_PREFIX["ap-southeast-3"] == "APS4" # Jakarta
assert _REGION_PREFIX["ap-south-2"] == "APS5" # Hyderabad


# --- Multi-region live sync ----------------------------------------------------

def test_sync_pricing_catalog_covers_multiple_regions(monkeypatch):
"""sync_pricing_catalog fetches every metric for EACH requested region."""
_set_creds(monkeypatch)
calls = []
monkeypatch.setattr(ic.InfracostClient, "is_authenticated", lambda self: True)
monkeypatch.setattr(
ic.InfracostClient, "sync_to_cache",
lambda self, cache, usage_metric, region, vendor="aws":
calls.append((usage_metric, region)) or 1,
)
total, source = ic.sync_pricing_catalog(
services=["KMS-Key-Month"], regions=["us-east-1", "eu-west-1", "ap-south-1"])
assert source == "infracost"
assert total == 3
assert {r for _, r in calls} == {"us-east-1", "eu-west-1", "ap-south-1"}


def test_sync_pricing_catalog_defaults_to_us_east_1(monkeypatch):
"""No regions arg → us-east-1 only (backward compatible)."""
_set_creds(monkeypatch)
calls = []
monkeypatch.setattr(ic.InfracostClient, "is_authenticated", lambda self: True)
monkeypatch.setattr(
ic.InfracostClient, "sync_to_cache",
lambda self, cache, usage_metric, region, vendor="aws":
calls.append(region) or 1,
)
ic.sync_pricing_catalog(services=["KMS-Key-Month"])
assert calls == ["us-east-1"]
Loading