If enabled, allow users and orgs to set their time machine expiration

Fixes https://www.pivotaltracker.com/story/show/142881203
This commit is contained in:
Joseph Schorr 2017-04-05 14:01:55 -04:00
parent eb5cebbcdf
commit 3dcbe3c631
25 changed files with 472 additions and 15 deletions

View file

@ -20,7 +20,8 @@ CLIENT_WHITELIST = ['SERVER_HOSTNAME', 'PREFERRED_URL_SCHEME', 'MIXPANEL_KEY',
'AUTHENTICATION_TYPE', 'REGISTRY_TITLE', 'REGISTRY_TITLE_SHORT',
'CONTACT_INFO', 'AVATAR_KIND', 'LOCAL_OAUTH_HANDLER', 'DOCUMENTATION_LOCATION',
'DOCUMENTATION_METADATA', 'SETUP_COMPLETE', 'DEBUG', 'MARKETO_MUNCHKIN_ID',
'STATIC_SITE_BUCKET', 'RECAPTCHA_SITE_KEY', 'CHANNEL_COLORS']
'STATIC_SITE_BUCKET', 'RECAPTCHA_SITE_KEY', 'CHANNEL_COLORS',
'TAG_EXPIRATION_OPTIONS']
def frontend_visible_config(config_dict):
@ -455,3 +456,12 @@ class DefaultConfig(ImmutableConfig):
TEAM_RESYNC_STALE_TIME = '30m'
TEAM_SYNC_WORKER_FREQUENCY = 60 # seconds
# The default configurable tag expiration time for time machine.
DEFAULT_TAG_EXPIRATION = '2w'
# The options to present in namespace settings for the tag expiration. If empty, no option
# will be given and the default will be displayed read-only.
TAG_EXPIRATION_OPTIONS = ['0s', '1d', '1w', '2w', '4w']
# Feature Flag: Whether users can view and change their tag expiration.
FEATURE_CHANGE_TAG_EXPIRATION = True

View file

@ -0,0 +1,9 @@
from mock import patch
from data.model.user import create_user_noverify
from test.fixtures import database_uri, init_db_path, sqlitedb_file
def test_create_user_with_expiration(database_uri):
with patch('data.model.config.app_config', {'DEFAULT_TAG_EXPIRATION': '1h'}):
user = create_user_noverify('foobar', 'foo@example.com', email_required=False)
assert user.removed_tag_expiration_s == 60 * 60

View file

@ -24,6 +24,7 @@ from util.names import format_robot_username, parse_robot_username
from util.validation import (validate_username, validate_email, validate_password,
INVALID_PASSWORD_MESSAGE)
from util.backoff import exponential_backoff
from util.timedeltastring import convert_to_timedelta
logger = logging.getLogger(__name__)
@ -78,7 +79,8 @@ def create_user_noverify(username, email, email_required=True, prompts=tuple()):
logger.debug('Email and username are unique!')
try:
new_user = User.create(username=username, email=email)
default_expr_s = _convert_to_s(config.app_config['DEFAULT_TAG_EXPIRATION'])
new_user = User.create(username=username, email=email, removed_tag_expiration_s=default_expr_s)
for prompt in prompts:
create_user_prompt(new_user, prompt)
@ -188,7 +190,20 @@ def change_send_invoice_email(user, invoice_email):
user.save()
def _convert_to_s(timespan_string):
""" Returns the given timespan string (e.g. `2w` or `45s`) into seconds. """
return convert_to_timedelta(timespan_string).total_seconds()
def change_user_tag_expiration(user, tag_expiration_s):
""" Changes the tag expiration on the given user/org. Note that the specified expiration must
be within the configured TAG_EXPIRATION_OPTIONS or this method will raise a
DataModelException.
"""
allowed_options = [_convert_to_s(o) for o in config.app_config['TAG_EXPIRATION_OPTIONS']]
if tag_expiration_s not in allowed_options:
raise DataModelException('Invalid tag expiration option')
user.removed_tag_expiration_s = tag_expiration_s
user.save()

View file

@ -58,6 +58,7 @@ def org_view(o, teams):
if is_admin:
view['invoice_email'] = o.invoice_email
view['invoice_email_address'] = o.invoice_email_address
view['tag_expiration_s'] = o.removed_tag_expiration_s
return view
@ -139,10 +140,10 @@ class Organization(ApiResource):
'type': ['string', 'null'],
'description': 'The email address at which to receive invoices',
},
'tag_expiration': {
'tag_expiration_s': {
'type': 'integer',
'maximum': 2592000,
'minimum': 0,
'description': 'The number of seconds for tag expiration',
},
},
},
@ -196,9 +197,9 @@ class Organization(ApiResource):
logger.debug('Changing email address for organization: %s', org.username)
model.user.update_email(org, new_email)
if 'tag_expiration' in org_data:
logger.debug('Changing organization tag expiration to: %ss', org_data['tag_expiration'])
model.user.change_user_tag_expiration(org, org_data['tag_expiration'])
if features.CHANGE_TAG_EXPIRATION and 'tag_expiration_s' in org_data:
logger.debug('Changing organization tag expiration to: %ss', org_data['tag_expiration_s'])
model.user.change_user_tag_expiration(org, org_data['tag_expiration_s'])
teams = model.team.get_teams_within_org(org)
return org_view(org, teams)

View file

@ -0,0 +1,18 @@
import pytest
from data import model
from endpoints.api import api
from endpoints.api.test.shared import client_with_identity, conduct_api_call
from endpoints.api.organization import Organization
from test.fixtures import app, appconfig, database_uri, init_db_path, sqlitedb_file
@pytest.mark.parametrize('expiration, expected_code', [
(0, 200),
(100, 400),
(100000000000000000000, 400),
])
def test_change_tag_expiration(expiration, expected_code, client):
with client_with_identity('devtable', client) as cl:
conduct_api_call(cl, Organization, 'PUT', {'orgname': 'buynlarge'},
body={'tag_expiration_s': expiration},
expected_code=expected_code)

View file

@ -122,7 +122,7 @@ def user_view(user, previous_username=None):
'invoice_email': user.invoice_email,
'invoice_email_address': user.invoice_email_address,
'preferred_namespace': not (user.stripe_id is None),
'tag_expiration': user.removed_tag_expiration_s,
'tag_expiration_s': user.removed_tag_expiration_s,
'prompts': model.user.get_user_prompts(user),
})
@ -210,10 +210,10 @@ class User(ApiResource):
'type': 'string',
'description': 'The user\'s email address',
},
'tag_expiration': {
'tag_expiration_s': {
'type': 'integer',
'maximum': 2592000,
'minimum': 0,
'description': 'The number of seconds for tag expiration',
},
'username': {
'type': 'string',
@ -326,9 +326,9 @@ class User(ApiResource):
logger.debug('Changing invoice_email for user: %s', user.username)
model.user.change_send_invoice_email(user, user_data['invoice_email'])
if 'tag_expiration' in user_data:
logger.debug('Changing user tag expiration to: %ss', user_data['tag_expiration'])
model.user.change_user_tag_expiration(user, user_data['tag_expiration'])
if features.CHANGE_TAG_EXPIRATION and 'tag_expiration_s' in user_data:
logger.debug('Changing user tag expiration to: %ss', user_data['tag_expiration_s'])
model.user.change_user_tag_expiration(user, user_data['tag_expiration_s'])
if ('invoice_email_address' in user_data and
user_data['invoice_email_address'] != user.invoice_email_address):

View file

@ -0,0 +1,5 @@
.duration-input-element input {
display: inline-block;
width: 200px;
margin-right: 10px;
}

View file

@ -0,0 +1,3 @@
.time-machine-settings-element .btn {
margin-top: 10px;
}

View file

@ -10,6 +10,7 @@
<span class="empty" ng-if="!binding || binding.length == 0">No {{ itemTitle }}s defined</span>
<form class="form-control-container" ng-submit="addItem()">
<input type="text" class="form-control" placeholder="{{ placeholder }}"
ng-pattern="getRegexp(itemPattern)"
ng-model="newItemName" style="display: inline-block">
<button class="btn btn-default" style="display: inline-block">Add</button>
</form>

View file

@ -177,6 +177,53 @@
</div>
</div>
<!-- Time Machine -->
<div class="co-panel">
<div class="co-panel-heading">
<i class="fa fa-history"></i> Time Machine
</div>
<div class="co-panel-body">
<div class="description">
<p>Time machine keeps older copies of tags within a repository for the configured period
of time, after whichn they are garbage collected. This allows users to
revert tags to older images in case they accidentally pushed a broken image. It is
highly recommended to have time machine enabled, but it does take a bit more space
in storage.
</p>
</div>
<table class="config-table">
<tr>
<td>Default expiration period:</td>
<td>
<span class="config-string-field" binding="config.MINIMUM_TAG_EXPIRATION"
pattern="[0-9]+(m|w|h|d|s)"></span>
<div class="help-text">
The default tag expiration period for all namespaces (users and organizations). Must be expressed in a duration string form: <code>30m</code>, <code>1h</code>, <code>1d</code>, <code>2w</code>.
</div>
</td>
</tr>
<tr>
<td>Allow users to select expiration:</td>
<td>
<div class="config-bool-field" binding="config.FEATURE_CHANGE_TAG_EXPIRATION">
Enable Expiration Configuration
<div class="help-text">
If enabled, users will be able to select the tag expiration duration for the namespace(s) they
administrate, from the configured list of options.
</div>
</div>
</td>
</tr>
<tr ng-if="config.FEATURE_CHANGE_TAG_EXPIRATION">
<td>Selectable expiration periods:</td>
<td>
<span class="config-list-field" item-title="Tag Expiration Period" binding="config.TAG_EXPIRATION_OPTIONS" item-pattern="[0-9]+(m|w|h|d|s)"></span>
</td>
</tr>
</table>
</div>
</div> <!-- /Time Machine -->
<!-- Redis -->
<div class="co-panel">
<div class="co-panel-heading">

View file

@ -21,6 +21,8 @@ angular.module("core-config-setup", ['angularFileUpload'])
{'id': 'registry-storage', 'title': 'Registry Storage'},
{'id': 'time-machine', 'title': 'Time Machine'},
{'id': 'ssl', 'title': 'SSL certificate and key', 'condition': function(config) {
return config.PREFERRED_URL_SCHEME == 'https';
}},
@ -826,7 +828,8 @@ angular.module("core-config-setup", ['angularFileUpload'])
'binding': '=binding',
'placeholder': '@placeholder',
'defaultValue': '@defaultValue',
'itemTitle': '@itemTitle'
'itemTitle': '@itemTitle',
'itemPattern': '@itemPattern'
},
controller: function($scope, $element) {
$scope.removeItem = function(item) {
@ -853,6 +856,20 @@ angular.module("core-config-setup", ['angularFileUpload'])
$scope.newItemName = null;
};
$scope.patternMap = {};
$scope.getRegexp = function(pattern) {
if (!pattern) {
pattern = '.*';
}
if ($scope.patternMap[pattern]) {
return $scope.patternMap[pattern];
}
return $scope.patternMap[pattern] = new RegExp(pattern);
};
$scope.$watch('binding', function(binding) {
if (!binding && $scope.defaultValue) {
$scope.binding = eval($scope.defaultValue);

View file

@ -0,0 +1,104 @@
// From: https://github.com/angular/angular.js/issues/6726#issuecomment-116251130
angular.module('quay').directive('rangeSlider', [function () {
return {
replace: true,
restrict: 'E',
require: 'ngModel',
template: '<input type="range"/>',
link: function (scope, element, attrs, ngModel) {
var ngRangeMin;
var ngRangeMax;
var ngRangeStep;
var value;
function init() {
if (!angular.isDefined(attrs.ngRangeMin)) {
ngRangeMin = 0;
} else {
scope.$watch(attrs.ngRangeMin, function (newValue, oldValue, scope) {
if (angular.isDefined(newValue)) {
ngRangeMin = newValue;
setValue();
}
});
}
if (!angular.isDefined(attrs.ngRangeMax)) {
ngRangeMax = 100;
} else {
scope.$watch(attrs.ngRangeMax, function (newValue, oldValue, scope) {
if (angular.isDefined(newValue)) {
ngRangeMax = newValue;
setValue();
}
});
}
if (!angular.isDefined(attrs.ngRangeStep)) {
ngRangeStep = 1;
} else {
scope.$watch(attrs.ngRangeStep, function (newValue, oldValue, scope) {
if (angular.isDefined(newValue)) {
ngRangeStep = newValue;
setValue();
}
});
}
if (!angular.isDefined(ngModel)) {
value = 50;
} else {
scope.$watch(
function () {
return ngModel.$modelValue;
},
function (newValue, oldValue, scope) {
if (angular.isDefined(newValue)) {
value = newValue;
setValue();
}
}
);
}
if (!ngModel) {
return;
}
ngModel.$parsers.push(function (value) {
var val = Number(value);
if (val !== val) {
val = undefined;
}
return val;
});
}
function setValue() {
if (
angular.isDefined(ngRangeMin) &&
angular.isDefined(ngRangeMax) &&
angular.isDefined(ngRangeStep) &&
angular.isDefined(value)
) {
element.attr("min", ngRangeMin);
element.attr("max", ngRangeMax);
element.attr("step", ngRangeStep);
element.val(value);
}
}
function read() {
if (angular.isDefined(ngModel)) {
ngModel.$setViewValue(value);
}
}
element.on('change', function () {
if (angular.isDefined(value) && (value != element.val())) {
value = element.val();
scope.$apply(read);
}
});
init();
}
};
}
]);

View file

@ -0,0 +1,4 @@
<div class="duration-input-element">
<range-slider ng-range-min="$ctrl.min_s" ng-range-max="$ctrl.max_s" ng-model="$ctrl.seconds"></range-slider>
<span class="duration-explanation">{{ $ctrl.durationExplanation($ctrl.seconds) }}</span>
</div>

View file

@ -0,0 +1,57 @@
import { Input, Output, Component, Inject } from 'ng-metadata/core';
import * as moment from "moment";
/**
* A component that allows for selecting a time duration.
*/
@Component({
selector: 'duration-input',
templateUrl: '/static/js/directives/ui/duration-input/duration-input.component.html'
})
export class DurationInputComponent implements ng.IComponentController {
@Input('<') public min: string;
@Input('<') public max: string;
@Input('=?') public value: string;
@Input('=?') public seconds: number;
private min_s: number;
private max_s: number;
constructor (@Inject('$scope') private $scope: ng.IScope) {
}
public $onInit(): void {
// TODO: replace this.
this.$scope.$watch(() => this.seconds, this.updateValue.bind(this));
this.refresh();
}
public $onChanges(changes: ng.IOnChangesObject): void {
this.refresh();
}
private updateValue(): void {
this.value = this.seconds + 's';
}
private refresh(): void {
this.min_s = this.toSeconds(this.min || '0s');
this.max_s = this.toSeconds(this.max || '1h');
if (this.value) {
this.seconds = this.toSeconds(this.value || '0s')
};
}
private durationExplanation(durationSeconds: string): string {
return moment.duration(parseInt(durationSeconds), 's').humanize();
}
private toSeconds(durationStr: string): number {
var number = durationStr.substring(0, durationStr.length - 1);
var suffix = durationStr.substring(durationStr.length - 1);
return moment.duration(parseInt(number), <moment.unitOfTime.Base>suffix).asSeconds();
}
}

View file

@ -0,0 +1,22 @@
<div class="time-machine-settings-element" ng-if="$ctrl.maximum != '0s' && $ctrl.Features.CHANGE_TAG_EXPIRATION">
<table class="co-list-table">
<tr>
<td>Time Machine:</td>
<td ng-show="$ctrl.updating">
<div class="cor-loader-inline"></div>
</td>
<td ng-show="!$ctrl.updating" ng-if="$ctrl.Config.TAG_EXPIRATION_OPTIONS.length">
<select class="form-control" ng-model="$ctrl.current_s"
ng-options="$ctrl.getSeconds(o) as $ctrl.durationExplanation($ctrl.getSeconds(o)) for o in $ctrl.Config.TAG_EXPIRATION_OPTIONS">
</select>
<div class="help-text">The amount of time, after a tag is deleted, that the tag is accessible in time machine before being garbage collected.</div>
<a class="btn btn-primary save-expiration" ng-disabled="$ctrl.current_s == $ctrl.initial_s" ng-click="$ctrl.updateExpiration()">Save Expiration Time</a>
</td>
<td ng-show="!$ctrl.updating" ng-if="!$ctrl.Config.TAG_EXPIRATION_OPTIONS.length">
<div>{{ $ctrl.durationExplanation($ctrl.current_s) }}</div>
<div class="help-text">The amount of time, after a tag is deleted, that the tag is accessible in time machine before being garbage collected.</div>
</td>
</tr>
</table>
</div>

View file

@ -0,0 +1,72 @@
import { Input, Component, Inject } from 'ng-metadata/core';
import * as moment from "moment";
/**
* A component that displays settings for a namespace for time machine.
*/
@Component({
selector: 'timeMachineSettings',
templateUrl: '/static/js/directives/ui/time-machine-settings/time-machine-settings.component.html'
})
export class TimeMachineSettingsComponent implements ng.IComponentController {
@Input('<') public user: any;
@Input('<') public organization: any;
private initial_s: number;
private current_s: number;
private updating: boolean;
constructor (@Inject('Config') private Config: any, @Inject('ApiService') private ApiService: any,
@Inject('Features') private Features: any) {
this.current_s = 0;
this.initial_s = 0;
this.updating = false;
}
public $onInit(): void {
if (this.user) {
this.current_s = this.user.tag_expiration_s;
this.initial_s = this.user.tag_expiration_s;
} else if (this.organization) {
this.current_s = this.organization.tag_expiration_s;
this.initial_s = this.organization.tag_expiration_s;
}
}
private getSeconds(durationStr: string): number {
if (!durationStr) {
return 0;
}
var number = durationStr.substring(0, durationStr.length - 1);
var suffix = durationStr.substring(durationStr.length - 1);
return moment.duration(parseInt(number), <moment.unitOfTime.Base>suffix).asSeconds();
}
private durationExplanation(durationSeconds: number): string {
return moment.duration(durationSeconds || 0, 's').humanize();
}
private updateExpiration(): void {
this.updating = true;
var errorDisplay = this.ApiService.errorDisplay('Could not update time machine setting', () => {
this.updating = false;
})
var method = (this.user ? this.ApiService.changeUserDetails :
this.ApiService.changeOrganizationDetails);
var params = {};
if (this.organization) {
params['orgname'] = this.organization.name;
}
var data = {
'tag_expiration_s': this.current_s,
};
method(data, params).then((resp) => {
this.updating = false;
this.initial_s = this.current_s;
}, errorDisplay);
}
}

View file

@ -16,6 +16,8 @@ import { CorTableColumn } from './directives/ui/cor-table/cor-table-col.componen
import { ChannelIconComponent } from './directives/ui/channel-icon/channel-icon.component';
import { TagSigningDisplayComponent } from './directives/ui/tag-signing-display/tag-signing-display.component';
import { RepositorySigningConfigComponent } from './directives/ui/repository-signing-config/repository-signing-config.component';
import { TimeMachineSettingsComponent } from './directives/ui/time-machine-settings/time-machine-settings.component';
import { DurationInputComponent } from './directives/ui/duration-input/duration-input.component';
import { BuildServiceImpl } from './services/build/build.service.impl';
import { AvatarServiceImpl } from './services/avatar/avatar.service.impl';
import { DockerfileServiceImpl } from './services/dockerfile/dockerfile.service.impl';
@ -48,6 +50,8 @@ import { QuayRequireDirective } from './directives/structural/quay-require/quay-
QuayRequireDirective,
TagSigningDisplayComponent,
RepositorySigningConfigComponent,
TimeMachineSettingsComponent,
DurationInputComponent,
],
providers: [
ViewArrayImpl,

View file

@ -119,6 +119,8 @@
</table>
<div class="delete-namespace-view" subscription-status="subscriptionStatus" organization="organization" namespace-title="organization"></div>
<time-machine-settings organization="organization"></time-machine-settings>
</div>
<!-- Billing Information -->

View file

@ -135,6 +135,8 @@
</table>
<div class="delete-namespace-view" subscription-status="subscriptionStatus" user="context.viewuser" namespace-title="account" quay-show="Config.AUTHENTICATION_TYPE == 'Database'"></div>
<time-machine-settings user="context.viewuser"></time-machine-settings>
</div>
<!-- Billing Information -->

View file

@ -97,3 +97,7 @@ class TestConfig(DefaultConfig):
FEATURE_APP_REGISTRY = True
FEATURE_TEAM_SYNCING = True
FEATURE_CHANGE_TAG_EXPIRATION = True
TAG_EXPIRATION_OPTIONS = ['0s', '1s', '1d', '1w', '2w', '4w']

View file

@ -17,6 +17,8 @@ def add_enterprise_config_defaults(config_obj, current_secret_key, hostname):
config_obj['FEATURE_USER_CREATION'] = config_obj.get('FEATURE_USER_CREATION', True)
config_obj['FEATURE_ANONYMOUS_ACCESS'] = config_obj.get('FEATURE_ANONYMOUS_ACCESS', True)
config_obj['FEATURE_REQUIRE_TEAM_INVITE'] = config_obj.get('FEATURE_REQUIRE_TEAM_INVITE', True)
config_obj['FEATURE_CHANGE_TAG_EXPIRATION'] = config_obj.get('FEATURE_CHANGE_TAG_EXPIRATION',
True)
# Default features that are off.
config_obj['FEATURE_MAILING'] = config_obj.get('FEATURE_MAILING', False)
@ -40,6 +42,11 @@ def add_enterprise_config_defaults(config_obj, current_secret_key, hostname):
config_obj['SECURITY_SCANNER_ISSUER_NAME'] = config_obj.get(
'SECURITY_SCANNER_ISSUER_NAME', 'security_scanner')
# Default time machine config.
config_obj['TAG_EXPIRATION_OPTIONS'] = config_obj.get('TAG_EXPIRATION_OPTIONS',
['0s', '1d', '1w', '2w', '4w'])
config_obj['DEFAULT_TAG_EXPIRATION'] = config_obj.get('DEFAULT_TAG_EXPIRATION', '2w')
# Default mail setings.
config_obj['MAIL_USE_TLS'] = config_obj.get('MAIL_USE_TLS', True)
config_obj['MAIL_PORT'] = config_obj.get('MAIL_PORT', 587)

View file

@ -19,6 +19,7 @@ from util.config.validators.validate_bitbucket_trigger import BitbucketTriggerVa
from util.config.validators.validate_gitlab_trigger import GitLabTriggerValidator
from util.config.validators.validate_github import GitHubLoginValidator, GitHubTriggerValidator
from util.config.validators.validate_oidc import OIDCLoginValidator
from util.config.validators.validate_timemachine import TimeMachineValidator
logger = logging.getLogger(__name__)
@ -53,6 +54,7 @@ VALIDATORS = {
SecurityScannerValidator.name: SecurityScannerValidator.validate,
BittorrentValidator.name: BittorrentValidator.validate,
OIDCLoginValidator.name: OIDCLoginValidator.validate,
TimeMachineValidator.name: TimeMachineValidator.validate,
}
def validate_service_for_config(service, config, password=None):

View file

@ -0,0 +1,23 @@
import pytest
from util.config.validators import ConfigValidationException
from util.config.validators.validate_timemachine import TimeMachineValidator
from util.morecollections import AttrDict
@pytest.mark.parametrize('default_exp,options,expected_exception', [
('2d', ['1w', '2d'], None),
('2d', ['1w'], 'Default expiration must be in expiration options set'),
('2d', ['2d', '1M'], 'Invalid tag expiration option: 1M'),
])
def test_validate(default_exp, options, expected_exception):
config = {}
config['DEFAULT_TAG_EXPIRATION'] = default_exp
config['TAG_EXPIRATION_OPTIONS'] = options
if expected_exception is not None:
with pytest.raises(ConfigValidationException) as cve:
TimeMachineValidator.validate(config, None, None)
assert str(cve.value) == str(expected_exception)
else:
TimeMachineValidator.validate(config, None, None)

View file

@ -0,0 +1,25 @@
import logging
from util.config.validators import BaseValidator, ConfigValidationException
from util.timedeltastring import convert_to_timedelta
logger = logging.getLogger(__name__)
class TimeMachineValidator(BaseValidator):
name = "time-machine"
@classmethod
def validate(cls, config, user, user_password):
try:
convert_to_timedelta(config['DEFAULT_TAG_EXPIRATION']).total_seconds()
except ValueError as ve:
raise ConfigValidationException('Invalid default expiration: %s' % ve.message)
if not config['DEFAULT_TAG_EXPIRATION'] in config['TAG_EXPIRATION_OPTIONS']:
raise ConfigValidationException('Default expiration must be in expiration options set')
for ts in config['TAG_EXPIRATION_OPTIONS']:
try:
convert_to_timedelta(ts)
except ValueError as ve:
raise ConfigValidationException('Invalid tag expiration option: %s' % ts)

View file

@ -15,6 +15,7 @@ def convert_to_timedelta(time_val):
m Minutes '5m' -> 5 Minutes
h Hours '24h' -> 24 Hours
d Days '7d' -> 7 Days
w Weeks '2w' -> 2 weeks
========= ======= ===================
Examples::
@ -37,5 +38,7 @@ def convert_to_timedelta(time_val):
return timedelta(hours=num)
elif time_val.endswith('d'):
return timedelta(days=num)
elif time_val.endswith('w'):
return timedelta(days=num*7)
else:
raise ValueError('Unknown suffix on timedelta: %s' % time_val)