runbooks 0.2.5__py3-none-any.whl → 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- conftest.py +26 -0
- jupyter-agent/.env +2 -0
- jupyter-agent/.env.template +2 -0
- jupyter-agent/.gitattributes +35 -0
- jupyter-agent/.gradio/certificate.pem +31 -0
- jupyter-agent/README.md +16 -0
- jupyter-agent/__main__.log +8 -0
- jupyter-agent/app.py +256 -0
- jupyter-agent/cloudops-agent.png +0 -0
- jupyter-agent/ds-system-prompt.txt +154 -0
- jupyter-agent/jupyter-agent.png +0 -0
- jupyter-agent/llama3_template.jinja +123 -0
- jupyter-agent/requirements.txt +9 -0
- jupyter-agent/tmp/4ojbs8a02ir/jupyter-agent.ipynb +68 -0
- jupyter-agent/tmp/cm5iasgpm3p/jupyter-agent.ipynb +91 -0
- jupyter-agent/tmp/crqbsseag5/jupyter-agent.ipynb +91 -0
- jupyter-agent/tmp/hohanq1u097/jupyter-agent.ipynb +57 -0
- jupyter-agent/tmp/jns1sam29wm/jupyter-agent.ipynb +53 -0
- jupyter-agent/tmp/jupyter-agent.ipynb +27 -0
- jupyter-agent/utils.py +409 -0
- runbooks/__init__.py +71 -3
- runbooks/__main__.py +13 -0
- runbooks/aws/ec2_describe_instances.py +1 -1
- runbooks/aws/ec2_run_instances.py +8 -2
- runbooks/aws/ec2_start_stop_instances.py +17 -4
- runbooks/aws/ec2_unused_volumes.py +5 -1
- runbooks/aws/s3_create_bucket.py +4 -2
- runbooks/aws/s3_list_objects.py +6 -1
- runbooks/aws/tagging_lambda_handler.py +13 -2
- runbooks/aws/tags.json +12 -0
- runbooks/base.py +353 -0
- runbooks/cfat/README.md +49 -0
- runbooks/cfat/__init__.py +74 -0
- runbooks/cfat/app.ts +644 -0
- runbooks/cfat/assessment/__init__.py +40 -0
- runbooks/cfat/assessment/asana-import.csv +39 -0
- runbooks/cfat/assessment/cfat-checks.csv +31 -0
- runbooks/cfat/assessment/cfat.txt +520 -0
- runbooks/cfat/assessment/collectors.py +200 -0
- runbooks/cfat/assessment/jira-import.csv +39 -0
- runbooks/cfat/assessment/runner.py +387 -0
- runbooks/cfat/assessment/validators.py +290 -0
- runbooks/cfat/cli.py +103 -0
- runbooks/cfat/docs/asana-import.csv +24 -0
- runbooks/cfat/docs/cfat-checks.csv +31 -0
- runbooks/cfat/docs/cfat.txt +335 -0
- runbooks/cfat/docs/checks-output.png +0 -0
- runbooks/cfat/docs/cloudshell-console-run.png +0 -0
- runbooks/cfat/docs/cloudshell-download.png +0 -0
- runbooks/cfat/docs/cloudshell-output.png +0 -0
- runbooks/cfat/docs/downloadfile.png +0 -0
- runbooks/cfat/docs/jira-import.csv +24 -0
- runbooks/cfat/docs/open-cloudshell.png +0 -0
- runbooks/cfat/docs/report-header.png +0 -0
- runbooks/cfat/models.py +1026 -0
- runbooks/cfat/package-lock.json +5116 -0
- runbooks/cfat/package.json +38 -0
- runbooks/cfat/report.py +496 -0
- runbooks/cfat/reporting/__init__.py +46 -0
- runbooks/cfat/reporting/exporters.py +337 -0
- runbooks/cfat/reporting/formatters.py +496 -0
- runbooks/cfat/reporting/templates.py +135 -0
- runbooks/cfat/run-assessment.sh +23 -0
- runbooks/cfat/runner.py +69 -0
- runbooks/cfat/src/actions/check-cloudtrail-existence.ts +43 -0
- runbooks/cfat/src/actions/check-config-existence.ts +37 -0
- runbooks/cfat/src/actions/check-control-tower.ts +37 -0
- runbooks/cfat/src/actions/check-ec2-existence.ts +46 -0
- runbooks/cfat/src/actions/check-iam-users.ts +50 -0
- runbooks/cfat/src/actions/check-legacy-cur.ts +30 -0
- runbooks/cfat/src/actions/check-org-cloudformation.ts +30 -0
- runbooks/cfat/src/actions/check-vpc-existence.ts +43 -0
- runbooks/cfat/src/actions/create-asanaimport.ts +14 -0
- runbooks/cfat/src/actions/create-backlog.ts +372 -0
- runbooks/cfat/src/actions/create-jiraimport.ts +15 -0
- runbooks/cfat/src/actions/create-report.ts +616 -0
- runbooks/cfat/src/actions/define-account-type.ts +51 -0
- runbooks/cfat/src/actions/get-enabled-org-policy-types.ts +40 -0
- runbooks/cfat/src/actions/get-enabled-org-services.ts +26 -0
- runbooks/cfat/src/actions/get-idc-info.ts +34 -0
- runbooks/cfat/src/actions/get-org-da-accounts.ts +34 -0
- runbooks/cfat/src/actions/get-org-details.ts +35 -0
- runbooks/cfat/src/actions/get-org-member-accounts.ts +44 -0
- runbooks/cfat/src/actions/get-org-ous.ts +35 -0
- runbooks/cfat/src/actions/get-regions.ts +22 -0
- runbooks/cfat/src/actions/zip-assessment.ts +27 -0
- runbooks/cfat/src/types/index.d.ts +147 -0
- runbooks/cfat/tests/__init__.py +141 -0
- runbooks/cfat/tests/test_cli.py +340 -0
- runbooks/cfat/tests/test_integration.py +290 -0
- runbooks/cfat/tests/test_models.py +505 -0
- runbooks/cfat/tests/test_reporting.py +354 -0
- runbooks/cfat/tsconfig.json +16 -0
- runbooks/cfat/webpack.config.cjs +27 -0
- runbooks/config.py +260 -0
- runbooks/finops/README.md +337 -0
- runbooks/finops/__init__.py +86 -0
- runbooks/finops/aws_client.py +245 -0
- runbooks/finops/cli.py +151 -0
- runbooks/finops/cost_processor.py +410 -0
- runbooks/finops/dashboard_runner.py +448 -0
- runbooks/finops/helpers.py +355 -0
- runbooks/finops/main.py +14 -0
- runbooks/finops/profile_processor.py +174 -0
- runbooks/finops/types.py +66 -0
- runbooks/finops/visualisations.py +80 -0
- runbooks/inventory/.gitignore +354 -0
- runbooks/inventory/ArgumentsClass.py +261 -0
- runbooks/inventory/FAILED_SCRIPTS_TROUBLESHOOTING.md +619 -0
- runbooks/inventory/Inventory_Modules.py +6130 -0
- runbooks/inventory/LandingZone/delete_lz.py +1075 -0
- runbooks/inventory/PASSED_SCRIPTS_GUIDE.md +738 -0
- runbooks/inventory/README.md +1320 -0
- runbooks/inventory/__init__.py +62 -0
- runbooks/inventory/account_class.py +532 -0
- runbooks/inventory/all_my_instances_wrapper.py +123 -0
- runbooks/inventory/aws_decorators.py +201 -0
- runbooks/inventory/aws_organization.png +0 -0
- runbooks/inventory/cfn_move_stack_instances.py +1526 -0
- runbooks/inventory/check_cloudtrail_compliance.py +614 -0
- runbooks/inventory/check_controltower_readiness.py +1107 -0
- runbooks/inventory/check_landingzone_readiness.py +711 -0
- runbooks/inventory/cloudtrail.md +727 -0
- runbooks/inventory/collectors/__init__.py +20 -0
- runbooks/inventory/collectors/aws_compute.py +518 -0
- runbooks/inventory/collectors/aws_networking.py +275 -0
- runbooks/inventory/collectors/base.py +222 -0
- runbooks/inventory/core/__init__.py +19 -0
- runbooks/inventory/core/collector.py +303 -0
- runbooks/inventory/core/formatter.py +296 -0
- runbooks/inventory/delete_s3_buckets_objects.py +169 -0
- runbooks/inventory/discovery.md +81 -0
- runbooks/inventory/draw_org_structure.py +748 -0
- runbooks/inventory/ec2_vpc_utils.py +341 -0
- runbooks/inventory/find_cfn_drift_detection.py +272 -0
- runbooks/inventory/find_cfn_orphaned_stacks.py +719 -0
- runbooks/inventory/find_cfn_stackset_drift.py +733 -0
- runbooks/inventory/find_ec2_security_groups.py +669 -0
- runbooks/inventory/find_landingzone_versions.py +201 -0
- runbooks/inventory/find_vpc_flow_logs.py +1221 -0
- runbooks/inventory/inventory.sh +659 -0
- runbooks/inventory/list_cfn_stacks.py +558 -0
- runbooks/inventory/list_cfn_stackset_operation_results.py +252 -0
- runbooks/inventory/list_cfn_stackset_operations.py +734 -0
- runbooks/inventory/list_cfn_stacksets.py +453 -0
- runbooks/inventory/list_config_recorders_delivery_channels.py +681 -0
- runbooks/inventory/list_ds_directories.py +354 -0
- runbooks/inventory/list_ec2_availability_zones.py +286 -0
- runbooks/inventory/list_ec2_ebs_volumes.py +244 -0
- runbooks/inventory/list_ec2_instances.py +425 -0
- runbooks/inventory/list_ecs_clusters_and_tasks.py +562 -0
- runbooks/inventory/list_elbs_load_balancers.py +411 -0
- runbooks/inventory/list_enis_network_interfaces.py +526 -0
- runbooks/inventory/list_guardduty_detectors.py +568 -0
- runbooks/inventory/list_iam_policies.py +404 -0
- runbooks/inventory/list_iam_roles.py +518 -0
- runbooks/inventory/list_iam_saml_providers.py +359 -0
- runbooks/inventory/list_lambda_functions.py +882 -0
- runbooks/inventory/list_org_accounts.py +446 -0
- runbooks/inventory/list_org_accounts_users.py +354 -0
- runbooks/inventory/list_rds_db_instances.py +406 -0
- runbooks/inventory/list_route53_hosted_zones.py +318 -0
- runbooks/inventory/list_servicecatalog_provisioned_products.py +575 -0
- runbooks/inventory/list_sns_topics.py +360 -0
- runbooks/inventory/list_ssm_parameters.py +402 -0
- runbooks/inventory/list_vpc_subnets.py +433 -0
- runbooks/inventory/list_vpcs.py +422 -0
- runbooks/inventory/lockdown_cfn_stackset_role.py +224 -0
- runbooks/inventory/models/__init__.py +24 -0
- runbooks/inventory/models/account.py +192 -0
- runbooks/inventory/models/inventory.py +309 -0
- runbooks/inventory/models/resource.py +247 -0
- runbooks/inventory/recover_cfn_stack_ids.py +205 -0
- runbooks/inventory/requirements.txt +12 -0
- runbooks/inventory/run_on_multi_accounts.py +211 -0
- runbooks/inventory/tests/common_test_data.py +3661 -0
- runbooks/inventory/tests/common_test_functions.py +204 -0
- runbooks/inventory/tests/setup.py +24 -0
- runbooks/inventory/tests/src.py +18 -0
- runbooks/inventory/tests/test_cfn_describe_stacks.py +208 -0
- runbooks/inventory/tests/test_ec2_describe_instances.py +162 -0
- runbooks/inventory/tests/test_inventory_modules.py +55 -0
- runbooks/inventory/tests/test_lambda_list_functions.py +86 -0
- runbooks/inventory/tests/test_moto_integration_example.py +273 -0
- runbooks/inventory/tests/test_org_list_accounts.py +49 -0
- runbooks/inventory/update_aws_actions.py +173 -0
- runbooks/inventory/update_cfn_stacksets.py +1215 -0
- runbooks/inventory/update_cloudwatch_logs_retention_policy.py +294 -0
- runbooks/inventory/update_iam_roles_cross_accounts.py +478 -0
- runbooks/inventory/update_s3_public_access_block.py +539 -0
- runbooks/inventory/utils/__init__.py +23 -0
- runbooks/inventory/utils/aws_helpers.py +510 -0
- runbooks/inventory/utils/threading_utils.py +493 -0
- runbooks/inventory/utils/validation.py +682 -0
- runbooks/inventory/verify_ec2_security_groups.py +1430 -0
- runbooks/main.py +1004 -0
- runbooks/organizations/__init__.py +12 -0
- runbooks/organizations/manager.py +374 -0
- runbooks/security/README.md +447 -0
- runbooks/security/__init__.py +71 -0
- runbooks/{security_baseline → security}/checklist/alternate_contacts.py +8 -1
- runbooks/{security_baseline → security}/checklist/bucket_public_access.py +4 -1
- runbooks/{security_baseline → security}/checklist/cloudwatch_alarm_configuration.py +9 -2
- runbooks/{security_baseline → security}/checklist/guardduty_enabled.py +9 -2
- runbooks/{security_baseline → security}/checklist/multi_region_instance_usage.py +5 -1
- runbooks/{security_baseline → security}/checklist/root_access_key.py +6 -1
- runbooks/{security_baseline → security}/config-origin.json +1 -1
- runbooks/{security_baseline → security}/config.json +1 -1
- runbooks/{security_baseline → security}/permission.json +1 -1
- runbooks/{security_baseline → security}/report_generator.py +10 -2
- runbooks/{security_baseline → security}/report_template_en.html +7 -7
- runbooks/{security_baseline → security}/report_template_jp.html +7 -7
- runbooks/{security_baseline → security}/report_template_kr.html +12 -12
- runbooks/{security_baseline → security}/report_template_vn.html +7 -7
- runbooks/{security_baseline → security}/run_script.py +8 -2
- runbooks/{security_baseline → security}/security_baseline_tester.py +12 -4
- runbooks/{security_baseline → security}/utils/common.py +5 -1
- runbooks/utils/__init__.py +204 -0
- runbooks-0.7.0.dist-info/METADATA +375 -0
- runbooks-0.7.0.dist-info/RECORD +249 -0
- {runbooks-0.2.5.dist-info → runbooks-0.7.0.dist-info}/WHEEL +1 -1
- runbooks-0.7.0.dist-info/entry_points.txt +7 -0
- runbooks-0.7.0.dist-info/licenses/LICENSE +201 -0
- runbooks-0.7.0.dist-info/top_level.txt +3 -0
- runbooks/python101/calculator.py +0 -34
- runbooks/python101/config.py +0 -1
- runbooks/python101/exceptions.py +0 -16
- runbooks/python101/file_manager.py +0 -218
- runbooks/python101/toolkit.py +0 -153
- runbooks-0.2.5.dist-info/METADATA +0 -439
- runbooks-0.2.5.dist-info/RECORD +0 -61
- runbooks-0.2.5.dist-info/entry_points.txt +0 -3
- runbooks-0.2.5.dist-info/top_level.txt +0 -1
- /runbooks/{security_baseline/__init__.py → inventory/tests/script_test_data.py} +0 -0
- /runbooks/{security_baseline → security}/checklist/__init__.py +0 -0
- /runbooks/{security_baseline → security}/checklist/account_level_bucket_public_access.py +0 -0
- /runbooks/{security_baseline → security}/checklist/direct_attached_policy.py +0 -0
- /runbooks/{security_baseline → security}/checklist/iam_password_policy.py +0 -0
- /runbooks/{security_baseline → security}/checklist/iam_user_mfa.py +0 -0
- /runbooks/{security_baseline → security}/checklist/multi_region_trail.py +0 -0
- /runbooks/{security_baseline → security}/checklist/root_mfa.py +0 -0
- /runbooks/{security_baseline → security}/checklist/root_usage.py +0 -0
- /runbooks/{security_baseline → security}/checklist/trail_enabled.py +0 -0
- /runbooks/{security_baseline → security}/checklist/trusted_advisor.py +0 -0
- /runbooks/{security_baseline → security}/utils/__init__.py +0 -0
- /runbooks/{security_baseline → security}/utils/enums.py +0 -0
- /runbooks/{security_baseline → security}/utils/language.py +0 -0
- /runbooks/{security_baseline → security}/utils/level_const.py +0 -0
- /runbooks/{security_baseline → security}/utils/permission_list.py +0 -0
@@ -0,0 +1,355 @@
|
|
1
|
+
import csv # Added csv
|
2
|
+
import json
|
3
|
+
import os
|
4
|
+
import re
|
5
|
+
import sys
|
6
|
+
import tomllib # Built-in since Python 3.11
|
7
|
+
from datetime import datetime
|
8
|
+
from typing import Any, Dict, List, Optional
|
9
|
+
|
10
|
+
import yaml
|
11
|
+
from reportlab.lib import colors
|
12
|
+
from reportlab.lib.pagesizes import landscape, letter
|
13
|
+
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
14
|
+
from reportlab.platypus import (
|
15
|
+
Flowable,
|
16
|
+
Paragraph,
|
17
|
+
SimpleDocTemplate,
|
18
|
+
Spacer,
|
19
|
+
Table,
|
20
|
+
TableStyle,
|
21
|
+
)
|
22
|
+
from rich.console import Console
|
23
|
+
|
24
|
+
from runbooks.finops.types import ProfileData
|
25
|
+
|
26
|
+
console = Console()
|
27
|
+
|
28
|
+
styles = getSampleStyleSheet()
|
29
|
+
|
30
|
+
# Custom style for the footer
|
31
|
+
audit_footer_style = ParagraphStyle(
|
32
|
+
name="AuditFooter",
|
33
|
+
parent=styles["Normal"],
|
34
|
+
fontSize=8,
|
35
|
+
textColor=colors.grey,
|
36
|
+
alignment=1,
|
37
|
+
leading=10,
|
38
|
+
)
|
39
|
+
|
40
|
+
|
41
|
+
def export_audit_report_to_pdf(
|
42
|
+
audit_data_list: List[Dict[str, str]],
|
43
|
+
file_name: str = "audit_report",
|
44
|
+
path: Optional[str] = None,
|
45
|
+
) -> Optional[str]:
|
46
|
+
"""
|
47
|
+
Export the audit report to a PDF file.
|
48
|
+
|
49
|
+
:param audit_data_list: List of dictionaries, each representing a profile/account's audit data.
|
50
|
+
:param file_name: The base name of the output PDF file.
|
51
|
+
:param path: Optional directory where the PDF file will be saved.
|
52
|
+
:return: Full path of the generated PDF file or None on error.
|
53
|
+
"""
|
54
|
+
try:
|
55
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
|
56
|
+
base_filename = f"{file_name}_{timestamp}.pdf"
|
57
|
+
|
58
|
+
if path:
|
59
|
+
os.makedirs(path, exist_ok=True)
|
60
|
+
output_filename = os.path.join(path, base_filename)
|
61
|
+
else:
|
62
|
+
output_filename = base_filename
|
63
|
+
|
64
|
+
doc = SimpleDocTemplate(output_filename, pagesize=landscape(letter))
|
65
|
+
styles = getSampleStyleSheet()
|
66
|
+
elements: List[Flowable] = []
|
67
|
+
|
68
|
+
headers = [
|
69
|
+
"Profile",
|
70
|
+
"Account ID",
|
71
|
+
"Untagged Resources",
|
72
|
+
"Stopped EC2 Instances",
|
73
|
+
"Unused Volumes",
|
74
|
+
"Unused EIPs",
|
75
|
+
"Budget Alerts",
|
76
|
+
]
|
77
|
+
table_data = [headers]
|
78
|
+
|
79
|
+
for row in audit_data_list:
|
80
|
+
table_data.append(
|
81
|
+
[
|
82
|
+
row.get("profile", ""),
|
83
|
+
row.get("account_id", ""),
|
84
|
+
row.get("untagged_resources", ""),
|
85
|
+
row.get("stopped_instances", ""),
|
86
|
+
row.get("unused_volumes", ""),
|
87
|
+
row.get("unused_eips", ""),
|
88
|
+
row.get("budget_alerts", ""),
|
89
|
+
]
|
90
|
+
)
|
91
|
+
|
92
|
+
table = Table(table_data, repeatRows=1)
|
93
|
+
table.setStyle(
|
94
|
+
TableStyle(
|
95
|
+
[
|
96
|
+
("BACKGROUND", (0, 0), (-1, 0), colors.black),
|
97
|
+
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
|
98
|
+
("FONTNAME", (0, 0), (-1, -1), "Helvetica"),
|
99
|
+
("FONTSIZE", (0, 0), (-1, -1), 8),
|
100
|
+
("ALIGN", (0, 0), (-1, -1), "LEFT"),
|
101
|
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
102
|
+
("GRID", (0, 0), (-1, -1), 0.25, colors.black),
|
103
|
+
("BACKGROUND", (0, 1), (-1, -1), colors.whitesmoke),
|
104
|
+
]
|
105
|
+
)
|
106
|
+
)
|
107
|
+
|
108
|
+
elements.append(Paragraph("AWS FinOps Dashboard (Audit Report)", styles["Title"]))
|
109
|
+
elements.append(Spacer(1, 12))
|
110
|
+
elements.append(table)
|
111
|
+
elements.append(Spacer(1, 4))
|
112
|
+
elements.append(
|
113
|
+
Paragraph(
|
114
|
+
"Note: This table lists untagged EC2, RDS, Lambda, ELBv2 only.",
|
115
|
+
audit_footer_style,
|
116
|
+
)
|
117
|
+
)
|
118
|
+
elements.append(Spacer(1, 2))
|
119
|
+
current_time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
120
|
+
footer_text = (
|
121
|
+
f"This audit report is generated using AWS FinOps Dashboard (CLI) \u00a9 2025 on {current_time_str}"
|
122
|
+
)
|
123
|
+
elements.append(Paragraph(footer_text, audit_footer_style))
|
124
|
+
|
125
|
+
doc.build(elements)
|
126
|
+
return output_filename
|
127
|
+
except Exception as e:
|
128
|
+
console.print(f"[bold red]Error exporting audit report to PDF: {str(e)}[/]")
|
129
|
+
return None
|
130
|
+
|
131
|
+
|
132
|
+
def clean_rich_tags(text: str) -> str:
|
133
|
+
"""
|
134
|
+
Clean the rich text before writing the data to a pdf.
|
135
|
+
|
136
|
+
:param text: The rich text to clean.
|
137
|
+
:return: Cleaned text.
|
138
|
+
"""
|
139
|
+
return re.sub(r"\[/?[a-zA-Z0-9#_]*\]", "", text)
|
140
|
+
|
141
|
+
|
142
|
+
def export_audit_report_to_csv(
|
143
|
+
audit_data_list: List[Dict[str, str]],
|
144
|
+
file_name: str = "audit_report",
|
145
|
+
path: Optional[str] = None,
|
146
|
+
) -> Optional[str]:
|
147
|
+
"""Export the audit report to a CSV file."""
|
148
|
+
try:
|
149
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
|
150
|
+
base_filename = f"{file_name}_{timestamp}.csv"
|
151
|
+
output_filename = base_filename
|
152
|
+
if path:
|
153
|
+
os.makedirs(path, exist_ok=True)
|
154
|
+
output_filename = os.path.join(path, base_filename)
|
155
|
+
|
156
|
+
headers = [
|
157
|
+
"Profile",
|
158
|
+
"Account ID",
|
159
|
+
"Untagged Resources",
|
160
|
+
"Stopped EC2 Instances",
|
161
|
+
"Unused Volumes",
|
162
|
+
"Unused EIPs",
|
163
|
+
"Budget Alerts",
|
164
|
+
]
|
165
|
+
# Corresponding keys in the audit_data_list dictionaries
|
166
|
+
data_keys = [
|
167
|
+
"profile",
|
168
|
+
"account_id",
|
169
|
+
"untagged_resources",
|
170
|
+
"stopped_instances",
|
171
|
+
"unused_volumes",
|
172
|
+
"unused_eips",
|
173
|
+
"budget_alerts",
|
174
|
+
]
|
175
|
+
|
176
|
+
with open(output_filename, "w", newline="") as csvfile:
|
177
|
+
writer = csv.writer(csvfile)
|
178
|
+
writer.writerow(headers)
|
179
|
+
for item in audit_data_list:
|
180
|
+
writer.writerow([item.get(key, "") for key in data_keys])
|
181
|
+
return output_filename
|
182
|
+
except Exception as e:
|
183
|
+
console.print(f"[bold red]Error exporting audit report to CSV: {str(e)}[/]")
|
184
|
+
return None
|
185
|
+
|
186
|
+
|
187
|
+
def export_audit_report_to_json(
|
188
|
+
raw_audit_data: List[Dict[str, Any]], file_name: str = "audit_report", path: Optional[str] = None
|
189
|
+
) -> Optional[str]:
|
190
|
+
"""Export the audit report to a JSON file."""
|
191
|
+
try:
|
192
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
|
193
|
+
base_filename = f"{file_name}_{timestamp}.json"
|
194
|
+
output_filename = base_filename
|
195
|
+
if path:
|
196
|
+
os.makedirs(path, exist_ok=True)
|
197
|
+
output_filename = os.path.join(path, base_filename)
|
198
|
+
|
199
|
+
with open(output_filename, "w", encoding="utf-8") as jsonfile:
|
200
|
+
json.dump(raw_audit_data, jsonfile, indent=4) # Use the structured list
|
201
|
+
return output_filename
|
202
|
+
except Exception as e:
|
203
|
+
console.print(f"[bold red]Error exporting audit report to JSON: {str(e)}[/]")
|
204
|
+
return None
|
205
|
+
|
206
|
+
|
207
|
+
def export_trend_data_to_json(
|
208
|
+
trend_data: List[Dict[str, Any]], file_name: str = "trend_data", path: Optional[str] = None
|
209
|
+
) -> Optional[str]:
|
210
|
+
"""Export trend data to a JSON file."""
|
211
|
+
try:
|
212
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
|
213
|
+
base_filename = f"{file_name}_{timestamp}.json"
|
214
|
+
output_filename = base_filename
|
215
|
+
if path:
|
216
|
+
os.makedirs(path, exist_ok=True)
|
217
|
+
output_filename = os.path.join(path, base_filename)
|
218
|
+
|
219
|
+
with open(output_filename, "w", encoding="utf-8") as jsonfile:
|
220
|
+
json.dump(trend_data, jsonfile, indent=4)
|
221
|
+
return output_filename
|
222
|
+
except Exception as e:
|
223
|
+
console.print(f"[bold red]Error exporting trend data to JSON: {str(e)}[/]")
|
224
|
+
return None
|
225
|
+
|
226
|
+
|
227
|
+
def export_cost_dashboard_to_pdf(
|
228
|
+
data: List[ProfileData],
|
229
|
+
filename: str,
|
230
|
+
output_dir: Optional[str] = None,
|
231
|
+
previous_period_dates: str = "N/A",
|
232
|
+
current_period_dates: str = "N/A",
|
233
|
+
) -> Optional[str]:
|
234
|
+
"""Export dashboard data to a PDF file."""
|
235
|
+
try:
|
236
|
+
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
|
237
|
+
base_filename = f"{filename}_{timestamp}.pdf"
|
238
|
+
|
239
|
+
if output_dir:
|
240
|
+
os.makedirs(output_dir, exist_ok=True)
|
241
|
+
output_filename = os.path.join(output_dir, base_filename)
|
242
|
+
else:
|
243
|
+
output_filename = base_filename
|
244
|
+
|
245
|
+
doc = SimpleDocTemplate(output_filename, pagesize=landscape(letter))
|
246
|
+
styles = getSampleStyleSheet()
|
247
|
+
elements: List[Flowable] = []
|
248
|
+
|
249
|
+
previous_period_header = f"Cost for period\n({previous_period_dates})"
|
250
|
+
current_period_header = f"Cost for period\n({current_period_dates})"
|
251
|
+
|
252
|
+
headers = [
|
253
|
+
"CLI Profile",
|
254
|
+
"AWS Account ID",
|
255
|
+
previous_period_header,
|
256
|
+
current_period_header,
|
257
|
+
"Cost By Service",
|
258
|
+
"Budget Status",
|
259
|
+
"EC2 Instances",
|
260
|
+
]
|
261
|
+
table_data = [headers]
|
262
|
+
|
263
|
+
for row in data:
|
264
|
+
services_data = "\n".join([f"{service}: ${cost:.2f}" for service, cost in row["service_costs"]])
|
265
|
+
budgets_data = "\n".join(row["budget_info"]) if row["budget_info"] else "No budgets"
|
266
|
+
ec2_data_summary = "\n".join(
|
267
|
+
[f"{state}: {count}" for state, count in row["ec2_summary"].items() if count > 0]
|
268
|
+
)
|
269
|
+
|
270
|
+
table_data.append(
|
271
|
+
[
|
272
|
+
row["profile"],
|
273
|
+
row["account_id"],
|
274
|
+
f"${row['last_month']:.2f}",
|
275
|
+
f"${row['current_month']:.2f}",
|
276
|
+
services_data or "No costs",
|
277
|
+
budgets_data or "No budgets",
|
278
|
+
ec2_data_summary or "No instances",
|
279
|
+
]
|
280
|
+
)
|
281
|
+
|
282
|
+
table = Table(table_data, repeatRows=1)
|
283
|
+
table.setStyle(
|
284
|
+
TableStyle(
|
285
|
+
[
|
286
|
+
("BACKGROUND", (0, 0), (-1, 0), colors.black),
|
287
|
+
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
|
288
|
+
("FONTNAME", (0, 0), (-1, -1), "Helvetica"),
|
289
|
+
("FONTSIZE", (0, 0), (-1, -1), 8),
|
290
|
+
("ALIGN", (0, 0), (-1, -1), "LEFT"),
|
291
|
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
292
|
+
("GRID", (0, 0), (-1, -1), 0.25, colors.grey),
|
293
|
+
("BACKGROUND", (0, 1), (-1, -1), colors.whitesmoke),
|
294
|
+
]
|
295
|
+
)
|
296
|
+
)
|
297
|
+
|
298
|
+
elements.append(Paragraph("AWS FinOps Dashboard (Cost Report)", styles["Title"]))
|
299
|
+
elements.append(Spacer(1, 12))
|
300
|
+
elements.append(table)
|
301
|
+
elements.append(Spacer(1, 4))
|
302
|
+
current_time_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
303
|
+
footer_text = f"This report is generated using AWS FinOps Dashboard (CLI) \u00a9 2025 on {current_time_str}"
|
304
|
+
elements.append(Paragraph(footer_text, audit_footer_style))
|
305
|
+
|
306
|
+
doc.build(elements)
|
307
|
+
return os.path.abspath(output_filename)
|
308
|
+
except Exception as e:
|
309
|
+
console.print(f"[bold red]Error exporting to PDF: {str(e)}[/]")
|
310
|
+
return None
|
311
|
+
|
312
|
+
|
313
|
+
def load_config_file(file_path: str) -> Optional[Dict[str, Any]]:
|
314
|
+
"""Load configuration from TOML, YAML, or JSON file."""
|
315
|
+
_, file_extension = os.path.splitext(file_path)
|
316
|
+
file_extension = file_extension.lower()
|
317
|
+
|
318
|
+
try:
|
319
|
+
with open(file_path, "rb" if file_extension == ".toml" else "r") as f:
|
320
|
+
if file_extension == ".toml":
|
321
|
+
loaded_data = tomllib.load(f)
|
322
|
+
if isinstance(loaded_data, dict):
|
323
|
+
return loaded_data
|
324
|
+
console.print(f"[bold red]Error: TOML file {file_path} did not load as a dictionary.[/]")
|
325
|
+
return None
|
326
|
+
elif file_extension in [".yaml", ".yml"]:
|
327
|
+
loaded_data = yaml.safe_load(f)
|
328
|
+
if isinstance(loaded_data, dict):
|
329
|
+
return loaded_data
|
330
|
+
console.print(f"[bold red]Error: YAML file {file_path} did not load as a dictionary.[/]")
|
331
|
+
return None
|
332
|
+
elif file_extension == ".json":
|
333
|
+
loaded_data = json.load(f)
|
334
|
+
if isinstance(loaded_data, dict):
|
335
|
+
return loaded_data
|
336
|
+
console.print(f"[bold red]Error: JSON file {file_path} did not load as a dictionary.[/]")
|
337
|
+
return None
|
338
|
+
else:
|
339
|
+
console.print(f"[bold red]Error: Unsupported configuration file format: {file_extension}[/]")
|
340
|
+
return None
|
341
|
+
except FileNotFoundError:
|
342
|
+
console.print(f"[bold red]Error: Configuration file not found: {file_path}[/]")
|
343
|
+
return None
|
344
|
+
except tomllib.TOMLDecodeError as e:
|
345
|
+
console.print(f"[bold red]Error decoding TOML file {file_path}: {e}[/]")
|
346
|
+
return None
|
347
|
+
except yaml.YAMLError as e:
|
348
|
+
console.print(f"[bold red]Error decoding YAML file {file_path}: {e}[/]")
|
349
|
+
return None
|
350
|
+
except json.JSONDecodeError as e:
|
351
|
+
console.print(f"[bold red]Error decoding JSON file {file_path}: {e}[/]")
|
352
|
+
return None
|
353
|
+
except Exception as e:
|
354
|
+
console.print(f"[bold red]Error loading configuration file {file_path}: {e}[/]")
|
355
|
+
return None
|
runbooks/finops/main.py
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
import argparse
|
2
|
+
import sys
|
3
|
+
from typing import Dict, List, Optional
|
4
|
+
|
5
|
+
from runbooks.finops.cli import main as cli_main_entry
|
6
|
+
|
7
|
+
|
8
|
+
def main() -> int:
|
9
|
+
"""Entry point for the finops submodule when run directly."""
|
10
|
+
return cli_main_entry()
|
11
|
+
|
12
|
+
|
13
|
+
if __name__ == "__main__":
|
14
|
+
sys.exit(main())
|
@@ -0,0 +1,174 @@
|
|
1
|
+
from collections import defaultdict
|
2
|
+
from typing import Dict, List, Optional
|
3
|
+
|
4
|
+
import boto3
|
5
|
+
from rich.console import Console
|
6
|
+
|
7
|
+
from runbooks.finops.aws_client import (
|
8
|
+
ec2_summary,
|
9
|
+
get_accessible_regions,
|
10
|
+
)
|
11
|
+
from runbooks.finops.cost_processor import (
|
12
|
+
change_in_total_cost,
|
13
|
+
format_budget_info,
|
14
|
+
format_ec2_summary,
|
15
|
+
get_cost_data,
|
16
|
+
process_service_costs,
|
17
|
+
)
|
18
|
+
from runbooks.finops.types import (
|
19
|
+
BudgetInfo,
|
20
|
+
CostData,
|
21
|
+
ProfileData,
|
22
|
+
)
|
23
|
+
|
24
|
+
console = Console()
|
25
|
+
|
26
|
+
|
27
|
+
def process_single_profile(
|
28
|
+
profile: str,
|
29
|
+
user_regions: Optional[List[str]] = None,
|
30
|
+
time_range: Optional[int] = None,
|
31
|
+
tag: Optional[List[str]] = None,
|
32
|
+
) -> ProfileData:
|
33
|
+
"""Process a single AWS profile and return its data."""
|
34
|
+
try:
|
35
|
+
session = boto3.Session(profile_name=profile)
|
36
|
+
cost_data = get_cost_data(session, time_range, tag)
|
37
|
+
|
38
|
+
if user_regions:
|
39
|
+
profile_regions = user_regions
|
40
|
+
else:
|
41
|
+
profile_regions = get_accessible_regions(session)
|
42
|
+
|
43
|
+
ec2_data = ec2_summary(session, profile_regions)
|
44
|
+
service_costs, service_cost_data = process_service_costs(cost_data)
|
45
|
+
budget_info = format_budget_info(cost_data["budgets"])
|
46
|
+
account_id = cost_data.get("account_id", "Unknown") or "Unknown"
|
47
|
+
ec2_summary_text = format_ec2_summary(ec2_data)
|
48
|
+
percent_change_in_total_cost = change_in_total_cost(cost_data["current_month"], cost_data["last_month"])
|
49
|
+
|
50
|
+
return {
|
51
|
+
"profile": profile,
|
52
|
+
"account_id": account_id,
|
53
|
+
"last_month": cost_data["last_month"],
|
54
|
+
"current_month": cost_data["current_month"],
|
55
|
+
"service_costs": service_cost_data,
|
56
|
+
"service_costs_formatted": service_costs,
|
57
|
+
"budget_info": budget_info,
|
58
|
+
"ec2_summary": ec2_data,
|
59
|
+
"ec2_summary_formatted": ec2_summary_text,
|
60
|
+
"success": True,
|
61
|
+
"error": None,
|
62
|
+
"current_period_name": cost_data["current_period_name"],
|
63
|
+
"previous_period_name": cost_data["previous_period_name"],
|
64
|
+
"percent_change_in_total_cost": percent_change_in_total_cost,
|
65
|
+
}
|
66
|
+
|
67
|
+
except Exception as e:
|
68
|
+
return {
|
69
|
+
"profile": profile,
|
70
|
+
"account_id": "Error",
|
71
|
+
"last_month": 0,
|
72
|
+
"current_month": 0,
|
73
|
+
"service_costs": [],
|
74
|
+
"service_costs_formatted": [f"Failed to process profile: {str(e)}"],
|
75
|
+
"budget_info": ["N/A"],
|
76
|
+
"ec2_summary": {"N/A": 0},
|
77
|
+
"ec2_summary_formatted": ["Error"],
|
78
|
+
"success": False,
|
79
|
+
"error": str(e),
|
80
|
+
"current_period_name": "Current month",
|
81
|
+
"previous_period_name": "Last month",
|
82
|
+
"percent_change_in_total_cost": None,
|
83
|
+
}
|
84
|
+
|
85
|
+
|
86
|
+
def process_combined_profiles(
|
87
|
+
account_id: str,
|
88
|
+
profiles: List[str],
|
89
|
+
user_regions: Optional[List[str]] = None,
|
90
|
+
time_range: Optional[int] = None,
|
91
|
+
tag: Optional[List[str]] = None,
|
92
|
+
) -> ProfileData:
|
93
|
+
"""Process multiple profiles from the same AWS account."""
|
94
|
+
|
95
|
+
primary_profile = profiles[0]
|
96
|
+
primary_session = boto3.Session(profile_name=primary_profile)
|
97
|
+
|
98
|
+
account_cost_data: CostData = {
|
99
|
+
"account_id": account_id,
|
100
|
+
"current_month": 0.0,
|
101
|
+
"last_month": 0.0,
|
102
|
+
"current_month_cost_by_service": [],
|
103
|
+
"budgets": [],
|
104
|
+
"current_period_name": "Current month",
|
105
|
+
"previous_period_name": "Last month",
|
106
|
+
"time_range": time_range,
|
107
|
+
"current_period_start": "N/A",
|
108
|
+
"current_period_end": "N/A",
|
109
|
+
"previous_period_start": "N/A",
|
110
|
+
"previous_period_end": "N/A",
|
111
|
+
"monthly_costs": None,
|
112
|
+
}
|
113
|
+
|
114
|
+
try:
|
115
|
+
# Attempt to overwrite with actual data from Cost Explorer
|
116
|
+
account_cost_data = get_cost_data(primary_session, time_range, tag)
|
117
|
+
except Exception as e:
|
118
|
+
console.log(f"[bold red]Error getting cost data for account {account_id}: {str(e)}[/]")
|
119
|
+
# account_cost_data retains its default values if an error occurs
|
120
|
+
|
121
|
+
combined_current_month = account_cost_data["current_month"]
|
122
|
+
combined_last_month = account_cost_data["last_month"]
|
123
|
+
combined_service_costs_dict: Dict[str, float] = defaultdict(float)
|
124
|
+
|
125
|
+
for group in account_cost_data["current_month_cost_by_service"]:
|
126
|
+
if "Keys" in group and "Metrics" in group:
|
127
|
+
service_name = group["Keys"][0]
|
128
|
+
cost_amount = float(group["Metrics"]["UnblendedCost"]["Amount"])
|
129
|
+
if cost_amount > 0.001:
|
130
|
+
combined_service_costs_dict[service_name] += cost_amount
|
131
|
+
|
132
|
+
combined_budgets = account_cost_data["budgets"]
|
133
|
+
|
134
|
+
if user_regions:
|
135
|
+
primary_regions = user_regions
|
136
|
+
else:
|
137
|
+
primary_regions = get_accessible_regions(primary_session)
|
138
|
+
|
139
|
+
combined_ec2 = ec2_summary(primary_session, primary_regions)
|
140
|
+
|
141
|
+
service_costs = []
|
142
|
+
service_cost_data = [(service, cost) for service, cost in combined_service_costs_dict.items() if cost > 0.001]
|
143
|
+
service_cost_data.sort(key=lambda x: x[1], reverse=True)
|
144
|
+
|
145
|
+
if not service_cost_data:
|
146
|
+
service_costs.append("No costs associated with this account")
|
147
|
+
else:
|
148
|
+
for service_name, cost_amount in service_cost_data:
|
149
|
+
service_costs.append(f"{service_name}: ${cost_amount:.2f}")
|
150
|
+
|
151
|
+
budget_info = format_budget_info(combined_budgets)
|
152
|
+
|
153
|
+
ec2_summary_text = format_ec2_summary(combined_ec2)
|
154
|
+
|
155
|
+
profile_list = ", ".join(profiles)
|
156
|
+
|
157
|
+
percent_change_in_total_cost = change_in_total_cost(combined_current_month, combined_last_month)
|
158
|
+
|
159
|
+
return {
|
160
|
+
"profile": profile_list,
|
161
|
+
"account_id": account_id,
|
162
|
+
"last_month": combined_last_month,
|
163
|
+
"current_month": combined_current_month,
|
164
|
+
"service_costs": service_cost_data,
|
165
|
+
"service_costs_formatted": service_costs,
|
166
|
+
"budget_info": budget_info,
|
167
|
+
"ec2_summary": combined_ec2,
|
168
|
+
"ec2_summary_formatted": ec2_summary_text,
|
169
|
+
"success": True,
|
170
|
+
"error": None,
|
171
|
+
"current_period_name": account_cost_data["current_period_name"],
|
172
|
+
"previous_period_name": account_cost_data["previous_period_name"],
|
173
|
+
"percent_change_in_total_cost": percent_change_in_total_cost,
|
174
|
+
}
|
runbooks/finops/types.py
ADDED
@@ -0,0 +1,66 @@
|
|
1
|
+
"""Type definitions for AWS FinOps Dashboard."""
|
2
|
+
|
3
|
+
from typing import Dict, List, Optional, Tuple, TypedDict
|
4
|
+
|
5
|
+
|
6
|
+
class BudgetInfo(TypedDict):
|
7
|
+
"""Type for a budget entry."""
|
8
|
+
|
9
|
+
name: str
|
10
|
+
limit: float
|
11
|
+
actual: float
|
12
|
+
forecast: Optional[float]
|
13
|
+
|
14
|
+
|
15
|
+
class CostData(TypedDict):
|
16
|
+
"""Type for cost data returned from AWS Cost Explorer."""
|
17
|
+
|
18
|
+
account_id: Optional[str]
|
19
|
+
current_month: float
|
20
|
+
last_month: float
|
21
|
+
current_month_cost_by_service: List[Dict]
|
22
|
+
budgets: List[BudgetInfo]
|
23
|
+
current_period_name: str
|
24
|
+
previous_period_name: str
|
25
|
+
time_range: Optional[int]
|
26
|
+
current_period_start: str
|
27
|
+
current_period_end: str
|
28
|
+
previous_period_start: str
|
29
|
+
previous_period_end: str
|
30
|
+
monthly_costs: Optional[List[Tuple[str, float]]]
|
31
|
+
|
32
|
+
|
33
|
+
class ProfileData(TypedDict):
|
34
|
+
"""Type for processed profile data."""
|
35
|
+
|
36
|
+
profile: str
|
37
|
+
account_id: str
|
38
|
+
last_month: float
|
39
|
+
current_month: float
|
40
|
+
service_costs: List[Tuple[str, float]]
|
41
|
+
service_costs_formatted: List[str]
|
42
|
+
budget_info: List[str]
|
43
|
+
ec2_summary: Dict[str, int]
|
44
|
+
ec2_summary_formatted: List[str]
|
45
|
+
success: bool
|
46
|
+
error: Optional[str]
|
47
|
+
current_period_name: str
|
48
|
+
previous_period_name: str
|
49
|
+
percent_change_in_total_cost: Optional[float]
|
50
|
+
|
51
|
+
|
52
|
+
class CLIArgs(TypedDict, total=False):
|
53
|
+
"""Type for CLI arguments."""
|
54
|
+
|
55
|
+
profiles: Optional[List[str]]
|
56
|
+
regions: Optional[List[str]]
|
57
|
+
all: bool
|
58
|
+
combine: bool
|
59
|
+
report_name: Optional[str]
|
60
|
+
report_type: Optional[List[str]]
|
61
|
+
dir: Optional[str]
|
62
|
+
time_range: Optional[int]
|
63
|
+
|
64
|
+
|
65
|
+
RegionName = str
|
66
|
+
EC2Summary = Dict[str, int]
|
@@ -0,0 +1,80 @@
|
|
1
|
+
from decimal import ROUND_HALF_UP, Decimal, getcontext
|
2
|
+
from typing import List, Tuple
|
3
|
+
|
4
|
+
from rich.console import Console
|
5
|
+
from rich.panel import Panel
|
6
|
+
from rich.table import Table
|
7
|
+
|
8
|
+
# Set precision context for Decimal operations
|
9
|
+
getcontext().prec = 6
|
10
|
+
|
11
|
+
console = Console()
|
12
|
+
|
13
|
+
|
14
|
+
def create_trend_bars(monthly_costs: List[Tuple[str, float]]) -> None:
|
15
|
+
"""Create colorful trend bars using Rich's styling and precise Decimal math."""
|
16
|
+
if not monthly_costs:
|
17
|
+
return
|
18
|
+
|
19
|
+
table = Table(box=None, padding=(1, 1), collapse_padding=True)
|
20
|
+
|
21
|
+
table.add_column("Month", style="bright_magenta", width=10)
|
22
|
+
table.add_column("Cost", style="bright_cyan", justify="right", width=15)
|
23
|
+
table.add_column("", width=50)
|
24
|
+
table.add_column("MoM Change", style="bright_yellow", width=12)
|
25
|
+
|
26
|
+
max_cost = max(cost for _, cost in monthly_costs)
|
27
|
+
if max_cost == 0:
|
28
|
+
console.print("[yellow]All costs are $0.00 for this period[/]")
|
29
|
+
return
|
30
|
+
|
31
|
+
prev_cost = None
|
32
|
+
|
33
|
+
for month, cost in monthly_costs:
|
34
|
+
cost_d = Decimal(str(cost))
|
35
|
+
bar_length = int((cost / max_cost) * 40) if max_cost > 0 else 0
|
36
|
+
bar = "█" * bar_length
|
37
|
+
|
38
|
+
# Default values
|
39
|
+
bar_color = "blue"
|
40
|
+
change = ""
|
41
|
+
|
42
|
+
if prev_cost is not None:
|
43
|
+
prev_d = Decimal(str(prev_cost))
|
44
|
+
|
45
|
+
if prev_d < Decimal("0.01"):
|
46
|
+
if cost_d < Decimal("0.01"):
|
47
|
+
change = "[bright_yellow]0%[/]"
|
48
|
+
bar_color = "yellow"
|
49
|
+
else:
|
50
|
+
change = "[bright_red]N/A[/]"
|
51
|
+
bar_color = "bright_red"
|
52
|
+
else:
|
53
|
+
change_pct = ((cost_d - prev_d) / prev_d * Decimal("100")).quantize(
|
54
|
+
Decimal("0.01"), rounding=ROUND_HALF_UP
|
55
|
+
)
|
56
|
+
|
57
|
+
if abs(change_pct) < Decimal("0.01"):
|
58
|
+
change = "[bright_yellow]0%[/]"
|
59
|
+
bar_color = "yellow"
|
60
|
+
elif abs(change_pct) > Decimal("999"):
|
61
|
+
color = "bright_red" if change_pct > 0 else "bright_green"
|
62
|
+
change = f"[{color}]{'>+' if change_pct > 0 else '-'}999%[/]"
|
63
|
+
bar_color = color
|
64
|
+
else:
|
65
|
+
color = "bright_red" if change_pct > 0 else "bright_green"
|
66
|
+
sign = "+" if change_pct > 0 else ""
|
67
|
+
change = f"[{color}]{sign}{change_pct}%[/]"
|
68
|
+
bar_color = color
|
69
|
+
|
70
|
+
table.add_row(month, f"${cost:,.2f}", f"[{bar_color}]{bar}[/]", change)
|
71
|
+
prev_cost = cost
|
72
|
+
|
73
|
+
console.print(
|
74
|
+
Panel(
|
75
|
+
table,
|
76
|
+
title="[cyan]AWS Cost Trend Analysis[/]",
|
77
|
+
border_style="bright_blue",
|
78
|
+
padding=(1, 1),
|
79
|
+
)
|
80
|
+
)
|