diff --git a/DESIGN_PRINCIPLES.md b/DESIGN_PRINCIPLES.md index edcea3b..6d651dc 100644 --- a/DESIGN_PRINCIPLES.md +++ b/DESIGN_PRINCIPLES.md @@ -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. diff --git a/README.md b/README.md index d023014..75df1e5 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/infra_cost_model/cli.py b/infra_cost_model/cli.py index 7c122ed..b6be34e 100644 --- a/infra_cost_model/cli.py +++ b/infra_cost_model/cli.py @@ -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="", + 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="", help="Specific services to seed (default: all)") p_seed.add_argument("--all", action="store_true", default=True, @@ -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 diff --git a/infra_cost_model/pricing/sources/infracost.py b/infra_cost_model/pricing/sources/infracost.py index 87b78ea..bf41988 100644 --- a/infra_cost_model/pricing/sources/infracost.py +++ b/infra_cost_model/pricing/sources/infracost.py @@ -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 @@ -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( diff --git a/tests/test_cli.py b/tests/test_cli.py index 77059ae..23cf71d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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() diff --git a/tests/test_infracost_client.py b/tests/test_infracost_client.py index 9677fe1..fd6f412 100644 --- a/tests/test_infracost_client.py +++ b/tests/test_infracost_client.py @@ -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"]