aws-cost-calculator-cli 1.5.2__py3-none-any.whl → 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cost_calculator/cli.py CHANGED
@@ -11,57 +11,267 @@ Usage:
11
11
  import click
12
12
  import boto3
13
13
  import json
14
+ import os
15
+ import platform
14
16
  from datetime import datetime, timedelta
15
17
  from pathlib import Path
16
18
  from cost_calculator.trends import format_trends_markdown
17
19
  from cost_calculator.monthly import format_monthly_markdown
18
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
+ )
19
32
  from cost_calculator.executor import execute_trends, execute_monthly, execute_drill
20
33
 
21
34
 
22
- def load_profile(profile_name):
23
- """Load profile configuration from ~/.config/cost-calculator/profiles.json"""
35
+ def apply_auth_options(config, sso=None, access_key_id=None, secret_access_key=None, session_token=None):
36
+ """Apply authentication options to profile config
37
+
38
+ Args:
39
+ config: Profile configuration dict
40
+ sso: AWS SSO profile name
41
+ access_key_id: AWS Access Key ID
42
+ secret_access_key: AWS Secret Access Key
43
+ session_token: AWS Session Token
44
+
45
+ Returns:
46
+ Updated config dict
47
+ """
48
+ import subprocess
49
+
50
+ if sso:
51
+ # SSO authentication - trigger login if needed
52
+ try:
53
+ # Test if SSO session is valid
54
+ result = subprocess.run(
55
+ ['aws', 'sts', 'get-caller-identity', '--profile', sso],
56
+ capture_output=True,
57
+ text=True,
58
+ timeout=5
59
+ )
60
+ if result.returncode != 0:
61
+ if 'expired' in result.stderr.lower() or 'token' in result.stderr.lower():
62
+ click.echo(f"SSO session expired or not initialized. Logging in...")
63
+ subprocess.run(['aws', 'sso', 'login', '--profile', sso], check=True)
64
+ except Exception as e:
65
+ click.echo(f"Warning: Could not verify SSO session: {e}")
66
+
67
+ config['aws_profile'] = sso
68
+ elif access_key_id and secret_access_key:
69
+ # Static credentials provided via CLI
70
+ config['credentials'] = {
71
+ 'aws_access_key_id': access_key_id,
72
+ 'aws_secret_access_key': secret_access_key,
73
+ 'region': 'us-east-1'
74
+ }
75
+ if session_token:
76
+ config['credentials']['aws_session_token'] = session_token
77
+
78
+ return config
79
+
80
+
81
+ def get_api_secret():
82
+ """Get API secret from config file or environment variable"""
83
+ import os
84
+
85
+ # Check environment variable first
86
+ api_secret = os.environ.get('COST_API_SECRET')
87
+ if api_secret:
88
+ return api_secret
89
+
90
+ # Check config file
24
91
  config_dir = Path.home() / '.config' / 'cost-calculator'
25
- config_file = config_dir / 'profiles.json'
26
- creds_file = config_dir / 'credentials.json'
92
+ config_file = config_dir / 'config.json'
93
+
94
+ if config_file.exists():
95
+ with open(config_file) as 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
27
105
 
28
- if not config_file.exists():
106
+ api_secret = get_api_secret()
107
+ if not api_secret:
29
108
  raise click.ClickException(
30
- f"Profile configuration not found at {config_file}\n"
31
- f"Run: cc init --profile {profile_name}"
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
32
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
+
192
+ def load_profile(profile_name):
193
+ """Load profile configuration from DynamoDB API (API-only, no local files)"""
194
+ import requests
33
195
 
34
- with open(config_file) as f:
35
- profiles = json.load(f)
196
+ # Get API secret
197
+ api_secret = get_api_secret()
36
198
 
37
- if profile_name not in profiles:
199
+ if not api_secret:
38
200
  raise click.ClickException(
39
- f"Profile '{profile_name}' not found in {config_file}\n"
40
- f"Available profiles: {', '.join(profiles.keys())}"
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"
41
204
  )
42
205
 
43
- profile = profiles[profile_name]
44
-
45
- # Load credentials if using static credentials (not SSO)
46
- if 'aws_profile' not in profile:
47
- if not creds_file.exists():
48
- raise click.ClickException(
49
- f"No credentials found for profile '{profile_name}'.\n"
50
- f"Run: cc configure --profile {profile_name}"
51
- )
52
-
53
- with open(creds_file) as f:
54
- creds = json.load(f)
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
+ )
55
213
 
56
- if profile_name not in creds:
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 = {
219
+ 'accounts': profile_data['accounts'],
220
+ 'profile_name': profile_name # Store profile name for API calls
221
+ }
222
+
223
+ # If profile has aws_profile field, use it
224
+ if 'aws_profile' in profile_data:
225
+ profile['aws_profile'] = profile_data['aws_profile']
226
+ # Check for AWS_PROFILE environment variable (SSO support)
227
+ elif os.environ.get('AWS_PROFILE'):
228
+ profile['aws_profile'] = os.environ['AWS_PROFILE']
229
+ # Use environment credentials
230
+ elif os.environ.get('AWS_ACCESS_KEY_ID'):
231
+ profile['credentials'] = {
232
+ 'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
233
+ 'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
234
+ 'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
235
+ }
236
+ else:
237
+ # Try to find a matching AWS profile by name
238
+ # This allows "khoros" profile to work with "khoros_umbrella" AWS profile
239
+ import subprocess
240
+ try:
241
+ result = subprocess.run(
242
+ ['aws', 'configure', 'list-profiles'],
243
+ capture_output=True,
244
+ text=True,
245
+ timeout=5
246
+ )
247
+ if result.returncode == 0:
248
+ available_profiles = result.stdout.strip().split('\n')
249
+ # Try exact match first
250
+ if profile_name in available_profiles:
251
+ profile['aws_profile'] = profile_name
252
+ # Try with common suffixes
253
+ elif f"{profile_name}_umbrella" in available_profiles:
254
+ profile['aws_profile'] = f"{profile_name}_umbrella"
255
+ elif f"{profile_name}-umbrella" in available_profiles:
256
+ profile['aws_profile'] = f"{profile_name}-umbrella"
257
+ elif f"{profile_name}_prod" in available_profiles:
258
+ profile['aws_profile'] = f"{profile_name}_prod"
259
+ # If no match found, leave it unset - user must provide --sso
260
+ except:
261
+ # If we can't list profiles, leave it unset - user must provide --sso
262
+ pass
263
+
264
+ return profile
265
+ else:
57
266
  raise click.ClickException(
58
- f"No credentials found for profile '{profile_name}'.\n"
59
- f"Run: cc configure --profile {profile_name}"
267
+ f"Profile '{profile_name}' not found in DynamoDB.\n"
268
+ f"Run: cc profile create --name {profile_name} --accounts \"...\""
60
269
  )
61
-
62
- profile['credentials'] = creds[profile_name]
63
-
64
- return profile
270
+ except requests.exceptions.RequestException as e:
271
+ raise click.ClickException(
272
+ f"Failed to fetch profile from API: {e}\n"
273
+ "Check your API secret and network connection."
274
+ )
65
275
 
66
276
 
67
277
  def calculate_costs(profile_config, accounts, start_date, offset, window):
@@ -139,7 +349,10 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
139
349
  )
140
350
  raise
141
351
 
142
- # Build filter
352
+ # Build filter with dynamic exclusions
353
+ exclusions = get_exclusions() # Get from DynamoDB
354
+ exclusion_filters = build_exclusion_filter(exclusions)
355
+
143
356
  cost_filter = {
144
357
  "And": [
145
358
  {
@@ -153,18 +366,13 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
153
366
  "Key": "BILLING_ENTITY",
154
367
  "Values": ["AWS"]
155
368
  }
156
- },
157
- {
158
- "Not": {
159
- "Dimensions": {
160
- "Key": "RECORD_TYPE",
161
- "Values": ["Tax", "Support"]
162
- }
163
- }
164
369
  }
165
370
  ]
166
371
  }
167
372
 
373
+ # Add dynamic exclusion filters
374
+ cost_filter["And"].extend(exclusion_filters)
375
+
168
376
  # Get daily costs
169
377
  click.echo("Fetching cost data...")
170
378
  try:
@@ -229,9 +437,10 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
229
437
  # Calculate days in the month that the support covers
230
438
  # Support on Nov 1 covers October (31 days)
231
439
  support_month = support_month_date - timedelta(days=1) # Go back to previous month
232
- days_in_support_month = support_month.day # This gives us the last day of the month
440
+ import calendar
441
+ days_in_support_month = calendar.monthrange(support_month.year, support_month.month)[1]
233
442
 
234
- # Support allocation: divide by 2 (half to Khoros), then by days in month
443
+ # Support allocation: divide by 2 (50% allocation), then by days in month
235
444
  support_per_day = (support_cost / 2) / days_in_support_month
236
445
 
237
446
  # Calculate daily rate
@@ -290,18 +499,157 @@ def cli():
290
499
  pass
291
500
 
292
501
 
502
+ @cli.command('setup-cur')
503
+ @click.option('--database', required=True, prompt='CUR Athena Database', help='Athena database name for CUR')
504
+ @click.option('--table', required=True, prompt='CUR Table Name', help='CUR table name')
505
+ @click.option('--s3-output', required=True, prompt='S3 Output Location', help='S3 bucket for Athena query results')
506
+ def setup_cur(database, table, s3_output):
507
+ """
508
+ Configure CUR (Cost and Usage Report) settings for resource-level queries
509
+
510
+ Saves CUR configuration to ~/.config/cost-calculator/cur_config.json
511
+
512
+ Example:
513
+ cc setup-cur --database my_cur_db --table cur_table --s3-output s3://my-bucket/
514
+ """
515
+ import json
516
+
517
+ config_dir = Path.home() / '.config' / 'cost-calculator'
518
+ config_dir.mkdir(parents=True, exist_ok=True)
519
+
520
+ config_file = config_dir / 'cur_config.json'
521
+
522
+ config = {
523
+ 'database': database,
524
+ 'table': table,
525
+ 's3_output': s3_output
526
+ }
527
+
528
+ with open(config_file, 'w') as f:
529
+ json.dump(config, f, indent=2)
530
+
531
+ click.echo(f"✓ CUR configuration saved to {config_file}")
532
+ click.echo(f" Database: {database}")
533
+ click.echo(f" Table: {table}")
534
+ click.echo(f" S3 Output: {s3_output}")
535
+ click.echo("")
536
+ click.echo("You can now use: cc drill --service 'EC2 - Other' --resources")
537
+
538
+
539
+ @cli.command('setup-api')
540
+ @click.option('--api-secret', required=True, prompt=True, hide_input=True, help='COST_API_SECRET value')
541
+ def setup_api(api_secret):
542
+ """
543
+ Configure COST_API_SECRET for backend API access
544
+
545
+ Saves the API secret to the appropriate location based on your OS:
546
+ - Mac/Linux: ~/.zshrc or ~/.bashrc
547
+ - Windows: User environment variables
548
+
549
+ Example:
550
+ cc setup-api --api-secret your-secret-here
551
+
552
+ Or let it prompt you (input will be hidden):
553
+ cc setup-api
554
+ """
555
+ system = platform.system()
556
+
557
+ if system == "Windows":
558
+ # Windows: Set user environment variable
559
+ try:
560
+ import winreg
561
+ key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Environment', 0, winreg.KEY_SET_VALUE)
562
+ winreg.SetValueEx(key, 'COST_API_SECRET', 0, winreg.REG_SZ, api_secret)
563
+ winreg.CloseKey(key)
564
+ click.echo("✓ COST_API_SECRET saved to Windows user environment variables")
565
+ click.echo(" Please restart your terminal for changes to take effect")
566
+ except Exception as e:
567
+ click.echo(f"✗ Error setting Windows environment variable: {e}", err=True)
568
+ click.echo("\nManual setup:")
569
+ click.echo("1. Open System Properties > Environment Variables")
570
+ click.echo("2. Add new User variable:")
571
+ click.echo(" Name: COST_API_SECRET")
572
+ click.echo(f" Value: {api_secret}")
573
+ return
574
+ else:
575
+ # Mac/Linux: Add to shell profile
576
+ shell = os.environ.get('SHELL', '/bin/bash')
577
+
578
+ if 'zsh' in shell:
579
+ profile_file = Path.home() / '.zshrc'
580
+ else:
581
+ profile_file = Path.home() / '.bashrc'
582
+
583
+ # Check if already exists
584
+ export_line = f'export COST_API_SECRET="{api_secret}"'
585
+
586
+ try:
587
+ if profile_file.exists():
588
+ content = profile_file.read_text()
589
+ if 'COST_API_SECRET' in content:
590
+ # Replace existing
591
+ lines = content.split('\n')
592
+ new_lines = []
593
+ for line in lines:
594
+ if 'COST_API_SECRET' in line and line.strip().startswith('export'):
595
+ new_lines.append(export_line)
596
+ else:
597
+ new_lines.append(line)
598
+ profile_file.write_text('\n'.join(new_lines))
599
+ click.echo(f"✓ Updated COST_API_SECRET in {profile_file}")
600
+ else:
601
+ # Append
602
+ with profile_file.open('a') as f:
603
+ f.write(f'\n# AWS Cost Calculator API Secret\n{export_line}\n')
604
+ click.echo(f"✓ Added COST_API_SECRET to {profile_file}")
605
+ else:
606
+ # Create new file
607
+ profile_file.write_text(f'# AWS Cost Calculator API Secret\n{export_line}\n')
608
+ click.echo(f"✓ Created {profile_file} with COST_API_SECRET")
609
+
610
+ # Also set for current session
611
+ os.environ['COST_API_SECRET'] = api_secret
612
+ click.echo(f"✓ Set COST_API_SECRET for current session")
613
+ click.echo(f"\nTo use in new terminals, run: source {profile_file}")
614
+
615
+ except Exception as e:
616
+ click.echo(f"✗ Error writing to {profile_file}: {e}", err=True)
617
+ click.echo(f"\nManual setup: Add this line to {profile_file}:")
618
+ click.echo(f" {export_line}")
619
+ return
620
+
621
+
293
622
  @cli.command()
294
623
  @click.option('--profile', required=True, help='Profile name (e.g., myprofile)')
295
624
  @click.option('--start-date', help='Start date (YYYY-MM-DD, default: today)')
296
625
  @click.option('--offset', default=2, help='Days to go back from start date (default: 2)')
297
626
  @click.option('--window', default=30, help='Number of days to analyze (default: 30)')
298
627
  @click.option('--json-output', is_flag=True, help='Output as JSON')
299
- def calculate(profile, start_date, offset, window, json_output):
300
- """Calculate AWS costs for the specified period"""
628
+ @click.option('--sso', help='AWS SSO profile name (e.g., my_sso_profile)')
629
+ @click.option('--access-key-id', help='AWS Access Key ID (for static credentials)')
630
+ @click.option('--secret-access-key', help='AWS Secret Access Key (for static credentials)')
631
+ @click.option('--session-token', help='AWS Session Token (for static credentials)')
632
+ def calculate(profile, start_date, offset, window, json_output, sso, access_key_id, secret_access_key, session_token):
633
+ """
634
+ Calculate AWS costs for the specified period
635
+
636
+ \b
637
+ Authentication Options:
638
+ 1. SSO: --sso <profile_name>
639
+ Example: cc calculate --profile myprofile --sso my_sso_profile
640
+
641
+ 2. Static Credentials: --access-key-id, --secret-access-key, --session-token
642
+ Example: cc calculate --profile myprofile --access-key-id ASIA... --secret-access-key ... --session-token ...
643
+
644
+ 3. Environment Variables: AWS_PROFILE or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY
645
+ """
301
646
 
302
647
  # Load profile configuration
303
648
  config = load_profile(profile)
304
649
 
650
+ # Apply authentication options
651
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
652
+
305
653
  # Calculate costs
306
654
  result = calculate_costs(
307
655
  profile_config=config,
@@ -329,230 +677,155 @@ def calculate(profile, start_date, offset, window, json_output):
329
677
  click.echo("=" * 60)
330
678
 
331
679
 
332
- @cli.command()
333
- @click.option('--profile', required=True, help='Profile name to create')
334
- @click.option('--aws-profile', required=True, help='AWS CLI profile name')
335
- @click.option('--accounts', required=True, help='Comma-separated list of account IDs')
336
- def init(profile, aws_profile, accounts):
337
- """Initialize a new profile configuration"""
338
-
339
- config_dir = Path.home() / '.config' / 'cost-calculator'
340
- config_file = config_dir / 'profiles.json'
341
-
342
- # Create config directory if it doesn't exist
343
- config_dir.mkdir(parents=True, exist_ok=True)
344
-
345
- # Load existing profiles or create new
346
- if config_file.exists() and config_file.stat().st_size > 0:
347
- try:
348
- with open(config_file) as f:
349
- profiles = json.load(f)
350
- except json.JSONDecodeError:
351
- profiles = {}
352
- else:
353
- profiles = {}
354
-
355
- # Parse accounts
356
- account_list = [acc.strip() for acc in accounts.split(',')]
357
-
358
- # Add new profile
359
- profiles[profile] = {
360
- 'aws_profile': aws_profile,
361
- 'accounts': account_list
362
- }
363
-
364
- # Save
365
- with open(config_file, 'w') as f:
366
- json.dump(profiles, f, indent=2)
367
-
368
- click.echo(f"✓ Profile '{profile}' created with {len(account_list)} accounts")
369
- click.echo(f"✓ Configuration saved to {config_file}")
370
- click.echo(f"\nUsage: cc calculate --profile {profile}")
680
+ # init command removed - use backend API via 'cc profile create' instead
371
681
 
372
682
 
373
683
  @cli.command()
374
684
  def list_profiles():
375
- """List all configured profiles"""
376
-
377
- config_file = Path.home() / '.config' / 'cost-calculator' / 'profiles.json'
378
-
379
- if not config_file.exists():
380
- click.echo("No profiles configured. Run: cc init --profile <name>")
381
- return
382
-
383
- with open(config_file) as f:
384
- profiles = json.load(f)
685
+ """List all profiles from backend API (no local caching)"""
686
+ import requests
385
687
 
386
- if not profiles:
387
- click.echo("No profiles configured.")
688
+ api_secret = get_api_secret()
689
+ if not api_secret:
690
+ click.echo("No API secret configured.")
691
+ click.echo("Run: cc configure --api-secret YOUR_SECRET")
388
692
  return
389
693
 
390
- click.echo("Configured profiles:")
391
- click.echo("")
392
- for name, config in profiles.items():
393
- click.echo(f" {name}")
394
- if 'aws_profile' in config:
395
- click.echo(f" AWS Profile: {config['aws_profile']} (SSO)")
396
- else:
397
- click.echo(f" AWS Credentials: Configured (Static)")
398
- click.echo(f" Accounts: {len(config['accounts'])}")
694
+ try:
695
+ response = requests.post(
696
+ f"{API_BASE_URL}/profiles",
697
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
698
+ json={'operation': 'list'},
699
+ timeout=10
700
+ )
701
+
702
+ if response.status_code != 200:
703
+ click.echo(f"Error: {response.status_code} - {response.text}")
704
+ return
705
+
706
+ result = response.json()
707
+ profiles = result.get('profiles', [])
708
+
709
+ if not profiles:
710
+ click.echo("No profiles found in backend.")
711
+ click.echo("Contact admin to create profiles in DynamoDB.")
712
+ return
713
+
714
+ click.echo("Profiles (from backend API):")
399
715
  click.echo("")
716
+ for profile in profiles:
717
+ # Each profile is a dict, extract the profile_name
718
+ if isinstance(profile, dict):
719
+ name = profile.get('profile_name', 'unknown')
720
+ # Skip exclusions entries
721
+ if not name.startswith('exclusions:'):
722
+ accounts = profile.get('accounts', [])
723
+ click.echo(f" {name}")
724
+ click.echo(f" Accounts: {len(accounts)}")
725
+ click.echo("")
726
+ else:
727
+ click.echo(f" {profile}")
728
+ click.echo(f"Total: {len([p for p in profiles if isinstance(p, dict) and not p.get('profile_name', '').startswith('exclusions:')])} profile(s)")
729
+
730
+ except Exception as e:
731
+ click.echo(f"Error loading profiles: {e}")
400
732
 
401
733
 
402
- @cli.command()
403
- def setup():
404
- """Show setup instructions for manual profile configuration"""
405
- import platform
406
-
407
- system = platform.system()
408
-
409
- if system == "Windows":
410
- config_path = "%USERPROFILE%\\.config\\cost-calculator\\profiles.json"
411
- config_path_example = "C:\\Users\\YourName\\.config\\cost-calculator\\profiles.json"
412
- mkdir_cmd = "mkdir %USERPROFILE%\\.config\\cost-calculator"
413
- edit_cmd = "notepad %USERPROFILE%\\.config\\cost-calculator\\profiles.json"
414
- else: # macOS/Linux
415
- config_path = "~/.config/cost-calculator/profiles.json"
416
- config_path_example = "/Users/yourname/.config/cost-calculator/profiles.json"
417
- mkdir_cmd = "mkdir -p ~/.config/cost-calculator"
418
- edit_cmd = "nano ~/.config/cost-calculator/profiles.json"
419
-
420
- click.echo("=" * 70)
421
- click.echo("AWS Cost Calculator - Manual Profile Setup")
422
- click.echo("=" * 70)
423
- click.echo("")
424
- click.echo(f"Platform: {system}")
425
- click.echo(f"Config location: {config_path}")
426
- click.echo("")
427
- click.echo("Step 1: Create the config directory")
428
- click.echo(f" {mkdir_cmd}")
429
- click.echo("")
430
- click.echo("Step 2: Create the profiles.json file")
431
- click.echo(f" {edit_cmd}")
432
- click.echo("")
433
- click.echo("Step 3: Add your profile configuration (JSON format):")
434
- click.echo("")
435
- click.echo(' {')
436
- click.echo(' "myprofile": {')
437
- click.echo(' "aws_profile": "my_aws_profile",')
438
- click.echo(' "accounts": [')
439
- click.echo(' "123456789012",')
440
- click.echo(' "234567890123",')
441
- click.echo(' "345678901234"')
442
- click.echo(' ]')
443
- click.echo(' }')
444
- click.echo(' }')
445
- click.echo("")
446
- click.echo("Step 4: Save the file")
447
- click.echo("")
448
- click.echo("Step 5: Verify it works")
449
- click.echo(" cc list-profiles")
450
- click.echo("")
451
- click.echo("Step 6: Configure AWS credentials")
452
- click.echo(" Option A (SSO):")
453
- click.echo(" aws sso login --profile my_aws_profile")
454
- click.echo(" cc calculate --profile myprofile")
455
- click.echo("")
456
- click.echo(" Option B (Static credentials):")
457
- click.echo(" cc configure --profile myprofile")
458
- click.echo(" cc calculate --profile myprofile")
459
- click.echo("")
460
- click.echo("=" * 70)
461
- click.echo("")
462
- click.echo("For multiple profiles, add more entries to the JSON:")
463
- click.echo("")
464
- click.echo(' {')
465
- click.echo(' "profile1": { ... },')
466
- click.echo(' "profile2": { ... }')
467
- click.echo(' }')
468
- click.echo("")
469
- click.echo(f"Full path example: {config_path_example}")
470
- click.echo("=" * 70)
734
+ # setup command removed - profiles are managed in DynamoDB backend only
471
735
 
472
736
 
473
737
  @cli.command()
474
- @click.option('--profile', required=True, help='Profile name to configure')
475
- @click.option('--access-key-id', prompt=True, hide_input=False, help='AWS Access Key ID')
476
- @click.option('--secret-access-key', prompt=True, hide_input=True, help='AWS Secret Access Key')
477
- @click.option('--session-token', default='', help='AWS Session Token (optional, for temporary credentials)')
478
- @click.option('--region', default='us-east-1', help='AWS Region (default: us-east-1)')
479
- def configure(profile, access_key_id, secret_access_key, session_token, region):
480
- """Configure AWS credentials for a profile (alternative to SSO)"""
738
+ @click.option('--api-secret', help='API secret for DynamoDB profile access')
739
+ @click.option('--show', is_flag=True, help='Show current configuration')
740
+ def configure(api_secret, show):
741
+ """
742
+ Configure Cost Calculator CLI settings.
481
743
 
482
- config_dir = Path.home() / '.config' / 'cost-calculator'
483
- config_file = config_dir / 'profiles.json'
484
- creds_file = config_dir / 'credentials.json'
744
+ This tool requires an API secret to access profiles stored in DynamoDB.
745
+ The secret can be configured here or set via COST_API_SECRET environment variable.
485
746
 
486
- # Create config directory if it doesn't exist
747
+ Examples:
748
+ # Configure API secret
749
+ cc configure --api-secret YOUR_SECRET_KEY
750
+
751
+ # Show current configuration
752
+ cc configure --show
753
+
754
+ # Use environment variable instead (no configuration needed)
755
+ export COST_API_SECRET=YOUR_SECRET_KEY
756
+ """
757
+ import os
758
+
759
+ config_dir = Path.home() / '.config' / 'cost-calculator'
487
760
  config_dir.mkdir(parents=True, exist_ok=True)
761
+ config_file = config_dir / 'config.json'
488
762
 
489
- # Load existing profiles
490
- if config_file.exists() and config_file.stat().st_size > 0:
491
- try:
763
+ if show:
764
+ # Show current configuration
765
+ if config_file.exists():
492
766
  with open(config_file) as f:
493
- profiles = json.load(f)
494
- except json.JSONDecodeError:
495
- profiles = {}
496
- else:
497
- profiles = {}
498
-
499
- # Check if profile exists
500
- if profile not in profiles:
501
- click.echo(f"Error: Profile '{profile}' not found. Create it first with: cc init --profile {profile}")
767
+ config = json.load(f)
768
+ if 'api_secret' in config:
769
+ masked_secret = config['api_secret'][:8] + '...' + config['api_secret'][-4:]
770
+ click.echo(f"API Secret: {masked_secret} (configured)")
771
+ else:
772
+ click.echo("API Secret: Not configured")
773
+ else:
774
+ click.echo("No configuration file found")
775
+
776
+ # Check environment variable
777
+ import os
778
+ if os.environ.get('COST_API_SECRET'):
779
+ click.echo("Environment: COST_API_SECRET is set")
780
+ else:
781
+ click.echo("Environment: COST_API_SECRET is not set")
782
+
502
783
  return
503
784
 
504
- # Remove aws_profile if it exists (switching from SSO to static creds)
505
- if 'aws_profile' in profiles[profile]:
506
- del profiles[profile]['aws_profile']
507
-
508
- # Save updated profile
509
- with open(config_file, 'w') as f:
510
- json.dump(profiles, f, indent=2)
511
-
512
- # Load or create credentials file
513
- if creds_file.exists() and creds_file.stat().st_size > 0:
514
- try:
515
- with open(creds_file) as f:
516
- creds = json.load(f)
517
- except json.JSONDecodeError:
518
- creds = {}
519
- else:
520
- creds = {}
785
+ if not api_secret:
786
+ raise click.ClickException(
787
+ "Please provide --api-secret or use --show to view current configuration\n"
788
+ "Example: cc configure --api-secret YOUR_SECRET_KEY"
789
+ )
521
790
 
522
- # Store credentials (encrypted would be better, but for now just file permissions)
523
- creds[profile] = {
524
- 'aws_access_key_id': access_key_id,
525
- 'aws_secret_access_key': secret_access_key,
526
- 'region': region
527
- }
791
+ # Load existing config
792
+ config = {}
793
+ if config_file.exists():
794
+ with open(config_file) as f:
795
+ config = json.load(f)
528
796
 
529
- if session_token:
530
- creds[profile]['aws_session_token'] = session_token
797
+ # Update API secret
798
+ config['api_secret'] = api_secret
531
799
 
532
- # Save credentials with restricted permissions
533
- with open(creds_file, 'w') as f:
534
- json.dump(creds, f, indent=2)
800
+ # Save config
801
+ with open(config_file, 'w') as f:
802
+ json.dump(config, f, indent=2)
535
803
 
536
- # Set file permissions to 600 (owner read/write only)
537
- creds_file.chmod(0o600)
804
+ # Set restrictive permissions (Unix/Mac only - Windows uses different permission model)
805
+ import platform
806
+ if platform.system() != 'Windows':
807
+ os.chmod(config_file, 0o600)
538
808
 
539
- click.echo(f"✓ AWS credentials configured for profile '{profile}'")
540
- click.echo(f"✓ Credentials saved to {creds_file} (permissions: 600)")
541
- click.echo(f"\nUsage: cc calculate --profile {profile}")
542
- click.echo("\nNote: Credentials are stored locally. For temporary credentials,")
543
- click.echo(" you'll need to reconfigure when they expire.")
544
-
809
+ masked_secret = api_secret[:8] + '...' + api_secret[-4:]
810
+ click.echo(f"✓ API secret configured: {masked_secret}")
811
+ click.echo(f"\nYou can now run: cc calculate --profile PROFILE_NAME")
812
+ click.echo(f"\nNote: Profiles are stored in DynamoDB and accessed via the API.")
545
813
 
546
814
  @cli.command()
547
815
  @click.option('--profile', required=True, help='Profile name')
548
816
  @click.option('--weeks', default=3, help='Number of weeks to analyze (default: 3)')
549
817
  @click.option('--output', default='cost_trends.md', help='Output markdown file (default: cost_trends.md)')
550
- @click.option('--json-output', is_flag=True, help='Output as JSON instead of markdown')
551
- def trends(profile, weeks, output, json_output):
818
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
819
+ @click.option('--sso', help='AWS SSO profile name')
820
+ @click.option('--access-key-id', help='AWS Access Key ID')
821
+ @click.option('--secret-access-key', help='AWS Secret Access Key')
822
+ @click.option('--session-token', help='AWS Session Token')
823
+ def trends(profile, weeks, output, json_output, sso, access_key_id, secret_access_key, session_token):
552
824
  """Analyze cost trends with Week-over-Week and Trailing 30-Day comparisons"""
553
825
 
554
826
  # Load profile configuration
555
827
  config = load_profile(profile)
828
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
556
829
 
557
830
  click.echo(f"Analyzing last {weeks} weeks...")
558
831
  click.echo("")
@@ -613,12 +886,17 @@ def trends(profile, weeks, output, json_output):
613
886
  @click.option('--profile', required=True, help='Profile name')
614
887
  @click.option('--months', default=6, help='Number of months to analyze (default: 6)')
615
888
  @click.option('--output', default='monthly_trends.md', help='Output markdown file (default: monthly_trends.md)')
616
- @click.option('--json-output', is_flag=True, help='Output as JSON instead of markdown')
617
- def monthly(profile, months, output, json_output):
889
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
890
+ @click.option('--sso', help='AWS SSO profile name')
891
+ @click.option('--access-key-id', help='AWS Access Key ID')
892
+ @click.option('--secret-access-key', help='AWS Secret Access Key')
893
+ @click.option('--session-token', help='AWS Session Token')
894
+ def monthly(profile, months, output, json_output, sso, access_key_id, secret_access_key, session_token):
618
895
  """Analyze month-over-month cost trends at service level"""
619
896
 
620
- # Load profile configuration
897
+ # Load profile
621
898
  config = load_profile(profile)
899
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
622
900
 
623
901
  click.echo(f"Analyzing last {months} months...")
624
902
  click.echo("")
@@ -679,26 +957,80 @@ def monthly(profile, months, output, json_output):
679
957
  @click.option('--service', help='Filter by service name (e.g., "EC2 - Other")')
680
958
  @click.option('--account', help='Filter by account ID')
681
959
  @click.option('--usage-type', help='Filter by usage type')
960
+ @click.option('--dimension', type=click.Choice(['service', 'account', 'region', 'usage_type', 'resource', 'instance_type', 'operation', 'availability_zone']),
961
+ help='Dimension to analyze by (overrides --service/--account filters)')
962
+ @click.option('--backend', type=click.Choice(['auto', 'ce', 'athena']), default='auto',
963
+ help='Data source: auto (smart selection), ce (Cost Explorer), athena (CUR). Default: auto')
964
+ @click.option('--resources', is_flag=True, help='Show individual resource IDs (requires CUR, uses Athena)')
682
965
  @click.option('--output', default='drill_down.md', help='Output markdown file (default: drill_down.md)')
683
- @click.option('--json-output', is_flag=True, help='Output as JSON instead of markdown')
684
- def drill(profile, weeks, service, account, usage_type, output, json_output):
685
- """Drill down into cost changes by service, account, or usage type"""
966
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
967
+ @click.option('--sso', help='AWS SSO profile name')
968
+ @click.option('--access-key-id', help='AWS Access Key ID')
969
+ @click.option('--secret-access-key', help='AWS Secret Access Key')
970
+ @click.option('--session-token', help='AWS Session Token')
971
+ def drill(profile, weeks, service, account, usage_type, dimension, backend, resources, output, json_output, sso, access_key_id, secret_access_key, session_token):
972
+ """
973
+ Drill down into cost changes by service, account, or usage type
686
974
 
687
- # Load profile configuration
975
+ Add --resources flag to see individual resource IDs and costs (requires CUR data via Athena)
976
+
977
+ Examples:
978
+ # Analyze by service (auto-selects Cost Explorer for speed)
979
+ cc drill --profile khoros --dimension service
980
+
981
+ # Analyze by region with specific service filter
982
+ cc drill --profile khoros --dimension region --service AWSELB
983
+
984
+ # Analyze by resource (auto-selects Athena - only source with resource IDs)
985
+ cc drill --profile khoros --dimension resource --service AWSELB --account 820054669588
986
+
987
+ # Force Athena backend for detailed analysis
988
+ cc drill --profile khoros --dimension service --backend athena
989
+ """
990
+
991
+ # Load profile
688
992
  config = load_profile(profile)
993
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
994
+
995
+ # Smart backend selection
996
+ def select_backend(dimension, resources_flag, backend_choice):
997
+ """Auto-select backend based on query requirements"""
998
+ if backend_choice != 'auto':
999
+ return backend_choice
1000
+
1001
+ # Must use Athena if:
1002
+ if dimension == 'resource' or resources_flag:
1003
+ return 'athena'
1004
+
1005
+ # Prefer CE for speed (unless explicitly requesting Athena)
1006
+ return 'ce'
1007
+
1008
+ selected_backend = select_backend(dimension, resources, backend)
689
1009
 
690
1010
  # Show filters
691
1011
  click.echo(f"Analyzing last {weeks} weeks...")
1012
+ if dimension:
1013
+ click.echo(f" Dimension: {dimension}")
692
1014
  if service:
693
1015
  click.echo(f" Service filter: {service}")
694
1016
  if account:
695
1017
  click.echo(f" Account filter: {account}")
696
1018
  if usage_type:
697
1019
  click.echo(f" Usage type filter: {usage_type}")
1020
+ if resources:
1021
+ click.echo(f" Mode: Resource-level (CUR via Athena)")
1022
+ click.echo(f" Backend: {selected_backend.upper()}")
698
1023
  click.echo("")
699
1024
 
700
1025
  # Execute via API or locally
701
- drill_data = execute_drill(config, weeks, service, account, usage_type)
1026
+ drill_data = execute_drill(config, weeks, service, account, usage_type, resources, dimension, selected_backend)
1027
+
1028
+ # Handle resource-level output differently
1029
+ if resources:
1030
+ from cost_calculator.cur import format_resource_output
1031
+ output_text = format_resource_output(drill_data)
1032
+ click.echo(output_text)
1033
+ return
702
1034
 
703
1035
  if json_output:
704
1036
  # Output as JSON
@@ -871,5 +1203,1302 @@ def profile(operation, name, accounts, description):
871
1203
  click.echo(result.get('message', 'Operation completed'))
872
1204
 
873
1205
 
1206
+ @cli.command()
1207
+ @click.option('--profile', required=True, help='Profile name')
1208
+ @click.option('--sso', help='AWS SSO profile to use')
1209
+ @click.option('--weeks', default=8, help='Number of weeks to analyze')
1210
+ @click.option('--account', help='Focus on specific account ID')
1211
+ @click.option('--service', help='Focus on specific service')
1212
+ @click.option('--no-cloudtrail', is_flag=True, help='Skip CloudTrail analysis (faster)')
1213
+ @click.option('--output', default='investigation_report.md', help='Output file path')
1214
+ def investigate(profile, sso, weeks, account, service, no_cloudtrail, output):
1215
+ """
1216
+ Multi-stage cost investigation:
1217
+ 1. Analyze cost trends and drill-downs
1218
+ 2. Inventory actual resources in problem accounts
1219
+ 3. Analyze CloudTrail events (optional)
1220
+ 4. Generate comprehensive report
1221
+ """
1222
+ from cost_calculator.executor import execute_trends, execute_drill, get_credentials_dict
1223
+ from cost_calculator.api_client import call_lambda_api, is_api_configured
1224
+ from cost_calculator.forensics import format_investigation_report
1225
+ from datetime import datetime, timedelta
1226
+
1227
+ click.echo("=" * 80)
1228
+ click.echo("COST INVESTIGATION")
1229
+ click.echo("=" * 80)
1230
+ click.echo(f"Profile: {profile}")
1231
+ click.echo(f"Weeks: {weeks}")
1232
+ if account:
1233
+ click.echo(f"Account: {account}")
1234
+ if service:
1235
+ click.echo(f"Service: {service}")
1236
+ click.echo("")
1237
+
1238
+ # Load profile
1239
+ config = load_profile(profile)
1240
+
1241
+ # Override with SSO if provided
1242
+ if sso:
1243
+ config['aws_profile'] = sso
1244
+
1245
+ # Validate that we have a way to get credentials
1246
+ if 'aws_profile' not in config and 'credentials' not in config:
1247
+ import subprocess
1248
+ try:
1249
+ result = subprocess.run(
1250
+ ['aws', 'configure', 'list-profiles'],
1251
+ capture_output=True,
1252
+ text=True,
1253
+ timeout=5
1254
+ )
1255
+ available = result.stdout.strip().split('\n') if result.returncode == 0 else []
1256
+ suggestion = f"\nAvailable AWS profiles: {', '.join(available[:5])}" if available else ""
1257
+ except:
1258
+ suggestion = ""
1259
+
1260
+ raise click.ClickException(
1261
+ f"Profile '{profile}' has no AWS authentication configured.\n"
1262
+ f"Use --sso flag to specify your AWS SSO profile:\n"
1263
+ f" cc investigate --profile {profile} --sso YOUR_AWS_PROFILE{suggestion}"
1264
+ )
1265
+
1266
+ # Step 1: Cost Analysis
1267
+ click.echo("Step 1/3: Analyzing cost trends...")
1268
+ try:
1269
+ trends_data = execute_trends(config, weeks)
1270
+ click.echo(f"✓ Found cost data for {weeks} weeks")
1271
+ except Exception as e:
1272
+ click.echo(f"✗ Error analyzing trends: {str(e)}")
1273
+ trends_data = None
1274
+
1275
+ # Step 2: Drill-down
1276
+ click.echo("\nStep 2/3: Drilling down into costs...")
1277
+ drill_data = None
1278
+ if service or account:
1279
+ try:
1280
+ drill_data = execute_drill(config, weeks, service, account, None, False)
1281
+ click.echo(f"✓ Drill-down complete")
1282
+ except Exception as e:
1283
+ click.echo(f"✗ Error in drill-down: {str(e)}")
1284
+
1285
+ # Step 3: Resource Inventory
1286
+ click.echo("\nStep 3/3: Inventorying resources...")
1287
+ inventories = []
1288
+ cloudtrail_analyses = []
1289
+
1290
+ # Determine which accounts to investigate
1291
+ accounts_to_investigate = []
1292
+ if account:
1293
+ accounts_to_investigate = [account]
1294
+ else:
1295
+ # Extract top cost accounts from trends/drill data
1296
+ # For now, we'll need the user to specify
1297
+ click.echo("⚠️ No account specified. Use --account to inventory resources.")
1298
+
1299
+ # For each account, do inventory and CloudTrail via backend API
1300
+ for acc_id in accounts_to_investigate:
1301
+ click.echo(f"\n Investigating account {acc_id}...")
1302
+
1303
+ # Get credentials (SSO or static)
1304
+ account_creds = get_credentials_dict(config)
1305
+ if not account_creds:
1306
+ click.echo(f" ⚠️ No credentials available for account")
1307
+ continue
1308
+
1309
+ # Inventory resources via backend API only
1310
+ if not is_api_configured():
1311
+ click.echo(f" ✗ API not configured. Set COST_API_SECRET environment variable.")
1312
+ continue
1313
+
1314
+ try:
1315
+ regions = ['us-west-2', 'us-east-1', 'eu-west-1']
1316
+ for region in regions:
1317
+ try:
1318
+ inv = call_lambda_api(
1319
+ 'forensics',
1320
+ account_creds,
1321
+ [], # accounts not needed for forensics
1322
+ operation='inventory',
1323
+ account_id=acc_id,
1324
+ region=region
1325
+ )
1326
+
1327
+ if not inv.get('error'):
1328
+ inventories.append(inv)
1329
+ click.echo(f" ✓ Inventory complete for {region}")
1330
+ click.echo(f" - EC2: {len(inv['ec2_instances'])} instances")
1331
+ click.echo(f" - EFS: {len(inv['efs_file_systems'])} file systems ({inv.get('total_efs_size_gb', 0):,.0f} GB)")
1332
+ click.echo(f" - ELB: {len(inv['load_balancers'])} load balancers")
1333
+ break
1334
+ except Exception as e:
1335
+ continue
1336
+ except Exception as e:
1337
+ click.echo(f" ✗ Inventory error: {str(e)}")
1338
+
1339
+ # CloudTrail analysis via backend API only
1340
+ if not no_cloudtrail:
1341
+ if not is_api_configured():
1342
+ click.echo(f" ✗ CloudTrail skipped: API not configured")
1343
+ else:
1344
+ try:
1345
+ start_date = (datetime.now() - timedelta(days=weeks * 7)).isoformat() + 'Z'
1346
+ end_date = datetime.now().isoformat() + 'Z'
1347
+
1348
+ ct_analysis = call_lambda_api(
1349
+ 'forensics',
1350
+ account_creds,
1351
+ [],
1352
+ operation='cloudtrail',
1353
+ account_id=acc_id,
1354
+ start_date=start_date,
1355
+ end_date=end_date,
1356
+ region='us-west-2'
1357
+ )
1358
+
1359
+ cloudtrail_analyses.append(ct_analysis)
1360
+
1361
+ if ct_analysis.get('error'):
1362
+ click.echo(f" ⚠️ CloudTrail: {ct_analysis['error']}")
1363
+ else:
1364
+ click.echo(f" ✓ CloudTrail analysis complete")
1365
+ click.echo(f" - {len(ct_analysis['event_summary'])} event types")
1366
+ click.echo(f" - {len(ct_analysis['write_events'])} resource changes")
1367
+ except Exception as e:
1368
+ click.echo(f" ✗ CloudTrail error: {str(e)}")
1369
+
1370
+ # Generate report
1371
+ click.echo(f"\nGenerating report...")
1372
+ report = format_investigation_report(trends_data, inventories, cloudtrail_analyses if not no_cloudtrail else None)
1373
+
1374
+ # Write to file
1375
+ with open(output, 'w') as f:
1376
+ f.write(report)
1377
+
1378
+ click.echo(f"\n✓ Investigation complete!")
1379
+ click.echo(f"✓ Report saved to: {output}")
1380
+ click.echo("")
1381
+
1382
+
1383
+ def find_account_profile(account_id):
1384
+ """
1385
+ Find the SSO profile name for a given account ID
1386
+ Returns profile name or None
1387
+ """
1388
+ import subprocess
1389
+
1390
+ try:
1391
+ # Get list of profiles
1392
+ result = subprocess.run(
1393
+ ['aws', 'configure', 'list-profiles'],
1394
+ capture_output=True,
1395
+ text=True
1396
+ )
1397
+
1398
+ profiles = result.stdout.strip().split('\n')
1399
+
1400
+ # Check each profile
1401
+ for profile in profiles:
1402
+ try:
1403
+ result = subprocess.run(
1404
+ ['aws', 'sts', 'get-caller-identity', '--profile', profile],
1405
+ capture_output=True,
1406
+ text=True,
1407
+ timeout=5
1408
+ )
1409
+
1410
+ if account_id in result.stdout:
1411
+ return profile
1412
+ except:
1413
+ continue
1414
+
1415
+ return None
1416
+ except:
1417
+ return None
1418
+
1419
+
1420
+ @cli.command()
1421
+ @click.option('--profile', required=True, help='Profile name')
1422
+ @click.option('--start-date', help='Start date (YYYY-MM-DD)')
1423
+ @click.option('--end-date', help='End date (YYYY-MM-DD)')
1424
+ @click.option('--days', type=int, default=10, help='Number of days to analyze (default: 10)')
1425
+ @click.option('--service', help='Filter by service name')
1426
+ @click.option('--account', help='Filter by account ID')
1427
+ @click.option('--sso', help='AWS SSO profile name')
1428
+ @click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
1429
+ def daily(profile, start_date, end_date, days, service, account, sso, output_json):
1430
+ """
1431
+ Get daily cost breakdown with granular detail.
1432
+
1433
+ Shows day-by-day costs for specific services and accounts, useful for:
1434
+ - Identifying cost spikes on specific dates
1435
+ - Validating daily cost patterns
1436
+ - Calculating precise daily averages
1437
+
1438
+ Examples:
1439
+ # Last 10 days of CloudWatch costs for specific account
1440
+ cc daily --profile khoros --days 10 --service AmazonCloudWatch --account 820054669588
1441
+
1442
+ # Custom date range with JSON output for automation
1443
+ cc daily --profile khoros --start-date 2025-10-28 --end-date 2025-11-06 --json
1444
+
1445
+ # Find high-cost days using jq
1446
+ cc daily --profile khoros --days 30 --json | jq '.daily_costs | map(select(.cost > 1000))'
1447
+ """
1448
+ # Load profile
1449
+ config = load_profile(profile)
1450
+
1451
+ # Apply SSO if provided
1452
+ if sso:
1453
+ config['aws_profile'] = sso
1454
+
1455
+ # Calculate date range
1456
+ if end_date:
1457
+ end = datetime.strptime(end_date, '%Y-%m-%d')
1458
+ else:
1459
+ end = datetime.now()
1460
+
1461
+ if start_date:
1462
+ start = datetime.strptime(start_date, '%Y-%m-%d')
1463
+ else:
1464
+ start = end - timedelta(days=days)
1465
+
1466
+ click.echo(f"Daily breakdown: {start.strftime('%Y-%m-%d')} to {end.strftime('%Y-%m-%d')}")
1467
+ if service:
1468
+ click.echo(f"Service filter: {service}")
1469
+ if account:
1470
+ click.echo(f"Account filter: {account}")
1471
+ click.echo("")
1472
+
1473
+ # Get credentials
1474
+ try:
1475
+ if 'aws_profile' in config:
1476
+ session = boto3.Session(profile_name=config['aws_profile'])
1477
+ else:
1478
+ creds = config['credentials']
1479
+ session = boto3.Session(
1480
+ aws_access_key_id=creds['aws_access_key_id'],
1481
+ aws_secret_access_key=creds['aws_secret_access_key'],
1482
+ aws_session_token=creds.get('aws_session_token')
1483
+ )
1484
+
1485
+ ce_client = session.client('ce', region_name='us-east-1')
1486
+
1487
+ # Build filter
1488
+ filter_parts = []
1489
+
1490
+ # Account filter
1491
+ if account:
1492
+ filter_parts.append({
1493
+ "Dimensions": {
1494
+ "Key": "LINKED_ACCOUNT",
1495
+ "Values": [account]
1496
+ }
1497
+ })
1498
+ else:
1499
+ filter_parts.append({
1500
+ "Dimensions": {
1501
+ "Key": "LINKED_ACCOUNT",
1502
+ "Values": config['accounts']
1503
+ }
1504
+ })
1505
+
1506
+ # Service filter
1507
+ if service:
1508
+ filter_parts.append({
1509
+ "Dimensions": {
1510
+ "Key": "SERVICE",
1511
+ "Values": [service]
1512
+ }
1513
+ })
1514
+
1515
+ # Exclude support and tax
1516
+ filter_parts.append({
1517
+ "Not": {
1518
+ "Dimensions": {
1519
+ "Key": "RECORD_TYPE",
1520
+ "Values": ["Tax", "Support"]
1521
+ }
1522
+ }
1523
+ })
1524
+
1525
+ cost_filter = {"And": filter_parts} if len(filter_parts) > 1 else filter_parts[0]
1526
+
1527
+ # Get daily costs
1528
+ response = ce_client.get_cost_and_usage(
1529
+ TimePeriod={
1530
+ 'Start': start.strftime('%Y-%m-%d'),
1531
+ 'End': (end + timedelta(days=1)).strftime('%Y-%m-%d')
1532
+ },
1533
+ Granularity='DAILY',
1534
+ Metrics=['UnblendedCost'],
1535
+ Filter=cost_filter
1536
+ )
1537
+
1538
+ # Collect results
1539
+ daily_costs = []
1540
+ total = 0
1541
+ for day in response['ResultsByTime']:
1542
+ date = day['TimePeriod']['Start']
1543
+ cost = float(day['Total']['UnblendedCost']['Amount'])
1544
+ total += cost
1545
+ daily_costs.append({'date': date, 'cost': cost})
1546
+
1547
+ num_days = len(response['ResultsByTime'])
1548
+ daily_avg = total / num_days if num_days > 0 else 0
1549
+ annual = daily_avg * 365
1550
+
1551
+ # Output results
1552
+ if output_json:
1553
+ import json
1554
+ result = {
1555
+ 'period': {
1556
+ 'start': start.strftime('%Y-%m-%d'),
1557
+ 'end': end.strftime('%Y-%m-%d'),
1558
+ 'days': num_days
1559
+ },
1560
+ 'filters': {
1561
+ 'service': service,
1562
+ 'account': account
1563
+ },
1564
+ 'daily_costs': daily_costs,
1565
+ 'summary': {
1566
+ 'total': total,
1567
+ 'daily_avg': daily_avg,
1568
+ 'annual_projection': annual
1569
+ }
1570
+ }
1571
+ click.echo(json.dumps(result, indent=2))
1572
+ else:
1573
+ click.echo("Date | Cost")
1574
+ click.echo("-----------|-----------")
1575
+ for item in daily_costs:
1576
+ click.echo(f"{item['date']} | ${item['cost']:,.2f}")
1577
+ click.echo("-----------|-----------")
1578
+ click.echo(f"Total | ${total:,.2f}")
1579
+ click.echo(f"Daily Avg | ${daily_avg:,.2f}")
1580
+ click.echo(f"Annual | ${annual:,.0f}")
1581
+
1582
+ except Exception as e:
1583
+ raise click.ClickException(f"Failed to get daily costs: {e}")
1584
+
1585
+
1586
+ @cli.command()
1587
+ @click.option('--profile', required=True, help='Profile name')
1588
+ @click.option('--account', help='Account ID to compare')
1589
+ @click.option('--service', help='Service to compare')
1590
+ @click.option('--before', required=True, help='Before period (YYYY-MM-DD:YYYY-MM-DD)')
1591
+ @click.option('--after', required=True, help='After period (YYYY-MM-DD:YYYY-MM-DD)')
1592
+ @click.option('--expected-reduction', type=float, help='Expected reduction percentage')
1593
+ @click.option('--sso', help='AWS SSO profile name')
1594
+ @click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
1595
+ def compare(profile, account, service, before, after, expected_reduction, sso, output_json):
1596
+ """
1597
+ Compare costs between two periods for validation and analysis.
1598
+
1599
+ Perfect for:
1600
+ - Validating cost optimization savings
1601
+ - Before/after migration analysis
1602
+ - Measuring impact of infrastructure changes
1603
+ - Automated savings validation in CI/CD
1604
+
1605
+ Examples:
1606
+ # Validate Datadog migration savings (expect 50% reduction)
1607
+ cc compare --profile khoros --account 180770971501 --service AmazonCloudWatch \
1608
+ --before "2025-10-28:2025-11-06" --after "2025-11-17:2025-11-26" --expected-reduction 50
1609
+
1610
+ # Compare total costs across all accounts
1611
+ cc compare --profile khoros --before "2025-10-01:2025-10-31" --after "2025-11-01:2025-11-30"
1612
+
1613
+ # JSON output for automated validation
1614
+ cc compare --profile khoros --service EC2 --before "2025-10-01:2025-10-07" \
1615
+ --after "2025-11-08:2025-11-14" --json | jq '.comparison.met_expectation'
1616
+ """
1617
+ # Load profile
1618
+ config = load_profile(profile)
1619
+
1620
+ # Apply SSO if provided
1621
+ if sso:
1622
+ config['aws_profile'] = sso
1623
+
1624
+ # Parse periods
1625
+ try:
1626
+ before_start, before_end = before.split(':')
1627
+ after_start, after_end = after.split(':')
1628
+ except ValueError:
1629
+ raise click.ClickException("Period format must be 'YYYY-MM-DD:YYYY-MM-DD'")
1630
+
1631
+ if not output_json:
1632
+ click.echo(f"Comparing periods:")
1633
+ click.echo(f" Before: {before_start} to {before_end}")
1634
+ click.echo(f" After: {after_start} to {after_end}")
1635
+ if service:
1636
+ click.echo(f" Service: {service}")
1637
+ if account:
1638
+ click.echo(f" Account: {account}")
1639
+ click.echo("")
1640
+
1641
+ # Get credentials
1642
+ try:
1643
+ if 'aws_profile' in config:
1644
+ session = boto3.Session(profile_name=config['aws_profile'])
1645
+ else:
1646
+ creds = config['credentials']
1647
+ session = boto3.Session(
1648
+ aws_access_key_id=creds['aws_access_key_id'],
1649
+ aws_secret_access_key=creds['aws_secret_access_key'],
1650
+ aws_session_token=creds.get('aws_session_token')
1651
+ )
1652
+
1653
+ ce_client = session.client('ce', region_name='us-east-1')
1654
+
1655
+ # Build filter
1656
+ def build_filter():
1657
+ filter_parts = []
1658
+
1659
+ if account:
1660
+ filter_parts.append({
1661
+ "Dimensions": {
1662
+ "Key": "LINKED_ACCOUNT",
1663
+ "Values": [account]
1664
+ }
1665
+ })
1666
+ else:
1667
+ filter_parts.append({
1668
+ "Dimensions": {
1669
+ "Key": "LINKED_ACCOUNT",
1670
+ "Values": config['accounts']
1671
+ }
1672
+ })
1673
+
1674
+ if service:
1675
+ filter_parts.append({
1676
+ "Dimensions": {
1677
+ "Key": "SERVICE",
1678
+ "Values": [service]
1679
+ }
1680
+ })
1681
+
1682
+ filter_parts.append({
1683
+ "Not": {
1684
+ "Dimensions": {
1685
+ "Key": "RECORD_TYPE",
1686
+ "Values": ["Tax", "Support"]
1687
+ }
1688
+ }
1689
+ })
1690
+
1691
+ return {"And": filter_parts} if len(filter_parts) > 1 else filter_parts[0]
1692
+
1693
+ cost_filter = build_filter()
1694
+
1695
+ # Get before period costs
1696
+ before_response = ce_client.get_cost_and_usage(
1697
+ TimePeriod={
1698
+ 'Start': before_start,
1699
+ 'End': (datetime.strptime(before_end, '%Y-%m-%d') + timedelta(days=1)).strftime('%Y-%m-%d')
1700
+ },
1701
+ Granularity='DAILY',
1702
+ Metrics=['UnblendedCost'],
1703
+ Filter=cost_filter
1704
+ )
1705
+
1706
+ # Get after period costs
1707
+ after_response = ce_client.get_cost_and_usage(
1708
+ TimePeriod={
1709
+ 'Start': after_start,
1710
+ 'End': (datetime.strptime(after_end, '%Y-%m-%d') + timedelta(days=1)).strftime('%Y-%m-%d')
1711
+ },
1712
+ Granularity='DAILY',
1713
+ Metrics=['UnblendedCost'],
1714
+ Filter=cost_filter
1715
+ )
1716
+
1717
+ # Calculate totals
1718
+ before_total = sum(float(day['Total']['UnblendedCost']['Amount']) for day in before_response['ResultsByTime'])
1719
+ after_total = sum(float(day['Total']['UnblendedCost']['Amount']) for day in after_response['ResultsByTime'])
1720
+
1721
+ before_days = len(before_response['ResultsByTime'])
1722
+ after_days = len(after_response['ResultsByTime'])
1723
+
1724
+ before_daily = before_total / before_days if before_days > 0 else 0
1725
+ after_daily = after_total / after_days if after_days > 0 else 0
1726
+
1727
+ reduction = before_daily - after_daily
1728
+ reduction_pct = (reduction / before_daily * 100) if before_daily > 0 else 0
1729
+ annual_savings = reduction * 365
1730
+
1731
+ # Output results
1732
+ if output_json:
1733
+ import json
1734
+ result = {
1735
+ 'before': {
1736
+ 'period': {'start': before_start, 'end': before_end},
1737
+ 'total': before_total,
1738
+ 'daily_avg': before_daily,
1739
+ 'days': before_days
1740
+ },
1741
+ 'after': {
1742
+ 'period': {'start': after_start, 'end': after_end},
1743
+ 'total': after_total,
1744
+ 'daily_avg': after_daily,
1745
+ 'days': after_days
1746
+ },
1747
+ 'comparison': {
1748
+ 'daily_reduction': reduction,
1749
+ 'reduction_pct': reduction_pct,
1750
+ 'annual_savings': annual_savings
1751
+ }
1752
+ }
1753
+
1754
+ if expected_reduction is not None:
1755
+ result['comparison']['expected_reduction_pct'] = expected_reduction
1756
+ result['comparison']['met_expectation'] = reduction_pct >= expected_reduction
1757
+
1758
+ click.echo(json.dumps(result, indent=2))
1759
+ else:
1760
+ click.echo("Before Period:")
1761
+ click.echo(f" Total: ${before_total:,.2f}")
1762
+ click.echo(f" Daily Avg: ${before_daily:,.2f}")
1763
+ click.echo(f" Days: {before_days}")
1764
+ click.echo("")
1765
+ click.echo("After Period:")
1766
+ click.echo(f" Total: ${after_total:,.2f}")
1767
+ click.echo(f" Daily Avg: ${after_daily:,.2f}")
1768
+ click.echo(f" Days: {after_days}")
1769
+ click.echo("")
1770
+ click.echo("Comparison:")
1771
+ click.echo(f" Daily Reduction: ${reduction:,.2f}")
1772
+ click.echo(f" Reduction %: {reduction_pct:.1f}%")
1773
+ click.echo(f" Annual Savings: ${annual_savings:,.0f}")
1774
+
1775
+ if expected_reduction is not None:
1776
+ click.echo("")
1777
+ if reduction_pct >= expected_reduction:
1778
+ click.echo(f"✅ Savings achieved: {reduction_pct:.1f}% (expected {expected_reduction}%)")
1779
+ else:
1780
+ click.echo(f"⚠️ Below target: {reduction_pct:.1f}% (expected {expected_reduction}%)")
1781
+
1782
+ except Exception as e:
1783
+ raise click.ClickException(f"Comparison failed: {e}")
1784
+
1785
+
1786
+ @cli.command()
1787
+ @click.option('--profile', required=True, help='Profile name')
1788
+ @click.option('--tag-key', required=True, help='Tag key to filter by')
1789
+ @click.option('--tag-value', help='Tag value to filter by (optional)')
1790
+ @click.option('--start-date', help='Start date (YYYY-MM-DD)')
1791
+ @click.option('--end-date', help='End date (YYYY-MM-DD)')
1792
+ @click.option('--days', type=int, default=30, help='Number of days to analyze (default: 30)')
1793
+ @click.option('--sso', help='AWS SSO profile name')
1794
+ @click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
1795
+ def tags(profile, tag_key, tag_value, start_date, end_date, days, sso, output_json):
1796
+ """
1797
+ Analyze costs grouped by resource tags for cost attribution.
1798
+
1799
+ Useful for:
1800
+ - Cost allocation by team, project, or environment
1801
+ - Identifying untagged resources (cost attribution gaps)
1802
+ - Tracking costs by cost center or department
1803
+ - Validating tagging compliance
1804
+
1805
+ Examples:
1806
+ # See all costs by Environment tag
1807
+ cc tags --profile khoros --tag-key "Environment" --days 30
1808
+
1809
+ # Filter to specific tag value
1810
+ cc tags --profile khoros --tag-key "Team" --tag-value "Platform" --days 30
1811
+
1812
+ # Find top cost centers with JSON output
1813
+ cc tags --profile khoros --tag-key "CostCenter" --days 30 --json | \
1814
+ jq '.tag_costs | sort_by(-.cost) | .[:5]'
1815
+
1816
+ # Identify untagged resources (look for empty tag values)
1817
+ cc tags --profile khoros --tag-key "Owner" --days 7
1818
+ """
1819
+ # Load profile
1820
+ config = load_profile(profile)
1821
+
1822
+ # Apply SSO if provided
1823
+ if sso:
1824
+ config['aws_profile'] = sso
1825
+
1826
+ # Calculate date range
1827
+ if end_date:
1828
+ end = datetime.strptime(end_date, '%Y-%m-%d')
1829
+ else:
1830
+ end = datetime.now()
1831
+
1832
+ if start_date:
1833
+ start = datetime.strptime(start_date, '%Y-%m-%d')
1834
+ else:
1835
+ start = end - timedelta(days=days)
1836
+
1837
+ if not output_json:
1838
+ click.echo(f"Tag-based cost analysis: {start.strftime('%Y-%m-%d')} to {end.strftime('%Y-%m-%d')}")
1839
+ click.echo(f"Tag key: {tag_key}")
1840
+ if tag_value:
1841
+ click.echo(f"Tag value: {tag_value}")
1842
+ click.echo("")
1843
+
1844
+ # Get credentials
1845
+ try:
1846
+ if 'aws_profile' in config:
1847
+ session = boto3.Session(profile_name=config['aws_profile'])
1848
+ else:
1849
+ creds = config['credentials']
1850
+ session = boto3.Session(
1851
+ aws_access_key_id=creds['aws_access_key_id'],
1852
+ aws_secret_access_key=creds['aws_secret_access_key'],
1853
+ aws_session_token=creds.get('aws_session_token')
1854
+ )
1855
+
1856
+ ce_client = session.client('ce', region_name='us-east-1')
1857
+
1858
+ # Build filter
1859
+ filter_parts = [
1860
+ {
1861
+ "Dimensions": {
1862
+ "Key": "LINKED_ACCOUNT",
1863
+ "Values": config['accounts']
1864
+ }
1865
+ },
1866
+ {
1867
+ "Not": {
1868
+ "Dimensions": {
1869
+ "Key": "RECORD_TYPE",
1870
+ "Values": ["Tax", "Support"]
1871
+ }
1872
+ }
1873
+ }
1874
+ ]
1875
+
1876
+ # Add tag filter if value specified
1877
+ if tag_value:
1878
+ filter_parts.append({
1879
+ "Tags": {
1880
+ "Key": tag_key,
1881
+ "Values": [tag_value]
1882
+ }
1883
+ })
1884
+
1885
+ cost_filter = {"And": filter_parts}
1886
+
1887
+ # Get costs grouped by tag values
1888
+ response = ce_client.get_cost_and_usage(
1889
+ TimePeriod={
1890
+ 'Start': start.strftime('%Y-%m-%d'),
1891
+ 'End': (end + timedelta(days=1)).strftime('%Y-%m-%d')
1892
+ },
1893
+ Granularity='MONTHLY',
1894
+ Metrics=['UnblendedCost'],
1895
+ GroupBy=[{
1896
+ 'Type': 'TAG',
1897
+ 'Key': tag_key
1898
+ }],
1899
+ Filter=cost_filter
1900
+ )
1901
+
1902
+ # Collect results
1903
+ tag_costs = {}
1904
+ for period in response['ResultsByTime']:
1905
+ for group in period['Groups']:
1906
+ tag_val = group['Keys'][0].split('$')[1] if '$' in group['Keys'][0] else group['Keys'][0]
1907
+ cost = float(group['Metrics']['UnblendedCost']['Amount'])
1908
+ tag_costs[tag_val] = tag_costs.get(tag_val, 0) + cost
1909
+
1910
+ # Sort by cost
1911
+ sorted_tags = sorted(tag_costs.items(), key=lambda x: x[1], reverse=True)
1912
+
1913
+ total = sum(tag_costs.values())
1914
+ num_days = (end - start).days
1915
+ daily_avg = total / num_days if num_days > 0 else 0
1916
+
1917
+ # Output results
1918
+ if output_json:
1919
+ import json
1920
+ result = {
1921
+ 'period': {
1922
+ 'start': start.strftime('%Y-%m-%d'),
1923
+ 'end': end.strftime('%Y-%m-%d'),
1924
+ 'days': num_days
1925
+ },
1926
+ 'tag_key': tag_key,
1927
+ 'tag_value_filter': tag_value,
1928
+ 'tag_costs': [{'tag_value': k, 'cost': v, 'percentage': (v/total*100) if total > 0 else 0} for k, v in sorted_tags],
1929
+ 'summary': {
1930
+ 'total': total,
1931
+ 'daily_avg': daily_avg,
1932
+ 'annual_projection': daily_avg * 365
1933
+ }
1934
+ }
1935
+ click.echo(json.dumps(result, indent=2))
1936
+ else:
1937
+ click.echo(f"Tag Value{' '*(30-len('Tag Value'))} | Cost | %")
1938
+ click.echo("-" * 60)
1939
+ for tag_val, cost in sorted_tags:
1940
+ pct = (cost / total * 100) if total > 0 else 0
1941
+ tag_display = tag_val[:30].ljust(30)
1942
+ click.echo(f"{tag_display} | ${cost:>9,.2f} | {pct:>5.1f}%")
1943
+ click.echo("-" * 60)
1944
+ click.echo(f"{'Total'.ljust(30)} | ${total:>9,.2f} | 100.0%")
1945
+ click.echo("")
1946
+ click.echo(f"Daily Avg: ${daily_avg:,.2f}")
1947
+ click.echo(f"Annual Projection: ${daily_avg * 365:,.0f}")
1948
+
1949
+ except Exception as e:
1950
+ raise click.ClickException(f"Tag analysis failed: {e}")
1951
+
1952
+
1953
+ @cli.command()
1954
+ @click.option('--profile', required=True, help='Profile name')
1955
+ @click.option('--query', required=True, help='SQL query to execute')
1956
+ @click.option('--database', default='athenacurcfn_cloud_intelligence_dashboard', help='Athena database name')
1957
+ @click.option('--output-bucket', help='S3 bucket for query results (default: from profile)')
1958
+ @click.option('--sso', help='AWS SSO profile name')
1959
+ def query(profile, query, database, output_bucket, sso):
1960
+ """
1961
+ Execute custom Athena SQL query on CUR data
1962
+
1963
+ Example:
1964
+ cc query --profile khoros --query "SELECT line_item_usage_account_id, SUM(line_item_unblended_cost) as cost FROM cloud_intelligence_dashboard WHERE line_item_usage_start_date >= DATE '2025-11-01' GROUP BY 1 ORDER BY 2 DESC LIMIT 10"
1965
+ """
1966
+ # Load profile
1967
+ config = load_profile(profile)
1968
+
1969
+ # Apply SSO if provided
1970
+ if sso:
1971
+ config['aws_profile'] = sso
1972
+
1973
+ # Get credentials
1974
+ try:
1975
+ if 'aws_profile' in config:
1976
+ session = boto3.Session(profile_name=config['aws_profile'])
1977
+ else:
1978
+ creds = config['credentials']
1979
+ session = boto3.Session(
1980
+ aws_access_key_id=creds['aws_access_key_id'],
1981
+ aws_secret_access_key=creds['aws_secret_access_key'],
1982
+ aws_session_token=creds.get('aws_session_token')
1983
+ )
1984
+
1985
+ athena_client = session.client('athena', region_name='us-east-1')
1986
+
1987
+ # Default output location
1988
+ if not output_bucket:
1989
+ output_bucket = 's3://khoros-finops-athena/athena/'
1990
+
1991
+ click.echo(f"Executing query on database: {database}")
1992
+ click.echo(f"Output location: {output_bucket}")
1993
+ click.echo("")
1994
+
1995
+ # Execute query
1996
+ response = athena_client.start_query_execution(
1997
+ QueryString=query,
1998
+ QueryExecutionContext={'Database': database},
1999
+ ResultConfiguration={'OutputLocation': output_bucket}
2000
+ )
2001
+
2002
+ query_id = response['QueryExecutionId']
2003
+ click.echo(f"Query ID: {query_id}")
2004
+ click.echo("Waiting for query to complete...")
2005
+
2006
+ # Wait for completion
2007
+ import time
2008
+ max_wait = 60
2009
+ waited = 0
2010
+ while waited < max_wait:
2011
+ status_response = athena_client.get_query_execution(QueryExecutionId=query_id)
2012
+ status = status_response['QueryExecution']['Status']['State']
2013
+
2014
+ if status == 'SUCCEEDED':
2015
+ click.echo("✓ Query completed successfully")
2016
+ break
2017
+ elif status in ['FAILED', 'CANCELLED']:
2018
+ reason = status_response['QueryExecution']['Status'].get('StateChangeReason', 'Unknown')
2019
+ raise click.ClickException(f"Query {status}: {reason}")
2020
+
2021
+ time.sleep(2)
2022
+ waited += 2
2023
+
2024
+ if waited >= max_wait:
2025
+ raise click.ClickException(f"Query timeout after {max_wait}s. Check query ID: {query_id}")
2026
+
2027
+ # Get results
2028
+ results = athena_client.get_query_results(QueryExecutionId=query_id)
2029
+
2030
+ # Display results
2031
+ rows = results['ResultSet']['Rows']
2032
+ if not rows:
2033
+ click.echo("No results returned")
2034
+ return
2035
+
2036
+ # Header
2037
+ headers = [col['VarCharValue'] for col in rows[0]['Data']]
2038
+ click.echo(" | ".join(headers))
2039
+ click.echo("-" * (len(" | ".join(headers))))
2040
+
2041
+ # Data rows
2042
+ for row in rows[1:]:
2043
+ values = [col.get('VarCharValue', '') for col in row['Data']]
2044
+ click.echo(" | ".join(values))
2045
+
2046
+ click.echo("")
2047
+ click.echo(f"Returned {len(rows)-1} rows")
2048
+
2049
+ except Exception as e:
2050
+ raise click.ClickException(f"Query failed: {e}")
2051
+
2052
+
2053
+ @cli.group()
2054
+ def exclusions():
2055
+ """
2056
+ Manage cost exclusions (services/types to exclude from calculations).
2057
+
2058
+ Exclusions are stored in DynamoDB and apply globally or per-profile.
2059
+ Common exclusions: Tax, Support, OCBLateFee, Refunds, Credits
2060
+ """
2061
+ pass
2062
+
2063
+
2064
+ @exclusions.command('show')
2065
+ @click.option('--profile', help='Show profile-specific exclusions (default: global)')
2066
+ @click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
2067
+ def show_exclusions(profile, output_json):
2068
+ """Show current exclusions configuration"""
2069
+ import requests
2070
+
2071
+ api_secret = get_api_secret()
2072
+ if not api_secret:
2073
+ raise click.ClickException(
2074
+ "No API secret configured.\n"
2075
+ "Run: cc configure --api-secret YOUR_SECRET"
2076
+ )
2077
+
2078
+ try:
2079
+ response = requests.post(
2080
+ PROFILES_API_URL,
2081
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
2082
+ json={'operation': 'get_exclusions', 'profile_name': profile},
2083
+ timeout=10
2084
+ )
2085
+
2086
+ if response.status_code == 200:
2087
+ exclusions_data = response.json().get('exclusions', {})
2088
+
2089
+ if output_json:
2090
+ click.echo(json.dumps(exclusions_data, indent=2))
2091
+ else:
2092
+ scope = profile if profile else "global"
2093
+ click.echo(f"Exclusions ({scope}):")
2094
+ click.echo("")
2095
+
2096
+ if exclusions_data.get('record_types'):
2097
+ click.echo("Record Types:")
2098
+ for rt in exclusions_data['record_types']:
2099
+ click.echo(f" - {rt}")
2100
+ click.echo("")
2101
+
2102
+ if exclusions_data.get('services'):
2103
+ click.echo("Services:")
2104
+ for svc in exclusions_data['services']:
2105
+ click.echo(f" - {svc}")
2106
+ click.echo("")
2107
+
2108
+ if exclusions_data.get('line_item_types'):
2109
+ click.echo("Line Item Types:")
2110
+ for lit in exclusions_data['line_item_types']:
2111
+ click.echo(f" - {lit}")
2112
+ click.echo("")
2113
+
2114
+ if exclusions_data.get('usage_types'):
2115
+ click.echo("Usage Types:")
2116
+ for ut in exclusions_data['usage_types']:
2117
+ click.echo(f" - {ut}")
2118
+ else:
2119
+ raise click.ClickException(f"Failed to fetch exclusions: {response.status_code}")
2120
+
2121
+ except requests.exceptions.RequestException as e:
2122
+ raise click.ClickException(f"API request failed: {e}")
2123
+
2124
+
2125
+ @exclusions.command('add')
2126
+ @click.option('--record-type', help='Add record type exclusion (e.g., Tax, Support)')
2127
+ @click.option('--service', help='Add service exclusion (e.g., OCBLateFee)')
2128
+ @click.option('--line-item-type', help='Add line item type exclusion (e.g., Refund, Credit)')
2129
+ @click.option('--usage-type', help='Add usage type exclusion')
2130
+ @click.option('--profile', help='Add to profile-specific exclusions (default: global)')
2131
+ def add_exclusion(record_type, service, line_item_type, usage_type, profile):
2132
+ """Add an exclusion"""
2133
+ import requests
2134
+
2135
+ if not any([record_type, service, line_item_type, usage_type]):
2136
+ raise click.ClickException(
2137
+ "Must specify at least one of: --record-type, --service, --line-item-type, --usage-type"
2138
+ )
2139
+
2140
+ api_secret = get_api_secret()
2141
+ if not api_secret:
2142
+ raise click.ClickException(
2143
+ "No API secret configured.\n"
2144
+ "Run: cc configure --api-secret YOUR_SECRET"
2145
+ )
2146
+
2147
+ # Get current exclusions
2148
+ try:
2149
+ response = requests.post(
2150
+ PROFILES_API_URL,
2151
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
2152
+ json={'operation': 'get_exclusions', 'profile_name': profile},
2153
+ timeout=10
2154
+ )
2155
+
2156
+ if response.status_code == 200:
2157
+ exclusions_data = response.json().get('exclusions', {})
2158
+ else:
2159
+ exclusions_data = {
2160
+ 'record_types': [],
2161
+ 'services': [],
2162
+ 'line_item_types': [],
2163
+ 'usage_types': []
2164
+ }
2165
+
2166
+ # Add new exclusions
2167
+ if record_type and record_type not in exclusions_data.get('record_types', []):
2168
+ exclusions_data.setdefault('record_types', []).append(record_type)
2169
+
2170
+ if service and service not in exclusions_data.get('services', []):
2171
+ exclusions_data.setdefault('services', []).append(service)
2172
+
2173
+ if line_item_type and line_item_type not in exclusions_data.get('line_item_types', []):
2174
+ exclusions_data.setdefault('line_item_types', []).append(line_item_type)
2175
+
2176
+ if usage_type and usage_type not in exclusions_data.get('usage_types', []):
2177
+ exclusions_data.setdefault('usage_types', []).append(usage_type)
2178
+
2179
+ # Update exclusions
2180
+ update_response = requests.post(
2181
+ PROFILES_API_URL,
2182
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
2183
+ json={
2184
+ 'operation': 'update_exclusions',
2185
+ 'profile_name': profile,
2186
+ 'record_types': exclusions_data.get('record_types', []),
2187
+ 'services': exclusions_data.get('services', []),
2188
+ 'line_item_types': exclusions_data.get('line_item_types', []),
2189
+ 'usage_types': exclusions_data.get('usage_types', [])
2190
+ },
2191
+ timeout=10
2192
+ )
2193
+
2194
+ if update_response.status_code == 200:
2195
+ scope = profile if profile else "global"
2196
+ click.echo(f"✓ Exclusion added to {scope} config")
2197
+ if record_type:
2198
+ click.echo(f" Record Type: {record_type}")
2199
+ if service:
2200
+ click.echo(f" Service: {service}")
2201
+ if line_item_type:
2202
+ click.echo(f" Line Item Type: {line_item_type}")
2203
+ if usage_type:
2204
+ click.echo(f" Usage Type: {usage_type}")
2205
+ else:
2206
+ raise click.ClickException(f"Failed to update exclusions: {update_response.status_code}")
2207
+
2208
+ except requests.exceptions.RequestException as e:
2209
+ raise click.ClickException(f"API request failed: {e}")
2210
+
2211
+
2212
+ @exclusions.command('remove')
2213
+ @click.option('--record-type', help='Remove record type exclusion')
2214
+ @click.option('--service', help='Remove service exclusion')
2215
+ @click.option('--line-item-type', help='Remove line item type exclusion')
2216
+ @click.option('--usage-type', help='Remove usage type exclusion')
2217
+ @click.option('--profile', help='Remove from profile-specific exclusions (default: global)')
2218
+ def remove_exclusion(record_type, service, line_item_type, usage_type, profile):
2219
+ """Remove an exclusion"""
2220
+ import requests
2221
+
2222
+ if not any([record_type, service, line_item_type, usage_type]):
2223
+ raise click.ClickException(
2224
+ "Must specify at least one of: --record-type, --service, --line-item-type, --usage-type"
2225
+ )
2226
+
2227
+ api_secret = get_api_secret()
2228
+ if not api_secret:
2229
+ raise click.ClickException(
2230
+ "No API secret configured.\n"
2231
+ "Run: cc configure --api-secret YOUR_SECRET"
2232
+ )
2233
+
2234
+ # Get current exclusions
2235
+ try:
2236
+ response = requests.post(
2237
+ PROFILES_API_URL,
2238
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
2239
+ json={'operation': 'get_exclusions', 'profile_name': profile},
2240
+ timeout=10
2241
+ )
2242
+
2243
+ if response.status_code == 200:
2244
+ exclusions_data = response.json().get('exclusions', {})
2245
+ else:
2246
+ raise click.ClickException("No exclusions found")
2247
+
2248
+ # Remove exclusions
2249
+ if record_type and record_type in exclusions_data.get('record_types', []):
2250
+ exclusions_data['record_types'].remove(record_type)
2251
+
2252
+ if service and service in exclusions_data.get('services', []):
2253
+ exclusions_data['services'].remove(service)
2254
+
2255
+ if line_item_type and line_item_type in exclusions_data.get('line_item_types', []):
2256
+ exclusions_data['line_item_types'].remove(line_item_type)
2257
+
2258
+ if usage_type and usage_type in exclusions_data.get('usage_types', []):
2259
+ exclusions_data['usage_types'].remove(usage_type)
2260
+
2261
+ # Update exclusions
2262
+ update_response = requests.post(
2263
+ PROFILES_API_URL,
2264
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
2265
+ json={
2266
+ 'operation': 'update_exclusions',
2267
+ 'profile_name': profile,
2268
+ 'record_types': exclusions_data.get('record_types', []),
2269
+ 'services': exclusions_data.get('services', []),
2270
+ 'line_item_types': exclusions_data.get('line_item_types', []),
2271
+ 'usage_types': exclusions_data.get('usage_types', [])
2272
+ },
2273
+ timeout=10
2274
+ )
2275
+
2276
+ if update_response.status_code == 200:
2277
+ scope = profile if profile else "global"
2278
+ click.echo(f"✓ Exclusion removed from {scope} config")
2279
+ else:
2280
+ raise click.ClickException(f"Failed to update exclusions: {update_response.status_code}")
2281
+
2282
+ except requests.exceptions.RequestException as e:
2283
+ raise click.ClickException(f"API request failed: {e}")
2284
+
2285
+
2286
+ @cli.command('trends-detailed')
2287
+ @click.option('--profile', required=True, help='Profile name')
2288
+ @click.option('--accounts', help='Comma-separated account IDs (optional, defaults to all profile accounts)')
2289
+ @click.option('--services', help='Comma-separated service names (optional, defaults to top N)')
2290
+ @click.option('--start-date', help='Start date (YYYY-MM-DD, defaults to 30 days before end-date)')
2291
+ @click.option('--end-date', help='End date (YYYY-MM-DD, defaults to T-2)')
2292
+ @click.option('--granularity', type=click.Choice(['DAILY', 'HOURLY'], case_sensitive=False), default='DAILY', help='Time granularity')
2293
+ @click.option('--top-n', type=int, default=20, help='Number of top services to return')
2294
+ @click.option('--filter-spikes/--no-filter-spikes', default=True, help='Filter out anomalous spikes (>100% day-over-day)')
2295
+ @click.option('--spike-threshold', type=float, default=2.0, help='Spike threshold multiplier (2.0 = 100% increase)')
2296
+ @click.option('--trendline', is_flag=True, help='Fit smooth trendline over spiky data (removes outliers)')
2297
+ @click.option('--outlier-threshold', type=float, default=2.5, help='Standard deviations from mean to consider outlier')
2298
+ @click.option('--output', default='trends_detailed.csv', help='Output CSV file')
2299
+ @click.option('--chart', is_flag=True, help='Generate trend chart (PNG)')
2300
+ @click.option('--json-output', is_flag=True, help='Output as JSON instead of CSV')
2301
+ @click.option('--sso', help='AWS SSO profile name')
2302
+ @click.option('--access-key-id', help='AWS Access Key ID')
2303
+ @click.option('--secret-access-key', help='AWS Secret Access Key')
2304
+ @click.option('--session-token', help='AWS Session Token')
2305
+ def trends_detailed(profile, accounts, services, start_date, end_date, granularity,
2306
+ top_n, filter_spikes, spike_threshold, trendline, outlier_threshold,
2307
+ output, chart, json_output,
2308
+ sso, access_key_id, secret_access_key, session_token):
2309
+ """
2310
+ Detailed cost trends with flexible granularity and filtering.
2311
+
2312
+ Supports:
2313
+ - Daily or Hourly granularity
2314
+ - Account filtering (specific accounts or all)
2315
+ - Service filtering (specific services or top N)
2316
+ - Spike detection and removal (one-time charges)
2317
+ - Automatic exclusions (Tax, Support, etc.)
2318
+
2319
+ Examples:
2320
+
2321
+ # Top 20 services for 2 specific accounts, daily, last 30 days
2322
+ cc trends-detailed --profile khoros --accounts "820054669588,180770971501" --sso khoros_umbrella
2323
+
2324
+ # Hourly granularity for specific date range
2325
+ cc trends-detailed --profile khoros --granularity HOURLY --start-date 2025-11-01 --end-date 2025-11-05 --sso khoros_umbrella
2326
+
2327
+ # Top 10 services with chart
2328
+ cc trends-detailed --profile khoros --top-n 10 --chart --sso khoros_umbrella
2329
+
2330
+ # Specific services only
2331
+ cc trends-detailed --profile khoros --services "EC2,RDS,CloudWatch" --sso khoros_umbrella
2332
+ """
2333
+ import requests
2334
+ from datetime import datetime, timedelta
2335
+
2336
+ # Load profile configuration
2337
+ config = load_profile(profile)
2338
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
2339
+
2340
+ # Get credentials
2341
+ from cost_calculator.executor import get_credentials_dict
2342
+ credentials = get_credentials_dict(config)
2343
+ if not credentials:
2344
+ raise click.ClickException("Failed to get AWS credentials. Check your AWS SSO session.")
2345
+
2346
+ # Parse accounts and services
2347
+ account_list = [acc.strip() for acc in accounts.split(',')] if accounts else None
2348
+ service_list = [svc.strip() for svc in services.split(',')] if services else None
2349
+
2350
+ # Build request
2351
+ request_data = {
2352
+ 'profile': profile,
2353
+ 'credentials': credentials,
2354
+ 'granularity': granularity.upper(),
2355
+ 'top_n': top_n,
2356
+ 'filter_spikes': filter_spikes,
2357
+ 'spike_threshold': spike_threshold,
2358
+ 'fit_trendline': trendline,
2359
+ 'outlier_threshold': outlier_threshold
2360
+ }
2361
+
2362
+ if start_date:
2363
+ request_data['start_date'] = start_date
2364
+ if end_date:
2365
+ request_data['end_date'] = end_date
2366
+ if account_list:
2367
+ request_data['accounts'] = account_list
2368
+ if service_list:
2369
+ request_data['services'] = service_list
2370
+
2371
+ # Get API secret
2372
+ api_secret = get_api_secret()
2373
+ if not api_secret:
2374
+ raise click.ClickException(
2375
+ "No API secret configured.\n"
2376
+ "Run: cc configure --api-secret YOUR_SECRET"
2377
+ )
2378
+
2379
+ # Call API
2380
+ click.echo(f"Fetching {granularity.lower()} cost trends...")
2381
+ if account_list:
2382
+ click.echo(f" Accounts: {', '.join(account_list)}")
2383
+ if service_list:
2384
+ click.echo(f" Services: {', '.join(service_list)}")
2385
+ else:
2386
+ click.echo(f" Top {top_n} services")
2387
+ click.echo(f" Spike filtering: {'enabled' if filter_spikes else 'disabled'}")
2388
+ click.echo("")
2389
+
2390
+ try:
2391
+ response = requests.post(
2392
+ f"{API_BASE_URL}/trends-detailed",
2393
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
2394
+ json=request_data,
2395
+ timeout=300 # 5 minutes for large queries
2396
+ )
2397
+
2398
+ if response.status_code != 200:
2399
+ raise click.ClickException(f"API call failed: {response.status_code} - {response.text}")
2400
+
2401
+ result = response.json()
2402
+
2403
+ if json_output:
2404
+ # Output as JSON
2405
+ click.echo(json.dumps(result, indent=2))
2406
+ else:
2407
+ # Generate CSV
2408
+ import csv
2409
+
2410
+ with open(output, 'w', newline='') as f:
2411
+ writer = csv.writer(f)
2412
+
2413
+ # Get all dates from first service
2414
+ if result['services']:
2415
+ dates = sorted(result['services'][0]['daily_costs'].keys())
2416
+
2417
+ # Header
2418
+ writer.writerow(['Service', 'Total', 'Daily Avg', 'Min', 'Max'] + dates)
2419
+
2420
+ # Data rows
2421
+ for service in result['services']:
2422
+ row = [
2423
+ service['name'],
2424
+ service['total'],
2425
+ service['daily_average'],
2426
+ service['min'],
2427
+ service['max']
2428
+ ]
2429
+ row.extend([service['daily_costs'].get(date, 0.0) for date in dates])
2430
+ writer.writerow(row)
2431
+
2432
+ click.echo(f"✓ Trends data saved to {output}")
2433
+
2434
+ # Show summary
2435
+ summary = result['summary']
2436
+ click.echo("")
2437
+ click.echo(f"Total Cost: ${summary['total_cost']:,.2f}")
2438
+ click.echo(f"Daily Average: ${summary['daily_average']:,.2f}")
2439
+ click.echo(f"Services: {summary['num_services']}")
2440
+ click.echo(f"Periods: {summary['num_periods']}")
2441
+
2442
+ # Generate chart if requested
2443
+ if chart:
2444
+ try:
2445
+ import matplotlib.pyplot as plt
2446
+ import matplotlib.dates as mdates
2447
+ from datetime import datetime as dt
2448
+
2449
+ chart_file = output.replace('.csv', '.png')
2450
+
2451
+ # Prepare data
2452
+ dates = sorted(result['services'][0]['daily_costs'].keys())
2453
+ date_objects = [dt.fromisoformat(d) for d in dates]
2454
+
2455
+ # Create plot
2456
+ fig, ax = plt.subplots(figsize=(16, 10))
2457
+
2458
+ for service in result['services'][:10]: # Top 10 only
2459
+ costs = [service['daily_costs'].get(date, 0.0) for date in dates]
2460
+
2461
+ if trendline and 'trendline' in service:
2462
+ # Plot raw data with lighter color and smaller markers
2463
+ line = ax.plot(date_objects, costs, marker='o', alpha=0.3,
2464
+ linewidth=1, markersize=3, linestyle='--')[0]
2465
+ # Plot trendline with same color but bolder
2466
+ trendline_costs = [service['trendline'].get(date, 0.0) for date in dates]
2467
+ ax.plot(date_objects, trendline_costs, color=line.get_color(),
2468
+ label=service['name'], linewidth=3, markersize=0)
2469
+ else:
2470
+ # Plot raw data only
2471
+ ax.plot(date_objects, costs, marker='o', label=service['name'],
2472
+ linewidth=2, markersize=4)
2473
+
2474
+ title = f'Top 10 Services - {granularity.title()} Cost Trends'
2475
+ if trendline:
2476
+ title += ' (with Fitted Trendlines)'
2477
+ title += f"\nAccounts: {', '.join(account_list) if account_list else 'All'}"
2478
+ ax.set_title(title, fontsize=14, fontweight='bold')
2479
+ ax.set_xlabel('Date', fontsize=12)
2480
+ ax.set_ylabel(f'{granularity.title()} Cost ($)', fontsize=12)
2481
+ ax.legend(loc='upper left', bbox_to_anchor=(1.02, 1), fontsize=9)
2482
+ ax.grid(True, alpha=0.3)
2483
+
2484
+ if granularity == 'DAILY':
2485
+ ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d'))
2486
+ ax.xaxis.set_major_locator(mdates.DayLocator(interval=max(1, len(dates)//15)))
2487
+ else:
2488
+ ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d %H:%M'))
2489
+ ax.xaxis.set_major_locator(mdates.HourLocator(interval=max(1, len(dates)//15)))
2490
+
2491
+ plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha='right')
2492
+ plt.tight_layout()
2493
+ plt.savefig(chart_file, dpi=150, bbox_inches='tight')
2494
+
2495
+ click.echo(f"✓ Chart saved to {chart_file}")
2496
+ except ImportError:
2497
+ click.echo("⚠ matplotlib not installed, skipping chart generation")
2498
+
2499
+ except requests.exceptions.RequestException as e:
2500
+ raise click.ClickException(f"API request failed: {e}")
2501
+
2502
+
874
2503
  if __name__ == '__main__':
875
2504
  cli()