azure-rvtools 1.0.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.
- azure_rvtools-1.0.0/PKG-INFO +18 -0
- azure_rvtools-1.0.0/azure_rvtools/__init__.py +5 -0
- azure_rvtools-1.0.0/azure_rvtools/__main__.py +6 -0
- azure_rvtools-1.0.0/azure_rvtools/cli.py +358 -0
- azure_rvtools-1.0.0/azure_rvtools/excel.py +716 -0
- azure_rvtools-1.0.0/azure_rvtools/output.py +444 -0
- azure_rvtools-1.0.0/azure_rvtools/pricing.py +409 -0
- azure_rvtools-1.0.0/azure_rvtools/rvtools.py +441 -0
- azure_rvtools-1.0.0/azure_rvtools/sku_mapper.py +191 -0
- azure_rvtools-1.0.0/azure_rvtools.egg-info/PKG-INFO +18 -0
- azure_rvtools-1.0.0/azure_rvtools.egg-info/SOURCES.txt +15 -0
- azure_rvtools-1.0.0/azure_rvtools.egg-info/dependency_links.txt +1 -0
- azure_rvtools-1.0.0/azure_rvtools.egg-info/entry_points.txt +2 -0
- azure_rvtools-1.0.0/azure_rvtools.egg-info/requires.txt +3 -0
- azure_rvtools-1.0.0/azure_rvtools.egg-info/top_level.txt +1 -0
- azure_rvtools-1.0.0/pyproject.toml +35 -0
- azure_rvtools-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: azure-rvtools
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Convert RVTools Excel exports into an Azure IaaS cost estimate.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/KimTholstorf/azure-rvtools-cost-estimator
|
|
7
|
+
Project-URL: Repository, https://github.com/KimTholstorf/azure-rvtools-cost-estimator
|
|
8
|
+
Keywords: azure,rvtools,vmware,cloud,cost,estimator
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Topic :: Utilities
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: pandas>=2.0
|
|
17
|
+
Requires-Dist: openpyxl>=3.1
|
|
18
|
+
Requires-Dist: tabulate>=0.9
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .excel import write_workbook
|
|
9
|
+
from .output import (
|
|
10
|
+
ReservationRec,
|
|
11
|
+
VMResult,
|
|
12
|
+
print_recommendations,
|
|
13
|
+
print_summary,
|
|
14
|
+
write_csv,
|
|
15
|
+
)
|
|
16
|
+
from .pricing import PricingClient
|
|
17
|
+
from .rvtools import VMRecord, filter_vms, list_topology, parse_rvtools
|
|
18
|
+
from .sku_mapper import ALL_VM_SKUS, find_disk_tier, find_vm_sku
|
|
19
|
+
|
|
20
|
+
VERSION = "1.0.0"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Argument parsing
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="azure-rvtools",
|
|
30
|
+
description="Convert RVTools Excel exports into an Azure IaaS cost estimate.",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
"--rvtools",
|
|
35
|
+
metavar="PATH",
|
|
36
|
+
required=True,
|
|
37
|
+
help="RVTools XLSX export file",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--region",
|
|
41
|
+
metavar="REGION",
|
|
42
|
+
default=None,
|
|
43
|
+
help="Azure region slug (e.g. westeurope, eastus). Required unless --list is used.",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--pricing",
|
|
48
|
+
choices=["optimized", "all-payg", "all-reserved"],
|
|
49
|
+
default="optimized",
|
|
50
|
+
help="Pricing mode (default: optimized)",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--disk-type",
|
|
54
|
+
choices=["premium-ssd", "standard-ssd", "standard-hdd"],
|
|
55
|
+
default="premium-ssd",
|
|
56
|
+
help="Managed disk type (default: premium-ssd)",
|
|
57
|
+
)
|
|
58
|
+
parser.add_argument(
|
|
59
|
+
"--include-powered-off",
|
|
60
|
+
action="store_true",
|
|
61
|
+
help="Include VMs that are not powered on",
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument(
|
|
64
|
+
"--list",
|
|
65
|
+
action="store_true",
|
|
66
|
+
help="Print all Datacenters and Clusters in the file and exit",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--datacenter",
|
|
70
|
+
metavar="NAME",
|
|
71
|
+
nargs="+",
|
|
72
|
+
help="Only include VMs from these Datacenter(s) (case-insensitive, repeatable)",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--cluster",
|
|
76
|
+
metavar="NAME",
|
|
77
|
+
nargs="+",
|
|
78
|
+
help="Only include VMs from these Cluster(s) (case-insensitive, repeatable)",
|
|
79
|
+
)
|
|
80
|
+
parser.add_argument(
|
|
81
|
+
"--output",
|
|
82
|
+
metavar="PATH",
|
|
83
|
+
default="azure_estimate.xlsx",
|
|
84
|
+
help="Excel workbook output path (default: azure_estimate.xlsx)",
|
|
85
|
+
)
|
|
86
|
+
parser.add_argument(
|
|
87
|
+
"--csv",
|
|
88
|
+
metavar="PATH",
|
|
89
|
+
help="Also write a CSV to this path",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--reserved-term",
|
|
93
|
+
choices=["1-year", "3-year"],
|
|
94
|
+
default="3-year",
|
|
95
|
+
help="Reservation term for optimized / all-reserved pricing (default: 3-year)",
|
|
96
|
+
)
|
|
97
|
+
parser.add_argument(
|
|
98
|
+
"--support",
|
|
99
|
+
choices=["basic", "developer", "standard", "professional-direct"],
|
|
100
|
+
default="basic",
|
|
101
|
+
metavar="PLAN",
|
|
102
|
+
help=(
|
|
103
|
+
"Azure support plan to include in the estimate: "
|
|
104
|
+
"basic (free, default), developer ($29/mo), "
|
|
105
|
+
"standard ($100/mo), professional-direct ($1000/mo)"
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--os-license-included",
|
|
110
|
+
action="store_true",
|
|
111
|
+
dest="os_license_included",
|
|
112
|
+
help=(
|
|
113
|
+
"Use OS-included (Windows) pricing for Windows VMs. "
|
|
114
|
+
"By default Azure Hybrid Benefit is assumed (Linux compute rate)."
|
|
115
|
+
),
|
|
116
|
+
)
|
|
117
|
+
parser.add_argument(
|
|
118
|
+
"--no-cache",
|
|
119
|
+
action="store_true",
|
|
120
|
+
help="Bypass local pricing cache",
|
|
121
|
+
)
|
|
122
|
+
parser.add_argument(
|
|
123
|
+
"--currency",
|
|
124
|
+
metavar="CODE",
|
|
125
|
+
default="USD",
|
|
126
|
+
help="Currency code (default: USD)",
|
|
127
|
+
)
|
|
128
|
+
parser.add_argument(
|
|
129
|
+
"--version",
|
|
130
|
+
action="version",
|
|
131
|
+
version=VERSION,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
args = parser.parse_args(argv)
|
|
135
|
+
|
|
136
|
+
# --region is required for everything except --list
|
|
137
|
+
if not args.list and not args.region:
|
|
138
|
+
parser.error(
|
|
139
|
+
"--region is required (e.g. --region westeurope). "
|
|
140
|
+
"Use --list to inspect the file without specifying a region."
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
return args
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# Recommendation builder
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
def build_recommendations(results: list[VMResult]) -> list[ReservationRec]:
|
|
151
|
+
"""
|
|
152
|
+
Group VMResults by SKU name and build reservation recommendations.
|
|
153
|
+
Only includes VMs where both sku and reserved_compute_monthly are not None.
|
|
154
|
+
Sorted by savings_monthly descending.
|
|
155
|
+
"""
|
|
156
|
+
groups: dict[str, list[VMResult]] = {}
|
|
157
|
+
for r in results:
|
|
158
|
+
if r.sku is None or r.reserved_compute_monthly is None:
|
|
159
|
+
continue
|
|
160
|
+
groups.setdefault(r.sku.name, []).append(r)
|
|
161
|
+
|
|
162
|
+
recs: list[ReservationRec] = []
|
|
163
|
+
for sku_name, group in groups.items():
|
|
164
|
+
payg_total = sum(r.payg_compute_monthly for r in group)
|
|
165
|
+
rsv_total = sum(r.reserved_compute_monthly for r in group) # type: ignore[misc]
|
|
166
|
+
recs.append(
|
|
167
|
+
ReservationRec(
|
|
168
|
+
sku_name=sku_name,
|
|
169
|
+
vm_count=len(group),
|
|
170
|
+
payg_compute_monthly=payg_total,
|
|
171
|
+
rsv_compute_monthly=rsv_total,
|
|
172
|
+
)
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
recs.sort(key=lambda rec: rec.savings_monthly, reverse=True)
|
|
176
|
+
return recs
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
# Main orchestration
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def main(argv: list[str] | None = None) -> int:
|
|
184
|
+
try:
|
|
185
|
+
args = parse_args(argv)
|
|
186
|
+
|
|
187
|
+
# --- Validate input file ---
|
|
188
|
+
rvtools_path = Path(args.rvtools)
|
|
189
|
+
if not rvtools_path.exists():
|
|
190
|
+
print(f"[ERROR] File not found: {rvtools_path}", file=sys.stderr)
|
|
191
|
+
raise SystemExit(1)
|
|
192
|
+
|
|
193
|
+
# --- --list: print topology and exit (no --region needed) ---
|
|
194
|
+
if args.list:
|
|
195
|
+
list_topology(rvtools_path)
|
|
196
|
+
return 0
|
|
197
|
+
|
|
198
|
+
# --- Parse RVTools (always include powered-off so we can count them) ---
|
|
199
|
+
print(f"[INFO] Reading {args.rvtools}...", file=sys.stderr)
|
|
200
|
+
try:
|
|
201
|
+
all_vms: list[VMRecord] = parse_rvtools(rvtools_path, include_powered_off=True)
|
|
202
|
+
except ValueError as exc:
|
|
203
|
+
print(f"[ERROR] Failed to parse RVTools file: {exc}", file=sys.stderr)
|
|
204
|
+
raise SystemExit(1)
|
|
205
|
+
|
|
206
|
+
# --- Apply datacenter / cluster filters ---
|
|
207
|
+
if args.datacenter or args.cluster:
|
|
208
|
+
before = len(all_vms)
|
|
209
|
+
all_vms = filter_vms(all_vms, args.datacenter, args.cluster)
|
|
210
|
+
after = len(all_vms)
|
|
211
|
+
filters = []
|
|
212
|
+
if args.datacenter:
|
|
213
|
+
filters.append(f"Datacenter={', '.join(args.datacenter)}")
|
|
214
|
+
if args.cluster:
|
|
215
|
+
filters.append(f"Cluster={', '.join(args.cluster)}")
|
|
216
|
+
print(
|
|
217
|
+
f"[INFO] Filter ({' | '.join(filters)}): {before} → {after} VM(s).",
|
|
218
|
+
file=sys.stderr,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if not all_vms:
|
|
222
|
+
print(
|
|
223
|
+
"[WARN] No VMs remain after filtering (use --list to see available names).",
|
|
224
|
+
file=sys.stderr,
|
|
225
|
+
)
|
|
226
|
+
return 0
|
|
227
|
+
|
|
228
|
+
powered_on_count = sum(1 for v in all_vms if v.is_powered_on)
|
|
229
|
+
powered_off_count = sum(1 for v in all_vms if not v.is_powered_on)
|
|
230
|
+
print(
|
|
231
|
+
f"[INFO] Found {len(all_vms)} VM(s) "
|
|
232
|
+
f"({powered_on_count} powered-on, {powered_off_count} powered-off).",
|
|
233
|
+
file=sys.stderr,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
# Drop powered-off VMs from cost processing unless explicitly included
|
|
237
|
+
vms = all_vms if args.include_powered_off else [v for v in all_vms if v.is_powered_on]
|
|
238
|
+
|
|
239
|
+
# --- Pricing client ---
|
|
240
|
+
client = PricingClient(
|
|
241
|
+
region=args.region,
|
|
242
|
+
currency=args.currency,
|
|
243
|
+
no_cache=args.no_cache,
|
|
244
|
+
)
|
|
245
|
+
client.ensure_loaded(args.disk_type)
|
|
246
|
+
|
|
247
|
+
# --- Build results ---
|
|
248
|
+
results: list[VMResult] = []
|
|
249
|
+
|
|
250
|
+
for vm in vms:
|
|
251
|
+
# SKU matching
|
|
252
|
+
sku_match = find_vm_sku(vm.vcpus, vm.ram_gb)
|
|
253
|
+
|
|
254
|
+
# Disk tier mapping
|
|
255
|
+
disk_tier_counts: dict[str, int] = {}
|
|
256
|
+
disk_monthly = 0.0
|
|
257
|
+
|
|
258
|
+
for disk in vm.effective_disks:
|
|
259
|
+
tier = find_disk_tier(disk.capacity_gb, args.disk_type)
|
|
260
|
+
if tier is not None:
|
|
261
|
+
disk_tier_counts[tier.tier] = disk_tier_counts.get(tier.tier, 0) + 1
|
|
262
|
+
disk_monthly += client.get_disk_price(tier.tier, args.disk_type)
|
|
263
|
+
|
|
264
|
+
# Sort disk tiers by numeric size (P2 < P10, not alphabetically)
|
|
265
|
+
def _tier_num(name: str) -> int:
|
|
266
|
+
m = re.search(r"\d+", name)
|
|
267
|
+
return int(m.group()) if m else 0
|
|
268
|
+
|
|
269
|
+
disk_tiers_sorted = sorted(disk_tier_counts.items(), key=lambda x: _tier_num(x[0]))
|
|
270
|
+
|
|
271
|
+
# VM pricing
|
|
272
|
+
payg_compute_monthly = 0.0
|
|
273
|
+
reserved_compute_monthly: float | None = None
|
|
274
|
+
|
|
275
|
+
if sku_match is not None and vm.is_powered_on:
|
|
276
|
+
vm_prices = client.get_vm_prices(
|
|
277
|
+
sku_match.sku.name,
|
|
278
|
+
vm.os_type,
|
|
279
|
+
hybrid_benefit=not args.os_license_included,
|
|
280
|
+
)
|
|
281
|
+
payg_compute_monthly = vm_prices.payg_monthly
|
|
282
|
+
if args.reserved_term == "3-year":
|
|
283
|
+
reserved_compute_monthly = vm_prices.reserved_3yr_monthly
|
|
284
|
+
else:
|
|
285
|
+
reserved_compute_monthly = vm_prices.reserved_1yr_monthly
|
|
286
|
+
|
|
287
|
+
results.append(
|
|
288
|
+
VMResult(
|
|
289
|
+
vm=vm,
|
|
290
|
+
sku=sku_match.sku if sku_match else None,
|
|
291
|
+
sku_notes=sku_match.notes if sku_match else [],
|
|
292
|
+
disk_tiers=disk_tiers_sorted,
|
|
293
|
+
payg_compute_monthly=payg_compute_monthly,
|
|
294
|
+
reserved_compute_monthly=reserved_compute_monthly,
|
|
295
|
+
disk_monthly=disk_monthly,
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
# --- Build recommendations ---
|
|
300
|
+
recommendations: list[ReservationRec] = []
|
|
301
|
+
if args.pricing == "optimized":
|
|
302
|
+
recommendations = build_recommendations(results)
|
|
303
|
+
|
|
304
|
+
# --- Output ---
|
|
305
|
+
total_disk_monthly = sum(r.disk_monthly for r in results if r.vm.is_powered_on)
|
|
306
|
+
|
|
307
|
+
print_summary(
|
|
308
|
+
results=results,
|
|
309
|
+
recommendations=recommendations,
|
|
310
|
+
pricing_mode=args.pricing,
|
|
311
|
+
currency=args.currency,
|
|
312
|
+
region=args.region,
|
|
313
|
+
disk_type=args.disk_type,
|
|
314
|
+
reserved_term=args.reserved_term,
|
|
315
|
+
hybrid_benefit=not args.os_license_included,
|
|
316
|
+
support_plan=args.support,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
if args.pricing == "optimized" and recommendations:
|
|
320
|
+
print("")
|
|
321
|
+
print_recommendations(recommendations, args.currency, total_disk_monthly,
|
|
322
|
+
args.reserved_term)
|
|
323
|
+
|
|
324
|
+
# --- Excel workbook ---
|
|
325
|
+
print(f"[INFO] Writing Excel workbook to {args.output}...", file=sys.stderr)
|
|
326
|
+
write_workbook(
|
|
327
|
+
results=results,
|
|
328
|
+
recommendations=recommendations,
|
|
329
|
+
output_path=args.output,
|
|
330
|
+
region=args.region,
|
|
331
|
+
currency=args.currency,
|
|
332
|
+
pricing_mode=args.pricing,
|
|
333
|
+
disk_type=args.disk_type,
|
|
334
|
+
rvtools_filename=Path(args.rvtools).name,
|
|
335
|
+
reserved_term=args.reserved_term,
|
|
336
|
+
hybrid_benefit=not args.os_license_included,
|
|
337
|
+
support_plan=args.support,
|
|
338
|
+
)
|
|
339
|
+
print(f"[INFO] Workbook saved: {args.output}", file=sys.stderr)
|
|
340
|
+
|
|
341
|
+
# --- CSV output ---
|
|
342
|
+
if args.csv:
|
|
343
|
+
write_csv(results, recommendations, args.csv, args.pricing, args.reserved_term)
|
|
344
|
+
print(f"[INFO] CSV written to {args.csv}", file=sys.stderr)
|
|
345
|
+
|
|
346
|
+
return 0
|
|
347
|
+
|
|
348
|
+
except SystemExit as exc:
|
|
349
|
+
code = exc.code
|
|
350
|
+
if isinstance(code, int):
|
|
351
|
+
return code
|
|
352
|
+
return 1
|
|
353
|
+
except KeyboardInterrupt:
|
|
354
|
+
print("\n[INFO] Interrupted.", file=sys.stderr)
|
|
355
|
+
return 130
|
|
356
|
+
except Exception as exc: # noqa: BLE001
|
|
357
|
+
print(f"[ERROR] {exc}", file=sys.stderr)
|
|
358
|
+
return 1
|