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
def setup_split(self, v22_whitelist):
if os.getenv('OCI_DATA_MODEL') == 'true':
return
logger.info('===============================')
logger.info('Enabling split registry model with namespace whitelist `%s`', v22_whitelist)
logger.info('===============================')

View file

@ -207,7 +207,14 @@ class Manifest(datatype('Manifest', ['digest', 'media_type', 'manifest_bytes']))
@property
@requiresinput('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.
"""
return legacy_image

View file

@ -194,13 +194,21 @@ class RegistryDataInterface(object):
def get_manifest_local_blobs(self, manifest, include_placements=False):
""" 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
def list_parsed_manifest_layers(self, repository_ref, parsed_manifest, storage,
include_placements=False):
""" 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
(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

View file

@ -115,7 +115,7 @@ class OCIModel(SharedModel, RegistryDataInterface):
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)
except database.ManifestLegacyImage.DoesNotExist:
return None
pass
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)
return Manifest.for_manifest(manifest, LegacyImage.for_image(legacy_image))
def list_manifest_layers(self, manifest, 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.
"""
def list_manifest_layers(self, manifest, storage, include_placements=False):
try:
manifest_obj = database.Manifest.get(id=manifest._db_id)
except database.Manifest.DoesNotExist:
@ -431,8 +427,8 @@ class OCIModel(SharedModel, RegistryDataInterface):
logger.exception('Could not parse and validate manifest `%s`', manifest._db_id)
return None
return self._list_manifest_layers(manifest_obj.repository_id, parsed, include_placements,
by_manifest=True)
return self._list_manifest_layers(manifest_obj.repository_id, parsed, storage,
include_placements, by_manifest=True)
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)
def list_manifest_layers(self, manifest, 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.
"""
def list_manifest_layers(self, manifest, storage, include_placements=False):
try:
tag_manifest = database.TagManifest.get(id=manifest._db_id)
except database.TagManifest.DoesNotExist:
@ -497,7 +493,7 @@ class PreOCIModel(SharedModel, RegistryDataInterface):
return None
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):
"""

View file

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

View file

@ -1,14 +1,17 @@
""" Manage the manifests of a repository. """
import json
import logging
from flask import request
from app import label_validator
from app import label_validator, storage
from data.model import InvalidLabelKeyException, InvalidMediaTypeException
from data.registry_model import registry_model
from digest import digest_tools
from endpoints.api import (resource, nickname, require_repo_read, require_repo_write,
RepositoryParamResource, log_action, validate_json_request,
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.exception import NotFound
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)
ALLOWED_LABEL_MEDIA_TYPES = ['text/plain', 'application/json']
logger = logging.getLogger(__name__)
def _label_dict(label):
return {
'id': label.uuid,
@ -27,15 +33,45 @@ def _label_dict(label):
'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):
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)
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 {
'digest': manifest.digest,
'is_manifest_list': manifest.is_manifest_list,
'manifest_data': manifest.manifest_bytes,
'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;
padding: 10px;
padding-left: 40px;
}
.image-view-layer-element .image-comment {
.manifest-view-layer-element .image-comment {
margin-bottom: 10px;
}
.image-view-layer-element .nondocker-command {
.manifest-view-layer-element .nondocker-command {
font-family: monospace;
padding: 2px;
}
.image-view-layer-element .nondocker-command:before {
.manifest-view-layer-element .nondocker-command:before {
content: "\f120";
font-family: "FontAwesome";
font-size: 16px;
@ -21,7 +21,7 @@
color: #999;
}
.image-view-layer-element .image-layer-line {
.manifest-view-layer-element .image-layer-line {
position: absolute;
top: 0px;
bottom: 0px;
@ -31,15 +31,15 @@
width: 0px;
}
.image-view-layer-element.first .image-layer-line {
.manifest-view-layer-element.first .image-layer-line {
top: 20px;
}
.image-view-layer-element.last .image-layer-line {
.manifest-view-layer-element.last .image-layer-line {
height: 16px;
}
.image-view-layer-element .image-layer-dot {
.manifest-view-layer-element .image-layer-dot {
position: absolute;
top: 14px;
left: 5px;
@ -52,17 +52,17 @@
z-index: 2;
}
.image-view-layer-element.first .image-layer-dot {
.manifest-view-layer-element.first .image-layer-dot {
background: #428bca;
}
@media (max-width: 767px) {
.image-view-layer-element .dockerfile-command-element .label {
.manifest-view-layer-element .dockerfile-command-element .label {
position: relative;
display: block;
}
.image-view-layer-element .dockerfile-command-element .command-title {
.manifest-view-layer-element .dockerfile-command-element .command-title {
padding-top: 10px;
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 -->
<tr ng-if="expandedView" class="expanded-viewport" ng-class="{'manifest-list': tag.is_manifest_list}">
<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 -->
<div class="image-id-row">
<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 = {
priority: 0,
templateUrl: '/static/directives/image-view-layer.html',
templateUrl: '/static/directives/manifest-view-layer.html',
replace: false,
transclude: true,
restrict: 'C',
scope: {
'repository': '=repository',
'image': '=image',
'images': '=images'
'manifest': '=manifest',
'layer': '=layer'
},
controller: function($scope, $element) {
$scope.getClass = function() {
var index = $.inArray($scope.image, $scope.images);
if (index < 0) {
return 'first';
if ($scope.layer.index == 0) {
return 'last';
}
if (index == $scope.images.length - 1) {
return 'last';
if ($scope.layer.index == $scope.manifest.layers.length - 1) {
return 'first';
}
return '';

View file

@ -30,7 +30,7 @@
$scope.manifestResource = ApiService.getRepoManifestAsResource(params).get(function(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; }
$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>
</span>
<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) }}
</span>
</div>
<cor-tab-panel orientation="vertical" cor-nav-tabs>
<cor-tabs>
<cor-tab tab-title="Layers" tab-id="layers">
<i class="fa ci-layers"></i>
</cor-tab>
<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>
<!-- Manifest list -->
<div ng-if="manifest.is_manifest_list">
<div class="co-main-content-panel">
<cor-table table-data="manifestsOf(manifest)" table-item-title="manifests" filter-fields="['digest', 'os', 'architecture']">
<cor-table-col datafield="digest" sortfield="digest" title="Manifest"
templateurl="/static/directives/manifest-view-manifest-link.html"></cor-table-col>
<cor-table-col datafield="os" sortfield="os" title="Operating System"></cor-table-col>
<cor-table-col datafield="architecture" sortfield="architecture" title="Architecture"></cor-table-col>
</cor-table>
</div>
</div>
<cor-tab-content>
<!-- Layers -->
<cor-tab-pane id="layers">
<h3>Manifest Layers</h3>
<div class="image-view-layer" repository="repository" image="manifest.image" images="manifest.image.history"></div>
<div class="image-view-layer" repository="repository" image="parent" images="manifest.image.history"
ng-repeat="parent in reversedHistory"></div>
</cor-tab-pane>
<!-- Manifest -->
<div ng-if="!manifest.is_manifest_list">
<cor-tab-panel orientation="vertical" cor-nav-tabs>
<cor-tabs>
<cor-tab tab-title="Layers" tab-id="layers">
<i class="fa ci-layers"></i>
</cor-tab>
<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-pane id="vulnerabilities" quay-show="Features.SECURITY_SCANNER">
<div quay-require="['SECURITY_SCANNER']">
<div class="manifest-vulnerability-view" repository="repository" manifest="manifest" is-enabled="manifestSecurityCounter"></div>
</div>
</cor-tab-pane>
<cor-tab-content>
<!-- Layers -->
<cor-tab-pane id="layers">
<h3>Manifest Layers</h3>
<div class="manifest-view-layer" repository="repository" layer="layer"
manifest="manifest" ng-repeat="layer in reversedLayers"></div>
</cor-tab-pane>
<!-- 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>
<!-- Vulnerabilities -->
<cor-tab-pane id="vulnerabilities" quay-show="Features.SECURITY_SCANNER">
<div quay-require="['SECURITY_SCANNER']">
<div class="manifest-vulnerability-view" repository="repository" manifest="manifest" is-enabled="manifestSecurityCounter"></div>
</div>
</cor-tab-pane>
<!-- 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>