cloudcosting 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.
- cloudcosting/__init__.py +8 -0
- cloudcosting/__main__.py +6 -0
- cloudcosting/cache.py +110 -0
- cloudcosting/cli.py +155 -0
- cloudcosting/config.py +112 -0
- cloudcosting/domain.py +159 -0
- cloudcosting/estimator.py +104 -0
- cloudcosting/providers/__init__.py +0 -0
- cloudcosting/providers/aws/__init__.py +0 -0
- cloudcosting/providers/aws/calculators/__init__.py +0 -0
- cloudcosting/providers/aws/calculators/alb.py +55 -0
- cloudcosting/providers/aws/calculators/ebs.py +63 -0
- cloudcosting/providers/aws/calculators/ec2.py +81 -0
- cloudcosting/providers/aws/calculators/nat_gateway.py +52 -0
- cloudcosting/providers/aws/calculators/rds.py +255 -0
- cloudcosting/providers/aws/calculators/s3.py +59 -0
- cloudcosting/providers/aws/pricing.py +164 -0
- cloudcosting/providers/aws/provider.py +112 -0
- cloudcosting/providers/registry.py +26 -0
- cloudcosting-0.1.0.dist-info/METADATA +154 -0
- cloudcosting-0.1.0.dist-info/RECORD +24 -0
- cloudcosting-0.1.0.dist-info/WHEEL +4 -0
- cloudcosting-0.1.0.dist-info/entry_points.txt +2 -0
- cloudcosting-0.1.0.dist-info/licenses/LICENSE +674 -0
cloudcosting/__init__.py
ADDED
cloudcosting/__main__.py
ADDED
cloudcosting/cache.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""File-based Price Cache with TTL.
|
|
2
|
+
|
|
3
|
+
Provider-agnostic cache that stores keyed pricing data as JSON files.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from cloudcosting.domain import CacheError
|
|
15
|
+
|
|
16
|
+
DEFAULT_CACHE_DIR = Path.home() / ".cloudcosting" / "cache"
|
|
17
|
+
DEFAULT_TTL_SECONDS = 86400 # 24 hours
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class CacheResult:
|
|
22
|
+
"""Result of a cache retrieval."""
|
|
23
|
+
|
|
24
|
+
data: dict | None
|
|
25
|
+
status: str # "fresh", "stale", "miss"
|
|
26
|
+
age_seconds: float | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PriceCache:
|
|
30
|
+
"""File-based key-value cache with TTL and provider-scoped refresh."""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self,
|
|
34
|
+
cache_dir: Path = DEFAULT_CACHE_DIR,
|
|
35
|
+
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
|
36
|
+
):
|
|
37
|
+
self._cache_dir = cache_dir
|
|
38
|
+
self._ttl_seconds = ttl_seconds
|
|
39
|
+
|
|
40
|
+
def store(self, provider: str, key: tuple, data: dict) -> None:
|
|
41
|
+
"""Store pricing data with a cache key."""
|
|
42
|
+
try:
|
|
43
|
+
self._cache_dir.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
entry = {
|
|
45
|
+
"provider": provider,
|
|
46
|
+
"key": str(key),
|
|
47
|
+
"data": data,
|
|
48
|
+
"timestamp": time.time(),
|
|
49
|
+
"ttl_seconds": self._ttl_seconds,
|
|
50
|
+
}
|
|
51
|
+
filepath = self._key_to_path(key)
|
|
52
|
+
filepath.write_text(json.dumps(entry, indent=2))
|
|
53
|
+
except OSError as e:
|
|
54
|
+
raise CacheError(f"Failed to write cache: {e}") from e
|
|
55
|
+
|
|
56
|
+
def retrieve(self, key: tuple, allow_stale: bool = False) -> CacheResult:
|
|
57
|
+
"""Retrieve cached data. Returns fresh, stale, or miss."""
|
|
58
|
+
filepath = self._key_to_path(key)
|
|
59
|
+
if not filepath.exists():
|
|
60
|
+
return CacheResult(data=None, status="miss")
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
entry = json.loads(filepath.read_text())
|
|
64
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
65
|
+
raise CacheError(f"Failed to read cache: {e}") from e
|
|
66
|
+
|
|
67
|
+
age = time.time() - entry["timestamp"]
|
|
68
|
+
is_expired = age > entry.get("ttl_seconds", self._ttl_seconds)
|
|
69
|
+
|
|
70
|
+
if not is_expired:
|
|
71
|
+
return CacheResult(data=entry["data"], status="fresh", age_seconds=age)
|
|
72
|
+
|
|
73
|
+
if allow_stale:
|
|
74
|
+
return CacheResult(data=entry["data"], status="stale", age_seconds=age)
|
|
75
|
+
|
|
76
|
+
return CacheResult(data=None, status="miss", age_seconds=age)
|
|
77
|
+
|
|
78
|
+
def refresh_provider(self, provider: str) -> int:
|
|
79
|
+
"""Delete all cached entries for a provider. Returns count deleted."""
|
|
80
|
+
if not self._cache_dir.exists():
|
|
81
|
+
return 0
|
|
82
|
+
count = 0
|
|
83
|
+
for filepath in self._cache_dir.glob("*.json"):
|
|
84
|
+
try:
|
|
85
|
+
entry = json.loads(filepath.read_text())
|
|
86
|
+
if entry.get("provider") == provider:
|
|
87
|
+
filepath.unlink()
|
|
88
|
+
count += 1
|
|
89
|
+
except (OSError, json.JSONDecodeError):
|
|
90
|
+
continue
|
|
91
|
+
return count
|
|
92
|
+
|
|
93
|
+
def refresh_all(self) -> int:
|
|
94
|
+
"""Delete all cached entries. Returns count deleted."""
|
|
95
|
+
if not self._cache_dir.exists():
|
|
96
|
+
return 0
|
|
97
|
+
count = 0
|
|
98
|
+
for filepath in self._cache_dir.glob("*.json"):
|
|
99
|
+
try:
|
|
100
|
+
filepath.unlink()
|
|
101
|
+
count += 1
|
|
102
|
+
except OSError:
|
|
103
|
+
continue
|
|
104
|
+
return count
|
|
105
|
+
|
|
106
|
+
def _key_to_path(self, key: tuple) -> Path:
|
|
107
|
+
"""Deterministic hash of cache key to file path."""
|
|
108
|
+
key_str = json.dumps(key, sort_keys=True, default=str)
|
|
109
|
+
key_hash = hashlib.sha256(key_str.encode()).hexdigest()[:16]
|
|
110
|
+
return self._cache_dir / f"{key_hash}.json"
|
cloudcosting/cli.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""CLI entry point for cloudcosting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
from cloudcosting import __version__
|
|
12
|
+
from cloudcosting.cache import PriceCache
|
|
13
|
+
from cloudcosting.domain import CloudCostError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main():
|
|
17
|
+
"""Main CLI entry point."""
|
|
18
|
+
args = sys.argv[1:]
|
|
19
|
+
|
|
20
|
+
if not args or args[0] in ("-h", "--help"):
|
|
21
|
+
_print_help()
|
|
22
|
+
sys.exit(0)
|
|
23
|
+
|
|
24
|
+
if args[0] in ("-v", "--version"):
|
|
25
|
+
print(f"cloudcosting {__version__}")
|
|
26
|
+
sys.exit(0)
|
|
27
|
+
|
|
28
|
+
command = args[0]
|
|
29
|
+
|
|
30
|
+
if command == "estimate":
|
|
31
|
+
_cmd_estimate(args[1:])
|
|
32
|
+
elif command == "cache":
|
|
33
|
+
_cmd_cache(args[1:])
|
|
34
|
+
else:
|
|
35
|
+
print(f"Unknown command: {command}", file=sys.stderr)
|
|
36
|
+
_print_help()
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _cmd_estimate(args: list[str]):
|
|
41
|
+
"""Run cost estimation from a config file."""
|
|
42
|
+
if not args:
|
|
43
|
+
print(
|
|
44
|
+
"Usage: cloudcosting estimate <config.yaml> [--format yaml|json] [-o output]",
|
|
45
|
+
file=sys.stderr,
|
|
46
|
+
)
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
|
|
49
|
+
config_path = Path(args[0])
|
|
50
|
+
output_format = "yaml"
|
|
51
|
+
output_path = None
|
|
52
|
+
|
|
53
|
+
i = 1
|
|
54
|
+
while i < len(args):
|
|
55
|
+
if args[i] == "--format" and i + 1 < len(args):
|
|
56
|
+
output_format = args[i + 1]
|
|
57
|
+
i += 2
|
|
58
|
+
elif args[i] == "-o" and i + 1 < len(args):
|
|
59
|
+
output_path = Path(args[i + 1])
|
|
60
|
+
i += 2
|
|
61
|
+
else:
|
|
62
|
+
print(f"Unknown option: {args[i]}", file=sys.stderr)
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
from cloudcosting.estimator import run_estimation
|
|
67
|
+
|
|
68
|
+
estimate = run_estimation(config_path)
|
|
69
|
+
result = estimate.to_dict()
|
|
70
|
+
|
|
71
|
+
if output_format == "json":
|
|
72
|
+
output = json.dumps(result, indent=2)
|
|
73
|
+
else:
|
|
74
|
+
output = yaml.dump(result, default_flow_style=False, sort_keys=False)
|
|
75
|
+
|
|
76
|
+
if output_path:
|
|
77
|
+
output_path.write_text(output)
|
|
78
|
+
print(f"Estimate written to {output_path}", file=sys.stderr)
|
|
79
|
+
else:
|
|
80
|
+
print(output)
|
|
81
|
+
|
|
82
|
+
# Print summary to stderr
|
|
83
|
+
totals = result["estimate"]["totals"]
|
|
84
|
+
status = result["estimate"]["status"]
|
|
85
|
+
n_resources = sum(len(p["resources"]) for p in result["estimate"]["providers"])
|
|
86
|
+
n_errors = len(result["estimate"]["errors"]) + sum(
|
|
87
|
+
len(p.get("errors", [])) for p in result["estimate"]["providers"]
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
print("\n--- Summary ---", file=sys.stderr)
|
|
91
|
+
print(f"Status: {status}", file=sys.stderr)
|
|
92
|
+
print(f"Resources estimated: {n_resources}", file=sys.stderr)
|
|
93
|
+
if n_errors > 0:
|
|
94
|
+
print(f"Errors: {n_errors}", file=sys.stderr)
|
|
95
|
+
print(f"Monthly total: ${totals['monthly']:,.2f}", file=sys.stderr)
|
|
96
|
+
print(f"Annual total: ${totals['annual']:,.2f}", file=sys.stderr)
|
|
97
|
+
|
|
98
|
+
sys.exit(0 if status == "complete" else 1)
|
|
99
|
+
|
|
100
|
+
except CloudCostError as e:
|
|
101
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
102
|
+
sys.exit(1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _cmd_cache(args: list[str]):
|
|
106
|
+
"""Cache management commands."""
|
|
107
|
+
if not args:
|
|
108
|
+
print("Usage: cloudcosting cache <refresh|status>", file=sys.stderr)
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
|
|
111
|
+
subcommand = args[0]
|
|
112
|
+
cache = PriceCache()
|
|
113
|
+
|
|
114
|
+
if subcommand == "refresh":
|
|
115
|
+
provider = args[1] if len(args) > 1 else None
|
|
116
|
+
if provider:
|
|
117
|
+
count = cache.refresh_provider(provider)
|
|
118
|
+
print(f"Deleted {count} cached entries for provider '{provider}'")
|
|
119
|
+
else:
|
|
120
|
+
count = cache.refresh_all()
|
|
121
|
+
print(f"Deleted {count} cached entries")
|
|
122
|
+
elif subcommand == "status":
|
|
123
|
+
cache_dir = cache._cache_dir
|
|
124
|
+
if cache_dir.exists():
|
|
125
|
+
files = list(cache_dir.glob("*.json"))
|
|
126
|
+
print(f"Cache directory: {cache_dir}")
|
|
127
|
+
print(f"Cached entries: {len(files)}")
|
|
128
|
+
else:
|
|
129
|
+
print(f"Cache directory: {cache_dir} (does not exist)")
|
|
130
|
+
print("Cached entries: 0")
|
|
131
|
+
else:
|
|
132
|
+
print(f"Unknown cache command: {subcommand}", file=sys.stderr)
|
|
133
|
+
sys.exit(1)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _print_help():
|
|
137
|
+
print(f"""cloudcosting {__version__} - Multi-cloud infrastructure cost estimation
|
|
138
|
+
|
|
139
|
+
Usage:
|
|
140
|
+
cloudcosting estimate <config.yaml> [--format yaml|json] [-o output_file]
|
|
141
|
+
cloudcosting cache refresh [provider]
|
|
142
|
+
cloudcosting cache status
|
|
143
|
+
cloudcosting --version
|
|
144
|
+
cloudcosting --help
|
|
145
|
+
|
|
146
|
+
Commands:
|
|
147
|
+
estimate Run cost estimation from a YAML configuration file
|
|
148
|
+
cache Manage the pricing data cache
|
|
149
|
+
|
|
150
|
+
Examples:
|
|
151
|
+
cloudcosting estimate infrastructure.yaml
|
|
152
|
+
cloudcosting estimate infrastructure.yaml --format json
|
|
153
|
+
cloudcosting estimate infrastructure.yaml -o costs.yaml
|
|
154
|
+
cloudcosting cache refresh aws
|
|
155
|
+
cloudcosting cache status""")
|
cloudcosting/config.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Config Loader: reads YAML, validates structure, returns EstimationConfig.
|
|
2
|
+
|
|
3
|
+
Validates structural requirements only. Provider-specific fields pass through.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from cloudcosting.domain import ConfigError, EstimationConfig, ResourceSpec
|
|
13
|
+
|
|
14
|
+
REQUIRED_RESOURCE_FIELDS = ("provider", "region", "type")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def load_config(
|
|
18
|
+
path: Path,
|
|
19
|
+
known_providers: set[str] | None = None,
|
|
20
|
+
) -> EstimationConfig:
|
|
21
|
+
"""Load and validate a YAML configuration file.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
path: Path to YAML config file.
|
|
25
|
+
known_providers: If provided, validate provider IDs against this set.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
EstimationConfig with validated resource specs.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ConfigError: On any structural validation failure.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
raw = yaml.safe_load(path.read_text())
|
|
35
|
+
except FileNotFoundError:
|
|
36
|
+
raise ConfigError(f"Configuration file not found: {path}") from None
|
|
37
|
+
except yaml.YAMLError as e:
|
|
38
|
+
raise ConfigError(f"Invalid YAML in {path}: {e}") from e
|
|
39
|
+
|
|
40
|
+
if not isinstance(raw, dict):
|
|
41
|
+
raise ConfigError(
|
|
42
|
+
f"Configuration must be a YAML mapping, got {type(raw).__name__}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# Extract top-level defaults
|
|
46
|
+
defaults = {}
|
|
47
|
+
if "provider" in raw:
|
|
48
|
+
defaults["provider"] = str(raw["provider"])
|
|
49
|
+
if "region" in raw:
|
|
50
|
+
defaults["region"] = str(raw["region"])
|
|
51
|
+
|
|
52
|
+
# Resources list is required
|
|
53
|
+
if "resources" not in raw:
|
|
54
|
+
raise ConfigError("Configuration must contain a 'resources' key")
|
|
55
|
+
|
|
56
|
+
raw_resources = raw["resources"]
|
|
57
|
+
if not isinstance(raw_resources, list):
|
|
58
|
+
raise ConfigError("'resources' must be a list")
|
|
59
|
+
|
|
60
|
+
if len(raw_resources) == 0:
|
|
61
|
+
raise ConfigError("'resources' list must not be empty")
|
|
62
|
+
|
|
63
|
+
resources = []
|
|
64
|
+
for i, res in enumerate(raw_resources):
|
|
65
|
+
if not isinstance(res, dict):
|
|
66
|
+
raise ConfigError(
|
|
67
|
+
f"Resource {i}: must be a mapping, got {type(res).__name__}"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Apply defaults for missing fields
|
|
71
|
+
effective = {**defaults, **res}
|
|
72
|
+
|
|
73
|
+
# Check required fields
|
|
74
|
+
for field in REQUIRED_RESOURCE_FIELDS:
|
|
75
|
+
if field not in effective:
|
|
76
|
+
raise ConfigError(f"Resource {i}: missing required field '{field}'")
|
|
77
|
+
if not isinstance(effective[field], str):
|
|
78
|
+
raise ConfigError(
|
|
79
|
+
f"Resource {i}: '{field}' must be a string, "
|
|
80
|
+
f"got {type(effective[field]).__name__}"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
provider = effective["provider"]
|
|
84
|
+
region = effective["region"]
|
|
85
|
+
resource_type = effective["type"]
|
|
86
|
+
|
|
87
|
+
# Validate provider ID if registry is available
|
|
88
|
+
if known_providers is not None and provider not in known_providers:
|
|
89
|
+
raise ConfigError(
|
|
90
|
+
f"Resource {i}: unknown provider '{provider}'. "
|
|
91
|
+
f"Known providers: {sorted(known_providers)}"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
# Extract label, pass everything else as params
|
|
95
|
+
label = str(effective.get("label", ""))
|
|
96
|
+
params = {
|
|
97
|
+
k: v
|
|
98
|
+
for k, v in effective.items()
|
|
99
|
+
if k not in ("provider", "region", "type", "label")
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
resources.append(
|
|
103
|
+
ResourceSpec(
|
|
104
|
+
provider=provider,
|
|
105
|
+
region=region,
|
|
106
|
+
type=resource_type,
|
|
107
|
+
params=params,
|
|
108
|
+
label=label,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
return EstimationConfig(resources=tuple(resources), defaults=defaults)
|
cloudcosting/domain.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Domain objects and exceptions for cloudcosting.
|
|
2
|
+
|
|
3
|
+
All data flowing through the system is represented here.
|
|
4
|
+
No component defines its own ad-hoc dictionaries for passing data.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
# -- Exceptions --
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CloudCostError(Exception):
|
|
15
|
+
"""Base exception for all cloudcosting errors."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigError(CloudCostError):
|
|
19
|
+
"""Configuration file is malformed or missing required fields."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ProviderError(CloudCostError):
|
|
23
|
+
"""Provider-specific validation failure."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PricingError(CloudCostError):
|
|
27
|
+
"""Unable to fetch or parse pricing data."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CacheError(CloudCostError):
|
|
31
|
+
"""Cache read/write failure (filesystem issues)."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# -- Input domain --
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class ResourceSpec:
|
|
39
|
+
"""A single resource specification from the configuration."""
|
|
40
|
+
|
|
41
|
+
provider: str
|
|
42
|
+
region: str
|
|
43
|
+
type: str
|
|
44
|
+
params: dict = field(default_factory=dict)
|
|
45
|
+
label: str = ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class EstimationConfig:
|
|
50
|
+
"""Full parsed configuration: resources plus global defaults."""
|
|
51
|
+
|
|
52
|
+
resources: tuple[ResourceSpec, ...]
|
|
53
|
+
defaults: dict = field(default_factory=dict)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# -- Output domain --
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class CostLineItem:
|
|
61
|
+
"""One cost component (e.g., instance, storage, backup)."""
|
|
62
|
+
|
|
63
|
+
name: str
|
|
64
|
+
monthly: float
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict:
|
|
67
|
+
return {"name": self.name, "monthly": round(self.monthly, 2)}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class ResourceCost:
|
|
72
|
+
"""Full cost breakdown for one successfully estimated resource."""
|
|
73
|
+
|
|
74
|
+
label: str
|
|
75
|
+
type: str
|
|
76
|
+
monthly: float
|
|
77
|
+
annual: float
|
|
78
|
+
line_items: tuple[CostLineItem, ...]
|
|
79
|
+
notes: tuple[str, ...] = ()
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> dict:
|
|
82
|
+
result = {
|
|
83
|
+
"label": self.label,
|
|
84
|
+
"type": self.type,
|
|
85
|
+
"monthly": round(self.monthly, 2),
|
|
86
|
+
"annual": round(self.annual, 2),
|
|
87
|
+
"line_items": [item.to_dict() for item in self.line_items],
|
|
88
|
+
}
|
|
89
|
+
if self.notes:
|
|
90
|
+
result["notes"] = list(self.notes)
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class ResourceError:
|
|
96
|
+
"""A failed resource with reason."""
|
|
97
|
+
|
|
98
|
+
label: str
|
|
99
|
+
type: str
|
|
100
|
+
reason: str
|
|
101
|
+
|
|
102
|
+
def to_dict(self) -> dict:
|
|
103
|
+
return {"label": self.label, "type": self.type, "reason": self.reason}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class ProviderEstimate:
|
|
108
|
+
"""Results for one provider: metadata + resource breakdowns + errors."""
|
|
109
|
+
|
|
110
|
+
provider: str
|
|
111
|
+
region: str
|
|
112
|
+
pricing_date: str
|
|
113
|
+
currency: str
|
|
114
|
+
cache_status: str
|
|
115
|
+
resources: tuple[ResourceCost, ...]
|
|
116
|
+
errors: tuple[ResourceError, ...] = ()
|
|
117
|
+
|
|
118
|
+
def to_dict(self) -> dict:
|
|
119
|
+
result = {
|
|
120
|
+
"provider": self.provider,
|
|
121
|
+
"region": self.region,
|
|
122
|
+
"pricing_date": self.pricing_date,
|
|
123
|
+
"currency": self.currency,
|
|
124
|
+
"cache_status": self.cache_status,
|
|
125
|
+
"resources": [r.to_dict() for r in self.resources],
|
|
126
|
+
}
|
|
127
|
+
if self.errors:
|
|
128
|
+
result["errors"] = [e.to_dict() for e in self.errors]
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class Estimate:
|
|
134
|
+
"""Top-level output: everything the CLI needs to serialize."""
|
|
135
|
+
|
|
136
|
+
version: str
|
|
137
|
+
timestamp: str
|
|
138
|
+
status: str # "complete" or "partial"
|
|
139
|
+
providers: tuple[ProviderEstimate, ...]
|
|
140
|
+
totals: dict # {"monthly": float, "annual": float}
|
|
141
|
+
errors: tuple[ResourceError, ...] = ()
|
|
142
|
+
warnings: tuple[str, ...] = ()
|
|
143
|
+
|
|
144
|
+
def to_dict(self) -> dict:
|
|
145
|
+
result: dict = {
|
|
146
|
+
"estimate": {
|
|
147
|
+
"version": self.version,
|
|
148
|
+
"timestamp": self.timestamp,
|
|
149
|
+
"status": self.status,
|
|
150
|
+
"providers": [p.to_dict() for p in self.providers],
|
|
151
|
+
"totals": {
|
|
152
|
+
"monthly": round(self.totals.get("monthly", 0.0), 2),
|
|
153
|
+
"annual": round(self.totals.get("annual", 0.0), 2),
|
|
154
|
+
},
|
|
155
|
+
"errors": [e.to_dict() for e in self.errors],
|
|
156
|
+
"warnings": list(self.warnings),
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return result
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Estimator: Transaction script that orchestrates the full estimation workflow.
|
|
2
|
+
|
|
3
|
+
Load config -> group resources by provider/region -> estimate -> aggregate -> return Estimate.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from cloudcosting import __version__
|
|
13
|
+
from cloudcosting.cache import PriceCache
|
|
14
|
+
from cloudcosting.config import load_config
|
|
15
|
+
from cloudcosting.domain import (
|
|
16
|
+
Estimate,
|
|
17
|
+
EstimationConfig,
|
|
18
|
+
ProviderEstimate,
|
|
19
|
+
ResourceError,
|
|
20
|
+
ResourceSpec,
|
|
21
|
+
)
|
|
22
|
+
from cloudcosting.providers.registry import ProviderRegistry
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def run_estimation(
|
|
26
|
+
config_path: Path,
|
|
27
|
+
cache: PriceCache | None = None,
|
|
28
|
+
) -> Estimate:
|
|
29
|
+
"""Full estimation workflow: config -> providers -> aggregate.
|
|
30
|
+
|
|
31
|
+
This is the main entry point for the estimation pipeline.
|
|
32
|
+
"""
|
|
33
|
+
if cache is None:
|
|
34
|
+
cache = PriceCache()
|
|
35
|
+
|
|
36
|
+
registry = ProviderRegistry(cache=cache)
|
|
37
|
+
|
|
38
|
+
# Load and validate config
|
|
39
|
+
config = load_config(config_path, known_providers=registry.known_ids)
|
|
40
|
+
|
|
41
|
+
return estimate_from_config(config, registry)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def estimate_from_config(
|
|
45
|
+
config: EstimationConfig,
|
|
46
|
+
registry: ProviderRegistry,
|
|
47
|
+
) -> Estimate:
|
|
48
|
+
"""Estimate costs from an already-loaded config."""
|
|
49
|
+
# Group resources by (provider, region)
|
|
50
|
+
groups: dict[tuple[str, str], list[ResourceSpec]] = defaultdict(list)
|
|
51
|
+
global_errors: list[ResourceError] = []
|
|
52
|
+
|
|
53
|
+
for spec in config.resources:
|
|
54
|
+
provider = registry.get(spec.provider)
|
|
55
|
+
if provider is None:
|
|
56
|
+
global_errors.append(
|
|
57
|
+
ResourceError(
|
|
58
|
+
label=spec.label or spec.type,
|
|
59
|
+
type=spec.type,
|
|
60
|
+
reason=f"No provider registered for '{spec.provider}'",
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
continue
|
|
64
|
+
groups[(spec.provider, spec.region)].append(spec)
|
|
65
|
+
|
|
66
|
+
# Run estimation per provider/region group
|
|
67
|
+
provider_estimates: list[ProviderEstimate] = []
|
|
68
|
+
for (provider_id, region), specs in groups.items():
|
|
69
|
+
provider = registry.get(provider_id)
|
|
70
|
+
pe = provider.estimate_resources(specs, region)
|
|
71
|
+
provider_estimates.append(pe)
|
|
72
|
+
|
|
73
|
+
# Aggregate totals
|
|
74
|
+
total_monthly = sum(rc.monthly for pe in provider_estimates for rc in pe.resources)
|
|
75
|
+
total_annual = total_monthly * 12
|
|
76
|
+
|
|
77
|
+
# Determine status
|
|
78
|
+
all_errors = list(global_errors)
|
|
79
|
+
for pe in provider_estimates:
|
|
80
|
+
all_errors.extend(pe.errors)
|
|
81
|
+
|
|
82
|
+
has_results = any(pe.resources for pe in provider_estimates)
|
|
83
|
+
if all_errors and has_results:
|
|
84
|
+
status = "partial"
|
|
85
|
+
elif all_errors and not has_results:
|
|
86
|
+
status = "failed"
|
|
87
|
+
else:
|
|
88
|
+
status = "complete"
|
|
89
|
+
|
|
90
|
+
# Collect warnings
|
|
91
|
+
warnings = []
|
|
92
|
+
for pe in provider_estimates:
|
|
93
|
+
if pe.cache_status == "stale":
|
|
94
|
+
warnings.append(f"Using stale cached pricing for {pe.provider}/{pe.region}")
|
|
95
|
+
|
|
96
|
+
return Estimate(
|
|
97
|
+
version=__version__,
|
|
98
|
+
timestamp=datetime.now(UTC).isoformat(),
|
|
99
|
+
status=status,
|
|
100
|
+
providers=tuple(provider_estimates),
|
|
101
|
+
totals={"monthly": total_monthly, "annual": total_annual},
|
|
102
|
+
errors=tuple(global_errors),
|
|
103
|
+
warnings=tuple(warnings),
|
|
104
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|