Implement a manifest builder, to allow for easier management of state around constructing manifests
This commit is contained in:
parent
1224930af8
commit
65d5be23c7
5 changed files with 326 additions and 5 deletions
|
@ -366,6 +366,13 @@ def get_image(repo, docker_image_id):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_image_by_db_id(id):
|
||||||
|
try:
|
||||||
|
return Image.get(id=id)
|
||||||
|
except Image.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def synthesize_v1_image(repo, image_storage_id, storage_image_size, docker_image_id,
|
def synthesize_v1_image(repo, image_storage_id, storage_image_size, docker_image_id,
|
||||||
created_date_str, comment, command, v1_json_metadata, parent_image=None):
|
created_date_str, comment, command, v1_json_metadata, parent_image=None):
|
||||||
""" Find an existing image with this docker image id, and if none exists, write one with the
|
""" Find an existing image with this docker image id, and if none exists, write one with the
|
||||||
|
|
|
@ -50,3 +50,16 @@ def requiresinput(input_name):
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
return inner
|
return inner
|
||||||
|
|
||||||
|
|
||||||
|
def optionalinput(input_name):
|
||||||
|
""" Marks a property on the data type as having an input be optional when invoked. """
|
||||||
|
def inner(func):
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(self, *args, **kwargs):
|
||||||
|
kwargs[input_name] = self._inputs.get(input_name)
|
||||||
|
result = func(self, *args, **kwargs)
|
||||||
|
return result
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
return inner
|
||||||
|
|
|
@ -6,7 +6,7 @@ from enum import Enum, unique
|
||||||
from cachetools import lru_cache
|
from cachetools import lru_cache
|
||||||
|
|
||||||
from data import model
|
from data import model
|
||||||
from data.registry_model.datatype import datatype, requiresinput
|
from data.registry_model.datatype import datatype, requiresinput, optionalinput
|
||||||
from image.docker.schema1 import DockerSchema1Manifest
|
from image.docker.schema1 import DockerSchema1Manifest
|
||||||
|
|
||||||
|
|
||||||
|
@ -17,17 +17,41 @@ class RepositoryReference(datatype('Repository', [])):
|
||||||
if repo_obj is None:
|
if repo_obj is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
return RepositoryReference(db_id=repo_obj.id)
|
return RepositoryReference(db_id=repo_obj.id,
|
||||||
|
inputs=dict(
|
||||||
|
kind=model.repository.get_repo_kind_name(repo_obj),
|
||||||
|
is_public=model.repository.is_repository_public(repo_obj)
|
||||||
|
))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def for_id(cls, repo_id):
|
def for_id(cls, repo_id):
|
||||||
return RepositoryReference(db_id=repo_id)
|
return RepositoryReference(db_id=repo_id, inputs=dict(kind=None, is_public=None))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def _repository_obj(self):
|
def _repository_obj(self):
|
||||||
return model.repository.lookup_repository(self._db_id)
|
return model.repository.lookup_repository(self._db_id)
|
||||||
|
|
||||||
|
@property
|
||||||
|
@optionalinput('kind')
|
||||||
|
def kind(self, kind):
|
||||||
|
""" Returns the kind of the repository. """
|
||||||
|
return kind or model.repository.get_repo_kind_name(self._repositry_obj)
|
||||||
|
|
||||||
|
@property
|
||||||
|
@optionalinput('is_public')
|
||||||
|
def is_public(self, is_public):
|
||||||
|
""" Returns whether the repository is public. """
|
||||||
|
if is_public is not None:
|
||||||
|
return is_public
|
||||||
|
|
||||||
|
return model.repository.is_repository_public(self._repository_obj)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self):
|
||||||
|
""" Returns the database ID of the repository. """
|
||||||
|
return self._db_id
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def namespace_name(self):
|
def namespace_name(self):
|
||||||
""" Returns the namespace name of this repository.
|
""" Returns the namespace name of this repository.
|
||||||
|
@ -119,7 +143,8 @@ class Manifest(datatype('Manifest', ['digest', 'manifest_bytes'])):
|
||||||
|
|
||||||
|
|
||||||
class LegacyImage(datatype('LegacyImage', ['docker_image_id', 'created', 'comment', 'command',
|
class LegacyImage(datatype('LegacyImage', ['docker_image_id', 'created', 'comment', 'command',
|
||||||
'image_size', 'aggregate_size', 'uploading'])):
|
'image_size', 'aggregate_size', 'uploading',
|
||||||
|
'v1_metadata_string'])):
|
||||||
""" LegacyImage represents a Docker V1-style image found in a repository. """
|
""" LegacyImage represents a Docker V1-style image found in a repository. """
|
||||||
@classmethod
|
@classmethod
|
||||||
def for_image(cls, image, images_map=None, tags_map=None, blob=None):
|
def for_image(cls, image, images_map=None, tags_map=None, blob=None):
|
||||||
|
@ -134,6 +159,7 @@ class LegacyImage(datatype('LegacyImage', ['docker_image_id', 'created', 'commen
|
||||||
created=image.created,
|
created=image.created,
|
||||||
comment=image.comment,
|
comment=image.comment,
|
||||||
command=image.command,
|
command=image.command,
|
||||||
|
v1_metadata_string=image.v1_json_metadata,
|
||||||
image_size=image.storage.image_size,
|
image_size=image.storage.image_size,
|
||||||
aggregate_size=image.aggregate_size,
|
aggregate_size=image.aggregate_size,
|
||||||
uploading=image.storage.uploading)
|
uploading=image.storage.uploading)
|
||||||
|
@ -143,7 +169,8 @@ class LegacyImage(datatype('LegacyImage', ['docker_image_id', 'created', 'commen
|
||||||
@requiresinput('ancestor_id_list')
|
@requiresinput('ancestor_id_list')
|
||||||
def parents(self, images_map, ancestor_id_list):
|
def parents(self, images_map, ancestor_id_list):
|
||||||
""" Returns the parent images for this image. Raises an exception if the parents have
|
""" Returns the parent images for this image. Raises an exception if the parents have
|
||||||
not been loaded before this property is invoked.
|
not been loaded before this property is invoked. Parents are returned starting at the
|
||||||
|
leaf image.
|
||||||
"""
|
"""
|
||||||
return [LegacyImage.for_image(images_map[ancestor_id], images_map=images_map)
|
return [LegacyImage.for_image(images_map[ancestor_id], images_map=images_map)
|
||||||
for ancestor_id in reversed(ancestor_id_list)
|
for ancestor_id in reversed(ancestor_id_list)
|
||||||
|
|
180
data/registry_model/manifestbuilder.py
Normal file
180
data/registry_model/manifestbuilder.py
Normal file
|
@ -0,0 +1,180 @@
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
|
from flask import session
|
||||||
|
|
||||||
|
from data import model
|
||||||
|
from data.database import db_transaction
|
||||||
|
from data.registry_model import registry_model
|
||||||
|
|
||||||
|
|
||||||
|
ManifestLayer = namedtuple('ManifestLayer', ['layer_id', 'v1_metadata', 'db_id'])
|
||||||
|
_BuilderState = namedtuple('_BuilderState', ['builder_id', 'images', 'tags', 'checksums'])
|
||||||
|
|
||||||
|
|
||||||
|
_SESSION_KEY = '__manifestbuilder'
|
||||||
|
|
||||||
|
|
||||||
|
def create_manifest_builder(repository_ref):
|
||||||
|
""" Creates a new manifest builder for populating manifests under the specified repository
|
||||||
|
and returns it. Returns None if the builder could not be constructed.
|
||||||
|
"""
|
||||||
|
builder_id = str(uuid.uuid4())
|
||||||
|
builder = _ManifestBuilder(repository_ref, _BuilderState(builder_id, {}, {}, {}))
|
||||||
|
builder._save_to_session()
|
||||||
|
return builder
|
||||||
|
|
||||||
|
|
||||||
|
def lookup_manifest_builder(repository_ref, builder_id):
|
||||||
|
""" Looks up the manifest builder with the given ID under the specified repository and returns
|
||||||
|
it or None if none.
|
||||||
|
"""
|
||||||
|
builder_state_tuple = session.get(_SESSION_KEY)
|
||||||
|
if builder_state_tuple is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
builder_state = _BuilderState(*builder_state_tuple)
|
||||||
|
if builder_state.builder_id != builder_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return _ManifestBuilder(repository_ref, builder_state)
|
||||||
|
|
||||||
|
|
||||||
|
class _ManifestBuilder(object):
|
||||||
|
""" Helper class which provides an interface for bookkeeping the layers and configuration of
|
||||||
|
manifests being constructed.
|
||||||
|
"""
|
||||||
|
def __init__(self, repository_ref, builder_state):
|
||||||
|
self._repository_ref = repository_ref
|
||||||
|
self._builder_state = builder_state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def builder_id(self):
|
||||||
|
""" Returns the unique ID for this builder. """
|
||||||
|
return self._builder_state.builder_id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def committed_tags(self):
|
||||||
|
""" Returns the tags committed by this builder, if any. """
|
||||||
|
return [registry_model.get_repo_tag(self._repository_ref, tag_name, include_legacy_image=True)
|
||||||
|
for tag_name in self._builder_state.tags.keys()]
|
||||||
|
|
||||||
|
def start_layer(self, layer_id, v1_metadata, location_name, calling_user, temp_tag_expiration):
|
||||||
|
""" Starts a new layer with the given ID to be placed into a manifest. Returns the layer
|
||||||
|
started or None if an error occurred.
|
||||||
|
"""
|
||||||
|
# Ensure the repository still exists.
|
||||||
|
repository = model.repository.lookup_repository(self._repository_ref._db_id)
|
||||||
|
if repository is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
namespace_name = repository.namespace_user.username
|
||||||
|
repo_name = repository.name
|
||||||
|
|
||||||
|
# Sanity check that the ID matches the v1 metadata.
|
||||||
|
if layer_id != v1_metadata['id']:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Ensure the parent already exists in the repository.
|
||||||
|
parent_id = v1_metadata.get('parent', None)
|
||||||
|
parent_image = None
|
||||||
|
|
||||||
|
if parent_id is not None:
|
||||||
|
parent_image = model.image.get_repo_image(namespace_name, repo_name, parent_id)
|
||||||
|
if parent_image is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check to see if this layer already exists in the repository. If so, we can skip the creation.
|
||||||
|
existing_image = registry_model.get_legacy_image(self._repository_ref, layer_id)
|
||||||
|
if existing_image is not None:
|
||||||
|
self._builder_state.images[layer_id] = existing_image.id
|
||||||
|
self._save_to_session()
|
||||||
|
return ManifestLayer(layer_id, v1_metadata, existing_image.id)
|
||||||
|
|
||||||
|
with db_transaction():
|
||||||
|
# Otherwise, create a new legacy image and point a temporary tag at it.
|
||||||
|
created = model.image.find_create_or_link_image(layer_id, repository, calling_user, {},
|
||||||
|
location_name)
|
||||||
|
model.tag.create_temporary_hidden_tag(repository, created, temp_tag_expiration)
|
||||||
|
|
||||||
|
# Mark the image as uploading.
|
||||||
|
created.storage.uploading = True
|
||||||
|
created.storage.save()
|
||||||
|
|
||||||
|
# Save its V1 metadata.
|
||||||
|
command_list = v1_metadata.get('container_config', {}).get('Cmd', None)
|
||||||
|
command = json.dumps(command_list) if command_list else None
|
||||||
|
|
||||||
|
model.image.set_image_metadata(layer_id, namespace_name, repo_name,
|
||||||
|
v1_metadata.get('created'),
|
||||||
|
v1_metadata.get('comment'),
|
||||||
|
command, json.dumps(v1_metadata),
|
||||||
|
parent=parent_image)
|
||||||
|
|
||||||
|
# Save the changes to the builder.
|
||||||
|
self._builder_state.images[layer_id] = created.id
|
||||||
|
self._save_to_session()
|
||||||
|
|
||||||
|
return ManifestLayer(layer_id, v1_metadata, created.id)
|
||||||
|
|
||||||
|
def lookup_layer(self, layer_id):
|
||||||
|
""" Returns a layer with the given ID under this builder. If none exists, returns None. """
|
||||||
|
if layer_id not in self._builder_state.images:
|
||||||
|
return None
|
||||||
|
|
||||||
|
image = model.image.get_image_by_db_id(self._builder_state.images[layer_id])
|
||||||
|
if image is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return ManifestLayer(layer_id, json.loads(image.v1_json_metadata), image.id)
|
||||||
|
|
||||||
|
def assign_layer_blob(self, layer, blob, computed_checksums):
|
||||||
|
""" Assigns a blob to a layer. """
|
||||||
|
assert blob
|
||||||
|
|
||||||
|
repo_image = model.image.get_image_by_db_id(layer.db_id)
|
||||||
|
if repo_image is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
with db_transaction():
|
||||||
|
existing_storage = repo_image.storage
|
||||||
|
repo_image.storage = blob._db_id
|
||||||
|
repo_image.save()
|
||||||
|
existing_storage.delete_instance(recursive=True)
|
||||||
|
|
||||||
|
self._builder_state.checksums[layer.layer_id] = computed_checksums
|
||||||
|
self._save_to_session()
|
||||||
|
return True
|
||||||
|
|
||||||
|
def validate_layer_checksum(self, layer, checksum):
|
||||||
|
""" Returns whether the checksum for a layer matches that specified.
|
||||||
|
"""
|
||||||
|
return checksum in self._builder_state.checksums.get(layer.layer_id)
|
||||||
|
|
||||||
|
def commit_tag_and_manifest(self, tag_name, layer):
|
||||||
|
""" Commits a new tag + manifest for that tag to the repository with the given name,
|
||||||
|
pointing to the given layer.
|
||||||
|
"""
|
||||||
|
legacy_image = registry_model.get_legacy_image(self._repository_ref, layer.layer_id)
|
||||||
|
if legacy_image is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tag = registry_model.retarget_tag(self._repository_ref, tag_name, legacy_image)
|
||||||
|
if tag is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._builder_state.tags[tag_name] = tag._db_id
|
||||||
|
self._save_to_session()
|
||||||
|
return tag
|
||||||
|
|
||||||
|
def done(self):
|
||||||
|
""" Marks the manifest builder as complete and disposes of any state. This call is optional
|
||||||
|
and it is expected manifest builders will eventually time out if unusued for an
|
||||||
|
extended period of time.
|
||||||
|
"""
|
||||||
|
session.pop(_SESSION_KEY, None)
|
||||||
|
|
||||||
|
def _save_to_session(self):
|
||||||
|
session[_SESSION_KEY] = self._builder_state
|
94
data/registry_model/test/test_manifestbuilder.py
Normal file
94
data/registry_model/test/test_manifestbuilder.py
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mock import patch
|
||||||
|
|
||||||
|
from data.registry_model.blobuploader import BlobUploadSettings, upload_blob
|
||||||
|
from data.registry_model.manifestbuilder import create_manifest_builder, lookup_manifest_builder
|
||||||
|
from data.registry_model.registry_pre_oci_model import PreOCIModel
|
||||||
|
|
||||||
|
from storage.distributedstorage import DistributedStorage
|
||||||
|
from storage.fakestorage import FakeStorage
|
||||||
|
from test.fixtures import *
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def pre_oci_model(initialized_db):
|
||||||
|
return PreOCIModel()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def fake_session():
|
||||||
|
with patch('data.registry_model.manifestbuilder.session', {}):
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('layers', [
|
||||||
|
pytest.param([('someid', None, 'some data')], id='Single layer'),
|
||||||
|
pytest.param([('parentid', None, 'some parent data'),
|
||||||
|
('someid', 'parentid', 'some data')],
|
||||||
|
id='Multi layer'),
|
||||||
|
])
|
||||||
|
def test_build_manifest(layers, fake_session, pre_oci_model):
|
||||||
|
repository_ref = pre_oci_model.lookup_repository('devtable', 'complex')
|
||||||
|
storage = DistributedStorage({'local_us': FakeStorage(None)}, ['local_us'])
|
||||||
|
settings = BlobUploadSettings('2M', 512 * 1024, 3600)
|
||||||
|
app_config = {'TESTING': True}
|
||||||
|
|
||||||
|
builder = create_manifest_builder(repository_ref)
|
||||||
|
assert lookup_manifest_builder(repository_ref, 'anotherid') is None
|
||||||
|
assert lookup_manifest_builder(repository_ref, builder.builder_id) is not None
|
||||||
|
|
||||||
|
blobs_by_layer = {}
|
||||||
|
for layer_id, parent_id, layer_bytes in layers:
|
||||||
|
# Start a new layer.
|
||||||
|
assert builder.start_layer(layer_id, {'id': layer_id, 'parent': parent_id}, 'local_us', None,
|
||||||
|
60)
|
||||||
|
|
||||||
|
checksum = hashlib.sha1(layer_bytes).hexdigest()
|
||||||
|
|
||||||
|
# Assign it a blob.
|
||||||
|
with upload_blob(repository_ref, storage, settings) as uploader:
|
||||||
|
uploader.upload_chunk(app_config, BytesIO(layer_bytes))
|
||||||
|
blob = uploader.commit_to_blob(app_config)
|
||||||
|
blobs_by_layer[layer_id] = blob
|
||||||
|
builder.assign_layer_blob(builder.lookup_layer(layer_id), blob, [checksum])
|
||||||
|
|
||||||
|
# Validate the checksum.
|
||||||
|
assert builder.validate_layer_checksum(builder.lookup_layer(layer_id), checksum)
|
||||||
|
|
||||||
|
# Commit the manifest to a tag.
|
||||||
|
tag = builder.commit_tag_and_manifest('somenewtag', builder.lookup_layer(layers[-1][0]))
|
||||||
|
assert tag
|
||||||
|
assert tag in builder.committed_tags
|
||||||
|
|
||||||
|
# Verify the legacy image for the tag.
|
||||||
|
found = pre_oci_model.get_repo_tag(repository_ref, 'somenewtag', include_legacy_image=True)
|
||||||
|
assert found
|
||||||
|
assert found.name == 'somenewtag'
|
||||||
|
assert found.legacy_image.docker_image_id == layers[-1][0]
|
||||||
|
|
||||||
|
# Verify the blob and manifest.
|
||||||
|
manifest = pre_oci_model.get_manifest_for_tag(found)
|
||||||
|
assert manifest
|
||||||
|
|
||||||
|
parsed = manifest.get_parsed_manifest()
|
||||||
|
assert len(list(parsed.layers)) == len(layers)
|
||||||
|
|
||||||
|
for index, (layer_id, parent_id, layer_bytes) in enumerate(layers):
|
||||||
|
assert list(parsed.blob_digests)[index] == blobs_by_layer[layer_id].digest
|
||||||
|
assert list(parsed.layers)[index].v1_metadata.image_id == layer_id
|
||||||
|
assert list(parsed.layers)[index].v1_metadata.parent_image_id == parent_id
|
||||||
|
|
||||||
|
assert parsed.leaf_layer_v1_image_id == layers[-1][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_manifest_missing_parent(fake_session, pre_oci_model):
|
||||||
|
repository_ref = pre_oci_model.lookup_repository('devtable', 'complex')
|
||||||
|
builder = create_manifest_builder(repository_ref)
|
||||||
|
|
||||||
|
assert builder.start_layer('somelayer', {'id': 'somelayer', 'parent': 'someparent'},
|
||||||
|
'local_us', None, 60) is None
|
Reference in a new issue