aws-cost-calculator-cli 1.11.0__tar.gz → 2.1.0__tar.gz

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.
Files changed (22) hide show
  1. {aws_cost_calculator_cli-1.11.0/aws_cost_calculator_cli.egg-info → aws_cost_calculator_cli-2.1.0}/PKG-INFO +1 -1
  2. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0/aws_cost_calculator_cli.egg-info}/PKG-INFO +1 -1
  3. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/cost_calculator/cli.py +538 -185
  4. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/cost_calculator/executor.py +5 -2
  5. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/setup.py +1 -1
  6. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/CHANGES.md +0 -0
  7. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/LICENSE +0 -0
  8. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/MANIFEST.in +0 -0
  9. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/README.md +0 -0
  10. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/aws_cost_calculator_cli.egg-info/SOURCES.txt +0 -0
  11. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/aws_cost_calculator_cli.egg-info/dependency_links.txt +0 -0
  12. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/aws_cost_calculator_cli.egg-info/entry_points.txt +0 -0
  13. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/aws_cost_calculator_cli.egg-info/requires.txt +0 -0
  14. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/aws_cost_calculator_cli.egg-info/top_level.txt +0 -0
  15. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/cost_calculator/__init__.py +0 -0
  16. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/cost_calculator/api_client.py +0 -0
  17. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/cost_calculator/cur.py +0 -0
  18. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/cost_calculator/drill.py +0 -0
  19. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/cost_calculator/forensics.py +0 -0
  20. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/cost_calculator/monthly.py +0 -0
  21. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/cost_calculator/trends.py +0 -0
  22. {aws_cost_calculator_cli-1.11.0 → aws_cost_calculator_cli-2.1.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-cost-calculator-cli
3
- Version: 1.11.0
3
+ Version: 2.1.0
4
4
  Summary: AWS Cost Calculator CLI - Calculate daily and annual AWS costs across multiple accounts
5
5
  Home-page: https://github.com/trilogy-group/aws-cost-calculator
6
6
  Author: Cost Optimization Team
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-cost-calculator-cli
3
- Version: 1.11.0
3
+ Version: 2.1.0
4
4
  Summary: AWS Cost Calculator CLI - Calculate daily and annual AWS costs across multiple accounts
5
5
  Home-page: https://github.com/trilogy-group/aws-cost-calculator
6
6
  Author: Cost Optimization Team
@@ -18,6 +18,12 @@ 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
+ PROFILES_API_URL = os.environ.get(
24
+ 'COST_CALCULATOR_PROFILES_URL',
25
+ 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/'
26
+ )
21
27
  from cost_calculator.executor import execute_trends, execute_monthly, execute_drill
22
28
 
23
29
 
@@ -67,127 +73,197 @@ def apply_auth_options(config, sso=None, access_key_id=None, secret_access_key=N
67
73
  return config
68
74
 
69
75
 
70
- def load_profile(profile_name):
71
- """Load profile configuration from DynamoDB API or local file as fallback"""
76
+ def get_api_secret():
77
+ """Get API secret from config file or environment variable"""
72
78
  import os
73
- import requests
74
79
 
75
- config_dir = Path.home() / '.config' / 'cost-calculator'
76
- config_file = config_dir / 'profiles.json'
77
- creds_file = config_dir / 'credentials.json'
78
-
79
- # Try DynamoDB API first if COST_API_SECRET is set
80
+ # Check environment variable first
80
81
  api_secret = os.environ.get('COST_API_SECRET')
81
82
  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
- )
83
+ return api_secret
84
+
85
+ # Check config file
86
+ config_dir = Path.home() / '.config' / 'cost-calculator'
87
+ config_file = config_dir / 'config.json'
147
88
 
148
- # Fallback to local file if no API secret
149
89
  if config_file.exists():
150
90
  with open(config_file) as f:
151
- profiles = json.load(f)
91
+ config = json.load(f)
92
+ return config.get('api_secret')
93
+
94
+ return None
95
+
96
+
97
+ def get_exclusions(profile_name=None):
98
+ """Get exclusions configuration from DynamoDB API"""
99
+ import requests
100
+
101
+ api_secret = get_api_secret()
102
+ if not api_secret:
103
+ raise click.ClickException(
104
+ "No API secret configured.\n"
105
+ "Run: cc configure --api-secret YOUR_SECRET"
106
+ )
107
+
108
+ try:
109
+ response = requests.post(
110
+ PROFILES_API_URL,
111
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
112
+ json={'operation': 'get_exclusions', 'profile_name': profile_name},
113
+ timeout=10
114
+ )
115
+
116
+ if response.status_code == 200:
117
+ return response.json().get('exclusions', {})
118
+ else:
119
+ # Return defaults if API fails
120
+ return {
121
+ 'record_types': ['Tax', 'Support'],
122
+ 'services': [],
123
+ 'usage_types': [],
124
+ 'line_item_types': []
125
+ }
126
+ except:
127
+ # Return defaults if request fails
128
+ return {
129
+ 'record_types': ['Tax', 'Support'],
130
+ 'services': [],
131
+ 'usage_types': [],
132
+ 'line_item_types': []
133
+ }
134
+
135
+
136
+ def build_exclusion_filter(exclusions):
137
+ """Build AWS Cost Explorer filter from exclusions config"""
138
+ filter_parts = []
139
+
140
+ # Exclude record types (Tax, Support, etc.)
141
+ if exclusions.get('record_types'):
142
+ filter_parts.append({
143
+ "Not": {
144
+ "Dimensions": {
145
+ "Key": "RECORD_TYPE",
146
+ "Values": exclusions['record_types']
147
+ }
148
+ }
149
+ })
150
+
151
+ # Exclude specific services (OCBLateFee, etc.)
152
+ if exclusions.get('services'):
153
+ filter_parts.append({
154
+ "Not": {
155
+ "Dimensions": {
156
+ "Key": "SERVICE",
157
+ "Values": exclusions['services']
158
+ }
159
+ }
160
+ })
161
+
162
+ # Exclude usage types
163
+ if exclusions.get('usage_types'):
164
+ filter_parts.append({
165
+ "Not": {
166
+ "Dimensions": {
167
+ "Key": "USAGE_TYPE",
168
+ "Values": exclusions['usage_types']
169
+ }
170
+ }
171
+ })
172
+
173
+ # Exclude line item types (Refund, Credit, etc.)
174
+ if exclusions.get('line_item_types'):
175
+ filter_parts.append({
176
+ "Not": {
177
+ "Dimensions": {
178
+ "Key": "LINE_ITEM_TYPE",
179
+ "Values": exclusions['line_item_types']
180
+ }
181
+ }
182
+ })
183
+
184
+ return filter_parts
185
+
186
+
187
+ def load_profile(profile_name):
188
+ """Load profile configuration from DynamoDB API (API-only, no local files)"""
189
+ import requests
190
+
191
+ # Get API secret
192
+ api_secret = get_api_secret()
193
+
194
+ if not api_secret:
195
+ raise click.ClickException(
196
+ "No API secret configured.\n"
197
+ "Run: cc configure --api-secret YOUR_SECRET\n"
198
+ "Or set environment variable: export COST_API_SECRET=YOUR_SECRET"
199
+ )
200
+
201
+ try:
202
+ response = requests.post(
203
+ PROFILES_API_URL,
204
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
205
+ json={'operation': 'get', 'profile_name': profile_name},
206
+ timeout=10
207
+ )
152
208
 
153
- if profile_name in profiles:
154
- profile = profiles[profile_name]
209
+ if response.status_code == 200:
210
+ response_data = response.json()
211
+ # API returns {"profile": {...}} wrapper
212
+ profile_data = response_data.get('profile', response_data)
213
+ profile = {'accounts': profile_data['accounts']}
155
214
 
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}"
215
+ # If profile has aws_profile field, use it
216
+ if 'aws_profile' in profile_data:
217
+ profile['aws_profile'] = profile_data['aws_profile']
218
+ # Check for AWS_PROFILE environment variable (SSO support)
219
+ elif os.environ.get('AWS_PROFILE'):
220
+ profile['aws_profile'] = os.environ['AWS_PROFILE']
221
+ # Use environment credentials
222
+ elif os.environ.get('AWS_ACCESS_KEY_ID'):
223
+ profile['credentials'] = {
224
+ 'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
225
+ 'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
226
+ 'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
227
+ }
228
+ else:
229
+ # Try to find a matching AWS profile by name
230
+ # This allows "khoros" profile to work with "khoros_umbrella" AWS profile
231
+ import subprocess
232
+ try:
233
+ result = subprocess.run(
234
+ ['aws', 'configure', 'list-profiles'],
235
+ capture_output=True,
236
+ text=True,
237
+ timeout=5
180
238
  )
181
-
182
- profile['credentials'] = creds[profile_name]
239
+ if result.returncode == 0:
240
+ available_profiles = result.stdout.strip().split('\n')
241
+ # Try exact match first
242
+ if profile_name in available_profiles:
243
+ profile['aws_profile'] = profile_name
244
+ # Try with common suffixes
245
+ elif f"{profile_name}_umbrella" in available_profiles:
246
+ profile['aws_profile'] = f"{profile_name}_umbrella"
247
+ elif f"{profile_name}-umbrella" in available_profiles:
248
+ profile['aws_profile'] = f"{profile_name}-umbrella"
249
+ elif f"{profile_name}_prod" in available_profiles:
250
+ profile['aws_profile'] = f"{profile_name}_prod"
251
+ # If no match found, leave it unset - user must provide --sso
252
+ except:
253
+ # If we can't list profiles, leave it unset - user must provide --sso
254
+ pass
183
255
 
184
256
  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
- )
257
+ else:
258
+ raise click.ClickException(
259
+ f"Profile '{profile_name}' not found in DynamoDB.\n"
260
+ f"Run: cc profile create --name {profile_name} --accounts \"...\""
261
+ )
262
+ except requests.exceptions.RequestException as e:
263
+ raise click.ClickException(
264
+ f"Failed to fetch profile from API: {e}\n"
265
+ "Check your API secret and network connection."
266
+ )
191
267
 
192
268
 
193
269
  def calculate_costs(profile_config, accounts, start_date, offset, window):
@@ -265,7 +341,10 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
265
341
  )
266
342
  raise
267
343
 
268
- # Build filter
344
+ # Build filter with dynamic exclusions
345
+ exclusions = get_exclusions() # Get from DynamoDB
346
+ exclusion_filters = build_exclusion_filter(exclusions)
347
+
269
348
  cost_filter = {
270
349
  "And": [
271
350
  {
@@ -279,18 +358,13 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
279
358
  "Key": "BILLING_ENTITY",
280
359
  "Values": ["AWS"]
281
360
  }
282
- },
283
- {
284
- "Not": {
285
- "Dimensions": {
286
- "Key": "RECORD_TYPE",
287
- "Values": ["Tax", "Support"]
288
- }
289
- }
290
361
  }
291
362
  ]
292
363
  }
293
364
 
365
+ # Add dynamic exclusion filters
366
+ cost_filter["And"].extend(exclusion_filters)
367
+
294
368
  # Get daily costs
295
369
  click.echo("Fetching cost data...")
296
370
  try:
@@ -737,77 +811,81 @@ def setup():
737
811
 
738
812
 
739
813
  @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)"""
814
+ @click.option('--api-secret', help='API secret for DynamoDB profile access')
815
+ @click.option('--show', is_flag=True, help='Show current configuration')
816
+ def configure(api_secret, show):
817
+ """
818
+ Configure Cost Calculator CLI settings.
747
819
 
748
- config_dir = Path.home() / '.config' / 'cost-calculator'
749
- config_file = config_dir / 'profiles.json'
750
- creds_file = config_dir / 'credentials.json'
820
+ This tool requires an API secret to access profiles stored in DynamoDB.
821
+ The secret can be configured here or set via COST_API_SECRET environment variable.
751
822
 
752
- # Create config directory if it doesn't exist
823
+ Examples:
824
+ # Configure API secret
825
+ cc configure --api-secret YOUR_SECRET_KEY
826
+
827
+ # Show current configuration
828
+ cc configure --show
829
+
830
+ # Use environment variable instead (no configuration needed)
831
+ export COST_API_SECRET=YOUR_SECRET_KEY
832
+ """
833
+ import os
834
+
835
+ config_dir = Path.home() / '.config' / 'cost-calculator'
753
836
  config_dir.mkdir(parents=True, exist_ok=True)
837
+ config_file = config_dir / 'config.json'
754
838
 
755
- # Load existing profiles
756
- if config_file.exists() and config_file.stat().st_size > 0:
757
- try:
839
+ if show:
840
+ # Show current configuration
841
+ if config_file.exists():
758
842
  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}")
843
+ config = json.load(f)
844
+ if 'api_secret' in config:
845
+ masked_secret = config['api_secret'][:8] + '...' + config['api_secret'][-4:]
846
+ click.echo(f"API Secret: {masked_secret} (configured)")
847
+ else:
848
+ click.echo("API Secret: Not configured")
849
+ else:
850
+ click.echo("No configuration file found")
851
+
852
+ # Check environment variable
853
+ import os
854
+ if os.environ.get('COST_API_SECRET'):
855
+ click.echo("Environment: COST_API_SECRET is set")
856
+ else:
857
+ click.echo("Environment: COST_API_SECRET is not set")
858
+
768
859
  return
769
860
 
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 = {}
861
+ if not api_secret:
862
+ raise click.ClickException(
863
+ "Please provide --api-secret or use --show to view current configuration\n"
864
+ "Example: cc configure --api-secret YOUR_SECRET_KEY"
865
+ )
787
866
 
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
- }
867
+ # Load existing config
868
+ config = {}
869
+ if config_file.exists():
870
+ with open(config_file) as f:
871
+ config = json.load(f)
794
872
 
795
- if session_token:
796
- creds[profile]['aws_session_token'] = session_token
873
+ # Update API secret
874
+ config['api_secret'] = api_secret
797
875
 
798
- # Save credentials with restricted permissions
799
- with open(creds_file, 'w') as f:
800
- json.dump(creds, f, indent=2)
876
+ # Save config
877
+ with open(config_file, 'w') as f:
878
+ json.dump(config, f, indent=2)
801
879
 
802
- # Set file permissions to 600 (owner read/write only)
803
- creds_file.chmod(0o600)
880
+ # Set restrictive permissions (Unix/Mac only - Windows uses different permission model)
881
+ import platform
882
+ if platform.system() != 'Windows':
883
+ os.chmod(config_file, 0o600)
804
884
 
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
-
885
+ masked_secret = api_secret[:8] + '...' + api_secret[-4:]
886
+ click.echo(f"✓ API secret configured: {masked_secret}")
887
+ click.echo(f"\nYou can now run: cc calculate --profile PROFILE_NAME")
888
+ click.echo(f"\nNote: Profiles are stored in DynamoDB and accessed via the API.")
811
889
 
812
890
  @cli.command()
813
891
  @click.option('--profile', required=True, help='Profile name')
@@ -1391,10 +1469,22 @@ def find_account_profile(account_id):
1391
1469
  @click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
1392
1470
  def daily(profile, start_date, end_date, days, service, account, sso, output_json):
1393
1471
  """
1394
- Get daily cost breakdown with granular detail
1472
+ Get daily cost breakdown with granular detail.
1395
1473
 
1396
- Example:
1474
+ Shows day-by-day costs for specific services and accounts, useful for:
1475
+ - Identifying cost spikes on specific dates
1476
+ - Validating daily cost patterns
1477
+ - Calculating precise daily averages
1478
+
1479
+ Examples:
1480
+ # Last 10 days of CloudWatch costs for specific account
1397
1481
  cc daily --profile khoros --days 10 --service AmazonCloudWatch --account 820054669588
1482
+
1483
+ # Custom date range with JSON output for automation
1484
+ cc daily --profile khoros --start-date 2025-10-28 --end-date 2025-11-06 --json
1485
+
1486
+ # Find high-cost days using jq
1487
+ cc daily --profile khoros --days 30 --json | jq '.daily_costs | map(select(.cost > 1000))'
1398
1488
  """
1399
1489
  # Load profile
1400
1490
  config = load_profile(profile)
@@ -1545,11 +1635,25 @@ def daily(profile, start_date, end_date, days, service, account, sso, output_jso
1545
1635
  @click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
1546
1636
  def compare(profile, account, service, before, after, expected_reduction, sso, output_json):
1547
1637
  """
1548
- Compare costs between two periods
1638
+ Compare costs between two periods for validation and analysis.
1549
1639
 
1550
- Example:
1640
+ Perfect for:
1641
+ - Validating cost optimization savings
1642
+ - Before/after migration analysis
1643
+ - Measuring impact of infrastructure changes
1644
+ - Automated savings validation in CI/CD
1645
+
1646
+ Examples:
1647
+ # Validate Datadog migration savings (expect 50% reduction)
1551
1648
  cc compare --profile khoros --account 180770971501 --service AmazonCloudWatch \
1552
1649
  --before "2025-10-28:2025-11-06" --after "2025-11-17:2025-11-26" --expected-reduction 50
1650
+
1651
+ # Compare total costs across all accounts
1652
+ cc compare --profile khoros --before "2025-10-01:2025-10-31" --after "2025-11-01:2025-11-30"
1653
+
1654
+ # JSON output for automated validation
1655
+ cc compare --profile khoros --service EC2 --before "2025-10-01:2025-10-07" \
1656
+ --after "2025-11-08:2025-11-14" --json | jq '.comparison.met_expectation'
1553
1657
  """
1554
1658
  # Load profile
1555
1659
  config = load_profile(profile)
@@ -1731,11 +1835,27 @@ def compare(profile, account, service, before, after, expected_reduction, sso, o
1731
1835
  @click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
1732
1836
  def tags(profile, tag_key, tag_value, start_date, end_date, days, sso, output_json):
1733
1837
  """
1734
- Analyze costs by resource tags
1838
+ Analyze costs grouped by resource tags for cost attribution.
1735
1839
 
1736
- Example:
1737
- cc tags --profile khoros --tag-key "datadog:org" --days 30
1738
- cc tags --profile khoros --tag-key "Environment" --tag-value "Production"
1840
+ Useful for:
1841
+ - Cost allocation by team, project, or environment
1842
+ - Identifying untagged resources (cost attribution gaps)
1843
+ - Tracking costs by cost center or department
1844
+ - Validating tagging compliance
1845
+
1846
+ Examples:
1847
+ # See all costs by Environment tag
1848
+ cc tags --profile khoros --tag-key "Environment" --days 30
1849
+
1850
+ # Filter to specific tag value
1851
+ cc tags --profile khoros --tag-key "Team" --tag-value "Platform" --days 30
1852
+
1853
+ # Find top cost centers with JSON output
1854
+ cc tags --profile khoros --tag-key "CostCenter" --days 30 --json | \
1855
+ jq '.tag_costs | sort_by(-.cost) | .[:5]'
1856
+
1857
+ # Identify untagged resources (look for empty tag values)
1858
+ cc tags --profile khoros --tag-key "Owner" --days 7
1739
1859
  """
1740
1860
  # Load profile
1741
1861
  config = load_profile(profile)
@@ -1971,5 +2091,238 @@ def query(profile, query, database, output_bucket, sso):
1971
2091
  raise click.ClickException(f"Query failed: {e}")
1972
2092
 
1973
2093
 
2094
+ @cli.group()
2095
+ def exclusions():
2096
+ """
2097
+ Manage cost exclusions (services/types to exclude from calculations).
2098
+
2099
+ Exclusions are stored in DynamoDB and apply globally or per-profile.
2100
+ Common exclusions: Tax, Support, OCBLateFee, Refunds, Credits
2101
+ """
2102
+ pass
2103
+
2104
+
2105
+ @exclusions.command('show')
2106
+ @click.option('--profile', help='Show profile-specific exclusions (default: global)')
2107
+ @click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
2108
+ def show_exclusions(profile, output_json):
2109
+ """Show current exclusions configuration"""
2110
+ import requests
2111
+
2112
+ api_secret = get_api_secret()
2113
+ if not api_secret:
2114
+ raise click.ClickException(
2115
+ "No API secret configured.\n"
2116
+ "Run: cc configure --api-secret YOUR_SECRET"
2117
+ )
2118
+
2119
+ try:
2120
+ response = requests.post(
2121
+ PROFILES_API_URL,
2122
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
2123
+ json={'operation': 'get_exclusions', 'profile_name': profile},
2124
+ timeout=10
2125
+ )
2126
+
2127
+ if response.status_code == 200:
2128
+ exclusions_data = response.json().get('exclusions', {})
2129
+
2130
+ if output_json:
2131
+ click.echo(json.dumps(exclusions_data, indent=2))
2132
+ else:
2133
+ scope = profile if profile else "global"
2134
+ click.echo(f"Exclusions ({scope}):")
2135
+ click.echo("")
2136
+
2137
+ if exclusions_data.get('record_types'):
2138
+ click.echo("Record Types:")
2139
+ for rt in exclusions_data['record_types']:
2140
+ click.echo(f" - {rt}")
2141
+ click.echo("")
2142
+
2143
+ if exclusions_data.get('services'):
2144
+ click.echo("Services:")
2145
+ for svc in exclusions_data['services']:
2146
+ click.echo(f" - {svc}")
2147
+ click.echo("")
2148
+
2149
+ if exclusions_data.get('line_item_types'):
2150
+ click.echo("Line Item Types:")
2151
+ for lit in exclusions_data['line_item_types']:
2152
+ click.echo(f" - {lit}")
2153
+ click.echo("")
2154
+
2155
+ if exclusions_data.get('usage_types'):
2156
+ click.echo("Usage Types:")
2157
+ for ut in exclusions_data['usage_types']:
2158
+ click.echo(f" - {ut}")
2159
+ else:
2160
+ raise click.ClickException(f"Failed to fetch exclusions: {response.status_code}")
2161
+
2162
+ except requests.exceptions.RequestException as e:
2163
+ raise click.ClickException(f"API request failed: {e}")
2164
+
2165
+
2166
+ @exclusions.command('add')
2167
+ @click.option('--record-type', help='Add record type exclusion (e.g., Tax, Support)')
2168
+ @click.option('--service', help='Add service exclusion (e.g., OCBLateFee)')
2169
+ @click.option('--line-item-type', help='Add line item type exclusion (e.g., Refund, Credit)')
2170
+ @click.option('--usage-type', help='Add usage type exclusion')
2171
+ @click.option('--profile', help='Add to profile-specific exclusions (default: global)')
2172
+ def add_exclusion(record_type, service, line_item_type, usage_type, profile):
2173
+ """Add an exclusion"""
2174
+ import requests
2175
+
2176
+ if not any([record_type, service, line_item_type, usage_type]):
2177
+ raise click.ClickException(
2178
+ "Must specify at least one of: --record-type, --service, --line-item-type, --usage-type"
2179
+ )
2180
+
2181
+ api_secret = get_api_secret()
2182
+ if not api_secret:
2183
+ raise click.ClickException(
2184
+ "No API secret configured.\n"
2185
+ "Run: cc configure --api-secret YOUR_SECRET"
2186
+ )
2187
+
2188
+ # Get current exclusions
2189
+ try:
2190
+ response = requests.post(
2191
+ PROFILES_API_URL,
2192
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
2193
+ json={'operation': 'get_exclusions', 'profile_name': profile},
2194
+ timeout=10
2195
+ )
2196
+
2197
+ if response.status_code == 200:
2198
+ exclusions_data = response.json().get('exclusions', {})
2199
+ else:
2200
+ exclusions_data = {
2201
+ 'record_types': [],
2202
+ 'services': [],
2203
+ 'line_item_types': [],
2204
+ 'usage_types': []
2205
+ }
2206
+
2207
+ # Add new exclusions
2208
+ if record_type and record_type not in exclusions_data.get('record_types', []):
2209
+ exclusions_data.setdefault('record_types', []).append(record_type)
2210
+
2211
+ if service and service not in exclusions_data.get('services', []):
2212
+ exclusions_data.setdefault('services', []).append(service)
2213
+
2214
+ if line_item_type and line_item_type not in exclusions_data.get('line_item_types', []):
2215
+ exclusions_data.setdefault('line_item_types', []).append(line_item_type)
2216
+
2217
+ if usage_type and usage_type not in exclusions_data.get('usage_types', []):
2218
+ exclusions_data.setdefault('usage_types', []).append(usage_type)
2219
+
2220
+ # Update exclusions
2221
+ update_response = requests.post(
2222
+ PROFILES_API_URL,
2223
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
2224
+ json={
2225
+ 'operation': 'update_exclusions',
2226
+ 'profile_name': profile,
2227
+ 'record_types': exclusions_data.get('record_types', []),
2228
+ 'services': exclusions_data.get('services', []),
2229
+ 'line_item_types': exclusions_data.get('line_item_types', []),
2230
+ 'usage_types': exclusions_data.get('usage_types', [])
2231
+ },
2232
+ timeout=10
2233
+ )
2234
+
2235
+ if update_response.status_code == 200:
2236
+ scope = profile if profile else "global"
2237
+ click.echo(f"✓ Exclusion added to {scope} config")
2238
+ if record_type:
2239
+ click.echo(f" Record Type: {record_type}")
2240
+ if service:
2241
+ click.echo(f" Service: {service}")
2242
+ if line_item_type:
2243
+ click.echo(f" Line Item Type: {line_item_type}")
2244
+ if usage_type:
2245
+ click.echo(f" Usage Type: {usage_type}")
2246
+ else:
2247
+ raise click.ClickException(f"Failed to update exclusions: {update_response.status_code}")
2248
+
2249
+ except requests.exceptions.RequestException as e:
2250
+ raise click.ClickException(f"API request failed: {e}")
2251
+
2252
+
2253
+ @exclusions.command('remove')
2254
+ @click.option('--record-type', help='Remove record type exclusion')
2255
+ @click.option('--service', help='Remove service exclusion')
2256
+ @click.option('--line-item-type', help='Remove line item type exclusion')
2257
+ @click.option('--usage-type', help='Remove usage type exclusion')
2258
+ @click.option('--profile', help='Remove from profile-specific exclusions (default: global)')
2259
+ def remove_exclusion(record_type, service, line_item_type, usage_type, profile):
2260
+ """Remove an exclusion"""
2261
+ import requests
2262
+
2263
+ if not any([record_type, service, line_item_type, usage_type]):
2264
+ raise click.ClickException(
2265
+ "Must specify at least one of: --record-type, --service, --line-item-type, --usage-type"
2266
+ )
2267
+
2268
+ api_secret = get_api_secret()
2269
+ if not api_secret:
2270
+ raise click.ClickException(
2271
+ "No API secret configured.\n"
2272
+ "Run: cc configure --api-secret YOUR_SECRET"
2273
+ )
2274
+
2275
+ # Get current exclusions
2276
+ try:
2277
+ response = requests.post(
2278
+ PROFILES_API_URL,
2279
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
2280
+ json={'operation': 'get_exclusions', 'profile_name': profile},
2281
+ timeout=10
2282
+ )
2283
+
2284
+ if response.status_code == 200:
2285
+ exclusions_data = response.json().get('exclusions', {})
2286
+ else:
2287
+ raise click.ClickException("No exclusions found")
2288
+
2289
+ # Remove exclusions
2290
+ if record_type and record_type in exclusions_data.get('record_types', []):
2291
+ exclusions_data['record_types'].remove(record_type)
2292
+
2293
+ if service and service in exclusions_data.get('services', []):
2294
+ exclusions_data['services'].remove(service)
2295
+
2296
+ if line_item_type and line_item_type in exclusions_data.get('line_item_types', []):
2297
+ exclusions_data['line_item_types'].remove(line_item_type)
2298
+
2299
+ if usage_type and usage_type in exclusions_data.get('usage_types', []):
2300
+ exclusions_data['usage_types'].remove(usage_type)
2301
+
2302
+ # Update exclusions
2303
+ update_response = requests.post(
2304
+ PROFILES_API_URL,
2305
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
2306
+ json={
2307
+ 'operation': 'update_exclusions',
2308
+ 'profile_name': profile,
2309
+ 'record_types': exclusions_data.get('record_types', []),
2310
+ 'services': exclusions_data.get('services', []),
2311
+ 'line_item_types': exclusions_data.get('line_item_types', []),
2312
+ 'usage_types': exclusions_data.get('usage_types', [])
2313
+ },
2314
+ timeout=10
2315
+ )
2316
+
2317
+ if update_response.status_code == 200:
2318
+ scope = profile if profile else "global"
2319
+ click.echo(f"✓ Exclusion removed from {scope} config")
2320
+ else:
2321
+ raise click.ClickException(f"Failed to update exclusions: {update_response.status_code}")
2322
+
2323
+ except requests.exceptions.RequestException as e:
2324
+ raise click.ClickException(f"API request failed: {e}")
2325
+
2326
+
1974
2327
  if __name__ == '__main__':
1975
2328
  cli()
@@ -227,8 +227,11 @@ def execute_profile_operation(operation, profile_name=None, accounts=None, descr
227
227
 
228
228
  api_secret = os.environ.get('COST_API_SECRET', '')
229
229
 
230
- # Use profiles endpoint (hardcoded URL)
231
- url = 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/'
230
+ # Use profiles endpoint (can be overridden via environment variable)
231
+ url = os.environ.get(
232
+ 'COST_CALCULATOR_PROFILES_URL',
233
+ 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/'
234
+ )
232
235
 
233
236
  payload = {'operation': operation}
234
237
  if profile_name:
@@ -5,7 +5,7 @@ with open("README.md", "r", encoding="utf-8") as fh:
5
5
 
6
6
  setup(
7
7
  name='aws-cost-calculator-cli',
8
- version='1.11.0',
8
+ version='2.1.0',
9
9
  packages=['cost_calculator'],
10
10
  install_requires=[
11
11
  'click>=8.0.0',