cloudwright-ai 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cloudwright/__init__.py +100 -0
- cloudwright/adapters/__init__.py +79 -0
- cloudwright/adapters/aws.py +314 -0
- cloudwright/adapters/azure.py +274 -0
- cloudwright/adapters/gcp.py +305 -0
- cloudwright/analyzer.py +180 -0
- cloudwright/architect.py +603 -0
- cloudwright/catalog/__init__.py +26 -0
- cloudwright/catalog/formula.py +257 -0
- cloudwright/catalog/refresh.py +248 -0
- cloudwright/catalog/store.py +672 -0
- cloudwright/cost.py +281 -0
- cloudwright/data/catalog.db +0 -0
- cloudwright/data/registry/analytics.yaml +32 -0
- cloudwright/data/registry/cache.yaml +64 -0
- cloudwright/data/registry/compute.yaml +78 -0
- cloudwright/data/registry/containers.yaml +116 -0
- cloudwright/data/registry/database_nosql.yaml +33 -0
- cloudwright/data/registry/database_relational.yaml +96 -0
- cloudwright/data/registry/messaging.yaml +47 -0
- cloudwright/data/registry/ml.yaml +32 -0
- cloudwright/data/registry/networking_api.yaml +31 -0
- cloudwright/data/registry/networking_cdn.yaml +29 -0
- cloudwright/data/registry/networking_dns.yaml +31 -0
- cloudwright/data/registry/networking_lb.yaml +77 -0
- cloudwright/data/registry/orchestration.yaml +32 -0
- cloudwright/data/registry/security_auth.yaml +29 -0
- cloudwright/data/registry/security_waf.yaml +31 -0
- cloudwright/data/registry/serverless.yaml +74 -0
- cloudwright/data/registry/storage_block.yaml +31 -0
- cloudwright/data/registry/storage_object.yaml +71 -0
- cloudwright/data/registry/streaming.yaml +32 -0
- cloudwright/data/templates/_index.yaml +99 -0
- cloudwright/data/templates/azure-microservices.yaml +81 -0
- cloudwright/data/templates/azure-serverless-api.yaml +49 -0
- cloudwright/data/templates/azure-three-tier-web.yaml +59 -0
- cloudwright/data/templates/batch-processing.yaml +103 -0
- cloudwright/data/templates/data-lake.yaml +91 -0
- cloudwright/data/templates/event-driven.yaml +89 -0
- cloudwright/data/templates/gcp-microservices.yaml +90 -0
- cloudwright/data/templates/gcp-serverless-api.yaml +48 -0
- cloudwright/data/templates/gcp-three-tier-web.yaml +59 -0
- cloudwright/data/templates/microservices.yaml +131 -0
- cloudwright/data/templates/ml_pipeline.yaml +60 -0
- cloudwright/data/templates/serverless_api.yaml +60 -0
- cloudwright/data/templates/static-site.yaml +92 -0
- cloudwright/data/templates/three_tier_web.yaml +59 -0
- cloudwright/differ.py +237 -0
- cloudwright/drift.py +90 -0
- cloudwright/evolution.py +63 -0
- cloudwright/exporter/__init__.py +105 -0
- cloudwright/exporter/aibom.py +104 -0
- cloudwright/exporter/cloudformation.py +218 -0
- cloudwright/exporter/compliance_report.py +161 -0
- cloudwright/exporter/d2.py +82 -0
- cloudwright/exporter/mermaid.py +69 -0
- cloudwright/exporter/sbom.py +68 -0
- cloudwright/exporter/terraform.py +1003 -0
- cloudwright/importer/__init__.py +78 -0
- cloudwright/importer/cloudformation.py +368 -0
- cloudwright/importer/terraform_state.py +320 -0
- cloudwright/importer/utils.py +45 -0
- cloudwright/linter.py +255 -0
- cloudwright/llm/__init__.py +45 -0
- cloudwright/llm/anthropic.py +42 -0
- cloudwright/llm/base.py +14 -0
- cloudwright/llm/openai.py +42 -0
- cloudwright/plugins.py +73 -0
- cloudwright/policy.py +204 -0
- cloudwright/providers/__init__.py +59 -0
- cloudwright/providers/aws.py +172 -0
- cloudwright/providers/azure.py +151 -0
- cloudwright/providers/gcp.py +151 -0
- cloudwright/py.typed +0 -0
- cloudwright/registry.py +223 -0
- cloudwright/scorer.py +369 -0
- cloudwright/spec.py +185 -0
- cloudwright/validator.py +841 -0
- cloudwright_ai-0.1.0.dist-info/METADATA +541 -0
- cloudwright_ai-0.1.0.dist-info/RECORD +81 -0
- cloudwright_ai-0.1.0.dist-info/WHEEL +4 -0
cloudwright/__init__.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Cloudwright — Architecture intelligence for cloud engineers."""
|
|
2
|
+
|
|
3
|
+
from cloudwright.spec import (
|
|
4
|
+
Alternative,
|
|
5
|
+
ArchSpec,
|
|
6
|
+
ArchVersion,
|
|
7
|
+
Component,
|
|
8
|
+
ComponentChange,
|
|
9
|
+
ComponentCost,
|
|
10
|
+
Connection,
|
|
11
|
+
ConnectionChange,
|
|
12
|
+
Constraints,
|
|
13
|
+
CostEstimate,
|
|
14
|
+
DiffResult,
|
|
15
|
+
ValidationCheck,
|
|
16
|
+
ValidationResult,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Alternative",
|
|
23
|
+
"ArchSpec",
|
|
24
|
+
"ArchVersion",
|
|
25
|
+
"Architect",
|
|
26
|
+
"Catalog",
|
|
27
|
+
"ConversationSession",
|
|
28
|
+
"Component",
|
|
29
|
+
"ComponentChange",
|
|
30
|
+
"ComponentCost",
|
|
31
|
+
"Connection",
|
|
32
|
+
"ConnectionChange",
|
|
33
|
+
"Constraints",
|
|
34
|
+
"CostEstimate",
|
|
35
|
+
"create_version",
|
|
36
|
+
"detect_drift",
|
|
37
|
+
"diff_versions",
|
|
38
|
+
"Differ",
|
|
39
|
+
"DiffResult",
|
|
40
|
+
"DriftReport",
|
|
41
|
+
"get_timeline",
|
|
42
|
+
"LintWarning",
|
|
43
|
+
"lint",
|
|
44
|
+
"ValidationCheck",
|
|
45
|
+
"ValidationResult",
|
|
46
|
+
"Validator",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def __getattr__(name: str):
|
|
51
|
+
# Lazy imports for heavy modules that need LLM/DB
|
|
52
|
+
if name == "Architect":
|
|
53
|
+
from cloudwright.architect import Architect
|
|
54
|
+
|
|
55
|
+
return Architect
|
|
56
|
+
if name == "ConversationSession":
|
|
57
|
+
from cloudwright.architect import ConversationSession
|
|
58
|
+
|
|
59
|
+
return ConversationSession
|
|
60
|
+
if name == "Catalog":
|
|
61
|
+
from cloudwright.catalog import Catalog
|
|
62
|
+
|
|
63
|
+
return Catalog
|
|
64
|
+
if name == "Differ":
|
|
65
|
+
from cloudwright.differ import Differ
|
|
66
|
+
|
|
67
|
+
return Differ
|
|
68
|
+
if name == "Validator":
|
|
69
|
+
from cloudwright.validator import Validator
|
|
70
|
+
|
|
71
|
+
return Validator
|
|
72
|
+
if name == "lint":
|
|
73
|
+
from cloudwright.linter import lint
|
|
74
|
+
|
|
75
|
+
return lint
|
|
76
|
+
if name == "LintWarning":
|
|
77
|
+
from cloudwright.linter import LintWarning
|
|
78
|
+
|
|
79
|
+
return LintWarning
|
|
80
|
+
if name == "detect_drift":
|
|
81
|
+
from cloudwright.drift import detect_drift
|
|
82
|
+
|
|
83
|
+
return detect_drift
|
|
84
|
+
if name == "DriftReport":
|
|
85
|
+
from cloudwright.drift import DriftReport
|
|
86
|
+
|
|
87
|
+
return DriftReport
|
|
88
|
+
if name == "create_version":
|
|
89
|
+
from cloudwright.evolution import create_version
|
|
90
|
+
|
|
91
|
+
return create_version
|
|
92
|
+
if name == "get_timeline":
|
|
93
|
+
from cloudwright.evolution import get_timeline
|
|
94
|
+
|
|
95
|
+
return get_timeline
|
|
96
|
+
if name == "diff_versions":
|
|
97
|
+
from cloudwright.evolution import diff_versions
|
|
98
|
+
|
|
99
|
+
return diff_versions
|
|
100
|
+
raise AttributeError(f"module 'cloudwright' has no attribute {name!r}")
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Cloud pricing adapters — fetch live pricing data from provider APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ssl
|
|
6
|
+
import urllib.request
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Iterator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _ssl_context() -> ssl.SSLContext:
|
|
13
|
+
"""Create an SSL context using certifi CA bundle (macOS workaround)."""
|
|
14
|
+
try:
|
|
15
|
+
import certifi
|
|
16
|
+
|
|
17
|
+
return ssl.create_default_context(cafile=certifi.where())
|
|
18
|
+
except ImportError:
|
|
19
|
+
return ssl.create_default_context()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def urlopen_safe(req: urllib.request.Request, timeout: int = 30) -> bytes:
|
|
23
|
+
"""urlopen with certifi SSL — use this instead of raw urllib.request.urlopen."""
|
|
24
|
+
ctx = _ssl_context()
|
|
25
|
+
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
|
|
26
|
+
return resp.read()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class InstancePrice:
|
|
31
|
+
"""Pricing record for a compute instance type."""
|
|
32
|
+
|
|
33
|
+
instance_type: str
|
|
34
|
+
region: str
|
|
35
|
+
vcpus: int
|
|
36
|
+
memory_gb: float
|
|
37
|
+
price_per_hour: float
|
|
38
|
+
price_type: str = "on_demand" # on_demand | reserved_1yr | reserved_3yr | spot
|
|
39
|
+
os: str = "linux"
|
|
40
|
+
storage_desc: str = ""
|
|
41
|
+
network_bandwidth: str = ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ManagedServicePrice:
|
|
46
|
+
"""Pricing record for a managed service tier."""
|
|
47
|
+
|
|
48
|
+
service: str
|
|
49
|
+
tier_name: str
|
|
50
|
+
price_per_hour: float
|
|
51
|
+
price_per_month: float
|
|
52
|
+
description: str = ""
|
|
53
|
+
vcpus: int = 0
|
|
54
|
+
memory_gb: float = 0.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PricingAdapter(ABC):
|
|
58
|
+
"""Abstract base for cloud pricing data adapters.
|
|
59
|
+
|
|
60
|
+
Subclasses fetch live pricing from provider-specific APIs and return
|
|
61
|
+
normalized InstancePrice / ManagedServicePrice records for catalog ingestion.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
provider: str # "aws" | "gcp" | "azure"
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
def fetch_instance_pricing(self, region: str) -> Iterator[InstancePrice]:
|
|
68
|
+
"""Yield compute instance prices for the given region."""
|
|
69
|
+
|
|
70
|
+
@abstractmethod
|
|
71
|
+
def fetch_managed_service_pricing(self, service: str, region: str) -> list[ManagedServicePrice]:
|
|
72
|
+
"""Return pricing tiers for a managed service in the given region."""
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
def supported_managed_services(self) -> list[str]:
|
|
76
|
+
"""List of managed service keys this adapter can fetch pricing for."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
__all__ = ["InstancePrice", "ManagedServicePrice", "PricingAdapter"]
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""AWS Pricing API adapter.
|
|
2
|
+
|
|
3
|
+
Streams EC2 instance pricing from the AWS Bulk Pricing CSV (region-scoped)
|
|
4
|
+
and parses managed service pricing from the AWS JSON Pricing API for
|
|
5
|
+
Lambda, S3, RDS, and DynamoDB.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
import io
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
import urllib.error
|
|
15
|
+
import urllib.request
|
|
16
|
+
from typing import Any, Iterator
|
|
17
|
+
|
|
18
|
+
from cloudwright.adapters import InstancePrice, ManagedServicePrice, PricingAdapter, urlopen_safe
|
|
19
|
+
|
|
20
|
+
_PRICING_BASE = "https://pricing.us-east-1.amazonaws.com"
|
|
21
|
+
_TIMEOUT = 30 # seconds
|
|
22
|
+
|
|
23
|
+
# AWS region code -> location name used in the pricing API
|
|
24
|
+
_REGION_TO_LOCATION: dict[str, str] = {
|
|
25
|
+
"us-east-1": "US East (N. Virginia)",
|
|
26
|
+
"us-east-2": "US East (Ohio)",
|
|
27
|
+
"us-west-1": "US West (N. California)",
|
|
28
|
+
"us-west-2": "US West (Oregon)",
|
|
29
|
+
"eu-west-1": "EU (Ireland)",
|
|
30
|
+
"eu-west-2": "EU (London)",
|
|
31
|
+
"eu-central-1": "EU (Frankfurt)",
|
|
32
|
+
"ap-southeast-1": "Asia Pacific (Singapore)",
|
|
33
|
+
"ap-southeast-2": "Asia Pacific (Sydney)",
|
|
34
|
+
"ap-northeast-1": "Asia Pacific (Tokyo)",
|
|
35
|
+
"ap-south-1": "Asia Pacific (Mumbai)",
|
|
36
|
+
"ca-central-1": "Canada (Central)",
|
|
37
|
+
"sa-east-1": "South America (Sao Paulo)",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse_memory_gib(mem_str: str) -> float:
|
|
42
|
+
"""Parse '16 GiB' or '16,384 MiB' -> float GiB."""
|
|
43
|
+
m = re.match(r"([\d,]+(?:\.\d+)?)\s*(GiB|MiB)", mem_str.strip())
|
|
44
|
+
if not m:
|
|
45
|
+
return 0.0
|
|
46
|
+
value = float(m.group(1).replace(",", ""))
|
|
47
|
+
return value / 1024 if m.group(2) == "MiB" else value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _safe_int(val: str) -> int:
|
|
51
|
+
try:
|
|
52
|
+
return int(val)
|
|
53
|
+
except (ValueError, TypeError):
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _safe_float(val: str | float) -> float:
|
|
58
|
+
try:
|
|
59
|
+
return float(val)
|
|
60
|
+
except (ValueError, TypeError):
|
|
61
|
+
return 0.0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _first_price(terms: dict) -> float:
|
|
65
|
+
"""Extract the first USD price from a terms dict."""
|
|
66
|
+
for term in terms.values():
|
|
67
|
+
for dim in term.get("priceDimensions", {}).values():
|
|
68
|
+
p = _safe_float(dim.get("pricePerUnit", {}).get("USD", "0"))
|
|
69
|
+
if p > 0:
|
|
70
|
+
return p
|
|
71
|
+
return 0.0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class AWSPricingAdapter(PricingAdapter):
|
|
75
|
+
"""Fetches AWS pricing from the bulk pricing API.
|
|
76
|
+
|
|
77
|
+
EC2 pricing is streamed from the CSV index (large file; streamed to avoid
|
|
78
|
+
loading the full ~1 GB decompressed CSV into memory at once).
|
|
79
|
+
Managed service pricing uses the JSON API.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
provider = "aws"
|
|
83
|
+
|
|
84
|
+
def __init__(self, timeout: int = _TIMEOUT):
|
|
85
|
+
self._timeout = timeout
|
|
86
|
+
|
|
87
|
+
# Public interface
|
|
88
|
+
|
|
89
|
+
def fetch_instance_pricing(self, region: str = "us-east-1") -> Iterator[InstancePrice]:
|
|
90
|
+
"""Stream on-demand Linux EC2 instance prices for the given region."""
|
|
91
|
+
url = f"{_PRICING_BASE}/offers/v1.0/aws/AmazonEC2/current/{region}/index.csv"
|
|
92
|
+
data = self._get(url)
|
|
93
|
+
yield from self._parse_ec2_csv(data, region)
|
|
94
|
+
|
|
95
|
+
def fetch_managed_service_pricing(self, service: str, region: str = "us-east-1") -> list[ManagedServicePrice]:
|
|
96
|
+
"""Return pricing tiers for a supported managed service."""
|
|
97
|
+
parsers = {
|
|
98
|
+
"lambda": self._parse_lambda,
|
|
99
|
+
"s3": self._parse_s3,
|
|
100
|
+
"rds": self._parse_rds,
|
|
101
|
+
"dynamodb": self._parse_dynamodb,
|
|
102
|
+
}
|
|
103
|
+
handler = parsers.get(service)
|
|
104
|
+
return handler(region) if handler else []
|
|
105
|
+
|
|
106
|
+
def supported_managed_services(self) -> list[str]:
|
|
107
|
+
return ["lambda", "s3", "rds", "dynamodb"]
|
|
108
|
+
|
|
109
|
+
# EC2 CSV parsing
|
|
110
|
+
|
|
111
|
+
def _parse_ec2_csv(self, data: bytes, region: str) -> Iterator[InstancePrice]:
|
|
112
|
+
"""Parse EC2 pricing CSV.
|
|
113
|
+
|
|
114
|
+
The CSV starts with several metadata lines before the actual header row
|
|
115
|
+
(the one whose first field is 'SKU'). We scan for it and then parse
|
|
116
|
+
the remainder as standard CSV.
|
|
117
|
+
"""
|
|
118
|
+
text = data.decode("utf-8", errors="replace")
|
|
119
|
+
lines = text.splitlines()
|
|
120
|
+
|
|
121
|
+
# Find the header row — first line where field 0 is 'SKU' (quoted or bare)
|
|
122
|
+
header_idx = 0
|
|
123
|
+
for i, line in enumerate(lines):
|
|
124
|
+
stripped = line.strip().strip('"')
|
|
125
|
+
if stripped.startswith("SKU"):
|
|
126
|
+
header_idx = i
|
|
127
|
+
break
|
|
128
|
+
|
|
129
|
+
reader = csv.DictReader(io.StringIO("\n".join(lines[header_idx:])))
|
|
130
|
+
|
|
131
|
+
for row in reader:
|
|
132
|
+
if (
|
|
133
|
+
row.get("TermType") == "OnDemand"
|
|
134
|
+
and row.get("Operating System", "Linux") in ("Linux", "")
|
|
135
|
+
and row.get("Tenancy", "Shared") == "Shared"
|
|
136
|
+
and row.get("CapacityStatus", "Used") == "Used"
|
|
137
|
+
and row.get("Pre Installed S/W", "NA") in ("NA", "")
|
|
138
|
+
and row.get("productFamily", "Compute Instance") == "Compute Instance"
|
|
139
|
+
):
|
|
140
|
+
price = _safe_float(row.get("PricePerUnit", "0"))
|
|
141
|
+
if price <= 0:
|
|
142
|
+
continue
|
|
143
|
+
yield InstancePrice(
|
|
144
|
+
instance_type=row.get("Instance Type", ""),
|
|
145
|
+
region=region,
|
|
146
|
+
vcpus=_safe_int(row.get("vCPU", "0")),
|
|
147
|
+
memory_gb=_parse_memory_gib(row.get("Memory", "0 GiB")),
|
|
148
|
+
price_per_hour=price,
|
|
149
|
+
price_type="on_demand",
|
|
150
|
+
os="linux",
|
|
151
|
+
storage_desc=row.get("Storage", ""),
|
|
152
|
+
network_bandwidth=row.get("Network Performance", ""),
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# JSON API parsing
|
|
156
|
+
|
|
157
|
+
def _fetch_json(self, offer_code: str, region: str) -> dict[str, Any]:
|
|
158
|
+
url = f"{_PRICING_BASE}/offers/v1.0/aws/{offer_code}/current/{region}/index.json"
|
|
159
|
+
return json.loads(self._get(url))
|
|
160
|
+
|
|
161
|
+
def _parse_lambda(self, region: str) -> list[ManagedServicePrice]:
|
|
162
|
+
data = self._fetch_json("AWSLambda", region)
|
|
163
|
+
location = _REGION_TO_LOCATION.get(region, region)
|
|
164
|
+
on_demand = data.get("terms", {}).get("OnDemand", {})
|
|
165
|
+
prices: list[ManagedServicePrice] = []
|
|
166
|
+
|
|
167
|
+
for sku, product in data.get("products", {}).items():
|
|
168
|
+
attrs = product.get("attributes", {})
|
|
169
|
+
if attrs.get("location") not in (location, region):
|
|
170
|
+
continue
|
|
171
|
+
sku_terms = on_demand.get(sku, {})
|
|
172
|
+
for term in sku_terms.values():
|
|
173
|
+
for dim in term.get("priceDimensions", {}).values():
|
|
174
|
+
unit = dim.get("unit", "")
|
|
175
|
+
desc = dim.get("description", "")
|
|
176
|
+
price = _safe_float(dim.get("pricePerUnit", {}).get("USD", "0"))
|
|
177
|
+
if "request" in unit.lower() or "request" in desc.lower():
|
|
178
|
+
prices.append(
|
|
179
|
+
ManagedServicePrice(
|
|
180
|
+
service="lambda",
|
|
181
|
+
tier_name="per_request",
|
|
182
|
+
price_per_hour=0.0,
|
|
183
|
+
# price is per-request; store per-million
|
|
184
|
+
price_per_month=round(price * 1_000_000, 4),
|
|
185
|
+
description=desc,
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
elif "second" in unit.lower() or "gb-second" in unit.lower():
|
|
189
|
+
prices.append(
|
|
190
|
+
ManagedServicePrice(
|
|
191
|
+
service="lambda",
|
|
192
|
+
tier_name="per_gb_second",
|
|
193
|
+
price_per_hour=round(price * 3600, 6),
|
|
194
|
+
price_per_month=0.0,
|
|
195
|
+
description=desc,
|
|
196
|
+
)
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return prices
|
|
200
|
+
|
|
201
|
+
def _parse_s3(self, region: str) -> list[ManagedServicePrice]:
|
|
202
|
+
# S3 uses a global index (no region path component)
|
|
203
|
+
url = f"{_PRICING_BASE}/offers/v1.0/aws/AmazonS3/current/index.json"
|
|
204
|
+
data = json.loads(self._get(url))
|
|
205
|
+
location = _REGION_TO_LOCATION.get(region, region)
|
|
206
|
+
on_demand = data.get("terms", {}).get("OnDemand", {})
|
|
207
|
+
prices: list[ManagedServicePrice] = []
|
|
208
|
+
|
|
209
|
+
for sku, product in data.get("products", {}).items():
|
|
210
|
+
attrs = product.get("attributes", {})
|
|
211
|
+
if attrs.get("location") != location:
|
|
212
|
+
continue
|
|
213
|
+
if attrs.get("storageClass") != "General Purpose":
|
|
214
|
+
continue
|
|
215
|
+
if attrs.get("volumeType") != "Standard":
|
|
216
|
+
continue
|
|
217
|
+
sku_terms = on_demand.get(sku, {})
|
|
218
|
+
for term in sku_terms.values():
|
|
219
|
+
for dim in term.get("priceDimensions", {}).values():
|
|
220
|
+
price = _safe_float(dim.get("pricePerUnit", {}).get("USD", "0"))
|
|
221
|
+
if price > 0:
|
|
222
|
+
prices.append(
|
|
223
|
+
ManagedServicePrice(
|
|
224
|
+
service="s3",
|
|
225
|
+
tier_name="standard_storage_gb",
|
|
226
|
+
price_per_hour=0.0,
|
|
227
|
+
price_per_month=price, # per GB/month
|
|
228
|
+
description=dim.get("description", ""),
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return prices
|
|
233
|
+
|
|
234
|
+
def _parse_rds(self, region: str) -> list[ManagedServicePrice]:
|
|
235
|
+
data = self._fetch_json("AmazonRDS", region)
|
|
236
|
+
location = _REGION_TO_LOCATION.get(region, region)
|
|
237
|
+
on_demand = data.get("terms", {}).get("OnDemand", {})
|
|
238
|
+
prices: list[ManagedServicePrice] = []
|
|
239
|
+
|
|
240
|
+
for sku, product in data.get("products", {}).items():
|
|
241
|
+
attrs = product.get("attributes", {})
|
|
242
|
+
if attrs.get("location") not in (location, region):
|
|
243
|
+
continue
|
|
244
|
+
if attrs.get("databaseEngine") not in ("PostgreSQL", "MySQL"):
|
|
245
|
+
continue
|
|
246
|
+
if attrs.get("deploymentOption") != "Single-AZ":
|
|
247
|
+
continue
|
|
248
|
+
db_class = attrs.get("instanceType", "")
|
|
249
|
+
if not db_class:
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
sku_terms = on_demand.get(sku, {})
|
|
253
|
+
price = _first_price(sku_terms)
|
|
254
|
+
if price > 0:
|
|
255
|
+
prices.append(
|
|
256
|
+
ManagedServicePrice(
|
|
257
|
+
service="rds",
|
|
258
|
+
tier_name=db_class,
|
|
259
|
+
price_per_hour=price,
|
|
260
|
+
price_per_month=round(price * 730, 2),
|
|
261
|
+
description=f"{attrs.get('databaseEngine')} {db_class} Single-AZ",
|
|
262
|
+
vcpus=_safe_int(attrs.get("vcpu", "0")),
|
|
263
|
+
memory_gb=_parse_memory_gib(attrs.get("memory", "0 GiB")),
|
|
264
|
+
)
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
return prices
|
|
268
|
+
|
|
269
|
+
def _parse_dynamodb(self, region: str) -> list[ManagedServicePrice]:
|
|
270
|
+
data = self._fetch_json("AmazonDynamoDB", region)
|
|
271
|
+
location = _REGION_TO_LOCATION.get(region, region)
|
|
272
|
+
on_demand = data.get("terms", {}).get("OnDemand", {})
|
|
273
|
+
prices: list[ManagedServicePrice] = []
|
|
274
|
+
|
|
275
|
+
for sku, product in data.get("products", {}).items():
|
|
276
|
+
attrs = product.get("attributes", {})
|
|
277
|
+
if attrs.get("location") not in (location, region):
|
|
278
|
+
continue
|
|
279
|
+
group = attrs.get("group", "")
|
|
280
|
+
sku_terms = on_demand.get(sku, {})
|
|
281
|
+
for term in sku_terms.values():
|
|
282
|
+
for dim in term.get("priceDimensions", {}).values():
|
|
283
|
+
price = _safe_float(dim.get("pricePerUnit", {}).get("USD", "0"))
|
|
284
|
+
dim_desc = dim.get("description", "")
|
|
285
|
+
if not price:
|
|
286
|
+
continue
|
|
287
|
+
if "write" in group.lower() or "write" in dim_desc.lower():
|
|
288
|
+
prices.append(
|
|
289
|
+
ManagedServicePrice(
|
|
290
|
+
service="dynamodb",
|
|
291
|
+
tier_name="write_request_unit",
|
|
292
|
+
price_per_hour=0.0,
|
|
293
|
+
price_per_month=round(price * 1_000_000, 4),
|
|
294
|
+
description=dim_desc,
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
elif "read" in group.lower() or "read" in dim_desc.lower():
|
|
298
|
+
prices.append(
|
|
299
|
+
ManagedServicePrice(
|
|
300
|
+
service="dynamodb",
|
|
301
|
+
tier_name="read_request_unit",
|
|
302
|
+
price_per_hour=0.0,
|
|
303
|
+
price_per_month=round(price * 1_000_000, 4),
|
|
304
|
+
description=dim_desc,
|
|
305
|
+
)
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
return prices
|
|
309
|
+
|
|
310
|
+
# HTTP
|
|
311
|
+
|
|
312
|
+
def _get(self, url: str) -> bytes:
|
|
313
|
+
req = urllib.request.Request(url, headers={"Accept": "*/*"})
|
|
314
|
+
return urlopen_safe(req, timeout=self._timeout)
|