Setting the file. One moment.
IO Optimized Analyzer · Amazon Aurora PostgreSQL · aws/agent-toolkit-for-aws · Skills Docs
ContentsBack to the top of the page 62.7
Create Instructions
92
Routing Traffic With Route53 And CloudFront
Resilience Program Design
Debugging Lambda Timeouts
def _emit
— line 574
This file
Number 62.41
Position 41 of 41
Type Python
Size 26 KB
Lines 639 scripts/ io_optimized_analyzer.py
Python · 639 lines · 26 KB
16 python io_optimized_analyzer.py --all --region us-east-1 --days 14
17 python io_optimized_analyzer.py offline --instance db.r6g.2xlarge \\
18 --num-instances 2 --storage-gib 800 --monthly-io-millions 1200
19 """
20
21 from __future__ import annotations
22
23 import argparse
24 import datetime as dt
25 import json
26 import sys
27 from typing import Any
28
29 # ---------------------------------------------------------------------------
30 # Pricing constants (us-east-1). Overridden by live API when available.
31 # ---------------------------------------------------------------------------
32 STORAGE_STANDARD_PER_GIB = 0.10 # $/GiB-month, Standard
33 STORAGE_IO_OPT_PER_GIB = 0.225 # $/GiB-month, I/O-Optimized
34 IO_COST_PER_MILLION = 0.20 # $/million I/O requests, Standard only
35 IO_OPT_COMPUTE_MULTIPLIER = 1.30 # 30% compute premium on I/O-Optimized
36 IO_OPT_BREAKEVEN_PCT = 25.0
37 HOURS_PER_MONTH = 730
38 MIN_VIABLE_DAYS = 7.0
39
40 # Static instance hourly prices (us-east-1, Standard, Aurora PostgreSQL).
41 # Same rates apply to MySQL; I/O-Optimized is derived via the multiplier.
42 _STATIC_INSTANCE_PRICES = {
43 "db.t3.medium" : 0.082 ,
44 "db.t3.large" : 0.164 ,
45 "db.t4g.medium" : 0.073 ,
46 "db.t4g.large" : 0.146 ,
47 "db.r5.large" : 0.290 ,
48 "db.r5.xlarge" : 0.580 ,
49 "db.r5.2xlarge" : 1.160 ,
50 "db.r5.4xlarge" : 2.320 ,
51 "db.r5.8xlarge" : 4.640 ,
52 "db.r5.12xlarge" : 6.960 ,
53 "db.r5.16xlarge" : 9.280 ,
54 "db.r5.24xlarge" : 13.920 ,
55 "db.r6g.large" : 0.260 ,
56 "db.r6g.xlarge" : 0.519 ,
57 "db.r6g.2xlarge" : 1.038 ,
58 "db.r6g.4xlarge" : 2.076 ,
59 "db.r6g.8xlarge" : 4.152 ,
60 "db.r6g.12xlarge" : 6.228 ,
61 "db.r6g.16xlarge" : 8.304 ,
62 "db.r7g.large" : 0.276 ,
63 "db.r7g.xlarge" : 0.553 ,
64 "db.r7g.2xlarge" : 1.106 ,
65 "db.r7g.4xlarge" : 2.211 ,
66 "db.r7g.8xlarge" : 4.422 ,
67 "db.r7g.12xlarge" : 6.633 ,
68 "db.r7g.16xlarge" : 8.844 ,
69 "db.r8g.large" : 0.276 ,
70 "db.r8g.xlarge" : 0.552 ,
71 "db.r8g.2xlarge" : 1.104 ,
72 "db.r8g.4xlarge" : 2.208 ,
73 "db.r8g.8xlarge" : 4.416 ,
74 "db.r8g.12xlarge" : 6.624 ,
75 "db.r8g.16xlarge" : 8.832 ,
76 "db.r8g.24xlarge" : 13.248 ,
77 "db.r8g.48xlarge" : 26.496 ,
78 }
79
80 _REGION_NAMES = {
81 "us-east-1" : "US East (N. Virginia)" ,
82 "us-east-2" : "US East (Ohio)" ,
83 "us-west-1" : "US West (N. California)" ,
84 "us-west-2" : "US West (Oregon)" ,
85 "eu-west-1" : "EU (Ireland)" ,
86 "eu-west-2" : "EU (London)" ,
87 "eu-central-1" : "EU (Frankfurt)" ,
88 "eu-north-1" : "EU (Stockholm)" ,
89 "ap-southeast-1" : "Asia Pacific (Singapore)" ,
90 "ap-southeast-2" : "Asia Pacific (Sydney)" ,
91 "ap-northeast-1" : "Asia Pacific (Tokyo)" ,
92 "ap-south-1" : "Asia Pacific (Mumbai)" ,
93 "ca-central-1" : "Canada (Central)" ,
94 "sa-east-1" : "South America (Sao Paulo)" ,
95 }
96
97 INSTANCE_PRICES = dict ( _STATIC_INSTANCE_PRICES )
98 _pricing_source: dict[ str , Any] = { "source" : "static_fallback" , "region" : "us-east-1" }
99
100
101 # ---------------------------------------------------------------------------
102 # Live pricing (best-effort)
103 # ---------------------------------------------------------------------------
104
105
106 def refresh_pricing (region: str ) -> dict :
107 """Try to fetch live instance + storage + I/O pricing. Silent fallback on failure."""
108 global INSTANCE_PRICES , STORAGE_STANDARD_PER_GIB , STORAGE_IO_OPT_PER_GIB
109 global IO_COST_PER_MILLION , _pricing_source
110
111 location = _REGION_NAMES .get(region)
112 if not location:
113 _pricing_source = {
114 "source" : "static_fallback" ,
115 "region" : region,
116 "note" : f "Region { region } not mapped; using us-east-1 defaults" ,
117 }
118 return _pricing_source
119
120 try :
121 import boto3
122 except ImportError :
123 _pricing_source = {
124 "source" : "static_fallback" ,
125 "region" : region,
126 "note" : "boto3 not installed" ,
127 }
128 return _pricing_source
129
130 try :
131 pricing = boto3.client( "pricing" , region_name = "us-east-1" )
132 live_instances = 0
133 filters = [
134 { "Type" : "TERM_MATCH" , "Field" : "databaseEngine" , "Value" : "Aurora PostgreSQL" },
135 { "Type" : "TERM_MATCH" , "Field" : "location" , "Value" : location},
136 { "Type" : "TERM_MATCH" , "Field" : "deploymentOption" , "Value" : "Single-AZ" },
137 { "Type" : "TERM_MATCH" , "Field" : "termType" , "Value" : "OnDemand" },
138 ]
139 for page in pricing.get_paginator( "get_products" ).paginate(
140 ServiceCode = "AmazonRDS" , Filters = filters
141 ):
142 for item_json in page[ "PriceList" ]:
143 item = json.loads(item_json) if isinstance (item_json, str ) else item_json
144 attrs = item.get( "product" , {}).get( "attributes" , {})
145 itype = attrs.get( "instanceType" , "" )
146 if not itype.startswith( "db." ):
147 continue
148 if "IOOptimized" in attrs.get( "usagetype" , "" ):
149 continue
150 for term in item.get( "terms" , {}).get( "OnDemand" , {}).values():
151 for dim in term.get( "priceDimensions" , {}).values():
152 try :
153 price = float (dim.get( "pricePerUnit" , {}).get( "USD" , "0" ))
154 except ( ValueError , TypeError ):
155 continue
156 if price > 0 :
157 INSTANCE_PRICES [itype] = price
158 live_instances += 1
159
160 # Storage + I/O pricing
161 storage_filters = [
162 { "Type" : "TERM_MATCH" , "Field" : "databaseEngine" , "Value" : "Aurora PostgreSQL" },
163 { "Type" : "TERM_MATCH" , "Field" : "location" , "Value" : location},
164 { "Type" : "TERM_MATCH" , "Field" : "productFamily" , "Value" : "Database Storage" },
165 { "Type" : "TERM_MATCH" , "Field" : "termType" , "Value" : "OnDemand" },
166 ]
167 for page in pricing.get_paginator( "get_products" ).paginate(
168 ServiceCode = "AmazonRDS" , Filters = storage_filters
169 ):
170 for item_json in page[ "PriceList" ]:
171 item = json.loads(item_json) if isinstance (item_json, str ) else item_json
172 attrs = item.get( "product" , {}).get( "attributes" , {})
173 usage = attrs.get( "usagetype" , "" )
174 for term in item.get( "terms" , {}).get( "OnDemand" , {}).values():
175 for dim in term.get( "priceDimensions" , {}).values():
176 try :
177 price = float (dim.get( "pricePerUnit" , {}).get( "USD" , "0" ))
178 except ( ValueError , TypeError ):
179 continue
180 if price <= 0 :
181 continue
182 if "IOOptimized" in usage:
183 STORAGE_IO_OPT_PER_GIB = price
184 elif "Aurora:StorageUsage" in usage:
185 STORAGE_STANDARD_PER_GIB = price
186 elif "Aurora:StorageIOUsage" in usage:
187 IO_COST_PER_MILLION = price * 1_000_000 # per-request -> per-million
188
189 _pricing_source = {
190 "source" : "live" ,
191 "region" : region,
192 "live_instances" : live_instances,
193 "storage_standard" : STORAGE_STANDARD_PER_GIB ,
194 "storage_io_opt" : STORAGE_IO_OPT_PER_GIB ,
195 "io_per_million" : IO_COST_PER_MILLION ,
196 }
197 except Exception as e:
198 _pricing_source = { "source" : "static_fallback" , "region" : region, "error" : str (e)}
199
200 return _pricing_source
201
202
203 # ---------------------------------------------------------------------------
204 # Core calculation (shared by live and offline paths)
205 # ---------------------------------------------------------------------------
206
207
208 def compute_comparison (
209 compute_monthly: float ,
210 storage_gib: float ,
211 monthly_io_millions: float ,
212 ) -> dict :
213 """Return Standard vs I/O-Optimized comparison and recommendation."""
214 storage_std = storage_gib * STORAGE_STANDARD_PER_GIB
215 io_cost = monthly_io_millions * IO_COST_PER_MILLION
216 total_std = compute_monthly + storage_std + io_cost
217
218 compute_io_opt = compute_monthly * IO_OPT_COMPUTE_MULTIPLIER
219 storage_io_opt = storage_gib * STORAGE_IO_OPT_PER_GIB
220 total_io_opt = compute_io_opt + storage_io_opt
221
222 io_pct = (io_cost / total_std * 100 ) if total_std > 0 else 0
223 savings = total_std - total_io_opt
224
225 # Drive the recommendation off the ACTUAL dollar savings, not the 25% heuristic
226 # alone — near the breakeven boundary the two diverge, and gating purely on the
227 # threshold can recommend I/O-Optimized while it actually costs more (and print a
228 # nonsensical "saves $-N/mo"). The 25% rule is a useful rule-of-thumb but the real
229 # decision is whether the I/O charges eliminated exceed the compute+storage premium.
230 threshold_note = (
231 f "I/O is { io_pct :.0f} % of total cost "
232 f "( { '≥' if io_pct >= IO_OPT_BREAKEVEN_PCT else 'below ' }{ IO_OPT_BREAKEVEN_PCT :.0f} % rule-of-thumb)."
233 )
234 if savings > 0 :
235 rec = "io_optimized"
236 reason = f " { threshold_note } I/O-Optimized saves $ { savings :.0f} /mo."
237 else :
238 rec = "standard"
239 reason = f " { threshold_note } I/O-Optimized would cost $ { abs (savings) :.0f} /mo more."
240
241 return {
242 "standard" : {
243 "compute_monthly" : round (compute_monthly, 2 ),
244 "storage_monthly" : round (storage_std, 2 ),
245 "io_monthly" : round (io_cost, 2 ),
246 "total_monthly" : round (total_std, 2 ),
247 },
248 "io_optimized" : {
249 "compute_monthly" : round (compute_io_opt, 2 ),
250 "storage_monthly" : round (storage_io_opt, 2 ),
251 "io_monthly" : 0.0 ,
252 "total_monthly" : round (total_io_opt, 2 ),
253 },
254 "monthly_io_millions" : round (monthly_io_millions, 1 ),
255 "storage_gib" : round (storage_gib, 1 ),
256 "io_cost_pct_of_total" : round (io_pct, 1 ),
257 "savings_with_io_opt" : round (savings, 2 ),
258 "recommendation" : rec,
259 "reason" : reason,
260 }
261
262
263 def data_quality_tag (days: float ) -> str :
264 if days < 3 :
265 return "insufficient"
266 if days < 7 :
267 return "short"
268 if days < 14 :
269 return "adequate"
270 return "good"
271
272
273 # ---------------------------------------------------------------------------
274 # Live AWS path
275 # ---------------------------------------------------------------------------
276
277
278 def _sum_metric (cw, cluster_id: str , metric: str , start: dt.datetime, end: dt.datetime) -> float :
279 """Sum a CloudWatch metric over the window. Returns total."""
280 resp = cw.get_metric_statistics(
281 Namespace = "AWS/RDS" ,
282 MetricName = metric,
283 Dimensions = [{ "Name" : "DBClusterIdentifier" , "Value" : cluster_id}],
284 StartTime = start,
285 EndTime = end,
286 Period = 3600 ,
287 Statistics = [ "Sum" ],
288 )
289 return sum (dp.get( "Sum" , 0 ) for dp in resp.get( "Datapoints" , []))
290
291
292 def _avg_metric (cw, cluster_id: str , metric: str , start: dt.datetime, end: dt.datetime) -> float :
293 """Average a CloudWatch metric over the window."""
294 resp = cw.get_metric_statistics(
295 Namespace = "AWS/RDS" ,
296 MetricName = metric,
297 Dimensions = [{ "Name" : "DBClusterIdentifier" , "Value" : cluster_id}],
298 StartTime = start,
299 EndTime = end,
300 Period = 3600 ,
301 Statistics = [ "Average" ],
302 )
303 dps = resp.get( "Datapoints" , [])
304 return ( sum (dp.get( "Average" , 0 ) for dp in dps) / len (dps)) if dps else 0.0
305
306
307 def _is_empty_cluster (cluster: dict ) -> bool :
308 """Skip clusters with no compute to analyze.
309
310 An Aurora cluster with no DB instances has no compute cost to compare. The
311 genuine causes are a cluster whose last writer/reader instance was deleted,
312 or an Aurora Limitless cluster (which is locked to I/O-Optimized and uses a
313 different pricing model). Note: an auto-paused (scale-to-zero) Aurora
314 serverless instance still appears in DBClusterMembers and is analyzable, so
315 it is NOT an empty cluster. The cost comparison doesn't apply here — skip.
316 """
317 return len (cluster.get( "DBClusterMembers" , [])) == 0
318
319
320 def analyze_cluster_live (cluster_id: str , region: str , days: int ) -> dict :
321 """Analyze a single cluster using live AWS APIs."""
322 import boto3
323
324 rds = boto3.client( "rds" , region_name = region)
325 cw = boto3.client( "cloudwatch" , region_name = region)
326
327 # Cluster metadata
328 resp = rds.describe_db_clusters( DBClusterIdentifier = cluster_id)
329 clusters = resp.get( "DBClusters" , [])
330 if not clusters:
331 return { "cluster_id" : cluster_id, "error" : "cluster not found" }
332 cluster = clusters[ 0 ]
333 current_storage_type = cluster.get( "StorageType" , "aurora" ) # 'aurora' or 'aurora-iopt1'
334 engine = cluster.get( "Engine" , "" )
335
336 # Guardrail: skip clusters with no DB instances (last instance deleted, or Aurora Limitless)
337 if _is_empty_cluster(cluster):
338 return {
339 "cluster_id" : cluster_id,
340 "engine" : engine,
341 "engine_version" : cluster.get( "EngineVersion" , "" ),
342 "current_storage_type" : current_storage_type,
343 "skipped" : True ,
344 "reason" : (
345 "Cluster has no DB instances — no compute to analyze. "
346 "This usually means the cluster's last writer/reader instance "
347 "was deleted, or it is an Aurora Limitless cluster (locked to "
348 "I/O-Optimized, different pricing model). The Standard vs "
349 "I/O-Optimized comparison does not apply."
350 ),
351 }
352
353 # Get instance types in the cluster
354 member_ids = [m[ "DBInstanceIdentifier" ] for m in cluster.get( "DBClusterMembers" , [])]
355 compute_monthly = 0.0
356 instance_summary = []
357 compute_warnings = []
358 for mid in member_ids:
359 try :
360 inst_resp = rds.describe_db_instances( DBInstanceIdentifier = mid)
361 for inst in inst_resp.get( "DBInstances" , []):
362 itype = inst.get( "DBInstanceClass" , "" )
363 # Aurora Serverless v2 (db.serverless) has no fixed hourly rate — it bills
364 # per-ACU-hour from a CloudWatch metric, not from INSTANCE_PRICES. Counting it
365 # at $0 would silently understate compute and skew the I/O-cost percentage, so
366 # exclude it and flag the estimate as partial rather than emit a wrong number.
367 if itype == "db.serverless" :
368 instance_summary.append(
369 { "id" : mid, "type" : itype, "note" : "serverless_excluded" }
370 )
371 compute_warnings.append(
372 f " { mid } is Aurora Serverless v2 (db.serverless) — its ACU-based compute "
373 "cost is not included (it has no fixed hourly rate); the Standard vs "
374 "I/O-Optimized compute figures below cover provisioned instances only."
375 )
376 continue
377 price = INSTANCE_PRICES .get(itype, 0.0 )
378 if price == 0.0 :
379 # Unknown/unpriced provisioned type — don't silently add $0.
380 instance_summary.append({ "id" : mid, "type" : itype, "note" : "unknown_price" })
381 compute_warnings.append(
382 f " { mid } ( { itype } ) has no known hourly price in the static/live table — "
383 "excluded from the compute estimate; results are partial."
384 )
385 continue
386 compute_monthly += price * HOURS_PER_MONTH
387 instance_summary.append({ "id" : mid, "type" : itype, "price_hr" : price})
388 except Exception as e:
389 instance_summary.append({ "id" : mid, "error" : str (e)})
390
391 # CloudWatch window
392 end = dt.datetime.now(dt.timezone.utc).replace( minute = 0 , second = 0 , microsecond = 0 )
393 start = end - dt.timedelta( days = days)
394 observed_hours = days * 24
395
396 read_io = _sum_metric(cw, cluster_id, "VolumeReadIOPs" , start, end)
397 write_io = _sum_metric(cw, cluster_id, "VolumeWriteIOPs" , start, end)
398 total_io = read_io + write_io
399 # Extrapolate to 730-hour month
400 monthly_io = (total_io / observed_hours * HOURS_PER_MONTH ) if observed_hours > 0 else 0
401 monthly_io_millions = monthly_io / 1_000_000
402
403 # Storage (average)
404 avg_bytes = _avg_metric(cw, cluster_id, "VolumeBytesUsed" , start, end)
405 storage_gib = avg_bytes / ( 1024 ** 3 ) # Aurora bills actual usage; no fixed minimum
406
407 comparison = compute_comparison(compute_monthly, storage_gib, monthly_io_millions)
408
409 result = {
410 "cluster_id" : cluster_id,
411 "engine" : engine,
412 "current_storage_type" : current_storage_type,
413 "instances" : instance_summary,
414 "lookback_days" : days,
415 "data_quality" : data_quality_tag(days),
416 "observed_io_total" : int (total_io),
417 ** comparison,
418 }
419 if compute_warnings:
420 result[ "compute_partial" ] = True
421 result[ "compute_warnings" ] = compute_warnings
422 return result
423
424
425 def list_clusters (region: str ) -> list[ str ]:
426 import boto3
427
428 rds = boto3.client( "rds" , region_name = region)
429 names = []
430 for page in rds.get_paginator( "describe_db_clusters" ).paginate():
431 for c in page.get( "DBClusters" , []):
432 if c.get( "Engine" , "" ).startswith( "aurora" ):
433 names.append(c[ "DBClusterIdentifier" ])
434 return names
435
436
437 # ---------------------------------------------------------------------------
438 # Offline path (no AWS calls)
439 # ---------------------------------------------------------------------------
440
441
442 def analyze_offline (
443 instance: str ,
444 num_instances: int ,
445 storage_gib: float ,
446 monthly_io_millions: float ,
447 ) -> dict :
448 if instance not in INSTANCE_PRICES :
449 return {
450 "error" : f "Unknown instance type: { instance } . "
451 f "Supported: { ', ' .join( sorted ( INSTANCE_PRICES )) } "
452 }
453 compute_monthly = INSTANCE_PRICES [instance] * HOURS_PER_MONTH * num_instances
454 comparison = compute_comparison(compute_monthly, storage_gib, monthly_io_millions)
455 return {
456 "cluster_id" : "offline-input" ,
457 "instance_type" : instance,
458 "num_instances" : num_instances,
459 "data_quality" : "user_supplied" ,
460 ** comparison,
461 }
462
463
464 # ---------------------------------------------------------------------------
465 # CLI
466 # ---------------------------------------------------------------------------
467
468
469 def main ():
470 parser = argparse.ArgumentParser( description = "Aurora I/O-Optimized vs Standard cost analyzer" )
471 parser.add_argument( "--region" , default = "us-east-1" )
472 parser.add_argument(
473 "--days" , type = int , default = 14 , help = "CloudWatch lookback window (default 14)"
474 )
475 parser.add_argument( "--format" , choices = [ "json" , "table" ], default = "json" )
476
477 # Modes are positional/optional
478 parser.add_argument( "--cluster" , help = "Analyze a single cluster by identifier" )
479 parser.add_argument(
480 "--all" , action = "store_true" , help = "Analyze all Aurora clusters in the region"
481 )
482
483 sub = parser.add_subparsers( dest = "mode" )
484 off = sub.add_parser( "offline" , help = "Use user-supplied numbers, no AWS calls" )
485 off.add_argument( "--instance" , required = True )
486 off.add_argument( "--num-instances" , type = int , default = 1 )
487 off.add_argument( "--storage-gib" , type = float , required = True )
488 off.add_argument( "--monthly-io-millions" , type = float , required = True )
489 # --region / --format are already defined on the main parser. Re-declare them on
490 # the offline subparser so they are ALSO accepted after the subcommand, but with
491 # SUPPRESSed defaults so a copy doesn't clobber a value passed before 'offline';
492 # the real default is resolved once, post-parse, below.
493 off.add_argument( "--region" , default = argparse. SUPPRESS )
494 off.add_argument( "--format" , choices = [ "json" , "table" ], default = argparse. SUPPRESS )
495
496 args = parser.parse_args()
497 if not hasattr (args, "region" ) or args.region is None :
498 args.region = "us-east-1"
499 if not hasattr (args, "format" ) or args.format is None :
500 args.format = "json"
501
502 # Offline mode
503 if args.mode == "offline" :
504 # Still attempt to refresh pricing so regional factors can apply
505 refresh_pricing(args.region)
506 result = analyze_offline(
507 args.instance, args.num_instances, args.storage_gib, args.monthly_io_millions
508 )
509 result[ "pricing_source" ] = _pricing_source
510 _emit(result, args.format)
511 return
512
513 # Live modes require boto3
514 try :
515 import boto3 # noqa: F401
516 except ImportError :
517 print (
518 "ERROR: boto3 required for live AWS analysis. Install boto3 or use the 'offline' subcommand." ,
519 file = sys.stderr,
520 )
521 sys.exit( 2 )
522
523 refresh_pricing(args.region)
524
525 if args.all:
526 cluster_ids = list_clusters(args.region)
527 if not cluster_ids:
528 print (json.dumps({ "status" : "ok" , "region" : args.region, "clusters" : []}, indent = 2 ))
529 return
530 results = [analyze_cluster_live(cid, args.region, args.days) for cid in cluster_ids]
531 summary = _fleet_summary(results)
532 output = {
533 "region" : args.region,
534 "pricing_source" : _pricing_source,
535 "summary" : summary,
536 "clusters" : results,
537 }
538 _emit(output, args.format, fleet = True )
539 return
540
541 if args.cluster:
542 result = analyze_cluster_live(args.cluster, args.region, args.days)
543 result[ "pricing_source" ] = _pricing_source
544 _emit(result, args.format)
545 return
546
547 parser.print_help()
548
549
550 def _fleet_summary (results: list[ dict ]) -> dict :
551 # Exclude errored and skipped clusters (e.g., Limitless) from dollar totals
552 analyzable = [r for r in results if "error" not in r and not r.get( "skipped" )]
553 skipped = [r for r in results if r.get( "skipped" )]
554 total_std = sum (r.get( "standard" , {}).get( "total_monthly" , 0 ) for r in analyzable)
555 total_io_opt = sum (r.get( "io_optimized" , {}).get( "total_monthly" , 0 ) for r in analyzable)
556 switch_wins = [r[ "cluster_id" ] for r in analyzable if r.get( "recommendation" ) == "io_optimized" ]
557 return {
558 "cluster_count" : len (results),
559 "analyzable_count" : len (analyzable),
560 "skipped_count" : len (skipped),
561 "skipped_clusters" : [
562 { "cluster_id" : r[ "cluster_id" ], "reason" : r.get( "reason" , "" )} for r in skipped
563 ],
564 "clusters_that_should_switch" : switch_wins,
565 "current_monthly_total_standard" : round (total_std, 2 ),
566 "if_all_on_io_optimized_monthly" : round (total_io_opt, 2 ),
567 "optimal_savings_monthly" : round (
568 sum ( max ( 0 , r.get( "savings_with_io_opt" , 0 )) for r in analyzable),
569 2 ,
570 ),
571 }
572
573
574 def _emit (result: dict , fmt: str , fleet: bool = False ) -> None :
575 if fmt == "json" :
576 print (json.dumps(result, indent = 2 , default = str ))
577 return
578 # Table format
579 if fleet:
580 s = result[ "summary" ]
581 print (
582 f "Region: { result[ 'region' ] } Clusters: { s[ 'cluster_count' ] } "
583 f "(analyzable: { s[ 'analyzable_count' ] } , skipped: { s[ 'skipped_count' ] } )"
584 )
585 print ( f " Current (Standard): $ { s[ 'current_monthly_total_standard' ] :.0f} /mo" )
586 print ( f " All on I/O-Optimized: $ { s[ 'if_all_on_io_optimized_monthly' ] :.0f} /mo" )
587 print ( f " Optimal (switch winners): saves $ { s[ 'optimal_savings_monthly' ] :.0f} /mo" )
588 print ( f " Clusters to switch: { ', ' .join(s[ 'clusters_that_should_switch' ]) or '(none)' } " )
589 if s.get( "skipped_clusters" ):
590 print ( f " Skipped (not applicable):" )
591 for sc in s[ "skipped_clusters" ]:
592 print ( f " - { sc[ 'cluster_id' ] } : { sc[ 'reason' ][: 80 ] } " )
593 print ()
594 print ( f " { 'Cluster' :<30} { 'I/O %' :>6} { 'Std $/mo' :>10} { 'IOOpt $/mo' :>12} { 'Rec' :>14} " )
595 print ( "-" * 76 )
596 for r in result[ "clusters" ]:
597 if "error" in r:
598 print ( f " { r[ 'cluster_id' ] :<30} ERROR: { r[ 'error' ] } " )
599 continue
600 if r.get( "skipped" ):
601 print (
602 f " { r[ 'cluster_id' ] :<30} { '—' :>6} { '—' :>10} { '—' :>12} { 'skipped (limitless)' :>20} "
603 )
604 continue
605 print (
606 f " { r[ 'cluster_id' ] :<30} { r[ 'io_cost_pct_of_total' ] :>5.0f} % "
607 f " { r[ 'standard' ][ 'total_monthly' ] :>10.0f} "
608 f " { r[ 'io_optimized' ][ 'total_monthly' ] :>12.0f} "
609 f " { r[ 'recommendation' ] :>14} "
610 )
611 return
612 # Single cluster
613 r = result
614 if r.get( "skipped" ):
615 print ( f "Cluster: { r.get( 'cluster_id' , '?' ) } " )
616 print ( f " Engine: { r.get( 'engine' , '?' ) } { r.get( 'engine_version' , '' ) } " )
617 print ( f " Status: SKIPPED — not applicable" )
618 print ( f " { r.get( 'reason' , '' ) } " )
619 return
620 print ( f "Cluster: { r.get( 'cluster_id' , '?' ) } ( { r.get( 'data_quality' , '?' ) } data)" )
621 print ( f " Current storage type: { r.get( 'current_storage_type' , '?' ) } " )
622 print ( f " Monthly I/O: { r.get( 'monthly_io_millions' , 0 ) :.0f} M requests" )
623 print ( f " Storage: { r.get( 'storage_gib' , 0 ) :.0f} GiB" )
624 print ()
625 std = r[ "standard" ]
626 ioo = r[ "io_optimized" ]
627 print ( f " { 'Component' :<12} { 'Standard' :>12} { 'I/O-Optimized' :>15} " )
628 print ( f " { 'Compute' :<12} { std[ 'compute_monthly' ] :>12.0f} { ioo[ 'compute_monthly' ] :>15.0f} " )
629 print ( f " { 'Storage' :<12} { std[ 'storage_monthly' ] :>12.0f} { ioo[ 'storage_monthly' ] :>15.0f} " )
630 print ( f " { 'I/O' :<12} { std[ 'io_monthly' ] :>12.0f} { ioo[ 'io_monthly' ] :>15.0f} " )
631 print ( f " { 'Total' :<12} { std[ 'total_monthly' ] :>12.0f} { ioo[ 'total_monthly' ] :>15.0f} " )
632 print ()
633 print ( f " I/O cost: { r[ 'io_cost_pct_of_total' ] :.0f} % of total" )
634 print ( f " Recommendation: { r[ 'recommendation' ].upper() } " )
635 print ( f " { r[ 'reason' ] } " )
636
637
638 if __name__ == "__main__" :
639 main()