Setting the file. One moment. Commitment Pricing Analyzer · Amazon Aurora PostgreSQL · aws/agent-toolkit-for-aws · Skills Docs62.7
Create Instructions
92
Routing Traffic With Route53 And CloudFront
Resilience Program Design
Debugging Lambda Timeouts
def ri_adjusted_monthly
— line 418
This file
- Number
- 62.40
- Position
- 40 of 41
- Type
- Python
- Size
- 33 KB
- Lines
- 908
scripts/commitment_pricing_analyzer.py
Python·908 lines·33 KB
15
16from __future__ import annotations
17
18import argparse
19import json
20import re
21import sys
22from dataclasses import dataclass
23
24HOURS_PER_MONTH = 730
25
26# ---------------------------------------------------------------------------
27# Static fallback on-demand prices (us-east-1, Aurora PostgreSQL/MySQL Standard).
28# I/O-Optimized premium applied via multiplier.
29# ---------------------------------------------------------------------------
30IO_OPT_COMPUTE_MULTIPLIER = 1.30
31ACU_PRICE_STANDARD = 0.12 # $/ACU-Hr
32ACU_PRICE_IO_OPTIMIZED = 0.156
33
34_STATIC_INSTANCE_PRICES = {
35 "db.t3.medium": 0.082,
36 "db.t3.large": 0.164,
37 "db.t4g.medium": 0.073,
38 "db.t4g.large": 0.146,
39 "db.r5.large": 0.290,
40 "db.r5.xlarge": 0.580,
41 "db.r5.2xlarge": 1.160,
42 "db.r5.4xlarge": 2.320,
43 "db.r5.8xlarge": 4.640,
44 "db.r5.12xlarge": 6.960,
45 "db.r5.16xlarge": 9.280,
46 "db.r5.24xlarge": 13.920,
47 "db.r6g.large": 0.260,
48 "db.r6g.xlarge": 0.519,
49 "db.r6g.2xlarge": 1.038,
50 "db.r6g.4xlarge": 2.076,
51 "db.r6g.8xlarge": 4.152,
52 "db.r6g.12xlarge": 6.228,
53 "db.r6g.16xlarge": 8.304,
54 "db.r7g.large": 0.276,
55 "db.r7g.xlarge": 0.553,
56 "db.r7g.2xlarge": 1.106,
57 "db.r7g.4xlarge": 2.211,
58 "db.r7g.8xlarge": 4.422,
59 "db.r7g.12xlarge": 6.633,
60 "db.r7g.16xlarge": 8.844,
61 "db.r8g.large": 0.276,
62 "db.r8g.xlarge": 0.552,
63 "db.r8g.2xlarge": 1.104,
64 "db.r8g.4xlarge": 2.208,
65 "db.r8g.8xlarge": 4.416,
66 "db.r8g.12xlarge": 6.624,
67 "db.r8g.16xlarge": 8.832,
68 "db.r8g.24xlarge": 13.248,
69 "db.r8g.48xlarge": 26.496,
70}
71
72_REGION_NAMES = {
73 "us-east-1": "US East (N. Virginia)",
74 "us-east-2": "US East (Ohio)",
75 "us-west-1": "US West (N. California)",
76 "us-west-2": "US West (Oregon)",
77 "eu-west-1": "EU (Ireland)",
78 "eu-west-2": "EU (London)",
79 "eu-central-1": "EU (Frankfurt)",
80 "eu-north-1": "EU (Stockholm)",
81 "ap-southeast-1": "Asia Pacific (Singapore)",
82 "ap-southeast-2": "Asia Pacific (Sydney)",
83 "ap-northeast-1": "Asia Pacific (Tokyo)",
84 "ap-south-1": "Asia Pacific (Mumbai)",
85 "ca-central-1": "Canada (Central)",
86}
87
88# DSP only covers these families
89_DSP_ELIGIBLE_FAMILIES = {"r7g", "r7i", "r8g", "r8gd", "m7g", "m7i", "c7g", "c7i", "x8g"}
90
91_DSP_SIZE_MAP = {
92 "micro": "micro",
93 "small": "small",
94 "medium": "medium",
95 "large": "large",
96 "xl": "xlarge",
97 "2xl": "2xlarge",
98 "4xl": "4xlarge",
99 "8xl": "8xlarge",
100 "12xl": "12xlarge",
101 "16xl": "16xlarge",
102 "24xl": "24xlarge",
103 "48xl": "48xlarge",
104}
105
106
107# ---------------------------------------------------------------------------
108# Data classes
109# ---------------------------------------------------------------------------
110
111
112@dataclass
113class RIOffering:
114 instance_type: str
115 term_years: int
116 payment_option: str # "No Upfront" | "Partial Upfront" | "All Upfront"
117 effective_hourly: float # (upfront / term_hours) + recurring
118 upfront_cost: float
119 recurring_hourly: float
120
121 def monthly_cost(self) -> float:
122 return self.effective_hourly * HOURS_PER_MONTH
123
124
125@dataclass
126class DSPRate:
127 usage_type: str # instance type or "ServerlessV2"
128 term_years: int # always 1 for Aurora DSP
129 payment_option: str
130 rate_per_hour: float
131
132 def monthly_cost(self) -> float:
133 return self.rate_per_hour * HOURS_PER_MONTH
134
135
136# ---------------------------------------------------------------------------
137# Live AWS fetchers
138# ---------------------------------------------------------------------------
139
140
141def _family_from_instance(instance_type: str) -> str:
142 m = re.match(r"db\.([a-z0-9]+)\.", instance_type)
143 return m.group(1) if m else ""
144
145
146def get_on_demand_price(instance_type: str, region: str = "us-east-1") -> float:
147 """Return on-demand hourly price. Tries Pricing API, falls back to static."""
148 try:
149 import boto3
150 except ImportError:
151 return _STATIC_INSTANCE_PRICES.get(instance_type, 0.0)
152
153 location = _REGION_NAMES.get(region)
154 if not location:
155 return _STATIC_INSTANCE_PRICES.get(instance_type, 0.0)
156
157 try:
158 pricing = boto3.client("pricing", region_name="us-east-1")
159 filters = [
160 {"Type": "TERM_MATCH", "Field": "databaseEngine", "Value": "Aurora PostgreSQL"},
161 {"Type": "TERM_MATCH", "Field": "location", "Value": location},
162 {"Type": "TERM_MATCH", "Field": "instanceType", "Value": instance_type},
163 {"Type": "TERM_MATCH", "Field": "deploymentOption", "Value": "Single-AZ"},
164 {"Type": "TERM_MATCH", "Field": "termType", "Value": "OnDemand"},
165 ]
166 for page in pricing.get_paginator("get_products").paginate(
167 ServiceCode="AmazonRDS", Filters=filters
168 ):
169 for item_json in page["PriceList"]:
170 item = json.loads(item_json) if isinstance(item_json, str) else item_json
171 attrs = item.get("product", {}).get("attributes", {})
172 if "IOOptimized" in attrs.get("usagetype", ""):
173 continue
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 return price
182 except Exception:
183 pass
184
185 return _STATIC_INSTANCE_PRICES.get(instance_type, 0.0)
186
187
188def fetch_ri_offerings(instance_type: str, region: str) -> list[RIOffering]:
189 """Fetch all RI offerings for an instance type. Returns [] on failure."""
190 try:
191 import boto3
192 except ImportError:
193 return []
194
195 results: list[RIOffering] = []
196 try:
197 rds = boto3.client("rds", region_name=region)
198 for engine in ("aurora-postgresql",):
199 try:
200 paginator = rds.get_paginator("describe_reserved_db_instances_offerings")
201 for page in paginator.paginate(
202 DBInstanceClass=instance_type,
203 ProductDescription=engine,
204 MultiAZ=False,
205 ):
206 for offering in page.get("ReservedDBInstancesOfferings", []):
207 inst = offering.get("DBInstanceClass", "")
208 if inst != instance_type:
209 continue
210 duration = offering.get("Duration", 0)
211 term_years = 3 if duration > 94_000_000 else 1
212 payment = offering.get("OfferingType", "")
213 fixed = float(offering.get("FixedPrice", 0.0))
214 recurring_list = offering.get("RecurringCharges", [])
215 recurring_hr = sum(
216 float(rc.get("RecurringChargeAmount", 0.0)) for rc in recurring_list
217 )
218 term_hours = term_years * 365 * 24
219 effective = (fixed / term_hours) + recurring_hr
220 results.append(
221 RIOffering(
222 instance_type=inst,
223 term_years=term_years,
224 payment_option=payment,
225 effective_hourly=round(effective, 6),
226 upfront_cost=round(fixed, 2),
227 recurring_hourly=round(recurring_hr, 6),
228 )
229 )
230 except Exception:
231 continue
232 except Exception:
233 return []
234
235 # Deduplicate (same offering exists for both engines)
236 seen = set()
237 deduped = []
238 for r in results:
239 key = (r.term_years, r.payment_option, round(r.effective_hourly, 6))
240 if key in seen:
241 continue
242 seen.add(key)
243 deduped.append(r)
244 return deduped
245
246
247def fetch_dsp_rates(region: str) -> dict[str, list[DSPRate]]:
248 """Fetch Database Savings Plan rates for Aurora in the region.
249
250 Returns dict mapping usage key (instance type or 'ServerlessV2') to rates.
251 """
252 try:
253 import boto3
254 except ImportError:
255 return {}
256
257 result: dict[str, list[DSPRate]] = {}
258 try:
259 sp = boto3.client("savingsplans", region_name="us-east-1")
260 except Exception:
261 return {}
262
263 for engine in ("Aurora PostgreSQL",):
264 try:
265 rates = []
266 token = None
267 while True:
268 kwargs = {
269 "savingsPlanTypes": ["Database"],
270 "products": ["RDS"],
271 "serviceCodes": ["AmazonRDS"],
272 "filters": [
273 {"name": "region", "values": [region]},
274 {"name": "productDescription", "values": [engine]},
275 ],
276 "maxResults": 1000,
277 }
278 if token:
279 kwargs["nextToken"] = token
280 resp = sp.describe_savings_plans_offering_rates(**kwargs)
281 rates.extend(resp.get("searchResults", []))
282 token = resp.get("nextToken")
283 if not token:
284 break
285
286 for rate_entry in rates:
287 offering = rate_entry.get("savingsPlanOffering", {})
288 dur = offering.get("durationSeconds", 0)
289 term_years = 3 if dur > 94_000_000 else 1
290 payment = offering.get("paymentOption", "")
291 try:
292 rate_val = float(rate_entry.get("rate", "0"))
293 except (ValueError, TypeError):
294 continue
295 if rate_val <= 0:
296 continue
297
298 usage = rate_entry.get("usageType", "")
299 unit = rate_entry.get("unit", "")
300
301 # Skip I/O-Optimized variants for consistency; main pricing uses Standard
302 if "IOOptimized" in usage:
303 continue
304
305 if unit == "ACU-Hr" and "ServerlessV2" in usage:
306 key = "ServerlessV2"
307 else:
308 m = re.match(r"InstanceUsage:db\.(\w+)\.(\w+)", usage)
309 if not m:
310 continue
311 family = m.group(1)
312 short_size = m.group(2)
313 size = _DSP_SIZE_MAP.get(short_size, short_size)
314 key = f"db.{family}.{size}"
315
316 entry = DSPRate(
317 usage_type=key,
318 term_years=term_years,
319 payment_option=payment,
320 rate_per_hour=round(rate_val, 6),
321 )
322 existing = result.get(key, [])
323 if not any(
324 e.term_years == entry.term_years and e.payment_option == entry.payment_option
325 for e in existing
326 ):
327 result.setdefault(key, []).append(entry)
328 except Exception:
329 continue
330
331 return result
332
333
334# ---------------------------------------------------------------------------
335# Best-of selection (lowest effective monthly cost per term/category)
336# ---------------------------------------------------------------------------
337
338
339def best_ri(offerings: list[RIOffering], term_years: int) -> RIOffering | None:
340 candidates = [r for r in offerings if r.term_years == term_years]
341 if not candidates:
342 return None
343 return min(candidates, key=lambda r: r.effective_hourly)
344
345
346def best_dsp(rates: list[DSPRate], term_years: int = 1) -> DSPRate | None:
347 candidates = [r for r in rates if r.term_years == term_years]
348 if not candidates:
349 return None
350 return min(candidates, key=lambda r: r.rate_per_hour)
351
352
353# ---------------------------------------------------------------------------
354# Comparison builder — single workload
355# ---------------------------------------------------------------------------
356
357
358def build_comparison(
359 instance_type: str,
360 num_instances: int,
361 region: str,
362 io_optimized: bool = False,
363 is_serverless: bool = False,
364 avg_acu: float = 0.0,
365 dsp_rates: dict[str, list[DSPRate]] | None = None,
366) -> dict:
367 """Compare on-demand, RI, and DSP for a single workload description."""
368 if dsp_rates is None:
369 dsp_rates = fetch_dsp_rates(region)
370
371 if is_serverless:
372 od_hourly = ACU_PRICE_IO_OPTIMIZED if io_optimized else ACU_PRICE_STANDARD
373 # For Serverless v2, "number of instances" is irrelevant — we price avg ACU continuously
374 units = avg_acu
375 od_monthly = od_hourly * units * HOURS_PER_MONTH
376
377 dsp_entry = best_dsp(dsp_rates.get("ServerlessV2", []))
378 dsp_monthly = dsp_entry.rate_per_hour * units * HOURS_PER_MONTH if dsp_entry else None
379 dsp_savings = (od_monthly - dsp_monthly) if dsp_monthly is not None else None
380
381 return {
382 "workload_type": "serverless_v2",
383 "avg_acu": avg_acu,
384 "io_optimized": io_optimized,
385 "on_demand": {
386 "hourly": od_hourly,
387 "monthly": round(od_monthly, 2),
388 },
389 "ri_1yr": None,
390 "ri_3yr": None,
391 "dsp_1yr": _format_dsp(dsp_entry, units, od_monthly) if dsp_entry else None,
392 "recommendation": _recommend_serverless(dsp_savings, od_monthly),
393 "notes": [
394 "Reserved Instances do not apply to Aurora Serverless v2.",
395 "DSP covers ACU-hours but bills the committed $/hr continuously, "
396 "even during auto-pause. Only commit to the steady baseline ACU.",
397 ],
398 }
399
400 # Provisioned
401 family = _family_from_instance(instance_type)
402 od_hourly = get_on_demand_price(instance_type, region)
403 if io_optimized:
404 od_hourly *= IO_OPT_COMPUTE_MULTIPLIER
405 od_monthly = od_hourly * HOURS_PER_MONTH * num_instances
406
407 ri_offerings = fetch_ri_offerings(instance_type, region)
408 ri_1yr = best_ri(ri_offerings, 1)
409 ri_3yr = best_ri(ri_offerings, 3)
410
411 # I/O-Optimized RI coverage (AWS Compute Optimizer, verified): an I/O-Optimized
412 # instance is FULLY covered by Reserved Instances — no portion is forced to
413 # on-demand — but it "consumes 30% more normalized units per hour than Aurora
414 # Standard", i.e. it draws down RI capacity at 1.30×. So the effective RI cost is
415 # the (Standard-normalized) RI rate × 1.30. Equivalently: buy ~30% more normalized
416 # RI units to cover the same I/O-Optimized fleet.
417 # io-opt RI monthly = ri_rate × 1.30 × hours × N
418 def ri_adjusted_monthly(ri: RIOffering | None) -> float | None:
419 if ri is None:
420 return None
421 units = IO_OPT_COMPUTE_MULTIPLIER if io_optimized else 1.0
422 return ri.effective_hourly * units * HOURS_PER_MONTH * num_instances
423
424 ri_1yr_monthly = ri_adjusted_monthly(ri_1yr)
425 ri_3yr_monthly = ri_adjusted_monthly(ri_3yr)
426
427 dsp_entry = best_dsp(dsp_rates.get(instance_type, []))
428 dsp_monthly = dsp_entry.rate_per_hour * HOURS_PER_MONTH * num_instances if dsp_entry else None
429
430 dsp_eligible = family in _DSP_ELIGIBLE_FAMILIES
431 notes = []
432 if not dsp_eligible:
433 notes.append(
434 f"Database Savings Plans do not cover the {family} family. "
435 f"DSP requires r7g, r8g, r7i, or newer-gen Aurora-compatible families."
436 )
437 if io_optimized:
438 notes.append(
439 "Cluster is I/O-Optimized (30% compute premium). Both RI and DSP cover the "
440 "full I/O-Optimized instance price — I/O-Optimized consumes 30% more "
441 "normalized units per hour, so an RI draws down capacity at 1.30× (buy ~30% "
442 "more normalized RI units to fully cover the fleet); no portion is on-demand."
443 )
444
445 return {
446 "workload_type": "provisioned",
447 "instance_type": instance_type,
448 "num_instances": num_instances,
449 "io_optimized": io_optimized,
450 "on_demand": {
451 "hourly": round(od_hourly, 4),
452 "monthly": round(od_monthly, 2),
453 },
454 "ri_1yr": _format_ri(ri_1yr, ri_1yr_monthly, od_monthly, num_instances),
455 "ri_3yr": _format_ri(ri_3yr, ri_3yr_monthly, od_monthly, num_instances),
456 "dsp_1yr": (_format_dsp(dsp_entry, num_instances, od_monthly) if dsp_entry else None),
457 "recommendation": _recommend_provisioned(
458 od_monthly,
459 ri_1yr_monthly,
460 ri_3yr_monthly,
461 dsp_monthly,
462 dsp_eligible=dsp_eligible,
463 io_optimized=io_optimized,
464 ),
465 "notes": notes,
466 }
467
468
469def _format_ri(
470 ri: RIOffering | None, monthly: float | None, od_monthly: float, n: int
471) -> dict | None:
472 if ri is None or monthly is None:
473 return None
474 savings = od_monthly - monthly
475 pct = (savings / od_monthly * 100) if od_monthly > 0 else 0
476 return {
477 "term_years": ri.term_years,
478 "payment_option": ri.payment_option,
479 "effective_hourly_per_instance": round(ri.effective_hourly, 4),
480 "upfront_total": round(ri.upfront_cost * n, 2),
481 "monthly": round(monthly, 2),
482 "savings_monthly": round(savings, 2),
483 "savings_pct": round(pct, 1),
484 }
485
486
487def _format_dsp(dsp: DSPRate | None, units: float, od_monthly: float) -> dict | None:
488 if dsp is None:
489 return None
490 monthly = dsp.rate_per_hour * HOURS_PER_MONTH * units
491 savings = od_monthly - monthly
492 pct = (savings / od_monthly * 100) if od_monthly > 0 else 0
493 return {
494 "term_years": dsp.term_years,
495 "payment_option": dsp.payment_option,
496 "rate_per_hour": round(dsp.rate_per_hour, 4),
497 "monthly": round(monthly, 2),
498 "savings_monthly": round(savings, 2),
499 "savings_pct": round(pct, 1),
500 }
501
502
503def _recommend_provisioned(
504 od: float,
505 ri_1yr: float | None,
506 ri_3yr: float | None,
507 dsp: float | None,
508 dsp_eligible: bool,
509 io_optimized: bool,
510) -> dict:
511 options = []
512 if ri_1yr is not None:
513 options.append(("1yr RI", ri_1yr))
514 if ri_3yr is not None:
515 options.append(("3yr RI", ri_3yr))
516 if dsp is not None:
517 options.append(("1yr DSP", dsp))
518
519 if not options:
520 return {
521 "best_option": "on_demand",
522 "reason": "No RI or DSP offerings available for this instance type/region.",
523 }
524
525 best_label, best_cost = min(options, key=lambda x: x[1])
526 savings = od - best_cost
527 pct = (savings / od * 100) if od > 0 else 0
528
529 reasons = [
530 f"{best_label} is the lowest-cost option, saving ${savings:.0f}/mo ({pct:.0f}%) vs on-demand."
531 ]
532 if best_label == "3yr RI":
533 reasons.append(
534 "Best fit for steady 24/7 workloads you're confident will stay on this instance family for 3 years."
535 )
536 elif best_label == "1yr DSP":
537 reasons.append(
538 "Offers flexibility — covers any eligible Aurora instance family in the account, including future upgrades."
539 )
540 if io_optimized:
541 reasons.append(
542 "DSP is particularly attractive for I/O-Optimized clusters since it covers the full rate."
543 )
544 elif best_label == "1yr RI":
545 reasons.append(
546 "Shorter commitment than 3yr, useful when instance-family migration is on the horizon."
547 )
548
549 if not dsp_eligible and dsp is None:
550 reasons.append(
551 "DSP is not available for this instance family, so RI is the only commitment option."
552 )
553
554 return {
555 "best_option": best_label,
556 "best_monthly_cost": round(best_cost, 2),
557 "savings_vs_on_demand": round(savings, 2),
558 "savings_pct": round(pct, 1),
559 "reason": " ".join(reasons),
560 }
561
562
563def _recommend_serverless(dsp_savings: float | None, od_monthly: float) -> dict:
564 if dsp_savings is None:
565 return {
566 "best_option": "on_demand",
567 "reason": "No Database Savings Plan rates available for Serverless v2 ACU in this region.",
568 }
569 if dsp_savings <= 0:
570 return {
571 "best_option": "on_demand",
572 "reason": "DSP would not save money at the specified average ACU.",
573 }
574 pct = (dsp_savings / od_monthly * 100) if od_monthly > 0 else 0
575 return {
576 "best_option": "1yr DSP",
577 "savings_vs_on_demand": round(dsp_savings, 2),
578 "savings_pct": round(pct, 1),
579 "reason": (
580 f"1yr DSP saves ${dsp_savings:.0f}/mo ({pct:.0f}%). "
581 "Size the commitment to your steady baseline ACU — DSP bills the committed $/hr "
582 "continuously, even during idle periods."
583 ),
584 }
585
586
587# ---------------------------------------------------------------------------
588# Live cluster analysis
589# ---------------------------------------------------------------------------
590
591
592def _is_empty_cluster(cluster: dict) -> bool:
593 """Skip clusters with no compute to analyze.
594
595 An Aurora cluster with no DB instances has no compute cost, so RI and DSP
596 commitment analysis doesn't apply. Typical cases: Aurora Limitless
597 (sharded compute, not instances), paused/stopped clusters, or clusters
598 mid-migration.
599 """
600 return len(cluster.get("DBClusterMembers", [])) == 0
601
602
603def analyze_cluster_live(cluster_id: str, region: str) -> dict:
604 import boto3
605
606 rds = boto3.client("rds", region_name=region)
607
608 try:
609 resp = rds.describe_db_clusters(DBClusterIdentifier=cluster_id)
610 except Exception as e:
611 return {"cluster_id": cluster_id, "error": str(e)}
612 clusters = resp.get("DBClusters", [])
613 if not clusters:
614 return {"cluster_id": cluster_id, "error": "cluster not found"}
615 cluster = clusters[0]
616 storage_type = cluster.get("StorageType", "aurora")
617 io_optimized = storage_type == "aurora-iopt1"
618 engine = cluster.get("Engine", "")
619
620 # Guardrail: skip clusters with no DB instances (Limitless, paused, mid-migration, etc.)
621 if _is_empty_cluster(cluster):
622 return {
623 "cluster_id": cluster_id,
624 "engine": engine,
625 "engine_version": cluster.get("EngineVersion", ""),
626 "storage_type": storage_type,
627 "skipped": True,
628 "reason": (
629 "Cluster has no DB instances — no compute to price. "
630 "RI and DSP commitment analysis does not apply. "
631 "This typically indicates Aurora Limitless, a paused cluster, "
632 "or a cluster mid-migration."
633 ),
634 }
635
636 # Identify instances
637 member_ids = [m["DBInstanceIdentifier"] for m in cluster.get("DBClusterMembers", [])]
638 type_counts: dict[str, int] = {}
639 serverless_instances = 0
640 for mid in member_ids:
641 try:
642 iresp = rds.describe_db_instances(DBInstanceIdentifier=mid)
643 for inst in iresp.get("DBInstances", []):
644 itype = inst.get("DBInstanceClass", "")
645 if itype == "db.serverless":
646 serverless_instances += 1
647 else:
648 type_counts[itype] = type_counts.get(itype, 0) + 1
649 except Exception:
650 continue
651
652 # Prefetch DSP rates once for this cluster analysis
653 dsp_rates = fetch_dsp_rates(region)
654
655 sub_workloads = []
656 for itype, count in type_counts.items():
657 sub_workloads.append(
658 build_comparison(
659 instance_type=itype,
660 num_instances=count,
661 region=region,
662 io_optimized=io_optimized,
663 dsp_rates=dsp_rates,
664 )
665 )
666 if serverless_instances > 0:
667 # Without observed ACU metrics, we can't price serverless exactly — note it
668 sub_workloads.append(
669 {
670 "workload_type": "serverless_v2",
671 "instance_count": serverless_instances,
672 "note": "Serverless v2 instances detected. Re-run with 'offline --serverless "
673 "--avg-acu <N>' using your observed average ACU (from CloudWatch "
674 "ServerlessDatabaseCapacity metric) for a precise DSP estimate.",
675 "io_optimized": io_optimized,
676 }
677 )
678
679 return {
680 "cluster_id": cluster_id,
681 "engine": engine,
682 "storage_type": storage_type,
683 "io_optimized": io_optimized,
684 "instance_mix": {**type_counts, "serverless": serverless_instances},
685 "workloads": sub_workloads,
686 }
687
688
689def list_clusters(region: str) -> list[str]:
690 import boto3
691
692 rds = boto3.client("rds", region_name=region)
693 names = []
694 for page in rds.get_paginator("describe_db_clusters").paginate():
695 for c in page.get("DBClusters", []):
696 if c.get("Engine", "").startswith("aurora"):
697 names.append(c["DBClusterIdentifier"])
698 return names
699
700
701# ---------------------------------------------------------------------------
702# Output formatting
703# ---------------------------------------------------------------------------
704
705
706def _format_table_single(result: dict) -> str:
707 lines = []
708 lines.append("=" * 72)
709 if result.get("workload_type") == "serverless_v2":
710 lines.append(f"Aurora Serverless v2 Commitment Pricing")
711 lines.append(
712 f" Avg ACU: {result.get('avg_acu', 0):.1f} "
713 f"I/O-Optimized: {result.get('io_optimized', False)}"
714 )
715 else:
716 lines.append(
717 f"Aurora Commitment Pricing — "
718 f"{result.get('num_instances', 1)}× {result.get('instance_type', '?')}"
719 )
720 if result.get("io_optimized"):
721 lines.append(" Storage: I/O-Optimized (30% compute premium applied)")
722 lines.append("=" * 72)
723 od_monthly = result["on_demand"]["monthly"]
724 lines.append("")
725 lines.append(f" {'Option':<28} {'Monthly':>12} {'Savings':>12} {'Upfront':>12} Term")
726 lines.append(" " + "-" * 70)
727 lines.append(f" {'On-Demand':<28} ${od_monthly:>11,.0f} {'—':>12} {'$0':>12} —")
728
729 for key, label, term_hint in (
730 ("ri_1yr", "1yr RI", "1 year"),
731 ("ri_3yr", "3yr RI", "3 years"),
732 ("dsp_1yr", "1yr DSP", "1 year"),
733 ):
734 entry = result.get(key)
735 if not entry:
736 continue
737 payment = entry.get("payment_option", "")
738 display = f"{label} ({payment})" if payment else label
739 monthly = entry.get("monthly", 0)
740 savings = entry.get("savings_monthly", 0)
741 pct = entry.get("savings_pct", 0)
742 upfront = entry.get("upfront_total", 0)
743 savings_str = f"${savings:,.0f} ({pct:.0f}%)" if savings else "—"
744 upfront_str = f"${upfront:,.0f}" if upfront else "$0"
745 lines.append(
746 f" {display:<28} ${monthly:>11,.0f} {savings_str:>12} {upfront_str:>12} {term_hint}"
747 )
748
749 rec = result.get("recommendation", {})
750 lines.append("")
751 lines.append(f" Recommendation: {rec.get('best_option', '?')}")
752 lines.append(f" {rec.get('reason', '')}")
753
754 notes = result.get("notes", [])
755 if notes:
756 lines.append("")
757 for n in notes:
758 lines.append(f" Note: {n}")
759 lines.append("=" * 72)
760 return "\n".join(lines)
761
762
763def _format_cluster(result: dict) -> str:
764 lines = []
765 lines.append(
766 f"Cluster: {result.get('cluster_id', '?')} "
767 f"({result.get('engine', '?')}) "
768 f"storage_type={result.get('storage_type', '?')}"
769 )
770 workloads = result.get("workloads", [])
771 for wl in workloads:
772 if wl.get("workload_type") == "serverless_v2" and "note" in wl:
773 lines.append("")
774 lines.append(f" [Serverless v2 — {wl.get('instance_count', 0)} instance(s)]")
775 lines.append(f" {wl['note']}")
776 continue
777 lines.append("")
778 lines.append(_format_table_single(wl))
779 return "\n".join(lines)
780
781
782def _format_fleet(output: dict) -> str:
783 lines = []
784 lines.append(f"Region: {output['region']}")
785 lines.append("")
786 total_od = 0.0
787 total_best = 0.0
788 for cluster in output["clusters"]:
789 if "error" in cluster:
790 lines.append(f"{cluster['cluster_id']}: ERROR {cluster['error']}")
791 continue
792 for wl in cluster.get("workloads", []):
793 if wl.get("workload_type") != "provisioned":
794 continue
795 od = wl["on_demand"]["monthly"]
796 best = wl.get("recommendation", {}).get("best_monthly_cost", od)
797 total_od += od
798 total_best += best
799 lines.append(f" Fleet monthly on-demand: ${total_od:,.0f}")
800 lines.append(f" With best commitments: ${total_best:,.0f}")
801 savings = total_od - total_best
802 pct = (savings / total_od * 100) if total_od > 0 else 0
803 lines.append(f" Fleet savings opportunity: ${savings:,.0f}/mo ({pct:.0f}%)")
804 lines.append("")
805 for cluster in output["clusters"]:
806 if "error" in cluster:
807 continue
808 lines.append("")
809 lines.append(_format_cluster(cluster))
810 return "\n".join(lines)
811
812
813# ---------------------------------------------------------------------------
814# CLI
815# ---------------------------------------------------------------------------
816
817
818def main():
819 parser = argparse.ArgumentParser(
820 description="Aurora RI & Database Savings Plan estimator (read-only)"
821 )
822 parser.add_argument("--region", default="us-east-1")
823 parser.add_argument("--format", choices=["json", "table"], default="json")
824 parser.add_argument("--cluster", help="Analyze a single cluster by identifier")
825 parser.add_argument(
826 "--all", action="store_true", help="Analyze all Aurora clusters in the region"
827 )
828
829 sub = parser.add_subparsers(dest="mode")
830 off = sub.add_parser("offline", help="Use user-supplied workload description")
831 off.add_argument("--instance", help="Instance type (e.g., db.r7g.2xlarge)")
832 off.add_argument("--num-instances", type=int, default=1)
833 off.add_argument(
834 "--io-optimized", action="store_true", help="Workload uses Aurora I/O-Optimized storage"
835 )
836 off.add_argument(
837 "--serverless", action="store_true", help="Serverless v2 workload — requires --avg-acu"
838 )
839 off.add_argument(
840 "--avg-acu", type=float, default=0.0, help="Average ACU for serverless workload"
841 )
842 off.add_argument("--region", default="us-east-1")
843 off.add_argument("--format", choices=["json", "table"], default="json")
844
845 args = parser.parse_args()
846
847 if args.mode == "offline":
848 if args.serverless:
849 if args.avg_acu <= 0:
850 print("ERROR: --serverless requires --avg-acu > 0", file=sys.stderr)
851 sys.exit(2)
852 result = build_comparison(
853 instance_type="",
854 num_instances=0,
855 region=args.region,
856 io_optimized=args.io_optimized,
857 is_serverless=True,
858 avg_acu=args.avg_acu,
859 )
860 else:
861 if not args.instance:
862 print("ERROR: offline mode requires --instance (or --serverless)", file=sys.stderr)
863 sys.exit(2)
864 result = build_comparison(
865 instance_type=args.instance,
866 num_instances=args.num_instances,
867 region=args.region,
868 io_optimized=args.io_optimized,
869 )
870 if args.format == "json":
871 print(json.dumps(result, indent=2, default=str))
872 else:
873 print(_format_table_single(result))
874 return
875
876 # Live modes require boto3
877 try:
878 import boto3 # noqa: F401
879 except ImportError:
880 print(
881 "ERROR: boto3 required for live AWS analysis. Use the 'offline' subcommand.",
882 file=sys.stderr,
883 )
884 sys.exit(2)
885
886 if args.all:
887 cluster_ids = list_clusters(args.region)
888 results = [analyze_cluster_live(cid, args.region) for cid in cluster_ids]
889 output = {"region": args.region, "cluster_count": len(results), "clusters": results}
890 if args.format == "json":
891 print(json.dumps(output, indent=2, default=str))
892 else:
893 print(_format_fleet(output))
894 return
895
896 if args.cluster:
897 result = analyze_cluster_live(args.cluster, args.region)
898 if args.format == "json":
899 print(json.dumps(result, indent=2, default=str))
900 else:
901 print(_format_cluster(result))
902 return
903
904 parser.print_help()
905
906
907if __name__ == "__main__":
908 main()