Merge pull request #3306 from quay/fix-manifest-ui

Fix manifest UI page to properly show the layers of manifests and show manifest lists
This commit is contained in:
Joseph Schorr 2018-12-11 16:53:36 -05:00 committed by GitHub
commit fc691cefb4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 186 additions and 91 deletions

View file

@ -13,6 +13,9 @@ class RegistryModelProxy(object):
self._model = oci_model if os.getenv('OCI_DATA_MODEL') == 'true' else pre_oci_model self._model = oci_model if os.getenv('OCI_DATA_MODEL') == 'true' else pre_oci_model
def setup_split(self, v22_whitelist): def setup_split(self, v22_whitelist):
if os.getenv('OCI_DATA_MODEL') == 'true':
return
logger.info('===============================') logger.info('===============================')
logger.info('Enabling split registry model with namespace whitelist `%s`', v22_whitelist) logger.info('Enabling split registry model with namespace whitelist `%s`', v22_whitelist)
logger.info('===============================') logger.info('===============================')

View file

@ -207,7 +207,14 @@ class Manifest(datatype('Manifest', ['digest', 'media_type', 'manifest_bytes']))
@property @property
@requiresinput('legacy_image') @requiresinput('legacy_image')
def legacy_image(self, legacy_image): def legacy_image(self, legacy_image):
""" Returns the legacy Docker V1-style image for this manifest. Note that this """ Returns the legacy Docker V1-style image for this manifest.
"""
return legacy_image
@property
@optionalinput('legacy_image')
def legacy_image_if_present(self, legacy_image):
""" Returns the legacy Docker V1-style image for this manifest. Note that this
will be None for manifests that point to other manifests instead of images. will be None for manifests that point to other manifests instead of images.
""" """
return legacy_image return legacy_image

View file

@ -194,13 +194,21 @@ class RegistryDataInterface(object):
def get_manifest_local_blobs(self, manifest, include_placements=False): def get_manifest_local_blobs(self, manifest, include_placements=False):
""" Returns the set of local blobs for the given manifest or None if none. """ """ Returns the set of local blobs for the given manifest or None if none. """
@abstractmethod
def list_manifest_layers(self, manifest, storage, include_placements=False):
""" Returns an *ordered list* of the layers found in the manifest, starting at the base
and working towards the leaf, including the associated Blob and its placements
(if specified). The layer information in `layer_info` will be of type
`image.docker.types.ManifestImageLayer`. Should not be called for a manifest list.
"""
@abstractmethod @abstractmethod
def list_parsed_manifest_layers(self, repository_ref, parsed_manifest, storage, def list_parsed_manifest_layers(self, repository_ref, parsed_manifest, storage,
include_placements=False): include_placements=False):
""" Returns an *ordered list* of the layers found in the parsed manifest, starting at the base """ Returns an *ordered list* of the layers found in the parsed manifest, starting at the base
and working towards the leaf, including the associated Blob and its placements and working towards the leaf, including the associated Blob and its placements
(if specified). The layer information in `layer_info` will be of type (if specified). The layer information in `layer_info` will be of type
`image.docker.types.ManifestImageLayer`. `image.docker.types.ManifestImageLayer`. Should not be called for a manifest list.
""" """
@abstractmethod @abstractmethod

View file

@ -115,7 +115,7 @@ class OCIModel(SharedModel, RegistryDataInterface):
legacy_image_id = database.ManifestLegacyImage.get(manifest=manifest).image.docker_image_id legacy_image_id = database.ManifestLegacyImage.get(manifest=manifest).image.docker_image_id
legacy_image = self.get_legacy_image(repository_ref, legacy_image_id, include_parents=True) legacy_image = self.get_legacy_image(repository_ref, legacy_image_id, include_parents=True)
except database.ManifestLegacyImage.DoesNotExist: except database.ManifestLegacyImage.DoesNotExist:
return None pass
return Manifest.for_manifest(manifest, legacy_image) return Manifest.for_manifest(manifest, legacy_image)
@ -414,11 +414,7 @@ class OCIModel(SharedModel, RegistryDataInterface):
legacy_image = oci.shared.get_legacy_image_for_manifest(manifest) legacy_image = oci.shared.get_legacy_image_for_manifest(manifest)
return Manifest.for_manifest(manifest, LegacyImage.for_image(legacy_image)) return Manifest.for_manifest(manifest, LegacyImage.for_image(legacy_image))
def list_manifest_layers(self, manifest, include_placements=False): def list_manifest_layers(self, manifest, storage, include_placements=False):
""" Returns an *ordered list* of the layers found in the manifest, starting at the base and
working towards the leaf, including the associated Blob and its placements (if specified).
Returns None if the manifest could not be parsed and validated.
"""
try: try:
manifest_obj = database.Manifest.get(id=manifest._db_id) manifest_obj = database.Manifest.get(id=manifest._db_id)
except database.Manifest.DoesNotExist: except database.Manifest.DoesNotExist:
@ -431,8 +427,8 @@ class OCIModel(SharedModel, RegistryDataInterface):
logger.exception('Could not parse and validate manifest `%s`', manifest._db_id) logger.exception('Could not parse and validate manifest `%s`', manifest._db_id)
return None return None
return self._list_manifest_layers(manifest_obj.repository_id, parsed, include_placements, return self._list_manifest_layers(manifest_obj.repository_id, parsed, storage,
by_manifest=True) include_placements, by_manifest=True)
def lookup_derived_image(self, manifest, verb, varying_metadata=None, include_placements=False): def lookup_derived_image(self, manifest, verb, varying_metadata=None, include_placements=False):
""" """

View file

@ -479,11 +479,7 @@ class PreOCIModel(SharedModel, RegistryDataInterface):
return Manifest.for_tag_manifest(tag_manifest) return Manifest.for_tag_manifest(tag_manifest)
def list_manifest_layers(self, manifest, include_placements=False): def list_manifest_layers(self, manifest, storage, include_placements=False):
""" Returns an *ordered list* of the layers found in the manifest, starting at the base and
working towards the leaf, including the associated Blob and its placements (if specified).
Returns None if the manifest could not be parsed and validated.
"""
try: try:
tag_manifest = database.TagManifest.get(id=manifest._db_id) tag_manifest = database.TagManifest.get(id=manifest._db_id)
except database.TagManifest.DoesNotExist: except database.TagManifest.DoesNotExist:
@ -497,7 +493,7 @@ class PreOCIModel(SharedModel, RegistryDataInterface):
return None return None
repo_ref = RepositoryReference.for_id(tag_manifest.tag.repository_id) repo_ref = RepositoryReference.for_id(tag_manifest.tag.repository_id)
return self.list_parsed_manifest_layers(repo_ref, parsed, include_placements) return self.list_parsed_manifest_layers(repo_ref, parsed, storage, include_placements)
def lookup_derived_image(self, manifest, verb, varying_metadata=None, include_placements=False): def lookup_derived_image(self, manifest, verb, varying_metadata=None, include_placements=False):
""" """

View file

@ -12,6 +12,7 @@ from data.registry_model.datatype import FromDictionaryException
from data.registry_model.datatypes import (RepositoryReference, Blob, TorrentInfo, BlobUpload, from data.registry_model.datatypes import (RepositoryReference, Blob, TorrentInfo, BlobUpload,
LegacyImage, ManifestLayer, DerivedImage) LegacyImage, ManifestLayer, DerivedImage)
from image.docker.schema1 import ManifestException, DockerSchema1ManifestBuilder from image.docker.schema1 import ManifestException, DockerSchema1ManifestBuilder
from image.docker.schema2 import EMPTY_LAYER_BLOB_DIGEST
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -344,14 +345,23 @@ class SharedModel:
working towards the leaf, including the associated Blob and its placements (if specified). working towards the leaf, including the associated Blob and its placements (if specified).
Returns None if the manifest could not be parsed and validated. Returns None if the manifest could not be parsed and validated.
""" """
assert not parsed.is_manifest_list
retriever = RepositoryContentRetriever(repo_id, storage)
requires_empty_blob = parsed.get_requires_empty_layer_blob(retriever)
storage_map = {} storage_map = {}
if parsed.local_blob_digests: blob_digests = list(parsed.local_blob_digests)
if requires_empty_blob:
blob_digests.append(EMPTY_LAYER_BLOB_DIGEST)
if blob_digests:
blob_query = model.storage.lookup_repo_storages_by_content_checksum(repo_id, blob_query = model.storage.lookup_repo_storages_by_content_checksum(repo_id,
parsed.local_blob_digests, blob_digests,
by_manifest=by_manifest) by_manifest=by_manifest)
storage_map = {blob.content_checksum: blob for blob in blob_query} storage_map = {blob.content_checksum: blob for blob in blob_query}
retriever = RepositoryContentRetriever(repo_id, storage)
layers = parsed.get_layers(retriever) layers = parsed.get_layers(retriever)
if layers is None: if layers is None:
logger.error('Could not load layers for manifest `%s`', parsed.digest) logger.error('Could not load layers for manifest `%s`', parsed.digest)

View file

@ -1,14 +1,17 @@
""" Manage the manifests of a repository. """ """ Manage the manifests of a repository. """
import json
import logging
from flask import request from flask import request
from app import label_validator from app import label_validator, storage
from data.model import InvalidLabelKeyException, InvalidMediaTypeException from data.model import InvalidLabelKeyException, InvalidMediaTypeException
from data.registry_model import registry_model from data.registry_model import registry_model
from digest import digest_tools from digest import digest_tools
from endpoints.api import (resource, nickname, require_repo_read, require_repo_write, from endpoints.api import (resource, nickname, require_repo_read, require_repo_write,
RepositoryParamResource, log_action, validate_json_request, RepositoryParamResource, log_action, validate_json_request,
path_param, parse_args, query_param, abort, api, path_param, parse_args, query_param, abort, api,
disallow_for_app_repositories) disallow_for_app_repositories, format_date)
from endpoints.api.image import image_dict from endpoints.api.image import image_dict
from endpoints.exception import NotFound from endpoints.exception import NotFound
from util.validation import VALID_LABEL_KEY_REGEX from util.validation import VALID_LABEL_KEY_REGEX
@ -18,6 +21,9 @@ BASE_MANIFEST_ROUTE = '/v1/repository/<apirepopath:repository>/manifest/<regex("
MANIFEST_DIGEST_ROUTE = BASE_MANIFEST_ROUTE.format(digest_tools.DIGEST_PATTERN) MANIFEST_DIGEST_ROUTE = BASE_MANIFEST_ROUTE.format(digest_tools.DIGEST_PATTERN)
ALLOWED_LABEL_MEDIA_TYPES = ['text/plain', 'application/json'] ALLOWED_LABEL_MEDIA_TYPES = ['text/plain', 'application/json']
logger = logging.getLogger(__name__)
def _label_dict(label): def _label_dict(label):
return { return {
'id': label.uuid, 'id': label.uuid,
@ -27,15 +33,45 @@ def _label_dict(label):
'media_type': label.media_type_name, 'media_type': label.media_type_name,
} }
def _layer_dict(manifest_layer, index):
# NOTE: The `command` in the layer is either a JSON string of an array (schema 1) or
# a single string (schema 2). The block below normalizes it to have the same format.
try:
command = json.loads(manifest_layer.command)
except (TypeError, ValueError):
command = [manifest_layer.command]
return {
'index': index,
'compressed_size': manifest_layer.compressed_size,
'is_remote': manifest_layer.is_remote,
'urls': manifest_layer.urls,
'command': command,
'blob_digest': str(manifest_layer.blob_digest),
'created_datetime': format_date(manifest_layer.created_datetime),
}
def _manifest_dict(manifest): def _manifest_dict(manifest):
image = None image = None
if manifest.legacy_image is not None: if manifest.legacy_image_if_present is not None:
image = image_dict(manifest.legacy_image, with_history=True) image = image_dict(manifest.legacy_image, with_history=True)
layers = None
if not manifest.is_manifest_list:
layers = registry_model.list_manifest_layers(manifest, storage)
if layers is None:
logger.debug('Missing layers for manifest `%s`', manifest.digest)
abort(404)
return { return {
'digest': manifest.digest, 'digest': manifest.digest,
'is_manifest_list': manifest.is_manifest_list,
'manifest_data': manifest.manifest_bytes, 'manifest_data': manifest.manifest_bytes,
'image': image, 'image': image,
'layers': ([_layer_dict(lyr.layer_info, idx) for idx, lyr in enumerate(layers)]
if layers else None),
} }

View file

@ -1,19 +1,19 @@
.image-view-layer-element { .manifest-view-layer-element {
position: relative; position: relative;
padding: 10px; padding: 10px;
padding-left: 40px; padding-left: 40px;
} }
.image-view-layer-element .image-comment { .manifest-view-layer-element .image-comment {
margin-bottom: 10px; margin-bottom: 10px;
} }
.image-view-layer-element .nondocker-command { .manifest-view-layer-element .nondocker-command {
font-family: monospace; font-family: monospace;
padding: 2px; padding: 2px;
} }
.image-view-layer-element .nondocker-command:before { .manifest-view-layer-element .nondocker-command:before {
content: "\f120"; content: "\f120";
font-family: "FontAwesome"; font-family: "FontAwesome";
font-size: 16px; font-size: 16px;
@ -21,7 +21,7 @@
color: #999; color: #999;
} }
.image-view-layer-element .image-layer-line { .manifest-view-layer-element .image-layer-line {
position: absolute; position: absolute;
top: 0px; top: 0px;
bottom: 0px; bottom: 0px;
@ -31,15 +31,15 @@
width: 0px; width: 0px;
} }
.image-view-layer-element.first .image-layer-line { .manifest-view-layer-element.first .image-layer-line {
top: 20px; top: 20px;
} }
.image-view-layer-element.last .image-layer-line { .manifest-view-layer-element.last .image-layer-line {
height: 16px; height: 16px;
} }
.image-view-layer-element .image-layer-dot { .manifest-view-layer-element .image-layer-dot {
position: absolute; position: absolute;
top: 14px; top: 14px;
left: 5px; left: 5px;
@ -52,17 +52,17 @@
z-index: 2; z-index: 2;
} }
.image-view-layer-element.first .image-layer-dot { .manifest-view-layer-element.first .image-layer-dot {
background: #428bca; background: #428bca;
} }
@media (max-width: 767px) { @media (max-width: 767px) {
.image-view-layer-element .dockerfile-command-element .label { .manifest-view-layer-element .dockerfile-command-element .label {
position: relative; position: relative;
display: block; display: block;
} }
.image-view-layer-element .dockerfile-command-element .command-title { .manifest-view-layer-element .dockerfile-command-element .command-title {
padding-top: 10px; padding-top: 10px;
padding-left: 0px; padding-left: 0px;
} }

View file

@ -1,7 +0,0 @@
<div class="image-view-layer-element" ng-class="getClass()">
<div class="image-command">
<image-command command="image.command"></image-command>
</div>
<div class="image-layer-dot"></div>
<div class="image-layer-line"></div>
</div>

View file

@ -0,0 +1,7 @@
<div class="manifest-view-layer-element" ng-class="getClass()">
<div class="image-command">
<image-command command="layer.command"></image-command>
</div>
<div class="image-layer-dot"></div>
<div class="image-layer-line"></div>
</div>

View file

@ -0,0 +1 @@
<manifest-link repository="item.repository" manifest-digest="item.digest"></manifest-link>

View file

@ -298,7 +298,7 @@
<!-- Expanded View --> <!-- Expanded View -->
<tr ng-if="expandedView" class="expanded-viewport" ng-class="{'manifest-list': tag.is_manifest_list}"> <tr ng-if="expandedView" class="expanded-viewport" ng-class="{'manifest-list': tag.is_manifest_list}">
<td class="checkbox-col"></td> <td class="checkbox-col"></td>
<td class="labels-col" colspan="{{6 + (repository.trust_enabled ? 1 : 0) + (Features.SECURITY_SCANNER ? 1 : 0) }}"> <td class="labels-col" colspan="{{6 + (repository.trust_enabled ? 1 : 0) + (Features.SECURITY_SCANNER ? 1 : -1) }}">
<!-- Image ID --> <!-- Image ID -->
<div class="image-id-row"> <div class="image-id-row">
<manifest-link repository="repository" image-id="tag.image_id" manifest-digest="tag.manifest_digest"></manifest-link> <manifest-link repository="repository" image-id="tag.image_id" manifest-digest="tag.manifest_digest"></manifest-link>

View file

@ -1,27 +1,26 @@
/** /**
* An element which displays a single layer representing an image in the image view. * An element which displays a single layer in the manifest view.
*/ */
angular.module('quay').directive('imageViewLayer', function () { angular.module('quay').directive('manifestViewLayer', function () {
var directiveDefinitionObject = { var directiveDefinitionObject = {
priority: 0, priority: 0,
templateUrl: '/static/directives/image-view-layer.html', templateUrl: '/static/directives/manifest-view-layer.html',
replace: false, replace: false,
transclude: true, transclude: true,
restrict: 'C', restrict: 'C',
scope: { scope: {
'repository': '=repository', 'repository': '=repository',
'image': '=image', 'manifest': '=manifest',
'images': '=images' 'layer': '=layer'
}, },
controller: function($scope, $element) { controller: function($scope, $element) {
$scope.getClass = function() { $scope.getClass = function() {
var index = $.inArray($scope.image, $scope.images); if ($scope.layer.index == 0) {
if (index < 0) { return 'last';
return 'first';
} }
if (index == $scope.images.length - 1) { if ($scope.layer.index == $scope.manifest.layers.length - 1) {
return 'last'; return 'first';
} }
return ''; return '';

View file

@ -30,7 +30,7 @@
$scope.manifestResource = ApiService.getRepoManifestAsResource(params).get(function(manifest) { $scope.manifestResource = ApiService.getRepoManifestAsResource(params).get(function(manifest) {
$scope.manifest = manifest; $scope.manifest = manifest;
$scope.reversedHistory = manifest.image.history.reverse(); $scope.reversedLayers = manifest.layers ? manifest.layers.reverse() : null;
}); });
}; };
@ -57,5 +57,30 @@
if (!Features.SECURITY_SCANNER) { return; } if (!Features.SECURITY_SCANNER) { return; }
$scope.manifestPackageCounter++; $scope.manifestPackageCounter++;
}; };
$scope.manifestsOf = function(manifest) {
if (!manifest || !manifest.is_manifest_list) {
return [];
}
if (!manifest._mapped_manifests) {
// Calculate once and cache to avoid angular digest cycles.
var parsed_manifest = JSON.parse(manifest.manifest_data);
manifest._mapped_manifests = parsed_manifest.manifests.map(function(manifest) {
return {
'repository': $scope.repository,
'raw': manifest,
'os': manifest.platform.os,
'architecture': manifest.platform.architecture,
'size': manifest.size,
'digest': manifest.digest,
'description': `${manifest.platform.os} on ${manifest.platform.architecture}`,
};
});
}
return manifest._mapped_manifests;
};
} }
})(); })();

View file

@ -10,51 +10,65 @@
</a> </a>
</span> </span>
<span class="cor-title-content"> <span class="cor-title-content">
<i class="fa fa-file fa-lg" style="margin-right: 10px"></i> <i class="fa fa-lg" ng-class="{'fa-file': !manifest.is_manifest_list, 'fa-file-text-o': manifest.is_manifest_list}" style="margin-right: 10px"></i>
{{ manifest.digest.substr(7, 12) }} {{ manifest.digest.substr(7, 12) }}
</span> </span>
</div> </div>
<cor-tab-panel orientation="vertical" cor-nav-tabs> <!-- Manifest list -->
<cor-tabs> <div ng-if="manifest.is_manifest_list">
<cor-tab tab-title="Layers" tab-id="layers"> <div class="co-main-content-panel">
<i class="fa ci-layers"></i> <cor-table table-data="manifestsOf(manifest)" table-item-title="manifests" filter-fields="['digest', 'os', 'architecture']">
</cor-tab> <cor-table-col datafield="digest" sortfield="digest" title="Manifest"
<cor-tab tab-title="Security Scan" tab-id="vulnerabilities" templateurl="/static/directives/manifest-view-manifest-link.html"></cor-table-col>
tab-init="loadManifestSecurity()" <cor-table-col datafield="os" sortfield="os" title="Operating System"></cor-table-col>
quay-show="Features.SECURITY_SCANNER"> <cor-table-col datafield="architecture" sortfield="architecture" title="Architecture"></cor-table-col>
<i class="fa fa-bug"></i> </cor-table>
</cor-tab> </div>
<cor-tab tab-title="Packages" tab-id="packages" </div>
tab-init="loadManifestPackages()"
quay-show="Features.SECURITY_SCANNER">
<i class="fa ci-package"></i>
</cor-tab>
</cor-tabs>
<cor-tab-content> <!-- Manifest -->
<!-- Layers --> <div ng-if="!manifest.is_manifest_list">
<cor-tab-pane id="layers"> <cor-tab-panel orientation="vertical" cor-nav-tabs>
<h3>Manifest Layers</h3> <cor-tabs>
<div class="image-view-layer" repository="repository" image="manifest.image" images="manifest.image.history"></div> <cor-tab tab-title="Layers" tab-id="layers">
<div class="image-view-layer" repository="repository" image="parent" images="manifest.image.history" <i class="fa ci-layers"></i>
ng-repeat="parent in reversedHistory"></div> </cor-tab>
</cor-tab-pane> <cor-tab tab-title="Security Scan" tab-id="vulnerabilities"
tab-init="loadManifestSecurity()"
quay-show="Features.SECURITY_SCANNER">
<i class="fa fa-bug"></i>
</cor-tab>
<cor-tab tab-title="Packages" tab-id="packages"
tab-init="loadManifestPackages()"
quay-show="Features.SECURITY_SCANNER">
<i class="fa ci-package"></i>
</cor-tab>
</cor-tabs>
<!-- Vulnerabilities --> <cor-tab-content>
<cor-tab-pane id="vulnerabilities" quay-show="Features.SECURITY_SCANNER"> <!-- Layers -->
<div quay-require="['SECURITY_SCANNER']"> <cor-tab-pane id="layers">
<div class="manifest-vulnerability-view" repository="repository" manifest="manifest" is-enabled="manifestSecurityCounter"></div> <h3>Manifest Layers</h3>
</div> <div class="manifest-view-layer" repository="repository" layer="layer"
</cor-tab-pane> manifest="manifest" ng-repeat="layer in reversedLayers"></div>
</cor-tab-pane>
<!-- Features --> <!-- Vulnerabilities -->
<cor-tab-pane id="packages" quay-show="Features.SECURITY_SCANNER"> <cor-tab-pane id="vulnerabilities" quay-show="Features.SECURITY_SCANNER">
<div quay-require="['SECURITY_SCANNER']"> <div quay-require="['SECURITY_SCANNER']">
<div class="manifest-feature-view" repository="repository" manifest="manifest" is-enabled="manifestPackageCounter"></div> <div class="manifest-vulnerability-view" repository="repository" manifest="manifest" is-enabled="manifestSecurityCounter"></div>
</div> </div>
</cor-tab-pane> </cor-tab-pane>
</cor-tab-content>
</cor-tab-panel> <!-- Features -->
<cor-tab-pane id="packages" quay-show="Features.SECURITY_SCANNER">
<div quay-require="['SECURITY_SCANNER']">
<div class="manifest-feature-view" repository="repository" manifest="manifest" is-enabled="manifestPackageCounter"></div>
</div>
</cor-tab-pane>
</cor-tab-content>
</cor-tab-panel>
</div>
</div> </div>
</div> </div>