Implement ECR batch_delete_image (#2225)
This implements the endpoint in spulec #2224
This commit is contained in:
parent
8f53b16b9a
commit
664b27d8e7
@ -1581,9 +1581,9 @@
|
|||||||
- [ ] update_security_group_rule_descriptions_egress
|
- [ ] update_security_group_rule_descriptions_egress
|
||||||
- [ ] update_security_group_rule_descriptions_ingress
|
- [ ] update_security_group_rule_descriptions_ingress
|
||||||
|
|
||||||
## ecr - 31% implemented
|
## ecr - 36% implemented
|
||||||
- [ ] batch_check_layer_availability
|
- [ ] batch_check_layer_availability
|
||||||
- [ ] batch_delete_image
|
- [X] batch_delete_image
|
||||||
- [X] batch_get_image
|
- [X] batch_get_image
|
||||||
- [ ] complete_layer_upload
|
- [ ] complete_layer_upload
|
||||||
- [X] create_repository
|
- [X] create_repository
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import re
|
||||||
from copy import copy
|
from copy import copy
|
||||||
from random import random
|
from random import random
|
||||||
|
|
||||||
@ -119,6 +120,12 @@ class Image(BaseObject):
|
|||||||
def get_image_manifest(self):
|
def get_image_manifest(self):
|
||||||
return self.image_manifest
|
return self.image_manifest
|
||||||
|
|
||||||
|
def remove_tag(self, tag):
|
||||||
|
if tag is not None and tag in self.image_tags:
|
||||||
|
self.image_tags.remove(tag)
|
||||||
|
if self.image_tags:
|
||||||
|
self.image_tag = self.image_tags[-1]
|
||||||
|
|
||||||
def update_tag(self, tag):
|
def update_tag(self, tag):
|
||||||
self.image_tag = tag
|
self.image_tag = tag
|
||||||
if tag not in self.image_tags and tag is not None:
|
if tag not in self.image_tags and tag is not None:
|
||||||
@ -165,6 +172,13 @@ class Image(BaseObject):
|
|||||||
response_object['registryId'] = self.registry_id
|
response_object['registryId'] = self.registry_id
|
||||||
return {k: v for k, v in response_object.items() if v is not None and v != [None]}
|
return {k: v for k, v in response_object.items() if v is not None and v != [None]}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def response_batch_delete_image(self):
|
||||||
|
response_object = {}
|
||||||
|
response_object['imageDigest'] = self.get_image_digest()
|
||||||
|
response_object['imageTag'] = self.image_tag
|
||||||
|
return {k: v for k, v in response_object.items() if v is not None and v != [None]}
|
||||||
|
|
||||||
|
|
||||||
class ECRBackend(BaseBackend):
|
class ECRBackend(BaseBackend):
|
||||||
|
|
||||||
@ -310,6 +324,103 @@ class ECRBackend(BaseBackend):
|
|||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
def batch_delete_image(self, repository_name, registry_id=None, image_ids=None):
|
||||||
|
if repository_name in self.repositories:
|
||||||
|
repository = self.repositories[repository_name]
|
||||||
|
else:
|
||||||
|
raise RepositoryNotFoundException(
|
||||||
|
repository_name, registry_id or DEFAULT_REGISTRY_ID
|
||||||
|
)
|
||||||
|
|
||||||
|
if not image_ids:
|
||||||
|
raise ParamValidationError(
|
||||||
|
msg='Missing required parameter in input: "imageIds"'
|
||||||
|
)
|
||||||
|
|
||||||
|
response = {
|
||||||
|
"imageIds": [],
|
||||||
|
"failures": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for image_id in image_ids:
|
||||||
|
image_found = False
|
||||||
|
|
||||||
|
# Is request missing both digest and tag?
|
||||||
|
if "imageDigest" not in image_id and "imageTag" not in image_id:
|
||||||
|
response["failures"].append(
|
||||||
|
{
|
||||||
|
"imageId": {},
|
||||||
|
"failureCode": "MissingDigestAndTag",
|
||||||
|
"failureReason": "Invalid request parameters: both tag and digest cannot be null",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If we have a digest, is it valid?
|
||||||
|
if "imageDigest" in image_id:
|
||||||
|
pattern = re.compile("^[0-9a-zA-Z_+\.-]+:[0-9a-fA-F]{64}")
|
||||||
|
if not pattern.match(image_id.get("imageDigest")):
|
||||||
|
response["failures"].append(
|
||||||
|
{
|
||||||
|
"imageId": {
|
||||||
|
"imageDigest": image_id.get("imageDigest", "null")
|
||||||
|
},
|
||||||
|
"failureCode": "InvalidImageDigest",
|
||||||
|
"failureReason": "Invalid request parameters: image digest should satisfy the regex '[a-zA-Z0-9-_+.]+:[a-fA-F0-9]+'",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for num, image in enumerate(repository.images):
|
||||||
|
|
||||||
|
# Search by matching both digest and tag
|
||||||
|
if "imageDigest" in image_id and "imageTag" in image_id:
|
||||||
|
if (
|
||||||
|
image_id["imageDigest"] == image.get_image_digest() and
|
||||||
|
image_id["imageTag"] in image.image_tags
|
||||||
|
):
|
||||||
|
image_found = True
|
||||||
|
for image_tag in reversed(image.image_tags):
|
||||||
|
repository.images[num].image_tag = image_tag
|
||||||
|
response["imageIds"].append(
|
||||||
|
image.response_batch_delete_image
|
||||||
|
)
|
||||||
|
repository.images[num].remove_tag(image_tag)
|
||||||
|
del repository.images[num]
|
||||||
|
|
||||||
|
# Search by matching digest
|
||||||
|
elif "imageDigest" in image_id and image.get_image_digest() == image_id["imageDigest"]:
|
||||||
|
image_found = True
|
||||||
|
for image_tag in reversed(image.image_tags):
|
||||||
|
repository.images[num].image_tag = image_tag
|
||||||
|
response["imageIds"].append(image.response_batch_delete_image)
|
||||||
|
repository.images[num].remove_tag(image_tag)
|
||||||
|
del repository.images[num]
|
||||||
|
|
||||||
|
# Search by matching tag
|
||||||
|
elif "imageTag" in image_id and image_id["imageTag"] in image.image_tags:
|
||||||
|
image_found = True
|
||||||
|
repository.images[num].image_tag = image_id["imageTag"]
|
||||||
|
response["imageIds"].append(image.response_batch_delete_image)
|
||||||
|
repository.images[num].remove_tag(image_id["imageTag"])
|
||||||
|
|
||||||
|
if not image_found:
|
||||||
|
failure_response = {
|
||||||
|
"imageId": {},
|
||||||
|
"failureCode": "ImageNotFound",
|
||||||
|
"failureReason": "Requested image not found",
|
||||||
|
}
|
||||||
|
|
||||||
|
if "imageDigest" in image_id:
|
||||||
|
failure_response["imageId"]["imageDigest"] = image_id.get("imageDigest", "null")
|
||||||
|
|
||||||
|
if "imageTag" in image_id:
|
||||||
|
failure_response["imageId"]["imageTag"] = image_id.get("imageTag", "null")
|
||||||
|
|
||||||
|
response["failures"].append(failure_response)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
ecr_backends = {}
|
ecr_backends = {}
|
||||||
for region, ec2_backend in ec2_backends.items():
|
for region, ec2_backend in ec2_backends.items():
|
||||||
|
@ -84,9 +84,12 @@ class ECRResponse(BaseResponse):
|
|||||||
'ECR.batch_check_layer_availability is not yet implemented')
|
'ECR.batch_check_layer_availability is not yet implemented')
|
||||||
|
|
||||||
def batch_delete_image(self):
|
def batch_delete_image(self):
|
||||||
if self.is_not_dryrun('BatchDeleteImage'):
|
repository_str = self._get_param('repositoryName')
|
||||||
raise NotImplementedError(
|
registry_id = self._get_param('registryId')
|
||||||
'ECR.batch_delete_image is not yet implemented')
|
image_ids = self._get_param('imageIds')
|
||||||
|
|
||||||
|
response = self.ecr_backend.batch_delete_image(repository_str, registry_id, image_ids)
|
||||||
|
return json.dumps(response)
|
||||||
|
|
||||||
def batch_get_image(self):
|
def batch_get_image(self):
|
||||||
repository_str = self._get_param('repositoryName')
|
repository_str = self._get_param('repositoryName')
|
||||||
|
@ -695,3 +695,308 @@ def test_batch_get_image_no_tags():
|
|||||||
client.batch_get_image.when.called_with(
|
client.batch_get_image.when.called_with(
|
||||||
repositoryName='test_repository').should.throw(
|
repositoryName='test_repository').should.throw(
|
||||||
ParamValidationError, error_msg)
|
ParamValidationError, error_msg)
|
||||||
|
|
||||||
|
|
||||||
|
@mock_ecr
|
||||||
|
def test_batch_delete_image_by_tag():
|
||||||
|
client = boto3.client('ecr', region_name='us-east-1')
|
||||||
|
_ = client.create_repository(
|
||||||
|
repositoryName='test_repository'
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = _create_image_manifest()
|
||||||
|
|
||||||
|
tags = ['v1', 'v1.0', 'latest']
|
||||||
|
for tag in tags:
|
||||||
|
put_response = client.put_image(
|
||||||
|
repositoryName='test_repository',
|
||||||
|
imageManifest=json.dumps(manifest),
|
||||||
|
imageTag=tag,
|
||||||
|
)
|
||||||
|
|
||||||
|
describe_response1 = client.describe_images(repositoryName='test_repository')
|
||||||
|
image_digest = describe_response1['imageDetails'][0]['imageDigest']
|
||||||
|
|
||||||
|
batch_delete_response = client.batch_delete_image(
|
||||||
|
registryId='012345678910',
|
||||||
|
repositoryName='test_repository',
|
||||||
|
imageIds=[
|
||||||
|
{
|
||||||
|
'imageTag': 'latest'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
describe_response2 = client.describe_images(repositoryName='test_repository')
|
||||||
|
|
||||||
|
type(describe_response1['imageDetails'][0]['imageTags']).should.be(list)
|
||||||
|
len(describe_response1['imageDetails'][0]['imageTags']).should.be(3)
|
||||||
|
|
||||||
|
type(describe_response2['imageDetails'][0]['imageTags']).should.be(list)
|
||||||
|
len(describe_response2['imageDetails'][0]['imageTags']).should.be(2)
|
||||||
|
|
||||||
|
type(batch_delete_response['imageIds']).should.be(list)
|
||||||
|
len(batch_delete_response['imageIds']).should.be(1)
|
||||||
|
|
||||||
|
batch_delete_response['imageIds'][0]['imageTag'].should.equal("latest")
|
||||||
|
|
||||||
|
type(batch_delete_response['failures']).should.be(list)
|
||||||
|
len(batch_delete_response['failures']).should.be(0)
|
||||||
|
|
||||||
|
|
||||||
|
@mock_ecr
|
||||||
|
def test_batch_delete_image_with_nonexistent_tag():
|
||||||
|
client = boto3.client('ecr', region_name='us-east-1')
|
||||||
|
_ = client.create_repository(
|
||||||
|
repositoryName='test_repository'
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = _create_image_manifest()
|
||||||
|
|
||||||
|
tags = ['v1', 'v1.0', 'latest']
|
||||||
|
for tag in tags:
|
||||||
|
put_response = client.put_image(
|
||||||
|
repositoryName='test_repository',
|
||||||
|
imageManifest=json.dumps(manifest),
|
||||||
|
imageTag=tag,
|
||||||
|
)
|
||||||
|
|
||||||
|
describe_response = client.describe_images(repositoryName='test_repository')
|
||||||
|
image_digest = describe_response['imageDetails'][0]['imageDigest']
|
||||||
|
|
||||||
|
missing_tag = "missing-tag"
|
||||||
|
batch_delete_response = client.batch_delete_image(
|
||||||
|
registryId='012345678910',
|
||||||
|
repositoryName='test_repository',
|
||||||
|
imageIds=[
|
||||||
|
{
|
||||||
|
'imageTag': missing_tag
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
type(describe_response['imageDetails'][0]['imageTags']).should.be(list)
|
||||||
|
len(describe_response['imageDetails'][0]['imageTags']).should.be(3)
|
||||||
|
|
||||||
|
type(batch_delete_response['imageIds']).should.be(list)
|
||||||
|
len(batch_delete_response['imageIds']).should.be(0)
|
||||||
|
|
||||||
|
batch_delete_response['failures'][0]['imageId']['imageTag'].should.equal(missing_tag)
|
||||||
|
batch_delete_response['failures'][0]['failureCode'].should.equal("ImageNotFound")
|
||||||
|
batch_delete_response['failures'][0]['failureReason'].should.equal("Requested image not found")
|
||||||
|
|
||||||
|
type(batch_delete_response['failures']).should.be(list)
|
||||||
|
len(batch_delete_response['failures']).should.be(1)
|
||||||
|
|
||||||
|
|
||||||
|
@mock_ecr
|
||||||
|
def test_batch_delete_image_by_digest():
|
||||||
|
client = boto3.client('ecr', region_name='us-east-1')
|
||||||
|
_ = client.create_repository(
|
||||||
|
repositoryName='test_repository'
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = _create_image_manifest()
|
||||||
|
|
||||||
|
tags = ['v1', 'v2', 'latest']
|
||||||
|
for tag in tags:
|
||||||
|
put_response = client.put_image(
|
||||||
|
repositoryName='test_repository',
|
||||||
|
imageManifest=json.dumps(manifest),
|
||||||
|
imageTag=tag
|
||||||
|
)
|
||||||
|
|
||||||
|
describe_response = client.describe_images(repositoryName='test_repository')
|
||||||
|
image_digest = describe_response['imageDetails'][0]['imageDigest']
|
||||||
|
|
||||||
|
batch_delete_response = client.batch_delete_image(
|
||||||
|
registryId='012345678910',
|
||||||
|
repositoryName='test_repository',
|
||||||
|
imageIds=[
|
||||||
|
{
|
||||||
|
'imageDigest': image_digest
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
describe_response = client.describe_images(repositoryName='test_repository')
|
||||||
|
|
||||||
|
type(describe_response['imageDetails']).should.be(list)
|
||||||
|
len(describe_response['imageDetails']).should.be(0)
|
||||||
|
|
||||||
|
type(batch_delete_response['imageIds']).should.be(list)
|
||||||
|
len(batch_delete_response['imageIds']).should.be(3)
|
||||||
|
|
||||||
|
batch_delete_response['imageIds'][0]['imageDigest'].should.equal(image_digest)
|
||||||
|
batch_delete_response['imageIds'][1]['imageDigest'].should.equal(image_digest)
|
||||||
|
batch_delete_response['imageIds'][2]['imageDigest'].should.equal(image_digest)
|
||||||
|
|
||||||
|
set([
|
||||||
|
batch_delete_response['imageIds'][0]['imageTag'],
|
||||||
|
batch_delete_response['imageIds'][1]['imageTag'],
|
||||||
|
batch_delete_response['imageIds'][2]['imageTag']]).should.equal(set(tags))
|
||||||
|
|
||||||
|
type(batch_delete_response['failures']).should.be(list)
|
||||||
|
len(batch_delete_response['failures']).should.be(0)
|
||||||
|
|
||||||
|
|
||||||
|
@mock_ecr
|
||||||
|
def test_batch_delete_image_with_invalid_digest():
|
||||||
|
client = boto3.client('ecr', region_name='us-east-1')
|
||||||
|
_ = client.create_repository(
|
||||||
|
repositoryName='test_repository'
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = _create_image_manifest()
|
||||||
|
|
||||||
|
tags = ['v1', 'v2', 'latest']
|
||||||
|
for tag in tags:
|
||||||
|
put_response = client.put_image(
|
||||||
|
repositoryName='test_repository',
|
||||||
|
imageManifest=json.dumps(manifest),
|
||||||
|
imageTag=tag
|
||||||
|
)
|
||||||
|
|
||||||
|
describe_response = client.describe_images(repositoryName='test_repository')
|
||||||
|
invalid_image_digest = 'sha256:invalid-digest'
|
||||||
|
|
||||||
|
batch_delete_response = client.batch_delete_image(
|
||||||
|
registryId='012345678910',
|
||||||
|
repositoryName='test_repository',
|
||||||
|
imageIds=[
|
||||||
|
{
|
||||||
|
'imageDigest': invalid_image_digest
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
type(batch_delete_response['imageIds']).should.be(list)
|
||||||
|
len(batch_delete_response['imageIds']).should.be(0)
|
||||||
|
|
||||||
|
type(batch_delete_response['failures']).should.be(list)
|
||||||
|
len(batch_delete_response['failures']).should.be(1)
|
||||||
|
|
||||||
|
batch_delete_response['failures'][0]['imageId']['imageDigest'].should.equal(invalid_image_digest)
|
||||||
|
batch_delete_response['failures'][0]['failureCode'].should.equal("InvalidImageDigest")
|
||||||
|
batch_delete_response['failures'][0]['failureReason'].should.equal("Invalid request parameters: image digest should satisfy the regex '[a-zA-Z0-9-_+.]+:[a-fA-F0-9]+'")
|
||||||
|
|
||||||
|
|
||||||
|
@mock_ecr
|
||||||
|
def test_batch_delete_image_with_missing_parameters():
|
||||||
|
client = boto3.client('ecr', region_name='us-east-1')
|
||||||
|
_ = client.create_repository(
|
||||||
|
repositoryName='test_repository'
|
||||||
|
)
|
||||||
|
|
||||||
|
batch_delete_response = client.batch_delete_image(
|
||||||
|
registryId='012345678910',
|
||||||
|
repositoryName='test_repository',
|
||||||
|
imageIds=[
|
||||||
|
{
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
type(batch_delete_response['imageIds']).should.be(list)
|
||||||
|
len(batch_delete_response['imageIds']).should.be(0)
|
||||||
|
|
||||||
|
type(batch_delete_response['failures']).should.be(list)
|
||||||
|
len(batch_delete_response['failures']).should.be(1)
|
||||||
|
|
||||||
|
batch_delete_response['failures'][0]['failureCode'].should.equal("MissingDigestAndTag")
|
||||||
|
batch_delete_response['failures'][0]['failureReason'].should.equal("Invalid request parameters: both tag and digest cannot be null")
|
||||||
|
|
||||||
|
|
||||||
|
@mock_ecr
|
||||||
|
def test_batch_delete_image_with_matching_digest_and_tag():
|
||||||
|
client = boto3.client('ecr', region_name='us-east-1')
|
||||||
|
_ = client.create_repository(
|
||||||
|
repositoryName='test_repository'
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = _create_image_manifest()
|
||||||
|
|
||||||
|
tags = ['v1', 'v1.0', 'latest']
|
||||||
|
for tag in tags:
|
||||||
|
put_response = client.put_image(
|
||||||
|
repositoryName='test_repository',
|
||||||
|
imageManifest=json.dumps(manifest),
|
||||||
|
imageTag=tag
|
||||||
|
)
|
||||||
|
|
||||||
|
describe_response = client.describe_images(repositoryName='test_repository')
|
||||||
|
image_digest = describe_response['imageDetails'][0]['imageDigest']
|
||||||
|
|
||||||
|
batch_delete_response = client.batch_delete_image(
|
||||||
|
registryId='012345678910',
|
||||||
|
repositoryName='test_repository',
|
||||||
|
imageIds=[
|
||||||
|
{
|
||||||
|
'imageDigest': image_digest,
|
||||||
|
'imageTag': 'v1'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
describe_response = client.describe_images(repositoryName='test_repository')
|
||||||
|
|
||||||
|
type(describe_response['imageDetails']).should.be(list)
|
||||||
|
len(describe_response['imageDetails']).should.be(0)
|
||||||
|
|
||||||
|
type(batch_delete_response['imageIds']).should.be(list)
|
||||||
|
len(batch_delete_response['imageIds']).should.be(3)
|
||||||
|
|
||||||
|
batch_delete_response['imageIds'][0]['imageDigest'].should.equal(image_digest)
|
||||||
|
batch_delete_response['imageIds'][1]['imageDigest'].should.equal(image_digest)
|
||||||
|
batch_delete_response['imageIds'][2]['imageDigest'].should.equal(image_digest)
|
||||||
|
|
||||||
|
set([
|
||||||
|
batch_delete_response['imageIds'][0]['imageTag'],
|
||||||
|
batch_delete_response['imageIds'][1]['imageTag'],
|
||||||
|
batch_delete_response['imageIds'][2]['imageTag']]).should.equal(set(tags))
|
||||||
|
|
||||||
|
type(batch_delete_response['failures']).should.be(list)
|
||||||
|
len(batch_delete_response['failures']).should.be(0)
|
||||||
|
|
||||||
|
|
||||||
|
@mock_ecr
|
||||||
|
def test_batch_delete_image_with_mismatched_digest_and_tag():
|
||||||
|
client = boto3.client('ecr', region_name='us-east-1')
|
||||||
|
_ = client.create_repository(
|
||||||
|
repositoryName='test_repository'
|
||||||
|
)
|
||||||
|
|
||||||
|
manifest = _create_image_manifest()
|
||||||
|
|
||||||
|
tags = ['v1', 'latest']
|
||||||
|
for tag in tags:
|
||||||
|
put_response = client.put_image(
|
||||||
|
repositoryName='test_repository',
|
||||||
|
imageManifest=json.dumps(manifest),
|
||||||
|
imageTag=tag
|
||||||
|
)
|
||||||
|
|
||||||
|
describe_response = client.describe_images(repositoryName='test_repository')
|
||||||
|
image_digest = describe_response['imageDetails'][0]['imageDigest']
|
||||||
|
|
||||||
|
batch_delete_response = client.batch_delete_image(
|
||||||
|
registryId='012345678910',
|
||||||
|
repositoryName='test_repository',
|
||||||
|
imageIds=[
|
||||||
|
{
|
||||||
|
'imageDigest': image_digest,
|
||||||
|
'imageTag': 'v2'
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
type(batch_delete_response['imageIds']).should.be(list)
|
||||||
|
len(batch_delete_response['imageIds']).should.be(0)
|
||||||
|
|
||||||
|
type(batch_delete_response['failures']).should.be(list)
|
||||||
|
len(batch_delete_response['failures']).should.be(1)
|
||||||
|
|
||||||
|
batch_delete_response['failures'][0]['imageId']['imageDigest'].should.equal(image_digest)
|
||||||
|
batch_delete_response['failures'][0]['imageId']['imageTag'].should.equal("v2")
|
||||||
|
batch_delete_response['failures'][0]['failureCode'].should.equal("ImageNotFound")
|
||||||
|
batch_delete_response['failures'][0]['failureReason'].should.equal("Requested image not found")
|
||||||
|
Loading…
Reference in New Issue
Block a user