aws-cost-calculator-cli 2.0.1__py3-none-any.whl → 2.3.1__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.
Potentially problematic release.
This version of aws-cost-calculator-cli might be problematic. Click here for more details.
- {aws_cost_calculator_cli-2.0.1.dist-info → aws_cost_calculator_cli-2.3.1.dist-info}/METADATA +1 -1
- aws_cost_calculator_cli-2.3.1.dist-info/RECORD +16 -0
- {aws_cost_calculator_cli-2.0.1.dist-info → aws_cost_calculator_cli-2.3.1.dist-info}/WHEEL +1 -1
- cost_calculator/api_client.py +18 -21
- cost_calculator/cli.py +424 -141
- cost_calculator/dimensions.py +141 -0
- cost_calculator/executor.py +25 -12
- aws_cost_calculator_cli-2.0.1.dist-info/RECORD +0 -15
- {aws_cost_calculator_cli-2.0.1.dist-info → aws_cost_calculator_cli-2.3.1.dist-info}/entry_points.txt +0 -0
- {aws_cost_calculator_cli-2.0.1.dist-info → aws_cost_calculator_cli-2.3.1.dist-info}/licenses/LICENSE +0 -0
- {aws_cost_calculator_cli-2.0.1.dist-info → aws_cost_calculator_cli-2.3.1.dist-info}/top_level.txt +0 -0
{aws_cost_calculator_cli-2.0.1.dist-info → aws_cost_calculator_cli-2.3.1.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aws-cost-calculator-cli
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.1
|
|
4
4
|
Summary: AWS Cost Calculator CLI - Calculate daily and annual AWS costs across multiple accounts
|
|
5
5
|
Home-page: https://github.com/trilogy-group/aws-cost-calculator
|
|
6
6
|
Author: Cost Optimization Team
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
aws_cost_calculator_cli-2.3.1.dist-info/licenses/LICENSE,sha256=cYtmQZHNGGTXOtg3T7LHDRneleaH0dHXHfxFV3WR50Y,1079
|
|
2
|
+
cost_calculator/__init__.py,sha256=PJeIqvWh5AYJVrJxPPkI4pJnAt37rIjasrNS0I87kaM,52
|
|
3
|
+
cost_calculator/api_client.py,sha256=UN_dKo8OYWVN905xZAn8pLlHMBSFR_EJWCUq21K8IKg,2204
|
|
4
|
+
cost_calculator/cli.py,sha256=z8_eKrBrg2JsLn7oA8DkEI1vKkMVNvPiIDn8HEHINsw,87635
|
|
5
|
+
cost_calculator/cur.py,sha256=QaZ_nyDSw5_cti-h5Ho6eYLbqzY5TWoub24DpyzIiSs,9502
|
|
6
|
+
cost_calculator/dimensions.py,sha256=cTpdYm-w88IgqlcB4ObhUsvGREBMrMSZx0d2PyehI6M,4478
|
|
7
|
+
cost_calculator/drill.py,sha256=hGi-prLgZDvNMMICQc4fl3LenM7YaZ3To_Ei4LKwrdc,10543
|
|
8
|
+
cost_calculator/executor.py,sha256=vwp4SoZfTobUeTKPujRzZzkO7tjOG8_LEGxgtraOZAQ,9348
|
|
9
|
+
cost_calculator/forensics.py,sha256=uhRo3I_zOeMEaBENHfgq65URga31W0Z4vzS2UN6VmTY,12819
|
|
10
|
+
cost_calculator/monthly.py,sha256=6k9F8S7djhX1wGV3-T1MZP7CvWbbfhSTEaddwCfVu5M,7932
|
|
11
|
+
cost_calculator/trends.py,sha256=k_s4ylBX50sqoiM_fwepi58HW01zz767FMJhQUPDznk,12246
|
|
12
|
+
aws_cost_calculator_cli-2.3.1.dist-info/METADATA,sha256=AxN6JVPuY3KljBGk1uBzxOHCgKkd8dT1fCnXmtTPwoc,11978
|
|
13
|
+
aws_cost_calculator_cli-2.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
14
|
+
aws_cost_calculator_cli-2.3.1.dist-info/entry_points.txt,sha256=_5Qy4EcHbYVYrdgOu1E48faMHb9fLUl5VJ3djDHuJBo,47
|
|
15
|
+
aws_cost_calculator_cli-2.3.1.dist-info/top_level.txt,sha256=PRwGPPlNqASfyhGHDjSfyl4SXeE7GF3OVTu1tY1Uqyc,16
|
|
16
|
+
aws_cost_calculator_cli-2.3.1.dist-info/RECORD,,
|
cost_calculator/api_client.py
CHANGED
|
@@ -23,14 +23,15 @@ def get_api_config():
|
|
|
23
23
|
return None
|
|
24
24
|
|
|
25
25
|
|
|
26
|
-
def call_lambda_api(endpoint, credentials, accounts, **kwargs):
|
|
26
|
+
def call_lambda_api(endpoint, credentials, accounts=None, profile=None, **kwargs):
|
|
27
27
|
"""
|
|
28
|
-
Call
|
|
28
|
+
Call unified API Gateway endpoint.
|
|
29
29
|
|
|
30
30
|
Args:
|
|
31
|
-
endpoint: API endpoint name ('trends', 'monthly', 'drill')
|
|
31
|
+
endpoint: API endpoint name ('calculate', 'trends', 'monthly', 'drill', 'query', etc.)
|
|
32
32
|
credentials: dict with AWS credentials
|
|
33
|
-
accounts: list of account IDs
|
|
33
|
+
accounts: list of account IDs (deprecated - use profile instead)
|
|
34
|
+
profile: profile name (preferred over accounts)
|
|
34
35
|
**kwargs: additional parameters for the specific endpoint
|
|
35
36
|
|
|
36
37
|
Returns:
|
|
@@ -44,26 +45,22 @@ def call_lambda_api(endpoint, credentials, accounts, **kwargs):
|
|
|
44
45
|
if not api_config:
|
|
45
46
|
raise Exception("API not configured. Set COST_API_SECRET environment variable.")
|
|
46
47
|
|
|
47
|
-
#
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
'monthly': 'https://6aueebodw6q4zdeu3aaexb6tle0fqhhr.lambda-url.us-east-1.on.aws/',
|
|
51
|
-
'drill': 'https://3ncm2gzxrsyptrhud3ua3x5lju0akvsr.lambda-url.us-east-1.on.aws/',
|
|
52
|
-
'analyze': 'https://y6npmidtxwzg62nrqzkbacfs5q0edwgs.lambda-url.us-east-1.on.aws/',
|
|
53
|
-
'profiles': 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/',
|
|
54
|
-
'forensics': 'https://gaekfzz7sc2hwn4mjyk64sieke0vadfo.lambda-url.us-east-1.on.aws/' # Will be populated after deployment
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
url = endpoint_urls.get(endpoint)
|
|
58
|
-
|
|
59
|
-
if not url:
|
|
60
|
-
raise Exception(f"Unknown endpoint: {endpoint}")
|
|
48
|
+
# Get base URL from environment or use default
|
|
49
|
+
base_url = os.environ.get('COST_CALCULATOR_API_URL', 'https://api.costcop.cloudfix.dev')
|
|
50
|
+
url = f"{base_url}/{endpoint}"
|
|
61
51
|
|
|
62
52
|
# Build request payload
|
|
63
53
|
payload = {
|
|
64
|
-
'credentials': credentials
|
|
65
|
-
'accounts': accounts
|
|
54
|
+
'credentials': credentials
|
|
66
55
|
}
|
|
56
|
+
|
|
57
|
+
# Add profile or accounts
|
|
58
|
+
if profile:
|
|
59
|
+
payload['profile'] = profile
|
|
60
|
+
elif accounts:
|
|
61
|
+
payload['accounts'] = accounts
|
|
62
|
+
|
|
63
|
+
# Add additional parameters
|
|
67
64
|
payload.update(kwargs)
|
|
68
65
|
|
|
69
66
|
# Make API call
|
|
@@ -72,7 +69,7 @@ def call_lambda_api(endpoint, credentials, accounts, **kwargs):
|
|
|
72
69
|
'Content-Type': 'application/json'
|
|
73
70
|
}
|
|
74
71
|
|
|
75
|
-
response = requests.post(url, headers=headers, json=payload, timeout=
|
|
72
|
+
response = requests.post(url, headers=headers, json=payload, timeout=900) # 15 min timeout
|
|
76
73
|
|
|
77
74
|
if response.status_code != 200:
|
|
78
75
|
raise Exception(f"API call failed: {response.status_code} - {response.text}")
|
cost_calculator/cli.py
CHANGED
|
@@ -18,6 +18,17 @@ from pathlib import Path
|
|
|
18
18
|
from cost_calculator.trends import format_trends_markdown
|
|
19
19
|
from cost_calculator.monthly import format_monthly_markdown
|
|
20
20
|
from cost_calculator.drill import format_drill_down_markdown
|
|
21
|
+
|
|
22
|
+
# API Configuration - can be overridden via environment variable
|
|
23
|
+
API_BASE_URL = os.environ.get(
|
|
24
|
+
'COST_CALCULATOR_API_URL',
|
|
25
|
+
'https://api.costcop.cloudfix.dev'
|
|
26
|
+
)
|
|
27
|
+
# Legacy profiles URL for backward compatibility
|
|
28
|
+
PROFILES_API_URL = os.environ.get(
|
|
29
|
+
'COST_CALCULATOR_PROFILES_URL',
|
|
30
|
+
f'{API_BASE_URL}/profiles'
|
|
31
|
+
)
|
|
21
32
|
from cost_calculator.executor import execute_trends, execute_monthly, execute_drill
|
|
22
33
|
|
|
23
34
|
|
|
@@ -88,6 +99,96 @@ def get_api_secret():
|
|
|
88
99
|
return None
|
|
89
100
|
|
|
90
101
|
|
|
102
|
+
def get_exclusions(profile_name=None):
|
|
103
|
+
"""Get exclusions configuration from DynamoDB API"""
|
|
104
|
+
import requests
|
|
105
|
+
|
|
106
|
+
api_secret = get_api_secret()
|
|
107
|
+
if not api_secret:
|
|
108
|
+
raise click.ClickException(
|
|
109
|
+
"No API secret configured.\n"
|
|
110
|
+
"Run: cc configure --api-secret YOUR_SECRET"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
response = requests.post(
|
|
115
|
+
PROFILES_API_URL,
|
|
116
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
117
|
+
json={'operation': 'get_exclusions', 'profile_name': profile_name},
|
|
118
|
+
timeout=10
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if response.status_code == 200:
|
|
122
|
+
return response.json().get('exclusions', {})
|
|
123
|
+
else:
|
|
124
|
+
# Return defaults if API fails
|
|
125
|
+
return {
|
|
126
|
+
'record_types': ['Tax', 'Support'],
|
|
127
|
+
'services': [],
|
|
128
|
+
'usage_types': [],
|
|
129
|
+
'line_item_types': []
|
|
130
|
+
}
|
|
131
|
+
except:
|
|
132
|
+
# Return defaults if request fails
|
|
133
|
+
return {
|
|
134
|
+
'record_types': ['Tax', 'Support'],
|
|
135
|
+
'services': [],
|
|
136
|
+
'usage_types': [],
|
|
137
|
+
'line_item_types': []
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def build_exclusion_filter(exclusions):
|
|
142
|
+
"""Build AWS Cost Explorer filter from exclusions config"""
|
|
143
|
+
filter_parts = []
|
|
144
|
+
|
|
145
|
+
# Exclude record types (Tax, Support, etc.)
|
|
146
|
+
if exclusions.get('record_types'):
|
|
147
|
+
filter_parts.append({
|
|
148
|
+
"Not": {
|
|
149
|
+
"Dimensions": {
|
|
150
|
+
"Key": "RECORD_TYPE",
|
|
151
|
+
"Values": exclusions['record_types']
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
# Exclude specific services (OCBLateFee, etc.)
|
|
157
|
+
if exclusions.get('services'):
|
|
158
|
+
filter_parts.append({
|
|
159
|
+
"Not": {
|
|
160
|
+
"Dimensions": {
|
|
161
|
+
"Key": "SERVICE",
|
|
162
|
+
"Values": exclusions['services']
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
# Exclude usage types
|
|
168
|
+
if exclusions.get('usage_types'):
|
|
169
|
+
filter_parts.append({
|
|
170
|
+
"Not": {
|
|
171
|
+
"Dimensions": {
|
|
172
|
+
"Key": "USAGE_TYPE",
|
|
173
|
+
"Values": exclusions['usage_types']
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
# Exclude line item types (Refund, Credit, etc.)
|
|
179
|
+
if exclusions.get('line_item_types'):
|
|
180
|
+
filter_parts.append({
|
|
181
|
+
"Not": {
|
|
182
|
+
"Dimensions": {
|
|
183
|
+
"Key": "LINE_ITEM_TYPE",
|
|
184
|
+
"Values": exclusions['line_item_types']
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
return filter_parts
|
|
190
|
+
|
|
191
|
+
|
|
91
192
|
def load_profile(profile_name):
|
|
92
193
|
"""Load profile configuration from DynamoDB API (API-only, no local files)"""
|
|
93
194
|
import requests
|
|
@@ -104,7 +205,7 @@ def load_profile(profile_name):
|
|
|
104
205
|
|
|
105
206
|
try:
|
|
106
207
|
response = requests.post(
|
|
107
|
-
|
|
208
|
+
PROFILES_API_URL,
|
|
108
209
|
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
109
210
|
json={'operation': 'get', 'profile_name': profile_name},
|
|
110
211
|
timeout=10
|
|
@@ -245,7 +346,10 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
|
|
|
245
346
|
)
|
|
246
347
|
raise
|
|
247
348
|
|
|
248
|
-
# Build filter
|
|
349
|
+
# Build filter with dynamic exclusions
|
|
350
|
+
exclusions = get_exclusions() # Get from DynamoDB
|
|
351
|
+
exclusion_filters = build_exclusion_filter(exclusions)
|
|
352
|
+
|
|
249
353
|
cost_filter = {
|
|
250
354
|
"And": [
|
|
251
355
|
{
|
|
@@ -259,18 +363,13 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
|
|
|
259
363
|
"Key": "BILLING_ENTITY",
|
|
260
364
|
"Values": ["AWS"]
|
|
261
365
|
}
|
|
262
|
-
},
|
|
263
|
-
{
|
|
264
|
-
"Not": {
|
|
265
|
-
"Dimensions": {
|
|
266
|
-
"Key": "RECORD_TYPE",
|
|
267
|
-
"Values": ["Tax", "Support"]
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
366
|
}
|
|
271
367
|
]
|
|
272
368
|
}
|
|
273
369
|
|
|
370
|
+
# Add dynamic exclusion filters
|
|
371
|
+
cost_filter["And"].extend(exclusion_filters)
|
|
372
|
+
|
|
274
373
|
# Get daily costs
|
|
275
374
|
click.echo("Fetching cost data...")
|
|
276
375
|
try:
|
|
@@ -575,145 +674,61 @@ def calculate(profile, start_date, offset, window, json_output, sso, access_key_
|
|
|
575
674
|
click.echo("=" * 60)
|
|
576
675
|
|
|
577
676
|
|
|
578
|
-
|
|
579
|
-
@click.option('--profile', required=True, help='Profile name to create')
|
|
580
|
-
@click.option('--aws-profile', required=True, help='AWS CLI profile name')
|
|
581
|
-
@click.option('--accounts', required=True, help='Comma-separated list of account IDs')
|
|
582
|
-
def init(profile, aws_profile, accounts):
|
|
583
|
-
"""Initialize a new profile configuration"""
|
|
584
|
-
|
|
585
|
-
config_dir = Path.home() / '.config' / 'cost-calculator'
|
|
586
|
-
config_file = config_dir / 'profiles.json'
|
|
587
|
-
|
|
588
|
-
# Create config directory if it doesn't exist
|
|
589
|
-
config_dir.mkdir(parents=True, exist_ok=True)
|
|
590
|
-
|
|
591
|
-
# Load existing profiles or create new
|
|
592
|
-
if config_file.exists() and config_file.stat().st_size > 0:
|
|
593
|
-
try:
|
|
594
|
-
with open(config_file) as f:
|
|
595
|
-
profiles = json.load(f)
|
|
596
|
-
except json.JSONDecodeError:
|
|
597
|
-
profiles = {}
|
|
598
|
-
else:
|
|
599
|
-
profiles = {}
|
|
600
|
-
|
|
601
|
-
# Parse accounts
|
|
602
|
-
account_list = [acc.strip() for acc in accounts.split(',')]
|
|
603
|
-
|
|
604
|
-
# Add new profile
|
|
605
|
-
profiles[profile] = {
|
|
606
|
-
'aws_profile': aws_profile,
|
|
607
|
-
'accounts': account_list
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
# Save
|
|
611
|
-
with open(config_file, 'w') as f:
|
|
612
|
-
json.dump(profiles, f, indent=2)
|
|
613
|
-
|
|
614
|
-
click.echo(f"✓ Profile '{profile}' created with {len(account_list)} accounts")
|
|
615
|
-
click.echo(f"✓ Configuration saved to {config_file}")
|
|
616
|
-
click.echo(f"\nUsage: cc calculate --profile {profile}")
|
|
677
|
+
# init command removed - use backend API via 'cc profile create' instead
|
|
617
678
|
|
|
618
679
|
|
|
619
680
|
@cli.command()
|
|
620
681
|
def list_profiles():
|
|
621
|
-
"""List all
|
|
622
|
-
|
|
623
|
-
config_file = Path.home() / '.config' / 'cost-calculator' / 'profiles.json'
|
|
624
|
-
|
|
625
|
-
if not config_file.exists():
|
|
626
|
-
click.echo("No profiles configured. Run: cc init --profile <name>")
|
|
627
|
-
return
|
|
628
|
-
|
|
629
|
-
with open(config_file) as f:
|
|
630
|
-
profiles = json.load(f)
|
|
682
|
+
"""List all profiles from backend API (no local caching)"""
|
|
683
|
+
import requests
|
|
631
684
|
|
|
632
|
-
|
|
633
|
-
|
|
685
|
+
api_secret = get_api_secret()
|
|
686
|
+
if not api_secret:
|
|
687
|
+
click.echo("No API secret configured.")
|
|
688
|
+
click.echo("Run: cc configure --api-secret YOUR_SECRET")
|
|
634
689
|
return
|
|
635
690
|
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
691
|
+
try:
|
|
692
|
+
response = requests.post(
|
|
693
|
+
f"{API_BASE_URL}/profiles",
|
|
694
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
695
|
+
json={'operation': 'list'},
|
|
696
|
+
timeout=10
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
if response.status_code != 200:
|
|
700
|
+
click.echo(f"Error: {response.status_code} - {response.text}")
|
|
701
|
+
return
|
|
702
|
+
|
|
703
|
+
result = response.json()
|
|
704
|
+
profiles = result.get('profiles', [])
|
|
705
|
+
|
|
706
|
+
if not profiles:
|
|
707
|
+
click.echo("No profiles found in backend.")
|
|
708
|
+
click.echo("Contact admin to create profiles in DynamoDB.")
|
|
709
|
+
return
|
|
710
|
+
|
|
711
|
+
click.echo("Profiles (from backend API):")
|
|
645
712
|
click.echo("")
|
|
713
|
+
for profile in profiles:
|
|
714
|
+
# Each profile is a dict, extract the profile_name
|
|
715
|
+
if isinstance(profile, dict):
|
|
716
|
+
name = profile.get('profile_name', 'unknown')
|
|
717
|
+
# Skip exclusions entries
|
|
718
|
+
if not name.startswith('exclusions:'):
|
|
719
|
+
accounts = profile.get('accounts', [])
|
|
720
|
+
click.echo(f" {name}")
|
|
721
|
+
click.echo(f" Accounts: {len(accounts)}")
|
|
722
|
+
click.echo("")
|
|
723
|
+
else:
|
|
724
|
+
click.echo(f" {profile}")
|
|
725
|
+
click.echo(f"Total: {len([p for p in profiles if isinstance(p, dict) and not p.get('profile_name', '').startswith('exclusions:')])} profile(s)")
|
|
726
|
+
|
|
727
|
+
except Exception as e:
|
|
728
|
+
click.echo(f"Error loading profiles: {e}")
|
|
646
729
|
|
|
647
730
|
|
|
648
|
-
|
|
649
|
-
def setup():
|
|
650
|
-
"""Show setup instructions for manual profile configuration"""
|
|
651
|
-
import platform
|
|
652
|
-
|
|
653
|
-
system = platform.system()
|
|
654
|
-
|
|
655
|
-
if system == "Windows":
|
|
656
|
-
config_path = "%USERPROFILE%\\.config\\cost-calculator\\profiles.json"
|
|
657
|
-
config_path_example = "C:\\Users\\YourName\\.config\\cost-calculator\\profiles.json"
|
|
658
|
-
mkdir_cmd = "mkdir %USERPROFILE%\\.config\\cost-calculator"
|
|
659
|
-
edit_cmd = "notepad %USERPROFILE%\\.config\\cost-calculator\\profiles.json"
|
|
660
|
-
else: # macOS/Linux
|
|
661
|
-
config_path = "~/.config/cost-calculator/profiles.json"
|
|
662
|
-
config_path_example = "/Users/yourname/.config/cost-calculator/profiles.json"
|
|
663
|
-
mkdir_cmd = "mkdir -p ~/.config/cost-calculator"
|
|
664
|
-
edit_cmd = "nano ~/.config/cost-calculator/profiles.json"
|
|
665
|
-
|
|
666
|
-
click.echo("=" * 70)
|
|
667
|
-
click.echo("AWS Cost Calculator - Manual Profile Setup")
|
|
668
|
-
click.echo("=" * 70)
|
|
669
|
-
click.echo("")
|
|
670
|
-
click.echo(f"Platform: {system}")
|
|
671
|
-
click.echo(f"Config location: {config_path}")
|
|
672
|
-
click.echo("")
|
|
673
|
-
click.echo("Step 1: Create the config directory")
|
|
674
|
-
click.echo(f" {mkdir_cmd}")
|
|
675
|
-
click.echo("")
|
|
676
|
-
click.echo("Step 2: Create the profiles.json file")
|
|
677
|
-
click.echo(f" {edit_cmd}")
|
|
678
|
-
click.echo("")
|
|
679
|
-
click.echo("Step 3: Add your profile configuration (JSON format):")
|
|
680
|
-
click.echo("")
|
|
681
|
-
click.echo(' {')
|
|
682
|
-
click.echo(' "myprofile": {')
|
|
683
|
-
click.echo(' "aws_profile": "my_aws_profile",')
|
|
684
|
-
click.echo(' "accounts": [')
|
|
685
|
-
click.echo(' "123456789012",')
|
|
686
|
-
click.echo(' "234567890123",')
|
|
687
|
-
click.echo(' "345678901234"')
|
|
688
|
-
click.echo(' ]')
|
|
689
|
-
click.echo(' }')
|
|
690
|
-
click.echo(' }')
|
|
691
|
-
click.echo("")
|
|
692
|
-
click.echo("Step 4: Save the file")
|
|
693
|
-
click.echo("")
|
|
694
|
-
click.echo("Step 5: Verify it works")
|
|
695
|
-
click.echo(" cc list-profiles")
|
|
696
|
-
click.echo("")
|
|
697
|
-
click.echo("Step 6: Configure AWS credentials")
|
|
698
|
-
click.echo(" Option A (SSO):")
|
|
699
|
-
click.echo(" aws sso login --profile my_aws_profile")
|
|
700
|
-
click.echo(" cc calculate --profile myprofile")
|
|
701
|
-
click.echo("")
|
|
702
|
-
click.echo(" Option B (Static credentials):")
|
|
703
|
-
click.echo(" cc configure --profile myprofile")
|
|
704
|
-
click.echo(" cc calculate --profile myprofile")
|
|
705
|
-
click.echo("")
|
|
706
|
-
click.echo("=" * 70)
|
|
707
|
-
click.echo("")
|
|
708
|
-
click.echo("For multiple profiles, add more entries to the JSON:")
|
|
709
|
-
click.echo("")
|
|
710
|
-
click.echo(' {')
|
|
711
|
-
click.echo(' "profile1": { ... },')
|
|
712
|
-
click.echo(' "profile2": { ... }')
|
|
713
|
-
click.echo(' }')
|
|
714
|
-
click.echo("")
|
|
715
|
-
click.echo(f"Full path example: {config_path_example}")
|
|
716
|
-
click.echo("=" * 70)
|
|
731
|
+
# setup command removed - profiles are managed in DynamoDB backend only
|
|
717
732
|
|
|
718
733
|
|
|
719
734
|
@cli.command()
|
|
@@ -939,6 +954,10 @@ def monthly(profile, months, output, json_output, sso, access_key_id, secret_acc
|
|
|
939
954
|
@click.option('--service', help='Filter by service name (e.g., "EC2 - Other")')
|
|
940
955
|
@click.option('--account', help='Filter by account ID')
|
|
941
956
|
@click.option('--usage-type', help='Filter by usage type')
|
|
957
|
+
@click.option('--dimension', type=click.Choice(['service', 'account', 'region', 'usage_type', 'resource', 'instance_type', 'operation', 'availability_zone']),
|
|
958
|
+
help='Dimension to analyze by (overrides --service/--account filters)')
|
|
959
|
+
@click.option('--backend', type=click.Choice(['auto', 'ce', 'athena']), default='auto',
|
|
960
|
+
help='Data source: auto (smart selection), ce (Cost Explorer), athena (CUR). Default: auto')
|
|
942
961
|
@click.option('--resources', is_flag=True, help='Show individual resource IDs (requires CUR, uses Athena)')
|
|
943
962
|
@click.option('--output', default='drill_down.md', help='Output markdown file (default: drill_down.md)')
|
|
944
963
|
@click.option('--json-output', is_flag=True, help='Output as JSON')
|
|
@@ -946,19 +965,49 @@ def monthly(profile, months, output, json_output, sso, access_key_id, secret_acc
|
|
|
946
965
|
@click.option('--access-key-id', help='AWS Access Key ID')
|
|
947
966
|
@click.option('--secret-access-key', help='AWS Secret Access Key')
|
|
948
967
|
@click.option('--session-token', help='AWS Session Token')
|
|
949
|
-
def drill(profile, weeks, service, account, usage_type, resources, output, json_output, sso, access_key_id, secret_access_key, session_token):
|
|
968
|
+
def drill(profile, weeks, service, account, usage_type, dimension, backend, resources, output, json_output, sso, access_key_id, secret_access_key, session_token):
|
|
950
969
|
"""
|
|
951
970
|
Drill down into cost changes by service, account, or usage type
|
|
952
971
|
|
|
953
972
|
Add --resources flag to see individual resource IDs and costs (requires CUR data via Athena)
|
|
973
|
+
|
|
974
|
+
Examples:
|
|
975
|
+
# Analyze by service (auto-selects Cost Explorer for speed)
|
|
976
|
+
cc drill --profile khoros --dimension service
|
|
977
|
+
|
|
978
|
+
# Analyze by region with specific service filter
|
|
979
|
+
cc drill --profile khoros --dimension region --service AWSELB
|
|
980
|
+
|
|
981
|
+
# Analyze by resource (auto-selects Athena - only source with resource IDs)
|
|
982
|
+
cc drill --profile khoros --dimension resource --service AWSELB --account 820054669588
|
|
983
|
+
|
|
984
|
+
# Force Athena backend for detailed analysis
|
|
985
|
+
cc drill --profile khoros --dimension service --backend athena
|
|
954
986
|
"""
|
|
955
987
|
|
|
956
988
|
# Load profile
|
|
957
989
|
config = load_profile(profile)
|
|
958
990
|
config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
|
|
959
991
|
|
|
992
|
+
# Smart backend selection
|
|
993
|
+
def select_backend(dimension, resources_flag, backend_choice):
|
|
994
|
+
"""Auto-select backend based on query requirements"""
|
|
995
|
+
if backend_choice != 'auto':
|
|
996
|
+
return backend_choice
|
|
997
|
+
|
|
998
|
+
# Must use Athena if:
|
|
999
|
+
if dimension == 'resource' or resources_flag:
|
|
1000
|
+
return 'athena'
|
|
1001
|
+
|
|
1002
|
+
# Prefer CE for speed (unless explicitly requesting Athena)
|
|
1003
|
+
return 'ce'
|
|
1004
|
+
|
|
1005
|
+
selected_backend = select_backend(dimension, resources, backend)
|
|
1006
|
+
|
|
960
1007
|
# Show filters
|
|
961
1008
|
click.echo(f"Analyzing last {weeks} weeks...")
|
|
1009
|
+
if dimension:
|
|
1010
|
+
click.echo(f" Dimension: {dimension}")
|
|
962
1011
|
if service:
|
|
963
1012
|
click.echo(f" Service filter: {service}")
|
|
964
1013
|
if account:
|
|
@@ -967,10 +1016,11 @@ def drill(profile, weeks, service, account, usage_type, resources, output, json_
|
|
|
967
1016
|
click.echo(f" Usage type filter: {usage_type}")
|
|
968
1017
|
if resources:
|
|
969
1018
|
click.echo(f" Mode: Resource-level (CUR via Athena)")
|
|
1019
|
+
click.echo(f" Backend: {selected_backend.upper()}")
|
|
970
1020
|
click.echo("")
|
|
971
1021
|
|
|
972
1022
|
# Execute via API or locally
|
|
973
|
-
drill_data = execute_drill(config, weeks, service, account, usage_type, resources)
|
|
1023
|
+
drill_data = execute_drill(config, weeks, service, account, usage_type, resources, dimension, selected_backend)
|
|
974
1024
|
|
|
975
1025
|
# Handle resource-level output differently
|
|
976
1026
|
if resources:
|
|
@@ -1997,5 +2047,238 @@ def query(profile, query, database, output_bucket, sso):
|
|
|
1997
2047
|
raise click.ClickException(f"Query failed: {e}")
|
|
1998
2048
|
|
|
1999
2049
|
|
|
2050
|
+
@cli.group()
|
|
2051
|
+
def exclusions():
|
|
2052
|
+
"""
|
|
2053
|
+
Manage cost exclusions (services/types to exclude from calculations).
|
|
2054
|
+
|
|
2055
|
+
Exclusions are stored in DynamoDB and apply globally or per-profile.
|
|
2056
|
+
Common exclusions: Tax, Support, OCBLateFee, Refunds, Credits
|
|
2057
|
+
"""
|
|
2058
|
+
pass
|
|
2059
|
+
|
|
2060
|
+
|
|
2061
|
+
@exclusions.command('show')
|
|
2062
|
+
@click.option('--profile', help='Show profile-specific exclusions (default: global)')
|
|
2063
|
+
@click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
|
|
2064
|
+
def show_exclusions(profile, output_json):
|
|
2065
|
+
"""Show current exclusions configuration"""
|
|
2066
|
+
import requests
|
|
2067
|
+
|
|
2068
|
+
api_secret = get_api_secret()
|
|
2069
|
+
if not api_secret:
|
|
2070
|
+
raise click.ClickException(
|
|
2071
|
+
"No API secret configured.\n"
|
|
2072
|
+
"Run: cc configure --api-secret YOUR_SECRET"
|
|
2073
|
+
)
|
|
2074
|
+
|
|
2075
|
+
try:
|
|
2076
|
+
response = requests.post(
|
|
2077
|
+
PROFILES_API_URL,
|
|
2078
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2079
|
+
json={'operation': 'get_exclusions', 'profile_name': profile},
|
|
2080
|
+
timeout=10
|
|
2081
|
+
)
|
|
2082
|
+
|
|
2083
|
+
if response.status_code == 200:
|
|
2084
|
+
exclusions_data = response.json().get('exclusions', {})
|
|
2085
|
+
|
|
2086
|
+
if output_json:
|
|
2087
|
+
click.echo(json.dumps(exclusions_data, indent=2))
|
|
2088
|
+
else:
|
|
2089
|
+
scope = profile if profile else "global"
|
|
2090
|
+
click.echo(f"Exclusions ({scope}):")
|
|
2091
|
+
click.echo("")
|
|
2092
|
+
|
|
2093
|
+
if exclusions_data.get('record_types'):
|
|
2094
|
+
click.echo("Record Types:")
|
|
2095
|
+
for rt in exclusions_data['record_types']:
|
|
2096
|
+
click.echo(f" - {rt}")
|
|
2097
|
+
click.echo("")
|
|
2098
|
+
|
|
2099
|
+
if exclusions_data.get('services'):
|
|
2100
|
+
click.echo("Services:")
|
|
2101
|
+
for svc in exclusions_data['services']:
|
|
2102
|
+
click.echo(f" - {svc}")
|
|
2103
|
+
click.echo("")
|
|
2104
|
+
|
|
2105
|
+
if exclusions_data.get('line_item_types'):
|
|
2106
|
+
click.echo("Line Item Types:")
|
|
2107
|
+
for lit in exclusions_data['line_item_types']:
|
|
2108
|
+
click.echo(f" - {lit}")
|
|
2109
|
+
click.echo("")
|
|
2110
|
+
|
|
2111
|
+
if exclusions_data.get('usage_types'):
|
|
2112
|
+
click.echo("Usage Types:")
|
|
2113
|
+
for ut in exclusions_data['usage_types']:
|
|
2114
|
+
click.echo(f" - {ut}")
|
|
2115
|
+
else:
|
|
2116
|
+
raise click.ClickException(f"Failed to fetch exclusions: {response.status_code}")
|
|
2117
|
+
|
|
2118
|
+
except requests.exceptions.RequestException as e:
|
|
2119
|
+
raise click.ClickException(f"API request failed: {e}")
|
|
2120
|
+
|
|
2121
|
+
|
|
2122
|
+
@exclusions.command('add')
|
|
2123
|
+
@click.option('--record-type', help='Add record type exclusion (e.g., Tax, Support)')
|
|
2124
|
+
@click.option('--service', help='Add service exclusion (e.g., OCBLateFee)')
|
|
2125
|
+
@click.option('--line-item-type', help='Add line item type exclusion (e.g., Refund, Credit)')
|
|
2126
|
+
@click.option('--usage-type', help='Add usage type exclusion')
|
|
2127
|
+
@click.option('--profile', help='Add to profile-specific exclusions (default: global)')
|
|
2128
|
+
def add_exclusion(record_type, service, line_item_type, usage_type, profile):
|
|
2129
|
+
"""Add an exclusion"""
|
|
2130
|
+
import requests
|
|
2131
|
+
|
|
2132
|
+
if not any([record_type, service, line_item_type, usage_type]):
|
|
2133
|
+
raise click.ClickException(
|
|
2134
|
+
"Must specify at least one of: --record-type, --service, --line-item-type, --usage-type"
|
|
2135
|
+
)
|
|
2136
|
+
|
|
2137
|
+
api_secret = get_api_secret()
|
|
2138
|
+
if not api_secret:
|
|
2139
|
+
raise click.ClickException(
|
|
2140
|
+
"No API secret configured.\n"
|
|
2141
|
+
"Run: cc configure --api-secret YOUR_SECRET"
|
|
2142
|
+
)
|
|
2143
|
+
|
|
2144
|
+
# Get current exclusions
|
|
2145
|
+
try:
|
|
2146
|
+
response = requests.post(
|
|
2147
|
+
PROFILES_API_URL,
|
|
2148
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2149
|
+
json={'operation': 'get_exclusions', 'profile_name': profile},
|
|
2150
|
+
timeout=10
|
|
2151
|
+
)
|
|
2152
|
+
|
|
2153
|
+
if response.status_code == 200:
|
|
2154
|
+
exclusions_data = response.json().get('exclusions', {})
|
|
2155
|
+
else:
|
|
2156
|
+
exclusions_data = {
|
|
2157
|
+
'record_types': [],
|
|
2158
|
+
'services': [],
|
|
2159
|
+
'line_item_types': [],
|
|
2160
|
+
'usage_types': []
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
# Add new exclusions
|
|
2164
|
+
if record_type and record_type not in exclusions_data.get('record_types', []):
|
|
2165
|
+
exclusions_data.setdefault('record_types', []).append(record_type)
|
|
2166
|
+
|
|
2167
|
+
if service and service not in exclusions_data.get('services', []):
|
|
2168
|
+
exclusions_data.setdefault('services', []).append(service)
|
|
2169
|
+
|
|
2170
|
+
if line_item_type and line_item_type not in exclusions_data.get('line_item_types', []):
|
|
2171
|
+
exclusions_data.setdefault('line_item_types', []).append(line_item_type)
|
|
2172
|
+
|
|
2173
|
+
if usage_type and usage_type not in exclusions_data.get('usage_types', []):
|
|
2174
|
+
exclusions_data.setdefault('usage_types', []).append(usage_type)
|
|
2175
|
+
|
|
2176
|
+
# Update exclusions
|
|
2177
|
+
update_response = requests.post(
|
|
2178
|
+
PROFILES_API_URL,
|
|
2179
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2180
|
+
json={
|
|
2181
|
+
'operation': 'update_exclusions',
|
|
2182
|
+
'profile_name': profile,
|
|
2183
|
+
'record_types': exclusions_data.get('record_types', []),
|
|
2184
|
+
'services': exclusions_data.get('services', []),
|
|
2185
|
+
'line_item_types': exclusions_data.get('line_item_types', []),
|
|
2186
|
+
'usage_types': exclusions_data.get('usage_types', [])
|
|
2187
|
+
},
|
|
2188
|
+
timeout=10
|
|
2189
|
+
)
|
|
2190
|
+
|
|
2191
|
+
if update_response.status_code == 200:
|
|
2192
|
+
scope = profile if profile else "global"
|
|
2193
|
+
click.echo(f"✓ Exclusion added to {scope} config")
|
|
2194
|
+
if record_type:
|
|
2195
|
+
click.echo(f" Record Type: {record_type}")
|
|
2196
|
+
if service:
|
|
2197
|
+
click.echo(f" Service: {service}")
|
|
2198
|
+
if line_item_type:
|
|
2199
|
+
click.echo(f" Line Item Type: {line_item_type}")
|
|
2200
|
+
if usage_type:
|
|
2201
|
+
click.echo(f" Usage Type: {usage_type}")
|
|
2202
|
+
else:
|
|
2203
|
+
raise click.ClickException(f"Failed to update exclusions: {update_response.status_code}")
|
|
2204
|
+
|
|
2205
|
+
except requests.exceptions.RequestException as e:
|
|
2206
|
+
raise click.ClickException(f"API request failed: {e}")
|
|
2207
|
+
|
|
2208
|
+
|
|
2209
|
+
@exclusions.command('remove')
|
|
2210
|
+
@click.option('--record-type', help='Remove record type exclusion')
|
|
2211
|
+
@click.option('--service', help='Remove service exclusion')
|
|
2212
|
+
@click.option('--line-item-type', help='Remove line item type exclusion')
|
|
2213
|
+
@click.option('--usage-type', help='Remove usage type exclusion')
|
|
2214
|
+
@click.option('--profile', help='Remove from profile-specific exclusions (default: global)')
|
|
2215
|
+
def remove_exclusion(record_type, service, line_item_type, usage_type, profile):
|
|
2216
|
+
"""Remove an exclusion"""
|
|
2217
|
+
import requests
|
|
2218
|
+
|
|
2219
|
+
if not any([record_type, service, line_item_type, usage_type]):
|
|
2220
|
+
raise click.ClickException(
|
|
2221
|
+
"Must specify at least one of: --record-type, --service, --line-item-type, --usage-type"
|
|
2222
|
+
)
|
|
2223
|
+
|
|
2224
|
+
api_secret = get_api_secret()
|
|
2225
|
+
if not api_secret:
|
|
2226
|
+
raise click.ClickException(
|
|
2227
|
+
"No API secret configured.\n"
|
|
2228
|
+
"Run: cc configure --api-secret YOUR_SECRET"
|
|
2229
|
+
)
|
|
2230
|
+
|
|
2231
|
+
# Get current exclusions
|
|
2232
|
+
try:
|
|
2233
|
+
response = requests.post(
|
|
2234
|
+
PROFILES_API_URL,
|
|
2235
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2236
|
+
json={'operation': 'get_exclusions', 'profile_name': profile},
|
|
2237
|
+
timeout=10
|
|
2238
|
+
)
|
|
2239
|
+
|
|
2240
|
+
if response.status_code == 200:
|
|
2241
|
+
exclusions_data = response.json().get('exclusions', {})
|
|
2242
|
+
else:
|
|
2243
|
+
raise click.ClickException("No exclusions found")
|
|
2244
|
+
|
|
2245
|
+
# Remove exclusions
|
|
2246
|
+
if record_type and record_type in exclusions_data.get('record_types', []):
|
|
2247
|
+
exclusions_data['record_types'].remove(record_type)
|
|
2248
|
+
|
|
2249
|
+
if service and service in exclusions_data.get('services', []):
|
|
2250
|
+
exclusions_data['services'].remove(service)
|
|
2251
|
+
|
|
2252
|
+
if line_item_type and line_item_type in exclusions_data.get('line_item_types', []):
|
|
2253
|
+
exclusions_data['line_item_types'].remove(line_item_type)
|
|
2254
|
+
|
|
2255
|
+
if usage_type and usage_type in exclusions_data.get('usage_types', []):
|
|
2256
|
+
exclusions_data['usage_types'].remove(usage_type)
|
|
2257
|
+
|
|
2258
|
+
# Update exclusions
|
|
2259
|
+
update_response = requests.post(
|
|
2260
|
+
PROFILES_API_URL,
|
|
2261
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2262
|
+
json={
|
|
2263
|
+
'operation': 'update_exclusions',
|
|
2264
|
+
'profile_name': profile,
|
|
2265
|
+
'record_types': exclusions_data.get('record_types', []),
|
|
2266
|
+
'services': exclusions_data.get('services', []),
|
|
2267
|
+
'line_item_types': exclusions_data.get('line_item_types', []),
|
|
2268
|
+
'usage_types': exclusions_data.get('usage_types', [])
|
|
2269
|
+
},
|
|
2270
|
+
timeout=10
|
|
2271
|
+
)
|
|
2272
|
+
|
|
2273
|
+
if update_response.status_code == 200:
|
|
2274
|
+
scope = profile if profile else "global"
|
|
2275
|
+
click.echo(f"✓ Exclusion removed from {scope} config")
|
|
2276
|
+
else:
|
|
2277
|
+
raise click.ClickException(f"Failed to update exclusions: {update_response.status_code}")
|
|
2278
|
+
|
|
2279
|
+
except requests.exceptions.RequestException as e:
|
|
2280
|
+
raise click.ClickException(f"API request failed: {e}")
|
|
2281
|
+
|
|
2282
|
+
|
|
2000
2283
|
if __name__ == '__main__':
|
|
2001
2284
|
cli()
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dimension mapping between CLI, Cost Explorer, and Athena CUR.
|
|
3
|
+
|
|
4
|
+
This module provides utilities for translating dimension names across different backends.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Dimension mapping: CLI dimension -> (CE dimension, Athena column)
|
|
8
|
+
DIMENSION_MAP = {
|
|
9
|
+
'service': ('SERVICE', 'line_item_product_code'),
|
|
10
|
+
'account': ('LINKED_ACCOUNT', 'line_item_usage_account_id'),
|
|
11
|
+
'region': ('REGION', 'product_region'),
|
|
12
|
+
'usage_type': ('USAGE_TYPE', 'line_item_usage_type'),
|
|
13
|
+
'resource': (None, 'line_item_resource_id'), # Athena only
|
|
14
|
+
'instance_type': ('INSTANCE_TYPE', 'product_instance_type'),
|
|
15
|
+
'operation': ('OPERATION', 'line_item_operation'),
|
|
16
|
+
'availability_zone': ('AVAILABILITY_ZONE', 'product_availability_zone'),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_ce_dimension(cli_dimension):
|
|
21
|
+
"""
|
|
22
|
+
Get Cost Explorer dimension key for a CLI dimension.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
cli_dimension: CLI dimension name (e.g., 'service', 'account')
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
str: Cost Explorer dimension key (e.g., 'SERVICE', 'LINKED_ACCOUNT')
|
|
29
|
+
None: If dimension is not available in Cost Explorer
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
ValueError: If dimension is unknown
|
|
33
|
+
"""
|
|
34
|
+
if cli_dimension not in DIMENSION_MAP:
|
|
35
|
+
raise ValueError(f"Unknown dimension: {cli_dimension}")
|
|
36
|
+
|
|
37
|
+
ce_dim, _ = DIMENSION_MAP[cli_dimension]
|
|
38
|
+
return ce_dim
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_athena_column(cli_dimension):
|
|
42
|
+
"""
|
|
43
|
+
Get Athena CUR column name for a CLI dimension.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
cli_dimension: CLI dimension name (e.g., 'service', 'account')
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
str: Athena column name (e.g., 'line_item_product_code', 'line_item_usage_account_id')
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: If dimension is unknown
|
|
53
|
+
"""
|
|
54
|
+
if cli_dimension not in DIMENSION_MAP:
|
|
55
|
+
raise ValueError(f"Unknown dimension: {cli_dimension}")
|
|
56
|
+
|
|
57
|
+
_, athena_col = DIMENSION_MAP[cli_dimension]
|
|
58
|
+
return athena_col
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_athena_only(cli_dimension):
|
|
62
|
+
"""
|
|
63
|
+
Check if a dimension is only available in Athena (not in Cost Explorer).
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
cli_dimension: CLI dimension name
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
bool: True if dimension requires Athena, False otherwise
|
|
70
|
+
"""
|
|
71
|
+
if cli_dimension not in DIMENSION_MAP:
|
|
72
|
+
raise ValueError(f"Unknown dimension: {cli_dimension}")
|
|
73
|
+
|
|
74
|
+
ce_dim, _ = DIMENSION_MAP[cli_dimension]
|
|
75
|
+
return ce_dim is None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_available_dimensions(backend='auto'):
|
|
79
|
+
"""
|
|
80
|
+
Get list of available dimensions for a given backend.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
backend: 'auto', 'ce', or 'athena'
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
list: List of available dimension names
|
|
87
|
+
"""
|
|
88
|
+
if backend == 'athena':
|
|
89
|
+
# All dimensions available in Athena
|
|
90
|
+
return list(DIMENSION_MAP.keys())
|
|
91
|
+
elif backend == 'ce':
|
|
92
|
+
# Only dimensions with CE mapping
|
|
93
|
+
return [dim for dim, (ce_dim, _) in DIMENSION_MAP.items() if ce_dim is not None]
|
|
94
|
+
else: # auto
|
|
95
|
+
# All dimensions (backend will be auto-selected)
|
|
96
|
+
return list(DIMENSION_MAP.keys())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def validate_dimension_backend(dimension, backend):
|
|
100
|
+
"""
|
|
101
|
+
Validate that a dimension is available for the specified backend.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
dimension: CLI dimension name
|
|
105
|
+
backend: 'auto', 'ce', or 'athena'
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
tuple: (is_valid, error_message)
|
|
109
|
+
|
|
110
|
+
Examples:
|
|
111
|
+
>>> validate_dimension_backend('resource', 'ce')
|
|
112
|
+
(False, "Dimension 'resource' requires Athena backend (not available in Cost Explorer)")
|
|
113
|
+
|
|
114
|
+
>>> validate_dimension_backend('service', 'ce')
|
|
115
|
+
(True, None)
|
|
116
|
+
"""
|
|
117
|
+
if dimension not in DIMENSION_MAP:
|
|
118
|
+
return False, f"Unknown dimension: {dimension}"
|
|
119
|
+
|
|
120
|
+
if backend == 'ce' and is_athena_only(dimension):
|
|
121
|
+
return False, f"Dimension '{dimension}' requires Athena backend (not available in Cost Explorer)"
|
|
122
|
+
|
|
123
|
+
return True, None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Human-readable dimension descriptions
|
|
127
|
+
DIMENSION_DESCRIPTIONS = {
|
|
128
|
+
'service': 'AWS Service (e.g., EC2, S3, RDS)',
|
|
129
|
+
'account': 'AWS Account ID',
|
|
130
|
+
'region': 'AWS Region (e.g., us-east-1, eu-west-1)',
|
|
131
|
+
'usage_type': 'Usage Type (e.g., BoxUsage:t3.micro)',
|
|
132
|
+
'resource': 'Resource ID/ARN (Athena only)',
|
|
133
|
+
'instance_type': 'Instance Type (e.g., t3.micro, m5.large)',
|
|
134
|
+
'operation': 'Operation (e.g., RunInstances, CreateBucket)',
|
|
135
|
+
'availability_zone': 'Availability Zone (e.g., us-east-1a)',
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_dimension_description(dimension):
|
|
140
|
+
"""Get human-readable description for a dimension."""
|
|
141
|
+
return DIMENSION_DESCRIPTIONS.get(dimension, dimension)
|
cost_calculator/executor.py
CHANGED
|
@@ -92,12 +92,16 @@ def get_credentials_dict(config):
|
|
|
92
92
|
|
|
93
93
|
def execute_trends(config, weeks):
|
|
94
94
|
"""
|
|
95
|
-
Execute trends analysis via API
|
|
95
|
+
Execute trends analysis via API.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
config: Profile configuration
|
|
99
|
+
weeks: Number of weeks to analyze
|
|
96
100
|
|
|
97
101
|
Returns:
|
|
98
102
|
dict: trends data
|
|
99
103
|
"""
|
|
100
|
-
|
|
104
|
+
profile_name = config.get('profile_name', config.get('name'))
|
|
101
105
|
|
|
102
106
|
if not is_api_configured():
|
|
103
107
|
raise Exception(
|
|
@@ -110,7 +114,7 @@ def execute_trends(config, weeks):
|
|
|
110
114
|
credentials = get_credentials_dict(config)
|
|
111
115
|
if not credentials:
|
|
112
116
|
raise Exception("Failed to get AWS credentials. Check your AWS SSO session.")
|
|
113
|
-
return call_lambda_api('trends', credentials,
|
|
117
|
+
return call_lambda_api('trends', credentials, profile=profile_name, weeks=weeks)
|
|
114
118
|
|
|
115
119
|
|
|
116
120
|
def execute_monthly(config, months):
|
|
@@ -120,7 +124,7 @@ def execute_monthly(config, months):
|
|
|
120
124
|
Returns:
|
|
121
125
|
dict: monthly data
|
|
122
126
|
"""
|
|
123
|
-
|
|
127
|
+
profile_name = config.get('profile_name', config.get('name'))
|
|
124
128
|
|
|
125
129
|
if not is_api_configured():
|
|
126
130
|
raise Exception(
|
|
@@ -133,10 +137,10 @@ def execute_monthly(config, months):
|
|
|
133
137
|
credentials = get_credentials_dict(config)
|
|
134
138
|
if not credentials:
|
|
135
139
|
raise Exception("Failed to get AWS credentials. Check your AWS SSO session.")
|
|
136
|
-
return call_lambda_api('monthly', credentials,
|
|
140
|
+
return call_lambda_api('monthly', credentials, profile=profile_name, months=months)
|
|
137
141
|
|
|
138
142
|
|
|
139
|
-
def execute_drill(config, weeks, service_filter=None, account_filter=None, usage_type_filter=None, resources=False):
|
|
143
|
+
def execute_drill(config, weeks, service_filter=None, account_filter=None, usage_type_filter=None, resources=False, dimension=None, backend='auto'):
|
|
140
144
|
"""
|
|
141
145
|
Execute drill-down analysis via API.
|
|
142
146
|
|
|
@@ -147,11 +151,13 @@ def execute_drill(config, weeks, service_filter=None, account_filter=None, usage
|
|
|
147
151
|
account_filter: Optional account ID filter
|
|
148
152
|
usage_type_filter: Optional usage type filter
|
|
149
153
|
resources: If True, query CUR for resource-level details
|
|
154
|
+
dimension: Dimension to analyze by (service, account, region, usage_type, resource, etc.)
|
|
155
|
+
backend: Backend to use ('auto', 'ce', 'athena')
|
|
150
156
|
|
|
151
157
|
Returns:
|
|
152
158
|
dict: drill data or resource data
|
|
153
159
|
"""
|
|
154
|
-
|
|
160
|
+
profile_name = config.get('profile_name', config.get('name'))
|
|
155
161
|
|
|
156
162
|
if not is_api_configured():
|
|
157
163
|
raise Exception(
|
|
@@ -172,12 +178,16 @@ def execute_drill(config, weeks, service_filter=None, account_filter=None, usage
|
|
|
172
178
|
kwargs['account'] = account_filter
|
|
173
179
|
if usage_type_filter:
|
|
174
180
|
kwargs['usage_type'] = usage_type_filter
|
|
181
|
+
if dimension:
|
|
182
|
+
kwargs['dimension'] = dimension
|
|
183
|
+
if backend and backend != 'auto':
|
|
184
|
+
kwargs['backend'] = backend
|
|
175
185
|
if resources:
|
|
176
|
-
if not service_filter:
|
|
177
|
-
raise click.ClickException("--service is required when using --resources flag")
|
|
186
|
+
if not service_filter and not dimension:
|
|
187
|
+
raise click.ClickException("--service or --dimension is required when using --resources flag")
|
|
178
188
|
kwargs['resources'] = True
|
|
179
189
|
|
|
180
|
-
return call_lambda_api('drill', credentials,
|
|
190
|
+
return call_lambda_api('drill', credentials, profile=profile_name, **kwargs)
|
|
181
191
|
|
|
182
192
|
|
|
183
193
|
def execute_analyze(config, weeks, analysis_type, pattern=None, min_cost=None):
|
|
@@ -227,8 +237,11 @@ def execute_profile_operation(operation, profile_name=None, accounts=None, descr
|
|
|
227
237
|
|
|
228
238
|
api_secret = os.environ.get('COST_API_SECRET', '')
|
|
229
239
|
|
|
230
|
-
# Use profiles endpoint (
|
|
231
|
-
url =
|
|
240
|
+
# Use profiles endpoint (can be overridden via environment variable)
|
|
241
|
+
url = os.environ.get(
|
|
242
|
+
'COST_CALCULATOR_PROFILES_URL',
|
|
243
|
+
'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/'
|
|
244
|
+
)
|
|
232
245
|
|
|
233
246
|
payload = {'operation': operation}
|
|
234
247
|
if profile_name:
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
aws_cost_calculator_cli-2.0.1.dist-info/licenses/LICENSE,sha256=cYtmQZHNGGTXOtg3T7LHDRneleaH0dHXHfxFV3WR50Y,1079
|
|
2
|
-
cost_calculator/__init__.py,sha256=PJeIqvWh5AYJVrJxPPkI4pJnAt37rIjasrNS0I87kaM,52
|
|
3
|
-
cost_calculator/api_client.py,sha256=4ZI2XcGIN3FBeQqb7xOxQ91kCoeM43-rExiOELXoKBQ,2485
|
|
4
|
-
cost_calculator/cli.py,sha256=7j_jK2PKOchF9oereH6GtDtTjDnlhiTaVuS5FFdeL6U,76371
|
|
5
|
-
cost_calculator/cur.py,sha256=QaZ_nyDSw5_cti-h5Ho6eYLbqzY5TWoub24DpyzIiSs,9502
|
|
6
|
-
cost_calculator/drill.py,sha256=hGi-prLgZDvNMMICQc4fl3LenM7YaZ3To_Ei4LKwrdc,10543
|
|
7
|
-
cost_calculator/executor.py,sha256=yZTCUgJc1OpB892O3mq9ZA0Yekc7N-HvaW8xLFyrXjo,8681
|
|
8
|
-
cost_calculator/forensics.py,sha256=uhRo3I_zOeMEaBENHfgq65URga31W0Z4vzS2UN6VmTY,12819
|
|
9
|
-
cost_calculator/monthly.py,sha256=6k9F8S7djhX1wGV3-T1MZP7CvWbbfhSTEaddwCfVu5M,7932
|
|
10
|
-
cost_calculator/trends.py,sha256=k_s4ylBX50sqoiM_fwepi58HW01zz767FMJhQUPDznk,12246
|
|
11
|
-
aws_cost_calculator_cli-2.0.1.dist-info/METADATA,sha256=vC_DLqKCR82e1D9uideemfOE6AihjB57fR4wS1F-WWc,11978
|
|
12
|
-
aws_cost_calculator_cli-2.0.1.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
|
|
13
|
-
aws_cost_calculator_cli-2.0.1.dist-info/entry_points.txt,sha256=_5Qy4EcHbYVYrdgOu1E48faMHb9fLUl5VJ3djDHuJBo,47
|
|
14
|
-
aws_cost_calculator_cli-2.0.1.dist-info/top_level.txt,sha256=PRwGPPlNqASfyhGHDjSfyl4SXeE7GF3OVTu1tY1Uqyc,16
|
|
15
|
-
aws_cost_calculator_cli-2.0.1.dist-info/RECORD,,
|
{aws_cost_calculator_cli-2.0.1.dist-info → aws_cost_calculator_cli-2.3.1.dist-info}/entry_points.txt
RENAMED
|
File without changes
|
{aws_cost_calculator_cli-2.0.1.dist-info → aws_cost_calculator_cli-2.3.1.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|
{aws_cost_calculator_cli-2.0.1.dist-info → aws_cost_calculator_cli-2.3.1.dist-info}/top_level.txt
RENAMED
|
File without changes
|