support Tags parameter in ACM import_certificate() and request_certificate() methods (#3373)
* ACM: support `tags` parameter in import_certificate() * ACM: support tags parameter in request_certificate() * ACM: better tag operations with more unit tests Co-authored-by: nom3ad <nom3ad@gmail.com>
This commit is contained in:
parent
502818be4c
commit
ea0ba91f63
@ -70,6 +70,68 @@ class AWSResourceNotFoundException(AWSError):
|
|||||||
TYPE = "ResourceNotFoundException"
|
TYPE = "ResourceNotFoundException"
|
||||||
|
|
||||||
|
|
||||||
|
class AWSTooManyTagsException(AWSError):
|
||||||
|
TYPE = "TooManyTagsException"
|
||||||
|
|
||||||
|
|
||||||
|
class TagHolder(dict):
|
||||||
|
MAX_TAG_COUNT = 50
|
||||||
|
MAX_KEY_LENGTH = 128
|
||||||
|
MAX_VALUE_LENGTH = 256
|
||||||
|
|
||||||
|
def _validate_kv(self, key, value, index):
|
||||||
|
if len(key) > self.MAX_KEY_LENGTH:
|
||||||
|
raise AWSValidationException(
|
||||||
|
"Value '%s' at 'tags.%d.member.key' failed to satisfy constraint: Member must have length less than or equal to %s"
|
||||||
|
% (key, index, self.MAX_KEY_LENGTH)
|
||||||
|
)
|
||||||
|
if value and len(value) > self.MAX_VALUE_LENGTH:
|
||||||
|
raise AWSValidationException(
|
||||||
|
"Value '%s' at 'tags.%d.member.value' failed to satisfy constraint: Member must have length less than or equal to %s"
|
||||||
|
% (value, index, self.MAX_VALUE_LENGTH)
|
||||||
|
)
|
||||||
|
if key.startswith("aws:"):
|
||||||
|
raise AWSValidationException(
|
||||||
|
'Invalid Tag Key: "%s". AWS internal tags cannot be changed with this API'
|
||||||
|
% key
|
||||||
|
)
|
||||||
|
|
||||||
|
def add(self, tags):
|
||||||
|
tags_copy = self.copy()
|
||||||
|
for i, tag in enumerate(tags):
|
||||||
|
key = tag["Key"]
|
||||||
|
value = tag.get("Value", None)
|
||||||
|
self._validate_kv(key, value, i + 1)
|
||||||
|
|
||||||
|
tags_copy[key] = value
|
||||||
|
if len(tags_copy) > self.MAX_TAG_COUNT:
|
||||||
|
raise AWSTooManyTagsException(
|
||||||
|
"the TagSet: '{%s}' contains too many Tags"
|
||||||
|
% ", ".join(k + "=" + str(v or "") for k, v in tags_copy.items())
|
||||||
|
)
|
||||||
|
|
||||||
|
self.update(tags_copy)
|
||||||
|
|
||||||
|
def remove(self, tags):
|
||||||
|
for i, tag in enumerate(tags):
|
||||||
|
key = tag["Key"]
|
||||||
|
value = tag.get("Value", None)
|
||||||
|
self._validate_kv(key, value, i + 1)
|
||||||
|
try:
|
||||||
|
# If value isnt provided, just delete key
|
||||||
|
if value is None:
|
||||||
|
del self[key]
|
||||||
|
# If value is provided, only delete if it matches what already exists
|
||||||
|
elif self[key] == value:
|
||||||
|
del self[key]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def equals(self, tags):
|
||||||
|
tags = {t["Key"]: t.get("Value", None) for t in tags} if tags else {}
|
||||||
|
return self == tags
|
||||||
|
|
||||||
|
|
||||||
class CertBundle(BaseModel):
|
class CertBundle(BaseModel):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@ -88,7 +150,7 @@ class CertBundle(BaseModel):
|
|||||||
self.key = private_key
|
self.key = private_key
|
||||||
self._key = None
|
self._key = None
|
||||||
self.chain = chain
|
self.chain = chain
|
||||||
self.tags = {}
|
self.tags = TagHolder()
|
||||||
self._chain = None
|
self._chain = None
|
||||||
self.type = cert_type # Should really be an enum
|
self.type = cert_type # Should really be an enum
|
||||||
self.status = cert_status # Should really be an enum
|
self.status = cert_status # Should really be an enum
|
||||||
@ -388,7 +450,7 @@ class AWSCertificateManagerBackend(BaseBackend):
|
|||||||
"expires": datetime.datetime.now() + datetime.timedelta(hours=1),
|
"expires": datetime.datetime.now() + datetime.timedelta(hours=1),
|
||||||
}
|
}
|
||||||
|
|
||||||
def import_cert(self, certificate, private_key, chain=None, arn=None):
|
def import_cert(self, certificate, private_key, chain=None, arn=None, tags=None):
|
||||||
if arn is not None:
|
if arn is not None:
|
||||||
if arn not in self._certificates:
|
if arn not in self._certificates:
|
||||||
raise self._arn_not_found(arn)
|
raise self._arn_not_found(arn)
|
||||||
@ -403,6 +465,9 @@ class AWSCertificateManagerBackend(BaseBackend):
|
|||||||
|
|
||||||
self._certificates[bundle.arn] = bundle
|
self._certificates[bundle.arn] = bundle
|
||||||
|
|
||||||
|
if tags:
|
||||||
|
self.add_tags_to_certificate(bundle.arn, tags)
|
||||||
|
|
||||||
return bundle.arn
|
return bundle.arn
|
||||||
|
|
||||||
def get_certificates_list(self, statuses):
|
def get_certificates_list(self, statuses):
|
||||||
@ -437,10 +502,11 @@ class AWSCertificateManagerBackend(BaseBackend):
|
|||||||
domain_validation_options,
|
domain_validation_options,
|
||||||
idempotency_token,
|
idempotency_token,
|
||||||
subject_alt_names,
|
subject_alt_names,
|
||||||
|
tags=None,
|
||||||
):
|
):
|
||||||
if idempotency_token is not None:
|
if idempotency_token is not None:
|
||||||
arn = self._get_arn_from_idempotency_token(idempotency_token)
|
arn = self._get_arn_from_idempotency_token(idempotency_token)
|
||||||
if arn is not None:
|
if arn and self._certificates[arn].tags.equals(tags):
|
||||||
return arn
|
return arn
|
||||||
|
|
||||||
cert = CertBundle.generate_cert(
|
cert = CertBundle.generate_cert(
|
||||||
@ -450,34 +516,20 @@ class AWSCertificateManagerBackend(BaseBackend):
|
|||||||
self._set_idempotency_token_arn(idempotency_token, cert.arn)
|
self._set_idempotency_token_arn(idempotency_token, cert.arn)
|
||||||
self._certificates[cert.arn] = cert
|
self._certificates[cert.arn] = cert
|
||||||
|
|
||||||
|
if tags:
|
||||||
|
cert.tags.add(tags)
|
||||||
|
|
||||||
return cert.arn
|
return cert.arn
|
||||||
|
|
||||||
def add_tags_to_certificate(self, arn, tags):
|
def add_tags_to_certificate(self, arn, tags):
|
||||||
# get_cert does arn check
|
# get_cert does arn check
|
||||||
cert_bundle = self.get_certificate(arn)
|
cert_bundle = self.get_certificate(arn)
|
||||||
|
cert_bundle.tags.add(tags)
|
||||||
for tag in tags:
|
|
||||||
key = tag["Key"]
|
|
||||||
value = tag.get("Value", None)
|
|
||||||
cert_bundle.tags[key] = value
|
|
||||||
|
|
||||||
def remove_tags_from_certificate(self, arn, tags):
|
def remove_tags_from_certificate(self, arn, tags):
|
||||||
# get_cert does arn check
|
# get_cert does arn check
|
||||||
cert_bundle = self.get_certificate(arn)
|
cert_bundle = self.get_certificate(arn)
|
||||||
|
cert_bundle.tags.remove(tags)
|
||||||
for tag in tags:
|
|
||||||
key = tag["Key"]
|
|
||||||
value = tag.get("Value", None)
|
|
||||||
|
|
||||||
try:
|
|
||||||
# If value isnt provided, just delete key
|
|
||||||
if value is None:
|
|
||||||
del cert_bundle.tags[key]
|
|
||||||
# If value is provided, only delete if it matches what already exists
|
|
||||||
elif cert_bundle.tags[key] == value:
|
|
||||||
del cert_bundle.tags[key]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
acm_backends = {}
|
acm_backends = {}
|
||||||
|
@ -117,6 +117,7 @@ class AWSCertificateManagerResponse(BaseResponse):
|
|||||||
private_key = self._get_param("PrivateKey")
|
private_key = self._get_param("PrivateKey")
|
||||||
chain = self._get_param("CertificateChain") # Optional
|
chain = self._get_param("CertificateChain") # Optional
|
||||||
current_arn = self._get_param("CertificateArn") # Optional
|
current_arn = self._get_param("CertificateArn") # Optional
|
||||||
|
tags = self._get_param("Tags") # Optional
|
||||||
|
|
||||||
# Simple parameter decoding. Rather do it here as its a data transport decision not part of the
|
# Simple parameter decoding. Rather do it here as its a data transport decision not part of the
|
||||||
# actual data
|
# actual data
|
||||||
@ -142,7 +143,7 @@ class AWSCertificateManagerResponse(BaseResponse):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
arn = self.acm_backend.import_cert(
|
arn = self.acm_backend.import_cert(
|
||||||
certificate, private_key, chain=chain, arn=current_arn
|
certificate, private_key, chain=chain, arn=current_arn, tags=tags
|
||||||
)
|
)
|
||||||
except AWSError as err:
|
except AWSError as err:
|
||||||
return err.response()
|
return err.response()
|
||||||
@ -210,6 +211,7 @@ class AWSCertificateManagerResponse(BaseResponse):
|
|||||||
) # is ignored atm
|
) # is ignored atm
|
||||||
idempotency_token = self._get_param("IdempotencyToken")
|
idempotency_token = self._get_param("IdempotencyToken")
|
||||||
subject_alt_names = self._get_param("SubjectAlternativeNames")
|
subject_alt_names = self._get_param("SubjectAlternativeNames")
|
||||||
|
tags = self._get_param("Tags") # Optional
|
||||||
|
|
||||||
if subject_alt_names is not None and len(subject_alt_names) > 10:
|
if subject_alt_names is not None and len(subject_alt_names) > 10:
|
||||||
# There is initial AWS limit of 10
|
# There is initial AWS limit of 10
|
||||||
@ -227,6 +229,7 @@ class AWSCertificateManagerResponse(BaseResponse):
|
|||||||
domain_validation_options,
|
domain_validation_options,
|
||||||
idempotency_token,
|
idempotency_token,
|
||||||
subject_alt_names,
|
subject_alt_names,
|
||||||
|
tags,
|
||||||
)
|
)
|
||||||
except AWSError as err:
|
except AWSError as err:
|
||||||
return err.response()
|
return err.response()
|
||||||
|
@ -11,6 +11,7 @@ from botocore.exceptions import ClientError
|
|||||||
from moto import mock_acm
|
from moto import mock_acm
|
||||||
from moto.core import ACCOUNT_ID
|
from moto.core import ACCOUNT_ID
|
||||||
|
|
||||||
|
from nose.tools import assert_raises
|
||||||
|
|
||||||
RESOURCE_FOLDER = os.path.join(os.path.dirname(__file__), "resources")
|
RESOURCE_FOLDER = os.path.join(os.path.dirname(__file__), "resources")
|
||||||
_GET_RESOURCE = lambda x: open(os.path.join(RESOURCE_FOLDER, x), "rb").read()
|
_GET_RESOURCE = lambda x: open(os.path.join(RESOURCE_FOLDER, x), "rb").read()
|
||||||
@ -46,6 +47,30 @@ def test_import_certificate():
|
|||||||
resp.should.contain("CertificateChain")
|
resp.should.contain("CertificateChain")
|
||||||
|
|
||||||
|
|
||||||
|
@mock_acm
|
||||||
|
def test_import_certificate_with_tags():
|
||||||
|
client = boto3.client("acm", region_name="eu-central-1")
|
||||||
|
|
||||||
|
resp = client.import_certificate(
|
||||||
|
Certificate=SERVER_CRT,
|
||||||
|
PrivateKey=SERVER_KEY,
|
||||||
|
CertificateChain=CA_CRT,
|
||||||
|
Tags=[{"Key": "Environment", "Value": "QA"}, {"Key": "KeyOnly"},],
|
||||||
|
)
|
||||||
|
arn = resp["CertificateArn"]
|
||||||
|
|
||||||
|
resp = client.get_certificate(CertificateArn=arn)
|
||||||
|
resp["Certificate"].should.equal(SERVER_CRT.decode())
|
||||||
|
resp.should.contain("CertificateChain")
|
||||||
|
|
||||||
|
resp = client.list_tags_for_certificate(CertificateArn=arn)
|
||||||
|
tags = {item["Key"]: item.get("Value", "__NONE__") for item in resp["Tags"]}
|
||||||
|
tags.should.contain("Environment")
|
||||||
|
tags.should.contain("KeyOnly")
|
||||||
|
tags["Environment"].should.equal("QA")
|
||||||
|
tags["KeyOnly"].should.equal("__NONE__")
|
||||||
|
|
||||||
|
|
||||||
@mock_acm
|
@mock_acm
|
||||||
def test_import_bad_certificate():
|
def test_import_bad_certificate():
|
||||||
client = boto3.client("acm", region_name="eu-central-1")
|
client = boto3.client("acm", region_name="eu-central-1")
|
||||||
@ -313,6 +338,150 @@ def test_request_certificate():
|
|||||||
resp["CertificateArn"].should.equal(arn)
|
resp["CertificateArn"].should.equal(arn)
|
||||||
|
|
||||||
|
|
||||||
|
@mock_acm
|
||||||
|
def test_request_certificate_with_tags():
|
||||||
|
client = boto3.client("acm", region_name="eu-central-1")
|
||||||
|
|
||||||
|
token = str(uuid.uuid4())
|
||||||
|
|
||||||
|
resp = client.request_certificate(
|
||||||
|
DomainName="google.com",
|
||||||
|
IdempotencyToken=token,
|
||||||
|
SubjectAlternativeNames=["google.com", "www.google.com", "mail.google.com"],
|
||||||
|
Tags=[
|
||||||
|
{"Key": "Environment", "Value": "QA"},
|
||||||
|
{"Key": "WithEmptyStr", "Value": ""},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
resp.should.contain("CertificateArn")
|
||||||
|
arn_1 = resp["CertificateArn"]
|
||||||
|
|
||||||
|
resp = client.list_tags_for_certificate(CertificateArn=arn_1)
|
||||||
|
tags = {item["Key"]: item.get("Value", "__NONE__") for item in resp["Tags"]}
|
||||||
|
tags.should.have.length_of(2)
|
||||||
|
tags["Environment"].should.equal("QA")
|
||||||
|
tags["WithEmptyStr"].should.equal("")
|
||||||
|
|
||||||
|
# Request certificate for "google.com" with same IdempotencyToken but with different Tags
|
||||||
|
resp = client.request_certificate(
|
||||||
|
DomainName="google.com",
|
||||||
|
IdempotencyToken=token,
|
||||||
|
SubjectAlternativeNames=["google.com", "www.google.com", "mail.google.com"],
|
||||||
|
Tags=[{"Key": "Environment", "Value": "Prod"}, {"Key": "KeyOnly"},],
|
||||||
|
)
|
||||||
|
arn_2 = resp["CertificateArn"]
|
||||||
|
|
||||||
|
assert arn_1 != arn_2 # if tags are matched, ACM would have returned same arn
|
||||||
|
|
||||||
|
resp = client.list_tags_for_certificate(CertificateArn=arn_2)
|
||||||
|
tags = {item["Key"]: item.get("Value", "__NONE__") for item in resp["Tags"]}
|
||||||
|
tags.should.have.length_of(2)
|
||||||
|
tags["Environment"].should.equal("Prod")
|
||||||
|
tags["KeyOnly"].should.equal("__NONE__")
|
||||||
|
|
||||||
|
resp = client.request_certificate(
|
||||||
|
DomainName="google.com",
|
||||||
|
IdempotencyToken=token,
|
||||||
|
SubjectAlternativeNames=["google.com", "www.google.com", "mail.google.com"],
|
||||||
|
Tags=[
|
||||||
|
{"Key": "Environment", "Value": "QA"},
|
||||||
|
{"Key": "WithEmptyStr", "Value": ""},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock_acm
|
||||||
|
def test_operations_with_invalid_tags():
|
||||||
|
client = boto3.client("acm", region_name="eu-central-1")
|
||||||
|
|
||||||
|
# request certificate with invalid tags
|
||||||
|
with assert_raises(ClientError) as ex:
|
||||||
|
client.request_certificate(
|
||||||
|
DomainName="example.com", Tags=[{"Key": "X" * 200, "Value": "Valid"}],
|
||||||
|
)
|
||||||
|
ex.exception.response["Error"]["Code"].should.equal("ValidationException")
|
||||||
|
ex.exception.response["Error"]["Message"].should.contain(
|
||||||
|
"Member must have length less than or equal to 128"
|
||||||
|
)
|
||||||
|
|
||||||
|
# import certificate with invalid tags
|
||||||
|
with assert_raises(ClientError) as ex:
|
||||||
|
client.import_certificate(
|
||||||
|
Certificate=SERVER_CRT,
|
||||||
|
PrivateKey=SERVER_KEY,
|
||||||
|
CertificateChain=CA_CRT,
|
||||||
|
Tags=[
|
||||||
|
{"Key": "Valid", "Value": "X" * 300},
|
||||||
|
{"Key": "aws:xx", "Value": "Valid"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
ex.exception.response["Error"]["Code"].should.equal("ValidationException")
|
||||||
|
ex.exception.response["Error"]["Message"].should.contain(
|
||||||
|
"Member must have length less than or equal to 256"
|
||||||
|
)
|
||||||
|
|
||||||
|
arn = _import_cert(client)
|
||||||
|
|
||||||
|
# add invalid tags to existing certificate
|
||||||
|
with assert_raises(ClientError) as ex:
|
||||||
|
client.add_tags_to_certificate(
|
||||||
|
CertificateArn=arn,
|
||||||
|
Tags=[{"Key": "aws:xxx", "Value": "Valid"}, {"Key": "key2"}],
|
||||||
|
)
|
||||||
|
ex.exception.response["Error"]["Code"].should.equal("ValidationException")
|
||||||
|
ex.exception.response["Error"]["Message"].should.contain(
|
||||||
|
"AWS internal tags cannot be changed with this API"
|
||||||
|
)
|
||||||
|
|
||||||
|
# try removing invalid tags from existing certificate
|
||||||
|
with assert_raises(ClientError) as ex:
|
||||||
|
client.remove_tags_from_certificate(
|
||||||
|
CertificateArn=arn, Tags=[{"Key": "aws:xxx", "Value": "Valid"}]
|
||||||
|
)
|
||||||
|
ex.exception.response["Error"]["Code"].should.equal("ValidationException")
|
||||||
|
ex.exception.response["Error"]["Message"].should.contain(
|
||||||
|
"AWS internal tags cannot be changed with this API"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock_acm
|
||||||
|
def test_add_too_many_tags():
|
||||||
|
client = boto3.client("acm", region_name="eu-central-1")
|
||||||
|
arn = _import_cert(client)
|
||||||
|
|
||||||
|
# Add 51 tags
|
||||||
|
with assert_raises(ClientError) as ex:
|
||||||
|
client.add_tags_to_certificate(
|
||||||
|
CertificateArn=arn,
|
||||||
|
Tags=[{"Key": "a-%d" % i, "Value": "abcd"} for i in range(1, 52)],
|
||||||
|
)
|
||||||
|
ex.exception.response["Error"]["Code"].should.equal("TooManyTagsException")
|
||||||
|
ex.exception.response["Error"]["Message"].should.contain("contains too many Tags")
|
||||||
|
client.list_tags_for_certificate(CertificateArn=arn)["Tags"].should.have.empty
|
||||||
|
|
||||||
|
# Add 49 tags first, then try to add 2 more.
|
||||||
|
client.add_tags_to_certificate(
|
||||||
|
CertificateArn=arn,
|
||||||
|
Tags=[{"Key": "p-%d" % i, "Value": "pqrs"} for i in range(1, 50)],
|
||||||
|
)
|
||||||
|
client.list_tags_for_certificate(CertificateArn=arn)["Tags"].should.have.length_of(
|
||||||
|
49
|
||||||
|
)
|
||||||
|
with assert_raises(ClientError) as ex:
|
||||||
|
client.add_tags_to_certificate(
|
||||||
|
CertificateArn=arn,
|
||||||
|
Tags=[{"Key": "x-1", "Value": "xyz"}, {"Key": "x-2", "Value": "xyz"}],
|
||||||
|
)
|
||||||
|
ex.exception.response["Error"]["Code"].should.equal("TooManyTagsException")
|
||||||
|
ex.exception.response["Error"]["Message"].should.contain("contains too many Tags")
|
||||||
|
ex.exception.response["Error"]["Message"].count("pqrs").should.equal(49)
|
||||||
|
ex.exception.response["Error"]["Message"].count("xyz").should.equal(2)
|
||||||
|
client.list_tags_for_certificate(CertificateArn=arn)["Tags"].should.have.length_of(
|
||||||
|
49
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@mock_acm
|
@mock_acm
|
||||||
def test_request_certificate_no_san():
|
def test_request_certificate_no_san():
|
||||||
client = boto3.client("acm", region_name="eu-central-1")
|
client = boto3.client("acm", region_name="eu-central-1")
|
||||||
|
Loading…
x
Reference in New Issue
Block a user