From ee80f433758b42bba215e8d8b672d34d665227c5 Mon Sep 17 00:00:00 2001 From: yackob03 Date: Thu, 2 Jan 2014 17:39:38 -0500 Subject: [PATCH 1/5] Make that change which allows for sparse pushes. --- endpoints/index.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/endpoints/index.py b/endpoints/index.py index c8b519c7e..981c83b3e 100644 --- a/endpoints/index.py +++ b/endpoints/index.py @@ -158,10 +158,6 @@ def create_repository(namespace, repository): for existing in model.get_repository_images(namespace, repository): if existing.docker_image_id in new_repo_images: added_images.pop(existing.docker_image_id) - else: - logger.debug('Deleting existing image with id: %s' % - existing.docker_image_id) - existing.delete_instance(recursive=True) for image_description in added_images.values(): model.create_image(image_description['id'], repo) From 9da93c7caf2c12822d23225acec98aedd8ace198 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Mon, 6 Jan 2014 15:20:58 -0500 Subject: [PATCH 2/5] Add frontend and API support for deleting tags. Model support is needed. --- data/model.py | 3 ++ endpoints/api.py | 18 ++++++++ initdb.py | 4 ++ static/css/quay.css | 28 +++++++++++++ static/js/controllers.js | 74 +++++++++++++++++++++++++++++++++ static/js/graphing.js | 41 +++++++++++++++--- static/partials/view-repo.html | 33 ++++++++++++++- test/data/test.db | Bin 339968 -> 339968 bytes 8 files changed, 195 insertions(+), 6 deletions(-) diff --git a/data/model.py b/data/model.py index 9182950be..7c4f80fb5 100644 --- a/data/model.py +++ b/data/model.py @@ -740,6 +740,9 @@ def list_repository_tags(namespace_name, repository_name): return with_image.where(Repository.name == repository_name, Repository.namespace == namespace_name) +def delete_tag_and_images(namespace_name, repository_name, tag_name): + # TODO: Implement this. + pass def get_tag_image(namespace_name, repository_name, tag_name): joined = Image.select().join(RepositoryTag).join(Repository) diff --git a/endpoints/api.py b/endpoints/api.py index b954befb1..d1c5f8d2d 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -1158,6 +1158,24 @@ def get_image_changes(namespace, repository, image_id): abort(403) +@app.route('/api/repository//tag/', + methods=['DELETE']) +@parse_repository_name +def delete_full_tag(namespace, repository, tag): + permission = AdministerRepositoryPermission(namespace, repository) + if permission.can(): + model.delete_tag_and_images(namespace, repository, tag) + + username = current_user.db_user().username + log_action('delete_tag', namespace, + {'username': username, 'repo': repository, 'tag': tag}, + repo=model.get_repository(namespace, repository)) + + return make_response('Deleted', 204) + + abort(403) # Permission denied + + @app.route('/api/repository//tag//images', methods=['GET']) @parse_repository_name diff --git a/initdb.py b/initdb.py index 5dc75e52f..8df4e7f00 100644 --- a/initdb.py +++ b/initdb.py @@ -123,6 +123,7 @@ def initialize_database(): LogEntryKind.create(name='push_repo') LogEntryKind.create(name='pull_repo') LogEntryKind.create(name='delete_repo') + LogEntryKind.create(name='delete_tag') LogEntryKind.create(name='add_repo_permission') LogEntryKind.create(name='change_repo_permission') LogEntryKind.create(name='delete_repo_permission') @@ -279,6 +280,9 @@ def populate_database(): model.log_action('pull_repo', org.username, repository=org_repo, timestamp=today, metadata={'token': 'sometoken', 'token_code': 'somecode', 'repo': 'orgrepo'}) + model.log_action('delete_tag', org.username, performer=new_user_2, repository=org_repo, timestamp=today, + metadata={'username': new_user_2.username, 'repo': 'orgrepo', 'tag': 'sometag'}) + if __name__ == '__main__': logging.basicConfig(**app.config['LOGGING_CONFIG']) initialize_database() diff --git a/static/css/quay.css b/static/css/quay.css index 8cbb3bb25..7adb2cfd7 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -1148,6 +1148,34 @@ p.editable:hover i { font-size: 1.15em; } +#tagContextMenu { + display: none; + position: absolute; + z-index: 100000; + outline: none; +} + +#tagContextMenu ul { + display: block; + position: static; + margin-bottom: 5px; +} + +.tag-controls { + display: inline-block; + margin-right: 20px; + margin-top: 2px; + opacity: 0; + float: right; + -webkit-transition: opacity 200ms ease-in-out; + -moz-transition: opacity 200ms ease-in-out; + transition: opacity 200ms ease-in-out; +} + +.tag-heading:hover .tag-controls { + opacity: 1; +} + .right-title { display: inline-block; float: right; diff --git a/static/js/controllers.js b/static/js/controllers.js index f66f052dc..c58dd564a 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -194,6 +194,38 @@ function RepoCtrl($scope, Restangular, ApiService, $routeParams, $rootScope, $lo } }; + $scope.askDeleteTag = function(tagName) { + if (!$scope.repo.can_admin) { return; } + + $scope.tagToDelete = tagName; + $('#confirmdeleteTagModal').modal('show'); + }; + + $scope.deleteTag = function(tagName) { + if (!$scope.repo.can_admin) { return; } + $('#confirmdeleteTagModal').modal('hide'); + + var params = { + 'repository': namespace + '/' + name, + 'tag': tagName + }; + + ApiService.deleteFullTag(null, params).then(function() { + loadViewInfo(); + }, function(resp) { + bootbox.dialog({ + "message": resp.data ? resp.data : 'Could not delete tag', + "title": "Cannot delete tag", + "buttons": { + "close": { + "label": "Close", + "className": "btn-primary" + } + } + }); + }); + }; + $scope.setTag = function(tagName, opt_updateURL) { var repo = $scope.repo; var proposedTag = repo.tags[tagName]; @@ -229,6 +261,40 @@ function RepoCtrl($scope, Restangular, ApiService, $routeParams, $rootScope, $lo return count; }; + $scope.hideTagMenu = function(tagName, clientX, clientY) { + $scope.currentMenuTag = null; + + var tagMenu = $("#tagContextMenu"); + tagMenu.hide(); + }; + + $scope.showTagMenu = function(tagName, clientX, clientY) { + if (!$scope.repo.can_admin) { return; } + + $scope.currentMenuTag = tagName; + + var tagMenu = $("#tagContextMenu"); + tagMenu.css({ + display: "block", + left: clientX, + top: clientY + }); + + tagMenu.on("blur", function() { + setTimeout(function() { + tagMenu.hide(); + }, 100); // Needed to allow clicking on menu items. + }); + + tagMenu.on("click", "a", function() { + setTimeout(function() { + tagMenu.hide(); + }, 100); // Needed to allow clicking on menu items. + }); + + tagMenu[0].focus(); + }; + var getDefaultTag = function() { if ($scope.repo === undefined) { return undefined; @@ -343,6 +409,14 @@ function RepoCtrl($scope, Restangular, ApiService, $routeParams, $rootScope, $lo $scope.$apply(function() { $scope.setImage(e.image); }); }); + $($scope.tree).bind('showTagMenu', function(e) { + $scope.$apply(function() { $scope.showTagMenu(e.tag, e.clientX, e.clientY); }); + }); + + $($scope.tree).bind('hideTagMenu', function(e) { + $scope.$apply(function() { $scope.hideTagMenu(); }); + }); + return resp.images; }); }; diff --git a/static/js/graphing.js b/static/js/graphing.js index fbf5f2162..b7466da6a 100644 --- a/static/js/graphing.js +++ b/static/js/graphing.js @@ -69,6 +69,25 @@ ImageHistoryTree.prototype.calculateDimensions_ = function(container) { }; +ImageHistoryTree.prototype.setupOverscroll_ = function() { + var container = this.container_; + var that = this; + var overscroll = $('#' + container).overscroll(); + + overscroll.on('overscroll:dragstart', function() { + $(that).trigger({ + 'type': 'hideTagMenu' + }); + }); + + overscroll.on('scroll', function() { + $(that).trigger({ + 'type': 'hideTagMenu' + }); + }); +}; + + /** * Updates the dimensions of the tree. */ @@ -88,8 +107,8 @@ ImageHistoryTree.prototype.updateDimensions_ = function() { var boundingBox = document.getElementById(container).getBoundingClientRect(); document.getElementById(container).style.maxHeight = (viewportHeight - boundingBox.top - 110) + 'px'; - $('#' + container).overscroll(); - + this.setupOverscroll_(); + // Update the tree. var rootSvg = this.rootSvg_; var tree = this.tree_; @@ -183,8 +202,7 @@ ImageHistoryTree.prototype.draw = function(container) { this.root_.y0 = 0; this.setTag_(this.currentTag_); - - $('#' + container).overscroll(); + this.setupOverscroll_(); }; @@ -642,7 +660,7 @@ ImageHistoryTree.prototype.update_ = function(source) { if (tag == currentTag) { kind = 'success'; } - html += '' + tag + ''; + html += '' + tag + ''; } return html; }); @@ -654,6 +672,19 @@ ImageHistoryTree.prototype.update_ = function(source) { if (tag) { that.changeTag_(tag); } + }) + .on("contextmenu", function(d, e) { + d3.event.preventDefault(); + + var tag = this.getAttribute('data-tag'); + if (tag) { + $(that).trigger({ + 'type': 'showTagMenu', + 'tag': tag, + 'clientX': d3.event.clientX, + 'clientY': d3.event.clientY + }); + } }); // Ensure the tags are visible. diff --git a/static/partials/view-repo.html b/static/partials/view-repo.html index ebc1ea5b2..7d119d4fa 100644 --- a/static/partials/view-repo.html +++ b/static/partials/view-repo.html @@ -1,3 +1,9 @@ + +
@@ -73,7 +79,7 @@ sudo docker push quay.io/{{repo.namespace}}/{{repo.name}}
-
+
Tags + + Delete Tag +
@@ -180,3 +189,25 @@ sudo docker push quay.io/{{repo.namespace}}/{{repo.name}}
+ + + + diff --git a/test/data/test.db b/test/data/test.db index c2ef5d6528ab19aa099da05eaf5e87dfae0471c3..41dd4cb0be5ce20621ce683203bc2d999063cdcb 100644 GIT binary patch delta 3599 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 ChXG6g From 971dd7dd3ab1356f1116f18fa583a2c4d66b8e2b Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 7 Jan 2014 15:21:24 -0500 Subject: [PATCH 3/5] Get tag deletion working --- data/model.py | 44 ++++++++++++++++++++++++++++++++++++++-- static/js/controllers.js | 5 +++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/data/model.py b/data/model.py index 7c4f80fb5..e0cef7ddb 100644 --- a/data/model.py +++ b/data/model.py @@ -741,8 +741,48 @@ def list_repository_tags(namespace_name, repository_name): Repository.namespace == namespace_name) def delete_tag_and_images(namespace_name, repository_name, tag_name): - # TODO: Implement this. - pass + all_images = get_repository_images(namespace_name, repository_name) + all_tags = list_repository_tags(namespace_name, repository_name) + + # Find the tag's information. + found_tag = None + for tag in all_tags: + if tag.name == tag_name: + found_tag = tag + break + + if not found_tag: + return + + # Build the set of database IDs corresponding to the tag's ancestor images, as well as the + # tag's image itself. + tag_image_ids = set(found_tag.image.ancestors.split('/')) + tag_image_ids.add(str(found_tag.image.id)) + + # Filter out any images that belong to any other tags. + for tag in all_tags: + if tag.name != tag_name: + # Remove all ancestors of the tag. + tag_image_ids = tag_image_ids - set(tag.image.ancestors.split('/')) + + # Remove the current image ID. + tag_image_ids.discard(str(tag.image.id)) + + # Find all the images that belong to the tag. + tag_images = [] + for image in all_images: + if str(image.id) in tag_image_ids: + tag_images.append(image) + + # Delete the tag found. + found_tag.delete_instance() + + # Delete the images found. + for image in tag_images: + image.delete_instance() + + # TODO: Delete the image's layer data as well. + def get_tag_image(namespace_name, repository_name, tag_name): joined = Image.select().join(RepositoryTag).join(Repository) diff --git a/static/js/controllers.js b/static/js/controllers.js index c58dd564a..800d25232 100644 --- a/static/js/controllers.js +++ b/static/js/controllers.js @@ -250,6 +250,11 @@ function RepoCtrl($scope, Restangular, ApiService, $routeParams, $rootScope, $lo $location.search('tag', $scope.currentTag.name); } } + + if ($scope.currentTag && !repo.tags[$scope.currentTag.name]) { + $scope.currentTag = null; + $scope.currentImage = null; + } }; $scope.getTagCount = function(repo) { From bd2fe5cf99e4bf8116c501f923028a75647b0e36 Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 7 Jan 2014 15:48:44 -0500 Subject: [PATCH 4/5] Change to a list comprehesion --- data/model.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/data/model.py b/data/model.py index e0cef7ddb..b7e64bfcb 100644 --- a/data/model.py +++ b/data/model.py @@ -769,10 +769,7 @@ def delete_tag_and_images(namespace_name, repository_name, tag_name): tag_image_ids.discard(str(tag.image.id)) # Find all the images that belong to the tag. - tag_images = [] - for image in all_images: - if str(image.id) in tag_image_ids: - tag_images.append(image) + tag_images = [image for image in all_images if str(image.id) in tag_image_ids] # Delete the tag found. found_tag.delete_instance() From 50db761a1dd72c315a968ef7b057d3d01a1f84ef Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Tue, 7 Jan 2014 17:16:12 -0500 Subject: [PATCH 5/5] Add tag deletion info to the guide --- static/partials/guide.html | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/static/partials/guide.html b/static/partials/guide.html index e3c916b7b..b5a018a75 100644 --- a/static/partials/guide.html +++ b/static/partials/guide.html @@ -93,6 +93,15 @@ Email: my@email.com
+ +

Deleting a tag Requires Admin Access

+
+
+ A specific tag and all its images can be deleted by right clicking on the tag in the repository history tree and choosing "Delete Tag". This will delete the tag and any images unique to it. Images will not be deleted until all tags sharing them are deleted. +
+
+ +

Using push webhooks Requires Admin Access