diff --git a/config.py b/config.py index d5febdee3..8ca7ea7d6 100644 --- a/config.py +++ b/config.py @@ -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 diff --git a/data/model/test/test_user.py b/data/model/test/test_user.py new file mode 100644 index 000000000..af87f1a4b --- /dev/null +++ b/data/model/test/test_user.py @@ -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 diff --git a/data/model/user.py b/data/model/user.py index db5d2f587..a1fb0d81e 100644 --- a/data/model/user.py +++ b/data/model/user.py @@ -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() diff --git a/endpoints/api/organization.py b/endpoints/api/organization.py index 9411d8edd..9bd0f5126 100644 --- a/endpoints/api/organization.py +++ b/endpoints/api/organization.py @@ -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) diff --git a/endpoints/api/test/test_organization.py b/endpoints/api/test/test_organization.py new file mode 100644 index 000000000..004d6a8ee --- /dev/null +++ b/endpoints/api/test/test_organization.py @@ -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) diff --git a/endpoints/api/user.py b/endpoints/api/user.py index e9afd30f0..093824e13 100644 --- a/endpoints/api/user.py +++ b/endpoints/api/user.py @@ -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): diff --git a/static/css/directives/ui/duration-input.css b/static/css/directives/ui/duration-input.css new file mode 100644 index 000000000..9f632d604 --- /dev/null +++ b/static/css/directives/ui/duration-input.css @@ -0,0 +1,5 @@ +.duration-input-element input { + display: inline-block; + width: 200px; + margin-right: 10px; +} diff --git a/static/css/directives/ui/time-machine-settings.css b/static/css/directives/ui/time-machine-settings.css new file mode 100644 index 000000000..451838ccb --- /dev/null +++ b/static/css/directives/ui/time-machine-settings.css @@ -0,0 +1,3 @@ +.time-machine-settings-element .btn { + margin-top: 10px; +} \ No newline at end of file diff --git a/static/directives/config/config-list-field.html b/static/directives/config/config-list-field.html index e15e82833..9918e9a07 100644 --- a/static/directives/config/config-list-field.html +++ b/static/directives/config/config-list-field.html @@ -10,6 +10,7 @@ No {{ itemTitle }}s defined
diff --git a/static/directives/config/config-setup-tool.html b/static/directives/config/config-setup-tool.html index 715eaa62a..f93738089 100644 --- a/static/directives/config/config-setup-tool.html +++ b/static/directives/config/config-setup-tool.html @@ -177,6 +177,53 @@ + +
+
+ Time Machine +
+
+
+

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. +

+
+ + + + + + + + + + + + + +
Default expiration period: + +
+ The default tag expiration period for all namespaces (users and organizations). Must be expressed in a duration string form: 30m, 1h, 1d, 2w. +
+
Allow users to select expiration: +
+ Enable Expiration Configuration +
+ If enabled, users will be able to select the tag expiration duration for the namespace(s) they + administrate, from the configured list of options. +
+
+
Selectable expiration periods: + +
+
+
+
diff --git a/static/js/core-config-setup.js b/static/js/core-config-setup.js index aac0fd78d..d483e3d3e 100644 --- a/static/js/core-config-setup.js +++ b/static/js/core-config-setup.js @@ -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); diff --git a/static/js/directives/range-slider.js b/static/js/directives/range-slider.js new file mode 100644 index 000000000..c65fc0765 --- /dev/null +++ b/static/js/directives/range-slider.js @@ -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: '', + 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(); + } + }; +} +]); \ No newline at end of file diff --git a/static/js/directives/ui/duration-input/duration-input.component.html b/static/js/directives/ui/duration-input/duration-input.component.html new file mode 100644 index 000000000..b2d031e63 --- /dev/null +++ b/static/js/directives/ui/duration-input/duration-input.component.html @@ -0,0 +1,4 @@ +
+ + {{ $ctrl.durationExplanation($ctrl.seconds) }} +
\ No newline at end of file diff --git a/static/js/directives/ui/duration-input/duration-input.component.ts b/static/js/directives/ui/duration-input/duration-input.component.ts new file mode 100644 index 000000000..937a1e456 --- /dev/null +++ b/static/js/directives/ui/duration-input/duration-input.component.ts @@ -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), suffix).asSeconds(); + } +} diff --git a/static/js/directives/ui/time-machine-settings/time-machine-settings.component.html b/static/js/directives/ui/time-machine-settings/time-machine-settings.component.html new file mode 100644 index 000000000..a4be8b0f8 --- /dev/null +++ b/static/js/directives/ui/time-machine-settings/time-machine-settings.component.html @@ -0,0 +1,22 @@ +
+ + + + + + + +
Time Machine: +
+
+ + +
The amount of time, after a tag is deleted, that the tag is accessible in time machine before being garbage collected.
+ Save Expiration Time +
+
{{ $ctrl.durationExplanation($ctrl.current_s) }}
+
The amount of time, after a tag is deleted, that the tag is accessible in time machine before being garbage collected.
+
+
\ No newline at end of file diff --git a/static/js/directives/ui/time-machine-settings/time-machine-settings.component.ts b/static/js/directives/ui/time-machine-settings/time-machine-settings.component.ts new file mode 100644 index 000000000..092ec6a16 --- /dev/null +++ b/static/js/directives/ui/time-machine-settings/time-machine-settings.component.ts @@ -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), 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); + } +} diff --git a/static/js/quay.module.ts b/static/js/quay.module.ts index 416429622..2d6b2a0a7 100644 --- a/static/js/quay.module.ts +++ b/static/js/quay.module.ts @@ -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, diff --git a/static/partials/org-view.html b/static/partials/org-view.html index c08e6a4d5..d750ac66c 100644 --- a/static/partials/org-view.html +++ b/static/partials/org-view.html @@ -119,6 +119,8 @@
+ +
diff --git a/static/partials/user-view.html b/static/partials/user-view.html index 7f7769073..5bc311935 100644 --- a/static/partials/user-view.html +++ b/static/partials/user-view.html @@ -135,6 +135,8 @@
+ +
diff --git a/test/testconfig.py b/test/testconfig.py index ab2a238b9..e0d1e360f 100644 --- a/test/testconfig.py +++ b/test/testconfig.py @@ -64,7 +64,7 @@ class TestConfig(DefaultConfig): SECURITY_SCANNER_API_VERSION = 'v1' SECURITY_SCANNER_ENGINE_VERSION_TARGET = 1 SECURITY_SCANNER_API_TIMEOUT_SECONDS = 1 - + FEATURE_SIGNING = True SIGNING_ENGINE = 'gpg2' @@ -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'] + diff --git a/util/config/configutil.py b/util/config/configutil.py index e1105d9f7..ee39feba3 100644 --- a/util/config/configutil.py +++ b/util/config/configutil.py @@ -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) diff --git a/util/config/validator.py b/util/config/validator.py index ae3f02be8..360942b16 100644 --- a/util/config/validator.py +++ b/util/config/validator.py @@ -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): diff --git a/util/config/validators/test/test_validate_timemachine.py b/util/config/validators/test/test_validate_timemachine.py new file mode 100644 index 000000000..843e4955b --- /dev/null +++ b/util/config/validators/test/test_validate_timemachine.py @@ -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) diff --git a/util/config/validators/validate_timemachine.py b/util/config/validators/validate_timemachine.py new file mode 100644 index 000000000..ff96f9de1 --- /dev/null +++ b/util/config/validators/validate_timemachine.py @@ -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) diff --git a/util/timedeltastring.py b/util/timedeltastring.py index 99e459356..3f0c5c368 100644 --- a/util/timedeltastring.py +++ b/util/timedeltastring.py @@ -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)