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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-cost-calculator-cli
3
- Version: 2.0.1
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.7.1)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -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 Lambda API endpoint.
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
- # Map endpoint names to Lambda URLs
48
- endpoint_urls = {
49
- 'trends': 'https://pq3mqntc6vuwi4zw5flulsoleq0yiqtl.lambda-url.us-east-1.on.aws/',
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=300)
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
- 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/',
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
- @cli.command()
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 configured profiles"""
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
- if not profiles:
633
- click.echo("No profiles configured.")
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
- click.echo("Configured profiles:")
637
- click.echo("")
638
- for name, config in profiles.items():
639
- click.echo(f" {name}")
640
- if 'aws_profile' in config:
641
- click.echo(f" AWS Profile: {config['aws_profile']} (SSO)")
642
- else:
643
- click.echo(f" AWS Credentials: Configured (Static)")
644
- click.echo(f" Accounts: {len(config['accounts'])}")
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
- @cli.command()
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)
@@ -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 or locally.
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
- accounts = config['accounts']
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, accounts, weeks=weeks)
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
- accounts = config['accounts']
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, accounts, months=months)
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
- accounts = config['accounts']
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, accounts, **kwargs)
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 (hardcoded URL)
231
- url = 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/'
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,,