Fix manifest UI page to properly show the layers of manifests and show manifest lists

This commit is contained in:
Joseph Schorr 2018-12-10 15:33:59 -05:00
parent 8cd3740c69
commit 4106f5ce51
13 changed files with 162 additions and 89 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,6 +207,13 @@ 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.
"""
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 """ 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.
""" """

View file

@ -194,6 +194,14 @@ 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`.
"""
@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):

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

@ -1,14 +1,16 @@
""" Manage the manifests of a repository. """ """ Manage the manifests of a repository. """
import json
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
@ -27,15 +29,37 @@ def _label_dict(label):
'media_type': label.media_type_name, 'media_type': label.media_type_name,
} }
def _manifest_dict(manifest):
image = None def _layer_dict(manifest_layer, index):
if manifest.legacy_image is not None: try:
image = image_dict(manifest.legacy_image, with_history=True) command = json.loads(manifest_layer.command)
except (TypeError, ValueError):
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_if_present is not None:
image = image_dict(manifest.legacy_image, with_history=True)
layers = registry_model.list_manifest_layers(manifest, storage)
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

@ -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,11 +10,25 @@
</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>
<!-- 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>
<!-- Manifest -->
<div ng-if="!manifest.is_manifest_list">
<cor-tab-panel orientation="vertical" cor-nav-tabs> <cor-tab-panel orientation="vertical" cor-nav-tabs>
<cor-tabs> <cor-tabs>
<cor-tab tab-title="Layers" tab-id="layers"> <cor-tab tab-title="Layers" tab-id="layers">
@ -36,9 +50,8 @@
<!-- Layers --> <!-- Layers -->
<cor-tab-pane id="layers"> <cor-tab-pane id="layers">
<h3>Manifest Layers</h3> <h3>Manifest Layers</h3>
<div class="image-view-layer" repository="repository" image="manifest.image" images="manifest.image.history"></div> <div class="manifest-view-layer" repository="repository" layer="layer"
<div class="image-view-layer" repository="repository" image="parent" images="manifest.image.history" manifest="manifest" ng-repeat="layer in reversedLayers"></div>
ng-repeat="parent in reversedHistory"></div>
</cor-tab-pane> </cor-tab-pane>
<!-- Vulnerabilities --> <!-- Vulnerabilities -->
@ -58,3 +71,4 @@
</cor-tab-panel> </cor-tab-panel>
</div> </div>
</div> </div>
</div>