awslabs.cost-explorer-mcp-server 0.0.2__tar.gz → 0.0.5__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.
- awslabs_cost_explorer_mcp_server-0.0.5/CHANGELOG.md +27 -0
- {awslabs_cost_explorer_mcp_server-0.0.2 → awslabs_cost_explorer_mcp_server-0.0.5}/Dockerfile +11 -8
- {awslabs_cost_explorer_mcp_server-0.0.2 → awslabs_cost_explorer_mcp_server-0.0.5}/PKG-INFO +6 -2
- {awslabs_cost_explorer_mcp_server-0.0.2 → awslabs_cost_explorer_mcp_server-0.0.5}/README.md +3 -0
- awslabs_cost_explorer_mcp_server-0.0.5/awslabs/__init__.py +17 -0
- awslabs_cost_explorer_mcp_server-0.0.5/awslabs/cost_explorer_mcp_server/__init__.py +20 -0
- awslabs_cost_explorer_mcp_server-0.0.5/awslabs/cost_explorer_mcp_server/helpers.py +389 -0
- awslabs_cost_explorer_mcp_server-0.0.5/awslabs/cost_explorer_mcp_server/server.py +517 -0
- awslabs_cost_explorer_mcp_server-0.0.5/docker-healthcheck.sh +26 -0
- {awslabs_cost_explorer_mcp_server-0.0.2 → awslabs_cost_explorer_mcp_server-0.0.5}/pyproject.toml +25 -40
- awslabs_cost_explorer_mcp_server-0.0.5/tests/__init__.py +15 -0
- awslabs_cost_explorer_mcp_server-0.0.5/tests/conftest.py +150 -0
- awslabs_cost_explorer_mcp_server-0.0.5/tests/test_helpers.py +919 -0
- awslabs_cost_explorer_mcp_server-0.0.5/tests/test_server.py +1378 -0
- {awslabs_cost_explorer_mcp_server-0.0.2 → awslabs_cost_explorer_mcp_server-0.0.5}/uv.lock +392 -184
- awslabs_cost_explorer_mcp_server-0.0.2/.pre-commit-config.yaml +0 -19
- awslabs_cost_explorer_mcp_server-0.0.2/CHANGELOG.md +0 -11
- awslabs_cost_explorer_mcp_server-0.0.2/awslabs/__init__.py +0 -3
- awslabs_cost_explorer_mcp_server-0.0.2/awslabs/cost_explorer_mcp_server/__init__.py +0 -6
- awslabs_cost_explorer_mcp_server-0.0.2/awslabs/cost_explorer_mcp_server/helpers.py +0 -259
- awslabs_cost_explorer_mcp_server-0.0.2/awslabs/cost_explorer_mcp_server/server.py +0 -476
- awslabs_cost_explorer_mcp_server-0.0.2/docker-healthcheck.sh +0 -12
- awslabs_cost_explorer_mcp_server-0.0.2/tests/__init__.py +0 -1
- awslabs_cost_explorer_mcp_server-0.0.2/tests/conftest.py +0 -93
- awslabs_cost_explorer_mcp_server-0.0.2/tests/test_helpers.py +0 -282
- awslabs_cost_explorer_mcp_server-0.0.2/tests/test_server.py +0 -427
- {awslabs_cost_explorer_mcp_server-0.0.2 → awslabs_cost_explorer_mcp_server-0.0.5}/.gitignore +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.2 → awslabs_cost_explorer_mcp_server-0.0.5}/.python-version +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.2 → awslabs_cost_explorer_mcp_server-0.0.5}/LICENSE +0 -0
- {awslabs_cost_explorer_mcp_server-0.0.2 → awslabs_cost_explorer_mcp_server-0.0.5}/NOTICE +0 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.5.0] - 2025-06-16
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Enhanced documentation for UsageQuantity metric with specific filtering requirements
|
|
12
|
+
- Dynamic labeling for cost metrics based on grouping dimension (e.g., "Region Total" vs "Service Total")
|
|
13
|
+
- Metadata support for usage metrics including grouped_by, metric type, and period information
|
|
14
|
+
- Optimized JSON serialization that only runs stringify_keys when needed
|
|
15
|
+
- Added Match_Option Validation
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
- Usage metrics now return clean nested structure instead of complex pandas tuples
|
|
19
|
+
- Old: `{("2025-01-01", "Amount"): {"EC2": 100}, ("2025-01-01", "Unit"): {"EC2": "Hours"}}`
|
|
20
|
+
- New: `{"GroupedUsage": {"2025-01-01": {"EC2": {"amount": 100, "unit": "Hours"}}}}`
|
|
21
|
+
- Improved UsageQuantity documentation to emphasize need for SERVICE + USAGE_TYPE filtering
|
|
22
|
+
- Enhanced error handling for metric data processing
|
|
23
|
+
|
|
24
|
+
## [0.1.0] - 2025-06-01
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- Initial release of the Cost Explorer MCP Server
|
{awslabs_cost_explorer_mcp_server-0.0.2 → awslabs_cost_explorer_mcp_server-0.0.5}/Dockerfile
RENAMED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
2
|
#
|
|
3
|
-
# Licensed under the Apache License, Version 2.0 (the "License")
|
|
4
|
-
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
5
6
|
#
|
|
6
|
-
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
7
8
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
11
14
|
|
|
12
|
-
FROM public.ecr.aws/sam/build-python3.10@sha256:
|
|
15
|
+
FROM public.ecr.aws/sam/build-python3.10@sha256:d821662474d65f3cf2fc97dba2fa807a3adb580d02895fc4545527812550ea65 AS uv
|
|
13
16
|
|
|
14
17
|
# Install the project into `/app`
|
|
15
18
|
WORKDIR /app
|
|
@@ -43,7 +46,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|
|
43
46
|
# Make the directory just in case it doesn't exist
|
|
44
47
|
RUN mkdir -p /root/.local
|
|
45
48
|
|
|
46
|
-
FROM public.ecr.aws/sam/build-python3.10@sha256:
|
|
49
|
+
FROM public.ecr.aws/sam/build-python3.10@sha256:d821662474d65f3cf2fc97dba2fa807a3adb580d02895fc4545527812550ea65
|
|
47
50
|
|
|
48
51
|
# Place executables in the environment at the front of the path and include other binaries
|
|
49
52
|
ENV PATH="/app/.venv/bin:$PATH:/usr/sbin"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: awslabs.cost-explorer-mcp-server
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.5
|
|
4
4
|
Summary: MCP server for analyzing AWS costs and usage data through the AWS Cost Explorer API
|
|
5
5
|
Project-URL: Homepage, https://awslabs.github.io/mcp/
|
|
6
6
|
Project-URL: Documentation, https://awslabs.github.io/mcp/servers/cost-explorer-mcp-server/
|
|
@@ -22,7 +22,8 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.13
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
24
|
Requires-Dist: boto3>=1.36.20
|
|
25
|
-
Requires-Dist:
|
|
25
|
+
Requires-Dist: loguru>=0.7.0
|
|
26
|
+
Requires-Dist: mcp[cli]>=1.6.0
|
|
26
27
|
Requires-Dist: pandas>=2.2.3
|
|
27
28
|
Requires-Dist: pydantic>=2.10.6
|
|
28
29
|
Description-Content-Type: text/markdown
|
|
@@ -63,6 +64,8 @@ MCP server for analyzing AWS costs and usage data through the AWS Cost Explorer
|
|
|
63
64
|
|
|
64
65
|
## Installation
|
|
65
66
|
|
|
67
|
+
[](https://cursor.com/install-mcp?name=awslabs.cost-explorer-mcp-server&config=eyJjb21tYW5kIjoidXZ4IGF3c2xhYnMuY29zdC1leHBsb3Jlci1tY3Atc2VydmVyQGxhdGVzdCIsImVudiI6eyJGQVNUTUNQX0xPR19MRVZFTCI6IkVSUk9SIiwiQVdTX1BST0ZJTEUiOiJ5b3VyLWF3cy1wcm9maWxlIn0sImRpc2FibGVkIjpmYWxzZSwiYXV0b0FwcHJvdmUiOltdfQ%3D%3D)
|
|
68
|
+
|
|
66
69
|
Here are some ways you can work with MCP across AWS, and we'll be adding support to more products including Amazon Q Developer CLI soon: (e.g. for Amazon Q Developer CLI MCP, `~/.aws/amazonq/mcp.json`):
|
|
67
70
|
|
|
68
71
|
```json
|
|
@@ -127,6 +130,7 @@ The MCP server uses the AWS profile specified in the `AWS_PROFILE` environment v
|
|
|
127
130
|
```
|
|
128
131
|
|
|
129
132
|
Make sure the AWS profile has permissions to access the AWS Cost Explorer API. The MCP server creates a boto3 session using the specified profile to authenticate with AWS services. Your AWS IAM credentials remain on your local machine and are strictly used for accessing AWS services.
|
|
133
|
+
|
|
130
134
|
## Cost Considerations
|
|
131
135
|
|
|
132
136
|
**Important:** AWS Cost Explorer API incurs charges on a per-request basis. Each API call made by this MCP server will result in charges to your AWS account.
|
|
@@ -34,6 +34,8 @@ MCP server for analyzing AWS costs and usage data through the AWS Cost Explorer
|
|
|
34
34
|
|
|
35
35
|
## Installation
|
|
36
36
|
|
|
37
|
+
[](https://cursor.com/install-mcp?name=awslabs.cost-explorer-mcp-server&config=eyJjb21tYW5kIjoidXZ4IGF3c2xhYnMuY29zdC1leHBsb3Jlci1tY3Atc2VydmVyQGxhdGVzdCIsImVudiI6eyJGQVNUTUNQX0xPR19MRVZFTCI6IkVSUk9SIiwiQVdTX1BST0ZJTEUiOiJ5b3VyLWF3cy1wcm9maWxlIn0sImRpc2FibGVkIjpmYWxzZSwiYXV0b0FwcHJvdmUiOltdfQ%3D%3D)
|
|
38
|
+
|
|
37
39
|
Here are some ways you can work with MCP across AWS, and we'll be adding support to more products including Amazon Q Developer CLI soon: (e.g. for Amazon Q Developer CLI MCP, `~/.aws/amazonq/mcp.json`):
|
|
38
40
|
|
|
39
41
|
```json
|
|
@@ -98,6 +100,7 @@ The MCP server uses the AWS profile specified in the `AWS_PROFILE` environment v
|
|
|
98
100
|
```
|
|
99
101
|
|
|
100
102
|
Make sure the AWS profile has permissions to access the AWS Cost Explorer API. The MCP server creates a boto3 session using the specified profile to authenticate with AWS services. Your AWS IAM credentials remain on your local machine and are strictly used for accessing AWS services.
|
|
103
|
+
|
|
101
104
|
## Cost Considerations
|
|
102
105
|
|
|
103
106
|
**Important:** AWS Cost Explorer API incurs charges on a per-request basis. Each API call made by this MCP server will result in charges to your AWS account.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""
|
|
16
|
+
AWS Labs Cost Explorer MCP Server package.
|
|
17
|
+
"""
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Cost Explorer MCP Server module.
|
|
16
|
+
|
|
17
|
+
This module provides MCP tools for analyzing AWS costs and usage data through the AWS Cost Explorer API.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
__version__ = '0.0.0'
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Helper functions for the Cost Explorer MCP server."""
|
|
16
|
+
|
|
17
|
+
import boto3
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
from datetime import datetime
|
|
22
|
+
from loguru import logger
|
|
23
|
+
from typing import Any, Dict, Optional, Tuple
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Configure Loguru logging
|
|
27
|
+
logger.remove()
|
|
28
|
+
logger.add(sys.stderr, level=os.getenv('FASTMCP_LOG_LEVEL', 'WARNING'))
|
|
29
|
+
|
|
30
|
+
# Global client cache
|
|
31
|
+
_cost_explorer_client = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_cost_explorer_client():
|
|
35
|
+
"""Get Cost Explorer client with proper session management and caching.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
boto3.client: Configured Cost Explorer client (cached after first call)
|
|
39
|
+
"""
|
|
40
|
+
global _cost_explorer_client
|
|
41
|
+
|
|
42
|
+
if _cost_explorer_client is None:
|
|
43
|
+
try:
|
|
44
|
+
# Read environment variables dynamically
|
|
45
|
+
aws_region = os.environ.get('AWS_REGION', 'us-east-1')
|
|
46
|
+
aws_profile = os.environ.get('AWS_PROFILE')
|
|
47
|
+
|
|
48
|
+
if aws_profile:
|
|
49
|
+
_cost_explorer_client = boto3.Session(
|
|
50
|
+
profile_name=aws_profile, region_name=aws_region
|
|
51
|
+
).client('ce')
|
|
52
|
+
else:
|
|
53
|
+
_cost_explorer_client = boto3.Session(region_name=aws_region).client('ce')
|
|
54
|
+
except Exception as e:
|
|
55
|
+
logger.error(f'Error creating Cost Explorer client: {str(e)}')
|
|
56
|
+
raise
|
|
57
|
+
|
|
58
|
+
return _cost_explorer_client
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def validate_date_format(date_str: str) -> Tuple[bool, str]:
|
|
62
|
+
"""Validate that a date string is in YYYY-MM-DD format and is a valid date.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
date_str: The date string to validate
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Tuple of (is_valid, error_message)
|
|
69
|
+
"""
|
|
70
|
+
# Check format with regex
|
|
71
|
+
if not re.match(r'^\d{4}-\d{2}-\d{2}$', date_str):
|
|
72
|
+
return False, f"Date '{date_str}' is not in YYYY-MM-DD format"
|
|
73
|
+
|
|
74
|
+
# Check if it's a valid date
|
|
75
|
+
try:
|
|
76
|
+
datetime.strptime(date_str, '%Y-%m-%d')
|
|
77
|
+
return True, ''
|
|
78
|
+
except ValueError as e:
|
|
79
|
+
return False, f"Invalid date '{date_str}': {str(e)}"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def format_date_for_api(date_str: str, granularity: str) -> str:
|
|
83
|
+
"""Format date string appropriately for AWS Cost Explorer API based on granularity.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
date_str: Date string in YYYY-MM-DD format
|
|
87
|
+
granularity: The granularity (DAILY, MONTHLY, HOURLY)
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Formatted date string appropriate for the API call
|
|
91
|
+
"""
|
|
92
|
+
if granularity.upper() == 'HOURLY':
|
|
93
|
+
# For hourly granularity, AWS expects datetime format
|
|
94
|
+
# Convert YYYY-MM-DD to YYYY-MM-DDTHH:MM:SSZ
|
|
95
|
+
dt = datetime.strptime(date_str, '%Y-%m-%d')
|
|
96
|
+
return dt.strftime('%Y-%m-%dT00:00:00Z')
|
|
97
|
+
else:
|
|
98
|
+
# For DAILY and MONTHLY, use the original date format
|
|
99
|
+
return date_str
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def validate_date_range(
|
|
103
|
+
start_date: str, end_date: str, granularity: Optional[str] = None
|
|
104
|
+
) -> Tuple[bool, str]:
|
|
105
|
+
"""Validate date range with format and logical checks.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
start_date: The start date string in YYYY-MM-DD format
|
|
109
|
+
end_date: The end date string in YYYY-MM-DD format
|
|
110
|
+
granularity: Optional granularity to check specific constraints
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Tuple of (is_valid, error_message)
|
|
114
|
+
"""
|
|
115
|
+
# Validate start date format
|
|
116
|
+
is_valid_start, error_start = validate_date_format(start_date)
|
|
117
|
+
if not is_valid_start:
|
|
118
|
+
return False, error_start
|
|
119
|
+
|
|
120
|
+
# Validate end date format
|
|
121
|
+
is_valid_end, error_end = validate_date_format(end_date)
|
|
122
|
+
if not is_valid_end:
|
|
123
|
+
return False, error_end
|
|
124
|
+
|
|
125
|
+
# Validate date range logic
|
|
126
|
+
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
|
|
127
|
+
end_dt = datetime.strptime(end_date, '%Y-%m-%d')
|
|
128
|
+
if start_dt > end_dt:
|
|
129
|
+
return False, f"Start date '{start_date}' cannot be after end date '{end_date}'"
|
|
130
|
+
|
|
131
|
+
# Validate granularity-specific constraints
|
|
132
|
+
if granularity and granularity.upper() == 'HOURLY':
|
|
133
|
+
# HOURLY granularity supports maximum 14 days
|
|
134
|
+
date_diff = (end_dt - start_dt).days
|
|
135
|
+
if date_diff > 14:
|
|
136
|
+
return (
|
|
137
|
+
False,
|
|
138
|
+
f'HOURLY granularity supports a maximum of 14 days. Current range is {date_diff} days ({start_date} to {end_date}). Please use a shorter date range.',
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
return True, ''
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def get_dimension_values(
|
|
145
|
+
key: str, billing_period_start: str, billing_period_end: str
|
|
146
|
+
) -> Dict[str, Any]:
|
|
147
|
+
"""Get available values for a specific dimension."""
|
|
148
|
+
# Validate date range (no granularity constraint for dimension values)
|
|
149
|
+
is_valid, error_message = validate_date_range(billing_period_start, billing_period_end)
|
|
150
|
+
if not is_valid:
|
|
151
|
+
return {'error': error_message}
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
ce = get_cost_explorer_client()
|
|
155
|
+
response = ce.get_dimension_values(
|
|
156
|
+
TimePeriod={'Start': billing_period_start, 'End': billing_period_end},
|
|
157
|
+
Dimension=key.upper(),
|
|
158
|
+
)
|
|
159
|
+
dimension_values = response['DimensionValues']
|
|
160
|
+
values = [value['Value'] for value in dimension_values]
|
|
161
|
+
return {'dimension': key.upper(), 'values': values}
|
|
162
|
+
except Exception as e:
|
|
163
|
+
logger.error(
|
|
164
|
+
f'Error getting dimension values for {key.upper()} ({billing_period_start} to {billing_period_end}): {e}'
|
|
165
|
+
)
|
|
166
|
+
return {'error': str(e)}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def get_tag_values(
|
|
170
|
+
tag_key: str, billing_period_start: str, billing_period_end: str
|
|
171
|
+
) -> Dict[str, Any]:
|
|
172
|
+
"""Get available values for a specific tag key."""
|
|
173
|
+
# Validate date range (no granularity constraint for tag values)
|
|
174
|
+
is_valid, error_message = validate_date_range(billing_period_start, billing_period_end)
|
|
175
|
+
if not is_valid:
|
|
176
|
+
return {'error': error_message}
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
ce = get_cost_explorer_client()
|
|
180
|
+
response = ce.get_tags(
|
|
181
|
+
TimePeriod={'Start': billing_period_start, 'End': billing_period_end},
|
|
182
|
+
TagKey=tag_key,
|
|
183
|
+
)
|
|
184
|
+
tag_values = response['Tags']
|
|
185
|
+
return {'tag_key': tag_key, 'values': tag_values}
|
|
186
|
+
except Exception as e:
|
|
187
|
+
logger.error(
|
|
188
|
+
f'Error getting tag values for {tag_key} ({billing_period_start} to {billing_period_end}): {e}'
|
|
189
|
+
)
|
|
190
|
+
return {'error': str(e)}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def validate_match_options(match_options: list, filter_type: str) -> Dict[str, Any]:
|
|
194
|
+
"""Validate MatchOptions based on filter type.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
match_options: List of match options to validate
|
|
198
|
+
filter_type: Type of filter ('Dimensions', 'Tags', 'CostCategories')
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Empty dictionary if valid, or an error dictionary
|
|
202
|
+
"""
|
|
203
|
+
if filter_type == 'Dimensions':
|
|
204
|
+
valid_options = ['EQUALS', 'CASE_SENSITIVE']
|
|
205
|
+
elif filter_type in ['Tags', 'CostCategories']:
|
|
206
|
+
valid_options = ['EQUALS', 'ABSENT', 'CASE_SENSITIVE']
|
|
207
|
+
else:
|
|
208
|
+
return {'error': f'Unknown filter type: {filter_type}'}
|
|
209
|
+
|
|
210
|
+
for option in match_options:
|
|
211
|
+
if option not in valid_options:
|
|
212
|
+
return {
|
|
213
|
+
'error': f"Invalid MatchOption '{option}' for {filter_type}. Valid values are: {valid_options}"
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def validate_expression(
|
|
220
|
+
expression: Dict[str, Any], billing_period_start: str, billing_period_end: str
|
|
221
|
+
) -> Dict[str, Any]:
|
|
222
|
+
"""Recursively validate the filter expression.
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
expression: The filter expression to validate
|
|
226
|
+
billing_period_start: Start date of the billing period
|
|
227
|
+
billing_period_end: End date of the billing period
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Empty dictionary if valid, or an error dictionary
|
|
231
|
+
"""
|
|
232
|
+
# Validate date range (no granularity constraint for filter validation)
|
|
233
|
+
is_valid, error_message = validate_date_range(billing_period_start, billing_period_end)
|
|
234
|
+
if not is_valid:
|
|
235
|
+
return {'error': error_message}
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
if 'Dimensions' in expression:
|
|
239
|
+
dimension = expression['Dimensions']
|
|
240
|
+
if (
|
|
241
|
+
'Key' not in dimension
|
|
242
|
+
or 'Values' not in dimension
|
|
243
|
+
or 'MatchOptions' not in dimension
|
|
244
|
+
):
|
|
245
|
+
return {
|
|
246
|
+
'error': 'Dimensions filter must include "Key", "Values", and "MatchOptions".'
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
# Validate MatchOptions for Dimensions
|
|
250
|
+
match_options_result = validate_match_options(dimension['MatchOptions'], 'Dimensions')
|
|
251
|
+
if 'error' in match_options_result:
|
|
252
|
+
return match_options_result
|
|
253
|
+
|
|
254
|
+
dimension_key = dimension['Key']
|
|
255
|
+
dimension_values = dimension['Values']
|
|
256
|
+
valid_values_response = get_dimension_values(
|
|
257
|
+
dimension_key, billing_period_start, billing_period_end
|
|
258
|
+
)
|
|
259
|
+
if 'error' in valid_values_response:
|
|
260
|
+
return {'error': valid_values_response['error']}
|
|
261
|
+
valid_values = valid_values_response['values']
|
|
262
|
+
for value in dimension_values:
|
|
263
|
+
if value not in valid_values:
|
|
264
|
+
return {
|
|
265
|
+
'error': f"Invalid value '{value}' for dimension '{dimension_key}'. Valid values are: {valid_values}"
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if 'Tags' in expression:
|
|
269
|
+
tag = expression['Tags']
|
|
270
|
+
if 'Key' not in tag or 'Values' not in tag or 'MatchOptions' not in tag:
|
|
271
|
+
return {'error': 'Tags filter must include "Key", "Values", and "MatchOptions".'}
|
|
272
|
+
|
|
273
|
+
# Validate MatchOptions for Tags
|
|
274
|
+
match_options_result = validate_match_options(tag['MatchOptions'], 'Tags')
|
|
275
|
+
if 'error' in match_options_result:
|
|
276
|
+
return match_options_result
|
|
277
|
+
|
|
278
|
+
tag_key = tag['Key']
|
|
279
|
+
tag_values = tag['Values']
|
|
280
|
+
valid_tag_values_response = get_tag_values(
|
|
281
|
+
tag_key, billing_period_start, billing_period_end
|
|
282
|
+
)
|
|
283
|
+
if 'error' in valid_tag_values_response:
|
|
284
|
+
return {'error': valid_tag_values_response['error']}
|
|
285
|
+
valid_tag_values = valid_tag_values_response['values']
|
|
286
|
+
for value in tag_values:
|
|
287
|
+
if value not in valid_tag_values:
|
|
288
|
+
return {
|
|
289
|
+
'error': f"Invalid value '{value}' for tag '{tag_key}'. Valid values are: {valid_tag_values}"
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if 'CostCategories' in expression:
|
|
293
|
+
cost_category = expression['CostCategories']
|
|
294
|
+
if (
|
|
295
|
+
'Key' not in cost_category
|
|
296
|
+
or 'Values' not in cost_category
|
|
297
|
+
or 'MatchOptions' not in cost_category
|
|
298
|
+
):
|
|
299
|
+
return {
|
|
300
|
+
'error': 'CostCategories filter must include "Key", "Values", and "MatchOptions".'
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
# Validate MatchOptions for CostCategories
|
|
304
|
+
match_options_result = validate_match_options(
|
|
305
|
+
cost_category['MatchOptions'], 'CostCategories'
|
|
306
|
+
)
|
|
307
|
+
if 'error' in match_options_result:
|
|
308
|
+
return match_options_result
|
|
309
|
+
|
|
310
|
+
logical_operators = ['And', 'Or', 'Not']
|
|
311
|
+
logical_count = sum(1 for op in logical_operators if op in expression)
|
|
312
|
+
|
|
313
|
+
if logical_count > 1:
|
|
314
|
+
return {
|
|
315
|
+
'error': 'Only one logical operator (And, Or, Not) is allowed per expression in filter parameter.'
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if logical_count == 0 and len(expression) > 1:
|
|
319
|
+
return {
|
|
320
|
+
'error': 'Filter parameter with multiple expressions require a logical operator (And, Or, Not).'
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if 'And' in expression:
|
|
324
|
+
if not isinstance(expression['And'], list):
|
|
325
|
+
return {'error': 'And expression must be a list of expressions.'}
|
|
326
|
+
for sub_expression in expression['And']:
|
|
327
|
+
result = validate_expression(
|
|
328
|
+
sub_expression, billing_period_start, billing_period_end
|
|
329
|
+
)
|
|
330
|
+
if 'error' in result:
|
|
331
|
+
return result
|
|
332
|
+
|
|
333
|
+
if 'Or' in expression:
|
|
334
|
+
if not isinstance(expression['Or'], list):
|
|
335
|
+
return {'error': 'Or expression must be a list of expressions.'}
|
|
336
|
+
for sub_expression in expression['Or']:
|
|
337
|
+
result = validate_expression(
|
|
338
|
+
sub_expression, billing_period_start, billing_period_end
|
|
339
|
+
)
|
|
340
|
+
if 'error' in result:
|
|
341
|
+
return result
|
|
342
|
+
|
|
343
|
+
if 'Not' in expression:
|
|
344
|
+
if not isinstance(expression['Not'], dict):
|
|
345
|
+
return {'error': 'Not expression must be a single expression.'}
|
|
346
|
+
result = validate_expression(
|
|
347
|
+
expression['Not'], billing_period_start, billing_period_end
|
|
348
|
+
)
|
|
349
|
+
if 'error' in result:
|
|
350
|
+
return result
|
|
351
|
+
|
|
352
|
+
if not any(
|
|
353
|
+
k in expression for k in ['Dimensions', 'Tags', 'CostCategories', 'And', 'Or', 'Not']
|
|
354
|
+
):
|
|
355
|
+
return {
|
|
356
|
+
'error': 'Filter Expression must include at least one of the following keys: "Dimensions", "Tags", "CostCategories", "And", "Or", "Not".'
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {}
|
|
360
|
+
except Exception as e:
|
|
361
|
+
return {'error': f'Error validating expression: {str(e)}'}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def validate_group_by(group_by: Optional[Dict[str, Any]]) -> Dict[str, Any]:
|
|
365
|
+
"""Validate the group_by parameter.
|
|
366
|
+
|
|
367
|
+
Args:
|
|
368
|
+
group_by: The group_by dictionary to validate
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
Empty dictionary if valid, or an error dictionary
|
|
372
|
+
"""
|
|
373
|
+
try:
|
|
374
|
+
if (
|
|
375
|
+
group_by is None
|
|
376
|
+
or not isinstance(group_by, dict)
|
|
377
|
+
or 'Type' not in group_by
|
|
378
|
+
or 'Key' not in group_by
|
|
379
|
+
):
|
|
380
|
+
return {'error': 'group_by must be a dictionary with "Type" and "Key" keys.'}
|
|
381
|
+
|
|
382
|
+
if group_by['Type'].upper() not in ['DIMENSION', 'TAG', 'COST_CATEGORY']:
|
|
383
|
+
return {
|
|
384
|
+
'error': 'Invalid group Type. Valid types are DIMENSION, TAG, and COST_CATEGORY.'
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {}
|
|
388
|
+
except Exception as e:
|
|
389
|
+
return {'error': f'Error validating group_by: {str(e)}'}
|