Implement image delete for exported images

This will allow an image delete command to be implemented so that
users can manually manage which images are available for deployment.

Blueprint: podman-support
Change-Id: Ia534c3344c52ae8e031a8e71392ef116a3cea237
This commit is contained in:
Steve Baker 2019-03-13 13:01:39 +13:00
parent 577188682a
commit c2b969b72b
3 changed files with 264 additions and 5 deletions

View File

@ -17,6 +17,7 @@ import collections
import hashlib import hashlib
import json import json
import os import os
import shutil
from oslo_log import log as logging from oslo_log import log as logging
@ -151,11 +152,8 @@ def export_manifest_config(target_url,
manifest_symlink_path = os.path.join(manifests_path, tag) manifest_symlink_path = os.path.join(manifests_path, tag)
manifest_path = os.path.join(manifest_dir_path, 'index.json') manifest_path = os.path.join(manifest_dir_path, 'index.json')
htaccess_path = os.path.join(manifest_dir_path, '.htaccess') htaccess_path = os.path.join(manifest_dir_path, '.htaccess')
tags_dir_path = os.path.join(IMAGE_EXPORT_DIR, 'v2', image, 'tags')
tags_list_path = os.path.join(tags_dir_path, 'list')
make_dir(manifest_dir_path) make_dir(manifest_dir_path)
make_dir(tags_dir_path)
build_catalog() build_catalog()
headers = collections.OrderedDict() headers = collections.OrderedDict()
@ -172,7 +170,16 @@ def export_manifest_config(target_url,
if os.path.exists(manifest_symlink_path): if os.path.exists(manifest_symlink_path):
os.remove(manifest_symlink_path) os.remove(manifest_symlink_path)
os.symlink(manifest_dir_path, manifest_symlink_path) os.symlink(manifest_dir_path, manifest_symlink_path)
build_tags_list(image)
def build_tags_list(image):
manifests_path = os.path.join(
IMAGE_EXPORT_DIR, 'v2', image, 'manifests')
tags_dir_path = os.path.join(IMAGE_EXPORT_DIR, 'v2', image, 'tags')
tags_list_path = os.path.join(tags_dir_path, 'list')
LOG.debug('Rebuilding %s' % tags_dir_path)
make_dir(tags_dir_path)
tags = [] tags = []
for f in os.listdir(manifests_path): for f in os.listdir(manifests_path):
f_path = os.path.join(manifests_path, f) f_path = os.path.join(manifests_path, f)
@ -203,3 +210,75 @@ def build_catalog():
catalog = {'repositories': catalog_entries} catalog = {'repositories': catalog_entries}
with open(catalog_path, 'w+b') as f: with open(catalog_path, 'w+b') as f:
f.write(json.dumps(catalog, ensure_ascii=False).encode('utf-8')) f.write(json.dumps(catalog, ensure_ascii=False).encode('utf-8'))
def delete_image(image_url):
image, tag = image_tag_from_url(image_url)
manifests_path = os.path.join(
IMAGE_EXPORT_DIR, 'v2', image, 'manifests')
manifest_symlink_path = os.path.join(manifests_path, tag)
# delete manifest_symlink_path
LOG.debug('Deleting tag symlink %s' % manifest_symlink_path)
os.remove(manifest_symlink_path)
build_tags_list(image)
# build list of manifest_dir_path without symlinks
linked_manifest_dirs = set()
manifest_dirs = set()
for f in os.listdir(manifests_path):
f_path = os.path.join(manifests_path, f)
if os.path.islink(f_path):
linked_manifest_dirs.add(os.readlink(f_path))
elif os.path.isdir(f_path):
manifest_dirs.add(f_path)
delete_manifest_dirs = manifest_dirs.difference(linked_manifest_dirs)
# delete list of manifest_dir_path without symlinks
for manifest_dir in delete_manifest_dirs:
LOG.debug('Deleting manifest %s' % manifest_dir)
shutil.rmtree(manifest_dir)
# load all remaining manifests and build the set of of in-use blobs,
# delete any layer blob not in-use
reffed_blobs = set()
blobs_path = os.path.join(IMAGE_EXPORT_DIR, 'v2', image, 'blobs')
def add_reffed_blob(digest):
blob_path = os.path.join(blobs_path, digest)
gz_blob_path = os.path.join(blobs_path, '%s.gz' % digest)
if os.path.isfile(gz_blob_path):
reffed_blobs.add(gz_blob_path)
elif os.path.isfile(blob_path):
reffed_blobs.add(blob_path)
for manifest_dir in linked_manifest_dirs:
manifest_path = os.path.join(manifest_dir, 'index.json')
with open(manifest_path) as f:
manifest = json.load(f)
v1manifest = manifest.get('schemaVersion', 2) == 1
if v1manifest:
for digest in manifest.get('fsLayers', []):
add_reffed_blob(digest)
else:
for layer in manifest.get('layers', []):
add_reffed_blob(layer.get('digest'))
add_reffed_blob(manifest.get('config', {}).get('digest'))
all_blobs = set([os.path.join(blobs_path, b)
for b in os.listdir(blobs_path)])
delete_blobs = all_blobs.difference(reffed_blobs)
for blob in delete_blobs:
LOG.debug('Deleting layer blob %s' % blob)
os.remove(blob)
# if no files left in manifests_path, delete the whole image
if not os.listdir(manifests_path):
image_path = os.path.join(IMAGE_EXPORT_DIR, 'v2', image)
LOG.debug('Deleting image directory %s' % image_path)
shutil.rmtree(image_path)
# rebuild the catalog for the current image list
build_catalog()

View File

@ -477,6 +477,14 @@ class BaseImageUploader(object):
image_url = self._image_to_url(image) image_url = self._image_to_url(image)
return self._inspect(image_url, session) return self._inspect(image_url, session)
def delete(self, image, session=None):
image_url = self._image_to_url(image)
return self._delete(image_url, session)
@classmethod
def _delete(cls, image, session=None):
raise NotImplementedError()
@classmethod @classmethod
@tenacity.retry( # Retry up to 5 times with jittered exponential backoff @tenacity.retry( # Retry up to 5 times with jittered exponential backoff
reraise=True, reraise=True,
@ -782,7 +790,7 @@ class SkopeoImageUploader(BaseImageUploader):
return out return out
@classmethod @classmethod
def _delete(cls, image_url): def _delete(cls, image_url, session=None):
insecure = cls.is_insecure_registry(image_url.netloc) insecure = cls.is_insecure_registry(image_url.netloc)
image = image_url.geturl() image = image_url.geturl()
LOG.info('Deleting %s' % image) LOG.info('Deleting %s' % image)
@ -1513,9 +1521,18 @@ class PythonImageUploader(BaseImageUploader):
} }
@classmethod @classmethod
def _delete(cls, image_url): def _delete_from_registry(cls, image_url, session=None):
if not cls._detect_target_export(image_url, session):
raise NotImplementedError(
'Deleting not supported via the registry API')
return image_export.delete_image(image_url)
@classmethod
def _delete(cls, image_url, session=None):
image = image_url.geturl() image = image_url.geturl()
LOG.info('Deleting %s' % image) LOG.info('Deleting %s' % image)
if image_url.scheme == 'docker':
return cls._delete_from_registry(image_url, session)
if image_url.scheme != 'containers-storage': if image_url.scheme != 'containers-storage':
raise ImageUploaderException('Delete not implemented for %s' % raise ImageUploaderException('Delete not implemented for %s' %
image_url.geturl()) image_url.geturl())

View File

@ -224,3 +224,166 @@ Header set ETag "%s"
self.assertEqual(manifest_str, f.read()) self.assertEqual(manifest_str, f.read())
with open(manifest_htaccess_path, 'r') as f: with open(manifest_htaccess_path, 'r') as f:
self.assertEqual(expected_htaccess, f.read()) self.assertEqual(expected_htaccess, f.read())
def _write_test_image(self, url, manifest):
image, tag = image_uploader.BaseImageUploader._image_tag_from_url(
url)
blob_dir = os.path.join(
image_export.IMAGE_EXPORT_DIR, 'v2', image[1:], 'blobs')
image_export.make_dir(blob_dir)
config_str = '{"config": {}}'
manifest_str = json.dumps(manifest)
calc_digest = hashlib.sha256()
calc_digest.update(manifest_str.encode('utf-8'))
manifest_digest = 'sha256:%s' % calc_digest.hexdigest()
image_export.export_manifest_config(
url, manifest_str,
image_uploader.MEDIA_MANIFEST_V2, config_str
)
for layer in manifest['layers']:
blob_path = os.path.join(blob_dir, '%s.gz' % layer['digest'])
with open(blob_path, 'w+') as f:
f.write('The Blob')
return manifest_digest
def assertFiles(self, dirs, files, symlinks, deleted):
for d in dirs:
self.assertTrue(os.path.isdir(d), 'is dir: %s' % d)
for f in files:
self.assertTrue(os.path.isfile(f), 'is file: %s' % f)
for d in deleted:
self.assertFalse(os.path.exists(d), 'not exists: %s' % d)
for l, f in symlinks.items():
self.assertTrue(os.path.islink(l), 'is link: %s' % l)
self.assertEqual(f, os.readlink(l))
self.assertTrue(os.path.exists(f),
'link target exists: %s' % f)
def test_delete_image(self):
url1 = urlparse('docker://localhost:8787/t/nova-api:latest')
url2 = urlparse('docker://localhost:8787/t/nova-api:abc')
manifest_1 = {
'config': {
'digest': 'sha256:1234',
'size': 2,
'mediaType': 'application/vnd.docker.container.image.v1+json'
},
'layers': [
{'digest': 'sha256:aeb786'},
{'digest': 'sha256:4dc536'},
],
'mediaType': 'application/vnd.docker.'
'distribution.manifest.v2+json',
}
manifest_2 = {
'config': {
'digest': 'sha256:5678',
'size': 2,
'mediaType': 'application/vnd.docker.container.image.v1+json'
},
'layers': [
{'digest': 'sha256:aeb786'}, # shared with manifest_1
{'digest': 'sha256:eeeeee'}, # different to manifest_1
],
'mediaType': 'application/vnd.docker.'
'distribution.manifest.v2+json',
}
m1_digest = self._write_test_image(
url=url1,
manifest=manifest_1
)
m2_digest = self._write_test_image(
url=url2,
manifest=manifest_2
)
v2_dir = os.path.join(image_export.IMAGE_EXPORT_DIR, 'v2')
image_dir = os.path.join(v2_dir, 't/nova-api')
blob_dir = os.path.join(image_dir, 'blobs')
m_dir = os.path.join(image_dir, 'manifests')
# assert every directory, file and symlink for the 2 images
self.assertFiles(
dirs=[
v2_dir,
image_dir,
blob_dir,
m_dir,
os.path.join(m_dir, m1_digest),
os.path.join(m_dir, m2_digest),
],
files=[
os.path.join(m_dir, m1_digest, 'index.json'),
os.path.join(m_dir, m2_digest, 'index.json'),
os.path.join(blob_dir, 'sha256:1234'),
os.path.join(blob_dir, 'sha256:aeb786.gz'),
os.path.join(blob_dir, 'sha256:4dc536.gz'),
os.path.join(blob_dir, 'sha256:5678'),
os.path.join(blob_dir, 'sha256:eeeeee.gz'),
],
symlinks={
os.path.join(m_dir, 'latest'): os.path.join(m_dir, m1_digest),
os.path.join(m_dir, 'abc'): os.path.join(m_dir, m2_digest),
},
deleted=[]
)
image_export.delete_image(url2)
# assert files deleted for nova-api:abc
self.assertFiles(
dirs=[
v2_dir,
image_dir,
blob_dir,
m_dir,
os.path.join(m_dir, m1_digest),
],
files=[
os.path.join(m_dir, m1_digest, 'index.json'),
os.path.join(blob_dir, 'sha256:1234'),
os.path.join(blob_dir, 'sha256:aeb786.gz'),
os.path.join(blob_dir, 'sha256:4dc536.gz'),
],
symlinks={
os.path.join(m_dir, 'latest'): os.path.join(m_dir, m1_digest),
},
deleted=[
os.path.join(m_dir, 'abc'),
os.path.join(m_dir, m2_digest),
os.path.join(m_dir, m2_digest, 'index.json'),
os.path.join(blob_dir, 'sha256:5678'),
os.path.join(blob_dir, 'sha256:eeeeee.gz'),
]
)
image_export.delete_image(url1)
# assert all nova-api files deleted after deleting the last image
self.assertFiles(
dirs=[
v2_dir,
],
files=[],
symlinks={},
deleted=[
image_dir,
blob_dir,
m_dir,
os.path.join(m_dir, 'abc'),
os.path.join(m_dir, 'latest'),
os.path.join(m_dir, m1_digest),
os.path.join(m_dir, m1_digest, 'index.json'),
os.path.join(m_dir, m2_digest),
os.path.join(m_dir, m2_digest, 'index.json'),
os.path.join(blob_dir, 'sha256:5678'),
os.path.join(blob_dir, 'sha256:eeeeee.gz'),
os.path.join(blob_dir, 'sha256:1234'),
os.path.join(blob_dir, 'sha256:aeb786.gz'),
os.path.join(blob_dir, 'sha256:4dc536.gz'),
]
)