tfcost 0.1.0__tar.gz
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.
- tfcost-0.1.0/PKG-INFO +9 -0
- tfcost-0.1.0/setup.cfg +4 -0
- tfcost-0.1.0/setup.py +17 -0
- tfcost-0.1.0/tfcost/__init__.py +0 -0
- tfcost-0.1.0/tfcost/cli.py +148 -0
- tfcost-0.1.0/tfcost/cost_calculator.py +170 -0
- tfcost-0.1.0/tfcost/history.py +42 -0
- tfcost-0.1.0/tfcost/plan_parser.py +77 -0
- tfcost-0.1.0/tfcost/pricing/__init__.py +1 -0
- tfcost-0.1.0/tfcost/pricing/aws_ebs.py +17 -0
- tfcost-0.1.0/tfcost/pricing/aws_ec2.py +18 -0
- tfcost-0.1.0/tfcost/pricing/aws_nat_gateway.py +15 -0
- tfcost-0.1.0/tfcost/pricing/aws_pricing.py +130 -0
- tfcost-0.1.0/tfcost/pricing/aws_s3.py +14 -0
- tfcost-0.1.0/tfcost/pricing/resource_pricing_map.py +205 -0
- tfcost-0.1.0/tfcost/pricing/service_detector.py +33 -0
- tfcost-0.1.0/tfcost/terraform_runner.py +13 -0
- tfcost-0.1.0/tfcost.egg-info/PKG-INFO +9 -0
- tfcost-0.1.0/tfcost.egg-info/SOURCES.txt +21 -0
- tfcost-0.1.0/tfcost.egg-info/dependency_links.txt +1 -0
- tfcost-0.1.0/tfcost.egg-info/entry_points.txt +2 -0
- tfcost-0.1.0/tfcost.egg-info/requires.txt +1 -0
- tfcost-0.1.0/tfcost.egg-info/top_level.txt +1 -0
tfcost-0.1.0/PKG-INFO
ADDED
tfcost-0.1.0/setup.cfg
ADDED
tfcost-0.1.0/setup.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from setuptools import setup, find_packages
|
|
2
|
+
|
|
3
|
+
setup(
|
|
4
|
+
name="tfcost",
|
|
5
|
+
version="0.1.0",
|
|
6
|
+
packages=find_packages(),
|
|
7
|
+
install_requires=[
|
|
8
|
+
"boto3"
|
|
9
|
+
],
|
|
10
|
+
entry_points={
|
|
11
|
+
"console_scripts": [
|
|
12
|
+
"tfcost=tfcost.cli:main"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
author="Rakshith Jakkani",
|
|
16
|
+
description="Terraform cost estimation tool",
|
|
17
|
+
)
|
|
File without changes
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from tfcost.plan_parser import parse_plan
|
|
5
|
+
from tfcost.cost_calculator import calculate_cost
|
|
6
|
+
from tfcost.history import show_cost_trend
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def main():
|
|
10
|
+
|
|
11
|
+
parser = argparse.ArgumentParser()
|
|
12
|
+
|
|
13
|
+
parser.add_argument(
|
|
14
|
+
"--summary",
|
|
15
|
+
action="store_true",
|
|
16
|
+
help="Show summary only"
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--fail-if-increase",
|
|
21
|
+
type=float,
|
|
22
|
+
help="Fail if cost increase exceeds this value"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"--output",
|
|
27
|
+
choices=["table", "json"],
|
|
28
|
+
default="table",
|
|
29
|
+
help="Output format"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--trend",
|
|
34
|
+
action="store_true",
|
|
35
|
+
help="Show infrastructure cost trend"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--var-file",
|
|
40
|
+
help="Terraform variable file (e.g. prod.tfvars)"
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--compare",
|
|
45
|
+
nargs=2,
|
|
46
|
+
metavar=("ENV1", "ENV2"),
|
|
47
|
+
help="Compare two Terraform variable files"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--diff",
|
|
52
|
+
action="store_true",
|
|
53
|
+
help="Show cost difference between current and proposed infrastructure"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
args = parser.parse_args()
|
|
57
|
+
|
|
58
|
+
# --------------------------------
|
|
59
|
+
# Cost trend
|
|
60
|
+
# --------------------------------
|
|
61
|
+
if args.trend:
|
|
62
|
+
show_cost_trend()
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
# --------------------------------
|
|
66
|
+
# Environment comparison
|
|
67
|
+
# --------------------------------
|
|
68
|
+
if args.compare:
|
|
69
|
+
|
|
70
|
+
env1, env2 = args.compare
|
|
71
|
+
|
|
72
|
+
print("\nRunning Terraform plan for:", env1)
|
|
73
|
+
resources1, region1 = parse_plan(var_file=env1)
|
|
74
|
+
cost1 = calculate_cost(resources1, region1, summary=True)
|
|
75
|
+
|
|
76
|
+
print("\nRunning Terraform plan for:", env2)
|
|
77
|
+
resources2, region2 = parse_plan(var_file=env2)
|
|
78
|
+
cost2 = calculate_cost(resources2, region2, summary=True)
|
|
79
|
+
|
|
80
|
+
diff = cost2 - cost1
|
|
81
|
+
percent = (diff / cost1 * 100) if cost1 else 0
|
|
82
|
+
|
|
83
|
+
print("\nTerraform Environment Cost Comparison")
|
|
84
|
+
print("─────────────────────────────────────")
|
|
85
|
+
print(f"{env1:20} ${cost1:>10.2f}")
|
|
86
|
+
print(f"{env2:20} ${cost2:>10.2f}")
|
|
87
|
+
print("")
|
|
88
|
+
print(f"{'Difference':20} ${diff:>10.2f}")
|
|
89
|
+
print(f"{'Change %':20} {percent:>10.2f} %")
|
|
90
|
+
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
# --------------------------------
|
|
94
|
+
# Pull Request Cost Diff
|
|
95
|
+
# --------------------------------
|
|
96
|
+
if args.diff:
|
|
97
|
+
|
|
98
|
+
print("\nCalculating current infrastructure cost...")
|
|
99
|
+
resources_old, region_old = parse_plan()
|
|
100
|
+
current_cost = calculate_cost(resources_old, region_old, summary=True)
|
|
101
|
+
|
|
102
|
+
print("\nCalculating proposed infrastructure cost...")
|
|
103
|
+
resources_new, region_new = parse_plan(var_file=args.var_file)
|
|
104
|
+
new_cost = calculate_cost(resources_new, region_new, summary=True)
|
|
105
|
+
|
|
106
|
+
diff = new_cost - current_cost
|
|
107
|
+
percent = (diff / current_cost * 100) if current_cost else 0
|
|
108
|
+
|
|
109
|
+
print("\nTerraform Cost Diff")
|
|
110
|
+
print("────────────────────────────────")
|
|
111
|
+
print(f"{'Current Infrastructure Cost':35} ${current_cost:>10.2f}")
|
|
112
|
+
print(f"{'Proposed Infrastructure Cost':35} ${new_cost:>10.2f}")
|
|
113
|
+
print("")
|
|
114
|
+
print(f"{'Cost Increase':35} ${diff:>10.2f}")
|
|
115
|
+
print(f"{'Change %':35} {percent:>10.2f}%")
|
|
116
|
+
|
|
117
|
+
if diff > 0:
|
|
118
|
+
print("\n⚠ Infrastructure cost will increase.")
|
|
119
|
+
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
# --------------------------------
|
|
123
|
+
# Normal cost estimation
|
|
124
|
+
# --------------------------------
|
|
125
|
+
resources, region = parse_plan(var_file=args.var_file)
|
|
126
|
+
|
|
127
|
+
cost_change = calculate_cost(
|
|
128
|
+
resources,
|
|
129
|
+
region,
|
|
130
|
+
summary=args.summary,
|
|
131
|
+
output=args.output
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# --------------------------------
|
|
135
|
+
# Cost threshold guardrail
|
|
136
|
+
# --------------------------------
|
|
137
|
+
if args.fail_if_increase is not None:
|
|
138
|
+
|
|
139
|
+
threshold = args.fail_if_increase
|
|
140
|
+
|
|
141
|
+
print(f"\nThreshold : ${threshold:.2f}")
|
|
142
|
+
|
|
143
|
+
if cost_change > threshold:
|
|
144
|
+
print("Status : FAIL")
|
|
145
|
+
print("Cost increase exceeds allowed threshold.")
|
|
146
|
+
sys.exit(1)
|
|
147
|
+
else:
|
|
148
|
+
print("Status : PASS")
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
from tfcost.engines.pricing_engine import calculate_resource_cost
|
|
4
|
+
from tfcost.pricing.service_detector import detect_service
|
|
5
|
+
from tfcost.history import save_cost_snapshot
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_module_name(address):
|
|
9
|
+
|
|
10
|
+
if address.startswith("module."):
|
|
11
|
+
parts = address.split(".")
|
|
12
|
+
return parts[1]
|
|
13
|
+
|
|
14
|
+
return "root"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def calculate_cost(resources, region, summary=False, output="table"):
|
|
18
|
+
|
|
19
|
+
has_changes = False
|
|
20
|
+
|
|
21
|
+
show_resources = (not summary) and (output != "json")
|
|
22
|
+
|
|
23
|
+
previous_cost = 0
|
|
24
|
+
final_cost = 0
|
|
25
|
+
|
|
26
|
+
service_costs = {}
|
|
27
|
+
module_costs = {}
|
|
28
|
+
|
|
29
|
+
# Header will be printed ONLY if changes exist
|
|
30
|
+
printed_header = False
|
|
31
|
+
|
|
32
|
+
for r in resources:
|
|
33
|
+
|
|
34
|
+
actions = r.get("actions", [])
|
|
35
|
+
|
|
36
|
+
before_config = r.get("before")
|
|
37
|
+
after_config = r.get("config")
|
|
38
|
+
|
|
39
|
+
before_cost = 0
|
|
40
|
+
after_cost = 0
|
|
41
|
+
|
|
42
|
+
# -----------------------------
|
|
43
|
+
# BEFORE COST
|
|
44
|
+
# -----------------------------
|
|
45
|
+
if before_config:
|
|
46
|
+
before_resource = {
|
|
47
|
+
"type": r["type"],
|
|
48
|
+
"config": before_config
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
before_result = calculate_resource_cost(before_resource, region)
|
|
52
|
+
before_cost = before_result["cost"]
|
|
53
|
+
|
|
54
|
+
# -----------------------------
|
|
55
|
+
# AFTER COST
|
|
56
|
+
# -----------------------------
|
|
57
|
+
if after_config:
|
|
58
|
+
after_result = calculate_resource_cost(r, region)
|
|
59
|
+
after_cost = after_result["cost"]
|
|
60
|
+
service = after_result["service"]
|
|
61
|
+
else:
|
|
62
|
+
service = detect_service(r["type"])
|
|
63
|
+
|
|
64
|
+
diff = after_cost - before_cost
|
|
65
|
+
|
|
66
|
+
if diff != 0:
|
|
67
|
+
has_changes = True
|
|
68
|
+
|
|
69
|
+
# -----------------------------
|
|
70
|
+
# PRINT ONLY CHANGED RESOURCES
|
|
71
|
+
# -----------------------------
|
|
72
|
+
if show_resources and diff != 0:
|
|
73
|
+
|
|
74
|
+
# Print header lazily (only when first change appears)
|
|
75
|
+
if not printed_header:
|
|
76
|
+
print("\nTerraform Cost Estimate")
|
|
77
|
+
print("─" * 80)
|
|
78
|
+
print(f"{'Resource':35} {'Before':>10} {'After':>10} {'Diff':>10}")
|
|
79
|
+
print("─" * 80)
|
|
80
|
+
printed_header = True
|
|
81
|
+
|
|
82
|
+
if "create" in actions:
|
|
83
|
+
symbol = "+"
|
|
84
|
+
elif "delete" in actions:
|
|
85
|
+
symbol = "-"
|
|
86
|
+
elif "update" in actions:
|
|
87
|
+
symbol = "~"
|
|
88
|
+
else:
|
|
89
|
+
symbol = " "
|
|
90
|
+
|
|
91
|
+
print(
|
|
92
|
+
f"{symbol} {r['address']:33} "
|
|
93
|
+
f"${before_cost:>8.2f} → ${after_cost:>8.2f} "
|
|
94
|
+
f"{diff:+10.2f}"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# -----------------------------
|
|
98
|
+
# AGGREGATION
|
|
99
|
+
# -----------------------------
|
|
100
|
+
previous_cost += before_cost
|
|
101
|
+
final_cost += after_cost
|
|
102
|
+
|
|
103
|
+
service_costs[service] = service_costs.get(service, 0) + after_cost
|
|
104
|
+
|
|
105
|
+
module = get_module_name(r["address"])
|
|
106
|
+
module_costs[module] = module_costs.get(module, 0) + after_cost
|
|
107
|
+
|
|
108
|
+
cost_change = final_cost - previous_cost
|
|
109
|
+
|
|
110
|
+
result = {
|
|
111
|
+
"previous_cost": round(previous_cost, 2),
|
|
112
|
+
"estimated_cost": round(final_cost, 2),
|
|
113
|
+
"cost_change": round(cost_change, 2),
|
|
114
|
+
"services": {k: round(v, 2) for k, v in service_costs.items()},
|
|
115
|
+
"modules": {k: round(v, 2) for k, v in module_costs.items()}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
# -----------------------------
|
|
119
|
+
# JSON OUTPUT
|
|
120
|
+
# -----------------------------
|
|
121
|
+
if output == "json":
|
|
122
|
+
print(json.dumps(result, indent=2))
|
|
123
|
+
return final_cost
|
|
124
|
+
|
|
125
|
+
# -----------------------------
|
|
126
|
+
# NO CHANGE CASE
|
|
127
|
+
# -----------------------------
|
|
128
|
+
if not has_changes:
|
|
129
|
+
print("\nNo cost changes detected. Infrastructure cost is up-to-date.")
|
|
130
|
+
save_cost_snapshot(final_cost)
|
|
131
|
+
return final_cost
|
|
132
|
+
|
|
133
|
+
# -----------------------------
|
|
134
|
+
# SUMMARY
|
|
135
|
+
# -----------------------------
|
|
136
|
+
print("─" * 80)
|
|
137
|
+
print(f"{'Previous Infrastructure Cost':45} ${previous_cost:>10.2f}")
|
|
138
|
+
print(f"{'Estimated Infrastructure Cost':45} ${final_cost:>10.2f}")
|
|
139
|
+
print(f"{'Cost Change':45} {cost_change:+10.2f}")
|
|
140
|
+
print("─" * 80)
|
|
141
|
+
|
|
142
|
+
# -----------------------------
|
|
143
|
+
# SERVICE BREAKDOWN
|
|
144
|
+
# -----------------------------
|
|
145
|
+
print("\nCost Breakdown by Service")
|
|
146
|
+
print("─" * 30)
|
|
147
|
+
|
|
148
|
+
for service, cost in service_costs.items():
|
|
149
|
+
if cost > 0:
|
|
150
|
+
print(f"{service:20} ${cost:>8.2f}")
|
|
151
|
+
|
|
152
|
+
print("─" * 30)
|
|
153
|
+
|
|
154
|
+
# -----------------------------
|
|
155
|
+
# MODULE BREAKDOWN
|
|
156
|
+
# -----------------------------
|
|
157
|
+
print("\nCost Breakdown by Module")
|
|
158
|
+
print("─" * 30)
|
|
159
|
+
|
|
160
|
+
for module, cost in module_costs.items():
|
|
161
|
+
print(f"{module:20} ${cost:>8.2f}")
|
|
162
|
+
|
|
163
|
+
print("─" * 30)
|
|
164
|
+
|
|
165
|
+
# -----------------------------
|
|
166
|
+
# SAVE HISTORY
|
|
167
|
+
# -----------------------------
|
|
168
|
+
save_cost_snapshot(final_cost)
|
|
169
|
+
|
|
170
|
+
return final_cost
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from datetime import date
|
|
4
|
+
|
|
5
|
+
HISTORY_FILE = "cost_history.json"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def save_cost_snapshot(cost):
|
|
9
|
+
|
|
10
|
+
history = []
|
|
11
|
+
|
|
12
|
+
if os.path.exists(HISTORY_FILE):
|
|
13
|
+
with open(HISTORY_FILE) as f:
|
|
14
|
+
history = json.load(f)
|
|
15
|
+
|
|
16
|
+
# prevent duplicate entries
|
|
17
|
+
if history and history[-1]["cost"] == round(cost, 2):
|
|
18
|
+
return
|
|
19
|
+
|
|
20
|
+
history.append({
|
|
21
|
+
"date": str(date.today()),
|
|
22
|
+
"cost": round(cost, 2)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
with open(HISTORY_FILE, "w") as f:
|
|
26
|
+
json.dump(history, f, indent=2)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def show_cost_trend():
|
|
30
|
+
|
|
31
|
+
if not os.path.exists(HISTORY_FILE):
|
|
32
|
+
print("\nNo cost history available.")
|
|
33
|
+
return
|
|
34
|
+
|
|
35
|
+
with open(HISTORY_FILE) as f:
|
|
36
|
+
history = json.load(f)
|
|
37
|
+
|
|
38
|
+
print("\nInfrastructure Cost Trend")
|
|
39
|
+
print("─" * 30)
|
|
40
|
+
|
|
41
|
+
for entry in history:
|
|
42
|
+
print(f"{entry['date']:12} ${entry['cost']:>8.2f}")
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def load_terraform_plan(var_file=None):
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
|
|
9
|
+
# Build terraform plan command
|
|
10
|
+
cmd = ["terraform", "plan", "-out", "tfplan"]
|
|
11
|
+
|
|
12
|
+
if var_file:
|
|
13
|
+
cmd.append(f"-var-file={var_file}")
|
|
14
|
+
print("Running terraform plan...")
|
|
15
|
+
# Run terraform plan
|
|
16
|
+
subprocess.run(
|
|
17
|
+
cmd,
|
|
18
|
+
check=True,
|
|
19
|
+
capture_output=True,
|
|
20
|
+
text=True
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
# Get terraform JSON output
|
|
24
|
+
result = subprocess.run(
|
|
25
|
+
["terraform", "show", "-json", "tfplan"],
|
|
26
|
+
check=True,
|
|
27
|
+
capture_output=True,
|
|
28
|
+
text=True
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
return json.loads(result.stdout)
|
|
32
|
+
|
|
33
|
+
except subprocess.CalledProcessError as e:
|
|
34
|
+
raise Exception("Terraform execution failed:\n" + e.stderr)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_plan(var_file=None):
|
|
38
|
+
|
|
39
|
+
# Load Terraform plan
|
|
40
|
+
plan = load_terraform_plan(var_file)
|
|
41
|
+
|
|
42
|
+
resources = []
|
|
43
|
+
region = None
|
|
44
|
+
|
|
45
|
+
for r in plan.get("resource_changes", []):
|
|
46
|
+
|
|
47
|
+
change = r.get("change", {})
|
|
48
|
+
actions = change.get("actions", [])
|
|
49
|
+
|
|
50
|
+
before = change.get("before")
|
|
51
|
+
after = change.get("after")
|
|
52
|
+
|
|
53
|
+
# Use after for create/update, before for delete
|
|
54
|
+
config = after if after is not None else before
|
|
55
|
+
|
|
56
|
+
if config is None:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
# Detect region
|
|
60
|
+
if not region and isinstance(config, dict) and "region" in config:
|
|
61
|
+
region = config["region"]
|
|
62
|
+
|
|
63
|
+
resource = {
|
|
64
|
+
"type": r.get("type"),
|
|
65
|
+
"address": r.get("address"),
|
|
66
|
+
"config": after,
|
|
67
|
+
"before": before,
|
|
68
|
+
"actions": actions
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
resources.append(resource)
|
|
72
|
+
|
|
73
|
+
# Fallback region
|
|
74
|
+
if not region:
|
|
75
|
+
region = "us-east-1"
|
|
76
|
+
|
|
77
|
+
return resources, region
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# pricing package
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# from tfcost.engines.resource_engine import register
|
|
2
|
+
# from tfcost.pricing.aws_pricing import get_ebs_price
|
|
3
|
+
|
|
4
|
+
# @register("aws_ebs_volume")
|
|
5
|
+
# def price_ebs(resource, region):
|
|
6
|
+
|
|
7
|
+
# size = resource["config"].get("size")
|
|
8
|
+
# vtype = resource["config"].get("type", "gp3")
|
|
9
|
+
|
|
10
|
+
# price = get_ebs_price(vtype, region)
|
|
11
|
+
|
|
12
|
+
# monthly = price * size
|
|
13
|
+
|
|
14
|
+
# return {
|
|
15
|
+
# "service": "EBS",
|
|
16
|
+
# "cost": monthly
|
|
17
|
+
# }
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# from tfcost.engines.resource_engine import register
|
|
2
|
+
# from tfcost.pricing.aws_pricing import get_ec2_price
|
|
3
|
+
|
|
4
|
+
# HOURS_PER_MONTH = 730.5
|
|
5
|
+
|
|
6
|
+
# @register("aws_instance")
|
|
7
|
+
# def price_ec2(resource, region):
|
|
8
|
+
|
|
9
|
+
# instance_type = resource["config"]["instance_type"]
|
|
10
|
+
|
|
11
|
+
# hourly = get_ec2_price(instance_type, region)
|
|
12
|
+
|
|
13
|
+
# monthly = hourly * HOURS_PER_MONTH
|
|
14
|
+
|
|
15
|
+
# return {
|
|
16
|
+
# "service": "EC2",
|
|
17
|
+
# "cost": monthly
|
|
18
|
+
# }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# from tfcost.engines.resource_engine import register
|
|
2
|
+
|
|
3
|
+
# HOURS_PER_MONTH = 730.5
|
|
4
|
+
# NAT_HOURLY_PRICE = 0.045
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# @register("aws_nat_gateway")
|
|
8
|
+
# def price_nat_gateway(resource, region):
|
|
9
|
+
|
|
10
|
+
# monthly = NAT_HOURLY_PRICE * HOURS_PER_MONTH
|
|
11
|
+
|
|
12
|
+
# return {
|
|
13
|
+
# "service": "NAT Gateway",
|
|
14
|
+
# "cost": monthly
|
|
15
|
+
# }
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
import boto3
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
pricing_client = boto3.client("pricing", region_name="us-east-1")
|
|
7
|
+
|
|
8
|
+
PRICE_CACHE = {}
|
|
9
|
+
|
|
10
|
+
def get_pricing_location(region):
|
|
11
|
+
|
|
12
|
+
REGION_MAP = {
|
|
13
|
+
"us-east-1": "US East (N. Virginia)",
|
|
14
|
+
"us-east-2": "US East (Ohio)",
|
|
15
|
+
"us-west-1": "US West (N. California)",
|
|
16
|
+
"us-west-2": "US West (Oregon)",
|
|
17
|
+
"eu-west-1": "EU (Ireland)",
|
|
18
|
+
"eu-west-2": "EU (London)",
|
|
19
|
+
"eu-west-3": "EU (Paris)",
|
|
20
|
+
"ap-south-1": "Asia Pacific (Mumbai)"
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return REGION_MAP.get(region, "US East (N. Virginia)")
|
|
24
|
+
|
|
25
|
+
EC2_CLIENT_CACHE = {}
|
|
26
|
+
|
|
27
|
+
def get_ami_root_volume(ami_id, region):
|
|
28
|
+
if region not in EC2_CLIENT_CACHE:
|
|
29
|
+
EC2_CLIENT_CACHE[region] = boto3.client("ec2", region_name=region)
|
|
30
|
+
ec2 = EC2_CLIENT_CACHE[region]
|
|
31
|
+
response = ec2.describe_images(ImageIds=[ami_id])
|
|
32
|
+
images = response.get("Images", [])
|
|
33
|
+
if not images:
|
|
34
|
+
return None, None
|
|
35
|
+
image = images[0]
|
|
36
|
+
mappings = image.get("BlockDeviceMappings", [])
|
|
37
|
+
for mapping in mappings:
|
|
38
|
+
ebs = mapping.get("Ebs")
|
|
39
|
+
if ebs:
|
|
40
|
+
size = ebs.get("VolumeSize")
|
|
41
|
+
vtype = ebs.get("VolumeType")
|
|
42
|
+
return size, vtype
|
|
43
|
+
|
|
44
|
+
return None, None
|
|
45
|
+
|
|
46
|
+
def get_ec2_price(instance_type, region):
|
|
47
|
+
|
|
48
|
+
cache_key = f"{instance_type}:{region}"
|
|
49
|
+
|
|
50
|
+
if cache_key in PRICE_CACHE:
|
|
51
|
+
return PRICE_CACHE[cache_key]
|
|
52
|
+
|
|
53
|
+
# location = REGION_MAP.get(region, "US East (N. Virginia)")
|
|
54
|
+
location = get_pricing_location(region)
|
|
55
|
+
|
|
56
|
+
response = pricing_client.get_products(
|
|
57
|
+
ServiceCode="AmazonEC2",
|
|
58
|
+
Filters=[
|
|
59
|
+
{"Type": "TERM_MATCH", "Field": "instanceType", "Value": instance_type},
|
|
60
|
+
{"Type": "TERM_MATCH", "Field": "location", "Value": location},
|
|
61
|
+
{"Type": "TERM_MATCH", "Field": "operatingSystem", "Value": "Linux"},
|
|
62
|
+
{"Type": "TERM_MATCH", "Field": "tenancy", "Value": "Shared"},
|
|
63
|
+
{"Type": "TERM_MATCH", "Field": "preInstalledSw", "Value": "NA"},
|
|
64
|
+
{"Type": "TERM_MATCH", "Field": "capacitystatus", "Value": "Used"},
|
|
65
|
+
],
|
|
66
|
+
MaxResults=1
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if not response["PriceList"]:
|
|
70
|
+
return 0
|
|
71
|
+
|
|
72
|
+
price_item = json.loads(response["PriceList"][0])
|
|
73
|
+
|
|
74
|
+
terms = price_item["terms"]["OnDemand"]
|
|
75
|
+
|
|
76
|
+
for term in terms.values():
|
|
77
|
+
for dim in term["priceDimensions"].values():
|
|
78
|
+
price = float(dim["pricePerUnit"]["USD"])
|
|
79
|
+
|
|
80
|
+
# store in cache
|
|
81
|
+
PRICE_CACHE[cache_key] = price
|
|
82
|
+
|
|
83
|
+
return price
|
|
84
|
+
|
|
85
|
+
return 0
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_ebs_price(volume_type, region):
|
|
89
|
+
|
|
90
|
+
# location = REGION_MAP.get(region, "US East (N. Virginia)")
|
|
91
|
+
location = get_pricing_location(region)
|
|
92
|
+
|
|
93
|
+
response = pricing_client.get_products(
|
|
94
|
+
ServiceCode="AmazonEC2",
|
|
95
|
+
Filters=[
|
|
96
|
+
{"Type": "TERM_MATCH", "Field": "volumeApiName", "Value": volume_type},
|
|
97
|
+
{"Type": "TERM_MATCH", "Field": "location", "Value": location},
|
|
98
|
+
{"Type": "TERM_MATCH", "Field": "productFamily", "Value": "Storage"}
|
|
99
|
+
],
|
|
100
|
+
MaxResults=1
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if not response["PriceList"]:
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
price_item = json.loads(response["PriceList"][0])
|
|
107
|
+
|
|
108
|
+
terms = price_item["terms"]["OnDemand"]
|
|
109
|
+
|
|
110
|
+
for term in terms.values():
|
|
111
|
+
for dim in term["priceDimensions"].values():
|
|
112
|
+
return float(dim["pricePerUnit"]["USD"])
|
|
113
|
+
|
|
114
|
+
return 0
|
|
115
|
+
|
|
116
|
+
def get_instance_price(instance_type, region):
|
|
117
|
+
"""
|
|
118
|
+
Generic instance pricing.
|
|
119
|
+
Currently uses EC2 pricing API.
|
|
120
|
+
Works for EC2 and similar instance-based services.
|
|
121
|
+
"""
|
|
122
|
+
return get_ec2_price(instance_type, region)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def get_storage_price(volume_type, region):
|
|
126
|
+
"""
|
|
127
|
+
Generic storage pricing.
|
|
128
|
+
Currently uses EBS pricing API.
|
|
129
|
+
"""
|
|
130
|
+
return get_ebs_price(volume_type, region)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# from tfcost.engines.resource_engine import register
|
|
2
|
+
|
|
3
|
+
# @register("aws_s3_bucket")
|
|
4
|
+
# def price_s3(resource, region):
|
|
5
|
+
|
|
6
|
+
# storage_gb = 10
|
|
7
|
+
# price_per_gb = 0.023
|
|
8
|
+
|
|
9
|
+
# monthly = storage_gb * price_per_gb
|
|
10
|
+
|
|
11
|
+
# return {
|
|
12
|
+
# "service": "S3",
|
|
13
|
+
# "cost": monthly
|
|
14
|
+
# }
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
RESOURCE_PRICING_MAP = {
|
|
2
|
+
|
|
3
|
+
# ========================
|
|
4
|
+
# EC2
|
|
5
|
+
# ========================
|
|
6
|
+
"aws_instance": {
|
|
7
|
+
"service": "EC2",
|
|
8
|
+
"lookup": "instance_type",
|
|
9
|
+
"pricing_model": "instance_hour"
|
|
10
|
+
},
|
|
11
|
+
|
|
12
|
+
"aws_launch_template": {
|
|
13
|
+
"service": "EC2",
|
|
14
|
+
"lookup": "instance_type",
|
|
15
|
+
"pricing_model": "instance_hour"
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
"aws_autoscaling_group": {
|
|
19
|
+
"service": "EC2",
|
|
20
|
+
"lookup": "instance_type",
|
|
21
|
+
"pricing_model": "instance_hour"
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
"aws_spot_instance_request": {
|
|
25
|
+
"service": "EC2",
|
|
26
|
+
"lookup": "instance_type",
|
|
27
|
+
"pricing_model": "instance_hour"
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
# ========================
|
|
31
|
+
# EBS
|
|
32
|
+
# ========================
|
|
33
|
+
"aws_ebs_volume": {
|
|
34
|
+
"service": "EBS",
|
|
35
|
+
"lookup": "type",
|
|
36
|
+
"size": "size",
|
|
37
|
+
"pricing_model": "storage_gb_month"
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
"aws_ebs_snapshot": {
|
|
41
|
+
"service": "EBS",
|
|
42
|
+
"size": "size",
|
|
43
|
+
"pricing_model": "storage_gb_month"
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
# ========================
|
|
47
|
+
# S3
|
|
48
|
+
# ========================
|
|
49
|
+
"aws_s3_bucket": {
|
|
50
|
+
"service": "S3",
|
|
51
|
+
"pricing_model": "bucket_storage",
|
|
52
|
+
"default_size": 10,
|
|
53
|
+
"price_per_gb": 0.023
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
# ========================
|
|
57
|
+
# RDS
|
|
58
|
+
# ========================
|
|
59
|
+
"aws_db_instance": {
|
|
60
|
+
"service": "RDS",
|
|
61
|
+
"lookup": "instance_class",
|
|
62
|
+
"pricing_model": "instance_hour"
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
"aws_rds_cluster_instance": {
|
|
66
|
+
"service": "RDS",
|
|
67
|
+
"lookup": "instance_class",
|
|
68
|
+
"pricing_model": "instance_hour"
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
"aws_rds_cluster": {
|
|
72
|
+
"service": "RDS",
|
|
73
|
+
"lookup": "engine",
|
|
74
|
+
"pricing_model": "instance_hour"
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
# ========================
|
|
78
|
+
# Load Balancers
|
|
79
|
+
# ========================
|
|
80
|
+
"aws_lb": {
|
|
81
|
+
"service": "ELB",
|
|
82
|
+
"pricing_model": "hourly_fixed",
|
|
83
|
+
"price": 0.025
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
"aws_elb": {
|
|
87
|
+
"service": "ELB",
|
|
88
|
+
"pricing_model": "hourly_fixed",
|
|
89
|
+
"price": 0.025
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
# ========================
|
|
93
|
+
# NAT Gateway
|
|
94
|
+
# ========================
|
|
95
|
+
"aws_nat_gateway": {
|
|
96
|
+
"service": "NAT Gateway",
|
|
97
|
+
"pricing_model": "hourly_fixed",
|
|
98
|
+
"price": 0.045
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
# ========================
|
|
102
|
+
# Elastic IP
|
|
103
|
+
# ========================
|
|
104
|
+
"aws_eip": {
|
|
105
|
+
"service": "Elastic IP",
|
|
106
|
+
"pricing_model": "hourly_fixed",
|
|
107
|
+
"price": 0.005
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
# ========================
|
|
111
|
+
# EFS
|
|
112
|
+
# ========================
|
|
113
|
+
"aws_efs_file_system": {
|
|
114
|
+
"service": "EFS",
|
|
115
|
+
"size": "size",
|
|
116
|
+
"pricing_model": "storage_gb_month"
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
# ========================
|
|
120
|
+
# DynamoDB
|
|
121
|
+
# ========================
|
|
122
|
+
"aws_dynamodb_table": {
|
|
123
|
+
"service": "DynamoDB",
|
|
124
|
+
"pricing_model": "capacity"
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
# ========================
|
|
128
|
+
# ElastiCache
|
|
129
|
+
# ========================
|
|
130
|
+
"aws_elasticache_cluster": {
|
|
131
|
+
"service": "ElastiCache",
|
|
132
|
+
"lookup": "node_type",
|
|
133
|
+
"pricing_model": "instance_hour"
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
"aws_elasticache_replication_group": {
|
|
137
|
+
"service": "ElastiCache",
|
|
138
|
+
"lookup": "node_type",
|
|
139
|
+
"pricing_model": "instance_hour"
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
# ========================
|
|
143
|
+
# Redshift
|
|
144
|
+
# ========================
|
|
145
|
+
"aws_redshift_cluster": {
|
|
146
|
+
"service": "Redshift",
|
|
147
|
+
"lookup": "node_type",
|
|
148
|
+
"pricing_model": "instance_hour"
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
# ========================
|
|
152
|
+
# EKS
|
|
153
|
+
# ========================
|
|
154
|
+
"aws_eks_cluster": {
|
|
155
|
+
"service": "EKS",
|
|
156
|
+
"pricing_model": "hourly_fixed",
|
|
157
|
+
"price": 0.10
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
"aws_eks_node_group": {
|
|
161
|
+
"service": "EKS",
|
|
162
|
+
"lookup": "instance_types",
|
|
163
|
+
"count": "desired_size",
|
|
164
|
+
"pricing_model": "eks_node_group"
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
# ========================
|
|
168
|
+
# MSK
|
|
169
|
+
# ========================
|
|
170
|
+
"aws_msk_cluster": {
|
|
171
|
+
"service": "MSK",
|
|
172
|
+
"lookup": "instance_type",
|
|
173
|
+
"pricing_model": "instance_hour"
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
# ========================
|
|
177
|
+
# OpenSearch
|
|
178
|
+
# ========================
|
|
179
|
+
"aws_opensearch_domain": {
|
|
180
|
+
"service": "OpenSearch",
|
|
181
|
+
"lookup": "instance_type",
|
|
182
|
+
"pricing_model": "instance_hour"
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
# ========================
|
|
186
|
+
# EKS
|
|
187
|
+
# ========================
|
|
188
|
+
|
|
189
|
+
"aws_eks_cluster": {
|
|
190
|
+
"service": "EKS",
|
|
191
|
+
"pricing_model": "hourly_fixed",
|
|
192
|
+
"price": 0.10
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
# ========================
|
|
196
|
+
# Glue
|
|
197
|
+
# ========================
|
|
198
|
+
"aws_glue_job": {
|
|
199
|
+
"service": "Glue",
|
|
200
|
+
"pricing_model": "hourly_fixed",
|
|
201
|
+
"price": 0.44
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
SERVICE_PREFIX_MAP = {
|
|
2
|
+
|
|
3
|
+
"aws_instance": "EC2",
|
|
4
|
+
"aws_ebs": "EBS",
|
|
5
|
+
"aws_s3": "S3",
|
|
6
|
+
"aws_lambda": "Lambda",
|
|
7
|
+
"aws_lb": "ELB",
|
|
8
|
+
"aws_elb": "ELB",
|
|
9
|
+
"aws_nat_gateway": "NAT Gateway",
|
|
10
|
+
"aws_dynamodb": "DynamoDB",
|
|
11
|
+
"aws_db": "RDS",
|
|
12
|
+
"aws_rds": "RDS",
|
|
13
|
+
"aws_redshift": "Redshift",
|
|
14
|
+
"aws_cloudfront": "CloudFront",
|
|
15
|
+
"aws_efs": "EFS",
|
|
16
|
+
"aws_elasticache": "ElastiCache",
|
|
17
|
+
"aws_kinesis": "Kinesis",
|
|
18
|
+
"aws_sqs": "SQS",
|
|
19
|
+
"aws_sns": "SNS",
|
|
20
|
+
"aws_api_gateway": "API Gateway",
|
|
21
|
+
"aws_eks": "EKS"
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def detect_service(resource_type):
|
|
26
|
+
|
|
27
|
+
for prefix, service in SERVICE_PREFIX_MAP.items():
|
|
28
|
+
|
|
29
|
+
if resource_type.startswith(prefix):
|
|
30
|
+
|
|
31
|
+
return service
|
|
32
|
+
|
|
33
|
+
return "Other"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
|
|
3
|
+
def run_terraform_plan(terraform_args):
|
|
4
|
+
|
|
5
|
+
cmd = ["terraform", "plan"] + list(terraform_args) + ["-out=tfplan"]
|
|
6
|
+
|
|
7
|
+
subprocess.run(cmd, check=True)
|
|
8
|
+
|
|
9
|
+
subprocess.run(
|
|
10
|
+
["terraform", "show", "-json", "tfplan"],
|
|
11
|
+
stdout=open("plan.json", "w"),
|
|
12
|
+
check=True
|
|
13
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
setup.py
|
|
2
|
+
tfcost/__init__.py
|
|
3
|
+
tfcost/cli.py
|
|
4
|
+
tfcost/cost_calculator.py
|
|
5
|
+
tfcost/history.py
|
|
6
|
+
tfcost/plan_parser.py
|
|
7
|
+
tfcost/terraform_runner.py
|
|
8
|
+
tfcost.egg-info/PKG-INFO
|
|
9
|
+
tfcost.egg-info/SOURCES.txt
|
|
10
|
+
tfcost.egg-info/dependency_links.txt
|
|
11
|
+
tfcost.egg-info/entry_points.txt
|
|
12
|
+
tfcost.egg-info/requires.txt
|
|
13
|
+
tfcost.egg-info/top_level.txt
|
|
14
|
+
tfcost/pricing/__init__.py
|
|
15
|
+
tfcost/pricing/aws_ebs.py
|
|
16
|
+
tfcost/pricing/aws_ec2.py
|
|
17
|
+
tfcost/pricing/aws_nat_gateway.py
|
|
18
|
+
tfcost/pricing/aws_pricing.py
|
|
19
|
+
tfcost/pricing/aws_s3.py
|
|
20
|
+
tfcost/pricing/resource_pricing_map.py
|
|
21
|
+
tfcost/pricing/service_detector.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
boto3
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tfcost
|