finops-scan 0.1.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.
- finops_scan/__init__.py +0 -0
- finops_scan/analyzer.py +217 -0
- finops_scan/cli.py +118 -0
- finops_scan/fetcher.py +142 -0
- finops_scan/reporter.py +162 -0
- finops_scan-0.1.0.dist-info/METADATA +145 -0
- finops_scan-0.1.0.dist-info/RECORD +9 -0
- finops_scan-0.1.0.dist-info/WHEEL +4 -0
- finops_scan-0.1.0.dist-info/entry_points.txt +2 -0
finops_scan/__init__.py
ADDED
|
File without changes
|
finops_scan/analyzer.py
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Analyzer — derives waste insights from Cost Explorer data.
|
|
3
|
+
No AWS API calls here — pure computation on DailySpend records.
|
|
4
|
+
Separated from fetcher so it can be unit tested without AWS credentials.
|
|
5
|
+
"""
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import List, Dict
|
|
9
|
+
from src.finops_scan.fetcher import DailySpend
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class ServiceSummary:
|
|
14
|
+
service: str
|
|
15
|
+
total_30d: Decimal
|
|
16
|
+
daily_average: Decimal
|
|
17
|
+
trend: str # "increasing" | "decreasing" | "stable"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class WasteOpportunity:
|
|
22
|
+
rank: int
|
|
23
|
+
service: str
|
|
24
|
+
finding: str
|
|
25
|
+
monthly_cost: Decimal
|
|
26
|
+
action: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CostAnalyzer:
|
|
30
|
+
"""
|
|
31
|
+
Derives ranked waste opportunities from DailySpend records.
|
|
32
|
+
|
|
33
|
+
All methods are pure functions — same input always produces
|
|
34
|
+
same output. No side effects, no API calls.
|
|
35
|
+
Easy to unit test without AWS credentials.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
# Services that typically indicate waste when spend is high
|
|
39
|
+
# relative to their utility
|
|
40
|
+
IDLE_SIGNALS = {
|
|
41
|
+
"Amazon Elastic Compute Cloud - Compute": "EC2 instances",
|
|
42
|
+
"Amazon Relational Database Service": "RDS instances",
|
|
43
|
+
"Amazon ElastiCache": "ElastiCache clusters",
|
|
44
|
+
"Amazon Redshift": "Redshift clusters",
|
|
45
|
+
"AWS Lambda": "Lambda functions",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
AI_SERVICES = {
|
|
49
|
+
"Amazon Bedrock",
|
|
50
|
+
"Amazon SageMaker",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
def summarize_by_service(
|
|
54
|
+
self,
|
|
55
|
+
records: List[DailySpend],
|
|
56
|
+
) -> List[ServiceSummary]:
|
|
57
|
+
"""
|
|
58
|
+
Aggregates DailySpend records into per-service summaries.
|
|
59
|
+
Sorted by total_30d descending — highest spend first.
|
|
60
|
+
"""
|
|
61
|
+
totals: Dict[str, Decimal] = {}
|
|
62
|
+
counts: Dict[str, int] = {}
|
|
63
|
+
first7: Dict[str, Decimal] = {}
|
|
64
|
+
last7: Dict[str, Decimal] = {}
|
|
65
|
+
|
|
66
|
+
sorted_records = sorted(records, key=lambda r: r.date)
|
|
67
|
+
all_dates = sorted(set(r.date for r in sorted_records))
|
|
68
|
+
first_7_dates = set(all_dates[:7])
|
|
69
|
+
last_7_dates = set(all_dates[-7:])
|
|
70
|
+
|
|
71
|
+
for r in records:
|
|
72
|
+
totals[r.service] = totals.get(r.service, Decimal("0")) + r.amount
|
|
73
|
+
counts[r.service] = counts.get(r.service, 0) + 1
|
|
74
|
+
if r.date in first_7_dates:
|
|
75
|
+
first7[r.service] = first7.get(r.service,
|
|
76
|
+
Decimal("0")) + r.amount
|
|
77
|
+
if r.date in last_7_dates:
|
|
78
|
+
last7[r.service] = last7.get(r.service,
|
|
79
|
+
Decimal("0")) + r.amount
|
|
80
|
+
|
|
81
|
+
summaries = []
|
|
82
|
+
for service, total in totals.items():
|
|
83
|
+
count = counts[service]
|
|
84
|
+
daily_avg = total / count if count > 0 else Decimal("0")
|
|
85
|
+
f = first7.get(service, Decimal("0"))
|
|
86
|
+
l = last7.get(service, Decimal("0"))
|
|
87
|
+
if l > f * Decimal("1.10"):
|
|
88
|
+
trend = "increasing"
|
|
89
|
+
elif l < f * Decimal("0.90"):
|
|
90
|
+
trend = "decreasing"
|
|
91
|
+
else:
|
|
92
|
+
trend = "stable"
|
|
93
|
+
|
|
94
|
+
summaries.append(ServiceSummary(
|
|
95
|
+
service=service,
|
|
96
|
+
total_30d=total,
|
|
97
|
+
daily_average=daily_avg,
|
|
98
|
+
trend=trend,
|
|
99
|
+
))
|
|
100
|
+
|
|
101
|
+
return sorted(summaries, key=lambda s: s.total_30d, reverse=True)
|
|
102
|
+
|
|
103
|
+
def identify_waste(
|
|
104
|
+
self,
|
|
105
|
+
summaries: List[ServiceSummary],
|
|
106
|
+
tag_coverage: dict,
|
|
107
|
+
) -> List[WasteOpportunity]:
|
|
108
|
+
"""
|
|
109
|
+
Produces ranked waste opportunities from service summaries.
|
|
110
|
+
Each opportunity has a specific finding and action.
|
|
111
|
+
"""
|
|
112
|
+
opportunities: List[WasteOpportunity] = []
|
|
113
|
+
rank = 1
|
|
114
|
+
|
|
115
|
+
# Opportunity 1: increasing spend on compute services
|
|
116
|
+
for s in summaries:
|
|
117
|
+
if s.trend == "increasing" and s.service in self.IDLE_SIGNALS:
|
|
118
|
+
name = self.IDLE_SIGNALS[s.service]
|
|
119
|
+
opportunities.append(WasteOpportunity(
|
|
120
|
+
rank=rank,
|
|
121
|
+
service=s.service,
|
|
122
|
+
finding=(
|
|
123
|
+
f"{name} spend increased 10%+ over the last 7 days. "
|
|
124
|
+
f"30-day total: ${s.total_30d:,.2f}"
|
|
125
|
+
),
|
|
126
|
+
monthly_cost=s.total_30d,
|
|
127
|
+
action=(
|
|
128
|
+
f"Review {name} utilization. "
|
|
129
|
+
f"Rightsizing or stopping idle instances "
|
|
130
|
+
f"could reduce this cost by 20-40%."
|
|
131
|
+
),
|
|
132
|
+
))
|
|
133
|
+
rank += 1
|
|
134
|
+
|
|
135
|
+
# Opportunity 2: high spend on data transfer
|
|
136
|
+
for s in summaries:
|
|
137
|
+
if "Data Transfer" in s.service and s.total_30d > Decimal("100"):
|
|
138
|
+
opportunities.append(WasteOpportunity(
|
|
139
|
+
rank=rank,
|
|
140
|
+
service=s.service,
|
|
141
|
+
finding=(
|
|
142
|
+
f"Data transfer cost ${s.total_30d:,.2f} in 30 days. "
|
|
143
|
+
f"Often caused by cross-AZ traffic or "
|
|
144
|
+
f"unoptimized S3 access patterns."
|
|
145
|
+
),
|
|
146
|
+
monthly_cost=s.total_30d,
|
|
147
|
+
action=(
|
|
148
|
+
"Move resources to the same AZ. "
|
|
149
|
+
"Use S3 VPC endpoints to eliminate "
|
|
150
|
+
"data transfer charges."
|
|
151
|
+
),
|
|
152
|
+
))
|
|
153
|
+
rank += 1
|
|
154
|
+
|
|
155
|
+
# Opportunity 3: low tag coverage
|
|
156
|
+
if tag_coverage.get("untagged_pct", 100) > 20:
|
|
157
|
+
untagged = tag_coverage.get("untagged_spend", Decimal("0"))
|
|
158
|
+
opportunities.append(WasteOpportunity(
|
|
159
|
+
rank=rank,
|
|
160
|
+
service="All services",
|
|
161
|
+
finding=(
|
|
162
|
+
f"{tag_coverage['untagged_pct']}% of spend "
|
|
163
|
+
f"(${untagged:,.2f}) has no team tag. "
|
|
164
|
+
f"You cannot allocate what you cannot attribute."
|
|
165
|
+
),
|
|
166
|
+
monthly_cost=untagged,
|
|
167
|
+
action=(
|
|
168
|
+
"Apply team tags to all resources. "
|
|
169
|
+
"fynC can automate tag suggestions "
|
|
170
|
+
"based on resource naming patterns."
|
|
171
|
+
),
|
|
172
|
+
))
|
|
173
|
+
rank += 1
|
|
174
|
+
|
|
175
|
+
# Opportunity 4: AI services with no visibility
|
|
176
|
+
ai_spend = sum(
|
|
177
|
+
s.total_30d for s in summaries
|
|
178
|
+
if any(ai in s.service for ai in self.AI_SERVICES)
|
|
179
|
+
)
|
|
180
|
+
if ai_spend > Decimal("0"):
|
|
181
|
+
opportunities.append(WasteOpportunity(
|
|
182
|
+
rank=rank,
|
|
183
|
+
service="AI Services",
|
|
184
|
+
finding=(
|
|
185
|
+
f"${ai_spend:,.2f} spent on AI services in 30 days "
|
|
186
|
+
f"with no model-level breakdown. "
|
|
187
|
+
f"You cannot optimize what you cannot see."
|
|
188
|
+
),
|
|
189
|
+
monthly_cost=ai_spend,
|
|
190
|
+
action=(
|
|
191
|
+
"Track AI spend at the model level with fynC. "
|
|
192
|
+
"See which team is driving cost and "
|
|
193
|
+
"which model gives the best cost/quality ratio."
|
|
194
|
+
),
|
|
195
|
+
))
|
|
196
|
+
rank += 1
|
|
197
|
+
|
|
198
|
+
return opportunities[:10] # top 10 only
|
|
199
|
+
|
|
200
|
+
def total_spend(self, records: List[DailySpend]) -> Decimal:
|
|
201
|
+
"""Total spend across all services for the period."""
|
|
202
|
+
return sum((r.amount for r in records), Decimal("0"))
|
|
203
|
+
|
|
204
|
+
def estimated_savings(
|
|
205
|
+
self,
|
|
206
|
+
opportunities: List[WasteOpportunity],
|
|
207
|
+
) -> Decimal:
|
|
208
|
+
"""
|
|
209
|
+
Conservative savings estimate: 20% of waste opportunity costs.
|
|
210
|
+
Real savings depend on which actions are taken.
|
|
211
|
+
"""
|
|
212
|
+
total = sum(
|
|
213
|
+
(o.monthly_cost for o in opportunities
|
|
214
|
+
if o.service != "All services"),
|
|
215
|
+
Decimal("0"),
|
|
216
|
+
)
|
|
217
|
+
return total * Decimal("0.20")
|
finops_scan/cli.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI entry point for finops-scan.
|
|
3
|
+
Uses Click for argument parsing and command structure.
|
|
4
|
+
Orchestrates: fetcher → analyzer → reporter.
|
|
5
|
+
"""
|
|
6
|
+
import sys
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command()
|
|
16
|
+
@click.option(
|
|
17
|
+
"--access-key",
|
|
18
|
+
envvar="AWS_ACCESS_KEY_ID",
|
|
19
|
+
required=True,
|
|
20
|
+
help="AWS Access Key ID (or set AWS_ACCESS_KEY_ID env var)",
|
|
21
|
+
)
|
|
22
|
+
@click.option(
|
|
23
|
+
"--secret-key",
|
|
24
|
+
envvar="AWS_SECRET_ACCESS_KEY",
|
|
25
|
+
required=True,
|
|
26
|
+
help="AWS Secret Access Key (or set AWS_SECRET_ACCESS_KEY env var)",
|
|
27
|
+
)
|
|
28
|
+
@click.option(
|
|
29
|
+
"--region",
|
|
30
|
+
default="us-east-1",
|
|
31
|
+
show_default=True,
|
|
32
|
+
help="AWS region",
|
|
33
|
+
)
|
|
34
|
+
@click.option(
|
|
35
|
+
"--account-id",
|
|
36
|
+
default="my-account",
|
|
37
|
+
help="AWS account ID or alias (display only)",
|
|
38
|
+
)
|
|
39
|
+
def main(
|
|
40
|
+
access_key: str,
|
|
41
|
+
secret_key: str,
|
|
42
|
+
region: str,
|
|
43
|
+
account_id: str,
|
|
44
|
+
) -> None:
|
|
45
|
+
"""
|
|
46
|
+
finops-scan — scan your AWS account for cloud waste in 60 seconds.
|
|
47
|
+
|
|
48
|
+
Reads cost data via AWS Cost Explorer API (read-only).
|
|
49
|
+
No data is stored. No resources are modified.
|
|
50
|
+
|
|
51
|
+
Required IAM permission: ce:GetCostAndUsage
|
|
52
|
+
|
|
53
|
+
Example:
|
|
54
|
+
|
|
55
|
+
finops-scan --access-key AKIA... --secret-key ... --region us-east-1
|
|
56
|
+
|
|
57
|
+
Or use environment variables:
|
|
58
|
+
|
|
59
|
+
export AWS_ACCESS_KEY_ID=AKIA...
|
|
60
|
+
|
|
61
|
+
export AWS_SECRET_ACCESS_KEY=...
|
|
62
|
+
|
|
63
|
+
finops-scan
|
|
64
|
+
"""
|
|
65
|
+
from src.finops_scan.fetcher import CostExplorerFetcher
|
|
66
|
+
from src.finops_scan.analyzer import CostAnalyzer
|
|
67
|
+
from src.finops_scan.reporter import WasteReporter
|
|
68
|
+
|
|
69
|
+
reporter = WasteReporter()
|
|
70
|
+
reporter.print_header(account_id)
|
|
71
|
+
|
|
72
|
+
# Step 1 — Fetch
|
|
73
|
+
console.print("[dim]Fetching cost data from AWS Cost Explorer...[/dim]")
|
|
74
|
+
try:
|
|
75
|
+
fetcher = CostExplorerFetcher(
|
|
76
|
+
aws_access_key=access_key,
|
|
77
|
+
aws_secret_key=secret_key,
|
|
78
|
+
region=region,
|
|
79
|
+
)
|
|
80
|
+
records = fetcher.fetch_last_30_days()
|
|
81
|
+
tag_coverage = fetcher.fetch_tag_coverage()
|
|
82
|
+
|
|
83
|
+
except Exception as e:
|
|
84
|
+
console.print(f"[red]✗ Failed to connect to AWS:[/red] {e}")
|
|
85
|
+
console.print(
|
|
86
|
+
"[dim]Check your credentials and ensure "
|
|
87
|
+
"ce:GetCostAndUsage permission is attached.[/dim]"
|
|
88
|
+
)
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
|
|
91
|
+
if not records:
|
|
92
|
+
console.print(
|
|
93
|
+
"[yellow]No cost data found for the last 30 days.[/yellow]"
|
|
94
|
+
)
|
|
95
|
+
sys.exit(0)
|
|
96
|
+
|
|
97
|
+
console.print(
|
|
98
|
+
f"[dim]✓ Fetched {len(records)} cost records.[/dim]"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Step 2 — Analyze
|
|
102
|
+
console.print("[dim]Analyzing waste patterns...[/dim]")
|
|
103
|
+
analyzer = CostAnalyzer()
|
|
104
|
+
summaries = analyzer.summarize_by_service(records)
|
|
105
|
+
opportunities = analyzer.identify_waste(summaries, tag_coverage)
|
|
106
|
+
total = analyzer.total_spend(records)
|
|
107
|
+
savings = analyzer.estimated_savings(opportunities)
|
|
108
|
+
console.print("[dim]✓ Analysis complete.[/dim]\n")
|
|
109
|
+
|
|
110
|
+
# Step 3 — Report
|
|
111
|
+
reporter.print_summary(total, savings, tag_coverage)
|
|
112
|
+
reporter.print_top_services(summaries)
|
|
113
|
+
reporter.print_waste_opportunities(opportunities)
|
|
114
|
+
reporter.print_cta()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
main()
|
finops_scan/fetcher.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AWS Cost Explorer fetcher.
|
|
3
|
+
Pulls cost and usage data using the Cost Explorer API.
|
|
4
|
+
Works on any AWS account — no CUR or S3 setup required.
|
|
5
|
+
Just needs Access Key + Secret Key with ce:GetCostAndUsage permission.
|
|
6
|
+
"""
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import date, timedelta
|
|
9
|
+
from decimal import Decimal
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
import boto3
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class DailySpend:
|
|
17
|
+
date: str
|
|
18
|
+
service: str
|
|
19
|
+
amount: Decimal
|
|
20
|
+
currency: str = "USD"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ResourceWaste:
|
|
25
|
+
service: str
|
|
26
|
+
resource_id: str
|
|
27
|
+
monthly_cost: Decimal
|
|
28
|
+
reason: str
|
|
29
|
+
savings: Decimal
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CostExplorerFetcher:
|
|
33
|
+
"""
|
|
34
|
+
Fetches cost data from AWS Cost Explorer API.
|
|
35
|
+
|
|
36
|
+
Required IAM permissions:
|
|
37
|
+
ce:GetCostAndUsage
|
|
38
|
+
ce:GetCostAndUsageWithResources (optional — for resource-level)
|
|
39
|
+
|
|
40
|
+
These are read-only permissions. fynC and finops-scan
|
|
41
|
+
never write to or modify any AWS resources.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
aws_access_key: str,
|
|
47
|
+
aws_secret_key: str,
|
|
48
|
+
region: str = "us-east-1",
|
|
49
|
+
) -> None:
|
|
50
|
+
self._client = boto3.client(
|
|
51
|
+
"ce",
|
|
52
|
+
aws_access_key_id=aws_access_key,
|
|
53
|
+
aws_secret_access_key=aws_secret_key,
|
|
54
|
+
region_name=region,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def fetch_last_30_days(self) -> List[DailySpend]:
|
|
58
|
+
"""
|
|
59
|
+
Fetches daily spend by service for the last 30 days.
|
|
60
|
+
Returns a flat list of DailySpend records.
|
|
61
|
+
"""
|
|
62
|
+
end = date.today()
|
|
63
|
+
start = end - timedelta(days=30)
|
|
64
|
+
|
|
65
|
+
response = self._client.get_cost_and_usage(
|
|
66
|
+
TimePeriod={
|
|
67
|
+
"Start": start.isoformat(),
|
|
68
|
+
"End": end.isoformat(),
|
|
69
|
+
},
|
|
70
|
+
Granularity="DAILY",
|
|
71
|
+
Metrics=["BlendedCost"],
|
|
72
|
+
GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
records: List[DailySpend] = []
|
|
76
|
+
|
|
77
|
+
for result in response.get("ResultsByTime", []):
|
|
78
|
+
period = result["TimePeriod"]["Start"]
|
|
79
|
+
for group in result.get("Groups", []):
|
|
80
|
+
service = group["Keys"][0]
|
|
81
|
+
amount = Decimal(
|
|
82
|
+
group["Metrics"]["BlendedCost"]["Amount"]
|
|
83
|
+
)
|
|
84
|
+
if amount > Decimal("0.001"): # skip negligible costs
|
|
85
|
+
records.append(DailySpend(
|
|
86
|
+
date=period,
|
|
87
|
+
service=service,
|
|
88
|
+
amount=amount,
|
|
89
|
+
))
|
|
90
|
+
|
|
91
|
+
return records
|
|
92
|
+
|
|
93
|
+
def fetch_tag_coverage(self) -> dict:
|
|
94
|
+
"""
|
|
95
|
+
Fetches tag coverage — what fraction of spend has team tags.
|
|
96
|
+
Returns dict with tagged_pct, untagged_pct, untagged_spend.
|
|
97
|
+
"""
|
|
98
|
+
end = date.today()
|
|
99
|
+
start = end - timedelta(days=30)
|
|
100
|
+
|
|
101
|
+
tagged = self._client.get_cost_and_usage(
|
|
102
|
+
TimePeriod={"Start": start.isoformat(), "End": end.isoformat()},
|
|
103
|
+
Granularity="MONTHLY",
|
|
104
|
+
Metrics=["BlendedCost"],
|
|
105
|
+
Filter={
|
|
106
|
+
"Tags": {
|
|
107
|
+
"Key": "team",
|
|
108
|
+
"Values": ["*"],
|
|
109
|
+
"MatchOptions": ["EXISTS"],
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
total = self._client.get_cost_and_usage(
|
|
115
|
+
TimePeriod={"Start": start.isoformat(), "End": end.isoformat()},
|
|
116
|
+
Granularity="MONTHLY",
|
|
117
|
+
Metrics=["BlendedCost"],
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
def extract(resp) -> Decimal:
|
|
121
|
+
results = resp.get("ResultsByTime", [])
|
|
122
|
+
if not results:
|
|
123
|
+
return Decimal("0")
|
|
124
|
+
return Decimal(
|
|
125
|
+
results[0]["Total"]["BlendedCost"]["Amount"]
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
tagged_spend = extract(tagged)
|
|
129
|
+
total_spend = extract(total)
|
|
130
|
+
|
|
131
|
+
if total_spend == Decimal("0"):
|
|
132
|
+
return {"tagged_pct": 0.0, "untagged_pct": 100.0,
|
|
133
|
+
"untagged_spend": Decimal("0")}
|
|
134
|
+
|
|
135
|
+
tagged_pct = float(tagged_spend / total_spend * 100)
|
|
136
|
+
untagged_pct = 100.0 - tagged_pct
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
"tagged_pct": round(tagged_pct, 1),
|
|
140
|
+
"untagged_pct": round(untagged_pct, 1),
|
|
141
|
+
"untagged_spend": total_spend - tagged_spend,
|
|
142
|
+
}
|
finops_scan/reporter.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Reporter — formats and prints the waste report to the terminal.
|
|
3
|
+
Uses Rich for colored, structured output.
|
|
4
|
+
Separated from analyzer: formatting concerns are independent
|
|
5
|
+
of business logic. Easy to add JSON or CSV output later.
|
|
6
|
+
"""
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from rich import box
|
|
15
|
+
|
|
16
|
+
from src.finops_scan.analyzer import ServiceSummary, WasteOpportunity
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class WasteReporter:
|
|
22
|
+
"""
|
|
23
|
+
Prints the finops-scan waste report to the terminal.
|
|
24
|
+
All output goes through Rich for consistent formatting.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
TREND_ICONS = {
|
|
28
|
+
"increasing": "[red]↑[/red]",
|
|
29
|
+
"decreasing": "[green]↓[/green]",
|
|
30
|
+
"stable": "[yellow]→[/yellow]",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
def print_header(self, account_id: str) -> None:
|
|
34
|
+
"""Prints the fynC branded header."""
|
|
35
|
+
console.print()
|
|
36
|
+
console.print(Panel(
|
|
37
|
+
Text.from_markup(
|
|
38
|
+
"[bold #E8460A]finops-scan[/bold #E8460A] "
|
|
39
|
+
"[dim]by fynC[/dim]\n"
|
|
40
|
+
"[dim]Cloud waste scanner · "
|
|
41
|
+
"Read-only · No data stored[/dim]"
|
|
42
|
+
),
|
|
43
|
+
border_style="#E8460A",
|
|
44
|
+
padding=(0, 2),
|
|
45
|
+
))
|
|
46
|
+
console.print(
|
|
47
|
+
f"[dim]Account:[/dim] [bold]{account_id}[/bold] "
|
|
48
|
+
f"[dim]Period:[/dim] [bold]Last 30 days[/bold]"
|
|
49
|
+
)
|
|
50
|
+
console.print()
|
|
51
|
+
|
|
52
|
+
def print_summary(
|
|
53
|
+
self,
|
|
54
|
+
total_spend: Decimal,
|
|
55
|
+
estimated_savings: Decimal,
|
|
56
|
+
tag_coverage: dict,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Prints the 3-number KPI summary at the top."""
|
|
59
|
+
table = Table(box=box.SIMPLE, show_header=False, padding=(0, 3))
|
|
60
|
+
table.add_column(style="dim")
|
|
61
|
+
table.add_column(style="bold")
|
|
62
|
+
|
|
63
|
+
table.add_row(
|
|
64
|
+
"Total 30-day spend",
|
|
65
|
+
f"[bold white]${total_spend:,.2f}[/bold white]",
|
|
66
|
+
)
|
|
67
|
+
table.add_row(
|
|
68
|
+
"Estimated savings available",
|
|
69
|
+
f"[bold green]${estimated_savings:,.2f}[/bold green]",
|
|
70
|
+
)
|
|
71
|
+
table.add_row(
|
|
72
|
+
"Tag coverage",
|
|
73
|
+
f"[bold {'green' if tag_coverage['tagged_pct'] >= 80 else 'red'}]"
|
|
74
|
+
f"{tag_coverage['tagged_pct']}%[/bold]",
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
console.print(Panel(table, title="[bold]Summary[/bold]",
|
|
78
|
+
border_style="dim"))
|
|
79
|
+
|
|
80
|
+
def print_top_services(
|
|
81
|
+
self,
|
|
82
|
+
summaries: List[ServiceSummary],
|
|
83
|
+
top_n: int = 8,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Prints the top N services by spend."""
|
|
86
|
+
table = Table(
|
|
87
|
+
title="Top Services by Spend",
|
|
88
|
+
box=box.SIMPLE_HEAD,
|
|
89
|
+
show_lines=False,
|
|
90
|
+
title_style="bold",
|
|
91
|
+
)
|
|
92
|
+
table.add_column("#", style="dim", width=3)
|
|
93
|
+
table.add_column("Service", style="white", min_width=35)
|
|
94
|
+
table.add_column("30-day", style="bold", justify="right")
|
|
95
|
+
table.add_column("Daily avg", justify="right")
|
|
96
|
+
table.add_column("Trend", justify="center")
|
|
97
|
+
|
|
98
|
+
for i, s in enumerate(summaries[:top_n], 1):
|
|
99
|
+
# Shorten long AWS service names
|
|
100
|
+
name = s.service.replace(
|
|
101
|
+
"Amazon Elastic Compute Cloud - Compute", "EC2 Compute"
|
|
102
|
+
).replace(
|
|
103
|
+
"Amazon Relational Database Service", "RDS"
|
|
104
|
+
).replace(
|
|
105
|
+
"Amazon Simple Storage Service", "S3"
|
|
106
|
+
)
|
|
107
|
+
table.add_row(
|
|
108
|
+
str(i),
|
|
109
|
+
name,
|
|
110
|
+
f"${s.total_30d:,.2f}",
|
|
111
|
+
f"${s.daily_average:,.2f}",
|
|
112
|
+
self.TREND_ICONS.get(s.trend, "→"),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
console.print(table)
|
|
116
|
+
|
|
117
|
+
def print_waste_opportunities(
|
|
118
|
+
self,
|
|
119
|
+
opportunities: List[WasteOpportunity],
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Prints ranked waste opportunities with actions."""
|
|
122
|
+
console.print()
|
|
123
|
+
console.print("[bold]Waste Opportunities[/bold]")
|
|
124
|
+
console.print("[dim]Ranked by estimated impact[/dim]")
|
|
125
|
+
console.print()
|
|
126
|
+
|
|
127
|
+
if not opportunities:
|
|
128
|
+
console.print(
|
|
129
|
+
"[green]✓ No significant waste detected.[/green] "
|
|
130
|
+
"Your account looks well optimized."
|
|
131
|
+
)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
for opp in opportunities:
|
|
135
|
+
console.print(
|
|
136
|
+
f"[bold #E8460A]#{opp.rank}[/bold #E8460A] "
|
|
137
|
+
f"[bold]{opp.service}[/bold]"
|
|
138
|
+
)
|
|
139
|
+
console.print(
|
|
140
|
+
f" [dim]Finding:[/dim] {opp.finding}"
|
|
141
|
+
)
|
|
142
|
+
console.print(
|
|
143
|
+
f" [dim]Action:[/dim] [green]{opp.action}[/green]"
|
|
144
|
+
)
|
|
145
|
+
console.print()
|
|
146
|
+
|
|
147
|
+
def print_cta(self) -> None:
|
|
148
|
+
"""Prints the call to action at the end."""
|
|
149
|
+
console.print(Panel(
|
|
150
|
+
Text.from_markup(
|
|
151
|
+
"[bold]Want this monitored automatically?[/bold]\n\n"
|
|
152
|
+
"fynC tracks your cloud and AI spend continuously,\n"
|
|
153
|
+
"alerts you when costs spike, and attributes every\n"
|
|
154
|
+
"dollar to a team — without any manual work.\n\n"
|
|
155
|
+
"[bold #E8460A]→ fync.io/early-access[/bold #E8460A] "
|
|
156
|
+
"[dim](or reply to the Show HN post)[/dim]"
|
|
157
|
+
),
|
|
158
|
+
border_style="#E8460A",
|
|
159
|
+
title="[bold #E8460A]fynC[/bold #E8460A]",
|
|
160
|
+
padding=(1, 3),
|
|
161
|
+
))
|
|
162
|
+
console.print()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: finops-scan
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scan your AWS account for cloud waste in 60 seconds
|
|
5
|
+
Project-URL: Homepage, https://fync.io
|
|
6
|
+
Project-URL: Repository, https://github.com/yourusername/finops-scan
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/yourusername/finops-scan/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: aws,cloud,cost,finops,optimization
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: System Administrators
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: System :: Systems Administration
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Requires-Dist: boto3>=1.35.0
|
|
20
|
+
Requires-Dist: click>=8.1.0
|
|
21
|
+
Requires-Dist: rich>=13.0.0
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# finops-scan
|
|
25
|
+
|
|
26
|
+
**Scan your AWS account for cloud waste in 60 seconds.**
|
|
27
|
+
|
|
28
|
+
Free, open source, read-only. No data stored. No resources modified.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install finops-scan
|
|
32
|
+
finops-scan --access-key AKIA... --secret-key ...
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## What it does
|
|
38
|
+
|
|
39
|
+
finops-scan connects to your AWS Cost Explorer API (read-only) and produces a ranked waste report in your terminal:
|
|
40
|
+
|
|
41
|
+
- **Top services by spend** — with 30-day trend (increasing/decreasing/stable)
|
|
42
|
+
- **Waste opportunities** — ranked by estimated impact, with specific actions
|
|
43
|
+
- **Tag coverage score** — what % of your spend has team attribution
|
|
44
|
+
- **Estimated savings** — conservative 20% estimate of actionable waste
|
|
45
|
+
|
|
46
|
+
## Example output
|
|
47
|
+
╭─────────────────────────────────────────────╮
|
|
48
|
+
│ finops-scan by fynC │
|
|
49
|
+
│ Cloud waste scanner · Read-only │
|
|
50
|
+
╰─────────────────────────────────────────────╯
|
|
51
|
+
Account: my-aws-account Period: Last 30 days
|
|
52
|
+
Total 30-day spend $18,420.55
|
|
53
|
+
Estimated savings $3,684.11
|
|
54
|
+
Tag coverage 42%
|
|
55
|
+
Top Services by Spend
|
|
56
|
+
Service 30-day Daily avg Trend
|
|
57
|
+
1 EC2 Compute $9,240.00 $308.00 ↑
|
|
58
|
+
2 RDS $4,100.00 $136.67 →
|
|
59
|
+
3 S3 $1,820.00 $60.67 ↓
|
|
60
|
+
Waste Opportunities
|
|
61
|
+
#1 EC2 Compute
|
|
62
|
+
Finding: EC2 Compute spend increased 10%+ over the last 7 days.
|
|
63
|
+
30-day total: $9,240.00
|
|
64
|
+
Action: Review EC2 utilization. Rightsizing or stopping idle
|
|
65
|
+
instances could reduce this cost by 20-40%.
|
|
66
|
+
#2 All services
|
|
67
|
+
Finding: 58% of spend ($10,683.92) has no team tag.
|
|
68
|
+
You cannot allocate what you cannot attribute.
|
|
69
|
+
Action: Apply team tags to all resources.
|
|
70
|
+
╭─────────────────────────────────────────────╮
|
|
71
|
+
│ Want this monitored automatically? │
|
|
72
|
+
│ │
|
|
73
|
+
│ fynC tracks your cloud and AI spend │
|
|
74
|
+
│ continuously, alerts you when costs spike, │
|
|
75
|
+
│ and attributes every dollar to a team. │
|
|
76
|
+
│ │
|
|
77
|
+
│ → fync.io/early-access │
|
|
78
|
+
╰─────────────────────────────────────────────╯
|
|
79
|
+
|
|
80
|
+
## Required IAM permission
|
|
81
|
+
|
|
82
|
+
finops-scan only needs one permission:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"Version": "2012-10-17",
|
|
87
|
+
"Statement": [
|
|
88
|
+
{
|
|
89
|
+
"Effect": "Allow",
|
|
90
|
+
"Action": [
|
|
91
|
+
"ce:GetCostAndUsage"
|
|
92
|
+
],
|
|
93
|
+
"Resource": "*"
|
|
94
|
+
}
|
|
95
|
+
]
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Create a read-only IAM user with this policy and pass the credentials to finops-scan.
|
|
100
|
+
|
|
101
|
+
## Installation
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install finops-scan
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Usage
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
# Using flags
|
|
111
|
+
finops-scan \
|
|
112
|
+
--access-key YOUR_ACCESS_KEY \
|
|
113
|
+
--secret-key YOUR_SECRET_KEY \
|
|
114
|
+
--region us-east-1 \
|
|
115
|
+
--account-id my-company
|
|
116
|
+
|
|
117
|
+
# Using environment variables (recommended)
|
|
118
|
+
export AWS_ACCESS_KEY_ID=YOUR_ACCESS_KEY
|
|
119
|
+
export AWS_SECRET_ACCESS_KEY=YOUR_SECRET_KEY
|
|
120
|
+
finops-scan
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## What it does NOT do
|
|
124
|
+
|
|
125
|
+
- Does not store any data
|
|
126
|
+
- Does not modify any AWS resources
|
|
127
|
+
- Does not require CUR or S3 setup
|
|
128
|
+
- Does not need root AWS credentials
|
|
129
|
+
- Does not send data anywhere except your terminal
|
|
130
|
+
|
|
131
|
+
## Built by fynC
|
|
132
|
+
|
|
133
|
+
finops-scan is the free CLI companion to
|
|
134
|
+
[fynC](https://fync.io) — a FinOps platform for cloud
|
|
135
|
+
and AI spend visibility.
|
|
136
|
+
|
|
137
|
+
fynC monitors your spend continuously, alerts you on anomalies,
|
|
138
|
+
attributes costs to teams automatically, and tracks AI/LLM spend
|
|
139
|
+
at the model level alongside cloud infrastructure costs.
|
|
140
|
+
|
|
141
|
+
[Join the early access list →](https://fync.io/early-access)
|
|
142
|
+
|
|
143
|
+
## License
|
|
144
|
+
|
|
145
|
+
MIT
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
finops_scan/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
finops_scan/analyzer.py,sha256=_QuPtDOfIRp7H4qPAUzlDYBdhhioW3szWavXR-FPL1g,8127
|
|
3
|
+
finops_scan/cli.py,sha256=3BkI17MBg9mWO78H-wCVO_CWkRKxSgj31sLMRKE3IBc,3300
|
|
4
|
+
finops_scan/fetcher.py,sha256=hMvA5jf96VA5zS9aBGpzj5SlEKr3tzJGzSdUNDmZrxo,4371
|
|
5
|
+
finops_scan/reporter.py,sha256=orY4NiM9TXIqPzg7WEQJgmkd6reI_yo_8ZFebHSGU8I,5647
|
|
6
|
+
finops_scan-0.1.0.dist-info/METADATA,sha256=D1U0cUyLjodM0TpEDLfQvHDtgDhsV9kAyYxb0-6EaeI,4737
|
|
7
|
+
finops_scan-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
finops_scan-0.1.0.dist-info/entry_points.txt,sha256=UXcJDrrUbxpR0Sj0O2BvQ785_CxkLx48mPCUSpx5bBs,53
|
|
9
|
+
finops_scan-0.1.0.dist-info/RECORD,,
|