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)