Use a typemap file instead of symlinks for tags

To support manifest lists, the response for requesting a tagged
manifest will need to return different contents based on the
content-type requested. Representing a tag as a symlink to the
manifest directory is not flexible enough for this need.

This change switches over to a type-map[1] based implementation so
that future changes can add entries to different files based on the
content-type. Whenever any write operation is performed on an image,
any existing symlink based tags will be migrated to a typemap.

[1] https://httpd.apache.org/docs/2.4/mod/mod_negotiation.html#typemaps
Change-Id: I0e56e1d74ef8468f1b89da5329a8f1ced711c0f3
This commit is contained in:
Steve Baker 2019-04-16 14:27:16 +12:00
parent 08ae3286c7
commit 35cfa6d363
2 changed files with 184 additions and 28 deletions

View File

@ -26,6 +26,26 @@ LOG = logging.getLogger(__name__)
IMAGE_EXPORT_DIR = '/var/lib/image-serve'
MEDIA_TYPES = (
MEDIA_MANIFEST_V1,
MEDIA_MANIFEST_V1_SIGNED,
MEDIA_MANIFEST_V2,
) = (
'application/vnd.docker.distribution.manifest.v1+json',
'application/vnd.docker.distribution.manifest.v1+prettyjws',
'application/vnd.docker.distribution.manifest.v2+json',
)
TYPE_KEYS = (
TYPE_KEY_URI,
TYPE_KEY_TYPE
) = (
'URI',
'Content-Type'
)
TYPE_MAP_EXTENSION = '.type-map'
def make_dir(path):
if os.path.exists(path):
@ -148,14 +168,18 @@ def export_manifest_config(target_url,
manifests_path = os.path.join(
IMAGE_EXPORT_DIR, 'v2', image, 'manifests')
manifests_htaccess_path = os.path.join(manifests_path, '.htaccess')
manifest_dir_path = os.path.join(manifests_path, manifest_digest)
manifest_symlink_path = os.path.join(manifests_path, tag)
manifest_path = os.path.join(manifest_dir_path, 'index.json')
htaccess_path = os.path.join(manifest_dir_path, '.htaccess')
make_dir(manifest_dir_path)
build_catalog()
with open(manifests_htaccess_path, 'w+') as f:
f.write('AddHandler type-map %s\n' % TYPE_MAP_EXTENSION)
f.write('MultiviewsMatch Handlers\n')
headers = collections.OrderedDict()
headers['Content-Type'] = manifest_type
headers['Docker-Content-Digest'] = manifest_digest
@ -165,14 +189,53 @@ def export_manifest_config(target_url,
f.write('Header set %s "%s"\n' % header)
with open(manifest_path, 'w+b') as f:
f.write(manifest_str.encode('utf-8'))
manifest_data = manifest_str.encode('utf-8')
f.write(manifest_data)
if os.path.exists(manifest_symlink_path):
os.remove(manifest_symlink_path)
os.symlink(manifest_dir_path, manifest_symlink_path)
write_type_map_file(image, tag, manifest_digest)
build_tags_list(image)
def write_type_map_file(image, tag, manifest_digest):
manifests_path = os.path.join(
IMAGE_EXPORT_DIR, 'v2', image, 'manifests')
type_map_path = os.path.join(manifests_path, '%s%s' %
(tag, TYPE_MAP_EXTENSION))
with open(type_map_path, 'w+') as f:
f.write('URI: %s\n\n' % tag)
f.write('Content-Type: %s\n' % MEDIA_MANIFEST_V2)
f.write('URI: %s/index.json\n\n' % manifest_digest)
def parse_type_map_file(type_map_path):
uri = None
content_type = None
type_map = {}
with open(type_map_path, 'r') as f:
for l in f:
line = l[:-1]
if not line:
if uri and content_type:
type_map[content_type] = uri
uri = None
content_type = None
else:
key, value = line.split(': ')
if key == TYPE_KEY_URI:
uri = value
elif key == TYPE_KEY_TYPE:
content_type = value
return type_map
def migrate_to_type_map_file(image, manifest_symlink_path):
tag = os.path.split(manifest_symlink_path)[-1]
manifest_dir = os.readlink(manifest_symlink_path)
manifest_digest = os.path.split(manifest_dir)[-1]
write_type_map_file(image, tag, manifest_digest)
os.remove(manifest_symlink_path)
def build_tags_list(image):
manifests_path = os.path.join(
IMAGE_EXPORT_DIR, 'v2', image, 'manifests')
@ -185,6 +248,9 @@ def build_tags_list(image):
f_path = os.path.join(manifests_path, f)
if os.path.islink(f_path):
tags.append(f)
migrate_to_type_map_file(image, f_path)
if f.endswith(TYPE_MAP_EXTENSION):
tags.append(f[:-len(TYPE_MAP_EXTENSION)])
tags_data = {
"name": image,
@ -216,11 +282,18 @@ 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)
manifest_symlink_path = os.path.join(manifests_path, tag)
if os.path.exists(manifest_symlink_path):
LOG.debug('Deleting legacy tag symlink %s' % manifest_symlink_path)
os.remove(manifest_symlink_path)
type_map_path = os.path.join(manifests_path, '%s%s' %
(tag, TYPE_MAP_EXTENSION))
if os.path.exists(type_map_path):
LOG.debug('Deleting typemap file %s' % type_map_path)
os.remove(type_map_path)
build_tags_list(image)
# build list of manifest_dir_path without symlinks
@ -228,8 +301,11 @@ def delete_image(image_url):
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))
if f_path.endswith(TYPE_MAP_EXTENSION):
for uri in parse_type_map_file(f_path).values():
linked_manifest_dir = os.path.dirname(
os.path.join(manifests_path, uri))
linked_manifest_dirs.add(linked_manifest_dir)
elif os.path.isdir(f_path):
manifest_dirs.add(f_path)
@ -275,7 +351,8 @@ def delete_image(image_url):
os.remove(blob)
# if no files left in manifests_path, delete the whole image
if not os.listdir(manifests_path):
remaining = os.listdir(manifests_path)
if not remaining or remaining == ['.htaccess']:
image_path = os.path.join(IMAGE_EXPORT_DIR, 'v2', image)
LOG.debug('Deleting image directory %s' % image_path)
shutil.rmtree(image_path)

View File

@ -225,6 +225,95 @@ Header set ETag "%s"
with open(manifest_htaccess_path, 'r') as f:
self.assertEqual(expected_htaccess, f.read())
def test_write_parse_type_map_file(self):
manifest_dir_path = os.path.join(
image_export.IMAGE_EXPORT_DIR,
'v2/foo/bar/manifests'
)
map_file_path = os.path.join(
image_export.IMAGE_EXPORT_DIR,
manifest_dir_path, 'latest.type-map'
)
image_export.make_dir(manifest_dir_path)
image_export.write_type_map_file(
'foo/bar', 'latest', 'sha256:1234abcd')
expected_map_file = '''URI: latest
Content-Type: application/vnd.docker.distribution.manifest.v2+json
URI: sha256:1234abcd/index.json
'''
# assert the file contains the expected content
with open(map_file_path, 'r') as f:
self.assertEqual(expected_map_file, f.read())
# assert parse_type_map_file correctly reads that file
self.assertEqual(
{
'application/vnd.docker.distribution.manifest.v2+json':
'sha256:1234abcd/index.json'
},
image_export.parse_type_map_file(map_file_path)
)
# assert a multi-entry file is correctly parsed
multi_map_file = '''URI: latest
Content-Type: application/vnd.docker.distribution.manifest.v2+json
URI: sha256:1234abcd/index.json
Content-Type: application/vnd.docker.distribution.manifest.list.v2+json
URI: sha256:eeeeeeee/index.json
'''
with open(map_file_path, 'w+') as f:
f.write(multi_map_file)
self.assertEqual(
{
'application/vnd.docker.distribution.manifest.v2+json':
'sha256:1234abcd/index.json',
'application/vnd.docker.distribution.manifest.list.v2+json':
'sha256:eeeeeeee/index.json'
},
image_export.parse_type_map_file(map_file_path)
)
def test_migrate_to_type_map_file(self):
manifest_dir_path = os.path.join(
image_export.IMAGE_EXPORT_DIR,
'v2/foo/bar/manifests'
)
map_file_path = os.path.join(
image_export.IMAGE_EXPORT_DIR,
manifest_dir_path, 'latest.type-map'
)
symlink_path = os.path.join(
image_export.IMAGE_EXPORT_DIR,
manifest_dir_path, 'latest'
)
manifest_path = os.path.join(
image_export.IMAGE_EXPORT_DIR,
manifest_dir_path, 'sha256:1234abcd'
)
image_export.make_dir(manifest_dir_path)
# create legacy symlink
os.symlink(manifest_path, symlink_path)
# run the migration
image_export.migrate_to_type_map_file('foo/bar', symlink_path)
expected_map_file = '''URI: latest
Content-Type: application/vnd.docker.distribution.manifest.v2+json
URI: sha256:1234abcd/index.json
'''
# assert the migrated file contains the expected content
with open(map_file_path, 'r') as f:
self.assertEqual(expected_map_file, f.read())
def _write_test_image(self, url, manifest):
image, tag = image_uploader.BaseImageUploader._image_tag_from_url(
url)
@ -256,18 +345,13 @@ Header set ETag "%s"
f.write('The Blob')
return manifest_digest
def assertFiles(self, dirs, files, symlinks, deleted):
def assertFiles(self, dirs, files, 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)
self.assertFalse(os.path.exists(d), 'deleted still exists: %s' % d)
def test_delete_image(self):
url1 = urlparse('docker://localhost:8787/t/nova-api:latest')
@ -309,7 +393,7 @@ Header set ETag "%s"
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
# assert every directory and file for the 2 images
self.assertFiles(
dirs=[
v2_dir,
@ -326,11 +410,9 @@ Header set ETag "%s"
os.path.join(blob_dir, 'sha256:4dc536.gz'),
os.path.join(blob_dir, 'sha256:5678'),
os.path.join(blob_dir, 'sha256:eeeeee.gz'),
os.path.join(m_dir, 'latest.type-map'),
os.path.join(m_dir, 'abc.type-map'),
],
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=[]
)
@ -349,10 +431,8 @@ Header set ETag "%s"
os.path.join(m_dir, m1_digest, 'index.json'),
os.path.join(blob_dir, 'sha256:aeb786.gz'),
os.path.join(blob_dir, 'sha256:4dc536.gz'),
os.path.join(m_dir, 'latest.type-map'),
],
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),
@ -370,7 +450,6 @@ Header set ETag "%s"
v2_dir,
],
files=[],
symlinks={},
deleted=[
image_dir,
blob_dir,