Skip to content

Commit dcf088a

Browse files
authored
S3Control: implement Tagging support for S3 Bucket (#13435)
1 parent c5aa22c commit dcf088a

File tree

5 files changed

+524
-4
lines changed

5 files changed

+524
-4
lines changed
Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,104 @@
1-
from localstack.aws.api.s3control import S3ControlApi
1+
from localstack.aws.api import CommonServiceException, RequestContext
2+
from localstack.aws.api.s3control import (
3+
AccountId,
4+
ListTagsForResourceResult,
5+
S3ControlApi,
6+
S3ResourceArn,
7+
TagKeyList,
8+
TagList,
9+
TagResourceResult,
10+
UntagResourceResult,
11+
)
12+
from localstack.aws.forwarder import NotImplementedAvoidFallbackError
13+
from localstack.services.s3.models import s3_stores
14+
from localstack.services.s3control.validation import validate_tags
15+
from localstack.utils.tagging import TaggingService
16+
17+
18+
class NoSuchResource(CommonServiceException):
19+
def __init__(self, message=None):
20+
super().__init__("NoSuchResource", status_code=404, message=message)
221

322

423
class S3ControlProvider(S3ControlApi):
5-
pass
24+
"""
25+
S3Control is a management interface for S3, and can access some of its internals with no public API
26+
This requires us to access the s3 stores directly
27+
"""
28+
29+
@staticmethod
30+
def _get_tagging_service_for_bucket(
31+
resource_arn: S3ResourceArn,
32+
partition: str,
33+
region: str,
34+
account_id: str,
35+
) -> TaggingService:
36+
s3_prefix = f"arn:{partition}:s3:::"
37+
if not resource_arn.startswith(s3_prefix):
38+
# Moto does not support Tagging operations for S3 Control, so we should not forward those operations back
39+
# to it
40+
raise NotImplementedAvoidFallbackError(
41+
"LocalStack only support Bucket tagging operations for S3Control"
42+
)
43+
44+
store = s3_stores[account_id][region]
45+
bucket_name = resource_arn.removeprefix(s3_prefix)
46+
if bucket_name not in store.global_bucket_map:
47+
raise NoSuchResource("The specified resource doesn't exist.")
48+
49+
return store.TAGS
50+
51+
def tag_resource(
52+
self,
53+
context: RequestContext,
54+
account_id: AccountId,
55+
resource_arn: S3ResourceArn,
56+
tags: TagList,
57+
**kwargs,
58+
) -> TagResourceResult:
59+
# currently S3Control only supports tagging buckets
60+
tagging_service = self._get_tagging_service_for_bucket(
61+
resource_arn=resource_arn,
62+
partition=context.partition,
63+
region=context.region,
64+
account_id=account_id,
65+
)
66+
67+
validate_tags(tags=tags)
68+
tagging_service.tag_resource(resource_arn, tags)
69+
70+
return TagResourceResult()
71+
72+
def untag_resource(
73+
self,
74+
context: RequestContext,
75+
account_id: AccountId,
76+
resource_arn: S3ResourceArn,
77+
tag_keys: TagKeyList,
78+
**kwargs,
79+
) -> UntagResourceResult:
80+
# currently S3Control only supports tagging buckets
81+
tagging_service = self._get_tagging_service_for_bucket(
82+
resource_arn=resource_arn,
83+
partition=context.partition,
84+
region=context.region,
85+
account_id=account_id,
86+
)
87+
88+
tagging_service.untag_resource(resource_arn, tag_keys)
89+
90+
return TagResourceResult()
91+
92+
def list_tags_for_resource(
93+
self, context: RequestContext, account_id: AccountId, resource_arn: S3ResourceArn, **kwargs
94+
) -> ListTagsForResourceResult:
95+
# currently S3Control only supports tagging buckets
96+
tagging_service = self._get_tagging_service_for_bucket(
97+
resource_arn=resource_arn,
98+
partition=context.partition,
99+
region=context.region,
100+
account_id=account_id,
101+
)
102+
103+
tags = tagging_service.list_tags_for_resource(resource_arn)
104+
return ListTagsForResourceResult(Tags=tags["Tags"])
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from localstack.aws.api.s3 import InvalidTag
2+
from localstack.aws.api.s3control import Tag, TagList
3+
from localstack.services.s3.exceptions import MalformedXML
4+
from localstack.services.s3.utils import TAG_REGEX
5+
6+
7+
def validate_tags(tags: TagList):
8+
"""
9+
Validate the tags provided. This is the same function as S3, but with different error messages
10+
:param tags: a TagList object
11+
:raises MalformedXML if the object does not conform to the schema
12+
:raises InvalidTag if the tag key or value are outside the set of validations defined by S3 and S3Control
13+
:return: None
14+
"""
15+
keys = set()
16+
for tag in tags:
17+
tag: Tag
18+
if set(tag) != {"Key", "Value"}:
19+
raise MalformedXML()
20+
21+
key = tag["Key"]
22+
value = tag["Value"]
23+
24+
if key is None or value is None:
25+
raise MalformedXML()
26+
27+
if key in keys:
28+
raise InvalidTag(
29+
"There are duplicate tag keys in your request. Remove the duplicate tag keys and try again.",
30+
TagKey=key,
31+
)
32+
33+
if key.startswith("aws:"):
34+
raise InvalidTag(
35+
'User-defined tag keys can\'t start with "aws:". This prefix is reserved for system tags. Remove "aws:" from your tag keys and try again.',
36+
)
37+
38+
if not TAG_REGEX.match(key):
39+
raise InvalidTag(
40+
"This request contains a tag key or value that isn't valid. Valid characters include the following: [a-zA-Z+-=._:/]. Tag keys can contain up to 128 characters. Tag values can contain up to 256 characters.",
41+
TagKey=key,
42+
)
43+
elif not TAG_REGEX.match(value):
44+
raise InvalidTag(
45+
"This request contains a tag key or value that isn't valid. Valid characters include the following: [a-zA-Z+-=._:/]. Tag keys can contain up to 128 characters. Tag values can contain up to 256 characters.",
46+
TagKey=key,
47+
TagValue=value,
48+
)
49+
50+
keys.add(key)

tests/aws/services/s3control/test_s3control.py

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,6 @@
1010
from localstack.utils.strings import short_uid
1111
from localstack.utils.urls import localstack_host
1212

13-
# TODO: this fails in CI, not sure why yet
14-
# s3_control_endpoint = f"http://s3-control.{localstack_host()}"
1513
s3_control_endpoint = f"https://{localstack_host().host_and_port()}"
1614

1715

@@ -421,3 +419,152 @@ def test_access_point_pagination(
421419

422420
list_by_bucket = s3control_client.list_access_points(AccountId=account_id, Bucket=bucket_1)
423421
snapshot.match("list-by-bucket", list_by_bucket)
422+
423+
424+
@markers.snapshot.skip_snapshot_verify(
425+
paths=[
426+
# FIXME: this needs to be updated in the serializer, see https://github.com/localstack/localstack/pull/9730
427+
"$..HostId",
428+
]
429+
)
430+
class TestS3ControlTagging:
431+
@markers.aws.validated
432+
def test_tag_lifecycle(
433+
self, s3_bucket, s3control_client, snapshot, account_id, s3control_snapshot
434+
):
435+
bucket_arn = f"arn:aws:s3:::{s3_bucket}"
436+
437+
list_tags_before = s3control_client.list_tags_for_resource(
438+
AccountId=account_id,
439+
ResourceArn=bucket_arn,
440+
)
441+
snapshot.match("list-tags-before", list_tags_before)
442+
443+
tag_resource = s3control_client.tag_resource(
444+
AccountId=account_id,
445+
ResourceArn=bucket_arn,
446+
Tags=[
447+
{"Key": "key1", "Value": "value1"},
448+
{"Key": "key2", "Value": "value2"},
449+
],
450+
)
451+
snapshot.match("tag-resource", tag_resource)
452+
453+
list_tags_1 = s3control_client.list_tags_for_resource(
454+
AccountId=account_id,
455+
ResourceArn=bucket_arn,
456+
)
457+
snapshot.match("list-tags-1", list_tags_1)
458+
459+
tag_resource_after_update = s3control_client.tag_resource(
460+
AccountId=account_id, ResourceArn=bucket_arn, Tags=[{"Key": "key3", "Value": "value3"}]
461+
)
462+
snapshot.match("tag-resource-after-update", tag_resource_after_update)
463+
464+
list_tags_after_update = s3control_client.list_tags_for_resource(
465+
AccountId=account_id,
466+
ResourceArn=bucket_arn,
467+
)
468+
snapshot.match("list-tags-after-update", list_tags_after_update)
469+
470+
untag_resource = s3control_client.untag_resource(
471+
AccountId=account_id, ResourceArn=bucket_arn, TagKeys=["key3"]
472+
)
473+
snapshot.match("untag-resource", untag_resource)
474+
475+
list_tags_after_untag = s3control_client.list_tags_for_resource(
476+
AccountId=account_id,
477+
ResourceArn=bucket_arn,
478+
)
479+
snapshot.match("list-tags-after-untag", list_tags_after_untag)
480+
481+
untag_resource_idempotent = s3control_client.untag_resource(
482+
AccountId=account_id, ResourceArn=bucket_arn, TagKeys=["key3"]
483+
)
484+
snapshot.match("untag-resource-idempotent", untag_resource_idempotent)
485+
486+
@markers.aws.validated
487+
def test_tag_resource_validation(
488+
self, s3_bucket, s3control_client_no_validation, account_id, snapshot, s3control_snapshot
489+
):
490+
bucket_arn = f"arn:aws:s3:::{s3_bucket}"
491+
492+
with pytest.raises(ClientError) as e:
493+
s3control_client_no_validation.tag_resource(
494+
AccountId=account_id,
495+
ResourceArn=bucket_arn,
496+
Tags=[{"Key": None, "Value": "value1"}],
497+
)
498+
snapshot.match("tags-key-none", e.value.response)
499+
500+
with pytest.raises(ClientError) as e:
501+
s3control_client_no_validation.tag_resource(
502+
AccountId=account_id,
503+
ResourceArn=bucket_arn,
504+
Tags=[{"Key": "key1", "Value": None}],
505+
)
506+
snapshot.match("tags-value-none", e.value.response)
507+
508+
with pytest.raises(ClientError) as e:
509+
s3control_client_no_validation.tag_resource(
510+
AccountId=account_id,
511+
ResourceArn=bucket_arn,
512+
Tags=[{"Key": "aws:test", "Value": "value1"}],
513+
)
514+
snapshot.match("tags-key-aws-prefix", e.value.response)
515+
516+
with pytest.raises(ClientError) as e:
517+
s3control_client_no_validation.tag_resource(
518+
AccountId=account_id,
519+
ResourceArn=bucket_arn,
520+
Tags=[
521+
{"Key": "key1", "Value": "value1"},
522+
{"Key": "key1", "Value": "value1"},
523+
],
524+
)
525+
snapshot.match("tags-key-aws-duplicated-key", e.value.response)
526+
527+
with pytest.raises(ClientError) as e:
528+
s3control_client_no_validation.tag_resource(
529+
AccountId=account_id,
530+
ResourceArn=bucket_arn,
531+
Tags=[{"Key": "test", "Value": "value1,value2"}],
532+
)
533+
snapshot.match("tags-key-aws-bad-value", e.value.response)
534+
535+
with pytest.raises(ClientError) as e:
536+
s3control_client_no_validation.tag_resource(
537+
AccountId=account_id,
538+
ResourceArn=bucket_arn,
539+
Tags=[{"Key": "test,test2", "Value": "value1"}],
540+
)
541+
snapshot.match("tags-key-aws-bad-key", e.value.response)
542+
543+
@markers.aws.validated
544+
def test_tag_operation_no_bucket(
545+
self, s3_bucket, s3control_client, account_id, snapshot, s3control_snapshot
546+
):
547+
bucket_arn = f"arn:aws:s3:::{short_uid()}-{short_uid()}"
548+
549+
with pytest.raises(ClientError) as e:
550+
s3control_client.tag_resource(
551+
AccountId=account_id,
552+
ResourceArn=bucket_arn,
553+
Tags=[{"Key": "key1", "Value": "value1"}],
554+
)
555+
snapshot.match("tag-resource-no-exist", e.value.response)
556+
557+
with pytest.raises(ClientError) as e:
558+
s3control_client.untag_resource(
559+
AccountId=account_id,
560+
ResourceArn=bucket_arn,
561+
TagKeys=["key1"],
562+
)
563+
snapshot.match("untag-resource-no-exist", e.value.response)
564+
565+
with pytest.raises(ClientError) as e:
566+
s3control_client.list_tags_for_resource(
567+
AccountId=account_id,
568+
ResourceArn=bucket_arn,
569+
)
570+
snapshot.match("list-resource-tags-no-exist", e.value.response)

0 commit comments

Comments
 (0)