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
3 changes: 3 additions & 0 deletions data/seed/aws_pricelist_seed.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@
{"vendor": "aws", "service": "AWSKMS", "region": "us-east-1", "usage_metric": "KMS-Key-Month", "unit": "Keys", "price_usd": 1.00, "source": "seed", "description": "KMS customer-managed key monthly pricing ($1.00 per key-month)"},
{"vendor": "aws", "service": "AWSKMS", "region": "us-east-1", "usage_metric": "KMS-API-Request", "unit": "requests", "price_usd": 0, "start_usage_amount": 0, "end_usage_amount": 20000, "source": "seed", "description": "KMS API request free tier: first 20,000 requests/month at $0"},
{"vendor": "aws", "service": "AWSKMS", "region": "us-east-1", "usage_metric": "KMS-API-Request", "unit": "requests", "price_usd": 0.000003, "start_usage_amount": 20000, "source": "seed", "description": "KMS symmetric API request pricing ($0.03 per 10K requests beyond free tier)"},
{"vendor": "aws", "service": "AWSWAF", "region": "us-east-1", "usage_metric": "WAF-WebACL-Month", "unit": "Months", "price_usd": 5.00, "source": "seed", "description": "WAFv2 web ACL monthly pricing ($5.00 per web-ACL-month)"},
{"vendor": "aws", "service": "AWSWAF", "region": "us-east-1", "usage_metric": "WAF-Rule-Month", "unit": "Rules", "price_usd": 1.00, "source": "seed", "description": "WAFv2 rule monthly pricing ($1.00 per rule-month)"},
{"vendor": "aws", "service": "AWSWAF", "region": "us-east-1", "usage_metric": "WAF-Request", "unit": "requests", "price_usd": 0.0000006, "source": "seed", "description": "WAFv2 request-inspection pricing ($0.60 per million requests)"},
{"vendor": "aws", "service": "AmazonCloudWatch", "region": "us-east-1", "usage_metric": "CloudWatch-Metric-Month", "unit": "Metrics", "price_usd": 0.30, "source": "seed", "description": "CloudWatch custom metric pricing ($0.30 per metric-month, standard resolution)"},
{"vendor": "aws", "service": "AmazonCloudWatch", "region": "us-east-1", "usage_metric": "CloudWatch-Alarm-Month", "unit": "Alarms", "price_usd": 0.10, "source": "seed", "description": "CloudWatch alarm pricing ($0.10 per standard-resolution alarm-month)"},
{"vendor": "aws", "service": "AmazonCloudWatch", "region": "us-east-1", "usage_metric": "CloudWatch-GetMetricData", "unit": "Metrics", "price_usd": 0, "start_usage_amount": 0, "end_usage_amount": 1000000, "source": "seed", "description": "CloudWatch GetMetricData free tier: first 1M metrics requested per month at $0"},
Expand Down
4 changes: 4 additions & 0 deletions infra_cost_model/resources/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from .azure import AzureFunction, CosmosDB, APIManagement, AzureOpenAI, AzureBlobStorage
from .misc_services import SecretsManagerSecret, ECRRepository, Route53Zone
from .kms import KMSKey
from .waf import WAFv2WebACL
from .data_transfer import DataTransferNode


Expand Down Expand Up @@ -238,6 +239,9 @@ def extract(cls, resource_address: str, resource_data: dict,
# AWS KMS
ResourceRegistry.register(KMSKey)

# AWS WAFv2
ResourceRegistry.register(WAFv2WebACL)

# AWS Data Transfer (usage-derived node, no IaC resource)
ResourceRegistry.register(DataTransferNode)

Expand Down
103 changes: 103 additions & 0 deletions infra_cost_model/resources/waf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""AWS WAFv2 web ACL resource model.

Native handler for AWS WAFv2 web ACLs (``aws_wafv2_web_acl``).
- Recurring cost: $/web-ACL-month + $/rule-month (per rule in the ACL)
- Usage cost: $/request inspected

The optional add-on SKUs (Bot Control, Fraud Control / Account Takeover
Prevention, CAPTCHA, intelligent threat mitigation) are separate products and
out of scope here — the same way the ALB handler defers NLB and the KMS handler
defers asymmetric-key requests. Classic WAF (``aws_waf_web_acl``) is a distinct,
retired product and is intentionally not matched.
"""

from typing import Optional
from infra_cost_model.pricing.catalog import PricingCatalog
from .types import RoutingResource, ResourceExtract


class WAFv2WebACL(RoutingResource):
"""AWS WAFv2 web ACL - routing node with per-ACL + per-rule + per-request pricing."""

@property
def valid_metrics(self) -> list[str]:
return ["webAcls", "rules", "requests"]

@property
def catalog_metrics(self) -> dict[str, str]:
return {
"webAcls": "WAF-WebACL-Month",
"rules": "WAF-Rule-Month",
"requests": "WAF-Request",
}

@classmethod
def from_address(cls, resource_address: str) -> Optional["WAFv2WebACL"]:
if (resource_address.startswith("aws_wafv2_web_acl.") or
resource_address.startswith("aws.wafv2.WebAcl:") or
resource_address.startswith("aws:wafv2:WebAcl:") or
"WAFv2::WebACL:" in resource_address):
return cls()
return None

@classmethod
def extract_tf(cls, resource: dict) -> ResourceExtract:
values = resource.get("values", {})
return ResourceExtract(
resource_address=resource.get("address", ""),
node_type="routing", provider="aws", service="AWSWAF",
region=values.get("region"),
config={
"name": values.get("name"),
"scope": values.get("scope", "REGIONAL"),
"ruleCount": len(values.get("rule") or []),
},
)

@classmethod
def extract_pulumi(cls, resource: dict) -> ResourceExtract:
inputs = resource.get("inputs", {})
return ResourceExtract(
resource_address=resource.get("id", ""),
node_type="routing", provider="aws", service="AWSWAF",
region=inputs.get("region"),
config={
"name": inputs.get("name"),
"scope": inputs.get("scope", "REGIONAL"),
"ruleCount": len(inputs.get("rules") or []),
},
)

@classmethod
def extract_cdk(cls, resource: dict) -> ResourceExtract:
properties = resource.get("Properties", {})
return ResourceExtract(
resource_address=resource.get("LogicalId", ""),
node_type="routing", provider="aws", service="AWSWAF",
region=None,
config={
"name": properties.get("Name"),
"scope": properties.get("Scope", "REGIONAL"),
"ruleCount": len(properties.get("Rules") or []),
},
)


def _waf_cost(web_acls=1, rules=0, requests=0, *,
catalog=None, provider: str = "aws", region: str) -> float:
if catalog is None:
catalog = PricingCatalog()
total = 0.0
if web_acls > 0:
r = catalog.query(provider, "AWSWAF", region, "WAF-WebACL-Month", web_acls)
if r and hasattr(r, "total_cost"):
total += r.total_cost
if rules > 0:
r = catalog.query(provider, "AWSWAF", region, "WAF-Rule-Month", rules)
if r and hasattr(r, "total_cost"):
total += r.total_cost
if requests > 0:
r = catalog.query(provider, "AWSWAF", region, "WAF-Request", requests)
if r and hasattr(r, "total_cost"):
total += r.total_cost
return total
181 changes: 181 additions & 0 deletions tests/test_waf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"""Tests for the AWS WAFv2 web ACL resource handler (Issue #234)."""
import pytest
from infra_cost_model.pricing.catalog import PricingCatalog
from infra_cost_model.resources.waf import WAFv2WebACL, _waf_cost


class TestWAFAddress:
def test_from_address_terraform(self):
r = WAFv2WebACL.from_address("aws_wafv2_web_acl.admin")
assert r is not None and r.node_type == "routing"

def test_from_address_pulumi(self):
r = WAFv2WebACL.from_address("aws.wafv2.WebAcl:edge")
assert r is not None and r.node_type == "routing"

def test_from_address_cdk(self):
# CDK synthetic address format: "<Type>:<LogicalId>"
r = WAFv2WebACL.from_address("AWS::WAFv2::WebACL:EdgeAcl")
assert r is not None and r.node_type == "routing"

def test_from_address_unrelated(self):
assert WAFv2WebACL.from_address("aws_lb.public") is None
assert WAFv2WebACL.from_address("aws_kms_key.main") is None
# Classic WAF (aws_waf_web_acl) is a distinct, retired product; must not match.
assert WAFv2WebACL.from_address("aws_waf_web_acl.legacy") is None
# A rule group is a distinct resource, not the web ACL.
assert WAFv2WebACL.from_address("aws_wafv2_rule_group.rg") is None


class TestWAFExtract:
def test_extract_tf(self):
resource = {
"address": "aws_wafv2_web_acl.admin",
"values": {
"name": "admin-waf",
"scope": "REGIONAL",
"region": "us-east-1",
"default_action": [{"allow": [{}]}],
"rule": [{"name": "common"}, {"name": "bad-inputs"}],
},
}
result = WAFv2WebACL.extract_tf(resource)
assert result.node_type == "routing"
assert result.provider == "aws"
assert result.service == "AWSWAF"
assert result.region == "us-east-1"
assert result.config["name"] == "admin-waf"
assert result.config["scope"] == "REGIONAL"
assert result.config["ruleCount"] == 2

def test_extract_tf_defaults(self):
resource = {
"address": "aws_wafv2_web_acl.simple",
"values": {"region": "us-west-2"},
}
result = WAFv2WebACL.extract_tf(resource)
assert result.config["name"] is None
assert result.config["scope"] == "REGIONAL"
assert result.config["ruleCount"] == 0

def test_extract_pulumi(self):
resource = {
"id": "aws.wafv2.WebAcl:edge",
"inputs": {
"name": "edge-waf",
"scope": "CLOUDFRONT",
"region": "us-east-1",
"rules": [{"name": "a"}, {"name": "b"}, {"name": "c"}],
},
}
result = WAFv2WebACL.extract_pulumi(resource)
assert result.service == "AWSWAF"
assert result.config["name"] == "edge-waf"
assert result.config["scope"] == "CLOUDFRONT"
assert result.config["ruleCount"] == 3

def test_extract_pulumi_defaults(self):
resource = {"id": "aws.wafv2.WebAcl:plain", "inputs": {}}
result = WAFv2WebACL.extract_pulumi(resource)
assert result.config["name"] is None
assert result.config["scope"] == "REGIONAL"
assert result.config["ruleCount"] == 0

def test_extract_cdk(self):
resource = {
"Type": "AWS::WAFv2::WebACL",
"LogicalId": "EdgeAcl",
"Properties": {
"Name": "cdk-waf",
"Scope": "CLOUDFRONT",
"Rules": [{"Name": "a"}],
},
}
result = WAFv2WebACL.extract_cdk(resource)
assert result.service == "AWSWAF"
assert result.config["name"] == "cdk-waf"
assert result.config["scope"] == "CLOUDFRONT"
assert result.config["ruleCount"] == 1

def test_extract_cdk_defaults(self):
resource = {
"Type": "AWS::WAFv2::WebACL",
"LogicalId": "PlainAcl",
"Properties": {},
}
result = WAFv2WebACL.extract_cdk(resource)
assert result.config["name"] is None
assert result.config["scope"] == "REGIONAL"
assert result.config["ruleCount"] == 0


class TestWAFNodeAndMetrics:
def test_node_type(self):
assert WAFv2WebACL().node_type == "routing"

def test_valid_metrics(self):
waf = WAFv2WebACL()
assert "webAcls" in waf.valid_metrics
assert "rules" in waf.valid_metrics
assert "requests" in waf.valid_metrics

def test_catalog_metrics(self):
waf = WAFv2WebACL()
assert waf.catalog_metrics["webAcls"] == "WAF-WebACL-Month"
assert waf.catalog_metrics["rules"] == "WAF-Rule-Month"
assert waf.catalog_metrics["requests"] == "WAF-Request"


class TestWAFPricing:
def test_pricing_single_web_acl(self):
catalog = PricingCatalog(seed=True)
cost = _waf_cost(web_acls=1, rules=0, requests=0,
catalog=catalog, region="us-east-1")
assert cost == pytest.approx(5.00, rel=0.01)

def test_pricing_rules(self):
catalog = PricingCatalog(seed=True)
# 1 web ACL ($5) + 4 rules ($4) = $9
cost = _waf_cost(web_acls=1, rules=4, requests=0,
catalog=catalog, region="us-east-1")
assert cost == pytest.approx(9.00, rel=0.01)

def test_pricing_requests(self):
catalog = PricingCatalog(seed=True)
# 1M requests at $0.60/million
cost = _waf_cost(web_acls=0, rules=0, requests=1_000_000,
catalog=catalog, region="us-east-1")
assert cost == pytest.approx(0.60, rel=0.01)

def test_pricing_combined(self):
catalog = PricingCatalog(seed=True)
# 1 web ACL ($5) + 4 rules ($4) + 1M requests ($0.60) = $9.60
cost = _waf_cost(web_acls=1, rules=4, requests=1_000_000,
catalog=catalog, region="us-east-1")
assert cost == pytest.approx(9.60, rel=0.01)

def test_pricing_zero_usage(self):
catalog = PricingCatalog(seed=True)
cost = _waf_cost(web_acls=0, rules=0, requests=0,
catalog=catalog, region="us-east-1")
assert cost == 0.0


class TestWAFRegistry:
def test_registry_from_address(self):
from infra_cost_model.resources.registry import ResourceRegistry
assert ResourceRegistry.from_address("aws_wafv2_web_acl.admin") == WAFv2WebACL

def test_extract_via_registry(self):
from infra_cost_model.resources.registry import ResourceRegistry
resource = {
"address": "aws_wafv2_web_acl.admin",
"values": {"name": "admin-waf", "scope": "REGIONAL", "region": "us-east-1"},
}
result = ResourceRegistry.extract(
"aws_wafv2_web_acl.admin", resource, "terraform"
)
assert result is not None
assert result["provider"] == "aws"
assert result["service"] == "AWSWAF"
assert result["nodeType"] == "routing"
Loading