From 395f95f6fbb4b5a78415d863ce5efc23a361f116 Mon Sep 17 00:00:00 2001 From: Stefanie Plieschnegger Date: Thu, 22 Dec 2022 17:57:38 +0100 Subject: [PATCH 1/2] add store backend to logs, add tagging for resources --- localstack/services/logs/models.py | 24 +++++ localstack/services/logs/provider.py | 106 ++++++++++++++++++++++ tests/integration/test_logs.py | 55 +++++++++-- tests/integration/test_logs.snapshot.json | 78 ++++++++++++++++ 4 files changed, 255 insertions(+), 8 deletions(-) create mode 100644 localstack/services/logs/models.py diff --git a/localstack/services/logs/models.py b/localstack/services/logs/models.py new file mode 100644 index 0000000000000..5d44d00323eb4 --- /dev/null +++ b/localstack/services/logs/models.py @@ -0,0 +1,24 @@ +from typing import Dict + +from moto.logs.models import LogsBackend as MotoLogsBackend +from moto.logs.models import logs_backends as moto_logs_backend + +from localstack.constants import DEFAULT_AWS_ACCOUNT_ID +from localstack.services.stores import AccountRegionBundle, BaseStore, CrossRegionAttribute +from localstack.utils.aws import aws_stack + + +def get_moto_logs_backend(account_id: str = None, region_name: str = None) -> MotoLogsBackend: + account_id = account_id or DEFAULT_AWS_ACCOUNT_ID + region_name = region_name or aws_stack.get_region() + + return moto_logs_backend[account_id][region_name] + + +class LogsStore(BaseStore): + + # maps resource ARN to tags + TAGS: Dict[str, Dict[str, str]] = CrossRegionAttribute(default=dict) + + +logs_stores = AccountRegionBundle("logs", LogsStore) diff --git a/localstack/services/logs/provider.py b/localstack/services/logs/provider.py index 4243b815d5220..e5d65aa4259fd 100644 --- a/localstack/services/logs/provider.py +++ b/localstack/services/logs/provider.py @@ -16,13 +16,21 @@ from localstack.aws.accounts import get_aws_account_id from localstack.aws.api import RequestContext from localstack.aws.api.logs import ( + AmazonResourceName, InputLogEvents, + KmsKeyId, + ListTagsForResourceResponse, + ListTagsLogGroupResponse, LogGroupName, LogsApi, LogStreamName, PutLogEventsResponse, SequenceToken, + TagKeyList, + TagList, + Tags, ) +from localstack.services.logs.models import get_moto_logs_backend, logs_stores from localstack.services.moto import call_moto from localstack.services.plugins import ServiceLifecycleHook from localstack.utils.aws import arns, aws_stack @@ -72,6 +80,104 @@ def put_log_events( ) return call_moto(context) + def create_log_group( + self, + context: RequestContext, + log_group_name: LogGroupName, + kms_key_id: KmsKeyId = None, + tags: Tags = None, + ) -> None: + call_moto(context) + if tags: + resource_arn = arns.log_group_arn( + group_name=log_group_name, account_id=context.account_id, region_name=context.region + ) + store = logs_stores[context.account_id][context.region] + store.TAGS.setdefault(resource_arn, {}).update(tags) + + def list_tags_for_resource( + self, context: RequestContext, resource_arn: AmazonResourceName + ) -> ListTagsForResourceResponse: + self._check_resource_arn_tagging(resource_arn) + store = logs_stores[context.account_id][context.region] + tags = store.TAGS.get(resource_arn, {}) + return ListTagsForResourceResponse(tags=tags) + + def list_tags_log_group( + self, context: RequestContext, log_group_name: LogGroupName + ) -> ListTagsLogGroupResponse: + # deprecated implementation, new one: list_tags_for_resource + self._verify_log_group_exists( + group_name=log_group_name, account_id=context.account_id, region_name=context.region + ) + resource_arn = arns.log_group_arn( + group_name=log_group_name, account_id=context.account_id, region_name=context.region + ) + store = logs_stores[context.account_id][context.region] + tags = store.TAGS.get(resource_arn, {}) + return ListTagsLogGroupResponse(tags=tags) + + def untag_resource( + self, context: RequestContext, resource_arn: AmazonResourceName, tag_keys: TagKeyList + ) -> None: + self._check_resource_arn_tagging(resource_arn) + store = logs_stores[context.account_id][context.region] + tags_stored = store.TAGS.get(resource_arn, {}) + for tag in tag_keys: + tags_stored.pop(tag, None) + + def untag_log_group( + self, context: RequestContext, log_group_name: LogGroupName, tags: TagList + ) -> None: + # deprecated implementation -> new one: untag_resource + self._verify_log_group_exists( + group_name=log_group_name, account_id=context.account_id, region_name=context.region + ) + resource_arn = arns.log_group_arn( + group_name=log_group_name, account_id=context.account_id, region_name=context.region + ) + store = logs_stores[context.account_id][context.region] + tags_stored = store.TAGS.get(resource_arn, {}) + for tag in tags: + tags_stored.pop(tag, None) + + def tag_resource( + self, context: RequestContext, resource_arn: AmazonResourceName, tags: Tags + ) -> None: + self._check_resource_arn_tagging(resource_arn) + store = logs_stores[context.account_id][context.region] + store.TAGS.get(resource_arn, {}).update(tags or {}) + + def tag_log_group( + self, context: RequestContext, log_group_name: LogGroupName, tags: Tags + ) -> None: + # deprecated implementation -> new one: tag_resource + self._verify_log_group_exists( + group_name=log_group_name, account_id=context.account_id, region_name=context.region + ) + resource_arn = arns.log_group_arn( + group_name=log_group_name, account_id=context.account_id, region_name=context.region + ) + store = logs_stores[context.account_id][context.region] + store.TAGS.get(resource_arn, {}).update(tags or {}) + + def _verify_log_group_exists(self, group_name: LogGroupName, account_id: str, region_name: str): + store = get_moto_logs_backend(account_id, region_name) + if group_name not in store.groups: + raise ResourceNotFoundException() + + def _check_resource_arn_tagging(self, resource_arn): + service = arns.extract_service_from_arn(resource_arn) + region = arns.extract_region_from_arn(resource_arn) + account = arns.extract_account_id_from_arn(resource_arn) + + # AWS currently only supports tagging for Log Group and Destinations + # LS: we only verify if log group exists, and create tags for other resources + if service.lower().startswith("log-group:"): + self._verify_log_group_exists( + service.split(":")[-1], account_id=account, region_name=region + ) + def get_pattern_matcher(pattern: str) -> Callable[[str, Dict], bool]: """Returns a pattern matcher. Can be patched by plugins to return a more sophisticated pattern matcher.""" diff --git a/tests/integration/test_logs.py b/tests/integration/test_logs.py index 08231e3811607..7bce5641fd571 100644 --- a/tests/integration/test_logs.py +++ b/tests/integration/test_logs.py @@ -88,17 +88,56 @@ def test_create_and_delete_log_group(self, logs_client): ) assert len(log_groups_after) == len(log_groups_before) - def test_list_tags_log_group(self, logs_client): + @pytest.mark.aws_validated + def test_list_tags_log_group(self, logs_client, snapshot): test_name = f"test-log-group-{short_uid()}" - logs_client.create_log_group(logGroupName=test_name, tags={"env": "testing1"}) + try: + logs_client.create_log_group(logGroupName=test_name, tags={"env": "testing1"}) + response = logs_client.list_tags_log_group(logGroupName=test_name) + snapshot.match("list_tags_after_create_log_group", response) + + # get group arn, to use the tag-resource api + log_group_arn = logs_client.describe_log_groups(logGroupNamePrefix=test_name)[ + "logGroups" + ][0]["arn"].rstrip(":*") + + # add a tag - new api + logs_client.tag_resource( + resourceArn=log_group_arn, tags={"test1": "val1", "test2": "val2"} + ) - response = logs_client.list_tags_log_group(logGroupName=test_name) - assert response["ResponseMetadata"]["HTTPStatusCode"] == 200 - assert "tags" in response - assert response["tags"]["env"] == "testing1" + response = logs_client.list_tags_log_group(logGroupName=test_name) + response_2 = logs_client.list_tags_for_resource(resourceArn=log_group_arn) - # clean up - logs_client.delete_log_group(logGroupName=test_name) + snapshot.match("list_tags_log_group_after_tag_resource", response) + snapshot.match("list_tags_for_resource_after_tag_resource", response_2) + # values should be the same + assert response["tags"] == response_2["tags"] + + # add a tag - old api + logs_client.tag_log_group(logGroupName=test_name, tags={"test3": "val3"}) + + response = logs_client.list_tags_log_group(logGroupName=test_name) + response_2 = logs_client.list_tags_for_resource(resourceArn=log_group_arn) + + snapshot.match("list_tags_log_group_after_tag_log_group", response) + snapshot.match("list_tags_for_resource_after_tag_log_group", response_2) + assert response["tags"] == response_2["tags"] + + # untag - use both apis + logs_client.untag_log_group(logGroupName=test_name, tags=["test3"]) + logs_client.untag_resource(resourceArn=log_group_arn, tagKeys=["env", "test1"]) + + response = logs_client.list_tags_log_group(logGroupName=test_name) + response_2 = logs_client.list_tags_for_resource(resourceArn=log_group_arn) + snapshot.match("list_tags_log_group_after_untag", response) + snapshot.match("list_tags_for_resource_after_untag", response_2) + + assert response["tags"] == response_2["tags"] + + finally: + # clean up + logs_client.delete_log_group(logGroupName=test_name) def test_create_and_delete_log_stream(self, logs_client, logs_log_group): test_name = f"test-log-stream-{short_uid()}" diff --git a/tests/integration/test_logs.snapshot.json b/tests/integration/test_logs.snapshot.json index c91dad25d41e9..1a05f7c6dafea 100644 --- a/tests/integration/test_logs.snapshot.json +++ b/tests/integration/test_logs.snapshot.json @@ -64,5 +64,83 @@ } ] } + }, + "tests/integration/test_logs.py::TestCloudWatchLogs::test_list_tags_log_group": { + "recorded-date": "22-12-2022, 17:46:54", + "recorded-content": { + "list_tags_after_create_log_group": { + "tags": { + "env": "testing1" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_log_group_after_tag_resource": { + "tags": { + "env": "testing1", + "test1": "val1", + "test2": "val2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_resource_after_tag_resource": { + "tags": { + "env": "testing1", + "test1": "val1", + "test2": "val2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_log_group_after_tag_log_group": { + "tags": { + "env": "testing1", + "test1": "val1", + "test2": "val2", + "test3": "val3" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_resource_after_tag_log_group": { + "tags": { + "env": "testing1", + "test1": "val1", + "test2": "val2", + "test3": "val3" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_log_group_after_untag": { + "tags": { + "test2": "val2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + }, + "list_tags_for_resource_after_untag": { + "tags": { + "test2": "val2" + }, + "ResponseMetadata": { + "HTTPHeaders": {}, + "HTTPStatusCode": 200 + } + } + } } } From e85e4acb6f0e4b5965039f74a8ef1df9fbc4c7e7 Mon Sep 17 00:00:00 2001 From: Stefanie Plieschnegger Date: Fri, 23 Dec 2022 14:49:02 +0100 Subject: [PATCH 2/2] fix NIT - require account_id + region_name for get_moto_logs_backend --- localstack/services/logs/models.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/localstack/services/logs/models.py b/localstack/services/logs/models.py index 5d44d00323eb4..794341a2464d6 100644 --- a/localstack/services/logs/models.py +++ b/localstack/services/logs/models.py @@ -3,15 +3,10 @@ from moto.logs.models import LogsBackend as MotoLogsBackend from moto.logs.models import logs_backends as moto_logs_backend -from localstack.constants import DEFAULT_AWS_ACCOUNT_ID from localstack.services.stores import AccountRegionBundle, BaseStore, CrossRegionAttribute -from localstack.utils.aws import aws_stack -def get_moto_logs_backend(account_id: str = None, region_name: str = None) -> MotoLogsBackend: - account_id = account_id or DEFAULT_AWS_ACCOUNT_ID - region_name = region_name or aws_stack.get_region() - +def get_moto_logs_backend(account_id: str, region_name: str) -> MotoLogsBackend: return moto_logs_backend[account_id][region_name]