diff --git a/data/model.py b/data/model.py index c6fcb0c7a..21b64e750 100644 --- a/data/model.py +++ b/data/model.py @@ -755,6 +755,46 @@ 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): + 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 = [image for image in all_images if str(image.id) in tag_image_ids] + + # 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/endpoints/api.py b/endpoints/api.py index c145b3296..234489f90 100644 --- a/endpoints/api.py +++ b/endpoints/api.py @@ -1159,6 +1159,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/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) diff --git a/initdb.py b/initdb.py index 44537d4cf..6a4fc5853 100644 --- a/initdb.py +++ b/initdb.py @@ -126,6 +126,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') @@ -282,6 +283,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 f09ce7efc..6cc64319c 100644 --- a/static/css/quay.css +++ b/static/css/quay.css @@ -1156,6 +1156,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..800d25232 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]; @@ -218,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) { @@ -229,6 +266,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 +414,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 406c9bbf6..7b478dc8a 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 - 150) + '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/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

diff --git a/static/partials/view-repo.html b/static/partials/view-repo.html index d54a61aff..fb69739c0 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 +
@@ -185,3 +194,25 @@ sudo docker push quay.io/{{repo.namespace}}/{{repo.name}}
+ + + + diff --git a/test/data/test.db b/test/data/test.db index ec363c24d..41dd4cb0b 100644 Binary files a/test/data/test.db and b/test/data/test.db differ