aws-cost-calculator-cli 1.11.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.

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
 
@@ -67,127 +78,197 @@ def apply_auth_options(config, sso=None, access_key_id=None, secret_access_key=N
67
78
  return config
68
79
 
69
80
 
70
- def load_profile(profile_name):
71
- """Load profile configuration from DynamoDB API or local file as fallback"""
81
+ def get_api_secret():
82
+ """Get API secret from config file or environment variable"""
72
83
  import os
73
- import requests
74
-
75
- config_dir = Path.home() / '.config' / 'cost-calculator'
76
- config_file = config_dir / 'profiles.json'
77
- creds_file = config_dir / 'credentials.json'
78
84
 
79
- # Try DynamoDB API first if COST_API_SECRET is set
85
+ # Check environment variable first
80
86
  api_secret = os.environ.get('COST_API_SECRET')
81
87
  if api_secret:
82
- try:
83
- response = requests.post(
84
- 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/',
85
- headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
86
- json={'operation': 'get', 'profile_name': profile_name},
87
- timeout=10
88
- )
89
-
90
- if response.status_code == 200:
91
- response_data = response.json()
92
- # API returns {"profile": {...}} wrapper
93
- profile_data = response_data.get('profile', response_data)
94
- profile = {'accounts': profile_data['accounts']}
95
-
96
- # If profile has aws_profile field, use it
97
- if 'aws_profile' in profile_data:
98
- profile['aws_profile'] = profile_data['aws_profile']
99
- # Check for AWS_PROFILE environment variable (SSO support)
100
- elif os.environ.get('AWS_PROFILE'):
101
- profile['aws_profile'] = os.environ['AWS_PROFILE']
102
- # Use environment credentials
103
- elif os.environ.get('AWS_ACCESS_KEY_ID'):
104
- profile['credentials'] = {
105
- 'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
106
- 'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
107
- 'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
108
- }
109
- else:
110
- # Try to find a matching AWS profile by name
111
- # This allows "khoros" profile to work with "khoros_umbrella" AWS profile
112
- import subprocess
113
- try:
114
- result = subprocess.run(
115
- ['aws', 'configure', 'list-profiles'],
116
- capture_output=True,
117
- text=True,
118
- timeout=5
119
- )
120
- if result.returncode == 0:
121
- available_profiles = result.stdout.strip().split('\n')
122
- # Try exact match first
123
- if profile_name in available_profiles:
124
- profile['aws_profile'] = profile_name
125
- # Try with common suffixes
126
- elif f"{profile_name}_umbrella" in available_profiles:
127
- profile['aws_profile'] = f"{profile_name}_umbrella"
128
- elif f"{profile_name}-umbrella" in available_profiles:
129
- profile['aws_profile'] = f"{profile_name}-umbrella"
130
- elif f"{profile_name}_prod" in available_profiles:
131
- profile['aws_profile'] = f"{profile_name}_prod"
132
- # If no match found, leave it unset - user must provide --sso
133
- except:
134
- # If we can't list profiles, leave it unset - user must provide --sso
135
- pass
136
-
137
- return profile
138
- else:
139
- raise click.ClickException(
140
- f"Profile '{profile_name}' not found in DynamoDB.\n"
141
- f"Run: cc profile create --name {profile_name} --accounts \"...\""
142
- )
143
- except requests.exceptions.RequestException as e:
144
- raise click.ClickException(
145
- f"Failed to fetch profile from API: {e}\n"
146
- )
88
+ return api_secret
89
+
90
+ # Check config file
91
+ config_dir = Path.home() / '.config' / 'cost-calculator'
92
+ config_file = config_dir / 'config.json'
147
93
 
148
- # Fallback to local file if no API secret
149
94
  if config_file.exists():
150
95
  with open(config_file) as f:
151
- profiles = json.load(f)
96
+ config = json.load(f)
97
+ return config.get('api_secret')
98
+
99
+ return None
100
+
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
+ )
152
120
 
153
- if profile_name in profiles:
154
- profile = profiles[profile_name]
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
+
192
+ def load_profile(profile_name):
193
+ """Load profile configuration from DynamoDB API (API-only, no local files)"""
194
+ import requests
195
+
196
+ # Get API secret
197
+ api_secret = get_api_secret()
198
+
199
+ if not api_secret:
200
+ raise click.ClickException(
201
+ "No API secret configured.\n"
202
+ "Run: cc configure --api-secret YOUR_SECRET\n"
203
+ "Or set environment variable: export COST_API_SECRET=YOUR_SECRET"
204
+ )
205
+
206
+ try:
207
+ response = requests.post(
208
+ PROFILES_API_URL,
209
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
210
+ json={'operation': 'get', 'profile_name': profile_name},
211
+ timeout=10
212
+ )
213
+
214
+ if response.status_code == 200:
215
+ response_data = response.json()
216
+ # API returns {"profile": {...}} wrapper
217
+ profile_data = response_data.get('profile', response_data)
218
+ profile = {'accounts': profile_data['accounts']}
155
219
 
156
- # Load credentials if using static credentials (not SSO)
157
- if 'aws_profile' not in profile:
158
- if not creds_file.exists():
159
- # Try environment variables
160
- if os.environ.get('AWS_ACCESS_KEY_ID'):
161
- profile['credentials'] = {
162
- 'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
163
- 'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
164
- 'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
165
- }
166
- return profile
167
-
168
- raise click.ClickException(
169
- f"No credentials found for profile '{profile_name}'.\n"
170
- f"Run: cc configure --profile {profile_name}"
171
- )
172
-
173
- with open(creds_file) as f:
174
- creds = json.load(f)
175
-
176
- if profile_name not in creds:
177
- raise click.ClickException(
178
- f"No credentials found for profile '{profile_name}'.\n"
179
- f"Run: cc configure --profile {profile_name}"
220
+ # If profile has aws_profile field, use it
221
+ if 'aws_profile' in profile_data:
222
+ profile['aws_profile'] = profile_data['aws_profile']
223
+ # Check for AWS_PROFILE environment variable (SSO support)
224
+ elif os.environ.get('AWS_PROFILE'):
225
+ profile['aws_profile'] = os.environ['AWS_PROFILE']
226
+ # Use environment credentials
227
+ elif os.environ.get('AWS_ACCESS_KEY_ID'):
228
+ profile['credentials'] = {
229
+ 'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
230
+ 'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
231
+ 'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
232
+ }
233
+ else:
234
+ # Try to find a matching AWS profile by name
235
+ # This allows "khoros" profile to work with "khoros_umbrella" AWS profile
236
+ import subprocess
237
+ try:
238
+ result = subprocess.run(
239
+ ['aws', 'configure', 'list-profiles'],
240
+ capture_output=True,
241
+ text=True,
242
+ timeout=5
180
243
  )
181
-
182
- profile['credentials'] = creds[profile_name]
244
+ if result.returncode == 0:
245
+ available_profiles = result.stdout.strip().split('\n')
246
+ # Try exact match first
247
+ if profile_name in available_profiles:
248
+ profile['aws_profile'] = profile_name
249
+ # Try with common suffixes
250
+ elif f"{profile_name}_umbrella" in available_profiles:
251
+ profile['aws_profile'] = f"{profile_name}_umbrella"
252
+ elif f"{profile_name}-umbrella" in available_profiles:
253
+ profile['aws_profile'] = f"{profile_name}-umbrella"
254
+ elif f"{profile_name}_prod" in available_profiles:
255
+ profile['aws_profile'] = f"{profile_name}_prod"
256
+ # If no match found, leave it unset - user must provide --sso
257
+ except:
258
+ # If we can't list profiles, leave it unset - user must provide --sso
259
+ pass
183
260
 
184
261
  return profile
185
-
186
- # Profile not found anywhere
187
- raise click.ClickException(
188
- f"Profile '{profile_name}' not found.\n"
189
- f"Run: cc profile create --name {profile_name} --accounts \"...\""
190
- )
262
+ else:
263
+ raise click.ClickException(
264
+ f"Profile '{profile_name}' not found in DynamoDB.\n"
265
+ f"Run: cc profile create --name {profile_name} --accounts \"...\""
266
+ )
267
+ except requests.exceptions.RequestException as e:
268
+ raise click.ClickException(
269
+ f"Failed to fetch profile from API: {e}\n"
270
+ "Check your API secret and network connection."
271
+ )
191
272
 
192
273
 
193
274
  def calculate_costs(profile_config, accounts, start_date, offset, window):
@@ -265,7 +346,10 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
265
346
  )
266
347
  raise
267
348
 
268
- # Build filter
349
+ # Build filter with dynamic exclusions
350
+ exclusions = get_exclusions() # Get from DynamoDB
351
+ exclusion_filters = build_exclusion_filter(exclusions)
352
+
269
353
  cost_filter = {
270
354
  "And": [
271
355
  {
@@ -279,18 +363,13 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
279
363
  "Key": "BILLING_ENTITY",
280
364
  "Values": ["AWS"]
281
365
  }
282
- },
283
- {
284
- "Not": {
285
- "Dimensions": {
286
- "Key": "RECORD_TYPE",
287
- "Values": ["Tax", "Support"]
288
- }
289
- }
290
366
  }
291
367
  ]
292
368
  }
293
369
 
370
+ # Add dynamic exclusion filters
371
+ cost_filter["And"].extend(exclusion_filters)
372
+
294
373
  # Get daily costs
295
374
  click.echo("Fetching cost data...")
296
375
  try:
@@ -595,219 +674,139 @@ def calculate(profile, start_date, offset, window, json_output, sso, access_key_
595
674
  click.echo("=" * 60)
596
675
 
597
676
 
598
- @cli.command()
599
- @click.option('--profile', required=True, help='Profile name to create')
600
- @click.option('--aws-profile', required=True, help='AWS CLI profile name')
601
- @click.option('--accounts', required=True, help='Comma-separated list of account IDs')
602
- def init(profile, aws_profile, accounts):
603
- """Initialize a new profile configuration"""
604
-
605
- config_dir = Path.home() / '.config' / 'cost-calculator'
606
- config_file = config_dir / 'profiles.json'
607
-
608
- # Create config directory if it doesn't exist
609
- config_dir.mkdir(parents=True, exist_ok=True)
610
-
611
- # Load existing profiles or create new
612
- if config_file.exists() and config_file.stat().st_size > 0:
613
- try:
614
- with open(config_file) as f:
615
- profiles = json.load(f)
616
- except json.JSONDecodeError:
617
- profiles = {}
618
- else:
619
- profiles = {}
620
-
621
- # Parse accounts
622
- account_list = [acc.strip() for acc in accounts.split(',')]
623
-
624
- # Add new profile
625
- profiles[profile] = {
626
- 'aws_profile': aws_profile,
627
- 'accounts': account_list
628
- }
629
-
630
- # Save
631
- with open(config_file, 'w') as f:
632
- json.dump(profiles, f, indent=2)
633
-
634
- click.echo(f"✓ Profile '{profile}' created with {len(account_list)} accounts")
635
- click.echo(f"✓ Configuration saved to {config_file}")
636
- click.echo(f"\nUsage: cc calculate --profile {profile}")
677
+ # init command removed - use backend API via 'cc profile create' instead
637
678
 
638
679
 
639
680
  @cli.command()
640
681
  def list_profiles():
641
- """List all configured profiles"""
642
-
643
- config_file = Path.home() / '.config' / 'cost-calculator' / 'profiles.json'
644
-
645
- if not config_file.exists():
646
- click.echo("No profiles configured. Run: cc init --profile <name>")
647
- return
648
-
649
- with open(config_file) as f:
650
- profiles = json.load(f)
682
+ """List all profiles from backend API (no local caching)"""
683
+ import requests
651
684
 
652
- if not profiles:
653
- 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")
654
689
  return
655
690
 
656
- click.echo("Configured profiles:")
657
- click.echo("")
658
- for name, config in profiles.items():
659
- click.echo(f" {name}")
660
- if 'aws_profile' in config:
661
- click.echo(f" AWS Profile: {config['aws_profile']} (SSO)")
662
- else:
663
- click.echo(f" AWS Credentials: Configured (Static)")
664
- 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):")
665
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}")
666
729
 
667
730
 
668
- @cli.command()
669
- def setup():
670
- """Show setup instructions for manual profile configuration"""
671
- import platform
672
-
673
- system = platform.system()
674
-
675
- if system == "Windows":
676
- config_path = "%USERPROFILE%\\.config\\cost-calculator\\profiles.json"
677
- config_path_example = "C:\\Users\\YourName\\.config\\cost-calculator\\profiles.json"
678
- mkdir_cmd = "mkdir %USERPROFILE%\\.config\\cost-calculator"
679
- edit_cmd = "notepad %USERPROFILE%\\.config\\cost-calculator\\profiles.json"
680
- else: # macOS/Linux
681
- config_path = "~/.config/cost-calculator/profiles.json"
682
- config_path_example = "/Users/yourname/.config/cost-calculator/profiles.json"
683
- mkdir_cmd = "mkdir -p ~/.config/cost-calculator"
684
- edit_cmd = "nano ~/.config/cost-calculator/profiles.json"
685
-
686
- click.echo("=" * 70)
687
- click.echo("AWS Cost Calculator - Manual Profile Setup")
688
- click.echo("=" * 70)
689
- click.echo("")
690
- click.echo(f"Platform: {system}")
691
- click.echo(f"Config location: {config_path}")
692
- click.echo("")
693
- click.echo("Step 1: Create the config directory")
694
- click.echo(f" {mkdir_cmd}")
695
- click.echo("")
696
- click.echo("Step 2: Create the profiles.json file")
697
- click.echo(f" {edit_cmd}")
698
- click.echo("")
699
- click.echo("Step 3: Add your profile configuration (JSON format):")
700
- click.echo("")
701
- click.echo(' {')
702
- click.echo(' "myprofile": {')
703
- click.echo(' "aws_profile": "my_aws_profile",')
704
- click.echo(' "accounts": [')
705
- click.echo(' "123456789012",')
706
- click.echo(' "234567890123",')
707
- click.echo(' "345678901234"')
708
- click.echo(' ]')
709
- click.echo(' }')
710
- click.echo(' }')
711
- click.echo("")
712
- click.echo("Step 4: Save the file")
713
- click.echo("")
714
- click.echo("Step 5: Verify it works")
715
- click.echo(" cc list-profiles")
716
- click.echo("")
717
- click.echo("Step 6: Configure AWS credentials")
718
- click.echo(" Option A (SSO):")
719
- click.echo(" aws sso login --profile my_aws_profile")
720
- click.echo(" cc calculate --profile myprofile")
721
- click.echo("")
722
- click.echo(" Option B (Static credentials):")
723
- click.echo(" cc configure --profile myprofile")
724
- click.echo(" cc calculate --profile myprofile")
725
- click.echo("")
726
- click.echo("=" * 70)
727
- click.echo("")
728
- click.echo("For multiple profiles, add more entries to the JSON:")
729
- click.echo("")
730
- click.echo(' {')
731
- click.echo(' "profile1": { ... },')
732
- click.echo(' "profile2": { ... }')
733
- click.echo(' }')
734
- click.echo("")
735
- click.echo(f"Full path example: {config_path_example}")
736
- click.echo("=" * 70)
731
+ # setup command removed - profiles are managed in DynamoDB backend only
737
732
 
738
733
 
739
734
  @cli.command()
740
- @click.option('--profile', required=True, help='Profile name to configure')
741
- @click.option('--access-key-id', prompt=True, hide_input=False, help='AWS Access Key ID')
742
- @click.option('--secret-access-key', prompt=True, hide_input=True, help='AWS Secret Access Key')
743
- @click.option('--session-token', default='', help='AWS Session Token (optional, for temporary credentials)')
744
- @click.option('--region', default='us-east-1', help='AWS Region (default: us-east-1)')
745
- def configure(profile, access_key_id, secret_access_key, session_token, region):
746
- """Configure AWS credentials for a profile (alternative to SSO)"""
735
+ @click.option('--api-secret', help='API secret for DynamoDB profile access')
736
+ @click.option('--show', is_flag=True, help='Show current configuration')
737
+ def configure(api_secret, show):
738
+ """
739
+ Configure Cost Calculator CLI settings.
747
740
 
748
- config_dir = Path.home() / '.config' / 'cost-calculator'
749
- config_file = config_dir / 'profiles.json'
750
- creds_file = config_dir / 'credentials.json'
741
+ This tool requires an API secret to access profiles stored in DynamoDB.
742
+ The secret can be configured here or set via COST_API_SECRET environment variable.
743
+
744
+ Examples:
745
+ # Configure API secret
746
+ cc configure --api-secret YOUR_SECRET_KEY
747
+
748
+ # Show current configuration
749
+ cc configure --show
750
+
751
+ # Use environment variable instead (no configuration needed)
752
+ export COST_API_SECRET=YOUR_SECRET_KEY
753
+ """
754
+ import os
751
755
 
752
- # Create config directory if it doesn't exist
756
+ config_dir = Path.home() / '.config' / 'cost-calculator'
753
757
  config_dir.mkdir(parents=True, exist_ok=True)
758
+ config_file = config_dir / 'config.json'
754
759
 
755
- # Load existing profiles
756
- if config_file.exists() and config_file.stat().st_size > 0:
757
- try:
760
+ if show:
761
+ # Show current configuration
762
+ if config_file.exists():
758
763
  with open(config_file) as f:
759
- profiles = json.load(f)
760
- except json.JSONDecodeError:
761
- profiles = {}
762
- else:
763
- profiles = {}
764
-
765
- # Check if profile exists
766
- if profile not in profiles:
767
- click.echo(f"Error: Profile '{profile}' not found. Create it first with: cc init --profile {profile}")
764
+ config = json.load(f)
765
+ if 'api_secret' in config:
766
+ masked_secret = config['api_secret'][:8] + '...' + config['api_secret'][-4:]
767
+ click.echo(f"API Secret: {masked_secret} (configured)")
768
+ else:
769
+ click.echo("API Secret: Not configured")
770
+ else:
771
+ click.echo("No configuration file found")
772
+
773
+ # Check environment variable
774
+ import os
775
+ if os.environ.get('COST_API_SECRET'):
776
+ click.echo("Environment: COST_API_SECRET is set")
777
+ else:
778
+ click.echo("Environment: COST_API_SECRET is not set")
779
+
768
780
  return
769
781
 
770
- # Remove aws_profile if it exists (switching from SSO to static creds)
771
- if 'aws_profile' in profiles[profile]:
772
- del profiles[profile]['aws_profile']
773
-
774
- # Save updated profile
775
- with open(config_file, 'w') as f:
776
- json.dump(profiles, f, indent=2)
777
-
778
- # Load or create credentials file
779
- if creds_file.exists() and creds_file.stat().st_size > 0:
780
- try:
781
- with open(creds_file) as f:
782
- creds = json.load(f)
783
- except json.JSONDecodeError:
784
- creds = {}
785
- else:
786
- creds = {}
782
+ if not api_secret:
783
+ raise click.ClickException(
784
+ "Please provide --api-secret or use --show to view current configuration\n"
785
+ "Example: cc configure --api-secret YOUR_SECRET_KEY"
786
+ )
787
787
 
788
- # Store credentials (encrypted would be better, but for now just file permissions)
789
- creds[profile] = {
790
- 'aws_access_key_id': access_key_id,
791
- 'aws_secret_access_key': secret_access_key,
792
- 'region': region
793
- }
788
+ # Load existing config
789
+ config = {}
790
+ if config_file.exists():
791
+ with open(config_file) as f:
792
+ config = json.load(f)
794
793
 
795
- if session_token:
796
- creds[profile]['aws_session_token'] = session_token
794
+ # Update API secret
795
+ config['api_secret'] = api_secret
797
796
 
798
- # Save credentials with restricted permissions
799
- with open(creds_file, 'w') as f:
800
- json.dump(creds, f, indent=2)
797
+ # Save config
798
+ with open(config_file, 'w') as f:
799
+ json.dump(config, f, indent=2)
801
800
 
802
- # Set file permissions to 600 (owner read/write only)
803
- creds_file.chmod(0o600)
801
+ # Set restrictive permissions (Unix/Mac only - Windows uses different permission model)
802
+ import platform
803
+ if platform.system() != 'Windows':
804
+ os.chmod(config_file, 0o600)
804
805
 
805
- click.echo(f"✓ AWS credentials configured for profile '{profile}'")
806
- click.echo(f"✓ Credentials saved to {creds_file} (permissions: 600)")
807
- click.echo(f"\nUsage: cc calculate --profile {profile}")
808
- click.echo("\nNote: Credentials are stored locally. For temporary credentials,")
809
- click.echo(" you'll need to reconfigure when they expire.")
810
-
806
+ masked_secret = api_secret[:8] + '...' + api_secret[-4:]
807
+ click.echo(f"✓ API secret configured: {masked_secret}")
808
+ click.echo(f"\nYou can now run: cc calculate --profile PROFILE_NAME")
809
+ click.echo(f"\nNote: Profiles are stored in DynamoDB and accessed via the API.")
811
810
 
812
811
  @cli.command()
813
812
  @click.option('--profile', required=True, help='Profile name')
@@ -955,6 +954,10 @@ def monthly(profile, months, output, json_output, sso, access_key_id, secret_acc
955
954
  @click.option('--service', help='Filter by service name (e.g., "EC2 - Other")')
956
955
  @click.option('--account', help='Filter by account ID')
957
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')
958
961
  @click.option('--resources', is_flag=True, help='Show individual resource IDs (requires CUR, uses Athena)')
959
962
  @click.option('--output', default='drill_down.md', help='Output markdown file (default: drill_down.md)')
960
963
  @click.option('--json-output', is_flag=True, help='Output as JSON')
@@ -962,19 +965,49 @@ def monthly(profile, months, output, json_output, sso, access_key_id, secret_acc
962
965
  @click.option('--access-key-id', help='AWS Access Key ID')
963
966
  @click.option('--secret-access-key', help='AWS Secret Access Key')
964
967
  @click.option('--session-token', help='AWS Session Token')
965
- 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):
966
969
  """
967
970
  Drill down into cost changes by service, account, or usage type
968
971
 
969
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
970
986
  """
971
987
 
972
988
  # Load profile
973
989
  config = load_profile(profile)
974
990
  config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
975
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
+
976
1007
  # Show filters
977
1008
  click.echo(f"Analyzing last {weeks} weeks...")
1009
+ if dimension:
1010
+ click.echo(f" Dimension: {dimension}")
978
1011
  if service:
979
1012
  click.echo(f" Service filter: {service}")
980
1013
  if account:
@@ -983,10 +1016,11 @@ def drill(profile, weeks, service, account, usage_type, resources, output, json_
983
1016
  click.echo(f" Usage type filter: {usage_type}")
984
1017
  if resources:
985
1018
  click.echo(f" Mode: Resource-level (CUR via Athena)")
1019
+ click.echo(f" Backend: {selected_backend.upper()}")
986
1020
  click.echo("")
987
1021
 
988
1022
  # Execute via API or locally
989
- 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)
990
1024
 
991
1025
  # Handle resource-level output differently
992
1026
  if resources:
@@ -2013,5 +2047,238 @@ def query(profile, query, database, output_bucket, sso):
2013
2047
  raise click.ClickException(f"Query failed: {e}")
2014
2048
 
2015
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
+
2016
2283
  if __name__ == '__main__':
2017
2284
  cli()