Add UI for viewing and changing the expiration of tags

This commit is contained in:
Joseph Schorr 2017-06-21 21:33:26 -04:00
parent 977539bf08
commit 99d7fde8ee
13 changed files with 329 additions and 26 deletions

View file

@ -0,0 +1,25 @@
"""Add change_tag_expiration log type
Revision ID: d8989249f8f6
Revises: dc4af11a5f90
Create Date: 2017-06-21 21:18:25.948689
"""
# revision identifiers, used by Alembic.
revision = 'd8989249f8f6'
down_revision = 'dc4af11a5f90'
from alembic import op
def upgrade(tables):
op.bulk_insert(tables.logentrykind, [
{'name': 'change_tag_expiration'},
])
def downgrade(tables):
op.execute(tables
.logentrykind
.delete()
.where(tables.logentrykind.c.name == op.inline_literal('change_tag_expiration')))

View file

@ -1,13 +1,17 @@
import logging import logging
import time
from calendar import timegm
from uuid import uuid4 from uuid import uuid4
from peewee import IntegrityError, JOIN_LEFT_OUTER, fn from peewee import IntegrityError, JOIN_LEFT_OUTER, fn
from data.model import (image, db_transaction, DataModelException, _basequery, from data.model import (image, db_transaction, DataModelException, _basequery,
InvalidManifestException, TagAlreadyCreatedException, StaleTagException) InvalidManifestException, TagAlreadyCreatedException, StaleTagException,
config)
from data.database import (RepositoryTag, Repository, Image, ImageStorage, Namespace, TagManifest, from data.database import (RepositoryTag, Repository, Image, ImageStorage, Namespace, TagManifest,
RepositoryNotification, Label, TagManifestLabel, get_epoch_timestamp, RepositoryNotification, Label, TagManifestLabel, get_epoch_timestamp,
db_for_update) db_for_update)
from util.timedeltastring import convert_to_timedelta
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -570,3 +574,41 @@ def _load_repo_manifests(namespace, repo_name):
.join(Repository) .join(Repository)
.join(Namespace, on=(Namespace.id == Repository.namespace_user)) .join(Namespace, on=(Namespace.id == Repository.namespace_user))
.where(Repository.name == repo_name, Namespace.username == namespace)) .where(Repository.name == repo_name, Namespace.username == namespace))
def change_repository_tag_expiration(namespace_name, repo_name, tag_name, expiration_date):
""" Changes the expiration of the tag with the given name to the given expiration datetime. If
the expiration datetime is None, then the tag is marked as not expiring.
"""
try:
tag = get_active_tag(namespace_name, repo_name, tag_name)
return change_tag_expiration(tag, expiration_date)
except RepositoryTag.DoesNotExist:
return (None, False)
def change_tag_expiration(tag, expiration_date):
""" Changes the expiration of the given tag to the given expiration datetime. If
the expiration datetime is None, then the tag is marked as not expiring.
"""
end_ts = None
min_expire_sec = convert_to_timedelta(config.app_config.get('LABELED_EXPIRATION_MINIMUM', '1h'))
max_expire_sec = convert_to_timedelta(config.app_config.get('LABELED_EXPIRATION_MAXIMUM', '104w'))
if expiration_date is not None:
offset = timegm(expiration_date.utctimetuple()) - tag.lifetime_start_ts
offset = min(max(offset, min_expire_sec.total_seconds()), max_expire_sec.total_seconds())
end_ts = tag.lifetime_start_ts + offset
if end_ts == tag.lifetime_end_ts:
return (None, True)
# Note: We check not just the ID of the tag but also its lifetime_end_ts, to ensure that it has
# not changed while we were updatings it expiration.
result = (RepositoryTag
.update(lifetime_end_ts=end_ts)
.where(RepositoryTag.id == tag.id,
RepositoryTag.lifetime_end_ts == tag.lifetime_end_ts)
.execute())
return (tag.lifetime_end_ts, result > 0)

View file

@ -1,13 +1,16 @@
import pytest import pytest
from datetime import datetime
from mock import patch from mock import patch
from time import time from time import time
from data.database import Image, RepositoryTag, ImageStorage, Repository from data.database import Image, RepositoryTag, ImageStorage, Repository
from data.model.repository import create_repository from data.model.repository import create_repository
from data.model.tag import (list_active_repo_tags, create_or_update_tag, delete_tag, from data.model.tag import (list_active_repo_tags, create_or_update_tag, delete_tag,
get_matching_tags, _tag_alive, get_matching_tags_for_images) get_matching_tags, _tag_alive, get_matching_tags_for_images,
change_tag_expiration, get_active_tag)
from data.model.image import find_create_or_link_image from data.model.image import find_create_or_link_image
from util.timedeltastring import convert_to_timedelta
from test.fixtures import * from test.fixtures import *
@ -177,3 +180,34 @@ def test_list_active_tags(initialized_db):
# "Move" foo by updating it and make sure we don't get duplicates. # "Move" foo by updating it and make sure we don't get duplicates.
create_or_update_tag('devtable', 'somenewrepo', 'foo', image2.docker_image_id) create_or_update_tag('devtable', 'somenewrepo', 'foo', image2.docker_image_id)
assert_tags(repository, 'foo', 'bar') assert_tags(repository, 'foo', 'bar')
@pytest.mark.parametrize('expiration_offset, expected_offset', [
(None, None),
('0s', '1h'),
('30m', '1h'),
('2h', '2h'),
('2w', '2w'),
('200w', '104w'),
])
def test_change_tag_expiration(expiration_offset, expected_offset, initialized_db):
repository = create_repository('devtable', 'somenewrepo', None)
image1 = find_create_or_link_image('foobarimage1', repository, None, {}, 'local_us')
footag = create_or_update_tag('devtable', 'somenewrepo', 'foo', image1.docker_image_id)
expiration_date = None
if expiration_offset is not None:
expiration_date = datetime.utcnow() + convert_to_timedelta(expiration_offset)
assert change_tag_expiration(footag, expiration_date)
# Lookup the tag again.
footag_updated = get_active_tag('devtable', 'somenewrepo', 'foo')
if expected_offset is None:
assert footag_updated.lifetime_end_ts is None
else:
start_date = datetime.utcfromtimestamp(footag_updated.lifetime_start_ts)
end_date = datetime.utcfromtimestamp(footag_updated.lifetime_end_ts)
expected_end_date = start_date + convert_to_timedelta(expected_offset)
assert end_date == expected_end_date

View file

@ -334,6 +334,10 @@ class Repository(RepositoryParamResource):
last_modified = format_date(datetime.fromtimestamp(tag.lifetime_start_ts)) last_modified = format_date(datetime.fromtimestamp(tag.lifetime_start_ts))
tag_info['last_modified'] = last_modified tag_info['last_modified'] = last_modified
if tag.lifetime_end_ts:
expiration = format_date(datetime.fromtimestamp(tag.lifetime_end_ts))
tag_info['expiration'] = expiration
if tag.tagmanifest is not None: if tag.tagmanifest is not None:
tag_info['manifest_digest'] = tag.tagmanifest.digest tag_info['manifest_digest'] = tag.tagmanifest.digest

View file

@ -1,5 +1,6 @@
""" Manage the tags of a repository. """ """ Manage the tags of a repository. """
from datetime import datetime, timedelta
from flask import request, abort from flask import request, abort
from auth.auth_context import get_authenticated_user from auth.auth_context import get_authenticated_user
@ -54,12 +55,15 @@ class RepositoryTag(RepositoryParamResource):
schemas = { schemas = {
'MoveTag': { 'MoveTag': {
'type': 'object', 'type': 'object',
'description': 'Description of to which image a new or existing tag should point', 'description': 'Makes changes to a specific tag',
'required': ['image',],
'properties': { 'properties': {
'image': { 'image': {
'type': 'string', 'type': ['string', 'null'],
'description': 'Image identifier to which the tag should point', 'description': '(If specified) Image identifier to which the tag should point',
},
'image': {
'type': ['number', 'null'],
'description': '(If specified) The expiration for the image',
}, },
}, },
}, },
@ -75,11 +79,39 @@ class RepositoryTag(RepositoryParamResource):
if not TAG_REGEX.match(tag): if not TAG_REGEX.match(tag):
abort(400, TAG_ERROR) abort(400, TAG_ERROR)
image_id = request.get_json()['image'] repo = model.get_repo(namespace, repository)
repo = model.get_repo(namespace, repository, image_id)
if not repo: if not repo:
raise NotFound() raise NotFound()
if 'expiration' in request.get_json():
expiration = request.get_json().get('expiration')
expiration_date = None
if expiration is not None:
try:
expiration_date = datetime.utcfromtimestamp(float(expiration))
except ValueError:
abort(400)
if expiration_date <= datetime.now():
abort(400)
existing_end_ts, ok = model.change_repository_tag_expiration(namespace, repository, tag,
expiration_date)
if ok:
if not (existing_end_ts is None and expiration_date is None):
log_action('change_tag_expiration', namespace, {
'username': get_authenticated_user().username,
'repo': repository,
'tag': tag,
'namespace': namespace,
'expiration_date': expiration_date,
'old_expiration_date': existing_end_ts
}, repo=repo)
else:
abort(400, 'Could not update tag expiration; Tag has probably changed')
if 'image' in request.get_json():
image_id = request.get_json()['image']
original_image_id = model.get_repo_tag_image(repo, tag) original_image_id = model.get_repo_tag_image(repo, tag)
model.create_or_update_tag(namespace, repository, tag, image_id) model.create_or_update_tag(namespace, repository, tag, image_id)
@ -92,7 +124,6 @@ class RepositoryTag(RepositoryParamResource):
'image': image_id, 'image': image_id,
'original_image': original_image_id 'original_image': original_image_id
}, repo_name=repository) }, repo_name=repository)
_generate_and_store_manifest(namespace, repository, tag) _generate_and_store_manifest(namespace, repository, tag)
return 'Updated', 201 return 'Updated', 201

View file

@ -124,6 +124,43 @@ def find_no_repo_tag_history():
yield yield
@pytest.mark.parametrize('expiration_time, expected_status', [
(None, 201),
('aksdjhasd', 400),
])
def test_change_tag_expiration_default(expiration_time, expected_status, client, app):
with client_with_identity('devtable', client) as cl:
params = {
'repository': 'devtable/simple',
'tag': 'latest',
}
request_body = {
'expiration': expiration_time,
}
conduct_api_call(cl, RepositoryTag, 'put', params, request_body, expected_status)
def test_change_tag_expiration(client, app):
with client_with_identity('devtable', client) as cl:
params = {
'repository': 'devtable/simple',
'tag': 'latest',
}
tag = model.tag.get_active_tag('devtable', 'simple', 'latest')
updated_expiration = tag.lifetime_start_ts + 60*60*24
request_body = {
'expiration': updated_expiration,
}
conduct_api_call(cl, RepositoryTag, 'put', params, request_body, 201)
tag = model.tag.get_active_tag('devtable', 'simple', 'latest')
assert tag.lifetime_end_ts == updated_expiration
@pytest.mark.parametrize('test_image,test_tag,expected_status', [ @pytest.mark.parametrize('test_image,test_tag,expected_status', [
('image1', '-INVALID-TAG-NAME', 400), ('image1', '-INVALID-TAG-NAME', 400),
('image1', '.INVALID-TAG-NAME', 400), ('image1', '.INVALID-TAG-NAME', 400),

View file

@ -20,7 +20,7 @@ from auth.permissions import (AdministerOrganizationPermission, CreateRepository
UserAdminPermission, UserReadPermission, SuperUserPermission) UserAdminPermission, UserReadPermission, SuperUserPermission)
from data import model from data import model
from data.billing import get_plan from data.billing import get_plan
from data.database import Repository as RepositoryTable, UserPromptTypes from data.database import Repository as RepositoryTable
from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error, from endpoints.api import (ApiResource, nickname, resource, validate_json_request, request_error,
log_action, internal_only, require_user_admin, parse_args, log_action, internal_only, require_user_admin, parse_args,
query_param, require_scope, format_date, show_if, query_param, require_scope, format_date, show_if,

View file

@ -350,6 +350,8 @@ def initialize_database():
LogEntryKind.create(name='manifest_label_add') LogEntryKind.create(name='manifest_label_add')
LogEntryKind.create(name='manifest_label_delete') LogEntryKind.create(name='manifest_label_delete')
LogEntryKind.create(name='change_tag_expiration')
ImageStorageLocation.create(name='local_eu') ImageStorageLocation.create(name='local_eu')
ImageStorageLocation.create(name='local_us') ImageStorageLocation.create(name='local_us')

View file

@ -57,6 +57,12 @@
<i class="fa fa-times"></i><span class="text">Delete Tags</span> <i class="fa fa-times"></i><span class="text">Delete Tags</span>
</a> </a>
</li> </li>
<li ng-if="repository.can_write">
<a ng-click="askChangeTagsExpiration(checkedTags.checked)"
ng-class="repository.tag_operations_disabled ? 'disabled-option' : ''">
<i class="fa fa-clock-o"></i><span class="text">Change Tags Expiration</span>
</a>
</li>
</ul> </ul>
</div> </div>
</span> </span>
@ -105,6 +111,11 @@
style="width: 80px;"> style="width: 80px;">
<a ng-click="orderBy('size')" data-title="The compressed size of the tag's image" data-container="body" bs-tooltip>Size</a> <a ng-click="orderBy('size')" data-title="The compressed size of the tag's image" data-container="body" bs-tooltip>Size</a>
</td> </td>
<td class="hidden-sm hidden-xs"
ng-class="tablePredicateClass('expiration_date', options.predicate, options.reverse)"
style="width: 140px;">
<a ng-click="orderBy('expiration_date')" data-title="When the tag expires" data-container="body" bs-tooltip>Expires</a>
</td>
<td class="hidden-xs hidden-sm" <td class="hidden-xs hidden-sm"
ng-class="tablePredicateClass('image_id', options.predicate, options.reverse)" ng-class="tablePredicateClass('image_id', options.predicate, options.reverse)"
style="width: 140px;"> style="width: 140px;">
@ -133,12 +144,16 @@
ng-if="repository.trust_enabled"> ng-if="repository.trust_enabled">
<tag-signing-display tag="tag" delegations="repoDelegationsInfo" compact="true"></tag-signing-display> <tag-signing-display tag="tag" delegations="repoDelegationsInfo" compact="true"></tag-signing-display>
</td> </td>
<!-- Last Modified -->
<td class="hidden-xs"> <td class="hidden-xs">
<span bo-if="tag.last_modified" data-title="{{ tag.last_modified | amDateFormat:'dddd, MMMM Do YYYY, h:mm:ss a' }}" bs-tooltip> <span bo-if="tag.last_modified" data-title="{{ tag.last_modified | amDateFormat:'dddd, MMMM Do YYYY, h:mm:ss a' }}" bs-tooltip>
<span am-time-ago="tag.last_modified"></span> <span am-time-ago="tag.last_modified"></span>
</span> </span>
<span bo-if="!tag.last_modified">Unknown</span> <span bo-if="!tag.last_modified">Unknown</span>
</td> </td>
<!-- Security scanning -->
<td quay-require="['SECURITY_SCANNER']" class="security-scan-col hidden-xs"> <td quay-require="['SECURITY_SCANNER']" class="security-scan-col hidden-xs">
<span class="cor-loader-inline" ng-if="getTagVulnerabilities(tag).loading"></span> <span class="cor-loader-inline" ng-if="getTagVulnerabilities(tag).loading"></span>
<span class="vuln-load-error" ng-if="getTagVulnerabilities(tag).hasError" <span class="vuln-load-error" ng-if="getTagVulnerabilities(tag).hasError"
@ -212,7 +227,20 @@
</span> </span>
</span> </span>
</td> </td>
<!-- Size -->
<td class="hidden-sm hidden-xs" bo-text="tag.size | bytes"></td> <td class="hidden-sm hidden-xs" bo-text="tag.size | bytes"></td>
<!-- Expiration -->
<td class="hidden-xs hidden-sm">
<a ng-click="askChangeTagsExpiration([tag])"
ng-if="!repository.tag_operations_disabled && repository.can_write">
<expiration-status-view expiration-date="tag.expiration_date"></expiration-status-view>
</a>
<expiration-status-view expiration-date="tag.expiration_date" ng-if="repository.tag_operations_disabled || !repository.can_write"></expiration-status-view>
</td>
<!-- Image link -->
<td class="hidden-xs hidden-sm image-id-col"> <td class="hidden-xs hidden-sm image-id-col">
<span class="image-link" repository="repository" image-id="tag.image_id" manifest-digest="tag.manifest_digest"></span> <span class="image-link" repository="repository" image-id="tag.image_id" manifest-digest="tag.manifest_digest"></span>
</td> </td>
@ -254,6 +282,10 @@
ng-class="repository.tag_operations_disabled ? 'disabled-option' : ''"> ng-class="repository.tag_operations_disabled ? 'disabled-option' : ''">
<i class="fa fa-times"></i> Delete Tag <i class="fa fa-times"></i> Delete Tag
</span> </span>
<span class="cor-option" option-click="askChangeTagsExpiration([tag])"
ng-class="repository.tag_operations_disabled ? 'disabled-option' : ''">
<i class="fa fa-clock-o"></i> Change Expiration
</span>
</span> </span>
</span> </span>
</td> </td>
@ -261,7 +293,7 @@
</tr> </tr>
<tr ng-if="expandedView"> <tr ng-if="expandedView">
<td class="checkbox-col"></td> <td class="checkbox-col"></td>
<td class="labels-col" colspan="{{5 + (Features.SECURITY_SCANNER ? 1 : 0) + (repository.trust_enabled ? 1 : 0) }}"> <td class="labels-col" colspan="{{6 + (Features.SECURITY_SCANNER ? 1 : 0) + (repository.trust_enabled ? 1 : 0) }}">
<!-- Labels --> <!-- Labels -->
<div class="manifest-label-list" repository="repository" <div class="manifest-label-list" repository="repository"
manifest-digest="tag.manifest_digest" cache="labelCache"></div> manifest-digest="tag.manifest_digest" cache="labelCache"></div>

View file

@ -111,6 +111,28 @@
</div> </div>
</div> </div>
<!-- Change Tags Expiration -->
<div class="cor-confirm-dialog"
dialog-context="changeTagsExpirationInfo"
dialog-action="changeTagsExpiration(info.tags, info.expiration_date, callback)"
dialog-title="Change Tags Expiration"
dialog-action-title="Change Expiration">
<form class="expiration-form">
<label>Tags that will be updated:</label>
<ul class="delete-tag-list">
<li ng-repeat="tag_info in changeTagsExpirationInfo.tags">
<span class="label label-default tag">{{ tag_info.name }}</span>
</li>
</ul>
<label style="margin-top: 20px;">Expiration Date:</label>
<span class="datetime-picker" datetime="changeTagsExpirationInfo.expiration_date"></span>
<span class="co-help-text">
If specified, the date and time that the key expires. If set to none, the tag(s) will not expire.
</span>
</form>
</div>
<!-- Delete Tag Confirm --> <!-- Delete Tag Confirm -->
<div class="cor-confirm-dialog" <div class="cor-confirm-dialog"
dialog-context="deleteTagInfo" dialog-context="deleteTagInfo"

View file

@ -74,7 +74,8 @@ angular.module('quay').directive('repoPanelTags', function () {
var tagData = $scope.repository.tags[tag]; var tagData = $scope.repository.tags[tag];
var tagInfo = $.extend(tagData, { var tagInfo = $.extend(tagData, {
'name': tag, 'name': tag,
'last_modified_datetime': TableService.getReversedTimestamp(tagData.last_modified) 'last_modified_datetime': TableService.getReversedTimestamp(tagData.last_modified),
'expiration_date': tagData.expiration ? TableService.getReversedTimestamp(tagData.expiration) : null,
}); });
allTags.push(tagInfo); allTags.push(tagInfo);
@ -355,6 +356,10 @@ angular.module('quay').directive('repoPanelTags', function () {
$scope.tagActionHandler.askDeleteMultipleTags(tags); $scope.tagActionHandler.askDeleteMultipleTags(tags);
}; };
$scope.askChangeTagsExpiration = function(tags) {
$scope.tagActionHandler.askChangeTagsExpiration(tags);
};
$scope.askAddTag = function(tag) { $scope.askAddTag = function(tag) {
$scope.tagActionHandler.askAddTag(tag.image_id); $scope.tagActionHandler.askAddTag(tag.image_id);
}; };

View file

@ -271,6 +271,18 @@ angular.module('quay').directive('logsView', function () {
'manifest_label_add': 'Label {key} added to manifest {manifest_digest} under repository {namespace}/{repo}', 'manifest_label_add': 'Label {key} added to manifest {manifest_digest} under repository {namespace}/{repo}',
'manifest_label_delete': 'Label {key} deleted from manifest {manifest_digest} under repository {namespace}/{repo}', 'manifest_label_delete': 'Label {key} deleted from manifest {manifest_digest} under repository {namespace}/{repo}',
'change_tag_expiration': function(metadata) {
if (metadata.expiration_date && metadata.old_expiration_date) {
return 'Tag {tag} set to expire on {expiration_date} (previously {old_expiration_date})';
} else if (metadata.expiration_date) {
return 'Tag {tag} set to expire on {expiration_date}';
} else if (metadata.old_expiration_date) {
return 'Tag {tag} set to no longer expire (previously {old_expiration_date})';
} else {
return 'Tag {tag} set to no longer expire';
}
},
// Note: These are deprecated. // Note: These are deprecated.
'add_repo_webhook': 'Add webhook in repository {repo}', 'add_repo_webhook': 'Add webhook in repository {repo}',
'delete_repo_webhook': 'Delete webhook in repository {repo}' 'delete_repo_webhook': 'Delete webhook in repository {repo}'
@ -332,6 +344,7 @@ angular.module('quay').directive('logsView', function () {
'take_ownership': 'Take Namespace Ownership', 'take_ownership': 'Take Namespace Ownership',
'manifest_label_add': 'Add Manifest Label', 'manifest_label_add': 'Add Manifest Label',
'manifest_label_delete': 'Delete Manifest Label', 'manifest_label_delete': 'Delete Manifest Label',
'change_tag_expiration': 'Change tag expiration',
// Note: these are deprecated. // Note: these are deprecated.
'add_repo_webhook': 'Add webhook', 'add_repo_webhook': 'Add webhook',

View file

@ -81,13 +81,58 @@ angular.module('quay').directive('tagOperationsDialog', function () {
$element.find('#createOrMoveTagModal').modal('hide'); $element.find('#createOrMoveTagModal').modal('hide');
}); });
ApiService.changeTagImage(data, params).then(function(resp) { ApiService.changeTag(data, params).then(function(resp) {
$element.find('#createOrMoveTagModal').modal('hide'); $element.find('#createOrMoveTagModal').modal('hide');
$scope.addingTag = false; $scope.addingTag = false;
markChanged([tag], []); markChanged([tag], []);
}, errorHandler); }, errorHandler);
}; };
$scope.changeTagsExpiration = function(tags, expiration_date, callback) {
if (!$scope.repository.can_write) { return; }
var count = tags.length;
var perform = function(index) {
if (index >= count) {
callback(true);
markChanged(tags, []);
return;
}
var tag_info = tags[index];
if (!tag_info) { return; }
$scope.changeTagExpiration(tag_info.name, expiration_date, function(result) {
if (!result) {
callback(false);
return;
}
perform(index + 1);
}, true);
};
perform(0);
};
$scope.changeTagExpiration = function(tag, expiration_date, callback) {
if (!$scope.repository.can_write) { return; }
var params = {
'repository': $scope.repository.namespace + '/' + $scope.repository.name,
'tag': tag
};
var data = {
'expiration': expiration_date
};
var errorHandler = ApiService.errorDisplay('Cannot change tag expiration', callback);
ApiService.changeTag(data, params).then(function() {
callback(true);
}, errorHandler);
};
$scope.deleteMultipleTags = function(tags, callback) { $scope.deleteMultipleTags = function(tags, callback) {
if (!$scope.repository.can_write) { return; } if (!$scope.repository.can_write) { return; }
@ -296,6 +341,17 @@ angular.module('quay').directive('tagOperationsDialog', function () {
}, ApiService.errorDisplay('Could not load manifest labels')); }, ApiService.errorDisplay('Could not load manifest labels'));
}, },
'askChangeTagsExpiration': function(tags) {
if ($scope.alertOnTagOpsDisabled()) {
return;
}
$scope.changeTagsExpirationInfo ={
'tags': tags,
'expiration_date': null
};
},
'askRestoreTag': function(tag, image_id, opt_manifest_digest) { 'askRestoreTag': function(tag, image_id, opt_manifest_digest) {
if ($scope.alertOnTagOpsDisabled()) { if ($scope.alertOnTagOpsDisabled()) {
return; return;