diff --git a/infra_cost_model/pricing/sources/infracost.py b/infra_cost_model/pricing/sources/infracost.py index 069af08..6911fc9 100644 --- a/infra_cost_model/pricing/sources/infracost.py +++ b/infra_cost_model/pricing/sources/infracost.py @@ -266,6 +266,10 @@ def sync_to_cache(self, cache, usage_metric: str, region: str, return self._upsert_region_pair_representative( cache, prices, usage_metric, region, unit_match, descriptor, now) + if descriptor.get("regionless_usagetype"): + return self._upsert_regionless_usagetype( + cache, prices, usage_metric, region, unit_match, descriptor, now) + count = 0 for p in prices: if unit_match and p.get("unit") != unit_match: @@ -331,6 +335,44 @@ def _upsert_region_pair_representative(self, cache, prices, usage_metric, )) return 1 + def _upsert_regionless_usagetype(self, cache, prices, usage_metric, + region, unit_match, descriptor, now) -> int: + """Store a globally-catalogued, single-usagetype metric under the region. + + Unlike inter-region transfer, internet egress and inter-AZ transfer have + ONE usagetype per source region (not per region pair), but still live in + the global (region="") ``AWSDataTransfer`` catalogue. This keeps every row + for the region's usagetype — preserving tiers (internet egress is tiered + $0.09 / $0.085 / $0.07 / $0.05) — and stores them under the sync ``region``. + + us-east-1 data-transfer usagetypes are unprefixed (an AWS legacy quirk); + every other region prepends its short prefix (e.g. ``USW1-``). + + Returns the number of rows upserted. + """ + from infra_cost_model.pricing.cache import Price + + base = descriptor["usagetype_base"] + prefix = _region_usagetype_prefix(region) + target = base if region == "us-east-1" else f"{prefix}-{base}" + + count = 0 + for p in prices: + if unit_match and p.get("unit") != unit_match: + continue + if (p.get("attributes") or {}).get("usagetype") != target: + continue + cache.upsert(Price( + vendor=p["vendor"], service=p["service"], region=region, + product_family=p.get("product_family"), attributes=p.get("attributes", {}), + usage_metric=usage_metric, unit=p["unit"], price_usd=p["price_usd"], + start_usage_amount=p["start_usage_amount"], + end_usage_amount=p["end_usage_amount"], + source="infracost", effective_date=now, fetched_at=now, + )) + count += 1 + return count + # Map each catalog usage_metric to the Infracost product query that prices it. # Validated against the live Cloud Pricing API; extend per service as needed. @@ -494,6 +536,27 @@ def _upsert_region_pair_representative(self, cache, prices, usage_metric, "region_pair_source": True, "usagetype_suffix": "-AWS-Out-Bytes", }, + # Internet egress (#211): transferType "AWS Outbound", one usagetype per + # source region (us-east-1 is the unprefixed "DataTransfer-Out-Bytes"), + # tiered $0.09 / $0.085 / $0.07 / $0.05 across the 10/50/150 TB breakpoints. + "DataTransfer-Internet-Out-GB": { + "service": "AWSDataTransfer", + "query_region": "", + "attribute_filters": [{"key": "transferType", "value": "AWS Outbound"}], + "unit": "GB", + "regionless_usagetype": True, + "usagetype_base": "DataTransfer-Out-Bytes", + }, + # Regional inter-AZ transfer (#211): transferType "IntraRegion", flat $0.01/GB, + # usagetype "-DataTransfer-Regional-Bytes" (bare for us-east-1). + "DataTransfer-InterAZ-GB": { + "service": "AWSDataTransfer", + "query_region": "", + "attribute_filters": [{"key": "transferType", "value": "IntraRegion"}], + "unit": "GB", + "regionless_usagetype": True, + "usagetype_base": "DataTransfer-Regional-Bytes", + }, } diff --git a/tests/test_infracost_client.py b/tests/test_infracost_client.py index e7fd324..8177f1e 100644 --- a/tests/test_infracost_client.py +++ b/tests/test_infracost_client.py @@ -309,3 +309,110 @@ def test_data_transfer_unknown_region_upserts_nothing(monkeypatch): cache, "DataTransfer-InterRegion-GB", "moon-base-1") assert n == 0 cache.upsert.assert_not_called() + + +# --- Regionless single-usagetype: internet egress (tiered) + inter-AZ ---------- + +def _dt_tiered_product(usagetype, tiers, transfer_type="AWS Outbound", unit="GB"): + """tiers: list of (usd, start, end) — one price row each (preserves tiers).""" + return { + "productFamily": "", + "attributes": [ + {"key": "usagetype", "value": usagetype}, + {"key": "transferType", "value": transfer_type}, + ], + "prices": [{"USD": str(u), "unit": unit, + "startUsageAmount": str(s), + "endUsageAmount": (None if e is None else str(e))} + for (u, s, e) in tiers], + } + + +def test_internet_egress_preserves_tiers_us_east_1(monkeypatch): + """us-east-1 internet egress uses the unprefixed usagetype and keeps all tiers.""" + _set_creds(monkeypatch) + products = [ + _dt_tiered_product("DataTransfer-Out-Bytes", [ + (0.09, 0, 10240), (0.085, 10240, 51200), + (0.07, 51200, 153600), (0.05, 153600, None), + ]), + _dt_tiered_product("USW1-DataTransfer-Out-Bytes", [(0.09, 0, None)]), # other region + _dt_product("USE1-USW2-AWS-Out-Bytes", 0.02), # wrong metric + ] + upserted = [] + cache = MagicMock() + cache.upsert.side_effect = lambda p: upserted.append(p) + with patch.object(ic.requests, "post", return_value=_graphql_response(products)): + n = ic.InfracostClient().sync_to_cache(cache, "DataTransfer-Internet-Out-GB", "us-east-1") + assert n == 4 # four tiers of the unprefixed us-east-1 usagetype only + assert all(p.region == "us-east-1" and p.usage_metric == "DataTransfer-Internet-Out-GB" + for p in upserted) + assert sorted(p.price_usd for p in upserted) == [0.05, 0.07, 0.085, 0.09] + first = next(p for p in upserted if p.start_usage_amount == 0) + assert first.price_usd == pytest.approx(0.09) and first.end_usage_amount == pytest.approx(10240) + + +def test_internet_egress_prefixes_non_us_east_1(monkeypatch): + """A non-us-east-1 region selects the prefixed usagetype, not the bare one.""" + _set_creds(monkeypatch) + products = [ + _dt_tiered_product("DataTransfer-Out-Bytes", [(0.09, 0, None)]), # us-east-1 bare + _dt_tiered_product("USW1-DataTransfer-Out-Bytes", [(0.09, 0, None)]), # us-west-1 + ] + upserted = [] + cache = MagicMock() + cache.upsert.side_effect = lambda p: upserted.append(p) + with patch.object(ic.requests, "post", return_value=_graphql_response(products)): + n = ic.InfracostClient().sync_to_cache(cache, "DataTransfer-Internet-Out-GB", "us-west-1") + assert n == 1 + assert upserted[0].region == "us-west-1" + assert upserted[0].attributes["usagetype"] == "USW1-DataTransfer-Out-Bytes" + + +def test_inter_az_flat_rate_us_east_1(monkeypatch): + """Inter-AZ is a single flat $0.01/GB row under the unprefixed usagetype.""" + _set_creds(monkeypatch) + products = [ + _dt_product("DataTransfer-Regional-Bytes", 0.01, transfer_type="IntraRegion"), + _dt_product("APS4-DataTransfer-Regional-Bytes", 0.01, transfer_type="IntraRegion"), + ] + upserted = [] + cache = MagicMock() + cache.upsert.side_effect = lambda p: upserted.append(p) + with patch.object(ic.requests, "post", return_value=_graphql_response(products)): + n = ic.InfracostClient().sync_to_cache(cache, "DataTransfer-InterAZ-GB", "us-east-1") + assert n == 1 + assert upserted[0].price_usd == pytest.approx(0.01) + assert upserted[0].region == "us-east-1" + + +def test_data_transfer_internet_and_interaz_descriptors_present(): + d1 = ic.METRIC_DESCRIPTORS["DataTransfer-Internet-Out-GB"] + assert d1["query_region"] == "" and d1["regionless_usagetype"] is True + assert d1["usagetype_base"] == "DataTransfer-Out-Bytes" + d2 = ic.METRIC_DESCRIPTORS["DataTransfer-InterAZ-GB"] + assert d2["regionless_usagetype"] is True + assert d2["usagetype_base"] == "DataTransfer-Regional-Bytes" + + +def test_regionless_usagetype_queries_global_region(monkeypatch): + """Internet-egress / inter-AZ descriptors must issue the global (region="") query.""" + _set_creds(monkeypatch) + cache = MagicMock() + prod = _dt_tiered_product("DataTransfer-Out-Bytes", [(0.09, 0, None)]) + with patch.object(ic.requests, "post", return_value=_graphql_response([prod])) as post: + ic.InfracostClient().sync_to_cache(cache, "DataTransfer-Internet-Out-GB", "us-east-1") + assert post.call_args.kwargs["json"]["variables"]["region"] == "" + + +def test_regionless_usagetype_unknown_region_upserts_nothing(monkeypatch): + """An unmapped region resolves to the 'REGION_PREFIX' fallback target, which + matches no usagetype → nothing stored (falls back to seed).""" + _set_creds(monkeypatch) + products = [_dt_tiered_product("DataTransfer-Out-Bytes", [(0.09, 0, None)])] + cache = MagicMock() + with patch.object(ic.requests, "post", return_value=_graphql_response(products)): + n = ic.InfracostClient().sync_to_cache( + cache, "DataTransfer-Internet-Out-GB", "moon-base-1") + assert n == 0 + cache.upsert.assert_not_called()