Files
adaptive-multi-protocol-gat…/ampg/route_policy.py
T

169 lines
5.5 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from fnmatch import fnmatchcase
from .capabilities import DEFAULT_MAX_TIERS, TIER_ORDER
from .config import GatewayConfig, ProtocolConfig, RoutePolicyConfig, SiteConfig
@dataclass(frozen=True)
class RouteExposure:
site_id: str
protocol: str
route_index: int
match: str
source: str
tier: str
identity: str
payments: str
realtime: bool
public_allowed: bool
max_tier: str
status: str
reason: str
@dataclass(frozen=True)
class RouteIssue:
site_id: str
route_index: int
match: str
source: str
tier: str
code: str
message: str
def route_exposures(config: GatewayConfig) -> list[RouteExposure]:
exposures: list[RouteExposure] = []
for site in config.sites:
enabled_protocols = [protocol for protocol in site.protocols.values() if protocol.enabled]
for route_index, route_policy in enumerate(site.interactions.routes):
for protocol in enabled_protocols:
status, reason = route_decision(site, protocol, route_policy)
exposures.append(
RouteExposure(
site_id=site.id,
protocol=protocol.name,
route_index=route_index,
match=route_policy.match,
source=route_policy.source,
tier=route_policy.tier,
identity=route_policy.identity,
payments=route_policy.payments,
realtime=route_policy.realtime,
public_allowed=route_policy.public_allowed,
max_tier=protocol_max_tier(protocol),
status=status,
reason=reason,
)
)
return exposures
def route_issues(config: GatewayConfig) -> list[RouteIssue]:
issues: list[RouteIssue] = []
exposures = route_exposures(config)
by_site_route_index = {
(exposure.site_id, exposure.route_index): []
for exposure in exposures
}
for exposure in exposures:
by_site_route_index[(exposure.site_id, exposure.route_index)].append(exposure)
for site in config.sites:
for route_index, route_policy in enumerate(site.interactions.routes):
if not route_has_public_intent(site, route_policy):
continue
decisions = by_site_route_index.get((site.id, route_index), [])
if any(decision.status == "exposed" for decision in decisions):
continue
issues.append(
RouteIssue(
site_id=site.id,
route_index=route_index,
match=route_policy.match,
source=route_policy.source,
tier=route_policy.tier,
code="no-compatible-protocol",
message="public route is not exposed by any enabled protocol",
)
)
return issues
def route_exposed(site: SiteConfig, protocol: ProtocolConfig, route_policy: RoutePolicyConfig) -> bool:
status, _reason = route_decision(site, protocol, route_policy)
return status == "exposed"
def route_decision(
site: SiteConfig,
protocol: ProtocolConfig,
route_policy: RoutePolicyConfig,
) -> tuple[str, str]:
if route_policy.tier == "internal":
return "skipped", "tier is internal"
if not route_policy.public_allowed:
return "skipped", "public_allowed=false"
deny_route = denied_by(site, route_policy)
if deny_route:
return "skipped", f"matched deny_route {deny_route}"
max_tier = protocol_max_tier(protocol)
if tier_rank(route_policy.tier) > tier_rank(max_tier):
return "skipped", f"tier {route_policy.tier} exceeds protocol max_tier {max_tier}"
return "exposed", "public route within protocol max_tier"
def route_has_public_intent(site: SiteConfig, route_policy: RoutePolicyConfig) -> bool:
return (
route_policy.tier != "internal"
and route_policy.public_allowed
and denied_by(site, route_policy) is None
)
def denied_by(site: SiteConfig, route_policy: RoutePolicyConfig) -> str | None:
route_variants = _pattern_variants(route_policy.match)
for deny_route in site.interactions.deny_routes:
deny_pattern = _normalize_pattern(deny_route)
if any(fnmatchcase(route_variant, deny_pattern) for route_variant in route_variants):
return deny_route
return None
def protocol_max_tier(protocol: ProtocolConfig) -> str:
return str(protocol.options.get("max_tier", DEFAULT_MAX_TIERS.get(protocol.name, "static")))
def tier_rank(tier: str) -> int:
try:
return TIER_ORDER[tier]
except KeyError as exc:
raise ValueError(f"unsupported interaction tier {tier!r}") from exc
def fixture_path(match: str) -> str:
value = _normalize_pattern(match).replace("*", "")
if not value or value == "/":
return "/"
return value if value.endswith("/") else value + "/"
def _pattern_variants(pattern: str) -> tuple[str, ...]:
normalized = _normalize_pattern(pattern)
fixture = fixture_path(pattern)
trimmed = fixture.rstrip("/") or "/"
return tuple(dict.fromkeys((normalized, fixture, trimmed)))
def _normalize_pattern(pattern: str) -> str:
value = pattern.strip()
if not value.startswith("/"):
value = "/" + value
return value