diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index a54cb2956..c51d2e8d7 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -735,7 +735,7 @@ ## ce
-18% implemented +21% implemented - [ ] create_anomaly_monitor - [ ] create_anomaly_subscription @@ -747,7 +747,7 @@ - [ ] get_anomalies - [ ] get_anomaly_monitors - [ ] get_anomaly_subscriptions -- [ ] get_cost_and_usage +- [X] get_cost_and_usage - [ ] get_cost_and_usage_with_resources - [ ] get_cost_categories - [ ] get_cost_forecast diff --git a/docs/docs/services/ce.rst b/docs/docs/services/ce.rst index 77d77b532..bd609ebb3 100644 --- a/docs/docs/services/ce.rst +++ b/docs/docs/services/ce.rst @@ -38,7 +38,50 @@ ce - [ ] get_anomalies - [ ] get_anomaly_monitors - [ ] get_anomaly_subscriptions -- [ ] get_cost_and_usage +- [X] get_cost_and_usage + + There is no validation yet on any of the input parameters. + + Cost or usage is not tracked by Moto, so this call will return nothing by default. + + You can use a dedicated API to override this, by configuring a queue of expected results. + + A request to `get_cost_and_usage` will take the first result from that queue, and assign it to the provided parameters. Subsequent requests using the same parameters will return the same result. Other requests using different parameters will take the next result from the queue, or return an empty result if the queue is empty. + + Configure this queue by making an HTTP request to `/moto-api/static/ce/cost-and-usage-results`. An example invocation looks like this: + + .. sourcecode:: python + + result = { + "results": [ + { + "ResultsByTime": [ + { + "TimePeriod": {"Start": "2024-01-01", "End": "2024-01-02"}, + "Total": { + "BlendedCost": {"Amount": "0.0101516483", "Unit": "USD"} + }, + "Groups": [], + "Estimated": False + } + ], + "DimensionValueAttributes": [{"Value": "v", "Attributes": {"a": "b"}}] + }, + { + ... + }, + ] + } + resp = requests.post( + "http://motoapi.amazonaws.com:5000/moto-api/static/ce/cost-and-usage-results", + json=expected_results, + ) + assert resp.status_code == 201 + + ce = boto3.client("ce", region_name="us-east-1") + resp = ce.get_cost_and_usage(...) + + - [ ] get_cost_and_usage_with_resources - [ ] get_cost_categories - [ ] get_cost_forecast diff --git a/moto/ce/models.py b/moto/ce/models.py index 97938e303..add58e3c0 100644 --- a/moto/ce/models.py +++ b/moto/ce/models.py @@ -74,6 +74,8 @@ class CostExplorerBackend(BaseBackend): def __init__(self, region_name: str, account_id: str): super().__init__(region_name, account_id) self.cost_categories: Dict[str, CostCategoryDefinition] = dict() + self.cost_usage_results_queue: List[Dict[str, Any]] = [] + self.cost_usage_results: Dict[str, Dict[str, Any]] = {} self.tagger = TaggingService() def create_cost_category_definition( @@ -154,6 +156,57 @@ class CostExplorerBackend(BaseBackend): def untag_resource(self, resource_arn: str, tag_keys: List[str]) -> None: self.tagger.untag_resource_using_names(resource_arn, tag_keys) + def get_cost_and_usage(self, body: str) -> Dict[str, Any]: + """ + There is no validation yet on any of the input parameters. + + Cost or usage is not tracked by Moto, so this call will return nothing by default. + + You can use a dedicated API to override this, by configuring a queue of expected results. + + A request to `get_cost_and_usage` will take the first result from that queue, and assign it to the provided parameters. Subsequent requests using the same parameters will return the same result. Other requests using different parameters will take the next result from the queue, or return an empty result if the queue is empty. + + Configure this queue by making an HTTP request to `/moto-api/static/ce/cost-and-usage-results`. An example invocation looks like this: + + .. sourcecode:: python + + result = { + "results": [ + { + "ResultsByTime": [ + { + "TimePeriod": {"Start": "2024-01-01", "End": "2024-01-02"}, + "Total": { + "BlendedCost": {"Amount": "0.0101516483", "Unit": "USD"} + }, + "Groups": [], + "Estimated": False + } + ], + "DimensionValueAttributes": [{"Value": "v", "Attributes": {"a": "b"}}] + }, + { + ... + }, + ] + } + resp = requests.post( + "http://motoapi.amazonaws.com:5000/moto-api/static/ce/cost-and-usage-results", + json=expected_results, + ) + assert resp.status_code == 201 + + ce = boto3.client("ce", region_name="us-east-1") + resp = ce.get_cost_and_usage(...) + """ + default_result: Dict[str, Any] = { + "ResultsByTime": [], + "DimensionValueAttributes": [], + } + if body not in self.cost_usage_results and self.cost_usage_results_queue: + self.cost_usage_results[body] = self.cost_usage_results_queue.pop(0) + return self.cost_usage_results.get(body, default_result) + ce_backends = BackendDict( CostExplorerBackend, "ce", use_boto3_regions=False, additional_regions=["global"] diff --git a/moto/ce/responses.py b/moto/ce/responses.py index 45770478c..9ade12190 100644 --- a/moto/ce/responses.py +++ b/moto/ce/responses.py @@ -102,3 +102,7 @@ class CostExplorerResponse(BaseResponse): tag_names = params.get("ResourceTagKeys") self.ce_backend.untag_resource(resource_arn, tag_names) return json.dumps({}) + + def get_cost_and_usage(self) -> str: + resp = self.ce_backend.get_cost_and_usage(self.body) + return json.dumps(resp) diff --git a/moto/moto_api/_internal/models.py b/moto/moto_api/_internal/models.py index b67e9533f..28f1fdc03 100644 --- a/moto/moto_api/_internal/models.py +++ b/moto/moto_api/_internal/models.py @@ -42,6 +42,12 @@ class MotoAPIBackend(BaseBackend): results = QueryResults(rows=rows, column_info=column_info) backend.query_results_queue.append(results) + def set_ce_cost_usage(self, result: Dict[str, Any], account_id: str) -> None: + from moto.ce.models import ce_backends + + backend = ce_backends[account_id]["global"] + backend.cost_usage_results_queue.append(result) + def set_sagemaker_result( self, body: str, diff --git a/moto/moto_api/_internal/responses.py b/moto/moto_api/_internal/responses.py index 8302d0077..66c0bcf54 100644 --- a/moto/moto_api/_internal/responses.py +++ b/moto/moto_api/_internal/responses.py @@ -168,6 +168,23 @@ class MotoAPIResponse(BaseResponse): ) return 201, {}, "" + def set_ce_cost_usage_result( + self, + request: Any, + full_url: str, # pylint: disable=unused-argument + headers: Any, + ) -> TYPE_RESPONSE: + from .models import moto_api_backend + + request_body_size = int(headers["Content-Length"]) + body = request.environ["wsgi.input"].read(request_body_size).decode("utf-8") + body = json.loads(body) + account_id = body.get("account_id", DEFAULT_ACCOUNT_ID) + + for result in body.get("results", []): + moto_api_backend.set_ce_cost_usage(result=result, account_id=account_id) + return 201, {}, "" + def set_sagemaker_result( self, request: Any, diff --git a/moto/moto_api/_internal/urls.py b/moto/moto_api/_internal/urls.py index 987a4278c..d9f0d243d 100644 --- a/moto/moto_api/_internal/urls.py +++ b/moto/moto_api/_internal/urls.py @@ -13,6 +13,7 @@ url_paths = { "{0}/moto-api/reset-auth": response_instance.reset_auth_response, "{0}/moto-api/seed": response_instance.seed, "{0}/moto-api/static/athena/query-results": response_instance.set_athena_result, + "{0}/moto-api/static/ce/cost-and-usage-results": response_instance.set_ce_cost_usage_result, "{0}/moto-api/static/inspector2/findings-results": response_instance.set_inspector2_findings_result, "{0}/moto-api/static/sagemaker/endpoint-results": response_instance.set_sagemaker_result, "{0}/moto-api/static/rds-data/statement-results": response_instance.set_rds_data_result, diff --git a/tests/test_ce/test_ce_cost_and_usage.py b/tests/test_ce/test_ce_cost_and_usage.py new file mode 100644 index 000000000..584d38134 --- /dev/null +++ b/tests/test_ce/test_ce_cost_and_usage.py @@ -0,0 +1,64 @@ +import boto3 +import requests + +from moto import mock_aws, settings + +# See our Development Tips on writing tests for hints on how to write good tests: +# http://docs.getmoto.org/en/latest/docs/contributing/development_tips/tests.html + + +@mock_aws +def test_get_cost_and_usage(): + ce = boto3.client("ce", region_name="eu-west-1") + + resp = ce.get_cost_and_usage( + TimePeriod={"Start": "2024-01-01", "End": "2024-02-01"}, + Granularity="DAILY", + Metrics=["UNBLENDEDCOST"], + ) + + assert resp["DimensionValueAttributes"] == [] + assert resp["ResultsByTime"] == [] + + +@mock_aws +def test_set_query_results(): + base_url = ( + settings.test_server_mode_endpoint() + if settings.TEST_SERVER_MODE + else "http://motoapi.amazonaws.com" + ) + + cost_and_usage_result = { + "results": [ + { + "ResultsByTime": [ + { + "TimePeriod": {"Start": "2024-01-01", "End": "2024-01-02"}, + "Total": { + "BlendedCost": {"Amount": "0.0101516483", "Unit": "USD"} + }, + "Groups": [], + "Estimated": False, + } + ], + "DimensionValueAttributes": [{"Value": "v", "Attributes": {"a": "b"}}], + } + ], + } + resp = requests.post( + f"{base_url}/moto-api/static/ce/cost-and-usage-results", + json=cost_and_usage_result, + ) + assert resp.status_code == 201 + + ce = boto3.client("ce", region_name="us-west-1") + + resp = ce.get_cost_and_usage( + TimePeriod={"Start": "2024-01-01", "End": "2024-02-01"}, + Granularity="DAILY", + Metrics=["BLENDEDCOST", "AMORTIZEDCOST"], + ) + + resp.pop("ResponseMetadata") + assert resp == cost_and_usage_result["results"][0]