From ca0c5c0fe0076b1d9764b13c47b7850cac873087 Mon Sep 17 00:00:00 2001 From: Brad van der Laan Date: Tue, 12 Jun 2018 23:17:03 -0400 Subject: [PATCH] Update Manifest query to handle both schema versions The Manifest API will return information about the image or tag. This information is stored in a schema, that schema has two versions. For older Docker clients/ registries the v1 schema is returned by default; however, newer registries can return either v1 or v2. The API requires that you set a header to tell it which schema version you want. PR #101 added the use of the Manifest API so we can show the Dockerfile, and size, and labels, etc. It is setting the header of the request so that we get the v2 version of the schema however it is parsing/expecting the V1 version of the schema. If we are using an older registry (i.e. 2.1.1) then this works because the header is ignored and only the V1 is returned; however, if using a newer version (i.e. 2.6.2) the v2 schema would be returned and not parsed correctly. PR #168 modifies the use of the Manifest API so that it _does_ parse/expect the V2 schema; however, that means that the app will no longer work in older registries (or for images pushed by older clients) as it no longer supportes the V1 schema. This change set merged these two PRs together by checking the content type header in the response to figure out which version the registry returned. It then parses the payload depending on the schema version. With this change the user gets a very similar experance regardless of the manifest schema version being returned. Note: There is likly room for performance improvments here, I plan on a larger refactor and will like to address the ineffeciencies their --- app/image/image-controller.js | 33 +++- app/image/image-details-directive.html | 18 +- app/services/registry-services.js | 253 +++++++++++++++++-------- app/tag/tag-controller.js | 32 +++- 4 files changed, 225 insertions(+), 111 deletions(-) diff --git a/app/image/image-controller.js b/app/image/image-controller.js index 6c6c365..60ec54f 100644 --- a/app/image/image-controller.js +++ b/app/image/image-controller.js @@ -13,8 +13,25 @@ angular.module('image-controller', ['registry-services', 'app-mode-services']) $scope.appMode = AppMode.query(); - $scope.totalImageSize = 0; - $scope.imageDetails = Manifest.query({repository: $scope.repository, tagName: $scope.tagName}); + Manifest.query({repository: $scope.repository, tagName: $scope.tagName}) + .$promise.then(function(data) { + $scope.imageDetails = angular.copy(data); + + return !data.isSchemaV2 + ? undefined + : Blob.query({repository: $scope.repository, digest: 'sha256:'+data.id}) + .$promise.then(function(config) { + $scope.imageDetails.created = config.created; + $scope.imageDetails.docker_version = config.docker_version; + $scope.imageDetails.os = config.os; + $scope.imageDetails.architecture = config.architecture; + $scope.imageDetails.labels = config.container_config && config.container_config.Labels; + $scope.imageDetails.dockerfile = config.dockerfile; + $scope.imageDetails.layers = config.dockerfile.length; + + $scope.totalImageSize = $scope.imageDetails.size; + }); + }); @@ -22,15 +39,15 @@ angular.module('image-controller', ['registry-services', 'app-mode-services']) * Calculates the total download size for the image based on * it's layers. */ - $scope.totalImageSize = null; $scope.calculateTotalImageSize = function() { $scope.totalImageSize = 0; angular.forEach($scope.imageDetails.fsLayers, function (id, key) { - Blob.query({repository: $scope.repository, digest: id.blobSum}).$promise.then(function(data){ - if(!isNaN(data.contentLength-0)){ - $scope.totalImageSize += data.contentLength; - } - }); + Blob.querySize({repository: $scope.repository, digest: id.blobSum}) + .$promise.then(function(data){ + if(!isNaN(data.contentLength-0)){ + $scope.totalImageSize += data.contentLength; + } + }); }); }; }]); diff --git a/app/image/image-details-directive.html b/app/image/image-details-directive.html index 79d3961..9fb2857 100644 --- a/app/image/image-details-directive.html +++ b/app/image/image-details-directive.html @@ -14,13 +14,13 @@

General information
-
+

{{imageDetails.labels.maintainer || imageDetails.author}}

-
+

@@ -28,7 +28,7 @@

-
+

@@ -37,25 +37,25 @@

-
+

{{imageDetails.layers}}

-
+

{{imageDetails.docker_version}}

-
+

{{imageDetails.os}}/{{imageDetails.architecture}}

-
+

@@ -75,10 +75,10 @@

- + {{totalImageSize/1024/1024 | number: 2}} MB -

diff --git a/app/services/registry-services.js b/app/services/registry-services.js index 1241c62..7ac0709 100644 --- a/app/services/registry-services.js +++ b/app/services/registry-services.js @@ -51,6 +51,113 @@ function linkParser(linkHeader) { return { repository: repository }; } +function handelSchemaV1(data) { + // https://docs.docker.com/registry/spec/manifest-v2-1/ + /** Response example: + * { + * "schemaVersion": 1, + * "name": "arthur/busybox", + * "tag": "demo", + * "architecture": "amd64", + * "fsLayers": [ + * { + * "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4" + * }, + * { + * "blobSum": "sha256:d7e8ec85c5abc60edf74bd4b8d68049350127e4102a084f22060f7321eac3586" + * } + * ], + * "history": [ + * { + * "v1Compatibility": "{\"id\":\"3e1018ee907f25aef8c50016296ab33624796511fdbfdbbdeca6a3ed2d0ba4e2\",\"parent\":\"176dfc9032a1ec3ac8586b383e325e1a65d1f5b5e6f46c2a55052b5aea8310f7\",\"created\":\"2016-01-12T17:47:39.251310827Z\",\"container\":\"2732d16efa11ab7da6393645e85a7f2070af94941a782a69e86457a2284f4a69\",\"container_config\":{\"Hostname\":\"ea7fe68f39fd\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) LABEL awesome=Not yet!\"],\"Image\":\"176dfc9032a1ec3ac8586b383e325e1a65d1f5b5e6f46c2a55052b5aea8310f7\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":[],\"Labels\":{\"awesome\":\"Not yet!\",\"test\":\"yes\",\"working\":\"true\"}},\"docker_version\":\"1.9.1\",\"author\":\"Arthur\",\"config\":{\"Hostname\":\"ea7fe68f39fd\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\"Cmd\":[\"sh\"],\"Image\":\"176dfc9032a1ec3ac8586b383e325e1a65d1f5b5e6f46c2a55052b5aea8310f7\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":[],\"Labels\":{\"awesome\":\"Not yet!\",\"test\":\"yes\",\"working\":\"true\"}},\"architecture\":\"amd64\",\"os\":\"linux\"}" + * }, + * { + * "v1Compatibility": "{\"id\":\"5c5fb281b01ee091a0fffa5b4a4c7fb7d358e7fb7c49c263d6d7a4e35d199fd0\",\"created\":\"2015-12-08T18:31:50.979824705Z\",\"container\":\"ea7fe68f39fd0df314e841247fb940ddef4c02ab7b5edb0ee724adc3174bc8d9\",\"container_config\":{\"Hostname\":\"ea7fe68f39fd\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) ADD file:c295b0748bf05d4527f500b62ff269bfd0037f7515f1375d2ee474b830bad382 in /\"],\"Image\":\"\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"docker_version\":\"1.8.3\",\"config\":{\"Hostname\":\"ea7fe68f39fd\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":null,\"Image\":\"\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"architecture\":\"amd64\",\"os\":\"linux\",\"Size\":1113436}" + * } + * ], + * } + **/ + + var dockerFile; + var res = {}; + var history = data.history.map(function(history) { + return angular.fromJson(history.v1Compatibility); + }).filter(function(history) { + return history !== undefined; + }).map(function(history) { + return { + id: history.id, + os: history.os, + docker_version: history.docker_version, + created: history.created, + author: history.author, + labels: history.config && history.config.Labels, + layerCmd: history.container_config && history.container_config.Cmd.join(' ') + .replace(/^\/bin\/sh -c #\(nop\)\s*/, '') + .replace('/bin/sh -c', 'RUN') + .replace(/\t\t/g, '\\\n\t'), + }; + }); + + dockerFile = history.map(function(history) { + return history.layerCmd; + }).reverse(); + + if(history.length > 0){ + res = history.shift(); + res.history = history; + } + + res.dockerfile = dockerFile + res.layers = dockerFile.length + res.fsLayers = data.fsLayers; + res.architecture = data.architecture; + + return res; +} + +function handelSchemaV2(data) { + // https://docs.docker.com/registry/spec/manifest-v2-2/#image-manifest-field-descriptions + /** Response example: + * { + * "schemaVersion": 2, + * "mediaType": "application/vnd.docker.distribution.manifest.v2+json", + * "config": { + * "mediaType": "application/vnd.docker.container.image.v1+json", + * "size": 7023, + * "digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7" + * }, + * "layers": [ + * { + * "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + * "size": 32654, + * "digest": "sha256:e692418e4cbaf90ca69d05a66403747baa33ee08806650b51fab815ad7fc331f" + * }, + * { + * "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + * "size": 16724, + * "digest": "sha256:3c3a4604a545cdc127456d94e421cd355bca5b528f4a9c1905b15da2eb4a4c6b" + * }, + * { + * "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", + * "size": 73109, + * "digest": "sha256:ec4b8955958665577945c89419d1af06b5f7636b4ac3da7f12184802ad867736" + * } + * ] + * } + **/ + + var res = { + id: data.config.digest.replace(/^sha256:/, ''), + }; + + res.size = data.layers.reduce(function(size, layer) { + return size + layer.size; + }, data.config.size); + + return res; +} + angular.module('registry-services', ['ngResource']) .factory('RegistryHost', ['$resource', function($resource){ return $resource('registry-host.json', {}, { @@ -60,23 +167,24 @@ angular.module('registry-services', ['ngResource']) }, }); }]) - // Repository returns: - // - // { - // repos: [ - // {username: 'SomeNamespace', name: 'SomeNamespace/SomeRepo1', selected: true|false}, - // {username: 'SomeOtherNamespace', name: 'SomeOtherNamespace/SomeRepo2', selected: true|false}, - // {username: 'SomeCompletelyDifferenNamespace', name: 'SomeCompletelyDifferenNamespace/SomeRepo3', selected: true|false} - // ], - // nextLink: '/v2/_catalog?last=SomeNamespace%F2SomeRepo&n=1' - // } - // - // The "nextLink" element is a preparation for supporting pagination - // (see https://github.com/docker/distribution/blob/master/docs/spec/api.md#pagination) - // - // On subsequent calls to "Repository()" you may pass in "n" as the number of - // elements per page as well as "last" which is the "nextLink" from the last - // call to Repository. + /* Repository returns: + * + * { + * repos: [ + * {username: 'SomeNamespace', name: 'SomeNamespace/SomeRepo1', selected: true|false}, + * {username: 'SomeOtherNamespace', name: 'SomeOtherNamespace/SomeRepo2', selected: true|false}, + * {username: 'SomeCompletelyDifferenNamespace', name: 'SomeCompletelyDifferenNamespace/SomeRepo3', selected: true|false} + * ], + * nextLink: '/v2/_catalog?last=SomeNamespace%F2SomeRepo&n=1' + * } + * + * The "nextLink" element is a preparation for supporting pagination + * (see https: *github.com/docker/distribution/blob/master/docs/spec/api.md#pagination) + * + * On subsequent calls to "Repository()" you may pass in "n" as the number of + * elements per page as well as "last" which is the "nextLink" from the last + * call to Repository. + **/ .factory('Repository', ['$resource', function($resource){ return $resource('/v2/_catalog?n=:n&last=:last', {}, { 'query': { @@ -153,31 +261,7 @@ angular.module('registry-services', ['ngResource']) }); }]) .factory('Manifest', ['$resource', function($resource){ - return $resource('/v2/:repository/manifests/:tagName', {}, { - // Response example: - // { - // "schemaVersion": 1, - // "name": "arthur/busybox", - // "tag": "demo", - // "architecture": "amd64", - // "fsLayers": [ - // { - // "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4" - // }, - // { - // "blobSum": "sha256:d7e8ec85c5abc60edf74bd4b8d68049350127e4102a084f22060f7321eac3586" - // } - // ], - // "history": [ - // { - // "v1Compatibility": "{\"id\":\"3e1018ee907f25aef8c50016296ab33624796511fdbfdbbdeca6a3ed2d0ba4e2\",\"parent\":\"176dfc9032a1ec3ac8586b383e325e1a65d1f5b5e6f46c2a55052b5aea8310f7\",\"created\":\"2016-01-12T17:47:39.251310827Z\",\"container\":\"2732d16efa11ab7da6393645e85a7f2070af94941a782a69e86457a2284f4a69\",\"container_config\":{\"Hostname\":\"ea7fe68f39fd\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) LABEL awesome=Not yet!\"],\"Image\":\"176dfc9032a1ec3ac8586b383e325e1a65d1f5b5e6f46c2a55052b5aea8310f7\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":[],\"Labels\":{\"awesome\":\"Not yet!\",\"test\":\"yes\",\"working\":\"true\"}},\"docker_version\":\"1.9.1\",\"author\":\"Arthur\",\"config\":{\"Hostname\":\"ea7fe68f39fd\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":[\"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin\"],\"Cmd\":[\"sh\"],\"Image\":\"176dfc9032a1ec3ac8586b383e325e1a65d1f5b5e6f46c2a55052b5aea8310f7\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":[],\"Labels\":{\"awesome\":\"Not yet!\",\"test\":\"yes\",\"working\":\"true\"}},\"architecture\":\"amd64\",\"os\":\"linux\"}" - // }, - // { - // "v1Compatibility": "{\"id\":\"5c5fb281b01ee091a0fffa5b4a4c7fb7d358e7fb7c49c263d6d7a4e35d199fd0\",\"created\":\"2015-12-08T18:31:50.979824705Z\",\"container\":\"ea7fe68f39fd0df314e841247fb940ddef4c02ab7b5edb0ee724adc3174bc8d9\",\"container_config\":{\"Hostname\":\"ea7fe68f39fd\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) ADD file:c295b0748bf05d4527f500b62ff269bfd0037f7515f1375d2ee474b830bad382 in /\"],\"Image\":\"\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"docker_version\":\"1.8.3\",\"config\":{\"Hostname\":\"ea7fe68f39fd\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":null,\"Image\":\"\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"architecture\":\"amd64\",\"os\":\"linux\",\"Size\":1113436}" - // } - // ], - // } 'query': { method:'GET', headers: { @@ -185,50 +269,14 @@ angular.module('registry-services', ['ngResource']) }, isArray: false, transformResponse: function(data, headers){ - var res = {}; - var history = []; - var tmp; var resp = angular.fromJson(data); - var v1Compatibility = undefined; - var dockerfile = []; - var cmd; - var instruction; - - for (var idx in resp.history){ - - v1Compatibility = angular.fromJson(resp.history[idx].v1Compatibility); - - if(v1Compatibility !== undefined){ - tmp = { - id : v1Compatibility.id, - os : v1Compatibility.os, - docker_version: v1Compatibility.docker_version, - created: v1Compatibility.created, - // parentLayer: v1Compatibility.parent - }; - if(v1Compatibility.author){ - tmp.author = v1Compatibility.author; - } - if(v1Compatibility.config && v1Compatibility.config.Labels){ - tmp.labels = v1Compatibility.config.Labels; - } - if(v1Compatibility.container_config && v1Compatibility.container_config.Cmd){ - cmd = v1Compatibility.container_config.Cmd - instruction = cmd.join(' ').replace('/bin/sh -c #(nop) ', '').replace('/bin/sh -c ', 'RUN ') - dockerfile.unshift(instruction) - } - history.push(tmp); - } - } - if(history.length > 0){ - res = history[0]; - res.history = history; - } - res.fsLayers = resp.fsLayers; + var isSchemaV2 = (headers('content-type') === 'application/vnd.docker.distribution.manifest.v2+json'); + var res = isSchemaV2 + ? handelSchemaV2(resp) + : handelSchemaV1(resp); res.digest = headers('docker-content-digest'); - res.architecture = resp.architecture; - res.dockerfile = dockerfile - res.layers = dockerfile.length + res.isSchemaV2 = isSchemaV2; + return res; }, }, @@ -240,7 +288,7 @@ angular.module('registry-services', ['ngResource']) }]) .factory('Blob', ['$resource', function($resource){ return $resource('/v2/:repository/blobs/:digest', {}, { - 'query': { + 'querySize': { method:'HEAD', interceptor: { response: function(response){ @@ -248,6 +296,43 @@ angular.module('registry-services', ['ngResource']) return res; } } + }, + /** Example Response: + * { + * "architecture": "amd64", + * "config": {}, + * "container": "caab3f21c75adc3560754e71374cd01cb1bbe39b2b9c2809ff6c22bbcd39206c", + * "container_config": {}, + * "created": "2017-04-25T03:44:24.620936172Z", + * "docker_version": "17.04.0-ce", + * "history": [ + * { + * "created": "2017-04-24T19:20:41.290148217Z", + * "created_by": "/bin/sh -c #(nop) ADD file:712c48086043553b85ffb031d8f6c5de857a2e53974df30cdfbc1e85c1b00a25 in / " + * }, + * { + * "created": "2017-04-24T19:20:42.022627269Z", + * "created_by": "/bin/sh -c #(nop) CMD [\"/bin/bash\"]", + * "empty_layer": true + * } + * ], + * "os": "linux", + * "rootfs": {} + * } + **/ + 'query': { + method: 'GET', + transformResponse: function(data, headers){ + data = angular.fromJson(data); + data.dockerfile = data.history.map(function(history) { + return history.created_by + .replace(new RegExp('^/bin/sh -c #\\(nop\\)\\s*'), '') + .replace(new RegExp('^/bin/sh -c\\s*'), 'RUN ') + .replace(/\t\t/g, '\\\n\t'); + }); + + return data; + } } }); }]); diff --git a/app/tag/tag-controller.js b/app/tag/tag-controller.js index 17a9974..ef446e5 100644 --- a/app/tag/tag-controller.js +++ b/app/tag/tag-controller.js @@ -8,8 +8,8 @@ * Controller of the docker-registry-frontend */ angular.module('tag-controller', ['ui.bootstrap', 'registry-services', 'app-mode-services']) - .controller('TagController', ['$scope', '$route', '$location', '$filter', 'Manifest', 'Tag', 'AppMode', 'filterFilter', '$modal', - function($scope, $route, $location, $filter, Manifest, Tag, AppMode, filterFilter, $modal){ + .controller('TagController', ['$scope', '$route', '$location', '$filter', 'Manifest', 'Tag', 'AppMode', 'filterFilter', '$modal', 'Blob', + function($scope, $route, $location, $filter, Manifest, Tag, AppMode, filterFilter, $modal, Blob){ $scope.$route = $route; $scope.$location = $location; @@ -60,16 +60,28 @@ angular.module('tag-controller', ['ui.bootstrap', 'registry-services', 'app-mode idxShift = ($scope.tagsCurrentPage - 1) * $scope.tagsPerPage; $scope.displayedTags = $scope.displayedTags.slice(idxShift, ($scope.tagsCurrentPage ) * $scope.tagsPerPage ); } - var tmpIdx; + // Fetch wanted manifests - for (var idx in $scope.displayedTags){ - if(!isNaN(idx)){ - tmpIdx = parseInt(idx) + idxShift; - if ( result[tmpIdx].hasOwnProperty('name') ) { - result[tmpIdx].details = Manifest.query({repository: $scope.repository, tagName: result[tmpIdx].name}); - } + $scope.displayedTags.forEach(function(tag) { + if ( tag.hasOwnProperty('name') ) { + Manifest.query({repository: $scope.repository, tagName: tag.name}) + .$promise.then(function(data) { + tag.details = angular.copy(data); + return !data.isSchemaV2 + ? undefined + : Blob.query({repository: $scope.repository, digest: 'sha256:'+data.id}) + .$promise.then(function(config) { + tag.details.created = config.created; + tag.details.docker_version = config.docker_version; + tag.details.os = config.os; + tag.details.architecture = config.architecture; + tag.details.labels = config.container_config && config.container_config.Labels; + tag.details.dockerfile = config.dockerfile; + tag.details.layers = config.dockerfile.length; + }); + }); } - } + }); $scope.$watch('displayedTags|filter:{selected:true}', function(nv) { $scope.selection = nv.map(function (tag) {