From f19d2f684ef3aa60847a5fb02f12ca685f7a3c3b Mon Sep 17 00:00:00 2001 From: Joseph Schorr Date: Thu, 16 Apr 2015 17:18:00 -0400 Subject: [PATCH] Add ability to revert tags via time machine --- data/database.py | 1 + ...decf6b9c4_add_revert_tag_log_entry_kind.py | 29 +++++++++ ..._add_reversion_column_to_the_tags_table.py | 26 ++++++++ data/model/legacy.py | 21 ++++++- endpoints/api/tag.py | 63 ++++++++++++++++++- initdb.py | 1 + .../directives/repo-view/repo-panel-tags.css | 17 ++++- static/css/directives/ui/repo-tag-history.css | 20 ++++-- .../directives/ui/tag-operations-dialog.css | 4 ++ static/directives/repo-tag-history.html | 6 ++ .../directives/repo-view/repo-panel-tags.html | 30 +++++++-- static/directives/tag-operations-dialog.html | 17 +++++ .../directives/repo-view/repo-panel-tags.js | 22 +++++++ static/js/directives/ui/logs-view.js | 2 + static/js/directives/ui/repo-tag-history.js | 4 +- .../js/directives/ui/tag-operations-dialog.js | 33 ++++++++++ 16 files changed, 277 insertions(+), 19 deletions(-) create mode 100644 data/migrations/versions/1c3decf6b9c4_add_revert_tag_log_entry_kind.py create mode 100644 data/migrations/versions/4ce2169efd3b_add_reversion_column_to_the_tags_table.py create mode 100644 static/css/directives/ui/tag-operations-dialog.css diff --git a/data/database.py b/data/database.py index 8bc0488a7..29b7e3c04 100644 --- a/data/database.py +++ b/data/database.py @@ -497,6 +497,7 @@ class RepositoryTag(BaseModel): lifetime_start_ts = IntegerField(default=get_epoch_timestamp) lifetime_end_ts = IntegerField(null=True, index=True) hidden = BooleanField(default=False) + reversion = BooleanField(default=False) class Meta: database = db diff --git a/data/migrations/versions/1c3decf6b9c4_add_revert_tag_log_entry_kind.py b/data/migrations/versions/1c3decf6b9c4_add_revert_tag_log_entry_kind.py new file mode 100644 index 000000000..6e2bccb68 --- /dev/null +++ b/data/migrations/versions/1c3decf6b9c4_add_revert_tag_log_entry_kind.py @@ -0,0 +1,29 @@ +"""Add revert_tag log entry kind + +Revision ID: 1c3decf6b9c4 +Revises: 4ce2169efd3b +Create Date: 2015-04-16 17:14:11.154856 + +""" + +# revision identifiers, used by Alembic. +revision = '1c3decf6b9c4' +down_revision = '4ce2169efd3b' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(tables): + op.bulk_insert(tables.logentrykind, + [ + {'id': 47, 'name':'revert_tag'}, + ]) + + +def downgrade(tables): + op.execute( + (tables.logentrykind.delete() + .where(tables.logentrykind.c.name == op.inline_literal('revert_tag'))) + + ) \ No newline at end of file diff --git a/data/migrations/versions/4ce2169efd3b_add_reversion_column_to_the_tags_table.py b/data/migrations/versions/4ce2169efd3b_add_reversion_column_to_the_tags_table.py new file mode 100644 index 000000000..9329942b0 --- /dev/null +++ b/data/migrations/versions/4ce2169efd3b_add_reversion_column_to_the_tags_table.py @@ -0,0 +1,26 @@ +"""Add reversion column to the tags table + +Revision ID: 4ce2169efd3b +Revises: 2b4dc0818a5e +Create Date: 2015-04-16 17:10:16.039835 + +""" + +# revision identifiers, used by Alembic. +revision = '4ce2169efd3b' +down_revision = '2b4dc0818a5e' + +from alembic import op +import sqlalchemy as sa + + +def upgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.add_column('repositorytag', sa.Column('reversion', sa.Boolean(), nullable=False)) + ### end Alembic commands ### + + +def downgrade(tables): + ### commands auto generated by Alembic - please adjust! ### + op.drop_column('repositorytag', 'reversion') + ### end Alembic commands ### diff --git a/data/model/legacy.py b/data/model/legacy.py index 7ec27eed9..49d569bf5 100644 --- a/data/model/legacy.py +++ b/data/model/legacy.py @@ -1762,13 +1762,17 @@ def _tag_alive(query, now_ts=None): (RepositoryTag.lifetime_end_ts > now_ts)) -def list_repository_tag_history(repository, limit=100): +def list_repository_tag_history(repository, limit=100, specific_tag=None): query = (RepositoryTag .select(RepositoryTag, Image) .join(Image) .where(RepositoryTag.repository == repository) .order_by(RepositoryTag.lifetime_start_ts.desc()) .limit(limit)) + + if specific_tag: + query = query.where(RepositoryTag.name == specific_tag) + return query @@ -1990,7 +1994,7 @@ def get_parent_images(namespace_name, repository_name, image_obj): def create_or_update_tag(namespace_name, repository_name, tag_name, - tag_docker_image_id): + tag_docker_image_id, reversion=False): try: repo = _get_repository(namespace_name, repository_name) except Repository.DoesNotExist: @@ -2015,7 +2019,7 @@ def create_or_update_tag(namespace_name, repository_name, tag_name, raise DataModelException('Invalid image with id: %s' % tag_docker_image_id) return RepositoryTag.create(repository=repo, image=image, name=tag_name, - lifetime_start_ts=now_ts) + lifetime_start_ts=now_ts, reversion=reversion) def delete_tag(namespace_name, repository_name, tag_name): now_ts = get_epoch_timestamp() @@ -2823,3 +2827,14 @@ def repository_is_starred(user, repository): return True except Star.DoesNotExist: return False + + +def revert_tag(namespace_name, repository_name, tag_name, docker_image_id): + """ Reverts a tag to a specific image ID. """ + image = get_image_by_id(namespace_name, repository_name, docker_image_id) + if image is None: + raise DataModelException('Cannot revert to unknown image') + + return create_or_update_tag(namespace_name, repository_name, tag_name, docker_image_id, + reversion=True) + diff --git a/endpoints/api/tag.py b/endpoints/api/tag.py index f698be851..c5a6b2288 100644 --- a/endpoints/api/tag.py +++ b/endpoints/api/tag.py @@ -2,7 +2,7 @@ from flask import request, abort from endpoints.api import (resource, nickname, require_repo_read, require_repo_write, RepositoryParamResource, log_action, NotFound, validate_json_request, - path_param, format_date) + path_param, format_date, parse_args, query_param) from endpoints.api.image import image_view from data import model from auth.auth_context import get_authenticated_user @@ -17,8 +17,11 @@ class ListRepositoryTags(RepositoryParamResource): """ Resource for listing repository tags. """ @require_repo_write + @parse_args + @query_param('specificTag', 'Filters the tags to the specific tag.', type=str, default='') + @query_param('limit', 'Limit to the number of results to return. Max 100.', type=int, default=50) @nickname('listRepoTags') - def get(self, namespace, repository): + def get(self, args, namespace, repository): repo = model.get_repository(namespace, repository) if not repo: abort(404) @@ -27,6 +30,7 @@ class ListRepositoryTags(RepositoryParamResource): tag_info = { 'name': tag.name, 'docker_image_id': tag.image.docker_image_id, + 'reversion': tag.reversion, } if tag.lifetime_start_ts > 0: @@ -37,7 +41,9 @@ class ListRepositoryTags(RepositoryParamResource): return tag_info - tags = model.list_repository_tag_history(repo, limit=100) + specific_tag = args.get('specificTag') or None + limit = min(100, max(1, args.get('limit', 50))) + tags = model.list_repository_tag_history(repo, limit=limit, specific_tag=specific_tag) return {'tags': [tag_view(tag) for tag in tags]} @@ -134,3 +140,54 @@ class RepositoryTagImages(RepositoryParamResource): return { 'images': [image_view(image, image_map) for image in all_images] } + + + +@resource('/v1/repository//tag//revert') +@path_param('repository', 'The full path of the repository. e.g. namespace/name') +@path_param('tag', 'The name of the tag') +class RevertTag(RepositoryParamResource): + """ Resource for reverting a repository tag back to a previous image. """ + schemas = { + 'RevertTag': { + 'id': 'RevertTag', + 'type': 'object', + 'description': 'Reverts a tag to a specific image', + 'required': [ + 'image', + ], + 'properties': { + 'image': { + 'type': 'string', + 'description': 'Image identifier to which the tag should point', + }, + }, + }, + } + + @require_repo_write + @nickname('revertTag') + @validate_json_request('RevertTag') + def post(self, namespace, repository, tag): + """ Reverts a repository tag back to a previous image in the repository. """ + try: + tag_image = model.get_tag_image(namespace, repository, tag) + except model.DataModelException: + raise NotFound() + + # Revert the tag back to the previous image. + image_id = request.get_json()['image'] + model.revert_tag(namespace, repository, tag, image_id) + model.garbage_collect_repository(namespace, repository) + + # Log the reversion. + username = get_authenticated_user().username + log_action('revert_tag', namespace, + {'username': username, 'repo': repository, 'tag': tag, + 'image': image_id, 'original_image': tag_image.docker_image_id}, + repo=model.get_repository(namespace, repository)) + + return { + 'image_id': image_id, + 'original_image_id': tag_image.docker_image_id + } diff --git a/initdb.py b/initdb.py index 0788884cb..4d40da2ed 100644 --- a/initdb.py +++ b/initdb.py @@ -220,6 +220,7 @@ def initialize_database(): LogEntryKind.create(name='create_tag') LogEntryKind.create(name='move_tag') LogEntryKind.create(name='delete_tag') + LogEntryKind.create(name='revert_tag') LogEntryKind.create(name='add_repo_permission') LogEntryKind.create(name='change_repo_permission') LogEntryKind.create(name='delete_repo_permission') diff --git a/static/css/directives/repo-view/repo-panel-tags.css b/static/css/directives/repo-view/repo-panel-tags.css index 9df9c2679..c515b9190 100644 --- a/static/css/directives/repo-view/repo-panel-tags.css +++ b/static/css/directives/repo-view/repo-panel-tags.css @@ -65,7 +65,22 @@ padding-left: 20px; } -.repo-panel-tags-element .options-col .fa-download { +.repo-panel-tags-element .options-col .fa-download, .repo-panel-tags-element .options-col .fa-history { color: #999; cursor: pointer; +} + +.repo-panel-tags-element .tag-image-history-item .image-id { + font-family: Consolas, "Lucida Console", Monaco, monospace; + font-size: 12px; +} + +.repo-panel-tags-element .tag-image-history-item .image-apply-time { + color: #ccc; + font-size: 11px; + padding-left: 20px; +} + +.repo-panel-tags-element .tag-image-history-item .fa-circle-o { + margin-right: 2px; } \ No newline at end of file diff --git a/static/css/directives/ui/repo-tag-history.css b/static/css/directives/ui/repo-tag-history.css index 7ee39b7bc..a0a2c2df7 100644 --- a/static/css/directives/ui/repo-tag-history.css +++ b/static/css/directives/ui/repo-tag-history.css @@ -41,14 +41,15 @@ } .repo-tag-history-element .history-entry .history-date-break:before { - content: ""; + content: "\f073"; + font-family: FontAwesome; + position: absolute; - border-radius: 50%; width: 12px; height: 12px; - background: #ccc; - top: 4px; - left: -7px; + top: 1px; + left: -9px; + background: white; } .repo-tag-history-element .history-entry .history-icon { @@ -79,11 +80,20 @@ font-family: FontAwesome; } +.repo-tag-history-element .history-entry.revert .history-icon:before { + content: "\f0e2"; + font-family: FontAwesome; +} + .repo-tag-history-element .history-entry.delete .history-icon:before { content: "\f014"; font-family: FontAwesome; } +.repo-tag-history-element .history-entry.current.revert .history-icon { + background-color: #F0C577; +} + .repo-tag-history-element .history-entry.current.move .history-icon { background-color: #77BFF0; } diff --git a/static/css/directives/ui/tag-operations-dialog.css b/static/css/directives/ui/tag-operations-dialog.css new file mode 100644 index 000000000..1bbccaaff --- /dev/null +++ b/static/css/directives/ui/tag-operations-dialog.css @@ -0,0 +1,4 @@ +.tag-operations-dialog .image-id { + font-family: Consolas, "Lucida Console", Monaco, monospace; + font-size: 12px; +} \ No newline at end of file diff --git a/static/directives/repo-tag-history.html b/static/directives/repo-tag-history.html index 73bd1c6b2..f6e4c30c1 100644 --- a/static/directives/repo-tag-history.html +++ b/static/directives/repo-tag-history.html @@ -35,6 +35,12 @@ from image + + was reverted to image + + from image + +
{{ entry.time | amDateFormat:'dddd, MMMM Do YYYY, h:mm:ss a' }}
diff --git a/static/directives/repo-view/repo-panel-tags.html b/static/directives/repo-view/repo-panel-tags.html index 9454d8f4e..ee54bf013 100644 --- a/static/directives/repo-view/repo-panel-tags.html +++ b/static/directives/repo-view/repo-panel-tags.html @@ -77,6 +77,7 @@ style="min-width: 120px;"> Image + @@ -105,12 +106,30 @@ ng-click="fetchTagActionHandler.askFetchTag(tag)"> + + + - - View Tag History - Delete Tag @@ -137,5 +156,4 @@
-
-
\ No newline at end of file +
\ No newline at end of file diff --git a/static/directives/tag-operations-dialog.html b/static/directives/tag-operations-dialog.html index 2c5ebd616..92a8a7fd0 100644 --- a/static/directives/tag-operations-dialog.html +++ b/static/directives/tag-operations-dialog.html @@ -85,3 +85,20 @@ + + +
+ +
+ This will change the image to which the tag points. +
+ + Are you sure you want to revert tag + {{ revertTagInfo.tag.name }} to image + {{ revertTagInfo.image_id.substr(0, 12) }}? +
+ \ No newline at end of file diff --git a/static/js/directives/repo-view/repo-panel-tags.js b/static/js/directives/repo-view/repo-panel-tags.js index 3d0604503..97b63250b 100644 --- a/static/js/directives/repo-view/repo-panel-tags.js +++ b/static/js/directives/repo-view/repo-panel-tags.js @@ -24,6 +24,7 @@ angular.module('quay').directive('repoPanelTags', function () { }; $scope.iterationState = {}; + $scope.tagHistory = {}; $scope.tagActionHandler = null; $scope.showingHistory = false; @@ -84,6 +85,9 @@ angular.module('quay').directive('repoPanelTags', function () { 'count': imageMap[image_id].length, 'tags': imageMap[image_id] }); + + imageMap[image_id]['color'] = colors(index); + ++index; } }); @@ -224,6 +228,24 @@ angular.module('quay').directive('repoPanelTags', function () { return names.join(','); }; + + $scope.loadTagHistory = function(tag) { + delete $scope.tagHistory[tag.name]; + + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'specificTag': tag.name, + 'limit': 5 + }; + + ApiService.listRepoTags(null, params).then(function(resp) { + $scope.tagHistory[tag.name] = resp.tags; + }, ApiService.errorDisplay('Could not load tag history')); + }; + + $scope.askRevertTag = function(tag, image_id) { + $scope.tagActionHandler.askRevertTag(tag, image_id); + }; } }; return directiveDefinitionObject; diff --git a/static/js/directives/ui/logs-view.js b/static/js/directives/ui/logs-view.js index 8f85c3261..9a353b2c8 100644 --- a/static/js/directives/ui/logs-view.js +++ b/static/js/directives/ui/logs-view.js @@ -98,6 +98,7 @@ angular.module('quay').directive('logsView', function () { return 'Remove permission for token {token} from repository {repo}'; } }, + 'revert_tag': 'Tag {tag} reverted to image {image} from image {original_image}', 'delete_tag': 'Tag {tag} deleted in repository {repo} by user {username}', 'create_tag': 'Tag {tag} created in repository {repo} on image {image} by user {username}', 'move_tag': 'Tag {tag} moved from image {original_image} to image {image} in repository {repo} by user {username}', @@ -213,6 +214,7 @@ angular.module('quay').directive('logsView', function () { 'delete_tag': 'Delete Tag', 'create_tag': 'Create Tag', 'move_tag': 'Move Tag', + 'revert_tag':' Revert Tag', 'org_create_team': 'Create team', 'org_delete_team': 'Delete team', 'org_add_team_member': 'Add team member', diff --git a/static/js/directives/ui/repo-tag-history.js b/static/js/directives/ui/repo-tag-history.js index 840441c1c..c6ba6120d 100644 --- a/static/js/directives/ui/repo-tag-history.js +++ b/static/js/directives/ui/repo-tag-history.js @@ -56,6 +56,7 @@ angular.module('quay').directive('repoTagHistory', function () { 'action': action, 'start_ts': tag.start_ts, 'end_ts': tag.end_ts, + 'reversion': tag.reversion, 'time': time * 1000, // JS expects ms, not s since epoch. 'docker_image_id': opt_docker_id || dockerImageId, 'old_docker_image_id': opt_old_docker_id || '' @@ -73,7 +74,8 @@ angular.module('quay').directive('repoTagHistory', function () { var futureEntry = currentEntries.length > 0 ? currentEntries[currentEntries.length - 1] : {}; if (futureEntry.start_ts == tag.end_ts) { removeEntry(futureEntry); - addEntry('move', tag.end_ts, futureEntry.docker_image_id, dockerImageId); + addEntry(futureEntry.reversion ? 'revert': 'move', tag.end_ts, + futureEntry.docker_image_id, dockerImageId); } else { addEntry('delete', tag.end_ts) } diff --git a/static/js/directives/ui/tag-operations-dialog.js b/static/js/directives/ui/tag-operations-dialog.js index 4a5aa1877..3fc3ea080 100644 --- a/static/js/directives/ui/tag-operations-dialog.js +++ b/static/js/directives/ui/tag-operations-dialog.js @@ -121,6 +121,25 @@ angular.module('quay').directive('tagOperationsDialog', function () { }, errorHandler); }; + $scope.revertTag = function(tag, image_id, callback) { + if (!$scope.repository.can_write) { return; } + + var params = { + 'repository': $scope.repository.namespace + '/' + $scope.repository.name, + 'tag': tag.name + }; + + var data = { + 'image': image_id + }; + + var errorHandler = ApiService.errorDisplay('Cannot revert tag', callback); + ApiService.revertTag(data, params).then(function() { + callback(true); + markChanged([], [tag]); + }, errorHandler); + }; + $scope.actionHandler = { 'askDeleteTag': function(tag) { $scope.deleteTagInfo = { @@ -140,6 +159,20 @@ angular.module('quay').directive('tagOperationsDialog', function () { $scope.addingTag = false; $scope.addTagForm.$setPristine(); $element.find('#createOrMoveTagModal').modal('show'); + }, + + 'askRevertTag': function(tag, image_id) { + if (tag.image_id == image_id) { + bootbox.alert('This is the current image for the tag'); + return; + } + + $scope.revertTagInfo = { + 'tag': tag, + 'image_id': image_id + }; + + $element.find('#revertTagModal').modal('show'); } }; }